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 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 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
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
#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