No content change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
414 lines
19 KiB
C#
414 lines
19 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 ErsatzTV.Serialization;
|
|
using LanguageExt;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Logging;
|
|
using Newtonsoft.Json;
|
|
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>
|
|
/// The hops this cannot host, named rather than implied: the <c>Cli.Wrap</c> launch of the user's
|
|
/// own program (exit code, timeout, stdout capture); the Kestrel/middleware/auth transport the
|
|
/// program calls back over; and MVC's binding <i>wrapper</i> — the input formatter's content-type
|
|
/// selection and malformed-body handling, model validation (a non-nullable reference type picks
|
|
/// up an implicit required check there), and the <c>[ApiController]</c> automatic 400 either
|
|
/// produces before an action runs, since every test here hands an action an already-bound object.
|
|
/// The serializer <i>inside</i> that wrapper is not residue: request bodies are deserialized with
|
|
/// <see cref="ApiJsonSettings" />, the same configuration <c>Startup</c> hands to
|
|
/// <c>AddNewtonsoftJson</c>, so the fixture is bound the way production binds it. That is not a
|
|
/// formality — Newtonsoft and System.Text.Json disagree on the DTOs' <c>required</c> members,
|
|
/// which <see cref="Production_Body_Binder_Ignores_Required_Members" /> pins. The reasoning for
|
|
/// the scope-out is 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);
|
|
|
|
// What Startup hands to AddNewtonsoftJson, from the same function rather than a mirror of it.
|
|
private static readonly JsonSerializerSettings BodyBinderSettings = ApiJsonSettings.Create();
|
|
|
|
// A body that omits the `required` member "collection"; the two serializers disagree about it.
|
|
private const string BodyMissingRequiredMember = """{"key":"content","order":"chronological"}""";
|
|
|
|
// 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 void Production_Body_Binder_Ignores_Required_Members()
|
|
{
|
|
// The replay above is only faithful while Bind uses MVC's own serializer, and "the two agree" is
|
|
// not a safe assumption to leave unstated: Newtonsoft has no notion of the C# `required` keyword,
|
|
// so a body omitting one deserializes to a default, where System.Text.Json rejects it outright.
|
|
// The claim is about the SERIALIZER only — what MVC's validation layer then does with such a
|
|
// body (non-nullable reference types pick up an implicit required check) is the uncovered
|
|
// wrapper, not this. Binding through Bind itself is what gives this teeth: the fixture's own
|
|
// bodies parse identically under either serializer, so only a body like this one witnesses a swap.
|
|
using JsonDocument document = JsonDocument.Parse(BodyMissingRequiredMember);
|
|
|
|
ContentCollection bound = Bind<ContentCollection>(document.RootElement).ShouldNotBeNull();
|
|
bound.Key.ShouldBe(ContentKey);
|
|
bound.Collection.ShouldBeNull();
|
|
|
|
// the negative control: the same body under the serializer a swap would reach for
|
|
Should.Throw<System.Text.Json.JsonException>(
|
|
() => System.Text.Json.JsonSerializer.Deserialize<ContentCollection>(
|
|
BodyMissingRequiredMember,
|
|
new JsonSerializerOptions(JsonSerializerDefaults.Web)));
|
|
}
|
|
|
|
[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)
|
|
{
|
|
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),
|
|
CancellationToken.None));
|
|
break;
|
|
case "start_epg_group":
|
|
ShouldBeOk(controller.StartEpgGroup(BuildId, Bind<ControlStartEpgGroup>(body)));
|
|
break;
|
|
case "stop_epg_group":
|
|
ShouldBeOk(controller.StopEpgGroup(BuildId));
|
|
break;
|
|
case "add_count":
|
|
ShouldBeOkContext(controller.AddCount(BuildId, Bind<PlayoutCount>(body)));
|
|
break;
|
|
case "add_all":
|
|
ShouldBeOkContext(controller.AddAll(BuildId, Bind<ContentAll>(body)));
|
|
break;
|
|
case "add_duration":
|
|
ShouldBeOkContext(controller.AddDuration(BuildId, Bind<PlayoutDuration>(body)));
|
|
break;
|
|
case "pad_until_exact":
|
|
ShouldBeOkContext(controller.PadUntilExact(BuildId, Bind<PlayoutPadUntilExact>(body)));
|
|
break;
|
|
default:
|
|
Assert.Fail($"Fixture uses action '{action}', which the replayer does not implement.");
|
|
break;
|
|
}
|
|
|
|
replayed.Add(action);
|
|
}
|
|
|
|
return replayed;
|
|
}
|
|
|
|
// Deserializes a fixture body with the SAME configuration the production body binder uses
|
|
// (Startup -> AddNewtonsoftJson -> ApiJsonSettings), so the fixture's field names and casing are
|
|
// asserted against the serializer MVC actually runs rather than a lookalike that agrees by luck.
|
|
private static T Bind<T>(JsonElement body)
|
|
{
|
|
body.ValueKind.ShouldBe(JsonValueKind.Object);
|
|
return JsonConvert.DeserializeObject<T>(body.GetRawText(), BodyBinderSettings);
|
|
}
|
|
|
|
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)
|
|
}
|
|
]
|
|
};
|
|
}
|