864 lines
32 KiB
C#
864 lines
32 KiB
C#
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));
|
|
}
|
|
}
|
|
}
|