fix(e5): deep-review round — combobox Enter, parse_person collision, enrich hardening + coverage
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.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Gates the shared PopoverItem ARIA contract: aria-checked for menuitemradio/menuitemcheckbox and
|
||||
// aria-selected for option are rendered as explicit attributes AFTER the floating-ui item props (so they
|
||||
// win over getItemProps' single-select guess). Six live menus (LanguageSwitcher, ViewSwitcher,
|
||||
// AddToPlaylist, ChannelTags, tableHeader, plexDetailUi) depend on this; without a test, a regression in
|
||||
// the shared path would silently drop the checked indicator for screen-reader users. Floating-ui recipe:
|
||||
// stub the two observers autoUpdate wires, wrap in a FloatingTree, query the hidden surface.
|
||||
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function Menu() {
|
||||
const [open, setOpen] = useState(true);
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, mode: "menu" });
|
||||
return (
|
||||
<PopoverSurface popover={pop} ariaLabel="Choices">
|
||||
<PopoverItem role="menuitemradio" checked onSelect={() => {}}>
|
||||
Newest
|
||||
</PopoverItem>
|
||||
<PopoverItem role="menuitemradio" checked={false} onSelect={() => {}}>
|
||||
Oldest
|
||||
</PopoverItem>
|
||||
<PopoverItem role="menuitemcheckbox" checked onSelect={() => {}}>
|
||||
Show shorts
|
||||
</PopoverItem>
|
||||
</PopoverSurface>
|
||||
);
|
||||
}
|
||||
|
||||
describe("PopoverItem ARIA contract", () => {
|
||||
it("exposes aria-checked on menuitemradio and menuitemcheckbox items", () => {
|
||||
render(
|
||||
<FloatingTree>
|
||||
<Menu />
|
||||
</FloatingTree>,
|
||||
);
|
||||
const radios = screen.getAllByRole("menuitemradio", { hidden: true });
|
||||
expect(radios.map((r) => r.getAttribute("aria-checked"))).toEqual(["true", "false"]);
|
||||
const checkbox = screen.getByRole("menuitemcheckbox", { hidden: true });
|
||||
expect(checkbox.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -127,6 +127,71 @@ describe("Combobox", () => {
|
||||
expect(screen.getByText("plex.filter.moreRefine")).toBeTruthy(); // 10 hidden behind search
|
||||
});
|
||||
|
||||
it("Enter commits the pointer-highlighted option, not the arrow-active one", () => {
|
||||
// The visible highlight follows the pointer (hoverIndex); Enter must commit THAT row, not the bare
|
||||
// keyboard activeIndex — otherwise arrow-then-hover-then-Enter toggles a different option than shown.
|
||||
const onChange = vi.fn();
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={onChange} />);
|
||||
const input = screen.getByRole("combobox");
|
||||
input.focus();
|
||||
fireEvent.focus(input);
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" }); // activeIndex → 0 (Drama)
|
||||
const action = screen
|
||||
.getAllByRole("option", { hidden: true })
|
||||
.find((el) => el.textContent?.includes("Action"))!;
|
||||
fireEvent.mouseMove(action); // hoverIndex → Action (the visibly highlighted row)
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).toHaveBeenCalledWith(["Action"]); // the highlighted (hovered) row, not Drama
|
||||
});
|
||||
|
||||
it("Enter commits a hover-highlighted option even with no prior arrow key", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={onChange} />);
|
||||
const input = screen.getByRole("combobox");
|
||||
input.focus();
|
||||
fireEvent.focus(input);
|
||||
const comedy = screen
|
||||
.getAllByRole("option", { hidden: true })
|
||||
.find((el) => el.textContent?.includes("Comedy"))!;
|
||||
fireEvent.mouseMove(comedy); // highlight via hover, activeIndex still null
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).toHaveBeenCalledWith(["Comedy"]); // no longer a silent no-op
|
||||
});
|
||||
|
||||
it("async mode: controlled query + onQueryChange/onOpenChange, no client filter, selectedLabels chip", () => {
|
||||
const onQueryChange = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
const serverOpts = [{ value: "id1", label: "Harry Potter", count: 8 }];
|
||||
wrap(
|
||||
<Combobox
|
||||
label="Collection"
|
||||
multiple={false}
|
||||
clientFilter={false}
|
||||
options={serverOpts}
|
||||
selected={["stored-id"]}
|
||||
selectedLabels={{ "stored-id": "Stored Collection" }}
|
||||
query="har"
|
||||
onQueryChange={onQueryChange}
|
||||
onOpenChange={onOpenChange}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
// The selected value's option isn't in the (server-narrowed) list, so its chip uses selectedLabels.
|
||||
expect(screen.getByText("Stored Collection")).toBeTruthy();
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.focus(input);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true);
|
||||
fireEvent.change(input, { target: { value: "harr" } });
|
||||
expect(onQueryChange).toHaveBeenCalledWith("harr"); // controlled query propagates out
|
||||
// clientFilter=false: the single server option renders verbatim (not re-filtered against "har").
|
||||
const opts = screen.getAllByRole("option", { hidden: true });
|
||||
expect(opts.length).toBe(1);
|
||||
expect(opts[0]?.textContent).toContain("Harry Potter");
|
||||
fireEvent.click(screen.getByText("Harry Potter"));
|
||||
expect(onChange).toHaveBeenCalledWith(["id1"]); // single-select replaces
|
||||
});
|
||||
|
||||
it("single-select: picking replaces the value and closes the list", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(
|
||||
|
||||
@@ -100,7 +100,11 @@ export default function Combobox({
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: setOpenAnd, mode: "combobox" });
|
||||
const { activeIndex } = pop;
|
||||
// `highlightIndex` = the row the user actually SEES highlighted (pointer hover, else keyboard); Enter
|
||||
// commits THAT, not the bare keyboard `activeIndex` — otherwise arrow-then-hover-then-Enter would toggle
|
||||
// a different option than the highlighted one, and a hover with no prior arrow (activeIndex still null)
|
||||
// would make Enter a no-op over a highlighted row.
|
||||
const { activeIndex, highlightIndex } = pop;
|
||||
|
||||
// Clear the typed query when the list closes.
|
||||
useEffect(() => {
|
||||
@@ -176,9 +180,10 @@ export default function Combobox({
|
||||
},
|
||||
onFocus: () => setOpenAnd(true),
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === "Enter" && open && activeIndex != null && visible[activeIndex]) {
|
||||
const idx = highlightIndex ?? activeIndex;
|
||||
if (e.key === "Enter" && open && idx != null && visible[idx]) {
|
||||
e.preventDefault(); // select the highlighted option, don't submit a surrounding form
|
||||
if (!visible[activeIndex].disabled) commit(visible[activeIndex].value);
|
||||
if (!visible[idx].disabled) commit(visible[idx].value);
|
||||
} else if (e.key === "Backspace" && query === "" && selected.length > 0) {
|
||||
// Backspace on an empty field removes the last chip (react-select behaviour).
|
||||
onChange(selected.slice(0, -1));
|
||||
|
||||
@@ -109,6 +109,24 @@ describe("PlexInfo enriched detail (page)", () => {
|
||||
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();
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useDetailPrefs,
|
||||
} from "../lib/plexDetailUi";
|
||||
import { usePref } from "../lib/usePref";
|
||||
import i18n from "../i18n";
|
||||
import { Carousel } from "./Carousel";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||
@@ -27,9 +28,13 @@ import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||
// The TMDB-enriched pieces (multi-source ratings row, facts, keyword chips, full crew — E5 S5) render
|
||||
// on the page variant only; the overlay stays lean.
|
||||
|
||||
const compact = (n: number) => new Intl.NumberFormat("en", { notation: "compact" }).format(n);
|
||||
// Localize the grouping/compact notation to the active language (the app convention, lib/api/core.ts);
|
||||
// `i18n.language` is read at call time, and the component re-renders on a language switch. Currency stays
|
||||
// USD — TMDB budget/revenue figures are USD regardless of the viewer's language.
|
||||
const compact = (n: number) =>
|
||||
new Intl.NumberFormat(i18n.language, { notation: "compact" }).format(n);
|
||||
const money = (n: number) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
new Intl.NumberFormat(i18n.language, {
|
||||
notation: "compact",
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
|
||||
Reference in New Issue
Block a user