The biography sat at reading width in the left half of a full-width panel with nothing on the right. Now the person (headshot, facts, the bio clamped to eight lines with More / Less when it is cut) is on the left and "Known for" on the right; with fewer than four titles the rail takes only its posters' width. One column where the panel is narrow (200% zoom, the largest text size). While the server fetches a bio (`enriching`), the page shows a paragraph skeleton (a new "text" shape of LoadingState) and asks again every 1.5 s for up to 20 s. The view is keyed by the person's name, so another person starts fresh.
254 lines
8.7 KiB
TypeScript
254 lines
8.7 KiB
TypeScript
// @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 { setLanguage } from "../i18n";
|
|
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";
|
|
|
|
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();
|
|
|
|
// E5 S6 bio hero: department, biography, birth facts, aka.
|
|
// E30: 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();
|
|
// E30 S3: 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 (E30)", () => {
|
|
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");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("PlexPersonView — a bio on its way (E30 S6)", () => {
|
|
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("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.person.more"));
|
|
expect(screen.getByText("plex.person.less").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.person.more")).toBeNull(); // jsdom: nothing is cut
|
|
});
|
|
});
|