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".
516 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|