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
Tornado*.lic
**/Cuts/
**/Res/
**/배경/
# 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)을 기준으로 판단합니다.
2026-07-18의 실행 파일 옆 `Cuts/Res` 배치, 실제 DB, Release x64 MSIX와 UI-only 결과는
[Cuts/Res 실행 구조 검증](docs/LEGACY_RUNTIME_ASSET_VALIDATION_20260718.md)에 기록했습니다.
현재 기준 기술 스택은 다음과 같습니다.
@@ -24,7 +26,8 @@
- 드래그 정렬 및 위/아래 이동
- 선택 삭제
- 로컬 저장/불러오기
- Oracle/MariaDB 4개 국내 시장 종목 검색과 CP949 `Res\종목.ini` 컷 선택(`%LOCALAPPDATA%` 외부 override → 패키지 후보 → 검증된 31개 내장 목록)
- Oracle/MariaDB 4개 국내 시장 종목 검색과 실행 파일 옆 `Res` INI 기반 컷/UI 구성
- 빌드 시 읽기 전용 원본 `bin\Debug\Cuts`를 실행 파일 옆 `Cuts`로 같은 구조로 배치
- 기존 운영 흐름을 반영한 `PREPARE` / `TAKE IN` / `NEXT` / `TAKE OUT` UI
- `F8`, `Esc` 단축키와 원본 배경 선택용 F2의 안전 예약
- 원본의 공급자 비의존 SQL/데이터 요청 71개를 .NET 8 Core 프로젝트로 이관
@@ -35,11 +38,17 @@
- 기본 `DryRun`, Test 전용 인스턴스·채널·씬 allowlist 및 Live 이중 승인 안전 게이트
- MSIX 패키지 매니페스트와 x64 게시 프로필
Oracle/MariaDB 조회 계층과 Tornado/K3D 어댑터의 코드·계약·자동 검증은 구현했습니다. 이는 운영 Oracle 쓰기나 35개 장면 전체의 실제 PGM 동등성이 끝났다는 뜻이 아닙니다. 앱과 패키지의 기본 모드는 계속 `DryRun`이며 Live allowlist, 회차별 승인, 명령 예산, callback/`OutcomeUnknown` 게이트를 완화하지 않습니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정·실제 검증 증거·롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 원본 Scene/PageN 대조는 [송출 흐름 분석](docs/LEGACY_PLAYOUT_ANALYSIS.md), 장면별 현황은 [35개 Scene 동등성 매트릭스](docs/SCENE_EQUIVALENCE.md), 전체 412개 실행 action과 전용 편집 화면은 [운영자 UI 동등성 인벤토리](docs/OPERATOR_UI_PARITY.md)를 참고하세요. 재감사에서 확인한 미적용·적용 중·외부자산 필요 항목은 [원본 기능 재감사 기준표](docs/LEGACY_FEATURE_AUDIT.md)가 최종 상태 기준입니다.
Oracle/MariaDB 조회 계층과 Tornado/K3D 어댑터의 코드·계약·자동 검증은 구현했습니다. 이는 운영 Oracle 쓰기나 35개 장면 전체의 실제 PGM 동등성이 끝났다는 뜻이 아닙니다. 앱과 Release 패키지의 기본 모드는 계속 `DryRun`이며 Live allowlist, 명령 예산, callback/`OutcomeUnknown` 게이트를 완화하지 않습니다. 이 개발 PC의 Tornado2/PGM 마이그레이션 검증은 사용자가 2026-07-18에 반복 승인 없이 진행하도록 상시 허용했으며, 적용 범위와 중단 조건은 [개발 환경 상시 권한](docs/DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)에 기록했습니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정·실제 검증 증거·롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 원본 Scene/PageN 대조는 [송출 흐름 분석](docs/LEGACY_PLAYOUT_ANALYSIS.md), 장면별 현황은 [35개 Scene 동등성 매트릭스](docs/SCENE_EQUIVALENCE.md), 전체 412개 실행 action과 전용 편집 화면은 [운영자 UI 동등성 인벤토리](docs/OPERATOR_UI_PARITY.md)를 참고하세요. 재감사에서 확인한 미적용·적용 중·외부자산 필요 항목은 [원본 기능 재감사 기준표](docs/LEGACY_FEATURE_AUDIT.md)가 최종 상태 기준입니다.
35개 Scene/PageN 런타임과 원본 MainForm·UC1~UC7·GraphE·FSell·VIList·PList·AList·ThemeA·EList의 화면·bridge 계약은 WinUI 3/WebView2에 연결했습니다. 412개 고정 실행 action은 폐쇄형 매트릭스로 고정하며, 수동 재무·순매도·VI·테마·전문가·이름 있는 플레이리스트는 저장 후 재조회와 신뢰 검증이 끝나기 전에는 PREPARE할 수 없습니다. 실제 PList 2,213행은 한국어 7필드 무손상과 typed persistence가 모두 일치했고, KRX·해외종목·GraphE fresh proof를 합쳐 1,603행이 현재 selection으로 복원되며 610행은 명시적 사유로 차단니다. `종목비교.dat` 8쌍은 설치 패키지에서 1회 이전·재시작 영속성까지 확인했습니다. 이 수치는 scene data·asset·PREPARE·PGM 성공이 아니며, 운영 DB write와 승인된 `5001`/`5074` 밖의 실제 PGM 확인은 여전히 미검증입니다.
35개 Scene/PageN 런타임과 원본 MainForm·UC1~UC7·GraphE·FSell·VIList·PList·AList·ThemeA·EList의 화면·bridge 계약은 WinUI 3/WebView2에 연결했습니다. 412개 고정 실행 action은 폐쇄형 매트릭스로 고정하며, 수동 재무·순매도·VI·테마·전문가·이름 있는 플레이리스트는 저장 후 재조회와 신뢰 검증이 끝나기 전에는 PREPARE할 수 없습니다. 2026-07-18 실데이터 재검증에서 PList 2,213행은 한국어 7필드 무손상과 typed persistence가 모두 일치했고, 1,581행이 현재 selection으로 복원되며 632행은 명시적 사유로 차단됐습니다. 이 복원 수치는 DB 상태에 따라 달라질 수 있습니다. `종목비교.dat` 8쌍은 설치 패키지에서 1회 이전·재시작 영속성까지 확인했습니다. 이 수치는 scene data·asset·PREPARE·PGM 성공이 아니며, 운영 DB write와 승인된 `5001`/`5074` 밖의 실제 PGM 확인은 여전히 미검증입니다.
## 최종 검증 기준선 (2026-07-12)
## 과거 1.0.5 검증 기준선 (2026-07-12)
아래 항목은 당시 `1.0.5` 패키지와 고정된 일회성 Gate 회차의 역사적 증거다.
현재 `Cuts/Res` 실행 폴더 배치를 포함해 다시 만든 패키지의 파일 수·해시나 권한으로
재사용하지 않는다. 현행 자산 배치 규약은 [원본 Cuts/Res 런타임 배치](docs/LEGACY_RUNTIME_ASSETS.md),
개발 Tornado2/PGM 작업 권한은 [개발 환경 상시 권한](docs/DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)을
따른다.
- 현재 마이그레이션 소스에서 Core 1,383개, Infrastructure 173개, Playout 393개가 Debug/Release x64에서 각각 모두 통과했다. 구성별 1,949개, 두 구성 합계 3,898개이며 실패·skip은 0건이다. Web 구문 27개와 테스트 415/415, 원본 scene 기준선 35/35도 통과했다.
- 원본 `Cuts` 기준 active alias 45개의 `.t2s`는 missing/unsafe 0건이고 종속 asset 23개는 unsafe 0건, missing 14건이다. 누락은 `s5006` 큐브 배경 1개와 `s6001` 국가별 영상 13개다. 실제 Oracle 21c/MariaDB read-only 스모크는 `삼성` 검색과 필수 loader 33/33, 전체 query 58건을 통과했지만 DB→DTO→mutation 검증 범위다.
@@ -100,9 +109,9 @@ dotnet build .\src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyPar
## 구성과 보안
원본 `RES/MmoneyCoder.ini`의 값은 저장소에 복사하지 않았습니다. [예제 설정](Config/appsettings.example.json)에는 빈 값만 있으며 실제 설정은 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\database.local.json` 또는 `MBN_STOCK_*` 환경 변수를 사용합니다. 로컬 파일 생성, 환경 변수, 보안과 스모크 테스트 방법은 [DB 운영 가이드](docs/DATABASE.md)에 정리했습니다.
원본 `Res/MmoneyCoder.ini`의 값은 저장소에 복사하지 않았습니다. 일반 Visual Studio 개발 빌드는 원본 INI를 Git 밖에서 실행 파일 옆 `Res`로 복사해 기존 경로를 재현하고, MSIX는 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` 또는 기존 `database.local.json`을 사용합니다. 환경 변수는 선택된 파일보다 우선합니다. 설정 우선순위, 보안과 실제 DB 스모크 방법은 [DB 운영 가이드](docs/DATABASE.md)에 정리했습니다.
MSIX 설치 폴더는 읽기 전용입니다. 로그, 플레이리스트, 운영자 설정과 미리보기 파일은 이후 `ApplicationData.Current.LocalFolder` 또는 `LocalCacheFolder`에 저장해야 합니다. Tornado 씬/영상처럼 큰 운영 자산은 별도의 설정 가능한 외부 폴더로 유지합니다.
MSIX 설치 폴더는 읽기 전용입니다. 빌드 시 읽기 전용 원본 `Cuts` 179개와 허용된 UI용 `Res` 파일은 실행 출력·게시·MSIX에 포함되지만, DB 자격증명 INI·백업·인증서·벤더 DLL은 포함하지 않습니다. 현재 원본에 없는 `s5006`/`s6001` 영상과 일부 배경은 별도 원본 제작 자산을 확보해야 합니다. 로그, 플레이리스트, 운영자 설정과 미리보기 파일은 `ApplicationData.Current.LocalFolder` 또는 `LocalCacheFolder`에 저장합니다.
## 프로젝트 구조

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
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\database.local.json
@@ -34,7 +45,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\Initialize-DatabaseConfig.ps1
Oracle은 `sid` 또는 `serviceName` 중 정확히 하나를 설정합니다. 원본 MBN_STOCK_N은 SID descriptor를 사용했으므로 legacy 연결은 `sid`로 옮깁니다. 설정 파일은 평문 비밀번호를 포함할 수 있으므로 저장소·로그·첨부파일로 복사하지 않습니다. 운영 환경에서는 실행 계정의 파일 ACL을 유지하고, 환경 변수 오버라이드 또는 조직의 승인된 비밀 저장소를 사용할 수 있습니다.
JSON보다 환경 변수가 우선합니다.
선택된 INI 또는 JSON보다 환경 변수가 우선합니다.
```text
MBN_STOCK_ORACLE_HOST / PORT / SID / SERVICE_NAME / USERNAME / PASSWORD
@@ -70,6 +81,19 @@ dotnet test MBN_STOCK_WEBVIEW.sln -c Release -p:Platform=x64
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj -c Release
```
원본 실행 폴더의 `Res\MmoneyCoder.ini` 경로를 그대로 검증할 때는 다음처럼 실행합니다.
```powershell
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj `
-c Release -- --legacy-runtime-root `
"C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug"
```
이 모드의 NXT endpoint 감사는 지정한 INI의 host/port/database를 환경변수 적용 전
비교 기준으로 사용합니다. 기본/`--config` 모드도 선택한 JSON의 환경변수 적용 전
endpoint를 기준으로 사용하므로 환경변수가 연결 대상을 바꾼 경우를 감지할 수
있습니다. 비교 출력은 SHA-256과 일치 여부뿐이며 계정과 비밀번호는 포함하지 않습니다.
스모크는 양쪽 `SELECT 1`, Oracle 서버 버전, 코스피·코스닥·NXT·5개 국내 지수·해외 종목 카탈로그의 실제 행을 확인합니다. 출력에는 상태, 테이블 이름, 행 수만 포함합니다.
## MSIX

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

