436 lines
18 KiB
C#
436 lines
18 KiB
C#
using System.Runtime.InteropServices;
|
|
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
|
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
|
|
|
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
|
|
|
public sealed class TornadoPlayoutEngineTests
|
|
{
|
|
[Theory]
|
|
[InlineData(0, false)]
|
|
[InlineData(1, true)]
|
|
public async Task DefaultDryRun_MonitorsProcessWithoutCreatingComSession(
|
|
int totalProcessCount,
|
|
bool expectedRunning)
|
|
{
|
|
var sessionFactory = new FakeK3dSessionFactory();
|
|
var registration = new FakeRegistrationProbe();
|
|
var process = new FakeTornadoProcessProbe(
|
|
new TornadoProcessSnapshot(totalProcessCount, totalProcessCount, 0));
|
|
await using var engine = CreateEngine(
|
|
new PlayoutOptions(),
|
|
sessionFactory,
|
|
registration,
|
|
process,
|
|
liveAuthorized: false);
|
|
|
|
await engine.PollProcessOnceAsync(CancellationToken.None);
|
|
var connect = await engine.ConnectAsync(CancellationToken.None);
|
|
var prepare = await engine.PrepareAsync(
|
|
new PlayoutCue("never-resolved.t2s", "dry-run-scene"),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(PlayoutMode.DryRun, engine.Status.Mode);
|
|
Assert.Equal(expectedRunning, engine.Status.IsProcessRunning);
|
|
Assert.Equal(PlayoutResultCode.Success, connect.Code);
|
|
Assert.Equal(PlayoutResultCode.Success, prepare.Code);
|
|
Assert.Equal(0, sessionFactory.CreateCount);
|
|
Assert.Equal(0, registration.ProbeCount);
|
|
}
|
|
|
|
[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.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 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 TestSceneOutsideAllowlist_IsRejectedBeforeSessionCall()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("allowed.t2s", "blocked.t2s");
|
|
var session = new FakeK3dSession();
|
|
var sessionFactory = new FakeK3dSessionFactory(() => session);
|
|
await using var engine = CreateEngine(
|
|
TestOptions(scenes.Path, "allowed"),
|
|
sessionFactory,
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
|
liveAuthorized: false);
|
|
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
|
|
|
var prepare = await engine.PrepareAsync(Cue("blocked"), CancellationToken.None);
|
|
var next = await engine.NextAsync(Cue("blocked"), CancellationToken.None);
|
|
|
|
Assert.Equal(PlayoutResultCode.Rejected, prepare.Code);
|
|
Assert.Equal(PlayoutResultCode.Rejected, next.Code);
|
|
Assert.DoesNotContain(session.Calls, call => call.StartsWith("Prepare:", StringComparison.Ordinal));
|
|
Assert.DoesNotContain("blocked.t2s", prepare.Message, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain(scenes.Path, prepare.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false, false)]
|
|
[InlineData(false, true)]
|
|
[InlineData(true, false)]
|
|
public async Task LiveWithoutBothAuthorizations_RejectsCommandsBeforeCom(
|
|
bool trustedLiveOutputEnabled,
|
|
bool launchAuthorized)
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("live-scene.t2s");
|
|
var sessionFactory = new FakeK3dSessionFactory();
|
|
var registration = new FakeRegistrationProbe();
|
|
await using var engine = CreateEngine(
|
|
LiveOptions(scenes.Path, trustedLiveOutputEnabled),
|
|
sessionFactory,
|
|
registration,
|
|
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
|
launchAuthorized);
|
|
|
|
var results = new[]
|
|
{
|
|
await engine.ConnectAsync(CancellationToken.None),
|
|
await engine.PrepareAsync(Cue("live-scene"), CancellationToken.None),
|
|
await engine.TakeInAsync(CancellationToken.None),
|
|
await engine.NextAsync(Cue("live-scene"), CancellationToken.None),
|
|
await engine.TakeOutAsync(PlayoutTakeOutScope.All, CancellationToken.None)
|
|
};
|
|
|
|
Assert.All(results, result => Assert.Equal(PlayoutResultCode.Rejected, result.Code));
|
|
Assert.Equal(0, sessionFactory.CreateCount);
|
|
Assert.Equal(0, registration.ProbeCount);
|
|
Assert.False(engine.Status.LiveTakeInAllowed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LiveWithBothAuthorizations_CanUseFakeSessionOnly()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("live-scene.t2s");
|
|
var session = new FakeK3dSession();
|
|
await using var engine = CreateEngine(
|
|
LiveOptions(scenes.Path, trustedLiveOutputEnabled: true),
|
|
new FakeK3dSessionFactory(() => session),
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
|
liveAuthorized: true);
|
|
|
|
var connect = await engine.ConnectAsync(CancellationToken.None);
|
|
var prepare = await engine.PrepareAsync(Cue("live-scene"), CancellationToken.None);
|
|
var takeIn = await engine.TakeInAsync(CancellationToken.None);
|
|
|
|
Assert.True(connect.IsSuccess);
|
|
Assert.True(prepare.IsSuccess);
|
|
Assert.True(takeIn.IsSuccess);
|
|
Assert.True(engine.Status.LiveTakeInAllowed);
|
|
Assert.Contains("Play", session.Calls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TimedOutTakeIn_QuarantinesEngineAndNeverReconnectsOrReplays()
|
|
{
|
|
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;
|
|
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);
|
|
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();
|
|
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(session.Calls, call => call.StartsWith("TakeOut:", StringComparison.Ordinal));
|
|
}
|
|
finally
|
|
{
|
|
releasePlay.Set();
|
|
}
|
|
}
|
|
|
|
[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);
|
|
|
|
Assert.Equal(1, sessionFactory.CreateCount);
|
|
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
|
|
Assert.Equal(PlayoutResultCode.OutcomeUnknown, laterCommand.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 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);
|
|
}
|
|
|
|
private static TornadoPlayoutEngine CreateEngine(
|
|
PlayoutOptions rawOptions,
|
|
FakeK3dSessionFactory sessionFactory,
|
|
FakeRegistrationProbe registrationProbe,
|
|
FakeTornadoProcessProbe processProbe,
|
|
bool liveAuthorized)
|
|
{
|
|
var options = ValidatedPlayoutOptions.Create(rawOptions);
|
|
IStaDispatcher? dispatcher = options.Mode is PlayoutMode.Test or PlayoutMode.Live
|
|
? new StaDispatcher(options.QueueCapacity)
|
|
: null;
|
|
return new TornadoPlayoutEngine(
|
|
options,
|
|
sessionFactory,
|
|
registrationProbe,
|
|
processProbe,
|
|
dispatcher,
|
|
new FakeLiveAuthorization(liveAuthorized),
|
|
TimeProvider.System,
|
|
startMonitor: false);
|
|
}
|
|
|
|
private static PlayoutOptions TestOptions(
|
|
string sceneDirectory,
|
|
params string[] allowedScenes) => new()
|
|
{
|
|
Mode = PlayoutMode.Test,
|
|
SceneDirectory = sceneDirectory,
|
|
OutputChannel = 9,
|
|
TestProcessWindowTitlePattern = "^Tornado2 TEST$",
|
|
TestSceneAllowlist = [.. allowedScenes],
|
|
ConnectTimeoutMilliseconds = 500,
|
|
OperationTimeoutMilliseconds = 500,
|
|
DisconnectTimeoutMilliseconds = 500,
|
|
ProcessPollIntervalMilliseconds = 100,
|
|
ReconnectDelayMilliseconds = 0,
|
|
MaximumReconnectAttempts = 3
|
|
};
|
|
|
|
private static PlayoutOptions LiveOptions(
|
|
string sceneDirectory,
|
|
bool trustedLiveOutputEnabled) => new()
|
|
{
|
|
Mode = PlayoutMode.Live,
|
|
SceneDirectory = sceneDirectory,
|
|
TrustedLiveOutputEnabled = trustedLiveOutputEnabled,
|
|
ConnectTimeoutMilliseconds = 500,
|
|
OperationTimeoutMilliseconds = 500,
|
|
DisconnectTimeoutMilliseconds = 500,
|
|
ProcessPollIntervalMilliseconds = 100,
|
|
ReconnectDelayMilliseconds = 0,
|
|
MaximumReconnectAttempts = 3
|
|
};
|
|
|
|
private static PlayoutCue Cue(string sceneName) => new(
|
|
$"{sceneName}.t2s",
|
|
sceneName,
|
|
[new PlayoutField("headline", "fake", true)]);
|
|
}
|