test(381): golden coverage for Sequential (YAML) playout builder; document Scripted deferral
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 28s
PR Gates / Docs update reminder (pull_request) Successful in 46s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m53s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 18s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m29s
PR Gates / decisions lifecycle (pull_request) Successful in 18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Extends the #163 PlayoutBuildGoldenTests in-memory net to the Sequential (YAML)
builder: a committed fixture (Goldens/Fixtures/sequential-schedule.yml) with two
`count: 2` instructions over one chronological collection, built via
SequentialPlayoutBuilder over the pinned window. The count/all/duration handlers
do UTC-only arithmetic off the caller-supplied start, so the case is
TZ-independent (passes, not skips, under a non-UTC TZ) and needs no Assume guard.
Non-vacuity: a fixture count tweak flips the golden + the contiguity assertion.

Scripted is deliberately excluded from the golden net — ScriptedPlayoutBuilder
shells out via Cli.Wrap to an external process that drives SchedulingEngine over
HTTP, which no in-memory golden can characterize. Recorded as the Done-when
"documented decision" arm in docs/decisions.md
(testing.scripted-playout-golden-deferred) + docs/testing.md; the scripted
integration harness is tracked as follow-up #563.

fixes #381

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 19:18:12 +02:00
co-authored by Claude Opus 4.8
parent 1681ae4e60
commit 80a9824a4e
6 changed files with 262 additions and 4 deletions
@@ -0,0 +1,18 @@
# Deterministic Sequential (YAML) schedule fixture for
# PlayoutBuildGoldenTests.Sequential_yaml (ersatztv#381).
#
# Two `count` instructions over ONE chronological collection. The content enumerator is cached by key,
# so it continues across the two instructions: items 1-2 come from the first `count`, items 3-4 from the
# second. `order: chronological` + literal integer counts keep the build free of shuffle-seed, RNG, and
# wall-clock/local-time dependence, so the snapshot of raw UTC Start/Finish is machine-timezone-independent
# (no Assume guard needed, unlike the Block golden). Do not introduce `wait_until` / `pad_to_next` /
# `pad_until` (local-time-of-day) or a `shuffle` order without revisiting that determinism claim.
content:
- collection: Sequential Test Collection
key: movies
order: chronological
playout:
- count: 2
content: movies
- count: 2
content: movies
@@ -0,0 +1,4 @@
000 | 2026-01-15 06:00:00 - 2026-01-15 06:30:00 | None | Sequential Movie 01
001 | 2026-01-15 06:30:00 - 2026-01-15 07:15:00 | None | Sequential Movie 02
002 | 2026-01-15 07:15:00 - 2026-01-15 08:15:00 | None | Sequential Movie 03
003 | 2026-01-15 08:15:00 - 2026-01-15 08:45:00 | None | Sequential Movie 04
@@ -6,10 +6,12 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling.BlockScheduling;
using ErsatzTV.Core.Scheduling.YamlScheduling;
using ErsatzTV.Infrastructure;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
@@ -134,6 +136,36 @@ public class PlayoutBuildGoldenTests
[Test]
public Task Classic_weighted() => Verify("classic-weighted.txt", BuildWeightedPlayout);
// Sequential (YAML) builder (#381, follow-up to #163). SequentialPlayoutBuilder reads a YAML schedule
// file (Playout.ScheduleFile) instead of a ProgramSchedule/Block calendar. The committed fixture
// Goldens/Fixtures/sequential-schedule.yml schedules two `count: 2` instructions over one chronological
// collection, so the builder lays exactly four items back-to-back from the pinned start (the enumerator
// is cached by content key and continues across the two instructions). Besides the golden we assert the
// contiguity invariant: it is what "sequential" means here, and a deliberate change to a count or a
// duration flips both the assertion and the golden (#12 non-vacuity). The count/all/duration handlers do
// pure UTC arithmetic off the caller-supplied start (no TimeZoneInfo.Local / ToLocalTime), so this case
// is TZ-independent and needs no Assume guard — unlike Block, and unlike the wait_until/pad_* handlers
// the fixture deliberately avoids.
[Test]
public async Task Sequential_yaml()
{
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSequentialPlayout();
List<PlayoutItem> ordered = items.OrderBy(i => i.Start).ToList();
ordered.Count.ShouldBe(4);
ordered.ShouldAllBe(i => i.FillerKind == FillerKind.None);
ordered[0].Start.ShouldBe(Start.UtcDateTime);
for (var i = 1; i < ordered.Count; i++)
{
ordered[i].Start.ShouldBe(
ordered[i - 1].Finish,
$"sequential item {i} at {ordered[i].Start:HH:mm:ss} is not contiguous with the previous finish");
}
await CompareGolden("sequential-yaml.txt", items, titles);
}
[Test]
[Explicit("Regenerates all playout goldens from current output; review the diff before committing.")]
public async Task Regenerate_goldens()
@@ -142,7 +174,10 @@ public class PlayoutBuildGoldenTests
try
{
foreach (Func<Task> regen in new Func<Task>[]
{ Classic_chronological, Block_playout, Classic_clock_padded, Classic_shuffle })
{
Classic_chronological, Block_playout, Classic_clock_padded, Classic_shuffle,
Classic_weighted, Sequential_yaml
})
{
try
{
@@ -1141,6 +1176,166 @@ public class PlayoutBuildGoldenTests
TimeSpan.Zero);
}
// --- Sequential (YAML) builder (issue #381) ---
//
// SequentialPlayoutBuilder reads its schedule from a YAML file at Playout.ScheduleFile. It checks the
// file's existence through the injected IFileSystem but reads the bytes with the static System.IO.File,
// so the test writes a REAL committed fixture on disk (Goldens/Fixtures/sequential-schedule.yml) and
// only stubs IFileSystem.File.Exists -> true. The schema validator is stubbed (the real one loads a JSON
// schema from a runtime cache folder that a unit test has no reason to populate); this golden locks the
// BUILDER's PlayoutItem output, not the validator, which is a separate surface.
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildSequentialPlayout()
{
var cancellationToken = CancellationToken.None;
var (playoutId, titles) = await SeedSequentialData(cancellationToken);
var fileSystem = Substitute.For<System.IO.Abstractions.IFileSystem>();
fileSystem.File.Exists(Arg.Any<string>()).Returns(true);
var validator = Substitute.For<ISequentialScheduleValidator>();
validator.ValidateSchedule(Arg.Any<string>(), Arg.Any<bool>()).Returns(Task.FromResult(true));
var builder = new SequentialPlayoutBuilder(
fileSystem,
new ConfigElementRepository(_dbContextFactory),
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
Substitute.For<IChannelRepository>(),
Substitute.For<IGraphicsElementRepository>(),
validator,
NullLogger<SequentialPlayoutBuilder>.Instance);
await using TvContext context = _dbContextFactory.CreateDbContext();
Playout playout = await context.Playouts
.Include(p => p.ProgramScheduleAnchors)
.ThenInclude(a => a.EnumeratorState)
.Include(p => p.FillGroupIndices)
.ThenInclude(fgi => fgi.EnumeratorState)
.SingleAsync(p => p.Id == playoutId, cancellationToken);
PlayoutReferenceData referenceData = await GetSequentialReferenceData(context, playoutId);
// Reset over the pinned window: with no prior Anchor, Reset avoids the YamlPlayoutContext.Reset path
// (its ToLocalTime() only runs on a saved-anchor Continue), keeping the build TZ-independent.
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
Start,
playout,
referenceData,
PlayoutBuildMode.Reset,
cancellationToken);
PlayoutBuildResult buildResult = result.Match(
r => r,
error => throw new AssertionException($"Build returned error: {error.Value}"));
return (buildResult.AddedItems, titles);
}
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedSequentialData(
CancellationToken cancellationToken)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
var path = new LibraryPath { Path = "Sequential LibraryPath" };
var library = new LocalLibrary
{
MediaKind = LibraryMediaKind.Movies,
Paths = new List<LibraryPath> { path },
MediaSource = new LocalMediaSource()
};
await context.Libraries.AddAsync(library, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// Six movies, distinct release dates (so chronological order is unambiguous) and varied durations so
// item boundaries are visible. Only the first four are scheduled (2 + 2 counts).
int[] durationsMinutes = [30, 45, 60, 30, 45, 60];
var movies = new List<Movie>();
for (var i = 1; i <= 6; i++)
{
var movie = new Movie
{
MediaVersions = new List<MediaVersion>
{
new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) }
},
MovieMetadata = new List<MovieMetadata>
{
new()
{
Title = $"Sequential Movie {i:D2}",
ReleaseDate = new DateTime(2005, 1, 1).AddDays(i)
}
},
LibraryPath = path,
LibraryPathId = path.Id
};
movies.Add(movie);
}
await context.Movies.AddRangeAsync(movies, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
// Name must match the fixture YAML's `collection:` value — EnumeratorCache resolves content by
// Collection.Name.
var collection = new Collection
{
Name = "Sequential Test Collection",
MediaItems = movies.Cast<MediaItem>().ToList()
};
await context.Collections.AddAsync(collection, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var ffmpegProfile = new FFmpegProfile { Name = "Sequential FFmpeg Profile" };
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// Number/GUID must be globally unique: every golden fixture shares one in-memory DB.
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000006"))
{
Name = "Sequential Test Channel",
Number = "6",
FFmpegProfile = ffmpegProfile,
FFmpegProfileId = ffmpegProfile.Id
};
await context.Channels.AddAsync(channel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
// Sequential playout: no ProgramSchedule; content comes from the YAML ScheduleFile.
var playout = new Playout
{
Channel = channel,
ChannelId = channel.Id,
ScheduleKind = PlayoutScheduleKind.Sequential,
ScheduleFile = Path.Combine(FixtureDir(), "sequential-schedule.yml")
};
await context.Playouts.AddAsync(playout, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return (playout.Id, titles);
}
private static async Task<PlayoutReferenceData> GetSequentialReferenceData(TvContext dbContext, int playoutId)
{
Channel channel = await dbContext.Channels
.AsNoTracking()
.Where(c => c.Playouts.Any(p => p.Id == playoutId))
.FirstOrDefaultAsync();
// Sequential reads content from the YAML file, not a ProgramSchedule; empty history + a fresh build.
return new PlayoutReferenceData(
channel,
Option<Deco>.None,
[],
[],
null,
[],
[],
TimeSpan.Zero);
}
// One line per PlayoutItem, ordered by Start then MediaItemId (stable tiebreak). Raw UTC Start/Finish
// serialized invariant — NOT the *Offset properties (those localize). Title resolved from the seed map.
private static string Snapshot(List<PlayoutItem> items, Dictionary<int, string> titles)
@@ -1176,6 +1371,12 @@ public class PlayoutBuildGoldenTests
private static string GoldenDir([CallerFilePath] string thisFile = "") =>
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Goldens");
// Committed YAML input fixtures (not golden outputs) for builders that read a schedule file. Note
// GoldenDir nests as Goldens/Goldens (this test file already lives under Goldens/), so fixtures sit in a
// sibling Goldens/Fixtures to keep inputs and snapshot outputs visually separate.
private static string FixtureDir([CallerFilePath] string thisFile = "") =>
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Fixtures");
private sealed class TestTvContextFactory(DbContextOptions<TvContext> options) : IDbContextFactory<TvContext>
{
public TvContext CreateDbContext() =>
+28
View File
@@ -3473,3 +3473,31 @@ enabled`) because `/iptv/*` does not accept the SPA's `ctv-session` cookie and n
for the browser. #552 closed that: the SPA now mints a short-lived token and appends it as
`?access_token=`, so this projection no longer inspects JWT status at all. See
`security.iptv-browser-token`.
## 2026-07-22 — Sequential (YAML) playout gets a golden; Scripted is excluded from the golden net by construction (#381)
`key: testing.scripted-playout-golden-deferred` · `status: active` · `since: 2026-07-22` · `supersedes: none` · `superseded-by: none`
**Rule:** The `PlayoutBuildGoldenTests` in-memory golden net covers Sequential (YAML) as of #381, but Scripted playout is deliberately excluded from it — Scripted shells out to an external process that calls back over HTTP, which no in-memory golden can characterize; its integration harness is tracked separately.
**Signals:** why is there no scripted golden; SequentialPlayoutBuilder golden; scripted playout is not deterministic in-process; Cli.Wrap external process; SchedulingEngine HTTP callback · paths: `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs`, `ErsatzTV.Core.Tests/Scheduling/Goldens/Fixtures/sequential-schedule.yml`, `ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs`, `ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs` · issues: #381, #163, #563
**Mechanics:** docs/testing.md → Golden-file nets
**Sequential (YAML) is golden-able and TZ-independent.** `SequentialPlayoutBuilder` reads a YAML schedule
file (`Playout.ScheduleFile`) rather than a `ProgramSchedule`/Block calendar, but it is still a pure
in-process build: content resolves from an in-memory-SQLite `Collection` by name, and the `count`/`all`/
`duration` handlers do UTC-only arithmetic off the caller-supplied `start`. So the `Sequential_yaml` case
uses the exact same harness as Classic/Block — a committed input fixture
(`Goldens/Fixtures/sequential-schedule.yml`) with two `count: 2` instructions over one `chronological`
collection — and needs **no** `Assume`/TZ guard (verified: it passes, not skips, under a non-UTC `TZ`,
unlike Block). The fixture deliberately avoids the local-time-of-day handlers (`wait_until`, `pad_to_next`,
`pad_until`) and shuffle order, which would reintroduce TZ- or seed-dependence. The builder checks the
file via the injected `IFileSystem` but reads bytes with the static `System.IO.File`, so the test commits a
real fixture on disk and stubs only `IFileSystem.File.Exists`; the JSON-schema validator is stubbed (it
loads its schema from a runtime cache folder, irrelevant to characterizing builder output).
**Scripted is not golden-able.** `ScriptedPlayoutBuilder` builds nothing in-process: it `Cli.Wrap`-executes
an external script process that drives `SchedulingEngine` over `http://localhost:{Settings.UiPort}`. An
external-process + HTTP-callback flow cannot be pinned by the in-memory golden harness (shared SQLite, no
I/O, deterministic clock). The deterministic core the scripts drive (`SchedulingEngine`) is in-process and
unit-testable — there is a seed `SchedulingEngineTests` — but the scripted *builder* is process orchestration
plus HTTP transport, i.e. integration-test territory. Rather than force a golden onto it, the scripted
integration harness is deferred to **#563**; this is the Done-when-sanctioned "documented decision" arm of
#381, not an omission.
+1
View File
@@ -157,4 +157,5 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `testing.e2e-local-fresh-config-dir` | Always point `scripts/e2e-local.sh` at a fresh config dir — a reused one never emits the probe's ready line and the script kills a healthy server. | 2026-07-21 | [link](workflow-process.md#2026-07-21--run-scriptse2e-localsh-against-a-fresh-config-dir-a-reused-one-hangs-the-readiness-probe-542) |
| `testing.live-e2e-prepush-timing` | Run live-E2E via `scripts/e2e-local.sh` before pushing a write-path or UI change, and exercise download endpoints with curl, never a browser tab. | 2026-07-21 | [link](workflow-process.md#2026-07-21--live-e2e-runs-before-the-push-and-downloads-are-curled-not-browsed-542) |
| `testing.playwright-mcp-download-and-recovery` | In Playwright-MCP E2E, fetch file-download endpoints with curl — never a browser tab or `window.open` — and if browser tools stall repeatedly, `pkill -f ms-playwright-mcp` and drive a fresh session. | 2026-07-21 | [link](workflow-process.md#2026-07-21--playwright-mcp-curl-download-endpoints-never-open-a-tab-or-windowopen-542) |
| `testing.scripted-playout-golden-deferred` | The `PlayoutBuildGoldenTests` in-memory golden net covers Sequential (YAML) as of #381, but Scripted playout is deliberately excluded from it — Scripted shells out to an external process that calls back over HTTP, which no in-memory golden can characterize; its integration harness is tracked separately. | 2026-07-22 | [link](../decisions.md#2026-07-22--sequential-yaml-playout-gets-a-golden-scripted-is-excluded-from-the-golden-net-by-construction-381) |
| `testing.troubleshoot-path-cannot-test-branding` | Verify logo/watermark/bug changes through a real channel playout — a green troubleshoot run proves nothing about branding. | 2026-07-21 | [link](workflow-process.md#2026-07-21--channel-branding-is-not-testable-through-the-troubleshooting-playback-api-542) |
+9 -3
View File
@@ -8,7 +8,7 @@ before adding tests, not just `docs/contributing.md` §8 (which now just points
| Project | Covers | Notes |
|---|---|---|
| `ErsatzTV.Tests` | API controllers + MediatR handlers | In-memory SQLite fixture: a shared `SqliteConnection("Data Source=:memory:;Foreign Keys=False")` kept open + `EnsureCreatedAsync()` (**not** full migration replay) + `PRAGMA foreign_keys=OFF`, then seed; a tiny `IDbContextFactory` wraps `new TvContext(...)`. 828 tests currently. |
| `ErsatzTV.Core.Tests` | Domain logic, scheduling, IPTV/XMLTV generation | References `ErsatzTV.Application` directly — there is no separate `Application.Tests` project. 542 tests + 1 skipped under `TZ=UTC` (the Block playout golden additionally skips under a non-UTC `TZ`; see Golden-file nets). |
| `ErsatzTV.Core.Tests` | Domain logic, scheduling, IPTV/XMLTV generation | References `ErsatzTV.Application` directly — there is no separate `Application.Tests` project. 543 tests + 1 skipped under `TZ=UTC` (the Block playout golden additionally skips under a non-UTC `TZ`; see Golden-file nets). |
| `ErsatzTV.Scanner.Tests` | Library scanning: scan handlers, folder scanners, NFO readers | Handler tests substitute the folder scanners + `ILibraryRepository` and assert the resulting repository writes (e.g. `ScanLocalLibraryHandlerTests` pins which `LastScan` levels a scan records — ersatztv#264). Fakes/`Testably` back the file-system-facing scanners. 1471 tests. Additionally contains `Core/FFmpeg/TranscodingTests``[Explicit]` + `[Combinatorial]`, so it never runs in CI or a plain `dotnet test` (it needs real ffmpeg/hardware) and contributes 0 to that count; run it by name when touching the transcoding pipeline. |
| `ErsatzTV.Architecture.Tests` | Layering rules via NetArchTest.eNhancedEdition | Core↛Infra/App/EF; FFmpeg↛all; App↛concrete providers. 5 tests. See `docs/contributing.md` §1. |
| `ErsatzTV.FFmpeg.Tests` | FFmpeg command construction | Build a pipeline, assert the exact rendered arg string (`PipelineBuilderBaseTests.cs`). |
@@ -23,8 +23,14 @@ Three golden-file suites guard the highest-value, most-subtle output:
- **Playout build**: `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs` (ersatztv#163)
— env var **`ETV_UPDATE_PLAYOUT_GOLDENS`** (deliberately separate from `ETV_UPDATE_GOLDENS` so
regenerating one net can't silently rewrite the other). Snapshots the `PlayoutItem`s each builder
produces over a pinned build window. Covers the **Classic** (`PlaybackOrder.Chronological`) and
**Block** builders; **Sequential (YAML)** + **Scripted** are tracked in ersatztv#381. A third case,
produces over a pinned build window. Covers the **Classic** (`PlaybackOrder.Chronological`),
**Block**, and **Sequential (YAML)** builders. **Scripted** is deliberately excluded from this net —
`ScriptedPlayoutBuilder` shells out to an external process that drives `SchedulingEngine` over HTTP, so
no in-memory golden can characterize it; its integration harness is tracked in ersatztv#563 (decision:
`testing.scripted-playout-golden-deferred`). The **Sequential** case (`Sequential_yaml`, ersatztv#381)
builds from a committed YAML fixture (`Goldens/Fixtures/sequential-schedule.yml`) instead of a
`ProgramSchedule`; it is TZ-independent (the `count`/`all`/`duration` handlers do UTC-only arithmetic —
it passes, not skips, under a non-UTC `TZ`) so needs no `Assume` guard. A third case,
`Classic_clock_padded` (ersatztv#77), locks clock-boundary padding: a `FillerMode.Pad` +
`PadToNearestMinute=15` PostRoll preset snaps content to `:15`, and the test both goldens the output and
asserts every content item after the first starts on a quarter-hour. Its EPG counterpart is