2579 lines
105 KiB
C#
2579 lines
105 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Runtime.InteropServices;
|
|
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
|
using MBN_STOCK_WEBVIEW.Playout.Interop;
|
|
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
|
|
|
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
|
|
|
public sealed class 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);
|
|
Assert.DoesNotContain("Dispose", session.Calls);
|
|
|
|
releaseActivation.Set();
|
|
Assert.True(SpinWait.SpinUntil(
|
|
() => session.Calls.Contains("Dispose"),
|
|
TimeSpan.FromSeconds(2)));
|
|
Assert.Equal(0, session.KtapDispatchCount);
|
|
Assert.Equal(new[] { "Connect", "Dispose" }, session.Calls);
|
|
Assert.Single(session.ThreadIds.Distinct());
|
|
Assert.All(
|
|
session.ApartmentStates,
|
|
state => Assert.Equal(ApartmentState.STA, state));
|
|
}
|
|
finally
|
|
{
|
|
releaseActivation.Set();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TestConnect_BlockingFactoryThenConnectTimeout_PreventsLateKtapDispatch()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
using var factoryStarted = new ManualResetEventSlim();
|
|
using var releaseFactory = new ManualResetEventSlim();
|
|
using var beforeKtapDispatch = new ManualResetEventSlim();
|
|
using var releaseConnect = new ManualResetEventSlim();
|
|
var session = new FakeK3dSession
|
|
{
|
|
BeforeKtapDispatchAction = () =>
|
|
{
|
|
beforeKtapDispatch.Set();
|
|
if (!releaseConnect.Wait(TimeSpan.FromSeconds(5)))
|
|
{
|
|
throw new TimeoutException("fake connect coordination timeout");
|
|
}
|
|
}
|
|
};
|
|
var sessionFactory = new FakeK3dSessionFactory(() =>
|
|
{
|
|
factoryStarted.Set();
|
|
if (!releaseFactory.Wait(TimeSpan.FromSeconds(5)))
|
|
{
|
|
throw new TimeoutException("fake factory coordination timeout");
|
|
}
|
|
|
|
return session;
|
|
});
|
|
var options = TestOptions(scenes.Path, "test-scene");
|
|
options.ConnectTimeoutMilliseconds = 100;
|
|
await using var engine = CreateEngine(
|
|
options,
|
|
sessionFactory,
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
|
liveAuthorized: false);
|
|
|
|
try
|
|
{
|
|
var connectTask = Task.Run(() => engine.ConnectAsync(CancellationToken.None));
|
|
Assert.True(factoryStarted.Wait(TimeSpan.FromSeconds(2)));
|
|
|
|
await Task.Delay(250);
|
|
Assert.False(connectTask.IsCompleted);
|
|
Assert.Equal(0, session.KtapDispatchCount);
|
|
|
|
releaseFactory.Set();
|
|
Assert.True(beforeKtapDispatch.Wait(TimeSpan.FromSeconds(2)));
|
|
var result = await connectTask;
|
|
|
|
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
|
|
Assert.Equal(PlayoutKtapConnectState.NotAttempted, engine.Status.LastKtapConnectState);
|
|
Assert.Equal(0, session.KtapDispatchCount);
|
|
Assert.DoesNotContain("Dispose", session.Calls);
|
|
|
|
releaseConnect.Set();
|
|
Assert.True(SpinWait.SpinUntil(
|
|
() => session.Calls.Contains("Dispose"),
|
|
TimeSpan.FromSeconds(2)));
|
|
Assert.Equal(0, session.KtapDispatchCount);
|
|
}
|
|
finally
|
|
{
|
|
releaseFactory.Set();
|
|
releaseConnect.Set();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuardedConnect_FinalTargetFailure_PreventsKtapAndAbandonsWithoutDisconnect()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var session = new FakeK3dSession();
|
|
await using var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "test-scene"),
|
|
new FakeK3dSessionFactory(() => session),
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
|
liveAuthorized: false,
|
|
finalConnectSafetyCheck: () => false,
|
|
abandonOnTargetMismatch: true);
|
|
|
|
var result = await engine.ConnectAsync(CancellationToken.None);
|
|
|
|
Assert.Equal(PlayoutResultCode.Failed, result.Code);
|
|
Assert.Equal(0, session.KtapDispatchCount);
|
|
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
|
|
Assert.DoesNotContain("Disconnect", session.Calls);
|
|
Assert.DoesNotContain("Dispose", session.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuardedConnect_KtapFailure_IsOutcomeUnknownAndNeverReconnectable()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var session = new FakeK3dSession
|
|
{
|
|
ConnectAction = _ => throw new COMException("fake ambiguous KTAP 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,
|
|
finalConnectSafetyCheck: () => true,
|
|
abandonOnTargetMismatch: true);
|
|
|
|
var result = await engine.ConnectAsync(CancellationToken.None);
|
|
|
|
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
|
|
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
|
|
Assert.False(engine.Status.IsCommandAvailable);
|
|
Assert.Equal(1, session.KtapDispatchCount);
|
|
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
|
|
Assert.DoesNotContain("Disconnect", session.Calls);
|
|
Assert.DoesNotContain("Dispose", session.Calls);
|
|
await engine.PollProcessOnceAsync(CancellationToken.None);
|
|
Assert.Equal(1, sessionFactory.CreateCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuardedConnect_CallbackCancellationAfterKtap_IsOutcomeUnknown()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var session = new FakeK3dSession
|
|
{
|
|
ConnectAction = _ => throw new OperationCanceledException(
|
|
"fake cancellation after KTAP dispatch")
|
|
};
|
|
await using var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "test-scene"),
|
|
new FakeK3dSessionFactory(() => session),
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
|
liveAuthorized: false,
|
|
finalConnectSafetyCheck: () => true,
|
|
abandonOnTargetMismatch: true);
|
|
|
|
var result = await engine.ConnectAsync(CancellationToken.None);
|
|
|
|
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
|
|
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
|
|
Assert.Equal(1, session.KtapDispatchCount);
|
|
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
|
|
Assert.DoesNotContain("Disconnect", session.Calls);
|
|
Assert.DoesNotContain("Dispose", session.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuardedConnect_CancelledBeforeSerialization_RemainsKnownAndDoesNotCreateSession()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var sessionFactory = new FakeK3dSessionFactory();
|
|
await using var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "test-scene"),
|
|
sessionFactory,
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
|
liveAuthorized: false,
|
|
finalConnectSafetyCheck: () => true,
|
|
abandonOnTargetMismatch: true);
|
|
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(PlayoutKtapConnectState.NotAttempted, engine.Status.LastKtapConnectState);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuardedConnect_SlowSafetyCheckPastTimeoutNeverDispatchesKtap()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
using var safetyStarted = new ManualResetEventSlim();
|
|
using var releaseSafety = new ManualResetEventSlim();
|
|
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,
|
|
finalConnectSafetyCheck: () =>
|
|
{
|
|
safetyStarted.Set();
|
|
Assert.True(releaseSafety.Wait(TimeSpan.FromSeconds(5)));
|
|
return true;
|
|
},
|
|
abandonOnTargetMismatch: true);
|
|
|
|
try
|
|
{
|
|
var connectTask = engine.ConnectAsync(CancellationToken.None);
|
|
Assert.True(safetyStarted.Wait(TimeSpan.FromSeconds(2)));
|
|
|
|
var result = await connectTask;
|
|
|
|
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
|
|
Assert.Equal(0, session.KtapDispatchCount);
|
|
releaseSafety.Set();
|
|
Assert.True(SpinWait.SpinUntil(
|
|
() => session.Calls.Contains("Abandon"),
|
|
TimeSpan.FromSeconds(2)));
|
|
Assert.Equal(0, session.KtapDispatchCount);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => engine.QuarantineAsync().AsTask());
|
|
Assert.DoesNotContain("Disconnect", session.Calls);
|
|
Assert.DoesNotContain("Dispose", session.Calls);
|
|
}
|
|
finally
|
|
{
|
|
releaseSafety.Set();
|
|
await engine.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuardedConnect_TimeoutWinningAtomicClaimPreventsKtapDispatch()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
using var claimStarted = new ManualResetEventSlim();
|
|
using var releaseClaim = new ManualResetEventSlim();
|
|
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,
|
|
finalConnectSafetyCheck: () => true,
|
|
abandonOnTargetMismatch: true,
|
|
beforeSdkDispatchClaim: () =>
|
|
{
|
|
claimStarted.Set();
|
|
Assert.True(releaseClaim.Wait(TimeSpan.FromSeconds(5)));
|
|
});
|
|
|
|
try
|
|
{
|
|
var connectTask = engine.ConnectAsync(CancellationToken.None);
|
|
Assert.True(claimStarted.Wait(TimeSpan.FromSeconds(2)));
|
|
|
|
var connect = await connectTask;
|
|
|
|
Assert.Equal(PlayoutResultCode.OutcomeUnknown, connect.Code);
|
|
Assert.Equal(0, session.KtapDispatchCount);
|
|
releaseClaim.Set();
|
|
Assert.True(SpinWait.SpinUntil(
|
|
() => session.Calls.Contains("Abandon"),
|
|
TimeSpan.FromSeconds(2)));
|
|
Assert.Equal(0, session.KtapDispatchCount);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => engine.QuarantineAsync().AsTask());
|
|
Assert.DoesNotContain("Disconnect", session.Calls);
|
|
Assert.DoesNotContain("Dispose", session.Calls);
|
|
}
|
|
finally
|
|
{
|
|
releaseClaim.Set();
|
|
await engine.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuardedConnect_PostGenerationChange_AbandonsWithoutDisconnect()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var original = ProcessSnapshot("pgm-generation-a");
|
|
var changed = ProcessSnapshot("pgm-generation-b");
|
|
var session = new FakeK3dSession();
|
|
var finalChecks = 0;
|
|
await using var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "test-scene"),
|
|
new FakeK3dSessionFactory(() => session),
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(original, changed),
|
|
liveAuthorized: false,
|
|
finalConnectSafetyCheck: () => Interlocked.Increment(ref finalChecks) == 1,
|
|
abandonOnTargetMismatch: true);
|
|
|
|
var result = await engine.ConnectAsync(CancellationToken.None);
|
|
|
|
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
|
|
Assert.Equal(1, finalChecks);
|
|
Assert.Equal(1, session.KtapDispatchCount);
|
|
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
|
|
Assert.DoesNotContain("Disconnect", session.Calls);
|
|
Assert.DoesNotContain("Dispose", session.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuardedTargetPolicy_PreCommandGenerationChange_AbandonsWithoutDisconnect()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var original = ProcessSnapshot("pgm-generation-a");
|
|
var changed = ProcessSnapshot("pgm-generation-b");
|
|
var session = new FakeK3dSession();
|
|
await using var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "test-scene"),
|
|
new FakeK3dSessionFactory(() => session),
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(original, original, changed),
|
|
liveAuthorized: false,
|
|
finalConnectSafetyCheck: () => true,
|
|
abandonOnTargetMismatch: true);
|
|
|
|
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(new[] { "Connect", "Abandon" }, session.Calls);
|
|
Assert.DoesNotContain("Disconnect", session.Calls);
|
|
Assert.DoesNotContain("Dispose", session.Calls);
|
|
Assert.DoesNotContain(session.Calls, call =>
|
|
call.StartsWith("Prepare:", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuardedTargetPolicy_AmbiguousPrepareFailure_AbandonsWithoutDisconnect()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var stable = ProcessSnapshot("pgm-generation-a");
|
|
var session = new FakeK3dSession
|
|
{
|
|
PrepareAction = (_, _) => throw new COMException("fake ambiguous prepare failure")
|
|
};
|
|
await using var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "test-scene"),
|
|
new FakeK3dSessionFactory(() => session),
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(stable),
|
|
liveAuthorized: false,
|
|
finalConnectSafetyCheck: () => true,
|
|
abandonOnTargetMismatch: true);
|
|
|
|
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(
|
|
new[] { "Connect", "Prepare:test-scene", "Abandon" },
|
|
session.Calls);
|
|
Assert.DoesNotContain("Disconnect", session.Calls);
|
|
Assert.DoesNotContain("Dispose", session.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuardedTargetPolicy_NextChecksTargetBetweenPrepareAndPlay()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create(
|
|
"test-scene.t2s",
|
|
"next-scene.t2s");
|
|
var stable = ProcessSnapshot("pgm-generation-a");
|
|
var session = new FakeK3dSession();
|
|
var safetyChecks = 0;
|
|
await using var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "test-scene", "next-scene"),
|
|
new FakeK3dSessionFactory(() => session),
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(stable),
|
|
liveAuthorized: false,
|
|
finalConnectSafetyCheck: () =>
|
|
Interlocked.Increment(ref safetyChecks) != 7,
|
|
abandonOnTargetMismatch: true);
|
|
|
|
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 next = await engine.NextAsync(
|
|
Cue("next-scene"),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(PlayoutResultCode.OutcomeUnknown, next.Code);
|
|
Assert.Equal(7, safetyChecks);
|
|
Assert.Equal(1, session.Calls.Count(call => call == "Play"));
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"Connect",
|
|
"Prepare:test-scene",
|
|
"Play",
|
|
"Prepare:next-scene",
|
|
"Abandon"
|
|
},
|
|
session.Calls);
|
|
Assert.DoesNotContain("Disconnect", session.Calls);
|
|
Assert.DoesNotContain("Dispose", session.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuardedTargetPolicy_DisconnectPrecheckFailureAbandonsWithoutDisconnect()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var stable = ProcessSnapshot("pgm-generation-a");
|
|
var session = new FakeK3dSession();
|
|
var safetyChecks = 0;
|
|
await using var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "test-scene"),
|
|
new FakeK3dSessionFactory(() => session),
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(stable),
|
|
liveAuthorized: false,
|
|
finalConnectSafetyCheck: () =>
|
|
Interlocked.Increment(ref safetyChecks) == 1,
|
|
abandonOnTargetMismatch: true);
|
|
|
|
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
|
var disconnect = await engine.DisconnectAsync(CancellationToken.None);
|
|
|
|
Assert.Equal(PlayoutResultCode.OutcomeUnknown, disconnect.Code);
|
|
Assert.Equal(2, safetyChecks);
|
|
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
|
|
Assert.DoesNotContain("Disconnect", session.Calls);
|
|
Assert.DoesNotContain("Dispose", session.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuardedTargetPolicy_SlowCommandSafetyCheckPastTimeoutNeverDispatchesSdkCall()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
using var safetyStarted = new ManualResetEventSlim();
|
|
using var releaseSafety = new ManualResetEventSlim();
|
|
var stable = ProcessSnapshot("pgm-generation-a");
|
|
var session = new FakeK3dSession();
|
|
var safetyChecks = 0;
|
|
var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "test-scene"),
|
|
new FakeK3dSessionFactory(() => session),
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(stable),
|
|
liveAuthorized: false,
|
|
finalConnectSafetyCheck: () =>
|
|
{
|
|
if (Interlocked.Increment(ref safetyChecks) == 2)
|
|
{
|
|
safetyStarted.Set();
|
|
Assert.True(releaseSafety.Wait(TimeSpan.FromSeconds(5)));
|
|
}
|
|
|
|
return true;
|
|
},
|
|
abandonOnTargetMismatch: true);
|
|
|
|
try
|
|
{
|
|
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
|
var prepareTask = engine.PrepareAsync(
|
|
Cue("test-scene"),
|
|
CancellationToken.None);
|
|
Assert.True(safetyStarted.Wait(TimeSpan.FromSeconds(2)));
|
|
|
|
var prepare = await prepareTask;
|
|
|
|
Assert.Equal(PlayoutResultCode.OutcomeUnknown, prepare.Code);
|
|
releaseSafety.Set();
|
|
Assert.True(SpinWait.SpinUntil(
|
|
() => session.Calls.Contains("Abandon"),
|
|
TimeSpan.FromSeconds(2)));
|
|
Assert.DoesNotContain(
|
|
session.Calls,
|
|
call => call.StartsWith("Prepare:", StringComparison.Ordinal));
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => engine.QuarantineAsync().AsTask());
|
|
Assert.DoesNotContain("Disconnect", session.Calls);
|
|
Assert.DoesNotContain("Dispose", session.Calls);
|
|
}
|
|
finally
|
|
{
|
|
releaseSafety.Set();
|
|
await engine.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuardedTargetPolicy_SlowDisconnectSafetyCheckPastTimeoutNeverDisconnects()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
using var safetyStarted = new ManualResetEventSlim();
|
|
using var releaseSafety = new ManualResetEventSlim();
|
|
var stable = ProcessSnapshot("pgm-generation-a");
|
|
var session = new FakeK3dSession();
|
|
var safetyChecks = 0;
|
|
var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "test-scene"),
|
|
new FakeK3dSessionFactory(() => session),
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(stable),
|
|
liveAuthorized: false,
|
|
finalConnectSafetyCheck: () =>
|
|
{
|
|
if (Interlocked.Increment(ref safetyChecks) == 2)
|
|
{
|
|
safetyStarted.Set();
|
|
Assert.True(releaseSafety.Wait(TimeSpan.FromSeconds(5)));
|
|
}
|
|
|
|
return true;
|
|
},
|
|
abandonOnTargetMismatch: true);
|
|
|
|
try
|
|
{
|
|
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
|
var disconnectTask = engine.DisconnectAsync(CancellationToken.None);
|
|
Assert.True(safetyStarted.Wait(TimeSpan.FromSeconds(2)));
|
|
|
|
var disconnect = await disconnectTask;
|
|
|
|
Assert.Equal(PlayoutResultCode.OutcomeUnknown, disconnect.Code);
|
|
releaseSafety.Set();
|
|
Assert.True(SpinWait.SpinUntil(
|
|
() => session.Calls.Contains("Abandon"),
|
|
TimeSpan.FromSeconds(2)));
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => engine.QuarantineAsync().AsTask());
|
|
Assert.DoesNotContain("Disconnect", session.Calls);
|
|
Assert.DoesNotContain("Dispose", session.Calls);
|
|
}
|
|
finally
|
|
{
|
|
releaseSafety.Set();
|
|
await engine.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuardedTargetPolicy_TimeoutWinningAtomicCommandClaimPreventsPrepare()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
using var claimStarted = new ManualResetEventSlim();
|
|
using var releaseClaim = new ManualResetEventSlim();
|
|
var session = new FakeK3dSession();
|
|
var claims = 0;
|
|
var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "test-scene"),
|
|
new FakeK3dSessionFactory(() => session),
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(ProcessSnapshot("pgm-generation-a")),
|
|
liveAuthorized: false,
|
|
finalConnectSafetyCheck: () => true,
|
|
abandonOnTargetMismatch: true,
|
|
beforeSdkDispatchClaim: () =>
|
|
{
|
|
if (Interlocked.Increment(ref claims) == 2)
|
|
{
|
|
claimStarted.Set();
|
|
Assert.True(releaseClaim.Wait(TimeSpan.FromSeconds(5)));
|
|
}
|
|
});
|
|
|
|
try
|
|
{
|
|
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
|
var prepareTask = engine.PrepareAsync(
|
|
Cue("test-scene"),
|
|
CancellationToken.None);
|
|
Assert.True(claimStarted.Wait(TimeSpan.FromSeconds(2)));
|
|
|
|
var prepare = await prepareTask;
|
|
|
|
Assert.Equal(PlayoutResultCode.OutcomeUnknown, prepare.Code);
|
|
releaseClaim.Set();
|
|
Assert.True(SpinWait.SpinUntil(
|
|
() => session.Calls.Contains("Abandon"),
|
|
TimeSpan.FromSeconds(2)));
|
|
Assert.DoesNotContain(
|
|
session.Calls,
|
|
call => call.StartsWith("Prepare:", StringComparison.Ordinal));
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => engine.QuarantineAsync().AsTask());
|
|
Assert.DoesNotContain("Disconnect", session.Calls);
|
|
Assert.DoesNotContain("Dispose", session.Calls);
|
|
}
|
|
finally
|
|
{
|
|
releaseClaim.Set();
|
|
await engine.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuardedTargetPolicy_NextUsesSeparateAtomicClaimsForPrepareAndPlay()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create(
|
|
"test-scene.t2s",
|
|
"next-scene.t2s");
|
|
using var playClaimStarted = new ManualResetEventSlim();
|
|
using var releasePlayClaim = new ManualResetEventSlim();
|
|
var session = new FakeK3dSession();
|
|
var claims = 0;
|
|
var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "test-scene", "next-scene"),
|
|
new FakeK3dSessionFactory(() => session),
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(ProcessSnapshot("pgm-generation-a")),
|
|
liveAuthorized: false,
|
|
finalConnectSafetyCheck: () => true,
|
|
abandonOnTargetMismatch: true,
|
|
beforeSdkDispatchClaim: () =>
|
|
{
|
|
if (Interlocked.Increment(ref claims) == 5)
|
|
{
|
|
playClaimStarted.Set();
|
|
Assert.True(releasePlayClaim.Wait(TimeSpan.FromSeconds(5)));
|
|
}
|
|
});
|
|
|
|
try
|
|
{
|
|
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 nextTask = engine.NextAsync(
|
|
Cue("next-scene"),
|
|
CancellationToken.None);
|
|
Assert.True(playClaimStarted.Wait(TimeSpan.FromSeconds(2)));
|
|
|
|
var next = await nextTask;
|
|
|
|
Assert.Equal(PlayoutResultCode.OutcomeUnknown, next.Code);
|
|
Assert.Contains("Prepare:next-scene", session.Calls);
|
|
Assert.Equal(1, session.Calls.Count(call => call == "Play"));
|
|
releasePlayClaim.Set();
|
|
Assert.True(SpinWait.SpinUntil(
|
|
() => session.Calls.Contains("Abandon"),
|
|
TimeSpan.FromSeconds(2)));
|
|
Assert.Equal(1, session.Calls.Count(call => call == "Play"));
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => engine.QuarantineAsync().AsTask());
|
|
Assert.DoesNotContain("Disconnect", session.Calls);
|
|
Assert.DoesNotContain("Dispose", session.Calls);
|
|
}
|
|
finally
|
|
{
|
|
releasePlayClaim.Set();
|
|
await engine.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuardedTargetPolicy_TimeoutWinningAtomicDisconnectClaimPreventsDisconnect()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
using var claimStarted = new ManualResetEventSlim();
|
|
using var releaseClaim = new ManualResetEventSlim();
|
|
var session = new FakeK3dSession();
|
|
var claims = 0;
|
|
var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "test-scene"),
|
|
new FakeK3dSessionFactory(() => session),
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(ProcessSnapshot("pgm-generation-a")),
|
|
liveAuthorized: false,
|
|
finalConnectSafetyCheck: () => true,
|
|
abandonOnTargetMismatch: true,
|
|
beforeSdkDispatchClaim: () =>
|
|
{
|
|
if (Interlocked.Increment(ref claims) == 2)
|
|
{
|
|
claimStarted.Set();
|
|
Assert.True(releaseClaim.Wait(TimeSpan.FromSeconds(5)));
|
|
}
|
|
});
|
|
|
|
try
|
|
{
|
|
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
|
var disconnectTask = engine.DisconnectAsync(CancellationToken.None);
|
|
Assert.True(claimStarted.Wait(TimeSpan.FromSeconds(2)));
|
|
|
|
var disconnect = await disconnectTask;
|
|
|
|
Assert.Equal(PlayoutResultCode.OutcomeUnknown, disconnect.Code);
|
|
releaseClaim.Set();
|
|
Assert.True(SpinWait.SpinUntil(
|
|
() => session.Calls.Contains("Abandon"),
|
|
TimeSpan.FromSeconds(2)));
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => engine.QuarantineAsync().AsTask());
|
|
Assert.DoesNotContain("Disconnect", session.Calls);
|
|
Assert.DoesNotContain("Dispose", session.Calls);
|
|
}
|
|
finally
|
|
{
|
|
releaseClaim.Set();
|
|
await engine.DisposeAsync();
|
|
}
|
|
}
|
|
|
|
[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 UpdateOnAir_TestMode_UsesSerializedSessionPathAndKeepsSceneOnAir()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var session = new FakeK3dSession();
|
|
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 result = await engine.UpdateOnAirAsync(
|
|
Cue("test-scene") with
|
|
{
|
|
Fields = [new PlayoutField("headline", "page two", true)]
|
|
},
|
|
CancellationToken.None);
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(PlayoutOperation.UpdateOnAir, result.Operation);
|
|
Assert.False(result.IsDryRun);
|
|
Assert.Equal("test-scene", engine.Status.OnAirSceneName);
|
|
Assert.Null(engine.Status.PreparedSceneName);
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"Connect",
|
|
"Prepare:test-scene",
|
|
"Play",
|
|
"UpdateOnAir:test-scene"
|
|
},
|
|
session.Calls);
|
|
Assert.Single(session.ThreadIds.Distinct());
|
|
Assert.All(session.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateOnAir_RejectsIdleOrDifferentSceneBeforeSessionDispatch()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("first.t2s", "other.t2s");
|
|
var session = new FakeK3dSession();
|
|
await using var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "first", "other"),
|
|
new FakeK3dSessionFactory(() => session),
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
|
liveAuthorized: false);
|
|
|
|
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
|
var idle = await engine.UpdateOnAirAsync(Cue("first"), CancellationToken.None);
|
|
Assert.True((await engine.PrepareAsync(Cue("first"), CancellationToken.None)).IsSuccess);
|
|
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
|
|
var different = await engine.UpdateOnAirAsync(
|
|
Cue("other"),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(PlayoutResultCode.Rejected, idle.Code);
|
|
Assert.Equal(PlayoutResultCode.Rejected, different.Code);
|
|
Assert.Equal("first", engine.Status.OnAirSceneName);
|
|
Assert.DoesNotContain(session.Calls, call =>
|
|
call.StartsWith("UpdateOnAir:", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateOnAir_AmbiguousFailureLatchesOutcomeUnknownAndIsNeverRetried()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var session = new FakeK3dSession
|
|
{
|
|
UpdateOnAirAction = (_, _) =>
|
|
throw new COMException("fake ambiguous on-air update 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 failed = await engine.UpdateOnAirAsync(
|
|
Cue("test-scene"),
|
|
CancellationToken.None);
|
|
var later = await engine.UpdateOnAirAsync(
|
|
Cue("test-scene"),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(PlayoutResultCode.OutcomeUnknown, failed.Code);
|
|
Assert.Equal(PlayoutResultCode.OutcomeUnknown, later.Code);
|
|
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
|
|
Assert.Equal(
|
|
1,
|
|
session.Calls.Count(call => call == "UpdateOnAir:test-scene"));
|
|
Assert.Contains("Abandon", session.Calls);
|
|
Assert.DoesNotContain("Disconnect", session.Calls);
|
|
Assert.DoesNotContain("Dispose", session.Calls);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(100)]
|
|
[InlineData(837)]
|
|
[InlineData(300_000)]
|
|
public async Task Status_ExposesValidatedOperationTimeoutMilliseconds(
|
|
int operationTimeoutMilliseconds)
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var options = TestOptions(scenes.Path, "test-scene");
|
|
options.OperationTimeoutMilliseconds = operationTimeoutMilliseconds;
|
|
await using var engine = CreateEngine(
|
|
options,
|
|
new FakeK3dSessionFactory(),
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(ProcessSnapshot("eligible-generation-a")),
|
|
liveAuthorized: false);
|
|
|
|
Assert.Equal(operationTimeoutMilliseconds, 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.UpdateOnAirAsync(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);
|
|
var update = await engine.UpdateOnAirAsync(
|
|
Cue("live-scene"),
|
|
CancellationToken.None);
|
|
|
|
Assert.True(connect.IsSuccess);
|
|
Assert.True(prepare.IsSuccess);
|
|
Assert.True(takeIn.IsSuccess);
|
|
Assert.True(update.IsSuccess);
|
|
Assert.True(engine.Status.LiveTakeInAllowed);
|
|
Assert.Contains("Play", session.Calls);
|
|
Assert.Contains("UpdateOnAir:live-scene", session.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LiveWithBothAuthorizations_RejectsSceneOutsideClosedAllowlist()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create(
|
|
"live-scene.t2s",
|
|
"blocked-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);
|
|
|
|
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
|
|
|
var prepare = await engine.PrepareAsync(
|
|
Cue("blocked-scene"),
|
|
CancellationToken.None);
|
|
var next = await engine.NextAsync(
|
|
Cue("blocked-scene"),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(PlayoutResultCode.Rejected, prepare.Code);
|
|
Assert.Equal(PlayoutResultCode.Rejected, next.Code);
|
|
Assert.DoesNotContain(
|
|
session.Calls,
|
|
call => call.Contains("blocked-scene", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateOnAir_LiveMode_RechecksAuthorizationBeforeSessionDispatch()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("live-scene.t2s");
|
|
var session = new FakeK3dSession();
|
|
var authorization = new FakeLiveAuthorization(true);
|
|
await using var engine = CreateEngine(
|
|
LiveOptions(scenes.Path, trustedLiveOutputEnabled: true),
|
|
new FakeK3dSessionFactory(() => session),
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
|
liveAuthorized: true,
|
|
liveAuthorization: authorization);
|
|
|
|
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
|
Assert.True((await engine.PrepareAsync(
|
|
Cue("live-scene"),
|
|
CancellationToken.None)).IsSuccess);
|
|
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
|
|
authorization.IsAuthorizedForThisLaunch = false;
|
|
|
|
var result = await engine.UpdateOnAirAsync(
|
|
Cue("live-scene"),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(PlayoutResultCode.Rejected, result.Code);
|
|
Assert.DoesNotContain("UpdateOnAir:live-scene", session.Calls);
|
|
Assert.Equal("live-scene", engine.Status.OnAirSceneName);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Quarantine_WhenLocalAbandonFails_ReportsFailureAndNeverDisconnectsOrDisposes()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var session = new FakeK3dSession
|
|
{
|
|
AbandonAction = () => throw new InvalidOperationException(
|
|
"fake local abandon 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);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => engine.QuarantineAsync().AsTask());
|
|
|
|
Assert.Equal(new[] { "Connect", "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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProcessMonitor_DrainsLifecycleCallbacksOnTheStaAndPublishesHello()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var session = new FakeK3dSession
|
|
{
|
|
SupportsLifecycleCallbacks = true,
|
|
KtapHelloObserved = false,
|
|
HasPendingLifecycleCallbacks = true,
|
|
ProcessPendingCallbacksAction = _ => new K3dCallbackDrainResult(2, 1, true)
|
|
};
|
|
var sessionFactory = new FakeK3dSessionFactory(() => session);
|
|
var process = new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0));
|
|
await using var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "test-scene"),
|
|
sessionFactory,
|
|
new FakeRegistrationProbe(),
|
|
process,
|
|
liveAuthorized: false);
|
|
|
|
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
|
Assert.False(engine.Status.KtapHelloObserved);
|
|
session.KtapHelloObserved = true;
|
|
|
|
await engine.PollProcessOnceAsync(CancellationToken.None);
|
|
|
|
Assert.True(engine.Status.KtapHelloObserved);
|
|
Assert.Contains("ProcessPendingCallbacks", session.Calls);
|
|
Assert.All(session.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
|
|
Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Disconnect_DoesNotDispatchWhileTakeOutCompletionCallbackIsPending()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var session = new FakeK3dSession
|
|
{
|
|
SupportsLifecycleCallbacks = true,
|
|
ClearPendingLifecycleCallbacksAfterProcess = false
|
|
};
|
|
session.TakeOutAction = (_, _) => session.HasPendingLifecycleCallbacks = true;
|
|
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);
|
|
Assert.True((await engine.TakeOutAsync(
|
|
PlayoutTakeOutScope.All,
|
|
CancellationToken.None)).IsSuccess);
|
|
|
|
var disconnect = await engine.DisconnectAsync(CancellationToken.None);
|
|
|
|
Assert.Equal(PlayoutResultCode.Unavailable, disconnect.Code);
|
|
Assert.Contains("ProcessPendingCallbacks", session.Calls);
|
|
Assert.DoesNotContain("Disconnect", session.Calls);
|
|
Assert.DoesNotContain("Abandon", session.Calls);
|
|
Assert.True(session.IsConnected);
|
|
|
|
session.HasPendingLifecycleCallbacks = false;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Dispose_AbandonsWithoutDisconnectWhenTakeOutCallbackNeverArrives()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var session = new FakeK3dSession
|
|
{
|
|
SupportsLifecycleCallbacks = true,
|
|
ClearPendingLifecycleCallbacksAfterProcess = false
|
|
};
|
|
session.TakeOutAction = (_, _) => session.HasPendingLifecycleCallbacks = true;
|
|
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);
|
|
Assert.True((await engine.TakeOutAsync(
|
|
PlayoutTakeOutScope.All,
|
|
CancellationToken.None)).IsSuccess);
|
|
|
|
await engine.DisposeAsync();
|
|
|
|
Assert.Contains("ProcessPendingCallbacks", session.Calls);
|
|
Assert.Contains("Abandon", session.Calls);
|
|
Assert.DoesNotContain("Disconnect", session.Calls);
|
|
Assert.DoesNotContain("Dispose", session.Calls);
|
|
Assert.Equal(PlayoutConnectionState.Disposed, engine.Status.State);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LifecycleCallbackFailure_LatchesOutcomeUnknownWithoutDisconnectOrReplay()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var session = new FakeK3dSession
|
|
{
|
|
SupportsLifecycleCallbacks = true,
|
|
HasPendingLifecycleCallbacks = true,
|
|
ProcessPendingCallbacksAction = _ =>
|
|
throw new InvalidOperationException("fake callback failure")
|
|
};
|
|
var sessionFactory = new FakeK3dSessionFactory(() => session);
|
|
var process = new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0));
|
|
await using var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "test-scene"),
|
|
sessionFactory,
|
|
new FakeRegistrationProbe(),
|
|
process,
|
|
liveAuthorized: false);
|
|
|
|
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
|
await engine.PollProcessOnceAsync(CancellationToken.None);
|
|
|
|
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
|
|
Assert.Contains("ProcessPendingCallbacks", session.Calls);
|
|
Assert.Contains("Abandon", session.Calls);
|
|
Assert.DoesNotContain("Disconnect", session.Calls);
|
|
Assert.DoesNotContain("Dispose", session.Calls);
|
|
var later = await engine.PrepareAsync(Cue("test-scene"), CancellationToken.None);
|
|
Assert.Equal(PlayoutResultCode.OutcomeUnknown, later.Code);
|
|
Assert.Equal(1, session.Calls.Count(call => call == "ProcessPendingCallbacks"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PlayCompletionPending_StatusClearsOnlyAfterCallbackDrain()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
|
var callbackReady = false;
|
|
var session = new FakeK3dSession
|
|
{
|
|
SupportsLifecycleCallbacks = true,
|
|
ClearPendingLifecycleCallbacksAfterProcess = false
|
|
};
|
|
session.PlayAction = _ =>
|
|
{
|
|
session.HasPendingPlayCallbacks = true;
|
|
session.HasPendingLifecycleCallbacks = true;
|
|
};
|
|
session.ProcessPendingCallbacksAction = _ =>
|
|
{
|
|
if (!callbackReady)
|
|
{
|
|
return new K3dCallbackDrainResult(0, 0, false);
|
|
}
|
|
|
|
session.HasPendingPlayCallbacks = false;
|
|
session.HasPendingLifecycleCallbacks = false;
|
|
return new K3dCallbackDrainResult(1, 1, false);
|
|
};
|
|
await using var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "5001"),
|
|
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("5001"),
|
|
CancellationToken.None)).IsSuccess);
|
|
var observedPendingStates = new List<bool>();
|
|
engine.StatusChanged += (_, args) =>
|
|
observedPendingStates.Add(args.Current.IsPlayCompletionPending);
|
|
|
|
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
|
|
await engine.PollProcessOnceAsync(CancellationToken.None);
|
|
|
|
Assert.True(engine.Status.IsPlayCompletionPending);
|
|
Assert.Equal([true, true], observedPendingStates);
|
|
|
|
callbackReady = true;
|
|
await engine.PollProcessOnceAsync(CancellationToken.None);
|
|
|
|
Assert.False(engine.Status.IsPlayCompletionPending);
|
|
Assert.Equal([true, true, false], observedPendingStates);
|
|
Assert.True((await engine.TakeOutAsync(
|
|
PlayoutTakeOutScope.All,
|
|
CancellationToken.None)).IsSuccess);
|
|
Assert.True((await engine.DisconnectAsync(CancellationToken.None)).IsSuccess);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PendingPlayCallback_BlocksDelayedMultiNextUntilEachCompletionIsDrained()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create(
|
|
"5001.t2s",
|
|
"5006.t2s",
|
|
"5011.t2s");
|
|
var callbackReady = false;
|
|
var session = new FakeK3dSession
|
|
{
|
|
SupportsLifecycleCallbacks = true,
|
|
ClearPendingLifecycleCallbacksAfterProcess = false
|
|
};
|
|
session.PlayAction = _ =>
|
|
{
|
|
session.HasPendingPlayCallbacks = true;
|
|
session.HasPendingLifecycleCallbacks = true;
|
|
};
|
|
session.ProcessPendingCallbacksAction = _ =>
|
|
{
|
|
if (!callbackReady)
|
|
{
|
|
return new K3dCallbackDrainResult(0, 0, false);
|
|
}
|
|
|
|
callbackReady = false;
|
|
session.HasPendingPlayCallbacks = false;
|
|
session.HasPendingLifecycleCallbacks = false;
|
|
return new K3dCallbackDrainResult(1, 1, false);
|
|
};
|
|
await using var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "5001", "5006", "5011"),
|
|
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("5001"),
|
|
CancellationToken.None)).IsSuccess);
|
|
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
|
|
Assert.True(session.HasPendingPlayCallbacks);
|
|
Assert.True(engine.Status.IsPlayCompletionPending);
|
|
|
|
var firstEarlyNext = await engine.NextAsync(
|
|
Cue("5006"),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(PlayoutResultCode.Unavailable, firstEarlyNext.Code);
|
|
Assert.Contains("completion callback", firstEarlyNext.Message, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("Prepare:5006", session.Calls);
|
|
|
|
callbackReady = true;
|
|
Assert.True((await engine.NextAsync(
|
|
Cue("5006"),
|
|
CancellationToken.None)).IsSuccess);
|
|
Assert.True(session.HasPendingPlayCallbacks);
|
|
Assert.Equal(1, session.Calls.Count(call => call == "Prepare:5006"));
|
|
|
|
var secondEarlyNext = await engine.NextAsync(
|
|
Cue("5011"),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(PlayoutResultCode.Unavailable, secondEarlyNext.Code);
|
|
Assert.DoesNotContain("Prepare:5011", session.Calls);
|
|
|
|
callbackReady = true;
|
|
Assert.True((await engine.NextAsync(
|
|
Cue("5011"),
|
|
CancellationToken.None)).IsSuccess);
|
|
Assert.Equal(1, session.Calls.Count(call => call == "Prepare:5011"));
|
|
|
|
callbackReady = true;
|
|
await engine.PollProcessOnceAsync(CancellationToken.None);
|
|
Assert.False(session.HasPendingPlayCallbacks);
|
|
Assert.False(engine.Status.IsPlayCompletionPending);
|
|
Assert.True((await engine.TakeOutAsync(
|
|
PlayoutTakeOutScope.All,
|
|
CancellationToken.None)).IsSuccess);
|
|
Assert.True((await engine.DisconnectAsync(CancellationToken.None)).IsSuccess);
|
|
Assert.Contains("Disconnect", session.Calls);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("prepare")]
|
|
[InlineData("take-in")]
|
|
[InlineData("next")]
|
|
[InlineData("update-on-air")]
|
|
public async Task PendingPlayCallback_RejectsEveryNonTakeOutSceneMutation(string operation)
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
|
|
var session = new FakeK3dSession
|
|
{
|
|
SupportsLifecycleCallbacks = true,
|
|
ClearPendingLifecycleCallbacksAfterProcess = false
|
|
};
|
|
session.PlayAction = _ =>
|
|
{
|
|
session.HasPendingPlayCallbacks = true;
|
|
session.HasPendingLifecycleCallbacks = true;
|
|
};
|
|
await using var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "5001", "5006"),
|
|
new FakeK3dSessionFactory(() => session),
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
|
liveAuthorized: false);
|
|
|
|
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
|
if (operation != "prepare")
|
|
{
|
|
Assert.True((await engine.PrepareAsync(
|
|
Cue("5001"),
|
|
CancellationToken.None)).IsSuccess);
|
|
}
|
|
|
|
if (operation is "next" or "update-on-air")
|
|
{
|
|
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
|
|
}
|
|
else
|
|
{
|
|
session.HasPendingPlayCallbacks = true;
|
|
session.HasPendingLifecycleCallbacks = true;
|
|
}
|
|
|
|
var result = operation switch
|
|
{
|
|
"prepare" => await engine.PrepareAsync(
|
|
Cue("5006"),
|
|
CancellationToken.None),
|
|
"take-in" => await engine.TakeInAsync(CancellationToken.None),
|
|
"next" => await engine.NextAsync(
|
|
Cue("5006"),
|
|
CancellationToken.None),
|
|
"update-on-air" => await engine.UpdateOnAirAsync(
|
|
Cue("5001"),
|
|
CancellationToken.None),
|
|
_ => throw new InvalidOperationException("Unknown test operation.")
|
|
};
|
|
|
|
Assert.Equal(PlayoutResultCode.Unavailable, result.Code);
|
|
Assert.Contains("completion callback", result.Message, StringComparison.Ordinal);
|
|
if (operation == "prepare")
|
|
{
|
|
Assert.DoesNotContain("Prepare:5006", session.Calls);
|
|
}
|
|
else if (operation == "take-in")
|
|
{
|
|
Assert.DoesNotContain("Play", session.Calls);
|
|
}
|
|
else if (operation == "next")
|
|
{
|
|
Assert.DoesNotContain("Prepare:5006", session.Calls);
|
|
}
|
|
else
|
|
{
|
|
Assert.DoesNotContain("UpdateOnAir:5001", session.Calls);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PendingPlayCallback_DoesNotBlockEmergencyTakeOut()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
|
var session = new FakeK3dSession
|
|
{
|
|
SupportsLifecycleCallbacks = true,
|
|
ClearPendingLifecycleCallbacksAfterProcess = false
|
|
};
|
|
session.PlayAction = _ =>
|
|
{
|
|
session.HasPendingPlayCallbacks = true;
|
|
session.HasPendingLifecycleCallbacks = true;
|
|
};
|
|
await using var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "5001"),
|
|
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("5001"),
|
|
CancellationToken.None)).IsSuccess);
|
|
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
|
|
Assert.True(session.HasPendingPlayCallbacks);
|
|
Assert.True(engine.Status.IsPlayCompletionPending);
|
|
|
|
var result = await engine.TakeOutAsync(
|
|
PlayoutTakeOutScope.All,
|
|
CancellationToken.None);
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Contains("TakeOut:All", session.Calls);
|
|
Assert.True(engine.Status.IsPlayCompletionPending);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(PlayoutTakeOutScope.All)]
|
|
[InlineData(PlayoutTakeOutScope.Layout)]
|
|
public async Task TakeOutCompletion_ClearsInterruptedPlayAndAllowsDisconnect(
|
|
PlayoutTakeOutScope scope)
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
|
var takeOutCompletionReady = false;
|
|
var session = new FakeK3dSession
|
|
{
|
|
SupportsLifecycleCallbacks = true,
|
|
ClearPendingLifecycleCallbacksAfterProcess = false
|
|
};
|
|
session.PlayAction = _ =>
|
|
{
|
|
session.HasPendingPlayCallbacks = true;
|
|
session.HasPendingLifecycleCallbacks = true;
|
|
};
|
|
session.TakeOutAction = (_, _) => session.HasPendingLifecycleCallbacks = true;
|
|
session.ProcessPendingCallbacksAction = _ =>
|
|
{
|
|
if (!takeOutCompletionReady)
|
|
{
|
|
return new K3dCallbackDrainResult(0, 0, false);
|
|
}
|
|
|
|
session.HasPendingPlayCallbacks = false;
|
|
session.HasPendingLifecycleCallbacks = false;
|
|
return new K3dCallbackDrainResult(1, 1, false);
|
|
};
|
|
await using var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "5001"),
|
|
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("5001"),
|
|
CancellationToken.None)).IsSuccess);
|
|
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
|
|
Assert.True(session.HasPendingPlayCallbacks);
|
|
Assert.True((await engine.TakeOutAsync(scope, CancellationToken.None)).IsSuccess);
|
|
|
|
takeOutCompletionReady = true;
|
|
var disconnect = await engine.DisconnectAsync(CancellationToken.None);
|
|
|
|
Assert.True(disconnect.IsSuccess);
|
|
Assert.False(session.HasPendingPlayCallbacks);
|
|
Assert.Contains($"TakeOut:{scope}", session.Calls);
|
|
Assert.Contains("ProcessPendingCallbacks", session.Calls);
|
|
Assert.Contains("Disconnect", session.Calls);
|
|
}
|
|
|
|
private static TornadoPlayoutEngine CreateEngine(
|
|
PlayoutOptions rawOptions,
|
|
FakeK3dSessionFactory sessionFactory,
|
|
FakeRegistrationProbe registrationProbe,
|
|
FakeTornadoProcessProbe processProbe,
|
|
bool liveAuthorized,
|
|
Func<bool>? finalConnectSafetyCheck = null,
|
|
bool abandonOnTargetMismatch = false,
|
|
Action? beforeSdkDispatchClaim = null,
|
|
FakeLiveAuthorization? liveAuthorization = null)
|
|
{
|
|
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,
|
|
liveAuthorization ?? new FakeLiveAuthorization(liveAuthorized),
|
|
TimeProvider.System,
|
|
startMonitor: false,
|
|
finalConnectSafetyCheck: finalConnectSafetyCheck,
|
|
abandonOnTargetMismatch: abandonOnTargetMismatch,
|
|
beforeSdkDispatchClaim: beforeSdkDispatchClaim);
|
|
}
|
|
|
|
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,
|
|
TestSceneAllowlist = ["live-scene"],
|
|
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)
|
|
};
|
|
}
|