refactor(395): dedup Scripted≡YAML enumerator construction into ContentEnumeratorBuilder
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m25s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m21s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m36s
PR Gates / decisions lifecycle (pull_request) Failing after 14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Has been cancelled

Extract the byte-identical PlaybackOrder -> IMediaCollectionEnumerator switch
shared by SchedulingEngine.EnumeratorForContent (Scripted) and
EnumeratorCache.GetEnumeratorForContent (Sequential/YAML) into one static
per-family seam, mirroring #380's ShuffleSourceBuilder. Each engine keeps its own
"not supported" warning on the None branch, so the per-engine message is unchanged.
Adds ContentEnumeratorBuilderTests pinning the block-shuffle-not-classic trap and
the unsupported-order -> None (#70) contract across all 8 unsupported orders.

Corrects the testing.scripted-playout-golden-deferred decision record: Scripted's
external-process + HTTP pipeline is integration-only (deferred to #563), but the
in-process SchedulingEngine it drives IS unit-testable (ScriptedScheduleController
is a 1:1 pass-through) -- the earlier "un-golden-able by construction" framing
conflated transport with engine. docs/testing.md reframed to match.

[decisions-edit]

fixes #395

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 20:21:52 +02:00
co-authored by Claude Opus 4.8
parent 5c20a98468
commit f54c9ae195
7 changed files with 218 additions and 46 deletions
@@ -0,0 +1,119 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Scheduling.BlockScheduling;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Scheduling;
// Direct unit coverage for the enumerator-construction helper extracted from the Scripted
// (SchedulingEngine.EnumeratorForContent) and Sequential/YAML (EnumeratorCache.GetEnumeratorForContent)
// engines (#395). These pin the two behaviors the issue flags as traps:
// - Shuffle must build the *block* enumerator, NOT Classic's ShuffledMediaCollectionEnumerator (a
// different algorithm keyed on the same PlaybackOrder), and
// - every order the two engines don't support returns None, so each caller logs its own #70 warning
// instead of silently scheduling nothing.
// The Scripted engine has no golden (its external-process/HTTP transport is integration-only, #563), so
// this direct helper test is the in-process regression net for the shared construction it drives.
[TestFixture]
public class ContentEnumeratorBuilderTests
{
[Test]
public void Chronological_Builds_Chronological_Enumerator()
{
Option<IMediaCollectionEnumerator> result = ContentEnumeratorBuilder.ForContent(
[FakeMovie(1), FakeMovie(2)],
new CollectionEnumeratorState(),
PlaybackOrder.Chronological,
multiPart: false);
result.IsSome.ShouldBeTrue();
foreach (IMediaCollectionEnumerator enumerator in result)
{
enumerator.ShouldBeOfType<ChronologicalMediaCollectionEnumerator>();
}
}
[Test]
public void Shuffle_Builds_Block_Shuffle_Enumerator_Not_Classic()
{
Option<IMediaCollectionEnumerator> result = ContentEnumeratorBuilder.ForContent(
[FakeMovie(1), FakeMovie(2)],
new CollectionEnumeratorState(),
PlaybackOrder.Shuffle,
multiPart: false);
result.IsSome.ShouldBeTrue();
foreach (IMediaCollectionEnumerator enumerator in result)
{
// The documented trap: Shuffle here is the block algorithm, never Classic's shuffle.
enumerator.ShouldBeOfType<BlockPlayoutShuffledMediaCollectionEnumerator>();
enumerator.ShouldNotBeOfType<ShuffledMediaCollectionEnumerator>();
}
}
[Test]
public void Shuffle_MultiPart_Also_Builds_Block_Shuffle_Enumerator()
{
// "(1)"/"(2)" are a two-part episode MultiPartEpisodeGrouper keeps together; multiPart routes the
// items through it before the block enumerator. Grouping mechanics are covered by
// MultiPartEpisodeGrouper's / ShuffleSourceBuilder's own tests; here we pin that the flag path still
// produces the block enumerator (and doesn't throw on the grouped list).
List<MediaItem> parts =
[
NamedEpisode("Episode 1 (1)", 1),
NamedEpisode("Episode 2 (2)", 2)
];
Option<IMediaCollectionEnumerator> result = ContentEnumeratorBuilder.ForContent(
parts,
new CollectionEnumeratorState(),
PlaybackOrder.Shuffle,
multiPart: true);
result.IsSome.ShouldBeTrue();
foreach (IMediaCollectionEnumerator enumerator in result)
{
enumerator.ShouldBeOfType<BlockPlayoutShuffledMediaCollectionEnumerator>();
}
}
[TestCase(PlaybackOrder.None)]
[TestCase(PlaybackOrder.Random)]
[TestCase(PlaybackOrder.ShuffleInOrder)]
[TestCase(PlaybackOrder.MultiEpisodeShuffle)]
[TestCase(PlaybackOrder.SeasonEpisode)]
[TestCase(PlaybackOrder.RandomRotation)]
[TestCase(PlaybackOrder.Marathon)]
[TestCase(PlaybackOrder.WeightedShuffle)]
public void Unsupported_Order_Returns_None(PlaybackOrder order)
{
// #70: these two engines support only Chronological + Shuffle; every other order returns None so the
// caller logs a "not supported" warning instead of silently scheduling nothing.
Option<IMediaCollectionEnumerator> result = ContentEnumeratorBuilder.ForContent(
[FakeMovie(1)],
new CollectionEnumeratorState(),
order,
multiPart: false);
result.IsNone.ShouldBeTrue();
}
private static Episode NamedEpisode(string title, int id) => new()
{
Id = id,
EpisodeMetadata = [new EpisodeMetadata { Title = title, EpisodeNumber = id }],
Season = new Season { SeasonNumber = 1, Show = new Show { Id = 1 }, ShowId = 1 }
};
private static Movie FakeMovie(int id) => new()
{
Id = id,
MediaVersions = [],
MovieMetadata =
[
new MovieMetadata { ReleaseDate = new DateTime(2020, 1, id) }
]
};
}
@@ -0,0 +1,47 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling.BlockScheduling;
namespace ErsatzTV.Core.Scheduling;
/// <summary>
/// Builds the stateless-index <see cref="IMediaCollectionEnumerator" /> for a single content entry from its
/// <see cref="PlaybackOrder" /> and flat item list.
/// <para>
/// Extracted (issue #395) so the Scripted engine (<c>SchedulingEngine.EnumeratorForContent</c>) and the
/// Sequential/YAML engine (<c>EnumeratorCache.GetEnumeratorForContent</c>) share ONE construction switch
/// instead of two byte-identical copies. Kept a <c>public static</c> helper with dependencies passed as
/// parameters, matching the sibling <see cref="ShuffleSourceBuilder" /> / <see cref="MultiPartEpisodeGrouper" />
/// seams (issue #380).
/// </para>
/// <para>
/// Deliberately per-family and narrow: only <see cref="PlaybackOrder.Chronological" /> and
/// <see cref="PlaybackOrder.Shuffle" /> are supported, and Shuffle maps to the <em>block</em> variant
/// <see cref="BlockPlayoutShuffledMediaCollectionEnumerator" /> — NOT Classic's
/// <see cref="ShuffledMediaCollectionEnumerator" />, which is a different algorithm. Every other order returns
/// <see cref="Option{A}" />.<c>None</c> so each caller logs its own engine-specific "not supported" warning
/// (#70); folding the warning in here would lose that per-engine message.
/// </para>
/// </summary>
public static class ContentEnumeratorBuilder
{
public static Option<IMediaCollectionEnumerator> ForContent(
List<MediaItem> items,
CollectionEnumeratorState state,
PlaybackOrder playbackOrder,
bool multiPart)
{
switch (playbackOrder)
{
case PlaybackOrder.Chronological:
return new ChronologicalMediaCollectionEnumerator(items, state);
case PlaybackOrder.Shuffle:
List<GroupedMediaItem> groupedMediaItems = multiPart
? MultiPartEpisodeGrouper.GroupMediaItems(items, false)
: items.Map(mi => new GroupedMediaItem(mi, null)).ToList();
return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state);
default:
return Option<IMediaCollectionEnumerator>.None;
}
}
}
@@ -1225,25 +1225,19 @@ public class SchedulingEngine(
PlaybackOrder playbackOrder,
bool multiPart = false)
{
switch (playbackOrder)
{
case PlaybackOrder.Chronological:
return new ChronologicalMediaCollectionEnumerator(items, state);
case PlaybackOrder.Shuffle:
bool keepMultiPartEpisodesTogether = multiPart;
List<GroupedMediaItem> groupedMediaItems = keepMultiPartEpisodesTogether
? MultiPartEpisodeGrouper.GroupMediaItems(items, false)
: items.Map(mi => new GroupedMediaItem(mi, null)).ToList();
return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state);
}
Option<IMediaCollectionEnumerator> enumerator =
ContentEnumeratorBuilder.ForContent(items, state, playbackOrder, multiPart);
// None means the caller's foreach never runs, so the content is simply absent from the playout with
// nothing said. Report it instead of leaving a silently empty schedule (#70).
logger.LogWarning(
"Playback order {PlaybackOrder} is not supported by scripted scheduling; no content will be scheduled for this entry",
playbackOrder);
if (enumerator.IsNone)
{
logger.LogWarning(
"Playback order {PlaybackOrder} is not supported by scripted scheduling; no content will be scheduled for this entry",
playbackOrder);
}
return Option<IMediaCollectionEnumerator>.None;
return enumerator;
}
private void ApplyPlaylistHistory(
@@ -180,26 +180,20 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
}
var parsedOrder = Enum.Parse<PlaybackOrder>(content.Order, true);
switch (parsedOrder)
{
case PlaybackOrder.Chronological:
return new ChronologicalMediaCollectionEnumerator(items, state);
case PlaybackOrder.Shuffle:
bool keepMultiPartEpisodesTogether = content.MultiPart;
List<GroupedMediaItem> groupedMediaItems = keepMultiPartEpisodesTogether
? MultiPartEpisodeGrouper.GroupMediaItems(items, false)
: items.Map(mi => new GroupedMediaItem(mi, null)).ToList();
return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state);
}
Option<IMediaCollectionEnumerator> enumerator =
ContentEnumeratorBuilder.ForContent(items, state, parsedOrder, content.MultiPart);
// this path schedules nothing for the content, which is indistinguishable from "no items" downstream.
// Orders are addressed by name here, so any order the enum knows parses fine and then lands here --
// say so, rather than leaving an empty schedule to be explained (#70).
logger.LogWarning(
"Playback order {PlaybackOrder} is not supported by sequential (YAML) scheduling; no content will be scheduled for this entry",
parsedOrder);
if (enumerator.IsNone)
{
logger.LogWarning(
"Playback order {PlaybackOrder} is not supported by sequential (YAML) scheduling; no content will be scheduled for this entry",
parsedOrder);
}
return Option<IMediaCollectionEnumerator>.None;
return enumerator;
}
private record PlaylistKey(string ContentKey, CollectionKey CollectionKey);
+24 -11
View File
@@ -3473,11 +3473,11 @@ 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)
## 2026-07-22 — Sequential (YAML) playout gets a golden; Scripted's external-process pipeline is integration-only, but its scheduling engine is in-process testable (#381, #395)
`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
**Rule:** The `PlayoutBuildGoldenTests` in-memory golden net covers Sequential (YAML) as of #381. Scripted's *end-to-end pipeline* is excluded — `ScriptedPlayoutBuilder` runs a user-authored external program that drives the engine over HTTP loopback, which the in-memory harness can't pin — so that full-pipeline (integration) harness is deferred to #563. But the scheduling *behavior* those scripts drive lives entirely in the in-process `SchedulingEngine` (the `ScriptedScheduleController` is a 1:1 pass-through to it), which IS directly unit/golden-testable; the earlier "Scripted is un-golden-able by construction" framing overstated the constraint by conflating transport with engine. #395 extracts that shared switch to `ContentEnumeratorBuilder` and adds a direct regression net (`ContentEnumeratorBuilderTests`) over it.
**Signals:** why is there no scripted golden; scripted transport vs engine; SchedulingEngine is in-process testable; Cli.Wrap external process is transport only; ScriptedScheduleController 1:1 pass-through; engine-level scripted regression net; EnumeratorForContent · paths: `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs`, `ErsatzTV.Core.Tests/Scheduling/Engine/SchedulingEngineTests.cs`, `ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs`, `ErsatzTV/Controllers/Api/ScriptedScheduleController.cs`, `ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs` · issues: #381, #163, #395, #563
**Mechanics:** docs/testing.md → Golden-file nets
**Sequential (YAML) is golden-able and TZ-independent.** `SequentialPlayoutBuilder` reads a YAML schedule
@@ -3493,11 +3493,24 @@ file via the injected `IFileSystem` but reads bytes with the static `System.IO.F
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.
**Scripted: transport is integration-only, but the engine is in-process testable.** `ScriptedPlayoutBuilder`
builds nothing itself — it `Cli.Wrap`-executes a user-authored external program, hands it
`http://localhost:{Settings.UiPort}` + a build id, and after the process exits reads the result straight off
the in-process engine (`schedulingEngine.GetState()`/`GetAnchor()`). The program drives the build by calling
back over HTTP, but `ScriptedScheduleController` is a **1:1 pass-through**: every action resolves the engine by
build id and forwards to a single `ISchedulingEngine` method (`AddCollection`, `AddCount`, `PadUntil`, …) with
no scheduling logic of its own. So "Scripted is un-golden-able" conflates two different things:
- The **end-to-end pipeline** (real external process + Kestrel on `UiPort` + HTTP loopback) genuinely is
integration-test territory — the in-memory golden harness (shared SQLite, no I/O, deterministic clock) can't
pin it, and that harness is deferred to **#563**. This part of the original decision stands.
- The **scheduling behavior** the scripts drive is 100% in `SchedulingEngine`, a plain DI-substitutable object
already unit-tested (`SchedulingEngineTests`, `new SchedulingEngine(…4 substitutes…)`). It is directly
drivable and snapshot-testable with no process and no HTTP — the same way the YAML golden drives its builder,
just skipping the script/HTTP front-end. #395 extracts the enumerator-construction switch that the Scripted
and Sequential/YAML engines share to `ContentEnumeratorBuilder` and adds a direct regression net
(`ContentEnumeratorBuilderTests`) over it — the real safety net its dedup needs (not #563).
The #381 "documented decision" arm correctly deferred the *pipeline* golden; it overstated the case by writing
off engine-level coverage too. Scripted scheduling *behavior* is now covered in-process; only the
external-process pipeline remains #563's.
+1 -1
View File
@@ -157,5 +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.scripted-playout-golden-deferred` | The `PlayoutBuildGoldenTests` in-memory golden net covers Sequential (YAML) as of #381. Scripted's *end-to-end pipeline* is excluded — `ScriptedPlayoutBuilder` runs a user-authored external program that drives the engine over HTTP loopback, which the in-memory harness can't pin — so that full-pipeline (integration) harness is deferred to #563. But the scheduling *behavior* those scripts drive lives entirely in the in-process `SchedulingEngine` (the `ScriptedScheduleController` is a 1:1 pass-through to it), which IS directly unit/golden-testable; the earlier "Scripted is un-golden-able by construction" framing overstated the constraint by conflating transport with engine. #395 extracts that shared switch to `ContentEnumeratorBuilder` and adds a direct regression net (`ContentEnumeratorBuilderTests`) over it. | 2026-07-22 | [link](../decisions.md#2026-07-22--sequential-yaml-playout-gets-a-golden-scripteds-external-process-pipeline-is-integration-only-but-its-scheduling-engine-is-in-process-testable-381-395) |
| `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 -4
View File
@@ -24,10 +24,15 @@ Three golden-file suites guard the highest-value, most-subtle output:
— 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`),
**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)
**Block**, and **Sequential (YAML)** builders. The **Scripted** *end-to-end pipeline* is excluded from this
net — `ScriptedPlayoutBuilder` runs a user-authored external process that drives the engine over HTTP, which
the in-memory harness can't pin; that integration harness is tracked in ersatztv#563. The scheduling
*behavior* those scripts drive, though, lives in the in-process `SchedulingEngine` (the HTTP controller is a
1:1 pass-through) and IS directly testable — `SchedulingEngineTests` news it up with substitutes, and
`ContentEnumeratorBuilderTests` (ersatztv#395) is the direct regression net over the enumerator-construction
helper the Scripted and Sequential/YAML engines now share (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,