feat: verify Tornado PGM KTAP connection
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Interop;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Registration;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class InstalledK3dInteropMetadataTests
|
||||
{
|
||||
[Fact]
|
||||
public void ApprovedSha256_RequiresAndDecodesExactly64HexCharacters()
|
||||
{
|
||||
var parsed = InstalledK3dInteropMetadata.ParseApprovedSha256(
|
||||
"0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789ABCDEF");
|
||||
|
||||
Assert.Equal(32, parsed.Length);
|
||||
Assert.Equal(0x01, parsed[0]);
|
||||
Assert.Equal(0xEF, parsed[^1]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde")]
|
||||
[InlineData(" 123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")]
|
||||
[InlineData("g123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")]
|
||||
public void ApprovedSha256_RejectsMissingMalformedOrWhitespaceValues(string? value)
|
||||
{
|
||||
Assert.Throws<InstalledK3dInteropMetadataUnavailableException>(
|
||||
() => InstalledK3dInteropMetadata.ParseApprovedSha256(value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApprovedSha256_RequiresExactContentForEitherBinaryPin()
|
||||
{
|
||||
byte[] content = [1, 2, 3, 4];
|
||||
var approved = Convert.ToHexString(SHA256.HashData(content));
|
||||
using var matching = new MemoryStream(content);
|
||||
using var different = new MemoryStream([1, 2, 3, 5]);
|
||||
|
||||
InstalledK3dInteropMetadata.ValidateApprovedSha256(matching, approved);
|
||||
Assert.Throws<InstalledK3dInteropMetadataUnavailableException>(
|
||||
() => InstalledK3dInteropMetadata.ValidateApprovedSha256(different, approved));
|
||||
Assert.Equal(
|
||||
"MBN_STOCK_K3D_NATIVE_SHA256",
|
||||
InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable);
|
||||
Assert.Equal(
|
||||
"MBN_STOCK_K3D_INTEROP_SHA256",
|
||||
InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FileLease_KeepsNativeAndInteropFilesWriteLocked()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"k3d-lease-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
var nativePath = Path.Combine(directory, "native.dll");
|
||||
var interopPath = Path.Combine(directory, "interop.dll");
|
||||
File.WriteAllBytes(nativePath, [1]);
|
||||
File.WriteAllBytes(interopPath, [2]);
|
||||
|
||||
InstalledK3dInteropFileLease? lease = null;
|
||||
try
|
||||
{
|
||||
lease = new InstalledK3dInteropFileLease(
|
||||
new FileStream(nativePath, FileMode.Open, FileAccess.Read, FileShare.Read),
|
||||
new FileStream(interopPath, FileMode.Open, FileAccess.Read, FileShare.Read));
|
||||
|
||||
Assert.Throws<IOException>(() => OpenForWrite(nativePath).Dispose());
|
||||
Assert.Throws<IOException>(() => OpenForWrite(interopPath).Dispose());
|
||||
}
|
||||
finally
|
||||
{
|
||||
lease?.InteropStream.Dispose();
|
||||
lease?.NativeStream.Dispose();
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisteredPaths_MapOnlyExactX64VendorLayout()
|
||||
{
|
||||
const string native = @"C:\Vendor\K3DAsyncEngine\DLL\x64\Release\K3DAsyncEngine.dll";
|
||||
var paths = InstalledK3dInteropMetadata.ResolveRegisteredPaths(ReadySnapshot(native));
|
||||
|
||||
Assert.Equal(Path.GetFullPath(native), paths.NativeBinaryPath);
|
||||
Assert.Equal(
|
||||
Path.GetFullPath(
|
||||
@"C:\Vendor\K3DAsyncEngine\Bin\x64\C#\Interop.K3DAsyncEngineLib.dll"),
|
||||
paths.InteropAssemblyPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisteredPaths_RejectDivergentClassBinary()
|
||||
{
|
||||
const string native = @"C:\Vendor\K3DAsyncEngine\DLL\x64\Release\K3DAsyncEngine.dll";
|
||||
var snapshot = ReadySnapshot(native) with
|
||||
{
|
||||
EventHandler = ReadySnapshot(native).EventHandler with
|
||||
{
|
||||
InprocServerPath =
|
||||
@"C:\Other\K3DAsyncEngine\DLL\x64\Release\K3DAsyncEngine.dll"
|
||||
}
|
||||
};
|
||||
|
||||
Assert.Throws<InstalledK3dInteropMetadataUnavailableException>(
|
||||
() => InstalledK3dInteropMetadata.ResolveRegisteredPaths(snapshot));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisteredPaths_RejectUnexpectedNativeLayout()
|
||||
{
|
||||
const string native = @"C:\Vendor\K3DAsyncEngine\bin\K3DAsyncEngine.dll";
|
||||
|
||||
Assert.Throws<InstalledK3dInteropMetadataUnavailableException>(
|
||||
() => InstalledK3dInteropMetadata.ResolveRegisteredPaths(ReadySnapshot(native)));
|
||||
}
|
||||
|
||||
private static K3dRegistrySnapshot ReadySnapshot(string nativePath) => new(
|
||||
true,
|
||||
nativePath,
|
||||
new K3dClassRegistrationSnapshot(
|
||||
true,
|
||||
K3dComConstants.KaEngineProgId,
|
||||
K3dComConstants.KaEngineClassId,
|
||||
nativePath,
|
||||
K3dComConstants.ApartmentThreadingModel,
|
||||
K3dComConstants.TypeLibraryId),
|
||||
new K3dClassRegistrationSnapshot(
|
||||
true,
|
||||
K3dComConstants.KaEventHandlerProgId,
|
||||
K3dComConstants.KaEventHandlerClassId,
|
||||
nativePath,
|
||||
K3dComConstants.ApartmentThreadingModel,
|
||||
K3dComConstants.TypeLibraryId),
|
||||
false);
|
||||
|
||||
private static FileStream OpenForWrite(string path) => new(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Write,
|
||||
FileShare.ReadWrite | FileShare.Delete);
|
||||
}
|
||||
@@ -142,7 +142,13 @@ public sealed class IsolatedTestCommandTests : IDisposable
|
||||
"Disconnect",
|
||||
"Dispose"
|
||||
], engine.Calls);
|
||||
Assert.Equal([TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)], delays);
|
||||
Assert.Equal(
|
||||
[
|
||||
TimeSpan.FromSeconds(1),
|
||||
TimeSpan.FromSeconds(1),
|
||||
TimeSpan.FromSeconds(1)
|
||||
],
|
||||
delays);
|
||||
Assert.True(report.Completed);
|
||||
Assert.True(report.ComActivationAttempted);
|
||||
Assert.Equal("accepted-unconfirmed", report.LastKtapConnectState);
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Interop;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class PgmConnectDiagnosticTests
|
||||
{
|
||||
[Fact]
|
||||
public void RealContactDiagnosticApi_IsNotPublic()
|
||||
{
|
||||
Assert.False(typeof(PgmConnectDiagnosticRunner).IsVisible);
|
||||
Assert.False(typeof(PgmConnectDiagnosticRequest).IsVisible);
|
||||
Assert.False(typeof(PgmConnectDiagnosticReport).IsVisible);
|
||||
Assert.Null(typeof(PgmConnectDiagnosticRunner).GetMethod(
|
||||
nameof(PgmConnectDiagnosticRunner.ExecuteAsync),
|
||||
System.Reflection.BindingFlags.Instance |
|
||||
System.Reflection.BindingFlags.Public));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("localhost", 30001, "PGM")]
|
||||
[InlineData("192.0.2.1", 30001, "PGM")]
|
||||
[InlineData("127.0.0.1", 0, "PGM")]
|
||||
[InlineData("127.0.0.1", 30001, "TEST")]
|
||||
[InlineData("127.0.0.1", 30001, "CONPROGRAM")]
|
||||
public void RequestValidation_RejectsUnsafeEndpointOrWindow(
|
||||
string host,
|
||||
int port,
|
||||
string title)
|
||||
{
|
||||
var valid = PgmConnectDiagnosticRunner.TryValidateRequest(
|
||||
new PgmConnectDiagnosticRequest(host, port, title),
|
||||
out _);
|
||||
|
||||
Assert.False(valid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Runner_SuccessUsesOnlyConnectThenDisconnectAndReportsUnconfirmedEvidence()
|
||||
{
|
||||
var snapshot = ReadySnapshot(10);
|
||||
var safety = new FakeSafetyProbe(snapshot, snapshot, snapshot, snapshot);
|
||||
var session = new FakeConnectOnlySession();
|
||||
var factory = new FakeConnectOnlySessionFactory(session);
|
||||
var runner = new PgmConnectDiagnosticRunner(
|
||||
safety,
|
||||
factory,
|
||||
new FakeRegistrationProbe(),
|
||||
new TrackingStaDispatcherFactory());
|
||||
var request = new PgmConnectDiagnosticRequest("127.0.0.1", 30001, "PGM");
|
||||
|
||||
var report = await runner.ExecuteAsync(request);
|
||||
|
||||
Assert.Equal(["Connect", "Disconnect"], session.Calls);
|
||||
Assert.True(report.Completed);
|
||||
Assert.False(report.OutcomeUnknown);
|
||||
Assert.True(report.ConnectRequestIssued);
|
||||
Assert.True(report.ComActivationAttempted);
|
||||
Assert.Equal(PlayoutKtapConnectState.AcceptedUnconfirmed, report.LastKtapConnectState);
|
||||
Assert.True(report.KtapConnectAttempted);
|
||||
Assert.True(report.KtapConnectAccepted);
|
||||
Assert.Null(report.KtapHelloObserved);
|
||||
Assert.True(report.NetworkMonitoringRecordExpected);
|
||||
Assert.True(report.NetworkMonitoringCheckRequired);
|
||||
Assert.Null(report.NetworkMonitoringVerified);
|
||||
Assert.Equal("success", report.DisconnectCode);
|
||||
Assert.False(report.RenderCommandSurfaceExposed);
|
||||
Assert.False(report.RenderCommandAttempted);
|
||||
Assert.False(report.AutomaticReconnectEnabled);
|
||||
|
||||
var json = JsonSerializer.Serialize(report);
|
||||
Assert.DoesNotContain("127.0.0.1", json, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("30001", json, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("PGM", json, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Runner_TargetRejectedStopsBeforeRegistrationAndCom()
|
||||
{
|
||||
var rejected = new PgmDiagnosticTargetSnapshot(false, 0, 0, false, null);
|
||||
var factory = new FakeConnectOnlySessionFactory(new FakeConnectOnlySession());
|
||||
var registration = new FakeRegistrationProbe();
|
||||
var runner = new PgmConnectDiagnosticRunner(
|
||||
new FakeSafetyProbe(rejected),
|
||||
factory,
|
||||
registration,
|
||||
new TrackingStaDispatcherFactory());
|
||||
|
||||
var report = await runner.ExecuteAsync(
|
||||
new PgmConnectDiagnosticRequest("127.0.0.1", 30001, "PGM"));
|
||||
|
||||
Assert.False(report.Completed);
|
||||
Assert.False(report.ConnectRequestIssued);
|
||||
Assert.False(report.ComActivationAttempted);
|
||||
Assert.Equal(0, factory.CreateCount);
|
||||
Assert.Equal(0, registration.ProbeCount);
|
||||
Assert.Equal("pgm-target-rejected", report.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Runner_UnapprovedInstalledInteropIsDistinctFromComActivationFailure()
|
||||
{
|
||||
var snapshot = ReadySnapshot(10);
|
||||
var runner = new PgmConnectDiagnosticRunner(
|
||||
new FakeSafetyProbe(snapshot),
|
||||
new UnavailableInteropSessionFactory(),
|
||||
new FakeRegistrationProbe(),
|
||||
new TrackingStaDispatcherFactory());
|
||||
|
||||
var report = await runner.ExecuteAsync(
|
||||
new PgmConnectDiagnosticRequest("127.0.0.1", 30001, "PGM"));
|
||||
|
||||
Assert.False(report.Completed);
|
||||
Assert.False(report.ComActivationAttempted);
|
||||
Assert.False(report.KtapConnectAttempted);
|
||||
Assert.False(report.OutcomeUnknown);
|
||||
Assert.Equal("not-attempted", report.DisconnectCode);
|
||||
Assert.Equal("installed-interop-metadata-unavailable", report.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Runner_TargetChangeAfterAcceptanceAbandonsWithoutDisconnect()
|
||||
{
|
||||
var baseline = ReadySnapshot(10);
|
||||
var changed = ReadySnapshot(11);
|
||||
var session = new FakeConnectOnlySession();
|
||||
var runner = new PgmConnectDiagnosticRunner(
|
||||
new FakeSafetyProbe(baseline, baseline, baseline, changed),
|
||||
new FakeConnectOnlySessionFactory(session),
|
||||
new FakeRegistrationProbe(),
|
||||
new TrackingStaDispatcherFactory());
|
||||
|
||||
var report = await runner.ExecuteAsync(
|
||||
new PgmConnectDiagnosticRequest("127.0.0.1", 30001, "PGM"));
|
||||
|
||||
Assert.Equal(["Connect", "Abandon"], session.Calls);
|
||||
Assert.False(report.Completed);
|
||||
Assert.True(report.OutcomeUnknown);
|
||||
Assert.True(report.KtapConnectAttempted);
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.Equal("pgm-target-changed-after-connect", report.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DynamicSession_InvokesOnlyKtapConnectAndDisconnect()
|
||||
{
|
||||
var log = new ConcurrentQueue<string>();
|
||||
var session = new DynamicKtapConnectOnlySession(new RecordingComBinding(log));
|
||||
await using var dispatcher = new Runtime.StaDispatcher(1);
|
||||
|
||||
await dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
session.Connect("127.0.0.1", 30001, static () => true);
|
||||
session.Disconnect();
|
||||
return true;
|
||||
},
|
||||
TimeSpan.FromSeconds(2),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"CreateEventHandler",
|
||||
"CreateEngine",
|
||||
"KTAPConnect",
|
||||
"Disconnect",
|
||||
"Release",
|
||||
"Release"
|
||||
],
|
||||
log);
|
||||
Assert.Equal(PlayoutKtapConnectState.AcceptedUnconfirmed, session.LastKtapConnectState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DynamicSession_FinalSafetyCheckRunsAfterActivationAndBeforeKtap()
|
||||
{
|
||||
var log = new ConcurrentQueue<string>();
|
||||
var session = new DynamicKtapConnectOnlySession(new RecordingComBinding(log));
|
||||
await using var dispatcher = new Runtime.StaDispatcher(1);
|
||||
|
||||
await Assert.ThrowsAsync<PgmDiagnosticSafetyException>(() =>
|
||||
dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
session.Connect(
|
||||
"127.0.0.1",
|
||||
30001,
|
||||
() =>
|
||||
{
|
||||
log.Enqueue("FinalSafetyCheck");
|
||||
return false;
|
||||
});
|
||||
return true;
|
||||
},
|
||||
TimeSpan.FromSeconds(2),
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"CreateEventHandler",
|
||||
"CreateEngine",
|
||||
"FinalSafetyCheck",
|
||||
"Release",
|
||||
"Release"
|
||||
],
|
||||
log);
|
||||
Assert.DoesNotContain("KTAPConnect", log);
|
||||
Assert.Equal(PlayoutKtapConnectState.NotAttempted, session.LastKtapConnectState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Runner_ConnectTimeoutPreventsLateKtapDispatch()
|
||||
{
|
||||
var snapshot = ReadySnapshot(10);
|
||||
using var safety = new BlockingSecondCaptureSafetyProbe(snapshot);
|
||||
var session = new FakeConnectOnlySession();
|
||||
var dispatcherFactory = new TrackingStaDispatcherFactory();
|
||||
var runner = new PgmConnectDiagnosticRunner(
|
||||
safety,
|
||||
new FakeConnectOnlySessionFactory(session),
|
||||
new FakeRegistrationProbe(),
|
||||
dispatcherFactory,
|
||||
TimeSpan.FromMilliseconds(50),
|
||||
TimeSpan.FromSeconds(1));
|
||||
|
||||
var execution = runner.ExecuteAsync(
|
||||
new PgmConnectDiagnosticRequest("127.0.0.1", 30001, "PGM"));
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(safety.SecondCaptureEntered.Wait(TimeSpan.FromSeconds(2)));
|
||||
Assert.True(session.PreventionAttempted.Wait(TimeSpan.FromSeconds(2)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
safety.ReleaseSecondCapture.Set();
|
||||
}
|
||||
|
||||
var report = await execution.WaitAsync(TimeSpan.FromSeconds(3));
|
||||
Assert.True(session.ConnectCompleted.Wait(TimeSpan.FromSeconds(2)));
|
||||
|
||||
Assert.Equal("connect-timeout", report.ErrorCode);
|
||||
Assert.False(report.Completed);
|
||||
Assert.False(report.OutcomeUnknown);
|
||||
Assert.False(report.KtapConnectAttempted);
|
||||
Assert.Equal(PlayoutKtapConnectState.NotAttempted, report.LastKtapConnectState);
|
||||
Assert.False(session.ComActivationAttempted);
|
||||
Assert.Equal(0, session.DispatchCount);
|
||||
Assert.Contains("Connect", session.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectOnlySessionSurfaceHasNoRenderCommand()
|
||||
{
|
||||
var methodNames = typeof(IKtapConnectOnlySession)
|
||||
.GetMethods()
|
||||
.Select(method => method.Name)
|
||||
.ToArray();
|
||||
string[] forbidden =
|
||||
[
|
||||
"GetScenePlayer",
|
||||
"LoadScene",
|
||||
"Prepare",
|
||||
"Play",
|
||||
"PlayDirect",
|
||||
"TakeIn",
|
||||
"Next",
|
||||
"TakeOut",
|
||||
"CutOut",
|
||||
"Stop",
|
||||
"StopAll"
|
||||
];
|
||||
|
||||
Assert.DoesNotContain(methodNames, name =>
|
||||
forbidden.Contains(name, StringComparer.OrdinalIgnoreCase));
|
||||
Assert.Contains("Connect", methodNames);
|
||||
Assert.Contains("Disconnect", methodNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListenerPortDecoderUsesNetworkByteOrder()
|
||||
{
|
||||
const ushort port = 30001;
|
||||
var encoded = unchecked((uint)(ushort)IPAddress.HostToNetworkOrder((short)port));
|
||||
|
||||
Assert.Equal(port, WindowsTcpListenerOwnershipProbe.DecodePort(encoded));
|
||||
}
|
||||
|
||||
private static PgmDiagnosticTargetSnapshot ReadySnapshot(int processId) => new(
|
||||
true,
|
||||
1,
|
||||
1,
|
||||
true,
|
||||
new PgmDiagnosticTargetIdentity(processId, 100));
|
||||
|
||||
private sealed class FakeSafetyProbe : IPgmDiagnosticSafetyProbe
|
||||
{
|
||||
private readonly Queue<PgmDiagnosticTargetSnapshot> _snapshots;
|
||||
private PgmDiagnosticTargetSnapshot _last;
|
||||
|
||||
public FakeSafetyProbe(params PgmDiagnosticTargetSnapshot[] snapshots)
|
||||
{
|
||||
_snapshots = new Queue<PgmDiagnosticTargetSnapshot>(snapshots);
|
||||
_last = snapshots.Last();
|
||||
}
|
||||
|
||||
public PgmDiagnosticTargetSnapshot Capture(
|
||||
PgmConnectDiagnosticRequest request,
|
||||
IPAddress parsedHost)
|
||||
{
|
||||
if (_snapshots.Count > 0)
|
||||
{
|
||||
_last = _snapshots.Dequeue();
|
||||
}
|
||||
|
||||
return _last;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingSecondCaptureSafetyProbe : IPgmDiagnosticSafetyProbe, IDisposable
|
||||
{
|
||||
private readonly PgmDiagnosticTargetSnapshot _snapshot;
|
||||
private int _captureCount;
|
||||
|
||||
public BlockingSecondCaptureSafetyProbe(PgmDiagnosticTargetSnapshot snapshot)
|
||||
{
|
||||
_snapshot = snapshot;
|
||||
}
|
||||
|
||||
public ManualResetEventSlim SecondCaptureEntered { get; } = new(false);
|
||||
|
||||
public ManualResetEventSlim ReleaseSecondCapture { get; } = new(false);
|
||||
|
||||
public PgmDiagnosticTargetSnapshot Capture(
|
||||
PgmConnectDiagnosticRequest request,
|
||||
IPAddress parsedHost)
|
||||
{
|
||||
if (Interlocked.Increment(ref _captureCount) == 2)
|
||||
{
|
||||
SecondCaptureEntered.Set();
|
||||
ReleaseSecondCapture.Wait();
|
||||
}
|
||||
|
||||
return _snapshot;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ReleaseSecondCapture.Set();
|
||||
SecondCaptureEntered.Dispose();
|
||||
ReleaseSecondCapture.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeConnectOnlySessionFactory : IKtapConnectOnlySessionFactory
|
||||
{
|
||||
private readonly IKtapConnectOnlySession _session;
|
||||
|
||||
public FakeConnectOnlySessionFactory(IKtapConnectOnlySession session)
|
||||
{
|
||||
_session = session;
|
||||
}
|
||||
|
||||
public int CreateCount { get; private set; }
|
||||
|
||||
public IKtapConnectOnlySession Create()
|
||||
{
|
||||
CreateCount++;
|
||||
return _session;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class UnavailableInteropSessionFactory : IKtapConnectOnlySessionFactory
|
||||
{
|
||||
public IKtapConnectOnlySession Create() =>
|
||||
throw new InstalledK3dInteropMetadataUnavailableException();
|
||||
}
|
||||
|
||||
private sealed class FakeConnectOnlySession : IKtapConnectOnlySession
|
||||
{
|
||||
private int _dispatchGate;
|
||||
private int _dispatchCount;
|
||||
private int _abandonRecorded;
|
||||
|
||||
public ConcurrentQueue<string> Calls { get; } = new();
|
||||
|
||||
public ManualResetEventSlim PreventionAttempted { get; } = new(false);
|
||||
|
||||
public ManualResetEventSlim ConnectCompleted { get; } = new(false);
|
||||
|
||||
public int DispatchCount => Volatile.Read(ref _dispatchCount);
|
||||
|
||||
public bool ComActivationAttempted { get; private set; }
|
||||
|
||||
public PlayoutKtapConnectState LastKtapConnectState { get; private set; }
|
||||
|
||||
public bool TryPreventKtapConnect()
|
||||
{
|
||||
PreventionAttempted.Set();
|
||||
var previous = Interlocked.CompareExchange(ref _dispatchGate, 2, 0);
|
||||
if (previous == 1 && LastKtapConnectState == PlayoutKtapConnectState.NotAttempted)
|
||||
{
|
||||
LastKtapConnectState = PlayoutKtapConnectState.Attempted;
|
||||
}
|
||||
|
||||
return previous == 0;
|
||||
}
|
||||
|
||||
public void Connect(string host, int port, Func<bool> finalSafetyCheck)
|
||||
{
|
||||
Calls.Enqueue("Connect");
|
||||
try
|
||||
{
|
||||
if (Volatile.Read(ref _dispatchGate) != 0)
|
||||
{
|
||||
throw new InvalidOperationException();
|
||||
}
|
||||
|
||||
ComActivationAttempted = true;
|
||||
if (!finalSafetyCheck())
|
||||
{
|
||||
throw new PgmDiagnosticSafetyException();
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(ref _dispatchGate, 1, 0) != 0)
|
||||
{
|
||||
throw new InvalidOperationException();
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _dispatchCount);
|
||||
LastKtapConnectState = PlayoutKtapConnectState.AcceptedUnconfirmed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ConnectCompleted.Set();
|
||||
}
|
||||
}
|
||||
|
||||
public void Disconnect() => Calls.Enqueue("Disconnect");
|
||||
|
||||
public void Abandon()
|
||||
{
|
||||
TryPreventKtapConnect();
|
||||
if (Interlocked.Exchange(ref _abandonRecorded, 1) == 0)
|
||||
{
|
||||
Calls.Enqueue("Abandon");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingComBinding : IK3dConnectOnlyComBinding
|
||||
{
|
||||
private readonly ConcurrentQueue<string> _log;
|
||||
|
||||
public RecordingComBinding(ConcurrentQueue<string> log)
|
||||
{
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public object CreateEventHandler()
|
||||
{
|
||||
_log.Enqueue("CreateEventHandler");
|
||||
return new object();
|
||||
}
|
||||
|
||||
public object CreateEngine()
|
||||
{
|
||||
_log.Enqueue("CreateEngine");
|
||||
return new object();
|
||||
}
|
||||
|
||||
public int KtapConnect(
|
||||
object engine,
|
||||
string host,
|
||||
int port,
|
||||
object eventHandler)
|
||||
{
|
||||
_log.Enqueue("KTAPConnect");
|
||||
return 1;
|
||||
}
|
||||
|
||||
public void Disconnect(object engine) => _log.Enqueue("Disconnect");
|
||||
|
||||
public void Release(object value) => _log.Enqueue("Release");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Net;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class PgmDiagnosticSafetyProbeTests
|
||||
{
|
||||
private const int Port = 30_001;
|
||||
private const int TargetProcessId = 101;
|
||||
private const int CompetingProcessId = 202;
|
||||
|
||||
[Theory]
|
||||
[InlineData("127.0.0.1", "127.0.0.1")]
|
||||
[InlineData("127.0.0.1", "0.0.0.0")]
|
||||
[InlineData("::1", "::1")]
|
||||
[InlineData("::1", "::")]
|
||||
public void TargetOwnedExactOrWildcardListener_IsAccepted(
|
||||
string requestedAddress,
|
||||
string listenerAddress)
|
||||
{
|
||||
var queried = Evaluate(
|
||||
requestedAddress,
|
||||
[Entry(listenerAddress, TargetProcessId)],
|
||||
out var isOwned);
|
||||
|
||||
Assert.True(queried);
|
||||
Assert.True(isOwned);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("127.0.0.1", "127.0.0.1", "0.0.0.0")]
|
||||
[InlineData("127.0.0.1", "0.0.0.0", "127.0.0.1")]
|
||||
[InlineData("::1", "::1", "::")]
|
||||
[InlineData("::1", "::", "::1")]
|
||||
public void MatchingEndpointWithCompetingOwner_FailsClosedEvenAfterTargetRow(
|
||||
string requestedAddress,
|
||||
string targetAddress,
|
||||
string competingAddress)
|
||||
{
|
||||
var queried = Evaluate(
|
||||
requestedAddress,
|
||||
[
|
||||
Entry(targetAddress, TargetProcessId),
|
||||
Entry(competingAddress, CompetingProcessId)
|
||||
],
|
||||
out var isOwned);
|
||||
|
||||
Assert.True(queried);
|
||||
Assert.False(isOwned);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("127.0.0.1", "127.0.0.2")]
|
||||
[InlineData("::1", "::2")]
|
||||
public void DifferentLocalAddressOwnedByCompetitor_DoesNotCompete(
|
||||
string requestedAddress,
|
||||
string unrelatedAddress)
|
||||
{
|
||||
var queried = Evaluate(
|
||||
requestedAddress,
|
||||
[
|
||||
Entry(requestedAddress, TargetProcessId),
|
||||
Entry(unrelatedAddress, CompetingProcessId)
|
||||
],
|
||||
out var isOwned);
|
||||
|
||||
Assert.True(queried);
|
||||
Assert.True(isOwned);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("127.0.0.1", "127.0.0.2")]
|
||||
[InlineData("::1", "::2")]
|
||||
public void TargetOwnedDifferentLocalAddress_IsNotAccepted(
|
||||
string requestedAddress,
|
||||
string differentAddress)
|
||||
{
|
||||
var queried = Evaluate(
|
||||
requestedAddress,
|
||||
[Entry(differentAddress, TargetProcessId)],
|
||||
out var isOwned);
|
||||
|
||||
Assert.True(queried);
|
||||
Assert.False(isOwned);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("127.0.0.1", "::")]
|
||||
[InlineData("::1", "0.0.0.0")]
|
||||
public void OtherAddressFamilyWildcard_DoesNotMatchRequestedEndpoint(
|
||||
string requestedAddress,
|
||||
string otherFamilyWildcard)
|
||||
{
|
||||
var queried = Evaluate(
|
||||
requestedAddress,
|
||||
[Entry(otherFamilyWildcard, TargetProcessId)],
|
||||
out var isOwned);
|
||||
|
||||
Assert.True(queried);
|
||||
Assert.False(isOwned);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0.0.0.0")]
|
||||
[InlineData("192.0.2.1")]
|
||||
[InlineData("::")]
|
||||
[InlineData("2001:db8::1")]
|
||||
public void RequestedAddressMustBeLoopback(string requestedAddress)
|
||||
{
|
||||
var queried = Evaluate(
|
||||
requestedAddress,
|
||||
[Entry(requestedAddress, TargetProcessId)],
|
||||
out var isOwned);
|
||||
|
||||
Assert.False(queried);
|
||||
Assert.False(isOwned);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("127.0.0.1", "127.0.0.1")]
|
||||
[InlineData("::1", "::1")]
|
||||
public void MatchingEndpointOwnedOnlyByCompetitor_IsRejected(
|
||||
string requestedAddress,
|
||||
string competingAddress)
|
||||
{
|
||||
var queried = Evaluate(
|
||||
requestedAddress,
|
||||
[Entry(competingAddress, CompetingProcessId)],
|
||||
out var isOwned);
|
||||
|
||||
Assert.True(queried);
|
||||
Assert.False(isOwned);
|
||||
}
|
||||
|
||||
private static bool Evaluate(
|
||||
string requestedAddress,
|
||||
IReadOnlyList<TcpListenerOwnershipEntry> listeners,
|
||||
out bool isOwned) =>
|
||||
WindowsTcpListenerOwnershipProbe.TryEvaluateOwnership(
|
||||
IPAddress.Parse(requestedAddress),
|
||||
Port,
|
||||
TargetProcessId,
|
||||
listeners,
|
||||
out isOwned);
|
||||
|
||||
private static TcpListenerOwnershipEntry Entry(
|
||||
string address,
|
||||
int processId) =>
|
||||
new(IPAddress.Parse(address), Port, processId);
|
||||
}
|
||||
@@ -181,6 +181,88 @@ public sealed class SmokeCommandLineTests
|
||||
Assert.Equal(SmokeCommandKind.TestPlan, parsed.Invocation.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_PgmConnectDiagnosticRequiresDistinctAcknowledgementAndExactInputs()
|
||||
{
|
||||
string[] complete =
|
||||
[
|
||||
"--pgm-connect-diagnostic",
|
||||
SmokeCommandLine.PgmConnectAcknowledgementFlag,
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"30001",
|
||||
"--expected-pgm-window-title",
|
||||
"PGM"
|
||||
];
|
||||
var missingAcknowledgement = SmokeCommandLine.Parse(complete[0..1]
|
||||
.Concat(complete[2..])
|
||||
.ToArray());
|
||||
var wrongAcknowledgement = SmokeCommandLine.Parse(
|
||||
complete.Select(value => value == SmokeCommandLine.PgmConnectAcknowledgementFlag
|
||||
? SmokeCommandLine.AcknowledgementFlag
|
||||
: value).ToArray());
|
||||
var parsed = SmokeCommandLine.Parse(complete);
|
||||
|
||||
Assert.False(missingAcknowledgement.IsValid);
|
||||
Assert.Equal("missing-acknowledgement", missingAcknowledgement.ErrorCode);
|
||||
Assert.False(wrongAcknowledgement.IsValid);
|
||||
Assert.Equal("unknown-option", wrongAcknowledgement.ErrorCode);
|
||||
Assert.True(parsed.IsValid);
|
||||
Assert.Equal(SmokeCommandKind.PgmConnectDiagnostic, parsed.Invocation.Kind);
|
||||
Assert.Equal("127.0.0.1", parsed.Invocation.Host);
|
||||
Assert.Equal(30001, parsed.Invocation.Port);
|
||||
Assert.Equal("PGM", parsed.Invocation.ExpectedPgmWindowTitle);
|
||||
Assert.Null(parsed.Invocation.ConfigPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("--config")]
|
||||
[InlineData("--prepare")]
|
||||
[InlineData("--next")]
|
||||
[InlineData("--observe-ms")]
|
||||
public void Parse_PgmConnectDiagnosticRejectsEveryConfigOrRenderOption(string option)
|
||||
{
|
||||
var parsed = SmokeCommandLine.Parse(
|
||||
[
|
||||
"--pgm-connect-diagnostic",
|
||||
SmokeCommandLine.PgmConnectAcknowledgementFlag,
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"30001",
|
||||
"--expected-pgm-window-title",
|
||||
"PGM",
|
||||
option,
|
||||
"5001"
|
||||
]);
|
||||
|
||||
Assert.False(parsed.IsValid);
|
||||
Assert.Equal("unknown-option", parsed.ErrorCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0")]
|
||||
[InlineData("65536")]
|
||||
[InlineData("secret")]
|
||||
public void Parse_PgmConnectDiagnosticRejectsInvalidPort(string port)
|
||||
{
|
||||
var parsed = SmokeCommandLine.Parse(
|
||||
[
|
||||
"--pgm-connect-diagnostic",
|
||||
SmokeCommandLine.PgmConnectAcknowledgementFlag,
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
port,
|
||||
"--expected-pgm-window-title",
|
||||
"PGM"
|
||||
]);
|
||||
|
||||
Assert.False(parsed.IsValid);
|
||||
Assert.Equal("invalid-port", parsed.ErrorCode);
|
||||
}
|
||||
|
||||
private static SmokeCommandParseResult ValidSequenceArguments(
|
||||
string prepare = "5001",
|
||||
string observation = "1000") =>
|
||||
|
||||
@@ -239,16 +239,91 @@ public sealed class TornadoPlayoutEngineTests
|
||||
Assert.False(engine.Status.NetworkMonitoringRecordExpected);
|
||||
Assert.False(engine.Status.NetworkMonitoringCheckRequired);
|
||||
Assert.Equal(0, session.KtapDispatchCount);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
|
||||
releaseActivation.Set();
|
||||
Assert.True(SpinWait.SpinUntil(
|
||||
() => session.Calls.Contains("Dispose"),
|
||||
TimeSpan.FromSeconds(2)));
|
||||
Assert.Equal(0, session.KtapDispatchCount);
|
||||
Assert.Equal(new[] { "Connect", "Dispose" }, session.Calls);
|
||||
Assert.Single(session.ThreadIds.Distinct());
|
||||
Assert.All(
|
||||
session.ApartmentStates,
|
||||
state => Assert.Equal(ApartmentState.STA, state));
|
||||
}
|
||||
finally
|
||||
{
|
||||
releaseActivation.Set();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestConnect_BlockingFactoryThenConnectTimeout_PreventsLateKtapDispatch()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
using var factoryStarted = new ManualResetEventSlim();
|
||||
using var releaseFactory = new ManualResetEventSlim();
|
||||
using var beforeKtapDispatch = new ManualResetEventSlim();
|
||||
using var releaseConnect = new ManualResetEventSlim();
|
||||
var session = new FakeK3dSession
|
||||
{
|
||||
BeforeKtapDispatchAction = () =>
|
||||
{
|
||||
beforeKtapDispatch.Set();
|
||||
if (!releaseConnect.Wait(TimeSpan.FromSeconds(5)))
|
||||
{
|
||||
throw new TimeoutException("fake connect coordination timeout");
|
||||
}
|
||||
}
|
||||
};
|
||||
var sessionFactory = new FakeK3dSessionFactory(() =>
|
||||
{
|
||||
factoryStarted.Set();
|
||||
if (!releaseFactory.Wait(TimeSpan.FromSeconds(5)))
|
||||
{
|
||||
throw new TimeoutException("fake factory coordination timeout");
|
||||
}
|
||||
|
||||
return session;
|
||||
});
|
||||
var options = TestOptions(scenes.Path, "test-scene");
|
||||
options.ConnectTimeoutMilliseconds = 100;
|
||||
await using var engine = CreateEngine(
|
||||
options,
|
||||
sessionFactory,
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
||||
liveAuthorized: false);
|
||||
|
||||
try
|
||||
{
|
||||
var connectTask = Task.Run(() => engine.ConnectAsync(CancellationToken.None));
|
||||
Assert.True(factoryStarted.Wait(TimeSpan.FromSeconds(2)));
|
||||
|
||||
await Task.Delay(250);
|
||||
Assert.False(connectTask.IsCompleted);
|
||||
Assert.Equal(0, session.KtapDispatchCount);
|
||||
|
||||
releaseFactory.Set();
|
||||
Assert.True(beforeKtapDispatch.Wait(TimeSpan.FromSeconds(2)));
|
||||
var result = await connectTask;
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
|
||||
Assert.Equal(PlayoutKtapConnectState.NotAttempted, engine.Status.LastKtapConnectState);
|
||||
Assert.Equal(0, session.KtapDispatchCount);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
|
||||
releaseConnect.Set();
|
||||
Assert.True(SpinWait.SpinUntil(
|
||||
() => session.Calls.Contains("Dispose"),
|
||||
TimeSpan.FromSeconds(2)));
|
||||
Assert.Equal(0, session.KtapDispatchCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
releaseActivation.Set();
|
||||
releaseFactory.Set();
|
||||
releaseConnect.Set();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user