import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { Route, Routes } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' import { buildPost, buildTheatre, buildTheatreComment, buildTheatreInfo, buildTheatrePostSelectionWeights, buildTheatreProgramme, buildUser } from '@/test/factories' import { renderWithProviders } from '@/test/render' import type { ReactNode } from 'react' const api = vi.hoisted (() => ({ apiDelete: vi.fn (), apiGet: vi.fn (), apiPatch: vi.fn (), apiPost: vi.fn (), apiPut: vi.fn (), isApiError: vi.fn (() => false), })) const postsApi = vi.hoisted (() => ({ fetchPost: vi.fn (), })) const dialogue = vi.hoisted (() => ({ confirm: vi.fn (), })) const postEmbed = vi.hoisted (() => ({ props: vi.fn (), play: vi.fn (), seek: vi.fn (), })) vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/components/dialogues/DialogueProvider', () => ({ useDialogue: () => dialogue, })) vi.mock ('@/components/PostEmbed', () => ({ default: (props: { ref?: { current: unknown } post: { title: string | null; url: string } }) => { postEmbed.props (props) if (props.ref) props.ref.current = { play: postEmbed.play, seek: postEmbed.seek, } return
Embed:{props.post.title || props.post.url}
}, })) vi.mock ('@/components/PostEditForm', () => ({ default: () =>
Post edit form
, })) vi.mock ('framer-motion', () => ({ motion: { aside: ({ children }: { children?: ReactNode }) => , div: ({ children }: { children?: ReactNode }) =>
{children}
, main: ({ children }: { children?: ReactNode }) =>
{children}
, }, })) const currentPost = buildPost ({ id: 10, title: '上映中の投稿', url: 'https://www.nicovideo.jp/watch/sm10', }) const theatre = buildTheatre ({ id: 7, name: '上映室' }) const programme = buildTheatreProgramme ({ theatreId: 7, position: 3, post: currentPost, }) const weights = buildTheatrePostSelectionWeights ({ lightestPosts: [{ post: currentPost, penalty: 2, weight: 0.5, tags: [], }], }) const renderPage = (user = buildUser ({ id: 1, role: 'member' })) => renderWithProviders ( }/> , { route: '/theatres/7' }, ) const mockDefaultApi = () => { api.apiGet.mockImplementation ((path: string) => { switch (path) { case '/theatres/7': return Promise.resolve (theatre) case '/theatres/7/comments': return Promise.resolve ([ buildTheatreComment ({ theatreId: 7, no: 2, user: { id: 1, name: 'tester' }, content: '視聴コメント', }), ]) case '/theatres/7/programmes': return Promise.resolve ([programme]) case '/theatres/7/post_selection_weights': return Promise.resolve (weights) default: return Promise.reject (new Error (`Unexpected GET ${ path }`)) } }) api.apiPut.mockImplementation ((path: string) => { switch (path) { case '/theatres/7/watching': return Promise.resolve (buildTheatreInfo ({ postId: currentPost.id, postStartedAt: '2026-01-02T03:04:05.000Z', postElapsedMs: 1_000, watchingUsers: [{ id: 1, name: 'tester' }], skipVote: { votesCount: 0, requiredCount: 2, watchingUsersCount: 1, voted: false, }, })) case '/theatres/7/skip_vote': return Promise.resolve (buildTheatreInfo ({ postId: currentPost.id, postStartedAt: '2026-01-02T03:04:05.000Z', postElapsedMs: 2_000, watchingUsers: [{ id: 1, name: 'tester' }], skipVote: { votesCount: 1, requiredCount: 2, watchingUsersCount: 1, voted: true, }, })) default: return Promise.reject (new Error (`Unexpected PUT ${ path }`)) } }) api.apiDelete.mockResolvedValue (undefined) api.apiPatch.mockResolvedValue (undefined) api.apiPost.mockResolvedValue (undefined) postsApi.fetchPost.mockResolvedValue (currentPost) dialogue.confirm.mockResolvedValue (true) } describe ('TheatreDetailPage', () => { beforeEach (() => { vi.useRealTimers () vi.clearAllMocks () mockDefaultApi () }) it ('loads theatre state, comments, current post, programme history, and weights', async () => { renderPage () expect (await screen.findByText ('上映会場『上映室』')).toBeInTheDocument () expect (await screen.findByText ('Embed:上映中の投稿')).toBeInTheDocument () expect (screen.getAllByText ('視聴コメント')[0]).toBeInTheDocument () expect (screen.getAllByText ('上映中の投稿')[0]).toBeInTheDocument () expect (screen.getByText ('penalty 2')).toBeInTheDocument () await waitFor (() => { expect (postsApi.fetchPost).toHaveBeenCalledWith ('10') }) }) it ('votes to skip the current post', async () => { renderPage () await screen.findByText ('Embed:上映中の投稿') fireEvent.click (screen.getByRole ('button', { name: 'スキップ 0 / 2' })) await waitFor (() => { expect (api.apiPut).toHaveBeenCalledWith ( '/theatres/7/skip_vote', { post_id: 10 }, ) }) expect (await screen.findByRole ('button', { name: 'スキップ取消 1 / 2' })) .toBeInTheDocument () }) it ('does not seek to zero while applying video length from the player', async () => { api.apiPut.mockImplementation ((path: string) => { switch (path) { case '/theatres/7/watching': return Promise.resolve (buildTheatreInfo ({ hostFlg: true, postId: currentPost.id, postStartedAt: '2026-01-02T03:04:05.000Z', postElapsedMs: 7_000, watchingUsers: [{ id: 1, name: 'tester' }], skipVote: { votesCount: 0, requiredCount: 2, watchingUsersCount: 1, voted: false, }, })) default: return Promise.reject (new Error (`Unexpected PUT ${ path }`)) } }) renderPage () await screen.findByText ('Embed:上映中の投稿') const props = postEmbed.props.mock.calls.at (-1)![0] act (() => { props.onVideoReady (120_000) props.onPlaybackChange (0) }) const seekMs = postEmbed.seek.mock.calls[0][0] expect (seekMs).toBeGreaterThanOrEqual (7_000) expect (seekMs).toBeLessThan (10_000) expect (postEmbed.seek).not.toHaveBeenCalledWith (0) }) it ('does not advance host post while video length is unknown', async () => { api.apiPut.mockImplementation ((path: string) => { switch (path) { case '/theatres/7/watching': return Promise.resolve (buildTheatreInfo ({ hostFlg: true, postId: currentPost.id, postStartedAt: '2026-01-02T03:04:05.000Z', postElapsedMs: 4_000, watchingUsers: [{ id: 1, name: 'tester' }], skipVote: { votesCount: 0, requiredCount: 2, watchingUsersCount: 1, voted: false, }, })) default: return Promise.reject (new Error (`Unexpected PUT ${ path }`)) } }) renderPage () await screen.findByText ('Embed:上映中の投稿') await waitFor (() => { expect (api.apiPut).toHaveBeenCalledWith ('/theatres/7/watching') }) await waitFor (() => { expect (api.apiPut).toHaveBeenCalledTimes (2) }, { timeout: 2_500 }) expect (api.apiPatch).not.toHaveBeenCalledWith ('/theatres/7/next_post') }) it ('deletes an owned comment after confirmation', async () => { renderPage () fireEvent.click ((await screen.findAllByLabelText ('コメントを削除'))[0]) await waitFor (() => { expect (dialogue.confirm).toHaveBeenCalled () }) await waitFor (() => { expect (api.apiDelete).toHaveBeenCalledWith ('/theatres/7/comments/2') }) expect (await screen.findAllByText ('削除されました.')).toHaveLength (2) }) })