feat: load legacy Cuts and Res runtime assets
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -53,6 +53,7 @@ artifacts/K3DInterop/
|
|||||||
K3D*.lic
|
K3D*.lic
|
||||||
Tornado*.lic
|
Tornado*.lic
|
||||||
**/Cuts/
|
**/Cuts/
|
||||||
|
**/Res/
|
||||||
**/배경/
|
**/배경/
|
||||||
|
|
||||||
# Web tooling (if introduced later)
|
# Web tooling (if introduced later)
|
||||||
|
|||||||
42
AGENTS.md
Normal file
42
AGENTS.md
Normal 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.
|
||||||
21
README.md
21
README.md
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
원본 이벤트를 코드 단위로 다시 검증하는 현재 작업 순서와 완료 범위는
|
원본 이벤트를 코드 단위로 다시 검증하는 현재 작업 순서와 완료 범위는
|
||||||
[동작 동등성 작업 기록](docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md)을 기준으로 판단합니다.
|
[동작 동등성 작업 기록](docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md)을 기준으로 판단합니다.
|
||||||
|
2026-07-18의 실행 파일 옆 `Cuts/Res` 배치, 실제 DB, Release x64 MSIX와 UI-only 결과는
|
||||||
|
[Cuts/Res 실행 구조 검증](docs/LEGACY_RUNTIME_ASSET_VALIDATION_20260718.md)에 기록했습니다.
|
||||||
|
|
||||||
현재 기준 기술 스택은 다음과 같습니다.
|
현재 기준 기술 스택은 다음과 같습니다.
|
||||||
|
|
||||||
@@ -24,7 +26,8 @@
|
|||||||
- 드래그 정렬 및 위/아래 이동
|
- 드래그 정렬 및 위/아래 이동
|
||||||
- 선택 삭제
|
- 선택 삭제
|
||||||
- 로컬 저장/불러오기
|
- 로컬 저장/불러오기
|
||||||
- Oracle/MariaDB 4개 국내 시장 종목 검색과 CP949 `Res\종목.ini` 컷 선택(`%LOCALAPPDATA%` 외부 override → 패키지 후보 → 검증된 31개 내장 목록)
|
- Oracle/MariaDB 4개 국내 시장 종목 검색과 실행 파일 옆 `Res` INI 기반 컷/UI 구성
|
||||||
|
- 빌드 시 읽기 전용 원본 `bin\Debug\Cuts`를 실행 파일 옆 `Cuts`로 같은 구조로 배치
|
||||||
- 기존 운영 흐름을 반영한 `PREPARE` / `TAKE IN` / `NEXT` / `TAKE OUT` UI
|
- 기존 운영 흐름을 반영한 `PREPARE` / `TAKE IN` / `NEXT` / `TAKE OUT` UI
|
||||||
- `F8`, `Esc` 단축키와 원본 배경 선택용 F2의 안전 예약
|
- `F8`, `Esc` 단축키와 원본 배경 선택용 F2의 안전 예약
|
||||||
- 원본의 공급자 비의존 SQL/데이터 요청 71개를 .NET 8 Core 프로젝트로 이관
|
- 원본의 공급자 비의존 SQL/데이터 요청 71개를 .NET 8 Core 프로젝트로 이관
|
||||||
@@ -35,11 +38,17 @@
|
|||||||
- 기본 `DryRun`, Test 전용 인스턴스·채널·씬 allowlist 및 Live 이중 승인 안전 게이트
|
- 기본 `DryRun`, Test 전용 인스턴스·채널·씬 allowlist 및 Live 이중 승인 안전 게이트
|
||||||
- MSIX 패키지 매니페스트와 x64 게시 프로필
|
- MSIX 패키지 매니페스트와 x64 게시 프로필
|
||||||
|
|
||||||
Oracle/MariaDB 조회 계층과 Tornado/K3D 어댑터의 코드·계약·자동 검증은 구현했습니다. 이는 운영 Oracle 쓰기나 35개 장면 전체의 실제 PGM 동등성이 끝났다는 뜻이 아닙니다. 앱과 패키지의 기본 모드는 계속 `DryRun`이며 Live allowlist, 회차별 승인, 명령 예산, callback/`OutcomeUnknown` 게이트를 완화하지 않습니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정·실제 검증 증거·롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 원본 Scene/PageN 대조는 [송출 흐름 분석](docs/LEGACY_PLAYOUT_ANALYSIS.md), 장면별 현황은 [35개 Scene 동등성 매트릭스](docs/SCENE_EQUIVALENCE.md), 전체 412개 실행 action과 전용 편집 화면은 [운영자 UI 동등성 인벤토리](docs/OPERATOR_UI_PARITY.md)를 참고하세요. 재감사에서 확인한 미적용·적용 중·외부자산 필요 항목은 [원본 기능 재감사 기준표](docs/LEGACY_FEATURE_AUDIT.md)가 최종 상태 기준입니다.
|
Oracle/MariaDB 조회 계층과 Tornado/K3D 어댑터의 코드·계약·자동 검증은 구현했습니다. 이는 운영 Oracle 쓰기나 35개 장면 전체의 실제 PGM 동등성이 끝났다는 뜻이 아닙니다. 앱과 Release 패키지의 기본 모드는 계속 `DryRun`이며 Live allowlist, 명령 예산, callback/`OutcomeUnknown` 게이트를 완화하지 않습니다. 이 개발 PC의 Tornado2/PGM 마이그레이션 검증은 사용자가 2026-07-18에 반복 승인 없이 진행하도록 상시 허용했으며, 적용 범위와 중단 조건은 [개발 환경 상시 권한](docs/DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)에 기록했습니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정·실제 검증 증거·롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 원본 Scene/PageN 대조는 [송출 흐름 분석](docs/LEGACY_PLAYOUT_ANALYSIS.md), 장면별 현황은 [35개 Scene 동등성 매트릭스](docs/SCENE_EQUIVALENCE.md), 전체 412개 실행 action과 전용 편집 화면은 [운영자 UI 동등성 인벤토리](docs/OPERATOR_UI_PARITY.md)를 참고하세요. 재감사에서 확인한 미적용·적용 중·외부자산 필요 항목은 [원본 기능 재감사 기준표](docs/LEGACY_FEATURE_AUDIT.md)가 최종 상태 기준입니다.
|
||||||
|
|
||||||
35개 Scene/PageN 런타임과 원본 MainForm·UC1~UC7·GraphE·FSell·VIList·PList·AList·ThemeA·EList의 화면·bridge 계약은 WinUI 3/WebView2에 연결했습니다. 412개 고정 실행 action은 폐쇄형 매트릭스로 고정하며, 수동 재무·순매도·VI·테마·전문가·이름 있는 플레이리스트는 저장 후 재조회와 신뢰 검증이 끝나기 전에는 PREPARE할 수 없습니다. 실제 PList 2,213행은 한국어 7필드 무손상과 typed persistence가 모두 일치했고, KRX·해외종목·GraphE fresh proof를 합쳐 1,603행이 현재 selection으로 복원되며 610행은 명시적 사유로 차단됩니다. `종목비교.dat` 8쌍은 설치 패키지에서 1회 이전·재시작 영속성까지 확인했습니다. 이 수치는 scene data·asset·PREPARE·PGM 성공이 아니며, 운영 DB write와 승인된 `5001`/`5074` 밖의 실제 PGM 확인은 여전히 미검증입니다.
|
35개 Scene/PageN 런타임과 원본 MainForm·UC1~UC7·GraphE·FSell·VIList·PList·AList·ThemeA·EList의 화면·bridge 계약은 WinUI 3/WebView2에 연결했습니다. 412개 고정 실행 action은 폐쇄형 매트릭스로 고정하며, 수동 재무·순매도·VI·테마·전문가·이름 있는 플레이리스트는 저장 후 재조회와 신뢰 검증이 끝나기 전에는 PREPARE할 수 없습니다. 2026-07-18 실데이터 재검증에서 PList 2,213행은 한국어 7필드 무손상과 typed persistence가 모두 일치했고, 1,581행이 현재 selection으로 복원되며 632행은 명시적 사유로 차단됐습니다. 이 복원 수치는 DB 상태에 따라 달라질 수 있습니다. `종목비교.dat` 8쌍은 설치 패키지에서 1회 이전·재시작 영속성까지 확인했습니다. 이 수치는 scene data·asset·PREPARE·PGM 성공이 아니며, 운영 DB write와 승인된 `5001`/`5074` 밖의 실제 PGM 확인은 여전히 미검증입니다.
|
||||||
|
|
||||||
## 최종 검증 기준선 (2026-07-12)
|
## 과거 1.0.5 검증 기준선 (2026-07-12)
|
||||||
|
|
||||||
|
아래 항목은 당시 `1.0.5` 패키지와 고정된 일회성 Gate 회차의 역사적 증거다.
|
||||||
|
현재 `Cuts/Res` 실행 폴더 배치를 포함해 다시 만든 패키지의 파일 수·해시나 권한으로
|
||||||
|
재사용하지 않는다. 현행 자산 배치 규약은 [원본 Cuts/Res 런타임 배치](docs/LEGACY_RUNTIME_ASSETS.md),
|
||||||
|
개발 Tornado2/PGM 작업 권한은 [개발 환경 상시 권한](docs/DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)을
|
||||||
|
따른다.
|
||||||
|
|
||||||
- 현재 마이그레이션 소스에서 Core 1,383개, Infrastructure 173개, Playout 393개가 Debug/Release x64에서 각각 모두 통과했다. 구성별 1,949개, 두 구성 합계 3,898개이며 실패·skip은 0건이다. Web 구문 27개와 테스트 415/415, 원본 scene 기준선 35/35도 통과했다.
|
- 현재 마이그레이션 소스에서 Core 1,383개, Infrastructure 173개, Playout 393개가 Debug/Release x64에서 각각 모두 통과했다. 구성별 1,949개, 두 구성 합계 3,898개이며 실패·skip은 0건이다. Web 구문 27개와 테스트 415/415, 원본 scene 기준선 35/35도 통과했다.
|
||||||
- 원본 `Cuts` 기준 active alias 45개의 `.t2s`는 missing/unsafe 0건이고 종속 asset 23개는 unsafe 0건, missing 14건이다. 누락은 `s5006` 큐브 배경 1개와 `s6001` 국가별 영상 13개다. 실제 Oracle 21c/MariaDB read-only 스모크는 `삼성` 검색과 필수 loader 33/33, 전체 query 58건을 통과했지만 DB→DTO→mutation 검증 범위다.
|
- 원본 `Cuts` 기준 active alias 45개의 `.t2s`는 missing/unsafe 0건이고 종속 asset 23개는 unsafe 0건, missing 14건이다. 누락은 `s5006` 큐브 배경 1개와 `s6001` 국가별 영상 13개다. 실제 Oracle 21c/MariaDB read-only 스모크는 `삼성` 검색과 필수 loader 33/33, 전체 query 58건을 통과했지만 DB→DTO→mutation 검증 범위다.
|
||||||
@@ -100,9 +109,9 @@ dotnet build .\src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyPar
|
|||||||
|
|
||||||
## 구성과 보안
|
## 구성과 보안
|
||||||
|
|
||||||
원본 `RES/MmoneyCoder.ini`의 값은 저장소에 복사하지 않았습니다. [예제 설정](Config/appsettings.example.json)에는 빈 값만 있으며 실제 설정은 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\database.local.json` 또는 `MBN_STOCK_*` 환경 변수를 사용합니다. 로컬 파일 생성, 환경 변수, 보안과 스모크 테스트 방법은 [DB 운영 가이드](docs/DATABASE.md)에 정리했습니다.
|
원본 `Res/MmoneyCoder.ini`의 값은 저장소에 복사하지 않았습니다. 일반 Visual Studio 개발 빌드는 원본 INI를 Git 밖에서 실행 파일 옆 `Res`로 복사해 기존 경로를 재현하고, MSIX는 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` 또는 기존 `database.local.json`을 사용합니다. 환경 변수는 선택된 파일보다 우선합니다. 설정 우선순위, 보안과 실제 DB 스모크 방법은 [DB 운영 가이드](docs/DATABASE.md)에 정리했습니다.
|
||||||
|
|
||||||
MSIX 설치 폴더는 읽기 전용입니다. 로그, 플레이리스트, 운영자 설정과 미리보기 파일은 이후 `ApplicationData.Current.LocalFolder` 또는 `LocalCacheFolder`에 저장해야 합니다. Tornado 씬/영상처럼 큰 운영 자산은 별도의 설정 가능한 외부 폴더로 유지합니다.
|
MSIX 설치 폴더는 읽기 전용입니다. 빌드 시 읽기 전용 원본 `Cuts` 179개와 허용된 UI용 `Res` 파일은 실행 출력·게시·MSIX에 포함되지만, DB 자격증명 INI·백업·인증서·벤더 DLL은 포함하지 않습니다. 현재 원본에 없는 `s5006`/`s6001` 영상과 일부 배경은 별도 원본 제작 자산을 확보해야 합니다. 로그, 플레이리스트, 운영자 설정과 미리보기 파일은 `ApplicationData.Current.LocalFolder` 또는 `LocalCacheFolder`에 저장합니다.
|
||||||
|
|
||||||
## 프로젝트 구조
|
## 프로젝트 구조
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,18 @@ Oracle ODP.NET Core 23 계열은 TAP 기반 `OpenAsync`/명령 비동기를 지
|
|||||||
|
|
||||||
## 런타임 설정
|
## 런타임 설정
|
||||||
|
|
||||||
MSIX 설치 폴더는 읽기 전용이므로 설정 파일을 패키지 디렉터리에 두지 않습니다. 기본 경로는 다음과 같습니다.
|
원본 호환 앱은 다음 순서로 DB 설정을 선택합니다.
|
||||||
|
|
||||||
|
1. 실행 파일 옆 `Res\MmoneyCoder.ini` — Visual Studio의 일반 개발 출력에서 원본과 같은 방식으로 사용
|
||||||
|
2. `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini` — MSIX용 로컬 overlay
|
||||||
|
3. 기존 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\database.local.json` — INI가 없을 때 fallback
|
||||||
|
|
||||||
|
`MmoneyCoder.ini`의 `[Oracle]`/`[Maria]` 섹션에서 `ConnectionName=host:port/service`,
|
||||||
|
`ID`, `Pass`를 읽습니다. BOM 없는 UTF-8과 CP949를 지원하고 환경 변수는 선택된 파일 값보다
|
||||||
|
우선합니다. 원본 `Res`의 복사본·`.bak`·`.zip`은 사용하지도 배포하지도 않습니다.
|
||||||
|
|
||||||
|
MSIX 설치 폴더는 읽기 전용이며 평문 자격증명을 패키지에 포함하지 않습니다. 패키지에서는
|
||||||
|
두 번째 또는 세 번째 경로를 사용합니다. JSON 기본 경로는 다음과 같습니다.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\database.local.json
|
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\database.local.json
|
||||||
@@ -34,7 +45,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\Initialize-DatabaseConfig.ps1
|
|||||||
|
|
||||||
Oracle은 `sid` 또는 `serviceName` 중 정확히 하나를 설정합니다. 원본 MBN_STOCK_N은 SID descriptor를 사용했으므로 legacy 연결은 `sid`로 옮깁니다. 설정 파일은 평문 비밀번호를 포함할 수 있으므로 저장소·로그·첨부파일로 복사하지 않습니다. 운영 환경에서는 실행 계정의 파일 ACL을 유지하고, 환경 변수 오버라이드 또는 조직의 승인된 비밀 저장소를 사용할 수 있습니다.
|
Oracle은 `sid` 또는 `serviceName` 중 정확히 하나를 설정합니다. 원본 MBN_STOCK_N은 SID descriptor를 사용했으므로 legacy 연결은 `sid`로 옮깁니다. 설정 파일은 평문 비밀번호를 포함할 수 있으므로 저장소·로그·첨부파일로 복사하지 않습니다. 운영 환경에서는 실행 계정의 파일 ACL을 유지하고, 환경 변수 오버라이드 또는 조직의 승인된 비밀 저장소를 사용할 수 있습니다.
|
||||||
|
|
||||||
JSON보다 환경 변수가 우선합니다.
|
선택된 INI 또는 JSON보다 환경 변수가 우선합니다.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
MBN_STOCK_ORACLE_HOST / PORT / SID / SERVICE_NAME / USERNAME / PASSWORD
|
MBN_STOCK_ORACLE_HOST / PORT / SID / SERVICE_NAME / USERNAME / PASSWORD
|
||||||
@@ -70,6 +81,19 @@ dotnet test MBN_STOCK_WEBVIEW.sln -c Release -p:Platform=x64
|
|||||||
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj -c Release
|
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj -c Release
|
||||||
```
|
```
|
||||||
|
|
||||||
|
원본 실행 폴더의 `Res\MmoneyCoder.ini` 경로를 그대로 검증할 때는 다음처럼 실행합니다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke.csproj `
|
||||||
|
-c Release -- --legacy-runtime-root `
|
||||||
|
"C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug"
|
||||||
|
```
|
||||||
|
|
||||||
|
이 모드의 NXT endpoint 감사는 지정한 INI의 host/port/database를 환경변수 적용 전
|
||||||
|
비교 기준으로 사용합니다. 기본/`--config` 모드도 선택한 JSON의 환경변수 적용 전
|
||||||
|
endpoint를 기준으로 사용하므로 환경변수가 연결 대상을 바꾼 경우를 감지할 수
|
||||||
|
있습니다. 비교 출력은 SHA-256과 일치 여부뿐이며 계정과 비밀번호는 포함하지 않습니다.
|
||||||
|
|
||||||
스모크는 양쪽 `SELECT 1`, Oracle 서버 버전, 코스피·코스닥·NXT·5개 국내 지수·해외 종목 카탈로그의 실제 행을 확인합니다. 출력에는 상태, 테이블 이름, 행 수만 포함합니다.
|
스모크는 양쪽 `SELECT 1`, Oracle 서버 버전, 코스피·코스닥·NXT·5개 국내 지수·해외 종목 카탈로그의 실제 행을 확인합니다. 출력에는 상태, 테이블 이름, 행 수만 포함합니다.
|
||||||
|
|
||||||
## MSIX
|
## MSIX
|
||||||
|
|||||||
30
docs/DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md
Normal file
30
docs/DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md
Normal 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으로 확인되고 같은 개발 범위 안에 있으면 별도의 대화형 승인을
|
||||||
|
받지 않고 이어서 작업한다.
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# Visual Studio 2026 개발 Tornado Live 실행
|
# Visual Studio 2026 개발 Tornado Live 실행
|
||||||
|
|
||||||
|
> 2026-07-18: 현재 지정 개발 PC에서는 사용자가 Tornado2/PGM 마이그레이션 작업을
|
||||||
|
> 상시 허용했다. 아래 Git 제외 로컬 승인 파일은 이 개발 환경의 지속 설정으로 사용하며,
|
||||||
|
> 매 실행이나 명령마다 새 계획 해시·대화형 승인을 요청하지 않는다. 운영/방송 대상에는
|
||||||
|
> 이 예외를 적용하지 않으며 자세한 범위는
|
||||||
|
> [개발 환경 상시 권한](DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)을 따른다.
|
||||||
|
|
||||||
## 목적과 안전 기본
|
## 목적과 안전 기본
|
||||||
|
|
||||||
이 문서는 개발 PC의 Visual Studio 2026에서 `MBN_STOCK_WEBVIEW.LegacyParityApp`을
|
이 문서는 개발 PC의 Visual Studio 2026에서 `MBN_STOCK_WEBVIEW.LegacyParityApp`을
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# MBN_STOCK_N 동작 동등성 작업 기록
|
# MBN_STOCK_N 동작 동등성 작업 기록
|
||||||
|
|
||||||
|
> 2026-07-18 이후 현재 개발 PC의 Tornado2/PGM 작업에는
|
||||||
|
> [개발 환경 상시 권한](DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)을 적용한다. 아래의
|
||||||
|
> 과거 회차별 승인 기록은 당시 증거이며 새 개발 작업의 반복 승인 요구로 재사용하지 않는다.
|
||||||
|
|
||||||
기준 원본은 `C:\Users\MD\source\repos\MBN_STOCK_N`이며 항상 읽기 전용으로 분석한다.
|
기준 원본은 `C:\Users\MD\source\repos\MBN_STOCK_N`이며 항상 읽기 전용으로 분석한다.
|
||||||
대상은 `MBN_STOCK_WEBVIEW.LegacyParityApp`이다. 화면 모양만 비슷하게 만드는 것이 아니라
|
대상은 `MBN_STOCK_WEBVIEW.LegacyParityApp`이다. 화면 모양만 비슷하게 만드는 것이 아니라
|
||||||
원본 이벤트의 입력, 상태 전이, DB 조회, K3D 호출 순서와 화면 피드백을 코드 단위로 대조한다.
|
원본 이벤트의 입력, 상태 전이, DB 조회, K3D 호출 순서와 화면 피드백을 코드 단위로 대조한다.
|
||||||
|
|||||||
66
docs/LEGACY_RUNTIME_ASSETS.md
Normal file
66
docs/LEGACY_RUNTIME_ASSETS.md
Normal 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 추적 대상이 아니다.
|
||||||
75
docs/LEGACY_RUNTIME_ASSET_VALIDATION_20260718.md
Normal file
75
docs/LEGACY_RUNTIME_ASSET_VALIDATION_20260718.md
Normal 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가
|
||||||
|
명시적으로 차단해야 한다.
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
- Oracle 및 MariaDB SQL/데이터 요청 계층
|
- Oracle 및 MariaDB SQL/데이터 요청 계층
|
||||||
- FarPoint Spread 4 플레이리스트/그리드
|
- FarPoint Spread 4 플레이리스트/그리드
|
||||||
|
|
||||||
원본 작업 트리의 `MainForm.cs`와 `RES/MmoneyCoder.ini`에는 커밋되지 않은 변경이 있습니다. 원본 저장소는 그대로 보존했으며, 새 프로젝트에는 비밀값이 포함된 INI를 복사하지 않았습니다.
|
원본 작업 트리의 `MainForm.cs`와 `RES/MmoneyCoder.ini`에는 커밋되지 않은 변경이 있습니다. 원본 저장소는 그대로 보존합니다. 실제 DB INI는 Git이나 MSIX에 넣지 않으며, 일반 개발 빌드에서만 읽기 전용 원본 `bin\Debug\Res`에서 실행 파일 옆 `Res`로 복사해 기존 상대 경로를 재현합니다.
|
||||||
|
|
||||||
## 화면/계약 적용 현황
|
## 화면/계약 적용 현황
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| WinForms MainForm | WinUI 3 `MainWindow` + 로컬 WebView2 UI |
|
| WinForms MainForm | WinUI 3 `MainWindow` + 로컬 WebView2 UI |
|
||||||
| FarPoint `FpSpread` 플레이리스트 | HTML table + 선택/드래그 정렬/이동/삭제 |
|
| FarPoint `FpSpread` 플레이리스트 | HTML table + 선택/드래그 정렬/이동/삭제 |
|
||||||
|
| 실행 폴더 `Cuts`/`Res` | 읽기 전용 원본 `bin\Debug`를 빌드 입력으로 연결해 실행 출력과 MSIX에 비민감 자산 배치 |
|
||||||
| 플레이리스트 임시 저장 | WebView2 사용자 데이터 영역의 `localStorage` |
|
| 플레이리스트 임시 저장 | WebView2 사용자 데이터 영역의 `localStorage` |
|
||||||
| Oracle `DC_LIST`/`PLAY_LIST` | 이름 있는 플레이리스트 생성·교체·불러오기·삭제와 fresh page preflight |
|
| Oracle `DC_LIST`/`PLAY_LIST` | 이름 있는 플레이리스트 생성·교체·불러오기·삭제와 fresh page preflight |
|
||||||
| WinForms 단축키 | Web UI `F2` / `F8` / `Esc` 처리 |
|
| WinForms 단축키 | Web UI `F2` / `F8` / `Esc` 처리 |
|
||||||
|
|||||||
@@ -38,10 +38,16 @@ SUBSTRING(SB_ITEM.SB_I_CODE, 2, 12) = v_all_stock.F_STOCK_CODE
|
|||||||
|
|
||||||
새 `LegacyThemeSelectionService`와
|
새 `LegacyThemeSelectionService`와
|
||||||
`LegacyNamedNxtThemeRestoreService`도 같은 prefix 제거 규칙, 같은
|
`LegacyNamedNxtThemeRestoreService`도 같은 prefix 제거 규칙, 같은
|
||||||
`F_STOP_GUBUN = 'N'` 조건을 사용한다. 원본 설정과 현재 runtime 설정의
|
`F_STOP_GUBUN = 'N'` 조건을 사용한다. 이 문서의 검증 당시에는 선택한 원본
|
||||||
host/port/database를 비밀값 없이 정규화해 비교한 결과 SHA-256과 값이
|
설정과 현재 runtime 설정의 host/port/database를 비밀값 없이 정규화해 비교한
|
||||||
동일했고, 실제 `DATABASE()`도 현재 runtime database와 일치했다. 서버는
|
결과 SHA-256과 값이 동일했고, 실제 `DATABASE()`도 현재 runtime database와
|
||||||
MariaDB 10.5 계열이다.
|
일치했다. 서버는 MariaDB 10.5 계열이다.
|
||||||
|
|
||||||
|
현재 스모크 도구는 머신 고정 경로를 다시 읽지 않는다. 기본/`--config` 실행은
|
||||||
|
선택한 JSON을 환경변수 적용 전에 읽은 endpoint를, `--legacy-runtime-root` 실행은
|
||||||
|
그 루트의 `Res\MmoneyCoder.ini`를 환경변수 적용 전에 읽은 endpoint를 비교
|
||||||
|
기준으로 명시 전달한다. 실제 연결에 적용된 endpoint와의 비교 결과만 SHA-256으로
|
||||||
|
출력하며 계정과 비밀번호는 비교 모델이나 출력에 포함하지 않는다.
|
||||||
|
|
||||||
따라서 다른 DB를 바라보는 마이그레이션 오류가 아니며, 현 데이터 상태에서는
|
따라서 다른 DB를 바라보는 마이그레이션 오류가 아니며, 현 데이터 상태에서는
|
||||||
원본 프로그램도 같은 `v_all_stock` 경로로 NXT 테마 구성종목을 얻지 못한다.
|
원본 프로그램도 같은 `v_all_stock` 경로로 NXT 테마 구성종목을 얻지 못한다.
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
# Tornado/K3D x64 송출 운영 가이드
|
# Tornado/K3D x64 송출 운영 가이드
|
||||||
|
|
||||||
|
> 2026-07-18 개발 환경 변경: 현재 지정 개발 PC의 Tornado2/PGM 마이그레이션 검증은
|
||||||
|
> [개발 환경 상시 권한](DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)에 따라 회차별 대화형
|
||||||
|
> 승인을 다시 받지 않는다. 운영/방송 대상과 `OutcomeUnknown` 중단 규칙은 그대로다.
|
||||||
|
|
||||||
## 안전 기준
|
## 안전 기준
|
||||||
|
|
||||||
기본 모드는 `DryRun`입니다. `DryRun`은 COM 객체를 만들거나 Tornado 출력에 명령을 보내지 않고 WebView 메시지, 큐, 상태 및 오류 표시 흐름만 검증합니다. 현재 방송 PROGRAM(PGM)에 연결된 Tornado 프로세스는 항상 **안전하지 않은 대상**으로 취급합니다. 프로세스 이름이나 실행 여부만으로 테스트 대상이라고 판단하지 않습니다.
|
기본 모드는 `DryRun`입니다. `DryRun`은 COM 객체를 만들거나 Tornado 출력에 명령을 보내지 않고 WebView 메시지, 큐, 상태 및 오류 표시 흐름만 검증합니다. 현재 방송 PROGRAM(PGM)에 연결된 Tornado 프로세스는 항상 **안전하지 않은 대상**으로 취급합니다. 프로세스 이름이나 실행 여부만으로 테스트 대상이라고 판단하지 않습니다.
|
||||||
|
|
||||||
이 저장소의 작업과 자동 검증은 실제 PGM 출력 또는 라이브 `TAKE IN`을 허가하지 않습니다. `Test` 검증은 PGM과 분리된 전용 테스트 인스턴스, 테스트 출력 채널 및 허용 목록에 든 테스트 씬을 모두 확인한 뒤에만 수행합니다. `Live`는 운영 책임자의 명시적인 회차별 허가 없이는 사용하지 않습니다.
|
운영/방송 대상의 작업과 자동 검증은 실제 PGM 출력 또는 라이브 `TAKE IN`을 자동 허가하지 않습니다. `Test` 검증은 PGM과 분리된 전용 테스트 인스턴스, 테스트 출력 채널 및 허용 목록에 든 테스트 씬을 모두 확인한 뒤에만 수행합니다. 단, 위에 링크한 현재 개발 PC의 상시 권한 범위에서는 별도의 회차별 허가를 다시 요청하지 않습니다.
|
||||||
|
|
||||||
이 문서의 adapter·builder·DryRun 완료 표현은 화면/계약 구현 범위다. 운영 DB-W, 외부 asset readiness, 기존 데이터 복원과 장면별 실제 PGM은 별도 증거가 있어야 한다. 현재 미적용·적용 중·외부자산 필요 항목은 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)를 따른다.
|
이 문서의 adapter·builder·DryRun 완료 표현은 화면/계약 구현 범위다. 운영 DB-W, 외부 asset readiness, 기존 데이터 복원과 장면별 실제 PGM은 별도 증거가 있어야 한다. 현재 미적용·적용 중·외부자산 필요 항목은 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)를 따른다.
|
||||||
|
|
||||||
@@ -85,14 +89,14 @@ SDK가 기본 위치에 없다면 x64 SDK의 `TlbImp.exe` 절대 경로를 `-Tlb
|
|||||||
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\playout.local.json
|
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\playout.local.json
|
||||||
```
|
```
|
||||||
|
|
||||||
[playout.example.json](../Config/playout.example.json)을 구조 참고용으로 사용합니다. 예시는 `DryRun`, 외부 씬 루트와 출력 채널 미지정, 빈 씬 허용 목록, 라이브 신뢰 플래그 해제 상태이므로 실제 출력에 사용할 수 없습니다. 테스트 장비의 호스트, 채널, 창 제목 패턴, 외부 씬 루트와 허용할 씬 이름은 로컬 파일에만 기록하고 Git, 로그 또는 지원 첨부파일에 넣지 않습니다.
|
[playout.example.json](../Config/playout.example.json)을 구조 참고용으로 사용합니다. 예시는 `DryRun`, 출력 채널 미지정, 빈 씬 허용 목록, 라이브 신뢰 플래그 해제 상태이므로 실제 출력에 사용할 수 없습니다. `sceneDirectory`가 비어 있고 실행 파일 옆에 빌드가 배치한 `Cuts`가 있으면 그 폴더를 기본으로 사용합니다. 별도 씬 루트를 명시할 때는 테스트 장비의 호스트, 채널, 창 제목 패턴과 허용할 씬 이름을 로컬 파일에만 기록하고 Git, 로그 또는 지원 첨부파일에 넣지 않습니다.
|
||||||
|
|
||||||
| 속성 | 의미 |
|
| 속성 | 의미 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `mode` | `Disabled`, `DryRun`, `Test`, `Live` 중 하나 |
|
| `mode` | `Disabled`, `DryRun`, `Test`, `Live` 중 하나 |
|
||||||
| `host`, `port` | 테스트 또는 운영 승인을 받은 KTAP endpoint |
|
| `host`, `port` | 테스트 또는 운영 승인을 받은 KTAP endpoint |
|
||||||
| `tcpMode`, `clientPort` | `KTAPConnect`의 전송 모드와 로컬 client port 인자. Test/Live는 유실 방지를 위해 `tcpMode: 1`만 허용 |
|
| `tcpMode`, `clientPort` | `KTAPConnect`의 전송 모드와 로컬 client port 인자. Test/Live는 유실 방지를 위해 `tcpMode: 1`만 허용 |
|
||||||
| `sceneDirectory` | Test/Live에서 사용하는 외부 `.t2s` 루트의 절대 경로. `null`은 안전한 미설정 상태 |
|
| `sceneDirectory` | Test/Live에서 사용하는 `.t2s` 루트의 절대 경로. 명시값이 우선하며, `null`이면 실행 파일 옆 `Cuts`가 존재할 때 그 경로를 사용하고 없으면 미설정 상태 |
|
||||||
| `outputChannel` | 확인된 전용 출력 채널. `null`은 안전한 미설정 상태 |
|
| `outputChannel` | 확인된 전용 출력 채널. `null`은 안전한 미설정 상태 |
|
||||||
| `layoutIndex` | 씬 플레이어 layout 위치 |
|
| `layoutIndex` | 씬 플레이어 layout 위치 |
|
||||||
| `legacySceneFadeDuration` | 원본 `ComboDi.SelectedIndex`에 대응하는 fade. 기본 인덱스 6(화면 7), 허용 범위 0~19(화면 표시 1~20) |
|
| `legacySceneFadeDuration` | 원본 `ComboDi.SelectedIndex`에 대응하는 fade. 기본 인덱스 6(화면 7), 허용 범위 0~19(화면 표시 1~20) |
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# MBN_STOCK_WEBVIEW 송출 운영·검증 절차
|
# MBN_STOCK_WEBVIEW 송출 운영·검증 절차
|
||||||
|
|
||||||
|
> 2026-07-18 개발 환경 변경: 이 문서의 회차별 승인 절차는 운영/방송 대상에 계속
|
||||||
|
> 적용한다. 현재 지정된 개발 PC의 Tornado2/PGM 마이그레이션 검증은 사용자가 상시
|
||||||
|
> 허용했으므로 반복 승인을 요청하지 않는다. 범위와 기술적 중단 조건은
|
||||||
|
> [개발 환경 상시 권한](DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)을 우선 적용한다.
|
||||||
|
|
||||||
이 문서는 마이그레이션된 WebView → `IPlayoutEngine` → K3D/Tornado2 경로를 검증할 때의 승인, 실행, 감시, 장애 복구와 rollback 기준이다. 기존 [`PLAYOUT.md`](PLAYOUT.md)의 K3D 설치·해시 핀·격리 진단 규칙을 대체하지 않고, 35개 scene과 PageN 동등성 검증에 필요한 운영 절차를 추가한다. 화면/계약 구현과 운영 동등성의 최신 재감사 상태는 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)를 따른다.
|
이 문서는 마이그레이션된 WebView → `IPlayoutEngine` → K3D/Tornado2 경로를 검증할 때의 승인, 실행, 감시, 장애 복구와 rollback 기준이다. 기존 [`PLAYOUT.md`](PLAYOUT.md)의 K3D 설치·해시 핀·격리 진단 규칙을 대체하지 않고, 35개 scene과 PageN 동등성 검증에 필요한 운영 절차를 추가한다. 화면/계약 구현과 운영 동등성의 최신 재감사 상태는 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)를 따른다.
|
||||||
|
|
||||||
## 현재 상태
|
## 현재 상태
|
||||||
|
|||||||
@@ -44,10 +44,7 @@ public sealed class DatabaseOptionsLoader
|
|||||||
{
|
{
|
||||||
var path = jsonPath ?? DefaultConfigurationPath;
|
var path = jsonPath ?? DefaultConfigurationPath;
|
||||||
var options = LoadJson(path);
|
var options = LoadJson(path);
|
||||||
ApplyEnvironmentOverrides(options);
|
return ApplyEnvironmentOverridesAndValidate(options);
|
||||||
options.Normalize();
|
|
||||||
options.ValidateRuntimeSettings();
|
|
||||||
return options;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DatabaseOptions> LoadAsync(
|
public async Task<DatabaseOptions> LoadAsync(
|
||||||
@@ -56,6 +53,12 @@ public sealed class DatabaseOptionsLoader
|
|||||||
{
|
{
|
||||||
var path = jsonPath ?? DefaultConfigurationPath;
|
var path = jsonPath ?? DefaultConfigurationPath;
|
||||||
var options = await LoadJsonAsync(path, cancellationToken).ConfigureAwait(false);
|
var options = await LoadJsonAsync(path, cancellationToken).ConfigureAwait(false);
|
||||||
|
return ApplyEnvironmentOverridesAndValidate(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal DatabaseOptions ApplyEnvironmentOverridesAndValidate(DatabaseOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
ApplyEnvironmentOverrides(options);
|
ApplyEnvironmentOverrides(options);
|
||||||
options.Normalize();
|
options.Normalize();
|
||||||
options.ValidateRuntimeSettings();
|
options.ValidateRuntimeSettings();
|
||||||
|
|||||||
@@ -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("실행 폴더의 레거시 데이터베이스 설정을 읽을 수 없습니다.");
|
||||||
|
}
|
||||||
@@ -30,6 +30,43 @@ public sealed class DatabaseRuntime
|
|||||||
return Create(options);
|
return Create(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static DatabaseRuntime CreateLegacyExecutableDefault(
|
||||||
|
string baseDirectory,
|
||||||
|
string? fallbackConfigurationPath = null,
|
||||||
|
string? legacyOverridePath = null)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(baseDirectory);
|
||||||
|
var legacyIniPath = Path.Combine(
|
||||||
|
Path.GetFullPath(baseDirectory),
|
||||||
|
"Res",
|
||||||
|
"MmoneyCoder.ini");
|
||||||
|
if (File.Exists(legacyIniPath))
|
||||||
|
{
|
||||||
|
return Create(new LegacyIniDatabaseOptionsLoader().Load(legacyIniPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
var localOverride = legacyOverridePath ?? ResolveLegacyOverridePath();
|
||||||
|
if (!string.IsNullOrWhiteSpace(localOverride) && File.Exists(localOverride))
|
||||||
|
{
|
||||||
|
return Create(new LegacyIniDatabaseOptionsLoader().Load(localOverride));
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreateDefault(fallbackConfigurationPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveLegacyOverridePath()
|
||||||
|
{
|
||||||
|
var localAppData = Environment.GetFolderPath(
|
||||||
|
Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
return string.IsNullOrWhiteSpace(localAppData)
|
||||||
|
? null
|
||||||
|
: Path.Combine(
|
||||||
|
localAppData,
|
||||||
|
"MBN_STOCK_WEBVIEW",
|
||||||
|
"Res",
|
||||||
|
"MmoneyCoder.ini");
|
||||||
|
}
|
||||||
|
|
||||||
public static DatabaseRuntime Create(DatabaseOptions options)
|
public static DatabaseRuntime Create(DatabaseOptions options)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ internal static class LegacyComparisonWorkflowCatalog
|
|||||||
|
|
||||||
public static IReadOnlyList<LegacyComparisonTarget> FixedTargets => FixedTargetList;
|
public static IReadOnlyList<LegacyComparisonTarget> FixedTargets => FixedTargetList;
|
||||||
|
|
||||||
|
internal static IReadOnlyList<LegacyComparisonActionDefinition> Actions => ActionList;
|
||||||
|
|
||||||
public static LegacyComparisonTarget? GetFixedTarget(string targetId) =>
|
public static LegacyComparisonTarget? GetFixedTarget(string targetId) =>
|
||||||
targetId is not null && FixedById.TryGetValue(targetId, out var target) ? target : null;
|
targetId is not null && FixedById.TryGetValue(targetId, out var target) ? target : null;
|
||||||
|
|
||||||
@@ -195,13 +197,31 @@ internal static class LegacyComparisonWorkflowCatalog
|
|||||||
LegacyComparisonTarget? first,
|
LegacyComparisonTarget? first,
|
||||||
LegacyComparisonTarget? second)
|
LegacyComparisonTarget? second)
|
||||||
{
|
{
|
||||||
var views = ActionList.Select(action =>
|
var placements = ActionList
|
||||||
|
.Select(action => new LegacyComparisonActionPlacement(action.ActionId, action.Section))
|
||||||
|
.ToArray();
|
||||||
|
return CreateActionViews(first, second, placements);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<LegacyComparisonActionView> CreateActionViews(
|
||||||
|
LegacyComparisonTarget? first,
|
||||||
|
LegacyComparisonTarget? second,
|
||||||
|
IReadOnlyList<LegacyComparisonActionPlacement> placements)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(placements);
|
||||||
|
var views = placements.Select(placement =>
|
||||||
{
|
{
|
||||||
|
if (!ActionsById.TryGetValue(placement.ActionId, out var action))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The runtime comparison layout contains an unknown action.");
|
||||||
|
}
|
||||||
|
|
||||||
var compatibility = GetCompatibility(action, first, second);
|
var compatibility = GetCompatibility(action, first, second);
|
||||||
return new LegacyComparisonActionView(
|
return new LegacyComparisonActionView(
|
||||||
action.ActionId,
|
action.ActionId,
|
||||||
action.Label,
|
action.Label,
|
||||||
action.Section,
|
placement.Section,
|
||||||
compatibility.IsAvailable,
|
compatibility.IsAvailable,
|
||||||
compatibility.Reason);
|
compatibility.Reason);
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public sealed class LegacyComparisonWorkflowController
|
|||||||
{
|
{
|
||||||
private readonly ILegacyComparisonDataService _dataService;
|
private readonly ILegacyComparisonDataService _dataService;
|
||||||
private readonly ILegacyComparisonPairStore _pairStore;
|
private readonly ILegacyComparisonPairStore _pairStore;
|
||||||
|
private readonly LegacyComparisonUiLayout _uiLayout;
|
||||||
private List<SearchResultState> _domesticResults = [];
|
private List<SearchResultState> _domesticResults = [];
|
||||||
private List<SearchResultState> _worldResults = [];
|
private List<SearchResultState> _worldResults = [];
|
||||||
private List<SavedPairState> _savedPairs = [];
|
private List<SavedPairState> _savedPairs = [];
|
||||||
@@ -38,10 +39,12 @@ public sealed class LegacyComparisonWorkflowController
|
|||||||
|
|
||||||
public LegacyComparisonWorkflowController(
|
public LegacyComparisonWorkflowController(
|
||||||
ILegacyComparisonDataService dataService,
|
ILegacyComparisonDataService dataService,
|
||||||
ILegacyComparisonPairStore pairStore)
|
ILegacyComparisonPairStore pairStore,
|
||||||
|
LegacyComparisonUiLayout? uiLayout = null)
|
||||||
{
|
{
|
||||||
_dataService = dataService ?? throw new ArgumentNullException(nameof(dataService));
|
_dataService = dataService ?? throw new ArgumentNullException(nameof(dataService));
|
||||||
_pairStore = pairStore ?? throw new ArgumentNullException(nameof(pairStore));
|
_pairStore = pairStore ?? throw new ArgumentNullException(nameof(pairStore));
|
||||||
|
_uiLayout = uiLayout ?? LegacyComparisonUiLayout.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LegacyComparisonWorkflowSnapshot Current => CreateSnapshot();
|
public LegacyComparisonWorkflowSnapshot Current => CreateSnapshot();
|
||||||
@@ -606,7 +609,7 @@ public sealed class LegacyComparisonWorkflowController
|
|||||||
result.Target,
|
result.Target,
|
||||||
result.SelectionId))
|
result.SelectionId))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
var fixedTargets = LegacyComparisonWorkflowCatalog.FixedTargets
|
var fixedTargets = _uiLayout.FixedTargets
|
||||||
.Select(target => LegacyComparisonWorkflowCatalog.ToView(target, target.TargetId))
|
.Select(target => LegacyComparisonWorkflowCatalog.ToView(target, target.TargetId))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
var savedPairs = _savedPairs.Select((pair, index) =>
|
var savedPairs = _savedPairs.Select((pair, index) =>
|
||||||
@@ -645,7 +648,7 @@ public sealed class LegacyComparisonWorkflowController
|
|||||||
: LegacyComparisonWorkflowCatalog.ToView(_secondTarget, "comparison-draft-second"),
|
: LegacyComparisonWorkflowCatalog.ToView(_secondTarget, "comparison-draft-second"),
|
||||||
Array.AsReadOnly(savedPairs),
|
Array.AsReadOnly(savedPairs),
|
||||||
_selectedPairId,
|
_selectedPairId,
|
||||||
LegacyComparisonWorkflowCatalog.CreateActionViews(
|
_uiLayout.CreateActionViews(
|
||||||
selectedPair?.First,
|
selectedPair?.First,
|
||||||
selectedPair?.Second),
|
selectedPair?.Second),
|
||||||
_statusMessage,
|
_statusMessage,
|
||||||
|
|||||||
@@ -259,6 +259,35 @@ public sealed class LegacyFixedActionCatalog
|
|||||||
return RuntimeAssets[market];
|
return RuntimeAssets[market];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal LegacyFixedActionCatalog WithRuntimeOrder(
|
||||||
|
IReadOnlyDictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedAction>> actionsByMarket)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(actionsByMarket);
|
||||||
|
var ordered = new List<LegacyFixedAction>(Actions.Count);
|
||||||
|
foreach (var market in Enum.GetValues<LegacyFixedMarket>())
|
||||||
|
{
|
||||||
|
var source = actionsByMarket.TryGetValue(market, out var runtimeActions)
|
||||||
|
? runtimeActions
|
||||||
|
: _byMarket[market];
|
||||||
|
if (source is null || source.Count != _byMarket[market].Count ||
|
||||||
|
source.Any(action => action is null || action.Market != market) ||
|
||||||
|
!source.Select(action => action.Id).ToHashSet(StringComparer.Ordinal)
|
||||||
|
.SetEquals(_byMarket[market].Select(action => action.Id)))
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
$"The runtime fixed-action order for '{MarketText(market)}' is invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered.AddRange(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LegacyFixedActionCatalog(
|
||||||
|
ReadOnly(ordered),
|
||||||
|
RuntimeAssets,
|
||||||
|
DocumentSha256,
|
||||||
|
GeneratorSourceSha256);
|
||||||
|
}
|
||||||
|
|
||||||
public LegacyMaterializedFixedAction Materialize(
|
public LegacyMaterializedFixedAction Materialize(
|
||||||
string actionId,
|
string actionId,
|
||||||
DateTimeOffset localNow)
|
DateTimeOffset localNow)
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ public sealed class LegacyIndustrySelectionWorkflow
|
|||||||
|
|
||||||
private readonly IIndustrySelectionService _selectionService;
|
private readonly IIndustrySelectionService _selectionService;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly LegacyIndustryActionLayout _actionLayout;
|
||||||
private IReadOnlyList<IndustrySelection> _industries = NoIndustries;
|
private IReadOnlyList<IndustrySelection> _industries = NoIndustries;
|
||||||
private IReadOnlyList<LegacyIndustryActionDefinition> _actions = NoActions;
|
private IReadOnlyList<LegacyIndustryActionDefinition> _actions = NoActions;
|
||||||
private IndustryMarket? _market;
|
private IndustryMarket? _market;
|
||||||
@@ -84,11 +85,13 @@ public sealed class LegacyIndustrySelectionWorkflow
|
|||||||
|
|
||||||
public LegacyIndustrySelectionWorkflow(
|
public LegacyIndustrySelectionWorkflow(
|
||||||
IIndustrySelectionService selectionService,
|
IIndustrySelectionService selectionService,
|
||||||
TimeProvider? timeProvider = null)
|
TimeProvider? timeProvider = null,
|
||||||
|
LegacyIndustryActionLayout? actionLayout = null)
|
||||||
{
|
{
|
||||||
_selectionService = selectionService ??
|
_selectionService = selectionService ??
|
||||||
throw new ArgumentNullException(nameof(selectionService));
|
throw new ArgumentNullException(nameof(selectionService));
|
||||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_actionLayout = actionLayout ?? LegacyIndustryActionLayout.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LegacyIndustrySelectionSnapshot Current => CreateSnapshot();
|
public LegacyIndustrySelectionSnapshot Current => CreateSnapshot();
|
||||||
@@ -104,7 +107,7 @@ public sealed class LegacyIndustrySelectionWorkflow
|
|||||||
Interlocked.Increment(ref _loadGeneration);
|
Interlocked.Increment(ref _loadGeneration);
|
||||||
_market = market;
|
_market = market;
|
||||||
_industries = NoIndustries;
|
_industries = NoIndustries;
|
||||||
_actions = LegacyIndustryActionCatalog.GetActions(market);
|
_actions = _actionLayout.GetActions(market);
|
||||||
_selectedIndex = null;
|
_selectedIndex = null;
|
||||||
_selectedIndustry = null;
|
_selectedIndustry = null;
|
||||||
_firstIndustry = null;
|
_firstIndustry = null;
|
||||||
@@ -146,7 +149,7 @@ public sealed class LegacyIndustrySelectionWorkflow
|
|||||||
|
|
||||||
_market = market;
|
_market = market;
|
||||||
_industries = normalizedRows;
|
_industries = normalizedRows;
|
||||||
_actions = LegacyIndustryActionCatalog.GetActions(market);
|
_actions = _actionLayout.GetActions(market);
|
||||||
_selectedIndustry = Reconcile(previousSelected, normalizedRows);
|
_selectedIndustry = Reconcile(previousSelected, normalizedRows);
|
||||||
_selectedIndex = _selectedIndustry is null
|
_selectedIndex = _selectedIndustry is null
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -23,9 +23,55 @@
|
|||||||
<ApplicationDisplayVersion>0.1.0</ApplicationDisplayVersion>
|
<ApplicationDisplayVersion>0.1.0</ApplicationDisplayVersion>
|
||||||
<ApplicationVersion>1</ApplicationVersion>
|
<ApplicationVersion>1</ApplicationVersion>
|
||||||
<Version>0.1.0</Version>
|
<Version>0.1.0</Version>
|
||||||
|
<!--
|
||||||
|
Keep the legacy runtime assets outside this repository while reproducing the
|
||||||
|
original executable-folder layout. Override this property at build time when
|
||||||
|
the read-only MBN_STOCK_N checkout is in a different location:
|
||||||
|
/p:LegacyRuntimeSourceRoot="D:\path\to\MBN_STOCK_N\bin\Debug"
|
||||||
|
-->
|
||||||
|
<LegacyRuntimeSourceRoot Condition="'$(LegacyRuntimeSourceRoot)' == ''">$(MSBuildProjectDirectory)\..\..\..\MBN_STOCK_N\MBN_STOCK_N\bin\Debug</LegacyRuntimeSourceRoot>
|
||||||
|
<LegacyRuntimeSourceRoot>$([System.IO.Path]::GetFullPath('$(LegacyRuntimeSourceRoot)'))</LegacyRuntimeSourceRoot>
|
||||||
|
<LegacyCutsSourceRoot>$(LegacyRuntimeSourceRoot)\Cuts</LegacyCutsSourceRoot>
|
||||||
|
<LegacyResSourceRoot>$(LegacyRuntimeSourceRoot)\Res</LegacyResSourceRoot>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<!-- Explicit allowlist: these legacy Res assets contain UI/menu data only. -->
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\a.png" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\aa.png" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\dot.png" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\Find_16x16.png" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\Find_32x32.png" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\green.png" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\Grow.bmp" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\logo.png" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\MmoneyCoder.ico" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\MoveDown_16x16.png" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\MoveUp_16x16.png" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\P.png" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\pie.bmp" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\Preview.png" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\pro#00083.png" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\profit.bmp" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\red.png" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\sample_1.png" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\sell.bmp" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\setup.ini" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\t.png" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\VRIPNG.png" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\기타.ini" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\업종.ini" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\업종_코스닥.ini" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\업종_코스피.ini" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\종목.ini" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\종목_0111.ini" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\종목비교.ini" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\지수.ini" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\지수_0318.ini" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\지수_250401.ini" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\해외.ini" />
|
||||||
|
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\환율.ini" />
|
||||||
|
|
||||||
<Compile Include="..\..\LegacySceneRuntimeFactory.cs"
|
<Compile Include="..\..\LegacySceneRuntimeFactory.cs"
|
||||||
Link="Runtime\LegacySceneRuntimeFactory.cs" />
|
Link="Runtime\LegacySceneRuntimeFactory.cs" />
|
||||||
<Content Include="..\..\Assets\LockScreenLogo.scale-200.png" Link="Assets\LockScreenLogo.scale-200.png" />
|
<Content Include="..\..\Assets\LockScreenLogo.scale-200.png" Link="Assets\LockScreenLogo.scale-200.png" />
|
||||||
@@ -39,9 +85,99 @@
|
|||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="$(LegacyCutsSourceRoot)\**\*">
|
||||||
|
<Link>Cuts\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||||
|
<TargetPath>Cuts\%(RecursiveDir)%(Filename)%(Extension)</TargetPath>
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
</Content>
|
||||||
|
<Content Include="@(LegacyPackagedResAsset)">
|
||||||
|
<Link>Res\%(Filename)%(Extension)</Link>
|
||||||
|
<TargetPath>Res\%(Filename)%(Extension)</TargetPath>
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||||
|
</Content>
|
||||||
|
<!--
|
||||||
|
The active legacy DB INI contains credentials. It is available beside the
|
||||||
|
executable for local F5/unpackaged development only and is never MSIX Content.
|
||||||
|
Historical copies, backups, archives and afiedt.buf.txt are not included at all.
|
||||||
|
-->
|
||||||
<Manifest Include="$(ApplicationManifest)" />
|
<Manifest Include="$(ApplicationManifest)" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target Name="ValidateLegacyRuntimeAssets" BeforeTargets="PrepareForBuild">
|
||||||
|
<Error Condition="!Exists('$(LegacyRuntimeSourceRoot)')"
|
||||||
|
Text="Legacy runtime source root was not found: $(LegacyRuntimeSourceRoot). Set /p:LegacyRuntimeSourceRoot to the read-only MBN_STOCK_N bin\Debug directory." />
|
||||||
|
<Error Condition="!Exists('$(LegacyCutsSourceRoot)')"
|
||||||
|
Text="Legacy Cuts source directory was not found: $(LegacyCutsSourceRoot)." />
|
||||||
|
<Error Condition="!Exists('$(LegacyResSourceRoot)')"
|
||||||
|
Text="Legacy Res source directory was not found: $(LegacyResSourceRoot)." />
|
||||||
|
<Error Condition="!Exists('$(LegacyCutsSourceRoot)\5001.t2s')"
|
||||||
|
Text="The required legacy scene file Cuts\5001.t2s is missing from $(LegacyRuntimeSourceRoot)." />
|
||||||
|
<Error Condition="'$(GenerateAppxPackageOnBuild)' != 'true' and
|
||||||
|
'$(PublishAppxPackage)' != 'true' and
|
||||||
|
!Exists('$(LegacyResSourceRoot)\MmoneyCoder.ini')"
|
||||||
|
Text="The required legacy database settings file Res\MmoneyCoder.ini is missing from $(LegacyRuntimeSourceRoot)." />
|
||||||
|
<Error Condition="!Exists('$(LegacyResSourceRoot)\종목.ini')"
|
||||||
|
Text="The required legacy stock menu file Res\종목.ini is missing from $(LegacyRuntimeSourceRoot)." />
|
||||||
|
<Error Condition="!Exists('$(LegacyResSourceRoot)\업종_코스피.ini')"
|
||||||
|
Text="The required legacy KOSPI industry menu file Res\업종_코스피.ini is missing from $(LegacyRuntimeSourceRoot)." />
|
||||||
|
<Error Condition="!Exists('$(LegacyResSourceRoot)\업종_코스닥.ini')"
|
||||||
|
Text="The required legacy KOSDAQ industry menu file Res\업종_코스닥.ini is missing from $(LegacyRuntimeSourceRoot)." />
|
||||||
|
<Error Condition="!Exists('$(LegacyResSourceRoot)\해외.ini')"
|
||||||
|
Text="The required legacy overseas menu file Res\해외.ini is missing from $(LegacyRuntimeSourceRoot)." />
|
||||||
|
<Error Condition="!Exists('$(LegacyResSourceRoot)\환율.ini')"
|
||||||
|
Text="The required legacy exchange menu file Res\환율.ini is missing from $(LegacyRuntimeSourceRoot)." />
|
||||||
|
<Error Condition="!Exists('$(LegacyResSourceRoot)\지수.ini')"
|
||||||
|
Text="The required legacy index menu file Res\지수.ini is missing from $(LegacyRuntimeSourceRoot)." />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Do not declare the credential-bearing INI as Content, None, or another item
|
||||||
|
harvested by PackagingOutputs. The MSIX tooling harvests every
|
||||||
|
CopyToOutputDirectory item into its loose-package recipe, even when
|
||||||
|
GenerateAppxPackageOnBuild is false. This post-build copy provides the original
|
||||||
|
executable-relative layout only for ordinary local output. The CPS-only
|
||||||
|
up-to-date items below are not package payload candidates.
|
||||||
|
-->
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- These CPS-only items participate in Visual Studio's fast up-to-date check. -->
|
||||||
|
<UpToDateCheckInput Include="$(LegacyResSourceRoot)\MmoneyCoder.ini" />
|
||||||
|
<UpToDateCheckBuilt Include="$(TargetDir)Res\MmoneyCoder.ini" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target Name="CopyLegacyDatabaseIniToLocalOutput"
|
||||||
|
AfterTargets="Build"
|
||||||
|
Inputs="$(LegacyResSourceRoot)\MmoneyCoder.ini"
|
||||||
|
Outputs="$(TargetDir)Res\MmoneyCoder.ini"
|
||||||
|
Condition="'$(GenerateAppxPackageOnBuild)' != 'true' and '$(PublishAppxPackage)' != 'true'">
|
||||||
|
<MakeDir Directories="$(TargetDir)Res" />
|
||||||
|
<Copy SourceFiles="$(LegacyResSourceRoot)\MmoneyCoder.ini"
|
||||||
|
DestinationFiles="$(TargetDir)Res\MmoneyCoder.ini"
|
||||||
|
SkipUnchangedFiles="true" />
|
||||||
|
<ItemGroup>
|
||||||
|
<FileWrites Include="$(TargetDir)Res\MmoneyCoder.ini" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<!-- The credential-bearing local development copy must not survive Clean. -->
|
||||||
|
<Target Name="RemoveLegacyDatabaseIniFromLocalOutput"
|
||||||
|
BeforeTargets="Clean">
|
||||||
|
<Delete Files="$(TargetDir)Res\MmoneyCoder.ini"
|
||||||
|
Condition="Exists('$(TargetDir)Res\MmoneyCoder.ini')" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<Target Name="RejectLegacyDatabaseIniFromPackagePayload"
|
||||||
|
BeforeTargets="_ComputeAppxPackagePayload">
|
||||||
|
<ItemGroup>
|
||||||
|
<_ForbiddenLegacyDatabasePackagePayload Include="@(PackagingOutputs)"
|
||||||
|
Condition="'%(PackagingOutputs.TargetPath)' == 'Res\MmoneyCoder.ini' or
|
||||||
|
'%(PackagingOutputs.TargetPath)' == 'Res/MmoneyCoder.ini'" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Error Condition="'@(_ForbiddenLegacyDatabasePackagePayload)' != ''"
|
||||||
|
Text="Credential-bearing Res\MmoneyCoder.ini must never enter an MSIX payload or package recipe." />
|
||||||
|
</Target>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" />
|
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" />
|
||||||
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3967.48" />
|
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3967.48" />
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ public sealed partial class MainWindow : Window
|
|||||||
var cutMenuComposition = LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates(
|
var cutMenuComposition = LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates(
|
||||||
runtimeCutMenuOverridePath,
|
runtimeCutMenuOverridePath,
|
||||||
AppContext.BaseDirectory);
|
AppContext.BaseDirectory);
|
||||||
|
var runtimeUiCatalogs = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory(
|
||||||
|
AppContext.BaseDirectory);
|
||||||
|
|
||||||
ILegacyStockLookup stockLookup;
|
ILegacyStockLookup stockLookup;
|
||||||
IIndustrySelectionService industrySelectionService;
|
IIndustrySelectionService industrySelectionService;
|
||||||
@@ -76,7 +78,8 @@ public sealed partial class MainWindow : Window
|
|||||||
string? initializationError = null;
|
string? initializationError = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_databaseRuntime = DatabaseRuntime.CreateDefault();
|
_databaseRuntime = DatabaseRuntime.CreateLegacyExecutableDefault(
|
||||||
|
AppContext.BaseDirectory);
|
||||||
DataQueryExecutor.Configure(_databaseRuntime.Executor);
|
DataQueryExecutor.Configure(_databaseRuntime.Executor);
|
||||||
var databaseMutationAuthorization =
|
var databaseMutationAuthorization =
|
||||||
new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization);
|
new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization);
|
||||||
@@ -163,7 +166,9 @@ public sealed partial class MainWindow : Window
|
|||||||
DataQueryExecutor.Reset();
|
DataQueryExecutor.Reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
_industryWorkflow = new LegacyIndustrySelectionWorkflow(industrySelectionService);
|
_industryWorkflow = new LegacyIndustrySelectionWorkflow(
|
||||||
|
industrySelectionService,
|
||||||
|
actionLayout: runtimeUiCatalogs.IndustryLayout);
|
||||||
var overseasWorkflow = new LegacyOverseasSelectionWorkflow(
|
var overseasWorkflow = new LegacyOverseasSelectionWorkflow(
|
||||||
overseasIndustrySearchService,
|
overseasIndustrySearchService,
|
||||||
overseasStockSearchService);
|
overseasStockSearchService);
|
||||||
@@ -177,6 +182,7 @@ public sealed partial class MainWindow : Window
|
|||||||
var manualListsWorkflow = CreateManualListsWorkflow(stockLookup);
|
var manualListsWorkflow = CreateManualListsWorkflow(stockLookup);
|
||||||
_controller = new LegacyOperatorController(
|
_controller = new LegacyOperatorController(
|
||||||
stockLookup,
|
stockLookup,
|
||||||
|
fixedCatalog: runtimeUiCatalogs.FixedCatalog,
|
||||||
industryWorkflow: _industryWorkflow,
|
industryWorkflow: _industryWorkflow,
|
||||||
themeWorkflow: new LegacyThemeWorkflow(themeSelectionService),
|
themeWorkflow: new LegacyThemeWorkflow(themeSelectionService),
|
||||||
expertWorkflow: new LegacyExpertWorkflowController(expertSelectionService),
|
expertWorkflow: new LegacyExpertWorkflowController(expertSelectionService),
|
||||||
@@ -184,7 +190,8 @@ public sealed partial class MainWindow : Window
|
|||||||
tradingHaltSelectionService),
|
tradingHaltSelectionService),
|
||||||
comparisonWorkflow: new LegacyComparisonWorkflowController(
|
comparisonWorkflow: new LegacyComparisonWorkflowController(
|
||||||
new LegacyComparisonDataService(stockLookup, worldStockSearchService),
|
new LegacyComparisonDataService(stockLookup, worldStockSearchService),
|
||||||
comparisonStore),
|
comparisonStore,
|
||||||
|
runtimeUiCatalogs.ComparisonLayout),
|
||||||
manualFinancialWorkflow: new LegacyManualFinancialWorkflow(
|
manualFinancialWorkflow: new LegacyManualFinancialWorkflow(
|
||||||
manualFinancialService,
|
manualFinancialService,
|
||||||
manualFinancialStockSearchService),
|
manualFinancialStockSearchService),
|
||||||
@@ -205,9 +212,15 @@ public sealed partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
_controller.ReportError(initializationError);
|
_controller.ReportError(initializationError);
|
||||||
}
|
}
|
||||||
else if (cutMenuComposition.WarningMessage is { } cutMenuWarning)
|
else if (new[]
|
||||||
|
{
|
||||||
|
cutMenuComposition.WarningMessage,
|
||||||
|
runtimeUiCatalogs.WarningMessage
|
||||||
|
}
|
||||||
|
.Where(message => message is not null)
|
||||||
|
.ToArray() is { Length: > 0 } catalogWarnings)
|
||||||
{
|
{
|
||||||
_controller.ReportWarning(cutMenuWarning);
|
_controller.ReportWarning(string.Join(" ", catalogWarnings));
|
||||||
}
|
}
|
||||||
|
|
||||||
InitializePlayoutRuntime();
|
InitializePlayoutRuntime();
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public static class PlayoutOptionsLoader
|
|||||||
{
|
{
|
||||||
var options = LoadJson(path ?? DefaultPath);
|
var options = LoadJson(path ?? DefaultPath);
|
||||||
ApplyEnvironment(options);
|
ApplyEnvironment(options);
|
||||||
|
ApplyExecutableAssetDefaults(options, AppContext.BaseDirectory);
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +43,36 @@ public static class PlayoutOptionsLoader
|
|||||||
RequiredLiveAuthorizationValue,
|
RequiredLiveAuthorizationValue,
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
|
|
||||||
|
internal static void ApplyExecutableAssetDefaults(
|
||||||
|
PlayoutOptions options,
|
||||||
|
string? baseDirectory)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.SceneDirectory) ||
|
||||||
|
string.IsNullOrWhiteSpace(baseDirectory))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cutsDirectory = Path.Combine(
|
||||||
|
Path.GetFullPath(baseDirectory),
|
||||||
|
"Cuts");
|
||||||
|
if (Directory.Exists(cutsDirectory))
|
||||||
|
{
|
||||||
|
options.SceneDirectory = cutsDirectory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (
|
||||||
|
exception is ArgumentException or IOException or NotSupportedException or
|
||||||
|
System.Security.SecurityException)
|
||||||
|
{
|
||||||
|
// A missing or invalid executable asset root leaves the existing safe
|
||||||
|
// configuration unchanged; validation reports it if a command needs it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static PlayoutOptions LoadJson(string path)
|
private static PlayoutOptions LoadJson(string path)
|
||||||
{
|
{
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,32 +38,37 @@ public sealed class LegacyRuntimeStockCutMenuNativeContractTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DatabaseInitializationErrorWinsOverTheSingleCutMenuFallbackWarning()
|
public void DatabaseInitializationErrorWinsOverCombinedRuntimeCatalogWarnings()
|
||||||
{
|
{
|
||||||
var errorBranch = MainWindow.IndexOf(
|
var errorBranch = MainWindow.IndexOf(
|
||||||
"if (initializationError is not null)",
|
"if (initializationError is not null)",
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
var warningBranch = MainWindow.IndexOf(
|
var warningBranch = MainWindow.IndexOf(
|
||||||
"else if (cutMenuComposition.WarningMessage is { } cutMenuWarning)",
|
"cutMenuComposition.WarningMessage,",
|
||||||
errorBranch,
|
errorBranch,
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
|
var runtimeUiWarning = MainWindow.IndexOf(
|
||||||
|
"runtimeUiCatalogs.WarningMessage",
|
||||||
|
warningBranch,
|
||||||
|
StringComparison.Ordinal);
|
||||||
var errorReport = MainWindow.IndexOf(
|
var errorReport = MainWindow.IndexOf(
|
||||||
"_controller.ReportError(initializationError);",
|
"_controller.ReportError(initializationError);",
|
||||||
errorBranch,
|
errorBranch,
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
var warningReport = MainWindow.IndexOf(
|
var warningReport = MainWindow.IndexOf(
|
||||||
"_controller.ReportWarning(cutMenuWarning);",
|
"_controller.ReportWarning(string.Join(\" \", catalogWarnings));",
|
||||||
warningBranch,
|
warningBranch,
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
|
|
||||||
Assert.True(errorBranch >= 0);
|
Assert.True(errorBranch >= 0);
|
||||||
Assert.True(errorReport > errorBranch);
|
Assert.True(errorReport > errorBranch);
|
||||||
Assert.True(warningBranch > errorReport);
|
Assert.True(warningBranch > errorReport);
|
||||||
|
Assert.True(runtimeUiWarning > warningBranch);
|
||||||
Assert.True(warningReport > warningBranch);
|
Assert.True(warningReport > warningBranch);
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
warningReport,
|
warningReport,
|
||||||
MainWindow.LastIndexOf(
|
MainWindow.LastIndexOf(
|
||||||
"_controller.ReportWarning(cutMenuWarning);",
|
"_controller.ReportWarning(string.Join(\" \", catalogWarnings));",
|
||||||
StringComparison.Ordinal));
|
StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,39 @@ public sealed class PlayoutOptionsLoaderTests
|
|||||||
Assert.Null(options.MaximumAutomaticRefreshesPerTakeIn);
|
Assert.Null(options.MaximumAutomaticRefreshesPerTakeIn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyExecutableAssetDefaults_UsesCutsBesideExecutable()
|
||||||
|
{
|
||||||
|
var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||||
|
var cuts = Path.Combine(directory, "Cuts");
|
||||||
|
Directory.CreateDirectory(cuts);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var options = new PlayoutOptions();
|
||||||
|
|
||||||
|
PlayoutOptionsLoader.ApplyExecutableAssetDefaults(options, directory);
|
||||||
|
|
||||||
|
Assert.Equal(Path.GetFullPath(cuts), options.SceneDirectory);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyExecutableAssetDefaults_DoesNotReplaceExplicitSceneDirectory()
|
||||||
|
{
|
||||||
|
var options = new PlayoutOptions
|
||||||
|
{
|
||||||
|
SceneDirectory = "C:\\explicit-cuts"
|
||||||
|
};
|
||||||
|
|
||||||
|
PlayoutOptionsLoader.ApplyExecutableAssetDefaults(options, "C:\\runtime");
|
||||||
|
|
||||||
|
Assert.Equal("C:\\explicit-cuts", options.SceneDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Load_EnvironmentOverridesJson_ExceptFileOnlySafetyGates()
|
public void Load_EnvironmentOverridesJson_ExceptFileOnlySafetyGates()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ using MMoneyCoderSharp.Data;
|
|||||||
|
|
||||||
internal static class NxtThemeRestoreDbAudit
|
internal static class NxtThemeRestoreDbAudit
|
||||||
{
|
{
|
||||||
private const string OriginalConfigurationPath =
|
|
||||||
@"C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\RES\MmoneyCoder.ini";
|
|
||||||
private const string ActiveRowsQueryName = "AUDIT_ACTIVE_NXT_THEME_ROWS";
|
private const string ActiveRowsQueryName = "AUDIT_ACTIVE_NXT_THEME_ROWS";
|
||||||
|
|
||||||
private const string ActiveRowsSql = """
|
private const string ActiveRowsSql = """
|
||||||
@@ -26,9 +24,14 @@ internal static class NxtThemeRestoreDbAudit
|
|||||||
internal static async Task RunAsync(
|
internal static async Task RunAsync(
|
||||||
IDataQueryExecutor executor,
|
IDataQueryExecutor executor,
|
||||||
MariaDbDatabaseOptions currentOptions,
|
MariaDbDatabaseOptions currentOptions,
|
||||||
|
MariaDbEndpointReference referenceOptions,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await RunEndpointAuditAsync(executor, currentOptions, cancellationToken)
|
await RunEndpointAuditAsync(
|
||||||
|
executor,
|
||||||
|
currentOptions,
|
||||||
|
referenceOptions,
|
||||||
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
const int maximumRows = 1_000;
|
const int maximumRows = 1_000;
|
||||||
@@ -291,13 +294,18 @@ internal static class NxtThemeRestoreDbAudit
|
|||||||
private static async Task RunEndpointAuditAsync(
|
private static async Task RunEndpointAuditAsync(
|
||||||
IDataQueryExecutor executor,
|
IDataQueryExecutor executor,
|
||||||
MariaDbDatabaseOptions currentOptions,
|
MariaDbDatabaseOptions currentOptions,
|
||||||
|
MariaDbEndpointReference referenceOptions,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(executor);
|
ArgumentNullException.ThrowIfNull(executor);
|
||||||
ArgumentNullException.ThrowIfNull(currentOptions);
|
ArgumentNullException.ThrowIfNull(currentOptions);
|
||||||
|
ArgumentNullException.ThrowIfNull(referenceOptions);
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var original = ReadOriginalEndpoint(OriginalConfigurationPath);
|
var original = NormalizeEndpoint(
|
||||||
|
referenceOptions.Host,
|
||||||
|
referenceOptions.Port,
|
||||||
|
referenceOptions.Database);
|
||||||
var current = NormalizeEndpoint(
|
var current = NormalizeEndpoint(
|
||||||
currentOptions.Host,
|
currentOptions.Host,
|
||||||
currentOptions.Port,
|
currentOptions.Port,
|
||||||
@@ -354,78 +362,6 @@ internal static class NxtThemeRestoreDbAudit
|
|||||||
$"serverMajorMinor={versionMajorMinor}");
|
$"serverMajorMinor={versionMajorMinor}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ReadOriginalEndpoint(string path)
|
|
||||||
{
|
|
||||||
if (!File.Exists(path))
|
|
||||||
{
|
|
||||||
throw new InvalidDataException(
|
|
||||||
"The fixed read-only original MariaDB configuration is unavailable.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var source = new FileInfo(path);
|
|
||||||
if ((source.Attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 ||
|
|
||||||
source.Length is < 1 or > 64 * 1024)
|
|
||||||
{
|
|
||||||
throw new InvalidDataException(
|
|
||||||
"The fixed read-only original MariaDB configuration failed its file boundary.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var inMariaSection = false;
|
|
||||||
string? connectionName = null;
|
|
||||||
foreach (var rawLine in File.ReadLines(path))
|
|
||||||
{
|
|
||||||
var line = rawLine.Trim();
|
|
||||||
if (line.StartsWith("[", StringComparison.Ordinal) &&
|
|
||||||
line.EndsWith("]", StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
inMariaSection = string.Equals(line, "[Maria]", StringComparison.Ordinal);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!inMariaSection || line.Length == 0 ||
|
|
||||||
line.StartsWith("//", StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const string prefix = "ConnectionName=";
|
|
||||||
if (line.StartsWith(prefix, StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
if (connectionName is not null)
|
|
||||||
{
|
|
||||||
throw new InvalidDataException(
|
|
||||||
"The original MariaDB endpoint is ambiguous.");
|
|
||||||
}
|
|
||||||
|
|
||||||
connectionName = line[prefix.Length..];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connectionName is null)
|
|
||||||
{
|
|
||||||
throw new InvalidDataException(
|
|
||||||
"The original MariaDB endpoint is unavailable.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var slash = connectionName.LastIndexOf('/');
|
|
||||||
var colon = connectionName.LastIndexOf(':', slash - 1);
|
|
||||||
if (slash <= 0 || colon <= 0 || colon >= slash - 1 || slash == connectionName.Length - 1 ||
|
|
||||||
!int.TryParse(
|
|
||||||
connectionName.AsSpan(colon + 1, slash - colon - 1),
|
|
||||||
NumberStyles.None,
|
|
||||||
CultureInfo.InvariantCulture,
|
|
||||||
out var port))
|
|
||||||
{
|
|
||||||
throw new InvalidDataException(
|
|
||||||
"The original MariaDB endpoint has an invalid closed shape.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return NormalizeEndpoint(
|
|
||||||
connectionName[..colon],
|
|
||||||
port,
|
|
||||||
connectionName[(slash + 1)..]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormalizeEndpoint(string? host, int port, string? database)
|
private static string NormalizeEndpoint(string? host, int port, string? database)
|
||||||
{
|
{
|
||||||
var normalizedHost = (host ?? string.Empty)
|
var normalizedHost = (host ?? string.Empty)
|
||||||
@@ -460,3 +396,15 @@ internal static class NxtThemeRestoreDbAudit
|
|||||||
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
|
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
|
||||||
column.DataType == typeof(string);
|
column.DataType == typeof(string);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed record MariaDbEndpointReference(
|
||||||
|
string? Host,
|
||||||
|
int Port,
|
||||||
|
string? Database)
|
||||||
|
{
|
||||||
|
internal static MariaDbEndpointReference From(MariaDbDatabaseOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
return new MariaDbEndpointReference(options.Host, options.Port, options.Database);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,11 +25,10 @@ Console.CancelKeyPress += (_, eventArgs) =>
|
|||||||
cancellation.Cancel();
|
cancellation.Cancel();
|
||||||
};
|
};
|
||||||
|
|
||||||
var configurationPath = GetConfigurationPath(args);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var runtime = DatabaseRuntime.CreateDefault(configurationPath);
|
var selection = CreateRuntimeSelection(args);
|
||||||
|
var runtime = selection.Runtime;
|
||||||
var statuses = await runtime.HealthService.CheckAllAsync(cancellation.Token);
|
var statuses = await runtime.HealthService.CheckAllAsync(cancellation.Token);
|
||||||
|
|
||||||
foreach (var status in statuses)
|
foreach (var status in statuses)
|
||||||
@@ -59,6 +58,7 @@ try
|
|||||||
await NxtThemeRestoreDbAudit.RunAsync(
|
await NxtThemeRestoreDbAudit.RunAsync(
|
||||||
runtime.Executor,
|
runtime.Executor,
|
||||||
runtime.Options.MariaDb,
|
runtime.Options.MariaDb,
|
||||||
|
selection.ReferenceMariaDb,
|
||||||
cancellation.Token);
|
cancellation.Token);
|
||||||
await VerifyLegacyComparisonImportAsync(runtime.Executor, cancellation.Token);
|
await VerifyLegacyComparisonImportAsync(runtime.Executor, cancellation.Token);
|
||||||
|
|
||||||
@@ -131,23 +131,63 @@ static string FormatNullableBoolean(bool? value) => value switch
|
|||||||
static string FormatProviderErrorCode(int? value) =>
|
static string FormatProviderErrorCode(int? value) =>
|
||||||
value?.ToString(CultureInfo.InvariantCulture) ?? "none";
|
value?.ToString(CultureInfo.InvariantCulture) ?? "none";
|
||||||
|
|
||||||
static string? GetConfigurationPath(string[] arguments)
|
static DatabaseSmokeRuntimeSelection CreateRuntimeSelection(string[] arguments)
|
||||||
{
|
{
|
||||||
if (arguments.Length == 0)
|
if (arguments.Length == 0)
|
||||||
{
|
{
|
||||||
return null;
|
return CreateJsonRuntimeSelection(configurationPath: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (arguments.Length == 2 &&
|
if (arguments.Length == 2 &&
|
||||||
arguments[0].Equals("--config", StringComparison.OrdinalIgnoreCase))
|
arguments[0].Equals("--config", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return arguments[1];
|
return CreateJsonRuntimeSelection(arguments[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arguments.Length == 2 &&
|
||||||
|
arguments[0].Equals("--legacy-runtime-root", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var root = Path.GetFullPath(arguments[1]);
|
||||||
|
var iniPath = Path.Combine(root, "Res", "MmoneyCoder.ini");
|
||||||
|
if (!File.Exists(iniPath))
|
||||||
|
{
|
||||||
|
throw new DatabaseConfigurationException(
|
||||||
|
"The legacy runtime root does not contain Res\\MmoneyCoder.ini.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the selected file's endpoint as the comparison reference before
|
||||||
|
// environment overrides are applied. Credentials never leave the
|
||||||
|
// loader-owned DatabaseOptions instance.
|
||||||
|
var selectedOptions = new LegacyIniDatabaseOptionsLoader(_ => null).Load(iniPath);
|
||||||
|
var runtime = DatabaseRuntime.CreateLegacyExecutableDefault(
|
||||||
|
root,
|
||||||
|
fallbackConfigurationPath: Path.Combine(root, "missing-database.json"),
|
||||||
|
legacyOverridePath: Path.Combine(root, "missing-legacy.ini"));
|
||||||
|
return new DatabaseSmokeRuntimeSelection(
|
||||||
|
runtime,
|
||||||
|
MariaDbEndpointReference.From(selectedOptions.MariaDb));
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new DatabaseConfigurationException(
|
throw new DatabaseConfigurationException(
|
||||||
"Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config <path>|--manual-import-audit|--manual-import-fixture]");
|
"Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config <path>|--legacy-runtime-root <bin-debug-path>|--manual-import-audit|--manual-import-fixture]");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static DatabaseSmokeRuntimeSelection CreateJsonRuntimeSelection(string? configurationPath)
|
||||||
|
{
|
||||||
|
var configuredOptions = new DatabaseOptionsLoader(_ => null).Load(configurationPath);
|
||||||
|
var runtime = DatabaseRuntime.CreateDefault(configurationPath);
|
||||||
|
var configuredMariaDb = configuredOptions.MariaDb;
|
||||||
|
var reference = HasCompleteEndpointIdentity(configuredMariaDb)
|
||||||
|
? MariaDbEndpointReference.From(configuredMariaDb)
|
||||||
|
: MariaDbEndpointReference.From(runtime.Options.MariaDb);
|
||||||
|
return new DatabaseSmokeRuntimeSelection(runtime, reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool HasCompleteEndpointIdentity(MariaDbDatabaseOptions options) =>
|
||||||
|
!string.IsNullOrWhiteSpace(options.Host) &&
|
||||||
|
options.Port is >= 1 and <= 65_535 &&
|
||||||
|
!string.IsNullOrWhiteSpace(options.Database);
|
||||||
|
|
||||||
static async Task VerifyLegacyComparisonImportAsync(
|
static async Task VerifyLegacyComparisonImportAsync(
|
||||||
IDataQueryExecutor executor,
|
IDataQueryExecutor executor,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -711,3 +751,7 @@ static async Task VerifyTradingHaltSelectorAsync(
|
|||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"TradingHaltSelector: {result.Items.Count.ToString(CultureInfo.InvariantCulture)} rows");
|
$"TradingHaltSelector: {result.Items.Count.ToString(CultureInfo.InvariantCulture)} rows");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed record DatabaseSmokeRuntimeSelection(
|
||||||
|
DatabaseRuntime Runtime,
|
||||||
|
MariaDbEndpointReference ReferenceMariaDb);
|
||||||
|
|||||||
Reference in New Issue
Block a user