Files
ersatztv/ErsatzTV.Core.Tests/Scheduling/Engine/SchedulingEngineTests.cs
T
timothyandClaude Fable 5.1 b3bc3ed115 test(563): pin per-item guide-group advancement, which mutant b showed was unmeasured
Removing SchedulingEngine.AddCountInternal's _state.AdvanceGuideGroup() left both
guide-group assertions green: the locked-group test only compared inside/outside
the group, and the snapshot only compared item 2 to item 0. Assert the actual
sequence instead.

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

520 lines
19 KiB
C#

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;
/// <summary>
/// Characterization of the <see cref="SchedulingEngine" /> build API — the surface a scripted schedule
/// drives, one method per <c>ScriptedScheduleController</c> action. Covers content registration
/// (<c>AddCollection</c>), the scheduling instructions (<c>AddCount</c>, <c>AddAll</c>,
/// <c>AddDuration</c>, <c>PadUntilExact</c>), EPG grouping, per-item history, the no-progress halt, and
/// the anchor round-trip that a Continue build restores from.
/// <para>
/// Deliberately out of scope here: the <c>Cli.Wrap</c> 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 <c>ErsatzTV.Tests/Controllers/ScriptedScheduleControllerTests</c>. Decision:
/// <c>testing.scripted-engine-in-process-net</c>.
/// </para>
/// <para>
/// Every fixture here is timezone-independent by construction: it uses only Chronological order plus
/// <c>AddCount</c>/<c>AddAll</c>/<c>AddDuration</c>/<c>PadUntilExact</c>, all of which preserve the
/// instant. <c>WaitUntil(TimeOnly)</c> and <c>PadUntil(string)</c> read the LOCAL day and time-of-day
/// and are therefore excluded. See docs/testing.md → Timezone independence.
/// </para>
/// </summary>
[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<IMediaCollectionRepository>());
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<FillerKind>.None, null, false).ShouldBeTrue();
List<PlayoutItem> 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<FillerKind>.None, null, false).ShouldBeTrue();
List<PlayoutItem> 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<FillerKind>.None,
customTitle: null,
disableWatermarks: false)
.ShouldBeTrue();
DateTimeOffset target = Start.AddHours(2);
List<PlayoutItem> 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<FillerKind>.None,
customTitle: null,
disableWatermarks: false)
.ShouldBeTrue();
DateTimeOffset target = Start.AddHours(2);
List<PlayoutItem> 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<FillerKind>.None,
customTitle: null,
disableWatermarks: false)
.ShouldBeTrue();
List<PlayoutItem> 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<FillerKind>.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<FillerKind>.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<FillerKind>.None, null, false),
"add_count" => engine.AddCount(Unknown, 1, Option<FillerKind>.None, null, false),
"add_duration" => engine.AddDuration(
Unknown,
"1:00:00",
null,
false,
0,
true,
false,
Option<FillerKind>.None,
null,
false),
"pad_to_next" => engine.PadToNext(
Unknown,
15,
null,
false,
0,
true,
false,
Option<FillerKind>.None,
null,
false),
"pad_until" => engine.PadUntil(
Unknown,
"07:00",
false,
null,
false,
0,
true,
false,
Option<FillerKind>.None,
null,
false),
"pad_until_exact" => engine.PadUntilExact(
Unknown,
Start.AddHours(1),
null,
false,
0,
true,
false,
Option<FillerKind>.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<IMediaCollectionRepository>();
repository.GetCollectionItemsByName(CollectionName, Arg.Any<CancellationToken>())
.Returns(new List<MediaItem>());
SchedulingEngine engine = ResetEngine(repository);
await engine.AddCollection(ContentKey, CollectionName, PlaybackOrder.Chronological, CancellationToken.None);
engine.AddCount(ContentKey, 1, Option<FillerKind>.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<FillerKind>.None, null, false).ShouldBeTrue();
engine.UnlockGuideGroup();
engine.AddCount(ContentKey, 1, Option<FillerKind>.None, null, false).ShouldBeTrue();
List<PlayoutItem> items = engine.GetState().AddedItems;
items.Count.ShouldBe(4);
List<PlayoutItem> 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<FillerKind>.None, null, false).ShouldBeTrue();
List<PlayoutItem> items = engine.GetState().AddedItems;
List<PlayoutHistory> 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<IMediaCollectionRepository>());
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<InvalidOperationException>(() => _ = 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<FillerKind>.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<FillerKind>.None, null, false).ShouldBeTrue();
List<PlayoutItem> 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<FillerKind>.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<IGraphicsElementRepository>(),
Substitute.For<IChannelRepository>(),
Substitute.For<ILogger<SchedulingEngine>>());
// 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<PlayoutAnchor>.None);
return engine;
}
private static async Task<SchedulingEngine> ResetEngineWithCollection()
{
SchedulingEngine engine = ResetEngine(CollectionRepository());
await engine.AddCollection(ContentKey, CollectionName, PlaybackOrder.Chronological, CancellationToken.None);
return engine;
}
private static IMediaCollectionRepository CollectionRepository()
{
var repository = Substitute.For<IMediaCollectionRepository>();
repository.GetCollectionItemsByName(CollectionName, Arg.Any<CancellationToken>())
.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<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)
}
]
};
private static PlayoutReferenceData EmptyReferenceData() =>
new(null, Option<Deco>.None, [], [], null, [], [], TimeSpan.Zero);
}