feat: verify Tornado PGM cut sequence
This commit is contained in:
@@ -185,6 +185,95 @@ public sealed class DynamicK3dSessionTests
|
||||
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")]
|
||||
|
||||
@@ -0,0 +1,863 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class PgmCutsSequenceRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StableTarget_ExecutesFixedSequenceWithExactSafetyAndMilestoneOrder()
|
||||
{
|
||||
using var environment = new EnvironmentScope()
|
||||
.Set("MBN_STOCK_PLAYOUT_MODE", "DryRun")
|
||||
.Set("MBN_STOCK_PLAYOUT_HOST", "192.0.2.99")
|
||||
.Set("MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL", "77")
|
||||
.Set("MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT", "false");
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
|
||||
var events = new ConcurrentQueue<string>();
|
||||
var safety = new FakeSafetyProbe(events);
|
||||
var engine = new FakeEngine(events);
|
||||
var factory = new FakeEngineFactory(events, engine);
|
||||
var runner = new PgmCutsSequenceRunner(
|
||||
safety,
|
||||
new FakeHashPins(true),
|
||||
factory,
|
||||
new FakeDelay(events),
|
||||
new FakeAssetLeases(true));
|
||||
|
||||
var report = await runner.ExecuteAsync(
|
||||
Request(scenes.Path),
|
||||
stage => events.Enqueue($"Milestone:{stage}"));
|
||||
|
||||
Assert.True(report.Completed);
|
||||
Assert.False(report.OutcomeUnknown);
|
||||
Assert.False(report.QuarantineAttempted);
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"Safety:1",
|
||||
"CreateEngine",
|
||||
"Safety:2",
|
||||
"Connect",
|
||||
"Safety:3",
|
||||
"Safety:4",
|
||||
"Prepare:5001",
|
||||
"Safety:5",
|
||||
"Safety:6",
|
||||
"TakeIn",
|
||||
"Safety:7",
|
||||
"Safety:8",
|
||||
"Milestone:First",
|
||||
"Delay:5000",
|
||||
"Safety:9",
|
||||
"Safety:10",
|
||||
"Next:5006",
|
||||
"Safety:11",
|
||||
"Safety:12",
|
||||
"Milestone:Next",
|
||||
"Delay:5000",
|
||||
"Safety:13",
|
||||
"Safety:14",
|
||||
"TakeOut:All",
|
||||
"Safety:15",
|
||||
"Safety:16",
|
||||
"Milestone:TakeOut",
|
||||
"Delay:5000",
|
||||
"Safety:17",
|
||||
"Safety:18",
|
||||
"Disconnect",
|
||||
"Safety:19",
|
||||
"Dispose"
|
||||
},
|
||||
events);
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
PgmCutsSequenceStage.Connect,
|
||||
PgmCutsSequenceStage.PrepareFirst,
|
||||
PgmCutsSequenceStage.TakeIn,
|
||||
PgmCutsSequenceStage.Next,
|
||||
PgmCutsSequenceStage.TakeOut,
|
||||
PgmCutsSequenceStage.Disconnect
|
||||
},
|
||||
report.Commands.Select(command => command.Stage));
|
||||
|
||||
var plan = Assert.IsType<PgmCutsSequencePlan>(factory.Plan);
|
||||
Assert.Equal(PlayoutMode.Live, plan.Options.Mode);
|
||||
Assert.Equal("127.0.0.1", plan.Options.Host);
|
||||
Assert.Equal(30_001, plan.Options.Port);
|
||||
Assert.Null(plan.Options.OutputChannel);
|
||||
Assert.Equal(10, plan.Options.LayoutIndex);
|
||||
Assert.True(plan.Options.TrustedLiveOutputEnabled);
|
||||
Assert.False(plan.Options.ReconnectEnabled);
|
||||
Assert.Equal(0, plan.Options.MaximumReconnectAttempts);
|
||||
Assert.Equal(TimeSpan.FromMilliseconds(5_000), plan.ObservationDuration);
|
||||
Assert.Equal("5001", plan.FirstCue.SceneName);
|
||||
Assert.Equal("5006", plan.NextCue.SceneName);
|
||||
Assert.Equal("5001.t2s", plan.FirstCue.SceneFile);
|
||||
Assert.Equal("5006.t2s", plan.NextCue.SceneFile);
|
||||
Assert.False(Path.IsPathFullyQualified(plan.FirstCue.SceneFile));
|
||||
Assert.False(Path.IsPathFullyQualified(plan.NextCue.SceneFile));
|
||||
Assert.True(Path.IsPathFullyQualified(plan.FirstAssetPath));
|
||||
Assert.True(Path.IsPathFullyQualified(plan.NextAssetPath));
|
||||
Assert.Equal("5001.t2s", Path.GetFileName(plan.FirstAssetPath));
|
||||
Assert.Equal("5006.t2s", Path.GetFileName(plan.NextAssetPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RawPlanCue_IsResolvedExactlyOnceByTornadoEngineBeforeSdkPrepare()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
|
||||
var events = new ConcurrentQueue<string>();
|
||||
var captureFactory = new FakeEngineFactory(events, new FakeEngine(events));
|
||||
var runner = new PgmCutsSequenceRunner(
|
||||
new FakeSafetyProbe(events),
|
||||
new FakeHashPins(true),
|
||||
captureFactory,
|
||||
new FakeDelay(events),
|
||||
new FakeAssetLeases(true));
|
||||
Assert.True((await runner.ExecuteAsync(Request(scenes.Path))).Completed);
|
||||
var plan = Assert.IsType<PgmCutsSequencePlan>(captureFactory.Plan);
|
||||
PlayoutCue? sdkCue = null;
|
||||
var session = new FakeK3dSession
|
||||
{
|
||||
PrepareAction = (cue, _) => sdkCue = cue
|
||||
};
|
||||
var dispatcher = new StaDispatcher(plan.Options.QueueCapacity);
|
||||
await using var engine = new TornadoPlayoutEngine(
|
||||
plan.Options,
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
||||
dispatcher,
|
||||
new FakeLiveAuthorization(true),
|
||||
TimeProvider.System,
|
||||
startMonitor: false);
|
||||
|
||||
Assert.False(Path.IsPathFullyQualified(plan.FirstCue.SceneFile));
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
|
||||
var prepare = await engine.PrepareAsync(
|
||||
plan.FirstCue,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(prepare.IsSuccess);
|
||||
var resolvedCue = Assert.IsType<PlayoutCue>(sdkCue);
|
||||
Assert.True(Path.IsPathFullyQualified(resolvedCue.SceneFile));
|
||||
Assert.Equal(
|
||||
Path.GetFullPath(plan.FirstAssetPath),
|
||||
Path.GetFullPath(resolvedCue.SceneFile),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
Assert.True((await engine.DisconnectAsync(CancellationToken.None)).IsSuccess);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
[InlineData(5)]
|
||||
[InlineData(6)]
|
||||
[InlineData(7)]
|
||||
[InlineData(8)]
|
||||
[InlineData(9)]
|
||||
[InlineData(10)]
|
||||
[InlineData(11)]
|
||||
[InlineData(12)]
|
||||
[InlineData(13)]
|
||||
[InlineData(14)]
|
||||
[InlineData(15)]
|
||||
[InlineData(16)]
|
||||
[InlineData(17)]
|
||||
[InlineData(18)]
|
||||
[InlineData(19)]
|
||||
public async Task TargetChangeAtEveryBoundary_StopsAndQuarantinesWithoutLaterOutput(
|
||||
int changedCapture)
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
|
||||
var events = new ConcurrentQueue<string>();
|
||||
var engine = new FakeEngine(events);
|
||||
var runner = new PgmCutsSequenceRunner(
|
||||
new FakeSafetyProbe(events, changedCapture),
|
||||
new FakeHashPins(true),
|
||||
new FakeEngineFactory(events, engine),
|
||||
new FakeDelay(events),
|
||||
new FakeAssetLeases(true));
|
||||
|
||||
var report = await runner.ExecuteAsync(
|
||||
Request(scenes.Path),
|
||||
stage => events.Enqueue($"Milestone:{stage}"));
|
||||
|
||||
Assert.False(report.Completed);
|
||||
Assert.Equal("pgm-target-changed", report.ErrorCode);
|
||||
Assert.True(report.QuarantineAttempted);
|
||||
Assert.True(report.QuarantineCompleted);
|
||||
Assert.Equal(1, events.Count(value => value == "Quarantine"));
|
||||
var quarantineIndex = Array.IndexOf(events.ToArray(), "Quarantine");
|
||||
Assert.DoesNotContain(
|
||||
events.Skip(quarantineIndex + 1),
|
||||
value => IsOutputCommand(value) || value == "Disconnect");
|
||||
if (changedCapture <= 18)
|
||||
{
|
||||
Assert.DoesNotContain("Disconnect", events);
|
||||
}
|
||||
|
||||
Assert.Equal(changedCapture is > 2 and < 19, report.OutcomeUnknown);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((int)PgmCutsSequenceStage.Connect)]
|
||||
[InlineData((int)PgmCutsSequenceStage.PrepareFirst)]
|
||||
[InlineData((int)PgmCutsSequenceStage.TakeIn)]
|
||||
[InlineData((int)PgmCutsSequenceStage.Next)]
|
||||
[InlineData((int)PgmCutsSequenceStage.TakeOut)]
|
||||
[InlineData((int)PgmCutsSequenceStage.Disconnect)]
|
||||
public async Task OutcomeUnknownAtEveryCommand_QuarantinesWithoutSubsequentCommand(
|
||||
int stageValue)
|
||||
{
|
||||
var stage = (PgmCutsSequenceStage)stageValue;
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
|
||||
var events = new ConcurrentQueue<string>();
|
||||
var engine = new FakeEngine(events);
|
||||
engine.Results[stage] = PlayoutResultCode.OutcomeUnknown;
|
||||
var runner = Runner(events, engine);
|
||||
|
||||
var report = await runner.ExecuteAsync(Request(scenes.Path));
|
||||
|
||||
Assert.False(report.Completed);
|
||||
Assert.True(report.OutcomeUnknown);
|
||||
Assert.True(report.QuarantineCompleted);
|
||||
Assert.Equal("command-outcome-unknown", report.ErrorCode);
|
||||
var quarantineIndex = Array.IndexOf(events.ToArray(), "Quarantine");
|
||||
Assert.DoesNotContain(
|
||||
events.Skip(quarantineIndex + 1),
|
||||
value => IsOutputCommand(value) || value == "Disconnect");
|
||||
if (stage != PgmCutsSequenceStage.Disconnect)
|
||||
{
|
||||
Assert.DoesNotContain("Disconnect", events);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KnownPrepareFailure_PerformsExplicitSameTargetDisconnectCleanup()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
|
||||
var events = new ConcurrentQueue<string>();
|
||||
var engine = new FakeEngine(events);
|
||||
engine.Results[PgmCutsSequenceStage.PrepareFirst] = PlayoutResultCode.Failed;
|
||||
|
||||
var report = await Runner(events, engine).ExecuteAsync(Request(scenes.Path));
|
||||
|
||||
Assert.False(report.Completed);
|
||||
Assert.False(report.OutcomeUnknown);
|
||||
Assert.False(report.QuarantineAttempted);
|
||||
Assert.True(report.DisconnectAttempted);
|
||||
Assert.Equal(
|
||||
new[] { "Connect", "Prepare:5001", "Disconnect", "Dispose" },
|
||||
EngineCalls(events));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QuarantineFailure_SkipsDisposeAndEveryFurtherSdkCommand()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
|
||||
var events = new ConcurrentQueue<string>();
|
||||
var engine = new FakeEngine(events)
|
||||
{
|
||||
QuarantineFailure = new InvalidOperationException("fake quarantine failure")
|
||||
};
|
||||
engine.Results[PgmCutsSequenceStage.Next] = PlayoutResultCode.OutcomeUnknown;
|
||||
|
||||
var report = await Runner(events, engine).ExecuteAsync(Request(scenes.Path));
|
||||
|
||||
Assert.True(report.QuarantineAttempted);
|
||||
Assert.False(report.QuarantineCompleted);
|
||||
Assert.True(report.OutcomeUnknown);
|
||||
Assert.DoesNotContain("Dispose", events);
|
||||
Assert.DoesNotContain("Disconnect", events);
|
||||
Assert.Equal("Quarantine", events.Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TimedOutTakeOut_IsTreatedAsUnknownAndNeverDisconnects()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
|
||||
var events = new ConcurrentQueue<string>();
|
||||
var engine = new FakeEngine(events);
|
||||
engine.Results[PgmCutsSequenceStage.TakeOut] = PlayoutResultCode.TimedOut;
|
||||
|
||||
var report = await Runner(events, engine).ExecuteAsync(Request(scenes.Path));
|
||||
|
||||
Assert.True(report.RenderCommandAttempted);
|
||||
Assert.True(report.OutcomeUnknown);
|
||||
Assert.True(report.QuarantineCompleted);
|
||||
Assert.DoesNotContain("Disconnect", events);
|
||||
Assert.Equal(1, events.Count(value => value == "TakeOut:All"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, false)]
|
||||
[InlineData(2, true)]
|
||||
public async Task InterruptedOnAirObservation_PerformsOneShotTakeOutThenDisconnect(
|
||||
int interruptedObservation,
|
||||
bool nextWasRendered)
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
|
||||
var events = new ConcurrentQueue<string>();
|
||||
var delay = new FakeDelay(events)
|
||||
{
|
||||
FailureOnCall = interruptedObservation
|
||||
};
|
||||
var engine = new FakeEngine(events);
|
||||
var runner = new PgmCutsSequenceRunner(
|
||||
new FakeSafetyProbe(events),
|
||||
new FakeHashPins(true),
|
||||
new FakeEngineFactory(events, engine),
|
||||
delay,
|
||||
new FakeAssetLeases(true));
|
||||
|
||||
var report = await runner.ExecuteAsync(Request(scenes.Path));
|
||||
|
||||
Assert.False(report.Completed);
|
||||
Assert.False(report.OutcomeUnknown);
|
||||
Assert.False(report.QuarantineAttempted);
|
||||
Assert.Equal("observation-interrupted", report.ErrorCode);
|
||||
Assert.Equal(1, events.Count(value => value == "TakeOut:All"));
|
||||
Assert.Equal(1, events.Count(value => value == "Disconnect"));
|
||||
Assert.Equal(nextWasRendered, events.Contains("Next:5006"));
|
||||
Assert.Equal(
|
||||
new[] { PgmCutsSequenceStage.TakeOut, PgmCutsSequenceStage.Disconnect },
|
||||
report.Commands.TakeLast(2).Select(command => command.Stage));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PlayoutResultCode.Failed)]
|
||||
[InlineData(PlayoutResultCode.TimedOut)]
|
||||
[InlineData(PlayoutResultCode.OutcomeUnknown)]
|
||||
public async Task InterruptedObservation_WhenOneShotTakeOutIsNotSuccessful_QuarantinesWithoutDisconnect(
|
||||
PlayoutResultCode takeOutCode)
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
|
||||
var events = new ConcurrentQueue<string>();
|
||||
var engine = new FakeEngine(events);
|
||||
engine.Results[PgmCutsSequenceStage.TakeOut] = takeOutCode;
|
||||
var delay = new FakeDelay(events)
|
||||
{
|
||||
FailureOnCall = 1
|
||||
};
|
||||
var runner = new PgmCutsSequenceRunner(
|
||||
new FakeSafetyProbe(events),
|
||||
new FakeHashPins(true),
|
||||
new FakeEngineFactory(events, engine),
|
||||
delay,
|
||||
new FakeAssetLeases(true));
|
||||
|
||||
var report = await runner.ExecuteAsync(Request(scenes.Path));
|
||||
|
||||
Assert.False(report.Completed);
|
||||
Assert.True(report.OutcomeUnknown);
|
||||
Assert.True(report.QuarantineCompleted);
|
||||
Assert.Equal(1, events.Count(value => value == "TakeOut:All"));
|
||||
Assert.DoesNotContain("Disconnect", events);
|
||||
var takeOutIndex = Array.IndexOf(events.ToArray(), "TakeOut:All");
|
||||
Assert.DoesNotContain(
|
||||
events.Skip(takeOutIndex + 1),
|
||||
value => value == "TakeOut:All");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InterruptedPostTakeOutObservation_DoesNotRepeatTakeOutAndDisconnects()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
|
||||
var events = new ConcurrentQueue<string>();
|
||||
var delay = new FakeDelay(events)
|
||||
{
|
||||
FailureOnCall = 3
|
||||
};
|
||||
var engine = new FakeEngine(events);
|
||||
var runner = new PgmCutsSequenceRunner(
|
||||
new FakeSafetyProbe(events),
|
||||
new FakeHashPins(true),
|
||||
new FakeEngineFactory(events, engine),
|
||||
delay,
|
||||
new FakeAssetLeases(true));
|
||||
|
||||
var report = await runner.ExecuteAsync(Request(scenes.Path));
|
||||
|
||||
Assert.False(report.Completed);
|
||||
Assert.False(report.OutcomeUnknown);
|
||||
Assert.False(report.QuarantineAttempted);
|
||||
Assert.Equal("observation-interrupted", report.ErrorCode);
|
||||
Assert.Equal(1, events.Count(value => value == "TakeOut:All"));
|
||||
Assert.Equal(1, events.Count(value => value == "Disconnect"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingPinsOrInvalidAssets_RejectBeforeEngineCreation()
|
||||
{
|
||||
using var missingScene = TemporarySceneDirectory.Create("5001.t2s");
|
||||
using var validScenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
|
||||
var missingPinEvents = new ConcurrentQueue<string>();
|
||||
var pinFactory = new FakeEngineFactory(missingPinEvents, new FakeEngine(missingPinEvents));
|
||||
var missingPins = new PgmCutsSequenceRunner(
|
||||
new FakeSafetyProbe(missingPinEvents),
|
||||
new FakeHashPins(false),
|
||||
pinFactory,
|
||||
new FakeDelay(missingPinEvents));
|
||||
var assetEvents = new ConcurrentQueue<string>();
|
||||
var assetFactory = new FakeEngineFactory(assetEvents, new FakeEngine(assetEvents));
|
||||
var invalidAssets = new PgmCutsSequenceRunner(
|
||||
new FakeSafetyProbe(assetEvents),
|
||||
new FakeHashPins(true),
|
||||
assetFactory,
|
||||
new FakeDelay(assetEvents),
|
||||
new FakeAssetLeases(true));
|
||||
|
||||
var rejectedAssetEvents = new ConcurrentQueue<string>();
|
||||
var rejectedAssetFactory = new FakeEngineFactory(
|
||||
rejectedAssetEvents,
|
||||
new FakeEngine(rejectedAssetEvents));
|
||||
var rejectedAssets = new PgmCutsSequenceRunner(
|
||||
new FakeSafetyProbe(rejectedAssetEvents),
|
||||
new FakeHashPins(true),
|
||||
rejectedAssetFactory,
|
||||
new FakeDelay(rejectedAssetEvents),
|
||||
new FakeAssetLeases(false));
|
||||
|
||||
var pinReport = await missingPins.ExecuteAsync(Request(validScenes.Path));
|
||||
var assetReport = await invalidAssets.ExecuteAsync(Request(missingScene.Path));
|
||||
var rejectedAssetReport = await rejectedAssets.ExecuteAsync(Request(validScenes.Path));
|
||||
|
||||
Assert.Equal("hash-pins-required", pinReport.ErrorCode);
|
||||
Assert.Equal("request-rejected", assetReport.ErrorCode);
|
||||
Assert.Equal("cut-assets-not-approved", rejectedAssetReport.ErrorCode);
|
||||
Assert.Equal(0, pinFactory.CreateCount);
|
||||
Assert.Equal(0, assetFactory.CreateCount);
|
||||
Assert.Equal(0, rejectedAssetFactory.CreateCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportAndMilestonesContainNoEndpointTitleIdentityPathHashOrSceneCode()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
|
||||
const string title = "Unique PGM Private Title";
|
||||
var events = new ConcurrentQueue<string>();
|
||||
var report = await Runner(events, new FakeEngine(events)).ExecuteAsync(
|
||||
Request(scenes.Path) with { ExpectedPgmWindowTitle = title });
|
||||
|
||||
var json = JsonSerializer.Serialize(report);
|
||||
Assert.DoesNotContain("127.0.0.1", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("30001", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(title, json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(scenes.Path, json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("4242", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain(new string('A', 64), json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("5001", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("5006", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.All(
|
||||
events.Where(value => value.StartsWith("Milestone:", StringComparison.Ordinal)),
|
||||
value => Assert.DoesNotContain("500", value, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunnerCapabilityAndEntryPointsAreNotPublic_AndNormalTestGateIsUnchanged()
|
||||
{
|
||||
Assert.False(typeof(PgmCutsSequenceRunner).IsPublic);
|
||||
Assert.False(typeof(PgmCutsSequenceRequest).IsPublic);
|
||||
Assert.False(typeof(PgmCutsSequenceReport).IsPublic);
|
||||
Assert.All(
|
||||
typeof(FullPgmCutsSequenceEngineFactory).GetNestedTypes(
|
||||
System.Reflection.BindingFlags.Public |
|
||||
System.Reflection.BindingFlags.NonPublic),
|
||||
type => Assert.False(type.IsPublic));
|
||||
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
|
||||
var normalTest = new PlayoutOptions
|
||||
{
|
||||
Mode = PlayoutMode.Test,
|
||||
SceneDirectory = scenes.Path,
|
||||
OutputChannel = 9,
|
||||
TestProcessWindowTitlePattern = "^Tornado2 PGM$",
|
||||
TestSceneAllowlist = ["5001", "5006"]
|
||||
};
|
||||
|
||||
Assert.Throws<PlayoutConfigurationException>(
|
||||
() => ValidatedPlayoutOptions.Create(normalTest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullFactoryUsesPrivateCapabilityAndNeverReadsAmbientLiveAuthorization()
|
||||
{
|
||||
using var environment = new EnvironmentScope()
|
||||
.Set("MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT", "false");
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
|
||||
var events = new ConcurrentQueue<string>();
|
||||
var captureFactory = new FakeEngineFactory(events, new FakeEngine(events));
|
||||
var runner = new PgmCutsSequenceRunner(
|
||||
new FakeSafetyProbe(events),
|
||||
new FakeHashPins(true),
|
||||
captureFactory,
|
||||
new FakeDelay(events),
|
||||
new FakeAssetLeases(true));
|
||||
Assert.True((await runner.ExecuteAsync(Request(scenes.Path))).Completed);
|
||||
var plan = Assert.IsType<PgmCutsSequencePlan>(captureFactory.Plan);
|
||||
var baseline = new PgmDiagnosticTargetSnapshot(
|
||||
true,
|
||||
1,
|
||||
1,
|
||||
true,
|
||||
new PgmDiagnosticTargetIdentity(4_242, 123_456));
|
||||
|
||||
await using var engine = new FullPgmCutsSequenceEngineFactory(
|
||||
new FakeSafetyProbe(new ConcurrentQueue<string>()))
|
||||
.Create(plan, baseline);
|
||||
var authorizationField = typeof(TornadoPlayoutEngine).GetField(
|
||||
"_liveAuthorization",
|
||||
System.Reflection.BindingFlags.Instance |
|
||||
System.Reflection.BindingFlags.NonPublic);
|
||||
Assert.NotNull(authorizationField);
|
||||
var authorization = Assert.IsAssignableFrom<MBN_STOCK_WEBVIEW.Playout.Safety.ILiveAuthorization>(
|
||||
authorizationField.GetValue(engine));
|
||||
|
||||
Assert.True(authorization.IsAuthorizedForThisLaunch);
|
||||
Assert.True(authorization.GetType().IsNestedPrivate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessHashPinRequirementRequiresBothProcessScopedHexPins()
|
||||
{
|
||||
using var environment = new EnvironmentScope()
|
||||
.Set(InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable, null)
|
||||
.Set(InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable, null);
|
||||
var requirement = new ProcessPgmCutsSequenceHashPinRequirement();
|
||||
|
||||
Assert.False(requirement.IsSatisfied());
|
||||
environment
|
||||
.Set(
|
||||
InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable,
|
||||
new string('A', 64))
|
||||
.Set(
|
||||
InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable,
|
||||
new string('B', 64));
|
||||
Assert.True(requirement.IsSatisfied());
|
||||
environment.Set(
|
||||
InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable,
|
||||
"not-a-hash");
|
||||
Assert.False(requirement.IsSatisfied());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CutAssetRequirementUsesExactApprovedHashesAndFixedTimeComparison()
|
||||
{
|
||||
Assert.Equal(
|
||||
"99CE3B689A42D8C42BEB09A86FA10C2D7C1AEF4F50D324D81276C1A1E4C4D8A7",
|
||||
FixedPgmCutsSequenceAssetLeaseFactory.FirstApprovedSha256);
|
||||
Assert.Equal(
|
||||
"25CD0AE931F51E4E3B84CE3E6FD21A40DB85464F157A23CC3511D63B336D8757",
|
||||
FixedPgmCutsSequenceAssetLeaseFactory.NextApprovedSha256);
|
||||
|
||||
byte[] content = [1, 3, 3, 7, 42];
|
||||
var contentHash = Convert.ToHexString(
|
||||
System.Security.Cryptography.SHA256.HashData(content));
|
||||
using var matching = new MemoryStream(content, writable: false);
|
||||
using var rejected = new MemoryStream(content, writable: false);
|
||||
|
||||
Assert.True(FixedPgmCutsSequenceAssetLeaseFactory.HasExpectedSha256(
|
||||
matching,
|
||||
contentHash));
|
||||
Assert.False(FixedPgmCutsSequenceAssetLeaseFactory.HasExpectedSha256(
|
||||
rejected,
|
||||
FixedPgmCutsSequenceAssetLeaseFactory.FirstApprovedSha256));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SceneTrustRequiresFixedLocalRootAndFilesUnderThatRoot()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
|
||||
using var outside = TemporarySceneDirectory.Create("outside.t2s");
|
||||
|
||||
Assert.True(PgmCutsSequenceRunner.IsTrustedLocalScenePaths(
|
||||
scenes.Path,
|
||||
Path.Combine(scenes.Path, "5001.t2s"),
|
||||
Path.Combine(scenes.Path, "5006.t2s")));
|
||||
Assert.False(PgmCutsSequenceRunner.IsTrustedLocalScenePaths(
|
||||
scenes.Path,
|
||||
Path.Combine(scenes.Path, "5001.t2s"),
|
||||
Path.Combine(outside.Path, "outside.t2s")));
|
||||
Assert.False(PgmCutsSequenceRunner.IsTrustedLocalScenePaths(
|
||||
"\\\\server\\share\\Cuts",
|
||||
"\\\\server\\share\\Cuts\\5001.t2s"));
|
||||
}
|
||||
|
||||
private static PgmCutsSequenceRunner Runner(
|
||||
ConcurrentQueue<string> events,
|
||||
FakeEngine engine) => new(
|
||||
new FakeSafetyProbe(events),
|
||||
new FakeHashPins(true),
|
||||
new FakeEngineFactory(events, engine),
|
||||
new FakeDelay(events),
|
||||
new FakeAssetLeases(true));
|
||||
|
||||
private static PgmCutsSequenceRequest Request(string sceneRoot) => new(
|
||||
"127.0.0.1",
|
||||
30_001,
|
||||
"Tornado2 PGM",
|
||||
sceneRoot,
|
||||
5_000);
|
||||
|
||||
private static bool IsOutputCommand(string value) =>
|
||||
value.StartsWith("Prepare:", StringComparison.Ordinal) ||
|
||||
value == "TakeIn" ||
|
||||
value.StartsWith("Next:", StringComparison.Ordinal) ||
|
||||
value.StartsWith("TakeOut:", StringComparison.Ordinal);
|
||||
|
||||
private static string[] EngineCalls(ConcurrentQueue<string> events) =>
|
||||
events.Where(value =>
|
||||
value == "Connect" ||
|
||||
IsOutputCommand(value) ||
|
||||
value is "Disconnect" or "Quarantine" or "Dispose").ToArray();
|
||||
|
||||
private sealed class FakeHashPins : IPgmCutsSequenceHashPinRequirement
|
||||
{
|
||||
private readonly bool _satisfied;
|
||||
|
||||
public FakeHashPins(bool satisfied)
|
||||
{
|
||||
_satisfied = satisfied;
|
||||
}
|
||||
|
||||
public bool IsSatisfied() => _satisfied;
|
||||
}
|
||||
|
||||
private sealed class FakeAssetLeases : IPgmCutsSequenceAssetLeaseFactory
|
||||
{
|
||||
private readonly bool _satisfied;
|
||||
|
||||
public FakeAssetLeases(bool satisfied)
|
||||
{
|
||||
_satisfied = satisfied;
|
||||
}
|
||||
|
||||
public bool TryAcquire(
|
||||
PgmCutsSequencePlan plan,
|
||||
out IPgmCutsSequenceAssetLease? lease)
|
||||
{
|
||||
if (!_satisfied)
|
||||
{
|
||||
lease = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
lease = new FakeAssetLease();
|
||||
return true;
|
||||
}
|
||||
|
||||
private sealed class FakeAssetLease : IPgmCutsSequenceAssetLease
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSafetyProbe : IPgmDiagnosticSafetyProbe
|
||||
{
|
||||
private static readonly PgmDiagnosticTargetSnapshot Baseline = new(
|
||||
true,
|
||||
1,
|
||||
1,
|
||||
true,
|
||||
new PgmDiagnosticTargetIdentity(4_242, 123_456));
|
||||
private static readonly PgmDiagnosticTargetSnapshot Changed = new(
|
||||
true,
|
||||
1,
|
||||
1,
|
||||
true,
|
||||
new PgmDiagnosticTargetIdentity(4_243, 123_457));
|
||||
|
||||
private readonly ConcurrentQueue<string> _events;
|
||||
private readonly int? _changedCapture;
|
||||
private int _captureCount;
|
||||
|
||||
public FakeSafetyProbe(
|
||||
ConcurrentQueue<string> events,
|
||||
int? changedCapture = null)
|
||||
{
|
||||
_events = events;
|
||||
_changedCapture = changedCapture;
|
||||
}
|
||||
|
||||
public PgmDiagnosticTargetSnapshot Capture(
|
||||
PgmConnectDiagnosticRequest request,
|
||||
System.Net.IPAddress parsedHost)
|
||||
{
|
||||
Assert.Equal("127.0.0.1", parsedHost.ToString());
|
||||
Assert.Equal(30_001, request.Port);
|
||||
var count = Interlocked.Increment(ref _captureCount);
|
||||
_events.Enqueue($"Safety:{count}");
|
||||
return _changedCapture is { } changed && count >= changed
|
||||
? Changed
|
||||
: Baseline;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeDelay : IPgmCutsSequenceDelay
|
||||
{
|
||||
private readonly ConcurrentQueue<string> _events;
|
||||
private int _calls;
|
||||
|
||||
public FakeDelay(ConcurrentQueue<string> events)
|
||||
{
|
||||
_events = events;
|
||||
}
|
||||
|
||||
public int? FailureOnCall { get; init; }
|
||||
|
||||
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||
{
|
||||
var call = Interlocked.Increment(ref _calls);
|
||||
_events.Enqueue($"Delay:{delay.TotalMilliseconds:0}");
|
||||
if (FailureOnCall == call)
|
||||
{
|
||||
throw new OperationCanceledException("fake observation interruption");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeEngineFactory : IPgmCutsSequenceEngineFactory
|
||||
{
|
||||
private readonly ConcurrentQueue<string> _events;
|
||||
private readonly FakeEngine _engine;
|
||||
|
||||
public FakeEngineFactory(
|
||||
ConcurrentQueue<string> events,
|
||||
FakeEngine engine)
|
||||
{
|
||||
_events = events;
|
||||
_engine = engine;
|
||||
}
|
||||
|
||||
public int CreateCount { get; private set; }
|
||||
|
||||
public PgmCutsSequencePlan? Plan { get; private set; }
|
||||
|
||||
public IPlayoutEngine Create(
|
||||
PgmCutsSequencePlan plan,
|
||||
PgmDiagnosticTargetSnapshot baseline)
|
||||
{
|
||||
CreateCount++;
|
||||
Plan = plan;
|
||||
Assert.True(baseline.IsEligible);
|
||||
_events.Enqueue("CreateEngine");
|
||||
return _engine;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeEngine : IPlayoutEngine
|
||||
{
|
||||
private readonly ConcurrentQueue<string> _events;
|
||||
|
||||
public FakeEngine(ConcurrentQueue<string> events)
|
||||
{
|
||||
_events = events;
|
||||
}
|
||||
|
||||
public Dictionary<PgmCutsSequenceStage, PlayoutResultCode> Results { get; } = [];
|
||||
|
||||
public Exception? QuarantineFailure { get; init; }
|
||||
|
||||
public PlayoutStatus Status { get; } = new(
|
||||
PlayoutMode.Live,
|
||||
PlayoutConnectionState.Disconnected,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
"fake",
|
||||
DateTimeOffset.UnixEpoch,
|
||||
0);
|
||||
|
||||
public event EventHandler<PlayoutStatusChangedEventArgs>? StatusChanged
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
|
||||
public Task<PlayoutResult> ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_events.Enqueue("Connect");
|
||||
return Result(PgmCutsSequenceStage.Connect, PlayoutOperation.Connect);
|
||||
}
|
||||
|
||||
public Task<PlayoutResult> DisconnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_events.Enqueue("Disconnect");
|
||||
return Result(PgmCutsSequenceStage.Disconnect, PlayoutOperation.Disconnect);
|
||||
}
|
||||
|
||||
public Task<PlayoutResult> PrepareAsync(
|
||||
PlayoutCue cue,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_events.Enqueue($"Prepare:{cue.SceneName}");
|
||||
return Result(PgmCutsSequenceStage.PrepareFirst, PlayoutOperation.Prepare);
|
||||
}
|
||||
|
||||
public Task<PlayoutResult> TakeInAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_events.Enqueue("TakeIn");
|
||||
return Result(PgmCutsSequenceStage.TakeIn, PlayoutOperation.TakeIn);
|
||||
}
|
||||
|
||||
public Task<PlayoutResult> NextAsync(
|
||||
PlayoutCue cue,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_events.Enqueue($"Next:{cue.SceneName}");
|
||||
return Result(PgmCutsSequenceStage.Next, PlayoutOperation.Next);
|
||||
}
|
||||
|
||||
public Task<PlayoutResult> TakeOutAsync(
|
||||
PlayoutTakeOutScope scope,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_events.Enqueue($"TakeOut:{scope}");
|
||||
return Result(PgmCutsSequenceStage.TakeOut, PlayoutOperation.TakeOut);
|
||||
}
|
||||
|
||||
public ValueTask QuarantineAsync()
|
||||
{
|
||||
_events.Enqueue("Quarantine");
|
||||
return QuarantineFailure is null
|
||||
? ValueTask.CompletedTask
|
||||
: ValueTask.FromException(QuarantineFailure);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_events.Enqueue("Dispose");
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private Task<PlayoutResult> Result(
|
||||
PgmCutsSequenceStage stage,
|
||||
PlayoutOperation operation)
|
||||
{
|
||||
var code = Results.GetValueOrDefault(stage, PlayoutResultCode.Success);
|
||||
return Task.FromResult(new PlayoutResult(
|
||||
operation,
|
||||
code,
|
||||
false,
|
||||
"fake",
|
||||
DateTimeOffset.UnixEpoch));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,19 @@ internal sealed class FakeK3dSessionFactory : IK3dSessionFactory
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeK3dSession : IK3dSession
|
||||
internal sealed class FakeK3dSdkDispatchLatch(bool allow = true) : IK3dSdkDispatchLatch
|
||||
{
|
||||
public IDisposable? TryClaim() => allow ? new Claim() : null;
|
||||
|
||||
private sealed class Claim : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeK3dSession : IK3dGuardedConnectSession
|
||||
{
|
||||
private int _disposed;
|
||||
private int _ktapDispatchGate;
|
||||
@@ -66,24 +78,53 @@ internal sealed class FakeK3dSession : IK3dSession
|
||||
public Action<string>? CallObserver { get; set; }
|
||||
|
||||
public void Connect(ValidatedPlayoutOptions options)
|
||||
=> ConnectCore(options, finalSafetyCheck: null);
|
||||
|
||||
public void ConnectGuarded(
|
||||
ValidatedPlayoutOptions options,
|
||||
Func<bool> finalSafetyCheck,
|
||||
IK3dSdkDispatchLatch dispatchLatch)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(finalSafetyCheck);
|
||||
ArgumentNullException.ThrowIfNull(dispatchLatch);
|
||||
ConnectCore(options, finalSafetyCheck, dispatchLatch);
|
||||
}
|
||||
|
||||
private void ConnectCore(
|
||||
ValidatedPlayoutOptions options,
|
||||
Func<bool>? finalSafetyCheck,
|
||||
IK3dSdkDispatchLatch? dispatchLatch = null)
|
||||
{
|
||||
Record("Connect");
|
||||
BeforeKtapDispatchAction?.Invoke();
|
||||
if (Interlocked.CompareExchange(ref _ktapDispatchGate, 1, 0) != 0)
|
||||
if (finalSafetyCheck is not null && !finalSafetyCheck())
|
||||
{
|
||||
throw new InvalidOperationException("Fake KTAP dispatch was prevented.");
|
||||
throw new K3dConnectSafetyException();
|
||||
}
|
||||
|
||||
LastKtapConnectState = PlayoutKtapConnectState.Attempted;
|
||||
Interlocked.Increment(ref _ktapDispatchCount);
|
||||
try
|
||||
using (var dispatchClaim = dispatchLatch?.TryClaim())
|
||||
{
|
||||
ConnectAction?.Invoke(options);
|
||||
}
|
||||
catch
|
||||
{
|
||||
LastKtapConnectState = PlayoutKtapConnectState.Failed;
|
||||
throw;
|
||||
if (dispatchLatch is not null && dispatchClaim is null)
|
||||
{
|
||||
throw new K3dConnectSafetyException();
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(ref _ktapDispatchGate, 1, 0) != 0)
|
||||
{
|
||||
throw new InvalidOperationException("Fake KTAP dispatch was prevented.");
|
||||
}
|
||||
|
||||
LastKtapConnectState = PlayoutKtapConnectState.Attempted;
|
||||
Interlocked.Increment(ref _ktapDispatchCount);
|
||||
try
|
||||
{
|
||||
ConnectAction?.Invoke(options);
|
||||
}
|
||||
catch
|
||||
{
|
||||
LastKtapConnectState = PlayoutKtapConnectState.Failed;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
LastKtapConnectState = PlayoutKtapConnectState.AcceptedUnconfirmed;
|
||||
|
||||
@@ -263,6 +263,79 @@ public sealed class SmokeCommandLineTests
|
||||
Assert.Equal("invalid-port", parsed.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_PgmCutsSequenceRequiresItsOwnLongAcknowledgementAndFixedInputs()
|
||||
{
|
||||
var complete = ValidPgmCutsArguments();
|
||||
var missingAcknowledgement = SmokeCommandLine.Parse(
|
||||
complete.Where(value => value != SmokeCommandLine.PgmCutsSequenceAcknowledgementFlag)
|
||||
.ToArray());
|
||||
var wrongAcknowledgement = SmokeCommandLine.Parse(
|
||||
complete.Select(value => value == SmokeCommandLine.PgmCutsSequenceAcknowledgementFlag
|
||||
? SmokeCommandLine.AcknowledgementFlag
|
||||
: value).ToArray());
|
||||
var parsed = SmokeCommandLine.Parse(complete);
|
||||
var explicitObservation = SmokeCommandLine.Parse(
|
||||
complete.Concat(["--observe-ms", "5000"]).ToArray());
|
||||
|
||||
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.True(explicitObservation.IsValid);
|
||||
Assert.Equal(SmokeCommandKind.PgmCutsSequence, parsed.Invocation.Kind);
|
||||
Assert.Equal(5_000, parsed.Invocation.ObservationMilliseconds);
|
||||
Assert.Equal("C:\\Cuts", parsed.Invocation.SceneRoot);
|
||||
Assert.Null(parsed.Invocation.ConfigPath);
|
||||
Assert.Null(parsed.Invocation.PrepareSceneCode);
|
||||
Assert.Null(parsed.Invocation.NextSceneCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("--config")]
|
||||
[InlineData("--prepare")]
|
||||
[InlineData("--next")]
|
||||
public void Parse_PgmCutsSequenceRejectsConfigAndCallerSelectedScenes(string option)
|
||||
{
|
||||
var parsed = SmokeCommandLine.Parse(
|
||||
ValidPgmCutsArguments().Concat([option, "sensitive-value"]).ToArray());
|
||||
|
||||
Assert.False(parsed.IsValid);
|
||||
Assert.Equal("unknown-option", parsed.ErrorCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("4999")]
|
||||
[InlineData("5001")]
|
||||
[InlineData("secret")]
|
||||
public void Parse_PgmCutsSequenceOnlyAcceptsExplicitFiveSecondObservation(string value)
|
||||
{
|
||||
var parsed = SmokeCommandLine.Parse(
|
||||
ValidPgmCutsArguments().Concat(["--observe-ms", value]).ToArray());
|
||||
|
||||
Assert.False(parsed.IsValid);
|
||||
Assert.Equal("invalid-observation-duration", parsed.ErrorCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("localhost", "C:\\Cuts")]
|
||||
[InlineData("192.0.2.1", "C:\\Cuts")]
|
||||
[InlineData("127.0.0.1", "relative-cuts")]
|
||||
public void Parse_PgmCutsSequenceRequiresLiteralLoopbackAndAbsoluteSceneRoot(
|
||||
string host,
|
||||
string sceneRoot)
|
||||
{
|
||||
var arguments = ValidPgmCutsArguments();
|
||||
arguments[Array.IndexOf(arguments, "127.0.0.1")] = host;
|
||||
arguments[Array.IndexOf(arguments, "C:\\Cuts")] = sceneRoot;
|
||||
|
||||
var parsed = SmokeCommandLine.Parse(arguments);
|
||||
|
||||
Assert.False(parsed.IsValid);
|
||||
Assert.Equal("missing-or-invalid-required-option", parsed.ErrorCode);
|
||||
}
|
||||
|
||||
private static SmokeCommandParseResult ValidSequenceArguments(
|
||||
string prepare = "5001",
|
||||
string observation = "1000") =>
|
||||
@@ -279,4 +352,18 @@ public sealed class SmokeCommandLineTests
|
||||
"--observe-ms",
|
||||
observation
|
||||
]);
|
||||
|
||||
private static string[] ValidPgmCutsArguments() =>
|
||||
[
|
||||
"--pgm-cuts-sequence",
|
||||
SmokeCommandLine.PgmCutsSequenceAcknowledgementFlag,
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"30001",
|
||||
"--expected-pgm-window-title",
|
||||
"Tornado2 PGM",
|
||||
"--scene-root",
|
||||
"C:\\Cuts"
|
||||
];
|
||||
}
|
||||
|
||||
@@ -327,6 +327,640 @@ public sealed class TornadoPlayoutEngineTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuardedConnect_FinalTargetFailure_PreventsKtapAndAbandonsWithoutDisconnect()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
var session = new FakeK3dSession();
|
||||
await using var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "test-scene"),
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
||||
liveAuthorized: false,
|
||||
finalConnectSafetyCheck: () => false,
|
||||
abandonOnTargetMismatch: true);
|
||||
|
||||
var result = await engine.ConnectAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.Failed, result.Code);
|
||||
Assert.Equal(0, session.KtapDispatchCount);
|
||||
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuardedConnect_KtapFailure_IsOutcomeUnknownAndNeverReconnectable()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
var session = new FakeK3dSession
|
||||
{
|
||||
ConnectAction = _ => throw new COMException("fake ambiguous KTAP failure")
|
||||
};
|
||||
var sessionFactory = new FakeK3dSessionFactory(() => session);
|
||||
await using var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "test-scene"),
|
||||
sessionFactory,
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
||||
liveAuthorized: false,
|
||||
finalConnectSafetyCheck: () => true,
|
||||
abandonOnTargetMismatch: true);
|
||||
|
||||
var result = await engine.ConnectAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
|
||||
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
|
||||
Assert.False(engine.Status.IsCommandAvailable);
|
||||
Assert.Equal(1, session.KtapDispatchCount);
|
||||
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
await engine.PollProcessOnceAsync(CancellationToken.None);
|
||||
Assert.Equal(1, sessionFactory.CreateCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuardedConnect_CallbackCancellationAfterKtap_IsOutcomeUnknown()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
var session = new FakeK3dSession
|
||||
{
|
||||
ConnectAction = _ => throw new OperationCanceledException(
|
||||
"fake cancellation after KTAP dispatch")
|
||||
};
|
||||
await using var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "test-scene"),
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
||||
liveAuthorized: false,
|
||||
finalConnectSafetyCheck: () => true,
|
||||
abandonOnTargetMismatch: true);
|
||||
|
||||
var result = await engine.ConnectAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
|
||||
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
|
||||
Assert.Equal(1, session.KtapDispatchCount);
|
||||
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuardedConnect_CancelledBeforeSerialization_RemainsKnownAndDoesNotCreateSession()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
var sessionFactory = new FakeK3dSessionFactory();
|
||||
await using var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "test-scene"),
|
||||
sessionFactory,
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
||||
liveAuthorized: false,
|
||||
finalConnectSafetyCheck: () => true,
|
||||
abandonOnTargetMismatch: true);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
|
||||
var result = await engine.ConnectAsync(cancellation.Token);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.Cancelled, result.Code);
|
||||
Assert.Equal(0, sessionFactory.CreateCount);
|
||||
Assert.Equal(PlayoutKtapConnectState.NotAttempted, engine.Status.LastKtapConnectState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuardedConnect_SlowSafetyCheckPastTimeoutNeverDispatchesKtap()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
using var safetyStarted = new ManualResetEventSlim();
|
||||
using var releaseSafety = new ManualResetEventSlim();
|
||||
var session = new FakeK3dSession();
|
||||
var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "test-scene"),
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
||||
liveAuthorized: false,
|
||||
finalConnectSafetyCheck: () =>
|
||||
{
|
||||
safetyStarted.Set();
|
||||
Assert.True(releaseSafety.Wait(TimeSpan.FromSeconds(5)));
|
||||
return true;
|
||||
},
|
||||
abandonOnTargetMismatch: true);
|
||||
|
||||
try
|
||||
{
|
||||
var connectTask = engine.ConnectAsync(CancellationToken.None);
|
||||
Assert.True(safetyStarted.Wait(TimeSpan.FromSeconds(2)));
|
||||
|
||||
var result = await connectTask;
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
|
||||
Assert.Equal(0, session.KtapDispatchCount);
|
||||
releaseSafety.Set();
|
||||
Assert.True(SpinWait.SpinUntil(
|
||||
() => session.Calls.Contains("Abandon"),
|
||||
TimeSpan.FromSeconds(2)));
|
||||
Assert.Equal(0, session.KtapDispatchCount);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => engine.QuarantineAsync().AsTask());
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
releaseSafety.Set();
|
||||
await engine.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuardedConnect_TimeoutWinningAtomicClaimPreventsKtapDispatch()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
using var claimStarted = new ManualResetEventSlim();
|
||||
using var releaseClaim = new ManualResetEventSlim();
|
||||
var session = new FakeK3dSession();
|
||||
var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "test-scene"),
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
||||
liveAuthorized: false,
|
||||
finalConnectSafetyCheck: () => true,
|
||||
abandonOnTargetMismatch: true,
|
||||
beforeSdkDispatchClaim: () =>
|
||||
{
|
||||
claimStarted.Set();
|
||||
Assert.True(releaseClaim.Wait(TimeSpan.FromSeconds(5)));
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var connectTask = engine.ConnectAsync(CancellationToken.None);
|
||||
Assert.True(claimStarted.Wait(TimeSpan.FromSeconds(2)));
|
||||
|
||||
var connect = await connectTask;
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, connect.Code);
|
||||
Assert.Equal(0, session.KtapDispatchCount);
|
||||
releaseClaim.Set();
|
||||
Assert.True(SpinWait.SpinUntil(
|
||||
() => session.Calls.Contains("Abandon"),
|
||||
TimeSpan.FromSeconds(2)));
|
||||
Assert.Equal(0, session.KtapDispatchCount);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => engine.QuarantineAsync().AsTask());
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
releaseClaim.Set();
|
||||
await engine.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuardedConnect_PostGenerationChange_AbandonsWithoutDisconnect()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
var original = ProcessSnapshot("pgm-generation-a");
|
||||
var changed = ProcessSnapshot("pgm-generation-b");
|
||||
var session = new FakeK3dSession();
|
||||
var finalChecks = 0;
|
||||
await using var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "test-scene"),
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(original, changed),
|
||||
liveAuthorized: false,
|
||||
finalConnectSafetyCheck: () => Interlocked.Increment(ref finalChecks) == 1,
|
||||
abandonOnTargetMismatch: true);
|
||||
|
||||
var result = await engine.ConnectAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
|
||||
Assert.Equal(1, finalChecks);
|
||||
Assert.Equal(1, session.KtapDispatchCount);
|
||||
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuardedTargetPolicy_PreCommandGenerationChange_AbandonsWithoutDisconnect()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
var original = ProcessSnapshot("pgm-generation-a");
|
||||
var changed = ProcessSnapshot("pgm-generation-b");
|
||||
var session = new FakeK3dSession();
|
||||
await using var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "test-scene"),
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(original, original, changed),
|
||||
liveAuthorized: false,
|
||||
finalConnectSafetyCheck: () => true,
|
||||
abandonOnTargetMismatch: true);
|
||||
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
var prepare = await engine.PrepareAsync(
|
||||
Cue("test-scene"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, prepare.Code);
|
||||
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
Assert.DoesNotContain(session.Calls, call =>
|
||||
call.StartsWith("Prepare:", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuardedTargetPolicy_AmbiguousPrepareFailure_AbandonsWithoutDisconnect()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
var stable = ProcessSnapshot("pgm-generation-a");
|
||||
var session = new FakeK3dSession
|
||||
{
|
||||
PrepareAction = (_, _) => throw new COMException("fake ambiguous prepare failure")
|
||||
};
|
||||
await using var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "test-scene"),
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(stable),
|
||||
liveAuthorized: false,
|
||||
finalConnectSafetyCheck: () => true,
|
||||
abandonOnTargetMismatch: true);
|
||||
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
var prepare = await engine.PrepareAsync(
|
||||
Cue("test-scene"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, prepare.Code);
|
||||
Assert.Equal(
|
||||
new[] { "Connect", "Prepare:test-scene", "Abandon" },
|
||||
session.Calls);
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuardedTargetPolicy_NextChecksTargetBetweenPrepareAndPlay()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create(
|
||||
"test-scene.t2s",
|
||||
"next-scene.t2s");
|
||||
var stable = ProcessSnapshot("pgm-generation-a");
|
||||
var session = new FakeK3dSession();
|
||||
var safetyChecks = 0;
|
||||
await using var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "test-scene", "next-scene"),
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(stable),
|
||||
liveAuthorized: false,
|
||||
finalConnectSafetyCheck: () =>
|
||||
Interlocked.Increment(ref safetyChecks) != 7,
|
||||
abandonOnTargetMismatch: true);
|
||||
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
Assert.True((await engine.PrepareAsync(
|
||||
Cue("test-scene"),
|
||||
CancellationToken.None)).IsSuccess);
|
||||
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
|
||||
|
||||
var next = await engine.NextAsync(
|
||||
Cue("next-scene"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, next.Code);
|
||||
Assert.Equal(7, safetyChecks);
|
||||
Assert.Equal(1, session.Calls.Count(call => call == "Play"));
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"Connect",
|
||||
"Prepare:test-scene",
|
||||
"Play",
|
||||
"Prepare:next-scene",
|
||||
"Abandon"
|
||||
},
|
||||
session.Calls);
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuardedTargetPolicy_DisconnectPrecheckFailureAbandonsWithoutDisconnect()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
var stable = ProcessSnapshot("pgm-generation-a");
|
||||
var session = new FakeK3dSession();
|
||||
var safetyChecks = 0;
|
||||
await using var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "test-scene"),
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(stable),
|
||||
liveAuthorized: false,
|
||||
finalConnectSafetyCheck: () =>
|
||||
Interlocked.Increment(ref safetyChecks) == 1,
|
||||
abandonOnTargetMismatch: true);
|
||||
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
var disconnect = await engine.DisconnectAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, disconnect.Code);
|
||||
Assert.Equal(2, safetyChecks);
|
||||
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuardedTargetPolicy_SlowCommandSafetyCheckPastTimeoutNeverDispatchesSdkCall()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
using var safetyStarted = new ManualResetEventSlim();
|
||||
using var releaseSafety = new ManualResetEventSlim();
|
||||
var stable = ProcessSnapshot("pgm-generation-a");
|
||||
var session = new FakeK3dSession();
|
||||
var safetyChecks = 0;
|
||||
var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "test-scene"),
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(stable),
|
||||
liveAuthorized: false,
|
||||
finalConnectSafetyCheck: () =>
|
||||
{
|
||||
if (Interlocked.Increment(ref safetyChecks) == 2)
|
||||
{
|
||||
safetyStarted.Set();
|
||||
Assert.True(releaseSafety.Wait(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
abandonOnTargetMismatch: true);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
var prepareTask = engine.PrepareAsync(
|
||||
Cue("test-scene"),
|
||||
CancellationToken.None);
|
||||
Assert.True(safetyStarted.Wait(TimeSpan.FromSeconds(2)));
|
||||
|
||||
var prepare = await prepareTask;
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, prepare.Code);
|
||||
releaseSafety.Set();
|
||||
Assert.True(SpinWait.SpinUntil(
|
||||
() => session.Calls.Contains("Abandon"),
|
||||
TimeSpan.FromSeconds(2)));
|
||||
Assert.DoesNotContain(
|
||||
session.Calls,
|
||||
call => call.StartsWith("Prepare:", StringComparison.Ordinal));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => engine.QuarantineAsync().AsTask());
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
releaseSafety.Set();
|
||||
await engine.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuardedTargetPolicy_SlowDisconnectSafetyCheckPastTimeoutNeverDisconnects()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
using var safetyStarted = new ManualResetEventSlim();
|
||||
using var releaseSafety = new ManualResetEventSlim();
|
||||
var stable = ProcessSnapshot("pgm-generation-a");
|
||||
var session = new FakeK3dSession();
|
||||
var safetyChecks = 0;
|
||||
var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "test-scene"),
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(stable),
|
||||
liveAuthorized: false,
|
||||
finalConnectSafetyCheck: () =>
|
||||
{
|
||||
if (Interlocked.Increment(ref safetyChecks) == 2)
|
||||
{
|
||||
safetyStarted.Set();
|
||||
Assert.True(releaseSafety.Wait(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
abandonOnTargetMismatch: true);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
var disconnectTask = engine.DisconnectAsync(CancellationToken.None);
|
||||
Assert.True(safetyStarted.Wait(TimeSpan.FromSeconds(2)));
|
||||
|
||||
var disconnect = await disconnectTask;
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, disconnect.Code);
|
||||
releaseSafety.Set();
|
||||
Assert.True(SpinWait.SpinUntil(
|
||||
() => session.Calls.Contains("Abandon"),
|
||||
TimeSpan.FromSeconds(2)));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => engine.QuarantineAsync().AsTask());
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
releaseSafety.Set();
|
||||
await engine.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuardedTargetPolicy_TimeoutWinningAtomicCommandClaimPreventsPrepare()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
using var claimStarted = new ManualResetEventSlim();
|
||||
using var releaseClaim = new ManualResetEventSlim();
|
||||
var session = new FakeK3dSession();
|
||||
var claims = 0;
|
||||
var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "test-scene"),
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(ProcessSnapshot("pgm-generation-a")),
|
||||
liveAuthorized: false,
|
||||
finalConnectSafetyCheck: () => true,
|
||||
abandonOnTargetMismatch: true,
|
||||
beforeSdkDispatchClaim: () =>
|
||||
{
|
||||
if (Interlocked.Increment(ref claims) == 2)
|
||||
{
|
||||
claimStarted.Set();
|
||||
Assert.True(releaseClaim.Wait(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
var prepareTask = engine.PrepareAsync(
|
||||
Cue("test-scene"),
|
||||
CancellationToken.None);
|
||||
Assert.True(claimStarted.Wait(TimeSpan.FromSeconds(2)));
|
||||
|
||||
var prepare = await prepareTask;
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, prepare.Code);
|
||||
releaseClaim.Set();
|
||||
Assert.True(SpinWait.SpinUntil(
|
||||
() => session.Calls.Contains("Abandon"),
|
||||
TimeSpan.FromSeconds(2)));
|
||||
Assert.DoesNotContain(
|
||||
session.Calls,
|
||||
call => call.StartsWith("Prepare:", StringComparison.Ordinal));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => engine.QuarantineAsync().AsTask());
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
releaseClaim.Set();
|
||||
await engine.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuardedTargetPolicy_NextUsesSeparateAtomicClaimsForPrepareAndPlay()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create(
|
||||
"test-scene.t2s",
|
||||
"next-scene.t2s");
|
||||
using var playClaimStarted = new ManualResetEventSlim();
|
||||
using var releasePlayClaim = new ManualResetEventSlim();
|
||||
var session = new FakeK3dSession();
|
||||
var claims = 0;
|
||||
var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "test-scene", "next-scene"),
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(ProcessSnapshot("pgm-generation-a")),
|
||||
liveAuthorized: false,
|
||||
finalConnectSafetyCheck: () => true,
|
||||
abandonOnTargetMismatch: true,
|
||||
beforeSdkDispatchClaim: () =>
|
||||
{
|
||||
if (Interlocked.Increment(ref claims) == 5)
|
||||
{
|
||||
playClaimStarted.Set();
|
||||
Assert.True(releasePlayClaim.Wait(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
Assert.True((await engine.PrepareAsync(
|
||||
Cue("test-scene"),
|
||||
CancellationToken.None)).IsSuccess);
|
||||
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
|
||||
var nextTask = engine.NextAsync(
|
||||
Cue("next-scene"),
|
||||
CancellationToken.None);
|
||||
Assert.True(playClaimStarted.Wait(TimeSpan.FromSeconds(2)));
|
||||
|
||||
var next = await nextTask;
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, next.Code);
|
||||
Assert.Contains("Prepare:next-scene", session.Calls);
|
||||
Assert.Equal(1, session.Calls.Count(call => call == "Play"));
|
||||
releasePlayClaim.Set();
|
||||
Assert.True(SpinWait.SpinUntil(
|
||||
() => session.Calls.Contains("Abandon"),
|
||||
TimeSpan.FromSeconds(2)));
|
||||
Assert.Equal(1, session.Calls.Count(call => call == "Play"));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => engine.QuarantineAsync().AsTask());
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
releasePlayClaim.Set();
|
||||
await engine.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GuardedTargetPolicy_TimeoutWinningAtomicDisconnectClaimPreventsDisconnect()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
using var claimStarted = new ManualResetEventSlim();
|
||||
using var releaseClaim = new ManualResetEventSlim();
|
||||
var session = new FakeK3dSession();
|
||||
var claims = 0;
|
||||
var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "test-scene"),
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(ProcessSnapshot("pgm-generation-a")),
|
||||
liveAuthorized: false,
|
||||
finalConnectSafetyCheck: () => true,
|
||||
abandonOnTargetMismatch: true,
|
||||
beforeSdkDispatchClaim: () =>
|
||||
{
|
||||
if (Interlocked.Increment(ref claims) == 2)
|
||||
{
|
||||
claimStarted.Set();
|
||||
Assert.True(releaseClaim.Wait(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
var disconnectTask = engine.DisconnectAsync(CancellationToken.None);
|
||||
Assert.True(claimStarted.Wait(TimeSpan.FromSeconds(2)));
|
||||
|
||||
var disconnect = await disconnectTask;
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, disconnect.Code);
|
||||
releaseClaim.Set();
|
||||
Assert.True(SpinWait.SpinUntil(
|
||||
() => session.Calls.Contains("Abandon"),
|
||||
TimeSpan.FromSeconds(2)));
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => engine.QuarantineAsync().AsTask());
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
releaseClaim.Set();
|
||||
await engine.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LastKtapEvidence_SurvivesLaterFailureBeforeAnotherDispatch()
|
||||
{
|
||||
@@ -1171,6 +1805,34 @@ public sealed class TornadoPlayoutEngineTests
|
||||
Assert.Equal(1, sessionFactory.CreateCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Quarantine_WhenLocalAbandonFails_ReportsFailureAndNeverDisconnectsOrDisposes()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||
var session = new FakeK3dSession
|
||||
{
|
||||
AbandonAction = () => throw new InvalidOperationException(
|
||||
"fake local abandon failure")
|
||||
};
|
||||
var sessionFactory = new FakeK3dSessionFactory(() => session);
|
||||
await using var engine = CreateEngine(
|
||||
TestOptions(scenes.Path, "test-scene"),
|
||||
sessionFactory,
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
|
||||
liveAuthorized: false);
|
||||
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => engine.QuarantineAsync().AsTask());
|
||||
|
||||
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
Assert.Equal(1, sessionFactory.CreateCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
@@ -1219,7 +1881,10 @@ public sealed class TornadoPlayoutEngineTests
|
||||
FakeK3dSessionFactory sessionFactory,
|
||||
FakeRegistrationProbe registrationProbe,
|
||||
FakeTornadoProcessProbe processProbe,
|
||||
bool liveAuthorized)
|
||||
bool liveAuthorized,
|
||||
Func<bool>? finalConnectSafetyCheck = null,
|
||||
bool abandonOnTargetMismatch = false,
|
||||
Action? beforeSdkDispatchClaim = null)
|
||||
{
|
||||
var options = ValidatedPlayoutOptions.Create(rawOptions);
|
||||
IStaDispatcher? dispatcher = options.Mode is PlayoutMode.Test or PlayoutMode.Live
|
||||
@@ -1233,7 +1898,10 @@ public sealed class TornadoPlayoutEngineTests
|
||||
dispatcher,
|
||||
new FakeLiveAuthorization(liveAuthorized),
|
||||
TimeProvider.System,
|
||||
startMonitor: false);
|
||||
startMonitor: false,
|
||||
finalConnectSafetyCheck: finalConnectSafetyCheck,
|
||||
abandonOnTargetMismatch: abandonOnTargetMismatch,
|
||||
beforeSdkDispatchClaim: beforeSdkDispatchClaim);
|
||||
}
|
||||
|
||||
private static PlayoutOptions TestOptions(
|
||||
|
||||
Reference in New Issue
Block a user