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:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user