View File

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

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/데이터 요청 계층
- FarPoint Spread 4 플레이리스트/그리드
원본 작업 트리의 `MainForm.cs``RES/MmoneyCoder.ini`에는 커밋되지 않은 변경이 있습니다. 원본 저장소는 그대로 보존했으며, 새 프로젝트에는 비밀값이 포함된 INI를 복사하지 않았습니다.
원본 작업 트리의 `MainForm.cs``RES/MmoneyCoder.ini`에는 커밋되지 않은 변경이 있습니다. 원본 저장소는 그대로 보존합니다. 실제 DB INI는 Git이나 MSIX에 넣지 않으며, 일반 개발 빌드에서만 읽기 전용 원본 `bin\Debug\Res`에서 실행 파일 옆 `Res`로 복사해 기존 상대 경로를 재현합니다.
## 화면/계약 적용 현황
@@ -18,6 +18,7 @@
|---|---|
| WinForms MainForm | WinUI 3 `MainWindow` + 로컬 WebView2 UI |
| FarPoint `FpSpread` 플레이리스트 | HTML table + 선택/드래그 정렬/이동/삭제 |
| 실행 폴더 `Cuts`/`Res` | 읽기 전용 원본 `bin\Debug`를 빌드 입력으로 연결해 실행 출력과 MSIX에 비민감 자산 배치 |
| 플레이리스트 임시 저장 | WebView2 사용자 데이터 영역의 `localStorage` |
| Oracle `DC_LIST`/`PLAY_LIST` | 이름 있는 플레이리스트 생성·교체·불러오기·삭제와 fresh page preflight |
| WinForms 단축키 | Web UI `F2` / `F8` / `Esc` 처리 |

