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; /// /// In-process stand-in for a scripted playout build (ersatztv#563). A committed script fixture /// (Fixtures/scripted-build.json) is replayed through the REAL /// , the REAL and the /// REAL , and the resulting PlayoutItems 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 , the seam that exists for exactly this. /// /// The hops this cannot host, named rather than implied: the Cli.Wrap 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 wrapper — 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 [ApiController] automatic 400 either /// produces before an action runs, since every test here hands an action an already-bound object. /// The serializer inside that wrapper is not residue: request bodies are deserialized with /// , the same function Startup hands to AddNewtonsoftJson. /// It is the production configuration, not the production object — MVC applies it to /// settings it has already configured, and ApiJsonSettings.Create() starts from a bare one, so /// MVC's stricter MaxDepth and its two ProblemDetails converters are missing here /// (enumerated and pinned by ApiJsonSettingsTests; both inert for these DTOs, which nest two /// levels and are never a ProblemDetails). Two tests hold the two ways that binder can be /// replaced, since the fixture's own bodies parse identically under all of them: /// against a System.Text.Json swap, and /// against a /// plain Newtonsoft settings object. What no test here observes is Startup's own /// registration — 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 testing.scripted-engine-in-process-net. /// /// /// 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 /// ErsatzTV.Core.Tests, which cannot reference a controller, and duplicating it here would /// create a second action-to-engine mapping — the thing this design exists to avoid. /// /// [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. // The two Production_Body_Binder_* tests below are what hold this to the production configuration. 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 replayed = await ReplayFixture(controller); replayed.ShouldBe(ExpectedActions); List 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 separates them. // It separates THAT pair only — a Newtonsoft settings object that has merely lost the production // configuration reads this body exactly as production does, which is the sibling test's subject. using JsonDocument document = JsonDocument.Parse(BodyMissingRequiredMember); ContentCollection bound = Bind(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.JsonSerializer.Deserialize( 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 — which is what makes // the "shuffle" and the OkResult below assertions about the production configuration itself. (ScriptedScheduleController controller, SchedulingEngine engine) = NewSession(); using JsonDocument document = JsonDocument.Parse(BodyWithExplicitNullOrder); ContentCollection bound = Bind(document.RootElement).ShouldNotBeNull(); bound.Order.ShouldBe("shuffle"); IActionResult result = await controller.AddCollection(BuildId, bound, CancellationToken.None); result.ShouldBeOfType(); // and the content is usable, i.e. the surviving default really was a valid playback order engine.AddCount(ContentKey, 1, Option.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(); IActionResult collection = await controller.AddCollection( other, new ContentCollection { Key = ContentKey, Collection = CollectionName, Order = "chronological" }, CancellationToken.None); collection.ShouldBeOfType(); ActionResult count = controller.AddCount( other, new PlayoutCount { Content = ContentKey, Count = 1 }); count.Result.ShouldBeOfType(); } [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(); // the 400 short-circuits before the engine sees the content engine.AddCount(ContentKey, 1, Option.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(); // an unparseable filler_kind is NOT rejected the way an unparseable order is; it degrades to None ActionResult result = controller.AddCount( BuildId, new PlayoutCount { Content = ContentKey, Count = 1, FillerKind = "not-a-filler-kind" }); result.Result.ShouldBeOfType(); 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(); controller.AddCount( BuildId, new PlayoutCount { Content = ContentKey, Count = 1, FillerKind = "PreRoll" }) .Result.ShouldBeOfType(); 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(); } controller.GetContext(BuildId).Result.ShouldBeOfType(); } [Test] public void Context_Reports_Start_Finish_And_Current_Time() { (ScriptedScheduleController controller, SchedulingEngine _) = NewSession(); var ok = controller.GetContext(BuildId).Result.ShouldBeOfType(); var context = ok.Value.ShouldBeOfType(); 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(); repository.GetCollectionItemsByName(CollectionName, Arg.Any()) .Returns(_ => TestCollection()); var engine = new SchedulingEngine( repository, Substitute.For(), Substitute.For(), Substitute.For>()); // 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.None, [], [], null, [], [], TimeSpan.Zero)) .RestoreOrReset(Option.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> ReplayFixture(ScriptedScheduleController controller) { string json = await File.ReadAllTextAsync(Path.Combine(FixtureDir(), "scripted-build.json")); using JsonDocument document = JsonDocument.Parse(json); var replayed = new List(); 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(body), CancellationToken.None)); break; case "start_epg_group": ShouldBeOk(controller.StartEpgGroup(BuildId, Bind(body))); break; case "stop_epg_group": ShouldBeOk(controller.StopEpgGroup(BuildId)); break; case "add_count": ShouldBeOkContext(controller.AddCount(BuildId, Bind(body))); break; case "add_all": ShouldBeOkContext(controller.AddAll(BuildId, Bind(body))); break; case "add_duration": ShouldBeOkContext(controller.AddDuration(BuildId, Bind(body))); break; case "pad_until_exact": ShouldBeOkContext(controller.PadUntilExact(BuildId, Bind(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(JsonElement body) where T : class { body.ValueKind.ShouldBe(JsonValueKind.Object); return JsonConvert.DeserializeObject(body.GetRawText(), BodyBinderSettings).ShouldNotBeNull(); } private static void ShouldBeOk(IActionResult result) => result.ShouldBeOfType(); private static void ShouldBeOkContext(ActionResult result) { var ok = result.Result.ShouldBeOfType(); ok.Value.ShouldBeOfType(); } // Same line format as PlayoutBuildGoldenTests.Snapshot: ordered by Start then MediaItemId, raw UTC. private static string Snapshot(List items) { List 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 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) } ] }; }