diff --git a/frontend/src/components/layout/SidebarComponent.test.tsx b/frontend/src/components/layout/SidebarComponent.test.tsx new file mode 100644 index 0000000..7b55142 --- /dev/null +++ b/frontend/src/components/layout/SidebarComponent.test.tsx @@ -0,0 +1,99 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { HelmetProvider } from 'react-helmet-async' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import SidebarComponent from '@/components/layout/SidebarComponent' + + +const sidebarWidth = (element: Element | null): string => + (element as HTMLElement).style.getPropertyValue ('--sidebar-width') + + +const sidebarRoot = (container: HTMLElement): Element => + container.querySelector ('[style*="--sidebar-width"]')! + + +const renderSidebar = ( + props: Partial[0]> = {}, + containerWidth = 800, +) => { + Object.defineProperty (HTMLElement.prototype, 'clientWidth', { + configurable: true, + get () { + return this.hasAttribute ('data-sidebar-container') ? containerWidth : 0 + }, + }) + + const rendered = render ( + +
+ + Sidebar body + +
+
) + + return rendered +} + + +describe ('SidebarComponent', () => { + beforeEach (() => { + Object.defineProperty (window, 'innerWidth', { + configurable: true, + value: 1_000, + }) + + Object.defineProperty (HTMLElement.prototype, 'setPointerCapture', { + configurable: true, + value: vi.fn (), + }) + Object.defineProperty (HTMLElement.prototype, 'releasePointerCapture', { + configurable: true, + value: vi.fn (), + }) + Object.defineProperty (HTMLElement.prototype, 'hasPointerCapture', { + configurable: true, + value: vi.fn (() => true), + }) + + vi.spyOn (HTMLElement.prototype, 'setPointerCapture').mockImplementation (() => { + ; + }) + vi.spyOn (HTMLElement.prototype, 'releasePointerCapture').mockImplementation (() => { + ; + }) + vi.spyOn (HTMLElement.prototype, 'hasPointerCapture').mockReturnValue (true) + }) + + it ('uses maxWidth without applying the container main-width reserve twice', async () => { + localStorage.setItem ('sidebar:test-sidebar:left:width', '700') + const onWidthChange = vi.fn () + + const { container } = renderSidebar ({ maxWidth: 700, onWidthChange }, 800) + + await waitFor (() => { + expect (sidebarWidth (sidebarRoot (container))).toBe ('700px') + }) + expect (onWidthChange).toHaveBeenLastCalledWith (700) + }) + + it ('resets width and removes only its storage key on double click', async () => { + localStorage.setItem ('sidebar:test-sidebar:right:width', '420') + localStorage.setItem ('sidebar:other-sidebar:right:width', '480') + + const { container } = renderSidebar ({ side: 'right' }, 900) + + await waitFor (() => { + expect (sidebarWidth (sidebarRoot (container))).toBe ('420px') + }) + + fireEvent.doubleClick (screen.getByRole ('separator', { name: 'サイドバー幅' })) + + await waitFor (() => { + expect (sidebarWidth (sidebarRoot (container))).toBe ('256px') + }) + expect (localStorage.getItem ('sidebar:test-sidebar:right:width')).toBeNull () + expect (localStorage.getItem ('sidebar:other-sidebar:right:width')).toBe ('480') + }) +}) diff --git a/frontend/src/components/layout/SidebarComponent.tsx b/frontend/src/components/layout/SidebarComponent.tsx index 2037066..a96b7a2 100644 --- a/frontend/src/components/layout/SidebarComponent.tsx +++ b/frontend/src/components/layout/SidebarComponent.tsx @@ -47,23 +47,16 @@ const getSidebarMaxWidth = (containerWidth: number): number => { } -const clampSidebarWidth = (width: number, containerWidth: number): number => - Math.min (getSidebarMaxWidth (containerWidth), Math.max (MIN_SIDEBAR_WIDTH, width)) - - const readStoredSidebarWidth = ( sidebarKey: string, - side: SidebarSide, - containerWidth: number): number => { + side: SidebarSide): number => { if (typeof window === 'undefined') return DEFAULT_SIDEBAR_WIDTH const value = window.localStorage.getItem (storageKeyForSidebar (sidebarKey, side)) const width = value == null ? Number.NaN : Number (value) - return Number.isFinite (width) - ? clampSidebarWidth (width, containerWidth) - : clampSidebarWidth (DEFAULT_SIDEBAR_WIDTH, containerWidth) + return Number.isFinite (width) ? width : DEFAULT_SIDEBAR_WIDTH } @@ -101,7 +94,7 @@ const SidebarComponent: FC = ({ typeof window === 'undefined' ? DEFAULT_SIDEBAR_WIDTH : getClampedWidth ( - readStoredSidebarWidth (sidebarKey, side, window.innerWidth), + readStoredSidebarWidth (sidebarKey, side), window.innerWidth))) const [resizing, setResizing] = useState (false) const widthRef = useRef (width) @@ -256,7 +249,7 @@ const SidebarComponent: FC = ({ useEffect (() => { const containerWidth = getContainerWidth () const nextWidth = getClampedWidth ( - readStoredSidebarWidth (sidebarKey, side, containerWidth), + readStoredSidebarWidth (sidebarKey, side), containerWidth) widthRef.current = nextWidth setWidth (nextWidth) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx index 2c87aba..745ea4e 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx @@ -105,6 +105,20 @@ const tagSection = (): HTMLElement => screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')! const mockDefaultApi = () => { + let commentsFetched = false + let currentTheatreInfo = buildTheatreInfo ({ + postId: currentPost.id, + postStartedAt: '2026-01-02T03:04:05.000Z', + postElapsedMs: 1_000, + watchingUsers, + skipVote: { + votesCount: 0, + requiredCount: 2, + watchingUsersCount: watchingUsers.length, + voted: false, + }, + }) + api.apiGet.mockImplementation ((path: string) => { switch (path) { @@ -112,13 +126,17 @@ const mockDefaultApi = () => { return Promise.resolve (theatre) case '/theatres/7/comments': + if (commentsFetched) + return Promise.resolve ([]) + + commentsFetched = true return Promise.resolve ([ - buildTheatreComment ({ - theatreId: 7, - no: 2, - user: { id: 1, name: 'tester' }, - content: '視聴コメント', - }), + buildTheatreComment ({ + theatreId: 7, + no: 2, + user: { id: 1, name: 'tester' }, + content: '視聴コメント', + }), ]) case '/theatres/7/programmes': @@ -136,32 +154,22 @@ const mockDefaultApi = () => { switch (path) { case '/theatres/7/watching': - return Promise.resolve (buildTheatreInfo ({ - postId: currentPost.id, - postStartedAt: '2026-01-02T03:04:05.000Z', - postElapsedMs: 1_000, - watchingUsers, - skipVote: { - votesCount: 0, - requiredCount: 2, - watchingUsersCount: watchingUsers.length, - voted: false, - }, - })) + return Promise.resolve (currentTheatreInfo) case '/theatres/7/skip_vote': - return Promise.resolve (buildTheatreInfo ({ - postId: currentPost.id, - postStartedAt: '2026-01-02T03:04:05.000Z', - postElapsedMs: 2_000, - watchingUsers, - skipVote: { - votesCount: 1, - requiredCount: 2, - watchingUsersCount: watchingUsers.length, - voted: true, - }, - })) + currentTheatreInfo = buildTheatreInfo ({ + postId: currentPost.id, + postStartedAt: '2026-01-02T03:04:05.000Z', + postElapsedMs: 2_000, + watchingUsers, + skipVote: { + votesCount: 1, + requiredCount: 2, + watchingUsersCount: watchingUsers.length, + voted: true, + }, + }) + return Promise.resolve (currentTheatreInfo) default: return Promise.reject (new Error (`Unexpected PUT ${ path }`)) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index 6efb2cc..c414048 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -266,6 +266,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => { theatreInfoReceivedAtRef.current = performance.now () + theatreInfoRef.current = nextInfo setTheatreInfo (nextInfo) }, []) @@ -406,6 +407,9 @@ const TheatreDetailPage: FC = ({ user }: Props) => { setComments (prev => [...newComments, ...prev]) } + if (loadingRef.current) + return + const currentInfo = theatreInfoRef.current const ended = currentInfo.hostFlg @@ -427,8 +431,9 @@ const TheatreDetailPage: FC = ({ user }: Props) => { return } + const watchingRequestedAt = performance.now () const nextInfo = await apiPut (`/theatres/${ id }/watching`) - if (!(cancelled)) + if (!(cancelled) && watchingRequestedAt >= theatreInfoReceivedAtRef.current) applyTheatreInfo (nextInfo) } catch (error) @@ -557,6 +562,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { if (!(id) || !(post)) return + loadingRef.current = true setLoading (true) try { @@ -585,6 +591,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { } finally { + loadingRef.current = false setLoading (false) } }