test(563): characterize the scripted engine build API in-process and replay a committed script fixture through the real controller
#381 deferred Scripted from the playout golden net because ScriptedPlayoutBuilder shells out via Cli.Wrap to a user-authored program that drives SchedulingEngine over HTTP loopback. #563 offered two arms: a full process+Kestrel integration harness, or expanded engine coverage with the shell-out scoped out. This takes the second arm, but delivers the first arm's "documented in-process stand-in" so the scope-out is a measured claim rather than a prose one: - SchedulingEngineTests grows from 1 test to 21, covering AddCollection/AddCount/ AddAll/AddDuration/PadUntilExact, EPG guide-group locking, per-item history, the 20-call no-progress halt and its reset, and the anchor round-trip a Continue build restores from. Unknown-content-key cases assert false AND that nothing was scheduled. - ScriptedScheduleControllerTests replays Fixtures/scripted-build.json through the real ScriptedScheduleController + ScriptedPlayoutBuilderService.MockSession + SchedulingEngine and pins a 13-item snapshot in the golden line format, so there is exactly one action->engine mapping under test — the production one. It also pins the three mappings the predecessor record's "1:1 pass-through" wording hides: 404 on an unknown build id, 400 on an unparseable playback order, a SILENT fall back to FillerKind.None on an unparseable filler kind, and the InvalidOperationException -> 400 translation. MockSession was declared on IScriptedPlayoutBuilderService with zero callers; it is the seam this needs and now has one. Both fixtures are TZ-independent by construction (Chronological order plus only instant-preserving instructions) and verified passing, not skipping, under TZ=UTC, America/New_York, Australia/Lord_Howe and Asia/Kathmandu. Refs #563 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
This commit is contained in:
@@ -1,23 +1,50 @@
|
||||
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()
|
||||
{
|
||||
var engine = new SchedulingEngine(
|
||||
Substitute.For<IMediaCollectionRepository>(),
|
||||
Substitute.For<IGraphicsElementRepository>(),
|
||||
Substitute.For<IChannelRepository>(),
|
||||
Substitute.For<ILogger<SchedulingEngine>>());
|
||||
SchedulingEngine engine = NewEngine(Substitute.For<IMediaCollectionRepository>());
|
||||
|
||||
var anchor = new PlayoutAnchor
|
||||
{
|
||||
@@ -25,11 +52,464 @@ public class SchedulingEngineTests
|
||||
};
|
||||
|
||||
var start = new DateTimeOffset(new DateTime(2025, 11, 20), TimeSpan.FromHours(-6));
|
||||
var finish = start.AddDays(1);
|
||||
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);
|
||||
}
|
||||
|
||||
// 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");
|
||||
|
||||
items[3].GuideGroup.ShouldNotBe(grouped[0].GuideGroup);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"description": "Committed stand-in for a user-authored scripted schedule: the ordered sequence of calls such a script makes against /api/v1/scripted/playout/build/{buildId}, replayed through the real ScriptedScheduleController by ScriptedScheduleControllerTests. Field names and casing match what the HTTP body binder accepts. Deliberately avoids wait_until / pad_until / pad_to_next (local day and time-of-day) and shuffle order, which would make the pinned snapshot machine-timezone- or seed-dependent.",
|
||||
"script": [
|
||||
{
|
||||
"action": "add_collection",
|
||||
"body": {
|
||||
"key": "content",
|
||||
"collection": "Test Collection",
|
||||
"order": "chronological"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "start_epg_group",
|
||||
"body": {
|
||||
"advance": true,
|
||||
"customTitle": "Morning Block"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "add_count",
|
||||
"body": {
|
||||
"content": "content",
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "stop_epg_group"
|
||||
},
|
||||
{
|
||||
"action": "add_duration",
|
||||
"body": {
|
||||
"content": "content",
|
||||
"duration": "2:00:00",
|
||||
"trim": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "pad_until_exact",
|
||||
"body": {
|
||||
"content": "content",
|
||||
"when": "2026-01-15T10:00:00+00:00",
|
||||
"trim": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "add_all",
|
||||
"body": {
|
||||
"content": "content"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
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 LanguageExt;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// In-process stand-in for a scripted playout build (ersatztv#563). A committed script fixture
|
||||
/// (<c>Fixtures/scripted-build.json</c>) is replayed through the REAL
|
||||
/// <see cref="ScriptedScheduleController" />, the REAL <see cref="ScriptedPlayoutBuilderService" /> and the
|
||||
/// REAL <see cref="SchedulingEngine" />, and the resulting <c>PlayoutItem</c>s 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 <see cref="ScriptedPlayoutBuilderService.MockSession" />, the seam that exists for exactly this.
|
||||
/// <para>
|
||||
/// This covers everything the production pipeline does EXCEPT the two hops it cannot host: the
|
||||
/// <c>Cli.Wrap</c> launch of the user's own program (exit code, timeout, stdout capture) and the
|
||||
/// Kestrel/middleware/auth transport the program calls back over. Those are permanently out of the
|
||||
/// automated suite; the reasoning and the named residue are in docs/testing.md → "Scripted playout
|
||||
/// coverage" and the decision record <c>testing.scripted-engine-in-process-net</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// <c>ErsatzTV.Core.Tests</c>, which cannot reference a controller, and duplicating it here would
|
||||
/// create a second action-to-engine mapping — the thing this design exists to avoid.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[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);
|
||||
|
||||
// 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<string> replayed = await ReplayFixture(controller);
|
||||
replayed.ShouldBe(ExpectedActions);
|
||||
|
||||
List<PlayoutItem> 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();
|
||||
|
||||
// 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 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<NotFoundObjectResult>();
|
||||
|
||||
IActionResult collection = await controller.AddCollection(
|
||||
other,
|
||||
new ContentCollection { Key = ContentKey, Collection = CollectionName, Order = "chronological" },
|
||||
CancellationToken.None);
|
||||
collection.ShouldBeOfType<NotFoundObjectResult>();
|
||||
|
||||
ActionResult<PlayoutContext> count = controller.AddCount(
|
||||
other,
|
||||
new PlayoutCount { Content = ContentKey, Count = 1 });
|
||||
count.Result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[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<BadRequestObjectResult>();
|
||||
|
||||
// the 400 short-circuits before the engine sees the content
|
||||
engine.AddCount(ContentKey, 1, Option<FillerKind>.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<OkResult>();
|
||||
|
||||
// an unparseable filler_kind is NOT rejected the way an unparseable order is; it degrades to None
|
||||
ActionResult<PlayoutContext> result = controller.AddCount(
|
||||
BuildId,
|
||||
new PlayoutCount { Content = ContentKey, Count = 1, FillerKind = "not-a-filler-kind" });
|
||||
|
||||
result.Result.ShouldBeOfType<OkObjectResult>();
|
||||
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<OkResult>();
|
||||
|
||||
controller.AddCount(
|
||||
BuildId,
|
||||
new PlayoutCount { Content = ContentKey, Count = 1, FillerKind = "PreRoll" })
|
||||
.Result.ShouldBeOfType<OkObjectResult>();
|
||||
|
||||
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<OkObjectResult>();
|
||||
}
|
||||
|
||||
controller.GetContext(BuildId).Result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Context_Reports_Start_Finish_And_Current_Time()
|
||||
{
|
||||
(ScriptedScheduleController controller, SchedulingEngine _) = NewSession();
|
||||
|
||||
var ok = controller.GetContext(BuildId).Result.ShouldBeOfType<OkObjectResult>();
|
||||
var context = ok.Value.ShouldBeOfType<PlayoutContext>();
|
||||
|
||||
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<IMediaCollectionRepository>();
|
||||
repository.GetCollectionItemsByName(CollectionName, Arg.Any<CancellationToken>())
|
||||
.Returns(_ => TestCollection());
|
||||
|
||||
var engine = new SchedulingEngine(
|
||||
repository,
|
||||
Substitute.For<IGraphicsElementRepository>(),
|
||||
Substitute.For<IChannelRepository>(),
|
||||
Substitute.For<ILogger<SchedulingEngine>>());
|
||||
|
||||
// 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<Deco>.None, [], [], null, [], [], TimeSpan.Zero))
|
||||
.RestoreOrReset(Option<PlayoutAnchor>.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<List<string>> ReplayFixture(ScriptedScheduleController controller)
|
||||
{
|
||||
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
string json = await File.ReadAllTextAsync(Path.Combine(FixtureDir(), "scripted-build.json"));
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(json);
|
||||
var replayed = new List<string>();
|
||||
|
||||
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<ContentCollection>(body, options),
|
||||
CancellationToken.None));
|
||||
break;
|
||||
case "start_epg_group":
|
||||
ShouldBeOk(controller.StartEpgGroup(BuildId, Bind<ControlStartEpgGroup>(body, options)));
|
||||
break;
|
||||
case "stop_epg_group":
|
||||
ShouldBeOk(controller.StopEpgGroup(BuildId));
|
||||
break;
|
||||
case "add_count":
|
||||
ShouldBeOkContext(controller.AddCount(BuildId, Bind<PlayoutCount>(body, options)));
|
||||
break;
|
||||
case "add_all":
|
||||
ShouldBeOkContext(controller.AddAll(BuildId, Bind<ContentAll>(body, options)));
|
||||
break;
|
||||
case "add_duration":
|
||||
ShouldBeOkContext(controller.AddDuration(BuildId, Bind<PlayoutDuration>(body, options)));
|
||||
break;
|
||||
case "pad_until_exact":
|
||||
ShouldBeOkContext(controller.PadUntilExact(BuildId, Bind<PlayoutPadUntilExact>(body, options)));
|
||||
break;
|
||||
default:
|
||||
Assert.Fail($"Fixture uses action '{action}', which the replayer does not implement.");
|
||||
break;
|
||||
}
|
||||
|
||||
replayed.Add(action);
|
||||
}
|
||||
|
||||
return replayed;
|
||||
}
|
||||
|
||||
private static T Bind<T>(JsonElement body, JsonSerializerOptions options)
|
||||
{
|
||||
body.ValueKind.ShouldBe(JsonValueKind.Object);
|
||||
return body.Deserialize<T>(options);
|
||||
}
|
||||
|
||||
private static void ShouldBeOk(IActionResult result) => result.ShouldBeOfType<OkResult>();
|
||||
|
||||
private static void ShouldBeOkContext(ActionResult<PlayoutContext> result)
|
||||
{
|
||||
var ok = result.Result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldBeOfType<PlayoutContext>();
|
||||
}
|
||||
|
||||
// Same line format as PlayoutBuildGoldenTests.Snapshot: ordered by Start then MediaItemId, raw UTC.
|
||||
private static string Snapshot(List<PlayoutItem> items)
|
||||
{
|
||||
List<PlayoutItem> 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<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)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user