Files
movieloop-frontend/src/components/game/ShareableResult.test.tsx
T
TehRiehlDeal 8d0b7f39ee feat(ui): give the app one visual identity as Movie Loop
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".
2026-09-12 22:42:55 -07:00

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