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:
TehRiehlDeal committed 2026-09-12 02:11:45 -07:00
1 parent 5a7e8cebc3
commit c9291243be
102 files changed
+17844 -79

No files matched your search

+197
View File
@@ -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();
});
});