using System.Collections.Concurrent; using MBN_STOCK_WEBVIEW.Playout.Configuration; using MBN_STOCK_WEBVIEW.Playout.Interop; using MBN_STOCK_WEBVIEW.Playout.Runtime; namespace MBN_STOCK_WEBVIEW.Playout.Tests; public sealed class DynamicK3dSessionTests { [Theory] [InlineData(0)] [InlineData(-1)] public async Task Connect_WhenKtapDoesNotReturnOne_FailsClosed(int connectResult) { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path)); var log = new FakeComLog(); var engine = new FakeEngine( log, new FakePlayer(log), new FakeScene(log), connectResult); var activator = new FakeActivator(log, engine); var releaser = new FakeReleaser( log, (engine, "Engine"), (activator.EventHandler, "EventHandler")); await using var dispatcher = new StaDispatcher(capacity: 1); var exception = await Assert.ThrowsAnyAsync( () => dispatcher.InvokeAsync( () => { using var session = new DynamicK3dSession(activator, releaser); try { session.Connect(options); } catch { Assert.False(session.IsConnected); Assert.Equal( PlayoutKtapConnectState.Failed, session.LastKtapConnectState); throw; } return true; }, TimeSpan.FromSeconds(5), CancellationToken.None)); Assert.DoesNotContain(options.Host, exception.Message, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain(scenes.Path, exception.Message, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain(connectResult.ToString(), exception.Message, StringComparison.Ordinal); Assert.DoesNotContain("GetScenePlayer", log.Names); Assert.Equal( new[] { $"Create:{K3dComConstants.KaEventHandlerClassGuid:B}", $"Create:{K3dComConstants.KaEngineClassGuid:B}", "KTAPConnect:1:127.0.0.1:30001:0", "Disconnect", "Release:Engine", "Release:EventHandler" }, log.Names); Assert.Equal(1, log.Names.Count(name => name == "Disconnect")); Assert.Single(log.ThreadIds.Distinct()); Assert.All(log.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state)); } [Fact] public async Task Connect_WhenPlayerInitializationFails_DisconnectsOnceAndPreservesOriginalFailure() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path)); var log = new FakeComLog(); var originalFailure = new InvalidOperationException("fake original player failure"); var engine = new FakeEngine( log, new FakePlayer(log), new FakeScene(log), playerFailure: originalFailure, disconnectFailure: new InvalidOperationException("fake cleanup failure")); var activator = new FakeActivator(log, engine); var releaser = new FakeReleaser( log, (engine, "Engine"), (activator.EventHandler, "EventHandler")); await using var dispatcher = new StaDispatcher(capacity: 1); var lastKtapConnectState = PlayoutKtapConnectState.NotAttempted; var exception = await Assert.ThrowsAsync( () => dispatcher.InvokeAsync( () => { using var session = new DynamicK3dSession(activator, releaser); try { session.Connect(options); } finally { lastKtapConnectState = session.LastKtapConnectState; } return true; }, TimeSpan.FromSeconds(5), CancellationToken.None)); Assert.Same(originalFailure, exception); Assert.Equal(PlayoutKtapConnectState.AcceptedUnconfirmed, lastKtapConnectState); Assert.Equal( new[] { $"Create:{K3dComConstants.KaEventHandlerClassGuid:B}", $"Create:{K3dComConstants.KaEngineClassGuid:B}", "KTAPConnect:1:127.0.0.1:30001:0", "GetScenePlayerOnChannel:9", "Disconnect", "Release:Engine", "Release:EventHandler" }, log.Names); Assert.Equal(1, log.Names.Count(name => name == "Disconnect")); Assert.Single(log.ThreadIds.Distinct()); Assert.All(log.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state)); } [Fact] public async Task Connect_WhenKtapInvocationThrows_AttemptsDisconnectAndPreservesOriginalFailure() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path)); var log = new FakeComLog(); var originalFailure = new InvalidOperationException("fake original KTAP failure"); var engine = new FakeEngine( log, new FakePlayer(log), new FakeScene(log), connectFailure: originalFailure); var activator = new FakeActivator(log, engine); var releaser = new FakeReleaser( log, (engine, "Engine"), (activator.EventHandler, "EventHandler")); await using var dispatcher = new StaDispatcher(capacity: 1); var lastKtapConnectState = PlayoutKtapConnectState.NotAttempted; var exception = await Assert.ThrowsAsync( () => dispatcher.InvokeAsync( () => { using var session = new DynamicK3dSession(activator, releaser); try { session.Connect(options); } finally { lastKtapConnectState = session.LastKtapConnectState; } return true; }, TimeSpan.FromSeconds(5), CancellationToken.None)); Assert.Same(originalFailure, exception); Assert.Equal(PlayoutKtapConnectState.Failed, lastKtapConnectState); Assert.Equal( new[] { $"Create:{K3dComConstants.KaEventHandlerClassGuid:B}", $"Create:{K3dComConstants.KaEngineClassGuid:B}", "KTAPConnect:1:127.0.0.1:30001:0", "Disconnect", "Release:Engine", "Release:EventHandler" }, log.Names); Assert.Equal(1, log.Names.Count(name => name == "Disconnect")); } [Theory] [InlineData(PlayoutTakeOutScope.All, "StopAll")] [InlineData(PlayoutTakeOutScope.Layout, "CutOut:10")] public async Task FakeLateBoundCom_UsesLegacyCallOrderOnOneStaThread( PlayoutTakeOutScope takeOutScope, string expectedTakeOutCall) { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path)); var cue = options.ResolveCue(new PlayoutCue( "test-scene.t2s", "test-scene", [ new PlayoutField("headline", "safe value", true), new PlayoutField("badge", null, false) ], FadeDuration: 12)); var log = new FakeComLog(); var player = new FakePlayer(log); var scene = new FakeScene(log); var engine = new FakeEngine(log, player, scene); var activator = new FakeActivator(log, engine); await using var dispatcher = new StaDispatcher(capacity: 4); await dispatcher.InvokeAsync( () => { using var session = new DynamicK3dSession(activator); session.Connect(options); Assert.Equal( PlayoutKtapConnectState.AcceptedUnconfirmed, session.LastKtapConnectState); session.Prepare(cue, options.LayoutIndex); session.Play(options.LayoutIndex); session.TakeOut(options.LayoutIndex, takeOutScope); session.Disconnect(); return true; }, TimeSpan.FromSeconds(5), CancellationToken.None); Assert.Equal( new[] { $"Create:{K3dComConstants.KaEventHandlerClassGuid:B}", $"Create:{K3dComConstants.KaEngineClassGuid:B}", "KTAPConnect:1:127.0.0.1:30001:0", "GetScenePlayerOnChannel:9", "LoadScene:test-scene", "SetSceneEffectType:10:7:12", "BeginTransaction", "GetObject:headline", "SetValue:headline:safe value", "SetVisible:headline:1", "GetObject:badge", "SetVisible:badge:0", "QueryVariables", "EndTransaction", "Prepare:10", "Play:10", expectedTakeOutCall, "Disconnect" }, log.Names); Assert.Single(log.ThreadIds.Distinct()); Assert.All(log.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state)); } [Fact] public void LateBoundActivatorContract_AcceptsOnlyAClsid() { var create = typeof(ILateBoundComActivator).GetMethod(nameof(ILateBoundComActivator.Create)); Assert.NotNull(create); Assert.Equal(typeof(Guid), Assert.Single(create.GetParameters()).ParameterType); Assert.Equal(typeof(object), create.ReturnType); } [Fact] public async Task Connect_WhenComActivationFails_ReportsKtapNotAttempted() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path)); var originalFailure = new InvalidOperationException("fake activation failure"); var lastKtapConnectState = PlayoutKtapConnectState.Failed; await using var dispatcher = new StaDispatcher(capacity: 1); var exception = await Assert.ThrowsAsync( () => dispatcher.InvokeAsync( () => { using var session = new DynamicK3dSession( new ThrowingActivator(originalFailure)); try { session.Connect(options); } finally { lastKtapConnectState = session.LastKtapConnectState; } return true; }, TimeSpan.FromSeconds(5), CancellationToken.None)); Assert.Same(originalFailure, exception); Assert.Equal(PlayoutKtapConnectState.NotAttempted, lastKtapConnectState); } [Fact] public async Task PreventKtapConnect_DuringActivation_BlocksLateDispatch() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); using var activationStarted = new ManualResetEventSlim(); using var releaseActivation = new ManualResetEventSlim(); var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path)); var log = new FakeComLog(); var engine = new FakeEngine(log, new FakePlayer(log), new FakeScene(log)); var activator = new BlockingEngineActivator( log, engine, activationStarted, releaseActivation); var session = new DynamicK3dSession(activator); await using var dispatcher = new StaDispatcher(capacity: 1); try { var connectTask = dispatcher.InvokeAsync( () => { using (session) { session.Connect(options); } return true; }, TimeSpan.FromSeconds(5), CancellationToken.None); Assert.True(activationStarted.Wait(TimeSpan.FromSeconds(2))); Assert.True(session.TryPreventKtapConnect()); releaseActivation.Set(); await Assert.ThrowsAsync(() => connectTask); Assert.Equal( PlayoutKtapConnectState.NotAttempted, session.LastKtapConnectState); Assert.DoesNotContain(log.Names, name => name.StartsWith("KTAPConnect:", StringComparison.Ordinal)); } finally { releaseActivation.Set(); } } [Fact] public async Task Abandon_ReleasesEveryComReferenceWithoutDisconnect() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path)); var log = new FakeComLog(); var player = new FakePlayer(log); var scene = new FakeScene(log); var engine = new FakeEngine(log, player, scene); var activator = new FakeActivator(log, engine); var releaser = new FakeReleaser( log, (player, "Player"), (engine, "Engine"), (activator.EventHandler, "EventHandler")); await using var dispatcher = new StaDispatcher(capacity: 1); await dispatcher.InvokeAsync( () => { using var session = new DynamicK3dSession(activator, releaser); session.Connect(options); session.Abandon(); return true; }, TimeSpan.FromSeconds(5), CancellationToken.None); Assert.DoesNotContain("Disconnect", log.Names); Assert.Equal(1, log.Names.Count(name => name == "Release:Player")); Assert.Equal(1, log.Names.Count(name => name == "Release:Engine")); Assert.Equal(1, log.Names.Count(name => name == "Release:EventHandler")); } private static PlayoutOptions TestOptions(string sceneDirectory) => new() { Mode = PlayoutMode.Test, SceneDirectory = sceneDirectory, OutputChannel = 9, TestProcessWindowTitlePattern = "^Tornado2 TEST$", TestSceneAllowlist = ["test-scene"] }; public sealed class FakeComLog { private readonly ConcurrentQueue _names = new(); private readonly ConcurrentQueue _threadIds = new(); private readonly ConcurrentQueue _apartmentStates = new(); public string[] Names => _names.ToArray(); public int[] ThreadIds => _threadIds.ToArray(); public ApartmentState[] ApartmentStates => _apartmentStates.ToArray(); public void Add(string name) { _names.Enqueue(name); _threadIds.Enqueue(Environment.CurrentManagedThreadId); _apartmentStates.Enqueue(Thread.CurrentThread.GetApartmentState()); } } private sealed class FakeActivator : ILateBoundComActivator { private readonly FakeComLog _log; private readonly FakeEngine _engine; private readonly object _eventHandler = new(); public FakeActivator(FakeComLog log, FakeEngine engine) { _log = log; _engine = engine; } public object EventHandler => _eventHandler; public object Create(Guid classId) { _log.Add($"Create:{classId:B}"); return classId switch { var value when value == K3dComConstants.KaEventHandlerClassGuid => _eventHandler, var value when value == K3dComConstants.KaEngineClassGuid => _engine, _ => throw new InvalidOperationException("Unexpected fake CLSID.") }; } } private sealed class ThrowingActivator : ILateBoundComActivator { private readonly Exception _exception; public ThrowingActivator(Exception exception) { _exception = exception; } public object Create(Guid classId) => throw _exception; } private sealed class BlockingEngineActivator : ILateBoundComActivator { private readonly FakeComLog _log; private readonly FakeEngine _engine; private readonly ManualResetEventSlim _activationStarted; private readonly ManualResetEventSlim _releaseActivation; private readonly object _eventHandler = new(); public BlockingEngineActivator( FakeComLog log, FakeEngine engine, ManualResetEventSlim activationStarted, ManualResetEventSlim releaseActivation) { _log = log; _engine = engine; _activationStarted = activationStarted; _releaseActivation = releaseActivation; } public object Create(Guid classId) { _log.Add($"Create:{classId:B}"); if (classId == K3dComConstants.KaEventHandlerClassGuid) { return _eventHandler; } if (classId != K3dComConstants.KaEngineClassGuid) { throw new InvalidOperationException("Unexpected fake CLSID."); } _activationStarted.Set(); if (!_releaseActivation.Wait(TimeSpan.FromSeconds(5))) { throw new TimeoutException("fake activation coordination timeout"); } return _engine; } } public sealed class FakeEngine { private readonly FakeComLog _log; private readonly FakePlayer _player; private readonly FakeScene _scene; private readonly int _connectResult; private readonly Exception? _playerFailure; private readonly Exception? _disconnectFailure; private readonly Exception? _connectFailure; public FakeEngine( FakeComLog log, FakePlayer player, FakeScene scene, int connectResult = 1, Exception? playerFailure = null, Exception? disconnectFailure = null, Exception? connectFailure = null) { _log = log; _player = player; _scene = scene; _connectResult = connectResult; _playerFailure = playerFailure; _disconnectFailure = disconnectFailure; _connectFailure = connectFailure; } public int KTAPConnect( int tcpMode, string host, int hostPort, int clientPort, object eventHandler) { Assert.NotNull(eventHandler); _log.Add($"KTAPConnect:{tcpMode}:{host}:{hostPort}:{clientPort}"); if (_connectFailure is not null) { throw _connectFailure; } return _connectResult; } public object GetScenePlayerOnChannel(int channel) { _log.Add($"GetScenePlayerOnChannel:{channel}"); if (_playerFailure is not null) { throw _playerFailure; } return _player; } public object LoadScene(string sceneFile, string sceneName) { Assert.True(System.IO.Path.IsPathFullyQualified(sceneFile)); _log.Add($"LoadScene:{sceneName}"); return _scene; } public void BeginTransaction() => _log.Add("BeginTransaction"); public void EndTransaction() => _log.Add("EndTransaction"); public void Disconnect() { _log.Add("Disconnect"); if (_disconnectFailure is not null) { throw _disconnectFailure; } } } private sealed class FakeReleaser : ILateBoundComObjectReleaser { private readonly FakeComLog _log; private readonly (object Value, string Name)[] _objects; public FakeReleaser( FakeComLog log, params (object Value, string Name)[] objects) { _log = log; _objects = objects; } public void Release(object value) { var match = Assert.Single(_objects, item => ReferenceEquals(item.Value, value)); _log.Add($"Release:{match.Name}"); } } public sealed class FakePlayer { private readonly FakeComLog _log; public FakePlayer(FakeComLog log) { _log = log; } public void Prepare(int layoutIndex, object scene) { Assert.NotNull(scene); _log.Add($"Prepare:{layoutIndex}"); } public void Play(int layoutIndex) => _log.Add($"Play:{layoutIndex}"); public void StopAll() => _log.Add("StopAll"); public void CutOut(int layoutIndex) => _log.Add($"CutOut:{layoutIndex}"); } public sealed class FakeScene { private readonly FakeComLog _log; public FakeScene(FakeComLog log) { _log = log; } public void SetSceneEffectType(int layoutIndex, int effectType, int duration) => _log.Add($"SetSceneEffectType:{layoutIndex}:{effectType}:{duration}"); public object GetObject(string objectName) { _log.Add($"GetObject:{objectName}"); return new FakeSceneObject(_log, objectName); } public void QueryVariables() => _log.Add("QueryVariables"); } public sealed class FakeSceneObject { private readonly FakeComLog _log; private readonly string _name; public FakeSceneObject(FakeComLog log, string name) { _log = log; _name = name; } public void SetValue(string value) => _log.Add($"SetValue:{_name}:{value}"); public void SetVisible(int visible) => _log.Add($"SetVisible:{_name}:{visible}"); } }