feat: add audited one-shot prepare gate

This commit is contained in:
2026-07-18 12:29:09 +09:00
parent 1302b1b92f
commit 5379511b8a
25 changed files with 6188 additions and 41 deletions

View File

@@ -4,6 +4,7 @@ using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
using MBN_STOCK_WEBVIEW.Playout.Interop;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
using MBN_STOCK_WEBVIEW.Playout.Safety;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
@@ -1678,6 +1679,128 @@ public sealed class DynamicK3dSessionTests
Assert.Equal(1, log.Names.Count(name => name == "Release:EventHandler"));
}
[Theory]
[InlineData("mutation")]
[InlineData("query")]
[InlineData("end")]
public async Task GateA_PrepareStageFailureNeverRollsBackDisconnectsOrRetries(
string failureStage)
{
const string capability =
"0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var rawOptions = new PlayoutOptions
{
Mode = PlayoutMode.Live,
Host = "127.0.0.1",
Port = 30001,
TcpMode = 1,
LayoutIndex = 10,
SceneDirectory = scenes.Path,
TestSceneAllowlist = ["5001"],
TrustedLiveOutputEnabled = true,
ReconnectEnabled = false,
MaximumReconnectAttempts = 0,
MaximumAutomaticRefreshesPerTakeIn = 0,
LegacySceneFadeDuration = 6,
ConnectTimeoutMilliseconds = 500,
OperationTimeoutMilliseconds = 500,
DisconnectTimeoutMilliseconds = 500,
ProcessPollIntervalMilliseconds = 100
};
var options = ValidatedPlayoutOptions.Create(rawOptions);
var failure = new COMException($"fake Gate A {failureStage} failure");
var log = new FakeComLog();
var scene = new FakeScene(log, "5001");
var player = new FakePlayer(log);
var vendorEngine = new FakeEngine(log, player, scene);
switch (failureStage)
{
case "mutation":
scene.GetObjectFailure = failure;
break;
case "query":
scene.QueryVariablesFailure = failure;
break;
case "end":
vendorEngine.EndTransactionFailure = failure;
break;
default:
throw new InvalidOperationException("Unknown test failure stage.");
}
var activator = new FakeActivator(log, vendorEngine);
var releaser = new FakeReleaser(
log,
(scene, "5001"),
(player, "Player"),
(vendorEngine, "Engine"),
(activator.EventHandler, "EventHandler"));
var sessionFactory = new DelegateK3dSessionFactory(() =>
new DynamicK3dSession(
activator,
releaser,
new InstalledK3dInteropMethodInvoker(),
new ActivatorK3dEventHandlerFactory(activator),
rollbackOnPrepareFailure: false));
using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
capability);
var dispatcher = new StaDispatcher(options.QueueCapacity);
var engine = new TornadoPlayoutEngine(
options,
sessionFactory,
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(
ProcessSnapshot("approved-pgm"),
ProcessSnapshot("approved-pgm")),
dispatcher,
authorization,
TimeProvider.System,
startMonitor: false,
finalConnectSafetyCheck: () => true,
abandonOnTargetMismatch: true,
beforeSdkDispatchClaim: authorization.EnsureGateASdkDispatchAuthorized,
launchAuthorization: authorization);
try
{
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.True(authorization.TryClaimGateAPrepare(capability));
var cue = new PlayoutCue(
"5001.t2s",
"5001",
[new PlayoutField("headline", "test", true)],
FadeDuration: 6,
Mutations: [new PlayoutUseBackground(false)]);
var result = await engine.PrepareAsync(cue, CancellationToken.None);
var retry = await engine.PrepareAsync(cue, CancellationToken.None);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, retry.Code);
}
finally
{
authorization.CompleteGateAPrepare();
await engine.DisposeAsync();
}
Assert.Equal(1, log.Names.Count(name => name == "BeginTransaction"));
Assert.DoesNotContain("RollbackTransaction", log.Names);
Assert.DoesNotContain("Disconnect", log.Names);
Assert.DoesNotContain("Prepare:10", log.Names);
Assert.Equal(1, log.Names.Count(name => name == "Release:5001"));
Assert.Equal(1, log.Names.Count(name => name == "Release:Player"));
Assert.Equal(1, log.Names.Count(name => name == "Release:Engine"));
Assert.Equal(1, log.Names.Count(name => name == "Release:EventHandler"));
}
private static TornadoProcessSnapshot ProcessSnapshot(string generation) =>
new(1, 1, 0)
{
EligibleProcessGeneration = new TornadoProcessGeneration(generation)
};
private static PlayoutOptions TestOptions(string sceneDirectory) => new()
{
Mode = PlayoutMode.Test,
@@ -1715,6 +1838,12 @@ public sealed class DynamicK3dSessionTests
name.StartsWith("Unload:", StringComparison.Ordinal) ||
name.StartsWith("Release:500", StringComparison.Ordinal)).ToArray();
private sealed class DelegateK3dSessionFactory(Func<IK3dSession> create)
: IK3dSessionFactory
{
public IK3dSession Create() => create();
}
public sealed class FakeComLog
{
private readonly ConcurrentQueue<string> _names = new();
@@ -1865,6 +1994,8 @@ public sealed class DynamicK3dSessionTests
public Func<string, FakeScene>? SceneResolver { get; init; }
public Exception? EndTransactionFailure { get; set; }
public int KTAPConnect(
int tcpMode,
string host,
@@ -1913,10 +2044,23 @@ public sealed class DynamicK3dSessionTests
public void BeginTransaction() => _log.Add("BeginTransaction");
public void EndTransaction() => _log.Add("EndTransaction");
public void EndTransaction()
{
_log.Add("EndTransaction");
if (EndTransactionFailure is not null)
{
throw EndTransactionFailure;
}
}
public void EndTransactionOnChannel(int channel) =>
public void EndTransactionOnChannel(int channel)
{
_log.Add($"EndTransactionOnChannel:{channel}");
if (EndTransactionFailure is not null)
{
throw EndTransactionFailure;
}
}
public void RollbackTransaction() => _log.Add("RollbackTransaction");
@@ -2026,6 +2170,11 @@ public sealed class DynamicK3dSessionTests
public object GetObject(string objectName)
{
_log.Add($"GetObject:{objectName}");
if (GetObjectFailure is not null)
{
throw GetObjectFailure;
}
return new FakeSceneObject(_log, objectName);
}
@@ -2044,6 +2193,8 @@ public sealed class DynamicK3dSessionTests
set => _queryVariablesFailure = value;
}
public Exception? GetObjectFailure { get; set; }
public void Unload()
{
_log.Add(_name is null ? "Unload" : $"Unload:{_name}");

View File

@@ -0,0 +1,747 @@
using System.Security.Cryptography;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.Playout.Safety;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class PlayoutLaunchAuthorizationTests
{
private const string Capability =
"0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
[Fact]
public void Missing_gate_environment_preserves_the_standard_launch_profile()
{
using var environment = ClearGateEnvironment();
var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment();
Assert.False(authorization.IsGateA);
Assert.True(authorization.AllowsDatabaseWrites);
Assert.True(authorization.AllowsCompositionMutation);
Assert.False(authorization.TryClaimGateAPrepare(Capability));
}
[Fact]
public void Non_gate_capture_preserves_existing_live_authorization_semantics()
{
using var environment = ClearGateEnvironment()
.Set(
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
using var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment();
Assert.False(authorization.IsGateA);
Assert.True(authorization.IsAuthorizedForThisLaunch);
Assert.Equal(
PlayoutOptionsLoader.RequiredLiveAuthorizationValue,
Environment.GetEnvironmentVariable(
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable));
}
[Fact]
public void Gate_capture_consumes_static_live_bearer_and_second_capture_fails_closed()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
var clock = new ManualGateTimeProvider();
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
using var environment = GateEnvironment(
Capability,
"41308",
"638878000000000000",
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture))
.Set(
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
using var first = PlayoutLaunchAuthorization.CaptureFromEnvironment(
clock,
scenePath,
SceneHash(scenePath));
PlayoutLaunchAuthorization? second = null;
Assert.Throws<PlayoutConfigurationException>(() =>
second = PlayoutLaunchAuthorization.CaptureFromEnvironment());
Assert.True(first.IsGateA);
Assert.True(first.IsAuthorizedForThisLaunch);
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable));
Assert.Null(second);
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable));
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable));
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable));
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable));
}
[Fact]
public void Gate_environment_is_captured_once_and_cleared_before_browser_startup()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
var clock = new ManualGateTimeProvider();
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
using var environment = GateEnvironment(
Capability,
"41308",
"638878000000000000",
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture));
using var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment(
clock,
scenePath,
SceneHash(scenePath));
Assert.True(authorization.IsGateA);
Assert.Equal(41308, authorization.ExpectedPgmProcessId);
Assert.Equal(638878000000000000, authorization.ExpectedPgmStartTimeUtcTicks);
Assert.Equal(expiry, authorization.ExpiresAtUtcTicks);
Assert.False(authorization.AllowsDatabaseWrites);
Assert.False(authorization.AllowsCompositionMutation);
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable));
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable));
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable));
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable));
environment.Set(
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable,
new string('F', 64));
Assert.True(authorization.TryClaimGateAPrepare(Capability));
Assert.False(authorization.TryClaimGateAPrepare(new string('F', 64)));
}
[Theory]
[InlineData("lowercase0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab")]
[InlineData("ABCDEF")]
[InlineData(null)]
public void Invalid_or_partial_gate_environment_fails_closed_and_is_cleared(
string? capability)
{
using var environment = ClearGateEnvironment()
.Set(
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable,
capability)
.Set(
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable,
"41308");
Assert.Throws<PlayoutConfigurationException>(
PlayoutLaunchAuthorization.CaptureFromEnvironment);
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable));
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable));
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable));
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable));
}
[Fact]
public void Malformed_first_capture_permanently_blocks_reinjection_and_clears_every_bearer()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
var clock = new ManualGateTimeProvider();
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
using var environment = ClearGateEnvironment()
.Set(
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable,
"ABCDEF")
.Set(
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable,
"41308")
.Set(
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
Assert.Throws<PlayoutConfigurationException>(() =>
PlayoutLaunchAuthorization.CaptureFromEnvironment(
clock,
scenePath,
SceneHash(scenePath)));
environment
.Set(
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable,
Capability)
.Set(
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable,
"41308")
.Set(
PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable,
"638878000000000000")
.Set(
PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable,
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture))
.Set(
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
PlayoutLaunchAuthorization? second = null;
Assert.Throws<PlayoutConfigurationException>(() =>
second = PlayoutLaunchAuthorization.CaptureFromEnvironment(
clock,
scenePath,
SceneHash(scenePath)));
Assert.Null(second);
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable));
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable));
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable));
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable));
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable));
}
[Fact]
public async Task Concurrent_gate_captures_have_one_success_and_all_later_calls_fail_closed()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
var clock = new ManualGateTimeProvider();
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
using var environment = GateEnvironment(
Capability,
"41308",
"638878000000000000",
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture))
.Set(
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
using var start = new ManualResetEventSlim();
var tasks = Enumerable.Range(0, 8)
.Select(_ => Task.Run(() =>
{
start.Wait();
try
{
return (
Authorization: PlayoutLaunchAuthorization.CaptureFromEnvironment(
clock,
scenePath,
SceneHash(scenePath)),
Error: (Exception?)null);
}
catch (Exception exception)
{
return (Authorization: (PlayoutLaunchAuthorization?)null, Error: exception);
}
}))
.ToArray();
start.Set();
var results = await Task.WhenAll(tasks);
var successful = Assert.Single(results, static result => result.Authorization is not null);
Assert.True(successful.Authorization!.IsGateA);
foreach (var failed in results.Where(static result => result.Authorization is null))
{
Assert.IsType<PlayoutConfigurationException>(failed.Error);
}
successful.Authorization.Dispose();
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable));
}
[Theory]
[InlineData(0L)]
[InlineData(-1L)]
[InlineData(9000000001L)]
public void Expired_or_overlong_gate_environment_fails_closed_and_clears_every_value(
long expiryOffsetTicks)
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
var clock = new ManualGateTimeProvider();
var expiry = clock.GetUtcNow().UtcTicks + expiryOffsetTicks;
using var environment = GateEnvironment(
Capability,
"41308",
"638878000000000000",
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture));
Assert.Throws<PlayoutConfigurationException>(() =>
PlayoutLaunchAuthorization.CaptureFromEnvironment(
clock,
scenePath,
SceneHash(scenePath)));
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable));
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable));
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable));
Assert.Null(Environment.GetEnvironmentVariable(
PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable));
}
[Fact]
public void Gate_scene_wrong_hash_fails_closed_and_does_not_leave_a_lease()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
var clock = new ManualGateTimeProvider();
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
using var environment = GateEnvironment(
Capability,
"41308",
"638878000000000000",
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture));
Assert.Throws<PlayoutConfigurationException>(() =>
PlayoutLaunchAuthorization.CaptureFromEnvironment(
clock,
scenePath,
new string('A', 64)));
using var write = File.Open(
scenePath,
FileMode.Open,
FileAccess.Write,
FileShare.ReadWrite | FileShare.Delete);
Assert.True(write.CanWrite);
}
[Fact]
public void Gate_scene_native_read_lease_blocks_write_and_delete_until_native_shutdown()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
var clock = new ManualGateTimeProvider();
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
using var environment = GateEnvironment(
Capability,
"41308",
"638878000000000000",
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture));
var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment(
clock,
scenePath,
SceneHash(scenePath));
using (var secondReader = File.Open(
scenePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read))
{
Assert.True(secondReader.CanRead);
}
Assert.ThrowsAny<IOException>(() => File.Open(
scenePath,
FileMode.Open,
FileAccess.Write,
FileShare.ReadWrite | FileShare.Delete));
Assert.ThrowsAny<IOException>(() => File.Delete(scenePath));
authorization.CompleteGateAPrepare();
Assert.ThrowsAny<IOException>(() => File.Delete(scenePath));
authorization.Dispose();
File.Delete(scenePath);
Assert.False(File.Exists(scenePath));
}
[Fact]
public void Native_expiry_at_operator_claim_is_terminal_and_cannot_be_rearmed()
{
var clock = new ManualGateTimeProvider();
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks;
using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
Capability,
timeProvider: clock,
expiresAtUtcTicks: expiry);
clock.Advance(TimeSpan.FromMinutes(1));
Assert.False(authorization.TryClaimGateAPrepare(Capability));
clock.SetUtcNow(clock.GetUtcNow().AddHours(-1));
Assert.False(authorization.TryClaimGateAPrepare(Capability));
Assert.False(authorization.TryClaimEngineCommand(
PlayoutOperation.Connect,
cue: null));
}
[Fact]
public void Expiry_between_operator_claim_and_engine_prepare_blocks_vendor_boundary()
{
var clock = new ManualGateTimeProvider();
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks;
using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
Capability,
timeProvider: clock,
expiresAtUtcTicks: expiry);
clock.Advance(TimeSpan.FromMinutes(1) - TimeSpan.FromTicks(1));
Assert.True(authorization.TryClaimGateAPrepare(Capability));
clock.Advance(TimeSpan.FromTicks(1));
Assert.False(authorization.TryClaimEngineCommand(
PlayoutOperation.Prepare,
GateACue()));
Assert.False(authorization.TryClaimEngineCommand(
PlayoutOperation.Prepare,
GateACue()));
}
[Fact]
public void Connect_just_before_expiry_does_not_extend_later_prepare_authority()
{
var clock = new ManualGateTimeProvider();
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks;
using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
Capability,
timeProvider: clock,
expiresAtUtcTicks: expiry);
clock.Advance(TimeSpan.FromMinutes(1) - TimeSpan.FromTicks(1));
Assert.True(authorization.TryClaimEngineCommand(
PlayoutOperation.Connect,
cue: null));
clock.Advance(TimeSpan.FromTicks(1));
Assert.False(authorization.TryClaimGateAPrepare(Capability));
Assert.False(authorization.TryClaimEngineCommand(
PlayoutOperation.Prepare,
GateACue()));
}
[Fact]
public void Connect_claim_uses_monotonic_deadline_so_utc_rollback_cannot_extend_gate()
{
var clock = new ManualGateTimeProvider();
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks;
using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
Capability,
timeProvider: clock,
expiresAtUtcTicks: expiry);
clock.SetUtcNow(clock.GetUtcNow().AddHours(-1));
clock.AdvanceMonotonic(TimeSpan.FromMinutes(1));
Assert.False(authorization.TryClaimEngineCommand(
PlayoutOperation.Connect,
cue: null));
Assert.False(authorization.TryClaimGateAPrepare(Capability));
}
[Fact]
public void Capability_and_engine_prepare_are_each_one_shot_and_never_rearm()
{
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
var cue = GateACue();
Assert.False(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue));
Assert.False(authorization.TryClaimGateAPrepare(new string('F', 64)));
Assert.True(authorization.TryClaimGateAPrepare(Capability));
Assert.False(authorization.TryClaimGateAPrepare(Capability));
Assert.True(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue));
Assert.False(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue));
authorization.CompleteGateAPrepare();
Assert.False(authorization.TryClaimGateAPrepare(Capability));
Assert.False(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue));
}
[Fact]
public void Wrong_prepare_target_consumes_the_engine_phase_and_blocks_correction()
{
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
Assert.True(authorization.TryClaimGateAPrepare(Capability));
Assert.False(authorization.TryClaimEngineCommand(
PlayoutOperation.Prepare,
GateACue() with { SceneName = "5006", SceneFile = "5006.t2s" }));
Assert.False(authorization.TryClaimEngineCommand(
PlayoutOperation.Prepare,
GateACue()));
}
[Fact]
public void GateA_rejects_operator_fade_and_background_changes_before_engine_dispatch()
{
var blockedCues = new[]
{
GateACue() with { FadeDuration = 5 },
GateACue() with { Mutations = [] },
GateACue() with
{
Mutations = [new PlayoutUseBackground(true)]
},
GateACue() with
{
Mutations =
[
new PlayoutUseBackground(false),
new PlayoutUseBackground(false)
]
},
GateACue() with
{
Mutations = [new PlayoutSetBackgroundTexture("background.png")]
},
GateACue() with
{
Mutations = [new PlayoutSetBackgroundVideo("background.mp4", 0, 1)]
}
};
foreach (var cue in blockedCues)
{
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
Assert.True(authorization.TryClaimGateAPrepare(Capability));
Assert.False(authorization.TryClaimEngineCommand(
PlayoutOperation.Prepare,
cue));
Assert.False(authorization.TryClaimEngineCommand(
PlayoutOperation.Prepare,
GateACue()));
}
}
[Fact]
public async Task Real_5001_provider_cue_with_exact_disabled_background_claims_native_prepare()
{
var provider = new RegisteredLegacySceneCueProvider(
new SinglePageDataSource(new LegacySceneDataPage(
"s5001",
new S5001DomesticStockSceneData(
LegacyDomesticEquityMarket.Kospi,
S5001DomesticStockMode.Current,
S5001NxtBadge.None,
"Samsung Electronics",
ScenePriceDirection.Up,
70_000,
500,
0.72m),
1)));
var page = await provider.CreatePageAsync(
new LegacyPlaylistEntry("gate-a-5001", "5001", FadeDuration: 6),
pageIndexZeroBased: 0);
using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
Assert.Equal(new PlayoutUseBackground(false), page.Cue.Mutations![0]);
Assert.True(authorization.TryClaimGateAPrepare(Capability));
Assert.True(authorization.TryClaimEngineCommand(
PlayoutOperation.Prepare,
page.Cue));
}
[Fact]
public async Task Concurrent_operator_claims_have_exactly_one_winner()
{
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
using var start = new ManualResetEventSlim();
var tasks = Enumerable.Range(0, 16)
.Select(_ => Task.Run(() =>
{
start.Wait();
return authorization.TryClaimGateAPrepare(Capability);
}))
.ToArray();
start.Set();
var results = await Task.WhenAll(tasks);
Assert.Single(results, static result => result);
}
[Fact]
public void GateA_options_require_the_exact_closed_profile()
{
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
var valid = GateAOptions();
PlayoutEngineFactory.ValidateGateAOptions(valid, authorization);
foreach (var invalid in new[]
{
GateAOptions().WithMode(PlayoutMode.DryRun),
GateAOptions().WithAllowlist("5001", "5006"),
GateAOptions().WithReconnect(true, 0),
GateAOptions().WithReconnect(false, 1),
GateAOptions().WithRefresh(null),
GateAOptions().WithFade(5),
GateAOptions().WithBackground(LegacySceneBackgroundKind.Texture),
GateAOptions().WithSceneDirectory(Path.GetTempPath()),
GateAOptions().WithClientPort(1),
GateAOptions().WithOutputChannel(0)
})
{
Assert.Throws<PlayoutConfigurationException>(() =>
PlayoutEngineFactory.ValidateGateAOptions(invalid, authorization));
}
}
private static PlayoutCue GateACue() => new(
"5001.t2s",
"5001",
FadeDuration: 6,
Mutations: [new PlayoutUseBackground(false)]);
private static PlayoutOptions GateAOptions() => new()
{
Mode = PlayoutMode.Live,
Host = "127.0.0.1",
Port = 30001,
TcpMode = 1,
LayoutIndex = 10,
TestSceneAllowlist = ["5001"],
TrustedLiveOutputEnabled = true,
ReconnectEnabled = false,
MaximumReconnectAttempts = 0,
MaximumAutomaticRefreshesPerTakeIn = 0,
LegacySceneFadeDuration = 6,
LegacySceneBackgroundKind = LegacySceneBackgroundKind.None,
SceneDirectory = PlayoutLaunchAuthorization.GateAApprovedSceneDirectory
};
private static string SceneHash(string path) =>
Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
private sealed class SinglePageDataSource(LegacySceneDataPage page)
: ILegacySceneDataSource
{
public Task<LegacySceneDataPage> LoadPageAsync(
LegacyPlaylistEntry entry,
int pageIndexZeroBased,
CancellationToken cancellationToken = default) =>
Task.FromResult(page);
}
private static EnvironmentScope ClearGateEnvironment()
{
PlayoutLaunchAuthorization.ResetGateACaptureLatchForTests();
return new EnvironmentScope()
.Set(PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable, null)
.Set(PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable, null)
.Set(PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable, null)
.Set(PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable, null)
.Set(PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, null);
}
private static EnvironmentScope GateEnvironment(
string capability,
string processId,
string startTimeUtcTicks,
string expiresAtUtcTicks) =>
ClearGateEnvironment()
.Set(PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable, capability)
.Set(PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable, processId)
.Set(PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable, startTimeUtcTicks)
.Set(PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable, expiresAtUtcTicks);
}
internal sealed class ManualGateTimeProvider : TimeProvider
{
private DateTimeOffset _utcNow = new(2026, 7, 18, 0, 0, 0, TimeSpan.Zero);
private long _timestamp;
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
public override DateTimeOffset GetUtcNow() => _utcNow;
public override long GetTimestamp() => _timestamp;
public void Advance(TimeSpan duration)
{
_utcNow = _utcNow.Add(duration);
_timestamp += duration.Ticks;
}
public void AdvanceMonotonic(TimeSpan duration) =>
_timestamp += duration.Ticks;
public void SetUtcNow(DateTimeOffset value) => _utcNow = value;
}
internal static class GateAOptionTestExtensions
{
public static PlayoutOptions WithMode(this PlayoutOptions options, PlayoutMode mode)
{
options.Mode = mode;
return options;
}
public static PlayoutOptions WithAllowlist(
this PlayoutOptions options,
params string[] scenes)
{
options.TestSceneAllowlist = [.. scenes];
return options;
}
public static PlayoutOptions WithReconnect(
this PlayoutOptions options,
bool enabled,
int attempts)
{
options.ReconnectEnabled = enabled;
options.MaximumReconnectAttempts = attempts;
return options;
}
public static PlayoutOptions WithRefresh(this PlayoutOptions options, int? maximum)
{
options.MaximumAutomaticRefreshesPerTakeIn = maximum;
return options;
}
public static PlayoutOptions WithFade(this PlayoutOptions options, int fade)
{
options.LegacySceneFadeDuration = fade;
return options;
}
public static PlayoutOptions WithBackground(
this PlayoutOptions options,
LegacySceneBackgroundKind kind)
{
options.LegacySceneBackgroundKind = kind;
return options;
}
public static PlayoutOptions WithSceneDirectory(
this PlayoutOptions options,
string sceneDirectory)
{
options.SceneDirectory = sceneDirectory;
return options;
}
public static PlayoutOptions WithClientPort(
this PlayoutOptions options,
int clientPort)
{
options.ClientPort = clientPort;
return options;
}
public static PlayoutOptions WithOutputChannel(
this PlayoutOptions options,
int outputChannel)
{
options.OutputChannel = outputChannel;
return options;
}
}

