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 """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) ps = aliased(PlexState)
return ( cols = [
select( PlexItem.show_id.label("show_id"),
PlexItem.show_id.label("show_id"), func.count(PlexItem.id).label("total"),
func.count(PlexItem.id).label("total"), func.count(case((ps.status == "watched", 1))).label("watched"),
func.count(case((ps.status == "watched", 1))).label("watched"), func.count(
func.count( case(
case( (
( and_(
and_( ps.position_seconds > 0,
ps.position_seconds > 0, or_(ps.status.is_(None), ps.status != "watched"),
or_(ps.status.is_(None), ps.status != "watched"), ),
), 1,
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)) .outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id))
.where(PlexItem.kind == "episode", PlexItem.show_id.isnot(None)) .where(PlexItem.kind == "episode", PlexItem.show_id.isnot(None))
.group_by(PlexItem.show_id) .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 """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.""" 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 [] 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] = [] known: list[dict] = []
for c in sorted(credits, key=lambda c: c.get("popularity") or 0, reverse=True): for c in sorted(credits, key=lambda c: c.get("popularity") or 0, reverse=True):
tid, mt = c.get("id"), c.get("media_type") 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 continue
seen.add(tid) seen.add((tid, mt))
known.append({"tmdb_id": tid, "media_type": mt, "title": c.get("title") or c.get("name")}) known.append({"tmdb_id": tid, "media_type": mt, "title": c.get("title") or c.get("name")})
if len(known) >= _KNOWN_FOR_MAX: if len(known) >= _KNOWN_FOR_MAX:
break 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 fastapi import APIRouter, Depends, Query
from sqlalchemy import and_, case, func, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.models import PlexItem, PlexShow, PlexState, User from app.models import PlexItem, PlexShow, PlexState, User
from app.plex import rollup from app.plex import rollup
from app.plex.cards import movie_card, show_card 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.plex.tmdb import resolve_recs
from app.routes.plex._shared import _content_libs, _norm_lang 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))) rows.append((st.progress_updated_at, movie_card(it, st)))
if show_ids: if show_ids:
ps = aliased(PlexState) # Reuse the shared per-show episode rollup (total/watched/inprog) rather than re-inlining it, plus
prog = ( # its `resumed_at` = max(progress_updated_at); the in-progress predicate comes from the same shared
select( # `apply_show_state_filter` the grid uses, so the rail can't drift from the grid's definition.
PlexItem.show_id.label("show_id"), prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
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`).
srows = db.execute( srows = db.execute(
select(PlexShow, prog.c.resumed_at) apply_show_state_filter(
.join(prog, prog.c.show_id == PlexShow.id) select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
.where( prog.c.total,
PlexShow.library_id.in_(show_ids), prog.c.watched,
or_(prog.c.watched > 0, prog.c.inprog > 0), prog.c.inprog,
prog.c.watched < prog.c.total, "in_progress",
) )
.where(PlexShow.library_id.in_(show_ids))
.order_by(prog.c.resumed_at.desc().nullslast()) .order_by(prog.c.resumed_at.desc().nullslast())
.limit(RAIL_LIMIT) .limit(RAIL_LIMIT)
).all() ).all()
+36 -1
View File
@@ -1,6 +1,8 @@
"""Plex people: cast/crew filter-chip cards + a single person's filmography page.""" """Plex people: cast/crew filter-chip cards + a single person's filmography page."""
import logging import logging
import threading
import time
from fastapi import APIRouter, BackgroundTasks, Depends from fastapi import APIRouter, BackgroundTasks, Depends
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
@@ -112,6 +114,32 @@ def people_cards(
_PERSON_SECTION_MAX = 200 _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: 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 """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 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() 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. # 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) background_tasks.add_task(_enrich_person_bg, person.id)
movie_lib_ids, show_lib_ids = _content_libs(db) 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 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): def test_because_you_watched_resolves_in_library_recs(db, monkeypatch):
user = _seed(db) user = _seed(db)
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json() 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 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: class _FakeTmdb:
def __init__(self, payload): def __init__(self, payload):
self.payload = 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] 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): def test_enrich_person_noop_without_key(db, monkeypatch):
person = PlexPerson(name="Actor Y", tmdb_person_id=555) person = PlexPerson(name="Actor Y", tmdb_person_id=555)
db.add(person) db.add(person)
@@ -116,6 +175,7 @@ def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch):
scheduled: list[int] = [] scheduled: list[int] = []
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid)) 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") resp = _get(db, user, monkeypatch, "/api/plex/person/Lazy P")
assert resp.status_code == 200, resp.text 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 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): 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. # An already-enriched person (tmdb_enriched_at set) must NOT schedule another background fetch.
from datetime import datetime, timezone 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 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): 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 # 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 # 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 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", () => { it("single-select: picking replaces the value and closes the list", () => {
const onChange = vi.fn(); const onChange = vi.fn();
wrap( wrap(
+8 -3
View File
@@ -100,7 +100,11 @@ export default function Combobox({
onOpenChange?.(next); onOpenChange?.(next);
}; };
const pop = useAnchoredPopover({ open, onOpenChange: setOpenAnd, mode: "combobox" }); 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. // Clear the typed query when the list closes.
useEffect(() => { useEffect(() => {
@@ -176,9 +180,10 @@ export default function Combobox({
}, },
onFocus: () => setOpenAnd(true), onFocus: () => setOpenAnd(true),
onKeyDown: (e) => { 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 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) { } else if (e.key === "Backspace" && query === "" && selected.length > 0) {
// Backspace on an empty field removes the last chip (react-select behaviour). // Backspace on an empty field removes the last chip (react-select behaviour).
onChange(selected.slice(0, -1)); onChange(selected.slice(0, -1));
@@ -109,6 +109,24 @@ describe("PlexInfo enriched detail (page)", () => {
expect(onOpenPerson).toHaveBeenCalledWith("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)", () => { it("the overlay variant stays lean (no facts / keywords / crew panels)", () => {
wrap(<PlexInfo detail={detail} variant="overlay" />); wrap(<PlexInfo detail={detail} variant="overlay" />);
expect(screen.queryByText("plex.info.facts.title")).toBeNull(); expect(screen.queryByText("plex.info.facts.title")).toBeNull();
+7 -2
View File
@@ -15,6 +15,7 @@ import {
useDetailPrefs, useDetailPrefs,
} from "../lib/plexDetailUi"; } from "../lib/plexDetailUi";
import { usePref } from "../lib/usePref"; import { usePref } from "../lib/usePref";
import i18n from "../i18n";
import { Carousel } from "./Carousel"; import { Carousel } from "./Carousel";
import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd"; 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 // 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. // 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) => const money = (n: number) =>
new Intl.NumberFormat("en-US", { new Intl.NumberFormat(i18n.language, {
notation: "compact", notation: "compact",
style: "currency", style: "currency",
currency: "USD", currency: "USD",
-1
View File
@@ -72,7 +72,6 @@
"collectionEmpty": "No collections", "collectionEmpty": "No collections",
"matchLabel": "Match", "matchLabel": "Match",
"studio": "Studio", "studio": "Studio",
"add": "Add",
"moreRefine": "{{count}} more — refine with search" "moreRefine": "{{count}} more — refine with search"
}, },
"count": "{{count}} titles", "count": "{{count}} titles",
-1
View File
@@ -72,7 +72,6 @@
"collectionEmpty": "Nincs kollekció", "collectionEmpty": "Nincs kollekció",
"matchLabel": "Egyezés", "matchLabel": "Egyezés",
"studio": "Stúdió", "studio": "Stúdió",
"add": "Hozzáadás",
"moreRefine": "még {{count}} — finomíts kereséssel" "moreRefine": "még {{count}} — finomíts kereséssel"
}, },
"count": "{{count}} cím", "count": "{{count}} cím",