Files
siftlode/frontend/src/components/PlexPersonView.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

359 lines
13 KiB
TypeScript

// @vitest-environment jsdom
//
// The actor detail page. Covers the two pieces the /code-review flagged as untested — the
// cast-row rewiring (a cast click now OPENS the person page via onOpenPerson, falling back to the old
// filter gesture only when no onOpenPerson is wired) and PlexPersonView's render (headshot/name hero,
// the Movies/Shows sections, and the "filter library by this person" action). i18n is key-passthrough
// and the api is mocked so the query resolves synchronously in the test.
import { setLanguage } from "../i18n";
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { act, render, screen, fireEvent, cleanup, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
beforeAll(() => {
// CastStrip now scrolls inside a Carousel, whose useHScroll wires a ResizeObserver; jsdom ships none.
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
// the real instance: these tests switch the language with setLanguage
const real = (await import("i18next")).default;
return {
...actual,
useTranslation: () => ({ i18n: real, t: (key: string) => key }),
};
});
const plexPerson = vi.fn();
const plexSetState = vi.fn((..._a: unknown[]) => Promise.resolve());
vi.mock("../lib/api", () => ({
api: {
plexPerson: (...a: unknown[]) => plexPerson(...a),
plexSetState: (...a: unknown[]) => plexSetState(...a),
},
EMPTY_PLEX_FILTERS: {},
plexFilterCount: () => 0,
}));
import { CastStrip } from "./plexCards";
import { PlexPersonView } from "./plexViews";
afterEach(cleanup);
const card = (id: string, title: string, type: "movie" | "show" = "movie") => ({
id,
type,
title,
year: 2000,
duration_seconds: 3600,
thumb: `/api/plex/image/${id}`,
playable: "direct",
status: "new",
position_seconds: 0,
});
function wrap(ui: React.ReactElement) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
describe("CastStrip cast click", () => {
it("opens the person page (onOpenPerson) rather than filtering, when both are wired", () => {
const onOpenPerson = vi.fn();
const onFilter = vi.fn();
render(
<CastStrip cast={[{ name: "Jane Doe" }]} onOpenPerson={onOpenPerson} onFilter={onFilter} />,
);
fireEvent.click(screen.getByTitle("plex.info.viewPerson"));
expect(onOpenPerson).toHaveBeenCalledWith("Jane Doe");
expect(onFilter).not.toHaveBeenCalled();
});
it("falls back to the filter gesture when only onFilter is wired (e.g. the overlay)", () => {
const onFilter = vi.fn();
render(<CastStrip cast={[{ name: "Jane Doe" }]} onFilter={onFilter} />);
fireEvent.click(screen.getByTitle("plex.info.filterActor"));
expect(onFilter).toHaveBeenCalledWith({ actors: ["Jane Doe"] });
});
});
describe("PlexPersonView", () => {
it("renders the Movies + Shows sections and the filter-by-person action", async () => {
plexPerson.mockResolvedValue({
name: "Jane Doe",
photo: null,
biography: "A celebrated actor.",
birthday: "1970-01-01",
place_of_birth: "Springfield",
known_for_department: "Acting",
also_known_as: ["Janie D"],
known_for: [card("kf1", "Famous Film")],
movies: [card("m1", "Movie One")],
shows: [card("s1", "Show One", "show")],
});
const onFilter = vi.fn();
wrap(
<PlexPersonView
name="Jane Doe"
onBack={() => {}}
onOpenCard={() => {}}
onRestartCard={() => {}}
onInfoCard={() => {}}
onFilter={onFilter}
/>,
);
// Both section grids render their cards once the mocked query resolves.
await screen.findByText("Movie One");
expect(screen.getByText("Show One")).toBeTruthy();
expect(screen.getByText("plex.person.movies")).toBeTruthy(); // section headings (key passthrough)
expect(screen.getByText("plex.person.shows")).toBeTruthy();
// The bio hero: department, biography, birth facts, aka.
// TMDB's English department, named through the vocab (key-echo i18n here).
expect(screen.getByText("vocab.department.Acting")).toBeTruthy();
expect(screen.getByText("A celebrated actor.")).toBeTruthy();
// The birth date as a date in the UI language, not the raw ISO string
expect(screen.getByText("Jan 1, 1970 · Springfield")).toBeTruthy();
expect(screen.getByText(/plex\.person\.aka/)).toBeTruthy();
expect(screen.getByText(/Janie D/)).toBeTruthy();
// "Known for" rail renders its card.
expect(screen.getByText("plex.person.knownFor")).toBeTruthy();
expect(screen.getAllByText("Famous Film").length).toBeGreaterThan(0);
fireEvent.click(screen.getByText("plex.person.filterBy"));
expect(onFilter).toHaveBeenCalledWith({ actors: ["Jane Doe"] });
expect(plexPerson).toHaveBeenCalledWith("Jane Doe", "en");
});
it("shows the empty message when the person has no titles", async () => {
plexPerson.mockResolvedValue({
name: "Nobody",
photo: null,
also_known_as: [],
known_for: [],
movies: [],
shows: [],
});
wrap(
<PlexPersonView
name="Nobody"
onBack={() => {}}
onOpenCard={() => {}}
onRestartCard={() => {}}
onInfoCard={() => {}}
onFilter={() => {}}
/>,
);
await screen.findByText("plex.person.empty");
});
});
describe("PlexPersonView — the reader's language", () => {
it("asks for the filmography in Hungarian", async () => {
setLanguage("hu");
try {
plexPerson.mockResolvedValue({
name: "Jane Doe",
photo: null,
also_known_as: [],
known_for: [],
movies: [card("m1", "Egy film")],
shows: [],
});
wrap(
<PlexPersonView
name="Jane Doe"
onBack={() => {}}
onOpenCard={() => {}}
onRestartCard={() => {}}
onInfoCard={() => {}}
onFilter={() => {}}
/>,
);
await screen.findByText("Egy film");
expect(plexPerson).toHaveBeenCalledWith("Jane Doe", "hu");
} finally {
setLanguage("en");
}
});
});
const BIO_POLL = 1600; // just past plexViews' BIO_POLL_MS
describe("PlexPersonView — a bio on its way", () => {
const view = (
<PlexPersonView
name="Jane Doe"
onBack={() => {}}
onOpenCard={() => {}}
onRestartCard={() => {}}
onInfoCard={() => {}}
onFilter={() => {}}
/>
);
const person = (over: object) => ({
name: "Jane Doe",
photo: null,
also_known_as: [],
known_for: [],
movies: [card("m1", "Movie One")],
shows: [],
...over,
});
it("holds the bio's place and asks again until it lands", async () => {
plexPerson.mockReset();
plexPerson
.mockResolvedValueOnce(person({ biography: null, enriching: true }))
.mockResolvedValue(person({ biography: "Landed bio." }));
wrap(view);
await screen.findByText("Movie One");
expect(screen.getByRole("status").textContent).toContain("plex.person.bioLoading");
await screen.findByText("Landed bio.", undefined, { timeout: 3000 });
expect(screen.queryByRole("status")).toBeNull();
expect(plexPerson).toHaveBeenCalledTimes(2);
});
it("does not ask again for a person whose bio is not coming", async () => {
plexPerson.mockReset();
plexPerson.mockResolvedValue(person({ biography: null, enriching: false }));
wrap(view);
await screen.findByText("Movie One");
await new Promise((r) => setTimeout(r, 1800));
expect(plexPerson).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("status")).toBeNull();
});
it("stops asking once the wait is over, and takes the placeholder away", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
plexPerson.mockReset();
plexPerson.mockResolvedValue(person({ biography: null, enriching: true })); // TMDB never answers
wrap(view);
await screen.findByText("Movie One");
await act(() => vi.advanceTimersByTimeAsync(21_000));
const asked = plexPerson.mock.calls.length;
expect(asked).toBeGreaterThan(2);
await act(() => vi.advanceTimersByTimeAsync(10_000));
expect(plexPerson).toHaveBeenCalledTimes(asked);
expect(screen.queryByRole("status")).toBeNull();
} finally {
vi.useRealTimers();
}
});
it("stops asking when the page's own request fails, and says so with a retry", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
plexPerson.mockReset();
plexPerson
.mockResolvedValueOnce(person({ biography: null, enriching: true }))
.mockRejectedValue(new Error("server down"));
wrap(view);
await screen.findByText("Movie One");
await act(() => vi.advanceTimersByTimeAsync(BIO_POLL));
expect(plexPerson).toHaveBeenCalledTimes(2);
await act(() => vi.advanceTimersByTimeAsync(10_000));
expect(plexPerson).toHaveBeenCalledTimes(2); // not every 1.5 s for as long as it fails
expect(screen.queryByRole("status")).toBeNull();
// Not a blank that reads as "no biography": the failure is said, and a retry asks again.
const alert = screen.getByRole("alert");
expect(alert.textContent).toContain("plex.person.bioFailed");
plexPerson.mockResolvedValue(person({ biography: "Landed bio." }));
fireEvent.click(within(alert).getByRole("button"));
await screen.findByText("Landed bio.");
} finally {
vi.useRealTimers();
}
});
it("a retry after the wait is over waits again: a bio still on its way still lands", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
plexPerson.mockReset();
plexPerson
.mockResolvedValueOnce(person({ biography: null, enriching: true }))
.mockRejectedValueOnce(new Error("server restarting"))
.mockResolvedValueOnce(person({ biography: null, enriching: true })) // the retry
.mockResolvedValue(person({ biography: "Late bio." }));
wrap(view);
await screen.findByText("Movie One");
await act(() => vi.advanceTimersByTimeAsync(BIO_POLL));
await act(() => vi.advanceTimersByTimeAsync(25_000)); // the first wait runs out
fireEvent.click(within(screen.getByRole("alert")).getByRole("button"));
await act(() => vi.advanceTimersByTimeAsync(10));
expect(screen.getByRole("status")).toBeTruthy(); // on its way again, not a blank
await act(() => vi.advanceTimersByTimeAsync(BIO_POLL));
await screen.findByText("Late bio.");
} finally {
vi.useRealTimers();
}
});
it("offers More only when the bio is cut, and Less once open", async () => {
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(160);
try {
plexPerson.mockReset();
plexPerson.mockResolvedValue(person({ biography: "A long life.\n\nMany films." }));
wrap(view);
fireEvent.click(await screen.findByText("plex.textMore"));
expect(screen.getByText("plex.textLess").getAttribute("aria-expanded")).toBe("true");
} finally {
tall.mockRestore();
box.mockRestore();
}
cleanup();
plexPerson.mockResolvedValue(person({ biography: "Short." }));
wrap(view);
await screen.findByText("Short.");
expect(screen.queryByText("plex.textMore")).toBeNull(); // jsdom: nothing is cut
});
});
describe("PlexPersonView — the hero's columns", () => {
const heroGrid = async (knownFor: number) => {
plexPerson.mockReset();
plexPerson.mockResolvedValue({
name: "Jane Doe",
photo: null,
biography: "Bio.",
also_known_as: [],
known_for: Array.from({ length: knownFor }, (_, i) => card(`k${i}`, `Known ${i}`)),
movies: [card("m1", "Movie One")],
shows: [],
});
const { container, unmount } = wrap(
<PlexPersonView
name="Jane Doe"
onBack={() => {}}
onOpenCard={() => {}}
onRestartCard={() => {}}
onInfoCard={() => {}}
onFilter={() => {}}
/>,
);
await screen.findByText("Movie One");
const cls = container.querySelector('section[class~="@container"] > div')!.className;
unmount();
return cls;
};
it("gives a full rail half the hero and puts a short one right beside the text", async () => {
expect(await heroGrid(4)).toContain("@3xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]");
const short = await heroGrid(1);
expect(short).toContain("@3xl:grid-cols-[auto_auto]");
expect(short).toContain("@3xl:justify-start");
expect(await heroGrid(0)).not.toContain("grid");
});
});