From 80a9824a4e89c52624e49ca3014e3e6b3dfe8cde Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 22 Jul 2026 19:18:12 +0200 Subject: [PATCH] test(381): golden coverage for Sequential (YAML) playout builder; document Scripted deferral MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Goldens/Fixtures/sequential-schedule.yml | 18 ++ .../Goldens/Goldens/sequential-yaml.txt | 4 + .../Goldens/PlayoutBuildGoldenTests.cs | 203 +++++++++++++++++- docs/decisions.md | 28 +++ docs/decisions/README.md | 1 + docs/testing.md | 12 +- 6 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 ErsatzTV.Core.Tests/Scheduling/Goldens/Fixtures/sequential-schedule.yml create mode 100644 ErsatzTV.Core.Tests/Scheduling/Goldens/Goldens/sequential-yaml.txt diff --git a/ErsatzTV.Core.Tests/Scheduling/Goldens/Fixtures/sequential-schedule.yml b/ErsatzTV.Core.Tests/Scheduling/Goldens/Fixtures/sequential-schedule.yml new file mode 100644 index 000000000..bb26e5492 --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/Goldens/Fixtures/sequential-schedule.yml @@ -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 diff --git a/ErsatzTV.Core.Tests/Scheduling/Goldens/Goldens/sequential-yaml.txt b/ErsatzTV.Core.Tests/Scheduling/Goldens/Goldens/sequential-yaml.txt new file mode 100644 index 000000000..30a2f3ce2 --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/Goldens/Goldens/sequential-yaml.txt @@ -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 diff --git a/ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs b/ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs index 5d6716488..8d3a8f9d6 100644 --- a/ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs @@ -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 items, Dictionary titles) = await BuildSequentialPlayout(); + + List 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 regen in new Func[] - { 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 Items, Dictionary Titles)> BuildSequentialPlayout() + { + var cancellationToken = CancellationToken.None; + + var (playoutId, titles) = await SeedSequentialData(cancellationToken); + + var fileSystem = Substitute.For(); + fileSystem.File.Exists(Arg.Any()).Returns(true); + + var validator = Substitute.For(); + validator.ValidateSchedule(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + + var builder = new SequentialPlayoutBuilder( + fileSystem, + new ConfigElementRepository(_dbContextFactory), + new MediaCollectionRepository(Substitute.For(), _dbContextFactory), + Substitute.For(), + Substitute.For(), + validator, + NullLogger.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 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 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 { 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(); + for (var i = 1; i <= 6; i++) + { + var movie = new Movie + { + MediaVersions = new List + { + new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) } + }, + MovieMetadata = new List + { + 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().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 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.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 items, Dictionary 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 options) : IDbContextFactory { public TvContext CreateDbContext() => diff --git a/docs/decisions.md b/docs/decisions.md index cb210cdbe..a0d1f79ac 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 3845ea94a..73040780c 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -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) | diff --git a/docs/testing.md b/docs/testing.md index 374afc96f..aa03f8d79 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 -- 2.47.3