b1d5fbefcba02fdc6c19fef85cec1c4e82fc8dea
21
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e1d211cd1b |
test(830): pin which ARM the call site reaches, not only that the hook has one
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
PR Gates / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 10s
Review verdict / Set review-verdict status (pull_request_target) Successful in 22s
review-verdict/h10 Review-verdict: MERGEABLE @ e1d211c (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m34s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m5s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
Review measured M6: swapping `reportFailure(...)` for `onAddFailed(...)` in `AddItemsDialog`'s catch left all 490 tests across the 42 `src/screens` files green. Under that mutant a failure that lands while the dialog is STILL OPEN renders into `ManualItemsView`'s screen banner, which sits behind the dialog's `createPortal` panel with `aria-modal="true"` — covered for sighted users, hidden from AT, and the surface the user is actually looking at stays blank. That is the exact shape the decision record calls "its own defect", and the whole gap was the call-site wiring: the hook's inline branch is pinned at unit level in `hooks.test.tsx`, but a unit test of the hook cannot see which arm a consumer reaches. Adds the integration assertion: fail the POST with the dialog still up, assert the message is inside `[role="dialog"]` and appears exactly once in the tree. Re-executed the mutation with it in place — 1 failed / 490 passed, and the red is this test alone. Records the new pin as mechanics (5) on `spa.dismissible-write-failure-reporting` and the general form in `spa-conventions.md` §3c, so the next site wired to the hook pins both arms at its call site rather than inheriting the hook's unit coverage. Refs #830 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV |
||
|
|
e4c346e649 |
test(830): pin the ORDINARY close too — the negative test did not cover it
Round 6 verdict was MERGEABLE with two follow-ups; both are one-liners on lines this branch just touched, so they are in rather than deferred. L11. I believed the suite covered "a normal add still closes the dialog". Review MEASURED that it did not: deleting the `onClose()` call entirely -- so a successful add leaves the picker open forever -- kept the whole suite green, 1277/1277. The new #830 test only pins the NEGATIVE direction (do not close when unmounted), so a future edit dropping the call, believing the guard had made it dead, would have shipped silently. The Song add test now asserts the dialog closes; with that line, the same deletion reddens. Both directions of the report/dismiss split are pinned. Worth naming the shape: I asserted coverage from plausibility rather than from a mutation, in the same PR whose whole subject is claims that were written down before they were measured. N12. The guards test's "exactly ONE post-await write to state THIS component owns" is still true, but it now reads as a census of `mountedRef` reads, and `submit` has two -- the success path's guarded `onClose()` is the other, which that failure-path test never reaches. Added the clause so nobody derives the guard population from that number. 1277 tests green, tsc/eslint/build clean, pytest 1228 passed, validator OK, catalog no drift. The two red CI contexts on the previous head are runner flakes, not this branch: both failed inside `Post Checkout` with `Cannot find module '/var/run/act/actions/<hash>/dist/index.js'`, their logs are timestamped 19:18 (before that head existed), this branch touches no CI or docker/ci file, and both contexts were green on its earlier heads. refs #830, #877 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY |
||
|
|
86e9ad41a6 |
fix(830): gate onClose at the exemplar — the convention contradicted its own reference
Round 5 found that §3c and the `rule:` field now instruct readers to gate the DISMISS request, while `AddItemsDialog` -- the one site this record names as "the shape fixed here" -- called `onClose()` unguarded, with a comment arguing that was correct. So a reader following the convention wrote the gate and a reader copying the reference implementation did not. The defect is pre-existing; the CONTRADICTION is mine, introduced when round 3 withdrew H1's code but kept the convention it produced. I checked the docs against the withdrawn addTo code and did not re-check them against the exemplar that stayed. Measured at this site: submit, Escape mid-request, reopen the picker to retry, first POST returns 204 -> the stale instance's `onClose()` (`() => setPickerOpen(false)`) closes the dialog the user just reopened, discarding the selection they rebuilt. Identical mechanism to the addTo clobber. Unlike the addTo layer, the one-line gate IS sufficient here, and that difference is the point: `AddItemsDialog`'s parent has no competing closer (`onAdded` is `load`, which never touches `pickerOpen`), whereas `AddToMenu.handleAdded` closes its dialog itself. That is now stated in the record as the concrete reason one half shipped and the other went to #877. - `onAdded()` stays unguarded -- it REPORTS, and the parent's list reload must survive dismissal - `onClose()` is guarded -- it REQUESTS A DISMISSAL, and after dismissal it aims at whatever the user opened next - comment rewritten to say which is which and why, instead of defending both as "belong to the still-mounted PARENT" Pinned, and nothing pinned it before: "a late SUCCESS does not close the dialog the user reopened after dismissing (#830)". It carries an anti-vacuity check that the late response was actually processed -- `onAdded` is `load`, so a second GET of the items endpoint must have happened -- because otherwise "the dialog is still open" holds trivially. Executed: deleting the `if (mountedRef.current)` around `onClose()` reddens it alone. Also rewrapped five record body lines left ragged by earlier splices. 1277 tests green, tsc/eslint/build clean, pytest 1228 passed, validator OK, catalog no drift. refs #830, #877 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY |
||
|
|
4b12125f4d |
docs(830): drop a coverage claim this record's own mechanics field contradicts
Round 4 found one clause left, and it is a good example of the thing this record is about. The one-consumer paragraph said `hooks.test.tsx` "is the ONLY thing pinning the diverted branch". Measured at the previous head, disarming `reportRef.current(message)` reddens THREE tests -- both hooks.test.tsx divert tests AND the CollectionsScreen integration test -- which is exactly what the `mechanics:` field of the same record says 58 lines earlier. So the record asserted a coverage fact and then contradicted itself. The concrete harm is not the inconsistency: a future session pruning tests reads "hooks.test.tsx is the only pin", concludes the CollectionsScreen #830 test is redundant, and deletes the only end-to-end pin of the whole path -- the one that actually drives Escape-dismissal through the real dialog. Clause dropped; the argument the paragraph needed (the hook's shape earns its own unit tests) survives without it. The clause originated in the reviewer's round-3 wording and I transcribed it without checking it against a field I had written myself two rounds earlier. Worth recording: a review finding is not exempt from verification just because it came from the reviewer. Also: - the `AddToMenu` clobber sentence now splits what was MEASURED (a late success closes a reopened dialog) from what was READ (both parents call `clearSelection()` unconditionally, so the wipe follows). On a record whose subject is over-attributing measurements, that distinction has to hold in its own prose. - §3c now carries the same "nothing diverts to those screens today" disclaimer the record's limit (2) has, so the two artifacts say the same thing - rewrapped one 141-char comment line left ragged by the previous round's splice Docs only, plus one comment rewrap. 1276 tests green, tsc/eslint/build/validator/catalog clean. refs #830, #877 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY |
||
|
|
626cbfbf9d |
docs(830): three sentences survived the revert of the code they described
Round 3 verified the withdrawal itself is clean -- the eight reverted files are byte-identical to origin/main, no orphans, and the mutation gives the stated three reds -- but found docs still asserting the withdrawn change shipped. That is the stale-comment failure in its usual form: after a retraction, the retracted WORDING has to be swept, not just the code. - `hooks.ts` said "#830 removed that gate", flatly false at this head, in the hook's own doc comment right above the export -- the first thing a maintainer reads. It also carried round 2's framing ("both halves of the outcome") as the hook's purpose, when what ships carries only the failure half. Rewritten to the present tense of the shipped tree. - The `rule:` field still said the surviving surface "differs per screen", naming MediaBrowseScreen and SearchScreen as wired. They wire nothing. This one matters beyond an ordinary sentence: `rule:` is the canonical summary, it is what the catalog row shows, and it is what gets mirrored per-key into MemPalace -- so it is the version a future session retrieves WITHOUT opening the file. Now: exactly one wired screen, the Toast pair named as a CANDIDATE. - The "two limits" bullet described a failure being diverted to those same screens and announced politely. Nothing can divert there -- they receive no reporting callback. Restated as the limit the second surface will have when it is wired. - `onFailed` in a hooks.ts comment was a dangling identifier; the real prop is `onAddFailed`. Also added the caveat the reviewer asked for rather than leaving it to be discovered: this is a shared hook with exactly ONE consumer. It earns that shape (directly unit-tested, and those tests are the only thing pinning the diverted branch; prescribed by §3c; #877 queued as a second consumer) -- but #877 may land a shared reporting SURFACE instead of a per-site prop, in which case the second consumer never arrives. Accepted risk, now written down. Docs only. No code change, 1276 tests still green, tsc/eslint/validator/catalog clean. refs #830, #877 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY |
||
|
|
de9432ff49 |
fix(830): withdraw the media/addTo extension — two rounds, two defects, one coupled gate
Round 2 of adversarial review found that my round-1 fix introduced an adjacent defect, and it is the same mechanism both times: `onAdded?.()` and `onClose()` sat behind ONE unmount gate in the four `media/addTo/` dialogs, and those two callbacks do not mean the same thing. - Gate both (origin/main): a write that SUCCEEDS after dismissal reports nothing. Measured on `AddToCollectionDialog` -- `onAdded` called 0 times after dismissal. That was round 1's finding. - Un-gate both (my round-1 fix): a late success closes a dialog the user REOPENED to retry, and on SearchScreen/MediaBrowseScreen `clearSelection()` wipes a multi-select they rebuilt. Measured against the real `AddToMenu`. That was round 2's finding, and I introduced it. - Gate only `onClose`: still wrong on its own, because `AddToMenu.handleAdded` nulls the dialog itself. Needs three coupled edits across five files -- plus a genuine product question nobody has answered: should `clearSelection()` fire for a write the user walked away from? That is a design change, not a bug fix, and #830 never asked for it -- the issue is about `AddItemsDialog`. Two defects from one mechanism in two rounds is the signal to stop widening, so the `media/addTo/` extension is REVERTED here and moves to #877 with every measurement attached (#877 comment). What ships is the thing the issue asked for, proved: - `useDismissSafeError` + `AddItemsDialog` + `CollectionsScreen` wiring - the witnessed red is unchanged: disarming `reportRef.current(message)` reddens the integration test on `Unable to find an element with the text: Request failed with status 500` Docs now describe what is actually true rather than what I hoped: - the record says ONE A1 site is fixed and explains why the other four were withdrawn, keeping the wrong first claim visible because "one site read, four assumed" is the lesson - §3c splits the rule the round-2 defect came from: report the OUTCOME unguarded, gate the DISMISS request separately -- the earlier text lumped `onClose` in with `onAdded` and would have propagated the clobber to the next screen that adopted it - §5c no longer tells authors to wire an `onFailed` that the addTo layer does not have; it says the layer has no failure channel at all and points at #877 - the reporting prop is REQUIRED where the host has a surface (`AddItemsDialog.onAddFailed`), which is what the docs now say instead of calling it optional - `mechanics:` no longer implies the shared clause reddens one test; it reddens three, so re-running the mutation should expect three refs #830, #877 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY |
||
|
|
f61764d051 |
fix(830): report SUCCESS past dismissal too — the review measured my claim false
Adversarial review (cold, worktree-isolated) blocked the first commit on its central prose claim,
correctly. I wrote "at every one of these sites SUCCESS already outlives dismissal" into three
durable artifacts -- the decision record, spa-conventions §3c and the hooks.ts header -- after
reading ONE site. `AddItemsDialog` does report success past dismissal and says so in a comment; I
generalised from it. The review probed the other four instead and MEASURED `onAdded` called 0 times
after dismissal: all four `media/addTo/` dialogs gated `onAdded?.()`/`onSaved?.()` behind their own
`activeRef`, exactly like the failure path.
So after the first commit those four were still asymmetric, just inverted: dismiss-then-fail loud,
dismiss-then-succeed silent -- and additionally leaving the caller's selection state stale, because
SearchScreen's `onAddedToSelectionTarget` never ran to clear it. The record's own advice ("add the
failure counterpart") followed literally would have reproduced it.
Fixes, each proved by execution:
- the `activeRef` gate above `onAdded?.()`/`onSaved?.()` is removed in all four dialogs; those two
statements belong to the still-mounted PARENT, which is the reasoning AddItemsDialog already had
- `AddToCollectionDialog.test.tsx` covers the media/addTo half in BOTH directions. It had NO
coverage before: reverting `reportFailure` to `setInlineError` in all four left the whole suite
green. Restoring the success gate reddens the SUCCESS test alone; disarming
`reportRef.current(message)` reddens the FAILURE test alone
- the three prose sites now say what was measured, and the record keeps the wrong first version
visible, because "one site read, four assumed, written down before measuring" is the finding
Also from the review:
- hooks.ts said "React 18"; package.json pins 19.2.7. Now "React 18+"
- the "nothing better to do" comment overclaimed: diversion reaches ONE level, so Back out of a
collection mid-add still drops the message. Stated, with where it would be fixed
- recorded two limits rather than leaving them to be rediscovered: useIsMountedRef clears in a
PASSIVE effect cleanup, leaving a narrow window where the message renders inline into a detached
tree (useLayoutEffect would close it, but that hook is shared by every async caller -- #877, not a
bug fix); and Toast is role="status" with one last-writer-wins slot, so it is not equivalent to
CollectionsScreen's role="alert"
- §5c now cross-links §3c, since that is the section a screen author reads before wiring AddToMenu
- the sweep count is 67 caller-owned + ConfirmDialog's own internal <Dialog>
Three other media/addTo dialogs remain unpinned; they are identical in shape to the covered one,
which is a reason to expect the same behaviour, not evidence of it. Said so in the record.
refs #830, #877
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY
|
||
|
|
9ba397e1dd |
fix(830): a write failure reports to a surface that outlives the dialog that started it
`AddItemsDialog.submit` POSTed to `/api/v1/collections/{id}/items` and reported failure into a
banner rendered from its OWN state. The dialog is dismissible mid-request through three paths that
never consult `adding` -- Escape and a backdrop click (both `useOverlayBehavior`) and the header
close button -- and the caller remounts it on `key={`add-${pickerOpen}`}`, so dismissal genuinely
unmounts it. Select 12 items, Add, press Escape, the request fails: nothing surfaces, the list
reloads unchanged, and the user believes 12 items were added.
drop deliberate rather than accidental. A deliberate drop is still a user who is told nothing.
The asymmetry is the finding: SUCCESS already outlived dismissal everywhere here, because it is
reported through a parent callback (`onAdded`/`onDone`, which the screens turn into a `Toast`).
Only failure died with the surface. So this is not a new notification system -- it routes failure
through the channel success already uses. `AddToMenu` had `onDone` and no counterpart at all.
`useDismissSafeError` (`web/src/hooks.ts`) renders the message INLINE while the surface is mounted
-- the better surface, since it keeps the user's selections and context -- and diverts to a
caller-supplied `onFailed` once it is gone. The surviving surface belongs to the parent and differs
per screen (a `role="alert"` banner on CollectionsScreen, `notice`+`Toast` on MediaBrowse/Search),
so it is a prop contract rather than a rendering decision. Gating dismissal on the busy flag was
considered and rejected: it traps the user behind an in-flight request with no cancel path, and
would not cancel the write anyway.
Applied to the A1 shape -- where the surface owns the error state and is really unmounted:
AddItemsDialog plus the four `web/src/media/addTo/` dialogs, whose failures previously could not
reach the screen Toast that already showed their successes.
Proofs, executed rather than described:
- deleting `reportRef.current(message)` alone reddens the new CollectionsScreen test on
`Unable to find an element with the text: Request failed with status 500`
- `hooks.test.tsx` pins both branches directly, plus that the report goes through the LATEST
callback rather than the one captured on first render
- `CollectionsScreen.guards.test.tsx`'s is-mounted read count moves 2 -> 1 because the catch's
guard migrated into the hook (its `...actual` module mock cannot see the hook's internal
`useIsMountedRef()`); removing the surviving `finally` guard takes it to 0 and reddens, so the
anti-masking property that count was added for is intact
Scope is stated rather than implied. A sweep of all 68 Dialog/ConfirmDialog/SlideOver call sites
found three shapes; only A1 is fixed here. A2 -- error state that survives but whose render site is
gated by the same condition dismissal clears, mostly delete-confirm flows -- is left open in #877
because its right answer is probably a shared surface, not twenty prop threads.
fixes #830
refs #877, #740, #685
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XE2tF2aUasK2hWPmBRsrMY
|
||
|
|
40a3232d9e |
feat(734): field-level progressive disclosure — shared FieldHelp trigger + panel (#841)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 15s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m57s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m32s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m18s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m27s
Implements the three-level field-help pattern from #734 as a shared component: field name + optional one-sentence summary → a one-short-paragraph panel behind a consistent Info-icon trigger → a future external-docs deep link (`docsHref`, built and typed; no screen passes one yet). Adopted on FFmpegProfilesScreen (9 fields), documented as docs/spa-conventions.md §15 with decision record `spa.field-progressive-disclosure`, and mirrored into the design-system prototype. The panel is portalled to document.body: `.ctv-card` sets `overflow: hidden`, which clips a positioned descendant whatever its z-index, and one field's explainer rendered 12px of a 92px paragraph in every state of the Audio card. A `::before` hover bridge was added and then WITHDRAWN — it held for a vertical descent onto the panel and failed for a diagonal one, leaving a safe sideways exit of 1.25px on an 18px icon. Hover reads the paragraph in place; the panel's interactive content is reached by pinning. Four cold adversarial review rounds; the first three returned BLOCKED. They found five wrong copy claims across nine paragraphs and two vacuous tests in a row for the same mechanism. Deferred with owners: #839 (placement verified by hand, not by a test) and #840 (the portal puts a docsHref link at the end of the tab order). fixes #734 Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be> |
||
|
|
39c4e8df0a |
fix(651): review round 8 — put the selection-id predicate at the boundary, not the site
Round 7 added the int32 check inside `isSearchPickerOption` — the place the defect was found — which left every other door into editor state open. A malformed successful response carrying `1.5` or `2147483648` still entered `draft` through list-backed options and through the selection restored from the detail read, keeping Save enabled and sending a value the API cannot bind, while the identical value arriving via SearchPicker was correctly rejected. The predicate now lives once, in `web/src/api/selectionId.ts`, and sits on every path by which an id from the wire becomes editor state. The class crosses all three screens, not just the one the finding named, so all three are covered: - RerunCollectionsScreen: `toPickerOptions` (3 list branches) + `draftFromRerun` - PlaylistsScreen: `toPickerOptions` (3 list branches) + `draftFromItem` (4 id fields) - FillerPresetsScreen: `draftFromPreset` (5 id fields) + the collection-family browse options - pickers.tsx: `isSearchPickerOption` now delegates rather than carrying its own copy An unbindable id is treated as ABSENT, never coerced — rounding 1.5 to 1 would submit a DIFFERENT record — so it surfaces as "no selection" with Save disabled and a visible reason; an option that cannot be selected safely is dropped rather than rendered. Five regressions assert zero writes are reachable via each previously-unguarded path. Also corrects two of my own test descriptions, per the review: the padded-ETag test is a regression guard rather than a round-7 defect demonstration (Headers strips outer whitespace before the app sees it), and the late-settlement test guards the abort/race COMPOSITION — what it actually fails is an abort-only implementation whose fetch ignores its signal, which is why its stub ignores `init.signal`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e605e4006a |
fix(651): review round 7 — treat "no usable token" as one class, not three values
HIGH: the fail-closed gate rejected `null` but not the adjacent values. `Headers.get('ETag')`
returns `''` for an empty or whitespace-only header, which PASSED the gate and produced an
editable draft; `updateRerunCollection`'s `ifMatch ? … : undefined` then dropped the empty
string as falsy and sent no `If-Match`, silently overwriting a collaborator — the exact class
the gate exists to make unreachable, reached through the value next door. Absent, empty and
whitespace are now one case ("no usable concurrency token"), normalized by a single
`usableEtag` helper that returns the TRIMMED token or null, so `etagRef` can only ever hold
something that will actually be sent. Tested across four blank shapes asserting zero PUTs are
reachable, plus a padded ETag that must be trimmed and USED rather than dropped.
MEDIUM: the deadline abandoned the wait without cancelling the work, so each Retry stacked
another live connection. It now aborts via an AbortSignal (threaded through
`getRerunCollectionWithMeta`) AND clears its timer on settlement and unmount. Both halves are
kept deliberately: aborting cancels the work, while the rejected race stops the UI waiting
even if the abort never propagates — cancellation and giving-up are not the same guarantee,
which the late-settlement test proves by using a stub that ignores its signal.
MEDIUM: `Number.isFinite` accepted ids the API cannot bind — `1.5` and values outside int32
rendered, committed through `onSelect`, and would fail server-side on `selectedId`. Validated
as an int32 integer.
MEDIUM: a malformed or failed page was reported as "No matches", telling the user the library
is empty when the request actually failed and giving no hint that reopening retries. Failures
now surface as a distinct alert.
MEDIUM: `spa-conventions.md` still mandated the deleted "never let a refresh clear an id it
failed to name" guard and said "the client guard stays" — contradicting the initialize-once
bullet 20 lines below it. Rewritten to state that the guard is gone and must not be rebuilt,
with the reason (it only ever preserved a list-seeded value that is null in production).
Grepping the DELETED TERMS across all docs — the lesson from round 6's stale `rule:` — also
caught two stale `signals:` tokens on the record that the rule fix had missed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
a973fc48e2 |
fix(651): review round 6 — fail closed on a missing ETag; validate elements, not containers
HIGH: "the draft is only created alongside the ETag" was not the invariant I claimed. The response can OMIT the header, in which case the draft was still created and the subsequent PUT carried no `If-Match` — the round-4 force-write hole in a new form. The editor now fails closed: no concurrency token, no editable draft (error + Retry/Back). Six tests were leaning on ETag-less detail mocks, which is exactly how this stayed invisible; every single-record GET mock now goes through a `detailResponse` helper that always sets one, and the absent case is tested explicitly — asserting zero PUTs are even reachable. MEDIUM: the detail GET had neither deadline nor recovery affordance, so a never-settling request left a bare spinner with no way out. It is now raced against a 15s deadline, the loading view carries a Back control, and the error view offers Retry. MEDIUM: the malformed-body guard checked the container, not the elements. `[null]` passes `Array.isArray`, reaches `setResults`, and throws on `option.id` during render; a wrong-typed `id` would commit an invalid value through `onSelect`. Each element is now validated, and a malformed payload is treated as a failed attempt so it stays retryable rather than cached. MEDIUM: the decision record's `rule:` — the authoritative string, copied verbatim into the catalog that is the documented entry point — still mandated the machinery round 5 deleted: touched-field hydration, `replaceDraft`, conflict reconciliation. Anyone following it would have rebuilt the rejected design. Rewritten to the initialize-once policy and the catalog regenerated; historical prose no longer says `replaceDraft` "is now" separate. MEDIUM: the replacement Reload test resolved its second GET immediately and returned a non-null selection, so it observed neither a pending reload nor the dirty-selection discard — removing `setDraft(null)` could leave it green. It now holds the reload open, asserts the form is ABSENT while pending, and returns `selectedId: null` to pin the case round 3 showed could resurrect a dirty id over a collaborator's change. Checklist item taken from this: when a mechanism is deleted, the decision record's `rule:` is the single most likely thing left stale, and the one string that propagates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
78cd9e0ebf |
fix(651): review round 5 — delete the draft-reconciliation layer instead of extending it
Took the coordinator's proposal. Rounds 2-4 built and rebuilt a layer that merged a late detail response into a draft the user was already editing; it produced a HIGH finding in three consecutive rounds, three of them cross-user lost updates. Round 5's finding was unfixable in kind: `identityConflicts` had no immutable baseline, so it could not tell "the user changed this" from "the server changed this" — giving both a missed conflict (same type, different id: v2 ETag installed over the user's id, third lost update) and a false one (local type switch: dialog opens spuriously, and "Keep editing" leaves `etagRef` null so the next PUT is a silent FORCE-WRITE). The race is removed rather than refereed. `RerunCollectionEditor` now initializes its draft EXACTLY ONCE from the detail GET and withholds the form until it lands; conflicts are detected at save time by the `If-Match` -> 412 -> Reload path that already existed. Deleted: `touchedRef`, `hydrateDraft`, `hydrateSelection`, `hydrateIdentity`, `identityOf`, `identityConflicts`, `replaceDraft`, `replacePending` and the hydrate/replace load mode. Reload simply sets the draft back to null and re-runs the same load, so the form is unmounted while the replacement is in flight — round 4's MEDIUM-4 becomes structural. Two facts make this lossless rather than a regression. The list row could never have helped: `GetPagedRerunCollectionsHandler` applies ZERO `.Include()`s where `GetRerunCollectionByIdHandler` applies fourteen, and both project through the same mapper, so the list response is a strict SUBSET of the detail one — the id round 1 preserved from it is null in production for every row (#671), and existed only in test fixtures. And FillerPresetsScreen/PlaylistsScreen already worked this way; RerunCollections was the outlier, which is why nearly every finding in rounds 3-5 traced to it. The ETag is now written in the same callback that sets the draft, so `draft != null` implies an ETag and a PUT without `If-Match` is unreachable by construction. MEDIUM-2: a failed search retried every debounce forever — a fresh `{ok:false}` re-ran the effect and the success guard declined it. `ok` (is the held answer authoritative) is now separate from `attemptRef` (have we already tried this exact source+query); only an explicit reopen/focus/edit re-arms a retry. MEDIUM-3: a malformed 2xx body resolves as `undefined` (client.ts swallows the SyntaxError), and `setResults(undefined)` threw on the next render. A non-array is now treated as a failed attempt, and `search` is raced against a 10s deadline since a caller-supplied promise carries no abort signal. Two tests were hiding findings and are fixed: the failed-search test pressed Escape before the unintended retry could fire, and the round-4 conflict test claimed to select id 9 while firing the already-selected id 5. Fourteen tests of the deleted machinery are gone with it, replaced by six asserting the new invariants. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f601d957a6 |
fix(651): review round 4 — an id never travels without its namespace
Round 4's through-line: rounds 2-3 made HYDRATION treat {collectionType, selectedId,
selectedName} as one value, but the fix was applied to the structure that was named rather
than to every structure that carries an id. Three more instances of the same class, plus
two independent findings.
HIGH-1 + HIGH-2 (one change, per the structural directive): every result, option and
cached result set now carries its SOURCE, and identity is compared as (type, id).
- `SearchPicker` takes a REQUIRED `source` prop and caches results against (source, query),
not query text. Keying on text let the round-3 re-query guard SUPPRESS the new source's
request and leave the previous namespace's hit clickable under the new label — a
Collection id stored as a SmartCollection id. Results from another source are now hidden
outright rather than dimmed: they are not stale, they are wrong. Required rather than
defaulted, because a default would silently opt every caller out.
- `pickerFor` tags list-backed options with the type they were loaded for, on both
RerunCollectionsScreen and PlaylistsScreen, so the previous type's rows stop being
selectable the instant the active type changes rather than lingering through the
replacement load.
HIGH-3: a touched identity contradicting the server's type is a CONFLICT. Pinning the
user's edit was right; adopting the response's newest ETag alongside it authorized a Save
that silently overwrote the collaborator's type change with no 412. The conflict dialog is
raised and the stale ETag kept, so even a forced Save 412s. With round 3's Reload defect
this is the second cross-user lost update, so it is recorded as a category: never install a
save-authorizing ETag over a local edit the server contradicts.
MEDIUM-4: the editor is inert while a Reload is pending. The dialog closes immediately, so
an edit typed before the replacement landed was silently erased along with the touched set
that protected it.
MEDIUM-5: cached search provenance records `ok`, so a transient 500 is retried instead of
being cached as an authoritative "No matches" that reopening can never clear.
Also: `npx tsc --noEmit` typechecks NOTHING in web/ — the root tsconfig is solution-style
("files": [] + references), so it resolves to zero inputs and exits 0. The real gate, and
what CI runs, is `npm run typecheck` (tsc -b). Verified by planting a deliberate type error:
--noEmit stayed green, -b caught it. Running the real gate surfaced four genuine errors in
tests written earlier this branch (a missing required prop and three `never has no call
signatures` from closure-assigned mock variables), fixed with the repo's existing
holder-object pattern rather than casts. Recorded in spa-conventions so the next session
doesn't repeat it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
5b0ba08aab |
fix(651): review round 3 — cover the class, not the named instance
Round 3 found four defects that are all one mistake: each earlier fix enumerated one instance of a class instead of covering the class. Fixed by asking what else is in each class rather than patching the named case. CRITICAL — cross-user lost update. Conflict "Reload" ran through the refresh policy with a touched-set reset. Because a reloaded RemoteStream reports `selectedId: null` under the #671 server gap, the keep-ours-when-the-id-is-missing fallback restored the user's DIRTY selection, the fresh ETag was installed, and the next Save silently overwrote the collaborator's change — with the very edits the user had explicitly asked to discard. "Keep ours when the server omits the id" is a REFRESH policy; a reload is a REPLACE. `replaceDraft` is now a separate function and the mode travels with the load, so the two cannot be confused at the call site. HIGH — `collectionType` and the selection still hydrated apart. `collectionType` says which table an id indexes, so it is part of the same indivisible value as the id and its name; splitting it is the identical bug to splitting id from name. A record retyped server-side mid-load hydrated the new type while retaining the old id, displaying and saving a Collection id as a RemoteStream id. All three fields now resolve as one `Identity` unit: either half touched pins the whole thing, a differing type takes the response's unit whole (null selection included), and only once both sides agree on the type does the id/name rule apply. HIGH — stale results were still committable by pointer. Enter was gated and `onClick` was not: the same defect in another modality. The guard moved into the single `choose()` sink so every commit path is covered, including any added later, and a superseded list is now genuinely inert (`aria-disabled` + dimmed) rather than looking normal and silently no-opping. MEDIUM — reopening after Escape re-queried an already-current result set; the duplicate response reset the cursor the user had since moved, leaving Enter doing nothing. The effect now skips the search when the cached results match the trimmed query, and reopening places the cursor per the ARIA APG instead of swallowing the keypress. Also corrects an overstated justification in FillerPresetsScreen: the render-time id check was dropped because there is no reachable path TODAY, not because "every writer sets both" — the initial load writes the id alone, and a stale resolver can repopulate the label after a clear. Same enumeration error as the findings above; the comment now says what is actually true and what to do if a path appears. Note: an apostrophe I introduced inside the single-quoted `rule:` scalar broke PyYAML while `decisions_validate.py` (hand parser) stayed green — the same trap class as the unquoted `#`, caught only by scripts/tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ba52219a9a |
fix(651): review round 2 — hydrate untouched fields, never merge a selection field-by-field
Re-review of a04d9f0b came back BLOCKED: the F1 merge fixed a visible data loss and introduced a silent one. Four blockers, all web-only. BLOCKER 1: `selectedId` and `selectedName` were coalesced independently, but they are one value. Against a Song response (id resolves, name does not), a user selecting a different song while the detail GET was in flight got the NEW name paired with the STORED id — chip read "New Song", Save wrote 42, no error and no visual cue. Strictly worse than the original defect, which at least cleared the field visibly. The same merge resurrected a deliberate clear and reverted a type switch. Replaced with two rules. The id/name pair resolves atomically (no id in the response -> keep ours whole; same id -> its name may fill ours in; different id -> take theirs whole). And hydration loses every race against the user: a `touchedRef`, fed by a single `edit()` funnel so "touched" cannot drift from "changed", limits the refresh to fields the user has not edited; an explicit conflict-reload clears it, since discarding local edits is its entire purpose. Three interleaving tests hold the detail response open, act as the user, then release it. BLOCKER 2: Enter could commit a result from the previous query — highlight Alpha for "Al", retype "Be", press Enter before the debounce. The highlight now drops on input change rather than when the next response happens to arrive, and every keyboard action is gated on the results matching what is typed. The stale list stays visible (hiding it flickers on each keystroke), it just stops being actionable. BLOCKER 3: Escape closed the popup while focus stayed in the input, where `onFocus` can never re-arm it — the picker was dead until the user blurred and refocused. Typing and ArrowDown now both reopen it. BLOCKER 4: the LCG boundary test recomputed the divisor instead of exercising `lcg`, so all three tests passed with the old `/ 0xffffffff`. Since the recurrence is a bijection mod 2^32, the seed whose first step lands on 0xffffffff is solvable exactly (653637408); the tests now drive the real generator into that state. Also: the rerun #id-degrade and re-save tests did not await the refresh, so they were satisfiable from the initial draft; they now await it and re-read live textContent. The Episode/MusicVideo cases are relabelled as error-path guards — a 500 never reaches the hydrate. FillerPresets' render-time id comparison is dropped as unreachable (every writer sets label and id together, and the one async writer refuses a mismatched id); an unreachable guard is an untested one. #671 is referenced from spa-conventions §3b as the server-side root cause the client guard defends against. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e7e425fa25 |
fix(651): review round 1 — never clear an unnamed id, complete the Lucene escaping, keyboard-operable picker
Cold cross-family review of 57aefcdf. Six findings, all web-only. F1 (HIGH, data-loss shaped): RerunCollectionsController.ProjectToResponseModel derives BOTH selectedId and selectedName from the same eager-loaded navigation, and GetRerunCollectionByIdHandler loads media metadata only for Show/Season/Artist/Movie while MediaCollections/Mapper maps RemoteStream through `_ => null`. So opening a RemoteStream rerun collection returned HTTP 200 with a null selection and the edit-load refresh CLEARED a stored id, leaving Save permanently disabled. The refresh now merges instead of replacing, so no path can clear an id it merely failed to name; the label degrades to `#id`. Covered per affected type — RemoteStream, Episode, MusicVideo, Song, OtherVideo, Image — plus a re-save assertion. The read-model gaps themselves are server-side and are NOT touched here. F2: `&` and `|` were missing from the escaped set, so `Rock && Roll` compiled with the boolean operator live. Pre-existing in Auto-Tune's original helper, but propagated to three more pickers — and now fixed for Auto-Tune too, since the helper is shared. The test that claimed to cover "every Lucene special" carried its own hand-copied sample and could not see its own omissions; it is now driven per-character off an exported LIBRARY_PICKER_LUCENE_SPECIALS. F3: a slow edit-load name resolution could relabel a newer selection. The label is now keyed to the id it was resolved for AND refuses to overwrite a label naming a different id — keying the render alone stops the mislabelling but discards the correct new label. F4: searchLibraryPickerOptions clamps pageSize instead of merely defaulting it. A bound a caller can exceed is not a bound. F6: replacing a native <select> with an input+listbox dropped keyboard operability. Full ARIA combobox pattern added — role/aria-expanded/aria-controls/aria-autocomplete, Arrow/Home/End over aria-activedescendant, Enter to commit, Escape to dismiss, options as non-tab-stops, cursor reset on each new result set. F7: both is-mounted tests were unsound. React 19 no longer warns on setState-after-unmount and an unmounted tree renders nothing either way, so the DOM assertion could not fail; the hook re-arm test used rerender rather than an effect cleanup. Now: a hook-module mock proving SearchPicker actually reads the guard and sees false, and a StrictMode double-invoke for the re-arm. Both verified by removing the mechanism and watching them fail. Same for the LCG divisor, which now has a direct boundary test. F5 (FillerPresets collection-family names) is filed as #670, not fixed here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fad6805b91 |
feat(651): media-library pickers resolve by search instead of a bounded window
The three `getLibraryBrowseItems` pickers (RerunCollectionsScreen, PlaylistsScreen, FillerPresetsScreen) populated a native <select> from a 100-row window over media-library tables that can hold tens of thousands of rows. #644 made that truncation visible; it did not make the picker usable, and paging to completeness would have been worse than the bug (~200 serial requests, each more expensive than the last). They now resolve by SEARCH through the shared `SearchPicker` over a new `searchLibraryPickerOptions` helper: zero requests on mount or on a type switch, at most ONE bounded request (25 rows) per settled query, nothing below 2 characters. Typed text is compiled via the now-shared `titleContainsQuery` (`title:*<escaped>*`) rather than forwarded raw, since the index's default field does not match bare title words. The current selection renders from the owning record — `selectedName` for rerun collections and playlist items, and for filler presets (which store only an id) a single by-id detail read — so editing an existing record can never lose or fail to name its selection. Class A stays put: bounded-by-construction admin lists still page to completeness via `loadAllPages`, and the collection-family filler-preset types keep their bounded single page (their `query` is a SQL LIKE, which a compiled Lucene query would not match). No server-side cap is raised; this is a web-only change. Folded in from #578: the rule-builder facet typeahead arms on focus rather than on mount (an N-rule tree fired N unrequested lookups), both typeaheads pair their `seqRef` guard with a shared `useIsMountedRef`, and the roundtrip test's LCG divides by 2^32 so `pick()` can no longer index one past the end. Decision record `spa.list-completeness-vs-bounded-pickers` is archived as superseded by the new `spa.library-pickers-resolve-by-search`; spa-conventions §3b rewritten to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
daedf003e5 |
fix(644): round-3 review — split truncated/incomplete picker hints, F2 out-of-list gaps, F3 abort warns, F4 aria wiring, F5 FillerPresetsScreen tests
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 24s
PR Gates / decisions lifecycle (pull_request) Successful in 28s
Review verdict / Set review-verdict status (pull_request) Successful in 6s
PR Gates / Script tests (pytest) (pull_request) Successful in 33s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m19s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m24s
review-verdict/h10 Review-verdict: MERGEABLE @ daedf00
Addresses the round-3 cold re-review's five low-severity findings on #644's client-side paging fix: - F1: `loadPickerOptions` (RerunCollectionsScreen, PlaylistsScreen) returned one `truncated: boolean` for two different conditions — a real Class B cap hit vs an unconverged Class A `loadAllPages` load — so an incomplete multi-collection load rendered the self-contradictory "Showing the first 47 of 47 — use search to narrow." Replaced with a `hint: 'incomplete' | 'none' | 'truncated'` discriminator and distinct copy per value; 'incomplete' matches the wording already used by the Class A list-load warn Badge. - F2: mirrored the out-of-list current-selection injection (RerunCollectionsScreen/PlaylistsScreen's `selectedInList` prepend) into FillerPresetsScreen and ScheduleItemInspector's rerun-collection picker, so an id outside the loaded page still renders as selected instead of misrepresenting the stored value as "(none)". - F3: gated the `console.warn` on an incomplete Class A load with `!signal?.aborted` in the `multi` branches (RerunCollectionsScreen, PlaylistsScreen) and SchedulesScreen.loadAllRerunCollections, so a superseded/aborted load (Retry, or a type switch mid-load) no longer logs a false warning. - F4: added `Select`'s `ariaDescribedBy` prop and wired the truncation/incomplete hint span to it via `useId()` in RerunCollectionsScreen and PlaylistsScreen, so screen readers announce the hint (FillerPresetsScreen already routed it through `Row help=`). - F5: added FillerPresetsScreen.test.tsx (previously untested) covering the Class B single-request guarantee, the truncation hint's totalCount>100/<=100 boundary, and the F2 injection; added the two assertions the re-review found missing anywhere in the suite — the Class A `incomplete` warn Badge actually rendering, and a screen-level seqRef stale-overwrite race — to RerunCollectionsScreen.test.tsx. Updates docs/spa-conventions.md §3b and the spa.list-completeness-vs-bounded-pickers decision record to describe the hint discriminator. Decisions-Edit: yes |
||
|
|
94182cdd53 |
fix(644): split loadAllPages by list class; bound media-library pickers to one page
Cold adversarial review of
|
||
|
|
fba5233caf |
feat(610): split the decision corpus into one YAML-frontmatter file per record
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and docs/decisions/archive/<area>/<topic>.md (5 archived). The filename IS the key, so one-active-record-per-key becomes a filesystem property rather than a validator check, and supersession becomes a `git mv`. WHY: the monolith was a concurrency problem before an aesthetic one. A 3,900-line append target made parallel sessions collide -- PR #605 and PR #614 both hit append-vs-append conflicts during routine rebases, and hand-resolving those inside the corpus is exactly the operation the rationale-rewrite guard exists to police. HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness does not rest on reading it. The parser was taught BOTH formats first, so the body-diff guard parses the old form at the merge-base and the new form at head -- the migration validates itself, no bypass. The proof is a field-level equivalence harness: 168 records before and after, zero lost, zero gained, zero field mismatches, zero rationale bodies differing. Reviewers should scrutinise the harness; it is the actual evidence. What measuring caught that reading would not have: - ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each topic file's preamble, mostly the only copy. Source files are kept and stripped, never deleted. They also cannot be filed per-area: topic files hold several areas and 4 of 23 areas span several files. - Archive discovery was a non-recursive glob; after the split it found ZERO archived records, surfacing as four bogus "supersedes points to unknown key" errors rather than an obvious failure. - ~32 live docs point into the corpus BY DATE, which the split dangles. Each stripped file now ends with a generated "Records formerly in this file" index, which also rescues the identical breadcrumbs in old issue comments. - decisions.md's "In this file:" list was 97 same-file anchor bullets that the split makes WRONG, not merely stale. Dropped; the generated index replaces them with links that resolve. The equivalence harness now runs against a checked-in FIXTURE, not the live corpus. The earlier version migrated the real tree, which made it a one-shot: the moment the migration landed there was nothing left to move and the tests failed for reasons unrelated to the code. A fixture keeps them testing the SCRIPT rather than the repo's current state. Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain two directories for one concept. Renaming a key is not a move -- it changes identity, breaks the equivalence proof, and invalidates MemPalace's per-key drawers. Taxonomy normalisation is separate work. refs #610 |