Files
siftlode/frontend/src/components/PlexInfo.dom.test.tsx
T
peter 70f98b352f fix(plex): a collection sibling is a full Plex card, and the player's info overlay plays it
- PlexInfo's collection strip gives each card the related strip's action set: the "…" menu (with
  Restart for a touch reader), the watched toggle and the playlist add.
- `onPlayItem` is required, so no variant draws a Play that does nothing; the player's info overlay
  plays the sibling in the running player (`go(id, _, fromZero)` for its Restart).
2026-09-27 14:44:38 +02:00

275 lines
11 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: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts?.name ? `${key}:${opts.name}` : key,
}),
};
});
import PlexInfo from "./PlexInfo";
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"],
production_countries: ["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
expect(screen.getByText("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();
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.
describe("PlexInfo layout B (page)", () => {
const TWO_COLUMNS = "lg:grid-cols-[minmax(0,1fr)_20rem]";
// 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 column = (el: HTMLElement) => el.closest("div.flex-col") as HTMLElement;
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("stacks the collection, the crew and the cast in the main column, the facts beside it", () => {
wrap(<PlexInfo onPlayItem={noop} detail={full} variant="page" />);
const coll = heading("The Collection");
const crew = heading("plex.info.crew");
const cast = heading("plex.info.cast");
const facts = heading("plex.info.facts.title");
const follows = (a: HTMLElement, b: HTMLElement) =>
!!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
expect(follows(coll, crew) && follows(crew, cast)).toBe(true);
const main = column(coll);
expect(main.contains(crew) && main.contains(cast)).toBe(true);
const side = column(facts);
expect(side).not.toBe(main);
expect(side).toBe(column(heading("plex.info.keywords")));
// the two columns are the two children of the one grid
expect(main.parentElement!.className).toContain(TWO_COLUMNS);
expect([...main.parentElement!.children]).toEqual([main, side]);
});
it("with nothing for the main column, the sidebar's sections take the full width", () => {
wrap(
<PlexInfo
onPlayItem={noop}
detail={{ ...detail, tmdb: { ...detail.tmdb!, crew: [] } }}
variant="page"
/>,
);
expect(screen.queryByRole("heading", { name: "plex.info.crew" })).toBeNull();
const side = column(heading("plex.info.facts.title"));
expect(side).toBe(column(heading("plex.info.keywords")));
expect(side.parentElement!.className).not.toContain(TWO_COLUMNS);
expect([...side.parentElement!.children]).toEqual([side]);
});
});
// 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));
});
});