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)
}
]
};
}