test(563): witness the trim flag on both trimming actions, and stop the binder claiming parity with MVC's
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
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"description": "Committed stand-in for a user-authored scripted schedule: the ordered sequence of calls such a script makes against /api/v1/scripted/playout/build/{buildId}, replayed through the real ScriptedScheduleController by ScriptedScheduleControllerTests. Field names and casing match what the HTTP body binder accepts \u2014 the replay deserializes each body with ApiJsonSettings, the configuration Startup applies to AddNewtonsoftJson. Deliberately avoids wait_until / pad_until / pad_to_next (local day and time-of-day) and shuffle order, which would make the pinned snapshot machine-timezone- or seed-dependent.",
|
||||
"description": "Committed stand-in for a user-authored scripted schedule: the ordered sequence of calls such a script makes against /api/v1/scripted/playout/build/{buildId}, replayed through the real ScriptedScheduleController by ScriptedScheduleControllerTests. Field names and casing match what the HTTP body binder accepts \u2014 the replay deserializes each body with ApiJsonSettings, the configuration Startup applies to AddNewtonsoftJson. Deliberately avoids wait_until / pad_until / pad_to_next (local day and time-of-day) and shuffle order, which would make the pinned snapshot machine-timezone- or seed-dependent. add_duration and pad_until_exact both end BETWEEN two content boundaries on purpose: an instruction that happens to end on one never reaches the engine's trim branch, and its trim flag is then witnessed by nothing.",
|
||||
"script": [
|
||||
{
|
||||
"action": "add_collection",
|
||||
@@ -38,7 +38,7 @@
|
||||
"action": "pad_until_exact",
|
||||
"body": {
|
||||
"content": "content",
|
||||
"when": "2026-01-15T10:00:00+00:00",
|
||||
"when": "2026-01-15T09:55:00+00:00",
|
||||
"trim": true
|
||||
}
|
||||
},
|
||||
|
||||
@@ -37,11 +37,14 @@ namespace ErsatzTV.Tests.Controllers;
|
||||
/// up an implicit required check there), and the <c>[ApiController]</c> automatic 400 either
|
||||
/// produces before an action runs, since every test here hands an action an already-bound object.
|
||||
/// The serializer <i>inside</i> that wrapper is not residue: request bodies are deserialized with
|
||||
/// <see cref="ApiJsonSettings" />, the same configuration <c>Startup</c> hands to
|
||||
/// <c>AddNewtonsoftJson</c>, so the fixture is bound the way production binds it. Two tests hold
|
||||
/// the two ways that binder can be replaced, since the fixture's own bodies parse identically
|
||||
/// under all of them: <see cref="Production_Body_Binder_Ignores_Required_Members" /> against a
|
||||
/// System.Text.Json swap, and
|
||||
/// <see cref="ApiJsonSettings" />, the same function <c>Startup</c> hands to <c>AddNewtonsoftJson</c>.
|
||||
/// It is the production <i>configuration</i>, not the production <i>object</i> — MVC applies it to
|
||||
/// settings it has already configured, and <c>ApiJsonSettings.Create()</c> starts from a bare one, so
|
||||
/// MVC's stricter <c>MaxDepth</c> and its two <c>ProblemDetails</c> converters are missing here
|
||||
/// (enumerated and pinned by <c>ApiJsonSettingsTests</c>; both inert for these DTOs, which nest two
|
||||
/// levels and are never a <c>ProblemDetails</c>). Two tests hold the two ways that binder can be
|
||||
/// replaced, since the fixture's own bodies parse identically under all of them:
|
||||
/// <see cref="Production_Body_Binder_Ignores_Required_Members" /> against a System.Text.Json swap, and
|
||||
/// <see cref="Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null" /> against a
|
||||
/// plain Newtonsoft settings object. What no test here observes is <c>Startup</c>'s own
|
||||
/// registration — <see cref="ApiJsonSettings" /> removes the duplicate rather than detecting
|
||||
@@ -89,7 +92,13 @@ public class ScriptedScheduleControllerTests
|
||||
"add_all"
|
||||
];
|
||||
|
||||
// Raw UTC Start/Finish (never the *Offset properties, which localize), FillerKind, and the seeded title.
|
||||
// Raw UTC Start/Finish (never the *Offset properties, which localize), FillerKind, and the item's
|
||||
// MediaItemId rendered as "Movie NN" — a label built from the id, not a lookup of the seeded title.
|
||||
//
|
||||
// 004 and 006 are the two trimmed items, and they are the reason the fixture's numbers look arbitrary.
|
||||
// Movie 05 (90m) starts at 08:35 inside a two-hour add_duration ending 09:15, and pad_until_exact
|
||||
// targets 09:55, which Movie 01 (30m) would overshoot from 09:30. Land either target on a content
|
||||
// boundary instead and the trim branch never runs, leaving that action's `trim` argument unwitnessed.
|
||||
private const string ExpectedSnapshot =
|
||||
"""
|
||||
000 | 2026-01-15 06:00:00 - 2026-01-15 06:30:00 | None | Movie 01
|
||||
@@ -98,13 +107,13 @@ public class ScriptedScheduleControllerTests
|
||||
003 | 2026-01-15 08:15:00 - 2026-01-15 08:35:00 | None | Movie 04
|
||||
004 | 2026-01-15 08:35:00 - 2026-01-15 09:15:00 | None | Movie 05
|
||||
005 | 2026-01-15 09:15:00 - 2026-01-15 09:30:00 | None | Movie 06
|
||||
006 | 2026-01-15 09:30:00 - 2026-01-15 10:00:00 | None | Movie 01
|
||||
007 | 2026-01-15 10:00:00 - 2026-01-15 10:45:00 | None | Movie 02
|
||||
008 | 2026-01-15 10:45:00 - 2026-01-15 11:45:00 | None | Movie 03
|
||||
009 | 2026-01-15 11:45:00 - 2026-01-15 12:05:00 | None | Movie 04
|
||||
010 | 2026-01-15 12:05:00 - 2026-01-15 13:35:00 | None | Movie 05
|
||||
011 | 2026-01-15 13:35:00 - 2026-01-15 13:50:00 | None | Movie 06
|
||||
012 | 2026-01-15 13:50:00 - 2026-01-15 14:20:00 | None | Movie 01
|
||||
006 | 2026-01-15 09:30:00 - 2026-01-15 09:55:00 | None | Movie 01
|
||||
007 | 2026-01-15 09:55:00 - 2026-01-15 10:40:00 | None | Movie 02
|
||||
008 | 2026-01-15 10:40:00 - 2026-01-15 11:40:00 | None | Movie 03
|
||||
009 | 2026-01-15 11:40:00 - 2026-01-15 12:00:00 | None | Movie 04
|
||||
010 | 2026-01-15 12:00:00 - 2026-01-15 13:30:00 | None | Movie 05
|
||||
011 | 2026-01-15 13:30:00 - 2026-01-15 13:45:00 | None | Movie 06
|
||||
012 | 2026-01-15 13:45:00 - 2026-01-15 14:15:00 | None | Movie 01
|
||||
""";
|
||||
|
||||
[Test]
|
||||
@@ -135,8 +144,12 @@ public class ScriptedScheduleControllerTests
|
||||
// after stop_epg_group every item opens its own guide group again
|
||||
items.Skip(2).Select(i => i.GuideGroup).ShouldBe(Enumerable.Range(2, 11));
|
||||
|
||||
// pad_until_exact lands on the requested instant, whatever the machine offset is
|
||||
items[6].Finish.ShouldBe(new DateTime(2026, 1, 15, 10, 0, 0, DateTimeKind.Utc));
|
||||
// pad_until_exact lands on the requested instant, whatever the machine offset is, and gets there by
|
||||
// TRIMMING: Movie 01 is 30 minutes and starts at 09:30, so the 25-minute item below is the fixture's
|
||||
// `"trim": true` reaching the engine. Passing `false` for that argument drops the item entirely.
|
||||
items[6].Finish.ShouldBe(new DateTime(2026, 1, 15, 9, 55, 0, DateTimeKind.Utc));
|
||||
(items[6].Finish - items[6].Start).ShouldBe(TimeSpan.FromMinutes(25));
|
||||
items[6].OutPoint.ShouldBe(TimeSpan.FromMinutes(25));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -328,7 +341,7 @@ public class ScriptedScheduleControllerTests
|
||||
|
||||
foreach (JsonElement entry in document.RootElement.GetProperty("script").EnumerateArray())
|
||||
{
|
||||
string action = entry.GetProperty("action").GetString();
|
||||
string action = entry.GetProperty("action").GetString().ShouldNotBeNull();
|
||||
JsonElement body = entry.TryGetProperty("body", out JsonElement maybeBody) ? maybeBody : default;
|
||||
|
||||
switch (action)
|
||||
@@ -369,14 +382,17 @@ public class ScriptedScheduleControllerTests
|
||||
return replayed;
|
||||
}
|
||||
|
||||
// Deserializes a fixture body with the SAME configuration the production body binder uses
|
||||
// Deserializes a fixture body with the configuration the production body binder runs
|
||||
// (Startup -> AddNewtonsoftJson -> ApiJsonSettings), so the fixture's field names and casing are
|
||||
// asserted against the serializer MVC actually runs. The two Production_Body_Binder_* tests, not
|
||||
// this helper, are what make a replacement of that configuration visible.
|
||||
private static T Bind<T>(JsonElement body)
|
||||
// asserted against that serializer rather than a convenient one. Not the identical settings OBJECT:
|
||||
// MVC applies the configuration to its own pre-configured settings, so its stricter MaxDepth and its
|
||||
// two ProblemDetails converters are absent from Create() — ApiJsonSettingsTests enumerates the gap and
|
||||
// shows why neither reaches a request body. The two Production_Body_Binder_* tests, not this helper,
|
||||
// are what make a replacement of the configuration visible.
|
||||
private static T Bind<T>(JsonElement body) where T : class
|
||||
{
|
||||
body.ValueKind.ShouldBe(JsonValueKind.Object);
|
||||
return JsonConvert.DeserializeObject<T>(body.GetRawText(), BodyBinderSettings);
|
||||
return JsonConvert.DeserializeObject<T>(body.GetRawText(), BodyBinderSettings).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
private static void ShouldBeOk(IActionResult result) => result.ShouldBeOfType<OkResult>();
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using ErsatzTV.Serialization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the gap between the two objects <see cref="ApiJsonSettings" /> produces:
|
||||
/// <see cref="ApiJsonSettings.Apply" /> onto MVC's own settings, which is what the production body binder
|
||||
/// runs, and <see cref="ApiJsonSettings.Create" />, which tests outside the MVC pipeline
|
||||
/// (<c>ScriptedScheduleControllerTests</c>, <c>OpenApiSerializerContractTests</c>) bind and serialize with.
|
||||
/// They are not the same object, and the difference belongs in a test rather than in a comment: a prose
|
||||
/// claim of parity cannot notice the day it stops being true, and every doc describing the scripted
|
||||
/// coverage rests on knowing which parts carry over and which do not.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ApiJsonSettingsTests
|
||||
{
|
||||
// The object MVC's options pipeline constructs before Startup's configure delegate runs. Building it
|
||||
// directly rather than hosting the app is the point of the caveat below: this measures the settings
|
||||
// MvcNewtonsoftJsonOptions itself defines, not a fully composed host.
|
||||
private static JsonSerializerSettings Production() =>
|
||||
ApiJsonSettings.Apply(new MvcNewtonsoftJsonOptions().SerializerSettings);
|
||||
|
||||
[Test]
|
||||
public void Apply_Sets_The_Same_Four_Things_On_Either_Object()
|
||||
{
|
||||
JsonSerializerSettings production = Production();
|
||||
JsonSerializerSettings standalone = ApiJsonSettings.Create();
|
||||
|
||||
production.NullValueHandling.ShouldBe(NullValueHandling.Ignore);
|
||||
standalone.NullValueHandling.ShouldBe(NullValueHandling.Ignore);
|
||||
|
||||
production.ReferenceLoopHandling.ShouldBe(ReferenceLoopHandling.Ignore);
|
||||
standalone.ReferenceLoopHandling.ShouldBe(ReferenceLoopHandling.Ignore);
|
||||
|
||||
production.ContractResolver.ShouldBeOfType<CustomContractResolver>();
|
||||
standalone.ContractResolver.ShouldBeOfType<CustomContractResolver>();
|
||||
|
||||
production.Converters.ShouldContain(c => c is Newtonsoft.Json.Converters.StringEnumConverter);
|
||||
standalone.Converters.ShouldContain(c => c is Newtonsoft.Json.Converters.StringEnumConverter);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Standalone_Matches_On_The_Mvc_Defaults_That_Change_How_A_Body_Parses()
|
||||
{
|
||||
JsonSerializerSettings production = Production();
|
||||
JsonSerializerSettings standalone = ApiJsonSettings.Create();
|
||||
|
||||
standalone.MissingMemberHandling.ShouldBe(production.MissingMemberHandling);
|
||||
standalone.TypeNameHandling.ShouldBe(production.TypeNameHandling);
|
||||
standalone.DateParseHandling.ShouldBe(production.DateParseHandling);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Standalone_Does_Not_Carry_Mvcs_Depth_Limit_Or_Its_Error_Converters()
|
||||
{
|
||||
JsonSerializerSettings production = Production();
|
||||
JsonSerializerSettings standalone = ApiJsonSettings.Create();
|
||||
|
||||
// MVC clamps the object graph it will consume; a bare settings object keeps Newtonsoft's own limit.
|
||||
// A body deeper than 32 therefore binds here and 400s in production — inert for the scripted request
|
||||
// DTOs, which nest two levels, and the reason no test may generalize from Create() to "production".
|
||||
production.MaxDepth.ShouldBe(32);
|
||||
standalone.MaxDepth.ShouldBe(64);
|
||||
|
||||
// The rest of the delta is MVC's error-response shaping, which no request body reaches.
|
||||
string[] missing = production.Converters
|
||||
.Select(c => c.GetType().Name)
|
||||
.Except(standalone.Converters.Select(c => c.GetType().Name))
|
||||
.Order()
|
||||
.ToArray();
|
||||
|
||||
missing.ShouldBe(["ProblemDetailsConverter", "ValidationProblemDetailsConverter"]);
|
||||
|
||||
// and nothing goes the other way: the standalone object adds nothing production lacks
|
||||
standalone.Converters
|
||||
.Select(c => c.GetType().Name)
|
||||
.Except(production.Converters.Select(c => c.GetType().Name))
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,16 @@ public static class ApiJsonSettings
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <summary>A standalone serializer configured the way MVC's is.</summary>
|
||||
/// <summary>
|
||||
/// <see cref="Apply" /> onto a bare settings object, for a caller outside the MVC pipeline. It carries
|
||||
/// everything <see cref="Apply" /> sets and nothing MVC supplies around it, so it is NOT the object the
|
||||
/// body binder runs. Measured 2026-09-05 against <c>new MvcNewtonsoftJsonOptions().SerializerSettings</c>:
|
||||
/// <c>MaxDepth</c> is Newtonsoft's 64 rather than MVC's stricter 32, and MVC's two error-shaping
|
||||
/// converters — <c>ProblemDetailsConverter</c> and <c>ValidationProblemDetailsConverter</c> — are absent.
|
||||
/// <c>MissingMemberHandling</c>, <c>TypeNameHandling</c> and <c>DateParseHandling</c> match. Both gaps are
|
||||
/// inert for the request DTOs the tests bind (two levels deep, never a <c>ProblemDetails</c>), which is why
|
||||
/// this is usable there at all; the delta is pinned by <c>ApiJsonSettingsTests</c> so it cannot decay into
|
||||
/// a parity claim.
|
||||
/// </summary>
|
||||
public static JsonSerializerSettings Create() => Apply(new JsonSerializerSettings());
|
||||
}
|
||||
|
||||
+12
-1
@@ -71,8 +71,9 @@ at all (ersatztv#563, decision `testing.scripted-engine-in-process-net`).
|
||||
| Where | Covers |
|
||||
| --- | --- |
|
||||
| `ErsatzTV.Core.Tests/Scheduling/Engine/SchedulingEngineTests.cs` | The engine build API a script drives: `AddCollection`, `AddCount`, `AddAll`, `AddDuration` (stop-before-end and trim), `PadUntilExact`, EPG guide-group locking, per-item `PlayoutHistory`, the `IsDone` no-progress halt and its reset, and the `GetAnchor`/`RestoreOrReset` round-trip a Continue build resumes from. Substituted repositories, no database. |
|
||||
| `ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests.cs` | A committed script fixture (`Controllers/Fixtures/scripted-build.json`), bound with the production body-binder configuration (`ApiJsonSettings`) and replayed through the real `ScriptedScheduleController` + `ScriptedPlayoutBuilderService.MockSession` + `SchedulingEngine`, with a pinned 13-item snapshot in the same line format the playout goldens use, plus the four adapter mappings: 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 engine's no-progress `InvalidOperationException` translated to a 400. |
|
||||
| `ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests.cs` | A committed script fixture (`Controllers/Fixtures/scripted-build.json`), bound with the production body-binder configuration (`ApiJsonSettings`) and replayed through the real `ScriptedScheduleController` + `ScriptedPlayoutBuilderService.MockSession` + `SchedulingEngine`, with a pinned 13-item snapshot in the same line format the playout goldens use (both trimming actions target an instant *between* two content boundaries, so each one's `trim` argument changes the snapshot), plus the four adapter mappings: 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 engine's no-progress `InvalidOperationException` translated to a 400. |
|
||||
| `ErsatzTV.Core.Tests/Scheduling/ContentEnumeratorBuilderTests.cs` | The enumerator-construction helper the Scripted and Sequential/YAML engines share (ersatztv#395). |
|
||||
| `ErsatzTV.Tests/Serialization/ApiJsonSettingsTests.cs` | What the replay's standalone binder does and does not share with the one MVC runs, so the paragraph below stays a measurement rather than a claim. |
|
||||
|
||||
The expected snapshot is a string constant in the test rather than a fourth golden file: the golden
|
||||
harness lives in `ErsatzTV.Core.Tests`, which cannot reference a controller, and duplicating it would
|
||||
@@ -104,6 +105,16 @@ separate them (mutants run 2026-09-05):
|
||||
Both are statements about the serializer and stop there — what MVC validation does with such a body
|
||||
belongs to the wrapper named above.
|
||||
|
||||
What is shared with production is the *configuration*, not the settings **object**. MVC applies it to
|
||||
settings it has already configured; `ApiJsonSettings.Create()`, which every test outside the pipeline
|
||||
uses, applies it to a bare one. Measured 2026-09-05, the standalone object therefore keeps Newtonsoft's
|
||||
`MaxDepth` of 64 instead of MVC's stricter 32 and lacks MVC's `ProblemDetailsConverter` and
|
||||
`ValidationProblemDetailsConverter`; `MissingMemberHandling`, `TypeNameHandling` and `DateParseHandling`
|
||||
match. Neither gap can reach a scripted request body — the DTOs nest two levels and are never a
|
||||
`ProblemDetails` — which is what makes `Create()` usable in a test at all, and no test may generalize
|
||||
from it to "production" beyond that. `ErsatzTV.Tests/Serialization/ApiJsonSettingsTests.cs` pins the
|
||||
whole delta in both directions, so it fails rather than rots if either object moves.
|
||||
|
||||
`ApiJsonSettings` exists so that binder is *defined* once; it is not a drift detector, and the difference
|
||||
is worth stating because it bounds what the suite can promise. Nothing here observes
|
||||
`Startup.ConfigureServices`, so re-inlining its `AddNewtonsoftJson` lambda as a hand-copy reddens no test
|
||||
|
||||
Reference in New Issue
Block a user