feat: add safe Tornado K3D playout adapter

This commit is contained in:
2026-07-10 06:41:48 +09:00
parent 39c4504b87
commit 5a8c7028dc
43 changed files with 7341 additions and 92 deletions

View File

@@ -0,0 +1,3 @@
using Xunit;
[assembly: CollectionBehavior(DisableTestParallelization = true)]

View File

@@ -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);
}
}

View 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}");
}
}

View 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;

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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>

View 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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
};
}

View 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();
}
}

View 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);
}
}
}

View File

@@ -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)]);
}