using System.Security.Cryptography; using MBN_STOCK_WEBVIEW.Core.Playout; using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; using MBN_STOCK_WEBVIEW.Playout.Safety; namespace MBN_STOCK_WEBVIEW.Playout.Tests; public sealed class PlayoutLaunchAuthorizationTests { private const string Capability = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; [Fact] public void Missing_gate_environment_preserves_the_standard_launch_profile() { using var environment = ClearGateEnvironment(); var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment(); Assert.False(authorization.IsGateA); Assert.True(authorization.AllowsDatabaseWrites); Assert.True(authorization.AllowsCompositionMutation); Assert.False(authorization.TryClaimGateAPrepare(Capability)); } [Fact] public void Non_gate_capture_preserves_existing_live_authorization_semantics() { using var environment = ClearGateEnvironment() .Set( PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, PlayoutOptionsLoader.RequiredLiveAuthorizationValue); using var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment(); Assert.False(authorization.IsGateA); Assert.True(authorization.IsAuthorizedForThisLaunch); Assert.Equal( PlayoutOptionsLoader.RequiredLiveAuthorizationValue, Environment.GetEnvironmentVariable( PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable)); } [Fact] public void Gate_capture_consumes_static_live_bearer_and_second_capture_fails_closed() { using var scenes = TemporarySceneDirectory.Create("5001.t2s"); var scenePath = Path.Combine(scenes.Path, "5001.t2s"); var clock = new ManualGateTimeProvider(); var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks; using var environment = GateEnvironment( Capability, "41308", "638878000000000000", expiry.ToString(System.Globalization.CultureInfo.InvariantCulture)) .Set( PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, PlayoutOptionsLoader.RequiredLiveAuthorizationValue); using var first = PlayoutLaunchAuthorization.CaptureFromEnvironment( clock, scenePath, SceneHash(scenePath)); PlayoutLaunchAuthorization? second = null; Assert.Throws(() => second = PlayoutLaunchAuthorization.CaptureFromEnvironment()); Assert.True(first.IsGateA); Assert.True(first.IsAuthorizedForThisLaunch); Assert.Null(Environment.GetEnvironmentVariable( PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable)); Assert.Null(second); Assert.Null(Environment.GetEnvironmentVariable( PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable)); Assert.Null(Environment.GetEnvironmentVariable( PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable)); Assert.Null(Environment.GetEnvironmentVariable( PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable)); Assert.Null(Environment.GetEnvironmentVariable( PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable)); } [Fact] public void Gate_environment_is_captured_once_and_cleared_before_browser_startup() { using var scenes = TemporarySceneDirectory.Create("5001.t2s"); var scenePath = Path.Combine(scenes.Path, "5001.t2s"); var clock = new ManualGateTimeProvider(); var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks; using var environment = GateEnvironment( Capability, "41308", "638878000000000000", expiry.ToString(System.Globalization.CultureInfo.InvariantCulture)); using var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment( clock, scenePath, SceneHash(scenePath)); Assert.True(authorization.IsGateA); Assert.Equal(41308, authorization.ExpectedPgmProcessId); Assert.Equal(638878000000000000, authorization.ExpectedPgmStartTimeUtcTicks); Assert.Equal(expiry, authorization.ExpiresAtUtcTicks); Assert.False(authorization.AllowsDatabaseWrites); Assert.False(authorization.AllowsCompositionMutation); Assert.Null(Environment.GetEnvironmentVariable( PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable)); Assert.Null(Environment.GetEnvironmentVariable( PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable)); Assert.Null(Environment.GetEnvironmentVariable( PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable)); Assert.Null(Environment.GetEnvironmentVariable( PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable)); environment.Set( PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable, new string('F', 64)); Assert.True(authorization.TryClaimGateAPrepare(Capability)); Assert.False(authorization.TryClaimGateAPrepare(new string('F', 64))); } [Theory] [InlineData("lowercase0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab")] [InlineData("ABCDEF")] [InlineData(null)] public void Invalid_or_partial_gate_environment_fails_closed_and_is_cleared( string? capability) { using var environment = ClearGateEnvironment() .Set( PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable, capability) .Set( PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable, "41308"); Assert.Throws( PlayoutLaunchAuthorization.CaptureFromEnvironment); Assert.Null(Environment.GetEnvironmentVariable( PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable)); Assert.Null(Environment.GetEnvironmentVariable( PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable)); Assert.Null(Environment.GetEnvironmentVariable( PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable)); Assert.Null(Environment.GetEnvironmentVariable( PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable)); } [Fact] public void Malformed_first_capture_permanently_blocks_reinjection_and_clears_every_bearer() { using var scenes = TemporarySceneDirectory.Create("5001.t2s"); var scenePath = Path.Combine(scenes.Path, "5001.t2s"); var clock = new ManualGateTimeProvider(); var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks; using var environment = ClearGateEnvironment() .Set( PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable, "ABCDEF") .Set( PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable, "41308") .Set( PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, PlayoutOptionsLoader.RequiredLiveAuthorizationValue); Assert.Throws(() => PlayoutLaunchAuthorization.CaptureFromEnvironment( clock, scenePath, SceneHash(scenePath))); environment .Set( PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable, Capability) .Set( PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable, "41308") .Set( PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable, "638878000000000000") .Set( PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable, expiry.ToString(System.Globalization.CultureInfo.InvariantCulture)) .Set( PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, PlayoutOptionsLoader.RequiredLiveAuthorizationValue); PlayoutLaunchAuthorization? second = null; Assert.Throws(() => second = PlayoutLaunchAuthorization.CaptureFromEnvironment( clock, scenePath, SceneHash(scenePath))); Assert.Null(second); Assert.Null(Environment.GetEnvironmentVariable( PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable)); Assert.Null(Environment.GetEnvironmentVariable( PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable)); Assert.Null(Environment.GetEnvironmentVariable( PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable)); Assert.Null(Environment.GetEnvironmentVariable( PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable)); Assert.Null(Environment.GetEnvironmentVariable( PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable)); } [Fact] public async Task Concurrent_gate_captures_have_one_success_and_all_later_calls_fail_closed() { using var scenes = TemporarySceneDirectory.Create("5001.t2s"); var scenePath = Path.Combine(scenes.Path, "5001.t2s"); var clock = new ManualGateTimeProvider(); var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks; using var environment = GateEnvironment( Capability, "41308", "638878000000000000", expiry.ToString(System.Globalization.CultureInfo.InvariantCulture)) .Set( PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, PlayoutOptionsLoader.RequiredLiveAuthorizationValue); using var start = new ManualResetEventSlim(); var tasks = Enumerable.Range(0, 8) .Select(_ => Task.Run(() => { start.Wait(); try { return ( Authorization: PlayoutLaunchAuthorization.CaptureFromEnvironment( clock, scenePath, SceneHash(scenePath)), Error: (Exception?)null); } catch (Exception exception) { return (Authorization: (PlayoutLaunchAuthorization?)null, Error: exception); } })) .ToArray(); start.Set(); var results = await Task.WhenAll(tasks); var successful = Assert.Single(results, static result => result.Authorization is not null); Assert.True(successful.Authorization!.IsGateA); foreach (var failed in results.Where(static result => result.Authorization is null)) { Assert.IsType(failed.Error); } successful.Authorization.Dispose(); Assert.Null(Environment.GetEnvironmentVariable( PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable)); } [Theory] [InlineData(0L)] [InlineData(-1L)] [InlineData(9000000001L)] public void Expired_or_overlong_gate_environment_fails_closed_and_clears_every_value( long expiryOffsetTicks) { using var scenes = TemporarySceneDirectory.Create("5001.t2s"); var scenePath = Path.Combine(scenes.Path, "5001.t2s"); var clock = new ManualGateTimeProvider(); var expiry = clock.GetUtcNow().UtcTicks + expiryOffsetTicks; using var environment = GateEnvironment( Capability, "41308", "638878000000000000", expiry.ToString(System.Globalization.CultureInfo.InvariantCulture)); Assert.Throws(() => PlayoutLaunchAuthorization.CaptureFromEnvironment( clock, scenePath, SceneHash(scenePath))); Assert.Null(Environment.GetEnvironmentVariable( PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable)); Assert.Null(Environment.GetEnvironmentVariable( PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable)); Assert.Null(Environment.GetEnvironmentVariable( PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable)); Assert.Null(Environment.GetEnvironmentVariable( PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable)); } [Fact] public void Gate_scene_wrong_hash_fails_closed_and_does_not_leave_a_lease() { using var scenes = TemporarySceneDirectory.Create("5001.t2s"); var scenePath = Path.Combine(scenes.Path, "5001.t2s"); var clock = new ManualGateTimeProvider(); var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks; using var environment = GateEnvironment( Capability, "41308", "638878000000000000", expiry.ToString(System.Globalization.CultureInfo.InvariantCulture)); Assert.Throws(() => PlayoutLaunchAuthorization.CaptureFromEnvironment( clock, scenePath, new string('A', 64))); using var write = File.Open( scenePath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete); Assert.True(write.CanWrite); } [Fact] public void Gate_scene_native_read_lease_blocks_write_and_delete_until_native_shutdown() { using var scenes = TemporarySceneDirectory.Create("5001.t2s"); var scenePath = Path.Combine(scenes.Path, "5001.t2s"); var clock = new ManualGateTimeProvider(); var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks; using var environment = GateEnvironment( Capability, "41308", "638878000000000000", expiry.ToString(System.Globalization.CultureInfo.InvariantCulture)); var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment( clock, scenePath, SceneHash(scenePath)); using (var secondReader = File.Open( scenePath, FileMode.Open, FileAccess.Read, FileShare.Read)) { Assert.True(secondReader.CanRead); } Assert.ThrowsAny(() => File.Open( scenePath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite | FileShare.Delete)); Assert.ThrowsAny(() => File.Delete(scenePath)); authorization.CompleteGateAPrepare(); Assert.ThrowsAny(() => File.Delete(scenePath)); authorization.Dispose(); File.Delete(scenePath); Assert.False(File.Exists(scenePath)); } [Fact] public void Native_expiry_at_operator_claim_is_terminal_and_cannot_be_rearmed() { var clock = new ManualGateTimeProvider(); var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks; using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( Capability, timeProvider: clock, expiresAtUtcTicks: expiry); clock.Advance(TimeSpan.FromMinutes(1)); Assert.False(authorization.TryClaimGateAPrepare(Capability)); clock.SetUtcNow(clock.GetUtcNow().AddHours(-1)); Assert.False(authorization.TryClaimGateAPrepare(Capability)); Assert.False(authorization.TryClaimEngineCommand( PlayoutOperation.Connect, cue: null)); } [Fact] public void Expiry_between_operator_claim_and_engine_prepare_blocks_vendor_boundary() { var clock = new ManualGateTimeProvider(); var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks; using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( Capability, timeProvider: clock, expiresAtUtcTicks: expiry); clock.Advance(TimeSpan.FromMinutes(1) - TimeSpan.FromTicks(1)); Assert.True(authorization.TryClaimGateAPrepare(Capability)); clock.Advance(TimeSpan.FromTicks(1)); Assert.False(authorization.TryClaimEngineCommand( PlayoutOperation.Prepare, GateACue())); Assert.False(authorization.TryClaimEngineCommand( PlayoutOperation.Prepare, GateACue())); } [Fact] public void Connect_just_before_expiry_does_not_extend_later_prepare_authority() { var clock = new ManualGateTimeProvider(); var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks; using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( Capability, timeProvider: clock, expiresAtUtcTicks: expiry); clock.Advance(TimeSpan.FromMinutes(1) - TimeSpan.FromTicks(1)); Assert.True(authorization.TryClaimEngineCommand( PlayoutOperation.Connect, cue: null)); clock.Advance(TimeSpan.FromTicks(1)); Assert.False(authorization.TryClaimGateAPrepare(Capability)); Assert.False(authorization.TryClaimEngineCommand( PlayoutOperation.Prepare, GateACue())); } [Fact] public void Connect_claim_uses_monotonic_deadline_so_utc_rollback_cannot_extend_gate() { var clock = new ManualGateTimeProvider(); var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks; using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests( Capability, timeProvider: clock, expiresAtUtcTicks: expiry); clock.SetUtcNow(clock.GetUtcNow().AddHours(-1)); clock.AdvanceMonotonic(TimeSpan.FromMinutes(1)); Assert.False(authorization.TryClaimEngineCommand( PlayoutOperation.Connect, cue: null)); Assert.False(authorization.TryClaimGateAPrepare(Capability)); } [Fact] public void Capability_and_engine_prepare_are_each_one_shot_and_never_rearm() { var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability); var cue = GateACue(); Assert.False(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue)); Assert.False(authorization.TryClaimGateAPrepare(new string('F', 64))); Assert.True(authorization.TryClaimGateAPrepare(Capability)); Assert.False(authorization.TryClaimGateAPrepare(Capability)); Assert.True(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue)); Assert.False(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue)); authorization.CompleteGateAPrepare(); Assert.False(authorization.TryClaimGateAPrepare(Capability)); Assert.False(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue)); } [Fact] public void Wrong_prepare_target_consumes_the_engine_phase_and_blocks_correction() { var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability); Assert.True(authorization.TryClaimGateAPrepare(Capability)); Assert.False(authorization.TryClaimEngineCommand( PlayoutOperation.Prepare, GateACue() with { SceneName = "5006", SceneFile = "5006.t2s" })); Assert.False(authorization.TryClaimEngineCommand( PlayoutOperation.Prepare, GateACue())); } [Fact] public void GateA_rejects_operator_fade_and_background_changes_before_engine_dispatch() { var blockedCues = new[] { GateACue() with { FadeDuration = 5 }, GateACue() with { Mutations = [] }, GateACue() with { Mutations = [new PlayoutUseBackground(true)] }, GateACue() with { Mutations = [ new PlayoutUseBackground(false), new PlayoutUseBackground(false) ] }, GateACue() with { Mutations = [new PlayoutSetBackgroundTexture("background.png")] }, GateACue() with { Mutations = [new PlayoutSetBackgroundVideo("background.mp4", 0, 1)] } }; foreach (var cue in blockedCues) { var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability); Assert.True(authorization.TryClaimGateAPrepare(Capability)); Assert.False(authorization.TryClaimEngineCommand( PlayoutOperation.Prepare, cue)); Assert.False(authorization.TryClaimEngineCommand( PlayoutOperation.Prepare, GateACue())); } } [Fact] public async Task Real_5001_provider_cue_with_exact_disabled_background_claims_native_prepare() { var provider = new RegisteredLegacySceneCueProvider( new SinglePageDataSource(new LegacySceneDataPage( "s5001", new S5001DomesticStockSceneData( LegacyDomesticEquityMarket.Kospi, S5001DomesticStockMode.Current, S5001NxtBadge.None, "Samsung Electronics", ScenePriceDirection.Up, 70_000, 500, 0.72m), 1))); var page = await provider.CreatePageAsync( new LegacyPlaylistEntry("gate-a-5001", "5001", FadeDuration: 6), pageIndexZeroBased: 0); using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability); Assert.Equal(new PlayoutUseBackground(false), page.Cue.Mutations![0]); Assert.True(authorization.TryClaimGateAPrepare(Capability)); Assert.True(authorization.TryClaimEngineCommand( PlayoutOperation.Prepare, page.Cue)); } [Fact] public async Task Concurrent_operator_claims_have_exactly_one_winner() { var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability); using var start = new ManualResetEventSlim(); var tasks = Enumerable.Range(0, 16) .Select(_ => Task.Run(() => { start.Wait(); return authorization.TryClaimGateAPrepare(Capability); })) .ToArray(); start.Set(); var results = await Task.WhenAll(tasks); Assert.Single(results, static result => result); } [Fact] public void GateA_options_require_the_exact_closed_profile() { var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability); var valid = GateAOptions(); PlayoutEngineFactory.ValidateGateAOptions(valid, authorization); foreach (var invalid in new[] { GateAOptions().WithMode(PlayoutMode.DryRun), GateAOptions().WithAllowlist("5001", "5006"), GateAOptions().WithReconnect(true, 0), GateAOptions().WithReconnect(false, 1), GateAOptions().WithRefresh(null), GateAOptions().WithFade(5), GateAOptions().WithBackground(LegacySceneBackgroundKind.Texture), GateAOptions().WithSceneDirectory(Path.GetTempPath()), GateAOptions().WithClientPort(1), GateAOptions().WithOutputChannel(0) }) { Assert.Throws(() => PlayoutEngineFactory.ValidateGateAOptions(invalid, authorization)); } } private static PlayoutCue GateACue() => new( "5001.t2s", "5001", FadeDuration: 6, Mutations: [new PlayoutUseBackground(false)]); private static PlayoutOptions GateAOptions() => new() { Mode = PlayoutMode.Live, Host = "127.0.0.1", Port = 30001, TcpMode = 1, LayoutIndex = 10, TestSceneAllowlist = ["5001"], TrustedLiveOutputEnabled = true, ReconnectEnabled = false, MaximumReconnectAttempts = 0, MaximumAutomaticRefreshesPerTakeIn = 0, LegacySceneFadeDuration = 6, LegacySceneBackgroundKind = LegacySceneBackgroundKind.None, SceneDirectory = PlayoutLaunchAuthorization.GateAApprovedSceneDirectory }; private static string SceneHash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))); private sealed class SinglePageDataSource(LegacySceneDataPage page) : ILegacySceneDataSource { public Task LoadPageAsync( LegacyPlaylistEntry entry, int pageIndexZeroBased, CancellationToken cancellationToken = default) => Task.FromResult(page); } private static EnvironmentScope ClearGateEnvironment() { PlayoutLaunchAuthorization.ResetGateACaptureLatchForTests(); return new EnvironmentScope() .Set(PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable, null) .Set(PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable, null) .Set(PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable, null) .Set(PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable, null) .Set(PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, null); } private static EnvironmentScope GateEnvironment( string capability, string processId, string startTimeUtcTicks, string expiresAtUtcTicks) => ClearGateEnvironment() .Set(PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable, capability) .Set(PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable, processId) .Set(PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable, startTimeUtcTicks) .Set(PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable, expiresAtUtcTicks); } internal sealed class ManualGateTimeProvider : TimeProvider { private DateTimeOffset _utcNow = new(2026, 7, 18, 0, 0, 0, TimeSpan.Zero); private long _timestamp; public override long TimestampFrequency => TimeSpan.TicksPerSecond; public override DateTimeOffset GetUtcNow() => _utcNow; public override long GetTimestamp() => _timestamp; public void Advance(TimeSpan duration) { _utcNow = _utcNow.Add(duration); _timestamp += duration.Ticks; } public void AdvanceMonotonic(TimeSpan duration) => _timestamp += duration.Ticks; public void SetUtcNow(DateTimeOffset value) => _utcNow = value; } internal static class GateAOptionTestExtensions { public static PlayoutOptions WithMode(this PlayoutOptions options, PlayoutMode mode) { options.Mode = mode; return options; } public static PlayoutOptions WithAllowlist( this PlayoutOptions options, params string[] scenes) { options.TestSceneAllowlist = [.. scenes]; return options; } public static PlayoutOptions WithReconnect( this PlayoutOptions options, bool enabled, int attempts) { options.ReconnectEnabled = enabled; options.MaximumReconnectAttempts = attempts; return options; } public static PlayoutOptions WithRefresh(this PlayoutOptions options, int? maximum) { options.MaximumAutomaticRefreshesPerTakeIn = maximum; return options; } public static PlayoutOptions WithFade(this PlayoutOptions options, int fade) { options.LegacySceneFadeDuration = fade; return options; } public static PlayoutOptions WithBackground( this PlayoutOptions options, LegacySceneBackgroundKind kind) { options.LegacySceneBackgroundKind = kind; return options; } public static PlayoutOptions WithSceneDirectory( this PlayoutOptions options, string sceneDirectory) { options.SceneDirectory = sceneDirectory; return options; } public static PlayoutOptions WithClientPort( this PlayoutOptions options, int clientPort) { options.ClientPort = clientPort; return options; } public static PlayoutOptions WithOutputChannel( this PlayoutOptions options, int outputChannel) { options.OutputChannel = outputChannel; return options; } }