From 68a41194c9e31180fa2bf0aa2aeb952dc0b2fd41 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 24 Jul 2026 14:03:29 +0900 Subject: [PATCH] fix: allow safe fresh-clone DryRun builds --- README.md | 29 +++- docs/LEGACY_RUNTIME_ASSETS.md | 36 ++++- .../App.xaml.cs | 15 +- .../MBN_STOCK_WEBVIEW.LegacyParityApp.csproj | 116 +++++++++++--- .../MainWindow.Playout.cs | 3 +- .../MainWindow.xaml.cs | 23 ++- .../Web/index.html | 1 + .../Web/styles.css | 17 ++ .../Web/workspace-navigation.js | 13 ++ .../Configuration/PlayoutOptionsLoader.cs | 11 +- .../operator-visual-accessibility.test.cjs | 6 + .../workspace-navigation.test.cjs | 9 ++ ...gacyDevelopmentLiveStartupContractTests.cs | 7 + ...gacyRuntimeAssetDeploymentContractTests.cs | 148 ++++++++++++++++++ .../PlayoutOptionsLoaderTests.cs | 26 +++ 15 files changed, 420 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index dffe379..1140595 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ - 선택 삭제 - 로컬 저장/불러오기 - Oracle/MariaDB 4개 국내 시장 종목 검색과 실행 파일 옆 `Res` INI 기반 컷/UI 구성 -- 빌드 시 읽기 전용 원본 `bin\Debug\Cuts`를 실행 파일 옆 `Cuts`로 같은 구조로 배치 +- 원본 자산이 있는 전체 빌드는 읽기 전용 `bin\Debug\Cuts`를 실행 파일 옆 `Cuts`로 같은 구조로 배치 - 기존 운영 흐름을 반영한 `PREPARE` / `TAKE IN` / `NEXT` / `TAKE OUT` UI - `F8`, `Esc` 단축키와 원본 배경 선택용 F2의 안전 예약 - 원본의 공급자 비의존 SQL/데이터 요청 71개를 .NET 8 Core 프로젝트로 이관 @@ -61,6 +61,17 @@ Oracle/MariaDB 조회 계층, 원본 10초 DB 상태 감시, Tornado/K3D 어댑 4. 실행 프로필이 `MBN_STOCK_WEBVIEW.LegacyParityApp - Explicit DryRun (Package)`인지 확인합니다. 5. `F5`로 빌드·배포·실행합니다. +새로 받은 소스에 외부 `MBN_STOCK_N\...\bin\Debug`가 없어도 Debug/F5는 안전한 +**소스 전용 DryRun**으로 빌드됩니다. 이 모드에서는 검증된 내장 메뉴로 화면을 확인할 수 있지만 +DB 검색과 실제 장면 송출은 사용할 수 없습니다. Test/Live 송출 안전 게이트는 완화되지 않으며, +소스 전용 빌드는 과거 로컬 송출 프로필·환경 변수·Development Live 실행 인수를 무시하고 +DryRun과 DB 미연결을 강제합니다. 출력과 중간 패키징 파일도 이전 전체 빌드의 `Cuts`, `Res` +또는 DB 설정을 잘못 재사용하지 않도록 `bin\SourceOnly`와 `obj\SourceOnly`에 분리됩니다. + +전체 장면·DB 기능이 필요한 개발자는 Git에 자산이나 자격증명을 추가하지 말고 승인된 +`MBN_STOCK_N\MBN_STOCK_N\bin\Debug`를 별도로 준비한 뒤 +[Cuts/Res 런타임 배치](docs/LEGACY_RUNTIME_ASSETS.md)의 전체 빌드 절차를 사용하세요. + 이 프로젝트는 MSIX 패키지 ID가 필요한 앱입니다. `bin` 아래의 EXE를 직접 실행하지 말고 반드시 Package 프로필이나 설치된 MSIX로 실행하세요. 기존 중복 형식 오류(`CS0121`, `CS0436`)는 루트 앱 프로젝트가 하위 Core 소스까지 다시 컴파일하던 문제였으며, 현재 `src\**\*.cs`를 앱 컴파일 대상에서 제외해 해결했습니다. Visual Studio가 이전 진단을 계속 표시하면 `빌드 > 솔루션 정리` 후 다시 빌드하세요. @@ -100,12 +111,24 @@ dotnet build .\src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyPar ``` 위 명령은 서명되지 않은 개발 패키지를 `AppPackages`에 만듭니다. 배포용 MSIX는 `Package.appxmanifest`의 `Identity/Publisher` 값과 정확히 일치하는 인증서로 서명해야 합니다. 인증서와 개인 키는 저장소에 커밋하지 않습니다. +Release MSIX는 전체 런타임 자산을 필수로 검증합니다. 원본 위치가 기본 형제 경로와 다르면 +`-p:LegacyRuntimeSourceRoot="D:\path\to\MBN_STOCK_N\MBN_STOCK_N\bin\Debug"`를 +추가해야 합니다. ## 구성과 보안 -원본 `Res/MmoneyCoder.ini`의 값은 저장소에 복사하지 않았습니다. 일반 Visual Studio 개발 빌드는 원본 INI를 Git 밖에서 실행 파일 옆 `Res`로 복사해 기존 경로를 재현하고, MSIX는 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` 또는 기존 `database.local.json`을 사용합니다. 환경 변수는 선택된 파일보다 우선합니다. 설정 우선순위, 보안과 실제 DB 스모크 방법은 [DB 운영 가이드](docs/DATABASE.md)에 정리했습니다. +원본 `Res/MmoneyCoder.ini`의 값은 저장소에 복사하지 않았습니다. 원본 자산을 사용하는 일반 +Visual Studio 전체 개발 빌드만 원본 INI를 Git 밖에서 실행 파일 옆 `Res`로 복사해 기존 경로를 +재현합니다. 소스 전용 빌드는 자격증명 없이 DB 미연결 상태로 시작하고, MSIX는 +`%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` 또는 기존 +`database.local.json`을 사용합니다. 환경 변수는 선택된 파일보다 우선합니다. 설정 우선순위, +보안과 실제 DB 스모크 방법은 [DB 운영 가이드](docs/DATABASE.md)에 정리했습니다. -MSIX 설치 폴더는 읽기 전용입니다. 빌드 시 읽기 전용 원본 `Cuts` 179개와 허용된 UI용 `Res` 파일은 실행 출력·게시·MSIX에 포함되지만, DB 자격증명 INI·백업·인증서·벤더 DLL은 포함하지 않습니다. 현재 원본에 없는 `s5006`/`s6001` 영상과 일부 배경은 별도 원본 제작 자산을 확보해야 합니다. 로그, 플레이리스트, 운영자 설정과 미리보기 파일은 `ApplicationData.Current.LocalFolder` 또는 `LocalCacheFolder`에 저장합니다. +MSIX 설치 폴더는 읽기 전용입니다. 전체 자산 빌드에서는 읽기 전용 원본 `Cuts` 179개와 허용된 +UI용 `Res` 파일이 실행 출력·게시·MSIX에 포함되지만, DB 자격증명 INI·백업·인증서·벤더 DLL은 +포함하지 않습니다. 현재 원본에 없는 `s5006`/`s6001` 영상과 일부 배경은 별도 원본 제작 자산을 +확보해야 합니다. 로그, 플레이리스트, 운영자 설정과 미리보기 파일은 +`ApplicationData.Current.LocalFolder` 또는 `LocalCacheFolder`에 저장합니다. ## 프로젝트 구조 diff --git a/docs/LEGACY_RUNTIME_ASSETS.md b/docs/LEGACY_RUNTIME_ASSETS.md index 9cbcc1e..2f4cddd 100644 --- a/docs/LEGACY_RUNTIME_ASSETS.md +++ b/docs/LEGACY_RUNTIME_ASSETS.md @@ -12,8 +12,8 @@ C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug Visual Studio가 시작하는 프로젝트는 `src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj`다. -이 프로젝트는 빌드할 때 원본 파일을 저장소 안으로 복제하지 않고 외부 Content로 연결해 -실행 파일 옆의 다음 구조를 만든다. +이 프로젝트는 원본 경로가 존재하는 전체 빌드에서 파일을 저장소 안으로 복제하지 않고 외부 +Content로 연결해 실행 파일 옆의 다음 구조를 만든다. ```text MBN_STOCK_WEBVIEW.LegacyParityApp.exe @@ -22,13 +22,41 @@ Res\... Web\... ``` -원본 경로가 다른 장비에서는 다음 빌드 속성으로 `bin\Debug` 위치를 지정한다. +## 소스 전용 빌드와 전체 자산 빌드 + +기본 `LegacyRuntimeAssetsMode`는 `Auto`다. + +- 기본 형제 경로에 원본이 있으면 모든 필수 자산을 검증하고 전체 자산 빌드를 만든다. +- 원본이 없는 새 Git clone의 Debug/F5는 빌드를 막지 않고 소스 전용 DryRun을 만든다. +- 원본 경로를 명시하거나 Release MSIX를 만들면 기본값이 `Required`로 바뀌어 누락 자산을 + 다시 오류로 차단한다. + +소스 전용 앱은 내장 컷/UI 목록으로 화면 검토가 가능하고 DB는 미연결 상태로 표시한다. +과거 로컬 송출 프로필, 운영자 송출·배경 경로, 프로세스 환경 override와 Development Live 실행 +인수는 사용하지 않고 안전한 DryRun 기본값을 강제한다. 출력과 중간 패키징 파일은 이전 전체 빌드의 +자산이나 `MmoneyCoder.ini`를 재사용하지 않도록 다음 별도 경로에 생성된다. + +```text +src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\SourceOnly\... +src\MBN_STOCK_WEBVIEW.LegacyParityApp\obj\SourceOnly\... +``` + +원본 경로가 다른 장비에서 전체 기능을 빌드하려면 다음 속성으로 `bin\Debug` 위치를 지정한다. +명시한 경로는 자동으로 `Required` 취급된다. ```text /p:LegacyRuntimeSourceRoot="D:\path\to\MBN_STOCK_N\MBN_STOCK_N\bin\Debug" ``` -필수 폴더와 `Cuts\5001.t2s`, 주요 UI INI가 없으면 빌드는 원인을 표시하고 중단한다. +필수 폴더와 `Cuts\5001.t2s`, 주요 UI INI가 없으면 전체 자산 빌드는 원인을 표시하고 중단한다. +경로 탐색이나 CI에서 소스 전용 동작을 명시적으로 확인할 때만 다음 조합을 사용할 수 있다. +이는 전체 기능 또는 배포 승인용 빌드가 아니다. + +```text +/p:LegacyRuntimeAssetsMode=Auto +``` + +Git에는 `Cuts`, DB 자격증명, 벤더 DLL, 라이선스, 인증서 또는 로컬 구성을 추가하지 않는다. ## Cuts diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs index 8dbdab6..0c0abab 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs @@ -44,10 +44,19 @@ public partial class App : Application #else const bool isDebugBuild = false; #endif - var developmentLive = DevelopmentLiveLaunchBootstrap.TryApply( - DevelopmentLiveLaunchBootstrap.ResolveLaunchArguments( + +#if SOURCE_ONLY_RUNTIME + const bool isSourceOnlyBuild = true; +#else + const bool isSourceOnlyBuild = false; +#endif + var launchArguments = isSourceOnlyBuild + ? null + : DevelopmentLiveLaunchBootstrap.ResolveLaunchArguments( args.Arguments, - Environment.GetCommandLineArgs()), + Environment.GetCommandLineArgs()); + var developmentLive = DevelopmentLiveLaunchBootstrap.TryApply( + launchArguments, isDebugBuild); System.Diagnostics.Debug.WriteLine( $"Development Live bootstrap: {developmentLive.Code}"); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj index f3cf9f1..f40d562 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj @@ -26,12 +26,45 @@ 0.1.0 + true $(MSBuildProjectDirectory)\..\..\..\MBN_STOCK_N\MBN_STOCK_N\bin\Debug $([System.IO.Path]::GetFullPath('$(LegacyRuntimeSourceRoot)')) + Required + Auto + true + false + + bin\SourceOnly\ + obj\SourceOnly\$(Platform)\$(Configuration)\ + $(DefineConstants);SOURCE_ONLY_RUNTIME + true + false $(LegacyRuntimeSourceRoot)\Cuts $(LegacyRuntimeSourceRoot)\Res @@ -40,7 +73,9 @@ + + @@ -88,7 +123,9 @@ + + @@ -111,25 +148,29 @@ PreserveNewest PreserveNewest - + Cuts\%(RecursiveDir)%(Filename)%(Extension) Cuts\%(RecursiveDir)%(Filename)%(Extension) PreserveNewest PreserveNewest - + Res\%(Filename)%(Extension) Res\%(Filename)%(Extension) PreserveNewest PreserveNewest - + Web\Previews\%(Filename)%(Extension) Web\Previews\%(Filename)%(Extension) PreserveNewest PreserveNewest - + Web\Brand\%(Filename)%(Extension) Web\Brand\%(Filename)%(Extension) PreserveNewest @@ -144,38 +185,59 @@ - - + + + - - - - - - - - - - - - @@ -187,7 +249,7 @@ executable-relative layout only for ordinary local output. The CPS-only up-to-date items below are not package payload candidates. --> - + @@ -197,7 +259,9 @@ AfterTargets="Build" Inputs="$(LegacyResSourceRoot)\MmoneyCoder.ini" Outputs="$(TargetDir)Res\MmoneyCoder.ini" - Condition="'$(GenerateAppxPackageOnBuild)' != 'true' and '$(PublishAppxPackage)' != 'true'"> + Condition="'$(LegacyRuntimeAssetsEnabled)' == 'true' and + '$(GenerateAppxPackageOnBuild)' != 'true' and + '$(PublishAppxPackage)' != 'true'">
+ 매일경제TV 매일경제TV diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css index 8391bd1..fb9b9b9 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css @@ -945,6 +945,23 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--ui-accent); } background: #fff; box-shadow: 0 0 0 1px rgba(15, 23, 42, .08), 0 1px 2px rgba(15, 23, 42, .08); } +.brand-wordmark-fallback, +.brand-wordmark { + grid-area: 1 / 1; +} +.brand-wordmark-fallback { + visibility: hidden; + color: #172033; + font-size: 16px; + font-weight: 800; + letter-spacing: -.045em; +} +.brand-wordmark-plate.wordmark-fallback-active .brand-wordmark-fallback { + visibility: visible; +} +.brand-wordmark-plate.wordmark-fallback-active .brand-wordmark { + display: none; +} .brand-wordmark { display: block; width: 142px; diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/workspace-navigation.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/workspace-navigation.js index 3dcf19f..b16b1ad 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/workspace-navigation.js +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/workspace-navigation.js @@ -17,6 +17,19 @@ const shell = document.querySelector(".operator-shell"); const splitter = document.getElementById("workspace-splitter"); const playlistPanel = document.querySelector(".playlist-panel"); + const brandWordmark = document.querySelector("img.brand-wordmark"); + const brandWordmarkPlate = brandWordmark?.closest(".brand-wordmark-plate"); + + function showBrandWordmarkFallback() { + brandWordmarkPlate?.classList.add("wordmark-fallback-active"); + } + + if (brandWordmark) { + brandWordmark.addEventListener("error", showBrandWordmarkFallback, { once: true }); + if (brandWordmark.complete && brandWordmark.naturalWidth === 0) { + showBrandWordmarkFallback(); + } + } if (!region || !navigation || !navigationItems || !toggle || !toggleLabel || !stockTab || !settingsTab || !stockWorkspace || !catalogWorkspace || diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs index c8d4f3d..ec50386 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs @@ -22,8 +22,17 @@ public static class PlayoutOptionsLoader public static PlayoutOptions Load( string? path = null, string? operatorSceneDirectory = null, - string? operatorBackgroundDirectory = null) + string? operatorBackgroundDirectory = null, + bool forceSafeDryRun = false) { + // Source-only builds are for code/UI review on machines without the + // approved runtime tree. Do not consume a previous machine-local playout + // profile or process override in that build flavor. + if (forceSafeDryRun) + { + return new PlayoutOptions(); + } + var options = LoadJson(path ?? DefaultPath); ApplyOperatorAssetOverrides( options, diff --git a/tests/LegacyParityWeb/operator-visual-accessibility.test.cjs b/tests/LegacyParityWeb/operator-visual-accessibility.test.cjs index b22a648..f8b838b 100644 --- a/tests/LegacyParityWeb/operator-visual-accessibility.test.cjs +++ b/tests/LegacyParityWeb/operator-visual-accessibility.test.cjs @@ -204,6 +204,12 @@ test("appearance controls and accessibility preferences retain clear feedback", test("workspace header keeps one concise title and the live native connection state", () => { assert.match(markup, /class="brand"[\s\S]*?class="brand-wordmark-plate"[\s\S]*?[\s\S]*?class="brand-divider" aria-hidden="true"[\s\S]*?class="brand-product">V-STOCK<\/span>/); + assert.match(markup, + /class="brand-wordmark-fallback">매일경제TV<\/span>/); + assert.match(styles, + /\.brand-wordmark-plate\.wordmark-fallback-active \.brand-wordmark-fallback\s*\{[^}]*visibility:\s*visible/); + assert.match(styles, + /\.brand-wordmark-plate\.wordmark-fallback-active \.brand-wordmark\s*\{[^}]*display:\s*none/); assert.doesNotMatch(markup, /class="brand-symbol"|class="brand-lockup"|>VRi[\s\S]*?

