Files
movieloop-frontend/src/components/versus/VersusCompletionModal.test.tsx
T
TehRiehlDeal 8d0b7f39ee feat(ui): give the app one visual identity as Movie Loop
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".
2026-09-12 22:42:55 -07:00

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();
});
});