diff --git a/MBN_STOCK_WEBVIEW.csproj b/MBN_STOCK_WEBVIEW.csproj index 0a58ea3..b75bd50 100644 --- a/MBN_STOCK_WEBVIEW.csproj +++ b/MBN_STOCK_WEBVIEW.csproj @@ -28,6 +28,9 @@ 1.0.5 6 1.0.5 + + + $(DefaultItemExcludes);src\**;tests\**;tools\** diff --git a/MBN_STOCK_WEBVIEW.sln b/MBN_STOCK_WEBVIEW.sln index 702c245..29ec317 100644 --- a/MBN_STOCK_WEBVIEW.sln +++ b/MBN_STOCK_WEBVIEW.sln @@ -26,9 +26,23 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.Playout.T EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.PlayoutSmoke", "tools\MBN_STOCK_WEBVIEW.PlayoutSmoke\MBN_STOCK_WEBVIEW.PlayoutSmoke.csproj", "{515E56D2-8F90-45A0-83BC-44BD541B10A6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.LegacyApplication", "src\MBN_STOCK_WEBVIEW.LegacyApplication\MBN_STOCK_WEBVIEW.LegacyApplication.csproj", "{BD45A78B-3C97-4C55-A917-B46927F00311}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.LegacyBridge", "src\MBN_STOCK_WEBVIEW.LegacyBridge\MBN_STOCK_WEBVIEW.LegacyBridge.csproj", "{7C8D7811-8902-482B-9EF3-67107A3CB676}" +EndProject +Project("{84322810-C106-461C-892A-5A3631226260}") = "MBN_STOCK_WEBVIEW.LegacyParityApp", "src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj", "{0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.LegacyApplication.Tests", "tests\MBN_STOCK_WEBVIEW.LegacyApplication.Tests\MBN_STOCK_WEBVIEW.LegacyApplication.Tests.csproj", "{60A364DD-1283-4DA1-8660-791B5CB4F86F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.LegacyBridge.Tests", "tests\MBN_STOCK_WEBVIEW.LegacyBridge.Tests\MBN_STOCK_WEBVIEW.LegacyBridge.Tests.csproj", "{8F28CB75-54A4-4C27-A7F8-88A30D485B19}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.LegacyWeb.Tests", "tests\MBN_STOCK_WEBVIEW.LegacyWeb.Tests\MBN_STOCK_WEBVIEW.LegacyWeb.Tests.csproj", "{9333727F-7367-4AF8-BA9D-EF159A3148D9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 + ParityDebug|x64 = ParityDebug|x64 + PrototypeDebug|x64 = PrototypeDebug|x64 Release|x64 = Release|x64 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution @@ -70,6 +84,90 @@ Global {515E56D2-8F90-45A0-83BC-44BD541B10A6}.Debug|x64.Build.0 = Debug|x64 {515E56D2-8F90-45A0-83BC-44BD541B10A6}.Release|x64.ActiveCfg = Release|x64 {515E56D2-8F90-45A0-83BC-44BD541B10A6}.Release|x64.Build.0 = Release|x64 + {E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.ParityDebug|x64.ActiveCfg = Debug|x64 + {E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.PrototypeDebug|x64.ActiveCfg = Debug|x64 + {E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.PrototypeDebug|x64.Build.0 = Debug|x64 + {E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.PrototypeDebug|x64.Deploy.0 = Debug|x64 + {CCFB7CB0-F898-444F-A16B-E28554A2698A}.ParityDebug|x64.ActiveCfg = Debug|Any CPU + {CCFB7CB0-F898-444F-A16B-E28554A2698A}.ParityDebug|x64.Build.0 = Debug|Any CPU + {CCFB7CB0-F898-444F-A16B-E28554A2698A}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU + {CCFB7CB0-F898-444F-A16B-E28554A2698A}.PrototypeDebug|x64.Build.0 = Debug|Any CPU + {7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.ParityDebug|x64.ActiveCfg = Debug|Any CPU + {7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.ParityDebug|x64.Build.0 = Debug|Any CPU + {7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU + {7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.PrototypeDebug|x64.Build.0 = Debug|Any CPU + {040E8620-035C-4F62-B408-C05C5BDC90FE}.ParityDebug|x64.ActiveCfg = Debug|Any CPU + {040E8620-035C-4F62-B408-C05C5BDC90FE}.ParityDebug|x64.Build.0 = Debug|Any CPU + {040E8620-035C-4F62-B408-C05C5BDC90FE}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU + {040E8620-035C-4F62-B408-C05C5BDC90FE}.PrototypeDebug|x64.Build.0 = Debug|Any CPU + {1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.ParityDebug|x64.ActiveCfg = Debug|Any CPU + {1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.ParityDebug|x64.Build.0 = Debug|Any CPU + {1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU + {1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.PrototypeDebug|x64.Build.0 = Debug|Any CPU + {6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.ParityDebug|x64.ActiveCfg = Debug|Any CPU + {6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.ParityDebug|x64.Build.0 = Debug|Any CPU + {6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU + {6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.PrototypeDebug|x64.Build.0 = Debug|Any CPU + {8275871E-4ECB-465B-AA77-D09E61CC1D84}.ParityDebug|x64.ActiveCfg = Debug|x64 + {8275871E-4ECB-465B-AA77-D09E61CC1D84}.ParityDebug|x64.Build.0 = Debug|x64 + {8275871E-4ECB-465B-AA77-D09E61CC1D84}.PrototypeDebug|x64.ActiveCfg = Debug|x64 + {8275871E-4ECB-465B-AA77-D09E61CC1D84}.PrototypeDebug|x64.Build.0 = Debug|x64 + {74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}.ParityDebug|x64.ActiveCfg = Debug|x64 + {74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}.ParityDebug|x64.Build.0 = Debug|x64 + {74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}.PrototypeDebug|x64.ActiveCfg = Debug|x64 + {74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}.PrototypeDebug|x64.Build.0 = Debug|x64 + {515E56D2-8F90-45A0-83BC-44BD541B10A6}.ParityDebug|x64.ActiveCfg = Debug|x64 + {515E56D2-8F90-45A0-83BC-44BD541B10A6}.ParityDebug|x64.Build.0 = Debug|x64 + {515E56D2-8F90-45A0-83BC-44BD541B10A6}.PrototypeDebug|x64.ActiveCfg = Debug|x64 + {515E56D2-8F90-45A0-83BC-44BD541B10A6}.PrototypeDebug|x64.Build.0 = Debug|x64 + {BD45A78B-3C97-4C55-A917-B46927F00311}.Debug|x64.ActiveCfg = Debug|Any CPU + {BD45A78B-3C97-4C55-A917-B46927F00311}.Debug|x64.Build.0 = Debug|Any CPU + {BD45A78B-3C97-4C55-A917-B46927F00311}.ParityDebug|x64.ActiveCfg = Debug|Any CPU + {BD45A78B-3C97-4C55-A917-B46927F00311}.ParityDebug|x64.Build.0 = Debug|Any CPU + {BD45A78B-3C97-4C55-A917-B46927F00311}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU + {BD45A78B-3C97-4C55-A917-B46927F00311}.PrototypeDebug|x64.Build.0 = Debug|Any CPU + {BD45A78B-3C97-4C55-A917-B46927F00311}.Release|x64.ActiveCfg = Release|Any CPU + {BD45A78B-3C97-4C55-A917-B46927F00311}.Release|x64.Build.0 = Release|Any CPU + {7C8D7811-8902-482B-9EF3-67107A3CB676}.Debug|x64.ActiveCfg = Debug|Any CPU + {7C8D7811-8902-482B-9EF3-67107A3CB676}.Debug|x64.Build.0 = Debug|Any CPU + {7C8D7811-8902-482B-9EF3-67107A3CB676}.ParityDebug|x64.ActiveCfg = Debug|Any CPU + {7C8D7811-8902-482B-9EF3-67107A3CB676}.ParityDebug|x64.Build.0 = Debug|Any CPU + {7C8D7811-8902-482B-9EF3-67107A3CB676}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU + {7C8D7811-8902-482B-9EF3-67107A3CB676}.PrototypeDebug|x64.Build.0 = Debug|Any CPU + {7C8D7811-8902-482B-9EF3-67107A3CB676}.Release|x64.ActiveCfg = Release|Any CPU + {7C8D7811-8902-482B-9EF3-67107A3CB676}.Release|x64.Build.0 = Release|Any CPU + {0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.Debug|x64.ActiveCfg = Debug|x64 + {0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.Debug|x64.Build.0 = Debug|x64 + {0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.ParityDebug|x64.ActiveCfg = Debug|x64 + {0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.ParityDebug|x64.Build.0 = Debug|x64 + {0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.ParityDebug|x64.Deploy.0 = Debug|x64 + {0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.PrototypeDebug|x64.ActiveCfg = Debug|x64 + {0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.Release|x64.ActiveCfg = Release|x64 + {0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.Release|x64.Build.0 = Release|x64 + {60A364DD-1283-4DA1-8660-791B5CB4F86F}.Debug|x64.ActiveCfg = Debug|Any CPU + {60A364DD-1283-4DA1-8660-791B5CB4F86F}.Debug|x64.Build.0 = Debug|Any CPU + {60A364DD-1283-4DA1-8660-791B5CB4F86F}.ParityDebug|x64.ActiveCfg = Debug|Any CPU + {60A364DD-1283-4DA1-8660-791B5CB4F86F}.ParityDebug|x64.Build.0 = Debug|Any CPU + {60A364DD-1283-4DA1-8660-791B5CB4F86F}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU + {60A364DD-1283-4DA1-8660-791B5CB4F86F}.PrototypeDebug|x64.Build.0 = Debug|Any CPU + {60A364DD-1283-4DA1-8660-791B5CB4F86F}.Release|x64.ActiveCfg = Release|Any CPU + {60A364DD-1283-4DA1-8660-791B5CB4F86F}.Release|x64.Build.0 = Release|Any CPU + {8F28CB75-54A4-4C27-A7F8-88A30D485B19}.Debug|x64.ActiveCfg = Debug|Any CPU + {8F28CB75-54A4-4C27-A7F8-88A30D485B19}.Debug|x64.Build.0 = Debug|Any CPU + {8F28CB75-54A4-4C27-A7F8-88A30D485B19}.ParityDebug|x64.ActiveCfg = Debug|Any CPU + {8F28CB75-54A4-4C27-A7F8-88A30D485B19}.ParityDebug|x64.Build.0 = Debug|Any CPU + {8F28CB75-54A4-4C27-A7F8-88A30D485B19}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU + {8F28CB75-54A4-4C27-A7F8-88A30D485B19}.PrototypeDebug|x64.Build.0 = Debug|Any CPU + {8F28CB75-54A4-4C27-A7F8-88A30D485B19}.Release|x64.ActiveCfg = Release|Any CPU + {8F28CB75-54A4-4C27-A7F8-88A30D485B19}.Release|x64.Build.0 = Release|Any CPU + {9333727F-7367-4AF8-BA9D-EF159A3148D9}.Debug|x64.ActiveCfg = Debug|Any CPU + {9333727F-7367-4AF8-BA9D-EF159A3148D9}.Debug|x64.Build.0 = Debug|Any CPU + {9333727F-7367-4AF8-BA9D-EF159A3148D9}.ParityDebug|x64.ActiveCfg = Debug|Any CPU + {9333727F-7367-4AF8-BA9D-EF159A3148D9}.ParityDebug|x64.Build.0 = Debug|Any CPU + {9333727F-7367-4AF8-BA9D-EF159A3148D9}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU + {9333727F-7367-4AF8-BA9D-EF159A3148D9}.PrototypeDebug|x64.Build.0 = Debug|Any CPU + {9333727F-7367-4AF8-BA9D-EF159A3148D9}.Release|x64.ActiveCfg = Release|Any CPU + {9333727F-7367-4AF8-BA9D-EF159A3148D9}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -83,6 +181,12 @@ Global {8275871E-4ECB-465B-AA77-D09E61CC1D84} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {74DE0671-7705-4EA9-8BCB-BA98FD86C6B2} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {515E56D2-8F90-45A0-83BC-44BD541B10A6} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} + {BD45A78B-3C97-4C55-A917-B46927F00311} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {7C8D7811-8902-482B-9EF3-67107A3CB676} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {60A364DD-1283-4DA1-8660-791B5CB4F86F} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {8F28CB75-54A4-4C27-A7F8-88A30D485B19} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {9333727F-7367-4AF8-BA9D-EF159A3148D9} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {58E98A35-4C8C-4C80-9227-1063B345AD07} diff --git a/README.md b/README.md index 3eb4d1b..ab35392 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ 기존 `MBN_STOCK_N` WinForms 애플리케이션을 단계적으로 대체하기 위한 Windows 데스크톱 프로젝트입니다. +> 현재 개발 경로는 둘로 분리되어 있습니다. 아래의 기존 구현 범위는 비교용 루트 프로토타입을 설명합니다. 고객 요구처럼 원본 UI와 클릭 이벤트를 먼저 1:1 이관하는 새 병렬 앱의 범위와 실행 방법은 [원본 호환 마이그레이션 기준선](docs/LEGACY_PARITY_REBASELINE.md)을 참조하세요. + 현재 기준 기술 스택은 다음과 같습니다. - .NET 8 / C# diff --git a/docs/LEGACY_PARITY_REBASELINE.md b/docs/LEGACY_PARITY_REBASELINE.md new file mode 100644 index 0000000..d07393a --- /dev/null +++ b/docs/LEGACY_PARITY_REBASELINE.md @@ -0,0 +1,101 @@ +# 원본 호환 마이그레이션 기준선 + +## 결정 + +기존 저장소와 검증된 `Core / Infrastructure / Playout`은 유지한다. 기존 루트 WinUI 앱은 비교용 프로토타입으로 보존하고, 원본 UI와 이벤트를 먼저 재현하는 `MBN_STOCK_WEBVIEW.LegacyParityApp`을 같은 솔루션에서 병행 개발한다. + +WebView는 HTML 컨트롤 표시와 사용자 입력 전달만 담당한다. 검색 정책, 선택 상태, 플레이리스트 필드, 장면 alias, PageN 및 송출 상태의 권위는 C# Application 계층에 둔다. + +## 기준 원본 + +- 읽기 전용 저장소: `C:\Users\MD\source\repos\MBN_STOCK_N` +- 조사 시 HEAD: `309286309eaf20b58172247263f575753a860700` +- 실제 실행 컷 목록: `MBN_STOCK_N\bin\Debug\Res\종목.ini` +- 실행 컷 목록 SHA-256: `45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20` +- 실행 컷 목록은 구분행 2개를 포함한 33개 물리행이며 실제 컷은 31개다. +- 3번 컷은 고객 화면과 실행본을 따라 `1열판기본_시간외단일가`를 사용한다. 소스 `Res`의 `시간외종가`는 기준으로 사용하지 않는다. + +원본 작업 트리에는 기존 수정이 있으므로 커밋 SHA만으로 동등성을 판단하지 않는다. 원본은 계속 읽기 전용으로 취급한다. + +## 신규 프로젝트 + +| 프로젝트 | 책임 | +|---|---| +| `MBN_STOCK_WEBVIEW.LegacyApplication` | 원본 MainForm/UC 상태, 이벤트 및 업무 규칙 | +| `MBN_STOCK_WEBVIEW.LegacyBridge` | 허용된 WebView 입력 intent와 표시 snapshot 계약 | +| `MBN_STOCK_WEBVIEW.LegacyParityApp` | WinUI 3 창, WebView2, DB composition root, 별도 개발 MSIX | +| `LegacyApplication.Tests` | 원본 이벤트 및 결과 DTO 동등성 테스트 | +| `LegacyBridge.Tests` | strict JSON과 Web 권한 경계 테스트 | +| `LegacyWeb.Tests` | Visual Studio에서 항상 실행되는 Web thin-client 계약 테스트 | + +개발 패키지 identity는 `Wickedness.MBNStockWebView.LegacyParity`다. 운영 identity `Wickedness.MBNStockWebView`를 대체하지 않는다. + +## 첫 수직 흐름 상태 + +구현됨: + +- 검색 버튼의 정확한 빈 문자열 no-op과 Enter 빈 검색 허용 +- KOSPI → KOSDAQ → NXT KOSPI → NXT KOSDAQ 순서 및 DB 반환 순서 유지 +- trim, 전체 정렬, 결과 제한 및 wildcard escape를 하지 않는 원본 호환 검색 +- 바인드 파라미터를 통한 SQL injection 방지 +- master snapshot을 이름으로 다시 순회하고 마지막 일치를 채택하는 `jongmokCode` 규칙 +- 33개 물리 컷 행과 31개 display 번호 +- 원본 `ClientSize 1905 x 1015`, `FixedSingle`, 최대화 불가 창 계약 +- 일반/Ctrl/Shift/Ctrl+Shift 선택을 C# 상태로 처리 +- 0.5초 이내, 동일 행, 동일 좌표 조건의 원본 더블클릭 판정 +- 선택 컷 drag/drop과 물리 index 오름차순 추가 +- 원본 7필드 플레이리스트 행과 중복 추가 허용 +- NXT `1열판기본_현재가`만 허용하고 나머지는 원본 경고 표시 +- PREPARE 직전 `LegacyPlaylistEntry`로 변환할 수 있는 31개 컷 행의 alias 매핑 + +아직 이관하지 않음: + +- 좌측 수동 입력 GraphE 4개 모달 +- 중앙 UC1~UC7과 모든 탭 이벤트 +- 플레이리스트 선택/삭제/정렬/DB 저장·불러오기 +- 배경, 이동평균, Fade/Update 시간 컨트롤 +- PREPARE/TAKE IN/NEXT/TAKE OUT 실제 연결 +- 운영 DB write와 Tornado2/PGM 검증 + +미이관 버튼은 현재 disabled 상태이며, 연결 상태는 `미연결`로 명시한다. 새 앱은 Tornado2에 연결하지 않는다. + +## Visual Studio 실행 + +1. `MBN_STOCK_WEBVIEW.sln`을 연다. +2. 솔루션 구성을 `ParityDebug | x64`로 선택한다. +3. `MBN_STOCK_WEBVIEW.LegacyParityApp`을 시작 프로젝트로 지정한다. +4. 실행한다. + +`PrototypeDebug | x64`는 기존 루트 앱을 배포하는 비교용 구성이다. 두 앱은 서로 다른 MSIX identity와 single-instance key를 사용한다. + +기존 `Release | x64`의 자동 배포 대상은 비교용 루트 앱으로 유지한다. 호환 앱 Release는 두 패키지를 동시에 배포하지 않도록 `MBN_STOCK_WEBVIEW.LegacyParityApp` 프로젝트의 MSIX 생성 명령으로 명시적으로 만든다. + +## 2026-07-15 기준선 검증 + +- `ParityDebug | x64`와 `Release | x64` 솔루션 빌드: 경고 0, 오류 0 +- Debug/Release 각각 C# 테스트 1,980개 통과 + - Core 1,383 / Infrastructure 173 / Playout 393 + - LegacyApplication 17 / LegacyBridge 7 / LegacyWeb 7 +- Node 기반 JavaScript syntax 및 Web 계약 테스트 7개 통과 +- Release x64 개발 MSIX `0.1.0.0` 생성 및 `CN=Comtrophy` 서명 검증 +- MSIX SHA-256: `D3FF9CF1F63E6BC68AA147C0ED47690FE130CF425C0276B3201F763EC5C50A4B` +- 패키지 76개 항목 중 인증서, 비밀번호, 라이선스, `.t2s`, 운영 설정 파일 0개 +- 패키지의 `Web/index.html`, `styles.css`, `app.js`가 소스와 byte 단위로 일치 +- 별도 패키지 전체 이름 `Wickedness.MBNStockWebView.LegacyParity_0.1.0.0_x64__qbv3jkvsn3aj0`, package family `Wickedness.MBNStockWebView.LegacyParity_qbv3jkvsn3aj0` 설치·실행 성공 +- 패키지 창의 실제 WebView client bounds `1905 x 1015` 확인 +- 격리 WinForms ListView trace로 최초 Shift의 native 선택 `0..현재행`과 별도 pointer 목록의 `-1..현재행`, 같은 행 Shift의 native 선택 유지와 pointer 목록 clear를 확인하고 두 상태를 분리 +- 실제 DB read-only 경로로 `삼성` 검색 → `삼성출판사` 선택 → `현재가` 동일 좌표 더블클릭 → 원본 7필드 플레이리스트 행 생성 확인 +- 검색 시작 직후 정상 창 닫기 경합 검증에서 10초 이내 종료 및 Windows Application 오류 이벤트 0개 + +이 검증에서는 DB write, PREPARE, TAKE IN, NEXT, TAKE OUT 및 Tornado2 연결을 실행하지 않았다. 화면의 송출 버튼도 계속 비활성 상태다. + +## 다음 이관 순서 + +1. 플레이리스트 row 선택·Ctrl/Shift·삭제·전체/개별 enabled +2. 중앙 탭과 UC1부터 원본 이벤트 단위로 수직 이관 +3. 수동 입력 Form과 격리 DB write/read-back +4. C# playlist authority를 기존 `LegacyPlayoutWorkflow`에 연결한 DryRun PREPARE +5. 35개 scene/PageN differential trace +6. 승인된 실제 Tornado2/PGM 검증 + +각 기능은 원본 입력과 신규 입력의 snapshot, DB 파라미터, 플레이리스트 7필드 및 K3D trace를 비교한 뒤 완료 처리한다. diff --git a/scripts/Test-LegacyParityWeb.ps1 b/scripts/Test-LegacyParityWeb.ps1 new file mode 100644 index 0000000..dd702c6 --- /dev/null +++ b/scripts/Test-LegacyParityWeb.ps1 @@ -0,0 +1,45 @@ +#Requires -Version 5.1 + +[CmdletBinding()] +param([string] $NodePath) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($NodePath)) { + $command = Get-Command node -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($null -ne $command) { + $NodePath = $command.Source + } + else { + $bundledNode = Join-Path $env:USERPROFILE ` + '.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe' + if (Test-Path -LiteralPath $bundledNode -PathType Leaf) { + $NodePath = $bundledNode + } + } + + if ([string]::IsNullOrWhiteSpace($NodePath)) { + throw 'Node.js was not found. Pass an absolute executable path with -NodePath.' + } +} + +if (-not [IO.Path]::IsPathRooted($NodePath) -or + -not (Test-Path -LiteralPath $NodePath -PathType Leaf)) { + throw 'NodePath must identify an existing absolute node executable.' +} + +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$appScript = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\Web\app.js' +$testFile = Join-Path $repositoryRoot 'tests\LegacyParityWeb\thin-client.test.cjs' + +& $NodePath --check $appScript +if ($LASTEXITCODE -ne 0) { + throw "Legacy parity JavaScript syntax validation failed with exit code $LASTEXITCODE." +} + +& $NodePath --test $testFile +if ($LASTEXITCODE -ne 0) { + throw "Legacy parity Web tests failed with exit code $LASTEXITCODE." +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs new file mode 100644 index 0000000..d071668 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs @@ -0,0 +1,371 @@ +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +public enum LegacySearchTrigger +{ + Button, + Enter +} + +public enum LegacyOperatorStatusKind +{ + Neutral, + Information, + Warning, + Error +} + +public sealed record LegacyStockResultRow(int Index, string DisplayName); + +public sealed record LegacyCutViewRow( + int PhysicalIndex, + int? DisplayOrdinal, + string RawLabel, + bool IsSeparator, + bool IsSelected); + +public sealed record LegacyDialogState(string Message); + +public sealed record LegacyOperatorSnapshot( + long Revision, + string SearchText, + IReadOnlyList SearchResults, + int? SelectedStockIndex, + IReadOnlyList CutRows, + IReadOnlyList Playlist, + LegacyDialogState? Dialog, + string StatusMessage, + LegacyOperatorStatusKind StatusKind); + +/// +/// Authoritative C# state for the first legacy operator vertical slice. The WebView sends +/// input intent only; it never derives stock identity, cut aliases or playlist fields. +/// +public sealed class LegacyOperatorController +{ + private const long DoubleClickWindowMilliseconds = 500; + + private readonly ILegacyStockLookup _stockLookup; + private readonly SortedSet _legacyPointerRows = []; + private readonly SortedSet _nativeSelectedCutRows = []; + private readonly List _playlist = []; + private Queue? _pendingDropCutRows; + private LegacyStockSearchData _searchData = new(string.Empty, [], []); + private int? _selectedStockIndex; + private int? _cutAnchorIndex; + private int _nativeSelectionAnchorIndex; + private int? _previousPointerRow; + private int _previousPointerX; + private int _previousPointerY; + private long? _previousPointerTimestamp; + private long _revision; + private long _playlistSequence; + private string _searchText = string.Empty; + private string _statusMessage = string.Empty; + private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral; + private LegacyDialogState? _dialog; + + public LegacyOperatorController(ILegacyStockLookup stockLookup) + { + _stockLookup = stockLookup ?? throw new ArgumentNullException(nameof(stockLookup)); + } + + public LegacyOperatorSnapshot Current => CreateSnapshot(); + + public async Task SearchStocksAsync( + string text, + LegacySearchTrigger trigger, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(text); + _searchText = text; + + // button9_Click returned before calling searchStock only for an exactly empty string. + // txtSearch_KeyPress did not have that guard, so blank Enter still queries all rows. + if (trigger == LegacySearchTrigger.Button && text.Length == 0) + { + Touch(); + return CreateSnapshot(); + } + + _searchData = await _stockLookup.SearchAsync(text, cancellationToken).ConfigureAwait(false); + _selectedStockIndex = null; + _statusMessage = string.Empty; + _statusKind = LegacyOperatorStatusKind.Neutral; + _dialog = null; + _pendingDropCutRows = null; + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot SelectStock(int resultIndex) + { + if (resultIndex < 0 || resultIndex >= _searchData.DisplayNames.Count) + { + return CreateSnapshot(); + } + + _selectedStockIndex = resultIndex; + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot CutPointerDown( + int physicalIndex, + bool control, + bool shift, + int x, + int y, + long timestampMilliseconds) + { + if (_selectedStockIndex is null || !IsCutIndex(physicalIndex)) + { + return CreateSnapshot(); + } + + var gap = _previousPointerTimestamp is long previousTimestamp + ? timestampMilliseconds - previousTimestamp + : long.MaxValue; + if (_previousPointerTimestamp is not null && + gap >= 0 && gap < DoubleClickWindowMilliseconds && + _previousPointerRow == physicalIndex && + _previousPointerX == x && + _previousPointerY == y && + _legacyPointerRows.Count == 1) + { + TryAppendCut(_legacyPointerRows.Min); + _previousPointerTimestamp = null; + } + else + { + _previousPointerTimestamp = timestampMilliseconds; + } + + _previousPointerRow = physicalIndex; + _previousPointerX = x; + _previousPointerY = y; + + ApplyLegacyPointerSelection(physicalIndex, control, shift); + ApplyNativeListSelection(physicalIndex, control, shift); + + _cutAnchorIndex = physicalIndex; + _nativeSelectionAnchorIndex = physicalIndex; + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot DropSelectedCutsOnPlaylist() + { + if (_selectedStockIndex is null || _dialog is not null) + { + return CreateSnapshot(); + } + + _pendingDropCutRows = new Queue(_nativeSelectedCutRows); + ContinuePendingDrop(); + + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot SetPlaylistRowEnabled(string rowId, bool enabled) + { + ArgumentNullException.ThrowIfNull(rowId); + var index = _playlist.FindIndex(row => + string.Equals(row.RowId, rowId, StringComparison.Ordinal)); + if (index >= 0) + { + _playlist[index] = _playlist[index] with { IsEnabled = enabled }; + Touch(); + } + + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot DismissDialog() + { + if (_dialog is not null) + { + _dialog = null; + ContinuePendingDrop(); + Touch(); + } + + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot ReportError(string message) + { + _statusMessage = message ?? string.Empty; + _statusKind = LegacyOperatorStatusKind.Error; + Touch(); + return CreateSnapshot(); + } + + private void ContinuePendingDrop() + { + while (_pendingDropCutRows is { Count: > 0 }) + { + var physicalIndex = _pendingDropCutRows.Dequeue(); + if (!TryAppendCut(physicalIndex)) + { + return; + } + } + + _pendingDropCutRows = null; + } + + private bool TryAppendCut(int physicalIndex) + { + if (_selectedStockIndex is not int stockIndex || + stockIndex < 0 || stockIndex >= _searchData.DisplayNames.Count || + !IsCutIndex(physicalIndex)) + { + return true; + } + + var cut = LegacyStockCutCatalog.Rows[physicalIndex]; + var stockName = _searchData.DisplayNames[stockIndex]; + var isNxt = stockName.Contains("(NXT)", StringComparison.Ordinal); + if (isNxt && !string.Equals( + cut.RawLabel, + LegacyStockCutCatalog.NxtAllowedLabel, + StringComparison.Ordinal)) + { + _dialog = new LegacyDialogState("NXT는 제공하지 않습니다."); + return false; + } + + if (cut.IsSeparator) + { + return true; + } + + var identity = _searchData.ResolveLastByDisplayName(stockName); + var rowId = $"legacy-stock-{++_playlistSequence:D8}"; + _playlist.Add(LegacyStockCutCatalog.CreatePlaylistRow( + rowId, + stockName, + identity, + cut)); + return true; + } + + private void ApplyLegacyPointerSelection(int physicalIndex, bool control, bool shift) + { + if (control && shift) + { + AddLegacyRangeFromAnchor(physicalIndex); + } + else if (control) + { + if (!_legacyPointerRows.Add(physicalIndex)) + { + _legacyPointerRows.Remove(physicalIndex); + } + } + else if (shift) + { + _legacyPointerRows.Clear(); + AddLegacyRangeFromAnchor(physicalIndex); + } + else + { + _legacyPointerRows.Clear(); + _legacyPointerRows.Add(physicalIndex); + } + } + + private void ApplyNativeListSelection(int physicalIndex, bool control, bool shift) + { + if (control && shift) + { + AddInclusiveRange( + _nativeSelectedCutRows, + _nativeSelectionAnchorIndex, + physicalIndex); + } + else if (control) + { + if (!_nativeSelectedCutRows.Add(physicalIndex)) + { + _nativeSelectedCutRows.Remove(physicalIndex); + } + } + else if (shift) + { + _nativeSelectedCutRows.Clear(); + AddInclusiveRange( + _nativeSelectedCutRows, + _nativeSelectionAnchorIndex, + physicalIndex); + } + else + { + _nativeSelectedCutRows.Clear(); + _nativeSelectedCutRows.Add(physicalIndex); + } + } + + private void AddLegacyRangeFromAnchor(int physicalIndex) + { + // MainForm used beforeRow = -1 and skipped the range when the previous + // and current rows were equal. The sentinel also affected its double-click count. + var anchor = _cutAnchorIndex ?? -1; + if (anchor == physicalIndex) + { + return; + } + + var first = Math.Min(anchor, physicalIndex); + var last = Math.Max(anchor, physicalIndex); + for (var index = first; index <= last; index++) + { + _legacyPointerRows.Add(index); + } + } + + private static void AddInclusiveRange( + ISet target, + int anchor, + int physicalIndex) + { + var first = Math.Min(anchor, physicalIndex); + var last = Math.Max(anchor, physicalIndex); + for (var index = first; index <= last; index++) + { + target.Add(index); + } + } + + private LegacyOperatorSnapshot CreateSnapshot() + { + var searchRows = _searchData.DisplayNames + .Select((name, index) => new LegacyStockResultRow(index, name)) + .ToArray(); + var cutRows = LegacyStockCutCatalog.Rows + .Select(row => new LegacyCutViewRow( + row.PhysicalIndex, + row.DisplayOrdinal, + row.RawLabel, + row.IsSeparator, + _nativeSelectedCutRows.Contains(row.PhysicalIndex))) + .ToArray(); + + return new LegacyOperatorSnapshot( + _revision, + _searchText, + Array.AsReadOnly(searchRows), + _selectedStockIndex, + Array.AsReadOnly(cutRows), + Array.AsReadOnly(_playlist.ToArray()), + _dialog, + _statusMessage, + _statusKind); + } + + private static bool IsCutIndex(int physicalIndex) => + physicalIndex >= 0 && physicalIndex < LegacyStockCutCatalog.Rows.Count; + + private void Touch() => _revision++; +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyParityStockSearchService.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyParityStockSearchService.cs new file mode 100644 index 0000000..c2f1a56 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyParityStockSearchService.cs @@ -0,0 +1,164 @@ +using System.Data; +using System.Globalization; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +/// +/// Compatibility search for the operator screen. Unlike the modern lookup it intentionally +/// keeps blank-enter, LIKE wildcard, provider ordering and unlimited-result semantics. +/// Values remain bound parameters so parity does not reintroduce SQL concatenation. +/// +public sealed class LegacyParityStockSearchService : ILegacyStockLookup +{ + public const int LegacyTextBoxMaximumLength = 32_767; + + private static readonly SearchProfile[] Profiles = + [ + new( + LegacyDomesticStockMarket.Kospi, + DataSourceKind.Oracle, + "LEGACY_PARITY_KOSPI", + "SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_STOCK WHERE F_MKT_HALT = 'N' AND UPPER(F_STOCK_WANNAME) LIKE '%' || :search_term || '%'", + "SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_STOCK WHERE F_MKT_HALT = 'N'"), + new( + LegacyDomesticStockMarket.Kosdaq, + DataSourceKind.Oracle, + "LEGACY_PARITY_KOSDAQ", + "SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_KOSDAQ_STOCK WHERE F_MKT_HALT = 'N' AND UPPER(F_STOCK_WANNAME) LIKE '%' || :search_term || '%'", + "SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM T_KOSDAQ_STOCK WHERE F_MKT_HALT = 'N'"), + new( + LegacyDomesticStockMarket.NxtKospi, + DataSourceKind.MariaDb, + "LEGACY_PARITY_NXT_KOSPI", + "SELECT CONCAT(a.F_STOCK_NAME, '(NXT)') AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE FROM N_STOCK a, N_ONLINE b WHERE a.F_STOP_GUBUN = 'N' AND a.F_STOCK_CODE = b.F_STOCK_CODE AND UPPER(a.F_STOCK_NAME) LIKE CONCAT('%', @search_term, '%')", + "SELECT CONCAT(F_STOCK_NAME, '(NXT)') AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM N_STOCK WHERE F_STOP_GUBUN = 'N'"), + new( + LegacyDomesticStockMarket.NxtKosdaq, + DataSourceKind.MariaDb, + "LEGACY_PARITY_NXT_KOSDAQ", + "SELECT CONCAT(a.F_STOCK_NAME, '(NXT)') AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE FROM N_KOSDAQ_STOCK a, N_KOSDAQ_ONLINE b WHERE a.F_STOP_GUBUN = 'N' AND a.F_STOCK_CODE = b.F_STOCK_CODE AND UPPER(a.F_STOCK_NAME) LIKE CONCAT('%', @search_term, '%')", + "SELECT CONCAT(F_STOCK_NAME, '(NXT)') AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE FROM N_KOSDAQ_STOCK WHERE F_STOP_GUBUN = 'N'") + ]; + + private readonly IDataQueryExecutor _executor; + private readonly SemaphoreSlim _masterLoadGate = new(1, 1); + private IReadOnlyList? _masterCatalog; + + public LegacyParityStockSearchService(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + public async Task SearchAsync( + string query, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + if (query.Length > LegacyTextBoxMaximumLength) + { + throw new ArgumentOutOfRangeException( + nameof(query), + $"The legacy search text cannot exceed {LegacyTextBoxMaximumLength} characters."); + } + + var masterCatalog = await GetMasterCatalogAsync(cancellationToken).ConfigureAwait(false); + var searchTerm = query.ToUpper(CultureInfo.CurrentCulture); + var names = new List(); + + foreach (var profile in Profiles) + { + cancellationToken.ThrowIfCancellationRequested(); + var parameter = new DataQueryParameter("search_term", searchTerm, DbType.String); + var spec = new DataQuerySpec(profile.SearchSql, [parameter]); + var table = await _executor + .ExecuteAsync(profile.Source, profile.SearchTableName, spec, cancellationToken) + .ConfigureAwait(false); + AppendNames(table, profile.SearchTableName, names); + } + + return new LegacyStockSearchData(query, names, masterCatalog); + } + + private async Task> GetMasterCatalogAsync( + CancellationToken cancellationToken) + { + var cached = Volatile.Read(ref _masterCatalog); + if (cached is not null) + { + return cached; + } + + await _masterLoadGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + cached = Volatile.Read(ref _masterCatalog); + if (cached is not null) + { + return cached; + } + + var items = new List(); + foreach (var profile in Profiles) + { + cancellationToken.ThrowIfCancellationRequested(); + var table = await _executor + .ExecuteAsync( + profile.Source, + $"{profile.SearchTableName}_MASTER", + new DataQuerySpec(profile.MasterSql), + cancellationToken) + .ConfigureAwait(false); + AppendMaster(table, profile, items); + } + + cached = Array.AsReadOnly(items.ToArray()); + Volatile.Write(ref _masterCatalog, cached); + return cached; + } + finally + { + _masterLoadGate.Release(); + } + } + + private static void AppendNames(DataTable? table, string sourceName, ICollection target) + { + ValidateTable(table, sourceName); + foreach (DataRow row in table!.Rows) + { + target.Add(Convert.ToString(row[0], CultureInfo.CurrentCulture) ?? string.Empty); + } + } + + private static void AppendMaster( + DataTable? table, + SearchProfile profile, + ICollection target) + { + ValidateTable(table, $"{profile.SearchTableName}_MASTER"); + foreach (DataRow row in table!.Rows) + { + target.Add(new LegacyStockIdentity( + profile.Market, + Convert.ToString(row[0], CultureInfo.CurrentCulture) ?? string.Empty, + Convert.ToString(row[1], CultureInfo.CurrentCulture) ?? string.Empty)); + } + } + + private static void ValidateTable(DataTable? table, string sourceName) + { + if (table is null || table.Columns.Count < 2) + { + throw new InvalidOperationException( + $"Legacy stock data from {sourceName} does not contain two columns."); + } + } + + private sealed record SearchProfile( + LegacyDomesticStockMarket Market, + DataSourceKind Source, + string SearchTableName, + string SearchSql, + string MasterSql); +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs new file mode 100644 index 0000000..91b3967 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs @@ -0,0 +1,197 @@ +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +public sealed record LegacyCutCatalogRow( + int PhysicalIndex, + int? DisplayOrdinal, + string RawLabel, + bool IsSeparator); + +public sealed record LegacyOperatorPlaylistRow( + string RowId, + bool IsEnabled, + string MarketText, + string StockName, + string GraphicType, + string Subtype, + string PageText, + string StockCode, + string RawCutLabel, + int FadeDuration) +{ + public LegacyPlaylistEntry ToPlayoutEntry() + { + var isNxt = StockName.Contains("(NXT)", StringComparison.Ordinal); + var cutCode = LegacyStockCutCatalog.ResolveCutAlias(RawCutLabel, isNxt); + return new LegacyPlaylistEntry( + RowId, + cutCode, + IsEnabled, + FadeDuration, + new LegacySceneSelection( + MarketText, + StockName, + GraphicType, + Subtype, + StockCode)); + } +} + +public static class LegacyStockCutCatalog +{ + public const string RuntimeAssetSha256 = + "45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20"; + public const string NxtAllowedLabel = "1열판기본_현재가"; + + private static readonly string?[] RuntimeRows = + [ + "1열판기본_예상체결가", + "1열판기본_현재가", + "1열판기본_시간외단일가", + null, + "1열판상세_시가", + "1열판상세_액면가", + "1열판상세_PBR", + "1열판상세_거래량", + null, + "수익률그래프_5일", + "수익률그래프_20일", + "수익률그래프_60일", + "수익률그래프_120일", + "수익률그래프_240일", + "캔들그래프_일봉", + "캔들그래프_5일", + "캔들그래프_20일", + "캔들그래프_60일", + "캔들그래프_120일", + "캔들그래프_240일", + "캔들그래프(거래량)_일봉", + "캔들그래프(거래량)_5일", + "캔들그래프(거래량)_20일", + "캔들그래프(거래량)_60일", + "캔들그래프(거래량)_120일", + "캔들그래프(거래량)_240일", + "캔들그래프(예상체결가)_5일", + "캔들그래프(예상체결가)_20일", + "캔들그래프(예상체결가)_60일", + "캔들그래프(예상체결가)_120일", + "캔들그래프(예상체결가)_240일", + "호가창_표그래프", + "거래원_표그래프" + ]; + + public static IReadOnlyList Rows { get; } = CreateRows(); + + public static string ResolveCutAlias(string rawLabel, bool isNxt) + { + ArgumentException.ThrowIfNullOrEmpty(rawLabel); + if (isNxt) + { + if (!string.Equals(rawLabel, NxtAllowedLabel, StringComparison.Ordinal)) + { + throw new InvalidOperationException("NXT는 1열판기본_현재가만 지원합니다."); + } + + return "N5001"; + } + + if (rawLabel.StartsWith("1열판기본_", StringComparison.Ordinal)) + { + return "5001"; + } + + if (string.Equals(rawLabel, "1열판상세_시가", StringComparison.Ordinal)) + { + return "5006"; + } + + if (rawLabel.StartsWith("1열판상세_", StringComparison.Ordinal)) + { + return "5011"; + } + + if (rawLabel.StartsWith("수익률그래프_", StringComparison.Ordinal)) + { + return "5086"; + } + + if (rawLabel.StartsWith("캔들그래프", StringComparison.Ordinal)) + { + var split = rawLabel.LastIndexOf('_'); + var period = split >= 0 ? rawLabel[(split + 1)..] : string.Empty; + return period switch + { + "일봉" => "8035", + "5일" => "8061", + "20일" => "8040", + "60일" => "8046", + "120일" => "8051", + "240일" => "8056", + _ => throw UnknownCut(rawLabel) + }; + } + + return rawLabel switch + { + "호가창_표그래프" => "8003", + "거래원_표그래프" => "5037", + _ => throw UnknownCut(rawLabel) + }; + } + + public static LegacyOperatorPlaylistRow CreatePlaylistRow( + string rowId, + string stockName, + LegacyStockIdentity? identity, + LegacyCutCatalogRow cut, + int fadeDuration = 6) + { + ArgumentException.ThrowIfNullOrEmpty(rowId); + ArgumentNullException.ThrowIfNull(stockName); + ArgumentNullException.ThrowIfNull(cut); + if (cut.IsSeparator || string.IsNullOrEmpty(cut.RawLabel)) + { + throw new ArgumentException("A separator cannot become a playlist row.", nameof(cut)); + } + + var split = cut.RawLabel.IndexOf('_'); + if (split <= 0 || split == cut.RawLabel.Length - 1) + { + throw UnknownCut(cut.RawLabel); + } + + return new LegacyOperatorPlaylistRow( + rowId, + true, + identity?.MarketText ?? string.Empty, + stockName, + cut.RawLabel[..split], + cut.RawLabel[(split + 1)..], + "1/1", + identity?.StockCode ?? string.Empty, + cut.RawLabel, + fadeDuration); + } + + private static IReadOnlyList CreateRows() + { + var displayOrdinal = 0; + var rows = new LegacyCutCatalogRow[RuntimeRows.Length]; + for (var index = 0; index < RuntimeRows.Length; index++) + { + var label = RuntimeRows[index]; + var separator = label is null; + rows[index] = new LegacyCutCatalogRow( + index, + separator ? null : ++displayOrdinal, + label ?? string.Empty, + separator); + } + + return Array.AsReadOnly(rows); + } + + private static InvalidOperationException UnknownCut(string label) => + new($"The legacy stock cut is unknown: {label}"); +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockModels.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockModels.cs new file mode 100644 index 0000000..34bb8c9 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockModels.cs @@ -0,0 +1,77 @@ +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +public enum LegacyDomesticStockMarket +{ + Kospi, + Kosdaq, + NxtKospi, + NxtKosdaq +} + +public sealed record LegacyStockIdentity( + LegacyDomesticStockMarket Market, + string DisplayName, + string StockCode) +{ + public bool IsNxt => Market is + LegacyDomesticStockMarket.NxtKospi or LegacyDomesticStockMarket.NxtKosdaq; + + public string MarketText => Market switch + { + LegacyDomesticStockMarket.Kospi => "코스피", + LegacyDomesticStockMarket.Kosdaq => "코스닥", + LegacyDomesticStockMarket.NxtKospi => "코스피_NXT", + LegacyDomesticStockMarket.NxtKosdaq => "코스닥_NXT", + _ => string.Empty + }; +} + +public sealed class LegacyStockSearchData +{ + private readonly LegacyStockIdentity[] _masterCatalog; + + public LegacyStockSearchData( + string query, + IEnumerable displayNames, + IEnumerable masterCatalog) + { + Query = query ?? throw new ArgumentNullException(nameof(query)); + ArgumentNullException.ThrowIfNull(displayNames); + ArgumentNullException.ThrowIfNull(masterCatalog); + + DisplayNames = Array.AsReadOnly(displayNames.ToArray()); + _masterCatalog = masterCatalog.ToArray(); + MasterCatalog = Array.AsReadOnly(_masterCatalog); + } + + public string Query { get; } + + public IReadOnlyList DisplayNames { get; } + + public IReadOnlyList MasterCatalog { get; } + + /// + /// MainForm.jongmokCode scanned all four cached master tables without breaking. + /// Keeping the final match therefore reproduces its duplicate-name behaviour. + /// + public LegacyStockIdentity? ResolveLastByDisplayName(string displayName) + { + LegacyStockIdentity? match = null; + foreach (var item in _masterCatalog) + { + if (string.Equals(item.DisplayName, displayName, StringComparison.Ordinal)) + { + match = item; + } + } + + return match; + } +} + +public interface ILegacyStockLookup +{ + Task SearchAsync( + string query, + CancellationToken cancellationToken = default); +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/MBN_STOCK_WEBVIEW.LegacyApplication.csproj b/src/MBN_STOCK_WEBVIEW.LegacyApplication/MBN_STOCK_WEBVIEW.LegacyApplication.csproj new file mode 100644 index 0000000..ec7bd4c --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/MBN_STOCK_WEBVIEW.LegacyApplication.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + latest + true + MBN_STOCK_WEBVIEW.LegacyApplication + MBN_STOCK_WEBVIEW.LegacyApplication + + + + + + + diff --git a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs new file mode 100644 index 0000000..9c8d7f5 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs @@ -0,0 +1,299 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using MBN_STOCK_WEBVIEW.LegacyApplication; + +namespace MBN_STOCK_WEBVIEW.LegacyBridge; + +public abstract record LegacyUiIntent; + +public sealed record LegacyReadyIntent : LegacyUiIntent; + +public sealed record LegacySearchStocksIntent(string Text, LegacySearchTrigger Trigger) + : LegacyUiIntent; + +public sealed record LegacySelectStockIntent(int ResultIndex) : LegacyUiIntent; + +public sealed record LegacyCutPointerDownIntent( + int PhysicalIndex, + bool Control, + bool Shift, + int X, + int Y, + long TimestampMilliseconds) : LegacyUiIntent; + +public sealed record LegacyDropSelectedCutsIntent : LegacyUiIntent; + +public sealed record LegacySetPlaylistEnabledIntent(string RowId, bool Enabled) + : LegacyUiIntent; + +public sealed record LegacyDismissDialogIntent : LegacyUiIntent; + +public static class LegacyBridgeProtocol +{ + public const string TrustedHost = "legacy-parity.mbn.local"; + + private static readonly JsonSerializerOptions SerializerOptions = CreateSerializerOptions(); + + public static bool IsTrustedSource(string? source) + { + if (!Uri.TryCreate(source, UriKind.Absolute, out var uri)) + { + return false; + } + + return string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + string.Equals(uri.Host, TrustedHost, StringComparison.OrdinalIgnoreCase) && + uri.IsDefaultPort; + } + + public static bool TryParseIntent( + string json, + out LegacyUiIntent? intent, + out string? error) + { + intent = null; + error = null; + + try + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 8 + }); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(root, "type", "payload") || + !TryString(root, "type", 64, out var type) || + !root.TryGetProperty("payload", out var payload) || + payload.ValueKind != JsonValueKind.Object) + { + error = "올바르지 않은 UI 요청입니다."; + return false; + } + + intent = type switch + { + "ready" => ParseReady(payload), + "search-stocks" => ParseSearch(payload), + "select-stock" => ParseStockSelection(payload), + "cut-pointer-down" => ParseCutPointer(payload), + "drop-selected-cuts" => ParseDrop(payload), + "set-playlist-enabled" => ParseEnabled(payload), + "dismiss-dialog" => ParseDismiss(payload), + _ => null + }; + + if (intent is null) + { + error = "지원하지 않는 UI 요청입니다."; + return false; + } + + return true; + } + catch (JsonException) + { + error = "UI 요청 JSON을 읽을 수 없습니다."; + return false; + } + } + + public static string SerializeState( + LegacyOperatorSnapshot snapshot, + bool isBusy = false) + { + ArgumentNullException.ThrowIfNull(snapshot); + var payload = new + { + snapshot.Revision, + snapshot.SearchText, + snapshot.SearchResults, + snapshot.SelectedStockIndex, + snapshot.CutRows, + playlist = snapshot.Playlist.Select(row => new + { + row.RowId, + row.IsEnabled, + row.MarketText, + row.StockName, + row.GraphicType, + row.Subtype, + row.PageText + }), + snapshot.Dialog, + snapshot.StatusMessage, + snapshot.StatusKind, + isBusy + }; + + return JsonSerializer.Serialize(new { type = "state", payload }, SerializerOptions); + } + + private static LegacyUiIntent? ParseReady(JsonElement payload) => + HasOnlyProperties(payload) ? new LegacyReadyIntent() : null; + + private static LegacyUiIntent? ParseSearch(JsonElement payload) + { + if (!HasOnlyProperties(payload, "text", "trigger") || + !TryString( + payload, + "text", + LegacyParityStockSearchService.LegacyTextBoxMaximumLength, + out var text) || + !TryString(payload, "trigger", 16, out var triggerText)) + { + return null; + } + + var trigger = triggerText switch + { + "button" => LegacySearchTrigger.Button, + "enter" => LegacySearchTrigger.Enter, + _ => (LegacySearchTrigger?)null + }; + return trigger.HasValue ? new LegacySearchStocksIntent(text, trigger.Value) : null; + } + + private static LegacyUiIntent? ParseStockSelection(JsonElement payload) => + HasOnlyProperties(payload, "resultIndex") && + TryInt32(payload, "resultIndex", 0, int.MaxValue, out var resultIndex) + ? new LegacySelectStockIntent(resultIndex) + : null; + + private static LegacyUiIntent? ParseCutPointer(JsonElement payload) + { + if (!HasOnlyProperties( + payload, + "physicalIndex", + "control", + "shift", + "x", + "y", + "timestampMilliseconds") || + !TryInt32(payload, "physicalIndex", 0, 32, out var physicalIndex) || + !TryBoolean(payload, "control", out var control) || + !TryBoolean(payload, "shift", out var shift) || + !TryInt32(payload, "x", 0, 16_384, out var x) || + !TryInt32(payload, "y", 0, 16_384, out var y) || + !TryInt64(payload, "timestampMilliseconds", 0, long.MaxValue, out var timestamp)) + { + return null; + } + + return new LegacyCutPointerDownIntent( + physicalIndex, + control, + shift, + x, + y, + timestamp); + } + + private static LegacyUiIntent? ParseDrop(JsonElement payload) => + HasOnlyProperties(payload) ? new LegacyDropSelectedCutsIntent() : null; + + private static LegacyUiIntent? ParseEnabled(JsonElement payload) + { + if (!HasOnlyProperties(payload, "rowId", "enabled") || + !TryString(payload, "rowId", 128, out var rowId) || + !IsSafeIdentifier(rowId) || + !TryBoolean(payload, "enabled", out var enabled)) + { + return null; + } + + return new LegacySetPlaylistEnabledIntent(rowId, enabled); + } + + private static LegacyUiIntent? ParseDismiss(JsonElement payload) => + HasOnlyProperties(payload) ? new LegacyDismissDialogIntent() : null; + + private static bool HasOnlyProperties(JsonElement element, params string[] names) + { + var expected = new HashSet(names, StringComparer.Ordinal); + var count = 0; + foreach (var property in element.EnumerateObject()) + { + count++; + if (!expected.Contains(property.Name)) + { + return false; + } + } + + return count == expected.Count; + } + + private static bool TryString( + JsonElement element, + string propertyName, + int maximumLength, + out string value) + { + value = string.Empty; + return element.TryGetProperty(propertyName, out var property) && + property.ValueKind == JsonValueKind.String && + (value = property.GetString() ?? string.Empty).Length <= maximumLength; + } + + private static bool TryBoolean(JsonElement element, string name, out bool value) + { + value = false; + if (!element.TryGetProperty(name, out var property) || + (property.ValueKind != JsonValueKind.True && property.ValueKind != JsonValueKind.False)) + { + return false; + } + + value = property.GetBoolean(); + return true; + } + + private static bool TryInt32( + JsonElement element, + string name, + int minimum, + int maximum, + out int value) + { + value = 0; + return element.TryGetProperty(name, out var property) && + property.ValueKind == JsonValueKind.Number && + property.TryGetInt32(out value) && + value >= minimum && value <= maximum; + } + + private static bool TryInt64( + JsonElement element, + string name, + long minimum, + long maximum, + out long value) + { + value = 0; + return element.TryGetProperty(name, out var property) && + property.ValueKind == JsonValueKind.Number && + property.TryGetInt64(out value) && + value >= minimum && value <= maximum; + } + + private static bool IsSafeIdentifier(string value) + { + if (value.Length == 0) + { + return false; + } + + return value.All(character => + char.IsAsciiLetterOrDigit(character) || character is '-' or '_'); + } + + private static JsonSerializerOptions CreateSerializerOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + return options; + } +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyBridge/MBN_STOCK_WEBVIEW.LegacyBridge.csproj b/src/MBN_STOCK_WEBVIEW.LegacyBridge/MBN_STOCK_WEBVIEW.LegacyBridge.csproj new file mode 100644 index 0000000..9a3d00c --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyBridge/MBN_STOCK_WEBVIEW.LegacyBridge.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + latest + true + MBN_STOCK_WEBVIEW.LegacyBridge + MBN_STOCK_WEBVIEW.LegacyBridge + + + + + + + diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml new file mode 100644 index 0000000..cb3664f --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs new file mode 100644 index 0000000..b62ab8a --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs @@ -0,0 +1,48 @@ +using Microsoft.Windows.AppLifecycle; + +namespace MBN_STOCK_WEBVIEW.LegacyParityApp; + +public partial class App : Application +{ + private const string MainInstanceKey = "Wickedness.MBNStockWebView.LegacyParity.Main"; + + private Window? _window; + private AppInstance? _mainInstance; + + public App() + { + InitializeComponent(); + } + + protected override async void OnLaunched(LaunchActivatedEventArgs args) + { + var registeredInstance = AppInstance.FindOrRegisterForKey(MainInstanceKey); + if (!registeredInstance.IsCurrent) + { + try + { + var activation = AppInstance.GetCurrent().GetActivatedEventArgs(); + if (activation is not null) + { + await registeredInstance.RedirectActivationToAsync(activation); + } + } + finally + { + Exit(); + } + + return; + } + + _mainInstance = registeredInstance; + _mainInstance.Activated += OnMainInstanceActivated; + _window = new MainWindow(); + _window.Activate(); + } + + private void OnMainInstanceActivated(object? sender, AppActivationArguments args) + { + _window?.DispatcherQueue.TryEnqueue(_window.Activate); + } +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Imports.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Imports.cs new file mode 100644 index 0000000..5443927 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Imports.cs @@ -0,0 +1,2 @@ +global using Microsoft.UI.Xaml; +global using Microsoft.UI.Xaml.Controls; diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj new file mode 100644 index 0000000..b9750ee --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj @@ -0,0 +1,65 @@ + + + + + net8.0-windows10.0.19041.0 + 10.0.17763.0 + WinExe + true + true + enable + latest + enable + MBN_STOCK_WEBVIEW.LegacyParityApp + MBN_STOCK_WEBVIEW.LegacyParityApp + true + x64 + x64 + win-x64 + win-x64 + app.manifest + false + false + 0.1.0 + 1 + 0.1.0 + + + + + + + + + + + + PreserveNewest + PreserveNewest + + + + + + + + + + + + + + + + + + + + + + + + true + + + diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml new file mode 100644 index 0000000..599e528 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs new file mode 100644 index 0000000..011fafe --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs @@ -0,0 +1,353 @@ +using MBN_STOCK_WEBVIEW.Infrastructure; +using MBN_STOCK_WEBVIEW.LegacyApplication; +using MBN_STOCK_WEBVIEW.LegacyBridge; +using Microsoft.UI.Windowing; +using Microsoft.Web.WebView2.Core; +using MMoneyCoderSharp.Data; +using Windows.Graphics; +using WinRT.Interop; + +namespace MBN_STOCK_WEBVIEW.LegacyParityApp; + +public sealed partial class MainWindow : Window +{ + private readonly CancellationTokenSource _lifetimeCancellation = new(); + private readonly SemaphoreSlim _intentGate = new(1, 1); + private readonly LegacyOperatorController _controller; + private DatabaseRuntime? _databaseRuntime; + private bool _closing; + private bool _intentBusy; + private bool _webViewReady; + + public MainWindow() + { + InitializeComponent(); + + ILegacyStockLookup stockLookup; + string? initializationError = null; + try + { + _databaseRuntime = DatabaseRuntime.CreateDefault(); + DataQueryExecutor.Configure(_databaseRuntime.Executor); + stockLookup = new LegacyParityStockSearchService(_databaseRuntime.Executor); + } + catch + { + initializationError = + "데이터베이스가 설정되지 않았습니다. 로컬 설정을 확인하세요."; + stockLookup = new UnavailableStockLookup(initializationError); + DataQueryExecutor.Reset(); + } + + _controller = new LegacyOperatorController(stockLookup); + if (initializationError is not null) + { + _controller.ReportError(initializationError); + } + + Root.Loaded += OnRootLoaded; + Closed += OnClosed; + } + + private async void OnRootLoaded(object sender, RoutedEventArgs e) + { + Root.Loaded -= OnRootLoaded; + if (_closing) + { + return; + } + + ConfigureWindow(); + await InitializeWebViewAsync(); + } + + private void ConfigureWindow() + { + var windowHandle = WindowNative.GetWindowHandle(this); + var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle); + var appWindow = AppWindow.GetFromWindowId(windowId); + appWindow.Title = "V-Stock 증권정보송출시스템 for 매일경제TV (26.03.26)"; + + if (appWindow.Presenter is OverlappedPresenter presenter) + { + presenter.IsMaximizable = false; + presenter.IsResizable = false; + } + + // WinForms stored 1905 x 1015 as ClientSize. AppWindow.Resize would include + // the caption and borders and silently make the WebView viewport smaller. + appWindow.ResizeClient(new SizeInt32(1905, 1015)); + var displayArea = DisplayArea.GetFromWindowId(windowId, DisplayAreaFallback.Primary); + appWindow.Move(new PointInt32(displayArea.WorkArea.X, displayArea.WorkArea.Y)); + } + + private async Task InitializeWebViewAsync() + { + try + { + var webRoot = Path.Combine(AppContext.BaseDirectory, "Web"); + var startPage = Path.Combine(webRoot, "index.html"); + if (!File.Exists(startPage)) + { + throw new FileNotFoundException("The parity Web UI was not found.", startPage); + } + + await Browser.EnsureCoreWebView2Async(); + if (_closing) + { + return; + } + + var coreWebView = Browser.CoreWebView2 ?? + throw new InvalidOperationException("WebView2 was not initialized."); + coreWebView.SetVirtualHostNameToFolderMapping( + LegacyBridgeProtocol.TrustedHost, + webRoot, + CoreWebView2HostResourceAccessKind.DenyCors); + coreWebView.Settings.AreDevToolsEnabled = + System.Diagnostics.Debugger.IsAttached; + coreWebView.Settings.AreDefaultContextMenusEnabled = false; + coreWebView.Settings.IsStatusBarEnabled = false; + coreWebView.NavigationStarting += OnNavigationStarting; + coreWebView.NavigationCompleted += OnNavigationCompleted; + coreWebView.NewWindowRequested += OnNewWindowRequested; + coreWebView.WebMessageReceived += OnWebMessageReceived; + coreWebView.ProcessFailed += OnProcessFailed; + + _webViewReady = true; + Browser.Source = new Uri( + $"https://{LegacyBridgeProtocol.TrustedHost}/index.html"); + } + catch (Exception exception) + { + if (!_closing) + { + ShowError("WebView2 초기화에 실패했습니다.", exception.Message); + } + } + } + + private void OnNavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs args) + { + if (!LegacyBridgeProtocol.IsTrustedSource(args.Uri) && + !string.Equals(args.Uri, "about:blank", StringComparison.OrdinalIgnoreCase)) + { + args.Cancel = true; + } + } + + private void OnNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs args) + { + if (_closing) + { + return; + } + + LoadingOverlay.Visibility = Visibility.Collapsed; + if (!args.IsSuccess) + { + ShowError("원본 호환 화면을 불러오지 못했습니다.", args.WebErrorStatus.ToString()); + } + } + + private static void OnNewWindowRequested( + object? sender, + CoreWebView2NewWindowRequestedEventArgs args) + { + args.Handled = true; + } + + private void OnWebMessageReceived( + object? sender, + CoreWebView2WebMessageReceivedEventArgs args) + { + if (_closing) + { + return; + } + + LegacyUiIntent? intent = null; + string? parseError = null; + if (!LegacyBridgeProtocol.IsTrustedSource(args.Source) || + !LegacyBridgeProtocol.TryParseIntent( + args.WebMessageAsJson, + out intent, + out parseError) || + intent is null) + { + if (!string.IsNullOrEmpty(parseError)) + { + PostState(_controller.ReportError(parseError)); + } + + return; + } + + _ = ProcessIntentAsync(intent); + } + + private async Task ProcessIntentAsync(LegacyUiIntent intent) + { + var gateEntered = false; + try + { + // MainForm performed database search synchronously. The busy snapshot + // freezes the Web controls, while this zero-timeout gate prevents an + // already-posted duplicate from growing an unbounded DB request queue. + gateEntered = await _intentGate.WaitAsync( + 0, + _lifetimeCancellation.Token); + if (!gateEntered) + { + PostState(_controller.Current); + return; + } + + if (_closing) + { + return; + } + + if (intent is LegacySearchStocksIntent searchIntent && + !(searchIntent.Trigger == LegacySearchTrigger.Button && + searchIntent.Text.Length == 0)) + { + _intentBusy = true; + PostState(_controller.Current); + } + + var state = await HandleIntentAsync(intent, _lifetimeCancellation.Token); + if (!_closing) + { + _intentBusy = false; + PostState(state); + } + } + catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested) + { + } + catch when (_closing) + { + } + catch + { + _intentBusy = false; + PostState(_controller.ReportError( + "요청을 처리하지 못했습니다. 데이터베이스 연결 상태를 확인하세요.")); + } + finally + { + if (_closing) + { + _intentBusy = false; + } + + if (gateEntered) + { + _intentGate.Release(); + } + } + } + + private async Task HandleIntentAsync( + LegacyUiIntent intent, + CancellationToken cancellationToken) + { + return intent switch + { + LegacyReadyIntent => _controller.Current, + LegacySearchStocksIntent search => await _controller.SearchStocksAsync( + search.Text, + search.Trigger, + cancellationToken), + LegacySelectStockIntent selection => + _controller.SelectStock(selection.ResultIndex), + LegacyCutPointerDownIntent pointer => _controller.CutPointerDown( + pointer.PhysicalIndex, + pointer.Control, + pointer.Shift, + pointer.X, + pointer.Y, + pointer.TimestampMilliseconds), + LegacyDropSelectedCutsIntent => _controller.DropSelectedCutsOnPlaylist(), + LegacySetPlaylistEnabledIntent enabled => + _controller.SetPlaylistRowEnabled(enabled.RowId, enabled.Enabled), + LegacyDismissDialogIntent => _controller.DismissDialog(), + _ => _controller.ReportError("지원하지 않는 화면 요청입니다.") + }; + } + + private void PostState(LegacyOperatorSnapshot snapshot) + { + if (!_closing && _webViewReady && Browser.CoreWebView2 is not null) + { + Browser.CoreWebView2.PostWebMessageAsJson( + LegacyBridgeProtocol.SerializeState(snapshot, _intentBusy)); + } + } + + private void OnProcessFailed(object? sender, CoreWebView2ProcessFailedEventArgs args) + { + if (!_closing) + { + ShowError("WebView2 프로세스가 중단되었습니다.", args.ProcessFailedKind.ToString()); + } + } + + private void ShowError(string title, string message) + { + if (_closing) + { + return; + } + + ErrorBar.Title = title; + ErrorBar.Message = message; + ErrorBar.IsOpen = true; + } + + private void OnClosed(object sender, WindowEventArgs args) + { + if (_closing) + { + return; + } + + _closing = true; + Root.Loaded -= OnRootLoaded; + Closed -= OnClosed; + _lifetimeCancellation.Cancel(); + DataQueryExecutor.Reset(); + + if (Browser.CoreWebView2 is not null) + { + Browser.CoreWebView2.NavigationStarting -= OnNavigationStarting; + Browser.CoreWebView2.NavigationCompleted -= OnNavigationCompleted; + Browser.CoreWebView2.NewWindowRequested -= OnNewWindowRequested; + Browser.CoreWebView2.WebMessageReceived -= OnWebMessageReceived; + Browser.CoreWebView2.ProcessFailed -= OnProcessFailed; + } + + _webViewReady = false; + + // These process-lifetime guards deliberately remain undisposed. An in-flight + // provider call may resume after Closed and must still be able to observe + // cancellation and release the gate without racing disposed objects. + } + + private sealed class UnavailableStockLookup : ILegacyStockLookup + { + private readonly string _message; + + public UnavailableStockLookup(string message) + { + _message = message; + } + + public Task SearchAsync( + string query, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + } +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Package.appxmanifest b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Package.appxmanifest new file mode 100644 index 0000000..b693477 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Package.appxmanifest @@ -0,0 +1,43 @@ + + + + + + MBN Stock WebView (Legacy Parity) + Wickedness + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Properties/launchSettings.json b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Properties/launchSettings.json new file mode 100644 index 0000000..e6827a3 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Properties/launchSettings.json @@ -0,0 +1,7 @@ +{ + "profiles": { + "MBN_STOCK_WEBVIEW.LegacyParityApp (Package)": { + "commandName": "MsixPackage" + } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js new file mode 100644 index 0000000..866e602 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js @@ -0,0 +1,232 @@ +(function () { + "use strict"; + + const webview = window.chrome && window.chrome.webview; + const searchInput = document.getElementById("stock-search"); + const searchButton = document.getElementById("stock-search-button"); + const stockResults = document.getElementById("stock-results"); + const cutList = document.getElementById("cut-list"); + const playlistRows = document.getElementById("playlist-rows"); + const playlistDropZone = document.getElementById("playlist-drop-zone"); + const status = document.getElementById("operator-status"); + const dialog = document.getElementById("legacy-dialog"); + const dialogMessage = document.getElementById("legacy-dialog-message"); + const dialogOk = document.getElementById("legacy-dialog-ok"); + let uiBusy = false; + + function send(type, payload) { + if (webview && !uiBusy) webview.postMessage({ type: type, payload: payload || {} }); + } + + function createStockResultElement(row) { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.resultIndex = String(row.index); + button.setAttribute("role", "option"); + button.addEventListener("click", function () { + send("select-stock", { resultIndex: Number(button.dataset.resultIndex) }); + }); + return button; + } + + function renderStockResults(state) { + state.searchResults.forEach(function (row, position) { + let button = stockResults.children.item(position); + if (!button || button.dataset.resultIndex !== String(row.index)) { + button = createStockResultElement(row); + if (position < stockResults.children.length) { + stockResults.replaceChild(button, stockResults.children.item(position)); + } else { + stockResults.appendChild(button); + } + } + + button.className = "stock-result" + + (state.selectedStockIndex === row.index ? " selected" : ""); + button.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false"); + button.textContent = row.displayName; + button.setAttribute("aria-selected", state.selectedStockIndex === row.index ? "true" : "false"); + }); + + while (stockResults.children.length > state.searchResults.length) { + stockResults.lastElementChild.remove(); + } + } + + function pointerPosition(event) { + const bounds = cutList.getBoundingClientRect(); + return { + x: Math.max(0, Math.round(event.clientX - bounds.left)), + y: Math.max(0, Math.round(event.clientY - bounds.top)) + }; + } + + function createCutElement(row) { + const element = document.createElement("div"); + element.setAttribute("role", "option"); + element.dataset.physicalIndex = String(row.physicalIndex); + element.draggable = true; + + const number = document.createElement("span"); + number.className = "cut-number"; + number.textContent = row.displayOrdinal === null ? "" : String(row.displayOrdinal); + const label = document.createElement("span"); + label.textContent = row.rawLabel; + element.append(number, label); + + element.addEventListener("pointerdown", function (event) { + const position = pointerPosition(event); + send("cut-pointer-down", { + physicalIndex: row.physicalIndex, + control: event.ctrlKey, + shift: event.shiftKey, + x: position.x, + y: position.y, + timestampMilliseconds: Math.max(0, Math.round(event.timeStamp)) + }); + }); + element.addEventListener("dragstart", function (event) { + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", "legacy-selected-cuts"); + } + }); + return element; + } + + function renderCuts(state) { + state.cutRows.forEach(function (row, index) { + let element = cutList.children.item(index); + if (!element || element.dataset.physicalIndex !== String(row.physicalIndex)) { + element = createCutElement(row); + if (index < cutList.children.length) { + cutList.replaceChild(element, cutList.children.item(index)); + } else { + cutList.appendChild(element); + } + } + + element.className = "cut-row" + + (row.isSelected ? " selected" : "") + + (row.isSeparator ? " separator" : ""); + element.setAttribute("aria-selected", row.isSelected ? "true" : "false"); + element.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false"); + element.draggable = state.isBusy !== true; + }); + + while (cutList.children.length > state.cutRows.length) { + cutList.lastElementChild.remove(); + } + } + + function createPlaylistElement(row) { + const element = document.createElement("div"); + element.className = "playlist-row playlist-data-row"; + element.dataset.rowId = row.rowId; + + const enabledCell = document.createElement("div"); + enabledCell.className = "enabled-cell"; + const enabled = document.createElement("input"); + enabled.type = "checkbox"; + enabled.addEventListener("change", function () { + send("set-playlist-enabled", { + rowId: element.dataset.rowId, + enabled: enabled.checked + }); + }); + enabledCell.appendChild(enabled); + element.appendChild(enabledCell); + + for (let index = 0; index < 5; index += 1) { + element.appendChild(document.createElement("div")); + } + return element; + } + + function renderPlaylist(state) { + state.playlist.forEach(function (row, position) { + let element = playlistRows.children.item(position); + if (!element || element.dataset.rowId !== row.rowId) { + element = createPlaylistElement(row); + if (position < playlistRows.children.length) { + playlistRows.replaceChild(element, playlistRows.children.item(position)); + } else { + playlistRows.appendChild(element); + } + } + + const enabled = element.querySelector("input"); + enabled.checked = row.isEnabled; + enabled.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false"); + [row.marketText, row.stockName, row.graphicType, row.subtype, row.pageText] + .forEach(function (text, cellIndex) { + const cell = element.children.item(cellIndex + 1); + cell.textContent = text; + cell.title = text; + }); + }); + + while (playlistRows.children.length > state.playlist.length) { + playlistRows.lastElementChild.remove(); + } + } + + function renderDialog(state) { + if (state.dialog && state.dialog.message) { + dialogMessage.textContent = state.dialog.message; + dialog.hidden = false; + dialogOk.focus(); + } else { + dialog.hidden = true; + } + } + + function render(state) { + const isBusy = state.isBusy === true; + uiBusy = isBusy; + document.body.classList.toggle("busy", isBusy); + document.body.setAttribute("aria-busy", isBusy ? "true" : "false"); + searchInput.readOnly = isBusy; + searchButton.setAttribute("aria-disabled", isBusy ? "true" : "false"); + if (document.activeElement !== searchInput) searchInput.value = state.searchText || ""; + renderStockResults(state); + renderCuts(state); + renderPlaylist(state); + renderDialog(state); + status.textContent = isBusy ? "종목을 검색하고 있습니다." : (state.statusMessage || ""); + } + + searchButton.addEventListener("click", function () { + send("search-stocks", { text: searchInput.value, trigger: "button" }); + }); + + searchInput.addEventListener("keypress", function (event) { + if (event.key === "Enter") { + event.preventDefault(); + send("search-stocks", { text: searchInput.value, trigger: "enter" }); + } + }); + + playlistDropZone.addEventListener("dragover", function (event) { + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; + }); + playlistDropZone.addEventListener("drop", function (event) { + event.preventDefault(); + send("drop-selected-cuts", {}); + }); + dialogOk.addEventListener("click", function () { + send("dismiss-dialog", {}); + }); + + if (webview) { + webview.addEventListener("message", function (event) { + if (event.data && event.data.type === "state" && event.data.payload) { + render(event.data.payload); + } + }); + send("ready", {}); + } else { + status.textContent = "WebView2 브리지를 사용할 수 없습니다."; + } +}()); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html new file mode 100644 index 0000000..268704f --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html @@ -0,0 +1,114 @@ + + + + + + V-Stock 증권정보송출시스템 + + + +
+
+
+
매일경제TVVRi
+
미연결
+
+ +
+ + + +
+ +
+ +
+ + + + +
+ +
+
+
+ +
+ + +
Category
+
+
[1열판기본]
+
▸ 다우
+
▸ 나스닥
+
▸ S&P
+
▸ 독일
+
▸ 영국
+
▸ 프랑스
+
▸ 니케이
+
▸ 중국 상해
+
▸ 홍콩 항셍
+
▸ 대만 가권
+
▸ 싱가포르 지수
+
▸ 필리핀 지수
+
▸ 말레이시아 지수
+
[2열판-]
+
▸ 공산품 [원면, 목화(면화)]
+
▸ 원자재 [국제 금, 국제 은]
+
+
+ +
+
+ + + + + + + + + + + + + +
+ +
+
+
송출
종류
종목
컷파일
세부사항
Page
+
+
+
+ +
+ + + +
+
원본 호환 UI 기준선 · 송출 연결 안 함
+
+
+ + + + + + diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css new file mode 100644 index 0000000..f2843b3 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css @@ -0,0 +1,92 @@ +:root { + color-scheme: light; + font-family: "NanumGothic", "맑은 고딕", sans-serif; + font-size: 13px; + background: #efefef; + color: #111; +} + +* { box-sizing: border-box; } +html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; } +body.busy { cursor: wait; } +body.busy .operator-shell { pointer-events: none; } +button, input { font: inherit; } +button { border: 1px solid #a8a8a8; background: linear-gradient(#fff, #e9e9e9); color: #111; } +button:not(:disabled) { cursor: pointer; } +button:disabled { color: #555; opacity: 1; } + +.operator-shell { + display: grid; + grid-template-columns: 430px 500px minmax(760px, 1fr); + width: 100%; + min-width: 1690px; + height: 100vh; + background: #efefef; +} + +.left-panel, .catalog-panel, .playlist-panel { min-height: 0; border-right: 1px solid #bbb; } +.left-panel { display: flex; flex-direction: column; padding: 6px 15px 4px 20px; background: #f5f5f5; } + +.brand-line { height: 68px; display: flex; align-items: center; justify-content: space-between; } +.brand { display: flex; align-items: center; gap: 12px; white-space: nowrap; } +.brand span { color: #ed671f; font-size: 28px; font-weight: 800; letter-spacing: -2px; } +.brand strong { font: italic 900 38px Arial, sans-serif; letter-spacing: -5px; } +.connection-state { padding: 10px 12px; background: #c40000; color: white; font: 700 18px Consolas, monospace; } + +.search-line { display: grid; grid-template-columns: 40px 162px 1fr 117px; align-items: center; gap: 3px; height: 38px; } +.search-line label { font-weight: 700; } +.search-line input { grid-column: 2; height: 27px; padding: 2px 7px; border: 1px solid #888; font-size: 16px; } +.search-line button { grid-column: 4; height: 31px; font-size: 14px; } +.search-line button span { font-size: 21px; margin-right: 20px; } + +.stock-results { height: 174px; border: 1px solid #999; background: #fff; overflow-y: auto; } +.stock-result { display: block; width: 100%; height: 20px; padding: 1px 5px; border: 0; background: #fff; text-align: left; font-size: 16px; line-height: 18px; } +.stock-result.selected { background: #696969; color: #fff; } + +.manual-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: 9px 0; } +.manual-actions button { height: 34px; font-size: 12px; font-weight: 700; } + +.cut-list { flex: 1; min-height: 0; border: 1px solid #999; background: #fff; overflow-y: auto; user-select: none; } +.cut-row { display: grid; grid-template-columns: 62px 1fr; min-height: 23px; border-bottom: 1px solid #ddd; background: #fff; font-size: 15px; } +.cut-row > span { padding: 2px 7px; pointer-events: none; } +.cut-row .cut-number { border-right: 1px solid #ddd; text-align: center; color: #145ab4; } +.cut-row.selected { background: #dcdcdc; } +.cut-row.separator { background: #fff; } +.operator-status { min-height: 18px; padding-top: 2px; color: #a40000; font-size: 11px; } + +.catalog-panel { display: flex; flex-direction: column; padding: 0 8px; background: #f3f3f3; } +.market-tabs { display: flex; height: 29px; border-bottom: 1px solid #999; } +.market-tabs button { min-width: 48px; padding: 3px 8px; border-width: 0 1px 0 0; background: #efefef; } +.market-tabs button.active { background: #fff; border-top: 2px solid #e8781d; } +.expand-line { height: 31px; padding: 7px 8px 0; } +.category-title { height: 27px; border-bottom: 1px solid #777; font-size: 12px; } +.category-tree { flex: 1; overflow: auto; padding: 7px 12px; background: #fff; border-left: 1px solid #aaa; border-right: 1px solid #aaa; } +.tree-root { margin: 5px 0 2px; color: #168000; font-size: 16px; } +.tree-root.second { margin-top: 15px; } +.tree-item { padding: 3px 0 3px 29px; font-size: 15px; } + +.playlist-panel { display: grid; grid-template-rows: 58px minmax(0, 1fr) 76px 42px; padding: 0 8px; background: #efefef; } +.playlist-toolbar { display: flex; align-items: center; gap: 8px; white-space: nowrap; } +.playlist-toolbar button { height: 25px; padding: 2px 9px; } +.playlist-toolbar .toolbar-spacer { flex: 1; } +.background-name { width: 95px; height: 25px; border: 1px solid #888; background: #fff; } + +.playlist-grid { min-height: 0; border: 1px solid #888; background: #d5d5d5; overflow: auto; } +.playlist-row { display: grid; grid-template-columns: 40px 100px 260px 200px minmax(255px, 1fr) 52px; min-width: 907px; } +.playlist-header { position: sticky; top: 0; z-index: 2; height: 29px; background: #efefef; border-bottom: 1px solid #999; } +.playlist-header > div { display: flex; align-items: center; justify-content: center; border-right: 1px solid #aaa; } +.playlist-data-row { min-height: 25px; background: #fff; border-bottom: 1px solid #ddd; } +.playlist-data-row > div { padding: 3px 6px; border-right: 1px solid #ddd; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.playlist-data-row .enabled-cell { text-align: center; } + +.playout-actions { display: flex; align-items: center; justify-content: space-between; gap: 18px; } +.playout-actions button { height: 51px; background: #fff; font-size: 25px; font-weight: 700; } +.take-in { width: 215px; } +.next { width: 180px; } +.take-out { width: 212px; } +.playout-status { border-top: 1px solid #bbb; display: flex; align-items: center; justify-content: center; color: #666; } + +.dialog-backdrop { position: fixed; inset: 0; z-index: 20; background: rgba(0, 0, 0, .25); } +.dialog-backdrop[hidden] { display: none; } +.legacy-dialog { position: absolute; left: 50%; top: 50%; width: 320px; padding: 24px; transform: translate(-50%, -50%); border: 1px solid #777; background: #fff; box-shadow: 0 8px 30px rgba(0, 0, 0, .3); text-align: center; } +.legacy-dialog button { min-width: 80px; margin-top: 24px; padding: 6px 15px; } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/app.manifest b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/app.manifest new file mode 100644 index 0000000..13b341c --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/app.manifest @@ -0,0 +1,14 @@ + + + + + + + + + + + PerMonitorV2 + + + diff --git a/tests/LegacyParityWeb/thin-client.test.cjs b/tests/LegacyParityWeb/thin-client.test.cjs new file mode 100644 index 0000000..1152ad2 --- /dev/null +++ b/tests/LegacyParityWeb/thin-client.test.cjs @@ -0,0 +1,70 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const repositoryRoot = path.resolve(__dirname, "..", ".."); +const webRoot = path.join(repositoryRoot, "src", "MBN_STOCK_WEBVIEW.LegacyParityApp", "Web"); +const app = fs.readFileSync(path.join(webRoot, "app.js"), "utf8"); +const markup = fs.readFileSync(path.join(webRoot, "index.html"), "utf8"); + +test("legacy shell exposes the original stock, cut, playlist and playout controls", () => { + for (const id of [ + "stock-search", + "stock-search-button", + "stock-results", + "cut-list", + "playlist-drop-zone", + "playlist-rows", + "legacy-dialog" + ]) { + assert.match(markup, new RegExp("id=\\\"" + id + "\\\"")); + } + assert.match(markup, /TAKE IN/); + assert.match(markup, /TAKE OUT/); + assert.match(markup, /NEXT/); + assert.match(markup, /class="connection-state"[^>]*>미연결<\/div>/); + assert.match(markup, /class="active" type="button" disabled>해외<\/button>/); + assert.match(markup, /type="button" disabled>정지<\/button>/); +}); + +test("web layer sends intent and does not own scene, DB identity or playlist state", () => { + assert.doesNotMatch(app, /(?:sceneCode|cutCode|stockCode|dataCode|builderKey)/); + assert.doesNotMatch(app, /(?:localStorage|sessionStorage|playlist\.push|fetch\s*\()/); + assert.doesNotMatch(app, /\b(?:5001|N5001|5006|5011|5086|8035|8061|8040|8046|8051|8056|8003|5037)\b/); +}); + +test("legacy double click is represented by pointer-down facts, not browser dblclick policy", () => { + assert.match(app, /cut-pointer-down/); + assert.match(app, /timestampMilliseconds/); + assert.match(app, /event\.ctrlKey/); + assert.match(app, /event\.shiftKey/); + assert.doesNotMatch(app, /dblclick/); +}); + +test("cut rows are reconciled in place so a state reply cannot cancel native drag", () => { + assert.match(app, /dataset\.physicalIndex/); + assert.match(app, /function createCutElement/); + assert.doesNotMatch(app, /cutList\.replaceChildren/); +}); + +test("state rendering preserves stock and playlist focus like native controls", () => { + assert.match(app, /dataset\.resultIndex/); + assert.match(app, /dataset\.rowId/); + assert.doesNotMatch(app, /stockResults\.replaceChildren/); + assert.doesNotMatch(app, /playlistRows\.replaceChildren/); +}); + +test("separator rows preserve the original drag gesture", () => { + assert.match(app, /element\.draggable = true/); + assert.doesNotMatch(app, /draggable = !row\.isSeparator/); +}); + +test("a C# busy snapshot freezes controls instead of silently accepting queued input", () => { + const styles = fs.readFileSync(path.join(webRoot, "styles.css"), "utf8"); + assert.match(app, /state\.isBusy === true/); + assert.match(app, /classList\.toggle\("busy"/); + assert.match(styles, /body\.busy \.operator-shell \{ pointer-events: none; \}/); +}); diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/GlobalUsings.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorControllerTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorControllerTests.cs new file mode 100644 index 0000000..ffe2ec6 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorControllerTests.cs @@ -0,0 +1,221 @@ +using MBN_STOCK_WEBVIEW.LegacyApplication; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests; + +public sealed class LegacyOperatorControllerTests +{ + [Fact] + public async Task EmptyButtonIsNoOpButEmptyEnterSearches() + { + var lookup = Lookup(); + var controller = new LegacyOperatorController(lookup); + + await controller.SearchStocksAsync(string.Empty, LegacySearchTrigger.Button); + Assert.Equal(0, lookup.CallCount); + + await controller.SearchStocksAsync(string.Empty, LegacySearchTrigger.Enter); + Assert.Equal(1, lookup.CallCount); + } + + [Fact] + public async Task ExactPointerDoubleClick_AppendsOneLegacyRow() + { + var controller = new LegacyOperatorController(Lookup()); + await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button); + controller.SelectStock(0); + + controller.CutPointerDown(1, false, false, 20, 45, 1_000); + var state = controller.CutPointerDown(1, false, false, 20, 45, 1_400); + + var row = Assert.Single(state.Playlist); + Assert.Equal("코스피", row.MarketText); + Assert.Equal("삼성전자", row.StockName); + Assert.Equal("1열판기본", row.GraphicType); + Assert.Equal("현재가", row.Subtype); + Assert.Equal("005930", row.StockCode); + Assert.Equal("5001", row.ToPlayoutEntry().CutCode); + } + + [Fact] + public async Task ExactPointerDoubleClick_AllowsZeroBasedWebTimestamp() + { + var controller = new LegacyOperatorController(Lookup()); + await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button); + controller.SelectStock(0); + + controller.CutPointerDown(1, false, false, 20, 45, 0); + var state = controller.CutPointerDown(1, false, false, 20, 45, 499); + + Assert.Single(state.Playlist); + } + + [Fact] + public async Task DifferentPointerPosition_DoesNotCountAsLegacyDoubleClick() + { + var controller = new LegacyOperatorController(Lookup()); + await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button); + controller.SelectStock(0); + + controller.CutPointerDown(1, false, false, 20, 45, 1_000); + var state = controller.CutPointerDown(1, false, false, 21, 45, 1_200); + + Assert.Empty(state.Playlist); + } + + [Fact] + public async Task NativeSelectionAndLegacyDoubleClickRowsStaySeparateAtShiftEdges() + { + var controller = new LegacyOperatorController(Lookup()); + await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button); + controller.SelectStock(0); + + var firstShift = controller.CutPointerDown(2, false, true, 20, 69, 1_000); + Assert.Equal( + [0, 1, 2], + firstShift.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex)); + + var noDoubleClick = controller.CutPointerDown(2, false, false, 20, 69, 1_400); + Assert.Empty(noDoubleClick.Playlist); + + var sameRowShift = controller.CutPointerDown(2, false, true, 21, 69, 2_000); + Assert.Equal( + [2], + sameRowShift.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex)); + + var stillNoDoubleClick = controller.CutPointerDown(2, false, false, 21, 69, 2_400); + Assert.Empty(stillNoDoubleClick.Playlist); + } + + [Fact] + public async Task ControlAndShiftSelection_DropsInPhysicalOrderAndKeepsDuplicates() + { + var controller = new LegacyOperatorController(Lookup()); + await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button); + controller.SelectStock(0); + controller.CutPointerDown(0, false, false, 1, 1, 1_000); + controller.CutPointerDown(2, true, false, 1, 47, 2_000); + controller.CutPointerDown(1, true, true, 1, 24, 3_000); + + var first = controller.DropSelectedCutsOnPlaylist(); + var second = controller.DropSelectedCutsOnPlaylist(); + + Assert.Equal(3, first.Playlist.Count); + Assert.Equal(6, second.Playlist.Count); + Assert.Equal( + ["예상체결가", "현재가", "시간외단일가"], + first.Playlist.Select(row => row.Subtype)); + } + + [Fact] + public async Task SameRowShiftStillDropsTheNativeListViewSelection() + { + var controller = new LegacyOperatorController(Lookup()); + await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button); + controller.SelectStock(0); + controller.CutPointerDown(1, false, false, 1, 24, 1_000); + controller.CutPointerDown(1, false, true, 2, 24, 2_000); + + var state = controller.DropSelectedCutsOnPlaylist(); + + Assert.Equal("현재가", Assert.Single(state.Playlist).Subtype); + } + + [Fact] + public async Task NxtOnlyAllowsCurrentAndShowsOriginalWarning() + { + var controller = new LegacyOperatorController(Lookup()); + await controller.SearchStocksAsync("넥스트", LegacySearchTrigger.Button); + controller.SelectStock(1); + controller.CutPointerDown(0, false, false, 1, 1, 1_000); + var warning = controller.DropSelectedCutsOnPlaylist(); + + Assert.Empty(warning.Playlist); + Assert.Equal("NXT는 제공하지 않습니다.", warning.Dialog?.Message); + + controller.DismissDialog(); + controller.CutPointerDown(1, false, false, 1, 24, 2_000); + var accepted = controller.DropSelectedCutsOnPlaylist(); + Assert.Equal("N5001", Assert.Single(accepted.Playlist).ToPlayoutEntry().CutCode); + } + + [Fact] + public async Task NxtMultiSelection_PausesAtEachOriginalModalBeforeContinuing() + { + var controller = new LegacyOperatorController(Lookup()); + await controller.SearchStocksAsync("넥스트", LegacySearchTrigger.Button); + controller.SelectStock(1); + controller.CutPointerDown(0, false, false, 1, 1, 1_000); + controller.CutPointerDown(1, true, false, 1, 24, 2_000); + controller.CutPointerDown(2, true, false, 1, 47, 3_000); + + var firstWarning = controller.DropSelectedCutsOnPlaylist(); + Assert.Empty(firstWarning.Playlist); + Assert.Equal("NXT는 제공하지 않습니다.", firstWarning.Dialog?.Message); + + var secondWarning = controller.DismissDialog(); + Assert.Equal("N5001", Assert.Single(secondWarning.Playlist).ToPlayoutEntry().CutCode); + Assert.Equal("NXT는 제공하지 않습니다.", secondWarning.Dialog?.Message); + + var finished = controller.DismissDialog(); + Assert.Single(finished.Playlist); + Assert.Null(finished.Dialog); + } + + [Fact] + public async Task NxtSeparatorStillShowsOriginalWarning() + { + var controller = new LegacyOperatorController(Lookup()); + await controller.SearchStocksAsync("넥스트", LegacySearchTrigger.Button); + controller.SelectStock(1); + controller.CutPointerDown(3, false, false, 1, 70, 1_000); + + var warning = controller.DropSelectedCutsOnPlaylist(); + + Assert.Empty(warning.Playlist); + Assert.Equal("NXT는 제공하지 않습니다.", warning.Dialog?.Message); + } + + [Fact] + public async Task NewSearchClearsStockSelectionButNotCutCatalogOrPlaylist() + { + var controller = new LegacyOperatorController(Lookup()); + await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button); + controller.SelectStock(0); + controller.CutPointerDown(1, false, false, 1, 24, 1_000); + controller.DropSelectedCutsOnPlaylist(); + + var state = await controller.SearchStocksAsync("다시", LegacySearchTrigger.Button); + + Assert.Null(state.SelectedStockIndex); + Assert.Equal(33, state.CutRows.Count); + Assert.Single(state.Playlist); + } + + private static FakeLookup Lookup() => new(new LegacyStockSearchData( + "query", + ["삼성전자", "넥스트(NXT)"], + [ + new LegacyStockIdentity(LegacyDomesticStockMarket.Kospi, "삼성전자", "005930"), + new LegacyStockIdentity(LegacyDomesticStockMarket.NxtKospi, "넥스트(NXT)", "A00001") + ])); + + private sealed class FakeLookup : ILegacyStockLookup + { + private readonly LegacyStockSearchData _result; + + public FakeLookup(LegacyStockSearchData result) + { + _result = result; + } + + public int CallCount { get; private set; } + + public Task SearchAsync( + string query, + CancellationToken cancellationToken = default) + { + CallCount++; + return Task.FromResult(_result); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyParityStockSearchServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyParityStockSearchServiceTests.cs new file mode 100644 index 0000000..84a70b9 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyParityStockSearchServiceTests.cs @@ -0,0 +1,115 @@ +using System.Data; +using MBN_STOCK_WEBVIEW.LegacyApplication; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests; + +public sealed class LegacyParityStockSearchServiceTests +{ + [Fact] + public async Task Search_PreservesProviderAndRowOrderWithoutLimitOrSort() + { + var executor = new RecordingExecutor(new Dictionary + { + ["LEGACY_PARITY_KOSPI_MASTER"] = Table(("중복", "000001")), + ["LEGACY_PARITY_KOSDAQ_MASTER"] = Table(("중복", "100001")), + ["LEGACY_PARITY_NXT_KOSPI_MASTER"] = Table(("중복(NXT)", "200001")), + ["LEGACY_PARITY_NXT_KOSDAQ_MASTER"] = Table(("중복", "300001")), + ["LEGACY_PARITY_KOSPI"] = Table(("가-2", "000002"), ("가-1", "000001")), + ["LEGACY_PARITY_KOSDAQ"] = Table(("나", "100001")), + ["LEGACY_PARITY_NXT_KOSPI"] = Table(("다(NXT)", "200001")), + ["LEGACY_PARITY_NXT_KOSDAQ"] = Table(("라(NXT)", "300001")) + }); + var service = new LegacyParityStockSearchService(executor); + + var result = await service.SearchAsync(" a%_ "); + + Assert.Equal(["가-2", "가-1", "나", "다(NXT)", "라(NXT)"], result.DisplayNames); + Assert.Equal("300001", result.ResolveLastByDisplayName("중복")?.StockCode); + Assert.Equal(LegacyDomesticStockMarket.NxtKosdaq, + result.ResolveLastByDisplayName("중복")?.Market); + Assert.Equal(8, executor.Calls.Count); + + foreach (var call in executor.Calls.Skip(4)) + { + Assert.DoesNotContain("ORDER BY", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("LIMIT", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("ROWNUM", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Single(call.Spec.Parameters); + Assert.Equal(" A%_ ", call.Spec.Parameters[0].Value); + } + } + + [Fact] + public async Task EmptySearch_IsAllowedAndMasterSnapshotIsCached() + { + var tables = new Dictionary(); + foreach (var name in new[] + { + "LEGACY_PARITY_KOSPI", "LEGACY_PARITY_KOSDAQ", + "LEGACY_PARITY_NXT_KOSPI", "LEGACY_PARITY_NXT_KOSDAQ" + }) + { + tables[name] = Table(); + tables[$"{name}_MASTER"] = Table(); + } + + var executor = new RecordingExecutor(tables); + var service = new LegacyParityStockSearchService(executor); + + await service.SearchAsync(string.Empty); + await service.SearchAsync(string.Empty); + + Assert.Equal(12, executor.Calls.Count); + Assert.Equal(4, executor.Calls.Count(call => call.TableName.EndsWith("_MASTER", StringComparison.Ordinal))); + Assert.All(executor.Calls.Where(call => !call.TableName.EndsWith("_MASTER", StringComparison.Ordinal)), + call => Assert.Equal(string.Empty, call.Spec.Parameters[0].Value)); + } + + private static DataTable Table(params (string Name, string Code)[] rows) + { + var table = new DataTable(); + table.Columns.Add("STOCK_NAME", typeof(string)); + table.Columns.Add("STOCK_CODE", typeof(string)); + foreach (var row in rows) + { + table.Rows.Add(row.Name, row.Code); + } + + return table; + } + + private sealed class RecordingExecutor : IDataQueryExecutor + { + private readonly IReadOnlyDictionary _tables; + + public RecordingExecutor(IReadOnlyDictionary tables) + { + _tables = tables; + } + + public List Calls { get; } = []; + + public DataTable Execute(DataSourceKind source, string tableName, string query) => + throw new NotSupportedException(); + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + string query, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task ExecuteAsync( + DataSourceKind source, + string tableName, + DataQuerySpec query, + CancellationToken cancellationToken = default) + { + Calls.Add(new Call(source, tableName, query)); + return Task.FromResult(_tables[tableName].Copy()); + } + } + + private sealed record Call(DataSourceKind Source, string TableName, DataQuerySpec Spec); +} diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyStockCutCatalogTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyStockCutCatalogTests.cs new file mode 100644 index 0000000..4e8f115 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyStockCutCatalogTests.cs @@ -0,0 +1,65 @@ +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MBN_STOCK_WEBVIEW.LegacyApplication; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests; + +public sealed class LegacyStockCutCatalogTests +{ + [Fact] + public void Rows_MatchRuntimeAssetPhysicalLayout() + { + Assert.Equal(33, LegacyStockCutCatalog.Rows.Count); + Assert.Equal(31, LegacyStockCutCatalog.Rows.Count(row => !row.IsSeparator)); + Assert.Equal([3, 8], LegacyStockCutCatalog.Rows + .Where(row => row.IsSeparator) + .Select(row => row.PhysicalIndex)); + Assert.Equal("1열판기본_시간외단일가", LegacyStockCutCatalog.Rows[2].RawLabel); + Assert.Equal(31, LegacyStockCutCatalog.Rows[^1].DisplayOrdinal); + Assert.Equal( + "45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20", + LegacyStockCutCatalog.RuntimeAssetSha256); + } + + [Fact] + public void EveryActiveCut_ResolvesToRegisteredAlias() + { + foreach (var row in LegacyStockCutCatalog.Rows.Where(row => !row.IsSeparator)) + { + var alias = LegacyStockCutCatalog.ResolveCutAlias(row.RawLabel, isNxt: false); + Assert.NotEmpty(LegacySceneCatalog.FindByCutAlias(alias)); + } + } + + [Theory] + [InlineData("1열판기본_현재가", "N5001")] + public void NxtCurrent_UsesN5001(string label, string expected) + { + Assert.Equal(expected, LegacyStockCutCatalog.ResolveCutAlias(label, isNxt: true)); + } + + [Fact] + public void PlaylistRow_PreservesLegacySevenFieldsAndDefersAliasResolution() + { + var stock = new LegacyStockIdentity( + LegacyDomesticStockMarket.Kospi, + "삼성전자", + "005930"); + var row = LegacyStockCutCatalog.CreatePlaylistRow( + "row-1", + stock.DisplayName, + stock, + LegacyStockCutCatalog.Rows[1]); + + Assert.True(row.IsEnabled); + Assert.Equal("코스피", row.MarketText); + Assert.Equal("삼성전자", row.StockName); + Assert.Equal("1열판기본", row.GraphicType); + Assert.Equal("현재가", row.Subtype); + Assert.Equal("1/1", row.PageText); + Assert.Equal("005930", row.StockCode); + + var playout = row.ToPlayoutEntry(); + Assert.Equal("5001", playout.CutCode); + Assert.Equal("005930", playout.Selection?.DataCode); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests.csproj b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests.csproj new file mode 100644 index 0000000..1da7e34 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + false + true + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/GlobalUsings.cs b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyBridgeProtocolTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyBridgeProtocolTests.cs new file mode 100644 index 0000000..55469c4 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyBridgeProtocolTests.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using MBN_STOCK_WEBVIEW.LegacyApplication; +using MBN_STOCK_WEBVIEW.LegacyBridge; + +namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests; + +public sealed class LegacyBridgeProtocolTests +{ + [Fact] + public void ParseSearch_PreservesBlankEnterAndRejectsExtraAuthority() + { + Assert.True(LegacyBridgeProtocol.TryParseIntent( + "{\"type\":\"search-stocks\",\"payload\":{\"text\":\"\",\"trigger\":\"enter\"}}", + out var parsed, + out var error)); + Assert.Null(error); + var search = Assert.IsType(parsed); + Assert.Equal(string.Empty, search.Text); + Assert.Equal(LegacySearchTrigger.Enter, search.Trigger); + + Assert.False(LegacyBridgeProtocol.TryParseIntent( + "{\"type\":\"search-stocks\",\"payload\":{\"text\":\"삼성\",\"trigger\":\"button\",\"sceneCode\":\"5001\"}}", + out _, + out _)); + } + + [Fact] + public void ParsePointer_RequiresExactBoundedInput() + { + const string json = + "{\"type\":\"cut-pointer-down\",\"payload\":{\"physicalIndex\":1,\"control\":false,\"shift\":true,\"x\":20,\"y\":45,\"timestampMilliseconds\":1400}}"; + + Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out _)); + var pointer = Assert.IsType(parsed); + Assert.Equal(1, pointer.PhysicalIndex); + Assert.True(pointer.Shift); + Assert.Equal(1_400, pointer.TimestampMilliseconds); + } + + [Theory] + [InlineData("https://legacy-parity.mbn.local/index.html", true)] + [InlineData("https://legacy-parity.mbn.local.evil/index.html", false)] + [InlineData("http://legacy-parity.mbn.local/index.html", false)] + [InlineData("file:///C:/index.html", false)] + public void TrustedSource_IsExactHttpsOrigin(string source, bool expected) + { + Assert.Equal(expected, LegacyBridgeProtocol.IsTrustedSource(source)); + } + + [Fact] + public void SerializedState_DoesNotExposeHiddenDbOrSceneAuthority() + { + var playlistRow = LegacyStockCutCatalog.CreatePlaylistRow( + "legacy-stock-00000001", + "삼성전자", + new LegacyStockIdentity(LegacyDomesticStockMarket.Kospi, "삼성전자", "005930"), + LegacyStockCutCatalog.Rows[1]); + var snapshot = new LegacyOperatorSnapshot( + 1, + "삼성", + [new LegacyStockResultRow(0, "삼성전자")], + 0, + [new LegacyCutViewRow(1, 2, "1열판기본_현재가", false, true)], + [playlistRow], + null, + string.Empty, + LegacyOperatorStatusKind.Neutral); + + var json = LegacyBridgeProtocol.SerializeState(snapshot, isBusy: true); + + Assert.DoesNotContain("005930", json, StringComparison.Ordinal); + Assert.DoesNotContain("5001", json, StringComparison.Ordinal); + Assert.DoesNotContain("rawCutLabel", json, StringComparison.Ordinal); + + using var document = JsonDocument.Parse(json); + var payload = document.RootElement.GetProperty("payload"); + Assert.Equal( + "삼성전자", + payload.GetProperty("searchResults")[0].GetProperty("displayName").GetString()); + Assert.Equal( + "삼성전자", + payload.GetProperty("playlist")[0].GetProperty("stockName").GetString()); + Assert.Equal( + "1열판기본_현재가", + payload.GetProperty("cutRows")[0].GetProperty("rawLabel").GetString()); + Assert.True(payload.GetProperty("isBusy").GetBoolean()); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests.csproj b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests.csproj new file mode 100644 index 0000000..bc8a951 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + false + true + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/GlobalUsings.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWebContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWebContractTests.cs new file mode 100644 index 0000000..4ab5192 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWebContractTests.cs @@ -0,0 +1,122 @@ +using System.Text.RegularExpressions; + +namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests; + +public sealed class LegacyWebContractTests +{ + private static readonly string RepositoryRoot = FindRepositoryRoot(); + private static readonly string WebRoot = Path.Combine( + RepositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", + "Web"); + private static readonly string App = File.ReadAllText(Path.Combine(WebRoot, "app.js")); + private static readonly string Markup = File.ReadAllText(Path.Combine(WebRoot, "index.html")); + private static readonly string Styles = File.ReadAllText(Path.Combine(WebRoot, "styles.css")); + + [Fact] + public void ShellExposesOriginalStockCutPlaylistAndPlayoutControls() + { + foreach (var id in new[] + { + "stock-search", + "stock-search-button", + "stock-results", + "cut-list", + "playlist-drop-zone", + "playlist-rows", + "legacy-dialog" + }) + { + Assert.Contains($"id=\"{id}\"", Markup, StringComparison.Ordinal); + } + + Assert.Contains("TAKE IN", Markup, StringComparison.Ordinal); + Assert.Contains("TAKE OUT", Markup, StringComparison.Ordinal); + Assert.Contains("NEXT", Markup, StringComparison.Ordinal); + Assert.Matches("class=\"connection-state\"[^>]*>미연결", Markup); + Assert.Contains( + "class=\"active\" type=\"button\" disabled>해외", + Markup, + StringComparison.Ordinal); + Assert.Contains( + "type=\"button\" disabled>정지", + Markup, + StringComparison.Ordinal); + } + + [Fact] + public void WebLayerDoesNotOwnSceneDatabaseIdentityOrPlaylistState() + { + Assert.DoesNotMatch( + new Regex("(?:sceneCode|cutCode|stockCode|dataCode|builderKey)"), + App); + Assert.DoesNotMatch( + new Regex("(?:localStorage|sessionStorage|playlist\\.push|fetch\\s*\\()"), + App); + Assert.DoesNotMatch( + new Regex("\\b(?:5001|N5001|5006|5011|5086|8035|8061|8040|8046|8051|8056|8003|5037)\\b"), + App); + } + + [Fact] + public void DoubleClickUsesPointerFactsInsteadOfBrowserPolicy() + { + Assert.Contains("cut-pointer-down", App, StringComparison.Ordinal); + Assert.Contains("timestampMilliseconds", App, StringComparison.Ordinal); + Assert.Contains("event.ctrlKey", App, StringComparison.Ordinal); + Assert.Contains("event.shiftKey", App, StringComparison.Ordinal); + Assert.DoesNotContain("dblclick", App, StringComparison.Ordinal); + } + + [Fact] + public void CutRowsAreReconciledInPlace() + { + Assert.Contains("dataset.physicalIndex", App, StringComparison.Ordinal); + Assert.Contains("function createCutElement", App, StringComparison.Ordinal); + Assert.DoesNotContain("cutList.replaceChildren", App, StringComparison.Ordinal); + } + + [Fact] + public void StockAndPlaylistRowsPreserveNativeControlFocus() + { + Assert.Contains("dataset.resultIndex", App, StringComparison.Ordinal); + Assert.Contains("dataset.rowId", App, StringComparison.Ordinal); + Assert.DoesNotContain("stockResults.replaceChildren", App, StringComparison.Ordinal); + Assert.DoesNotContain("playlistRows.replaceChildren", App, StringComparison.Ordinal); + } + + [Fact] + public void SeparatorRowsPreserveOriginalDragGesture() + { + Assert.Contains("element.draggable = true", App, StringComparison.Ordinal); + Assert.DoesNotContain("draggable = !row.isSeparator", App, StringComparison.Ordinal); + } + + [Fact] + public void BusySnapshotFreezesControlsWithoutQueueingInput() + { + Assert.Contains("state.isBusy === true", App, StringComparison.Ordinal); + Assert.Contains("classList.toggle(\"busy\"", App, StringComparison.Ordinal); + Assert.Contains( + "body.busy .operator-shell { pointer-events: none; }", + Styles, + StringComparison.Ordinal); + } + + private static string FindRepositoryRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "MBN_STOCK_WEBVIEW.sln"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("The repository root could not be located."); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests.csproj b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests.csproj new file mode 100644 index 0000000..a6ea8e0 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + false + true + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + +