|
@@ -1,258 +0,0 @@
|
|
|
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
|
-import { act, render } from "@testing-library/react";
|
|
|
|
|
-import { RollAnimation } from "@/components/dice/roll-animation";
|
|
|
|
|
-import type { Movie } from "@/types/movie";
|
|
|
|
|
-
|
|
|
|
|
-function makeMovie(id: string, title = `Movie ${id}`): Movie {
|
|
|
|
|
- return {
|
|
|
|
|
- id,
|
|
|
|
|
- group_id: "g1",
|
|
|
|
|
- tmdb_id: Number(id) || 0,
|
|
|
|
|
- title,
|
|
|
|
|
- year: 2020,
|
|
|
|
|
- poster_path: `/poster-${id}.jpg`,
|
|
|
|
|
- genres: [],
|
|
|
|
|
- trailer_url: null,
|
|
|
|
|
- trailer_url_refreshed_at: null,
|
|
|
|
|
- metadata_refreshed_at: null,
|
|
|
|
|
- added_by: null,
|
|
|
|
|
- watched: false,
|
|
|
|
|
- watched_at: null,
|
|
|
|
|
- added_at: "2026-01-01T00:00:00Z",
|
|
|
|
|
- };
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-interface MatchMediaListener {
|
|
|
|
|
- (event: { matches: boolean }): void;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-interface FakeMediaQueryList {
|
|
|
|
|
- matches: boolean;
|
|
|
|
|
- media: string;
|
|
|
|
|
- addEventListener: (type: "change", listener: MatchMediaListener) => void;
|
|
|
|
|
- removeEventListener: (type: "change", listener: MatchMediaListener) => void;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function installMatchMedia(matches: boolean) {
|
|
|
|
|
- const listeners = new Set<MatchMediaListener>();
|
|
|
|
|
- const mql: FakeMediaQueryList = {
|
|
|
|
|
- matches,
|
|
|
|
|
- media: "(prefers-reduced-motion: reduce)",
|
|
|
|
|
- addEventListener: (_type, listener) => listeners.add(listener),
|
|
|
|
|
- removeEventListener: (_type, listener) => listeners.delete(listener),
|
|
|
|
|
- };
|
|
|
|
|
- vi.stubGlobal(
|
|
|
|
|
- "matchMedia",
|
|
|
|
|
- vi.fn(() => mql),
|
|
|
|
|
- );
|
|
|
|
|
- return mql;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-const POOL: Movie[] = [
|
|
|
|
|
- makeMovie("1", "Alpha"),
|
|
|
|
|
- makeMovie("2", "Beta"),
|
|
|
|
|
- makeMovie("3", "Gamma"),
|
|
|
|
|
- makeMovie("4", "Delta"),
|
|
|
|
|
-];
|
|
|
|
|
-
|
|
|
|
|
-describe("<RollAnimation />", () => {
|
|
|
|
|
- beforeEach(() => {
|
|
|
|
|
- vi.useFakeTimers();
|
|
|
|
|
- installMatchMedia(false);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- afterEach(() => {
|
|
|
|
|
- vi.useRealTimers();
|
|
|
|
|
- vi.unstubAllGlobals();
|
|
|
|
|
- vi.restoreAllMocks();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- describe("DOM-per-state", () => {
|
|
|
|
|
- it("renders nothing when state is 'idle'", () => {
|
|
|
|
|
- const { container } = render(<RollAnimation pool={POOL} winner={null} state="idle" />);
|
|
|
|
|
- expect(container.firstChild).toBeNull();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("renders the scatter DOM with poster elements when state is 'rolling'", () => {
|
|
|
|
|
- const { getByTestId, queryAllByTestId } = render(
|
|
|
|
|
- <RollAnimation pool={POOL} winner={POOL[0]} state="rolling" />,
|
|
|
|
|
- );
|
|
|
|
|
- expect(getByTestId("roll-animation-scatter")).toBeTruthy();
|
|
|
|
|
- const posters = queryAllByTestId(/^roll-animation-(poster|winner)$/);
|
|
|
|
|
- expect(posters.length).toBeGreaterThan(0);
|
|
|
|
|
- expect(posters.length).toBeLessThanOrEqual(16);
|
|
|
|
|
- // Winner element exists.
|
|
|
|
|
- expect(getByTestId("roll-animation-winner")).toBeTruthy();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("renders the winner prominently when state is 'complete'", () => {
|
|
|
|
|
- const winner = POOL[1];
|
|
|
|
|
- const { getByTestId } = render(
|
|
|
|
|
- <RollAnimation pool={POOL} winner={winner} state="complete" />,
|
|
|
|
|
- );
|
|
|
|
|
- const winnerEl = getByTestId("roll-animation-winner");
|
|
|
|
|
- expect(winnerEl).toBeTruthy();
|
|
|
|
|
- // Prominent winner gets ring + scale styling — verified via class string
|
|
|
|
|
- // (Tailwind classes are not compiled in the test env, so we assert the
|
|
|
|
|
- // class names exist on the element).
|
|
|
|
|
- expect(winnerEl.className).toMatch(/ring-/);
|
|
|
|
|
- // Prominent poster has descriptive alt text.
|
|
|
|
|
- const img = winnerEl.querySelector("img");
|
|
|
|
|
- expect(img).toBeTruthy();
|
|
|
|
|
- expect(img!.getAttribute("alt")).toBe("Beta poster");
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("caps the scatter at 16 DOM elements (perf budget)", () => {
|
|
|
|
|
- const bigPool = Array.from({ length: 50 }, (_, i) => makeMovie(String(i + 1)));
|
|
|
|
|
- const { queryAllByTestId } = render(
|
|
|
|
|
- <RollAnimation pool={bigPool} winner={bigPool[0]} state="rolling" />,
|
|
|
|
|
- );
|
|
|
|
|
- const posters = queryAllByTestId(/^roll-animation-(poster|winner)$/);
|
|
|
|
|
- expect(posters.length).toBeLessThanOrEqual(16);
|
|
|
|
|
- });
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- describe("reduced-motion branch", () => {
|
|
|
|
|
- beforeEach(() => {
|
|
|
|
|
- installMatchMedia(true);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("renders the fade-only path (no scatter DOM) when rolling", () => {
|
|
|
|
|
- const { queryByTestId } = render(
|
|
|
|
|
- <RollAnimation pool={POOL} winner={POOL[0]} state="rolling" />,
|
|
|
|
|
- );
|
|
|
|
|
- expect(queryByTestId("roll-animation-reduced-motion")).toBeTruthy();
|
|
|
|
|
- expect(queryByTestId("roll-animation-scatter")).toBeNull();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("renders the fade-only path when state is 'complete'", () => {
|
|
|
|
|
- const { queryByTestId } = render(
|
|
|
|
|
- <RollAnimation pool={POOL} winner={POOL[2]} state="complete" />,
|
|
|
|
|
- );
|
|
|
|
|
- expect(queryByTestId("roll-animation-reduced-motion")).toBeTruthy();
|
|
|
|
|
- expect(queryByTestId("roll-animation-scatter")).toBeNull();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("renders nothing when reduced-motion is on but no winner is provided", () => {
|
|
|
|
|
- const { container } = render(<RollAnimation pool={POOL} winner={null} state="rolling" />);
|
|
|
|
|
- expect(container.firstChild).toBeNull();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("fires onComplete exactly once after the 150 ms fade-in window", () => {
|
|
|
|
|
- const onComplete = vi.fn();
|
|
|
|
|
- render(
|
|
|
|
|
- <RollAnimation pool={POOL} winner={POOL[0]} state="rolling" onComplete={onComplete} />,
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- expect(onComplete).not.toHaveBeenCalled();
|
|
|
|
|
-
|
|
|
|
|
- act(() => {
|
|
|
|
|
- vi.advanceTimersByTime(150);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- expect(onComplete).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- describe("onComplete lifecycle", () => {
|
|
|
|
|
- it("fires exactly once at the end of the rolling timeline", () => {
|
|
|
|
|
- const onComplete = vi.fn();
|
|
|
|
|
- const { rerender } = render(
|
|
|
|
|
- <RollAnimation pool={POOL} winner={POOL[0]} state="rolling" onComplete={onComplete} />,
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- // Mid-animation: not yet fired.
|
|
|
|
|
- act(() => {
|
|
|
|
|
- vi.advanceTimersByTime(2499);
|
|
|
|
|
- });
|
|
|
|
|
- expect(onComplete).not.toHaveBeenCalled();
|
|
|
|
|
-
|
|
|
|
|
- // Cross the 2500 ms boundary.
|
|
|
|
|
- act(() => {
|
|
|
|
|
- vi.advanceTimersByTime(1);
|
|
|
|
|
- });
|
|
|
|
|
- expect(onComplete).toHaveBeenCalledTimes(1);
|
|
|
|
|
-
|
|
|
|
|
- // Re-render with state="complete" — the parent's typical transition.
|
|
|
|
|
- // onComplete must NOT be called again.
|
|
|
|
|
- rerender(
|
|
|
|
|
- <RollAnimation pool={POOL} winner={POOL[0]} state="complete" onComplete={onComplete} />,
|
|
|
|
|
- );
|
|
|
|
|
- act(() => {
|
|
|
|
|
- vi.advanceTimersByTime(5000);
|
|
|
|
|
- });
|
|
|
|
|
- expect(onComplete).toHaveBeenCalledTimes(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("does not fire onComplete when state stays 'idle'", () => {
|
|
|
|
|
- const onComplete = vi.fn();
|
|
|
|
|
- render(<RollAnimation pool={POOL} winner={null} state="idle" onComplete={onComplete} />);
|
|
|
|
|
- act(() => {
|
|
|
|
|
- vi.advanceTimersByTime(10_000);
|
|
|
|
|
- });
|
|
|
|
|
- expect(onComplete).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("re-arms onComplete on a fresh idle → rolling cycle", () => {
|
|
|
|
|
- const onComplete = vi.fn();
|
|
|
|
|
- const { rerender } = render(
|
|
|
|
|
- <RollAnimation pool={POOL} winner={POOL[0]} state="rolling" onComplete={onComplete} />,
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- act(() => {
|
|
|
|
|
- vi.advanceTimersByTime(2500);
|
|
|
|
|
- });
|
|
|
|
|
- expect(onComplete).toHaveBeenCalledTimes(1);
|
|
|
|
|
-
|
|
|
|
|
- // Parent transitions back to idle, then starts a new roll.
|
|
|
|
|
- rerender(<RollAnimation pool={POOL} winner={null} state="idle" onComplete={onComplete} />);
|
|
|
|
|
- rerender(
|
|
|
|
|
- <RollAnimation pool={POOL} winner={POOL[1]} state="rolling" onComplete={onComplete} />,
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- act(() => {
|
|
|
|
|
- vi.advanceTimersByTime(2500);
|
|
|
|
|
- });
|
|
|
|
|
- expect(onComplete).toHaveBeenCalledTimes(2);
|
|
|
|
|
- });
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- describe("timer cleanup", () => {
|
|
|
|
|
- it("cancels all pending timers on unmount (no leaks, no late onComplete)", () => {
|
|
|
|
|
- const onComplete = vi.fn();
|
|
|
|
|
- const { unmount } = render(
|
|
|
|
|
- <RollAnimation pool={POOL} winner={POOL[0]} state="rolling" onComplete={onComplete} />,
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- // Mid-rolling.
|
|
|
|
|
- act(() => {
|
|
|
|
|
- vi.advanceTimersByTime(500);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- unmount();
|
|
|
|
|
-
|
|
|
|
|
- // Advance well past every phase boundary.
|
|
|
|
|
- act(() => {
|
|
|
|
|
- vi.advanceTimersByTime(10_000);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- expect(vi.getTimerCount()).toBe(0);
|
|
|
|
|
- expect(onComplete).not.toHaveBeenCalled();
|
|
|
|
|
- });
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- describe("XSS safety", () => {
|
|
|
|
|
- it("renders movie titles as text content only (no innerHTML injection)", () => {
|
|
|
|
|
- const evil = makeMovie("9", "<script>alert(1)</script>");
|
|
|
|
|
- // Strip poster_path so the no-image branch renders the title as text.
|
|
|
|
|
- const evilNoPoster: Movie = { ...evil, poster_path: null };
|
|
|
|
|
- const { container } = render(
|
|
|
|
|
- <RollAnimation pool={[evilNoPoster]} winner={evilNoPoster} state="complete" />,
|
|
|
|
|
- );
|
|
|
|
|
- // The literal angle brackets must appear as escaped text — never as
|
|
|
|
|
- // a real <script> element in the DOM.
|
|
|
|
|
- expect(container.querySelector("script")).toBeNull();
|
|
|
|
|
- expect(container.textContent).toContain("<script>alert(1)</script>");
|
|
|
|
|
- });
|
|
|
|
|
- });
|
|
|
|
|
-});
|
|
|