docs(563): retire the forward-references the supersession falsified, and trim the record to what only it says

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
This commit is contained in:
2026-09-05 09:25:11 +02:00
co-authored by Claude Fable 5.1
parent 053982180d
commit d6e4426aa9
4 changed files with 15 additions and 29 deletions
@@ -14,8 +14,10 @@ namespace ErsatzTV.Core.Tests.Scheduling;
// different algorithm keyed on the same PlaybackOrder), and
// - every order the two engines don't support returns None, so each caller logs its own #70 warning
// instead of silently scheduling nothing.
// The Scripted engine has no golden (its external-process/HTTP transport is integration-only, #563), so
// this direct helper test is the in-process regression net for the shared construction it drives.
// The Scripted engine has no golden: its external-process/HTTP transport is permanently outside the
// automated suite, and the engine behind it is covered in-process instead (decision
// testing.scripted-engine-in-process-net, docs/testing.md -> "Scripted playout coverage"). This direct
// helper test is the regression net for the shared construction that engine drives.
[TestFixture]
public class ContentEnumeratorBuilderTests
{
-1
View File
@@ -99,7 +99,6 @@ leaving the shared tree dirty is the one outcome that would make this script a n
**Negative control (inherited from #231/#250).** A dedicated test hammers unbalanced releases on an empty pool while reader threads sample the count; none may ever observe a value below zero. Reinstating the pre-#539 decrement-first body makes it fail (`sawNegative > 0` — the readers catch the transient `−1`); verified. As with the #536 tests, break the primitive by reverting the real body, **not** `if (true)` (CS0219 under warnings-as-errors leaves `--no-build` running a stale, still-fixed dll).
**SPA is id-fields-first; the API is complete ahead of the UI.** The Channel Builder + Auto-Tune DetailPanel re-add a real "None" option to the five id selects (watermark + fillers) — the pickers #89 had degraded to "Inherit"-only — routed through a `CLEAR` overrides sentinel folded into `advanced.clear` at request-build time (`applyOverridesToRequest`, so the sentinel never leaks as a field value). The three string clear-fields are covered by the backend enum for machine clients (MCP) but the SPA text inputs keep "empty = inherit"; adding a tri-state to those inputs is deferred, not blocked. This is the deliberate "REST API is a real audience" posture (`rest-api-purpose-mcp-and-new-ui`).
`security.iptv-browser-token`.
external-process pipeline remains #563's.
picker; TZ-independence holds only for divisors of 60. See #77 (prior art) and #392.
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@ status: active
since: '2026-09-05'
supersedes: testing.scripted-playout-golden-deferred@2026-07-22
superseded-by: none
rule: 'Scripted playout is characterized at two in-process levels and no further. (1) `SchedulingEngineTests` drives the engine build API directly with substituted repositories — the same setup order `ScriptedPlayoutBuilder` uses (`WithPlayoutId`/`WithMode`/`WithSeed`/`BuildBetween`/`WithReferenceData`/`RestoreOrReset`, and `WithReferenceData` MUST precede the last two or `PlayoutReferenceData.PlayoutHistory` NREs). (2) `ScriptedScheduleControllerTests` replays a committed script fixture (`ErsatzTV.Tests/Controllers/Fixtures/scripted-build.json`) through the REAL `ScriptedScheduleController` + `ScriptedPlayoutBuilderService.MockSession` + `SchedulingEngine` and pins the resulting `PlayoutItem`s as a snapshot in the golden line format — so exactly ONE action-to-engine mapping exists in the repo, the production one. Fixture bodies in that replay are deserialized with `ApiJsonSettings`, the configuration `Startup` applies to `AddNewtonsoftJson`, invoked from the same function rather than mirrored — and two tests WITNESS that choice instead of asserting it: `Production_Body_Binder_Ignores_Required_Members` reddens under a System.Text.Json binder, `Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null` reddens under a bare `JsonSerializerSettings`. What no test observes is the `Startup` registration itself, so the extraction REMOVES the duplicate rather than detecting its drift. The `Cli.Wrap` launch of the user-authored program (exit code, timeout, stdout capture), the Kestrel/middleware/`ApiAuthorizationFilter` transport it calls back over, and MVC model binding as a WRAPPER (the input formatter, model validation and the `[ApiController]` automatic 400 either produces before an action runs — the serializer inside that wrapper is covered, the wrapper around it is not) are NOT tested and are not scheduled to be; that residue is named below rather than argued away. The controller is a thin adapter, NOT a 1:1 pass-through: 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 no-progress `InvalidOperationException` is translated to a 400 — each pinned by a test, because a prose claim of equivalence decays.'
signals: 'why is there no scripted golden; scripted integration harness; scripted transport vs engine; SchedulingEngine is in-process testable; Cli.Wrap external process is transport only; shell-out scoped out; MockSession seam; in-process stand-in for a scripted script; engine build API characterization; committed script fixture replay; ScriptedScheduleController is an adapter not a pass-through; scripted filler kind silently falls back to None; scripted playout no-progress halt maps to 400; scripted build 401 blind spot; EnumeratorForContent · paths: `ErsatzTV.Core.Tests/Scheduling/Engine/SchedulingEngineTests.cs`, `ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests.cs`, `ErsatzTV.Tests/Controllers/Fixtures/scripted-build.json`, `ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs`, `ErsatzTV/Controllers/Api/ScriptedScheduleController.cs`, `ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs`, `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs` · issues: #563, #381, #395, #163'
rule: 'Scripted playout is characterized at two in-process levels and no further. (1) `SchedulingEngineTests` drives the engine build API directly with substituted repositories — the same setup order `ScriptedPlayoutBuilder` uses (`WithPlayoutId`/`WithMode`/`WithSeed`/`BuildBetween`/`WithReferenceData`/`RestoreOrReset`, and `WithReferenceData` MUST precede the last two or `PlayoutReferenceData.PlayoutHistory` NREs). (2) `ScriptedScheduleControllerTests` replays a committed script fixture (`ErsatzTV.Tests/Controllers/Fixtures/scripted-build.json`) through the REAL `ScriptedScheduleController` + `ScriptedPlayoutBuilderService.MockSession` + `SchedulingEngine` and pins the resulting `PlayoutItem`s as a snapshot in the golden line format — so exactly ONE action-to-engine mapping exists in the repo, the production one. Fixture bodies in that replay are deserialized with `ApiJsonSettings`, the configuration `Startup` applies to `AddNewtonsoftJson`, invoked from the same function rather than mirrored — and two tests WITNESS that choice instead of asserting it: `Production_Body_Binder_Ignores_Required_Members` reddens under a System.Text.Json binder, `Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null` reddens under a bare `JsonSerializerSettings`. What is shared is the CONFIGURATION, never MVC''s settings OBJECT: `ApiJsonSettings.Create()` applies it to a bare settings object, so MVC''s `MaxDepth` of 32 and its `ProblemDetails`/`ValidationProblemDetails` converters are absent (measured 2026-09-05, pinned by `ApiJsonSettingsTests`); neither is reachable from a scripted request body, and no test may generalize from `Create()` to production past that. What no test observes is the `Startup` registration itself, so the extraction REMOVES the duplicate rather than detecting its drift. The `Cli.Wrap` launch of the user-authored program (exit code, timeout, stdout capture), the Kestrel/middleware/`ApiAuthorizationFilter` transport it calls back over, and MVC model binding as a WRAPPER (the input formatter, model validation and the `[ApiController]` automatic 400 either produces before an action runs — the serializer inside that wrapper is covered, the wrapper around it is not) are NOT tested and are not scheduled to be; that residue is named below rather than argued away. Both fixtures must aim every trimming instruction at an instant BETWEEN two content boundaries: a pad or duration that happens to land on one never enters the engine''s trim branch, and that action''s `trim` argument then survives being replaced with `false`. The controller is a thin adapter, NOT a 1:1 pass-through: 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 no-progress `InvalidOperationException` is translated to a 400 — each pinned by a test, because a prose claim of equivalence decays.'
signals: 'why is there no scripted golden; scripted integration harness; scripted transport vs engine; SchedulingEngine is in-process testable; Cli.Wrap external process is transport only; shell-out scoped out; MockSession seam; in-process stand-in for a scripted script; engine build API characterization; committed script fixture replay; ScriptedScheduleController is an adapter not a pass-through; scripted filler kind silently falls back to None; scripted playout no-progress halt maps to 400; scripted build 401 blind spot; EnumeratorForContent · paths: `ErsatzTV.Core.Tests/Scheduling/Engine/SchedulingEngineTests.cs`, `ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests.cs`, `ErsatzTV.Tests/Controllers/Fixtures/scripted-build.json`, `ErsatzTV.Tests/Serialization/ApiJsonSettingsTests.cs`, `ErsatzTV/Serialization/ApiJsonSettings.cs`, `ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs`, `ErsatzTV/Controllers/Api/ScriptedScheduleController.cs`, `ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs`, `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs` · issues: #563, #381, #395, #163'
mechanics: docs/testing.md → "Scripted playout coverage" and "Golden-file nets"
---
@@ -37,35 +37,20 @@ wrong — the four adapter mappings above are real behaviour that only a test ca
4. *The stand-in loses nothing the harness would have added at the engine level.* Replaying the fixture
through the real controller and the real service keeps a single action-to-engine mapping under test;
what is absent is the two transport hops plus MVC binding as a wrapper, and the wrapper is the shallow
half — the serializer inside it is configured from `ApiJsonSettings`, the production object itself.
half — the serializer inside it is configured by `ApiJsonSettings`, the production configuration (not
MVC's settings *object*, whose remaining defaults the rule enumerates).
**The residue, named.** Uncovered: process launch, exit code and timeout handling; Kestrel plus the
forwarded-headers / compression / SPA-hosting middleware; MVC model binding as a *wrapper* — the input
formatter (content-type selection, malformed-body handling), model validation (a non-nullable reference
type picks up an implicit required check there) and the `[ApiController]` automatic 400 either produces
before an action runs, since every test hands an action an already-bound object; and
`ApiAuthorizationFilter`. The *serializer* inside that wrapper is deliberately not on this list:
`ScriptedScheduleControllerTests` binds with `ApiJsonSettings` itself, and two tests hold the two ways
that binder can be replaced (mutants run 2026-09-05, 9 tests in the fixture):
| Replace the replay binder with | Result |
| --- | --- |
| `System.Text.Json` with web defaults | 2 red — Newtonsoft has no notion of the C# `required` keyword, so a body omitting one deserializes to a default rather than being rejected |
| a bare `new JsonSerializerSettings()` — Newtonsoft, but without the production configuration | 1 red — `NullValueHandling.Ignore` keeps `ContentCollection.Order` at its declared `"shuffle"` over an explicit `"order": null`, where Newtonsoft's own `Include` default writes the null through and `AddCollection`'s `Enum.TryParse` then returns a 400 |
Both claims are about the SERIALIZER and are scoped to it: what the wrapper then does with such a body —
non-nullable reference types pick up an implicit required check in MVC validation — is on the uncovered
list above, not measured here.
**What the extraction does and does not buy.** `ApiJsonSettings` exists so the binder is defined once;
it is not a drift *detector*, and the difference matters because the branch cannot claim one. Nothing in
the suite observes `Startup.ConfigureServices` — re-inlining the `AddNewtonsoftJson` lambda as a hand-copy
of `Apply` reddens no test, because a byte-equal mirror is behaviourally indistinguishable by
construction. What the two suites *do* catch is a mirror that has lost something: the table above for the
read path, and `OpenApiSerializerContractTests` for the write path, where dropping `CustomContractResolver`
turns all 4 cases red on PascalCase keys (measured 2026-09-05). Drift confined to `ReferenceLoopHandling`
or the `StringEnumConverter` is witnessed by neither, since neither suite serializes a cycle and
`OpenApiSerializerContractTests` compares key names rather than values.
`ApiAuthorizationFilter`. The *serializer* inside that wrapper is deliberately not on this list — it is
`ApiJsonSettings` itself, and two tests hold the two ways it can be replaced. `ApiJsonSettings` exists so
that binder is *defined* once; it is not a drift **detector**, and the branch may not claim one, because
nothing observes `Startup.ConfigureServices` and a byte-equal hand-copy of `Apply` is indistinguishable
by construction. What the suites do catch is a mirror that has *lost* something. The mechanics doc
tabulates which mutant reddens which suite, and what neither witnesses.
`ApiAuthorizationFilter` on that list is not hypothetical — `ScriptedScheduleController` carries no
`[SkipApiAuthorization]` while