// @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(); // 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({ui}); } 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( , ); 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(); 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( {}} 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( {}} 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( {}} 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 = ( {}} 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 }); });