diff --git a/Directory.Packages.props b/Directory.Packages.props index 7d382d41..aa4cfe84 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,6 +9,7 @@ + diff --git a/GenWave.sln b/GenWave.sln index 9b377d0b..d93f92bb 100644 --- a/GenWave.sln +++ b/GenWave.sln @@ -57,6 +57,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExamplePlugin", "examples\g EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenWave.Ads", "src\GenWave.Ads\GenWave.Ads.csproj", "{60CD65BA-8A92-41D5-BB1A-0D39AA42184B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenWave.TestSupport", "tests\GenWave.TestSupport\GenWave.TestSupport.csproj", "{9F21C208-B1B7-4203-8034-79493F06BA1C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -331,6 +333,18 @@ Global {60CD65BA-8A92-41D5-BB1A-0D39AA42184B}.Release|x64.Build.0 = Release|Any CPU {60CD65BA-8A92-41D5-BB1A-0D39AA42184B}.Release|x86.ActiveCfg = Release|Any CPU {60CD65BA-8A92-41D5-BB1A-0D39AA42184B}.Release|x86.Build.0 = Release|Any CPU + {9F21C208-B1B7-4203-8034-79493F06BA1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9F21C208-B1B7-4203-8034-79493F06BA1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F21C208-B1B7-4203-8034-79493F06BA1C}.Debug|x64.ActiveCfg = Debug|Any CPU + {9F21C208-B1B7-4203-8034-79493F06BA1C}.Debug|x64.Build.0 = Debug|Any CPU + {9F21C208-B1B7-4203-8034-79493F06BA1C}.Debug|x86.ActiveCfg = Debug|Any CPU + {9F21C208-B1B7-4203-8034-79493F06BA1C}.Debug|x86.Build.0 = Debug|Any CPU + {9F21C208-B1B7-4203-8034-79493F06BA1C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9F21C208-B1B7-4203-8034-79493F06BA1C}.Release|Any CPU.Build.0 = Release|Any CPU + {9F21C208-B1B7-4203-8034-79493F06BA1C}.Release|x64.ActiveCfg = Release|Any CPU + {9F21C208-B1B7-4203-8034-79493F06BA1C}.Release|x64.Build.0 = Release|Any CPU + {9F21C208-B1B7-4203-8034-79493F06BA1C}.Release|x86.ActiveCfg = Release|Any CPU + {9F21C208-B1B7-4203-8034-79493F06BA1C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -359,5 +373,6 @@ Global {59CC0FFE-92F4-E0A7-1AC9-3F1F42E6F62C} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} {E9101478-ACF8-4253-96B7-2D3070BC3F23} = {59CC0FFE-92F4-E0A7-1AC9-3F1F42E6F62C} {60CD65BA-8A92-41D5-BB1A-0D39AA42184B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {9F21C208-B1B7-4203-8034-79493F06BA1C} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/src/GenWave.Orchestration/OrchestrationServiceCollectionExtensions.cs b/src/GenWave.Orchestration/OrchestrationServiceCollectionExtensions.cs index 1c6b92d1..60326a61 100644 --- a/src/GenWave.Orchestration/OrchestrationServiceCollectionExtensions.cs +++ b/src/GenWave.Orchestration/OrchestrationServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; using GenWave.Core.Abstractions; namespace GenWave.Orchestration; @@ -118,6 +119,44 @@ public static IServiceCollection AddGenWaveOrchestration(this IServiceCollection // constructor parameter's own remarks). services.AddSingleton(); - return services.AddSingleton(); + // The production construction site (SPEC F184.3/F184.5, STORY-451, T514). The only other + // `new Orchestrator(` is GenWave.TestSupport's OrchestratorBuilder; Story451_ConstructionPins + // pins the pair. A factory, not AddSingleton(): every seam is + // read INSIDE the lambda, i.e. at first resolve of INextItemProvider, never at this call. + // That is load-bearing — Program.cs registers IPersonaPickProvider, IRequestFulfillmentSource, + // IStationEventSink, IContextSettingsProvider and IAdSpotVend AFTER AddGenWaveOrchestration so + // they beat the TryAdd defaults above; a resolve-time read sees the last registration, exactly + // as constructor injection did. Optional seams with a NoOp type coalesce to it; the other ten + // pass GetService's null through to the Orchestrator's own null handling, unchanged. + services.AddSingleton(sp => new Orchestrator( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + scheduleResolver: sp.GetService(), + personaStore: sp.GetService(), + events: sp.GetService() ?? NoOpStationEventSink.Instance, + stationClock: sp.GetService(), + patterEstimator: sp.GetService(), + contextSettings: sp.GetService() ?? NoOpContextSettingsProvider.Instance, + catalog: sp.GetService(), + imagingSettings: sp.GetService() ?? NoOpStationImagingSettingsProvider.Instance, + crosstalkPlanner: sp.GetService(), + announcementSource: sp.GetService(), + announcementRenderer: sp.GetService(), + voiceLister: sp.GetService(), + announcementCopyWriter: sp.GetService(), + adCadenceProvider: sp.GetService() ?? NoOpAdCadenceProvider.Instance, + adSpotVend: sp.GetService() ?? NoOpAdSpotVend.Instance)); + + return services; } } diff --git a/tests/GenWave.Architecture.Tests/GenWave.Architecture.Tests.csproj b/tests/GenWave.Architecture.Tests/GenWave.Architecture.Tests.csproj index 56b2023e..ef4b3762 100644 --- a/tests/GenWave.Architecture.Tests/GenWave.Architecture.Tests.csproj +++ b/tests/GenWave.Architecture.Tests/GenWave.Architecture.Tests.csproj @@ -76,6 +76,7 @@ + diff --git a/tests/GenWave.Architecture.Tests/Specs/Story451_ConstructionPins.cs b/tests/GenWave.Architecture.Tests/Specs/Story451_ConstructionPins.cs new file mode 100644 index 00000000..44ea65d0 --- /dev/null +++ b/tests/GenWave.Architecture.Tests/Specs/Story451_ConstructionPins.cs @@ -0,0 +1,106 @@ +// STORY-451 — One construction path — the pins (gh-#401 · SPEC F184.3–F184.5 · PLAN T510, T513, T514) +// +// BDD specification — xUnit. AC4 scans src/ and tests/ for the Orchestrator's own construction call; +// AC5 reads Host.Tests' csproj and file list; AC8 reflects the TestSupport assembly for [Fact] methods. +// +// GREEN at T514: T513 already un-skipped ScenarioHostTestsAfterTheMove's three facts. +// ScenarioTheTextScanForConstruction's three facts land here, now that AddGenWaveOrchestration's own +// factory registration is the second (and last) construction site alongside OrchestratorBuilder. + +using System.Reflection; +using System.Xml.Linq; +using GenWave.Architecture.Tests.Support; + +namespace GenWave.Architecture.Tests.Specs; + +public static class FeatureConstructionPins +{ + // --------------------------------------------------------------------- + // HAPPY PATH + // --------------------------------------------------------------------- + + public sealed class ScenarioTheTextScanForConstruction + { + // Given: every .cs under src/ and tests/ (bin/obj excluded) scanned for the literal below — + // split, mirroring Story442_FakeClockPins.cs's own hand-rolled-clock-declaration split, so + // this file's own source text never spells the literal out contiguously and self-matches + // the scan (and never spells THAT pin's own literal out contiguously either). + const string ConstructorCallLiteral = "new " + "Orchestrator("; + + static readonly string[] hits = new[] { "src", "tests" } + .SelectMany(dir => Directory.EnumerateFiles( + Path.Combine(SolutionLocator.Root(), dir), "*.cs", SearchOption.AllDirectories)) + .Where(path => !path.Split('/', '\\').Any(segment => segment is "bin" or "obj")) + .Where(path => File.ReadAllText(path).Contains(ConstructorCallLiteral, StringComparison.Ordinal)) + .ToArray(); + + /// AC4 — the builder is one site + [Fact] + public void HitsOrchestratorBuilder() => + Assert.Contains(hits, path => path.EndsWith("OrchestratorBuilder.cs", StringComparison.Ordinal)); + + /// AC4 — AddOrchestration is the other site + [Fact] + public void HitsTheServiceCollectionExtensions() => + Assert.Contains(hits, path => path.EndsWith("OrchestrationServiceCollectionExtensions.cs", StringComparison.Ordinal)); + + /// AC4 — exactly two files hit + [Fact] + public void HitsNothingElse() => Assert.Equal(2, hits.Length); + } + + public sealed class ScenarioHostTestsAfterTheMove + { + // Given: GenWave.Host.Tests.csproj and its file tree + + static readonly string HostTestsDir = + Path.Combine(SolutionLocator.Root(), "tests", "GenWave.Host.Tests"); + + /// AC5 — the ProjectReference is present + [Fact] + public void ReferencesTestSupport() + { + var csprojPath = Path.Combine(HostTestsDir, "GenWave.Host.Tests.csproj"); + var csproj = XDocument.Load(csprojPath); + + var referencesTestSupport = csproj.Descendants("ProjectReference") + .Select(reference => reference.Attribute("Include")?.Value) + .Any(include => include is not null + && include.EndsWith("GenWave.TestSupport.csproj", StringComparison.Ordinal)); + + Assert.True(referencesTestSupport, $"{csprojPath} carries no ProjectReference to GenWave.TestSupport"); + } + + /// AC5 — the duplicate fake is deleted + [Fact] + public void NoLongerCarriesFakeRenderBudgetProvider() => + Assert.Empty(Directory.EnumerateFiles(HostTestsDir, "FakeRenderBudgetProvider.cs", SearchOption.AllDirectories)); + + /// AC5 — the duplicate fake is deleted + [Fact] + public void NoLongerCarriesFakeBoundaryBiasProvider() => + Assert.Empty(Directory.EnumerateFiles(HostTestsDir, "FakeBoundaryBiasProvider.cs", SearchOption.AllDirectories)); + } + + public sealed class ScenarioTheSupportAssembly + { + // Given: GenWave.TestSupport reflected + + /// AC8 — the project carries fakes, not facts + [Fact] + public void HasNoFactMethods() + { + var assembly = typeof(GenWave.TestSupport.AssemblyMarker).Assembly; + + var factMethods = assembly.GetTypes() + .SelectMany(type => type.GetMethods( + BindingFlags.Public | BindingFlags.NonPublic + | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)) + .Where(method => method.GetCustomAttributes(inherit: true) + .Any(attribute => attribute is Xunit.FactAttribute)) + .ToList(); + + Assert.Empty(factMethods); + } + } +} diff --git a/tests/GenWave.Architecture.Tests/Specs/Story456_SeamIndex.cs b/tests/GenWave.Architecture.Tests/Specs/Story456_SeamIndex.cs new file mode 100644 index 00000000..d33edb4a --- /dev/null +++ b/tests/GenWave.Architecture.Tests/Specs/Story456_SeamIndex.cs @@ -0,0 +1,34 @@ +// STORY-456 — The speaker travels with the plan — the seam index (gh-#772 · SPEC F189.7 · PLAN T528) +// +// BDD specification — xUnit. AC11 runs tools/SeamIndexGenerator in check mode and reads the committed SEAMS.md. +// +// RED at plan time: every fact is [Fact(Skip = Pending)] with a loud body — remove the Skip only in the +// task that makes it green. The builder comments name the arrange each scenario needs. + +namespace GenWave.Architecture.Tests.Specs; + +public static class FeatureSeamIndexListsTheSpeakerSource +{ + const string Pending = "pending: T528 — SEAMS.md regenerated with ISpeakerSnapshotSource and IPersonaCardByIdSource (STORY-456)"; + + // --------------------------------------------------------------------- + // HAPPY PATH + // --------------------------------------------------------------------- + + public sealed class ScenarioTheCommittedSeamIndex + { + // Given: SEAMS.md at the repo root + + /// AC11 — + [Fact(Skip = Pending)] + public void ListsISpeakerSnapshotSource() => Assert.Fail(Pending); + + /// AC11 — + [Fact(Skip = Pending)] + public void ListsIPersonaCardByIdSource() => Assert.Fail(Pending); + + /// AC11 — + [Fact(Skip = Pending)] + public void IsByteIdenticalToTheGenerator() => Assert.Fail(Pending); + } +} diff --git a/tests/GenWave.Architecture.Tests/Specs/Story460_TheOrchestratorAfter.cs b/tests/GenWave.Architecture.Tests/Specs/Story460_TheOrchestratorAfter.cs new file mode 100644 index 00000000..17d3b201 --- /dev/null +++ b/tests/GenWave.Architecture.Tests/Specs/Story460_TheOrchestratorAfter.cs @@ -0,0 +1,79 @@ +// STORY-460 — The Orchestrator after (gh-#401 · SPEC F193 · PLAN T537) +// +// BDD specification — xUnit. AC1 reflects every public constructor in GenWave.Orchestration; AC2 is a text scan; AC3 reflects the Orchestrator; +// AC4 reads the replay's ceremony-only trace; AC5 is the existing laws. +// +// RED at plan time: every fact is [Fact(Skip = Pending)] with a loud body — remove the Skip only in the +// task that makes it green. The builder comments name the arrange each scenario needs. + +namespace GenWave.Architecture.Tests.Specs; + +public static class FeatureTheOrchestratorAfter +{ + const string Pending = "pending: T537 — fitness pins: no optional seam params, two construction sites, the public surface (STORY-460)"; + + // --------------------------------------------------------------------- + // HAPPY PATH + // --------------------------------------------------------------------- + + public sealed class ScenarioEveryPublicConstructorInOrchestration + { + // Given: parameters reflected + + /// AC1 — + [Fact(Skip = Pending)] + public void NoInterfaceParameterHasADefault() => Assert.Fail(Pending); + } + + public sealed class ScenarioTheTextScanAfterTheSplit + { + // Given: the Orchestrator's own construction call, scanned over src/ and tests/ + + /// AC2 — + [Fact(Skip = Pending)] + public void HitsExactlyTheBuilderAndTheRoot() => Assert.Fail(Pending); + } + + public sealed class ScenarioTheOrchestratorReflected + { + // Given: typeof(Orchestrator) + + /// AC3 — + [Fact(Skip = Pending)] + public void ImplementsINextItemProvider() => Assert.Fail(Pending); + + /// AC3 — + [Fact(Skip = Pending)] + public void ImplementsIBoundaryFitLog() => Assert.Fail(Pending); + + /// AC3 — + [Fact(Skip = Pending)] + public void ExposesSignOffLeadTime() => Assert.Fail(Pending); + + /// AC3 — + [Fact(Skip = Pending)] + public void ExposesTimeDateHonestyThreshold() => Assert.Fail(Pending); + } + + public sealed class ScenarioTheCeremonyOnlyUnitsTrace + { + // Given: the replay's ceremony-only unit + + /// AC4 — + [Fact(Skip = Pending)] + public void ListsTheSignOff() => Assert.Fail(Pending); + + /// AC4 — + [Fact(Skip = Pending)] + public void ListsNoLeadIn() => Assert.Fail(Pending); + } + + public sealed class ScenarioTheLaws + { + // Given: L1, L5, L10, L11 + + /// AC5 — + [Fact(Skip = Pending)] + public void AllGreen() => Assert.Fail(Pending); + } +} diff --git a/tests/GenWave.Host.Tests/FakeRenderBudgetProvider.cs b/tests/GenWave.Host.Tests/FakeRenderBudgetProvider.cs deleted file mode 100644 index 4fb0a92b..00000000 --- a/tests/GenWave.Host.Tests/FakeRenderBudgetProvider.cs +++ /dev/null @@ -1,16 +0,0 @@ -using GenWave.Core.Abstractions; - -namespace GenWave.Host.Tests; - -/// -/// Mutable double (SPEC F44.2, mirrors -/// one seam over). Set between calls to simulate a live -/// IOptionsMonitor<TtsOptions> reload without standing up a real options stack in a -/// unit test. -/// -sealed class FakeRenderBudgetProvider(TimeSpan budget) : IRenderBudgetProvider -{ - public TimeSpan Budget { get; set; } = budget; - - public TimeSpan Current => Budget; -} diff --git a/tests/GenWave.Host.Tests/GenWave.Host.Tests.csproj b/tests/GenWave.Host.Tests/GenWave.Host.Tests.csproj index 85b11bbe..db9836ce 100644 --- a/tests/GenWave.Host.Tests/GenWave.Host.Tests.csproj +++ b/tests/GenWave.Host.Tests/GenWave.Host.Tests.csproj @@ -50,4 +50,8 @@ + + + + diff --git a/tests/GenWave.Host.Tests/Specs/Story012_AcceptanceGate01_RenderAheadGracefulSkip.cs b/tests/GenWave.Host.Tests/Specs/Story012_AcceptanceGate01_RenderAheadGracefulSkip.cs index c4d2c210..99cb3b38 100644 --- a/tests/GenWave.Host.Tests/Specs/Story012_AcceptanceGate01_RenderAheadGracefulSkip.cs +++ b/tests/GenWave.Host.Tests/Specs/Story012_AcceptanceGate01_RenderAheadGracefulSkip.cs @@ -15,6 +15,7 @@ using GenWave.Host.Tests.Fakes; using GenWave.Host.Tests.Support; using GenWave.Orchestration; +using GenWave.TestSupport; using GenWave.Tts; namespace GenWave.Host.Tests.Specs; @@ -184,12 +185,21 @@ static Orchestrator BuildOrchestrator( var rotationProvider = new FakeRotationSettingsProvider(new RotationSettings()); var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - return new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, segmentSource, - new NoOpActivePersonaAccessor(), NullLogger.Instance, - new FakeRenderBudgetProvider(renderBudget), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + return new OrchestratorBuilder() + .WithIdentity(identityProvider) + .WithScope(scopeProvider) + .WithCadence(cadenceProvider) + .WithRotation(rotationProvider) + .WithMusicSelectionPolicy(musicSelectionPolicy) + .WithTts(segmentSource) + .WithPersonaAccessor(new NoOpActivePersonaAccessor()) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(renderBudget) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithLookahead(TimeSpan.Zero) + .Build() + .Orchestrator; } // ------------------------------------------------------------------------- diff --git a/tests/GenWave.Host.Tests/Specs/Story102_MainScopeLiveness.cs b/tests/GenWave.Host.Tests/Specs/Story102_MainScopeLiveness.cs index cf5e63da..4edabd50 100644 --- a/tests/GenWave.Host.Tests/Specs/Story102_MainScopeLiveness.cs +++ b/tests/GenWave.Host.Tests/Specs/Story102_MainScopeLiveness.cs @@ -19,6 +19,7 @@ using GenWave.Core.Domain; using GenWave.Host.Api; using GenWave.Orchestration; +using GenWave.TestSupport; namespace GenWave.Host.Tests.Specs; @@ -210,13 +211,21 @@ public async Task SelectionHonorsAWidenedScopeWithoutRebuild() var cadenceProvider = new FakeCadenceProvider(SilentCadence); var rotationProvider = new FakeRotationSettingsProvider(new RotationSettings()); var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - var orchestrator = new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, - new NoOpTtsSegmentSource(), - new NoOpActivePersonaAccessor(), NullLogger.Instance, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(5)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(identityProvider) + .WithScope(scopeProvider) + .WithCadence(cadenceProvider) + .WithRotation(rotationProvider) + .WithMusicSelectionPolicy(musicSelectionPolicy) + .WithTts(new NoOpTtsSegmentSource()) + .WithPersonaAccessor(new NoOpActivePersonaAccessor()) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(5)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithLookahead(TimeSpan.Zero) + .Build() + .Orchestrator; await orchestrator.GetNextAsync(new PlayoutContext([]), CancellationToken.None); Assert.Equal(new long[] { 1L }, catalog.Scopes[0].LibraryIds); @@ -290,13 +299,21 @@ public async Task EmptyEffectiveScopeStillReturnsNothing() var cadenceProvider = new FakeCadenceProvider(SilentCadence); var rotationProvider = new FakeRotationSettingsProvider(new RotationSettings()); var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - var orchestrator = new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, - new NoOpTtsSegmentSource(), - new NoOpActivePersonaAccessor(), NullLogger.Instance, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(5)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(identityProvider) + .WithScope(scopeProvider) + .WithCadence(cadenceProvider) + .WithRotation(rotationProvider) + .WithMusicSelectionPolicy(musicSelectionPolicy) + .WithTts(new NoOpTtsSegmentSource()) + .WithPersonaAccessor(new NoOpActivePersonaAccessor()) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(5)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithLookahead(TimeSpan.Zero) + .Build() + .Orchestrator; var item = await orchestrator.GetNextAsync(new PlayoutContext([]), CancellationToken.None); diff --git a/tests/GenWave.Host.Tests/Specs/Story139_SettingsSurfaceCompletion.cs b/tests/GenWave.Host.Tests/Specs/Story139_SettingsSurfaceCompletion.cs index c35aa777..71803aa6 100644 --- a/tests/GenWave.Host.Tests/Specs/Story139_SettingsSurfaceCompletion.cs +++ b/tests/GenWave.Host.Tests/Specs/Story139_SettingsSurfaceCompletion.cs @@ -20,6 +20,8 @@ using GenWave.Host.Playout; using GenWave.Host.Tests.Fakes; using GenWave.Orchestration; +using GenWave.TestSupport; +using GenWave.TestSupport.Fakes; // Alias to disambiguate GenWave.Loudness (the analyzer project) from the Loudness domain type. using CoreLoudness = GenWave.Core.Domain.Loudness; @@ -178,11 +180,21 @@ public async Task TheOrchestratorRenderBudgetReadsTheMonitorNotAFrozenTimeSpan() var budgetProvider = new FakeRenderBudgetProvider(TimeSpan.FromSeconds(1)); var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - var orchestrator = new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, tts, - new NoOpActivePersonaAccessor(), NullLogger.Instance, budgetProvider, - new SpeechDeferralQueue(time), - time, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(identityProvider) + .WithScope(scopeProvider) + .WithCadence(cadenceProvider) + .WithRotation(rotationProvider) + .WithMusicSelectionPolicy(musicSelectionPolicy) + .WithTts(tts) + .WithPersonaAccessor(new NoOpActivePersonaAccessor()) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(budgetProvider) + .WithDeferralQueue(new SpeechDeferralQueue(time)) + .WithTime(time) + .WithLookahead(TimeSpan.Zero) + .Build() + .Orchestrator; var ctx = new PlayoutContext([]); // Unit 1 — budget (1s) is far shorter than the render delay (10s): the budget timer is diff --git a/tests/GenWave.Host.Tests/Specs/Story456_PersonaCardById.cs b/tests/GenWave.Host.Tests/Specs/Story456_PersonaCardById.cs new file mode 100644 index 00000000..d3884219 --- /dev/null +++ b/tests/GenWave.Host.Tests/Specs/Story456_PersonaCardById.cs @@ -0,0 +1,31 @@ +// STORY-456 — The speaker travels with the plan — a card by id (gh-#772 · SPEC F189.2 · PLAN T525) +// +// BDD specification — xUnit. AC10 drives the Host IPersonaCardByIdSource implementation against the Postgres fixture: a persona row whose +// card carries one pronunciation rule. +// +// RED at plan time: every fact is [Fact(Skip = Pending)] with a loud body — remove the Skip only in the +// task that makes it green. The builder comments name the arrange each scenario needs. + +namespace GenWave.Host.Tests.Specs; + +public static class FeaturePersonaCardById +{ + const string Pending = "pending: T525 — the Host card-by-id store reads a persona card by id (STORY-456)"; + + // --------------------------------------------------------------------- + // HAPPY PATH + // --------------------------------------------------------------------- + + public sealed class ScenarioAPersonaRowWithACard + { + // Given: the card carries one pronunciation rule + + /// AC10 — + [Fact(Skip = Pending)] + public void ResolvesTheCardById() => Assert.Fail(Pending); + + /// AC10 — + [Fact(Skip = Pending)] + public void TheSnapshotCarriesTheRule() => Assert.Fail(Pending); + } +} diff --git a/tests/GenWave.Orchestration.Tests/Fakes/FakeBoundaryBiasProvider.cs b/tests/GenWave.Orchestration.Tests/Fakes/FakeBoundaryBiasProvider.cs deleted file mode 100644 index 55a71ec8..00000000 --- a/tests/GenWave.Orchestration.Tests/Fakes/FakeBoundaryBiasProvider.cs +++ /dev/null @@ -1,16 +0,0 @@ -using GenWave.Core.Abstractions; - -namespace GenWave.Orchestration.Tests.Fakes; - -/// -/// Mutable double (SPEC F74.3, mirrors -/// one seam over). Set between -/// calls to simulate a config-provider reload without standing up a real options stack in a unit -/// test. -/// -sealed class FakeBoundaryBiasProvider(TimeSpan lookahead) : IBoundaryBiasProvider -{ - public TimeSpan Lookahead { get; set; } = lookahead; - - public TimeSpan Current => Lookahead; -} diff --git a/tests/GenWave.Orchestration.Tests/Fakes/ProductionChainHarness.cs b/tests/GenWave.Orchestration.Tests/Fakes/ProductionChainHarness.cs index 15f0b08b..fa637a1d 100644 --- a/tests/GenWave.Orchestration.Tests/Fakes/ProductionChainHarness.cs +++ b/tests/GenWave.Orchestration.Tests/Fakes/ProductionChainHarness.cs @@ -83,46 +83,38 @@ public static ProductionChain BuildProductionChain( var mediaCatalog = catalog ?? new FakeMediaCatalog(MakeTrackRef("t1")); var musicSelectionPolicy = new MusicSelectionPolicy(mediaCatalog, NullLogger.Instance); - var orchestrator = new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, - tts, personaAccessor, logger, - new FakeRenderBudgetProvider(renderBudget ?? TimeSpan.FromSeconds(5)), - queue, - time, new FakeBoundaryBiasProvider(lookahead), - scheduleResolver: caching, - personaStore: personaStore, - events: events, - catalog: mediaCatalog, - patterEstimator: patterEstimator, - crosstalkPlanner: crosstalkPlanner); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(identityProvider) + .WithScope(scopeProvider) + .WithCadence(cadenceProvider) + .WithRotation(rotationProvider) + .WithMusicSelectionPolicy(musicSelectionPolicy) + .WithTts(tts) + .WithPersonaAccessor(personaAccessor) + .WithLogger(logger) + .WithRenderBudget(renderBudget ?? TimeSpan.FromSeconds(5)) + .WithDeferralQueue(queue) + .WithTime(time) + .WithBoundaryBias(new FakeBoundaryBiasProvider(lookahead)) + .WithScheduleResolver(caching) + .WithPersonaStore(personaStore) + .WithEvents(events) + .WithCatalog(mediaCatalog) + .WithPatterEstimator(patterEstimator) + .WithCrosstalkPlanner(crosstalkPlanner) + .Build() + .Orchestrator; return new ProductionChain(orchestrator, queue, time, scheduleStore, tts, events, logger, mediaCatalog); } - public static Persona MakePersona(long id, string name, string voice) - { - var now = DateTime.UnixEpoch; - return new Persona(id, name, "", "", voice, now, now); - } + // Moved to GenWave.TestSupport.TestData at PLAN T511 (OrchestratorBuilder's own defaults need + // these too); forwarding keeps every existing call site in this project unchanged until T512 folds this harness into the builder. + public static Persona MakePersona(long id, string name, string voice) => + TestData.MakePersona(id, name, voice); - public static MediaReference MakeTrackRef(string id) => new( - MediaId: id, - Locator: $"/media/{id}.mp3", - Title: $"Track {id}", - Loudness: new Loudness(-23.0, -1.0, true), - DurationMs: null, - SampleRate: null, - Channels: null, - BitrateKbps: null, - Artist: null, - Album: null, - Genre: null, - Year: null); + public static MediaReference MakeTrackRef(string id) => TestData.MakeTrackRef(id); - public static FakePersonaStore OneDjStore(long id, string name, string voice) - { - var store = new FakePersonaStore(); - store.Add(MakePersona(id, name, voice)); - return store; - } + public static FakePersonaStore OneDjStore(long id, string name, string voice) => + TestData.OneDjStore(id, name, voice); } diff --git a/tests/GenWave.Orchestration.Tests/GenWave.Orchestration.Tests.csproj b/tests/GenWave.Orchestration.Tests/GenWave.Orchestration.Tests.csproj index b2803b4b..185875ef 100644 --- a/tests/GenWave.Orchestration.Tests/GenWave.Orchestration.Tests.csproj +++ b/tests/GenWave.Orchestration.Tests/GenWave.Orchestration.Tests.csproj @@ -10,6 +10,7 @@ + @@ -18,10 +19,13 @@ + + + diff --git a/tests/GenWave.Orchestration.Tests/Specs/Gh117_StationLocalSegmentClock.cs b/tests/GenWave.Orchestration.Tests/Specs/Gh117_StationLocalSegmentClock.cs index a3814764..4ded80d7 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Gh117_StationLocalSegmentClock.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Gh117_StationLocalSegmentClock.cs @@ -51,20 +51,22 @@ public static class FeatureStationLocalSegmentClock var tts = new FakeTtsSegmentSource(); var musicSelectionPolicy = new MusicSelectionPolicy( new FakeMediaCatalog(MakeRef("track1")), NullLogger.Instance); - var orchestrator = new Orchestrator( - new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "af_heart")), - new FakeStationScopeProvider(new LibraryScope([1L])), - new FakeCadenceProvider(cadence), - new FakeRotationSettingsProvider(new RotationSettings()), - musicSelectionPolicy, - tts, - new FakeActivePersonaAccessor(), - NullLogger.Instance, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(30)), - new SpeechDeferralQueue(new FakeTimeProvider(FixedUtc)), - new FakeTimeProvider(FixedUtc), - new FakeBoundaryBiasProvider(TimeSpan.Zero), - stationClock: stationClock); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "af_heart"))) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(cadence) + .WithRotation(new FakeRotationSettingsProvider(new RotationSettings())) + .WithMusicSelectionPolicy(musicSelectionPolicy) + .WithTts(tts) + .WithPersonaAccessor(new FakeActivePersonaAccessor()) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(30)) + .WithDeferralQueue(new SpeechDeferralQueue(new FakeTimeProvider(FixedUtc))) + .WithTime(new FakeTimeProvider(FixedUtc)) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .WithStationClock(stationClock) + .Build() + .Orchestrator; return (orchestrator, tts); } diff --git a/tests/GenWave.Orchestration.Tests/Specs/Gh253_PatterDurationEstimator.cs b/tests/GenWave.Orchestration.Tests/Specs/Gh253_PatterDurationEstimator.cs index 91fe1556..68a4eeab 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Gh253_PatterDurationEstimator.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Gh253_PatterDurationEstimator.cs @@ -257,25 +257,27 @@ public static async Task Rendered_segment_durations_flow_into_the_estimator() var clock = new FakeTimeProvider(DateTimeOffset.Parse("2030-01-01T00:00:00Z")); var estimator = new RollingPatterDurationEstimator(); var tts = new FakeTtsSegmentSource { DurationMs = 12_000 }; - var orchestrator = new Orchestrator( - new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default")), - new FakeStationScopeProvider(new LibraryScope([1L])), - new FakeCadenceProvider(new CadenceConfig + var orchestrator = new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default"))) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(new CadenceConfig { LeadInBeforeEachTrack = true, BackAnnounceAfterEachTrack = false, StationIdEveryNUnits = 0, - }), - new FakeRotationSettingsProvider(new RotationSettings()), - new MusicSelectionPolicy(new FakeMediaCatalog(MakeTrack("m1")), NullLogger.Instance), - tts, - new FakeActivePersonaAccessor(), - NullLogger.Instance, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(30)), - new SpeechDeferralQueue(clock), - clock, - new FakeBoundaryBiasProvider(TimeSpan.FromMinutes(10)), - patterEstimator: estimator); + }) + .WithRotation(new FakeRotationSettingsProvider(new RotationSettings())) + .WithMusicSelectionPolicy(new MusicSelectionPolicy(new FakeMediaCatalog(MakeTrack("m1")), NullLogger.Instance)) + .WithTts(tts) + .WithPersonaAccessor(new FakeActivePersonaAccessor()) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(30)) + .WithDeferralQueue(new SpeechDeferralQueue(clock)) + .WithTime(clock) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.FromMinutes(10))) + .WithPatterEstimator(estimator) + .Build() + .Orchestrator; // When three units are planned (each unit = one lead-in + one music item) var ctx = new PlayoutContext([]); @@ -294,25 +296,27 @@ public static async Task A_render_without_a_measured_duration_is_never_observed( var clock = new FakeTimeProvider(DateTimeOffset.Parse("2030-01-01T00:00:00Z")); var estimator = new RollingPatterDurationEstimator(); var tts = new FakeTtsSegmentSource { DurationMs = null }; - var orchestrator = new Orchestrator( - new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default")), - new FakeStationScopeProvider(new LibraryScope([1L])), - new FakeCadenceProvider(new CadenceConfig + var orchestrator = new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default"))) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(new CadenceConfig { LeadInBeforeEachTrack = true, BackAnnounceAfterEachTrack = false, StationIdEveryNUnits = 0, - }), - new FakeRotationSettingsProvider(new RotationSettings()), - new MusicSelectionPolicy(new FakeMediaCatalog(MakeTrack("m1")), NullLogger.Instance), - tts, - new FakeActivePersonaAccessor(), - NullLogger.Instance, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(30)), - new SpeechDeferralQueue(clock), - clock, - new FakeBoundaryBiasProvider(TimeSpan.FromMinutes(10)), - patterEstimator: estimator); + }) + .WithRotation(new FakeRotationSettingsProvider(new RotationSettings())) + .WithMusicSelectionPolicy(new MusicSelectionPolicy(new FakeMediaCatalog(MakeTrack("m1")), NullLogger.Instance)) + .WithTts(tts) + .WithPersonaAccessor(new FakeActivePersonaAccessor()) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(30)) + .WithDeferralQueue(new SpeechDeferralQueue(clock)) + .WithTime(clock) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.FromMinutes(10))) + .WithPatterEstimator(estimator) + .Build() + .Orchestrator; // When units are planned var ctx = new PlayoutContext([]); diff --git a/tests/GenWave.Orchestration.Tests/Specs/Gh259_AttributionFromTheAiringItem.cs b/tests/GenWave.Orchestration.Tests/Specs/Gh259_AttributionFromTheAiringItem.cs index feacc6e6..afc8e4f7 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Gh259_AttributionFromTheAiringItem.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Gh259_AttributionFromTheAiringItem.cs @@ -32,18 +32,23 @@ static Persona BuildPersona(string name) => BackAnnounceAfterEachTrack = false, StationIdEveryNUnits = stationIdEveryNUnits, }; - var identityProvider = new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "af_heart")); - var scopeProvider = new FakeStationScopeProvider(new LibraryScope([1L])); - var cadenceProvider = new FakeCadenceProvider(cadence); - var rotationProvider = new FakeRotationSettingsProvider(new RotationSettings()); var catalog = new FakeMediaCatalog(MakeRef("track1")); var tts = new FakeTtsSegmentSource(); - var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - var orchestrator = new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, tts, accessor, - NullLogger.Instance, new FakeRenderBudgetProvider(TimeSpan.FromSeconds(30)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "af_heart"))) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(cadence) + .WithRotation(new FakeRotationSettingsProvider(new RotationSettings())) + .WithMusicSelectionPolicy(new MusicSelectionPolicy(catalog, NullLogger.Instance)) + .WithTts(tts) + .WithPersonaAccessor(accessor) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(30)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .Build() + .Orchestrator; return (orchestrator, tts); } diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story007_OrchestratorPlanner.cs b/tests/GenWave.Orchestration.Tests/Specs/Story007_OrchestratorPlanner.cs index 9b371c8c..cd150359 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story007_OrchestratorPlanner.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story007_OrchestratorPlanner.cs @@ -25,20 +25,22 @@ static Orchestrator BuildOrchestrator( ITtsSegmentSource ttsSource, CadenceConfig? cadence = null, LibraryScope? scope = null, - TimeSpan? renderBudget = null) - { - var identityProvider = new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default")); - var scopeProvider = new FakeStationScopeProvider(scope ?? new LibraryScope([1L])); - var cadenceProvider = new FakeCadenceProvider(cadence ?? new CadenceConfig()); - var rotationProvider = new FakeRotationSettingsProvider(new RotationSettings()); - var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - return new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, ttsSource, - new FakeActivePersonaAccessor(), NullLogger.Instance, - new FakeRenderBudgetProvider(renderBudget ?? TimeSpan.FromSeconds(30)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero)); - } + TimeSpan? renderBudget = null) => + new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default"))) + .WithScope(new FakeStationScopeProvider(scope ?? new LibraryScope([1L]))) + .WithCadence(cadence ?? new CadenceConfig()) + .WithRotation(new FakeRotationSettingsProvider(new RotationSettings())) + .WithMusicSelectionPolicy(new MusicSelectionPolicy(catalog, NullLogger.Instance)) + .WithTts(ttsSource) + .WithPersonaAccessor(new FakeActivePersonaAccessor()) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(renderBudget ?? TimeSpan.FromSeconds(30)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .Build() + .Orchestrator; static List ProduceN(Orchestrator orchestrator, int n) { @@ -262,16 +264,21 @@ public async Task SecondUnitHonorsACadenceChangeMadeAfterTheFirst() BackAnnounceAfterEachTrack = false, StationIdEveryNUnits = 0, }); - var identityProvider = new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default")); - var scopeProvider = new FakeStationScopeProvider(new LibraryScope([1L])); - var rotationProvider = new FakeRotationSettingsProvider(new RotationSettings()); - var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - var orchestrator = new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, tts, - new FakeActivePersonaAccessor(), NullLogger.Instance, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(30)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default"))) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(cadenceProvider) + .WithRotation(new FakeRotationSettingsProvider(new RotationSettings())) + .WithMusicSelectionPolicy(new MusicSelectionPolicy(catalog, NullLogger.Instance)) + .WithTts(tts) + .WithPersonaAccessor(new FakeActivePersonaAccessor()) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(30)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .Build() + .Orchestrator; var ctx = new PlayoutContext([]); // Unit 1 — cadence has LeadIn off: the pulled item is music, no lead-in precedes it. diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story008_RenderAheadGracefulSkipToMusic.cs b/tests/GenWave.Orchestration.Tests/Specs/Story008_RenderAheadGracefulSkipToMusic.cs index d872de48..f15d54d9 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story008_RenderAheadGracefulSkipToMusic.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story008_RenderAheadGracefulSkipToMusic.cs @@ -24,20 +24,22 @@ static Orchestrator BuildOrchestrator( FakeMediaCatalog catalog, FakeTtsSegmentSource ttsSource, CadenceConfig? cadence = null, - TimeSpan? renderBudget = null) - { - var identityProvider = new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default")); - var scopeProvider = new FakeStationScopeProvider(new LibraryScope([1L])); - var cadenceProvider = new FakeCadenceProvider(cadence ?? new CadenceConfig()); - var rotationProvider = new FakeRotationSettingsProvider(new RotationSettings()); - var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - return new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, ttsSource, - new FakeActivePersonaAccessor(), NullLogger.Instance, - new FakeRenderBudgetProvider(renderBudget ?? TimeSpan.FromSeconds(30)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero)); - } + TimeSpan? renderBudget = null) => + new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default"))) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(cadence ?? new CadenceConfig()) + .WithRotation(new FakeRotationSettingsProvider(new RotationSettings())) + .WithMusicSelectionPolicy(new MusicSelectionPolicy(catalog, NullLogger.Instance)) + .WithTts(ttsSource) + .WithPersonaAccessor(new FakeActivePersonaAccessor()) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(renderBudget ?? TimeSpan.FromSeconds(30)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .Build() + .Orchestrator; // --------------------------------------------------------------------- // HAPPY PATH — config / default value assertions @@ -218,17 +220,21 @@ public async Task SubsequentSlotsContinueInOrder() StationIdEveryNUnits = 0, }; // Use the controllable source directly for this test - var identityProvider = new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default")); - var scopeProvider = new FakeStationScopeProvider(new LibraryScope([1L])); - var cadenceProvider = new FakeCadenceProvider(cadence); - var rotationProvider = new FakeRotationSettingsProvider(new RotationSettings()); - var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - var o2 = new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, tts, - new FakeActivePersonaAccessor(), NullLogger.Instance, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(5)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + var o2 = new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default"))) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(cadence) + .WithRotation(new FakeRotationSettingsProvider(new RotationSettings())) + .WithMusicSelectionPolicy(new MusicSelectionPolicy(catalog, NullLogger.Instance)) + .WithTts(tts) + .WithPersonaAccessor(new FakeActivePersonaAccessor()) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(5)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .Build() + .Orchestrator; var ctx = new PlayoutContext([]); // Unit 1: LeadIn dropped (null), so first item is Music diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story121_PersonaVoiceResolution.cs b/tests/GenWave.Orchestration.Tests/Specs/Story121_PersonaVoiceResolution.cs index 27b1f762..18f8aa55 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story121_PersonaVoiceResolution.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story121_PersonaVoiceResolution.cs @@ -44,18 +44,23 @@ static Persona BuildPersona(string voice) => BackAnnounceAfterEachTrack = false, StationIdEveryNUnits = 0, }; - var identityProvider = new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", stationVoice)); - var scopeProvider = new FakeStationScopeProvider(new LibraryScope([1L])); - var cadenceProvider = new FakeCadenceProvider(cadence); - var rotationProvider = new FakeRotationSettingsProvider(new RotationSettings()); var catalog = new FakeMediaCatalog(MakeRef("track1")); var tts = new FakeTtsSegmentSource(); - var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - var orchestrator = new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, tts, accessor, - NullLogger.Instance, new FakeRenderBudgetProvider(TimeSpan.FromSeconds(30)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", stationVoice))) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(cadence) + .WithRotation(new FakeRotationSettingsProvider(new RotationSettings())) + .WithMusicSelectionPolicy(new MusicSelectionPolicy(catalog, NullLogger.Instance)) + .WithTts(tts) + .WithPersonaAccessor(accessor) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(30)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .Build() + .Orchestrator; return (orchestrator, tts); } @@ -124,18 +129,23 @@ public sealed class ScenarioStationIdsAreStationImaging BackAnnounceAfterEachTrack = false, StationIdEveryNUnits = 1, }; - var identityProvider = new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", stationVoice)); - var scopeProvider = new FakeStationScopeProvider(new LibraryScope([1L])); - var cadenceProvider = new FakeCadenceProvider(cadence); - var rotationProvider = new FakeRotationSettingsProvider(new RotationSettings()); var catalog = new FakeMediaCatalog(MakeRef("track1")); var tts = new FakeTtsSegmentSource(); - var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - var orchestrator = new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, tts, accessor, - NullLogger.Instance, new FakeRenderBudgetProvider(TimeSpan.FromSeconds(30)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", stationVoice))) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(cadence) + .WithRotation(new FakeRotationSettingsProvider(new RotationSettings())) + .WithMusicSelectionPolicy(new MusicSelectionPolicy(catalog, NullLogger.Instance)) + .WithTts(tts) + .WithPersonaAccessor(accessor) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(30)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .Build() + .Orchestrator; return (orchestrator, tts); } diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story131_PersonaAttributionRequestShape.cs b/tests/GenWave.Orchestration.Tests/Specs/Story131_PersonaAttributionRequestShape.cs index 05e14186..043bf40b 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story131_PersonaAttributionRequestShape.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story131_PersonaAttributionRequestShape.cs @@ -40,18 +40,23 @@ static Persona BuildPersona(string name, string voice) => BackAnnounceAfterEachTrack = false, StationIdEveryNUnits = 0, }; - var identityProvider = new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", stationVoice)); - var scopeProvider = new FakeStationScopeProvider(new LibraryScope([1L])); - var cadenceProvider = new FakeCadenceProvider(cadence); - var rotationProvider = new FakeRotationSettingsProvider(new RotationSettings()); var catalog = new FakeMediaCatalog(MakeRef("track1")); var tts = new FakeTtsSegmentSource(); - var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - var orchestrator = new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, tts, accessor, - NullLogger.Instance, new FakeRenderBudgetProvider(TimeSpan.FromSeconds(30)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", stationVoice))) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(cadence) + .WithRotation(new FakeRotationSettingsProvider(new RotationSettings())) + .WithMusicSelectionPolicy(new MusicSelectionPolicy(catalog, NullLogger.Instance)) + .WithTts(tts) + .WithPersonaAccessor(accessor) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(30)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .Build() + .Orchestrator; return (orchestrator, tts); } diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story134_RotationRelaxationDiagnostics.cs b/tests/GenWave.Orchestration.Tests/Specs/Story134_RotationRelaxationDiagnostics.cs index 94877c9c..268c1141 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story134_RotationRelaxationDiagnostics.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story134_RotationRelaxationDiagnostics.cs @@ -39,18 +39,22 @@ public static class FeatureRotationRelaxationDiagnostics BuildOrchestrator(MediaReference? ready) { var catalog = new FakeMediaCatalog(ready); - var identityProvider = new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default")); - var scopeProvider = new FakeStationScopeProvider(new LibraryScope([1L])); - var cadenceProvider = new FakeCadenceProvider(SilentCadence); - var rotationProvider = new FakeRotationSettingsProvider(new RotationSettings()); var logger = new CapturingLogger(); - var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - var orchestrator = new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, - new FakeTtsSegmentSource(), new FakeActivePersonaAccessor(), logger, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(5)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default"))) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(SilentCadence) + .WithRotation(new FakeRotationSettingsProvider(new RotationSettings())) + .WithMusicSelectionPolicy(new MusicSelectionPolicy(catalog, NullLogger.Instance)) + .WithTts(new FakeTtsSegmentSource()) + .WithPersonaAccessor(new FakeActivePersonaAccessor()) + .WithLogger(logger) + .WithRenderBudget(TimeSpan.FromSeconds(5)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .Build() + .Orchestrator; return (orchestrator, catalog, logger); } diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story135_ArtistSeparationLive.cs b/tests/GenWave.Orchestration.Tests/Specs/Story135_ArtistSeparationLive.cs index 3cd7248a..d6a9c04b 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story135_ArtistSeparationLive.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story135_ArtistSeparationLive.cs @@ -31,18 +31,23 @@ public static class FeatureArtistSeparationLive BuildOrchestrator(MediaReference? ready, int artistSeparation) { var catalog = new FakeMediaCatalog(ready); - var identityProvider = new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default")); - var scopeProvider = new FakeStationScopeProvider(new LibraryScope([1L])); - var cadenceProvider = new FakeCadenceProvider(SilentCadence); var rotationProvider = new FakeRotationSettingsProvider(new RotationSettings { ArtistSeparation = artistSeparation }); var logger = new CapturingLogger(); - var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - var orchestrator = new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, - new FakeTtsSegmentSource(), new FakeActivePersonaAccessor(), logger, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(5)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default"))) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(SilentCadence) + .WithRotation(rotationProvider) + .WithMusicSelectionPolicy(new MusicSelectionPolicy(catalog, NullLogger.Instance)) + .WithTts(new FakeTtsSegmentSource()) + .WithPersonaAccessor(new FakeActivePersonaAccessor()) + .WithLogger(logger) + .WithRenderBudget(TimeSpan.FromSeconds(5)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .Build() + .Orchestrator; return (orchestrator, catalog, rotationProvider, logger); } diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story136_StationIdCadence.cs b/tests/GenWave.Orchestration.Tests/Specs/Story136_StationIdCadence.cs index aad32415..17f898ad 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story136_StationIdCadence.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story136_StationIdCadence.cs @@ -28,24 +28,27 @@ public static class FeatureStationIdCadence static Orchestrator BuildOrchestrator(int stationIdEveryNUnits) { - var identityProvider = new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default")); - var scopeProvider = new FakeStationScopeProvider(new LibraryScope([1L])); - var cadenceProvider = new FakeCadenceProvider(new CadenceConfig - { - LeadInBeforeEachTrack = false, - BackAnnounceAfterEachTrack = false, - StationIdEveryNUnits = stationIdEveryNUnits, - }); - var rotationProvider = new FakeRotationSettingsProvider(new RotationSettings()); var catalog = new FakeMediaCatalog(MakeRef("track1")); - var tts = new FakeTtsSegmentSource(); - var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - return new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, tts, - new FakeActivePersonaAccessor(), NullLogger.Instance, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(30)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + return new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default"))) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(new CadenceConfig + { + LeadInBeforeEachTrack = false, + BackAnnounceAfterEachTrack = false, + StationIdEveryNUnits = stationIdEveryNUnits, + }) + .WithRotation(new FakeRotationSettingsProvider(new RotationSettings())) + .WithMusicSelectionPolicy(new MusicSelectionPolicy(catalog, NullLogger.Instance)) + .WithTts(new FakeTtsSegmentSource()) + .WithPersonaAccessor(new FakeActivePersonaAccessor()) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(30)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .Build() + .Orchestrator; } static List ProduceN(Orchestrator orchestrator, int n) diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story138_StationIdentityLive.cs b/tests/GenWave.Orchestration.Tests/Specs/Story138_StationIdentityLive.cs index 81cfe64a..4a5c46a2 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story138_StationIdentityLive.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story138_StationIdentityLive.cs @@ -38,18 +38,23 @@ public static class FeatureStationIdentityLive BuildOrchestrator(string name) { var identityProvider = new FakeStationIdentityProvider(new StationIdentity("s1", name, "af_heart")); - var scopeProvider = new FakeStationScopeProvider(new LibraryScope([1L])); - var cadenceProvider = new FakeCadenceProvider(LeadInOnlyCadence); - var rotationProvider = new FakeRotationSettingsProvider(new RotationSettings()); var catalog = new FakeMediaCatalog(MakeRef("track1")); var tts = new FakeTtsSegmentSource(); - var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - var orchestrator = new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, tts, - new FakeActivePersonaAccessor(), NullLogger.Instance, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(30)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(identityProvider) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(LeadInOnlyCadence) + .WithRotation(new FakeRotationSettingsProvider(new RotationSettings())) + .WithMusicSelectionPolicy(new MusicSelectionPolicy(catalog, NullLogger.Instance)) + .WithTts(tts) + .WithPersonaAccessor(new FakeActivePersonaAccessor()) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(30)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .Build() + .Orchestrator; return (orchestrator, tts, identityProvider); } diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story212_EnvelopeProviderAndLadder.cs b/tests/GenWave.Orchestration.Tests/Specs/Story212_EnvelopeProviderAndLadder.cs index 990996bd..a7f9d745 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story212_EnvelopeProviderAndLadder.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story212_EnvelopeProviderAndLadder.cs @@ -52,19 +52,23 @@ public static class FeatureEnvelopeProviderAndLadder IPersonaPickProvider? personaPickProvider = null, int artistSeparation = 0) { - var identityProvider = new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default")); - var scopeProvider = new FakeStationScopeProvider(new LibraryScope([1L])); - var cadenceProvider = new FakeCadenceProvider(SilentCadence); - var rotationProvider = new FakeRotationSettingsProvider(new RotationSettings { ArtistSeparation = artistSeparation }); var policyLogger = new CapturingLogger(); - var musicSelectionPolicy = new MusicSelectionPolicy( - catalog, policyLogger, new FakeEnvelopeProvider(envelope), personaPickProvider); - var orchestrator = new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, - new FakeTtsSegmentSource(), new FakeActivePersonaAccessor(), NullLogger.Instance, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(5)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default"))) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(SilentCadence) + .WithRotation(new FakeRotationSettingsProvider(new RotationSettings { ArtistSeparation = artistSeparation })) + .WithMusicSelectionPolicy(new MusicSelectionPolicy( + catalog, policyLogger, new FakeEnvelopeProvider(envelope), personaPickProvider)) + .WithTts(new FakeTtsSegmentSource()) + .WithPersonaAccessor(new FakeActivePersonaAccessor()) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(5)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .Build() + .Orchestrator; return (orchestrator, policyLogger); } diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story213_PersonaRanker.cs b/tests/GenWave.Orchestration.Tests/Specs/Story213_PersonaRanker.cs index 183e780b..07428ed4 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story213_PersonaRanker.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story213_PersonaRanker.cs @@ -275,12 +275,21 @@ public static async Task OneLineCarriesAllSixAnswerFields() var logger = new CapturingLogger(); var musicSelectionPolicy = new MusicSelectionPolicy( catalog, logger, new FakeEnvelopeProvider(SegmentEnvelope.StationDefault), provider); - var orchestrator = new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, - new FakeTtsSegmentSource(), personaAccessor, NullLogger.Instance, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(5)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(identityProvider) + .WithScope(scopeProvider) + .WithCadence(cadenceProvider) + .WithRotation(rotationProvider) + .WithMusicSelectionPolicy(musicSelectionPolicy) + .WithTts(new FakeTtsSegmentSource()) + .WithPersonaAccessor(personaAccessor) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(5)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .Build() + .Orchestrator; var item = await orchestrator.GetNextAsync(new PlayoutContext([]), CancellationToken.None); diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story227_RequestFulfillment.cs b/tests/GenWave.Orchestration.Tests/Specs/Story227_RequestFulfillment.cs index 3c6471c7..216e5a4a 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story227_RequestFulfillment.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story227_RequestFulfillment.cs @@ -61,12 +61,21 @@ static Orchestrator BuildOrchestrator( catalog, new CapturingLogger(), new FakeEnvelopeProvider(envelope), personaPickProvider, requestFulfillmentSource); - return new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, - new FakeTtsSegmentSource(), new FakeActivePersonaAccessor(), new CapturingLogger(), - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(5)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + return new OrchestratorBuilder() + .WithIdentity(identityProvider) + .WithScope(scopeProvider) + .WithCadence(cadenceProvider) + .WithRotation(rotationProvider) + .WithMusicSelectionPolicy(musicSelectionPolicy) + .WithTts(new FakeTtsSegmentSource()) + .WithPersonaAccessor(new FakeActivePersonaAccessor()) + .WithLogger(new CapturingLogger()) + .WithRenderBudget(TimeSpan.FromSeconds(5)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .Build() + .Orchestrator; } public static class ScenarioALiveRequestShortCircuitsTheChain @@ -190,19 +199,21 @@ public static async Task ExactlyOneRequestIsFulfilledAndItsTrackWinsThePick() new FakeEnvelopeProvider(envelope), personaPickProvider: null, requestFulfillmentSource: fulfillmentSource); - var orchestrator = new Orchestrator( - new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default")), - new FakeStationScopeProvider(new LibraryScope([1L])), - new FakeCadenceProvider(SilentCadence), - new FakeRotationSettingsProvider(new RotationSettings()), - musicSelectionPolicy, - new FakeTtsSegmentSource(), - new FakeActivePersonaAccessor(), - new CapturingLogger(), - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(5)), - deferralQueue, - clock, - new FakeBoundaryBiasProvider(TimeSpan.FromMinutes(10))); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default"))) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(SilentCadence) + .WithRotation(new FakeRotationSettingsProvider(new RotationSettings())) + .WithMusicSelectionPolicy(musicSelectionPolicy) + .WithTts(new FakeTtsSegmentSource()) + .WithPersonaAccessor(new FakeActivePersonaAccessor()) + .WithLogger(new CapturingLogger()) + .WithRenderBudget(TimeSpan.FromSeconds(5)) + .WithDeferralQueue(deferralQueue) + .WithTime(clock) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.FromMinutes(10))) + .Build() + .Orchestrator; // Act: pull the next item — the bias window is active, so pre-fix this drove the // resample loop straight through the fulfillment rung on every attempt. diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story228_RequestShoutOut.cs b/tests/GenWave.Orchestration.Tests/Specs/Story228_RequestShoutOut.cs index 74b117f2..ade65ca1 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story228_RequestShoutOut.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story228_RequestShoutOut.cs @@ -55,19 +55,21 @@ static Orchestrator BuildOrchestrator(CadenceConfig cadence, ITtsSegmentSource t personaPickProvider: null, requestFulfillmentSource: fulfillmentSource); - return new Orchestrator( - new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default")), - new FakeStationScopeProvider(new LibraryScope([1L])), - new FakeCadenceProvider(cadence), - new FakeRotationSettingsProvider(new RotationSettings()), - musicSelectionPolicy, - ttsSource, - new FakeActivePersonaAccessor(), - new CapturingLogger(), - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(5)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, - new FakeBoundaryBiasProvider(TimeSpan.Zero)); + return new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default"))) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(cadence) + .WithRotation(new FakeRotationSettingsProvider(new RotationSettings())) + .WithMusicSelectionPolicy(musicSelectionPolicy) + .WithTts(ttsSource) + .WithPersonaAccessor(new FakeActivePersonaAccessor()) + .WithLogger(new CapturingLogger()) + .WithRenderBudget(TimeSpan.FromSeconds(5)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .Build() + .Orchestrator; } static CadenceConfig CadenceWithLeadIn(bool leadInBeforeEachTrack) => new() diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story241_StationFollowsTheClock.cs b/tests/GenWave.Orchestration.Tests/Specs/Story241_StationFollowsTheClock.cs index d1c20113..ba57732c 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story241_StationFollowsTheClock.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story241_StationFollowsTheClock.cs @@ -52,24 +52,38 @@ static ProductionChain BuildProductionChain( var personaAccessor = new OnAirPersonaAccessor(caching, personaStore, NullLogger.Instance); var envelopeProvider = new ScheduleEnvelopeProvider(caching, stationDefault); - var identityProvider = new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default")); - var scopeProvider = new FakeStationScopeProvider(new LibraryScope([1L])); - var cadenceProvider = new FakeCadenceProvider(cadence ?? new CadenceConfig - { - LeadInBeforeEachTrack = false, - BackAnnounceAfterEachTrack = false, - StationIdEveryNUnits = 0, - }); - var rotationProvider = new FakeRotationSettingsProvider(rotationSettings ?? new RotationSettings()); var logger = new CapturingLogger(); var musicSelectionPolicy = new MusicSelectionPolicy(catalog, logger, envelopeProvider, personaPickProvider); var tts = new FakeTtsSegmentSource(); - var orchestrator = new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, - tts, personaAccessor, NullLogger.Instance, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(5)), - new SpeechDeferralQueue(time), - time, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + // Deliberately never calls WithScheduleResolver/WithSchedule below: the ORIGINAL wiring never + // gave the Orchestrator its own scheduleResolver seam either — `caching` is threaded only + // through personaAccessor and envelopeProvider above, exactly as the production DI shape does. + // The builder's own default (an empty-snapshot CachingScheduleResolver) resolves no boundary + // and no current show for every one of Orchestrator's own scheduleResolver reads, which is + // externally indistinguishable from Orchestrator's true null default here (both leave + // EnqueueHandoffCeremonyAsync a permanent no-op and every Show-name read null) — the only + // difference is one unasserted "No CachingScheduleResolver wired" WARN line the null path logs + // once, which this file's facts never assert on. + var orchestrator = new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default"))) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(cadence ?? new CadenceConfig + { + LeadInBeforeEachTrack = false, + BackAnnounceAfterEachTrack = false, + StationIdEveryNUnits = 0, + }) + .WithRotation(new FakeRotationSettingsProvider(rotationSettings ?? new RotationSettings())) + .WithMusicSelectionPolicy(musicSelectionPolicy) + .WithTts(tts) + .WithPersonaAccessor(personaAccessor) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(5)) + .WithDeferralQueue(new SpeechDeferralQueue(time)) + .WithTime(time) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .Build() + .Orchestrator; return new ProductionChain(orchestrator, personaAccessor, envelopeProvider, time, logger, tts); } diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story243_DjsHandOffAudibly.cs b/tests/GenWave.Orchestration.Tests/Specs/Story243_DjsHandOffAudibly.cs index 660fa266..f8847f32 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story243_DjsHandOffAudibly.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story243_DjsHandOffAudibly.cs @@ -56,15 +56,6 @@ internal static ProductionChain BuildProductionChain( var caching = new CachingScheduleResolver(scheduleStore, resolver, new FakeScheduleSpecialStore()); var personaAccessor = new OnAirPersonaAccessor(caching, personaStore, NullLogger.Instance); - var identityProvider = new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default")); - var scopeProvider = new FakeStationScopeProvider(new LibraryScope([1L])); - var cadenceProvider = new FakeCadenceProvider(cadence ?? new CadenceConfig - { - LeadInBeforeEachTrack = false, - BackAnnounceAfterEachTrack = false, - StationIdEveryNUnits = 0, - }); - var rotationProvider = new FakeRotationSettingsProvider(new RotationSettings()); var logger = new CapturingLogger(); // The render double's own RenderDelay rides the SAME fake clock as the render budget below // (STORY-442, PLAN T483) — a spec drives both off due order on chain.Time, never wall-clock @@ -75,15 +66,29 @@ internal static ProductionChain BuildProductionChain( var catalog = new FakeMediaCatalog(MakeTrackRef("t1")); var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - var orchestrator = new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, - tts, personaAccessor, logger, - new FakeRenderBudgetProvider(renderBudget ?? TimeSpan.FromSeconds(5)), - queue, - time, new FakeBoundaryBiasProvider(lookahead), - scheduleResolver: caching, - personaStore: personaStore, - events: events); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default"))) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(cadence ?? new CadenceConfig + { + LeadInBeforeEachTrack = false, + BackAnnounceAfterEachTrack = false, + StationIdEveryNUnits = 0, + }) + .WithRotation(new FakeRotationSettingsProvider(new RotationSettings())) + .WithMusicSelectionPolicy(musicSelectionPolicy) + .WithTts(tts) + .WithPersonaAccessor(personaAccessor) + .WithLogger(logger) + .WithRenderBudget(renderBudget ?? TimeSpan.FromSeconds(5)) + .WithDeferralQueue(queue) + .WithTime(time) + .WithBoundaryBias(new FakeBoundaryBiasProvider(lookahead)) + .WithScheduleResolver(caching) + .WithPersonaStore(personaStore) + .WithEvents(events) + .Build() + .Orchestrator; return new ProductionChain(orchestrator, queue, time, scheduleStore, tts, events, logger); } diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story297_ContextSegmentsAir.cs b/tests/GenWave.Orchestration.Tests/Specs/Story297_ContextSegmentsAir.cs index db6461c0..6127e5d3 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story297_ContextSegmentsAir.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story297_ContextSegmentsAir.cs @@ -60,16 +60,23 @@ static Orchestrator BuildOrchestrator( var catalog = new FakeMediaCatalog(MakeTrackRef("t1")); var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - return new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, tts, - personaAccessor ?? new FakeActivePersonaAccessor(), - logger ?? NullLogger.Instance, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(30)), - queue, - clock, - new FakeBoundaryBiasProvider(TimeSpan.Zero), - personaStore: personaStore, - contextSettings: contextSettings ?? new FakeContextSettingsProvider()); + var builder = new OrchestratorBuilder() + .WithIdentity(identityProvider) + .WithScope(scopeProvider) + .WithCadence(cadenceProvider) + .WithRotation(rotationProvider) + .WithMusicSelectionPolicy(musicSelectionPolicy) + .WithTts(tts) + .WithPersonaAccessor(personaAccessor ?? new FakeActivePersonaAccessor()) + .WithLogger(logger ?? NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(30)) + .WithDeferralQueue(queue) + .WithTime(clock) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .WithContextSettings(contextSettings ?? new FakeContextSettingsProvider()); + if (personaStore is not null) + builder = builder.WithPersonaStore(personaStore); + return builder.Build().Orchestrator; } // --------------------------------------------------------------------- diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story301_ClockAnchoredIdents.cs b/tests/GenWave.Orchestration.Tests/Specs/Story301_ClockAnchoredIdents.cs index 6e8fa779..f67aaeb5 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story301_ClockAnchoredIdents.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story301_ClockAnchoredIdents.cs @@ -32,12 +32,22 @@ static Orchestrator BuildOrchestrator( var rotationProvider = new FakeRotationSettingsProvider(new RotationSettings()); var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - return new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, tts, - new FakeActivePersonaAccessor(), NullLogger.Instance, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(30)), - queue, clock, new FakeBoundaryBiasProvider(TimeSpan.Zero), - catalog: catalog); + return new OrchestratorBuilder() + .WithIdentity(identityProvider) + .WithScope(scopeProvider) + .WithCadence(cadenceProvider) + .WithRotation(rotationProvider) + .WithMusicSelectionPolicy(musicSelectionPolicy) + .WithTts(tts) + .WithPersonaAccessor(new FakeActivePersonaAccessor()) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(30)) + .WithDeferralQueue(queue) + .WithTime(clock) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .WithCatalog(catalog) + .Build() + .Orchestrator; } // --------------------------------------------------------------------- diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story302_TimeAnnouncements.cs b/tests/GenWave.Orchestration.Tests/Specs/Story302_TimeAnnouncements.cs index 02336336..745302dd 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story302_TimeAnnouncements.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story302_TimeAnnouncements.cs @@ -39,12 +39,21 @@ static Orchestrator BuildOrchestrator( var catalog = new FakeMediaCatalog(MakeTrackRef("t1")); var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - return new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, tts, - new FakeActivePersonaAccessor(), - logger ?? NullLogger.Instance, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(30)), - queue, clock, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + return new OrchestratorBuilder() + .WithIdentity(identityProvider) + .WithScope(scopeProvider) + .WithCadence(cadenceProvider) + .WithRotation(rotationProvider) + .WithMusicSelectionPolicy(musicSelectionPolicy) + .WithTts(tts) + .WithPersonaAccessor(new FakeActivePersonaAccessor()) + .WithLogger(logger ?? NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(30)) + .WithDeferralQueue(queue) + .WithTime(clock) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .Build() + .Orchestrator; } // --------------------------------------------------------------------- diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story303_StraddleHandoff.cs b/tests/GenWave.Orchestration.Tests/Specs/Story303_StraddleHandoff.cs index f7524711..3bae9fad 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story303_StraddleHandoff.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story303_StraddleHandoff.cs @@ -658,21 +658,23 @@ static FakePersonaStore ThreeDjStore() var resolver = new ScheduleResolver(time, new FakeStationDefaultEnvelopeSource(SegmentEnvelope.StationDefault)); var caching = new CachingScheduleResolver(scheduleStore, resolver, new FakeScheduleSpecialStore()); var queue = new SpeechDeferralQueue(time); - var orchestrator = new Orchestrator( - new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default")), - new FakeStationScopeProvider(new LibraryScope([1L])), - new FakeCadenceProvider(CadenceOff), - new FakeRotationSettingsProvider(new RotationSettings()), - new MusicSelectionPolicy(catalog, NullLogger.Instance), - new FakeTtsSegmentSource(), - new FakeActivePersonaAccessor(), - NullLogger.Instance, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(30)), - queue, - time, - new FakeBoundaryBiasProvider(TimeSpan.FromMinutes(10)), - scheduleResolver: caching, - personaStore: personaStore ?? TwoDjStore()); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default"))) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(CadenceOff) + .WithRotation(new FakeRotationSettingsProvider(new RotationSettings())) + .WithMusicSelectionPolicy(new MusicSelectionPolicy(catalog, NullLogger.Instance)) + .WithTts(new FakeTtsSegmentSource()) + .WithPersonaAccessor(new FakeActivePersonaAccessor()) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(30)) + .WithDeferralQueue(queue) + .WithTime(time) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.FromMinutes(10))) + .WithScheduleResolver(caching) + .WithPersonaStore(personaStore ?? TwoDjStore()) + .Build() + .Orchestrator; return (orchestrator, scheduleStore, time, queue); } diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story307_CeremonyNamesTheShow.cs b/tests/GenWave.Orchestration.Tests/Specs/Story307_CeremonyNamesTheShow.cs index 130a201f..6d067f7a 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story307_CeremonyNamesTheShow.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story307_CeremonyNamesTheShow.cs @@ -43,15 +43,6 @@ static ProductionChain BuildProductionChain( var caching = new CachingScheduleResolver(scheduleStore, resolver, new FakeScheduleSpecialStore()); var personaAccessor = new OnAirPersonaAccessor(caching, personaStore, NullLogger.Instance); - var identityProvider = new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default")); - var scopeProvider = new FakeStationScopeProvider(new LibraryScope([1L])); - var cadenceProvider = new FakeCadenceProvider(new CadenceConfig - { - LeadInBeforeEachTrack = false, - BackAnnounceAfterEachTrack = false, - StationIdEveryNUnits = 0, - }); - var rotationProvider = new FakeRotationSettingsProvider(new RotationSettings()); var logger = new CapturingLogger(); var tts = new FakeTtsSegmentSource(); var events = new CapturingStationEventSink(); @@ -59,15 +50,29 @@ static ProductionChain BuildProductionChain( var catalog = new FakeMediaCatalog(MakeTrackRef("t1")); var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - var orchestrator = new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, - tts, personaAccessor, logger, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(5)), - queue, - time, new FakeBoundaryBiasProvider(lookahead), - scheduleResolver: caching, - personaStore: personaStore, - events: events); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default"))) + .WithScope(new FakeStationScopeProvider(new LibraryScope([1L]))) + .WithCadence(new CadenceConfig + { + LeadInBeforeEachTrack = false, + BackAnnounceAfterEachTrack = false, + StationIdEveryNUnits = 0, + }) + .WithRotation(new FakeRotationSettingsProvider(new RotationSettings())) + .WithMusicSelectionPolicy(musicSelectionPolicy) + .WithTts(tts) + .WithPersonaAccessor(personaAccessor) + .WithLogger(logger) + .WithRenderBudget(TimeSpan.FromSeconds(5)) + .WithDeferralQueue(queue) + .WithTime(time) + .WithBoundaryBias(new FakeBoundaryBiasProvider(lookahead)) + .WithScheduleResolver(caching) + .WithPersonaStore(personaStore) + .WithEvents(events) + .Build() + .Orchestrator; return new ProductionChain(orchestrator, queue, time, scheduleStore, tts); } diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story321_LateTimeCheckDies.cs b/tests/GenWave.Orchestration.Tests/Specs/Story321_LateTimeCheckDies.cs index e0c16fa0..9fe4ba72 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story321_LateTimeCheckDies.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story321_LateTimeCheckDies.cs @@ -59,13 +59,22 @@ static Orchestrator BuildOrchestrator( var catalog = new FakeMediaCatalog(MakeTrackRef("t1")); var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - return new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, tts, - new FakeActivePersonaAccessor(), - logger ?? NullLogger.Instance, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(30)), - queue, clock, new FakeBoundaryBiasProvider(TimeSpan.Zero), - imagingSettings: imagingSettings); + return new OrchestratorBuilder() + .WithIdentity(identityProvider) + .WithScope(scopeProvider) + .WithCadence(cadenceProvider) + .WithRotation(rotationProvider) + .WithMusicSelectionPolicy(musicSelectionPolicy) + .WithTts(tts) + .WithPersonaAccessor(new FakeActivePersonaAccessor()) + .WithLogger(logger ?? NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(30)) + .WithDeferralQueue(queue) + .WithTime(clock) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .WithImagingSettings(imagingSettings) + .Build() + .Orchestrator; } // ── HAPPY PATH ────────────────────────────────────────────────────────── diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story358_AnnouncementVendAndVerbatim.cs b/tests/GenWave.Orchestration.Tests/Specs/Story358_AnnouncementVendAndVerbatim.cs index cefc53d1..b1840c4a 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story358_AnnouncementVendAndVerbatim.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story358_AnnouncementVendAndVerbatim.cs @@ -35,16 +35,25 @@ static Orchestrator BuildOrchestrator( var catalog = new FakeMediaCatalog(MakeRef("t1")); var musicSelectionPolicy = new MusicSelectionPolicy(catalog, NullLogger.Instance); - return new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, tts, - new FakeActivePersonaAccessor(), logger ?? NullLogger.Instance, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(5)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero), - announcementSource: announcementSource, - announcementRenderer: announcementRenderer, - voiceLister: voiceLister, - announcementCopyWriter: announcementCopyWriter); + return new OrchestratorBuilder() + .WithIdentity(identityProvider) + .WithScope(scopeProvider) + .WithCadence(cadenceProvider) + .WithRotation(rotationProvider) + .WithMusicSelectionPolicy(musicSelectionPolicy) + .WithTts(tts) + .WithPersonaAccessor(new FakeActivePersonaAccessor()) + .WithLogger(logger ?? NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(5)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .WithAnnouncementSource(announcementSource) + .WithAnnouncementRenderer(announcementRenderer) + .WithVoiceLister(voiceLister) + .WithAnnouncementCopyWriter(announcementCopyWriter) + .Build() + .Orchestrator; } static MediaReference MakeRef(string id) => new( diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story371_TheNudgeInTheRanker.cs b/tests/GenWave.Orchestration.Tests/Specs/Story371_TheNudgeInTheRanker.cs index 110a97c8..ff4829c7 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story371_TheNudgeInTheRanker.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story371_TheNudgeInTheRanker.cs @@ -418,12 +418,21 @@ public sealed class ScenarioObservability var logger = new CapturingLogger(); var musicSelectionPolicy = new MusicSelectionPolicy( catalog, logger, new FakeEnvelopeProvider(SegmentEnvelope.StationDefault), provider); - var orchestrator = new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, - new FakeTtsSegmentSource(), personaAccessor, NullLogger.Instance, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(5)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(identityProvider) + .WithScope(scopeProvider) + .WithCadence(cadenceProvider) + .WithRotation(rotationProvider) + .WithMusicSelectionPolicy(musicSelectionPolicy) + .WithTts(new FakeTtsSegmentSource()) + .WithPersonaAccessor(personaAccessor) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(5)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .Build() + .Orchestrator; var item = await orchestrator.GetNextAsync(new PlayoutContext([]), CancellationToken.None); return (item, logger); diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story372_DeepCutsAndTheRelaxLadder.cs b/tests/GenWave.Orchestration.Tests/Specs/Story372_DeepCutsAndTheRelaxLadder.cs index b57dbf26..1ec04ab7 100644 --- a/tests/GenWave.Orchestration.Tests/Specs/Story372_DeepCutsAndTheRelaxLadder.cs +++ b/tests/GenWave.Orchestration.Tests/Specs/Story372_DeepCutsAndTheRelaxLadder.cs @@ -105,12 +105,21 @@ public static class FeatureDeepCutsAndTheRelaxLadder var policyLogger = new CapturingLogger(); var musicSelectionPolicy = new MusicSelectionPolicy( catalog, policyLogger, new FakeEnvelopeProvider(envelope), provider); - var orchestrator = new Orchestrator( - identityProvider, scopeProvider, cadenceProvider, rotationProvider, musicSelectionPolicy, - new FakeTtsSegmentSource(), personaAccessor, NullLogger.Instance, - new FakeRenderBudgetProvider(TimeSpan.FromSeconds(5)), - new SpeechDeferralQueue(TimeProvider.System), - TimeProvider.System, new FakeBoundaryBiasProvider(TimeSpan.Zero)); + var orchestrator = new OrchestratorBuilder() + .WithIdentity(identityProvider) + .WithScope(scopeProvider) + .WithCadence(cadenceProvider) + .WithRotation(rotationProvider) + .WithMusicSelectionPolicy(musicSelectionPolicy) + .WithTts(new FakeTtsSegmentSource()) + .WithPersonaAccessor(personaAccessor) + .WithLogger(NullLogger.Instance) + .WithRenderBudget(TimeSpan.FromSeconds(5)) + .WithDeferralQueue(new SpeechDeferralQueue(TimeProvider.System)) + .WithTime(TimeProvider.System) + .WithBoundaryBias(new FakeBoundaryBiasProvider(TimeSpan.Zero)) + .Build() + .Orchestrator; return (orchestrator, policyLogger); } diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story451_OneConstructionPath.cs b/tests/GenWave.Orchestration.Tests/Specs/Story451_OneConstructionPath.cs new file mode 100644 index 00000000..c44581a9 --- /dev/null +++ b/tests/GenWave.Orchestration.Tests/Specs/Story451_OneConstructionPath.cs @@ -0,0 +1,296 @@ +// STORY-451 — One construction path (gh-#401 · SPEC F184 · PLAN T510–T514) +// +// BDD specification — xUnit. AC1–AC3 drive OrchestratorBuilder (tests/GenWave.TestSupport); AC6/AC7 drive AddOrchestration through a +// ServiceCollection; AC9 is the recount. AC4/AC5/AC8 are pins in Architecture.Tests (Story451_ConstructionPins). +// +// AC1/AC2 went green at T511, AC3/AC9 at T512. AC6/AC7 land at T514, below: they drive +// AddGenWaveOrchestration through a real ServiceCollection rather than OrchestratorBuilder, so the +// optional-seam resolution the extension method itself owns (not the builder's own With* defaults) +// is what each fact actually exercises. + +using System.Reflection; +using System.Text.RegularExpressions; +using GenWave.Abstractions.Playout; +using GenWave.Core.Abstractions; +using GenWave.Core.Domain; +using GenWave.Orchestration.Tests.Fakes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; + +namespace GenWave.Orchestration.Tests.Specs; + +public static class FeatureOneConstructionPath +{ + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + /// + /// Every seam AddGenWaveOrchestration itself does not supply (AC6/AC7's own arrange) — the 12 + /// required Orchestrator constructor params it resolves via GetRequiredService, plus + /// IMediaCatalog/ILogger<MusicSelectionPolicy> so its own TryAddSingleton<MusicSelectionPolicy> + /// can activate. TimeProvider/SpeechDeferralQueue/MusicSelectionPolicy stay unregistered here on + /// purpose — AddGenWaveOrchestration TryAdds all three itself. + /// + static ServiceCollection RequiredSeamServices() + { + var services = new ServiceCollection(); + + services.AddSingleton(new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default"))); + services.AddSingleton(new FakeStationScopeProvider(new LibraryScope([1L]))); + services.AddSingleton(new FakeCadenceProvider(new CadenceConfig + { + LeadInBeforeEachTrack = false, + BackAnnounceAfterEachTrack = false, + StationIdEveryNUnits = 0, + })); + services.AddSingleton(new FakeRotationSettingsProvider(new RotationSettings())); + services.AddSingleton(new FakeTtsSegmentSource()); + services.AddSingleton(new FakeActivePersonaAccessor()); + services.AddSingleton(new FakeRenderBudgetProvider(TimeSpan.FromSeconds(30))); + services.AddSingleton(new FakeBoundaryBiasProvider(TimeSpan.Zero)); + services.AddSingleton>(NullLogger.Instance); + services.AddSingleton>(NullLogger.Instance); + services.AddSingleton(new FakeMediaCatalog(TestData.MakeTrackRef("t1"))); + + return services; + } + + // --------------------------------------------------------------------- + // HAPPY PATH + // --------------------------------------------------------------------- + + public sealed class ScenarioTheBuilderWithDefaults + { + readonly OrchestratorChain chain; + + // Given: new OrchestratorBuilder().Build() + public ScenarioTheBuilderWithDefaults() + { + chain = new OrchestratorBuilder().Build(); + } + + /// AC1 — the chain record carries the Orchestrator + [Fact] + public void ExposesTheOrchestrator() => Assert.NotNull(chain.Orchestrator); + + /// AC1 — the deferral queue is the one the Orchestrator was built with + [Fact] + public void ExposesTheDeferralQueue() => Assert.NotNull(chain.Queue); + + /// AC1 — a FakeTimeProvider drives the chain by default + [Fact] + public void ExposesTheFakeClock() => Assert.IsType(chain.Time); + + /// AC1 — the tts fake is reachable for assertions by default + [Fact] + public void ExposesTheTtsFake() => Assert.IsType(chain.Tts); + + /// AC1 — the capturing event sink is reachable by default + [Fact] + public void ExposesTheEventSink() => Assert.IsType(chain.Events); + + /// AC1 — the fake catalog is reachable by default + [Fact] + public void ExposesTheCatalog() => Assert.IsType(chain.Catalog); + + /// + /// AC1 — the estimator slot is reachable. Its default is deliberately null, not a fake — see + /// 's own remarks: the one estimator double this project owns + /// () is not behaviorally equivalent to + /// Orchestrator's own internal fallback, so defaulting to it here would silently change + /// boundary-fit behavior for every spec that never overrides this seam. + /// + [Fact] + public void ExposesThePatterEstimator() => Assert.Null(chain.PatterEstimator); + } + + public sealed class ScenarioAnOverriddenAdSpotVend : IAsyncLifetime + { + readonly FakeAdSpotVend vend = new() + { + Answer = new MediaItem( + "spot-1", "/authored/ads/spot-1.wav", "Spot spot-1", + new Loudness(-14.0, -1.0, true), + SegmentKind: SegmentKind.Ad), + }; + + // Given: builder.WithAdSpotVend(fake) and ad cadence every unit; two units served + public async Task InitializeAsync() + { + var chain = new OrchestratorBuilder() + .WithAdCadence(new FakeAdCadenceProvider(1)) + .WithAdSpotVend(vend) + .Build(); + + var ctx = new PlayoutContext([]); + await chain.Orchestrator.GetNextAsync(ctx, CancellationToken.None); // unit 0 — no trigger + await chain.Orchestrator.GetNextAsync(ctx, CancellationToken.None); // unit 1 — fires + } + + public Task DisposeAsync() => Task.CompletedTask; + + /// AC2 — the seam override reaches the Orchestrator + [Fact] + public void TheFakeReceivedOneVend() => Assert.Equal(1, vend.CallCount); + } + + public sealed class ScenarioTheHarnessRidesTheBuilder + { + // Given: ProductionChainHarness rewritten as a builder call — reruns the exact two-DJ + // noon-boundary shape Story243_DjsHandOffAudibly.cs/Story307_CeremonyNamesTheShow.cs each + // exercise through ProductionChainHarness.BuildProductionChain directly (rather than their own + // inline copy), pinning the resulting unit sequence so a future change to the builder's + // wiring order trips this fact first. + + static readonly DayOfWeek Monday = new DateTimeOffset(2026, 3, 2, 0, 0, 0, TimeSpan.Zero).DayOfWeek; + static readonly DateTimeOffset JustBeforeNoon = new(2026, 3, 2, 11, 55, 0, TimeSpan.Zero); + + static ScheduleWeekSnapshot TwoDjSchedule() => new( + [ + new ScheduleSegment(Id: 1, Day: Monday, StartMinute: 0, EndMinute: 720, PersonaId: 10, Genres: null, EnergyMin: null, EnergyMax: null), + new ScheduleSegment(Id: 2, Day: Monday, StartMinute: 720, EndMinute: 1440, PersonaId: 20, Genres: null, EnergyMin: null, EnergyMax: null), + ]); + + static FakePersonaStore TwoDjStore() + { + var store = new FakePersonaStore(); + store.Add(TestData.MakePersona(10, "DJ Alpha", "af_alpha")); + store.Add(TestData.MakePersona(20, "DJ Beta", "af_beta")); + return store; + } + + static bool IsSignOff(MediaItem item) => + item.MediaId.StartsWith("tts:signoff", StringComparison.OrdinalIgnoreCase); + + static bool IsSignOn(MediaItem item) => + item.MediaId.StartsWith("tts:signon", StringComparison.OrdinalIgnoreCase); + + static string Classify(MediaItem item) => + IsSignOff(item) ? "SignOff" : IsSignOn(item) ? "SignOn" : "Music"; + + /// AC3 — the four harness specs' unit order is unchanged + [Fact] + public async Task ProducesTheSameUnitOrderAsBefore() + { + var chain = ProductionChainHarness.BuildProductionChain( + TwoDjStore(), TwoDjSchedule(), JustBeforeNoon, TimeSpan.FromMinutes(10)); + + var kinds = new List(); + for (var i = 0; i < 12; i++) + { + var item = await chain.Orchestrator.GetNextAsync(new PlayoutContext([]), CancellationToken.None) + ?? throw new InvalidOperationException("Expected GetNextAsync to produce a media item."); + kinds.Add(Classify(item)); + chain.Time.Advance(TimeSpan.FromSeconds(30)); + } + + Assert.Equal( + [ + "Music", "Music", "Music", "Music", "Music", "Music", + "Music", "SignOff", "SignOn", "Music", "Music", "Music", + ], + kinds); + } + } + + public sealed class ScenarioAddOrchestrationWithNoOptionalSeams : IAsyncLifetime + { + readonly List served = []; + INextItemProvider? orchestrator; + + // Given: ServiceCollection + AddGenWaveOrchestration, no IAdSpotVend registered — the ad + // cadence still fires every unit, so a real vend WOULD be reachable if one were wired. + public async Task InitializeAsync() + { + var services = RequiredSeamServices(); + services.AddSingleton(new FakeAdCadenceProvider(1)); + services.AddGenWaveOrchestration(); + + var provider = services.BuildServiceProvider().GetRequiredService(); + orchestrator = provider; + + var ctx = new PlayoutContext([]); + served.Add(await provider.GetNextAsync(ctx, CancellationToken.None)); // unit 0 — no trigger + served.Add(await provider.GetNextAsync(ctx, CancellationToken.None)); // unit 1 — fires + } + + public Task DisposeAsync() => Task.CompletedTask; + + /// AC6 — resolution succeeds without the optional seam + [Fact] + public void ResolvesTheNextItemProvider() => Assert.NotNull(orchestrator); + + /// AC6 — the Orchestrator's vend is NoOpAdSpotVend: no ad ever airs + [Fact] + public void BuiltWithTheNoOpVend() => Assert.DoesNotContain(served, item => item?.SegmentKind == SegmentKind.Ad); + } + + public sealed class ScenarioAddOrchestrationWithARegisteredSeam : IAsyncLifetime + { + readonly FakeAdSpotVend vend = new() + { + Answer = new MediaItem( + "spot-1", "/authored/ads/spot-1.wav", "Spot spot-1", + new Loudness(-14.0, -1.0, true), + SegmentKind: SegmentKind.Ad), + }; + + // Given: a fake IAdSpotVend registered before AddGenWaveOrchestration — AddGenWaveOrchestration's + // own TryAddSingleton(NoOpAdSpotVend.Instance) must lose to it. + public async Task InitializeAsync() + { + var services = RequiredSeamServices(); + services.AddSingleton(new FakeAdCadenceProvider(1)); + services.AddSingleton(vend); + services.AddGenWaveOrchestration(); + + var orchestrator = services.BuildServiceProvider().GetRequiredService(); + + var ctx = new PlayoutContext([]); + await orchestrator.GetNextAsync(ctx, CancellationToken.None); // unit 0 — no trigger + await orchestrator.GetNextAsync(ctx, CancellationToken.None); // unit 1 — fires + } + + public Task DisposeAsync() => Task.CompletedTask; + + /// AC7 — the registered fake wins over the NoOp + [Fact] + public void BuiltWithTheRegisteredVend() => Assert.Equal(1, vend.CallCount); + } + + public sealed class ScenarioTheRecountAfterTheMove + { + // Given: the Orchestration.Tests assembly reflected — every [Fact]/[Theory]-decorated METHOD + // (not each Theory row) counted once, mirroring how the pre-move baseline was measured. + + const int FactMethodCount = 556; // reflected [Fact]/[Theory] methods, measured before T512 moved any site (the move adds and removes no attribute, so pre = post); the runner reports 562 cases = 556 + theory rows + + static IReadOnlyList<(MethodInfo Method, FactAttribute Attribute)> ReflectFactMethods() => + typeof(FeatureOneConstructionPath).Assembly + .GetTypes() + .SelectMany(t => t.GetMethods( + BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)) + .SelectMany(m => m.GetCustomAttributes().OfType() + .Select(a => (Method: m, Attribute: a))) + .ToList(); + + /// AC9 — no fact was lost in the migration + [Fact] + public void KeepsThePreMoveFactCount() => + Assert.Equal(FactMethodCount, ReflectFactMethods().Count); + + /// AC9 — no fact is skipped outside the STORY-451…459 pendings + [Fact] + public void SkipsNothingOutsideTheEpicPendings() + { + var skipsOutsideTheEpic = ReflectFactMethods() + .Select(x => x.Attribute.Skip) + .Where(skip => skip is not null && !Regex.IsMatch(skip, "STORY-45[1-9]")); + + Assert.Empty(skipsOutsideTheEpic); + } + } +} diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story452_BreakCharacterisationReplay.cs b/tests/GenWave.Orchestration.Tests/Specs/Story452_BreakCharacterisationReplay.cs new file mode 100644 index 00000000..67372238 --- /dev/null +++ b/tests/GenWave.Orchestration.Tests/Specs/Story452_BreakCharacterisationReplay.cs @@ -0,0 +1,299 @@ +// STORY-452 — The break characterisation replay (gh-#401 · SPEC F185 · PLAN T515, T522) +// +// BDD specification — xUnit. FROZEN for gh-#401 (PLAN T515): AC1–AC4 pin four tables — buffer +// order by media id, DjName per item, event kinds in order, and WARN+ messages — each OBSERVED +// from ONE deterministic run of the F185.1 script through OrchestratorBuilder against the real, +// unsplit Orchestrator (RunScriptAsync below). Once T515 lands, no PR inside gh-#401 may change an +// assertion in THIS file or in Story452_PinnedTables.cs. The ONLY exception is PLAN T522 (PR-3), +// which may un-skip AC6 (MatchThePinnedTraceTable) and add BreakPlan.ToTrace() once BreakPlan +// exists. Any other change to this pair must name the behaviour change in its own PR title and in +// SPEC F186. +// +// One script, served unit by unit on a shared FakeTimeProvider: back-announce + lead-in on, +// station-id cadence hit, ad cadence hit, two pending owner announcements, a vended crosstalk +// exchange, a due context segment, time/date at on-time/late/expired, a show boundary with a +// straddling track (sign-off drains, sign-on holds), a ceremony-only finale, one over-budget TTS +// render (dropped), one null TTS render (dropped). +// +// Disclosed deviations from the SPEC F185.1 "six music units" headline: +// - Nine units, not six: the over-budget render and the null render each get their own isolated +// unit (every other cadence off) so neither drop is masked by an unrelated element sharing the +// pull; the ceremony-only finale is its own unit by construction (SPEC F124/gh-#300). +// - The crosstalk asset lives at a FIXED path this file writes before the run, not STORY-329's +// Path.GetTempFileName() idiom — a random name would leak into the crosstalk item's MediaId +// (tts:crosstalk:{asset filename}), breaking AC1's determinism across runs. +// - The "over-budget render" beat is a sign-off, not an owner announcement: the announcement +// renderer double completes synchronously with no delay mechanism to race a budget against, +// while FakeTtsSegmentSource.RenderDelay does. + +using System.Runtime.CompilerServices; +using GenWave.Core.Abstractions; +using GenWave.Core.Domain; +using GenWave.Orchestration.Tests.Fakes; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; + +namespace GenWave.Orchestration.Tests.Specs; + +public static class FeatureBreakCharacterisationReplay +{ + const long HostPersonaId = 10; + const long NeighborPersonaId = 20; + + /// The one handoff pairing every ceremony beat (O5, O6, O9) hands off across. + static readonly HandoffContext Handoff = new("af_flip", "Nova", "Milo"); + + /// Everything AC1–AC4 assert on, captured from one deterministic run of the script. + sealed record ReplayResult( + IReadOnlyList BufferedMediaIds, + IReadOnlyList DjNames, + IReadOnlyList EventKinds, + IReadOnlyList Warnings); + + /// + /// Builds the F185.1 script through against the real, unsplit + /// and serves it unit by unit — see this file's header for the + /// nine-vs-six-units disclosure. + /// + static async Task RunScriptAsync() + { + var clock = new FakeTimeProvider(new DateTimeOffset(2026, 3, 2, 10, 0, 0, TimeSpan.Zero)); + var cadence = new FakeCadenceProvider(new CadenceConfig + { + LeadInBeforeEachTrack = true, + BackAnnounceAfterEachTrack = true, + StationIdEveryNUnits = 3, + }); + var adCadence = new FakeAdCadenceProvider(2); + var adSpotVend = new FakeAdSpotVend(); + var announcementSource = new FakeAnnouncementSource(); + var announcementRenderer = new FakeVerbatimSegmentRenderer(); + var fakeTts = new FakeTtsSegmentSource { TimeProvider = clock }; + var events = new CapturingStationEventSink(); + var logger = new CapturingLogger(); + + var personaStore = new FakePersonaStore(); + personaStore.Add(TestData.MakePersona(HostPersonaId, "Nova", "af_nova")); + personaStore.Add(TestData.MakePersona(NeighborPersonaId, "Milo", "af_milo")); + + var week = new ScheduleWeekSnapshot([ + new ScheduleSegment(1, DayOfWeek.Monday, 0, 720, HostPersonaId, null, null, null, + Show: new ShowSummary(5, "Morning Mix", null, null) { Slug = "morning-mix" }, ShowId: 5), + new ScheduleSegment(2, DayOfWeek.Monday, 720, 1440, NeighborPersonaId, null, null, null), + ]); + + var crosstalkScope = new FakeCrosstalkScopeProvider(["morning-mix"], everyNthAiring: 1); + var crosstalkPlanner = new CrosstalkPlanner(personaStore, crosstalkScope, NullLogger.Instance); + var crosstalkAssetPath = Path.Combine(Path.GetTempPath(), "genwave-story452-crosstalk.wav"); + File.WriteAllBytes(crosstalkAssetPath, [0]); + + var contextSettings = new FakeContextSettingsProvider(); + var imaging = new FakeStationImagingSettingsProvider { Current = new StationImagingSettings(false, false, 180) }; + + var catalog = FakeMediaCatalog.WithPool([ + TestData.MakeTrackRef("t1"), TestData.MakeTrackRef("t2"), TestData.MakeTrackRef("t3"), + TestData.MakeTrackRef("t4"), TestData.MakeTrackRef("t5"), + TestData.MakeTrackRef("t6-straddle") with { DurationMs = 540_000 }, + ]); + + var chain = new OrchestratorBuilder() + .WithTime(clock) + .WithSchedule(week) + .WithPersonaStore(personaStore) + .WithCadence(cadence) + .WithAdCadence(adCadence) + .WithAdSpotVend(adSpotVend) + .WithAnnouncementSource(announcementSource) + .WithAnnouncementRenderer(announcementRenderer) + .WithCrosstalkPlanner(crosstalkPlanner) + .WithContextSettings(contextSettings) + .WithImagingSettings(imaging) + .WithCatalog(catalog) + .WithTts(fakeTts) + .WithEvents(events) + .WithLogger(logger) + .WithLookahead(TimeSpan.FromMinutes(10)) + .Build(); + + var orchestrator = chain.Orchestrator; + var queue = chain.Queue; + + async Task PullOneAsync(PlayoutContext? ctx = null) + { + var item = await orchestrator.GetNextAsync(ctx ?? new PlayoutContext([]), CancellationToken.None); + Assert.NotNull(item); + return item; + } + + async Task> PullManyAsync(int count, PlayoutContext? ctx = null) + { + var items = new List(); + for (var i = 0; i < count; i++) items.Add(await PullOneAsync(ctx)); + return items; + } + + // Races the sign-off render (RenderDelay=10s) against the default 5s render budget by + // advancing the SAME clock the budget's own timer and the render's own Task.Delay share + // (mirrors STORY-243/STORY-442's own racing idiom). + async Task PullRacingBudgetAsync() + { + var task = orchestrator.GetNextAsync(new PlayoutContext([]), CancellationToken.None); + for (var i = 0; i < 8 && !task.IsCompleted; i++) + { + clock.Advance(TimeSpan.FromSeconds(1)); + await Task.Yield(); + } + + var item = await task; + Assert.NotNull(item); + return item; + } + + // O1 — two pending owner announcements drain (SPEC F144.1) alongside the opening lead-in; + // no back-announce yet (no previous track). + announcementSource.Pending.Enqueue(new AnnouncementItem(9001, "Happy birthday to Sam!", Verbatim: true, RequestedVoice: null)); + announcementSource.Pending.Enqueue(new AnnouncementItem(9002, "Pledge drive ends Friday.", Verbatim: true, RequestedVoice: null)); + var o1 = await PullManyAsync(4); + + // O2 — a stocked crosstalk exchange vends for the enabled "morning-mix" show (SPEC F127.1). + crosstalkPlanner.Stock(new StockedCrosstalkExchange("morning-mix", + new CrosstalkCast(HostPersonaId, NeighborPersonaId), crosstalkAssetPath, new Loudness(-16.0, -1.0, true), Cue: null, DurationMs: 6_000)); + var o2 = await PullManyAsync(4); + + // O3 — the ad cadence (every 2 units) hits (SPEC F158.1). + adSpotVend.Answer = new MediaItem("ad-spot-1", "/ads/spot1.mp3", "Ad Spot", new Loudness(-16.0, -1.0, true)); + var o3 = await PullManyAsync(4); + + // O4 — the station-id cadence (every 3 units) hits alongside a due, fresh context segment + // (SPEC F107.3). + queue.Enqueue(SpeechDeferralKind.Context, "test: weather due", due: clock.GetUtcNow(), discriminator: "weather", + context: new ContextSegmentFacts("Sunny and mild, 70F.", clock.GetUtcNow().AddMinutes(10))); + var o4 = await PullManyAsync(5); + + // O5 — an isolated sign-off whose render exceeds the render budget and is dropped (SPEC + // F92.4). Every other cadence is off for this one unit so the sign-off is the ONLY pending + // render; the ad cadence is also permanently zeroed here — its one hit at O3 already proved + // the element, and leaving it on would refire later and collide with O7/O8's own design. + cadence.Cadence = new CadenceConfig { LeadInBeforeEachTrack = false, BackAnnounceAfterEachTrack = false, StationIdEveryNUnits = 0 }; + adCadence.EveryNUnits = 0; + fakeTts.RenderDelay = TimeSpan.FromSeconds(10); + queue.Enqueue(SpeechDeferralKind.SignOff, "test: over-budget signoff", due: clock.GetUtcNow(), handoff: Handoff); + var o5 = new List { await PullRacingBudgetAsync() }; + fakeTts.RenderDelay = null; + + // O6 — a show-boundary straddle: the sign-off drains now (due in 6 minutes, inside the + // 10-minute lookahead), the paired sign-on holds, and the boundary-crossing 9-minute track + // straddles it (SPEC F111/F124). Lead-in/back-announce return; station-id stays off for the + // rest of the script — its one hit at O4 already proved the element. + cadence.Cadence = new CadenceConfig { LeadInBeforeEachTrack = true, BackAnnounceAfterEachTrack = true, StationIdEveryNUnits = 0 }; + var signOffDue = clock.GetUtcNow() + TimeSpan.FromMinutes(6); + queue.Enqueue(SpeechDeferralKind.SignOff, "test: straddle signoff", due: signOffDue, handoff: Handoff); + queue.Enqueue(SpeechDeferralKind.SignOn, "test: straddle signon", due: signOffDue + TimeSpan.FromSeconds(15), handoff: Handoff); + var o6 = await PullManyAsync(4); + + // O7 — the held sign-on drains once its due time passes, alongside a context segment whose + // render returns null and is dropped (SPEC F107.6). + clock.Advance(TimeSpan.FromMinutes(7)); + fakeTts.ShouldReturnNull = request => request.Kind == SegmentKind.ContextSegment; + queue.Enqueue(SpeechDeferralKind.Context, "test: null-render context", due: clock.GetUtcNow(), discriminator: "traffic", + context: new ContextSegmentFacts("Heavy traffic downtown.", clock.GetUtcNow().AddMinutes(10))); + var o7 = await PullManyAsync(4); + fakeTts.ShouldReturnNull = null; + + // O8 — three time/date deferrals with distinct discriminators (so none supersedes another — + // SpeechDeferralQueue's own (Kind, Discriminator) key): on-time, late (past the 90s honesty + // threshold but inside the 180s budget), and expired (past the budget — dropped undrained, + // SPEC F141.2). + var timeDateNow = clock.GetUtcNow(); + queue.Enqueue(SpeechDeferralKind.TimeDate, "test: on-time", due: timeDateNow, discriminator: "ontime"); + queue.Enqueue(SpeechDeferralKind.TimeDate, "test: late", due: timeDateNow - TimeSpan.FromSeconds(120), discriminator: "late"); + queue.Enqueue(SpeechDeferralKind.TimeDate, "test: expired", due: timeDateNow - TimeSpan.FromSeconds(300), discriminator: "expired"); + var o8 = await PullManyAsync(5); + + // O9 — a ceremony-only finale (SPEC F124/gh-#300): with lead-in/back-announce off (matching + // gh-#300's own arrange — a pending back-announce would otherwise air INSTEAD of the + // ceremony), the queued-ahead tail already crosses the 45s-out boundary, so the decline + // check fires before any music is planned: a single spoken segment (sign-off; the paired + // sign-on holds), drawing nothing from the catalog. + cadence.Cadence = new CadenceConfig { LeadInBeforeEachTrack = false, BackAnnounceAfterEachTrack = false, StationIdEveryNUnits = 0 }; + queue.Enqueue(SpeechDeferralKind.SignOff, "test: decline signoff", due: clock.GetUtcNow() + TimeSpan.FromSeconds(30), handoff: Handoff); + queue.Enqueue(SpeechDeferralKind.SignOn, "test: decline signon", due: clock.GetUtcNow() + TimeSpan.FromSeconds(45), handoff: Handoff); + var o9 = await PullManyAsync(1, new PlayoutContext([], QueuedAheadMs: 200_000)); + + List[] units = [o1, o2, o3, o4, o5, o6, o7, o8, o9]; + return new ReplayResult( + [.. units.SelectMany(unit => unit.Select(item => item.MediaId))], + [.. units.SelectMany(unit => unit.Select(item => item.DjName ?? "(none)"))], + [.. events.Events.Select(evt => evt.GetType().Name)], + [.. logger.Warnings]); + } + + public sealed class ScenarioTheScriptServedEndToEnd + { + // Given: the F185.1 script through the builder, every unit served (RunScriptAsync above) + + /// AC1 — media ids per unit equal the pinned table. + [Fact] + public async Task BuffersTheUnitsInThePinnedOrder() + { + var result = await RunScriptAsync(); + + Assert.Equal(Story452PinnedTables.BufferedMediaIds, result.BufferedMediaIds); + } + + /// AC2 — DjName per buffered item equals the pinned table. + [Fact] + public async Task StampsThePinnedDjNames() + { + var result = await RunScriptAsync(); + + Assert.Equal(Story452PinnedTables.DjNames, result.DjNames); + } + + /// AC3 — event kinds in order equal the pinned list. + [Fact] + public async Task PublishesThePinnedEventKinds() + { + var result = await RunScriptAsync(); + + Assert.Equal(Story452PinnedTables.EventKinds, result.EventKinds); + } + + /// AC4 — WARN+ messages equal the pinned list. + [Fact] + public async Task LogsThePinnedWarnings() + { + var result = await RunScriptAsync(); + + Assert.Equal(Story452PinnedTables.Warnings, result.Warnings); + } + } + + public sealed class ScenarioTheFileHeader + { + // Given: this file's own leading comment + + /// AC5 — the header states the freeze and names gh-#401 and the one PR (T522) that may add an assertion. + [Fact] + public void DeclaresTheFreeze() + { + var header = ReadOwnSource(); + + Assert.True(header.Contains("FROZEN", StringComparison.Ordinal) + && header.Contains("gh-#401", StringComparison.Ordinal) && header.Contains("T522", StringComparison.Ordinal)); + } + + static string ReadOwnSource([CallerFilePath] string path = "") => File.ReadAllText(path); + } + + public sealed class ScenarioThePlanTraces + { + const string Pending = "pending: T522 — BreakPlan.ToTrace() per unit (STORY-452)"; + + // Given: the same run once BreakPlan exists (T522) + + /// AC6 — ToTrace() per unit equals the pinned table. + [Fact(Skip = Pending)] + public void MatchThePinnedTraceTable() => throw new NotImplementedException(Pending); + } +} diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story452_PinnedTables.cs b/tests/GenWave.Orchestration.Tests/Specs/Story452_PinnedTables.cs new file mode 100644 index 00000000..48400fa0 --- /dev/null +++ b/tests/GenWave.Orchestration.Tests/Specs/Story452_PinnedTables.cs @@ -0,0 +1,80 @@ +// STORY-452 — pinned trace tables for the break characterisation replay (gh-#401 · PLAN T515, T522) +// +// FROZEN alongside Story452_BreakCharacterisationReplay.cs — see that file's header for the exact +// freeze rule (only PLAN T522 may touch either file, and only to add the AC6 trace table). These +// four tables are OBSERVED output from one deterministic run of that file's RunScriptAsync(), never +// hand-designed values. + +namespace GenWave.Orchestration.Tests.Specs; + +static class Story452PinnedTables +{ + public static readonly string[] BufferedMediaIds = + [ + // O1 — two pending owner announcements + the opening lead-in (no back-announce yet) + "tts:announcement:9001:tts:1", + "tts:announcement:9002:tts:2", + "tts:leadin-1", + "t1", + // O2 — the vended crosstalk exchange + "tts:backannounce-2", + "tts:crosstalk:genwave-story452-crosstalk", + "tts:leadin-3", + "t2", + // O3 — the ad cadence hit + "tts:backannounce-4", + "ad-spot-1", + "tts:leadin-5", + "t3", + // O4 — the station-id cadence hit + a due context segment + "tts:backannounce-6", + "tts:stationid-7", + "tts:contextsegment-8", + "tts:leadin-9", + "t4", + // O5 — the sign-off render exceeds the render budget and is dropped; the fallback track airs + "t5", + // O6 — the show-boundary straddle: sign-off drains, sign-on holds, the track crosses the boundary + "tts:backannounce-11", + "tts:signoff-12", + "tts:leadin-13", + "t6-straddle", + // O7 — the held sign-on drains; the context segment's render returns null and is dropped + "tts:backannounce-14", + "tts:signon-15", + "tts:leadin-17", + "t5", + // O8 — on-time and late time/date deferrals render; the expired one is dropped undrained + // (on-time and late are indistinguishable here: only their count and the expired drop are pinned, + // so row order never proves the honesty threshold) + "tts:backannounce-18", + "tts:timedate-19", + "tts:timedate-20", + "tts:leadin-21", + "t6-straddle", + // O9 — the ceremony-only finale: the decline fires before any music is planned + "tts:signoff-22", + ]; + + public static readonly string[] DjNames = + [ + "Nova", "Nova", "(none)", "Nova", // O1 + "(none)", "Nova", "(none)", "Nova", // O2 + "(none)", "Nova", "(none)", "Nova", // O3 + "(none)", "Nova", "(none)", "(none)", "Nova", // O4 + "Nova", // O5 + "(none)", "(none)", "(none)", "Nova", // O6 + "(none)", "(none)", "(none)", "Nova", // O7 + "(none)", "(none)", "(none)", "(none)", "Nova", // O8 + "(none)", // O9 + ]; + + public static readonly string[] EventKinds = ["HandoffPieceDropped"]; + + public static readonly string[] Warnings = + [ + "Handoff piece SignOff dropped (render budget exceeded) — that half of the ceremony airs nothing; the other piece still airs if it rendered, and the next boundary retries the full ceremony (SPEC F92.4).", + "Context segment for provider traffic dropped (render returned null) — no context item reaches air this boundary; music continues, and the next drain retries (SPEC F107.6).", + "TimeDate deferral armed for 10:02 dropped undrained — 300s past its armed hour (budget 180s); a late time check would invent the hour.", + ]; +} diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContract.cs b/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContract.cs new file mode 100644 index 00000000..b6f4c387 --- /dev/null +++ b/tests/GenWave.Orchestration.Tests/Specs/Story453_BreakContract.cs @@ -0,0 +1,260 @@ +// 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), drops AC18–AC23 +// (T518). 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. +// +// RED at plan time: every fact is [Fact(Skip = Pending)] with a loud body — remove the Skip only in the +// task that makes it green. The builder comments name the arrange each scenario needs. + +namespace GenWave.Orchestration.Tests.Specs; + +public static class FeatureBreakContract +{ + const string Pending = "pending: T517 — every F186 row and collision pinned through the builder (STORY-453)"; + const string Manual = "manual: two follow-up issues named in SPEC F186.4 — review evidence on PR-2 (STORY-453)"; + + // --------------------------------------------------------------------- + // 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); + } + + public sealed class ScenarioTheFirstUnit + { + // Given: no previous track + + /// AC2 — nothing to announce + [Fact(Skip = Pending)] + public void BuffersNoBackAnnounce() => Assert.Fail(Pending); + } + + 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); + } + + 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); + } + + public sealed class ScenarioThreePendingAnnouncements + { + // Given: three claimable announcements + + /// AC5 — the cap is two per break + [Fact(Skip = Pending)] + public void BuffersExactlyTwo() => Assert.Fail(Pending); + } + + public sealed class ScenarioStationIdCadenceHit + { + // Given: StationIdEveryNUnits = 2, unit 2 + + /// AC6 — the cadence enqueues a StationId + [Fact(Skip = Pending)] + public void BuffersAStationIdAfterTheAnnouncements() => Assert.Fail(Pending); + } + + 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); + } + + public sealed class ScenarioStationIdAndAdDueTogether + { + // Given: both cadences hit the same unit + + /// AC8 — the drain tiebreak + [Fact(Skip = Pending)] + public void StationIdPrecedesTheAd() => Assert.Fail(Pending); + } + + 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); + } +} diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story454_ThePlanIsAValue.cs b/tests/GenWave.Orchestration.Tests/Specs/Story454_ThePlanIsAValue.cs new file mode 100644 index 00000000..14265217 --- /dev/null +++ b/tests/GenWave.Orchestration.Tests/Specs/Story454_ThePlanIsAValue.cs @@ -0,0 +1,108 @@ +// STORY-454 — The plan is a value (gh-#401 · SPEC F187 · PLAN T520) +// +// BDD specification — xUnit. AC1/AC2 reflect the types; AC3–AC8 build plans by hand and read ordinals, traces, reservations and speakers. +// +// RED at plan time: every fact is [Fact(Skip = Pending)] with a loud body — remove the Skip only in the +// task that makes it green. The builder comments name the arrange each scenario needs. + +namespace GenWave.Orchestration.Tests.Specs; + +public static class FeatureThePlanIsAValue +{ + const string Pending = "pending: T520 — BreakPlan, PlannedSlot, SlotSource, Reservation, BreakContext, ToTrace() (STORY-454)"; + + // --------------------------------------------------------------------- + // HAPPY PATH + // --------------------------------------------------------------------- + + public sealed class ScenarioThePlanTypesReflected + { + // Given: typeof(BreakPlan), PlannedSlot, Reservation, BreakContext + + /// AC1 — + [Fact(Skip = Pending)] + public void BreakPlanIsAPublicSealedRecord() => Assert.Fail(Pending); + + /// AC1 — + [Fact(Skip = Pending)] + public void PlannedSlotIsAPublicSealedRecord() => Assert.Fail(Pending); + + /// AC1 — + [Fact(Skip = Pending)] + public void ReservationIsAPublicSealedRecord() => Assert.Fail(Pending); + + /// AC1 — + [Fact(Skip = Pending)] + public void BreakContextIsAPublicSealedRecord() => Assert.Fail(Pending); + } + + public sealed class ScenarioTheClosedHierarchies + { + // Given: every subtype of SlotSource and SlotOutcome in the assembly + + /// AC2 — Render, Verbatim, Ready + [Fact(Skip = Pending)] + public void EverySlotSourceIsSealed() => Assert.Fail(Pending); + + /// AC2 — Rendered, TimedOut, Failed, Abandoned + [Fact(Skip = Pending)] + public void EverySlotOutcomeIsSealed() => Assert.Fail(Pending); + } + + public sealed class ScenarioAThreeSlotPlan + { + // Given: a plan with three slots + + /// AC3 — ordinals 1, 2, 3 in list order + [Fact(Skip = Pending)] + public void NumbersThemFromOne() => Assert.Fail(Pending); + } + + public sealed class ScenarioARenderSlotTrace + { + // Given: Render slot LeadIn, speaker Ada, no reservation + + /// AC4 — #1 LeadIn render speaker=Ada res=- + [Fact(Skip = Pending)] + public void PrintsTheLine() => Assert.Fail(Pending); + } + + public sealed class ScenarioAReservedReadySlotTrace + { + // Given: Ready slot Ad with reservation AdSpot:42 + + /// AC5 — #1 Ad ready speaker=- res=AdSpot:42 + [Fact(Skip = Pending)] + public void PrintsTheLine() => Assert.Fail(Pending); + } + + public sealed class ScenarioTheEmptyPlan + { + // Given: a plan with no slots + + /// AC6 — (empty) + [Fact(Skip = Pending)] + public void PrintsEmpty() => Assert.Fail(Pending); + } + + public sealed class ScenarioAPlanFromTheScript + { + // Given: the F185.1 break planned + + /// AC7 — BackAnnounce, LeadIn, SignOff, SignOn, TimeDate, Context + [Fact(Skip = Pending)] + public void UnclaimedKindsCarryNoReservation() => Assert.Fail(Pending); + + /// AC7 — every other slot + [Fact(Skip = Pending)] + public void ClaimedKindsCarryOne() => Assert.Fail(Pending); + + /// AC8 — Render and Verbatim + [Fact(Skip = Pending)] + public void SpokenSlotsCarryASpeaker() => Assert.Fail(Pending); + + /// AC8 — + [Fact(Skip = Pending)] + public void ReadySlotsCarryNone() => Assert.Fail(Pending); + } +} diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story455_BreakPlanner.cs b/tests/GenWave.Orchestration.Tests/Specs/Story455_BreakPlanner.cs new file mode 100644 index 00000000..feb925f4 --- /dev/null +++ b/tests/GenWave.Orchestration.Tests/Specs/Story455_BreakPlanner.cs @@ -0,0 +1,84 @@ +// STORY-455 — BreakPlanner decides the break (gh-#401 · SPEC F188 · PLAN T521–T523) +// +// BDD specification — xUnit. AC1–AC4 drive BreakPlanner directly with recording/throwing/counting fakes; AC5 is the STORY-452 replay +// through the Orchestrator that consumes the plan; AC6 reflects the deleted methods (T522). +// +// RED at plan time: every fact is [Fact(Skip = Pending)] with a loud body — remove the Skip only in the +// task that makes it green. The builder comments name the arrange each scenario needs. + +namespace GenWave.Orchestration.Tests.Specs; + +public static class FeatureBreakPlanner +{ + const string Pending = "pending: T521 — BreakPlanner.PlanAsync performs today's steps and drains in today's order (STORY-455)"; + + // --------------------------------------------------------------------- + // HAPPY PATH + // --------------------------------------------------------------------- + + public sealed class ScenarioTheScriptPlanned + { + // Given: PlanAsync per unit of the F185.1 script + + /// AC1 — slot kinds in order equal the trace table + [Fact(Skip = Pending)] + public void YieldsThePinnedSlotKinds() => Assert.Fail(Pending); + } + + public sealed class ScenarioThrowingRenderFakes + { + // Given: ITtsSegmentSource, IVerbatimSegmentRenderer and the copy writer throw when called + + /// AC2 — the plan phase is render-free + [Fact(Skip = Pending)] + public void NoFakeWasCalled() => Assert.Fail(Pending); + } + + public sealed class ScenarioRecordingSideEffectFakes + { + // Given: crosstalk, announcements, deferral queue, catalog, ad vend record their calls + + /// AC3 — MarkVended, claim, StationId enqueue, Ad enqueue, drain, pool lookup, vend + [Fact(Skip = Pending)] + public void RecordsTodaysOrder() => Assert.Fail(Pending); + } + + public sealed class ScenarioCountingBudgetProviders + { + // Given: budget providers count their reads + + /// AC4 — + [Fact(Skip = Pending)] + public void ReadsEachOnce() => Assert.Fail(Pending); + + /// AC4 — + [Fact(Skip = Pending)] + public void CarriesTheRenderBudgetOnThePlan() => Assert.Fail(Pending); + } + + public sealed class ScenarioTheOrchestratorRidesThePlan + { + // Given: the STORY-452 replay after T522 + + /// AC5 — + [Fact(Skip = Pending)] + public void KeepsEveryFrozenAssertionGreen() => Assert.Fail(Pending); + + /// AC5 — + [Fact(Skip = Pending)] + public void MatchesThePinnedTraces() => Assert.Fail(Pending); + } + + public sealed class ScenarioTheOldPathReflected + { + // Given: typeof(Orchestrator) non-public methods + + /// AC6 — + [Fact(Skip = Pending)] + public void HasNoEnqueuePatterAsync() => Assert.Fail(Pending); + + /// AC6 — BuildStationIdRequest, BuildAdRequest, BuildHandoffRequest, BuildTimeDateRequest, BuildContextSegmentRequestAsync + [Fact(Skip = Pending)] + public void HasNoBuildRequestMethods() => Assert.Fail(Pending); + } +} diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story456_SpeakerTravelsWithThePlan.cs b/tests/GenWave.Orchestration.Tests/Specs/Story456_SpeakerTravelsWithThePlan.cs new file mode 100644 index 00000000..f0166db6 --- /dev/null +++ b/tests/GenWave.Orchestration.Tests/Specs/Story456_SpeakerTravelsWithThePlan.cs @@ -0,0 +1,83 @@ +// STORY-456 — The speaker travels with the plan (gh-#772 · SPEC F189.1, F189.4, F189.5, F189.7 · PLAN T524, T527) +// +// BDD specification — xUnit. AC1 pins the additive contract; AC6–AC9 drive the planner and the ceremony arm with a counting snapshot source +// and a source that flips the active persona at render time. The render branch is Tts.Tests +// (Story456_SnapshotDrivesTheRender); card-by-id is Host.Tests (Story456_PersonaCardById); the seam index is +// Architecture.Tests (Story456_SeamIndex). AC13 is the dev-station wire (T529). +// +// RED at plan time: every fact is [Fact(Skip = Pending)] with a loud body — remove the Skip only in the +// task that makes it green. The builder comments name the arrange each scenario needs. + +namespace GenWave.Orchestration.Tests.Specs; + +public static class FeatureSpeakerTravelsWithThePlan +{ + const string Pending = "pending: T527 — the planner stamps speakers; HandoffContext.Speaker captured at arm (STORY-456)"; + const string Manual = "manual: dev-station wire, T529 — booth_log rows + stereo capture (STORY-456)"; + + // --------------------------------------------------------------------- + // HAPPY PATH + // --------------------------------------------------------------------- + + public sealed class ScenarioASegmentRequestBuiltTheOldWay + { + // Given: SegmentRequest with today's arguments + + /// AC1 — Speaker is null + [Fact(Skip = Pending)] + public void HasNoSpeaker() => Assert.Fail(Pending); + } + + public sealed class ScenarioASignOnArmedForBWhileAIsActive + { + // Given: the active persona flips to A at render time + + /// AC6 — the request's Speaker is B + [Fact(Skip = Pending)] + public void RendersWithBsSnapshot() => Assert.Fail(Pending); + } + + public sealed class ScenarioAContextSegmentPlannedUnderA + { + // Given: the active persona flips to B before the render + + /// AC7 — the request's Speaker is A + [Fact(Skip = Pending)] + public void RendersWithAsSnapshot() => Assert.Fail(Pending); + } + + public sealed class ScenarioAnArmedCeremonyReArmed + { + // Given: re-arm with heldNotBefore + + /// AC8 — HandoffContext.Speaker survives + [Fact(Skip = Pending)] + public void KeepsTheOriginalSpeaker() => Assert.Fail(Pending); + } + + public sealed class ScenarioACountingSnapshotSource + { + // Given: a break naming the station voice and two personas + + /// AC9 — + [Fact(Skip = Pending)] + public void ResolvesTheStationOnce() => Assert.Fail(Pending); + + /// AC9 — + [Fact(Skip = Pending)] + public void ResolvesEachPersonaOnce() => Assert.Fail(Pending); + } + + public sealed class ScenarioTheDevStationWire + { + // Given: two personas of different pace, flip mid-break (manual, T529) + + /// AC13 — + [Fact(Skip = Manual)] + public void TheBoothLogNamesThePlannedPersona() => Assert.Fail(Manual); + + /// AC13 — + [Fact(Skip = Manual)] + public void TheAudioPaceMatchesThePlan() => Assert.Fail(Manual); + } +} diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story457_HandoffCeremonyProducer.cs b/tests/GenWave.Orchestration.Tests/Specs/Story457_HandoffCeremonyProducer.cs new file mode 100644 index 00000000..d166a329 --- /dev/null +++ b/tests/GenWave.Orchestration.Tests/Specs/Story457_HandoffCeremonyProducer.cs @@ -0,0 +1,134 @@ +// STORY-457 — HandoffCeremonyProducer arms the ceremony (gh-#401 · SPEC F190 · PLAN T532) +// +// BDD specification — xUnit. AC1–AC7 drive ArmAsync per dedupe row; AC8/AC9 the capture and hold; AC10 warn-once; AC11 reflects the +// constant; AC12 is the existing F92/F142/F112 suite. +// +// RED at plan time: every fact is [Fact(Skip = Pending)] with a loud body — remove the Skip only in the +// task that makes it green. The builder comments name the arrange each scenario needs. + +namespace GenWave.Orchestration.Tests.Specs; + +public static class FeatureHandoffCeremonyProducer +{ + const string Pending = "pending: T532 — HandoffCeremonyProducer extracted with the dedupe matrix (STORY-457)"; + + // --------------------------------------------------------------------- + // HAPPY PATH + // --------------------------------------------------------------------- + + public sealed class ScenarioGapToGap + { + // Given: no show either side + + /// AC1 — + [Fact(Skip = Pending)] + public void ArmsNothing() => Assert.Fail(Pending); + } + + public sealed class ScenarioSelfHandoff + { + // Given: same persona, same show + + /// AC2 — + [Fact(Skip = Pending)] + public void ArmsNothing() => Assert.Fail(Pending); + } + + public sealed class ScenarioSamePersonaDifferentShow + { + // Given: persona A, show X then Y + + /// AC3 — + [Fact(Skip = Pending)] + public void ArmsASignOnOnly() => Assert.Fail(Pending); + } + + public sealed class ScenarioShowThenGap + { + // Given: show X then nothing + + /// AC4 — + [Fact(Skip = Pending)] + public void ArmsASignOffOnly() => Assert.Fail(Pending); + } + + public sealed class ScenarioGapThenShow + { + // Given: nothing then show Y + + /// AC5 — + [Fact(Skip = Pending)] + public void ArmsASignOnOnly() => Assert.Fail(Pending); + } + + public sealed class ScenarioTwoShowsTwoPersonas + { + // Given: show X (A) then show Y (B) + + /// AC6 — + [Fact(Skip = Pending)] + public void ArmsASignOff() => Assert.Fail(Pending); + + /// AC6 — + [Fact(Skip = Pending)] + public void ArmsASignOn() => Assert.Fail(Pending); + } + + public sealed class ScenarioTheSameBoundaryArmedTwice + { + // Given: ArmAsync twice + + /// AC7 — + [Fact(Skip = Pending)] + public void HoldsEachKindOnce() => Assert.Fail(Pending); + } + + public sealed class ScenarioACrossingTrackCaptured + { + // Given: CaptureCrossingTrack(track) on an armed SignOn + + /// AC8 — + [Fact(Skip = Pending)] + public void StampsTheCrossingTitle() => Assert.Fail(Pending); + } + + public sealed class ScenarioASignOnHeldPastTheTail + { + // Given: HoldSignOnPastQueuedTail(now, 40 s) + + /// AC9 — clamped + [Fact(Skip = Pending)] + public void SetsNotBeforeToNowPlusTheTail() => Assert.Fail(Pending); + } + + public sealed class ScenarioTheConstantOnTheOrchestrator + { + // Given: typeof(Orchestrator).SignOffLeadTime + + /// AC11 — + [Fact(Skip = Pending)] + public void IsAPublicStaticFifteenSeconds() => Assert.Fail(Pending); + } + + public sealed class ScenarioTheOldFacts + { + // Given: F92 / F142 / F112 specs + + /// AC12 — + [Fact(Skip = Pending)] + public void StayGreenWithTheProducer() => Assert.Fail(Pending); + } + + // --------------------------------------------------------------------- + // SAD PATH — segregated + // --------------------------------------------------------------------- + + public sealed class ScenarioNoScheduleResolver + { + // Given: ArmAsync three times without a resolver + + /// AC10 — + [Fact(Skip = Pending)] + public void WarnsExactlyOnce() => Assert.Fail(Pending); + } +} diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story458_BreakRenderer.cs b/tests/GenWave.Orchestration.Tests/Specs/Story458_BreakRenderer.cs new file mode 100644 index 00000000..59b9f761 --- /dev/null +++ b/tests/GenWave.Orchestration.Tests/Specs/Story458_BreakRenderer.cs @@ -0,0 +1,132 @@ +// STORY-458 — BreakRenderer turns slots into outcomes (gh-#401 · SPEC F191 · PLAN T534) +// +// BDD specification — xUnit. Every scenario drives BreakRenderer.RenderAsync with a hand-built plan, a recording tts fake and a fake clock. +// Sad path: null, throw, budget miss. +// +// RED at plan time: every fact is [Fact(Skip = Pending)] with a loud body — remove the Skip only in the +// task that makes it green. The builder comments name the arrange each scenario needs. + +namespace GenWave.Orchestration.Tests.Specs; + +public static class FeatureBreakRenderer +{ + const string Pending = "pending: T534 — BreakRenderer.RenderAsync on a FakeTimeProvider (STORY-458)"; + + // --------------------------------------------------------------------- + // HAPPY PATH + // --------------------------------------------------------------------- + + public sealed class ScenarioAFourSlotPlan + { + // Given: four Render slots + + /// AC1 — + [Fact(Skip = Pending)] + public void ReturnsFourOutcomes() => Assert.Fail(Pending); + + /// AC1 — + [Fact(Skip = Pending)] + public void ReturnsThemInOrdinalOrder() => Assert.Fail(Pending); + } + + public sealed class ScenarioThreeOneSecondRenders + { + // Given: a tts fake stamping the fake clock per call + + /// AC2 — all three timestamps equal the start + [Fact(Skip = Pending)] + public void KicksEveryRenderAtTheStart() => Assert.Fail(Pending); + } + + public sealed class ScenarioAReadySlot + { + // Given: Ready slot with item X + + /// AC3 — Rendered(X) + [Fact(Skip = Pending)] + public void PassesTheItemThrough() => Assert.Fail(Pending); + + /// AC3 — + [Fact(Skip = Pending)] + public void MakesNoTtsCall() => Assert.Fail(Pending); + } + + public sealed class ScenarioAVerbatimSlotWithFlavoredCopy + { + // Given: the copy writer returns flavored + + /// AC4 — + [Fact(Skip = Pending)] + public void RendersTheFlavoredCopy() => Assert.Fail(Pending); + } + + public sealed class ScenarioAVerbatimSlotWithoutFlavoredCopy + { + // Given: the copy writer returns null + + /// AC5 — + [Fact(Skip = Pending)] + public void RendersThePlainCopy() => Assert.Fail(Pending); + } + + public sealed class ScenarioACapturingLoggerEventSinkAndEstimator + { + // Given: all three wired to the renderer's caller, not the renderer + + /// AC6 — + [Fact(Skip = Pending)] + public void TheLoggerHeardNothing() => Assert.Fail(Pending); + + /// AC6 — + [Fact(Skip = Pending)] + public void TheEventSinkHeardNothing() => Assert.Fail(Pending); + + /// AC6 — + [Fact(Skip = Pending)] + public void TheEstimatorHeardNothing() => Assert.Fail(Pending); + } + + public sealed class ScenarioARenderThatLandsOnTheBudget + { + // Given: completes at exactly the budget + + /// AC10 — outcome from the task, not the race + [Fact(Skip = Pending)] + public void IsRendered() => Assert.Fail(Pending); + } + + // --------------------------------------------------------------------- + // SAD PATH — segregated + // --------------------------------------------------------------------- + + public sealed class ScenarioANullRender + { + // Given: the tts fake returns null + + /// AC7 — + [Fact(Skip = Pending)] + public void IsFailed() => Assert.Fail(Pending); + } + + public sealed class ScenarioAThrowingRender + { + // Given: the tts fake throws + + /// AC8 — + [Fact(Skip = Pending)] + public void IsFailed() => Assert.Fail(Pending); + + /// AC8 — + [Fact(Skip = Pending)] + public void CarriesTheException() => Assert.Fail(Pending); + } + + public sealed class ScenarioABudgetMiss + { + // Given: 5 s budget, the render completes after 6 s of fake time + + /// AC9 — + [Fact(Skip = Pending)] + public void IsTimedOut() => Assert.Fail(Pending); + } +} diff --git a/tests/GenWave.Orchestration.Tests/Specs/Story459_BreakDelivery.cs b/tests/GenWave.Orchestration.Tests/Specs/Story459_BreakDelivery.cs new file mode 100644 index 00000000..95ddd857 --- /dev/null +++ b/tests/GenWave.Orchestration.Tests/Specs/Story459_BreakDelivery.cs @@ -0,0 +1,111 @@ +// STORY-459 — BreakDelivery orders the buffer and keeps the books (gh-#401 · SPEC F192 · PLAN T536) +// +// BDD specification — xUnit. AC1–AC6 drive Deliver with hand-built outcomes; AC7/AC8 the drop policies; AC9 goes through the Orchestrator +// with a cancelled token. +// +// RED at plan time: every fact is [Fact(Skip = Pending)] with a loud body — remove the Skip only in the +// task that makes it green. The builder comments name the arrange each scenario needs. + +namespace GenWave.Orchestration.Tests.Specs; + +public static class FeatureBreakDelivery +{ + const string Pending = "pending: T536 — BreakDelivery.Deliver(plan, outcomes) → BreakOutcome (STORY-459)"; + + // --------------------------------------------------------------------- + // HAPPY PATH + // --------------------------------------------------------------------- + + public sealed class ScenarioRenderedFailedRendered + { + // Given: outcomes for slots 1–3 + + /// AC1 — + [Fact(Skip = Pending)] + public void ItemsHoldSlotOneThenSlotThree() => Assert.Fail(Pending); + } + + public sealed class ScenarioRenderedStationIdAnnouncementAndAd + { + // Given: three rendered slots, unit DJ Ada + + /// AC2 — + [Fact(Skip = Pending)] + public void StampsEveryItemWithTheDjName() => Assert.Fail(Pending); + } + + public sealed class ScenarioARenderedAnnouncementWithIdSeven + { + // Given: + + /// AC3 — AnnouncementMediaId.Wrap(7, …) + [Fact(Skip = Pending)] + public void WrapsTheMediaId() => Assert.Fail(Pending); + } + + public sealed class ScenarioTwoRenderedSlotsWithDurations + { + // Given: ObserveDuration set + + /// AC4 — + [Fact(Skip = Pending)] + public void ObservesBothInOrder() => Assert.Fail(Pending); + } + + public sealed class ScenarioARenderedAdAndAFailedAnnouncement + { + // Given: reservations on both + + /// AC5 — + [Fact(Skip = Pending)] + public void MarksTheAdAired() => Assert.Fail(Pending); + + /// AC5 — + [Fact(Skip = Pending)] + public void MarksTheAnnouncementDropped() => Assert.Fail(Pending); + } + + public sealed class ScenarioTheDeliveryTypeReflected + { + // Given: typeof(BreakDelivery) fields + + /// AC6 — the buffer stays on the Orchestrator + [Fact(Skip = Pending)] + public void HoldsNoQueue() => Assert.Fail(Pending); + } + + // --------------------------------------------------------------------- + // SAD PATH — segregated + // --------------------------------------------------------------------- + + public sealed class ScenarioAFailedContextWithPolicyWarn + { + // Given: + + /// AC7 — + [Fact(Skip = Pending)] + public void LogsOneWarnNamingTheProvider() => Assert.Fail(Pending); + } + + public sealed class ScenarioAFailedSignOffWithPolicyWarnAndEvent + { + // Given: + + /// AC8 — + [Fact(Skip = Pending)] + public void PublishesOneHandoffPieceDropped() => Assert.Fail(Pending); + } + + public sealed class ScenarioACancelledTokenBeforeDelivery + { + // Given: served through the Orchestrator + + /// AC9 — + [Fact(Skip = Pending)] + public void BuffersNothing() => Assert.Fail(Pending); + + /// AC9 — + [Fact(Skip = Pending)] + public void AbandonsEveryReservation() => Assert.Fail(Pending); + } +} diff --git a/tests/GenWave.TestSupport/AssemblyMarker.cs b/tests/GenWave.TestSupport/AssemblyMarker.cs new file mode 100644 index 00000000..54c7702d --- /dev/null +++ b/tests/GenWave.TestSupport/AssemblyMarker.cs @@ -0,0 +1,6 @@ +namespace GenWave.TestSupport; + +/// Anchor type for reflecting this assembly (STORY-451 AC8). +public static class AssemblyMarker +{ +} diff --git a/tests/GenWave.Orchestration.Tests/Fakes/CapturingLogger.cs b/tests/GenWave.TestSupport/Fakes/CapturingLogger.cs similarity index 77% rename from tests/GenWave.Orchestration.Tests/Fakes/CapturingLogger.cs rename to tests/GenWave.TestSupport/Fakes/CapturingLogger.cs index 5defb3e2..1b9dc0db 100644 --- a/tests/GenWave.Orchestration.Tests/Fakes/CapturingLogger.cs +++ b/tests/GenWave.TestSupport/Fakes/CapturingLogger.cs @@ -1,4 +1,4 @@ -namespace GenWave.Orchestration.Tests.Fakes; +namespace GenWave.TestSupport.Fakes; using Microsoft.Extensions.Logging; @@ -11,17 +11,21 @@ namespace GenWave.Orchestration.Tests.Fakes; /// F82.6 per-pick Debug line, which no pre-T64 spec needed to inspect. Mirrors /// GenWave.Tts.Tests.Fakes.CapturingLogger<T>. Test-scope only. /// -sealed class CapturingLogger : ILogger +public sealed class CapturingLogger : ILogger { /// Every logged message, in call order, tagged with the level it was logged at. public List<(LogLevel Level, string Message)> Entries { get; } = []; + /// Every logged message at Warning level or above, in call order. public IEnumerable Warnings => Entries.Where(e => e.Level >= LogLevel.Warning).Select(e => e.Message); + /// No scope tracking — always returns null. public IDisposable? BeginScope(TState state) where TState : notnull => null; + /// Always enabled — every level is captured. public bool IsEnabled(LogLevel logLevel) => true; + /// Records the formatted message into . public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) diff --git a/tests/GenWave.Orchestration.Tests/Fakes/CapturingPatterDurationEstimator.cs b/tests/GenWave.TestSupport/Fakes/CapturingPatterDurationEstimator.cs similarity index 69% rename from tests/GenWave.Orchestration.Tests/Fakes/CapturingPatterDurationEstimator.cs rename to tests/GenWave.TestSupport/Fakes/CapturingPatterDurationEstimator.cs index b489525e..fe2c612e 100644 --- a/tests/GenWave.Orchestration.Tests/Fakes/CapturingPatterDurationEstimator.cs +++ b/tests/GenWave.TestSupport/Fakes/CapturingPatterDurationEstimator.cs @@ -1,7 +1,7 @@ using GenWave.Core.Abstractions; using GenWave.Core.Domain; -namespace GenWave.Orchestration.Tests.Fakes; +namespace GenWave.TestSupport.Fakes; /// /// gh-#463 — records every call's (Kind, PersonaName, Voice, ShowName) tuple so @@ -9,19 +9,23 @@ namespace GenWave.Orchestration.Tests.Fakes; /// it does with the answer. Always answers at — no /// spec using this fake asserts on the returned duration itself, only the call shape. /// -sealed class CapturingPatterDurationEstimator : IPatterDurationEstimator +public sealed class CapturingPatterDurationEstimator : IPatterDurationEstimator { + /// Every call, in call order. public List<(SegmentKind Kind, string? PersonaName, string Voice, string? ShowName)> Calls { get; } = []; + /// Records the call (showName: null) and answers the fixed heuristic estimate. public PatterDurationEstimate Estimate(SegmentKind kind, string? personaName, string voice) => Estimate(kind, personaName, voice, showName: null); + /// Records the call and answers the fixed heuristic estimate. public PatterDurationEstimate Estimate(SegmentKind kind, string? personaName, string voice, string? showName) { Calls.Add((kind, personaName, voice, showName)); return new PatterDurationEstimate(TimeSpan.FromSeconds(5), PatterEstimateConfidence.Heuristic); } + /// No-op — this fake never simulates historical-tier learning. public void ObserveRendered(SegmentKind kind, string? personaName, string voice, TimeSpan measured) { } diff --git a/tests/GenWave.Orchestration.Tests/Fakes/CapturingStationEventSink.cs b/tests/GenWave.TestSupport/Fakes/CapturingStationEventSink.cs similarity index 73% rename from tests/GenWave.Orchestration.Tests/Fakes/CapturingStationEventSink.cs rename to tests/GenWave.TestSupport/Fakes/CapturingStationEventSink.cs index de43601d..b65530c4 100644 --- a/tests/GenWave.Orchestration.Tests/Fakes/CapturingStationEventSink.cs +++ b/tests/GenWave.TestSupport/Fakes/CapturingStationEventSink.cs @@ -1,7 +1,7 @@ using GenWave.Core.Abstractions; using GenWave.Core.Events; -namespace GenWave.Orchestration.Tests.Fakes; +namespace GenWave.TestSupport.Fakes; /// /// Recording double (SPEC F87.6, STORY-227, PLAN T90) — collects @@ -10,9 +10,11 @@ namespace GenWave.Orchestration.Tests.Fakes; /// over; lives here instead because 's own specs (this /// project) are the first in GenWave.Orchestration.Tests to need one. /// -sealed class CapturingStationEventSink : IStationEventSink +public sealed class CapturingStationEventSink : IStationEventSink { + /// Every published event, in publish order. public List Events { get; } = []; + /// Records the event into . public void Publish(StationEvent evt) => Events.Add(evt); } diff --git a/tests/GenWave.Host.Tests/FakeBoundaryBiasProvider.cs b/tests/GenWave.TestSupport/Fakes/FakeBoundaryBiasProvider.cs similarity index 66% rename from tests/GenWave.Host.Tests/FakeBoundaryBiasProvider.cs rename to tests/GenWave.TestSupport/Fakes/FakeBoundaryBiasProvider.cs index 83b42f8a..4e8d5f74 100644 --- a/tests/GenWave.Host.Tests/FakeBoundaryBiasProvider.cs +++ b/tests/GenWave.TestSupport/Fakes/FakeBoundaryBiasProvider.cs @@ -1,6 +1,6 @@ using GenWave.Core.Abstractions; -namespace GenWave.Host.Tests; +namespace GenWave.TestSupport.Fakes; /// /// Mutable double (SPEC F74.3, mirrors @@ -8,9 +8,11 @@ namespace GenWave.Host.Tests; /// calls to simulate a config-provider reload without standing up a real options stack in a unit /// test. /// -sealed class FakeBoundaryBiasProvider(TimeSpan lookahead) : IBoundaryBiasProvider +public sealed class FakeBoundaryBiasProvider(TimeSpan lookahead) : IBoundaryBiasProvider { + /// The lookahead window a spec can mutate between calls. public TimeSpan Lookahead { get; set; } = lookahead; + /// public TimeSpan Current => Lookahead; } diff --git a/tests/GenWave.Orchestration.Tests/Fakes/FakeCadenceProvider.cs b/tests/GenWave.TestSupport/Fakes/FakeCadenceProvider.cs similarity index 69% rename from tests/GenWave.Orchestration.Tests/Fakes/FakeCadenceProvider.cs rename to tests/GenWave.TestSupport/Fakes/FakeCadenceProvider.cs index 29277244..5f89420a 100644 --- a/tests/GenWave.Orchestration.Tests/Fakes/FakeCadenceProvider.cs +++ b/tests/GenWave.TestSupport/Fakes/FakeCadenceProvider.cs @@ -1,7 +1,7 @@ using GenWave.Core.Abstractions; using GenWave.Core.Domain; -namespace GenWave.Orchestration.Tests.Fakes; +namespace GenWave.TestSupport.Fakes; /// /// Mutable double (gitea-#211, mirrors @@ -9,9 +9,11 @@ namespace GenWave.Orchestration.Tests.Fakes; /// IOptionsMonitor<StationOptions> reload without standing up a real options stack in a /// unit test. /// -sealed class FakeCadenceProvider(CadenceConfig cadence) : ICadenceProvider +public sealed class FakeCadenceProvider(CadenceConfig cadence) : ICadenceProvider { + /// The cadence config a spec can mutate between calls. public CadenceConfig Cadence { get; set; } = cadence; + /// public CadenceConfig Current => Cadence; } diff --git a/tests/GenWave.Orchestration.Tests/Fakes/FakeMediaCatalog.cs b/tests/GenWave.TestSupport/Fakes/FakeMediaCatalog.cs similarity index 92% rename from tests/GenWave.Orchestration.Tests/Fakes/FakeMediaCatalog.cs rename to tests/GenWave.TestSupport/Fakes/FakeMediaCatalog.cs index 65bfd11f..01f9f7a5 100644 --- a/tests/GenWave.Orchestration.Tests/Fakes/FakeMediaCatalog.cs +++ b/tests/GenWave.TestSupport/Fakes/FakeMediaCatalog.cs @@ -1,7 +1,7 @@ using GenWave.Core.Abstractions; using GenWave.Core.Domain; -namespace GenWave.Orchestration.Tests.Fakes; +namespace GenWave.TestSupport.Fakes; /// /// Scripted catalog double for orchestrator unit tests. Captures every call to @@ -23,11 +23,12 @@ namespace GenWave.Orchestration.Tests.Fakes; /// literal- ones. /// /// -sealed class FakeMediaCatalog : IMediaCatalog +public sealed class FakeMediaCatalog : IMediaCatalog { readonly IReadOnlyList pool; int nextIndex; + /// Seeds a single-track pool (or an empty one, passing ). public FakeMediaCatalog(MediaReference? ready) : this(ready is null ? [] : [ready]) { } @@ -40,7 +41,10 @@ public FakeMediaCatalog(MediaReference? ready) : this(ready is null ? [] : [read /// See the type-level remarks — a candidate pool for boundary-bias specs (SPEC F74.3). public static FakeMediaCatalog WithPool(IReadOnlyList pool) => new(pool); + /// Every excludeIds list passed to , in call order. public List> RandomCallExcludeLists { get; } = []; + + /// Every scope passed to , in call order. public List RandomCallScopes { get; } = []; /// Every orderedRecentIds list passed to , in call order. @@ -83,9 +87,11 @@ public FakeMediaCatalog(MediaReference? ready) : this(ready is null ? [] : [read /// SQL concern the real repository's own deliberate non-delegation guards against, so one scripted /// answer source is simpler and no less honest here. /// + /// public Task GetRandomReadyByImagingKindAsync(LibraryScope scope, ImagingKind kind, CancellationToken ct) => GetRandomReadyByImagingKindAsync(scope, kind, showId: null, ct); + /// public Task GetRandomReadyByImagingKindAsync( LibraryScope scope, ImagingKind kind, long? showId, CancellationToken ct) { @@ -93,12 +99,15 @@ public FakeMediaCatalog(MediaReference? ready) : this(ready is null ? [] : [read return Task.FromResult(ImagingPoolResult); } + /// public Task GetByIdAsync(LibraryScope scope, string mediaId, CancellationToken ct) => Task.FromResult(pool.FirstOrDefault(m => m.MediaId == mediaId)); + /// public Task GetByIdUnscopedAsync(string mediaId, CancellationToken ct) => Task.FromResult(pool.FirstOrDefault(m => m.MediaId == mediaId)); + /// public Task GetRandomReadyAsync(LibraryScope scope, IReadOnlyList excludeIds, CancellationToken ct) { RandomCallExcludeLists.Add(excludeIds); @@ -106,6 +115,7 @@ public FakeMediaCatalog(MediaReference? ready) : this(ready is null ? [] : [read return Task.FromResult(pool.Count == 0 ? null : pool[0]); } + /// public Task GetRotationCandidateAsync( LibraryScope scope, IReadOnlyList orderedRecentIds, int artistSeparation, CancellationToken ct) { @@ -123,9 +133,11 @@ public FakeMediaCatalog(MediaReference? ready) : this(ready is null ? [] : [read RepeatedArtist: ScriptedRepeatedArtist)); } + /// public Task> ListAsync(LibraryScope scope, MediaQuery query, CancellationToken ct) => Task.FromResult(new PagedResult([], 0, 0)); + /// public Task GetStatusCountsAsync(LibraryScope safeScope, CancellationToken ct) => Task.FromResult(new CatalogStatusCounts(0, 0, 0, 0, 0)); diff --git a/tests/GenWave.Orchestration.Tests/Fakes/FakePersonaStore.cs b/tests/GenWave.TestSupport/Fakes/FakePersonaStore.cs similarity index 79% rename from tests/GenWave.Orchestration.Tests/Fakes/FakePersonaStore.cs rename to tests/GenWave.TestSupport/Fakes/FakePersonaStore.cs index 8bd8d4fd..da658fff 100644 --- a/tests/GenWave.Orchestration.Tests/Fakes/FakePersonaStore.cs +++ b/tests/GenWave.TestSupport/Fakes/FakePersonaStore.cs @@ -1,7 +1,7 @@ using GenWave.Core.Abstractions; using GenWave.Core.Domain; -namespace GenWave.Orchestration.Tests.Fakes; +namespace GenWave.TestSupport.Fakes; /// /// In-memory double (STORY-241/242, PLAN T120) for @@ -10,7 +10,7 @@ namespace GenWave.Orchestration.Tests.Fakes; /// member throws, mirroring the "not exercised by these scenarios" convention every other /// double in this codebase already follows. /// -sealed class FakePersonaStore : IPersonaStore +public sealed class FakePersonaStore : IPersonaStore { readonly Dictionary personas = []; readonly Dictionary cards = []; @@ -24,8 +24,10 @@ sealed class FakePersonaStore : IPersonaStore /// ResolveAsync path. public Exception? ThrowOnGetCardById { get; set; } + /// Every id passed to , in call order. public List GetByIdCalls { get; } = []; + /// Seeds a persona, keyed by its own id. public void Add(Persona persona) => personas[persona.Id] = persona; /// Round-3 review addition — simulates a persona "deleted out of band" mid-test @@ -34,8 +36,10 @@ sealed class FakePersonaStore : IPersonaStore /// store itself faulted" shape. public void Remove(long id) => personas.Remove(id); + /// Seeds a persona card, keyed by persona id. public void AddCard(long personaId, PersonaCard card) => cards[personaId] = card; + /// public Task GetByIdAsync(long id, CancellationToken ct) { GetByIdCalls.Add(id); @@ -44,6 +48,7 @@ sealed class FakePersonaStore : IPersonaStore return Task.FromResult(personas.TryGetValue(id, out var persona) ? persona : null); } + /// public Task GetCardByIdAsync(long id, CancellationToken ct) { if (ThrowOnGetCardById is { } ex) throw ex; @@ -51,18 +56,23 @@ sealed class FakePersonaStore : IPersonaStore return Task.FromResult(cards.TryGetValue(id, out var card) ? card : null); } + /// Not exercised by OnAirPersonaAccessor specs — always throws. public Task> GetAllAsync(CancellationToken ct) => throw new NotSupportedException("Not exercised by OnAirPersonaAccessor specs."); + /// Not exercised by OnAirPersonaAccessor specs — always throws. public Task CreateAsync(PersonaDraft draft, CancellationToken ct) => throw new NotSupportedException("Not exercised by OnAirPersonaAccessor specs."); + /// Not exercised by OnAirPersonaAccessor specs — always throws. public Task UpdateAsync(long id, PersonaDraft draft, CancellationToken ct) => throw new NotSupportedException("Not exercised by OnAirPersonaAccessor specs."); + /// Not exercised by OnAirPersonaAccessor specs — always throws. public Task DeleteAsync(long id, CancellationToken ct) => throw new NotSupportedException("Not exercised by OnAirPersonaAccessor specs."); + /// Not exercised by OnAirPersonaAccessor specs — always throws. public Task GetIdBySlugAsync(string slug, CancellationToken ct) => throw new NotSupportedException("Not exercised by OnAirPersonaAccessor specs."); } diff --git a/tests/GenWave.Orchestration.Tests/Fakes/FakeRenderBudgetProvider.cs b/tests/GenWave.TestSupport/Fakes/FakeRenderBudgetProvider.cs similarity index 67% rename from tests/GenWave.Orchestration.Tests/Fakes/FakeRenderBudgetProvider.cs rename to tests/GenWave.TestSupport/Fakes/FakeRenderBudgetProvider.cs index 3fc4d41e..99382df8 100644 --- a/tests/GenWave.Orchestration.Tests/Fakes/FakeRenderBudgetProvider.cs +++ b/tests/GenWave.TestSupport/Fakes/FakeRenderBudgetProvider.cs @@ -1,6 +1,6 @@ using GenWave.Core.Abstractions; -namespace GenWave.Orchestration.Tests.Fakes; +namespace GenWave.TestSupport.Fakes; /// /// Mutable double (SPEC F44.2, mirrors @@ -8,9 +8,11 @@ namespace GenWave.Orchestration.Tests.Fakes; /// IOptionsMonitor<TtsOptions> reload without standing up a real options stack in a /// unit test. /// -sealed class FakeRenderBudgetProvider(TimeSpan budget) : IRenderBudgetProvider +public sealed class FakeRenderBudgetProvider(TimeSpan budget) : IRenderBudgetProvider { + /// The render budget a spec can mutate between calls. public TimeSpan Budget { get; set; } = budget; + /// public TimeSpan Current => Budget; } diff --git a/tests/GenWave.Orchestration.Tests/Fakes/FakeRotationSettingsProvider.cs b/tests/GenWave.TestSupport/Fakes/FakeRotationSettingsProvider.cs similarity index 67% rename from tests/GenWave.Orchestration.Tests/Fakes/FakeRotationSettingsProvider.cs rename to tests/GenWave.TestSupport/Fakes/FakeRotationSettingsProvider.cs index 00d887d4..480e76bf 100644 --- a/tests/GenWave.Orchestration.Tests/Fakes/FakeRotationSettingsProvider.cs +++ b/tests/GenWave.TestSupport/Fakes/FakeRotationSettingsProvider.cs @@ -1,7 +1,7 @@ using GenWave.Core.Abstractions; using GenWave.Core.Domain; -namespace GenWave.Orchestration.Tests.Fakes; +namespace GenWave.TestSupport.Fakes; /// /// Mutable double (SPEC F41.6, mirrors @@ -9,9 +9,11 @@ namespace GenWave.Orchestration.Tests.Fakes; /// IOptionsMonitor<StationOptions> reload without standing up a real options stack in a /// unit test. /// -sealed class FakeRotationSettingsProvider(RotationSettings settings) : IRotationSettingsProvider +public sealed class FakeRotationSettingsProvider(RotationSettings settings) : IRotationSettingsProvider { + /// The rotation settings a spec can mutate between calls. public RotationSettings Settings { get; set; } = settings; + /// public RotationSettings Current => Settings; } diff --git a/tests/GenWave.Orchestration.Tests/Fakes/FakeScheduleSpecialStore.cs b/tests/GenWave.TestSupport/Fakes/FakeScheduleSpecialStore.cs similarity index 83% rename from tests/GenWave.Orchestration.Tests/Fakes/FakeScheduleSpecialStore.cs rename to tests/GenWave.TestSupport/Fakes/FakeScheduleSpecialStore.cs index 660f61cc..498a76c3 100644 --- a/tests/GenWave.Orchestration.Tests/Fakes/FakeScheduleSpecialStore.cs +++ b/tests/GenWave.TestSupport/Fakes/FakeScheduleSpecialStore.cs @@ -1,7 +1,7 @@ using GenWave.Core.Abstractions; using GenWave.Core.Domain; -namespace GenWave.Orchestration.Tests.Fakes; +namespace GenWave.TestSupport.Fakes; /// /// In-memory double (STORY-317, PLAN T260) — the specials-cache @@ -22,11 +22,12 @@ namespace GenWave.Orchestration.Tests.Fakes; /// mid-flight-invalidation race already proves for the week snapshot. /// /// -sealed class FakeScheduleSpecialStore(IReadOnlyList? seed = null) : IScheduleSpecialStore +public sealed class FakeScheduleSpecialStore(IReadOnlyList? seed = null) : IScheduleSpecialStore { IReadOnlyList current = seed ?? []; TaskCompletionSource>? pendingLoad; + /// How many times has been called. public int ListUpcomingAsyncCallCount { get; private set; } /// Every fromDate a caller has ever passed, in call order — lets a spec assert @@ -38,8 +39,10 @@ sealed class FakeScheduleSpecialStore(IReadOnlyList? seed = nul /// 's own convention. Never cleared automatically. public Exception? ThrowOnListUpcoming { get; set; } + /// Raised by to simulate a write from another caller. public event Action? SpecialsChanged; + /// public Task> ListUpcomingAsync(DateOnly fromDate, CancellationToken ct) { ListUpcomingAsyncCallCount++; @@ -49,12 +52,15 @@ public Task> ListUpcomingAsync(DateOnly fromDate, return pendingLoad?.Task ?? Task.FromResult(current); } + /// Not needed by any T260 spec — only ever reads. Always throws. public Task CreateAsync(ScheduleSpecial special, CancellationToken ct) => throw new NotSupportedException("FakeScheduleSpecialStore is a read-only double for T260 caching specs."); + /// Not needed by any T260 spec — only ever reads. Always throws. public Task DeleteAsync(long id, CancellationToken ct) => throw new NotSupportedException("FakeScheduleSpecialStore is a read-only double for T260 caching specs."); + /// Simulates a specials write landing from another caller by raising . public void RaiseSpecialsChanged() => SpecialsChanged?.Invoke(); /// Arms a gate so the NEXT call returns an incomplete task, diff --git a/tests/GenWave.Orchestration.Tests/Fakes/FakeScheduleStore.cs b/tests/GenWave.TestSupport/Fakes/FakeScheduleStore.cs similarity index 82% rename from tests/GenWave.Orchestration.Tests/Fakes/FakeScheduleStore.cs rename to tests/GenWave.TestSupport/Fakes/FakeScheduleStore.cs index b84802f8..4e7af955 100644 --- a/tests/GenWave.Orchestration.Tests/Fakes/FakeScheduleStore.cs +++ b/tests/GenWave.TestSupport/Fakes/FakeScheduleStore.cs @@ -1,7 +1,7 @@ using GenWave.Core.Abstractions; using GenWave.Core.Domain; -namespace GenWave.Orchestration.Tests.Fakes; +namespace GenWave.TestSupport.Fakes; /// /// In-memory double (STORY-241, PLAN T119) that counts @@ -18,11 +18,12 @@ namespace GenWave.Orchestration.Tests.Fakes; /// so the NEXT (ungated) load observes it. /// /// -sealed class FakeScheduleStore(ScheduleWeekSnapshot snapshot) : IScheduleStore +public sealed class FakeScheduleStore(ScheduleWeekSnapshot snapshot) : IScheduleStore { ScheduleWeekSnapshot current = snapshot; TaskCompletionSource? pendingLoad; + /// How many times has been called. public int LoadWeekAsyncCallCount { get; private set; } /// Set to make the next call throw, simulating an @@ -31,8 +32,10 @@ sealed class FakeScheduleStore(ScheduleWeekSnapshot snapshot) : IScheduleStore /// a scenario proving recovery sets this back to itself. public Exception? ThrowOnLoadWeek { get; set; } + /// Raised by to simulate a write from another caller. public event Action? WeekChanged; + /// public Task LoadWeekAsync(CancellationToken ct) { LoadWeekAsyncCallCount++; @@ -41,20 +44,24 @@ public Task LoadWeekAsync(CancellationToken ct) return pendingLoad?.Task ?? Task.FromResult(current); } + /// Not needed by any T119 spec — only ever reads. Always throws. public Task ReplaceWeekAsync( IReadOnlyList week, string? expectedVersion, CancellationToken ct) => throw new NotSupportedException("FakeScheduleStore is a read-only double for T119 specs."); + /// PLAN T240's show delete guard read — not exercised by any T119 spec. Always throws. // PLAN T240's show delete guard read — not exercised by any T119 spec (this double never carries // show_id-scoped rows), mirrors ReplaceWeekAsync's own NotSupportedException posture above. public Task> GetSlotsByShowIdAsync(long showId, CancellationToken ct) => throw new NotSupportedException("FakeScheduleStore is a read-only double for T119 specs."); + /// PLAN T243's show assignment write — not exercised by any T119 spec. Always throws. // PLAN T243's show assignment write — not exercised by any T119 spec (this double is read-only by // design, see class remarks), mirrors ReplaceWeekAsync's own NotSupportedException posture above. public Task AssignShowAsync(long blockId, long? showId, bool applyToRun, CancellationToken ct) => throw new NotSupportedException("FakeScheduleStore is a read-only double for T119 specs."); + /// Simulates a schedule write landing from another caller by raising . public void RaiseWeekChanged() => WeekChanged?.Invoke(); /// Arms a gate so the NEXT call returns an incomplete task, diff --git a/tests/GenWave.Orchestration.Tests/Fakes/FakeStationDefaultEnvelopeSource.cs b/tests/GenWave.TestSupport/Fakes/FakeStationDefaultEnvelopeSource.cs similarity index 63% rename from tests/GenWave.Orchestration.Tests/Fakes/FakeStationDefaultEnvelopeSource.cs rename to tests/GenWave.TestSupport/Fakes/FakeStationDefaultEnvelopeSource.cs index 267e871b..0b9972c0 100644 --- a/tests/GenWave.Orchestration.Tests/Fakes/FakeStationDefaultEnvelopeSource.cs +++ b/tests/GenWave.TestSupport/Fakes/FakeStationDefaultEnvelopeSource.cs @@ -1,13 +1,14 @@ using GenWave.Abstractions.Playout; using GenWave.Core.Abstractions; -namespace GenWave.Orchestration.Tests.Fakes; +namespace GenWave.TestSupport.Fakes; /// /// Fixed double (STORY-241, PLAN T119) — mirrors /// one seam over. /// -sealed class FakeStationDefaultEnvelopeSource(SegmentEnvelope envelope) : IStationDefaultEnvelopeSource +public sealed class FakeStationDefaultEnvelopeSource(SegmentEnvelope envelope) : IStationDefaultEnvelopeSource { + /// public SegmentEnvelope Current => envelope; } diff --git a/tests/GenWave.Orchestration.Tests/Fakes/FakeStationIdentityProvider.cs b/tests/GenWave.TestSupport/Fakes/FakeStationIdentityProvider.cs similarity index 68% rename from tests/GenWave.Orchestration.Tests/Fakes/FakeStationIdentityProvider.cs rename to tests/GenWave.TestSupport/Fakes/FakeStationIdentityProvider.cs index ddf42be0..148eb5e2 100644 --- a/tests/GenWave.Orchestration.Tests/Fakes/FakeStationIdentityProvider.cs +++ b/tests/GenWave.TestSupport/Fakes/FakeStationIdentityProvider.cs @@ -1,7 +1,7 @@ using GenWave.Core.Abstractions; using GenWave.Core.Domain; -namespace GenWave.Orchestration.Tests.Fakes; +namespace GenWave.TestSupport.Fakes; /// /// Mutable double (SPEC F44.1, gitea-#196, mirrors @@ -9,9 +9,11 @@ namespace GenWave.Orchestration.Tests.Fakes; /// simulate a live IOptionsMonitor<StationOptions> reload without standing up a real /// options stack in a unit test. /// -sealed class FakeStationIdentityProvider(StationIdentity identity) : IStationIdentityProvider +public sealed class FakeStationIdentityProvider(StationIdentity identity) : IStationIdentityProvider { + /// The station identity a spec can mutate between calls. public StationIdentity Identity { get; set; } = identity; + /// public StationIdentity Current => Identity; } diff --git a/tests/GenWave.Orchestration.Tests/Fakes/FakeStationScopeProvider.cs b/tests/GenWave.TestSupport/Fakes/FakeStationScopeProvider.cs similarity index 65% rename from tests/GenWave.Orchestration.Tests/Fakes/FakeStationScopeProvider.cs rename to tests/GenWave.TestSupport/Fakes/FakeStationScopeProvider.cs index d585a7fa..7e13f8bf 100644 --- a/tests/GenWave.Orchestration.Tests/Fakes/FakeStationScopeProvider.cs +++ b/tests/GenWave.TestSupport/Fakes/FakeStationScopeProvider.cs @@ -1,16 +1,18 @@ using GenWave.Core.Abstractions; using GenWave.Core.Domain; -namespace GenWave.Orchestration.Tests.Fakes; +namespace GenWave.TestSupport.Fakes; /// /// Mutable double. Set between calls to /// simulate a live IOptionsMonitor<StationOptions> reload (SPEC F30.1) without /// standing up a real options stack in a unit test. /// -sealed class FakeStationScopeProvider(LibraryScope scope) : IStationScopeProvider +public sealed class FakeStationScopeProvider(LibraryScope scope) : IStationScopeProvider { + /// The library scope a spec can mutate between calls. public LibraryScope Scope { get; set; } = scope; + /// public LibraryScope Current => Scope; } diff --git a/tests/GenWave.Orchestration.Tests/Fakes/FakeTtsSegmentSource.cs b/tests/GenWave.TestSupport/Fakes/FakeTtsSegmentSource.cs similarity index 91% rename from tests/GenWave.Orchestration.Tests/Fakes/FakeTtsSegmentSource.cs rename to tests/GenWave.TestSupport/Fakes/FakeTtsSegmentSource.cs index f70a93b3..0717cfdb 100644 --- a/tests/GenWave.Orchestration.Tests/Fakes/FakeTtsSegmentSource.cs +++ b/tests/GenWave.TestSupport/Fakes/FakeTtsSegmentSource.cs @@ -1,7 +1,7 @@ using GenWave.Core.Abstractions; using GenWave.Core.Domain; -namespace GenWave.Orchestration.Tests.Fakes; +namespace GenWave.TestSupport.Fakes; /// /// Scripted TTS source double for orchestrator unit tests. Returns a pre-built segment with a @@ -9,10 +9,15 @@ namespace GenWave.Orchestration.Tests.Fakes; /// When is set the task waits for that duration before completing, /// simulating a slow render that may exceed the budget. /// -sealed class FakeTtsSegmentSource : ITtsSegmentSource +public sealed class FakeTtsSegmentSource : ITtsSegmentSource { + /// When true, every call returns null unconditionally. public bool AlwaysReturnNull { get; set; } + + /// How many times has been called. public int RenderCallCount { get; private set; } + + /// The most recent request passed to . public SegmentRequest? LastRequest { get; private set; } /// Every request seen, in call order — for specs that assert on a specific @@ -58,6 +63,7 @@ sealed class FakeTtsSegmentSource : ITtsSegmentSource /// public Func? ShouldThrow { get; set; } + /// public async Task RenderAsync(SegmentRequest request, CancellationToken ct) { RenderCallCount++; diff --git a/tests/GenWave.TestSupport/GenWave.TestSupport.csproj b/tests/GenWave.TestSupport/GenWave.TestSupport.csproj new file mode 100644 index 00000000..a738b66b --- /dev/null +++ b/tests/GenWave.TestSupport/GenWave.TestSupport.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + false + true + + false + + + + + + + + + + + + + + + diff --git a/tests/GenWave.TestSupport/OrchestratorBuilder.cs b/tests/GenWave.TestSupport/OrchestratorBuilder.cs new file mode 100644 index 00000000..204b3ad3 --- /dev/null +++ b/tests/GenWave.TestSupport/OrchestratorBuilder.cs @@ -0,0 +1,273 @@ +using GenWave.Abstractions.Playout; +using GenWave.Core.Abstractions; +using GenWave.Core.Domain; +using GenWave.Orchestration; +using GenWave.TestSupport.Fakes; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; + +namespace GenWave.TestSupport; + +/// +/// Builds a real one seam at a time (SPEC F184.2, STORY-451, PLAN T511) — +/// every seam defaults to the same fake already uses, and every +/// seam has a With* override, so a spec never reaches for the 27-parameter constructor +/// directly. resolves seams whose default depends on another seam's FINAL value +/// (the clock feeds tts/deferral-queue/schedule-resolver; the catalog feeds the music selection +/// policy; the schedule resolver and persona store feed the persona accessor) in dependency order, so +/// With* calls in any order before always produce a consistent chain. +/// +/// +/// types every slot to the seam type its With* accepts, so +/// never throws for an override that compiles. A spec that needs a fake's own +/// members on a default seam passes its own fake through With* and keeps the reference itself. +/// +/// +/// +/// defaults to , not a fake: the only +/// IPatterDurationEstimator double here () always +/// answers a fixed heuristic, which is NOT behaviorally equivalent to 's own +/// tiered fallback — defaulting to it would silently change boundary-fit behavior for every spec that +/// never calls . +/// +/// +public sealed class OrchestratorBuilder +{ + IStationIdentityProvider? identityProvider; + IStationScopeProvider? scopeProvider; + ICadenceProvider? cadenceProvider; + IRotationSettingsProvider? rotationProvider; + MusicSelectionPolicy? musicSelectionPolicy; + ITtsSegmentSource? tts; + IActivePersonaAccessor? personaAccessor; + ILogger? logger; + IRenderBudgetProvider? renderBudgetProvider; + SpeechDeferralQueue? deferralQueue; + TimeProvider? timeProvider; + IBoundaryBiasProvider? boundaryBiasProvider; + CachingScheduleResolver? scheduleResolver; + IPersonaStore? personaStore; + IStationEventSink? events; + IStationClockProvider? stationClock; + IPatterDurationEstimator? patterEstimator; + IContextSettingsProvider? contextSettings; + IMediaCatalog? catalog; + IStationImagingSettingsProvider? imagingSettings; + CrosstalkPlanner? crosstalkPlanner; + IAnnouncementSource? announcementSource; + IVerbatimSegmentRenderer? announcementRenderer; + ITtsVoiceLister? voiceLister; + IAnnouncementCopyWriter? announcementCopyWriter; + IAdCadenceProvider? adCadenceProvider; + IAdSpotVend? adSpotVend; + + // Only meaningful when scheduleResolver is unset — feeds the default schedule-resolver chain's + // FakeScheduleStore (see WithSchedule). + ScheduleWeekSnapshot snapshot = new([]); + bool scheduleSet; + + /// Every trivial "set the seam, return this" setter below funnels through here — one + /// place owns the assign-and-chain boilerplate instead of 27 near-identical bodies. + OrchestratorBuilder With(ref T? slot, T? value) where T : class + { + slot = value; + return this; + } + + /// Overrides the station identity seam. Defaults to a fixed FakeStationIdentityProvider. + public OrchestratorBuilder WithIdentity(IStationIdentityProvider identity) => With(ref identityProvider, identity); + + /// Overrides the library scope seam. Defaults to a fixed FakeStationScopeProvider. + public OrchestratorBuilder WithScope(IStationScopeProvider scope) => With(ref scopeProvider, scope); + + /// Overrides the cadence seam. Defaults to a FakeCadenceProvider with every knob off. + public OrchestratorBuilder WithCadence(ICadenceProvider cadence) => With(ref cadenceProvider, cadence); + + /// Convenience — wraps in a . + public OrchestratorBuilder WithCadence(CadenceConfig cadence) => WithCadence(new FakeCadenceProvider(cadence)); + + /// Overrides the rotation settings seam. Defaults to a fixed FakeRotationSettingsProvider. + public OrchestratorBuilder WithRotation(IRotationSettingsProvider rotation) => With(ref rotationProvider, rotation); + + /// Overrides the music selection policy seam. Defaults to a real policy over the final catalog. + public OrchestratorBuilder WithMusicSelectionPolicy(MusicSelectionPolicy policy) => With(ref musicSelectionPolicy, policy); + + /// Overrides the TTS seam. Defaults to a sharing the final clock. + public OrchestratorBuilder WithTts(ITtsSegmentSource ttsSegmentSource) => With(ref tts, ttsSegmentSource); + + /// Overrides the active-persona accessor seam. Defaults to a real accessor over the final schedule resolver and persona store. + public OrchestratorBuilder WithPersonaAccessor(IActivePersonaAccessor accessor) => With(ref personaAccessor, accessor); + + /// Overrides the logger seam. Defaults to a . + public OrchestratorBuilder WithLogger(ILogger orchestratorLogger) => With(ref logger, orchestratorLogger); + + /// Overrides the render budget seam. Defaults to a fixed 5-second FakeRenderBudgetProvider. + public OrchestratorBuilder WithRenderBudget(IRenderBudgetProvider renderBudget) => With(ref renderBudgetProvider, renderBudget); + + /// Convenience — wraps in a . + public OrchestratorBuilder WithRenderBudget(TimeSpan budget) => WithRenderBudget(new FakeRenderBudgetProvider(budget)); + + /// Overrides the speech deferral queue seam. Defaults to a queue over the final clock. + public OrchestratorBuilder WithDeferralQueue(SpeechDeferralQueue queue) => With(ref deferralQueue, queue); + + /// Overrides the clock seam. Defaults to a at a fixed instant. + public OrchestratorBuilder WithTime(TimeProvider time) => With(ref timeProvider, time); + + /// Convenience — a at . + public OrchestratorBuilder WithNow(DateTimeOffset now) => WithTime(new FakeTimeProvider(now)); + + /// Overrides the boundary bias seam. Defaults to a zero-lookahead FakeBoundaryBiasProvider. + public OrchestratorBuilder WithBoundaryBias(IBoundaryBiasProvider boundaryBias) => With(ref boundaryBiasProvider, boundaryBias); + + /// Convenience — wraps in a . + public OrchestratorBuilder WithLookahead(TimeSpan lookahead) => WithBoundaryBias(new FakeBoundaryBiasProvider(lookahead)); + + /// Overrides the schedule resolver seam outright. Exclusive with — throws if both are set. + public OrchestratorBuilder WithScheduleResolver(CachingScheduleResolver resolver) => With(ref scheduleResolver, resolver); + + /// Convenience — keeps the default schedule-resolver chain but seeds its with . Exclusive with — throws if both are set. + public OrchestratorBuilder WithSchedule(ScheduleWeekSnapshot week) + { + snapshot = week; + scheduleSet = true; + return this; + } + + /// Overrides the persona store seam. Defaults to an empty . + public OrchestratorBuilder WithPersonaStore(IPersonaStore store) => With(ref personaStore, store); + + /// Overrides the station event sink seam. Defaults to a . + public OrchestratorBuilder WithEvents(IStationEventSink sink) => With(ref events, sink); + + /// Overrides the station clock seam. Defaults to (feature-dark — Orchestrator falls back to 's own UTC now). + public OrchestratorBuilder WithStationClock(IStationClockProvider? clock) => With(ref stationClock, clock); + + /// Overrides the patter duration estimator seam. Defaults to — see this class's own remarks for why the builder never defaults this to a fake. + public OrchestratorBuilder WithPatterEstimator(IPatterDurationEstimator? estimator) => With(ref patterEstimator, estimator); + + /// Overrides the per-key context-persona seam. Defaults to (feature-dark). + public OrchestratorBuilder WithContextSettings(IContextSettingsProvider? settings) => With(ref contextSettings, settings); + + /// Overrides the media catalog seam. Defaults to a single-track (also feeds the default ). + public OrchestratorBuilder WithCatalog(IMediaCatalog mediaCatalog) => With(ref catalog, mediaCatalog); + + /// Overrides the station imaging settings seam. Defaults to (feature-dark). + public OrchestratorBuilder WithImagingSettings(IStationImagingSettingsProvider? settings) => With(ref imagingSettings, settings); + + /// Overrides the crosstalk planner seam. Defaults to (feature-dark). + public OrchestratorBuilder WithCrosstalkPlanner(CrosstalkPlanner? planner) => With(ref crosstalkPlanner, planner); + + /// Overrides the announcement source seam. Defaults to (feature-dark). + public OrchestratorBuilder WithAnnouncementSource(IAnnouncementSource? source) => With(ref announcementSource, source); + + /// Overrides the verbatim announcement renderer seam. Defaults to (feature-dark). + public OrchestratorBuilder WithAnnouncementRenderer(IVerbatimSegmentRenderer? renderer) => With(ref announcementRenderer, renderer); + + /// Overrides the TTS voice lister seam. Defaults to (feature-dark). + public OrchestratorBuilder WithVoiceLister(ITtsVoiceLister? lister) => With(ref voiceLister, lister); + + /// Overrides the announcement copy writer seam. Defaults to (feature-dark). + public OrchestratorBuilder WithAnnouncementCopyWriter(IAnnouncementCopyWriter? writer) => With(ref announcementCopyWriter, writer); + + /// Overrides the ad cadence seam. Defaults to (feature-dark — no ad ever fires). + public OrchestratorBuilder WithAdCadence(IAdCadenceProvider? adCadence) => With(ref adCadenceProvider, adCadence); + + /// Overrides the ad spot vend seam. Defaults to (feature-dark — no ad ever fires). + public OrchestratorBuilder WithAdSpotVend(IAdSpotVend? vend) => With(ref adSpotVend, vend); + + /// + /// Resolves every unset seam to its default and constructs the Orchestrator, returning it alongside + /// every collaborator a spec has ever needed to assert on directly. + /// + public OrchestratorChain Build() + { + if (scheduleResolver is not null && scheduleSet) + { + throw new InvalidOperationException("WithSchedule and WithScheduleResolver are exclusive"); + } + + var resolvedTime = timeProvider ?? new FakeTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + + var resolvedCatalog = catalog ?? new FakeMediaCatalog(TestData.MakeTrackRef("t1")); + + var resolvedMusicSelectionPolicy = musicSelectionPolicy + ?? new MusicSelectionPolicy(resolvedCatalog, NullLogger.Instance); + + var resolvedPersonaStore = personaStore ?? new FakePersonaStore(); + + var resolvedScheduleResolver = scheduleResolver; + if (resolvedScheduleResolver is null) + { + var scheduleStore = new FakeScheduleStore(snapshot); + var stationDefault = new FakeStationDefaultEnvelopeSource(SegmentEnvelope.StationDefault); + var resolver = new ScheduleResolver(resolvedTime, stationDefault); + resolvedScheduleResolver = new CachingScheduleResolver(scheduleStore, resolver, new FakeScheduleSpecialStore()); + } + + var resolvedPersonaAccessor = personaAccessor + ?? new OnAirPersonaAccessor(resolvedScheduleResolver, resolvedPersonaStore, NullLogger.Instance); + + // The render double's own RenderDelay rides the SAME fake clock as the render budget (STORY-442, + // PLAN T483) — mirrors ProductionChainHarness's own wiring. + var resolvedTts = tts ?? new FakeTtsSegmentSource { TimeProvider = resolvedTime }; + + var resolvedEvents = events ?? new CapturingStationEventSink(); + + var resolvedLogger = logger ?? new CapturingLogger(); + + var resolvedDeferralQueue = deferralQueue ?? new SpeechDeferralQueue(resolvedTime); + + var resolvedIdentityProvider = identityProvider + ?? new FakeStationIdentityProvider(new StationIdentity("s1", "GenWave", "default")); + var resolvedScopeProvider = scopeProvider ?? new FakeStationScopeProvider(new LibraryScope([1L])); + var resolvedCadenceProvider = cadenceProvider ?? new FakeCadenceProvider(new CadenceConfig + { + LeadInBeforeEachTrack = false, + BackAnnounceAfterEachTrack = false, + StationIdEveryNUnits = 0, + }); + var resolvedRotationProvider = rotationProvider ?? new FakeRotationSettingsProvider(new RotationSettings()); + var resolvedRenderBudgetProvider = renderBudgetProvider ?? new FakeRenderBudgetProvider(TimeSpan.FromSeconds(5)); + var resolvedBoundaryBiasProvider = boundaryBiasProvider ?? new FakeBoundaryBiasProvider(TimeSpan.Zero); + + var orchestrator = new Orchestrator( + resolvedIdentityProvider, + resolvedScopeProvider, + resolvedCadenceProvider, + resolvedRotationProvider, + resolvedMusicSelectionPolicy, + resolvedTts, + resolvedPersonaAccessor, + resolvedLogger, + resolvedRenderBudgetProvider, + resolvedDeferralQueue, + resolvedTime, + resolvedBoundaryBiasProvider, + scheduleResolver: resolvedScheduleResolver, + personaStore: resolvedPersonaStore, + events: resolvedEvents, + stationClock: stationClock, + patterEstimator: patterEstimator, + contextSettings: contextSettings, + catalog: resolvedCatalog, + imagingSettings: imagingSettings, + crosstalkPlanner: crosstalkPlanner, + announcementSource: announcementSource, + announcementRenderer: announcementRenderer, + voiceLister: voiceLister, + announcementCopyWriter: announcementCopyWriter, + adCadenceProvider: adCadenceProvider, + adSpotVend: adSpotVend); + + return new OrchestratorChain( + orchestrator, + resolvedDeferralQueue, + resolvedTime, + resolvedTts, + resolvedEvents, + resolvedLogger, + resolvedCatalog, + patterEstimator); + } +} diff --git a/tests/GenWave.TestSupport/OrchestratorChain.cs b/tests/GenWave.TestSupport/OrchestratorChain.cs new file mode 100644 index 00000000..e0061299 --- /dev/null +++ b/tests/GenWave.TestSupport/OrchestratorChain.cs @@ -0,0 +1,27 @@ +using GenWave.Core.Abstractions; +using GenWave.Orchestration; +using Microsoft.Extensions.Logging; + +namespace GenWave.TestSupport; + +/// +/// What returns (SPEC F184.2, STORY-451, PLAN T511) — the +/// Orchestrator plus every collaborator a spec has ever needed to reach into directly, so no spec +/// has to fall back to a constructor call to get at them. +/// +/// +/// Every slot is typed to the seam type the matching With* method accepts, so Build() +/// never throws for an override that compiles. A spec that needs a fake's own members on a default +/// seam (e.g. FakeTimeProvider.Advance) passes its own fake through the matching With* +/// and keeps a reference to it — the default-typed value here stays widened to the interface. +/// +/// +public sealed record OrchestratorChain( + Orchestrator Orchestrator, + SpeechDeferralQueue Queue, + TimeProvider Time, + ITtsSegmentSource Tts, + IStationEventSink Events, + ILogger Logger, + IMediaCatalog Catalog, + IPatterDurationEstimator? PatterEstimator); diff --git a/tests/GenWave.TestSupport/TestData.cs b/tests/GenWave.TestSupport/TestData.cs new file mode 100644 index 00000000..f3176212 --- /dev/null +++ b/tests/GenWave.TestSupport/TestData.cs @@ -0,0 +1,46 @@ +using GenWave.Core.Domain; +using GenWave.TestSupport.Fakes; + +namespace GenWave.TestSupport; + +/// +/// Small scripted-value factories shared across Orchestrator specs (moved out of +/// GenWave.Orchestration.Tests.Fakes.ProductionChainHarness at PLAN T511 so +/// 's own defaults can use them too, without either side +/// depending on the other). +/// +public static class TestData +{ + /// Builds a persona with the given id/name/voice; every other field a fixed, + /// arbitrary-but-stable value no spec has ever needed to vary. + public static Persona MakePersona(long id, string name, string voice) + { + var now = DateTime.UnixEpoch; + return new Persona(id, name, "", "", voice, now, now); + } + + /// Builds a playable for the given id; every other + /// field a fixed, arbitrary-but-stable value no spec has ever needed to vary. + public static MediaReference MakeTrackRef(string id) => new( + MediaId: id, + Locator: $"/media/{id}.mp3", + Title: $"Track {id}", + Loudness: new Loudness(-23.0, -1.0, true), + DurationMs: null, + SampleRate: null, + Channels: null, + BitrateKbps: null, + Artist: null, + Album: null, + Genre: null, + Year: null); + + /// A seeded with exactly one persona — the common + /// single-DJ-station shape most specs need. + public static FakePersonaStore OneDjStore(long id, string name, string voice) + { + var store = new FakePersonaStore(); + store.Add(MakePersona(id, name, voice)); + return store; + } +} diff --git a/tests/GenWave.Tts.Tests/Specs/Story456_SnapshotDrivesTheRender.cs b/tests/GenWave.Tts.Tests/Specs/Story456_SnapshotDrivesTheRender.cs new file mode 100644 index 00000000..16bb527a --- /dev/null +++ b/tests/GenWave.Tts.Tests/Specs/Story456_SnapshotDrivesTheRender.cs @@ -0,0 +1,75 @@ +// STORY-456 — The speaker travels with the plan — the render branch (gh-#772 · SPEC F189.3, F189.6 · PLAN T525, T526) +// +// BDD specification — xUnit. AC2–AC5 drive TtsSegmentSource with a recording synthesizer and ambient caches that throw on refresh; AC12 drives +// the Tts SpeakerSnapshotSource with an unknown id. +// +// RED at plan time: every fact is [Fact(Skip = Pending)] with a loud body — remove the Skip only in the +// task that makes it green. The builder comments name the arrange each scenario needs. + +namespace GenWave.Tts.Tests.Specs; + +public static class FeatureSnapshotDrivesTheRender +{ + const string Pending = "pending: T526 — TtsSegmentSource.RenderAsync branches on SegmentRequest.Speaker (STORY-456)"; + + // --------------------------------------------------------------------- + // HAPPY PATH + // --------------------------------------------------------------------- + + public sealed class ScenarioARequestWithASnapshot + { + // Given: Speaker pace 1.2 and one pronunciation rule + + /// AC2 — the synthesizer received 1.2 + [Fact(Skip = Pending)] + public void SendsTheSnapshotPace() => Assert.Fail(Pending); + + /// AC2 — the rule rewrote the copy + [Fact(Skip = Pending)] + public void AppliesTheSnapshotRule() => Assert.Fail(Pending); + } + + public sealed class ScenarioARequestWithASnapshotAndThrowingCaches + { + // Given: ambient caches throw on refresh + + /// AC3 — the snapshot bypasses the caches + [Fact(Skip = Pending)] + public void RendersSuccessfully() => Assert.Fail(Pending); + } + + public sealed class ScenarioTwoSnapshotsSameText + { + // Given: identical text, ContentHash differs + + /// AC4 — the cache key carries the snapshot + [Fact(Skip = Pending)] + public void SynthesizesTwice() => Assert.Fail(Pending); + } + + public sealed class ScenarioARequestWithoutASnapshot + { + // Given: Speaker null, ambient caches at pace 0.9 + + /// AC5 — today's path unchanged + [Fact(Skip = Pending)] + public void SendsTheAmbientPace() => Assert.Fail(Pending); + } + + // --------------------------------------------------------------------- + // SAD PATH — segregated + // --------------------------------------------------------------------- + + public sealed class ScenarioAnUnknownPersonaId + { + // Given: the snapshot source resolves an id that does not exist + + /// AC12 — + [Fact(Skip = Pending)] + public void FallsBackToTheStationSnapshot() => Assert.Fail(Pending); + + /// AC12 — + [Fact(Skip = Pending)] + public void LogsOneWarnNamingTheId() => Assert.Fail(Pending); + } +}