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

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