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 realScriptedScheduleController + 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.
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.
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>>
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
#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
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
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
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 main2026-09-05 10:43:20 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
fixes #563
Root cause / why the deferral existed
ScriptedPlayoutBuilderdoesn't build items in-process: it shells out viaCli.Wrapto anexternal script process that drives
SchedulingEngineover HTTP loopback, so the sharedin-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
SchedulingEngineTestswith the shell-outexplicitly 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
SchedulingEngineTestsgrows from 1 test to 21:AddCollection/AddCount/AddAll/AddDuration/PadUntilExact, EPG guide-group locking (with per-item advancement pinned, notjust endpoint comparison), per-item history, the 20-call no-progress halt and its reset, and
the anchor round-trip a
Continuebuild restores from.ScriptedScheduleControllerTestsreplaysFixtures/scripted-build.jsonthrough the productioncontroller 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.Noneon an unparseable filler kind, and the engine'sInvalidOperationException→ 400 translation./api/*bodies. Production bindswith Newtonsoft (
Startup→AddNewtonsoftJson→CustomContractResolver+StringEnumConverter); the replay originally usedSystem.Text.Json, and measurement showedthe two diverge on a body omitting a
requiredmember. The Newtonsoft configuration moved intoErsatzTV/Serialization/ApiJsonSettings.cs;Startup,OpenApiSerializerContractTests, and thereplay all now call that one function — no copy left to drift.
ApiJsonSettings.Create()returns a standalone serializer sharing production's
MissingMemberHandling,TypeNameHandling,DateParseHandlingand resolver, but it is not MVC's settings object:MaxDepthstays Newtonsoft's 64 rather than MVC's 32, and the two MVC-only converters(
ProblemDetailsConverter,ValidationProblemDetailsConverter) are absent.ApiJsonSettingsTestspins that delta in both directions rather than leaving it as a claim.
docs/decisions/records/testing/scripted-engine-in-process-net.md(active) supersedestesting.scripted-playout-golden-deferred, which isgit mv'd todocs/decisions/archive/testing/with only its frontmatter retargeted (body prose byte-identical, no
Decisions-Editneeded forthat commit).
docs/decisions.mdand the catalog are updated/regenerated accordingly; theorphaned
docs/decisions.mdfragment still naming #563 as owning the external-process pipelineis removed, since the pipeline is now permanently out of the automated suite rather than deferred.
docs/testing.mdgets a new "Scripted playout coverage" section stating what is covered whereand what is deliberately not (
Cli.Wraplaunch, Kestrel + Startup middleware,ApiAuthorizationFilter), plus the timezone-independence rationale for the fixtures.ContentEnumeratorBuilderTests.csheader,the
ExpectedSnapshotcomment mislabelling theMediaItemIdcolumn as "the seeded title") arecorrected.
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 realcontroller.
ScriptedScheduleController.PadUntilExact(..., request.Trim, ...) -> false— 1 Failed, 8Passed (
Committed_Script_Fixture_Produces_The_Pinned_Snapshotreddens). Re-proved again aspart of this finishing pass, output:
Failed: 1, Passed: 8, Skipped: 0, Total: 9. This closesthe round-two finding: before the fixture's
pad_until_exacttarget moved off the 10:00:00content 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.Jsonwith 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.OpenApiSerializerContractTestsRuntimeSettings -> new()— 4 Failed, 0 Passed.ApiJsonSettings.Create()vs MVC's binder settings, probed in-tree (temporary fixture, sinceremoved):
MaxDepth prod=32 standalone=64;Converters prod=3 standalone=1(missingProblemDetailsConverter,ValidationProblemDetailsConverter);MissingMemberHandling,TypeNameHandling,DateParseHandlingand the resolver match. Behaviourally inert for thescripted 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 teston all five test projects touched or coveringErsatzTV.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.od -A n -t x1 -N 3) on every touched.csfile: 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); newrecord 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 regeneratedand in sync.
python3 scripts/check-doc-narrative.py --diff origin/main: 0 advisory warnings across 3 files.web/,scripts/,.claude/,.husky/, or.gitea/files touched by this branch — SPA gateand
scripts/testspytest gate not applicable.branch).
origin/mainhad not moved since this branch's merge-base (8575c02ad) at finishing time — norebase was needed, so the reviewed patch-id (
433fbd5ef1bc00793e706665b754f88490f5d4c5) isunchanged from what round 2 reviewed.
Cross-family review status
not required (routine risk class under process.independent-review-rubric)
Deliberately left / out of scope
Cli.Wrap/HTTP shell-out itself (launching the external script process, the real Kestrelendpoint,
ScriptedPlayoutBuilderService.StartSessionover loopback) remains untested by thisharness — 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, notsilently dropped.
[ApiController]400 that runs before an action,and MVC's own model-validation
required-member check) is not covered by the replay — only theserializer inside it is. Named as residue in
ApiJsonSettings.cs, the test docstrings,docs/testing.md, and the decision record'srule:.ReferenceLoopHandlingor theStringEnumConverterbetween production and thestandalone serializer is witnessed by neither this suite nor
OpenApiSerializerContractTests—stated as a gap in the record rather than implied as covered.
ScriptedScheduleController(no[SkipApiAuthorization], andneither
ScriptedPlayoutBuildernorentrypoint.pysupplies a credential) surfaced duringmeasurement 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):
4454f1adfdocs(563): anchor the measurements to the issue, which a squash merge cannot erase |3df98d247docs(563): state each binder test's mechanism instead of its mutant colour, per the amended CLAIMS rule |d6e4426aadocs(563): retire the forward-references the supersession falsified, and trim the record to what only it says |053982180test(563): witness the trim flag on both trimming actions, and stop the binder claiming parity with MVC's |c0a70d724test(563): witness the Newtonsoft half of the binder claim, and narrow the prose to what reddens |b6ac7febbstyle(563): rewrap the residue paragraph in the ScriptedScheduleControllerTests docstring |4bd5cf9e9docs(563): scope the required-member claim to the serializer, and name model validation as residue |16a6e2790test(563): bind the fixture with the production body binder, and name the wrapper that is left |0d2cd8978docs(563): supersede the scripted-golden deferral with the in-process coverage rule |b3bc3ed11test(563): pin per-item guide-group advancement, which mutant b showed was unmeasured |02ecd484etest(563): characterize the scripted engine build API in-process and replay a committed script fixture through the real controllerround 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
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_015QqCpYFsKgnAnx6jVwrKiVThe 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_015QqCpYFsKgnAnx6jVwrKiVReview-verdict: MERGEABLE @
4454f1aOrchestrated-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.