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; 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", "SetOutputChannelIndex:9", "SetSceneEffectType:1:7:12", "BeginTransaction", "GetObject:headline", "SetValue:headline:safe value", "SetVisible:headline:1", "GetObject:badge", "SetVisible:badge:0", "EndTransactionOnChannel:9", "QueryVariables", "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 async Task Prepare_WithoutOutputChannel_UsesDefaultPlayerAndDoesNotSetSceneChannel() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var unchanneledOptions = TestOptions(scenes.Path); unchanneledOptions.Mode = PlayoutMode.Live; unchanneledOptions.OutputChannel = null; var options = ValidatedPlayoutOptions.Create(unchanneledOptions); var cue = options.ResolveCue(new PlayoutCue( "test-scene.t2s", "test-scene", [], 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: 2); await dispatcher.InvokeAsync( () => { using var session = new DynamicK3dSession(activator); session.Connect(options); session.Prepare(cue, options.LayoutIndex); 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", "GetScenePlayer", "LoadScene:test-scene", "SetSceneEffectType:1:7:12", "BeginTransaction", "EndTransaction", "QueryVariables", "Prepare:10", "Disconnect" }, log.Names); Assert.DoesNotContain(log.Names, name => name.StartsWith("SetOutputChannelIndex:", StringComparison.Ordinal)); } [Fact] public async Task SceneReplacement_5001To5006_RetainsAllScenesUntilDisconnect() { using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); var options = SceneOptions(scenes.Path, "5001", "5006"); var cue5001 = SceneCue(options, "5001"); var cue5006 = SceneCue(options, "5006"); var log = new FakeComLog(); var player = new FakePlayer(log); var scene5001 = new FakeScene(log, "5001"); var scene5006 = new FakeScene(log, "5006"); var engine = new FakeEngine(log, player, scene5001) { SceneResolver = name => name == "5006" ? scene5006 : scene5001 }; var activator = new FakeActivator(log, engine); var releaser = new FakeReleaser( log, (scene5001, "5001"), (scene5006, "5006"), (player, "Player"), (engine, "Engine"), (activator.EventHandler, "EventHandler")); await using var dispatcher = new StaDispatcher(capacity: 4); await dispatcher.InvokeAsync( () => { using var session = new DynamicK3dSession(activator, releaser); session.Connect(options); session.Prepare(cue5001, options.LayoutIndex); session.Play(options.LayoutIndex); session.Prepare(cue5006, options.LayoutIndex); Assert.DoesNotContain("Unload:5001", log.Names); session.Play(options.LayoutIndex); session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All); Assert.DoesNotContain(log.Names, name => name.StartsWith("Release:500", StringComparison.Ordinal)); session.Disconnect(); return true; }, TimeSpan.FromSeconds(5), CancellationToken.None); Assert.Equal( new[] { "LoadScene:5001", "Prepare:10", "Play:10", "LoadScene:5006", "Prepare:10", "Play:10", "StopAll", "Disconnect", "Release:5001", "Release:5006" }, SceneLifecycleCalls(log)); } [Fact] public async Task Prepare_ReplacingUnplayedScene_RetainsBothScenesUntilDisconnect() { using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); var options = SceneOptions(scenes.Path, "5001", "5006"); var log = new FakeComLog(); var player = new FakePlayer(log); var scene5001 = new FakeScene(log, "5001"); var scene5006 = new FakeScene(log, "5006"); var engine = new FakeEngine(log, player, scene5001) { SceneResolver = name => name == "5006" ? scene5006 : scene5001 }; var activator = new FakeActivator(log, engine); var releaser = new FakeReleaser( log, (scene5001, "5001"), (scene5006, "5006"), (player, "Player"), (engine, "Engine"), (activator.EventHandler, "EventHandler")); await using var dispatcher = new StaDispatcher(capacity: 2); await dispatcher.InvokeAsync( () => { using var session = new DynamicK3dSession(activator, releaser); session.Connect(options); session.Prepare(SceneCue(options, "5001"), options.LayoutIndex); session.Prepare(SceneCue(options, "5006"), options.LayoutIndex); Assert.DoesNotContain(log.Names, name => name.StartsWith("Release:500", StringComparison.Ordinal)); session.Disconnect(); return true; }, TimeSpan.FromSeconds(5), CancellationToken.None); Assert.Equal( new[] { "LoadScene:5001", "Prepare:10", "LoadScene:5006", "Prepare:10", "Disconnect", "Release:5006", "Release:5001" }, SceneLifecycleCalls(log)); Assert.DoesNotContain("Unload:5006", log.Names); } [Fact] public async Task Prepare_WhenReplacementFails_RetainsFailedAndOnAirScenesUntilDisconnect() { using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); var options = SceneOptions(scenes.Path, "5001", "5006"); var originalFailure = new COMException("fake replacement failure"); var log = new FakeComLog(); var player = new FakePlayer(log); var scene5001 = new FakeScene(log, "5001"); var scene5006 = new FakeScene( log, "5006", queryVariablesFailure: originalFailure); var engine = new FakeEngine(log, player, scene5001) { SceneResolver = name => name == "5006" ? scene5006 : scene5001 }; var activator = new FakeActivator(log, engine); var releaser = new FakeReleaser( log, (scene5001, "5001"), (scene5006, "5006"), (player, "Player"), (engine, "Engine"), (activator.EventHandler, "EventHandler")); await using var dispatcher = new StaDispatcher(capacity: 3); await dispatcher.InvokeAsync( () => { using var session = new DynamicK3dSession(activator, releaser); session.Connect(options); session.Prepare(SceneCue(options, "5001"), options.LayoutIndex); session.Play(options.LayoutIndex); var exception = Assert.Throws( () => session.Prepare(SceneCue(options, "5006"), options.LayoutIndex)); Assert.Same(originalFailure, exception); Assert.DoesNotContain("Unload:5001", log.Names); Assert.DoesNotContain(log.Names, name => name.StartsWith("Release:500", StringComparison.Ordinal)); session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All); Assert.DoesNotContain(log.Names, name => name.StartsWith("Release:500", StringComparison.Ordinal)); session.Disconnect(); return true; }, TimeSpan.FromSeconds(5), CancellationToken.None); Assert.Equal( new[] { "LoadScene:5001", "Prepare:10", "Play:10", "LoadScene:5006", "StopAll", "Disconnect", "Release:5006", "Release:5001" }, SceneLifecycleCalls(log)); } [Fact] public async Task AsyncOutputSequence_DoesNotUnloadAndReleasesScenesOnlyAfterDisconnect() { using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); var options = SceneOptions(scenes.Path, "5001", "5006"); var log = new FakeComLog(); var player = new FakePlayer(log); var scene5001 = new FakeScene(log, "5001"); var scene5006 = new FakeScene(log, "5006"); var engine = new FakeEngine(log, player, scene5001) { SceneResolver = name => name == "5006" ? scene5006 : scene5001 }; var activator = new FakeActivator(log, engine); var releaser = new FakeReleaser( log, (scene5001, "5001"), (scene5006, "5006"), (player, "Player"), (engine, "Engine"), (activator.EventHandler, "EventHandler")); await using var dispatcher = new StaDispatcher(capacity: 4); await dispatcher.InvokeAsync( () => { using var session = new DynamicK3dSession(activator, releaser); session.Connect(options); session.Prepare(SceneCue(options, "5001"), options.LayoutIndex); session.Play(options.LayoutIndex); session.Prepare(SceneCue(options, "5006"), options.LayoutIndex); session.Play(options.LayoutIndex); session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All); Assert.DoesNotContain(log.Names, name => name.StartsWith("Release:500", StringComparison.Ordinal)); session.Disconnect(); return true; }, TimeSpan.FromSeconds(5), CancellationToken.None); Assert.Equal(2, log.Names.Count(name => name == "Play:10")); Assert.Equal(1, log.Names.Count(name => name == "StopAll")); Assert.DoesNotContain(log.Names, name => name.StartsWith("Unload", StringComparison.Ordinal)); Assert.Equal(1, log.Names.Count(name => name == "Release:5001")); Assert.Equal(1, log.Names.Count(name => name == "Release:5006")); } [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_AfterUnknownPlayOutcome_ReleasesReferencesWithoutSdkCleanup() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path)); var cue = SceneCue(options, "test-scene"); var playFailure = new COMException("fake ambiguous play failure"); var log = new FakeComLog(); var player = new FakePlayer(log, playFailure); var scene = new FakeScene(log); var engine = new FakeEngine(log, player, scene); var activator = new FakeActivator(log, engine); var releaser = new FakeReleaser( log, (scene, "Scene"), (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.Prepare(cue, options.LayoutIndex); var exception = Assert.Throws( () => session.Play(options.LayoutIndex)); Assert.Same(playFailure, exception); session.Abandon(); return true; }, TimeSpan.FromSeconds(5), CancellationToken.None); Assert.DoesNotContain("Disconnect", log.Names); Assert.DoesNotContain(log.Names, name => name.StartsWith("Unload", StringComparison.Ordinal)); Assert.Equal(1, log.Names.Count(name => name == "Play:10")); Assert.Equal(1, log.Names.Count(name => name == "Release:Scene")); 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"] }; private static ValidatedPlayoutOptions SceneOptions( string sceneDirectory, params string[] sceneNames) { var options = TestOptions(sceneDirectory); options.TestSceneAllowlist = [.. sceneNames]; return ValidatedPlayoutOptions.Create(options); } private static PlayoutCue SceneCue(ValidatedPlayoutOptions options, string sceneName) => options.ResolveCue(new PlayoutCue( $"{sceneName}.t2s", sceneName, [], FadeDuration: 0)); private static string[] SceneLifecycleCalls(FakeComLog log) => log.Names.Where(name => name.StartsWith("LoadScene:", StringComparison.Ordinal) || name == "Prepare:10" || name == "Play:10" || name == "StopAll" || name == "CutOut:10" || name == "Disconnect" || name == "RollbackTransaction" || name.StartsWith("Unload:", StringComparison.Ordinal) || name.StartsWith("Release:500", StringComparison.Ordinal)).ToArray(); 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 Func? SceneResolver { get; init; } 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 GetScenePlayer() { _log.Add("GetScenePlayer"); 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 SceneResolver?.Invoke(sceneName) ?? _scene; } public void BeginTransaction() => _log.Add("BeginTransaction"); public void EndTransaction() => _log.Add("EndTransaction"); public void EndTransactionOnChannel(int channel) => _log.Add($"EndTransactionOnChannel:{channel}"); public void RollbackTransaction() => _log.Add("RollbackTransaction"); 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; private readonly Exception? _playFailure; public FakePlayer(FakeComLog log, Exception? playFailure = null) { _log = log; _playFailure = playFailure; } public void Prepare(int layoutIndex, object scene) { Assert.NotNull(scene); _log.Add($"Prepare:{layoutIndex}"); } public void Play(int layoutIndex) { _log.Add($"Play:{layoutIndex}"); if (_playFailure is not null) { throw _playFailure; } } public void StopAll() => _log.Add("StopAll"); public void CutOut(int layoutIndex) => _log.Add($"CutOut:{layoutIndex}"); } public sealed class FakeScene { private readonly FakeComLog _log; private readonly string? _name; private readonly Exception? _queryVariablesFailure; private readonly Exception? _unloadFailure; public FakeScene( FakeComLog log, string? name = null, Exception? queryVariablesFailure = null, Exception? unloadFailure = null) { _log = log; _name = name; _queryVariablesFailure = queryVariablesFailure; _unloadFailure = unloadFailure; } public void SetOutputChannelIndex(int channel) => _log.Add($"SetOutputChannelIndex:{channel}"); public void SetSceneEffectType(int inEffect, int effectType, int duration) => _log.Add($"SetSceneEffectType:{inEffect}:{effectType}:{duration}"); public object GetObject(string objectName) { _log.Add($"GetObject:{objectName}"); return new FakeSceneObject(_log, objectName); } public void QueryVariables() { _log.Add("QueryVariables"); if (_queryVariablesFailure is not null) { throw _queryVariablesFailure; } } public void Unload() { _log.Add(_name is null ? "Unload" : $"Unload:{_name}"); if (_unloadFailure is not null) { throw _unloadFailure; } } } 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}"); } }