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
520 lines
19 KiB
C#
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);
|
|
}
|