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:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user