feat: migrate legacy playout workflow and scenes
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user