// @vitest-environment jsdom // // 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 stays static (no overlay, first image // only) for a single image, the crossfade off, or when disabled. Then the crossfade setting itself: // untouched it follows prefers-reduced-motion, and the reader's own pick wins over it (the owner, whose // Windows animation effects are off, never saw the crossfade — 2026-10-03). matchMedia is stubbed per test. import { describe, it, expect, afterEach, vi } from "vitest"; import { render, cleanup, renderHook, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createElement, type ReactNode } from "react"; import { api } from "./api"; import { qk } from "./queryKeys"; import { useCrossfadeBackdrop, useDetailPrefs } 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("animates under prefers-reduced-motion once the reader's setting says so", () => { withRoot(); mockReducedMotion(true); render(); expect(overlays().length).toBe(1); }); 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(""); }); }); describe("the crossfade setting", () => { const prefsWith = (preferences: Record) => { const qc = new QueryClient(); qc.setQueryData(qk.me(), { preferences }); return ({ children }: { children: ReactNode }) => createElement(QueryClientProvider, { client: qc }, children); }; it.each([ [false, true], [true, false], ])("untouched, follows the system (reduced motion %s → crossfade %s)", (reduce, on) => { mockReducedMotion(reduce); const { result } = renderHook(() => useDetailPrefs(), { wrapper: prefsWith({}) }); expect(result.current.crossfade).toBe(on); }); it("keeps the reader's own pick over the system", () => { mockReducedMotion(true); const on = renderHook(() => useDetailPrefs(), { wrapper: prefsWith({ plexInfoArtCrossfade: true }), }); expect(on.result.current.crossfade).toBe(true); mockReducedMotion(false); const off = renderHook(() => useDetailPrefs(), { wrapper: prefsWith({ plexInfoArtCrossfade: false }), }); expect(off.result.current.crossfade).toBe(false); }); it("turns on from the state in force, and stores that as the reader's pick", () => { mockReducedMotion(true); const save = vi.spyOn(api, "savePrefs").mockResolvedValue({} as never); const { result } = renderHook(() => useDetailPrefs(), { wrapper: prefsWith({}) }); act(() => result.current.toggleCrossfade()); expect(result.current.crossfade).toBe(true); expect(save).toHaveBeenCalledWith({ plexInfoArtCrossfade: true }); save.mockRestore(); }); });