import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, Routes, Route } from 'react-router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; /** * Endless.tsx: the genre/decade filter badges (including resetting to "Any"), * the franchise-aware pair pick (`@/lib/movie-pair`'s `pickDistinctPair` for * the unfiltered path, `@/lib/movie-pairs`'s `getFilteredPair` for a filtered * one), and the Start button disabling while a game is starting. * * `@/lib/movie-pair` and `@/lib/movie-pairs` are exercised for real - they are * plain, deterministic-once-`Math.random`-is-pinned logic, not an I/O edge - * only `@/api/client` is intercepted. */ interface ReqConfig { params?: Record; } const discoverSpy = vi.fn(); const movieDetailsSpy = vi.fn(); const createSessionSpy = vi.fn(); function route(method: string, url: string): (body: unknown, config?: ReqConfig) => Promise { let m: RegExpMatchArray | null; if (method === 'get' && url === '/movies/discover') { return (_b, config) => discoverSpy(config?.params); } if (method === 'get' && (m = /^\/movies\/(\d+)$/.exec(url))) { return () => movieDetailsSpy(Number(m![1])); } if (method === 'post' && url === '/games') return (body) => createSessionSpy(body); throw new Error(`test reached an unrouted request: ${method} ${url}`); } vi.mock('@/api/client', () => { const call = (method: string) => async (url: string, a?: unknown, b?: unknown): Promise<{ data: unknown }> => { const config = (method === 'get' ? a : b) as ReqConfig | undefined; const body = method === 'get' ? undefined : a; return { data: await route(method, url)(body, config) }; }; return { default: { get: call('get'), post: call('post'), put: call('put'), delete: call('delete'), }, }; }); const { default: Endless } = await import('./Endless'); const { useGameStore } = await import('@/stores/game-store'); function movieDetails(id: number) { return { id, title: `Movie ${id}`, poster_path: null, release_date: '2000-01-01', popularity: 5 }; } function renderPage() { return render( } /> Game Page} /> , ); } beforeEach(() => { vi.clearAllMocks(); useGameStore.getState().resetGame(); movieDetailsSpy.mockImplementation((id: number) => Promise.resolve(movieDetails(id))); createSessionSpy.mockResolvedValue({ id: 'session-1', startedAt: 'now' }); discoverSpy.mockResolvedValue({ page: 1, results: [ { id: 9001, title: 'Indie Film', release_date: '2015-05-01' }, { id: 9002, title: 'Another Indie', release_date: '2016-05-01' }, ], total_pages: 1, total_results: 2, }); }); afterEach(() => { vi.restoreAllMocks(); }); describe('filters panel', () => { it('is hidden until "Show filters" is toggled', async () => { renderPage(); expect(screen.queryByText('Genre')).toBeNull(); await userEvent.click(screen.getByRole('button', { name: 'Show filters' })); expect(screen.getByText('Genre')).toBeInTheDocument(); expect(screen.getByText('Decade')).toBeInTheDocument(); await userEvent.click(screen.getByRole('button', { name: 'Hide filters' })); expect(screen.queryByText('Genre')).toBeNull(); }); }); describe('starting a game', () => { it('uses a preset pair for the chosen decade', async () => { vi.spyOn(Math, 'random').mockReturnValue(0); renderPage(); await userEvent.click(screen.getByRole('button', { name: 'Show filters' })); await userEvent.click(screen.getByText('1970s')); await userEvent.click(screen.getByRole('button', { name: 'Play' })); expect(await screen.findByText('Game Page')).toBeInTheDocument(); expect(discoverSpy).not.toHaveBeenCalled(); // The first 1970s preset pair, in PRESET_PAIRS order, with `random` pinned // to 0: Star Wars (11) and Raiders of the Lost Ark (85). const requestedIds = movieDetailsSpy.mock.calls.map((c) => c[0] as number); expect(requestedIds.sort()).toEqual([11, 85]); }); it('falls back to a TMDB discover pair once a filter is reset to "Any"', async () => { renderPage(); await userEvent.click(screen.getByRole('button', { name: 'Show filters' })); await userEvent.click(screen.getByText('Comedy')); await userEvent.click(screen.getByText('1970s')); // Reset both filters back to unfiltered before starting. await userEvent.click(screen.getAllByText('Any')[0] as HTMLElement); await userEvent.click(screen.getAllByText('Any')[1] as HTMLElement); await userEvent.click(screen.getByRole('button', { name: 'Play' })); expect(await screen.findByText('Game Page')).toBeInTheDocument(); expect(discoverSpy).toHaveBeenCalledTimes(1); const requestedIds = movieDetailsSpy.mock.calls.map((c) => c[0] as number); expect(requestedIds.sort()).toEqual([9001, 9002]); }); it('disables the Start button and shows a spinner while starting', async () => { let release!: () => void; movieDetailsSpy.mockImplementationOnce( () => new Promise((resolve) => { release = () => resolve(movieDetails(11)); }), ); vi.spyOn(Math, 'random').mockReturnValue(0); renderPage(); await userEvent.click(screen.getByRole('button', { name: 'Show filters' })); await userEvent.click(screen.getByText('1970s')); await userEvent.click(screen.getByRole('button', { name: 'Play' })); const loadingButton = await screen.findByRole('button', { name: 'Finding a pair…' }); expect(loadingButton).toBeDisabled(); release(); await screen.findByText('Game Page'); }); // Before WP-19 each of these was an unhandled rejection out of the click // handler, or a navigation to /game whose idle redirect bounced the player // back with nothing shown. it('reports a TMDB failure instead of throwing out of the click handler', async () => { discoverSpy.mockRejectedValue(new Error('Network Error')); renderPage(); await userEvent.click(screen.getByRole('button', { name: 'Play' })); expect(await screen.findByRole('alert')).toHaveTextContent(/Network error/); expect(screen.queryByText('Game Page')).toBeNull(); expect(screen.getByRole('button', { name: 'Play' })).toBeEnabled(); }); it('explains a page with too few usable movies rather than failing silently', async () => { discoverSpy.mockResolvedValue({ page: 1, results: [{ id: 9001, title: 'Indie Film', release_date: '2015-05-01' }], total_pages: 1, total_results: 1, }); renderPage(); await userEvent.click(screen.getByRole('button', { name: 'Play' })); expect(await screen.findByRole('alert')).toHaveTextContent(/not return enough movies/); }); it("shows the store's error and stays put when the game fails to start", async () => { movieDetailsSpy.mockRejectedValue(new Error('network')); renderPage(); await userEvent.click(screen.getByRole('button', { name: 'Play' })); expect(await screen.findByRole('alert')).toHaveTextContent( 'Failed to load movie details. Please try again.', ); expect(screen.queryByText('Game Page')).toBeNull(); }); });