feat: harden isolated Tornado test workflow

This commit is contained in:
2026-07-10 11:27:40 +09:00
parent 5a8c7028dc
commit fc932b27f6
36 changed files with 3919 additions and 226 deletions

View File

@@ -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()
{

View File

@@ -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

View File

@@ -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));
}
}
}

View File

@@ -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) =>

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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()
{

View File

@@ -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();

View 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
]);
}

View File

@@ -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)
};
}