The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
149 lines
5.4 KiB
TypeScript
149 lines
5.4 KiB
TypeScript
// @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(<Harness arts={["/a.jpg", "/b.jpg", "/c.jpg"]} />);
|
|
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(<Harness arts={["/a.jpg", "/b.jpg"]} />);
|
|
expect(overlays().length).toBe(1);
|
|
});
|
|
|
|
it("stays static for a single image (no overlay)", () => {
|
|
withRoot();
|
|
mockReducedMotion(false);
|
|
render(<Harness arts={["/only.jpg", null, undefined]} />);
|
|
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(<Harness arts={["/a.jpg", "/b.jpg"]} animate={false} />);
|
|
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(<Harness arts={["/a.jpg", "/b.jpg"]} enabled={false} />);
|
|
expect(root().style.backgroundImage).toBe("");
|
|
expect(overlays().length).toBe(0);
|
|
rerender(<Harness arts={["/a.jpg", "/b.jpg"]} enabled={true} />);
|
|
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<string, unknown>) => {
|
|
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();
|
|
});
|
|
});
|