[^<]+<\/h1>/); diff --git a/tests/LegacyParityWeb/workspace-navigation.test.cjs b/tests/LegacyParityWeb/workspace-navigation.test.cjs index ad45a5f..a7150e0 100644 --- a/tests/LegacyParityWeb/workspace-navigation.test.cjs +++ b/tests/LegacyParityWeb/workspace-navigation.test.cjs @@ -510,6 +510,15 @@ test("workspace navigation stores only the approved local presentation preferenc assert.doesNotMatch(navigation, /postMessage|sessionStorage|send\s*\(/); }); +test("missing external wordmark uses the source-only text fallback", () => { + assert.match(navigation, + /brandWordmark\.addEventListener\("error", showBrandWordmarkFallback, \{ once: true \}\)/); + assert.match(navigation, + /brandWordmark\.complete && brandWordmark\.naturalWidth === 0/); + assert.match(navigation, + /classList\.add\("wordmark-fallback-active"\)/); +}); + test("splitter clamps pointer and keyboard resizing and Escape restores the gesture", () => { for (const declaration of [ /const scheduleMinimumPercent = 34/, diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDevelopmentLiveStartupContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDevelopmentLiveStartupContractTests.cs index 7e24976..4b282c9 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDevelopmentLiveStartupContractTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDevelopmentLiveStartupContractTests.cs @@ -88,6 +88,13 @@ public sealed class LegacyDevelopmentLiveStartupContractTests Assert.Contains("#if DEBUG", app, StringComparison.Ordinal); Assert.Contains("const bool isDebugBuild = true;", app, StringComparison.Ordinal); Assert.Contains("const bool isDebugBuild = false;", app, StringComparison.Ordinal); + Assert.Contains("#if SOURCE_ONLY_RUNTIME", app, StringComparison.Ordinal); + Assert.Contains("const bool isSourceOnlyBuild = true;", app, + StringComparison.Ordinal); + Assert.Contains("const bool isSourceOnlyBuild = false;", app, + StringComparison.Ordinal); + Assert.Contains("isSourceOnlyBuild", app, StringComparison.Ordinal); + Assert.Contains("? null", app, StringComparison.Ordinal); Assert.Contains("args.Arguments", app, StringComparison.Ordinal); Assert.Contains("Environment.GetCommandLineArgs()", app, StringComparison.Ordinal); Assert.Contains("ResolveLaunchArguments(", app, StringComparison.Ordinal); diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs index a60c978..00baef9 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs @@ -268,6 +268,154 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests Assert.Single(rejectionTarget.Elements("Error")); } + [Fact] + public void FreshCloneUsesIsolatedSourceOnlyOutputWithoutWeakeningFullBuilds() + { + var project = XDocument.Load(ProjectPath); + + var sourceRootWasSpecified = Assert.Single( + project.Descendants("LegacyRuntimeSourceRootWasSpecified")); + Assert.Equal( + "'$(LegacyRuntimeSourceRoot)' != ''", + sourceRootWasSpecified.Attribute("Condition")?.Value); + + var modes = project.Descendants("LegacyRuntimeAssetsMode").ToArray(); + Assert.Equal(2, modes.Length); + var requiredMode = Assert.Single( + modes, + mode => string.Equals(mode.Value, "Required", StringComparison.Ordinal)); + var requiredCondition = requiredMode.Attribute("Condition")?.Value; + Assert.Contains( + "$(LegacyRuntimeSourceRootWasSpecified)", + requiredCondition, + StringComparison.Ordinal); + Assert.Contains( + "'$(Configuration)' == 'Release'", + requiredCondition, + StringComparison.Ordinal); + Assert.Contains( + "$(GenerateAppxPackageOnBuild)", + requiredCondition, + StringComparison.Ordinal); + Assert.Contains("$(PublishAppxPackage)", requiredCondition, StringComparison.Ordinal); + + var autoMode = Assert.Single( + modes, + mode => string.Equals(mode.Value, "Auto", StringComparison.Ordinal)); + Assert.Equal( + "'$(LegacyRuntimeAssetsMode)' == ''", + autoMode.Attribute("Condition")?.Value); + + var assetsEnabled = Assert.Single( + project.Descendants("LegacyRuntimeAssetsEnabled"), + element => (element.Attribute("Condition")?.Value ?? string.Empty) + .Contains("Exists('$(LegacyRuntimeSourceRoot)')", StringComparison.Ordinal)); + Assert.Equal("true", assetsEnabled.Value); + + var sourceOnlyOutput = Assert.Single(project.Descendants("BaseOutputPath")); + Assert.Equal(@"bin\SourceOnly\", sourceOnlyOutput.Value); + Assert.Contains( + "$(LegacyRuntimeAssetsEnabled)", + sourceOnlyOutput.Attribute("Condition")?.Value, + StringComparison.Ordinal); + var sourceOnlyIntermediate = Assert.Single( + project.Descendants("IntermediateOutputPath")); + Assert.Equal( + @"obj\SourceOnly\$(Platform)\$(Configuration)\", + sourceOnlyIntermediate.Value); + Assert.Contains( + "$(LegacyRuntimeAssetsEnabled)", + sourceOnlyIntermediate.Attribute("Condition")?.Value, + StringComparison.Ordinal); + var sourceOnlyConstants = Assert.Single(project.Descendants("DefineConstants")); + Assert.Contains( + "SOURCE_ONLY_RUNTIME", + sourceOnlyConstants.Value, + StringComparison.Ordinal); + Assert.Contains( + "$(LegacyRuntimeAssetsEnabled)", + sourceOnlyConstants.Attribute("Condition")?.Value, + StringComparison.Ordinal); + + var validationTarget = Assert.Single( + project.Descendants("Target"), + element => string.Equals( + element.Attribute("Name")?.Value, + "ValidateLegacyRuntimeAssets", + StringComparison.Ordinal)); + var sourceOnlyMessage = Assert.Single(validationTarget.Elements("Message")); + Assert.Contains( + "$(LegacyRuntimeAssetsMode)", + sourceOnlyMessage.Attribute("Condition")?.Value, + StringComparison.Ordinal); + Assert.Contains( + "source-only DryRun", + sourceOnlyMessage.Attribute("Text")?.Value, + StringComparison.Ordinal); + + var runtimeValidationErrors = validationTarget.Elements("Error") + .Where(error => (error.Attribute("Condition")?.Value ?? string.Empty) + .Contains("Exists(", StringComparison.Ordinal)) + .ToArray(); + Assert.NotEmpty(runtimeValidationErrors); + Assert.All(runtimeValidationErrors, error => + Assert.Contains( + "$(LegacyRuntimeAssetsValidationEnabled)", + error.Attribute("Condition")?.Value, + StringComparison.Ordinal)); + + string[] externalContentIncludes = + [ + @"$(LegacyCutsSourceRoot)\**\*", + "@(LegacyPackagedResAsset)", + "@(LegacyFinancialPreviewAsset)", + "@(LegacyBrandAsset)" + ]; + Assert.All(externalContentIncludes, include => + { + var content = Assert.Single( + project.Descendants("Content"), + element => string.Equals( + element.Attribute("Include")?.Value, + include, + StringComparison.Ordinal)); + Assert.Contains( + "$(LegacyRuntimeAssetsEnabled)", + content.Attribute("Condition")?.Value, + StringComparison.Ordinal); + }); + + var databaseCopy = Assert.Single( + project.Descendants("Target"), + element => string.Equals( + element.Attribute("Name")?.Value, + "CopyLegacyDatabaseIniToLocalOutput", + StringComparison.Ordinal)); + Assert.Contains( + "$(LegacyRuntimeAssetsEnabled)", + databaseCopy.Attribute("Condition")?.Value, + StringComparison.Ordinal); + + var mainWindow = File.ReadAllText(Path.Combine( + RepositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", + "MainWindow.xaml.cs")); + Assert.Contains("#if SOURCE_ONLY_RUNTIME", mainWindow, StringComparison.Ordinal); + Assert.Contains( + "Source-only builds do not initialize a database runtime.", + mainWindow, + StringComparison.Ordinal); + + var playout = File.ReadAllText(Path.Combine( + RepositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", + "MainWindow.Playout.cs")); + Assert.Contains("forceSafeDryRun: IsSourceOnlyBuild", playout, + StringComparison.Ordinal); + } + [Fact] public void LegacyRuntimeSourceRootIsOverrideableAndValidatedBeforeBuild() { diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs index 369ab5d..f2ade04 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs @@ -62,6 +62,32 @@ public sealed class PlayoutOptionsLoaderTests Assert.Null(options.MaximumAutomaticRefreshesPerTakeIn); } + [Fact] + public void Load_SourceOnlyIgnoresLocalProfileOperatorRootsAndEnvironment() + { + using var environment = ClearedEnvironment() + .Set("MBN_STOCK_PLAYOUT_MODE", "Live") + .Set("MBN_STOCK_PLAYOUT_HOST", "configured-live-host") + .Set("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", "C:\\configured-live-scenes") + .Set( + PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, + PlayoutOptionsLoader.RequiredLiveAuthorizationValue); + using var file = TemporaryJsonFile.Create("{ intentionally-invalid-json"); + + var options = PlayoutOptionsLoader.Load( + file.Path, + operatorSceneDirectory: "C:\\operator-scenes", + operatorBackgroundDirectory: "C:\\operator-backgrounds", + forceSafeDryRun: true); + + Assert.Equal(PlayoutMode.DryRun, options.Mode); + Assert.Equal("127.0.0.1", options.Host); + Assert.Null(options.SceneDirectory); + Assert.Null(options.LegacyBackgroundDirectory); + Assert.False(options.TrustedLiveOutputEnabled); + Assert.Empty(options.TestSceneAllowlist); + } + [Fact] public void ApplyExecutableAssetDefaults_UsesCutsBesideExecutable() {