feat: migrate legacy playout workflow and scenes

This commit is contained in:
2026-07-10 23:58:45 +09:00
parent 491a740505
commit 8dae7b8e0d
128 changed files with 39177 additions and 205 deletions

View File

@@ -39,6 +39,7 @@ public sealed class DryRunPlayoutEngineTests
await engine.PrepareAsync(cue, CancellationToken.None),
await engine.TakeInAsync(CancellationToken.None),
await engine.NextAsync(cue, CancellationToken.None),
await engine.UpdateOnAirAsync(cue, CancellationToken.None),
await engine.TakeOutAsync(
PlayoutTakeOutScope.All,
CancellationToken.None),
@@ -72,6 +73,12 @@ public sealed class DryRunPlayoutEngineTests
Assert.Null(engine.Status.PreparedSceneName);
Assert.Equal("first-scene", engine.Status.OnAirSceneName);
var update = await engine.UpdateOnAirAsync(
Cue("first-scene"),
CancellationToken.None);
Assert.Null(engine.Status.PreparedSceneName);
Assert.Equal("first-scene", engine.Status.OnAirSceneName);
var next = await engine.NextAsync(
Cue("next-scene"),
CancellationToken.None);
@@ -86,12 +93,12 @@ public sealed class DryRunPlayoutEngineTests
var disconnect = await engine.DisconnectAsync(CancellationToken.None);
Assert.All(
new[] { connect, prepare, takeIn, next, takeOut, disconnect },
new[] { connect, prepare, takeIn, update, next, takeOut, disconnect },
result => Assert.Equal(PlayoutResultCode.Success, result.Code));
Assert.All(
new[] { connect, prepare, takeIn, next, takeOut, disconnect },
new[] { connect, prepare, takeIn, update, next, takeOut, disconnect },
result => Assert.True(result.IsDryRun));
Assert.Equal(6, statuses.Count);
Assert.Equal(7, 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));
}

View File

