fix(685): AddItemsDialog resolves by search instead of windowing the whole media-library type #741

Merged
timothy merged 5 commits from fix/685-additems-searchpicker into main 2026-08-05 20:02:00 +02:00
Owner

fixes #685

AddItemsDialog.runSearch was reachable with an empty query two ways — a blank form submit, and a kind-chip click which called it immediately — and getLibraryBrowseItems omits a falsy query. So each path degraded into an unfiltered browse of the whole media-library type (first 50 rows, per kind) presented as the answer, with nothing surfacing the truncation. merged.slice(0, 50) separately dropped up to 100 of 150 fetched rows even for a real query.

All ten ADDABLE_TYPE_LIST entries are spa-conventions.md §3b Class B media-library types.

Shape of the fix

The dialog is multi-select (checkbox rows, many items added at once, fanned out over several kinds), so §3b's SearchPicker — single-select — does not fit. It takes §3b's constraints instead, and they live in a shared helper rather than at the call site:

  • New searchLibraryBrowseItems (web/src/api/libraryBrowse.ts), a sibling of searchLibraryPickerOptions, owns the LIBRARY_PICKER_MIN_QUERY gate, the LIBRARY_PICKER_RESULTS clamp and the titleContainsQuery compile. It returns full LibraryBrowseItem rows plus totalCount (the {id, name} shape loses the mediaType that toAddItemsRequest needs).
  • Raw text is no longer forwarded as Lucene — a second latent §3b violation on this screen, since the index's default field does not match bare title words.
  • merged.slice(0, 50) is removed rather than hinted at; the per-kind cap that can still truncate is surfaced as Showing N of M matches.
  • Results carry a Results for "<query>" label keyed to the settled query, so rows are never shown without saying which search produced them.
  • Both ingresses into the searched kinds (MediaKindFilter, DEFAULT_SEARCH_KINDS) are derived from ADDABLE_TYPE_LIST, making the hint's pre-filter/post-filter arithmetic a compile-time property.

Two corrections to the issue body

  1. It says ADDABLE_TYPES includes collection-family types needing a truncation hint. It does not — all ten entries are Class B media types. Done-when box 4 has no subject on this screen.
  2. It prescribes rendering SearchPicker, which is single-select and cannot serve this dialog.

Review

Four independent adversarial review rounds (cold brief, isolated worktree). Round 2 returned BLOCKED: the helper's bound was dead code to the suite — deleting its clamp left 1119/1119 green while the registry note claimed callers "cannot skip the bound" — and a duplicated gate in the screen masked the helper's, so the boundary test pinned nothing. Both fixed and re-verified by mutation. Rounds 4 and 5 confirmed no new defects.

Every gate and guard here has at least one test that reddens when that gate alone is removed; each was verified by mutating the single clause.

Verification

npm run typecheck clean · npm run lint clean · 1199/1199 tests green, on current origin/main.

No live-E2E: pure-SPA change, no API or server-state effect.

Docs

docs/spa-conventions.md §3b — records that multi-select pickers satisfy §3b without SearchPicker, corrects a parent paragraph that contradicted the new hint, and carries the lesson that a gate's home is the shared helper however single-sink the screen's own function looks.

Follow-up filed: #740 (pre-existing missing seqRef/useIsMountedRef guards on this screen, explicitly not introduced here).

