Scripted playout: in-process integration-test harness for the engine build API (#563) #915

Merged
timothy merged 11 commits from 563-scripted-playout-integration-harness into main 2026-09-05 10:43:20 +02:00
Owner

fixes #563

Root cause / why the deferral existed

ScriptedPlayoutBuilder doesn't build items in-process: it shells out via Cli.Wrap to an
external script process that drives SchedulingEngine over HTTP loopback, so the shared
in-memory-SQLite golden harness from #381 fundamentally does not fit it (external process +
HTTP callback, no way to pin a clock or avoid real I/O). #563 offered two arms — a full
process+Kestrel integration harness, or expanded SchedulingEngineTests with the shell-out
explicitly scoped out. This PR takes the second arm, but delivers the first arm's "documented
in-process stand-in" as a measured claim rather than a prose one, replaying a committed script
fixture through the real ScriptedScheduleController + MockSession + SchedulingEngine.

What changed

  • SchedulingEngineTests grows from 1 test to 21: AddCollection/AddCount/AddAll/
    AddDuration/PadUntilExact, EPG guide-group locking (with per-item advancement pinned, not
    just endpoint comparison), per-item history, the 20-call no-progress halt and its reset, and
    the anchor round-trip a Continue build restores from.
  • ScriptedScheduleControllerTests replays Fixtures/scripted-build.json through the production
    controller and pins a 13-item snapshot in the golden line format — one action→engine mapping
    under test, the production one — plus the three mappings the predecessor record's "1:1
    pass-through" wording hid: 404 on an unknown build id, 400 on an unparseable playback order, a
    silent fallback to FillerKind.None on an unparseable filler kind, and the engine's
    InvalidOperationException → 400 translation.
  • The fixture is now bound the way production actually binds /api/* bodies. Production binds
    with Newtonsoft (Startup → AddNewtonsoftJson → CustomContractResolver +
    StringEnumConverter); the replay originally used System.Text.Json, and measurement showed
    the two diverge on a body omitting a required member. The Newtonsoft configuration moved into
    ErsatzTV/Serialization/ApiJsonSettings.cs; Startup, OpenApiSerializerContractTests, and the
    replay all now call that one function — no copy left to drift. ApiJsonSettings.Create()
    returns a standalone serializer sharing production's MissingMemberHandling,
    TypeNameHandling, DateParseHandling and resolver, but it is not MVC's settings object:
    MaxDepth stays Newtonsoft's 64 rather than MVC's 32, and the two MVC-only converters
    (ProblemDetailsConverter, ValidationProblemDetailsConverter) are absent. ApiJsonSettingsTests
    pins that delta in both directions rather than leaving it as a claim.
  • docs/decisions/records/testing/scripted-engine-in-process-net.md (active) supersedes
    testing.scripted-playout-golden-deferred, which is git mv'd to docs/decisions/archive/testing/
    with only its frontmatter retargeted (body prose byte-identical, no Decisions-Edit needed for
    that commit). docs/decisions.md and the catalog are updated/regenerated accordingly; the
    orphaned docs/decisions.md fragment still naming #563 as owning the external-process pipeline
    is removed, since the pipeline is now permanently out of the automated suite rather than deferred.
  • docs/testing.md gets a new "Scripted playout coverage" section stating what is covered where
    and what is deliberately not (Cli.Wrap launch, Kestrel + Startup middleware,
    ApiAuthorizationFilter), plus the timezone-independence rationale for the fixtures.
  • Stale breadcrumbs pointing at #563 as the open reason (ContentEnumeratorBuilderTests.cs header,
    the ExpectedSnapshot comment mislabelling the MediaItemId column as "the seeded title") are
    corrected.
  • Three CS8600/CS8604/CS8603 nullable warnings the replayer helpers introduced are cleared.

Measured numbers

  • SchedulingEngineTests: 1 → 21 tests, all TZ-independent by construction (Chronological order,
    only instant-preserving instructions) and verified passing (not skipped) under TZ=UTC, America/New_York, Australia/Lord_Howe, Asia/Kathmandu.
  • ScriptedScheduleControllerTests: 9 tests, replaying a committed fixture through the real
    controller.
  • Mutation proofs (re-measured 2026-09-05, mutate → run → restore from git → verify clean):
    • ScriptedScheduleController.PadUntilExact(..., request.Trim, ...) -> false — 1 Failed, 8
      Passed
      (Committed_Script_Fixture_Produces_The_Pinned_Snapshot reddens). Re-proved again as
      part of this finishing pass, output: Failed: 1, Passed: 8, Skipped: 0, Total: 9. This closes
      the round-two finding: before the fixture's pad_until_exact target moved off the 10:00:00
      content boundary (to 09:55:00), this same mutant left all 9 tests green.
    • AddDuration(..., request.Trim, ...) -> false — 1 Failed, 8 Passed (sibling trim mutant,
      already covered).
    • Bind<T> -> System.Text.Json with web defaults — 2 Failed, 7 Passed
      (Production_Body_Binder_Ignores_Required_Members,
      Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null).
    • BodyBinderSettings -> new JsonSerializerSettings() — 1 Failed, 8 Passed.
    • OpenApiSerializerContractTests RuntimeSettings -> new() — 4 Failed, 0 Passed.
  • ApiJsonSettings.Create() vs MVC's binder settings, probed in-tree (temporary fixture, since
    removed): MaxDepth prod=32 standalone=64; Converters prod=3 standalone=1 (missing
    ProblemDetailsConverter, ValidationProblemDetailsConverter); MissingMemberHandling,
    TypeNameHandling, DateParseHandling and the resolver match. Behaviourally inert for the
    scripted DTOs (max nesting depth 2) — now stated as such everywhere parity had been claimed.

Local gate (this finishing pass, 2026-09-05)

  • dotnet build ErsatzTV.sln -c Debug: 0 Warning(s), 0 Error(s).
  • dotnet test on all five test projects touched or covering ErsatzTV.Core/ErsatzTV/Serialization:
    • ErsatzTV.Tests: Passed 2111, Skipped 6, Failed 0.
    • ErsatzTV.Core.Tests: Passed 716, Skipped 1, Failed 0.
    • ErsatzTV.Infrastructure.Tests: Passed 114, Failed 0.
    • ErsatzTV.FFmpeg.Tests: Passed 83, Failed 0.
    • ErsatzTV.Scanner.Tests: Passed 1504, Failed 0.
    • ErsatzTV.Mcp.Tests: Passed 61, Failed 0.
    • ErsatzTV.Architecture.Tests: Passed 7, Failed 0.
  • BOM check (od -A n -t x1 -N 3) on every touched .cs file: no BOM.
  • dotnet format whitespace . --folder --verify-no-changes --include <touched files>: clean, no output.
  • PYTHONPATH=. python3 scripts/decisions_validate.py: decisions-validate: OK (exit 0); new
    record is 65 prose lines (trimmed under this branch's own round-3 fix from the 70 flagged
    earlier).
  • PYTHONPATH=. python3 scripts/build_decisions_catalog.py: no diff — catalog already regenerated
    and in sync.
  • python3 scripts/check-doc-narrative.py --diff origin/main: 0 advisory warnings across 3 files.
  • No web/, scripts/, .claude/, .husky/, or .gitea/ files touched by this branch — SPA gate
    and scripts/tests pytest gate not applicable.
  • Live-E2E not applicable (no write-path or screen change; this is a test-only + serializer-wiring
    branch).
  • origin/main had not moved since this branch's merge-base (8575c02ad) at finishing time — no
    rebase was needed, so the reviewed patch-id (433fbd5ef1bc00793e706665b754f88490f5d4c5) is
    unchanged from what round 2 reviewed.

Cross-family review status

not required (routine risk class under process.independent-review-rubric)

Deliberately left / out of scope

  • The Cli.Wrap/HTTP shell-out itself (launching the external script process, the real Kestrel
    endpoint, ScriptedPlayoutBuilderService.StartSession over loopback) remains untested by this
    harness — that is the arm of #563 this PR does not take. Stated explicitly in
    docs/testing.md's new "Scripted playout coverage" section as deliberately out of scope, not
    silently dropped.
  • The MVC input-formatter wrapper (the automatic [ApiController] 400 that runs before an action,
    and MVC's own model-validation required-member check) is not covered by the replay — only the
    serializer inside it is. Named as residue in ApiJsonSettings.cs, the test docstrings,
    docs/testing.md, and the decision record's rule:.
  • Drift confined to ReferenceLoopHandling or the StringEnumConverter between production and the
    standalone serializer is witnessed by neither this suite nor OpenApiSerializerContractTests —
    stated as a gap in the record rather than implied as covered.
  • A pre-existing 401 blind spot on ScriptedScheduleController (no [SkipApiAuthorization], and
    neither ScriptedPlayoutBuilder nor entrypoint.py supplies a credential) surfaced during
    measurement for this branch and is filed separately as ersatztv#913 rather than fixed here.

<<REVIEW HISTORY
round 1: 2 lens(es); 1 blocking, 0 should-fix, 2 nit; answered by fix commit(s): 4454f1adf docs(563): anchor the measurements to the issue, which a squash merge cannot erase | 3df98d247 docs(563): state each binder test's mechanism instead of its mutant colour, per the amended CLAIMS rule | d6e4426aa docs(563): retire the forward-references the supersession falsified, and trim the record to what only it says | 053982180 test(563): witness the trim flag on both trimming actions, and stop the binder claiming parity with MVC's | c0a70d724 test(563): witness the Newtonsoft half of the binder claim, and narrow the prose to what reddens | b6ac7febb style(563): rewrap the residue paragraph in the ScriptedScheduleControllerTests docstring | 4bd5cf9e9 docs(563): scope the required-member claim to the serializer, and name model validation as residue | 16a6e2790 test(563): bind the fixture with the production body binder, and name the wrapper that is left | 0d2cd8978 docs(563): supersede the scripted-golden deferral with the in-process coverage rule | b3bc3ed11 test(563): pin per-item guide-group advancement, which mutant b showed was unmeasured | 02ecd484e test(563): characterize the scripted engine build API in-process and replay a committed script fixture through the real controller
round 2: 2 lens(es); 0 blocking, 0 should-fix, 4 nit; clean — loop ended
REVIEW HISTORY>>

🤖 Generated with Claude Code

https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV

fixes #563 ## Root cause / why the deferral existed `ScriptedPlayoutBuilder` doesn't build items in-process: it shells out via `Cli.Wrap` to an external script process that drives `SchedulingEngine` over HTTP loopback, so the shared in-memory-SQLite golden harness from #381 fundamentally does not fit it (external process + HTTP callback, no way to pin a clock or avoid real I/O). #563 offered two arms — a full process+Kestrel integration harness, or expanded `SchedulingEngineTests` with the shell-out explicitly scoped out. This PR takes the second arm, but delivers the first arm's "documented in-process stand-in" as a measured claim rather than a prose one, replaying a committed script fixture through the *real* `ScriptedScheduleController` + `MockSession` + `SchedulingEngine`. ## What changed - `SchedulingEngineTests` grows from 1 test to 21: `AddCollection`/`AddCount`/`AddAll`/ `AddDuration`/`PadUntilExact`, EPG guide-group locking (with per-item advancement pinned, not just endpoint comparison), per-item history, the 20-call no-progress halt and its reset, and the anchor round-trip a `Continue` build restores from. - `ScriptedScheduleControllerTests` replays `Fixtures/scripted-build.json` through the production controller and pins a 13-item snapshot in the golden line format — one action→engine mapping under test, the production one — plus the three mappings the predecessor record's "1:1 pass-through" wording hid: 404 on an unknown build id, 400 on an unparseable playback order, a silent fallback to `FillerKind.None` on an unparseable filler kind, and the engine's `InvalidOperationException` → 400 translation. - The fixture is now bound the way production actually binds `/api/*` bodies. Production binds with Newtonsoft (`Startup` → `AddNewtonsoftJson` → `CustomContractResolver` + `StringEnumConverter`); the replay originally used `System.Text.Json`, and measurement showed the two diverge on a body omitting a `required` member. The Newtonsoft configuration moved into `ErsatzTV/Serialization/ApiJsonSettings.cs`; `Startup`, `OpenApiSerializerContractTests`, and the replay all now call that one function — no copy left to drift. `ApiJsonSettings.Create()` returns a *standalone* serializer sharing production's `MissingMemberHandling`, `TypeNameHandling`, `DateParseHandling` and resolver, but it is **not** MVC's settings object: `MaxDepth` stays Newtonsoft's 64 rather than MVC's 32, and the two MVC-only converters (`ProblemDetailsConverter`, `ValidationProblemDetailsConverter`) are absent. `ApiJsonSettingsTests` pins that delta in both directions rather than leaving it as a claim. - `docs/decisions/records/testing/scripted-engine-in-process-net.md` (active) supersedes `testing.scripted-playout-golden-deferred`, which is `git mv`'d to `docs/decisions/archive/testing/` with only its frontmatter retargeted (body prose byte-identical, no `Decisions-Edit` needed for that commit). `docs/decisions.md` and the catalog are updated/regenerated accordingly; the orphaned `docs/decisions.md` fragment still naming #563 as owning the external-process pipeline is removed, since the pipeline is now permanently out of the automated suite rather than deferred. - `docs/testing.md` gets a new "Scripted playout coverage" section stating what is covered where and what is deliberately not (`Cli.Wrap` launch, Kestrel + Startup middleware, `ApiAuthorizationFilter`), plus the timezone-independence rationale for the fixtures. - Stale breadcrumbs pointing at #563 as the open reason (`ContentEnumeratorBuilderTests.cs` header, the `ExpectedSnapshot` comment mislabelling the `MediaItemId` column as "the seeded title") are corrected. - Three CS8600/CS8604/CS8603 nullable warnings the replayer helpers introduced are cleared. ## Measured numbers - `SchedulingEngineTests`: 1 → 21 tests, all TZ-independent by construction (Chronological order, only instant-preserving instructions) and verified passing (not skipped) under `TZ=UTC, America/New_York, Australia/Lord_Howe, Asia/Kathmandu`. - `ScriptedScheduleControllerTests`: 9 tests, replaying a committed fixture through the real controller. - Mutation proofs (re-measured 2026-09-05, mutate → run → restore from git → verify clean): - `ScriptedScheduleController.PadUntilExact(..., request.Trim, ...) -> false` — **1 Failed, 8 Passed** (`Committed_Script_Fixture_Produces_The_Pinned_Snapshot` reddens). Re-proved again as part of this finishing pass, output: `Failed: 1, Passed: 8, Skipped: 0, Total: 9`. This closes the round-two finding: before the fixture's `pad_until_exact` target moved off the 10:00:00 content boundary (to `09:55:00`), this same mutant left all 9 tests green. - `AddDuration(..., request.Trim, ...) -> false` — 1 Failed, 8 Passed (sibling trim mutant, already covered). - `Bind<T> -> System.Text.Json` with web defaults — 2 Failed, 7 Passed (`Production_Body_Binder_Ignores_Required_Members`, `Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null`). - `BodyBinderSettings -> new JsonSerializerSettings()` — 1 Failed, 8 Passed. - `OpenApiSerializerContractTests` `RuntimeSettings -> new()` — 4 Failed, 0 Passed. - `ApiJsonSettings.Create()` vs MVC's binder settings, probed in-tree (temporary fixture, since removed): `MaxDepth prod=32 standalone=64`; `Converters prod=3 standalone=1` (missing `ProblemDetailsConverter`, `ValidationProblemDetailsConverter`); `MissingMemberHandling`, `TypeNameHandling`, `DateParseHandling` and the resolver match. Behaviourally inert for the scripted DTOs (max nesting depth 2) — now stated as such everywhere parity had been claimed. ## Local gate (this finishing pass, 2026-09-05) - `dotnet build ErsatzTV.sln -c Debug`: **0 Warning(s), 0 Error(s)**. - `dotnet test` on all five test projects touched or covering `ErsatzTV.Core`/`ErsatzTV/Serialization`: - `ErsatzTV.Tests`: Passed 2111, Skipped 6, Failed 0. - `ErsatzTV.Core.Tests`: Passed 716, Skipped 1, Failed 0. - `ErsatzTV.Infrastructure.Tests`: Passed 114, Failed 0. - `ErsatzTV.FFmpeg.Tests`: Passed 83, Failed 0. - `ErsatzTV.Scanner.Tests`: Passed 1504, Failed 0. - `ErsatzTV.Mcp.Tests`: Passed 61, Failed 0. - `ErsatzTV.Architecture.Tests`: Passed 7, Failed 0. - BOM check (`od -A n -t x1 -N 3`) on every touched `.cs` file: no BOM. - `dotnet format whitespace . --folder --verify-no-changes --include <touched files>`: clean, no output. - `PYTHONPATH=. python3 scripts/decisions_validate.py`: `decisions-validate: OK` (exit 0); new record is 65 prose lines (trimmed under this branch's own round-3 fix from the 70 flagged earlier). - `PYTHONPATH=. python3 scripts/build_decisions_catalog.py`: no diff — catalog already regenerated and in sync. - `python3 scripts/check-doc-narrative.py --diff origin/main`: 0 advisory warnings across 3 files. - No `web/`, `scripts/`, `.claude/`, `.husky/`, or `.gitea/` files touched by this branch — SPA gate and `scripts/tests` pytest gate not applicable. - Live-E2E not applicable (no write-path or screen change; this is a test-only + serializer-wiring branch). - `origin/main` had not moved since this branch's merge-base (`8575c02ad`) at finishing time — no rebase was needed, so the reviewed patch-id (`433fbd5ef1bc00793e706665b754f88490f5d4c5`) is unchanged from what round 2 reviewed. ## Cross-family review status not required (routine risk class under process.independent-review-rubric) ## Deliberately left / out of scope - The `Cli.Wrap`/HTTP shell-out itself (launching the external script process, the real Kestrel endpoint, `ScriptedPlayoutBuilderService.StartSession` over loopback) remains untested by this harness — that is the arm of #563 this PR does not take. Stated explicitly in `docs/testing.md`'s new "Scripted playout coverage" section as deliberately out of scope, not silently dropped. - The MVC input-formatter wrapper (the automatic `[ApiController]` 400 that runs before an action, and MVC's own model-validation `required`-member check) is not covered by the replay — only the serializer inside it is. Named as residue in `ApiJsonSettings.cs`, the test docstrings, `docs/testing.md`, and the decision record's `rule:`. - Drift confined to `ReferenceLoopHandling` or the `StringEnumConverter` between production and the standalone serializer is witnessed by neither this suite nor `OpenApiSerializerContractTests` — stated as a gap in the record rather than implied as covered. - A pre-existing 401 blind spot on `ScriptedScheduleController` (no `[SkipApiAuthorization]`, and neither `ScriptedPlayoutBuilder` nor `entrypoint.py` supplies a credential) surfaced during measurement for this branch and is filed separately as **ersatztv#913** rather than fixed here. <<REVIEW HISTORY round 1: 2 lens(es); 1 blocking, 0 should-fix, 2 nit; answered by fix commit(s): 4454f1adf docs(563): anchor the measurements to the issue, which a squash merge cannot erase | 3df98d247 docs(563): state each binder test's mechanism instead of its mutant colour, per the amended CLAIMS rule | d6e4426aa docs(563): retire the forward-references the supersession falsified, and trim the record to what only it says | 053982180 test(563): witness the trim flag on both trimming actions, and stop the binder claiming parity with MVC's | c0a70d724 test(563): witness the Newtonsoft half of the binder claim, and narrow the prose to what reddens | b6ac7febb style(563): rewrap the residue paragraph in the ScriptedScheduleControllerTests docstring | 4bd5cf9e9 docs(563): scope the required-member claim to the serializer, and name model validation as residue | 16a6e2790 test(563): bind the fixture with the production body binder, and name the wrapper that is left | 0d2cd8978 docs(563): supersede the scripted-golden deferral with the in-process coverage rule | b3bc3ed11 test(563): pin per-item guide-group advancement, which mutant b showed was unmeasured | 02ecd484e test(563): characterize the scripted engine build API in-process and replay a committed script fixture through the real controller round 2: 2 lens(es); 0 blocking, 0 should-fix, 4 nit; clean — loop ended REVIEW HISTORY>> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
timothy added 11 commits 2026-09-05 10:19:44 +02:00
#381 deferred Scripted from the playout golden net because ScriptedPlayoutBuilder
shells out via Cli.Wrap to a user-authored program that drives SchedulingEngine
over HTTP loopback. #563 offered two arms: a full process+Kestrel integration
harness, or expanded engine coverage with the shell-out scoped out.

This takes the second arm, but delivers the first arm's "documented in-process
stand-in" so the scope-out is a measured claim rather than a prose one:

- SchedulingEngineTests grows from 1 test to 21, covering AddCollection/AddCount/
  AddAll/AddDuration/PadUntilExact, EPG guide-group locking, per-item history,
  the 20-call no-progress halt and its reset, and the anchor round-trip a
  Continue build restores from. Unknown-content-key cases assert false AND that
  nothing was scheduled.
- ScriptedScheduleControllerTests replays Fixtures/scripted-build.json through
  the real ScriptedScheduleController + ScriptedPlayoutBuilderService.MockSession
  + SchedulingEngine and pins a 13-item snapshot in the golden line format, so
  there is exactly one action->engine mapping under test — the production one. It
  also pins the three mappings the predecessor record's "1:1 pass-through"
  wording hides: 404 on an unknown build id, 400 on an unparseable playback
  order, a SILENT fall back to FillerKind.None on an unparseable filler kind, and
  the InvalidOperationException -> 400 translation.

MockSession was declared on IScriptedPlayoutBuilderService with zero callers;
it is the seam this needs and now has one.

Both fixtures are TZ-independent by construction (Chronological order plus only
instant-preserving instructions) and verified passing, not skipping, under
TZ=UTC, America/New_York, Australia/Lord_Howe and Asia/Kathmandu.

Refs #563

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
Removing SchedulingEngine.AddCountInternal's _state.AdvanceGuideGroup() left both
guide-group assertions green: the locked-group test only compared inside/outside
the group, and the snapshot only compared item 2 to item 0. Assert the actual
sequence instead.

Refs #563

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
The deferral record described ScriptedScheduleController as a "1:1 pass-through"
to SchedulingEngine. It is not: an unparseable playback order is a 400, an
unparseable filler kind SILENTLY degrades to FillerKind.None, an unknown build id
is a 404, and the engine's no-progress InvalidOperationException is translated to
a 400. Carrying that wording forward would have shipped a false statement, so the
successor states a thin adapter with named mappings, each pinned by a test.

- new record testing.scripted-engine-in-process-net (active, since 2026-09-05)
- predecessor testing.scripted-playout-golden-deferred git mv'd to
  docs/decisions/archive/testing/ with frontmatter retargeted only; body prose
  byte-identical, so no Decisions-Edit trailer
- docs/decisions.md Index line retargeted to the archive path plus a new dated
  line for the successor
- catalog regenerated with scripts/build_decisions_catalog.py
- docs/testing.md: the Golden-file nets paragraph now points at the new coverage
  instead of "tracked in ersatztv#563"; a new "Scripted playout coverage" section
  states what is covered where and what is deliberately not covered (Cli.Wrap
  launch, Kestrel + Startup middleware, ApiAuthorizationFilter), dated
  2026-09-05; Timezone independence records the per-call TZ audit that decided
  which engine instructions the fixtures may use.

Refs #563

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
The docstring, the decision record and docs/testing.md all claimed the replay covered everything
except two hops. MVC model binding was a third: production binds /api/* bodies with Newtonsoft
(Startup -> AddNewtonsoftJson -> CustomContractResolver + StringEnumConverter) while the replay
deserialized with System.Text.Json. Measured on this tree: for the fixture's own bodies the two
agree, but for a body omitting the `required` member "collection" they diverge -- System.Text.Json
throws, Newtonsoft binds Collection = null and the action runs. So the fixture's stated purpose
("field names and casing match what the HTTP body binder accepts") was asserted by nothing, and a
fixture production would bind differently could still go green.

Rather than only widening the residue list, bind the way production binds. The registration moves
into ErsatzTV/Serialization/ApiJsonSettings.cs, Startup applies it from there, and both
OpenApiSerializerContractTests (which had its own mirror of the settings) and the scripted replay
now call that same function -- one definition, no copies to drift.
Production_Body_Binder_Ignores_Required_Members asserts both halves of the divergence THROUGH the
replay's own Bind helper, so pointing the replayer at another serializer reddens; the fixture's own
bodies cannot witness that swap.

The residue is now named honestly in all four places: the binding WRAPPER (input formatter, the
[ApiController] automatic 400 before an action runs) is uncovered, the serializer inside it is not.

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
The previous commit said a body omitting a `required` member "reaches the action" in production. That
overreaches what was measured: MVC adds an implicit required check for non-nullable reference types
(ErsatzTV.Core.Nullable has <Nullable>enable</Nullable>, and Startup configures no ApiBehaviorOptions,
so the [ApiController] automatic 400 is live), which would very likely reject that body before the
action. What is measured is the SERIALIZER: Newtonsoft deserializes it to a default, System.Text.Json
throws. The prose in the test, ApiJsonSettings, the record and docs/testing.md now stops there and puts
MVC model validation on the uncovered-wrapper list where it belongs.

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
No content change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
The branch asserted that binding fixture bodies through ApiJsonSettings makes "a swap to a
lookalike serializer" redden. Measured, only half of that was true: replacing
ScriptedScheduleControllerTests' BodyBinderSettings with a plain `new JsonSerializerSettings()`
-- a Newtonsoft lookalike that has lost the production configuration -- left all 8 tests green.
Only the System.Text.Json swap reddened. So the production edits the branch makes for that
coupling (ErsatzTV/Serialization/ApiJsonSettings.cs and the Startup rewrite) were justified in
four places by a hazard no test could see.

Both halves are now real. Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null
binds `{"order": null}` and posts it to AddCollection: NullValueHandling.Ignore keeps
ContentCollection.Order at its declared "shuffle" and the call is a 200, where Newtonsoft's own
Include default writes the null through and AddCollection's Enum.TryParse returns a 400. That is
a behaviour difference a script would see, not a settings-shape assertion, so it is not a second
copy of the settings list.

Mutants, run 2026-09-05 over the 9-test fixture:

  Bind -> System.Text.Json web defaults      2 red
  BodyBinderSettings -> new()                1 red (was 0 before this commit)
  OpenApi RuntimeSettings -> new()           4 red (write side, naming strategy)

What still nothing observes is Startup.ConfigureServices itself: re-inlining the
AddNewtonsoftJson lambda as a hand-copy of Apply reddens no test, because a byte-equal mirror is
behaviourally indistinguishable. ApiJsonSettings removes the duplicate rather than detecting its
drift, and docs/testing.md, the decision record and all four docstrings now say that instead of
claiming a detector. Drift confined to ReferenceLoopHandling or the StringEnumConverter is
witnessed by neither suite; that is stated rather than left implied.

Also files the 401 blind spot the record had described as "tracked separately" while nothing
tracked it. ersatztv#913 records the chain, verified from source: the filter is registered
globally, EndpointRequiresKey fail-closes every mutating verb, ScriptedScheduleController carries
no [SkipApiAuthorization], and neither ScriptedPlayoutBuilder nor entrypoint.py supplies a
credential.

Refs ersatztv#563 and ersatztv#913.

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
The fixture's pad_until_exact targeted 10:00, which the 15- and 30-minute
items reached exactly, so the engine's trim branch never ran and mutating
`engine.PadUntilExact(..., request.Trim, ...)` to `false` left every
controller test green -- measured on the pre-change fixture, `Passed! -
Failed: 0, Passed: 9`. The target moves to 09:55, off every content
boundary: Movie 01 is now trimmed from 30 minutes to 25, the snapshot is
re-pinned around it, and the same mutant fails
Committed_Script_Fixture_Produces_The_Pinned_Snapshot while the sibling
add_duration mutant still does. The trimmed span and OutPoint are asserted
directly rather than resting on the snapshot alone, and the fixture and the
snapshot comment both record that landing a trimming instruction on a
content boundary is what silences its trim flag.

ApiJsonSettings.Create() was documented as a standalone serializer
configured the way MVC's is, which measurement refutes: Apply runs against
a bare JsonSerializerSettings rather than the one MvcNewtonsoftJsonOptions
pre-configures, so MaxDepth stays at Newtonsoft's 64 instead of MVC's 32
and ProblemDetailsConverter and ValidationProblemDetailsConverter are
absent (MissingMemberHandling, TypeNameHandling and DateParseHandling do
match). Neither gap can reach a scripted request body -- two levels of
nesting, never a ProblemDetails -- so this was overstated prose, not a
broken test. Restating the delta everywhere parity was claimed would leave
four copies to rot, so ApiJsonSettingsTests pins it in both directions and
the prose points at the pin.

Also clears the three nullable warnings the replayer helpers introduced
(CS8600/CS8604 on the action string, CS8603 on Bind<T>) and corrects the
ExpectedSnapshot comment, whose last column is built from MediaItemId
rather than looked up from the seeded title.

Refs #563

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
Two pointers still named #563 as the open reason scripted playout has no
coverage. The ContentEnumeratorBuilderTests header now points at the
successor record and docs/testing.md instead of the issue this branch
closes. docs/decisions.md carried an orphaned fragment, "external-process
pipeline remains #563's", in the residual block under ## Index -- with the
pipeline now permanently outside the automated suite rather than deferred,
the fragment states something false and has no recoverable subject to
rewrite it around, so it goes.

The record's rule gains the two things measurement settled: that
ApiJsonSettings shares production's configuration and never MVC's settings
object (MaxDepth 32, the two ProblemDetails converters, pinned by
ApiJsonSettingsTests), and that a fixture must aim every trimming
instruction between two content boundaries or that action's trim argument
is witnessed by nothing. Its body loses the mutant table and the
extraction paragraph, which the mechanics doc its own frontmatter points
at carries verbatim; what remains is the conclusion plus the reasoning
that exists nowhere else. 70 prose lines to 56, under the advisory ceiling
without dropping a distinct finding.

Refs #563

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
`testing.mutation-claims-are-executed` was amended on main while this branch was in review (#881,
merged as #914): a sentence asserting that a specific mutation reddens — or does not redden — a named
test is now either a `CLAIMS` entry in `scripts/tests/mutation_manifest.py` that executes every run,
or it is not written. This branch carried six such sentences and none of them can be declared:
`Claim.node_id` resolves a proof to `scripts/tests/<node id>` and `run_pytest` invokes pytest, so an
NUnit proof has no representation in that harness at all.

Durable prose now states the mechanism each test is built on — which serializer difference, which
engine branch — which a reader re-checks by reading the code rather than by trusting a remembered
outcome. The record says that in one paragraph, so the limit is stated rather than papered over.

The outcomes themselves are here. Re-measured 2026-09-05 on this branch's tree (the commit before
this one), each mutant applied to the working tree and restored from the index between runs, tree
verified clean afterwards:

  positive control          ScriptedScheduleControllerTests   Passed: 9, Failed: 0
                            OpenApiSerializerContractTests    Passed: 4, Failed: 0
  Bind<T> -> System.Text.Json with JsonSerializerDefaults.Web
                            Failed: 2, Passed: 7 — Production_Body_Binder_Ignores_Required_Members,
                            Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null
  BodyBinderSettings = ApiJsonSettings.Create() -> new JsonSerializerSettings()
                            Failed: 1, Passed: 8 —
                            Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null
  OpenApiSerializerContractTests RuntimeSettings -> new JsonSerializerSettings()
                            Failed: 4, Passed: 0 — all four cases, on PascalCase keys
  ScriptedScheduleController AddDuration(..., request.Trim, ...) -> false
                            Failed: 1, Passed: 8 — Committed_Script_Fixture_Produces_The_Pinned_Snapshot
  ScriptedScheduleController PadUntilExact(..., request.Trim, ...) -> false
                            Failed: 1, Passed: 8 — Committed_Script_Fixture_Produces_The_Pinned_Snapshot

The last one is the round-two finding closed and re-witnessed: before the fixture's pad target moved
off the content boundary, that mutant left all nine green.

A squash merge writes its own message, so these figures also belong in the PR description.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
docs(563): anchor the measurements to the issue, which a squash merge cannot erase
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 12s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 22s
PR Gates / Docs update reminder (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 16s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
Review verdict / Set review-verdict status (pull_request_target) Successful in 20s
review-verdict/h10 Review-verdict: MERGEABLE @ 4454f1a (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m34s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 17m42s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
4454f1adfc
The paragraph added a commit ago pointed at "the commit messages that ran them" as the home of the
binder and `trim` mutant outcomes. A squash merge writes its own message and drops the bodies it
squashes, so that pointer can go stale the moment this branch lands. The issue and its pull request
survive it, and #563 is where the round-by-round measurements already are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
Author
Owner

Review-verdict: MERGEABLE @ 4454f1a

Orchestrated-session workflow: five cold review rounds across the build and the fix round (correctness Opus high in its own worktree, conformance sonnet high); every blocking and should-fix finding answered by a fix commit the PR body lists from git show; final round nits only. All seven .NET test projects green on the pushed head, TZ-independence measured across four zones, the PadUntilExact trim mutant re-executed red. Cross-family review: not required (routine risk class; tests + docs, no write path). Verdict on the pushed head; patch unchanged since review.

Review-verdict: MERGEABLE @ 4454f1a Orchestrated-session workflow: five cold review rounds across the build and the fix round (correctness Opus high in its own worktree, conformance sonnet high); every blocking and should-fix finding answered by a fix commit the PR body lists from git show; final round nits only. All seven .NET test projects green on the pushed head, TZ-independence measured across four zones, the PadUntilExact trim mutant re-executed red. Cross-family review: not required (routine risk class; tests + docs, no write path). Verdict on the pushed head; patch unchanged since review.
timothy merged commit ea1519871d into main 2026-09-05 10:43:20 +02:00
timothy deleted branch 563-scripted-playout-integration-harness 2026-09-05 10:43:21 +02:00
Sign in to join this conversation.