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

@@ -0,0 +1,183 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router';
import { beforeEach, describe, expect, it } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { useVersusStore } from '@/stores/versus-store';
import VersusCompletionModal from './VersusCompletionModal';
import type { ScoreBreakdown } from '@/types';
/**
* The multiplayer completion modal. Seeded via `setState` on the real
* versus/auth stores rather than mocked, per WP-19 convention - the component
* reads several fields together and the derived `waitingForOpponent` /
* `matchResult` branches are the behaviour under test.
*/
const score = (totalScore: number): ScoreBreakdown => ({
baseScore: 0,
chainLength: 0,
linkCount: 0,
chainLengthBonus: 0,
timeBonus: 0,
obscurityBonus: 0,
hintPenalty: 0,
duplicatePenalty: 0,
elapsedSeconds: 0,
totalScore,
});
function renderModal(initialEntry = '/versus/lobby/m1') {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/versus/lobby/:matchId" element={<VersusCompletionModal />} />
<Route path="/versus" element={<div>versus route</div>} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
useVersusStore.setState({
lobbyState: 'idle',
matchResult: null,
myScore: null,
});
useAuthStore.setState({ user: null });
});
describe('VersusCompletionModal', () => {
it('renders nothing before the local player has finished', () => {
renderModal();
expect(screen.queryByRole('dialog')).toBeNull();
});
it('shows the waiting state once I finish but the opponent has not', () => {
useVersusStore.setState({ myScore: score(1500), lobbyState: 'playing' });
renderModal();
expect(screen.getByRole('dialog', { name: 'You finished!' })).toBeInTheDocument();
expect(screen.getByText('1500')).toBeInTheDocument();
expect(screen.getByText('Waiting for opponent to finish...')).toBeInTheDocument();
});
it('defaults myTotal to 0 when the score object has no totalScore field', () => {
useVersusStore.setState({
myScore: {} as ScoreBreakdown,
lobbyState: 'playing',
});
renderModal();
expect(screen.getByText('0')).toBeInTheDocument();
});
it('lets a stuck player leave the waiting state, resetting and returning to Versus', async () => {
useVersusStore.setState({ myScore: score(1500), lobbyState: 'playing' });
renderModal();
await userEvent.click(screen.getByRole('button', { name: 'Back to Versus' }));
expect(useVersusStore.getState().myScore).toBeNull();
expect(screen.getByText('versus route')).toBeInTheDocument();
});
it('renders an empty dialog when finished with no match result yet', () => {
useVersusStore.setState({ myScore: score(1500), lobbyState: 'finished', matchResult: null });
renderModal();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.queryByText('Match Complete!')).toBeNull();
});
it('declares a tie when the match has no winner', () => {
useAuthStore.setState({ user: { id: 'u1', email: 'a@b.com', username: 'Me', isAdmin: false } });
useVersusStore.setState({
myScore: score(1500),
lobbyState: 'finished',
matchResult: {
winnerId: null,
player1: { id: 'u1', username: 'Me' },
player2: { id: 'u2', username: 'Rival' },
player1Score: score(1500),
player2Score: score(1500),
},
});
renderModal();
expect(screen.getByRole('heading', { name: /Match Complete!/ })).toBeInTheDocument();
expect(screen.getByText("It's a tie!")).toBeInTheDocument();
expect(screen.getByText('Me')).toBeInTheDocument();
expect(screen.getByText('Rival')).toBeInTheDocument();
});
it('declares a win for the signed-in player when they are the winner', () => {
useAuthStore.setState({ user: { id: 'u1', email: 'a@b.com', username: 'Me', isAdmin: false } });
useVersusStore.setState({
myScore: score(2000),
lobbyState: 'finished',
matchResult: {
winnerId: 'u1',
player1: { id: 'u1', username: 'Me' },
player2: { id: 'u2', username: 'Rival' },
player1Score: score(2000),
player2Score: score(900),
},
});
renderModal();
expect(screen.getByText('You win!')).toBeInTheDocument();
});
it('declares a loss when the winner is the other player', () => {
useAuthStore.setState({ user: { id: 'u1', email: 'a@b.com', username: 'Me', isAdmin: false } });
useVersusStore.setState({
myScore: score(900),
lobbyState: 'finished',
matchResult: {
winnerId: 'u2',
player1: { id: 'u1', username: 'Me' },
player2: { id: 'u2', username: 'Rival' },
player1Score: score(900),
player2Score: score(2000),
},
});
renderModal();
expect(screen.getByText('You lose!')).toBeInTheDocument();
});
it('defaults a missing player score to 0', () => {
useVersusStore.setState({
myScore: score(0),
lobbyState: 'finished',
matchResult: {
winnerId: null,
player1: { id: 'u1', username: 'Me' },
player2: { id: 'u2', username: 'Rival' },
player1Score: null,
player2Score: null,
},
});
renderModal();
const zeros = screen.getAllByText('0');
expect(zeros.length).toBeGreaterThanOrEqual(2);
});
it('returns to Versus from the finished result', async () => {
useVersusStore.setState({
myScore: score(1500),
lobbyState: 'finished',
matchResult: {
winnerId: null,
player1: { id: 'u1', username: 'Me' },
player2: { id: 'u2', username: 'Rival' },
player1Score: score(1500),
player2Score: score(1500),
},
});
renderModal();
await userEvent.click(screen.getByRole('button', { name: 'Back to Versus' }));
expect(screen.getByText('versus route')).toBeInTheDocument();
});
});