// @vitest-environment jsdom // // E5 S5: the enriched Plex detail page. Covers the TMDB-fed pieces added on the page variant — the // multi-source ratings row (IMDb / TMDb / audience), the facts panel (budget / revenue / studios / // countries), the keyword chips (→ onFilter), and the full crew (→ onOpenPerson). i18n is // key-passthrough; PlexInfo is prop-driven (no queries) so it just needs a QueryClient for its cache // reads. The overlay variant is asserted to stay lean (no facts/keywords/crew). 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"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, // A certificate code ("R") is no catalog word: it falls back to itself, as in the real catalog. t: (key: string, opts?: Record) => opts?.name ? `${key}:${opts.name}` : key.startsWith("vocab.certificate.") && typeof opts?.defaultValue === "string" ? opts.defaultValue : key, }), }; }); import PlexInfo from "./PlexInfo"; import { CREW_SHOWN } from "./plexDetailAside"; import type { PlexItemDetail } from "../lib/api"; afterEach(cleanup); const noop = () => {}; const detail: PlexItemDetail = { id: "m1", kind: "movie", title: "Test Movie", summary: "A summary.", year: 2010, duration_seconds: 7200, playable: "direct", thumb: "/api/plex/image/m1", art: "/api/plex/image/m1?variant=art", cast: [], imdb_rating: 7.8, imdb_id: "tt1", imdb_url: "https://www.imdb.com/title/tt1", rating: 8.4, // Plex audience content_rating: "R", genres: ["Crime"], directors: ["Plex Director"], studio: "Fallback Studio", tagline: "A tagline.", markers: [], audio_streams: [], subtitle_streams: [], status: "new", position_seconds: 0, tmdb: { tmdb_id: 42, rating: 7.2, vote_count: 1234, budget: 160000000, revenue: 830000000, keywords: ["heist", "bank robbery"], crew: [ { name: "Jane Director", job: "Director" }, { name: "Sam Writer", job: "Writer" }, ], studios: ["Studio A", "Studio B"], // One coded (named in the reader's language), one from a row enriched before codes were stored. production_countries: [ { code: "HU", name: "Hungary" }, { code: null, name: "United States" }, ], recommendations: [], backdrops: [], }, }; function wrap(ui: React.ReactElement) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render({ui}); } describe("PlexInfo enriched detail (page)", () => { it("renders the multi-source ratings row (IMDb / TMDb / audience)", () => { wrap(); expect(screen.getByText("IMDb")).toBeTruthy(); expect(screen.getByText("7.8")).toBeTruthy(); expect(screen.getByText(/TMDb/)).toBeTruthy(); // "TMDb · 1.2K" (vote count compacted) expect(screen.getByText("7.2")).toBeTruthy(); expect(screen.getByText("plex.info.audience")).toBeTruthy(); expect(screen.getByText("8.4")).toBeTruthy(); }); it("renders the facts panel with budget / revenue / studios / countries", () => { wrap(); expect(screen.getByText("plex.info.facts.title")).toBeTruthy(); expect(screen.getByText("plex.info.facts.budget")).toBeTruthy(); expect(screen.getByText("$160M")).toBeTruthy(); expect(screen.getByText("$830M")).toBeTruthy(); expect(screen.getByText("Studio A")).toBeTruthy(); // tmdb.studios preferred over the fallback // E30: a coded country is named through the vocab (key-echo i18n here), an uncoded one as stored. expect(screen.getByText("vocab.region.HU, United States")).toBeTruthy(); }); it("keyword chips filter the library on click", () => { const onFilter = vi.fn(); wrap(); fireEvent.click(screen.getByText("heist")); expect(onFilter).toHaveBeenCalledWith({ keywords: ["heist"] }); }); // E28: every value that filters says so on hover — the genre, certificate and studio had no hint // while the keyword and the year did — and a value filters in the facts panel as in the header. it("every filtering value carries a filter hint", () => { wrap( {}} />); for (const v of ["heist", "Crime", "R", "Studio A"]) for (const el of screen.getAllByText(v)) expect(el.closest("[title]")?.getAttribute("title")).toBe(`plex.info.filterValue:${v}`); }); it("the facts panel's year and certificate filter like the header's", () => { const onFilter = vi.fn(); wrap(); const [, factsCert] = screen.getAllByText("R"); fireEvent.click(factsCert!); expect(onFilter).toHaveBeenLastCalledWith({ contentRatings: ["R"] }); const [, factsYear] = screen.getAllByText("2010"); fireEvent.click(factsYear!); expect(onFilter).toHaveBeenLastCalledWith({ yearMin: 2010, yearMax: 2010 }); }); it("crew members open the person page on click", () => { const onOpenPerson = vi.fn(); wrap(); expect(screen.getByText("plex.info.crew")).toBeTruthy(); // E30: the job is TMDB's English word, named through the vocab (key-echo i18n here). expect(screen.getByText("vocab.crewJob.Director")).toBeTruthy(); fireEvent.click(screen.getByText("Jane Director")); expect(onOpenPerson).toHaveBeenCalledWith("Jane Director"); }); it("RatingsRow: with no IMDb score, the audience score becomes the filterable one", () => { const onFilter = vi.fn(); const noImdb = { ...detail, imdb_rating: null, imdb_id: null, imdb_url: null }; wrap(); expect(screen.queryByText("IMDb")).toBeNull(); // audience (8.4) is now the primary/filterable score → clicking it filters ratingMin. fireEvent.click(screen.getByText("8.4")); expect(onFilter).toHaveBeenCalledWith({ ratingMin: 8 }); }); it("RatingsRow: suppresses the duplicate audience chip when it equals the IMDb score", () => { const dupe = { ...detail, rating: 7.8 }; // audience == imdb_rating (7.8) wrap(); expect(screen.getByText("IMDb")).toBeTruthy(); expect(screen.queryByText("plex.info.audience")).toBeNull(); // duplicate audience chip suppressed expect(screen.getAllByText("7.8")).toHaveLength(1); // the score shows once (IMDb only) }); it("the overlay variant stays lean (no facts / keywords / crew panels)", () => { wrap(); expect(screen.queryByText("plex.info.facts.title")).toBeNull(); expect(screen.queryByText("plex.info.keywords")).toBeNull(); expect(screen.queryByText("plex.info.crew")).toBeNull(); // but the ratings row still renders (it's compact enough for the overlay) expect(screen.getByText("IMDb")).toBeTruthy(); }); }); // E28 S3h, the owner's layout B: a main column (collection strips → crew → cast) beside a sidebar of // facts (details, keywords); a column with nothing in it is not rendered and the other takes the width. // E31 (the owner's pick "A"): the hero's text stopped at ~55% of the panel. The facts and the key crew are // its second column now (a grid that splits in a wide hero), the keywords sit under the text, and under // the hero only the collection strips and the cast remain, stacked. describe("PlexInfo — the hero's second column (page)", () => { const TWO_COLUMNS = "@5xl:grid-cols-[minmax(0,1fr)_minmax(0,24rem)]"; // The strips are Carousels, which watch their size. beforeAll(() => { class Stub { observe() {} unobserve() {} disconnect() {} } globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; }); const heading = (name: string) => screen.getByRole("heading", { name }); const follows = (a: HTMLElement, b: HTMLElement) => !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); const full: PlexItemDetail = { ...detail, cast: [{ name: "An Actor", role: "Hero" }], collections: [ { id: "c1", title: "The Collection", source: "plex", items: [{ id: "m2", type: "movie", title: "Sequel", thumb: "/api/plex/image/m2" }], }, ], }; it("puts the facts over the crew beside the text, the keywords under it, the rest below the hero", () => { wrap(); const aside = screen.getByTestId("detail-aside"); expect(aside.contains(heading("plex.info.facts.title"))).toBe(true); expect(aside.contains(heading("plex.info.crew"))).toBe(true); expect(follows(heading("plex.info.facts.title"), heading("plex.info.crew"))).toBe(true); // The grid's two children: the poster + text, and the column. const grid = aside.parentElement!; expect(grid.className).toContain(TWO_COLUMNS); const main = grid.children[0]!; const side = grid.children[1]; expect(side).toBe(aside); expect(main.contains(screen.getByText("A summary."))).toBe(true); expect(main.contains(screen.getByTestId("detail-keywords"))).toBe(true); // Below the hero (the panel under the title): the collection, then the cast — and neither in it. const hero = grid.parentElement!; expect(hero.contains(heading("Test Movie"))).toBe(true); const coll = heading("The Collection"); const cast = heading("plex.info.cast"); expect(hero.contains(coll) || hero.contains(cast)).toBe(false); expect(follows(hero, coll) && follows(coll, cast)).toBe(true); }); it("without facts or crew there is no second column; the keywords still sit under the text", () => { const bare: PlexItemDetail = { ...detail, year: null, duration_seconds: null, content_rating: null, studio: null, tmdb: { ...detail.tmdb!, budget: null, revenue: null, studios: [], production_countries: [], crew: [], }, }; wrap(); expect(screen.queryByTestId("detail-aside")).toBeNull(); expect(screen.queryByRole("heading", { name: "plex.info.facts.title" })).toBeNull(); const kw = screen.getByTestId("detail-keywords"); expect(document.querySelector(`[class*="${TWO_COLUMNS}"]`)).toBeNull(); expect(follows(heading("Test Movie"), kw)).toBe(true); }); // E31: the hero's grid row is as tall as its taller column — the crew, beside the text — and the poster, // a flex child, stretched to it: a 2:3 picture drawn ~175×590 and cropped. It keeps its own ratio. it("keeps the poster at its own height, not stretched to the row", () => { wrap(); const poster = document.querySelector(`img[src="${detail.thumb}"]`)!; expect(poster.className).toContain("aspect-2/3"); expect(poster.className.split(/\s+/)).toContain("self-start"); }); // E31 (the owner's call): twelve names ran the column ~170px past the text. The first CREW_SHOWN show, // the rest behind "Show N more"; a list barely longer shows whole. it("shows the first crew names and opens the rest on demand", () => { const crew = Array.from({ length: 12 }, (_, i) => ({ name: `Person ${i}`, job: "Producer" })); wrap( , ); const aside = screen.getByTestId("detail-aside"); expect(aside.textContent).toContain(`Person ${CREW_SHOWN - 1}`); expect(aside.textContent).not.toContain(`Person ${CREW_SHOWN}`); const more = screen.getByRole("button", { name: "plex.info.crewMore" }); expect(more.getAttribute("aria-expanded")).toBe("false"); fireEvent.click(more); expect(aside.textContent).toContain("Person 11"); const less = screen.getByRole("button", { name: "plex.info.crewLess" }); expect(less.getAttribute("aria-expanded")).toBe("true"); fireEvent.click(less); expect(aside.textContent).not.toContain(`Person ${CREW_SHOWN}`); }); it("shows a crew only a little longer than that whole, with no toggle", () => { const crew = Array.from({ length: CREW_SHOWN + 2 }, (_, i) => ({ name: `Person ${i}`, job: "Producer", })); wrap( , ); expect(screen.getByTestId("detail-aside").textContent).toContain(`Person ${CREW_SHOWN + 1}`); expect(screen.queryByRole("button", { name: "plex.info.crewMore" })).toBeNull(); }); }); // E28 review D1 + D2: a collection sibling is a Plex card like any other — its "…" menu, its watched // toggle and its playlist add, as on the related strip — where it used to be a Play and nothing else. describe("a collection strip's card", () => { beforeAll(() => { class Stub { observe() {} unobserve() {} disconnect() {} } globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; }); const withColl: PlexItemDetail = { ...detail, collections: [ { id: "c1", title: "The Collection", source: "plex", items: [ { id: "m2", type: "movie", title: "Sequel", thumb: "/api/plex/image/m2", status: "in_progress", position_seconds: 60, duration_seconds: 6000, }, ], }, ], }; it("carries the card's whole action set, the Restart in its menu too", async () => { const onPlayItem = vi.fn(); wrap(); const strip = screen.getByRole("heading", { name: "The Collection" }).parentElement! .parentElement!; const named = (n: string) => [...strip.querySelectorAll("button")].find((b) => b.getAttribute("aria-label") === n); expect(named("plex.markWatched")).toBeDefined(); expect(named("plex.playlistAdd.manage")).toBeDefined(); const more = strip.querySelector("button[aria-haspopup]"); expect(more).not.toBeNull(); fireEvent.click(more!); const restart = await screen.findAllByRole("menuitem", { hidden: true }); fireEvent.click(restart.find((r) => r.textContent === "card.restartTitle")!); await vi.waitFor(() => expect(onPlayItem).toHaveBeenCalledWith("m2", true)); }); });