このコミットが含まれているのは:
2026-07-04 15:41:37 +09:00
コミット 88b0195e03
4個のファイルの変更149行の追加42行の削除
+99
ファイルの表示
@@ -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<Parameters<typeof SidebarComponent>[0]> = {},
containerWidth = 800,
) => {
Object.defineProperty (HTMLElement.prototype, 'clientWidth', {
configurable: true,
get () {
return this.hasAttribute ('data-sidebar-container') ? containerWidth : 0
},
})
const rendered = render (
<HelmetProvider>
<div data-sidebar-container>
<SidebarComponent sidebarKey="test-sidebar" {...props}>
Sidebar body
</SidebarComponent>
</div>
</HelmetProvider>)
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')
})
})
+4 -11
ファイルの表示
@@ -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 = ( const readStoredSidebarWidth = (
sidebarKey: string, sidebarKey: string,
side: SidebarSide, side: SidebarSide): number => {
containerWidth: number): number => {
if (typeof window === 'undefined') if (typeof window === 'undefined')
return DEFAULT_SIDEBAR_WIDTH return DEFAULT_SIDEBAR_WIDTH
const value = window.localStorage.getItem (storageKeyForSidebar (sidebarKey, side)) const value = window.localStorage.getItem (storageKeyForSidebar (sidebarKey, side))
const width = value == null ? Number.NaN : Number (value) const width = value == null ? Number.NaN : Number (value)
return Number.isFinite (width) return Number.isFinite (width) ? width : DEFAULT_SIDEBAR_WIDTH
? clampSidebarWidth (width, containerWidth)
: clampSidebarWidth (DEFAULT_SIDEBAR_WIDTH, containerWidth)
} }
@@ -101,7 +94,7 @@ const SidebarComponent: FC<Props> = ({
typeof window === 'undefined' typeof window === 'undefined'
? DEFAULT_SIDEBAR_WIDTH ? DEFAULT_SIDEBAR_WIDTH
: getClampedWidth ( : getClampedWidth (
readStoredSidebarWidth (sidebarKey, side, window.innerWidth), readStoredSidebarWidth (sidebarKey, side),
window.innerWidth))) window.innerWidth)))
const [resizing, setResizing] = useState (false) const [resizing, setResizing] = useState (false)
const widthRef = useRef (width) const widthRef = useRef (width)
@@ -256,7 +249,7 @@ const SidebarComponent: FC<Props> = ({
useEffect (() => { useEffect (() => {
const containerWidth = getContainerWidth () const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth ( const nextWidth = getClampedWidth (
readStoredSidebarWidth (sidebarKey, side, containerWidth), readStoredSidebarWidth (sidebarKey, side),
containerWidth) containerWidth)
widthRef.current = nextWidth widthRef.current = nextWidth
setWidth (nextWidth) setWidth (nextWidth)
+22 -14
ファイルの表示
@@ -105,6 +105,20 @@ const tagSection = (): HTMLElement =>
screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')! screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')!
const mockDefaultApi = () => { 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) => { api.apiGet.mockImplementation ((path: string) => {
switch (path) switch (path)
{ {
@@ -112,6 +126,10 @@ const mockDefaultApi = () => {
return Promise.resolve (theatre) return Promise.resolve (theatre)
case '/theatres/7/comments': case '/theatres/7/comments':
if (commentsFetched)
return Promise.resolve ([])
commentsFetched = true
return Promise.resolve ([ return Promise.resolve ([
buildTheatreComment ({ buildTheatreComment ({
theatreId: 7, theatreId: 7,
@@ -136,21 +154,10 @@ const mockDefaultApi = () => {
switch (path) switch (path)
{ {
case '/theatres/7/watching': case '/theatres/7/watching':
return Promise.resolve (buildTheatreInfo ({ return Promise.resolve (currentTheatreInfo)
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 1_000,
watchingUsers,
skipVote: {
votesCount: 0,
requiredCount: 2,
watchingUsersCount: watchingUsers.length,
voted: false,
},
}))
case '/theatres/7/skip_vote': case '/theatres/7/skip_vote':
return Promise.resolve (buildTheatreInfo ({ currentTheatreInfo = buildTheatreInfo ({
postId: currentPost.id, postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z', postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 2_000, postElapsedMs: 2_000,
@@ -161,7 +168,8 @@ const mockDefaultApi = () => {
watchingUsersCount: watchingUsers.length, watchingUsersCount: watchingUsers.length,
voted: true, voted: true,
}, },
})) })
return Promise.resolve (currentTheatreInfo)
default: default:
return Promise.reject (new Error (`Unexpected PUT ${ path }`)) return Promise.reject (new Error (`Unexpected PUT ${ path }`))
+8 -1
ファイルの表示
@@ -266,6 +266,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => { const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => {
theatreInfoReceivedAtRef.current = performance.now () theatreInfoReceivedAtRef.current = performance.now ()
theatreInfoRef.current = nextInfo
setTheatreInfo (nextInfo) setTheatreInfo (nextInfo)
}, []) }, [])
@@ -406,6 +407,9 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
setComments (prev => [...newComments, ...prev]) setComments (prev => [...newComments, ...prev])
} }
if (loadingRef.current)
return
const currentInfo = theatreInfoRef.current const currentInfo = theatreInfoRef.current
const ended = const ended =
currentInfo.hostFlg currentInfo.hostFlg
@@ -427,8 +431,9 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
return return
} }
const watchingRequestedAt = performance.now ()
const nextInfo = await apiPut<TheatreInfo> (`/theatres/${ id }/watching`) const nextInfo = await apiPut<TheatreInfo> (`/theatres/${ id }/watching`)
if (!(cancelled)) if (!(cancelled) && watchingRequestedAt >= theatreInfoReceivedAtRef.current)
applyTheatreInfo (nextInfo) applyTheatreInfo (nextInfo)
} }
catch (error) catch (error)
@@ -557,6 +562,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
if (!(id) || !(post)) if (!(id) || !(post))
return return
loadingRef.current = true
setLoading (true) setLoading (true)
try try
{ {
@@ -585,6 +591,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
} }
finally finally
{ {
loadingRef.current = false
setLoading (false) setLoading (false)
} }
} }