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 configuration Startup hands to /// AddNewtonsoftJson, so the fixture is bound the way production binds it. That is not a /// formality — Newtonsoft and System.Text.Json disagree on the DTOs' required members, /// which pins. 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 mirror of it. private static readonly JsonSerializerSettings BodyBinderSettings = ApiJsonSettings.Create(); // A body that omits the `required` member "collection"; the two serializers disagree about it. private const string BodyMissingRequiredMember = """{"key":"content","order":"chronological"}"""; // 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 seeded title. 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 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 """; [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 items[6].Finish.ShouldBe(new DateTime(2026, 1, 15, 10, 0, 0, DateTimeKind.Utc)); } [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 a swap. 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 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(); 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 SAME configuration the production body binder uses // (Startup -> AddNewtonsoftJson -> ApiJsonSettings), so the fixture's field names and casing are // asserted against the serializer MVC actually runs rather than a lookalike that agrees by luck. private static T Bind(JsonElement body) { body.ValueKind.ShouldBe(JsonValueKind.Object); return JsonConvert.DeserializeObject(body.GetRawText(), BodyBinderSettings); } 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) } ] }; }