Files
MBN_STOCK_WEBVIEW/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs

1207 lines
49 KiB
C#

using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class TornadoPlayoutEngineTests
{
[Theory]
[InlineData(0, false)]
[InlineData(1, true)]
public async Task DefaultDryRun_MonitorsProcessWithoutCreatingComSession(
int totalProcessCount,
bool expectedRunning)
{
var sessionFactory = new FakeK3dSessionFactory();
var registration = new FakeRegistrationProbe();
var process = new FakeTornadoProcessProbe(
new TornadoProcessSnapshot(totalProcessCount, totalProcessCount, 0));
await using var engine = CreateEngine(
new PlayoutOptions(),
sessionFactory,
registration,
process,
liveAuthorized: false);
await engine.PollProcessOnceAsync(CancellationToken.None);
var connect = await engine.ConnectAsync(CancellationToken.None);
var prepare = await engine.PrepareAsync(
new PlayoutCue("never-resolved.t2s", "dry-run-scene"),
CancellationToken.None);
Assert.Equal(PlayoutMode.DryRun, engine.Status.Mode);
Assert.Equal(expectedRunning, engine.Status.IsProcessRunning);
Assert.Equal(PlayoutResultCode.Success, connect.Code);
Assert.Equal(PlayoutResultCode.Success, prepare.Code);
Assert.Equal(0, sessionFactory.CreateCount);
Assert.Equal(0, registration.ProbeCount);
Assert.Equal(
PlayoutKtapConnectState.NotAttempted,
engine.Status.LastKtapConnectState);
Assert.False(engine.Status.NetworkMonitoringRecordExpected);
Assert.False(engine.Status.NetworkMonitoringCheckRequired);
Assert.Contains("Network Monitoring", engine.Status.Message, StringComparison.Ordinal);
}
[Fact]
public async Task CancelledCommandBeforeSerialization_DoesNotProbeOrCreateSession()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var sessionFactory = new FakeK3dSessionFactory();
var registration = new FakeRegistrationProbe();
var process = new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0));
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
sessionFactory,
registration,
process,
liveAuthorized: false);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var result = await engine.ConnectAsync(cancellation.Token);
Assert.Equal(PlayoutResultCode.Cancelled, result.Code);
Assert.Equal(0, sessionFactory.CreateCount);
Assert.Equal(0, registration.ProbeCount);
Assert.Equal(0, process.CaptureCount);
}
[Theory]
[InlineData(0, 0, 0, false, "찾을 수 없습니다")]
[InlineData(2, 1, 0, false, "여러 개")]
[InlineData(1, 0, 1, false, "PROGRAM")]
[InlineData(1, 1, 0, true, null)]
public async Task TestConnect_RequiresExactlyOneEligibleNonProgramInstance(
int totalProcessCount,
int eligibleProcessCount,
int programProcessCount,
bool expectedSuccess,
string? expectedFailureText)
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var sessionFactory = new FakeK3dSessionFactory();
var registration = new FakeRegistrationProbe();
var process = new FakeTornadoProcessProbe(new TornadoProcessSnapshot(
totalProcessCount,
eligibleProcessCount,
programProcessCount));
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
sessionFactory,
registration,
process,
liveAuthorized: false);
var result = await engine.ConnectAsync(CancellationToken.None);
Assert.Equal(expectedSuccess, result.IsSuccess);
Assert.Equal(expectedSuccess ? 1 : 0, sessionFactory.CreateCount);
Assert.Equal(expectedSuccess ? 1 : 0, registration.ProbeCount);
Assert.Equal(
expectedSuccess
? PlayoutKtapConnectState.AcceptedUnconfirmed
: PlayoutKtapConnectState.NotAttempted,
engine.Status.LastKtapConnectState);
Assert.Equal(expectedSuccess, engine.Status.NetworkMonitoringRecordExpected);
Assert.Equal(expectedSuccess, engine.Status.NetworkMonitoringCheckRequired);
Assert.NotNull(process.LastPattern);
if (expectedSuccess)
{
Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State);
Assert.True(engine.Status.LiveTakeInAllowed);
}
else
{
Assert.Equal(PlayoutResultCode.Unavailable, result.Code);
Assert.Contains(expectedFailureText!, result.Message, StringComparison.OrdinalIgnoreCase);
Assert.False(engine.Status.LiveTakeInAllowed);
}
}
[Fact]
public async Task TestConnect_WhenKtapCallFails_ReportsAttemptedFailureEvidence()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var session = new FakeK3dSession
{
ConnectAction = _ => throw new InvalidOperationException("fake KTAP 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);
var result = await engine.ConnectAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.Failed, result.Code);
Assert.Equal(
PlayoutKtapConnectState.Failed,
engine.Status.LastKtapConnectState);
Assert.True(engine.Status.KtapConnectAttempted);
Assert.Null(engine.Status.KtapConnectAccepted);
Assert.Null(engine.Status.NetworkMonitoringRecordExpected);
Assert.True(engine.Status.NetworkMonitoringCheckRequired);
Assert.Null(engine.Status.KtapHelloObserved);
}
[Fact]
public async Task TestConnect_WhenKtapCallTimesOut_PreservesAttemptedUnknownEvidence()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
using var connectStarted = new ManualResetEventSlim();
using var releaseConnect = new ManualResetEventSlim();
var session = new FakeK3dSession
{
ConnectAction = _ =>
{
connectStarted.Set();
if (!releaseConnect.Wait(TimeSpan.FromSeconds(5)))
{
throw new TimeoutException("fake test coordination timeout");
}
}
};
var options = TestOptions(scenes.Path, "test-scene");
options.ConnectTimeoutMilliseconds = 100;
await using var engine = CreateEngine(
options,
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
try
{
var connectTask = engine.ConnectAsync(CancellationToken.None);
Assert.True(connectStarted.Wait(TimeSpan.FromSeconds(2)));
var result = await connectTask;
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
Assert.Equal(
PlayoutKtapConnectState.Attempted,
engine.Status.LastKtapConnectState);
Assert.True(engine.Status.KtapConnectAttempted);
Assert.Null(engine.Status.KtapConnectAccepted);
Assert.Null(engine.Status.NetworkMonitoringRecordExpected);
Assert.True(engine.Status.NetworkMonitoringCheckRequired);
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
}
finally
{
releaseConnect.Set();
}
}
[Fact]
public async Task TestConnect_TimeoutDuringActivation_PreventsLateKtapDispatch()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
using var activationStarted = new ManualResetEventSlim();
using var releaseActivation = new ManualResetEventSlim();
var session = new FakeK3dSession
{
BeforeKtapDispatchAction = () =>
{
activationStarted.Set();
if (!releaseActivation.Wait(TimeSpan.FromSeconds(5)))
{
throw new TimeoutException("fake activation coordination timeout");
}
}
};
var options = TestOptions(scenes.Path, "test-scene");
options.ConnectTimeoutMilliseconds = 100;
await using var engine = CreateEngine(
options,
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
try
{
var connectTask = engine.ConnectAsync(CancellationToken.None);
Assert.True(activationStarted.Wait(TimeSpan.FromSeconds(2)));
var result = await connectTask;
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
Assert.Equal(
PlayoutKtapConnectState.NotAttempted,
engine.Status.LastKtapConnectState);
Assert.False(engine.Status.KtapConnectAttempted);
Assert.False(engine.Status.NetworkMonitoringRecordExpected);
Assert.False(engine.Status.NetworkMonitoringCheckRequired);
Assert.Equal(0, session.KtapDispatchCount);
releaseActivation.Set();
Assert.True(SpinWait.SpinUntil(
() => session.Calls.Contains("Dispose"),
TimeSpan.FromSeconds(2)));
Assert.Equal(0, session.KtapDispatchCount);
}
finally
{
releaseActivation.Set();
}
}
[Fact]
public async Task LastKtapEvidence_SurvivesLaterFailureBeforeAnotherDispatch()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var session = new FakeK3dSession();
var createCount = 0;
var sessionFactory = new FakeK3dSessionFactory(() =>
Interlocked.Increment(ref createCount) == 1
? session
: throw new InvalidOperationException("fake activation-stage failure"));
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);
Assert.True((await engine.DisconnectAsync(CancellationToken.None)).IsSuccess);
var secondConnect = await engine.ConnectAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.Failed, secondConnect.Code);
Assert.Equal(PlayoutConnectionState.Faulted, engine.Status.State);
Assert.Equal(
PlayoutKtapConnectState.AcceptedUnconfirmed,
engine.Status.LastKtapConnectState);
Assert.True(engine.Status.NetworkMonitoringRecordExpected);
Assert.True(engine.Status.NetworkMonitoringCheckRequired);
Assert.Equal(2, sessionFactory.CreateCount);
}
[Fact]
public async Task TestWorkflow_ExecutesFakeSessionInCommandOrderOnSta()
{
using var scenes = TemporarySceneDirectory.Create("first.t2s", "next.t2s");
var session = new FakeK3dSession();
var sessionFactory = new FakeK3dSessionFactory(() => session);
var process = new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0));
await using var engine = CreateEngine(
TestOptions(scenes.Path, "first", "next"),
sessionFactory,
new FakeRegistrationProbe(),
process,
liveAuthorized: false);
var connect = await engine.ConnectAsync(CancellationToken.None);
Assert.True(engine.Status.LiveTakeInAllowed);
var prepare = await engine.PrepareAsync(Cue("first"), CancellationToken.None);
var takeIn = await engine.TakeInAsync(CancellationToken.None);
var next = await engine.NextAsync(Cue("next"), CancellationToken.None);
var takeOut = await engine.TakeOutAsync(
PlayoutTakeOutScope.All,
CancellationToken.None);
var disconnect = await engine.DisconnectAsync(CancellationToken.None);
Assert.All(
new[] { connect, prepare, takeIn, next, takeOut, disconnect },
result => Assert.Equal(PlayoutResultCode.Success, result.Code));
Assert.Equal(
new[]
{
"Connect",
"Prepare:first",
"Play",
"Prepare:next",
"Play",
"TakeOut:All",
"Disconnect",
"Dispose"
},
session.Calls);
Assert.Single(session.ThreadIds.Distinct());
Assert.All(session.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
Assert.Equal(PlayoutConnectionState.Disconnected, engine.Status.State);
Assert.False(engine.Status.LiveTakeInAllowed);
Assert.Null(engine.Status.PreparedSceneName);
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()
{
using var scenes = TemporarySceneDirectory.Create("allowed.t2s", "blocked.t2s");
var session = new FakeK3dSession();
var sessionFactory = new FakeK3dSessionFactory(() => session);
await using var engine = CreateEngine(
TestOptions(scenes.Path, "allowed"),
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("blocked"), CancellationToken.None);
var next = await engine.NextAsync(Cue("blocked"), CancellationToken.None);
Assert.Equal(PlayoutResultCode.Rejected, prepare.Code);
Assert.Equal(PlayoutResultCode.Rejected, next.Code);
Assert.DoesNotContain(session.Calls, call => call.StartsWith("Prepare:", StringComparison.Ordinal));
Assert.DoesNotContain("blocked.t2s", prepare.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(scenes.Path, prepare.Message, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
public async Task LiveWithoutBothAuthorizations_RejectsCommandsBeforeCom(
bool trustedLiveOutputEnabled,
bool launchAuthorized)
{
using var scenes = TemporarySceneDirectory.Create("live-scene.t2s");
var sessionFactory = new FakeK3dSessionFactory();
var registration = new FakeRegistrationProbe();
await using var engine = CreateEngine(
LiveOptions(scenes.Path, trustedLiveOutputEnabled),
sessionFactory,
registration,
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
launchAuthorized);
var results = new[]
{
await engine.ConnectAsync(CancellationToken.None),
await engine.PrepareAsync(Cue("live-scene"), CancellationToken.None),
await engine.TakeInAsync(CancellationToken.None),
await engine.NextAsync(Cue("live-scene"), CancellationToken.None),
await engine.TakeOutAsync(PlayoutTakeOutScope.All, CancellationToken.None)
};
Assert.All(results, result => Assert.Equal(PlayoutResultCode.Rejected, result.Code));
Assert.Equal(0, sessionFactory.CreateCount);
Assert.Equal(0, registration.ProbeCount);
Assert.False(engine.Status.LiveTakeInAllowed);
}
[Fact]
public async Task LiveWithBothAuthorizations_CanUseFakeSessionOnly()
{
using var scenes = TemporarySceneDirectory.Create("live-scene.t2s");
var session = new FakeK3dSession();
await using var engine = CreateEngine(
LiveOptions(scenes.Path, trustedLiveOutputEnabled: true),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: true);
var connect = await engine.ConnectAsync(CancellationToken.None);
var prepare = await engine.PrepareAsync(Cue("live-scene"), CancellationToken.None);
var takeIn = await engine.TakeInAsync(CancellationToken.None);
Assert.True(connect.IsSuccess);
Assert.True(prepare.IsSuccess);
Assert.True(takeIn.IsSuccess);
Assert.True(engine.Status.LiveTakeInAllowed);
Assert.Contains("Play", session.Calls);
}
[Fact]
public async Task TimedOutTakeIn_WithProcessGenerationChange_NeverReconnectsOrReplays()
{
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 sessionFactory = new FakeK3dSessionFactory(() => session);
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(
originalProcess,
originalProcess,
originalProcess,
originalProcess,
restartedProcess),
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 takeIn = await takeInTask;
Assert.Equal(PlayoutResultCode.OutcomeUnknown, takeIn.Code);
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
Assert.False(engine.Status.IsCommandAvailable);
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,
CancellationToken.None);
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
{
releasePlay.Set();
}
}
[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()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var secretPath = System.IO.Path.Combine(scenes.Path, "test-scene.t2s");
var first = new FakeK3dSession
{
PlayAction = _ => throw new COMException(
$"vendor HRESULT 0x80004005 at {secretPath}",
unchecked((int)0x80004005))
};
var second = new FakeK3dSession();
var created = 0;
var sessionFactory = new FakeK3dSessionFactory(
() => Interlocked.Increment(ref created) == 1 ? first : second);
var options = TestOptions(scenes.Path, "test-scene");
options.ReconnectDelayMilliseconds = 0;
await using var engine = CreateEngine(
options,
sessionFactory,
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);
var takeIn = await engine.TakeInAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, takeIn.Code);
Assert.DoesNotContain("0x80004005", takeIn.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(secretPath, takeIn.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("0x80004005", engine.Status.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(secretPath, engine.Status.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(1, first.Calls.Count(call => call == "Play"));
await engine.PollProcessOnceAsync(CancellationToken.None);
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()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var first = new FakeK3dSession
{
PrepareAction = (_, _) => throw new InvalidOperationException("fake prepare failure")
};
var second = new FakeK3dSession();
var created = 0;
var sessionFactory = new FakeK3dSessionFactory(
() => Interlocked.Increment(ref created) == 1 ? first : second);
var options = TestOptions(scenes.Path, "test-scene");
options.ReconnectDelayMilliseconds = 0;
await using var engine = CreateEngine(
options,
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.Failed, prepare.Code);
Assert.Equal(1, first.Calls.Count(call => call == "Prepare:test-scene"));
Assert.Equal(PlayoutConnectionState.Reconnecting, engine.Status.State);
await engine.PollProcessOnceAsync(CancellationToken.None);
Assert.Equal(2, sessionFactory.CreateCount);
Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State);
Assert.DoesNotContain(second.Calls, call => call.StartsWith("Prepare:", StringComparison.Ordinal));
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,
FakeRegistrationProbe registrationProbe,
FakeTornadoProcessProbe processProbe,
bool liveAuthorized)
{
var options = ValidatedPlayoutOptions.Create(rawOptions);
IStaDispatcher? dispatcher = options.Mode is PlayoutMode.Test or PlayoutMode.Live
? new StaDispatcher(options.QueueCapacity)
: null;
return new TornadoPlayoutEngine(
options,
sessionFactory,
registrationProbe,
processProbe,
dispatcher,
new FakeLiveAuthorization(liveAuthorized),
TimeProvider.System,
startMonitor: false);
}
private static PlayoutOptions TestOptions(
string sceneDirectory,
params string[] allowedScenes) => new()
{
Mode = PlayoutMode.Test,
SceneDirectory = sceneDirectory,
OutputChannel = 9,
TestProcessWindowTitlePattern = "^Tornado2 TEST$",
TestSceneAllowlist = [.. allowedScenes],
ConnectTimeoutMilliseconds = 500,
OperationTimeoutMilliseconds = 500,
DisconnectTimeoutMilliseconds = 500,
ProcessPollIntervalMilliseconds = 100,
ReconnectDelayMilliseconds = 0,
MaximumReconnectAttempts = 3
};
private static PlayoutOptions LiveOptions(
string sceneDirectory,
bool trustedLiveOutputEnabled) => new()
{
Mode = PlayoutMode.Live,
SceneDirectory = sceneDirectory,
TrustedLiveOutputEnabled = trustedLiveOutputEnabled,
ConnectTimeoutMilliseconds = 500,
OperationTimeoutMilliseconds = 500,
DisconnectTimeoutMilliseconds = 500,
ProcessPollIntervalMilliseconds = 100,
ReconnectDelayMilliseconds = 0,
MaximumReconnectAttempts = 3
};
private static PlayoutCue Cue(string sceneName) => new(
$"{sceneName}.t2s",
sceneName,
[new PlayoutField("headline", "fake", true)]);
private static TornadoProcessSnapshot ProcessSnapshot(string generation) =>
new(1, 1, 0)
{
EligibleProcessGeneration = new TornadoProcessGeneration(generation)
};
}