2274 lines
88 KiB
C#
2274 lines
88 KiB
C#
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;
|
|
using MBN_STOCK_WEBVIEW.Playout.Safety;
|
|
|
|
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
|
|
|
public sealed class DynamicK3dSessionTests
|
|
{
|
|
[Theory]
|
|
[InlineData(0)]
|
|
[InlineData(-1)]
|
|
public async Task Connect_WhenKtapDoesNotReturnOne_FailsClosed(int connectResult)
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
|
|
var log = new FakeComLog();
|
|
var engine = new FakeEngine(
|
|
log,
|
|
new FakePlayer(log),
|
|
new FakeScene(log),
|
|
connectResult);
|
|
var activator = new FakeActivator(log, engine);
|
|
var releaser = new FakeReleaser(
|
|
log,
|
|
(engine, "Engine"),
|
|
(activator.EventHandler, "EventHandler"));
|
|
await using var dispatcher = new StaDispatcher(capacity: 1);
|
|
|
|
var exception = await Assert.ThrowsAnyAsync<Exception>(
|
|
() => dispatcher.InvokeAsync(
|
|
() =>
|
|
{
|
|
using var session = new DynamicK3dSession(activator, releaser);
|
|
try
|
|
{
|
|
session.Connect(options);
|
|
}
|
|
catch
|
|
{
|
|
Assert.False(session.IsConnected);
|
|
Assert.Equal(
|
|
PlayoutKtapConnectState.Failed,
|
|
session.LastKtapConnectState);
|
|
throw;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None));
|
|
|
|
Assert.DoesNotContain(options.Host, exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain(scenes.Path, exception.Message, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain(connectResult.ToString(), exception.Message, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("GetScenePlayer", log.Names);
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
$"Create:{K3dComConstants.KaEventHandlerClassGuid:B}",
|
|
$"Create:{K3dComConstants.KaEngineClassGuid:B}",
|
|
"KTAPConnect:1:127.0.0.1:30001:0",
|
|
"Disconnect",
|
|
"Release:Engine",
|
|
"Release:EventHandler"
|
|
},
|
|
log.Names);
|
|
Assert.Equal(1, log.Names.Count(name => name == "Disconnect"));
|
|
Assert.Single(log.ThreadIds.Distinct());
|
|
Assert.All(log.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Connect_WhenPlayerInitializationFails_DisconnectsOnceAndPreservesOriginalFailure()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
|
|
var log = new FakeComLog();
|
|
var originalFailure = new InvalidOperationException("fake original player failure");
|
|
var engine = new FakeEngine(
|
|
log,
|
|
new FakePlayer(log),
|
|
new FakeScene(log),
|
|
playerFailure: originalFailure,
|
|
disconnectFailure: new InvalidOperationException("fake cleanup failure"));
|
|
var activator = new FakeActivator(log, engine);
|
|
var releaser = new FakeReleaser(
|
|
log,
|
|
(engine, "Engine"),
|
|
(activator.EventHandler, "EventHandler"));
|
|
await using var dispatcher = new StaDispatcher(capacity: 1);
|
|
var lastKtapConnectState = PlayoutKtapConnectState.NotAttempted;
|
|
|
|
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => dispatcher.InvokeAsync(
|
|
() =>
|
|
{
|
|
using var session = new DynamicK3dSession(activator, releaser);
|
|
try
|
|
{
|
|
session.Connect(options);
|
|
}
|
|
finally
|
|
{
|
|
lastKtapConnectState = session.LastKtapConnectState;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None));
|
|
|
|
Assert.Same(originalFailure, exception);
|
|
Assert.Equal(PlayoutKtapConnectState.AcceptedUnconfirmed, lastKtapConnectState);
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
$"Create:{K3dComConstants.KaEventHandlerClassGuid:B}",
|
|
$"Create:{K3dComConstants.KaEngineClassGuid:B}",
|
|
"KTAPConnect:1:127.0.0.1:30001:0",
|
|
"GetScenePlayerOnChannel:9",
|
|
"Disconnect",
|
|
"Release:Engine",
|
|
"Release:EventHandler"
|
|
},
|
|
log.Names);
|
|
Assert.Equal(1, log.Names.Count(name => name == "Disconnect"));
|
|
Assert.Single(log.ThreadIds.Distinct());
|
|
Assert.All(log.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Connect_WhenKtapInvocationThrows_AttemptsDisconnectAndPreservesOriginalFailure()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
|
|
var log = new FakeComLog();
|
|
var originalFailure = new InvalidOperationException("fake original KTAP failure");
|
|
var engine = new FakeEngine(
|
|
log,
|
|
new FakePlayer(log),
|
|
new FakeScene(log),
|
|
connectFailure: originalFailure);
|
|
var activator = new FakeActivator(log, engine);
|
|
var releaser = new FakeReleaser(
|
|
log,
|
|
(engine, "Engine"),
|
|
(activator.EventHandler, "EventHandler"));
|
|
await using var dispatcher = new StaDispatcher(capacity: 1);
|
|
var lastKtapConnectState = PlayoutKtapConnectState.NotAttempted;
|
|
|
|
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => dispatcher.InvokeAsync(
|
|
() =>
|
|
{
|
|
using var session = new DynamicK3dSession(activator, releaser);
|
|
try
|
|
{
|
|
session.Connect(options);
|
|
}
|
|
finally
|
|
{
|
|
lastKtapConnectState = session.LastKtapConnectState;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None));
|
|
|
|
Assert.Same(originalFailure, exception);
|
|
Assert.Equal(PlayoutKtapConnectState.Failed, lastKtapConnectState);
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
$"Create:{K3dComConstants.KaEventHandlerClassGuid:B}",
|
|
$"Create:{K3dComConstants.KaEngineClassGuid:B}",
|
|
"KTAPConnect:1:127.0.0.1:30001:0",
|
|
"Disconnect",
|
|
"Release:Engine",
|
|
"Release:EventHandler"
|
|
},
|
|
log.Names);
|
|
Assert.Equal(1, log.Names.Count(name => name == "Disconnect"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuardedConnect_WhenFinalTargetCheckFails_BlocksKtapAndNeverDisconnects()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
|
|
var log = new FakeComLog();
|
|
var engine = new FakeEngine(log, new FakePlayer(log), new FakeScene(log));
|
|
var activator = new FakeActivator(log, engine);
|
|
var releaser = new FakeReleaser(
|
|
log,
|
|
(engine, "Engine"),
|
|
(activator.EventHandler, "EventHandler"));
|
|
await using var dispatcher = new StaDispatcher(capacity: 1);
|
|
|
|
await dispatcher.InvokeAsync(
|
|
() =>
|
|
{
|
|
using var session = new DynamicK3dSession(activator, releaser);
|
|
Assert.Throws<K3dConnectSafetyException>(() => session.ConnectGuarded(
|
|
options,
|
|
() =>
|
|
{
|
|
log.Add("FinalSafetyCheck");
|
|
return false;
|
|
},
|
|
new FakeK3dSdkDispatchLatch()));
|
|
Assert.Equal(
|
|
PlayoutKtapConnectState.NotAttempted,
|
|
session.LastKtapConnectState);
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
$"Create:{K3dComConstants.KaEventHandlerClassGuid:B}",
|
|
$"Create:{K3dComConstants.KaEngineClassGuid:B}",
|
|
"FinalSafetyCheck",
|
|
"Release:Engine",
|
|
"Release:EventHandler"
|
|
},
|
|
log.Names);
|
|
Assert.DoesNotContain(log.Names, name =>
|
|
name.StartsWith("KTAPConnect:", StringComparison.Ordinal));
|
|
Assert.DoesNotContain("Disconnect", log.Names);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GuardedConnect_WhenKtapThrows_ReleasesWithoutSdkDisconnect()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
|
|
var log = new FakeComLog();
|
|
var failure = new COMException("fake ambiguous guarded connect failure");
|
|
var engine = new FakeEngine(
|
|
log,
|
|
new FakePlayer(log),
|
|
new FakeScene(log),
|
|
connectFailure: failure);
|
|
var activator = new FakeActivator(log, engine);
|
|
var releaser = new FakeReleaser(
|
|
log,
|
|
(engine, "Engine"),
|
|
(activator.EventHandler, "EventHandler"));
|
|
await using var dispatcher = new StaDispatcher(capacity: 1);
|
|
|
|
await dispatcher.InvokeAsync(
|
|
() =>
|
|
{
|
|
using var session = new DynamicK3dSession(activator, releaser);
|
|
var exception = Assert.Throws<COMException>(
|
|
() => session.ConnectGuarded(
|
|
options,
|
|
() => true,
|
|
new FakeK3dSdkDispatchLatch()));
|
|
Assert.Same(failure, exception);
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None);
|
|
|
|
Assert.Contains("KTAPConnect:1:127.0.0.1:30001:0", log.Names);
|
|
Assert.DoesNotContain("Disconnect", log.Names);
|
|
Assert.Equal(1, log.Names.Count(name => name == "Release:Engine"));
|
|
Assert.Equal(1, log.Names.Count(name => name == "Release:EventHandler"));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(PlayoutTakeOutScope.All, "StopAll")]
|
|
[InlineData(PlayoutTakeOutScope.Layout, "CutOut:10")]
|
|
public async Task FakeLateBoundCom_UsesLegacyCallOrderOnOneStaThread(
|
|
PlayoutTakeOutScope takeOutScope,
|
|
string expectedTakeOutCall)
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
|
|
var cue = options.ResolveCue(new PlayoutCue(
|
|
"test-scene.t2s",
|
|
"test-scene",
|
|
[
|
|
new PlayoutField("headline", "safe value", true),
|
|
new PlayoutField("badge", null, false)
|
|
],
|
|
FadeDuration: 12));
|
|
var log = new FakeComLog();
|
|
var player = new FakePlayer(log);
|
|
var scene = new FakeScene(log);
|
|
var engine = new FakeEngine(log, player, scene);
|
|
var activator = new FakeActivator(log, engine);
|
|
await using var dispatcher = new StaDispatcher(capacity: 4);
|
|
|
|
await dispatcher.InvokeAsync(
|
|
() =>
|
|
{
|
|
using var session = new DynamicK3dSession(activator);
|
|
session.Connect(options);
|
|
Assert.Equal(
|
|
PlayoutKtapConnectState.AcceptedUnconfirmed,
|
|
session.LastKtapConnectState);
|
|
session.Prepare(cue, options.LayoutIndex);
|
|
session.Play(options.LayoutIndex);
|
|
session.TakeOut(options.LayoutIndex, takeOutScope);
|
|
session.Disconnect();
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
$"Create:{K3dComConstants.KaEventHandlerClassGuid:B}",
|
|
$"Create:{K3dComConstants.KaEngineClassGuid:B}",
|
|
"KTAPConnect:1:127.0.0.1:30001:0",
|
|
"GetScenePlayerOnChannel:9",
|
|
"LoadScene:test-scene",
|
|
"SetOutputChannelIndex:9",
|
|
"SetSceneEffectType:10:7:12",
|
|
"BeginTransaction",
|
|
"GetObject:headline",
|
|
"SetValue:headline:safe value",
|
|
"SetVisible:headline:1",
|
|
"GetObject:badge",
|
|
"SetVisible:badge:0",
|
|
"QueryVariables",
|
|
"EndTransactionOnChannel:9",
|
|
"Prepare:10",
|
|
"Play:10",
|
|
expectedTakeOutCall,
|
|
"Disconnect"
|
|
},
|
|
log.Names);
|
|
Assert.Single(log.ThreadIds.Distinct());
|
|
Assert.All(log.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
|
|
}
|
|
|
|
[Fact]
|
|
public 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, 10),
|
|
new PlayoutSetValue("headline", "value"),
|
|
new PlayoutSetBackgroundVideo(
|
|
"Video/background.vrv",
|
|
3,
|
|
1,
|
|
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:10:7:4",
|
|
"UseBackground:1",
|
|
"SetBackgroundTexture:background.vrv",
|
|
"SetBackgroundVideo:background.vrv:2:10",
|
|
"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_ReusesRetainedSceneAndIgnoresInvalidPlayingSceneProxy()
|
|
{
|
|
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: 19,
|
|
Mutations:
|
|
[
|
|
new PlayoutUseBackground(false),
|
|
new PlayoutSetBackgroundTexture("Video/background.vrv"),
|
|
new PlayoutSetBackgroundVideo("Video/background.vrv", 4, 10),
|
|
new PlayoutSetBackgroundVideo(
|
|
"Video/background.vrv",
|
|
8,
|
|
1,
|
|
PlayoutMutationTiming.InTransaction),
|
|
new PlayoutSetVisible("badge", false)
|
|
]));
|
|
var log = new FakeComLog();
|
|
var scene = new FakeScene(log, "test-scene");
|
|
var player = new FakePlayer(log) { PlayingScene = new object() };
|
|
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[]
|
|
{
|
|
"UseBackground:0",
|
|
"SetBackgroundTexture:background.vrv",
|
|
"SetBackgroundVideo:background.vrv:4:10",
|
|
"BeginTransaction",
|
|
"GetObject:headline",
|
|
"SetValue:headline:page two",
|
|
"SetVisible:headline:1",
|
|
"SetBackgroundVideo:background.vrv:8:1",
|
|
"GetObject:badge",
|
|
"SetVisible:badge:0",
|
|
"QueryVariables",
|
|
"EndTransactionOnChannel:9",
|
|
"Prepare:10",
|
|
"Play:10"
|
|
},
|
|
updateCalls);
|
|
Assert.Equal(1, updateCalls.Count(call => call == "Play:10"));
|
|
Assert.DoesNotContain("GetPlayingScene:10", log.Names);
|
|
Assert.DoesNotContain(updateCalls, call =>
|
|
call.StartsWith("LoadScene:", StringComparison.Ordinal) ||
|
|
call.StartsWith("SetSceneEffectType:", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateOnAir_MismatchedRetainedSceneFailsBeforeAnySdkMutation()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create(
|
|
"5001.t2s",
|
|
"5006.t2s",
|
|
"Video\\background.vrv");
|
|
var options = SceneOptions(scenes.Path, "5001", "5006");
|
|
var initialCue = SceneCue(options, "5001");
|
|
var mismatchedCue = options.ResolveCue(new PlayoutCue(
|
|
"5006.t2s",
|
|
"5006",
|
|
[new PlayoutField("headline", "must not be sent", true)],
|
|
Mutations:
|
|
[
|
|
new PlayoutUseBackground(false),
|
|
new PlayoutSetBackgroundTexture("Video/background.vrv")
|
|
]));
|
|
var log = new FakeComLog();
|
|
var retainedScene = new FakeScene(log, "5001");
|
|
var player = new FakePlayer(log) { PlayingScene = new object() };
|
|
var activator = new FakeActivator(
|
|
log,
|
|
new FakeEngine(log, player, retainedScene));
|
|
await using var dispatcher = new StaDispatcher(capacity: 2);
|
|
string[] updateCalls = [];
|
|
|
|
await dispatcher.InvokeAsync(
|
|
() =>
|
|
{
|
|
using var session = new DynamicK3dSession(activator);
|
|
session.Connect(options);
|
|
session.Prepare(initialCue, options.LayoutIndex);
|
|
session.Play(options.LayoutIndex);
|
|
|
|
var updateStart = log.Names.Length;
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => session.UpdateOnAir(mismatchedCue, options.LayoutIndex));
|
|
updateCalls = log.Names.Skip(updateStart).ToArray();
|
|
session.Disconnect();
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None);
|
|
|
|
Assert.Empty(updateCalls);
|
|
Assert.DoesNotContain("GetPlayingScene:10", log.Names);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateOnAir_WithoutRetainedCurrentSceneFailsBeforeAnySdkMutation()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
|
var options = SceneOptions(scenes.Path, "5001");
|
|
var cue = SceneCue(options, "5001");
|
|
var log = new FakeComLog();
|
|
var player = new FakePlayer(log) { PlayingScene = new object() };
|
|
var scene = new FakeScene(log, "5001");
|
|
var activator = new FakeActivator(log, new FakeEngine(log, player, scene));
|
|
await using var dispatcher = new StaDispatcher(capacity: 2);
|
|
string[] updateCalls = [];
|
|
|
|
await dispatcher.InvokeAsync(
|
|
() =>
|
|
{
|
|
using var session = new DynamicK3dSession(activator);
|
|
session.Connect(options);
|
|
|
|
var updateStart = log.Names.Length;
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => session.UpdateOnAir(cue, options.LayoutIndex));
|
|
updateCalls = log.Names.Skip(updateStart).ToArray();
|
|
session.Disconnect();
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None);
|
|
|
|
Assert.Empty(updateCalls);
|
|
Assert.DoesNotContain("GetPlayingScene:10", log.Names);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateOnAir_WithAnotherPreparedSceneFailsBeforeAnySdkMutation()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
|
|
var options = SceneOptions(scenes.Path, "5001", "5006");
|
|
var cue5001 = SceneCue(options, "5001");
|
|
var log = new FakeComLog();
|
|
var player = new FakePlayer(log) { PlayingScene = new object() };
|
|
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);
|
|
await using var dispatcher = new StaDispatcher(capacity: 2);
|
|
string[] updateCalls = [];
|
|
|
|
await dispatcher.InvokeAsync(
|
|
() =>
|
|
{
|
|
using var session = new DynamicK3dSession(activator);
|
|
session.Connect(options);
|
|
session.Prepare(cue5001, options.LayoutIndex);
|
|
session.Play(options.LayoutIndex);
|
|
session.Prepare(SceneCue(options, "5006"), options.LayoutIndex);
|
|
|
|
var updateStart = log.Names.Length;
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => session.UpdateOnAir(cue5001, options.LayoutIndex));
|
|
updateCalls = log.Names.Skip(updateStart).ToArray();
|
|
session.Disconnect();
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None);
|
|
|
|
Assert.Empty(updateCalls);
|
|
Assert.DoesNotContain("GetPlayingScene:10", log.Names);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateOnAir_WithPendingInitialPlayFailsBeforeAnySdkMutation()
|
|
{
|
|
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 = new object() };
|
|
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);
|
|
string[] updateCalls = [];
|
|
|
|
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);
|
|
Assert.True(session.HasPendingPlayCallbacks);
|
|
|
|
var updateStart = log.Names.Length;
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => session.UpdateOnAir(cue, options.LayoutIndex));
|
|
updateCalls = log.Names.Skip(updateStart).ToArray();
|
|
|
|
callbacks.Fire("OnScenePlayed", 1, 9, options.LayoutIndex);
|
|
session.ProcessPendingCallbacks(options.LayoutIndex);
|
|
Assert.False(session.HasPendingPlayCallbacks);
|
|
session.Disconnect();
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None);
|
|
|
|
Assert.Empty(updateCalls);
|
|
Assert.DoesNotContain("GetPlayingScene:10", log.Names);
|
|
Assert.Equal(1, log.Names.Count(name => name == "Release:5001"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateOnAir_TransactionFailureRollsBackAndRetainsSceneForStopAllCleanup()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
|
var options = SceneOptions(scenes.Path, "5001");
|
|
var initialCue = SceneCue(options, "5001");
|
|
var updateCue = options.ResolveCue(new PlayoutCue(
|
|
"5001.t2s",
|
|
"5001",
|
|
[new PlayoutField("headline", "refresh", true)],
|
|
Mutations: [new PlayoutUseBackground(false)]));
|
|
var log = new FakeComLog();
|
|
var scene = new FakeScene(log, "5001");
|
|
var player = new FakePlayer(log) { PlayingScene = new object() };
|
|
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(initialCue, options.LayoutIndex);
|
|
session.Play(options.LayoutIndex);
|
|
callbacks.Fire("OnScenePlayed", 1, 9, options.LayoutIndex);
|
|
session.ProcessPendingCallbacks(options.LayoutIndex);
|
|
|
|
scene.QueryVariablesFailure = new InvalidOperationException("fake refresh failure");
|
|
var updateStart = log.Names.Length;
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => session.UpdateOnAir(updateCue, options.LayoutIndex));
|
|
var updateCalls = log.Names.Skip(updateStart).ToArray();
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"UseBackground:0",
|
|
"BeginTransaction",
|
|
"GetObject:headline",
|
|
"SetValue:headline:refresh",
|
|
"SetVisible:headline:1",
|
|
"QueryVariables",
|
|
"RollbackTransaction"
|
|
},
|
|
updateCalls);
|
|
Assert.DoesNotContain("Release:5001", log.Names);
|
|
Assert.DoesNotContain("Unload:5001", log.Names);
|
|
|
|
scene.QueryVariablesFailure = null;
|
|
session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
|
|
Assert.True(session.HasPendingTakeOutCallbacks);
|
|
callbacks.Fire("OnStopAll", 1);
|
|
Assert.Equal(
|
|
1,
|
|
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
|
|
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 == "Release:5001"));
|
|
Assert.DoesNotContain("GetPlayingScene:10", log.Names);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateOnAir_CallbackSession_TracksPlayAndRetainsSceneUntilStopAll()
|
|
{
|
|
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 = new object() };
|
|
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);
|
|
Assert.DoesNotContain("Release:5001", log.Names);
|
|
Assert.DoesNotContain("Unload:5001", log.Names);
|
|
|
|
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.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
|
|
callbacks.Fire("OnStopAll", 1);
|
|
Assert.Equal(
|
|
1,
|
|
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
|
|
Assert.False(session.HasPendingLifecycleCallbacks);
|
|
Assert.False(session.HasPendingTakeOutCallbacks);
|
|
session.Disconnect();
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(2, log.Names.Count(name => name == "Play:10"));
|
|
Assert.DoesNotContain("GetPlayingScene:10", log.Names);
|
|
Assert.Equal(1, log.Names.Count(name => name == "Unload:5001"));
|
|
Assert.Equal(1, log.Names.Count(name => name == "Release:5001"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Prepare_SamePreparedScene_ReusesSceneWithoutSecondLoad()
|
|
{
|
|
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 releaser = new FakeReleaser(
|
|
log,
|
|
(scene, "5001"),
|
|
(player, "Player"),
|
|
(engine, "Engine"),
|
|
(activator.EventHandler, "EventHandler"));
|
|
await using var dispatcher = new StaDispatcher(capacity: 2);
|
|
|
|
await dispatcher.InvokeAsync(
|
|
() =>
|
|
{
|
|
using var session = new DynamicK3dSession(activator, releaser);
|
|
session.Connect(options);
|
|
session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
|
|
session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
|
|
session.Disconnect();
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(1, log.Names.Count(name => name == "LoadScene:5001"));
|
|
Assert.Equal(2, log.Names.Count(name => name == "Prepare:10"));
|
|
Assert.DoesNotContain("Unload:5001", log.Names);
|
|
Assert.Equal(1, log.Names.Count(name => name == "Release:5001"));
|
|
}
|
|
|
|
[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 LifecycleCallbacks_CurrentSameNamePrepare_ReusesActiveSceneUntilStopAll()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
|
var options = SceneOptions(scenes.Path, "5001");
|
|
var log = new FakeComLog();
|
|
var player = new FakePlayer(log);
|
|
var activeScene = new FakeScene(log, "5001");
|
|
var engine = new FakeEngine(log, player, activeScene);
|
|
var activator = new FakeActivator(log, engine);
|
|
var callbacks = new RecordingEventHandlerFactory();
|
|
var releaser = new FakeReleaser(
|
|
log,
|
|
(activeScene, "5001"),
|
|
(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);
|
|
|
|
session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
|
|
session.Play(options.LayoutIndex);
|
|
callbacks.Fire("OnScenePlayed", 1, 9, options.LayoutIndex);
|
|
Assert.Equal(
|
|
0,
|
|
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
|
|
|
|
session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
|
|
session.Play(options.LayoutIndex);
|
|
callbacks.Fire("OnScenePlayed", 1, 9, options.LayoutIndex);
|
|
Assert.Equal(
|
|
0,
|
|
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
|
|
Assert.DoesNotContain("Unload:5001", log.Names);
|
|
Assert.DoesNotContain("Release:5001", log.Names);
|
|
Assert.Equal(1, log.Names.Count(name => name == "LoadScene:5001"));
|
|
|
|
session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
|
|
callbacks.Fire("OnStopAll", 1);
|
|
Assert.Equal(
|
|
1,
|
|
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
|
|
Assert.Equal(1, log.Names.Count(name => name == "Unload:5001"));
|
|
Assert.Contains("Release:5001", log.Names);
|
|
session.Disconnect();
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(1, log.Names.Count(name => name == "LoadScene:5001"));
|
|
Assert.Equal(1, log.Names.Count(name => name == "Unload:5001"));
|
|
Assert.Equal(1, log.Names.Count(name => name == "Release:5001"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Prepare_CurrentSceneReuse_IgnoresSceneNameCasing()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("SceneA.t2s");
|
|
var options = SceneOptions(scenes.Path, "SceneA");
|
|
var log = new FakeComLog();
|
|
var player = new FakePlayer(log);
|
|
var activeScene = new FakeScene(log, "SceneA");
|
|
var engine = new FakeEngine(log, player, activeScene);
|
|
var activator = new FakeActivator(log, engine);
|
|
var callbacks = new RecordingEventHandlerFactory();
|
|
var releaser = new FakeReleaser(
|
|
log,
|
|
(activeScene, "SceneA"),
|
|
(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);
|
|
|
|
session.Prepare(SceneCue(options, "SceneA"), options.LayoutIndex);
|
|
session.Play(options.LayoutIndex);
|
|
callbacks.Fire("OnScenePlayed", 1, 9, options.LayoutIndex);
|
|
session.ProcessPendingCallbacks(options.LayoutIndex);
|
|
|
|
session.Prepare(SceneCue(options, "scenea"), options.LayoutIndex);
|
|
session.Play(options.LayoutIndex);
|
|
callbacks.Fire("OnScenePlayed", 1, 9, options.LayoutIndex);
|
|
Assert.Equal(
|
|
0,
|
|
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
|
|
Assert.DoesNotContain("Unload:SceneA", log.Names);
|
|
Assert.DoesNotContain("Release:SceneA", log.Names);
|
|
Assert.Equal(1, log.Names.Count(name => name == "LoadScene:SceneA"));
|
|
Assert.DoesNotContain("LoadScene:scenea", log.Names);
|
|
|
|
session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
|
|
callbacks.Fire("OnStopAll", 1);
|
|
Assert.Equal(
|
|
1,
|
|
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
|
|
session.Disconnect();
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(1, log.Names.Count(name => name == "LoadScene:SceneA"));
|
|
Assert.DoesNotContain("LoadScene:scenea", log.Names);
|
|
Assert.Equal(1, log.Names.Count(name => name == "Unload:SceneA"));
|
|
Assert.Equal(1, log.Names.Count(name => name == "Release:SceneA"));
|
|
}
|
|
|
|
[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()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var unchanneledOptions = TestOptions(scenes.Path);
|
|
unchanneledOptions.Mode = PlayoutMode.Live;
|
|
unchanneledOptions.OutputChannel = null;
|
|
var options = ValidatedPlayoutOptions.Create(unchanneledOptions);
|
|
var cue = options.ResolveCue(new PlayoutCue(
|
|
"test-scene.t2s",
|
|
"test-scene",
|
|
[],
|
|
FadeDuration: 12));
|
|
var log = new FakeComLog();
|
|
var player = new FakePlayer(log);
|
|
var scene = new FakeScene(log);
|
|
var engine = new FakeEngine(log, player, scene);
|
|
var activator = new FakeActivator(log, engine);
|
|
await using var dispatcher = new StaDispatcher(capacity: 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[]
|
|
{
|
|
$"Create:{K3dComConstants.KaEventHandlerClassGuid:B}",
|
|
$"Create:{K3dComConstants.KaEngineClassGuid:B}",
|
|
"KTAPConnect:1:127.0.0.1:30001:0",
|
|
"GetScenePlayer",
|
|
"LoadScene:test-scene",
|
|
"SetSceneEffectType:10:7:12",
|
|
"BeginTransaction",
|
|
"QueryVariables",
|
|
"EndTransaction",
|
|
"Prepare:10",
|
|
"Disconnect"
|
|
},
|
|
log.Names);
|
|
Assert.DoesNotContain(log.Names, name => name.StartsWith("SetOutputChannelIndex:", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SceneReplacement_5001To5006_RetainsAllScenesUntilDisconnect()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
|
|
var options = SceneOptions(scenes.Path, "5001", "5006");
|
|
var cue5001 = SceneCue(options, "5001");
|
|
var cue5006 = SceneCue(options, "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 releaser = new FakeReleaser(
|
|
log,
|
|
(scene5001, "5001"),
|
|
(scene5006, "5006"),
|
|
(player, "Player"),
|
|
(engine, "Engine"),
|
|
(activator.EventHandler, "EventHandler"));
|
|
await using var dispatcher = new StaDispatcher(capacity: 4);
|
|
|
|
await dispatcher.InvokeAsync(
|
|
() =>
|
|
{
|
|
using var session = new DynamicK3dSession(activator, releaser);
|
|
session.Connect(options);
|
|
session.Prepare(cue5001, options.LayoutIndex);
|
|
session.Play(options.LayoutIndex);
|
|
session.Prepare(cue5006, options.LayoutIndex);
|
|
Assert.DoesNotContain("Unload:5001", log.Names);
|
|
session.Play(options.LayoutIndex);
|
|
session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
|
|
Assert.DoesNotContain(log.Names, name =>
|
|
name.StartsWith("Release:500", StringComparison.Ordinal));
|
|
session.Disconnect();
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"LoadScene:5001",
|
|
"Prepare:10",
|
|
"Play:10",
|
|
"LoadScene:5006",
|
|
"Prepare:10",
|
|
"Play:10",
|
|
"StopAll",
|
|
"Disconnect",
|
|
"Release:5001",
|
|
"Release:5006"
|
|
},
|
|
SceneLifecycleCalls(log));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Prepare_ReplacingUnplayedScene_RetainsBothScenesUntilDisconnect()
|
|
{
|
|
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 releaser = new FakeReleaser(
|
|
log,
|
|
(scene5001, "5001"),
|
|
(scene5006, "5006"),
|
|
(player, "Player"),
|
|
(engine, "Engine"),
|
|
(activator.EventHandler, "EventHandler"));
|
|
await using var dispatcher = new StaDispatcher(capacity: 2);
|
|
|
|
await dispatcher.InvokeAsync(
|
|
() =>
|
|
{
|
|
using var session = new DynamicK3dSession(activator, releaser);
|
|
session.Connect(options);
|
|
session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
|
|
session.Prepare(SceneCue(options, "5006"), options.LayoutIndex);
|
|
Assert.DoesNotContain(log.Names, name =>
|
|
name.StartsWith("Release:500", StringComparison.Ordinal));
|
|
session.Disconnect();
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"LoadScene:5001",
|
|
"Prepare:10",
|
|
"LoadScene:5006",
|
|
"Prepare:10",
|
|
"Disconnect",
|
|
"Release:5006",
|
|
"Release:5001"
|
|
},
|
|
SceneLifecycleCalls(log));
|
|
Assert.DoesNotContain("Unload:5006", log.Names);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Prepare_WhenReplacementFails_RetainsFailedAndOnAirScenesUntilDisconnect()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
|
|
var options = SceneOptions(scenes.Path, "5001", "5006");
|
|
var originalFailure = new COMException("fake replacement failure");
|
|
var log = new FakeComLog();
|
|
var player = new FakePlayer(log);
|
|
var scene5001 = new FakeScene(log, "5001");
|
|
var scene5006 = new FakeScene(
|
|
log,
|
|
"5006",
|
|
queryVariablesFailure: originalFailure);
|
|
var engine = new FakeEngine(log, player, scene5001)
|
|
{
|
|
SceneResolver = name => name == "5006" ? scene5006 : scene5001
|
|
};
|
|
var activator = new FakeActivator(log, engine);
|
|
var releaser = new FakeReleaser(
|
|
log,
|
|
(scene5001, "5001"),
|
|
(scene5006, "5006"),
|
|
(player, "Player"),
|
|
(engine, "Engine"),
|
|
(activator.EventHandler, "EventHandler"));
|
|
await using var dispatcher = new StaDispatcher(capacity: 3);
|
|
|
|
await dispatcher.InvokeAsync(
|
|
() =>
|
|
{
|
|
using var session = new DynamicK3dSession(activator, releaser);
|
|
session.Connect(options);
|
|
session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
|
|
session.Play(options.LayoutIndex);
|
|
|
|
var exception = Assert.Throws<COMException>(
|
|
() => session.Prepare(SceneCue(options, "5006"), options.LayoutIndex));
|
|
Assert.Same(originalFailure, exception);
|
|
Assert.DoesNotContain("Unload:5001", log.Names);
|
|
Assert.DoesNotContain(log.Names, name =>
|
|
name.StartsWith("Release:500", StringComparison.Ordinal));
|
|
|
|
session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
|
|
Assert.DoesNotContain(log.Names, name =>
|
|
name.StartsWith("Release:500", StringComparison.Ordinal));
|
|
session.Disconnect();
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"LoadScene:5001",
|
|
"Prepare:10",
|
|
"Play:10",
|
|
"LoadScene:5006",
|
|
"RollbackTransaction",
|
|
"StopAll",
|
|
"Disconnect",
|
|
"Release:5006",
|
|
"Release:5001"
|
|
},
|
|
SceneLifecycleCalls(log));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AsyncOutputSequence_DoesNotUnloadAndReleasesScenesOnlyAfterDisconnect()
|
|
{
|
|
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 releaser = new FakeReleaser(
|
|
log,
|
|
(scene5001, "5001"),
|
|
(scene5006, "5006"),
|
|
(player, "Player"),
|
|
(engine, "Engine"),
|
|
(activator.EventHandler, "EventHandler"));
|
|
await using var dispatcher = new StaDispatcher(capacity: 4);
|
|
|
|
await dispatcher.InvokeAsync(
|
|
() =>
|
|
{
|
|
using var session = new DynamicK3dSession(activator, releaser);
|
|
session.Connect(options);
|
|
session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
|
|
session.Play(options.LayoutIndex);
|
|
session.Prepare(SceneCue(options, "5006"), options.LayoutIndex);
|
|
session.Play(options.LayoutIndex);
|
|
session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
|
|
Assert.DoesNotContain(log.Names, name =>
|
|
name.StartsWith("Release:500", StringComparison.Ordinal));
|
|
session.Disconnect();
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal(2, log.Names.Count(name => name == "Play:10"));
|
|
Assert.Equal(1, log.Names.Count(name => name == "StopAll"));
|
|
Assert.DoesNotContain(log.Names, name => name.StartsWith("Unload", StringComparison.Ordinal));
|
|
Assert.Equal(1, log.Names.Count(name => name == "Release:5001"));
|
|
Assert.Equal(1, log.Names.Count(name => name == "Release:5006"));
|
|
}
|
|
|
|
[Fact]
|
|
public void LateBoundActivatorContract_AcceptsOnlyAClsid()
|
|
{
|
|
var create = typeof(ILateBoundComActivator).GetMethod(nameof(ILateBoundComActivator.Create));
|
|
|
|
Assert.NotNull(create);
|
|
Assert.Equal(typeof(Guid), Assert.Single(create.GetParameters()).ParameterType);
|
|
Assert.Equal(typeof(object), create.ReturnType);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Connect_WhenComActivationFails_ReportsKtapNotAttempted()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
|
|
var originalFailure = new InvalidOperationException("fake activation failure");
|
|
var lastKtapConnectState = PlayoutKtapConnectState.Failed;
|
|
await using var dispatcher = new StaDispatcher(capacity: 1);
|
|
|
|
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => dispatcher.InvokeAsync(
|
|
() =>
|
|
{
|
|
using var session = new DynamicK3dSession(
|
|
new ThrowingActivator(originalFailure));
|
|
try
|
|
{
|
|
session.Connect(options);
|
|
}
|
|
finally
|
|
{
|
|
lastKtapConnectState = session.LastKtapConnectState;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None));
|
|
|
|
Assert.Same(originalFailure, exception);
|
|
Assert.Equal(PlayoutKtapConnectState.NotAttempted, lastKtapConnectState);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PreventKtapConnect_DuringActivation_BlocksLateDispatch()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
using var activationStarted = new ManualResetEventSlim();
|
|
using var releaseActivation = new ManualResetEventSlim();
|
|
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
|
|
var log = new FakeComLog();
|
|
var engine = new FakeEngine(log, new FakePlayer(log), new FakeScene(log));
|
|
var activator = new BlockingEngineActivator(
|
|
log,
|
|
engine,
|
|
activationStarted,
|
|
releaseActivation);
|
|
var session = new DynamicK3dSession(activator);
|
|
await using var dispatcher = new StaDispatcher(capacity: 1);
|
|
|
|
try
|
|
{
|
|
var connectTask = dispatcher.InvokeAsync(
|
|
() =>
|
|
{
|
|
using (session)
|
|
{
|
|
session.Connect(options);
|
|
}
|
|
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None);
|
|
Assert.True(activationStarted.Wait(TimeSpan.FromSeconds(2)));
|
|
|
|
Assert.True(session.TryPreventKtapConnect());
|
|
releaseActivation.Set();
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => connectTask);
|
|
Assert.Equal(
|
|
PlayoutKtapConnectState.NotAttempted,
|
|
session.LastKtapConnectState);
|
|
Assert.DoesNotContain(log.Names, name =>
|
|
name.StartsWith("KTAPConnect:", StringComparison.Ordinal));
|
|
}
|
|
finally
|
|
{
|
|
releaseActivation.Set();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Abandon_AfterUnknownPlayOutcome_ReleasesReferencesWithoutSdkCleanup()
|
|
{
|
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
|
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
|
|
var cue = SceneCue(options, "test-scene");
|
|
var playFailure = new COMException("fake ambiguous play failure");
|
|
var log = new FakeComLog();
|
|
var player = new FakePlayer(log, playFailure);
|
|
var scene = new FakeScene(log);
|
|
var engine = new FakeEngine(log, player, scene);
|
|
var activator = new FakeActivator(log, engine);
|
|
var releaser = new FakeReleaser(
|
|
log,
|
|
(scene, "Scene"),
|
|
(player, "Player"),
|
|
(engine, "Engine"),
|
|
(activator.EventHandler, "EventHandler"));
|
|
await using var dispatcher = new StaDispatcher(capacity: 1);
|
|
|
|
await dispatcher.InvokeAsync(
|
|
() =>
|
|
{
|
|
using var session = new DynamicK3dSession(activator, releaser);
|
|
session.Connect(options);
|
|
session.Prepare(cue, options.LayoutIndex);
|
|
var exception = Assert.Throws<COMException>(
|
|
() => session.Play(options.LayoutIndex));
|
|
Assert.Same(playFailure, exception);
|
|
session.Abandon();
|
|
return true;
|
|
},
|
|
TimeSpan.FromSeconds(5),
|
|
CancellationToken.None);
|
|
|
|
Assert.DoesNotContain("Disconnect", log.Names);
|
|
Assert.DoesNotContain(log.Names, name => name.StartsWith("Unload", StringComparison.Ordinal));
|
|
Assert.Equal(1, log.Names.Count(name => name == "Play:10"));
|
|
Assert.Equal(1, log.Names.Count(name => name == "Release:Scene"));
|
|
Assert.Equal(1, log.Names.Count(name => name == "Release:Player"));
|
|
Assert.Equal(1, log.Names.Count(name => name == "Release:Engine"));
|
|
Assert.Equal(1, log.Names.Count(name => name == "Release:EventHandler"));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("mutation")]
|
|
[InlineData("query")]
|
|
[InlineData("end")]
|
|
public async Task GateA_PrepareStageFailureNeverRollsBackDisconnectsOrRetries(
|
|
string failureStage)
|
|
{
|
|
const string capability =
|
|
"0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
|
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
|
var rawOptions = new PlayoutOptions
|
|
{
|
|
Mode = PlayoutMode.Live,
|
|
Host = "127.0.0.1",
|
|
Port = 30001,
|
|
TcpMode = 1,
|
|
LayoutIndex = 10,
|
|
SceneDirectory = scenes.Path,
|
|
TestSceneAllowlist = ["5001"],
|
|
TrustedLiveOutputEnabled = true,
|
|
ReconnectEnabled = false,
|
|
MaximumReconnectAttempts = 0,
|
|
MaximumAutomaticRefreshesPerTakeIn = 0,
|
|
LegacySceneFadeDuration = 6,
|
|
ConnectTimeoutMilliseconds = 500,
|
|
OperationTimeoutMilliseconds = 500,
|
|
DisconnectTimeoutMilliseconds = 500,
|
|
ProcessPollIntervalMilliseconds = 100
|
|
};
|
|
var options = ValidatedPlayoutOptions.Create(rawOptions);
|
|
var failure = new COMException($"fake Gate A {failureStage} failure");
|
|
var log = new FakeComLog();
|
|
var scene = new FakeScene(log, "5001");
|
|
var player = new FakePlayer(log);
|
|
var vendorEngine = new FakeEngine(log, player, scene);
|
|
switch (failureStage)
|
|
{
|
|
case "mutation":
|
|
scene.GetObjectFailure = failure;
|
|
break;
|
|
case "query":
|
|
scene.QueryVariablesFailure = failure;
|
|
break;
|
|
case "end":
|
|
vendorEngine.EndTransactionFailure = failure;
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException("Unknown test failure stage.");
|
|
}
|
|
|
|
var activator = new FakeActivator(log, vendorEngine);
|
|
var releaser = new FakeReleaser(
|
|
log,
|
|
(scene, "5001"),
|
|
(player, "Player"),
|
|
(vendorEngine, "Engine"),
|
|
(activator.EventHandler, "EventHandler"));
|
|
var sessionFactory = new DelegateK3dSessionFactory(() =>
|
|
new DynamicK3dSession(
|
|
activator,
|
|
releaser,
|
|
new InstalledK3dInteropMethodInvoker(),
|
|
new ActivatorK3dEventHandlerFactory(activator),
|
|
rollbackOnPrepareFailure: false));
|
|
using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
|
|
capability);
|
|
var dispatcher = new StaDispatcher(options.QueueCapacity);
|
|
var engine = new TornadoPlayoutEngine(
|
|
options,
|
|
sessionFactory,
|
|
new FakeRegistrationProbe(),
|
|
new FakeTornadoProcessProbe(
|
|
ProcessSnapshot("approved-pgm"),
|
|
ProcessSnapshot("approved-pgm")),
|
|
dispatcher,
|
|
authorization,
|
|
TimeProvider.System,
|
|
startMonitor: false,
|
|
finalConnectSafetyCheck: () => true,
|
|
abandonOnTargetMismatch: true,
|
|
beforeSdkDispatchClaim: authorization.EnsureGateASdkDispatchAuthorized,
|
|
launchAuthorization: authorization);
|
|
|
|
try
|
|
{
|
|
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
|
Assert.True(authorization.TryClaimGateAPrepare(capability));
|
|
var cue = new PlayoutCue(
|
|
"5001.t2s",
|
|
"5001",
|
|
[new PlayoutField("headline", "test", true)],
|
|
FadeDuration: 6,
|
|
Mutations: [new PlayoutUseBackground(false)]);
|
|
|
|
var result = await engine.PrepareAsync(cue, CancellationToken.None);
|
|
var retry = await engine.PrepareAsync(cue, CancellationToken.None);
|
|
|
|
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
|
|
Assert.Equal(PlayoutResultCode.OutcomeUnknown, retry.Code);
|
|
}
|
|
finally
|
|
{
|
|
authorization.CompleteGateAPrepare();
|
|
await engine.DisposeAsync();
|
|
}
|
|
|
|
Assert.Equal(1, log.Names.Count(name => name == "BeginTransaction"));
|
|
Assert.DoesNotContain("RollbackTransaction", log.Names);
|
|
Assert.DoesNotContain("Disconnect", log.Names);
|
|
Assert.DoesNotContain("Prepare:10", log.Names);
|
|
Assert.Equal(1, log.Names.Count(name => name == "Release:5001"));
|
|
Assert.Equal(1, log.Names.Count(name => name == "Release:Player"));
|
|
Assert.Equal(1, log.Names.Count(name => name == "Release:Engine"));
|
|
Assert.Equal(1, log.Names.Count(name => name == "Release:EventHandler"));
|
|
}
|
|
|
|
private static TornadoProcessSnapshot ProcessSnapshot(string generation) =>
|
|
new(1, 1, 0)
|
|
{
|
|
EligibleProcessGeneration = new TornadoProcessGeneration(generation)
|
|
};
|
|
|
|
private static PlayoutOptions TestOptions(string sceneDirectory) => new()
|
|
{
|
|
Mode = PlayoutMode.Test,
|
|
SceneDirectory = sceneDirectory,
|
|
OutputChannel = 9,
|
|
TestProcessWindowTitlePattern = "^Tornado2 TEST$",
|
|
TestSceneAllowlist = ["test-scene"]
|
|
};
|
|
|
|
private static ValidatedPlayoutOptions SceneOptions(
|
|
string sceneDirectory,
|
|
params string[] sceneNames)
|
|
{
|
|
var options = TestOptions(sceneDirectory);
|
|
options.TestSceneAllowlist = [.. sceneNames];
|
|
return ValidatedPlayoutOptions.Create(options);
|
|
}
|
|
|
|
private static PlayoutCue SceneCue(ValidatedPlayoutOptions options, string sceneName) =>
|
|
options.ResolveCue(new PlayoutCue(
|
|
$"{sceneName}.t2s",
|
|
sceneName,
|
|
[],
|
|
FadeDuration: 0));
|
|
|
|
private static string[] SceneLifecycleCalls(FakeComLog log) =>
|
|
log.Names.Where(name =>
|
|
name.StartsWith("LoadScene:", StringComparison.Ordinal) ||
|
|
name == "Prepare:10" ||
|
|
name == "Play:10" ||
|
|
name == "StopAll" ||
|
|
name == "CutOut:10" ||
|
|
name == "Disconnect" ||
|
|
name == "RollbackTransaction" ||
|
|
name.StartsWith("Unload:", StringComparison.Ordinal) ||
|
|
name.StartsWith("Release:500", StringComparison.Ordinal)).ToArray();
|
|
|
|
private sealed class DelegateK3dSessionFactory(Func<IK3dSession> create)
|
|
: IK3dSessionFactory
|
|
{
|
|
public IK3dSession Create() => create();
|
|
}
|
|
|
|
public sealed class FakeComLog
|
|
{
|
|
private readonly ConcurrentQueue<string> _names = new();
|
|
private readonly ConcurrentQueue<int> _threadIds = new();
|
|
private readonly ConcurrentQueue<ApartmentState> _apartmentStates = new();
|
|
|
|
public string[] Names => _names.ToArray();
|
|
|
|
public int[] ThreadIds => _threadIds.ToArray();
|
|
|
|
public ApartmentState[] ApartmentStates => _apartmentStates.ToArray();
|
|
|
|
public void Add(string name)
|
|
{
|
|
_names.Enqueue(name);
|
|
_threadIds.Enqueue(Environment.CurrentManagedThreadId);
|
|
_apartmentStates.Enqueue(Thread.CurrentThread.GetApartmentState());
|
|
}
|
|
}
|
|
|
|
private sealed class FakeActivator : ILateBoundComActivator
|
|
{
|
|
private readonly FakeComLog _log;
|
|
private readonly FakeEngine _engine;
|
|
private readonly object _eventHandler = new();
|
|
|
|
public FakeActivator(FakeComLog log, FakeEngine engine)
|
|
{
|
|
_log = log;
|
|
_engine = engine;
|
|
}
|
|
|
|
public object EventHandler => _eventHandler;
|
|
|
|
public object Create(Guid classId)
|
|
{
|
|
_log.Add($"Create:{classId:B}");
|
|
return classId switch
|
|
{
|
|
var value when value == K3dComConstants.KaEventHandlerClassGuid => _eventHandler,
|
|
var value when value == K3dComConstants.KaEngineClassGuid => _engine,
|
|
_ => throw new InvalidOperationException("Unexpected fake CLSID.")
|
|
};
|
|
}
|
|
}
|
|
|
|
private sealed class ThrowingActivator : ILateBoundComActivator
|
|
{
|
|
private readonly Exception _exception;
|
|
|
|
public ThrowingActivator(Exception exception)
|
|
{
|
|
_exception = exception;
|
|
}
|
|
|
|
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;
|
|
private readonly FakeEngine _engine;
|
|
private readonly ManualResetEventSlim _activationStarted;
|
|
private readonly ManualResetEventSlim _releaseActivation;
|
|
private readonly object _eventHandler = new();
|
|
|
|
public BlockingEngineActivator(
|
|
FakeComLog log,
|
|
FakeEngine engine,
|
|
ManualResetEventSlim activationStarted,
|
|
ManualResetEventSlim releaseActivation)
|
|
{
|
|
_log = log;
|
|
_engine = engine;
|
|
_activationStarted = activationStarted;
|
|
_releaseActivation = releaseActivation;
|
|
}
|
|
|
|
public object Create(Guid classId)
|
|
{
|
|
_log.Add($"Create:{classId:B}");
|
|
if (classId == K3dComConstants.KaEventHandlerClassGuid)
|
|
{
|
|
return _eventHandler;
|
|
}
|
|
|
|
if (classId != K3dComConstants.KaEngineClassGuid)
|
|
{
|
|
throw new InvalidOperationException("Unexpected fake CLSID.");
|
|
}
|
|
|
|
_activationStarted.Set();
|
|
if (!_releaseActivation.Wait(TimeSpan.FromSeconds(5)))
|
|
{
|
|
throw new TimeoutException("fake activation coordination timeout");
|
|
}
|
|
|
|
return _engine;
|
|
}
|
|
}
|
|
|
|
public sealed class FakeEngine
|
|
{
|
|
private readonly FakeComLog _log;
|
|
private readonly FakePlayer _player;
|
|
private readonly FakeScene _scene;
|
|
private readonly int _connectResult;
|
|
private readonly Exception? _playerFailure;
|
|
private readonly Exception? _disconnectFailure;
|
|
private readonly Exception? _connectFailure;
|
|
|
|
public FakeEngine(
|
|
FakeComLog log,
|
|
FakePlayer player,
|
|
FakeScene scene,
|
|
int connectResult = 1,
|
|
Exception? playerFailure = null,
|
|
Exception? disconnectFailure = null,
|
|
Exception? connectFailure = null)
|
|
{
|
|
_log = log;
|
|
_player = player;
|
|
_scene = scene;
|
|
_connectResult = connectResult;
|
|
_playerFailure = playerFailure;
|
|
_disconnectFailure = disconnectFailure;
|
|
_connectFailure = connectFailure;
|
|
}
|
|
|
|
public Func<string, FakeScene>? SceneResolver { get; init; }
|
|
|
|
public Exception? EndTransactionFailure { get; set; }
|
|
|
|
public int KTAPConnect(
|
|
int tcpMode,
|
|
string host,
|
|
int hostPort,
|
|
int clientPort,
|
|
object eventHandler)
|
|
{
|
|
Assert.NotNull(eventHandler);
|
|
_log.Add($"KTAPConnect:{tcpMode}:{host}:{hostPort}:{clientPort}");
|
|
if (_connectFailure is not null)
|
|
{
|
|
throw _connectFailure;
|
|
}
|
|
|
|
return _connectResult;
|
|
}
|
|
|
|
public object GetScenePlayerOnChannel(int channel)
|
|
{
|
|
_log.Add($"GetScenePlayerOnChannel:{channel}");
|
|
if (_playerFailure is not null)
|
|
{
|
|
throw _playerFailure;
|
|
}
|
|
|
|
return _player;
|
|
}
|
|
|
|
public object GetScenePlayer()
|
|
{
|
|
_log.Add("GetScenePlayer");
|
|
if (_playerFailure is not null)
|
|
{
|
|
throw _playerFailure;
|
|
}
|
|
|
|
return _player;
|
|
}
|
|
|
|
public object LoadScene(string sceneFile, string sceneName)
|
|
{
|
|
Assert.True(System.IO.Path.IsPathFullyQualified(sceneFile));
|
|
_log.Add($"LoadScene:{sceneName}");
|
|
return SceneResolver?.Invoke(sceneName) ?? _scene;
|
|
}
|
|
|
|
public void BeginTransaction() => _log.Add("BeginTransaction");
|
|
|
|
public void EndTransaction()
|
|
{
|
|
_log.Add("EndTransaction");
|
|
if (EndTransactionFailure is not null)
|
|
{
|
|
throw EndTransactionFailure;
|
|
}
|
|
}
|
|
|
|
public void EndTransactionOnChannel(int channel)
|
|
{
|
|
_log.Add($"EndTransactionOnChannel:{channel}");
|
|
if (EndTransactionFailure is not null)
|
|
{
|
|
throw EndTransactionFailure;
|
|
}
|
|
}
|
|
|
|
public void RollbackTransaction() => _log.Add("RollbackTransaction");
|
|
|
|
public void Disconnect()
|
|
{
|
|
_log.Add("Disconnect");
|
|
if (_disconnectFailure is not null)
|
|
{
|
|
throw _disconnectFailure;
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class FakeReleaser : ILateBoundComObjectReleaser
|
|
{
|
|
private readonly FakeComLog _log;
|
|
private readonly (object Value, string Name)[] _objects;
|
|
|
|
public FakeReleaser(
|
|
FakeComLog log,
|
|
params (object Value, string Name)[] objects)
|
|
{
|
|
_log = log;
|
|
_objects = objects;
|
|
}
|
|
|
|
public void Release(object value)
|
|
{
|
|
var match = Assert.Single(_objects, item => ReferenceEquals(item.Value, value));
|
|
_log.Add($"Release:{match.Name}");
|
|
}
|
|
}
|
|
|
|
public sealed class FakePlayer
|
|
{
|
|
private readonly FakeComLog _log;
|
|
private readonly Exception? _playFailure;
|
|
|
|
public FakePlayer(FakeComLog log, Exception? playFailure = null)
|
|
{
|
|
_log = log;
|
|
_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);
|
|
_log.Add($"Prepare:{layoutIndex}");
|
|
}
|
|
|
|
public void Play(int layoutIndex)
|
|
{
|
|
_log.Add($"Play:{layoutIndex}");
|
|
if (_playFailure is not null)
|
|
{
|
|
throw _playFailure;
|
|
}
|
|
}
|
|
|
|
public void StopAll() => _log.Add("StopAll");
|
|
|
|
public void CutOut(int layoutIndex) => _log.Add($"CutOut:{layoutIndex}");
|
|
}
|
|
|
|
public sealed class FakeScene
|
|
{
|
|
private readonly FakeComLog _log;
|
|
private readonly string? _name;
|
|
private Exception? _queryVariablesFailure;
|
|
private readonly Exception? _unloadFailure;
|
|
|
|
public FakeScene(
|
|
FakeComLog log,
|
|
string? name = null,
|
|
Exception? queryVariablesFailure = null,
|
|
Exception? unloadFailure = null)
|
|
{
|
|
_log = log;
|
|
_name = name;
|
|
_queryVariablesFailure = queryVariablesFailure;
|
|
_unloadFailure = unloadFailure;
|
|
}
|
|
|
|
public void SetOutputChannelIndex(int channel) =>
|
|
_log.Add($"SetOutputChannelIndex:{channel}");
|
|
|
|
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}");
|
|
if (GetObjectFailure is not null)
|
|
{
|
|
throw GetObjectFailure;
|
|
}
|
|
|
|
return new FakeSceneObject(_log, objectName);
|
|
}
|
|
|
|
public void QueryVariables()
|
|
{
|
|
_log.Add("QueryVariables");
|
|
if (_queryVariablesFailure is not null)
|
|
{
|
|
throw _queryVariablesFailure;
|
|
}
|
|
}
|
|
|
|
public Exception? QueryVariablesFailure
|
|
{
|
|
get => _queryVariablesFailure;
|
|
set => _queryVariablesFailure = value;
|
|
}
|
|
|
|
public Exception? GetObjectFailure { get; set; }
|
|
|
|
public void Unload()
|
|
{
|
|
_log.Add(_name is null ? "Unload" : $"Unload:{_name}");
|
|
if (_unloadFailure is not null)
|
|
{
|
|
throw _unloadFailure;
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed class FakeSceneObject
|
|
{
|
|
private readonly FakeComLog _log;
|
|
private readonly string _name;
|
|
|
|
public FakeSceneObject(FakeComLog log, string name)
|
|
{
|
|
_log = log;
|
|
_name = name;
|
|
}
|
|
|
|
public void SetValue(string value) => _log.Add(
|
|
$"SetValue:{_name}:{(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}");
|
|
}
|
|
}
|