View File

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

View File

@@ -1,10 +1,14 @@
# Tornado/K3D x64 송출 운영 가이드
> 2026-07-18 개발 환경 변경: 현재 지정 개발 PC의 Tornado2/PGM 마이그레이션 검증은
> [개발 환경 상시 권한](DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)에 따라 회차별 대화형
> 승인을 다시 받지 않는다. 운영/방송 대상과 `OutcomeUnknown` 중단 규칙은 그대로다.
## 안전 기준
기본 모드는 `DryRun`입니다. `DryRun`은 COM 객체를 만들거나 Tornado 출력에 명령을 보내지 않고 WebView 메시지, 큐, 상태 및 오류 표시 흐름만 검증합니다. 현재 방송 PROGRAM(PGM)에 연결된 Tornado 프로세스는 항상 **안전하지 않은 대상**으로 취급합니다. 프로세스 이름이나 실행 여부만으로 테스트 대상이라고 판단하지 않습니다.
이 저장소의 작업과 자동 검증은 실제 PGM 출력 또는 라이브 `TAKE IN`을 허가하지 않습니다. `Test` 검증은 PGM과 분리된 전용 테스트 인스턴스, 테스트 출력 채널 및 허용 목록에 든 테스트 씬을 모두 확인한 뒤에만 수행합니다. `Live`는 운영 책임자의 명시적인 회차별 허가 없이는 사용하지 않습니다.
운영/방송 대상의 작업과 자동 검증은 실제 PGM 출력 또는 라이브 `TAKE IN` 자동 허가하지 않습니다. `Test` 검증은 PGM과 분리된 전용 테스트 인스턴스, 테스트 출력 채널 및 허용 목록에 든 테스트 씬을 모두 확인한 뒤에만 수행합니다. 단, 위에 링크한 현재 개발 PC의 상시 권한 범위에서는 별도의 회차별 허가를 다시 요청하지 않습니다.
이 문서의 adapter·builder·DryRun 완료 표현은 화면/계약 구현 범위다. 운영 DB-W, 외부 asset readiness, 기존 데이터 복원과 장면별 실제 PGM은 별도 증거가 있어야 한다. 현재 미적용·적용 중·외부자산 필요 항목은 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)를 따른다.
@@ -85,14 +89,14 @@ SDK가 기본 위치에 없다면 x64 SDK의 `TlbImp.exe` 절대 경로를 `-Tlb
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\playout.local.json
```
[playout.example.json](../Config/playout.example.json)을 구조 참고용으로 사용합니다. 예시는 `DryRun`, 외부 씬 루트와 출력 채널 미지정, 빈 씬 허용 목록, 라이브 신뢰 플래그 해제 상태이므로 실제 출력에 사용할 수 없습니다. 테스트 장비의 호스트, 채널, 창 제목 패턴, 외부 씬 루트와 허용할 씬 이름 로컬 파일에만 기록하고 Git, 로그 또는 지원 첨부파일에 넣지 않습니다.
[playout.example.json](../Config/playout.example.json)을 구조 참고용으로 사용합니다. 예시는 `DryRun`, 출력 채널 미지정, 빈 씬 허용 목록, 라이브 신뢰 플래그 해제 상태이므로 실제 출력에 사용할 수 없습니다. `sceneDirectory`가 비어 있고 실행 파일 옆에 빌드가 배치한 `Cuts`가 있으면 그 폴더를 기본으로 사용합니다. 별도 씬 루트를 명시할 때는 테스트 장비의 호스트, 채널, 창 제목 패턴 허용할 씬 이름 로컬 파일에만 기록하고 Git, 로그 또는 지원 첨부파일에 넣지 않습니다.
| 속성 | 의미 |
|---|---|
| `mode` | `Disabled`, `DryRun`, `Test`, `Live` 중 하나 |
| `host`, `port` | 테스트 또는 운영 승인을 받은 KTAP endpoint |
| `tcpMode`, `clientPort` | `KTAPConnect`의 전송 모드와 로컬 client port 인자. Test/Live는 유실 방지를 위해 `tcpMode: 1`만 허용 |
| `sceneDirectory` | Test/Live에서 사용하는 외부 `.t2s` 루트의 절대 경로. `null`은 안전한 미설정 상태 |
| `sceneDirectory` | Test/Live에서 사용하는 `.t2s` 루트의 절대 경로. 명시값이 우선하며, `null`이면 실행 파일 옆 `Cuts`가 존재할 때 그 경로를 사용하고 없으면 미설정 상태 |
| `outputChannel` | 확인된 전용 출력 채널. `null`은 안전한 미설정 상태 |
| `layoutIndex` | 씬 플레이어 layout 위치 |
| `legacySceneFadeDuration` | 원본 `ComboDi.SelectedIndex`에 대응하는 fade. 기본 인덱스 6(화면 7), 허용 범위 0~19(화면 표시 1~20) |

