From f54c9ae19512ee83e480bd2a1c4a874f7a049130 Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 22 Jul 2026 20:21:52 +0200 Subject: [PATCH 1/2] =?UTF-8?q?refactor(395):=20dedup=20Scripted=E2=89=A1Y?= =?UTF-8?q?AML=20enumerator=20construction=20into=20ContentEnumeratorBuild?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ContentEnumeratorBuilderTests.cs | 119 ++++++++++++++++++ .../Scheduling/ContentEnumeratorBuilder.cs | 47 +++++++ .../Scheduling/Engine/SchedulingEngine.cs | 24 ++-- .../YamlScheduling/EnumeratorCache.cs | 24 ++-- docs/decisions.md | 35 ++++-- docs/decisions/README.md | 2 +- docs/testing.md | 13 +- 7 files changed, 218 insertions(+), 46 deletions(-) create mode 100644 ErsatzTV.Core.Tests/Scheduling/ContentEnumeratorBuilderTests.cs create mode 100644 ErsatzTV.Core/Scheduling/ContentEnumeratorBuilder.cs diff --git a/ErsatzTV.Core.Tests/Scheduling/ContentEnumeratorBuilderTests.cs b/ErsatzTV.Core.Tests/Scheduling/ContentEnumeratorBuilderTests.cs new file mode 100644 index 000000000..780bfde41 --- /dev/null +++ b/ErsatzTV.Core.Tests/Scheduling/ContentEnumeratorBuilderTests.cs @@ -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 result = ContentEnumeratorBuilder.ForContent( + [FakeMovie(1), FakeMovie(2)], + new CollectionEnumeratorState(), + PlaybackOrder.Chronological, + multiPart: false); + + result.IsSome.ShouldBeTrue(); + foreach (IMediaCollectionEnumerator enumerator in result) + { + enumerator.ShouldBeOfType(); + } + } + + [Test] + public void Shuffle_Builds_Block_Shuffle_Enumerator_Not_Classic() + { + Option 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(); + enumerator.ShouldNotBeOfType(); + } + } + + [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 parts = + [ + NamedEpisode("Episode 1 (1)", 1), + NamedEpisode("Episode 2 (2)", 2) + ]; + + Option result = ContentEnumeratorBuilder.ForContent( + parts, + new CollectionEnumeratorState(), + PlaybackOrder.Shuffle, + multiPart: true); + + result.IsSome.ShouldBeTrue(); + foreach (IMediaCollectionEnumerator enumerator in result) + { + enumerator.ShouldBeOfType(); + } + } + + [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 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) } + ] + }; +} diff --git a/ErsatzTV.Core/Scheduling/ContentEnumeratorBuilder.cs b/ErsatzTV.Core/Scheduling/ContentEnumeratorBuilder.cs new file mode 100644 index 000000000..eaca403f9 --- /dev/null +++ b/ErsatzTV.Core/Scheduling/ContentEnumeratorBuilder.cs @@ -0,0 +1,47 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Scheduling; +using ErsatzTV.Core.Scheduling.BlockScheduling; + +namespace ErsatzTV.Core.Scheduling; + +/// +/// Builds the stateless-index for a single content entry from its +/// and flat item list. +/// +/// Extracted (issue #395) so the Scripted engine (SchedulingEngine.EnumeratorForContent) and the +/// Sequential/YAML engine (EnumeratorCache.GetEnumeratorForContent) share ONE construction switch +/// instead of two byte-identical copies. Kept a public static helper with dependencies passed as +/// parameters, matching the sibling / +/// seams (issue #380). +/// +/// +/// Deliberately per-family and narrow: only and +/// are supported, and Shuffle maps to the block variant +/// — NOT Classic's +/// , which is a different algorithm. Every other order returns +/// .None so each caller logs its own engine-specific "not supported" warning +/// (#70); folding the warning in here would lose that per-engine message. +/// +/// +public static class ContentEnumeratorBuilder +{ + public static Option ForContent( + List items, + CollectionEnumeratorState state, + PlaybackOrder playbackOrder, + bool multiPart) + { + switch (playbackOrder) + { + case PlaybackOrder.Chronological: + return new ChronologicalMediaCollectionEnumerator(items, state); + case PlaybackOrder.Shuffle: + List groupedMediaItems = multiPart + ? MultiPartEpisodeGrouper.GroupMediaItems(items, false) + : items.Map(mi => new GroupedMediaItem(mi, null)).ToList(); + return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state); + default: + return Option.None; + } + } +} diff --git a/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs b/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs index 9a29ddcd9..77fe84678 100644 --- a/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs +++ b/ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs @@ -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 groupedMediaItems = keepMultiPartEpisodesTogether - ? MultiPartEpisodeGrouper.GroupMediaItems(items, false) - : items.Map(mi => new GroupedMediaItem(mi, null)).ToList(); - return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state); - } + Option 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.None; + return enumerator; } private void ApplyPlaylistHistory( diff --git a/ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs b/ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs index c5b1b04a0..66c849159 100644 --- a/ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs +++ b/ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs @@ -180,26 +180,20 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor } var parsedOrder = Enum.Parse(content.Order, true); - switch (parsedOrder) - { - case PlaybackOrder.Chronological: - return new ChronologicalMediaCollectionEnumerator(items, state); - case PlaybackOrder.Shuffle: - bool keepMultiPartEpisodesTogether = content.MultiPart; - List groupedMediaItems = keepMultiPartEpisodesTogether - ? MultiPartEpisodeGrouper.GroupMediaItems(items, false) - : items.Map(mi => new GroupedMediaItem(mi, null)).ToList(); - return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state); - } + Option 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.None; + return enumerator; } private record PlaylistKey(string ContentKey, CollectionKey CollectionKey); diff --git a/docs/decisions.md b/docs/decisions.md index a0d1f79ac..6d51d1a3f 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 73040780c..95987dbeb 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -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) | diff --git a/docs/testing.md b/docs/testing.md index aa03f8d79..2e5e0456f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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, -- 2.47.3 From 7b77a37f0f91b1f922cf479bfdf2715ef2dd7d7a Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 22 Jul 2026 20:28:58 +0200 Subject: [PATCH 2/2] docs(395): keep the decision record's original heading (fix decisions-lifecycle) The lifecycle validator matches records by their `##` heading for removal detection (gone = base_headings - head_headings), ungated by [decisions-edit]. Renaming the heading read as an unlogged record removal. Restore the exact original heading -- which stays literally true ("excluded from the golden net": #395 adds a unit test, not a golden) -- and keep the rationale correction in the Rule/Signals/body, which is what [decisions-edit] authorizes. [decisions-edit] Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/decisions.md | 2 +- docs/decisions/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index 6d51d1a3f..10898a806 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -3473,7 +3473,7 @@ 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's external-process pipeline is integration-only, but its scheduling engine is in-process testable (#381, #395) +## 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. 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. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 95987dbeb..c154d2f42 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -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. 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.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-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) | -- 2.47.3