View File

@@ -3,11 +3,15 @@ using System.Runtime.InteropServices;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Interop;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
using MBN_STOCK_WEBVIEW.Playout.Safety;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class TornadoPlayoutEngineTests
{
private const string GateACapability =
"0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
[Theory]
[InlineData(0, false)]
[InlineData(1, true)]
@@ -2568,6 +2572,339 @@ public sealed class TornadoPlayoutEngineTests
Assert.Contains("Disconnect", session.Calls);
}
[Fact]
public async Task GateA_AllowsExactlyOneConnectAndClaimed5001Prepare_ThenAbandonsWithoutBye()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var session = new FakeK3dSession();
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
GateACapability);
var engine = CreateEngine(
GateAOptions(scenes.Path),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")),
liveAuthorized: true,
launchAuthorization: authorization);
try
{
var cue = GateACue();
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.Equal(
PlayoutResultCode.Rejected,
(await engine.ConnectAsync(CancellationToken.None)).Code);
Assert.Equal(
PlayoutResultCode.Rejected,
(await engine.PrepareAsync(cue, CancellationToken.None)).Code);
Assert.Equal(
PlayoutResultCode.Rejected,
(await engine.TakeInAsync(CancellationToken.None)).Code);
Assert.Equal(
PlayoutResultCode.Rejected,
(await engine.NextAsync(cue, CancellationToken.None)).Code);
Assert.Equal(
PlayoutResultCode.Rejected,
(await engine.UpdateOnAirAsync(cue, CancellationToken.None)).Code);
Assert.Equal(
PlayoutResultCode.Rejected,
(await engine.TakeOutAsync(
PlayoutTakeOutScope.All,
CancellationToken.None)).Code);
Assert.Equal(
PlayoutResultCode.Rejected,
(await engine.DisconnectAsync(CancellationToken.None)).Code);
Assert.False(engine.Status.LiveTakeInAllowed);
Assert.True(authorization.TryClaimGateAPrepare(GateACapability));
Assert.True((await engine.PrepareAsync(cue, CancellationToken.None)).IsSuccess);
Assert.Equal(
PlayoutResultCode.Rejected,
(await engine.PrepareAsync(cue, CancellationToken.None)).Code);
}
finally
{
authorization.CompleteGateAPrepare();
await engine.DisposeAsync();
}
Assert.Equal(new[] { "Connect", "Prepare:5001", "Abandon" }, session.Calls);
Assert.Equal(1, session.KtapDispatchCount);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
}
[Fact]
public async Task GateA_WrongPrepareTargetPermanentlyConsumesPrepareWithoutSdkMutation()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var session = new FakeK3dSession();
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
GateACapability);
var engine = CreateEngine(
GateAOptions(scenes.Path),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")),
liveAuthorized: true,
launchAuthorization: authorization);
try
{
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.True(authorization.TryClaimGateAPrepare(GateACapability));
var wrong = await engine.PrepareAsync(
new PlayoutCue("5006.t2s", "5006", FadeDuration: 6),
CancellationToken.None);
var correction = await engine.PrepareAsync(
GateACue(),
CancellationToken.None);
Assert.Equal(PlayoutResultCode.Rejected, wrong.Code);
Assert.Equal(PlayoutResultCode.Rejected, correction.Code);
}
finally
{
authorization.CompleteGateAPrepare();
await engine.DisposeAsync();
}
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
Assert.DoesNotContain(
session.Calls,
static call => call.StartsWith("Prepare:", StringComparison.Ordinal));
Assert.DoesNotContain("Disconnect", session.Calls);
}
[Fact]
public async Task GateA_PgmGenerationChangeAbandonsWithoutReconnectOrBye()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var sessionFactory = new FakeK3dSessionFactory();
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
GateACapability);
var options = GateAOptions(scenes.Path);
options.ReconnectEnabled = true;
options.MaximumReconnectAttempts = 3;
var engine = CreateEngine(
options,
sessionFactory,
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(
ProcessSnapshot("approved-pgm"),
ProcessSnapshot("approved-pgm"),
ProcessSnapshot("replacement-pgm")),
liveAuthorized: true,
launchAuthorization: authorization);
try
{
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
await engine.PollProcessOnceAsync(CancellationToken.None);
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
Assert.Equal(1, sessionFactory.CreateCount);
var session = Assert.Single(sessionFactory.Sessions);
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
Assert.DoesNotContain("Disconnect", session.Calls);
}
finally
{
authorization.CompleteGateAPrepare();
await engine.DisposeAsync();
}
Assert.Equal(1, sessionFactory.CreateCount);
}
[Fact]
public async Task GateA_ExpiredConnectIsRejectedBeforeProcessRegistrationOrVendorBoundary()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var clock = new ManualGateTimeProvider();
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
GateACapability,
timeProvider: clock,
expiresAtUtcTicks:
clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks);
clock.Advance(TimeSpan.FromMinutes(1));
var sessionFactory = new FakeK3dSessionFactory();
var registration = new FakeRegistrationProbe();
var process = new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm"));
var engine = CreateEngine(
GateAOptions(scenes.Path),
sessionFactory,
registration,
process,
liveAuthorized: true,
launchAuthorization: authorization);
try
{
var result = await engine.ConnectAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.Rejected, result.Code);
Assert.Equal(0, process.CaptureCount);
Assert.Equal(0, registration.ProbeCount);
Assert.Equal(0, sessionFactory.CreateCount);
}
finally
{
await engine.DisposeAsync();
authorization.Dispose();
}
}
[Fact]
public async Task GateA_PrepareFailureBecomesOutcomeUnknownAndCannotRetryOrDisconnect()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var session = new FakeK3dSession
{
PrepareAction = (_, _) => throw new COMException(
"fake ambiguous Gate A prepare failure")
};
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
GateACapability);
var engine = CreateEngine(
GateAOptions(scenes.Path),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")),
liveAuthorized: true,
abandonOnTargetMismatch: true,
launchAuthorization: authorization);
try
{
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.True(authorization.TryClaimGateAPrepare(GateACapability));
var failed = await engine.PrepareAsync(GateACue(), CancellationToken.None);
var retry = await engine.PrepareAsync(GateACue(), CancellationToken.None);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, failed.Code);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, retry.Code);
}
finally
{
authorization.CompleteGateAPrepare();
await engine.DisposeAsync();
authorization.Dispose();
}
Assert.Equal(new[] { "Connect", "Prepare:5001", "Abandon" }, session.Calls);
Assert.Equal(1, session.Calls.Count(call => call == "Prepare:5001"));
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
}
[Fact]
public async Task GateA_ConnectExpiryAtSdkBoundaryPreventsKtapDispatch()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var clock = new ManualGateTimeProvider();
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
GateACapability,
timeProvider: clock,
expiresAtUtcTicks:
clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks);
var session = new FakeK3dSession
{
BeforeKtapDispatchAction = () => clock.Advance(TimeSpan.FromMinutes(1))
};
var engine = CreateEngine(
GateAOptions(scenes.Path),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")),
liveAuthorized: true,
finalConnectSafetyCheck: () => true,
abandonOnTargetMismatch: true,
beforeSdkDispatchClaim: authorization.EnsureGateASdkDispatchAuthorized,
launchAuthorization: authorization);
try
{
var result = await engine.ConnectAsync(CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.Equal(0, session.KtapDispatchCount);
}
finally
{
await engine.DisposeAsync();
authorization.Dispose();
}
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
}
[Fact]
public async Task GateA_PrepareExpiryAtSdkBoundaryPreventsPrepareDispatchAndQuarantines()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var clock = new ManualGateTimeProvider();
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
GateACapability,
timeProvider: clock,
expiresAtUtcTicks:
clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks);
var dispatchBoundaryCount = 0;
void DelayedDispatchGuard()
{
if (Interlocked.Increment(ref dispatchBoundaryCount) == 2)
{
clock.Advance(TimeSpan.FromMinutes(1));
}
authorization.EnsureGateASdkDispatchAuthorized();
}
var session = new FakeK3dSession();
var engine = CreateEngine(
GateAOptions(scenes.Path),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")),
liveAuthorized: true,
finalConnectSafetyCheck: () => true,
abandonOnTargetMismatch: true,
beforeSdkDispatchClaim: DelayedDispatchGuard,
launchAuthorization: authorization);
try
{
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.True(authorization.TryClaimGateAPrepare(GateACapability));
var result = await engine.PrepareAsync(GateACue(), CancellationToken.None);
var retry = await engine.PrepareAsync(GateACue(), CancellationToken.None);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, retry.Code);
Assert.Equal(2, dispatchBoundaryCount);
}
finally
{
authorization.CompleteGateAPrepare();
await engine.DisposeAsync();
authorization.Dispose();
}
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
Assert.DoesNotContain(
session.Calls,
static call => call.StartsWith("Prepare:", StringComparison.Ordinal));
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
}
private static TornadoPlayoutEngine CreateEngine(
PlayoutOptions rawOptions,
FakeK3dSessionFactory sessionFactory,
@@ -2577,7 +2914,8 @@ public sealed class TornadoPlayoutEngineTests
Func<bool>? finalConnectSafetyCheck = null,
bool abandonOnTargetMismatch = false,
Action? beforeSdkDispatchClaim = null,
FakeLiveAuthorization? liveAuthorization = null)
FakeLiveAuthorization? liveAuthorization = null,
PlayoutLaunchAuthorization? launchAuthorization = null)
{
var options = ValidatedPlayoutOptions.Create(rawOptions);
IStaDispatcher? dispatcher = options.Mode is PlayoutMode.Test or PlayoutMode.Live
@@ -2594,7 +2932,8 @@ public sealed class TornadoPlayoutEngineTests
startMonitor: false,
finalConnectSafetyCheck: finalConnectSafetyCheck,
abandonOnTargetMismatch: abandonOnTargetMismatch,
beforeSdkDispatchClaim: beforeSdkDispatchClaim);
beforeSdkDispatchClaim: beforeSdkDispatchClaim,
launchAuthorization: launchAuthorization);
}
private static PlayoutOptions TestOptions(
@@ -2630,6 +2969,33 @@ public sealed class TornadoPlayoutEngineTests
MaximumReconnectAttempts = 3
};
private static PlayoutOptions GateAOptions(string sceneDirectory) => new()
{
Mode = PlayoutMode.Live,
Host = "127.0.0.1",
Port = 30001,
TcpMode = 1,
LayoutIndex = 10,
SceneDirectory = sceneDirectory,
TestSceneAllowlist = ["5001"],
TrustedLiveOutputEnabled = true,
ReconnectEnabled = false,
MaximumReconnectAttempts = 0,
MaximumAutomaticRefreshesPerTakeIn = 0,
LegacySceneFadeDuration = 6,
ConnectTimeoutMilliseconds = 500,
OperationTimeoutMilliseconds = 500,
DisconnectTimeoutMilliseconds = 500,
ProcessPollIntervalMilliseconds = 100,
ReconnectDelayMilliseconds = 0
};
private static PlayoutCue GateACue() => new(
"5001.t2s",
"5001",
FadeDuration: 6,
Mutations: [new PlayoutUseBackground(false)]);
private static PlayoutCue Cue(string sceneName) => new(
$"{sceneName}.t2s",
sceneName,