View File

@@ -1,5 +1,10 @@
# MBN_STOCK_WEBVIEW 송출 운영·검증 절차
> 2026-07-18 개발 환경 변경: 이 문서의 회차별 승인 절차는 운영/방송 대상에 계속
> 적용한다. 현재 지정된 개발 PC의 Tornado2/PGM 마이그레이션 검증은 사용자가 상시
> 허용했으므로 반복 승인을 요청하지 않는다. 범위와 기술적 중단 조건은
> [개발 환경 상시 권한](DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)을 우선 적용한다.
이 문서는 마이그레이션된 WebView → `IPlayoutEngine` → K3D/Tornado2 경로를 검증할 때의 승인, 실행, 감시, 장애 복구와 rollback 기준이다. 기존 [`PLAYOUT.md`](PLAYOUT.md)의 K3D 설치·해시 핀·격리 진단 규칙을 대체하지 않고, 35개 scene과 PageN 동등성 검증에 필요한 운영 절차를 추가한다. 화면/계약 구현과 운영 동등성의 최신 재감사 상태는 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)를 따른다.
## 현재 상태

View File

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

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);
}
public static DatabaseRuntime CreateLegacyExecutableDefault(
string baseDirectory,
string? fallbackConfigurationPath = null,
string? legacyOverridePath = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(baseDirectory);
var legacyIniPath = Path.Combine(
Path.GetFullPath(baseDirectory),
"Res",
"MmoneyCoder.ini");
if (File.Exists(legacyIniPath))
{
return Create(new LegacyIniDatabaseOptionsLoader().Load(legacyIniPath));
}
var localOverride = legacyOverridePath ?? ResolveLegacyOverridePath();
if (!string.IsNullOrWhiteSpace(localOverride) && File.Exists(localOverride))
{
return Create(new LegacyIniDatabaseOptionsLoader().Load(localOverride));
}
return CreateDefault(fallbackConfigurationPath);
}
private static string? ResolveLegacyOverridePath()
{
var localAppData = Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData);
return string.IsNullOrWhiteSpace(localAppData)
? null
: Path.Combine(
localAppData,
"MBN_STOCK_WEBVIEW",
"Res",
"MmoneyCoder.ini");
}
public static DatabaseRuntime Create(DatabaseOptions options)
{
ArgumentNullException.ThrowIfNull(options);

View File

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

View File

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

View File

@@ -259,6 +259,35 @@ public sealed class LegacyFixedActionCatalog
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(
string actionId,
DateTimeOffset localNow)

View File

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

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>
<ApplicationVersion>1</ApplicationVersion>
<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>
<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"
Link="Runtime\LegacySceneRuntimeFactory.cs" />
<Content Include="..\..\Assets\LockScreenLogo.scale-200.png" Link="Assets\LockScreenLogo.scale-200.png" />
@@ -39,9 +85,99 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</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)" />
</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>
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" />
<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(
runtimeCutMenuOverridePath,
AppContext.BaseDirectory);
var runtimeUiCatalogs = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory(
AppContext.BaseDirectory);
ILegacyStockLookup stockLookup;
IIndustrySelectionService industrySelectionService;
@@ -76,7 +78,8 @@ public sealed partial class MainWindow : Window
string? initializationError = null;
try
{
_databaseRuntime = DatabaseRuntime.CreateDefault();
_databaseRuntime = DatabaseRuntime.CreateLegacyExecutableDefault(
AppContext.BaseDirectory);
DataQueryExecutor.Configure(_databaseRuntime.Executor);
var databaseMutationAuthorization =
new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization);
@@ -163,7 +166,9 @@ public sealed partial class MainWindow : Window
DataQueryExecutor.Reset();
}
_industryWorkflow = new LegacyIndustrySelectionWorkflow(industrySelectionService);
_industryWorkflow = new LegacyIndustrySelectionWorkflow(
industrySelectionService,
actionLayout: runtimeUiCatalogs.IndustryLayout);
var overseasWorkflow = new LegacyOverseasSelectionWorkflow(
overseasIndustrySearchService,
overseasStockSearchService);
@@ -177,6 +182,7 @@ public sealed partial class MainWindow : Window
var manualListsWorkflow = CreateManualListsWorkflow(stockLookup);
_controller = new LegacyOperatorController(
stockLookup,
fixedCatalog: runtimeUiCatalogs.FixedCatalog,
industryWorkflow: _industryWorkflow,
themeWorkflow: new LegacyThemeWorkflow(themeSelectionService),
expertWorkflow: new LegacyExpertWorkflowController(expertSelectionService),
@@ -184,7 +190,8 @@ public sealed partial class MainWindow : Window
tradingHaltSelectionService),
comparisonWorkflow: new LegacyComparisonWorkflowController(
new LegacyComparisonDataService(stockLookup, worldStockSearchService),
comparisonStore),
comparisonStore,
runtimeUiCatalogs.ComparisonLayout),
manualFinancialWorkflow: new LegacyManualFinancialWorkflow(
manualFinancialService,
manualFinancialStockSearchService),
@@ -205,9 +212,15 @@ public sealed partial class MainWindow : Window
{
_controller.ReportError(initializationError);
}
else if (cutMenuComposition.WarningMessage is { } cutMenuWarning)
else if (new[]
{
cutMenuComposition.WarningMessage,
runtimeUiCatalogs.WarningMessage
}
.Where(message => message is not null)
.ToArray() is { Length: > 0 } catalogWarnings)
{
_controller.ReportWarning(cutMenuWarning);
_controller.ReportWarning(string.Join(" ", catalogWarnings));
}
InitializePlayoutRuntime();

