diff --git a/.gitignore b/.gitignore index 6841b6a..e487e8f 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ artifacts/K3DInterop/ K3D*.lic Tornado*.lic **/Cuts/ +**/Res/ **/배경/ # Web tooling (if introduced later) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..86def2f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ +# MBN_STOCK_WEBVIEW workspace instructions + +## Source and runtime assets + +- Treat `C:\Users\MD\source\repos\MBN_STOCK_N` as read-only. Never edit, clean, + reset, commit, or otherwise mutate that repository. +- The authoritative development runtime assets are under + `MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts` and `...\Res`. +- Keep cut files, database credentials, vendor DLLs, licences, certificates, and + machine-local configuration out of Git. Build-time links and ignored local + deployment copies are allowed. + +## Standing development Tornado2/PGM authorization + +Recorded at the user's request on 2026-07-18. This workstation, its database, +Tornado2, and PGM are a development environment with no broadcast impact. For +MBN_STOCK_WEBVIEW migration, parity testing, and debugging on this development +environment, do not ask the user for a new round, plan hash, or per-command +approval before operating Tornado2/PGM. + +The standing authorization includes launching and closing the development app, +CONNECT/DISCONNECT, PREPARE, TAKE IN, NEXT, Page NEXT, TAKE OUT, observing Network +Monitoring/PGM, and development-DB reads or writes needed by an in-scope migration +test. Prefer the existing Debug development-live profile and its ignored local +authorization file; do not weaken Release/default `DryRun` behavior. + +This is standing permission, not permission to guess after an ambiguous outcome: + +- Confirm the target is the local designated development Tornado2/PGM endpoint + before the first command of a run. A clearly different or production target is + outside this authorization. +- Keep scene/path allowlists, vendor hash checks, STA serialization, and target + identity checks enabled. +- On timeout, `OutcomeUnknown`, target identity change, or an unclear result, do + not repeat, invert, or issue speculative cleanup commands. Stop dispatching, + preserve evidence, diagnose read-only, and resume only from a known state. +- Do not force-terminate Tornado2/PGM or bypass K3D licence checks. +- Do not extend development database-write permission to a production database. + +These safety boundaries are technical stop conditions. They do not require a +fresh conversational approval when the state is known and remains inside the +development scope above. diff --git a/README.md b/README.md index db8a1ee..2765efd 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ 원본 이벤트를 코드 단위로 다시 검증하는 현재 작업 순서와 완료 범위는 [동작 동등성 작업 기록](docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md)을 기준으로 판단합니다. +2026-07-18의 실행 파일 옆 `Cuts/Res` 배치, 실제 DB, Release x64 MSIX와 UI-only 결과는 +[Cuts/Res 실행 구조 검증](docs/LEGACY_RUNTIME_ASSET_VALIDATION_20260718.md)에 기록했습니다. 현재 기준 기술 스택은 다음과 같습니다. @@ -24,7 +26,8 @@ - 드래그 정렬 및 위/아래 이동 - 선택 삭제 - 로컬 저장/불러오기 -- Oracle/MariaDB 4개 국내 시장 종목 검색과 CP949 `Res\종목.ini` 컷 선택(`%LOCALAPPDATA%` 외부 override → 패키지 후보 → 검증된 31개 내장 목록) +- Oracle/MariaDB 4개 국내 시장 종목 검색과 실행 파일 옆 `Res` INI 기반 컷/UI 구성 +- 빌드 시 읽기 전용 원본 `bin\Debug\Cuts`를 실행 파일 옆 `Cuts`로 같은 구조로 배치 - 기존 운영 흐름을 반영한 `PREPARE` / `TAKE IN` / `NEXT` / `TAKE OUT` UI - `F8`, `Esc` 단축키와 원본 배경 선택용 F2의 안전 예약 - 원본의 공급자 비의존 SQL/데이터 요청 71개를 .NET 8 Core 프로젝트로 이관 @@ -35,11 +38,17 @@ - 기본 `DryRun`, Test 전용 인스턴스·채널·씬 allowlist 및 Live 이중 승인 안전 게이트 - MSIX 패키지 매니페스트와 x64 게시 프로필 -Oracle/MariaDB 조회 계층과 Tornado/K3D 어댑터의 코드·계약·자동 검증은 구현했습니다. 이는 운영 Oracle 쓰기나 35개 장면 전체의 실제 PGM 동등성이 끝났다는 뜻이 아닙니다. 앱과 패키지의 기본 모드는 계속 `DryRun`이며 Live allowlist, 회차별 승인, 명령 예산, callback/`OutcomeUnknown` 게이트를 완화하지 않습니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정·실제 검증 증거·롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 원본 Scene/PageN 대조는 [송출 흐름 분석](docs/LEGACY_PLAYOUT_ANALYSIS.md), 장면별 현황은 [35개 Scene 동등성 매트릭스](docs/SCENE_EQUIVALENCE.md), 전체 412개 실행 action과 전용 편집 화면은 [운영자 UI 동등성 인벤토리](docs/OPERATOR_UI_PARITY.md)를 참고하세요. 재감사에서 확인한 미적용·적용 중·외부자산 필요 항목은 [원본 기능 재감사 기준표](docs/LEGACY_FEATURE_AUDIT.md)가 최종 상태 기준입니다. +Oracle/MariaDB 조회 계층과 Tornado/K3D 어댑터의 코드·계약·자동 검증은 구현했습니다. 이는 운영 Oracle 쓰기나 35개 장면 전체의 실제 PGM 동등성이 끝났다는 뜻이 아닙니다. 앱과 Release 패키지의 기본 모드는 계속 `DryRun`이며 Live allowlist, 명령 예산, callback/`OutcomeUnknown` 게이트를 완화하지 않습니다. 이 개발 PC의 Tornado2/PGM 마이그레이션 검증은 사용자가 2026-07-18에 반복 승인 없이 진행하도록 상시 허용했으며, 적용 범위와 중단 조건은 [개발 환경 상시 권한](docs/DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)에 기록했습니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정·실제 검증 증거·롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 원본 Scene/PageN 대조는 [송출 흐름 분석](docs/LEGACY_PLAYOUT_ANALYSIS.md), 장면별 현황은 [35개 Scene 동등성 매트릭스](docs/SCENE_EQUIVALENCE.md), 전체 412개 실행 action과 전용 편집 화면은 [운영자 UI 동등성 인벤토리](docs/OPERATOR_UI_PARITY.md)를 참고하세요. 재감사에서 확인한 미적용·적용 중·외부자산 필요 항목은 [원본 기능 재감사 기준표](docs/LEGACY_FEATURE_AUDIT.md)가 최종 상태 기준입니다. -35개 Scene/PageN 런타임과 원본 MainForm·UC1~UC7·GraphE·FSell·VIList·PList·AList·ThemeA·EList의 화면·bridge 계약은 WinUI 3/WebView2에 연결했습니다. 412개 고정 실행 action은 폐쇄형 매트릭스로 고정하며, 수동 재무·순매도·VI·테마·전문가·이름 있는 플레이리스트는 저장 후 재조회와 신뢰 검증이 끝나기 전에는 PREPARE할 수 없습니다. 실제 PList 2,213행은 한국어 7필드 무손상과 typed persistence가 모두 일치했고, KRX·해외종목·GraphE fresh proof를 합쳐 1,603행이 현재 selection으로 복원되며 610행은 명시적 사유로 차단됩니다. `종목비교.dat` 8쌍은 설치 패키지에서 1회 이전·재시작 영속성까지 확인했습니다. 이 수치는 scene data·asset·PREPARE·PGM 성공이 아니며, 운영 DB write와 승인된 `5001`/`5074` 밖의 실제 PGM 확인은 여전히 미검증입니다. +35개 Scene/PageN 런타임과 원본 MainForm·UC1~UC7·GraphE·FSell·VIList·PList·AList·ThemeA·EList의 화면·bridge 계약은 WinUI 3/WebView2에 연결했습니다. 412개 고정 실행 action은 폐쇄형 매트릭스로 고정하며, 수동 재무·순매도·VI·테마·전문가·이름 있는 플레이리스트는 저장 후 재조회와 신뢰 검증이 끝나기 전에는 PREPARE할 수 없습니다. 2026-07-18 실데이터 재검증에서 PList 2,213행은 한국어 7필드 무손상과 typed persistence가 모두 일치했고, 1,581행이 현재 selection으로 복원되며 632행은 명시적 사유로 차단됐습니다. 이 복원 수치는 DB 상태에 따라 달라질 수 있습니다. `종목비교.dat` 8쌍은 설치 패키지에서 1회 이전·재시작 영속성까지 확인했습니다. 이 수치는 scene data·asset·PREPARE·PGM 성공이 아니며, 운영 DB write와 승인된 `5001`/`5074` 밖의 실제 PGM 확인은 여전히 미검증입니다. -## 최종 검증 기준선 (2026-07-12) +## 과거 1.0.5 검증 기준선 (2026-07-12) + +아래 항목은 당시 `1.0.5` 패키지와 고정된 일회성 Gate 회차의 역사적 증거다. +현재 `Cuts/Res` 실행 폴더 배치를 포함해 다시 만든 패키지의 파일 수·해시나 권한으로 +재사용하지 않는다. 현행 자산 배치 규약은 [원본 Cuts/Res 런타임 배치](docs/LEGACY_RUNTIME_ASSETS.md), +개발 Tornado2/PGM 작업 권한은 [개발 환경 상시 권한](docs/DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)을 +따른다. - 현재 마이그레이션 소스에서 Core 1,383개, Infrastructure 173개, Playout 393개가 Debug/Release x64에서 각각 모두 통과했다. 구성별 1,949개, 두 구성 합계 3,898개이며 실패·skip은 0건이다. Web 구문 27개와 테스트 415/415, 원본 scene 기준선 35/35도 통과했다. - 원본 `Cuts` 기준 active alias 45개의 `.t2s`는 missing/unsafe 0건이고 종속 asset 23개는 unsafe 0건, missing 14건이다. 누락은 `s5006` 큐브 배경 1개와 `s6001` 국가별 영상 13개다. 실제 Oracle 21c/MariaDB read-only 스모크는 `삼성` 검색과 필수 loader 33/33, 전체 query 58건을 통과했지만 DB→DTO→mutation 검증 범위다. @@ -100,9 +109,9 @@ dotnet build .\src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyPar ## 구성과 보안 -원본 `RES/MmoneyCoder.ini`의 값은 저장소에 복사하지 않았습니다. [예제 설정](Config/appsettings.example.json)에는 빈 값만 있으며 실제 설정은 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\database.local.json` 또는 `MBN_STOCK_*` 환경 변수를 사용합니다. 로컬 파일 생성, 환경 변수, 보안과 스모크 테스트 방법은 [DB 운영 가이드](docs/DATABASE.md)에 정리했습니다. +원본 `Res/MmoneyCoder.ini`의 값은 저장소에 복사하지 않았습니다. 일반 Visual Studio 개발 빌드는 원본 INI를 Git 밖에서 실행 파일 옆 `Res`로 복사해 기존 경로를 재현하고, MSIX는 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` 또는 기존 `database.local.json`을 사용합니다. 환경 변수는 선택된 파일보다 우선합니다. 설정 우선순위, 보안과 실제 DB 스모크 방법은 [DB 운영 가이드](docs/DATABASE.md)에 정리했습니다. -MSIX 설치 폴더는 읽기 전용입니다. 로그, 플레이리스트, 운영자 설정과 미리보기 파일은 이후 `ApplicationData.Current.LocalFolder` 또는 `LocalCacheFolder`에 저장해야 합니다. Tornado 씬/영상처럼 큰 운영 자산은 별도의 설정 가능한 외부 폴더로 유지합니다. +MSIX 설치 폴더는 읽기 전용입니다. 빌드 시 읽기 전용 원본 `Cuts` 179개와 허용된 UI용 `Res` 파일은 실행 출력·게시·MSIX에 포함되지만, DB 자격증명 INI·백업·인증서·벤더 DLL은 포함하지 않습니다. 현재 원본에 없는 `s5006`/`s6001` 영상과 일부 배경은 별도 원본 제작 자산을 확보해야 합니다. 로그, 플레이리스트, 운영자 설정과 미리보기 파일은 `ApplicationData.Current.LocalFolder` 또는 `LocalCacheFolder`에 저장합니다. ## 프로젝트 구조 diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 2c2846a..da7cf8a 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -18,7 +18,18 @@ Oracle ODP.NET Core 23 계열은 TAP 기반 `OpenAsync`/명령 비동기를 지 ## 런타임 설정 -MSIX 설치 폴더는 읽기 전용이므로 설정 파일을 패키지 디렉터리에 두지 않습니다. 기본 경로는 다음과 같습니다. +원본 호환 앱은 다음 순서로 DB 설정을 선택합니다. + +1. 실행 파일 옆 `Res\MmoneyCoder.ini` — Visual Studio의 일반 개발 출력에서 원본과 같은 방식으로 사용 +2. `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` — MSIX용 로컬 overlay +3. 기존 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\database.local.json` — INI가 없을 때 fallback + +`MmoneyCoder.ini`의 `[Oracle]`/`[Maria]` 섹션에서 `ConnectionName=host:port/service`, +`ID`, `Pass`를 읽습니다. BOM 없는 UTF-8과 CP949를 지원하고 환경 변수는 선택된 파일 값보다 +우선합니다. 원본 `Res`의 복사본·`.bak`·`.zip`은 사용하지도 배포하지도 않습니다. + +MSIX 설치 폴더는 읽기 전용이며 평문 자격증명을 패키지에 포함하지 않습니다. 패키지에서는 +두 번째 또는 세 번째 경로를 사용합니다. JSON 기본 경로는 다음과 같습니다. ```text %LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\database.local.json @@ -34,7 +45,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\Initialize-DatabaseConfig.ps1 Oracle은 `sid` 또는 `serviceName` 중 정확히 하나를 설정합니다. 원본 MBN_STOCK_N은 SID descriptor를 사용했으므로 legacy 연결은 `sid`로 옮깁니다. 설정 파일은 평문 비밀번호를 포함할 수 있으므로 저장소·로그·첨부파일로 복사하지 않습니다. 운영 환경에서는 실행 계정의 파일 ACL을 유지하고, 환경 변수 오버라이드 또는 조직의 승인된 비밀 저장소를 사용할 수 있습니다. -JSON보다 환경 변수가 우선합니다. +선택된 INI 또는 JSON보다 환경 변수가 우선합니다. ```text MBN_STOCK_ORACLE_HOST / PORT / SID / SERVICE_NAME / USERNAME / PASSWORD @@ -70,6 +81,19 @@ dotnet test MBN_STOCK_WEBVIEW.sln -c Release -p:Platform=x64 dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj -c Release ``` +원본 실행 폴더의 `Res\MmoneyCoder.ini` 경로를 그대로 검증할 때는 다음처럼 실행합니다. + +```powershell +dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj ` + -c Release -- --legacy-runtime-root ` + "C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug" +``` + +이 모드의 NXT endpoint 감사는 지정한 INI의 host/port/database를 환경변수 적용 전 +비교 기준으로 사용합니다. 기본/`--config` 모드도 선택한 JSON의 환경변수 적용 전 +endpoint를 기준으로 사용하므로 환경변수가 연결 대상을 바꾼 경우를 감지할 수 +있습니다. 비교 출력은 SHA-256과 일치 여부뿐이며 계정과 비밀번호는 포함하지 않습니다. + 스모크는 양쪽 `SELECT 1`, Oracle 서버 버전, 코스피·코스닥·NXT·5개 국내 지수·해외 종목 카탈로그의 실제 행을 확인합니다. 출력에는 상태, 테이블 이름, 행 수만 포함합니다. ## MSIX diff --git a/docs/DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md b/docs/DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md new file mode 100644 index 0000000..4889b92 --- /dev/null +++ b/docs/DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md @@ -0,0 +1,30 @@ +# 개발 환경 Tornado2/PGM 상시 작업 권한 + +## 기록 + +2026-07-18 사용자는 이 장비의 MBN_STOCK_WEBVIEW, 데이터베이스, Tornado2와 +PGM이 개발 목적이며 방송에 영향을 주지 않는 환경임을 확인했다. 이 저장소의 마이그레이션, +동등성 확인 및 디버깅 범위에서는 매 회차 계획 해시나 CONNECT/PREPARE/TAKE 명령별 승인을 +다시 요청하지 않고 계속 작업한다. 미래 작업 에이전트가 같은 방침을 적용하도록 저장소 루트 +`AGENTS.md`에도 이 권한을 기록한다. + +상시 허용 범위는 개발 앱의 실행·정상 종료, CONNECT/DISCONNECT, PREPARE, TAKE IN, +NEXT, Page NEXT, TAKE OUT, Network Monitoring/PGM 확인, 그리고 해당 검증에 필요한 +개발 DB 읽기·쓰기다. Debug의 Git 제외 로컬 development-live 설정과 고정된 K3D DLL +해시를 사용한다. Release와 일반 설치본의 기본 `DryRun`은 그대로 유지한다. + +## 기술적 중단 조건 + +반복 승인을 생략하는 것과 결과를 추측하는 것은 별개다. 다음 조건에서는 새 명령 전송을 +멈추고 읽기 전용으로 상태와 증거를 확인한다. + +- 대상이 로컬의 지정 개발 Tornado2/PGM이 아니거나 운영 대상일 가능성이 있는 경우 +- timeout, `OutcomeUnknown`, callback 불일치 또는 명령 결과가 불명확한 경우 +- 대상 프로세스 세대/listener가 명령 도중 바뀐 경우 +- K3D 라이선스, vendor DLL 해시, scene/path allowlist 검사가 실패한 경우 +- 설정이 개발 DB가 아니라 운영 DB를 가리키는 경우 + +불명확한 명령은 같은 명령 재시도, 반대 명령 또는 추측성 정리 명령으로 덮지 않는다. +Tornado2/PGM 강제 종료, 라이선스 우회, 자격증명·라이선스·컷 파일의 Git 추가도 허용 범위가 +아니다. 상태가 known outcome으로 확인되고 같은 개발 범위 안에 있으면 별도의 대화형 승인을 +받지 않고 이어서 작업한다. diff --git a/docs/DEVELOPMENT_LIVE_VISUAL_STUDIO.md b/docs/DEVELOPMENT_LIVE_VISUAL_STUDIO.md index 6926b5f..69ea830 100644 --- a/docs/DEVELOPMENT_LIVE_VISUAL_STUDIO.md +++ b/docs/DEVELOPMENT_LIVE_VISUAL_STUDIO.md @@ -1,5 +1,11 @@ # Visual Studio 2026 개발 Tornado Live 실행 +> 2026-07-18: 현재 지정 개발 PC에서는 사용자가 Tornado2/PGM 마이그레이션 작업을 +> 상시 허용했다. 아래 Git 제외 로컬 승인 파일은 이 개발 환경의 지속 설정으로 사용하며, +> 매 실행이나 명령마다 새 계획 해시·대화형 승인을 요청하지 않는다. 운영/방송 대상에는 +> 이 예외를 적용하지 않으며 자세한 범위는 +> [개발 환경 상시 권한](DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)을 따른다. + ## 목적과 안전 기본 이 문서는 개발 PC의 Visual Studio 2026에서 `MBN_STOCK_WEBVIEW.LegacyParityApp`을 diff --git a/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md b/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md index a522a3a..8eb05ff 100644 --- a/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md +++ b/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md @@ -1,5 +1,9 @@ # MBN_STOCK_N 동작 동등성 작업 기록 +> 2026-07-18 이후 현재 개발 PC의 Tornado2/PGM 작업에는 +> [개발 환경 상시 권한](DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)을 적용한다. 아래의 +> 과거 회차별 승인 기록은 당시 증거이며 새 개발 작업의 반복 승인 요구로 재사용하지 않는다. + 기준 원본은 `C:\Users\MD\source\repos\MBN_STOCK_N`이며 항상 읽기 전용으로 분석한다. 대상은 `MBN_STOCK_WEBVIEW.LegacyParityApp`이다. 화면 모양만 비슷하게 만드는 것이 아니라 원본 이벤트의 입력, 상태 전이, DB 조회, K3D 호출 순서와 화면 피드백을 코드 단위로 대조한다. diff --git a/docs/LEGACY_RUNTIME_ASSETS.md b/docs/LEGACY_RUNTIME_ASSETS.md new file mode 100644 index 0000000..b99a8b4 --- /dev/null +++ b/docs/LEGACY_RUNTIME_ASSETS.md @@ -0,0 +1,66 @@ +# 원본 Cuts/Res 런타임 배치 + +## 기준 원본 + +원본 저장소는 읽기 전용이며 다음 개발 출력만 런타임 자산 기준으로 사용한다. + +```text +C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug +├─ Cuts +└─ Res +``` + +Visual Studio가 시작하는 프로젝트는 +`src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj`다. +이 프로젝트는 빌드할 때 원본 파일을 저장소 안으로 복제하지 않고 외부 Content로 연결해 +실행 파일 옆의 다음 구조를 만든다. + +```text +MBN_STOCK_WEBVIEW.LegacyParityApp.exe +Cuts\... +Res\... +Web\... +``` + +원본 경로가 다른 장비에서는 다음 빌드 속성으로 `bin\Debug` 위치를 지정한다. + +```text +/p:LegacyRuntimeSourceRoot="D:\path\to\MBN_STOCK_N\MBN_STOCK_N\bin\Debug" +``` + +필수 폴더와 `Cuts\5001.t2s`, 주요 UI INI가 없으면 빌드는 원인을 표시하고 중단한다. + +## Cuts + +`Cuts`의 179개 파일과 하위 구조를 출력·게시·MSIX에 그대로 배치한다. 앱의 playout 설정에 +명시적인 `sceneDirectory`가 없고 실행 파일 옆 `Cuts`가 있으면 그 경로를 자동으로 사용한다. +JSON 또는 환경 변수의 명시적 경로는 계속 우선한다. + +현재 원본의 `Cuts\Video`는 비어 있다. 따라서 다음 외부 영상은 이 복사만으로 생기지 않는다. + +- `s5006`의 `Video\큐브배경.vrv` +- `s6001`의 국가별 `Video\20201008_<국가>.vrv` 13개 +- 원본 코드가 참조하는 일부 기본/스튜디오 `.vrv` 배경 + +이 파일은 임의 대체물을 만들지 않고 원 제작 자산을 확보할 때까지 해당 장면의 자산 검사에서 +명시적으로 누락 처리한다. + +## Res와 DB 설정 + +UI 이미지와 UI용 INI는 allowlist로 출력·게시·MSIX에 배치한다. 앱은 실행 시 이 파일들을 +CP949 레거시 형식으로 읽고, 파일의 section/row/order를 기존의 폐쇄형 C# action catalog와 +대조한 뒤 UI를 구성한다. INI가 임의의 scene/action을 새로 추가할 수는 없다. + +`MmoneyCoder.ini`는 실제 자격증명을 포함하므로 일반 Visual Studio 개발 출력에만 +`Res\MmoneyCoder.ini`로 복사하고 MSIX Content에는 넣지 않는다. 일반 개발 빌드는 이 +원본 파일이 없으면 중단하지만, MSIX 생성·게시 빌드는 자격증명 파일 없이도 비민감 +자산만으로 수행할 수 있다. `Clean`은 실행 출력에 복사했던 INI를 제거한다. 패키지 실행은 +다음 로컬 overlay를 사용할 수 있다. + +```text +%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini +``` + +`MmoneyCoder - 복사본.ini`, `.bak`, `.zip`, `afiedt.buf.txt`는 런타임 소비자가 없고 과거 +자격증명 또는 SQL 흔적을 담을 수 있어 어떤 출력/패키지에도 넣지 않는다. 실제 자산과 설정은 +Git 추적 대상이 아니다. diff --git a/docs/LEGACY_RUNTIME_ASSET_VALIDATION_20260718.md b/docs/LEGACY_RUNTIME_ASSET_VALIDATION_20260718.md new file mode 100644 index 0000000..3878251 --- /dev/null +++ b/docs/LEGACY_RUNTIME_ASSET_VALIDATION_20260718.md @@ -0,0 +1,75 @@ +# Cuts/Res 실행 구조 검증 (2026-07-18) + +## 적용 결과 + +읽기 전용 원본은 다음 개발 출력이다. + +```text +C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug +``` + +`LegacyParityApp`은 이 위치의 `Cuts`와 허용된 `Res`를 빌드 입력으로 연결한다. 일반 +Visual Studio Debug 출력에는 실제 DB용 `Res\MmoneyCoder.ini`도 실행 파일 옆에 복사하되, +Git·게시·MSIX에는 넣지 않는다. 원본 저장소의 기존 수정 파일은 변경하지 않았다. + +## 자산 무결성 + +| 대상 | 결과 | +|---|---:| +| 원본 `Cuts` | 179개 / 222,825,612 bytes | +| Debug 실행 출력 `Cuts` | 179개, 원본과 파일별 경로·크기·SHA-256 일치 | +| Release MSIX `Cuts` | 179개 / 222,825,612 bytes, 원본과 파일별 경로·크기·SHA-256 일치 | +| Release MSIX `Res` | 허용 목록 34개 / 2,920,725 bytes, 원본과 파일별 크기·SHA-256 일치 | +| Debug 실행 출력 `Res` | UI용 34개 + DB INI 1개 | +| 필수 장면 | `Cuts\5001.t2s` 포함 | + +Release x64 개발 MSIX 결과는 다음과 같다. + +| 항목 | 값 | +|---|---| +| 패키지 | `MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64.msix` | +| 크기 | 61,239,787 bytes | +| SHA-256 | `CDCC7C2FB9DA66A631473025C187B427FF84E073EB6F174D819DE327E58BAF7B` | +| 전체 파일 | 291개 | +| 빌드 | clean Release x64, 경고 0 / 오류 0 | + +패키지, `build.appxrecipe`, `package.map.txt`, layout resfiles와 Release 실행 +레이아웃에서 `MmoneyCoder.ini`, 복사본, `.bak`, 압축본, `afiedt`, 인증서·개인키·라이선스 +파일과 K3D 벤더 파일은 모두 0건이다. 설치된 SDK 10.0.19041의 `MakeAppx`에는 +`validate` 명령이 없어 해당 부가 명령은 사용할 수 없었고, 패키지 생성·BlockMap 및 +파일별 payload 감사와는 별개다. + +추가 감사에서 일반 출력의 DB INI가 `Clean` 뒤 남을 가능성과 패키지 빌드가 불필요하게 +원본 DB INI를 요구하는 문제를 발견해 보완했다. 현재 일반 빌드 산출물은 `Clean`이 DB +INI를 제거하고, MSIX 생성·게시 경로는 원본 DB INI가 없어도 검증 단계를 통과한다. + +## 런타임 검증 + +- 실제 원본 UI INI를 사용한 조합 검사: 경고 0 +- 해외/환율/지수 항목: 78/46/204개와 section·row 순서 일치 +- 업종 코스피/코스닥: 각각 22개와 순서 일치 +- 종목비교: 고정 대상 24개 + action 9개 일치 +- 실제 원본 DB INI 파싱과 Oracle/MariaDB read-only smoke 통과 +- 필수 scene selector 33/33 및 전체 query 58개 통과 +- Debug x64 .NET 테스트: 2,760/2,760 통과 +- Release x64 .NET 테스트: 2,760/2,760 통과 +- Visual Studio 18.7.4(Visual Studio 2026) MSBuild로 LegacyParityApp Debug/Release x64 빌드 통과 +- Legacy Web shell 테스트: 34/34 통과 +- Web playout 테스트: 422/422 통과, scene catalog 35개와 cut coverage fixture 통과 +- Release package-context UI-only smoke: `PASS`, `DRY RUN`, input/DB intent/native playout + intent 0, Tornado 연결 0, 정상 종료 후 잔류 앱 프로세스 0 +- Release package-context 실제 입력 smoke: `PASS`; `삼성` 검색에서 23개 결과를 표시하고 + `삼성출판사`의 1열판 기본 3개 컷을 플레이리스트에 구성했다. 컷/플레이리스트 키보드, + 단일·다중 드래그, 실제 Windows row-header drag와 이름 있는 플레이리스트 대화상자를 + 포함한 12개 checkpoint가 통과했다. DB write와 playout intent는 0건이었다. + +UI-only와 실제 입력 증거는 Git 제외 경로 `artifacts\package-ui-only-smoke` 및 +`artifacts\package-input-smoke`에 JSON과 화면 캡처로 남긴다. 이 검증은 Release 기본 +`DryRun`을 확인한 것이며 실제 Tornado2/PGM 명령을 보내지 않았다. + +## 남아 있는 원본 자산 전제조건 + +현재 원본 `Cuts\Video`가 비어 있어 `s5006`의 큐브 배경 1개와 `s6001` 국가별 영상 +13개는 이 배치만으로 제공되지 않는다. 일부 기본/스튜디오 `.vrv` 배경도 원본 제작 +자산이 필요하다. 임의 파일로 대체하지 않으며 제공 전까지 해당 장면의 자산 preflight가 +명시적으로 차단해야 한다. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 75b127b..a47d314 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -10,7 +10,7 @@ - Oracle 및 MariaDB SQL/데이터 요청 계층 - FarPoint Spread 4 플레이리스트/그리드 -원본 작업 트리의 `MainForm.cs`와 `RES/MmoneyCoder.ini`에는 커밋되지 않은 변경이 있습니다. 원본 저장소는 그대로 보존했으며, 새 프로젝트에는 비밀값이 포함된 INI를 복사하지 않았습니다. +원본 작업 트리의 `MainForm.cs`와 `RES/MmoneyCoder.ini`에는 커밋되지 않은 변경이 있습니다. 원본 저장소는 그대로 보존합니다. 실제 DB INI는 Git이나 MSIX에 넣지 않으며, 일반 개발 빌드에서만 읽기 전용 원본 `bin\Debug\Res`에서 실행 파일 옆 `Res`로 복사해 기존 상대 경로를 재현합니다. ## 화면/계약 적용 현황 @@ -18,6 +18,7 @@ |---|---| | WinForms MainForm | WinUI 3 `MainWindow` + 로컬 WebView2 UI | | FarPoint `FpSpread` 플레이리스트 | HTML table + 선택/드래그 정렬/이동/삭제 | +| 실행 폴더 `Cuts`/`Res` | 읽기 전용 원본 `bin\Debug`를 빌드 입력으로 연결해 실행 출력과 MSIX에 비민감 자산 배치 | | 플레이리스트 임시 저장 | WebView2 사용자 데이터 영역의 `localStorage` | | Oracle `DC_LIST`/`PLAY_LIST` | 이름 있는 플레이리스트 생성·교체·불러오기·삭제와 fresh page preflight | | WinForms 단축키 | Web UI `F2` / `F8` / `Esc` 처리 | diff --git a/docs/NXT_THEME_RESTORE_READ_ONLY_AUDIT.md b/docs/NXT_THEME_RESTORE_READ_ONLY_AUDIT.md index 96ae818..4775800 100644 --- a/docs/NXT_THEME_RESTORE_READ_ONLY_AUDIT.md +++ b/docs/NXT_THEME_RESTORE_READ_ONLY_AUDIT.md @@ -38,10 +38,16 @@ SUBSTRING(SB_ITEM.SB_I_CODE, 2, 12) = v_all_stock.F_STOCK_CODE 새 `LegacyThemeSelectionService`와 `LegacyNamedNxtThemeRestoreService`도 같은 prefix 제거 규칙, 같은 -`F_STOP_GUBUN = 'N'` 조건을 사용한다. 원본 설정과 현재 runtime 설정의 -host/port/database를 비밀값 없이 정규화해 비교한 결과 SHA-256과 값이 -동일했고, 실제 `DATABASE()`도 현재 runtime database와 일치했다. 서버는 -MariaDB 10.5 계열이다. +`F_STOP_GUBUN = 'N'` 조건을 사용한다. 이 문서의 검증 당시에는 선택한 원본 +설정과 현재 runtime 설정의 host/port/database를 비밀값 없이 정규화해 비교한 +결과 SHA-256과 값이 동일했고, 실제 `DATABASE()`도 현재 runtime database와 +일치했다. 서버는 MariaDB 10.5 계열이다. + +현재 스모크 도구는 머신 고정 경로를 다시 읽지 않는다. 기본/`--config` 실행은 +선택한 JSON을 환경변수 적용 전에 읽은 endpoint를, `--legacy-runtime-root` 실행은 +그 루트의 `Res\MmoneyCoder.ini`를 환경변수 적용 전에 읽은 endpoint를 비교 +기준으로 명시 전달한다. 실제 연결에 적용된 endpoint와의 비교 결과만 SHA-256으로 +출력하며 계정과 비밀번호는 비교 모델이나 출력에 포함하지 않는다. 따라서 다른 DB를 바라보는 마이그레이션 오류가 아니며, 현 데이터 상태에서는 원본 프로그램도 같은 `v_all_stock` 경로로 NXT 테마 구성종목을 얻지 못한다. diff --git a/docs/PLAYOUT.md b/docs/PLAYOUT.md index 4481cc7..183905c 100644 --- a/docs/PLAYOUT.md +++ b/docs/PLAYOUT.md @@ -1,10 +1,14 @@ # Tornado/K3D x64 송출 운영 가이드 +> 2026-07-18 개발 환경 변경: 현재 지정 개발 PC의 Tornado2/PGM 마이그레이션 검증은 +> [개발 환경 상시 권한](DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)에 따라 회차별 대화형 +> 승인을 다시 받지 않는다. 운영/방송 대상과 `OutcomeUnknown` 중단 규칙은 그대로다. + ## 안전 기준 기본 모드는 `DryRun`입니다. `DryRun`은 COM 객체를 만들거나 Tornado 출력에 명령을 보내지 않고 WebView 메시지, 큐, 상태 및 오류 표시 흐름만 검증합니다. 현재 방송 PROGRAM(PGM)에 연결된 Tornado 프로세스는 항상 **안전하지 않은 대상**으로 취급합니다. 프로세스 이름이나 실행 여부만으로 테스트 대상이라고 판단하지 않습니다. -이 저장소의 작업과 자동 검증은 실제 PGM 출력 또는 라이브 `TAKE IN`을 허가하지 않습니다. `Test` 검증은 PGM과 분리된 전용 테스트 인스턴스, 테스트 출력 채널 및 허용 목록에 든 테스트 씬을 모두 확인한 뒤에만 수행합니다. `Live`는 운영 책임자의 명시적인 회차별 허가 없이는 사용하지 않습니다. +운영/방송 대상의 작업과 자동 검증은 실제 PGM 출력 또는 라이브 `TAKE IN`을 자동 허가하지 않습니다. `Test` 검증은 PGM과 분리된 전용 테스트 인스턴스, 테스트 출력 채널 및 허용 목록에 든 테스트 씬을 모두 확인한 뒤에만 수행합니다. 단, 위에 링크한 현재 개발 PC의 상시 권한 범위에서는 별도의 회차별 허가를 다시 요청하지 않습니다. 이 문서의 adapter·builder·DryRun 완료 표현은 화면/계약 구현 범위다. 운영 DB-W, 외부 asset readiness, 기존 데이터 복원과 장면별 실제 PGM은 별도 증거가 있어야 한다. 현재 미적용·적용 중·외부자산 필요 항목은 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)를 따른다. @@ -85,14 +89,14 @@ SDK가 기본 위치에 없다면 x64 SDK의 `TlbImp.exe` 절대 경로를 `-Tlb %LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\playout.local.json ``` -[playout.example.json](../Config/playout.example.json)을 구조 참고용으로 사용합니다. 예시는 `DryRun`, 외부 씬 루트와 출력 채널 미지정, 빈 씬 허용 목록, 라이브 신뢰 플래그 해제 상태이므로 실제 출력에 사용할 수 없습니다. 테스트 장비의 호스트, 채널, 창 제목 패턴, 외부 씬 루트와 허용할 씬 이름은 로컬 파일에만 기록하고 Git, 로그 또는 지원 첨부파일에 넣지 않습니다. +[playout.example.json](../Config/playout.example.json)을 구조 참고용으로 사용합니다. 예시는 `DryRun`, 출력 채널 미지정, 빈 씬 허용 목록, 라이브 신뢰 플래그 해제 상태이므로 실제 출력에 사용할 수 없습니다. `sceneDirectory`가 비어 있고 실행 파일 옆에 빌드가 배치한 `Cuts`가 있으면 그 폴더를 기본으로 사용합니다. 별도 씬 루트를 명시할 때는 테스트 장비의 호스트, 채널, 창 제목 패턴과 허용할 씬 이름을 로컬 파일에만 기록하고 Git, 로그 또는 지원 첨부파일에 넣지 않습니다. | 속성 | 의미 | |---|---| | `mode` | `Disabled`, `DryRun`, `Test`, `Live` 중 하나 | | `host`, `port` | 테스트 또는 운영 승인을 받은 KTAP endpoint | | `tcpMode`, `clientPort` | `KTAPConnect`의 전송 모드와 로컬 client port 인자. Test/Live는 유실 방지를 위해 `tcpMode: 1`만 허용 | -| `sceneDirectory` | Test/Live에서 사용하는 외부 `.t2s` 루트의 절대 경로. `null`은 안전한 미설정 상태 | +| `sceneDirectory` | Test/Live에서 사용하는 `.t2s` 루트의 절대 경로. 명시값이 우선하며, `null`이면 실행 파일 옆 `Cuts`가 존재할 때 그 경로를 사용하고 없으면 미설정 상태 | | `outputChannel` | 확인된 전용 출력 채널. `null`은 안전한 미설정 상태 | | `layoutIndex` | 씬 플레이어 layout 위치 | | `legacySceneFadeDuration` | 원본 `ComboDi.SelectedIndex`에 대응하는 fade. 기본 인덱스 6(화면 7), 허용 범위 0~19(화면 표시 1~20) | diff --git a/docs/PLAYOUT_OPERATIONS.md b/docs/PLAYOUT_OPERATIONS.md index 0e57946..a784df4 100644 --- a/docs/PLAYOUT_OPERATIONS.md +++ b/docs/PLAYOUT_OPERATIONS.md @@ -1,5 +1,10 @@ # MBN_STOCK_WEBVIEW 송출 운영·검증 절차 +> 2026-07-18 개발 환경 변경: 이 문서의 회차별 승인 절차는 운영/방송 대상에 계속 +> 적용한다. 현재 지정된 개발 PC의 Tornado2/PGM 마이그레이션 검증은 사용자가 상시 +> 허용했으므로 반복 승인을 요청하지 않는다. 범위와 기술적 중단 조건은 +> [개발 환경 상시 권한](DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)을 우선 적용한다. + 이 문서는 마이그레이션된 WebView → `IPlayoutEngine` → K3D/Tornado2 경로를 검증할 때의 승인, 실행, 감시, 장애 복구와 rollback 기준이다. 기존 [`PLAYOUT.md`](PLAYOUT.md)의 K3D 설치·해시 핀·격리 진단 규칙을 대체하지 않고, 35개 scene과 PageN 동등성 검증에 필요한 운영 절차를 추가한다. 화면/계약 구현과 운영 동등성의 최신 재감사 상태는 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)를 따른다. ## 현재 상태 diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/Configuration/DatabaseOptionsLoader.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/Configuration/DatabaseOptionsLoader.cs index c04c42f..5444969 100644 --- a/src/MBN_STOCK_WEBVIEW.Infrastructure/Configuration/DatabaseOptionsLoader.cs +++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/Configuration/DatabaseOptionsLoader.cs @@ -44,10 +44,7 @@ public sealed class DatabaseOptionsLoader { var path = jsonPath ?? DefaultConfigurationPath; var options = LoadJson(path); - ApplyEnvironmentOverrides(options); - options.Normalize(); - options.ValidateRuntimeSettings(); - return options; + return ApplyEnvironmentOverridesAndValidate(options); } public async Task LoadAsync( @@ -56,6 +53,12 @@ public sealed class DatabaseOptionsLoader { var path = jsonPath ?? DefaultConfigurationPath; var options = await LoadJsonAsync(path, cancellationToken).ConfigureAwait(false); + return ApplyEnvironmentOverridesAndValidate(options); + } + + internal DatabaseOptions ApplyEnvironmentOverridesAndValidate(DatabaseOptions options) + { + ArgumentNullException.ThrowIfNull(options); ApplyEnvironmentOverrides(options); options.Normalize(); options.ValidateRuntimeSettings(); diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/Configuration/LegacyIniDatabaseOptionsLoader.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/Configuration/LegacyIniDatabaseOptionsLoader.cs new file mode 100644 index 0000000..0f8fcf6 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/Configuration/LegacyIniDatabaseOptionsLoader.cs @@ -0,0 +1,287 @@ +using System.Security; +using System.Text; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Infrastructure; + +/// +/// Reads the legacy executable-relative Res\MmoneyCoder.ini contract without +/// exposing any of its values through diagnostics or exception messages. +/// +public sealed class LegacyIniDatabaseOptionsLoader +{ + public const int MaximumFileBytes = 64 * 1024; + + private static readonly UTF8Encoding StrictUtf8 = + new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + private static readonly Encoding StrictCp949 = CreateCp949Encoding(); + + private readonly DatabaseOptionsLoader _optionsLoader; + + public LegacyIniDatabaseOptionsLoader( + Func? readEnvironmentVariable = null) + { + _optionsLoader = new DatabaseOptionsLoader(readEnvironmentVariable); + } + + public DatabaseOptions Load(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + byte[] bytes; + try + { + var fullPath = Path.GetFullPath(path); + var attributes = File.GetAttributes(fullPath); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) + { + throw new InvalidDataException(); + } + + using var stream = new FileStream( + fullPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4096, + FileOptions.SequentialScan); + if (stream.Length is <= 0 or > MaximumFileBytes) + { + throw new InvalidDataException(); + } + + bytes = new byte[checked((int)stream.Length)]; + stream.ReadExactly(bytes); + } + catch (Exception exception) when (IsExpectedFileFailure(exception)) + { + throw InvalidConfiguration(); + } + + var text = Decode(bytes); + var values = Parse(text); + var options = CreateOptions(values); + options = _optionsLoader.ApplyEnvironmentOverridesAndValidate(options); + if (!options.IsConfigured(DataSourceKind.Oracle) || + !options.IsConfigured(DataSourceKind.MariaDb)) + { + throw InvalidConfiguration(); + } + + return options; + } + + private static string Decode(byte[] bytes) + { + string text; + try + { + text = StrictUtf8.GetString(bytes); + } + catch (DecoderFallbackException) + { + try + { + text = StrictCp949.GetString(bytes); + } + catch (DecoderFallbackException) + { + throw InvalidConfiguration(); + } + } + + return text.Length > 0 && text[0] == '\uFEFF' + ? text[1..] + : text; + } + + private static IReadOnlyDictionary> Parse( + string text) + { + var sections = new Dictionary>( + StringComparer.Ordinal); + Dictionary? current = null; + + using var reader = new StringReader(text); + while (reader.ReadLine() is { } sourceLine) + { + var line = sourceLine.Trim(); + if (line.Length == 0) + { + continue; + } + + if (line.StartsWith("[", StringComparison.Ordinal)) + { + if (line.Length < 3 || line[^1] != ']') + { + throw InvalidConfiguration(); + } + + var sectionName = line[1..^1]; + if (!sections.TryGetValue(sectionName, out current)) + { + current = new Dictionary(StringComparer.Ordinal); + sections.Add(sectionName, current); + } + + continue; + } + + if (line.StartsWith("//", StringComparison.Ordinal)) + { + continue; + } + + if (current is null) + { + throw InvalidConfiguration(); + } + + var separatorIndex = line.IndexOf('='); + if (separatorIndex <= 0) + { + continue; + } + + var key = line[..separatorIndex].Trim(); + if (key is not ("ConnectionName" or "ID" or "Pass")) + { + continue; + } + + var value = line[(separatorIndex + 1)..].Trim(); + if (value.Length == 0 || value.Any(char.IsControl)) + { + throw InvalidConfiguration(); + } + + // The original reader keeps the last occurrence in file order. + current[key] = value; + } + + return sections.ToDictionary( + static pair => pair.Key, + static pair => (IReadOnlyDictionary)pair.Value, + StringComparer.Ordinal); + } + + private static DatabaseOptions CreateOptions( + IReadOnlyDictionary> sections) + { + if (!sections.TryGetValue("Oracle", out var oracle) || + !sections.TryGetValue("Maria", out var maria) || + !TryGetRequired(oracle, "ConnectionName", out var oracleConnection) || + !TryGetRequired(oracle, "ID", out var oracleId) || + !TryGetRequired(oracle, "Pass", out var oraclePassword) || + !TryGetRequired(maria, "ConnectionName", out var mariaConnection) || + !TryGetRequired(maria, "ID", out var mariaId) || + !TryGetRequired(maria, "Pass", out var mariaPassword) || + !TryParseConnectionName( + oracleConnection, + out var oracleHost, + out var oraclePort, + out var oracleSid) || + !TryParseConnectionName( + mariaConnection, + out var mariaHost, + out var mariaPort, + out var mariaDatabase)) + { + throw InvalidConfiguration(); + } + + return new DatabaseOptions + { + Oracle = new OracleDatabaseOptions + { + Host = oracleHost, + Port = oraclePort, + Sid = oracleSid, + ServiceName = null, + UserName = oracleId, + Password = oraclePassword + }, + MariaDb = new MariaDbDatabaseOptions + { + Host = mariaHost, + Port = mariaPort, + Database = mariaDatabase, + UserName = mariaId, + Password = mariaPassword, + // The legacy MySQL connection string did not request TLS. + TlsMode = MariaDbTlsMode.Disabled + } + }; + } + + private static bool TryGetRequired( + IReadOnlyDictionary section, + string key, + out string value) + { + if (section.TryGetValue(key, out var candidate) && + !string.IsNullOrWhiteSpace(candidate)) + { + value = candidate; + return true; + } + + value = string.Empty; + return false; + } + + private static bool TryParseConnectionName( + string value, + out string host, + out int port, + out string database) + { + host = string.Empty; + port = 0; + database = string.Empty; + + var colonIndex = value.IndexOf(':'); + var slashIndex = value.IndexOf('/', colonIndex + 1); + if (colonIndex <= 0 || slashIndex <= colonIndex + 1 || + slashIndex == value.Length - 1 || + value.IndexOf(':', colonIndex + 1) >= 0 || + value.IndexOf('/', slashIndex + 1) >= 0) + { + return false; + } + + var parsedHost = value[..colonIndex].Trim(); + var portText = value[(colonIndex + 1)..slashIndex].Trim(); + var parsedDatabase = value[(slashIndex + 1)..].Trim(); + if (parsedHost.Length == 0 || parsedDatabase.Length == 0 || + parsedHost.Any(character => char.IsWhiteSpace(character) || char.IsControl(character)) || + parsedDatabase.Any(character => char.IsWhiteSpace(character) || char.IsControl(character)) || + !int.TryParse(portText, out var parsedPort) || + parsedPort is < 1 or > 65_535) + { + return false; + } + + host = parsedHost; + port = parsedPort; + database = parsedDatabase; + return true; + } + + private static Encoding CreateCp949Encoding() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + return Encoding.GetEncoding( + 949, + EncoderFallback.ExceptionFallback, + DecoderFallback.ExceptionFallback); + } + + private static bool IsExpectedFileFailure(Exception exception) => + exception is IOException or UnauthorizedAccessException or SecurityException or + ArgumentException or NotSupportedException; + + private static DatabaseConfigurationException InvalidConfiguration() => + new("실행 폴더의 레거시 데이터베이스 설정을 읽을 수 없습니다."); +} diff --git a/src/MBN_STOCK_WEBVIEW.Infrastructure/DatabaseRuntime.cs b/src/MBN_STOCK_WEBVIEW.Infrastructure/DatabaseRuntime.cs index 6be63aa..9cfc64f 100644 --- a/src/MBN_STOCK_WEBVIEW.Infrastructure/DatabaseRuntime.cs +++ b/src/MBN_STOCK_WEBVIEW.Infrastructure/DatabaseRuntime.cs @@ -30,6 +30,43 @@ public sealed class DatabaseRuntime return Create(options); } + public static DatabaseRuntime CreateLegacyExecutableDefault( + string baseDirectory, + string? fallbackConfigurationPath = null, + string? legacyOverridePath = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(baseDirectory); + var legacyIniPath = Path.Combine( + Path.GetFullPath(baseDirectory), + "Res", + "MmoneyCoder.ini"); + if (File.Exists(legacyIniPath)) + { + return Create(new LegacyIniDatabaseOptionsLoader().Load(legacyIniPath)); + } + + var localOverride = legacyOverridePath ?? ResolveLegacyOverridePath(); + if (!string.IsNullOrWhiteSpace(localOverride) && File.Exists(localOverride)) + { + return Create(new LegacyIniDatabaseOptionsLoader().Load(localOverride)); + } + + return CreateDefault(fallbackConfigurationPath); + } + + private static string? ResolveLegacyOverridePath() + { + var localAppData = Environment.GetFolderPath( + Environment.SpecialFolder.LocalApplicationData); + return string.IsNullOrWhiteSpace(localAppData) + ? null + : Path.Combine( + localAppData, + "MBN_STOCK_WEBVIEW", + "Res", + "MmoneyCoder.ini"); + } + public static DatabaseRuntime Create(DatabaseOptions options) { ArgumentNullException.ThrowIfNull(options); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowCatalog.cs index 4505940..ac97699 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowCatalog.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowCatalog.cs @@ -111,6 +111,8 @@ internal static class LegacyComparisonWorkflowCatalog public static IReadOnlyList FixedTargets => FixedTargetList; + internal static IReadOnlyList Actions => ActionList; + public static LegacyComparisonTarget? GetFixedTarget(string targetId) => targetId is not null && FixedById.TryGetValue(targetId, out var target) ? target : null; @@ -195,13 +197,31 @@ internal static class LegacyComparisonWorkflowCatalog LegacyComparisonTarget? first, LegacyComparisonTarget? second) { - var views = ActionList.Select(action => + var placements = ActionList + .Select(action => new LegacyComparisonActionPlacement(action.ActionId, action.Section)) + .ToArray(); + return CreateActionViews(first, second, placements); + } + + internal static IReadOnlyList CreateActionViews( + LegacyComparisonTarget? first, + LegacyComparisonTarget? second, + IReadOnlyList placements) + { + ArgumentNullException.ThrowIfNull(placements); + var views = placements.Select(placement => { + if (!ActionsById.TryGetValue(placement.ActionId, out var action)) + { + throw new InvalidOperationException( + "The runtime comparison layout contains an unknown action."); + } + var compatibility = GetCompatibility(action, first, second); return new LegacyComparisonActionView( action.ActionId, action.Label, - action.Section, + placement.Section, compatibility.IsAvailable, compatibility.Reason); }).ToArray(); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs index a5210f9..f16b487 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs @@ -11,6 +11,7 @@ public sealed class LegacyComparisonWorkflowController { private readonly ILegacyComparisonDataService _dataService; private readonly ILegacyComparisonPairStore _pairStore; + private readonly LegacyComparisonUiLayout _uiLayout; private List _domesticResults = []; private List _worldResults = []; private List _savedPairs = []; @@ -38,10 +39,12 @@ public sealed class LegacyComparisonWorkflowController public LegacyComparisonWorkflowController( ILegacyComparisonDataService dataService, - ILegacyComparisonPairStore pairStore) + ILegacyComparisonPairStore pairStore, + LegacyComparisonUiLayout? uiLayout = null) { _dataService = dataService ?? throw new ArgumentNullException(nameof(dataService)); _pairStore = pairStore ?? throw new ArgumentNullException(nameof(pairStore)); + _uiLayout = uiLayout ?? LegacyComparisonUiLayout.Default; } public LegacyComparisonWorkflowSnapshot Current => CreateSnapshot(); @@ -606,7 +609,7 @@ public sealed class LegacyComparisonWorkflowController result.Target, result.SelectionId)) .ToArray(); - var fixedTargets = LegacyComparisonWorkflowCatalog.FixedTargets + var fixedTargets = _uiLayout.FixedTargets .Select(target => LegacyComparisonWorkflowCatalog.ToView(target, target.TargetId)) .ToArray(); var savedPairs = _savedPairs.Select((pair, index) => @@ -645,7 +648,7 @@ public sealed class LegacyComparisonWorkflowController : LegacyComparisonWorkflowCatalog.ToView(_secondTarget, "comparison-draft-second"), Array.AsReadOnly(savedPairs), _selectedPairId, - LegacyComparisonWorkflowCatalog.CreateActionViews( + _uiLayout.CreateActionViews( selectedPair?.First, selectedPair?.Second), _statusMessage, diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyFixedActionCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyFixedActionCatalog.cs index 014dd03..4b38124 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyFixedActionCatalog.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyFixedActionCatalog.cs @@ -259,6 +259,35 @@ public sealed class LegacyFixedActionCatalog return RuntimeAssets[market]; } + internal LegacyFixedActionCatalog WithRuntimeOrder( + IReadOnlyDictionary> actionsByMarket) + { + ArgumentNullException.ThrowIfNull(actionsByMarket); + var ordered = new List(Actions.Count); + foreach (var market in Enum.GetValues()) + { + var source = actionsByMarket.TryGetValue(market, out var runtimeActions) + ? runtimeActions + : _byMarket[market]; + if (source is null || source.Count != _byMarket[market].Count || + source.Any(action => action is null || action.Market != market) || + !source.Select(action => action.Id).ToHashSet(StringComparer.Ordinal) + .SetEquals(_byMarket[market].Select(action => action.Id))) + { + throw new InvalidDataException( + $"The runtime fixed-action order for '{MarketText(market)}' is invalid."); + } + + ordered.AddRange(source); + } + + return new LegacyFixedActionCatalog( + ReadOnly(ordered), + RuntimeAssets, + DocumentSha256, + GeneratorSourceSha256); + } + public LegacyMaterializedFixedAction Materialize( string actionId, DateTimeOffset localNow) diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs index 4cbbe61..86befff 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs @@ -70,6 +70,7 @@ public sealed class LegacyIndustrySelectionWorkflow private readonly IIndustrySelectionService _selectionService; private readonly TimeProvider _timeProvider; + private readonly LegacyIndustryActionLayout _actionLayout; private IReadOnlyList _industries = NoIndustries; private IReadOnlyList _actions = NoActions; private IndustryMarket? _market; @@ -84,11 +85,13 @@ public sealed class LegacyIndustrySelectionWorkflow public LegacyIndustrySelectionWorkflow( IIndustrySelectionService selectionService, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + LegacyIndustryActionLayout? actionLayout = null) { _selectionService = selectionService ?? throw new ArgumentNullException(nameof(selectionService)); _timeProvider = timeProvider ?? TimeProvider.System; + _actionLayout = actionLayout ?? LegacyIndustryActionLayout.Default; } public LegacyIndustrySelectionSnapshot Current => CreateSnapshot(); @@ -104,7 +107,7 @@ public sealed class LegacyIndustrySelectionWorkflow Interlocked.Increment(ref _loadGeneration); _market = market; _industries = NoIndustries; - _actions = LegacyIndustryActionCatalog.GetActions(market); + _actions = _actionLayout.GetActions(market); _selectedIndex = null; _selectedIndustry = null; _firstIndustry = null; @@ -146,7 +149,7 @@ public sealed class LegacyIndustrySelectionWorkflow _market = market; _industries = normalizedRows; - _actions = LegacyIndustryActionCatalog.GetActions(market); + _actions = _actionLayout.GetActions(market); _selectedIndustry = Reconcile(previousSelected, normalizedRows); _selectedIndex = _selectedIndustry is null ? null diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyRuntimeUiCatalogLoader.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyRuntimeUiCatalogLoader.cs new file mode 100644 index 0000000..58984d3 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyRuntimeUiCatalogLoader.cs @@ -0,0 +1,745 @@ +using System.Collections.ObjectModel; +using System.Text; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +public enum LegacyRuntimeUiCatalogFailure +{ + None, + InvalidBaseDirectory, + FileNotFound, + FileUnavailable, + FileTooLarge, + InvalidEncoding, + InvalidFormat, + TooManySections, + TooManyRows, + UnknownEntry, + DuplicateEntry, + MissingEntry +} + +public sealed record LegacyRuntimeUiCatalogWarning( + string FileName, + LegacyRuntimeUiCatalogFailure Failure); + +public sealed class LegacyIndustryActionLayout +{ + private readonly IReadOnlyDictionary> _actions; + + private LegacyIndustryActionLayout( + IReadOnlyDictionary> actions) + { + _actions = actions; + } + + public static LegacyIndustryActionLayout Default { get; } = Create( + LegacyIndustryActionCatalog.GetActions(IndustryMarket.Kospi), + LegacyIndustryActionCatalog.GetActions(IndustryMarket.Kosdaq)); + + public IReadOnlyList GetActions(IndustryMarket market) => + _actions.TryGetValue(market, out var actions) + ? actions + : throw new ArgumentOutOfRangeException( + nameof(market), market, "Unsupported industry market."); + + internal static LegacyIndustryActionLayout Create( + IReadOnlyList kospi, + IReadOnlyList kosdaq) + { + Validate(IndustryMarket.Kospi, kospi); + Validate(IndustryMarket.Kosdaq, kosdaq); + return new LegacyIndustryActionLayout( + new ReadOnlyDictionary>( + new Dictionary> + { + [IndustryMarket.Kospi] = Array.AsReadOnly(kospi.ToArray()), + [IndustryMarket.Kosdaq] = Array.AsReadOnly(kosdaq.ToArray()) + })); + } + + private static void Validate( + IndustryMarket market, + IReadOnlyList actions) + { + ArgumentNullException.ThrowIfNull(actions); + var expected = LegacyIndustryActionCatalog.GetActions(market); + if (actions.Count != expected.Count || + actions.Any(action => action is null || action.Market != market) || + !actions.Select(action => action.ActionId).ToHashSet(StringComparer.Ordinal) + .SetEquals(expected.Select(action => action.ActionId))) + { + throw new InvalidDataException("The runtime industry action layout is invalid."); + } + } +} + +internal sealed record LegacyComparisonActionPlacement(string ActionId, string Section); + +public sealed class LegacyComparisonUiLayout +{ + private readonly IReadOnlyList _fixedTargetIds; + private readonly IReadOnlyList _actionPlacements; + + private LegacyComparisonUiLayout( + IReadOnlyList fixedTargetIds, + IReadOnlyList actionPlacements) + { + _fixedTargetIds = fixedTargetIds; + _actionPlacements = actionPlacements; + } + + public static LegacyComparisonUiLayout Default { get; } = Create( + LegacyComparisonWorkflowCatalog.FixedTargets.Select(target => target.TargetId), + LegacyComparisonWorkflowCatalog.Actions.Select(action => + new LegacyComparisonActionPlacement(action.ActionId, action.Section))); + + internal IReadOnlyList FixedTargets => _fixedTargetIds + .Select(id => LegacyComparisonWorkflowCatalog.GetFixedTarget(id) ?? + throw new InvalidOperationException( + "The runtime comparison layout contains an unknown target.")) + .ToArray(); + + internal IReadOnlyList CreateActionViews( + LegacyComparisonTarget? first, + LegacyComparisonTarget? second) => + LegacyComparisonWorkflowCatalog.CreateActionViews( + first, + second, + _actionPlacements); + + internal static LegacyComparisonUiLayout Create( + IEnumerable fixedTargetIds, + IEnumerable actionPlacements) + { + ArgumentNullException.ThrowIfNull(fixedTargetIds); + ArgumentNullException.ThrowIfNull(actionPlacements); + var targetIds = fixedTargetIds.ToArray(); + var placements = actionPlacements.ToArray(); + var expectedTargetIds = LegacyComparisonWorkflowCatalog.FixedTargets + .Select(target => target.TargetId) + .ToHashSet(StringComparer.Ordinal); + var expectedActionIds = LegacyComparisonWorkflowCatalog.Actions + .Select(action => action.ActionId) + .ToHashSet(StringComparer.Ordinal); + if (targetIds.Length != expectedTargetIds.Count || + targetIds.Distinct(StringComparer.Ordinal).Count() != targetIds.Length || + !targetIds.ToHashSet(StringComparer.Ordinal).SetEquals(expectedTargetIds) || + placements.Length != expectedActionIds.Count || + placements.Any(placement => + placement is null || + string.IsNullOrWhiteSpace(placement.Section) || + placement.Section.Any(char.IsControl)) || + placements.Select(placement => placement.ActionId) + .Distinct(StringComparer.Ordinal).Count() != placements.Length || + !placements.Select(placement => placement.ActionId) + .ToHashSet(StringComparer.Ordinal).SetEquals(expectedActionIds)) + { + throw new InvalidDataException("The runtime comparison UI layout is invalid."); + } + + return new LegacyComparisonUiLayout( + Array.AsReadOnly(targetIds), + Array.AsReadOnly(placements)); + } +} + +public sealed record LegacyRuntimeUiCatalogComposition( + LegacyFixedActionCatalog FixedCatalog, + LegacyIndustryActionLayout IndustryLayout, + LegacyComparisonUiLayout ComparisonLayout, + IReadOnlyList Warnings) +{ + public string? WarningMessage => Warnings.Count == 0 + ? null + : "런타임 UI INI 일부를 읽지 못해 검증된 내장 목록을 사용합니다: " + + string.Join(", ", Warnings.Select(warning => + $"{warning.FileName} ({warning.Failure})")); +} + +/// +/// Reads only the six non-secret CP949 UI catalogs next to the executable. Raw INI text +/// controls section and row order, while every executable action remains joined to one +/// pre-registered C# identity. A malformed file can therefore change neither scene aliases +/// nor mutation data and falls back independently to the embedded catalog. +/// +public static class LegacyRuntimeUiCatalogLoader +{ + public const int MaximumFileBytes = 64 * 1024; + public const int MaximumSections = 20; + public const int MaximumRowsPerSection = 50; + public const int MaximumLineLength = 256; + + private static readonly Encoding Cp949 = CreateCp949Encoding(); + + public static LegacyRuntimeUiCatalogComposition ResolveFromBaseDirectory( + string? baseDirectory) + { + var warnings = new List(); + if (!TryCreateResourceDirectory(baseDirectory, out var resourceDirectory)) + { + warnings.Add(new LegacyRuntimeUiCatalogWarning( + "Res", + LegacyRuntimeUiCatalogFailure.InvalidBaseDirectory)); + return CreateFallback(warnings); + } + + var fixedCatalog = LegacyFixedActionCatalog.Default; + var fixedOrders = new Dictionary>(); + LoadFixed( + resourceDirectory, + "해외.ini", + LegacyFixedMarket.Overseas, + fixedCatalog, + fixedOrders, + warnings); + LoadFixed( + resourceDirectory, + "환율.ini", + LegacyFixedMarket.Exchange, + fixedCatalog, + fixedOrders, + warnings); + LoadFixed( + resourceDirectory, + "지수.ini", + LegacyFixedMarket.Index, + fixedCatalog, + fixedOrders, + warnings); + fixedCatalog = fixedCatalog.WithRuntimeOrder(fixedOrders); + + var kospi = LoadIndustry( + resourceDirectory, + "업종_코스피.ini", + IndustryMarket.Kospi, + warnings); + var kosdaq = LoadIndustry( + resourceDirectory, + "업종_코스닥.ini", + IndustryMarket.Kosdaq, + warnings); + var industryLayout = LegacyIndustryActionLayout.Create(kospi, kosdaq); + + var comparisonLayout = LoadComparison( + resourceDirectory, + "종목비교.ini", + warnings); + return new LegacyRuntimeUiCatalogComposition( + fixedCatalog, + industryLayout, + comparisonLayout, + Array.AsReadOnly(warnings.ToArray())); + } + + private static LegacyRuntimeUiCatalogComposition CreateFallback( + IReadOnlyList warnings) => new( + LegacyFixedActionCatalog.Default, + LegacyIndustryActionLayout.Default, + LegacyComparisonUiLayout.Default, + warnings); + + private static void LoadFixed( + string resourceDirectory, + string fileName, + LegacyFixedMarket market, + LegacyFixedActionCatalog catalog, + IDictionary> orders, + ICollection warnings) + { + if (TryReadDocument( + Path.Combine(resourceDirectory, fileName), + out var document, + out var failure) && + TryJoinRows( + document, + catalog.GetActions(market), + action => action.Section, + action => action.Label, + out var actions, + out failure)) + { + orders.Add(market, actions); + return; + } + + orders.Add(market, catalog.GetActions(market)); + warnings.Add(new LegacyRuntimeUiCatalogWarning(fileName, failure)); + } + + private static IReadOnlyList LoadIndustry( + string resourceDirectory, + string fileName, + IndustryMarket market, + ICollection warnings) + { + var embedded = LegacyIndustryActionCatalog.GetActions(market); + if (!TryReadDocument( + Path.Combine(resourceDirectory, fileName), + out var document, + out var failure)) + { + warnings.Add(new LegacyRuntimeUiCatalogWarning(fileName, failure)); + return embedded; + } + + var expectedSection = market == IndustryMarket.Kospi ? "코스피" : "코스닥"; + if (document.Sections.Count != 1 || + !string.Equals( + document.Sections[0].Name, + expectedSection, + StringComparison.Ordinal)) + { + warnings.Add(new LegacyRuntimeUiCatalogWarning( + fileName, + LegacyRuntimeUiCatalogFailure.InvalidFormat)); + return embedded; + } + + var expected = embedded.Select(action => new IndustryJoinRow( + expectedSection, + action.Label, + action)).ToArray(); + if (!TryJoinRows( + document, + expected, + row => row.Section, + row => row.Label, + out var ordered, + out failure)) + { + warnings.Add(new LegacyRuntimeUiCatalogWarning(fileName, failure)); + return embedded; + } + + return Array.AsReadOnly(ordered + .Select(row => row.Action with { Section = expectedSection }) + .ToArray()); + } + + private static LegacyComparisonUiLayout LoadComparison( + string resourceDirectory, + string fileName, + ICollection warnings) + { + if (!TryReadDocument( + Path.Combine(resourceDirectory, fileName), + out var document, + out var failure) || + !TryCreateComparisonLayout(document, out var layout, out failure)) + { + warnings.Add(new LegacyRuntimeUiCatalogWarning(fileName, failure)); + return LegacyComparisonUiLayout.Default; + } + + return layout; + } + + private static bool TryCreateComparisonLayout( + IniDocument document, + out LegacyComparisonUiLayout layout, + out LegacyRuntimeUiCatalogFailure failure) + { + layout = LegacyComparisonUiLayout.Default; + if (document.Sections.Count != 6 || + !string.Equals(document.Sections[0].Name, "지수", StringComparison.Ordinal)) + { + failure = LegacyRuntimeUiCatalogFailure.InvalidFormat; + return false; + } + + var targetByLabel = LegacyComparisonWorkflowCatalog.FixedTargets + .ToDictionary(target => target.DisplayName, StringComparer.Ordinal); + var targetIds = new List(targetByLabel.Count); + var targetLabels = new HashSet(StringComparer.Ordinal); + foreach (var label in document.Sections[0].Rows) + { + if (!targetLabels.Add(label)) + { + failure = LegacyRuntimeUiCatalogFailure.DuplicateEntry; + return false; + } + + if (!targetByLabel.TryGetValue(label, out var target)) + { + failure = LegacyRuntimeUiCatalogFailure.UnknownEntry; + return false; + } + + targetIds.Add(target.TargetId); + } + + if (targetIds.Count != targetByLabel.Count) + { + failure = LegacyRuntimeUiCatalogFailure.MissingEntry; + return false; + } + + var actions = LegacyComparisonWorkflowCatalog.Actions; + var directActions = actions + .Where(action => action.Kind != LegacyComparisonActionKind.Return) + .ToDictionary(action => action.Label, StringComparer.Ordinal); + var returnActions = actions + .Where(action => action.Kind == LegacyComparisonActionKind.Return) + .ToArray(); + var returnSectionNames = returnActions + .Select(action => action.Section) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (returnSectionNames.Length != 1) + { + failure = LegacyRuntimeUiCatalogFailure.InvalidFormat; + return false; + } + + var expectedSectionNames = directActions.Keys + .Append(returnSectionNames[0]) + .ToHashSet(StringComparer.Ordinal); + var actualSectionNames = document.Sections.Skip(1) + .Select(section => section.Name) + .ToHashSet(StringComparer.Ordinal); + if (actualSectionNames.Count != expectedSectionNames.Count || + !actualSectionNames.SetEquals(expectedSectionNames)) + { + failure = LegacyRuntimeUiCatalogFailure.UnknownEntry; + return false; + } + + var placements = new List(actions.Count); + var actionIds = new HashSet(StringComparer.Ordinal); + foreach (var section in document.Sections.Skip(1)) + { + if (directActions.TryGetValue(section.Name, out var directAction)) + { + if (section.Rows.Count != 0) + { + failure = LegacyRuntimeUiCatalogFailure.InvalidFormat; + return false; + } + + if (!actionIds.Add(directAction.ActionId)) + { + failure = LegacyRuntimeUiCatalogFailure.DuplicateEntry; + return false; + } + + placements.Add(new LegacyComparisonActionPlacement( + directAction.ActionId, + section.Name)); + continue; + } + + if (!string.Equals( + section.Name, + returnSectionNames[0], + StringComparison.Ordinal)) + { + failure = LegacyRuntimeUiCatalogFailure.UnknownEntry; + return false; + } + + var expectedReturnRows = returnActions.Select(action => + new ComparisonJoinRow(section.Name, action.Label, action)).ToArray(); + var returnDocument = new IniDocument( + Array.AsReadOnly(new[] { section })); + if (!TryJoinRows( + returnDocument, + expectedReturnRows, + row => row.Section, + row => row.Label, + out var orderedReturns, + out failure)) + { + return false; + } + + foreach (var row in orderedReturns) + { + actionIds.Add(row.Action.ActionId); + placements.Add(new LegacyComparisonActionPlacement( + row.Action.ActionId, + section.Name)); + } + } + + if (placements.Count != actions.Count || actionIds.Count != actions.Count) + { + failure = LegacyRuntimeUiCatalogFailure.MissingEntry; + return false; + } + + try + { + layout = LegacyComparisonUiLayout.Create(targetIds, placements); + failure = LegacyRuntimeUiCatalogFailure.None; + return true; + } + catch (InvalidDataException) + { + failure = LegacyRuntimeUiCatalogFailure.InvalidFormat; + return false; + } + } + + private static bool TryJoinRows( + IniDocument document, + IReadOnlyList expected, + Func sectionSelector, + Func labelSelector, + out IReadOnlyList ordered, + out LegacyRuntimeUiCatalogFailure failure) + { + ordered = Array.Empty(); + var expectedSections = expected.Select(sectionSelector) + .ToHashSet(StringComparer.Ordinal); + var actualSections = document.Sections.Select(section => section.Name) + .ToHashSet(StringComparer.Ordinal); + if (document.Sections.Count != expectedSections.Count || + !actualSections.SetEquals(expectedSections)) + { + failure = LegacyRuntimeUiCatalogFailure.InvalidFormat; + return false; + } + + var expectedOccurrences = new Dictionary(); + var expectedCounts = new Dictionary(); + foreach (var value in expected) + { + var pair = new EntryPair(sectionSelector(value), labelSelector(value)); + expectedCounts.TryGetValue(pair, out var occurrence); + expectedCounts[pair] = occurrence + 1; + expectedOccurrences.Add( + new EntryKey(pair.Section, pair.Label, occurrence), + value); + } + + var result = new List(expected.Count); + var actualCounts = new Dictionary(); + foreach (var section in document.Sections) + { + foreach (var label in section.Rows) + { + var pair = new EntryPair(section.Name, label); + actualCounts.TryGetValue(pair, out var occurrence); + actualCounts[pair] = occurrence + 1; + if (!expectedOccurrences.TryGetValue( + new EntryKey(section.Name, label, occurrence), + out var value)) + { + failure = expectedCounts.ContainsKey(pair) + ? LegacyRuntimeUiCatalogFailure.DuplicateEntry + : LegacyRuntimeUiCatalogFailure.UnknownEntry; + return false; + } + + result.Add(value); + } + } + + if (result.Count != expected.Count) + { + failure = LegacyRuntimeUiCatalogFailure.MissingEntry; + return false; + } + + ordered = Array.AsReadOnly(result.ToArray()); + failure = LegacyRuntimeUiCatalogFailure.None; + return true; + } + + private static bool TryReadDocument( + string path, + out IniDocument document, + out LegacyRuntimeUiCatalogFailure failure) + { + document = IniDocument.Empty; + byte[] bytes; + try + { + using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 4096, + FileOptions.SequentialScan); + if (stream.Length > MaximumFileBytes) + { + failure = LegacyRuntimeUiCatalogFailure.FileTooLarge; + return false; + } + + bytes = new byte[(int)stream.Length]; + stream.ReadExactly(bytes); + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException) + { + failure = LegacyRuntimeUiCatalogFailure.FileNotFound; + return false; + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or + ArgumentException or NotSupportedException or + System.Security.SecurityException) + { + failure = LegacyRuntimeUiCatalogFailure.FileUnavailable; + return false; + } + + string text; + try + { + text = Cp949.GetString(bytes); + } + catch (DecoderFallbackException) + { + failure = LegacyRuntimeUiCatalogFailure.InvalidEncoding; + return false; + } + + return TryParse(text, out document, out failure); + } + + private static bool TryParse( + string text, + out IniDocument document, + out LegacyRuntimeUiCatalogFailure failure) + { + document = IniDocument.Empty; + var sections = new List(); + var sectionNames = new HashSet(StringComparer.Ordinal); + List? currentRows = null; + using var reader = new StringReader(text); + while (reader.ReadLine() is { } sourceLine) + { + var line = sourceLine.Trim(); + if (line.Length == 0) + { + continue; + } + + if (line.Length > MaximumLineLength || + line.Any(character => char.IsControl(character) || char.IsSurrogate(character))) + { + failure = LegacyRuntimeUiCatalogFailure.InvalidFormat; + return false; + } + + if (line[0] == '[') + { + if (line.Length < 3 || line[^1] != ']' || + line.AsSpan(1, line.Length - 2).Contains('[') || + line.AsSpan(1, line.Length - 2).Contains(']')) + { + failure = LegacyRuntimeUiCatalogFailure.InvalidFormat; + return false; + } + + var name = line[1..^1].Trim(); + if (name.Length == 0 || !sectionNames.Add(name)) + { + failure = name.Length == 0 + ? LegacyRuntimeUiCatalogFailure.InvalidFormat + : LegacyRuntimeUiCatalogFailure.DuplicateEntry; + return false; + } + + if (sections.Count >= MaximumSections) + { + failure = LegacyRuntimeUiCatalogFailure.TooManySections; + return false; + } + + currentRows = []; + sections.Add(new IniSection(name, currentRows)); + continue; + } + + if (currentRows is null) + { + failure = LegacyRuntimeUiCatalogFailure.InvalidFormat; + return false; + } + + if (currentRows.Count >= MaximumRowsPerSection) + { + failure = LegacyRuntimeUiCatalogFailure.TooManyRows; + return false; + } + + currentRows.Add(line); + } + + if (sections.Count == 0) + { + failure = LegacyRuntimeUiCatalogFailure.InvalidFormat; + return false; + } + + document = new IniDocument(Array.AsReadOnly(sections.ToArray())); + failure = LegacyRuntimeUiCatalogFailure.None; + return true; + } + + private static bool TryCreateResourceDirectory( + string? baseDirectory, + out string resourceDirectory) + { + resourceDirectory = string.Empty; + if (string.IsNullOrWhiteSpace(baseDirectory) || + baseDirectory.Any(char.IsControl) || + !Path.IsPathFullyQualified(baseDirectory)) + { + return false; + } + + try + { + resourceDirectory = Path.Combine(Path.GetFullPath(baseDirectory), "Res"); + return true; + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or NotSupportedException or + System.Security.SecurityException) + { + return false; + } + } + + private static Encoding CreateCp949Encoding() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + return Encoding.GetEncoding( + 949, + EncoderFallback.ExceptionFallback, + DecoderFallback.ExceptionFallback); + } + + private sealed record IniSection(string Name, IReadOnlyList Rows); + + private sealed record IniDocument(IReadOnlyList Sections) + { + public static IniDocument Empty { get; } = new(Array.Empty()); + } + + private sealed record IndustryJoinRow( + string Section, + string Label, + LegacyIndustryActionDefinition Action); + + private sealed record ComparisonJoinRow( + string Section, + string Label, + LegacyComparisonActionDefinition Action); + + private readonly record struct EntryPair(string Section, string Label); + + private readonly record struct EntryKey(string Section, string Label, int Occurrence); +} 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 a3cacfe..d529662 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj @@ -23,9 +23,55 @@ 0.1.0 1 0.1.0 + + $(MSBuildProjectDirectory)\..\..\..\MBN_STOCK_N\MBN_STOCK_N\bin\Debug + $([System.IO.Path]::GetFullPath('$(LegacyRuntimeSourceRoot)')) + $(LegacyRuntimeSourceRoot)\Cuts + $(LegacyRuntimeSourceRoot)\Res + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -39,9 +85,99 @@ PreserveNewest PreserveNewest + + Cuts\%(RecursiveDir)%(Filename)%(Extension) + Cuts\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + PreserveNewest + + + Res\%(Filename)%(Extension) + Res\%(Filename)%(Extension) + PreserveNewest + PreserveNewest + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_ForbiddenLegacyDatabasePackagePayload Include="@(PackagingOutputs)" + Condition="'%(PackagingOutputs.TargetPath)' == 'Res\MmoneyCoder.ini' or + '%(PackagingOutputs.TargetPath)' == 'Res/MmoneyCoder.ini'" /> + + + + diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs index 11c1900..7055bf0 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs @@ -57,6 +57,8 @@ public sealed partial class MainWindow : Window var cutMenuComposition = LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates( runtimeCutMenuOverridePath, AppContext.BaseDirectory); + var runtimeUiCatalogs = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory( + AppContext.BaseDirectory); ILegacyStockLookup stockLookup; IIndustrySelectionService industrySelectionService; @@ -76,7 +78,8 @@ public sealed partial class MainWindow : Window string? initializationError = null; try { - _databaseRuntime = DatabaseRuntime.CreateDefault(); + _databaseRuntime = DatabaseRuntime.CreateLegacyExecutableDefault( + AppContext.BaseDirectory); DataQueryExecutor.Configure(_databaseRuntime.Executor); var databaseMutationAuthorization = new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization); @@ -163,7 +166,9 @@ public sealed partial class MainWindow : Window DataQueryExecutor.Reset(); } - _industryWorkflow = new LegacyIndustrySelectionWorkflow(industrySelectionService); + _industryWorkflow = new LegacyIndustrySelectionWorkflow( + industrySelectionService, + actionLayout: runtimeUiCatalogs.IndustryLayout); var overseasWorkflow = new LegacyOverseasSelectionWorkflow( overseasIndustrySearchService, overseasStockSearchService); @@ -177,6 +182,7 @@ public sealed partial class MainWindow : Window var manualListsWorkflow = CreateManualListsWorkflow(stockLookup); _controller = new LegacyOperatorController( stockLookup, + fixedCatalog: runtimeUiCatalogs.FixedCatalog, industryWorkflow: _industryWorkflow, themeWorkflow: new LegacyThemeWorkflow(themeSelectionService), expertWorkflow: new LegacyExpertWorkflowController(expertSelectionService), @@ -184,7 +190,8 @@ public sealed partial class MainWindow : Window tradingHaltSelectionService), comparisonWorkflow: new LegacyComparisonWorkflowController( new LegacyComparisonDataService(stockLookup, worldStockSearchService), - comparisonStore), + comparisonStore, + runtimeUiCatalogs.ComparisonLayout), manualFinancialWorkflow: new LegacyManualFinancialWorkflow( manualFinancialService, manualFinancialStockSearchService), @@ -205,9 +212,15 @@ public sealed partial class MainWindow : Window { _controller.ReportError(initializationError); } - else if (cutMenuComposition.WarningMessage is { } cutMenuWarning) + else if (new[] + { + cutMenuComposition.WarningMessage, + runtimeUiCatalogs.WarningMessage + } + .Where(message => message is not null) + .ToArray() is { Length: > 0 } catalogWarnings) { - _controller.ReportWarning(cutMenuWarning); + _controller.ReportWarning(string.Join(" ", catalogWarnings)); } InitializePlayoutRuntime(); diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs index e927e00..1d18405 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs @@ -23,6 +23,7 @@ public static class PlayoutOptionsLoader { var options = LoadJson(path ?? DefaultPath); ApplyEnvironment(options); + ApplyExecutableAssetDefaults(options, AppContext.BaseDirectory); return options; } @@ -42,6 +43,36 @@ public static class PlayoutOptionsLoader RequiredLiveAuthorizationValue, StringComparison.Ordinal); + internal static void ApplyExecutableAssetDefaults( + PlayoutOptions options, + string? baseDirectory) + { + ArgumentNullException.ThrowIfNull(options); + if (!string.IsNullOrWhiteSpace(options.SceneDirectory) || + string.IsNullOrWhiteSpace(baseDirectory)) + { + return; + } + + try + { + var cutsDirectory = Path.Combine( + Path.GetFullPath(baseDirectory), + "Cuts"); + if (Directory.Exists(cutsDirectory)) + { + options.SceneDirectory = cutsDirectory; + } + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or NotSupportedException or + System.Security.SecurityException) + { + // A missing or invalid executable asset root leaves the existing safe + // configuration unchanged; validation reports it if a command needs it. + } + } + private static PlayoutOptions LoadJson(string path) { if (!File.Exists(path)) diff --git a/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/LegacyIniDatabaseOptionsLoaderTests.cs b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/LegacyIniDatabaseOptionsLoaderTests.cs new file mode 100644 index 0000000..45de97c --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/LegacyIniDatabaseOptionsLoaderTests.cs @@ -0,0 +1,226 @@ +using System.Text; +using MBN_STOCK_WEBVIEW.Infrastructure; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests; + +public sealed class LegacyIniDatabaseOptionsLoaderTests +{ + private const string IntegrationPathEnvironmentVariable = + "MBN_STOCK_LEGACY_INI_INTEGRATION_PATH"; + + [Fact] + public void Load_Utf8LegacyFile_MapsBothProvidersAndKeepsLastValue() + { + using var file = TemporaryLegacyIni.Create( + Encoding.UTF8.GetBytes( + """ + [Oracle] + //ConnectionName=ignored.invalid:1/IGNORED + ConnectionName=oracle.dev.invalid:1522/DEVORA + ID=old-user + ID=oracle-user + Pass=oracle=password + + [Maria] + ConnectionName=maria.dev.invalid:3307/dev_stock + ID=maria-user + Pass=maria=password + """)); + var loader = new LegacyIniDatabaseOptionsLoader(_ => null); + + var options = loader.Load(file.Path); + + Assert.True(options.IsConfigured(DataSourceKind.Oracle)); + Assert.True(options.IsConfigured(DataSourceKind.MariaDb)); + Assert.Equal("oracle.dev.invalid", options.Oracle.Host); + Assert.Equal(1522, options.Oracle.Port); + Assert.Equal("DEVORA", options.Oracle.Sid); + Assert.Null(options.Oracle.ServiceName); + Assert.Equal("oracle-user", options.Oracle.UserName); + Assert.Equal("oracle=password", options.Oracle.Password); + Assert.Equal("maria.dev.invalid", options.MariaDb.Host); + Assert.Equal(3307, options.MariaDb.Port); + Assert.Equal("dev_stock", options.MariaDb.Database); + Assert.Equal("maria-user", options.MariaDb.UserName); + Assert.Equal("maria=password", options.MariaDb.Password); + Assert.Equal(MariaDbTlsMode.Disabled, options.MariaDb.TlsMode); + } + + [Fact] + public void Load_Cp949LegacyFile_PreservesKoreanValues() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + var cp949 = Encoding.GetEncoding( + 949, + EncoderFallback.ExceptionFallback, + DecoderFallback.ExceptionFallback); + using var file = TemporaryLegacyIni.Create(cp949.GetBytes( + """ + [Oracle] + ConnectionName=oracle.dev.invalid:1521/개발 + ID=오라클사용자 + Pass=암호 + [Maria] + ConnectionName=maria.dev.invalid:3306/주식 + ID=마리아사용자 + Pass=암호2 + """)); + + var options = new LegacyIniDatabaseOptionsLoader(_ => null).Load(file.Path); + + Assert.Equal("개발", options.Oracle.Sid); + Assert.Equal("오라클사용자", options.Oracle.UserName); + Assert.Equal("주식", options.MariaDb.Database); + Assert.Equal("마리아사용자", options.MariaDb.UserName); + } + + [Fact] + public void Load_EnvironmentStillOverridesLegacyValues() + { + using var file = TemporaryLegacyIni.CreateValidUtf8(); + var environment = new Dictionary(StringComparer.Ordinal) + { + ["MBN_STOCK_ORACLE_HOST"] = "override-oracle.invalid", + ["MBN_STOCK_MARIADB_TLS_MODE"] = "Required" + }; + var loader = new LegacyIniDatabaseOptionsLoader( + name => environment.TryGetValue(name, out var value) ? value : null); + + var options = loader.Load(file.Path); + + Assert.Equal("override-oracle.invalid", options.Oracle.Host); + Assert.Equal(MariaDbTlsMode.Required, options.MariaDb.TlsMode); + } + + [Fact] + public void Load_InvalidFile_DoesNotExposeFileValues() + { + const string secret = "sensitive-password-sentinel"; + using var file = TemporaryLegacyIni.Create(Encoding.UTF8.GetBytes( + $"[Oracle]\nConnectionName=invalid\nID=user\nPass={secret}")); + + var exception = Assert.Throws( + () => new LegacyIniDatabaseOptionsLoader(_ => null).Load(file.Path)); + + Assert.DoesNotContain(secret, exception.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain(file.Path, exception.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void Runtime_PrefersExecutableResIni_AndFallsBackWhenAbsent() + { + using var directory = TemporaryDirectory.Create(); + var res = Path.Combine(directory.Path, "Res"); + Directory.CreateDirectory(res); + File.WriteAllBytes( + Path.Combine(res, "MmoneyCoder.ini"), + TemporaryLegacyIni.ValidUtf8Bytes()); + + var runtime = DatabaseRuntime.CreateLegacyExecutableDefault( + directory.Path, + Path.Combine(directory.Path, "missing.json")); + + Assert.Equal("oracle.dev.invalid", runtime.Options.Oracle.Host); + + File.Delete(Path.Combine(res, "MmoneyCoder.ini")); + var fallback = DatabaseRuntime.CreateLegacyExecutableDefault( + directory.Path, + Path.Combine(directory.Path, "missing.json"), + Path.Combine(directory.Path, "missing-legacy.ini")); + Assert.False(fallback.Options.IsConfigured(DataSourceKind.Oracle)); + Assert.False(fallback.Options.IsConfigured(DataSourceKind.MariaDb)); + } + + [Fact] + public void Runtime_UsesLocalLegacyOverrideWhenPackageDoesNotContainCredentials() + { + using var directory = TemporaryDirectory.Create(); + using var overrideFile = TemporaryLegacyIni.CreateValidUtf8(); + + var runtime = DatabaseRuntime.CreateLegacyExecutableDefault( + directory.Path, + Path.Combine(directory.Path, "missing.json"), + overrideFile.Path); + + Assert.Equal("oracle.dev.invalid", runtime.Options.Oracle.Host); + Assert.Equal("maria.dev.invalid", runtime.Options.MariaDb.Host); + } + + [Fact] + public void Load_OptionalRealLegacyIni_IsConfiguredWithoutPrintingValues() + { + var path = Environment.GetEnvironmentVariable( + IntegrationPathEnvironmentVariable); + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + var options = new LegacyIniDatabaseOptionsLoader().Load(path); + + Assert.True(options.IsConfigured(DataSourceKind.Oracle)); + Assert.True(options.IsConfigured(DataSourceKind.MariaDb)); + } + + private sealed class TemporaryLegacyIni : IDisposable + { + private TemporaryLegacyIni(string directory, string path) + { + Directory = directory; + Path = path; + } + + public string Directory { get; } + + public string Path { get; } + + public static TemporaryLegacyIni CreateValidUtf8() => + Create(ValidUtf8Bytes()); + + public static byte[] ValidUtf8Bytes() => Encoding.UTF8.GetBytes( + """ + [Oracle] + ConnectionName=oracle.dev.invalid:1521/DEVORA + ID=oracle-user + Pass=oracle-password + [Maria] + ConnectionName=maria.dev.invalid:3306/dev_stock + ID=maria-user + Pass=maria-password + """); + + public static TemporaryLegacyIni Create(byte[] bytes) + { + var directory = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + Guid.NewGuid().ToString("N")); + System.IO.Directory.CreateDirectory(directory); + var path = System.IO.Path.Combine(directory, "MmoneyCoder.ini"); + File.WriteAllBytes(path, bytes); + return new TemporaryLegacyIni(directory, path); + } + + public void Dispose() => + System.IO.Directory.Delete(Directory, recursive: true); + } + + private sealed class TemporaryDirectory : IDisposable + { + private TemporaryDirectory(string path) => Path = path; + + public string Path { get; } + + public static TemporaryDirectory Create() + { + var path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + Guid.NewGuid().ToString("N")); + System.IO.Directory.CreateDirectory(path); + return new TemporaryDirectory(path); + } + + public void Dispose() => + System.IO.Directory.Delete(Path, recursive: true); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyRuntimeUiCatalogLoaderTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyRuntimeUiCatalogLoaderTests.cs new file mode 100644 index 0000000..56162ef --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyRuntimeUiCatalogLoaderTests.cs @@ -0,0 +1,578 @@ +using System.Text; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests; + +public sealed class LegacyRuntimeUiCatalogLoaderTests +{ + private static readonly Encoding Cp949 = CreateCp949Encoding(); + + [Fact] + public void ConfiguredLegacyBinDebugFiles_MatchEveryRuntimeProjection() + { + var legacyBinDebug = Environment.GetEnvironmentVariable( + "MBN_STOCK_N_LEGACY_BIN_DEBUG"); + if (string.IsNullOrWhiteSpace(legacyBinDebug)) + { + return; + } + + var composition = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory( + legacyBinDebug); + + Assert.Empty(composition.Warnings); + AssertRuntimeFixedFile( + legacyBinDebug, + "해외.ini", + LegacyFixedMarket.Overseas, + LegacyFixedActionCatalog.ExpectedOverseasCount, + composition.FixedCatalog); + AssertRuntimeFixedFile( + legacyBinDebug, + "환율.ini", + LegacyFixedMarket.Exchange, + LegacyFixedActionCatalog.ExpectedExchangeCount, + composition.FixedCatalog); + AssertRuntimeFixedFile( + legacyBinDebug, + "지수.ini", + LegacyFixedMarket.Index, + LegacyFixedActionCatalog.ExpectedIndexCount, + composition.FixedCatalog); + AssertRuntimeIndustryFile( + legacyBinDebug, + "업종_코스피.ini", + IndustryMarket.Kospi, + composition.IndustryLayout); + AssertRuntimeIndustryFile( + legacyBinDebug, + "업종_코스닥.ini", + IndustryMarket.Kosdaq, + composition.IndustryLayout); + AssertRuntimeComparisonFile( + legacyBinDebug, + composition.ComparisonLayout); + } + + [Fact] + public void RuntimeFiles_ControlOnlyTrustedDisplayOrder() + { + using var directory = new TemporaryDirectory(); + var fixedOrders = Enum.GetValues() + .ToDictionary( + market => market, + market => LegacyFixedActionCatalog.Default.GetActions(market) + .Reverse() + .ToArray()); + var kospi = LegacyIndustryActionCatalog.GetActions(IndustryMarket.Kospi) + .Reverse() + .ToArray(); + var kosdaq = LegacyIndustryActionCatalog.GetActions(IndustryMarket.Kosdaq) + .Reverse() + .ToArray(); + var targets = LegacyComparisonWorkflowCatalog.FixedTargets + .Reverse() + .ToArray(); + var directActions = LegacyComparisonWorkflowCatalog.Actions + .Where(action => action.Kind != LegacyComparisonActionKind.Return) + .Reverse() + .ToArray(); + var returnActions = LegacyComparisonWorkflowCatalog.Actions + .Where(action => action.Kind == LegacyComparisonActionKind.Return) + .Reverse() + .ToArray(); + + WriteAllValidFiles( + directory.Path, + fixedOrders, + kospi, + kosdaq, + targets, + directActions, + returnActions, + returnSectionFirst: true); + + var composition = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory( + directory.Path); + + Assert.Empty(composition.Warnings); + foreach (var market in Enum.GetValues()) + { + Assert.Equal( + FlattenForIni(fixedOrders[market]).Select(action => action.Id), + composition.FixedCatalog.GetActions(market).Select(action => action.Id)); + } + + Assert.Equal( + kospi.Select(action => action.ActionId), + composition.IndustryLayout.GetActions(IndustryMarket.Kospi) + .Select(action => action.ActionId)); + Assert.Equal( + kosdaq.Select(action => action.ActionId), + composition.IndustryLayout.GetActions(IndustryMarket.Kosdaq) + .Select(action => action.ActionId)); + Assert.Equal( + targets.Select(target => target.TargetId), + composition.ComparisonLayout.FixedTargets.Select(target => target.TargetId)); + Assert.Equal( + returnActions.Select(action => action.ActionId) + .Concat(directActions.Select(action => action.ActionId)), + composition.ComparisonLayout.CreateActionViews(null, null) + .Select(action => action.ActionId)); + + var industryWorkflow = new LegacyIndustrySelectionWorkflow( + new EmptyIndustrySelectionService(), + actionLayout: composition.IndustryLayout); + Assert.Equal( + kospi.Select(action => action.ActionId), + industryWorkflow.ResetForTabActivation(IndustryMarket.Kospi) + .Actions.Select(action => action.ActionId)); + + var comparisonWorkflow = new LegacyComparisonWorkflowController( + new EmptyComparisonDataService(), + new EmptyComparisonPairStore(), + composition.ComparisonLayout); + Assert.Equal( + targets.Select(target => target.DisplayName), + comparisonWorkflow.Current.FixedTargets.Select(target => target.DisplayName)); + Assert.Equal( + returnActions.Select(action => action.ActionId) + .Concat(directActions.Select(action => action.ActionId)), + comparisonWorkflow.Current.Actions.Select(action => action.ActionId)); + } + + [Fact] + public void UnknownFixedRow_FallsBackWithoutRegisteringAnAction() + { + using var directory = new TemporaryDirectory(); + WriteAllValidFiles(directory.Path); + WriteFixedFile( + directory.Path, + LegacyFixedMarket.Overseas, + LegacyFixedActionCatalog.Default.GetActions(LegacyFixedMarket.Overseas), + (action, index) => index == 0 ? "untrusted-new-action" : action.Label); + + var composition = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory( + directory.Path); + + var warning = Assert.Single(composition.Warnings); + Assert.Equal("해외.ini", warning.FileName); + Assert.Equal(LegacyRuntimeUiCatalogFailure.UnknownEntry, warning.Failure); + Assert.Equal( + LegacyFixedActionCatalog.Default.GetActions(LegacyFixedMarket.Overseas) + .Select(action => action.Id), + composition.FixedCatalog.GetActions(LegacyFixedMarket.Overseas) + .Select(action => action.Id)); + Assert.DoesNotContain( + composition.FixedCatalog.Actions, + action => string.Equals( + action.Label, + "untrusted-new-action", + StringComparison.Ordinal)); + } + + [Fact] + public void ExcessDuplicateRow_FallsBackWithDuplicateWarning() + { + using var directory = new TemporaryDirectory(); + WriteAllValidFiles(directory.Path); + WriteFixedFile( + directory.Path, + LegacyFixedMarket.Exchange, + LegacyFixedActionCatalog.Default.GetActions(LegacyFixedMarket.Exchange), + appendDuplicate: true); + + var composition = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory( + directory.Path); + + var warning = Assert.Single(composition.Warnings); + Assert.Equal("환율.ini", warning.FileName); + Assert.Equal(LegacyRuntimeUiCatalogFailure.DuplicateEntry, warning.Failure); + Assert.Equal( + LegacyFixedActionCatalog.Default.GetActions(LegacyFixedMarket.Exchange) + .Select(action => action.Id), + composition.FixedCatalog.GetActions(LegacyFixedMarket.Exchange) + .Select(action => action.Id)); + } + + [Fact] + public void InvalidCp949_FallsBackOnlyForThatFile() + { + using var directory = new TemporaryDirectory(); + WriteAllValidFiles(directory.Path); + File.WriteAllBytes( + System.IO.Path.Combine(directory.Path, "Res", "지수.ini"), + [0x81]); + + var composition = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory( + directory.Path); + + var warning = Assert.Single(composition.Warnings); + Assert.Equal("지수.ini", warning.FileName); + Assert.Equal(LegacyRuntimeUiCatalogFailure.InvalidEncoding, warning.Failure); + Assert.Equal( + LegacyFixedActionCatalog.Default.GetActions(LegacyFixedMarket.Index) + .Select(action => action.Id), + composition.FixedCatalog.GetActions(LegacyFixedMarket.Index) + .Select(action => action.Id)); + } + + [Fact] + public void RowLimit_FailsClosedBeforeCatalogJoin() + { + using var directory = new TemporaryDirectory(); + WriteAllValidFiles(directory.Path); + var section = LegacyFixedActionCatalog.Default + .GetActions(LegacyFixedMarket.Overseas)[0].Section; + WriteCp949( + System.IO.Path.Combine(directory.Path, "Res", "해외.ini"), + [$"[{section}]", .. Enumerable.Range(1, 51).Select(index => $"row-{index}")]); + + var composition = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory( + directory.Path); + + var warning = Assert.Single(composition.Warnings); + Assert.Equal("해외.ini", warning.FileName); + Assert.Equal(LegacyRuntimeUiCatalogFailure.TooManyRows, warning.Failure); + } + + [Fact] + public void ComparisonDirectActionCannotBeReinterpretedAsAChildRow() + { + using var directory = new TemporaryDirectory(); + WriteAllValidFiles(directory.Path); + var directActions = LegacyComparisonWorkflowCatalog.Actions + .Where(action => action.Kind != LegacyComparisonActionKind.Return) + .ToArray(); + var returnActions = LegacyComparisonWorkflowCatalog.Actions + .Where(action => action.Kind == LegacyComparisonActionKind.Return) + .ToArray(); + var lines = new List { "[지수]" }; + lines.AddRange(LegacyComparisonWorkflowCatalog.FixedTargets + .Select(target => target.DisplayName)); + foreach (var action in directActions) + { + lines.Add($"[{action.Label}]"); + } + + lines.Add($"[{returnActions[0].Section}]"); + lines.Add(directActions[0].Label); + lines.AddRange(returnActions.Select(action => action.Label)); + WriteCp949( + System.IO.Path.Combine(directory.Path, "Res", "종목비교.ini"), + lines); + + var composition = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory( + directory.Path); + + var warning = Assert.Single(composition.Warnings); + Assert.Equal("종목비교.ini", warning.FileName); + Assert.Equal(LegacyRuntimeUiCatalogFailure.UnknownEntry, warning.Failure); + Assert.Equal( + LegacyComparisonWorkflowCatalog.Actions.Select(action => action.ActionId), + composition.ComparisonLayout.CreateActionViews(null, null) + .Select(action => action.ActionId)); + } + + private static void WriteAllValidFiles( + string baseDirectory, + IReadOnlyDictionary? fixedOrders = null, + IReadOnlyList? kospi = null, + IReadOnlyList? kosdaq = null, + IReadOnlyList? targets = null, + IReadOnlyList? directActions = null, + IReadOnlyList? returnActions = null, + bool returnSectionFirst = false) + { + Directory.CreateDirectory(System.IO.Path.Combine(baseDirectory, "Res")); + foreach (var market in Enum.GetValues()) + { + WriteFixedFile( + baseDirectory, + market, + fixedOrders is not null && fixedOrders.TryGetValue(market, out var ordered) + ? ordered + : LegacyFixedActionCatalog.Default.GetActions(market)); + } + + WriteIndustryFile( + baseDirectory, + IndustryMarket.Kospi, + kospi ?? LegacyIndustryActionCatalog.GetActions(IndustryMarket.Kospi)); + WriteIndustryFile( + baseDirectory, + IndustryMarket.Kosdaq, + kosdaq ?? LegacyIndustryActionCatalog.GetActions(IndustryMarket.Kosdaq)); + WriteComparisonFile( + baseDirectory, + targets ?? LegacyComparisonWorkflowCatalog.FixedTargets, + directActions ?? LegacyComparisonWorkflowCatalog.Actions + .Where(action => action.Kind != LegacyComparisonActionKind.Return) + .ToArray(), + returnActions ?? LegacyComparisonWorkflowCatalog.Actions + .Where(action => action.Kind == LegacyComparisonActionKind.Return) + .ToArray(), + returnSectionFirst); + } + + private static void WriteFixedFile( + string baseDirectory, + LegacyFixedMarket market, + IReadOnlyList actions, + Func? labelSelector = null, + bool appendDuplicate = false) + { + var lines = new List(); + var rowIndex = 0; + foreach (var section in actions.GroupBy(action => action.Section, StringComparer.Ordinal)) + { + lines.Add($"[{section.Key}]"); + foreach (var action in section) + { + lines.Add(labelSelector?.Invoke(action, rowIndex) ?? action.Label); + rowIndex++; + } + + if (appendDuplicate) + { + lines.Add(section.First().Label); + appendDuplicate = false; + } + } + + WriteCp949( + System.IO.Path.Combine(baseDirectory, "Res", FixedFileName(market)), + lines); + } + + private static IEnumerable FlattenForIni( + IEnumerable actions) => + actions.GroupBy(action => action.Section, StringComparer.Ordinal) + .SelectMany(group => group); + + private static void WriteIndustryFile( + string baseDirectory, + IndustryMarket market, + IReadOnlyList actions) + { + var section = market == IndustryMarket.Kospi ? "코스피" : "코스닥"; + WriteCp949( + System.IO.Path.Combine(baseDirectory, "Res", IndustryFileName(market)), + [$"[{section}]", .. actions.Select(action => action.Label)]); + } + + private static void WriteComparisonFile( + string baseDirectory, + IReadOnlyList targets, + IReadOnlyList directActions, + IReadOnlyList returnActions, + bool returnSectionFirst) + { + var lines = new List { "[지수]" }; + lines.AddRange(targets.Select(target => target.DisplayName)); + void AddDirectSections() + { + foreach (var action in directActions) + { + lines.Add($"[{action.Label}]"); + } + } + + void AddReturnSection() + { + lines.Add($"[{returnActions[0].Section}]"); + lines.AddRange(returnActions.Select(action => action.Label)); + } + + if (returnSectionFirst) + { + AddReturnSection(); + AddDirectSections(); + } + else + { + AddDirectSections(); + AddReturnSection(); + } + + WriteCp949( + System.IO.Path.Combine(baseDirectory, "Res", "종목비교.ini"), + lines); + } + + private static void WriteCp949(string path, IEnumerable lines) => + File.WriteAllText(path, string.Join("\r\n", lines) + "\r\n", Cp949); + + private static void AssertRuntimeFixedFile( + string baseDirectory, + string fileName, + LegacyFixedMarket market, + int expectedCount, + LegacyFixedActionCatalog catalog) + { + var document = ReadTestIni(baseDirectory, fileName); + var actions = catalog.GetActions(market); + Assert.Equal(expectedCount, actions.Count); + Assert.Equal( + document.SelectMany(section => section.Rows), + actions.Select(action => action.Label)); + Assert.Equal( + document.SelectMany(section => section.Rows.Select(_ => section.Name)), + actions.Select(action => action.Section)); + } + + private static void AssertRuntimeIndustryFile( + string baseDirectory, + string fileName, + IndustryMarket market, + LegacyIndustryActionLayout layout) + { + var section = Assert.Single(ReadTestIni(baseDirectory, fileName)); + var actions = layout.GetActions(market); + Assert.Equal(LegacyIndustryActionCatalog.ActionsPerMarket, actions.Count); + Assert.Equal(section.Rows, actions.Select(action => action.Label)); + Assert.All(actions, action => Assert.Equal(section.Name, action.Section)); + } + + private static void AssertRuntimeComparisonFile( + string baseDirectory, + LegacyComparisonUiLayout layout) + { + var document = ReadTestIni(baseDirectory, "종목비교.ini"); + Assert.Equal(6, document.Count); + Assert.Equal(24, layout.FixedTargets.Count); + Assert.Equal( + document[0].Rows, + layout.FixedTargets.Select(target => target.DisplayName)); + var expectedActions = document.Skip(1).SelectMany(section => + section.Rows.Count == 0 + ? new[] { (Label: section.Name, Section: section.Name) } + : section.Rows.Select(row => (Label: row, Section: section.Name))); + var actualActions = layout.CreateActionViews(null, null); + Assert.Equal(9, actualActions.Count); + Assert.Equal( + expectedActions, + actualActions.Select(action => (action.Label, action.Section))); + } + + private static IReadOnlyList ReadTestIni( + string baseDirectory, + string fileName) + { + var sections = new List(); + List? rows = null; + foreach (var source in File.ReadAllLines( + System.IO.Path.Combine(baseDirectory, "Res", fileName), + Cp949)) + { + var line = source.Trim(); + if (line.Length == 0) + { + continue; + } + + if (line[0] == '[' && line[^1] == ']') + { + rows = []; + sections.Add(new TestIniSection(line[1..^1].Trim(), rows)); + } + else + { + Assert.NotNull(rows); + rows.Add(line); + } + } + + return sections; + } + + private static string FixedFileName(LegacyFixedMarket market) => market switch + { + LegacyFixedMarket.Overseas => "해외.ini", + LegacyFixedMarket.Exchange => "환율.ini", + LegacyFixedMarket.Index => "지수.ini", + _ => throw new ArgumentOutOfRangeException(nameof(market), market, null) + }; + + private static string IndustryFileName(IndustryMarket market) => market switch + { + IndustryMarket.Kospi => "업종_코스피.ini", + IndustryMarket.Kosdaq => "업종_코스닥.ini", + _ => throw new ArgumentOutOfRangeException(nameof(market), market, null) + }; + + private static Encoding CreateCp949Encoding() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + return Encoding.GetEncoding( + 949, + EncoderFallback.ExceptionFallback, + DecoderFallback.ExceptionFallback); + } + + private sealed class EmptyIndustrySelectionService : IIndustrySelectionService + { + public Task> GetAsync( + IndustryMarket market, + CancellationToken cancellationToken = default) => + Task.FromResult>( + Array.Empty()); + } + + private sealed class EmptyComparisonDataService : ILegacyComparisonDataService + { + public Task> SearchDomesticAsync( + string query, + int maximumResults, + CancellationToken cancellationToken = default) => + Task.FromResult>( + Array.Empty()); + + public Task> SearchWorldAsync( + string query, + int maximumResults, + CancellationToken cancellationToken = default) => + Task.FromResult>( + Array.Empty()); + + public Task ResolveStoredTargetAsync( + LegacyComparisonStoredTargetReference target, + CancellationToken cancellationToken = default) => + Task.FromResult(null); + } + + private sealed class EmptyComparisonPairStore : ILegacyComparisonPairStore + { + public Task ReadCp949Async( + CancellationToken cancellationToken = default) => + Task.FromResult(null); + + public Task WriteCp949Async( + IReadOnlyList rows, + CancellationToken cancellationToken = default) => + Task.CompletedTask; + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"MBN_STOCK_WEBVIEW_UI_INI_{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + } + + private sealed record TestIniSection(string Name, IReadOnlyList Rows); +} diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDbSmokeRuntimeSelectionContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDbSmokeRuntimeSelectionContractTests.cs new file mode 100644 index 0000000..bd7d7ee --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDbSmokeRuntimeSelectionContractTests.cs @@ -0,0 +1,121 @@ +namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests; + +public sealed class LegacyDbSmokeRuntimeSelectionContractTests +{ + private static readonly string RepositoryRoot = FindRepositoryRoot(); + private static readonly string Program = File.ReadAllText(Path.Combine( + RepositoryRoot, + "tools", + "MBN_STOCK_WEBVIEW.DbSmoke", + "Program.cs")); + private static readonly string NxtAudit = File.ReadAllText(Path.Combine( + RepositoryRoot, + "tools", + "MBN_STOCK_WEBVIEW.DbSmoke", + "NxtThemeRestoreDbAudit.cs")); + + [Fact] + public void SelectedLegacyRuntimeEndpointIsInjectedIntoNxtAudit() + { + Assert.Contains( + "var iniPath = Path.Combine(root, \"Res\", \"MmoneyCoder.ini\");", + Program, + StringComparison.Ordinal); + Assert.Contains( + "new LegacyIniDatabaseOptionsLoader(_ => null).Load(iniPath)", + Program, + StringComparison.Ordinal); + Assert.Contains( + "MariaDbEndpointReference.From(selectedOptions.MariaDb)", + Program, + StringComparison.Ordinal); + Assert.Contains( + "selection.ReferenceMariaDb", + Program, + StringComparison.Ordinal); + Assert.Contains( + "MariaDbEndpointReference referenceOptions", + NxtAudit, + StringComparison.Ordinal); + Assert.Contains( + "referenceOptions.Host", + NxtAudit, + StringComparison.Ordinal); + Assert.Contains( + "referenceOptions.Port", + NxtAudit, + StringComparison.Ordinal); + Assert.Contains( + "referenceOptions.Database", + NxtAudit, + StringComparison.Ordinal); + } + + [Fact] + public void JsonRuntimeEndpointIsCapturedBeforeEnvironmentOverrides() + { + Assert.Contains( + "new DatabaseOptionsLoader(_ => null).Load(configurationPath)", + Program, + StringComparison.Ordinal); + Assert.Contains( + "var runtime = DatabaseRuntime.CreateDefault(configurationPath);", + Program, + StringComparison.Ordinal); + Assert.Contains( + "HasCompleteEndpointIdentity(configuredMariaDb)", + Program, + StringComparison.Ordinal); + Assert.Contains( + "MariaDbEndpointReference.From(configuredMariaDb)", + Program, + StringComparison.Ordinal); + Assert.Contains( + "MariaDbEndpointReference.From(runtime.Options.MariaDb)", + Program, + StringComparison.Ordinal); + } + + [Fact] + public void NxtAuditHasNoMachineSpecificOriginalConfigurationDependency() + { + Assert.DoesNotContain("OriginalConfigurationPath", NxtAudit, StringComparison.Ordinal); + Assert.DoesNotContain("ReadOriginalEndpoint", NxtAudit, StringComparison.Ordinal); + Assert.DoesNotContain("MBN_STOCK_N", NxtAudit, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("MmoneyCoder.ini", NxtAudit, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void InjectedReferenceContainsEndpointIdentityOnly() + { + Assert.Contains( + "internal sealed record MariaDbEndpointReference(", + NxtAudit, + StringComparison.Ordinal); + Assert.Contains("string? Host", NxtAudit, StringComparison.Ordinal); + Assert.Contains("int Port", NxtAudit, StringComparison.Ordinal); + Assert.Contains("string? Database", NxtAudit, StringComparison.Ordinal); + Assert.DoesNotContain("UserName", NxtAudit, StringComparison.Ordinal); + Assert.DoesNotContain("Password", NxtAudit, StringComparison.Ordinal); + + Assert.Contains("originalSha256=", NxtAudit, StringComparison.Ordinal); + Assert.Contains("currentSha256=", NxtAudit, StringComparison.Ordinal); + Assert.Contains("configuredEqual=", NxtAudit, StringComparison.Ordinal); + } + + private static string FindRepositoryRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "MBN_STOCK_WEBVIEW.sln"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Could not locate the repository root."); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs new file mode 100644 index 0000000..f436fbb --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeAssetDeploymentContractTests.cs @@ -0,0 +1,238 @@ +using System.Xml.Linq; + +namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests; + +public sealed class LegacyRuntimeAssetDeploymentContractTests +{ + private static readonly string RepositoryRoot = FindRepositoryRoot(); + private static readonly string ProjectPath = Path.Combine( + RepositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", + "MBN_STOCK_WEBVIEW.LegacyParityApp.csproj"); + + [Fact] + public void LegacyParityProjectLinksCutsAndResIntoOutputPublishAndMsix() + { + var project = XDocument.Load(ProjectPath); + var contents = project.Descendants("Content").ToArray(); + + AssertLinkedRuntimeTree(contents, "$(LegacyCutsSourceRoot)\\**\\*", "Cuts\\"); + AssertLinkedRuntimeTree(contents, "@(LegacyPackagedResAsset)", "Res\\"); + + var packagedResNames = project.Descendants("LegacyPackagedResAsset") + .Select(element => Path.GetFileName(element.Attribute("Include")?.Value)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + string[] requiredUiMenus = + [ + "setup.ini", + "기타.ini", + "업종.ini", + "업종_코스닥.ini", + "업종_코스피.ini", + "종목.ini", + "종목_0111.ini", + "종목비교.ini", + "지수.ini", + "지수_0318.ini", + "지수_250401.ini", + "해외.ini", + "환율.ini" + ]; + + Assert.Equal(34, packagedResNames.Count); + Assert.All(requiredUiMenus, name => Assert.Contains(name, packagedResNames)); + Assert.DoesNotContain("MmoneyCoder.ini", packagedResNames); + Assert.DoesNotContain("MmoneyCoder - 복사본.ini", packagedResNames); + Assert.DoesNotContain("MmoneyCoder.ini.bak", packagedResNames); + Assert.DoesNotContain("MmoneyCoder.zip", packagedResNames); + Assert.DoesNotContain("afiedt.buf.txt", packagedResNames); + } + + [Fact] + public void ActiveDatabaseIniIsPostBuildOutputOnlyAndNeverAPackagingItem() + { + var project = XDocument.Load(ProjectPath); + Assert.DoesNotContain( + project.Descendants().Where(element => + element.Name.LocalName is "Content" or "None"), + element => (element.Attribute("Include")?.Value ?? string.Empty) + .Contains("MmoneyCoder.ini", StringComparison.OrdinalIgnoreCase)); + + var upToDateInput = Assert.Single(project.Descendants("UpToDateCheckInput")); + Assert.Equal( + "$(LegacyResSourceRoot)\\MmoneyCoder.ini", + upToDateInput.Attribute("Include")?.Value); + var upToDateBuilt = Assert.Single(project.Descendants("UpToDateCheckBuilt")); + Assert.Equal( + "$(TargetDir)Res\\MmoneyCoder.ini", + upToDateBuilt.Attribute("Include")?.Value); + + var copyTarget = Assert.Single( + project.Descendants("Target"), + element => string.Equals( + element.Attribute("Name")?.Value, + "CopyLegacyDatabaseIniToLocalOutput", + StringComparison.Ordinal)); + Assert.Equal("Build", copyTarget.Attribute("AfterTargets")?.Value); + Assert.Contains( + "GenerateAppxPackageOnBuild", + copyTarget.Attribute("Condition")?.Value, + StringComparison.Ordinal); + Assert.Contains( + "PublishAppxPackage", + copyTarget.Attribute("Condition")?.Value, + StringComparison.Ordinal); + Assert.Equal( + "$(LegacyResSourceRoot)\\MmoneyCoder.ini", + copyTarget.Attribute("Inputs")?.Value); + Assert.Equal( + "$(TargetDir)Res\\MmoneyCoder.ini", + copyTarget.Attribute("Outputs")?.Value); + var copy = Assert.Single(copyTarget.Elements("Copy")); + Assert.Equal( + "$(LegacyResSourceRoot)\\MmoneyCoder.ini", + copy.Attribute("SourceFiles")?.Value); + Assert.Equal( + "$(TargetDir)Res\\MmoneyCoder.ini", + copy.Attribute("DestinationFiles")?.Value); + var fileWrite = Assert.Single(copyTarget.Descendants("FileWrites")); + Assert.Equal( + "$(TargetDir)Res\\MmoneyCoder.ini", + fileWrite.Attribute("Include")?.Value); + + var cleanTarget = Assert.Single( + project.Descendants("Target"), + element => string.Equals( + element.Attribute("Name")?.Value, + "RemoveLegacyDatabaseIniFromLocalOutput", + StringComparison.Ordinal)); + Assert.Equal("Clean", cleanTarget.Attribute("BeforeTargets")?.Value); + var delete = Assert.Single(cleanTarget.Elements("Delete")); + Assert.Equal( + "$(TargetDir)Res\\MmoneyCoder.ini", + delete.Attribute("Files")?.Value); + + var rejectionTarget = Assert.Single( + project.Descendants("Target"), + element => string.Equals( + element.Attribute("Name")?.Value, + "RejectLegacyDatabaseIniFromPackagePayload", + StringComparison.Ordinal)); + Assert.Equal( + "_ComputeAppxPackagePayload", + rejectionTarget.Attribute("BeforeTargets")?.Value); + Assert.Single(rejectionTarget.Elements("Error")); + } + + [Fact] + public void LegacyRuntimeSourceRootIsOverrideableAndValidatedBeforeBuild() + { + var project = XDocument.Load(ProjectPath); + var sourceRoot = Assert.Single( + project.Descendants("LegacyRuntimeSourceRoot"), + element => element.Attribute("Condition") is not null); + + Assert.Equal("'$(LegacyRuntimeSourceRoot)' == ''", sourceRoot.Attribute("Condition")!.Value); + Assert.Contains("MBN_STOCK_N\\MBN_STOCK_N\\bin\\Debug", sourceRoot.Value, + StringComparison.Ordinal); + + var target = Assert.Single( + project.Descendants("Target"), + element => string.Equals( + element.Attribute("Name")?.Value, + "ValidateLegacyRuntimeAssets", + StringComparison.Ordinal)); + Assert.Equal("PrepareForBuild", target.Attribute("BeforeTargets")?.Value); + + var conditions = target.Elements("Error") + .Select(error => error.Attribute("Condition")?.Value) + .Where(condition => condition is not null) + .ToArray(); + string[] requiredSentinels = + [ + "$(LegacyRuntimeSourceRoot)", + "$(LegacyCutsSourceRoot)", + "$(LegacyResSourceRoot)", + "$(LegacyCutsSourceRoot)\\5001.t2s", + "$(LegacyResSourceRoot)\\MmoneyCoder.ini", + "$(LegacyResSourceRoot)\\종목.ini", + "$(LegacyResSourceRoot)\\업종_코스피.ini", + "$(LegacyResSourceRoot)\\업종_코스닥.ini", + "$(LegacyResSourceRoot)\\해외.ini", + "$(LegacyResSourceRoot)\\환율.ini", + "$(LegacyResSourceRoot)\\지수.ini" + ]; + + Assert.All(requiredSentinels, sentinel => + Assert.Contains(conditions, condition => + condition!.Contains(sentinel, StringComparison.Ordinal))); + + var databaseIniError = Assert.Single( + target.Elements("Error"), + error => (error.Attribute("Condition")?.Value ?? string.Empty) + .Contains("$(LegacyResSourceRoot)\\MmoneyCoder.ini", StringComparison.Ordinal)); + Assert.Contains( + "GenerateAppxPackageOnBuild", + databaseIniError.Attribute("Condition")?.Value, + StringComparison.Ordinal); + Assert.Contains( + "PublishAppxPackage", + databaseIniError.Attribute("Condition")?.Value, + StringComparison.Ordinal); + } + + [Fact] + public void RepositoryDoesNotContainCopiedLegacyRuntimeTrees() + { + Assert.False(Directory.Exists(Path.Combine(RepositoryRoot, "Cuts"))); + Assert.False(Directory.Exists(Path.Combine(RepositoryRoot, "Res"))); + Assert.False(Directory.Exists(Path.Combine( + RepositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", + "Cuts"))); + Assert.False(Directory.Exists(Path.Combine( + RepositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", + "Res"))); + } + + private static void AssertLinkedRuntimeTree( + IReadOnlyCollection contents, + string include, + string targetPrefix) + { + var content = Assert.Single( + contents, + element => string.Equals( + element.Attribute("Include")?.Value, + include, + StringComparison.Ordinal)); + + Assert.StartsWith(targetPrefix, content.Element("Link")?.Value, StringComparison.Ordinal); + Assert.StartsWith( + targetPrefix, + content.Element("TargetPath")?.Value, + StringComparison.Ordinal); + Assert.Equal("PreserveNewest", content.Element("CopyToOutputDirectory")?.Value); + Assert.Equal("PreserveNewest", content.Element("CopyToPublishDirectory")?.Value); + } + + private static string FindRepositoryRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "MBN_STOCK_WEBVIEW.sln"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Could not locate the repository root."); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeStockCutMenuNativeContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeStockCutMenuNativeContractTests.cs index fad4212..3ea8c11 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeStockCutMenuNativeContractTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeStockCutMenuNativeContractTests.cs @@ -38,32 +38,37 @@ public sealed class LegacyRuntimeStockCutMenuNativeContractTests } [Fact] - public void DatabaseInitializationErrorWinsOverTheSingleCutMenuFallbackWarning() + public void DatabaseInitializationErrorWinsOverCombinedRuntimeCatalogWarnings() { var errorBranch = MainWindow.IndexOf( "if (initializationError is not null)", StringComparison.Ordinal); var warningBranch = MainWindow.IndexOf( - "else if (cutMenuComposition.WarningMessage is { } cutMenuWarning)", + "cutMenuComposition.WarningMessage,", errorBranch, StringComparison.Ordinal); + var runtimeUiWarning = MainWindow.IndexOf( + "runtimeUiCatalogs.WarningMessage", + warningBranch, + StringComparison.Ordinal); var errorReport = MainWindow.IndexOf( "_controller.ReportError(initializationError);", errorBranch, StringComparison.Ordinal); var warningReport = MainWindow.IndexOf( - "_controller.ReportWarning(cutMenuWarning);", + "_controller.ReportWarning(string.Join(\" \", catalogWarnings));", warningBranch, StringComparison.Ordinal); Assert.True(errorBranch >= 0); Assert.True(errorReport > errorBranch); Assert.True(warningBranch > errorReport); + Assert.True(runtimeUiWarning > warningBranch); Assert.True(warningReport > warningBranch); Assert.Equal( warningReport, MainWindow.LastIndexOf( - "_controller.ReportWarning(cutMenuWarning);", + "_controller.ReportWarning(string.Join(\" \", catalogWarnings));", StringComparison.Ordinal)); } diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs index 946dd4d..f8e853c 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs @@ -62,6 +62,39 @@ public sealed class PlayoutOptionsLoaderTests 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() { diff --git a/tools/MBN_STOCK_WEBVIEW.DbSmoke/NxtThemeRestoreDbAudit.cs b/tools/MBN_STOCK_WEBVIEW.DbSmoke/NxtThemeRestoreDbAudit.cs index b7d0d02..2c6bef3 100644 --- a/tools/MBN_STOCK_WEBVIEW.DbSmoke/NxtThemeRestoreDbAudit.cs +++ b/tools/MBN_STOCK_WEBVIEW.DbSmoke/NxtThemeRestoreDbAudit.cs @@ -7,8 +7,6 @@ using MMoneyCoderSharp.Data; internal static class NxtThemeRestoreDbAudit { - private const string OriginalConfigurationPath = - @"C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\RES\MmoneyCoder.ini"; private const string ActiveRowsQueryName = "AUDIT_ACTIVE_NXT_THEME_ROWS"; private const string ActiveRowsSql = """ @@ -26,9 +24,14 @@ internal static class NxtThemeRestoreDbAudit internal static async Task RunAsync( IDataQueryExecutor executor, MariaDbDatabaseOptions currentOptions, + MariaDbEndpointReference referenceOptions, CancellationToken cancellationToken) { - await RunEndpointAuditAsync(executor, currentOptions, cancellationToken) + await RunEndpointAuditAsync( + executor, + currentOptions, + referenceOptions, + cancellationToken) .ConfigureAwait(false); const int maximumRows = 1_000; @@ -291,13 +294,18 @@ internal static class NxtThemeRestoreDbAudit private static async Task RunEndpointAuditAsync( IDataQueryExecutor executor, MariaDbDatabaseOptions currentOptions, + MariaDbEndpointReference referenceOptions, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(executor); ArgumentNullException.ThrowIfNull(currentOptions); + ArgumentNullException.ThrowIfNull(referenceOptions); cancellationToken.ThrowIfCancellationRequested(); - var original = ReadOriginalEndpoint(OriginalConfigurationPath); + var original = NormalizeEndpoint( + referenceOptions.Host, + referenceOptions.Port, + referenceOptions.Database); var current = NormalizeEndpoint( currentOptions.Host, currentOptions.Port, @@ -354,78 +362,6 @@ internal static class NxtThemeRestoreDbAudit $"serverMajorMinor={versionMajorMinor}"); } - private static string ReadOriginalEndpoint(string path) - { - if (!File.Exists(path)) - { - throw new InvalidDataException( - "The fixed read-only original MariaDB configuration is unavailable."); - } - - var source = new FileInfo(path); - if ((source.Attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 || - source.Length is < 1 or > 64 * 1024) - { - throw new InvalidDataException( - "The fixed read-only original MariaDB configuration failed its file boundary."); - } - - var inMariaSection = false; - string? connectionName = null; - foreach (var rawLine in File.ReadLines(path)) - { - var line = rawLine.Trim(); - if (line.StartsWith("[", StringComparison.Ordinal) && - line.EndsWith("]", StringComparison.Ordinal)) - { - inMariaSection = string.Equals(line, "[Maria]", StringComparison.Ordinal); - continue; - } - - if (!inMariaSection || line.Length == 0 || - line.StartsWith("//", StringComparison.Ordinal)) - { - continue; - } - - const string prefix = "ConnectionName="; - if (line.StartsWith(prefix, StringComparison.Ordinal)) - { - if (connectionName is not null) - { - throw new InvalidDataException( - "The original MariaDB endpoint is ambiguous."); - } - - connectionName = line[prefix.Length..]; - } - } - - if (connectionName is null) - { - throw new InvalidDataException( - "The original MariaDB endpoint is unavailable."); - } - - var slash = connectionName.LastIndexOf('/'); - var colon = connectionName.LastIndexOf(':', slash - 1); - if (slash <= 0 || colon <= 0 || colon >= slash - 1 || slash == connectionName.Length - 1 || - !int.TryParse( - connectionName.AsSpan(colon + 1, slash - colon - 1), - NumberStyles.None, - CultureInfo.InvariantCulture, - out var port)) - { - throw new InvalidDataException( - "The original MariaDB endpoint has an invalid closed shape."); - } - - return NormalizeEndpoint( - connectionName[..colon], - port, - connectionName[(slash + 1)..]); - } - private static string NormalizeEndpoint(string? host, int port, string? database) { var normalizedHost = (host ?? string.Empty) @@ -460,3 +396,15 @@ internal static class NxtThemeRestoreDbAudit string.Equals(column.ColumnName, name, StringComparison.Ordinal) && column.DataType == typeof(string); } + +internal sealed record MariaDbEndpointReference( + string? Host, + int Port, + string? Database) +{ + internal static MariaDbEndpointReference From(MariaDbDatabaseOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return new MariaDbEndpointReference(options.Host, options.Port, options.Database); + } +} diff --git a/tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs b/tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs index 309f553..e7a141b 100644 --- a/tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs +++ b/tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs @@ -25,11 +25,10 @@ Console.CancelKeyPress += (_, eventArgs) => cancellation.Cancel(); }; -var configurationPath = GetConfigurationPath(args); - try { - var runtime = DatabaseRuntime.CreateDefault(configurationPath); + var selection = CreateRuntimeSelection(args); + var runtime = selection.Runtime; var statuses = await runtime.HealthService.CheckAllAsync(cancellation.Token); foreach (var status in statuses) @@ -59,6 +58,7 @@ try await NxtThemeRestoreDbAudit.RunAsync( runtime.Executor, runtime.Options.MariaDb, + selection.ReferenceMariaDb, cancellation.Token); await VerifyLegacyComparisonImportAsync(runtime.Executor, cancellation.Token); @@ -131,23 +131,63 @@ static string FormatNullableBoolean(bool? value) => value switch static string FormatProviderErrorCode(int? value) => value?.ToString(CultureInfo.InvariantCulture) ?? "none"; -static string? GetConfigurationPath(string[] arguments) +static DatabaseSmokeRuntimeSelection CreateRuntimeSelection(string[] arguments) { if (arguments.Length == 0) { - return null; + return CreateJsonRuntimeSelection(configurationPath: null); } if (arguments.Length == 2 && arguments[0].Equals("--config", StringComparison.OrdinalIgnoreCase)) { - return arguments[1]; + return CreateJsonRuntimeSelection(arguments[1]); + } + + if (arguments.Length == 2 && + arguments[0].Equals("--legacy-runtime-root", StringComparison.OrdinalIgnoreCase)) + { + var root = Path.GetFullPath(arguments[1]); + var iniPath = Path.Combine(root, "Res", "MmoneyCoder.ini"); + if (!File.Exists(iniPath)) + { + throw new DatabaseConfigurationException( + "The legacy runtime root does not contain Res\\MmoneyCoder.ini."); + } + + // Keep the selected file's endpoint as the comparison reference before + // environment overrides are applied. Credentials never leave the + // loader-owned DatabaseOptions instance. + var selectedOptions = new LegacyIniDatabaseOptionsLoader(_ => null).Load(iniPath); + var runtime = DatabaseRuntime.CreateLegacyExecutableDefault( + root, + fallbackConfigurationPath: Path.Combine(root, "missing-database.json"), + legacyOverridePath: Path.Combine(root, "missing-legacy.ini")); + return new DatabaseSmokeRuntimeSelection( + runtime, + MariaDbEndpointReference.From(selectedOptions.MariaDb)); } throw new DatabaseConfigurationException( - "Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config |--manual-import-audit|--manual-import-fixture]"); + "Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config |--legacy-runtime-root |--manual-import-audit|--manual-import-fixture]"); } +static DatabaseSmokeRuntimeSelection CreateJsonRuntimeSelection(string? configurationPath) +{ + var configuredOptions = new DatabaseOptionsLoader(_ => null).Load(configurationPath); + var runtime = DatabaseRuntime.CreateDefault(configurationPath); + var configuredMariaDb = configuredOptions.MariaDb; + var reference = HasCompleteEndpointIdentity(configuredMariaDb) + ? MariaDbEndpointReference.From(configuredMariaDb) + : MariaDbEndpointReference.From(runtime.Options.MariaDb); + return new DatabaseSmokeRuntimeSelection(runtime, reference); +} + +static bool HasCompleteEndpointIdentity(MariaDbDatabaseOptions options) => + !string.IsNullOrWhiteSpace(options.Host) && + options.Port is >= 1 and <= 65_535 && + !string.IsNullOrWhiteSpace(options.Database); + static async Task VerifyLegacyComparisonImportAsync( IDataQueryExecutor executor, CancellationToken cancellationToken) @@ -711,3 +751,7 @@ static async Task VerifyTradingHaltSelectorAsync( Console.WriteLine( $"TradingHaltSelector: {result.Items.Count.ToString(CultureInfo.InvariantCulture)} rows"); } + +internal sealed record DatabaseSmokeRuntimeSelection( + DatabaseRuntime Runtime, + MariaDbEndpointReference ReferenceMariaDb);