fixes #685 `AddItemsDialog.runSearch` was reachable with an **empty query** two ways — a blank form submit, and a kind-chip click which called it immediately — and `getLibraryBrowseItems` omits a falsy `query`. So each path degraded into an unfiltered browse of the whole media-library type (first 50 rows, per kind) presented as the answer, with nothing surfacing the truncation. `merged.slice(0, 50)` separately dropped up to 100 of 150 fetched rows even for a real query. All ten `ADDABLE_TYPE_LIST` entries are `spa-conventions.md` §3b **Class B** media-library types. ## Shape of the fix The dialog is **multi-select** (checkbox rows, many items added at once, fanned out over several kinds), so §3b's `SearchPicker` — single-select — does not fit. It takes §3b's *constraints* instead, and they live in a shared helper rather than at the call site: - New `searchLibraryBrowseItems` (`web/src/api/libraryBrowse.ts`), a sibling of `searchLibraryPickerOptions`, owns the `LIBRARY_PICKER_MIN_QUERY` gate, the `LIBRARY_PICKER_RESULTS` clamp and the `titleContainsQuery` compile. It returns full `LibraryBrowseItem` rows plus `totalCount` (the `{id, name}` shape loses the `mediaType` that `toAddItemsRequest` needs). - Raw text is no longer forwarded as Lucene — a second latent §3b violation on this screen, since the index's default field does not match bare title words. - `merged.slice(0, 50)` is removed rather than hinted at; the per-kind cap that *can* still truncate is surfaced as `Showing N of M matches`. - Results carry a `Results for "<query>"` label keyed to the settled query, so rows are never shown without saying which search produced them. - Both ingresses into the searched kinds (`MediaKindFilter`, `DEFAULT_SEARCH_KINDS`) are derived from `ADDABLE_TYPE_LIST`, making the hint's pre-filter/post-filter arithmetic a compile-time property. ## Two corrections to the issue body 1. It says `ADDABLE_TYPES` includes collection-family types needing a truncation hint. It does not — all ten entries are Class B media types. **Done-when box 4 has no subject on this screen.** 2. It prescribes rendering `SearchPicker`, which is single-select and cannot serve this dialog. ## Review Four independent adversarial review rounds (cold brief, isolated worktree). Round 2 returned **BLOCKED**: the helper's bound was dead code to the suite — deleting its clamp left 1119/1119 green while the registry note claimed callers "cannot skip the bound" — and a duplicated gate in the screen masked the helper's, so the boundary test pinned nothing. Both fixed and re-verified by mutation. Rounds 4 and 5 confirmed no new defects. Every gate and guard here has at least one test that reddens when that gate **alone** is removed; each was verified by mutating the single clause. ## Verification `npm run typecheck` clean · `npm run lint` clean · **1199/1199** tests green, on current `origin/main`. No live-E2E: pure-SPA change, no API or server-state effect. ## Docs `docs/spa-conventions.md` §3b — records that multi-select pickers satisfy §3b without `SearchPicker`, corrects a parent paragraph that contradicted the new hint, and carries the lesson that a gate's home is the shared helper however single-sink the screen's own function looks. Follow-up filed: #740 (pre-existing missing `seqRef`/`useIsMountedRef` guards on this screen, explicitly not introduced here).
timothy added 5 commits 2026-08-05 19:40:57 +02:00
AddItemsDialog.runSearch was reachable with an empty query two ways — a blank
form submit, and a kind-chip click, which called it immediately — and
getLibraryBrowseItems omits a falsy `query`, so each path degraded into an
unfiltered browse of the whole media-library type (first 50 rows, per kind)
presented as the answer with nothing surfacing the truncation. All ten
ADDABLE_TYPE_LIST entries are spa-conventions §3b Class B media-library types.

The dialog is multi-select, so §3b's SearchPicker (single-select) does not fit;
it takes §3b's constraints instead:

- no request below LIBRARY_PICKER_MIN_QUERY, enforced in runSearch — the single
  sink both entry paths route through, not duplicated per caller
- typed text compiled with titleContainsQuery rather than forwarded raw (a
  second latent §3b violation here: the search index's default field does not
  match bare title words)
- each kind bounded to LIBRARY_PICKER_RESULTS
- merged.slice(0, 50) removed — it silently dropped up to 100 of 150 fetched
  rows even for a real query

Tests assert zero requests below the gate on both paths, exactly one bounded
request per kind above it (20k-row fixture), the compiled+escaped query, and
that no fetched row is dropped. Each was verified to fail with its mechanism
removed.

The pageSize registry entry moves from `deviation` to `search-bounded`. That
leaves zero deviation entries, so the anti-vacuity assertion guarding that list
is deleted deliberately, per its own instruction.

Done-when box 4 (collection-family truncation hint) has no subject: this screen
offers no collection-family type.