View File

@@ -23,6 +23,7 @@ public static class PlayoutOptionsLoader
{
var options = LoadJson(path ?? DefaultPath);
ApplyEnvironment(options);
ApplyExecutableAssetDefaults(options, AppContext.BaseDirectory);
return options;
}
@@ -42,6 +43,36 @@ public static class PlayoutOptionsLoader
RequiredLiveAuthorizationValue,
StringComparison.Ordinal);
internal static void ApplyExecutableAssetDefaults(
PlayoutOptions options,
string? baseDirectory)
{
ArgumentNullException.ThrowIfNull(options);
if (!string.IsNullOrWhiteSpace(options.SceneDirectory) ||
string.IsNullOrWhiteSpace(baseDirectory))
{
return;
}
try
{
var cutsDirectory = Path.Combine(
Path.GetFullPath(baseDirectory),
"Cuts");
if (Directory.Exists(cutsDirectory))
{
options.SceneDirectory = cutsDirectory;
}
}
catch (Exception exception) when (
exception is ArgumentException or IOException or NotSupportedException or
System.Security.SecurityException)
{
// A missing or invalid executable asset root leaves the existing safe
// configuration unchanged; validation reports it if a command needs it.
}
}
private static PlayoutOptions LoadJson(string path)
{
if (!File.Exists(path))

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]
public void DatabaseInitializationErrorWinsOverTheSingleCutMenuFallbackWarning()
public void DatabaseInitializationErrorWinsOverCombinedRuntimeCatalogWarnings()
{
var errorBranch = MainWindow.IndexOf(
"if (initializationError is not null)",
StringComparison.Ordinal);
var warningBranch = MainWindow.IndexOf(
"else if (cutMenuComposition.WarningMessage is { } cutMenuWarning)",
"cutMenuComposition.WarningMessage,",
errorBranch,
StringComparison.Ordinal);
var runtimeUiWarning = MainWindow.IndexOf(
"runtimeUiCatalogs.WarningMessage",
warningBranch,
StringComparison.Ordinal);
var errorReport = MainWindow.IndexOf(
"_controller.ReportError(initializationError);",
errorBranch,
StringComparison.Ordinal);
var warningReport = MainWindow.IndexOf(
"_controller.ReportWarning(cutMenuWarning);",
"_controller.ReportWarning(string.Join(\" \", catalogWarnings));",
warningBranch,
StringComparison.Ordinal);
Assert.True(errorBranch >= 0);
Assert.True(errorReport > errorBranch);
Assert.True(warningBranch > errorReport);
Assert.True(runtimeUiWarning > warningBranch);
Assert.True(warningReport > warningBranch);
Assert.Equal(
warningReport,
MainWindow.LastIndexOf(
"_controller.ReportWarning(cutMenuWarning);",
"_controller.ReportWarning(string.Join(\" \", catalogWarnings));",
StringComparison.Ordinal));
}