@@ -0,0 +1,103 @@
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using MBN_STOCK_WEBVIEW.Playout.Interop;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class DynamicK3dEventHandlerTests
{
[Fact]
public void GeneratedHandler_ImplementsImportedInterfaceAndForwardsOnlyLifecycleCallbacks()
{
var sink = new RecordingSink();
var value = DynamicK3dEventHandlerBuilder.Create(typeof(IFakeK3dEventHandler), sink);
var handler = Assert.IsAssignableFrom<IFakeK3dEventHandler>(value);
handler.OnHello();
handler.OnScenePlayed(1, 9, 10);
handler.OnCutOut(0, 9, 10);
handler.OnStopAll(1);
handler.OnIgnored("sensitive value");
Assert.Equal(
new[]
{
"OnHello:",
"OnScenePlayed:1,9,10",
"OnCutOut:0,9,10",
"OnStopAll:1"
},
sink.Calls);
Assert.Equal(
ClassInterfaceType.None,
value.GetType().GetCustomAttributes(typeof(ClassInterfaceAttribute), inherit: false)
.Cast<ClassInterfaceAttribute>()
.Single()
.Value);
}
[Fact]
public void EventQueue_ParsesCallbacksWithoutThrowingAcrossNativeBoundary()
{
var queue = new K3dEventQueue(capacity: 4);
queue.OnCallback("OnHello", []);
queue.OnCallback("OnScenePlayed", [1, 9, 10]);
queue.OnCallback("OnCutOut", [0, 9, 10]);
queue.OnCallback("OnStopAll", [1]);
queue.OnCallback("OnScenePlayed", ["invalid"]);
Assert.True(queue.HelloObserved);
Assert.False(queue.HasOverflowed);
Assert.Equal(3, queue.PendingCount);
Assert.True(queue.TryDequeue(out var played));
Assert.Equal(
new K3dLifecycleCallback(K3dLifecycleCallbackKind.ScenePlayed, true, 9, 10),
played);
Assert.True(queue.TryDequeue(out var cut));
Assert.False(cut.IsSuccess);
Assert.True(queue.TryDequeue(out var stopped));
Assert.Equal(K3dLifecycleCallbackKind.StopAll, stopped.Kind);
Assert.False(queue.TryDequeue(out _));
}
[Fact]
public void EventQueue_OverflowLatchesAndNeverExceedsCapacity()
{
var queue = new K3dEventQueue(capacity: 1);
queue.OnCallback("OnStopAll", [1]);
queue.OnCallback("OnStopAll", [1]);
Assert.True(queue.HasOverflowed);
Assert.Equal(1, queue.PendingCount);
Assert.True(queue.TryDequeue(out _));
Assert.False(queue.TryDequeue(out _));
}
private sealed class RecordingSink : IK3dEventSink
{
private readonly ConcurrentQueue<string> _calls = new();
public string[] Calls => _calls.ToArray();
public void OnCallback(string methodName, object?[] arguments) =>
_calls.Enqueue($"{methodName}:{string.Join(',', arguments)}");
}
[ComImport]
[Guid("C21817DB-C0D3-4647-8D85-027338DCF832")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IFakeK3dEventHandler
{
void OnHello();
void OnScenePlayed(int success, int outputChannelIndex, int layerIndex);
void OnCutOut(int success, int outputChannelIndex, int layerIndex);
void OnStopAll(int success);
void OnIgnored(string value);
}
}

View File

@@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
using MBN_STOCK_WEBVIEW.Playout.Interop;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
@@ -331,8 +332,8 @@ public sealed class DynamicK3dSessionTests
"SetVisible:headline:1",
"GetObject:badge",
"SetVisible:badge:0",
"EndTransactionOnChannel:9",
"QueryVariables",
"EndTransactionOnChannel:9",
"Prepare:10",
"Play:10",
expectedTakeOutCall,
@@ -343,6 +344,495 @@ public sealed class DynamicK3dSessionTests
Assert.All(log.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
}
[Fact]
public async Task Prepare_AppliesEveryLegacyMutationInOriginalTransactionOrder()
{
using var scenes = TemporarySceneDirectory.Create(
"test-scene.t2s",
"Images\\badge.png",
"Video\\background.vrv");
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
var cue = options.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"test-scene",
FadeDuration: 4,
Mutations:
[
new PlayoutUseBackground(true),
new PlayoutSetBackgroundTexture("Video/background.vrv"),
new PlayoutSetBackgroundVideo("Video/background.vrv", 2, true),
new PlayoutSetValue("headline", "value"),
new PlayoutSetBackgroundVideo(
"Video/background.vrv",
3,
true,
PlayoutMutationTiming.InTransaction),
new PlayoutSetAssetValue("badge", "Images/badge.png"),
new PlayoutSetVisible("badge", false),
new PlayoutSetFaceColor("headline", 1, 2, 3, 4),
new PlayoutSetPosition("position", 1, 2, 3, PlayoutVectorComponents.XYZ),
new PlayoutSetPositionKey("position", 5, 6, 7, 8, PlayoutVectorComponents.Y),
new PlayoutSetScale("scale", 9, 10, 11, PlayoutVectorComponents.X),
new PlayoutSetCropKey("crop", 1, 12, 13, 14, 15, PlayoutCropEdges.Vertical),
new PlayoutSetCircleAngleKey("circle", 2, 16, 17, PlayoutAngleComponents.All),
new PlayoutSetPathPoints(
"path",
[new PlayoutPoint(18, 19, 20), new PlayoutPoint(21, 22, 23)]),
new PlayoutSetPathShapePoints(
"shape",
[new PlayoutPoint(24, 25, 26)])
]));
var log = new FakeComLog();
var scene = new FakeScene(log);
var activator = new FakeActivator(
log,
new FakeEngine(log, new FakePlayer(log), scene));
await using var dispatcher = new StaDispatcher(capacity: 2);
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(activator);
session.Connect(options);
session.Prepare(cue, options.LayoutIndex);
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Equal(
new[]
{
"LoadScene:test-scene",
"SetOutputChannelIndex:9",
"SetSceneEffectType:1:7:4",
"UseBackground:1",
"SetBackgroundTexture:background.vrv",
"SetBackgroundVideo:background.vrv:2:1",
"BeginTransaction",
"GetObject:headline",
"SetValue:headline:value",
"SetBackgroundVideo:background.vrv:3:1",
"GetObject:badge",
"SetValue:badge:badge.png",
"GetObject:badge",
"SetVisible:badge:0",
"GetObject:headline",
"SetFaceColor:headline:1:2:3:4",
"GetObject:position",
"SetPosition:position:1:2:3:7",
"GetObject:position",
"SetPositionKey:position:5:6:7:8:2",
"GetObject:scale",
"SetScale:scale:9:10:11:1",
"GetObject:crop",
"SetCropKey:crop:1:12:13:14:15:10",
"GetObject:circle",
"SetCircleAngleKey:circle:2:16:17:3",
"GetObject:path",
"BeginPathPoint:path",
"ClearPathPoints:path",
"AddPathPoint:path:18:19:20",
"AddPathPoint:path:21:22:23",
"EndPathPoint:path",
"GetObject:shape",
"BeginPathShapePoint:shape",
"ClearPathShapePoints:shape",
"AddPathShapePoint:shape:24:25:26",
"EndPathShapePoint:shape",
"QueryVariables",
"EndTransactionOnChannel:9",
"Prepare:10"
},
log.Names.Where(name =>
name.StartsWith("LoadScene:", StringComparison.Ordinal) ||
name.StartsWith("Set", StringComparison.Ordinal) ||
name.StartsWith("Use", StringComparison.Ordinal) ||
name.StartsWith("Begin", StringComparison.Ordinal) ||
name.StartsWith("Clear", StringComparison.Ordinal) ||
name.StartsWith("Add", StringComparison.Ordinal) ||
name.StartsWith("End", StringComparison.Ordinal) ||
name.StartsWith("GetObject:", StringComparison.Ordinal) ||
name == "QueryVariables" || name == "Prepare:10"));
}
[Fact]
public async Task UpdateOnAir_UsesPlayingSceneWithoutReloadingOrReapplyingSceneSetup()
{
using var scenes = TemporarySceneDirectory.Create(
"test-scene.t2s",
"Video\\background.vrv");
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
var initialCue = SceneCue(options, "test-scene");
var updateCue = options.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"test-scene",
[new PlayoutField("headline", "page two", true)],
FadeDuration: 59,
Mutations:
[
new PlayoutUseBackground(false),
new PlayoutSetBackgroundTexture("Video/background.vrv"),
new PlayoutSetBackgroundVideo("Video/background.vrv", 4, true),
new PlayoutSetVisible("badge", false)
]));
var log = new FakeComLog();
var scene = new FakeScene(log, "test-scene");
var player = new FakePlayer(log) { PlayingScene = scene };
var activator = new FakeActivator(log, new FakeEngine(log, player, scene));
await using var dispatcher = new StaDispatcher(capacity: 2);
var updateStart = 0;
var updateEnd = 0;
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(activator);
session.Connect(options);
session.Prepare(initialCue, options.LayoutIndex);
session.Play(options.LayoutIndex);
updateStart = log.Names.Length;
session.UpdateOnAir(updateCue, options.LayoutIndex);
updateEnd = log.Names.Length;
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
var updateCalls = log.Names
.Skip(updateStart)
.Take(updateEnd - updateStart)
.ToArray();
Assert.Equal(
new[]
{
"Play:10",
"GetPlayingScene:10",
"BeginTransaction",
"GetObject:headline",
"SetValue:headline:page two",
"SetVisible:headline:1",
"GetObject:badge",
"SetVisible:badge:0",
"QueryVariables",
"EndTransactionOnChannel:9",
"Prepare:10",
"Play:10"
},
updateCalls);
Assert.DoesNotContain(updateCalls, call =>
call.StartsWith("LoadScene:", StringComparison.Ordinal) ||
call.StartsWith("SetSceneEffectType:", StringComparison.Ordinal) ||
call.StartsWith("UseBackground:", StringComparison.Ordinal) ||
call.StartsWith("SetBackgroundTexture:", StringComparison.Ordinal) ||
call.StartsWith("SetBackgroundVideo:", StringComparison.Ordinal));
}
[Fact]
public async Task UpdateOnAir_CallbackSession_TracksItsPlayUntilMatchingCallback()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var options = SceneOptions(scenes.Path, "5001");
var cue = SceneCue(options, "5001");
var log = new FakeComLog();
var scene = new FakeScene(log, "5001");
var player = new FakePlayer(log) { PlayingScene = scene };
var engine = new FakeEngine(log, player, scene);
var activator = new FakeActivator(log, engine);
var callbacks = new RecordingEventHandlerFactory();
var releaser = new FakeReleaser(
log,
(scene, "5001"),
(player, "Player"),
(engine, "Engine"),
(callbacks.Handler, "EventHandler"));
await using var dispatcher = new StaDispatcher(capacity: 2);
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(
activator,
releaser,
new InstalledK3dInteropMethodInvoker(),
callbacks);
session.Connect(options);
session.Prepare(cue, options.LayoutIndex);
session.Play(options.LayoutIndex);
callbacks.Fire("OnScenePlayed", 1, 8, 10);
session.ProcessPendingCallbacks(options.LayoutIndex);
Assert.True(session.HasPendingLifecycleCallbacks);
callbacks.Fire("OnScenePlayed", 1, 9, 10);
session.ProcessPendingCallbacks(options.LayoutIndex);
Assert.False(session.HasPendingLifecycleCallbacks);
session.UpdateOnAir(cue, options.LayoutIndex);
Assert.True(session.HasPendingLifecycleCallbacks);
callbacks.Fire("OnScenePlayed", 1, 8, 10);
session.ProcessPendingCallbacks(options.LayoutIndex);
Assert.True(session.HasPendingLifecycleCallbacks);
callbacks.Fire("OnScenePlayed", 1, 9, 10);
session.ProcessPendingCallbacks(options.LayoutIndex);
Assert.True(session.HasPendingLifecycleCallbacks);
callbacks.Fire("OnScenePlayed", 1, 9, 10);
session.ProcessPendingCallbacks(options.LayoutIndex);
Assert.False(session.HasPendingLifecycleCallbacks);
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Equal(3, log.Names.Count(name => name == "Play:10"));
Assert.Equal(1, log.Names.Count(name => name == "GetPlayingScene:10"));
Assert.DoesNotContain("Unload:5001", log.Names);
}
[Fact]
public async Task LifecycleCallbacks_UnloadOnlyRetiredScenesAfterMatchingSuccess()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var options = SceneOptions(scenes.Path, "5001", "5006");
var log = new FakeComLog();
var player = new FakePlayer(log);
var scene5001 = new FakeScene(log, "5001");
var scene5006 = new FakeScene(log, "5006");
var engine = new FakeEngine(log, player, scene5001)
{
SceneResolver = name => name == "5006" ? scene5006 : scene5001
};
var activator = new FakeActivator(log, engine);
var callbacks = new RecordingEventHandlerFactory();
var releaser = new FakeReleaser(
log,
(scene5001, "5001"),
(scene5006, "5006"),
(player, "Player"),
(engine, "Engine"),
(callbacks.Handler, "EventHandler"));
await using var dispatcher = new StaDispatcher(capacity: 3);
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(
activator,
releaser,
new InstalledK3dInteropMethodInvoker(),
callbacks);
session.Connect(options);
Assert.True(session.SupportsLifecycleCallbacks);
Assert.False(session.KtapHelloObserved);
callbacks.Fire("OnHello");
session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
session.Play(options.LayoutIndex);
callbacks.Fire("OnScenePlayed", 1, 9, 10);
var first = session.ProcessPendingCallbacks(options.LayoutIndex);
Assert.True(first.HelloObserved);
Assert.Equal(0, first.UnloadedSceneCount);
session.Prepare(SceneCue(options, "5006"), options.LayoutIndex);
session.Play(options.LayoutIndex);
Assert.DoesNotContain("Unload:5001", log.Names);
callbacks.Fire("OnScenePlayed", 1, 8, 10);
Assert.Equal(
0,
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
Assert.DoesNotContain("Unload:5001", log.Names);
callbacks.Fire("OnScenePlayed", 1, 9, 10);
Assert.Equal(
1,
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
Assert.Contains("Unload:5001", log.Names);
Assert.Contains("Release:5001", log.Names);
session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
Assert.True(session.HasPendingLifecycleCallbacks);
callbacks.Fire("OnScenePlayed", 1, 9, 10);
Assert.Equal(
0,
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
Assert.DoesNotContain("Unload:5006", log.Names);
callbacks.Fire("OnStopAll", 1);
Assert.Equal(
1,
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
Assert.Contains("Unload:5006", log.Names);
Assert.Contains("Release:5006", log.Names);
Assert.False(session.HasPendingLifecycleCallbacks);
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Equal(1, log.Names.Count(name => name == "Unload:5001"));
Assert.Equal(1, log.Names.Count(name => name == "Unload:5006"));
}
[Fact]
public async Task StopAllSuccess_ClearsInterruptedPlayBeforeDisconnect()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var options = SceneOptions(scenes.Path, "5001");
var log = new FakeComLog();
var scene = new FakeScene(log, "5001");
var player = new FakePlayer(log);
var engine = new FakeEngine(log, player, scene);
var activator = new FakeActivator(log, engine);
var callbacks = new RecordingEventHandlerFactory();
var releaser = new FakeReleaser(
log,
(scene, "5001"),
(player, "Player"),
(engine, "Engine"),
(callbacks.Handler, "EventHandler"));
await using var dispatcher = new StaDispatcher(capacity: 2);
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(
activator,
releaser,
new InstalledK3dInteropMethodInvoker(),
callbacks);
session.Connect(options);
session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
session.Play(options.LayoutIndex);
Assert.True(session.HasPendingPlayCallbacks);
session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
callbacks.Fire("OnStopAll", 1);
var drained = session.ProcessPendingCallbacks(options.LayoutIndex);
Assert.Equal(1, drained.UnloadedSceneCount);
Assert.False(session.HasPendingPlayCallbacks);
Assert.False(session.HasPendingLifecycleCallbacks);
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Contains("StopAll", log.Names);
Assert.Contains("Unload:5001", log.Names);
Assert.Contains("Disconnect", log.Names);
Assert.Equal(1, log.Names.Count(name => name == "Unload:5001"));
}
[Fact]
public async Task CutOutSuccess_ClearsOnlyAfterMatchingOutputBeforeDisconnect()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var options = SceneOptions(scenes.Path, "5001");
var log = new FakeComLog();
var scene = new FakeScene(log, "5001");
var player = new FakePlayer(log);
var engine = new FakeEngine(log, player, scene);
var activator = new FakeActivator(log, engine);
var callbacks = new RecordingEventHandlerFactory();
var releaser = new FakeReleaser(
log,
(scene, "5001"),
(player, "Player"),
(engine, "Engine"),
(callbacks.Handler, "EventHandler"));
await using var dispatcher = new StaDispatcher(capacity: 2);
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(
activator,
releaser,
new InstalledK3dInteropMethodInvoker(),
callbacks);
session.Connect(options);
session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
session.Play(options.LayoutIndex);
session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.Layout);
callbacks.Fire("OnCutOut", 1, 8, options.LayoutIndex);
Assert.Equal(
0,
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
Assert.True(session.HasPendingPlayCallbacks);
Assert.True(session.HasPendingLifecycleCallbacks);
Assert.DoesNotContain("Unload:5001", log.Names);
callbacks.Fire("OnCutOut", 1, 9, options.LayoutIndex);
Assert.Equal(
1,
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
Assert.False(session.HasPendingPlayCallbacks);
Assert.False(session.HasPendingLifecycleCallbacks);
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Contains("CutOut:10", log.Names);
Assert.Contains("Unload:5001", log.Names);
Assert.Contains("Disconnect", log.Names);
Assert.Equal(1, log.Names.Count(name => name == "Unload:5001"));
}
[Fact]
public async Task LifecycleCallbackFailure_DoesNotUnloadAndFailsClosed()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var options = SceneOptions(scenes.Path, "5001");
var log = new FakeComLog();
var player = new FakePlayer(log);
var scene = new FakeScene(log, "5001");
var engine = new FakeEngine(log, player, scene);
var activator = new FakeActivator(log, engine);
var callbacks = new RecordingEventHandlerFactory();
var releaser = new FakeReleaser(
log,
(scene, "5001"),
(player, "Player"),
(engine, "Engine"),
(callbacks.Handler, "EventHandler"));
await using var dispatcher = new StaDispatcher(capacity: 2);
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(
activator,
releaser,
new InstalledK3dInteropMethodInvoker(),
callbacks);
session.Connect(options);
session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
session.Play(options.LayoutIndex);
session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
callbacks.Fire("OnStopAll", 0);
Assert.Throws<InvalidOperationException>(
() => session.ProcessPendingCallbacks(options.LayoutIndex));
Assert.DoesNotContain("Unload:5001", log.Names);
session.Abandon();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
}
[Fact]
public async Task Prepare_WithoutOutputChannel_UsesDefaultPlayerAndDoesNotSetSceneChannel()
{
@@ -385,8 +875,8 @@ public sealed class DynamicK3dSessionTests
"LoadScene:test-scene",
"SetSceneEffectType:1:7:12",
"BeginTransaction",
"EndTransaction",
"QueryVariables",
"EndTransaction",
"Prepare:10",
"Disconnect"
},
@@ -566,6 +1056,7 @@ public sealed class DynamicK3dSessionTests
"Prepare:10",
"Play:10",
"LoadScene:5006",
"RollbackTransaction",
"StopAll",
"Disconnect",
"Release:5006",
@@ -853,6 +1344,25 @@ public sealed class DynamicK3dSessionTests
public object Create(Guid classId) => throw _exception;
}
private sealed class RecordingEventHandlerFactory : IK3dEventHandlerFactory
{
private IK3dEventSink? _sink;
public bool SupportsCallbacks => true;
public object Handler { get; } = new();
public object Create(IK3dEventSink sink)
{
_sink = sink;
return Handler;
}
public void Fire(string methodName, params object?[] arguments) =>
(_sink ?? throw new InvalidOperationException("Callback sink is not connected."))
.OnCallback(methodName, arguments);
}
private sealed class BlockingEngineActivator : ILateBoundComActivator
{
private readonly FakeComLog _log;
@@ -1022,6 +1532,15 @@ public sealed class DynamicK3dSessionTests
_playFailure = playFailure;
}
public object? PlayingScene { get; set; }
public object GetPlayingScene(int layoutIndex)
{
_log.Add($"GetPlayingScene:{layoutIndex}");
return PlayingScene
?? throw new InvalidOperationException("No fake playing scene is configured.");
}
public void Prepare(int layoutIndex, object scene)
{
Assert.NotNull(scene);
@@ -1067,6 +1586,14 @@ public sealed class DynamicK3dSessionTests
public void SetSceneEffectType(int inEffect, int effectType, int duration) =>
_log.Add($"SetSceneEffectType:{inEffect}:{effectType}:{duration}");
public void UseBackground(int enabled) => _log.Add($"UseBackground:{enabled}");
public void SetBackgroundTexture(string path) =>
_log.Add($"SetBackgroundTexture:{System.IO.Path.GetFileName(path)}");
public void SetBackgroundVideo(string path, int loopCount, int loopInfinite) =>
_log.Add($"SetBackgroundVideo:{System.IO.Path.GetFileName(path)}:{loopCount}:{loopInfinite}");
public object GetObject(string objectName)
{
_log.Add($"GetObject:{objectName}");
@@ -1103,8 +1630,58 @@ public sealed class DynamicK3dSessionTests
_name = name;
}
public void SetValue(string value) => _log.Add($"SetValue:{_name}:{value}");
public void SetValue(string value) => _log.Add(
$"SetValue:{_name}:{(System.IO.Path.IsPathFullyQualified(value) ? System.IO.Path.GetFileName(value) : value)}");
public void SetVisible(int visible) => _log.Add($"SetVisible:{_name}:{visible}");
public void SetFaceColor(int red, int green, int blue, int alpha) =>
_log.Add($"SetFaceColor:{_name}:{red}:{green}:{blue}:{alpha}");
public void SetPosition(float x, float y, float z, int components) =>
_log.Add(FormattableString.Invariant($"SetPosition:{_name}:{x}:{y}:{z}:{components}"));
public void SetPositionKey(int keyIndex, float x, float y, float z, int components) =>
_log.Add(FormattableString.Invariant(
$"SetPositionKey:{_name}:{keyIndex}:{x}:{y}:{z}:{components}"));
public void SetScale(float x, float y, float z, int components) =>
_log.Add(FormattableString.Invariant($"SetScale:{_name}:{x}:{y}:{z}:{components}"));
public void SetCropKey(
int keyIndex,
float left,
float top,
float right,
float bottom,
int edges) =>
_log.Add(FormattableString.Invariant(
$"SetCropKey:{_name}:{keyIndex}:{left}:{top}:{right}:{bottom}:{edges}"));
public void SetCircleAngleKey(
int keyIndex,
float start,
float end,
int components) =>
_log.Add(FormattableString.Invariant(
$"SetCircleAngleKey:{_name}:{keyIndex}:{start}:{end}:{components}"));
public void BeginPathPoint() => _log.Add($"BeginPathPoint:{_name}");
public void ClearPathPoints() => _log.Add($"ClearPathPoints:{_name}");
public void AddPathPoint(float x, float y, float z) =>
_log.Add(FormattableString.Invariant($"AddPathPoint:{_name}:{x}:{y}:{z}"));
public void EndPathPoint() => _log.Add($"EndPathPoint:{_name}");
public void BeginPathShapePoint() => _log.Add($"BeginPathShapePoint:{_name}");
public void ClearPathShapePoints() => _log.Add($"ClearPathShapePoints:{_name}");
public void AddPathShapePoint(float x, float y, float z) =>
_log.Add(FormattableString.Invariant($"AddPathShapePoint:{_name}:{x}:{y}:{z}"));
public void EndPathShapePoint() => _log.Add($"EndPathShapePoint:{_name}");
}
}

