test(e24): close the review's coverage gaps, and correct a comment that was untrue

`test_only_a_show_row_pays_for_a_resolution` seeded no watch state, so every rail query
inner-joined its way to zero rows and the loop body — with it the assertion — never ran. It
now seeds a video, a movie and an episode, and asserts all three kinds are present before
the loop, so it cannot go vacuous again.

The Plex half of the surprise rail had no test for either of its promises: every test that
reached `plex_surprise` passed budget="any" against a library with no state rows, so the
duration bounds and the already-started exclusion could both be deleted with the suite
green. The YouTube half has had tests for the same two rules since S1 — the asymmetric half
of one principle.

`plex_item` and `plex_show` — two of the five pinnable kinds — were pinned by no test at
all, leaving the rating_key → internal-id translation and both shelf arms uncovered,
including the self-reference cycle that once 500'd the shelf. And `GET /api/discover/pick`
was exercised only through its domain function, so the route's own query-string translation
could regress silently; the new test excludes all three candidates, which is what proves the
whole list parsed rather than one unsplit string.

`PIN_COLUMNS`/`PIN_KINDS` claimed in a comment to be the one place the pin vocabulary is
spoken and had no readers anywhere: `resolve_ref` re-spelled the column names and the route
re-typed the kind list. Both now drive off the constants.

The `_NO_TS` sentinel was introduced as a crash fix and is not one. The key it replaced,
`(ts is not None, ts)`, cannot raise — tuple comparison finds the first UNEQUAL element via
`==`, so two null keys compare equal and `None < None` is never reached; three independent
runs confirmed it over every permutation. The rationale now says what the sentinel actually
buys, lives once in `plex.rollup`, and the third rail that was still on the older spelling
has been swept to match. Its test is renamed and re-scoped to the mutation it does catch.

Frontend: the reroll's second press asserted `expect.any(Array)`, which cannot tell an
accumulating exclusion window from a replacing one — both that and SEEN_WINDOW = 1 (each
reinstating the measured nine-card cycle) left the suite green. And `onInfo`'s jump type was
untested while `onPlay`'s was pinned twice, so pointing the ⓘ affordance at the player
typechecked and shipped green.

All eight fixes mutation-checked; every mutant died on its intended test.
This commit is contained in:
2026-09-08 03:14:21 +02:00
parent fdc1eabbe0
commit cedebe0ffb
7 changed files with 219 additions and 46 deletions
+22 -16
View File
@@ -41,28 +41,34 @@ def resolve_ref(db: Session, user: User, kind: str, ref: str) -> tuple[str, obje
The client speaks the ids the rest of the API speaks: a video id, a channel id, a Plex
`rating_key`, a playlist id. Plex's internal integer keys never leave the backend, so the
translation belongs here rather than in five call sites."""
translation belongs here rather than in five call sites.
The COLUMN half of every answer comes from `PIN_COLUMNS`; only the LOOKUP differs per kind
(a direct `db.get`, a rating-key translation, an int parse plus an ownership check). Before the
E24 review the branches spelled the column names out again and `PIN_COLUMNS` had no readers at
all — a constant that called itself the one vocabulary while nothing spoke it."""
col = PIN_COLUMNS.get(kind)
if col is None:
return None
if kind == "video":
return ("video_id", ref) if db.get(Video, ref) is not None else None
return (col, ref) if db.get(Video, ref) is not None else None
if kind == "channel":
return ("channel_id", ref) if db.get(Channel, ref) is not None else None
return (col, ref) if db.get(Channel, ref) is not None else None
if kind == "plex_item":
it = by_rating_key(db, PlexItem, ref)
return ("plex_item_id", it.id) if it is not None else None
return (col, it.id) if it is not None else None
if kind == "plex_show":
sh = by_rating_key(db, PlexShow, ref)
return ("plex_show_id", sh.id) if sh is not None else None
if kind == "playlist":
try:
pid = int(ref)
except ValueError:
return None
pl = db.get(Playlist, pid)
# A playlist is per-user, unlike the other four (which are shared catalog rows whose PRIVACY
# lives in the state tables). Someone else's list must read as "no such thing", not as a 403 —
# the same reason `owned_or_404` 404s rather than 403s.
return ("playlist_id", pl.id) if pl is not None and pl.user_id == user.id else None
return None
return (col, sh.id) if sh is not None else None
try:
pid = int(ref)
except ValueError:
return None
pl = db.get(Playlist, pid)
# A playlist is per-user, unlike the other four (which are shared catalog rows whose PRIVACY
# lives in the state tables). Someone else's list must read as "no such thing", not as a 403 —
# the same reason `owned_or_404` 404s rather than 403s.
return (col, pl.id) if pl is not None and pl.user_id == user.id else None
def list_pins(db: Session, user: User) -> list[ComfortPin]:
+2 -8
View File
@@ -6,8 +6,6 @@ side-by-side rails would just move the choice the user could not make ("which ha
level up, which is the opposite of what this epic is for.
"""
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app import sysconfig
@@ -15,11 +13,7 @@ from app.discover import cards, queries
from app.discover.queries import RAIL_LIMIT
from app.models import PlexShow, User
from app.plex.lookups import by_rating_key
# Sort sentinel for a row whose timestamp is missing. A plain `(ts is not None, ts)` key looks like it
# handles NULLs and does not: two NULL rows compare equal on the flag and then compare `None < None`,
# which raises. A rail must not be able to 500 the landing page over missing metadata.
_NO_TS = datetime.min.replace(tzinfo=timezone.utc)
from app.plex.rollup import NO_TS
def plex_available(db: Session) -> bool:
@@ -64,7 +58,7 @@ def continue_watching(db: Session, user: User, limit: int = RAIL_LIMIT) -> list[
]
if plex_available(db):
rows += [(ts, cards.from_plex(c)) for ts, c in queries.plex_in_progress(db, user.id, limit)]
rows.sort(key=lambda r: r[0] or _NO_TS, reverse=True)
rows.sort(key=lambda r: r[0] or NO_TS, reverse=True)
out = [card for _, card in rows[:limit]]
# Resolve AFTER the slice: only the rows that survive it are worth a query.
for card in out: