// @vitest-environment jsdom // // E5 S8: the crossfading art backdrop hook. Covers the pieces that don't need real layout — that a // pool paints #root + isolates it (so the z-index:-1 overlay can paint above #root's own background), // creates exactly one overlay layer when it may animate, and HARD-OFFS the rotation (no overlay, first // image only) under prefers-reduced-motion, a single image, or when disabled. matchMedia is stubbed // per-test; the hook reads it at effect time. import { describe, it, expect, afterEach, vi } from "vitest"; import { render, cleanup } from "@testing-library/react"; import { useCrossfadeBackdrop } from "./plexDetail"; function mockReducedMotion(reduce: boolean) { window.matchMedia = vi.fn().mockImplementation((query: string) => ({ matches: query.includes("prefers-reduced-motion") ? reduce : false, media: query, addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, })) as unknown as typeof window.matchMedia; } function Harness({ arts, enabled = true, animate = true, }: { arts: (string | null | undefined)[]; enabled?: boolean; animate?: boolean; }) { useCrossfadeBackdrop(arts, enabled, animate); return null; } function root() { return document.getElementById("root")!; } // The hook paints #root, so it must exist in the document (main.tsx renders into it in the real app). function withRoot() { const el = document.createElement("div"); el.id = "root"; document.body.appendChild(el); return el; } afterEach(() => { cleanup(); document.getElementById("root")?.remove(); }); const overlays = () => Array.from(root().children).filter((c) => (c as HTMLElement).style.zIndex === "-1"); describe("useCrossfadeBackdrop", () => { it("paints + isolates #root and creates one overlay when it can animate", () => { withRoot(); mockReducedMotion(false); render(); expect(root().style.backgroundImage).toContain('url("/a.jpg")'); expect(root().style.isolation).toBe("isolate"); // needed for the z-index:-1 overlay to show expect(overlays().length).toBe(1); }); it("hard-offs the rotation under prefers-reduced-motion (static, no overlay)", () => { withRoot(); mockReducedMotion(true); render(); expect(root().style.backgroundImage).toContain('url("/a.jpg")'); // first image still painted expect(overlays().length).toBe(0); // but no crossfade layer }); it("stays static for a single image (no overlay)", () => { withRoot(); mockReducedMotion(false); render(); expect(root().style.backgroundImage).toContain('url("/only.jpg")'); expect(overlays().length).toBe(0); }); it("does not animate when animate=false (crossfade pref off)", () => { withRoot(); mockReducedMotion(false); render(); expect(root().style.backgroundImage).toContain('url("/a.jpg")'); expect(overlays().length).toBe(0); }); it("clears #root when disabled and removes the overlay on unmount", () => { withRoot(); mockReducedMotion(false); const { rerender, unmount } = render(); expect(root().style.backgroundImage).toBe(""); expect(overlays().length).toBe(0); rerender(); expect(overlays().length).toBe(1); unmount(); expect(overlays().length).toBe(0); // cleaned up expect(root().style.isolation).toBe(""); }); });