Two pointers still named #563 as the open reason scripted playout has no
coverage. The ContentEnumeratorBuilderTests header now points at the
successor record and docs/testing.md instead of the issue this branch
closes. docs/decisions.md carried an orphaned fragment, "external-process
pipeline remains #563's", in the residual block under ## Index -- with the
pipeline now permanently outside the automated suite rather than deferred,
the fragment states something false and has no recoverable subject to
rewrite it around, so it goes.
The record's rule gains the two things measurement settled: that
ApiJsonSettings shares production's configuration and never MVC's settings
object (MaxDepth 32, the two ProblemDetails converters, pinned by
ApiJsonSettingsTests), and that a fixture must aim every trimming
instruction between two content boundaries or that action's trim argument
is witnessed by nothing. Its body loses the mutant table and the
extraction paragraph, which the mechanics doc its own frontmatter points
at carries verbatim; what remains is the conclusion plus the reasoning
that exists nowhere else. 70 prose lines to 56, under the advisory ceiling
without dropping a distinct finding.
Refs #563
Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
Removing SchedulingEngine.AddCountInternal's _state.AdvanceGuideGroup() left both
guide-group assertions green: the locked-group test only compared inside/outside
the group, and the snapshot only compared item 2 to item 0. Assert the actual
sequence instead.
Refs #563
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
#381 deferred Scripted from the playout golden net because ScriptedPlayoutBuilder
shells out via Cli.Wrap to a user-authored program that drives SchedulingEngine
over HTTP loopback. #563 offered two arms: a full process+Kestrel integration
harness, or expanded engine coverage with the shell-out scoped out.
This takes the second arm, but delivers the first arm's "documented in-process
stand-in" so the scope-out is a measured claim rather than a prose one:
- SchedulingEngineTests grows from 1 test to 21, covering AddCollection/AddCount/
AddAll/AddDuration/PadUntilExact, EPG guide-group locking, per-item history,
the 20-call no-progress halt and its reset, and the anchor round-trip a
Continue build restores from. Unknown-content-key cases assert false AND that
nothing was scheduled.
- ScriptedScheduleControllerTests replays Fixtures/scripted-build.json through
the real ScriptedScheduleController + ScriptedPlayoutBuilderService.MockSession
+ SchedulingEngine and pins a 13-item snapshot in the golden line format, so
there is exactly one action->engine mapping under test — the production one. It
also pins the three mappings the predecessor record's "1:1 pass-through"
wording hides: 404 on an unknown build id, 400 on an unparseable playback
order, a SILENT fall back to FillerKind.None on an unparseable filler kind, and
the InvalidOperationException -> 400 translation.
MockSession was declared on IScriptedPlayoutBuilderService with zero callers;
it is the seam this needs and now has one.
Both fixtures are TZ-independent by construction (Chronological order plus only
instant-preserving instructions) and verified passing, not skipping, under
TZ=UTC, America/New_York, Australia/Lord_Howe and Asia/Kathmandu.
Refs #563
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
Round-four review. One HIGH, again in the decision record, and the previous
commit message asserted this exact class was cleared. It was not.
THE HIGH, and the reason it recurred.
A second sentence still described the rejected reading: "The guard form is
`?? []` into a local rather than this record's Optional(x).Flatten(), a STATED
deviation". The shipped guard is `?? AllDaysOfWeek()`. That sentence is the one
that dictates guard FORM to the next implementer, so it would have taught the
`[]` reading the same record spends a paragraph calling data corruption -- and
it had already propagated into docs/decisions/README.md, the mandated entry
point, which carries `rule:` verbatim.
The mechanism, not the sentence, is the defect. I swept with a regex keyed on
"null" plus a reading word; this sentence talks about guard FORM and contains
neither, so it could not match. That is grepping the retracted WORDING instead
of sweeping the CONCEPT, which is exactly what this corpus warns about -- and
three rounds in a row have now found a defect introduced by the previous
round's targeted string edit. So the fix is not another targeted edit: the
whole `rule:` field was split into its 39 sentences and read back one by one
against the code. Everything below came out of that pass rather than a grep.
Its secondary damage is worth recording because it is the shape of a rationale
that outlives its claim: the deviation was justified by ".ToList() allocates
for nothing", which is now BOTH irrelevant to the choice AND false about the
shipped code, since AllDaysOfMonth()/AllMonthsOfYear() are themselves
Enumerable.Range(...).ToList() on exactly the null path it describes.
- The opening sentence of `rule:` prescribed Optional(x).Flatten() as THE
read-site form. It is the sentence most likely to be read in isolation, and
it is wrong for six of the eight columns. It now separates the universal half
(a LOCAL, never assigned back) from the half that is not (the substituted
value), and names where each applies.
- `signals:` had never been touched, so roughly 60% of `rule:` was unreachable
by the discovery surface built for it -- no AlternateScheduleSelector, no
mapper, no "unrestricted", and its paths: list named none of the files this
work touched. It also advertised "Optional Flatten hoisted local" as the
form, which is precisely what the six do NOT use.
- The body prose was still entirely about SongMetadata while `rule:` had grown
a whole second subject. Added the two results that contradicted the prior
reasoning, in prose, where a reader meets them.
A REAL BUG in my own guard, not just prose:
fixture.IsAbstract.ShouldBeFalse(...)
A C# `static class` compiles to `abstract sealed`, and NUnit runs tests
declared in one -- this repo already has such a fixture
(AlternateScheduleSelectorTests is `public static class`). So the check I added
one commit ago to reject an un-runnable fixture would have falsely reddened a
perfectly good static one. Now rejects an abstract BASE (abstract and NOT
sealed), which is the case NUnit actually cannot instantiate.
A SURVIVING MUTANT the added controls did not kill:
AnyDate was 2024-03-06. With a day <= 12 a CROSS-WIRED substitution survives
the whole fixture -- `DaysOfMonth ?? AllMonthsOfYear()` hands back 1..12, which
still contains day 6, so every assertion passes while the guard substitutes the
wrong set. Moved to 2024-03-20, still a Wednesday in March, outside 1..12.
Measured both ways rather than reasoned: the cross-wire mutant passes the old
fixture and FAILS 2 of 11 on the new one.
Also re-witnessed, because I had modified that file and never re-proved it:
restoring `??=` in LuceneSearchIndex reddens the LUCENE fixture (1 red, 1
green) -- the exact mirror of the Elastic mutation. Extracting
ThrowOnWarningLogger did not cost #701 its proof, and the two fixtures are
independently load-bearing in both directions.
The record is now 73 prose lines, over the 60-line WARNING ceiling. Stated
rather than trimmed: it is 42nd of 42 records over that line, and the added
content is distinct findings (a second subject, a migration analysis and three
residuals), not redundancy against a sibling.
Local gate: ErsatzTV.Tests 2091 passed / 6 skipped (the three fixtures' MySQL
halves), Core.Tests 697/1 -- 0 failures. Format clean, no BOM. decisions
validate OK.
Refs #823
Refs #824
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019zUmJZHhVP7kXg5DV237TW
Round-three review findings. One HIGH, and it was in the durable artifact
rather than the code.
THE HIGH: the record stated the shipped reading and its inverse.
The semantic reversal (empty -> unrestricted) rewrote the residual and the
write-half of `media.nullable-primitive-collection-mutation` but left the
ORIGINAL reasoning standing two sentences earlier: "A null reads as EMPTY, so
the item matches nothing"; "the REJECTED alternative was the All*() set";
"SKIPPING the row is the conservative repair". The shipped code is
`?? AllDaysOfWeek()` -- precisely the alternative that passage calls rejected.
The previous commit then inserted residual (1), which reasons entirely FROM
the All*() reading, two sentences after the sentence denying it.
That is worse than a stale comment. A session resolving this key -- or reading
the MemPalace mirror, which carries `rule:` verbatim -- would have been told to
write the guard the other way, i.e. talked into the `[]` reading that the same
record elsewhere argues is data corruption one save later. Replaced the whole
passage, then swept the record for every other mention of the empty reading
rather than trusting the one replacement: the only survivor is the new sentence
that records EMPTY as the rejected alternative, which is the direction that
stops it being re-adopted.
THE MEDIUM: one arrangement did not close the hole it claimed to.
The discriminating control added last commit nulls DaysOfWeek against a
restrictive MonthsOfYear. It excludes "any NULL matches unconditionally" only
for that dimension. The review supplied the surviving mutant --
`if (item.MonthsOfYear is null) { return item; }` ahead of the checks -- and
traced it green through all nine tests. Verified by EXECUTION, not by reading:
applied to the previous fixture it passes; applied now it FAILS 1 of 11. Each
of the three dimensions is now nulled against a restriction on a different
dimension.
The rest, all from the same round:
- The coverage guard's test detection listed attribute TYPES, and each list
falsely reddened whatever it omitted: TestAttribute alone missed [TestCase],
and the three-type replacement missed [Theory]. Now decided by NUnit's own
ITestBuilder/ISimpleTestBuilder interfaces, which cannot fall behind the
vocabulary. It also dropped BindingFlags.Static (GetMethods() defaults to
including it), which would have falsely reddened a static test method.
- The same guard accepted an ABSTRACT fixture -- NUnit never instantiates one.
The indexer population already filtered IsAbstract; the fixture side now
mirrors it.
- The record's `mechanics:` still described the old `[Test]`-only clause, in
the same file the change edited.
- An <inheritdoc> made the ProgramScheduleAlternate empty-case test inherit a
docstring written from the PlayoutTemplate test's viewpoint.
Two more mutations executed:
- `if (item.MonthsOfYear is null) return item;` -> 1 red, 10 green. This is the
mutant that survived the previous head; it no longer does.
- an abstract type named in the covered set -> coverage guard red.
Local gate: ErsatzTV.Tests 2091 passed / 6 skipped (the three fixtures' MySQL
halves, skipping visibly without ETV_TEST_MYSQL_CONNECTION), Core.Tests 697/1
-- 0 failures. Format clean, no BOM on the touched set. decisions_validate OK.
Refs #823
Refs #824
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019zUmJZHhVP7kXg5DV237TW
Follow-up commit (the branch is pushed, so not an amend). Two more cold
reviews landed on the previous head; both reported 0 Blocker and 0 High, and
these are their Mediums and Lows. Each fix carries its own witnessed mutation.
1. The selector fixture could not tell the fix from a much broader one.
Every null test set a NULL and expected the item SELECTED, so all of them
pass equally under "NULL means unrestricted" and under "any NULL makes this
item match unconditionally" -- a refactor short-circuiting the whole date
check on any null kept them green. Added the discriminating control: a NULL
DaysOfWeek paired with MonthsOfYear = [1] against a MARCH date must be None.
Only the narrow reading passes.
2. A_Null_Item_Does_Not_Disturb_Selection_Of_A_Later_Item never measured its
own docstring. The nulled item was unrestricted and at Index 0, so it always
won and the second item was never evaluated -- the stated invariant ("a null
on the first item must not decide the second") went unmeasured while the
test passed. Split into two: one where the nulled item genuinely does not
match, which measures that the loop CONTINUES; and one that pins the
index-order win separately.
3. The empty-preservation control existed for one of two identical mappers.
The anti-mutant test for "empty or null becomes All*" covered only
Playouts.Mapper; Scheduling.Mapper is a byte-identical triple in another
file and had none, so a defensive edit to it alone would have rewritten a
deliberately-empty user selection to 1..31 with the suite green. That is the
one-helper-two-callers shape this repo has been bitten by. Added the
matching test.
4. The coverage guard's [Test] clause did not check what its message claimed.
GetMethods() without BindingFlags returns INHERITED methods, so a fixture
that merely subclasses another satisfied it while driving the wrong indexer
-- and Values.Distinct() cannot catch that, since the two Types differ. It
also matched TestAttribute alone, so a future fixture written as [TestCase]
would have falsely reddened, and it accepted an [Explicit]/[Ignore]d fixture
that never runs, which is the "wired is not running" failure the guard
exists to prevent. Now DeclaredOnly, the full test-method vocabulary, and
Explicit/Ignore rejected at both method and fixture level.
5. Three residuals recorded on media.nullable-primitive-collection-mutation
that the previous head asserted nothing about:
- the LOUDNESS change, worst for an all-three-NULL ProgramScheduleAlternate,
which now matches unconditionally and shadows the default schedule where
it previously threw. Unreachable today, and a choice over an unreachable
state rather than a measured requirement -- said plainly.
- the normalization is ONE-WAY and WHOLE-LIST: both PUT paths are full
replaces, so editing any row persists All*() over EVERY NULL row in that
playout, and afterwards "the operator selected all 31" and "this is a
legacy row" are indistinguishable. An ordinary user action closes that
door.
- the WRITE side disagrees with the READ side about what ABSENCE means: an
omitted daysOfWeek normalizes to [] ("never applies") while a NULL column
reads as unrestricted, so an API client gets HTTP 200 and a row that
silently never fires. Filed as #880 rather than folded in here, because a
client omitting a field on a write is a different question from what a
legacy NULL meant.
Two more mutations executed, both witnessed:
- DaysOfWeek guard disarmed in Scheduling.Mapper -> 1 red, 3 green.
- A fixture with no DECLARED test named in the covered set -> coverage red.
Local gate (MySQL lane armed): ErsatzTV.Tests 2097 passed / 0 skipped,
Core.Tests 695/1 -- 0 failures. Format clean, no BOM on the touched set with
the population count asserted. decisions_validate OK.
Refs #823
Refs #824
Refs #880
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019zUmJZHhVP7kXg5DV237TW
Both issues are #701 deferrals, and they land together because both rewrite
the same decision record.
#823 -- can a null reach one of the six collection-valued scalar columns?
MEASURED against a real TvContext on BOTH providers (SQLite, and MySQL 8.4
on an ephemeral server), because the reasoning available beforehand pointed
the wrong way. The two converters differ on their read side --
IntCollectionValueConverter maps null-or-blank to Array.Empty<int>(), while
EnumCollectionJsonValueConverter would dereference the result of
JsonConvert.DeserializeObject -- so the expectation was that a NULL row
behaves differently per column. NEITHER RUNS: EF does not invoke a value
converter for a NULL column at all. All six materialize as CLR null, the
int converter's null-to-empty branch is dead on this path, and unguarded
each .Contains in AlternateScheduleSelector throws NullReferenceException.
A NULL reads as UNRESTRICTED -- the All*() sets -- not as empty. This is
the whole semantic question and the first draft got it backwards. It is
decided by the one NULL reachable WITHOUT any code writing one: Sqlite's
20240113140741_Add_PlayoutTemplate_DaysOfMonth adds the column with
nullable:true and NO defaultValue, so a PlayoutTemplate row inserted before
it holds NULL and by construction had no day-of-month restriction. Reading
that as empty INVERTS the row's meaning and silently stops the template
applying at all. All*() preserves it, and is how "no restriction recorded"
is already represented (GetPlayoutAlternateSchedulesHandler,
PreviewBlockPlayoutHandler). What does NOT decide it, and was wrongly cited
in the first draft: the API request records normalize an omitted field with
`?? []`, but that is a client omitting a field on a WRITE and says nothing
about what a legacy database NULL meant.
Two read sites, not one. Guarding only the selector would have left the
entity->DTO mappers unguarded, and those feed the SPA: PlayoutScheduleEditors
spreads the collection (`[...template.daysOfMonth]` -> TypeError on a JSON
null) and playoutTemplateCalendar's appliesToDate -- an exact port of
GetScheduleForDate -- calls .includes on it. Both mappers now substitute the
SAME defaults, so the preview agrees with what is actually scheduled. Neither
guard is assigned back onto the entity, which is the
media.nullable-primitive-collection-mutation mechanism.
Reachability, stated precisely rather than overclaimed. All six are
nullable:true on both providers, but a nullable column does not produce a
NULL row: five of the six were present at CreateTable, so a NULL there still
needs code to write one, and on MySQL there is NO code-path-free NULL for any
of the six. The write path ACCEPTS a null (SaveChanges succeeds, stores SQL
NULL) but no caller supplies one today -- every production construction of the
two commands goes through the request records. That is a property of the code,
not a live caller; claiming otherwise would be the banned "it's AsNoTracking
today" argument pointed the other way.
#824 -- ElasticSearchIndex.UpdateSong had no regression test
Issue option 1 (a non-network transport) shipped, and needed no new package:
Elastic.Transport.InMemoryRequestInvoker is public in the pinned version and
ElasticsearchClientSettings(NodePool, IRequestInvoker) accepts it, injected
into the private _client the way #701 injects the Lucene IndexWriter.
UpdateItems never runs `_client ??= CreateClient()`, so the injected instance
is the one used.
Two traps there are load-bearing, both measured: the canned response must
carry an `X-Elastic-Product: Elasticsearch` header or the client's product
check throws UnsupportedProductException INTO UpdateSong's catch, and an empty
body fails to deserialize the same way. Either turns the fixture into a green
measurement of the error path -- which is how it first failed here, caught by
the ThrowOnWarningLogger. The document id is asserted as the LAST PATH SEGMENT,
not by substring: the index name carries digits, so ShouldContain would stop
discriminating for a song whose id collided with one.
Six mutations executed, each disarming ITS OWN clause alone:
- `??=` restored in ElasticSearchIndex only -> the Elastic fixture reddens on
"metadata.Artists should be null but was []" while the LUCENE fixture stays
GREEN. The #824 hole demonstrated, not described.
- DaysOfWeek guard disarmed in the selector -> 4 red, 3 green (DaysOfMonth and
MonthsOfYear unaffected). Each clause is independently load-bearing.
- DaysOfMonth guard disarmed in Playouts.Mapper -> 1 red, 2 green.
- Elastic dropped from the covered set / mapped to the SAME fixture as Lucene /
mapped to a class with no [Test] -> SearchIndexMutationCoverageTests reddens
on each.
That coverage guard is the boundary fix the issue asked for: the covered set is
compared against an ISearchIndex population DERIVED FROM THE ASSEMBLY. Its claim
stops where the check does -- no static check can establish that a named fixture
actually DRIVES its indexer, so it forces a human to look rather than proving
coverage. ThrowOnWarningLogger moved to ErsatzTV.Tests/Support so both fixtures
share it; the Lucene fixture's assertions are otherwise untouched, since it is a
witnessed proof artifact.
No production change in ElasticSearchIndex.cs -- #824 is coverage only.
Docs: testing.md gains a "Provider-parity fixtures" section naming all THREE
opt-in-MySQL fixtures and recording that CI runs none of them (#627);
docs/README.md gains the matching task signal; guard-inventory.md's
hand-written C# guard list goes from five files to six. Scheduling/Mapper.cs
loses the UTF-8 BOM it inherited, per #311 fix-as-you-touch.
Local gate (with the MySQL lane armed): ErsatzTV.Tests 2096 passed / 0 skipped,
Core.Tests 693/1, Infrastructure.Tests 114, Architecture.Tests 7, Scanner.Tests
1504 -- 0 failures in each. scripts/tests 1228 passed / 2 skipped. dotnet format
whitespace --verify-no-changes clean; BOM check over the touched set with the
population COUNT asserted, because a bare zsh loop silently checks one
concatenated filename. decisions_validate OK.
Fixes#823Fixes#824
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019zUmJZHhVP7kXg5DV237TW
Adds parameterized invariant tests (Schedule_clock_padded_offline_multimode)
exercising schedule-level clock pad + offline advance across a 2-day window
(two midnight crossings) through Flood, Duration, and Multiple — previously
only PlayoutModeSchedulerOne had any coverage. Fixture uses sub-15-min content
so each padded item occupies one :15 slot and Duration's fill-the-block
contract tiles exactly (no off-boundary packing).
Part 2 (precision): replaces the day-boundary anchor-clamp magnitude heuristic
(overrun <= one pad interval on a padded schedule) with a precise signal — the
last scheduler now reports the exact offline-pad target it advanced CurrentTime
to (transient PlayoutSchedulerResult.ClockPadOfflineTarget, never persisted),
and the clamp exempts only when CurrentTime equals that target exactly. A
non-offline overrun (Duration/Flood/Multiple ending short of its natural end, a
hard-stop, a tail advance) changes CurrentTime away from the target and still
clamps, so persisted NextStart no longer shifts by up to an interval. No
persisted-schema/migration change. Output byte-identical for all existing
goldens.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Extends the #163 PlayoutBuildGoldenTests in-memory net to the Sequential (YAML)
builder: a committed fixture (Goldens/Fixtures/sequential-schedule.yml) with two
`count: 2` instructions over one chronological collection, built via
SequentialPlayoutBuilder over the pinned window. The count/all/duration handlers
do UTC-only arithmetic off the caller-supplied start, so the case is
TZ-independent (passes, not skips, under a non-UTC TZ) and needs no Assume guard.
Non-vacuity: a fixture count tweak flips the golden + the contiguity assertion.
Scripted is deliberately excluded from the golden net — ScriptedPlayoutBuilder
shells out via Cli.Wrap to an external process that drives SchedulingEngine over
HTTP, which no in-memory golden can characterize. Recorded as the Done-when
"documented decision" arm in docs/decisions.md
(testing.scripted-playout-golden-deferred) + docs/testing.md; the scripted
integration harness is tracked as follow-up #563.
fixes#381
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-ups (cold review of PR #457):
- Marathon can reach PlayoutBuilder's default arm via `goto default` when
its enumerator can't be built; the new warning claimed "Marathon is not
supported by classic scheduling", which is false. Distinguish
supported-but-failed-to-build (logs "could not build") from genuinely
unsupported using PlaybackOrderSupport, which also makes Classic a
runtime consumer of the matrix.
- PlaybackOrderSupportTests now asserts every SchedulingEngineKind has a
matrix entry, so a new engine kind fails the test instead of throwing
KeyNotFoundException at runtime.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adding a new PlaybackOrder was unsafe by construction: three build-time
dispatch sites turned an unknown value into an enumerator silently.
Classic substituted RandomizedMediaCollectionEnumerator (the // TODO
default arm), PlaylistEnumerator had no default arm so the item was
dropped, and BlockPlayoutBuilder's allow-list continue skipped it.
(#70 already made YAML/Scripted log a warning and MultiCollectionGroup
throws.)
- each silent site now logs a Warning naming the order + engine + the
fallback taken; the fallback itself is preserved so a live channel
never goes dark on one misconfigured item and scheduler goldens do
not move.
- PlaylistEnumerator.Create gained an optional Option<ILogger> (it was
static with no logger -- why the drop was unreportable); loggered
callers pass it.
- BlockPlayoutBuilder gained an explicit Random arm (it previously
reached an enumerator only via the coincidental _ => fallback) and a
loud defensive fallback.
- new PlaybackOrderSupport matrix (per SchedulingEngineKind) + tripwire
PlaybackOrderSupportTests: Supported ∪ Unsupported must partition the
enum for every engine, so a new order fails the test until classified.
BlockPlayoutBuilder consumes the matrix for its allow-list.
- write-path rejection left unchanged (#70 closed the persistence hole;
the perimeter has been wrong three times per decisions.md); reverse
_ => None mappings reviewed and deferred (different axis; making them
loud would warn on legit enumerator types).
docs/decisions.md updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The unit tests pin the enumerator's sequence in isolation; this pins that the real
PlayoutBuilder actually distributes by weight, through the whole chain:
MultiCollection -> MediaCollectionRepository -> CollectionWithItems.Weight ->
ShuffleSourceBuilder -> WeightedShuffleCollectionEnumerator -> PlayoutItems.
The fixture is deliberately lopsided: the HEAVY source (weight 3) is the SMALL
collection (2 items) and the LIGHT source (weight 1) is the LARGE one (4). Over
the pinned 2-day window that yields 81 : 27 = exactly 3.00 : 1 — the smaller
collection taking the larger share, which is the entire point of the feature and
is not reachable by any existing order: Shuffle is size-proportional (~2:4 here)
and ShuffleInOrder plays every item exactly once per cycle. Longest consecutive
same-source run is 3, so it interleaves smoothly rather than draining blocks.
Same determinism contract as Classic_shuffle: pinned Playout.Seed + Continue mode
(Reset randomizes the seed). Channel number/GUID 5 — every golden fixture shares
one in-memory DB, so those must be globally unique.
The net is armed, not decorative: with the golden absent the test fails (missing
golden is a hard failure by design), and regenerating added only classic-weighted
— the three existing goldens are byte-identical, so nothing was silently
re-baselined.
Core.Tests 566 passed, 0 failed.
Refs #70
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial review of PR #402 returned BLOCKED. It could not break the WRR math or
the stateless-restore claim (it probed restore across wraps at indices 12/13/20/37
— all held, and the clamp preserves a 1000:1 ratio exactly). What it broke was the
perimeter.
B1 — the validation gate had a hole, so the silent-drop bug shipped.
CreateChannelFromLineup is a THIRD writer of PlaylistItem.PlaybackOrder; its own
guard only covered MultiCollection entries, so a 2+ entry lineup of plain
collections persisted WeightedShuffle straight through to PlaylistEnumerator's
null-drop. My decisions.md claim that "the silent sites never see it" was false as
written — corrected in place, with the lesson recorded: grep every writer of the
field, the non-obvious composite handler is the one that gets missed. The
Add*ToPlaylist handlers are safe only because they hardcode their order.
B2 — Weight had no validation at all, and create/update disagreed on the same
input. EF's HasDefaultValue(1) substitutes 1 for a 0 on INSERT (0 reads as "not
set") but an UPDATE writes the 0 through — and a 0-weight source was filtered out
of the rotation, deleting it from the channel silently. Exactly the failure this
order is careful to avoid everywhere else. Now bounded 1..1000 by a shared
MultiCollectionItemWeight used by both paths so they cannot drift, and clamped
again in the enumerator for rows that predate the gate.
B3 — Sum(weights) is checked arithmetic, so two int.MaxValue weights threw
OverflowException from inside a playout build. Reachable through the API precisely
because of B2. The ceiling fixes both; the sum also widens to long.
M1 the lineup mirror now allows WeightedShuffle for multi collections, matching the
PlayoutModeMustBeValid change it claims to mirror. M3 ScheduleAsGroup is documented
as deliberately unread by this order. L1 MinimumDuration is computed over every
source instead of the current rotation — under the clamp a rotation is a strict
subset and is rebuilt each wrap, so caching over it went stale. L2 the retry guard
keys off the rotation, not the raw collection count.
N1 the tautological default test is gone: it built entities in C#, so it asserted
the property initializer, not the migration — it could not have failed. Replaced
with clamp, overflow, and cross-wrap restore cases (the property the review proved
but found unpinned).
H1 the two follow-ups the PR body claimed were "filed" did not exist. Now filed:
#403 (silent dispatch-fallback hardening) and #404 (SPA weight UI, blocked-by #388).
Core.Tests 565 passed, ErsatzTV.Tests 1643 passed, 0 failed.
Refs #70
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ten tests pin the distribution contract exactly rather than statistically,
because the sequence IS the product decision: 3:1 emits A A B A (spread, not
drained); equal weights air a 2-item source as often as a 20-item one; ties break
to the earliest source; a defaulted weight behaves as fair-share (guarding the
migration default); restoring at an index equals advancing to it (the stateless
contract that lets CollectionEnumeratorState carry this order).
Fixes a hang found by the non-vacuity control. When a rotation wraps, MoveNext
retried the rebuild to avoid an immediate repeat. ShuffleInOrder can do that
unbounded because its reshuffle randomizes the lead item — but this order's lead
is decided by weight, so the heaviest source always leads, and when it holds a
single item the lead never changes and the retry never terminates. Two single-item
collections with unequal weights would wedge the playout build. The retry is now
bounded: avoiding a back-to-back repeat is a nicety, not terminating is not.
Regression test walks several wraps under a timeout.
Non-vacuity proven per repo lore: inverting the real WRR pick (max -> min, never
if(true), which trips CS0219 under warnings-as-errors and silently serves a stale
dll to --no-build) failed 5 of 10 tests on a clean build (0 errors, so not a
stale-dll false pass). Reverted; 10/10 green.
Refs #70
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Post-rebase onto main (which merged #77's Classic_clock_padded golden): both fixtures
seed the shared in-memory DB and independently picked Channel Number "3"/GUID ...0003,
tripping a UNIQUE constraint. Move the shuffle fixture to "4"/...0004. Golden output is
unchanged (it snapshots times/titles; shuffle determinism comes from playout.Seed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses the one substantive nit from the cold adversarial review of PR #391: the
true branch of ShuffleSourceBuilder.GetGroupedMediaItemsForShuffle (routing through
MultiPartEpisodeGrouper) had no direct regression net — the goldens and existing unit
tests all use false. Add a test that observably distinguishes the branches: four
episodes where two are a "(1)"/"(2)" multi-part pair collapse to 3 groups under
keepMultiPartEpisodesTogether=true vs 4 groups under false. Test-only; no production
change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Eliminate the one cross-engine reach-in in the scheduler: PlaylistEnumerator called
PlayoutBuilder.GetGroupedMediaItemsForShuffle / GetCollectionItemsForShuffleInOrder as
statics (one engine reaching into another engine's class). Move both helpers verbatim to
a new public static ShuffleSourceBuilder in ErsatzTV.Core/Scheduling (sibling to the
also-static MultiCollectionGrouper / MultiPartEpisodeGrouper; deps passed as parameters,
not DI). Classic (PlayoutBuilder) and Playlist (PlaylistEnumerator) now share this one
place to build shuffle sources.
One intentional signature change: GetGroupedMediaItemsForShuffle takes
(bool keepMultiPartEpisodesTogether, bool treatCollectionsAsShows) instead of a
ProgramSchedule (verified those are the only two properties it read). This deletes
PlaylistEnumerator's fake `new ProgramSchedule { KeepMultiPartEpisodesTogether = false }`
(its TODO becomes an honest false, false) and gives callers without a ProgramSchedule
(#176, #70) a schedule-entity-free entry point.
Scope is deliberately (a)-only: engine separation preserved, no god-factory. Block stays
its own family; the Scripted/YAML construction duplication is a separate follow-up gated
on #381. See docs/decisions.md 2026-07-17.
Behavior-preserving: the characterization net added in the previous commit (classic-shuffle
golden byte-identical, PlaylistEnumerator reach-in sequence unchanged) plus new direct
ShuffleSourceBuilder unit tests (multi-collection vs fake-multi-collection lookup;
multi-part grouping on/off) all green. Full Core.Tests: 546 passed. PlayoutBuilder.cs
also de-BOM'd + whitespace-normalized per the fix-as-you-touch convention (#311).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The #163 golden net (PlayoutBuildGoldenTests) only pins PlaybackOrder.Chronological
on Classic + Block. The paths #380 refactors — the two PlayoutBuilder shuffle-source
statics and PlaylistEnumerator's cross-engine reach-in into them — had zero coverage,
so "goldens green before & after" would be a false safety signal. Extend the net to
exactly the moved paths:
- PlayoutBuildGoldenTests.Classic_shuffle: Classic builder + PlaybackOrder.Shuffle,
pinned playout.Seed via Continue mode (Reset randomizes the seed) so the shuffle is
deterministic and TZ-independent. Exercises GetGroupedMediaItemsForShuffle + the
ShuffledMediaCollectionEnumerator wiring.
- PlaylistEnumeratorTests.ReachIn_Shuffle_And_ShuffleInOrder_Items_Are_Characterized:
a playlist with a Shuffle item and a ShuffleInOrder item, pinned seed, asserting the
emitted id sequence. Covers both PlaylistEnumerator.Create reach-in call sites
(:174 GetGroupedMediaItemsForShuffle, :185 GetCollectionItemsForShuffleInOrder) that
the extraction rewires.
No production changes. Verified deterministic across repeated runs and under TZ=UTC /
Asia/Kolkata.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the MERGEABLE cold review:
- Golden: document that TZ-independence relies on PadToNearestMinute=15
dividing every IANA offset; warn against regenerating with a non-15 divisor.
- decisions.md: include DecoDefault in the ProjectFlood coalesce list.
[decisions-edit] corrects a factual omission in the same-PR entry added this session.
Refs #77
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#77's core (pad/snap schedules to :00/:15/:30 via filler) already exists —
FillerMode.Pad + PadToNearestMinute (Classic), pad_to_next/pad_until
(Sequential); Block is inherently time-anchored. No production code change;
this locks the behaviour end-to-end and documents that it exists.
- PlayoutBuildGoldenTests.Classic_clock_padded: a PostRoll FillerMode.Pad(15)
preset through the real PlayoutBuilder snaps content to :15 (golden +
explicit quarter-hour assertion). Splits Verify -> CompareGolden for reuse.
- ChannelGuideProjectorClockPadTests: the guide projection coalesces trailing
filler so programmes STOP on the padded boundary (XMLTV half).
- docs: decisions.md (2026-07-17 entry), domain-model.md (clock-boundary row),
testing.md (test map + count 540->542).
Deferred (UI, blocked on #388): one-click per-channel/schedule clock-align
toggle + 60-min increment option.
Refs #77
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the cold adversarial review of PR #382 (MERGEABLE-WITH-NITS):
- Block TZ guard: BaseUtcOffset -> GetUtcOffset(Start). BaseUtcOffset is zero
year-round for DST zones like Europe/London, so it would pass in a summer-dated
fixture where London != UTC; GetUtcOffset pins the actual build instant and is
correct regardless of fixture date. (Safe today — mid-Jan fixture — but removes
the latent fixture-date dependency the reviewer flagged.)
- Document the determinism invariants the fixture relies on: golden captures raw
builder output (pre-trim AddedItems), Classic is TZ-independent for the captured
fields (no guard needed), and ResetPlayout's random Seed can't perturb the
Chronological fixture (RandomStartPoint/ShuffleScheduleItems false, distinct
release dates).
Re-verified: TZ=UTC both goldens pass; TZ=America/New_York Block skips, Classic
passes; no golden drift.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs — a
golden-file net that snapshots the PlayoutItems each builder produces over a
pinned build window; the scheduling counterpart to the M3U (#11) / XMLTV (#28)
goldens. This is the regression net that de-risks the scheduling-refactor
chain (#380 -> #70/#71/#176).
Coverage this slice:
- Classic (PlaybackOrder.Chronological)
- Block (TimeZoneInfo.Local-guarded to UTC via Assume: runs in CI, skips
gracefully under a non-UTC TZ; a real TZ seam is #380's scope)
Determinism: the builders read no wall clock (time enters only via the
caller-supplied start), so a pinned start is fully deterministic. Snapshots the
raw UTC Start/Finish, not the *Offset properties (which .ToLocalTime()). Regen
via ETV_UPDATE_PLAYOUT_GOLDENS (deliberately separate from ETV_UPDATE_GOLDENS).
Both goldens proven non-vacuous.
Sequential (YAML) + Scripted goldens tracked as a follow-up in #381.
Docs: docs/testing.md updated (third golden net + env var + Block TZ guard).
fixes#163
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Should_Not_Crash_Mid_Roll_{Zero,One}_Chapters passed 'new PlayoutItem()'
to AddFiller, whose Start defaults to DateTime.MinValue. The mid-roll Pad
path (PlayoutModeSchedulerBase.cs:609) subtracts 'currentMinute' minutes
from StartOffset; since StartOffset does ToLocalTime(), a non-zero local
offset makes currentMinute non-zero, underflowing DateTimeOffset.MinValue
and throwing ArgumentOutOfRangeException. Under UTC the offset is 0 so it
stays in range (hence CI was green). Unreachable in production — real
playout items never start at MinValue — so this is a test-data artifact,
not a product bug.
Fix: seed the fixtures with a real Start (startState.CurrentTime.UtcDateTime),
matching the convention in the passing AddFiller tests. Verified across
UTC, Europe/Brussels, and Asia/Kolkata (+5:30); full Core.Tests now passes
under any timezone (484/0). Updated docs/contributing.md §8 per its §10
deviation policy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor classic and block schedules to use same alternate schedule selector
* handle start year and end year
* add migrations for classic and block schedules
* allow editing block template start and end year
* add tests that include years
* add date range editing to classic (alternate) schedules
* fix running tests locally
* restore media files load; needed for local folder scanners
* update changelog
* feedback
* fix effective block tests running on github
* update dependencies
* pass tz again
* use tzconvert for time zones in tests
* temporary logging
* maybe fix
* test cleanup