Files
ersatztv/ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests.cs
T
timothyandClaude Fable 5.1 053982180d 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
2026-09-05 09:25:11 +02:00

465 lines
23 KiB
C#

using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core.Api.ScriptedPlayout;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling.Engine;
using ErsatzTV.Core.Scheduling.ScriptedScheduling;
using ErsatzTV.Serialization;
using LanguageExt;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
/// <summary>
/// In-process stand-in for a scripted playout build (ersatztv#563). A committed script fixture
/// (<c>Fixtures/scripted-build.json</c>) is replayed through the REAL
/// <see cref="ScriptedScheduleController" />, the REAL <see cref="ScriptedPlayoutBuilderService" /> and the
/// REAL <see cref="SchedulingEngine" />, and the resulting <c>PlayoutItem</c>s are compared to a pinned
/// snapshot in the same line format the playout goldens use. The engine is registered under a fixed build
/// id via <see cref="ScriptedPlayoutBuilderService.MockSession" />, the seam that exists for exactly this.
/// <para>
/// The hops this cannot host, named rather than implied: the <c>Cli.Wrap</c> launch of the user's
/// own program (exit code, timeout, stdout capture); the Kestrel/middleware/auth transport the
/// program calls back over; and MVC's binding <i>wrapper</i> — the input formatter's content-type
/// selection and malformed-body handling, model validation (a non-nullable reference type picks
/// 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 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
/// drift in one. The reasoning for the scope-out is in docs/testing.md → "Scripted playout
/// coverage" and the decision record <c>testing.scripted-engine-in-process-net</c>.
/// </para>
/// <para>
/// Deviation from the three golden-file nets (docs/contributing.md §10): the expected output is a
/// string constant in this file rather than a committed golden. The golden harness lives in
/// <c>ErsatzTV.Core.Tests</c>, which cannot reference a controller, and duplicating it here would
/// create a second action-to-engine mapping — the thing this design exists to avoid.
/// </para>
/// </summary>
[TestFixture]
public class ScriptedScheduleControllerTests
{
private const string ContentKey = "content";
private const string CollectionName = "Test Collection";
private static readonly Guid BuildId = Guid.Parse("00000000-0000-0000-0000-000000000563");
private static readonly DateTimeOffset Start = new(2026, 1, 15, 6, 0, 0, TimeSpan.Zero);
// What Startup hands to AddNewtonsoftJson, from the same function rather than a hand-copy of it.
// Replacing this with a plain `new()` reddens Production_Body_Binder_Keeps_Declared_Defaults_...
private static readonly JsonSerializerSettings BodyBinderSettings = ApiJsonSettings.Create();
// A body that omits the `required` member "collection"; Newtonsoft and System.Text.Json disagree.
private const string BodyMissingRequiredMember = """{"key":"content","order":"chronological"}""";
// A body sending "order" as an explicit null. NullValueHandling.Ignore keeps the DTO's declared
// default; Newtonsoft's own default (Include) overwrites it with null.
private const string BodyWithExplicitNullOrder =
"""{"key":"content","collection":"Test Collection","order":null}""";
// Every action the fixture is expected to exercise. A fixture edit that drops one must fail loudly
// rather than quietly shrinking what the snapshot covers.
private static readonly string[] ExpectedActions =
[
"add_collection",
"start_epg_group",
"add_count",
"stop_epg_group",
"add_duration",
"pad_until_exact",
"add_all"
];
// 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
001 | 2026-01-15 06:30:00 - 2026-01-15 07:15:00 | None | Movie 02
002 | 2026-01-15 07:15:00 - 2026-01-15 08:15:00 | None | Movie 03
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 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]
public async Task Committed_Script_Fixture_Produces_The_Pinned_Snapshot()
{
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
List<string> replayed = await ReplayFixture(controller);
replayed.ShouldBe(ExpectedActions);
List<PlayoutItem> items = engine.GetState().AddedItems;
Snapshot(items).ShouldBe(Canonicalize(ExpectedSnapshot));
// invariants the snapshot alone does not state, so a regenerated snapshot cannot silently absorb them
items.Count.ShouldBe(13);
for (var i = 1; i < items.Count; i++)
{
items[i].Start.ShouldBe(items[i - 1].Finish);
}
// start_epg_group ... stop_epg_group holds one guide group across its items and titles them
items[0].GuideGroup.ShouldBe(items[1].GuideGroup);
items[2].GuideGroup.ShouldNotBe(items[0].GuideGroup);
items[0].CustomTitle.ShouldBe("Morning Block");
items[1].CustomTitle.ShouldBe("Morning Block");
items[2].CustomTitle.ShouldBeNull();
// 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, 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]
public void Production_Body_Binder_Ignores_Required_Members()
{
// The replay above is only faithful while Bind uses MVC's own serializer, and "the two agree" is
// not a safe assumption to leave unstated: Newtonsoft has no notion of the C# `required` keyword,
// so a body omitting one deserializes to a default, where System.Text.Json rejects it outright.
// The claim is about the SERIALIZER only — what MVC's validation layer then does with such a
// body (non-nullable reference types pick up an implicit required check) is the uncovered
// wrapper, not this. Binding through Bind itself is what gives this teeth: the fixture's own
// bodies parse identically under either serializer, so only a body like this one witnesses the
// swap. It witnesses THAT swap only — a Newtonsoft settings object that has merely lost the
// production configuration still binds this body correctly, which is the sibling test's job.
using JsonDocument document = JsonDocument.Parse(BodyMissingRequiredMember);
ContentCollection bound = Bind<ContentCollection>(document.RootElement).ShouldNotBeNull();
bound.Key.ShouldBe(ContentKey);
bound.Collection.ShouldBeNull();
// the negative control: the same body under the serializer a swap would reach for
Should.Throw<System.Text.Json.JsonException>(
() => System.Text.Json.JsonSerializer.Deserialize<ContentCollection>(
BodyMissingRequiredMember,
new JsonSerializerOptions(JsonSerializerDefaults.Web)));
}
[Test]
public async Task Production_Body_Binder_Keeps_Declared_Defaults_Over_An_Explicit_Null()
{
// The other half of the binder-fidelity claim, and the half that a NEWTONSOFT lookalike witnesses:
// production sets NullValueHandling.Ignore, so an explicit `"order": null` leaves ContentCollection
// at its declared "shuffle" rather than overwriting it. Newtonsoft's own default is Include, which
// writes the null through — and AddCollection's Enum.TryParse then rejects it as a 400. So this is
// a behaviour difference a script would see, not a settings-shape assertion: it reddens if the bind
// settings are replaced by a plain JsonSerializerSettings that merely looks like the production one.
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
using JsonDocument document = JsonDocument.Parse(BodyWithExplicitNullOrder);
ContentCollection bound = Bind<ContentCollection>(document.RootElement).ShouldNotBeNull();
bound.Order.ShouldBe("shuffle");
IActionResult result = await controller.AddCollection(BuildId, bound, CancellationToken.None);
result.ShouldBeOfType<OkResult>();
// and the content is usable, i.e. the surviving default really was a valid playback order
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeTrue();
}
[Test]
public async Task Unknown_Build_Id_Returns_404()
{
(ScriptedScheduleController controller, SchedulingEngine _) = NewSession();
var other = Guid.Parse("00000000-0000-0000-0000-000000000999");
controller.GetContext(other).Result.ShouldBeOfType<NotFoundObjectResult>();
IActionResult collection = await controller.AddCollection(
other,
new ContentCollection { Key = ContentKey, Collection = CollectionName, Order = "chronological" },
CancellationToken.None);
collection.ShouldBeOfType<NotFoundObjectResult>();
ActionResult<PlayoutContext> count = controller.AddCount(
other,
new PlayoutCount { Content = ContentKey, Count = 1 });
count.Result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Invalid_Playback_Order_Returns_400()
{
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
IActionResult result = await controller.AddCollection(
BuildId,
new ContentCollection { Key = ContentKey, Collection = CollectionName, Order = "nonsense" },
CancellationToken.None);
result.ShouldBeOfType<BadRequestObjectResult>();
// the 400 short-circuits before the engine sees the content
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeFalse();
engine.GetState().AddedItems.ShouldBeEmpty();
}
[Test]
public async Task Unknown_Filler_Kind_Falls_Back_To_None()
{
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
(await controller.AddCollection(
BuildId,
new ContentCollection { Key = ContentKey, Collection = CollectionName, Order = "chronological" },
CancellationToken.None)).ShouldBeOfType<OkResult>();
// an unparseable filler_kind is NOT rejected the way an unparseable order is; it degrades to None
ActionResult<PlayoutContext> result = controller.AddCount(
BuildId,
new PlayoutCount { Content = ContentKey, Count = 1, FillerKind = "not-a-filler-kind" });
result.Result.ShouldBeOfType<OkObjectResult>();
engine.GetState().AddedItems.Single().FillerKind.ShouldBe(FillerKind.None);
}
[Test]
public async Task Known_Filler_Kind_Reaches_The_Item()
{
(ScriptedScheduleController controller, SchedulingEngine engine) = NewSession();
(await controller.AddCollection(
BuildId,
new ContentCollection { Key = ContentKey, Collection = CollectionName, Order = "chronological" },
CancellationToken.None)).ShouldBeOfType<OkResult>();
controller.AddCount(
BuildId,
new PlayoutCount { Content = ContentKey, Count = 1, FillerKind = "PreRoll" })
.Result.ShouldBeOfType<OkObjectResult>();
engine.GetState().AddedItems.Single().FillerKind.ShouldBe(FillerKind.PreRoll);
}
[Test]
public void No_Progress_Throw_Is_Mapped_To_400()
{
(ScriptedScheduleController controller, SchedulingEngine _) = NewSession();
// GetContext reads IsDone, which halts a script that stops advancing time. The engine throws;
// the controller must translate that into a 400 rather than letting it escape as a 500.
for (var i = 0; i < 20; i++)
{
controller.GetContext(BuildId).Result.ShouldBeOfType<OkObjectResult>();
}
controller.GetContext(BuildId).Result.ShouldBeOfType<BadRequestObjectResult>();
}
[Test]
public void Context_Reports_Start_Finish_And_Current_Time()
{
(ScriptedScheduleController controller, SchedulingEngine _) = NewSession();
var ok = controller.GetContext(BuildId).Result.ShouldBeOfType<OkObjectResult>();
var context = ok.Value.ShouldBeOfType<PlayoutContext>();
context.StartTime.ToUniversalTime().ShouldBe(Start);
context.FinishTime.ToUniversalTime().ShouldBe(Start.AddHours(12));
context.CurrentTime.ToUniversalTime().ShouldBe(Start);
context.IsDone.ShouldBeFalse();
}
private static (ScriptedScheduleController Controller, SchedulingEngine Engine) NewSession()
{
var repository = Substitute.For<IMediaCollectionRepository>();
repository.GetCollectionItemsByName(CollectionName, Arg.Any<CancellationToken>())
.Returns(_ => TestCollection());
var engine = new SchedulingEngine(
repository,
Substitute.For<IGraphicsElementRepository>(),
Substitute.For<IChannelRepository>(),
Substitute.For<ILogger<SchedulingEngine>>());
// same setup order ScriptedPlayoutBuilder uses; WithReferenceData must precede RestoreOrReset
engine.WithPlayoutId(1)
.WithMode(PlayoutBuildMode.Reset)
.WithSeed(0)
.BuildBetween(Start, Start.AddHours(12))
.WithReferenceData(new PlayoutReferenceData(null, Option<Deco>.None, [], [], null, [], [], TimeSpan.Zero))
.RestoreOrReset(Option<PlayoutAnchor>.None);
// a fresh service per test, so no session leaks between fixtures
var service = new ScriptedPlayoutBuilderService();
service.MockSession(engine, BuildId).ShouldBeTrue();
return (new ScriptedScheduleController(service), engine);
}
// Deserializes each fixture entry into the controller's own request DTO and invokes the matching action,
// asserting a 200 each time. Returns the actions actually replayed, so the caller can assert coverage.
private static async Task<List<string>> ReplayFixture(ScriptedScheduleController controller)
{
string json = await File.ReadAllTextAsync(Path.Combine(FixtureDir(), "scripted-build.json"));
using JsonDocument document = JsonDocument.Parse(json);
var replayed = new List<string>();
foreach (JsonElement entry in document.RootElement.GetProperty("script").EnumerateArray())
{
string action = entry.GetProperty("action").GetString().ShouldNotBeNull();
JsonElement body = entry.TryGetProperty("body", out JsonElement maybeBody) ? maybeBody : default;
switch (action)
{
case "add_collection":
ShouldBeOk(
await controller.AddCollection(
BuildId,
Bind<ContentCollection>(body),
CancellationToken.None));
break;
case "start_epg_group":
ShouldBeOk(controller.StartEpgGroup(BuildId, Bind<ControlStartEpgGroup>(body)));
break;
case "stop_epg_group":
ShouldBeOk(controller.StopEpgGroup(BuildId));
break;
case "add_count":
ShouldBeOkContext(controller.AddCount(BuildId, Bind<PlayoutCount>(body)));
break;
case "add_all":
ShouldBeOkContext(controller.AddAll(BuildId, Bind<ContentAll>(body)));
break;
case "add_duration":
ShouldBeOkContext(controller.AddDuration(BuildId, Bind<PlayoutDuration>(body)));
break;
case "pad_until_exact":
ShouldBeOkContext(controller.PadUntilExact(BuildId, Bind<PlayoutPadUntilExact>(body)));
break;
default:
Assert.Fail($"Fixture uses action '{action}', which the replayer does not implement.");
break;
}
replayed.Add(action);
}
return replayed;
}
// 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 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).ShouldNotBeNull();
}
private static void ShouldBeOk(IActionResult result) => result.ShouldBeOfType<OkResult>();
private static void ShouldBeOkContext(ActionResult<PlayoutContext> result)
{
var ok = result.Result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldBeOfType<PlayoutContext>();
}
// Same line format as PlayoutBuildGoldenTests.Snapshot: ordered by Start then MediaItemId, raw UTC.
private static string Snapshot(List<PlayoutItem> items)
{
List<PlayoutItem> ordered = items
.OrderBy(i => i.Start)
.ThenBy(i => i.MediaItemId)
.ToList();
var sb = new StringBuilder();
for (var index = 0; index < ordered.Count; index++)
{
PlayoutItem item = ordered[index];
sb.Append(index.ToString("D3", CultureInfo.InvariantCulture));
sb.Append(" | ");
sb.Append(item.Start.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
sb.Append(" - ");
sb.Append(item.Finish.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
sb.Append(" | ");
sb.Append(item.FillerKind.ToString());
sb.Append(" | ");
sb.Append($"Movie {item.MediaItemId:D2}");
sb.Append('\n');
}
return sb.ToString();
}
private static string Canonicalize(string text) => text.ReplaceLineEndings("\n").TrimEnd('\n') + "\n";
private static string FixtureDir([CallerFilePath] string thisFile = "") =>
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Fixtures");
// Distinct release dates make chronological order deterministic (id order); distinct durations make
// every boundary in the snapshot unambiguous.
private static List<MediaItem> TestCollection() =>
[
FakeMovie(1, 30),
FakeMovie(2, 45),
FakeMovie(3, 60),
FakeMovie(4, 20),
FakeMovie(5, 90),
FakeMovie(6, 15)
];
private static Movie FakeMovie(int id, int minutes) =>
new()
{
Id = id,
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(minutes) }],
MovieMetadata =
[
new MovieMetadata
{
Title = $"Movie {id:D2}",
ReleaseDate = new DateTime(2005, 1, 1).AddDays(id)
}
]
};
}