using System.Collections.Concurrent; using System.Runtime.InteropServices; using MBN_STOCK_WEBVIEW.Playout.Configuration; using MBN_STOCK_WEBVIEW.Playout.Interop; using MBN_STOCK_WEBVIEW.Playout.Runtime; using MBN_STOCK_WEBVIEW.Playout.Safety; namespace MBN_STOCK_WEBVIEW.Playout.Tests; public sealed class TornadoPlayoutEngineTests { private const string GateACapability = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; [Theory] [InlineData(0, false)] [InlineData(1, true)] public async Task DefaultDryRun_MonitorsProcessWithoutCreatingComSession( int totalProcessCount, bool expectedRunning) { var sessionFactory = new FakeK3dSessionFactory(); var registration = new FakeRegistrationProbe(); var process = new FakeTornadoProcessProbe( new TornadoProcessSnapshot(totalProcessCount, totalProcessCount, 0)); await using var engine = CreateEngine( new PlayoutOptions(), sessionFactory, registration, process, liveAuthorized: false); await engine.PollProcessOnceAsync(CancellationToken.None); var connect = await engine.ConnectAsync(CancellationToken.None); var prepare = await engine.PrepareAsync( new PlayoutCue("never-resolved.t2s", "dry-run-scene"), CancellationToken.None); Assert.Equal(PlayoutMode.DryRun, engine.Status.Mode); Assert.Equal(expectedRunning, engine.Status.IsProcessRunning); Assert.Equal(PlayoutResultCode.Success, connect.Code); Assert.Equal(PlayoutResultCode.Success, prepare.Code); Assert.Equal(0, sessionFactory.CreateCount); Assert.Equal(0, registration.ProbeCount); Assert.Equal( PlayoutKtapConnectState.NotAttempted, engine.Status.LastKtapConnectState); Assert.False(engine.Status.NetworkMonitoringRecordExpected); Assert.False(engine.Status.NetworkMonitoringCheckRequired); Assert.Contains("Network Monitoring", engine.Status.Message, StringComparison.Ordinal); } [Fact] public async Task CancelledCommandBeforeSerialization_DoesNotProbeOrCreateSession() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var sessionFactory = new FakeK3dSessionFactory(); var registration = new FakeRegistrationProbe(); var process = new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)); await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), sessionFactory, registration, process, liveAuthorized: false); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); var result = await engine.ConnectAsync(cancellation.Token); Assert.Equal(PlayoutResultCode.Cancelled, result.Code); Assert.Equal(0, sessionFactory.CreateCount); Assert.Equal(0, registration.ProbeCount); Assert.Equal(0, process.CaptureCount); } [Theory] [InlineData(0, 0, 0, false, "찾을 수 없습니다")] [InlineData(2, 1, 0, false, "여러 개")] [InlineData(1, 0, 1, false, "PROGRAM")] [InlineData(1, 1, 0, true, null)] public async Task TestConnect_RequiresExactlyOneEligibleNonProgramInstance( int totalProcessCount, int eligibleProcessCount, int programProcessCount, bool expectedSuccess, string? expectedFailureText) { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var sessionFactory = new FakeK3dSessionFactory(); var registration = new FakeRegistrationProbe(); var process = new FakeTornadoProcessProbe(new TornadoProcessSnapshot( totalProcessCount, eligibleProcessCount, programProcessCount)); await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), sessionFactory, registration, process, liveAuthorized: false); var result = await engine.ConnectAsync(CancellationToken.None); Assert.Equal(expectedSuccess, result.IsSuccess); Assert.Equal(expectedSuccess ? 1 : 0, sessionFactory.CreateCount); Assert.Equal(expectedSuccess ? 1 : 0, registration.ProbeCount); Assert.Equal( expectedSuccess ? PlayoutKtapConnectState.AcceptedUnconfirmed : PlayoutKtapConnectState.NotAttempted, engine.Status.LastKtapConnectState); Assert.Equal(expectedSuccess, engine.Status.NetworkMonitoringRecordExpected); Assert.Equal(expectedSuccess, engine.Status.NetworkMonitoringCheckRequired); Assert.NotNull(process.LastPattern); if (expectedSuccess) { Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State); Assert.True(engine.Status.LiveTakeInAllowed); } else { Assert.Equal(PlayoutResultCode.Unavailable, result.Code); Assert.Contains(expectedFailureText!, result.Message, StringComparison.OrdinalIgnoreCase); Assert.False(engine.Status.LiveTakeInAllowed); } } [Fact] public async Task TestConnect_WhenKtapCallFails_ReportsAttemptedFailureEvidence() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var session = new FakeK3dSession { ConnectAction = _ => throw new InvalidOperationException("fake KTAP failure") }; await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); var result = await engine.ConnectAsync(CancellationToken.None); Assert.Equal(PlayoutResultCode.Failed, result.Code); Assert.Equal( PlayoutKtapConnectState.Failed, engine.Status.LastKtapConnectState); Assert.True(engine.Status.KtapConnectAttempted); Assert.Null(engine.Status.KtapConnectAccepted); Assert.Null(engine.Status.NetworkMonitoringRecordExpected); Assert.True(engine.Status.NetworkMonitoringCheckRequired); Assert.Null(engine.Status.KtapHelloObserved); } [Fact] public async Task TestConnect_WhenKtapCallTimesOut_PreservesAttemptedUnknownEvidence() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); using var connectStarted = new ManualResetEventSlim(); using var releaseConnect = new ManualResetEventSlim(); var session = new FakeK3dSession { ConnectAction = _ => { connectStarted.Set(); if (!releaseConnect.Wait(TimeSpan.FromSeconds(5))) { throw new TimeoutException("fake test coordination timeout"); } } }; var options = TestOptions(scenes.Path, "test-scene"); options.ConnectTimeoutMilliseconds = 100; await using var engine = CreateEngine( options, new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); try { var connectTask = engine.ConnectAsync(CancellationToken.None); Assert.True(connectStarted.Wait(TimeSpan.FromSeconds(2))); var result = await connectTask; Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code); Assert.Equal( PlayoutKtapConnectState.Attempted, engine.Status.LastKtapConnectState); Assert.True(engine.Status.KtapConnectAttempted); Assert.Null(engine.Status.KtapConnectAccepted); Assert.Null(engine.Status.NetworkMonitoringRecordExpected); Assert.True(engine.Status.NetworkMonitoringCheckRequired); Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); } finally { releaseConnect.Set(); } } [Fact] public async Task TestConnect_TimeoutDuringActivation_PreventsLateKtapDispatch() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); using var activationStarted = new ManualResetEventSlim(); using var releaseActivation = new ManualResetEventSlim(); var session = new FakeK3dSession { BeforeKtapDispatchAction = () => { activationStarted.Set(); if (!releaseActivation.Wait(TimeSpan.FromSeconds(5))) { throw new TimeoutException("fake activation coordination timeout"); } } }; var options = TestOptions(scenes.Path, "test-scene"); options.ConnectTimeoutMilliseconds = 100; await using var engine = CreateEngine( options, new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); try { var connectTask = engine.ConnectAsync(CancellationToken.None); Assert.True(activationStarted.Wait(TimeSpan.FromSeconds(2))); var result = await connectTask; Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code); Assert.Equal( PlayoutKtapConnectState.NotAttempted, engine.Status.LastKtapConnectState); Assert.False(engine.Status.KtapConnectAttempted); Assert.False(engine.Status.NetworkMonitoringRecordExpected); Assert.False(engine.Status.NetworkMonitoringCheckRequired); Assert.Equal(0, session.KtapDispatchCount); Assert.DoesNotContain("Dispose", session.Calls); releaseActivation.Set(); Assert.True(SpinWait.SpinUntil( () => session.Calls.Contains("Dispose"), TimeSpan.FromSeconds(2))); Assert.Equal(0, session.KtapDispatchCount); Assert.Equal(new[] { "Connect", "Dispose" }, session.Calls); Assert.Single(session.ThreadIds.Distinct()); Assert.All( session.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state)); } finally { releaseActivation.Set(); } } [Fact] public async Task TestConnect_BlockingFactoryThenConnectTimeout_PreventsLateKtapDispatch() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); using var factoryStarted = new ManualResetEventSlim(); using var releaseFactory = new ManualResetEventSlim(); using var beforeKtapDispatch = new ManualResetEventSlim(); using var releaseConnect = new ManualResetEventSlim(); var session = new FakeK3dSession { BeforeKtapDispatchAction = () => { beforeKtapDispatch.Set(); if (!releaseConnect.Wait(TimeSpan.FromSeconds(5))) { throw new TimeoutException("fake connect coordination timeout"); } } }; var sessionFactory = new FakeK3dSessionFactory(() => { factoryStarted.Set(); if (!releaseFactory.Wait(TimeSpan.FromSeconds(5))) { throw new TimeoutException("fake factory coordination timeout"); } return session; }); var options = TestOptions(scenes.Path, "test-scene"); options.ConnectTimeoutMilliseconds = 100; await using var engine = CreateEngine( options, sessionFactory, new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); try { var connectTask = Task.Run(() => engine.ConnectAsync(CancellationToken.None)); Assert.True(factoryStarted.Wait(TimeSpan.FromSeconds(2))); await Task.Delay(250); Assert.False(connectTask.IsCompleted); Assert.Equal(0, session.KtapDispatchCount); releaseFactory.Set(); Assert.True(beforeKtapDispatch.Wait(TimeSpan.FromSeconds(2))); var result = await connectTask; Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code); Assert.Equal(PlayoutKtapConnectState.NotAttempted, engine.Status.LastKtapConnectState); Assert.Equal(0, session.KtapDispatchCount); Assert.DoesNotContain("Dispose", session.Calls); releaseConnect.Set(); Assert.True(SpinWait.SpinUntil( () => session.Calls.Contains("Dispose"), TimeSpan.FromSeconds(2))); Assert.Equal(0, session.KtapDispatchCount); } finally { releaseFactory.Set(); releaseConnect.Set(); } } [Fact] public async Task GuardedConnect_FinalTargetFailure_PreventsKtapAndAbandonsWithoutDisconnect() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var session = new FakeK3dSession(); await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false, finalConnectSafetyCheck: () => false, abandonOnTargetMismatch: true); var result = await engine.ConnectAsync(CancellationToken.None); Assert.Equal(PlayoutResultCode.Failed, result.Code); Assert.Equal(0, session.KtapDispatchCount); Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } [Fact] public async Task GuardedConnect_KtapFailure_IsOutcomeUnknownAndNeverReconnectable() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var session = new FakeK3dSession { ConnectAction = _ => throw new COMException("fake ambiguous KTAP failure") }; var sessionFactory = new FakeK3dSessionFactory(() => session); await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), sessionFactory, new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false, finalConnectSafetyCheck: () => true, abandonOnTargetMismatch: true); var result = await engine.ConnectAsync(CancellationToken.None); Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code); Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); Assert.False(engine.Status.IsCommandAvailable); Assert.Equal(1, session.KtapDispatchCount); Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); await engine.PollProcessOnceAsync(CancellationToken.None); Assert.Equal(1, sessionFactory.CreateCount); } [Fact] public async Task GuardedConnect_CallbackCancellationAfterKtap_IsOutcomeUnknown() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var session = new FakeK3dSession { ConnectAction = _ => throw new OperationCanceledException( "fake cancellation after KTAP dispatch") }; await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false, finalConnectSafetyCheck: () => true, abandonOnTargetMismatch: true); var result = await engine.ConnectAsync(CancellationToken.None); Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code); Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); Assert.Equal(1, session.KtapDispatchCount); Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } [Fact] public async Task GuardedConnect_CancelledBeforeSerialization_RemainsKnownAndDoesNotCreateSession() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var sessionFactory = new FakeK3dSessionFactory(); await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), sessionFactory, new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false, finalConnectSafetyCheck: () => true, abandonOnTargetMismatch: true); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); var result = await engine.ConnectAsync(cancellation.Token); Assert.Equal(PlayoutResultCode.Cancelled, result.Code); Assert.Equal(0, sessionFactory.CreateCount); Assert.Equal(PlayoutKtapConnectState.NotAttempted, engine.Status.LastKtapConnectState); } [Fact] public async Task GuardedConnect_SlowSafetyCheckPastTimeoutNeverDispatchesKtap() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); using var safetyStarted = new ManualResetEventSlim(); using var releaseSafety = new ManualResetEventSlim(); var session = new FakeK3dSession(); var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false, finalConnectSafetyCheck: () => { safetyStarted.Set(); Assert.True(releaseSafety.Wait(TimeSpan.FromSeconds(5))); return true; }, abandonOnTargetMismatch: true); try { var connectTask = engine.ConnectAsync(CancellationToken.None); Assert.True(safetyStarted.Wait(TimeSpan.FromSeconds(2))); var result = await connectTask; Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code); Assert.Equal(0, session.KtapDispatchCount); releaseSafety.Set(); Assert.True(SpinWait.SpinUntil( () => session.Calls.Contains("Abandon"), TimeSpan.FromSeconds(2))); Assert.Equal(0, session.KtapDispatchCount); await Assert.ThrowsAsync( () => engine.QuarantineAsync().AsTask()); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } finally { releaseSafety.Set(); await engine.DisposeAsync(); } } [Fact] public async Task GuardedConnect_TimeoutWinningAtomicClaimPreventsKtapDispatch() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); using var claimStarted = new ManualResetEventSlim(); using var releaseClaim = new ManualResetEventSlim(); var session = new FakeK3dSession(); var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false, finalConnectSafetyCheck: () => true, abandonOnTargetMismatch: true, beforeSdkDispatchClaim: () => { claimStarted.Set(); Assert.True(releaseClaim.Wait(TimeSpan.FromSeconds(5))); }); try { var connectTask = engine.ConnectAsync(CancellationToken.None); Assert.True(claimStarted.Wait(TimeSpan.FromSeconds(2))); var connect = await connectTask; Assert.Equal(PlayoutResultCode.OutcomeUnknown, connect.Code); Assert.Equal(0, session.KtapDispatchCount); releaseClaim.Set(); Assert.True(SpinWait.SpinUntil( () => session.Calls.Contains("Abandon"), TimeSpan.FromSeconds(2))); Assert.Equal(0, session.KtapDispatchCount); await Assert.ThrowsAsync( () => engine.QuarantineAsync().AsTask()); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } finally { releaseClaim.Set(); await engine.DisposeAsync(); } } [Fact] public async Task GuardedConnect_PostGenerationChange_AbandonsWithoutDisconnect() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var original = ProcessSnapshot("pgm-generation-a"); var changed = ProcessSnapshot("pgm-generation-b"); var session = new FakeK3dSession(); var finalChecks = 0; await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(original, changed), liveAuthorized: false, finalConnectSafetyCheck: () => Interlocked.Increment(ref finalChecks) == 1, abandonOnTargetMismatch: true); var result = await engine.ConnectAsync(CancellationToken.None); Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code); Assert.Equal(1, finalChecks); Assert.Equal(1, session.KtapDispatchCount); Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } [Fact] public async Task GuardedTargetPolicy_PreCommandGenerationChange_AbandonsWithoutDisconnect() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var original = ProcessSnapshot("pgm-generation-a"); var changed = ProcessSnapshot("pgm-generation-b"); var session = new FakeK3dSession(); await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(original, original, changed), liveAuthorized: false, finalConnectSafetyCheck: () => true, abandonOnTargetMismatch: true); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); var prepare = await engine.PrepareAsync( Cue("test-scene"), CancellationToken.None); Assert.Equal(PlayoutResultCode.OutcomeUnknown, prepare.Code); Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); Assert.DoesNotContain(session.Calls, call => call.StartsWith("Prepare:", StringComparison.Ordinal)); } [Fact] public async Task GuardedTargetPolicy_AmbiguousPrepareFailure_AbandonsWithoutDisconnect() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var stable = ProcessSnapshot("pgm-generation-a"); var session = new FakeK3dSession { PrepareAction = (_, _) => throw new COMException("fake ambiguous prepare failure") }; await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(stable), liveAuthorized: false, finalConnectSafetyCheck: () => true, abandonOnTargetMismatch: true); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); var prepare = await engine.PrepareAsync( Cue("test-scene"), CancellationToken.None); Assert.Equal(PlayoutResultCode.OutcomeUnknown, prepare.Code); Assert.Equal( new[] { "Connect", "Prepare:test-scene", "Abandon" }, session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } [Fact] public async Task GuardedTargetPolicy_NextChecksTargetBetweenPrepareAndPlay() { using var scenes = TemporarySceneDirectory.Create( "test-scene.t2s", "next-scene.t2s"); var stable = ProcessSnapshot("pgm-generation-a"); var session = new FakeK3dSession(); var safetyChecks = 0; await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene", "next-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(stable), liveAuthorized: false, finalConnectSafetyCheck: () => Interlocked.Increment(ref safetyChecks) != 7, abandonOnTargetMismatch: true); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync( Cue("test-scene"), CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); var next = await engine.NextAsync( Cue("next-scene"), CancellationToken.None); Assert.Equal(PlayoutResultCode.OutcomeUnknown, next.Code); Assert.Equal(7, safetyChecks); Assert.Equal(1, session.Calls.Count(call => call == "Play")); Assert.Equal( new[] { "Connect", "Prepare:test-scene", "Play", "Prepare:next-scene", "Abandon" }, session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } [Fact] public async Task GuardedTargetPolicy_DisconnectPrecheckFailureAbandonsWithoutDisconnect() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var stable = ProcessSnapshot("pgm-generation-a"); var session = new FakeK3dSession(); var safetyChecks = 0; await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(stable), liveAuthorized: false, finalConnectSafetyCheck: () => Interlocked.Increment(ref safetyChecks) == 1, abandonOnTargetMismatch: true); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); var disconnect = await engine.DisconnectAsync(CancellationToken.None); Assert.Equal(PlayoutResultCode.OutcomeUnknown, disconnect.Code); Assert.Equal(2, safetyChecks); Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } [Fact] public async Task GuardedTargetPolicy_SlowCommandSafetyCheckPastTimeoutNeverDispatchesSdkCall() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); using var safetyStarted = new ManualResetEventSlim(); using var releaseSafety = new ManualResetEventSlim(); var stable = ProcessSnapshot("pgm-generation-a"); var session = new FakeK3dSession(); var safetyChecks = 0; var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(stable), liveAuthorized: false, finalConnectSafetyCheck: () => { if (Interlocked.Increment(ref safetyChecks) == 2) { safetyStarted.Set(); Assert.True(releaseSafety.Wait(TimeSpan.FromSeconds(5))); } return true; }, abandonOnTargetMismatch: true); try { Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); var prepareTask = engine.PrepareAsync( Cue("test-scene"), CancellationToken.None); Assert.True(safetyStarted.Wait(TimeSpan.FromSeconds(2))); var prepare = await prepareTask; Assert.Equal(PlayoutResultCode.OutcomeUnknown, prepare.Code); releaseSafety.Set(); Assert.True(SpinWait.SpinUntil( () => session.Calls.Contains("Abandon"), TimeSpan.FromSeconds(2))); Assert.DoesNotContain( session.Calls, call => call.StartsWith("Prepare:", StringComparison.Ordinal)); await Assert.ThrowsAsync( () => engine.QuarantineAsync().AsTask()); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } finally { releaseSafety.Set(); await engine.DisposeAsync(); } } [Fact] public async Task GuardedTargetPolicy_SlowDisconnectSafetyCheckPastTimeoutNeverDisconnects() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); using var safetyStarted = new ManualResetEventSlim(); using var releaseSafety = new ManualResetEventSlim(); var stable = ProcessSnapshot("pgm-generation-a"); var session = new FakeK3dSession(); var safetyChecks = 0; var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(stable), liveAuthorized: false, finalConnectSafetyCheck: () => { if (Interlocked.Increment(ref safetyChecks) == 2) { safetyStarted.Set(); Assert.True(releaseSafety.Wait(TimeSpan.FromSeconds(5))); } return true; }, abandonOnTargetMismatch: true); try { Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); var disconnectTask = engine.DisconnectAsync(CancellationToken.None); Assert.True(safetyStarted.Wait(TimeSpan.FromSeconds(2))); var disconnect = await disconnectTask; Assert.Equal(PlayoutResultCode.OutcomeUnknown, disconnect.Code); releaseSafety.Set(); Assert.True(SpinWait.SpinUntil( () => session.Calls.Contains("Abandon"), TimeSpan.FromSeconds(2))); await Assert.ThrowsAsync( () => engine.QuarantineAsync().AsTask()); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } finally { releaseSafety.Set(); await engine.DisposeAsync(); } } [Fact] public async Task GuardedTargetPolicy_TimeoutWinningAtomicCommandClaimPreventsPrepare() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); using var claimStarted = new ManualResetEventSlim(); using var releaseClaim = new ManualResetEventSlim(); var session = new FakeK3dSession(); var claims = 0; var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(ProcessSnapshot("pgm-generation-a")), liveAuthorized: false, finalConnectSafetyCheck: () => true, abandonOnTargetMismatch: true, beforeSdkDispatchClaim: () => { if (Interlocked.Increment(ref claims) == 2) { claimStarted.Set(); Assert.True(releaseClaim.Wait(TimeSpan.FromSeconds(5))); } }); try { Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); var prepareTask = engine.PrepareAsync( Cue("test-scene"), CancellationToken.None); Assert.True(claimStarted.Wait(TimeSpan.FromSeconds(2))); var prepare = await prepareTask; Assert.Equal(PlayoutResultCode.OutcomeUnknown, prepare.Code); releaseClaim.Set(); Assert.True(SpinWait.SpinUntil( () => session.Calls.Contains("Abandon"), TimeSpan.FromSeconds(2))); Assert.DoesNotContain( session.Calls, call => call.StartsWith("Prepare:", StringComparison.Ordinal)); await Assert.ThrowsAsync( () => engine.QuarantineAsync().AsTask()); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } finally { releaseClaim.Set(); await engine.DisposeAsync(); } } [Fact] public async Task GuardedTargetPolicy_NextUsesSeparateAtomicClaimsForPrepareAndPlay() { using var scenes = TemporarySceneDirectory.Create( "test-scene.t2s", "next-scene.t2s"); using var playClaimStarted = new ManualResetEventSlim(); using var releasePlayClaim = new ManualResetEventSlim(); var session = new FakeK3dSession(); var claims = 0; var engine = CreateEngine( TestOptions(scenes.Path, "test-scene", "next-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(ProcessSnapshot("pgm-generation-a")), liveAuthorized: false, finalConnectSafetyCheck: () => true, abandonOnTargetMismatch: true, beforeSdkDispatchClaim: () => { if (Interlocked.Increment(ref claims) == 5) { playClaimStarted.Set(); Assert.True(releasePlayClaim.Wait(TimeSpan.FromSeconds(5))); } }); try { Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync( Cue("test-scene"), CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); var nextTask = engine.NextAsync( Cue("next-scene"), CancellationToken.None); Assert.True(playClaimStarted.Wait(TimeSpan.FromSeconds(2))); var next = await nextTask; Assert.Equal(PlayoutResultCode.OutcomeUnknown, next.Code); Assert.Contains("Prepare:next-scene", session.Calls); Assert.Equal(1, session.Calls.Count(call => call == "Play")); releasePlayClaim.Set(); Assert.True(SpinWait.SpinUntil( () => session.Calls.Contains("Abandon"), TimeSpan.FromSeconds(2))); Assert.Equal(1, session.Calls.Count(call => call == "Play")); await Assert.ThrowsAsync( () => engine.QuarantineAsync().AsTask()); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } finally { releasePlayClaim.Set(); await engine.DisposeAsync(); } } [Fact] public async Task GuardedTargetPolicy_TimeoutWinningAtomicDisconnectClaimPreventsDisconnect() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); using var claimStarted = new ManualResetEventSlim(); using var releaseClaim = new ManualResetEventSlim(); var session = new FakeK3dSession(); var claims = 0; var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(ProcessSnapshot("pgm-generation-a")), liveAuthorized: false, finalConnectSafetyCheck: () => true, abandonOnTargetMismatch: true, beforeSdkDispatchClaim: () => { if (Interlocked.Increment(ref claims) == 2) { claimStarted.Set(); Assert.True(releaseClaim.Wait(TimeSpan.FromSeconds(5))); } }); try { Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); var disconnectTask = engine.DisconnectAsync(CancellationToken.None); Assert.True(claimStarted.Wait(TimeSpan.FromSeconds(2))); var disconnect = await disconnectTask; Assert.Equal(PlayoutResultCode.OutcomeUnknown, disconnect.Code); releaseClaim.Set(); Assert.True(SpinWait.SpinUntil( () => session.Calls.Contains("Abandon"), TimeSpan.FromSeconds(2))); await Assert.ThrowsAsync( () => engine.QuarantineAsync().AsTask()); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } finally { releaseClaim.Set(); await engine.DisposeAsync(); } } [Fact] public async Task LastKtapEvidence_SurvivesLaterFailureBeforeAnotherDispatch() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var session = new FakeK3dSession(); var createCount = 0; var sessionFactory = new FakeK3dSessionFactory(() => Interlocked.Increment(ref createCount) == 1 ? session : throw new InvalidOperationException("fake activation-stage failure")); await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), sessionFactory, new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.DisconnectAsync(CancellationToken.None)).IsSuccess); var secondConnect = await engine.ConnectAsync(CancellationToken.None); Assert.Equal(PlayoutResultCode.Failed, secondConnect.Code); Assert.Equal(PlayoutConnectionState.Faulted, engine.Status.State); Assert.Equal( PlayoutKtapConnectState.AcceptedUnconfirmed, engine.Status.LastKtapConnectState); Assert.True(engine.Status.NetworkMonitoringRecordExpected); Assert.True(engine.Status.NetworkMonitoringCheckRequired); Assert.Equal(2, sessionFactory.CreateCount); } [Fact] public async Task TestWorkflow_ExecutesFakeSessionInCommandOrderOnSta() { using var scenes = TemporarySceneDirectory.Create("first.t2s", "next.t2s"); var session = new FakeK3dSession(); var sessionFactory = new FakeK3dSessionFactory(() => session); var process = new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)); await using var engine = CreateEngine( TestOptions(scenes.Path, "first", "next"), sessionFactory, new FakeRegistrationProbe(), process, liveAuthorized: false); var connect = await engine.ConnectAsync(CancellationToken.None); Assert.True(engine.Status.LiveTakeInAllowed); var prepare = await engine.PrepareAsync(Cue("first"), CancellationToken.None); var takeIn = await engine.TakeInAsync(CancellationToken.None); var next = await engine.NextAsync(Cue("next"), CancellationToken.None); var takeOut = await engine.TakeOutAsync( PlayoutTakeOutScope.All, CancellationToken.None); var disconnect = await engine.DisconnectAsync(CancellationToken.None); Assert.All( new[] { connect, prepare, takeIn, next, takeOut, disconnect }, result => Assert.Equal(PlayoutResultCode.Success, result.Code)); Assert.Equal( new[] { "Connect", "Prepare:first", "Play", "Prepare:next", "Play", "TakeOut:All", "Disconnect", "Dispose" }, session.Calls); Assert.Single(session.ThreadIds.Distinct()); Assert.All(session.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state)); Assert.Equal(PlayoutConnectionState.Disconnected, engine.Status.State); Assert.False(engine.Status.LiveTakeInAllowed); Assert.Null(engine.Status.PreparedSceneName); Assert.Null(engine.Status.OnAirSceneName); } [Fact] public async Task UpdateOnAir_TestMode_UsesSerializedSessionPathAndKeepsSceneOnAir() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var session = new FakeK3dSession(); await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync( Cue("test-scene"), CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); var result = await engine.UpdateOnAirAsync( Cue("test-scene") with { Fields = [new PlayoutField("headline", "page two", true)] }, CancellationToken.None); Assert.True(result.IsSuccess); Assert.Equal(PlayoutOperation.UpdateOnAir, result.Operation); Assert.False(result.IsDryRun); Assert.Equal("test-scene", engine.Status.OnAirSceneName); Assert.Null(engine.Status.PreparedSceneName); Assert.Equal( new[] { "Connect", "Prepare:test-scene", "Play", "UpdateOnAir:test-scene" }, session.Calls); Assert.Single(session.ThreadIds.Distinct()); Assert.All(session.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state)); } [Fact] public async Task UpdateOnAir_RejectsIdleOrDifferentSceneBeforeSessionDispatch() { using var scenes = TemporarySceneDirectory.Create("first.t2s", "other.t2s"); var session = new FakeK3dSession(); await using var engine = CreateEngine( TestOptions(scenes.Path, "first", "other"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); var idle = await engine.UpdateOnAirAsync(Cue("first"), CancellationToken.None); Assert.True((await engine.PrepareAsync(Cue("first"), CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); var different = await engine.UpdateOnAirAsync( Cue("other"), CancellationToken.None); Assert.Equal(PlayoutResultCode.Rejected, idle.Code); Assert.Equal(PlayoutResultCode.Rejected, different.Code); Assert.Equal("first", engine.Status.OnAirSceneName); Assert.DoesNotContain(session.Calls, call => call.StartsWith("UpdateOnAir:", StringComparison.Ordinal)); } [Fact] public async Task UpdateOnAir_AmbiguousFailureLatchesOutcomeUnknownAndIsNeverRetried() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var session = new FakeK3dSession { UpdateOnAirAction = (_, _) => throw new COMException("fake ambiguous on-air update failure") }; await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync( Cue("test-scene"), CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); var failed = await engine.UpdateOnAirAsync( Cue("test-scene"), CancellationToken.None); var later = await engine.UpdateOnAirAsync( Cue("test-scene"), CancellationToken.None); Assert.Equal(PlayoutResultCode.OutcomeUnknown, failed.Code); Assert.Equal(PlayoutResultCode.OutcomeUnknown, later.Code); Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); Assert.Equal( 1, session.Calls.Count(call => call == "UpdateOnAir:test-scene")); Assert.Contains("Abandon", session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } [Fact] public async Task UnchangedProcessGeneration_DoesNotReconnectAnExistingSession() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var session = new FakeK3dSession(); var sessionFactory = new FakeK3dSessionFactory(() => session); var unchanged = ProcessSnapshot("eligible-generation-a"); await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), sessionFactory, new FakeRegistrationProbe(), new FakeTornadoProcessProbe(unchanged), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); await engine.PollProcessOnceAsync(CancellationToken.None); Assert.Equal(1, sessionFactory.CreateCount); Assert.Equal(new[] { "Connect" }, session.Calls); Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State); } [Fact] public async Task ChangedProcessGeneration_CleansOldSessionBeforeReconnect() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var calls = new ConcurrentQueue(); var first = new FakeK3dSession { CallObserver = call => calls.Enqueue($"first:{call}") }; var second = new FakeK3dSession { CallObserver = call => calls.Enqueue($"second:{call}") }; var created = 0; var sessionFactory = new FakeK3dSessionFactory( () => Interlocked.Increment(ref created) == 1 ? first : second); var original = ProcessSnapshot("eligible-generation-a"); var replacement = ProcessSnapshot("eligible-generation-b"); var options = TestOptions(scenes.Path, "test-scene"); options.ReconnectDelayMilliseconds = 0; await using var engine = CreateEngine( options, sessionFactory, new FakeRegistrationProbe(), new FakeTornadoProcessProbe(original, original, replacement), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); await engine.PollProcessOnceAsync(CancellationToken.None); Assert.Equal(2, sessionFactory.CreateCount); Assert.Equal( new[] { "first:Connect", "first:Disconnect", "first:Dispose", "second:Connect" }, calls); Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State); Assert.DoesNotContain("eligible-generation-a", engine.Status.Message, StringComparison.Ordinal); Assert.DoesNotContain("eligible-generation-b", engine.Status.Message, StringComparison.Ordinal); } [Fact] public async Task UnreliableProcessIdentity_FailsClosedBeforeRegistrationOrCom() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var sessionFactory = new FakeK3dSessionFactory(); var registration = new FakeRegistrationProbe(); var snapshot = ProcessSnapshot("unreliable") with { IdentityInspectionSucceeded = false }; await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), sessionFactory, registration, new FakeTornadoProcessProbe(snapshot), liveAuthorized: false); var result = await engine.ConnectAsync(CancellationToken.None); Assert.Equal(PlayoutResultCode.Unavailable, result.Code); Assert.Equal(0, registration.ProbeCount); Assert.Equal(0, sessionFactory.CreateCount); } [Fact] public void ProcessGeneration_TracksEligibleAndProgramIdentitiesIndependently() { var baseline = ProcessSnapshot("eligible-generation-a") with { ProgramProcessGeneration = new TornadoProcessGeneration("program-generation-a") }; var same = ProcessSnapshot("eligible-generation-a") with { ProgramProcessGeneration = new TornadoProcessGeneration("program-generation-a") }; var changedProgram = same with { ProgramProcessGeneration = new TornadoProcessGeneration("program-generation-b") }; Assert.True(baseline.HasSameProcessGeneration(same)); Assert.False(baseline.HasSameProcessGeneration(changedProgram)); } [Fact] public async Task TestNextWithoutOnAirScene_IsRejectedBeforeNextSessionCalls() { using var scenes = TemporarySceneDirectory.Create("first.t2s", "next.t2s"); var session = new FakeK3dSession(); await using var engine = CreateEngine( TestOptions(scenes.Path, "first", "next"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(ProcessSnapshot("eligible-generation-a")), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); var callsBeforeIdleNext = session.Calls.ToArray(); var idleNext = await engine.NextAsync(Cue("next"), CancellationToken.None); Assert.Equal(PlayoutResultCode.Rejected, idleNext.Code); Assert.Equal(callsBeforeIdleNext, session.Calls); Assert.True((await engine.PrepareAsync(Cue("first"), CancellationToken.None)).IsSuccess); var callsBeforeNext = session.Calls.ToArray(); var next = await engine.NextAsync(Cue("next"), CancellationToken.None); Assert.Equal(PlayoutResultCode.Rejected, next.Code); Assert.Equal(callsBeforeNext, session.Calls); Assert.DoesNotContain("Play", session.Calls); Assert.Equal("first", engine.Status.PreparedSceneName); Assert.Null(engine.Status.OnAirSceneName); } [Theory] [InlineData(100)] [InlineData(837)] [InlineData(300_000)] public async Task Status_ExposesValidatedOperationTimeoutMilliseconds( int operationTimeoutMilliseconds) { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var options = TestOptions(scenes.Path, "test-scene"); options.OperationTimeoutMilliseconds = operationTimeoutMilliseconds; await using var engine = CreateEngine( options, new FakeK3dSessionFactory(), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(ProcessSnapshot("eligible-generation-a")), liveAuthorized: false); Assert.Equal(operationTimeoutMilliseconds, engine.Status.OperationTimeoutMilliseconds); } [Fact] public async Task TestSceneOutsideAllowlist_IsRejectedBeforeSessionCall() { using var scenes = TemporarySceneDirectory.Create("allowed.t2s", "blocked.t2s"); var session = new FakeK3dSession(); var sessionFactory = new FakeK3dSessionFactory(() => session); await using var engine = CreateEngine( TestOptions(scenes.Path, "allowed"), sessionFactory, new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); var prepare = await engine.PrepareAsync(Cue("blocked"), CancellationToken.None); var next = await engine.NextAsync(Cue("blocked"), CancellationToken.None); Assert.Equal(PlayoutResultCode.Rejected, prepare.Code); Assert.Equal(PlayoutResultCode.Rejected, next.Code); Assert.DoesNotContain(session.Calls, call => call.StartsWith("Prepare:", StringComparison.Ordinal)); Assert.DoesNotContain("blocked.t2s", prepare.Message, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain(scenes.Path, prepare.Message, StringComparison.OrdinalIgnoreCase); } [Theory] [InlineData(false, false)] [InlineData(false, true)] [InlineData(true, false)] public async Task LiveWithoutBothAuthorizations_RejectsCommandsBeforeCom( bool trustedLiveOutputEnabled, bool launchAuthorized) { using var scenes = TemporarySceneDirectory.Create("live-scene.t2s"); var sessionFactory = new FakeK3dSessionFactory(); var registration = new FakeRegistrationProbe(); await using var engine = CreateEngine( LiveOptions(scenes.Path, trustedLiveOutputEnabled), sessionFactory, registration, new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), launchAuthorized); var results = new[] { await engine.ConnectAsync(CancellationToken.None), await engine.PrepareAsync(Cue("live-scene"), CancellationToken.None), await engine.TakeInAsync(CancellationToken.None), await engine.NextAsync(Cue("live-scene"), CancellationToken.None), await engine.UpdateOnAirAsync(Cue("live-scene"), CancellationToken.None), await engine.TakeOutAsync(PlayoutTakeOutScope.All, CancellationToken.None) }; Assert.All(results, result => Assert.Equal(PlayoutResultCode.Rejected, result.Code)); Assert.Equal(0, sessionFactory.CreateCount); Assert.Equal(0, registration.ProbeCount); Assert.False(engine.Status.LiveTakeInAllowed); } [Fact] public async Task LiveWithBothAuthorizations_CanUseFakeSessionOnly() { using var scenes = TemporarySceneDirectory.Create("live-scene.t2s"); var session = new FakeK3dSession(); await using var engine = CreateEngine( LiveOptions(scenes.Path, trustedLiveOutputEnabled: true), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: true); var connect = await engine.ConnectAsync(CancellationToken.None); var prepare = await engine.PrepareAsync(Cue("live-scene"), CancellationToken.None); var takeIn = await engine.TakeInAsync(CancellationToken.None); var update = await engine.UpdateOnAirAsync( Cue("live-scene"), CancellationToken.None); Assert.True(connect.IsSuccess); Assert.True(prepare.IsSuccess); Assert.True(takeIn.IsSuccess); Assert.True(update.IsSuccess); Assert.True(engine.Status.LiveTakeInAllowed); Assert.Contains("Play", session.Calls); Assert.Contains("UpdateOnAir:live-scene", session.Calls); } [Fact] public async Task LiveWithBothAuthorizations_RejectsSceneOutsideClosedAllowlist() { using var scenes = TemporarySceneDirectory.Create( "live-scene.t2s", "blocked-scene.t2s"); var session = new FakeK3dSession(); await using var engine = CreateEngine( LiveOptions(scenes.Path, trustedLiveOutputEnabled: true), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: true); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); var prepare = await engine.PrepareAsync( Cue("blocked-scene"), CancellationToken.None); var next = await engine.NextAsync( Cue("blocked-scene"), CancellationToken.None); Assert.Equal(PlayoutResultCode.Rejected, prepare.Code); Assert.Equal(PlayoutResultCode.Rejected, next.Code); Assert.DoesNotContain( session.Calls, call => call.Contains("blocked-scene", StringComparison.Ordinal)); } [Fact] public async Task UpdateOnAir_LiveMode_RechecksAuthorizationBeforeSessionDispatch() { using var scenes = TemporarySceneDirectory.Create("live-scene.t2s"); var session = new FakeK3dSession(); var authorization = new FakeLiveAuthorization(true); await using var engine = CreateEngine( LiveOptions(scenes.Path, trustedLiveOutputEnabled: true), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: true, liveAuthorization: authorization); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync( Cue("live-scene"), CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); authorization.IsAuthorizedForThisLaunch = false; var result = await engine.UpdateOnAirAsync( Cue("live-scene"), CancellationToken.None); Assert.Equal(PlayoutResultCode.Rejected, result.Code); Assert.DoesNotContain("UpdateOnAir:live-scene", session.Calls); Assert.Equal("live-scene", engine.Status.OnAirSceneName); } [Fact] public async Task TimedOutTakeIn_WithProcessGenerationChange_NeverReconnectsOrReplays() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); using var playStarted = new ManualResetEventSlim(); using var releasePlay = new ManualResetEventSlim(); var session = new FakeK3dSession { PlayAction = _ => { playStarted.Set(); Assert.True(releasePlay.Wait(TimeSpan.FromSeconds(5))); } }; var sessionFactory = new FakeK3dSessionFactory(() => session); var options = TestOptions(scenes.Path, "test-scene"); options.OperationTimeoutMilliseconds = 100; options.ReconnectDelayMilliseconds = 0; var originalProcess = ProcessSnapshot("eligible-generation-a"); var restartedProcess = ProcessSnapshot("eligible-generation-b"); await using var engine = CreateEngine( options, sessionFactory, new FakeRegistrationProbe(), new FakeTornadoProcessProbe( originalProcess, originalProcess, originalProcess, originalProcess, restartedProcess), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync(Cue("test-scene"), CancellationToken.None)).IsSuccess); try { var takeInTask = engine.TakeInAsync(CancellationToken.None); Assert.True(playStarted.Wait(TimeSpan.FromSeconds(5))); var takeIn = await takeInTask; Assert.Equal(PlayoutResultCode.OutcomeUnknown, takeIn.Code); Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); Assert.False(engine.Status.IsCommandAvailable); Assert.Equal(1, session.Calls.Count(call => call == "Play")); releasePlay.Set(); Assert.True(SpinWait.SpinUntil( () => session.Calls.Contains("Abandon"), TimeSpan.FromSeconds(5))); await engine.PollProcessOnceAsync(CancellationToken.None); var takeOut = await engine.TakeOutAsync( PlayoutTakeOutScope.All, CancellationToken.None); Assert.Equal(PlayoutResultCode.OutcomeUnknown, takeOut.Code); Assert.Equal(1, sessionFactory.CreateCount); Assert.Equal(1, session.Calls.Count(call => call == "Play")); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain(session.Calls, call => call.StartsWith("TakeOut:", StringComparison.Ordinal)); } finally { releasePlay.Set(); } } [Theory] [InlineData(false)] [InlineData(true)] public async Task FailedTakeOut_AbandonsSessionWithoutSdkDisconnect( bool callbackThrowsCancellation) { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var session = new FakeK3dSession { TakeOutAction = (_, _) => { if (callbackThrowsCancellation) { throw new OperationCanceledException("fake callback cancellation"); } throw new COMException("fake take-out failure"); } }; await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync( Cue("test-scene"), CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); var takeOut = await engine.TakeOutAsync( PlayoutTakeOutScope.All, CancellationToken.None); Assert.Equal(PlayoutResultCode.OutcomeUnknown, takeOut.Code); Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); Assert.Contains("TakeOut:All", session.Calls); Assert.Contains("Abandon", session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } [Fact] public async Task FailedPrepareWhileOnAir_AbandonsWithoutSdkDisconnect() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var prepareCount = 0; var session = new FakeK3dSession { PrepareAction = (_, _) => { if (Interlocked.Increment(ref prepareCount) > 1) { throw new InvalidOperationException("fake on-air prepare failure"); } } }; await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync( Cue("test-scene"), CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); var prepare = await engine.PrepareAsync( Cue("test-scene"), CancellationToken.None); Assert.Equal(PlayoutResultCode.OutcomeUnknown, prepare.Code); Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); Assert.Contains("Abandon", session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } [Fact] public async Task TimedOutTakeOut_LateCompletionAbandonsWithoutSdkDisconnect() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); using var takeOutStarted = new ManualResetEventSlim(); using var releaseTakeOut = new ManualResetEventSlim(); var session = new FakeK3dSession { TakeOutAction = (_, _) => { takeOutStarted.Set(); Assert.True(releaseTakeOut.Wait(TimeSpan.FromSeconds(5))); } }; var options = TestOptions(scenes.Path, "test-scene"); options.OperationTimeoutMilliseconds = 100; await using var engine = CreateEngine( options, new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync( Cue("test-scene"), CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); try { var takeOutTask = engine.TakeOutAsync( PlayoutTakeOutScope.All, CancellationToken.None); Assert.True(takeOutStarted.Wait(TimeSpan.FromSeconds(5))); var takeOut = await takeOutTask; Assert.Equal(PlayoutResultCode.OutcomeUnknown, takeOut.Code); releaseTakeOut.Set(); Assert.True(SpinWait.SpinUntil( () => session.Calls.Contains("Abandon"), TimeSpan.FromSeconds(5))); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } finally { releaseTakeOut.Set(); } } [Theory] [InlineData(false)] [InlineData(true)] public async Task TakeOut_WhenOnAirProcessBecomesUnsafe_AbandonsWithoutDisconnect( bool generationChanged) { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var session = new FakeK3dSession(); var original = ProcessSnapshot("eligible-generation-a"); var unsafeSnapshot = generationChanged ? ProcessSnapshot("eligible-generation-b") : new TornadoProcessSnapshot(1, 0, 1); var snapshots = Enumerable.Repeat(original, 4) .Append(unsafeSnapshot) .ToArray(); await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(snapshots), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync( Cue("test-scene"), CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); var takeOut = await engine.TakeOutAsync( PlayoutTakeOutScope.All, CancellationToken.None); Assert.Equal(PlayoutResultCode.OutcomeUnknown, takeOut.Code); Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); Assert.Contains("Abandon", session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain(session.Calls, call => call.StartsWith("TakeOut:", StringComparison.Ordinal)); } [Fact] public async Task ProcessMonitor_WhenOutputIsOnAir_AbandonsWithoutDisconnect() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var session = new FakeK3dSession(); var original = ProcessSnapshot("eligible-generation-a"); var snapshots = Enumerable.Repeat(original, 4) .Append(new TornadoProcessSnapshot(1, 0, 1)) .ToArray(); await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(snapshots), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync( Cue("test-scene"), CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); await engine.PollProcessOnceAsync(CancellationToken.None); Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); Assert.Contains("Abandon", session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); } [Fact] public async Task OutcomeUnknown_PreservesLatchAndNeverReconnectsOrReplays() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var secretPath = System.IO.Path.Combine(scenes.Path, "test-scene.t2s"); var first = new FakeK3dSession { PlayAction = _ => throw new COMException( $"vendor HRESULT 0x80004005 at {secretPath}", unchecked((int)0x80004005)) }; var second = new FakeK3dSession(); var created = 0; var sessionFactory = new FakeK3dSessionFactory( () => Interlocked.Increment(ref created) == 1 ? first : second); var options = TestOptions(scenes.Path, "test-scene"); options.ReconnectDelayMilliseconds = 0; await using var engine = CreateEngine( options, sessionFactory, new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync(Cue("test-scene"), CancellationToken.None)).IsSuccess); var takeIn = await engine.TakeInAsync(CancellationToken.None); Assert.Equal(PlayoutResultCode.OutcomeUnknown, takeIn.Code); Assert.DoesNotContain("0x80004005", takeIn.Message, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain(secretPath, takeIn.Message, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("0x80004005", engine.Status.Message, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain(secretPath, engine.Status.Message, StringComparison.OrdinalIgnoreCase); Assert.Equal(1, first.Calls.Count(call => call == "Play")); await engine.PollProcessOnceAsync(CancellationToken.None); var laterCommand = await engine.TakeOutAsync( PlayoutTakeOutScope.All, CancellationToken.None); var laterTakeIn = await engine.TakeInAsync(CancellationToken.None); var laterNext = await engine.NextAsync( Cue("test-scene"), CancellationToken.None); using var cancelled = new CancellationTokenSource(); cancelled.Cancel(); var cancelledResults = new[] { await engine.ConnectAsync(cancelled.Token), await engine.DisconnectAsync(cancelled.Token), await engine.PrepareAsync(Cue("test-scene"), cancelled.Token), await engine.TakeInAsync(cancelled.Token), await engine.NextAsync(Cue("test-scene"), cancelled.Token), await engine.TakeOutAsync(PlayoutTakeOutScope.All, cancelled.Token) }; Assert.Equal(1, sessionFactory.CreateCount); Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); Assert.Equal(PlayoutResultCode.OutcomeUnknown, laterCommand.Code); Assert.Equal(PlayoutResultCode.OutcomeUnknown, laterTakeIn.Code); Assert.Equal(PlayoutResultCode.OutcomeUnknown, laterNext.Code); Assert.All(cancelledResults, result => Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code)); Assert.False(engine.Status.IsCommandAvailable); Assert.DoesNotContain("Play", second.Calls); Assert.DoesNotContain(second.Calls, call => call.StartsWith("Prepare:", StringComparison.Ordinal)); } [Fact] public async Task CancelledGateWait_WhenLeadingCommandLatchesUnknown_ReturnsOutcomeUnknown() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); using var playStarted = new ManualResetEventSlim(); using var releasePlay = new ManualResetEventSlim(); var session = new FakeK3dSession { PlayAction = _ => { playStarted.Set(); Assert.True(releasePlay.Wait(TimeSpan.FromSeconds(5))); throw new COMException("fake ambiguous play failure"); } }; await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync( Cue("test-scene"), CancellationToken.None)).IsSuccess); try { var takeInTask = engine.TakeInAsync(CancellationToken.None); Assert.True(playStarted.Wait(TimeSpan.FromSeconds(5))); using var cancellation = new CancellationTokenSource(); var waitingCommand = engine.NextAsync(Cue("test-scene"), cancellation.Token); cancellation.Cancel(); releasePlay.Set(); var takeIn = await takeInTask; var next = await waitingCommand; Assert.Equal(PlayoutResultCode.OutcomeUnknown, takeIn.Code); Assert.Equal(PlayoutResultCode.OutcomeUnknown, next.Code); } finally { releasePlay.Set(); } } [Fact] public async Task CancelledGateWaitBehindDispose_DoesNotReverseDisposedStateOrThrow() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); using var playStarted = new ManualResetEventSlim(); using var releasePlay = new ManualResetEventSlim(); var session = new FakeK3dSession { PlayAction = _ => { playStarted.Set(); Assert.True(releasePlay.Wait(TimeSpan.FromSeconds(5))); } }; var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync( Cue("test-scene"), CancellationToken.None)).IsSuccess); try { var takeInTask = engine.TakeInAsync(CancellationToken.None); Assert.True(playStarted.Wait(TimeSpan.FromSeconds(5))); var disposeTask = engine.DisposeAsync().AsTask(); using var cancellation = new CancellationTokenSource(); var waitingCommand = engine.NextAsync(Cue("test-scene"), cancellation.Token); cancellation.Cancel(); releasePlay.Set(); Assert.True((await takeInTask).IsSuccess); await disposeTask; var next = await waitingCommand; Assert.Equal(PlayoutResultCode.OutcomeUnknown, next.Code); Assert.Equal(PlayoutConnectionState.Disposed, engine.Status.State); await engine.DisposeAsync(); } finally { releasePlay.Set(); await engine.DisposeAsync(); } } [Fact] public async Task FailedPrepare_ReconnectsWithoutReplayingTheFailedCommand() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var first = new FakeK3dSession { PrepareAction = (_, _) => throw new InvalidOperationException("fake prepare failure") }; var second = new FakeK3dSession(); var created = 0; var sessionFactory = new FakeK3dSessionFactory( () => Interlocked.Increment(ref created) == 1 ? first : second); var options = TestOptions(scenes.Path, "test-scene"); options.ReconnectDelayMilliseconds = 0; await using var engine = CreateEngine( options, sessionFactory, new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); var prepare = await engine.PrepareAsync(Cue("test-scene"), CancellationToken.None); Assert.Equal(PlayoutResultCode.Failed, prepare.Code); Assert.Equal(1, first.Calls.Count(call => call == "Prepare:test-scene")); Assert.Equal(PlayoutConnectionState.Reconnecting, engine.Status.State); await engine.PollProcessOnceAsync(CancellationToken.None); Assert.Equal(2, sessionFactory.CreateCount); Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State); Assert.DoesNotContain(second.Calls, call => call.StartsWith("Prepare:", StringComparison.Ordinal)); Assert.DoesNotContain("Play", second.Calls); } [Fact] public async Task FailedPrepare_WhenSessionCleanupFails_ReturnsOutcomeUnknown() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var session = new FakeK3dSession { PrepareAction = (_, _) => throw new InvalidOperationException("fake prepare failure"), DisconnectAction = () => throw new InvalidOperationException("fake cleanup failure") }; var sessionFactory = new FakeK3dSessionFactory(() => session); await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), sessionFactory, new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); var prepare = await engine.PrepareAsync( Cue("test-scene"), CancellationToken.None); Assert.Equal(PlayoutResultCode.OutcomeUnknown, prepare.Code); Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); Assert.False(engine.Status.IsCommandAvailable); Assert.Contains("Disconnect", session.Calls); Assert.Contains("Dispose", session.Calls); Assert.Equal(1, sessionFactory.CreateCount); } [Fact] public async Task Quarantine_ReleasesLocalSessionWithoutSdkDisconnect() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var session = new FakeK3dSession(); var sessionFactory = new FakeK3dSessionFactory(() => session); var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), sessionFactory, new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); await engine.QuarantineAsync(); await engine.DisposeAsync(); Assert.Equal(PlayoutConnectionState.Disposed, engine.Status.State); Assert.Contains("Abandon", session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); Assert.Equal(1, sessionFactory.CreateCount); } [Fact] public async Task Quarantine_WhenLocalAbandonFails_ReportsFailureAndNeverDisconnectsOrDisposes() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var session = new FakeK3dSession { AbandonAction = () => throw new InvalidOperationException( "fake local abandon failure") }; var sessionFactory = new FakeK3dSessionFactory(() => session); await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), sessionFactory, new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); await Assert.ThrowsAsync( () => engine.QuarantineAsync().AsTask()); Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); Assert.Equal(1, sessionFactory.CreateCount); } [Theory] [InlineData(false)] [InlineData(true)] public async Task CancelledTakeOutThenShutdown_AbandonsWithoutSdkDisconnect( bool callDisconnectFirst) { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var session = new FakeK3dSession(); var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync( Cue("test-scene"), CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); var takeOut = await engine.TakeOutAsync( PlayoutTakeOutScope.All, cancellation.Token); Assert.Equal(PlayoutResultCode.Cancelled, takeOut.Code); if (callDisconnectFirst) { var disconnect = await engine.DisconnectAsync(CancellationToken.None); Assert.Equal(PlayoutResultCode.OutcomeUnknown, disconnect.Code); } await engine.DisposeAsync(); Assert.Contains("Abandon", session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); Assert.DoesNotContain(session.Calls, call => call.StartsWith("TakeOut:", StringComparison.Ordinal)); Assert.Equal(PlayoutConnectionState.Disposed, engine.Status.State); } [Fact] public async Task ProcessMonitor_DrainsLifecycleCallbacksOnTheStaAndPublishesHello() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var session = new FakeK3dSession { SupportsLifecycleCallbacks = true, KtapHelloObserved = false, HasPendingLifecycleCallbacks = true, ProcessPendingCallbacksAction = _ => new K3dCallbackDrainResult(2, 1, true) }; var sessionFactory = new FakeK3dSessionFactory(() => session); var process = new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)); await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), sessionFactory, new FakeRegistrationProbe(), process, liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.False(engine.Status.KtapHelloObserved); session.KtapHelloObserved = true; await engine.PollProcessOnceAsync(CancellationToken.None); Assert.True(engine.Status.KtapHelloObserved); Assert.Contains("ProcessPendingCallbacks", session.Calls); Assert.All(session.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state)); Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State); } [Fact] public async Task Disconnect_DoesNotDispatchWhileTakeOutCompletionCallbackIsPending() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var session = new FakeK3dSession { SupportsLifecycleCallbacks = true, ClearPendingLifecycleCallbacksAfterProcess = false }; session.TakeOutAction = (_, _) => session.HasPendingLifecycleCallbacks = true; await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync(Cue("test-scene"), CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeOutAsync( PlayoutTakeOutScope.All, CancellationToken.None)).IsSuccess); var disconnect = await engine.DisconnectAsync(CancellationToken.None); Assert.Equal(PlayoutResultCode.Unavailable, disconnect.Code); Assert.Contains("ProcessPendingCallbacks", session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Abandon", session.Calls); Assert.True(session.IsConnected); session.HasPendingLifecycleCallbacks = false; } [Fact] public async Task Dispose_AbandonsWithoutDisconnectWhenTakeOutCallbackNeverArrives() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var session = new FakeK3dSession { SupportsLifecycleCallbacks = true, ClearPendingLifecycleCallbacksAfterProcess = false }; session.TakeOutAction = (_, _) => session.HasPendingLifecycleCallbacks = true; var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync(Cue("test-scene"), CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeOutAsync( PlayoutTakeOutScope.All, CancellationToken.None)).IsSuccess); await engine.DisposeAsync(); Assert.Contains("ProcessPendingCallbacks", session.Calls); Assert.Contains("Abandon", session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); Assert.Equal(PlayoutConnectionState.Disposed, engine.Status.State); } [Fact] public async Task LifecycleCallbackFailure_LatchesOutcomeUnknownWithoutDisconnectOrReplay() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var session = new FakeK3dSession { SupportsLifecycleCallbacks = true, HasPendingLifecycleCallbacks = true, ProcessPendingCallbacksAction = _ => throw new InvalidOperationException("fake callback failure") }; var sessionFactory = new FakeK3dSessionFactory(() => session); var process = new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)); await using var engine = CreateEngine( TestOptions(scenes.Path, "test-scene"), sessionFactory, new FakeRegistrationProbe(), process, liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); await engine.PollProcessOnceAsync(CancellationToken.None); Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); Assert.Contains("ProcessPendingCallbacks", session.Calls); Assert.Contains("Abandon", session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); var later = await engine.PrepareAsync(Cue("test-scene"), CancellationToken.None); Assert.Equal(PlayoutResultCode.OutcomeUnknown, later.Code); Assert.Equal(1, session.Calls.Count(call => call == "ProcessPendingCallbacks")); } [Fact] public async Task PlayCompletionPending_StatusClearsOnlyAfterCallbackDrain() { using var scenes = TemporarySceneDirectory.Create("5001.t2s"); var callbackReady = false; var session = new FakeK3dSession { SupportsLifecycleCallbacks = true, ClearPendingLifecycleCallbacksAfterProcess = false }; session.PlayAction = _ => { session.HasPendingPlayCallbacks = true; session.HasPendingLifecycleCallbacks = true; }; session.ProcessPendingCallbacksAction = _ => { if (!callbackReady) { return new K3dCallbackDrainResult(0, 0, false); } session.HasPendingPlayCallbacks = false; session.HasPendingLifecycleCallbacks = false; return new K3dCallbackDrainResult(1, 1, false); }; await using var engine = CreateEngine( TestOptions(scenes.Path, "5001"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync( Cue("5001"), CancellationToken.None)).IsSuccess); var observedPendingStates = new List(); engine.StatusChanged += (_, args) => observedPendingStates.Add(args.Current.IsPlayCompletionPending); Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); await engine.PollProcessOnceAsync(CancellationToken.None); Assert.True(engine.Status.IsPlayCompletionPending); Assert.Equal([true, true], observedPendingStates); callbackReady = true; await engine.PollProcessOnceAsync(CancellationToken.None); Assert.False(engine.Status.IsPlayCompletionPending); Assert.Equal([true, true, false], observedPendingStates); Assert.True((await engine.TakeOutAsync( PlayoutTakeOutScope.All, CancellationToken.None)).IsSuccess); Assert.True((await engine.DisconnectAsync(CancellationToken.None)).IsSuccess); } [Fact] public async Task PendingPlayCallback_BlocksDelayedMultiNextUntilEachCompletionIsDrained() { using var scenes = TemporarySceneDirectory.Create( "5001.t2s", "5006.t2s", "5011.t2s"); var callbackReady = false; var session = new FakeK3dSession { SupportsLifecycleCallbacks = true, ClearPendingLifecycleCallbacksAfterProcess = false }; session.PlayAction = _ => { session.HasPendingPlayCallbacks = true; session.HasPendingLifecycleCallbacks = true; }; session.ProcessPendingCallbacksAction = _ => { if (!callbackReady) { return new K3dCallbackDrainResult(0, 0, false); } callbackReady = false; session.HasPendingPlayCallbacks = false; session.HasPendingLifecycleCallbacks = false; return new K3dCallbackDrainResult(1, 1, false); }; await using var engine = CreateEngine( TestOptions(scenes.Path, "5001", "5006", "5011"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync( Cue("5001"), CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); Assert.True(session.HasPendingPlayCallbacks); Assert.True(engine.Status.IsPlayCompletionPending); var firstEarlyNext = await engine.NextAsync( Cue("5006"), CancellationToken.None); Assert.Equal(PlayoutResultCode.Unavailable, firstEarlyNext.Code); Assert.Contains("completion callback", firstEarlyNext.Message, StringComparison.Ordinal); Assert.DoesNotContain("Prepare:5006", session.Calls); callbackReady = true; Assert.True((await engine.NextAsync( Cue("5006"), CancellationToken.None)).IsSuccess); Assert.True(session.HasPendingPlayCallbacks); Assert.Equal(1, session.Calls.Count(call => call == "Prepare:5006")); var secondEarlyNext = await engine.NextAsync( Cue("5011"), CancellationToken.None); Assert.Equal(PlayoutResultCode.Unavailable, secondEarlyNext.Code); Assert.DoesNotContain("Prepare:5011", session.Calls); callbackReady = true; Assert.True((await engine.NextAsync( Cue("5011"), CancellationToken.None)).IsSuccess); Assert.Equal(1, session.Calls.Count(call => call == "Prepare:5011")); callbackReady = true; await engine.PollProcessOnceAsync(CancellationToken.None); Assert.False(session.HasPendingPlayCallbacks); Assert.False(engine.Status.IsPlayCompletionPending); Assert.True((await engine.TakeOutAsync( PlayoutTakeOutScope.All, CancellationToken.None)).IsSuccess); Assert.True((await engine.DisconnectAsync(CancellationToken.None)).IsSuccess); Assert.Contains("Disconnect", session.Calls); } [Theory] [InlineData("prepare")] [InlineData("take-in")] [InlineData("next")] [InlineData("update-on-air")] public async Task PendingPlayCallback_RejectsEveryNonTakeOutSceneMutation(string operation) { using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); var session = new FakeK3dSession { SupportsLifecycleCallbacks = true, ClearPendingLifecycleCallbacksAfterProcess = false }; session.PlayAction = _ => { session.HasPendingPlayCallbacks = true; session.HasPendingLifecycleCallbacks = true; }; await using var engine = CreateEngine( TestOptions(scenes.Path, "5001", "5006"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); if (operation != "prepare") { Assert.True((await engine.PrepareAsync( Cue("5001"), CancellationToken.None)).IsSuccess); } if (operation is "next" or "update-on-air") { Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); } else { session.HasPendingPlayCallbacks = true; session.HasPendingLifecycleCallbacks = true; } var result = operation switch { "prepare" => await engine.PrepareAsync( Cue("5006"), CancellationToken.None), "take-in" => await engine.TakeInAsync(CancellationToken.None), "next" => await engine.NextAsync( Cue("5006"), CancellationToken.None), "update-on-air" => await engine.UpdateOnAirAsync( Cue("5001"), CancellationToken.None), _ => throw new InvalidOperationException("Unknown test operation.") }; Assert.Equal(PlayoutResultCode.Unavailable, result.Code); Assert.Contains("completion callback", result.Message, StringComparison.Ordinal); if (operation == "prepare") { Assert.DoesNotContain("Prepare:5006", session.Calls); } else if (operation == "take-in") { Assert.DoesNotContain("Play", session.Calls); } else if (operation == "next") { Assert.DoesNotContain("Prepare:5006", session.Calls); } else { Assert.DoesNotContain("UpdateOnAir:5001", session.Calls); } } [Fact] public async Task PendingPlayCallback_DoesNotBlockEmergencyTakeOut() { using var scenes = TemporarySceneDirectory.Create("5001.t2s"); var session = new FakeK3dSession { SupportsLifecycleCallbacks = true, ClearPendingLifecycleCallbacksAfterProcess = false }; session.PlayAction = _ => { session.HasPendingPlayCallbacks = true; session.HasPendingLifecycleCallbacks = true; }; await using var engine = CreateEngine( TestOptions(scenes.Path, "5001"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync( Cue("5001"), CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); Assert.True(session.HasPendingPlayCallbacks); Assert.True(engine.Status.IsPlayCompletionPending); var result = await engine.TakeOutAsync( PlayoutTakeOutScope.All, CancellationToken.None); Assert.True(result.IsSuccess); Assert.Contains("TakeOut:All", session.Calls); Assert.True(engine.Status.IsPlayCompletionPending); } [Fact] public async Task PendingTakeOutCallback_BlocksEveryFurtherSdkCommandUntilCompletion() { using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); var completionReady = false; var session = new FakeK3dSession { SupportsLifecycleCallbacks = true, ClearPendingLifecycleCallbacksAfterProcess = false }; session.TakeOutAction = (_, _) => { session.HasPendingLifecycleCallbacks = true; session.HasPendingTakeOutCallbacks = true; }; session.ProcessPendingCallbacksAction = _ => { if (!completionReady) { return new K3dCallbackDrainResult(0, 0, false); } session.HasPendingLifecycleCallbacks = false; session.HasPendingTakeOutCallbacks = false; return new K3dCallbackDrainResult(1, 1, false); }; await using var engine = CreateEngine( TestOptions(scenes.Path, "5001", "5006"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync( Cue("5001"), CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeOutAsync( PlayoutTakeOutScope.All, CancellationToken.None)).IsSuccess); Assert.True(engine.Status.IsTakeOutCompletionPending); Assert.False(engine.Status.IsCommandAvailable); var prepare = await engine.PrepareAsync(Cue("5006"), CancellationToken.None); var repeatedTakeOut = await engine.TakeOutAsync( PlayoutTakeOutScope.All, CancellationToken.None); Assert.Equal(PlayoutResultCode.Unavailable, prepare.Code); Assert.Equal(PlayoutResultCode.Unavailable, repeatedTakeOut.Code); Assert.Contains("CutOut/StopAll", prepare.Message, StringComparison.Ordinal); Assert.Equal(1, session.Calls.Count(call => call == "TakeOut:All")); Assert.DoesNotContain("Prepare:5006", session.Calls); completionReady = true; await engine.PollProcessOnceAsync(CancellationToken.None); Assert.False(engine.Status.IsTakeOutCompletionPending); Assert.True(engine.Status.IsCommandAvailable); Assert.True((await engine.PrepareAsync( Cue("5006"), CancellationToken.None)).IsSuccess); Assert.Equal(1, session.Calls.Count(call => call == "Prepare:5006")); } [Theory] [InlineData(PlayoutTakeOutScope.All)] [InlineData(PlayoutTakeOutScope.Layout)] public async Task TakeOutCompletion_ClearsInterruptedPlayAndAllowsDisconnect( PlayoutTakeOutScope scope) { using var scenes = TemporarySceneDirectory.Create("5001.t2s"); var takeOutCompletionReady = false; var session = new FakeK3dSession { SupportsLifecycleCallbacks = true, ClearPendingLifecycleCallbacksAfterProcess = false }; session.PlayAction = _ => { session.HasPendingPlayCallbacks = true; session.HasPendingLifecycleCallbacks = true; }; session.TakeOutAction = (_, _) => session.HasPendingLifecycleCallbacks = true; session.ProcessPendingCallbacksAction = _ => { if (!takeOutCompletionReady) { return new K3dCallbackDrainResult(0, 0, false); } session.HasPendingPlayCallbacks = false; session.HasPendingLifecycleCallbacks = false; return new K3dCallbackDrainResult(1, 1, false); }; await using var engine = CreateEngine( TestOptions(scenes.Path, "5001"), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True((await engine.PrepareAsync( Cue("5001"), CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); Assert.True(session.HasPendingPlayCallbacks); Assert.True((await engine.TakeOutAsync(scope, CancellationToken.None)).IsSuccess); takeOutCompletionReady = true; var disconnect = await engine.DisconnectAsync(CancellationToken.None); Assert.True(disconnect.IsSuccess); Assert.False(session.HasPendingPlayCallbacks); Assert.Contains($"TakeOut:{scope}", session.Calls); Assert.Contains("ProcessPendingCallbacks", session.Calls); Assert.Contains("Disconnect", session.Calls); } [Fact] public async Task GateA_AllowsExactlyOneConnectAndClaimed5001Prepare_ThenAbandonsWithoutBye() { using var scenes = TemporarySceneDirectory.Create("5001.t2s"); var session = new FakeK3dSession(); var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( GateACapability); var engine = CreateEngine( GateAOptions(scenes.Path), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")), liveAuthorized: true, launchAuthorization: authorization); try { var cue = GateACue(); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.Equal( PlayoutResultCode.Rejected, (await engine.ConnectAsync(CancellationToken.None)).Code); Assert.Equal( PlayoutResultCode.Rejected, (await engine.PrepareAsync(cue, CancellationToken.None)).Code); Assert.Equal( PlayoutResultCode.Rejected, (await engine.TakeInAsync(CancellationToken.None)).Code); Assert.Equal( PlayoutResultCode.Rejected, (await engine.NextAsync(cue, CancellationToken.None)).Code); Assert.Equal( PlayoutResultCode.Rejected, (await engine.UpdateOnAirAsync(cue, CancellationToken.None)).Code); Assert.Equal( PlayoutResultCode.Rejected, (await engine.TakeOutAsync( PlayoutTakeOutScope.All, CancellationToken.None)).Code); Assert.Equal( PlayoutResultCode.Rejected, (await engine.DisconnectAsync(CancellationToken.None)).Code); Assert.False(engine.Status.LiveTakeInAllowed); Assert.True(authorization.TryClaimGateAPrepare(GateACapability)); Assert.True((await engine.PrepareAsync(cue, CancellationToken.None)).IsSuccess); Assert.Equal( PlayoutResultCode.Rejected, (await engine.PrepareAsync(cue, CancellationToken.None)).Code); } finally { authorization.CompleteGateAPrepare(); await engine.DisposeAsync(); } Assert.Equal(new[] { "Connect", "Prepare:5001", "Abandon" }, session.Calls); Assert.Equal(1, session.KtapDispatchCount); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } [Fact] public async Task GateA_WrongPrepareTargetPermanentlyConsumesPrepareWithoutSdkMutation() { using var scenes = TemporarySceneDirectory.Create("5001.t2s"); var session = new FakeK3dSession(); var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( GateACapability); var engine = CreateEngine( GateAOptions(scenes.Path), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")), liveAuthorized: true, launchAuthorization: authorization); try { Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True(authorization.TryClaimGateAPrepare(GateACapability)); var wrong = await engine.PrepareAsync( new PlayoutCue("5006.t2s", "5006", FadeDuration: 6), CancellationToken.None); var correction = await engine.PrepareAsync( GateACue(), CancellationToken.None); Assert.Equal(PlayoutResultCode.Rejected, wrong.Code); Assert.Equal(PlayoutResultCode.Rejected, correction.Code); } finally { authorization.CompleteGateAPrepare(); await engine.DisposeAsync(); } Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls); Assert.DoesNotContain( session.Calls, static call => call.StartsWith("Prepare:", StringComparison.Ordinal)); Assert.DoesNotContain("Disconnect", session.Calls); } [Fact] public async Task GateA_PgmGenerationChangeAbandonsWithoutReconnectOrBye() { using var scenes = TemporarySceneDirectory.Create("5001.t2s"); var sessionFactory = new FakeK3dSessionFactory(); var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( GateACapability); var options = GateAOptions(scenes.Path); options.ReconnectEnabled = true; options.MaximumReconnectAttempts = 3; var engine = CreateEngine( options, sessionFactory, new FakeRegistrationProbe(), new FakeTornadoProcessProbe( ProcessSnapshot("approved-pgm"), ProcessSnapshot("approved-pgm"), ProcessSnapshot("replacement-pgm")), liveAuthorized: true, launchAuthorization: authorization); try { Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); await engine.PollProcessOnceAsync(CancellationToken.None); Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); Assert.Equal(1, sessionFactory.CreateCount); var session = Assert.Single(sessionFactory.Sessions); Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); } finally { authorization.CompleteGateAPrepare(); await engine.DisposeAsync(); } Assert.Equal(1, sessionFactory.CreateCount); } [Fact] public async Task GateA_ExpiredConnectIsRejectedBeforeProcessRegistrationOrVendorBoundary() { using var scenes = TemporarySceneDirectory.Create("5001.t2s"); var clock = new ManualGateTimeProvider(); var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( GateACapability, timeProvider: clock, expiresAtUtcTicks: clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks); clock.Advance(TimeSpan.FromMinutes(1)); var sessionFactory = new FakeK3dSessionFactory(); var registration = new FakeRegistrationProbe(); var process = new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")); var engine = CreateEngine( GateAOptions(scenes.Path), sessionFactory, registration, process, liveAuthorized: true, launchAuthorization: authorization); try { var result = await engine.ConnectAsync(CancellationToken.None); Assert.Equal(PlayoutResultCode.Rejected, result.Code); Assert.Equal(0, process.CaptureCount); Assert.Equal(0, registration.ProbeCount); Assert.Equal(0, sessionFactory.CreateCount); } finally { await engine.DisposeAsync(); authorization.Dispose(); } } [Fact] public async Task GateA_PrepareFailureBecomesOutcomeUnknownAndCannotRetryOrDisconnect() { using var scenes = TemporarySceneDirectory.Create("5001.t2s"); var session = new FakeK3dSession { PrepareAction = (_, _) => throw new COMException( "fake ambiguous Gate A prepare failure") }; var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( GateACapability); var engine = CreateEngine( GateAOptions(scenes.Path), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")), liveAuthorized: true, abandonOnTargetMismatch: true, launchAuthorization: authorization); try { Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True(authorization.TryClaimGateAPrepare(GateACapability)); var failed = await engine.PrepareAsync(GateACue(), CancellationToken.None); var retry = await engine.PrepareAsync(GateACue(), CancellationToken.None); Assert.Equal(PlayoutResultCode.OutcomeUnknown, failed.Code); Assert.Equal(PlayoutResultCode.OutcomeUnknown, retry.Code); } finally { authorization.CompleteGateAPrepare(); await engine.DisposeAsync(); authorization.Dispose(); } Assert.Equal(new[] { "Connect", "Prepare:5001", "Abandon" }, session.Calls); Assert.Equal(1, session.Calls.Count(call => call == "Prepare:5001")); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } [Fact] public async Task GateA_ConnectExpiryAtSdkBoundaryPreventsKtapDispatch() { using var scenes = TemporarySceneDirectory.Create("5001.t2s"); var clock = new ManualGateTimeProvider(); var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( GateACapability, timeProvider: clock, expiresAtUtcTicks: clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks); var session = new FakeK3dSession { BeforeKtapDispatchAction = () => clock.Advance(TimeSpan.FromMinutes(1)) }; var engine = CreateEngine( GateAOptions(scenes.Path), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")), liveAuthorized: true, finalConnectSafetyCheck: () => true, abandonOnTargetMismatch: true, beforeSdkDispatchClaim: authorization.EnsureGateASdkDispatchAuthorized, launchAuthorization: authorization); try { var result = await engine.ConnectAsync(CancellationToken.None); Assert.False(result.IsSuccess); Assert.Equal(0, session.KtapDispatchCount); } finally { await engine.DisposeAsync(); authorization.Dispose(); } Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } [Fact] public async Task GateA_PrepareExpiryAtSdkBoundaryPreventsPrepareDispatchAndQuarantines() { using var scenes = TemporarySceneDirectory.Create("5001.t2s"); var clock = new ManualGateTimeProvider(); var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( GateACapability, timeProvider: clock, expiresAtUtcTicks: clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks); var dispatchBoundaryCount = 0; void DelayedDispatchGuard() { if (Interlocked.Increment(ref dispatchBoundaryCount) == 2) { clock.Advance(TimeSpan.FromMinutes(1)); } authorization.EnsureGateASdkDispatchAuthorized(); } var session = new FakeK3dSession(); var engine = CreateEngine( GateAOptions(scenes.Path), new FakeK3dSessionFactory(() => session), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")), liveAuthorized: true, finalConnectSafetyCheck: () => true, abandonOnTargetMismatch: true, beforeSdkDispatchClaim: DelayedDispatchGuard, launchAuthorization: authorization); try { Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); Assert.True(authorization.TryClaimGateAPrepare(GateACapability)); var result = await engine.PrepareAsync(GateACue(), CancellationToken.None); var retry = await engine.PrepareAsync(GateACue(), CancellationToken.None); Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code); Assert.Equal(PlayoutResultCode.OutcomeUnknown, retry.Code); Assert.Equal(2, dispatchBoundaryCount); } finally { authorization.CompleteGateAPrepare(); await engine.DisposeAsync(); authorization.Dispose(); } Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls); Assert.DoesNotContain( session.Calls, static call => call.StartsWith("Prepare:", StringComparison.Ordinal)); Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain("Dispose", session.Calls); } private static TornadoPlayoutEngine CreateEngine( PlayoutOptions rawOptions, FakeK3dSessionFactory sessionFactory, FakeRegistrationProbe registrationProbe, FakeTornadoProcessProbe processProbe, bool liveAuthorized, Func? finalConnectSafetyCheck = null, bool abandonOnTargetMismatch = false, Action? beforeSdkDispatchClaim = null, FakeLiveAuthorization? liveAuthorization = null, PlayoutLaunchAuthorization? launchAuthorization = null) { var options = ValidatedPlayoutOptions.Create(rawOptions); IStaDispatcher? dispatcher = options.Mode is PlayoutMode.Test or PlayoutMode.Live ? new StaDispatcher(options.QueueCapacity) : null; return new TornadoPlayoutEngine( options, sessionFactory, registrationProbe, processProbe, dispatcher, liveAuthorization ?? new FakeLiveAuthorization(liveAuthorized), TimeProvider.System, startMonitor: false, finalConnectSafetyCheck: finalConnectSafetyCheck, abandonOnTargetMismatch: abandonOnTargetMismatch, beforeSdkDispatchClaim: beforeSdkDispatchClaim, launchAuthorization: launchAuthorization); } private static PlayoutOptions TestOptions( string sceneDirectory, params string[] allowedScenes) => new() { Mode = PlayoutMode.Test, SceneDirectory = sceneDirectory, OutputChannel = 9, TestProcessWindowTitlePattern = "^Tornado2 TEST$", TestSceneAllowlist = [.. allowedScenes], ConnectTimeoutMilliseconds = 500, OperationTimeoutMilliseconds = 500, DisconnectTimeoutMilliseconds = 500, ProcessPollIntervalMilliseconds = 100, ReconnectDelayMilliseconds = 0, MaximumReconnectAttempts = 3 }; private static PlayoutOptions LiveOptions( string sceneDirectory, bool trustedLiveOutputEnabled) => new() { Mode = PlayoutMode.Live, SceneDirectory = sceneDirectory, TestSceneAllowlist = ["live-scene"], TrustedLiveOutputEnabled = trustedLiveOutputEnabled, ConnectTimeoutMilliseconds = 500, OperationTimeoutMilliseconds = 500, DisconnectTimeoutMilliseconds = 500, ProcessPollIntervalMilliseconds = 100, ReconnectDelayMilliseconds = 0, MaximumReconnectAttempts = 3 }; private static PlayoutOptions GateAOptions(string sceneDirectory) => new() { Mode = PlayoutMode.Live, Host = "127.0.0.1", Port = 30001, TcpMode = 1, LayoutIndex = 10, SceneDirectory = sceneDirectory, TestSceneAllowlist = ["5001"], TrustedLiveOutputEnabled = true, ReconnectEnabled = false, MaximumReconnectAttempts = 0, MaximumAutomaticRefreshesPerTakeIn = 0, LegacySceneFadeDuration = 6, ConnectTimeoutMilliseconds = 500, OperationTimeoutMilliseconds = 500, DisconnectTimeoutMilliseconds = 500, ProcessPollIntervalMilliseconds = 100, ReconnectDelayMilliseconds = 0 }; private static PlayoutCue GateACue() => new( "5001.t2s", "5001", FadeDuration: 6, Mutations: [new PlayoutUseBackground(false)]); private static PlayoutCue Cue(string sceneName) => new( $"{sceneName}.t2s", sceneName, [new PlayoutField("headline", "fake", true)]); private static TornadoProcessSnapshot ProcessSnapshot(string generation) => new(1, 1, 0) { EligibleProcessGeneration = new TornadoProcessGeneration(generation) }; }