feat: verify Tornado PGM KTAP connection

This commit is contained in:
2026-07-10 14:28:45 +09:00
parent b113830c14
commit 930424b752
22 changed files with 3474 additions and 67 deletions

View File

@@ -1,4 +1,5 @@
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;
@@ -233,15 +234,16 @@ public sealed class DynamicK3dSessionTests
"KTAPConnect:1:127.0.0.1:30001:0",
"GetScenePlayerOnChannel:9",
"LoadScene:test-scene",
"SetSceneEffectType:10:7:12",
"SetOutputChannelIndex:9",
"SetSceneEffectType:1:7:12",
"BeginTransaction",
"GetObject:headline",
"SetValue:headline:safe value",
"SetVisible:headline:1",
"GetObject:badge",
"SetVisible:badge:0",
"EndTransactionOnChannel:9",
"QueryVariables",
"EndTransaction",
"Prepare:10",
"Play:10",
expectedTakeOutCall,
@@ -252,6 +254,285 @@ public sealed class DynamicK3dSessionTests
Assert.All(log.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
}
[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:1:7:12",
"BeginTransaction",
"EndTransaction",
"QueryVariables",
"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",
"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()
{
@@ -345,17 +626,20 @@ public sealed class DynamicK3dSessionTests
}
[Fact]
public async Task Abandon_ReleasesEveryComReferenceWithoutDisconnect()
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);
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"));
@@ -366,6 +650,10 @@ public sealed class DynamicK3dSessionTests
{
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;
},
@@ -373,6 +661,9 @@ public sealed class DynamicK3dSessionTests
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"));
@@ -387,6 +678,34 @@ public sealed class DynamicK3dSessionTests
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();
public sealed class FakeComLog
{
private readonly ConcurrentQueue<string> _names = new();
@@ -516,6 +835,8 @@ public sealed class DynamicK3dSessionTests
_connectFailure = connectFailure;
}
public Func<string, FakeScene>? SceneResolver { get; init; }
public int KTAPConnect(
int tcpMode,
string host,
@@ -544,17 +865,33 @@ public sealed class DynamicK3dSessionTests
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 _scene;
return SceneResolver?.Invoke(sceneName) ?? _scene;
}
public void BeginTransaction() => _log.Add("BeginTransaction");
public void EndTransaction() => _log.Add("EndTransaction");
public void EndTransactionOnChannel(int channel) =>
_log.Add($"EndTransactionOnChannel:{channel}");
public void RollbackTransaction() => _log.Add("RollbackTransaction");
public void Disconnect()
{
_log.Add("Disconnect");
@@ -588,10 +925,12 @@ public sealed class DynamicK3dSessionTests
public sealed class FakePlayer
{
private readonly FakeComLog _log;
private readonly Exception? _playFailure;
public FakePlayer(FakeComLog log)
public FakePlayer(FakeComLog log, Exception? playFailure = null)
{
_log = log;
_playFailure = playFailure;
}
public void Prepare(int layoutIndex, object scene)
@@ -600,7 +939,14 @@ public sealed class DynamicK3dSessionTests
_log.Add($"Prepare:{layoutIndex}");
}
public void Play(int layoutIndex) => _log.Add($"Play:{layoutIndex}");
public void Play(int layoutIndex)
{
_log.Add($"Play:{layoutIndex}");
if (_playFailure is not null)
{
throw _playFailure;
}
}
public void StopAll() => _log.Add("StopAll");
@@ -610,14 +956,27 @@ public sealed class DynamicK3dSessionTests
public sealed class FakeScene
{
private readonly FakeComLog _log;
private readonly string? _name;
private readonly Exception? _queryVariablesFailure;
private readonly Exception? _unloadFailure;
public FakeScene(FakeComLog log)
public FakeScene(
FakeComLog log,
string? name = null,
Exception? queryVariablesFailure = null,
Exception? unloadFailure = null)
{
_log = log;
_name = name;
_queryVariablesFailure = queryVariablesFailure;
_unloadFailure = unloadFailure;
}
public void SetSceneEffectType(int layoutIndex, int effectType, int duration) =>
_log.Add($"SetSceneEffectType:{layoutIndex}:{effectType}:{duration}");
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 object GetObject(string objectName)
{
@@ -625,7 +984,23 @@ public sealed class DynamicK3dSessionTests
return new FakeSceneObject(_log, objectName);
}
public void QueryVariables() => _log.Add("QueryVariables");
public void QueryVariables()
{
_log.Add("QueryVariables");
if (_queryVariablesFailure is not null)
{
throw _queryVariablesFailure;
}
}
public void Unload()
{
_log.Add(_name is null ? "Unload" : $"Unload:{_name}");
if (_unloadFailure is not null)
{
throw _unloadFailure;
}
}
}
public sealed class FakeSceneObject