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 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_QuarantinesEngineAndNeverReconnectsOrReplays() { 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; 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); 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(); 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(session.Calls, call => call.StartsWith("TakeOut:", StringComparison.Ordinal)); } finally { releasePlay.Set(); } } [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); Assert.Equal(1, sessionFactory.CreateCount); Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); Assert.Equal(PlayoutResultCode.OutcomeUnknown, laterCommand.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 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); } 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)]); }