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 Microsoft.Extensions.Logging; using NSubstitute; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Core.Tests.Scheduling.Engine; /// /// Characterization of the build API — the surface a scripted schedule /// drives, one method per ScriptedScheduleController action. Covers content registration /// (AddCollection), the scheduling instructions (AddCount, AddAll, /// AddDuration, PadUntilExact), EPG grouping, per-item history, the no-progress halt, and /// the anchor round-trip that a Continue build restores from. /// /// Deliberately out of scope here: the Cli.Wrap launch of the user-authored script process /// (exit code, timeout, stdout capture) and the Kestrel/HTTP/auth transport it calls back over. Those /// are permanently outside the automated suite; the controller adapter that sits between them and this /// engine is pinned by ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests. Decision: /// testing.scripted-engine-in-process-net. /// /// /// Every fixture here is timezone-independent by construction: it uses only Chronological order plus /// AddCount/AddAll/AddDuration/PadUntilExact, all of which preserve the /// instant. WaitUntil(TimeOnly) and PadUntil(string) read the LOCAL day and time-of-day /// and are therefore excluded. See docs/testing.md → Timezone independence. /// /// [TestFixture] public class SchedulingEngineTests { private const string ContentKey = "content"; private const string CollectionName = "Test Collection"; // Pinned build window, offset zero: the engine writes PlayoutItem.Start/Finish as UtcDateTime, so every // assertion below is on an instant rather than a wall-clock reading. private static readonly DateTimeOffset Start = new(2026, 1, 15, 6, 0, 0, TimeSpan.Zero); [Test] public void Continue_Across_Time_Change() { SchedulingEngine engine = NewEngine(Substitute.For()); var anchor = new PlayoutAnchor { NextStart = new DateTimeOffset(new DateTime(2025, 10, 26), TimeSpan.FromHours(-5)).UtcDateTime }; var start = new DateTimeOffset(new DateTime(2025, 11, 20), TimeSpan.FromHours(-6)); DateTimeOffset finish = start.AddDays(1); engine.BuildBetween(start, finish); // should not throw engine.RestoreOrReset(anchor); } [Test] public async Task AddCollection_Then_AddCount_Lays_Items_Back_To_Back() { SchedulingEngine engine = await ResetEngineWithCollection(); engine.AddCount(ContentKey, 4, Option.None, null, false).ShouldBeTrue(); List items = engine.GetState().AddedItems; items.Count.ShouldBe(4); // chronological order is the collection's release-date order, which is item id order here items.Select(i => i.MediaItemId).ShouldBe([1, 2, 3, 4]); items[0].Start.ShouldBe(Start.UtcDateTime); for (var i = 1; i < items.Count; i++) { items[i].Start.ShouldBe(items[i - 1].Finish); } foreach (PlayoutItem item in items) { item.FillerKind.ShouldBe(FillerKind.None); item.InPoint.ShouldBe(TimeSpan.Zero); item.OutPoint.ShouldBe(item.Finish - item.Start); item.PlayoutId.ShouldBe(1); } // outside an EPG group every item opens its own guide group items.Select(i => i.GuideGroup).ShouldBe([1, 2, 3, 4]); // 30 + 45 + 60 + 20 minutes of content TimeSpan scheduled = TimeSpan.FromMinutes(155); items[^1].Finish.ShouldBe(Start.UtcDateTime + scheduled); engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start + scheduled); } [Test] public async Task AddAll_Schedules_Every_Item_Once() { SchedulingEngine engine = await ResetEngineWithCollection(); engine.AddAll(ContentKey, Option.None, null, false).ShouldBeTrue(); List items = engine.GetState().AddedItems; items.Select(i => i.MediaItemId).ShouldBe([1, 2, 3, 4, 5, 6]); // 30 + 45 + 60 + 20 + 90 + 15 minutes engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start + TimeSpan.FromMinutes(260)); } [Test] public async Task AddDuration_Stops_Before_Overrunning_The_Target() { SchedulingEngine engine = await ResetEngineWithCollection(); engine.AddDuration( ContentKey, "2:00:00", fallback: null, trim: false, discardAttempts: 0, stopBeforeEnd: true, offlineTail: false, Option.None, customTitle: null, disableWatermarks: false) .ShouldBeTrue(); DateTimeOffset target = Start.AddHours(2); List items = engine.GetState().AddedItems; // 30 + 45 fits; the third item (60) does not, and nothing is trimmed items.Select(i => i.MediaItemId).ShouldBe([1, 2]); items[^1].Finish.ShouldBe(Start.UtcDateTime + TimeSpan.FromMinutes(75)); items[^1].Finish.ShouldBeLessThan(target.UtcDateTime); engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start + TimeSpan.FromMinutes(75)); } [Test] public async Task AddDuration_Trims_The_Last_Item_When_Trim_Is_Set() { SchedulingEngine engine = await ResetEngineWithCollection(); engine.AddDuration( ContentKey, "2:00:00", fallback: null, trim: true, discardAttempts: 0, stopBeforeEnd: true, offlineTail: false, Option.None, customTitle: null, disableWatermarks: false) .ShouldBeTrue(); DateTimeOffset target = Start.AddHours(2); List items = engine.GetState().AddedItems; items.Select(i => i.MediaItemId).ShouldBe([1, 2, 3]); items[^1].Finish.ShouldBe(target.UtcDateTime); items[^1].OutPoint.ShouldBe(items[^1].Finish - items[^1].Start); items[^1].OutPoint.ShouldBe(TimeSpan.FromMinutes(45)); engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(target); } [Test] public async Task PadUntilExact_Fills_To_The_Target_Instant() { SchedulingEngine engine = await ResetEngineWithCollection(); DateTimeOffset target = Start.AddHours(2); engine.PadUntilExact( ContentKey, target, fallback: null, trim: true, discardAttempts: 0, stopBeforeEnd: true, offlineTail: false, Option.None, customTitle: null, disableWatermarks: false) .ShouldBeTrue(); List items = engine.GetState().AddedItems; items.Count.ShouldBeGreaterThan(0); items[0].Start.ShouldBe(Start.UtcDateTime); items[^1].Finish.ShouldBe(target.UtcDateTime); // the target is an instant, so a machine-local offset must not move it engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(target); } [Test] public async Task AddDuration_Rejects_An_Unparseable_Duration() { SchedulingEngine engine = await ResetEngineWithCollection(); engine.AddDuration( ContentKey, "not-a-duration", fallback: null, trim: false, discardAttempts: 0, stopBeforeEnd: true, offlineTail: false, Option.None, customTitle: null, disableWatermarks: false) .ShouldBeFalse(); engine.GetState().AddedItems.ShouldBeEmpty(); engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start); } [Test] public async Task AddDuration_Rejects_Offline_Tail_Without_Stop_Before_End() { SchedulingEngine engine = await ResetEngineWithCollection(); engine.AddDuration( ContentKey, "2:00:00", fallback: null, trim: false, discardAttempts: 0, stopBeforeEnd: false, offlineTail: true, Option.None, customTitle: null, disableWatermarks: false) .ShouldBeFalse(); engine.GetState().AddedItems.ShouldBeEmpty(); } [TestCase("add_all")] [TestCase("add_count")] [TestCase("add_duration")] [TestCase("pad_to_next")] [TestCase("pad_until")] [TestCase("pad_until_exact")] public async Task Unknown_Content_Key_Returns_False_And_Schedules_Nothing(string instruction) { SchedulingEngine engine = await ResetEngineWithCollection(); const string Unknown = "no-such-key"; bool result = instruction switch { "add_all" => engine.AddAll(Unknown, Option.None, null, false), "add_count" => engine.AddCount(Unknown, 1, Option.None, null, false), "add_duration" => engine.AddDuration( Unknown, "1:00:00", null, false, 0, true, false, Option.None, null, false), "pad_to_next" => engine.PadToNext( Unknown, 15, null, false, 0, true, false, Option.None, null, false), "pad_until" => engine.PadUntil( Unknown, "07:00", false, null, false, 0, true, false, Option.None, null, false), "pad_until_exact" => engine.PadUntilExact( Unknown, Start.AddHours(1), null, false, 0, true, false, Option.None, null, false), _ => throw new ArgumentOutOfRangeException(nameof(instruction)) }; result.ShouldBeFalse(); // the false is only meaningful if nothing was scheduled behind it engine.GetState().AddedItems.ShouldBeEmpty(); engine.GetState().CurrentTime.ToUniversalTime().ShouldBe(Start); } [Test] public async Task Empty_Collection_Is_Skipped() { var repository = Substitute.For(); repository.GetCollectionItemsByName(CollectionName, Arg.Any()) .Returns(new List()); SchedulingEngine engine = ResetEngine(repository); await engine.AddCollection(ContentKey, CollectionName, PlaybackOrder.Chronological, CancellationToken.None); engine.AddCount(ContentKey, 1, Option.None, null, false).ShouldBeFalse(); engine.GetState().AddedItems.ShouldBeEmpty(); } [Test] public async Task Filler_Kind_And_Custom_Title_Reach_The_Item() { SchedulingEngine engine = await ResetEngineWithCollection(); engine.AddCount(ContentKey, 1, FillerKind.PreRoll, "Bumper", true).ShouldBeTrue(); PlayoutItem item = engine.GetState().AddedItems.Single(); item.FillerKind.ShouldBe(FillerKind.PreRoll); item.CustomTitle.ShouldBe("Bumper"); item.DisableWatermarks.ShouldBeTrue(); } [Test] public async Task Guide_Group_Is_Locked_Across_An_Epg_Group() { SchedulingEngine engine = await ResetEngineWithCollection(); engine.LockGuideGroup(advance: true, customTitle: "Block"); engine.AddCount(ContentKey, 3, Option.None, null, false).ShouldBeTrue(); engine.UnlockGuideGroup(); engine.AddCount(ContentKey, 1, Option.None, null, false).ShouldBeTrue(); List items = engine.GetState().AddedItems; items.Count.ShouldBe(4); List grouped = items.Take(3).ToList(); grouped.Select(i => i.GuideGroup).Distinct().Count().ShouldBe(1); grouped.ShouldAllBe(i => i.CustomTitle == "Block"); // unlocking resumes per-item advancement from the group's number items[3].GuideGroup.ShouldBe(grouped[0].GuideGroup + 1); items[3].CustomTitle.ShouldBeNull(); } [Test] public async Task History_Is_Recorded_Per_Item() { SchedulingEngine engine = await ResetEngineWithCollection(); engine.AddCount(ContentKey, 3, Option.None, null, false).ShouldBeTrue(); List items = engine.GetState().AddedItems; List history = engine.GetState().AddedHistory; history.Count.ShouldBe(items.Count); string expectedKey = HistoryDetails.KeyForSchedulingContent(ContentKey, PlaybackOrder.Chronological); for (var i = 0; i < history.Count; i++) { history[i].Key.ShouldBe(expectedKey); history[i].PlayoutId.ShouldBe(1); history[i].PlaybackOrder.ShouldBe(PlaybackOrder.Chronological); history[i].Index.ShouldBe(i); history[i].When.ShouldBe(items[i].Start); history[i].Finish.ShouldBe(items[i].Finish); } } [Test] public void Is_Done_Throws_After_Twenty_Consecutive_Calls_Without_Progress() { SchedulingEngine engine = ResetEngine(Substitute.For()); ISchedulingEngineState state = engine.GetState(); // the first read establishes the baseline; each of the next 19 increments the no-progress counter for (var i = 0; i < 20; i++) { state.IsDone.ShouldBeFalse(); } Should.Throw(() => _ = state.IsDone); } [Test] public async Task Is_Done_Counter_Resets_When_Time_Advances() { SchedulingEngine engine = await ResetEngineWithCollection(); ISchedulingEngineState state = engine.GetState(); for (var i = 0; i < 20; i++) { state.IsDone.ShouldBeFalse(); } // one instruction that advances CurrentTime clears the counter, so the budget starts over engine.AddCount(ContentKey, 1, Option.None, null, false).ShouldBeTrue(); for (var i = 0; i < 20; i++) { state.IsDone.ShouldBeFalse(); } } [Test] public async Task Anchor_Round_Trips_Through_Restore() { SchedulingEngine first = await ResetEngineWithCollection(); first.AddCount(ContentKey, 2, Option.None, null, false).ShouldBeTrue(); List firstItems = first.GetState().AddedItems; int lastGuideGroup = firstItems[^1].GuideGroup; PlayoutAnchor anchor = first.GetAnchor(); anchor.NextStart.ShouldBe(firstItems[^1].Finish); SchedulingEngine second = NewEngine(CollectionRepository()); second.WithPlayoutId(1) .WithMode(PlayoutBuildMode.Continue) .WithSeed(0) .BuildBetween(Start, Start.AddDays(1)) .WithReferenceData(EmptyReferenceData()) .RestoreOrReset(anchor); // the anchor carries an instant, not a wall-clock reading second.GetState().CurrentTime.ToUniversalTime().ShouldBe(new DateTimeOffset(anchor.NextStart, TimeSpan.Zero)); await second.AddCollection(ContentKey, CollectionName, PlaybackOrder.Chronological, CancellationToken.None); second.AddCount(ContentKey, 1, Option.None, null, false).ShouldBeTrue(); PlayoutItem resumed = second.GetState().AddedItems.Single(); resumed.Start.ShouldBe(anchor.NextStart); // the guide group continues from the serialized context instead of restarting at 1 resumed.GuideGroup.ShouldBe(lastGuideGroup + 1); } private static SchedulingEngine NewEngine(IMediaCollectionRepository repository) => new( repository, Substitute.For(), Substitute.For(), Substitute.For>()); // WithReferenceData must precede RestoreOrReset and AddCollection: both dereference // PlayoutReferenceData.PlayoutHistory, so a different order fails with a null reference that reads // like an engine bug. This is the same order ScriptedPlayoutBuilder uses. private static SchedulingEngine ResetEngine(IMediaCollectionRepository repository) { SchedulingEngine engine = NewEngine(repository); engine.WithPlayoutId(1) .WithMode(PlayoutBuildMode.Reset) .WithSeed(0) .BuildBetween(Start, Start.AddDays(1)) .WithReferenceData(EmptyReferenceData()) .RestoreOrReset(Option.None); return engine; } private static async Task ResetEngineWithCollection() { SchedulingEngine engine = ResetEngine(CollectionRepository()); await engine.AddCollection(ContentKey, CollectionName, PlaybackOrder.Chronological, CancellationToken.None); return engine; } private static IMediaCollectionRepository CollectionRepository() { var repository = Substitute.For(); repository.GetCollectionItemsByName(CollectionName, Arg.Any()) .Returns(_ => TestCollection()); return repository; } // Distinct release dates make chronological order deterministic (id order); distinct durations make // every boundary in an assertion 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) } ] }; private static PlayoutReferenceData EmptyReferenceData() => new(null, Option.None, [], [], null, [], [], TimeSpan.Zero); }