The hero's grid row is as tall as the crew column beside the text, and the poster, a flex child, stretched to it: a 2:3 picture drawn ~175x590 and cropped. It now aligns to the top, and a wide hero gives the movie and show poster a bigger rung (w-56). The crew, the hero's tallest part, shows its first 6 names and a 'Show N more' toggle; a list of 8 or fewer shows whole.
355 lines
15 KiB
TypeScript
355 lines
15 KiB
TypeScript
// @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<typeof import("react-i18next")>();
|
||
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<string, unknown>) =>
|
||
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(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||
}
|
||
|
||
describe("PlexInfo enriched detail (page)", () => {
|
||
it("renders the multi-source ratings row (IMDb / TMDb / audience)", () => {
|
||
wrap(<PlexInfo onPlayItem={noop} detail={detail} variant="page" />);
|
||
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(<PlexInfo onPlayItem={noop} detail={detail} variant="page" />);
|
||
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(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onFilter={onFilter} />);
|
||
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(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onFilter={() => {}} />);
|
||
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(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onFilter={onFilter} />);
|
||
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(<PlexInfo onPlayItem={noop} detail={detail} variant="page" onOpenPerson={onOpenPerson} />);
|
||
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(<PlexInfo onPlayItem={noop} detail={noImdb} variant="page" onFilter={onFilter} />);
|
||
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(<PlexInfo onPlayItem={noop} detail={dupe} variant="page" />);
|
||
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(<PlexInfo onPlayItem={noop} detail={detail} variant="overlay" />);
|
||
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(<PlexInfo onPlayItem={noop} detail={full} variant="page" />);
|
||
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(<PlexInfo onPlayItem={noop} detail={bare} variant="page" />);
|
||
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(<PlexInfo onPlayItem={noop} detail={full} variant="page" />);
|
||
const poster = document.querySelector<HTMLImageElement>(`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(
|
||
<PlexInfo
|
||
onPlayItem={noop}
|
||
detail={{ ...full, tmdb: { ...full.tmdb!, crew } }}
|
||
variant="page"
|
||
/>,
|
||
);
|
||
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(
|
||
<PlexInfo
|
||
onPlayItem={noop}
|
||
detail={{ ...full, tmdb: { ...full.tmdb!, crew } }}
|
||
variant="page"
|
||
/>,
|
||
);
|
||
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(<PlexInfo detail={withColl} variant="page" onPlayItem={onPlayItem} />);
|
||
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<HTMLButtonElement>("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));
|
||
});
|
||
});
|