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:
2026-08-21 08:11:18 +02:00
parent 8f6714e678
commit bad2aae753
14 changed files with 407 additions and 63 deletions
+23 -18
View File
@@ -42,27 +42,32 @@ def rollup(cards: list[dict]) -> dict:
}
def agg_subquery(db: Session, user_id: int):
def agg_subquery(db: Session, user_id: int, include_resumed: bool = False):
"""Per-show episode counts for a user: total, watched, in-progress. Reused to (a) filter the show
grid by aggregate watch-state and (b) compute each card's badge."""
grid by aggregate watch-state, (b) compute each card's badge, and (c) drive the Discover
continue-watching rail. `include_resumed` adds `resumed_at` = max(progress_updated_at) across the
show's episodes, so the rail can order shows by how recently they were last progressed."""
ps = aliased(PlexState)
return (
select(
PlexItem.show_id.label("show_id"),
func.count(PlexItem.id).label("total"),
func.count(case((ps.status == "watched", 1))).label("watched"),
func.count(
case(
(
and_(
ps.position_seconds > 0,
or_(ps.status.is_(None), ps.status != "watched"),
),
1,
)
cols = [
PlexItem.show_id.label("show_id"),
func.count(PlexItem.id).label("total"),
func.count(case((ps.status == "watched", 1))).label("watched"),
func.count(
case(
(
and_(
ps.position_seconds > 0,
or_(ps.status.is_(None), ps.status != "watched"),
),
1,
)
).label("inprog"),
)
)
).label("inprog"),
]
if include_resumed:
cols.append(func.max(ps.progress_updated_at).label("resumed_at"))
return (
select(*cols)
.outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id))
.where(PlexItem.kind == "episode", PlexItem.show_id.isnot(None))
.group_by(PlexItem.show_id)
+6 -3
View File
@@ -434,13 +434,16 @@ def parse_person(payload: dict) -> dict:
"""TMDB /person/{id} (+combined_credits) → the persisted bio fields. `known_for` = the person's
most-popular movie/tv credits (deduped by id), top `_KNOWN_FOR_MAX`, resolved to library later."""
credits = (payload.get("combined_credits") or {}).get("cast") or []
seen: set[int] = set()
# Dedup on (id, media_type), not the bare id: TMDB movie ids and tv ids are independent sequences
# (id 603 can be both a movie and a show — see resolve_library_guids), so a same-id movie+show pair
# is two distinct credits and both must survive.
seen: set[tuple[int, str]] = set()
known: list[dict] = []
for c in sorted(credits, key=lambda c: c.get("popularity") or 0, reverse=True):
tid, mt = c.get("id"), c.get("media_type")
if not tid or mt not in ("movie", "tv") or tid in seen:
if not tid or mt not in ("movie", "tv") or (tid, mt) in seen:
continue
seen.add(tid)
seen.add((tid, mt))
known.append({"tmdb_id": tid, "media_type": mt, "title": c.get("title") or c.get("name")})
if len(known) >= _KNOWN_FOR_MAX:
break
+14 -34
View File
@@ -10,15 +10,15 @@ card paths); the HU overlay coalesces only in the main grid.
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import and_, case, func, or_, select
from sqlalchemy.orm import Session, aliased
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
from app.auth import current_user
from app.db import get_db
from app.models import PlexItem, PlexShow, PlexState, User
from app.plex import rollup
from app.plex.cards import movie_card, show_card
from app.plex.query import genres_col
from app.plex.query import apply_show_state_filter, genres_col
from app.plex.tmdb import resolve_recs
from app.routes.plex._shared import _content_libs, _norm_lang
@@ -123,39 +123,19 @@ def _continue_watching(
rows.append((st.progress_updated_at, movie_card(it, st)))
if show_ids:
ps = aliased(PlexState)
prog = (
select(
PlexItem.show_id.label("show_id"),
func.max(ps.progress_updated_at).label("resumed_at"),
func.count(PlexItem.id).label("total"),
func.count(case((ps.status == "watched", 1))).label("watched"),
func.count(
case(
(
and_(
ps.position_seconds > 0,
or_(ps.status.is_(None), ps.status != "watched"),
),
1,
)
)
).label("inprog"),
)
.outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == uid))
.where(PlexItem.kind == "episode", PlexItem.show_id.isnot(None))
.group_by(PlexItem.show_id)
.subquery()
)
# in-progress == started but not fully watched (mirrors apply_show_state_filter's `in_progress`).
# Reuse the shared per-show episode rollup (total/watched/inprog) rather than re-inlining it, plus
# its `resumed_at` = max(progress_updated_at); the in-progress predicate comes from the same shared
# `apply_show_state_filter` the grid uses, so the rail can't drift from the grid's definition.
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
srows = db.execute(
select(PlexShow, prog.c.resumed_at)
.join(prog, prog.c.show_id == PlexShow.id)
.where(
PlexShow.library_id.in_(show_ids),
or_(prog.c.watched > 0, prog.c.inprog > 0),
prog.c.watched < prog.c.total,
apply_show_state_filter(
select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
prog.c.total,
prog.c.watched,
prog.c.inprog,
"in_progress",
)
.where(PlexShow.library_id.in_(show_ids))
.order_by(prog.c.resumed_at.desc().nullslast())
.limit(RAIL_LIMIT)
).all()
+36 -1
View File
@@ -1,6 +1,8 @@
"""Plex people: cast/crew filter-chip cards + a single person's filmography page."""
import logging
import threading
import time
from fastapi import APIRouter, BackgroundTasks, Depends
from sqlalchemy import func, or_, select
@@ -112,6 +114,32 @@ def people_cards(
_PERSON_SECTION_MAX = 200
# A person's bio is enriched at most once per this window: it dedups concurrent opens of the same person
# (each would otherwise schedule its own 8s TMDB task) AND rate-limits re-triggering when TMDB is
# down/slow (a transient failure leaves tmdb_enriched_at NULL, so without this every reload would spawn a
# fresh task and pile up on the shared threadpool). In-process + best-effort: a restart just clears it, at
# worst costing a few extra fetches. A successful enrich stamps the watermark, so the reservation only
# ever expires harmlessly.
_ENRICH_COOLDOWN_S = 60.0
_enrich_inflight: dict[int, float] = {}
_enrich_lock = threading.Lock()
def _should_schedule_enrich(person_id: int) -> bool:
"""True (and reserves a cooldown) if this person isn't already being enriched / recently attempted."""
now = time.monotonic()
with _enrich_lock:
deadline = _enrich_inflight.get(person_id)
if deadline is not None and now < deadline:
return False
_enrich_inflight[person_id] = now + _ENRICH_COOLDOWN_S
# Bound the map: drop expired reservations once it grows (a large library has many people).
if len(_enrich_inflight) > 1024:
for pid in [p for p, d in _enrich_inflight.items() if d <= now]:
del _enrich_inflight[pid]
return True
def _enrich_person_bg(person_id: int) -> None:
"""Fetch + persist one person's TMDB bio OFF the request path — a FastAPI background task that runs
AFTER the response is sent, so the outbound TMDB call never sits on the serve latency (the module's
@@ -150,7 +178,14 @@ def person_detail(
"""
person = db.execute(select(PlexPerson).where(PlexPerson.name == name)).scalars().first()
# Lazy one-time bio enrich, scheduled AFTER the response so the TMDB call is never on the serve path.
if person is not None and person.tmdb_person_id and person.tmdb_enriched_at is None:
# Gated by a per-person cooldown so concurrent/rapid opens (or repeated opens while TMDB is degraded)
# can't pile background tasks onto the shared threadpool.
if (
person is not None
and person.tmdb_person_id
and person.tmdb_enriched_at is None
and _should_schedule_enrich(person.id)
):
background_tasks.add_task(_enrich_person_bg, person.id)
movie_lib_ids, show_lib_ids = _content_libs(db)
+24
View File
@@ -140,6 +140,30 @@ def test_continue_watching_null_status_in_hidden_out(db, monkeypatch):
assert "mh" not in ids # hidden is excluded
def test_continue_watching_excludes_fully_watched_show(db, monkeypatch):
# A show whose every episode is watched is DONE, not in-progress: the `watched < total` guard must
# keep it off the Continue-watching rail (a bare `watched > 0` would wrongly resurface it).
lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add(lib_s)
db.flush()
sh = PlexShow(rating_key="shw", library_id=lib_s.id, title="All Watched", child_count=1)
db.add(sh)
db.flush()
ep = PlexItem(rating_key="shwe1", library_id=lib_s.id, kind="episode", title="E1",
show_id=sh.id, season_number=1, episode_number=1)
db.add(ep)
db.flush()
user = User(email="fullywatched@example.com")
db.add(user)
db.flush()
db.add(PlexState(user_id=user.id, item_id=ep.id, status="watched", position_seconds=0,
progress_updated_at=NOW))
db.commit()
ids = _ids(_get(db, user, monkeypatch, "/api/plex/discover?scope=show").json()["continue_watching"])
assert "shw" not in ids # fully watched → excluded from continue-watching
def test_because_you_watched_resolves_in_library_recs(db, monkeypatch):
user = _seed(db)
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()
+116
View File
@@ -47,6 +47,22 @@ def test_parse_person_empty_payload():
assert p["biography"] is None and p["known_for"] is None
def test_parse_person_keeps_same_id_across_media_types():
# TMDB movie ids and tv ids are independent, so a movie-42 and a tv-42 credit are two distinct works
# and BOTH must survive the dedup (keyed on (id, media_type), not the bare id).
payload = {
"combined_credits": {
"cast": [
{"id": 42, "media_type": "movie", "title": "Movie 42", "popularity": 90},
{"id": 42, "media_type": "tv", "name": "Show 42", "popularity": 80},
{"id": 42, "media_type": "movie", "title": "dup movie 42", "popularity": 5}, # real dup
]
}
}
kf = plex_tmdb.parse_person(payload)["known_for"]
assert [(k["tmdb_id"], k["media_type"]) for k in kf] == [(42, "movie"), (42, "tv")] # both kept, dup dropped
class _FakeTmdb:
def __init__(self, payload):
self.payload = payload
@@ -73,6 +89,49 @@ def test_enrich_person_persists_and_stamps(db, monkeypatch):
assert [k["tmdb_id"] for k in person.known_for] == [20, 10, 30]
class _FakeTmdbResult:
"""Context-manager TmdbClient stand-in whose fetch_person raises / returns a fixed payload."""
def __init__(self, *, raises=False, payload=None):
self._raises = raises
self._payload = payload
def __enter__(self):
return self
def __exit__(self, *a):
return False
def fetch_person(self, pid):
if self._raises:
raise plex_tmdb.TmdbError("429 rate limited")
return self._payload
def test_enrich_person_transient_error_leaves_watermark_null(db, monkeypatch):
# A transient TmdbError must NOT stamp the watermark, so the next open retries (never permanently
# blank a person after one blip).
person = PlexPerson(name="Transient P", tmdb_person_id=555)
db.add(person)
db.commit()
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else "")
monkeypatch.setattr(plex_tmdb, "TmdbClient", lambda d, timeout=None: _FakeTmdbResult(raises=True))
assert plex_tmdb.enrich_person(db, person) is False
assert person.tmdb_enriched_at is None # left NULL → retried next open
def test_enrich_person_404_stamps_watermark_without_bio(db, monkeypatch):
# A 404 (deleted person → None payload) stamps the watermark so it isn't refetched every open.
person = PlexPerson(name="Gone P", tmdb_person_id=555)
db.add(person)
db.commit()
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else "")
monkeypatch.setattr(plex_tmdb, "TmdbClient", lambda d, timeout=None: _FakeTmdbResult(payload=None))
assert plex_tmdb.enrich_person(db, person) is True
assert person.tmdb_enriched_at is not None # stamped
assert person.biography is None # nothing fetched
def test_enrich_person_noop_without_key(db, monkeypatch):
person = PlexPerson(name="Actor Y", tmdb_person_id=555)
db.add(person)
@@ -116,6 +175,7 @@ def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch):
scheduled: list[int] = []
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid))
people._enrich_inflight.clear() # ensure the per-person cooldown guard doesn't suppress this open
resp = _get(db, user, monkeypatch, "/api/plex/person/Lazy P")
assert resp.status_code == 200, resp.text
@@ -123,6 +183,62 @@ def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch):
assert len(scheduled) == 1 # the enrich was scheduled off the request path
def test_enrich_person_bg_closes_session_and_isolates_errors(monkeypatch):
# The background-task body must open a fresh session, fetch the person, and ALWAYS close it — rolling
# back (never propagating) if enrich_person raises. Pure-unit via a fake session (no DB needed).
from app.routes.plex import people
calls = {"rollback": 0, "close": 0, "enriched": 0}
class _FakeSession:
def __init__(self, person):
self._person = person
def get(self, model, pid):
return self._person
def rollback(self):
calls["rollback"] += 1
def close(self):
calls["close"] += 1
# Success path: person found → enrich called, session closed, no rollback.
monkeypatch.setattr(people, "SessionLocal", lambda: _FakeSession(object()))
monkeypatch.setattr(
people.plex_tmdb, "enrich_person", lambda db, p: calls.__setitem__("enriched", calls["enriched"] + 1)
)
people._enrich_person_bg(5)
assert calls == {"rollback": 0, "close": 1, "enriched": 1}
# Error path: enrich raises → rollback + close, no exception escapes.
calls.update({"rollback": 0, "close": 0, "enriched": 0})
def _boom(db, p):
raise RuntimeError("enrich blew up")
monkeypatch.setattr(people.plex_tmdb, "enrich_person", _boom)
people._enrich_person_bg(5) # must not raise
assert calls["rollback"] == 1 and calls["close"] == 1
# Missing person (deleted id): None-guard skips enrich, session still closed.
calls.update({"rollback": 0, "close": 0, "enriched": 0})
monkeypatch.setattr(people, "SessionLocal", lambda: _FakeSession(None))
people._enrich_person_bg(9)
assert calls == {"rollback": 0, "close": 1, "enriched": 0}
def test_should_schedule_enrich_dedups_within_cooldown():
# Concurrent/rapid opens of the same person must schedule the enrich only once per cooldown window.
from app.routes.plex import people
people._enrich_inflight.clear()
assert people._should_schedule_enrich(12345) is True # first open reserves
assert people._should_schedule_enrich(12345) is False # within cooldown → deduped
assert people._should_schedule_enrich(99999) is True # a different person is independent
people._enrich_inflight.clear()
def test_person_detail_no_enrich_when_already_enriched(db, monkeypatch):
# An already-enriched person (tmdb_enriched_at set) must NOT schedule another background fetch.
from datetime import datetime, timezone
+30
View File
@@ -293,6 +293,36 @@ def test_enrich_batch_skips_shows_without_guid(db):
assert sh.tmdb_enriched_at is None
def test_resolve_library_guids_scopes_to_given_libraries(db):
# The scoped path (person page) must keep a credit that lives in a NON-scoped library (disabled or an
# HU-overlay twin) OUT of the result; the unscoped path (detail recs) matches across all libraries;
# and an explicit empty list skips that media side entirely (distinct from None = match all).
lib_on = _lib(db, key="on", kind="movie")
lib_off = _lib(db, key="off", kind="movie")
m_on = PlexItem(rating_key="on1", library_id=lib_on.id, kind="movie", title="In Scope", guid="tmdb:100")
m_off = PlexItem(rating_key="off1", library_id=lib_off.id, kind="movie", title="Out", guid="tmdb:200")
db.add_all([m_on, m_off])
db.commit()
entries = [{"tmdb_id": 100, "media_type": "movie"}, {"tmdb_id": 200, "media_type": "movie"}]
# Scoped to lib_on: the row in lib_off is excluded.
movies, shows = tmdb.resolve_library_guids(
db, entries, movie_lib_ids=[lib_on.id], show_lib_ids=[]
)
assert set(movies) == {"tmdb:100"}
assert shows == {}
# Unscoped (None defaults): matches across all libraries.
movies_all, _ = tmdb.resolve_library_guids(db, entries)
assert set(movies_all) == {"tmdb:100", "tmdb:200"}
# Empty list for a media side skips its bucket even with candidates present (vs None = match all).
_, shows_empty = tmdb.resolve_library_guids(
db, [{"tmdb_id": 100, "media_type": "tv"}], show_lib_ids=[]
)
assert shows_empty == {}
def test_resolve_recs_matches_by_media_type_not_bare_id(db):
# TMDB movie ids and tv ids are independent, so tmdb:500 can be BOTH a show (in our library) and a
# movie recommendation. The match must respect media_type — a bare-id match would deep-link the movie
@@ -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(
+8 -3
View File
@@ -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();
+7 -2
View File
@@ -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",
-1
View File
@@ -72,7 +72,6 @@
"collectionEmpty": "No collections",
"matchLabel": "Match",
"studio": "Studio",
"add": "Add",
"moreRefine": "{{count}} more — refine with search"
},
"count": "{{count}} titles",
-1
View File
@@ -72,7 +72,6 @@
"collectionEmpty": "Nincs kollekció",
"matchLabel": "Egyezés",
"studio": "Stúdió",
"add": "Hozzáadás",
"moreRefine": "még {{count}} — finomíts kereséssel"
},
"count": "{{count}} cím",