fixes #685
Independent review round 2. Verdict was MERGEABLE with no blockers; this takes
the two recommended fixes plus the structural one it listed as a follow-up.

- The bound was caller discipline, not code: getLibraryBrowseItems does not
  clamp pageSize, so the bound was only the constant this one call site chose
  to pass, and §3b is explicit that a bound a caller can exceed is not a bound.
  New searchLibraryBrowseItems in libraryBrowse.ts owns the min-query gate, the
  pageSize clamp and the titleContainsQuery compile, returning full
  LibraryBrowseItem rows plus totalCount (searchLibraryPickerOptions' {id,name}
  shape loses the mediaType that toAddItemsRequest needs). runSearch keeps one
  early return, for the spinner only, and no longer re-implements the gate.

- The min-query guidance was keyed to the LIVE input, so backspacing below the
  gate after a search wiped the rendered rows and their checkmarks while
  `selected` and the Add button still counted them. Keyed to results.length too.

- "Nothing left to hint at" was false: each kind is still capped at
  LIBRARY_PICKER_RESULTS and totalCount was never read. This is a bulk
  multi-select add, so the cap is surfaced — per-kind totalCounts are summed and
  rendered as "Showing N of M matches" once it exceeds the rendered rows. The
  registry note and the §3b bullet are corrected to stop claiming otherwise.

- Gate boundary tested at 1 character (§3b: inclusive endpoints, or a > for >=
  slip passes the whole suite).

- The guard test's deviation loop iterates an empty list now, so it gains one
  bidirectional assertion that is non-vacuous: the set carrying an `issue` field
  must equal the set classified 'deviation'.

- The §3b bullet no longer reads as a conformance certificate: AddItemsDialog
  still lacks the seqRef and useIsMountedRef guards §3b mandates. That defect is
  PRE-EXISTING, not introduced here, and is tracked in #740.

refs #740
Independent review round 2 returned BLOCKED on two findings, both correct.

- The helper's bound was dead code to the suite. searchLibraryBrowseItems had
  zero tests, so deleting its clamp OR its gate left the whole suite green —
  while the registry note claimed a caller "cannot skip the bound". That is the
  previous round's finding relocated, not removed. It now has the three tests
  its sibling searchLibraryPickerOptions already had (clamp, gate, compile),
  plus one pinning the full-row return that is its reason to exist.

- The screen kept a second copy of the min-query check, and the two masked each
  other: the 1-character boundary test passed with EITHER gate alone, so it
  pinned nothing. The screen's copy is deleted; the helper is the sole gate.
  Measured before/after: with the duplicate present, removing the helper's gate
  left that test green; with it gone, the same removal reddens it.

- §3b contradicted itself two lines apart — the parent still said "there is no
  truncation, so there is no truncation hint" above a sub-bullet mandating one.
  Reworded so a hint is permitted, required only where bulk selection makes the
  count actionable. Same correction to the 'search-bounded' definition.

- A failed search left results/totalMatches stale, rendering a confident
  "Showing 75 of 60000 matches" beside the error banner. The catch clears them.

- Results now carry a `Results for "<query>"` heading and the guidance is keyed
  to the settled query, not the live input, so rows are never shown without
  saying which search produced them. `selected` persists across queries (correct
  for a multi-select picker); the Add button's count keeps it discoverable.

- The hint sums pre-filter totalCount against post-filter rows, which is only
  correct because every filterable kind is addable. MediaKindFilter is now
  derived from ADDABLE_TYPE_LIST, making that a compile error rather than prose.

- aria-live on the hint; the #740 doc caveat no longer overstates the typeahead
  rule as a mandate this screen violates.

Declined: the NaN pageSize edge (copied faithfully from the sibling helper) and
the registry's same-identity substitution gap (already disclosed in that file).

refs #740
Verification round returned MERGEABLE with all three blocking findings resolved
by measurement. These are its four remaining items.

- DEFAULT_SEARCH_KINDS was the SECOND ingress into the searched kinds and was
  not derived, so the previous commit's "enforced by the type system" claim held
  for one of two paths. A non-addable kind there typechecked clean and would
  have overstated the hint with every one of its rows dropped — the exact defect
  the derivation exists to prevent. Now derived; verified by mutation that
  adding 'Collection' to it is a compile error.

