Files
movieloop-frontend/src/pages/AsyncVersusGame.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

516 lines
17 KiB
TypeScript

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Link, MemoryRouter, Route, Routes } from 'react-router';
import { afterEach, 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 type {
ActorChainLink,
MovieChainLink,
ScoreBreakdown,
} from '@/types';
import type { AsyncLeaderboardResponse, VersusMatch, AsyncAttemptResponse } from '@/api/versus';
/**
* The async-versus game page: it starts a game as either the match's creator
* (playing to set the benchmark) or a challenger (playing against a target
* chain length), then submits the finished run once the loop closes.
*
* Requests are intercepted one level below the API modules, at `@/api/client`
* - the seam `game-store.test.ts` established, since a second concurrent
* dynamic import of an aliased module has previously resolved to the real
* module and reached the dev backend. `@/api/games` and `@/api/movies` are
* reached indirectly through `useGameStore.startGameFromVersus`.
*/
const getMovieDetails = vi.fn();
const createGameSession = vi.fn();
const completeGameSession = vi.fn();
const getMatch = vi.fn();
const startAsyncAttempt = vi.fn();
const submitCreatorScore = vi.fn();
const submitAsyncAttempt = vi.fn();
const getAsyncLeaderboard = vi.fn();
type Handler = (arg: unknown) => Promise<unknown>;
function route(method: string, url: string): Handler {
let m: RegExpMatchArray | null;
if (method === 'get' && (m = /^\/movies\/(\d+)$/.exec(url))) {
return () => getMovieDetails(Number(m![1]));
}
if (method === 'post' && url === '/games') {
return (body) => createGameSession(body);
}
if (method === 'post' && (m = /^\/games\/([^/]+)\/complete$/.exec(url))) {
return () => completeGameSession(m![1]);
}
if (method === 'get' && (m = /^\/versus\/([^/]+)$/.exec(url))) {
return (config) => getMatch(m![1], (config as { signal?: AbortSignal })?.signal);
}
if (method === 'get' && (m = /^\/versus\/([^/]+)\/leaderboard$/.exec(url))) {
return () => getAsyncLeaderboard(m![1]);
}
if (method === 'post' && (m = /^\/versus\/([^/]+)\/async-attempt$/.exec(url))) {
return (body) => startAsyncAttempt(m![1], body);
}
if (method === 'post' && (m = /^\/versus\/([^/]+)\/creator-score$/.exec(url))) {
return (body) => submitCreatorScore(m![1], body);
}
if (method === 'post' && (m = /^\/versus\/([^/]+)\/async-attempt\/submit$/.exec(url))) {
return (body) => submitAsyncAttempt(m![1], body);
}
throw new Error(`test reached an unrouted request: ${method} ${url}`);
}
vi.mock('@/api/client', () => {
const call =
(method: string) =>
async (url: string, arg?: unknown): Promise<{ data: unknown }> => ({
data: await route(method, url)(arg),
});
return {
default: {
get: call('get'),
post: call('post'),
put: call('put'),
delete: call('delete'),
},
};
});
const { useAuthStore } = await import('@/stores/auth-store');
const { useGameStore } = await import('@/stores/game-store');
const { default: AsyncVersusGame } = await import('./AsyncVersusGame');
// --- Fixtures ---------------------------------------------------------------
const USER = { id: 'user-1', email: 'me@example.com', username: 'Me', isAdmin: false };
const CREATOR = { id: 'creator-9', username: 'Creator' };
const movieDetails = (id: number, title: string) => ({
id,
title,
poster_path: `/p${id}.jpg`,
release_date: '2000-01-01',
popularity: 10,
});
const actor = (id: number): ActorChainLink => ({
type: 'actor',
id,
name: `Actor ${id}`,
profilePath: null,
popularity: 5,
});
const movie = (id: number, title = `Movie ${id}`): MovieChainLink => ({
type: 'movie',
id,
title,
posterPath: null,
releaseDate: '2000-01-01',
popularity: 5,
});
const SCORE: ScoreBreakdown = {
baseScore: 1000,
chainLength: 5,
linkCount: 2,
chainLengthBonus: 800,
timeBonus: 500,
obscurityBonus: 100,
hintPenalty: 0,
duplicatePenalty: 0,
elapsedSeconds: 300,
totalScore: 2400,
};
function creatorMatch(overrides: Partial<VersusMatch> = {}): VersusMatch {
return {
id: 'match-1',
movieAId: 100,
movieATitle: 'Movie A',
movieBId: 200,
movieBTitle: 'Movie B',
difficulty: 'medium',
status: 'waiting',
lobbyName: null,
winnerId: null,
player1: USER,
player2: null,
player1Score: null,
player2Score: null,
createdAt: '2024-01-01T00:00:00.000Z',
startedAt: null,
finishedAt: null,
isAsync: true,
...overrides,
};
}
function challengerMatch(overrides: Partial<VersusMatch> = {}): VersusMatch {
return creatorMatch({ player1: CREATOR, ...overrides });
}
function attemptResponse(overrides: Partial<AsyncAttemptResponse> = {}): AsyncAttemptResponse {
return {
attempt: { id: 'att-1', matchId: 'match-1', playerId: USER.id },
movieAId: 100,
movieATitle: 'Movie A',
movieBId: 200,
movieBTitle: 'Movie B',
chainLength: 7,
...overrides,
};
}
function leaderboardResponse(
overrides: Partial<AsyncLeaderboardResponse> = {},
): AsyncLeaderboardResponse {
return {
match: {
id: 'match-1',
difficulty: 'medium',
lobbyName: null,
expiresAt: null,
status: 'open',
},
creator: { id: CREATOR.id, username: CREATOR.username },
leaderboard: [
{
playerId: CREATOR.id,
username: CREATOR.username,
score: { totalScore: 1000 },
chainLength: 5,
isCreator: true,
rank: 1,
},
{
playerId: USER.id,
username: USER.username,
score: { totalScore: 2400 },
chainLength: 5,
isCreator: false,
rank: 1,
},
],
...overrides,
};
}
/** Lets fire-and-forget store work (dynamic `import()`s included) settle. */
const flush = async () => {
for (let i = 0; i < 6; i++) {
await new Promise<void>((r) => setTimeout(r, 0));
}
};
/** Drive the shortest legal loop directly through the store, bypassing the UI. */
function closeTheLoop() {
useGameStore.getState().addActorToChain(actor(1));
useGameStore.getState().addMovieToChain(movie(200, 'Movie B'));
useGameStore.getState().addActorToChain(actor(2));
useGameStore.getState().addMovieToChain(movie(100, 'Movie A'));
}
function renderPage(initialEntry = '/versus/async-game/match-1') {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/versus/async-game/:matchId" element={<AsyncVersusGame />} />
<Route path="/versus/async-game" element={<AsyncVersusGame />} />
<Route path="/versus" element={<div>versus route</div>} />
<Route
path="/versus/async/:matchId/leaderboard"
element={<div>leaderboard route</div>}
/>
</Routes>
</MemoryRouter>,
);
}
/** Both movie titles are duplicated on screen (target card + chain card, or
* target card + target badge), so wait on the chain-length badge instead -
* it renders exactly once, as soon as the game is ready to play. */
async function waitForReady() {
await screen.findByText('0 links');
}
beforeEach(() => {
vi.clearAllMocks();
useAuthStore.setState({ user: USER, isLoading: false, error: null });
useGameStore.getState().resetGame();
getMovieDetails.mockImplementation((id: number) =>
Promise.resolve(movieDetails(id, id === 100 ? 'Movie A' : 'Movie B')),
);
createGameSession.mockResolvedValue({ id: 'session-1', startedAt: 'now' });
completeGameSession.mockResolvedValue({ ...SCORE, newAchievements: [] });
submitCreatorScore.mockResolvedValue({});
submitAsyncAttempt.mockResolvedValue({});
getAsyncLeaderboard.mockResolvedValue(leaderboardResponse());
});
afterEach(() => {
useGameStore.getState().resetGame();
});
describe('guard clauses before loading', () => {
it('redirects to /versus when nobody is signed in', async () => {
useAuthStore.setState({ user: null, isLoading: false });
renderPage();
expect(await screen.findByText('versus route')).toBeInTheDocument();
expect(getMatch).not.toHaveBeenCalled();
});
it('redirects to /versus when the route has no match id', async () => {
renderPage('/versus/async-game');
expect(await screen.findByText('versus route')).toBeInTheDocument();
expect(getMatch).not.toHaveBeenCalled();
});
it('does nothing while auth is still resolving', () => {
useAuthStore.setState({ user: USER, isLoading: true });
renderPage();
expect(getMatch).not.toHaveBeenCalled();
expect(screen.queryByText('versus route')).toBeNull();
});
});
describe('loading the match', () => {
it('redirects to /versus when the match is not an async challenge', async () => {
getMatch.mockResolvedValue(creatorMatch({ isAsync: false }));
renderPage();
expect(await screen.findByText('versus route')).toBeInTheDocument();
});
it('sends a signed-in creator straight to the leaderboard once they have already played', async () => {
getMatch.mockResolvedValue(creatorMatch({ status: 'open' }));
renderPage();
expect(await screen.findByText('leaderboard route')).toBeInTheDocument();
expect(createGameSession).not.toHaveBeenCalled();
});
it('shows the server error message when the match fails to load', async () => {
getMatch.mockRejectedValue({ response: { data: { message: 'Match not found' } } });
renderPage();
expect(await screen.findByText('Match not found')).toBeInTheDocument();
});
it('falls back to a generic error when the server gives no message', async () => {
getMatch.mockRejectedValue(new Error('network down'));
renderPage();
expect(await screen.findByText('Failed to load match')).toBeInTheDocument();
});
it('surfaces a failed game start as a visible error, not a silent hang', async () => {
getMatch.mockResolvedValue(creatorMatch());
createGameSession.mockRejectedValue(new Error('500'));
renderPage();
expect(
await screen.findByText('Could not start this match. Please try again.'),
).toBeInTheDocument();
const back = screen.getByRole('button', { name: 'Back to Versus' });
await userEvent.click(back);
expect(await screen.findByText('versus route')).toBeInTheDocument();
});
});
describe('abort on unmount (WP cleanup)', () => {
it('aborts the in-flight fetch and resets the game store when unmounted before it resolves', async () => {
let rejectGetMatch: (e: unknown) => void = () => undefined;
getMatch.mockImplementation(
() => new Promise((_resolve, reject) => (rejectGetMatch = reject)),
);
const { unmount } = renderPage();
unmount();
rejectGetMatch(new DOMException('aborted', 'AbortError'));
await flush();
expect(useGameStore.getState().status).toBe('idle');
expect(useGameStore.getState().movieA).toBeNull();
});
it('does not let a stale creator start land after the match id changes', async () => {
let releaseDetails: (v: unknown) => void = () => undefined;
getMatch.mockImplementation((id: string) =>
Promise.resolve(creatorMatch({ id, movieAId: id === 'match-1' ? 100 : 300 })),
);
getMovieDetails.mockImplementation((id: number) => {
if (id === 100) {
return new Promise((resolve) => (releaseDetails = resolve));
}
return Promise.resolve(movieDetails(id, id === 300 ? 'Third' : 'Movie B'));
});
render(
<MemoryRouter initialEntries={['/versus/async-game/match-1']}>
<Routes>
<Route
path="/versus/async-game/:matchId"
element={
<>
{/* An in-app nav to a second match, same as clicking into it
from a matches list, without unmounting this page. */}
<Link to="/versus/async-game/match-2">go to match 2</Link>
<AsyncVersusGame />
</>
}
/>
</Routes>
</MemoryRouter>,
);
await flush();
// Switch to a different match before match-1's slow session finishes.
await userEvent.click(screen.getByText('go to match 2'));
await flush();
expect(await screen.findByText('0 links')).toBeInTheDocument();
expect(useGameStore.getState().movieA?.title).toBe('Third');
// match-1's delayed movie details now arrive - after being superseded.
releaseDetails(movieDetails(100, 'Movie A'));
await flush();
// The stale start must not have clobbered match-2's live game.
expect(useGameStore.getState().movieA?.title).toBe('Third');
expect(useGameStore.getState().validationError).toBeNull();
});
});
describe('creator mode', () => {
it('starts the game as the benchmark-setter and submits the score on completion', async () => {
getMatch.mockResolvedValue(creatorMatch());
renderPage();
await waitForReady();
expect(
screen.getByText('Your score will be the benchmark!'),
).toBeInTheDocument();
closeTheLoop();
await flush();
expect(submitCreatorScore).toHaveBeenCalledWith('match-1', { sessionId: 'session-1' });
expect(submitCreatorScore).toHaveBeenCalledTimes(1);
expect(await screen.findByText('Score submitted')).toBeInTheDocument();
expect(screen.getByText('2,400')).toBeInTheDocument();
});
it('navigates from the completion modal to the leaderboard', async () => {
getMatch.mockResolvedValue(creatorMatch());
renderPage();
await waitForReady();
closeTheLoop();
await screen.findByText('Score submitted');
await userEvent.click(screen.getByRole('button', { name: 'View leaderboard' }));
expect(await screen.findByText('leaderboard route')).toBeInTheDocument();
});
it('reports a submission failure once and does not retry it (WP-1)', async () => {
getMatch.mockResolvedValue(creatorMatch());
submitCreatorScore.mockRejectedValue(new Error('500'));
renderPage();
await waitForReady();
closeTheLoop();
await flush();
expect(await screen.findByText('Failed to submit score')).toBeInTheDocument();
expect(submitCreatorScore).toHaveBeenCalledTimes(1);
// Further ticks must not fire a second, third, ... identical request.
await flush();
await flush();
expect(submitCreatorScore).toHaveBeenCalledTimes(1);
});
});
describe('challenger mode', () => {
it('starts an attempt against the target chain length and submits it on completion', async () => {
getMatch.mockResolvedValue(challengerMatch());
startAsyncAttempt.mockResolvedValue(attemptResponse());
renderPage();
await waitForReady();
expect(startAsyncAttempt).toHaveBeenCalledWith('match-1', { password: undefined });
expect(screen.getByText('Target: 3 links')).toBeInTheDocument();
closeTheLoop();
await flush();
expect(submitAsyncAttempt).toHaveBeenCalledWith('match-1', { sessionId: 'session-1' });
expect(submitAsyncAttempt).toHaveBeenCalledTimes(1);
expect(await screen.findByText('Challenge complete')).toBeInTheDocument();
expect(screen.getByText('You beat the creator')).toBeInTheDocument();
expect(screen.getByText('Your rank:')).toBeInTheDocument();
expect(screen.getByText('#1')).toBeInTheDocument();
});
it('shows an unknown target length before the attempt responds with one', async () => {
getMatch.mockResolvedValue(challengerMatch());
startAsyncAttempt.mockResolvedValue(attemptResponse({ chainLength: null }));
renderPage();
await waitForReady();
expect(screen.getByText('Target: ? links')).toBeInTheDocument();
});
it('still shows the completion modal when the leaderboard lookup fails (non-critical)', async () => {
getMatch.mockResolvedValue(challengerMatch());
startAsyncAttempt.mockResolvedValue(attemptResponse());
getAsyncLeaderboard.mockRejectedValue(new Error('500'));
renderPage();
await waitForReady();
closeTheLoop();
await flush();
expect(await screen.findByText('Challenge complete')).toBeInTheDocument();
expect(screen.queryByText('Your rank:')).toBeNull();
});
it('surfaces a failed attempt start as a generic load error', async () => {
// startAsyncAttempt throws before startGameFromVersus is ever reached, so
// this is caught by init()'s outer catch, not the started==='failed' path.
getMatch.mockResolvedValue(challengerMatch());
startAsyncAttempt.mockRejectedValue(new Error('500'));
renderPage();
expect(await screen.findByText('Failed to load match')).toBeInTheDocument();
});
it('surfaces a failed game start (after a successful attempt) as a visible error', async () => {
getMatch.mockResolvedValue(challengerMatch());
startAsyncAttempt.mockResolvedValue(attemptResponse());
createGameSession.mockRejectedValue(new Error('500'));
renderPage();
expect(
await screen.findByText('Could not start this match. Please try again.'),
).toBeInTheDocument();
});
it('reports a submission failure once and does not retry it (WP-1)', async () => {
getMatch.mockResolvedValue(challengerMatch());
startAsyncAttempt.mockResolvedValue(attemptResponse());
submitAsyncAttempt.mockRejectedValue(new Error('500'));
renderPage();
await waitForReady();
closeTheLoop();
await flush();
expect(await screen.findByText('Failed to submit score')).toBeInTheDocument();
expect(submitAsyncAttempt).toHaveBeenCalledTimes(1);
await flush();
await flush();
expect(submitAsyncAttempt).toHaveBeenCalledTimes(1);
});
});