using System.Collections.Concurrent; using System.Text.Json; using MBN_STOCK_WEBVIEW.Core.Playout; using MBN_STOCK_WEBVIEW.Playout; using MBN_STOCK_WEBVIEW.Playout.Configuration; using MBN_STOCK_WEBVIEW.Playout.Diagnostics; using MBN_STOCK_WEBVIEW.Playout.Runtime; namespace MBN_STOCK_WEBVIEW.Playout.Tests; public sealed class PgmCutsSequenceRunnerTests { [Fact] public async Task StableTarget_ExecutesFixedSequenceWithExactSafetyAndMilestoneOrder() { using var environment = new EnvironmentScope() .Set("MBN_STOCK_PLAYOUT_MODE", "DryRun") .Set("MBN_STOCK_PLAYOUT_HOST", "192.0.2.99") .Set("MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL", "77") .Set("MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT", "false"); using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); var events = new ConcurrentQueue(); var safety = new FakeSafetyProbe(events); var engine = new FakeEngine(events); var factory = new FakeEngineFactory(events, engine); var runner = new PgmCutsSequenceRunner( safety, new FakeHashPins(true), factory, new FakeDelay(events), new FakeAssetLeases(true)); var report = await runner.ExecuteAsync( Request(scenes.Path), stage => events.Enqueue($"Milestone:{stage}")); Assert.True(report.Completed); Assert.False(report.OutcomeUnknown); Assert.False(report.QuarantineAttempted); Assert.Equal( new[] { "Safety:1", "CreateEngine", "Safety:2", "Connect", "Safety:3", "Safety:4", "Prepare:5001", "Safety:5", "Safety:6", "TakeIn", "Safety:7", "Safety:8", "Milestone:First", "Delay:5000", "Safety:9", "Safety:10", "Next:5006", "Safety:11", "Safety:12", "Milestone:Next", "Delay:5000", "Safety:13", "Safety:14", "TakeOut:All", "Safety:15", "Safety:16", "Milestone:TakeOut", "Delay:5000", "Safety:17", "Safety:18", "Disconnect", "Safety:19", "Dispose" }, events); Assert.Equal( new[] { PgmCutsSequenceStage.Connect, PgmCutsSequenceStage.PrepareFirst, PgmCutsSequenceStage.TakeIn, PgmCutsSequenceStage.Next, PgmCutsSequenceStage.TakeOut, PgmCutsSequenceStage.Disconnect }, report.Commands.Select(command => command.Stage)); var plan = Assert.IsType(factory.Plan); Assert.Equal(PlayoutMode.Live, plan.Options.Mode); Assert.Equal("127.0.0.1", plan.Options.Host); Assert.Equal(30_001, plan.Options.Port); Assert.Null(plan.Options.OutputChannel); Assert.Equal(10, plan.Options.LayoutIndex); Assert.Equal(["5001", "5006"], plan.Options.TestSceneAllowlist); Assert.True(plan.Options.TrustedLiveOutputEnabled); Assert.False(plan.Options.ReconnectEnabled); Assert.Equal(0, plan.Options.MaximumReconnectAttempts); Assert.Equal(TimeSpan.FromMilliseconds(5_000), plan.ObservationDuration); Assert.Equal("5001", plan.FirstCue.SceneName); Assert.Equal("5006", plan.NextCue.SceneName); Assert.Equal("5001.t2s", plan.FirstCue.SceneFile); Assert.Equal("5006.t2s", plan.NextCue.SceneFile); Assert.False(Path.IsPathFullyQualified(plan.FirstCue.SceneFile)); Assert.False(Path.IsPathFullyQualified(plan.NextCue.SceneFile)); Assert.True(Path.IsPathFullyQualified(plan.FirstAssetPath)); Assert.True(Path.IsPathFullyQualified(plan.NextAssetPath)); Assert.Equal("5001.t2s", Path.GetFileName(plan.FirstAssetPath)); Assert.Equal("5006.t2s", Path.GetFileName(plan.NextAssetPath)); } [Fact] public async Task RawPlanCue_IsResolvedExactlyOnceByTornadoEngineBeforeSdkPrepare() { using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); var events = new ConcurrentQueue(); var captureFactory = new FakeEngineFactory(events, new FakeEngine(events)); var runner = new PgmCutsSequenceRunner( new FakeSafetyProbe(events), new FakeHashPins(true), captureFactory, new FakeDelay(events), new FakeAssetLeases(true)); Assert.True((await runner.ExecuteAsync(Request(scenes.Path))).Completed); var plan = Assert.IsType(captureFactory.Plan); PlayoutCue? sdkCue = null; var session = new FakeK3dSession { PrepareAction = (cue, _) => sdkCue = cue }; var dispatcher = new StaDispatcher(plan.Options.QueueCapacity); await using var engine = new TornadoPlayoutEngine( plan.Options, new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), dispatcher, new FakeLiveAuthorization(true), TimeProvider.System, startMonitor: false); Assert.False(Path.IsPathFullyQualified(plan.FirstCue.SceneFile)); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); var prepare = await engine.PrepareAsync( plan.FirstCue, CancellationToken.None); Assert.True(prepare.IsSuccess); var resolvedCue = Assert.IsType(sdkCue); Assert.True(Path.IsPathFullyQualified(resolvedCue.SceneFile)); Assert.Equal( Path.GetFullPath(plan.FirstAssetPath), Path.GetFullPath(resolvedCue.SceneFile), StringComparer.OrdinalIgnoreCase); Assert.True((await engine.DisconnectAsync(CancellationToken.None)).IsSuccess); } [Theory] [InlineData(2)] [InlineData(3)] [InlineData(4)] [InlineData(5)] [InlineData(6)] [InlineData(7)] [InlineData(8)] [InlineData(9)] [InlineData(10)] [InlineData(11)] [InlineData(12)] [InlineData(13)] [InlineData(14)] [InlineData(15)] [InlineData(16)] [InlineData(17)] [InlineData(18)] [InlineData(19)] public async Task TargetChangeAtEveryBoundary_StopsAndQuarantinesWithoutLaterOutput( int changedCapture) { using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); var events = new ConcurrentQueue(); var engine = new FakeEngine(events); var runner = new PgmCutsSequenceRunner( new FakeSafetyProbe(events, changedCapture), new FakeHashPins(true), new FakeEngineFactory(events, engine), new FakeDelay(events), new FakeAssetLeases(true)); var report = await runner.ExecuteAsync( Request(scenes.Path), stage => events.Enqueue($"Milestone:{stage}")); Assert.False(report.Completed); Assert.Equal("pgm-target-changed", report.ErrorCode); Assert.True(report.QuarantineAttempted); Assert.True(report.QuarantineCompleted); Assert.Equal(1, events.Count(value => value == "Quarantine")); var quarantineIndex = Array.IndexOf(events.ToArray(), "Quarantine"); Assert.DoesNotContain( events.Skip(quarantineIndex + 1), value => IsOutputCommand(value) || value == "Disconnect"); if (changedCapture <= 18) { Assert.DoesNotContain("Disconnect", events); } Assert.Equal(changedCapture is > 2 and < 19, report.OutcomeUnknown); } [Theory] [InlineData((int)PgmCutsSequenceStage.Connect)] [InlineData((int)PgmCutsSequenceStage.PrepareFirst)] [InlineData((int)PgmCutsSequenceStage.TakeIn)] [InlineData((int)PgmCutsSequenceStage.Next)] [InlineData((int)PgmCutsSequenceStage.TakeOut)] [InlineData((int)PgmCutsSequenceStage.Disconnect)] public async Task OutcomeUnknownAtEveryCommand_QuarantinesWithoutSubsequentCommand( int stageValue) { var stage = (PgmCutsSequenceStage)stageValue; using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); var events = new ConcurrentQueue(); var engine = new FakeEngine(events); engine.Results[stage] = PlayoutResultCode.OutcomeUnknown; var runner = Runner(events, engine); var report = await runner.ExecuteAsync(Request(scenes.Path)); Assert.False(report.Completed); Assert.True(report.OutcomeUnknown); Assert.True(report.QuarantineCompleted); Assert.Equal("command-outcome-unknown", report.ErrorCode); var quarantineIndex = Array.IndexOf(events.ToArray(), "Quarantine"); Assert.DoesNotContain( events.Skip(quarantineIndex + 1), value => IsOutputCommand(value) || value == "Disconnect"); if (stage != PgmCutsSequenceStage.Disconnect) { Assert.DoesNotContain("Disconnect", events); } } [Fact] public async Task KnownPrepareFailure_PerformsExplicitSameTargetDisconnectCleanup() { using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); var events = new ConcurrentQueue(); var engine = new FakeEngine(events); engine.Results[PgmCutsSequenceStage.PrepareFirst] = PlayoutResultCode.Failed; var report = await Runner(events, engine).ExecuteAsync(Request(scenes.Path)); Assert.False(report.Completed); Assert.False(report.OutcomeUnknown); Assert.False(report.QuarantineAttempted); Assert.True(report.DisconnectAttempted); Assert.Equal( new[] { "Connect", "Prepare:5001", "Disconnect", "Dispose" }, EngineCalls(events)); } [Fact] public async Task QuarantineFailure_SkipsDisposeAndEveryFurtherSdkCommand() { using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); var events = new ConcurrentQueue(); var engine = new FakeEngine(events) { QuarantineFailure = new InvalidOperationException("fake quarantine failure") }; engine.Results[PgmCutsSequenceStage.Next] = PlayoutResultCode.OutcomeUnknown; var report = await Runner(events, engine).ExecuteAsync(Request(scenes.Path)); Assert.True(report.QuarantineAttempted); Assert.False(report.QuarantineCompleted); Assert.True(report.OutcomeUnknown); Assert.DoesNotContain("Dispose", events); Assert.DoesNotContain("Disconnect", events); Assert.Equal("Quarantine", events.Last()); } [Fact] public async Task TimedOutTakeOut_IsTreatedAsUnknownAndNeverDisconnects() { using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); var events = new ConcurrentQueue(); var engine = new FakeEngine(events); engine.Results[PgmCutsSequenceStage.TakeOut] = PlayoutResultCode.TimedOut; var report = await Runner(events, engine).ExecuteAsync(Request(scenes.Path)); Assert.True(report.RenderCommandAttempted); Assert.True(report.OutcomeUnknown); Assert.True(report.QuarantineCompleted); Assert.DoesNotContain("Disconnect", events); Assert.Equal(1, events.Count(value => value == "TakeOut:All")); } [Theory] [InlineData(1, false)] [InlineData(2, true)] public async Task InterruptedOnAirObservation_PerformsOneShotTakeOutThenDisconnect( int interruptedObservation, bool nextWasRendered) { using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); var events = new ConcurrentQueue(); var delay = new FakeDelay(events) { FailureOnCall = interruptedObservation }; var engine = new FakeEngine(events); var runner = new PgmCutsSequenceRunner( new FakeSafetyProbe(events), new FakeHashPins(true), new FakeEngineFactory(events, engine), delay, new FakeAssetLeases(true)); var report = await runner.ExecuteAsync(Request(scenes.Path)); Assert.False(report.Completed); Assert.False(report.OutcomeUnknown); Assert.False(report.QuarantineAttempted); Assert.Equal("observation-interrupted", report.ErrorCode); Assert.Equal(1, events.Count(value => value == "TakeOut:All")); Assert.Equal(1, events.Count(value => value == "Disconnect")); Assert.Equal(nextWasRendered, events.Contains("Next:5006")); Assert.Equal( new[] { PgmCutsSequenceStage.TakeOut, PgmCutsSequenceStage.Disconnect }, report.Commands.TakeLast(2).Select(command => command.Stage)); } [Theory] [InlineData(PlayoutResultCode.Failed)] [InlineData(PlayoutResultCode.TimedOut)] [InlineData(PlayoutResultCode.OutcomeUnknown)] public async Task InterruptedObservation_WhenOneShotTakeOutIsNotSuccessful_QuarantinesWithoutDisconnect( PlayoutResultCode takeOutCode) { using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); var events = new ConcurrentQueue(); var engine = new FakeEngine(events); engine.Results[PgmCutsSequenceStage.TakeOut] = takeOutCode; var delay = new FakeDelay(events) { FailureOnCall = 1 }; var runner = new PgmCutsSequenceRunner( new FakeSafetyProbe(events), new FakeHashPins(true), new FakeEngineFactory(events, engine), delay, new FakeAssetLeases(true)); var report = await runner.ExecuteAsync(Request(scenes.Path)); Assert.False(report.Completed); Assert.True(report.OutcomeUnknown); Assert.True(report.QuarantineCompleted); Assert.Equal(1, events.Count(value => value == "TakeOut:All")); Assert.DoesNotContain("Disconnect", events); var takeOutIndex = Array.IndexOf(events.ToArray(), "TakeOut:All"); Assert.DoesNotContain( events.Skip(takeOutIndex + 1), value => value == "TakeOut:All"); } [Fact] public async Task InterruptedPostTakeOutObservation_DoesNotRepeatTakeOutAndDisconnects() { using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); var events = new ConcurrentQueue(); var delay = new FakeDelay(events) { FailureOnCall = 3 }; var engine = new FakeEngine(events); var runner = new PgmCutsSequenceRunner( new FakeSafetyProbe(events), new FakeHashPins(true), new FakeEngineFactory(events, engine), delay, new FakeAssetLeases(true)); var report = await runner.ExecuteAsync(Request(scenes.Path)); Assert.False(report.Completed); Assert.False(report.OutcomeUnknown); Assert.False(report.QuarantineAttempted); Assert.Equal("observation-interrupted", report.ErrorCode); Assert.Equal(1, events.Count(value => value == "TakeOut:All")); Assert.Equal(1, events.Count(value => value == "Disconnect")); } [Fact] public async Task MissingPinsOrInvalidAssets_RejectBeforeEngineCreation() { using var missingScene = TemporarySceneDirectory.Create("5001.t2s"); using var validScenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); var missingPinEvents = new ConcurrentQueue(); var pinFactory = new FakeEngineFactory(missingPinEvents, new FakeEngine(missingPinEvents)); var missingPins = new PgmCutsSequenceRunner( new FakeSafetyProbe(missingPinEvents), new FakeHashPins(false), pinFactory, new FakeDelay(missingPinEvents)); var assetEvents = new ConcurrentQueue(); var assetFactory = new FakeEngineFactory(assetEvents, new FakeEngine(assetEvents)); var invalidAssets = new PgmCutsSequenceRunner( new FakeSafetyProbe(assetEvents), new FakeHashPins(true), assetFactory, new FakeDelay(assetEvents), new FakeAssetLeases(true)); var rejectedAssetEvents = new ConcurrentQueue(); var rejectedAssetFactory = new FakeEngineFactory( rejectedAssetEvents, new FakeEngine(rejectedAssetEvents)); var rejectedAssets = new PgmCutsSequenceRunner( new FakeSafetyProbe(rejectedAssetEvents), new FakeHashPins(true), rejectedAssetFactory, new FakeDelay(rejectedAssetEvents), new FakeAssetLeases(false)); var pinReport = await missingPins.ExecuteAsync(Request(validScenes.Path)); var assetReport = await invalidAssets.ExecuteAsync(Request(missingScene.Path)); var rejectedAssetReport = await rejectedAssets.ExecuteAsync(Request(validScenes.Path)); Assert.Equal("hash-pins-required", pinReport.ErrorCode); Assert.Equal("request-rejected", assetReport.ErrorCode); Assert.Equal("cut-assets-not-approved", rejectedAssetReport.ErrorCode); Assert.Equal(0, pinFactory.CreateCount); Assert.Equal(0, assetFactory.CreateCount); Assert.Equal(0, rejectedAssetFactory.CreateCount); } [Fact] public async Task ReportAndMilestonesContainNoEndpointTitleIdentityPathHashOrSceneCode() { using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); const string title = "Unique PGM Private Title"; var events = new ConcurrentQueue(); var report = await Runner(events, new FakeEngine(events)).ExecuteAsync( Request(scenes.Path) with { ExpectedPgmWindowTitle = title }); var json = JsonSerializer.Serialize(report); Assert.DoesNotContain("127.0.0.1", json, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("30001", json, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain(title, json, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain(scenes.Path, json, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("4242", json, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain(new string('A', 64), json, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("5001", json, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("5006", json, StringComparison.OrdinalIgnoreCase); Assert.All( events.Where(value => value.StartsWith("Milestone:", StringComparison.Ordinal)), value => Assert.DoesNotContain("500", value, StringComparison.Ordinal)); } [Fact] public void RunnerCapabilityAndEntryPointsAreNotPublic_AndNormalTestGateIsUnchanged() { Assert.False(typeof(PgmCutsSequenceRunner).IsPublic); Assert.False(typeof(PgmCutsSequenceRequest).IsPublic); Assert.False(typeof(PgmCutsSequenceReport).IsPublic); Assert.All( typeof(FullPgmCutsSequenceEngineFactory).GetNestedTypes( System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic), type => Assert.False(type.IsPublic)); using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); var normalTest = new PlayoutOptions { Mode = PlayoutMode.Test, SceneDirectory = scenes.Path, OutputChannel = 9, TestProcessWindowTitlePattern = "^Tornado2 PGM$", TestSceneAllowlist = ["5001", "5006"] }; Assert.Throws( () => ValidatedPlayoutOptions.Create(normalTest)); } [Fact] public async Task FullFactoryUsesPrivateCapabilityAndNeverReadsAmbientLiveAuthorization() { using var environment = new EnvironmentScope() .Set("MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT", "false"); using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); var events = new ConcurrentQueue(); var captureFactory = new FakeEngineFactory(events, new FakeEngine(events)); var runner = new PgmCutsSequenceRunner( new FakeSafetyProbe(events), new FakeHashPins(true), captureFactory, new FakeDelay(events), new FakeAssetLeases(true)); Assert.True((await runner.ExecuteAsync(Request(scenes.Path))).Completed); var plan = Assert.IsType(captureFactory.Plan); var baseline = new PgmDiagnosticTargetSnapshot( true, 1, 1, true, new PgmDiagnosticTargetIdentity(4_242, 123_456)); await using var engine = new FullPgmCutsSequenceEngineFactory( new FakeSafetyProbe(new ConcurrentQueue())) .Create(plan, baseline); var authorizationField = typeof(TornadoPlayoutEngine).GetField( "_liveAuthorization", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); Assert.NotNull(authorizationField); var authorization = Assert.IsAssignableFrom( authorizationField.GetValue(engine)); Assert.True(authorization.IsAuthorizedForThisLaunch); Assert.True(authorization.GetType().IsNestedPrivate); } [Fact] public void ProcessHashPinRequirementRequiresBothProcessScopedHexPins() { using var environment = new EnvironmentScope() .Set(InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable, null) .Set(InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable, null); var requirement = new ProcessPgmCutsSequenceHashPinRequirement(); Assert.False(requirement.IsSatisfied()); environment .Set( InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable, new string('A', 64)) .Set( InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable, new string('B', 64)); Assert.True(requirement.IsSatisfied()); environment.Set( InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable, "not-a-hash"); Assert.False(requirement.IsSatisfied()); } [Fact] public void CutAssetRequirementUsesExactApprovedHashesAndFixedTimeComparison() { Assert.Equal( "99CE3B689A42D8C42BEB09A86FA10C2D7C1AEF4F50D324D81276C1A1E4C4D8A7", FixedPgmCutsSequenceAssetLeaseFactory.FirstApprovedSha256); Assert.Equal( "25CD0AE931F51E4E3B84CE3E6FD21A40DB85464F157A23CC3511D63B336D8757", FixedPgmCutsSequenceAssetLeaseFactory.NextApprovedSha256); byte[] content = [1, 3, 3, 7, 42]; var contentHash = Convert.ToHexString( System.Security.Cryptography.SHA256.HashData(content)); using var matching = new MemoryStream(content, writable: false); using var rejected = new MemoryStream(content, writable: false); Assert.True(FixedPgmCutsSequenceAssetLeaseFactory.HasExpectedSha256( matching, contentHash)); Assert.False(FixedPgmCutsSequenceAssetLeaseFactory.HasExpectedSha256( rejected, FixedPgmCutsSequenceAssetLeaseFactory.FirstApprovedSha256)); } [Fact] public void SceneTrustRequiresFixedLocalRootAndFilesUnderThatRoot() { using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); using var outside = TemporarySceneDirectory.Create("outside.t2s"); Assert.True(PgmCutsSequenceRunner.IsTrustedLocalScenePaths( scenes.Path, Path.Combine(scenes.Path, "5001.t2s"), Path.Combine(scenes.Path, "5006.t2s"))); Assert.False(PgmCutsSequenceRunner.IsTrustedLocalScenePaths( scenes.Path, Path.Combine(scenes.Path, "5001.t2s"), Path.Combine(outside.Path, "outside.t2s"))); Assert.False(PgmCutsSequenceRunner.IsTrustedLocalScenePaths( "\\\\server\\share\\Cuts", "\\\\server\\share\\Cuts\\5001.t2s")); } private static PgmCutsSequenceRunner Runner( ConcurrentQueue events, FakeEngine engine) => new( new FakeSafetyProbe(events), new FakeHashPins(true), new FakeEngineFactory(events, engine), new FakeDelay(events), new FakeAssetLeases(true)); private static PgmCutsSequenceRequest Request(string sceneRoot) => new( "127.0.0.1", 30_001, "Tornado2 PGM", sceneRoot, 5_000); private static bool IsOutputCommand(string value) => value.StartsWith("Prepare:", StringComparison.Ordinal) || value == "TakeIn" || value.StartsWith("Next:", StringComparison.Ordinal) || value.StartsWith("TakeOut:", StringComparison.Ordinal); private static string[] EngineCalls(ConcurrentQueue events) => events.Where(value => value == "Connect" || IsOutputCommand(value) || value is "Disconnect" or "Quarantine" or "Dispose").ToArray(); private sealed class FakeHashPins : IPgmCutsSequenceHashPinRequirement { private readonly bool _satisfied; public FakeHashPins(bool satisfied) { _satisfied = satisfied; } public bool IsSatisfied() => _satisfied; } private sealed class FakeAssetLeases : IPgmCutsSequenceAssetLeaseFactory { private readonly bool _satisfied; public FakeAssetLeases(bool satisfied) { _satisfied = satisfied; } public bool TryAcquire( PgmCutsSequencePlan plan, out IPgmCutsSequenceAssetLease? lease) { if (!_satisfied) { lease = null; return false; } lease = new FakeAssetLease(); return true; } private sealed class FakeAssetLease : IPgmCutsSequenceAssetLease { public void Dispose() { } } } private sealed class FakeSafetyProbe : IPgmDiagnosticSafetyProbe { private static readonly PgmDiagnosticTargetSnapshot Baseline = new( true, 1, 1, true, new PgmDiagnosticTargetIdentity(4_242, 123_456)); private static readonly PgmDiagnosticTargetSnapshot Changed = new( true, 1, 1, true, new PgmDiagnosticTargetIdentity(4_243, 123_457)); private readonly ConcurrentQueue _events; private readonly int? _changedCapture; private int _captureCount; public FakeSafetyProbe( ConcurrentQueue events, int? changedCapture = null) { _events = events; _changedCapture = changedCapture; } public PgmDiagnosticTargetSnapshot Capture( PgmConnectDiagnosticRequest request, System.Net.IPAddress parsedHost) { Assert.Equal("127.0.0.1", parsedHost.ToString()); Assert.Equal(30_001, request.Port); var count = Interlocked.Increment(ref _captureCount); _events.Enqueue($"Safety:{count}"); return _changedCapture is { } changed && count >= changed ? Changed : Baseline; } } private sealed class FakeDelay : IPgmCutsSequenceDelay { private readonly ConcurrentQueue _events; private int _calls; public FakeDelay(ConcurrentQueue events) { _events = events; } public int? FailureOnCall { get; init; } public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) { var call = Interlocked.Increment(ref _calls); _events.Enqueue($"Delay:{delay.TotalMilliseconds:0}"); if (FailureOnCall == call) { throw new OperationCanceledException("fake observation interruption"); } return Task.CompletedTask; } } private sealed class FakeEngineFactory : IPgmCutsSequenceEngineFactory { private readonly ConcurrentQueue _events; private readonly FakeEngine _engine; public FakeEngineFactory( ConcurrentQueue events, FakeEngine engine) { _events = events; _engine = engine; } public int CreateCount { get; private set; } public PgmCutsSequencePlan? Plan { get; private set; } public IPlayoutEngine Create( PgmCutsSequencePlan plan, PgmDiagnosticTargetSnapshot baseline) { CreateCount++; Plan = plan; Assert.True(baseline.IsEligible); _events.Enqueue("CreateEngine"); return _engine; } } private sealed class FakeEngine : IPlayoutEngine { private readonly ConcurrentQueue _events; public FakeEngine(ConcurrentQueue events) { _events = events; } public Dictionary Results { get; } = []; public Exception? QuarantineFailure { get; init; } public PlayoutStatus Status { get; } = new( PlayoutMode.Live, PlayoutConnectionState.Disconnected, false, true, true, true, null, null, "fake", DateTimeOffset.UnixEpoch, 0); public event EventHandler? StatusChanged { add { } remove { } } public Task ConnectAsync(CancellationToken cancellationToken = default) { _events.Enqueue("Connect"); return Result(PgmCutsSequenceStage.Connect, PlayoutOperation.Connect); } public Task DisconnectAsync(CancellationToken cancellationToken = default) { _events.Enqueue("Disconnect"); return Result(PgmCutsSequenceStage.Disconnect, PlayoutOperation.Disconnect); } public Task PrepareAsync( PlayoutCue cue, CancellationToken cancellationToken = default) { _events.Enqueue($"Prepare:{cue.SceneName}"); return Result(PgmCutsSequenceStage.PrepareFirst, PlayoutOperation.Prepare); } public Task TakeInAsync(CancellationToken cancellationToken = default) { _events.Enqueue("TakeIn"); return Result(PgmCutsSequenceStage.TakeIn, PlayoutOperation.TakeIn); } public Task NextAsync( PlayoutCue cue, CancellationToken cancellationToken = default) { _events.Enqueue($"Next:{cue.SceneName}"); return Result(PgmCutsSequenceStage.Next, PlayoutOperation.Next); } public Task UpdateOnAirAsync( PlayoutCue cue, CancellationToken cancellationToken = default) { _events.Enqueue($"UpdateOnAir:{cue.SceneName}"); return Task.FromResult(new PlayoutResult( PlayoutOperation.UpdateOnAir, PlayoutResultCode.Success, false, "fake", DateTimeOffset.UnixEpoch)); } public Task TakeOutAsync( PlayoutTakeOutScope scope, CancellationToken cancellationToken = default) { _events.Enqueue($"TakeOut:{scope}"); return Result(PgmCutsSequenceStage.TakeOut, PlayoutOperation.TakeOut); } public ValueTask QuarantineAsync() { _events.Enqueue("Quarantine"); return QuarantineFailure is null ? ValueTask.CompletedTask : ValueTask.FromException(QuarantineFailure); } public ValueTask DisposeAsync() { _events.Enqueue("Dispose"); return ValueTask.CompletedTask; } private Task Result( PgmCutsSequenceStage stage, PlayoutOperation operation) { var code = Results.GetValueOrDefault(stage, PlayoutResultCode.Success); return Task.FromResult(new PlayoutResult( operation, code, false, "fake", DateTimeOffset.UnixEpoch)); } } }