using System.Collections.Concurrent; using System.Runtime.InteropServices; using MBN_STOCK_WEBVIEW.Playout.Configuration; using MBN_STOCK_WEBVIEW.Playout.Runtime; namespace MBN_STOCK_WEBVIEW.Playout.Tests; public sealed class TornadoPlayoutEngineTests { [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); } [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.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 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 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); } [Fact] public async Task Status_ExposesValidatedOperationTimeoutMilliseconds() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var options = TestOptions(scenes.Path, "test-scene"); options.OperationTimeoutMilliseconds = 837; await using var engine = CreateEngine( options, new FakeK3dSessionFactory(), new FakeRegistrationProbe(), new FakeTornadoProcessProbe(ProcessSnapshot("eligible-generation-a")), liveAuthorized: false); Assert.Equal(837, 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.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); Assert.True(connect.IsSuccess); Assert.True(prepare.IsSuccess); Assert.True(takeIn.IsSuccess); Assert.True(engine.Status.LiveTakeInAllowed); Assert.Contains("Play", session.Calls); } [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); } [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); } private static TornadoPlayoutEngine CreateEngine( PlayoutOptions rawOptions, FakeK3dSessionFactory sessionFactory, FakeRegistrationProbe registrationProbe, FakeTornadoProcessProbe processProbe, bool liveAuthorized) { 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, new FakeLiveAuthorization(liveAuthorized), TimeProvider.System, startMonitor: false); } 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, TrustedLiveOutputEnabled = trustedLiveOutputEnabled, ConnectTimeoutMilliseconds = 500, OperationTimeoutMilliseconds = 500, DisconnectTimeoutMilliseconds = 500, ProcessPollIntervalMilliseconds = 100, ReconnectDelayMilliseconds = 0, MaximumReconnectAttempts = 3 }; 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) }; }