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
373 lines
16 KiB
C#
373 lines
16 KiB
C#
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();
|
|
|
|
// 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
|
|
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)
|
|
}
|
|
]
|
|
};
|
|
}
|