fix(plex): address S4 review — cap filmography, dedupe toggle, scroll guard, tests

- Efficiency: cap each person section at _PERSON_SECTION_MAX (200) so a prolific person can't
  return an unbounded list the client mounts a card per row for.
- Reuse: extract the optimistic watch-toggle (flip → persist → resync) into a shared
  toggleWatchState helper; the grid and the person page each supply their own cache updater.
- Correctness (scroll): onOpenPerson now stashes infoScrollRef ONLY when leaving an info page
  (not from a show/grid context), and a jumped-to info page resets it — so a stale offset can't
  wrongly restore a fresh info page.
- Test-coverage: add PlexPersonView.dom.test.tsx (export CastStrip + PlexPersonView) covering
  the cast-row rewiring (open-person vs filter fallback) and the person page's sections + filter
  + empty state.

The name-as-identity finding is inherent to Plex cast carrying no per-person id (PlexPerson is
name-keyed too); documented in the endpoint docstring, not structurally fixable in S4.
This commit is contained in:
2026-08-16 21:55:34 +02:00
parent 87ef075ea7
commit 072d8fc88f
3 changed files with 174 additions and 40 deletions
@@ -0,0 +1,111 @@
// @vitest-environment jsdom
//
// E5 S4: 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 { describe, it, expect, afterEach, 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: () => ({ t: (key: string) => key }) };
});
const plexPerson = vi.fn();
const plexSetState = vi.fn(() => Promise.resolve());
vi.mock("../lib/api", () => ({
api: {
plexPerson: (...a: unknown[]) => plexPerson(...a),
plexSetState: (...a: unknown[]) => plexSetState(...a),
},
EMPTY_PLEX_FILTERS: {},
plexFilterCount: () => 0,
}));
import { CastStrip, PlexPersonView } from "./PlexBrowse";
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,
movies: [card("m1", "Movie One")],
shows: [card("s1", "Show One", "show")],
});
const onFilter = vi.fn();
wrap(
<PlexPersonView
name="Jane Doe"
onBack={() => {}}
onOpenCard={() => {}}
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();
fireEvent.click(screen.getByText("plex.person.filterBy"));
expect(onFilter).toHaveBeenCalledWith({ actors: ["Jane Doe"] });
expect(plexPerson).toHaveBeenCalledWith("Jane Doe");
});
it("shows the empty message when the person has no titles", async () => {
plexPerson.mockResolvedValue({ name: "Nobody", photo: null, movies: [], shows: [] });
wrap(
<PlexPersonView
name="Nobody"
onBack={() => {}}
onOpenCard={() => {}}
onInfoCard={() => {}}
onFilter={() => {}}
/>,
);
await screen.findByText("plex.person.empty");
});
});