From 347900701b85a60186e3e8adf74ed70e4ce927b7 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 28 Jul 2026 00:35:47 +0900 Subject: [PATCH] fix: apply direct runtime paths and respect taskbar --- docs/DEVELOPMENT_LIVE_HANDOFF.md | 20 +- docs/DEVELOPMENT_LIVE_VISUAL_STUDIO.md | 8 +- docs/LEGACY_RUNTIME_ASSETS.md | 32 +- scripts/Initialize-ExistingDevelopmentPc.ps1 | 308 ++++++++++-------- .../LegacyOperatorSettings.cs | 49 +-- .../FirstRunSetupWindow.xaml.cs | 11 +- .../MBN_STOCK_WEBVIEW.LegacyParityApp.csproj | 35 +- .../MainWindow.Playout.cs | 4 +- .../MainWindow.xaml.cs | 39 ++- .../Configuration/PlayoutOptionsLoader.cs | 15 +- .../LegacyOperatorSettingsTests.cs | 85 +---- ...gacyDevelopmentLiveStartupContractTests.cs | 18 +- .../LegacyFirstRunSetupNativeContractTests.cs | 6 +- ...gacyRuntimeAssetDeploymentContractTests.cs | 139 ++++---- .../LegacyWindowSizingNativeContractTests.cs | 111 +++++++ .../PlayoutOptionsLoaderTests.cs | 43 +++ 16 files changed, 522 insertions(+), 401 deletions(-) create mode 100644 tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWindowSizingNativeContractTests.cs diff --git a/docs/DEVELOPMENT_LIVE_HANDOFF.md b/docs/DEVELOPMENT_LIVE_HANDOFF.md index ad1770e..7058d36 100644 --- a/docs/DEVELOPMENT_LIVE_HANDOFF.md +++ b/docs/DEVELOPMENT_LIVE_HANDOFF.md @@ -76,23 +76,25 @@ allowlist만 runtime으로 구성하며, 자격증명 INI는 별도 사용자 초기화기는 다음 작업을 한 번에 수행해야 한다. -- 로컬 `Cuts`/`Res` tree와 프로젝트가 요구하는 자산 계약을 읽기 전용으로 검증하고, 임시 - bundle을 거쳐 사용자 전용 - `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\RuntimeBundles\`에 검증된 복사본을 설치한다. -- 설치 root를 명시적 MSBuild 속성으로 전달해, 저장소 binding을 아직 바꾸지 않은 상태에서 - Debug x64 전체 자산 빌드를 먼저 검증한다. +- 선택한 `Cuts`와 `Res`의 고정 로컬 경로를 + `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\runtime-folders.local.json`에 저장한다. + `Cuts`는 원래 폴더를 그대로 사용하며 전체 파일 열거, ZIP 생성, 복사, 크기·해시 검사를 + 하지 않는다. 실제 장면이나 영상이 사용될 때 그 파일만 개별 검증하므로 사용하지 않는 + 파일이 없거나 `Cuts\Video`가 일부 비어 있어도 초기화가 중단되지 않는다. +- 두 폴더의 공통 부모를 명시적 MSBuild 속성으로 전달해, 저장소 binding을 아직 바꾸지 않은 + 상태에서 Debug x64 빌드를 먼저 검증한다. 빌드 출력에는 `Cuts` 복사본을 만들지 않는다. - 기존 DB INI를 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini`로 복사하고 현재 Windows 사용자 전용 ACL을 적용한다. - K3D x64 등록 상태를 읽기 전용으로 점검하고 검증된 설치본의 native/Interop SHA-256을 사용자 전용 `Config\k3d-pins.local.json`의 최초 기준으로 자동 고정한다. 이 파일이 이미 있으면 같은 값을 재사용하고, 등록 DLL이 달라졌을 때 자동 갱신하지 않는다. -- Debug x64 전체 자산 빌드 후 선택된 runtime - root, Cuts/Res의 경로·길이·SHA-256·전체 파일 집합, SourceOnly가 아님과 DB INI가 빌드 - 출력에 들어가지 않았음을 검증한다. +- Debug x64 빌드 후 선택한 runtime root가 직접 적용되었는지, SourceOnly가 아닌지, + `Cuts` 복사본과 DB INI가 빌드 출력에 들어가지 않았는지 검증한다. `Res`는 UI에 필요한 + 폐쇄형 allowlist만 빌드 출력에 배치한다. - `127.0.0.1:30001` 개발 기본 endpoint의 보호된 로컬 Development Live 설정을 모든 선행 검증 뒤 새로 발급한다. - 저장소에서 제외되는 `Directory.Build.local.props`를 마지막 성공 표식으로 원자 기록해 - 그 설치 root를 `LegacyRuntimeAssetsMode=Required`로 사용하게 한다. + 선택한 두 폴더의 공통 부모를 `LegacyRuntimeAssetsMode=Required`로 사용하게 한다. 초기화 과정 자체는 Tornado2/PGM에 `CONNECT`하거나 PREPARE, TAKE IN, NEXT, TAKE OUT 등의 명령을 보내지 않는다. 초기화 도중 오류가 나면 F5로 송출을 시도하지 말고 원인부터 바로잡는다. diff --git a/docs/DEVELOPMENT_LIVE_VISUAL_STUDIO.md b/docs/DEVELOPMENT_LIVE_VISUAL_STUDIO.md index 1024845..7b6ae62 100644 --- a/docs/DEVELOPMENT_LIVE_VISUAL_STUDIO.md +++ b/docs/DEVELOPMENT_LIVE_VISUAL_STUDIO.md @@ -28,13 +28,15 @@ `MmoneyCoder.ini`, `종목.ini`, `업종_코스피.ini`, `업종_코스닥.ini`, `해외.ini`, `환율.ini`, `지수.ini`, `종목비교.ini` 등 활성 INI 8개를 모두 확인한다. 개별 INI나 Tornado2 endpoint를 입력하지 않으며 endpoint는 지정 개발 환경 기본값 -`127.0.0.1:30001`로 고정된다. 설정을 완료한 뒤 창을 닫고 F5를 한 번 더 누른다. 두 번째 -F5부터 위 세 조건을 검증해 Development Live를 적용한다. 자세한 절차는 +`127.0.0.1:30001`로 고정된다. 선택한 경로는 사용자 설정에 직접 저장하며 `Cuts` 전체를 +열거·복사·압축·해시 검사하지 않는다. 필요한 장면과 영상 파일은 실제 사용 시에만 개별 +확인하므로 일부 파일이 없어도 설정은 완료된다. 설정을 완료한 뒤 창을 닫고 F5를 한 번 더 +누른다. 두 번째 F5부터 위 세 조건을 검증해 Development Live를 적용한다. 자세한 절차는 [개발 PGM 인수 절차](DEVELOPMENT_LIVE_HANDOFF.md#2-기존-자산-보유-pc-clone-후-1회-초기화)를 따른다. `MmoneyCoder.ini`의 DB 자격증명은 테스트 개발 서버용이라도 Git에 넣지 않는다. 첫 실행 -설정은 사용자 전용 LocalAppData overlay로만 복사하며 빌드 출력, runtime bundle과 MSIX에는 +설정은 사용자 전용 LocalAppData overlay로만 복사하며 빌드 출력, 선택 경로 설정과 MSIX에는 포함하지 않는다. 첫 실행 화면이 열리지 않거나 기존 로컬 설정을 명시적으로 교체해야 할 때만 `Initialize-ExistingDevelopmentPc.ps1`을 수동 fallback으로 사용한다. diff --git a/docs/LEGACY_RUNTIME_ASSETS.md b/docs/LEGACY_RUNTIME_ASSETS.md index b191486..911fde4 100644 --- a/docs/LEGACY_RUNTIME_ASSETS.md +++ b/docs/LEGACY_RUNTIME_ASSETS.md @@ -12,12 +12,12 @@ 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로 연결해 실행 파일 옆의 다음 구조를 만든다. +이 프로젝트는 원본 경로가 존재하는 전체 빌드에서도 `Cuts`를 저장소나 빌드 출력으로 복제하지 +않고 선택한 원래 폴더를 직접 사용한다. 실행 파일 옆에는 UI용 `Res` allowlist와 Web 자산만 +배치한다. ```text MBN_STOCK_WEBVIEW.LegacyParityApp.exe -Cuts\... Res\... Web\... ``` @@ -26,9 +26,9 @@ Web\... 기본 `LegacyRuntimeAssetsMode`는 `Auto`다. -- 초기화기가 모든 설정과 명시 경로 전체 빌드를 검증한 뒤 마지막 성공 표식으로 만든 Git 제외 - `Directory.Build.local.props`가 있으면, 그 검증된 LocalAppData runtime으로 모든 필수 - 자산을 확인하고 전체 자산 빌드를 만든다. +- 초기화기가 선택한 두 경로와 빌드를 검증한 뒤 마지막 성공 표식으로 만든 Git 제외 + `Directory.Build.local.props`가 있으면, 그 공통 부모 아래의 원래 `Cuts`와 `Res` 경로를 + 직접 사용한다. - 새 Git clone의 Debug/F5는 주변의 과거 원본 경로를 자동 선택하지 않고 소스 전용 첫 실행 설정 앱을 만든다. - 원본 경로를 명시하거나 Release MSIX를 만들면 기본값이 `Required`로 바뀌어 누락 자산을 @@ -60,7 +60,8 @@ src\MBN_STOCK_WEBVIEW.LegacyParityApp\obj\SourceOnly\... /p:LegacyRuntimeSourceRoot="D:\path\to\MBN_STOCK_N\MBN_STOCK_N\bin\Debug" ``` -필수 폴더와 `Cuts\5001.t2s`, 주요 UI INI가 없으면 전체 자산 빌드는 원인을 표시하고 중단한다. +`Cuts`와 `Res` 폴더 또는 주요 UI INI가 없으면 전체 빌드는 원인을 표시하고 중단한다. +특정 장면·영상 파일은 빌드나 첫 실행에서 전부 요구하지 않고 실제 사용 시에만 개별 확인한다. 경로 탐색이나 CI에서 소스 전용 동작을 명시적으로 확인할 때만 다음 조합을 사용할 수 있다. 이는 전체 기능 또는 배포 승인용 빌드가 아니다. @@ -72,25 +73,24 @@ Git에는 `Cuts`, DB 자격증명, 벤더 DLL, 라이선스, 인증서 또는 ## Cuts -`Cuts`의 179개 파일과 하위 구조를 출력·게시·MSIX에 그대로 배치한다. 환경 변수, 사용자 설정 -메뉴의 디자인 폴더, playout 로컬 JSON의 `sceneDirectory`가 모두 비어 있고 실행 파일 옆 -`Cuts`가 있으면 그 경로를 자동으로 사용한다. 장면 루트의 우선순위는 환경 변수, 사용자 설정, -playout 로컬 JSON, 실행 파일 옆 기본 폴더 순이다. +`Cuts`의 전체 파일과 하위 구조는 출력·게시·MSIX에 복사하지 않는다. 첫 실행 또는 사용자 설정 +메뉴에서 선택한 고정 로컬 경로를 `sceneDirectory`로 저장하고, 앱은 그 원래 폴더에서 필요한 +장면과 영상만 읽는다. 전체 파일 열거, ZIP 생성, 크기·해시 검사는 하지 않는다. -현재 원본의 `Cuts\Video`는 비어 있다. 따라서 다음 외부 영상은 이 복사만으로 생기지 않는다. +따라서 아래처럼 일부 외부 영상이 없어도 경로 설정과 앱 시작은 정상 완료된다. - `s5006`의 `Video\큐브배경.vrv` - `s6001`의 국가별 `Video\20201008_<국가>.vrv` 13개 - 원본 코드가 참조하는 일부 기본/스튜디오 `.vrv` 배경 -이 파일은 임의 대체물을 만들지 않고 원 제작 자산을 확보할 때까지 해당 장면의 자산 검사에서 -명시적으로 누락 처리한다. +해당 장면을 실제로 선택하거나 송출할 때에만 필요한 파일을 안전하게 확인하며, 없으면 그 +동작에서 정확한 누락 원인을 표시한다. 다른 장면이나 설정까지 막지는 않는다. ## 사용자 설정 메뉴 좌측 메뉴 하단의 `설정`에서는 네이티브 폴더 선택 창으로 디자인(`Cuts`) 폴더, 설정(`Res`) -폴더와 선택 사항인 운영 배경 폴더를 직접 지정할 수 있다. 선택한 고정 로컬 폴더는 필수 파일, -카탈로그 형식과 reparse 여부를 검증한 뒤 다음 앱 전용 파일에 저장한다. +폴더와 선택 사항인 운영 배경 폴더를 직접 지정할 수 있다. 선택한 고정 로컬 경로와 `Res` +카탈로그 형식을 확인한 뒤 다음 앱 전용 파일에 저장한다. `Cuts` 내용 전체는 검사하지 않는다. ```text %LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\runtime-folders.local.json diff --git a/scripts/Initialize-ExistingDevelopmentPc.ps1 b/scripts/Initialize-ExistingDevelopmentPc.ps1 index 0960687..3f5b1dc 100644 --- a/scripts/Initialize-ExistingDevelopmentPc.ps1 +++ b/scripts/Initialize-ExistingDevelopmentPc.ps1 @@ -584,8 +584,18 @@ function Assert-LegacyRuntimeRoot { throw "Legacy runtime source root was not found: $Root" } + if ($Root.Contains('$') -or + $Root.Contains('%') -or + $Root.Contains('@') -or + $Root.Contains(';') -or + $Root.Contains("'")) { + throw 'Legacy runtime source root contains characters reserved by MSBuild.' + } + Assert-FixedLocalPath -Path $Root -Description 'Legacy runtime source root' - Assert-NoReparsePointInTree -Root $Root -Description 'Legacy runtime source tree' + Assert-NoReparsePointInExistingAncestry ` + -Path $Root ` + -Description 'Legacy runtime source root' if ((Test-SamePath -Left $Root -Right $RepositoryRoot) -or (Test-PathIsWithin -Root $RepositoryRoot -Candidate $Root) -or @@ -601,12 +611,17 @@ function Assert-LegacyRuntimeRoot { -not (Test-SamePath -Left ([IO.Path]::GetDirectoryName($resRoot)) -Right $Root)) { throw 'The selected folder must be the single common parent of Cuts and Res.' } + Assert-NoReparsePointInExistingAncestry ` + -Path $cutsRoot ` + -Description 'Cuts source directory' + Assert-NoReparsePointInExistingAncestry ` + -Path $resRoot ` + -Description 'Res source directory' } function Get-ExistingRuntimeBinding { param( - [Parameter(Mandatory = $true)][string] $RepositoryRoot, - [Parameter(Mandatory = $true)][string] $RuntimeStorageRoot + [Parameter(Mandatory = $true)][string] $RepositoryRoot ) $propsPath = Join-Path $RepositoryRoot 'Directory.Build.local.props' @@ -636,22 +651,10 @@ function Get-ExistingRuntimeBinding { throw 'Existing Directory.Build.local.props is not the exact generated Required runtime binding.' } - $installedRoot = Get-NormalizedFullPath -Path ([string] $roots[0].InnerText) - if (-not (Test-Path -LiteralPath $installedRoot -PathType Container) -or - -not (Test-PathIsWithin -Root $RuntimeStorageRoot -Candidate $installedRoot)) { - throw 'Existing runtime binding is not an installed LocalAppData runtime bundle.' - } - Assert-NoReparsePointInTree -Root $installedRoot -Description 'Installed runtime bundle' - - $manifestPath = Join-Path $installedRoot $manifestFileName - [void](Assert-RegularFile -Path $manifestPath -Description 'Installed runtime manifest') + $boundRoot = Get-NormalizedFullPath -Path ([string] $roots[0].InnerText) return [pscustomobject]@{ PropsPath = $propsPath - InstalledRoot = $installedRoot - ManifestPath = $manifestPath - ManifestSha256 = ( - Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256 - ).Hash.ToUpperInvariant() + BoundRoot = $boundRoot } } @@ -1273,6 +1276,41 @@ function Set-RuntimeBindingAtomically { } } +function New-DirectRuntimeBindingFile { + param( + [Parameter(Mandatory = $true)][string] $RuntimeRoot, + [Parameter(Mandatory = $true)][string] $OutputDirectory + ) + + $path = Join-Path $OutputDirectory 'Directory.Build.local.props' + $settings = New-Object Xml.XmlWriterSettings + $settings.Encoding = New-Object Text.UTF8Encoding($false) + $settings.Indent = $true + $settings.NewLineChars = [Environment]::NewLine + $settings.NewLineHandling = [Xml.NewLineHandling]::Replace + $writer = [Xml.XmlWriter]::Create($path, $settings) + try { + $writer.WriteStartDocument() + $writer.WriteStartElement('Project') + $writer.WriteStartElement('PropertyGroup') + $writer.WriteElementString( + 'LegacyRuntimeSourceRoot', + (Get-NormalizedFullPath -Path $RuntimeRoot)) + $writer.WriteElementString('LegacyRuntimeAssetsMode', 'Required') + $writer.WriteEndElement() + $writer.WriteEndElement() + $writer.WriteEndDocument() + } + finally { + $writer.Dispose() + } + + [void](Assert-RegularFile ` + -Path $path ` + -Description 'Generated direct runtime binding') + return $path +} + function Get-CanonicalJsonText { param( [Parameter(Mandatory = $true)][object] $Value, @@ -1282,6 +1320,44 @@ function Get-CanonicalJsonText { return ($Value | ConvertTo-Json -Depth $Depth) } +function Get-RuntimeFoldersPreflight { + param( + [Parameter(Mandatory = $true)][string] $LocalAppDataRoot, + [Parameter(Mandatory = $true)][string] $CutsDirectory, + [Parameter(Mandatory = $true)][string] $ResourceDirectory, + [switch] $AllowReplacement + ) + + $configurationRoot = Join-Path $LocalAppDataRoot 'MBN_STOCK_WEBVIEW\Config' + $path = Join-Path $configurationRoot 'runtime-folders.local.json' + $contract = [ordered]@{ + schemaVersion = 2 + sceneDirectory = $CutsDirectory + resourceDirectory = $ResourceDirectory + backgroundDirectory = $null + navigationExpanded = $true + colorTheme = 'system' + viewMode = 'automatic' + startWorkspace = 'stockCut' + } + $json = Get-CanonicalJsonText -Value $contract -Depth 3 + $exists = Test-Path -LiteralPath $path + $matches = $exists -and + (Test-ExistingJsonContract -Path $path -ExpectedJson $json) + if ($exists -and -not $matches -and -not $AllowReplacement) { + throw ( + 'The stored Cuts/Res paths differ from the selected folders. ' + + 'Re-run with -ReplaceRuntimeBinding.') + } + + return [pscustomobject]@{ + NeedsWrite = [bool] (-not $matches) + NeedsReplace = [bool] $exists + Path = $path + Json = $json + } +} + function Test-ExistingJsonContract { param( [Parameter(Mandatory = $true)][string] $Path, @@ -1768,14 +1844,13 @@ function Invoke-DebugBuildAndVerify { param( [Parameter(Mandatory = $true)][string] $DotNetPath, [Parameter(Mandatory = $true)][string] $ProjectPath, - [Parameter(Mandatory = $true)][string] $ExpectedRuntimeRoot, - [Parameter(Mandatory = $true)][string] $ExpectedManifestPath, - [Parameter(Mandatory = $true)][string] $ExpectedManifestSha256 + [Parameter(Mandatory = $true)][string] $ExpectedRuntimeRoot ) $runtimeRootProperty = "-p:LegacyRuntimeSourceRoot=$ExpectedRuntimeRoot" & $DotNetPath build $ProjectPath ` -c Debug ` + '--no-restore' ` '-p:Platform=x64' ` '-p:LegacyRuntimeAssetsMode=Required' ` $runtimeRootProperty | @@ -1841,10 +1916,9 @@ function Invoke-DebugBuildAndVerify { throw 'Debug x64 target directory is missing or is a SourceOnly output.' } - $payload = Assert-DebugRuntimePayloadMatchesManifest ` - -TargetDirectory $targetDir ` - -ManifestPath $ExpectedManifestPath ` - -ExpectedManifestSha256 $ExpectedManifestSha256 + if (Test-Path -LiteralPath (Join-Path $targetDir 'Cuts')) { + throw 'Debug x64 copied Cuts instead of using the selected external path.' + } if (@(Get-ChildItem -LiteralPath $targetDir -File -Recurse -Force | Where-Object { $_.Name.Equals('MmoneyCoder.ini', [StringComparison]::OrdinalIgnoreCase) @@ -1854,8 +1928,6 @@ function Invoke-DebugBuildAndVerify { return [pscustomobject]@{ TargetDirectory = $targetDir - CutsFileCount = [int] $payload.CutsFileCount - ResFileCount = [int] $payload.ResFileCount SourceOnly = $false } } @@ -1880,14 +1952,10 @@ if (-not $setupMutexOwned) { $repositoryRoot = Get-NormalizedFullPath -Path (Join-Path $PSScriptRoot '..') $projectPath = Join-Path $repositoryRoot ( 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj') -$bundleCreatorPath = Join-Path $PSScriptRoot 'New-LegacyRuntimeBundle.ps1' -$bundleInitializerPath = Join-Path $PSScriptRoot 'Initialize-LegacyRuntimeBundle.ps1' $registrationInspectorPath = Join-Path $PSScriptRoot 'Inspect-K3DRegistration.ps1' $liveInitializerPath = Join-Path $PSScriptRoot 'Initialize-DevelopmentLiveConfig.ps1' foreach ($requiredScript in @( - $bundleCreatorPath, - $bundleInitializerPath, $registrationInspectorPath, $liveInitializerPath)) { [void](Assert-RegularFile -Path $requiredScript -Description 'Required setup script') @@ -1936,6 +2004,22 @@ $runtimeSourceRoot = Select-LegacyRuntimeRoot ` -RequestedRoot $LegacyRuntimeSourceRoot ` -DisablePicker:$NoFolderPicker Assert-LegacyRuntimeRoot -Root $runtimeSourceRoot -RepositoryRoot $repositoryRoot +$cutsSourceRoot = Get-NormalizedFullPath -Path ( + Join-Path $runtimeSourceRoot 'Cuts') +$resourceSourceRoot = Get-NormalizedFullPath -Path ( + Join-Path $runtimeSourceRoot 'Res') +$existingRuntime = Get-ExistingRuntimeBinding -RepositoryRoot $repositoryRoot +$runtimeBindingReused = $null -ne $existingRuntime -and + (Test-SamePath ` + -Left ([string] $existingRuntime.BoundRoot) ` + -Right $runtimeSourceRoot) +if ($null -ne $existingRuntime -and + -not $runtimeBindingReused -and + -not $ReplaceRuntimeBinding) { + throw ( + 'The selected Cuts/Res path differs from the existing runtime binding. ' + + 'Re-run with -ReplaceRuntimeBinding.') +} # This is a read-only registry/file inspection. It never activates a COM class. $registrationReports = @(& $registrationInspectorPath) @@ -1980,6 +2064,11 @@ Assert-FixedLocalPath -Path $localAppDataRoot -Description 'LocalAppData' Assert-NoReparsePointInExistingAncestry ` -Path $localAppDataRoot ` -Description 'LocalAppData' +$runtimeFoldersPreflight = Get-RuntimeFoldersPreflight ` + -LocalAppDataRoot $localAppDataRoot ` + -CutsDirectory $cutsSourceRoot ` + -ResourceDirectory $resourceSourceRoot ` + -AllowReplacement:$ReplaceRuntimeBinding # Preflight a requested Live replacement before any setup write. The caller # either supplies both independent approval hashes or explicitly requests @@ -2162,12 +2251,6 @@ elseif (-not [string]::IsNullOrWhiteSpace($DatabaseIniPath) -or throw '-DatabaseIniPath/-ReplaceDatabaseProfile cannot be used with -SkipDatabaseProfile.' } -$runtimeStorageRoot = Join-Path $localAppDataRoot ( - 'MBN_STOCK_WEBVIEW\RuntimeBundles') -$existingRuntime = Get-ExistingRuntimeBinding ` - -RepositoryRoot $repositoryRoot ` - -RuntimeStorageRoot $runtimeStorageRoot - $setupParent = Join-Path $localAppDataRoot 'MBN_STOCK_WEBVIEW\SetupTemp' $rollbackParent = Join-Path $localAppDataRoot 'MBN_STOCK_WEBVIEW\SetupRollback' Assert-NoReparsePointInExistingAncestry ` @@ -2179,14 +2262,16 @@ Assert-NoReparsePointInExistingAncestry ` $setupRoot = Join-Path $setupParent ([Guid]::NewGuid().ToString('N')) $rollbackRoot = Join-Path $rollbackParent ([Guid]::NewGuid().ToString('N')) -$runtimeResult = $null $pendingRuntimeBinding = $null $buildResult = $null $databaseInstalled = $false +$runtimeFoldersWritten = $false $liveConfigured = $false $runtimeBindingMutationStarted = $false +$runtimeFoldersMutationStarted = $false $databaseMutationStarted = $false $propsSnapshot = $null +$runtimeFoldersSnapshot = $null $databaseSnapshot = $null $playoutSnapshot = $null $playoutMutationStarted = $false @@ -2216,6 +2301,10 @@ try { -Path (Join-Path $repositoryRoot 'Directory.Build.local.props') ` -BackupDirectory $rollbackRoot ` -Description 'Local build props' + $runtimeFoldersSnapshot = New-FileRollbackSnapshot ` + -Path ([string] $runtimeFoldersPreflight.Path) ` + -BackupDirectory $rollbackRoot ` + -Description 'Runtime folder configuration' if (-not $SkipDatabaseProfile) { $databaseSnapshot = New-FileRollbackSnapshot ` -Path $databaseDestination ` @@ -2229,90 +2318,12 @@ try { -Description 'Development Live base configuration' } - $bundleOutput = Join-Path $setupRoot 'bundle' - $bundleResults = @(& $bundleCreatorPath ` - -LegacyRuntimeSourceRoot $runtimeSourceRoot ` - -OutputDirectory $bundleOutput) - if ($bundleResults.Count -ne 1) { - throw 'Runtime bundle creation did not return one result.' - } - $verifiedBundle = Get-VerifiedBundleCreatorResult ` - -Result $bundleResults[0] ` - -BundleOutput $bundleOutput - $bundleResult = $verifiedBundle.Result - $newManifestPath = [string] $verifiedBundle.Manifest.Path - $newManifestHash = [string] $verifiedBundle.Manifest.Sha256 - - $sameRuntime = $null -ne $existingRuntime -and - $existingRuntime.ManifestSha256.Equals( - $newManifestHash, - [StringComparison]::Ordinal) - if ($sameRuntime) { - Assert-InstalledRuntimeMatchesManifest ` - -InstalledRoot $existingRuntime.InstalledRoot ` - -ManifestPath $newManifestPath - $runtimeResult = [pscustomobject]@{ - InstalledRoot = $existingRuntime.InstalledRoot - ManifestSha256 = $newManifestHash - ManifestPath = $existingRuntime.ManifestPath - CutsFileCount = [int] $verifiedBundle.Manifest.CutsFileCount - ResFileCount = [int] $verifiedBundle.Manifest.ResFileCount - Reused = $true - } - } - else { - if ($null -ne $existingRuntime -and -not $ReplaceRuntimeBinding) { - throw ( - 'The selected Cuts/Res manifest differs from the installed runtime ' + - 'binding. Re-run with -ReplaceRuntimeBinding.') - } - - # Install and fully verify the new immutable bundle while the current - # repository binding remains untouched. A minimal shadow repository lets - # the existing hardened initializer generate its exact Required props. - $shadowRepositoryRoot = Join-Path $setupRoot 'repository' - $shadowProjectDirectory = Join-Path $shadowRepositoryRoot ( - 'src\MBN_STOCK_WEBVIEW.LegacyParityApp') - [IO.Directory]::CreateDirectory($shadowProjectDirectory) | Out-Null - [IO.File]::Copy( - (Join-Path $repositoryRoot '.gitignore'), - (Join-Path $shadowRepositoryRoot '.gitignore'), - $false) - [IO.File]::Copy( - (Join-Path $repositoryRoot 'Directory.Build.props'), - (Join-Path $shadowRepositoryRoot 'Directory.Build.props'), - $false) - [IO.File]::Copy( - $projectPath, - (Join-Path $shadowProjectDirectory ( - 'MBN_STOCK_WEBVIEW.LegacyParityApp.csproj')), - $false) - - $initializerResults = @(& $bundleInitializerPath ` - -ZipPath ([string] $bundleResult.ArchivePath) ` - -ExpectedSha256 ([string] $bundleResult.ArchiveSha256) ` - -RepositoryRoot $shadowRepositoryRoot) - if ($initializerResults.Count -ne 1) { - throw 'Runtime bundle initialization did not return one result.' - } - $initialized = Get-VerifiedBundleInitializerResult ` - -Result $initializerResults[0] ` - -VerifiedBundle $verifiedBundle ` - -ShadowRepositoryRoot $shadowRepositoryRoot ` - -RuntimeStorageRoot $runtimeStorageRoot - - $runtimeResult = [pscustomobject]@{ - InstalledRoot = [string] $initialized.InstalledRoot - ManifestSha256 = [string] $initialized.ManifestSha256 - ManifestPath = Join-Path ` - ([string] $initialized.InstalledRoot) ` - $manifestFileName - CutsFileCount = [int] $initialized.CutsFileCount - ResFileCount = [int] $initialized.ResFileCount - Reused = $false - } + if (-not $runtimeBindingReused) { + $generatedPropsPath = New-DirectRuntimeBindingFile ` + -RuntimeRoot $runtimeSourceRoot ` + -OutputDirectory $setupRoot $pendingRuntimeBinding = [pscustomobject]@{ - GeneratedPropsPath = [string] $initialized.LocalBuildPropsPath + GeneratedPropsPath = $generatedPropsPath Replace = [bool] ($null -ne $existingRuntime) } } @@ -2321,15 +2332,7 @@ try { $buildResult = Invoke-DebugBuildAndVerify ` -DotNetPath $dotnetPath ` -ProjectPath $projectPath ` - -ExpectedRuntimeRoot $runtimeResult.InstalledRoot ` - -ExpectedManifestPath $runtimeResult.ManifestPath ` - -ExpectedManifestSha256 $runtimeResult.ManifestSha256 - if ([int] $buildResult.CutsFileCount -ne - [int] $runtimeResult.CutsFileCount -or - [int] $buildResult.ResFileCount -ne - [int] $runtimeResult.ResFileCount) { - throw 'Debug x64 payload count mismatch after exact manifest verification.' - } + -ExpectedRuntimeRoot $runtimeSourceRoot } if ($databaseNeedsWrite) { @@ -2342,6 +2345,20 @@ try { $databaseInstalled = $true } + if ($runtimeFoldersPreflight.NeedsWrite) { + $runtimeFoldersMutationStarted = $true + Write-ProtectedUtf8TextAtomically ` + -Path ([string] $runtimeFoldersPreflight.Path) ` + -ExpectedText ([string] $runtimeFoldersPreflight.Json) ` + -Description 'runtime folder configuration' ` + -Replace:$runtimeFoldersPreflight.NeedsReplace + Assert-ExistingJsonContract ` + -Path ([string] $runtimeFoldersPreflight.Path) ` + -ExpectedJson ([string] $runtimeFoldersPreflight.Json) ` + -Description 'Runtime folder configuration' + $runtimeFoldersWritten = $true + } + if ($ConfigureDevelopmentLive) { [void](Assert-RegularFile ` -Path $nativePath ` @@ -2410,20 +2427,13 @@ try { -RepositoryRoot $repositoryRoot ` -Replace:([bool] $pendingRuntimeBinding.Replace) $verifiedBinding = Get-ExistingRuntimeBinding ` - -RepositoryRoot $repositoryRoot ` - -RuntimeStorageRoot $runtimeStorageRoot + -RepositoryRoot $repositoryRoot if ($null -eq $verifiedBinding -or -not (Test-SamePath ` - -Left $verifiedBinding.InstalledRoot ` - -Right ([string] $runtimeResult.InstalledRoot)) -or - -not $verifiedBinding.ManifestSha256.Equals( - [string] $runtimeResult.ManifestSha256, - [StringComparison]::Ordinal)) { + -Left ([string] $verifiedBinding.BoundRoot) ` + -Right $runtimeSourceRoot)) { throw 'The atomically installed runtime binding failed final verification.' } - Assert-InstalledRuntimeMatchesManifest ` - -InstalledRoot $verifiedBinding.InstalledRoot ` - -ManifestPath $runtimeResult.ManifestPath } # A cleanup failure is still a transaction failure. Rollback snapshots are @@ -2469,6 +2479,19 @@ if (-not $transactionCommitted) { $_.Exception.Message) } } + if ($runtimeFoldersMutationStarted -and + $null -ne $runtimeFoldersSnapshot) { + try { + Restore-FileRollbackSnapshot ` + -Snapshot $runtimeFoldersSnapshot ` + -Description 'Runtime folder configuration' + } + catch { + $secondaryFailures.Add( + 'Runtime folder configuration rollback failed: ' + + $_.Exception.Message) + } + } if ($databaseMutationStarted -and $null -ne $databaseSnapshot) { try { Restore-FileRollbackSnapshot ` @@ -2538,9 +2561,10 @@ if (-not $transactionCommitted) { [pscustomobject]@{ RuntimeSourceRoot = $runtimeSourceRoot - InstalledRuntimeRoot = $runtimeResult.InstalledRoot - RuntimeManifestSha256 = $runtimeResult.ManifestSha256 - RuntimeBindingReused = [bool] $runtimeResult.Reused + BoundRuntimeRoot = $runtimeSourceRoot + RuntimeBindingReused = [bool] $runtimeBindingReused + RuntimeFolderConfigurationPath = [string] $runtimeFoldersPreflight.Path + RuntimeFolderConfigurationWritten = $runtimeFoldersWritten DatabaseProfilePath = if ($SkipDatabaseProfile) { $null } else { $databaseDestination } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorSettings.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorSettings.cs index c7a5e20..4a9eac0 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorSettings.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorSettings.cs @@ -93,8 +93,6 @@ public static class LegacyOperatorFolderValidator private const string InvalidFolderMessage = "선택한 폴더를 안전하게 사용할 수 없습니다."; - private const string MissingSceneMessage = - "완전한 Cuts 폴더가 아닙니다. 필수 장면과 기본 자산을 확인하세요. 기존 설정은 유지됩니다."; private const string InvalidResourceMessage = "선택한 설정 폴더의 UI 설정 파일을 검증할 수 없습니다."; @@ -186,50 +184,9 @@ public static class LegacyOperatorFolderValidator switch (kind) { case LegacyOperatorSettingsFolderKind.Scene: - foreach (var fileName in RequiredSceneFileNameValues) - { - if (!TryValidateContainedRegularFile( - canonicalPath, - fileName, - required: true, - out _, - out failure)) - { - return Invalid(failure, MissingSceneMessage); - } - } - - foreach (var relativePath in RequiredBuiltInAssetRelativePathValues) - { - if (!TryValidateContainedRegularFile( - canonicalPath, - relativePath, - required: true, - out _, - out failure)) - { - return Invalid(failure, MissingSceneMessage); - } - } - - foreach (var relativePath in OptionalExternalAssetRelativePathValues) - { - if (!TryValidateContainedRegularFile( - canonicalPath, - relativePath, - required: false, - out var detected, - out failure)) - { - return Invalid(failure, MissingSceneMessage); - } - - if (!detected) - { - missingOptionalExternalAssetCount++; - } - } - + // Cuts is an operator-owned external runtime directory. Individual + // scenes and media are optional and are validated only when a + // command actually resolves that file. break; case LegacyOperatorSettingsFolderKind.Resource: diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/FirstRunSetupWindow.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/FirstRunSetupWindow.xaml.cs index 8f8100f..85da2da 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/FirstRunSetupWindow.xaml.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/FirstRunSetupWindow.xaml.cs @@ -126,7 +126,7 @@ public sealed partial class FirstRunSetupWindow : Window SetSetupRunning(true); SetupStatusBar.Title = "설정 중"; SetupStatusBar.Message = - "자산·DB 설정·K3D를 검증하고 있습니다. 완료될 때까지 Visual Studio 실행을 중지하지 마세요."; + "선택한 경로와 DB 설정·K3D를 적용하고 있습니다. 완료될 때까지 Visual Studio 실행을 중지하지 마세요."; SetupStatusBar.Severity = InfoBarSeverity.Informational; try @@ -253,7 +253,7 @@ public sealed partial class FirstRunSetupWindow : Window InstallButton.IsEnabled = true; SetupStatusBar.Title = "준비 완료"; SetupStatusBar.Message = - "설정 시작을 누르면 검증된 파일만 이 PC의 로컬 실행 영역에 구성합니다."; + "설정 시작을 누르면 선택한 Cuts/Res 경로를 이 PC에 적용합니다."; SetupStatusBar.Severity = InfoBarSeverity.Success; } @@ -302,6 +302,13 @@ public sealed partial class FirstRunSetupWindow : Window return false; } + if (cutsParent.IndexOfAny(['$', '%', '@', ';', '\'']) >= 0) + { + warningMessage = + "선택한 폴더 경로에 설정용 예약 문자가 포함되어 있습니다. 다른 위치의 Cuts/Res를 선택해 주세요."; + return false; + } + runtimeRoot = cutsParent; return true; } 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 e22d41c..ee5a172 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj @@ -31,9 +31,9 @@ --> $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..')) bin\SourceOnly\ @@ -83,7 +83,7 @@ - + @@ -162,13 +162,6 @@ PreserveNewest PreserveNewest - - Cuts\%(RecursiveDir)%(Filename)%(Extension) - Cuts\%(RecursiveDir)%(Filename)%(Extension) - PreserveNewest - PreserveNewest - Res\%(Filename)%(Extension) @@ -209,7 +202,7 @@ Text="Legacy runtime assets were not found. Building the safe first-run setup app. Start Debug x64 and select the existing Cuts and Res folders." /> + Text="External legacy runtime paths are enabled. Cuts files remain in the selected folder and database credentials remain in the current user's LocalAppData overlay." /> @@ -228,12 +221,6 @@ - - @@ -264,6 +251,12 @@ Condition="Exists('$(TargetDir)Res\MmoneyCoder.ini')" /> + + + + diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs index a445bb5..2320458 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs @@ -38,7 +38,9 @@ public sealed partial class MainWindow _isDevelopmentLiveLaunch && !_developmentLiveStartupBlocked ? PlayoutOptionsLoader.LoadDevelopmentLive( - baseDirectory: AppContext.BaseDirectory) + baseDirectory: AppContext.BaseDirectory, + operatorSceneDirectory: + _appliedOperatorSettings.SceneDirectory) : PlayoutOptionsLoader.Load( operatorSceneDirectory: _appliedOperatorSettings.SceneDirectory, operatorBackgroundDirectory: _appliedOperatorSettings.BackgroundDirectory, diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs index 162d618..73f501b 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs @@ -87,13 +87,9 @@ public sealed partial class MainWindow : Window "MBN_STOCK_WEBVIEW", "Res", "종목.ini"); - var selectedResourceDirectory = _isDevelopmentLiveLaunch - ? null - : _appliedOperatorSettings.ResourceDirectory; - var cutMenuComposition = _isDevelopmentLiveLaunch - ? LegacyRuntimeStockCutMenuLoader.ResolveFromBaseDirectory( - AppContext.BaseDirectory) - : selectedResourceDirectory is null + var selectedResourceDirectory = + _appliedOperatorSettings.ResourceDirectory; + var cutMenuComposition = selectedResourceDirectory is null ? LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates( runtimeCutMenuOverridePath, AppContext.BaseDirectory) @@ -135,7 +131,9 @@ public sealed partial class MainWindow : Window // A Gate A plan pins database.local.json by path, size and SHA-256. // Never inspect either packaged or operator-selected legacy INI files // during that one-shot validation process. - var selectedLegacyDatabaseIni = selectedResourceDirectory is null + var selectedLegacyDatabaseIni = + _isDevelopmentLiveLaunch || + selectedResourceDirectory is null ? null : Path.Combine(selectedResourceDirectory, "MmoneyCoder.ini"); _databaseRuntime = _playoutLaunchAuthorization.IsGateA @@ -375,17 +373,28 @@ public sealed partial class MainWindow : Window appWindow.SetIcon(iconPath); } + // Preserve the legacy restore size when it fits, but clamp the complete + // window (client plus caption and borders) to the monitor work area. + // Clamping the client size alone can still place the bottom controls + // behind the taskbar. + var displayArea = DisplayArea.GetFromWindowId(windowId, DisplayAreaFallback.Primary); + var workArea = displayArea.WorkArea; + appWindow.ResizeClient(new SizeInt32(1905, 1015)); + var requestedWindowSize = appWindow.Size; + appWindow.MoveAndResize(new RectInt32( + workArea.X, + workArea.Y, + Math.Min(requestedWindowSize.Width, workArea.Width), + Math.Min(requestedWindowSize.Height, workArea.Height))); + if (appWindow.Presenter is OverlappedPresenter presenter) { - presenter.IsMaximizable = false; + // Keep the operator window fixed, while allowing Windows to use its + // standard work-area-aware maximize path on startup and after restore. + presenter.IsMaximizable = true; presenter.IsResizable = false; + presenter.Maximize(); } - - // WinForms stored 1905 x 1015 as ClientSize. AppWindow.Resize would include - // the caption and borders and silently make the WebView viewport smaller. - appWindow.ResizeClient(new SizeInt32(1905, 1015)); - var displayArea = DisplayArea.GetFromWindowId(windowId, DisplayAreaFallback.Primary); - appWindow.Move(new PointInt32(displayArea.WorkArea.X, displayArea.WorkArea.Y)); } private async Task InitializeWebViewAsync() diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs index 4973884..5ea728e 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs @@ -89,14 +89,15 @@ public static class PlayoutOptionsLoader /// /// Loads the inert LocalAppData base profile for an already-applied exact - /// Debug Development Live bootstrap. This path deliberately ignores every - /// process environment override and native operator folder selection. Live - /// mode is armed from bootstrap provenance, and the scene root is pinned to - /// the verified Cuts payload beside the executable. + /// Debug Development Live bootstrap. This path deliberately ignores process + /// environment and ordinary playout-profile scene overrides. Live mode is + /// armed from bootstrap provenance, and the scene root is pinned to the + /// native folder selected during first-run setup. /// internal static PlayoutOptions LoadDevelopmentLive( string? path = null, - string? baseDirectory = null) + string? baseDirectory = null, + string? operatorSceneDirectory = null) { var options = LoadDevelopmentLiveJson( path ?? DefaultPath, @@ -105,6 +106,10 @@ public static class PlayoutOptionsLoader options.Mode = PlayoutMode.Live; options.SceneDirectory = null; + ApplyOperatorAssetOverrides( + options, + operatorSceneDirectory, + backgroundDirectory: null); ApplyExecutableAssetDefaults( options, baseDirectory ?? AppContext.BaseDirectory); diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorSettingsTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorSettingsTests.cs index 5d2070a..4ebbb11 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorSettingsTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorSettingsTests.cs @@ -22,11 +22,10 @@ public sealed class LegacyOperatorSettingsTests } [Fact] - public void SceneValidation_RequiresEveryActiveSceneAndBuiltInAssetAndAllowsMissingExternalVideo() + public void SceneValidation_AcceptsAnEmptyCutsDirectoryAsADirectRuntimePath() { using var directory = new TemporaryDirectory(); var sceneDirectory = directory.CreateDirectory("Cuts"); - WriteValidSceneFiles(sceneDirectory); var result = LegacyOperatorFolderValidator.Validate( LegacyOperatorSettingsFolderKind.Scene, @@ -34,91 +33,33 @@ public sealed class LegacyOperatorSettingsTests Assert.True(result.IsValid); Assert.Equal(Path.GetFullPath(sceneDirectory), result.Folder!.CanonicalPath); - Assert.Equal("fixture-scene", File.ReadAllText( - Path.Combine(result.Folder.CanonicalPath, "5001.t2s"))); Assert.Contains("Cuts", result.Folder.AbbreviatedPath); Assert.InRange( result.Folder.AbbreviatedPath.Length, 1, LegacyOperatorFolderValidator.DefaultDisplayCharacters); Assert.False(result.Folder.DatabaseIniDetected); - Assert.Equal( - LegacyOperatorFolderValidator.OptionalExternalAssetRelativePaths.Count, - result.Folder.MissingOptionalExternalAssetCount); + Assert.Equal(0, result.Folder.MissingOptionalExternalAssetCount); Assert.Null(result.WarningMessage); } [Fact] - public void SceneValidation_BaselineOnlyIsRejectedInsteadOfBreakingOtherScenesAfterRestart() + public void SceneValidation_DoesNotInspectPartialScenesOrVideoFiles() { using var directory = new TemporaryDirectory(); var sceneDirectory = directory.CreateDirectory("partial-Cuts"); File.WriteAllText(Path.Combine(sceneDirectory, "5001.t2s"), "baseline"); + var videoDirectory = Directory.CreateDirectory( + Path.Combine(sceneDirectory, "Video")).FullName; + File.WriteAllBytes(Path.Combine(videoDirectory, "partial.vrv"), []); var result = LegacyOperatorFolderValidator.Validate( LegacyOperatorSettingsFolderKind.Scene, sceneDirectory); - Assert.False(result.IsValid); - Assert.Equal( - LegacyOperatorFolderValidationFailure.RequiredFileMissing, - result.Failure); - Assert.Null(result.Folder); - AssertPathFree(result.WarningMessage, sceneDirectory); - } - - [Fact] - public void SceneValidation_MissingOneActiveSceneOrBuiltInAssetIsRejected() - { - using var directory = new TemporaryDirectory(); - var missingSceneDirectory = directory.CreateDirectory("missing-scene"); - WriteValidSceneFiles(missingSceneDirectory); - File.Delete(Path.Combine(missingSceneDirectory, "5079.t2s")); - var missingScene = LegacyOperatorFolderValidator.Validate( - LegacyOperatorSettingsFolderKind.Scene, - missingSceneDirectory); - - var missingAssetDirectory = directory.CreateDirectory("missing-asset"); - WriteValidSceneFiles(missingAssetDirectory); - File.Delete(Path.Combine(missingAssetDirectory, "Images", "KRX.png")); - var missingAsset = LegacyOperatorFolderValidator.Validate( - LegacyOperatorSettingsFolderKind.Scene, - missingAssetDirectory); - - Assert.False(missingScene.IsValid); - Assert.Equal(LegacyOperatorFolderValidationFailure.RequiredFileMissing, - missingScene.Failure); - Assert.False(missingAsset.IsValid); - Assert.Equal(LegacyOperatorFolderValidationFailure.RequiredFileMissing, - missingAsset.Failure); - } - - [Fact] - public void SceneValidation_RejectsEmptyRequiredOrPresentOptionalFiles() - { - using var directory = new TemporaryDirectory(); - var emptyRequiredDirectory = directory.CreateDirectory("empty-required"); - WriteValidSceneFiles(emptyRequiredDirectory); - File.WriteAllBytes(Path.Combine(emptyRequiredDirectory, "5076.t2s"), []); - var emptyRequired = LegacyOperatorFolderValidator.Validate( - LegacyOperatorSettingsFolderKind.Scene, - emptyRequiredDirectory); - - var emptyOptionalDirectory = directory.CreateDirectory("empty-optional"); - WriteValidSceneFiles(emptyOptionalDirectory); - var optionalPath = Path.Combine(emptyOptionalDirectory, "Video", "큐브배경.vrv"); - Directory.CreateDirectory(Path.GetDirectoryName(optionalPath)!); - File.WriteAllBytes(optionalPath, []); - var emptyOptional = LegacyOperatorFolderValidator.Validate( - LegacyOperatorSettingsFolderKind.Scene, - emptyOptionalDirectory); - - Assert.False(emptyRequired.IsValid); - Assert.Equal(LegacyOperatorFolderValidationFailure.RequiredFileUnsafe, - emptyRequired.Failure); - Assert.False(emptyOptional.IsValid); - Assert.Equal(LegacyOperatorFolderValidationFailure.RequiredFileUnsafe, - emptyOptional.Failure); + Assert.True(result.IsValid); + Assert.Equal(Path.GetFullPath(sceneDirectory), result.Folder!.CanonicalPath); + Assert.Null(result.WarningMessage); } [Fact] @@ -162,10 +103,12 @@ public sealed class LegacyOperatorSettingsTests } [Fact] - public void SceneValidation_MissingBaselineFailsWithoutLeakingThePath() + public void SceneValidation_MissingDirectoryFailsWithoutLeakingThePath() { using var directory = new TemporaryDirectory(); - var sceneDirectory = directory.CreateDirectory("sensitive-design-folder"); + var sceneDirectory = Path.Combine( + directory.Path, + "sensitive-design-folder"); var result = LegacyOperatorFolderValidator.Validate( LegacyOperatorSettingsFolderKind.Scene, @@ -173,7 +116,7 @@ public sealed class LegacyOperatorSettingsTests Assert.False(result.IsValid); Assert.Equal( - LegacyOperatorFolderValidationFailure.RequiredFileMissing, + LegacyOperatorFolderValidationFailure.DirectoryNotFound, result.Failure); Assert.Null(result.Folder); AssertPathFree(result.WarningMessage, sceneDirectory); diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDevelopmentLiveStartupContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDevelopmentLiveStartupContractTests.cs index 12c54d0..3a21e8a 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDevelopmentLiveStartupContractTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDevelopmentLiveStartupContractTests.cs @@ -119,7 +119,7 @@ public sealed class LegacyDevelopmentLiveStartupContractTests } [Fact] - public void AppliedDevelopmentLiveUsesOnlyProtectedStartupInputs() + public void AppliedDevelopmentLiveUsesSelectedRuntimePathsAndProtectedDatabaseInputs() { var window = File.ReadAllText(Path.Combine(ParityRoot, "MainWindow.xaml.cs")); var playout = File.ReadAllText(Path.Combine( @@ -135,11 +135,18 @@ public sealed class LegacyDevelopmentLiveStartupContractTests window, StringComparison.Ordinal); Assert.Contains( - "? LegacyRuntimeStockCutMenuLoader.ResolveFromBaseDirectory(", + "var selectedResourceDirectory =\n" + + " _appliedOperatorSettings.ResourceDirectory;", + window.ReplaceLineEndings("\n"), + StringComparison.Ordinal); + Assert.Contains( + "LegacyRuntimeStockCutMenuLoader.ResolveFromResourceDirectory(", window, StringComparison.Ordinal); Assert.Contains( - "? null\n : _appliedOperatorSettings.ResourceDirectory;", + "_isDevelopmentLiveLaunch ||\n" + + " selectedResourceDirectory is null\n" + + " ? null", window.ReplaceLineEndings("\n"), StringComparison.Ordinal); Assert.Contains( @@ -150,6 +157,11 @@ public sealed class LegacyDevelopmentLiveStartupContractTests "? PlayoutOptionsLoader.LoadDevelopmentLive(", playout, StringComparison.Ordinal); + Assert.Contains( + "operatorSceneDirectory:\n" + + " _appliedOperatorSettings.SceneDirectory", + playout.ReplaceLineEndings("\n"), + StringComparison.Ordinal); Assert.Contains( "IsSourceOnlyBuild ||\n _isDevelopmentLiveLaunchRequested", playout.ReplaceLineEndings("\n"), diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyFirstRunSetupNativeContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyFirstRunSetupNativeContractTests.cs index 16dfa91..f9e5660 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyFirstRunSetupNativeContractTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyFirstRunSetupNativeContractTests.cs @@ -241,11 +241,13 @@ public sealed class LegacyFirstRunSetupNativeContractTests var pinPreflight = initializer.LastIndexOf( "$k3dPinPreflight = Get-K3DPinPreflight", StringComparison.Ordinal); - var pinWrite = initializer.LastIndexOf( + var pinWrite = initializer.IndexOf( "Write-ProtectedUtf8TextAtomically", + pinPreflight, StringComparison.Ordinal); - var livePreflight = initializer.LastIndexOf( + var livePreflight = initializer.IndexOf( "$livePreflight = Get-DevelopmentLivePreflight", + pinWrite, StringComparison.Ordinal); Assert.True(pinPreflight >= 0); Assert.True(pinWrite > pinPreflight); diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs index d12f8ba..9f017f8 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs @@ -12,13 +12,29 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests "MBN_STOCK_WEBVIEW.LegacyParityApp.csproj"); [Fact] - public void LegacyParityProjectLinksCutsAndResIntoOutputPublishAndMsix() + public void LegacyParityProjectUsesCutsInPlaceAndLinksOnlyAllowlistedRes() { var project = XDocument.Load(ProjectPath); var contents = project.Descendants("Content").ToArray(); - AssertLinkedRuntimeTree(contents, "$(LegacyCutsSourceRoot)\\**\\*", "Cuts\\"); + Assert.DoesNotContain( + contents, + element => string.Equals( + element.Attribute("Include")?.Value, + "$(LegacyCutsSourceRoot)\\**\\*", + StringComparison.Ordinal)); AssertLinkedRuntimeTree(contents, "@(LegacyPackagedResAsset)", "Res\\"); + var cutsScrub = Assert.Single( + project.Descendants("Target"), + target => string.Equals( + target.Attribute("Name")?.Value, + "ScrubCopiedCutsFromBuildOutput", + StringComparison.Ordinal)); + Assert.Contains( + "$(TargetDir)Cuts", + Assert.Single(cutsScrub.Elements("RemoveDir")) + .Attribute("Directories")?.Value, + StringComparison.Ordinal); var packagedResNames = project.Descendants("LegacyPackagedResAsset") .Select(element => Path.GetFileName(element.Attribute("Include")?.Value)) @@ -354,10 +370,10 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests validationTarget.Elements("Message"), message => (message.Attribute("Text")?.Value ?? string.Empty) .Contains( - "Verified full legacy runtime assets are enabled", + "External legacy runtime paths are enabled", StringComparison.Ordinal)); Assert.Contains( - "Verified full legacy runtime assets are enabled", + "Cuts files remain in the selected folder", fullRuntimeMessage.Attribute("Text")?.Value, StringComparison.Ordinal); @@ -379,7 +395,6 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests string[] externalContentIncludes = [ - @"$(LegacyCutsSourceRoot)\**\*", "@(LegacyPackagedResAsset)", "@(LegacyFinancialPreviewAsset)", "@(LegacyBrandAsset)" @@ -397,6 +412,12 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests content.Attribute("Condition")?.Value, StringComparison.Ordinal); }); + Assert.DoesNotContain( + project.Descendants("Content"), + element => string.Equals( + element.Attribute("Include")?.Value, + @"$(LegacyCutsSourceRoot)\**\*", + StringComparison.Ordinal)); Assert.DoesNotContain( project.Descendants("Target"), @@ -454,8 +475,6 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests "$(LegacyRuntimeSourceRoot)", "$(LegacyCutsSourceRoot)", "$(LegacyResSourceRoot)", - "$(LegacyCutsSourceRoot)\\%(LegacyRequiredScene.Identity).t2s", - "$(LegacyCutsSourceRoot)\\%(LegacyRequiredBuiltInAsset.Identity)", "$(LegacyResSourceRoot)\\logo.png", "$(LegacyResSourceRoot)\\종목.ini", "$(LegacyResSourceRoot)\\업종_코스피.ini", @@ -673,42 +692,52 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests "must be outside and must not overlap this repository", initializer, StringComparison.Ordinal); - Assert.Contains("Assert-NoReparsePointInTree", initializer, - StringComparison.Ordinal); - - Assert.Contains("'New-LegacyRuntimeBundle.ps1'", initializer, - StringComparison.Ordinal); - Assert.Contains("'Initialize-LegacyRuntimeBundle.ps1'", initializer, + Assert.Contains("Assert-NoReparsePointInExistingAncestry", initializer, StringComparison.Ordinal); Assert.Contains( - "$existingRuntime.ManifestSha256.Equals(", + "Legacy runtime source root contains characters reserved by MSBuild.", + initializer, + StringComparison.Ordinal); + Assert.DoesNotContain("$bundleCreatorPath =", initializer, + StringComparison.Ordinal); + Assert.DoesNotContain("$bundleInitializerPath =", initializer, + StringComparison.Ordinal); + Assert.DoesNotContain("& $bundleCreatorPath", initializer, + StringComparison.Ordinal); + Assert.DoesNotContain("& $bundleInitializerPath", initializer, + StringComparison.Ordinal); + Assert.Contains( + "function New-DirectRuntimeBindingFile", initializer, StringComparison.Ordinal); Assert.Contains( - "Assert-InstalledRuntimeMatchesManifest", + "function Get-RuntimeFoldersPreflight", initializer, StringComparison.Ordinal); Assert.Contains( - "Installed runtime bundle contains an unexpected file", + "runtime-folders.local.json", initializer, StringComparison.Ordinal); Assert.Contains("[switch] $ReplaceRuntimeBinding", initializer, StringComparison.Ordinal); + Assert.Contains( + "The selected Cuts/Res path differs from the existing runtime binding.", + initializer, + StringComparison.Ordinal); Assert.Contains("Set-RuntimeBindingAtomically", initializer, StringComparison.Ordinal); Assert.Contains( "[IO.File]::Replace($temporary, $destination, $backup)", initializer, StringComparison.Ordinal); - var shadowInstall = initializer.IndexOf( - "-RepositoryRoot $shadowRepositoryRoot", + var directBinding = initializer.LastIndexOf( + "New-DirectRuntimeBindingFile", StringComparison.Ordinal); - var bindingReplace = initializer.IndexOf( + var bindingReplace = initializer.LastIndexOf( "Set-RuntimeBindingAtomically", - shadowInstall, StringComparison.Ordinal); - Assert.True(shadowInstall >= 0); - Assert.True(bindingReplace > shadowInstall); + Assert.True(directBinding >= 0); + Assert.True(bindingReplace > directBinding); Assert.Contains("[string] $DatabaseIniPath", initializer, StringComparison.Ordinal); @@ -790,6 +819,7 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests Assert.Contains("& $DotNetPath build $ProjectPath", initializer, StringComparison.Ordinal); + Assert.Contains("'--no-restore'", initializer, StringComparison.Ordinal); Assert.Contains( "'-p:LegacyRuntimeAssetsMode=Required'", initializer, @@ -810,7 +840,9 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests "unexpectedly resolved as SourceOnly", initializer, StringComparison.Ordinal); - Assert.Contains("Debug x64 payload count mismatch", initializer, + Assert.Contains( + "Debug x64 copied Cuts instead of using the selected external path.", + initializer, StringComparison.Ordinal); Assert.Contains( "Credential-bearing MmoneyCoder.ini entered the Debug build output.", @@ -820,12 +852,15 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests var runtimeWrite = initializer.LastIndexOf( "Set-RuntimeBindingAtomically", StringComparison.Ordinal); - var pinWrite = initializer.LastIndexOf( - "Write-ProtectedUtf8TextAtomically", + var pinWrite = initializer.IndexOf( + "-Description 'K3D persistent pin'", StringComparison.Ordinal); var databaseWrite = initializer.LastIndexOf( "Copy-DatabaseProfileAtomically", StringComparison.Ordinal); + var runtimeFoldersWrite = initializer.LastIndexOf( + "-Description 'runtime folder configuration'", + StringComparison.Ordinal); var build = initializer.LastIndexOf( "Invoke-DebugBuildAndVerify", StringComparison.Ordinal); @@ -841,7 +876,8 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests Assert.True(pinWrite >= 0); Assert.True(build > pinWrite); Assert.True(databaseWrite > build); - Assert.True(finalK3dVerification > databaseWrite); + Assert.True(runtimeFoldersWrite > databaseWrite); + Assert.True(finalK3dVerification > runtimeFoldersWrite); Assert.True(liveWrite > finalK3dVerification); Assert.True(runtimeWrite > liveWrite); Assert.True(cleanup > runtimeWrite); @@ -910,6 +946,12 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests var databaseRollback = initializer.LastIndexOf( "-Snapshot $databaseSnapshot", StringComparison.Ordinal); + var runtimeFoldersMutation = initializer.LastIndexOf( + "$runtimeFoldersMutationStarted = $true", + StringComparison.Ordinal); + var runtimeFoldersRollback = initializer.LastIndexOf( + "-Snapshot $runtimeFoldersSnapshot", + StringComparison.Ordinal); var runtimeMutation = initializer.LastIndexOf( "$runtimeBindingMutationStarted = $true", StringComparison.Ordinal); @@ -920,8 +962,10 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests "-Snapshot $propsSnapshot", StringComparison.Ordinal); Assert.True(databaseMutation >= 0 && databaseMutation < databaseWrite); + Assert.True(runtimeFoldersMutation >= 0); Assert.True(runtimeMutation >= 0 && runtimeMutation < runtimeWrite); Assert.True(databaseRollback > databaseWrite); + Assert.True(runtimeFoldersRollback > runtimeFoldersMutation); Assert.True(runtimeRollback > runtimeWrite); Assert.Contains("Original failure:", initializer, StringComparison.Ordinal); Assert.Contains("Secondary recovery failure(s):", initializer, @@ -929,51 +973,16 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests Assert.Contains("function Assert-ExactObjectProperties", initializer, StringComparison.Ordinal); - Assert.Contains( - "-Description 'Runtime bundle creation result'", - initializer, - StringComparison.Ordinal); - Assert.Contains( - "-Description 'Runtime bundle initialization result'", - initializer, - StringComparison.Ordinal); Assert.Contains( "-Description 'K3D registration inspection result'", initializer, StringComparison.Ordinal); Assert.Contains( - "Runtime bundle creation result paths escaped or changed the exact output contract.", + "function New-DirectRuntimeBindingFile", initializer, StringComparison.Ordinal); Assert.Contains( - "Runtime bundle initialization result paths or hashes violate containment.", - initializer, - StringComparison.Ordinal); - - Assert.Contains("function Get-VerifiedRuntimeManifest", initializer, - StringComparison.Ordinal); - Assert.Contains( - "duplicate/extra/case/type/format changes are rejected.", - initializer, - StringComparison.Ordinal); - Assert.Contains( - "function Assert-DebugRuntimePayloadMatchesManifest", - initializer, - StringComparison.Ordinal); - Assert.Contains( - "Debug x64 runtime payload differs from the manifest:", - initializer, - StringComparison.Ordinal); - Assert.Contains( - "Debug x64 runtime output contains an unexpected file:", - initializer, - StringComparison.Ordinal); - Assert.Contains( - "Debug x64 runtime output does not match the exact manifest file set.", - initializer, - StringComparison.Ordinal); - Assert.Contains( - "-Root $targetRoot", + "Runtime folder configuration rollback failed:", initializer, StringComparison.Ordinal); @@ -1008,8 +1017,8 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests var buildVerification = initializer.LastIndexOf( "Invoke-DebugBuildAndVerify", StringComparison.Ordinal); - var pinIssue = initializer.LastIndexOf( - "Write-ProtectedUtf8TextAtomically", + var pinIssue = initializer.IndexOf( + "-Description 'K3D persistent pin'", StringComparison.Ordinal); var finalK3dVerification = initializer.LastIndexOf( "$finalNativeHash = (", diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWindowSizingNativeContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWindowSizingNativeContractTests.cs new file mode 100644 index 0000000..1483487 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWindowSizingNativeContractTests.cs @@ -0,0 +1,111 @@ +namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests; + +public sealed class LegacyWindowSizingNativeContractTests +{ + private static readonly string Window = File.ReadAllText(Path.Combine( + FindRepositoryRoot(), + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", + "MainWindow.xaml.cs")); + + [Fact] + public void MainWindowClampsRestoreBoundsAndUsesWorkAreaAwareSystemMaximize() + { + var configureWindow = Slice( + Window, + "private void ConfigureWindow()", + "private async Task InitializeWebViewAsync()"); + + var displayArea = RequiredIndex( + configureWindow, + "DisplayArea.GetFromWindowId(windowId, DisplayAreaFallback.Primary)"); + var workArea = RequiredIndex( + configureWindow, + "var workArea = displayArea.WorkArea;"); + var resizeClient = RequiredIndex( + configureWindow, + "appWindow.ResizeClient(new SizeInt32(1905, 1015));"); + var readOuterSize = RequiredIndex( + configureWindow, + "var requestedWindowSize = appWindow.Size;"); + var moveAndResize = RequiredIndex( + configureWindow, + "appWindow.MoveAndResize("); + var workAreaX = RequiredIndex(configureWindow, "workArea.X,"); + var workAreaY = RequiredIndex(configureWindow, "workArea.Y,"); + var allowSystemMaximize = RequiredIndex( + configureWindow, + "presenter.IsMaximizable = true;"); + var maximize = RequiredIndex( + configureWindow, + "presenter.Maximize();"); + Assert.True(displayArea < workArea); + Assert.True(workArea < resizeClient); + Assert.True(resizeClient < readOuterSize); + Assert.True(readOuterSize < moveAndResize); + Assert.True(moveAndResize < workAreaX); + Assert.True(workAreaX < workAreaY); + Assert.True(moveAndResize < allowSystemMaximize); + Assert.True(allowSystemMaximize < maximize); + Assert.DoesNotContain( + "presenter.IsMaximizable = false;", + configureWindow, + StringComparison.Ordinal); + Assert.Contains( + "presenter.IsResizable = false;", + configureWindow, + StringComparison.Ordinal); + Assert.Contains( + "var requestedWindowSize = appWindow.Size;", + configureWindow, + StringComparison.Ordinal); + Assert.Contains( + "Math.Min(requestedWindowSize.Width, workArea.Width)", + configureWindow, + StringComparison.Ordinal); + Assert.Contains( + "Math.Min(requestedWindowSize.Height, workArea.Height)", + configureWindow, + StringComparison.Ordinal); + Assert.DoesNotContain( + "appWindow.Move(new PointInt32(", + configureWindow, + StringComparison.Ordinal); + } + + private static int RequiredIndex(string value, string needle) + { + var index = value.IndexOf(needle, StringComparison.Ordinal); + Assert.True(index >= 0, $"Required marker was not found: {needle}"); + return index; + } + + private static string Slice(string value, string start, string end) + { + var startIndex = value.IndexOf(start, StringComparison.Ordinal); + var endIndex = value.IndexOf( + end, + startIndex + start.Length, + StringComparison.Ordinal); + + Assert.True(startIndex >= 0, $"Required marker was not found: {start}"); + Assert.True(endIndex >= 0, $"Required marker was not found: {end}"); + return value[startIndex..endIndex]; + } + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "MBN_STOCK_WEBVIEW.sln"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find the repository root."); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs index 77532bc..4d13b06 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs @@ -163,6 +163,49 @@ public sealed class PlayoutOptionsLoaderTests } } + [Fact] + public void LoadDevelopmentLive_UsesTheFirstRunCutsPathWithoutCopyingIt() + { + using var environment = ClearedEnvironment() + .Set("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", "C:\\stale-scenes"); + using var file = TemporaryJsonFile.Create(DevelopmentLiveBaseJson()); + var executableRoot = Path.Combine( + Path.GetTempPath(), + Guid.NewGuid().ToString("N"), + "app"); + var selectedCuts = Path.Combine( + Path.GetTempPath(), + Guid.NewGuid().ToString("N"), + "Cuts"); + Directory.CreateDirectory(executableRoot); + Directory.CreateDirectory(selectedCuts); + try + { + var options = PlayoutOptionsLoader.LoadDevelopmentLive( + file.Path, + executableRoot, + selectedCuts); + + Assert.Equal(Path.GetFullPath(selectedCuts), options.SceneDirectory); + Assert.Equal( + Path.GetFullPath(selectedCuts), + options.DevelopmentLiveExecutableSceneDirectory); + Assert.False(Directory.Exists(Path.Combine(executableRoot, "Cuts"))); + + var validated = ValidatedPlayoutOptions.Create(options); + Assert.Equal(Path.GetFullPath(selectedCuts), validated.SceneDirectory); + } + finally + { + Directory.Delete( + Path.GetDirectoryName(executableRoot)!, + recursive: true); + Directory.Delete( + Path.GetDirectoryName(selectedCuts)!, + recursive: true); + } + } + [Fact] public void LoadDevelopmentLive_RejectsBaseProfileThatCanReconnect() {