|
|
@@ -0,0 +1,255 @@
|
|
|
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
+import { act, renderHook } from "@testing-library/react";
|
|
|
+import { StrictMode, createElement, type ReactNode } from "react";
|
|
|
+import { useRoll } from "@/hooks/use-roll";
|
|
|
+import type { Movie } from "@/types/movie";
|
|
|
+
|
|
|
+function makeMovie(id: string, watched = false): Movie {
|
|
|
+ return {
|
|
|
+ id,
|
|
|
+ group_id: "g1",
|
|
|
+ tmdb_id: Number(id) || 0,
|
|
|
+ title: `Movie ${id}`,
|
|
|
+ year: 2020,
|
|
|
+ poster_path: null,
|
|
|
+ genres: [],
|
|
|
+ trailer_url: null,
|
|
|
+ trailer_url_refreshed_at: null,
|
|
|
+ metadata_refreshed_at: null,
|
|
|
+ added_by: null,
|
|
|
+ watched,
|
|
|
+ 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 StrictWrapper = ({ children }: { children: ReactNode }) =>
|
|
|
+ createElement(StrictMode, null, children);
|
|
|
+
|
|
|
+describe("useRoll", () => {
|
|
|
+ beforeEach(() => {
|
|
|
+ vi.useFakeTimers();
|
|
|
+ installMatchMedia(false);
|
|
|
+ });
|
|
|
+
|
|
|
+ afterEach(() => {
|
|
|
+ vi.useRealTimers();
|
|
|
+ vi.unstubAllGlobals();
|
|
|
+ vi.restoreAllMocks();
|
|
|
+ });
|
|
|
+
|
|
|
+ it("transitions idle -> rolling -> complete with the 2500ms timer", () => {
|
|
|
+ const { result } = renderHook(() => useRoll());
|
|
|
+ const pool = [makeMovie("1"), makeMovie("2"), makeMovie("3")];
|
|
|
+
|
|
|
+ expect(result.current.rollState).toBe("idle");
|
|
|
+ expect(result.current.result).toBeNull();
|
|
|
+
|
|
|
+ act(() => {
|
|
|
+ result.current.roll(pool);
|
|
|
+ });
|
|
|
+
|
|
|
+ expect(result.current.rollState).toBe("rolling");
|
|
|
+ expect(result.current.result).not.toBeNull();
|
|
|
+
|
|
|
+ act(() => {
|
|
|
+ vi.advanceTimersByTime(2499);
|
|
|
+ });
|
|
|
+ expect(result.current.rollState).toBe("rolling");
|
|
|
+
|
|
|
+ act(() => {
|
|
|
+ vi.advanceTimersByTime(1);
|
|
|
+ });
|
|
|
+ expect(result.current.rollState).toBe("complete");
|
|
|
+ });
|
|
|
+
|
|
|
+ it("reset returns to idle and clears the result", () => {
|
|
|
+ const { result } = renderHook(() => useRoll());
|
|
|
+ const pool = [makeMovie("1"), makeMovie("2")];
|
|
|
+
|
|
|
+ act(() => {
|
|
|
+ result.current.roll(pool);
|
|
|
+ });
|
|
|
+ act(() => {
|
|
|
+ vi.advanceTimersByTime(2500);
|
|
|
+ });
|
|
|
+ expect(result.current.rollState).toBe("complete");
|
|
|
+
|
|
|
+ act(() => {
|
|
|
+ result.current.reset();
|
|
|
+ });
|
|
|
+ expect(result.current.rollState).toBe("idle");
|
|
|
+ expect(result.current.result).toBeNull();
|
|
|
+ });
|
|
|
+
|
|
|
+ it("reduced-motion fast path: roll() goes straight to complete with no timer", () => {
|
|
|
+ installMatchMedia(true);
|
|
|
+
|
|
|
+ const { result } = renderHook(() => useRoll());
|
|
|
+ const pool = [makeMovie("1"), makeMovie("2")];
|
|
|
+
|
|
|
+ act(() => {
|
|
|
+ result.current.roll(pool);
|
|
|
+ });
|
|
|
+
|
|
|
+ // Synchronously complete — no timer advance needed.
|
|
|
+ expect(result.current.rollState).toBe("complete");
|
|
|
+ expect(result.current.result).not.toBeNull();
|
|
|
+ // No pending timers should remain.
|
|
|
+ expect(vi.getTimerCount()).toBe(0);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("captures the pool by value at call time (real-time cache mutations are ignored)", () => {
|
|
|
+ const { result } = renderHook(() => useRoll());
|
|
|
+
|
|
|
+ // Build a pool of distinct movies; mutate the source array after roll().
|
|
|
+ const original = [makeMovie("a"), makeMovie("b"), makeMovie("c")];
|
|
|
+ const originalIds = new Set(original.map((m) => m.id));
|
|
|
+ const pool = [...original];
|
|
|
+
|
|
|
+ act(() => {
|
|
|
+ result.current.roll(pool);
|
|
|
+ });
|
|
|
+
|
|
|
+ // Mutate aggressively: clear and replace with a totally different set.
|
|
|
+ pool.splice(0, pool.length);
|
|
|
+ pool.push(makeMovie("X"), makeMovie("Y"), makeMovie("Z"));
|
|
|
+
|
|
|
+ act(() => {
|
|
|
+ vi.advanceTimersByTime(2500);
|
|
|
+ });
|
|
|
+
|
|
|
+ expect(result.current.rollState).toBe("complete");
|
|
|
+ const winner = result.current.result;
|
|
|
+ expect(winner).not.toBeNull();
|
|
|
+ // Winner must come from the original snapshot, not the mutated array.
|
|
|
+ expect(originalIds.has(winner!.id)).toBe(true);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("re-roll: calling roll() again from complete returns to rolling", () => {
|
|
|
+ const { result } = renderHook(() => useRoll());
|
|
|
+ const pool = [makeMovie("1"), makeMovie("2"), makeMovie("3")];
|
|
|
+
|
|
|
+ act(() => {
|
|
|
+ result.current.roll(pool);
|
|
|
+ });
|
|
|
+ act(() => {
|
|
|
+ vi.advanceTimersByTime(2500);
|
|
|
+ });
|
|
|
+ expect(result.current.rollState).toBe("complete");
|
|
|
+
|
|
|
+ // Re-roll without supplying a new pool: must reuse the captured snapshot.
|
|
|
+ act(() => {
|
|
|
+ result.current.roll();
|
|
|
+ });
|
|
|
+ expect(result.current.rollState).toBe("rolling");
|
|
|
+ expect(result.current.result).not.toBeNull();
|
|
|
+
|
|
|
+ act(() => {
|
|
|
+ vi.advanceTimersByTime(2500);
|
|
|
+ });
|
|
|
+ expect(result.current.rollState).toBe("complete");
|
|
|
+ });
|
|
|
+
|
|
|
+ it("re-roll with a fresh pool overrides the captured snapshot", () => {
|
|
|
+ const { result } = renderHook(() => useRoll());
|
|
|
+
|
|
|
+ act(() => {
|
|
|
+ result.current.roll([makeMovie("1")]);
|
|
|
+ });
|
|
|
+ act(() => {
|
|
|
+ vi.advanceTimersByTime(2500);
|
|
|
+ });
|
|
|
+
|
|
|
+ const onlySurvivor = makeMovie("only");
|
|
|
+ act(() => {
|
|
|
+ result.current.roll([onlySurvivor]);
|
|
|
+ });
|
|
|
+ act(() => {
|
|
|
+ vi.advanceTimersByTime(2500);
|
|
|
+ });
|
|
|
+
|
|
|
+ expect(result.current.result?.id).toBe("only");
|
|
|
+ });
|
|
|
+
|
|
|
+ it("Strict Mode: unmounting before the timer fires cancels it (no late state updates)", () => {
|
|
|
+ const warn = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
+
|
|
|
+ const { result, unmount } = renderHook(() => useRoll(), {
|
|
|
+ wrapper: StrictWrapper,
|
|
|
+ });
|
|
|
+
|
|
|
+ act(() => {
|
|
|
+ result.current.roll([makeMovie("1"), makeMovie("2")]);
|
|
|
+ });
|
|
|
+
|
|
|
+ unmount();
|
|
|
+
|
|
|
+ // Advance well past the animation duration.
|
|
|
+ act(() => {
|
|
|
+ vi.advanceTimersByTime(10_000);
|
|
|
+ });
|
|
|
+
|
|
|
+ // No queued timers should remain after unmount + advance.
|
|
|
+ expect(vi.getTimerCount()).toBe(0);
|
|
|
+ // No "state update on unmounted component" warnings (Strict Mode double-invocation safe).
|
|
|
+ const stateUpdateWarning = warn.mock.calls.find((call) =>
|
|
|
+ String(call[0] ?? "").includes("unmounted"),
|
|
|
+ );
|
|
|
+ expect(stateUpdateWarning).toBeUndefined();
|
|
|
+ });
|
|
|
+
|
|
|
+ it("calling roll() again mid-rolling cancels the previous timer", () => {
|
|
|
+ const { result } = renderHook(() => useRoll());
|
|
|
+ const pool = [makeMovie("1"), makeMovie("2")];
|
|
|
+
|
|
|
+ act(() => {
|
|
|
+ result.current.roll(pool);
|
|
|
+ });
|
|
|
+ act(() => {
|
|
|
+ vi.advanceTimersByTime(1000);
|
|
|
+ });
|
|
|
+
|
|
|
+ // Start a new roll mid-flight; previous timer must be cancelled.
|
|
|
+ act(() => {
|
|
|
+ result.current.roll(pool);
|
|
|
+ });
|
|
|
+ expect(vi.getTimerCount()).toBe(1);
|
|
|
+
|
|
|
+ act(() => {
|
|
|
+ vi.advanceTimersByTime(2499);
|
|
|
+ });
|
|
|
+ expect(result.current.rollState).toBe("rolling");
|
|
|
+
|
|
|
+ act(() => {
|
|
|
+ vi.advanceTimersByTime(1);
|
|
|
+ });
|
|
|
+ expect(result.current.rollState).toBe("complete");
|
|
|
+ });
|
|
|
+});
|