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,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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user