- The error path fell into the min-query guidance branch, so a valid 2-character
  query that got a 500 told the user to type at least 2 characters. That branch
  conflated "nothing searched yet" with "the last search failed". Newly
  introduced by the previous commit's error-path reset; now gated on !error and
  pinned by a test.

- The aria-live region was mounted conditionally, creating the region and its
  text in one commit — which most screen readers do not announce. It is now
  mounted unconditionally with the condition inside.

- The §3b lesson mis-stated where the duplicate gate lived: it was inside
  runSearch, the genuine single sink, NOT at one of the callers — so the rule as
  written ("put the gate in the single sink, not at each caller") described the
  revision that was rejected. Reworded to the actual lesson: the gate's home is
  the shared helper, and "it's the single sink" is not evidence it is the only
  guard. That misreading is why #685 got this wrong twice.

Declined again, with reasons: the NaN pageSize edge (faithful to the sibling
helper), the clamp test's unpinned lower bound (same), and the registry's
disclosed same-identity substitution gap.

refs #740
fix(685): suppress both empty-states on error; name the addable-kind derivation
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 18s
Review verdict / Set review-verdict status (pull_request_target) Successful in 8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m33s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m21s
review-verdict/h10 Review-verdict: MERGEABLE @ 7be4265 (base: main)
PR Gates / decisions lifecycle (pull_request) Successful in 2m7s
PR Gates / Script tests (pytest) (pull_request) Successful in 2m13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m7s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m2s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
7be42654fe
Round-4 verification returned MERGEABLE with no new defects and no
BLOCKER/HIGH/MEDIUM. These are its remaining LOW and nits.

- "No results — try a search above." asserts a search that COMPLETED and found
  nothing, so it was false beside a failed request. Both empty-state messages
  are now suppressed on error and the role="alert" banner is the whole message,
  as that test's comment already claimed. Pinned positively and negatively so a
  refactor cannot satisfy the assertion by rendering nothing at all.

- The (typeof ADDABLE_TYPE_LIST)[number] derivation is now the named
  AddableKind, spelled once instead of twice: a third ingress into the searched
  kinds is most likely to be written by copying one of the existing two, and the
  "one list, all ingresses" property should be visible at a glance rather than
  reassembled.

- The §3b lesson cited two measured test counts, which go stale against the very
  suite they describe — a count taken before the helper had unit tests no longer
  holds now that it does. Scoped the observation to the sha it was measured on
  and replaced the counts with the invariant they were evidence for: every gate
  needs at least one test that reddens when that gate ALONE is removed.

- Documented why the error banner stays conditionally mounted while the hint's
  live region does not: role="alert" is the one live-region role screen readers
  reliably announce on insertion, so the two are correct for opposite reasons.
  The review flagged the divergence as unexplained, not as wrong.

refs #740
Author
Owner

Review-verdict: MERGEABLE @ 7be4265

Four independent adversarial rounds (cold brief, isolated worktree, Opus). Round 2 BLOCKED: helper bound was dead code to the suite (deleting the clamp left 1119/1119 green) and a duplicated screen gate masked the helper's, so the boundary test pinned nothing. Both fixed, re-verified by independent mutation. Rounds 4 and 5 confirmed no new defects; all severity bands empty. typecheck+lint clean, 1199/1199.

Review-verdict: MERGEABLE @ 7be4265 Four independent adversarial rounds (cold brief, isolated worktree, Opus). Round 2 BLOCKED: helper bound was dead code to the suite (deleting the clamp left 1119/1119 green) and a duplicated screen gate masked the helper's, so the boundary test pinned nothing. Both fixed, re-verified by independent mutation. Rounds 4 and 5 confirmed no new defects; all severity bands empty. typecheck+lint clean, 1199/1199.
timothy merged commit d7647b6104 into main 2026-08-05 20:02:00 +02:00
timothy deleted branch fix/685-additems-searchpicker 2026-08-05 20:02:02 +02:00
Sign in to join this conversation.