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".
191 lines
6.2 KiB
TypeScript
191 lines
6.2 KiB
TypeScript
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, vi } from 'vitest';
|
|
|
|
// The completion modal waits for the loop-closing animation; there is none
|
|
// to wait for in jsdom, and these tests are about the content, not the delay.
|
|
vi.mock('@/lib/motion', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('@/lib/motion')>()),
|
|
prefersReducedMotion: () => true,
|
|
}));
|
|
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('1,500')).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('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 won')).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 lost')).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();
|
|
});
|
|
});
|