View File

@@ -550,6 +550,11 @@ public sealed class IsolatedTestCommandTests : IDisposable
CancellationToken cancellationToken = default) =>
Result(PlayoutOperation.Next, "Next");
public Task<PlayoutResult> UpdateOnAirAsync(
PlayoutCue cue,
CancellationToken cancellationToken = default) =>
Result(PlayoutOperation.UpdateOnAir, "UpdateOnAir");
public Task<PlayoutResult> TakeOutAsync(
PlayoutTakeOutScope scope,
CancellationToken cancellationToken = default) =>

View File

@@ -93,6 +93,7 @@ public sealed class PgmCutsSequenceRunnerTests
Assert.Equal(30_001, plan.Options.Port);
Assert.Null(plan.Options.OutputChannel);
Assert.Equal(10, plan.Options.LayoutIndex);
Assert.Equal(["5001", "5006"], plan.Options.TestSceneAllowlist);
Assert.True(plan.Options.TrustedLiveOutputEnabled);
Assert.False(plan.Options.ReconnectEnabled);
Assert.Equal(0, plan.Options.MaximumReconnectAttempts);
@@ -825,6 +826,19 @@ public sealed class PgmCutsSequenceRunnerTests
return Result(PgmCutsSequenceStage.Next, PlayoutOperation.Next);
}
public Task<PlayoutResult> UpdateOnAirAsync(
PlayoutCue cue,
CancellationToken cancellationToken = default)
{
_events.Enqueue($"UpdateOnAir:{cue.SceneName}");
return Task.FromResult(new PlayoutResult(
PlayoutOperation.UpdateOnAir,
PlayoutResultCode.Success,
false,
"fake",
DateTimeOffset.UnixEpoch));
}
public Task<PlayoutResult> TakeOutAsync(
PlayoutTakeOutScope scope,
CancellationToken cancellationToken = default)

