Pārlūkot izejas kodu

[Phase 4 U5] add <RollAnimation /> with reduced-motion fallback and tests

Rewrites src/components/dice/roll-animation.tsx to match the U5 contract
from research/PHASE4_PLAN.md:

- Props { pool, winner, state, onComplete } (replaces { rollState, result })
- Single useEffect drives all phase transitions (scatter 0-800ms,
  eliminate 800-2000ms, settle 2000-2500ms) so cleanup is Strict-Mode safe
- Reduced-motion branch bypasses the scatter timeline entirely and renders
  a 150ms opacity fade-in on the winner only
- onComplete fires exactly once per "rolling" -> "complete" cycle, guarded
  by a ref that resets when leaving "rolling"
- <=16 scatter DOM elements; only transform/opacity animate (GPU-composited)
- Movie titles rendered as React text children / alt attributes only

Adds 13 unit tests covering DOM-per-state, reduced-motion path,
onComplete lifecycle (single fire, no double-fire on rerender, re-arm
on idle->rolling cycle), timer cleanup on unmount, and XSS escaping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User 2 mēneši atpakaļ
vecāks
revīzija
4c71d943c1

+ 258 - 0
src/__tests__/components/dice/roll-animation.test.tsx

@@ -0,0 +1,258 @@
+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>");
+    });
+  });
+});

+ 171 - 44
src/components/dice/roll-animation.tsx

@@ -1,13 +1,39 @@
 "use client";
 
-import { useEffect, useState, useSyncExternalStore } from "react";
+import { useEffect, useRef, useState, useSyncExternalStore } from "react";
 import type { Movie } from "@/types/movie";
 import type { RollState } from "@/hooks/use-roll";
 
+/**
+ * <RollAnimation /> — self-contained presentational component for the
+ * randomizer's dice roll animation.
+ *
+ * Phases (only when `state === "rolling"` and reduced-motion is OFF):
+ *   - 0–800ms      scatter:   posters fly out into a fan
+ *   - 800–2000ms   eliminate: non-winner posters fade/scale away
+ *   - 2000–2500ms  settle:    winner snaps to center, scaled up
+ *   - 2500ms       fires `onComplete?.()` exactly once
+ *
+ * Reduced-motion fast path: bypass scatter entirely. Render a 150 ms fade-in
+ * on the winner only.
+ *
+ * Animation strategy: CSS transitions on `transform` / `opacity` only — no
+ * `requestAnimationFrame` loops (jsdom no-ops rAF; React 19 Strict Mode
+ * double-invokes effects). All timers live in a SINGLE useEffect so the
+ * cleanup function is straightforward and Strict-Mode safe.
+ *
+ * XSS: movie titles are rendered as React text children / `alt` attributes
+ * only. No `dangerouslySetInnerHTML`, no unescaped `title=` attributes.
+ *
+ * Perf: ≤16 DOM elements in the scatter; only `transform` / `opacity`
+ * properties animate (GPU-composited).
+ */
+
 interface RollAnimationProps {
-  rollState: RollState;
-  result: Movie | null;
   pool: Movie[];
+  winner: Movie | null;
+  state: RollState;
+  onComplete?: () => void;
 }
 
 const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
@@ -34,68 +60,148 @@ function usePrefersReducedMotion(): boolean {
   );
 }
 
-const MAX_VISIBLE = 8;
+// Phase boundaries (ms from start of "rolling")
+const SCATTER_END_MS = 800;
+const ELIMINATE_END_MS = 2000;
+const SETTLE_END_MS = 2500;
 
