using System.Collections; using System.Text.Json; using System.Text.Json.Serialization; using MBN_STOCK_WEBVIEW.Core.Playout; using MBN_STOCK_WEBVIEW.Playout.Configuration; using MBN_STOCK_WEBVIEW.PlayoutSmoke; namespace MBN_STOCK_WEBVIEW.Playout.Tests; public sealed class IsolatedTestCommandTests : IDisposable { private readonly TemporarySceneDirectory _scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); [Fact] public void Preflight_ValidSequenceBuildsExactBasenameCuesAndDisablesReconnect() { using var environment = ClearedPlayoutEnvironment(); using var config = CreateConfig(ValidOptions()); var plan = IsolatedTestCommandPreflight.Create(Invocation( SmokeCommandKind.TestSequence, config.Path)); Assert.Equal("5001.t2s", plan.PrepareCue!.SceneFile); Assert.Equal("5001", plan.PrepareCue.SceneName); Assert.Equal("5006.t2s", plan.NextCue!.SceneFile); Assert.Equal("5006", plan.NextCue.SceneName); Assert.Equal(TimeSpan.FromSeconds(1), plan.ObservationDuration); Assert.False(plan.Options.ReconnectEnabled); Assert.Equal(0, plan.Options.MaximumReconnectAttempts); } [Fact] public void Preflight_TestConnectReusesTestConfigurationGatesWithoutScenes() { using var environment = ClearedPlayoutEnvironment(); using var config = CreateConfig(ValidOptions()); var plan = IsolatedTestCommandPreflight.Create(new SmokeCommandInvocation( SmokeCommandKind.TestConnect, config.Path)); Assert.Null(plan.PrepareCue); Assert.Null(plan.NextCue); Assert.Equal(TimeSpan.Zero, plan.ObservationDuration); Assert.Equal(PlayoutMode.Test, plan.Options.Mode); } [Fact] public void Preflight_RejectsAnyPlayoutEnvironmentOverrideBeforeReadingConfiguration() { using var environment = ClearedPlayoutEnvironment() .Set("MBN_STOCK_PLAYOUT_FUTURE_OVERRIDE", "sensitive-value"); var exception = Assert.Throws(() => IsolatedTestCommandPreflight.Create(Invocation( SmokeCommandKind.TestSequence, "C:\\sensitive\\missing.local.json"))); Assert.Equal("environment-override-present", exception.ErrorCode); Assert.DoesNotContain("sensitive", exception.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public void Preflight_RejectsRelativeOrMissingConfigWithoutReflectingPath() { using var environment = ClearedPlayoutEnvironment(); const string unsafePath = ".\\private\\playout.local.json"; var exception = Assert.Throws(() => IsolatedTestCommandPreflight.Create(Invocation( SmokeCommandKind.TestSequence, unsafePath))); Assert.Equal("config-file-rejected", exception.ErrorCode); Assert.DoesNotContain(unsafePath, exception.Message, StringComparison.OrdinalIgnoreCase); } [Theory] [InlineData(PlayoutMode.DryRun, false)] [InlineData(PlayoutMode.Live, false)] [InlineData(PlayoutMode.Test, true)] public void Preflight_RejectsAnythingThatCouldEnableLiveOutput( PlayoutMode mode, bool trustedLiveOutputEnabled) { using var environment = ClearedPlayoutEnvironment(); var options = ValidOptions(); options.Mode = mode; options.TrustedLiveOutputEnabled = trustedLiveOutputEnabled; using var config = CreateConfig(options); var exception = Assert.Throws(() => IsolatedTestCommandPreflight.Create(Invocation( SmokeCommandKind.TestSequence, config.Path))); Assert.Equal("test-mode-required", exception.ErrorCode); } [Fact] public void Preflight_ReusesAllowlistAndExistingFileValidationBeforeEngineCreation() { using var environment = ClearedPlayoutEnvironment(); var options = ValidOptions(); options.TestSceneAllowlist = ["5001"]; using var config = CreateConfig(options); var exception = Assert.Throws(() => IsolatedTestCommandPreflight.Create(Invocation( SmokeCommandKind.TestSequence, config.Path))); Assert.Equal("safety-gate-rejected", exception.ErrorCode); Assert.DoesNotContain("5006", exception.Message, StringComparison.Ordinal); Assert.DoesNotContain(_scenes.Path, exception.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task Executor_SuccessRunsExactSequenceWithObservationAndAllScopeTakeOut() { var engine = new RecordingPlayoutEngine(); var delays = new List(); var report = await IsolatedTestCommandExecutor.ExecuteAsync( ExecutionPlan(SmokeCommandKind.TestSequence), _ => engine, (delay, _) => { delays.Add(delay); return Task.CompletedTask; }); Assert.Equal( [ "Connect", "Prepare", "TakeIn", "Next", "TakeOut:All", "Disconnect", "Dispose" ], engine.Calls); Assert.Equal( [ TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1) ], delays); Assert.True(report.Completed); Assert.True(report.ComActivationAttempted); Assert.Equal("accepted-unconfirmed", report.LastKtapConnectState); Assert.True(report.KtapConnectAttempted); Assert.True(report.KtapConnectAccepted); Assert.True(report.NetworkMonitoringRecordExpected); Assert.True(report.NetworkMonitoringCheckRequired); Assert.Null(report.NetworkMonitoringVerified); Assert.False(report.OutcomeUnknown); Assert.Equal(6, report.Steps.Count); Assert.True(report.Cleanup.AdapterDisposed); } [Fact] public async Task Executor_TestConnectNeverIssuesSceneOrOutputMutation() { var engine = new RecordingPlayoutEngine(); var report = await IsolatedTestCommandExecutor.ExecuteAsync( ExecutionPlan(SmokeCommandKind.TestConnect), _ => engine, (_, _) => throw new InvalidOperationException("No observation is allowed.")); Assert.Equal(["Connect", "Disconnect", "Dispose"], engine.Calls); Assert.True(report.Completed); Assert.True(report.ComActivationAttempted); Assert.Equal("accepted-unconfirmed", report.LastKtapConnectState); Assert.True(report.NetworkMonitoringRecordExpected); Assert.True(report.NetworkMonitoringCheckRequired); } [Fact] public async Task Executor_ConnectUnavailableDoesNotGuessWhetherComWasAttempted() { var engine = new RecordingPlayoutEngine() .Return(PlayoutOperation.Connect, PlayoutResultCode.Unavailable); var report = await IsolatedTestCommandExecutor.ExecuteAsync( ExecutionPlan(SmokeCommandKind.TestConnect), _ => engine, (_, _) => Task.CompletedTask); Assert.Null(report.ComActivationAttempted); Assert.Equal("not-attempted", report.LastKtapConnectState); Assert.False(report.NetworkMonitoringRecordExpected); Assert.False(report.NetworkMonitoringCheckRequired); Assert.Equal(["Connect", "Disconnect", "Dispose"], engine.Calls); } [Fact] public async Task Executor_UnknownConnectOutcomeReportsUnknownActivationAndSkipsDisconnect() { var engine = new RecordingPlayoutEngine() .Return(PlayoutOperation.Connect, PlayoutResultCode.OutcomeUnknown); var report = await IsolatedTestCommandExecutor.ExecuteAsync( ExecutionPlan(SmokeCommandKind.TestConnect), _ => engine, (_, _) => Task.CompletedTask); Assert.Null(report.ComActivationAttempted); Assert.Equal("attempted", report.LastKtapConnectState); Assert.True(report.KtapConnectAttempted); Assert.Null(report.KtapConnectAccepted); Assert.Null(report.NetworkMonitoringRecordExpected); Assert.True(report.NetworkMonitoringCheckRequired); Assert.True(report.OutcomeUnknown); Assert.Equal(["Connect", "Quarantine", "Dispose"], engine.Calls); Assert.True(report.Cleanup.QuarantineCompleted); } [Fact] public async Task Executor_EngineCreationFailureReportsNoComActivationAttempt() { var report = await IsolatedTestCommandExecutor.ExecuteAsync( ExecutionPlan(SmokeCommandKind.TestConnect), _ => throw new InvalidOperationException("sensitive factory failure"), (_, _) => Task.CompletedTask); Assert.False(report.ConnectRequestIssued); Assert.False(report.ComActivationAttempted); Assert.False(report.Completed); Assert.False(report.OutcomeUnknown); } [Fact] public async Task Executor_IntermediateFailureStopsPlaybackAndPerformsDisconnectCleanup() { var engine = new RecordingPlayoutEngine() .Return(PlayoutOperation.Prepare, PlayoutResultCode.Failed); var report = await IsolatedTestCommandExecutor.ExecuteAsync( ExecutionPlan(SmokeCommandKind.TestSequence), _ => engine, (_, _) => Task.CompletedTask); Assert.Equal(["Connect", "Prepare", "Disconnect", "Dispose"], engine.Calls); Assert.False(report.Completed); Assert.Equal("Prepare", report.StoppedAfter); Assert.Contains(report.Steps, step => step.Phase == "cleanup" && step.Operation == "Disconnect"); } [Fact] public async Task Executor_OutcomeUnknownNeverAttemptsAutomaticDisconnectOrLaterCommand() { var engine = new RecordingPlayoutEngine() .Return(PlayoutOperation.TakeIn, PlayoutResultCode.OutcomeUnknown); var report = await IsolatedTestCommandExecutor.ExecuteAsync( ExecutionPlan(SmokeCommandKind.TestSequence), _ => engine, (_, _) => Task.CompletedTask); Assert.Equal(["Connect", "Prepare", "TakeIn", "Quarantine", "Dispose"], engine.Calls); Assert.True(report.OutcomeUnknown); Assert.True(report.OutputMayBeActive); Assert.False(report.Cleanup.DisconnectAttempted); Assert.True(report.Cleanup.AdapterDisposed); } [Fact] public async Task Executor_QuarantineFailureSkipsDisposeToAvoidImplicitDisconnect() { var engine = new RecordingPlayoutEngine { ThrowOnQuarantine = true }.Return(PlayoutOperation.TakeIn, PlayoutResultCode.OutcomeUnknown); var report = await IsolatedTestCommandExecutor.ExecuteAsync( ExecutionPlan(SmokeCommandKind.TestSequence), _ => engine, (_, _) => Task.CompletedTask); Assert.Equal(["Connect", "Prepare", "TakeIn", "Quarantine"], engine.Calls); Assert.True(report.Cleanup.QuarantineAttempted); Assert.False(report.Cleanup.QuarantineCompleted); Assert.False(report.Cleanup.AdapterDisposed); Assert.False(report.Cleanup.DisconnectAttempted); } [Fact] public async Task Executor_CancelledObservationStopsBeforeNextWithoutMarkingComOutcomeUnknown() { using var cancellation = new CancellationTokenSource(); var engine = new RecordingPlayoutEngine(); var report = await IsolatedTestCommandExecutor.ExecuteAsync( ExecutionPlan(SmokeCommandKind.TestSequence), _ => engine, (_, token) => { cancellation.Cancel(); return Task.FromCanceled(token); }, cancellation.Token); Assert.Equal( ["Connect", "Prepare", "TakeIn", "TakeOut:All", "Disconnect", "Dispose"], engine.Calls); Assert.Equal("Observation", report.StoppedAfter); Assert.False(report.OutcomeUnknown); Assert.False(report.OutputMayBeActive); Assert.False(report.Completed); Assert.Contains(report.Steps, step => step.Phase == "cleanup" && step.Operation == "TakeOut"); } [Fact] public async Task Executor_UnknownTakeOutCleanupSkipsAutomaticDisconnect() { using var cancellation = new CancellationTokenSource(); var engine = new RecordingPlayoutEngine() .Return(PlayoutOperation.TakeOut, PlayoutResultCode.OutcomeUnknown); var report = await IsolatedTestCommandExecutor.ExecuteAsync( ExecutionPlan(SmokeCommandKind.TestSequence), _ => engine, (_, token) => { cancellation.Cancel(); return Task.FromCanceled(token); }, cancellation.Token); Assert.Equal( ["Connect", "Prepare", "TakeIn", "TakeOut:All", "Quarantine", "Dispose"], engine.Calls); Assert.True(report.OutcomeUnknown); Assert.True(report.OutputMayBeActive); Assert.False(report.Cleanup.DisconnectAttempted); } [Theory] [InlineData(PlayoutResultCode.Failed)] [InlineData(PlayoutResultCode.Unavailable)] [InlineData(PlayoutResultCode.Cancelled)] [InlineData(PlayoutResultCode.Rejected)] public async Task Executor_NonSuccessfulCleanupTakeOutReportsPossibleActiveOutput( PlayoutResultCode takeOutCode) { using var cancellation = new CancellationTokenSource(); var engine = new RecordingPlayoutEngine() .Return(PlayoutOperation.TakeOut, takeOutCode); var report = await IsolatedTestCommandExecutor.ExecuteAsync( ExecutionPlan(SmokeCommandKind.TestSequence), _ => engine, (_, token) => { cancellation.Cancel(); return Task.FromCanceled(token); }, cancellation.Token); Assert.Equal( ["Connect", "Prepare", "TakeIn", "TakeOut:All", "Quarantine", "Dispose"], engine.Calls); Assert.True(report.OutcomeUnknown); Assert.True(report.OutputMayBeActive); Assert.False(report.Cleanup.DisconnectAttempted); } [Theory] [InlineData(PlayoutResultCode.Failed)] [InlineData(PlayoutResultCode.Unavailable)] [InlineData(PlayoutResultCode.Cancelled)] [InlineData(PlayoutResultCode.Rejected)] public async Task Executor_NonSuccessfulSequenceTakeOutReportsPossibleActiveOutput( PlayoutResultCode takeOutCode) { var engine = new RecordingPlayoutEngine() .Return(PlayoutOperation.TakeOut, takeOutCode); var report = await IsolatedTestCommandExecutor.ExecuteAsync( ExecutionPlan(SmokeCommandKind.TestSequence), _ => engine, (_, _) => Task.CompletedTask); Assert.Equal( ["Connect", "Prepare", "TakeIn", "Next", "TakeOut:All", "Quarantine", "Dispose"], engine.Calls); Assert.True(report.OutcomeUnknown); Assert.True(report.OutputMayBeActive); Assert.False(report.Cleanup.DisconnectAttempted); } [Fact] public async Task Executor_ObservationFailureStillPerformsTakeOutCleanup() { var engine = new RecordingPlayoutEngine(); var report = await IsolatedTestCommandExecutor.ExecuteAsync( ExecutionPlan(SmokeCommandKind.TestSequence), _ => engine, (_, _) => Task.FromException(new InvalidOperationException("fake observation failure"))); Assert.Equal( ["Connect", "Prepare", "TakeIn", "TakeOut:All", "Disconnect", "Dispose"], engine.Calls); Assert.Equal("Observation", report.StoppedAfter); Assert.False(report.OutcomeUnknown); Assert.False(report.OutputMayBeActive); Assert.True(report.Cleanup.DisconnectAttempted); } [Fact] public async Task Executor_ReportDoesNotContainEngineMessagesPathsOrSceneCodes() { var engine = new RecordingPlayoutEngine("C:\\private\\5001.t2s HRESULT 0x80004005"); var report = await IsolatedTestCommandExecutor.ExecuteAsync( ExecutionPlan(SmokeCommandKind.TestSequence), _ => engine, (_, _) => Task.CompletedTask); var json = JsonSerializer.Serialize(report); Assert.DoesNotContain("private", json, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("5001", json, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("HRESULT", json, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("0x80004005", json, StringComparison.OrdinalIgnoreCase); } public void Dispose() => _scenes.Dispose(); private IsolatedTestCommandPlan ExecutionPlan(SmokeCommandKind kind) => new( kind, ValidOptions(), kind == SmokeCommandKind.TestSequence ? new PlayoutCue("5001.t2s", "5001") : null, kind == SmokeCommandKind.TestSequence ? new PlayoutCue("5006.t2s", "5006") : null, kind == SmokeCommandKind.TestSequence ? TimeSpan.FromSeconds(1) : TimeSpan.Zero); private SmokeCommandInvocation Invocation(SmokeCommandKind kind, string configPath) => new( kind, configPath, "5001", "5006", 1000); private PlayoutOptions ValidOptions() => new() { Mode = PlayoutMode.Test, Host = "127.0.0.1", Port = 30001, TcpMode = 1, ClientPort = 0, OutputChannel = 9, LayoutIndex = 10, SceneDirectory = _scenes.Path, TestProcessWindowTitlePattern = "^Tornado2 TEST$", TestSceneAllowlist = ["5001", "5006"], TrustedLiveOutputEnabled = false, ReconnectEnabled = true, MaximumReconnectAttempts = 3 }; private static TemporaryJsonFile CreateConfig(PlayoutOptions options) { var serializerOptions = new JsonSerializerOptions(); serializerOptions.Converters.Add(new JsonStringEnumConverter()); return TemporaryJsonFile.Create(JsonSerializer.Serialize(options, serializerOptions)); } private static EnvironmentScope ClearedPlayoutEnvironment() { var result = new EnvironmentScope(); foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) { if (entry.Key is string name && name.StartsWith( IsolatedTestCommandPreflight.EnvironmentVariablePrefix, StringComparison.OrdinalIgnoreCase)) { result.Set(name, null); } } return result; } private sealed class RecordingPlayoutEngine : IPlayoutEngine { private readonly Dictionary _codes = []; private readonly string _message; public RecordingPlayoutEngine(string message = "safe") { _message = message; Status = new PlayoutStatus( PlayoutMode.Test, PlayoutConnectionState.Disconnected, false, true, false, false, null, null, "safe", DateTimeOffset.UnixEpoch, 1); } public List Calls { get; } = []; public bool ThrowOnQuarantine { get; init; } public PlayoutStatus Status { get; private set; } public event EventHandler? StatusChanged { add { } remove { } } public RecordingPlayoutEngine Return( PlayoutOperation operation, PlayoutResultCode code) { _codes[operation] = code; return this; } public Task ConnectAsync(CancellationToken cancellationToken = default) => Result(PlayoutOperation.Connect, "Connect"); public Task DisconnectAsync(CancellationToken cancellationToken = default) => Result(PlayoutOperation.Disconnect, "Disconnect"); public Task PrepareAsync( PlayoutCue cue, CancellationToken cancellationToken = default) => Result(PlayoutOperation.Prepare, "Prepare"); public Task TakeInAsync(CancellationToken cancellationToken = default) => Result(PlayoutOperation.TakeIn, "TakeIn"); public Task NextAsync( PlayoutCue cue, CancellationToken cancellationToken = default) => Result(PlayoutOperation.Next, "Next"); public Task TakeOutAsync( PlayoutTakeOutScope scope, CancellationToken cancellationToken = default) => Result(PlayoutOperation.TakeOut, $"TakeOut:{scope}"); public ValueTask DisposeAsync() { Calls.Add("Dispose"); return ValueTask.CompletedTask; } public ValueTask QuarantineAsync() { Calls.Add("Quarantine"); if (ThrowOnQuarantine) { throw new InvalidOperationException("fake quarantine failure"); } return ValueTask.CompletedTask; } private Task Result(PlayoutOperation operation, string call) { Calls.Add(call); var code = _codes.GetValueOrDefault(operation, PlayoutResultCode.Success); if (operation == PlayoutOperation.Connect) { Status = Status with { LastKtapConnectState = code switch { PlayoutResultCode.Success => PlayoutKtapConnectState.AcceptedUnconfirmed, PlayoutResultCode.Failed => PlayoutKtapConnectState.Failed, PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut => PlayoutKtapConnectState.Attempted, _ => PlayoutKtapConnectState.NotAttempted } }; } return Task.FromResult(new PlayoutResult( operation, code, false, _message, DateTimeOffset.UnixEpoch)); } } }