View File

@@ -62,6 +62,39 @@ public sealed class PlayoutOptionsLoaderTests
Assert.Null(options.MaximumAutomaticRefreshesPerTakeIn);
}
[Fact]
public void ApplyExecutableAssetDefaults_UsesCutsBesideExecutable()
{
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
var cuts = Path.Combine(directory, "Cuts");
Directory.CreateDirectory(cuts);
try
{
var options = new PlayoutOptions();
PlayoutOptionsLoader.ApplyExecutableAssetDefaults(options, directory);
Assert.Equal(Path.GetFullPath(cuts), options.SceneDirectory);
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void ApplyExecutableAssetDefaults_DoesNotReplaceExplicitSceneDirectory()
{
var options = new PlayoutOptions
{
SceneDirectory = "C:\\explicit-cuts"
};
PlayoutOptionsLoader.ApplyExecutableAssetDefaults(options, "C:\\runtime");
Assert.Equal("C:\\explicit-cuts", options.SceneDirectory);
}
[Fact]
public void Load_EnvironmentOverridesJson_ExceptFileOnlySafetyGates()
{

View File

@@ -7,8 +7,6 @@ using MMoneyCoderSharp.Data;
internal static class NxtThemeRestoreDbAudit
{
private const string OriginalConfigurationPath =
@"C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\RES\MmoneyCoder.ini";
private const string ActiveRowsQueryName = "AUDIT_ACTIVE_NXT_THEME_ROWS";
private const string ActiveRowsSql = """
@@ -26,9 +24,14 @@ internal static class NxtThemeRestoreDbAudit
internal static async Task RunAsync(
IDataQueryExecutor executor,
MariaDbDatabaseOptions currentOptions,
MariaDbEndpointReference referenceOptions,
CancellationToken cancellationToken)
{
await RunEndpointAuditAsync(executor, currentOptions, cancellationToken)
await RunEndpointAuditAsync(
executor,
currentOptions,
referenceOptions,
cancellationToken)
.ConfigureAwait(false);
const int maximumRows = 1_000;
@@ -291,13 +294,18 @@ internal static class NxtThemeRestoreDbAudit
private static async Task RunEndpointAuditAsync(
IDataQueryExecutor executor,
MariaDbDatabaseOptions currentOptions,
MariaDbEndpointReference referenceOptions,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(executor);
ArgumentNullException.ThrowIfNull(currentOptions);
ArgumentNullException.ThrowIfNull(referenceOptions);
cancellationToken.ThrowIfCancellationRequested();
var original = ReadOriginalEndpoint(OriginalConfigurationPath);
var original = NormalizeEndpoint(
referenceOptions.Host,
referenceOptions.Port,
referenceOptions.Database);
var current = NormalizeEndpoint(
currentOptions.Host,
currentOptions.Port,
@@ -354,78 +362,6 @@ internal static class NxtThemeRestoreDbAudit
$"serverMajorMinor={versionMajorMinor}");
}
private static string ReadOriginalEndpoint(string path)
{
if (!File.Exists(path))
{
throw new InvalidDataException(
"The fixed read-only original MariaDB configuration is unavailable.");
}
var source = new FileInfo(path);
if ((source.Attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 ||
source.Length is < 1 or > 64 * 1024)
{
throw new InvalidDataException(
"The fixed read-only original MariaDB configuration failed its file boundary.");
}
var inMariaSection = false;
string? connectionName = null;
foreach (var rawLine in File.ReadLines(path))
{
var line = rawLine.Trim();
if (line.StartsWith("[", StringComparison.Ordinal) &&
line.EndsWith("]", StringComparison.Ordinal))
{
inMariaSection = string.Equals(line, "[Maria]", StringComparison.Ordinal);
continue;
}
if (!inMariaSection || line.Length == 0 ||
line.StartsWith("//", StringComparison.Ordinal))
{
continue;
}
const string prefix = "ConnectionName=";
if (line.StartsWith(prefix, StringComparison.Ordinal))
{
if (connectionName is not null)
{
throw new InvalidDataException(
"The original MariaDB endpoint is ambiguous.");
}
connectionName = line[prefix.Length..];
}
}
if (connectionName is null)
{
throw new InvalidDataException(
"The original MariaDB endpoint is unavailable.");
}
var slash = connectionName.LastIndexOf('/');
var colon = connectionName.LastIndexOf(':', slash - 1);
if (slash <= 0 || colon <= 0 || colon >= slash - 1 || slash == connectionName.Length - 1 ||
!int.TryParse(
connectionName.AsSpan(colon + 1, slash - colon - 1),
NumberStyles.None,
CultureInfo.InvariantCulture,
out var port))
{
throw new InvalidDataException(
"The original MariaDB endpoint has an invalid closed shape.");
}
return NormalizeEndpoint(
connectionName[..colon],
port,
connectionName[(slash + 1)..]);
}
private static string NormalizeEndpoint(string? host, int port, string? database)
{
var normalizedHost = (host ?? string.Empty)
@@ -460,3 +396,15 @@ internal static class NxtThemeRestoreDbAudit
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
column.DataType == typeof(string);
}
internal sealed record MariaDbEndpointReference(
string? Host,
int Port,
string? Database)
{
internal static MariaDbEndpointReference From(MariaDbDatabaseOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return new MariaDbEndpointReference(options.Host, options.Port, options.Database);
}
}

View File

@@ -25,11 +25,10 @@ Console.CancelKeyPress += (_, eventArgs) =>
cancellation.Cancel();
};
var configurationPath = GetConfigurationPath(args);
try
{
var runtime = DatabaseRuntime.CreateDefault(configurationPath);
var selection = CreateRuntimeSelection(args);
var runtime = selection.Runtime;
var statuses = await runtime.HealthService.CheckAllAsync(cancellation.Token);
foreach (var status in statuses)
@@ -59,6 +58,7 @@ try
await NxtThemeRestoreDbAudit.RunAsync(
runtime.Executor,
runtime.Options.MariaDb,
selection.ReferenceMariaDb,
cancellation.Token);
await VerifyLegacyComparisonImportAsync(runtime.Executor, cancellation.Token);
@@ -131,23 +131,63 @@ static string FormatNullableBoolean(bool? value) => value switch
static string FormatProviderErrorCode(int? value) =>
value?.ToString(CultureInfo.InvariantCulture) ?? "none";
static string? GetConfigurationPath(string[] arguments)
static DatabaseSmokeRuntimeSelection CreateRuntimeSelection(string[] arguments)
{
if (arguments.Length == 0)
{
return null;
return CreateJsonRuntimeSelection(configurationPath: null);
}
if (arguments.Length == 2 &&
arguments[0].Equals("--config", StringComparison.OrdinalIgnoreCase))
{
return arguments[1];
return CreateJsonRuntimeSelection(arguments[1]);
}
if (arguments.Length == 2 &&
arguments[0].Equals("--legacy-runtime-root", StringComparison.OrdinalIgnoreCase))
{
var root = Path.GetFullPath(arguments[1]);
var iniPath = Path.Combine(root, "Res", "MmoneyCoder.ini");
if (!File.Exists(iniPath))
{
throw new DatabaseConfigurationException(
"The legacy runtime root does not contain Res\\MmoneyCoder.ini.");
}
// Keep the selected file's endpoint as the comparison reference before
// environment overrides are applied. Credentials never leave the
// loader-owned DatabaseOptions instance.
var selectedOptions = new LegacyIniDatabaseOptionsLoader(_ => null).Load(iniPath);
var runtime = DatabaseRuntime.CreateLegacyExecutableDefault(
root,
fallbackConfigurationPath: Path.Combine(root, "missing-database.json"),
legacyOverridePath: Path.Combine(root, "missing-legacy.ini"));
return new DatabaseSmokeRuntimeSelection(
runtime,
MariaDbEndpointReference.From(selectedOptions.MariaDb));
}
throw new DatabaseConfigurationException(
"Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config <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(
IDataQueryExecutor executor,
CancellationToken cancellationToken)
@@ -711,3 +751,7 @@ static async Task VerifyTradingHaltSelectorAsync(
Console.WriteLine(
$"TradingHaltSelector: {result.Items.Count.ToString(CultureInfo.InvariantCulture)} rows");
}
internal sealed record DatabaseSmokeRuntimeSelection(
DatabaseRuntime Runtime,
MariaDbEndpointReference ReferenceMariaDb);