Files
siftlode/frontend/src/components/PlexInfo.dom.test.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
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.
2026-10-05 00:19:39 +02:00

366 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
//
// The enriched Plex detail page. Covers the TMDB-fed pieces added on the page variant — the
// multi-source ratings row (IMDb / TMDb / audience), the facts panel (budget / revenue / studios /
// countries), the keyword chips (→ onFilter), and the full crew (→ onOpenPerson). i18n is
// key-passthrough; PlexInfo is prop-driven (no queries) so it just needs a QueryClient for its cache
// reads. The overlay variant is asserted to stay lean (no facts/keywords/crew).
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
i18n: { language: "en" },
// A certificate code ("R") is no catalog word: it falls back to itself, as in the real catalog.
t: (key: string, opts?: Record<string, unknown>) =>
opts?.name
? `${key}:${opts.name}`
: key.startsWith("vocab.certificate.") && typeof opts?.defaultValue === "string"
? opts.defaultValue
: key,
}),
};
});
import PlexInfo from "./PlexInfo";
import { CREW_SHOWN } from "./plexDetailAside";
import type { PlexItemDetail } from "../lib/api";
afterEach(cleanup);
const noop = () => {};
const detail: PlexItemDetail = {
id: "m1",
kind: "movie",
title: "Test Movie",
summary: "A summary.",
year: 2010,
duration_seconds: 7200,
playable: "direct",
thumb: "/api/plex/image/m1",
art: "/api/plex/image/m1?variant=art",
cast: [],
imdb_rating: 7.8,
imdb_id: "tt1",
imdb_url: "https://www.imdb.com/title/tt1",
rating: 8.4, // Plex audience
content_rating: "R",
genres: ["Crime"],
directors: ["Plex Director"],
studio: "Fallback Studio",
tagline: "A tagline.",
markers: [],
audio_streams: [],
subtitle_streams: [],
status: "new",
position_seconds: 0,
tmdb: {
tmdb_id: 42,
rating: 7.2,
vote_count: 1234,
budget: 160000000,
revenue: 830000000,
keywords: ["heist", "bank robbery"],
crew: [
{ name: "Jane Director", job: "Director" },
{ name: "Sam Writer", job: "Writer" },
],
studios: ["Studio A", "Studio B"],
// One coded (named in the reader's language), one from a row enriched before codes were stored.
production_countries: [
{ code: "HU", name: "Hungary" },
{ code: null, name: "United States" },
],
recommendations: [],
backdrops: [],
},
};
function wrap(ui: React.ReactElement) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
describe("PlexInfo enriched detail (page)", () => {
it("renders the multi-source ratings row (IMDb / TMDb / audience)", () => {
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" />);
expect(screen.getByText("IMDb")).toBeTruthy();
expect(screen.getByText("7.8")).toBeTruthy();
expect(screen.getByText(/TMDb/)).toBeTruthy(); // "TMDb · 1.2K" (vote count compacted)
expect(screen.getByText("7.2")).toBeTruthy();
expect(screen.getByText("plex.info.audience")).toBeTruthy();
expect(screen.getByText("8.4")).toBeTruthy();
});
it("renders the facts panel with budget / revenue / studios / countries", () => {
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" />);
expect(screen.getByText("plex.info.facts.title")).toBeTruthy();
expect(screen.getByText("plex.info.facts.budget")).toBeTruthy();
expect(screen.getByText("$160M")).toBeTruthy();
expect(screen.getByText("$830M")).toBeTruthy();
expect(screen.getByText("Studio A")).toBeTruthy(); // tmdb.studios preferred over the fallback
// A coded country is named through the vocab (key-echo i18n here), an uncoded one as stored.
expect(screen.getByText("vocab.region.HU, United States")).toBeTruthy();
});
it("keyword chips filter the library on click", () => {
const onFilter = vi.fn();
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onFilter={onFilter} />);
fireEvent.click(screen.getByText("heist"));
expect(onFilter).toHaveBeenCalledWith({ keywords: ["heist"] });
});
// Every value that filters says so on hover — the genre, certificate and studio had no hint
// while the keyword and the year did — and a value filters in the facts panel as in the header.
it("every filtering value carries a filter hint", () => {
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onFilter={() => {}} />);
for (const v of ["heist", "Crime", "R", "Studio A"])
for (const el of screen.getAllByText(v))
expect(el.closest("[title]")?.getAttribute("title")).toBe(`plex.info.filterValue:${v}`);
});
it("the facts panel's year and certificate filter like the header's", () => {
const onFilter = vi.fn();
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onFilter={onFilter} />);
const [, factsCert] = screen.getAllByText("R");
fireEvent.click(factsCert!);
expect(onFilter).toHaveBeenLastCalledWith({ contentRatings: ["R"] });
const [, factsYear] = screen.getAllByText("2010");
fireEvent.click(factsYear!);
expect(onFilter).toHaveBeenLastCalledWith({ yearMin: 2010, yearMax: 2010 });
});
it("crew members open the person page on click", () => {
const onOpenPerson = vi.fn();
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onOpenPerson={onOpenPerson} />);
expect(screen.getByText("plex.info.crew")).toBeTruthy();
// The job is TMDB's English word, named through the vocab (key-echo i18n here).
expect(screen.getByText("vocab.crewJob.Director")).toBeTruthy();
fireEvent.click(screen.getByText("Jane Director"));
expect(onOpenPerson).toHaveBeenCalledWith("Jane Director");
});
it("RatingsRow: with no IMDb score, the audience score becomes the filterable one", () => {
const onFilter = vi.fn();
const noImdb = { ...detail, imdb_rating: null, imdb_id: null, imdb_url: null };
wrap(<PlexInfo onPlayItem={noop} detail={noImdb} variant="page" onFilter={onFilter} />);
expect(screen.queryByText("IMDb")).toBeNull();
// audience (8.4) is now the primary/filterable score → clicking it filters ratingMin.
fireEvent.click(screen.getByText("8.4"));
expect(onFilter).toHaveBeenCalledWith({ ratingMin: 8 });
});
it("RatingsRow: suppresses the duplicate audience chip when it equals the IMDb score", () => {
const dupe = { ...detail, rating: 7.8 }; // audience == imdb_rating (7.8)
wrap(<PlexInfo onPlayItem={noop} detail={dupe} variant="page" />);
expect(screen.getByText("IMDb")).toBeTruthy();
expect(screen.queryByText("plex.info.audience")).toBeNull(); // duplicate audience chip suppressed
expect(screen.getAllByText("7.8")).toHaveLength(1); // the score shows once (IMDb only)
});
it("the overlay variant stays lean (no facts / keywords / crew panels)", () => {
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="overlay" />);
expect(screen.queryByText("plex.info.facts.title")).toBeNull();
expect(screen.queryByText("plex.info.keywords")).toBeNull();
expect(screen.queryByText("plex.info.crew")).toBeNull();
// but the ratings row still renders (it's compact enough for the overlay)
expect(screen.getByText("IMDb")).toBeTruthy();
});
});
// The owner's layout: a main column (collection strips → crew → cast) beside a sidebar of
// facts (details, keywords); a column with nothing in it is not rendered and the other takes the width.
// The owner's pick: the hero's text stopped at ~55% of the panel. The facts and the key crew are
// its second column now (a grid that splits in a wide hero), the keywords sit under the text, and under
// the hero only the collection strips and the cast remain, stacked.
describe("PlexInfo — the hero's second column (page)", () => {
const TWO_COLUMNS = "@5xl:grid-cols-[minmax(0,1fr)_minmax(0,24rem)]";
// The strips are Carousels, which watch their size.
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
const heading = (name: string) => screen.getByRole("heading", { name });
const follows = (a: HTMLElement, b: HTMLElement) =>
!!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
const full: PlexItemDetail = {
...detail,
cast: [{ name: "An Actor", role: "Hero" }],
collections: [
{
id: "c1",
title: "The Collection",
source: "plex",
items: [{ id: "m2", type: "movie", title: "Sequel", thumb: "/api/plex/image/m2" }],
},
],
};
it("puts the facts over the crew beside the text, the keywords under it, the rest below the hero", () => {
wrap(<PlexInfo onPlayItem={noop} detail={full} variant="page" />);
const aside = screen.getByTestId("detail-aside");
expect(aside.contains(heading("plex.info.facts.title"))).toBe(true);
expect(aside.contains(heading("plex.info.crew"))).toBe(true);
expect(follows(heading("plex.info.facts.title"), heading("plex.info.crew"))).toBe(true);
// The grid's two children: the poster + text, and the column.
const grid = aside.parentElement!;
expect(grid.className).toContain(TWO_COLUMNS);
const main = grid.children[0]!;
const side = grid.children[1];
expect(side).toBe(aside);
expect(main.contains(screen.getByText("A summary."))).toBe(true);
expect(main.contains(screen.getByTestId("detail-keywords"))).toBe(true);
// Below the hero (the panel under the title): the collection, then the cast — and neither in it.
const hero = grid.parentElement!;
expect(hero.contains(heading("Test Movie"))).toBe(true);
const coll = heading("The Collection");
const cast = heading("plex.info.cast");
expect(hero.contains(coll) || hero.contains(cast)).toBe(false);
expect(follows(hero, coll) && follows(coll, cast)).toBe(true);
});
it("without facts or crew there is no second column; the keywords still sit under the text", () => {
const bare: PlexItemDetail = {
...detail,
year: null,
duration_seconds: null,
content_rating: null,
studio: null,
tmdb: {
...detail.tmdb!,
budget: null,
revenue: null,
studios: [],
production_countries: [],
crew: [],
},
};
wrap(<PlexInfo onPlayItem={noop} detail={bare} variant="page" />);
expect(screen.queryByTestId("detail-aside")).toBeNull();
expect(screen.queryByRole("heading", { name: "plex.info.facts.title" })).toBeNull();
const kw = screen.getByTestId("detail-keywords");
expect(document.querySelector(`[class*="${TWO_COLUMNS}"]`)).toBeNull();
expect(follows(heading("Test Movie"), kw)).toBe(true);
});
// The hero's grid row is as tall as its taller column — the crew, beside the text — and the poster,
// a flex child, stretched to it: a 2:3 picture drawn ~175×590 and cropped. It keeps its own ratio.
it("keeps the poster at its own height, not stretched to the row", () => {
wrap(<PlexInfo onPlayItem={noop} detail={full} variant="page" />);
const poster = document.querySelector<HTMLImageElement>(`img[src="${detail.thumb}"]`)!;
expect(poster.className).toContain("aspect-2/3");
expect(poster.className.split(/\s+/)).toContain("self-start");
});
// The owner's call: twelve names ran the column ~170px past the text. The first CREW_SHOWN show,
// the rest behind "Show N more"; a list barely longer shows whole.
it("shows the first crew names and opens the rest on demand", () => {
const crew = Array.from({ length: 12 }, (_, i) => ({ name: `Person ${i}`, job: "Producer" }));
wrap(
<PlexInfo
onPlayItem={noop}
detail={{ ...full, tmdb: { ...full.tmdb!, crew } }}
variant="page"
/>,
);
const aside = screen.getByTestId("detail-aside");
expect(aside.textContent).toContain(`Person ${CREW_SHOWN - 1}`);
expect(aside.textContent).not.toContain(`Person ${CREW_SHOWN}`);
const more = screen.getByRole("button", { name: "plex.info.crewMore" });
expect(more.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(more);
expect(aside.textContent).toContain("Person 11");
const less = screen.getByRole("button", { name: "plex.info.crewLess" });
expect(less.getAttribute("aria-expanded")).toBe("true");
fireEvent.click(less);
expect(aside.textContent).not.toContain(`Person ${CREW_SHOWN}`);
});
it("shows a crew only a little longer than that whole, with no toggle", () => {
const crew = Array.from({ length: CREW_SHOWN + 2 }, (_, i) => ({
name: `Person ${i}`,
job: "Producer",
}));
wrap(
<PlexInfo
onPlayItem={noop}
detail={{ ...full, tmdb: { ...full.tmdb!, crew } }}
variant="page"
/>,
);
expect(screen.getByTestId("detail-aside").textContent).toContain(`Person ${CREW_SHOWN + 1}`);
expect(screen.queryByRole("button", { name: "plex.info.crewMore" })).toBeNull();
});
// The cast row is the shared CastRow now; the page and the player's overlay hand it their
// own palette. Only the overlay sits on the dark art, so only it may carry the white text.
it.each([
["page", false],
["overlay", true],
] as const)("the %s variant's cast row takes its own palette", (variant, white) => {
wrap(<PlexInfo onPlayItem={noop} detail={full} variant={variant} />);
expect(screen.getByText("An Actor").className.includes("text-white/90")).toBe(white);
expect(screen.getByText("Hero").className.includes("text-muted")).toBe(!white);
});
});
// A collection sibling is a Plex card like any other — its "…" menu, its watched
// toggle and its playlist add, as on the related strip — where it used to be a Play and nothing else.
describe("a collection strip's card", () => {
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
const withColl: PlexItemDetail = {
...detail,
collections: [
{
id: "c1",
title: "The Collection",
source: "plex",
items: [
{
id: "m2",
type: "movie",
title: "Sequel",
thumb: "/api/plex/image/m2",
status: "in_progress",
position_seconds: 60,
duration_seconds: 6000,
},
],
},
],
};
it("carries the card's whole action set, the Restart in its menu too", async () => {
const onPlayItem = vi.fn();
wrap(<PlexInfo detail={withColl} variant="page" onPlayItem={onPlayItem} />);
const strip = screen.getByRole("heading", { name: "The Collection" }).parentElement!
.parentElement!;
const named = (n: string) =>
[...strip.querySelectorAll("button")].find((b) => b.getAttribute("aria-label") === n);
expect(named("plex.markWatched")).toBeDefined();
expect(named("plex.playlistAdd.manage")).toBeDefined();
const more = strip.querySelector<HTMLButtonElement>("button[aria-haspopup]");
expect(more).not.toBeNull();
fireEvent.click(more!);
const restart = await screen.findAllByRole("menuitem", { hidden: true });
fireEvent.click(restart.find((r) => r.textContent === "card.restartTitle")!);
await vi.waitFor(() => expect(onPlayItem).toHaveBeenCalledWith("m2", true));
});
});