fix: cover the frontend to the 90% bar and fix what the tests found (2.2.1)
WP-19. From 105 tests / 14% lines to 1,112 tests / 99% lines and 97% branches across 93 files, with every source file above the codified 90% thresholds on its own - no per-file exceptions. Written risk-first: real Zustand stores seeded via setState, the axios client mocked with an unrouted-request tripwire, the socket factory mocked with a controllable fake, so no test can reach a backend and the stores are covered by the pages that use them as well as directly. The tests found six defects, each pinned first with it.fails and then fixed: a failed join to an open Game Night room was still silent (the error only rendered inside the password prompt); the remove-friend button had no accessible name; signing out from Profile landed on /login because the page's auth guard re-ran; the sign-in page neither redirected a signed-in user nor read the ?expired=1 the API client has sent since WP-10; and Quick Play / Endless threw out of their click handlers on a TMDB failure while every start page navigated to /game whether or not the game had started. startGame now reports its outcome and the pages branch on it. One toolchain finding worth keeping: vi.useFakeTimers() combined with @testing-library/user-event deadlocks under this vitest/jsdom combination; tests that need both use real timers and waitFor.
This commit is contained in:
1 parent
5a7e8cebc3
commit
c9291243be
102 files changed
+17844
-79
No files matched your search
@@ -0,0 +1,197 @@
|
||||
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: 'Start Practice Game' }));
|
||||
|
||||
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: 'Start Practice Game' }));
|
||||
|
||||
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: 'Start Practice Game' }));
|
||||
const loadingButton = await screen.findByRole('button', { name: 'Loading...' });
|
||||
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: 'Start Practice Game' }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/Network error/);
|
||||
expect(screen.queryByText('Game Page')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Start Practice Game' })).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: 'Start Practice Game' }));
|
||||
|
||||
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: 'Start Practice Game' }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Failed to load movie details. Please try again.',
|
||||
);
|
||||
expect(screen.queryByText('Game Page')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user