View File

@@ -53,6 +53,16 @@ internal sealed class FakeK3dSession : IK3dGuardedConnectSession
public PlayoutKtapConnectState LastKtapConnectState { get; private set; }
public bool SupportsLifecycleCallbacks { get; set; }
public bool? KtapHelloObserved { get; set; }
public bool HasPendingLifecycleCallbacks { get; set; }
public bool HasPendingPlayCallbacks { get; set; }
public bool ClearPendingLifecycleCallbacksAfterProcess { get; set; } = true;
public ConcurrentQueue<string> Calls { get; } = new();
public ConcurrentQueue<int> ThreadIds { get; } = new();
@@ -69,10 +79,14 @@ internal sealed class FakeK3dSession : IK3dGuardedConnectSession
public Action<PlayoutCue, int>? PrepareAction { get; set; }
public Action<PlayoutCue, int>? UpdateOnAirAction { get; set; }
public Action<int>? PlayAction { get; set; }
public Action<int, PlayoutTakeOutScope>? TakeOutAction { get; set; }
public Func<int, K3dCallbackDrainResult>? ProcessPendingCallbacksAction { get; set; }
public Action? AbandonAction { get; set; }
public Action<string>? CallObserver { get; set; }
@@ -155,6 +169,12 @@ internal sealed class FakeK3dSession : IK3dGuardedConnectSession
PrepareAction?.Invoke(cue, layoutIndex);
}
public void UpdateOnAir(PlayoutCue cue, int layoutIndex)
{
Record($"UpdateOnAir:{cue.SceneName}");
UpdateOnAirAction?.Invoke(cue, layoutIndex);
}
public void Play(int layoutIndex)
{
Record("Play");
@@ -167,6 +187,20 @@ internal sealed class FakeK3dSession : IK3dGuardedConnectSession
TakeOutAction?.Invoke(layoutIndex, scope);
}
public K3dCallbackDrainResult ProcessPendingCallbacks(int layoutIndex)
{
Record("ProcessPendingCallbacks");
var result = ProcessPendingCallbacksAction?.Invoke(layoutIndex)
?? new K3dCallbackDrainResult(0, 0, KtapHelloObserved == true);
if (ClearPendingLifecycleCallbacksAfterProcess)
{
HasPendingLifecycleCallbacks = false;
HasPendingPlayCallbacks = false;
}
return result;
}
public void Abandon()
{
TryPreventKtapConnect();
@@ -268,7 +302,7 @@ internal sealed class FakeLiveAuthorization : ILiveAuthorization
IsAuthorizedForThisLaunch = isAuthorizedForThisLaunch;
}
public bool IsAuthorizedForThisLaunch { get; }
public bool IsAuthorizedForThisLaunch { get; set; }
}
internal sealed class TrackingStaDispatcherFactory : IStaDispatcherFactory

