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
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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user