test: add vitest and the regression net for the multiplayer completion path
There was no test runner. This adds vitest with @testing-library/react and jsdom, a `test` job in CI, and the first 51 tests. They start where the audit's most user-visible defect lived. C1: every multiplayer mode started a game through startGameFromVersus, which never created a backend session, so `score` was only ever set inside `if (sessionId)` and the completion modal spun on "Calculating score..." forever, with no way out and an opponent waiting on the other side. The store tests pin the repaired path end to end - a multiplayer start creates a session, closing the loop lands on a score or a visible error, never the silent null - along with the generation guard that keeps a slow session from game A out of game B, and the serialised undo. The chain-validation hook is covered in both its server-validated and anonymous client-side forms. Coverage thresholds of 90 on every metric are codified in vite.config.ts. Two files are tested, at 100%/97.9% and 100%/91.4% branches, so `npm run test:cov` fails honestly on the whole-tree number while CI runs the plain `npm test`; the gap is real and is the next frontend work. The tests intercept the axios client rather than the API modules. An early version mocked `@/api/games` directly and one test sent a real request to the development backend: a second concurrent dynamic `import()` of a mocked aliased module resolved to the real file under vitest. The client is imported statically, which vitest mocks reliably, and the routing carries a tripwire that fails loudly on any unrouted request so a bypass can never reach the network again. Addresses L1.
This commit is contained in:
1 parent
ca3862d5a7
commit
2bdbf5fbc3
9 files changed
+2223
-2
No files matched your search
@@ -30,6 +30,23 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npx tsc -b
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- run: npm ci
|
||||
# Plain run, not coverage. The coverage thresholds in vite.config.ts are
|
||||
# the policy (90/85) and `npm run test:cov` enforces them locally; the
|
||||
# suite does not meet them yet, and gating CI on a bar the codebase is
|
||||
# known not to clear would just be a permanently red job nobody reads.
|
||||
# Switch this to `npm run test:cov` once WP-14's coverage work lands.
|
||||
- run: npm test
|
||||
|
||||
secrets-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -22,3 +22,7 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Test output
|
||||
coverage
|
||||
.vitest
|
||||
@@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- A test suite, where there was none: vitest with `@testing-library/react` and
|
||||
jsdom, run by `npm test`, with coverage via `npm run test:cov`. The first
|
||||
tests are the regression net for the audit's C1 - every multiplayer mode
|
||||
used to leave the completion modal on "Calculating score..." forever because
|
||||
no backend session was ever created - and for the chain-validation state
|
||||
machine in both its server-validated and anonymous client-side forms. 51
|
||||
tests. Coverage thresholds of 90% (lines, branches, functions, statements)
|
||||
are codified in `vite.config.ts`; the suite does not meet them yet, so
|
||||
`test:cov` fails honestly while CI runs the plain `npm test`. Tests intercept
|
||||
the axios client rather than the API modules, because a second concurrent
|
||||
dynamic `import()` of a mocked aliased module resolved to the real file under
|
||||
vitest - measured, and it reached the development backend once.
|
||||
- A `test` job in CI.
|
||||
|
||||
## [2.0.0] - 2026-09-10
|
||||
|
||||
Container and response-header hardening (WP-13: M31, M34).
|
||||
|
||||
Generated
+1109
-1
File diff suppressed because it is too large.
Load diff
+11
-1
@@ -9,6 +9,9 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:cov": "vitest run --coverage",
|
||||
"preview": "vite preview",
|
||||
"release": "commit-and-tag-version",
|
||||
"release:minor": "commit-and-tag-version --release-as minor",
|
||||
@@ -33,20 +36,27 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^5.0.0",
|
||||
"commit-and-tag-version": "^12.5.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^30.0.1",
|
||||
"shadcn": "^4.0.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
ActorChainLink,
|
||||
MovieChainLink,
|
||||
TmdbMovieResult,
|
||||
TmdbPersonResult,
|
||||
} from '@/types';
|
||||
|
||||
/**
|
||||
* The chain-validation state machine. Two mirror-image paths: with a server
|
||||
* session every link is validated by the backend (the authoritative path since
|
||||
* WP-3); without one - anonymous play with the backend unreachable - the hook
|
||||
* falls back to checking TMDB credits client-side.
|
||||
*
|
||||
* Requests are intercepted at the axios client and routed by URL, the same seam
|
||||
* `game-store.test.ts` uses and for the same reason: it is the one import that
|
||||
* vitest mocks reliably, and nothing can reach the network.
|
||||
*/
|
||||
|
||||
const validateLink = vi.fn();
|
||||
const movieCredits = vi.fn();
|
||||
const personCredits = vi.fn();
|
||||
const playSound = vi.fn();
|
||||
const toastWarning = vi.fn();
|
||||
|
||||
type Handler = (body: unknown) => Promise<unknown>;
|
||||
|
||||
function route(method: string, url: string): Handler {
|
||||
let m: RegExpMatchArray | null;
|
||||
if (method === 'post' && (m = /^\/games\/([^/]+)\/validate-link$/.exec(url))) {
|
||||
return (body) => validateLink(m![1], body);
|
||||
}
|
||||
if (method === 'get' && (m = /^\/movies\/(\d+)\/credits$/.exec(url))) {
|
||||
return () => movieCredits(Number(m![1]));
|
||||
}
|
||||
if (method === 'get' && (m = /^\/persons\/(\d+)\/movie-credits$/.exec(url))) {
|
||||
return () => personCredits(Number(m![1]));
|
||||
}
|
||||
throw new Error(`test reached an unrouted request: ${method} ${url}`);
|
||||
}
|
||||
|
||||
vi.mock('@/api/client', () => {
|
||||
const call =
|
||||
(method: string) =>
|
||||
async (url: string, body?: unknown): Promise<{ data: unknown }> => ({
|
||||
data: await route(method, url)(body),
|
||||
});
|
||||
return {
|
||||
default: {
|
||||
get: call('get'),
|
||||
post: call('post'),
|
||||
put: call('put'),
|
||||
delete: call('delete'),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('@/lib/sounds', () => ({
|
||||
playSound: (...args: unknown[]) => playSound(...args),
|
||||
}));
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { warning: (...args: unknown[]) => toastWarning(...args) },
|
||||
}));
|
||||
|
||||
const { useGameStore } = await import('@/stores/game-store');
|
||||
const { useChainValidation } = await import('./use-chain-validation');
|
||||
|
||||
// --- Fixtures ---------------------------------------------------------------
|
||||
|
||||
const movieLink = (id: number, title = `Movie ${id}`): MovieChainLink => ({
|
||||
type: 'movie',
|
||||
id,
|
||||
title,
|
||||
posterPath: null,
|
||||
releaseDate: '2000-01-01',
|
||||
popularity: 5,
|
||||
});
|
||||
const actorLink = (id: number, name = `Actor ${id}`): ActorChainLink => ({
|
||||
type: 'actor',
|
||||
id,
|
||||
name,
|
||||
profilePath: null,
|
||||
popularity: 5,
|
||||
});
|
||||
|
||||
const person = (id: number, name = `Actor ${id}`): TmdbPersonResult => ({
|
||||
id,
|
||||
name,
|
||||
profile_path: null,
|
||||
popularity: 7,
|
||||
known_for_department: 'Acting',
|
||||
known_for: [],
|
||||
adult: false,
|
||||
});
|
||||
const tmdbMovie = (id: number, title = `Movie ${id}`): TmdbMovieResult => ({
|
||||
id,
|
||||
title,
|
||||
original_title: title,
|
||||
overview: '',
|
||||
poster_path: '/p.jpg',
|
||||
backdrop_path: null,
|
||||
release_date: '2001-02-03',
|
||||
popularity: 9,
|
||||
vote_average: 7,
|
||||
vote_count: 100,
|
||||
genre_ids: [],
|
||||
adult: false,
|
||||
});
|
||||
|
||||
/** An axios-shaped rejection carrying a server message. */
|
||||
const serverError = (message: string | string[]) => ({
|
||||
response: { data: { message } },
|
||||
});
|
||||
|
||||
const MOVIE_A = movieLink(100, 'Movie A');
|
||||
const MOVIE_B = movieLink(200, 'Movie B');
|
||||
|
||||
function playing(
|
||||
chain: (MovieChainLink | ActorChainLink)[],
|
||||
extra: Partial<ReturnType<typeof useGameStore.getState>> = {},
|
||||
) {
|
||||
useGameStore.setState({
|
||||
status: 'playing',
|
||||
movieA: MOVIE_A,
|
||||
movieB: MOVIE_B,
|
||||
chain,
|
||||
passedMovieB: chain.some((l) => l.type === 'movie' && l.id === MOVIE_B.id),
|
||||
validationError: null,
|
||||
isValidating: false,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useGameStore.getState().resetGame();
|
||||
validateLink.mockResolvedValue({ valid: true });
|
||||
movieCredits.mockResolvedValue({ id: 0, cast: [] });
|
||||
personCredits.mockResolvedValue({ id: 0, cast: [] });
|
||||
});
|
||||
|
||||
/**
|
||||
* Rendered outside `act` on purpose: calling renderHook inside act defers the
|
||||
* render until act completes, so `result.current` is still null when read.
|
||||
*/
|
||||
function hook() {
|
||||
return renderHook(() => useChainValidation()).result.current;
|
||||
}
|
||||
|
||||
// --- Actors -----------------------------------------------------------------
|
||||
|
||||
describe('validateAndAddActor', () => {
|
||||
it('refuses an actor unless the last link is a movie', async () => {
|
||||
playing([MOVIE_A, actorLink(1)]);
|
||||
const h = hook();
|
||||
await act(() => h.validateAndAddActor(person(2)));
|
||||
|
||||
expect(useGameStore.getState().validationError).toMatch(/Expected a movie/);
|
||||
expect(playSound).toHaveBeenCalledWith('invalid');
|
||||
expect(validateLink).not.toHaveBeenCalled();
|
||||
expect(useGameStore.getState().chain).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('warns about a duplicate actor but still proceeds - reuse is penalised, not blocked', async () => {
|
||||
playing([MOVIE_A, actorLink(7, 'Repeat'), movieLink(300)], {
|
||||
sessionId: 's1',
|
||||
});
|
||||
const h = hook();
|
||||
await act(() => h.validateAndAddActor(person(7, 'Repeat')));
|
||||
|
||||
expect(toastWarning).toHaveBeenCalledWith(
|
||||
'Repeat is already in your chain',
|
||||
expect.objectContaining({ description: expect.stringMatching(/points/) }),
|
||||
);
|
||||
expect(validateLink).toHaveBeenCalled();
|
||||
expect(useGameStore.getState().chain).toHaveLength(4);
|
||||
});
|
||||
|
||||
describe('with a server session', () => {
|
||||
it('sends the link to the server and adds it on success', async () => {
|
||||
playing([MOVIE_A], { sessionId: 's1' });
|
||||
const h = hook();
|
||||
await act(() => h.validateAndAddActor(person(7, 'Seven')));
|
||||
|
||||
expect(validateLink).toHaveBeenCalledWith('s1', {
|
||||
type: 'actor',
|
||||
id: 7,
|
||||
name: 'Seven',
|
||||
popularity: 7,
|
||||
});
|
||||
const state = useGameStore.getState();
|
||||
expect(state.chain[1]).toMatchObject({ type: 'actor', id: 7, name: 'Seven' });
|
||||
expect(state.currentSearchMode).toBe('movie');
|
||||
expect(state.validationError).toBeNull();
|
||||
expect(state.isValidating).toBe(false);
|
||||
expect(playSound).toHaveBeenCalledWith('valid');
|
||||
// The client-side fallback must not run when the server is in charge.
|
||||
expect(movieCredits).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards a profile path when the person has one', async () => {
|
||||
playing([MOVIE_A], { sessionId: 's1' });
|
||||
const h = hook();
|
||||
await act(() =>
|
||||
h.validateAndAddActor({ ...person(7), profile_path: '/x.jpg' }),
|
||||
);
|
||||
expect(validateLink.mock.calls[0][1]).toMatchObject({
|
||||
profilePath: '/x.jpg',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the server rejection and leaves the chain untouched', async () => {
|
||||
playing([MOVIE_A], { sessionId: 's1' });
|
||||
validateLink.mockRejectedValue(
|
||||
serverError('Seven was not in "Movie A".'),
|
||||
);
|
||||
const h = hook();
|
||||
await act(() => h.validateAndAddActor(person(7, 'Seven')));
|
||||
|
||||
const state = useGameStore.getState();
|
||||
expect(state.validationError).toBe('Seven was not in "Movie A".');
|
||||
expect(state.chain).toHaveLength(1);
|
||||
expect(state.isValidating).toBe(false);
|
||||
expect(playSound).toHaveBeenCalledWith('invalid');
|
||||
});
|
||||
|
||||
it('takes the first message when the server returns a list', async () => {
|
||||
playing([MOVIE_A], { sessionId: 's1' });
|
||||
validateLink.mockRejectedValue(serverError(['first problem', 'second']));
|
||||
const h = hook();
|
||||
await act(() => h.validateAndAddActor(person(7)));
|
||||
expect(useGameStore.getState().validationError).toBe('first problem');
|
||||
});
|
||||
|
||||
it('explains a network failure specifically', async () => {
|
||||
playing([MOVIE_A], { sessionId: 's1' });
|
||||
validateLink.mockRejectedValue(new Error('Network Error'));
|
||||
const h = hook();
|
||||
await act(() => h.validateAndAddActor(person(7)));
|
||||
expect(useGameStore.getState().validationError).toMatch(
|
||||
/check your connection/,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to a generic message for anything else', async () => {
|
||||
playing([MOVIE_A], { sessionId: 's1' });
|
||||
validateLink.mockRejectedValue(new Error('something odd'));
|
||||
const h = hook();
|
||||
await act(() => h.validateAndAddActor(person(7)));
|
||||
expect(useGameStore.getState().validationError).toBe(
|
||||
'Failed to validate. Please try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('marks validating while the request is in flight', async () => {
|
||||
playing([MOVIE_A], { sessionId: 's1' });
|
||||
let release: (v: unknown) => void = () => undefined;
|
||||
validateLink.mockImplementation(
|
||||
() => new Promise((resolve) => (release = resolve)),
|
||||
);
|
||||
|
||||
const h = hook();
|
||||
let pending: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
pending = h.validateAndAddActor(person(7));
|
||||
});
|
||||
expect(useGameStore.getState().isValidating).toBe(true);
|
||||
|
||||
release({ valid: true });
|
||||
await act(() => pending);
|
||||
expect(useGameStore.getState().isValidating).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('without a session (client-side fallback)', () => {
|
||||
it('checks the last movie credits and adds the actor when present', async () => {
|
||||
playing([MOVIE_A, actorLink(1), movieLink(300, 'Third')]);
|
||||
movieCredits.mockResolvedValue({ id: 300, cast: [{ id: 7 }] });
|
||||
|
||||
const h = hook();
|
||||
|
||||
await act(() => h.validateAndAddActor(person(7)));
|
||||
|
||||
expect(movieCredits).toHaveBeenCalledWith(300);
|
||||
expect(validateLink).not.toHaveBeenCalled();
|
||||
expect(useGameStore.getState().chain).toHaveLength(4);
|
||||
expect(playSound).toHaveBeenCalledWith('valid');
|
||||
});
|
||||
|
||||
it('rejects an actor who is not in that movie', async () => {
|
||||
playing([MOVIE_A]);
|
||||
movieCredits.mockResolvedValue({ id: 100, cast: [{ id: 8 }] });
|
||||
|
||||
const h = hook();
|
||||
|
||||
await act(() => h.validateAndAddActor(person(7, 'Seven')));
|
||||
|
||||
const state = useGameStore.getState();
|
||||
expect(state.validationError).toBe('Seven was not in "Movie A".');
|
||||
expect(state.chain).toHaveLength(1);
|
||||
expect(state.isValidating).toBe(false);
|
||||
expect(playSound).toHaveBeenCalledWith('invalid');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Movies -----------------------------------------------------------------
|
||||
|
||||
describe('validateAndAddMovie', () => {
|
||||
it('refuses a movie unless the last link is an actor', async () => {
|
||||
playing([MOVIE_A]);
|
||||
const h = hook();
|
||||
await act(() => h.validateAndAddMovie(tmdbMovie(300)));
|
||||
|
||||
expect(useGameStore.getState().validationError).toMatch(/Expected an actor/);
|
||||
expect(validateLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a return to movie A before movie B has been visited, without a request', async () => {
|
||||
playing([MOVIE_A, actorLink(1)], { sessionId: 's1' });
|
||||
const h = hook();
|
||||
await act(() => h.validateAndAddMovie(tmdbMovie(100, 'Movie A')));
|
||||
|
||||
expect(useGameStore.getState().validationError).toBe(
|
||||
'You need to pass through "Movie B" before returning to "Movie A".',
|
||||
);
|
||||
expect(validateLink).not.toHaveBeenCalled();
|
||||
expect(playSound).toHaveBeenCalledWith('invalid');
|
||||
});
|
||||
|
||||
it('warns about a duplicate movie but still proceeds', async () => {
|
||||
playing([MOVIE_A, actorLink(1), movieLink(300, 'Again'), actorLink(2)], {
|
||||
sessionId: 's1',
|
||||
});
|
||||
const h = hook();
|
||||
await act(() => h.validateAndAddMovie(tmdbMovie(300, 'Again')));
|
||||
|
||||
expect(toastWarning).toHaveBeenCalledWith(
|
||||
'"Again" is already in your chain',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(useGameStore.getState().chain).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('does not treat the closing return to movie A as a duplicate', async () => {
|
||||
playing([MOVIE_A, actorLink(1), MOVIE_B, actorLink(2)], { sessionId: 's1' });
|
||||
const h = hook();
|
||||
await act(() => h.validateAndAddMovie(tmdbMovie(100, 'Movie A')));
|
||||
expect(toastWarning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('with a server session', () => {
|
||||
it('sends the link to the server and adds it on success', async () => {
|
||||
playing([MOVIE_A, actorLink(1)], { sessionId: 's1' });
|
||||
const h = hook();
|
||||
await act(() => h.validateAndAddMovie(tmdbMovie(300, 'Third')));
|
||||
|
||||
expect(validateLink).toHaveBeenCalledWith('s1', {
|
||||
type: 'movie',
|
||||
id: 300,
|
||||
title: 'Third',
|
||||
posterPath: '/p.jpg',
|
||||
releaseDate: '2001-02-03',
|
||||
popularity: 9,
|
||||
});
|
||||
const state = useGameStore.getState();
|
||||
expect(state.chain[2]).toMatchObject({ type: 'movie', id: 300 });
|
||||
expect(state.currentSearchMode).toBe('actor');
|
||||
expect(personCredits).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalises a missing poster path to undefined rather than sending null', async () => {
|
||||
playing([MOVIE_A, actorLink(1)], { sessionId: 's1' });
|
||||
const h = hook();
|
||||
await act(() =>
|
||||
h.validateAndAddMovie({ ...tmdbMovie(300), poster_path: null }),
|
||||
);
|
||||
// The schema accepts null and normalises it, but the hook does not lean on
|
||||
// that: it sends undefined, which axios drops from the JSON body.
|
||||
const body = validateLink.mock.calls[0][1] as { posterPath?: unknown };
|
||||
expect(body.posterPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shows the server rejection and leaves the chain untouched', async () => {
|
||||
playing([MOVIE_A, actorLink(1)], { sessionId: 's1' });
|
||||
validateLink.mockRejectedValue(serverError('Actor 1 was not in "Third".'));
|
||||
const h = hook();
|
||||
await act(() => h.validateAndAddMovie(tmdbMovie(300, 'Third')));
|
||||
|
||||
expect(useGameStore.getState().validationError).toBe(
|
||||
'Actor 1 was not in "Third".',
|
||||
);
|
||||
expect(useGameStore.getState().chain).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('without a session (client-side fallback)', () => {
|
||||
it('checks the actor filmography and adds the movie when present', async () => {
|
||||
playing([MOVIE_A, actorLink(1)]);
|
||||
personCredits.mockResolvedValue({ id: 1, cast: [{ id: 300 }] });
|
||||
|
||||
const h = hook();
|
||||
|
||||
await act(() => h.validateAndAddMovie(tmdbMovie(300)));
|
||||
|
||||
expect(personCredits).toHaveBeenCalledWith(1);
|
||||
expect(useGameStore.getState().chain).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('rejects a movie the actor was not in', async () => {
|
||||
playing([MOVIE_A, actorLink(1, 'One')]);
|
||||
personCredits.mockResolvedValue({ id: 1, cast: [{ id: 301 }] });
|
||||
|
||||
const h = hook();
|
||||
|
||||
await act(() => h.validateAndAddMovie(tmdbMovie(300, 'Third')));
|
||||
|
||||
expect(useGameStore.getState().validationError).toBe(
|
||||
'One was not in "Third".',
|
||||
);
|
||||
expect(useGameStore.getState().chain).toHaveLength(2);
|
||||
expect(useGameStore.getState().isValidating).toBe(false);
|
||||
});
|
||||
|
||||
it('surfaces a credits lookup failure', async () => {
|
||||
playing([MOVIE_A, actorLink(1)]);
|
||||
personCredits.mockRejectedValue(new Error('Network Error'));
|
||||
const h = hook();
|
||||
await act(() => h.validateAndAddMovie(tmdbMovie(300)));
|
||||
expect(useGameStore.getState().validationError).toMatch(/connection/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,595 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
ActorChainLink,
|
||||
MovieChainLink,
|
||||
MoviePair,
|
||||
ScoreBreakdown,
|
||||
} from '@/types';
|
||||
|
||||
/**
|
||||
* The audit's C1: every multiplayer mode started a game through
|
||||
* `startGameFromVersus`, which never created a backend session, so `sessionId`
|
||||
* stayed null, `score` was only ever set inside `if (sessionId)`, and the
|
||||
* completion modal spun forever on "Calculating score…" with no way out. The
|
||||
* opponent hung on "Waiting for opponent" and the match never resolved.
|
||||
*
|
||||
* These tests pin the repaired completion path: a multiplayer start creates a
|
||||
* session, and closing the loop always lands on either a score or a visible
|
||||
* error - never the silent null.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The store reaches the API through `import('@/api/games')` inside async
|
||||
* functions. Mocking that module directly proved unreliable: a second
|
||||
* concurrent dynamic import of the same aliased module resolved to the real
|
||||
* file rather than the mock - measured, and it sent a real request to the dev
|
||||
* backend once while these tests were being written.
|
||||
*
|
||||
* So the seam is one level down. `games.ts` and `movies.ts` import the axios
|
||||
* instance statically, which vitest mocks reliably, and every request is routed
|
||||
* here by URL. The real API modules run, which also exercises their request
|
||||
* shaping, and nothing can reach the network: an unrouted URL throws.
|
||||
*/
|
||||
const createGameSession = vi.fn();
|
||||
const completeGameSession = vi.fn();
|
||||
const undoLink = vi.fn();
|
||||
const getMovieDetails = vi.fn();
|
||||
const toastSuccess = vi.fn();
|
||||
|
||||
type Handler = (body: 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 === 'post' && (m = /^\/games\/([^/]+)\/undo$/.exec(url))) {
|
||||
return () => undoLink(m![1]);
|
||||
}
|
||||
throw new Error(`test reached an unrouted request: ${method} ${url}`);
|
||||
}
|
||||
|
||||
vi.mock('@/api/client', () => {
|
||||
const call =
|
||||
(method: string) =>
|
||||
async (url: string, body?: unknown): Promise<{ data: unknown }> => ({
|
||||
data: await route(method, url)(body),
|
||||
});
|
||||
return {
|
||||
default: {
|
||||
get: call('get'),
|
||||
post: call('post'),
|
||||
put: call('put'),
|
||||
delete: call('delete'),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { success: (...args: unknown[]) => toastSuccess(...args) },
|
||||
}));
|
||||
|
||||
// Imported after the mocks are registered.
|
||||
const { useGameStore } = await import('./game-store');
|
||||
|
||||
// --- Fixtures ---------------------------------------------------------------
|
||||
|
||||
const PAIR: MoviePair = {
|
||||
movieA: { id: 100, title: 'Movie A' },
|
||||
movieB: { id: 200, title: 'Movie B' },
|
||||
};
|
||||
|
||||
const details = (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,
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets fire-and-forget work settle. Several rounds, because the store reaches
|
||||
* the API through a dynamic `import()`, which is a real async module load under
|
||||
* vitest rather than a single microtask.
|
||||
*/
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await new Promise<void>((r) => setTimeout(r, 0));
|
||||
}
|
||||
};
|
||||
|
||||
/** Drive the shortest legal loop: A -> actor -> B -> actor -> A. */
|
||||
function closeTheLoop() {
|
||||
const store = useGameStore.getState();
|
||||
store.addActorToChain(actor(1));
|
||||
useGameStore.getState().addMovieToChain(movie(200, 'Movie B'));
|
||||
useGameStore.getState().addActorToChain(actor(2));
|
||||
useGameStore.getState().addMovieToChain(movie(100, 'Movie A'));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useGameStore.getState().resetGame();
|
||||
getMovieDetails.mockImplementation((id: number) =>
|
||||
Promise.resolve(details(id, id === 100 ? 'Movie A' : 'Movie B')),
|
||||
);
|
||||
createGameSession.mockResolvedValue({ id: 'session-1', startedAt: 'now' });
|
||||
completeGameSession.mockResolvedValue({ ...SCORE, newAchievements: [] });
|
||||
undoLink.mockResolvedValue({ chain: [] });
|
||||
});
|
||||
|
||||
// --- C1: multiplayer start creates a session --------------------------------
|
||||
|
||||
describe('startGameFromVersus (C1)', () => {
|
||||
it('creates a backend session and records its id before play begins', async () => {
|
||||
const result = await useGameStore
|
||||
.getState()
|
||||
.startGameFromVersus(PAIR, 1_000);
|
||||
|
||||
expect(result).toBe('started');
|
||||
expect(createGameSession).toHaveBeenCalledWith({
|
||||
movieAId: 100,
|
||||
movieATitle: 'Movie A',
|
||||
movieBId: 200,
|
||||
movieBTitle: 'Movie B',
|
||||
movieAReleaseDate: '2000-01-01',
|
||||
movieBReleaseDate: '2000-01-01',
|
||||
});
|
||||
const state = useGameStore.getState();
|
||||
expect(state.sessionId).toBe('session-1');
|
||||
expect(state.status).toBe('playing');
|
||||
expect(state.startTime).toBe(1_000);
|
||||
expect(state.chain).toEqual([
|
||||
expect.objectContaining({ type: 'movie', id: 100 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails visibly - not silently - when the server does not register the game', async () => {
|
||||
createGameSession.mockRejectedValue(new Error('500'));
|
||||
|
||||
const result = await useGameStore
|
||||
.getState()
|
||||
.startGameFromVersus(PAIR, 1_000);
|
||||
|
||||
expect(result).toBe('failed');
|
||||
const state = useGameStore.getState();
|
||||
expect(state.status).toBe('idle');
|
||||
expect(state.sessionId).toBeNull();
|
||||
expect(state.validationError).toMatch(/did not register this game/);
|
||||
});
|
||||
|
||||
it('fails visibly when movie details cannot be loaded', async () => {
|
||||
getMovieDetails.mockRejectedValue(new Error('network'));
|
||||
|
||||
const result = await useGameStore
|
||||
.getState()
|
||||
.startGameFromVersus(PAIR, 1_000);
|
||||
|
||||
expect(result).toBe('failed');
|
||||
expect(useGameStore.getState().validationError).toMatch(
|
||||
/Failed to load movie details/,
|
||||
);
|
||||
expect(createGameSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a start that was overtaken by a newer one as superseded, and stays quiet', async () => {
|
||||
// React StrictMode remounts an effect: two starts race, and the first
|
||||
// must neither report an error nor overwrite the second's state.
|
||||
let releaseFirst: (v: unknown) => void = () => undefined;
|
||||
getMovieDetails
|
||||
.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (releaseFirst = resolve)),
|
||||
)
|
||||
.mockImplementationOnce(() => Promise.resolve(details(200, 'Movie B')));
|
||||
|
||||
const first = useGameStore.getState().startGameFromVersus(PAIR, 1_000);
|
||||
const second = await useGameStore
|
||||
.getState()
|
||||
.startGameFromVersus(PAIR, 2_000);
|
||||
releaseFirst(details(100, 'Movie A'));
|
||||
|
||||
expect(second).toBe('started');
|
||||
await expect(first).resolves.toBe('superseded');
|
||||
expect(useGameStore.getState().startTime).toBe(2_000);
|
||||
expect(useGameStore.getState().validationError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startGameFromVersus supersession, later in the sequence', () => {
|
||||
it('is superseded if a newer start begins while the session is being created', async () => {
|
||||
let releaseSession: (v: unknown) => void = () => undefined;
|
||||
createGameSession
|
||||
.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (releaseSession = resolve)),
|
||||
)
|
||||
.mockResolvedValueOnce({ id: 'session-2', startedAt: 'now' });
|
||||
|
||||
const first = useGameStore.getState().startGameFromVersus(PAIR, 1_000);
|
||||
await flush(); // first is now awaiting its session
|
||||
const second = await useGameStore
|
||||
.getState()
|
||||
.startGameFromVersus(PAIR, 2_000);
|
||||
releaseSession({ id: 'session-1', startedAt: 'now' });
|
||||
|
||||
expect(second).toBe('started');
|
||||
await expect(first).resolves.toBe('superseded');
|
||||
// The first start's late session must not overwrite the second's.
|
||||
expect(useGameStore.getState().sessionId).toBe('session-2');
|
||||
});
|
||||
|
||||
it('is superseded, not failed, when its details error arrives after a newer start', async () => {
|
||||
let failFirst: (e: unknown) => void = () => undefined;
|
||||
getMovieDetails
|
||||
.mockImplementationOnce(
|
||||
() => new Promise((_, reject) => (failFirst = reject)),
|
||||
)
|
||||
.mockImplementationOnce(() => Promise.resolve(details(200, 'Movie B')));
|
||||
|
||||
const first = useGameStore.getState().startGameFromVersus(PAIR, 1_000);
|
||||
await useGameStore.getState().startGameFromVersus(PAIR, 2_000);
|
||||
failFirst(new Error('late network failure'));
|
||||
|
||||
await expect(first).resolves.toBe('superseded');
|
||||
// The stale failure must not paint an error over the live game.
|
||||
expect(useGameStore.getState().validationError).toBeNull();
|
||||
expect(useGameStore.getState().status).toBe('playing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('movies without a release date', () => {
|
||||
// TMDB omits release_date for some unreleased or obscure titles.
|
||||
const undated = (id: number) => ({ ...details(id, 'Undated'), release_date: undefined });
|
||||
|
||||
it('are tolerated by a multiplayer start', async () => {
|
||||
getMovieDetails.mockImplementation((id: number) => Promise.resolve(undated(id)));
|
||||
const result = await useGameStore.getState().startGameFromVersus(PAIR, 1);
|
||||
expect(result).toBe('started');
|
||||
expect(createGameSession.mock.calls[0][0]).not.toHaveProperty('movieAReleaseDate');
|
||||
});
|
||||
|
||||
it('are tolerated by a single-player start', async () => {
|
||||
getMovieDetails.mockImplementation((id: number) => Promise.resolve(undated(id)));
|
||||
await useGameStore.getState().startGame(PAIR);
|
||||
await flush();
|
||||
expect(useGameStore.getState().status).toBe('playing');
|
||||
expect(createGameSession.mock.calls[0][0]).not.toHaveProperty('movieBReleaseDate');
|
||||
});
|
||||
});
|
||||
|
||||
// --- C1: completion lands on a score or an error, never null ----------------
|
||||
|
||||
describe('completion after a multiplayer start (C1)', () => {
|
||||
it('resolves a score from the server once the loop closes', async () => {
|
||||
await useGameStore.getState().startGameFromVersus(PAIR, 1_000);
|
||||
|
||||
closeTheLoop();
|
||||
expect(useGameStore.getState().status).toBe('completed');
|
||||
await flush();
|
||||
|
||||
expect(completeGameSession).toHaveBeenCalledWith('session-1');
|
||||
expect(useGameStore.getState().score).toEqual(SCORE);
|
||||
expect(useGameStore.getState().validationError).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps achievements off the score object and announces them', async () => {
|
||||
// Storing them on `score` meant versus/game-night posted them back to the
|
||||
// API, where the strict schema rejected the whole submission with a 400.
|
||||
completeGameSession.mockResolvedValue({
|
||||
...SCORE,
|
||||
newAchievements: [
|
||||
{ key: 'first_loop', name: 'First Loop', description: 'd', icon: 'i' },
|
||||
],
|
||||
});
|
||||
await useGameStore.getState().startGameFromVersus(PAIR, 1_000);
|
||||
|
||||
closeTheLoop();
|
||||
await flush();
|
||||
|
||||
const { score } = useGameStore.getState();
|
||||
expect(score).toEqual(SCORE);
|
||||
expect(score).not.toHaveProperty('newAchievements');
|
||||
expect(toastSuccess).toHaveBeenCalledWith(
|
||||
'Achievement Unlocked: First Loop',
|
||||
expect.objectContaining({ description: 'd' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces a scoring failure as an error rather than an endless spinner', async () => {
|
||||
completeGameSession.mockRejectedValue(new Error('500'));
|
||||
await useGameStore.getState().startGameFromVersus(PAIR, 1_000);
|
||||
|
||||
closeTheLoop();
|
||||
await flush();
|
||||
|
||||
const state = useGameStore.getState();
|
||||
expect(state.score).toBeNull();
|
||||
expect(state.validationError).toBe('Failed to calculate score.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('completion in single-player', () => {
|
||||
it('waits for a session still being created rather than racing it', async () => {
|
||||
// A fast player can close the loop before session creation returns. The
|
||||
// old code checked `if (sessionId)` at that instant and gave up forever.
|
||||
let releaseSession: (v: unknown) => void = () => undefined;
|
||||
createGameSession.mockImplementation(
|
||||
() => new Promise((resolve) => (releaseSession = resolve)),
|
||||
);
|
||||
await useGameStore.getState().startGame(PAIR);
|
||||
expect(useGameStore.getState().sessionId).toBeNull();
|
||||
|
||||
closeTheLoop();
|
||||
await flush();
|
||||
expect(completeGameSession).not.toHaveBeenCalled();
|
||||
|
||||
releaseSession({ id: 'late-session', startedAt: 'now' });
|
||||
await flush();
|
||||
|
||||
expect(completeGameSession).toHaveBeenCalledWith('late-session');
|
||||
expect(useGameStore.getState().score).toEqual(SCORE);
|
||||
});
|
||||
|
||||
it('explains itself when no session ever existed, instead of a silent null', async () => {
|
||||
createGameSession.mockRejectedValue(new Error('offline'));
|
||||
await useGameStore.getState().startGame(PAIR);
|
||||
await flush();
|
||||
|
||||
closeTheLoop();
|
||||
await flush();
|
||||
|
||||
const state = useGameStore.getState();
|
||||
expect(state.status).toBe('completed');
|
||||
expect(state.score).toBeNull();
|
||||
expect(state.validationError).toMatch(/server never registered this game/);
|
||||
expect(completeGameSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a details failure and does not attempt a session', async () => {
|
||||
getMovieDetails.mockRejectedValue(new Error('network'));
|
||||
|
||||
await useGameStore.getState().startGame(PAIR);
|
||||
|
||||
const state = useGameStore.getState();
|
||||
expect(state.status).toBe('idle');
|
||||
expect(state.validationError).toMatch(/Failed to load movie details/);
|
||||
expect(createGameSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not let a slow session from game A land in game B (M12)', async () => {
|
||||
let releaseA: (v: unknown) => void = () => undefined;
|
||||
createGameSession
|
||||
.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (releaseA = resolve)),
|
||||
)
|
||||
.mockResolvedValueOnce({ id: 'session-B', startedAt: 'now' });
|
||||
|
||||
await useGameStore.getState().startGame(PAIR);
|
||||
await useGameStore.getState().startGame(PAIR);
|
||||
await flush();
|
||||
expect(useGameStore.getState().sessionId).toBe('session-B');
|
||||
|
||||
releaseA({ id: 'session-A', startedAt: 'now' });
|
||||
await flush();
|
||||
|
||||
expect(useGameStore.getState().sessionId).toBe('session-B');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Chain state machine ----------------------------------------------------
|
||||
|
||||
describe('chain state machine', () => {
|
||||
beforeEach(async () => {
|
||||
await useGameStore.getState().startGameFromVersus(PAIR, 1_000);
|
||||
});
|
||||
|
||||
it('alternates the search mode as links are added', () => {
|
||||
expect(useGameStore.getState().currentSearchMode).toBe('actor');
|
||||
useGameStore.getState().addActorToChain(actor(1));
|
||||
expect(useGameStore.getState().currentSearchMode).toBe('movie');
|
||||
useGameStore.getState().addMovieToChain(movie(300));
|
||||
expect(useGameStore.getState().currentSearchMode).toBe('actor');
|
||||
});
|
||||
|
||||
it('tracks passing movie B and does not complete on it', () => {
|
||||
useGameStore.getState().addActorToChain(actor(1));
|
||||
useGameStore.getState().addMovieToChain(movie(200, 'Movie B'));
|
||||
const state = useGameStore.getState();
|
||||
expect(state.passedMovieB).toBe(true);
|
||||
expect(state.status).toBe('playing');
|
||||
});
|
||||
|
||||
it('does not complete on a return to A before B has been visited', () => {
|
||||
useGameStore.getState().addActorToChain(actor(1));
|
||||
useGameStore.getState().addMovieToChain(movie(100, 'Movie A'));
|
||||
expect(useGameStore.getState().status).toBe('playing');
|
||||
expect(useGameStore.getState().score).toBeNull();
|
||||
});
|
||||
|
||||
it('completes on the minimum legal loop of five entries', async () => {
|
||||
closeTheLoop();
|
||||
expect(useGameStore.getState().status).toBe('completed');
|
||||
expect(useGameStore.getState().chain).toHaveLength(5);
|
||||
await flush();
|
||||
});
|
||||
|
||||
it('ignores link additions once the game is not in play', () => {
|
||||
useGameStore.getState().resetGame();
|
||||
useGameStore.getState().addActorToChain(actor(1));
|
||||
useGameStore.getState().addMovieToChain(movie(300));
|
||||
expect(useGameStore.getState().chain).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears a previous validation error when a link is accepted', () => {
|
||||
useGameStore.getState().setValidationError('nope');
|
||||
useGameStore.getState().addActorToChain(actor(1));
|
||||
expect(useGameStore.getState().validationError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('undoLastLink', () => {
|
||||
beforeEach(async () => {
|
||||
await useGameStore.getState().startGameFromVersus(PAIR, 1_000);
|
||||
});
|
||||
|
||||
it('never removes the opening movie', () => {
|
||||
useGameStore.getState().undoLastLink();
|
||||
expect(useGameStore.getState().chain).toHaveLength(1);
|
||||
expect(undoLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pops the last link, recomputes the mode and whether B was passed', async () => {
|
||||
useGameStore.getState().addActorToChain(actor(1));
|
||||
useGameStore.getState().addMovieToChain(movie(200, 'Movie B'));
|
||||
expect(useGameStore.getState().passedMovieB).toBe(true);
|
||||
// What the server holds after popping one link (it omits movie A).
|
||||
undoLink.mockResolvedValue({ chain: [actor(1)] });
|
||||
|
||||
useGameStore.getState().undoLastLink();
|
||||
await flush();
|
||||
|
||||
const state = useGameStore.getState();
|
||||
expect(state.chain).toHaveLength(2);
|
||||
expect(state.currentSearchMode).toBe('movie');
|
||||
expect(state.passedMovieB).toBe(false);
|
||||
expect(undoLink).toHaveBeenCalledWith('session-1');
|
||||
});
|
||||
|
||||
it('serialises rapid undos so the server pops exactly one link per call', async () => {
|
||||
useGameStore.getState().addActorToChain(actor(1));
|
||||
useGameStore.getState().addMovieToChain(movie(300));
|
||||
useGameStore.getState().addActorToChain(actor(2));
|
||||
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
undoLink.mockImplementation(async () => {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
await flush();
|
||||
inFlight -= 1;
|
||||
return { chain: [] };
|
||||
});
|
||||
|
||||
useGameStore.getState().undoLastLink();
|
||||
useGameStore.getState().undoLastLink();
|
||||
useGameStore.getState().undoLastLink();
|
||||
for (let i = 0; i < 6; i++) await flush();
|
||||
|
||||
expect(undoLink).toHaveBeenCalledTimes(3);
|
||||
expect(maxInFlight).toBe(1);
|
||||
});
|
||||
|
||||
it('adopts the server chain when it disagrees beyond the implicit movie A', async () => {
|
||||
useGameStore.getState().addActorToChain(actor(1));
|
||||
useGameStore.getState().addMovieToChain(movie(300));
|
||||
useGameStore.getState().addActorToChain(actor(2));
|
||||
// Server says only the first actor survives - the client thinks two
|
||||
// links do. The server is authoritative.
|
||||
undoLink.mockResolvedValue({ chain: [actor(1)] });
|
||||
|
||||
useGameStore.getState().undoLastLink();
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const state = useGameStore.getState();
|
||||
expect(state.chain.map((l) => l.id)).toEqual([100, 1]);
|
||||
expect(state.currentSearchMode).toBe('movie');
|
||||
});
|
||||
|
||||
it('tells the player when the server undo fails, instead of drifting silently', async () => {
|
||||
useGameStore.getState().addActorToChain(actor(1));
|
||||
undoLink.mockRejectedValue(new Error('500'));
|
||||
const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
useGameStore.getState().undoLastLink();
|
||||
await flush();
|
||||
|
||||
expect(useGameStore.getState().validationError).toMatch(/out of sync/);
|
||||
quiet.mockRestore();
|
||||
});
|
||||
|
||||
it('undoes locally only when there is no server session to mirror', async () => {
|
||||
// Single-player keeps playing when the backend is down; there is nothing
|
||||
// server-side to undo.
|
||||
createGameSession.mockRejectedValue(new Error('offline'));
|
||||
const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
await useGameStore.getState().startGame(PAIR);
|
||||
await flush();
|
||||
useGameStore.getState().addActorToChain(actor(1));
|
||||
|
||||
useGameStore.getState().undoLastLink();
|
||||
await flush();
|
||||
|
||||
expect(useGameStore.getState().chain).toHaveLength(1);
|
||||
expect(undoLink).not.toHaveBeenCalled();
|
||||
quiet.mockRestore();
|
||||
});
|
||||
|
||||
it('does nothing when the game is not in play', () => {
|
||||
useGameStore.getState().addActorToChain(actor(1));
|
||||
useGameStore.getState().resetGame();
|
||||
useGameStore.getState().undoLastLink();
|
||||
expect(undoLink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setValidating', () => {
|
||||
it('toggles the flag', () => {
|
||||
useGameStore.getState().setValidating(true);
|
||||
expect(useGameStore.getState().isValidating).toBe(true);
|
||||
useGameStore.getState().setValidating(false);
|
||||
expect(useGameStore.getState().isValidating).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetGame', () => {
|
||||
it('returns every field to its initial value', async () => {
|
||||
await useGameStore.getState().startGameFromVersus(PAIR, 1_000);
|
||||
useGameStore.getState().addActorToChain(actor(1));
|
||||
useGameStore.getState().setHintsUsed(2);
|
||||
|
||||
useGameStore.getState().resetGame();
|
||||
|
||||
const state = useGameStore.getState();
|
||||
expect(state.status).toBe('idle');
|
||||
expect(state.chain).toEqual([]);
|
||||
expect(state.sessionId).toBeNull();
|
||||
expect(state.score).toBeNull();
|
||||
expect(state.hintsUsed).toBe(0);
|
||||
expect(state.movieA).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
// Unmount everything between tests so one component's DOM cannot leak into
|
||||
// the next test's queries.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
@@ -29,4 +30,32 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json-summary'],
|
||||
// Source only. Types, the entry point, generated UI primitives and the
|
||||
// tests themselves are not behaviour this suite is responsible for.
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
exclude: [
|
||||
'src/**/*.{test,spec}.{ts,tsx}',
|
||||
'src/test/**',
|
||||
'src/types/**',
|
||||
'src/main.tsx',
|
||||
'src/vite-env.d.ts',
|
||||
],
|
||||
// The bar is 90 for both lines and branches. A file may be excepted
|
||||
// down to 85 only where 90 is genuinely unattainable, with the reason
|
||||
// recorded beside the override. See WP-14 in the audit plan.
|
||||
thresholds: {
|
||||
lines: 90,
|
||||
branches: 90,
|
||||
functions: 90,
|
||||
statements: 90,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user