-export function RollAnimation({ rollState, result, pool }: RollAnimationProps) {
-  const prefersReducedMotion = usePrefersReducedMotion();
-  const [eliminatedCount, setEliminatedCount] = useState(0);
+// Hard cap on scatter DOM elements (perf budget).
+const MAX_SCATTER = 16;
+
+// Reduced-motion fade-in duration.
+const REDUCED_MOTION_FADE_MS = 150;
 
-  const visiblePool = pool.slice(0, MAX_VISIBLE);
-  const totalToEliminate = visiblePool.length - 1;
+type Phase = "scatter" | "eliminate" | "settle" | "done";
+
+export function RollAnimation({ pool, winner, state, onComplete }: RollAnimationProps) {
+  const prefersReducedMotion = usePrefersReducedMotion();
+  const [phase, setPhase] = useState<Phase>("scatter");
+  // Drives the reduced-motion fade-in (opacity 0 → 1 via Tailwind transition).
+  const [fadedIn, setFadedIn] = useState(false);
+  // Tracks the previous `state` prop so we can reset the cycle's bookkeeping
+  // at render time when the parent transitions out of "rolling". The React
+  // docs recommend setState-during-render for prop-driven resets.
+  const [prevState, setPrevState] = useState<RollState>(state);
+  // Guard so onComplete fires exactly once per "rolling" → "complete" cycle.
+  // Mutated only inside effect / timer callbacks (never at render time).
+  const completedRef = useRef(false);
+
+  if (prevState !== state) {
+    setPrevState(state);
+    if (state !== "rolling") {
+      // Reset visible animation state synchronously so the next "rolling"
+      // cycle starts from a clean slate.
+      setPhase("scatter");
+      setFadedIn(false);
+    }
+  }
 
   useEffect(() => {
-    if (rollState !== "rolling") {
-      setEliminatedCount(0);
+    if (state !== "rolling") {
+      completedRef.current = false;
       return;
     }
 
-    if (prefersReducedMotion || totalToEliminate <= 0) return;
-
-    const intervalMs = 2000 / Math.max(totalToEliminate, 1);
-    let count = 0;
-    const timer = setInterval(() => {
-      count++;
-      setEliminatedCount(count);
-      if (count >= totalToEliminate) clearInterval(timer);
-    }, intervalMs);
-
-    return () => clearInterval(timer);
-  }, [rollState, prefersReducedMotion, totalToEliminate]);
-
-  if (rollState === "idle") return null;
+    completedRef.current = false;
+
+    // Reduced-motion: skip the scatter timeline entirely. Flip the opacity
+    // class one tick after mount so the CSS transition actually animates,
+    // then fire onComplete after the fade window.
+    if (prefersReducedMotion) {
+      const fadeStart = setTimeout(() => {
+        setPhase("done");
+        setFadedIn(true);
+      }, 0);
+      const fadeDone = setTimeout(() => {
+        if (!completedRef.current) {
+          completedRef.current = true;
+          onComplete?.();
+        }
+      }, REDUCED_MOTION_FADE_MS);
+      return () => {
+        clearTimeout(fadeStart);
+        clearTimeout(fadeDone);
+      };
+    }
 
+    // Full animation timeline. ALL timers are tracked locally and cleared
+    // in the cleanup function — Strict-Mode safe. Initial phase ("scatter")
+    // is handled by the render-time reset above; the timeline only mutates
+    // forward from there.
+    const t1 = setTimeout(() => setPhase("eliminate"), SCATTER_END_MS);
+    const t2 = setTimeout(() => setPhase("settle"), ELIMINATE_END_MS);
+    const t3 = setTimeout(() => {
+      setPhase("done");
+      if (!completedRef.current) {
+        completedRef.current = true;
+        onComplete?.();
+      }
+    }, SETTLE_END_MS);
+
+    return () => {
+      clearTimeout(t1);
+      clearTimeout(t2);
+      clearTimeout(t3);
+    };
+  }, [state, prefersReducedMotion, onComplete]);
+
+  if (state === "idle") return null;
+
+  // Reduced-motion render: winner-only fade-in via Tailwind opacity transition.
   if (prefersReducedMotion) {
-    if (!result) return null;
+    if (!winner) return null;
+    // When state arrives already as "complete" (no rolling step), render at
+    // full opacity immediately — there's no animation cycle to wait for.
+    const visible = fadedIn || state === "complete";
     return (
-      <div className="flex justify-center py-6">
+      <div className="flex justify-center py-6" data-testid="roll-animation-reduced-motion">
         <div
-          className={`transition-opacity duration-500 ${rollState === "complete" ? "opacity-100" : "opacity-0"}`}
+          className={`transition-opacity duration-150 ease-out ${visible ? "opacity-100" : "opacity-0"}`}
         >
-          <PosterThumbnail movie={result} showAlt />
+          <Poster movie={winner} prominent />
         </div>
       </div>
     );
   }
 
+  // Full animation: scatter pool. Cap to MAX_SCATTER, ensure winner is in.
+  const scatterPool = buildScatterPool(pool, winner, MAX_SCATTER);
+  const showWinnerProminent = state === "complete" || phase === "settle" || phase === "done";
+
   return (
-    <div className="flex flex-wrap justify-center gap-3 py-6" aria-hidden="true">
-      {visiblePool.map((movie, i) => {
-        const isWinner = result && movie.id === result.id;
-        const isEliminated = !isWinner && i < eliminatedCount;
-        const isRevealed = rollState === "complete" && isWinner;
+    <div
+      className="relative flex flex-wrap justify-center gap-3 py-6 min-h-[10rem]"
+      data-testid="roll-animation-scatter"
+      aria-hidden="true"
+    >
+      {scatterPool.map((movie, i) => {
+        const isWinner = winner !== null && movie.id === winner.id;
+        const eliminated = !isWinner && phase !== "scatter";
+
+        // Spread positions deterministically for visual scatter (transform only).
+        const angle = (i / Math.max(scatterPool.length, 1)) * 360;
+        const radius = phase === "scatter" || phase === "eliminate" ? 60 : 0;
+        const tx = Math.cos((angle * Math.PI) / 180) * radius;
+        const ty = Math.sin((angle * Math.PI) / 180) * radius;
+
+        const transform =
+          isWinner && showWinnerProminent
+            ? "translate(0px, 0px) scale(1.4)"
+            : `translate(${tx.toFixed(1)}px, ${ty.toFixed(1)}px) scale(${eliminated ? 0.4 : 1})`;
 
         return (
           <div
             key={movie.id}
+            data-testid={isWinner ? "roll-animation-winner" : "roll-animation-poster"}
             className={`
-              relative w-20 h-28 sm:w-24 sm:h-36 rounded-lg overflow-hidden
-              transition-all duration-500 ease-out
-              ${isEliminated ? "opacity-0 scale-50 rotate-45 translate-y-8" : ""}
-              ${isRevealed ? "scale-110 ring-4 ring-red-500 shadow-xl" : ""}
-              ${rollState === "rolling" && !isEliminated ? "animate-pulse" : ""}
+              w-20 h-28 sm:w-24 sm:h-36 rounded-lg overflow-hidden
+              transition-[transform,opacity] duration-500 ease-out
+              ${eliminated ? "opacity-0" : "opacity-100"}
+              ${isWinner && showWinnerProminent ? "ring-4 ring-yellow-400 shadow-2xl z-10" : ""}
             `}
+            style={{ transform, willChange: "transform, opacity" }}
           >
-            <PosterThumbnail movie={movie} />
+            <Poster movie={movie} prominent={isWinner && showWinnerProminent} />
           </div>
         );
       })}
@@ -103,19 +209,40 @@ export function RollAnimation({ rollState, result, pool }: RollAnimationProps) {
   );
 }
 
-function PosterThumbnail({ movie, showAlt }: { movie: Movie; showAlt?: boolean }) {
+/**
+ * Build the scatter pool: ensure the winner is included, cap to `max`,
+ * preserve original ordering otherwise. This guarantees the winner has a
+ * stable DOM key so its CSS transitions animate smoothly across phases.
+ */
+function buildScatterPool(pool: Movie[], winner: Movie | null, max: number): Movie[] {
+  if (pool.length === 0) return winner ? [winner] : [];
+  if (pool.length <= max) {
+    if (winner && !pool.some((m) => m.id === winner.id)) {
+      return [winner, ...pool].slice(0, max);
+    }
+    return pool;
+  }
+  const truncated = pool.slice(0, max);
+  if (winner && !truncated.some((m) => m.id === winner.id)) {
+    truncated[truncated.length - 1] = winner;
+  }
+  return truncated;
+}
+
+function Poster({ movie, prominent }: { movie: Movie; prominent?: boolean }) {
   if (movie.poster_path) {
     return (
+      // eslint-disable-next-line @next/next/no-img-element
       <img
         src={`https://image.tmdb.org/t/p/w185${movie.poster_path}`}
-        alt={showAlt ? `${movie.title} poster` : ""}
-        className="w-full h-full object-cover"
+        alt={prominent ? `${movie.title} poster` : ""}
         loading="lazy"
+        className="w-full h-full object-cover"
       />
     );
   }
   return (
-    <div className="w-full h-full bg-gray-700 flex items-center justify-center text-xs text-gray-400 p-1 text-center">
+    <div className="w-full h-full bg-foreground/10 flex items-center justify-center text-xs text-foreground/60 p-1 text-center">
       {movie.title}
     </div>
   );