Files
siftlode/frontend/src/components/PlexInfo.dom.test.tsx
T
peter 8776586ca3 fix(plex): a matched episode and a collection member offer Details in their menu
Their titles open the item's page, a pointer-only door; the card menu is the keyboard's.
2026-10-09 23:46:39 +02:00

429 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
//
// 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
// 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"] });
});
// 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();
// 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();
});
});
describe("an episode page links up to its show and season", () => {
const episode: PlexItemDetail = {
...detail,
id: "e1",
kind: "episode",
title: "Pilot",
show_title: "A Show",
show_id: "s1",
season_id: "se1",
season_number: 1,
episode_number: 25,
tmdb: null,
};
it("the show title opens the show, the episode number its season", () => {
const onOpenShow = vi.fn();
const onOpenSeason = vi.fn();
wrap(
<PlexInfo
onPlayItem={noop}
detail={episode}
variant="page"
onOpenShow={onOpenShow}
onOpenSeason={onOpenSeason}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "A Show" }));
expect(onOpenShow).toHaveBeenCalledWith("s1");
fireEvent.click(screen.getByRole("button", { name: "S1·E25" }));
expect(onOpenSeason).toHaveBeenCalledWith("s1", "se1");
expect(screen.getByText(/— Pilot/)).toBeTruthy();
});
it("over the running player the header stays plain text", () => {
wrap(
<PlexInfo
onPlayItem={noop}
detail={episode}
variant="overlay"
onOpenShow={noop}
onOpenSeason={noop}
/>,
);
expect(screen.queryByRole("button", { name: "A Show" })).toBeNull();
expect(screen.queryByRole("button", { name: "S1·E25" })).toBeNull();
expect(screen.getByText("S1·E25 — Pilot")).toBeTruthy();
});
});
// The owner's layout: 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.
// The owner's pick: 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);
});
// 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");
});
// 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();
});
// The cast row is the shared CastRow now; the page and the player's overlay hand it their
// own palette. Only the overlay sits on the dark art, so only it may carry the white text.
it.each([
["page", false],
["overlay", true],
] as const)("the %s variant's cast row takes its own palette", (variant, white) => {
wrap(<PlexInfo onPlayItem={noop} detail={full} variant={variant} />);
expect(screen.getByText("An Actor").className.includes("text-white/90")).toBe(white);
expect(screen.getByText("Hero").className.includes("text-muted")).toBe(!white);
});
});
// 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));
});
// Its title opens the member's page, a pointer's door: the menu offers the same to the keyboard.
it("opens the member's page from its menu, as its title does", async () => {
const onOpenInfo = vi.fn();
wrap(
<PlexInfo detail={withColl} variant="page" onPlayItem={vi.fn()} onOpenInfo={onOpenInfo} />,
);
const strip = screen.getByRole("heading", { name: "The Collection" }).parentElement!
.parentElement!;
fireEvent.click(strip.querySelector<HTMLButtonElement>("button[aria-haspopup]")!);
const rows = await screen.findAllByRole("menuitem", { hidden: true });
fireEvent.click(rows.find((r) => r.textContent === "plex.info.title")!);
await vi.waitFor(() => expect(onOpenInfo).toHaveBeenCalledWith("m2"));
});
});