E5 S5: enriched Plex detail page (ratings + facts + keywords + crew)
Consumes the TMDB block the S2 engine already persists + serves (movie/show detail's `tmdb`), turning the movie info page into a Seerr-style enriched detail (page variant only — the player overlay stays lean): - Multi-source ratings row: IMDb (links out) · TMDb (with vote count) · Plex audience — whichever are present. Only the primary score (IMDb, else audience) carries the ratingMin filter, mirroring the backend rating column. - Facts panel: release · runtime · cert · budget · revenue · studios · countries. - Keyword chips → tap to filter the library. Backend: a new `keywords` filter on LibraryFilters + meta_filter_conds (`keywords @> [kw]`, both models), wired through PlexFilters (appendPlexFilters / plexFilterCount / active-chip / clear). - Full crew (TMDB key-crew) → tap a name to open their person page. Tests: test_plex_keyword_filter.py (3) + PlexInfo.dom.test.tsx (5, incl. the overlay-stays-lean guard). New i18n plex.info.facts.* / keywords / crew / audience (en+hu). Gate: vitest 337, backend lane 396.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
// @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, 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 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 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 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 detail={detail} variant="page" onFilter={onFilter} />);
|
||||
fireEvent.click(screen.getByText("heist"));
|
||||
expect(onFilter).toHaveBeenCalledWith({ keywords: ["heist"] });
|
||||
});
|
||||
|
||||
it("crew members open the person page on click", () => {
|
||||
const onOpenPerson = vi.fn();
|
||||
wrap(<PlexInfo 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("the overlay variant stays lean (no facts / keywords / crew panels)", () => {
|
||||
wrap(<PlexInfo 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user