From 2865b43a96432d85381ee0bc2704e5cebfd0a9b1 Mon Sep 17 00:00:00 2001 From: GenWave Radio Date: Thu, 17 Sep 2026 19:23:45 -0600 Subject: [PATCH 1/3] =?UTF-8?q?test(orchestration):=20T517=20break-contrac?= =?UTF-8?q?t=20slot=20facts=20AC1=E2=80=93AC17=20pinned=20through=20the=20?= =?UTF-8?q?builder=20(gh-#401)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STORY-453 AC1–AC17 (SPEC F186 slot table, F186.1, F186.2 a–f, F186.4 i) go green against the unsplit Orchestrator: back-announce, crosstalk and its ceremony exclusion, the two-announcement cap, station-id and ad cadence, StationId PullAsync(Orchestrator orchestrator, PlayoutContext? ctx = null) + { + var item = await orchestrator.GetNextAsync(ctx ?? new PlayoutContext([]), CancellationToken.None); + Assert.NotNull(item); + return item; + } + + static async Task> PullManyAsync(Orchestrator orchestrator, int count, PlayoutContext? ctx = null) + { + var items = new List(); + for (var i = 0; i < count; i++) items.Add(await PullAsync(orchestrator, ctx)); + return items; + } + + /// The one place a scenario's three cadence knobs become a . + static CadenceConfig Cadence(bool leadIn, bool backAnnounce, int stationIdEvery) => new() + { + LeadInBeforeEachTrack = leadIn, + BackAnnounceAfterEachTrack = backAnnounce, + StationIdEveryNUnits = stationIdEvery, + }; + + /// Wires a real schedule/persona-store/crosstalk-planner chain (SPEC F111/F117) so + /// TryVendCrosstalkForThisBreak can resolve an on-air show slug — the shared arrange for AC3/AC4/AC15. + /// is null for AC3/AC4; AC15 passes its own fake to assert on the render seam. + static (OrchestratorChain Chain, CrosstalkPlanner Planner, FakeMediaCatalog Catalog) BuildCrosstalkChain( + CadenceConfig cadence, FakeTtsSegmentSource? tts = null) + { + var personaStore = new FakePersonaStore(); + personaStore.Add(TestData.MakePersona(HostPersonaId, "Host", "af_host")); + personaStore.Add(TestData.MakePersona(NeighborPersonaId, "Neighbor", "af_neighbor")); + + // Two segments (not one): CrosstalkPlanner.TryCastPersonas derives a cast from grid adjacency — + // a single all-day segment leaves the host with no distinct neighbor, so any stocked exchange + // reads as stale before it can ever vend. Mirrors Story452_BreakCharacterisationReplay's own + // precedent exactly (same two boundaries, same neighbor persona, same clock below). + var week = new ScheduleWeekSnapshot([ + new ScheduleSegment(1, DayOfWeek.Monday, 0, 720, HostPersonaId, null, null, null, + Show: new ShowSummary(5, "Morning Mix", null, null) { Slug = ShowSlug }, ShowId: 5), + new ScheduleSegment(2, DayOfWeek.Monday, 720, 1440, NeighborPersonaId, null, null, null), + ]); + + var crosstalkScope = new FakeCrosstalkScopeProvider([ShowSlug], everyNthAiring: 1); + var planner = new CrosstalkPlanner(personaStore, crosstalkScope, NullLogger.Instance); + var catalog = new FakeMediaCatalog(TestData.MakeTrackRef("t1")); + + var builder = new OrchestratorBuilder() + .WithTime(new FakeTimeProvider(new DateTimeOffset(2026, 3, 2, 10, 0, 0, TimeSpan.Zero))) + .WithSchedule(week) + .WithPersonaStore(personaStore) + .WithCadence(cadence) + .WithCrosstalkPlanner(planner) + .WithCatalog(catalog) + .WithLookahead(TimeSpan.FromMinutes(10)); + if (tts is not null) builder.WithTts(tts); + + return (builder.Build(), planner, catalog); + } + + static void StockReadyExchange(CrosstalkPlanner planner, string assetPath) + { + File.WriteAllBytes(assetPath, [0]); + planner.Stock(new StockedCrosstalkExchange( + ShowSlug, new CrosstalkCast(HostPersonaId, NeighborPersonaId), assetPath, new Loudness(-16.0, -1.0, true), + Cue: null, DurationMs: 6_000)); + } + // HAPPY PATH public sealed class ScenarioBackAnnounceWithAPreviousTrack { // Given: cadence back-announce on, a previous track - /// AC1 — the first spoken item names the previous track - [Fact(Skip = Pending)] - public void BuffersABackAnnounceFirst() => Assert.Fail(Pending); + [Fact] + public async Task BuffersABackAnnounceFirst() + { + var cadence = Cadence(leadIn: false, backAnnounce: true, stationIdEvery: 0); + var tts = new FakeTtsSegmentSource(); + var catalog = FakeMediaCatalog.WithPool([TestData.MakeTrackRef("t1"), TestData.MakeTrackRef("t2")]); + var chain = new OrchestratorBuilder().WithCadence(cadence).WithTts(tts).WithCatalog(catalog).Build(); + + var previous = await PullAsync(chain.Orchestrator); + var items = await PullManyAsync(chain.Orchestrator, 2); + + Assert.Equal(SegmentKind.BackAnnounce, items[0].SegmentKind); + var backAnnounce = Assert.Single(tts.Requests, r => r.Kind == SegmentKind.BackAnnounce); + var track = backAnnounce.Track; + Assert.NotNull(track); + Assert.Equal(previous.MediaId, track.MediaId); + } } public sealed class ScenarioTheFirstUnit { // Given: no previous track - /// AC2 — nothing to announce - [Fact(Skip = Pending)] - public void BuffersNoBackAnnounce() => Assert.Fail(Pending); + [Fact] + public async Task BuffersNoBackAnnounce() + { + var cadence = Cadence(leadIn: false, backAnnounce: true, stationIdEvery: 0); + var tts = new FakeTtsSegmentSource(); + var chain = new OrchestratorBuilder().WithCadence(cadence).WithTts(tts).Build(); + + await PullAsync(chain.Orchestrator); + + Assert.DoesNotContain(tts.Requests, r => r.Kind == SegmentKind.BackAnnounce); + } } public sealed class ScenarioAVendableCrosstalk { // Given: a crosstalk vend, a next track, no drainAsOf, no ceremony due - /// AC3 — the crosstalk file sits after the back-announce - [Fact(Skip = Pending)] - public void FollowsTheBackAnnounce() => Assert.Fail(Pending); + [Fact] + public async Task FollowsTheBackAnnounce() + { + var cadence = Cadence(leadIn: false, backAnnounce: true, stationIdEvery: 0); + var assetPath = Path.Combine(Path.GetTempPath(), "genwave-story453-ac3-crosstalk.wav"); + var (chain, planner, _) = BuildCrosstalkChain(cadence); + + await PullAsync(chain.Orchestrator); // primes the previous track + + StockReadyExchange(planner, assetPath); + var items = await PullManyAsync(chain.Orchestrator, 3); + + Assert.Equal(SegmentKind.BackAnnounce, items[0].SegmentKind); + Assert.Equal(SegmentKind.Crosstalk, items[1].SegmentKind); + Assert.Null(items[2].SegmentKind); + } } public sealed class ScenarioACrosstalkAndADueSignOff { // Given: a vendable crosstalk with a SignOff due now - /// AC4 — a drained ceremony excludes crosstalk - [Fact(Skip = Pending)] - public void BuffersNoCrosstalk() => Assert.Fail(Pending); + [Fact] + public async Task BuffersNoCrosstalk() + { + var cadence = Cadence(leadIn: false, backAnnounce: false, stationIdEvery: 0); + var assetPath = Path.Combine(Path.GetTempPath(), "genwave-story453-ac4-crosstalk.wav"); + var (chain, planner, _) = BuildCrosstalkChain(cadence); + StockReadyExchange(planner, assetPath); + + chain.Queue.Enqueue(SpeechDeferralKind.SignOff, "test: due signoff", chain.Time.GetUtcNow(), Handoff); + + var items = await PullManyAsync(chain.Orchestrator, 2); + + Assert.DoesNotContain(items, i => i.SegmentKind == SegmentKind.Crosstalk); + } } public sealed class ScenarioThreePendingAnnouncements { // Given: three claimable announcements - /// AC5 — the cap is two per break - [Fact(Skip = Pending)] - public void BuffersExactlyTwo() => Assert.Fail(Pending); + [Fact] + public async Task BuffersExactlyTwo() + { + var source = new FakeAnnouncementSource(); + source.Pending.Enqueue(new AnnouncementItem(1, "One", true, null)); + source.Pending.Enqueue(new AnnouncementItem(2, "Two", true, null)); + source.Pending.Enqueue(new AnnouncementItem(3, "Three", true, null)); + var renderer = new FakeVerbatimSegmentRenderer(); + var chain = new OrchestratorBuilder().WithAnnouncementSource(source).WithAnnouncementRenderer(renderer).Build(); + + var items = await PullManyAsync(chain.Orchestrator, 3); + + Assert.Equal(2, items.Count(i => i.SegmentKind == SegmentKind.Announcement)); + } } public sealed class ScenarioStationIdCadenceHit { // Given: StationIdEveryNUnits = 2, unit 2 - /// AC6 — the cadence enqueues a StationId - [Fact(Skip = Pending)] - public void BuffersAStationIdAfterTheAnnouncements() => Assert.Fail(Pending); + [Fact] + public async Task BuffersAStationIdAfterTheAnnouncements() + { + var cadence = Cadence(leadIn: false, backAnnounce: false, stationIdEvery: 2); + var source = new FakeAnnouncementSource(); + var renderer = new FakeVerbatimSegmentRenderer(); + var chain = new OrchestratorBuilder().WithCadence(cadence).WithAnnouncementSource(source).WithAnnouncementRenderer(renderer).Build(); + + await PullManyAsync(chain.Orchestrator, 2); // units 0-1 — the cadence has not hit yet + + source.Pending.Enqueue(new AnnouncementItem(1, "One", true, null)); + var items = await PullManyAsync(chain.Orchestrator, 3); // unit 2 — cadence hits + + Assert.Equal(SegmentKind.Announcement, items[0].SegmentKind); + Assert.Equal(SegmentKind.StationId, items[1].SegmentKind); + Assert.Null(items[2].SegmentKind); + } } public sealed class ScenarioAdCadenceHit { // Given: ad cadence every 2 units and a vendable spot, unit 2 - /// AC7 — the vended spot is buffered - [Fact(Skip = Pending)] - public void BuffersTheSpot() => Assert.Fail(Pending); + [Fact] + public async Task BuffersTheSpot() + { + var adCadence = new FakeAdCadenceProvider(2); + var spot = new MediaItem("ad-spot-1", "/ads/spot1.mp3", "Ad Spot", new Loudness(-16.0, -1.0, true)); + var adSpotVend = new FakeAdSpotVend { Answer = spot }; + var chain = new OrchestratorBuilder().WithAdCadence(adCadence).WithAdSpotVend(adSpotVend).Build(); + + await PullManyAsync(chain.Orchestrator, 2); // units 0-1 — the cadence has not hit yet + var items = await PullManyAsync(chain.Orchestrator, 2); // unit 2 — cadence hits + + Assert.Equal(SegmentKind.Ad, items[0].SegmentKind); + Assert.Equal("ad-spot-1", items[0].MediaId); + Assert.Null(items[1].SegmentKind); + } } public sealed class ScenarioStationIdAndAdDueTogether { // Given: both cadences hit the same unit - /// AC8 — the drain tiebreak - [Fact(Skip = Pending)] - public void StationIdPrecedesTheAd() => Assert.Fail(Pending); + [Fact] + public async Task StationIdPrecedesTheAd() + { + var cadence = Cadence(leadIn: false, backAnnounce: false, stationIdEvery: 2); + var adCadence = new FakeAdCadenceProvider(2); + var spot = new MediaItem("ad-spot-1", "/ads/spot1.mp3", "Ad Spot", new Loudness(-16.0, -1.0, true)); + var adSpotVend = new FakeAdSpotVend { Answer = spot }; + var chain = new OrchestratorBuilder().WithCadence(cadence).WithAdCadence(adCadence).WithAdSpotVend(adSpotVend).Build(); + + await PullManyAsync(chain.Orchestrator, 2); // units 0-1 — neither cadence has hit yet + var items = await PullManyAsync(chain.Orchestrator, 3); // unit 2 — both hit together + + Assert.Equal(SegmentKind.StationId, items[0].SegmentKind); + Assert.Equal(SegmentKind.Ad, items[1].SegmentKind); + Assert.Null(items[2].SegmentKind); + } } public sealed class ScenarioContextAndTimeDateDueTogether { // Given: both deferrals due the same unit - /// AC9 — the drain tiebreak - [Fact(Skip = Pending)] - public void ContextPrecedesTimeDate() => Assert.Fail(Pending); - } - - public sealed class ScenarioAPooledStationId - { - // Given: catalog with a ready station-id asset - - /// AC10 — the pool hit is used - [Fact(Skip = Pending)] - public void BuffersThePooledItem() => Assert.Fail(Pending); - - /// AC10 — no render when the pool hits - [Fact(Skip = Pending)] - public void SendsNoShowIdentRequest() => Assert.Fail(Pending); - } - - public sealed class ScenarioAnEmptyStationIdPool - { - // Given: catalog without a station-id asset - - /// AC11 — the tts fake received the render - [Fact(Skip = Pending)] - public void SendsAShowIdentRequest() => Assert.Fail(Pending); - } - - public sealed class ScenarioALateTimeDate - { - // Given: TimeDate due 10 s ago with a 90 s budget - - /// AC12 — the request says Late - [Fact(Skip = Pending)] - public void CarriesFreshnessLate() => Assert.Fail(Pending); - } - - public sealed class ScenarioADueContextSegment - { - // Given: a Context deferral with provider key weather - - /// AC13 — the buffered item names the provider - [Fact(Skip = Pending)] - public void CarriesTheProviderKey() => Assert.Fail(Pending); - } - - public sealed class ScenarioLeadInOn - { - // Given: cadence lead-in on - - /// AC14 — lead-in sits last - [Fact(Skip = Pending)] - public void IsTheLastSpokenItemBeforeTheTrack() => Assert.Fail(Pending); - } - - public sealed class ScenarioACeremonyOnlyUnit - { - // Given: a SignOff below the music floor and a vendable crosstalk - - /// AC15 — - [Fact(Skip = Pending)] - public void BuffersNoCrosstalk() => Assert.Fail(Pending); - - /// AC15 — - [Fact(Skip = Pending)] - public void BuffersNoLeadIn() => Assert.Fail(Pending); - - /// AC15 — - [Fact(Skip = Pending)] - public void BuffersNoTrack() => Assert.Fail(Pending); - } - - public sealed class ScenarioACeremonyOnlyUnitWithAPreviousTrack - { - // Given: the same unit with back-announce on (Dean: stays) - - /// AC16 — the outgoing DJ signs off the last track - [Fact(Skip = Pending)] - public void BuffersABackAnnounceBeforeTheSignOff() => Assert.Fail(Pending); - } - - public sealed class ScenarioAStraddlingTrackWithASignOffPending - { - // Given: the track crosses the boundary - - /// AC17 — NotBefore at or after now plus the tail - [Fact(Skip = Pending)] - public void HoldsTheSignOnPastTheQueuedTail() => Assert.Fail(Pending); - } - - // --------------------------------------------------------------------- - // SAD PATH — segregated - // --------------------------------------------------------------------- - - public sealed class ScenarioAnExpiredTimeDate - { - // Given: TimeDate due 120 s ago with a 90 s budget - - /// AC18 — - [Fact(Skip = Pending)] - public void BuffersNoTimeDate() => Assert.Fail(Pending); - - /// AC18 — - [Fact(Skip = Pending)] - public void LogsOneExpiryLine() => Assert.Fail(Pending); - } - - public sealed class ScenarioAnAdVendThatThrows - { - // Given: the vend throws - - /// AC19 — - [Fact(Skip = Pending)] - public void AssemblesTheBreakWithoutAnAd() => Assert.Fail(Pending); - - /// AC19 — - [Fact(Skip = Pending)] - public void LogsOneWarnNamingTheVend() => Assert.Fail(Pending); - } - - public sealed class ScenarioANullLeadInRender - { - // Given: the lead-in render returns null - - /// AC20 — the slot drops alone - [Fact(Skip = Pending)] - public void KeepsEveryOtherItemInPosition() => Assert.Fail(Pending); - } - - public sealed class ScenarioAnOverBudgetBackAnnounce - { - // Given: the back-announce render exceeds the budget - - /// AC21 — the slot drops alone - [Fact(Skip = Pending)] - public void KeepsEveryOtherItemInPosition() => Assert.Fail(Pending); - } - - public sealed class ScenarioANullSignOffRender - { - // Given: the SignOff render returns null - - /// AC22 — - [Fact(Skip = Pending)] - public void PublishesOneHandoffPieceDropped() => Assert.Fail(Pending); - } - - public sealed class ScenarioANullAnnouncementRender - { - // Given: an announcement render returns null - - /// AC23 — the claim is not released - [Fact(Skip = Pending)] - public void KeepsTheAnnouncementClaimed() => Assert.Fail(Pending); - } - - public sealed class ScenarioTheFollowUpsOnPrTwo - { - // Given: PR-2's body (manual) - - /// AC24 — announcements on a ceremony-only unit; ad in a straddle break - [Fact(Skip = Manual)] - public void TwoIssuesExistInProjectThree() => Assert.Fail(Manual); + [Fact] + public async Task ContextPrecedesTimeDate() + { + var tts = new FakeTtsSegmentSource(); + var chain = new OrchestratorBuilder().WithTts(tts).Build(); + var content = new ContextSegmentFacts("Sunny and mild.", chain.Time.GetUtcNow().AddMinutes(10)); + chain.Queue.Enqueue(SpeechDeferralKind.Context, "test: weather due", discriminator: "weather", context: content); + chain.Queue.Enqueue(SpeechDeferralKind.TimeDate, "test: time due"); + + await PullAsync(chain.Orchestrator); + + Assert.Equal(SegmentKind.ContextSegment, tts.Requests[0].Kind); + Assert.Equal(SegmentKind.TimeDate, tts.Requests[1].Kind); + } } } diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContractSlots.cs b/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContractSlots.cs new file mode 100644 index 00000000..fc7937c1 --- /dev/null +++ b/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContractSlots.cs @@ -0,0 +1,280 @@ +// STORY-453 — The break contract has one fact per cell (gh-#401 · SPEC F186 · PLAN T517–T519) +// +// AC10–AC17 (T517, GREEN below) and the drops AC18–AC23/AC24 — the second half of +// FeatureBreakContract, split from Story453_BreakContract.cs purely for the ~300-line budget +// (csharp-best-practices). Shares that file's consts/helpers (PullAsync, PullManyAsync, +// BuildCrosstalkChain, StockReadyExchange, Handoff, ShowSlug) via the partial class. PendingDrops +// and Manual are declared in THIS file instead — the Skip-prefix-law scanner resolves a Skip = +// reference within its own file only, so a const backing a Skip= here must live here too. + +using GenWave.Core.Domain; + +namespace GenWave.Orchestration.Tests.Specs; + +public static partial class FeatureBreakContract +{ + // Skip-prefix-law scanner (GenWave.Architecture.Tests) resolves a Skip = reference within + // its own file's syntax tree only — it does not follow a partial class across files. These two + // consts are declared here, not in Story453_BreakContract.cs, because every [Fact(Skip = ...)] + // usage of them lives in THIS file. + const string PendingDrops = "pending: T518 — the drop cells (STORY-453)"; + const string Manual = "manual: two follow-up issues named in SPEC F186.4 — review evidence on PR-2 (STORY-453)"; + + public sealed class ScenarioAPooledStationId : IAsyncLifetime + { + readonly FakeTtsSegmentSource tts = new(); + MediaItem? item; + + // Given: catalog with a ready station-id asset + public async Task InitializeAsync() + { + var cadence = Cadence(leadIn: false, backAnnounce: false, stationIdEvery: 1); + var catalog = new FakeMediaCatalog(TestData.MakeTrackRef("t1")) { ImagingPoolResult = TestData.MakeTrackRef("pooled-station-id") }; + var chain = new OrchestratorBuilder().WithCadence(cadence).WithCatalog(catalog).WithTts(tts).Build(); + + await PullAsync(chain.Orchestrator); // unit 0 — the cadence has not hit yet + item = await PullAsync(chain.Orchestrator); // unit 1 — the pooled station-id leads + } + + public Task DisposeAsync() => Task.CompletedTask; + + /// AC10 — the pool hit is used + [Fact] + public void BuffersThePooledItem() + { + Assert.NotNull(item); + Assert.Equal(SegmentKind.StationId, item.SegmentKind); + Assert.Equal("pooled-station-id", item.MediaId); + } + + /// AC10 — no render when the pool hits + [Fact] + public void SendsNoShowIdentRequest() => Assert.DoesNotContain(tts.Requests, r => r.Kind == SegmentKind.StationId); + } + + public sealed class ScenarioAnEmptyStationIdPool + { + // Given: catalog without a station-id asset + /// AC11 — the tts fake received the render + [Fact] + public async Task SendsAShowIdentRequest() + { + var cadence = Cadence(leadIn: false, backAnnounce: false, stationIdEvery: 1); + var tts = new FakeTtsSegmentSource(); + var chain = new OrchestratorBuilder().WithCadence(cadence).WithTts(tts).Build(); + + await PullAsync(chain.Orchestrator); // unit 0 — the cadence has not hit yet + await PullAsync(chain.Orchestrator); // unit 1 — no pool row, a templated render is sent + + Assert.Contains(tts.Requests, r => r.Kind == SegmentKind.StationId); + } + } + + public sealed class ScenarioALateTimeDate + { + // Given: TimeDate due 10 s ago with a 90 s budget + /// AC12 — the request says Late + [Fact] + public async Task CarriesFreshnessLate() + { + var tts = new FakeTtsSegmentSource(); + var chain = new OrchestratorBuilder().WithTts(tts).Build(); + chain.Queue.Enqueue(SpeechDeferralKind.TimeDate, "test: time due", chain.Time.GetUtcNow() - TimeSpan.FromSeconds(10)); + + // The fixed 90s honesty threshold (Orchestrator.TimeDateHonestyThreshold) reads lateness as + // (now + queuedAhead) - due — 10s of raw staleness alone never crosses it, so this fact's own + // queued tail (85s) is what pushes total lateness (95s) past the threshold into Late. + await PullAsync(chain.Orchestrator, new PlayoutContext([], QueuedAheadMs: 85_000)); + + var request = Assert.Single(tts.Requests, r => r.Kind == SegmentKind.TimeDate); + Assert.Equal(TimeAnnouncementFreshness.Late, request.TimeDateFreshness); + } + } + + public sealed class ScenarioADueContextSegment + { + // Given: a Context deferral with provider key weather + /// AC13 — the buffered item names the provider + [Fact] + public async Task CarriesTheProviderKey() + { + var tts = new FakeTtsSegmentSource(); + var chain = new OrchestratorBuilder().WithTts(tts).Build(); + var content = new ContextSegmentFacts("Sunny and mild, 70F.", chain.Time.GetUtcNow().AddMinutes(10)); + chain.Queue.Enqueue(SpeechDeferralKind.Context, "test: weather due", discriminator: "weather", context: content); + + await PullAsync(chain.Orchestrator); + + var request = Assert.Single(tts.Requests, r => r.Kind == SegmentKind.ContextSegment); + Assert.Equal("Sunny and mild, 70F.", request.ContextFacts); + } + } + + public sealed class ScenarioLeadInOn + { + // Given: cadence lead-in on + /// AC14 — lead-in sits last + [Fact] + public async Task IsTheLastSpokenItemBeforeTheTrack() + { + var cadence = Cadence(leadIn: true, backAnnounce: false, stationIdEvery: 1); + var chain = new OrchestratorBuilder().WithCadence(cadence).Build(); + + await PullManyAsync(chain.Orchestrator, 2); // unit 0 — lead-in, track (the cadence has not hit yet) + var items = await PullManyAsync(chain.Orchestrator, 3); // unit 1 — station id, lead-in, track + + Assert.Equal(SegmentKind.StationId, items[0].SegmentKind); + Assert.Equal(SegmentKind.LeadIn, items[1].SegmentKind); + Assert.Null(items[2].SegmentKind); + } + } + + public sealed class ScenarioACeremonyOnlyUnit : IAsyncLifetime + { + readonly FakeTtsSegmentSource tts = new(); + FakeMediaCatalog? catalog; + MediaItem? item; + + // Given: a SignOff below the music floor and a vendable crosstalk + public async Task InitializeAsync() + { + var cadence = Cadence(leadIn: true, backAnnounce: false, stationIdEvery: 0); + var assetPath = Path.Combine(Path.GetTempPath(), "genwave-story453-ac15-crosstalk.wav"); + var (chain, planner, resolvedCatalog) = BuildCrosstalkChain(cadence, tts); + catalog = resolvedCatalog; + StockReadyExchange(planner, assetPath); + + chain.Queue.Enqueue(SpeechDeferralKind.SignOff, "test: ceremony-only decline", chain.Time.GetUtcNow() + TimeSpan.FromSeconds(30), Handoff); + + item = await PullAsync(chain.Orchestrator, new PlayoutContext([], QueuedAheadMs: 200_000)); + } + + public Task DisposeAsync() => Task.CompletedTask; + + /// AC15 — it buffers no crosstalk + [Fact] + public void BuffersNoCrosstalk() + { + Assert.NotNull(item); + Assert.NotEqual(SegmentKind.Crosstalk, item.SegmentKind); + } + + /// AC15 — it buffers no lead-in. Proved on the render seam, not the drained item (F1, + /// round 2): the ceremony-only branch returns the drained SignOff before Orchestrator ever + /// reaches its lead-in slot (src/GenWave.Orchestration/Orchestrator.cs:1589), so the drained + /// item is never LeadIn regardless of whether a lead-in was rendered underneath it — only the + /// tts fake's own request log can tell the two apart. + [Fact] + public void BuffersNoLeadIn() => Assert.DoesNotContain(tts.Requests, r => r.Kind == SegmentKind.LeadIn); + + /// AC15 — it buffers no track + [Fact] + public void BuffersNoTrack() + { + Assert.NotNull(catalog); + Assert.Empty(catalog.RotationCallScopes); + } + } + + public sealed class ScenarioACeremonyOnlyUnitWithAPreviousTrack + { + // Given: the same unit with back-announce on (Dean: stays) + /// AC16 — the outgoing DJ signs off the last track + [Fact] + public async Task BuffersABackAnnounceBeforeTheSignOff() + { + var cadence = Cadence(leadIn: false, backAnnounce: true, stationIdEvery: 0); + var chain = new OrchestratorBuilder().WithCadence(cadence).WithLookahead(TimeSpan.FromMinutes(10)).Build(); + + await PullAsync(chain.Orchestrator); // primes the previous track + + chain.Queue.Enqueue(SpeechDeferralKind.SignOff, "test: ceremony-only decline", chain.Time.GetUtcNow() + TimeSpan.FromSeconds(30), Handoff); + var items = await PullManyAsync(chain.Orchestrator, 2, new PlayoutContext([], QueuedAheadMs: 200_000)); + + Assert.Equal(SegmentKind.BackAnnounce, items[0].SegmentKind); + Assert.Equal(SegmentKind.SignOff, items[1].SegmentKind); + } + } + + public sealed class ScenarioAStraddlingTrackWithASignOffPending + { + // Given: the queued tail alone crosses the boundary (SPEC F186.2(a)) — same below-floor SignOff + // shape as AC15/AC16, but with a SignOn also pending so HoldSignOnPastQueuedTail has a live + // target. This is TryServeCeremonyOnlyUnitAsync's own decline-Straddle rung, not + // GetNextAsync's track-selection straddle branch (that branch never sets NotBefore — see + // CaptureCrossingTrackForHeldSignOn's own remarks) — no catalog/crossing-track wiring is + // needed since next is always null on this path. + /// AC17 — NotBefore at or after now plus the tail + [Fact] + public async Task HoldsTheSignOnPastTheQueuedTail() + { + var chain = new OrchestratorBuilder().WithLookahead(TimeSpan.FromMinutes(10)).Build(); + + var signOffDue = chain.Time.GetUtcNow() + TimeSpan.FromSeconds(30); + var signOnDue = signOffDue + TimeSpan.FromSeconds(15); + chain.Queue.Enqueue(SpeechDeferralKind.SignOff, "test: straddle decline", signOffDue, Handoff); + chain.Queue.Enqueue(SpeechDeferralKind.SignOn, "test: straddle decline", signOnDue, Handoff); + + var pullInstant = chain.Time.GetUtcNow(); + await PullAsync(chain.Orchestrator, new PlayoutContext([], QueuedAheadMs: 200_000)); // the declined ceremony's sign-off piece + + var held = chain.Queue.Peek(SpeechDeferralKind.SignOn); + Assert.NotNull(held); + Assert.Equal(pullInstant + TimeSpan.FromSeconds(200), held.NotBefore); + } + } + + // SAD PATH — segregated. AC18–AC23 are T518's own cells; AC24 is manual (T519). + public sealed class ScenarioAnExpiredTimeDate + { + // Given: TimeDate due 120 s ago with a 90 s budget + /// AC18 — + [Fact(Skip = PendingDrops)] public void BuffersNoTimeDate() => throw new NotImplementedException(PendingDrops); + /// AC18 — + [Fact(Skip = PendingDrops)] public void LogsOneExpiryLine() => throw new NotImplementedException(PendingDrops); + } + + public sealed class ScenarioAnAdVendThatThrows + { + // Given: the vend throws + /// AC19 — + [Fact(Skip = PendingDrops)] public void AssemblesTheBreakWithoutAnAd() => throw new NotImplementedException(PendingDrops); + /// AC19 — + [Fact(Skip = PendingDrops)] public void LogsOneWarnNamingTheVend() => throw new NotImplementedException(PendingDrops); + } + + public sealed class ScenarioANullLeadInRender + { + // Given: the lead-in render returns null + /// AC20 — the slot drops alone + [Fact(Skip = PendingDrops)] public void KeepsEveryOtherItemInPosition() => throw new NotImplementedException(PendingDrops); + } + + public sealed class ScenarioAnOverBudgetBackAnnounce + { + // Given: the back-announce render exceeds the budget + /// AC21 — the slot drops alone + [Fact(Skip = PendingDrops)] public void KeepsEveryOtherItemInPosition() => throw new NotImplementedException(PendingDrops); + } + + public sealed class ScenarioANullSignOffRender + { + // Given: the SignOff render returns null + /// AC22 — + [Fact(Skip = PendingDrops)] public void PublishesOneHandoffPieceDropped() => throw new NotImplementedException(PendingDrops); + } + + public sealed class ScenarioANullAnnouncementRender + { + // Given: an announcement render returns null + /// AC23 — the claim is not released + [Fact(Skip = PendingDrops)] public void KeepsTheAnnouncementClaimed() => throw new NotImplementedException(PendingDrops); + } + + public sealed class ScenarioTheFollowUpsOnPrTwo + { + // Given: PR-2's body (manual) + /// AC24 — announcements on a ceremony-only unit; ad in a straddle break + [Fact(Skip = Manual)] public void TwoIssuesExistInProjectThree() => throw new NotImplementedException(Manual); + } +} From 4704fbc635e2d97b5bf9d685729de404ebe2be50 Mon Sep 17 00:00:00 2001 From: GenWave Radio Date: Thu, 17 Sep 2026 20:23:11 -0600 Subject: [PATCH 2/3] =?UTF-8?q?test(orchestration):=20T518=20break-contrac?= =?UTF-8?q?t=20drop=20facts=20AC18=E2=80=93AC23=20pinned=20through=20the?= =?UTF-8?q?=20builder=20(gh-#401)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STORY-453 AC18–AC23 (SPEC F186.3) go green against the unsplit Orchestrator: an expired time/date is not buffered and logs one expiry line; a throwing ad vend leaves the break without an ad and one WARN naming the vend; a null lead-in render and an over-budget back-announce render each drop their slot alone with every other item in position; a null SignOff render publishes exactly one HandoffPieceDropped; a null announcement render keeps the claim. Eight facts in a third partial file; the over-budget race is decided by the fake clock alone. AC24 stays manual. No production change. --- .../Specs/Story453_BreakContract.cs | 9 +- .../Specs/Story453_BreakContractDrops.cs | 215 ++++++++++++++++++ .../Specs/Story453_BreakContractSlots.cs | 66 +----- 3 files changed, 230 insertions(+), 60 deletions(-) create mode 100644 tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContractDrops.cs diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContract.cs b/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContract.cs index 02372f73..3edc4e5a 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContract.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContract.cs @@ -1,7 +1,7 @@ // STORY-453 — The break contract has one fact per cell (gh-#401 · SPEC F186 · PLAN T517–T519) // -// BDD specification — xUnit. One fact per cell of the SPEC F186 table, all against the unsplit code. Rows AC1–AC17 (T517, GREEN -// below), drops AC18–AC23 (T518, still pending). AC24 (the two follow-up issues) is a manual check on PR-2's body. F186.4: back-announce on a +// BDD specification — xUnit. One fact per cell of the SPEC F186 table, all against the unsplit code. Rows AC1–AC17 (T517) and +// drops AC18–AC23 (T518) are GREEN. AC24 (the two follow-up issues) is a manual check on PR-2's body. F186.4: back-announce on a // ceremony-only unit STAYS (Dean, 2026-09-17); the other two cells are scripted as-built and filed as follow-ups. // // Every AC1–AC17 scenario is arranged through OrchestratorBuilder/OrchestratorChain exactly as @@ -18,8 +18,9 @@ namespace GenWave.Orchestration.Tests.Specs; -// This class spans two files (~300-line budget, csharp-best-practices): AC1–AC9 (this file) and -// AC10–AC24 (Story453_BreakContractSlots.cs) share the same helpers/consts declared below. +// This class spans three files (~300-line budget, csharp-best-practices): AC1–AC9 (this file), +// AC10–AC17 + AC24 (Story453_BreakContractSlots.cs) and AC18–AC23 (Story453_BreakContractDrops.cs) +// share the same helpers declared below. public static partial class FeatureBreakContract { const long HostPersonaId = 10; diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContractDrops.cs b/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContractDrops.cs new file mode 100644 index 00000000..8b451afe --- /dev/null +++ b/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContractDrops.cs @@ -0,0 +1,215 @@ +// STORY-453 — The break contract has one fact per cell (gh-#401 · SPEC F186.3 · PLAN T518) +// +// AC18–AC23 — the drop cells, the third of three files FeatureBreakContract spans (AC1–AC9 live in +// Story453_BreakContract.cs, AC10–AC17/AC24 in Story453_BreakContractSlots.cs; this file exists +// purely for the ~300-line budget, csharp-best-practices). Shares those files' consts/helpers +// (PullAsync, PullManyAsync, Cadence, BuildCrosstalkChain, StockReadyExchange, Handoff, ShowSlug) +// via the partial class. Every fact below was un-skipped from the SAD PATH block +// Story453_BreakContractSlots.cs used to carry (PendingDrops, now deleted there — nothing left +// referencing it). +// +// Mirrors AC1–AC17's own arrange discipline: each scenario builds only what its own Given names, +// through OrchestratorBuilder/OrchestratorChain against the real, unsplit Orchestrator. AC20/AC21 +// each arrange the SAME full-break slot order SPEC F186 names (BackAnnounce, Announcement, +// StationId, LeadIn, Track) and assert the buffered kinds equal that order with the dropped kind +// removed — one Assert.Equal on two lists, never a second run of the same unit to diff against. + +using GenWave.Core.Domain; +using GenWave.Orchestration.Tests.Fakes; +using Microsoft.Extensions.Time.Testing; + +namespace GenWave.Orchestration.Tests.Specs; + +public static partial class FeatureBreakContract +{ + /// + /// AC21's own race: advances in steps + /// (the Story243/Story442 PullUnitsRacingTheRenderBudgetAsync idiom, drop case) so a render + /// whose delay exceeds the budget loses deterministically, then keeps yielding until the rest of + /// the unit's own renders (synchronous fakes, no clock dependency) finish assembling — no real + /// wall-clock wait either way. + /// + static async Task PullOverBudgetAsync(Orchestrator orchestrator, FakeTimeProvider clock, TimeSpan renderBudget) + { + var pull = orchestrator.GetNextAsync(new PlayoutContext([]), CancellationToken.None); + for (var round = 0; round < 8 && !pull.IsCompleted; round++) + { + clock.Advance(renderBudget); + for (var spin = 0; spin < 50 && !pull.IsCompleted; spin++) + await Task.Yield(); + } + + var item = await pull; + Assert.NotNull(item); + return item; + } + + public sealed class ScenarioAnExpiredTimeDate : IAsyncLifetime + { + readonly FakeTtsSegmentSource tts = new(); + readonly CapturingLogger logger = new(); + + // Given: TimeDate due 120 s ago with a 90 s budget + public async Task InitializeAsync() + { + var imaging = new FakeStationImagingSettingsProvider { Current = new StationImagingSettings(false, false, 90) }; + var chain = new OrchestratorBuilder().WithTts(tts).WithImagingSettings(imaging).WithLogger(logger).Build(); + chain.Queue.Enqueue(SpeechDeferralKind.TimeDate, "test: expired time due", chain.Time.GetUtcNow() - TimeSpan.FromSeconds(120)); + + await PullAsync(chain.Orchestrator); + } + + public Task DisposeAsync() => Task.CompletedTask; + + /// AC18 — no TimeDate render is ever requested + [Fact] + public void BuffersNoTimeDate() => Assert.DoesNotContain(tts.Requests, r => r.Kind == SegmentKind.TimeDate); + + /// AC18 — the expiry line names the drop + [Fact] + public void LogsOneExpiryLine() => Assert.Single(logger.Warnings, w => w.Contains("dropped undrained", StringComparison.Ordinal)); + } + + public sealed class ScenarioAnAdVendThatThrows : IAsyncLifetime + { + readonly CapturingLogger logger = new(); + List items = []; + + // Given: the vend throws + public async Task InitializeAsync() + { + var adCadence = new FakeAdCadenceProvider(1); + var adSpotVend = new FakeAdSpotVend { ThrowOnNextCall = new InvalidOperationException("Simulated ad vend fault (test double).") }; + var chain = new OrchestratorBuilder().WithAdCadence(adCadence).WithAdSpotVend(adSpotVend).WithLogger(logger).Build(); + + await PullAsync(chain.Orchestrator); // unit 0 — the ad cadence has not hit yet + items = await PullManyAsync(chain.Orchestrator, 1); // unit 1 — cadence hits, the vend throws + } + + public Task DisposeAsync() => Task.CompletedTask; + + /// AC19 — the break assembles without an ad + [Fact] + public void AssemblesTheBreakWithoutAnAd() => Assert.DoesNotContain(items, i => i.SegmentKind == SegmentKind.Ad); + + /// AC19 — one WARN names the vend + [Fact] + public void LogsOneWarnNamingTheVend() => Assert.Single(logger.Warnings, w => w.Contains("Ad spot vend threw", StringComparison.Ordinal)); + } + + public sealed class ScenarioANullLeadInRender : IAsyncLifetime + { + List items = []; + + // Given: the lead-in render returns null, inside a full break (back-announce, announcement, + // station id, lead-in, track — the SPEC F186 slot order) + public async Task InitializeAsync() + { + var cadence = Cadence(leadIn: true, backAnnounce: true, stationIdEvery: 1); + var source = new FakeAnnouncementSource(); + var renderer = new FakeVerbatimSegmentRenderer(); + var tts = new FakeTtsSegmentSource { ShouldReturnNull = r => r.Kind == SegmentKind.LeadIn }; + var chain = new OrchestratorBuilder() + .WithCadence(cadence).WithTts(tts).WithAnnouncementSource(source).WithAnnouncementRenderer(renderer).Build(); + + await PullAsync(chain.Orchestrator); // unit 0 — primes the previous track, cadence not hit yet + + source.Pending.Enqueue(new AnnouncementItem(1, "One", true, null)); + items = await PullManyAsync(chain.Orchestrator, 4); // unit 1 — the full break, lead-in drops alone + } + + public Task DisposeAsync() => Task.CompletedTask; + + /// AC20 — the slot drops alone + [Fact] + public void KeepsEveryOtherItemInPosition() + { + var fullBreak = new List { SegmentKind.BackAnnounce, SegmentKind.Announcement, SegmentKind.StationId, SegmentKind.LeadIn, null }; + Assert.Equal(fullBreak.Where(kind => kind != SegmentKind.LeadIn), items.Select(i => i.SegmentKind)); + } + } + + public sealed class ScenarioAnOverBudgetBackAnnounce : IAsyncLifetime + { + List items = []; + + // Given: the back-announce render exceeds the budget, inside a break of back-announce + + // announcement + track (no station id/lead-in — isolates the race to the ONE tts render this + // unit makes) + public async Task InitializeAsync() + { + var clock = new FakeTimeProvider(new DateTimeOffset(2026, 3, 2, 10, 0, 0, TimeSpan.Zero)); + var cadence = Cadence(leadIn: false, backAnnounce: true, stationIdEvery: 0); + var source = new FakeAnnouncementSource(); + var renderer = new FakeVerbatimSegmentRenderer(); + var tts = new FakeTtsSegmentSource { TimeProvider = clock }; + var renderBudget = TimeSpan.FromSeconds(5); + var chain = new OrchestratorBuilder() + .WithTime(clock).WithCadence(cadence).WithTts(tts) + .WithAnnouncementSource(source).WithAnnouncementRenderer(renderer) + .WithRenderBudget(renderBudget) + .Build(); + + await PullAsync(chain.Orchestrator); // unit 0 — primes the previous track + + source.Pending.Enqueue(new AnnouncementItem(1, "One", true, null)); + tts.RenderDelay = renderBudget + TimeSpan.FromSeconds(5); // exceeds the budget + var first = await PullOverBudgetAsync(chain.Orchestrator, clock, renderBudget); // unit 1 — the back-announce races the budget + items = [first, .. await PullManyAsync(chain.Orchestrator, 1)]; + } + + public Task DisposeAsync() => Task.CompletedTask; + + /// AC21 — the slot drops alone + [Fact] + public void KeepsEveryOtherItemInPosition() + { + var fullBreak = new List { SegmentKind.BackAnnounce, SegmentKind.Announcement, null }; + Assert.Equal(fullBreak.Where(kind => kind != SegmentKind.BackAnnounce), items.Select(i => i.SegmentKind)); + } + } + + public sealed class ScenarioANullSignOffRender : IAsyncLifetime + { + readonly CapturingStationEventSink events = new(); + + // Given: the SignOff render returns null + public async Task InitializeAsync() + { + var tts = new FakeTtsSegmentSource { ShouldReturnNull = r => r.Kind == SegmentKind.SignOff }; + var chain = new OrchestratorBuilder().WithTts(tts).WithEvents(events).Build(); + chain.Queue.Enqueue(SpeechDeferralKind.SignOff, "test: null render signoff", chain.Time.GetUtcNow(), Handoff); + + await PullAsync(chain.Orchestrator); + } + + public Task DisposeAsync() => Task.CompletedTask; + + /// AC22 — one HandoffPieceDropped event is published + [Fact] + public void PublishesOneHandoffPieceDropped() => Assert.Single(events.Events, evt => evt.GetType().Name == "HandoffPieceDropped"); + } + + public sealed class ScenarioANullAnnouncementRender : IAsyncLifetime + { + readonly FakeAnnouncementSource source = new(); + + // Given: an announcement render returns null + public async Task InitializeAsync() + { + source.Pending.Enqueue(new AnnouncementItem(9001, "Message", true, null)); + var renderer = new FakeVerbatimSegmentRenderer { AlwaysReturnNull = true }; + var chain = new OrchestratorBuilder().WithAnnouncementSource(source).WithAnnouncementRenderer(renderer).Build(); + + await PullAsync(chain.Orchestrator); + } + + public Task DisposeAsync() => Task.CompletedTask; + + /// AC23 — the claim is not released. IAnnouncementSource has no release method, so an + /// empty Pending (dequeued, never put back) is the only observable "still claimed" signal; a split + /// that adds a release path must extend the fake and this fact together. + [Fact] + public void KeepsTheAnnouncementClaimed() => Assert.Empty(source.Pending); + } +} diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContractSlots.cs b/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContractSlots.cs index fc7937c1..3373b280 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContractSlots.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContractSlots.cs @@ -1,11 +1,13 @@ // STORY-453 — The break contract has one fact per cell (gh-#401 · SPEC F186 · PLAN T517–T519) // -// AC10–AC17 (T517, GREEN below) and the drops AC18–AC23/AC24 — the second half of -// FeatureBreakContract, split from Story453_BreakContract.cs purely for the ~300-line budget +// AC10–AC17 (T517, GREEN below) and AC24 (manual, T519) — the second of three files +// FeatureBreakContract spans, split from Story453_BreakContract.cs purely for the ~300-line budget // (csharp-best-practices). Shares that file's consts/helpers (PullAsync, PullManyAsync, -// BuildCrosstalkChain, StockReadyExchange, Handoff, ShowSlug) via the partial class. PendingDrops -// and Manual are declared in THIS file instead — the Skip-prefix-law scanner resolves a Skip = -// reference within its own file only, so a const backing a Skip= here must live here too. +// BuildCrosstalkChain, StockReadyExchange, Handoff, ShowSlug) via the partial class. The drop cells +// (AC18–AC23, T518) live in Story453_BreakContractDrops.cs instead, un-skipped from the SAD PATH +// block this file used to carry. Manual is declared in THIS file — the Skip-prefix-law scanner +// resolves a Skip = reference within its own file only, so a const backing a Skip= here +// must live here too. using GenWave.Core.Domain; @@ -14,10 +16,9 @@ namespace GenWave.Orchestration.Tests.Specs; public static partial class FeatureBreakContract { // Skip-prefix-law scanner (GenWave.Architecture.Tests) resolves a Skip = reference within - // its own file's syntax tree only — it does not follow a partial class across files. These two - // consts are declared here, not in Story453_BreakContract.cs, because every [Fact(Skip = ...)] - // usage of them lives in THIS file. - const string PendingDrops = "pending: T518 — the drop cells (STORY-453)"; + // its own file's syntax tree only — it does not follow a partial class across files. Declared + // here, not in Story453_BreakContract.cs, because its one [Fact(Skip = ...)] usage lives in THIS + // file (AC24, below). const string Manual = "manual: two follow-up issues named in SPEC F186.4 — review evidence on PR-2 (STORY-453)"; public sealed class ScenarioAPooledStationId : IAsyncLifetime @@ -224,53 +225,6 @@ public async Task HoldsTheSignOnPastTheQueuedTail() } } - // SAD PATH — segregated. AC18–AC23 are T518's own cells; AC24 is manual (T519). - public sealed class ScenarioAnExpiredTimeDate - { - // Given: TimeDate due 120 s ago with a 90 s budget - /// AC18 — - [Fact(Skip = PendingDrops)] public void BuffersNoTimeDate() => throw new NotImplementedException(PendingDrops); - /// AC18 — - [Fact(Skip = PendingDrops)] public void LogsOneExpiryLine() => throw new NotImplementedException(PendingDrops); - } - - public sealed class ScenarioAnAdVendThatThrows - { - // Given: the vend throws - /// AC19 — - [Fact(Skip = PendingDrops)] public void AssemblesTheBreakWithoutAnAd() => throw new NotImplementedException(PendingDrops); - /// AC19 — - [Fact(Skip = PendingDrops)] public void LogsOneWarnNamingTheVend() => throw new NotImplementedException(PendingDrops); - } - - public sealed class ScenarioANullLeadInRender - { - // Given: the lead-in render returns null - /// AC20 — the slot drops alone - [Fact(Skip = PendingDrops)] public void KeepsEveryOtherItemInPosition() => throw new NotImplementedException(PendingDrops); - } - - public sealed class ScenarioAnOverBudgetBackAnnounce - { - // Given: the back-announce render exceeds the budget - /// AC21 — the slot drops alone - [Fact(Skip = PendingDrops)] public void KeepsEveryOtherItemInPosition() => throw new NotImplementedException(PendingDrops); - } - - public sealed class ScenarioANullSignOffRender - { - // Given: the SignOff render returns null - /// AC22 — - [Fact(Skip = PendingDrops)] public void PublishesOneHandoffPieceDropped() => throw new NotImplementedException(PendingDrops); - } - - public sealed class ScenarioANullAnnouncementRender - { - // Given: an announcement render returns null - /// AC23 — the claim is not released - [Fact(Skip = PendingDrops)] public void KeepsTheAnnouncementClaimed() => throw new NotImplementedException(PendingDrops); - } - public sealed class ScenarioTheFollowUpsOnPrTwo { // Given: PR-2's body (manual) From f4974700a1098a89e2550636b8999b8af1c0ff3d Mon Sep 17 00:00:00 2001 From: GenWave Radio Date: Thu, 17 Sep 2026 20:24:06 -0600 Subject: [PATCH 3/3] test(orchestration): T519 AC24 names the two follow-up issues gh-#806 and gh-#807 (gh-#401) STORY-453 AC24 is the manual cell: the two product follow-ups ruled in SPEC F186.4 (announcements claimed into a ceremony-only unit, an ad drained into a straddle break) are filed in project #3 and named on the skipped fact so the evidence is one grep away. --- .../Specs/Story453_BreakContractSlots.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContractSlots.cs b/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContractSlots.cs index 3373b280..f8b64eb5 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContractSlots.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContractSlots.cs @@ -228,7 +228,7 @@ public async Task HoldsTheSignOnPastTheQueuedTail() public sealed class ScenarioTheFollowUpsOnPrTwo { // Given: PR-2's body (manual) - /// AC24 — announcements on a ceremony-only unit; ad in a straddle break + /// AC24 — announcements on a ceremony-only unit (gh-#806); ad in a straddle break (gh-#807) [Fact(Skip = Manual)] public void TwoIssuesExistInProjectThree() => throw new NotImplementedException(Manual); } }