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; 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(); 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(); 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( , ); 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( , ); 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(); 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( , ); 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(); 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(); 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(); 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(); 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(); 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(); const shareButton = screen.getByRole('button', { name: 'Share' }); available = false; await clickAndSettle(shareButton); expect(share).not.toHaveBeenCalled(); expect(writeText).toHaveBeenCalledTimes(1); }); });