View File

@@ -1,3 +1,5 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class PlayoutOptionsLoaderTests
@@ -11,6 +13,11 @@ public sealed class PlayoutOptionsLoaderTests
"MBN_STOCK_PLAYOUT_CLIENT_PORT",
"MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL",
"MBN_STOCK_PLAYOUT_LAYOUT_INDEX",
"MBN_STOCK_PLAYOUT_LEGACY_FADE_DURATION",
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND",
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_ASSET",
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT",
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE",
"MBN_STOCK_PLAYOUT_SCENE_DIRECTORY",
"MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN",
"MBN_STOCK_PLAYOUT_QUEUE_CAPACITY",
@@ -41,6 +48,11 @@ public sealed class PlayoutOptionsLoaderTests
Assert.Equal(0, options.ClientPort);
Assert.Null(options.OutputChannel);
Assert.Null(options.SceneDirectory);
Assert.Equal(6, options.LegacySceneFadeDuration);
Assert.Equal(LegacySceneBackgroundKind.None, options.LegacySceneBackgroundKind);
Assert.Null(options.LegacySceneBackgroundAssetPath);
Assert.Equal(2004, options.LegacySceneBackgroundVideoLoopCount);
Assert.True(options.LegacySceneBackgroundVideoLoopInfinite);
Assert.Empty(options.TestSceneAllowlist);
Assert.False(options.TrustedLiveOutputEnabled);
Assert.True(options.ReconnectEnabled);
@@ -57,6 +69,11 @@ public sealed class PlayoutOptionsLoaderTests
.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_LEGACY_FADE_DURATION", "9")
.Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND", "Video")
.Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_ASSET", "Video\\studio.vrv")
.Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT", "2004")
.Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE", "false")
.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")
@@ -103,6 +120,11 @@ public sealed class PlayoutOptionsLoaderTests
Assert.Equal(32002, options.ClientPort);
Assert.Equal(12, options.OutputChannel);
Assert.Equal(13, options.LayoutIndex);
Assert.Equal(9, options.LegacySceneFadeDuration);
Assert.Equal(LegacySceneBackgroundKind.Video, options.LegacySceneBackgroundKind);
Assert.Equal("Video\\studio.vrv", options.LegacySceneBackgroundAssetPath);
Assert.Equal(2004, options.LegacySceneBackgroundVideoLoopCount);
Assert.False(options.LegacySceneBackgroundVideoLoopInfinite);
Assert.Equal("C:\\env-scenes", options.SceneDirectory);
Assert.Equal("ENV TEST", options.TestProcessWindowTitlePattern);
Assert.Equal(14, options.QueueCapacity);
@@ -153,6 +175,7 @@ public sealed class PlayoutOptionsLoaderTests
[Theory]
[InlineData("MBN_STOCK_PLAYOUT_MODE", "not-a-mode")]
[InlineData("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND", "not-a-background")]
[InlineData("MBN_STOCK_PLAYOUT_PORT", "not-a-number")]
[InlineData("MBN_STOCK_PLAYOUT_RECONNECT_ENABLED", "not-a-bool")]
public void Load_InvalidEnvironmentValue_IdentifiesVariableWithoutLeakingValue(

View File

@@ -6,7 +6,7 @@ namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class PlayoutSafetyValidationTests : IDisposable
{
private readonly TemporarySceneDirectory _scenes =
TemporarySceneDirectory.Create("test-scene.t2s");
TemporarySceneDirectory.Create("test-scene.t2s", "Video\\background.vrv");
[Theory]
[InlineData("Tornado2", true)]
@@ -94,6 +94,24 @@ public sealed class PlayoutSafetyValidationTests : IDisposable
Assert.Contains(nameof(PlayoutOptions.TestSceneAllowlist), exception.Message, StringComparison.Ordinal);
}
[Fact]
public void Validate_LiveModeRequiresClosedSceneAllowlist()
{
var options = ValidTestOptions();
options.Mode = PlayoutMode.Live;
options.OutputChannel = null;
options.TestProcessWindowTitlePattern = null;
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")]
@@ -202,6 +220,65 @@ public sealed class PlayoutSafetyValidationTests : IDisposable
Assert.DoesNotContain(rootedPath, exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ResolveCue_CanonicalizesAllowlistedBackgroundTextureUnderSceneRoot()
{
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
var resolved = validated.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"test-scene",
Mutations: [new PlayoutSetBackgroundTexture("Video/background.vrv")]));
var texture = Assert.IsType<PlayoutSetBackgroundTexture>(Assert.Single(resolved.Mutations!));
Assert.Equal(
Path.Combine(_scenes.Path, "Video", "background.vrv"),
texture.AssetPath,
ignoreCase: true);
}
[Fact]
public void ResolveCue_AcceptsTheLegacy2004BackgroundVideoLoopValue()
{
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
var resolved = validated.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"test-scene",
Mutations:
[
new PlayoutSetBackgroundVideo(
"Video/background.vrv",
LoopCount: 2004,
LoopInfinite: true)
]));
var video = Assert.IsType<PlayoutSetBackgroundVideo>(Assert.Single(resolved.Mutations!));
Assert.Equal(2004, video.LoopCount);
Assert.Equal(
Path.Combine(_scenes.Path, "Video", "background.vrv"),
video.AssetPath,
ignoreCase: true);
}
[Theory]
[InlineData("../outside.vrv")]
[InlineData("Video/missing.vrv")]
[InlineData("Video/background.exe")]
public void ResolveCue_RejectsUnsafeBackgroundTextureWithoutLeakingInput(string assetPath)
{
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
var exception = Assert.Throws<PlayoutRequestException>(() =>
validated.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"test-scene",
Mutations: [new PlayoutSetBackgroundTexture(assetPath)])));
Assert.DoesNotContain(assetPath, exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(_scenes.Path, exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData(0, 5000, 64, nameof(PlayoutOptions.Port))]
[InlineData(30001, 99, 64, nameof(PlayoutOptions.OperationTimeoutMilliseconds))]

View File

@@ -0,0 +1,53 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class PlayoutSceneCompositionFactoryTests
{
[Fact]
public void Default_MatchesLegacyFadeAndRequiresNoExternalAsset()
{
var result = PlayoutSceneCompositionFactory.Create(new PlayoutOptions());
Assert.Equal(6, result.FadeDuration);
Assert.Equal(LegacySceneBackgroundKind.None, result.BackgroundKind);
Assert.Null(result.BackgroundAssetPath);
}
[Fact]
public void TrustedRelativeVideo_IsVerifiedEvenInDryRun()
{
using var scenes = TemporarySceneDirectory.Create("Video\\studio.vrv");
var result = PlayoutSceneCompositionFactory.Create(new PlayoutOptions
{
Mode = PlayoutMode.DryRun,
SceneDirectory = scenes.Path,
LegacySceneBackgroundKind = LegacySceneBackgroundKind.Video,
LegacySceneBackgroundAssetPath = "Video\\studio.vrv"
});
Assert.Equal(LegacySceneBackgroundKind.Video, result.BackgroundKind);
Assert.Equal(Path.Combine("Video", "studio.vrv"), result.BackgroundAssetPath);
Assert.Equal(2004, result.BackgroundVideoLoopCount);
}
[Theory]
[InlineData("Video:studio.vrv")]
[InlineData("..\\outside.vrv")]
[InlineData("Video\\missing.vrv")]
[InlineData("Video\\studio.exe")]
public void InvalidOrMissingBackground_FailsBeforeDryRunPrepare(string relativePath)
{
using var scenes = TemporarySceneDirectory.Create("Video\\studio.vrv");
var options = new PlayoutOptions
{
Mode = PlayoutMode.DryRun,
SceneDirectory = scenes.Path,
LegacySceneBackgroundKind = LegacySceneBackgroundKind.Video,
LegacySceneBackgroundAssetPath = relativePath
};
Assert.Throws<PlayoutConfigurationException>(() =>
PlayoutSceneCompositionFactory.Create(options));
}
}

View File

@@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Interop;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
@@ -1041,6 +1042,116 @@ public sealed class TornadoPlayoutEngineTests
Assert.Null(engine.Status.OnAirSceneName);
}
[Fact]
public async Task UpdateOnAir_TestMode_UsesSerializedSessionPathAndKeepsSceneOnAir()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var session = new FakeK3dSession();
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.True((await engine.PrepareAsync(
Cue("test-scene"),
CancellationToken.None)).IsSuccess);
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
var result = await engine.UpdateOnAirAsync(
Cue("test-scene") with
{
Fields = [new PlayoutField("headline", "page two", true)]
},
CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal(PlayoutOperation.UpdateOnAir, result.Operation);
Assert.False(result.IsDryRun);
Assert.Equal("test-scene", engine.Status.OnAirSceneName);
Assert.Null(engine.Status.PreparedSceneName);
Assert.Equal(
new[]
{
"Connect",
"Prepare:test-scene",
"Play",
"UpdateOnAir:test-scene"
},
session.Calls);
Assert.Single(session.ThreadIds.Distinct());
Assert.All(session.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
}
[Fact]
public async Task UpdateOnAir_RejectsIdleOrDifferentSceneBeforeSessionDispatch()
{
using var scenes = TemporarySceneDirectory.Create("first.t2s", "other.t2s");
var session = new FakeK3dSession();
await using var engine = CreateEngine(
TestOptions(scenes.Path, "first", "other"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
var idle = await engine.UpdateOnAirAsync(Cue("first"), CancellationToken.None);
Assert.True((await engine.PrepareAsync(Cue("first"), CancellationToken.None)).IsSuccess);
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
var different = await engine.UpdateOnAirAsync(
Cue("other"),
CancellationToken.None);
Assert.Equal(PlayoutResultCode.Rejected, idle.Code);
Assert.Equal(PlayoutResultCode.Rejected, different.Code);
Assert.Equal("first", engine.Status.OnAirSceneName);
Assert.DoesNotContain(session.Calls, call =>
call.StartsWith("UpdateOnAir:", StringComparison.Ordinal));
}
[Fact]
public async Task UpdateOnAir_AmbiguousFailureLatchesOutcomeUnknownAndIsNeverRetried()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var session = new FakeK3dSession
{
UpdateOnAirAction = (_, _) =>
throw new COMException("fake ambiguous on-air update failure")
};
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.True((await engine.PrepareAsync(
Cue("test-scene"),
CancellationToken.None)).IsSuccess);
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
var failed = await engine.UpdateOnAirAsync(
Cue("test-scene"),
CancellationToken.None);
var later = await engine.UpdateOnAirAsync(
Cue("test-scene"),
CancellationToken.None);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, failed.Code);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, later.Code);
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
Assert.Equal(
1,
session.Calls.Count(call => call == "UpdateOnAir:test-scene"));
Assert.Contains("Abandon", session.Calls);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
}
[Fact]
public async Task UnchangedProcessGeneration_DoesNotReconnectAnExistingSession()
{
@@ -1248,6 +1359,7 @@ public sealed class TornadoPlayoutEngineTests
await engine.PrepareAsync(Cue("live-scene"), CancellationToken.None),
await engine.TakeInAsync(CancellationToken.None),
await engine.NextAsync(Cue("live-scene"), CancellationToken.None),
await engine.UpdateOnAirAsync(Cue("live-scene"), CancellationToken.None),
await engine.TakeOutAsync(PlayoutTakeOutScope.All, CancellationToken.None)
};
@@ -1272,12 +1384,77 @@ public sealed class TornadoPlayoutEngineTests
var connect = await engine.ConnectAsync(CancellationToken.None);
var prepare = await engine.PrepareAsync(Cue("live-scene"), CancellationToken.None);
var takeIn = await engine.TakeInAsync(CancellationToken.None);
var update = await engine.UpdateOnAirAsync(
Cue("live-scene"),
CancellationToken.None);
Assert.True(connect.IsSuccess);
Assert.True(prepare.IsSuccess);
Assert.True(takeIn.IsSuccess);
Assert.True(update.IsSuccess);
Assert.True(engine.Status.LiveTakeInAllowed);
Assert.Contains("Play", session.Calls);
Assert.Contains("UpdateOnAir:live-scene", session.Calls);
}
[Fact]
public async Task LiveWithBothAuthorizations_RejectsSceneOutsideClosedAllowlist()
{
using var scenes = TemporarySceneDirectory.Create(
"live-scene.t2s",
"blocked-scene.t2s");
var session = new FakeK3dSession();
await using var engine = CreateEngine(
LiveOptions(scenes.Path, trustedLiveOutputEnabled: true),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: true);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
var prepare = await engine.PrepareAsync(
Cue("blocked-scene"),
CancellationToken.None);
var next = await engine.NextAsync(
Cue("blocked-scene"),
CancellationToken.None);
Assert.Equal(PlayoutResultCode.Rejected, prepare.Code);
Assert.Equal(PlayoutResultCode.Rejected, next.Code);
Assert.DoesNotContain(
session.Calls,
call => call.Contains("blocked-scene", StringComparison.Ordinal));
}
[Fact]
public async Task UpdateOnAir_LiveMode_RechecksAuthorizationBeforeSessionDispatch()
{
using var scenes = TemporarySceneDirectory.Create("live-scene.t2s");
var session = new FakeK3dSession();
var authorization = new FakeLiveAuthorization(true);
await using var engine = CreateEngine(
LiveOptions(scenes.Path, trustedLiveOutputEnabled: true),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: true,
liveAuthorization: authorization);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.True((await engine.PrepareAsync(
Cue("live-scene"),
CancellationToken.None)).IsSuccess);
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
authorization.IsAuthorizedForThisLaunch = false;
var result = await engine.UpdateOnAirAsync(
Cue("live-scene"),
CancellationToken.None);
Assert.Equal(PlayoutResultCode.Rejected, result.Code);
Assert.DoesNotContain("UpdateOnAir:live-scene", session.Calls);
Assert.Equal("live-scene", engine.Status.OnAirSceneName);
}
[Fact]
@@ -1876,6 +2053,390 @@ public sealed class TornadoPlayoutEngineTests
Assert.Equal(PlayoutConnectionState.Disposed, engine.Status.State);
}
[Fact]
public async Task ProcessMonitor_DrainsLifecycleCallbacksOnTheStaAndPublishesHello()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var session = new FakeK3dSession
{
SupportsLifecycleCallbacks = true,
KtapHelloObserved = false,
HasPendingLifecycleCallbacks = true,
ProcessPendingCallbacksAction = _ => new K3dCallbackDrainResult(2, 1, true)
};
var sessionFactory = new FakeK3dSessionFactory(() => session);
var process = new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0));
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
sessionFactory,
new FakeRegistrationProbe(),
process,
liveAuthorized: false);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.False(engine.Status.KtapHelloObserved);
session.KtapHelloObserved = true;
await engine.PollProcessOnceAsync(CancellationToken.None);
Assert.True(engine.Status.KtapHelloObserved);
Assert.Contains("ProcessPendingCallbacks", session.Calls);
Assert.All(session.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State);
}
[Fact]
public async Task Disconnect_DoesNotDispatchWhileTakeOutCompletionCallbackIsPending()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var session = new FakeK3dSession
{
SupportsLifecycleCallbacks = true,
ClearPendingLifecycleCallbacksAfterProcess = false
};
session.TakeOutAction = (_, _) => session.HasPendingLifecycleCallbacks = true;
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.True((await engine.PrepareAsync(Cue("test-scene"), CancellationToken.None)).IsSuccess);
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
Assert.True((await engine.TakeOutAsync(
PlayoutTakeOutScope.All,
CancellationToken.None)).IsSuccess);
var disconnect = await engine.DisconnectAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.Unavailable, disconnect.Code);
Assert.Contains("ProcessPendingCallbacks", session.Calls);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Abandon", session.Calls);
Assert.True(session.IsConnected);
session.HasPendingLifecycleCallbacks = false;
}
[Fact]
public async Task Dispose_AbandonsWithoutDisconnectWhenTakeOutCallbackNeverArrives()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var session = new FakeK3dSession
{
SupportsLifecycleCallbacks = true,
ClearPendingLifecycleCallbacksAfterProcess = false
};
session.TakeOutAction = (_, _) => session.HasPendingLifecycleCallbacks = true;
var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.True((await engine.PrepareAsync(Cue("test-scene"), CancellationToken.None)).IsSuccess);
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
Assert.True((await engine.TakeOutAsync(
PlayoutTakeOutScope.All,
CancellationToken.None)).IsSuccess);
await engine.DisposeAsync();
Assert.Contains("ProcessPendingCallbacks", session.Calls);
Assert.Contains("Abandon", session.Calls);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
Assert.Equal(PlayoutConnectionState.Disposed, engine.Status.State);
}
[Fact]
public async Task LifecycleCallbackFailure_LatchesOutcomeUnknownWithoutDisconnectOrReplay()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var session = new FakeK3dSession
{
SupportsLifecycleCallbacks = true,
HasPendingLifecycleCallbacks = true,
ProcessPendingCallbacksAction = _ =>
throw new InvalidOperationException("fake callback failure")
};
var sessionFactory = new FakeK3dSessionFactory(() => session);
var process = new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0));
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
sessionFactory,
new FakeRegistrationProbe(),
process,
liveAuthorized: false);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
await engine.PollProcessOnceAsync(CancellationToken.None);
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
Assert.Contains("ProcessPendingCallbacks", session.Calls);
Assert.Contains("Abandon", session.Calls);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
var later = await engine.PrepareAsync(Cue("test-scene"), CancellationToken.None);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, later.Code);
Assert.Equal(1, session.Calls.Count(call => call == "ProcessPendingCallbacks"));
}
[Fact]
public async Task PendingPlayCallback_BlocksDelayedMultiNextUntilEachCompletionIsDrained()
{
using var scenes = TemporarySceneDirectory.Create(
"5001.t2s",
"5006.t2s",
"5011.t2s");
var callbackReady = false;
var session = new FakeK3dSession
{
SupportsLifecycleCallbacks = true,
ClearPendingLifecycleCallbacksAfterProcess = false
};
session.PlayAction = _ =>
{
session.HasPendingPlayCallbacks = true;
session.HasPendingLifecycleCallbacks = true;
};
session.ProcessPendingCallbacksAction = _ =>
{
if (!callbackReady)
{
return new K3dCallbackDrainResult(0, 0, false);
}
callbackReady = false;
session.HasPendingPlayCallbacks = false;
session.HasPendingLifecycleCallbacks = false;
return new K3dCallbackDrainResult(1, 1, false);
};
await using var engine = CreateEngine(
TestOptions(scenes.Path, "5001", "5006", "5011"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.True((await engine.PrepareAsync(
Cue("5001"),
CancellationToken.None)).IsSuccess);
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
Assert.True(session.HasPendingPlayCallbacks);
var firstEarlyNext = await engine.NextAsync(
Cue("5006"),
CancellationToken.None);
Assert.Equal(PlayoutResultCode.Unavailable, firstEarlyNext.Code);
Assert.Contains("completion callback", firstEarlyNext.Message, StringComparison.Ordinal);
Assert.DoesNotContain("Prepare:5006", session.Calls);
callbackReady = true;
Assert.True((await engine.NextAsync(
Cue("5006"),
CancellationToken.None)).IsSuccess);
Assert.True(session.HasPendingPlayCallbacks);
Assert.Equal(1, session.Calls.Count(call => call == "Prepare:5006"));
var secondEarlyNext = await engine.NextAsync(
Cue("5011"),
CancellationToken.None);
Assert.Equal(PlayoutResultCode.Unavailable, secondEarlyNext.Code);
Assert.DoesNotContain("Prepare:5011", session.Calls);
callbackReady = true;
Assert.True((await engine.NextAsync(
Cue("5011"),
CancellationToken.None)).IsSuccess);
Assert.Equal(1, session.Calls.Count(call => call == "Prepare:5011"));
callbackReady = true;
await engine.PollProcessOnceAsync(CancellationToken.None);
Assert.False(session.HasPendingPlayCallbacks);
Assert.True((await engine.TakeOutAsync(
PlayoutTakeOutScope.All,
CancellationToken.None)).IsSuccess);
Assert.True((await engine.DisconnectAsync(CancellationToken.None)).IsSuccess);
Assert.Contains("Disconnect", session.Calls);
}
[Theory]
[InlineData("prepare")]
[InlineData("take-in")]
[InlineData("next")]
[InlineData("update-on-air")]
public async Task PendingPlayCallback_RejectsEveryNonTakeOutSceneMutation(string operation)
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var session = new FakeK3dSession
{
SupportsLifecycleCallbacks = true,
ClearPendingLifecycleCallbacksAfterProcess = false
};
session.PlayAction = _ =>
{
session.HasPendingPlayCallbacks = true;
session.HasPendingLifecycleCallbacks = true;
};
await using var engine = CreateEngine(
TestOptions(scenes.Path, "5001", "5006"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
if (operation != "prepare")
{
Assert.True((await engine.PrepareAsync(
Cue("5001"),
CancellationToken.None)).IsSuccess);
}
if (operation is "next" or "update-on-air")
{
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
}
else
{
session.HasPendingPlayCallbacks = true;
session.HasPendingLifecycleCallbacks = true;
}
var result = operation switch
{
"prepare" => await engine.PrepareAsync(
Cue("5006"),
CancellationToken.None),
"take-in" => await engine.TakeInAsync(CancellationToken.None),
"next" => await engine.NextAsync(
Cue("5006"),
CancellationToken.None),
"update-on-air" => await engine.UpdateOnAirAsync(
Cue("5001"),
CancellationToken.None),
_ => throw new InvalidOperationException("Unknown test operation.")
};
Assert.Equal(PlayoutResultCode.Unavailable, result.Code);
Assert.Contains("completion callback", result.Message, StringComparison.Ordinal);
if (operation == "prepare")
{
Assert.DoesNotContain("Prepare:5006", session.Calls);
}
else if (operation == "take-in")
{
Assert.DoesNotContain("Play", session.Calls);
}
else if (operation == "next")
{
Assert.DoesNotContain("Prepare:5006", session.Calls);
}
else
{
Assert.DoesNotContain("UpdateOnAir:5001", session.Calls);
}
}
[Fact]
public async Task PendingPlayCallback_DoesNotBlockEmergencyTakeOut()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var session = new FakeK3dSession
{
SupportsLifecycleCallbacks = true,
ClearPendingLifecycleCallbacksAfterProcess = false
};
session.PlayAction = _ =>
{
session.HasPendingPlayCallbacks = true;
session.HasPendingLifecycleCallbacks = true;
};
await using var engine = CreateEngine(
TestOptions(scenes.Path, "5001"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.True((await engine.PrepareAsync(
Cue("5001"),
CancellationToken.None)).IsSuccess);
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
Assert.True(session.HasPendingPlayCallbacks);
var result = await engine.TakeOutAsync(
PlayoutTakeOutScope.All,
CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Contains("TakeOut:All", session.Calls);
}
[Theory]
[InlineData(PlayoutTakeOutScope.All)]
[InlineData(PlayoutTakeOutScope.Layout)]
public async Task TakeOutCompletion_ClearsInterruptedPlayAndAllowsDisconnect(
PlayoutTakeOutScope scope)
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var takeOutCompletionReady = false;
var session = new FakeK3dSession
{
SupportsLifecycleCallbacks = true,
ClearPendingLifecycleCallbacksAfterProcess = false
};
session.PlayAction = _ =>
{
session.HasPendingPlayCallbacks = true;
session.HasPendingLifecycleCallbacks = true;
};
session.TakeOutAction = (_, _) => session.HasPendingLifecycleCallbacks = true;
session.ProcessPendingCallbacksAction = _ =>
{
if (!takeOutCompletionReady)
{
return new K3dCallbackDrainResult(0, 0, false);
}
session.HasPendingPlayCallbacks = false;
session.HasPendingLifecycleCallbacks = false;
return new K3dCallbackDrainResult(1, 1, false);
};
await using var engine = CreateEngine(
TestOptions(scenes.Path, "5001"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.True((await engine.PrepareAsync(
Cue("5001"),
CancellationToken.None)).IsSuccess);
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
Assert.True(session.HasPendingPlayCallbacks);
Assert.True((await engine.TakeOutAsync(scope, CancellationToken.None)).IsSuccess);
takeOutCompletionReady = true;
var disconnect = await engine.DisconnectAsync(CancellationToken.None);
Assert.True(disconnect.IsSuccess);
Assert.False(session.HasPendingPlayCallbacks);
Assert.Contains($"TakeOut:{scope}", session.Calls);
Assert.Contains("ProcessPendingCallbacks", session.Calls);
Assert.Contains("Disconnect", session.Calls);
}
private static TornadoPlayoutEngine CreateEngine(
PlayoutOptions rawOptions,
FakeK3dSessionFactory sessionFactory,
@@ -1884,7 +2445,8 @@ public sealed class TornadoPlayoutEngineTests
bool liveAuthorized,
Func<bool>? finalConnectSafetyCheck = null,
bool abandonOnTargetMismatch = false,
Action? beforeSdkDispatchClaim = null)
Action? beforeSdkDispatchClaim = null,
FakeLiveAuthorization? liveAuthorization = null)
{
var options = ValidatedPlayoutOptions.Create(rawOptions);
IStaDispatcher? dispatcher = options.Mode is PlayoutMode.Test or PlayoutMode.Live
@@ -1896,7 +2458,7 @@ public sealed class TornadoPlayoutEngineTests
registrationProbe,
processProbe,
dispatcher,
new FakeLiveAuthorization(liveAuthorized),
liveAuthorization ?? new FakeLiveAuthorization(liveAuthorized),
TimeProvider.System,
startMonitor: false,
finalConnectSafetyCheck: finalConnectSafetyCheck,
@@ -1927,6 +2489,7 @@ public sealed class TornadoPlayoutEngineTests
{
Mode = PlayoutMode.Live,
SceneDirectory = sceneDirectory,
TestSceneAllowlist = ["live-scene"],
TrustedLiveOutputEnabled = trustedLiveOutputEnabled,
ConnectTimeoutMilliseconds = 500,
OperationTimeoutMilliseconds = 500,