feat: load legacy Cuts and Res runtime assets

This commit is contained in:
2026-07-18 14:32:04 +09:00
parent da8da06fac
commit 5eb4054120
32 changed files with 2893 additions and 120 deletions

1
.gitignore vendored
View File

@@ -53,6 +53,7 @@ artifacts/K3DInterop/
K3D*.lic K3D*.lic
Tornado*.lic Tornado*.lic
**/Cuts/ **/Cuts/
**/Res/
**/배경/ **/배경/
# Web tooling (if introduced later) # Web tooling (if introduced later)

42
AGENTS.md Normal file
View File

@@ -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.

View File

@@ -6,6 +6,8 @@
원본 이벤트를 코드 단위로 다시 검증하는 현재 작업 순서와 완료 범위는 원본 이벤트를 코드 단위로 다시 검증하는 현재 작업 순서와 완료 범위는
[동작 동등성 작업 기록](docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md)을 기준으로 판단합니다. [동작 동등성 작업 기록](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 - 기존 운영 흐름을 반영한 `PREPARE` / `TAKE IN` / `NEXT` / `TAKE OUT` UI
- `F8`, `Esc` 단축키와 원본 배경 선택용 F2의 안전 예약 - `F8`, `Esc` 단축키와 원본 배경 선택용 F2의 안전 예약
- 원본의 공급자 비의존 SQL/데이터 요청 71개를 .NET 8 Core 프로젝트로 이관 - 원본의 공급자 비의존 SQL/데이터 요청 71개를 .NET 8 Core 프로젝트로 이관
@@ -35,11 +38,17 @@
- 기본 `DryRun`, Test 전용 인스턴스·채널·씬 allowlist 및 Live 이중 승인 안전 게이트 - 기본 `DryRun`, Test 전용 인스턴스·채널·씬 allowlist 및 Live 이중 승인 안전 게이트
- MSIX 패키지 매니페스트와 x64 게시 프로필 - 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도 통과했다. - 현재 마이그레이션 소스에서 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 검증 범위다. - 원본 `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`에 저장합니다.
## 프로젝트 구조 ## 프로젝트 구조

View File

@@ -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 ```text
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\database.local.json %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을 유지하고, 환경 변수 오버라이드 또는 조직의 승인된 비밀 저장소를 사용할 수 있습니다. Oracle은 `sid` 또는 `serviceName` 중 정확히 하나를 설정합니다. 원본 MBN_STOCK_N은 SID descriptor를 사용했으므로 legacy 연결은 `sid`로 옮깁니다. 설정 파일은 평문 비밀번호를 포함할 수 있으므로 저장소·로그·첨부파일로 복사하지 않습니다. 운영 환경에서는 실행 계정의 파일 ACL을 유지하고, 환경 변수 오버라이드 또는 조직의 승인된 비밀 저장소를 사용할 수 있습니다.
JSON보다 환경 변수가 우선합니다. 선택된 INI 또는 JSON보다 환경 변수가 우선합니다.
```text ```text
MBN_STOCK_ORACLE_HOST / PORT / SID / SERVICE_NAME / USERNAME / PASSWORD 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 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개 국내 지수·해외 종목 카탈로그의 실제 행을 확인합니다. 출력에는 상태, 테이블 이름, 행 수만 포함합니다. 스모크는 양쪽 `SELECT 1`, Oracle 서버 버전, 코스피·코스닥·NXT·5개 국내 지수·해외 종목 카탈로그의 실제 행을 확인합니다. 출력에는 상태, 테이블 이름, 행 수만 포함합니다.
## MSIX ## MSIX

View File

@@ -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으로 확인되고 같은 개발 범위 안에 있으면 별도의 대화형 승인을
받지 않고 이어서 작업한다.

View File

@@ -1,5 +1,11 @@
# Visual Studio 2026 개발 Tornado Live 실행 # Visual Studio 2026 개발 Tornado Live 실행
> 2026-07-18: 현재 지정 개발 PC에서는 사용자가 Tornado2/PGM 마이그레이션 작업을
> 상시 허용했다. 아래 Git 제외 로컬 승인 파일은 이 개발 환경의 지속 설정으로 사용하며,
> 매 실행이나 명령마다 새 계획 해시·대화형 승인을 요청하지 않는다. 운영/방송 대상에는
> 이 예외를 적용하지 않으며 자세한 범위는
> [개발 환경 상시 권한](DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)을 따른다.
## 목적과 안전 기본 ## 목적과 안전 기본
이 문서는 개발 PC의 Visual Studio 2026에서 `MBN_STOCK_WEBVIEW.LegacyParityApp` 이 문서는 개발 PC의 Visual Studio 2026에서 `MBN_STOCK_WEBVIEW.LegacyParityApp`

View File

@@ -1,5 +1,9 @@
# MBN_STOCK_N 동작 동등성 작업 기록 # MBN_STOCK_N 동작 동등성 작업 기록
> 2026-07-18 이후 현재 개발 PC의 Tornado2/PGM 작업에는
> [개발 환경 상시 권한](DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)을 적용한다. 아래의
> 과거 회차별 승인 기록은 당시 증거이며 새 개발 작업의 반복 승인 요구로 재사용하지 않는다.
기준 원본은 `C:\Users\MD\source\repos\MBN_STOCK_N`이며 항상 읽기 전용으로 분석한다. 기준 원본은 `C:\Users\MD\source\repos\MBN_STOCK_N`이며 항상 읽기 전용으로 분석한다.
대상은 `MBN_STOCK_WEBVIEW.LegacyParityApp`이다. 화면 모양만 비슷하게 만드는 것이 아니라 대상은 `MBN_STOCK_WEBVIEW.LegacyParityApp`이다. 화면 모양만 비슷하게 만드는 것이 아니라
원본 이벤트의 입력, 상태 전이, DB 조회, K3D 호출 순서와 화면 피드백을 코드 단위로 대조한다. 원본 이벤트의 입력, 상태 전이, DB 조회, K3D 호출 순서와 화면 피드백을 코드 단위로 대조한다.

View File

@@ -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 추적 대상이 아니다.

View File

@@ -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가
명시적으로 차단해야 한다.

View File

@@ -10,7 +10,7 @@
- Oracle 및 MariaDB SQL/데이터 요청 계층 - Oracle 및 MariaDB SQL/데이터 요청 계층
- FarPoint Spread 4 플레이리스트/그리드 - 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 | | WinForms MainForm | WinUI 3 `MainWindow` + 로컬 WebView2 UI |
| FarPoint `FpSpread` 플레이리스트 | HTML table + 선택/드래그 정렬/이동/삭제 | | FarPoint `FpSpread` 플레이리스트 | HTML table + 선택/드래그 정렬/이동/삭제 |
| 실행 폴더 `Cuts`/`Res` | 읽기 전용 원본 `bin\Debug`를 빌드 입력으로 연결해 실행 출력과 MSIX에 비민감 자산 배치 |
| 플레이리스트 임시 저장 | WebView2 사용자 데이터 영역의 `localStorage` | | 플레이리스트 임시 저장 | WebView2 사용자 데이터 영역의 `localStorage` |
| Oracle `DC_LIST`/`PLAY_LIST` | 이름 있는 플레이리스트 생성·교체·불러오기·삭제와 fresh page preflight | | Oracle `DC_LIST`/`PLAY_LIST` | 이름 있는 플레이리스트 생성·교체·불러오기·삭제와 fresh page preflight |
| WinForms 단축키 | Web UI `F2` / `F8` / `Esc` 처리 | | WinForms 단축키 | Web UI `F2` / `F8` / `Esc` 처리 |

View File

@@ -38,10 +38,16 @@ SUBSTRING(SB_ITEM.SB_I_CODE, 2, 12) = v_all_stock.F_STOCK_CODE
`LegacyThemeSelectionService` `LegacyThemeSelectionService`
`LegacyNamedNxtThemeRestoreService`도 같은 prefix 제거 규칙, 같은 `LegacyNamedNxtThemeRestoreService`도 같은 prefix 제거 규칙, 같은
`F_STOP_GUBUN = 'N'` 조건을 사용한다. 원본 설정과 현재 runtime 설정의 `F_STOP_GUBUN = 'N'` 조건을 사용한다. 이 문서의 검증 당시에는 선택한 원본
host/port/database를 비밀값 없이 정규화해 비교한 결과 SHA-256과 값이 설정과 현재 runtime 설정의 host/port/database를 비밀값 없이 정규화해 비교한
동일했고, 실제 `DATABASE()`도 현재 runtime database와 일치했다. 서버는 결과 SHA-256과 값이 동일했고, 실제 `DATABASE()`도 현재 runtime database와
MariaDB 10.5 계열이다. 일치했다. 서버는 MariaDB 10.5 계열이다.
현재 스모크 도구는 머신 고정 경로를 다시 읽지 않는다. 기본/`--config` 실행은
선택한 JSON을 환경변수 적용 전에 읽은 endpoint를, `--legacy-runtime-root` 실행은
그 루트의 `Res\MmoneyCoder.ini`를 환경변수 적용 전에 읽은 endpoint를 비교
기준으로 명시 전달한다. 실제 연결에 적용된 endpoint와의 비교 결과만 SHA-256으로
출력하며 계정과 비밀번호는 비교 모델이나 출력에 포함하지 않는다.
따라서 다른 DB를 바라보는 마이그레이션 오류가 아니며, 현 데이터 상태에서는 따라서 다른 DB를 바라보는 마이그레이션 오류가 아니며, 현 데이터 상태에서는
원본 프로그램도 같은 `v_all_stock` 경로로 NXT 테마 구성종목을 얻지 못한다. 원본 프로그램도 같은 `v_all_stock` 경로로 NXT 테마 구성종목을 얻지 못한다.

View File

@@ -1,10 +1,14 @@
# Tornado/K3D x64 송출 운영 가이드 # Tornado/K3D x64 송출 운영 가이드
> 2026-07-18 개발 환경 변경: 현재 지정 개발 PC의 Tornado2/PGM 마이그레이션 검증은
> [개발 환경 상시 권한](DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)에 따라 회차별 대화형
> 승인을 다시 받지 않는다. 운영/방송 대상과 `OutcomeUnknown` 중단 규칙은 그대로다.
## 안전 기준 ## 안전 기준
기본 모드는 `DryRun`입니다. `DryRun`은 COM 객체를 만들거나 Tornado 출력에 명령을 보내지 않고 WebView 메시지, 큐, 상태 및 오류 표시 흐름만 검증합니다. 현재 방송 PROGRAM(PGM)에 연결된 Tornado 프로세스는 항상 **안전하지 않은 대상**으로 취급합니다. 프로세스 이름이나 실행 여부만으로 테스트 대상이라고 판단하지 않습니다. 기본 모드는 `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)를 따른다. 이 문서의 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 %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` 중 하나 | | `mode` | `Disabled`, `DryRun`, `Test`, `Live` 중 하나 |
| `host`, `port` | 테스트 또는 운영 승인을 받은 KTAP endpoint | | `host`, `port` | 테스트 또는 운영 승인을 받은 KTAP endpoint |
| `tcpMode`, `clientPort` | `KTAPConnect`의 전송 모드와 로컬 client port 인자. Test/Live는 유실 방지를 위해 `tcpMode: 1`만 허용 | | `tcpMode`, `clientPort` | `KTAPConnect`의 전송 모드와 로컬 client port 인자. Test/Live는 유실 방지를 위해 `tcpMode: 1`만 허용 |
| `sceneDirectory` | Test/Live에서 사용하는 외부 `.t2s` 루트의 절대 경로. `null`은 안전한 미설정 상태 | | `sceneDirectory` | Test/Live에서 사용하는 `.t2s` 루트의 절대 경로. 명시값이 우선하며, `null`이면 실행 파일 옆 `Cuts`가 존재할 때 그 경로를 사용하고 없으면 미설정 상태 |
| `outputChannel` | 확인된 전용 출력 채널. `null`은 안전한 미설정 상태 | | `outputChannel` | 확인된 전용 출력 채널. `null`은 안전한 미설정 상태 |
| `layoutIndex` | 씬 플레이어 layout 위치 | | `layoutIndex` | 씬 플레이어 layout 위치 |
| `legacySceneFadeDuration` | 원본 `ComboDi.SelectedIndex`에 대응하는 fade. 기본 인덱스 6(화면 7), 허용 범위 0~19(화면 표시 1~20) | | `legacySceneFadeDuration` | 원본 `ComboDi.SelectedIndex`에 대응하는 fade. 기본 인덱스 6(화면 7), 허용 범위 0~19(화면 표시 1~20) |

View File

@@ -1,5 +1,10 @@
# MBN_STOCK_WEBVIEW 송출 운영·검증 절차 # 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)를 따른다. 이 문서는 마이그레이션된 WebView → `IPlayoutEngine` → K3D/Tornado2 경로를 검증할 때의 승인, 실행, 감시, 장애 복구와 rollback 기준이다. 기존 [`PLAYOUT.md`](PLAYOUT.md)의 K3D 설치·해시 핀·격리 진단 규칙을 대체하지 않고, 35개 scene과 PageN 동등성 검증에 필요한 운영 절차를 추가한다. 화면/계약 구현과 운영 동등성의 최신 재감사 상태는 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)를 따른다.
## 현재 상태 ## 현재 상태

View File

@@ -44,10 +44,7 @@ public sealed class DatabaseOptionsLoader
{ {
var path = jsonPath ?? DefaultConfigurationPath; var path = jsonPath ?? DefaultConfigurationPath;
var options = LoadJson(path); var options = LoadJson(path);
ApplyEnvironmentOverrides(options); return ApplyEnvironmentOverridesAndValidate(options);
options.Normalize();
options.ValidateRuntimeSettings();
return options;
} }
public async Task<DatabaseOptions> LoadAsync( public async Task<DatabaseOptions> LoadAsync(
@@ -56,6 +53,12 @@ public sealed class DatabaseOptionsLoader
{ {
var path = jsonPath ?? DefaultConfigurationPath; var path = jsonPath ?? DefaultConfigurationPath;
var options = await LoadJsonAsync(path, cancellationToken).ConfigureAwait(false); var options = await LoadJsonAsync(path, cancellationToken).ConfigureAwait(false);
return ApplyEnvironmentOverridesAndValidate(options);
}
internal DatabaseOptions ApplyEnvironmentOverridesAndValidate(DatabaseOptions options)
{
ArgumentNullException.ThrowIfNull(options);
ApplyEnvironmentOverrides(options); ApplyEnvironmentOverrides(options);
options.Normalize(); options.Normalize();
options.ValidateRuntimeSettings(); options.ValidateRuntimeSettings();

View File

@@ -0,0 +1,287 @@
using System.Security;
using System.Text;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
/// <summary>
/// Reads the legacy executable-relative Res\MmoneyCoder.ini contract without
/// exposing any of its values through diagnostics or exception messages.
/// </summary>
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<string, string?>? 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<string, IReadOnlyDictionary<string, string>> Parse(
string text)
{
var sections = new Dictionary<string, Dictionary<string, string>>(
StringComparer.Ordinal);
Dictionary<string, string>? 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<string, string>(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<string, string>)pair.Value,
StringComparer.Ordinal);
}
private static DatabaseOptions CreateOptions(
IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> 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<string, string> 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("실행 폴더의 레거시 데이터베이스 설정을 읽을 수 없습니다.");
}

View File

@@ -30,6 +30,43 @@ public sealed class DatabaseRuntime
return Create(options); 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) public static DatabaseRuntime Create(DatabaseOptions options)
{ {
ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(options);

View File

@@ -111,6 +111,8 @@ internal static class LegacyComparisonWorkflowCatalog
public static IReadOnlyList<LegacyComparisonTarget> FixedTargets => FixedTargetList; public static IReadOnlyList<LegacyComparisonTarget> FixedTargets => FixedTargetList;
internal static IReadOnlyList<LegacyComparisonActionDefinition> Actions => ActionList;
public static LegacyComparisonTarget? GetFixedTarget(string targetId) => public static LegacyComparisonTarget? GetFixedTarget(string targetId) =>
targetId is not null && FixedById.TryGetValue(targetId, out var target) ? target : null; targetId is not null && FixedById.TryGetValue(targetId, out var target) ? target : null;
@@ -195,13 +197,31 @@ internal static class LegacyComparisonWorkflowCatalog
LegacyComparisonTarget? first, LegacyComparisonTarget? first,
LegacyComparisonTarget? second) 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<LegacyComparisonActionView> CreateActionViews(
LegacyComparisonTarget? first,
LegacyComparisonTarget? second,
IReadOnlyList<LegacyComparisonActionPlacement> 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); var compatibility = GetCompatibility(action, first, second);
return new LegacyComparisonActionView( return new LegacyComparisonActionView(
action.ActionId, action.ActionId,
action.Label, action.Label,
action.Section, placement.Section,
compatibility.IsAvailable, compatibility.IsAvailable,
compatibility.Reason); compatibility.Reason);
}).ToArray(); }).ToArray();

View File

@@ -11,6 +11,7 @@ public sealed class LegacyComparisonWorkflowController
{ {
private readonly ILegacyComparisonDataService _dataService; private readonly ILegacyComparisonDataService _dataService;
private readonly ILegacyComparisonPairStore _pairStore; private readonly ILegacyComparisonPairStore _pairStore;
private readonly LegacyComparisonUiLayout _uiLayout;
private List<SearchResultState> _domesticResults = []; private List<SearchResultState> _domesticResults = [];
private List<SearchResultState> _worldResults = []; private List<SearchResultState> _worldResults = [];
private List<SavedPairState> _savedPairs = []; private List<SavedPairState> _savedPairs = [];
@@ -38,10 +39,12 @@ public sealed class LegacyComparisonWorkflowController
public LegacyComparisonWorkflowController( public LegacyComparisonWorkflowController(
ILegacyComparisonDataService dataService, ILegacyComparisonDataService dataService,
ILegacyComparisonPairStore pairStore) ILegacyComparisonPairStore pairStore,
LegacyComparisonUiLayout? uiLayout = null)
{ {
_dataService = dataService ?? throw new ArgumentNullException(nameof(dataService)); _dataService = dataService ?? throw new ArgumentNullException(nameof(dataService));
_pairStore = pairStore ?? throw new ArgumentNullException(nameof(pairStore)); _pairStore = pairStore ?? throw new ArgumentNullException(nameof(pairStore));
_uiLayout = uiLayout ?? LegacyComparisonUiLayout.Default;
} }
public LegacyComparisonWorkflowSnapshot Current => CreateSnapshot(); public LegacyComparisonWorkflowSnapshot Current => CreateSnapshot();
@@ -606,7 +609,7 @@ public sealed class LegacyComparisonWorkflowController
result.Target, result.Target,
result.SelectionId)) result.SelectionId))
.ToArray(); .ToArray();
var fixedTargets = LegacyComparisonWorkflowCatalog.FixedTargets var fixedTargets = _uiLayout.FixedTargets
.Select(target => LegacyComparisonWorkflowCatalog.ToView(target, target.TargetId)) .Select(target => LegacyComparisonWorkflowCatalog.ToView(target, target.TargetId))
.ToArray(); .ToArray();
var savedPairs = _savedPairs.Select((pair, index) => var savedPairs = _savedPairs.Select((pair, index) =>
@@ -645,7 +648,7 @@ public sealed class LegacyComparisonWorkflowController
: LegacyComparisonWorkflowCatalog.ToView(_secondTarget, "comparison-draft-second"), : LegacyComparisonWorkflowCatalog.ToView(_secondTarget, "comparison-draft-second"),
Array.AsReadOnly(savedPairs), Array.AsReadOnly(savedPairs),
_selectedPairId, _selectedPairId,
LegacyComparisonWorkflowCatalog.CreateActionViews( _uiLayout.CreateActionViews(
selectedPair?.First, selectedPair?.First,
selectedPair?.Second), selectedPair?.Second),
_statusMessage, _statusMessage,

View File

@@ -259,6 +259,35 @@ public sealed class LegacyFixedActionCatalog
return RuntimeAssets[market]; return RuntimeAssets[market];
} }
internal LegacyFixedActionCatalog WithRuntimeOrder(
IReadOnlyDictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedAction>> actionsByMarket)
{
ArgumentNullException.ThrowIfNull(actionsByMarket);
var ordered = new List<LegacyFixedAction>(Actions.Count);
foreach (var market in Enum.GetValues<LegacyFixedMarket>())
{
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( public LegacyMaterializedFixedAction Materialize(
string actionId, string actionId,
DateTimeOffset localNow) DateTimeOffset localNow)

View File

@@ -70,6 +70,7 @@ public sealed class LegacyIndustrySelectionWorkflow
private readonly IIndustrySelectionService _selectionService; private readonly IIndustrySelectionService _selectionService;
private readonly TimeProvider _timeProvider; private readonly TimeProvider _timeProvider;
private readonly LegacyIndustryActionLayout _actionLayout;
private IReadOnlyList<IndustrySelection> _industries = NoIndustries; private IReadOnlyList<IndustrySelection> _industries = NoIndustries;
private IReadOnlyList<LegacyIndustryActionDefinition> _actions = NoActions; private IReadOnlyList<LegacyIndustryActionDefinition> _actions = NoActions;
private IndustryMarket? _market; private IndustryMarket? _market;
@@ -84,11 +85,13 @@ public sealed class LegacyIndustrySelectionWorkflow
public LegacyIndustrySelectionWorkflow( public LegacyIndustrySelectionWorkflow(
IIndustrySelectionService selectionService, IIndustrySelectionService selectionService,
TimeProvider? timeProvider = null) TimeProvider? timeProvider = null,
LegacyIndustryActionLayout? actionLayout = null)
{ {
_selectionService = selectionService ?? _selectionService = selectionService ??
throw new ArgumentNullException(nameof(selectionService)); throw new ArgumentNullException(nameof(selectionService));
_timeProvider = timeProvider ?? TimeProvider.System; _timeProvider = timeProvider ?? TimeProvider.System;
_actionLayout = actionLayout ?? LegacyIndustryActionLayout.Default;
} }
public LegacyIndustrySelectionSnapshot Current => CreateSnapshot(); public LegacyIndustrySelectionSnapshot Current => CreateSnapshot();
@@ -104,7 +107,7 @@ public sealed class LegacyIndustrySelectionWorkflow
Interlocked.Increment(ref _loadGeneration); Interlocked.Increment(ref _loadGeneration);
_market = market; _market = market;
_industries = NoIndustries; _industries = NoIndustries;
_actions = LegacyIndustryActionCatalog.GetActions(market); _actions = _actionLayout.GetActions(market);
_selectedIndex = null; _selectedIndex = null;
_selectedIndustry = null; _selectedIndustry = null;
_firstIndustry = null; _firstIndustry = null;
@@ -146,7 +149,7 @@ public sealed class LegacyIndustrySelectionWorkflow
_market = market; _market = market;
_industries = normalizedRows; _industries = normalizedRows;
_actions = LegacyIndustryActionCatalog.GetActions(market); _actions = _actionLayout.GetActions(market);
_selectedIndustry = Reconcile(previousSelected, normalizedRows); _selectedIndustry = Reconcile(previousSelected, normalizedRows);
_selectedIndex = _selectedIndustry is null _selectedIndex = _selectedIndustry is null
? null ? null

View File

@@ -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<IndustryMarket,
IReadOnlyList<LegacyIndustryActionDefinition>> _actions;
private LegacyIndustryActionLayout(
IReadOnlyDictionary<IndustryMarket,
IReadOnlyList<LegacyIndustryActionDefinition>> actions)
{
_actions = actions;
}
public static LegacyIndustryActionLayout Default { get; } = Create(
LegacyIndustryActionCatalog.GetActions(IndustryMarket.Kospi),
LegacyIndustryActionCatalog.GetActions(IndustryMarket.Kosdaq));
public IReadOnlyList<LegacyIndustryActionDefinition> GetActions(IndustryMarket market) =>
_actions.TryGetValue(market, out var actions)
? actions
: throw new ArgumentOutOfRangeException(
nameof(market), market, "Unsupported industry market.");
internal static LegacyIndustryActionLayout Create(
IReadOnlyList<LegacyIndustryActionDefinition> kospi,
IReadOnlyList<LegacyIndustryActionDefinition> kosdaq)
{
Validate(IndustryMarket.Kospi, kospi);
Validate(IndustryMarket.Kosdaq, kosdaq);
return new LegacyIndustryActionLayout(
new ReadOnlyDictionary<IndustryMarket,
IReadOnlyList<LegacyIndustryActionDefinition>>(
new Dictionary<IndustryMarket,
IReadOnlyList<LegacyIndustryActionDefinition>>
{
[IndustryMarket.Kospi] = Array.AsReadOnly(kospi.ToArray()),
[IndustryMarket.Kosdaq] = Array.AsReadOnly(kosdaq.ToArray())
}));
}
private static void Validate(
IndustryMarket market,
IReadOnlyList<LegacyIndustryActionDefinition> 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<string> _fixedTargetIds;
private readonly IReadOnlyList<LegacyComparisonActionPlacement> _actionPlacements;
private LegacyComparisonUiLayout(
IReadOnlyList<string> fixedTargetIds,
IReadOnlyList<LegacyComparisonActionPlacement> 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<LegacyComparisonTarget> FixedTargets => _fixedTargetIds
.Select(id => LegacyComparisonWorkflowCatalog.GetFixedTarget(id) ??
throw new InvalidOperationException(
"The runtime comparison layout contains an unknown target."))
.ToArray();
internal IReadOnlyList<LegacyComparisonActionView> CreateActionViews(
LegacyComparisonTarget? first,
LegacyComparisonTarget? second) =>
LegacyComparisonWorkflowCatalog.CreateActionViews(
first,
second,
_actionPlacements);
internal static LegacyComparisonUiLayout Create(
IEnumerable<string> fixedTargetIds,
IEnumerable<LegacyComparisonActionPlacement> 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<LegacyRuntimeUiCatalogWarning> Warnings)
{
public string? WarningMessage => Warnings.Count == 0
? null
: "런타임 UI INI 일부를 읽지 못해 검증된 내장 목록을 사용합니다: " +
string.Join(", ", Warnings.Select(warning =>
$"{warning.FileName} ({warning.Failure})"));
}
/// <summary>
/// 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.
/// </summary>
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<LegacyRuntimeUiCatalogWarning>();
if (!TryCreateResourceDirectory(baseDirectory, out var resourceDirectory))
{
warnings.Add(new LegacyRuntimeUiCatalogWarning(
"Res",
LegacyRuntimeUiCatalogFailure.InvalidBaseDirectory));
return CreateFallback(warnings);
}
var fixedCatalog = LegacyFixedActionCatalog.Default;
var fixedOrders = new Dictionary<LegacyFixedMarket,
IReadOnlyList<LegacyFixedAction>>();
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<LegacyRuntimeUiCatalogWarning> warnings) => new(
LegacyFixedActionCatalog.Default,
LegacyIndustryActionLayout.Default,
LegacyComparisonUiLayout.Default,
warnings);
private static void LoadFixed(
string resourceDirectory,
string fileName,
LegacyFixedMarket market,
LegacyFixedActionCatalog catalog,
IDictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedAction>> orders,
ICollection<LegacyRuntimeUiCatalogWarning> 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<LegacyIndustryActionDefinition> LoadIndustry(
string resourceDirectory,
string fileName,
IndustryMarket market,
ICollection<LegacyRuntimeUiCatalogWarning> 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<LegacyRuntimeUiCatalogWarning> 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<string>(targetByLabel.Count);
var targetLabels = new HashSet<string>(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<LegacyComparisonActionPlacement>(actions.Count);
var actionIds = new HashSet<string>(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<T>(
IniDocument document,
IReadOnlyList<T> expected,
Func<T, string> sectionSelector,
Func<T, string> labelSelector,
out IReadOnlyList<T> ordered,
out LegacyRuntimeUiCatalogFailure failure)
{
ordered = Array.Empty<T>();
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<EntryKey, T>();
var expectedCounts = new Dictionary<EntryPair, int>();
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<T>(expected.Count);
var actualCounts = new Dictionary<EntryPair, int>();
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<IniSection>();
var sectionNames = new HashSet<string>(StringComparer.Ordinal);
List<string>? 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<string> Rows);
private sealed record IniDocument(IReadOnlyList<IniSection> Sections)
{
public static IniDocument Empty { get; } = new(Array.Empty<IniSection>());
}
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);
}

View File

@@ -23,9 +23,55 @@
<ApplicationDisplayVersion>0.1.0</ApplicationDisplayVersion> <ApplicationDisplayVersion>0.1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion> <ApplicationVersion>1</ApplicationVersion>
<Version>0.1.0</Version> <Version>0.1.0</Version>
<!--
Keep the legacy runtime assets outside this repository while reproducing the
original executable-folder layout. Override this property at build time when
the read-only MBN_STOCK_N checkout is in a different location:
/p:LegacyRuntimeSourceRoot="D:\path\to\MBN_STOCK_N\bin\Debug"
-->
<LegacyRuntimeSourceRoot Condition="'$(LegacyRuntimeSourceRoot)' == ''">$(MSBuildProjectDirectory)\..\..\..\MBN_STOCK_N\MBN_STOCK_N\bin\Debug</LegacyRuntimeSourceRoot>
<LegacyRuntimeSourceRoot>$([System.IO.Path]::GetFullPath('$(LegacyRuntimeSourceRoot)'))</LegacyRuntimeSourceRoot>
<LegacyCutsSourceRoot>$(LegacyRuntimeSourceRoot)\Cuts</LegacyCutsSourceRoot>
<LegacyResSourceRoot>$(LegacyRuntimeSourceRoot)\Res</LegacyResSourceRoot>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<!-- Explicit allowlist: these legacy Res assets contain UI/menu data only. -->
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\a.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\aa.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\dot.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\Find_16x16.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\Find_32x32.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\green.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\Grow.bmp" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\logo.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\MmoneyCoder.ico" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\MoveDown_16x16.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\MoveUp_16x16.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\P.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\pie.bmp" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\Preview.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\pro#00083.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\profit.bmp" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\red.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\sample_1.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\sell.bmp" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\setup.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\t.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\VRIPNG.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\기타.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\업종.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\업종_코스닥.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\업종_코스피.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\종목.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\종목_0111.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\종목비교.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\지수.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\지수_0318.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\지수_250401.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\해외.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\환율.ini" />
<Compile Include="..\..\LegacySceneRuntimeFactory.cs" <Compile Include="..\..\LegacySceneRuntimeFactory.cs"
Link="Runtime\LegacySceneRuntimeFactory.cs" /> Link="Runtime\LegacySceneRuntimeFactory.cs" />
<Content Include="..\..\Assets\LockScreenLogo.scale-200.png" Link="Assets\LockScreenLogo.scale-200.png" /> <Content Include="..\..\Assets\LockScreenLogo.scale-200.png" Link="Assets\LockScreenLogo.scale-200.png" />
@@ -39,9 +85,99 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory> <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content> </Content>
<Content Include="$(LegacyCutsSourceRoot)\**\*">
<Link>Cuts\%(RecursiveDir)%(Filename)%(Extension)</Link>
<TargetPath>Cuts\%(RecursiveDir)%(Filename)%(Extension)</TargetPath>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="@(LegacyPackagedResAsset)">
<Link>Res\%(Filename)%(Extension)</Link>
<TargetPath>Res\%(Filename)%(Extension)</TargetPath>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<!--
The active legacy DB INI contains credentials. It is available beside the
executable for local F5/unpackaged development only and is never MSIX Content.
Historical copies, backups, archives and afiedt.buf.txt are not included at all.
-->
<Manifest Include="$(ApplicationManifest)" /> <Manifest Include="$(ApplicationManifest)" />
</ItemGroup> </ItemGroup>
<Target Name="ValidateLegacyRuntimeAssets" BeforeTargets="PrepareForBuild">
<Error Condition="!Exists('$(LegacyRuntimeSourceRoot)')"
Text="Legacy runtime source root was not found: $(LegacyRuntimeSourceRoot). Set /p:LegacyRuntimeSourceRoot to the read-only MBN_STOCK_N bin\Debug directory." />
<Error Condition="!Exists('$(LegacyCutsSourceRoot)')"
Text="Legacy Cuts source directory was not found: $(LegacyCutsSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)')"
Text="Legacy Res source directory was not found: $(LegacyResSourceRoot)." />
<Error Condition="!Exists('$(LegacyCutsSourceRoot)\5001.t2s')"
Text="The required legacy scene file Cuts\5001.t2s is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="'$(GenerateAppxPackageOnBuild)' != 'true' and
'$(PublishAppxPackage)' != 'true' and
!Exists('$(LegacyResSourceRoot)\MmoneyCoder.ini')"
Text="The required legacy database settings file Res\MmoneyCoder.ini is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\종목.ini')"
Text="The required legacy stock menu file Res\종목.ini is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\업종_코스피.ini')"
Text="The required legacy KOSPI industry menu file Res\업종_코스피.ini is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\업종_코스닥.ini')"
Text="The required legacy KOSDAQ industry menu file Res\업종_코스닥.ini is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\해외.ini')"
Text="The required legacy overseas menu file Res\해외.ini is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\환율.ini')"
Text="The required legacy exchange menu file Res\환율.ini is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\지수.ini')"
Text="The required legacy index menu file Res\지수.ini is missing from $(LegacyRuntimeSourceRoot)." />
</Target>
<!--
Do not declare the credential-bearing INI as Content, None, or another item
harvested by PackagingOutputs. The MSIX tooling harvests every
CopyToOutputDirectory item into its loose-package recipe, even when
GenerateAppxPackageOnBuild is false. This post-build copy provides the original
executable-relative layout only for ordinary local output. The CPS-only
up-to-date items below are not package payload candidates.
-->
<ItemGroup>
<!-- These CPS-only items participate in Visual Studio's fast up-to-date check. -->
<UpToDateCheckInput Include="$(LegacyResSourceRoot)\MmoneyCoder.ini" />
<UpToDateCheckBuilt Include="$(TargetDir)Res\MmoneyCoder.ini" />
</ItemGroup>
<Target Name="CopyLegacyDatabaseIniToLocalOutput"
AfterTargets="Build"
Inputs="$(LegacyResSourceRoot)\MmoneyCoder.ini"
Outputs="$(TargetDir)Res\MmoneyCoder.ini"
Condition="'$(GenerateAppxPackageOnBuild)' != 'true' and '$(PublishAppxPackage)' != 'true'">
<MakeDir Directories="$(TargetDir)Res" />
<Copy SourceFiles="$(LegacyResSourceRoot)\MmoneyCoder.ini"
DestinationFiles="$(TargetDir)Res\MmoneyCoder.ini"
SkipUnchangedFiles="true" />
<ItemGroup>
<FileWrites Include="$(TargetDir)Res\MmoneyCoder.ini" />
</ItemGroup>
</Target>
<!-- The credential-bearing local development copy must not survive Clean. -->
<Target Name="RemoveLegacyDatabaseIniFromLocalOutput"
BeforeTargets="Clean">
<Delete Files="$(TargetDir)Res\MmoneyCoder.ini"
Condition="Exists('$(TargetDir)Res\MmoneyCoder.ini')" />
</Target>
<Target Name="RejectLegacyDatabaseIniFromPackagePayload"
BeforeTargets="_ComputeAppxPackagePayload">
<ItemGroup>
<_ForbiddenLegacyDatabasePackagePayload Include="@(PackagingOutputs)"
Condition="'%(PackagingOutputs.TargetPath)' == 'Res\MmoneyCoder.ini' or
'%(PackagingOutputs.TargetPath)' == 'Res/MmoneyCoder.ini'" />
</ItemGroup>
<Error Condition="'@(_ForbiddenLegacyDatabasePackagePayload)' != ''"
Text="Credential-bearing Res\MmoneyCoder.ini must never enter an MSIX payload or package recipe." />
</Target>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" /> <PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3967.48" /> <PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3967.48" />

View File

@@ -57,6 +57,8 @@ public sealed partial class MainWindow : Window
var cutMenuComposition = LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates( var cutMenuComposition = LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates(
runtimeCutMenuOverridePath, runtimeCutMenuOverridePath,
AppContext.BaseDirectory); AppContext.BaseDirectory);
var runtimeUiCatalogs = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory(
AppContext.BaseDirectory);
ILegacyStockLookup stockLookup; ILegacyStockLookup stockLookup;
IIndustrySelectionService industrySelectionService; IIndustrySelectionService industrySelectionService;
@@ -76,7 +78,8 @@ public sealed partial class MainWindow : Window
string? initializationError = null; string? initializationError = null;
try try
{ {
_databaseRuntime = DatabaseRuntime.CreateDefault(); _databaseRuntime = DatabaseRuntime.CreateLegacyExecutableDefault(
AppContext.BaseDirectory);
DataQueryExecutor.Configure(_databaseRuntime.Executor); DataQueryExecutor.Configure(_databaseRuntime.Executor);
var databaseMutationAuthorization = var databaseMutationAuthorization =
new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization); new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization);
@@ -163,7 +166,9 @@ public sealed partial class MainWindow : Window
DataQueryExecutor.Reset(); DataQueryExecutor.Reset();
} }
_industryWorkflow = new LegacyIndustrySelectionWorkflow(industrySelectionService); _industryWorkflow = new LegacyIndustrySelectionWorkflow(
industrySelectionService,
actionLayout: runtimeUiCatalogs.IndustryLayout);
var overseasWorkflow = new LegacyOverseasSelectionWorkflow( var overseasWorkflow = new LegacyOverseasSelectionWorkflow(
overseasIndustrySearchService, overseasIndustrySearchService,
overseasStockSearchService); overseasStockSearchService);
@@ -177,6 +182,7 @@ public sealed partial class MainWindow : Window
var manualListsWorkflow = CreateManualListsWorkflow(stockLookup); var manualListsWorkflow = CreateManualListsWorkflow(stockLookup);
_controller = new LegacyOperatorController( _controller = new LegacyOperatorController(
stockLookup, stockLookup,
fixedCatalog: runtimeUiCatalogs.FixedCatalog,
industryWorkflow: _industryWorkflow, industryWorkflow: _industryWorkflow,
themeWorkflow: new LegacyThemeWorkflow(themeSelectionService), themeWorkflow: new LegacyThemeWorkflow(themeSelectionService),
expertWorkflow: new LegacyExpertWorkflowController(expertSelectionService), expertWorkflow: new LegacyExpertWorkflowController(expertSelectionService),
@@ -184,7 +190,8 @@ public sealed partial class MainWindow : Window
tradingHaltSelectionService), tradingHaltSelectionService),
comparisonWorkflow: new LegacyComparisonWorkflowController( comparisonWorkflow: new LegacyComparisonWorkflowController(
new LegacyComparisonDataService(stockLookup, worldStockSearchService), new LegacyComparisonDataService(stockLookup, worldStockSearchService),
comparisonStore), comparisonStore,
runtimeUiCatalogs.ComparisonLayout),
manualFinancialWorkflow: new LegacyManualFinancialWorkflow( manualFinancialWorkflow: new LegacyManualFinancialWorkflow(
manualFinancialService, manualFinancialService,
manualFinancialStockSearchService), manualFinancialStockSearchService),
@@ -205,9 +212,15 @@ public sealed partial class MainWindow : Window
{ {
_controller.ReportError(initializationError); _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(); InitializePlayoutRuntime();

View File

@@ -23,6 +23,7 @@ public static class PlayoutOptionsLoader
{ {
var options = LoadJson(path ?? DefaultPath); var options = LoadJson(path ?? DefaultPath);
ApplyEnvironment(options); ApplyEnvironment(options);
ApplyExecutableAssetDefaults(options, AppContext.BaseDirectory);
return options; return options;
} }
@@ -42,6 +43,36 @@ public static class PlayoutOptionsLoader
RequiredLiveAuthorizationValue, RequiredLiveAuthorizationValue,
StringComparison.Ordinal); 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) private static PlayoutOptions LoadJson(string path)
{ {
if (!File.Exists(path)) if (!File.Exists(path))

View File

@@ -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<string, string>(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<DatabaseConfigurationException>(
() => 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);
}
}

View File

@@ -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<LegacyFixedMarket>()
.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<LegacyFixedMarket>())
{
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<string> { "[지수]" };
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<LegacyFixedMarket, LegacyFixedAction[]>? fixedOrders = null,
IReadOnlyList<LegacyIndustryActionDefinition>? kospi = null,
IReadOnlyList<LegacyIndustryActionDefinition>? kosdaq = null,
IReadOnlyList<LegacyComparisonTarget>? targets = null,
IReadOnlyList<LegacyComparisonActionDefinition>? directActions = null,
IReadOnlyList<LegacyComparisonActionDefinition>? returnActions = null,
bool returnSectionFirst = false)
{
Directory.CreateDirectory(System.IO.Path.Combine(baseDirectory, "Res"));
foreach (var market in Enum.GetValues<LegacyFixedMarket>())
{
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<LegacyFixedAction> actions,
Func<LegacyFixedAction, int, string>? labelSelector = null,
bool appendDuplicate = false)
{
var lines = new List<string>();
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<LegacyFixedAction> FlattenForIni(
IEnumerable<LegacyFixedAction> actions) =>
actions.GroupBy(action => action.Section, StringComparer.Ordinal)
.SelectMany(group => group);
private static void WriteIndustryFile(
string baseDirectory,
IndustryMarket market,
IReadOnlyList<LegacyIndustryActionDefinition> 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<LegacyComparisonTarget> targets,
IReadOnlyList<LegacyComparisonActionDefinition> directActions,
IReadOnlyList<LegacyComparisonActionDefinition> returnActions,
bool returnSectionFirst)
{
var lines = new List<string> { "[지수]" };
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<string> 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<TestIniSection> ReadTestIni(
string baseDirectory,
string fileName)
{
var sections = new List<TestIniSection>();
List<string>? 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<IReadOnlyList<IndustrySelection>> GetAsync(
IndustryMarket market,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<IndustrySelection>>(
Array.Empty<IndustrySelection>());
}
private sealed class EmptyComparisonDataService : ILegacyComparisonDataService
{
public Task<IReadOnlyList<LegacyComparisonTarget>> SearchDomesticAsync(
string query,
int maximumResults,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<LegacyComparisonTarget>>(
Array.Empty<LegacyComparisonTarget>());
public Task<IReadOnlyList<LegacyComparisonTarget>> SearchWorldAsync(
string query,
int maximumResults,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<LegacyComparisonTarget>>(
Array.Empty<LegacyComparisonTarget>());
public Task<LegacyComparisonTarget?> ResolveStoredTargetAsync(
LegacyComparisonStoredTargetReference target,
CancellationToken cancellationToken = default) =>
Task.FromResult<LegacyComparisonTarget?>(null);
}
private sealed class EmptyComparisonPairStore : ILegacyComparisonPairStore
{
public Task<byte[]?> ReadCp949Async(
CancellationToken cancellationToken = default) =>
Task.FromResult<byte[]?>(null);
public Task WriteCp949Async(
IReadOnlyList<LegacyComparisonPairWriteRow> 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<string> Rows);
}

View File

@@ -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.");
}
}

View File

@@ -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<XElement> 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.");
}
}

View File

@@ -38,32 +38,37 @@ public sealed class LegacyRuntimeStockCutMenuNativeContractTests
} }
[Fact] [Fact]
public void DatabaseInitializationErrorWinsOverTheSingleCutMenuFallbackWarning() public void DatabaseInitializationErrorWinsOverCombinedRuntimeCatalogWarnings()
{ {
var errorBranch = MainWindow.IndexOf( var errorBranch = MainWindow.IndexOf(
"if (initializationError is not null)", "if (initializationError is not null)",
StringComparison.Ordinal); StringComparison.Ordinal);
var warningBranch = MainWindow.IndexOf( var warningBranch = MainWindow.IndexOf(
"else if (cutMenuComposition.WarningMessage is { } cutMenuWarning)", "cutMenuComposition.WarningMessage,",
errorBranch, errorBranch,
StringComparison.Ordinal); StringComparison.Ordinal);
var runtimeUiWarning = MainWindow.IndexOf(
"runtimeUiCatalogs.WarningMessage",
warningBranch,
StringComparison.Ordinal);
var errorReport = MainWindow.IndexOf( var errorReport = MainWindow.IndexOf(
"_controller.ReportError(initializationError);", "_controller.ReportError(initializationError);",
errorBranch, errorBranch,
StringComparison.Ordinal); StringComparison.Ordinal);
var warningReport = MainWindow.IndexOf( var warningReport = MainWindow.IndexOf(
"_controller.ReportWarning(cutMenuWarning);", "_controller.ReportWarning(string.Join(\" \", catalogWarnings));",
warningBranch, warningBranch,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.True(errorBranch >= 0); Assert.True(errorBranch >= 0);
Assert.True(errorReport > errorBranch); Assert.True(errorReport > errorBranch);
Assert.True(warningBranch > errorReport); Assert.True(warningBranch > errorReport);
Assert.True(runtimeUiWarning > warningBranch);
Assert.True(warningReport > warningBranch); Assert.True(warningReport > warningBranch);
Assert.Equal( Assert.Equal(
warningReport, warningReport,
MainWindow.LastIndexOf( MainWindow.LastIndexOf(
"_controller.ReportWarning(cutMenuWarning);", "_controller.ReportWarning(string.Join(\" \", catalogWarnings));",
StringComparison.Ordinal)); StringComparison.Ordinal));
} }

View File

@@ -62,6 +62,39 @@ public sealed class PlayoutOptionsLoaderTests
Assert.Null(options.MaximumAutomaticRefreshesPerTakeIn); 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] [Fact]
public void Load_EnvironmentOverridesJson_ExceptFileOnlySafetyGates() public void Load_EnvironmentOverridesJson_ExceptFileOnlySafetyGates()
{ {

View File

@@ -7,8 +7,6 @@ using MMoneyCoderSharp.Data;
internal static class NxtThemeRestoreDbAudit 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 ActiveRowsQueryName = "AUDIT_ACTIVE_NXT_THEME_ROWS";
private const string ActiveRowsSql = """ private const string ActiveRowsSql = """
@@ -26,9 +24,14 @@ internal static class NxtThemeRestoreDbAudit
internal static async Task RunAsync( internal static async Task RunAsync(
IDataQueryExecutor executor, IDataQueryExecutor executor,
MariaDbDatabaseOptions currentOptions, MariaDbDatabaseOptions currentOptions,
MariaDbEndpointReference referenceOptions,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await RunEndpointAuditAsync(executor, currentOptions, cancellationToken) await RunEndpointAuditAsync(
executor,
currentOptions,
referenceOptions,
cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
const int maximumRows = 1_000; const int maximumRows = 1_000;
@@ -291,13 +294,18 @@ internal static class NxtThemeRestoreDbAudit
private static async Task RunEndpointAuditAsync( private static async Task RunEndpointAuditAsync(
IDataQueryExecutor executor, IDataQueryExecutor executor,
MariaDbDatabaseOptions currentOptions, MariaDbDatabaseOptions currentOptions,
MariaDbEndpointReference referenceOptions,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
ArgumentNullException.ThrowIfNull(executor); ArgumentNullException.ThrowIfNull(executor);
ArgumentNullException.ThrowIfNull(currentOptions); ArgumentNullException.ThrowIfNull(currentOptions);
ArgumentNullException.ThrowIfNull(referenceOptions);
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var original = ReadOriginalEndpoint(OriginalConfigurationPath); var original = NormalizeEndpoint(
referenceOptions.Host,
referenceOptions.Port,
referenceOptions.Database);
var current = NormalizeEndpoint( var current = NormalizeEndpoint(
currentOptions.Host, currentOptions.Host,
currentOptions.Port, currentOptions.Port,
@@ -354,78 +362,6 @@ internal static class NxtThemeRestoreDbAudit
$"serverMajorMinor={versionMajorMinor}"); $"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) private static string NormalizeEndpoint(string? host, int port, string? database)
{ {
var normalizedHost = (host ?? string.Empty) var normalizedHost = (host ?? string.Empty)
@@ -460,3 +396,15 @@ internal static class NxtThemeRestoreDbAudit
string.Equals(column.ColumnName, name, StringComparison.Ordinal) && string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
column.DataType == typeof(string); 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);
}
}

View File

@@ -25,11 +25,10 @@ Console.CancelKeyPress += (_, eventArgs) =>
cancellation.Cancel(); cancellation.Cancel();
}; };
var configurationPath = GetConfigurationPath(args);
try try
{ {
var runtime = DatabaseRuntime.CreateDefault(configurationPath); var selection = CreateRuntimeSelection(args);
var runtime = selection.Runtime;
var statuses = await runtime.HealthService.CheckAllAsync(cancellation.Token); var statuses = await runtime.HealthService.CheckAllAsync(cancellation.Token);
foreach (var status in statuses) foreach (var status in statuses)
@@ -59,6 +58,7 @@ try
await NxtThemeRestoreDbAudit.RunAsync( await NxtThemeRestoreDbAudit.RunAsync(
runtime.Executor, runtime.Executor,
runtime.Options.MariaDb, runtime.Options.MariaDb,
selection.ReferenceMariaDb,
cancellation.Token); cancellation.Token);
await VerifyLegacyComparisonImportAsync(runtime.Executor, cancellation.Token); await VerifyLegacyComparisonImportAsync(runtime.Executor, cancellation.Token);
@@ -131,23 +131,63 @@ static string FormatNullableBoolean(bool? value) => value switch
static string FormatProviderErrorCode(int? value) => static string FormatProviderErrorCode(int? value) =>
value?.ToString(CultureInfo.InvariantCulture) ?? "none"; value?.ToString(CultureInfo.InvariantCulture) ?? "none";
static string? GetConfigurationPath(string[] arguments) static DatabaseSmokeRuntimeSelection CreateRuntimeSelection(string[] arguments)
{ {
if (arguments.Length == 0) if (arguments.Length == 0)
{ {
return null; return CreateJsonRuntimeSelection(configurationPath: null);
} }
if (arguments.Length == 2 && if (arguments.Length == 2 &&
arguments[0].Equals("--config", StringComparison.OrdinalIgnoreCase)) 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( throw new DatabaseConfigurationException(
"Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config <path>|--manual-import-audit|--manual-import-fixture]"); "Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config <path>|--legacy-runtime-root <bin-debug-path>|--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( static async Task VerifyLegacyComparisonImportAsync(
IDataQueryExecutor executor, IDataQueryExecutor executor,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -711,3 +751,7 @@ static async Task VerifyTradingHaltSelectorAsync(
Console.WriteLine( Console.WriteLine(
$"TradingHaltSelector: {result.Items.Count.ToString(CultureInfo.InvariantCulture)} rows"); $"TradingHaltSelector: {result.Items.Count.ToString(CultureInfo.InvariantCulture)} rows");
} }
internal sealed record DatabaseSmokeRuntimeSelection(
DatabaseRuntime Runtime,
MariaDbEndpointReference ReferenceMariaDb);