Gold is the primary in both themes, Bricolage Grotesque carries the wordmark, titles and the score, and Movie A / Movie B wear the blue and magenta of three-strip Technicolor on their posters instead of colouring whole rows. The loop closes on screen - connectors light, a gold line draws back to Movie A, the row glows, then the result arrives - in place of confetti, on every play mode, scaled to the chain's length. Every page shares one header and hairline lists; copy is sentence case with one verb per flow, and the Home hero shows a real finished loop. Also clamps link counts at zero: an opponent who had not moved yet showed "-1 links".
198 lines
7.3 KiB
TypeScript
198 lines
7.3 KiB
TypeScript
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<string, unknown>;
|
|
}
|
|
|
|
const discoverSpy = vi.fn();
|
|
const movieDetailsSpy = vi.fn();
|
|
const createSessionSpy = vi.fn();
|
|
|
|
function route(method: string, url: string): (body: unknown, config?: ReqConfig) => Promise<unknown> {
|
|
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(
|
|
<MemoryRouter initialEntries={['/endless']}>
|
|
<Routes>
|
|
<Route path="/endless" element={<Endless />} />
|
|
<Route path="/game" element={<div>Game Page</div>} />
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
}
|
|
|
|
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();
|
|
});
|
|
});
|