feat: add safe Tornado K3D playout adapter
This commit is contained in:
3
tests/MBN_STOCK_WEBVIEW.Playout.Tests/AssemblyInfo.cs
Normal file
3
tests/MBN_STOCK_WEBVIEW.Playout.Tests/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using Xunit;
|
||||
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
@@ -0,0 +1,152 @@
|
||||
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class DryRunPlayoutEngineTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DefaultOptions_AreDryRunAndNeverReportARealConnection()
|
||||
{
|
||||
await using var engine = CreateEngine(PlayoutMode.DryRun);
|
||||
|
||||
var result = await engine.ConnectAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutMode.DryRun, engine.Status.Mode);
|
||||
Assert.Equal(PlayoutConnectionState.DryRunReady, engine.Status.State);
|
||||
Assert.True(engine.Status.IsCommandAvailable);
|
||||
Assert.False(engine.Status.IsConnected);
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.IsDryRun);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisabledMode_RejectsEveryOperationWithoutChangingState()
|
||||
{
|
||||
await using var engine = CreateEngine(PlayoutMode.Disabled);
|
||||
var cue = Cue("disabled-scene");
|
||||
|
||||
var results = new[]
|
||||
{
|
||||
await engine.ConnectAsync(CancellationToken.None),
|
||||
await engine.PrepareAsync(cue, CancellationToken.None),
|
||||
await engine.TakeInAsync(CancellationToken.None),
|
||||
await engine.NextAsync(cue, CancellationToken.None),
|
||||
await engine.TakeOutAsync(
|
||||
PlayoutTakeOutScope.All,
|
||||
CancellationToken.None),
|
||||
await engine.DisconnectAsync(CancellationToken.None)
|
||||
};
|
||||
|
||||
Assert.All(results, result => Assert.Equal(PlayoutResultCode.Rejected, result.Code));
|
||||
Assert.All(results, result => Assert.False(result.IsDryRun));
|
||||
Assert.Equal(PlayoutConnectionState.Disabled, engine.Status.State);
|
||||
Assert.False(engine.Status.IsConnected);
|
||||
Assert.False(engine.Status.IsCommandAvailable);
|
||||
Assert.Null(engine.Status.PreparedSceneName);
|
||||
Assert.Null(engine.Status.OnAirSceneName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DryRun_TransitionsPreparedAndOnAirSceneStateInCommandOrder()
|
||||
{
|
||||
await using var engine = CreateEngine(PlayoutMode.DryRun);
|
||||
var statuses = new List<PlayoutStatus>();
|
||||
engine.StatusChanged += (_, args) => statuses.Add(args.Current);
|
||||
|
||||
var connect = await engine.ConnectAsync(CancellationToken.None);
|
||||
var prepare = await engine.PrepareAsync(
|
||||
Cue("first-scene"),
|
||||
CancellationToken.None);
|
||||
Assert.Equal("first-scene", engine.Status.PreparedSceneName);
|
||||
Assert.Null(engine.Status.OnAirSceneName);
|
||||
|
||||
var takeIn = await engine.TakeInAsync(CancellationToken.None);
|
||||
Assert.Null(engine.Status.PreparedSceneName);
|
||||
Assert.Equal("first-scene", engine.Status.OnAirSceneName);
|
||||
|
||||
var next = await engine.NextAsync(
|
||||
Cue("next-scene"),
|
||||
CancellationToken.None);
|
||||
Assert.Null(engine.Status.PreparedSceneName);
|
||||
Assert.Equal("next-scene", engine.Status.OnAirSceneName);
|
||||
|
||||
var takeOut = await engine.TakeOutAsync(
|
||||
PlayoutTakeOutScope.Layout,
|
||||
CancellationToken.None);
|
||||
Assert.Null(engine.Status.OnAirSceneName);
|
||||
|
||||
var disconnect = await engine.DisconnectAsync(CancellationToken.None);
|
||||
|
||||
Assert.All(
|
||||
new[] { connect, prepare, takeIn, next, takeOut, disconnect },
|
||||
result => Assert.Equal(PlayoutResultCode.Success, result.Code));
|
||||
Assert.All(
|
||||
new[] { connect, prepare, takeIn, next, takeOut, disconnect },
|
||||
result => Assert.True(result.IsDryRun));
|
||||
Assert.Equal(6, statuses.Count);
|
||||
Assert.True(statuses.Zip(statuses.Skip(1), (left, right) => left.Sequence < right.Sequence).All(x => x));
|
||||
Assert.All(statuses, status => Assert.Equal(PlayoutConnectionState.DryRunReady, status.State));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DryRun_TakeInWithoutPreparedSceneIsRejected()
|
||||
{
|
||||
await using var engine = CreateEngine(PlayoutMode.DryRun);
|
||||
|
||||
var result = await engine.TakeInAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.Rejected, result.Code);
|
||||
Assert.Null(engine.Status.OnAirSceneName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelledDryRunCommand_DoesNotChangeStatus()
|
||||
{
|
||||
await using var engine = CreateEngine(PlayoutMode.DryRun);
|
||||
var initialStatus = engine.Status;
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
|
||||
var result = await engine.PrepareAsync(Cue("cancelled-scene"), cancellation.Token);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.Cancelled, result.Code);
|
||||
Assert.Same(initialStatus, engine.Status);
|
||||
Assert.Null(engine.Status.PreparedSceneName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DryRun_PublicStatusAndResultDoNotExposeScenePath()
|
||||
{
|
||||
const string secretPath = "C:\\private-vendor-assets\\do-not-log\\secret.t2s";
|
||||
await using var engine = CreateEngine(PlayoutMode.DryRun);
|
||||
var cue = new PlayoutCue(secretPath, "C:\\unsafe-scene-name");
|
||||
|
||||
var result = await engine.PrepareAsync(cue, CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("장면", engine.Status.PreparedSceneName);
|
||||
Assert.DoesNotContain(secretPath, result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(secretPath, engine.Status.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(cue.SceneName, result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(cue.SceneName, engine.Status.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static PlayoutCue Cue(string sceneName) => new(
|
||||
$"C:\\test-only\\{sceneName}.t2s",
|
||||
sceneName,
|
||||
[new PlayoutField("headline", "safe value", true)]);
|
||||
|
||||
private static TornadoPlayoutEngine CreateEngine(PlayoutMode mode)
|
||||
{
|
||||
var options = ValidatedPlayoutOptions.Create(new PlayoutOptions { Mode = mode });
|
||||
return new TornadoPlayoutEngine(
|
||||
options,
|
||||
new FakeK3dSessionFactory(),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(0, 0, 0)),
|
||||
null,
|
||||
new FakeLiveAuthorization(false),
|
||||
TimeProvider.System,
|
||||
startMonitor: false);
|
||||
}
|
||||
}
|
||||
280
tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs
Normal file
280
tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs
Normal file
@@ -0,0 +1,280 @@
|
||||
using System.Collections.Concurrent;
|
||||
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 DynamicK3dSessionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public async Task Connect_WhenKtapDoesNotReturnOne_FailsClosed(int connectResult)
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
|
||||
var log = new FakeComLog();
|
||||
var engine = new FakeEngine(
|
||||
log,
|
||||
new FakePlayer(log),
|
||||
new FakeScene(log),
|
||||
connectResult);
|
||||
var activator = new FakeActivator(log, engine);
|
||||
await using var dispatcher = new StaDispatcher(capacity: 1);
|
||||
|
||||
var exception = await Assert.ThrowsAnyAsync<Exception>(
|
||||
() => dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
using var session = new DynamicK3dSession(activator);
|
||||
session.Connect(options);
|
||||
return true;
|
||||
},
|
||||
TimeSpan.FromSeconds(5),
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.DoesNotContain(options.Host, exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(scenes.Path, exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(connectResult.ToString(), exception.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("GetScenePlayer", log.Names);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PlayoutTakeOutScope.All, "StopAll")]
|
||||
[InlineData(PlayoutTakeOutScope.Layout, "CutOut:10")]
|
||||
public async Task FakeLateBoundCom_UsesLegacyCallOrderOnOneStaThread(
|
||||
PlayoutTakeOutScope takeOutScope,
|
||||
string expectedTakeOutCall)
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
|
||||
var cue = options.ResolveCue(new PlayoutCue(
|
||||
"test-scene.t2s",
|
||||
"test-scene",
|
||||
[
|
||||
new PlayoutField("headline", "safe value", true),
|
||||
new PlayoutField("badge", null, false)
|
||||
],
|
||||
FadeDuration: 12));
|
||||
var log = new FakeComLog();
|
||||
var player = new FakePlayer(log);
|
||||
var scene = new FakeScene(log);
|
||||
var engine = new FakeEngine(log, player, scene);
|
||||
var activator = new FakeActivator(log, engine);
|
||||
await using var dispatcher = new StaDispatcher(capacity: 4);
|
||||
|
||||
await dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
using var session = new DynamicK3dSession(activator);
|
||||
session.Connect(options);
|
||||
session.Prepare(cue, options.LayoutIndex);
|
||||
session.Play(options.LayoutIndex);
|
||||
session.TakeOut(options.LayoutIndex, takeOutScope);
|
||||
session.Disconnect();
|
||||
return true;
|
||||
},
|
||||
TimeSpan.FromSeconds(5),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
$"Create:{K3dComConstants.KaEventHandlerClassGuid:B}",
|
||||
$"Create:{K3dComConstants.KaEngineClassGuid:B}",
|
||||
"KTAPConnect:1:127.0.0.1:30001:0",
|
||||
"GetScenePlayerOnChannel:9",
|
||||
"LoadScene:test-scene",
|
||||
"SetSceneEffectType:10:7:12",
|
||||
"BeginTransaction",
|
||||
"GetObject:headline",
|
||||
"SetValue:headline:safe value",
|
||||
"SetVisible:headline:1",
|
||||
"GetObject:badge",
|
||||
"SetVisible:badge:0",
|
||||
"QueryVariables",
|
||||
"EndTransaction",
|
||||
"Prepare:10",
|
||||
"Play:10",
|
||||
expectedTakeOutCall,
|
||||
"Disconnect"
|
||||
},
|
||||
log.Names);
|
||||
Assert.Single(log.ThreadIds.Distinct());
|
||||
Assert.All(log.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LateBoundActivatorContract_AcceptsOnlyAClsid()
|
||||
{
|
||||
var create = typeof(ILateBoundComActivator).GetMethod(nameof(ILateBoundComActivator.Create));
|
||||
|
||||
Assert.NotNull(create);
|
||||
Assert.Equal(typeof(Guid), Assert.Single(create.GetParameters()).ParameterType);
|
||||
Assert.Equal(typeof(object), create.ReturnType);
|
||||
}
|
||||
|
||||
private static PlayoutOptions TestOptions(string sceneDirectory) => new()
|
||||
{
|
||||
Mode = PlayoutMode.Test,
|
||||
SceneDirectory = sceneDirectory,
|
||||
OutputChannel = 9,
|
||||
TestProcessWindowTitlePattern = "^Tornado2 TEST$",
|
||||
TestSceneAllowlist = ["test-scene"]
|
||||
};
|
||||
|
||||
public sealed class FakeComLog
|
||||
{
|
||||
private readonly ConcurrentQueue<string> _names = new();
|
||||
private readonly ConcurrentQueue<int> _threadIds = new();
|
||||
private readonly ConcurrentQueue<ApartmentState> _apartmentStates = new();
|
||||
|
||||
public string[] Names => _names.ToArray();
|
||||
|
||||
public int[] ThreadIds => _threadIds.ToArray();
|
||||
|
||||
public ApartmentState[] ApartmentStates => _apartmentStates.ToArray();
|
||||
|
||||
public void Add(string name)
|
||||
{
|
||||
_names.Enqueue(name);
|
||||
_threadIds.Enqueue(Environment.CurrentManagedThreadId);
|
||||
_apartmentStates.Enqueue(Thread.CurrentThread.GetApartmentState());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeActivator : ILateBoundComActivator
|
||||
{
|
||||
private readonly FakeComLog _log;
|
||||
private readonly FakeEngine _engine;
|
||||
private readonly object _eventHandler = new();
|
||||
|
||||
public FakeActivator(FakeComLog log, FakeEngine engine)
|
||||
{
|
||||
_log = log;
|
||||
_engine = engine;
|
||||
}
|
||||
|
||||
public object Create(Guid classId)
|
||||
{
|
||||
_log.Add($"Create:{classId:B}");
|
||||
return classId switch
|
||||
{
|
||||
var value when value == K3dComConstants.KaEventHandlerClassGuid => _eventHandler,
|
||||
var value when value == K3dComConstants.KaEngineClassGuid => _engine,
|
||||
_ => throw new InvalidOperationException("Unexpected fake CLSID.")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FakeEngine
|
||||
{
|
||||
private readonly FakeComLog _log;
|
||||
private readonly FakePlayer _player;
|
||||
private readonly FakeScene _scene;
|
||||
private readonly int _connectResult;
|
||||
|
||||
public FakeEngine(
|
||||
FakeComLog log,
|
||||
FakePlayer player,
|
||||
FakeScene scene,
|
||||
int connectResult = 1)
|
||||
{
|
||||
_log = log;
|
||||
_player = player;
|
||||
_scene = scene;
|
||||
_connectResult = connectResult;
|
||||
}
|
||||
|
||||
public int KTAPConnect(
|
||||
int tcpMode,
|
||||
string host,
|
||||
int hostPort,
|
||||
int clientPort,
|
||||
object eventHandler)
|
||||
{
|
||||
Assert.NotNull(eventHandler);
|
||||
_log.Add($"KTAPConnect:{tcpMode}:{host}:{hostPort}:{clientPort}");
|
||||
return _connectResult;
|
||||
}
|
||||
|
||||
public object GetScenePlayerOnChannel(int channel)
|
||||
{
|
||||
_log.Add($"GetScenePlayerOnChannel:{channel}");
|
||||
return _player;
|
||||
}
|
||||
|
||||
public object LoadScene(string sceneFile, string sceneName)
|
||||
{
|
||||
Assert.True(System.IO.Path.IsPathFullyQualified(sceneFile));
|
||||
_log.Add($"LoadScene:{sceneName}");
|
||||
return _scene;
|
||||
}
|
||||
|
||||
public void BeginTransaction() => _log.Add("BeginTransaction");
|
||||
|
||||
public void EndTransaction() => _log.Add("EndTransaction");
|
||||
|
||||
public void Disconnect() => _log.Add("Disconnect");
|
||||
}
|
||||
|
||||
public sealed class FakePlayer
|
||||
{
|
||||
private readonly FakeComLog _log;
|
||||
|
||||
public FakePlayer(FakeComLog log)
|
||||
{
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public void Prepare(int layoutIndex, object scene)
|
||||
{
|
||||
Assert.NotNull(scene);
|
||||
_log.Add($"Prepare:{layoutIndex}");
|
||||
}
|
||||
|
||||
public void Play(int layoutIndex) => _log.Add($"Play:{layoutIndex}");
|
||||
|
||||
public void StopAll() => _log.Add("StopAll");
|
||||
|
||||
public void CutOut(int layoutIndex) => _log.Add($"CutOut:{layoutIndex}");
|
||||
}
|
||||
|
||||
public sealed class FakeScene
|
||||
{
|
||||
private readonly FakeComLog _log;
|
||||
|
||||
public FakeScene(FakeComLog log)
|
||||
{
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public void SetSceneEffectType(int layoutIndex, int effectType, int duration) =>
|
||||
_log.Add($"SetSceneEffectType:{layoutIndex}:{effectType}:{duration}");
|
||||
|
||||
public object GetObject(string objectName)
|
||||
{
|
||||
_log.Add($"GetObject:{objectName}");
|
||||
return new FakeSceneObject(_log, objectName);
|
||||
}
|
||||
|
||||
public void QueryVariables() => _log.Add("QueryVariables");
|
||||
}
|
||||
|
||||
public sealed class FakeSceneObject
|
||||
{
|
||||
private readonly FakeComLog _log;
|
||||
private readonly string _name;
|
||||
|
||||
public FakeSceneObject(FakeComLog log, string name)
|
||||
{
|
||||
_log = log;
|
||||
_name = name;
|
||||
}
|
||||
|
||||
public void SetValue(string value) => _log.Add($"SetValue:{_name}:{value}");
|
||||
|
||||
public void SetVisible(int visible) => _log.Add($"SetVisible:{_name}:{visible}");
|
||||
}
|
||||
}
|
||||
4
tests/MBN_STOCK_WEBVIEW.Playout.Tests/GlobalUsings.cs
Normal file
4
tests/MBN_STOCK_WEBVIEW.Playout.Tests/GlobalUsings.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
global using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
global using MBN_STOCK_WEBVIEW.Playout;
|
||||
global using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,204 @@
|
||||
using MBN_STOCK_WEBVIEW.Playout.Interop;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Registration;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class K3dRegistrationProbeTests
|
||||
{
|
||||
private const string TypeLibraryPath = "C:\\vendor-secret\\typelib-x64.dll";
|
||||
private const string EnginePath = "C:\\vendor-secret\\engine-x64.dll";
|
||||
private const string EventHandlerPath = "C:\\vendor-secret\\events-x64.dll";
|
||||
|
||||
[Fact]
|
||||
public void Probe_WithReciprocalAmd64MachineRegistration_IsReady()
|
||||
{
|
||||
var binaries = ReadyBinaries();
|
||||
var probe = CreateProbe(ReadySnapshot(), binaries);
|
||||
|
||||
var report = probe.Probe();
|
||||
|
||||
Assert.True(report.IsReady);
|
||||
Assert.Equal(K3dRegistrationIssue.None, report.Issues);
|
||||
Assert.True(report.IsEngineClassReady);
|
||||
Assert.True(report.IsEventHandlerClassReady);
|
||||
Assert.Equal(
|
||||
new[] { TypeLibraryPath, EnginePath, EventHandlerPath },
|
||||
binaries.InspectedPaths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_WhenEitherDirectionOfReciprocalMappingDiffers_FailsClosed()
|
||||
{
|
||||
var snapshot = ReadySnapshot() with
|
||||
{
|
||||
Engine = ReadySnapshot().Engine with
|
||||
{
|
||||
ClassProgId = "Unexpected.Engine.1",
|
||||
ProgIdClassId = K3dComConstants.KaEventHandlerClassId
|
||||
},
|
||||
EventHandler = ReadySnapshot().EventHandler with
|
||||
{
|
||||
ProgIdClassId = K3dComConstants.KaEngineClassId
|
||||
}
|
||||
};
|
||||
var probe = CreateProbe(snapshot, ReadyBinaries());
|
||||
|
||||
var report = probe.Probe();
|
||||
|
||||
Assert.False(report.IsReady);
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EngineProgIdMismatch));
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EngineProgIdClassIdMismatch));
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EventHandlerProgIdClassIdMismatch));
|
||||
Assert.False(report.IsEngineClassReady);
|
||||
Assert.False(report.IsEventHandlerClassReady);
|
||||
AssertSafe(report);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_WhenEitherClassServerIsNotAmd64_FailsClosed()
|
||||
{
|
||||
var binaries = ReadyBinaries();
|
||||
binaries.Set(EnginePath, exists: true, isAmd64: false);
|
||||
binaries.Set(EventHandlerPath, exists: true, isAmd64: false);
|
||||
var probe = CreateProbe(ReadySnapshot(), binaries);
|
||||
|
||||
var report = probe.Probe();
|
||||
|
||||
Assert.False(report.IsReady);
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EngineServerIsNotAmd64));
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EventHandlerServerIsNotAmd64));
|
||||
Assert.False(report.IsEngineClassReady);
|
||||
Assert.False(report.IsEventHandlerClassReady);
|
||||
AssertSafe(report);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_WhenCurrentUserOverrideExists_FailsClosedWithoutDetails()
|
||||
{
|
||||
var probe = CreateProbe(
|
||||
ReadySnapshot() with { CurrentUserOverridePresent = true },
|
||||
ReadyBinaries());
|
||||
|
||||
var report = probe.Probe();
|
||||
|
||||
Assert.False(report.IsReady);
|
||||
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.CurrentUserOverridePresent));
|
||||
Assert.False(report.IsEngineClassReady);
|
||||
Assert.False(report.IsEventHandlerClassReady);
|
||||
Assert.Contains("재정의", report.Message, StringComparison.Ordinal);
|
||||
AssertSafe(report);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentUserOverrideBoundary_CoversBothClassesProgIdsAndTypeLibrary()
|
||||
{
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
$"CLSID\\{K3dComConstants.KaEngineClassId}",
|
||||
$"CLSID\\{K3dComConstants.KaEventHandlerClassId}",
|
||||
K3dComConstants.KaEngineProgId,
|
||||
K3dComConstants.KaEventHandlerProgId,
|
||||
$"TypeLib\\{K3dComConstants.TypeLibraryId}"
|
||||
},
|
||||
WindowsK3dRegistrySnapshotSource.CurrentUserOverridePaths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_WhenRegistrySnapshotCannotBeRead_ReturnsSafeFailure()
|
||||
{
|
||||
var probe = new K3dRegistrationProbe(
|
||||
new ThrowingSnapshotSource(),
|
||||
ReadyBinaries());
|
||||
|
||||
var report = probe.Probe();
|
||||
|
||||
Assert.False(report.IsReady);
|
||||
Assert.Equal(K3dRegistrationIssue.RegistryReadFailed, report.Issues);
|
||||
AssertSafe(report);
|
||||
}
|
||||
|
||||
private static K3dRegistrationProbe CreateProbe(
|
||||
K3dRegistrySnapshot snapshot,
|
||||
FakeBinaryInspector binaries) =>
|
||||
new(new FakeSnapshotSource(snapshot), binaries);
|
||||
|
||||
private static K3dRegistrySnapshot ReadySnapshot() => new(
|
||||
Is64BitProcess: true,
|
||||
Win64TypeLibraryPath: TypeLibraryPath,
|
||||
Engine: new K3dClassRegistrationSnapshot(
|
||||
ClassKeyPresent: true,
|
||||
ClassProgId: K3dComConstants.KaEngineProgId,
|
||||
ProgIdClassId: K3dComConstants.KaEngineClassId,
|
||||
InprocServerPath: EnginePath,
|
||||
ThreadingModel: K3dComConstants.ApartmentThreadingModel,
|
||||
TypeLibraryId: K3dComConstants.TypeLibraryId),
|
||||
EventHandler: new K3dClassRegistrationSnapshot(
|
||||
ClassKeyPresent: true,
|
||||
ClassProgId: K3dComConstants.KaEventHandlerProgId,
|
||||
ProgIdClassId: K3dComConstants.KaEventHandlerClassId,
|
||||
InprocServerPath: EventHandlerPath,
|
||||
ThreadingModel: K3dComConstants.ApartmentThreadingModel,
|
||||
TypeLibraryId: K3dComConstants.TypeLibraryId),
|
||||
CurrentUserOverridePresent: false);
|
||||
|
||||
private static FakeBinaryInspector ReadyBinaries() => new FakeBinaryInspector()
|
||||
.Set(TypeLibraryPath, exists: true, isAmd64: true)
|
||||
.Set(EnginePath, exists: true, isAmd64: true)
|
||||
.Set(EventHandlerPath, exists: true, isAmd64: true);
|
||||
|
||||
private static void AssertSafe(K3dRegistrationReport report)
|
||||
{
|
||||
Assert.DoesNotContain(TypeLibraryPath, report.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(EnginePath, report.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(EventHandlerPath, report.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("HKEY", report.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("CLSID\\", report.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed class FakeSnapshotSource : IK3dRegistrySnapshotSource
|
||||
{
|
||||
private readonly K3dRegistrySnapshot _snapshot;
|
||||
|
||||
public FakeSnapshotSource(K3dRegistrySnapshot snapshot)
|
||||
{
|
||||
_snapshot = snapshot;
|
||||
}
|
||||
|
||||
public K3dRegistrySnapshot Capture() => _snapshot;
|
||||
}
|
||||
|
||||
private sealed class ThrowingSnapshotSource : IK3dRegistrySnapshotSource
|
||||
{
|
||||
public K3dRegistrySnapshot Capture() =>
|
||||
throw new UnauthorizedAccessException("HKEY secret registry detail");
|
||||
}
|
||||
|
||||
private sealed class FakeBinaryInspector : IPeBinaryInspector
|
||||
{
|
||||
private readonly Dictionary<string, PeBinaryInspection> _inspections =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<string> _inspectedPaths = [];
|
||||
|
||||
public IReadOnlyList<string> InspectedPaths => _inspectedPaths;
|
||||
|
||||
public FakeBinaryInspector Set(string path, bool exists, bool isAmd64)
|
||||
{
|
||||
_inspections[path] = new PeBinaryInspection(exists, isAmd64);
|
||||
return this;
|
||||
}
|
||||
|
||||
public PeBinaryInspection Inspect(string? path)
|
||||
{
|
||||
if (path is null)
|
||||
{
|
||||
return new PeBinaryInspection(false, false);
|
||||
}
|
||||
|
||||
_inspectedPaths.Add(path);
|
||||
return _inspections.TryGetValue(path, out var inspection)
|
||||
? inspection
|
||||
: new PeBinaryInspection(false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using MBN_STOCK_WEBVIEW.Playout.Safety;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class LiveAuthorizationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH ", false)]
|
||||
[InlineData("i_authorize_live_program_output_for_this_launch", false)]
|
||||
[InlineData("wrong", false)]
|
||||
[InlineData(
|
||||
PlayoutOptionsLoader.RequiredLiveAuthorizationValue,
|
||||
true)]
|
||||
public void EnvironmentAuthorization_RequiresExactPerLaunchValue(
|
||||
string? value,
|
||||
bool expected)
|
||||
{
|
||||
using var environment = new EnvironmentScope()
|
||||
.Set(PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, value);
|
||||
|
||||
var authorization = new EnvironmentLiveAuthorization();
|
||||
|
||||
Assert.Equal(expected, authorization.IsAuthorizedForThisLaunch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvironmentAuthorization_IsCapturedOnceAndCannotBeArmedAfterConstruction()
|
||||
{
|
||||
using var environment = new EnvironmentScope()
|
||||
.Set(PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, null);
|
||||
var authorization = new EnvironmentLiveAuthorization();
|
||||
|
||||
environment.Set(
|
||||
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
|
||||
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
|
||||
|
||||
Assert.False(authorization.IsAuthorizedForThisLaunch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<Platforms>x64</Platforms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Playout\MBN_STOCK_WEBVIEW.Playout.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
195
tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutFakes.cs
Normal file
195
tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutFakes.cs
Normal file
@@ -0,0 +1,195 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Interop;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Registration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Safety;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
internal sealed class FakeK3dSessionFactory : IK3dSessionFactory
|
||||
{
|
||||
private readonly Func<FakeK3dSession> _create;
|
||||
private int _createCount;
|
||||
|
||||
public FakeK3dSessionFactory(Func<FakeK3dSession>? create = null)
|
||||
{
|
||||
_create = create ?? (() => new FakeK3dSession());
|
||||
}
|
||||
|
||||
public int CreateCount => Volatile.Read(ref _createCount);
|
||||
|
||||
public ConcurrentQueue<FakeK3dSession> Sessions { get; } = new();
|
||||
|
||||
public IK3dSession Create()
|
||||
{
|
||||
Interlocked.Increment(ref _createCount);
|
||||
var session = _create();
|
||||
Sessions.Enqueue(session);
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeK3dSession : IK3dSession
|
||||
{
|
||||
private int _disposed;
|
||||
|
||||
public bool IsConnected { get; private set; }
|
||||
|
||||
public ConcurrentQueue<string> Calls { get; } = new();
|
||||
|
||||
public ConcurrentQueue<int> ThreadIds { get; } = new();
|
||||
|
||||
public ConcurrentQueue<ApartmentState> ApartmentStates { get; } = new();
|
||||
|
||||
public Action<ValidatedPlayoutOptions>? ConnectAction { get; set; }
|
||||
|
||||
public Action? DisconnectAction { get; set; }
|
||||
|
||||
public Action<PlayoutCue, int>? PrepareAction { get; set; }
|
||||
|
||||
public Action<int>? PlayAction { get; set; }
|
||||
|
||||
public Action<int, PlayoutTakeOutScope>? TakeOutAction { get; set; }
|
||||
|
||||
public void Connect(ValidatedPlayoutOptions options)
|
||||
{
|
||||
Record("Connect");
|
||||
ConnectAction?.Invoke(options);
|
||||
IsConnected = true;
|
||||
}
|
||||
|
||||
public void Disconnect()
|
||||
{
|
||||
Record("Disconnect");
|
||||
DisconnectAction?.Invoke();
|
||||
IsConnected = false;
|
||||
}
|
||||
|
||||
public void Prepare(PlayoutCue cue, int layoutIndex)
|
||||
{
|
||||
Record($"Prepare:{cue.SceneName}");
|
||||
PrepareAction?.Invoke(cue, layoutIndex);
|
||||
}
|
||||
|
||||
public void Play(int layoutIndex)
|
||||
{
|
||||
Record("Play");
|
||||
PlayAction?.Invoke(layoutIndex);
|
||||
}
|
||||
|
||||
public void TakeOut(int layoutIndex, PlayoutTakeOutScope scope)
|
||||
{
|
||||
Record($"TakeOut:{scope}");
|
||||
TakeOutAction?.Invoke(layoutIndex, scope);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 0)
|
||||
{
|
||||
Record("Dispose");
|
||||
IsConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Record(string name)
|
||||
{
|
||||
Calls.Enqueue(name);
|
||||
ThreadIds.Enqueue(Environment.CurrentManagedThreadId);
|
||||
ApartmentStates.Enqueue(Thread.CurrentThread.GetApartmentState());
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeRegistrationProbe : IK3dRegistrationProbe
|
||||
{
|
||||
private int _probeCount;
|
||||
|
||||
public FakeRegistrationProbe(K3dRegistrationReport? report = null)
|
||||
{
|
||||
Report = report ?? ReadyReport;
|
||||
}
|
||||
|
||||
public static K3dRegistrationReport ReadyReport { get; } = new(
|
||||
K3dRegistrationIssue.None,
|
||||
Is64BitProcess: true,
|
||||
HasWin64TypeLibrary: true,
|
||||
IsWin64BinaryAmd64: true,
|
||||
IsEngineClassReady: true,
|
||||
IsEventHandlerClassReady: true,
|
||||
Message: "fake K3D registration ready");
|
||||
|
||||
public K3dRegistrationReport Report { get; set; }
|
||||
|
||||
public int ProbeCount => Volatile.Read(ref _probeCount);
|
||||
|
||||
public K3dRegistrationReport Probe()
|
||||
{
|
||||
Interlocked.Increment(ref _probeCount);
|
||||
return Report;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeTornadoProcessProbe : ITornadoProcessProbe
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private readonly Queue<TornadoProcessSnapshot> _snapshots = new();
|
||||
private TornadoProcessSnapshot _last;
|
||||
|
||||
public FakeTornadoProcessProbe(params TornadoProcessSnapshot[] snapshots)
|
||||
{
|
||||
_last = snapshots.LastOrDefault()
|
||||
?? new TornadoProcessSnapshot(0, 0, 0);
|
||||
foreach (var snapshot in snapshots)
|
||||
{
|
||||
_snapshots.Enqueue(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
public int CaptureCount { get; private set; }
|
||||
|
||||
public Regex? LastPattern { get; private set; }
|
||||
|
||||
public TornadoProcessSnapshot Capture(Regex? testWindowPattern)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
CaptureCount++;
|
||||
LastPattern = testWindowPattern;
|
||||
if (_snapshots.Count > 0)
|
||||
{
|
||||
_last = _snapshots.Dequeue();
|
||||
}
|
||||
|
||||
return _last;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeLiveAuthorization : ILiveAuthorization
|
||||
{
|
||||
public FakeLiveAuthorization(bool isAuthorizedForThisLaunch)
|
||||
{
|
||||
IsAuthorizedForThisLaunch = isAuthorizedForThisLaunch;
|
||||
}
|
||||
|
||||
public bool IsAuthorizedForThisLaunch { get; }
|
||||
}
|
||||
|
||||
internal sealed class TrackingStaDispatcherFactory : IStaDispatcherFactory
|
||||
{
|
||||
private int _createCount;
|
||||
|
||||
public int CreateCount => Volatile.Read(ref _createCount);
|
||||
|
||||
public ConcurrentQueue<IStaDispatcher> Dispatchers { get; } = new();
|
||||
|
||||
public IStaDispatcher Create(int capacity)
|
||||
{
|
||||
Interlocked.Increment(ref _createCount);
|
||||
var dispatcher = new StaDispatcher(capacity);
|
||||
Dispatchers.Enqueue(dispatcher);
|
||||
return dispatcher;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class PlayoutOptionsLoaderTests
|
||||
{
|
||||
private static readonly string[] EnvironmentNames =
|
||||
[
|
||||
"MBN_STOCK_PLAYOUT_MODE",
|
||||
"MBN_STOCK_PLAYOUT_HOST",
|
||||
"MBN_STOCK_PLAYOUT_PORT",
|
||||
"MBN_STOCK_PLAYOUT_TCP_MODE",
|
||||
"MBN_STOCK_PLAYOUT_CLIENT_PORT",
|
||||
"MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL",
|
||||
"MBN_STOCK_PLAYOUT_LAYOUT_INDEX",
|
||||
"MBN_STOCK_PLAYOUT_SCENE_DIRECTORY",
|
||||
"MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN",
|
||||
"MBN_STOCK_PLAYOUT_QUEUE_CAPACITY",
|
||||
"MBN_STOCK_PLAYOUT_CONNECT_TIMEOUT_MS",
|
||||
"MBN_STOCK_PLAYOUT_OPERATION_TIMEOUT_MS",
|
||||
"MBN_STOCK_PLAYOUT_DISCONNECT_TIMEOUT_MS",
|
||||
"MBN_STOCK_PLAYOUT_PROCESS_POLL_INTERVAL_MS",
|
||||
"MBN_STOCK_PLAYOUT_RECONNECT_DELAY_MS",
|
||||
"MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS",
|
||||
"MBN_STOCK_PLAYOUT_RECONNECT_ENABLED",
|
||||
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
|
||||
"MBN_STOCK_PLAYOUT_TEST_SCENE_ALLOWLIST",
|
||||
"MBN_STOCK_PLAYOUT_TRUSTED_LIVE_OUTPUT_ENABLED"
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void Load_WhenFileIsMissing_UsesSafeDryRunDefaults()
|
||||
{
|
||||
using var environment = ClearedEnvironment();
|
||||
using var file = TemporaryJsonFile.Missing();
|
||||
|
||||
var options = PlayoutOptionsLoader.Load(file.Path);
|
||||
|
||||
Assert.Equal(PlayoutMode.DryRun, options.Mode);
|
||||
Assert.Equal("127.0.0.1", options.Host);
|
||||
Assert.Equal(30001, options.Port);
|
||||
Assert.Equal(1, options.TcpMode);
|
||||
Assert.Equal(0, options.ClientPort);
|
||||
Assert.Null(options.OutputChannel);
|
||||
Assert.Null(options.SceneDirectory);
|
||||
Assert.Empty(options.TestSceneAllowlist);
|
||||
Assert.False(options.TrustedLiveOutputEnabled);
|
||||
Assert.True(options.ReconnectEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_EnvironmentOverridesJson_ExceptFileOnlySafetyGates()
|
||||
{
|
||||
using var environment = ClearedEnvironment()
|
||||
.Set("MBN_STOCK_PLAYOUT_MODE", "DryRun")
|
||||
.Set("MBN_STOCK_PLAYOUT_HOST", "env-host")
|
||||
.Set("MBN_STOCK_PLAYOUT_PORT", "32001")
|
||||
.Set("MBN_STOCK_PLAYOUT_TCP_MODE", "2")
|
||||
.Set("MBN_STOCK_PLAYOUT_CLIENT_PORT", "32002")
|
||||
.Set("MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL", "12")
|
||||
.Set("MBN_STOCK_PLAYOUT_LAYOUT_INDEX", "13")
|
||||
.Set("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", "C:\\env-scenes")
|
||||
.Set("MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN", "ENV TEST")
|
||||
.Set("MBN_STOCK_PLAYOUT_QUEUE_CAPACITY", "14")
|
||||
.Set("MBN_STOCK_PLAYOUT_CONNECT_TIMEOUT_MS", "1501")
|
||||
.Set("MBN_STOCK_PLAYOUT_OPERATION_TIMEOUT_MS", "1502")
|
||||
.Set("MBN_STOCK_PLAYOUT_DISCONNECT_TIMEOUT_MS", "1503")
|
||||
.Set("MBN_STOCK_PLAYOUT_PROCESS_POLL_INTERVAL_MS", "1504")
|
||||
.Set("MBN_STOCK_PLAYOUT_RECONNECT_DELAY_MS", "1505")
|
||||
.Set("MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS", "4")
|
||||
.Set("MBN_STOCK_PLAYOUT_RECONNECT_ENABLED", "false")
|
||||
.Set("MBN_STOCK_PLAYOUT_TEST_SCENE_ALLOWLIST", "C:\\env\\must-not-apply.t2s")
|
||||
.Set("MBN_STOCK_PLAYOUT_TRUSTED_LIVE_OUTPUT_ENABLED", "false");
|
||||
using var file = TemporaryJsonFile.Create(
|
||||
"""
|
||||
{
|
||||
"mode": "Test",
|
||||
"host": "json-host",
|
||||
"port": 31001,
|
||||
"tcpMode": 1,
|
||||
"clientPort": 31002,
|
||||
"outputChannel": 3,
|
||||
"layoutIndex": 10,
|
||||
"sceneDirectory": "C:\\json-scenes",
|
||||
"testProcessWindowTitlePattern": "JSON TEST",
|
||||
"testSceneAllowlist": ["C:\\test-scenes\\allowed.t2s"],
|
||||
"trustedLiveOutputEnabled": true,
|
||||
"queueCapacity": 64,
|
||||
"connectTimeoutMilliseconds": 5000,
|
||||
"operationTimeoutMilliseconds": 5000,
|
||||
"disconnectTimeoutMilliseconds": 3000,
|
||||
"processPollIntervalMilliseconds": 1000,
|
||||
"reconnectDelayMilliseconds": 1000,
|
||||
"maximumReconnectAttempts": 3,
|
||||
"reconnectEnabled": true
|
||||
}
|
||||
""");
|
||||
|
||||
var options = PlayoutOptionsLoader.Load(file.Path);
|
||||
|
||||
Assert.Equal(PlayoutMode.DryRun, options.Mode);
|
||||
Assert.Equal("env-host", options.Host);
|
||||
Assert.Equal(32001, options.Port);
|
||||
Assert.Equal(2, options.TcpMode);
|
||||
Assert.Equal(32002, options.ClientPort);
|
||||
Assert.Equal(12, options.OutputChannel);
|
||||
Assert.Equal(13, options.LayoutIndex);
|
||||
Assert.Equal("C:\\env-scenes", options.SceneDirectory);
|
||||
Assert.Equal("ENV TEST", options.TestProcessWindowTitlePattern);
|
||||
Assert.Equal(14, options.QueueCapacity);
|
||||
Assert.Equal(1501, options.ConnectTimeoutMilliseconds);
|
||||
Assert.Equal(1502, options.OperationTimeoutMilliseconds);
|
||||
Assert.Equal(1503, options.DisconnectTimeoutMilliseconds);
|
||||
Assert.Equal(1504, options.ProcessPollIntervalMilliseconds);
|
||||
Assert.Equal(1505, options.ReconnectDelayMilliseconds);
|
||||
Assert.Equal(4, options.MaximumReconnectAttempts);
|
||||
Assert.False(options.ReconnectEnabled);
|
||||
Assert.Equal(["C:\\test-scenes\\allowed.t2s"], options.TestSceneAllowlist);
|
||||
Assert.True(options.TrustedLiveOutputEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_InvalidJson_ReturnsOnlySafeConfigurationMessage()
|
||||
{
|
||||
using var environment = ClearedEnvironment();
|
||||
using var file = TemporaryJsonFile.Create("{ invalid-json: true }");
|
||||
|
||||
var exception = Assert.Throws<PlayoutConfigurationException>(
|
||||
() => PlayoutOptionsLoader.Load(file.Path));
|
||||
|
||||
Assert.Equal("송출 설정 파일 형식이 올바르지 않습니다.", exception.Message);
|
||||
Assert.DoesNotContain(file.Path, exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("invalid-json", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("MBN_STOCK_PLAYOUT_MODE", "not-a-mode")]
|
||||
[InlineData("MBN_STOCK_PLAYOUT_PORT", "not-a-number")]
|
||||
[InlineData("MBN_STOCK_PLAYOUT_RECONNECT_ENABLED", "not-a-bool")]
|
||||
public void Load_InvalidEnvironmentValue_IdentifiesVariableWithoutLeakingValue(
|
||||
string name,
|
||||
string invalidValue)
|
||||
{
|
||||
using var environment = ClearedEnvironment().Set(name, invalidValue);
|
||||
using var file = TemporaryJsonFile.Missing();
|
||||
|
||||
var exception = Assert.Throws<PlayoutConfigurationException>(
|
||||
() => PlayoutOptionsLoader.Load(file.Path));
|
||||
|
||||
Assert.Contains(name, exception.Message, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(invalidValue, exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static EnvironmentScope ClearedEnvironment()
|
||||
{
|
||||
var result = new EnvironmentScope();
|
||||
foreach (var name in EnvironmentNames)
|
||||
{
|
||||
result.Set(name, null);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class PlayoutSafetyValidationTests : IDisposable
|
||||
{
|
||||
private readonly TemporarySceneDirectory _scenes =
|
||||
TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
|
||||
[Theory]
|
||||
[InlineData("Tornado2", true)]
|
||||
[InlineData("tornado2-test", true)]
|
||||
[InlineData("TORNADO2Preview", true)]
|
||||
[InlineData("Tornado20", true)]
|
||||
[InlineData("Tornado", false)]
|
||||
[InlineData("PreviewTornado2", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsTornadoProcessName_UsesCaseInsensitiveTornado2Prefix(
|
||||
string? processName,
|
||||
bool expected)
|
||||
{
|
||||
Assert.Equal(expected, TornadoProcessProbe.IsTornadoProcessName(processName));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Tornado2 TEST", false)]
|
||||
[InlineData("Tornado2 PGM", true)]
|
||||
[InlineData("PROGRAM OUTPUT", true)]
|
||||
[InlineData("preview pgm backup", true)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsProgramTitle_RejectsPgmAndProgramWindows(string? title, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, TornadoProcessProbe.IsProgramTitle(title));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(1, true)]
|
||||
[InlineData(2, false)]
|
||||
[InlineData(10, false)]
|
||||
public void ProcessSnapshot_RequiresExactlyOneEligibleInstance(
|
||||
int eligibleProcessCount,
|
||||
bool expected)
|
||||
{
|
||||
var snapshot = new TornadoProcessSnapshot(
|
||||
TotalProcessCount: eligibleProcessCount,
|
||||
EligibleProcessCount: eligibleProcessCount,
|
||||
ProgramProcessCount: 0);
|
||||
|
||||
Assert.Equal(expected, snapshot.HasSingleEligibleProcess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_TestModeRequiresExplicitOutputChannel()
|
||||
{
|
||||
var options = ValidTestOptions();
|
||||
options.OutputChannel = null;
|
||||
|
||||
var exception = Assert.Throws<PlayoutConfigurationException>(
|
||||
() => ValidatedPlayoutOptions.Create(options));
|
||||
|
||||
Assert.Contains(nameof(PlayoutOptions.OutputChannel), exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_TestModeRequiresSceneAllowlist()
|
||||
{
|
||||
var options = ValidTestOptions();
|
||||
options.TestSceneAllowlist = [];
|
||||
|
||||
var exception = Assert.Throws<PlayoutConfigurationException>(
|
||||
() => ValidatedPlayoutOptions.Create(options));
|
||||
|
||||
Assert.Contains(nameof(PlayoutOptions.TestSceneAllowlist), exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("localhost")]
|
||||
[InlineData("test-tornado.local")]
|
||||
[InlineData("192.168.0.10")]
|
||||
public void Validate_TestModeRequiresLiteralLoopbackAddress(string host)
|
||||
{
|
||||
var options = ValidTestOptions();
|
||||
options.Host = host;
|
||||
|
||||
var exception = Assert.Throws<PlayoutConfigurationException>(
|
||||
() => ValidatedPlayoutOptions.Create(options));
|
||||
|
||||
Assert.Contains(nameof(PlayoutOptions.Host), exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(".*")]
|
||||
[InlineData("PGM")]
|
||||
[InlineData("PROGRAM")]
|
||||
[InlineData("^Tornado2 (TEST|PGM)$")]
|
||||
public void Validate_TestWindowPatternThatCanMatchProgramOutput_IsRejected(string pattern)
|
||||
{
|
||||
var options = ValidTestOptions();
|
||||
options.TestProcessWindowTitlePattern = pattern;
|
||||
|
||||
var exception = Assert.Throws<PlayoutConfigurationException>(
|
||||
() => ValidatedPlayoutOptions.Create(options));
|
||||
|
||||
Assert.Contains("PROGRAM", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidTestWindowPattern_IsRejectedWithoutEchoingPattern()
|
||||
{
|
||||
const string invalidPattern = "(?<secret>";
|
||||
var options = ValidTestOptions();
|
||||
options.TestProcessWindowTitlePattern = invalidPattern;
|
||||
|
||||
var exception = Assert.Throws<PlayoutConfigurationException>(
|
||||
() => ValidatedPlayoutOptions.Create(options));
|
||||
|
||||
Assert.Equal("테스트 Tornado 창 제목 설정이 올바르지 않습니다.", exception.Message);
|
||||
Assert.DoesNotContain(invalidPattern, exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_TestSceneAllowlistMatchesSceneNameOnlyAndIgnoresCase()
|
||||
{
|
||||
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
|
||||
|
||||
Assert.True(validated.IsTestSceneAllowed(
|
||||
new PlayoutCue("test-scene.t2s", "TEST-SCENE")));
|
||||
Assert.False(validated.IsTestSceneAllowed(
|
||||
new PlayoutCue("test-scene.t2s", "OTHER-SCENE")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCue_UsesCanonicalFileUnderConfiguredSceneDirectory()
|
||||
{
|
||||
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
|
||||
|
||||
var resolved = validated.ResolveCue(new PlayoutCue(
|
||||
"test-scene.t2s",
|
||||
"TEST-SCENE",
|
||||
FadeDuration: 5));
|
||||
|
||||
Assert.Equal(
|
||||
System.IO.Path.Combine(_scenes.Path, "test-scene.t2s"),
|
||||
resolved.SceneFile,
|
||||
ignoreCase: true);
|
||||
Assert.Equal("TEST-SCENE", resolved.SceneName);
|
||||
Assert.Equal(5, resolved.FadeDuration);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("..\\outside.t2s", "outside")]
|
||||
[InlineData("test-scene.txt", "test-scene")]
|
||||
[InlineData("test-scene.t2s", "different-name")]
|
||||
[InlineData("missing.t2s", "missing")]
|
||||
public void ResolveCue_RejectsUnsafeOrMissingFilesWithoutLeakingInput(
|
||||
string sceneFile,
|
||||
string sceneName)
|
||||
{
|
||||
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
|
||||
|
||||
var exception = Assert.Throws<PlayoutRequestException>(
|
||||
() => validated.ResolveCue(new PlayoutCue(sceneFile, sceneName)));
|
||||
|
||||
Assert.DoesNotContain(sceneFile, exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(_scenes.Path, exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCue_RejectsRootedScenePathWithoutLeakingIt()
|
||||
{
|
||||
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
|
||||
var rootedPath = System.IO.Path.Combine(_scenes.Path, "test-scene.t2s");
|
||||
|
||||
var exception = Assert.Throws<PlayoutRequestException>(
|
||||
() => validated.ResolveCue(new PlayoutCue(rootedPath, "test-scene")));
|
||||
|
||||
Assert.Equal("장면 파일 이름이 올바르지 않습니다.", exception.Message);
|
||||
Assert.DoesNotContain(rootedPath, exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 5000, 64, nameof(PlayoutOptions.Port))]
|
||||
[InlineData(30001, 99, 64, nameof(PlayoutOptions.OperationTimeoutMilliseconds))]
|
||||
[InlineData(30001, 5000, 0, nameof(PlayoutOptions.QueueCapacity))]
|
||||
public void Validate_OutOfRangeValuesAreRejected(
|
||||
int port,
|
||||
int operationTimeoutMilliseconds,
|
||||
int queueCapacity,
|
||||
string expectedProperty)
|
||||
{
|
||||
var options = ValidTestOptions();
|
||||
options.Port = port;
|
||||
options.OperationTimeoutMilliseconds = operationTimeoutMilliseconds;
|
||||
options.QueueCapacity = queueCapacity;
|
||||
|
||||
var exception = Assert.Throws<PlayoutConfigurationException>(
|
||||
() => ValidatedPlayoutOptions.Create(options));
|
||||
|
||||
Assert.Contains(expectedProperty, exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public void Dispose() => _scenes.Dispose();
|
||||
|
||||
private PlayoutOptions ValidTestOptions() => new()
|
||||
{
|
||||
Mode = PlayoutMode.Test,
|
||||
Host = "127.0.0.1",
|
||||
Port = 30001,
|
||||
TcpMode = 1,
|
||||
ClientPort = 0,
|
||||
OutputChannel = 9,
|
||||
LayoutIndex = 10,
|
||||
SceneDirectory = _scenes.Path,
|
||||
TestProcessWindowTitlePattern = "^Tornado2 TEST$",
|
||||
TestSceneAllowlist = ["test-scene"],
|
||||
TrustedLiveOutputEnabled = false,
|
||||
QueueCapacity = 64,
|
||||
ConnectTimeoutMilliseconds = 5000,
|
||||
OperationTimeoutMilliseconds = 5000,
|
||||
DisconnectTimeoutMilliseconds = 3000,
|
||||
ProcessPollIntervalMilliseconds = 1000,
|
||||
ReconnectDelayMilliseconds = 1000,
|
||||
MaximumReconnectAttempts = 3,
|
||||
ReconnectEnabled = true
|
||||
};
|
||||
}
|
||||
169
tests/MBN_STOCK_WEBVIEW.Playout.Tests/StaDispatcherTests.cs
Normal file
169
tests/MBN_STOCK_WEBVIEW.Playout.Tests/StaDispatcherTests.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using System.Collections.Concurrent;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class StaDispatcherTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InvokeAsync_RunsCallbacksOnOneStaThreadInFifoOrder()
|
||||
{
|
||||
await using var dispatcher = new StaDispatcher(capacity: 4);
|
||||
using var firstStarted = new ManualResetEventSlim();
|
||||
using var releaseFirst = new ManualResetEventSlim();
|
||||
var calls = new ConcurrentQueue<(int Order, int ThreadId, ApartmentState Apartment)>();
|
||||
|
||||
var first = dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
calls.Enqueue((
|
||||
1,
|
||||
Environment.CurrentManagedThreadId,
|
||||
Thread.CurrentThread.GetApartmentState()));
|
||||
firstStarted.Set();
|
||||
Assert.True(releaseFirst.Wait(TimeSpan.FromSeconds(5)));
|
||||
return 1;
|
||||
},
|
||||
TimeSpan.FromSeconds(10),
|
||||
CancellationToken.None);
|
||||
Assert.True(firstStarted.Wait(TimeSpan.FromSeconds(5)));
|
||||
|
||||
var second = dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
calls.Enqueue((
|
||||
2,
|
||||
Environment.CurrentManagedThreadId,
|
||||
Thread.CurrentThread.GetApartmentState()));
|
||||
return 2;
|
||||
},
|
||||
TimeSpan.FromSeconds(10),
|
||||
CancellationToken.None);
|
||||
var third = dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
calls.Enqueue((
|
||||
3,
|
||||
Environment.CurrentManagedThreadId,
|
||||
Thread.CurrentThread.GetApartmentState()));
|
||||
return 3;
|
||||
},
|
||||
TimeSpan.FromSeconds(10),
|
||||
CancellationToken.None);
|
||||
|
||||
releaseFirst.Set();
|
||||
var results = await Task.WhenAll(first, second, third);
|
||||
Assert.Equal(new[] { 1, 2, 3 }, results);
|
||||
|
||||
var recorded = calls.ToArray();
|
||||
Assert.Equal([1, 2, 3], recorded.Select(call => call.Order));
|
||||
Assert.Single(recorded.Select(call => call.ThreadId).Distinct());
|
||||
Assert.All(recorded, call => Assert.Equal(ApartmentState.STA, call.Apartment));
|
||||
Assert.Equal(recorded[0].ThreadId, dispatcher.ManagedThreadId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_CancelledWhileQueued_DoesNotRunCallback()
|
||||
{
|
||||
await using var dispatcher = new StaDispatcher(capacity: 2);
|
||||
using var firstStarted = new ManualResetEventSlim();
|
||||
using var releaseFirst = new ManualResetEventSlim();
|
||||
var cancelledCallbackRan = false;
|
||||
|
||||
var first = dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
firstStarted.Set();
|
||||
Assert.True(releaseFirst.Wait(TimeSpan.FromSeconds(5)));
|
||||
return 1;
|
||||
},
|
||||
TimeSpan.FromSeconds(10),
|
||||
CancellationToken.None);
|
||||
Assert.True(firstStarted.Wait(TimeSpan.FromSeconds(5)));
|
||||
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var cancelled = dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
cancelledCallbackRan = true;
|
||||
return 2;
|
||||
},
|
||||
TimeSpan.FromSeconds(10),
|
||||
cancellation.Token);
|
||||
cancellation.Cancel();
|
||||
releaseFirst.Set();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => cancelled);
|
||||
Assert.Equal(1, await first);
|
||||
Assert.False(cancelledCallbackRan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenBoundedQueueIsFull_CanCancelWithoutRunning()
|
||||
{
|
||||
await using var dispatcher = new StaDispatcher(capacity: 1);
|
||||
using var firstStarted = new ManualResetEventSlim();
|
||||
using var releaseFirst = new ManualResetEventSlim();
|
||||
var thirdCallbackRan = false;
|
||||
|
||||
var first = dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
firstStarted.Set();
|
||||
Assert.True(releaseFirst.Wait(TimeSpan.FromSeconds(5)));
|
||||
return 1;
|
||||
},
|
||||
TimeSpan.FromSeconds(10),
|
||||
CancellationToken.None);
|
||||
Assert.True(firstStarted.Wait(TimeSpan.FromSeconds(5)));
|
||||
|
||||
var second = dispatcher.InvokeAsync(
|
||||
() => 2,
|
||||
TimeSpan.FromSeconds(10),
|
||||
CancellationToken.None);
|
||||
using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
|
||||
var third = dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
thirdCallbackRan = true;
|
||||
return 3;
|
||||
},
|
||||
TimeSpan.FromSeconds(10),
|
||||
cancellation.Token);
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => third);
|
||||
Assert.False(thirdCallbackRan);
|
||||
releaseFirst.Set();
|
||||
var results = await Task.WhenAll(first, second);
|
||||
Assert.Equal(new[] { 1, 2 }, results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_TimeoutQuarantinesDispatcherAndRejectsLaterWork()
|
||||
{
|
||||
await using var dispatcher = new StaDispatcher(capacity: 2);
|
||||
using var callbackStarted = new ManualResetEventSlim();
|
||||
using var releaseCallback = new ManualResetEventSlim();
|
||||
|
||||
var timedOut = dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
callbackStarted.Set();
|
||||
Assert.True(releaseCallback.Wait(TimeSpan.FromSeconds(5)));
|
||||
return 1;
|
||||
},
|
||||
TimeSpan.FromMilliseconds(100),
|
||||
CancellationToken.None);
|
||||
Assert.True(callbackStarted.Wait(TimeSpan.FromSeconds(5)));
|
||||
|
||||
await Assert.ThrowsAsync<StaOperationTimedOutException>(() => timedOut);
|
||||
Assert.True(dispatcher.IsQuarantined);
|
||||
await Assert.ThrowsAsync<StaDispatcherQuarantinedException>(
|
||||
() => dispatcher.InvokeAsync(
|
||||
() => 2,
|
||||
TimeSpan.FromSeconds(1),
|
||||
CancellationToken.None));
|
||||
|
||||
releaseCallback.Set();
|
||||
}
|
||||
}
|
||||
118
tests/MBN_STOCK_WEBVIEW.Playout.Tests/TestScopes.cs
Normal file
118
tests/MBN_STOCK_WEBVIEW.Playout.Tests/TestScopes.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
internal sealed class EnvironmentScope : IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, string?> _originalValues = new(StringComparer.Ordinal);
|
||||
|
||||
public EnvironmentScope Set(string name, string? value)
|
||||
{
|
||||
if (!_originalValues.ContainsKey(name))
|
||||
{
|
||||
_originalValues.Add(name, Environment.GetEnvironmentVariable(name));
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable(name, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var pair in _originalValues)
|
||||
{
|
||||
Environment.SetEnvironmentVariable(pair.Key, pair.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TemporaryJsonFile : IDisposable
|
||||
{
|
||||
private TemporaryJsonFile(string path)
|
||||
{
|
||||
Path = path;
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public static TemporaryJsonFile Create(string json)
|
||||
{
|
||||
var directory = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
"MBN_STOCK_WEBVIEW.Playout.Tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
var path = System.IO.Path.Combine(directory, "playout.local.json");
|
||||
File.WriteAllText(path, json);
|
||||
return new TemporaryJsonFile(path);
|
||||
}
|
||||
|
||||
public static TemporaryJsonFile Missing()
|
||||
{
|
||||
var directory = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
"MBN_STOCK_WEBVIEW.Playout.Tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
return new TemporaryJsonFile(
|
||||
System.IO.Path.Combine(directory, "missing-playout.local.json"));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
var directory = System.IO.Path.GetDirectoryName(Path);
|
||||
if (directory is not null && Directory.Exists(directory))
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TemporarySceneDirectory : IDisposable
|
||||
{
|
||||
private TemporarySceneDirectory(string path)
|
||||
{
|
||||
Path = path;
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public static TemporarySceneDirectory Create(params string[] relativeFiles)
|
||||
{
|
||||
var path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
"MBN_STOCK_WEBVIEW.Playout.Tests",
|
||||
Guid.NewGuid().ToString("N"),
|
||||
"Scenes");
|
||||
Directory.CreateDirectory(path);
|
||||
var result = new TemporarySceneDirectory(path);
|
||||
foreach (var relativeFile in relativeFiles)
|
||||
{
|
||||
result.AddFile(relativeFile);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public string AddFile(string relativePath)
|
||||
{
|
||||
var fullPath = System.IO.Path.GetFullPath(System.IO.Path.Combine(Path, relativePath));
|
||||
var rootPrefix = Path.EndsWith(System.IO.Path.DirectorySeparatorChar)
|
||||
? Path
|
||||
: Path + System.IO.Path.DirectorySeparatorChar;
|
||||
if (!fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException("Test file must remain under the temporary scene directory.", nameof(relativePath));
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(fullPath)!);
|
||||
File.WriteAllText(fullPath, "fake test scene; never loaded by a real COM server");
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
var parent = Directory.GetParent(Path)?.FullName;
|
||||
if (parent is not null && Directory.Exists(parent))
|
||||
{
|
||||
Directory.Delete(parent, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
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)]);
|
||||
}
|
||||
Reference in New Issue
Block a user