using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; namespace MBN_STOCK_WEBVIEW.Playout.Tests; public sealed class PlayoutOptionsLoaderTests { private static readonly string[] EnvironmentNames = [ "MBN_STOCK_PLAYOUT_MODE", "MBN_STOCK_PLAYOUT_HOST", "MBN_STOCK_PLAYOUT_PORT", "MBN_STOCK_PLAYOUT_TCP_MODE", "MBN_STOCK_PLAYOUT_CLIENT_PORT", "MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL", "MBN_STOCK_PLAYOUT_LAYOUT_INDEX", "MBN_STOCK_PLAYOUT_LEGACY_FADE_DURATION", "MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND", "MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_ASSET", "MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT", "MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE", "MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_DIRECTORY", "MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", "MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN", "MBN_STOCK_PLAYOUT_QUEUE_CAPACITY", "MBN_STOCK_PLAYOUT_CONNECT_TIMEOUT_MS", "MBN_STOCK_PLAYOUT_OPERATION_TIMEOUT_MS", "MBN_STOCK_PLAYOUT_DISCONNECT_TIMEOUT_MS", "MBN_STOCK_PLAYOUT_PROCESS_POLL_INTERVAL_MS", "MBN_STOCK_PLAYOUT_RECONNECT_DELAY_MS", "MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS", "MBN_STOCK_PLAYOUT_RECONNECT_ENABLED", "MBN_STOCK_PLAYOUT_MAXIMUM_AUTOMATIC_REFRESHES_PER_TAKE_IN", PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, "MBN_STOCK_PLAYOUT_TEST_SCENE_ALLOWLIST", "MBN_STOCK_PLAYOUT_TRUSTED_LIVE_OUTPUT_ENABLED" ]; [Fact] public void Load_WhenFileIsMissing_UsesSafeDryRunDefaults() { using var environment = ClearedEnvironment(); using var file = TemporaryJsonFile.Missing(); var options = PlayoutOptionsLoader.Load(file.Path); Assert.Equal(PlayoutMode.DryRun, options.Mode); Assert.Equal("127.0.0.1", options.Host); Assert.Equal(30001, options.Port); Assert.Equal(1, options.TcpMode); Assert.Equal(0, options.ClientPort); Assert.Null(options.OutputChannel); Assert.Null(options.SceneDirectory); Assert.Equal(6, options.LegacySceneFadeDuration); Assert.Equal(LegacySceneBackgroundKind.None, options.LegacySceneBackgroundKind); Assert.Null(options.LegacySceneBackgroundAssetPath); Assert.Equal(2004, options.LegacySceneBackgroundVideoLoopCount); Assert.True(options.LegacySceneBackgroundVideoLoopInfinite); Assert.Null(options.LegacyBackgroundDirectory); Assert.Empty(options.TestSceneAllowlist); Assert.False(options.TrustedLiveOutputEnabled); Assert.True(options.ReconnectEnabled); Assert.Null(options.MaximumAutomaticRefreshesPerTakeIn); } [Fact] public void ApplyExecutableAssetDefaults_UsesCutsBesideExecutable() { var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var cuts = Path.Combine(directory, "Cuts"); Directory.CreateDirectory(cuts); try { var options = new PlayoutOptions(); PlayoutOptionsLoader.ApplyExecutableAssetDefaults(options, directory); Assert.Equal(Path.GetFullPath(cuts), options.SceneDirectory); } finally { Directory.Delete(directory, recursive: true); } } [Fact] public void ApplyExecutableAssetDefaults_DoesNotReplaceExplicitSceneDirectory() { var options = new PlayoutOptions { SceneDirectory = "C:\\explicit-cuts" }; PlayoutOptionsLoader.ApplyExecutableAssetDefaults(options, "C:\\runtime"); Assert.Equal("C:\\explicit-cuts", options.SceneDirectory); } [Fact] public void Load_EnvironmentOverridesJson_ExceptFileOnlySafetyGates() { using var environment = ClearedEnvironment() .Set("MBN_STOCK_PLAYOUT_MODE", "DryRun") .Set("MBN_STOCK_PLAYOUT_HOST", "env-host") .Set("MBN_STOCK_PLAYOUT_PORT", "32001") .Set("MBN_STOCK_PLAYOUT_TCP_MODE", "2") .Set("MBN_STOCK_PLAYOUT_CLIENT_PORT", "32002") .Set("MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL", "12") .Set("MBN_STOCK_PLAYOUT_LAYOUT_INDEX", "13") .Set("MBN_STOCK_PLAYOUT_LEGACY_FADE_DURATION", "9") .Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND", "Video") .Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_ASSET", "Video\\studio.vrv") .Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT", "2004") .Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE", "false") .Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_DIRECTORY", "C:\\env-backgrounds") .Set("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", "C:\\env-scenes") .Set("MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN", "ENV TEST") .Set("MBN_STOCK_PLAYOUT_QUEUE_CAPACITY", "14") .Set("MBN_STOCK_PLAYOUT_CONNECT_TIMEOUT_MS", "1501") .Set("MBN_STOCK_PLAYOUT_OPERATION_TIMEOUT_MS", "1502") .Set("MBN_STOCK_PLAYOUT_DISCONNECT_TIMEOUT_MS", "1503") .Set("MBN_STOCK_PLAYOUT_PROCESS_POLL_INTERVAL_MS", "1504") .Set("MBN_STOCK_PLAYOUT_RECONNECT_DELAY_MS", "1505") .Set("MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS", "4") .Set("MBN_STOCK_PLAYOUT_RECONNECT_ENABLED", "false") .Set("MBN_STOCK_PLAYOUT_MAXIMUM_AUTOMATIC_REFRESHES_PER_TAKE_IN", "999") .Set("MBN_STOCK_PLAYOUT_TEST_SCENE_ALLOWLIST", "C:\\env\\must-not-apply.t2s") .Set("MBN_STOCK_PLAYOUT_TRUSTED_LIVE_OUTPUT_ENABLED", "false"); using var file = TemporaryJsonFile.Create( """ { "mode": "Test", "host": "json-host", "port": 31001, "tcpMode": 1, "clientPort": 31002, "outputChannel": 3, "layoutIndex": 10, "sceneDirectory": "C:\\json-scenes", "testProcessWindowTitlePattern": "JSON TEST", "testSceneAllowlist": ["C:\\test-scenes\\allowed.t2s"], "trustedLiveOutputEnabled": true, "queueCapacity": 64, "connectTimeoutMilliseconds": 5000, "operationTimeoutMilliseconds": 5000, "disconnectTimeoutMilliseconds": 3000, "processPollIntervalMilliseconds": 1000, "reconnectDelayMilliseconds": 1000, "maximumReconnectAttempts": 3, "reconnectEnabled": true, "maximumAutomaticRefreshesPerTakeIn": 1 } """); var options = PlayoutOptionsLoader.Load(file.Path); Assert.Equal(PlayoutMode.DryRun, options.Mode); Assert.Equal("env-host", options.Host); Assert.Equal(32001, options.Port); Assert.Equal(2, options.TcpMode); Assert.Equal(32002, options.ClientPort); Assert.Equal(12, options.OutputChannel); Assert.Equal(13, options.LayoutIndex); Assert.Equal(9, options.LegacySceneFadeDuration); Assert.Equal(LegacySceneBackgroundKind.Video, options.LegacySceneBackgroundKind); Assert.Equal("Video\\studio.vrv", options.LegacySceneBackgroundAssetPath); Assert.Equal(2004, options.LegacySceneBackgroundVideoLoopCount); Assert.False(options.LegacySceneBackgroundVideoLoopInfinite); Assert.Equal("C:\\env-backgrounds", options.LegacyBackgroundDirectory); Assert.Equal("C:\\env-scenes", options.SceneDirectory); Assert.Equal("ENV TEST", options.TestProcessWindowTitlePattern); Assert.Equal(14, options.QueueCapacity); Assert.Equal(1501, options.ConnectTimeoutMilliseconds); Assert.Equal(1502, options.OperationTimeoutMilliseconds); Assert.Equal(1503, options.DisconnectTimeoutMilliseconds); Assert.Equal(1504, options.ProcessPollIntervalMilliseconds); Assert.Equal(1505, options.ReconnectDelayMilliseconds); Assert.Equal(4, options.MaximumReconnectAttempts); Assert.False(options.ReconnectEnabled); Assert.Equal(1, options.MaximumAutomaticRefreshesPerTakeIn); Assert.Equal(["C:\\test-scenes\\allowed.t2s"], options.TestSceneAllowlist); Assert.True(options.TrustedLiveOutputEnabled); } [Fact] public void LoadFileOnly_IgnoresEnvironmentForReproducibleExplicitDiagnostics() { using var environment = ClearedEnvironment() .Set("MBN_STOCK_PLAYOUT_MODE", "Live") .Set("MBN_STOCK_PLAYOUT_HOST", "sensitive-host"); using var file = TemporaryJsonFile.Create( """ { "mode": "Test", "host": "127.0.0.1" } """); var options = PlayoutOptionsLoader.LoadFileOnly(file.Path); Assert.Equal(PlayoutMode.Test, options.Mode); Assert.Equal("127.0.0.1", options.Host); } [Fact] public void Load_InvalidJson_ReturnsOnlySafeConfigurationMessage() { using var environment = ClearedEnvironment(); using var file = TemporaryJsonFile.Create("{ invalid-json: true }"); var exception = Assert.Throws( () => PlayoutOptionsLoader.Load(file.Path)); Assert.Equal("송출 설정 파일 형식이 올바르지 않습니다.", exception.Message); Assert.DoesNotContain(file.Path, exception.Message, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("invalid-json", exception.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public void Load_StringAutomaticRefreshMaximumFailsClosed() { using var environment = ClearedEnvironment(); using var file = TemporaryJsonFile.Create( """ { "maximumAutomaticRefreshesPerTakeIn": "1" } """); var exception = Assert.Throws( () => PlayoutOptionsLoader.Load(file.Path)); Assert.DoesNotContain(file.Path, exception.Message, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("\"1\"", exception.Message, StringComparison.Ordinal); } [Theory] [InlineData("MBN_STOCK_PLAYOUT_MODE", "not-a-mode")] [InlineData("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND", "not-a-background")] [InlineData("MBN_STOCK_PLAYOUT_PORT", "not-a-number")] [InlineData("MBN_STOCK_PLAYOUT_RECONNECT_ENABLED", "not-a-bool")] public void Load_InvalidEnvironmentValue_IdentifiesVariableWithoutLeakingValue( string name, string invalidValue) { using var environment = ClearedEnvironment().Set(name, invalidValue); using var file = TemporaryJsonFile.Missing(); var exception = Assert.Throws( () => PlayoutOptionsLoader.Load(file.Path)); Assert.Contains(name, exception.Message, StringComparison.Ordinal); Assert.DoesNotContain(invalidValue, exception.Message, StringComparison.Ordinal); } private static EnvironmentScope ClearedEnvironment() { var result = new EnvironmentScope(); foreach (var name in EnvironmentNames) { result.Set(name, null); } return result; } }