fix: cover the frontend to the 90% bar and fix what the tests found (2.2.1)

WP-19. From 105 tests / 14% lines to 1,112 tests / 99% lines and 97%
branches across 93 files, with every source file above the codified 90%
thresholds on its own - no per-file exceptions.

Written risk-first: real Zustand stores seeded via setState, the axios
client mocked with an unrouted-request tripwire, the socket factory
mocked with a controllable fake, so no test can reach a backend and the
stores are covered by the pages that use them as well as directly.

The tests found six defects, each pinned first with it.fails and then
fixed: a failed join to an open Game Night room was still silent (the
error only rendered inside the password prompt); the remove-friend
button had no accessible name; signing out from Profile landed on
/login because the page's auth guard re-ran; the sign-in page neither
redirected a signed-in user nor read the ?expired=1 the API client has
sent since WP-10; and Quick Play / Endless threw out of their click
handlers on a TMDB failure while every start page navigated to /game
whether or not the game had started. startGame now reports its outcome
and the pages branch on it.

One toolchain finding worth keeping: vi.useFakeTimers() combined with
@testing-library/user-event deadlocks under this vitest/jsdom
combination; tests that need both use real timers and waitFor.
This commit is contained in:
TehRiehlDeal committed 2026-09-12 02:11:45 -07:00
1 parent 5a7e8cebc3
commit c9291243be
102 files changed
+17844 -79

No files matched your search

+40
View File
@@ -9,6 +9,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.2.1] - 2026-09-12
The frontend test foundation (WP-19: the frontend half of L1), and the defects
it turned up.
### Fixed
- **Joining an open Game Night room that refuses you shows why.** The error was
set but only rendered inside the password prompt, which a passwordless join
never opens - WP-10 fixed the password path and left this one silent.
- **The remove-friend button has an accessible name** ("Remove <name> as a
friend"). It rendered only an icon, the one icon-only control the
accessibility pass missed; a screen reader announced "button" next to a
destructive action.
- **Signing out from Profile lands on the home page**, as the button intends.
The page's own auth guard re-ran the moment the user cleared and sent them
to the sign-in screen instead.
- **The sign-in page redirects someone who is already signed in** to their
profile, and **says so when the session expired**: the API client has sent
expired sessions to `/login?expired=1` since WP-10, and nothing read it, so
being signed out by an expired token looked identical to signing out.
- **Starting a game reports failure instead of losing it.** Quick Play and
Endless awaited the TMDB pick with no catch - a failed request, or a page
with fewer than two usable films, was an unhandled rejection with nothing on
screen - and the button only disabled itself during `startGame`, so a second
click during the pick started a second game. Every start page (Quick Play,
Endless, Custom, Daily) also navigated to `/game` whether or not the game
had started; a failure bounced off that page's idle redirect with the
store's message never displayed. `startGame` now reports `'started'` or
`'failed'`, and each page shows the reason and stays put.
### Changed
- `npm run test:cov` is what CI runs: the 90% thresholds codified in WP-14 are
enforced, having been cleared - 14% to 99% lines, 12% to 97% branches, 105
to 1,112 tests across 93 files. Every source file is above the bar on its
own; there are no per-file exceptions.
- The pair picker shuffles by random key instead of an in-place Fisher-Yates,
removing three index guards that could not be reached.
## [2.2.0] - 2026-09-11
Repository hygiene (WP-17: L2, L4, M9, M35, the frontend half of the audit's
+2 -2
View File
@@ -61,12 +61,12 @@ The built app reads its API base at runtime, not at build time, so one image ser
npm run lint # eslint --max-warnings 0, including jsx-a11y
npx tsc -b
npm test # vitest + testing-library, jsdom
npm run test:cov # coverage report; thresholds are 90% across the board
npm run test:cov # what CI runs: fails below 90% lines/branches/functions/statements
npm run build # tsc -b && vite build -> dist/
npm run preview # serve dist/ locally
```
Coverage thresholds live in `vite.config.ts`. The suite mocks the axios client and fails loudly on any request it did not route, so no test can reach a real backend.
Coverage thresholds live in `vite.config.ts`; every source file is above them individually. The suite mocks the axios client and fails loudly on any request it did not route, and mocks the socket factory with a controllable fake, so no test can reach a real backend. Pages are rendered with real Zustand stores seeded via `setState`, so the stores' selectors and actions are covered by the page tests as well as their own. One toolchain note: `vi.useFakeTimers()` combined with `@testing-library/user-event` deadlocks under this vitest/jsdom combination — tests that need both use real timers and `waitFor`.
`noUncheckedIndexedAccess` is on: indexing an array or record gives you `T | undefined`, and the code handles it rather than asserting it away. There are no `any`, no `eslint-disable` and no `@ts-ignore` in `src/`; the lint gate is configured to keep it that way.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "movie-loop-frontend",
"version": "2.2.0",
"version": "2.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "movie-loop-frontend",
"version": "2.2.0",
"version": "2.2.1",
"license": "MIT",
"dependencies": {
"@base-ui/react": "^1.2.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "movie-loop-frontend",
"private": true,
"version": "2.2.0",
"version": "2.2.1",
"type": "module",
"license": "MIT",
"author": "Kevin Riehl",
+138
View File
@@ -0,0 +1,138 @@
import { render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* App.tsx: `loadUser` fires on mount, a lazy route shows `RouteFallback`
* while its chunk loads and then resolves to the real page, and an unknown
* path falls through to `NotFound`. A real `BrowserRouter` is awkward to
* drive directly, so the path is set via `window.history.pushState` before
* `<App />` renders, mirroring how the browser would have gotten there.
*
* Heavy lazy pages reach the network - intercepted one level below `@/api/*`,
* at `@/api/client`, per the project's convention (mocking `@/api/*` modules
* directly has previously resolved to the real module under a concurrent
* dynamic import and reached the dev backend). Home and NotFound need none of
* this: Home only fetches on a button click, and NotFound fetches nothing.
*/
function unrouted(method: string, url: string): never {
throw new Error(`test reached an unrouted request: ${method} ${url}`);
}
vi.mock('@/api/client', () => {
const call =
(method: string) =>
async (url: string): Promise<{ data: unknown }> => {
if (method === 'get' && url === '/daily-challenges/today') {
return { data: [] };
}
return unrouted(method, url);
};
return {
default: {
get: call('get'),
post: call('post'),
put: call('put'),
delete: call('delete'),
},
};
});
const { default: App } = await import('./App');
const { useAuthStore } = await import('@/stores/auth-store');
beforeEach(() => {
window.history.pushState({}, '', '/');
localStorage.clear();
useAuthStore.setState({ user: null, isLoading: false, error: null });
});
afterEach(() => {
window.history.pushState({}, '', '/');
});
describe('mount', () => {
it('loads the current user on mount', () => {
const loadUserSpy = vi
.spyOn(useAuthStore.getState(), 'loadUser')
.mockResolvedValue(undefined);
render(<App />);
expect(loadUserSpy).toHaveBeenCalledTimes(1);
});
});
describe('routing', () => {
it('shows the loading fallback while a lazy route\'s chunk is in flight, then the page itself', async () => {
window.history.pushState({}, '', '/daily');
render(<App />);
expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument();
expect(
await screen.findByRole('heading', { name: 'Daily Challenges' }),
).toBeInTheDocument();
expect(screen.queryByRole('status', { name: 'Loading' })).toBeNull();
});
it('resolves the eager home route directly, without a loading fallback', () => {
window.history.pushState({}, '', '/');
render(<App />);
expect(
screen.getByRole('heading', { name: 'You Know Who Else Was In That Movie?' }),
).toBeInTheDocument();
expect(screen.queryByRole('status', { name: 'Loading' })).toBeNull();
});
it('falls through an unknown path to NotFound', async () => {
window.history.pushState({}, '', '/this-route-does-not-exist');
render(<App />);
expect(await screen.findByText('404')).toBeInTheDocument();
expect(screen.getByText('Page not found.')).toBeInTheDocument();
});
});
describe('every route resolves its lazy chunk', () => {
// Each of these pages has its own dedicated test file for behaviour; this
// sweep exists purely to prove App's routing table wires every path to a
// chunk that actually loads (each is its own `lazy(() => import(...))`
// call). Signed out throughout, so every auth-gated page below settles on
// its own redirect (most commonly to /login) rather than doing real work -
// and every one of them, redirected or not, ends up on a PageLayout-framed
// page, so the brand link is a reliable, page-agnostic settle signal.
const routes = [
'/game',
'/leaderboard',
'/profile',
'/login',
'/versus',
'/versus/lobby/m1',
'/versus/game',
'/versus/async-game/m1',
'/versus/async/m1/leaderboard',
'/achievements',
'/endless',
'/custom',
'/game-night',
'/game-night/lobby/r1',
'/game-night/play',
'/game-night/r1/results',
'/game/1/review',
'/friends',
'/friends/add',
'/friends/chat/f1',
'/admin',
];
it.each(routes)('resolves %s to a real page', async (path) => {
window.history.pushState({}, '', path);
render(<App />);
expect(await screen.findByRole('link', { name: 'Movie Loop' })).toBeInTheDocument();
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest';
import type { Achievement } from './achievements';
const get = vi.fn();
vi.mock('@/api/client', () => ({
default: { get: (...a: unknown[]) => get(...a) },
}));
const { getUserAchievements } = await import('./achievements');
const achievement = (): Achievement => ({
id: 'a1',
key: 'first_loop',
name: 'First Loop',
description: 'Complete your first loop',
icon: 'trophy',
category: 'general',
threshold: 1,
progress: 1,
unlocked: true,
unlockedAt: '2024-01-01T00:00:00Z',
});
describe('getUserAchievements', () => {
it('GETs /achievements/me with the abort signal and returns the data', async () => {
const data = [achievement()];
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getUserAchievements(controller.signal);
expect(get).toHaveBeenCalledWith('/achievements/me', { signal: controller.signal });
expect(result).toBe(data);
});
it('works without a signal', async () => {
get.mockResolvedValue({ data: [] });
await getUserAchievements();
expect(get).toHaveBeenCalledWith('/achievements/me', { signal: undefined });
});
});
+213
View File
@@ -0,0 +1,213 @@
import { describe, expect, it, vi } from 'vitest';
import type {
AdminUsersResponse,
BackfillGameOwnersResult,
BackfillUsernamesResult,
GeneratedChallenge,
PlatformStats,
RecalculateResult,
RetroactiveAchievementsResult,
} from './admin';
const get = vi.fn();
const post = vi.fn();
vi.mock('@/api/client', () => ({
default: {
get: (...a: unknown[]) => get(...a),
post: (...a: unknown[]) => post(...a),
},
}));
const {
getPlatformStats,
listUsers,
recalculateScores,
backfillGameOwners,
generateChallenge,
generateAllChallenges,
retroactiveAchievements,
backfillUsernames,
} = await import('./admin');
describe('getPlatformStats', () => {
it('GETs /admin/stats with the abort signal', async () => {
const data: PlatformStats = {
totalUsers: 1,
totalGames: 2,
completedGames: 1,
completionRate: 0.5,
totalChallenges: 3,
totalMatches: 4,
};
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getPlatformStats(controller.signal);
expect(get).toHaveBeenCalledWith('/admin/stats', { signal: controller.signal });
expect(result).toBe(data);
});
});
describe('listUsers', () => {
it('GETs /admin/users with the default page/limit and the abort signal', async () => {
const data: AdminUsersResponse = { data: [], total: 0, page: 1, limit: 50, totalPages: 0 };
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await listUsers(undefined, undefined, controller.signal);
expect(get).toHaveBeenCalledWith('/admin/users', {
params: { page: 1, limit: 50 },
signal: controller.signal,
});
expect(result).toBe(data);
});
it('forwards an explicit page and limit', async () => {
get.mockResolvedValue({ data: { data: [], total: 0, page: 2, limit: 10, totalPages: 0 } });
await listUsers(2, 10);
expect(get).toHaveBeenCalledWith('/admin/users', { params: { page: 2, limit: 10 }, signal: undefined });
});
});
describe('recalculateScores', () => {
it('POSTs /admin/recalculate-scores with no cursor param when omitted', async () => {
const data: RecalculateResult = {
hasMore: false,
nextCursor: null,
updated: 5,
skipped: 0,
total: 5,
};
post.mockResolvedValue({ data });
const result = await recalculateScores();
expect(post).toHaveBeenCalledWith('/admin/recalculate-scores', undefined, { params: undefined });
expect(result).toBe(data);
});
it('forwards the cursor as a query param', async () => {
post.mockResolvedValue({
data: { hasMore: true, nextCursor: 'c2', updated: 5, skipped: 0, total: 10 },
});
await recalculateScores('c1');
expect(post).toHaveBeenCalledWith('/admin/recalculate-scores', undefined, {
params: { cursor: 'c1' },
});
});
});
describe('backfillGameOwners', () => {
it('POSTs /admin/backfill-user-ids with no cursor param when omitted', async () => {
const data: BackfillGameOwnersResult = {
hasMore: false,
nextCursor: null,
backfilled: 1,
ambiguous: 0,
unmatched: 0,
total: 1,
};
post.mockResolvedValue({ data });
const result = await backfillGameOwners();
expect(post).toHaveBeenCalledWith('/admin/backfill-user-ids', undefined, { params: undefined });
expect(result).toBe(data);
});
it('forwards the cursor as a query param', async () => {
post.mockResolvedValue({
data: { hasMore: false, nextCursor: null, backfilled: 1, ambiguous: 0, unmatched: 0, total: 1 },
});
await backfillGameOwners('c1');
expect(post).toHaveBeenCalledWith('/admin/backfill-user-ids', undefined, { params: { cursor: 'c1' } });
});
});
describe('generateChallenge', () => {
it('POSTs /admin/daily-challenges/generate with a default difficulty', async () => {
const data = { id: 'c1' };
post.mockResolvedValue({ data });
const result = await generateChallenge('2024-01-01');
expect(post).toHaveBeenCalledWith('/admin/daily-challenges/generate', {
date: '2024-01-01',
difficulty: 'medium',
});
expect(result).toBe(data);
});
it('forwards an explicit difficulty', async () => {
post.mockResolvedValue({ data: { id: 'c2' } });
await generateChallenge('2024-01-01', 'hard');
expect(post).toHaveBeenCalledWith('/admin/daily-challenges/generate', {
date: '2024-01-01',
difficulty: 'hard',
});
});
});
describe('generateAllChallenges', () => {
it('POSTs /admin/daily-challenges/generate-all with the date', async () => {
const data: GeneratedChallenge[] = [
{ id: 'c1', difficulty: 'easy', movieATitle: 'A', movieBTitle: 'B', par: 4 },
];
post.mockResolvedValue({ data });
const result = await generateAllChallenges('2024-01-01');
expect(post).toHaveBeenCalledWith('/admin/daily-challenges/generate-all', { date: '2024-01-01' });
expect(result).toBe(data);
});
});
describe('retroactiveAchievements', () => {
it('POSTs /admin/retroactive-achievements with no cursor param when omitted', async () => {
const data: RetroactiveAchievementsResult = {
hasMore: false,
nextCursor: null,
totalAwarded: 3,
usersAffected: 2,
};
post.mockResolvedValue({ data });
const result = await retroactiveAchievements();
expect(post).toHaveBeenCalledWith('/admin/retroactive-achievements', undefined, {
params: undefined,
});
expect(result).toBe(data);
});
it('forwards the cursor as a query param', async () => {
post.mockResolvedValue({
data: { hasMore: false, nextCursor: null, totalAwarded: 1, usersAffected: 1 },
});
await retroactiveAchievements('c1');
expect(post).toHaveBeenCalledWith('/admin/retroactive-achievements', undefined, {
params: { cursor: 'c1' },
});
});
});
describe('backfillUsernames', () => {
it('POSTs /admin/backfill-usernames and returns the count', async () => {
const data: BackfillUsernamesResult = { backfilled: 12 };
post.mockResolvedValue({ data });
const result = await backfillUsernames();
expect(post).toHaveBeenCalledWith('/admin/backfill-usernames');
expect(result).toBe(data);
});
});
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest';
import type { AuthResponse } from './auth';
const post = vi.fn();
const get = vi.fn();
vi.mock('@/api/client', () => ({
default: {
post: (...a: unknown[]) => post(...a),
get: (...a: unknown[]) => get(...a),
},
}));
const { register, login, getProfile } = await import('./auth');
const authResponse = (): AuthResponse => ({
access_token: 'tok123',
user: { id: 'u1', email: 'a@b.com', username: 'a', isAdmin: false },
});
describe('register', () => {
it('POSTs /auth/register with credentials, defaulting rememberMe to false', async () => {
const data = authResponse();
post.mockResolvedValue({ data });
const result = await register('a@b.com', 'alice', 'pw123');
expect(post).toHaveBeenCalledWith('/auth/register', {
email: 'a@b.com',
username: 'alice',
password: 'pw123',
rememberMe: false,
});
expect(result).toBe(data);
});
it('forwards an explicit rememberMe', async () => {
post.mockResolvedValue({ data: authResponse() });
await register('a@b.com', 'alice', 'pw123', true);
expect(post).toHaveBeenCalledWith('/auth/register', {
email: 'a@b.com',
username: 'alice',
password: 'pw123',
rememberMe: true,
});
});
});
describe('login', () => {
it('POSTs /auth/login with credentials, defaulting rememberMe to false', async () => {
const data = authResponse();
post.mockResolvedValue({ data });
const result = await login('a@b.com', 'pw123');
expect(post).toHaveBeenCalledWith('/auth/login', {
email: 'a@b.com',
password: 'pw123',
rememberMe: false,
});
expect(result).toBe(data);
});
it('forwards an explicit rememberMe', async () => {
post.mockResolvedValue({ data: authResponse() });
await login('a@b.com', 'pw123', true);
expect(post).toHaveBeenCalledWith('/auth/login', {
email: 'a@b.com',
password: 'pw123',
rememberMe: true,
});
});
});
describe('getProfile', () => {
it('GETs /users/me and returns the data', async () => {
const data = { id: 'u1', username: 'alice' };
get.mockResolvedValue({ data });
const result = await getProfile();
expect(get).toHaveBeenCalledWith('/users/me');
expect(result).toBe(data);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from 'vitest';
import type { ChatMessage, ConversationEntry } from './chat';
const get = vi.fn();
vi.mock('@/api/client', () => ({
default: { get: (...a: unknown[]) => get(...a) },
}));
const { getConversations, getMessages } = await import('./chat');
const conversation = (): ConversationEntry => ({
friend: { id: 'friend-1', username: 'friend', displayName: null, avatarUrl: null },
lastMessageAt: '2024-01-01T00:00:00Z',
});
const messagesPage = (): { messages: ChatMessage[]; hasMore: boolean; nextCursor: string | null } => ({
messages: [
{
id: 'm1',
senderId: 'friend-1',
recipientId: 'me',
content: 'hi',
createdAt: '2024-01-01T00:00:00Z',
},
],
hasMore: false,
nextCursor: null,
});
describe('getConversations', () => {
it('GETs /chat/conversations and returns the data', async () => {
const data = [conversation()];
get.mockResolvedValue({ data });
const result = await getConversations();
expect(get).toHaveBeenCalledWith('/chat/conversations');
expect(result).toBe(data);
});
});
describe('getMessages', () => {
it('GETs /chat/:friendId/messages with a stringified default limit and no cursor', async () => {
const data = messagesPage();
get.mockResolvedValue({ data });
const result = await getMessages('friend-1');
expect(get).toHaveBeenCalledWith('/chat/friend-1/messages', { params: { limit: '50' } });
expect(result).toBe(data);
});
it('includes the cursor when paging backward', async () => {
get.mockResolvedValue({ data: messagesPage() });
await getMessages('friend-1', 'cursor-abc');
expect(get).toHaveBeenCalledWith('/chat/friend-1/messages', {
params: { limit: '50', cursor: 'cursor-abc' },
});
});
it('forwards a custom limit', async () => {
get.mockResolvedValue({ data: messagesPage() });
await getMessages('friend-1', undefined, 10);
expect(get).toHaveBeenCalledWith('/chat/friend-1/messages', { params: { limit: '10' } });
});
});
+273
View File
@@ -0,0 +1,273 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AxiosError, InternalAxiosRequestConfig } from 'axios';
/**
* The axios instance itself: the auth-header request interceptor, and the
* response interceptor's 401-driven session teardown - which must exclude
* `/auth/login` and `/auth/register` (a wrong password must surface on the
* form, not bounce the page), and must not fire at all for a browser that
* never had a session to begin with.
*
* `axios` is mocked so `.create()` returns a fake instance whose
* `interceptors.request.use` / `interceptors.response.use` calls are captured
* here and invoked directly against fake configs/errors - the same
* "invoke the captured handler" approach the WP task describes, since
* `client.ts` never exposes the interceptors any other way.
*/
const getAuthToken = vi.fn<() => string | null>();
const removeAuthToken = vi.fn();
vi.mock('@/lib/storage', () => ({
storage: {
getAuthToken: () => getAuthToken(),
removeAuthToken: () => removeAuthToken(),
},
}));
vi.mock('@/lib/config', () => ({
config: { apiUrl: '/api/v1' },
}));
type RequestHandler = (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig;
type ResponseSuccessHandler = (response: unknown) => unknown;
type ResponseErrorHandler = (error: AxiosError) => Promise<never>;
let requestHandler: RequestHandler = () => {
throw new Error('request interceptor was never captured');
};
let responseSuccessHandler: ResponseSuccessHandler = () => {
throw new Error('response success interceptor was never captured');
};
let responseErrorHandler: ResponseErrorHandler = () => {
throw new Error('response error interceptor was never captured');
};
const createMock = vi.fn();
vi.mock('axios', () => ({
default: {
create: (...args: unknown[]) => createMock(...args),
},
}));
createMock.mockImplementation(() => ({
interceptors: {
request: {
use: (fn: RequestHandler) => {
requestHandler = fn;
},
},
response: {
use: (success: ResponseSuccessHandler, error: ResponseErrorHandler) => {
responseSuccessHandler = success;
responseErrorHandler = error;
},
},
},
}));
const { default: apiClient } = await import('./client');
// `beforeEach` clears mock call history before each test runs, so the single
// `axios.create(...)` call made at module-evaluation time is captured here.
const createCallArgs: unknown = createMock.mock.calls[0]?.[0];
function stubLocation(pathname: string): { assign: ReturnType<typeof vi.fn> } {
const assign = vi.fn();
Object.defineProperty(window, 'location', {
value: { pathname, assign },
writable: true,
configurable: true,
});
return { assign };
}
const originalLocation = window.location;
function baseConfig(): InternalAxiosRequestConfig {
return { headers: {} } as InternalAxiosRequestConfig;
}
function axiosError(overrides: {
status?: number;
url?: string;
data?: { message?: string | string[] };
message?: string;
hasConfig?: boolean;
} = {}): AxiosError {
const { status, url, data, message, hasConfig = true } = overrides;
return {
message: message ?? 'Request failed',
config: hasConfig ? ({ url } as InternalAxiosRequestConfig) : undefined,
response:
status === undefined
? undefined
: ({ status, data: data ?? {} } as AxiosError['response']),
} as AxiosError;
}
beforeEach(() => {
vi.clearAllMocks();
getAuthToken.mockReturnValue(null);
});
afterEach(() => {
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
configurable: true,
});
});
describe('instance configuration', () => {
it('is created with the configured base URL and standard headers', () => {
expect(createCallArgs).toEqual({
baseURL: '/api/v1',
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
});
});
it('exports the created instance', () => {
expect(apiClient).toBeDefined();
});
});
describe('request interceptor', () => {
it('attaches a bearer token when one is stored', () => {
getAuthToken.mockReturnValue('tok123');
const config = requestHandler(baseConfig());
expect(config.headers.Authorization).toBe('Bearer tok123');
});
it('leaves the headers untouched when there is no token', () => {
getAuthToken.mockReturnValue(null);
const config = requestHandler(baseConfig());
expect(config.headers.Authorization).toBeUndefined();
});
});
describe('response interceptor - success path', () => {
it('passes the response through unchanged', () => {
const response = { data: { ok: true } };
expect(responseSuccessHandler(response)).toBe(response);
});
});
describe('response interceptor - session expiry (401)', () => {
it('clears the token and redirects when a token existed and the endpoint is not an auth endpoint', async () => {
getAuthToken.mockReturnValue('tok123');
const { assign } = stubLocation('/game');
const error = axiosError({ status: 401, url: '/games/1' });
await expect(responseErrorHandler(error)).rejects.toBe(error);
expect(removeAuthToken).toHaveBeenCalledTimes(1);
expect(assign).toHaveBeenCalledWith('/login?expired=1');
});
it('does not redirect again when already on the login page, but still clears the token', async () => {
getAuthToken.mockReturnValue('tok123');
const { assign } = stubLocation('/login');
const error = axiosError({ status: 401, url: '/games/1' });
await expect(responseErrorHandler(error)).rejects.toBe(error);
expect(removeAuthToken).toHaveBeenCalledTimes(1);
expect(assign).not.toHaveBeenCalled();
});
it('treats a 401 with no config/url as a session expiry when a token exists', async () => {
getAuthToken.mockReturnValue('tok123');
const { assign } = stubLocation('/game');
const error = axiosError({ status: 401, hasConfig: false });
await expect(responseErrorHandler(error)).rejects.toBe(error);
expect(removeAuthToken).toHaveBeenCalledTimes(1);
expect(assign).toHaveBeenCalledWith('/login?expired=1');
});
it('does not treat a 401 as a session expiry when there was never a token to lose', async () => {
getAuthToken.mockReturnValue(null);
const { assign } = stubLocation('/game');
const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const error = axiosError({ status: 401, url: '/games/1', data: { message: 'Unauthorized' } });
await expect(responseErrorHandler(error)).rejects.toBe(error);
expect(removeAuthToken).not.toHaveBeenCalled();
expect(assign).not.toHaveBeenCalled();
expect(quiet).toHaveBeenCalledWith('[API Error]', 'Unauthorized');
quiet.mockRestore();
});
it('excludes /auth/login from session-expiry handling so a bad password surfaces on the form', async () => {
getAuthToken.mockReturnValue('tok123');
const { assign } = stubLocation('/login');
const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const error = axiosError({
status: 401,
url: '/auth/login',
data: { message: 'Invalid credentials' },
});
await expect(responseErrorHandler(error)).rejects.toBe(error);
expect(removeAuthToken).not.toHaveBeenCalled();
expect(assign).not.toHaveBeenCalled();
expect(quiet).toHaveBeenCalledWith('[API Error]', 'Invalid credentials');
quiet.mockRestore();
});
it('excludes /auth/register from session-expiry handling', async () => {
getAuthToken.mockReturnValue('tok123');
stubLocation('/register');
const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const error = axiosError({
status: 401,
url: '/auth/register',
data: { message: 'Email taken' },
});
await expect(responseErrorHandler(error)).rejects.toBe(error);
expect(removeAuthToken).not.toHaveBeenCalled();
quiet.mockRestore();
});
});
describe('response interceptor - non-401 / generic errors', () => {
it('logs the server-provided message and rejects with the original error', async () => {
stubLocation('/game');
const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const error = axiosError({ status: 500, data: { message: 'Internal error' } });
await expect(responseErrorHandler(error)).rejects.toBe(error);
expect(quiet).toHaveBeenCalledWith('[API Error]', 'Internal error');
expect(removeAuthToken).not.toHaveBeenCalled();
quiet.mockRestore();
});
it('falls back to the error message when there is no response', async () => {
const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const error = axiosError({ message: 'Network Error' });
await expect(responseErrorHandler(error)).rejects.toBe(error);
expect(quiet).toHaveBeenCalledWith('[API Error]', 'Network Error');
quiet.mockRestore();
});
it('falls back to a generic message when neither a response nor a message exists', async () => {
const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const error = { config: undefined, response: undefined, message: undefined } as unknown as AxiosError;
await expect(responseErrorHandler(error)).rejects.toBe(error);
expect(quiet).toHaveBeenCalledWith('[API Error]', 'An error occurred');
quiet.mockRestore();
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest';
import type { DailyChallenge } from './daily-challenges';
const get = vi.fn();
vi.mock('@/api/client', () => ({
default: { get: (...a: unknown[]) => get(...a) },
}));
const { getTodaysChallenges } = await import('./daily-challenges');
const challenge = (): DailyChallenge => ({
id: 'c1',
date: '2024-01-01',
movieAId: 1,
movieATitle: 'A',
movieBId: 2,
movieBTitle: 'B',
par: 5,
difficulty: 'medium',
});
describe('getTodaysChallenges', () => {
it('GETs /daily-challenges/today with the abort signal and returns the data', async () => {
const data = [challenge()];
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getTodaysChallenges(controller.signal);
expect(get).toHaveBeenCalledWith('/daily-challenges/today', { signal: controller.signal });
expect(result).toBe(data);
});
it('works without a signal', async () => {
get.mockResolvedValue({ data: [] });
await getTodaysChallenges();
expect(get).toHaveBeenCalledWith('/daily-challenges/today', { signal: undefined });
});
});
+154
View File
@@ -0,0 +1,154 @@
import { describe, expect, it, vi } from 'vitest';
import type { FriendEntry, PendingRequest, SearchResult, SentRequest } from './friends';
const get = vi.fn();
const post = vi.fn();
const del = vi.fn();
vi.mock('@/api/client', () => ({
default: {
get: (...a: unknown[]) => get(...a),
post: (...a: unknown[]) => post(...a),
delete: (...a: unknown[]) => del(...a),
},
}));
const {
getFriends,
getPendingRequests,
getSentRequests,
getPendingCount,
searchUsers,
sendFriendRequest,
acceptRequest,
rejectRequest,
removeFriend,
createFriendChallenge,
} = await import('./friends');
const friendProfile = () => ({ id: 'u1', username: 'alice', displayName: null, avatarUrl: null });
describe('getFriends', () => {
it('GETs /friends and returns the data', async () => {
const data: FriendEntry[] = [{ friendshipId: 'f1', friend: friendProfile(), since: '2024-01-01' }];
get.mockResolvedValue({ data });
const result = await getFriends();
expect(get).toHaveBeenCalledWith('/friends');
expect(result).toBe(data);
});
});
describe('getPendingRequests', () => {
it('GETs /friends/requests/pending and returns the data', async () => {
const data: PendingRequest[] = [
{ friendshipId: 'p1', from: friendProfile(), sentAt: '2024-01-01' },
];
get.mockResolvedValue({ data });
const result = await getPendingRequests();
expect(get).toHaveBeenCalledWith('/friends/requests/pending');
expect(result).toBe(data);
});
});
describe('getSentRequests', () => {
it('GETs /friends/requests/sent and returns the data', async () => {
const data: SentRequest[] = [{ friendshipId: 's1', to: friendProfile(), sentAt: '2024-01-01' }];
get.mockResolvedValue({ data });
const result = await getSentRequests();
expect(get).toHaveBeenCalledWith('/friends/requests/sent');
expect(result).toBe(data);
});
});
describe('getPendingCount', () => {
it('GETs /friends/pending-count and returns the data', async () => {
get.mockResolvedValue({ data: 4 });
const result = await getPendingCount();
expect(get).toHaveBeenCalledWith('/friends/pending-count');
expect(result).toBe(4);
});
});
describe('searchUsers', () => {
it('GETs /friends/search with the query param', async () => {
const data: SearchResult[] = [
{ ...friendProfile(), friendshipStatus: null, friendshipId: null, friendshipDirection: null },
];
get.mockResolvedValue({ data });
const result = await searchUsers('alice');
expect(get).toHaveBeenCalledWith('/friends/search', { params: { q: 'alice' } });
expect(result).toBe(data);
});
});
describe('sendFriendRequest', () => {
it('POSTs /friends/request with the addressee id', async () => {
const data = { friendshipId: 'p1' };
post.mockResolvedValue({ data });
const result = await sendFriendRequest('u2');
expect(post).toHaveBeenCalledWith('/friends/request', { addresseeId: 'u2' });
expect(result).toBe(data);
});
});
describe('acceptRequest', () => {
it('POSTs /friends/:id/accept', async () => {
const data = { friendshipId: 'p1' };
post.mockResolvedValue({ data });
const result = await acceptRequest('p1');
expect(post).toHaveBeenCalledWith('/friends/p1/accept');
expect(result).toBe(data);
});
});
describe('rejectRequest', () => {
it('POSTs /friends/:id/reject', async () => {
const data = { friendshipId: 'p1' };
post.mockResolvedValue({ data });
const result = await rejectRequest('p1');
expect(post).toHaveBeenCalledWith('/friends/p1/reject');
expect(result).toBe(data);
});
});
describe('removeFriend', () => {
it('DELETEs /friends/:id', async () => {
const data = { success: true };
del.mockResolvedValue({ data });
const result = await removeFriend('f1');
expect(del).toHaveBeenCalledWith('/friends/f1');
expect(result).toBe(data);
});
});
describe('createFriendChallenge', () => {
it('POSTs /versus/friend-challenge with friendId, difficulty and isAsync', async () => {
const data = { id: 'match1' };
post.mockResolvedValue({ data });
const result = await createFriendChallenge('friend-1', 'hard', true);
expect(post).toHaveBeenCalledWith('/versus/friend-challenge', {
friendId: 'friend-1',
difficulty: 'hard',
isAsync: true,
});
expect(result).toBe(data);
});
});
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest';
import type { GameNightResults, GameNightRoom } from './game-night';
const get = vi.fn();
const post = vi.fn();
vi.mock('@/api/client', () => ({
default: {
get: (...a: unknown[]) => get(...a),
post: (...a: unknown[]) => post(...a),
},
}));
const { createRoom, getOpenRooms, joinRoom, getRoomResults } = await import('./game-night');
const room = (): GameNightRoom => ({
id: 'r1',
hostId: 'u1',
hostPlaying: true,
movieAId: 1,
movieATitle: 'A',
movieBId: 2,
movieBTitle: 'B',
roomName: null,
hasPassword: false,
status: 'open',
maxPlayers: 8,
timeoutMinutes: null,
createdAt: '2024-01-01',
startedAt: null,
finishedAt: null,
host: { id: 'u1', username: 'alice' },
players: [],
});
describe('createRoom', () => {
it('POSTs /game-night with the given body and returns the room', async () => {
const data = room();
post.mockResolvedValue({ data });
const body = { movieAId: 1, movieATitle: 'A', movieBId: 2, movieBTitle: 'B' };
const result = await createRoom(body);
expect(post).toHaveBeenCalledWith('/game-night', body);
expect(result).toBe(data);
});
});
describe('getOpenRooms', () => {
it('GETs /game-night/open with the abort signal', async () => {
const data = [room()];
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getOpenRooms(controller.signal);
expect(get).toHaveBeenCalledWith('/game-night/open', { signal: controller.signal });
expect(result).toBe(data);
});
});
describe('joinRoom', () => {
it('POSTs /game-night/:id/join with an optional password', async () => {
const data = room();
post.mockResolvedValue({ data });
const result = await joinRoom('r1', 'secret');
expect(post).toHaveBeenCalledWith('/game-night/r1/join', { password: 'secret' });
expect(result).toBe(data);
});
});
describe('getRoomResults', () => {
it('GETs /game-night/:id/results with the abort signal', async () => {
const data: GameNightResults = {
room: {
id: 'r1',
roomName: null,
movieAId: 1,
movieATitle: 'A',
movieBId: 2,
movieBTitle: 'B',
startedAt: null,
finishedAt: null,
status: 'finished',
},
host: { id: 'u1', username: 'alice' },
rankings: { byScore: [], byTime: [], byLinks: [] },
};
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getRoomResults('r1', controller.signal);
expect(get).toHaveBeenCalledWith('/game-night/r1/results', { signal: controller.signal });
expect(result).toBe(data);
});
});
+173
View File
@@ -0,0 +1,173 @@
import { describe, expect, it, vi } from 'vitest';
import type { ScoreBreakdown } from '@/types';
import type { GameHistoryEntry, GameHistoryResponse, HintResponse, ValidateLinkResponse } from './games';
const get = vi.fn();
const post = vi.fn();
vi.mock('@/api/client', () => ({
default: {
get: (...a: unknown[]) => get(...a),
post: (...a: unknown[]) => post(...a),
},
}));
const {
createGameSession,
completeGameSession,
useHint,
getGameHistory,
validateLink,
undoLink,
getGameSession,
} = await import('./games');
const score = (): ScoreBreakdown => ({
baseScore: 1000,
chainLength: 5,
linkCount: 2,
chainLengthBonus: 800,
timeBonus: 500,
obscurityBonus: 100,
hintPenalty: 0,
duplicatePenalty: 0,
elapsedSeconds: 300,
totalScore: 2400,
});
describe('createGameSession', () => {
it('POSTs /games with the required fields only when optional ones are omitted', async () => {
const data = { id: 'g1', startedAt: 'now' };
post.mockResolvedValue({ data });
const result = await createGameSession(100, 'Movie A', 200, 'Movie B');
expect(post).toHaveBeenCalledWith('/games', {
movieAId: 100,
movieATitle: 'Movie A',
movieBId: 200,
movieBTitle: 'Movie B',
});
expect(result).toBe(data);
});
it('includes dailyChallengeId and release dates only when provided', async () => {
post.mockResolvedValue({ data: { id: 'g2', startedAt: 'now' } });
await createGameSession(100, 'Movie A', 200, 'Movie B', 'dc1', '2000-01-01', '2001-01-01');
expect(post).toHaveBeenCalledWith('/games', {
movieAId: 100,
movieATitle: 'Movie A',
movieBId: 200,
movieBTitle: 'Movie B',
dailyChallengeId: 'dc1',
movieAReleaseDate: '2000-01-01',
movieBReleaseDate: '2001-01-01',
});
});
it('omits a null release date rather than sending null', async () => {
post.mockResolvedValue({ data: { id: 'g3', startedAt: 'now' } });
await createGameSession(100, 'Movie A', 200, 'Movie B', undefined, null, null);
const body = post.mock.calls[0]?.[1] as Record<string, unknown>;
expect(body).not.toHaveProperty('movieAReleaseDate');
expect(body).not.toHaveProperty('movieBReleaseDate');
expect(body).not.toHaveProperty('dailyChallengeId');
});
});
describe('completeGameSession', () => {
it('POSTs /games/:id/complete with an empty body and returns the score', async () => {
const data = score();
post.mockResolvedValue({ data });
const result = await completeGameSession('g1');
expect(post).toHaveBeenCalledWith('/games/g1/complete', {});
expect(result).toBe(data);
});
});
describe('useHint', () => {
it('POSTs /games/:id/hint with an empty body and returns the hint payload', async () => {
const data: HintResponse = { hintsUsed: 1, hintsRemaining: 2, actors: [] };
post.mockResolvedValue({ data });
const result = await useHint('g1');
expect(post).toHaveBeenCalledWith('/games/g1/hint', {});
expect(result).toBe(data);
});
});
describe('getGameHistory', () => {
it('GETs /games/me/history with default page/limit and the abort signal', async () => {
const data: GameHistoryResponse = {
data: [],
total: 0,
page: 1,
limit: 20,
totalPages: 0,
};
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getGameHistory(undefined, undefined, controller.signal);
expect(get).toHaveBeenCalledWith('/games/me/history', {
params: { page: 1, limit: 20 },
signal: controller.signal,
});
expect(result).toBe(data);
});
it('forwards an explicit page and limit', async () => {
get.mockResolvedValue({ data: { data: [], total: 0, page: 2, limit: 5, totalPages: 0 } });
await getGameHistory(2, 5);
expect(get).toHaveBeenCalledWith('/games/me/history', {
params: { page: 2, limit: 5 },
signal: undefined,
});
});
});
describe('validateLink', () => {
it('POSTs /games/:id/validate-link with the link and returns the result', async () => {
const data: ValidateLinkResponse = { valid: true, chain: [], loopComplete: false, passedMovieB: false };
post.mockResolvedValue({ data });
const link = { type: 'actor' as const, id: 7, name: 'Actor Seven' };
const result = await validateLink('g1', link);
expect(post).toHaveBeenCalledWith('/games/g1/validate-link', link);
expect(result).toBe(data);
});
});
describe('undoLink', () => {
it('POSTs /games/:id/undo and returns the resulting chain', async () => {
const data = { chain: [] };
post.mockResolvedValue({ data });
const result = await undoLink('g1');
expect(post).toHaveBeenCalledWith('/games/g1/undo');
expect(result).toBe(data);
});
});
describe('getGameSession', () => {
it('GETs /games/:id with the abort signal and returns the data', async () => {
const data = { id: 'g1' } as unknown as GameHistoryEntry;
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getGameSession('g1', controller.signal);
expect(get).toHaveBeenCalledWith('/games/g1', { signal: controller.signal });
expect(result).toBe(data);
});
});
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from 'vitest';
import type { DailyCompletionStatus, LeaderboardResponse, UserStats } from './leaderboards';
const get = vi.fn();
vi.mock('@/api/client', () => ({
default: { get: (...a: unknown[]) => get(...a) },
}));
const { getLeaderboard, getUserStats, checkDailyCompletion } = await import('./leaderboards');
const leaderboardResponse = (): LeaderboardResponse => ({
data: [],
total: 0,
page: 1,
limit: 20,
totalPages: 0,
});
describe('getLeaderboard', () => {
it('GETs /leaderboards with the daily defaults and no difficulty filter', async () => {
const data = leaderboardResponse();
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getLeaderboard(undefined, undefined, undefined, undefined, controller.signal);
expect(get).toHaveBeenCalledWith('/leaderboards', {
signal: controller.signal,
params: { period: 'daily', page: 1, limit: 20 },
});
expect(result).toBe(data);
});
it('forwards period, page, limit and difficulty', async () => {
get.mockResolvedValue({ data: leaderboardResponse() });
await getLeaderboard('weekly', 2, 10, 'hard');
expect(get).toHaveBeenCalledWith('/leaderboards', {
signal: undefined,
params: { period: 'weekly', page: 2, limit: 10, difficulty: 'hard' },
});
});
it('omits difficulty entirely when not given', async () => {
get.mockResolvedValue({ data: leaderboardResponse() });
await getLeaderboard('all-time', 1, 20);
const call = get.mock.calls[0]?.[1] as { params: Record<string, unknown> };
expect(call.params).not.toHaveProperty('difficulty');
});
});
describe('getUserStats', () => {
it('GETs /leaderboards/me with the daily default mode', async () => {
const data: UserStats = {
totalGamesPlayed: 5,
bestScore: 100,
averageScore: 50,
totalScore: 250,
currentStreak: 1,
longestStreak: 2,
recentScores: [],
};
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getUserStats(undefined, controller.signal);
expect(get).toHaveBeenCalledWith('/leaderboards/me', {
params: { mode: 'daily' },
signal: controller.signal,
});
expect(result).toBe(data);
});
it('forwards an explicit mode', async () => {
get.mockResolvedValue({
data: {
totalGamesPlayed: 0,
bestScore: 0,
averageScore: 0,
totalScore: 0,
currentStreak: 0,
longestStreak: 0,
recentScores: [],
},
});
await getUserStats('all');
expect(get).toHaveBeenCalledWith('/leaderboards/me', { params: { mode: 'all' }, signal: undefined });
});
});
describe('checkDailyCompletion', () => {
it('GETs /leaderboards/me/today and returns the data', async () => {
const data: DailyCompletionStatus = { easy: true, medium: false, hard: false };
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await checkDailyCompletion(controller.signal);
expect(get).toHaveBeenCalledWith('/leaderboards/me/today', { signal: controller.signal });
expect(result).toBe(data);
});
});
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest';
import type {
TmdbMovieCreditsResponse,
TmdbMovieDetailsResponse,
TmdbSearchMovieResponse,
} from '@/types';
const get = vi.fn();
vi.mock('@/api/client', () => ({
default: { get: (...a: unknown[]) => get(...a) },
}));
const { searchMovies, getMovieDetails, discoverMovies, getMovieCredits } = await import(
'./movies'
);
const searchResponse = (): TmdbSearchMovieResponse => ({
page: 1,
results: [],
total_pages: 1,
total_results: 0,
});
describe('searchMovies', () => {
it('GETs /movies/search with q and the default page', async () => {
const data = searchResponse();
get.mockResolvedValue({ data });
const result = await searchMovies('inception');
expect(get).toHaveBeenCalledWith('/movies/search', { params: { q: 'inception', page: 1 } });
expect(result).toBe(data);
});
it('passes an explicit page through', async () => {
get.mockResolvedValue({ data: searchResponse() });
await searchMovies('inception', 4);
expect(get).toHaveBeenCalledWith('/movies/search', { params: { q: 'inception', page: 4 } });
});
});
describe('getMovieDetails', () => {
it('GETs /movies/:id and returns the data', async () => {
const data: TmdbMovieDetailsResponse = {
id: 27205,
title: 'Inception',
original_title: 'Inception',
overview: '',
poster_path: null,
backdrop_path: null,
release_date: '2010-07-16',
popularity: 80,
vote_average: 8.4,
vote_count: 30000,
genre_ids: [],
adult: false,
runtime: 148,
budget: 160000000,
revenue: 830000000,
status: 'Released',
tagline: 'Your mind is the scene of the crime.',
genres: [],
};
get.mockResolvedValue({ data });
const result = await getMovieDetails(27205);
expect(get).toHaveBeenCalledWith('/movies/27205');
expect(result).toBe(data);
});
});
describe('discoverMovies', () => {
it('GETs /movies/discover with the default page', async () => {
const data = searchResponse();
get.mockResolvedValue({ data });
const result = await discoverMovies();
expect(get).toHaveBeenCalledWith('/movies/discover', { params: { page: 1 } });
expect(result).toBe(data);
});
it('passes an explicit page through', async () => {
get.mockResolvedValue({ data: searchResponse() });
await discoverMovies(2);
expect(get).toHaveBeenCalledWith('/movies/discover', { params: { page: 2 } });
});
});
describe('getMovieCredits', () => {
it('GETs /movies/:id/credits and returns the data', async () => {
const data: TmdbMovieCreditsResponse = { id: 27205, cast: [] };
get.mockResolvedValue({ data });
const result = await getMovieCredits(27205);
expect(get).toHaveBeenCalledWith('/movies/27205/credits');
expect(result).toBe(data);
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest';
import type { Notification } from './notifications';
const get = vi.fn();
const patch = vi.fn();
const post = vi.fn();
const del = vi.fn();
vi.mock('@/api/client', () => ({
default: {
get: (...a: unknown[]) => get(...a),
patch: (...a: unknown[]) => patch(...a),
post: (...a: unknown[]) => post(...a),
delete: (...a: unknown[]) => del(...a),
},
}));
const {
getNotifications,
getUnreadCount,
markNotificationRead,
markAllNotificationsRead,
deleteNotification,
} = await import('./notifications');
const notif = (): Notification => ({
id: 'n1',
userId: 'u1',
type: 'friend-request',
title: 'Title',
message: 'Message',
data: null,
read: false,
createdAt: '2024-01-01T00:00:00Z',
});
describe('getNotifications', () => {
it('GETs /notifications with default page and limit', async () => {
const data = { data: [notif()], total: 1, unreadCount: 1, page: 1, limit: 20 };
get.mockResolvedValue({ data });
const result = await getNotifications();
expect(get).toHaveBeenCalledWith('/notifications', { params: { page: 1, limit: 20 } });
expect(result).toBe(data);
});
it('forwards an explicit page and limit', async () => {
get.mockResolvedValue({ data: { data: [], total: 0, unreadCount: 0, page: 2, limit: 5 } });
await getNotifications(2, 5);
expect(get).toHaveBeenCalledWith('/notifications', { params: { page: 2, limit: 5 } });
});
});
describe('getUnreadCount', () => {
it('GETs /notifications/unread-count and returns just the count', async () => {
get.mockResolvedValue({ data: { count: 7 } });
const result = await getUnreadCount();
expect(get).toHaveBeenCalledWith('/notifications/unread-count');
expect(result).toBe(7);
});
});
describe('markNotificationRead', () => {
it('PATCHes /notifications/:id/read', async () => {
patch.mockResolvedValue({ data: undefined });
await markNotificationRead('n1');
expect(patch).toHaveBeenCalledWith('/notifications/n1/read');
});
});
describe('markAllNotificationsRead', () => {
it('POSTs /notifications/read-all', async () => {
post.mockResolvedValue({ data: undefined });
await markAllNotificationsRead();
expect(post).toHaveBeenCalledWith('/notifications/read-all');
});
});
describe('deleteNotification', () => {
it('DELETEs /notifications/:id', async () => {
del.mockResolvedValue({ data: undefined });
await deleteNotification('n1');
expect(del).toHaveBeenCalledWith('/notifications/n1');
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest';
import type { TmdbPersonMovieCreditsResponse, TmdbSearchPersonResponse } from '@/types';
const get = vi.fn();
vi.mock('@/api/client', () => ({
default: { get: (...a: unknown[]) => get(...a) },
}));
const { searchPersons, getPersonMovieCredits } = await import('./persons');
describe('searchPersons', () => {
it('GETs /persons/search with q and the default page', async () => {
const data: TmdbSearchPersonResponse = {
page: 1,
results: [],
total_pages: 1,
total_results: 0,
};
get.mockResolvedValue({ data });
const result = await searchPersons('tom hanks');
expect(get).toHaveBeenCalledWith('/persons/search', { params: { q: 'tom hanks', page: 1 } });
expect(result).toBe(data);
});
it('passes an explicit page through', async () => {
get.mockResolvedValue({ data: { page: 3, results: [], total_pages: 5, total_results: 90 } });
await searchPersons('tom hanks', 3);
expect(get).toHaveBeenCalledWith('/persons/search', { params: { q: 'tom hanks', page: 3 } });
});
});
describe('getPersonMovieCredits', () => {
it('GETs /persons/:id/movie-credits and returns the data', async () => {
const data: TmdbPersonMovieCreditsResponse = { id: 42, cast: [] };
get.mockResolvedValue({ data });
const result = await getPersonMovieCredits(42);
expect(get).toHaveBeenCalledWith('/persons/42/movie-credits');
expect(result).toBe(data);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest';
const patch = vi.fn();
vi.mock('@/api/client', () => ({
default: { patch: (...a: unknown[]) => patch(...a) },
}));
const { updateProfile, changePassword } = await import('./users');
describe('updateProfile', () => {
it('PATCHes /users/me with the given fields and returns the data', async () => {
const data = { id: 'u1', displayName: 'New Name' };
patch.mockResolvedValue({ data });
const result = await updateProfile({ displayName: 'New Name' });
expect(patch).toHaveBeenCalledWith('/users/me', { displayName: 'New Name' });
expect(result).toBe(data);
});
it('forwards an avatarUrl update', async () => {
patch.mockResolvedValue({ data: {} });
await updateProfile({ avatarUrl: 'https://example.com/a.png' });
expect(patch).toHaveBeenCalledWith('/users/me', { avatarUrl: 'https://example.com/a.png' });
});
});
describe('changePassword', () => {
it('PATCHes /users/me/password with both passwords and returns the data', async () => {
const data = { success: true };
patch.mockResolvedValue({ data });
const result = await changePassword('old-pw', 'new-pw');
expect(patch).toHaveBeenCalledWith('/users/me/password', {
currentPassword: 'old-pw',
newPassword: 'new-pw',
});
expect(result).toBe(data);
});
});
+202
View File
@@ -0,0 +1,202 @@
import { describe, expect, it, vi } from 'vitest';
import type { AsyncAttemptResponse, AsyncLeaderboardResponse, VersusMatch } from './versus';
const get = vi.fn();
const post = vi.fn();
vi.mock('@/api/client', () => ({
default: {
get: (...a: unknown[]) => get(...a),
post: (...a: unknown[]) => post(...a),
},
}));
const {
createMatch,
getWaitingMatches,
joinMatch,
cancelMatch,
getMyWaitingMatches,
getMatch,
submitCreatorScore,
startAsyncAttempt,
submitAsyncAttempt,
getAsyncLeaderboard,
} = await import('./versus');
const match = (): VersusMatch => ({
id: 'm1',
movieAId: 1,
movieBId: 2,
difficulty: 'medium',
status: 'waiting',
lobbyName: null,
winnerId: null,
player1: { id: 'u1', username: 'alice' },
player2: null,
player1Score: null,
player2Score: null,
createdAt: '2024-01-01',
startedAt: null,
finishedAt: null,
});
describe('createMatch', () => {
it('POSTs /versus with a default difficulty and no lobby name/password', async () => {
const data = match();
post.mockResolvedValue({ data });
const result = await createMatch();
expect(post).toHaveBeenCalledWith('/versus', {
difficulty: 'medium',
lobbyName: undefined,
password: undefined,
isAsync: undefined,
});
expect(result).toBe(data);
});
it('forwards every field when given', async () => {
post.mockResolvedValue({ data: match() });
await createMatch('hard', 'Lobby', 'secret', true);
expect(post).toHaveBeenCalledWith('/versus', {
difficulty: 'hard',
lobbyName: 'Lobby',
password: 'secret',
isAsync: true,
});
});
});
describe('getWaitingMatches', () => {
it('GETs /versus/waiting with no params and the abort signal when nothing is given', async () => {
const data = [match()];
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getWaitingMatches(undefined, undefined, controller.signal);
expect(get).toHaveBeenCalledWith('/versus/waiting', { params: {}, signal: controller.signal });
expect(result).toBe(data);
});
it('includes excludeUser and mode when given', async () => {
get.mockResolvedValue({ data: [] });
await getWaitingMatches('u1', 'daily');
expect(get).toHaveBeenCalledWith('/versus/waiting', {
params: { excludeUser: 'u1', mode: 'daily' },
signal: undefined,
});
});
});
describe('joinMatch', () => {
it('POSTs /versus/:id/join with a password', async () => {
const data = match();
post.mockResolvedValue({ data });
const result = await joinMatch('m1', 'secret');
expect(post).toHaveBeenCalledWith('/versus/m1/join', { password: 'secret' });
expect(result).toBe(data);
});
});
describe('cancelMatch', () => {
it('POSTs /versus/:id/cancel with no body', async () => {
post.mockResolvedValue({ data: undefined });
await cancelMatch('m1');
expect(post).toHaveBeenCalledWith('/versus/m1/cancel');
});
});
describe('getMyWaitingMatches', () => {
it('GETs /versus/me/waiting with the abort signal', async () => {
const data = [match()];
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getMyWaitingMatches(controller.signal);
expect(get).toHaveBeenCalledWith('/versus/me/waiting', { signal: controller.signal });
expect(result).toBe(data);
});
});
describe('getMatch', () => {
it('GETs /versus/:id with the abort signal', async () => {
const data = match();
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getMatch('m1', controller.signal);
expect(get).toHaveBeenCalledWith('/versus/m1', { signal: controller.signal });
expect(result).toBe(data);
});
});
describe('submitCreatorScore', () => {
it('POSTs /versus/:id/creator-score with the session id', async () => {
const data = match();
post.mockResolvedValue({ data });
const result = await submitCreatorScore('m1', 'session-1');
expect(post).toHaveBeenCalledWith('/versus/m1/creator-score', { sessionId: 'session-1' });
expect(result).toBe(data);
});
});
describe('startAsyncAttempt', () => {
it('POSTs /versus/:id/async-attempt with an optional password', async () => {
const data: AsyncAttemptResponse = {
attempt: { id: 'a1', matchId: 'm1', playerId: 'u1' },
movieAId: 1,
movieATitle: 'A',
movieBId: 2,
movieBTitle: 'B',
chainLength: null,
};
post.mockResolvedValue({ data });
const result = await startAsyncAttempt('m1', 'secret');
expect(post).toHaveBeenCalledWith('/versus/m1/async-attempt', { password: 'secret' });
expect(result).toBe(data);
});
});
describe('submitAsyncAttempt', () => {
it('POSTs /versus/:id/async-attempt/submit with the session id and returns the data', async () => {
const data = { rank: 1 };
post.mockResolvedValue({ data });
const result = await submitAsyncAttempt('m1', 'session-1');
expect(post).toHaveBeenCalledWith('/versus/m1/async-attempt/submit', { sessionId: 'session-1' });
expect(result).toBe(data);
});
});
describe('getAsyncLeaderboard', () => {
it('GETs /versus/:id/leaderboard with the abort signal', async () => {
const data: AsyncLeaderboardResponse = {
match: { id: 'm1', difficulty: 'medium', lobbyName: null, expiresAt: null, status: 'open' },
creator: { id: 'u1', username: 'alice' },
leaderboard: [],
};
get.mockResolvedValue({ data });
const controller = new AbortController();
const result = await getAsyncLeaderboard('m1', controller.signal);
expect(get).toHaveBeenCalledWith('/versus/m1/leaderboard', { signal: controller.signal });
expect(result).toBe(data);
});
});
@@ -0,0 +1,186 @@
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 } from 'vitest';
import { useGameStore } from '@/stores/game-store';
import { useGameNightStore } from '@/stores/game-night-store';
import GameNightCompletionModal from './GameNightCompletionModal';
import type { GameNightPlayer } from '@/api/game-night';
import type { ScoreBreakdown } from '@/types';
const score = (totalScore: number): ScoreBreakdown => ({
baseScore: 0,
chainLength: 0,
linkCount: 0,
chainLengthBonus: 0,
timeBonus: 0,
obscurityBonus: 0,
hintPenalty: 0,
duplicatePenalty: 0,
elapsedSeconds: 0,
totalScore,
});
const player = (
id: string,
username: string,
overrides: Partial<GameNightPlayer> = {},
): GameNightPlayer => ({
id,
userId: id,
isReady: true,
isHost: false,
isSpectator: false,
score: null,
chainLength: 0,
completed: false,
forfeited: false,
timedOut: false,
disconnected: false,
joinedAt: new Date().toISOString(),
completedAt: null,
user: { id, username },
...overrides,
});
function renderModal(initialEntry = '/game-night/room1') {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/game-night/:roomId" element={<GameNightCompletionModal />} />
<Route path="/game-night" element={<div>lobby-list route</div>} />
<Route path="/game-night/:roomId/results" element={<div>results route</div>} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
useGameStore.getState().resetGame();
useGameNightStore.setState({
lobbyState: 'idle',
players: [],
finishedPlayers: new Set(),
forfeitedPlayers: new Set(),
roomId: 'room1',
});
});
describe('GameNightCompletionModal', () => {
it('is closed while the chain is incomplete and the room has not finished', () => {
renderModal();
expect(screen.queryByRole('dialog')).toBeNull();
});
it('opens with "Chain Complete!" once my chain is done, before the room finishes', () => {
useGameStore.setState({ status: 'completed', score: score(1200) });
renderModal();
expect(screen.getByRole('heading', { name: 'Chain Complete!' })).toBeInTheDocument();
expect(screen.getByText('1200')).toBeInTheDocument();
expect(screen.getByText('Your Score')).toBeInTheDocument();
});
it('shows no score panel when the store has none yet', () => {
useGameStore.setState({ status: 'completed', score: null });
renderModal();
expect(screen.queryByText('Your Score')).toBeNull();
});
it('lists the other active players with singular wording for exactly one waiting', () => {
useGameStore.setState({ status: 'completed', score: score(500) });
useGameNightStore.setState({
players: [player('u1', 'Me'), player('u2', 'Rival')],
finishedPlayers: new Set(['u1']),
});
renderModal();
expect(screen.getByText('Waiting for 1 player...')).toBeInTheDocument();
expect(screen.getByText('Rival')).toBeInTheDocument();
});
it('uses plural wording for more than one player still waiting', () => {
useGameStore.setState({ status: 'completed', score: score(500) });
useGameNightStore.setState({
players: [player('u1', 'Me'), player('u2', 'Rival'), player('u3', 'Third')],
finishedPlayers: new Set(['u1']),
});
renderModal();
expect(screen.getByText('Waiting for 2 players...')).toBeInTheDocument();
});
it('excludes spectators from the waiting count and the roster', () => {
useGameStore.setState({ status: 'completed', score: score(500) });
useGameNightStore.setState({
players: [player('u1', 'Me'), player('u2', 'Spec', { isSpectator: true })],
finishedPlayers: new Set(['u1']),
});
renderModal();
expect(screen.queryByText(/Waiting for/)).toBeNull();
expect(screen.queryByText('Spec')).toBeNull();
});
it('shows a forfeit flag, a finished check, and a spinner per player state', () => {
useGameStore.setState({ status: 'completed', score: score(500) });
useGameNightStore.setState({
players: [
player('u1', 'Me'),
player('u2', 'Forfeiter'),
player('u3', 'StillGoing'),
],
finishedPlayers: new Set(['u1', 'u2']),
forfeitedPlayers: new Set(['u2']),
});
renderModal();
expect(screen.getByText('Forfeiter')).toBeInTheDocument();
expect(screen.getByText('StillGoing')).toBeInTheDocument();
// The dialog renders through a portal onto document.body, outside the
// render container, so icons are queried from the document.
expect(document.querySelector('.lucide-flag')).not.toBeNull();
expect(document.querySelector('.lucide-circle-check')).not.toBeNull();
expect(document.querySelector('.lucide-loader-circle')).not.toBeNull();
});
it('lets a waiting player leave, which resets game-night state and returns to the lobby list', async () => {
useGameStore.setState({ status: 'completed', score: score(500) });
useGameNightStore.setState({
players: [player('u1', 'Me'), player('u2', 'Rival')],
finishedPlayers: new Set(['u1']),
});
renderModal();
await userEvent.click(screen.getByRole('button', { name: 'Leave game' }));
expect(useGameNightStore.getState().roomId).toBeNull();
expect(useGameNightStore.getState().lobbyState).toBe('idle');
expect(screen.getByText('lobby-list route')).toBeInTheDocument();
});
it('shows "Game Over!" and a results button once the room has finished', async () => {
useGameNightStore.setState({ lobbyState: 'finished', roomId: 'room1' });
renderModal('/game-night/room1');
expect(screen.getByRole('heading', { name: 'Game Over!' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'View Results' }));
expect(screen.getByText('results route')).toBeInTheDocument();
});
it('does nothing when viewing results without a known room id', async () => {
useGameNightStore.setState({ lobbyState: 'finished', roomId: null });
renderModal('/game-night/room1');
await userEvent.click(screen.getByRole('button', { name: 'View Results' }));
expect(screen.queryByText('results route')).toBeNull();
});
it('hides the waiting roster once the room has finished, even if the map is stale', () => {
useGameNightStore.setState({
lobbyState: 'finished',
players: [player('u1', 'Me'), player('u2', 'Rival')],
finishedPlayers: new Set(['u1']),
});
renderModal();
expect(screen.queryByText(/Waiting for/)).toBeNull();
});
});
@@ -0,0 +1,90 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import PlayerCard from './PlayerCard';
import type { GameNightPlayer } from '@/api/game-night';
const player = (overrides: Partial<GameNightPlayer> = {}): GameNightPlayer => ({
id: 'p1',
userId: 'u1',
isReady: false,
isHost: false,
isSpectator: false,
score: null,
chainLength: 0,
completed: false,
forfeited: false,
timedOut: false,
disconnected: false,
joinedAt: new Date().toISOString(),
completedAt: null,
user: { id: 'u1', username: 'Alice' },
...overrides,
});
describe('PlayerCard', () => {
it('shows the username', () => {
render(<PlayerCard player={player()} isCurrentUser={false} />);
expect(screen.getByText('Alice')).toBeInTheDocument();
});
it('marks a host with a crown', () => {
render(<PlayerCard player={player({ isHost: true })} isCurrentUser={false} />);
expect(document.querySelector('.lucide-crown')).not.toBeNull();
});
it('shows a spectator badge instead of ready state', () => {
render(<PlayerCard player={player({ isSpectator: true })} isCurrentUser={false} />);
expect(screen.getByText('Spectator')).toBeInTheDocument();
});
it('shows a filled check when ready', () => {
render(<PlayerCard player={player({ isReady: true })} isCurrentUser={false} />);
expect(document.querySelector('.lucide-circle-check')).not.toBeNull();
});
it('shows an empty circle when not ready', () => {
render(<PlayerCard player={player({ isReady: false })} isCurrentUser={false} />);
expect(document.querySelector('.lucide-circle:not(.lucide-circle-check)')).not.toBeNull();
});
it('has no ready toggle for a spectator, even when a handler is given', () => {
const onToggleReady = vi.fn();
render(
<PlayerCard
player={player({ isSpectator: true })}
isCurrentUser={false}
onToggleReady={onToggleReady}
/>,
);
expect(screen.queryByRole('button')).toBeNull();
});
it('has no ready toggle when no handler is given', () => {
render(<PlayerCard player={player()} isCurrentUser={false} />);
expect(screen.queryByRole('button')).toBeNull();
});
it('offers a "Ready" toggle that calls the handler', async () => {
const onToggleReady = vi.fn();
render(
<PlayerCard player={player({ isReady: false })} isCurrentUser={false} onToggleReady={onToggleReady} />,
);
await userEvent.click(screen.getByRole('button', { name: 'Ready' }));
expect(onToggleReady).toHaveBeenCalledTimes(1);
});
it('offers an "Unready" toggle once ready', () => {
render(
<PlayerCard player={player({ isReady: true })} isCurrentUser={false} onToggleReady={vi.fn()} />,
);
expect(screen.getByRole('button', { name: 'Unready' })).toBeInTheDocument();
});
it('highlights the current user with a distinct border class', () => {
const { container: mine } = render(<PlayerCard player={player()} isCurrentUser />);
const { container: theirs } = render(<PlayerCard player={player()} isCurrentUser={false} />);
expect(mine.firstElementChild?.className).toContain('border-primary');
expect(theirs.firstElementChild?.className).toContain('border-border');
});
});
@@ -0,0 +1,114 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { useGameNightStore } from '@/stores/game-night-store';
import PlayersProgress from './PlayersProgress';
import type { GameNightPlayer } from '@/api/game-night';
const player = (
id: string,
username: string,
overrides: Partial<GameNightPlayer> = {},
): GameNightPlayer => ({
id,
userId: id,
isReady: true,
isHost: false,
isSpectator: false,
score: null,
chainLength: 0,
completed: false,
forfeited: false,
timedOut: false,
disconnected: false,
joinedAt: new Date().toISOString(),
completedAt: null,
user: { id, username },
...overrides,
});
beforeEach(() => {
useGameNightStore.setState({
players: [],
playerProgress: new Map(),
finishedPlayers: new Set(),
forfeitedPlayers: new Set(),
});
});
describe('PlayersProgress', () => {
it('shows each active player with their live link count', () => {
useGameNightStore.setState({
players: [player('u1', 'Me'), player('u2', 'Rival')],
playerProgress: new Map([
['u1', 3],
['u2', 0],
]),
});
render(<PlayersProgress />);
expect(screen.getByText('Me')).toBeInTheDocument();
expect(screen.getByText('3 links')).toBeInTheDocument();
expect(screen.getByText('0 links')).toBeInTheDocument();
});
it('excludes spectators from the roster', () => {
useGameNightStore.setState({
players: [player('u1', 'Me'), player('u2', 'Watcher', { isSpectator: true })],
});
render(<PlayersProgress />);
expect(screen.queryByText('Watcher')).toBeNull();
});
it('shows a "Done" badge for a finished player', () => {
useGameNightStore.setState({
players: [player('u1', 'Me')],
finishedPlayers: new Set(['u1']),
});
render(<PlayersProgress />);
expect(screen.getByText('Done')).toBeInTheDocument();
expect(document.querySelector('.lucide-circle-check')).not.toBeNull();
});
it('shows a "Forfeited" badge and takes priority over finished', () => {
useGameNightStore.setState({
players: [player('u1', 'Me')],
finishedPlayers: new Set(['u1']),
forfeitedPlayers: new Set(['u1']),
});
render(<PlayersProgress />);
expect(screen.getByText('Forfeited')).toBeInTheDocument();
expect(screen.queryByText('Done')).toBeNull();
expect(document.querySelector('.lucide-flag')).not.toBeNull();
});
it('shows a disconnected icon and a visually hidden note for a connected drop', () => {
useGameNightStore.setState({
players: [player('u1', 'Me', { disconnected: true })],
});
render(<PlayersProgress />);
expect(document.querySelector('.lucide-wifi-off')).not.toBeNull();
expect(screen.getByText('(disconnected)')).toBeInTheDocument();
});
it('does not show the disconnected note once a dropped player has finished', () => {
useGameNightStore.setState({
players: [player('u1', 'Me', { disconnected: true })],
finishedPlayers: new Set(['u1']),
});
render(<PlayersProgress />);
expect(screen.queryByText('(disconnected)')).toBeNull();
expect(document.querySelector('.lucide-circle-check')).not.toBeNull();
});
it('defaults an unreported chain length to zero', () => {
useGameNightStore.setState({ players: [player('u1', 'Me')] });
render(<PlayersProgress />);
expect(screen.getByText('0 links')).toBeInTheDocument();
});
it('shows a spinner for a connected player still playing', () => {
useGameNightStore.setState({ players: [player('u1', 'Me')] });
render(<PlayersProgress />);
expect(document.querySelector('.lucide-loader-circle')).not.toBeNull();
});
});
+38
View File
@@ -0,0 +1,38 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { ActorChainLink } from '@/types';
import ActorCard from './ActorCard';
const actor = (overrides: Partial<ActorChainLink> = {}): ActorChainLink => ({
type: 'actor',
id: 1,
name: 'Keanu Reeves',
profilePath: null,
popularity: 5,
...overrides,
});
describe('ActorCard', () => {
it('renders the full card with a placeholder image when there is no profile photo', () => {
render(<ActorCard actor={actor()} />);
const img = screen.getByRole('img', { name: 'Keanu Reeves' });
expect(img).toHaveAttribute('src', '/placeholder-person.svg');
expect(screen.getByText('Keanu Reeves')).toBeInTheDocument();
});
it('builds a TMDB image URL when a profile photo is known', () => {
render(<ActorCard actor={actor({ profilePath: '/keanu.jpg' })} />);
expect(screen.getByRole('img', { name: 'Keanu Reeves' })).toHaveAttribute(
'src',
'https://image.tmdb.org/t/p/w185/keanu.jpg',
);
});
it('renders the compact layout, still with a named image', () => {
render(<ActorCard actor={actor({ name: 'Carrie-Anne Moss' })} compact />);
expect(
screen.getByRole('img', { name: 'Carrie-Anne Moss' }),
).toBeInTheDocument();
expect(screen.getByText('Carrie-Anne Moss')).toBeInTheDocument();
});
});
@@ -0,0 +1,62 @@
import { act, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import CelebrationOverlay from './CelebrationOverlay';
/**
* Purely decorative confetti. Vestibular-disorder guidance says not to
* animate at all under prefers-reduced-motion, so the overlay must render
* nothing in that case rather than a still frame.
*/
function stubMatchMedia(matches: boolean) {
const mql = {
matches,
media: '(prefers-reduced-motion: reduce)',
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
vi.stubGlobal('matchMedia', vi.fn().mockReturnValue(mql));
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('CelebrationOverlay', () => {
it('renders confetti when the browser has no matchMedia support at all', () => {
// jsdom does not implement matchMedia by default; the component must
// treat that as "no preference" rather than throwing.
const { container } = render(<CelebrationOverlay />);
expect(container.firstElementChild).not.toBeNull();
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
expect(container.firstElementChild?.children).toHaveLength(20);
});
it('renders confetti when the user has not asked to reduce motion', () => {
stubMatchMedia(false);
const { container } = render(<CelebrationOverlay />);
expect(container.firstElementChild?.children).toHaveLength(20);
});
it('renders nothing under prefers-reduced-motion', () => {
stubMatchMedia(true);
const { container } = render(<CelebrationOverlay />);
expect(container).toBeEmptyDOMElement();
});
it('disappears on its own after three seconds', () => {
stubMatchMedia(false);
const { container } = render(<CelebrationOverlay />);
expect(container).not.toBeEmptyDOMElement();
act(() => {
vi.advanceTimersByTime(3000);
});
expect(container).toBeEmptyDOMElement();
});
});
+167
View File
@@ -0,0 +1,167 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import type { ActorChainLink, ChainLink, MovieChainLink } from '@/types';
const { useGameStore } = await import('@/stores/game-store');
const { default: ChainDisplay } = await import('./ChainDisplay');
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 MOVIE_A = movieLink(100, 'Movie A');
const MOVIE_B = movieLink(200, 'Movie B');
beforeEach(() => {
useGameStore.getState().resetGame();
});
describe('ChainDisplay in static mode (a finished chain passed as props)', () => {
it('renders the starting movie and every actor/movie pair', () => {
const chain: ChainLink[] = [
MOVIE_A,
actorLink(1),
MOVIE_B,
actorLink(2),
MOVIE_A,
];
render(<ChainDisplay chain={chain} movieAId={100} movieBId={200} />);
expect(screen.getAllByRole('img', { name: 'Movie A' })).toHaveLength(2);
expect(screen.getByRole('img', { name: 'Movie B' })).toBeInTheDocument();
expect(screen.getByText('Actor 1')).toBeInTheDocument();
expect(screen.getByText('Actor 2')).toBeInTheDocument();
expect(screen.getByText('Loop Complete!')).toBeInTheDocument();
});
it('marks the movie B slot with a B badge and the closing movie A slot with an A badge', () => {
const chain: ChainLink[] = [
MOVIE_A,
actorLink(1),
MOVIE_B,
actorLink(2),
MOVIE_A,
];
render(<ChainDisplay chain={chain} movieAId={100} movieBId={200} />);
// Both badges render as "Movie {A|B}"-labelled single-letter spans.
expect(screen.getAllByText('B')).toHaveLength(1);
expect(screen.getAllByText('A')).toHaveLength(1);
});
it('shows no pending slot or validating spinner for a static chain', () => {
const chain: ChainLink[] = [MOVIE_A, actorLink(1)];
render(<ChainDisplay chain={chain} movieAId={100} movieBId={200} />);
expect(screen.queryByText('Validating...')).toBeNull();
expect(screen.queryByText('?')).toBeNull();
});
});
describe('ChainDisplay in live mode (reading the game store)', () => {
it('renders nothing extra for a fresh, empty chain', () => {
useGameStore.setState({ chain: [], movieA: null, movieB: null, status: 'idle' });
render(<ChainDisplay />);
expect(screen.queryByRole('img')).toBeNull();
});
it('shows a dashed pending slot after an actor while awaiting the next movie', () => {
useGameStore.setState({
chain: [MOVIE_A, actorLink(1)],
movieA: MOVIE_A,
movieB: MOVIE_B,
status: 'playing',
isValidating: false,
});
render(<ChainDisplay />);
expect(screen.getByText('?')).toBeInTheDocument();
expect(screen.queryByText('Validating...')).toBeNull();
});
it('shows a validating spinner in the pending slot while a movie is being checked', () => {
useGameStore.setState({
chain: [MOVIE_A, actorLink(1)],
movieA: MOVIE_A,
movieB: MOVIE_B,
status: 'playing',
isValidating: true,
});
render(<ChainDisplay />);
// Two "Validating..." labels: the pending-movie slot and (since the chain
// length here is even, not odd) not the standalone validating row.
expect(screen.getByText('Validating...')).toBeInTheDocument();
});
it('shows the standalone validating row while searching for the next actor', () => {
useGameStore.setState({
chain: [MOVIE_A],
movieA: MOVIE_A,
movieB: MOVIE_B,
status: 'playing',
isValidating: true,
});
render(<ChainDisplay />);
expect(screen.getByText('Validating...')).toBeInTheDocument();
});
it('shows the standalone validating row on an odd-length chain beyond the first pair', () => {
useGameStore.setState({
chain: [MOVIE_A, actorLink(1), movieLink(300)],
movieA: MOVIE_A,
movieB: MOVIE_B,
status: 'playing',
isValidating: true,
});
render(<ChainDisplay />);
expect(screen.getByText('Validating...')).toBeInTheDocument();
});
it('does not show the standalone validating row once the chain is complete', () => {
useGameStore.setState({
chain: [MOVIE_A, actorLink(1), MOVIE_B, actorLink(2), MOVIE_A],
movieA: MOVIE_A,
movieB: MOVIE_B,
status: 'completed',
isValidating: false,
});
render(<ChainDisplay />);
expect(screen.queryByText('Validating...')).toBeNull();
expect(screen.getByText('Loop Complete!')).toBeInTheDocument();
});
it('badges the movie B link once the chain reaches it', () => {
useGameStore.setState({
chain: [MOVIE_A, actorLink(1), MOVIE_B],
movieA: MOVIE_A,
movieB: MOVIE_B,
status: 'playing',
isValidating: false,
});
render(<ChainDisplay />);
expect(screen.getByText('B')).toBeInTheDocument();
});
it('does not badge a mid-chain return to movie A before the loop actually closes', () => {
// Same id as movie A, but not the final pair and not completed: no badge.
useGameStore.setState({
chain: [MOVIE_A, actorLink(1), MOVIE_A, actorLink(2)],
movieA: MOVIE_A,
movieB: MOVIE_B,
status: 'playing',
isValidating: false,
});
render(<ChainDisplay />);
expect(screen.queryByText('A')).toBeNull();
});
});
@@ -0,0 +1,291 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ChainLink, MovieChainLink, ScoreBreakdown } from '@/types';
/**
* GameCompletionModal is a store-driven view over the completed game: the
* "calculating score" spinner, a scoring error, or the final score + share
* buttons, with Play Again / View Chain controls. Sound and navigation are
* mocked; everything else is driven by seeding the real game store per WP-19
* conventions.
*/
const playSound = vi.fn();
const navigateMock = vi.fn();
vi.mock('@/lib/sounds', () => ({
playSound: (...args: unknown[]) => playSound(...args),
}));
vi.mock('react-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router')>();
return { ...actual, useNavigate: () => navigateMock };
});
const { useGameStore } = await import('@/stores/game-store');
const { MemoryRouter } = await import('react-router');
const { default: GameCompletionModal } = await import('./GameCompletionModal');
const MOVIE_A: MovieChainLink = {
type: 'movie',
id: 100,
title: 'Movie A',
posterPath: null,
releaseDate: '2000-01-01',
popularity: 5,
};
const MOVIE_B: MovieChainLink = {
type: 'movie',
id: 200,
title: 'Movie B',
posterPath: null,
releaseDate: '2001-01-01',
popularity: 5,
};
const CHAIN: ChainLink[] = [
MOVIE_A,
{ type: 'actor', id: 1, name: 'Actor One', profilePath: null, popularity: 5 },
MOVIE_B,
{ type: 'actor', id: 2, name: 'Actor Two', profilePath: null, popularity: 5 },
MOVIE_A,
];
const SCORE: ScoreBreakdown = {
baseScore: 1000,
chainLength: 5,
linkCount: 2,
chainLengthBonus: 800,
timeBonus: 500,
obscurityBonus: 100,
hintPenalty: 0,
duplicatePenalty: 0,
elapsedSeconds: 125,
totalScore: 2400,
};
function renderModal() {
return render(
<MemoryRouter>
<GameCompletionModal />
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
useGameStore.getState().resetGame();
});
describe('GameCompletionModal', () => {
it('shows nothing before the game is completed', () => {
useGameStore.setState({ status: 'playing', movieA: MOVIE_A, movieB: MOVIE_B, chain: [MOVIE_A] });
renderModal();
expect(screen.queryByRole('dialog')).toBeNull();
});
it('shows a calculating spinner immediately on completion, before the score arrives', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: null,
validationError: null,
});
renderModal();
expect(screen.getByRole('dialog', { name: 'Congratulations!' })).toBeInTheDocument();
expect(screen.getByText('Calculating score...')).toBeInTheDocument();
});
it('shows a scoring error instead of a spinner when scoring failed', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: null,
validationError: 'Failed to calculate score.',
});
renderModal();
expect(screen.getByText('Failed to calculate score.')).toBeInTheDocument();
expect(screen.queryByText('Calculating score...')).toBeNull();
// No score means no share buttons.
expect(screen.queryByRole('button', { name: /Copy Result/ })).toBeNull();
});
it('shows the score, elapsed time and share controls once scored', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: SCORE,
hintsUsed: 0,
});
renderModal();
expect(screen.getByText('2400')).toBeInTheDocument();
expect(screen.getByText('Completed in 2:05')).toBeInTheDocument();
expect(screen.queryByText(/hints? used/)).toBeNull();
expect(
screen.getByRole('button', { name: /Copy Result/ }),
).toBeInTheDocument();
});
it('reports how many hints were used, pluralised correctly', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: SCORE,
hintsUsed: 1,
});
const { rerender } = renderModal();
expect(screen.getByText('1 hint used')).toBeInTheDocument();
useGameStore.setState({ hintsUsed: 2 });
rerender(
<MemoryRouter>
<GameCompletionModal />
</MemoryRouter>,
);
expect(screen.getByText('2 hints used')).toBeInTheDocument();
});
it('plays the completion sound exactly once per completion, not on every re-render', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: SCORE,
});
const { rerender } = renderModal();
expect(playSound).toHaveBeenCalledWith('completion');
expect(playSound).toHaveBeenCalledTimes(1);
act(() => {
useGameStore.setState({ hintsUsed: 1 });
});
rerender(
<MemoryRouter>
<GameCompletionModal />
</MemoryRouter>,
);
expect(playSound).toHaveBeenCalledTimes(1);
});
it('plays the completion sound again for the next game that completes', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: SCORE,
});
renderModal();
expect(playSound).toHaveBeenCalledTimes(1);
act(() => {
useGameStore.setState({ status: 'playing' });
});
act(() => {
useGameStore.setState({ status: 'completed' });
});
expect(playSound).toHaveBeenCalledTimes(2);
});
it('marks a completed daily challenge as done in local storage', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: SCORE,
isDailyChallenge: true,
dailyChallengeDate: '2024-03-01',
dailyChallengeDifficulty: 'hard',
});
renderModal();
expect(localStorage.getItem('daily-completed-2024-03-01-hard')).toBe('true');
});
it('dismisses the modal on View Chain without resetting the game', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: SCORE,
});
renderModal();
act(() => {
screen.getByRole('button', { name: 'View Chain' }).click();
});
expect(screen.queryByRole('dialog')).toBeNull();
expect(useGameStore.getState().status).toBe('completed');
expect(navigateMock).not.toHaveBeenCalled();
});
it('dismisses when closed via the dialog itself (e.g. Escape), same as View Chain', async () => {
const user = userEvent.setup();
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: SCORE,
});
renderModal();
expect(screen.getByRole('dialog')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).toBeNull();
expect(useGameStore.getState().status).toBe('completed');
});
it('resets the game and returns home on Play Again', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: SCORE,
});
renderModal();
act(() => {
screen.getByRole('button', { name: 'Play Again' }).click();
});
expect(useGameStore.getState().status).toBe('idle');
expect(navigateMock).toHaveBeenCalledWith('/');
});
it('reopens for a new completion after being dismissed for the previous one', async () => {
const user = userEvent.setup();
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: CHAIN,
score: SCORE,
});
renderModal();
await user.click(screen.getByRole('button', { name: 'View Chain' }));
expect(screen.queryByRole('dialog')).toBeNull();
act(() => {
useGameStore.setState({ status: 'playing', score: null });
});
expect(screen.queryByRole('dialog')).toBeNull();
act(() => {
useGameStore.setState({ status: 'completed', score: SCORE });
});
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
+81
View File
@@ -0,0 +1,81 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import type { ActorChainLink, MovieChainLink } from '@/types';
const { useGameStore } = await import('@/stores/game-store');
const { default: GameHeader } = await import('./GameHeader');
const movieA: MovieChainLink = {
type: 'movie',
id: 100,
title: 'Movie A',
posterPath: null,
releaseDate: '2000-01-01',
popularity: 5,
};
const movieB: MovieChainLink = {
type: 'movie',
id: 200,
title: 'Movie B',
posterPath: null,
releaseDate: '2001-01-01',
popularity: 5,
};
const actor: ActorChainLink = {
type: 'actor',
id: 1,
name: 'Actor One',
profilePath: null,
popularity: 5,
};
beforeEach(() => {
useGameStore.getState().resetGame();
});
describe('GameHeader', () => {
it('renders nothing before both target movies are known', () => {
const { container } = render(<GameHeader />);
expect(container).toBeEmptyDOMElement();
});
it('shows both target movies and the chain link count', () => {
useGameStore.setState({ movieA, movieB, chain: [movieA, actor, movieB, actor] });
render(<GameHeader />);
expect(screen.getByRole('img', { name: 'Movie A' })).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'Movie B' })).toBeInTheDocument();
// getLinkCount([movieA, actor, movieB, actor]) = floor((4-1)/2) = 1
expect(screen.getByText('Chain: 1 links')).toBeInTheDocument();
});
it('shows the custom game badge only for a custom game', () => {
useGameStore.setState({ movieA, movieB, chain: [movieA], isCustomGame: true });
const { rerender } = render(<GameHeader />);
expect(screen.getByText('Custom Game')).toBeInTheDocument();
useGameStore.setState({ isCustomGame: false });
rerender(<GameHeader />);
expect(screen.queryByText('Custom Game')).toBeNull();
});
it('announces that Movie B has not yet been reached', () => {
useGameStore.setState({ movieA, movieB, chain: [movieA], passedMovieB: false });
render(<GameHeader />);
// The visible label reads "Passed Movie B" in both states; the sr-only
// text is what actually distinguishes them for a screen reader.
const status = screen.getByRole('status');
expect(status).toHaveTextContent('Passed Movie B');
expect(screen.getByText('Movie B not yet reached')).toBeInTheDocument();
expect(screen.queryByText('Movie B reached')).toBeNull();
});
it('announces that Movie B has been reached', () => {
useGameStore.setState({ movieA, movieB, chain: [movieA], passedMovieB: true });
render(<GameHeader />);
expect(screen.getByText('Movie B reached')).toBeInTheDocument();
expect(screen.queryByText('Movie B not yet reached')).toBeNull();
});
});
@@ -0,0 +1,228 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import type { ReadyGameScreen } from '@/hooks/use-game-screen';
import type { MovieChainLink, TmdbMovieResult, TmdbPersonResult } from '@/types';
/**
* GameSearchPanel is a layout/wiring component: it assembles SearchAutocomplete,
* ValidationFeedback and HintButton around the undo control and the loop hint.
* Each of those has its own dedicated test file exercising its real behaviour
* (API calls, debounce, the game store); here they are replaced with light
* stand-ins so this file tests only what GameSearchPanel itself is
* responsible for: which props reach each child, and the undo/loop-hint logic.
*/
vi.mock('./SearchAutocomplete', () => ({
default: (props: {
mode: string;
disabled?: boolean;
placeholder?: string;
onSelectActor: (p: TmdbPersonResult) => void;
onSelectMovie: (m: TmdbMovieResult) => void;
}) => (
<div data-testid="search-autocomplete">
<span>mode:{props.mode}</span>
<span>disabled:{String(!!props.disabled)}</span>
<span>placeholder:{props.placeholder}</span>
<button
type="button"
onClick={() =>
props.onSelectActor({
id: 7,
name: 'Picked Actor',
profile_path: null,
popularity: 5,
known_for_department: 'Acting',
known_for: [],
adult: false,
})
}
>
pick actor
</button>
<button
type="button"
onClick={() =>
props.onSelectMovie({
id: 300,
title: 'Picked Movie',
original_title: 'Picked Movie',
overview: '',
poster_path: null,
backdrop_path: null,
release_date: '2000-01-01',
popularity: 5,
vote_average: 0,
vote_count: 0,
genre_ids: [],
adult: false,
})
}
>
pick movie
</button>
</div>
),
}));
vi.mock('./ValidationFeedback', () => ({
default: () => <div data-testid="validation-feedback" />,
}));
vi.mock('./HintButton', () => ({
default: () => <div data-testid="hint-button" />,
}));
const { default: GameSearchPanel } = await import('./GameSearchPanel');
const movieA: MovieChainLink = {
type: 'movie',
id: 100,
title: 'Movie A',
posterPath: null,
releaseDate: '2000-01-01',
popularity: 5,
};
const movieB: MovieChainLink = {
type: 'movie',
id: 200,
title: 'Movie B',
posterPath: null,
releaseDate: '2001-01-01',
popularity: 5,
};
function baseScreen(overrides: Partial<ReadyGameScreen> = {}): ReadyGameScreen {
return {
ready: true,
status: 'playing',
chain: [movieA],
currentSearchMode: 'actor',
passedMovieB: false,
isValidating: false,
undoLastLink: vi.fn(),
validateAndAddActor: vi.fn(),
validateAndAddMovie: vi.fn(),
searchLabel: 'Who was in "Movie A"?',
searchPlaceholder: 'Search for an actor...',
showLoopHint: false,
movieA,
movieB,
...overrides,
};
}
describe('GameSearchPanel', () => {
it('shows the contextual search label and forwards mode/placeholder to the search box', () => {
render(<GameSearchPanel screen={baseScreen()} />);
expect(screen.getByText('Who was in "Movie A"?')).toBeInTheDocument();
expect(screen.getByText('mode:actor')).toBeInTheDocument();
expect(
screen.getByText('placeholder:Search for an actor...'),
).toBeInTheDocument();
expect(screen.getByText('disabled:false')).toBeInTheDocument();
});
it('disables the search box while validating', () => {
render(<GameSearchPanel screen={baseScreen({ isValidating: true })} />);
expect(screen.getByText('disabled:true')).toBeInTheDocument();
});
it('forwards a picked actor and a picked movie to the screen callbacks', async () => {
const user = userEvent.setup();
const validateAndAddActor = vi.fn();
const validateAndAddMovie = vi.fn();
render(
<GameSearchPanel
screen={baseScreen({ validateAndAddActor, validateAndAddMovie })}
/>,
);
await user.click(screen.getByRole('button', { name: 'pick actor' }));
expect(validateAndAddActor).toHaveBeenCalledWith(
expect.objectContaining({ id: 7, name: 'Picked Actor' }),
);
await user.click(screen.getByRole('button', { name: 'pick movie' }));
expect(validateAndAddMovie).toHaveBeenCalledWith(
expect.objectContaining({ id: 300, title: 'Picked Movie' }),
);
});
it('disables Undo with only the starting movie in the chain, and enables it once a link has been added', () => {
const { rerender } = render(
<GameSearchPanel screen={baseScreen({ chain: [movieA] })} />,
);
expect(screen.getByRole('button', { name: /Undo/ })).toBeDisabled();
rerender(
<GameSearchPanel
screen={baseScreen({
chain: [
movieA,
{ type: 'actor', id: 1, name: 'Actor', profilePath: null, popularity: 5 },
],
})}
/>,
);
expect(screen.getByRole('button', { name: /Undo/ })).toBeEnabled();
});
it('disables Undo while validating even with a link in the chain', () => {
render(
<GameSearchPanel
screen={baseScreen({
isValidating: true,
chain: [
movieA,
{ type: 'actor', id: 1, name: 'Actor', profilePath: null, popularity: 5 },
],
})}
/>,
);
expect(screen.getByRole('button', { name: /Undo/ })).toBeDisabled();
});
it('calls undoLastLink when Undo is clicked', async () => {
const user = userEvent.setup();
const undoLastLink = vi.fn();
render(
<GameSearchPanel
screen={baseScreen({
undoLastLink,
chain: [
movieA,
{ type: 'actor', id: 1, name: 'Actor', profilePath: null, popularity: 5 },
],
})}
/>,
);
await user.click(screen.getByRole('button', { name: /Undo/ }));
expect(undoLastLink).toHaveBeenCalledTimes(1);
});
it('shows the close-the-loop nudge only when the screen says to', () => {
const { rerender } = render(
<GameSearchPanel screen={baseScreen({ showLoopHint: false })} />,
);
expect(screen.queryByText(/close the loop/)).toBeNull();
rerender(<GameSearchPanel screen={baseScreen({ showLoopHint: true })} />);
expect(
screen.getByText('Select "Movie A" to close the loop!'),
).toBeInTheDocument();
});
it('renders extra actions passed as children alongside Undo', () => {
render(
<GameSearchPanel screen={baseScreen()}>
<button type="button">Forfeit</button>
</GameSearchPanel>,
);
expect(screen.getByRole('button', { name: 'Forfeit' })).toBeInTheDocument();
});
it('renders the hint button and validation feedback slots', () => {
render(<GameSearchPanel screen={baseScreen()} />);
expect(screen.getByTestId('hint-button')).toBeInTheDocument();
expect(screen.getByTestId('validation-feedback')).toBeInTheDocument();
});
});
+188
View File
@@ -0,0 +1,188 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { MovieChainLink } from '@/types';
/**
* HintButton composes useHints (which calls POST /games/:id/hint) with
* useChainValidation (POST /games/:id/validate-link). Both hooks have their
* own dedicated test suites; this covers what HintButton itself renders and
* wires up. Requests are intercepted at the axios client and routed by URL,
* same seam as game-store.test.ts, so nothing reaches the network.
*/
const requestHint = vi.fn();
const validateLinkApi = vi.fn();
const playSound = vi.fn();
type Handler = (body: unknown) => Promise<unknown>;
function route(method: string, url: string): Handler {
let m: RegExpMatchArray | null;
if (method === 'post' && (m = /^\/games\/([^/]+)\/hint$/.exec(url))) {
return () => requestHint(m![1]);
}
if (method === 'post' && (m = /^\/games\/([^/]+)\/validate-link$/.exec(url))) {
return (body) => validateLinkApi(m![1], body);
}
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),
}));
const { useGameStore } = await import('@/stores/game-store');
const { default: HintButton } = await import('./HintButton');
const movieA: MovieChainLink = {
type: 'movie',
id: 100,
title: 'Movie A',
posterPath: null,
releaseDate: '2000-01-01',
popularity: 5,
};
beforeEach(() => {
vi.clearAllMocks();
useGameStore.getState().resetGame();
validateLinkApi.mockResolvedValue({
valid: true,
chain: [],
loopComplete: false,
passedMovieB: false,
});
});
function playingWithSession() {
useGameStore.setState({
status: 'playing',
movieA,
chain: [movieA],
currentSearchMode: 'actor',
sessionId: 's1',
hintsUsed: 0,
});
}
describe('HintButton', () => {
it('shows the remaining hint count and the point cost', () => {
playingWithSession();
render(<HintButton />);
expect(
screen.getByRole('button', { name: /Hint \(3 left\)/ }),
).toBeInTheDocument();
expect(screen.getByText('-150 pts')).toBeInTheDocument();
});
it('is disabled when a hint cannot be taken (no session)', () => {
useGameStore.setState({
status: 'playing',
movieA,
chain: [movieA],
currentSearchMode: 'actor',
sessionId: null,
});
render(<HintButton />);
expect(screen.getByRole('button', { name: /Hint/ })).toBeDisabled();
});
it('is disabled when the search mode is not looking for an actor', () => {
useGameStore.setState({
status: 'playing',
movieA,
chain: [movieA],
currentSearchMode: 'movie',
sessionId: 's1',
});
render(<HintButton />);
expect(screen.getByRole('button', { name: /Hint/ })).toBeDisabled();
});
it('fetches and lists hinted actors, updating the remaining count', async () => {
playingWithSession();
requestHint.mockResolvedValue({
hintsUsed: 1,
hintsRemaining: 2,
actors: [
{ id: 7, name: 'Hint Actor', profilePath: null, popularity: 5 },
],
});
const user = userEvent.setup();
render(<HintButton />);
await user.click(screen.getByRole('button', { name: /Hint \(3 left\)/ }));
expect(requestHint).toHaveBeenCalledWith('s1');
expect(
screen.getByRole('button', { name: /^Hint Actor/ }),
).toBeInTheDocument();
expect(useGameStore.getState().hintsUsed).toBe(1);
expect(
screen.getByRole('button', { name: /Hint \(2 left\)/ }),
).toBeInTheDocument();
});
it('adds the chosen hint to the chain and clears the hint list', async () => {
playingWithSession();
requestHint.mockResolvedValue({
hintsUsed: 1,
hintsRemaining: 2,
actors: [
{ id: 7, name: 'Hint Actor', profilePath: null, popularity: 9 },
],
});
const user = userEvent.setup();
render(<HintButton />);
await user.click(screen.getByRole('button', { name: /Hint \(3 left\)/ }));
await user.click(screen.getByRole('button', { name: /^Hint Actor/ }));
expect(validateLinkApi).toHaveBeenCalledWith('s1', {
type: 'actor',
id: 7,
name: 'Hint Actor',
popularity: 9,
});
expect(useGameStore.getState().chain).toHaveLength(2);
expect(useGameStore.getState().chain[1]).toMatchObject({
type: 'actor',
id: 7,
name: 'Hint Actor',
});
expect(playSound).toHaveBeenCalledWith('valid');
expect(screen.queryByRole('button', { name: /^Hint Actor/ })).toBeNull();
});
it('disables the button while a hint request is in flight', async () => {
playingWithSession();
let release: (v: unknown) => void = () => undefined;
requestHint.mockImplementation(
() => new Promise((resolve) => (release = resolve)),
);
const user = userEvent.setup();
render(<HintButton />);
const button = screen.getByRole('button', { name: /Hint \(3 left\)/ });
const clickPromise = user.click(button);
await vi.waitFor(() => expect(button).toBeDisabled());
release({ hintsUsed: 1, hintsRemaining: 2, actors: [] });
await clickPromise;
});
});
+55
View File
@@ -0,0 +1,55 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { MovieChainLink } from '@/types';
import MovieCard from './MovieCard';
const movie = (overrides: Partial<MovieChainLink> = {}): MovieChainLink => ({
type: 'movie',
id: 1,
title: 'The Matrix',
posterPath: null,
releaseDate: '1999-03-31',
popularity: 5,
...overrides,
});
describe('MovieCard', () => {
it('renders a placeholder poster when there is none, with the release year', () => {
render(<MovieCard movie={movie()} />);
const img = screen.getByRole('img', { name: 'The Matrix' });
expect(img).toHaveAttribute('src', '/placeholder-movie.svg');
expect(screen.getByText('1999')).toBeInTheDocument();
});
it('builds a TMDB image URL when a poster is known', () => {
render(<MovieCard movie={movie({ posterPath: '/matrix.jpg' })} />);
expect(screen.getByRole('img', { name: 'The Matrix' })).toHaveAttribute(
'src',
'https://image.tmdb.org/t/p/w154/matrix.jpg',
);
});
it('labels the card as Movie A or Movie B when it is a target', () => {
const { rerender } = render(<MovieCard movie={movie()} isTarget="A" />);
expect(screen.getByText('Movie A')).toBeInTheDocument();
rerender(<MovieCard movie={movie()} isTarget="B" />);
expect(screen.getByText('Movie B')).toBeInTheDocument();
expect(screen.queryByText('Movie A')).toBeNull();
});
it('shows no target badge by default, highlighted or not', () => {
const { rerender } = render(<MovieCard movie={movie()} />);
expect(screen.queryByText(/^Movie [AB]$/)).toBeNull();
rerender(<MovieCard movie={movie()} highlight />);
expect(screen.queryByText(/^Movie [AB]$/)).toBeNull();
});
it('renders the compact layout without a target badge', () => {
render(<MovieCard movie={movie({ title: 'Speed' })} isTarget="A" compact />);
expect(screen.getByRole('img', { name: 'Speed' })).toBeInTheDocument();
expect(screen.getByText('Speed')).toBeInTheDocument();
expect(screen.queryByText('Movie A')).toBeNull();
});
});
+63
View File
@@ -0,0 +1,63 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { ScoreBreakdown } from '@/types';
import ScoreDisplay from './ScoreDisplay';
const SCORE: ScoreBreakdown = {
baseScore: 1000,
chainLength: 5,
linkCount: 2,
chainLengthBonus: 800,
timeBonus: 500,
obscurityBonus: 100,
hintPenalty: 0,
duplicatePenalty: 0,
elapsedSeconds: 300,
totalScore: 2400,
};
describe('ScoreDisplay', () => {
it('shows the total score and each bonus line', () => {
render(<ScoreDisplay score={SCORE} />);
expect(screen.getByText('2400')).toBeInTheDocument();
expect(screen.getByText('points')).toBeInTheDocument();
expect(screen.getByText('Base score')).toBeInTheDocument();
expect(screen.getByText('1000')).toBeInTheDocument();
expect(screen.getByText('+800')).toBeInTheDocument();
expect(screen.getByText('+500')).toBeInTheDocument();
expect(screen.getByText('+100')).toBeInTheDocument();
});
it('pluralises the link count and omits par when there is none', () => {
render(<ScoreDisplay score={SCORE} />);
expect(screen.getByText('Chain length (2 links)')).toBeInTheDocument();
});
it('uses the singular "link" for a single link, and shows par when present', () => {
render(<ScoreDisplay score={{ ...SCORE, linkCount: 1, par: 3 }} />);
expect(screen.getByText('Chain length (1 link, par 3)')).toBeInTheDocument();
});
it('hides the duplicate and hint penalty lines when they are zero', () => {
render(<ScoreDisplay score={SCORE} />);
expect(screen.queryByText('Duplicate penalty')).toBeNull();
expect(screen.queryByText('Hint penalty')).toBeNull();
});
it('shows a negative penalty line when duplicate or hint penalties apply', () => {
render(
<ScoreDisplay score={{ ...SCORE, duplicatePenalty: 150, hintPenalty: 150 }} />,
);
expect(screen.getByText('Duplicate penalty')).toBeInTheDocument();
expect(screen.getByText('Hint penalty')).toBeInTheDocument();
expect(screen.getAllByText('-150')).toHaveLength(2);
});
it('does not prefix a zero or negative bonus value with a plus sign', () => {
render(<ScoreDisplay score={{ ...SCORE, timeBonus: 0 }} />);
// The bonus line for time renders "0", not "+0".
const line = screen.getByText('Time bonus').closest('div');
expect(line).toHaveTextContent('Time bonus0');
});
});
@@ -0,0 +1,523 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TmdbMovieResult, TmdbPersonResult } from '@/types';
/**
* The ARIA 1.2 combobox that drives every "who was in..." / "what movie was
* ... in" search step. Requests are intercepted at the axios client and
* routed by URL - the same seam game-store.test.ts uses - so nothing reaches
* the network; searchMovies/searchPersons run for real against the mock.
*
* Real timers throughout: combining `vi.useFakeTimers()` with
* `@testing-library/user-event` deadlocks every `user.type`/`user.click` call
* in this project's toolchain (vitest 5.0.0 + user-event 14.6.7 + jsdom 30) -
* reproduced even for a bare `<input>` with no debounce involved, regardless
* of `delay: null` or an `advanceTimers` callback. `waitFor`/`findBy*` (which
* poll on real timers) stand in for fake-timer control of the 300ms debounce.
*/
const searchPersonsApi = vi.fn();
const searchMoviesApi = vi.fn();
type Handler = (params: Record<string, unknown> | undefined) => Promise<unknown>;
function route(method: string, url: string): Handler {
if (method === 'get' && url === '/persons/search') {
return (params) => searchPersonsApi((params as { q: string }).q);
}
if (method === 'get' && url === '/movies/search') {
return (params) => searchMoviesApi((params as { q: string }).q);
}
throw new Error(`test reached an unrouted request: ${method} ${url}`);
}
vi.mock('@/api/client', () => {
const call =
(method: string) =>
async (
url: string,
config?: { params?: Record<string, unknown> },
): Promise<{ data: unknown }> => ({
data: await route(method, url)(config?.params),
});
return {
default: {
get: call('get'),
post: call('post'),
put: call('put'),
delete: call('delete'),
},
};
});
const { default: SearchAutocomplete } = await import('./SearchAutocomplete');
const person = (id: number, name = `Person ${id}`): TmdbPersonResult => ({
id,
name,
profile_path: null,
popularity: 5,
known_for_department: 'Acting',
known_for: [],
adult: false,
});
const tmdbMovie = (id: number, title = `Movie ${id}`): TmdbMovieResult => ({
id,
title,
original_title: title,
overview: '',
poster_path: null,
backdrop_path: null,
release_date: '2000-01-01',
popularity: 5,
vote_average: 7,
vote_count: 10,
genre_ids: [],
adult: false,
});
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/** Comfortably past the component's 300ms debounce. */
const pastDebounce = () => wait(400);
beforeEach(() => {
vi.clearAllMocks();
searchPersonsApi.mockResolvedValue({ page: 1, results: [], total_pages: 1, total_results: 0 });
searchMoviesApi.mockResolvedValue({ page: 1, results: [], total_pages: 1, total_results: 0 });
});
describe('SearchAutocomplete', () => {
it('does not search below the two-character threshold', async () => {
const user = userEvent.setup();
const onSelectActor = vi.fn();
render(
<SearchAutocomplete mode="actor" onSelectActor={onSelectActor} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'a');
await pastDebounce();
expect(searchPersonsApi).not.toHaveBeenCalled();
expect(screen.queryByRole('listbox')).toBeNull();
}, 10000);
it('debounces before searching: not called right after typing, called once settled', async () => {
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'ke');
expect(searchPersonsApi).not.toHaveBeenCalled();
await waitFor(() => expect(searchPersonsApi).toHaveBeenCalledWith('ke'));
}, 10000);
it('opens a labelled listbox of actor results, wired to the combobox', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'Keanu Reeves'), person(2, 'Carrie-Anne Moss')],
total_pages: 1,
total_results: 2,
});
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
const input = screen.getByRole('combobox', { name: 'Search for an actor' });
await user.type(input, 'ke');
const listbox = await screen.findByRole('listbox', { name: 'Actor results' });
expect(input).toHaveAttribute('aria-expanded', 'true');
expect(input).toHaveAttribute('aria-controls', listbox.id);
expect(screen.getByText('Keanu Reeves')).toBeInTheDocument();
expect(screen.getByText('Carrie-Anne Moss')).toBeInTheDocument();
}, 10000);
it('filters out results that are not in the Acting department', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [
person(1, 'An Actor'),
{ ...person(2, 'A Director'), known_for_department: 'Directing' },
],
total_pages: 1,
total_results: 2,
});
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'an');
await screen.findByText('An Actor');
expect(screen.queryByText('A Director')).toBeNull();
}, 10000);
it('searches movies in movie mode', async () => {
searchMoviesApi.mockResolvedValue({
page: 1,
results: [tmdbMovie(1, 'The Matrix')],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
render(
<SearchAutocomplete mode="movie" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox', { name: 'Search for a movie' }), 'ma');
await waitFor(() => expect(searchMoviesApi).toHaveBeenCalledWith('ma'));
expect(searchPersonsApi).not.toHaveBeenCalled();
expect(
screen.getByRole('listbox', { name: 'Movie results' }),
).toBeInTheDocument();
expect(screen.getByText('The Matrix')).toBeInTheDocument();
}, 10000);
it('shows "No results found" when a search comes back empty', async () => {
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'zz');
await screen.findByText('No results found');
}, 10000);
it('shows an error option when the search request fails', async () => {
searchPersonsApi.mockRejectedValue(new Error('network down'));
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'ke');
await screen.findByText('Search failed. Please try again.');
}, 10000);
it('shows a loading indicator while the request is in flight, then hides it', async () => {
let resolveSearch: (v: unknown) => void = () => undefined;
searchPersonsApi.mockImplementation(
() => new Promise((resolve) => (resolveSearch = resolve)),
);
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'ke');
await screen.findByLabelText('Searching...');
resolveSearch({ page: 1, results: [], total_pages: 1, total_results: 0 });
await waitFor(() => expect(screen.queryByLabelText('Searching...')).toBeNull());
}, 10000);
it('ignores a stale response that resolves after a newer search has started', async () => {
let resolveFirst: (v: unknown) => void = () => undefined;
let resolveSecond: (v: unknown) => void = () => undefined;
searchPersonsApi
.mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve)))
.mockImplementationOnce(() => new Promise((resolve) => (resolveSecond = resolve)));
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
const input = screen.getByRole('combobox');
await user.type(input, 'ab');
await waitFor(() => expect(searchPersonsApi).toHaveBeenCalledTimes(1));
await user.type(input, 'c');
await waitFor(() => expect(searchPersonsApi).toHaveBeenCalledTimes(2));
// The newer ("abc") request settles first.
resolveSecond({
page: 1,
results: [person(2, 'Current Result')],
total_pages: 1,
total_results: 1,
});
await screen.findByText('Current Result');
// The stale ("ab") request settles after: its cleanup already ran, so it
// must not clobber the current results.
resolveFirst({
page: 1,
results: [person(1, 'Stale Result')],
total_pages: 1,
total_results: 1,
});
await wait(50);
expect(screen.getByText('Current Result')).toBeInTheDocument();
expect(screen.queryByText('Stale Result')).toBeNull();
}, 10000);
it('navigates results with ArrowDown/ArrowUp, wrapping at each end', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'First'), person(2, 'Second')],
total_pages: 1,
total_results: 2,
});
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
const input = screen.getByRole('combobox');
await user.type(input, 'ke');
const listbox = await screen.findByRole('listbox');
expect(input).not.toHaveAttribute('aria-activedescendant');
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-activedescendant', `${listbox.id}-option-0`);
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-activedescendant', `${listbox.id}-option-1`);
// Wraps back to the first option.
await user.keyboard('{ArrowDown}');
expect(input).toHaveAttribute('aria-activedescendant', `${listbox.id}-option-0`);
// Wraps to the last option going up from the first.
await user.keyboard('{ArrowUp}');
expect(input).toHaveAttribute('aria-activedescendant', `${listbox.id}-option-1`);
}, 10000);
it('selects the active option on Enter, clears the query and closes the list', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(7, 'Seven')],
total_pages: 1,
total_results: 1,
});
const onSelectActor = vi.fn();
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={onSelectActor} onSelectMovie={vi.fn()} />,
);
const input = screen.getByRole('combobox');
await user.type(input, 'se');
await screen.findByText('Seven');
await user.keyboard('{ArrowDown}{Enter}');
expect(onSelectActor).toHaveBeenCalledWith(expect.objectContaining({ id: 7, name: 'Seven' }));
expect(input).toHaveValue('');
expect(input).toHaveAttribute('aria-expanded', 'false');
}, 10000);
it('does nothing on Enter when no option is active', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(7, 'Seven')],
total_pages: 1,
total_results: 1,
});
const onSelectActor = vi.fn();
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={onSelectActor} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'se');
await screen.findByText('Seven');
await user.keyboard('{Enter}');
expect(onSelectActor).not.toHaveBeenCalled();
}, 10000);
it('selects an actor result by clicking it', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(3, 'Clicked Actor')],
total_pages: 1,
total_results: 1,
});
const onSelectActor = vi.fn();
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={onSelectActor} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'cl');
await screen.findByText('Clicked Actor');
await user.click(screen.getByRole('button', { name: /Clicked Actor/ }));
expect(onSelectActor).toHaveBeenCalledWith(
expect.objectContaining({ id: 3, name: 'Clicked Actor' }),
);
}, 10000);
it("shows an actor's known-for titles when TMDB reports any", async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [
{
...person(4, 'Prolific Actor'),
known_for: [tmdbMovie(1, 'Movie One'), tmdbMovie(2, 'Movie Two')],
},
],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
await user.type(screen.getByRole('combobox'), 'pr');
await screen.findByText('Prolific Actor');
expect(screen.getByText('Known for: Movie One, Movie Two')).toBeInTheDocument();
}, 10000);
it('selects the active movie option on Enter in movie mode', async () => {
searchMoviesApi.mockResolvedValue({
page: 1,
results: [tmdbMovie(9, 'Speed')],
total_pages: 1,
total_results: 1,
});
const onSelectMovie = vi.fn();
const user = userEvent.setup();
render(
<SearchAutocomplete mode="movie" onSelectActor={vi.fn()} onSelectMovie={onSelectMovie} />,
);
const input = screen.getByRole('combobox');
await user.type(input, 'sp');
await screen.findByText('Speed');
await user.keyboard('{ArrowDown}{Enter}');
expect(onSelectMovie).toHaveBeenCalledWith(
expect.objectContaining({ id: 9, title: 'Speed' }),
);
expect(input).toHaveValue('');
}, 10000);
it('selects a movie result by clicking it', async () => {
searchMoviesApi.mockResolvedValue({
page: 1,
results: [tmdbMovie(9, 'Speed')],
total_pages: 1,
total_results: 1,
});
const onSelectMovie = vi.fn();
const user = userEvent.setup();
render(
<SearchAutocomplete mode="movie" onSelectActor={vi.fn()} onSelectMovie={onSelectMovie} />,
);
await user.type(screen.getByRole('combobox'), 'sp');
await screen.findByText('Speed');
await user.click(screen.getByRole('button', { name: /Speed/ }));
expect(onSelectMovie).toHaveBeenCalledWith(expect.objectContaining({ id: 9, title: 'Speed' }));
}, 10000);
it('closes and resets the active option on Escape', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'First')],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
const input = screen.getByRole('combobox');
await user.type(input, 'fi');
await screen.findByRole('listbox');
await user.keyboard('{ArrowDown}');
await user.keyboard('{Escape}');
expect(screen.queryByRole('listbox')).toBeNull();
expect(input).not.toHaveAttribute('aria-activedescendant');
}, 10000);
it('closes the dropdown when clicking outside the component', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'First')],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
render(
<div>
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />
<button type="button">outside</button>
</div>,
);
await user.type(screen.getByRole('combobox'), 'fi');
await screen.findByRole('listbox');
await user.click(screen.getByRole('button', { name: 'outside' }));
expect(screen.queryByRole('listbox')).toBeNull();
}, 10000);
it('reopens the dropdown on focus when there are already results', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'First')],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
render(
<div>
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />
<button type="button">outside</button>
</div>,
);
const input = screen.getByRole('combobox');
await user.type(input, 'fi');
await screen.findByRole('listbox');
await user.click(screen.getByRole('button', { name: 'outside' }));
expect(screen.queryByRole('listbox')).toBeNull();
await user.click(input);
expect(screen.getByRole('listbox')).toBeInTheDocument();
}, 10000);
it('disables the input and forwards a custom placeholder', () => {
render(
<SearchAutocomplete
mode="actor"
onSelectActor={vi.fn()}
onSelectMovie={vi.fn()}
disabled
placeholder="Custom prompt"
/>,
);
const input = screen.getByRole('combobox');
expect(input).toBeDisabled();
expect(input).toHaveAttribute('placeholder', 'Custom prompt');
});
it('resets its query and results when the mode prop switches, and refocuses', async () => {
searchPersonsApi.mockResolvedValue({
page: 1,
results: [person(1, 'First')],
total_pages: 1,
total_results: 1,
});
const user = userEvent.setup();
const { rerender } = render(
<SearchAutocomplete mode="actor" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
const actorInput = screen.getByRole('combobox');
await user.type(actorInput, 'fi');
await screen.findByRole('listbox');
expect(actorInput).toHaveValue('fi');
rerender(
<SearchAutocomplete mode="movie" onSelectActor={vi.fn()} onSelectMovie={vi.fn()} />,
);
const movieInput = screen.getByRole('combobox', { name: 'Search for a movie' });
expect(movieInput).toHaveValue('');
expect(screen.queryByRole('listbox')).toBeNull();
expect(movieInput).toHaveFocus();
}, 10000);
});
@@ -0,0 +1,30 @@
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import SerpentineConnector from './SerpentineConnector';
/**
* Purely decorative: the vertical line segment of the chain's connector. No
* accessible role applies, so the only observable contract is the inline
* position it's asked to render at.
*/
describe('SerpentineConnector', () => {
it('positions its vertical line at the given percentage', () => {
const { container } = render(<SerpentineConnector lineX={7.5} />);
const root = container.firstElementChild;
const line = root?.firstElementChild;
expect(line).not.toBeNull();
expect(line).toHaveStyle({ left: '7.5%' });
});
it('repositions when lineX changes', () => {
const { container, rerender } = render(<SerpentineConnector lineX={92.5} />);
expect(container.firstElementChild?.firstElementChild).toHaveStyle({
left: '92.5%',
});
rerender(<SerpentineConnector lineX={50} />);
expect(container.firstElementChild?.firstElementChild).toHaveStyle({
left: '50%',
});
});
});
@@ -0,0 +1,223 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ChainLink, ScoreBreakdown } from '@/types';
import ShareableResult from './ShareableResult';
const SCORE: ScoreBreakdown = {
baseScore: 1000,
chainLength: 5,
linkCount: 2,
chainLengthBonus: 800,
timeBonus: 500,
obscurityBonus: 100,
hintPenalty: 0,
duplicatePenalty: 0,
elapsedSeconds: 125,
totalScore: 2400,
};
const CHAIN: ChainLink[] = [
{ type: 'movie', id: 100, title: 'Movie A', posterPath: null, releaseDate: '2000-01-01', popularity: 5 },
{ type: 'actor', id: 1, name: 'Actor One', profilePath: null, popularity: 5 },
{ type: 'movie', id: 200, title: 'Movie B', posterPath: null, releaseDate: '2001-01-01', popularity: 5 },
];
function baseProps() {
return {
score: SCORE,
chain: CHAIN,
movieATitle: 'Movie A',
movieBTitle: 'Movie B',
movieAReleaseDate: '2000-01-01',
movieBReleaseDate: '2001-01-01',
};
}
/** Lets a click handler's own promise chain (clipboard/share calls) settle. */
async function clickAndSettle(button: HTMLElement) {
await act(async () => {
fireEvent.click(button);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
let writeText: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useFakeTimers();
writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
});
afterEach(() => {
vi.useRealTimers();
Reflect.deleteProperty(navigator, 'clipboard');
Reflect.deleteProperty(navigator, 'share');
});
describe('ShareableResult', () => {
it('copies the generated share text to the clipboard', async () => {
render(<ShareableResult {...baseProps()} />);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
);
expect(writeText).toHaveBeenCalledTimes(1);
const text = writeText.mock.calls[0]?.[0] as string;
expect(text).toContain('Movie A (2000) <-> Movie B (2001)');
expect(text).toContain('Score: 2,400');
expect(text).toContain('Chain: 1 links');
expect(text).toContain('Time: 2:05');
});
it('confirms the copy, then reverts the label after two seconds', async () => {
render(<ShareableResult {...baseProps()} />);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
);
expect(
screen.getByRole('button', { name: /Copied!/ }),
).toBeInTheDocument();
await act(async () => {
vi.advanceTimersByTime(2000);
});
expect(
screen.getByRole('button', { name: /Copy Result/ }),
).toBeInTheDocument();
});
it('labels the mode in the share text for a daily challenge and a versus match', async () => {
const { rerender } = render(
<ShareableResult {...baseProps()} mode={{ type: 'daily', date: '2024-01-01' }} />,
);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
);
expect(writeText.mock.calls[0]?.[0]).toContain('Daily Challenge');
expect(writeText.mock.calls[0]?.[0]).toContain('/daily');
rerender(
<ShareableResult
{...baseProps()}
mode={{ type: 'versus', matchId: 'm1' }}
/>,
);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
);
expect(writeText.mock.calls[1]?.[0]).toContain('Async Versus');
expect(writeText.mock.calls[1]?.[0]).toContain('/versus/m1');
});
it('links to plain versus play when no match id is given', async () => {
render(<ShareableResult {...baseProps()} mode={{ type: 'versus' }} />);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
);
expect(writeText.mock.calls[0]?.[0]).toMatch(/\/versus$/);
});
it('omits a mode label and years the source omits', async () => {
render(
<ShareableResult
{...baseProps()}
movieAReleaseDate={null}
movieBReleaseDate={undefined}
mode={{ type: 'freeplay' }}
/>,
);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
);
const text = writeText.mock.calls[0]?.[0] as string;
expect(text).toContain('Movie A <-> Movie B');
expect(text).not.toMatch(/\u{1F3AF}/u);
});
it('does not crash when the clipboard API rejects', async () => {
writeText.mockRejectedValue(new Error('denied'));
render(<ShareableResult {...baseProps()} />);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
);
expect(
screen.getByRole('button', { name: /Copy Result/ }),
).toBeInTheDocument();
});
it('clears its pending reset timer on unmount, so it never sets state after unmounting', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const { unmount } = render(<ShareableResult {...baseProps()} />);
await clickAndSettle(
screen.getByRole('button', { name: /Copy Result|Copied!/ }),
);
unmount();
await act(async () => {
vi.advanceTimersByTime(2000);
});
expect(errorSpy).not.toHaveBeenCalled();
errorSpy.mockRestore();
});
it('offers no Share button when the browser has no Web Share API', () => {
render(<ShareableResult {...baseProps()} />);
expect(screen.queryByRole('button', { name: /Share/ })).toBeNull();
});
it('shares via the Web Share API when available', async () => {
const share = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'share', { configurable: true, value: share });
render(<ShareableResult {...baseProps()} />);
await clickAndSettle(screen.getByRole('button', { name: 'Share' }));
expect(share).toHaveBeenCalledTimes(1);
const arg = share.mock.calls[0]?.[0] as { text: string };
expect(arg.text).toContain('Score: 2,400');
expect(writeText).not.toHaveBeenCalled();
});
it('swallows a cancelled share without falling back to the clipboard', async () => {
const share = vi.fn().mockRejectedValue(new Error('cancelled'));
Object.defineProperty(navigator, 'share', { configurable: true, value: share });
render(<ShareableResult {...baseProps()} />);
await clickAndSettle(screen.getByRole('button', { name: 'Share' }));
expect(share).toHaveBeenCalledTimes(1);
expect(writeText).not.toHaveBeenCalled();
});
it('falls back to copying when share becomes unavailable between render and click', async () => {
// Synthetic: the Share button's visibility and handleShare's own check
// both read `navigator.share` fresh, so this is the only way to exercise
// the handler's non-Web-Share fallback branch through the UI - the button
// is only rendered at all while `navigator.share` is a function.
let available = true;
const share = vi.fn();
Object.defineProperty(navigator, 'share', {
configurable: true,
get: () => (available ? share : undefined),
});
render(<ShareableResult {...baseProps()} />);
const shareButton = screen.getByRole('button', { name: 'Share' });
available = false;
await clickAndSettle(shareButton);
expect(share).not.toHaveBeenCalled();
expect(writeText).toHaveBeenCalledTimes(1);
});
});
+48
View File
@@ -0,0 +1,48 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import SoundToggle from './SoundToggle';
const KEY = 'movieloop-sound-enabled';
describe('SoundToggle', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it('is on by default and names the control "Mute sounds"', () => {
render(<SoundToggle />);
expect(
screen.getByRole('button', { name: 'Mute sounds' }),
).toBeInTheDocument();
});
it('mutes on click and persists the choice', async () => {
const user = userEvent.setup();
render(<SoundToggle />);
await user.click(screen.getByRole('button', { name: 'Mute sounds' }));
expect(
screen.getByRole('button', { name: 'Enable sounds' }),
).toBeInTheDocument();
expect(localStorage.getItem(KEY)).toBe('false');
});
it('reflects a previously muted preference and re-enables on click', async () => {
localStorage.setItem(KEY, 'false');
const user = userEvent.setup();
render(<SoundToggle />);
const button = screen.getByRole('button', { name: 'Enable sounds' });
await user.click(button);
expect(
screen.getByRole('button', { name: 'Mute sounds' }),
).toBeInTheDocument();
expect(localStorage.getItem(KEY)).toBe('true');
});
});
+74
View File
@@ -0,0 +1,74 @@
import { act, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* Timer is a thin view over useTimer/useGameStore: which color+label tier it
* picks for a given elapsed time, seeded through the real store per WP-19
* conventions rather than mocked.
*/
const { useGameStore } = await import('@/stores/game-store');
const { default: Timer } = await import('./Timer');
const NOW = new Date('2024-01-01T00:10:00.000Z').getTime();
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
useGameStore.getState().resetGame();
});
afterEach(() => {
vi.useRealTimers();
});
function seed(elapsedSeconds: number, status: 'playing' | 'idle' | 'completed' = 'playing') {
useGameStore.setState({ startTime: NOW - elapsedSeconds * 1000, status });
}
describe('Timer', () => {
it('shows the high bonus tier under a minute', () => {
seed(30);
render(<Timer />);
expect(screen.getByText('0:30')).toBeInTheDocument();
expect(screen.getByText('(+500)')).toBeInTheDocument();
});
it('shows the medium bonus tier between one and three minutes', () => {
seed(90);
render(<Timer />);
expect(screen.getByText('1:30')).toBeInTheDocument();
expect(screen.getByText('(+300)')).toBeInTheDocument();
});
it('shows the low bonus tier between three and five minutes', () => {
seed(200);
render(<Timer />);
expect(screen.getByText('3:20')).toBeInTheDocument();
expect(screen.getByText('(+100)')).toBeInTheDocument();
});
it('shows no bonus at five minutes or beyond', () => {
seed(301);
render(<Timer />);
expect(screen.getByText('5:01')).toBeInTheDocument();
expect(screen.getByText('(+0)')).toBeInTheDocument();
});
it('ticks upward every second while the game is playing', () => {
seed(0);
render(<Timer />);
expect(screen.getByText('0:00')).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(3000);
});
expect(screen.getByText('0:03')).toBeInTheDocument();
});
it('reads 0:00 when no game has started', () => {
useGameStore.setState({ startTime: null, status: 'idle' });
render(<Timer />);
expect(screen.getByText('0:00')).toBeInTheDocument();
expect(screen.getByText('(+500)')).toBeInTheDocument();
});
});
@@ -0,0 +1,84 @@
import { act, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MovieChainLink } from '@/types';
const { useGameStore } = await import('@/stores/game-store');
const { default: ValidationFeedback } = await import('./ValidationFeedback');
const movieA: MovieChainLink = {
type: 'movie',
id: 100,
title: 'Movie A',
posterPath: null,
releaseDate: '2000-01-01',
popularity: 5,
};
beforeEach(() => {
vi.useFakeTimers();
useGameStore.getState().resetGame();
});
afterEach(() => {
vi.useRealTimers();
});
describe('ValidationFeedback', () => {
it('renders nothing in the default, settled state', () => {
const { container } = render(<ValidationFeedback />);
expect(container).toBeEmptyDOMElement();
});
it('announces validation in progress', () => {
useGameStore.setState({ isValidating: true });
render(<ValidationFeedback />);
expect(screen.getByRole('status')).toHaveTextContent(
'Validating connection...',
);
});
it('shows the error as an alert', () => {
useGameStore.setState({ validationError: 'That actor was not in that movie.' });
render(<ValidationFeedback />);
expect(screen.getByRole('alert')).toHaveTextContent(
'That actor was not in that movie.',
);
});
it('prioritises the validating state over a stale error', () => {
useGameStore.setState({ isValidating: true, validationError: 'stale' });
render(<ValidationFeedback />);
expect(screen.getByRole('status')).toHaveTextContent(
'Validating connection...',
);
expect(screen.queryByRole('alert')).toBeNull();
});
it('flashes a success check when the chain grows, then clears it', () => {
useGameStore.setState({ chain: [movieA] });
render(<ValidationFeedback />);
expect(screen.queryByText('Connected!')).toBeNull();
act(() => {
useGameStore.setState({ chain: [movieA, movieA] });
});
expect(screen.getByRole('status')).toHaveTextContent('Connected!');
act(() => {
vi.advanceTimersByTime(600);
});
expect(screen.queryByText('Connected!')).toBeNull();
});
it('does not re-flash when the chain length is unchanged', () => {
useGameStore.setState({ chain: [movieA, movieA] });
const { rerender } = render(<ValidationFeedback />);
expect(screen.getByText('Connected!')).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(600);
});
rerender(<ValidationFeedback />);
expect(screen.queryByText('Connected!')).toBeNull();
});
});
@@ -0,0 +1,45 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import HowToPlayDialog from './HowToPlayDialog';
describe('HowToPlayDialog', () => {
it('is closed until the help button is clicked', () => {
render(<HowToPlayDialog />);
expect(screen.queryByRole('dialog')).toBeNull();
});
it('opens the how-to-play dialog from the help button', async () => {
render(<HowToPlayDialog />);
await userEvent.click(screen.getByRole('button', { name: 'How to play' }));
expect(
screen.getByRole('dialog', { name: 'How to Play' }),
).toBeInTheDocument();
expect(
screen.getByText(/Build a chain of actor-movie connections/),
).toBeInTheDocument();
});
it('describes the goal, connections, scoring, modes and tips', async () => {
render(<HowToPlayDialog />);
await userEvent.click(screen.getByRole('button', { name: 'How to play' }));
expect(screen.getByRole('heading', { name: 'The Goal' })).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: 'How Connections Work' }),
).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Scoring' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Game Modes' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Tips' })).toBeInTheDocument();
});
it('closes via its own close control', async () => {
render(<HowToPlayDialog />);
await userEvent.click(screen.getByRole('button', { name: 'How to play' }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.queryByRole('dialog')).toBeNull();
});
});
+193 -3
View File
@@ -1,6 +1,6 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router';
import { MemoryRouter, Route, Routes } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
/**
@@ -35,12 +35,17 @@ const { default: NotificationBell } = await import('./NotificationBell');
type Store = { setState: (p: Record<string, unknown>) => void };
const store = useNotificationStore as unknown as Store;
const notification = (id: string, title: string, read = false) => ({
const notification = (
id: string,
title: string,
read = false,
createdAt = new Date().toISOString(),
) => ({
id,
title,
message: `${title} body`,
read,
createdAt: new Date().toISOString(),
createdAt,
data: {},
});
@@ -52,6 +57,20 @@ function renderBell() {
);
}
function renderBellWithRoutes() {
return render(
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/" element={<NotificationBell />} />
<Route
path="/versus/async/:matchId/leaderboard"
element={<div>leaderboard route</div>}
/>
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
store.setState({ notifications: [], unreadCount: 0 });
@@ -74,6 +93,20 @@ describe('NotificationBell', () => {
expect(fetchNotifications).toHaveBeenCalledTimes(1);
});
it('closes from a second click on the trigger without re-fetching', async () => {
renderBell();
const trigger = screen.getByRole('button', { name: 'Notifications' });
await userEvent.click(trigger);
expect(fetchNotifications).toHaveBeenCalledTimes(1);
await userEvent.click(trigger);
expect(trigger).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('dialog')).toBeNull();
expect(fetchNotifications).toHaveBeenCalledTimes(1);
});
it('drops the count from the name when nothing is unread', () => {
renderBell();
expect(
@@ -206,4 +239,161 @@ describe('NotificationBell', () => {
);
expect(trigger.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
});
it('caps the visible badge count at 99+', () => {
store.setState({ unreadCount: 140 });
renderBell();
const trigger = screen.getByRole('button', { name: 'Notifications, 140 unread' });
expect(trigger.querySelector('span[aria-hidden="true"]')).toHaveTextContent('99+');
});
it('navigates to the match leaderboard and closes the panel for a versus notification', async () => {
store.setState({
notifications: [
{
...notification('n1', 'Your challenger has finished'),
data: { matchId: 'm42' },
},
],
unreadCount: 1,
});
renderBellWithRoutes();
await userEvent.click(screen.getByRole('button', { name: /Notifications/ }));
await userEvent.click(
screen.getByRole('button', { name: /^Unread:\s*Your challenger has finished/ }),
);
expect(screen.getByText('leaderboard route')).toBeInTheDocument();
expect(markRead).toHaveBeenCalledWith('n1');
});
it('marks a notification without a matchId read but does not navigate', async () => {
store.setState({
notifications: [notification('n1', 'Plain notice')],
unreadCount: 1,
});
renderBellWithRoutes();
await userEvent.click(screen.getByRole('button', { name: /Notifications/ }));
await userEvent.click(screen.getByRole('button', { name: /^Unread:\s*Plain notice/ }));
expect(markRead).toHaveBeenCalledWith('n1');
expect(screen.queryByText('leaderboard route')).toBeNull();
// Clicking a plain notification does not close the panel.
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('does not re-mark an already-read notification when clicked', async () => {
store.setState({
notifications: [notification('n1', 'Already read', true)],
unreadCount: 0,
});
renderBell();
await userEvent.click(screen.getByRole('button', { name: /Notifications/ }));
await userEvent.click(screen.getByRole('button', { name: /^Already read/ }));
expect(markRead).not.toHaveBeenCalled();
});
it('dismisses a notification from its trash control', async () => {
store.setState({
notifications: [notification('n1', 'Alice challenged you')],
unreadCount: 1,
});
renderBell();
await userEvent.click(screen.getByRole('button', { name: /Notifications/ }));
await userEvent.click(
screen.getByRole('button', { name: 'Dismiss "Alice challenged you"' }),
);
expect(dismiss).toHaveBeenCalledWith('n1');
});
it('shows an empty state with no notifications', async () => {
renderBell();
await userEvent.click(screen.getByRole('button', { name: 'Notifications' }));
expect(screen.getByText('No notifications')).toBeInTheDocument();
});
it('closes the panel when a click lands outside it', async () => {
render(
<MemoryRouter>
<div>
<NotificationBell />
<button type="button">outside</button>
</div>
</MemoryRouter>,
);
await userEvent.click(screen.getByRole('button', { name: 'Notifications' }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'outside' }));
expect(screen.queryByRole('dialog')).toBeNull();
});
it('does not close when the click lands inside the panel', async () => {
store.setState({
notifications: [notification('n1', 'Alice challenged you', true)],
});
renderBell();
await userEvent.click(screen.getByRole('button', { name: 'Notifications' }));
const dialog = screen.getByRole('dialog');
await userEvent.click(dialog);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('marks a single notification read from its own control, without opening it', async () => {
store.setState({
notifications: [notification('n1', 'Alice challenged you')],
unreadCount: 1,
});
renderBell();
await userEvent.click(screen.getByRole('button', { name: /Notifications/ }));
await userEvent.click(
screen.getByRole('button', { name: 'Mark "Alice challenged you" as read' }),
);
expect(markRead).toHaveBeenCalledWith('n1');
// Only the per-row control fired - not the row's own click handler too.
expect(markRead).toHaveBeenCalledTimes(1);
});
describe('timeAgo', () => {
it('reports minutes for something under an hour old', async () => {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString();
store.setState({
notifications: [notification('n1', 'Old one', true, fiveMinutesAgo)],
});
renderBell();
await userEvent.click(screen.getByRole('button', { name: 'Notifications' }));
expect(screen.getByText('5m ago')).toBeInTheDocument();
});
it('reports hours for something under a day old', async () => {
const threeHoursAgo = new Date(Date.now() - 3 * 3_600_000).toISOString();
store.setState({
notifications: [notification('n1', 'Old one', true, threeHoursAgo)],
});
renderBell();
await userEvent.click(screen.getByRole('button', { name: 'Notifications' }));
expect(screen.getByText('3h ago')).toBeInTheDocument();
});
it('reports days for anything older still', async () => {
const twoDaysAgo = new Date(Date.now() - 2 * 86_400_000).toISOString();
store.setState({
notifications: [notification('n1', 'Old one', true, twoDaysAgo)],
});
renderBell();
await userEvent.click(screen.getByRole('button', { name: 'Notifications' }));
expect(screen.getByText('2d ago')).toBeInTheDocument();
});
});
});
+214
View File
@@ -0,0 +1,214 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router';
import { beforeEach, describe, expect, it } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { useFriendsStore } from '@/stores/friends-store';
import PageLayout from './PageLayout';
/**
* The shared header/shell. Seeded via `setState` on the real auth/friends
* stores per WP-19 convention, and rendered inside a `MemoryRouter` since the
* component reads `useLocation` and renders `Link`s directly (no `<Route>`
* matching is needed for that).
*/
function renderLayout(path = '/', noPadding = false) {
return render(
<MemoryRouter initialEntries={[path]}>
<PageLayout noPadding={noPadding}>
<p>page content</p>
</PageLayout>
</MemoryRouter>,
);
}
beforeEach(() => {
useAuthStore.setState({ user: null });
useFriendsStore.setState({ pendingCount: 0 });
});
describe('PageLayout - signed out', () => {
it('renders the page content and a Sign In link, with no account controls', () => {
renderLayout();
expect(screen.getByText('page content')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Sign In' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: /Profile|Me/ })).toBeNull();
expect(screen.queryByRole('button', { name: /Notifications/ })).toBeNull();
expect(screen.queryByRole('link', { name: /Admin/ })).toBeNull();
});
it('offers a skip link to the main content', () => {
renderLayout();
const skip = screen.getByRole('link', { name: 'Skip to content' });
expect(skip).toHaveAttribute('href', '#main-content');
});
it('highlights the nav link matching the current route', () => {
renderLayout('/daily');
expect(screen.getByRole('link', { name: 'Daily' }).className).toContain(
'text-foreground',
);
expect(screen.getByRole('link', { name: 'Home' }).className).toContain(
'text-muted-foreground',
);
});
it('treats any /friends/* route as the Friends link being active', () => {
renderLayout('/friends/requests');
expect(screen.getByRole('link', { name: 'Friends' }).className).toContain(
'text-foreground',
);
});
it('shows no pending badge when the count is zero', () => {
renderLayout();
const friendsLink = screen.getByRole('link', { name: 'Friends' });
expect(friendsLink.textContent).toBe('Friends');
});
it('shows the pending-request count next to Friends', () => {
useFriendsStore.setState({ pendingCount: 4 });
renderLayout();
const friendsLinks = screen.getAllByRole('link', { name: /Friends/ });
expect(friendsLinks[0]?.textContent).toContain('4');
});
it('opens the mobile menu from the hamburger and shows the same nav links', async () => {
renderLayout();
await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
const dialog = screen.getByRole('dialog', { name: 'Menu' });
expect(dialog).toBeInTheDocument();
// No admin or sign-out controls without a signed-in user.
expect(screen.queryByRole('link', { name: /Admin/ })).toBeNull();
expect(screen.queryByRole('button', { name: 'Sign Out' })).toBeNull();
});
it('closes the mobile menu when a nav link is clicked', async () => {
renderLayout();
await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
// Two "Home" links exist (desktop + mobile) while the sheet is open; the
// one inside the dialog is the mobile one.
const dialog = screen.getByRole('dialog');
const homeLinkInDialog = screen.getAllByRole('link', { name: 'Home' })
.find((el) => dialog.contains(el));
expect(homeLinkInDialog).toBeDefined();
if (homeLinkInDialog) await userEvent.click(homeLinkInDialog);
expect(screen.queryByRole('dialog')).toBeNull();
});
it('applies the fixed, no-scroll layout when noPadding is set', () => {
const { container } = renderLayout('/', true);
const root = container.firstElementChild;
expect(root?.className).toContain('h-[100dvh]');
expect(root?.className).not.toContain('min-h-screen');
});
it('applies the scrollable layout by default', () => {
const { container } = renderLayout('/', false);
const root = container.firstElementChild;
expect(root?.className).toContain('min-h-screen');
});
});
describe('PageLayout - signed in', () => {
beforeEach(() => {
useAuthStore.setState({
user: { id: 'u1', email: 'a@b.com', username: 'Riley', isAdmin: false },
});
});
it('shows the profile link and notification bell for a signed-in user', () => {
renderLayout();
expect(screen.getByRole('link', { name: /Riley/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Notifications/ })).toBeInTheDocument();
});
it('hides the admin link for a non-admin user', () => {
renderLayout();
expect(screen.queryByRole('link', { name: /Admin/ })).toBeNull();
});
it('highlights the profile link while on the profile page', () => {
renderLayout('/profile');
expect(screen.getByRole('link', { name: /Riley/ }).className).toContain(
'text-foreground',
);
});
it('does not highlight the profile link elsewhere', () => {
renderLayout('/');
expect(screen.getByRole('link', { name: /Riley/ }).className).toContain(
'text-muted-foreground',
);
});
it('signs out from the desktop control, clearing the signed-in user', async () => {
renderLayout();
const signOutButtons = screen.getAllByRole('button');
const desktopSignOut = signOutButtons.find(
(b) => b.querySelector('.lucide-log-out') && b.className.includes('hidden'),
);
expect(desktopSignOut).toBeDefined();
if (desktopSignOut) await userEvent.click(desktopSignOut);
expect(useAuthStore.getState().user).toBeNull();
});
describe('admin user', () => {
beforeEach(() => {
useAuthStore.setState({
user: { id: 'u1', email: 'a@b.com', username: 'Riley', isAdmin: true },
});
});
it('shows the Admin link on the desktop nav', () => {
renderLayout('/admin');
const adminLink = screen.getAllByRole('link', { name: /Admin/ })[0];
expect(adminLink?.className).toContain('text-foreground');
});
it('shows the Admin link and a Sign Out button in the mobile menu too', async () => {
renderLayout();
await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
const dialog = screen.getByRole('dialog');
const adminLinkInDialog = screen
.getAllByRole('link', { name: /Admin/ })
.find((el) => dialog.contains(el));
expect(adminLinkInDialog).toBeDefined();
expect(
screen.getAllByRole('button', { name: 'Sign Out' }).some((b) => dialog.contains(b)),
).toBe(true);
});
it('closes the sheet when the mobile Admin link is clicked', async () => {
renderLayout();
await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
const dialog = screen.getByRole('dialog');
const adminLinkInDialog = screen
.getAllByRole('link', { name: /Admin/ })
.find((el) => dialog.contains(el));
expect(adminLinkInDialog).toBeDefined();
if (adminLinkInDialog) await userEvent.click(adminLinkInDialog);
expect(screen.queryByRole('dialog')).toBeNull();
});
it('signs out and closes the sheet from the mobile control', async () => {
renderLayout();
await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
const dialog = screen.getByRole('dialog');
const mobileSignOut = screen
.getAllByRole('button', { name: 'Sign Out' })
.find((b) => dialog.contains(b));
expect(mobileSignOut).toBeDefined();
if (mobileSignOut) await userEvent.click(mobileSignOut);
expect(useAuthStore.getState().user).toBeNull();
expect(screen.queryByRole('dialog')).toBeNull();
});
});
});
@@ -0,0 +1,69 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import ThemeToggle from './ThemeToggle';
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove('dark');
});
afterEach(() => {
localStorage.clear();
document.documentElement.classList.remove('dark');
});
describe('ThemeToggle', () => {
it('defaults to dark mode when nothing is stored', () => {
render(<ThemeToggle />);
expect(
screen.getByRole('button', { name: 'Switch to light mode' }),
).toBeInTheDocument();
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
it('honours a stored light preference', () => {
localStorage.setItem('theme', 'light');
render(<ThemeToggle />);
expect(
screen.getByRole('button', { name: 'Switch to dark mode' }),
).toBeInTheDocument();
expect(document.documentElement.classList.contains('dark')).toBe(false);
});
it('honours a stored dark preference', () => {
localStorage.setItem('theme', 'dark');
render(<ThemeToggle />);
expect(
screen.getByRole('button', { name: 'Switch to light mode' }),
).toBeInTheDocument();
});
it('falls back to dark for an unrecognised stored value', () => {
localStorage.setItem('theme', 'sepia');
render(<ThemeToggle />);
expect(
screen.getByRole('button', { name: 'Switch to light mode' }),
).toBeInTheDocument();
});
it('toggles from dark to light, persisting the choice and updating the document class', async () => {
render(<ThemeToggle />);
await userEvent.click(screen.getByRole('button', { name: 'Switch to light mode' }));
expect(
screen.getByRole('button', { name: 'Switch to dark mode' }),
).toBeInTheDocument();
expect(document.documentElement.classList.contains('dark')).toBe(false);
expect(localStorage.getItem('theme')).toBe('light');
});
it('toggles from light back to dark', async () => {
localStorage.setItem('theme', 'light');
render(<ThemeToggle />);
await userEvent.click(screen.getByRole('button', { name: 'Switch to dark mode' }));
expect(document.documentElement.classList.contains('dark')).toBe(true);
expect(localStorage.getItem('theme')).toBe('dark');
});
});
+346
View File
@@ -0,0 +1,346 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Alert, AlertAction, AlertDescription, AlertTitle } from './alert';
import { Badge } from './badge';
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from './card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from './dialog';
import { DifficultyBadge } from './difficulty-badge';
import { DIFFICULTY_STYLES, getDifficultyButtonClass } from './difficulty-styles';
import { Input } from './input';
import { Separator } from './separator';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './sheet';
import { Skeleton } from './skeleton';
/**
* A single sweep over the small UI primitives in WP-19's group: each exported
* piece rendered with its variants, asserting on the DOM it produces rather
* than on implementation details of the underlying base-ui/cva plumbing.
*/
describe('Alert', () => {
it('renders as a labelled alert region with a title and description', () => {
render(
<Alert>
<AlertTitle>Heads up</AlertTitle>
<AlertDescription>Something happened.</AlertDescription>
</Alert>,
);
const alert = screen.getByRole('alert');
expect(alert).toHaveTextContent('Heads up');
expect(alert).toHaveTextContent('Something happened.');
});
it('applies the destructive variant class', () => {
render(<Alert variant="destructive">Danger</Alert>);
expect(screen.getByRole('alert').className).toContain('text-destructive');
});
it('defaults to the default variant', () => {
render(<Alert>Default</Alert>);
expect(screen.getByRole('alert').className).toContain('bg-card');
});
it('positions an action slot', () => {
render(
<Alert>
<AlertAction>
<button type="button">Undo</button>
</AlertAction>
</Alert>,
);
expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument();
});
});
describe('Badge', () => {
it.each(['default', 'secondary', 'destructive', 'outline', 'ghost', 'link'] as const)(
'renders the %s variant as a span with its text',
(variant) => {
render(<Badge variant={variant}>Status</Badge>);
const badge = screen.getByText('Status');
expect(badge.tagName).toBe('SPAN');
expect(badge).toHaveAttribute('data-slot', 'badge');
},
);
it('defaults to the default variant when none is given', () => {
render(<Badge>Plain</Badge>);
expect(screen.getByText('Plain').className).toContain('bg-primary');
});
});
describe('Card', () => {
it('composes header, title, description, action, content and footer', () => {
render(
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
<CardDescription>Description</CardDescription>
<CardAction>
<button type="button">Action</button>
</CardAction>
</CardHeader>
<CardContent>Body</CardContent>
<CardFooter>Footer</CardFooter>
</Card>,
);
expect(screen.getByText('Title')).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
expect(screen.getByText('Body')).toBeInTheDocument();
expect(screen.getByText('Footer')).toBeInTheDocument();
});
it('applies the compact size variant', () => {
render(<Card size="sm" data-testid="card">content</Card>);
expect(screen.getByTestId('card')).toHaveAttribute('data-size', 'sm');
});
it('defaults to the default size', () => {
render(<Card data-testid="card">content</Card>);
expect(screen.getByTestId('card')).toHaveAttribute('data-size', 'default');
});
});
describe('Dialog', () => {
it('renders nothing while closed', () => {
render(
<Dialog open={false}>
<DialogContent>
<DialogHeader>
<DialogTitle>Hi</DialogTitle>
</DialogHeader>
</DialogContent>
</Dialog>,
);
expect(screen.queryByRole('dialog')).toBeNull();
});
it('renders a labelled dialog with a close button by default', async () => {
render(
<Dialog open>
<DialogContent>
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
</DialogContent>
</Dialog>,
);
expect(screen.getByRole('dialog', { name: 'Settings' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
});
it('omits the close button when told to', () => {
render(
<Dialog open>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>No close</DialogTitle>
</DialogHeader>
</DialogContent>
</Dialog>,
);
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull();
});
it('opens, uncontrolled, from a DialogTrigger', async () => {
render(
<Dialog>
<DialogTrigger render={<button type="button">Open dialog</button>} />
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Triggered</DialogTitle>
<DialogDescription>Opened from its trigger.</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>,
);
expect(screen.queryByRole('dialog')).toBeNull();
await userEvent.click(screen.getByRole('button', { name: 'Open dialog' }));
expect(screen.getByRole('dialog', { name: 'Triggered' })).toBeInTheDocument();
expect(screen.getByText('Opened from its trigger.')).toBeInTheDocument();
});
it('renders footer content without an extra close button by default', () => {
render(
<Dialog open>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Confirm</DialogTitle>
</DialogHeader>
<DialogFooter>
<button type="button">Save</button>
</DialogFooter>
</DialogContent>
</Dialog>,
);
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull();
});
it('adds a Close action to the footer when asked', () => {
render(
<Dialog open>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Confirm</DialogTitle>
</DialogHeader>
<DialogFooter showCloseButton>
<button type="button">Save</button>
</DialogFooter>
</DialogContent>
</Dialog>,
);
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
});
});
describe('DifficultyBadge', () => {
it.each(['easy', 'medium', 'hard'] as const)(
'renders the %s difficulty with its own styling',
(difficulty) => {
render(<DifficultyBadge difficulty={difficulty} />);
const badge = screen.getByText(difficulty);
for (const cls of DIFFICULTY_STYLES[difficulty].split(' ')) {
expect(badge.className).toContain(cls);
}
},
);
it('falls back to the medium style for an unrecognised difficulty', () => {
render(<DifficultyBadge difficulty="expert" />);
const badge = screen.getByText('expert');
for (const cls of DIFFICULTY_STYLES.medium.split(' ')) {
expect(badge.className).toContain(cls);
}
});
it('merges a custom className', () => {
render(<DifficultyBadge difficulty="easy" className="my-extra-class" />);
expect(screen.getByText('easy').className).toContain('my-extra-class');
});
});
describe('getDifficultyButtonClass', () => {
it('is empty when inactive, regardless of difficulty', () => {
expect(getDifficultyButtonClass('easy', false)).toBe('');
expect(getDifficultyButtonClass('hard', false)).toBe('');
});
it.each(['easy', 'medium', 'hard'] as const)(
'returns the active styling for %s when active',
(difficulty) => {
const result = getDifficultyButtonClass(difficulty, true);
expect(result.length).toBeGreaterThan(0);
expect(result).toContain('border-');
},
);
});
describe('Input', () => {
it('renders a native input and accepts typed text', async () => {
render(<Input placeholder="Search…" />);
const input = screen.getByPlaceholderText('Search…');
expect(input.tagName).toBe('INPUT');
await userEvent.type(input, 'hello');
expect(input).toHaveValue('hello');
});
it('passes through the type attribute', () => {
render(<Input type="email" aria-label="Email" />);
expect(screen.getByLabelText('Email')).toHaveAttribute('type', 'email');
});
it('can be disabled', () => {
render(<Input aria-label="Disabled field" disabled />);
expect(screen.getByLabelText('Disabled field')).toBeDisabled();
});
});
describe('Separator', () => {
it('defaults to a horizontal orientation', () => {
render(<Separator data-testid="sep" />);
expect(screen.getByTestId('sep')).toHaveAttribute('data-orientation', 'horizontal');
});
it('honours a vertical orientation', () => {
render(<Separator orientation="vertical" data-testid="sep" />);
expect(screen.getByTestId('sep')).toHaveAttribute('data-orientation', 'vertical');
});
});
describe('Sheet', () => {
it('renders nothing while closed', () => {
render(
<Sheet open={false}>
<SheetContent>
<SheetHeader>
<SheetTitle>Menu</SheetTitle>
</SheetHeader>
</SheetContent>
</Sheet>,
);
expect(screen.queryByText('Menu')).toBeNull();
});
it('renders its content, side and close button when open', () => {
const onOpenChange = vi.fn();
render(
<Sheet open onOpenChange={onOpenChange}>
<SheetContent side="left">
<SheetHeader>
<SheetTitle>Navigation</SheetTitle>
</SheetHeader>
</SheetContent>
</Sheet>,
);
expect(screen.getByText('Navigation')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
});
it('omits the close button when told to', () => {
render(
<Sheet open>
<SheetContent showCloseButton={false}>
<SheetHeader>
<SheetTitle>No close</SheetTitle>
</SheetHeader>
</SheetContent>
</Sheet>,
);
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull();
});
});
describe('Skeleton', () => {
it('renders a placeholder block with the pulse animation class', () => {
render(<Skeleton data-testid="skeleton" />);
expect(screen.getByTestId('skeleton').className).toContain('animate-pulse');
});
it('merges a custom className', () => {
render(<Skeleton data-testid="skeleton" className="h-4 w-4" />);
expect(screen.getByTestId('skeleton').className).toContain('h-4');
});
});
@@ -0,0 +1,172 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router';
import { describe, expect, it } from 'vitest';
import AsyncCompletionModal from './AsyncCompletionModal';
import type { ComponentProps } from 'react';
import type { ScoreBreakdown } from '@/types';
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(props: Partial<ComponentProps<typeof AsyncCompletionModal>>) {
const defaults: ComponentProps<typeof AsyncCompletionModal> = {
open: true,
matchId: 'm1',
mode: 'creator',
myScore: score(1000),
myChainLength: 5,
};
return render(
<MemoryRouter initialEntries={['/versus/async/m1']}>
<Routes>
<Route
path="/versus/async/m1"
element={<AsyncCompletionModal {...defaults} {...props} />}
/>
<Route path="/versus" element={<div>versus route</div>} />
<Route
path="/versus/async/:matchId/leaderboard"
element={<div>leaderboard route</div>}
/>
</Routes>
</MemoryRouter>,
);
}
describe('AsyncCompletionModal', () => {
it('renders nothing when closed', () => {
renderModal({ open: false });
expect(screen.queryByRole('dialog')).toBeNull();
});
it('renders nothing without a score yet', () => {
renderModal({ myScore: null });
expect(screen.queryByRole('dialog')).toBeNull();
});
it('defaults myTotal to 0 when the score object has no totalScore field', () => {
renderModal({ mode: 'creator', myScore: {} as ScoreBreakdown });
expect(screen.getByText('0')).toBeInTheDocument();
});
describe('creator mode', () => {
it('shows the submitted score and link count', () => {
renderModal({ mode: 'creator', myScore: score(1234), myChainLength: 7 });
expect(screen.getByRole('heading', { name: /Score Submitted!/ })).toBeInTheDocument();
expect(screen.getByText('1234')).toBeInTheDocument();
expect(screen.getByText(/7 links/)).toBeInTheDocument();
});
it('navigates back to Versus', async () => {
renderModal({ mode: 'creator' });
await userEvent.click(screen.getByRole('button', { name: 'Back to Versus' }));
expect(screen.getByText('versus route')).toBeInTheDocument();
});
it('navigates to the match leaderboard', async () => {
renderModal({ mode: 'creator', matchId: 'm1' });
await userEvent.click(screen.getByRole('button', { name: 'View Leaderboard' }));
expect(screen.getByText('leaderboard route')).toBeInTheDocument();
});
});
describe('challenger mode', () => {
it('declares a tie when the scores match', () => {
renderModal({
mode: 'challenger',
myScore: score(1000),
creatorScore: { totalScore: 1000 },
});
expect(screen.getByText("It's a tie!")).toBeInTheDocument();
});
it('declares a win when the challenger scores higher', () => {
renderModal({
mode: 'challenger',
myScore: score(1500),
creatorScore: { totalScore: 1000 },
});
expect(screen.getByText('You beat the creator!')).toBeInTheDocument();
});
it('declares the creator the winner otherwise', () => {
renderModal({
mode: 'challenger',
myScore: score(500),
creatorScore: { totalScore: 1000 },
});
expect(screen.getByText('Creator wins!')).toBeInTheDocument();
});
it('defaults a missing creator score to 0, tying against a zero score', () => {
renderModal({ mode: 'challenger', myScore: score(0), creatorScore: null });
expect(screen.getByText("It's a tie!")).toBeInTheDocument();
const zeros = screen.getAllByText('0');
expect(zeros.length).toBeGreaterThanOrEqual(2);
});
it('beats a creator whose score defaults to 0', () => {
renderModal({ mode: 'challenger', myScore: score(50), creatorScore: null });
expect(screen.getByText('You beat the creator!')).toBeInTheDocument();
});
it('shows "?" for an unknown creator chain length', () => {
renderModal({ mode: 'challenger', creatorChainLength: null });
expect(screen.getByText('? links')).toBeInTheDocument();
});
it('shows the creator chain length when known', () => {
renderModal({ mode: 'challenger', creatorChainLength: 9 });
expect(screen.getByText('9 links')).toBeInTheDocument();
});
it('shows the rank only when one is given', () => {
const { rerender } = renderModal({ mode: 'challenger', rank: undefined });
expect(screen.queryByText(/Your rank/)).toBeNull();
rerender(
<MemoryRouter initialEntries={['/versus/async/m1']}>
<Routes>
<Route
path="/versus/async/m1"
element={
<AsyncCompletionModal
open
matchId="m1"
mode="challenger"
myScore={score(1000)}
myChainLength={5}
rank={3}
/>
}
/>
</Routes>
</MemoryRouter>,
);
expect(screen.getByText('#3')).toBeInTheDocument();
});
it('navigates back to Versus and to the full leaderboard', async () => {
renderModal({ mode: 'challenger' });
await userEvent.click(screen.getByRole('button', { name: 'Back to Versus' }));
expect(screen.getByText('versus route')).toBeInTheDocument();
});
it('links to the full leaderboard', async () => {
renderModal({ mode: 'challenger', matchId: 'm1' });
await userEvent.click(screen.getByRole('button', { name: 'Full Leaderboard' }));
expect(screen.getByText('leaderboard route')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,57 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { useVersusStore } from '@/stores/versus-store';
import OpponentProgress from './OpponentProgress';
const ME = { id: 'u1', username: 'Me' };
const RIVAL = { id: 'u2', username: 'Rival' };
beforeEach(() => {
useAuthStore.setState({ user: null });
useVersusStore.setState({
player1: null,
player2: null,
opponentChainLength: 0,
opponentFinished: false,
});
});
describe('OpponentProgress', () => {
it('renders nothing before an opponent has joined', () => {
const { container } = render(<OpponentProgress />);
expect(container).toBeEmptyDOMElement();
});
it('identifies the opponent as player2 when I am player1', () => {
useAuthStore.setState({ user: { id: 'u1', email: 'a@b.com', username: 'Me', isAdmin: false } });
useVersusStore.setState({ player1: ME, player2: RIVAL, opponentChainLength: 4 });
render(<OpponentProgress />);
expect(screen.getByText('Rival')).toBeInTheDocument();
expect(screen.getByText(/Chain: 1 links/)).toBeInTheDocument();
});
it('identifies the opponent as player1 when I am player2', () => {
useAuthStore.setState({ user: { id: 'u2', email: 'a@b.com', username: 'Rival', isAdmin: false } });
useVersusStore.setState({ player1: ME, player2: RIVAL, opponentChainLength: 2 });
render(<OpponentProgress />);
expect(screen.getByText('Me')).toBeInTheDocument();
});
it('shows the finished state and the hurry-up note once the opponent is done', () => {
useAuthStore.setState({ user: { id: 'u1', email: 'a@b.com', username: 'Me', isAdmin: false } });
useVersusStore.setState({ player1: ME, player2: RIVAL, opponentFinished: true });
render(<OpponentProgress />);
expect(screen.getByText('Finished!')).toBeInTheDocument();
expect(
screen.getByText('Your opponent has finished — hurry up!'),
).toBeInTheDocument();
expect(screen.queryByText(/Chain:/)).toBeNull();
});
});
@@ -0,0 +1,183 @@
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 } from 'vitest';
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('1500')).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("It's 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 win!')).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 lose!')).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();
});
});
+89
View File
@@ -0,0 +1,89 @@
import { renderHook, act } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useDebounce } from './use-debounce';
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('useDebounce', () => {
it('starts with the initial value', () => {
const { result } = renderHook(() => useDebounce('a'));
expect(result.current).toBe('a');
});
it('does not update before the delay elapses', () => {
const { result, rerender } = renderHook(({ value }) => useDebounce(value), {
initialProps: { value: 'a' },
});
rerender({ value: 'b' });
act(() => {
vi.advanceTimersByTime(299);
});
expect(result.current).toBe('a');
});
it('updates to the latest value once the default 300ms delay elapses', () => {
const { result, rerender } = renderHook(({ value }) => useDebounce(value), {
initialProps: { value: 'a' },
});
rerender({ value: 'b' });
act(() => {
vi.advanceTimersByTime(300);
});
expect(result.current).toBe('b');
});
it('honours a custom delay', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 1000),
{ initialProps: { value: 'a' } },
);
rerender({ value: 'b' });
act(() => {
vi.advanceTimersByTime(500);
});
expect(result.current).toBe('a');
act(() => {
vi.advanceTimersByTime(500);
});
expect(result.current).toBe('b');
});
it('resets the timer when the value changes again before it fires', () => {
const { result, rerender } = renderHook(({ value }) => useDebounce(value), {
initialProps: { value: 'a' },
});
rerender({ value: 'b' });
act(() => {
vi.advanceTimersByTime(200);
});
rerender({ value: 'c' });
act(() => {
vi.advanceTimersByTime(200);
});
// 400ms since 'b' arrived, but only 200ms since 'c' - not yet due.
expect(result.current).toBe('a');
act(() => {
vi.advanceTimersByTime(100);
});
expect(result.current).toBe('c');
});
it('clears the pending timer on unmount', () => {
const { rerender, unmount } = renderHook(({ value }) => useDebounce(value), {
initialProps: { value: 'a' },
});
rerender({ value: 'b' });
unmount();
// Nothing to assert on `result` after unmount; this just proves the
// cleanup runs without throwing when timers later fire.
act(() => {
vi.advanceTimersByTime(1000);
});
});
});
+168
View File
@@ -0,0 +1,168 @@
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { useGameStore } from '@/stores/game-store';
import { useGameScreen } from './use-game-screen';
import type { ActorChainLink, MovieChainLink } from '@/types';
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 MOVIE_A = movieLink(100, 'Movie A');
const MOVIE_B = movieLink(200, 'Movie B');
beforeEach(() => {
useGameStore.getState().resetGame();
});
describe('useGameScreen', () => {
it('is not ready while the game is idle', () => {
const { result } = renderHook(() => useGameScreen());
expect(result.current.ready).toBe(false);
expect(result.current.status).toBe('idle');
});
it('is not ready when either target movie is missing, even mid-game', () => {
useGameStore.setState({ status: 'playing', movieA: MOVIE_A, movieB: null });
const { result } = renderHook(() => useGameScreen());
expect(result.current.ready).toBe(false);
});
it('is ready with both movies once the game is playing', () => {
useGameStore.setState({
status: 'playing',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: [MOVIE_A],
});
const { result } = renderHook(() => useGameScreen());
expect(result.current.ready).toBe(true);
if (result.current.ready) {
expect(result.current.movieA).toBe(MOVIE_A);
expect(result.current.movieB).toBe(MOVIE_B);
}
});
it('stays ready once the game is completed', () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: [MOVIE_A, actorLink(1), MOVIE_B, actorLink(2), MOVIE_A],
});
const { result } = renderHook(() => useGameScreen());
expect(result.current.ready).toBe(true);
});
it('prompts for an actor after the last link is a movie', () => {
useGameStore.setState({
status: 'playing',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: [MOVIE_A],
currentSearchMode: 'actor',
});
const { result } = renderHook(() => useGameScreen());
expect(result.current.searchLabel).toBe('Who was in "Movie A"?');
expect(result.current.searchPlaceholder).toBe('Search for an actor...');
});
it('prompts for a movie after the last link is an actor', () => {
useGameStore.setState({
status: 'playing',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: [MOVIE_A, actorLink(1, 'Keanu')],
currentSearchMode: 'movie',
});
const { result } = renderHook(() => useGameScreen());
expect(result.current.searchLabel).toBe('What movie was Keanu also in?');
expect(result.current.searchPlaceholder).toBe('Search for a movie...');
});
it('has no label when the chain is empty', () => {
const { result } = renderHook(() => useGameScreen());
expect(result.current.searchLabel).toBe('');
expect(result.current.searchPlaceholder).toBe('');
});
it('has no label when the search mode and last link type are mismatched mid-transition', () => {
useGameStore.setState({
chain: [MOVIE_A, actorLink(1)],
currentSearchMode: 'actor',
});
const { result } = renderHook(() => useGameScreen());
expect(result.current.searchLabel).toBe('');
});
it('flags the loop hint once movie B has been passed and a movie is next', () => {
useGameStore.setState({
status: 'playing',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: [MOVIE_A, actorLink(1), MOVIE_B, actorLink(2)],
currentSearchMode: 'movie',
passedMovieB: true,
});
const { result } = renderHook(() => useGameScreen());
expect(result.current.showLoopHint).toBe(true);
});
it('does not flag the loop hint before movie B has been passed', () => {
useGameStore.setState({
status: 'playing',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: [MOVIE_A],
currentSearchMode: 'movie',
passedMovieB: false,
});
const { result } = renderHook(() => useGameScreen());
expect(result.current.showLoopHint).toBe(false);
});
it('does not flag the loop hint while searching for an actor', () => {
useGameStore.setState({
status: 'playing',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: [MOVIE_A, actorLink(1), MOVIE_B],
currentSearchMode: 'actor',
passedMovieB: true,
});
const { result } = renderHook(() => useGameScreen());
expect(result.current.showLoopHint).toBe(false);
});
it('does not flag the loop hint without a movie A', () => {
useGameStore.setState({
status: 'idle',
movieA: null,
passedMovieB: true,
currentSearchMode: 'movie',
});
const { result } = renderHook(() => useGameScreen());
expect(result.current.showLoopHint).toBe(false);
});
it('exposes the store actions and validating flag directly', () => {
useGameStore.setState({ isValidating: true });
const { result } = renderHook(() => useGameScreen());
expect(result.current.isValidating).toBe(true);
expect(result.current.undoLastLink).toBe(useGameStore.getState().undoLastLink);
expect(typeof result.current.validateAndAddActor).toBe('function');
expect(typeof result.current.validateAndAddMovie).toBe('function');
});
});
+227
View File
@@ -0,0 +1,227 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { MovieChainLink, ActorChainLink } from '@/types';
/**
* Server-side hints: the hook only ever asks the backend for a hint (the
* server picks the actors and records the count), caps at 3 via the store's
* hintsUsed, and hides hints as soon as the chain moves past the movie they
* were fetched for.
*
* Requests are routed at the axios client, the same seam
* use-chain-validation.test.ts and game-store.test.ts use.
*/
const useHintApi = vi.fn();
function route(method: string, url: string) {
if (method === 'post' && /^\/games\/([^/]+)\/hint$/.exec(url)) {
return () => useHintApi();
}
throw new Error(`test reached an unrouted request: ${method} ${url}`);
}
vi.mock('@/api/client', () => {
const call =
(method: string) =>
async (url: string): Promise<{ data: unknown }> => ({
data: await route(method, url)(),
});
return {
default: {
get: call('get'),
post: call('post'),
put: call('put'),
delete: call('delete'),
},
};
});
const { useGameStore } = await import('@/stores/game-store');
const { useHints } = await import('./use-hints');
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 MOVIE_A = movieLink(100, 'Movie A');
function playing(
chain: (MovieChainLink | ActorChainLink)[],
extra: Partial<ReturnType<typeof useGameStore.getState>> = {},
) {
useGameStore.setState({
status: 'playing',
movieA: MOVIE_A,
chain,
currentSearchMode: 'actor',
sessionId: 's1',
hintsUsed: 0,
...extra,
});
}
beforeEach(() => {
vi.clearAllMocks();
useGameStore.getState().resetGame();
useHintApi.mockResolvedValue({
hintsUsed: 1,
hintsRemaining: 2,
actors: [
{ id: 7, name: 'Seven', profilePath: '/s.jpg', popularity: 12 },
],
});
});
function hook() {
return renderHook(() => useHints());
}
describe('canHint', () => {
it('is true after a movie, before the max hints, with a session and no request in flight', () => {
playing([MOVIE_A]);
const { result } = hook();
expect(result.current.canHint).toBe(true);
});
it('is false when the game is not in play', () => {
playing([MOVIE_A], { status: 'idle' });
const { result } = hook();
expect(result.current.canHint).toBe(false);
});
it('is false when searching for a movie rather than an actor', () => {
playing([MOVIE_A, actorLink(1)], { currentSearchMode: 'movie' });
const { result } = hook();
expect(result.current.canHint).toBe(false);
});
it('is false when the last link is not a movie', () => {
playing([MOVIE_A, actorLink(1)], { currentSearchMode: 'actor' });
const { result } = hook();
expect(result.current.canHint).toBe(false);
});
it('is false once the hint cap is reached', () => {
playing([MOVIE_A], { hintsUsed: 3 });
const { result } = hook();
expect(result.current.canHint).toBe(false);
expect(result.current.maxHints).toBe(3);
});
it('is false without a server session', () => {
playing([MOVIE_A], { sessionId: null });
const { result } = hook();
expect(result.current.canHint).toBe(false);
});
});
describe('getHint', () => {
it('does nothing when canHint is false', async () => {
playing([MOVIE_A], { status: 'idle' });
const { result } = hook();
await act(() => result.current.getHint());
expect(useHintApi).not.toHaveBeenCalled();
expect(result.current.hints).toEqual([]);
});
it('fetches a hint, records it on the store, and shows it for the current chain length', async () => {
playing([MOVIE_A]);
const { result } = hook();
await act(() => result.current.getHint());
expect(useHintApi).toHaveBeenCalledTimes(1);
expect(result.current.hints).toEqual([
{ id: 7, name: 'Seven', profile_path: '/s.jpg', popularity: 12 },
]);
expect(result.current.error).toBeNull();
expect(result.current.isLoading).toBe(false);
expect(useGameStore.getState().hintsUsed).toBe(1);
});
it('hides the fetched hints once the chain moves past that movie (stale-hint clearing)', async () => {
playing([MOVIE_A]);
const { result, rerender } = hook();
await act(() => result.current.getHint());
expect(result.current.hints).toHaveLength(1);
act(() => {
useGameStore.setState({
chain: [MOVIE_A, actorLink(7, 'Seven')],
currentSearchMode: 'movie',
});
});
rerender();
expect(result.current.hints).toEqual([]);
});
it('reports an error and clears the loading flag when the request fails', async () => {
playing([MOVIE_A]);
useHintApi.mockRejectedValue(new Error('500'));
const { result } = hook();
await act(() => result.current.getHint());
expect(result.current.error).toBe('Could not fetch a hint. Please try again.');
expect(result.current.isLoading).toBe(false);
expect(result.current.hints).toEqual([]);
});
it('marks isLoading true while the request is in flight', async () => {
playing([MOVIE_A]);
let release: (v: unknown) => void = () => undefined;
useHintApi.mockImplementation(
() => new Promise((resolve) => (release = resolve)),
);
const { result } = hook();
let pending: Promise<void> = Promise.resolve();
act(() => {
pending = result.current.getHint();
});
expect(result.current.isLoading).toBe(true);
release({ hintsUsed: 1, hintsRemaining: 2, actors: [] });
await act(() => pending);
expect(result.current.isLoading).toBe(false);
});
it('clears a previous error on a subsequent successful fetch', async () => {
playing([MOVIE_A]);
useHintApi.mockRejectedValueOnce(new Error('500'));
const { result } = hook();
await act(() => result.current.getHint());
expect(result.current.error).not.toBeNull();
await act(() => result.current.getHint());
expect(result.current.error).toBeNull();
});
});
describe('clearHints', () => {
it('empties the visible hints immediately', async () => {
playing([MOVIE_A]);
const { result } = hook();
await act(() => result.current.getHint());
expect(result.current.hints).toHaveLength(1);
act(() => result.current.clearHints());
expect(result.current.hints).toEqual([]);
});
});
+95
View File
@@ -0,0 +1,95 @@
import { renderHook, act } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useTimer } from './use-timer';
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
});
describe('useTimer', () => {
it('is zero and formatted when there is no start time', () => {
const { result } = renderHook(() => useTimer(null, false));
expect(result.current.elapsedSeconds).toBe(0);
expect(result.current.formattedTime).toBe('0:00');
expect(result.current.bonusTier).toBe('high');
});
it('seeds the elapsed time from Date.now() on the first render', () => {
const start = Date.now() - 65_000;
const { result } = renderHook(() => useTimer(start, false));
expect(result.current.elapsedSeconds).toBe(65);
expect(result.current.formattedTime).toBe('1:05');
});
it('ticks forward once per second while running', () => {
const start = Date.now();
const { result } = renderHook(() => useTimer(start, true));
expect(result.current.elapsedSeconds).toBe(0);
act(() => {
vi.advanceTimersByTime(1000);
});
expect(result.current.elapsedSeconds).toBe(1);
act(() => {
vi.advanceTimersByTime(2000);
});
expect(result.current.elapsedSeconds).toBe(3);
});
it('does not tick when isRunning is false', () => {
const start = Date.now();
const { result } = renderHook(() => useTimer(start, false));
act(() => {
vi.advanceTimersByTime(5000);
});
expect(result.current.elapsedSeconds).toBe(0);
});
it('stops ticking once isRunning flips to false, and clears the interval', () => {
const start = Date.now();
const { result, rerender } = renderHook(
({ running }) => useTimer(start, running),
{ initialProps: { running: true } },
);
act(() => {
vi.advanceTimersByTime(2000);
});
expect(result.current.elapsedSeconds).toBe(2);
rerender({ running: false });
act(() => {
vi.advanceTimersByTime(5000);
});
expect(result.current.elapsedSeconds).toBe(2);
});
it.each([
[30, 'high'],
[120, 'medium'],
[250, 'low'],
[400, 'none'],
] as const)('reports bonusTier %s -> %s at the boundary', (seconds, tier) => {
const start = Date.now() - seconds * 1000;
const { result } = renderHook(() => useTimer(start, false));
expect(result.current.bonusTier).toBe(tier);
});
it('never reports a negative elapsed time for a start time in the future', () => {
const start = Date.now() + 10_000;
const { result } = renderHook(() => useTimer(start, false));
expect(result.current.elapsedSeconds).toBe(0);
});
it('clears its interval on unmount', () => {
const start = Date.now();
const { unmount } = renderHook(() => useTimer(start, true));
unmount();
expect(() => vi.advanceTimersByTime(5000)).not.toThrow();
});
});
+26
View File
@@ -0,0 +1,26 @@
import axios from 'axios';
import { describe, expect, it } from 'vitest';
import { isAbortError } from './abort';
describe('isAbortError', () => {
it('recognises an axios cancellation', () => {
expect(isAbortError(new axios.Cancel('cancelled'))).toBe(true);
});
it('recognises a DOMException named AbortError', () => {
expect(isAbortError(new DOMException('aborted', 'AbortError'))).toBe(true);
});
it('does not treat another DOMException name as an abort', () => {
expect(isAbortError(new DOMException('oops', 'NotFoundError'))).toBe(false);
});
it('does not treat a plain Error as an abort', () => {
expect(isAbortError(new Error('boom'))).toBe(false);
});
it('does not treat non-error values as an abort', () => {
expect(isAbortError(undefined)).toBe(false);
expect(isAbortError('nope')).toBe(false);
});
});
+90
View File
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* `config.ts` resolves its value once, at module load, from either an
* injected `window.__APP_CONFIG__` (set by the container at start-up) or the
* build-time `VITE_API_URL` env var. Each case below needs a fresh module
* instance so the injected/env value in effect when the module is imported is
* the one under test.
*/
beforeEach(() => {
vi.resetModules();
delete window.__APP_CONFIG__;
});
afterEach(() => {
vi.unstubAllEnvs();
delete window.__APP_CONFIG__;
vi.restoreAllMocks();
});
describe('config', () => {
it('falls back to /api/v1 when nothing is injected and no env var is set', async () => {
vi.stubEnv('VITE_API_URL', undefined);
const { config } = await import('./config');
expect(config.apiUrl).toBe('/api/v1');
});
it('falls back to the VITE_API_URL env var when nothing is injected', async () => {
vi.stubEnv('VITE_API_URL', 'https://built-in.example/api');
const { config } = await import('./config');
expect(config.apiUrl).toBe('https://built-in.example/api');
});
it('uses an injected same-origin path', async () => {
window.__APP_CONFIG__ = { apiUrl: '/custom/api' };
const { config } = await import('./config');
expect(config.apiUrl).toBe('/custom/api');
});
it('uses an injected absolute https URL', async () => {
window.__APP_CONFIG__ = { apiUrl: 'https://api.example.com/v1' };
const { config } = await import('./config');
expect(config.apiUrl).toBe('https://api.example.com/v1');
});
it('uses an injected absolute http URL', async () => {
window.__APP_CONFIG__ = { apiUrl: 'http://api.example.com/v1' };
const { config } = await import('./config');
expect(config.apiUrl).toBe('http://api.example.com/v1');
});
it('rejects a non-http(s) protocol and logs, falling back to the default', async () => {
vi.stubEnv('VITE_API_URL', undefined);
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
window.__APP_CONFIG__ = { apiUrl: 'javascript:alert(1)' };
const { config } = await import('./config');
expect(config.apiUrl).toBe('/api/v1');
expect(error).toHaveBeenCalledWith(
'[config] Ignoring unusable injected apiUrl; falling back to',
'/api/v1',
);
});
it('rejects an unparsable URL string and logs', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
window.__APP_CONFIG__ = { apiUrl: 'not a url at all' };
const { config } = await import('./config');
expect(config.apiUrl).toBe('/api/v1');
expect(error).toHaveBeenCalled();
});
it('rejects an empty string apiUrl and logs', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
window.__APP_CONFIG__ = { apiUrl: '' };
const { config } = await import('./config');
expect(config.apiUrl).toBe('/api/v1');
expect(error).toHaveBeenCalled();
});
it('rejects a non-string apiUrl and logs', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
window.__APP_CONFIG__ = {
apiUrl: 42 as unknown as string,
};
const { config } = await import('./config');
expect(config.apiUrl).toBe('/api/v1');
expect(error).toHaveBeenCalled();
});
});
+49
View File
@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
/**
* Builds a socket.io connection for a namespace, gated on a stored auth
* token. `io` itself is mocked here (per WP-19 conventions) so we can assert
* exactly what URL and auth payload a real connection attempt would use,
* without ever touching the network. `getWsUrl` and `config` are left real:
* with no injected `window.__APP_CONFIG__` the default api base is the
* same-origin `/api/v1`, so the namespace is passed through unchanged.
*/
const getAuthToken = vi.fn<() => string | null>();
vi.mock('@/lib/storage', () => ({
storage: { getAuthToken: () => getAuthToken() },
}));
const io = vi.fn();
vi.mock('socket.io-client', () => ({
io: (...args: unknown[]) => io(...args),
}));
const { createSocket } = await import('./create-socket');
beforeEach(() => {
vi.clearAllMocks();
});
describe('createSocket', () => {
it('returns null and never dials out without a stored token', () => {
getAuthToken.mockReturnValue(null);
expect(createSocket('/chat')).toBeNull();
expect(io).not.toHaveBeenCalled();
});
it('dials the namespace with the token and a websocket-only transport', () => {
getAuthToken.mockReturnValue('tok-abc');
const fakeSocket = { id: 'sock-1' };
io.mockReturnValue(fakeSocket);
const result = createSocket('/chat');
expect(result).toBe(fakeSocket);
expect(io).toHaveBeenCalledWith('/chat', {
auth: { token: 'tok-abc' },
transports: ['websocket'],
});
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { getErrorMessage, getErrorStatus } from './error';
describe('getErrorMessage', () => {
it('returns a string response message from the server', () => {
const error = { response: { data: { message: 'Not allowed' } } };
expect(getErrorMessage(error)).toBe('Not allowed');
});
it('takes the first entry when the server returns a message array', () => {
const error = { response: { data: { message: ['first', 'second'] } } };
expect(getErrorMessage(error)).toBe('first');
});
it('falls back when a message array is empty', () => {
const error = { response: { data: { message: [] as string[] } } };
expect(getErrorMessage(error, 'default')).toBe('default');
});
it('maps a bare Network Error to a friendlier sentence', () => {
expect(getErrorMessage(new Error('Network Error'))).toBe(
'Network error — check your connection and try again.',
);
});
it('uses the fallback for an error with no response message', () => {
expect(getErrorMessage(new Error('something else'), 'Unknown error')).toBe(
'Unknown error',
);
});
it('uses the default fallback when none is given', () => {
expect(getErrorMessage(new Error('something else'))).toBe('Unknown error');
});
it('handles a value with no response or message field at all', () => {
expect(getErrorMessage(null, 'default')).toBe('default');
expect(getErrorMessage(undefined, 'default')).toBe('default');
expect(getErrorMessage('a string error', 'default')).toBe('default');
});
});
describe('getErrorStatus', () => {
it('reads the response status', () => {
expect(getErrorStatus({ response: { status: 404 } })).toBe(404);
});
it('is undefined when there is no response', () => {
expect(getErrorStatus(new Error('boom'))).toBeUndefined();
expect(getErrorStatus(null)).toBeUndefined();
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { formatTime } from './format';
describe('formatTime', () => {
it('formats zero seconds', () => {
expect(formatTime(0)).toBe('0:00');
});
it('pads seconds under ten', () => {
expect(formatTime(65)).toBe('1:05');
});
it('does not pad seconds at or above ten', () => {
expect(formatTime(125)).toBe('2:05');
});
it('handles durations of an hour or more as raw minutes, not hours', () => {
expect(formatTime(3661)).toBe('61:01');
});
});
+15 -21
View File
@@ -42,18 +42,18 @@ export function areSameFranchise(idA: number, idB: number): boolean {
return !!(tagA && tagB && tagA === tagB);
}
/** Fisher-Yates shuffle — uniform, unlike `.sort(() => Math.random() - 0.5)`. */
function shuffle<T>(items: T[]): T[] {
const shuffled = [...items];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const a = shuffled[i];
const b = shuffled[j];
if (a === undefined || b === undefined) continue; // unreachable: i, j are always valid indices
shuffled[i] = b;
shuffled[j] = a;
}
return shuffled;
/**
* Uniform shuffle: sort by an independent random key per item. Unlike
* `.sort(() => Math.random() - 0.5)` the comparator is consistent, so the
* result is a uniform permutation; unlike an in-place Fisher-Yates it needs no
* index reads, which under `noUncheckedIndexedAccess` each demand a guard for
* an index that cannot be out of range.
*/
function shuffle<T>(items: readonly T[]): T[] {
return items
.map((value) => ({ value, key: Math.random() }))
.sort((x, y) => x.key - y.key)
.map(({ value }) => value);
}
/**
@@ -66,20 +66,14 @@ function shuffle<T>(items: T[]): T[] {
* franchise (e.g. the list is entirely one series).
*/
export function pickDistinctPair<T extends { id: number; title: string }>(
movies: T[],
movies: readonly T[],
): [T, T] | null {
if (movies.length < 2) return null;
const shuffled = shuffle(movies);
for (let i = 0; i < shuffled.length; i++) {
const a = shuffled[i];
if (!a) continue; // unreachable: i is always a valid index
for (let j = i + 1; j < shuffled.length; j++) {
const b = shuffled[j];
if (!b) continue; // unreachable: j is always a valid index
for (const [i, a] of shuffled.entries()) {
for (const b of shuffled.slice(i + 1)) {
if (!areSameFranchise(a.id, b.id)) {
return [a, b];
}
+104
View File
@@ -0,0 +1,104 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
GENRES,
DECADES,
PRESET_PAIRS,
getRandomPair,
getFilteredPair,
} from './movie-pairs';
describe('PRESET_PAIRS', () => {
it('is a non-empty list of movie pairs with a description', () => {
expect(PRESET_PAIRS.length).toBeGreaterThan(0);
for (const pair of PRESET_PAIRS) {
expect(pair.movieA.id).toEqual(expect.any(Number));
expect(pair.movieB.id).toEqual(expect.any(Number));
expect(pair.description).toEqual(expect.any(String));
}
});
});
describe('GENRES / DECADES', () => {
it('exposes the tag vocabularies used to filter pairs', () => {
expect(GENRES).toContain('Action');
expect(DECADES).toContain('1990s');
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('getRandomPair', () => {
it('returns one of the preset pairs', () => {
for (let i = 0; i < 20; i++) {
const pair = getRandomPair();
expect(PRESET_PAIRS).toContainEqual(pair);
}
});
it('throws rather than return an out-of-range pair, if Math.random() ever returned 1', () => {
// Math.random() is spec-guaranteed to be < 1, so floor(random * length) is
// always a valid index in practice - this pins the defensive guard
// required by noUncheckedIndexedAccess in case that ever stopped holding.
vi.spyOn(Math, 'random').mockReturnValue(1);
expect(() => getRandomPair()).toThrow('PRESET_PAIRS is empty');
});
});
describe('getFilteredPair', () => {
it('returns any preset pair when neither filter is given', () => {
const pair = getFilteredPair(null, null);
expect(PRESET_PAIRS).toContainEqual(pair);
});
it('filters by genre alone', () => {
for (let i = 0; i < 20; i++) {
const pair = getFilteredPair('Fantasy', null);
expect(
(pair as { genres?: string[] }).genres?.includes('Fantasy'),
).toBe(true);
}
});
it('filters by decade alone', () => {
for (let i = 0; i < 20; i++) {
const pair = getFilteredPair(null, '1970s');
expect((pair as { decade?: string }).decade).toBe('1970s');
}
});
it('filters by genre and decade together when a pair matches both', () => {
for (let i = 0; i < 20; i++) {
const pair = getFilteredPair('Crime', '1970s') as {
genres?: string[];
decade?: string;
};
expect(pair.genres?.includes('Crime')).toBe(true);
expect(pair.decade).toBe('1970s');
}
});
it('falls back to a random pair when no preset matches both filters', () => {
// No 1970s pair is tagged Animation - this combination cannot match.
const pair = getFilteredPair('Animation', '1970s');
expect(PRESET_PAIRS).toContainEqual(pair);
});
it('falls back to a random pair for a genre that does not exist at all', () => {
const pair = getFilteredPair('Nonexistent Genre', null);
expect(PRESET_PAIRS).toContainEqual(pair);
});
it('falls back to a random pair for a decade that does not exist at all', () => {
const pair = getFilteredPair(null, '1930s');
expect(PRESET_PAIRS).toContainEqual(pair);
});
it('throws rather than return an out-of-range pair from a non-empty filtered list', () => {
// Same defensive guard as getRandomPair, on the branch where filtering
// left a non-empty list but the random index still landed out of range.
vi.spyOn(Math, 'random').mockReturnValue(1);
expect(() => getFilteredPair(null, null)).toThrow('no filtered pairs available');
});
});
+77
View File
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
/**
* Logging out used to disconnect only the notifications socket, leaving chat,
* versus and game-night sockets open with a token that had just been removed,
* and leaving the friends/game stores holding the previous user's data. This
* pins that every store is torn down (or reset) from one place, so a new
* store cannot be added here without also being wired in.
*
* Each store module is mocked directly - session-teardown.ts has no logic of
* its own beyond calling these, and each store's own behaviour is covered by
* its own test file.
*/
const notificationDisconnect = vi.fn();
const chatDisconnect = vi.fn();
const chatClearChat = vi.fn();
const versusDisconnect = vi.fn();
const gameNightDisconnect = vi.fn();
const gameResetGame = vi.fn();
const friendsReset = vi.fn();
vi.mock('@/stores/notification-store', () => ({
useNotificationStore: { getState: () => ({ disconnect: notificationDisconnect }) },
}));
vi.mock('@/stores/chat-store', () => ({
useChatStore: {
getState: () => ({ disconnect: chatDisconnect, clearChat: chatClearChat }),
},
}));
vi.mock('@/stores/versus-store', () => ({
useVersusStore: { getState: () => ({ disconnect: versusDisconnect }) },
}));
vi.mock('@/stores/game-night-store', () => ({
useGameNightStore: { getState: () => ({ disconnect: gameNightDisconnect }) },
}));
vi.mock('@/stores/game-store', () => ({
useGameStore: { getState: () => ({ resetGame: gameResetGame }) },
}));
vi.mock('@/stores/friends-store', () => ({
useFriendsStore: { getState: () => ({ reset: friendsReset }) },
}));
const { tearDownSession } = await import('./session-teardown');
beforeEach(() => {
vi.clearAllMocks();
});
describe('tearDownSession', () => {
it('disconnects every socket-backed store exactly once', () => {
tearDownSession();
expect(notificationDisconnect).toHaveBeenCalledTimes(1);
expect(chatDisconnect).toHaveBeenCalledTimes(1);
expect(versusDisconnect).toHaveBeenCalledTimes(1);
expect(gameNightDisconnect).toHaveBeenCalledTimes(1);
});
it('clears the chat, resets the game, and resets the friends list', () => {
tearDownSession();
expect(chatClearChat).toHaveBeenCalledTimes(1);
expect(gameResetGame).toHaveBeenCalledTimes(1);
expect(friendsReset).toHaveBeenCalledTimes(1);
});
it('disconnects the chat socket before clearing the chat it fed', () => {
const order: string[] = [];
chatDisconnect.mockImplementation(() => order.push('chat-disconnect'));
chatClearChat.mockImplementation(() => order.push('chat-clear'));
tearDownSession();
expect(order).toEqual(['chat-disconnect', 'chat-clear']);
});
});
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const isSoundEnabled = vi.fn();
const setSoundEnabled = vi.fn();
vi.mock('@/lib/storage', () => ({
storage: {
isSoundEnabled: () => isSoundEnabled(),
setSoundEnabled: (v: boolean) => setSoundEnabled(v),
},
}));
const sounds = await import('./sounds');
beforeEach(() => {
vi.clearAllMocks();
isSoundEnabled.mockReturnValue(true);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('isSoundEnabled', () => {
it('reflects the stored preference', () => {
isSoundEnabled.mockReturnValue(false);
expect(sounds.isSoundEnabled()).toBe(false);
isSoundEnabled.mockReturnValue(true);
expect(sounds.isSoundEnabled()).toBe(true);
});
it('defaults to enabled when storage throws', () => {
isSoundEnabled.mockImplementation(() => {
throw new Error('storage unavailable');
});
expect(sounds.isSoundEnabled()).toBe(true);
});
});
describe('toggleSound', () => {
it('flips the preference and persists it', () => {
isSoundEnabled.mockReturnValue(true);
const next = sounds.toggleSound();
expect(next).toBe(false);
expect(setSoundEnabled).toHaveBeenCalledWith(false);
});
it('flips back on from off', () => {
isSoundEnabled.mockReturnValue(false);
const next = sounds.toggleSound();
expect(next).toBe(true);
expect(setSoundEnabled).toHaveBeenCalledWith(true);
});
});
describe('playSound', () => {
it('does nothing when sound is disabled', () => {
isSoundEnabled.mockReturnValue(false);
const play = vi.spyOn(HTMLMediaElement.prototype, 'play');
sounds.playSound('valid');
expect(play).not.toHaveBeenCalled();
});
it('plays the requested sound at half volume when enabled', () => {
isSoundEnabled.mockReturnValue(true);
const play = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockImplementation(() => Promise.resolve());
sounds.playSound('completion');
expect(play).toHaveBeenCalledTimes(1);
});
it('swallows a rejected play() (autoplay blocked or file missing)', async () => {
isSoundEnabled.mockReturnValue(true);
vi.spyOn(HTMLMediaElement.prototype, 'play').mockImplementation(() =>
Promise.reject(new Error('NotAllowedError')),
);
expect(() => sounds.playSound('invalid')).not.toThrow();
// Let the rejection's .catch(() => {}) run without an unhandled rejection.
await new Promise((r) => setTimeout(r, 0));
});
it('swallows a failure constructing the Audio element', () => {
isSoundEnabled.mockReturnValue(true);
vi.stubGlobal(
'Audio',
function AudioStub() {
throw new Error('Audio API unavailable');
},
);
expect(() => sounds.playSound('valid')).not.toThrow();
});
});
+95
View File
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
/**
* A thin localStorage wrapper. `setSoundEnabled` is the only method that
* swallows a write failure (e.g. Safari private mode, or a full quota) - every
* other setter is expected to propagate. Both paths are exercised below.
*/
import { storage } from './storage';
beforeEach(() => {
localStorage.clear();
});
describe('auth token', () => {
it('reads null when nothing has been stored', () => {
expect(storage.getAuthToken()).toBeNull();
});
it('stores the token under its fixed key and reads it back', () => {
storage.setAuthToken('tok-123');
expect(localStorage.getItem('auth_token')).toBe('tok-123');
expect(storage.getAuthToken()).toBe('tok-123');
});
it('removes the token', () => {
storage.setAuthToken('tok-123');
storage.removeAuthToken();
expect(storage.getAuthToken()).toBeNull();
});
});
describe('sound preference', () => {
it('defaults to enabled when nothing is stored', () => {
expect(storage.isSoundEnabled()).toBe(true);
});
it('is disabled only when explicitly recorded as "false"', () => {
storage.setSoundEnabled(false);
expect(localStorage.getItem('movieloop-sound-enabled')).toBe('false');
expect(storage.isSoundEnabled()).toBe(false);
});
it('is enabled for any stored value other than "false"', () => {
storage.setSoundEnabled(true);
expect(localStorage.getItem('movieloop-sound-enabled')).toBe('true');
expect(storage.isSoundEnabled()).toBe(true);
});
it('swallows a write failure instead of throwing', () => {
const spy = vi
.spyOn(Storage.prototype, 'setItem')
.mockImplementation(() => {
throw new DOMException('QuotaExceededError');
});
expect(() => storage.setSoundEnabled(true)).not.toThrow();
spy.mockRestore();
});
});
describe('daily completion', () => {
it('is false when nothing has been recorded for the plain date key', () => {
expect(storage.isDailyCompleted('2024-01-01')).toBe(false);
});
it('records completion under the plain date key when no difficulty is given', () => {
storage.setDailyCompleted('2024-01-01');
expect(localStorage.getItem('daily-completed-2024-01-01')).toBe('true');
expect(storage.isDailyCompleted('2024-01-01')).toBe(true);
});
it('keys completion by difficulty separately from the plain date', () => {
storage.setDailyCompleted('2024-01-01', 'hard');
expect(localStorage.getItem('daily-completed-2024-01-01-hard')).toBe(
'true',
);
expect(storage.isDailyCompleted('2024-01-01', 'hard')).toBe(true);
// The plain (no-difficulty) key is untouched by a difficulty-scoped write.
expect(storage.isDailyCompleted('2024-01-01')).toBe(false);
});
});
describe('theme', () => {
it('reads null when unset', () => {
expect(storage.getTheme()).toBeNull();
});
it('stores and reads back the theme', () => {
storage.setTheme('dark');
expect(localStorage.getItem('theme')).toBe('dark');
expect(storage.getTheme()).toBe('dark');
});
});
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { posterUrl, profileUrl, releaseYear } from './tmdb';
describe('posterUrl', () => {
it('builds a poster URL at the default size', () => {
expect(posterUrl('/abc.jpg')).toBe(
'https://image.tmdb.org/t/p/w342/abc.jpg',
);
});
it('honours an explicit size', () => {
expect(posterUrl('/abc.jpg', 'w92')).toBe(
'https://image.tmdb.org/t/p/w92/abc.jpg',
);
});
it('is null when there is no path', () => {
expect(posterUrl(null)).toBeNull();
});
});
describe('profileUrl', () => {
it('builds a profile URL at the default size', () => {
expect(profileUrl('/xyz.jpg')).toBe(
'https://image.tmdb.org/t/p/w185/xyz.jpg',
);
});
it('honours an explicit size', () => {
expect(profileUrl('/xyz.jpg', 'original')).toBe(
'https://image.tmdb.org/t/p/original/xyz.jpg',
);
});
it('is null when there is no path', () => {
expect(profileUrl(null)).toBeNull();
});
});
describe('releaseYear', () => {
it('extracts the year from a full date', () => {
expect(releaseYear('2001-02-03')).toBe('2001');
});
it('is empty for null or undefined', () => {
expect(releaseYear(null)).toBe('');
expect(releaseYear(undefined)).toBe('');
});
it('is empty for an empty string', () => {
expect(releaseYear('')).toBe('');
});
});
+43
View File
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
/**
* Resolves a socket namespace against the configured API base. A same-origin
* (relative, or empty) base connects to the namespace as-is; an absolute base
* has its trailing `/api/v1` stripped so the namespace joins the bare origin.
*/
const configMock = { apiUrl: '/api/v1' };
vi.mock('@/lib/config', () => ({ config: configMock }));
const { getWsUrl } = await import('./ws');
beforeEach(() => {
configMock.apiUrl = '/api/v1';
});
describe('getWsUrl', () => {
it('returns the namespace unchanged when the api base is empty', () => {
configMock.apiUrl = '';
expect(getWsUrl('/chat')).toBe('/chat');
});
it('returns the namespace unchanged for a same-origin relative api base', () => {
configMock.apiUrl = '/api/v1';
expect(getWsUrl('/chat')).toBe('/chat');
});
it('strips a trailing /api/v1 and prefixes the namespace with the resulting origin', () => {
configMock.apiUrl = 'https://api.example.com/api/v1';
expect(getWsUrl('/chat')).toBe('https://api.example.com/chat');
});
it('tolerates a trailing slash after /api/v1', () => {
configMock.apiUrl = 'https://api.example.com/api/v1/';
expect(getWsUrl('/versus')).toBe('https://api.example.com/versus');
});
it('uses the absolute origin unmodified when it does not end in /api/v1', () => {
configMock.apiUrl = 'https://api.example.com';
expect(getWsUrl('/game-night')).toBe('https://api.example.com/game-night');
});
});
+174
View File
@@ -0,0 +1,174 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Achievement } from '@/api/achievements';
/**
* Achievements.tsx: the signed-out redirect, unlocked/locked rendering
* grouped by category, and the "0 / 0 unlocked" count - which reads the same
* whether the player genuinely has none yet or the fetch failed, since the
* component has no separate error banner. Both paths are still pinned here
* because they are distinct code paths (the catch below sets `[]` too).
*/
interface ReqConfig {
signal?: AbortSignal;
}
const achievementsSpy = vi.fn();
function route(url: string): (config?: ReqConfig) => Promise<unknown> {
if (url === '/achievements/me') {
// Signal-aware, mirroring real axios: an aborted request rejects with an
// AbortError regardless of what the underlying call would do.
return (config) =>
new Promise((resolve, reject) => {
config?.signal?.addEventListener('abort', () => {
reject(new DOMException('aborted', 'AbortError'));
});
achievementsSpy().then(resolve, reject);
});
}
throw new Error(`test reached an unrouted request: get ${url}`);
}
vi.mock('@/api/client', () => ({
default: {
get: async (url: string, config?: ReqConfig) => ({ data: await route(url)(config) }),
post: async () => {
throw new Error('test reached an unrouted POST request');
},
put: async () => {
throw new Error('test reached an unrouted PUT request');
},
delete: async () => {
throw new Error('test reached an unrouted DELETE request');
},
},
}));
const { default: Achievements } = await import('./Achievements');
const { useAuthStore } = await import('@/stores/auth-store');
const USER = { id: 'u1', email: 'a@a.com', username: 'alice', isAdmin: false };
function achievement(overrides: Partial<Achievement>): Achievement {
return {
id: 'a1',
key: 'first_loop',
name: 'First Loop',
description: 'Complete your first loop',
icon: 'trophy',
category: 'general',
threshold: 1,
progress: 0,
unlocked: false,
unlockedAt: null,
...overrides,
};
}
function renderPage() {
return render(
<MemoryRouter initialEntries={['/achievements']}>
<Routes>
<Route path="/achievements" element={<Achievements />} />
<Route path="/login" element={<div>Login Page</div>} />
<Route path="/" element={<div>Home Page</div>} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
useAuthStore.setState({ user: USER, isLoading: false, error: null });
achievementsSpy.mockResolvedValue([]);
});
describe('access gate', () => {
it('redirects to /login when signed out', async () => {
useAuthStore.setState({ user: null, isLoading: false });
renderPage();
expect(await screen.findByText('Login Page')).toBeInTheDocument();
});
it('renders nothing while auth is still resolving', () => {
useAuthStore.setState({ user: null, isLoading: true });
renderPage();
expect(screen.queryByRole('heading', { name: 'Achievements' })).toBeNull();
expect(screen.queryByText('Login Page')).toBeNull();
});
});
describe('unlocked count', () => {
it('shows "0 / 0 unlocked" when the player genuinely has none yet', async () => {
achievementsSpy.mockResolvedValue([]);
renderPage();
expect(await screen.findByText('0 / 0 unlocked')).toBeInTheDocument();
});
it('shows "0 / 0 unlocked" when the fetch fails, via the same fallback as empty', async () => {
achievementsSpy.mockRejectedValue(new Error('500'));
renderPage();
expect(await screen.findByText('0 / 0 unlocked')).toBeInTheDocument();
});
it('counts unlocked against the total', async () => {
achievementsSpy.mockResolvedValue([
achievement({ id: 'a1', unlocked: true }),
achievement({ id: 'a2', unlocked: false }),
achievement({ id: 'a3', unlocked: true }),
]);
renderPage();
expect(await screen.findByText('2 / 3 unlocked')).toBeInTheDocument();
});
it('does not crash when navigating away while the fetch is still pending', async () => {
achievementsSpy.mockImplementation(() => new Promise(() => undefined));
renderPage();
await screen.findByRole('heading', { name: 'Achievements' });
await userEvent.click(screen.getByRole('link', { name: 'Home' }));
expect(await screen.findByText('Home Page')).toBeInTheDocument();
});
});
describe('rendering achievements', () => {
it('groups by category, using the friendly label when known and the raw key otherwise', async () => {
achievementsSpy.mockResolvedValue([
achievement({ id: 'a1', category: 'speed', name: 'Quick Draw' }),
achievement({ id: 'a2', category: 'mystery-category', name: 'Mystery Badge' }),
]);
renderPage();
expect(await screen.findByText('Speed')).toBeInTheDocument();
expect(screen.getByText('mystery-category')).toBeInTheDocument();
});
it('shows a progress bar only for a locked achievement with a threshold above 1', async () => {
achievementsSpy.mockResolvedValue([
achievement({ id: 'a1', name: 'Progressing', unlocked: false, threshold: 10, progress: 4 }),
achievement({ id: 'a2', name: 'Binary', unlocked: false, threshold: 1, progress: 0 }),
]);
renderPage();
expect(await screen.findByText('4 / 10')).toBeInTheDocument();
expect(screen.queryByText('0 / 1')).toBeNull();
});
it('shows the unlock date for an unlocked achievement that has one', async () => {
achievementsSpy.mockResolvedValue([
achievement({ id: 'a1', name: 'Done', unlocked: true, unlockedAt: '2024-03-01T00:00:00.000Z' }),
]);
renderPage();
await screen.findByText('Done');
expect(screen.getByText(/^Unlocked/)).toBeInTheDocument();
});
it('falls back to the default icon for an unrecognised icon key', async () => {
achievementsSpy.mockResolvedValue([
achievement({ id: 'a1', name: 'Odd Icon', icon: 'not-a-real-icon' }),
]);
renderPage();
expect(await screen.findByText('Odd Icon')).toBeInTheDocument();
});
});
+276
View File
@@ -0,0 +1,276 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SearchResult } from '@/api/friends';
/**
* AddFriend page: debounced username search, sending a request, and the
* fixed cleanup effect that clears search results on unmount. Requests are
* intercepted at `@/api/client` and routed by URL - the seam `game-store.test.ts`
* and `Versus.test.tsx` use.
*/
const searchUsers = vi.fn();
const sendFriendRequest = vi.fn();
const toastSuccess = vi.fn();
const toastError = vi.fn();
type Handler = (arg: unknown) => Promise<unknown>;
function route(method: string, url: string): Handler {
if (method === 'get' && url === '/friends/search') {
return (config) =>
searchUsers((config as { params?: { q?: string } })?.params?.q ?? '');
}
if (method === 'post' && url === '/friends/request') {
return (body) => sendFriendRequest(body);
}
if (method === 'get' && url === '/friends/requests/sent') {
return () => Promise.resolve([]);
}
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'),
},
};
});
vi.mock('sonner', () => ({
toast: {
success: (...args: unknown[]) => toastSuccess(...args),
error: (...args: unknown[]) => toastError(...args),
},
}));
const { useAuthStore } = await import('@/stores/auth-store');
const { useFriendsStore } = await import('@/stores/friends-store');
const { default: AddFriend } = await import('./AddFriend');
// --- Fixtures ---------------------------------------------------------------
const USER = { id: 'user-1', email: 'me@example.com', username: 'Me', isAdmin: false };
function searchResult(overrides: Partial<SearchResult> = {}): SearchResult {
return {
id: 'result-1',
username: 'searcheduser',
displayName: 'Searched User',
avatarUrl: null,
friendshipStatus: null,
friendshipId: null,
friendshipDirection: null,
...overrides,
};
}
function renderAddFriend() {
return render(
<MemoryRouter initialEntries={['/friends/add']}>
<Routes>
<Route path="/friends/add" element={<AddFriend />} />
<Route path="/login" element={<div>login route</div>} />
<Route path="/friends" element={<div>friends route</div>} />
</Routes>
</MemoryRouter>,
);
}
async function typeAndWait(user: ReturnType<typeof userEvent.setup>, text: string) {
await user.type(screen.getByLabelText('Search by username'), text);
await vi.advanceTimersByTimeAsync(300);
}
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
useAuthStore.setState({ user: USER, isLoading: false, error: null });
useFriendsStore.getState().reset();
searchUsers.mockResolvedValue([]);
sendFriendRequest.mockResolvedValue({});
});
afterEach(() => {
vi.useRealTimers();
});
describe('authentication gate', () => {
it('redirects to login when nobody is signed in', async () => {
useAuthStore.setState({ user: null, isLoading: false });
renderAddFriend();
expect(await screen.findByText('login route')).toBeInTheDocument();
});
it('does not redirect while auth is still resolving', () => {
useAuthStore.setState({ user: null, isLoading: true });
renderAddFriend();
expect(screen.queryByText('login route')).toBeNull();
});
});
describe('searching', () => {
it('does not search for an empty query', () => {
renderAddFriend();
expect(searchUsers).not.toHaveBeenCalled();
});
it('debounces the search and shows results', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
searchUsers.mockResolvedValue([searchResult()]);
renderAddFriend();
await user.type(screen.getByLabelText('Search by username'), 'sea');
// Not yet - the search fires only after the debounce settles.
expect(searchUsers).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(300);
expect(searchUsers).toHaveBeenCalledWith('sea');
expect(await screen.findByText('Searched User')).toBeInTheDocument();
expect(screen.getByText('@searcheduser')).toBeInTheDocument();
});
it('shows "No users found" for a query with no matches', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
searchUsers.mockResolvedValue([]);
renderAddFriend();
await typeAndWait(user, 'nobody');
expect(await screen.findByText('No users found')).toBeInTheDocument();
});
it('falls back to the username when a result has no display name', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
searchUsers.mockResolvedValue([searchResult({ displayName: null, username: 'plain' })]);
renderAddFriend();
await typeAndWait(user, 'plain');
expect(await screen.findByText('plain')).toBeInTheDocument();
});
it('clears search results on unmount', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
searchUsers.mockResolvedValue([searchResult()]);
const { unmount } = renderAddFriend();
await typeAndWait(user, 'sea');
await screen.findByText('Searched User');
unmount();
expect(useFriendsStore.getState().searchResults).toEqual([]);
});
});
describe('result status variants', () => {
it('shows a disabled "Friends" control for an accepted friendship', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
searchUsers.mockResolvedValue([searchResult({ friendshipStatus: 'accepted' })]);
renderAddFriend();
await typeAndWait(user, 'sea');
expect(await screen.findByRole('button', { name: 'Friends' })).toBeDisabled();
});
it('shows "Sent" for a pending request this user sent', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
searchUsers.mockResolvedValue([
searchResult({ friendshipStatus: 'pending', friendshipDirection: 'sent' }),
]);
renderAddFriend();
await typeAndWait(user, 'sea');
expect(await screen.findByRole('button', { name: 'Sent' })).toBeDisabled();
});
it('shows "Pending" for a pending request received from them', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
searchUsers.mockResolvedValue([
searchResult({ friendshipStatus: 'pending', friendshipDirection: 'received' }),
]);
renderAddFriend();
await typeAndWait(user, 'sea');
expect(await screen.findByRole('button', { name: 'Pending' })).toBeDisabled();
});
it('offers no control at all for a blocked relationship', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
searchUsers.mockResolvedValue([searchResult({ friendshipStatus: 'blocked' })]);
renderAddFriend();
await typeAndWait(user, 'sea');
const name = await screen.findByText('Searched User');
const card = name.closest<HTMLElement>('div[data-slot="card"]');
if (!card) throw new Error('card not found');
expect(within(card).queryByRole('button')).toBeNull();
});
});
describe('sending a request', () => {
it('sends a request, re-searches and shows a success toast', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
searchUsers.mockResolvedValue([searchResult()]);
renderAddFriend();
await typeAndWait(user, 'sea');
await screen.findByText('Searched User');
searchUsers.mockClear();
searchUsers.mockResolvedValue([
searchResult({ friendshipStatus: 'pending', friendshipDirection: 'sent' }),
]);
await user.click(screen.getByRole('button', { name: 'Add Friend' }));
expect(sendFriendRequest).toHaveBeenCalledWith({ addresseeId: 'result-1' });
await waitFor(() => expect(toastSuccess).toHaveBeenCalledWith('Friend request sent!'));
await waitFor(() => expect(searchUsers).toHaveBeenCalledWith('sea'));
expect(await screen.findByRole('button', { name: 'Sent' })).toBeInTheDocument();
});
it('disables the button and shows a spinner while the request is in flight', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
searchUsers.mockResolvedValue([searchResult()]);
renderAddFriend();
await typeAndWait(user, 'sea');
await screen.findByText('Searched User');
let resolveSend: (v: unknown) => void = () => undefined;
sendFriendRequest.mockImplementation(
() =>
new Promise((resolve) => {
resolveSend = resolve;
}),
);
const addButton = screen.getByRole('button', { name: 'Add Friend' });
await user.click(addButton);
expect(addButton).toBeDisabled();
resolveSend({});
await waitFor(() => expect(toastSuccess).toHaveBeenCalled());
});
it('shows an error toast and re-enables the control when the request fails', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
searchUsers.mockResolvedValue([searchResult()]);
renderAddFriend();
await typeAndWait(user, 'sea');
await screen.findByText('Searched User');
sendFriendRequest.mockRejectedValue({
response: { data: { message: 'Already friends' } },
});
await user.click(screen.getByRole('button', { name: 'Add Friend' }));
await waitFor(() => expect(toastError).toHaveBeenCalledWith('Already friends'));
expect(screen.getByRole('button', { name: 'Add Friend' })).toBeEnabled();
});
});
+455
View File
@@ -0,0 +1,455 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
/**
* Admin.tsx: the admin gate/redirect, the stats section, the four paged
* maintenance jobs sharing `BatchJobSection`, the challenge generator and the
* paginated users table.
*
* Requests are intercepted one level below `@/api/admin`, at `@/api/client`,
* the same seam `game-store.test.ts` and `use-chain-validation.test.ts` use -
* mocking `@/api/*` modules directly has previously resolved to the real
* module under a concurrent dynamic import and reached the dev backend.
*/
interface ReqConfig {
signal?: AbortSignal;
params?: Record<string, unknown>;
}
const statsSpy = vi.fn();
const usersSpy = vi.fn();
const recalcSpy = vi.fn();
const backfillOwnersSpy = vi.fn();
const achievementsSpy = vi.fn();
const backfillUsernamesSpy = vi.fn();
const generateSpy = vi.fn();
const generateAllSpy = vi.fn();
function route(
method: string,
url: string,
): (body: unknown, config?: ReqConfig) => Promise<unknown> {
if (method === 'get' && url === '/admin/stats') {
// Mirrors real axios: an aborted signal rejects the in-flight request
// with an AbortError, whatever the underlying call would otherwise do.
return (_b, config) =>
new Promise((resolve, reject) => {
config?.signal?.addEventListener('abort', () => {
reject(new DOMException('aborted', 'AbortError'));
});
statsSpy().then(resolve, reject);
});
}
if (method === 'get' && url === '/admin/users') {
return (_b, config) => usersSpy(config?.params);
}
if (method === 'post' && url === '/admin/recalculate-scores') {
return (_b, config) => recalcSpy(config?.params?.cursor);
}
if (method === 'post' && url === '/admin/backfill-user-ids') {
return (_b, config) => backfillOwnersSpy(config?.params?.cursor);
}
if (method === 'post' && url === '/admin/retroactive-achievements') {
return (_b, config) => achievementsSpy(config?.params?.cursor);
}
if (method === 'post' && url === '/admin/backfill-usernames') {
return () => backfillUsernamesSpy();
}
if (method === 'post' && url === '/admin/daily-challenges/generate') {
return (body) => generateSpy(body);
}
if (method === 'post' && url === '/admin/daily-challenges/generate-all') {
return (body) => generateAllSpy(body);
}
throw new Error(`test reached an unrouted request: ${method} ${url}`);
}
vi.mock('@/api/client', () => {
const call =
(method: string) =>
async (url: string, a?: unknown, b?: unknown): Promise<{ data: unknown }> => {
const config = (method === 'get' ? a : b) as ReqConfig | undefined;
const body = method === 'get' ? undefined : a;
return { data: await route(method, url)(body, config) };
};
return {
default: {
get: call('get'),
post: call('post'),
put: call('put'),
delete: call('delete'),
},
};
});
const { default: Admin } = await import('./Admin');
const { useAuthStore } = await import('@/stores/auth-store');
const ADMIN_USER = { id: 'u1', email: 'a@a.com', username: 'admin', isAdmin: true };
const REGULAR_USER = { id: 'u2', email: 'b@b.com', username: 'bob', isAdmin: false };
const STATS = {
totalUsers: 10,
totalGames: 20,
completedGames: 15,
completionRate: 75,
totalChallenges: 3,
totalMatches: 4,
};
function adminUser(id: string, username: string) {
return {
id,
username,
email: `${username}@x.com`,
createdAt: '2024-01-01T00:00:00.000Z',
_count: { gameSessions: 3, leaderboardEntries: 2 },
};
}
function renderAdmin() {
return render(
<MemoryRouter initialEntries={['/admin']}>
<Routes>
<Route path="/admin" element={<Admin />} />
<Route path="/login" element={<div>Login Page</div>} />
<Route path="/" element={<div>Home Page</div>} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
useAuthStore.setState({ user: null, isLoading: false, error: null });
statsSpy.mockResolvedValue(STATS);
usersSpy.mockResolvedValue({ data: [], total: 0, page: 1, limit: 20, totalPages: 1 });
recalcSpy.mockResolvedValue({ updated: 5, skipped: 1, total: 6, hasMore: false, nextCursor: null });
backfillOwnersSpy.mockResolvedValue({ backfilled: 2, ambiguous: 1, unmatched: 0, total: 3, hasMore: false, nextCursor: null });
achievementsSpy.mockResolvedValue({ totalAwarded: 4, usersAffected: 2, hasMore: false, nextCursor: null });
backfillUsernamesSpy.mockResolvedValue({ backfilled: 1 });
generateSpy.mockResolvedValue({ movieATitle: 'A', movieBTitle: 'B' });
generateAllSpy.mockResolvedValue([]);
});
// --- Gate / redirect ---------------------------------------------------------
describe('access gate', () => {
it('redirects to /login when signed out', async () => {
renderAdmin();
expect(await screen.findByText('Login Page')).toBeInTheDocument();
});
it('redirects home when signed in but not an admin', async () => {
useAuthStore.setState({ user: REGULAR_USER, isLoading: false });
renderAdmin();
expect(await screen.findByText('Home Page')).toBeInTheDocument();
});
it('renders nothing while auth is still loading, even with no user yet', () => {
useAuthStore.setState({ user: null, isLoading: true });
renderAdmin();
expect(screen.queryByRole('heading', { name: 'Admin Panel' })).toBeNull();
expect(screen.queryByText('Login Page')).toBeNull();
});
it('renders the panel for an admin', async () => {
useAuthStore.setState({ user: ADMIN_USER, isLoading: false });
renderAdmin();
expect(
await screen.findByRole('heading', { name: 'Admin Panel' }),
).toBeInTheDocument();
});
});
// --- Stats section -----------------------------------------------------------
describe('platform stats', () => {
beforeEach(() => {
useAuthStore.setState({ user: ADMIN_USER, isLoading: false });
});
it('shows the loaded stats, including the derived completion rate', async () => {
renderAdmin();
expect(await screen.findByText('10')).toBeInTheDocument();
expect(screen.getByText('75%')).toBeInTheDocument();
expect(screen.getByText('Total Games')).toBeInTheDocument();
expect(screen.getByText('Versus Matches')).toBeInTheDocument();
});
it('shows a failure message when stats cannot be loaded', async () => {
statsSpy.mockRejectedValue(new Error('500'));
renderAdmin();
expect(await screen.findByText('Failed to load stats.')).toBeInTheDocument();
});
it('does not crash navigating away while the stats fetch is still pending', async () => {
// Never settles on its own: the only way it resolves is the effect's
// cleanup aborting it when the page is left, exercising the same
// isAbortError guard the request-cancellation tests below rely on.
statsSpy.mockImplementation(() => new Promise(() => undefined));
renderAdmin();
await screen.findByRole('heading', { name: 'Admin Panel' });
await userEvent.click(screen.getByRole('link', { name: 'Home' }));
expect(await screen.findByText('Home Page')).toBeInTheDocument();
});
});
// --- Batch jobs ---------------------------------------------------------------
describe('maintenance jobs', () => {
beforeEach(() => {
useAuthStore.setState({ user: ADMIN_USER, isLoading: false });
});
it('recalculates scores, then continues from the returned cursor', async () => {
recalcSpy.mockResolvedValueOnce({ updated: 5, skipped: 1, total: 6, hasMore: true, nextCursor: 'cursor-1' });
renderAdmin();
await userEvent.click(await screen.findByRole('button', { name: 'Recalculate Scores' }));
expect(
await screen.findByText(/Updated 5 games, skipped 1 \(6 in this page\)\./),
).toBeInTheDocument();
expect(
screen.getByText(/More remains - press Continue/),
).toBeInTheDocument();
const continueButton = screen.getByRole('button', { name: 'Continue' });
await userEvent.click(continueButton);
expect(recalcSpy).toHaveBeenNthCalledWith(2, 'cursor-1');
expect(
await screen.findByText('Updated 5 games, skipped 1 (6 in this page).'),
).toBeInTheDocument();
// hasMore is now false: no more cursor, so the button reverts to its
// original label instead of staying on "Continue".
expect(
screen.getByRole('button', { name: 'Recalculate Scores' }),
).toBeInTheDocument();
});
it('shows the failure message when a job fails, and disables the button while running', async () => {
let release!: () => void;
recalcSpy.mockImplementationOnce(
() => new Promise((_resolve, reject) => { release = () => reject(new Error('500')); }),
);
renderAdmin();
const button = await screen.findByRole('button', { name: 'Recalculate Scores' });
await userEvent.click(button);
expect(button).toBeDisabled();
release();
expect(await screen.findByText('Failed to recalculate scores.')).toBeInTheDocument();
expect(button).toBeEnabled();
});
it('keeps the original button label when more remains but no cursor was given', async () => {
// hasMore can be true with a null cursor (e.g. the last page is being
// reprocessed). Continuing then just means "run it again from scratch",
// not "Continue" from a specific point.
achievementsSpy.mockResolvedValueOnce({ totalAwarded: 1, usersAffected: 1, hasMore: true, nextCursor: null });
renderAdmin();
await userEvent.click(await screen.findByRole('button', { name: 'Award Retroactive Achievements' }));
expect(screen.getByText(/More remains - press Continue/)).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Award Retroactive Achievements' }),
).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull();
await userEvent.click(screen.getByRole('button', { name: 'Award Retroactive Achievements' }));
expect(achievementsSpy).toHaveBeenNthCalledWith(2, undefined);
});
it('backfills game owners', async () => {
renderAdmin();
await userEvent.click(await screen.findByRole('button', { name: 'Backfill Game Owners' }));
expect(
await screen.findByText('Re-attached 2 games; 1 ambiguous, 0 unmatched (3 in this page).'),
).toBeInTheDocument();
});
it('awards retroactive achievements', async () => {
renderAdmin();
await userEvent.click(await screen.findByRole('button', { name: 'Award Retroactive Achievements' }));
expect(
await screen.findByText('Awarded 4 achievements across 2 users.'),
).toBeInTheDocument();
});
it('backfills usernames, pluralising the count', async () => {
backfillUsernamesSpy.mockResolvedValueOnce({ backfilled: 1 });
renderAdmin();
const button = await screen.findByRole('button', { name: 'Backfill Usernames' });
await userEvent.click(button);
expect(await screen.findByText('Backfilled 1 user.')).toBeInTheDocument();
backfillUsernamesSpy.mockResolvedValueOnce({ backfilled: 3 });
await userEvent.click(button);
expect(await screen.findByText('Backfilled 3 users.')).toBeInTheDocument();
});
});
// --- Generate challenge -------------------------------------------------------
describe('generate daily challenge', () => {
beforeEach(() => {
useAuthStore.setState({ user: ADMIN_USER, isLoading: false });
});
it('disables both generate buttons until a date is chosen', async () => {
renderAdmin();
expect(await screen.findByRole('button', { name: 'Generate' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Generate All 3' })).toBeDisabled();
await userEvent.type(screen.getByLabelText('Date'), '2024-05-01');
expect(screen.getByRole('button', { name: 'Generate' })).toBeEnabled();
expect(screen.getByRole('button', { name: 'Generate All 3' })).toBeEnabled();
});
it('generates a single challenge at the chosen difficulty', async () => {
generateSpy.mockResolvedValueOnce({ movieATitle: 'Heat', movieBTitle: 'Speed' });
renderAdmin();
await userEvent.type(await screen.findByLabelText('Date'), '2024-05-01');
await userEvent.selectOptions(screen.getByLabelText('Difficulty'), 'hard');
await userEvent.click(screen.getByRole('button', { name: 'Generate' }));
expect(generateSpy).toHaveBeenCalledWith({ date: '2024-05-01', difficulty: 'hard' });
expect(
await screen.findByText('Generated hard: Heat ↔ Speed'),
).toBeInTheDocument();
});
it('reports a failure to generate a single challenge', async () => {
generateSpy.mockRejectedValueOnce(new Error('500'));
renderAdmin();
await userEvent.type(await screen.findByLabelText('Date'), '2024-05-01');
await userEvent.click(screen.getByRole('button', { name: 'Generate' }));
expect(await screen.findByText('Failed to generate challenge.')).toBeInTheDocument();
});
it('generates all three difficulties and lists a summary of each', async () => {
generateAllSpy.mockResolvedValueOnce([
{ id: '1', difficulty: 'easy', movieATitle: 'A1', movieBTitle: 'B1', par: 3 },
{ id: '2', difficulty: 'medium', movieATitle: 'A2', movieBTitle: 'B2', par: 4 },
]);
renderAdmin();
await userEvent.type(await screen.findByLabelText('Date'), '2024-05-01');
await userEvent.click(screen.getByRole('button', { name: 'Generate All 3' }));
expect(generateAllSpy).toHaveBeenCalledWith({ date: '2024-05-01' });
const alert = await screen.findByRole('alert');
expect(alert.textContent).toBe(
'Generated all 3:\neasy: A1 ↔ B1\nmedium: A2 ↔ B2',
);
});
it('reports a failure to generate all challenges', async () => {
generateAllSpy.mockRejectedValueOnce(new Error('500'));
renderAdmin();
await userEvent.type(await screen.findByLabelText('Date'), '2024-05-01');
await userEvent.click(screen.getByRole('button', { name: 'Generate All 3' }));
expect(await screen.findByText('Failed to generate challenges.')).toBeInTheDocument();
});
});
// --- Users table ---------------------------------------------------------------
describe('users table', () => {
beforeEach(() => {
useAuthStore.setState({ user: ADMIN_USER, isLoading: false });
});
it('shows "No users found." when the platform has none', async () => {
renderAdmin();
expect(await screen.findByText('No users found.')).toBeInTheDocument();
});
it('lists users and paginates between pages, without pagination controls on a single page', async () => {
usersSpy.mockImplementation((params: { page: number }) =>
Promise.resolve(
params.page === 1
? { data: [adminUser('1', 'alice')], total: 21, page: 1, limit: 20, totalPages: 2 }
: { data: [adminUser('2', 'bob')], total: 21, page: 2, limit: 20, totalPages: 2 },
),
);
renderAdmin();
expect(await screen.findByText('alice')).toBeInTheDocument();
expect(screen.getByText('Page 1 of 2')).toBeInTheDocument();
const prev = screen.getByRole('button', { name: 'Previous' });
const next = screen.getByRole('button', { name: 'Next' });
expect(prev).toBeDisabled();
expect(next).toBeEnabled();
await userEvent.click(next);
expect(await screen.findByText('bob')).toBeInTheDocument();
expect(screen.getByText('Page 2 of 2')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled();
await userEvent.click(screen.getByRole('button', { name: 'Previous' }));
expect(await screen.findByText('alice')).toBeInTheDocument();
});
it('does not show pagination controls for a single page of results', async () => {
usersSpy.mockResolvedValue({
data: [adminUser('1', 'solo')],
total: 1,
page: 1,
limit: 20,
totalPages: 1,
});
renderAdmin();
expect(await screen.findByText('solo')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Previous' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Next' })).toBeNull();
});
it('falls back to "No users found." when the request fails', async () => {
usersSpy.mockRejectedValue(new Error('500'));
renderAdmin();
expect(await screen.findByText('No users found.')).toBeInTheDocument();
});
it('ignores a users response that resolves after navigating away', async () => {
let resolveUsers!: (v: unknown) => void;
usersSpy.mockImplementationOnce(
() => new Promise((resolve) => { resolveUsers = resolve; }),
);
renderAdmin();
await screen.findByRole('heading', { name: 'Admin Panel' });
await userEvent.click(screen.getByRole('link', { name: 'Home' }));
expect(await screen.findByText('Home Page')).toBeInTheDocument();
resolveUsers({ data: [adminUser('1', 'late')], total: 1, page: 1, limit: 20, totalPages: 1 });
await new Promise((r) => setTimeout(r, 0));
expect(screen.getByText('Home Page')).toBeInTheDocument();
});
it('ignores a users failure that arrives after navigating away', async () => {
let rejectUsers!: (e: unknown) => void;
usersSpy.mockImplementationOnce(
() => new Promise((_resolve, reject) => { rejectUsers = reject; }),
);
renderAdmin();
await screen.findByRole('heading', { name: 'Admin Panel' });
await userEvent.click(screen.getByRole('link', { name: 'Home' }));
expect(await screen.findByText('Home Page')).toBeInTheDocument();
rejectUsers(new Error('500'));
await new Promise((r) => setTimeout(r, 0));
expect(screen.getByText('Home Page')).toBeInTheDocument();
});
});
+309
View File
@@ -0,0 +1,309 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Link, MemoryRouter, Route, Routes } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AsyncLeaderboardResponse } from '@/api/versus';
/**
* The async challenge's leaderboard page: match summary, creator badge, the
* viewer's own "YOU" highlight, and the load/error states. Requests are
* intercepted at `@/api/client`, routed by URL - the seam `game-store.test.ts`
* uses, since a second concurrent dynamic import of an aliased module has
* previously resolved to the real module and reached the dev backend.
*/
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 = /^\/versus\/([^/]+)\/leaderboard$/.exec(url))) {
return () => getAsyncLeaderboard(m![1]);
}
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 { default: AsyncMatchLeaderboard } = await import('./AsyncMatchLeaderboard');
// --- Fixtures ---------------------------------------------------------------
const USER = { id: 'user-1', email: 'me@example.com', username: 'Me', isAdmin: false };
function response(overrides: Partial<AsyncLeaderboardResponse> = {}): AsyncLeaderboardResponse {
return {
match: {
id: 'match-1',
difficulty: 'medium',
lobbyName: null,
expiresAt: null,
status: 'open',
movieATitle: 'Movie A',
movieAReleaseDate: '2001-01-01',
movieBTitle: 'Movie B',
movieBReleaseDate: '2002-02-02',
},
creator: { id: 'creator-1', username: 'Creator' },
leaderboard: [
{
playerId: 'creator-1',
username: 'Creator',
score: { totalScore: 1000 },
chainLength: 5,
isCreator: true,
rank: 1,
},
{
playerId: 'user-1',
username: 'Me',
score: { totalScore: 2400 },
chainLength: 7,
isCreator: false,
rank: 1,
},
],
...overrides,
};
}
function renderPage(initialEntry = '/versus/async/match-1/leaderboard') {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route
path="/versus/async/:matchId/leaderboard"
element={<AsyncMatchLeaderboard />}
/>
<Route path="/versus" element={<div>versus route</div>} />
<Route path="/versus/async/leaderboard" element={<AsyncMatchLeaderboard />} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
useAuthStore.setState({ user: USER, isLoading: false, error: null });
});
describe('loading', () => {
it('shows skeletons while the leaderboard is fetched', () => {
getAsyncLeaderboard.mockImplementation(() => new Promise(() => undefined));
renderPage();
expect(document.querySelectorAll('[data-slot="skeleton"]').length).toBeGreaterThan(0);
});
});
describe('a loaded leaderboard', () => {
it('renders the match info, movies, difficulty and creator by-line', async () => {
getAsyncLeaderboard.mockResolvedValue(response());
renderPage();
expect(await screen.findByText('Async Challenge')).toBeInTheDocument();
expect(screen.getByText(/Movie A \(2001\)/)).toBeInTheDocument();
expect(screen.getByText(/Movie B \(2002\)/)).toBeInTheDocument();
expect(screen.getByText('by Creator')).toBeInTheDocument();
expect(screen.getByText('medium')).toBeInTheDocument();
expect(screen.getByText('(2 players)')).toBeInTheDocument();
});
it('hides the movie pair when the server withholds it (the viewer has not played it)', async () => {
getAsyncLeaderboard.mockResolvedValue(
response({
match: {
id: 'match-1',
difficulty: 'medium',
lobbyName: null,
expiresAt: null,
status: 'open',
},
}),
);
renderPage();
expect(
await screen.findByText('Movies hidden until you play this challenge'),
).toBeInTheDocument();
});
it('falls back to the lobby name over the generic "creator\'s challenge" heading', async () => {
getAsyncLeaderboard.mockResolvedValue(
response({
match: { ...response().match, lobbyName: 'Friday Showdown' },
}),
);
renderPage();
expect(await screen.findByText('Friday Showdown')).toBeInTheDocument();
});
it('names the match by the creator when it has no lobby name', async () => {
getAsyncLeaderboard.mockResolvedValue(response());
renderPage();
expect(await screen.findByText("Creator's challenge")).toBeInTheDocument();
});
it('shows a time-remaining pill when the match has an expiry', async () => {
const future = new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString();
getAsyncLeaderboard.mockResolvedValue(
response({ match: { ...response().match, expiresAt: future } }),
);
renderPage();
expect(await screen.findByText(/\dh \d+m remaining/)).toBeInTheDocument();
});
it('labels an expired match', async () => {
const past = new Date(Date.now() - 1000).toISOString();
getAsyncLeaderboard.mockResolvedValue(
response({
match: { ...response().match, expiresAt: past, status: 'expired' },
}),
);
renderPage();
// "Expired" appears twice: the countdown text itself, and the status badge.
const matches = await screen.findAllByText('Expired');
expect(matches).toHaveLength(2);
expect(matches.some((el) => el.classList.contains('bg-red-100'))).toBe(true);
});
it('renders ranked rows, marks the creator with a crown and highlights the viewer as YOU', async () => {
getAsyncLeaderboard.mockResolvedValue(response());
renderPage();
expect(await screen.findAllByText('#1')).toHaveLength(2);
expect(screen.getByTitle('Creator')).toBeInTheDocument();
expect(screen.getByText('YOU')).toBeInTheDocument();
expect(screen.getByText('1000')).toBeInTheDocument();
expect(screen.getByText('2400')).toBeInTheDocument();
expect(screen.getByText('2 links')).toBeInTheDocument();
expect(screen.getByText('3 links')).toBeInTheDocument();
});
it('shows a default score of 0 and "?" links for an entry with no recorded score or chain', async () => {
getAsyncLeaderboard.mockResolvedValue(
response({
leaderboard: [
{
playerId: 'ghost-1',
username: 'Ghost',
score: null,
chainLength: null,
isCreator: false,
rank: 2,
},
],
}),
);
renderPage();
await screen.findByText('Ghost');
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('? links')).toBeInTheDocument();
});
it('shows the empty state when nobody has played yet', async () => {
getAsyncLeaderboard.mockResolvedValue(response({ leaderboard: [] }));
renderPage();
expect(await screen.findByText('No results yet')).toBeInTheDocument();
expect(screen.getByText('(0 players)')).toBeInTheDocument();
});
it('goes back to Versus from the back button', async () => {
getAsyncLeaderboard.mockResolvedValue(response());
renderPage();
await screen.findByText('Async Challenge');
await userEvent.click(screen.getByRole('button', { name: /Back/ }));
expect(await screen.findByText('versus route')).toBeInTheDocument();
});
});
describe('errors', () => {
it('shows "Match not found" and never fetches when the route has no match id', () => {
renderPage('/versus/async/leaderboard');
expect(screen.getByText('Match not found')).toBeInTheDocument();
expect(getAsyncLeaderboard).not.toHaveBeenCalled();
});
it('omits the release year for a movie pair with no known release date', async () => {
getAsyncLeaderboard.mockResolvedValue(
response({
match: {
...response().match,
movieAReleaseDate: null,
movieBReleaseDate: null,
},
}),
);
renderPage();
await screen.findByText('Async Challenge');
const pairText = screen.getByText(/Movie A.*Movie B/);
expect(pairText.textContent).not.toContain('(');
});
it('shows the server error message and a way back to Versus', async () => {
getAsyncLeaderboard.mockRejectedValue({
response: { data: { message: 'This challenge has expired.' } },
});
renderPage();
expect(await screen.findByText('This challenge has expired.')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Back to Versus' }));
expect(await screen.findByText('versus route')).toBeInTheDocument();
});
it('falls back to a generic message when the server gives none', async () => {
getAsyncLeaderboard.mockRejectedValue(new Error('network down'));
renderPage();
expect(await screen.findByText('Failed to load')).toBeInTheDocument();
});
});
describe('navigating between matches', () => {
it('reloads, showing a fresh skeleton, when navigating to a different match id', async () => {
getAsyncLeaderboard.mockImplementation((id: string) =>
Promise.resolve(
response({
match: { ...response().match, id },
creator: id === 'match-1' ? { id: 'creator-1', username: 'Creator' } : { id: 'creator-2', username: 'Other Creator' },
}),
),
);
render(
<MemoryRouter initialEntries={['/versus/async/match-1/leaderboard']}>
<Routes>
<Route
path="/versus/async/:matchId/leaderboard"
element={
<>
<Link to="/versus/async/match-2/leaderboard">go to match 2</Link>
<AsyncMatchLeaderboard />
</>
}
/>
</Routes>
</MemoryRouter>,
);
await screen.findByText('by Creator');
expect(getAsyncLeaderboard).toHaveBeenCalledWith('match-1');
await userEvent.click(screen.getByText('go to match 2'));
await waitFor(() => expect(getAsyncLeaderboard).toHaveBeenCalledWith('match-2'));
expect(await screen.findByText('by Other Creator')).toBeInTheDocument();
});
});
+508
View File
@@ -0,0 +1,508 @@
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';
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('Chain: 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('Chain: 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('2400')).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);
});
});
+497
View File
@@ -0,0 +1,497 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { FriendEntry, FriendProfile } from '@/api/friends';
import type { ChatMessage } from '@/api/chat';
/**
* Chat page: the message thread for one friend. Requests are intercepted at
* `@/api/client` and routed by URL - the seam `game-store.test.ts` and
* `Versus.test.tsx` use, since a second concurrent dynamic import of an
* aliased module has previously resolved to the real file and reached the
* dev backend. The chat socket is intercepted at `@/lib/create-socket` so no
* real websocket connection is attempted.
*/
const getFriends = vi.fn();
const getMessages = vi.fn();
const toastError = vi.fn();
type Handler = (arg: unknown) => Promise<unknown>;
function route(method: string, url: string): Handler {
let m: RegExpMatchArray | null;
if (method === 'get' && url === '/friends') {
return () => getFriends();
}
if (method === 'get' && (m = /^\/chat\/([^/]+)\/messages$/.exec(url))) {
return (config) =>
getMessages(m![1], (config as { params?: Record<string, string> })?.params);
}
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'),
},
};
});
vi.mock('sonner', () => ({
toast: { error: (...args: unknown[]) => toastError(...args) },
}));
interface FakeSocket {
id: string;
connected: boolean;
on: (event: string, cb: (...args: unknown[]) => void) => void;
off: (event: string, cb?: (...args: unknown[]) => void) => void;
once: (event: string, cb: (...args: unknown[]) => void) => void;
emit: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
connect: ReturnType<typeof vi.fn>;
removeAllListeners: ReturnType<typeof vi.fn>;
trigger: (event: string, ...args: unknown[]) => void;
}
function makeFakeSocket(): FakeSocket {
const handlers = new Map<string, Set<(...args: unknown[]) => void>>();
return {
id: 'fake-socket',
connected: true,
on: (event, cb) => {
const set = handlers.get(event) ?? new Set();
set.add(cb);
handlers.set(event, set);
},
off: (event, cb) => {
if (!cb) {
handlers.delete(event);
return;
}
handlers.get(event)?.delete(cb);
},
once: (event, cb) => {
const set = handlers.get(event) ?? new Set();
set.add(cb);
handlers.set(event, set);
},
emit: vi.fn(),
disconnect: vi.fn(),
connect: vi.fn(),
removeAllListeners: vi.fn(() => handlers.clear()),
trigger: (event, ...args) => {
handlers.get(event)?.forEach((cb) => cb(...args));
},
};
}
let sockets: FakeSocket[] = [];
const createSocketMock = vi.fn((_ns: string) => {
const socket = makeFakeSocket();
sockets.push(socket);
return socket;
});
vi.mock('@/lib/create-socket', () => ({
createSocket: (ns: string) => createSocketMock(ns),
}));
function latestSocket(): FakeSocket {
const socket = sockets.at(-1);
if (!socket) throw new Error('no socket created yet');
return socket;
}
const { useAuthStore } = await import('@/stores/auth-store');
const { useFriendsStore } = await import('@/stores/friends-store');
const { useChatStore } = await import('@/stores/chat-store');
const { default: Chat } = await import('./Chat');
// --- Fixtures ---------------------------------------------------------------
const USER = { id: 'user-1', email: 'me@example.com', username: 'Me', isAdmin: false };
function friendEntry(overrides: Partial<FriendProfile> = {}): FriendEntry {
return {
friendshipId: 'fs-1',
friend: {
id: 'friend-1',
username: 'friendlyuser',
displayName: 'Friendly Name',
avatarUrl: null,
...overrides,
},
since: '2024-01-01T00:00:00.000Z',
};
}
function makeMessage(overrides: Partial<ChatMessage> = {}): ChatMessage {
return {
id: 'm1',
senderId: 'friend-1',
recipientId: 'user-1',
content: 'hello there',
createdAt: '2024-01-01T12:00:00.000Z',
...overrides,
};
}
function renderChat(initialEntry = '/friends/chat/friend-1') {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/friends/chat/:friendId" element={<Chat />} />
<Route path="/login" element={<div>login route</div>} />
<Route path="/friends" element={<div>friends route</div>} />
</Routes>
</MemoryRouter>,
);
}
let originalScrollIntoView: typeof Element.prototype.scrollIntoView;
beforeEach(() => {
vi.clearAllMocks();
sockets = [];
originalScrollIntoView = Element.prototype.scrollIntoView;
Element.prototype.scrollIntoView = vi.fn();
useAuthStore.setState({ user: USER, isLoading: false, error: null });
useFriendsStore.getState().reset();
useChatStore.setState({
conversations: [],
messages: [],
socket: null,
typingFriendIds: new Set(),
activeFriendId: null,
loading: false,
hasMore: false,
nextCursor: null,
});
getFriends.mockResolvedValue([friendEntry()]);
getMessages.mockResolvedValue({ messages: [], hasMore: false, nextCursor: null });
});
afterEach(() => {
Element.prototype.scrollIntoView = originalScrollIntoView;
});
describe('authentication gate', () => {
it('redirects to login when nobody is signed in', async () => {
useAuthStore.setState({ user: null, isLoading: false });
renderChat();
expect(await screen.findByText('login route')).toBeInTheDocument();
});
it('does not redirect while auth is still resolving', () => {
useAuthStore.setState({ user: null, isLoading: true });
renderChat();
expect(screen.queryByText('login route')).toBeNull();
});
});
describe('friend header and initials', () => {
it('shows the friend display name and its first initial, uppercased', async () => {
getFriends.mockResolvedValue([friendEntry({ displayName: 'friendly name' })]);
renderChat();
expect(await screen.findByText('friendly name')).toBeInTheDocument();
expect(screen.getByText('F')).toBeInTheDocument();
});
it('falls back to the username when there is no display name', async () => {
getFriends.mockResolvedValue([friendEntry({ displayName: null, username: 'nodisplay' })]);
renderChat();
expect(await screen.findByText('nodisplay')).toBeInTheDocument();
expect(screen.getByText('N')).toBeInTheDocument();
});
it('falls back to "Unknown" and "?" when the friend is not in the list', async () => {
getFriends.mockResolvedValue([friendEntry({ id: 'someone-else' })]);
renderChat();
expect(await screen.findByText('Unknown')).toBeInTheDocument();
expect(screen.getByText('?')).toBeInTheDocument();
});
it('does not re-fetch friends when the list is already populated', async () => {
useFriendsStore.setState({ friends: [friendEntry()] });
renderChat();
await screen.findByText('Friendly Name');
expect(getFriends).not.toHaveBeenCalled();
});
it('shows a typing indicator only while the friend is typing', async () => {
renderChat();
await screen.findByText('Friendly Name');
expect(screen.queryByText('typing...')).toBeNull();
useChatStore.setState({ typingFriendIds: new Set(['friend-1']) });
expect(await screen.findByText('typing...')).toBeInTheDocument();
useChatStore.setState({ typingFriendIds: new Set() });
await waitFor(() => expect(screen.queryByText('typing...')).toBeNull());
});
});
describe('message thread', () => {
it('shows the empty state when there are no messages', async () => {
renderChat();
expect(
await screen.findByText('No messages yet. Say hello!'),
).toBeInTheDocument();
});
it('renders messages from the store, formatted with a time', async () => {
getMessages.mockResolvedValue({
messages: [
makeMessage({ id: 'm1', senderId: 'friend-1', content: 'hi from friend' }),
makeMessage({ id: 'm2', senderId: 'user-1', content: 'hi back' }),
],
hasMore: false,
nextCursor: null,
});
renderChat();
expect(await screen.findByText('hi from friend')).toBeInTheDocument();
expect(screen.getByText('hi back')).toBeInTheDocument();
});
it('shows a loading indicator while the conversation is being fetched', async () => {
let resolveMessages: (v: {
messages: ChatMessage[];
hasMore: boolean;
nextCursor: string | null;
}) => void = () => undefined;
getMessages.mockImplementation(
() =>
new Promise((resolve) => {
resolveMessages = resolve;
}),
);
const { container } = renderChat();
await screen.findByText('Friendly Name');
expect(container.querySelector('.animate-spin')).not.toBeNull();
resolveMessages({ messages: [], hasMore: false, nextCursor: null });
await waitFor(() =>
expect(container.querySelector('.animate-spin')).toBeNull(),
);
});
it('loads older messages when scrolled to the top and more are available', async () => {
getMessages.mockResolvedValueOnce({
messages: [makeMessage({ id: 'recent', content: 'recent message' })],
hasMore: true,
nextCursor: 'cursor-1',
});
const { container } = renderChat();
await screen.findByText('recent message');
getMessages.mockResolvedValueOnce({
messages: [makeMessage({ id: 'older', content: 'older message' })],
hasMore: false,
nextCursor: null,
});
const scrollContainer = container.querySelector('.overflow-y-auto');
if (!scrollContainer) throw new Error('scroll container not found');
scrollContainer.dispatchEvent(new Event('scroll', { bubbles: true }));
expect(await screen.findByText('older message')).toBeInTheDocument();
expect(getMessages).toHaveBeenNthCalledWith(2, 'friend-1', {
limit: '50',
cursor: 'cursor-1',
});
// Older messages are prepended, not appended.
const text = container.textContent ?? '';
expect(text.indexOf('older message')).toBeLessThan(text.indexOf('recent message'));
});
it('does not request more messages when there are none left', async () => {
const { container } = renderChat();
await screen.findByText('No messages yet. Say hello!');
const scrollContainer = container.querySelector('.overflow-y-auto');
if (!scrollContainer) throw new Error('scroll container not found');
scrollContainer.dispatchEvent(new Event('scroll', { bubbles: true }));
expect(getMessages).toHaveBeenCalledTimes(1);
});
it('does not request more messages when the container has not been scrolled to the very top', async () => {
getMessages.mockResolvedValueOnce({
messages: [makeMessage({ content: 'hello there' })],
hasMore: true,
nextCursor: 'cursor-1',
});
const { container } = renderChat();
await screen.findByText('hello there');
const scrollContainer = container.querySelector('.overflow-y-auto');
if (!scrollContainer) throw new Error('scroll container not found');
Object.defineProperty(scrollContainer, 'scrollTop', { value: 40, configurable: true });
scrollContainer.dispatchEvent(new Event('scroll', { bubbles: true }));
expect(getMessages).toHaveBeenCalledTimes(1);
});
it('does not auto-scroll to a new message once the user has scrolled up', async () => {
const { container } = renderChat();
await screen.findByText('No messages yet. Say hello!');
const scrollContainer = container.querySelector('.overflow-y-auto');
if (!scrollContainer) throw new Error('scroll container not found');
Object.defineProperty(scrollContainer, 'scrollHeight', { value: 1000, configurable: true });
Object.defineProperty(scrollContainer, 'scrollTop', { value: 0, configurable: true });
Object.defineProperty(scrollContainer, 'clientHeight', { value: 200, configurable: true });
scrollContainer.dispatchEvent(new Event('scroll', { bubbles: true }));
const scrollSpy = vi.mocked(Element.prototype.scrollIntoView);
scrollSpy.mockClear();
latestSocket().trigger('new-message', makeMessage({ id: 'later', content: 'later message' }));
expect(await screen.findByText('later message')).toBeInTheDocument();
expect(scrollSpy).not.toHaveBeenCalled();
});
it('scrolls to the newest message when one arrives over the socket', async () => {
renderChat();
await screen.findByText('No messages yet. Say hello!');
const scrollSpy = vi.mocked(Element.prototype.scrollIntoView);
scrollSpy.mockClear();
latestSocket().trigger('new-message', makeMessage({ id: 'incoming', content: 'ping' }));
expect(await screen.findByText('ping')).toBeInTheDocument();
expect(scrollSpy).toHaveBeenCalled();
});
});
describe('sending a message', () => {
it('disables Send until there is text, then emits over the socket and clears the input', async () => {
const user = userEvent.setup();
renderChat();
await screen.findByText('No messages yet. Say hello!');
const input = screen.getByLabelText('Message');
const sendButton = screen.getByRole('button', { name: 'Send message' });
expect(sendButton).toBeDisabled();
await user.type(input, 'hello friend');
expect(sendButton).toBeEnabled();
await user.click(sendButton);
expect(latestSocket().emit).toHaveBeenCalledWith('send-message', {
recipientId: 'friend-1',
content: 'hello friend',
});
expect(input).toHaveValue('');
});
it('emits typing at most once for a burst of keystrokes', async () => {
renderChat();
await screen.findByText('No messages yet. Say hello!');
const input = screen.getByLabelText('Message');
await userEvent.type(input, 'hi');
const typingCalls = latestSocket().emit.mock.calls.filter(
([event]) => event === 'typing',
);
expect(typingCalls).toHaveLength(1);
expect(typingCalls[0]?.[1]).toEqual({ recipientId: 'friend-1' });
});
it('reports a send failure instead of losing the composed message', async () => {
const user = userEvent.setup();
renderChat();
await screen.findByText('No messages yet. Say hello!');
const socket = latestSocket();
const input = screen.getByLabelText('Message');
await user.type(input, 'still here');
// Only the send itself is a dead socket - typing indicators sent while
// composing the message must not be affected.
socket.emit.mockImplementation((event: unknown) => {
if (event === 'send-message') throw new Error('socket is dead');
});
await user.click(screen.getByRole('button', { name: 'Send message' }));
await waitFor(() => expect(toastError).toHaveBeenCalledWith('Failed to send message'));
// The failed send does not silently clear what the user typed, and the
// button is not left stuck disabled.
expect(input).toHaveValue('still here');
expect(screen.getByRole('button', { name: 'Send message' })).toBeEnabled();
});
});
describe('connection lifecycle', () => {
it('connects the socket on mount and disconnects on unmount', async () => {
const { unmount } = renderChat();
await screen.findByText('No messages yet. Say hello!');
expect(createSocketMock).toHaveBeenCalledWith('/chat');
const socket = latestSocket();
unmount();
expect(socket.disconnect).toHaveBeenCalled();
});
it('navigates back to the friends list', async () => {
renderChat();
await screen.findByText('No messages yet. Say hello!');
await userEvent.click(screen.getByRole('button', { name: 'Back to friends' }));
expect(await screen.findByText('friends route')).toBeInTheDocument();
});
});
describe('mobile keyboard viewport handling', () => {
it('tracks the visual viewport and cleans up its listeners on unmount', async () => {
const originalVisualViewport = window.visualViewport;
const listeners = new Map<string, () => void>();
const fakeViewport = {
height: 700,
offsetTop: 0,
addEventListener: vi.fn((type: string, cb: () => void) => {
listeners.set(type, cb);
}),
removeEventListener: vi.fn((type: string) => {
listeners.delete(type);
}),
};
Object.defineProperty(window, 'visualViewport', {
value: fakeViewport,
configurable: true,
});
const { unmount } = renderChat();
await screen.findByText('No messages yet. Say hello!');
expect(fakeViewport.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
expect(fakeViewport.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
fakeViewport.offsetTop = 40;
const resizeHandler = listeners.get('resize');
if (!resizeHandler) throw new Error('resize handler was not registered');
resizeHandler();
expect(document.documentElement.style.getPropertyValue('--vv-offset')).toBe('40px');
unmount();
expect(fakeViewport.removeEventListener).toHaveBeenCalledWith(
'resize',
expect.any(Function),
);
expect(document.documentElement.style.getPropertyValue('--vv-offset')).toBe('');
Object.defineProperty(window, 'visualViewport', {
value: originalVisualViewport,
configurable: true,
});
});
});
+272
View File
@@ -0,0 +1,272 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TmdbMovieResult, TmdbPersonResult } from '@/types';
/**
* CustomGame.tsx: the two movie pickers, the "pick two different movies"
* guard, and starting the game. `SearchAutocomplete` has its own tests; here
* it is a simple stub that immediately "selects" a fixed movie so this page's
* own wiring - not the search UI - is what is under test.
*/
interface SelectProps {
onSelectMovie: (movie: TmdbMovieResult) => void;
onSelectActor: (person: TmdbPersonResult) => void;
placeholder?: string;
}
const DUMMY_PERSON: TmdbPersonResult = {
id: 99,
name: 'Unused Actor',
profile_path: null,
popularity: 1,
known_for_department: 'Acting',
known_for: [],
adult: false,
};
const MOVIE_A: TmdbMovieResult = {
id: 1,
title: 'Heat',
original_title: 'Heat',
overview: '',
poster_path: '/heat.jpg',
backdrop_path: null,
release_date: '1995-12-15',
popularity: 50,
vote_average: 8,
vote_count: 100,
genre_ids: [],
adult: false,
};
const MOVIE_B: TmdbMovieResult = {
id: 2,
title: 'Se7en',
original_title: 'Se7en',
overview: '',
poster_path: null,
backdrop_path: null,
release_date: '1995-09-22',
popularity: 40,
vote_average: 8.5,
vote_count: 90,
genre_ids: [],
adult: false,
};
vi.mock('@/components/game/SearchAutocomplete', () => ({
default: ({ onSelectMovie, onSelectActor, placeholder }: SelectProps) => (
<>
<button type="button" onClick={() => onSelectMovie(placeholder?.includes('Movie A') ? MOVIE_A : MOVIE_B)}>
{placeholder}
</button>
{/* Lets a test pick Heat for either slot, to exercise the
same-movie-twice guard regardless of which picker is used. */}
<button type="button" onClick={() => onSelectMovie(MOVIE_A)}>
Pick Heat for &quot;{placeholder}&quot;
</button>
{/* The real component only calls onSelectActor in "actor" mode, which
CustomGame never uses - but the prop is still wired up to a no-op,
and this exercises that wiring directly. */}
<button type="button" onClick={() => onSelectActor(DUMMY_PERSON)}>
Simulate actor select for &quot;{placeholder}&quot;
</button>
</>
),
}));
interface ReqConfig {
signal?: AbortSignal;
}
const movieDetailsSpy = vi.fn();
const createSessionSpy = vi.fn();
function route(method: string, url: string): (body: unknown, config?: ReqConfig) => Promise<unknown> {
let m: RegExpMatchArray | null;
if (method === 'get' && (m = /^\/movies\/(\d+)$/.exec(url))) {
return () => movieDetailsSpy(Number(m![1]));
}
if (method === 'post' && url === '/games') return (body) => createSessionSpy(body);
throw new Error(`test reached an unrouted request: ${method} ${url}`);
}
vi.mock('@/api/client', () => {
const call =
(method: string) =>
async (url: string, a?: unknown, b?: unknown): Promise<{ data: unknown }> => {
const config = (method === 'get' ? a : b) as ReqConfig | undefined;
const body = method === 'get' ? undefined : a;
return { data: await route(method, url)(body, config) };
};
return {
default: {
get: call('get'),
post: call('post'),
put: call('put'),
delete: call('delete'),
},
};
});
const { default: CustomGame } = await import('./CustomGame');
const { useGameStore } = await import('@/stores/game-store');
function movieDetails(id: number) {
return { id, title: `Movie ${id}`, poster_path: null, release_date: '2000-01-01', popularity: 5 };
}
function renderPage() {
return render(
<MemoryRouter initialEntries={['/custom']}>
<Routes>
<Route path="/custom" element={<CustomGame />} />
<Route path="/" element={<div>Home Page</div>} />
<Route path="/game" element={<div>Game Page</div>} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
useGameStore.getState().resetGame();
movieDetailsSpy.mockImplementation((id: number) => Promise.resolve(movieDetails(id)));
createSessionSpy.mockResolvedValue({ id: 'session-1', startedAt: 'now' });
});
it('navigates home from the Back button', async () => {
renderPage();
await userEvent.click(screen.getByRole('button', { name: /Back/ }));
expect(await screen.findByText('Home Page')).toBeInTheDocument();
});
describe('picking movies', () => {
it('shows a poster image when the picked movie has one, and none when it does not', async () => {
renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A...' }));
expect(await screen.findByText('Heat')).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'Heat' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B...' }));
expect(await screen.findByText('Se7en')).toBeInTheDocument();
expect(screen.queryByRole('img', { name: 'Se7en' })).toBeNull();
});
it('lets either pick be changed back to search', async () => {
renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A...' }));
await screen.findByText('Heat');
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B...' }));
await screen.findByText('Se7en');
const [changeA, changeB] = screen.getAllByRole('button', { name: 'Change' });
if (!changeA || !changeB) throw new Error('expected two Change buttons');
await userEvent.click(changeB);
expect(screen.getByRole('button', { name: 'Search for Movie B...' })).toBeInTheDocument();
expect(screen.queryByText('Se7en')).toBeNull();
await userEvent.click(changeA);
expect(screen.getByRole('button', { name: 'Search for Movie A...' })).toBeInTheDocument();
expect(screen.queryByText('Heat')).toBeNull();
});
it('wires an actor selection from either picker to a harmless no-op', async () => {
// Both pickers are always in "movie" mode, so the real SearchAutocomplete
// never actually calls onSelectActor - but the prop is required by its
// interface, and this pins that CustomGame's placeholder for it is inert.
renderPage();
await userEvent.click(
screen.getByRole('button', { name: 'Simulate actor select for "Search for Movie A..."' }),
);
await userEvent.click(
screen.getByRole('button', { name: 'Simulate actor select for "Search for Movie B..."' }),
);
expect(screen.getByRole('button', { name: 'Search for Movie A...' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Search for Movie B...' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Play' })).toBeDisabled();
});
it('disables Play until two movies are chosen', async () => {
renderPage();
expect(screen.getByRole('button', { name: 'Play' })).toBeDisabled();
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A...' }));
expect(screen.getByRole('button', { name: 'Play' })).toBeDisabled();
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B...' }));
expect(screen.getByRole('button', { name: 'Play' })).toBeEnabled();
});
it('warns and keeps Play disabled when the same movie is picked twice', async () => {
renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A...' }));
await screen.findByText('Heat');
await userEvent.click(
screen.getByRole('button', { name: 'Pick Heat for "Search for Movie B..."' }),
);
expect(
await screen.findByText('Please select two different movies.'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Play' })).toBeDisabled();
});
});
describe('starting the game', () => {
it('starts a custom game and navigates to /game', async () => {
renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A...' }));
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B...' }));
await userEvent.click(screen.getByRole('button', { name: 'Play' }));
expect(await screen.findByText('Game Page')).toBeInTheDocument();
expect(useGameStore.getState().isCustomGame).toBe(true);
// Session creation is fire-and-forget, kicked off after navigation.
await waitFor(() =>
expect(createSessionSpy).toHaveBeenCalledWith(
expect.objectContaining({ movieAId: 1, movieBId: 2 }),
),
);
});
it('shows a spinner and disables Play while starting', async () => {
let release!: () => void;
movieDetailsSpy.mockImplementationOnce(
() => new Promise((resolve) => { release = () => resolve(movieDetails(1)); }),
);
renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A...' }));
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B...' }));
await userEvent.click(screen.getByRole('button', { name: 'Play' }));
const loadingButton = await screen.findByRole('button', { name: 'Loading...' });
expect(loadingButton).toBeDisabled();
release();
await screen.findByText('Game Page');
});
it("shows the store's error and stays put when the game fails to start", async () => {
// startGame reports failure rather than rejecting. Before WP-19 this page
// navigated regardless and /game's idle redirect bounced the player back
// with the message never shown.
movieDetailsSpy.mockRejectedValue(new Error('network'));
renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie A...' }));
await userEvent.click(screen.getByRole('button', { name: 'Search for Movie B...' }));
await userEvent.click(screen.getByRole('button', { name: 'Play' }));
expect(await screen.findByRole('alert')).toHaveTextContent(
'Failed to load movie details. Please try again.',
);
expect(screen.queryByText('Game Page')).toBeNull();
expect(useGameStore.getState().status).toBe('idle');
expect(screen.getByRole('button', { name: 'Play' })).toBeEnabled();
});
});
+24 -9
View File
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Alert, AlertDescription } from '@/components/ui/alert';
import PageLayout from '@/components/layout/PageLayout';
import SearchAutocomplete from '@/components/game/SearchAutocomplete';
import { useGameStore } from '@/stores/game-store';
@@ -21,6 +22,7 @@ export default function CustomGame() {
const [movieA, setMovieA] = useState<SelectedMovie | null>(null);
const [movieB, setMovieB] = useState<SelectedMovie | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const startGame = useGameStore((s) => s.startGame);
const navigate = useNavigate();
@@ -47,18 +49,26 @@ export default function CustomGame() {
const handlePlay = async () => {
if (!movieA || !movieB) return;
setLoading(true);
try {
await startGame(
{
movieA: { id: movieA.id, title: movieA.title },
movieB: { id: movieB.id, title: movieB.title },
},
{ isCustom: true },
setError('');
// startGame never rejects; it reports failure and leaves the reason in the
// store. Navigating regardless landed on /game's idle redirect, which
// bounced back here with the message never shown.
const result = await startGame(
{
movieA: { id: movieA.id, title: movieA.title },
movieB: { id: movieB.id, title: movieB.title },
},
{ isCustom: true },
);
if (result !== 'started') {
setError(
useGameStore.getState().validationError ??
'Could not start the game. Please try again.',
);
navigate('/game');
} catch {
setLoading(false);
return;
}
navigate('/game');
};
return (
@@ -155,6 +165,11 @@ export default function CustomGame() {
<Separator />
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
size="lg"
className="w-full text-base"
+225
View File
@@ -0,0 +1,225 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { DailyChallenge as DailyChallengeType } from '@/api/daily-challenges';
/**
* DailyChallenge.tsx: the three difficulties, the `firstChallenge` narrowing
* used for the page's date subheading, starting a challenge, and the
* loading/error states.
*/
interface ReqConfig {
signal?: AbortSignal;
}
const challengesSpy = vi.fn();
const completionSpy = vi.fn();
const movieDetailsSpy = vi.fn();
const createSessionSpy = vi.fn();
function route(method: string, url: string): (body: unknown, config?: ReqConfig) => Promise<unknown> {
let m: RegExpMatchArray | null;
if (method === 'get' && url === '/daily-challenges/today') return () => challengesSpy();
if (method === 'get' && url === '/leaderboards/me/today') return () => completionSpy();
if (method === 'get' && (m = /^\/movies\/(\d+)$/.exec(url))) {
return () => movieDetailsSpy(Number(m![1]));
}
if (method === 'post' && url === '/games') return (body) => createSessionSpy(body);
throw new Error(`test reached an unrouted request: ${method} ${url}`);
}
vi.mock('@/api/client', () => {
const call =
(method: string) =>
async (url: string, a?: unknown, b?: unknown): Promise<{ data: unknown }> => {
const config = (method === 'get' ? a : b) as ReqConfig | undefined;
const body = method === 'get' ? undefined : a;
return { data: await route(method, url)(body, config) };
};
return {
default: {
get: call('get'),
post: call('post'),
put: call('put'),
delete: call('delete'),
},
};
});
const { default: DailyChallenge } = await import('./DailyChallenge');
const { useAuthStore } = await import('@/stores/auth-store');
const { useGameStore } = await import('@/stores/game-store');
function challenge(overrides: Partial<DailyChallengeType>): DailyChallengeType {
return {
id: 'c-easy',
date: '2024-06-15',
movieAId: 1,
movieATitle: 'Movie A',
movieAReleaseDate: '2000-01-01',
movieBId: 2,
movieBTitle: 'Movie B',
movieBReleaseDate: '2001-01-01',
par: 3,
difficulty: 'easy',
...overrides,
};
}
function renderPage() {
return render(
<MemoryRouter initialEntries={['/daily']}>
<Routes>
<Route path="/daily" element={<DailyChallenge />} />
<Route path="/game" element={<div>Game Page</div>} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
useAuthStore.setState({ user: null, isLoading: false, error: null });
useGameStore.getState().resetGame();
challengesSpy.mockResolvedValue([
challenge({ id: 'c-easy', difficulty: 'easy' }),
challenge({ id: 'c-medium', difficulty: 'medium' }),
challenge({ id: 'c-hard', difficulty: 'hard' }),
]);
completionSpy.mockResolvedValue({ easy: false, medium: false, hard: false });
movieDetailsSpy.mockImplementation((id: number) =>
Promise.resolve({
id,
title: id === 1 ? 'Movie A' : 'Movie B',
poster_path: null,
release_date: '2000-01-01',
popularity: 5,
}),
);
createSessionSpy.mockResolvedValue({ id: 'session-1', startedAt: 'now' });
});
afterEach(() => {
localStorage.clear();
});
describe('loading and listing', () => {
it('lists all three difficulties with their par', async () => {
renderPage();
expect(await screen.findByText('easy')).toBeInTheDocument();
expect(screen.getByText('medium')).toBeInTheDocument();
expect(screen.getByText('hard')).toBeInTheDocument();
expect(screen.getAllByText('Par: 3 links')).toHaveLength(3);
});
it("shows the first challenge's date once loaded", async () => {
renderPage();
expect(
await screen.findByText('Saturday, June 15, 2024'),
).toBeInTheDocument();
});
it('shows no date subheading when there are no challenges to derive one from', async () => {
challengesSpy.mockResolvedValue([]);
renderPage();
await screen.findByRole('heading', { name: 'Daily Challenges' });
expect(screen.queryByText(/2024/)).toBeNull();
});
it('shows an error message when challenges fail to load', async () => {
challengesSpy.mockRejectedValue(new Error('500'));
renderPage();
expect(
await screen.findByText("Failed to load today's challenges."),
).toBeInTheDocument();
});
it('does not check server completion for an anonymous visitor', async () => {
renderPage();
await screen.findByText('easy');
expect(completionSpy).not.toHaveBeenCalled();
});
});
describe('completion state', () => {
it('marks a difficulty completed from localStorage, independent of the server', async () => {
localStorage.setItem('daily-completed-2024-06-15-medium', 'true');
renderPage();
await screen.findByText('easy');
expect(screen.getAllByRole('button', { name: 'Completed' })).toHaveLength(1);
});
it('merges server-reported completion for a signed-in user', async () => {
useAuthStore.setState({ user: { id: 'u1', email: 'a@a.com', username: 'a', isAdmin: false }, isLoading: false });
completionSpy.mockResolvedValue({ easy: true, medium: false, hard: false });
renderPage();
expect(completionSpy).not.toHaveBeenCalled(); // not yet, until challenges resolve
expect(await screen.findByRole('button', { name: 'Completed' })).toBeInTheDocument();
});
it('falls back to localStorage-only when the server completion check fails', async () => {
useAuthStore.setState({ user: { id: 'u1', email: 'a@a.com', username: 'a', isAdmin: false }, isLoading: false });
completionSpy.mockRejectedValue(new Error('500'));
localStorage.setItem('daily-completed-2024-06-15-hard', 'true');
renderPage();
expect(await screen.findByRole('button', { name: 'Completed' })).toBeInTheDocument();
// No crash, and the other two remain playable.
expect(screen.getAllByRole('button', { name: 'Play' })).toHaveLength(2);
});
});
describe('starting a challenge', () => {
it('starts the game and navigates to /game', async () => {
renderPage();
const [playButton] = await screen.findAllByRole('button', { name: 'Play' });
if (!playButton) throw new Error('expected a Play button');
await userEvent.click(playButton);
expect(await screen.findByText('Game Page')).toBeInTheDocument();
const state = useGameStore.getState();
expect(state.isDailyChallenge).toBe(true);
expect(state.dailyChallengeDate).toBe('2024-06-15');
expect(state.dailyChallengeDifficulty).toBe('easy');
});
it("shows the store's error and stays on the page when the game fails to start", async () => {
// startGame reports failure rather than rejecting; before WP-19 this page
// navigated anyway and /game's idle redirect bounced the player back with
// the message never shown.
movieDetailsSpy.mockRejectedValue(new Error('network'));
renderPage();
const [playButton] = await screen.findAllByRole('button', { name: 'Play' });
if (!playButton) throw new Error('expected a Play button');
await userEvent.click(playButton);
expect(await screen.findByRole('alert')).toHaveTextContent(
'Failed to load movie details. Please try again.',
);
expect(screen.queryByText('Game Page')).toBeNull();
});
it('disables Play and shows a spinner while the chain is validating', async () => {
// isValidating is a single store-wide flag, so every card's button
// switches to the spinner while any one of them is starting a game.
let releaseDetails!: () => void;
movieDetailsSpy.mockImplementationOnce(
() => new Promise((resolve) => { releaseDetails = () => resolve({ id: 1, title: 'Movie A', poster_path: null, release_date: '2000-01-01', popularity: 5 }); }),
);
renderPage();
const [playButton] = await screen.findAllByRole('button', { name: 'Play' });
if (!playButton) throw new Error('expected a play button');
await userEvent.click(playButton);
const loadingButtons = await screen.findAllByRole('button', { name: 'Loading...' });
expect(loadingButtons).toHaveLength(3);
expect(loadingButtons[0]).toBeDisabled();
releaseDetails();
await screen.findByText('Game Page');
});
});
+12 -2
View File
@@ -64,13 +64,23 @@ export default function DailyChallenge() {
}, [userId]);
const handlePlay = async (challenge: DailyChallengeType) => {
await startGame(
setError(null);
const result = await startGame(
{
movieA: { id: challenge.movieAId, title: challenge.movieATitle },
movieB: { id: challenge.movieBId, title: challenge.movieBTitle },
},
{ isDaily: true, dailyDate: challenge.date, dailyChallengeId: challenge.id, difficulty: challenge.difficulty },
);
// A failed start is reported, not thrown; navigating anyway bounced off
// /game's idle redirect with the store's message never shown.
if (result !== 'started') {
setError(
useGameStore.getState().validationError ??
'Could not start the challenge. Please try again.',
);
return;
}
navigate('/game');
};
@@ -122,7 +132,7 @@ export default function DailyChallenge() {
)}
{error && (
<p className="text-destructive">{error}</p>
<p role="alert" className="text-destructive">{error}</p>
)}
{challenges.length > 0 && (
+197
View File
@@ -0,0 +1,197 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* Endless.tsx: the genre/decade filter badges (including resetting to "Any"),
* the franchise-aware pair pick (`@/lib/movie-pair`'s `pickDistinctPair` for
* the unfiltered path, `@/lib/movie-pairs`'s `getFilteredPair` for a filtered
* one), and the Start button disabling while a game is starting.
*
* `@/lib/movie-pair` and `@/lib/movie-pairs` are exercised for real - they are
* plain, deterministic-once-`Math.random`-is-pinned logic, not an I/O edge -
* only `@/api/client` is intercepted.
*/
interface ReqConfig {
params?: Record<string, unknown>;
}
const discoverSpy = vi.fn();
const movieDetailsSpy = vi.fn();
const createSessionSpy = vi.fn();
function route(method: string, url: string): (body: unknown, config?: ReqConfig) => Promise<unknown> {
let m: RegExpMatchArray | null;
if (method === 'get' && url === '/movies/discover') {
return (_b, config) => discoverSpy(config?.params);
}
if (method === 'get' && (m = /^\/movies\/(\d+)$/.exec(url))) {
return () => movieDetailsSpy(Number(m![1]));
}
if (method === 'post' && url === '/games') return (body) => createSessionSpy(body);
throw new Error(`test reached an unrouted request: ${method} ${url}`);
}
vi.mock('@/api/client', () => {
const call =
(method: string) =>
async (url: string, a?: unknown, b?: unknown): Promise<{ data: unknown }> => {
const config = (method === 'get' ? a : b) as ReqConfig | undefined;
const body = method === 'get' ? undefined : a;
return { data: await route(method, url)(body, config) };
};
return {
default: {
get: call('get'),
post: call('post'),
put: call('put'),
delete: call('delete'),
},
};
});
const { default: Endless } = await import('./Endless');
const { useGameStore } = await import('@/stores/game-store');
function movieDetails(id: number) {
return { id, title: `Movie ${id}`, poster_path: null, release_date: '2000-01-01', popularity: 5 };
}
function renderPage() {
return render(
<MemoryRouter initialEntries={['/endless']}>
<Routes>
<Route path="/endless" element={<Endless />} />
<Route path="/game" element={<div>Game Page</div>} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
useGameStore.getState().resetGame();
movieDetailsSpy.mockImplementation((id: number) => Promise.resolve(movieDetails(id)));
createSessionSpy.mockResolvedValue({ id: 'session-1', startedAt: 'now' });
discoverSpy.mockResolvedValue({
page: 1,
results: [
{ id: 9001, title: 'Indie Film', release_date: '2015-05-01' },
{ id: 9002, title: 'Another Indie', release_date: '2016-05-01' },
],
total_pages: 1,
total_results: 2,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('filters panel', () => {
it('is hidden until "Show Filters" is toggled', async () => {
renderPage();
expect(screen.queryByText('Genre')).toBeNull();
await userEvent.click(screen.getByRole('button', { name: 'Show Filters' }));
expect(screen.getByText('Genre')).toBeInTheDocument();
expect(screen.getByText('Decade')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Hide Filters' }));
expect(screen.queryByText('Genre')).toBeNull();
});
});
describe('starting a game', () => {
it('uses a preset pair for the chosen decade', async () => {
vi.spyOn(Math, 'random').mockReturnValue(0);
renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Show Filters' }));
await userEvent.click(screen.getByText('1970s'));
await userEvent.click(screen.getByRole('button', { name: 'Start Practice Game' }));
expect(await screen.findByText('Game Page')).toBeInTheDocument();
expect(discoverSpy).not.toHaveBeenCalled();
// The first 1970s preset pair, in PRESET_PAIRS order, with `random` pinned
// to 0: Star Wars (11) and Raiders of the Lost Ark (85).
const requestedIds = movieDetailsSpy.mock.calls.map((c) => c[0] as number);
expect(requestedIds.sort()).toEqual([11, 85]);
});
it('falls back to a TMDB discover pair once a filter is reset to "Any"', async () => {
renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Show Filters' }));
await userEvent.click(screen.getByText('Comedy'));
await userEvent.click(screen.getByText('1970s'));
// Reset both filters back to unfiltered before starting.
await userEvent.click(screen.getAllByText('Any')[0] as HTMLElement);
await userEvent.click(screen.getAllByText('Any')[1] as HTMLElement);
await userEvent.click(screen.getByRole('button', { name: 'Start Practice Game' }));
expect(await screen.findByText('Game Page')).toBeInTheDocument();
expect(discoverSpy).toHaveBeenCalledTimes(1);
const requestedIds = movieDetailsSpy.mock.calls.map((c) => c[0] as number);
expect(requestedIds.sort()).toEqual([9001, 9002]);
});
it('disables the Start button and shows a spinner while starting', async () => {
let release!: () => void;
movieDetailsSpy.mockImplementationOnce(
() => new Promise((resolve) => { release = () => resolve(movieDetails(11)); }),
);
vi.spyOn(Math, 'random').mockReturnValue(0);
renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Show Filters' }));
await userEvent.click(screen.getByText('1970s'));
await userEvent.click(screen.getByRole('button', { name: 'Start Practice Game' }));
const loadingButton = await screen.findByRole('button', { name: 'Loading...' });
expect(loadingButton).toBeDisabled();
release();
await screen.findByText('Game Page');
});
// Before WP-19 each of these was an unhandled rejection out of the click
// handler, or a navigation to /game whose idle redirect bounced the player
// back with nothing shown.
it('reports a TMDB failure instead of throwing out of the click handler', async () => {
discoverSpy.mockRejectedValue(new Error('Network Error'));
renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Start Practice Game' }));
expect(await screen.findByRole('alert')).toHaveTextContent(/Network error/);
expect(screen.queryByText('Game Page')).toBeNull();
expect(screen.getByRole('button', { name: 'Start Practice Game' })).toBeEnabled();
});
it('explains a page with too few usable movies rather than failing silently', async () => {
discoverSpy.mockResolvedValue({
page: 1,
results: [{ id: 9001, title: 'Indie Film', release_date: '2015-05-01' }],
total_pages: 1,
total_results: 1,
});
renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Start Practice Game' }));
expect(await screen.findByRole('alert')).toHaveTextContent(/not return enough movies/);
});
it("shows the store's error and stays put when the game fails to start", async () => {
movieDetailsSpy.mockRejectedValue(new Error('network'));
renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Start Practice Game' }));
expect(await screen.findByRole('alert')).toHaveTextContent(
'Failed to load movie details. Please try again.',
);
expect(screen.queryByText('Game Page')).toBeNull();
});
});
+50 -19
View File
@@ -7,6 +7,8 @@ import { useGameStore } from '@/stores/game-store';
import { getFilteredPair, GENRES, DECADES } from '@/lib/movie-pairs';
import { discoverMovies } from '@/api/movies';
import { pickDistinctPair } from '@/lib/movie-pair';
import { getErrorMessage } from '@/lib/error';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Loader2, Shuffle, Filter } from 'lucide-react';
export default function Endless() {
@@ -17,25 +19,49 @@ export default function Endless() {
const [decade, setDecade] = useState<string | null>(null);
const [showFilters, setShowFilters] = useState(false);
// As on Home: `isValidating` only covers startGame, not the TMDB pick
// before it, and a failure in here used to be an unhandled rejection.
const [starting, setStarting] = useState(false);
const [error, setError] = useState('');
const handleStart = async () => {
let pair;
if (genre || decade) {
pair = getFilteredPair(genre, decade);
} else {
// Use TMDB discover for unfiltered random pairs
const page = Math.floor(Math.random() * 20) + 1;
const data = await discoverMovies(page);
const movies = data.results.filter((m) => m.title && m.release_date);
const distinctPair = pickDistinctPair(movies);
if (!distinctPair) throw new Error('Not enough movies');
const [movieA, movieB] = distinctPair;
pair = {
movieA: { id: movieA.id, title: movieA.title },
movieB: { id: movieB.id, title: movieB.title },
};
setStarting(true);
setError('');
try {
let pair;
if (genre || decade) {
pair = getFilteredPair(genre, decade);
} else {
// Use TMDB discover for unfiltered random pairs
const page = Math.floor(Math.random() * 20) + 1;
const data = await discoverMovies(page);
const movies = data.results.filter((m) => m.title && m.release_date);
const distinctPair = pickDistinctPair(movies);
if (!distinctPair) {
setError('TMDB did not return enough movies to make a pair. Try again.');
return;
}
const [movieA, movieB] = distinctPair;
pair = {
movieA: { id: movieA.id, title: movieA.title },
movieB: { id: movieB.id, title: movieB.title },
};
}
if ((await startGame(pair)) !== 'started') {
setError(
useGameStore.getState().validationError ??
'Could not start the game. Please try again.',
);
return;
}
navigate('/game');
} catch (err) {
setError(
getErrorMessage(err, 'Could not reach the movie database. Please try again.'),
);
} finally {
setStarting(false);
}
await startGame(pair);
navigate('/game');
};
return (
@@ -113,13 +139,18 @@ export default function Endless() {
)}
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
size="lg"
className="w-full text-base"
onClick={handleStart}
disabled={isValidating}
disabled={starting || isValidating}
>
{isValidating ? (
{starting || isValidating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
+406
View File
@@ -0,0 +1,406 @@
import { render, screen, waitFor, within } 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';
import type { FriendEntry, FriendProfile, PendingRequest } from '@/api/friends';
/**
* Friends page: the friends list, pending requests, remove/accept/decline,
* online presence, and the Challenge Friend dialog (a real Dialog since
* WP-16). Requests are intercepted at `@/api/client` and routed by URL - the
* seam `game-store.test.ts` and `Versus.test.tsx` use. The presence socket
* lives on the (real) notification store, seeded directly rather than
* through `@/lib/create-socket`, since Friends.tsx only reads `socket` off
* that store and never calls `connect()` itself.
*/
const getFriends = vi.fn();
const getPendingRequests = vi.fn();
const acceptRequest = vi.fn();
const rejectRequest = vi.fn();
const removeFriend = vi.fn();
const createFriendChallenge = vi.fn();
const toastSuccess = vi.fn();
const toastError = vi.fn();
type Handler = (arg: unknown) => Promise<unknown>;
function route(method: string, url: string): Handler {
let m: RegExpMatchArray | null;
if (method === 'get' && url === '/friends') return () => getFriends();
if (method === 'get' && url === '/friends/requests/pending') {
return () => getPendingRequests();
}
if (method === 'post' && (m = /^\/friends\/([^/]+)\/accept$/.exec(url))) {
return () => acceptRequest(m![1]);
}
if (method === 'post' && (m = /^\/friends\/([^/]+)\/reject$/.exec(url))) {
return () => rejectRequest(m![1]);
}
if (method === 'delete' && (m = /^\/friends\/([^/]+)$/.exec(url))) {
return () => removeFriend(m![1]);
}
if (method === 'post' && url === '/versus/friend-challenge') {
return (body) => createFriendChallenge(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'),
},
};
});
vi.mock('sonner', () => ({
toast: {
success: (...args: unknown[]) => toastSuccess(...args),
error: (...args: unknown[]) => toastError(...args),
},
}));
interface FakeSocket {
on: (event: string, cb: (...args: unknown[]) => void) => void;
off: (event: string, cb?: (...args: unknown[]) => void) => void;
emit: ReturnType<typeof vi.fn>;
trigger: (event: string, ...args: unknown[]) => void;
}
function makeFakeSocket(): FakeSocket {
const handlers = new Map<string, Set<(...args: unknown[]) => void>>();
return {
on: (event, cb) => {
const set = handlers.get(event) ?? new Set();
set.add(cb);
handlers.set(event, set);
},
off: (event, cb) => {
if (!cb) {
handlers.delete(event);
return;
}
handlers.get(event)?.delete(cb);
},
emit: vi.fn(),
trigger: (event, ...args) => {
handlers.get(event)?.forEach((cb) => cb(...args));
},
};
}
const { useAuthStore } = await import('@/stores/auth-store');
const { useFriendsStore } = await import('@/stores/friends-store');
const { useNotificationStore } = await import('@/stores/notification-store');
const { default: Friends } = await import('./Friends');
// --- Fixtures ---------------------------------------------------------------
const USER = { id: 'user-1', email: 'me@example.com', username: 'Me', isAdmin: false };
function friendEntry(overrides: Partial<FriendProfile> = {}): FriendEntry {
return {
friendshipId: 'fs-1',
friend: {
id: 'friend-1',
username: 'friendlyuser',
displayName: 'Friendly Name',
avatarUrl: null,
...overrides,
},
since: '2024-01-01T00:00:00.000Z',
};
}
function pendingEntry(overrides: Partial<FriendProfile> = {}): PendingRequest {
return {
friendshipId: 'pr-1',
from: {
id: 'pending-1',
username: 'pendinguser',
displayName: 'Pending Person',
avatarUrl: null,
...overrides,
},
sentAt: '2024-01-01T00:00:00.000Z',
};
}
function renderFriends(initialEntry = '/friends') {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/friends" element={<Friends />} />
<Route path="/login" element={<div>login route</div>} />
<Route path="/versus/lobby/:matchId" element={<div>lobby route</div>} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
useAuthStore.setState({ user: USER, isLoading: false, error: null });
useFriendsStore.getState().reset();
useNotificationStore.setState({ notifications: [], unreadCount: 0, socket: null });
getFriends.mockResolvedValue([]);
getPendingRequests.mockResolvedValue([]);
});
describe('authentication gate', () => {
it('redirects to login when nobody is signed in', async () => {
useAuthStore.setState({ user: null, isLoading: false });
renderFriends();
expect(await screen.findByText('login route')).toBeInTheDocument();
});
it('does not redirect while auth is still resolving', () => {
useAuthStore.setState({ user: null, isLoading: true });
renderFriends();
expect(screen.queryByText('login route')).toBeNull();
});
});
describe('friends list', () => {
it('shows an empty state with a link to find friends', async () => {
renderFriends();
expect(await screen.findByText('No friends yet')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Find Friends' })).toHaveAttribute(
'href',
'/friends/add',
);
});
it('lists friends with their username, and switches to the pending tab', async () => {
getFriends.mockResolvedValue([friendEntry()]);
getPendingRequests.mockResolvedValue([pendingEntry()]);
renderFriends();
expect(await screen.findByText('Friendly Name')).toBeInTheDocument();
expect(screen.getByText('@friendlyuser')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Friends (1)' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Pending (1)' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Pending (1)' }));
expect(screen.getByText('Pending Person')).toBeInTheDocument();
expect(screen.queryByText('Friendly Name')).toBeNull();
await userEvent.click(screen.getByRole('button', { name: 'Friends (1)' }));
expect(screen.getByText('Friendly Name')).toBeInTheDocument();
expect(screen.queryByText('Pending Person')).toBeNull();
});
it('falls back to the username when a friend has no display name', async () => {
getFriends.mockResolvedValue([friendEntry({ displayName: null, username: 'plainuser' })]);
renderFriends();
expect(await screen.findByText('plainuser')).toBeInTheDocument();
});
it('shows an online dot with a text alternative for a friend who is online', async () => {
getFriends.mockResolvedValue([friendEntry()]);
renderFriends();
const card = (await screen.findByText('Friendly Name')).closest<HTMLElement>('div[data-slot="card"]');
if (!card) throw new Error('card not found');
expect(within(card).queryByText('Online')).toBeNull();
useFriendsStore.getState().setOnlineFriends(['friend-1']);
expect(await within(card).findByText('Online')).toBeInTheDocument();
expect(card.querySelector('[aria-hidden="true"].bg-green-500')).not.toBeNull();
});
it('shows "No pending requests" when there are none', async () => {
renderFriends();
await userEvent.click(screen.getByRole('button', { name: /Pending/ }));
expect(screen.getByText('No pending requests')).toBeInTheDocument();
});
});
describe('pending request actions', () => {
it('accepts a request and refreshes both lists', async () => {
getPendingRequests.mockResolvedValueOnce([pendingEntry()]);
renderFriends();
await userEvent.click(screen.getByRole('button', { name: /Pending/ }));
await screen.findByText('Pending Person');
getFriends.mockResolvedValue([friendEntry()]);
getPendingRequests.mockResolvedValue([]);
await userEvent.click(screen.getByRole('button', { name: 'Accept' }));
expect(acceptRequest).toHaveBeenCalledWith('pr-1');
await waitFor(() => expect(toastSuccess).toHaveBeenCalledWith('Friend request accepted!'));
await waitFor(() => expect(screen.queryByText('Pending Person')).toBeNull());
});
it('declines a request and removes it from the list immediately', async () => {
getPendingRequests.mockResolvedValueOnce([pendingEntry()]);
renderFriends();
await userEvent.click(screen.getByRole('button', { name: /Pending/ }));
await screen.findByText('Pending Person');
await userEvent.click(screen.getByRole('button', { name: 'Decline' }));
expect(rejectRequest).toHaveBeenCalledWith('pr-1');
await waitFor(() => expect(toastSuccess).toHaveBeenCalledWith('Request declined'));
expect(screen.queryByText('Pending Person')).toBeNull();
});
});
describe('removing a friend', () => {
it('has a working remove control, and removes the friend from the list', async () => {
getFriends.mockResolvedValueOnce([friendEntry()]);
renderFriends();
const card = (await screen.findByText('Friendly Name')).closest<HTMLElement>('div[data-slot="card"]');
if (!card) throw new Error('card not found');
const removeButton = within(card).getByRole('button', {
name: 'Remove Friendly Name as a friend',
});
await userEvent.click(removeButton);
expect(removeFriend).toHaveBeenCalledWith('fs-1');
await waitFor(() => expect(toastSuccess).toHaveBeenCalledWith('Friend removed'));
await waitFor(() => expect(screen.queryByText('Friendly Name')).toBeNull());
});
// Regression: the remove-friend button rendered only an <X> icon with no
// accessible name - the one icon-only control WP-16's a11y pass missed. A
// screen-reader user heard just "button" next to a destructive action.
it('the remove-friend button announces what it does', async () => {
getFriends.mockResolvedValueOnce([friendEntry()]);
renderFriends();
await screen.findByText('Friendly Name');
expect(
screen.getByRole('button', { name: /remove friendly name/i }),
).toBeInTheDocument();
});
});
describe('presence', () => {
it('asks the server for online status once friends and a socket are present', async () => {
const socket = makeFakeSocket();
useNotificationStore.setState({ socket: socket as unknown as ReturnType<typeof useNotificationStore.getState>['socket'] });
getFriends.mockResolvedValue([
friendEntry(),
{ ...friendEntry({ id: 'friend-2', displayName: 'Second Friend' }), friendshipId: 'fs-2' },
]);
renderFriends();
await screen.findByText('Friendly Name');
await screen.findByText('Second Friend');
await waitFor(() =>
expect(socket.emit).toHaveBeenCalledWith('get-friends-online', {
friendIds: ['friend-1', 'friend-2'],
}),
);
});
it('reacts to friend-online and friend-offline events from the socket', async () => {
const socket = makeFakeSocket();
useNotificationStore.setState({ socket: socket as unknown as ReturnType<typeof useNotificationStore.getState>['socket'] });
getFriends.mockResolvedValue([friendEntry()]);
renderFriends();
const card = (await screen.findByText('Friendly Name')).closest<HTMLElement>('div[data-slot="card"]');
if (!card) throw new Error('card not found');
socket.trigger('friend-online', { userId: 'friend-1' });
expect(await within(card).findByText('Online')).toBeInTheDocument();
socket.trigger('friend-offline', { userId: 'friend-1' });
await waitFor(() => expect(within(card).queryByText('Online')).toBeNull());
});
it('replaces the whole online set on a friends-online snapshot', async () => {
const socket = makeFakeSocket();
useNotificationStore.setState({ socket: socket as unknown as ReturnType<typeof useNotificationStore.getState>['socket'] });
getFriends.mockResolvedValue([friendEntry()]);
renderFriends();
const card = (await screen.findByText('Friendly Name')).closest<HTMLElement>('div[data-slot="card"]');
if (!card) throw new Error('card not found');
socket.trigger('friends-online', { onlineIds: ['friend-1'] });
expect(await within(card).findByText('Online')).toBeInTheDocument();
});
});
describe('challenge friend dialog', () => {
async function openDialog() {
getFriends.mockResolvedValue([friendEntry()]);
renderFriends();
await userEvent.click(await screen.findByRole('button', { name: 'Challenge' }));
return screen.findByRole('dialog', { name: 'Challenge Friend' });
}
it('defaults to medium difficulty and async off', async () => {
const dialog = await openDialog();
expect(within(dialog).getByRole('button', { name: 'medium' })).toHaveAttribute(
'aria-pressed',
'true',
);
expect(within(dialog).getByLabelText('Async (play at their own pace)')).not.toBeChecked();
});
it('sends a synchronous challenge and goes to the lobby', async () => {
createFriendChallenge.mockResolvedValue({ id: 'match-1' });
const dialog = await openDialog();
await userEvent.click(within(dialog).getByRole('button', { name: 'hard' }));
await userEvent.click(within(dialog).getByRole('button', { name: 'Send Challenge' }));
expect(createFriendChallenge).toHaveBeenCalledWith({
friendId: 'friend-1',
difficulty: 'hard',
isAsync: false,
});
await waitFor(() => expect(toastSuccess).toHaveBeenCalledWith('Challenge sent!'));
expect(await screen.findByText('lobby route')).toBeInTheDocument();
});
it('sends an async challenge and stays on the friends page', async () => {
createFriendChallenge.mockResolvedValue({ id: 'match-2' });
const dialog = await openDialog();
await userEvent.click(within(dialog).getByLabelText('Async (play at their own pace)'));
await userEvent.click(within(dialog).getByRole('button', { name: 'Send Challenge' }));
await waitFor(() => expect(toastSuccess).toHaveBeenCalledWith('Challenge sent!'));
expect(screen.queryByRole('dialog')).toBeNull();
expect(screen.queryByText('lobby route')).toBeNull();
});
it('shows an error and keeps the dialog open when the challenge fails', async () => {
createFriendChallenge.mockRejectedValue({
response: { data: { message: 'Friend is offline' } },
});
const dialog = await openDialog();
await userEvent.click(within(dialog).getByRole('button', { name: 'Send Challenge' }));
await waitFor(() => expect(toastError).toHaveBeenCalledWith('Friend is offline'));
expect(screen.getByRole('dialog', { name: 'Challenge Friend' })).toBeInTheDocument();
});
it('closes on Cancel without sending a challenge', async () => {
const dialog = await openDialog();
await userEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
expect(createFriendChallenge).not.toHaveBeenCalled();
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});
it('closes on Escape without sending a challenge', async () => {
await openDialog();
await userEvent.keyboard('{Escape}');
expect(createFriendChallenge).not.toHaveBeenCalled();
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});
});
+5 -1
View File
@@ -210,12 +210,16 @@ export default function Friends() {
<Button
variant="ghost"
size="sm"
aria-label={`Remove ${friend.displayName || friend.username} as a friend`}
onClick={async () => {
await removeFriend(friendshipId);
toast.success('Friend removed');
}}
>
<X className="h-4 w-4 text-muted-foreground" />
<X
className="h-4 w-4 text-muted-foreground"
aria-hidden="true"
/>
</Button>
</div>
</CardContent>
+168
View File
@@ -0,0 +1,168 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ChainLink, MovieChainLink, ScoreBreakdown } from '@/types';
/**
* Game.tsx: redirects home when there is no game in progress (`status`
* 'idle'), and otherwise renders the live game screen (header, chain,
* search panel) or the completion modal.
*/
function unrouted(method: string, url: string): never {
throw new Error(`test reached an unrouted request: ${method} ${url}`);
}
vi.mock('@/api/client', () => {
const call = (method: string) => async (url: string) => unrouted(method, url);
return {
default: {
get: call('get'),
post: call('post'),
put: call('put'),
delete: call('delete'),
},
};
});
const { default: Game } = await import('./Game');
const { useGameStore } = await import('@/stores/game-store');
const MOVIE_A: MovieChainLink = {
type: 'movie',
id: 100,
title: 'Heat',
posterPath: null,
releaseDate: '1995-12-15',
popularity: 10,
};
const MOVIE_B: MovieChainLink = {
type: 'movie',
id: 200,
title: 'Se7en',
posterPath: null,
releaseDate: '1995-09-22',
popularity: 8,
};
const SCORE: ScoreBreakdown = {
baseScore: 1000,
chainLength: 5,
linkCount: 2,
chainLengthBonus: 800,
timeBonus: 500,
obscurityBonus: 100,
hintPenalty: 0,
duplicatePenalty: 0,
elapsedSeconds: 125,
totalScore: 2400,
};
function renderPage() {
return render(
<MemoryRouter initialEntries={['/game']}>
<Routes>
<Route path="/game" element={<Game />} />
<Route path="/" element={<div>Home Page</div>} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
useGameStore.getState().resetGame();
});
describe('no game in progress', () => {
it('redirects home instead of rendering the screen', async () => {
renderPage();
expect(await screen.findByText('Home Page')).toBeInTheDocument();
expect(screen.queryByText('Heat')).toBeNull();
});
});
describe('a game in progress', () => {
const chain: ChainLink[] = [MOVIE_A];
beforeEach(() => {
useGameStore.setState({
status: 'playing',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain,
currentSearchMode: 'actor',
passedMovieB: false,
isValidating: false,
sessionId: 's1',
});
});
it('renders the header, chain and search panel, without the completion dialog', async () => {
renderPage();
// "Heat" appears twice: once as the header's target card, once as the
// chain's starting movie.
expect(await screen.findAllByText('Heat')).toHaveLength(2);
expect(screen.getByText('Se7en')).toBeInTheDocument();
expect(
screen.getByText('Who was in "Heat"?'),
).toBeInTheDocument();
expect(screen.queryByText('Congratulations!')).toBeNull();
});
it('does not redirect while a game is playing', async () => {
renderPage();
await screen.findAllByText('Heat');
expect(screen.queryByText('Home Page')).toBeNull();
});
});
describe('a completed game', () => {
it('shows the completion dialog with the score once the loop is closed', async () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: [MOVIE_A],
passedMovieB: true,
score: SCORE,
isValidating: false,
});
renderPage();
expect(await screen.findByText('Congratulations!')).toBeInTheDocument();
expect(screen.getByText('2400')).toBeInTheDocument();
});
it('shows a calculating spinner when the score has not arrived yet', async () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: [MOVIE_A],
passedMovieB: true,
score: null,
validationError: null,
isValidating: false,
});
renderPage();
expect(await screen.findByText('Calculating score...')).toBeInTheDocument();
});
it('shows the validation error instead of a spinner when scoring failed', async () => {
useGameStore.setState({
status: 'completed',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: [MOVIE_A],
passedMovieB: true,
score: null,
validationError: 'Failed to calculate score.',
isValidating: false,
});
renderPage();
expect(await screen.findByText('Failed to calculate score.')).toBeInTheDocument();
expect(screen.queryByText('Calculating score...')).toBeNull();
});
});
+583
View File
@@ -0,0 +1,583 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route } from 'react-router';
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import type { GameNightRoom } from '@/api/game-night';
import type { TmdbMovieResult, TmdbPersonResult } from '@/types';
/**
* The Game Night lobby browser / room creation form.
*
* The audit's M-series finding was that join failures were swallowed
* silently. WP-10 fixed the password path (the error shows next to the
* prompt) but the no-password path set the same `joinError` state and
* nothing rendered it - the paragraph lived inside the `joiningRoom &&`
* block, which a no-password join never opens. Found by the test below in
* WP-19; the error now renders once, below the room list, for both paths.
*
* Network is intercepted at `@/api/client` (never `@/api/game-night`
* directly) and routed by URL, per project convention: it's the one import
* vitest mocks reliably, and nothing can reach the real backend.
*/
const createRoomApi = vi.fn();
const getOpenRoomsApi = vi.fn();
const joinRoomApi = vi.fn();
type Handler = (body?: unknown) => Promise<unknown>;
function route(method: string, url: string): Handler {
let m: RegExpMatchArray | null;
if (method === 'post' && url === '/game-night') {
return (body) => createRoomApi(body);
}
if (method === 'get' && url === '/game-night/open') {
return () => getOpenRoomsApi();
}
if (method === 'post' && (m = /^\/game-night\/([^/]+)\/join$/.exec(url))) {
return (body) => joinRoomApi(m![1], body);
}
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: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
}));
/**
* SearchAutocomplete is not part of this page's own logic (it has its own
* network/debounce lifecycle and isn't in the review's dependency list for
* this file) - it is stubbed to a single button that hands back whatever
* movie fixture the test staged, so GameNight's own selection/create logic
* can be exercised deterministically and without a debounce timer.
*/
let nextMovieSelection: TmdbMovieResult = movieResult(100, 'Movie A');
function movieResult(id: number, title: string, posterPath: string | null = '/poster.jpg'): TmdbMovieResult {
return {
id,
title,
original_title: title,
overview: '',
poster_path: posterPath,
backdrop_path: null,
release_date: '2001-02-03',
popularity: 9,
vote_average: 7,
vote_count: 100,
genre_ids: [],
adult: false,
};
}
vi.mock('@/components/game/SearchAutocomplete', () => ({
default: ({
onSelectMovie,
placeholder,
}: {
mode: 'actor' | 'movie';
onSelectActor: (p: TmdbPersonResult) => void;
onSelectMovie: (m: TmdbMovieResult) => void;
disabled?: boolean;
placeholder?: string;
}) => (
<button type="button" onClick={() => onSelectMovie(nextMovieSelection)}>
{placeholder}
</button>
),
}));
const { useAuthStore } = await import('@/stores/auth-store');
const { default: GameNight } = await import('./GameNight');
// --- Fixtures ---------------------------------------------------------------
function room(overrides: Partial<GameNightRoom> = {}): GameNightRoom {
return {
id: 'room-1',
hostId: 'host-1',
hostPlaying: true,
movieAId: 100,
movieATitle: 'Movie A',
movieBId: 200,
movieBTitle: 'Movie B',
roomName: null,
hasPassword: false,
status: 'waiting',
maxPlayers: 8,
timeoutMinutes: null,
createdAt: '2024-01-01T00:00:00.000Z',
startedAt: null,
finishedAt: null,
host: { id: 'host-1', username: 'Hosty' },
players: [],
_count: { players: 1 },
...overrides,
};
}
function deferred<T>(): {
promise: Promise<T>;
resolve: (v: T) => void;
reject: (e: unknown) => void;
} {
let resolveFn: (v: T) => void = () => undefined;
let rejectFn: (e: unknown) => void = () => undefined;
const promise = new Promise<T>((res, rej) => {
resolveFn = res;
rejectFn = rej;
});
return { promise, resolve: resolveFn, reject: rejectFn };
}
function renderPage() {
return render(
<MemoryRouter initialEntries={['/game-night']}>
<Routes>
<Route path="/game-night" element={<GameNight />} />
<Route path="/" element={<div>home page</div>} />
<Route path="/login" element={<div>login page</div>} />
<Route
path="/game-night/lobby/:roomId"
element={<div>lobby route</div>}
/>
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
getOpenRoomsApi.mockResolvedValue([]);
useAuthStore.setState({ user: null, isLoading: false, error: null });
});
afterEach(() => {
localStorage.clear();
});
function loginUser() {
useAuthStore.setState({
user: { id: 'u1', email: 'a@b.com', username: 'Player1', isAdmin: false },
isLoading: false,
error: null,
});
}
// --- Logged-out state --------------------------------------------------------
describe('logged out', () => {
it('prompts to log in and does not show the browse/create UI', async () => {
renderPage();
expect(
await screen.findByText('You need to be logged in to play Game Night.'),
).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Browse Rooms' })).toBeNull();
});
it('sends the Log In button to the login route', async () => {
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole('button', { name: 'Log In' }));
expect(await screen.findByText('login page')).toBeInTheDocument();
});
});
it('sends Back to the home route regardless of auth', async () => {
const user = userEvent.setup();
renderPage();
await user.click(screen.getByRole('button', { name: /Back/ }));
expect(await screen.findByText('home page')).toBeInTheDocument();
});
// --- Browse rooms -------------------------------------------------------------
describe('browse rooms', () => {
it('shows a loading indicator until the open-rooms fetch settles', async () => {
loginUser();
const gate = deferred<GameNightRoom[]>();
getOpenRoomsApi.mockReturnValue(gate.promise);
renderPage();
expect(screen.getByRole('button', { name: /Refresh/ })).toBeDisabled();
expect(screen.queryByText('No open rooms. Create one!')).toBeNull();
gate.resolve([]);
expect(
await screen.findByText('No open rooms. Create one!'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Refresh/ })).toBeEnabled();
});
it('re-fetches the open rooms list when Refresh is clicked', async () => {
loginUser();
const user = userEvent.setup();
renderPage();
await screen.findByText('No open rooms. Create one!');
expect(getOpenRoomsApi).toHaveBeenCalledTimes(1);
getOpenRoomsApi.mockResolvedValue([room({ roomName: 'Second Fetch' })]);
await user.click(screen.getByRole('button', { name: /Refresh/ }));
expect(await screen.findByText('Second Fetch')).toBeInTheDocument();
expect(getOpenRoomsApi).toHaveBeenCalledTimes(2);
});
it('falls back to "<host>\'s Room" when a room has no name, and shows its password lock', async () => {
loginUser();
getOpenRoomsApi.mockResolvedValue([
room({ roomName: null, host: { id: 'h', username: 'Hosty' }, hasPassword: true }),
]);
renderPage();
expect(await screen.findByText("Hosty's Room")).toBeInTheDocument();
const card = screen.getByText("Hosty's Room").closest('div');
if (!card) throw new Error('expected a room card');
expect(card.querySelector('svg')).not.toBeNull();
});
it('shows a custom room name, the player count, and a time-limit badge', async () => {
loginUser();
getOpenRoomsApi.mockResolvedValue([
room({
roomName: 'Friday Fun',
_count: { players: 3 },
maxPlayers: 6,
timeoutMinutes: 10,
}),
]);
renderPage();
expect(await screen.findByText('Friday Fun')).toBeInTheDocument();
expect(screen.getByText('3/6')).toBeInTheDocument();
expect(screen.getByText('10m')).toBeInTheDocument();
});
it('shows "?" for the player count when the server omits _count', async () => {
loginUser();
getOpenRoomsApi.mockResolvedValue([room({ _count: undefined, maxPlayers: 4 })]);
renderPage();
expect(await screen.findByText('?/4')).toBeInTheDocument();
});
it('joins a passwordless room and goes to its lobby', async () => {
loginUser();
getOpenRoomsApi.mockResolvedValue([room({ id: 'r9', hasPassword: false })]);
joinRoomApi.mockResolvedValue(room({ id: 'r9' }));
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole('button', { name: 'Join' }));
expect(joinRoomApi).toHaveBeenCalledWith('r9', { password: undefined });
expect(await screen.findByText('lobby route')).toBeInTheDocument();
});
// Regression: a failed join to an open room used to be swallowed - the
// only place `joinError` rendered was inside the password prompt, which a
// passwordless join never opens.
it(
'shows the join error for a passwordless room too, not just a password-protected one',
async () => {
loginUser();
getOpenRoomsApi.mockResolvedValue([room({ id: 'r9', hasPassword: false })]);
joinRoomApi.mockRejectedValue({
response: { data: { message: 'Room is full' } },
});
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole('button', { name: 'Join' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Room is full');
},
);
describe('password-protected room', () => {
function setUpPasswordRoom() {
getOpenRoomsApi.mockResolvedValue([room({ id: 'r5', hasPassword: true })]);
}
it('opens a password prompt instead of joining immediately', async () => {
loginUser();
setUpPasswordRoom();
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole('button', { name: 'Join' }));
expect(
screen.getByText('This room requires a password'),
).toBeInTheDocument();
expect(joinRoomApi).not.toHaveBeenCalled();
});
it('joins with the entered password and goes to the lobby', async () => {
loginUser();
setUpPasswordRoom();
joinRoomApi.mockResolvedValue(room({ id: 'r5' }));
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole('button', { name: 'Join' }));
await user.type(screen.getByLabelText('Room password'), 'secret');
const dialog = screen.getByText('This room requires a password').closest('div');
if (!dialog) throw new Error('expected the password dialog');
await user.click(within(dialog).getByRole('button', { name: 'Join' }));
expect(joinRoomApi).toHaveBeenCalledWith('r5', { password: 'secret' });
expect(await screen.findByText('lobby route')).toBeInTheDocument();
});
it('shows the server rejection inside the dialog', async () => {
loginUser();
setUpPasswordRoom();
joinRoomApi.mockRejectedValue({
response: { data: { message: 'Wrong password' } },
});
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole('button', { name: 'Join' }));
const dialog = screen.getByText('This room requires a password').closest('div');
if (!dialog) throw new Error('expected the password dialog');
await user.click(within(dialog).getByRole('button', { name: 'Join' }));
expect(await screen.findByText('Wrong password')).toBeInTheDocument();
});
it('cancels the prompt without joining', async () => {
loginUser();
setUpPasswordRoom();
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole('button', { name: 'Join' }));
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(
screen.queryByText('This room requires a password'),
).toBeNull();
expect(joinRoomApi).not.toHaveBeenCalled();
});
});
});
// --- Create room --------------------------------------------------------------
describe('create room', () => {
async function openCreateTab() {
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole('button', { name: 'Create Room' }));
return user;
}
/**
* "Create Room" names both the tab toggle (always present) and the form's
* submit button (only once the create tab is open) - the toggle is always
* the first of the two in document order.
*/
function submitButton() {
const buttons = screen.getAllByRole('button', { name: /Create Room/ });
const submit = buttons[1];
if (!submit) throw new Error('expected the Create Room submit button');
return submit;
}
beforeEach(() => {
loginUser();
nextMovieSelection = movieResult(100, 'Movie A');
});
it('disables Create Room until two distinct movies are chosen', async () => {
const user = await openCreateTab();
expect(submitButton()).toBeDisabled();
nextMovieSelection = movieResult(100, 'Movie A');
await user.click(screen.getByRole('button', { name: 'Search for Movie A...' }));
expect(submitButton()).toBeDisabled();
nextMovieSelection = movieResult(200, 'Movie B');
await user.click(screen.getByRole('button', { name: 'Search for Movie B...' }));
expect(submitButton()).toBeEnabled();
});
it('shows a poster once a movie is picked, and clears it via Change', async () => {
const user = await openCreateTab();
await user.click(screen.getByRole('button', { name: 'Search for Movie A...' }));
const group = screen.getByRole('group', { name: 'Movie A' });
expect(within(group).getByRole('img', { name: 'Movie A' })).toHaveAttribute(
'src',
expect.stringContaining('/w92/poster.jpg'),
);
await user.click(within(group).getByRole('button', { name: 'Change' }));
expect(
screen.getByRole('button', { name: 'Search for Movie A...' }),
).toBeInTheDocument();
});
it('omits the poster image when the movie has none', async () => {
const user = await openCreateTab();
nextMovieSelection = movieResult(100, 'Movie A', null);
await user.click(screen.getByRole('button', { name: 'Search for Movie A...' }));
const group = screen.getByRole('group', { name: 'Movie A' });
expect(within(group).queryByRole('img')).toBeNull();
});
it('clears movie B via its own Change button', async () => {
const user = await openCreateTab();
nextMovieSelection = movieResult(200, 'Movie B');
await user.click(screen.getByRole('button', { name: 'Search for Movie B...' }));
const group = screen.getByRole('group', { name: 'Movie B' });
expect(within(group).getByRole('button', { name: 'Change' })).toBeInTheDocument();
await user.click(within(group).getByRole('button', { name: 'Change' }));
expect(
screen.getByRole('button', { name: 'Search for Movie B...' }),
).toBeInTheDocument();
});
it('returns to the browse tab from the create tab', async () => {
const user = await openCreateTab();
expect(screen.getByRole('heading', { name: 'Create Room' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Browse Rooms' }));
expect(screen.getByText('Open Rooms')).toBeInTheDocument();
});
it('warns when the same movie is chosen for both slots', async () => {
const user = await openCreateTab();
nextMovieSelection = movieResult(100, 'Same Movie');
await user.click(screen.getByRole('button', { name: 'Search for Movie A...' }));
nextMovieSelection = movieResult(100, 'Same Movie');
await user.click(screen.getByRole('button', { name: 'Search for Movie B...' }));
expect(
screen.getByText('Please select two different movies.'),
).toBeInTheDocument();
expect(submitButton()).toBeDisabled();
});
it('toggles "I want to play" between Playing and Spectating', async () => {
const user = await openCreateTab();
const toggle = screen.getByRole('switch', { name: 'I want to play' });
expect(toggle).toHaveAttribute('aria-checked', 'true');
expect(screen.getByText('Playing')).toBeInTheDocument();
await user.click(toggle);
expect(toggle).toHaveAttribute('aria-checked', 'false');
expect(screen.getByText('Spectating')).toBeInTheDocument();
});
it('updates the max-players label as the slider moves', async () => {
await openCreateTab();
const slider = screen.getByLabelText(/Max Players:/);
// userEvent has no reliable range-input driver; fireEvent is standard here.
const { fireEvent } = await import('@testing-library/react');
fireEvent.change(slider, { target: { value: '12' } });
expect(screen.getByText('Max Players: 12')).toBeInTheDocument();
});
it('creates a room with the selected options and goes to its lobby', async () => {
const user = await openCreateTab();
nextMovieSelection = movieResult(100, 'Movie A');
await user.click(screen.getByRole('button', { name: 'Search for Movie A...' }));
nextMovieSelection = movieResult(200, 'Movie B');
await user.click(screen.getByRole('button', { name: 'Search for Movie B...' }));
await user.type(screen.getByLabelText('Room Name (optional)'), 'Fam Night');
await user.type(screen.getByLabelText('Password (optional)'), 'shh');
await user.click(screen.getByRole('switch', { name: 'I want to play' })); // -> spectating
await user.click(screen.getByRole('button', { name: '10 min' }));
createRoomApi.mockResolvedValue(room({ id: 'new-room' }));
await user.click(submitButton());
expect(createRoomApi).toHaveBeenCalledWith({
movieAId: 100,
movieATitle: 'Movie A',
movieBId: 200,
movieBTitle: 'Movie B',
hostPlaying: false,
roomName: 'Fam Night',
password: 'shh',
maxPlayers: 8,
timeoutMinutes: 10,
});
expect(await screen.findByText('lobby route')).toBeInTheDocument();
});
it('omits room name, password and time limit when left at their defaults', async () => {
const user = await openCreateTab();
nextMovieSelection = movieResult(100, 'Movie A');
await user.click(screen.getByRole('button', { name: 'Search for Movie A...' }));
nextMovieSelection = movieResult(200, 'Movie B');
await user.click(screen.getByRole('button', { name: 'Search for Movie B...' }));
createRoomApi.mockResolvedValue(room({ id: 'new-room' }));
await user.click(submitButton());
const body = createRoomApi.mock.calls[0]?.[0] as {
roomName?: string;
password?: string;
timeoutMinutes?: number;
};
expect(body.roomName).toBeUndefined();
expect(body.password).toBeUndefined();
expect(body.timeoutMinutes).toBeUndefined();
});
it('shows "Creating..." and disables the button while the request is in flight', async () => {
const user = await openCreateTab();
nextMovieSelection = movieResult(100, 'Movie A');
await user.click(screen.getByRole('button', { name: 'Search for Movie A...' }));
nextMovieSelection = movieResult(200, 'Movie B');
await user.click(screen.getByRole('button', { name: 'Search for Movie B...' }));
const gate = deferred<GameNightRoom>();
createRoomApi.mockReturnValue(gate.promise);
await user.click(submitButton());
const button = screen.getByRole('button', { name: /Creating.../ });
expect(button).toBeDisabled();
gate.resolve(room({ id: 'new-room' }));
expect(await screen.findByText('lobby route')).toBeInTheDocument();
});
it('re-enables the form when room creation fails', async () => {
const user = await openCreateTab();
nextMovieSelection = movieResult(100, 'Movie A');
await user.click(screen.getByRole('button', { name: 'Search for Movie A...' }));
nextMovieSelection = movieResult(200, 'Movie B');
await user.click(screen.getByRole('button', { name: 'Search for Movie B...' }));
createRoomApi.mockRejectedValue(new Error('500'));
await user.click(submitButton());
await waitFor(() => expect(submitButton()).toBeEnabled());
expect(screen.queryByText('lobby route')).toBeNull();
});
});
+11 -3
View File
@@ -125,6 +125,7 @@ export default function GameNight() {
setJoinError('');
return;
}
setJoinError('');
try {
await joinRoom(roomId);
navigate(`/game-night/lobby/${roomId}`);
@@ -266,6 +267,16 @@ export default function GameNight() {
))}
</div>
{/* Rendered here, not inside the password prompt: a failed join
to an open room used to set this and show nothing, because
the only place it was displayed only exists while a password
is being asked for. */}
{joinError && (
<p role="alert" className="text-sm text-destructive">
{joinError}
</p>
)}
{/* Password prompt dialog */}
{joiningRoom && (
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
@@ -283,9 +294,6 @@ export default function GameNight() {
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="Enter password..."
/>
{joinError && (
<p className="text-sm text-destructive">{joinError}</p>
)}
<div className="flex gap-2">
<Button size="sm" onClick={handleJoinWithPassword}>
Join
+440
View File
@@ -0,0 +1,440 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GameNightPlayer, GameNightRoom } from '@/api/game-night';
import type { StartResult } from '@/stores/game-store';
/**
* The Game Night lobby: connect + join, the player roster, ready toggling,
* host-only start, the assertive countdown, spectator handling and the
* navigate-on-game-start / navigate-on-cancel flows.
*
* The socket is mocked at `@/lib/create-socket` (per project convention) as a
* small fake that records registered handlers and can `fire` them, mirroring
* `stores/game-night-store.test.ts`. Everything else - auth, game-night and
* game stores - is the real store, seeded via `setState`/`getInitialState`.
*/
type Listener = (...args: unknown[]) => void;
function makeFakeSocket() {
const handlers = new Map<string, Listener[]>();
const socket = {
id: 'sock-1',
connected: false,
on: vi.fn((event: string, handler: Listener) => {
const arr = handlers.get(event) ?? [];
arr.push(handler);
handlers.set(event, arr);
return socket;
}),
off: vi.fn((event: string, handler?: Listener) => {
if (!handler) {
handlers.delete(event);
return socket;
}
handlers.set(event, (handlers.get(event) ?? []).filter((h) => h !== handler));
return socket;
}),
once: vi.fn(),
emit: vi.fn(),
disconnect: vi.fn(),
connect: vi.fn(),
removeAllListeners: vi.fn(() => handlers.clear()),
fire(event: string, ...args: unknown[]) {
for (const handler of handlers.get(event) ?? []) handler(...args);
},
};
return socket;
}
type FakeSocket = ReturnType<typeof makeFakeSocket>;
let currentSocket: FakeSocket | null = null;
function socket(): FakeSocket {
if (!currentSocket) throw new Error('expected a socket to have been created');
return currentSocket;
}
const createSocketMock = vi.fn<(namespace: string) => FakeSocket | null>();
vi.mock('@/lib/create-socket', () => ({
createSocket: (namespace: string) => createSocketMock(namespace),
}));
const toastError = vi.fn();
vi.mock('sonner', () => ({
toast: { error: (...args: unknown[]) => toastError(...args) },
}));
const { useAuthStore } = await import('@/stores/auth-store');
const { useGameStore } = await import('@/stores/game-store');
const { useGameNightStore } = await import('@/stores/game-night-store');
const { default: GameNightLobby } = await import('./GameNightLobby');
// --- Fixtures ---------------------------------------------------------------
function player(overrides: Partial<GameNightPlayer> = {}): GameNightPlayer {
return {
id: 'p1',
userId: 'u1',
isReady: false,
isHost: false,
isSpectator: false,
score: null,
chainLength: null,
completed: false,
forfeited: false,
timedOut: false,
disconnected: false,
joinedAt: '2024-01-01T00:00:00.000Z',
completedAt: null,
user: { id: 'u1', username: 'Alice' },
...overrides,
};
}
function room(overrides: Partial<GameNightRoom> = {}): GameNightRoom {
return {
id: 'room-1',
hostId: 'u1',
hostPlaying: true,
movieAId: 100,
movieATitle: 'Movie A',
movieBId: 200,
movieBTitle: 'Movie B',
roomName: null,
hasPassword: false,
status: 'waiting',
maxPlayers: 8,
timeoutMinutes: null,
createdAt: '2024-01-01T00:00:00.000Z',
startedAt: null,
finishedAt: null,
host: { id: 'u1', username: 'Alice' },
players: [player()],
...overrides,
};
}
function renderLobby(entry = '/game-night/lobby/room-1') {
return render(
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/game-night/lobby/:roomId" element={<GameNightLobby />} />
<Route path="/game-night/lobby-no-id" element={<GameNightLobby />} />
<Route path="/game-night/play" element={<div>play route</div>} />
<Route path="/game-night" element={<div>game night home</div>} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
currentSocket = null;
createSocketMock.mockImplementation(() => {
currentSocket = makeFakeSocket();
return currentSocket;
});
localStorage.setItem('auth_token', 'tok');
useAuthStore.setState({
user: { id: 'u1', email: 'a@b.com', username: 'Alice', isAdmin: false },
isLoading: false,
error: null,
});
useGameStore.getState().resetGame();
useGameNightStore.setState(useGameNightStore.getInitialState());
});
afterEach(() => {
localStorage.clear();
});
function connectAndJoin(r: GameNightRoom = room()) {
act(() => {
socket().fire('connect');
socket().fire('room-state', { room: r });
});
}
// --- Auth gating --------------------------------------------------------------
describe('auth gating', () => {
it('waits for auth to settle without connecting or navigating', () => {
useAuthStore.setState({ user: null, isLoading: true, error: null });
renderLobby();
expect(createSocketMock).not.toHaveBeenCalled();
expect(screen.queryByText('game night home')).toBeNull();
});
it('redirects to /game-night when nobody is signed in', async () => {
useAuthStore.setState({ user: null, isLoading: false, error: null });
renderLobby();
expect(await screen.findByText('game night home')).toBeInTheDocument();
expect(createSocketMock).not.toHaveBeenCalled();
});
it('redirects to /game-night when the route has no room id', async () => {
renderLobby('/game-night/lobby-no-id');
expect(await screen.findByText('game night home')).toBeInTheDocument();
});
});
// --- Connecting -----------------------------------------------------------
describe('connecting', () => {
it('shows a connecting indicator and joins the room once the socket connects', async () => {
renderLobby();
expect(createSocketMock).toHaveBeenCalledWith('/game-night');
expect(screen.getByText('Connecting...')).toBeInTheDocument();
act(() => socket().fire('connect'));
expect(socket().emit).toHaveBeenCalledWith('join-room', { roomId: 'room-1' });
});
});
// --- Lobby state: roster and host controls ------------------------------------
describe('lobby - non-host', () => {
it('shows the ready toggle for the current player and waits on the host', async () => {
const user = userEvent.setup();
renderLobby();
connectAndJoin(
room({
hostId: 'host-2',
roomName: 'Fun Room',
maxPlayers: 4,
timeoutMinutes: 15,
players: [
player({ id: 'p1', userId: 'u1', isHost: false, isReady: false }),
player({ id: 'p2', userId: 'host-2', isHost: true, isReady: true, user: { id: 'host-2', username: 'Hosty' } }),
],
}),
);
expect(await screen.findByText('Fun Room')).toBeInTheDocument();
expect(screen.getByText('2/4')).toBeInTheDocument();
expect(screen.getByText('15m limit')).toBeInTheDocument();
expect(screen.getByText('Waiting for host to start...')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Start Game/ })).toBeNull();
await user.click(screen.getByRole('button', { name: 'Ready' }));
expect(socket().emit).toHaveBeenCalledWith('toggle-ready', { roomId: 'room-1' });
});
it('leaves the room, resets state and navigates home', async () => {
const user = userEvent.setup();
renderLobby();
connectAndJoin(
room({
hostId: 'host-2',
players: [
player({ userId: 'u1', isHost: false }),
player({ id: 'p2', userId: 'host-2', isHost: true, user: { id: 'host-2', username: 'Hosty' } }),
],
}),
);
await user.click(await screen.findByRole('button', { name: /Leave Room/ }));
expect(socket().emit).toHaveBeenCalledWith('leave-room', { roomId: 'room-1' });
expect(await screen.findByText('game night home')).toBeInTheDocument();
});
it('shows the spectator badge when the room marks the current user a spectator', async () => {
renderLobby();
connectAndJoin(
room({
players: [player({ userId: 'u1', isSpectator: true })],
}),
);
// Both the lobby header's own status badge and the player's roster card
// render the word "Spectator" - assert at least one is on screen rather
// than assuming a single match.
await screen.findByText('Game Night Lobby');
expect(screen.getAllByText('Spectator').length).toBeGreaterThan(0);
});
});
describe('lobby - host', () => {
it('disables Start Game and explains why with fewer than two active players', async () => {
renderLobby();
connectAndJoin(room({ hostId: 'u1', players: [player({ userId: 'u1', isHost: true })] }));
const startButton = await screen.findByRole('button', { name: /Start Game/ });
expect(startButton).toBeDisabled();
expect(
screen.getByText('Need at least 2 active players'),
).toBeInTheDocument();
});
it('does not count a spectator toward the active-player minimum', async () => {
renderLobby();
connectAndJoin(
room({
hostId: 'u1',
players: [
player({ userId: 'u1', isHost: true }),
player({ id: 'p2', userId: 'u2', isSpectator: true, user: { id: 'u2', username: 'Spec' } }),
],
}),
);
expect(await screen.findByText('Need at least 2 active players')).toBeInTheDocument();
});
it('explains it is waiting on ready-up once there are enough players', async () => {
renderLobby();
connectAndJoin(
room({
hostId: 'u1',
players: [
player({ userId: 'u1', isHost: true }),
player({ id: 'p2', userId: 'u2', isReady: false, user: { id: 'u2', username: 'Bob' } }),
],
}),
);
expect(await screen.findByText('Waiting for all players to ready up')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Start Game/ })).toBeDisabled();
});
it('enables Start Game once all non-host players are ready, and starts the countdown', async () => {
const user = userEvent.setup();
renderLobby();
connectAndJoin(
room({
hostId: 'u1',
players: [
player({ userId: 'u1', isHost: true }),
player({ id: 'p2', userId: 'u2', isReady: true, user: { id: 'u2', username: 'Bob' } }),
],
}),
);
const startButton = await screen.findByRole('button', { name: /Start Game/ });
expect(startButton).toBeEnabled();
await user.click(startButton);
expect(socket().emit).toHaveBeenCalledWith('start-countdown', { roomId: 'room-1' });
});
it('cancels the room as host', async () => {
const user = userEvent.setup();
renderLobby();
connectAndJoin(room({ hostId: 'u1', players: [player({ userId: 'u1', isHost: true })] }));
await user.click(await screen.findByRole('button', { name: /Cancel Room/ }));
expect(socket().emit).toHaveBeenCalledWith('leave-room', { roomId: 'room-1' });
expect(await screen.findByText('game night home')).toBeInTheDocument();
});
});
// --- Countdown ----------------------------------------------------------------
describe('countdown', () => {
it('announces the countdown assertively and hides the lobby roster', async () => {
renderLobby();
connectAndJoin(room({ hostId: 'u1', players: [player({ userId: 'u1', isHost: true })] }));
await screen.findByRole('button', { name: /Start Game/ });
act(() => socket().fire('countdown', { value: 3 }));
const timer = screen.getByRole('timer');
expect(timer).toHaveAttribute('aria-live', 'assertive');
expect(timer).toHaveTextContent('3');
expect(screen.getByText('Get ready...')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Start Game/ })).toBeNull();
});
});
// --- game-start handling --------------------------------------------------
describe('game-start', () => {
const payload = {
movieA: { id: 100, title: 'Movie A' },
movieB: { id: 200, title: 'Movie B' },
startTime: 1_000,
};
it('navigates to the play screen once the match starts', async () => {
const startSpy = vi.fn<() => Promise<StartResult>>().mockResolvedValue('started');
useGameStore.setState({ startGameFromVersus: startSpy });
renderLobby();
connectAndJoin();
await act(async () => {
socket().fire('game-start', payload);
await Promise.resolve();
await Promise.resolve();
});
expect(startSpy).toHaveBeenCalledWith(
{ movieA: payload.movieA, movieB: payload.movieB },
1_000,
);
expect(await screen.findByText('play route')).toBeInTheDocument();
});
it('stays quiet and does not navigate when a newer start supersedes this one', async () => {
const startSpy = vi.fn<() => Promise<StartResult>>().mockResolvedValue('superseded');
useGameStore.setState({ startGameFromVersus: startSpy });
renderLobby();
connectAndJoin();
await act(async () => {
socket().fire('game-start', payload);
await Promise.resolve();
await Promise.resolve();
});
expect(toastError).not.toHaveBeenCalled();
expect(screen.queryByText('play route')).toBeNull();
});
it('shows an error toast and stays put when the match fails to start', async () => {
const startSpy = vi.fn<() => Promise<StartResult>>().mockResolvedValue('failed');
useGameStore.setState({ startGameFromVersus: startSpy });
renderLobby();
connectAndJoin();
await act(async () => {
socket().fire('game-start', payload);
await Promise.resolve();
await Promise.resolve();
});
expect(toastError).toHaveBeenCalledWith('Could not start the game. Please try again.');
expect(screen.queryByText('play route')).toBeNull();
});
});
// --- room cancellation ------------------------------------------------------
describe('room-cancelled', () => {
it('navigates back to Game Night when the room is cancelled', async () => {
renderLobby();
connectAndJoin();
act(() => socket().fire('room-cancelled'));
expect(await screen.findByText('game night home')).toBeInTheDocument();
});
});
// --- cleanup ------------------------------------------------------------------
describe('unmount', () => {
it('unregisters its socket listeners', async () => {
const { unmount } = renderLobby();
connectAndJoin();
await screen.findByText(/Waiting for host|Start Game/);
unmount();
expect(socket().off).toHaveBeenCalledWith('game-start', expect.any(Function));
expect(socket().off).toHaveBeenCalledWith('room-cancelled', expect.any(Function));
});
});
+330
View File
@@ -0,0 +1,330 @@
import { act, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MovieChainLink, ScoreBreakdown } from '@/types';
/**
* The live Game Night match screen: the idle redirect, wiring the players
* sidebar, broadcasting chain progress, sending completion (guarded by
* `!isSpectator`), forfeiting, and the completion modal's "leave" escape
* hatch for a player stuck waiting on slower opponents.
*
* `sendChainUpdate`/`sendPlayerComplete`/`sendPlayerForfeit` are swapped for
* spies via `setState` on the real game-night store: whether *those*
* correctly emit over a socket is `stores/game-night-store.test.ts`'s job;
* this page's job is calling them with the right arguments under the right
* conditions, which needs no socket at all. `sonner` is mocked per
* convention even though this page does not call it directly - a
* descendant (`useChainValidation`, reached via `useGameScreen`) imports it.
*/
vi.mock('sonner', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
}));
const { useAuthStore } = await import('@/stores/auth-store');
const { useGameStore } = await import('@/stores/game-store');
const { useGameNightStore } = await import('@/stores/game-night-store');
const { default: GameNightPlay } = await import('./GameNightPlay');
// --- Fixtures ---------------------------------------------------------------
const MOVIE_A: MovieChainLink = {
type: 'movie',
id: 100,
title: 'Movie A',
posterPath: null,
releaseDate: '2000-01-01',
popularity: 5,
};
const MOVIE_B: MovieChainLink = {
type: 'movie',
id: 200,
title: 'Movie B',
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 renderPage(entry = '/game-night/play') {
return render(
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/game-night/play" element={<GameNightPlay />} />
<Route path="/game-night" element={<div>game night home</div>} />
<Route path="/game-night/r1/results" element={<div>results route</div>} />
</Routes>
</MemoryRouter>,
);
}
let sendChainUpdate: ReturnType<typeof vi.fn<(chainLength: number) => void>>;
let sendPlayerComplete: ReturnType<typeof vi.fn<(sessionId: string) => void>>;
let sendPlayerForfeit: ReturnType<typeof vi.fn<() => void>>;
beforeEach(() => {
vi.clearAllMocks();
sendChainUpdate = vi.fn<(chainLength: number) => void>();
sendPlayerComplete = vi.fn<(sessionId: string) => void>();
sendPlayerForfeit = vi.fn<() => void>();
useAuthStore.setState({
user: { id: 'u1', email: 'a@b.com', username: 'Alice', isAdmin: false },
isLoading: false,
error: null,
});
useGameStore.getState().resetGame();
useGameNightStore.setState({
...useGameNightStore.getInitialState(),
sendChainUpdate,
sendPlayerComplete,
sendPlayerForfeit,
});
});
afterEach(() => {
localStorage.clear();
});
function playingState(overrides: Partial<ReturnType<typeof useGameStore.getState>> = {}) {
useGameStore.setState({
status: 'playing',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: [MOVIE_A],
...overrides,
});
}
// --- Idle redirect ------------------------------------------------------------
describe('idle state', () => {
it('renders nothing and redirects to Game Night when there is no game in progress', async () => {
renderPage();
expect(await screen.findByText('game night home')).toBeInTheDocument();
});
});
// --- Rendering while playing ----------------------------------------------
describe('while playing', () => {
it('shows the search panel and forfeit control for an active (non-spectator) player', () => {
playingState();
useGameNightStore.setState({ isSpectator: false });
renderPage();
expect(screen.getByRole('button', { name: /I Give Up/ })).toBeInTheDocument();
});
it('hides the search panel and forfeit control for a spectator', () => {
playingState();
useGameNightStore.setState({ isSpectator: true });
renderPage();
expect(screen.queryByRole('button', { name: /I Give Up/ })).toBeNull();
});
it('sends a forfeit when "I Give Up" is clicked', async () => {
playingState();
useGameNightStore.setState({ isSpectator: false });
const user = userEvent.setup();
renderPage();
await user.click(screen.getByRole('button', { name: /I Give Up/ }));
expect(sendPlayerForfeit).toHaveBeenCalledTimes(1);
});
it('renders the players sidebar for both the desktop and mobile layouts', () => {
playingState();
useGameNightStore.setState({
isSpectator: false,
players: [
{
id: 'p1',
userId: 'u1',
isReady: true,
isHost: true,
isSpectator: false,
score: null,
chainLength: null,
completed: false,
forfeited: false,
timedOut: false,
disconnected: false,
joinedAt: '2024-01-01T00:00:00.000Z',
completedAt: null,
user: { id: 'u1', username: 'Alice' },
},
],
});
renderPage();
// PlayersProgress renders once for desktop and once for mobile; scope to
// <main> so PageLayout's own "Alice" (the signed-in nav link) is excluded.
expect(within(screen.getByRole('main')).getAllByText('Alice')).toHaveLength(2);
});
});
// --- chain-update broadcast ----------------------------------------------
describe('chain-update broadcast', () => {
it('broadcasts the chain length on mount and whenever it grows', () => {
playingState({ chain: [MOVIE_A] });
useGameNightStore.setState({ isSpectator: false });
renderPage();
expect(sendChainUpdate).toHaveBeenCalledWith(1);
act(() => {
useGameStore.setState({ chain: [MOVIE_A, MOVIE_A, MOVIE_A] });
});
expect(sendChainUpdate).toHaveBeenLastCalledWith(3);
});
it('never broadcasts for a spectator', () => {
playingState({ chain: [MOVIE_A] });
useGameNightStore.setState({ isSpectator: true });
renderPage();
act(() => {
useGameStore.setState({ chain: [MOVIE_A, MOVIE_A, MOVIE_A] });
});
expect(sendChainUpdate).not.toHaveBeenCalled();
});
});
// --- player-complete guard --------------------------------------------------
describe('player-complete on finishing the chain', () => {
function completedState(overrides: Partial<ReturnType<typeof useGameStore.getState>> = {}) {
playingState({
status: 'completed',
score: SCORE,
sessionId: 's1',
...overrides,
});
}
it('sends completion once status, score and session are all present', () => {
completedState();
useGameNightStore.setState({ isSpectator: false });
renderPage();
expect(sendPlayerComplete).toHaveBeenCalledWith('s1');
});
it('does not send completion for a spectator', () => {
completedState();
useGameNightStore.setState({ isSpectator: true });
renderPage();
expect(sendPlayerComplete).not.toHaveBeenCalled();
});
it('does not send completion before a score has arrived', () => {
completedState({ score: null });
useGameNightStore.setState({ isSpectator: false });
renderPage();
expect(sendPlayerComplete).not.toHaveBeenCalled();
});
it('does not send completion without a session id', () => {
completedState({ sessionId: null });
useGameNightStore.setState({ isSpectator: false });
renderPage();
expect(sendPlayerComplete).not.toHaveBeenCalled();
});
});
// --- lobbyState 'finished' redirect ------------------------------------------
describe('room finished', () => {
it('navigates to the results page once the room is finished', async () => {
playingState({ status: 'completed', score: SCORE, sessionId: 's1' });
useGameNightStore.setState({ isSpectator: false, lobbyState: 'finished', roomId: 'r1' });
renderPage();
expect(await screen.findByText('results route')).toBeInTheDocument();
});
it('does not navigate to results without a room id', () => {
playingState({ status: 'completed', score: SCORE, sessionId: 's1' });
useGameNightStore.setState({ isSpectator: false, lobbyState: 'finished', roomId: null });
renderPage();
expect(screen.queryByText('results route')).toBeNull();
});
});
// --- completion modal escape hatch ------------------------------------------
describe('completion modal', () => {
it('lets a finished player leave while waiting on slower opponents', async () => {
playingState({ status: 'completed', score: SCORE, sessionId: 's1' });
useGameNightStore.setState({
isSpectator: false,
players: [
{
id: 'p1',
userId: 'u1',
isReady: true,
isHost: true,
isSpectator: false,
score: null,
chainLength: null,
completed: true,
forfeited: false,
timedOut: false,
disconnected: false,
joinedAt: '2024-01-01T00:00:00.000Z',
completedAt: null,
user: { id: 'u1', username: 'Alice' },
},
{
id: 'p2',
userId: 'u2',
isReady: true,
isHost: false,
isSpectator: false,
score: null,
chainLength: null,
completed: false,
forfeited: false,
timedOut: false,
disconnected: false,
joinedAt: '2024-01-01T00:00:00.000Z',
completedAt: null,
user: { id: 'u2', username: 'Bob' },
},
],
finishedPlayers: new Set(['u1']),
});
const user = userEvent.setup();
renderPage();
expect(screen.getByText(/Waiting for 1 player/)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Leave game' }));
expect(await screen.findByText('game night home')).toBeInTheDocument();
});
});
+336
View File
@@ -0,0 +1,336 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GameNightPlayer, GameNightResults as Results } from '@/api/game-night';
/**
* The Game Night results page: the ranking table, the host-flattened
* `_count`-free player rows, and the distinction between "still loading",
* "the fetch genuinely came back empty/failed" and "the store already had
* results from the socket, so never fetch at all".
*
* Network is intercepted at `@/api/client`, never `@/api/game-night`
* directly, and routed by URL - the same convention as the store and hook
* tests.
*/
const getRoomResultsApi = vi.fn();
type Handler = (body?: unknown) => Promise<unknown>;
function route(method: string, url: string): Handler {
const m = /^\/game-night\/([^/]+)\/results$/.exec(url);
if (method === 'get' && m) {
return () => getRoomResultsApi(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'),
},
};
});
const { useGameNightStore } = await import('@/stores/game-night-store');
const { default: GameNightResults } = await import('./GameNightResults');
// --- Fixtures ---------------------------------------------------------------
function rankedPlayer(overrides: Partial<GameNightPlayer & { rank: number }> = {}): GameNightPlayer & { rank: number } {
return {
id: 'p1',
userId: 'u1',
isReady: true,
isHost: false,
isSpectator: false,
score: { totalScore: 1500 },
chainLength: 3,
completed: true,
forfeited: false,
timedOut: false,
disconnected: false,
joinedAt: '2024-01-01T00:00:00.000Z',
completedAt: '2024-01-01T00:02:05.000Z',
user: { id: 'u1', username: 'Alice' },
rank: 1,
...overrides,
};
}
function results(overrides: Partial<Results> = {}): Results {
return {
room: {
id: 'room-1',
roomName: null,
movieAId: 100,
movieATitle: 'Movie A',
movieBId: 200,
movieBTitle: 'Movie B',
startedAt: '2024-01-01T00:00:00.000Z',
finishedAt: '2024-01-01T00:05:00.000Z',
status: 'finished',
},
host: { id: 'u1', username: 'Alice' },
rankings: {
byScore: [rankedPlayer()],
byTime: [rankedPlayer()],
byLinks: [rankedPlayer()],
},
...overrides,
};
}
function deferred<T>(): { promise: Promise<T>; resolve: (v: T) => void; reject: (e: unknown) => void } {
let resolveFn: (v: T) => void = () => undefined;
let rejectFn: (e: unknown) => void = () => undefined;
const promise = new Promise<T>((res, rej) => {
resolveFn = res;
rejectFn = rej;
});
return { promise, resolve: resolveFn, reject: rejectFn };
}
function renderPage(entry = '/game-night/room-1/results') {
return render(
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/game-night/:roomId/results" element={<GameNightResults />} />
<Route path="/game-night/no-id/results" element={<GameNightResults />} />
<Route path="/game-night" element={<div>game night home</div>} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
useGameNightStore.setState(useGameNightStore.getInitialState());
});
afterEach(() => {
localStorage.clear();
});
// --- Loading vs not-found vs results ------------------------------------------
describe('loading state', () => {
it('shows no content until the fetch settles', async () => {
const gate = deferred<Results>();
getRoomResultsApi.mockReturnValue(gate.promise);
renderPage();
expect(screen.queryByRole('button', { name: 'Back to Game Night' })).toBeNull();
expect(screen.queryByText('Results not found.')).toBeNull();
gate.resolve(results());
expect(await screen.findByText(/Results$/)).toBeInTheDocument();
});
it('never fetches when the route has no room id, and stays loading', async () => {
renderPage('/game-night/no-id/results');
await Promise.resolve();
expect(getRoomResultsApi).not.toHaveBeenCalled();
expect(screen.queryByText('Results not found.')).toBeNull();
});
});
describe('not-found state', () => {
it('shows a not-found message when the fetch fails, without resetting the store', async () => {
getRoomResultsApi.mockRejectedValue(new Error('500'));
useGameNightStore.setState({ isSpectator: true });
const user = userEvent.setup();
renderPage();
expect(await screen.findByText('Results not found.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Back to Game Night' }));
expect(await screen.findByText('game night home')).toBeInTheDocument();
// This "Back" button does not call reset() - only the header/footer
// buttons on the results view do (via handlePlayAgain).
expect(useGameNightStore.getState().isSpectator).toBe(true);
});
});
describe('results already in the store', () => {
it('renders immediately from the store without fetching', async () => {
useGameNightStore.setState({ results: results({ room: { ...results().room, roomName: 'Stored Room' } }) });
renderPage();
expect(await screen.findByText('Stored Room Results')).toBeInTheDocument();
expect(getRoomResultsApi).not.toHaveBeenCalled();
});
});
// --- Rendered results ----------------------------------------------------------
describe('results view', () => {
it('shows the room title (or a fallback) and the movie pairing', async () => {
getRoomResultsApi.mockResolvedValue(results({ room: { ...results().room, roomName: null } }));
renderPage();
expect(await screen.findByText('Game Night Results')).toBeInTheDocument();
expect(screen.getByText(/Movie A.*Movie B/)).toBeInTheDocument();
});
it('crowns rank 1 and numbers the rest', async () => {
getRoomResultsApi.mockResolvedValue(
results({
rankings: {
byScore: [
rankedPlayer({ id: 'p1', userId: 'u1', rank: 1, user: { id: 'u1', username: 'Winner' } }),
rankedPlayer({ id: 'p2', userId: 'u2', rank: 2, user: { id: 'u2', username: 'Runner Up' } }),
],
byTime: [],
byLinks: [],
},
}),
);
renderPage();
await screen.findByText('Winner');
expect(screen.getByText('#2')).toBeInTheDocument();
});
it('shows forfeited and timed-out badges', async () => {
getRoomResultsApi.mockResolvedValue(
results({
rankings: {
byScore: [
rankedPlayer({ forfeited: true }),
rankedPlayer({ id: 'p2', userId: 'u2', rank: 2, timedOut: true, user: { id: 'u2', username: 'Slowpoke' } }),
],
byTime: [],
byLinks: [],
},
}),
);
renderPage();
await screen.findByText('Alice');
expect(screen.getByText('Forfeited')).toBeInTheDocument();
expect(screen.getByText('Timed Out')).toBeInTheDocument();
});
it('defaults a missing score to 0 and a missing chain length to a dash', async () => {
getRoomResultsApi.mockResolvedValue(
results({
rankings: {
byScore: [rankedPlayer({ score: null })],
byTime: [],
byLinks: [rankedPlayer({ chainLength: null })],
},
}),
);
const user = userEvent.setup();
renderPage();
await screen.findByText('Alice');
expect(screen.getByText('0')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'By Links' }));
expect(screen.getByText('- links')).toBeInTheDocument();
});
it('switches ranking tabs and shows an empty state for a tab with no rankings', async () => {
getRoomResultsApi.mockResolvedValue(
results({
rankings: {
byScore: [rankedPlayer()],
byTime: [rankedPlayer()],
byLinks: [],
},
}),
);
const user = userEvent.setup();
renderPage();
await screen.findByText('1500');
await user.click(screen.getByRole('button', { name: 'By Links' }));
expect(
screen.getByText('No results to show for this ranking.'),
).toBeInTheDocument();
});
it('computes elapsed seconds on the time tab, and omits it when completedAt is missing', async () => {
getRoomResultsApi.mockResolvedValue(
results({
room: { ...results().room, startedAt: '2024-01-01T00:00:00.000Z' },
rankings: {
byScore: [],
byTime: [
rankedPlayer({ completedAt: '2024-01-01T00:02:05.000Z' }),
rankedPlayer({
id: 'p2',
userId: 'u2',
rank: 2,
completedAt: null,
user: { id: 'u2', username: 'StillGoing' },
}),
],
byLinks: [],
},
}),
);
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole('button', { name: 'By Time' }));
expect(screen.getByText('125s')).toBeInTheDocument();
expect(screen.queryByText(/^\d+s$/, { selector: 'p' })).toHaveTextContent('125s');
});
it('resets the store and returns to Game Night from the header Back button', async () => {
getRoomResultsApi.mockResolvedValue(results());
useGameNightStore.setState({ isSpectator: true });
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole('button', { name: /Back to Game Night/ }));
expect(await screen.findByText('game night home')).toBeInTheDocument();
expect(useGameNightStore.getState().isSpectator).toBe(false);
});
it('resets the store and returns to Game Night from Play Again', async () => {
getRoomResultsApi.mockResolvedValue(results());
useGameNightStore.setState({ isSpectator: true });
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole('button', { name: 'Play Again' }));
expect(await screen.findByText('game night home')).toBeInTheDocument();
expect(useGameNightStore.getState().isSpectator).toBe(false);
});
});
describe('unmount before the fetch resolves', () => {
it('does not update state or throw after the component is gone', async () => {
const gate = deferred<Results>();
getRoomResultsApi.mockReturnValue(gate.promise);
const { unmount } = renderPage();
unmount();
gate.resolve(results());
await Promise.resolve();
await Promise.resolve();
// Reaching this line without an unhandled rejection or a React warning
// being promoted to a test failure is the assertion.
expect(true).toBe(true);
});
});
+193
View File
@@ -0,0 +1,193 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route, Link } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { GameHistoryEntry } from '@/api/games';
import type { ChainLink } from '@/types';
/**
* GameReview.tsx: a finished game rendered with the static (non-live)
* `ChainDisplay`, and the not-found error path - which reads the same for a
* 404 and a 500 (the component makes no distinction beyond "not an abort"),
* plus that a stale request for a previous id cannot clobber a newer one.
*/
interface ReqConfig {
signal?: AbortSignal;
}
const gameSpy = vi.fn();
function route(url: string): (config?: ReqConfig) => Promise<unknown> {
const m = /^\/games\/([^/]+)$/.exec(url);
if (!m) throw new Error(`test reached an unrouted request: get ${url}`);
const id = m[1];
// Signal-aware, mirroring real axios: aborting the request (the effect's
// cleanup, fired when `id` changes or the page unmounts) rejects it with an
// AbortError regardless of what the underlying call would otherwise do.
return (config) =>
new Promise((resolve, reject) => {
config?.signal?.addEventListener('abort', () => {
reject(new DOMException('aborted', 'AbortError'));
});
gameSpy(id).then(resolve, reject);
});
}
vi.mock('@/api/client', () => ({
default: {
get: async (url: string, config?: ReqConfig) => ({ data: await route(url)(config) }),
post: async () => {
throw new Error('test reached an unrouted POST request');
},
put: async () => {
throw new Error('test reached an unrouted PUT request');
},
delete: async () => {
throw new Error('test reached an unrouted DELETE request');
},
},
}));
const { default: GameReview } = await import('./GameReview');
function game(overrides: Partial<GameHistoryEntry> = {}): GameHistoryEntry {
return {
id: '1',
movieAId: 100,
movieATitle: 'Heat',
movieAReleaseDate: '1995-12-15',
movieBId: 200,
movieBTitle: 'Se7en',
movieBReleaseDate: '1995-09-22',
chain: [],
score: {
baseScore: 1000,
chainLength: 5,
linkCount: 2,
chainLengthBonus: 800,
timeBonus: 500,
obscurityBonus: 100,
hintPenalty: 0,
duplicatePenalty: 0,
elapsedSeconds: 125,
totalScore: 2400,
},
hintsUsed: 0,
startedAt: '2024-01-01T00:00:00.000Z',
completedAt: '2024-01-01T01:00:00.000Z',
...overrides,
};
}
const SAMPLE_CHAIN: ChainLink[] = [
{ type: 'movie', id: 100, title: 'Heat', posterPath: null, releaseDate: '1995-12-15', popularity: 10 },
{ type: 'actor', id: 10, name: 'Robert De Niro', profilePath: null, popularity: 20 },
{ type: 'movie', id: 200, title: 'Se7en', posterPath: null, releaseDate: '1995-09-22', popularity: 15 },
];
function renderPage(initial = '/game/1/review') {
return render(
<MemoryRouter initialEntries={[initial]}>
<nav>
<Link to="/game/1/review">Game 1</Link>
<Link to="/game/2/review">Game 2</Link>
</nav>
<Routes>
<Route path="/game/:id/review" element={<GameReview />} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
gameSpy.mockImplementation((id: string) => Promise.resolve(game({ id })));
});
describe('a completed game', () => {
it('shows the matchup, completion date, time and score', async () => {
renderPage();
expect(await screen.findByText(/Heat \(1995\)/)).toBeInTheDocument();
expect(screen.getByText(/Se7en \(1995\)/)).toBeInTheDocument();
expect(screen.getByText('2:05')).toBeInTheDocument();
expect(screen.getByText('2400')).toBeInTheDocument();
});
it('omits the release year when a movie has none', async () => {
gameSpy.mockResolvedValue(game({ movieAReleaseDate: null, movieBReleaseDate: undefined }));
renderPage();
const heading = await screen.findByRole('heading', { level: 1 });
expect(heading.textContent).toContain('Heat');
expect(heading.textContent).not.toMatch(/\(1995\)/);
});
it('shows no hint count when none were used', async () => {
renderPage();
await screen.findByText(/Heat/);
expect(screen.queryByText(/hint/)).toBeNull();
});
it('singularises one hint', async () => {
gameSpy.mockResolvedValue(game({ hintsUsed: 1 }));
renderPage();
expect(await screen.findByText('1 hint')).toBeInTheDocument();
});
it('pluralises more than one hint', async () => {
gameSpy.mockResolvedValue(game({ hintsUsed: 3 }));
renderPage();
expect(await screen.findByText('3 hints')).toBeInTheDocument();
});
it('renders the chain as a static, non-live display', async () => {
gameSpy.mockResolvedValue(game({ chain: SAMPLE_CHAIN }));
renderPage();
expect(await screen.findByRole('heading', { name: 'Chain' })).toBeInTheDocument();
expect(screen.getByText('Robert De Niro')).toBeInTheDocument();
});
it('shows no chain section for an empty chain', async () => {
gameSpy.mockResolvedValue(game({ chain: [] }));
renderPage();
await screen.findByText(/Heat/);
expect(screen.queryByRole('heading', { name: 'Chain' })).toBeNull();
});
});
describe('not found (WP: 404 and 500 read the same)', () => {
it('shows "Game not found" for a 404-shaped failure', async () => {
gameSpy.mockRejectedValue({ response: { status: 404, data: { message: 'Not Found' } } });
renderPage();
expect(await screen.findByText('Game not found')).toBeInTheDocument();
});
it('shows the same "Game not found" for a 500-shaped failure', async () => {
gameSpy.mockRejectedValue({ response: { status: 500, data: { message: 'Internal Error' } } });
renderPage();
expect(await screen.findByText('Game not found')).toBeInTheDocument();
});
});
describe('switching between games', () => {
it('does not let a stale, aborted request for a previous id show a false "not found"', async () => {
let resolveGame1!: (v: unknown) => void;
gameSpy.mockImplementation((id: string) => {
if (id === '1') return new Promise((resolve) => { resolveGame1 = resolve; });
return Promise.resolve(game({ id: '2', movieATitle: 'Second Game' }));
});
renderPage('/game/1/review');
await userEvent.click(screen.getByRole('link', { name: 'Game 2' }));
expect(await screen.findByText(/Second Game/)).toBeInTheDocument();
// The id=1 request was aborted by the effect cleanup when the id changed;
// resolving it late must not overwrite what is now shown for id=2.
resolveGame1(game({ id: '1', movieATitle: 'First Game' }));
await new Promise((r) => setTimeout(r, 0));
expect(screen.getByText(/Second Game/)).toBeInTheDocument();
expect(screen.queryByText('Game not found')).toBeNull();
expect(screen.queryByText(/First Game/)).toBeNull();
});
});
+197
View File
@@ -0,0 +1,197 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
/**
* Home.tsx: Quick Play (a random, franchise-aware TMDB pair via
* `pickDistinctPair`), the disabled-while-loading state, the mode links, and
* what happens when starting the game fails partway through.
*/
interface ReqConfig {
params?: Record<string, unknown>;
}
const discoverSpy = vi.fn();
const movieDetailsSpy = vi.fn();
const createSessionSpy = vi.fn();
function route(method: string, url: string): (body: unknown, config?: ReqConfig) => Promise<unknown> {
let m: RegExpMatchArray | null;
if (method === 'get' && url === '/movies/discover') {
return (_b, config) => discoverSpy(config?.params);
}
if (method === 'get' && (m = /^\/movies\/(\d+)$/.exec(url))) {
return () => movieDetailsSpy(Number(m![1]));
}
if (method === 'post' && url === '/games') return (body) => createSessionSpy(body);
throw new Error(`test reached an unrouted request: ${method} ${url}`);
}
vi.mock('@/api/client', () => {
const call =
(method: string) =>
async (url: string, a?: unknown, b?: unknown): Promise<{ data: unknown }> => {
const config = (method === 'get' ? a : b) as ReqConfig | undefined;
const body = method === 'get' ? undefined : a;
return { data: await route(method, url)(body, config) };
};
return {
default: {
get: call('get'),
post: call('post'),
put: call('put'),
delete: call('delete'),
},
};
});
const { default: Home } = await import('./Home');
const { useGameStore } = await import('@/stores/game-store');
function movieDetails(id: number) {
return { id, title: `Movie ${id}`, poster_path: null, release_date: '2000-01-01', popularity: 5 };
}
function renderPage() {
return render(
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/game" element={<div>Game Page</div>} />
<Route path="/daily" element={<div>Daily Page</div>} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
useGameStore.getState().resetGame();
discoverSpy.mockResolvedValue({
page: 1,
results: [
{ id: 301, title: 'Alpha', release_date: '2010-01-01' },
{ id: 302, title: 'Beta', release_date: '2012-01-01' },
],
total_pages: 20,
total_results: 40,
});
movieDetailsSpy.mockImplementation((id: number) => Promise.resolve(movieDetails(id)));
createSessionSpy.mockResolvedValue({ id: 'session-1', startedAt: 'now' });
});
describe('landing content', () => {
it('links to every game mode', () => {
renderPage();
expect(screen.getByRole('link', { name: /Daily Challenge/ })).toHaveAttribute('href', '/daily');
expect(screen.getByRole('link', { name: /Endless Mode/ })).toHaveAttribute('href', '/endless');
expect(screen.getByRole('link', { name: /Versus Mode/ })).toHaveAttribute('href', '/versus');
expect(screen.getByRole('link', { name: /Custom Game/ })).toHaveAttribute('href', '/custom');
expect(screen.getByRole('link', { name: /Game Night/ })).toHaveAttribute('href', '/game-night');
});
});
describe('quick play', () => {
it('picks a random TMDB pair and starts the game', async () => {
renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Quick Play' }));
expect(await screen.findByText('Game Page')).toBeInTheDocument();
expect(discoverSpy).toHaveBeenCalledTimes(1);
const page = discoverSpy.mock.calls[0]?.[0]?.page as number;
expect(page).toBeGreaterThanOrEqual(1);
expect(page).toBeLessThanOrEqual(20);
const requestedIds = movieDetailsSpy.mock.calls.map((c) => c[0] as number).sort();
expect(requestedIds).toEqual([301, 302]);
});
it('disables the button and shows a spinner while starting', async () => {
let release!: () => void;
movieDetailsSpy.mockImplementationOnce(
() => new Promise((resolve) => { release = () => resolve(movieDetails(301)); }),
);
renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Quick Play' }));
const loadingButton = await screen.findByRole('button', { name: 'Loading...' });
expect(loadingButton).toBeDisabled();
release();
await screen.findByText('Game Page');
});
// The three failure paths below were all unhandled before WP-19: the TMDB
// pick threw out of the click handler with nothing shown, and a failed
// startGame still navigated to /game, whose idle redirect bounced the
// player straight back with the store's message never displayed.
it("shows the store's error and stays put when the game fails to start", async () => {
movieDetailsSpy.mockRejectedValue(new Error('network'));
renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Quick Play' }));
expect(await screen.findByRole('alert')).toHaveTextContent(
'Failed to load movie details. Please try again.',
);
expect(screen.queryByText('Game Page')).toBeNull();
expect(screen.getByRole('button', { name: 'Quick Play' })).toBeEnabled();
});
it('reports a TMDB failure instead of throwing out of the click handler', async () => {
discoverSpy.mockRejectedValue({ response: { data: { message: 'Movie data is temporarily unavailable' } } });
renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Quick Play' }));
expect(await screen.findByRole('alert')).toHaveTextContent(
'Movie data is temporarily unavailable',
);
expect(movieDetailsSpy).not.toHaveBeenCalled();
});
it('explains a page with too few usable movies rather than failing silently', async () => {
discoverSpy.mockResolvedValue({
page: 1,
results: [{ id: 301, title: 'Alpha', release_date: '2010-01-01' }],
total_pages: 20,
total_results: 1,
});
renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Quick Play' }));
expect(await screen.findByRole('alert')).toHaveTextContent(/not return enough movies/);
});
it('disables the button during the TMDB pick, not only during startGame', async () => {
let release!: () => void;
discoverSpy.mockImplementationOnce(
() =>
new Promise((resolve) => {
release = () =>
resolve({
page: 1,
results: [
{ id: 301, title: 'Alpha', release_date: '2010-01-01' },
{ id: 302, title: 'Beta', release_date: '2012-01-01' },
],
total_pages: 20,
total_results: 40,
});
}),
);
renderPage();
await userEvent.click(screen.getByRole('button', { name: 'Quick Play' }));
// The store is not validating yet - only the pick is in flight - and the
// button is already disabled, so a second click cannot start a second game.
expect(useGameStore.getState().isValidating).toBe(false);
expect(screen.getByRole('button', { name: 'Loading...' })).toBeDisabled();
release();
await screen.findByText('Game Page');
});
});
+42 -9
View File
@@ -1,3 +1,4 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router';
import { Button } from '@/components/ui/button';
import { buttonVariants } from '@/components/ui/button-variants';
@@ -8,17 +9,18 @@ import { cn } from '@/lib/utils';
import { Loader2, Calendar, Swords, Shuffle, Crosshair, Users } from 'lucide-react';
import type { MoviePair } from '@/types';
import { pickDistinctPair } from '@/lib/movie-pair';
import { getErrorMessage } from '@/lib/error';
import { Alert, AlertDescription } from '@/components/ui/alert';
async function getRandomTmdbPair(): Promise<MoviePair> {
/** A random cross-franchise pair from TMDB, or null if the page had too few usable films. */
async function getRandomTmdbPair(): Promise<MoviePair | null> {
// Pick a random page from the first 20 pages of popular movies
const page = Math.floor(Math.random() * 20) + 1;
const data = await discoverMovies(page);
const movies = data.results.filter((m) => m.title && m.release_date);
const pair = pickDistinctPair(movies);
if (!pair) {
throw new Error('Not enough movies returned from TMDB');
}
if (!pair) return null;
const [movieA, movieB] = pair;
return {
@@ -31,11 +33,36 @@ export default function Home() {
const startGame = useGameStore((s) => s.startGame);
const isValidating = useGameStore((s) => s.isValidating);
const navigate = useNavigate();
// `isValidating` only covers startGame; the TMDB pick before it took long
// enough for a second click to start a second game, and a failure anywhere
// in here was an unhandled rejection the player never saw (audit 2.5).
const [starting, setStarting] = useState(false);
const [error, setError] = useState('');
const handleStartGame = async () => {
const pair = await getRandomTmdbPair();
await startGame(pair);
navigate('/game');
setStarting(true);
setError('');
try {
const pair = await getRandomTmdbPair();
if (!pair) {
setError('TMDB did not return enough movies to make a pair. Try again.');
return;
}
if ((await startGame(pair)) !== 'started') {
setError(
useGameStore.getState().validationError ??
'Could not start the game. Please try again.',
);
return;
}
navigate('/game');
} catch (err) {
setError(
getErrorMessage(err, 'Could not reach the movie database. Please try again.'),
);
} finally {
setStarting(false);
}
};
return (
@@ -64,14 +91,20 @@ export default function Home() {
</ol>
</div>
{error && (
<Alert variant="destructive" className="w-full max-w-lg">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="grid w-full max-w-lg gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Button
size="lg"
onClick={handleStartGame}
disabled={isValidating}
disabled={starting || isValidating}
className="text-base"
>
{isValidating ? (
{starting || isValidating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
+248
View File
@@ -0,0 +1,248 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { LeaderboardEntry } from '@/api/leaderboards';
/**
* Leaderboard.tsx: period and difficulty filters, the named Previous/Next
* page buttons, the empty state and its distinction from a failed fetch
* (WP-10 - a failed request must not be mistaken for "no scores yet"), and
* `formatTime` as rendered in a row.
*/
interface ReqConfig {
signal?: AbortSignal;
params?: Record<string, unknown>;
}
const leaderboardSpy = vi.fn();
function route(method: string, url: string): (config?: ReqConfig) => Promise<unknown> {
if (method === 'get' && url === '/leaderboards') {
return (config) => leaderboardSpy(config?.params);
}
throw new Error(`test reached an unrouted request: ${method} ${url}`);
}
vi.mock('@/api/client', () => {
const call =
(method: string) =>
async (url: string, config?: ReqConfig): Promise<{ data: unknown }> => ({
data: await route(method, url)(config),
});
return {
default: {
get: call('get'),
post: call('post'),
put: call('put'),
delete: call('delete'),
},
};
});
const { default: Leaderboard } = await import('./Leaderboard');
function entry(overrides: Partial<LeaderboardEntry>): LeaderboardEntry {
return {
rank: 1,
username: 'alice',
score: 1000,
chainLength: 5,
timeSeconds: 125,
hintsUsed: 0,
gameMode: 'freeplay',
date: '2024-01-01',
...overrides,
};
}
function renderLeaderboard() {
return render(
<MemoryRouter initialEntries={['/leaderboard']}>
<Routes>
<Route path="/leaderboard" element={<Leaderboard />} />
<Route path="/" element={<div>Home Page</div>} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
leaderboardSpy.mockResolvedValue({
data: [entry({})],
total: 1,
page: 1,
limit: 20,
totalPages: 1,
});
});
describe('rows', () => {
it('renders a row with rank, score, chain length and a formatted time', async () => {
leaderboardSpy.mockResolvedValue({
data: [entry({ rank: 1, username: 'alice', score: 2500, chainLength: 4, timeSeconds: 125 })],
total: 1,
page: 1,
limit: 20,
totalPages: 1,
});
renderLeaderboard();
expect(await screen.findByText('alice')).toBeInTheDocument();
expect(screen.getByText('2,500')).toBeInTheDocument();
expect(screen.getByText('2:05')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
});
it('badges the top three ranks and leaves the rest as plain numbers', async () => {
leaderboardSpy.mockResolvedValue({
data: [
entry({ rank: 1, username: 'first' }),
entry({ rank: 2, username: 'second' }),
entry({ rank: 4, username: 'fourth' }),
],
total: 3,
page: 1,
limit: 20,
totalPages: 1,
});
renderLeaderboard();
await screen.findByText('first');
// Ranks 1-3 render inside a Badge; rank 4 renders as bare text.
const badgedOne = screen.getByText('1');
const badgedTwo = screen.getByText('2');
expect(badgedOne.tagName).toBe('SPAN');
expect(badgedTwo.tagName).toBe('SPAN');
expect(screen.getByText('4')).toBeInTheDocument();
});
it('labels daily and freeplay games and shows nothing for other modes', async () => {
leaderboardSpy.mockResolvedValue({
data: [
entry({ rank: 1, username: 'dailyPlayer', gameMode: 'daily' }),
entry({ rank: 2, username: 'freePlayer', gameMode: 'freeplay' }),
entry({ rank: 3, username: 'versusPlayer', gameMode: 'versus' }),
],
total: 3,
page: 1,
limit: 20,
totalPages: 1,
});
renderLeaderboard();
await screen.findByText('dailyPlayer');
// Scoped to the badge: "Daily" also names a nav link in the page header.
expect(screen.getByText('Daily', { selector: 'span' })).toBeInTheDocument();
expect(screen.getByText('Freeplay', { selector: 'span' })).toBeInTheDocument();
expect(screen.queryByText('Versus', { selector: 'span' })).toBeNull();
});
});
describe('empty vs. failed (WP-10)', () => {
it('shows a dedicated message when a period genuinely has no scores', async () => {
leaderboardSpy.mockResolvedValue({ data: [], total: 0, page: 1, limit: 20, totalPages: 1 });
renderLeaderboard();
expect(
await screen.findByText('No scores yet for this period. Be the first!'),
).toBeInTheDocument();
});
it('does not show the "no scores" message when the request fails outright', async () => {
leaderboardSpy.mockRejectedValue(new Error('500'));
renderLeaderboard();
// Wait for loading to finish (the request settles) without ever showing
// the empty-state copy meant for a genuinely empty period.
await screen.findByRole('heading', { name: 'Leaderboard' });
await new Promise((r) => setTimeout(r, 0));
expect(screen.queryByText(/No scores yet/)).toBeNull();
expect(screen.queryByRole('grid')).toBeNull();
});
});
describe('filters', () => {
it('requests a new period and resets to page 1', async () => {
renderLeaderboard();
await screen.findByText('alice');
leaderboardSpy.mockClear();
await userEvent.click(screen.getByRole('button', { name: 'This Week' }));
expect(leaderboardSpy).toHaveBeenCalledWith(
expect.objectContaining({ period: 'weekly', page: 1 }),
);
});
it('sends the chosen difficulty, and omits it entirely for "All"', async () => {
renderLeaderboard();
await screen.findByText('alice');
leaderboardSpy.mockClear();
await userEvent.click(screen.getByRole('button', { name: 'Hard' }));
expect(leaderboardSpy).toHaveBeenCalledWith(
expect.objectContaining({ difficulty: 'hard', page: 1 }),
);
leaderboardSpy.mockClear();
await userEvent.click(screen.getByRole('button', { name: 'All' }));
const params = leaderboardSpy.mock.calls[0]?.[0] as Record<string, unknown>;
expect(params).not.toHaveProperty('difficulty');
});
});
describe('pagination', () => {
it('names Previous/Next, disables at the boundaries, and pages requests', async () => {
leaderboardSpy.mockImplementation((params: { page: number }) =>
Promise.resolve({
data: [entry({ rank: 1, username: `page-${params.page}` })],
total: 40,
page: params.page,
limit: 20,
totalPages: 2,
}),
);
renderLeaderboard();
await screen.findByText('page-1');
const prev = screen.getByRole('button', { name: 'Previous page' });
const next = screen.getByRole('button', { name: 'Next page' });
expect(prev).toBeDisabled();
expect(next).toBeEnabled();
expect(screen.getByText('Page 1 of 2')).toBeInTheDocument();
await userEvent.click(next);
expect(await screen.findByText('page-2')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled();
await userEvent.click(screen.getByRole('button', { name: 'Previous page' }));
expect(await screen.findByText('page-1')).toBeInTheDocument();
});
it('shows no pagination controls for a single page', async () => {
renderLeaderboard();
await screen.findByText('alice');
expect(screen.queryByRole('button', { name: 'Previous page' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Next page' })).toBeNull();
});
it('ignores a page response that resolves after navigating away', async () => {
let resolveNext!: (v: unknown) => void;
leaderboardSpy
.mockResolvedValueOnce({ data: [entry({ username: 'alice' })], total: 1, page: 1, limit: 20, totalPages: 1 })
.mockImplementationOnce(() => new Promise((resolve) => { resolveNext = resolve; }));
renderLeaderboard();
await screen.findByText('alice');
await userEvent.click(screen.getByRole('button', { name: 'This Week' }));
await userEvent.click(screen.getByRole('link', { name: 'Home' }));
expect(await screen.findByText('Home Page')).toBeInTheDocument();
resolveNext({ data: [entry({ username: 'late' })], total: 1, page: 1, limit: 20, totalPages: 1 });
await new Promise((r) => setTimeout(r, 0));
expect(screen.getByText('Home Page')).toBeInTheDocument();
});
});
+300
View File
@@ -0,0 +1,300 @@
import { cleanup, render, screen, waitFor } 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';
/**
* Login page: sign-in vs sign-up, labelled fields, remember-me, client-side
* validation, the store's error message, and the redirect after a successful
* submit. Requests are intercepted at `@/api/client` and routed by URL - the
* seam `game-store.test.ts` and `Versus.test.tsx` use. A successful login
* connects the notifications socket and registers a service worker from
* auth-store, so both are stubbed to keep this a page test, not an
* integration test of those subsystems.
*/
const login = vi.fn();
const register = vi.fn();
type Handler = (arg: unknown) => Promise<unknown>;
function route(method: string, url: string): Handler {
if (method === 'post' && url === '/auth/login') return (body) => login(body);
if (method === 'post' && url === '/auth/register') return (body) => register(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'),
},
};
});
vi.mock('@/lib/create-socket', () => ({ createSocket: () => null }));
vi.mock('@/lib/push', () => ({ registerServiceWorker: vi.fn() }));
const { useAuthStore } = await import('@/stores/auth-store');
const { default: Login } = await import('./Login');
function renderLogin(initialEntry = '/login') {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/profile" element={<div>profile route</div>} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
useAuthStore.setState({ user: null, isLoading: false, error: null });
});
describe('sign-in vs sign-up toggle', () => {
it('starts on the sign-in form with labelled email and password fields', () => {
renderLogin();
expect(screen.getByRole('heading', { name: 'Sign In' })).toBeInTheDocument();
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
expect(screen.queryByLabelText('Username')).toBeNull();
expect(screen.queryByLabelText('Confirm Password')).toBeNull();
expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument();
});
it('switches to sign-up and reveals the extra fields', async () => {
renderLogin();
await userEvent.click(screen.getByRole('button', { name: 'Create one' }));
expect(screen.getByRole('heading', { name: 'Create Account' })).toBeInTheDocument();
expect(screen.getByLabelText('Username')).toBeInTheDocument();
expect(screen.getByLabelText('Confirm Password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Create Account' })).toBeInTheDocument();
});
it('switches back to sign-in from sign-up', async () => {
renderLogin();
await userEvent.click(screen.getByRole('button', { name: 'Create one' }));
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }));
expect(screen.getByRole('heading', { name: 'Sign In' })).toBeInTheDocument();
expect(screen.queryByLabelText('Username')).toBeNull();
});
});
describe('remember me', () => {
it('is off by default and is passed through to login', async () => {
login.mockResolvedValue({ access_token: 'tok', user: { id: '1', email: 'a@b.com', username: 'a', isAdmin: false } });
renderLogin();
expect(screen.getByRole('checkbox', { name: 'Keep me signed in' })).not.toBeChecked();
await userEvent.type(screen.getByLabelText('Email'), 'a@b.com');
await userEvent.type(screen.getByLabelText('Password'), 'password1');
await userEvent.click(screen.getByRole('checkbox', { name: 'Keep me signed in' }));
await userEvent.click(screen.getByRole('button', { name: 'Sign In' }));
await waitFor(() =>
expect(login).toHaveBeenCalledWith({
email: 'a@b.com',
password: 'password1',
rememberMe: true,
}),
);
});
});
describe('validation', () => {
it('rejects a mismatched confirmation on sign-up without calling the API', async () => {
renderLogin();
await userEvent.click(screen.getByRole('button', { name: 'Create one' }));
await userEvent.type(screen.getByLabelText('Email'), 'a@b.com');
await userEvent.type(screen.getByLabelText('Username'), 'someuser');
await userEvent.type(screen.getByLabelText('Password'), 'password1');
await userEvent.type(screen.getByLabelText('Confirm Password'), 'different');
await userEvent.click(screen.getByRole('button', { name: 'Create Account' }));
expect(await screen.findByText('Passwords do not match')).toBeInTheDocument();
expect(register).not.toHaveBeenCalled();
});
it('clears a previous confirmation error once resubmitted correctly', async () => {
renderLogin();
await userEvent.click(screen.getByRole('button', { name: 'Create one' }));
await userEvent.type(screen.getByLabelText('Email'), 'a@b.com');
await userEvent.type(screen.getByLabelText('Username'), 'someuser');
await userEvent.type(screen.getByLabelText('Password'), 'password1');
await userEvent.type(screen.getByLabelText('Confirm Password'), 'nope');
await userEvent.click(screen.getByRole('button', { name: 'Create Account' }));
await screen.findByText('Passwords do not match');
register.mockResolvedValue({
access_token: 'tok',
user: { id: '1', email: 'a@b.com', username: 'someuser', isAdmin: false },
});
await userEvent.clear(screen.getByLabelText('Confirm Password'));
await userEvent.type(screen.getByLabelText('Confirm Password'), 'password1');
await userEvent.click(screen.getByRole('button', { name: 'Create Account' }));
await waitFor(() => expect(register).toHaveBeenCalled());
expect(screen.queryByText('Passwords do not match')).toBeNull();
});
});
describe('submitting', () => {
it('signs in and redirects to the profile page', async () => {
login.mockResolvedValue({
access_token: 'tok',
user: { id: '1', email: 'a@b.com', username: 'a', isAdmin: false },
});
renderLogin();
await userEvent.type(screen.getByLabelText('Email'), 'a@b.com');
await userEvent.type(screen.getByLabelText('Password'), 'password1');
await userEvent.click(screen.getByRole('button', { name: 'Sign In' }));
expect(await screen.findByText('profile route')).toBeInTheDocument();
expect(useAuthStore.getState().user).toEqual({
id: '1',
email: 'a@b.com',
username: 'a',
isAdmin: false,
});
});
it('registers a new account and redirects to the profile page', async () => {
register.mockResolvedValue({
access_token: 'tok',
user: { id: '2', email: 'new@b.com', username: 'newuser', isAdmin: false },
});
renderLogin();
await userEvent.click(screen.getByRole('button', { name: 'Create one' }));
await userEvent.type(screen.getByLabelText('Email'), 'new@b.com');
await userEvent.type(screen.getByLabelText('Username'), 'newuser');
await userEvent.type(screen.getByLabelText('Password'), 'password1');
await userEvent.type(screen.getByLabelText('Confirm Password'), 'password1');
await userEvent.click(screen.getByRole('button', { name: 'Create Account' }));
await waitFor(() =>
expect(register).toHaveBeenCalledWith({
email: 'new@b.com',
username: 'newuser',
password: 'password1',
rememberMe: false,
}),
);
expect(await screen.findByText('profile route')).toBeInTheDocument();
});
it('shows the store error and stays on the page when login fails', async () => {
login.mockRejectedValue({ response: { data: { message: 'Invalid credentials' } } });
renderLogin();
await userEvent.type(screen.getByLabelText('Email'), 'a@b.com');
await userEvent.type(screen.getByLabelText('Password'), 'wrongpass');
await userEvent.click(screen.getByRole('button', { name: 'Sign In' }));
expect(await screen.findByText('Invalid credentials')).toBeInTheDocument();
expect(screen.queryByText('profile route')).toBeNull();
});
it('shows a spinner and disables submit while signing in', async () => {
let resolveLogin: (v: unknown) => void = () => undefined;
login.mockImplementation(
() =>
new Promise((resolve) => {
resolveLogin = resolve;
}),
);
renderLogin();
await userEvent.type(screen.getByLabelText('Email'), 'a@b.com');
await userEvent.type(screen.getByLabelText('Password'), 'password1');
await userEvent.click(screen.getByRole('button', { name: 'Sign In' }));
const pending = screen.getByRole('button', { name: 'Signing in...' });
expect(pending).toBeDisabled();
resolveLogin({
access_token: 'tok',
user: { id: '1', email: 'a@b.com', username: 'a', isAdmin: false },
});
await screen.findByText('profile route');
});
it('shows a sign-up-specific spinner label while registering', async () => {
let resolveRegister: (v: unknown) => void = () => undefined;
register.mockImplementation(
() =>
new Promise((resolve) => {
resolveRegister = resolve;
}),
);
renderLogin();
await userEvent.click(screen.getByRole('button', { name: 'Create one' }));
await userEvent.type(screen.getByLabelText('Email'), 'a@b.com');
await userEvent.type(screen.getByLabelText('Username'), 'auser');
await userEvent.type(screen.getByLabelText('Password'), 'password1');
await userEvent.type(screen.getByLabelText('Confirm Password'), 'password1');
await userEvent.click(screen.getByRole('button', { name: 'Create Account' }));
expect(screen.getByRole('button', { name: 'Creating account...' })).toBeDisabled();
resolveRegister({
access_token: 'tok',
user: { id: '1', email: 'a@b.com', username: 'auser', isAdmin: false },
});
await screen.findByText('profile route');
});
});
describe('redirects and notices', () => {
// Regression (WP-19): a user who was already signed in and landed on /login
// - a stale bookmark, a back-navigation - saw the sign-in form again.
it('redirects away when the user is already signed in', () => {
useAuthStore.setState({
user: { id: '1', email: 'a@b.com', username: 'a', isAdmin: false },
isLoading: false,
error: null,
});
renderLogin();
expect(screen.getByText('profile route')).toBeInTheDocument();
});
// Regression (WP-19): `@/api/client.ts` has sent expired sessions to
// /login?expired=1 since WP-10, and nothing here read it, so being signed
// out by an expired token looked identical to signing out on purpose.
it('shows a notice when redirected here after the session expired', () => {
renderLogin('/login?expired=1');
expect(
screen.getByText('Your session expired. Please sign in again.'),
).toBeInTheDocument();
});
it('does not show the expired notice on a plain visit, nor on the sign-up form', async () => {
renderLogin();
expect(screen.queryByText(/session expired/i)).toBeNull();
cleanup();
renderLogin('/login?expired=1');
await userEvent.click(screen.getByRole('button', { name: 'Create one' }));
expect(screen.queryByText(/session expired/i)).toBeNull();
});
it('does not redirect while the stored session is still being checked', () => {
useAuthStore.setState({ user: null, isLoading: true, error: null });
renderLogin();
expect(screen.getByRole('heading', { name: 'Sign In' })).toBeInTheDocument();
});
});
+24 -2
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Alert, AlertDescription } from '@/components/ui/alert';
@@ -18,7 +18,21 @@ export default function Login() {
const register = useAuthStore((s) => s.register);
const isLoading = useAuthStore((s) => s.isLoading);
const error = useAuthStore((s) => s.error);
// The id, not the user object: loadUser() rebuilds that object on every
// call, which would re-run the redirect effect for nothing.
const userId = useAuthStore((s) => s.user?.id);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// The API client sends an expired session here as /login?expired=1. Until
// WP-19 nothing read it, so being signed out by an expired token looked
// exactly like having signed out on purpose.
const sessionExpired = searchParams.get('expired') === '1';
// Someone already signed in has nothing to do here (a stale bookmark, a
// back-navigation after signing in).
useEffect(() => {
if (!isLoading && userId) navigate('/profile', { replace: true });
}, [userId, isLoading, navigate]);
const [confirmError, setConfirmError] = useState('');
@@ -48,6 +62,14 @@ export default function Login() {
{isRegister ? 'Create Account' : 'Sign In'}
</h1>
{sessionExpired && !isRegister && (
<Alert className="mb-4">
<AlertDescription>
Your session expired. Please sign in again.
</AlertDescription>
</Alert>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="login-email" className="mb-1 block text-sm font-medium">Email</label>
+18
View File
@@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { describe, expect, it } from 'vitest';
import NotFound from './NotFound';
describe('NotFound', () => {
it('shows a 404 message and a link back home', () => {
render(
<MemoryRouter>
<NotFound />
</MemoryRouter>,
);
expect(screen.getByText('404')).toBeInTheDocument();
expect(screen.getByText('Page not found.')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Back to Home' })).toHaveAttribute('href', '/');
});
});
+440
View File
@@ -0,0 +1,440 @@
import { render, screen, waitFor } 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';
import type { UserStats } from '@/api/leaderboards';
import type { GameHistoryEntry } from '@/api/games';
/**
* Profile page: stats (all/daily toggle), recent games or scores, editing the
* display name, changing password, and signing out. Requests are intercepted
* at `@/api/client` and routed by URL - the seam `game-store.test.ts` and
* `Versus.test.tsx` use. `PushNotificationsSection` has its own test file and
* is stubbed here so this file's coverage is scoped to Profile.tsx itself.
*/
const getGameHistory = vi.fn();
const getUserStats = vi.fn();
const updateProfile = vi.fn();
const changePassword = vi.fn();
const getProfile = vi.fn();
type Handler = (arg: unknown) => Promise<unknown>;
function route(method: string, url: string): Handler {
if (method === 'get' && url === '/games/me/history') {
return (config) => getGameHistory((config as { params?: unknown })?.params);
}
if (method === 'get' && url === '/leaderboards/me') {
return (config) =>
getUserStats((config as { params?: { mode?: string } })?.params?.mode);
}
if (method === 'patch' && url === '/users/me') {
return (body) => updateProfile(body);
}
if (method === 'patch' && url === '/users/me/password') {
return (body) => changePassword(body);
}
if (method === 'get' && url === '/users/me') {
return () => getProfile();
}
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'),
patch: call('patch'),
put: call('put'),
delete: call('delete'),
},
};
});
vi.mock('@/lib/create-socket', () => ({ createSocket: () => null }));
vi.mock('@/lib/push', () => ({ registerServiceWorker: vi.fn() }));
vi.mock('@/components/profile/PushNotificationsSection', () => ({
default: () => <div data-testid="push-section-stub" />,
}));
const { useAuthStore } = await import('@/stores/auth-store');
const { default: Profile } = await import('./Profile');
// --- Fixtures ---------------------------------------------------------------
const USER = { id: 'user-1', email: 'me@example.com', username: 'moviebuff', isAdmin: false };
function makeStats(overrides: Partial<UserStats> = {}): UserStats {
return {
totalGamesPlayed: 10,
bestScore: 5000,
averageScore: 2500,
totalScore: 25000,
currentStreak: 1,
longestStreak: 3,
recentScores: [],
...overrides,
};
}
function makeHistoryEntry(overrides: Partial<GameHistoryEntry> = {}): GameHistoryEntry {
return {
id: 'game-1',
movieAId: 1,
movieATitle: 'Movie A',
movieBId: 2,
movieBTitle: 'Movie B',
chain: [],
score: {
baseScore: 1000,
chainLength: 5,
linkCount: 2,
chainLengthBonus: 0,
timeBonus: 0,
obscurityBonus: 0,
hintPenalty: 0,
duplicatePenalty: 0,
elapsedSeconds: 60,
totalScore: 1000,
},
hintsUsed: 0,
startedAt: '2024-01-01T00:00:00.000Z',
completedAt: '2024-01-01T00:10:00.000Z',
...overrides,
};
}
function renderProfile() {
return render(
<MemoryRouter initialEntries={['/profile']}>
<Routes>
<Route path="/profile" element={<Profile />} />
<Route path="/login" element={<div>login route</div>} />
<Route path="/" element={<div>home route</div>} />
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
// loadUser() (invoked after a successful profile save) short-circuits
// without a stored token, so a token must be present for that path.
localStorage.setItem('auth_token', 'test-token');
useAuthStore.setState({ user: USER, isLoading: false, error: null });
getGameHistory.mockResolvedValue({ data: [], total: 0, page: 1, limit: 10, totalPages: 0 });
getUserStats.mockResolvedValue(makeStats());
updateProfile.mockResolvedValue({});
changePassword.mockResolvedValue({});
getProfile.mockResolvedValue({
id: USER.id,
email: USER.email,
username: USER.username,
isAdmin: false,
});
});
describe('authentication gate', () => {
it('redirects to login when nobody is signed in', async () => {
useAuthStore.setState({ user: null, isLoading: false });
renderProfile();
expect(await screen.findByText('login route')).toBeInTheDocument();
});
it('does not redirect while auth is still resolving', () => {
useAuthStore.setState({ user: null, isLoading: true });
renderProfile();
expect(screen.queryByText('login route')).toBeNull();
});
});
describe('loading and stats', () => {
it('shows skeletons while the initial fetches are in flight', async () => {
let resolveHistory: (v: unknown) => void = () => undefined;
getGameHistory.mockImplementation(
() =>
new Promise((resolve) => {
resolveHistory = resolve;
}),
);
const { container } = renderProfile();
expect(container.querySelectorAll('[data-slot="skeleton"]').length).toBeGreaterThan(0);
resolveHistory({ data: [], total: 0, page: 1, limit: 10, totalPages: 0 });
await waitFor(() =>
expect(container.querySelectorAll('[data-slot="skeleton"]').length).toBe(0),
);
});
it('shows the stat cards once stats resolve', async () => {
renderProfile();
expect(await screen.findByText('Games Played')).toBeInTheDocument();
expect(screen.getByText('5,000')).toBeInTheDocument();
expect(screen.getByText('1 day')).toBeInTheDocument();
expect(screen.getByText('3 days')).toBeInTheDocument();
});
it('shows the empty state when there are no stats', async () => {
getUserStats.mockRejectedValue(new Error('none yet'));
renderProfile();
expect(
await screen.findByText('No stats yet. Play a daily challenge to get started!'),
).toBeInTheDocument();
});
it('re-fetches stats for the chosen mode and switches the active tab', async () => {
renderProfile();
await screen.findByText('Games Played');
expect(getUserStats).toHaveBeenCalledWith('all');
getUserStats.mockResolvedValue(makeStats({ totalGamesPlayed: 2 }));
await userEvent.click(screen.getByRole('button', { name: 'Daily Only' }));
await waitFor(() => expect(getUserStats).toHaveBeenCalledWith('daily'));
expect(await screen.findByText('2')).toBeInTheDocument();
});
it('pluralizes the streak day counts in both directions', async () => {
getUserStats.mockResolvedValue(makeStats({ currentStreak: 5, longestStreak: 1 }));
renderProfile();
expect(await screen.findByText('5 days')).toBeInTheDocument();
expect(screen.getByText('1 day')).toBeInTheDocument();
});
it('ignores a stale stats response from a mode switch that was itself superseded', async () => {
renderProfile();
await screen.findByText('Games Played');
let resolveDaily: (v: UserStats) => void = () => undefined;
getUserStats.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveDaily = resolve;
}),
);
await userEvent.click(screen.getByRole('button', { name: 'Daily Only' }));
getUserStats.mockResolvedValueOnce(makeStats({ totalGamesPlayed: 42 }));
await userEvent.click(screen.getByRole('button', { name: 'All Games' }));
await waitFor(() => expect(screen.getByText('42')).toBeInTheDocument());
// The stale "daily" request now resolves after the newer "all" one won.
resolveDaily(makeStats({ totalGamesPlayed: 999 }));
await new Promise((r) => setTimeout(r, 0));
expect(screen.queryByText('999')).toBeNull();
expect(screen.getByText('42')).toBeInTheDocument();
});
});
describe('recent games and scores', () => {
it('lists recent games with a link to review each one', async () => {
getGameHistory.mockResolvedValue({
data: [makeHistoryEntry()],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
});
renderProfile();
expect(await screen.findByText('Recent Games')).toBeInTheDocument();
expect(screen.getByText('Movie A ↔ Movie B')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Movie A ↔ Movie B/ })).toHaveAttribute(
'href',
'/game/game-1/review',
);
});
it('shows a dash for an in-progress game with no completion date or score', async () => {
getGameHistory.mockResolvedValue({
data: [
makeHistoryEntry({
completedAt: undefined as unknown as string,
score: undefined as unknown as GameHistoryEntry['score'],
}),
],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
});
renderProfile();
await screen.findByText('Recent Games');
expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(2);
});
it('falls back to the recent-scores table when there is no game history', async () => {
getUserStats.mockResolvedValue(
makeStats({ recentScores: [{ score: 900, chainLength: 4, timeSeconds: 125, date: '2024-01-02T00:00:00.000Z' }] }),
);
renderProfile();
expect(await screen.findByText('Recent Scores')).toBeInTheDocument();
expect(screen.getByText('900')).toBeInTheDocument();
expect(screen.getByText('2:05')).toBeInTheDocument();
expect(screen.queryByText('Recent Games')).toBeNull();
});
});
describe('editing the display name', () => {
it('saves a new display name and reloads the user', async () => {
renderProfile();
await screen.findByText('Games Played');
await userEvent.click(screen.getByRole('button', { name: 'Edit display name' }));
await userEvent.type(screen.getByLabelText('Display name'), 'New Name');
await userEvent.click(screen.getByRole('button', { name: 'Save display name' }));
expect(updateProfile).toHaveBeenCalledWith({ displayName: 'New Name' });
await waitFor(() => expect(getProfile).toHaveBeenCalled());
expect(screen.queryByLabelText('Display name')).toBeNull();
expect(screen.getByRole('heading', { name: 'moviebuff' })).toBeInTheDocument();
});
it('cancels editing without saving', async () => {
renderProfile();
await screen.findByText('Games Played');
await userEvent.click(screen.getByRole('button', { name: 'Edit display name' }));
await userEvent.type(screen.getByLabelText('Display name'), 'Discarded');
await userEvent.click(
screen.getByRole('button', { name: 'Cancel editing display name' }),
);
expect(updateProfile).not.toHaveBeenCalled();
expect(screen.queryByLabelText('Display name')).toBeNull();
});
it('leaves the editor open when saving fails', async () => {
updateProfile.mockRejectedValue(new Error('500'));
renderProfile();
await screen.findByText('Games Played');
await userEvent.click(screen.getByRole('button', { name: 'Edit display name' }));
await userEvent.type(screen.getByLabelText('Display name'), 'New Name');
await userEvent.click(screen.getByRole('button', { name: 'Save display name' }));
await waitFor(() => expect(updateProfile).toHaveBeenCalled());
expect(getProfile).not.toHaveBeenCalled();
expect(screen.getByLabelText('Display name')).toBeInTheDocument();
});
});
describe('changing password', () => {
async function openPasswordForm() {
await userEvent.click(screen.getByRole('button', { name: 'Change Password' }));
}
it('rejects a mismatched confirmation without calling the API', async () => {
renderProfile();
await screen.findByText('Games Played');
await openPasswordForm();
await userEvent.type(screen.getByLabelText('Current password'), 'oldpass1');
await userEvent.type(screen.getByLabelText('New password'), 'newpassword');
await userEvent.type(screen.getByLabelText('Confirm new password'), 'different');
await userEvent.click(screen.getByRole('button', { name: 'Update Password' }));
expect(await screen.findByText('New passwords do not match')).toBeInTheDocument();
expect(changePassword).not.toHaveBeenCalled();
});
it('rejects a new password shorter than 8 characters', async () => {
renderProfile();
await screen.findByText('Games Played');
await openPasswordForm();
await userEvent.type(screen.getByLabelText('Current password'), 'oldpass1');
await userEvent.type(screen.getByLabelText('New password'), 'short');
await userEvent.type(screen.getByLabelText('Confirm new password'), 'short');
await userEvent.click(screen.getByRole('button', { name: 'Update Password' }));
expect(
await screen.findByText('New password must be at least 8 characters'),
).toBeInTheDocument();
expect(changePassword).not.toHaveBeenCalled();
});
it('changes the password and shows a success message', async () => {
renderProfile();
await screen.findByText('Games Played');
await openPasswordForm();
await userEvent.type(screen.getByLabelText('Current password'), 'oldpass1');
await userEvent.type(screen.getByLabelText('New password'), 'newpassword');
await userEvent.type(screen.getByLabelText('Confirm new password'), 'newpassword');
await userEvent.click(screen.getByRole('button', { name: 'Update Password' }));
expect(changePassword).toHaveBeenCalledWith({
currentPassword: 'oldpass1',
newPassword: 'newpassword',
});
expect(
await screen.findByText('Password updated successfully'),
).toBeInTheDocument();
expect(screen.queryByLabelText('Current password')).toBeNull();
});
it('shows the server error message when changing the password fails', async () => {
changePassword.mockRejectedValue({
response: { data: { message: 'Current password is incorrect' } },
});
renderProfile();
await screen.findByText('Games Played');
await openPasswordForm();
await userEvent.type(screen.getByLabelText('Current password'), 'wrongpass');
await userEvent.type(screen.getByLabelText('New password'), 'newpassword');
await userEvent.type(screen.getByLabelText('Confirm new password'), 'newpassword');
await userEvent.click(screen.getByRole('button', { name: 'Update Password' }));
expect(
await screen.findByText('Current password is incorrect'),
).toBeInTheDocument();
});
it('cancels the password form and clears its fields', async () => {
renderProfile();
await screen.findByText('Games Played');
await openPasswordForm();
await userEvent.type(screen.getByLabelText('Current password'), 'oldpass1');
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.queryByLabelText('Current password')).toBeNull();
expect(changePassword).not.toHaveBeenCalled();
});
});
describe('sign out', () => {
it('clears the signed-in user and leaves the profile page', async () => {
renderProfile();
await screen.findByText('Games Played');
await userEvent.click(screen.getByRole('button', { name: 'Sign Out' }));
await waitFor(() => expect(useAuthStore.getState().user).toBeNull());
expect(screen.queryByText('Games Played')).toBeNull();
});
// Regression: handleLogout navigated home, but the page's own auth guard
// re-ran as soon as the store's user became null and sent the user to
// /login instead. Found in WP-19; the guard now yields to an explicit
// sign-out.
it('signs the user out and navigates home', async () => {
renderProfile();
await screen.findByText('Games Played');
await userEvent.click(screen.getByRole('button', { name: 'Sign Out' }));
expect(await screen.findByText('home route')).toBeInTheDocument();
});
});
+9 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useNavigate, Link } from 'react-router';
import { Button } from '@/components/ui/button';
import { buttonVariants } from '@/components/ui/button-variants';
@@ -45,11 +45,17 @@ export default function Profile() {
const [passwordError, setPasswordError] = useState('');
const [passwordSuccess, setPasswordSuccess] = useState('');
// Set by the page's own Sign Out. The auth guard below re-runs the moment
// the store's user becomes null, and without this it would send a user who
// just chose to sign out to the sign-in screen, overriding the navigation
// to home that the handler had already issued.
const signingOut = useRef(false);
// Game history is independent of statsMode, so it is fetched once.
useEffect(() => {
if (authLoading) return;
if (!userId) {
navigate('/login');
if (!signingOut.current) navigate('/login');
return;
}
const controller = new AbortController();
@@ -92,6 +98,7 @@ export default function Profile() {
}, [userId, authLoading, statsMode]);
const handleLogout = () => {
signingOut.current = true;
logout();
navigate('/');
};
+735
View File
@@ -0,0 +1,735 @@
import { render, screen, waitFor, within } 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';
import type { VersusMatch } from '@/api/versus';
/**
* Versus lobby-browser page: create a match, join or challenge one of the
* open matches, cancel one of your own, and the password-gated variants of
* join/challenge. Requests are intercepted at `@/api/client` and routed by
* URL - the seam `game-store.test.ts` uses, since a second concurrent dynamic
* import of an aliased module has previously resolved to the real file and
* reached the dev backend.
*/
const createMatch = vi.fn();
const getWaitingMatches = vi.fn();
const getMyWaitingMatches = vi.fn();
const joinMatch = vi.fn();
const cancelMatch = vi.fn();
const startAsyncAttempt = vi.fn();
const toastError = vi.fn();
type Handler = (arg: unknown) => Promise<unknown>;
function route(method: string, url: string): Handler {
let m: RegExpMatchArray | null;
if (method === 'post' && url === '/versus') {
return (body) => createMatch(body);
}
if (method === 'get' && url === '/versus/waiting') {
return (config) => getWaitingMatches((config as { params?: unknown })?.params);
}
if (method === 'get' && url === '/versus/me/waiting') {
return () => getMyWaitingMatches();
}
if (method === 'post' && (m = /^\/versus\/([^/]+)\/join$/.exec(url))) {
return (body) => joinMatch(m![1], body);
}
if (method === 'post' && (m = /^\/versus\/([^/]+)\/cancel$/.exec(url))) {
return () => cancelMatch(m![1]);
}
if (method === 'post' && (m = /^\/versus\/([^/]+)\/async-attempt$/.exec(url))) {
return (body) => startAsyncAttempt(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'),
},
};
});
vi.mock('sonner', () => ({
toast: { error: (...args: unknown[]) => toastError(...args) },
}));
const { useAuthStore } = await import('@/stores/auth-store');
const { default: Versus } = await import('./Versus');
// --- Fixtures ---------------------------------------------------------------
const USER = { id: 'user-1', email: 'me@example.com', username: 'Me', isAdmin: false };
function makeMatch(overrides: Partial<VersusMatch> = {}): VersusMatch {
return {
id: 'm1',
movieAId: 100,
movieBId: 200,
difficulty: 'medium',
status: 'waiting',
lobbyName: null,
hasPassword: false,
winnerId: null,
player1: { id: 'creator-1', username: 'Creator' },
player2: null,
player1Score: null,
player2Score: null,
createdAt: '2024-01-01T00:00:00.000Z',
startedAt: null,
finishedAt: null,
isAsync: false,
...overrides,
};
}
function renderVersus(initialEntry = '/versus') {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/versus" element={<Versus />} />
<Route path="/login" element={<div>login route</div>} />
<Route path="/versus/lobby/:matchId" element={<div>lobby route</div>} />
<Route path="/versus/async-game/:matchId" element={<div>async-game route</div>} />
<Route
path="/versus/async/:matchId/leaderboard"
element={<div>leaderboard route</div>}
/>
</Routes>
</MemoryRouter>,
);
}
/** Waits for the initial matches fetch to settle (skeletons gone). */
async function waitForLoaded() {
await waitFor(() =>
expect(screen.queryByText('No open matches right now. Create one!')).not.toBeNull(),
);
}
beforeEach(() => {
vi.clearAllMocks();
useAuthStore.setState({ user: USER, isLoading: false, error: null });
getWaitingMatches.mockResolvedValue([]);
getMyWaitingMatches.mockResolvedValue([]);
});
describe('authentication gate', () => {
it('redirects to login when nobody is signed in', async () => {
useAuthStore.setState({ user: null, isLoading: false });
renderVersus();
expect(await screen.findByText('login route')).toBeInTheDocument();
});
it('does not redirect while auth is still resolving', () => {
useAuthStore.setState({ user: null, isLoading: true });
renderVersus();
expect(screen.queryByText('login route')).toBeNull();
});
});
describe('loading and empty states', () => {
it('shows skeletons while the initial fetch is in flight, then the empty state', async () => {
let resolveWaiting: (v: VersusMatch[]) => void = () => undefined;
getWaitingMatches.mockImplementation(
() => new Promise((resolve) => (resolveWaiting = resolve)),
);
renderVersus();
expect(screen.getByText('Open Matches')).toBeInTheDocument();
expect(screen.queryByText('No open matches right now. Create one!')).toBeNull();
resolveWaiting([]);
await waitForLoaded();
});
it('falls back to empty lists rather than crashing when the fetch fails', async () => {
getWaitingMatches.mockRejectedValue(new Error('500'));
getMyWaitingMatches.mockRejectedValue(new Error('500'));
renderVersus();
await waitForLoaded();
expect(screen.queryByText('My Matches')).toBeNull();
});
});
describe('rendering open and my matches', () => {
it('renders an open match with its badges and by-line', async () => {
getWaitingMatches.mockResolvedValue([
makeMatch({
id: 'open-1',
lobbyName: 'Friday Night',
hasPassword: true,
difficulty: 'hard',
}),
]);
renderVersus();
expect(await screen.findByText('Friday Night')).toBeInTheDocument();
expect(screen.getByText('by Creator')).toBeInTheDocument();
expect(screen.getByText('LIVE')).toBeInTheDocument();
expect(screen.getByText('hard')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Join Friday Night' }),
).toBeInTheDocument();
});
it('shows an async open match with chain length, challenger count and expiry', async () => {
const future = new Date(Date.now() + 90 * 60 * 1000).toISOString();
getWaitingMatches.mockResolvedValue([
makeMatch({
id: 'open-async',
lobbyName: 'Async One',
isAsync: true,
chainLength: 7,
attemptCount: 4,
expiresAt: future,
}),
]);
renderVersus();
expect(await screen.findByText('Async One')).toBeInTheDocument();
expect(screen.getByText('ASYNC')).toBeInTheDocument();
expect(screen.getByText('Chain: 3 links')).toBeInTheDocument();
expect(screen.getByText('4 challengers')).toBeInTheDocument();
expect(screen.getByText(/1h \d+m/)).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Challenge Async One' }),
).toBeInTheDocument();
});
it('labels an expired async match', async () => {
const past = new Date(Date.now() - 1000).toISOString();
getWaitingMatches.mockResolvedValue([
makeMatch({ id: 'open-expired', lobbyName: 'Old', isAsync: true, expiresAt: past }),
]);
renderVersus();
expect(await screen.findByText('Expired')).toBeInTheDocument();
});
it('falls back to the creator username when a match has no lobby name', async () => {
getWaitingMatches.mockResolvedValue([makeMatch({ id: 'noname' })]);
renderVersus();
expect(await screen.findByText("Creator's match")).toBeInTheDocument();
});
it('falls back to "Unknown" when the match carries no creator info at all', async () => {
getWaitingMatches.mockResolvedValue([
makeMatch({
id: 'no-player1',
player1: undefined as unknown as VersusMatch['player1'],
}),
]);
renderVersus();
expect(await screen.findByText("Unknown's match")).toBeInTheDocument();
expect(screen.getByText('by Unknown')).toBeInTheDocument();
});
it('defaults an async open match with no chain length, expiry or attempts to 0 challengers and no extra badges', async () => {
getWaitingMatches.mockResolvedValue([
makeMatch({ id: 'bare-async', lobbyName: 'Bare Async', isAsync: true }),
]);
renderVersus();
await screen.findByText('Bare Async');
expect(screen.getByText('0 challengers')).toBeInTheDocument();
expect(screen.queryByText(/Chain:/)).toBeNull();
});
it('renders my waiting matches with a cancel control, and rejoin for a live match', async () => {
getMyWaitingMatches.mockResolvedValue([
makeMatch({ id: 'mine-1', lobbyName: 'Mine', status: 'waiting', player1: USER }),
]);
renderVersus();
expect(await screen.findByText('My Matches')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Cancel Mine' }),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Rejoin' })).toBeInTheDocument();
});
it('shows a leaderboard link for an open async match of mine, and challenger count', async () => {
getMyWaitingMatches.mockResolvedValue([
makeMatch({
id: 'mine-async',
lobbyName: 'Mine Async',
isAsync: true,
status: 'open',
attemptCount: 2,
player1: USER,
}),
]);
renderVersus();
expect(await screen.findByText('My Matches')).toBeInTheDocument();
expect(screen.getByText('2 challengers')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Leaderboard' }));
expect(await screen.findByText('leaderboard route')).toBeInTheDocument();
});
it('lets me navigate back into my own async game to play it', async () => {
getMyWaitingMatches.mockResolvedValue([
makeMatch({
id: 'mine-play',
lobbyName: 'Play Me',
isAsync: true,
status: 'waiting',
player1: USER,
}),
]);
renderVersus();
await userEvent.click(await screen.findByRole('button', { name: 'Play' }));
expect(await screen.findByText('async-game route')).toBeInTheDocument();
});
it('does not offer cancel on a match that is no longer waiting', async () => {
getMyWaitingMatches.mockResolvedValue([
makeMatch({ id: 'mine-open', lobbyName: 'Open Now', status: 'open', player1: USER }),
]);
renderVersus();
await screen.findByText('Open Now');
expect(screen.queryByRole('button', { name: /Cancel/ })).toBeNull();
});
it('rejoins a live match of mine via its lobby', async () => {
getMyWaitingMatches.mockResolvedValue([
makeMatch({ id: 'mine-live', lobbyName: 'Live Mine', status: 'open', player1: USER }),
]);
renderVersus();
await userEvent.click(await screen.findByRole('button', { name: 'Rejoin' }));
expect(await screen.findByText('lobby route')).toBeInTheDocument();
});
it('shows an unnamed, password-locked match of mine with a generic cancel label', async () => {
getMyWaitingMatches.mockResolvedValue([
makeMatch({
id: 'mine-unnamed',
lobbyName: null,
hasPassword: true,
status: 'waiting',
player1: USER,
}),
]);
renderVersus();
expect(await screen.findByText('Unnamed match')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel match' })).toBeInTheDocument();
});
it('defaults an open async match of mine with no attempts to 0 challengers', async () => {
getMyWaitingMatches.mockResolvedValue([
makeMatch({
id: 'mine-bare',
lobbyName: 'Mine Bare',
isAsync: true,
status: 'open',
player1: USER,
}),
]);
renderVersus();
await screen.findByText('Mine Bare');
expect(screen.getByText('0 challengers')).toBeInTheDocument();
});
});
describe('refresh and filters', () => {
it('refetches when Refresh is clicked', async () => {
renderVersus();
await waitForLoaded();
expect(getWaitingMatches).toHaveBeenCalledTimes(1);
await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
await waitFor(() => expect(getWaitingMatches).toHaveBeenCalledTimes(2));
});
it('requests a mode-filtered list when a filter tab is chosen', async () => {
renderVersus();
await waitForLoaded();
expect(getWaitingMatches).toHaveBeenLastCalledWith(
expect.objectContaining({ excludeUser: 'user-1' }),
);
await userEvent.click(screen.getByRole('button', { name: 'Async' }));
await waitFor(() =>
expect(getWaitingMatches).toHaveBeenLastCalledWith(
expect.objectContaining({ excludeUser: 'user-1', mode: 'async' }),
),
);
});
it('does not let a stale filter response overwrite a fresher one', async () => {
let resolveAll: (v: VersusMatch[]) => void = () => undefined;
getWaitingMatches.mockImplementationOnce(
() => new Promise((resolve) => (resolveAll = resolve)),
);
renderVersus();
// The first (all-mode) request is still pending.
getWaitingMatches.mockResolvedValueOnce([
makeMatch({ id: 'sync-1', lobbyName: 'Sync Match' }),
]);
await userEvent.click(screen.getByRole('button', { name: 'Live' }));
expect(await screen.findByText('Sync Match')).toBeInTheDocument();
// The stale "all" request now resolves after the newer one already won.
resolveAll([makeMatch({ id: 'all-1', lobbyName: 'All Match' })]);
await new Promise((r) => setTimeout(r, 0));
expect(screen.getByText('Sync Match')).toBeInTheDocument();
expect(screen.queryByText('All Match')).toBeNull();
});
});
describe('creating a match', () => {
it('creates a live match with the chosen difficulty, name and password, then goes to the lobby', async () => {
createMatch.mockResolvedValue(makeMatch({ id: 'new-1', isAsync: false }));
renderVersus();
await waitForLoaded();
await userEvent.click(screen.getByRole('button', { name: 'Hard' }));
await userEvent.type(
screen.getByRole('textbox', { name: 'Lobby name (optional)' }),
'My Lobby',
);
await userEvent.type(
screen.getByLabelText('Lobby password (optional)'),
'secret',
);
await userEvent.click(screen.getByRole('button', { name: 'Create Match' }));
expect(createMatch).toHaveBeenCalledWith({
difficulty: 'hard',
lobbyName: 'My Lobby',
password: 'secret',
isAsync: undefined,
});
expect(await screen.findByText('lobby route')).toBeInTheDocument();
});
it('creates an async match and goes straight to the async game', async () => {
createMatch.mockResolvedValue(makeMatch({ id: 'new-async', isAsync: true }));
renderVersus();
await waitForLoaded();
await userEvent.click(screen.getByRole('checkbox'));
expect(
screen.getByText(/Play first, then others challenge your score/),
).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Create Match' }));
expect(createMatch).toHaveBeenCalledWith(
expect.objectContaining({ isAsync: true }),
);
expect(await screen.findByText('async-game route')).toBeInTheDocument();
});
it('shows a toast and stops the spinner when creation fails', async () => {
let rejectCreate: (e: unknown) => void = () => undefined;
createMatch.mockImplementation(
() => new Promise((_resolve, reject) => (rejectCreate = reject)),
);
renderVersus();
await waitForLoaded();
const createButton = screen.getByRole('button', { name: /Create Match|Creating/ });
await userEvent.click(createButton);
expect(screen.getByRole('button', { name: 'Creating...' })).toBeDisabled();
rejectCreate(new Error('500'));
await waitFor(() =>
expect(toastError).toHaveBeenCalledWith('Failed to create match. Please try again.'),
);
expect(
screen.getByRole('button', { name: 'Create Match' }),
).toBeEnabled();
});
});
describe('joining a match', () => {
it('joins a match without a password directly', async () => {
getWaitingMatches.mockResolvedValue([makeMatch({ id: 'open-1', lobbyName: 'Open' })]);
joinMatch.mockResolvedValue(makeMatch({ id: 'open-1' }));
renderVersus();
await userEvent.click(await screen.findByRole('button', { name: 'Join Open' }));
expect(joinMatch).toHaveBeenCalledWith('open-1', { password: undefined });
expect(await screen.findByText('lobby route')).toBeInTheDocument();
});
it('prompts for a password on a locked match, and joins on the correct one', async () => {
getWaitingMatches.mockResolvedValue([
makeMatch({ id: 'locked-1', lobbyName: 'Locked', hasPassword: true }),
]);
joinMatch.mockResolvedValue(makeMatch({ id: 'locked-1' }));
renderVersus();
await userEvent.click(await screen.findByRole('button', { name: 'Join Locked' }));
const dialog = await screen.findByRole('dialog', { name: 'Enter Password' });
const submit = within(dialog).getByRole('button', { name: 'Join Match' });
expect(submit).toBeDisabled();
await userEvent.type(within(dialog).getByLabelText('Match password'), 'right');
expect(submit).toBeEnabled();
await userEvent.click(submit);
expect(joinMatch).toHaveBeenCalledWith('locked-1', { password: 'right' });
expect(await screen.findByText('lobby route')).toBeInTheDocument();
});
it('shows the server error and keeps the dialog open on a wrong password', async () => {
getWaitingMatches.mockResolvedValue([
makeMatch({ id: 'locked-2', lobbyName: 'Locked2', hasPassword: true }),
]);
joinMatch.mockRejectedValue({ response: { data: { message: 'Incorrect password' } } });
renderVersus();
await userEvent.click(await screen.findByRole('button', { name: 'Join Locked2' }));
const dialog = await screen.findByRole('dialog');
await userEvent.type(within(dialog).getByLabelText('Match password'), 'wrong');
await userEvent.click(within(dialog).getByRole('button', { name: 'Join Match' }));
expect(await within(dialog).findByText('Incorrect password')).toBeInTheDocument();
expect(dialog).toBeInTheDocument();
});
it('submits the password dialog on Enter', async () => {
getWaitingMatches.mockResolvedValue([
makeMatch({ id: 'locked-3', lobbyName: 'Locked3', hasPassword: true }),
]);
joinMatch.mockResolvedValue(makeMatch({ id: 'locked-3' }));
renderVersus();
await userEvent.click(await screen.findByRole('button', { name: 'Join Locked3' }));
const dialog = await screen.findByRole('dialog');
await userEvent.type(within(dialog).getByLabelText('Match password'), 'right{Enter}');
expect(await screen.findByText('lobby route')).toBeInTheDocument();
});
it('clears a previous password error as soon as the field changes', async () => {
getWaitingMatches.mockResolvedValue([
makeMatch({ id: 'locked-4', lobbyName: 'Locked4', hasPassword: true }),
]);
joinMatch.mockRejectedValueOnce({ response: { data: { message: 'nope' } } });
renderVersus();
await userEvent.click(await screen.findByRole('button', { name: 'Join Locked4' }));
const dialog = await screen.findByRole('dialog');
const field = within(dialog).getByLabelText('Match password');
await userEvent.type(field, 'wrong');
await userEvent.click(within(dialog).getByRole('button', { name: 'Join Match' }));
await within(dialog).findByText('nope');
await userEvent.type(field, 'x');
expect(within(dialog).queryByText('nope')).toBeNull();
});
it('disables the password submit button while the join is in flight', async () => {
getWaitingMatches.mockResolvedValue([
makeMatch({ id: 'locked-6', lobbyName: 'Locked6', hasPassword: true }),
]);
let releaseJoin: (v: VersusMatch) => void = () => undefined;
joinMatch.mockImplementation(
() => new Promise((resolve) => (releaseJoin = resolve)),
);
renderVersus();
await userEvent.click(await screen.findByRole('button', { name: 'Join Locked6' }));
const dialog = await screen.findByRole('dialog');
await userEvent.type(within(dialog).getByLabelText('Match password'), 'right');
const submit = within(dialog).getByRole('button', { name: 'Join Match' });
await userEvent.click(submit);
expect(submit).toBeDisabled();
releaseJoin(makeMatch({ id: 'locked-6' }));
await screen.findByText('lobby route');
});
it('closes the dialog when dismissed without joining', async () => {
getWaitingMatches.mockResolvedValue([
makeMatch({ id: 'locked-5', lobbyName: 'Locked5', hasPassword: true }),
]);
renderVersus();
await userEvent.click(await screen.findByRole('button', { name: 'Join Locked5' }));
const dialog = await screen.findByRole('dialog');
await userEvent.keyboard('{Escape}');
await waitFor(() => expect(dialog).not.toBeInTheDocument());
expect(joinMatch).not.toHaveBeenCalled();
});
});
describe('challenging an async match', () => {
it('labels an anonymous, unnamed async match by "Unknown" in the challenge button too', async () => {
getWaitingMatches.mockResolvedValue([
makeMatch({
id: 'a-unknown',
lobbyName: null,
isAsync: true,
player1: undefined as unknown as VersusMatch['player1'],
}),
]);
renderVersus();
expect(
await screen.findByRole('button', { name: "Challenge Unknown's match" }),
).toBeInTheDocument();
});
it('starts a challenge and goes to the async game', async () => {
getWaitingMatches.mockResolvedValue([
makeMatch({ id: 'a-1', lobbyName: 'Challenge Me', isAsync: true }),
]);
startAsyncAttempt.mockResolvedValue({
attempt: { id: 'att-1', matchId: 'a-1', playerId: 'user-1' },
movieAId: 1,
movieATitle: 'A',
movieBId: 2,
movieBTitle: 'B',
chainLength: 5,
});
renderVersus();
await userEvent.click(await screen.findByRole('button', { name: 'Challenge Challenge Me' }));
expect(startAsyncAttempt).toHaveBeenCalledWith('a-1', { password: undefined });
expect(await screen.findByText('async-game route')).toBeInTheDocument();
});
it('redirects to the leaderboard when the match was already attempted', async () => {
getWaitingMatches.mockResolvedValue([
makeMatch({ id: 'a-2', lobbyName: 'Already', isAsync: true }),
]);
startAsyncAttempt.mockRejectedValue({
response: { data: { message: 'You have already attempted this match' } },
});
renderVersus();
await userEvent.click(await screen.findByRole('button', { name: 'Challenge Already' }));
expect(await screen.findByText('leaderboard route')).toBeInTheDocument();
});
it('prompts for a password before challenging a locked async match', async () => {
getWaitingMatches.mockResolvedValue([
makeMatch({ id: 'a-3', lobbyName: 'Locked Async', isAsync: true, hasPassword: true }),
]);
startAsyncAttempt.mockResolvedValue({
attempt: { id: 'att-3', matchId: 'a-3', playerId: 'user-1' },
movieAId: 1,
movieATitle: 'A',
movieBId: 2,
movieBTitle: 'B',
chainLength: 5,
});
renderVersus();
await userEvent.click(
await screen.findByRole('button', { name: 'Challenge Locked Async' }),
);
const dialog = await screen.findByRole('dialog');
expect(
within(dialog).getByRole('button', { name: 'Start Challenge' }),
).toBeInTheDocument();
await userEvent.type(within(dialog).getByLabelText('Match password'), 'pw');
await userEvent.click(within(dialog).getByRole('button', { name: 'Start Challenge' }));
expect(startAsyncAttempt).toHaveBeenCalledWith('a-3', { password: 'pw' });
expect(await screen.findByText('async-game route')).toBeInTheDocument();
});
it('falls back to "Incorrect password" when a locked challenge fails without a message', async () => {
getWaitingMatches.mockResolvedValue([
makeMatch({ id: 'a-4', lobbyName: 'Locked4', isAsync: true, hasPassword: true }),
]);
startAsyncAttempt.mockRejectedValue(new Error('Network Error'));
renderVersus();
await userEvent.click(await screen.findByRole('button', { name: 'Challenge Locked4' }));
const dialog = await screen.findByRole('dialog');
await userEvent.type(within(dialog).getByLabelText('Match password'), 'pw');
await userEvent.click(within(dialog).getByRole('button', { name: 'Start Challenge' }));
expect(
await within(dialog).findByText('Network error — check your connection and try again.'),
).toBeInTheDocument();
});
it('disables the challenge button for the row currently in flight only', async () => {
getWaitingMatches.mockResolvedValue([
makeMatch({ id: 'a-5', lobbyName: 'Slow', isAsync: true }),
makeMatch({ id: 'a-6', lobbyName: 'Other', isAsync: true }),
]);
let releaseChallenge: (v: unknown) => void = () => undefined;
startAsyncAttempt.mockImplementation(
() => new Promise((resolve) => (releaseChallenge = resolve)),
);
renderVersus();
const slowButton = await screen.findByRole('button', { name: 'Challenge Slow' });
const otherButton = screen.getByRole('button', { name: 'Challenge Other' });
await userEvent.click(slowButton);
expect(slowButton).toBeDisabled();
expect(otherButton).toBeEnabled();
releaseChallenge({
attempt: { id: 'att', matchId: 'a-5', playerId: 'user-1' },
movieAId: 1,
movieATitle: 'A',
movieBId: 2,
movieBTitle: 'B',
chainLength: 5,
});
await screen.findByText('async-game route');
});
});
describe('cancelling a match', () => {
it('removes the match from My Matches and shows a spinner while in flight', async () => {
getMyWaitingMatches.mockResolvedValue([
makeMatch({ id: 'mine-cancel', lobbyName: 'Cancel Me', status: 'waiting', player1: USER }),
]);
let resolveCancel: () => void = () => undefined;
cancelMatch.mockImplementation(
() => new Promise<void>((resolve) => (resolveCancel = resolve)),
);
renderVersus();
const cancelButton = await screen.findByRole('button', { name: 'Cancel Cancel Me' });
await userEvent.click(cancelButton);
expect(cancelButton).toBeDisabled();
resolveCancel();
await waitFor(() => expect(screen.queryByText('Cancel Me')).toBeNull());
expect(cancelMatch).toHaveBeenCalledWith('mine-cancel');
});
it('re-enables the row if cancelling fails, and leaves the match in place', async () => {
getMyWaitingMatches.mockResolvedValue([
makeMatch({ id: 'mine-fail', lobbyName: 'Stays', status: 'waiting', player1: USER }),
]);
cancelMatch.mockRejectedValue(new Error('500'));
renderVersus();
const cancelButton = await screen.findByRole('button', { name: 'Cancel Stays' });
await userEvent.click(cancelButton);
await waitFor(() => expect(cancelButton).toBeEnabled());
expect(screen.getByText('Stays')).toBeInTheDocument();
});
});
+289
View File
@@ -0,0 +1,289 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ActorChainLink, MovieChainLink, ScoreBreakdown } from '@/types';
/**
* The live versus game screen: it has no fetch of its own, only three
* effects layered on the shared game store and the versus socket store - the
* idle redirect, broadcasting the player's own chain progress, and reporting
* completion once. The socket is faked at `@/lib/create-socket` (the seam
* this suite is told to use for sockets) so `versus-store`'s real `connect()`
* attaches real handlers to it, and server events are fired directly.
*/
type EventHandler = (data: unknown) => void;
interface FakeSocket {
id: string;
connected: boolean;
on: (event: string, handler: EventHandler) => FakeSocket;
off: (event: string, handler: EventHandler) => FakeSocket;
once: (event: string, handler: EventHandler) => FakeSocket;
emit: (event: string, ...args: unknown[]) => void;
disconnect: () => void;
connect: () => void;
removeAllListeners: () => void;
fire: (event: string, data?: unknown) => void;
}
function makeFakeSocket(): FakeSocket {
const handlers = new Map<string, Set<EventHandler>>();
const socket: FakeSocket = {
id: 'fake-socket-1',
connected: true,
on: vi.fn((event: string, handler: EventHandler) => {
if (!handlers.has(event)) handlers.set(event, new Set());
handlers.get(event)?.add(handler);
return socket;
}),
off: vi.fn((event: string, handler: EventHandler) => {
handlers.get(event)?.delete(handler);
return socket;
}),
once: vi.fn(() => socket),
emit: vi.fn(),
disconnect: vi.fn(),
connect: vi.fn(),
removeAllListeners: vi.fn(() => handlers.clear()),
fire: (event: string, data?: unknown) => {
for (const handler of handlers.get(event) ?? []) handler(data);
},
};
return socket;
}
const createSocket = vi.fn();
vi.mock('@/lib/create-socket', () => ({
createSocket: (namespace: string) => createSocket(namespace),
}));
const { useAuthStore } = await import('@/stores/auth-store');
const { useGameStore } = await import('@/stores/game-store');
const { useVersusStore } = await import('@/stores/versus-store');
const { default: VersusGame } = await import('./VersusGame');
// --- Fixtures ---------------------------------------------------------------
const USER = { id: 'user-1', email: 'me@example.com', username: 'Me', isAdmin: false };
const PLAYER1 = { id: 'user-1', username: 'Me' };
const PLAYER2 = { id: 'user-2', username: 'Opponent' };
const MOVIE_A: MovieChainLink = {
type: 'movie',
id: 100,
title: 'Movie A',
posterPath: null,
releaseDate: '2000-01-01',
popularity: 5,
};
const MOVIE_B: MovieChainLink = {
type: 'movie',
id: 200,
title: 'Movie B',
posterPath: null,
releaseDate: '2000-01-01',
popularity: 5,
};
const actor = (id: number): ActorChainLink => ({
type: 'actor',
id,
name: `Actor ${id}`,
profilePath: null,
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,
};
let fakeSocket: FakeSocket;
function renderGame() {
return render(
<MemoryRouter initialEntries={['/versus/game']}>
<Routes>
<Route path="/versus/game" element={<VersusGame />} />
<Route path="/versus" element={<div>versus route</div>} />
</Routes>
</MemoryRouter>,
);
}
/** Seeds a playing game and a connected versus session, as if arriving from
* the lobby after `startGameFromVersus` and `connect()`/`joinLobby()` already
* ran there. */
function seedPlayingGame() {
useGameStore.setState({
status: 'playing',
movieA: MOVIE_A,
movieB: MOVIE_B,
chain: [MOVIE_A],
currentSearchMode: 'actor',
sessionId: 'session-1',
score: null,
});
act(() => {
useVersusStore.getState().connect();
});
useVersusStore.setState({ matchId: 'match-1', player1: PLAYER1, player2: PLAYER2 });
}
beforeEach(() => {
vi.clearAllMocks();
useAuthStore.setState({ user: USER, isLoading: false, error: null });
useGameStore.getState().resetGame();
useVersusStore.setState({
socket: null,
connected: false,
matchId: null,
lobbyState: 'idle',
countdownValue: null,
player1: null,
player2: null,
opponentChainLength: 0,
opponentFinished: false,
matchResult: null,
myScore: null,
});
createSocket.mockImplementation(() => {
fakeSocket = makeFakeSocket();
return fakeSocket;
});
});
afterEach(() => {
useVersusStore.getState().reset();
useGameStore.getState().resetGame();
});
describe('idle redirect', () => {
it('sends the player back to Versus when there is no game in progress', async () => {
renderGame();
expect(await screen.findByText('versus route')).toBeInTheDocument();
});
});
describe('a game in progress', () => {
it('renders the shared game UI and the opponent panel', () => {
seedPlayingGame();
renderGame();
expect(screen.getAllByText('Movie A').length).toBeGreaterThan(0);
expect(screen.getByText('Opponent')).toBeInTheDocument();
expect(screen.getByText('Chain: 0 links')).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: 'Search for an actor' })).toBeInTheDocument();
});
});
describe('chain-update broadcast', () => {
it('broadcasts the chain length whenever it grows', () => {
seedPlayingGame();
renderGame();
expect(fakeSocket.emit).toHaveBeenCalledWith('chain-update', {
matchId: 'match-1',
chainLength: 1,
});
act(() => {
useGameStore.getState().addActorToChain(actor(1));
});
expect(fakeSocket.emit).toHaveBeenCalledWith('chain-update', {
matchId: 'match-1',
chainLength: 2,
});
});
it('shows the opponent`s own progress as it is reported over the socket', () => {
seedPlayingGame();
renderGame();
act(() => {
fakeSocket.fire('opponent-progress', { chainLength: 4 });
});
expect(screen.getByText('Chain: 1 links')).toBeInTheDocument();
});
});
describe('completion', () => {
function completeTheGame() {
act(() => {
useGameStore.setState({
chain: [MOVIE_A, actor(1), MOVIE_B, actor(2), MOVIE_A],
status: 'completed',
score: SCORE,
});
});
}
const matchCompleteCalls = () =>
(fakeSocket.emit as ReturnType<typeof vi.fn>).mock.calls.filter(
(call) => call[0] === 'match-complete',
);
it('sends the completed session to the match, exactly once, and shows the waiting screen', () => {
seedPlayingGame();
renderGame();
vi.clearAllMocks();
completeTheGame();
expect(fakeSocket.emit).toHaveBeenCalledWith('match-complete', {
matchId: 'match-1',
sessionId: 'session-1',
});
expect(matchCompleteCalls()).toHaveLength(1);
expect(screen.getByText("You finished!")).toBeInTheDocument();
expect(screen.getByText('2400')).toBeInTheDocument();
expect(screen.getByText('Waiting for opponent to finish...')).toBeInTheDocument();
// A benign re-render (the opponent's own progress ticking in) must not
// resend the already-reported completion.
act(() => {
fakeSocket.fire('opponent-progress', { chainLength: 3 });
});
expect(matchCompleteCalls()).toHaveLength(1);
});
it('shows the match result once the server reports both players finished', () => {
seedPlayingGame();
renderGame();
completeTheGame();
act(() => {
fakeSocket.fire('match-finished', {
winnerId: PLAYER1.id,
player1: PLAYER1,
player2: PLAYER2,
player1Score: SCORE,
player2Score: { ...SCORE, totalScore: 1000 },
});
});
expect(screen.getByText('Match Complete!')).toBeInTheDocument();
expect(screen.getByText('You win!')).toBeInTheDocument();
});
it('lets a player stuck waiting on an opponent bail out back to Versus', async () => {
seedPlayingGame();
renderGame();
completeTheGame();
await screen.findByText('Waiting for opponent to finish...');
await userEvent.click(screen.getByRole('button', { name: 'Back to Versus' }));
expect(await screen.findByText('versus route')).toBeInTheDocument();
expect(fakeSocket.disconnect).toHaveBeenCalled();
});
});
+375
View File
@@ -0,0 +1,375 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* The versus lobby: connect over the socket, join, watch for the opponent,
* run the host's countdown and hand off to the live game. The socket is
* faked at `@/lib/create-socket` - the seam this suite is told to use - so
* server events are fired directly and emits are asserted, with nothing
* touching a real connection. Game-session creation still goes through
* `@/api/client`, routed by URL as `game-store.test.ts` does, since a second
* concurrent dynamic import of an aliased module has previously resolved to
* the real file and reached the dev backend.
*/
const getMovieDetails = vi.fn();
const createGameSession = vi.fn();
const toastError = 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);
}
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'),
},
};
});
vi.mock('sonner', () => ({
toast: { error: (...args: unknown[]) => toastError(...args) },
}));
type EventHandler = (data: unknown) => void;
interface FakeSocket {
id: string;
connected: boolean;
on: (event: string, handler: EventHandler) => FakeSocket;
off: (event: string, handler: EventHandler) => FakeSocket;
once: (event: string, handler: EventHandler) => FakeSocket;
emit: (event: string, ...args: unknown[]) => void;
disconnect: () => void;
connect: () => void;
removeAllListeners: () => void;
fire: (event: string, data?: unknown) => void;
}
function makeFakeSocket(): FakeSocket {
const handlers = new Map<string, Set<EventHandler>>();
const socket: FakeSocket = {
id: 'fake-socket-1',
connected: false,
on: vi.fn((event: string, handler: EventHandler) => {
if (!handlers.has(event)) handlers.set(event, new Set());
handlers.get(event)?.add(handler);
return socket;
}),
off: vi.fn((event: string, handler: EventHandler) => {
handlers.get(event)?.delete(handler);
return socket;
}),
once: vi.fn(() => socket),
emit: vi.fn(),
disconnect: vi.fn(),
connect: vi.fn(),
removeAllListeners: vi.fn(() => handlers.clear()),
fire: (event: string, data?: unknown) => {
for (const handler of handlers.get(event) ?? []) handler(data);
},
};
return socket;
}
const createSocket = vi.fn();
vi.mock('@/lib/create-socket', () => ({
createSocket: (namespace: string) => createSocket(namespace),
}));
const { useAuthStore } = await import('@/stores/auth-store');
const { useVersusStore } = await import('@/stores/versus-store');
const { useGameStore } = await import('@/stores/game-store');
const { default: VersusLobby } = await import('./VersusLobby');
// --- Fixtures ---------------------------------------------------------------
const USER = { id: 'user-1', email: 'me@example.com', username: 'Me', isAdmin: false };
const PLAYER1 = { id: 'user-1', username: 'Me' };
const PLAYER2 = { id: 'user-2', username: 'Opponent' };
const movieDetails = (id: number, title: string) => ({
id,
title,
poster_path: null,
release_date: '2000-01-01',
popularity: 10,
});
let fakeSocket: FakeSocket;
function renderLobby(initialEntry = '/versus/lobby/match-1') {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/versus/lobby/:matchId" element={<VersusLobby />} />
<Route path="/versus" element={<div>versus route</div>} />
<Route path="/versus/game" element={<div>game route</div>} />
</Routes>
</MemoryRouter>,
);
}
const flush = async () => {
for (let i = 0; i < 6; i++) {
await new Promise<void>((r) => setTimeout(r, 0));
}
};
async function connectAndJoin() {
fakeSocket.fire('connect');
await flush();
}
beforeEach(() => {
vi.clearAllMocks();
useAuthStore.setState({ user: USER, isLoading: false, error: null });
useGameStore.getState().resetGame();
useVersusStore.setState({
socket: null,
connected: false,
matchId: null,
lobbyState: 'idle',
countdownValue: null,
player1: null,
player2: null,
opponentChainLength: 0,
opponentFinished: false,
matchResult: null,
myScore: null,
});
createSocket.mockImplementation(() => {
fakeSocket = makeFakeSocket();
return fakeSocket;
});
getMovieDetails.mockImplementation((id: number) =>
Promise.resolve(movieDetails(id, id === 100 ? 'Movie A' : 'Movie B')),
);
createGameSession.mockResolvedValue({ id: 'session-1', startedAt: 'now' });
});
afterEach(() => {
useVersusStore.getState().reset();
useGameStore.getState().resetGame();
});
describe('guard clauses', () => {
it('redirects to /versus when nobody is signed in', async () => {
useAuthStore.setState({ user: null, isLoading: false });
renderLobby();
expect(await screen.findByText('versus route')).toBeInTheDocument();
expect(createSocket).not.toHaveBeenCalled();
});
it('does nothing while auth is still resolving', () => {
useAuthStore.setState({ user: USER, isLoading: true });
renderLobby();
expect(createSocket).not.toHaveBeenCalled();
});
});
describe('connecting', () => {
it('shows a connecting indicator before the socket connects', () => {
renderLobby();
expect(screen.getByText('Connecting...')).toBeInTheDocument();
});
it('joins the lobby by matchId once connected', async () => {
renderLobby();
await connectAndJoin();
expect(fakeSocket.emit).toHaveBeenCalledWith('join-lobby', { matchId: 'match-1' });
});
});
describe('waiting for an opponent', () => {
it('shows the waiting state with a cancel control for the host', async () => {
renderLobby();
await connectAndJoin();
fakeSocket.fire('lobby-state', { player1: PLAYER1, player2: null });
expect(await screen.findByText('Waiting for opponent...')).toBeInTheDocument();
// "Me" also names the signed-in user in the page nav, so scope to the <p>.
expect(screen.getByText('Me', { selector: 'p' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel Match' })).toBeInTheDocument();
});
it('does not offer to cancel for a non-host', async () => {
useAuthStore.setState({ user: { ...USER, id: 'user-2' } });
renderLobby();
await connectAndJoin();
fakeSocket.fire('lobby-state', { player1: PLAYER1, player2: null });
await screen.findByText('Waiting for opponent...');
expect(screen.queryByRole('button', { name: 'Cancel Match' })).toBeNull();
});
it('cancels the match, leaves the lobby and returns to Versus', async () => {
renderLobby();
await connectAndJoin();
fakeSocket.fire('lobby-state', { player1: PLAYER1, player2: null });
await screen.findByRole('button', { name: 'Cancel Match' });
await userEvent.click(screen.getByRole('button', { name: 'Cancel Match' }));
expect(fakeSocket.emit).toHaveBeenCalledWith('leave-lobby', { matchId: 'match-1' });
expect(fakeSocket.disconnect).toHaveBeenCalled();
expect(await screen.findByText('versus route')).toBeInTheDocument();
});
});
describe('opponent joined', () => {
it('shows both players once the opponent arrives', async () => {
renderLobby();
await connectAndJoin();
fakeSocket.fire('player-joined', { player1: PLAYER1, player2: PLAYER2 });
expect(await screen.findByText('Opponent Found!')).toBeInTheDocument();
expect(screen.getByText('Opponent')).toBeInTheDocument();
expect(screen.getByText('Creator')).toBeInTheDocument();
expect(screen.getByText('Challenger')).toBeInTheDocument();
});
it('lets the host start the countdown', async () => {
renderLobby();
await connectAndJoin();
fakeSocket.fire('player-joined', { player1: PLAYER1, player2: PLAYER2 });
await screen.findByText('Opponent Found!');
await userEvent.click(screen.getByRole('button', { name: 'Start Game' }));
expect(fakeSocket.emit).toHaveBeenCalledWith('start-countdown', { matchId: 'match-1' });
});
it('tells a non-host it is waiting on the host to start', async () => {
useAuthStore.setState({ user: { ...USER, id: 'user-2' } });
renderLobby();
await connectAndJoin();
fakeSocket.fire('player-joined', { player1: PLAYER1, player2: PLAYER2 });
expect(await screen.findByText('Waiting for Me to start...')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Start Game' })).toBeNull();
});
it('reverts to waiting when the opponent leaves', async () => {
renderLobby();
await connectAndJoin();
fakeSocket.fire('player-joined', { player1: PLAYER1, player2: PLAYER2 });
await screen.findByText('Opponent Found!');
fakeSocket.fire('player-left');
expect(await screen.findByText('Waiting for opponent...')).toBeInTheDocument();
});
it('lets either player leave the lobby from the opponent-joined screen', async () => {
renderLobby();
await connectAndJoin();
fakeSocket.fire('player-joined', { player1: PLAYER1, player2: PLAYER2 });
await userEvent.click(await screen.findByRole('button', { name: 'Leave Lobby' }));
expect(await screen.findByText('versus route')).toBeInTheDocument();
});
});
describe('countdown', () => {
it('announces the countdown value assertively', async () => {
renderLobby();
await connectAndJoin();
fakeSocket.fire('countdown', { value: 3 });
const timer = await screen.findByRole('timer');
expect(timer).toHaveAttribute('aria-live', 'assertive');
expect(timer).toHaveTextContent('3');
expect(screen.getByText('Get ready...')).toBeInTheDocument();
});
});
describe('game start', () => {
it('starts the game and navigates to it once the server signals game-start', async () => {
renderLobby();
await connectAndJoin();
fakeSocket.fire('game-start', { movieA: { id: 100, title: 'Movie A' }, movieB: { id: 200, title: 'Movie B' }, startTime: 12345 });
await flush();
expect(createGameSession).toHaveBeenCalled();
expect(await screen.findByText('game route')).toBeInTheDocument();
expect(useGameStore.getState().status).toBe('playing');
expect(useGameStore.getState().startTime).toBe(12345);
});
it('shows an error and stays on the lobby when the game fails to start', async () => {
renderLobby();
await connectAndJoin();
createGameSession.mockRejectedValue(new Error('500'));
fakeSocket.fire('game-start', { movieA: { id: 100, title: 'Movie A' }, movieB: { id: 200, title: 'Movie B' }, startTime: 1 });
await flush();
expect(toastError).toHaveBeenCalledWith('Could not start the match. Please try again.');
expect(screen.queryByText('game route')).toBeNull();
});
it('stays quiet when a newer game-start has already superseded this one', async () => {
// Simulates two 'game-start' deliveries in quick succession (e.g. a
// duplicate emit): the first's slow session creation resolves after the
// second has already taken over, and must not report an error or navigate
// twice.
renderLobby();
await connectAndJoin();
let releaseFirst: (v: unknown) => void = () => undefined;
getMovieDetails
.mockImplementationOnce(() => new Promise((resolve) => (releaseFirst = resolve)))
.mockImplementationOnce(() => Promise.resolve(movieDetails(200, 'Movie B')));
fakeSocket.fire('game-start', { movieA: { id: 100, title: 'Movie A' }, movieB: { id: 200, title: 'Movie B' }, startTime: 1 });
fakeSocket.fire('game-start', { movieA: { id: 100, title: 'Movie A' }, movieB: { id: 200, title: 'Movie B' }, startTime: 2 });
await flush();
releaseFirst(movieDetails(100, 'Movie A'));
await flush();
expect(toastError).not.toHaveBeenCalled();
expect(useGameStore.getState().startTime).toBe(2);
});
});
describe('match cancellation from the server', () => {
it('navigates back to Versus when the match is cancelled', async () => {
renderLobby();
await connectAndJoin();
fakeSocket.fire('lobby-state', { player1: PLAYER1, player2: null });
await screen.findByText('Waiting for opponent...');
fakeSocket.fire('match-cancelled');
expect(await screen.findByText('versus route')).toBeInTheDocument();
});
});
describe('disconnected mid-lobby', () => {
it('falls back to the connecting indicator if the socket drops', async () => {
renderLobby();
await connectAndJoin();
fakeSocket.fire('lobby-state', { player1: PLAYER1, player2: null });
await screen.findByText('Waiting for opponent...');
fakeSocket.fire('disconnect');
await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument());
});
});
+287
View File
@@ -0,0 +1,287 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
/**
* Auth store: login/register/logout/loadUser. Requests are intercepted at the
* axios client (the seam `game-store.test.ts` established) so `api/auth.ts`
* runs for real; the notification socket, service-worker registration and
* full-session teardown are each covered by their own test files, so they are
* mocked here to keep this file focused on the store's own logic.
*/
type Handler = (body: unknown) => Promise<unknown>;
const loginHandler = vi.fn();
const registerHandler = vi.fn();
const profileHandler = vi.fn();
function route(method: string, url: string): Handler {
if (method === 'post' && url === '/auth/login') return () => loginHandler();
if (method === 'post' && url === '/auth/register') return () => registerHandler();
if (method === 'get' && url === '/users/me') return () => profileHandler();
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'),
},
};
});
const getAuthToken = vi.fn<() => string | null>();
const setAuthToken = vi.fn();
const removeAuthToken = vi.fn();
vi.mock('@/lib/storage', () => ({
storage: {
getAuthToken: () => getAuthToken(),
setAuthToken: (token: string) => setAuthToken(token),
removeAuthToken: () => removeAuthToken(),
},
}));
const notificationConnect = vi.fn();
vi.mock('@/stores/notification-store', () => ({
useNotificationStore: { getState: () => ({ connect: notificationConnect }) },
}));
const tearDownSession = vi.fn();
vi.mock('@/lib/session-teardown', () => ({
tearDownSession: () => tearDownSession(),
}));
const registerServiceWorker = vi.fn();
vi.mock('@/lib/push', () => ({
registerServiceWorker: () => registerServiceWorker(),
}));
beforeEach(() => {
vi.clearAllMocks();
getAuthToken.mockReturnValue(null);
});
describe('initial isLoading', () => {
// isLoading is seeded once, at module load, from whatever token is stored
// at that moment - so this needs its own fresh module instance per case.
it('starts true when a token is already stored', async () => {
getAuthToken.mockReturnValue('existing-token');
vi.resetModules();
const { useAuthStore } = await import('./auth-store');
expect(useAuthStore.getState().isLoading).toBe(true);
});
it('starts false with no stored token', async () => {
getAuthToken.mockReturnValue(null);
vi.resetModules();
const { useAuthStore } = await import('./auth-store');
expect(useAuthStore.getState().isLoading).toBe(false);
});
});
describe('login/register/logout/loadUser', () => {
// A single, stable store instance for the bulk of the behavioural tests.
let useAuthStore: Awaited<typeof import('./auth-store')>['useAuthStore'];
beforeEach(async () => {
vi.resetModules();
({ useAuthStore } = await import('./auth-store'));
useAuthStore.setState({ user: null, isLoading: false, error: null });
});
describe('login', () => {
it('stores the token, records the user, connects notifications and registers the service worker', async () => {
loginHandler.mockResolvedValue({
access_token: 'jwt-1',
user: { id: 'u1', email: 'a@b.com', username: 'alice', isAdmin: false },
});
await useAuthStore.getState().login('a@b.com', 'pw');
expect(setAuthToken).toHaveBeenCalledWith('jwt-1');
const state = useAuthStore.getState();
expect(state.user).toEqual({
id: 'u1',
email: 'a@b.com',
username: 'alice',
isAdmin: false,
});
expect(state.isLoading).toBe(false);
expect(state.error).toBeNull();
expect(notificationConnect).toHaveBeenCalledTimes(1);
expect(registerServiceWorker).toHaveBeenCalledTimes(1);
});
it('marks isLoading while the request is in flight', async () => {
let release: (v: unknown) => void = () => undefined;
loginHandler.mockImplementation(
() => new Promise((resolve) => (release = resolve)),
);
const pending = useAuthStore.getState().login('a@b.com', 'pw');
expect(useAuthStore.getState().isLoading).toBe(true);
release({
access_token: 'jwt-1',
user: { id: 'u1', email: 'a@b.com', username: 'alice', isAdmin: false },
});
await pending;
expect(useAuthStore.getState().isLoading).toBe(false);
});
it('surfaces the server message, rethrows, and never stores a token', async () => {
loginHandler.mockRejectedValue({
response: { data: { message: 'Invalid credentials' } },
});
await expect(
useAuthStore.getState().login('a@b.com', 'wrong'),
).rejects.toBeDefined();
const state = useAuthStore.getState();
expect(state.error).toBe('Invalid credentials');
expect(state.isLoading).toBe(false);
expect(setAuthToken).not.toHaveBeenCalled();
expect(notificationConnect).not.toHaveBeenCalled();
});
it('falls back to a generic login message for an unrecognised failure', async () => {
loginHandler.mockRejectedValue(new Error('boom'));
await expect(useAuthStore.getState().login('a@b.com', 'pw')).rejects.toThrow();
expect(useAuthStore.getState().error).toBe(
'Login failed. Please try again.',
);
});
});
describe('register', () => {
it('stores the token, records the user, connects notifications and registers the service worker', async () => {
registerHandler.mockResolvedValue({
access_token: 'jwt-2',
user: { id: 'u2', email: 'c@d.com', username: 'carol', isAdmin: true },
});
await useAuthStore.getState().register('c@d.com', 'carol', 'pw');
expect(setAuthToken).toHaveBeenCalledWith('jwt-2');
expect(useAuthStore.getState().user).toEqual({
id: 'u2',
email: 'c@d.com',
username: 'carol',
isAdmin: true,
});
expect(notificationConnect).toHaveBeenCalledTimes(1);
expect(registerServiceWorker).toHaveBeenCalledTimes(1);
});
it('falls back to a generic registration message for an unrecognised failure', async () => {
registerHandler.mockRejectedValue(new Error('boom'));
await expect(
useAuthStore.getState().register('c@d.com', 'carol', 'pw'),
).rejects.toThrow();
const state = useAuthStore.getState();
expect(state.error).toBe('Registration failed. Please try again.');
expect(state.isLoading).toBe(false);
expect(setAuthToken).not.toHaveBeenCalled();
});
it('surfaces a server validation message on registration failure', async () => {
registerHandler.mockRejectedValue({
response: { data: { message: ['Email already in use'] } },
});
await expect(
useAuthStore.getState().register('c@d.com', 'carol', 'pw'),
).rejects.toBeDefined();
expect(useAuthStore.getState().error).toBe('Email already in use');
});
});
describe('logout', () => {
it('removes the token, tears down the session, and clears the user', () => {
useAuthStore.setState({
user: { id: 'u1', email: 'a@b.com', username: 'alice', isAdmin: false },
error: 'stale error',
});
useAuthStore.getState().logout();
expect(removeAuthToken).toHaveBeenCalledTimes(1);
expect(tearDownSession).toHaveBeenCalledTimes(1);
const state = useAuthStore.getState();
expect(state.user).toBeNull();
expect(state.error).toBeNull();
});
});
describe('loadUser', () => {
it('does nothing without a stored token', async () => {
getAuthToken.mockReturnValue(null);
await useAuthStore.getState().loadUser();
expect(profileHandler).not.toHaveBeenCalled();
expect(useAuthStore.getState().isLoading).toBe(false);
});
it('loads the profile, defaulting a missing isAdmin flag to false', async () => {
getAuthToken.mockReturnValue('jwt-1');
profileHandler.mockResolvedValue({
id: 'u1',
email: 'a@b.com',
username: 'alice',
});
await useAuthStore.getState().loadUser();
const state = useAuthStore.getState();
expect(state.user).toEqual({
id: 'u1',
email: 'a@b.com',
username: 'alice',
isAdmin: false,
});
expect(state.isLoading).toBe(false);
expect(notificationConnect).toHaveBeenCalledTimes(1);
expect(registerServiceWorker).toHaveBeenCalledTimes(1);
});
it('keeps an explicit isAdmin flag from the profile', async () => {
getAuthToken.mockReturnValue('jwt-1');
profileHandler.mockResolvedValue({
id: 'u1',
email: 'a@b.com',
username: 'alice',
isAdmin: true,
});
await useAuthStore.getState().loadUser();
expect(useAuthStore.getState().user?.isAdmin).toBe(true);
});
it('clears the token and the user when the profile request fails', async () => {
getAuthToken.mockReturnValue('expired-token');
profileHandler.mockRejectedValue(new Error('401'));
await useAuthStore.getState().loadUser();
expect(removeAuthToken).toHaveBeenCalledTimes(1);
const state = useAuthStore.getState();
expect(state.user).toBeNull();
expect(state.isLoading).toBe(false);
});
});
});
+465
View File
@@ -0,0 +1,465 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ChatMessage, ConversationEntry } from '@/api/chat';
/**
* chat-store owns the `/chat` socket namespace and reaches the REST endpoints
* through the real `@/api/chat` module. The network seam is the axios
* instance, mocked and routed by URL exactly as game-store.test.ts does -
* mocking `@/api/chat` directly is unreliable (a concurrent dynamic import of
* an aliased module has resolved to the real file and hit the dev backend).
*
* WP-10 repaired two things pinned below: `connect()` now replaces - and
* disconnects - a stale, disconnected socket instead of abandoning it, and
* `disconnect()` clears the chat state (not just the socket).
*/
const getConversations = vi.fn();
const getMessages = vi.fn();
type Handler = (arg: unknown) => Promise<unknown>;
function route(method: string, url: string): Handler {
let m: RegExpMatchArray | null;
if (method === 'get' && url === '/chat/conversations') {
return () => getConversations();
}
if (method === 'get' && (m = /^\/chat\/([^/]+)\/messages$/.exec(url))) {
return () => getMessages(m![1]);
}
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'),
},
};
});
type EventHandler = (payload: unknown) => void;
interface FakeSocket {
id: string;
connected: boolean;
handlers: Map<string, EventHandler>;
on: ReturnType<typeof vi.fn>;
emit: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
removeAllListeners: ReturnType<typeof vi.fn>;
}
function makeSocket(connected = false): FakeSocket {
const handlers = new Map<string, EventHandler>();
return {
id: 'socket-1',
connected,
handlers,
on: vi.fn((event: string, handler: EventHandler) => {
handlers.set(event, handler);
}),
emit: vi.fn(),
disconnect: vi.fn(),
removeAllListeners: vi.fn(),
};
}
/** Invokes a handler registered via `.on()`, failing loudly if it never was. */
function fire(socket: FakeSocket, event: string, payload: unknown): void {
const handler = socket.handlers.get(event);
if (!handler) throw new Error(`no handler registered for "${event}"`);
handler(payload);
}
const createSocketMock = vi.fn<(namespace: string) => FakeSocket | null>();
vi.mock('@/lib/create-socket', () => ({
createSocket: (namespace: string) => createSocketMock(namespace),
}));
const { useChatStore } = await import('./chat-store');
const initialState = useChatStore.getState();
const message = (overrides: Partial<ChatMessage> = {}): ChatMessage => ({
id: 'm1',
senderId: 'friend-1',
recipientId: 'me',
content: 'hi',
createdAt: '2024-01-01T00:00:00Z',
...overrides,
});
const conversation = (): ConversationEntry => ({
friend: { id: 'friend-1', username: 'friend', displayName: null, avatarUrl: null },
lastMessageAt: '2024-01-01T00:00:00Z',
});
beforeEach(() => {
vi.clearAllMocks();
useChatStore.setState(initialState, true);
createSocketMock.mockReturnValue(makeSocket());
});
// --- connect / disconnect (WP-10) -------------------------------------------
describe('connect', () => {
it('creates a socket for the /chat namespace and registers listeners', () => {
const socket = makeSocket();
createSocketMock.mockReturnValue(socket);
useChatStore.getState().connect();
expect(createSocketMock).toHaveBeenCalledWith('/chat');
expect(useChatStore.getState().socket).toBe(socket);
expect(socket.on).toHaveBeenCalledWith('new-message', expect.any(Function));
expect(socket.on).toHaveBeenCalledWith('friend-typing', expect.any(Function));
});
it('does nothing when createSocket returns null (no auth token)', () => {
createSocketMock.mockReturnValue(null);
useChatStore.getState().connect();
expect(useChatStore.getState().socket).toBeNull();
});
it('is a no-op when an existing socket is already connected', () => {
const connected = makeSocket(true);
createSocketMock.mockReturnValueOnce(connected);
useChatStore.getState().connect();
useChatStore.getState().connect();
expect(createSocketMock).toHaveBeenCalledTimes(1);
expect(useChatStore.getState().socket).toBe(connected);
});
it('replaces and disconnects a stale, disconnected socket instead of abandoning it (WP-10)', () => {
const stale = makeSocket(false);
createSocketMock.mockReturnValueOnce(stale);
useChatStore.getState().connect();
expect(useChatStore.getState().socket).toBe(stale);
const fresh = makeSocket(true);
createSocketMock.mockReturnValueOnce(fresh);
useChatStore.getState().connect();
expect(stale.removeAllListeners).toHaveBeenCalledTimes(1);
expect(stale.disconnect).toHaveBeenCalledTimes(1);
expect(createSocketMock).toHaveBeenCalledTimes(2);
expect(useChatStore.getState().socket).toBe(fresh);
});
});
describe('disconnect', () => {
it('closes the socket, cancels pending typing timers, and resets chat state', () => {
vi.useFakeTimers();
try {
const socket = makeSocket();
createSocketMock.mockReturnValue(socket);
useChatStore.getState().connect();
fire(socket, 'friend-typing', { senderId: 'friend-1' });
useChatStore.setState({ activeFriendId: 'friend-1', messages: [message()] });
useChatStore.getState().disconnect();
expect(socket.disconnect).toHaveBeenCalledTimes(1);
const state = useChatStore.getState();
expect(state.socket).toBeNull();
expect(state.messages).toEqual([]);
expect(state.activeFriendId).toBeNull();
// The pending 3s typing timer must have been cancelled, not merely
// orphaned - if it fired it would still mutate typingFriendIds, since
// disconnect() does not touch that field directly.
vi.advanceTimersByTime(5000);
expect(useChatStore.getState().typingFriendIds.has('friend-1')).toBe(true);
} finally {
vi.useRealTimers();
}
});
it('is a no-op without an active socket', () => {
const before = useChatStore.getState();
useChatStore.getState().disconnect();
expect(useChatStore.getState()).toBe(before);
});
});
// --- new-message -------------------------------------------------------------
describe('new-message', () => {
it('appends an incoming message from the active conversation', () => {
const socket = makeSocket();
createSocketMock.mockReturnValue(socket);
useChatStore.getState().connect();
useChatStore.setState({ activeFriendId: 'friend-1', messages: [] });
fire(socket, 'new-message', message());
expect(useChatStore.getState().messages).toEqual([message()]);
});
it('appends a message echoed back to its sender for the active conversation', () => {
const socket = makeSocket();
createSocketMock.mockReturnValue(socket);
useChatStore.getState().connect();
useChatStore.setState({ activeFriendId: 'friend-1', messages: [] });
fire(socket, 'new-message', message({ senderId: 'me', recipientId: 'friend-1' }));
expect(useChatStore.getState().messages).toEqual([
message({ senderId: 'me', recipientId: 'friend-1' }),
]);
});
it('ignores a message for a conversation that is not open', () => {
const socket = makeSocket();
createSocketMock.mockReturnValue(socket);
useChatStore.getState().connect();
useChatStore.setState({ activeFriendId: 'friend-1', messages: [] });
fire(socket, 'new-message', message({ senderId: 'other', recipientId: 'other-2' }));
expect(useChatStore.getState().messages).toEqual([]);
});
it('does not duplicate a message the client already has', () => {
const socket = makeSocket();
createSocketMock.mockReturnValue(socket);
useChatStore.getState().connect();
const existing = message({ id: 'dup1' });
useChatStore.setState({ activeFriendId: 'friend-1', messages: [existing] });
fire(socket, 'new-message', message({ id: 'dup1' }));
expect(useChatStore.getState().messages).toEqual([existing]);
});
});
// --- friend-typing -----------------------------------------------------------
describe('friend-typing', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('marks the sender as typing and clears it after 3s', () => {
const socket = makeSocket();
createSocketMock.mockReturnValue(socket);
useChatStore.getState().connect();
fire(socket, 'friend-typing', { senderId: 'friend-1' });
expect(useChatStore.getState().typingFriendIds.has('friend-1')).toBe(true);
vi.advanceTimersByTime(2999);
expect(useChatStore.getState().typingFriendIds.has('friend-1')).toBe(true);
vi.advanceTimersByTime(1);
expect(useChatStore.getState().typingFriendIds.has('friend-1')).toBe(false);
});
it('restarts the 3s window when another typing event arrives for the same sender', () => {
const socket = makeSocket();
createSocketMock.mockReturnValue(socket);
useChatStore.getState().connect();
fire(socket, 'friend-typing', { senderId: 'friend-1' });
vi.advanceTimersByTime(2000);
fire(socket, 'friend-typing', { senderId: 'friend-1' });
vi.advanceTimersByTime(2000); // 4000ms since the first event, 2000ms since the second
expect(useChatStore.getState().typingFriendIds.has('friend-1')).toBe(true);
vi.advanceTimersByTime(1000); // 3000ms since the second event
expect(useChatStore.getState().typingFriendIds.has('friend-1')).toBe(false);
});
});
// --- REST-backed actions -------------------------------------------------------
describe('fetchConversations', () => {
it('loads conversations from the server', async () => {
getConversations.mockResolvedValue([conversation()]);
await useChatStore.getState().fetchConversations();
expect(useChatStore.getState().conversations).toEqual([conversation()]);
});
it('logs and leaves state unchanged on failure', async () => {
getConversations.mockRejectedValue(new Error('network'));
const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined);
await useChatStore.getState().fetchConversations();
expect(useChatStore.getState().conversations).toEqual([]);
expect(quiet).toHaveBeenCalled();
quiet.mockRestore();
});
});
describe('openConversation', () => {
it('sets loading state immediately, then applies the fetched page', async () => {
let resolveMessages: (v: {
messages: ChatMessage[];
hasMore: boolean;
nextCursor: string | null;
}) => void = () => undefined;
getMessages.mockImplementation(
() =>
new Promise((resolve) => {
resolveMessages = resolve;
}),
);
const pending = useChatStore.getState().openConversation('friend-1');
expect(useChatStore.getState()).toMatchObject({
activeFriendId: 'friend-1',
messages: [],
loading: true,
hasMore: false,
nextCursor: null,
});
resolveMessages({ messages: [message()], hasMore: true, nextCursor: 'cur1' });
await pending;
const state = useChatStore.getState();
expect(state.messages).toEqual([message()]);
expect(state.hasMore).toBe(true);
expect(state.nextCursor).toBe('cur1');
expect(state.loading).toBe(false);
});
it('clears loading and logs on failure, leaving messages empty', async () => {
getMessages.mockRejectedValue(new Error('500'));
const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined);
await useChatStore.getState().openConversation('friend-1');
const state = useChatStore.getState();
expect(state.loading).toBe(false);
expect(state.messages).toEqual([]);
expect(quiet).toHaveBeenCalled();
quiet.mockRestore();
});
});
describe('loadMoreMessages', () => {
it('does nothing without an active conversation', async () => {
await useChatStore.getState().loadMoreMessages();
expect(getMessages).not.toHaveBeenCalled();
});
it('does nothing when there is no next cursor', async () => {
useChatStore.setState({ activeFriendId: 'friend-1', nextCursor: null });
await useChatStore.getState().loadMoreMessages();
expect(getMessages).not.toHaveBeenCalled();
});
it('does nothing while already loading', async () => {
useChatStore.setState({ activeFriendId: 'friend-1', nextCursor: 'cur1', loading: true });
await useChatStore.getState().loadMoreMessages();
expect(getMessages).not.toHaveBeenCalled();
});
it('prepends the older page ahead of the messages already loaded', async () => {
useChatStore.setState({
activeFriendId: 'friend-1',
nextCursor: 'cur1',
messages: [message({ id: 'new1' })],
});
getMessages.mockResolvedValue({
messages: [message({ id: 'old1' })],
hasMore: false,
nextCursor: null,
});
await useChatStore.getState().loadMoreMessages();
expect(getMessages).toHaveBeenCalledWith('friend-1');
const state = useChatStore.getState();
expect(state.messages.map((m) => m.id)).toEqual(['old1', 'new1']);
expect(state.hasMore).toBe(false);
expect(state.nextCursor).toBeNull();
expect(state.loading).toBe(false);
});
it('logs and clears loading on failure', async () => {
useChatStore.setState({ activeFriendId: 'friend-1', nextCursor: 'cur1' });
getMessages.mockRejectedValue(new Error('500'));
const quiet = vi.spyOn(console, 'error').mockImplementation(() => undefined);
await useChatStore.getState().loadMoreMessages();
expect(useChatStore.getState().loading).toBe(false);
expect(quiet).toHaveBeenCalled();
quiet.mockRestore();
});
});
describe('sendMessage', () => {
it('emits send-message with the recipient and content', () => {
const socket = makeSocket();
createSocketMock.mockReturnValue(socket);
useChatStore.getState().connect();
useChatStore.getState().sendMessage('friend-1', 'hello');
expect(socket.emit).toHaveBeenCalledWith('send-message', {
recipientId: 'friend-1',
content: 'hello',
});
});
it('does nothing without a socket', () => {
expect(() => useChatStore.getState().sendMessage('friend-1', 'hello')).not.toThrow();
});
});
describe('emitTyping', () => {
it('emits typing with the recipient', () => {
const socket = makeSocket();
createSocketMock.mockReturnValue(socket);
useChatStore.getState().connect();
useChatStore.getState().emitTyping('friend-1');
expect(socket.emit).toHaveBeenCalledWith('typing', { recipientId: 'friend-1' });
});
it('does nothing without a socket', () => {
expect(() => useChatStore.getState().emitTyping('friend-1')).not.toThrow();
});
});
describe('clearChat', () => {
it('clears messages and the active conversation but leaves the socket connected', () => {
const socket = makeSocket();
createSocketMock.mockReturnValue(socket);
useChatStore.getState().connect();
useChatStore.setState({
messages: [message()],
activeFriendId: 'friend-1',
hasMore: true,
nextCursor: 'cur1',
});
useChatStore.getState().clearChat();
const state = useChatStore.getState();
expect(state.messages).toEqual([]);
expect(state.activeFriendId).toBeNull();
expect(state.hasMore).toBe(false);
expect(state.nextCursor).toBeNull();
expect(state.socket).toBe(socket);
});
});
+405
View File
@@ -0,0 +1,405 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { FriendEntry, PendingRequest, SearchResult, SentRequest } from '@/api/friends';
/**
* friends-store reaches the REST endpoints through the real `@/api/friends`
* module. The network seam is the axios instance, mocked and routed by URL -
* mocking `@/api/friends` directly is unreliable (a concurrent dynamic import
* of an aliased module has resolved to the real file and hit the dev
* backend).
*
* WP-10 repaired the "silently fail" catches: `fetchPendingRequests`,
* `fetchSentRequests`, `fetchPendingCount` and `searchUsers` leave state
* exactly as it was on failure (no crash, no wipe), while `fetchFriends`
* additionally always clears its loading flag so the UI cannot spin forever.
* `sendRequest`, `acceptRequest`, `rejectRequest` and `removeFriend` do not
* catch at all - a failure must propagate so a caller can show it.
*/
const getFriends = vi.fn();
const getPendingRequests = vi.fn();
const getSentRequests = vi.fn();
const getPendingCount = vi.fn();
const searchUsersApi = vi.fn();
const sendFriendRequest = vi.fn();
const acceptRequestApi = vi.fn();
const rejectRequestApi = vi.fn();
const removeFriendApi = vi.fn();
type Handler = (arg: unknown) => Promise<unknown>;
function route(method: string, url: string): Handler {
let m: RegExpMatchArray | null;
if (method === 'get' && url === '/friends') return () => getFriends();
if (method === 'get' && url === '/friends/requests/pending') return () => getPendingRequests();
if (method === 'get' && url === '/friends/requests/sent') return () => getSentRequests();
if (method === 'get' && url === '/friends/pending-count') return () => getPendingCount();
if (method === 'get' && url === '/friends/search') return (arg) => searchUsersApi(arg);
if (method === 'post' && url === '/friends/request') return (body) => sendFriendRequest(body);
if (method === 'post' && (m = /^\/friends\/([^/]+)\/accept$/.exec(url))) {
return () => acceptRequestApi(m![1]);
}
if (method === 'post' && (m = /^\/friends\/([^/]+)\/reject$/.exec(url))) {
return () => rejectRequestApi(m![1]);
}
if (method === 'delete' && (m = /^\/friends\/([^/]+)$/.exec(url))) {
return () => removeFriendApi(m![1]);
}
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 { useFriendsStore } = await import('./friends-store');
const initialState = useFriendsStore.getState();
const friendEntry = (overrides: Partial<FriendEntry> = {}): FriendEntry => ({
friendshipId: 'f1',
friend: { id: 'u1', username: 'alice', displayName: null, avatarUrl: null },
since: '2024-01-01T00:00:00Z',
...overrides,
});
const pendingRequest = (overrides: Partial<PendingRequest> = {}): PendingRequest => ({
friendshipId: 'p1',
from: { id: 'u2', username: 'bob', displayName: null, avatarUrl: null },
sentAt: '2024-01-01T00:00:00Z',
...overrides,
});
const sentRequest = (overrides: Partial<SentRequest> = {}): SentRequest => ({
friendshipId: 's1',
to: { id: 'u3', username: 'carol', displayName: null, avatarUrl: null },
sentAt: '2024-01-01T00:00:00Z',
...overrides,
});
const searchResult = (overrides: Partial<SearchResult> = {}): SearchResult => ({
id: 'u4',
username: 'dan',
displayName: null,
avatarUrl: null,
friendshipStatus: null,
friendshipId: null,
friendshipDirection: null,
...overrides,
});
/** Lets a fire-and-forget refresh (not awaited by the calling action) settle. */
const flush = async () => {
for (let i = 0; i < 4; i++) {
await new Promise<void>((r) => setTimeout(r, 0));
}
};
beforeEach(() => {
vi.clearAllMocks();
useFriendsStore.setState(initialState, true);
});
// --- fetchFriends -------------------------------------------------------------
describe('fetchFriends', () => {
it('sets loading, then the friends list on success', async () => {
let resolveFriends: (v: FriendEntry[]) => void = () => undefined;
getFriends.mockImplementation(
() =>
new Promise((resolve) => {
resolveFriends = resolve;
}),
);
const pending = useFriendsStore.getState().fetchFriends();
expect(useFriendsStore.getState().loading).toBe(true);
resolveFriends([friendEntry()]);
await pending;
const state = useFriendsStore.getState();
expect(state.friends).toEqual([friendEntry()]);
expect(state.loading).toBe(false);
});
it('clears loading but keeps the previous friends list on failure', async () => {
useFriendsStore.setState({ friends: [friendEntry()] });
getFriends.mockRejectedValue(new Error('500'));
await useFriendsStore.getState().fetchFriends();
const state = useFriendsStore.getState();
expect(state.loading).toBe(false);
expect(state.friends).toEqual([friendEntry()]);
});
});
// --- fetchPendingRequests / fetchSentRequests / fetchPendingCount -----------
describe('fetchPendingRequests', () => {
it('sets pendingRequests and derives pendingCount from its length', async () => {
getPendingRequests.mockResolvedValue([pendingRequest(), pendingRequest({ friendshipId: 'p2' })]);
await useFriendsStore.getState().fetchPendingRequests();
const state = useFriendsStore.getState();
expect(state.pendingRequests).toHaveLength(2);
expect(state.pendingCount).toBe(2);
});
it('silently fails, leaving state exactly as it was', async () => {
const prev = [pendingRequest()];
useFriendsStore.setState({ pendingRequests: prev, pendingCount: 1 });
getPendingRequests.mockRejectedValue(new Error('500'));
await useFriendsStore.getState().fetchPendingRequests();
const state = useFriendsStore.getState();
expect(state.pendingRequests).toBe(prev);
expect(state.pendingCount).toBe(1);
});
});
describe('fetchSentRequests', () => {
it('sets sentRequests on success', async () => {
getSentRequests.mockResolvedValue([sentRequest()]);
await useFriendsStore.getState().fetchSentRequests();
expect(useFriendsStore.getState().sentRequests).toEqual([sentRequest()]);
});
it('silently fails, leaving sentRequests exactly as it was', async () => {
const prev = [sentRequest()];
useFriendsStore.setState({ sentRequests: prev });
getSentRequests.mockRejectedValue(new Error('500'));
await useFriendsStore.getState().fetchSentRequests();
expect(useFriendsStore.getState().sentRequests).toBe(prev);
});
});
describe('fetchPendingCount', () => {
it('sets pendingCount on success', async () => {
getPendingCount.mockResolvedValue(6);
await useFriendsStore.getState().fetchPendingCount();
expect(useFriendsStore.getState().pendingCount).toBe(6);
});
it('silently fails, leaving pendingCount exactly as it was', async () => {
useFriendsStore.setState({ pendingCount: 2 });
getPendingCount.mockRejectedValue(new Error('500'));
await useFriendsStore.getState().fetchPendingCount();
expect(useFriendsStore.getState().pendingCount).toBe(2);
});
});
// --- searchUsers ---------------------------------------------------------------
describe('searchUsers', () => {
it('clears results without a request for a blank query', async () => {
useFriendsStore.setState({ searchResults: [searchResult()] });
await useFriendsStore.getState().searchUsers(' ');
expect(searchUsersApi).not.toHaveBeenCalled();
expect(useFriendsStore.getState().searchResults).toEqual([]);
});
it('searches and sets results for a non-blank query', async () => {
searchUsersApi.mockResolvedValue([searchResult()]);
await useFriendsStore.getState().searchUsers('dan');
expect(searchUsersApi).toHaveBeenCalledWith({ params: { q: 'dan' } });
expect(useFriendsStore.getState().searchResults).toEqual([searchResult()]);
});
it('silently fails, leaving searchResults exactly as it was', async () => {
const prev = [searchResult()];
useFriendsStore.setState({ searchResults: prev });
searchUsersApi.mockRejectedValue(new Error('500'));
await useFriendsStore.getState().searchUsers('dan');
expect(useFriendsStore.getState().searchResults).toBe(prev);
});
});
// --- sendRequest / acceptRequest / rejectRequest / removeFriend -------------
describe('sendRequest', () => {
it('sends the request and refreshes sent requests', async () => {
sendFriendRequest.mockResolvedValue({ id: 'p1' });
getSentRequests.mockResolvedValue([sentRequest()]);
await useFriendsStore.getState().sendRequest('u9');
await flush();
expect(sendFriendRequest).toHaveBeenCalledWith({ addresseeId: 'u9' });
expect(getSentRequests).toHaveBeenCalledTimes(1);
expect(useFriendsStore.getState().sentRequests).toEqual([sentRequest()]);
});
it('propagates a failure instead of swallowing it', async () => {
sendFriendRequest.mockRejectedValue(new Error('500'));
await expect(useFriendsStore.getState().sendRequest('u9')).rejects.toThrow('500');
expect(getSentRequests).not.toHaveBeenCalled();
});
});
describe('acceptRequest', () => {
it('accepts and refreshes both friends and pending requests', async () => {
acceptRequestApi.mockResolvedValue({ friendshipId: 'p1' });
getFriends.mockResolvedValue([friendEntry()]);
getPendingRequests.mockResolvedValue([]);
await useFriendsStore.getState().acceptRequest('p1');
await flush();
expect(acceptRequestApi).toHaveBeenCalledWith('p1');
expect(getFriends).toHaveBeenCalledTimes(1);
expect(getPendingRequests).toHaveBeenCalledTimes(1);
expect(useFriendsStore.getState().friends).toEqual([friendEntry()]);
});
it('propagates a failure instead of swallowing it', async () => {
acceptRequestApi.mockRejectedValue(new Error('500'));
await expect(useFriendsStore.getState().acceptRequest('p1')).rejects.toThrow('500');
expect(getFriends).not.toHaveBeenCalled();
expect(getPendingRequests).not.toHaveBeenCalled();
});
});
describe('rejectRequest', () => {
it('removes the request and decrements pendingCount', async () => {
rejectRequestApi.mockResolvedValue(undefined);
useFriendsStore.setState({
pendingRequests: [pendingRequest({ friendshipId: 'p1' }), pendingRequest({ friendshipId: 'p2' })],
pendingCount: 2,
});
await useFriendsStore.getState().rejectRequest('p1');
expect(rejectRequestApi).toHaveBeenCalledWith('p1');
const state = useFriendsStore.getState();
expect(state.pendingRequests.map((r) => r.friendshipId)).toEqual(['p2']);
expect(state.pendingCount).toBe(1);
});
it('never lets pendingCount go negative', async () => {
rejectRequestApi.mockResolvedValue(undefined);
useFriendsStore.setState({
pendingRequests: [pendingRequest({ friendshipId: 'p1' })],
pendingCount: 0,
});
await useFriendsStore.getState().rejectRequest('p1');
expect(useFriendsStore.getState().pendingCount).toBe(0);
});
it('propagates a failure and leaves pendingRequests untouched', async () => {
const prev = [pendingRequest({ friendshipId: 'p1' })];
useFriendsStore.setState({ pendingRequests: prev, pendingCount: 1 });
rejectRequestApi.mockRejectedValue(new Error('500'));
await expect(useFriendsStore.getState().rejectRequest('p1')).rejects.toThrow('500');
expect(useFriendsStore.getState().pendingRequests).toBe(prev);
});
});
describe('removeFriend', () => {
it('removes the matching friend from the list', async () => {
removeFriendApi.mockResolvedValue(undefined);
useFriendsStore.setState({
friends: [friendEntry({ friendshipId: 'f1' }), friendEntry({ friendshipId: 'f2' })],
});
await useFriendsStore.getState().removeFriend('f1');
expect(removeFriendApi).toHaveBeenCalledWith('f1');
expect(useFriendsStore.getState().friends.map((f) => f.friendshipId)).toEqual(['f2']);
});
it('propagates a failure and leaves friends untouched', async () => {
const prev = [friendEntry({ friendshipId: 'f1' })];
useFriendsStore.setState({ friends: prev });
removeFriendApi.mockRejectedValue(new Error('500'));
await expect(useFriendsStore.getState().removeFriend('f1')).rejects.toThrow('500');
expect(useFriendsStore.getState().friends).toBe(prev);
});
});
// --- pure sync setters ---------------------------------------------------------
describe('online-friend setters', () => {
it('setFriendOnline adds an id', () => {
useFriendsStore.getState().setFriendOnline('u1');
expect(useFriendsStore.getState().onlineFriendIds.has('u1')).toBe(true);
});
it('setFriendOffline removes an id', () => {
useFriendsStore.setState({ onlineFriendIds: new Set(['u1', 'u2']) });
useFriendsStore.getState().setFriendOffline('u1');
const ids = useFriendsStore.getState().onlineFriendIds;
expect(ids.has('u1')).toBe(false);
expect(ids.has('u2')).toBe(true);
});
it('setOnlineFriends replaces the whole set', () => {
useFriendsStore.setState({ onlineFriendIds: new Set(['stale']) });
useFriendsStore.getState().setOnlineFriends(['u1', 'u2']);
expect([...useFriendsStore.getState().onlineFriendIds].sort()).toEqual(['u1', 'u2']);
});
});
describe('clearSearch', () => {
it('empties searchResults', () => {
useFriendsStore.setState({ searchResults: [searchResult()] });
useFriendsStore.getState().clearSearch();
expect(useFriendsStore.getState().searchResults).toEqual([]);
});
});
describe('reset', () => {
it('restores every field to its initial value', () => {
useFriendsStore.setState({
friends: [friendEntry()],
pendingRequests: [pendingRequest()],
sentRequests: [sentRequest()],
searchResults: [searchResult()],
onlineFriendIds: new Set(['u1']),
pendingCount: 3,
loading: true,
});
useFriendsStore.getState().reset();
const state = useFriendsStore.getState();
expect(state.friends).toEqual([]);
expect(state.pendingRequests).toEqual([]);
expect(state.sentRequests).toEqual([]);
expect(state.searchResults).toEqual([]);
expect(state.onlineFriendIds.size).toBe(0);
expect(state.pendingCount).toBe(0);
expect(state.loading).toBe(false);
});
});
+524
View File
@@ -0,0 +1,524 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type {
GameNightPlayer,
GameNightRoom,
GameNightResults,
} from '@/api/game-night';
import type { ScoreBreakdown } from '@/types';
/**
* The game-night lobby socket store. WP-10 repaired two bugs here that these
* tests pin explicitly:
* - connect() used to stack a second socket on top of a stale, disconnected
* one instead of replacing it; it must now tear the old one down first.
* - disconnect() used to clear only `socket`/`connected`, leaving roomId,
* room, countdown and results behind so a later match could open under the
* previous one's leftover state; it must now reset the whole slice.
*
* The socket is mocked (per WP-19 conventions) as a small fake that records
* registered handlers and can `fire` them, so server -> client events can be
* driven directly without a real socket.io connection.
*/
type Listener = (...args: unknown[]) => void;
function makeFakeSocket() {
const handlers = new Map<string, Listener[]>();
const socket = {
id: 'sock-1',
connected: false,
on: vi.fn((event: string, handler: Listener) => {
const arr = handlers.get(event) ?? [];
arr.push(handler);
handlers.set(event, arr);
return socket;
}),
off: vi.fn(),
once: vi.fn(),
emit: vi.fn(),
disconnect: vi.fn(),
connect: vi.fn(),
removeAllListeners: vi.fn(() => {
handlers.clear();
}),
fire(event: string, ...args: unknown[]) {
for (const handler of handlers.get(event) ?? []) handler(...args);
},
};
return socket;
}
type FakeSocket = ReturnType<typeof makeFakeSocket>;
const createSocketMock = vi.fn<(namespace: string) => FakeSocket | null>();
vi.mock('@/lib/create-socket', () => ({
createSocket: (namespace: string) => createSocketMock(namespace),
}));
const getAuthToken = vi.fn<() => string | null>();
vi.mock('@/lib/storage', () => ({
storage: { getAuthToken: () => getAuthToken() },
}));
let currentUserId: string | undefined;
vi.mock('@/stores/auth-store', () => ({
useAuthStore: {
getState: () => ({ user: currentUserId ? { id: currentUserId } : null }),
},
}));
const { useGameNightStore } = await import('./game-night-store');
// --- Fixtures ---------------------------------------------------------------
function player(overrides: Partial<GameNightPlayer> = {}): GameNightPlayer {
return {
id: 'p1',
userId: 'user-1',
isReady: false,
isHost: false,
isSpectator: false,
score: null,
chainLength: null,
completed: false,
forfeited: false,
timedOut: false,
disconnected: false,
joinedAt: '2024-01-01T00:00:00.000Z',
completedAt: null,
user: { id: 'user-1', username: 'Alice' },
...overrides,
};
}
function room(overrides: Partial<GameNightRoom> = {}): GameNightRoom {
return {
id: 'room-1',
hostId: 'user-1',
hostPlaying: true,
movieAId: 100,
movieATitle: 'Movie A',
movieBId: 200,
movieBTitle: 'Movie B',
roomName: null,
hasPassword: false,
status: 'waiting',
maxPlayers: 4,
timeoutMinutes: null,
createdAt: '2024-01-01T00:00:00.000Z',
startedAt: null,
finishedAt: null,
host: { id: 'user-1', username: 'Alice' },
players: [player()],
...overrides,
};
}
const SCORE: ScoreBreakdown = {
baseScore: 1000,
chainLength: 5,
linkCount: 2,
chainLengthBonus: 800,
timeBonus: 500,
obscurityBonus: 100,
hintPenalty: 0,
duplicatePenalty: 0,
elapsedSeconds: 300,
totalScore: 2400,
};
function results(overrides: Partial<GameNightResults> = {}): GameNightResults {
return {
room: {
id: 'room-1',
roomName: null,
movieAId: 100,
movieATitle: 'Movie A',
movieBId: 200,
movieBTitle: 'Movie B',
startedAt: '2024-01-01T00:00:00.000Z',
finishedAt: '2024-01-01T00:10:00.000Z',
status: 'finished',
},
host: { id: 'user-1', username: 'Alice' },
rankings: { byScore: [], byTime: [], byLinks: [] },
...overrides,
};
}
function connectStore(): FakeSocket {
const socket = makeFakeSocket();
createSocketMock.mockReturnValueOnce(socket);
useGameNightStore.getState().connect();
return socket;
}
beforeEach(() => {
vi.clearAllMocks();
getAuthToken.mockReturnValue('tok');
currentUserId = 'user-1';
useGameNightStore.setState(useGameNightStore.getInitialState());
});
// --- connect() ---------------------------------------------------------------
describe('connect', () => {
it('does not create a socket without a stored auth token', () => {
getAuthToken.mockReturnValue(null);
useGameNightStore.getState().connect();
expect(createSocketMock).not.toHaveBeenCalled();
expect(useGameNightStore.getState().socket).toBeNull();
});
it('tolerates createSocket itself returning null', () => {
createSocketMock.mockReturnValueOnce(null);
expect(() => useGameNightStore.getState().connect()).not.toThrow();
expect(useGameNightStore.getState().socket).toBeNull();
});
it('tracks connected state via the socket connect/disconnect events', () => {
const socket = connectStore();
socket.fire('connect');
expect(useGameNightStore.getState().connected).toBe(true);
socket.fire('disconnect');
expect(useGameNightStore.getState().connected).toBe(false);
});
it('does nothing when called again while the existing socket is still connected', () => {
const first = makeFakeSocket();
first.connected = true;
createSocketMock.mockReturnValueOnce(first);
useGameNightStore.getState().connect();
useGameNightStore.getState().connect();
expect(createSocketMock).toHaveBeenCalledTimes(1);
expect(first.disconnect).not.toHaveBeenCalled();
expect(useGameNightStore.getState().socket).toBe(first);
});
it('replaces, rather than stacks, a stale disconnected socket (WP-10)', () => {
const first = makeFakeSocket();
first.connected = false;
const second = makeFakeSocket();
createSocketMock.mockReturnValueOnce(first).mockReturnValueOnce(second);
useGameNightStore.getState().connect();
expect(useGameNightStore.getState().socket).toBe(first);
useGameNightStore.getState().connect();
expect(first.removeAllListeners).toHaveBeenCalledTimes(1);
expect(first.disconnect).toHaveBeenCalledTimes(1);
expect(createSocketMock).toHaveBeenCalledTimes(2);
expect(useGameNightStore.getState().socket).toBe(second);
});
});
// --- room-state ---------------------------------------------------------------
describe('room-state event', () => {
it('marks the lobby playing when the room status is playing', () => {
const socket = connectStore();
socket.fire('room-state', { room: room({ status: 'playing' }) });
expect(useGameNightStore.getState().lobbyState).toBe('playing');
});
it('otherwise shows the lobby', () => {
const socket = connectStore();
socket.fire('room-state', { room: room({ status: 'waiting' }) });
expect(useGameNightStore.getState().lobbyState).toBe('lobby');
});
it('marks the current user a spectator when their player entry says so', () => {
currentUserId = 'user-1';
const socket = connectStore();
socket.fire('room-state', {
room: room({ players: [player({ userId: 'user-1', isSpectator: true })] }),
});
expect(useGameNightStore.getState().isSpectator).toBe(true);
});
it('is not a spectator when no player entry matches the current user', () => {
currentUserId = 'someone-else';
const socket = connectStore();
socket.fire('room-state', {
room: room({ players: [player({ userId: 'user-1', isSpectator: true })] }),
});
expect(useGameNightStore.getState().isSpectator).toBe(false);
});
it('records the room and its players', () => {
const socket = connectStore();
const r = room({
players: [player({ userId: 'a' }), player({ userId: 'b', id: 'p2' })],
});
socket.fire('room-state', { room: r });
const state = useGameNightStore.getState();
expect(state.room).toEqual(r);
expect(state.players).toEqual(r.players);
});
});
// --- roster and progress events -----------------------------------------------
describe('roster and progress events', () => {
function connectedWithTwoPlayers(): FakeSocket {
const socket = connectStore();
socket.fire('room-state', {
room: room({ players: [player({ userId: 'a' }), player({ userId: 'b' })] }),
});
return socket;
}
it('removes a player who left', () => {
const socket = connectedWithTwoPlayers();
socket.fire('player-left', { userId: 'a' });
expect(useGameNightStore.getState().players.map((p) => p.userId)).toEqual([
'b',
]);
});
it('updates a single player readiness without touching others', () => {
const socket = connectedWithTwoPlayers();
socket.fire('player-ready', { userId: 'a', isReady: true });
const players = useGameNightStore.getState().players;
expect(players.find((p) => p.userId === 'a')?.isReady).toBe(true);
expect(players.find((p) => p.userId === 'b')?.isReady).toBe(false);
});
it('starts the countdown with the given value', () => {
const socket = connectedWithTwoPlayers();
socket.fire('countdown', { value: 5 });
expect(useGameNightStore.getState()).toMatchObject({
lobbyState: 'countdown',
countdownValue: 5,
});
});
it('starts play on game-start', () => {
const socket = connectedWithTwoPlayers();
socket.fire('game-start');
expect(useGameNightStore.getState().lobbyState).toBe('playing');
});
it("tracks each player's chain length independently", () => {
const socket = connectedWithTwoPlayers();
socket.fire('player-progress', { userId: 'a', chainLength: 3 });
socket.fire('player-progress', { userId: 'b', chainLength: 1 });
const { playerProgress } = useGameNightStore.getState();
expect(playerProgress.get('a')).toBe(3);
expect(playerProgress.get('b')).toBe(1);
});
it('marks a player finished', () => {
const socket = connectedWithTwoPlayers();
socket.fire('player-finished', { userId: 'a', score: SCORE, completedAt: 'now' });
expect(useGameNightStore.getState().finishedPlayers.has('a')).toBe(true);
});
it('marks a forfeiting player both forfeited and finished', () => {
const socket = connectedWithTwoPlayers();
socket.fire('player-forfeited', { userId: 'a' });
const state = useGameNightStore.getState();
expect(state.forfeitedPlayers.has('a')).toBe(true);
expect(state.finishedPlayers.has('a')).toBe(true);
});
it('records final results on room-finished', () => {
const socket = connectedWithTwoPlayers();
const r = results();
socket.fire('room-finished', r);
const state = useGameNightStore.getState();
expect(state.lobbyState).toBe('finished');
expect(state.results).toEqual(r);
});
it('returns to idle and clears the room on room-cancelled', () => {
const socket = connectedWithTwoPlayers();
useGameNightStore.getState().joinRoom('room-1');
socket.fire('room-cancelled');
const state = useGameNightStore.getState();
expect(state.lobbyState).toBe('idle');
expect(state.roomId).toBeNull();
});
it('flags a player as disconnected without affecting others', () => {
const socket = connectedWithTwoPlayers();
socket.fire('player-disconnected', { userId: 'a' });
const players = useGameNightStore.getState().players;
expect(players.find((p) => p.userId === 'a')).toMatchObject({
disconnected: true,
});
expect(players.find((p) => p.userId === 'b')).toMatchObject({
disconnected: false,
});
});
it('clears the disconnected flag on reconnect', () => {
const socket = connectedWithTwoPlayers();
socket.fire('player-disconnected', { userId: 'a' });
socket.fire('player-reconnected', { userId: 'a' });
expect(
useGameNightStore.getState().players.find((p) => p.userId === 'a'),
).toMatchObject({ disconnected: false });
});
});
// --- disconnect() / reset() (WP-10) -------------------------------------------
describe('disconnect', () => {
it('resets the whole slice, not just socket/connected (WP-10)', () => {
const socket = connectStore();
socket.fire('connect');
useGameNightStore.getState().joinRoom('room-9');
socket.fire('room-state', { room: room({ status: 'playing' }) });
socket.fire('countdown', { value: 3 });
socket.fire('room-finished', results());
useGameNightStore.getState().disconnect();
expect(socket.removeAllListeners).toHaveBeenCalledTimes(1);
expect(socket.disconnect).toHaveBeenCalledTimes(1);
const state = useGameNightStore.getState();
expect(state.socket).toBeNull();
expect(state.connected).toBe(false);
expect(state.roomId).toBeNull();
expect(state.lobbyState).toBe('idle');
expect(state.countdownValue).toBeNull();
expect(state.room).toBeNull();
expect(state.results).toBeNull();
expect(state.isSpectator).toBe(false);
expect(state.players).toEqual([]);
});
it('resets state even when no socket is present', () => {
useGameNightStore.setState({
room: room(),
results: results(),
lobbyState: 'finished',
});
expect(() => useGameNightStore.getState().disconnect()).not.toThrow();
const state = useGameNightStore.getState();
expect(state.room).toBeNull();
expect(state.lobbyState).toBe('idle');
});
});
describe('reset', () => {
it('disconnects the socket (without removing listeners) and resets to initial state', () => {
const socket = connectStore();
useGameNightStore.getState().joinRoom('r1');
useGameNightStore.getState().reset();
expect(socket.disconnect).toHaveBeenCalledTimes(1);
expect(socket.removeAllListeners).not.toHaveBeenCalled();
expect(useGameNightStore.getState().roomId).toBeNull();
expect(useGameNightStore.getState().socket).toBeNull();
});
it('is safe to call with no socket', () => {
expect(() => useGameNightStore.getState().reset()).not.toThrow();
expect(useGameNightStore.getState().roomId).toBeNull();
});
});
// --- emitted client actions ----------------------------------------------------
describe('emitted client actions', () => {
it('does nothing for any room action without a socket at all', () => {
const store = useGameNightStore.getState();
expect(() => {
store.joinRoom('r1');
store.toggleReady();
store.startCountdown();
store.sendChainUpdate(2);
store.sendPlayerComplete('s1');
store.sendPlayerForfeit();
store.leaveRoom();
}).not.toThrow();
expect(useGameNightStore.getState().roomId).toBeNull();
});
it('does nothing for room actions when connected but no room has been joined', () => {
const socket = connectStore();
const store = useGameNightStore.getState();
store.toggleReady();
store.startCountdown();
store.sendChainUpdate(2);
store.sendPlayerComplete('s1');
store.sendPlayerForfeit();
store.leaveRoom();
expect(socket.emit).not.toHaveBeenCalled();
});
function connectAndJoin(roomId = 'room-1'): FakeSocket {
const socket = connectStore();
useGameNightStore.getState().joinRoom(roomId);
socket.emit.mockClear();
return socket;
}
it('joins a room and emits join-room', () => {
const socket = connectStore();
useGameNightStore.getState().joinRoom('room-7');
expect(useGameNightStore.getState().roomId).toBe('room-7');
expect(socket.emit).toHaveBeenCalledWith('join-room', { roomId: 'room-7' });
});
it('emits toggle-ready for the current room', () => {
const socket = connectAndJoin('room-1');
useGameNightStore.getState().toggleReady();
expect(socket.emit).toHaveBeenCalledWith('toggle-ready', { roomId: 'room-1' });
});
it('emits start-countdown for the current room', () => {
const socket = connectAndJoin('room-1');
useGameNightStore.getState().startCountdown();
expect(socket.emit).toHaveBeenCalledWith('start-countdown', {
roomId: 'room-1',
});
});
it('emits chain-update with the room and chain length', () => {
const socket = connectAndJoin('room-1');
useGameNightStore.getState().sendChainUpdate(4);
expect(socket.emit).toHaveBeenCalledWith('chain-update', {
roomId: 'room-1',
chainLength: 4,
});
});
it('emits player-complete with the session id', () => {
const socket = connectAndJoin('room-1');
useGameNightStore.getState().sendPlayerComplete('session-9');
expect(socket.emit).toHaveBeenCalledWith('player-complete', {
roomId: 'room-1',
sessionId: 'session-9',
});
});
it('emits player-forfeit for the current room', () => {
const socket = connectAndJoin('room-1');
useGameNightStore.getState().sendPlayerForfeit();
expect(socket.emit).toHaveBeenCalledWith('player-forfeit', {
roomId: 'room-1',
});
});
it('emits leave-room and returns to idle locally', () => {
const socket = connectAndJoin('room-1');
useGameNightStore.getState().leaveRoom();
expect(socket.emit).toHaveBeenCalledWith('leave-room', { roomId: 'room-1' });
const state = useGameNightStore.getState();
expect(state.roomId).toBeNull();
expect(state.lobbyState).toBe('idle');
});
});
+114
View File
@@ -342,7 +342,85 @@ describe('completion after a multiplayer start (C1)', () => {
});
});
describe('startGame outcome', () => {
// startGame never rejects - it reports. Pages used to navigate to /game
// regardless, and /game's idle redirect bounced a failed start straight
// back with the store's message never shown; they branch on this now.
it("resolves 'started' and is playing once the movie details load", async () => {
await expect(useGameStore.getState().startGame(PAIR)).resolves.toBe(
'started',
);
expect(useGameStore.getState().status).toBe('playing');
expect(useGameStore.getState().validationError).toBeNull();
});
it("resolves 'failed', idle, with the reason in validationError when details cannot load", async () => {
getMovieDetails.mockRejectedValue(new Error('network'));
await expect(useGameStore.getState().startGame(PAIR)).resolves.toBe(
'failed',
);
const state = useGameStore.getState();
expect(state.status).toBe('idle');
expect(state.isValidating).toBe(false);
expect(state.validationError).toMatch(/Failed to load movie details/);
});
});
describe('completion in single-player', () => {
it('drops the result when the game is reset while the session is still being created', async () => {
let releaseSession: (v: unknown) => void = () => undefined;
createGameSession.mockImplementation(
() => new Promise((resolve) => (releaseSession = resolve)),
);
await useGameStore.getState().startGame(PAIR);
closeTheLoop();
await flush();
useGameStore.getState().resetGame();
releaseSession({ id: 'late-session', startedAt: 'now' });
await flush();
await flush();
expect(completeGameSession).not.toHaveBeenCalled();
expect(useGameStore.getState().score).toBeNull();
expect(useGameStore.getState().validationError).toBeNull();
});
it('drops the score when the game is reset while completion is in flight', async () => {
let releaseScore: (v: unknown) => void = () => undefined;
completeGameSession.mockImplementation(
() => new Promise((resolve) => (releaseScore = resolve)),
);
await useGameStore.getState().startGame(PAIR);
await flush();
closeTheLoop();
await flush();
useGameStore.getState().resetGame();
releaseScore({ totalScore: 1234 });
await flush();
expect(useGameStore.getState().score).toBeNull();
expect(useGameStore.getState().status).toBe('idle');
});
it('drops the error when completion fails after the game was reset', async () => {
let rejectScore: (e: unknown) => void = () => undefined;
completeGameSession.mockImplementation(
() => new Promise((_, reject) => (rejectScore = reject)),
);
await useGameStore.getState().startGame(PAIR);
await flush();
closeTheLoop();
await flush();
useGameStore.getState().resetGame();
rejectScore(new Error('500'));
await flush();
expect(useGameStore.getState().validationError).toBeNull();
});
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.
@@ -530,6 +608,42 @@ describe('undoLastLink', () => {
expect(state.currentSearchMode).toBe('movie');
});
it('resumes from movie A when the server chain comes back empty', async () => {
useGameStore.getState().addActorToChain(actor(1));
useGameStore.getState().addMovieToChain(movie(300));
// The server has already rolled back to nothing beyond the implicit A.
undoLink.mockResolvedValue({ chain: [] });
useGameStore.getState().undoLastLink();
await flush();
await flush();
const state = useGameStore.getState();
expect(state.chain.map((l) => l.id)).toEqual([100]);
expect(state.currentSearchMode).toBe('actor');
expect(state.passedMovieB).toBe(false);
});
it('ignores a server undo response that lands after the game was reset', async () => {
useGameStore.getState().addActorToChain(actor(1));
useGameStore.getState().addMovieToChain(movie(300));
let release!: () => void;
undoLink.mockImplementation(
() => new Promise((resolve) => { release = () => resolve({ chain: [] }); }),
);
useGameStore.getState().undoLastLink();
await flush();
useGameStore.getState().resetGame();
release();
await flush();
await flush();
// Nothing from the stale response was applied to the reset store.
expect(useGameStore.getState().status).toBe('idle');
expect(useGameStore.getState().chain).toEqual([]);
});
it('tells the player when the server undo fails, instead of drifting silently', async () => {
useGameStore.getState().addActorToChain(actor(1));
undoLink.mockRejectedValue(new Error('500'));
+9 -3
View File
@@ -29,7 +29,7 @@ interface GameState {
dailyChallengeDate: string | null;
dailyChallengeDifficulty: string | null;
startGame: (pair: MoviePair, options?: { isCustom?: boolean; isDaily?: boolean; dailyDate?: string; dailyChallengeId?: string; difficulty?: string }) => Promise<void>;
startGame: (pair: MoviePair, options?: { isCustom?: boolean; isDaily?: boolean; dailyDate?: string; dailyChallengeId?: string; difficulty?: string }) => Promise<'started' | 'failed'>;
startGameFromVersus: (pair: MoviePair, startTime: number) => Promise<StartResult>;
addActorToChain: (actor: ActorChainLink) => void;
addMovieToChain: (movie: MovieChainLink) => void;
@@ -110,8 +110,9 @@ function reconcileChain(serverChain: ChainLink[]): void {
if (!movieA) return;
const merged = [movieA, ...serverChain];
const lastLink = merged[merged.length - 1];
if (!lastLink) return; // unreachable: merged always has at least movieA
// With every link undone the server chain is empty and play resumes from
// movie A itself.
const lastLink = serverChain.at(-1) ?? movieA;
useGameStore.setState({
chain: merged,
currentSearchMode: lastLink.type === 'movie' ? 'actor' : 'movie',
@@ -242,12 +243,17 @@ export const useGameStore = create<GameState>((set, get) => ({
movieAReleaseDate: detailsA.release_date ?? null,
movieBReleaseDate: detailsB.release_date ?? null,
});
return 'started';
} catch {
set({
status: 'idle',
isValidating: false,
validationError: 'Failed to load movie details. Please try again.',
});
// Reported, not thrown: the page decides how to show it. Callers that
// navigated to /game regardless landed on its idle redirect and the
// message above was never seen.
return 'failed';
}
},
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more