// @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();
});
});