feat: verify Tornado PGM cut sequence
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user