feat: harden isolated Tornado test workflow
This commit is contained in:
@@ -99,6 +99,31 @@ public sealed class DryRunPlayoutEngineTests
|
||||
Assert.Null(engine.Status.OnAirSceneName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DryRun_NextAfterPrepareButBeforeTakeInIsRejected()
|
||||
{
|
||||
await using var engine = CreateEngine(PlayoutMode.DryRun);
|
||||
var idleNext = await engine.NextAsync(
|
||||
Cue("next-scene"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.Rejected, idleNext.Code);
|
||||
Assert.Null(engine.Status.PreparedSceneName);
|
||||
Assert.Null(engine.Status.OnAirSceneName);
|
||||
|
||||
Assert.True((await engine.PrepareAsync(
|
||||
Cue("first-scene"),
|
||||
CancellationToken.None)).IsSuccess);
|
||||
|
||||
var next = await engine.NextAsync(
|
||||
Cue("next-scene"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.Rejected, next.Code);
|
||||
Assert.Equal("first-scene", engine.Status.PreparedSceneName);
|
||||
Assert.Null(engine.Status.OnAirSceneName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelledDryRunCommand_DoesNotChangeStatus()
|
||||
{
|
||||
|
||||
@@ -21,14 +21,27 @@ public sealed class DynamicK3dSessionTests
|
||||
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<Exception>(
|
||||
() => dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
using var session = new DynamicK3dSession(activator);
|
||||
session.Connect(options);
|
||||
using var session = new DynamicK3dSession(activator, releaser);
|
||||
try
|
||||
{
|
||||
session.Connect(options);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Assert.False(session.IsConnected);
|
||||
throw;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
TimeSpan.FromSeconds(5),
|
||||
@@ -38,6 +51,114 @@ public sealed class DynamicK3dSessionTests
|
||||
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 exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
using var session = new DynamicK3dSession(activator, releaser);
|
||||
session.Connect(options);
|
||||
return true;
|
||||
},
|
||||
TimeSpan.FromSeconds(5),
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Same(originalFailure, exception);
|
||||
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 exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
using var session = new DynamicK3dSession(activator, releaser);
|
||||
session.Connect(options);
|
||||
return true;
|
||||
},
|
||||
TimeSpan.FromSeconds(5),
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Same(originalFailure, exception);
|
||||
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]
|
||||
@@ -115,6 +236,40 @@ public sealed class DynamicK3dSessionTests
|
||||
Assert.Equal(typeof(object), create.ReturnType);
|
||||
}
|
||||
|
||||
[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,
|
||||
@@ -156,6 +311,8 @@ public sealed class DynamicK3dSessionTests
|
||||
_engine = engine;
|
||||
}
|
||||
|
||||
public object EventHandler => _eventHandler;
|
||||
|
||||
public object Create(Guid classId)
|
||||
{
|
||||
_log.Add($"Create:{classId:B}");
|
||||
@@ -174,17 +331,26 @@ public sealed class DynamicK3dSessionTests
|
||||
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)
|
||||
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(
|
||||
@@ -196,12 +362,22 @@ public sealed class DynamicK3dSessionTests
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -216,7 +392,34 @@ public sealed class DynamicK3dSessionTests
|
||||
|
||||
public void EndTransaction() => _log.Add("EndTransaction");
|
||||
|
||||
public void Disconnect() => _log.Add("Disconnect");
|
||||
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
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
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<IsolatedTestPreflightException>(() =>
|
||||
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<IsolatedTestPreflightException>(() =>
|
||||
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<IsolatedTestPreflightException>(() =>
|
||||
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<IsolatedTestPreflightException>(() =>
|
||||
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<TimeSpan>();
|
||||
|
||||
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)], delays);
|
||||
Assert.True(report.Completed);
|
||||
Assert.True(report.ComActivationAttempted);
|
||||
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);
|
||||
}
|
||||
|
||||
[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(["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.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<PlayoutOperation, PlayoutResultCode> _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<string> Calls { get; } = [];
|
||||
|
||||
public bool ThrowOnQuarantine { get; init; }
|
||||
|
||||
public PlayoutStatus Status { get; }
|
||||
|
||||
public event EventHandler<PlayoutStatusChangedEventArgs>? StatusChanged
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
|
||||
public RecordingPlayoutEngine Return(
|
||||
PlayoutOperation operation,
|
||||
PlayoutResultCode code)
|
||||
{
|
||||
_codes[operation] = code;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Task<PlayoutResult> ConnectAsync(CancellationToken cancellationToken = default) =>
|
||||
Result(PlayoutOperation.Connect, "Connect");
|
||||
|
||||
public Task<PlayoutResult> DisconnectAsync(CancellationToken cancellationToken = default) =>
|
||||
Result(PlayoutOperation.Disconnect, "Disconnect");
|
||||
|
||||
public Task<PlayoutResult> PrepareAsync(
|
||||
PlayoutCue cue,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Result(PlayoutOperation.Prepare, "Prepare");
|
||||
|
||||
public Task<PlayoutResult> TakeInAsync(CancellationToken cancellationToken = default) =>
|
||||
Result(PlayoutOperation.TakeIn, "TakeIn");
|
||||
|
||||
public Task<PlayoutResult> NextAsync(
|
||||
PlayoutCue cue,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Result(PlayoutOperation.Next, "Next");
|
||||
|
||||
public Task<PlayoutResult> 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<PlayoutResult> Result(PlayoutOperation operation, string call)
|
||||
{
|
||||
Calls.Add(call);
|
||||
var code = _codes.GetValueOrDefault(operation, PlayoutResultCode.Success);
|
||||
return Task.FromResult(new PlayoutResult(
|
||||
operation,
|
||||
code,
|
||||
false,
|
||||
_message,
|
||||
DateTimeOffset.UnixEpoch));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,75 @@ public sealed class K3dRegistrationProbeTests
|
||||
AssertSafe(report);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_WhenProcessOrTypeLibraryIsNotX64_FailsClosed()
|
||||
{
|
||||
var snapshot = ReadySnapshot() with
|
||||
{
|
||||
Is64BitProcess = false,
|
||||
Win64TypeLibraryPath = null
|
||||
};
|
||||
var probe = CreateProbe(snapshot, ReadyBinaries());
|
||||
|
||||
var report = probe.Probe();
|
||||
|
||||
Assert.False(report.IsReady);
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.ProcessIsNot64Bit));
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.TypeLibraryMissing));
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.Win64BinaryMissing));
|
||||
AssertSafe(report);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_WhenClassThreadingAndTypeLibraryDiffer_FailsClosed()
|
||||
{
|
||||
var snapshot = ReadySnapshot() with
|
||||
{
|
||||
Engine = ReadySnapshot().Engine with
|
||||
{
|
||||
ThreadingModel = "Both",
|
||||
TypeLibraryId = K3dComConstants.KaEventHandlerClassId
|
||||
},
|
||||
EventHandler = ReadySnapshot().EventHandler with
|
||||
{
|
||||
ThreadingModel = "Free",
|
||||
TypeLibraryId = K3dComConstants.KaEngineClassId
|
||||
}
|
||||
};
|
||||
var probe = CreateProbe(snapshot, ReadyBinaries());
|
||||
|
||||
var report = probe.Probe();
|
||||
|
||||
Assert.False(report.IsReady);
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EngineThreadingModelMismatch));
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EngineTypeLibraryMismatch));
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EventHandlerThreadingModelMismatch));
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EventHandlerTypeLibraryMismatch));
|
||||
Assert.False(report.IsEngineClassReady);
|
||||
Assert.False(report.IsEventHandlerClassReady);
|
||||
AssertSafe(report);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_WhenEitherClassRegistrationIsMissing_FailsClosed()
|
||||
{
|
||||
var snapshot = ReadySnapshot() with
|
||||
{
|
||||
Engine = ReadySnapshot().Engine with { ClassKeyPresent = false },
|
||||
EventHandler = ReadySnapshot().EventHandler with { ClassKeyPresent = false }
|
||||
};
|
||||
var probe = CreateProbe(snapshot, ReadyBinaries());
|
||||
|
||||
var report = probe.Probe();
|
||||
|
||||
Assert.False(report.IsReady);
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EngineClassMissing));
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EventHandlerClassMissing));
|
||||
Assert.False(report.IsEngineClassReady);
|
||||
Assert.False(report.IsEventHandlerClassReady);
|
||||
AssertSafe(report);
|
||||
}
|
||||
|
||||
private static K3dRegistrationProbe CreateProbe(
|
||||
K3dRegistrySnapshot snapshot,
|
||||
FakeBinaryInspector binaries) =>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Playout\MBN_STOCK_WEBVIEW.Playout.csproj" />
|
||||
<ProjectReference Include="..\..\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke\MBN_STOCK_WEBVIEW.PlayoutSmoke.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -53,6 +53,10 @@ internal sealed class FakeK3dSession : IK3dSession
|
||||
|
||||
public Action<int, PlayoutTakeOutScope>? TakeOutAction { get; set; }
|
||||
|
||||
public Action? AbandonAction { get; set; }
|
||||
|
||||
public Action<string>? CallObserver { get; set; }
|
||||
|
||||
public void Connect(ValidatedPlayoutOptions options)
|
||||
{
|
||||
Record("Connect");
|
||||
@@ -85,6 +89,16 @@ internal sealed class FakeK3dSession : IK3dSession
|
||||
TakeOutAction?.Invoke(layoutIndex, scope);
|
||||
}
|
||||
|
||||
public void Abandon()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 0)
|
||||
{
|
||||
Record("Abandon");
|
||||
AbandonAction?.Invoke();
|
||||
IsConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 0)
|
||||
@@ -99,6 +113,7 @@ internal sealed class FakeK3dSession : IK3dSession
|
||||
Calls.Enqueue(name);
|
||||
ThreadIds.Enqueue(Environment.CurrentManagedThreadId);
|
||||
ApartmentStates.Enqueue(Thread.CurrentThread.GetApartmentState());
|
||||
CallObserver?.Invoke(name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,26 @@ public sealed class PlayoutOptionsLoaderTests
|
||||
Assert.True(options.TrustedLiveOutputEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadFileOnly_IgnoresEnvironmentForReproducibleExplicitDiagnostics()
|
||||
{
|
||||
using var environment = ClearedEnvironment()
|
||||
.Set("MBN_STOCK_PLAYOUT_MODE", "Live")
|
||||
.Set("MBN_STOCK_PLAYOUT_HOST", "sensitive-host");
|
||||
using var file = TemporaryJsonFile.Create(
|
||||
"""
|
||||
{
|
||||
"mode": "Test",
|
||||
"host": "127.0.0.1"
|
||||
}
|
||||
""");
|
||||
|
||||
var options = PlayoutOptionsLoader.LoadFileOnly(file.Path);
|
||||
|
||||
Assert.Equal(PlayoutMode.Test, options.Mode);
|
||||
Assert.Equal("127.0.0.1", options.Host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_InvalidJson_ReturnsOnlySafeConfigurationMessage()
|
||||
{
|
||||
|
||||
@@ -25,16 +25,34 @@ public sealed class PlayoutSafetyValidationTests : IDisposable
|
||||
|
||||
[Theory]
|
||||
[InlineData("Tornado2 TEST", false)]
|
||||
[InlineData("Tornado2", true)]
|
||||
[InlineData("Tornado2 PGM", true)]
|
||||
[InlineData("PROGRAM OUTPUT", true)]
|
||||
[InlineData("preview pgm backup", true)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("SAFE", false)]
|
||||
[InlineData("", true)]
|
||||
[InlineData(null, true)]
|
||||
public void IsProgramTitle_RejectsPgmAndProgramWindows(string? title, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, TornadoProcessProbe.IsProgramTitle(title));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Tornado2 TEST", true)]
|
||||
[InlineData("TEST-1", true)]
|
||||
[InlineData("preview_test_output", true)]
|
||||
[InlineData("Tornado2", false)]
|
||||
[InlineData("CONTEST", false)]
|
||||
[InlineData("TEST1", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void HasExplicitTestMarker_RequiresStandaloneTestToken(
|
||||
string? title,
|
||||
bool expected)
|
||||
{
|
||||
Assert.Equal(expected, TornadoProcessProbe.HasExplicitTestMarker(title));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(1, true)]
|
||||
@@ -96,6 +114,9 @@ public sealed class PlayoutSafetyValidationTests : IDisposable
|
||||
[InlineData("PGM")]
|
||||
[InlineData("PROGRAM")]
|
||||
[InlineData("^Tornado2 (TEST|PGM)$")]
|
||||
[InlineData("^Tornado2$")]
|
||||
[InlineData("^$")]
|
||||
[InlineData("^SAFE$")]
|
||||
public void Validate_TestWindowPatternThatCanMatchProgramOutput_IsRejected(string pattern)
|
||||
{
|
||||
var options = ValidTestOptions();
|
||||
|
||||
200
tests/MBN_STOCK_WEBVIEW.Playout.Tests/SmokeCommandLineTests.cs
Normal file
200
tests/MBN_STOCK_WEBVIEW.Playout.Tests/SmokeCommandLineTests.cs
Normal file
@@ -0,0 +1,200 @@
|
||||
using MBN_STOCK_WEBVIEW.PlayoutSmoke;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class SmokeCommandLineTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Tornado2 TEST", false)]
|
||||
[InlineData("TEST-1", false)]
|
||||
[InlineData("Tornado2", true)]
|
||||
[InlineData("PGM", true)]
|
||||
[InlineData("CONTEST", true)]
|
||||
[InlineData("", true)]
|
||||
[InlineData(null, true)]
|
||||
public void TornadoWindowTitleWithoutExplicitTestMarkerIsUnsafe(
|
||||
string? title,
|
||||
bool expected)
|
||||
{
|
||||
Assert.Equal(expected, SmokeCommandLine.IsUnsafeTornadoWindowTitle(title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_NoArguments_PreservesReadOnlyProbeDefault()
|
||||
{
|
||||
var parsed = SmokeCommandLine.Parse([]);
|
||||
|
||||
Assert.True(parsed.IsValid);
|
||||
Assert.Equal(SmokeCommandKind.Probe, parsed.Invocation.Kind);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("--probe", "Probe")]
|
||||
[InlineData("--PROBE", "Probe")]
|
||||
[InlineData("--dry-run", "DryRun")]
|
||||
public void Parse_ExistingComFreeCommandsRemainAvailable(
|
||||
string argument,
|
||||
string expected)
|
||||
{
|
||||
var parsed = SmokeCommandLine.Parse([argument]);
|
||||
|
||||
Assert.True(parsed.IsValid);
|
||||
Assert.Equal(expected, parsed.Invocation.Kind.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TestConnectRequiresExplicitAcknowledgementAndConfigArgument()
|
||||
{
|
||||
var withoutAcknowledgement = SmokeCommandLine.Parse(
|
||||
["--test-connect", "--config", "C:\\safe\\playout.local.json"]);
|
||||
var complete = SmokeCommandLine.Parse(
|
||||
[
|
||||
"--test-connect",
|
||||
SmokeCommandLine.AcknowledgementFlag,
|
||||
"--config",
|
||||
"C:\\safe\\playout.local.json"
|
||||
]);
|
||||
|
||||
Assert.False(withoutAcknowledgement.IsValid);
|
||||
Assert.Equal("missing-acknowledgement", withoutAcknowledgement.ErrorCode);
|
||||
Assert.True(complete.IsValid);
|
||||
Assert.Equal(SmokeCommandKind.TestConnect, complete.Invocation.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TestSequenceRequiresBothSceneCodesAndBoundedObservation()
|
||||
{
|
||||
var missingObservation = SmokeCommandLine.Parse(
|
||||
[
|
||||
"--test-sequence",
|
||||
SmokeCommandLine.AcknowledgementFlag,
|
||||
"--config",
|
||||
"C:\\safe\\playout.local.json",
|
||||
"--prepare",
|
||||
"5001",
|
||||
"--next",
|
||||
"5006"
|
||||
]);
|
||||
var complete = SmokeCommandLine.Parse(
|
||||
[
|
||||
"--test-sequence",
|
||||
"--prepare",
|
||||
"5001",
|
||||
"--observe-ms",
|
||||
"1000",
|
||||
SmokeCommandLine.AcknowledgementFlag,
|
||||
"--next",
|
||||
"5006",
|
||||
"--config",
|
||||
"C:\\safe\\playout.local.json"
|
||||
]);
|
||||
|
||||
Assert.False(missingObservation.IsValid);
|
||||
Assert.Equal("missing-required-option", missingObservation.ErrorCode);
|
||||
Assert.True(complete.IsValid);
|
||||
Assert.Equal(1000, complete.Invocation.ObservationMilliseconds);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("999")]
|
||||
[InlineData("30001")]
|
||||
[InlineData("1.5")]
|
||||
[InlineData("secret")]
|
||||
public void Parse_RejectsUnboundedOrNonIntegralObservation(string value)
|
||||
{
|
||||
var parsed = ValidSequenceArguments(observation: value);
|
||||
|
||||
Assert.False(parsed.IsValid);
|
||||
Assert.Equal("invalid-observation-duration", parsed.ErrorCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../5001")]
|
||||
[InlineData("5001.t2s")]
|
||||
[InlineData("장면")]
|
||||
[InlineData("SCENE A")]
|
||||
public void Parse_RejectsUnsafeSceneCodesWithoutReflectingThem(string sceneCode)
|
||||
{
|
||||
var parsed = ValidSequenceArguments(prepare: sceneCode);
|
||||
|
||||
Assert.False(parsed.IsValid);
|
||||
Assert.Equal("unsafe-scene-code", parsed.ErrorCode);
|
||||
Assert.DoesNotContain(sceneCode, parsed.ErrorCode, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TestConnectRejectsMutationOptions()
|
||||
{
|
||||
var parsed = SmokeCommandLine.Parse(
|
||||
[
|
||||
"--test-connect",
|
||||
SmokeCommandLine.AcknowledgementFlag,
|
||||
"--config",
|
||||
"C:\\safe\\playout.local.json",
|
||||
"--prepare",
|
||||
"5001"
|
||||
]);
|
||||
|
||||
Assert.False(parsed.IsValid);
|
||||
Assert.Equal("unknown-option", parsed.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TestSequenceRequiresTwoDistinctSceneCodes()
|
||||
{
|
||||
var parsed = SmokeCommandLine.Parse(
|
||||
[
|
||||
"--test-sequence",
|
||||
SmokeCommandLine.AcknowledgementFlag,
|
||||
"--config",
|
||||
"C:\\safe\\playout.local.json",
|
||||
"--prepare",
|
||||
"5001",
|
||||
"--next",
|
||||
"5001",
|
||||
"--observe-ms",
|
||||
"1000"
|
||||
]);
|
||||
|
||||
Assert.False(parsed.IsValid);
|
||||
Assert.Equal("duplicate-scene-code", parsed.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TestPlanUsesTheSameSequenceInputsButNeverImpliesExecution()
|
||||
{
|
||||
var parsed = SmokeCommandLine.Parse(
|
||||
[
|
||||
"--test-plan",
|
||||
SmokeCommandLine.AcknowledgementFlag,
|
||||
"--config",
|
||||
"C:\\safe\\playout.local.json",
|
||||
"--prepare",
|
||||
"5001",
|
||||
"--next",
|
||||
"5006",
|
||||
"--observe-ms",
|
||||
"5000"
|
||||
]);
|
||||
|
||||
Assert.True(parsed.IsValid);
|
||||
Assert.Equal(SmokeCommandKind.TestPlan, parsed.Invocation.Kind);
|
||||
}
|
||||
|
||||
private static SmokeCommandParseResult ValidSequenceArguments(
|
||||
string prepare = "5001",
|
||||
string observation = "1000") =>
|
||||
SmokeCommandLine.Parse(
|
||||
[
|
||||
"--test-sequence",
|
||||
SmokeCommandLine.AcknowledgementFlag,
|
||||
"--config",
|
||||
"C:\\safe\\playout.local.json",
|
||||
"--prepare",
|
||||
prepare,
|
||||
"--next",
|
||||
"5006",
|
||||
"--observe-ms",
|
||||
observation
|
||||
]);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
@@ -155,6 +156,165 @@ public sealed class TornadoPlayoutEngineTests
|
||||
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<string>();
|
||||
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()
|
||||
{
|
||||
@@ -236,7 +396,7 @@ public sealed class TornadoPlayoutEngineTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TimedOutTakeIn_QuarantinesEngineAndNeverReconnectsOrReplays()
|
||||
public async Task TimedOutTakeIn_WithProcessGenerationChange_NeverReconnectsOrReplays()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
using var playStarted = new ManualResetEventSlim();
|
||||
@@ -253,11 +413,18 @@ public sealed class TornadoPlayoutEngineTests
|
||||
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(new TornadoProcessSnapshot(1, 1, 0)),
|
||||
new FakeTornadoProcessProbe(
|
||||
originalProcess,
|
||||
originalProcess,
|
||||
originalProcess,
|
||||
originalProcess,
|
||||
restartedProcess),
|
||||
liveAuthorized: false);
|
||||
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
@@ -274,6 +441,9 @@ public sealed class TornadoPlayoutEngineTests
|
||||
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,
|
||||
@@ -282,6 +452,7 @@ public sealed class TornadoPlayoutEngineTests
|
||||
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
|
||||
@@ -290,6 +461,208 @@ public sealed class TornadoPlayoutEngineTests
|
||||
}
|
||||
}
|
||||
|
||||
[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()
|
||||
{
|
||||
@@ -329,15 +702,133 @@ public sealed class TornadoPlayoutEngineTests
|
||||
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()
|
||||
{
|
||||
@@ -374,6 +865,104 @@ public sealed class TornadoPlayoutEngineTests
|
||||
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,
|
||||
@@ -432,4 +1021,10 @@ public sealed class TornadoPlayoutEngineTests
|
||||
$"{sceneName}.t2s",
|
||||
sceneName,
|
||||
[new PlayoutField("headline", "fake", true)]);
|
||||
|
||||
private static TornadoProcessSnapshot ProcessSnapshot(string generation) =>
|
||||
new(1, 1, 0)
|
||||
{
|
||||
EligibleProcessGeneration = new TornadoProcessGeneration(generation)
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user