Full-epic /deep-review (11 finders + 12 adversarial verifiers) surfaced no HIGH; every mechanism finding calibrated to LOW on verification. Fix all 13: Correctness / reliability: - Combobox: Enter now commits the VISUALLY highlighted row (highlightIndex ?? activeIndex), not the bare keyboard activeIndex — so arrow-then-hover-then-Enter no longer toggles a different option than the one painted, and hover-with-no-arrow is no longer a silent no-op. - parse_person: dedup the known_for pool by (id, media_type), not the bare id — TMDB movie and tv id spaces are independent, so a same-id movie+show pair is two distinct credits (matches the collision-safe key the rest of the module already uses). - person-enrich: a per-person in-flight + 60s cooldown guard (people._should_schedule_enrich) dedups concurrent opens and rate-limits re-triggering while TMDB is degraded (a transient TmdbError leaves the watermark NULL), so bursts can't pile 8s tasks onto the shared threadpool. - PlexInfo compact()/money(): format with i18n.language (keeping currency USD) instead of a hardcoded en/en-US, matching the app's Intl convention. - delete the dead `plex.filter.add` i18n key (both locales) — the +Add button that used it is gone. Reuse: - _continue_watching reuses rollup.agg_subquery(include_resumed=True) + query.apply_show_state_filter instead of a third hand-inlined copy of the per-show rollup + in_progress predicate, so the Discover rail can't drift from the grid's definition. Test coverage (the gaps a regression would ship through): - resolve_library_guids scope-exclusion (a credit in a non-scoped/disabled library is dropped) + the []-vs-None distinction; parse_person cross-media-id retention; enrich_person transient (NULL watermark) and 404 (stamp) branches; _enrich_person_bg body (fresh session close/rollback, None-guard) + the cooldown dedup; continue_watching fully-watched-show exclusion; Combobox Enter-commits-highlighted + async/controlled-query mode; PopoverItem aria-checked contract; RatingsRow missing-IMDb + imdb==audience branches. Backend lane 413→420, FE vitest 351→357; all gates green. No migration.
139 lines
5.5 KiB
TypeScript
139 lines
5.5 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, 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("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 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 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 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();
|
|
});
|
|
});
|