Gold is the primary in both themes, Bricolage Grotesque carries the wordmark, titles and the score, and Movie A / Movie B wear the blue and magenta of three-strip Technicolor on their posters instead of colouring whole rows. The loop closes on screen - connectors light, a gold line draws back to Movie A, the row glows, then the result arrives - in place of confetti, on every play mode, scaled to the chain's length. Every page shares one header and hairline lists; copy is sentence case with one verb per flow, and the Home hero shows a real finished loop. Also clamps link counts at zero: an opponent who had not moved yet showed "-1 links".
224 lines
7.2 KiB
TypeScript
224 lines
7.2 KiB
TypeScript
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('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);
|
|
});
|
|
});
|