diff --git a/.gitignore b/.gitignore index 3603ce9..6841b6a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,8 @@ Config/database.local.json **/database.local.json Config/playout.local.json **/playout.local.json +Config/playout.development-live.local.json +**/playout.development-live.local.json Config/playout*.local.json **/playout*.local.json *.local.ini diff --git a/Config/playout.development-live.example.json b/Config/playout.development-live.example.json new file mode 100644 index 0000000..ea6c15b --- /dev/null +++ b/Config/playout.development-live.example.json @@ -0,0 +1,7 @@ +{ + "schemaVersion": 1, + "mode": "Live", + "authorization": "I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH", + "nativeSha256": "<64-HEX-SHA256-FROM-AN-INDEPENDENTLY-APPROVED-NATIVE-DLL>", + "interopSha256": "<64-HEX-SHA256-FROM-AN-INDEPENDENTLY-APPROVED-INTEROP-DLL>" +} diff --git a/MBN_STOCK_WEBVIEW.sln b/MBN_STOCK_WEBVIEW.sln index 6c72f10..07e8a9f 100644 --- a/MBN_STOCK_WEBVIEW.sln +++ b/MBN_STOCK_WEBVIEW.sln @@ -52,10 +52,8 @@ Global GlobalSection(ProjectConfigurationPlatforms) = postSolution {E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|x64.ActiveCfg = Debug|x64 {E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|x64.Build.0 = Debug|x64 - {E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|x64.Deploy.0 = Debug|x64 {E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|x64.ActiveCfg = Release|x64 {E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|x64.Build.0 = Release|x64 - {E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|x64.Deploy.0 = Release|x64 {CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|x64.ActiveCfg = Debug|Any CPU {CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|x64.Build.0 = Debug|Any CPU {CCFB7CB0-F898-444F-A16B-E28554A2698A}.Release|x64.ActiveCfg = Release|Any CPU @@ -142,12 +140,14 @@ Global {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}.Debug|x64.Deploy.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 + {0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.Release|x64.Deploy.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 diff --git a/MBN_STOCK_WEBVIEW.slnLaunch b/MBN_STOCK_WEBVIEW.slnLaunch new file mode 100644 index 0000000..9557c3a --- /dev/null +++ b/MBN_STOCK_WEBVIEW.slnLaunch @@ -0,0 +1,11 @@ +[ + { + "Name": "Legacy Parity App (VS F5)", + "Projects": [ + { + "Path": "src\\MBN_STOCK_WEBVIEW.LegacyParityApp\\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj", + "Action": "Start" + } + ] + } +] diff --git a/docs/DEVELOPMENT_LIVE_VISUAL_STUDIO.md b/docs/DEVELOPMENT_LIVE_VISUAL_STUDIO.md new file mode 100644 index 0000000..6926b5f --- /dev/null +++ b/docs/DEVELOPMENT_LIVE_VISUAL_STUDIO.md @@ -0,0 +1,130 @@ +# Visual Studio 2026 개발 Tornado Live 실행 + +## 목적과 안전 기본 + +이 문서는 개발 PC의 Visual Studio 2026에서 `MBN_STOCK_WEBVIEW.LegacyParityApp`을 +명확한 시작 대상으로 실행하고, 개발 Tornado2에 연결하는 방법을 설명한다. + +저장소의 송출 기본값과 Release 동작은 계속 `DryRun`이다. 개발 Live는 다음 세 조건이 +동시에 맞는 **Debug 프로세스 한 회차**에서만 기존 Live 게이트를 설정한다. + +1. 시작 인자가 정확히 `--development-live` 하나이다. +2. 컴파일된 앱이 Debug 빌드이다. +3. 아래 Git 제외 로컬 승인 파일이 존재하며 엄격한 스키마 검사를 통과한다. + +조건이 하나라도 다르면 환경 변수를 설정하지 않는다. 로컬 파일이 없거나 읽기 권한이 +없고, JSON 또는 해시 형식이 잘못된 경우에도 시작은 fail-closed로 진행되어 기존 +`playout.local.json`의 안전 기본인 `DryRun`을 사용한다. + +## Visual Studio 시작 대상과 프로필 + +공유 솔루션 실행 프로필은 저장소 루트의 +`MBN_STOCK_WEBVIEW.slnLaunch`에 있으며 다음 프로젝트만 시작한다. + +```text +src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj +``` + +`Debug|x64`와 `Release|x64`의 MSIX Deploy 대상도 루트 프로토타입이 아니라 이 프로젝트이다. Visual +Studio의 시작 드롭다운에는 다음 두 프로필이 표시된다. + +| 순서 | 프로필 | 동작 | +|---:|---|---| +| 1 | `MBN_STOCK_WEBVIEW.LegacyParityApp - Development Live (Package)` | `--development-live`를 전달한다. 유효한 로컬 승인 파일이 있을 때만 Live를 구성한다. | +| 2 | `MBN_STOCK_WEBVIEW.LegacyParityApp - Explicit DryRun (Package)` | 개발 Live 인자를 전달하지 않는다. | + +개발 PC에서 Live를 사용할 때는 공유 실행 프로필 `Legacy Parity App (VS F5)`, 구성 +`Debug`, 플랫폼 `x64`, 첫 번째 Development Live 패키지 프로필을 선택하고 F5를 누른다. +DryRun 확인은 두 번째 프로필을 선택한다. 앱은 단일 인스턴스이므로 프로필을 바꿀 때 +기존 인스턴스를 정상 종료한 뒤 다시 실행해야 한다. + +패키지된 WinUI 3 full-trust 실행에서는 Visual Studio가 프로세스 명령줄에 인자를 전달해도 +`LaunchActivatedEventArgs.Arguments`가 빈 문자열일 수 있다. 부트스트랩은 activation 인자가 +비어 있을 때에만 `Environment.GetCommandLineArgs()`가 정확히 `exe + 인자 1개`인 경우 그 +하나를 사용한다. 인자가 없거나 둘 이상이면 합치거나 일부를 무시하지 않고 Live 요청을 +거부한다. Release 빌드는 이 fallback으로 정확한 인자를 얻더라도 Debug-only gate에서 +승인 파일을 읽지 않고 기존 `DryRun` 설정을 유지한다. + +## 로컬 승인 파일 + +실제 파일 경로는 다음과 같다. + +```text +%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\playout.development-live.local.json +``` + +실제 파일명은 `.gitignore`에 포함되어 있다. 저장소의 +`Config\playout.development-live.example.json`을 구조 참고용으로 사용하되, 예제의 +해시 placeholder는 실행 가능한 값이 아니다. 실제 승인 파일은 정확히 다음 다섯 속성만 +가진다. + +```json +{ + "schemaVersion": 1, + "mode": "Live", + "authorization": "I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH", + "nativeSha256": "<독립적으로 승인한 x64 K3D native DLL의 64자리 SHA-256>", + "interopSha256": "<독립적으로 승인한 x64 K3D Interop DLL의 64자리 SHA-256>" +} +``` + +검사 규칙은 다음과 같다. + +- 파일 크기는 1~4096바이트이다. +- 파일과 `%LOCALAPPDATA%` 아래의 중간 디렉터리는 reparse point가 아니다. +- JSON 주석, trailing comma, 중복 속성, 알 수 없는 속성, 대소문자가 다른 속성을 + 허용하지 않는다. +- `schemaVersion`은 숫자 `1`, `mode`는 정확히 `Live`, `authorization`은 위 문자열과 + 정확히 일치해야 한다. +- 두 해시는 공백 없는 64자리 16진수여야 한다. 경로나 DLL은 Git에 기록하지 않는다. +- 파일 없음, 잘못된 경로, 공유 위반, I/O 또는 읽기 권한 오류는 모두 같은 제한된 + `authorization-unavailable` 결과로 처리하며 경로와 값은 로그에 표시하지 않는다. + +현재 설치 파일의 해시를 그 자리에서 계산했다는 이유만으로 승인 값으로 사용해서는 안 +된다. 벤더 배포본 또는 기존에 독립 검수된 증적과 대조한 값을 사용한다. 파일 ACL은 현재 +개발 사용자와 관리자만 수정할 수 있도록 유지한다. + +## 기존 playout.local.json + +기존 설정 파일 경로는 그대로이다. + +```text +%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\playout.local.json +``` + +이 파일의 `mode`는 `DryRun`으로 유지한다. Development Live 부트스트랩이 성공한 해당 +Debug 프로세스에서만 `MBN_STOCK_PLAYOUT_MODE=Live`를 process scope로 덮어쓴다. +기존 이중 게이트를 유지하기 위해 로컬 설정에는 `trustedLiveOutputEnabled: true`가 +명시되어야 한다. + +실제 cue의 `PlayoutCue.SceneName`은 builder의 대표 SceneCode가 아니라 활성 cut alias다. +전체 이관 UI를 개발 PGM에서 검증할 때 `testSceneAllowlist`에는 +`LegacySceneRuntimeCoverage.ExpectedCutCodes`와 동일한 다음 45개 alias를 사용한다. + +```text +5001, 5006, 5011, 5016, 50160, 5023, 5024, 5025, 5026, 5029, +5032, 5037, 5068, 5070, 5072, 5074, 5076, 5077, 5078, 5079, +5080, 5081, 5082, 5083, 5084, 5085, 5086, 50860, 5087, 5088, +6001, 6067, 8001, 8002, 8003, 8018, 8032, 8035, 8040, 8046, +8051, 8056, 8061, 8067, N5001 +``` + +`8010`과 `8086`은 활성 cue alias가 아니므로 넣지 않는다. 승인 Cuts에 종속 asset이 없는 +장면은 allowlist에 있더라도 기존 asset 검증에서 계속 차단된다. + +## 프로세스 범위 적용 순서 + +엄격한 파일 검사가 끝난 뒤 다음 값을 process scope에만 적용한다. + +1. 승인된 native SHA-256 pin +2. 승인된 Interop SHA-256 pin +3. `MBN_STOCK_PLAYOUT_MODE=Live` +4. Live authorization — 항상 마지막에 설정 + +중간 환경 쓰기가 실패하면 기존 값을 복구하고 Live authorization을 새로 설정하지 않는다. +`EnvironmentLiveAuthorization`은 `MainWindow`와 `IPlayoutEngine`을 만들 때 값을 한 번만 +캡처하므로, 부트스트랩은 반드시 `new MainWindow()`보다 먼저 실행된다. + +Release 빌드에서는 `--development-live`가 전달되어도 로컬 승인 파일을 읽지 않고 환경을 +변경하지 않는다. 이 개발 편의 경로는 기존 운영 Live 승인 경로나 안전 게이트를 대체하지 +않는다. diff --git a/docs/LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md b/docs/LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md new file mode 100644 index 0000000..3dcb43c --- /dev/null +++ b/docs/LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md @@ -0,0 +1,212 @@ +# 원본 WinForms Designer 이벤트 동등성 감사 + +감사 기준일은 2026-07-15이다. 원본 `C:\Users\MD\source\repos\MBN_STOCK_N`의 커밋 +`309286309eaf20b58172247263f575753a860700`을 읽기 전용으로 조사했다. 대상은 실제로 +Designer가 구독하는 이벤트이며, 선언만 있고 구독되지 않은 메서드는 수에 넣지 않았다. + +Designer 구독은 186개이고, 같은 핸들러를 여러 컨트롤에 연결한 구독을 화면별로 합치면 +171개다. 이 문서의 상세 목록은 그 171개를 한 번씩 모두 분류한다. + +## 분류 기준 + +- `IMPL`: 원본의 활성 업무 동작이 C# 소유 상태/워크플로, 엄격한 WebView 브리지 intent, + 또는 네이티브 playout 경로로 이관됐다. +- `DEAD`: 원본 메서드가 비어 있거나 전부 주석이거나 즉시 반환한다. 새 명령을 만들지 않는 + 것이 원본과 같다. +- `VISUAL`: WinForms 전용 paint/draw/focus/resize 동작이며 DOM, CSS, 브라우저 기본 동작으로 + 대체됐다. +- `DRAG`: 원본 drag/mouse 순서를 Web drag-and-drop과 C# 소유 순서 변경으로 이관한 묶음이다. +- `SAFETY`: 원본과 의도적으로 다르다. 기존 송출 안전 정책 때문에 자동 `StopAll`을 넣지 않는다. + +## 집계 + +| 화면 | Designer 구독 | 고유 핸들러 | IMPL | DEAD | VISUAL | DRAG | SAFETY | +|---|---:|---:|---:|---:|---:|---:|---:| +| UC1 | 9 | 9 | 3 | 2 | 4 | 0 | 0 | +| UC2 | 4 | 4 | 3 | 1 | 0 | 0 | 0 | +| UC3 | 18 | 18 | 14 | 4 | 0 | 0 | 0 | +| UC4 | 15 | 15 | 13 | 2 | 0 | 0 | 0 | +| UC5 | 20 | 9 | 6 | 3 | 0 | 0 | 0 | +| UC6 | 13 | 13 | 12 | 1 | 0 | 0 | 0 | +| UC7 | 7 | 6 | 5 | 1 | 0 | 0 | 0 | +| AList | 3 | 3 | 3 | 0 | 0 | 0 | 0 | +| EList | 3 | 3 | 3 | 0 | 0 | 0 | 0 | +| FSell | 3 | 3 | 3 | 0 | 0 | 0 | 0 | +| GraphE | 15 | 15 | 14 | 1 | 0 | 0 | 0 | +| PList | 7 | 7 | 6 | 1 | 0 | 0 | 0 | +| ThemeA | 13 | 13 | 12 | 1 | 0 | 0 | 0 | +| VIList | 10 | 10 | 10 | 0 | 0 | 0 | 0 | +| MainForm | 46 | 43 | 24 | 3 | 6 | 9 | 1 | +| **합계** | **186** | **171** | **131** | **20** | **10** | **9** | **1** | + +UC5는 20개 구독이 9개 핸들러를, UC7은 7개 구독이 6개 핸들러를, MainForm은 +46개 구독이 43개 핸들러를 공유한다. 따라서 구독 수와 고유 핸들러 수가 다르다. + +## 171개 핸들러 상세 증거 + +`@숫자`는 각 원본 `*.Designer.cs`의 이벤트 구독 줄이다. + +### UC1 — `Control/UC1.Designer.cs` + +- `IMPL (3)`: `treeView_DoubleClick@51`, `chE_CheckedChanged@73`, `UC1_Load@84` +- `DEAD (2)`: `treeView_BeforeSelect@49`, `treeView_AfterSelect@50` +- `VISUAL (4)`: `treeView_BeforeCollapse@46`, `treeView_BeforeExpand@47`, + `treeView_DrawNode@48`, `treeView_MouseDown@52` + +### UC2 — `Control/UC2.Designer.cs` + +- `IMPL (3)`: `btnswap_Click@75`, `listbox_DoubleClick@106`, `UC2_Load@126` +- `DEAD (1)`: `listbox_SelectedIndexChanged@105` + +### UC3 — `Control/UC3.Designer.cs` + +- `IMPL (14)`: `btnswap_Click@154`, `txtSearch_KeyPress@220`, `listBox_DoubleClick@232`, + `btnsearch_Click@249`, `txtj_KeyPress@314`, `btnsearch2_Click@328`, + `FpSpread2_CellDoubleClick@396`, `btnAdd_Click@470`, `btn4_Click@531`, + `btn3_Click@545`, `btn2_Click@559`, `btn1_Click@573`, `listBox1_DoubleClick@720`, + `UC3_Load@741` +- `DEAD (4)`: `txtSearch_KeyDown@219`, `listBox_SelectedIndexChanged@231`, + `FpSpread2_CellClick_1@395`, `FpSpread_CellClick@641` + +### UC4 — `Control/UC4.Designer.cs` + +- `IMPL (13)`: `btn1_Click@288`, `txts_KeyDown@297`, `btn2_Click@311`, + `btn3_Click@325`, `btn4_Click@339`, `FpSpread1_CellClick@406`, + `FpSpread1_CellDoubleClick@407`, `btnpre_Click@483`, `btnnow_Click@497`, + `timer1_Tick@659`, `btnnow_6_Click@673`, `btnnow_12_Click@687`, `UC4_Load@752` +- `DEAD (2)`: `FpSpread1_DoubleClick@408`, `FpSpread2_CellClick@564` + +### UC5 — `Control/UC5.Designer.cs` + +- `IMPL (6)`: `btn1_Click@497`, `btnsearch2_Click@571`, `txtj_KeyPress@581`, + `btnsearch1_Click@747`, `txts_KeyPress@757`, `UC5_Load@1007` +- `DEAD (3)`: `txtj_TextChanged@580`, `txts_TextChanged@756`, `FpSpread_CellClick@939` + +### UC6 — `Control/UC6.Designer.cs` + +- `IMPL (12)`: `btlistadd_Click@263`, `txtSearch_KeyPress@284`, + `listBox_DoubleClick@295`, `btnsearch_Click@312`, `btnsave_Click@365`, + `btndel_Click@379`, `FpSpread1_CellClick@456`, `FpSpread2_CellClick@570`, + `FpSpread2_KeyDown@571`, `btnDelete_Click@648`, `btnAdd_Click@662`, `UC6_Load@673` +- `DEAD (1)`: `tabp6_1_Paint@331` +- 이벤트 구독과 별도로 원본 `FpSpread2.AllowDragDrop = true`가 + `Control/UC6.Designer.cs:519`에 있다. 이는 `DRAG` 경로의 전문가 추천행 재정렬로 이관한다. + +### UC7 — `Control/UC7.Designer.cs` + +- `IMPL (5)`: `btnsearch_Click@185`, `txts_KeyPress@194`, `btn4_Click@219`, + `FpSpread1_CellClick@301`, `UC7_Load@369` +- `DEAD (1)`: `groupBox3_Enter@161` + +### AList — `Form/AList.Designer.cs` + +- `IMPL (3)`: `btnok_Click@64`, `btncn_Click@88`, `AList_Load@124` + +### EList — `Form/EList.Designer.cs` + +- `IMPL (3)`: `btncn_Click@56`, `btnok_Click@82`, `EList_Load@137` + +### FSell — `Form/FSell.Designer.cs` + +- `IMPL (3)`: `btncn_Click@109`, `btnok_Click@125`, `FSell_Load@337` + +### GraphE — `Form/GraphE.Designer.cs` + +- `IMPL (14)`: `upSearch_Click@220`, `txt1_KeyPress@229`, `downSearch_Click@244`, + `FpSpread1_CellClick@312`, `FpSpread1_CellDoubleClick@313`, `btndel_Click@402`, + `btnall_Click@417`, `btnAdd_Click@432`, `txtSearch_KeyPress@453`, + `listBox_DoubleClick@464`, `btnsearch_Click@481`, `btadd_Click@531`, + `btnok_Click@1274`, `GraphE_Load@1318` +- `DEAD (1)`: `GraphE_KeyPress@1319` + +### PList — `Form/PList.Designer.cs` + +- `IMPL (6)`: `btnadd_Click@55`, `listBox_DoubleClick@79`, `btncn_Click@95`, + `button3_Click@111`, `button1_Click@127`, `PList_Load@139` +- `DEAD (1)`: `listBox_SelectedIndexChanged@78` + +### ThemeA — `Form/ThemeA.Designer.cs` + +- `IMPL (12)`: `btncancel_Click@246`, `btnok_Click@260`, `FpSpread_KeyDown@353`, + `btn4_Click@422`, `btn3_Click@436`, `btn2_Click@450`, `btn1_Click@464`, + `btnC_Click@479`, `listBox_DoubleClick@521`, `txtSearch_KeyPress@530`, + `btnsearch_Click@547`, `Theme_Load@569` +- `DEAD (1)`: `listBox_SelectedIndexChanged@520` + +### VIList — `Form/VIList.Designer.cs` + +- `IMPL (10)`: `txtSearch_KeyPress@188`, `btnok_Click@202`, `btn4_Click@391`, + `btn3_Click@405`, `btn2_Click@419`, `btn1_Click@433`, `listBox_DoubleClick@456`, + `btnsearch_Click@473`, `btncancel_Click@487`, `VILIST_Load@528` + +### MainForm — `MainForm.Designer.cs` + +- `IMPL (24)`: `tabControl1_SelectedIndexChanged@1062`, `btnPrepare_Click@1465`, + `PlayList_CellClick@1723`, `PlayList_KeyPress@1726`, `ckback_CheckedChanged@1818`, + `btnback_Click@1854`, `btntdelete_Click@1869`, `btndelete_Click@1884`, + `btnload_Click@1899`, `button5_Click@1914`, `btndown_Click@1930`, `btnup_Click@1946`, + `checkBox1_CheckedChanged@1986`, `btnTake_Click@2018`, `txtSearch_KeyPress@2047`, + `b1_Click@2108`, `button9_Click@2207`, `btnNext_Click@2410`, + `btnTakeout_Click@2428`, `timer1_Tick@2433`, `timer3_Tick@2443`, + `Form1_Load@2470`, `MainForm_KeyDown@2472`, `MainForm_KeyUp@2473` +- `DEAD (3)`: `PlayList_LeaveCell@1722`, `GridView1_RowLeave@2081`, + `FpSpread2_CellDoubleClick@2280` +- `VISUAL (6)`: `panel6_Paint@1426`, `GridView1_CellEnter@2080`, + `listBox_DrawItem@2189`, `timer2_Tick@2438`, `MainForm_Paint@2471`, + `MainForm_Resize@2474` +- `DRAG (9)`: `tab_DragOver@1063`, `tab_MouseDown@1064`, `tab_MouseMove@1065`, + `tab_MouseUp@1066`, `PlayList_DragDrop@1724`, `PlayList_DragOver@1725`, + `listView1_MouseDown@2349`, `listView1_MouseMove@2350`, `listView1_MouseUp@2351` +- `SAFETY (1)`: `MainForm_FormClosing@2469` + +## ThemeA `중복확인` 동등성 + +원본 `Form/ThemeA.cs:84-109`는 입력 이름과 현재 KRX/NXT 선택을 기준으로 정확히 같은 +테마명을 조회하고 `이미 사용중인 테마명입니다.` 또는 `사용 가능한 테마명입니다.`를 표시한다. +새 구현은 다음 경로를 사용한다. + +1. `Web/index.html`의 `operator-catalog-name-check`가 원본 `중복확인` 버튼이다. +2. Web은 이름만 담은 `check-operator-catalog-theme-name` intent를 보낸다. market, code, + table 이름 같은 DB 권한 정보는 보내지 못한다. +3. `LegacyBridgeProtocol`이 닫힌 payload와 128자 canonical text를 검증한다. +4. `LegacyOperatorController.CheckOperatorCatalogThemeNameAsync`가 + `LegacyOperatorCatalogWorkflowController.CheckThemeNameAvailabilityAsync`를 호출한다. +5. 워크플로는 C#이 소유한 현재 KRX/NXT 대상과 exact title을 + `IThemeCatalogPersistenceService.ThemeNameExistsAsync`에 전달한다. 구현은 선택한 provider의 + `SB_LIST`에 parameterized `COUNT(*)` SELECT를 정확히 한 번 실행하며 mutation executor를 + 호출하지 않는다. 128자 테마명도 잘림 없는 exact query로 처리한다. +6. 조회 결과를 확정할 수 없으면 `Indeterminate`로 표시하며 DB 쓰기를 하지 않는다. + +원본 쿼리는 edit 중인 현재 `SB_CODE`를 제외하지 않는다. 따라서 제목을 바꾸지 않은 기존 +테마에서 `중복확인`을 누르면 자기 행 때문에 `이미 사용중인 테마명입니다.`가 표시된다. 새 구현도 +고객의 원본 우선 요구에 따라 KRX/NXT 모두 이 동작을 그대로 보존한다. + +검증은 `LegacyThemeCatalogPersistenceServiceTests`, `LegacyOperatorCatalogWorkflowTests`, +`LegacyOperatorCatalogBridgeTests`, `LegacyOperatorCatalogParityWebTests`에 있다. +KRX와 NXT의 같은 이름을 서로 혼동하지 않는지, +create의 중복/사용 가능, edit의 자기 행 중복, 128자 parameter binding, 결과 불명 상태, +엄격한 payload, Web 버튼 및 native dispatch를 확인한다. + +## 의도적인 종료 안전 차이 + +원본 `MainForm.cs:751-780`은 종료 확인 뒤 `KAScenePlayer.StopAll()`, `KAScene.Unload()`, +500ms 대기, 현재 프로세스 강제 종료를 수행한다. 새 앱은 이를 그대로 이관하지 않는다. + +- `MainWindow.CloseConfirmation.cs:88-119`는 PREPARED/PROGRAM, pending callback, + `OutcomeUnknown`을 사용자에게 명시하고 종료 과정에서 TAKE OUT을 자동 실행하지 않는다. +- `MainWindow.Playout.cs:778-823`은 on-air 장면, pending callback, quarantine, + `OutcomeUnknown`이 없을 때만 bounded disconnect를 시도한다. +- 이 차이는 기존 안전 게이트의 `결과 불명 시 반복 금지`와 `명시적 송출 명령` 정책을 지키기 위한 + 활성 안전 요구사항이다. 동등성 작업에서 완화하거나 자동 `StopAll`로 바꾸지 않는다. + +## 기타 감사 메모 + +- 원본 `MainForm.Designer.cs:1399`에서 PREPARE가 들어 있는 `panel3.Visible = false`다. + 새 Web UI도 PREPARE를 일반 조작 화면에서는 숨기고 네이티브/승인된 검증 경로는 유지한다. +- 원본의 단일 인스턴스 기대는 `LegacyParityApp/App.xaml.cs`의 + `AppInstance.FindOrRegisterForKey`로 구현됐다. +- 원본 `timer3_Tick`의 Tornado 상태 감시는 `IPlayoutEngine` 뒤의 + `TornadoPlayoutEngine` monitor/reconnect 상태 기계로 격리돼 있다. +- 이벤트 이름이 여러 화면에서 같더라도 화면별 메서드는 별개로 셌다. 예를 들어 + `btn1_Click`, `btnsearch_Click`, `listBox_DoubleClick`은 전역 이름 하나가 아니라 각 화면의 + 서로 다른 바인딩이다. diff --git a/docs/LEGACY_FEATURE_AUDIT.md b/docs/LEGACY_FEATURE_AUDIT.md index 58ad9f4..bff0265 100644 --- a/docs/LEGACY_FEATURE_AUDIT.md +++ b/docs/LEGACY_FEATURE_AUDIT.md @@ -13,15 +13,19 @@ WebView2에 표시하는 x64 Windows 데스크톱/MSIX 앱이다. 초기 프로토타입 문서의 “미이관 버튼 disabled”, “Tornado2에 연결하지 않음”, “DB write -없음”은 현재 상태가 아니다. 원본 UI와 클릭 이벤트는 LegacyParityApp → strict bridge → -C# workflow로 연결됐고, 개발 DB write 9개 시나리오와 설치된 MSIX의 개발 PGM 최종 -통합까지 검증했다. 기본 송출 모드는 계속 `DryRun`이다. +없음”은 현재 구현 상태가 아니다. 원본 UI 이벤트는 LegacyParityApp → strict bridge → +C# workflow로 연결되고 개발 DB write 9개 시나리오의 기존 증적도 있다. 다만 현재 병렬 +변경이 합쳐진 signed Release MSIX 설치·DryRun 실행, 실제 마우스/CDP UI와 제한된 +HELLO/BYE 연결은 2026-07-15에 확인했다. scene/PREPARE/TAKE/PGM은 실행하지 않았으므로 +전체 방송 송출 성공으로 판정하지 않는다. 기본 송출 모드는 계속 `DryRun`이다. ## 상태 정의 | 상태 | 의미 | |---|---| -| `완료` | 현재 LegacyParityApp 코드와 해당 자동/통합 검증에 반영됨 | +| `완료` | 현재 LegacyParityApp 코드와 표에 명시한 자동/통합 검증에 반영됨. package/Live 여부는 각 행의 증적 범위로 별도 판정 | +| `부분 실측` | 현재 package 또는 Live의 표에 명시한 범위만 실제 확인했으며 scene/PGM 전체 통합으로 확대할 수 없음 | +| `현재 빌드 실측 제외` | 구현 경로는 있으나 현재 병렬 변경을 합친 package-context/CDP/PGM 결과를 아직 이 감사의 성공 증거로 채택하지 않음 | | `외부자산 필요` | 코드로 만들 수 없는 Git/MSIX 밖의 승인 영상·배경이 제공되지 않음 | | `외부 데이터 정합성` | 현재 DB view/data가 원본 규칙의 fresh identity를 공급하지 않아 추정 없이 차단함 | @@ -33,25 +37,61 @@ C# workflow로 연결됐고, 개발 DB write 9개 시나리오와 설치된 MSIX | LF-002 | P0 | cut/asset coverage | 완료 | active alias 45개 `.t2s` 존재. alias missing/unsafe 0, asset unsafe 0, asset missing 14를 분리 보고 | 누락 자산 자체는 LF-001/LF-003 | | LF-003 | P0 | `s5006` 큐브 배경 | 외부자산 필요 | `Video\큐브배경.vrv` 1개가 승인 Cuts에 없음. builder 누락이 아님 | 제공 후 s5006 DryRun/PGM | | LF-004 | P0 | 실제 개발 DB write | 완료 | GraphE 4 + PList/AList 2 + ThemeA KRX/NXT 2 + EList 1, 총 9개가 create/save→fresh readback→delete→absence PASS, 잔여 0 | 운영 DB write를 뜻하지 않음 | -| LF-005 | P0 | 개발 Tornado2/PGM 최종 통합 | 완료 | 설치된 최신 MSIX에서 `5001` PREPARE/TAKE IN/자동 갱신 → playlist NEXT `5074` 1/2 → Page NEXT 2/2 → TAKE OUT, Network Monitoring·PGM·BYE 확인 | 14개 외부 영상 자산 scene은 제공 후 별도 PGM | +| LF-005 | P0 | 개발 Tornado2/PGM 최종 통합 | 부분 실측 | 현재 signed Release 설치·DryRun 실행과 실제 UI를 확인. 별도 Live에서 HELLO request/success 각 1회와 BYE 1회만 확인했고 scene/PREPARE/TAKE/DB write는 0 | scene PREPARE/TAKE/PGM 전체 통합과 14개 외부 영상 자산 scene은 별도 | | LF-006 | P0 | 원본 35 scene | 완료 | 원본 inventory와 source 기준선 35/35, DTO→mutation→runtime matrix 통과 | 모든 scene의 실제 PGM 완료로 확대 해석 금지 | -| LF-007 | P0 | PageN/NEXT | 완료 | 5·6·12개 단위, 최대 20페이지, 빈/경계/마지막 부분 페이지 테스트와 개발 PGM에서 `5074` 9종목의 5+4 Page NEXT·다음 행 NEXT 확인 | 없음 | +| LF-007 | P0 | PageN/NEXT | 완료 | 5·6·12개 단위, 최대 20페이지, 빈/경계/마지막 부분 페이지와 같은 scene/다음 행 구분을 자동 테스트로 검증 | 현재 package/PGM 실측은 LF-005와 함께 판정 | | LF-008 | P0 | 원클릭 TAKE IN | 완료 | 보이는 TAKE IN/F8 한 번으로 fresh page load 후 PREPARE 1회→TAKE IN 1회. native PREPARE는 진단용으로 유지하되 hidden | unknown에서 재시도 금지 | | LF-009 | P0 | C# 권위/strict bridge | 완료 | Web은 intent만 전송. `LegacyBridgeProtocol` allowlist와 `LegacyOperatorController` snapshot이 DB identity·playlist·scene·송출 상태의 권위 | 새 intent에도 동일 경계 유지 | | LF-010 | P1 | 10개 탭과 action inventory | 완료 | 10탭, fixed 328, 업종 44, 종목 31, 비교 target 24/action 9를 폐쇄형으로 고정 | 동적 DB 행은 별도 검증 | | LF-011 | P1 | UC4/UC6/UC7 클릭 | 완료 | 테마·전문가·거래정지는 원본 단일 클릭. UC4/UC6 행은 fixed tree handler와 독립, stale generation 거부 | 없음 | | LF-012 | P1 | GraphE/FSell/VI | 완료 | GraphE CRUD·찾기·정렬·행 double-click, FSell/VI 확인 시 save+playlist append+close, 취소 경계 이관 | s5025/VI 전용 PGM은 LF-005 | | LF-013 | P1 | PList/AList | 완료 | 목록·생성·save/load/delete·행 double-click 이관, 개발 DB write/readback/cleanup PASS | 기존 blocked raw row는 추정 복원 금지 | -| LF-014 | P1 | ThemeA/EList | 완료 | 검색·신규·편집·삭제·종목 구성·순서·Delete key 이관, KRX/NXT/expert 개발 DB 시나리오 PASS | scene PGM은 LF-005 | +| LF-014 | P1 | ThemeA/EList | 완료 | 검색·신규·편집·삭제·종목 구성·Delete key, ThemeA exact-title 중복확인, EList opaque `rowId` drag 재정렬 이관. 실제 UI에서 ThemeA 500건 중복 read와 EList 5행 reorder 확인 | scene PGM은 LF-005 | | LF-015 | P1 | UC5 해외 | 완료 | 해외업종·해외종목·해외지수 조회와 playlist 경로 이관 | s6001 13개 영상은 LF-001 | | LF-016 | P1 | F2/F3 기본 배경 | 외부자산 필요 | sibling background root, picker, background-none와 trusted path 검사는 완료. 이 장비에 `기본.vrv`가 없음 | 제공 전 fail closed | -| LF-017 | P1 | 시스템 종료 확인 | 완료 | package-context에서 기본 취소 확인창의 중립 문구·종료/취소 두 버튼을 대조하고 종료 1회, BYE 1회, 자동 TAKE OUT/반대 명령 없음 확인 | 없음 | +| LF-017 | P1 | 시스템 종료 확인 | 완료 | 기본 취소 확인창, 중립 문구, 종료/취소 두 버튼과 자동 TAKE OUT/반대 명령 금지 경계를 코드·자동 테스트로 유지 | 현재 Release package-context 회귀는 LF-005와 함께 판정 | | LF-018 | P1 | 원본 비교쌍/수동 파일 | 완료 | `종목비교.dat` 8쌍 1회 import·재시작 marker, FSell 3×5행과 VI 9항목 trusted parser/store 검증 | 원본 파일은 계속 읽기 전용 | | LF-019 | P1 | 실제 DB read→DTO→mutation | 완료 | Oracle/MariaDB 필수 scene selector 33/33, 전체 query 58건 PASS | 외부 asset/PGM 성공과 별도 | -| LF-020 | P2 | FarPoint 대체 | 완료 | Web table로 바꾸되 선택·정렬·이동·single/double-click·저장 이벤트를 strict C# intent로 재현 | 활성 Print/Excel export는 원본에도 없음 | +| LF-020 | P2 | FarPoint 대체 | 완료 | 실제 마우스로 playlist 다중행 reorder·checkbox 차단·탭 release 전 dragover 교환·EList reorder 확인. row drop 버블링의 cut append와 Chromium checkbox-origin target 문제를 수정하고 DOM 회귀로 고정 | 활성 Print/Excel export는 원본에도 없음 | | LF-021 | P2 | 패키지 단일 인스턴스·모달 격리 | 완료 | 두 번째 activation 전달, 강제 종료 없음. modal에서 F2/F3/F8/Escape와 playlist key 격리 | 새 MSIX 전체 회귀 | | LF-022 | P2 | 영구 운영 로그/bridge 오류 | 완료 | bounded/redacted log와 toast, malformed payload가 송출 상태를 바꾸지 않음 | authoritative Network Monitoring과 함께 판독 | | LF-023 | P1 | NXT 저장 playlist restore | 외부 데이터 정합성 | stale code를 제목/current identity로 재검증하는 닫힌 경로는 완료. 현재 `v_all_stock` 결과가 비어 preview를 fail closed | DBA/data 정합성 없이는 direct-table 추정 금지 | +| LF-024 | P0 | Designer 이벤트 전수 감사 | 완료 | 원본 Designer 구독 **186개**, 화면별 고유 핸들러 **171개**를 IMPL 131/DEAD 20/VISUAL 10/DRAG 9/SAFETY 1로 전수 분류 | 상세 줄 단위 근거는 `LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md` | +| LF-025 | P0 | VS Development Live 시작 구성 | 완료 | `.slnLaunch`와 패키지 프로필이 LegacyParityApp을 시작 대상으로 고정. 실제 Release에서 `--development-live`가 무시돼 DryRun, TCP/Network 변경 0임을 확인 | Live는 exact argument + Debug + strict local authorization을 계속 요구 | + +## 이벤트·drag 감사 상세 + +[`LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md`](LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md)는 원본 +커밋의 `*.Designer.cs`를 읽기 전용으로 조사해 186개 구독과 171개 고유 핸들러를 줄 단위로 +대조한다. 선언만 있고 구독하지 않는 메서드는 제외하며, `DEAD`/`VISUAL`은 새 활성 업무 +intent를 만들지 않는 것이 원본과 동등하다. 종료의 자동 `StopAll`만 기존 안전 정책 때문에 +의도적으로 이관하지 않는 `SAFETY` 항목이다. + +현재 `DRAG` 구현은 다음 세 C# 권위 경로를 포함한다. + +- playlist 행 drag는 선택된 비연속 행도 현재 표시 순서를 보존한 한 블록으로 임의 + before/after 위치에 옮긴다. 체크박스에서 시작한 drag와 PREPARED/PROGRAM/pending/unknown + 상태의 변경은 차단한다. +- 시장 탭은 drop 때가 아니라 dragover 대상에 들어갈 때 교환한다. source/target identity와 + 예상 두 인덱스가 C# 현재 순서와 일치할 때만 적용해 중복 메시지가 순서를 되돌리지 않는다. +- 원본 UC6 `FpSpread2.AllowDragDrop`은 ThemeA/EList 편집행의 opaque `rowId` drag로 + 이관했다. Web은 play index나 DB identity를 결정하지 않는다. + +ThemeA의 원본 `중복확인`도 이름만 담은 strict intent로 연결했다. C#이 현재 KRX/NXT를 +결정하고 선택 provider의 `SB_LIST`에 exact title parameterized read를 한 번 수행한다. +결과 불명은 `Indeterminate`이며 write나 추정 fallback이 없다. 원본처럼 edit 자기 행을 +제외하지 않아 변경하지 않은 이름은 중복으로 판정된다. + +## Development Live strict local authorization + +[`DEVELOPMENT_LIVE_VISUAL_STUDIO.md`](DEVELOPMENT_LIVE_VISUAL_STUDIO.md)의 시작 구성은 +정확한 `--development-live` 인자와 Debug 빌드에서만 Git 제외 로컬 승인 파일을 읽는다. +승인 파일은 `%LOCALAPPDATA%` 아래에서 reparse가 아닌 경로, 최대 4096바이트, 정확한 다섯 +속성, canonical JSON, exact authorization 문자열과 독립 승인된 native/Interop SHA-256을 +모두 만족해야 한다. Live authorization은 환경 설정의 마지막 단계이며 일부 적용 실패 시 +기존 값을 복구한다. Release, 인자 불일치, 파일 없음/오류/알 수 없는 속성은 Live를 구성하지 +않는다. 기존 `trustedLiveOutputEnabled`, alias allowlist, asset 검사와 송출 안전 게이트는 +그대로 중첩된다. ## DB 검증 상세 @@ -133,17 +173,12 @@ redacted 형태로 보고한다. ## 최종 증적 기록 -확정된 증적과 최신 전체 회귀의 기록 위치는 -[`LEGACY_PARITY_VALIDATION_20260715.md`](LEGACY_PARITY_VALIDATION_20260715.md)다. - -다음 값은 추측하거나 이전 package 결과를 재사용하지 않는다. - -| 항목 | 상태 | -|---|---| -| Debug/Release x64 전체 테스트 개수 | **PASS** — .NET 각 2,394/2,394, Web 435/435 | -| 새 Release x64 MSIX SHA-256·서명 | `FC4EEE1509675E643D2B4CB435E279CAC4611B28534048D9B9EE611E361E0E1F`, Valid, `CN=Comtrophy` | -| 새 package-context 실행 | **PASS** — DryRun과 개발 Live, 정상 종료 | -| 개발 Tornado2 Network Monitoring/PGM 결과 | **PASS** — `5001` → `5074` 1/2 → 2/2 → TAKE OUT/BYE | -| Gitea 반영 commit | **PASS** — 최종 이관·검증 문서 포함 commit을 `origin/main`에 fast-forward push | +현재 구현 증적과 최종 검증 범위는 +[`LEGACY_PARITY_VALIDATION_20260715.md`](LEGACY_PARITY_VALIDATION_20260715.md)에 기록한다. +병렬 변경을 모두 합친 .NET Debug/Release x64는 각각 2,446/2,446, Web Node는 438/438이며 +Visual Studio 2026 Debug/Release x64 빌드는 경고 0·오류 0이다. 새 signed Release x64 MSIX의 +hash·서명·설치 실행, 실제 CDP UI와 제한된 HELLO/BYE 결과도 현재 값으로 기록했다. 단, +scene/PREPARE/TAKE/PGM을 실행하지 않은 현재 연결 회차를 전체 방송 송출 성공으로 확대하지 +않으며 과거 package hash나 PGM 로그를 현재 결과로 재사용하지 않는다. 원본 저장소는 최종 검증과 배포 이후에도 계속 읽기 전용으로 유지한다. diff --git a/docs/LEGACY_PARITY_VALIDATION_20260715.md b/docs/LEGACY_PARITY_VALIDATION_20260715.md index 19573e6..8800369 100644 --- a/docs/LEGACY_PARITY_VALIDATION_20260715.md +++ b/docs/LEGACY_PARITY_VALIDATION_20260715.md @@ -1,7 +1,10 @@ # LegacyParityApp 이관 검증 증적 — 2026-07-15 -이 문서는 현재 `MBN_STOCK_WEBVIEW.LegacyParityApp`의 구현·통합 최종 검증 결과를 -한곳에 모은다. 원본 +이 문서는 현재 `MBN_STOCK_WEBVIEW.LegacyParityApp`의 구현 증적과 지금 채택할 수 있는 +검증 경계를 한곳에 모은다. 2026-07-15의 signed Release MSIX 설치·실행, 실제 마우스/CDP +UI 동작과 제한된 Live 연결 실측은 아래 범위로 기록한다. 병렬 변경 전체의 최종 +Debug/Release 및 Web 회귀 수는 확정했지만, scene PREPARE/TAKE IN/PGM 출력은 이 회차의 +증적으로 확정하지 않는다. 원본 `C:\Users\MD\source\repos\MBN_STOCK_N`은 모든 조사와 테스트에서 읽기 전용으로 취급했다. @@ -15,23 +18,79 @@ | Web 경계 | `LegacyBridgeProtocol`의 strict JSON intent allowlist; Web은 DB identity, scene alias, playlist 또는 송출 상태를 확정하지 않음 | | 벤더 경계 | COM/K3D/Tornado 구현은 `IPlayoutEngine` 뒤에 격리하고 STA 큐와 안전 게이트 유지 | | 기본 송출 모드 | `DryRun`; Live는 별도 개발 설정, 프로세스별 승인, allowlist와 명령 예산 필요 | +| VS Development Live | 정확한 `--development-live` + Debug + Git 제외 strict 로컬 승인 파일이 모두 맞을 때만 process-scope Live 설정; Release와 오류 경로에서는 이 bootstrap이 Live를 새로 설정하지 않음 | | 불명확 결과 | timeout/`OutcomeUnknown`에서 자동 재시도·반대 명령·추측 cleanup 금지 | ## 원본 UI와 클릭 이벤트 | 범위 | 현재 증적 | |---|---| +| Designer 전수 감사 | 원본 Designer 구독 **186개**, 화면별 고유 핸들러 **171개**를 IMPL/DEAD/VISUAL/DRAG/SAFETY로 전수 분류 | | 업무 탭 | 원본 10개 탭, identity 기반 활성화와 순서 변경 | | 종목 영역 | 모든 탭에서 유지되는 종목 검색, 결과, 31개 컷과 playlist 추가 | | 고정/업종/비교 | fixed 328개, 업종 44개, 비교 대상 24개와 action 9개를 폐쇄형 inventory로 고정 | | 독립 클릭 | UC4 테마, UC6 전문가, UC7 거래정지의 원본 단일 클릭과 UC4/UC6 행별 독립 처리 | -| 전용 입력 | GraphE, FSell, VI, PList, AList, ThemeA, EList를 WebView 화면과 C# intent로 연결 | +| 전용 입력 | GraphE, FSell, VI, PList, AList, ThemeA, EList를 WebView 화면과 C# intent로 연결; ThemeA exact-title 중복확인 포함 | | 해외 | UC5 해외업종·해외종목·해외지수 조회와 playlist 생성 | -| playlist | 단일/Ctrl/Shift 선택, enabled, 위/아래, 선택/전체 삭제, drag/batch 경계 | -| FarPoint 대체 | Web table로 표현하되 원본 클릭·더블클릭·정렬·이동·저장 이벤트 의미 유지 | +| playlist | 단일/Ctrl/Shift 선택, enabled, 위/아래, 선택/전체 삭제와 비연속 다중 선택 block drag; PREPARED/PROGRAM/pending/unknown 변경 차단 | +| FarPoint 대체 | Web table로 표현하되 원본 클릭·더블클릭·정렬·이동·저장, 탭 dragover 교환과 UC6/EList opaque 행 drag 의미 유지 | 상세 이벤트 대조는 [`OPERATOR_UI_PARITY.md`](OPERATOR_UI_PARITY.md)를 따른다. +### 이벤트·drag·중복확인 구현 증적 + +줄 단위 원본 근거는 +[`LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md`](LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md)에 있다. +186개 구독/171개 고유 핸들러 수에는 선언만 있고 구독되지 않은 메서드를 넣지 않았다. + +- playlist drag는 opaque source/target `rowId`와 before/after만 Web에서 보내며, C#이 현재 + 선택 집합을 표시 순서대로 한 블록으로 이동한다. 체크박스 dragstart는 거부한다. +- 시장 탭은 dragover 대상 진입 시 source/target identity와 예상 인덱스를 보낸다. C# 현재 + 순서가 예상값과 일치할 때만 교환하므로 같은 전달의 재처리는 no-op이다. +- UC6의 `FpSpread2.AllowDragDrop`은 ThemeA/EList 편집 행의 opaque `rowId` 재정렬로 + 이관했다. Web이 DB identity나 play index를 정하지 않는다. +- ThemeA `중복확인`은 이름 하나만 strict intent로 받고 C#이 현재 KRX/NXT provider에서 + exact title parameterized read를 수행한다. 불명 결과는 `Indeterminate`이고 write나 + fallback이 없다. edit 자기 행을 제외하지 않는 원본 규칙도 유지한다. + +### 2026-07-15 실제 마우스/CDP UI 검증 + +설치한 현재 Release package의 실제 WebView 화면에서 다음 운영자 입력을 확인했다. + +- `삼성` 검색 결과는 **63개**였고 `삼성출판사`를 선택했다. 컷 3개를 선택한 뒤 한 번의 + drop으로 playlist에 정확히 3행을 추가했다. +- playlist 여러 행을 실제 마우스로 선택해 한 블록으로 재정렬했고, enabled 체크박스에서 + 시작한 drag는 행 이동을 만들지 않았다. +- 시장 탭은 마우스 버튼을 놓기 전 dragover 대상 진입 시 순서가 바뀌었다. +- EList 편집기에서 추천행 **5개**의 순서를 drag로 변경했다. +- ThemeA 목록 **500건**을 불러온 상태에서 `중복확인` exact-title read가 성공했다. + +이 과정에서 playlist 행 drop이 조상 `playlist-drop-zone`으로 버블링해 아직 선택된 컷을 +추가로 append하던 문제가 발견됐다. drop zone은 이제 exact `legacy-selected-cuts` +`text/plain` token만 허용하고 playlist row custom MIME와 외부 text drop은 무시한다. +따라서 행 재정렬은 `reorder-playlist-rows`만 만들고 `drop-selected-cuts`를 만들지 않는다. + +또한 Chromium은 체크박스를 눌러 시작한 경우에도 `dragstart.target`을 가장 가까운 draggable +조상 행으로 보고할 수 있어 단순 `event.target.closest("input")` 검사만으로는 부족했다. +pointerdown 때 checkbox origin의 행 ID를 기억하고 이어지는 row dragstart를 거부하며, +pointerup/pointercancel/dragend에서 상태를 지우도록 수정했다. thin-client 회귀 테스트는 +이 Chromium target 형태, 다중 선택 보존, row drop의 ancestor 무시, 외부 text 거부와 exact +selected-cut token의 단일 처리를 실제 DOM event 순서로 고정한다. + +### Development Live 시작 구성 증적 + +공유 `.slnLaunch`와 package launch profile은 LegacyParityApp을 시작 대상으로 고정한다. +[`DEVELOPMENT_LIVE_VISUAL_STUDIO.md`](DEVELOPMENT_LIVE_VISUAL_STUDIO.md)의 strict local +authorization은 `%LOCALAPPDATA%` 아래 Git 제외 파일에 정확한 다섯 속성, 최대 4096바이트, +canonical JSON, exact authorization 문자열과 독립 승인 native/Interop SHA-256을 요구한다. +reparse, 알 수 없는/중복 속성, 파일 및 환경 설정 오류는 fail closed하며 authorization은 +process scope에서 마지막에 설정된다. 이 부트스트랩은 기존 trusted Live 이중 게이트, +scene allowlist, asset 검사와 timeout/OutcomeUnknown 정책을 대체하지 않는다. + +실제 signed Release MSIX에 `--development-live`를 전달해도 compile-time Debug gate 때문에 +승인 파일을 적용하지 않고 `DryRun`으로 실행되는 것을 확인했다. 이 Release DryRun 회차에는 +Tornado TCP 연결이 없었고 Network Monitoring 내용도 변하지 않았다. + ## 송출 흐름과 장면 | 항목 | 결과 | @@ -54,6 +113,7 @@ | ThemeA KRX write | **PASS** — create, preview/readback, delete, absence | | ThemeA NXT write | **PASS** — create, preview/readback, delete, absence | | EList write | **PASS** — expert create, preview/readback, delete, absence | +| ThemeA 중복확인 read | **PASS** — 500건 목록 상태에서 현재 market의 exact title 조회; DB write 없음 | 개발 DB write는 총 9개 충돌 격리 시나리오다. production service/executor를 사용했으며 인증정보나 DB 원문을 출력하지 않았다. write 결과가 불명확한 경우에는 재시도와 cleanup을 @@ -71,54 +131,50 @@ 누락 자산은 Git 또는 MSIX에 임의로 넣지 않는다. 제공되면 같은 trusted-root, 0-byte, reparse, root-escape 검사를 통과한 뒤 해당 scene만 다시 검증한다. -## 개발 Tornado2와 PGM 실측 +## 2026-07-15 Tornado2 Live 연결 실측 -2026-07-15 개발 Tornado2 `2.7.5.49 x64 DSX10.0 S.R2`의 단일 `30001/tcp` -listener와 K3D x64 등록·라이선스를 고정한 뒤, 설치된 MSIX package-context 앱에서 다음 -시퀀스를 실행했다. +아래 시각은 개발 장비 로컬 시간(KST)이다. strict local authorization을 통과한 제한된 Live +회차에서 **14:28:28 HELLO request 1회와 success 1회**, **14:43:10 BYE 1회**를 Network +Monitoring에서 확인했다. 이 회차의 scene 명령, PREPARE, TAKE 계열 명령과 DB write는 +각각 **0건**이다. -1. 삼성전자 검색 → `5001` playlist 추가 -2. 원본과 같은 fixed leaf **더블클릭**으로 VI 수동 목록 9종목 조회 → `5074` 2페이지 추가 -3. `PREPARE 5001 page 1` → `TAKE IN` → `OnScenePlayed` → 자동 갱신 정확히 1회 -4. `NEXT`로 다음 playlist 항목 `5074 page 1/2` → 자동 갱신 정확히 1회 -5. 같은 scene `Page NEXT`로 `5074 page 2/2`의 마지막 4종목 송출 -6. `TAKE OUT All` → `IDLE` → 정상 종료 확인 → `BYE` +종료 후 보관한 Network Monitoring 원문은 **300,007자**, SHA-256 +`45858322F60C135858002EB6243D0474F51F1965E23D2332D397C3A3B5DB8049`다. 이 증적은 현재 +코드의 Live CONNECT/handshake/DISCONNECT 경로만 확인한다. scene load/mutation, +PREPARE, TAKE IN, NEXT/Page NEXT, TAKE OUT 또는 PGM 화면 출력을 검증했다는 뜻이 아니다. -첫 실측과 화면 증적용 known-outcome 재현은 모두 `OutcomeUnknown=false`로 끝났다. 불명확 -결과 뒤 명령 재시도는 없었다. Network Monitoring 최종 원문은 1,863줄, -299,616문자, SHA-256 -`7CDB183A2518EF47E5CC3819C2EB14C06492DE0C8E018350A0633E0FBBB5B994`다. 두 실측을 -합쳐 `LOAD_SCENE` 4/4, `BEGIN_TRANSACTION` 12/12, `VALUE_SET` 258/258, -`VISIBLE` 568/568, `SCENE_PREPARE` 12/12, `PLAY` 10/10, `STOPAL` 2/2, -`UNLOAD_SCENE` 4/4가 요청/성공으로 일치했고 `SCENE_PLAYED` 10회를 수신했다. 정상 -종료에서는 `BYE` 요청 1회가 기록됐고 이후 established 연결과 앱/CDP process는 0개다. +별도의 Release DryRun 회차에서는 `--development-live`가 무시됐고 TCP 연결이 생성되지 +않았으며 Network Monitoring 최종 내용도 변하지 않았다. 이는 Debug-only bootstrap 경계를 +확인한 결과이지 Release Live 송출 결과가 아니다. -화면 증적은 300프레임 중 72프레임이 검은 기준 화면과 달랐다. `5001` 삼성전자 화면, -`5074` page 1의 5종목, page 2의 4종목을 직접 확인했고 TAKE OUT 뒤 기준 검은 화면으로 -복귀했다. 프레임 원본과 Network Monitoring 원문은 실제 컷·운영자료를 Git에 넣지 않는 -정책에 따라 로컬 `artifacts/final-validation-20260715`에만 보관한다. +## 현재 package와 범위 회귀 기록 -## 최종 전체 회귀 기록 - -아래 값은 모든 병렬 변경과 fixed-leaf 더블클릭 회귀 테스트가 합쳐진 뒤 다시 실행한 -최종값이다. - -| 항목 | 최종 결과 | +| 항목 | 2026-07-15 확인 결과 | |---|---| -| Debug x64 전체 빌드·테스트 | **PASS** — .NET 2,394/2,394, VS 2026 `ParityDebug|x64`, 경고 0·오류 0 | -| Release x64 전체 빌드·테스트 | **PASS** — .NET 2,394/2,394, VS 2026 `Release|x64`, 경고 0·오류 0 | -| Web/LegacyParity 전체 테스트 | **PASS** — 47파일, 435/435 (`Test-WebPlayout` 415 + thin-client 20) | -| Release x64 MSIX 경로·버전 | `artifacts/final-validation-20260715/package/MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64_Test/MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64.msix`, `0.1.0.0`, 15,200,545 bytes | -| 새 MSIX SHA-256·서명 | `FC4EEE1509675E643D2B4CB435E279CAC4611B28534048D9B9EE611E361E0E1F`, **Valid**, `CN=Comtrophy` | -| 설치된 package-context 실행 | **PASS** — `Wickedness.MBNStockWebView.LegacyParity_0.1.0.0_x64__qbv3jkvsn3aj0`, DryRun 전체 흐름과 Live 개발 PGM 실행, 정상 종료 | -| 개발 Tornado2 Network Monitoring | **PASS** — HELLO, transaction/mutation/query/prepare/play, event, unload, STOPAL, BYE와 연결 해제 확인 | -| 개발 PGM PREPARE → TAKE IN → NEXT/Page NEXT → TAKE OUT | **PASS** — `5001` → `5074` 1/2 → `5074` 2/2 → 검은 화면 복귀 | -| Gitea main commit/push | **PASS** — 이 최종 검증 문서를 포함한 커밋을 `origin/main`에 fast-forward push | +| signed Release x64 MSIX | **15,213,188 bytes**, SHA-256 `357E17426C45FF516592EE0177D0F95502A98AF084759348ABC191BA5ED21FD5` | +| 설치·실행 | 설치된 package-context 앱 실행 확인 | +| Release `--development-live` | Debug-only gate에 따라 무시되고 `DryRun`; TCP 연결과 Network Monitoring 변경 없음 | +| 실제 마우스/CDP UI 범위 | 삼성 63건 검색→삼성출판사→3컷 단일 drop, playlist multi-row reorder, checkbox drag 차단, tab dragover 교환, EList 5행 reorder, EList 수량 입력칸 origin drag 차단, ThemeA 500건과 중복확인 read 확인 | +| 제한된 Live Network 범위 | HELLO request/success 각 1회와 BYE 1회; scene/PREPARE/TAKE/DB write 0 | +| .NET Debug/Release x64 | 각 **2,446/2,446**, 실패·건너뜀 0, 경고·오류 0 | +| Web Node 회귀 | **438/438**, 실패·취소·건너뜀·TODO 0 | +| Visual Studio 2026 build | VS 18.7.3 / MSBuild 18.7.8, Debug·Release x64 모두 경고 0 / 오류 0 | + +playlist drop MIME 경계, checkbox-origin drag와 EList input/button-origin drag 회귀는 +thin-client DOM event 테스트로도 고정했다. 설치된 최종 Release package에서 실제 마우스로 +EList 수량 입력칸에서 시작한 drag가 순서를 바꾸지 않고, 같은 행의 비입력 영역에서 시작한 +drag만 5행 순서를 바꾸는 것도 확인했다. .NET Debug/Release 합계 4,892회와 Web 438회를 +모두 통과했지만, +scene PREPARE/TAKE/PGM을 실행하지 않은 이 Live 연결 회차를 전체 방송 송출 PASS로 확대하지 +않는다. ## 판정 경계 - 35/35 자동 장면 동등성은 모든 장면의 실제 PGM 출력 완료를 뜻하지 않는다. - 33/33 DB selector 통과는 외부 영상/배경 자산 준비를 뜻하지 않는다. - 개발 DB 9개 write 시나리오 통과는 운영 DB에 쓰기를 허용하거나 수행했다는 뜻이 아니다. -- 개발 Tornado2/PGM은 안전 게이트를 유지한 별도 통합 검증 대상이다. +- 현재 signed Release MSIX의 설치·DryRun 실행, 실제 마우스/CDP UI, .NET Debug/Release + 각 2,446개와 Web 438개 회귀를 확인했다. +- Live 증적은 HELLO/BYE만 확인한 연결 회차다. scene/PREPARE/TAKE/DB write가 0이므로 + PGM 송출 또는 원클릭 TAKE IN 전체 통합 성공으로 판정하지 않는다. - 원본 저장소, 실제 Cuts, 라이선스와 인증정보는 계속 읽기 전용 또는 Git 외부로 유지한다. diff --git a/docs/OPERATOR_UI_PARITY.md b/docs/OPERATOR_UI_PARITY.md index 9332da4..37d73f2 100644 --- a/docs/OPERATOR_UI_PARITY.md +++ b/docs/OPERATOR_UI_PARITY.md @@ -28,6 +28,16 @@ playlist, PageN과 송출 상태는 `LegacyOperatorController`의 C# snapshot이 - 원본 `Cuts`도 읽기 전용 검증에만 사용하며 수정·복사하지 않는다. - 비밀번호, 라이선스, 벤더 DLL, 인증서, 실제 컷·영상·배경과 로컬 설정은 Git에 넣지 않는다. +## 원본 Designer 이벤트 전수 감사 + +원본 `*.Designer.cs`가 실제로 구독하는 이벤트를 전수 조사한 결과는 **186개 Designer +구독, 화면별 중복을 합친 171개 고유 핸들러**다. 선언만 있고 Designer에서 구독하지 않는 +메서드는 수에 넣지 않았다. 171개는 `IMPL` 131, `DEAD` 20, `VISUAL` 10, `DRAG` 9, +`SAFETY` 1로 모두 분류했다. 같은 이름의 핸들러라도 화면이 다르면 별도 바인딩으로 센다. + +상세 원본 파일·구독 줄·분류 근거는 +[`LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md`](LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md)에 있다. + ## 원본 실행 action inventory 동적 테마·전문가 DB 행과 전용 편집기의 데이터 행은 아래 고정 수량에 중복해 더하지 않는다. @@ -61,8 +71,8 @@ playlist, PageN과 송출 상태는 `LegacyOperatorController`의 C# snapshot이 | VIList | 종목 추가·삭제·이동, 5개 단위 page, 확인 시 저장+추가+닫기 | `LegacyManualListsWorkflow` + trusted store | 원본 형식 read와 격리 write 검증 | `s5074` 전용 입력 PGM 별도 | | PList | 목록, 행 더블클릭 load/save, 확인, 삭제 | `LegacyNamedPlaylistWorkflow` | 개발 DB create/save/read/delete/absence PASS | 복원 항목별 scene PGM 별도 | | AList | 다음 코드와 프로그램명 신규 생성 | named playlist workflow에 통합 | 위 named playlist 시나리오로 PASS | 해당 없음 | -| ThemeA | KRX/NXT 테마 생성·편집·삭제, 종목 구성·순서 | `LegacyOperatorCatalogWorkflow` | 개발 DB KRX/NXT 각 1개 PASS | scene PGM 별도 | -| EList | 전문가 생성·편집·삭제, 추천 종목·금액·순서 | `LegacyOperatorCatalogWorkflow` | 개발 DB expert 1개 PASS | scene PGM 별도 | +| ThemeA | KRX/NXT 테마 생성·편집·삭제, 이름 중복확인, 종목 구성·순서 | `LegacyOperatorCatalogWorkflow` + exact-title 조회 | 개발 DB KRX/NXT 각 1개 PASS | scene PGM 별도 | +| EList | 전문가 생성·편집·삭제, 추천 종목·금액·드래그 순서 | `LegacyOperatorCatalogWorkflow` + opaque row reorder | 개발 DB expert 1개 PASS | scene PGM 별도 | UC4/UC6의 결과 행은 고정 action 트리의 click handler에 버블링하지 않는다. UC4 테마, UC6 전문가와 UC7 거래정지는 원본처럼 **단일 클릭**으로 선택/playlist 동작을 수행하고, @@ -74,10 +84,10 @@ fixed/industry/comparison 트리의 원본 double-click과 독립적으로 처 |---|---|---| | `Form1_Load` | 자산·DB·탭·Tornado 초기화 | WebView local UI와 C# composition root 초기화; 기본 `DryRun` | | `tabControl1_SelectedIndexChanged` | 탭별 최초 데이터 조회 | 10개 identity별 C# activation gate; 최초 진입 1회 load, 재진입 snapshot 유지 | -| 탭 drag 이벤트 | 10개 탭 표시 순서 교환 | identity 기반 drag/키보드 이동; active tab·playlist 불변 | +| 탭 drag 이벤트 | 10개 탭 표시 순서 교환 | dragover 대상 진입 시 identity와 예상 위치를 C#이 검증해 교환; 중복 전달은 no-op, active tab·playlist 불변 | | 검색 버튼/Enter | 종목 검색 | 원본 KOSPI→KOSDAQ→NXT 순서와 마지막 이름 일치 규칙 유지 | | 컷 click/double-click/drag | 원본 31컷 선택·추가 | plain/Ctrl/Shift/Ctrl+Shift와 원본 double-click 판정, batch 추가 | -| playlist click/key/buttons | 선택, enabled, 이동, 삭제 | C# playlist authority; PREPARED/PROGRAM/pending/unknown에서 mutation 차단 | +| playlist click/key/buttons/drag | 선택, enabled, 이동, 삭제, 다중 선택 행 이동 | 선택된 행은 원래 순서를 보존한 한 블록으로 임의 위치에 이동; C# playlist authority와 PREPARED/PROGRAM/pending/unknown mutation 차단 유지 | | F2/F3 | 배경 선택·없음 | trusted sibling background root만 허용; 누락·reparse·escape fail closed | | F8 | TAKE IN | idle이면 내부 PREPARE → TAKE IN을 한 번에 실행 | | NEXT | Page NEXT 또는 다음 행 | 같은 scene PageN 여부를 C#이 판정 | @@ -91,6 +101,8 @@ FarPoint UI는 Web table/list로 표현한다. 다음 의미는 원본과 같이 - plain/Ctrl/Shift/Ctrl+Shift 선택과 현재 행 - column header 정렬과 현재 표시 순서 - 위/아래 이동, 선택/전체 삭제와 enabled 토글 +- playlist의 비연속 다중 선택을 한 블록으로 유지하는 행 drag와 임의 before/after drop +- UC6/EList 추천 행의 opaque `rowId` 기반 drag 재정렬 - single-click과 double-click을 컨트롤별로 구분 - 부모 section 전체 추가는 모든 자식을 먼저 materialize한 뒤 한 번에 commit - 수동/지원 불가 child가 있으면 부분 추가 없이 전체 차단 @@ -101,6 +113,34 @@ GraphE 행 double-click은 fresh DB record를 다시 확인한 뒤 한 행을 Theme 결과 double-click은 원본의 5행 현재가 경로를 사용한다. PList 행 double-click은 현재 save/load 모드에 맞춰 동작하며, 이름만으로 DB identity를 추정하지 않는다. +### drag 이관 경계 + +| 원본 동작 | Web 입력 | C# 권위와 중복 안전 | +|---|---|---| +| `PlayList_DragDrop/DragOver` | 내부 custom MIME와 opaque source/target `rowId`, before/after | source가 선택 행이면 현재 선택 전체를 표시 순서대로 이동한다. 체크박스에서 시작한 drag는 거부하고 PREPARED/PROGRAM/pending/unknown에서는 변경하지 않는다. | +| `tab_DragOver/MouseDown/Move/Up` | drop을 기다리지 않고 dragover로 `hover-swap-tabs` 전송 | C# 탭 identity와 `expectedSourceIndex/expectedTargetIndex`가 현재 순서와 일치할 때만 교환하므로 같은 메시지의 중복 전달이 순서를 되돌리지 않는다. | +| UC6 `FpSpread2.AllowDragDrop` | ThemeA/EList 편집행의 내부 custom MIME와 opaque `rowId` | 워크플로가 임의 before/after 위치를 계산한다. 존재하지 않는 ID, 같은 행, 추가 payload 필드는 strict bridge에서 거부하거나 no-op 처리한다. | + +실제 마우스 검증에서는 playlist 행 drop이 조상 drop zone으로 버블링해 현재 선택된 컷을 +한 번 더 추가하는 문제가 드러났다. 조상은 이제 exact `legacy-selected-cuts` `text/plain` +token만 처리하고 playlist row custom MIME와 외부 text는 무시한다. 따라서 한 row drop에서 +reorder와 cut append가 함께 발생하지 않는다. + +체크박스 origin도 브라우저 동작을 반영해 보강했다. Chromium의 `dragstart.target`은 실제 +pointer가 체크박스에서 시작했어도 draggable 조상 행일 수 있다. pointerdown 때 checkbox +origin row를 기억해 이어지는 dragstart를 거부하고 pointerup/pointercancel/dragend에서 지운다. +회귀 테스트는 이 target 형태, 다중선택 보존, row MIME의 조상 무시, 외부 text 거부와 exact +selected-cut token의 1회 처리를 DOM event 순서로 재현한다. + +### ThemeA 이름 중복확인 + +원본 ThemeA의 `중복확인` 버튼을 `operator-catalog-name-check`로 이관했다. Web은 이름만 +`check-operator-catalog-theme-name` intent로 전송하고 KRX/NXT, 코드와 테이블 identity는 +C# workflow가 소유한다. 선택된 provider의 `SB_LIST`에 exact title parameterized +`COUNT(*)` 조회를 한 번 수행하며 DB mutation은 실행하지 않는다. 중복, 사용 가능, +결과 불명 상태를 구분하고, 조회 결과를 확정할 수 없으면 쓰기 없이 fail closed한다. +원본처럼 편집 중 자기 행을 제외하지 않으므로 이름을 바꾸지 않은 행은 중복으로 판정된다. + ## 원클릭 TAKE IN과 숨김 PREPARE 원본 운영 화면의 핵심 동작은 별도 PREPARE를 먼저 누르는 것이 아니라 F8/TAKE IN 한 번으로 @@ -115,6 +155,46 @@ Theme 결과 double-click은 원본의 5행 현재가 경로를 사용한다. PL `hidden`이다. TAKE IN 실패·timeout·`OutcomeUnknown`이면 자동 재시도하지 않으며 성공한 prepared ownership을 추측해 지우지 않는다. +## Visual Studio Development Live 시작 경계 + +공유 `MBN_STOCK_WEBVIEW.slnLaunch`와 패키지 시작 프로필은 +`MBN_STOCK_WEBVIEW.LegacyParityApp`을 명시적인 시작 프로젝트로 사용한다. 저장소의 +기본값은 계속 `DryRun`이고 이 Development Live 부트스트랩은 Release에서 적용되지 않는다. +Development Live는 정확한 `--development-live` +인자, Debug 빌드, `%LOCALAPPDATA%` 아래 Git 제외 승인 파일의 세 조건을 모두 만족한 +프로세스에서만 구성된다. + +승인 파일은 정확한 다섯 속성, 최대 4096바이트, canonical JSON, exact authorization +문자열과 독립 검수된 native/Interop SHA-256 두 개를 요구한다. 알 수 없는 속성, 중복 속성, +reparse 경로, I/O 오류 또는 일부 환경 설정 실패는 모두 fail closed한다. Live authorization은 +process scope에서 마지막에 설정되고 `MainWindow`/engine 생성 시 한 번 캡처된다. 이 경로는 +기존 `trustedLiveOutputEnabled`, scene allowlist, asset 검사와 명령 안전 게이트를 완화하지 +않는다. 상세 절차는 +[`DEVELOPMENT_LIVE_VISUAL_STUDIO.md`](DEVELOPMENT_LIVE_VISUAL_STUDIO.md)에 있다. + +## 2026-07-15 실제 UI·package·연결 검증 + +설치한 signed Release x64 MSIX는 **15,213,188 bytes**, SHA-256 +`357E17426C45FF516592EE0177D0F95502A98AF084759348ABC191BA5ED21FD5`다. 설치와 +package-context 실행을 확인했다. Release에 `--development-live`를 전달한 회차는 Debug-only +gate 때문에 `DryRun`으로 남았고 Tornado TCP 연결과 Network Monitoring 변경이 없었다. + +실제 WebView 마우스/CDP 입력의 확인 범위는 다음과 같다. + +- `삼성` 검색 결과 **63개**, `삼성출판사` 선택, 선택한 컷 3개를 한 번의 drop으로 + playlist 3행에 추가 +- playlist multi-row block reorder와 enabled 체크박스 origin drag 차단 +- mouse release 전에 dragover로 시장 탭 순서 교환 +- EList 추천행 **5개** drag reorder. 수량 입력칸에서 시작한 실제 마우스 drag는 순서를 + 바꾸지 않고, 행의 비입력 영역에서 시작한 drag만 순서를 바꾸는 것을 확인 +- ThemeA 결과 **500건**과 exact-title `중복확인` read 성공 + +별도 승인된 Development Live 회차의 장비 로컬 시간(KST) **14:28:28**에 HELLO request와 +success를 각 1회, **14:43:10**에 BYE 1회를 확인했다. scene, PREPARE, TAKE 계열, DB write는 +모두 0건이다. 최종 Network Monitoring 원문은 **300,007자**, SHA-256 +`45858322F60C135858002EB6243D0474F51F1965E23D2332D397C3A3B5DB8049`다. 이 회차는 +Live 연결/해제 경로만 검증하며 scene/PGM 방송 송출 성공을 뜻하지 않는다. + ## PageN과 NEXT | scene | page size | 규칙 | @@ -167,9 +247,12 @@ create/save → fresh readback → delete → absence를 통과했다. | GraphE | [LegacyManualFinancialWorkflow.cs](../src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualFinancial/LegacyManualFinancialWorkflow.cs) | [manual-financial tests](../tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/ManualFinancial) | | PList/AList | [LegacyNamedPlaylistWorkflow.cs](../src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs) | [named-playlist tests](../tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/NamedPlaylist) | | ThemeA/EList | [LegacyOperatorCatalogWorkflow.cs](../src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs) | [catalog tests](../tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/OperatorCatalog) | +| Designer 이벤트 감사 | [LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md](LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md) | 186 subscriptions / 171 unique handlers | +| VS Development Live | [DEVELOPMENT_LIVE_VISUAL_STUDIO.md](DEVELOPMENT_LIVE_VISUAL_STUDIO.md) | strict local authorization와 Debug-only bootstrap | | scene matrix | [SCENE_EQUIVALENCE.md](SCENE_EQUIVALENCE.md) | 35/35 builder, 45 alias, PageN matrix | -| 2026-07-15 증적 | [LEGACY_PARITY_VALIDATION_20260715.md](LEGACY_PARITY_VALIDATION_20260715.md) | 최종 전체 회귀 기록란 포함 | +| 2026-07-15 증적 | [LEGACY_PARITY_VALIDATION_20260715.md](LEGACY_PARITY_VALIDATION_20260715.md) | signed Release, 실제 UI와 제한된 Live 연결 범위 | -최종 Debug/Release 전체 테스트 개수, 새 MSIX SHA-256와 개발 PGM 결과는 병렬 변경을 -모두 합친 뒤 위 검증 문서에 기록했다. 이전 package 숫자나 hash를 현재 결과로 -재사용하지 않았다. +현재 signed Release MSIX의 hash·크기·설치·DryRun 실행과 위 실제 마우스/CDP UI 범위는 +확인했다. 최종 회귀는 .NET Debug/Release x64에서 각각 **2,446/2,446**, Web Node에서 +**438/438**, Visual Studio 2026 Debug/Release x64 빌드에서 경고 0·오류 0이다. 다만 +HELLO/BYE만 수행한 Live 회차를 scene PREPARE/TAKE/PGM 성공으로 확대하지 않는다. diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs index d7c0311..1c2ac0c 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs @@ -21,6 +21,11 @@ public interface IThemeCatalogPersistenceService { bool CanMutate { get; } + Task ThemeNameExistsAsync( + ThemeMarket market, + string themeTitle, + CancellationToken cancellationToken = default); + Task SuggestNextCodeAsync( ThemeMarket market, CancellationToken cancellationToken = default); @@ -51,6 +56,8 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog internal const string NextKrxCodeQueryName = "THEME_NEXT_CODE_KRX"; internal const string NextNxtCodeQueryName = "THEME_NEXT_CODE_NXT"; + internal const string NameExistsKrxQueryName = "THEME_NAME_EXISTS_KRX"; + internal const string NameExistsNxtQueryName = "THEME_NAME_EXISTS_NXT"; private const int MaximumThemeTitleLength = 128; private const int MaximumItemNameLength = 200; @@ -95,6 +102,20 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog FROM SB_LIST """; + private const string NameExistsKrxSql = """ + SELECT COUNT(*) THEME_COUNT + FROM SB_LIST s + WHERE s.SB_MARKET = 'KRX' + AND s.SB_TITLE = :theme_title + """; + + private const string NameExistsNxtSql = """ + SELECT COUNT(*) THEME_COUNT + FROM SB_LIST s + WHERE s.SB_MARKET = 'NXT' + AND s.SB_TITLE = @theme_title + """; + private readonly IDataQueryExecutor _queryExecutor; private readonly IOperatorCatalogMutationExecutor? _mutationExecutor; @@ -113,6 +134,38 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog public bool CanMutate => _mutationExecutor is not null; + public async Task ThemeNameExistsAsync( + ThemeMarket market, + string themeTitle, + CancellationToken cancellationToken = default) + { + var canonicalTitle = ValidateThemeTitle(themeTitle, market, nameof(themeTitle)); + var profile = ThemeMutationProfiles.For(market); + cancellationToken.ThrowIfCancellationRequested(); + var query = new DataQuerySpec( + market == ThemeMarket.Krx ? NameExistsKrxSql : NameExistsNxtSql, + [new DataQueryParameter("theme_title", canonicalTitle, DbType.String)]); + query.ValidateFor(profile.Source); + var table = await _queryExecutor.ExecuteAsync( + profile.Source, + market == ThemeMarket.Krx ? NameExistsKrxQueryName : NameExistsNxtQueryName, + query, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + if (table is null || + table.Columns.Count != 1 || + !string.Equals(table.Columns[0].ColumnName, "THEME_COUNT", StringComparison.Ordinal) || + table.Rows.Count != 1 || + !TryReadNonNegativeCount(table.Rows[0][0], out var count)) + { + throw new ThemeSelectionDataException( + "Theme name availability data has an invalid schema or value."); + } + + return count > 0; + } + public async Task SuggestNextCodeAsync( ThemeMarket market, CancellationToken cancellationToken = default) @@ -417,6 +470,27 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog return value; } + private static bool TryReadNonNegativeCount(object? value, out decimal count) + { + count = 0; + if (value is null or DBNull || value is bool || value is char || value is string) + { + return false; + } + + try + { + count = Convert.ToDecimal(value, CultureInfo.InvariantCulture); + return count >= 0 && decimal.Truncate(count) == count; + } + catch (Exception exception) when ( + exception is InvalidCastException or FormatException or OverflowException) + { + count = 0; + return false; + } + } + private static bool IsSafeText(string value) { foreach (var character in value) diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs index b52fe59..e200281 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs @@ -23,6 +23,12 @@ public enum LegacyPlaylistMoveDirection Down } +public enum LegacyDragDropPosition +{ + Before, + After +} + public enum LegacyOperatorTabId { Overseas, @@ -549,6 +555,29 @@ public sealed class LegacyOperatorController return await SelectTabAsync(source, cancellationToken).ConfigureAwait(false); } + public Task SwapTabsAtExpectedPositionsAsync( + LegacyOperatorTabId source, + LegacyOperatorTabId target, + int expectedSourceIndex, + int expectedTargetIndex, + CancellationToken cancellationToken = default) + { + if (source == target || + expectedSourceIndex < 0 || expectedSourceIndex >= _tabOrder.Count || + expectedTargetIndex < 0 || expectedTargetIndex >= _tabOrder.Count || + expectedSourceIndex == expectedTargetIndex || + _tabOrder[expectedSourceIndex] != source || + _tabOrder[expectedTargetIndex] != target) + { + return Task.FromResult(CreateSnapshot()); + } + + // The expected positions make a repeated dragover message idempotent. Once + // the first message swaps the two tabs, an identical delivery no longer + // matches the C#-owned order and therefore cannot swap them back. + return SwapTabsAsync(source, target, cancellationToken); + } + public LegacyOperatorSnapshot SetMovingAverages(bool ma5, bool ma20) { var next = new LegacyMovingAverageSelection(ma5, ma20); @@ -831,6 +860,31 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } + public LegacyOperatorSnapshot ReorderOperatorCatalogDraftRow( + string sourceRowId, + string targetRowId, + LegacyDragDropPosition position) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다."); + } + + var beforeRevision = _operatorCatalogWorkflow.Current.Revision; + var reordered = _operatorCatalogWorkflow.ReorderDraftRow( + sourceRowId, + targetRowId, + position); + if (reordered.Revision == beforeRevision) + { + return CreateSnapshot(); + } + + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + public LegacyOperatorSnapshot SetOperatorCatalogDraftBuyAmount( string rowId, decimal buyAmount) @@ -882,6 +936,23 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } + public async Task CheckOperatorCatalogThemeNameAsync( + string editorName, + CancellationToken cancellationToken = default) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA 관리 기능이 구성되지 않았습니다."); + } + + await _operatorCatalogWorkflow.CheckThemeNameAvailabilityAsync( + editorName, + cancellationToken).ConfigureAwait(false); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + public async Task SaveOperatorCatalogAsync( string editorName, CancellationToken cancellationToken = default) @@ -2769,6 +2840,82 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } + public LegacyOperatorSnapshot ReorderPlaylistRows( + string sourceRowId, + string targetRowId, + LegacyDragDropPosition position) + { + ArgumentNullException.ThrowIfNull(sourceRowId); + ArgumentNullException.ThrowIfNull(targetRowId); + + var sourceIndex = FindPlaylistIndex(sourceRowId); + var targetIndex = FindPlaylistIndex(targetRowId); + if (sourceIndex < 0 || targetIndex < 0 || sourceIndex == targetIndex) + { + return CreateSnapshot(); + } + + var sourceWasSelected = _selectedPlaylistRowIds.Contains(sourceRowId); + var movingIds = sourceWasSelected + ? new HashSet(_selectedPlaylistRowIds, StringComparer.Ordinal) + : new HashSet([sourceRowId], StringComparer.Ordinal); + if (movingIds.Contains(targetRowId)) + { + return CreateSnapshot(); + } + + var movingRows = _playlist + .Where(row => movingIds.Contains(row.RowId)) + .ToArray(); + var remainingRows = _playlist + .Where(row => !movingIds.Contains(row.RowId)) + .ToList(); + var remainingTargetIndex = remainingRows.FindIndex(row => + string.Equals(row.RowId, targetRowId, StringComparison.Ordinal)); + if (movingRows.Length == 0 || remainingTargetIndex < 0) + { + return CreateSnapshot(); + } + + var insertionIndex = position switch + { + LegacyDragDropPosition.Before => remainingTargetIndex, + LegacyDragDropPosition.After => remainingTargetIndex + 1, + _ => throw new ArgumentOutOfRangeException(nameof(position), position, null) + }; + remainingRows.InsertRange(insertionIndex, movingRows); + + var orderChanged = !_playlist + .Select(static row => row.RowId) + .SequenceEqual( + remainingRows.Select(static row => row.RowId), + StringComparer.Ordinal); + var selectionChanged = !sourceWasSelected || + !string.Equals(_activePlaylistRowId, sourceRowId, StringComparison.Ordinal); + if (!orderChanged && !selectionChanged) + { + return CreateSnapshot(); + } + + if (orderChanged) + { + _playlist.Clear(); + _playlist.AddRange(remainingRows); + } + + if (!sourceWasSelected) + { + _selectedPlaylistRowIds.Clear(); + _selectedPlaylistRowIds.Add(sourceRowId); + } + + _activePlaylistRowId = sourceRowId; + _playlistCursorIndex = FindPlaylistIndex(sourceRowId); + _playlistPriorCursorIndex = _playlistCursorIndex; + Touch(); + return CreateSnapshot(); + } + public async Task RefreshNamedPlaylistsAsync( CancellationToken cancellationToken = default) { diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs index bf47908..b8246e8 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs @@ -25,6 +25,14 @@ public enum LegacyOperatorCatalogStatusKind Error } +public enum LegacyThemeNameAvailability +{ + NotChecked, + Available, + Duplicate, + Indeterminate +} + public enum LegacyOperatorCatalogMoveDirection { Up, @@ -65,6 +73,8 @@ public sealed record LegacyOperatorCatalogWorkflowSnapshot( string? SelectedResultId, string CodeLabel, string EditorName, + LegacyThemeNameAvailability ThemeNameAvailability, + string CheckedThemeName, IReadOnlyList DraftRows, string StockQuery, IReadOnlyList StockResults, @@ -73,6 +83,7 @@ public sealed record LegacyOperatorCatalogWorkflowSnapshot( bool SchemaValidated, bool WritesQuarantined, bool CanBeginCreate, + bool CanCheckThemeName, bool CanSave, bool CanDelete, bool CanAddStock, @@ -224,6 +235,9 @@ public sealed class LegacyOperatorCatalogWorkflowController private string _stockQuery = string.Empty; private string _codeLabel = string.Empty; private string _editorName = string.Empty; + private LegacyThemeNameAvailability _themeNameAvailability = + LegacyThemeNameAvailability.NotChecked; + private string _checkedThemeName = string.Empty; private string? _selectedResultId; private string? _selectedStockResultId; private ThemeSelectionIdentity? _expectedThemeIdentity; @@ -504,6 +518,7 @@ public sealed class LegacyOperatorCatalogWorkflowController _selectedResultId = resultId; _mode = LegacyOperatorCatalogMode.Edit; + ResetThemeNameAvailability(); ResetStockSearch(); SetStatus("편집할 항목을 불러왔습니다.", LegacyOperatorCatalogStatusKind.Information); } @@ -545,6 +560,7 @@ public sealed class LegacyOperatorCatalogWorkflowController _expectedThemeIdentity = null; _expectedExpertIdentity = null; _draftRows.Clear(); + ResetThemeNameAvailability(); ResetStockSearch(); SetStatus("새 ThemeA 항목을 입력하세요.", LegacyOperatorCatalogStatusKind.Information); } @@ -562,6 +578,53 @@ public sealed class LegacyOperatorCatalogWorkflowController return CreateSnapshot(); } + public async Task CheckThemeNameAvailabilityAsync( + string editorName, + CancellationToken cancellationToken = default) + { + EnsureOpenEntity(LegacyOperatorCatalogEntity.Theme); + EnsureEditing(); + _editorName = ValidateRequiredText(editorName, 128); + _checkedThemeName = _editorName; + _themeNameAvailability = LegacyThemeNameAvailability.Indeterminate; + + try + { + var duplicate = await _themePersistenceService.ThemeNameExistsAsync( + _newThemeMarket, + _editorName, + cancellationToken).ConfigureAwait(false); + + if (duplicate) + { + _themeNameAvailability = LegacyThemeNameAvailability.Duplicate; + SetStatus( + "이미 사용중인 테마명입니다.", + LegacyOperatorCatalogStatusKind.Warning); + } + else + { + _themeNameAvailability = LegacyThemeNameAvailability.Available; + SetStatus( + "사용 가능한 테마명입니다.", + LegacyOperatorCatalogStatusKind.Information); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + SetStatus( + "테마명 중복 여부를 확인하지 못했습니다. DB 연결을 확인하세요.", + LegacyOperatorCatalogStatusKind.Error); + } + + Touch(); + return CreateSnapshot(); + } + public async Task BeginCreateExpertAsync( CancellationToken cancellationToken = default) { @@ -730,6 +793,51 @@ public sealed class LegacyOperatorCatalogWorkflowController return CreateSnapshot(); } + public LegacyOperatorCatalogWorkflowSnapshot ReorderDraftRow( + string sourceRowId, + string targetRowId, + LegacyDragDropPosition position) + { + ArgumentNullException.ThrowIfNull(sourceRowId); + ArgumentNullException.ThrowIfNull(targetRowId); + EnsureEditing(); + + var sourceIndex = _draftRows.FindIndex(row => + string.Equals(row.RowId, sourceRowId, StringComparison.Ordinal)); + var targetIndex = _draftRows.FindIndex(row => + string.Equals(row.RowId, targetRowId, StringComparison.Ordinal)); + if (sourceIndex < 0 || targetIndex < 0 || sourceIndex == targetIndex) + { + return CreateSnapshot(); + } + + var candidate = _draftRows.ToList(); + var movingRow = candidate[sourceIndex]; + candidate.RemoveAt(sourceIndex); + var remainingTargetIndex = candidate.FindIndex(row => + string.Equals(row.RowId, targetRowId, StringComparison.Ordinal)); + var insertionIndex = position switch + { + LegacyDragDropPosition.Before => remainingTargetIndex, + LegacyDragDropPosition.After => remainingTargetIndex + 1, + _ => throw new ArgumentOutOfRangeException(nameof(position), position, null) + }; + candidate.Insert(insertionIndex, movingRow); + + if (_draftRows.Select(static row => row.RowId).SequenceEqual( + candidate.Select(static row => row.RowId), + StringComparer.Ordinal)) + { + return CreateSnapshot(); + } + + _draftRows.Clear(); + _draftRows.AddRange(candidate); + SetStatus("표시 순서를 변경했습니다.", LegacyOperatorCatalogStatusKind.Information); + Touch(); + return CreateSnapshot(); + } + public LegacyOperatorCatalogWorkflowSnapshot SetDraftBuyAmount( string rowId, decimal buyAmount) @@ -1103,6 +1211,8 @@ public sealed class LegacyOperatorCatalogWorkflowController _selectedResultId, _codeLabel, _editorName, + _themeNameAvailability, + _checkedThemeName, Array.AsReadOnly(draftViews), _stockQuery, Array.AsReadOnly(stockViews), @@ -1111,6 +1221,9 @@ public sealed class LegacyOperatorCatalogWorkflowController _schemaValidated, quarantined, canMutate && _mode == LegacyOperatorCatalogMode.List, + _schemaValidated && _isOpen && + _entity == LegacyOperatorCatalogEntity.Theme && + _mode is LegacyOperatorCatalogMode.Create or LegacyOperatorCatalogMode.Edit, canMutate && (_mode is LegacyOperatorCatalogMode.Create or LegacyOperatorCatalogMode.Edit), canMutate && _mode == LegacyOperatorCatalogMode.Edit, @@ -1149,9 +1262,16 @@ public sealed class LegacyOperatorCatalogWorkflowController _codeLabel = string.Empty; _editorName = string.Empty; _draftRows.Clear(); + ResetThemeNameAvailability(); ResetStockSearch(); } + private void ResetThemeNameAvailability() + { + _themeNameAvailability = LegacyThemeNameAvailability.NotChecked; + _checkedThemeName = string.Empty; + } + private void ResetStockSearch() { _stockQuery = string.Empty; diff --git a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs index dc64dcf..833f26f 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs @@ -40,6 +40,11 @@ public sealed record LegacySelectPlaylistRowIntent( public sealed record LegacyMoveSelectedPlaylistRowsIntent( LegacyPlaylistMoveDirection Direction) : LegacyUiIntent; +public sealed record LegacyReorderPlaylistRowsIntent( + string SourceRowId, + string TargetRowId, + LegacyDragDropPosition Position) : LegacyUiIntent; + public sealed record LegacyDeleteSelectedPlaylistRowsIntent : LegacyUiIntent; public sealed record LegacyDeleteAllPlaylistRowsIntent : LegacyUiIntent; @@ -54,6 +59,12 @@ public sealed record LegacySwapTabsIntent( LegacyOperatorTabId Source, LegacyOperatorTabId Target) : LegacyUiIntent; +public sealed record LegacyHoverSwapTabsIntent( + LegacyOperatorTabId Source, + LegacyOperatorTabId Target, + int ExpectedSourceIndex, + int ExpectedTargetIndex) : LegacyUiIntent; + public sealed record LegacySetMovingAveragesIntent(bool Ma5, bool Ma20) : LegacyUiIntent; public sealed record LegacyActivateFixedActionIntent(string ActionId) : LegacyUiIntent; @@ -180,12 +191,19 @@ public sealed record LegacyMoveOperatorCatalogDraftRowIntent( string RowId, LegacyOperatorCatalogMoveDirection Direction) : LegacyUiIntent; +public sealed record LegacyReorderOperatorCatalogDraftRowIntent( + string SourceRowId, + string TargetRowId, + LegacyDragDropPosition Position) : LegacyUiIntent; + public sealed record LegacyRemoveOperatorCatalogDraftRowIntent(string RowId) : LegacyUiIntent; public sealed record LegacySetOperatorCatalogDraftBuyAmountIntent( string RowId, decimal BuyAmount) : LegacyUiIntent; +public sealed record LegacyCheckOperatorCatalogThemeNameIntent(string Name) : LegacyUiIntent; + public sealed record LegacySaveOperatorCatalogIntent(string Name) : LegacyUiIntent; public sealed record LegacyDeleteOperatorCatalogIntent : LegacyUiIntent; @@ -336,12 +354,14 @@ public static class LegacyBridgeProtocol "set-all-playlist-enabled" => ParseAllEnabled(payload), "select-playlist-row" => ParsePlaylistSelection(payload), "move-selected-playlist-rows" => ParsePlaylistMove(payload), + "reorder-playlist-rows" => ParsePlaylistReorder(payload), "delete-selected-playlist-rows" => ParseDeleteSelected(payload), "delete-all-playlist-rows" => ParseDeleteAll(payload), "toggle-active-playlist-enabled" => ParseToggleActiveEnabled(payload), "select-playlist-boundary" => ParsePlaylistBoundary(payload), "select-tab" => ParseTabSelection(payload), "swap-tabs" => ParseTabSwap(payload), + "hover-swap-tabs" => ParseTabHoverSwap(payload), "set-moving-averages" => ParseMovingAverages(payload), "activate-fixed-action" => ParseFixedAction(payload), "activate-fixed-section" => ParseFixedSection(payload), @@ -423,9 +443,13 @@ public static class LegacyBridgeProtocol "activate-operator-catalog-stock" => ParseOperatorCatalogStockActivation(payload), "move-operator-catalog-draft-row" => ParseOperatorCatalogDraftMove(payload), + "reorder-operator-catalog-draft-row" => + ParseOperatorCatalogDraftReorder(payload), "remove-operator-catalog-draft-row" => ParseOperatorCatalogDraftRemove(payload), "set-operator-catalog-draft-buy-amount" => ParseOperatorCatalogDraftBuyAmount(payload), + "check-operator-catalog-theme-name" => + ParseOperatorCatalogThemeNameCheck(payload), "save-operator-catalog" => ParseOperatorCatalogSave(payload), "delete-operator-catalog" => ParseOperatorCatalogEmpty( payload, @@ -831,6 +855,8 @@ public static class LegacyBridgeProtocol snapshot.OperatorCatalog.SelectedResultId, snapshot.OperatorCatalog.CodeLabel, snapshot.OperatorCatalog.EditorName, + snapshot.OperatorCatalog.ThemeNameAvailability, + snapshot.OperatorCatalog.CheckedThemeName, draftRows = snapshot.OperatorCatalog.DraftRows.Select(row => new { row.RowId, @@ -853,6 +879,7 @@ public static class LegacyBridgeProtocol snapshot.OperatorCatalog.SchemaValidated, snapshot.OperatorCatalog.WritesQuarantined, snapshot.OperatorCatalog.CanBeginCreate, + snapshot.OperatorCatalog.CanCheckThemeName, snapshot.OperatorCatalog.CanSave, snapshot.OperatorCatalog.CanDelete, snapshot.OperatorCatalog.CanAddStock, @@ -1136,6 +1163,25 @@ public static class LegacyBridgeProtocol : null; } + private static LegacyUiIntent? ParsePlaylistReorder(JsonElement payload) + { + if (!HasOnlyProperties(payload, "sourceRowId", "targetRowId", "position") || + !TryString(payload, "sourceRowId", 128, out var sourceRowId) || + !TryString(payload, "targetRowId", 128, out var targetRowId) || + !IsSafeIdentifier(sourceRowId) || + !IsSafeIdentifier(targetRowId) || + string.Equals(sourceRowId, targetRowId, StringComparison.Ordinal) || + !TryString(payload, "position", 8, out var positionText)) + { + return null; + } + + var position = ParseDragDropPosition(positionText); + return position.HasValue + ? new LegacyReorderPlaylistRowsIntent(sourceRowId, targetRowId, position.Value) + : null; + } + private static LegacyUiIntent? ParseDeleteSelected(JsonElement payload) => HasOnlyProperties(payload) ? new LegacyDeleteSelectedPlaylistRowsIntent() : null; @@ -1174,6 +1220,27 @@ public static class LegacyBridgeProtocol ? new LegacySwapTabsIntent(source, target) : null; + private static LegacyUiIntent? ParseTabHoverSwap(JsonElement payload) + { + if (!HasOnlyProperties( + payload, + "source", + "target", + "expectedSourceIndex", + "expectedTargetIndex") || + !TryTabId(payload, "source", out var source) || + !TryTabId(payload, "target", out var target) || + source == target || + !TryInt32(payload, "expectedSourceIndex", 0, 9, out var sourceIndex) || + !TryInt32(payload, "expectedTargetIndex", 0, 9, out var targetIndex) || + sourceIndex == targetIndex) + { + return null; + } + + return new LegacyHoverSwapTabsIntent(source, target, sourceIndex, targetIndex); + } + private static LegacyUiIntent? ParseMovingAverages(JsonElement payload) => HasOnlyProperties(payload, "ma5", "ma20") && TryBoolean(payload, "ma5", out var ma5) && @@ -1630,6 +1697,28 @@ public static class LegacyBridgeProtocol : null; } + private static LegacyUiIntent? ParseOperatorCatalogDraftReorder(JsonElement payload) + { + if (!HasOnlyProperties(payload, "sourceRowId", "targetRowId", "position") || + !TryString(payload, "sourceRowId", 128, out var sourceRowId) || + !TryString(payload, "targetRowId", 128, out var targetRowId) || + !IsSafeIdentifier(sourceRowId) || + !IsSafeIdentifier(targetRowId) || + string.Equals(sourceRowId, targetRowId, StringComparison.Ordinal) || + !TryString(payload, "position", 8, out var positionText)) + { + return null; + } + + var position = ParseDragDropPosition(positionText); + return position.HasValue + ? new LegacyReorderOperatorCatalogDraftRowIntent( + sourceRowId, + targetRowId, + position.Value) + : null; + } + private static LegacyUiIntent? ParseOperatorCatalogDraftRemove(JsonElement payload) => HasOnlyProperties(payload, "rowId") && TryString(payload, "rowId", 128, out var rowId) && @@ -1662,6 +1751,19 @@ public static class LegacyBridgeProtocol ? new LegacySaveOperatorCatalogIntent(name) : null; + private static LegacyUiIntent? ParseOperatorCatalogThemeNameCheck(JsonElement payload) => + HasOnlyProperties(payload, "name") && + TrySafeString( + payload, + "name", + 128, + allowEmpty: false, + allowUnderscore: true, + out var name) && + string.Equals(name, name.Trim(), StringComparison.Ordinal) + ? new LegacyCheckOperatorCatalogThemeNameIntent(name) + : null; + private static LegacyUiIntent? ParseNamedPlaylistEmpty( JsonElement payload, Func factory) => @@ -2361,6 +2463,13 @@ public static class LegacyBridgeProtocol value >= minimum && value <= maximum; } + private static LegacyDragDropPosition? ParseDragDropPosition(string value) => value switch + { + "before" => LegacyDragDropPosition.Before, + "after" => LegacyDragDropPosition.After, + _ => null + }; + private static bool TryInt64( JsonElement element, string name, diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs index b62ab8a..8dbdab6 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/App.xaml.cs @@ -1,4 +1,5 @@ using Microsoft.Windows.AppLifecycle; +using MBN_STOCK_WEBVIEW.Playout.Configuration; namespace MBN_STOCK_WEBVIEW.LegacyParityApp; @@ -37,6 +38,20 @@ public partial class App : Application _mainInstance = registeredInstance; _mainInstance.Activated += OnMainInstanceActivated; + +#if DEBUG + const bool isDebugBuild = true; +#else + const bool isDebugBuild = false; +#endif + var developmentLive = DevelopmentLiveLaunchBootstrap.TryApply( + DevelopmentLiveLaunchBootstrap.ResolveLaunchArguments( + args.Arguments, + Environment.GetCommandLineArgs()), + isDebugBuild); + System.Diagnostics.Debug.WriteLine( + $"Development Live bootstrap: {developmentLive.Code}"); + _window = new MainWindow(); _window.Activate(); } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs index 89c92ac..0aff3ef 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs @@ -425,6 +425,7 @@ public sealed partial class MainWindow LegacySetPlaylistEnabledIntent or LegacySetAllPlaylistEnabledIntent or LegacyMoveSelectedPlaylistRowsIntent or + LegacyReorderPlaylistRowsIntent or LegacyDeleteSelectedPlaylistRowsIntent or LegacyDeleteAllPlaylistRowsIntent or LegacyToggleActivePlaylistEnabledIntent or @@ -454,6 +455,7 @@ public sealed partial class MainWindow LegacyAddOperatorCatalogStockIntent or LegacyActivateOperatorCatalogStockIntent or LegacyMoveOperatorCatalogDraftRowIntent or + LegacyReorderOperatorCatalogDraftRowIntent or LegacyRemoveOperatorCatalogDraftRowIntent or LegacySetOperatorCatalogDraftBuyAmountIntent or LegacySaveOperatorCatalogIntent or diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs index 5a84bc2..0a422b7 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs @@ -378,6 +378,7 @@ public sealed partial class MainWindow : Window LegacySearchOperatorCatalogStocksIntent or LegacyActivateOperatorCatalogStockIntent or LegacySetOperatorCatalogDraftBuyAmountIntent or + LegacyCheckOperatorCatalogThemeNameIntent or LegacySaveOperatorCatalogIntent or LegacyDeleteOperatorCatalogIntent or LegacyAddComparisonPairIntent or @@ -481,6 +482,11 @@ public sealed partial class MainWindow : Window selection.Shift), LegacyMoveSelectedPlaylistRowsIntent move => _controller.MoveSelectedPlaylistRows(move.Direction), + LegacyReorderPlaylistRowsIntent reorder => + _controller.ReorderPlaylistRows( + reorder.SourceRowId, + reorder.TargetRowId, + reorder.Position), LegacyDeleteSelectedPlaylistRowsIntent => _controller.DeleteSelectedPlaylistRows(), LegacyDeleteAllPlaylistRowsIntent => @@ -496,6 +502,13 @@ public sealed partial class MainWindow : Window tabs.Source, tabs.Target, cancellationToken), + LegacyHoverSwapTabsIntent tabs => + await _controller.SwapTabsAtExpectedPositionsAsync( + tabs.Source, + tabs.Target, + tabs.ExpectedSourceIndex, + tabs.ExpectedTargetIndex, + cancellationToken), LegacySetMovingAveragesIntent movingAverages => _controller.SetMovingAverages(movingAverages.Ma5, movingAverages.Ma20), LegacyActivateFixedActionIntent action => @@ -656,12 +669,21 @@ public sealed partial class MainWindow : Window activation.BuyAmount), LegacyMoveOperatorCatalogDraftRowIntent move => _controller.MoveOperatorCatalogDraftRow(move.RowId, move.Direction), + LegacyReorderOperatorCatalogDraftRowIntent reorder => + _controller.ReorderOperatorCatalogDraftRow( + reorder.SourceRowId, + reorder.TargetRowId, + reorder.Position), LegacyRemoveOperatorCatalogDraftRowIntent remove => _controller.RemoveOperatorCatalogDraftRow(remove.RowId), LegacySetOperatorCatalogDraftBuyAmountIntent amount => _controller.SetOperatorCatalogDraftBuyAmount( amount.RowId, amount.BuyAmount), + LegacyCheckOperatorCatalogThemeNameIntent check => + await _controller.CheckOperatorCatalogThemeNameAsync( + check.Name, + cancellationToken), LegacySaveOperatorCatalogIntent save => await _controller.SaveOperatorCatalogAsync( save.Name, @@ -869,6 +891,12 @@ public sealed partial class MainWindow : Window public bool CanMutate => false; + public Task ThemeNameExistsAsync( + ThemeMarket market, + string themeTitle, + CancellationToken cancellationToken = default) => + Task.FromException(new InvalidOperationException(_message)); + public Task SuggestNextCodeAsync( ThemeMarket market, CancellationToken cancellationToken = default) => diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Properties/launchSettings.json b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Properties/launchSettings.json index e6827a3..eefb321 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Properties/launchSettings.json +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Properties/launchSettings.json @@ -1,7 +1,12 @@ { "profiles": { - "MBN_STOCK_WEBVIEW.LegacyParityApp (Package)": { - "commandName": "MsixPackage" + "MBN_STOCK_WEBVIEW.LegacyParityApp - Development Live (Package)": { + "commandName": "MsixPackage", + "commandLineArgs": "--development-live" + }, + "MBN_STOCK_WEBVIEW.LegacyParityApp - Explicit DryRun (Package)": { + "commandName": "MsixPackage", + "commandLineArgs": "" } } } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js index 5581261..07f87be 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js @@ -61,6 +61,8 @@ const operatorCatalogCode = document.getElementById("operator-catalog-code"); const operatorCatalogNameLabel = document.getElementById("operator-catalog-name-label"); const operatorCatalogName = document.getElementById("operator-catalog-name"); + const operatorCatalogNameCheck = document.getElementById("operator-catalog-name-check"); + const operatorCatalogNameStatus = document.getElementById("operator-catalog-name-status"); const operatorCatalogStockQuery = document.getElementById("operator-catalog-stock-query"); const operatorCatalogStockSearch = document.getElementById("operator-catalog-stock-search"); const operatorCatalogStockResults = document.getElementById("operator-catalog-stock-results"); @@ -87,11 +89,36 @@ let namedPlaylistResultClickTimer = null; let pendingNamedPlaylistCommand = null; let lastCommandSequence = 0; + const selectedCutsDragToken = "legacy-selected-cuts"; + const playlistDragMime = "text/x-mbn-playlist-row"; + const operatorCatalogDragMime = "text/x-mbn-operator-catalog-row"; + let draggedPlaylistRowId = null; + let deferredPlaylistPlainSelectionRowId = null; + let playlistDragBlockedRowId = null; + let playlistMutationLocked = false; + let draggedTabId = null; + let tabDragLastTargetId = null; + let pendingTabHoverSwap = null; + let draggedOperatorCatalogRowId = null; + let operatorCatalogDragBlockedRowId = null; + let operatorCatalogMutationLocked = false; function send(type, payload) { if (webview && !uiBusy) webview.postMessage({ type: type, payload: payload || {} }); } + function isOperatorMutationLocked(state) { + const playout = state && state.playout; + return !state || state.isBusy === true || state.fixedSectionBatch != null || + (playout && (playout.isBusy === true || playout.outcomeUnknown === true || + playout.isPlayCompletionPending === true || playout.phase !== "idle")); + } + + function dragDropPosition(event, element) { + const bounds = element.getBoundingClientRect(); + return event.clientY < bounds.top + (bounds.height / 2) ? "before" : "after"; + } + function clearDelayedClick(timer) { if (timer !== null) window.clearTimeout(timer); } @@ -166,7 +193,7 @@ element.addEventListener("dragstart", function (event) { if (event.dataTransfer) { event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("text/plain", "legacy-selected-cuts"); + event.dataTransfer.setData("text/plain", selectedCutsDragToken); } }); return element; @@ -197,6 +224,83 @@ } } + function clearPlaylistDragVisuals() { + playlistRows.querySelectorAll(".dragging, .drag-before, .drag-after") + .forEach(function (row) { + row.classList.remove("dragging", "drag-before", "drag-after"); + }); + } + + function onPlaylistDragStart(event) { + const row = event.currentTarget; + const rowId = row.dataset.rowId; + if (playlistMutationLocked || !rowId || !event.dataTransfer || + playlistDragBlockedRowId === rowId || event.target.closest("input, button")) { + playlistDragBlockedRowId = null; + event.preventDefault(); + return; + } + + playlistDragBlockedRowId = null; + deferredPlaylistPlainSelectionRowId = null; + draggedPlaylistRowId = rowId; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData(playlistDragMime, rowId); + row.classList.add("dragging"); + } + + function onPlaylistDragOver(event) { + const target = event.currentTarget; + if (playlistMutationLocked || !draggedPlaylistRowId || + target.dataset.rowId === draggedPlaylistRowId) return; + + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; + const position = dragDropPosition(event, target); + target.classList.toggle("drag-before", position === "before"); + target.classList.toggle("drag-after", position === "after"); + } + + function onPlaylistDragLeave(event) { + event.currentTarget.classList.remove("drag-before", "drag-after"); + } + + function onPlaylistDrop(event) { + const target = event.currentTarget; + const sourceRowId = draggedPlaylistRowId; + const targetRowId = target.dataset.rowId; + if (playlistMutationLocked || !sourceRowId || !targetRowId || + sourceRowId === targetRowId || !event.dataTransfer) { + clearPlaylistDragVisuals(); + draggedPlaylistRowId = null; + return; + } + + const token = event.dataTransfer.getData(playlistDragMime); + if (token !== sourceRowId) { + clearPlaylistDragVisuals(); + draggedPlaylistRowId = null; + return; + } + + event.preventDefault(); + const position = dragDropPosition(event, target); + clearPlaylistDragVisuals(); + draggedPlaylistRowId = null; + send("reorder-playlist-rows", { + sourceRowId: sourceRowId, + targetRowId: targetRowId, + position: position + }); + } + + function onPlaylistDragEnd() { + draggedPlaylistRowId = null; + deferredPlaylistPlainSelectionRowId = null; + playlistDragBlockedRowId = null; + clearPlaylistDragVisuals(); + } + function createPlaylistElement(row) { const element = document.createElement("div"); element.className = "playlist-row playlist-data-row"; @@ -222,18 +326,67 @@ } element.addEventListener("pointerdown", function (event) { - if (event.button !== 0 || enabledCell.contains(event.target)) return; + if (event.button !== 0) return; + if (enabledCell.contains(event.target)) { + // Native dragstart targets the nearest draggable ancestor (the row), + // not necessarily the checkbox originally pressed. Remember the + // pointer origin so a checkbox gesture cannot reorder the row. + playlistDragBlockedRowId = element.dataset.rowId; + return; + } + playlistDragBlockedRowId = null; element.focus({ preventScroll: true }); + const plainSelection = !event.ctrlKey && !event.shiftKey; + const preserveSelectedBlock = plainSelection && + element.getAttribute("aria-selected") === "true" && + playlistRows.querySelectorAll('.playlist-data-row[aria-selected="true"]').length > 1; + if (preserveSelectedBlock) { + deferredPlaylistPlainSelectionRowId = element.dataset.rowId; + return; + } + + deferredPlaylistPlainSelectionRowId = null; send("select-playlist-row", { rowId: element.dataset.rowId, control: event.ctrlKey, shift: event.shiftKey }); }); + element.addEventListener("pointerup", function (event) { + if (enabledCell.contains(event.target)) { + playlistDragBlockedRowId = null; + return; + } + if (event.button !== 0 || + deferredPlaylistPlainSelectionRowId !== element.dataset.rowId) return; + deferredPlaylistPlainSelectionRowId = null; + send("select-playlist-row", { + rowId: element.dataset.rowId, + control: false, + shift: false + }); + }); + element.addEventListener("pointercancel", function () { + if (playlistDragBlockedRowId === element.dataset.rowId) { + playlistDragBlockedRowId = null; + } + }); + element.addEventListener("dragstart", onPlaylistDragStart); + element.addEventListener("dragover", onPlaylistDragOver); + element.addEventListener("dragleave", onPlaylistDragLeave); + element.addEventListener("drop", onPlaylistDrop); + element.addEventListener("dragend", onPlaylistDragEnd); return element; } function renderPlaylist(state) { + playlistMutationLocked = isOperatorMutationLocked(state); + if (playlistMutationLocked) { + draggedPlaylistRowId = null; + deferredPlaylistPlainSelectionRowId = null; + playlistDragBlockedRowId = null; + clearPlaylistDragVisuals(); + } const existingRows = new Map(); Array.from(playlistRows.children).forEach(function (element) { existingRows.set(element.dataset.rowId, element); @@ -257,6 +410,8 @@ element.className = "playlist-row playlist-data-row" + (row.isSelected ? " selected" : ""); element.setAttribute("aria-selected", row.isSelected ? "true" : "false"); + element.draggable = !playlistMutationLocked; + element.setAttribute("aria-disabled", playlistMutationLocked ? "true" : "false"); [row.marketText, row.stockName, row.graphicType, row.subtype, row.pageText] .forEach(function (text, cellIndex) { const cell = element.children.item(cellIndex + 1); @@ -281,6 +436,59 @@ playlistDeleteAll.disabled = interactionDisabled; } + function tabOrderFromDom() { + return Array.from(marketTabs.querySelectorAll("button[data-tab-id]")) + .map(function (button) { return button.dataset.tabId; }); + } + + function clearPendingTabHoverSwap() { + if (pendingTabHoverSwap && pendingTabHoverSwap.timeoutId !== null) { + window.clearTimeout(pendingTabHoverSwap.timeoutId); + } + pendingTabHoverSwap = null; + } + + function acknowledgePendingTabHoverSwap(order) { + if (!pendingTabHoverSwap) return; + const orderKey = order.join("|"); + if (orderKey === pendingTabHoverSwap.expectedOrderKey || + orderKey !== pendingTabHoverSwap.initialOrderKey) { + clearPendingTabHoverSwap(); + } + } + + function requestTabHoverSwap(targetId) { + if (uiBusy || !draggedTabId || !targetId || draggedTabId === targetId || + pendingTabHoverSwap) return; + + const order = tabOrderFromDom(); + const sourceIndex = order.indexOf(draggedTabId); + const targetIndex = order.indexOf(targetId); + if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return; + + const expectedOrder = order.slice(); + expectedOrder[sourceIndex] = targetId; + expectedOrder[targetIndex] = draggedTabId; + const pending = { + initialOrderKey: order.join("|"), + expectedOrderKey: expectedOrder.join("|"), + timeoutId: null + }; + pending.timeoutId = window.setTimeout(function () { + if (pendingTabHoverSwap === pending) { + pendingTabHoverSwap = null; + tabDragLastTargetId = null; + } + }, 500); + pendingTabHoverSwap = pending; + send("hover-swap-tabs", { + source: draggedTabId, + target: targetId, + expectedSourceIndex: sourceIndex, + expectedTargetIndex: targetIndex + }); + } + function renderTabs(state) { if (!Array.isArray(state.tabs)) return; const existingTabs = new Map(); @@ -298,6 +506,8 @@ button.disabled = state.isBusy === true || state.fixedSectionBatch != null; button.draggable = state.isBusy !== true && state.fixedSectionBatch == null; }); + acknowledgePendingTabHoverSwap( + state.tabs.map(function (tab) { return tab.id; })); } function renderFixedCatalog(state) { @@ -1528,19 +1738,116 @@ (namedPlaylistMode === "save" ? named.canSave !== true : named.canLoad !== true); } + function clearOperatorCatalogDragVisuals() { + operatorCatalogDraftRows.querySelectorAll(".dragging, .drag-before, .drag-after") + .forEach(function (row) { + row.classList.remove("dragging", "drag-before", "drag-after"); + }); + } + + function onOperatorCatalogPointerDown(event) { + if (event.button !== 0) return; + const rowId = event.currentTarget.dataset.operatorCatalogRowId; + operatorCatalogDragBlockedRowId = event.target.closest("button, input") + ? rowId + : null; + } + + function onOperatorCatalogPointerEnd(event) { + if (operatorCatalogDragBlockedRowId === + event.currentTarget.dataset.operatorCatalogRowId) { + operatorCatalogDragBlockedRowId = null; + } + } + + function onOperatorCatalogDragStart(event) { + const row = event.currentTarget; + const sourceRowId = row.dataset.operatorCatalogRowId; + if (operatorCatalogMutationLocked || !sourceRowId || !event.dataTransfer || + operatorCatalogDragBlockedRowId === sourceRowId || + event.target.closest("button, input")) { + operatorCatalogDragBlockedRowId = null; + event.preventDefault(); + return; + } + + operatorCatalogDragBlockedRowId = null; + draggedOperatorCatalogRowId = sourceRowId; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData(operatorCatalogDragMime, sourceRowId); + row.classList.add("dragging"); + } + + function onOperatorCatalogDragOver(event) { + const target = event.currentTarget; + if (operatorCatalogMutationLocked || !draggedOperatorCatalogRowId || + target.dataset.operatorCatalogRowId === draggedOperatorCatalogRowId) return; + + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; + const position = dragDropPosition(event, target); + target.classList.toggle("drag-before", position === "before"); + target.classList.toggle("drag-after", position === "after"); + } + + function onOperatorCatalogDragLeave(event) { + event.currentTarget.classList.remove("drag-before", "drag-after"); + } + + function onOperatorCatalogDrop(event) { + const target = event.currentTarget; + const sourceRowId = draggedOperatorCatalogRowId; + const targetRowId = target.dataset.operatorCatalogRowId; + if (operatorCatalogMutationLocked || !sourceRowId || !targetRowId || + sourceRowId === targetRowId || !event.dataTransfer || + event.dataTransfer.getData(operatorCatalogDragMime) !== sourceRowId) { + draggedOperatorCatalogRowId = null; + clearOperatorCatalogDragVisuals(); + return; + } + + event.preventDefault(); + const position = dragDropPosition(event, target); + draggedOperatorCatalogRowId = null; + clearOperatorCatalogDragVisuals(); + send("reorder-operator-catalog-draft-row", { + sourceRowId: sourceRowId, + targetRowId: targetRowId, + position: position + }); + } + + function onOperatorCatalogDragEnd() { + draggedOperatorCatalogRowId = null; + operatorCatalogDragBlockedRowId = null; + clearOperatorCatalogDragVisuals(); + } + function renderOperatorCatalog(state) { const catalog = state.operatorCatalog; const busy = state.isBusy === true; + operatorCatalogMutationLocked = isOperatorMutationLocked(state); operatorCatalogState = catalog || null; if (!catalog || catalog.isOpen !== true) { + draggedOperatorCatalogRowId = null; + operatorCatalogDragBlockedRowId = null; + clearOperatorCatalogDragVisuals(); clearDelayedClick(operatorCatalogStockClickTimer); operatorCatalogStockClickTimer = null; operatorCatalogModal.hidden = true; operatorCatalogDraftKey = ""; operatorCatalogNameDraft = ""; + operatorCatalogNameStatus.textContent = ""; + operatorCatalogNameStatus.className = ""; return; } + if (operatorCatalogMutationLocked) { + draggedOperatorCatalogRowId = null; + operatorCatalogDragBlockedRowId = null; + clearOperatorCatalogDragVisuals(); + } + operatorCatalogModal.hidden = false; const isTheme = catalog.entity === "theme"; const editing = catalog.mode === "create" || catalog.mode === "edit"; @@ -1599,6 +1906,27 @@ operatorCatalogNameLabel.textContent = isTheme ? "테마명" : "전문가명"; operatorCatalogName.placeholder = isTheme ? "ThemeA 이름" : "전문가 이름"; operatorCatalogName.disabled = busy || !editing; + const currentThemeName = operatorCatalogNameDraft.trim(); + const checkedCurrentThemeName = isTheme && currentThemeName.length > 0 && + catalog.checkedThemeName === currentThemeName; + const themeNameAvailability = checkedCurrentThemeName + ? catalog.themeNameAvailability + : "notChecked"; + operatorCatalogNameCheck.hidden = !isTheme; + operatorCatalogNameCheck.disabled = busy || !isTheme || !editing || + catalog.canCheckThemeName !== true || currentThemeName.length === 0; + operatorCatalogNameStatus.hidden = !isTheme; + operatorCatalogNameStatus.className = themeNameAvailability === "available" || + themeNameAvailability === "duplicate" || themeNameAvailability === "indeterminate" + ? themeNameAvailability + : ""; + operatorCatalogNameStatus.textContent = themeNameAvailability === "available" + ? "사용 가능" + : themeNameAvailability === "duplicate" + ? "이미 사용 중" + : themeNameAvailability === "indeterminate" + ? "확인 불가" + : ""; if (document.activeElement !== operatorCatalogStockQuery) { operatorCatalogStockQuery.value = catalog.stockQuery || ""; } @@ -1632,6 +1960,19 @@ (catalog.draftRows || []).forEach(function (row, index) { const line = document.createElement("div"); line.className = "operator-catalog-draft-row"; + line.dataset.operatorCatalogRowId = row.rowId; + line.draggable = !operatorCatalogMutationLocked; + line.setAttribute( + "aria-disabled", + operatorCatalogMutationLocked ? "true" : "false"); + line.addEventListener("pointerdown", onOperatorCatalogPointerDown); + line.addEventListener("pointerup", onOperatorCatalogPointerEnd); + line.addEventListener("pointercancel", onOperatorCatalogPointerEnd); + line.addEventListener("dragstart", onOperatorCatalogDragStart); + line.addEventListener("dragover", onOperatorCatalogDragOver); + line.addEventListener("dragleave", onOperatorCatalogDragLeave); + line.addEventListener("drop", onOperatorCatalogDrop); + line.addEventListener("dragend", onOperatorCatalogDragEnd); const order = document.createElement("span"); order.textContent = String(row.position + 1); const name = document.createElement("strong"); @@ -2012,6 +2353,25 @@ }); operatorCatalogName.addEventListener("input", function () { operatorCatalogNameDraft = operatorCatalogName.value; + operatorCatalogNameStatus.textContent = ""; + operatorCatalogNameStatus.className = ""; + operatorCatalogNameCheck.disabled = uiBusy || !operatorCatalogState || + operatorCatalogState.entity !== "theme" || + (operatorCatalogState.mode !== "create" && operatorCatalogState.mode !== "edit") || + operatorCatalogState.canCheckThemeName !== true || + operatorCatalogName.value.trim().length === 0; + }); + operatorCatalogNameCheck.addEventListener("click", function () { + const name = operatorCatalogName.value.trim(); + if (!name) { + operatorCatalogName.setCustomValidity("이름을 입력하세요."); + operatorCatalogName.reportValidity(); + return; + } + + operatorCatalogName.setCustomValidity(""); + operatorCatalogNameDraft = name; + send("check-operator-catalog-theme-name", { name: name }); }); function searchOperatorCatalogStocks() { const query = operatorCatalogStockQuery.value.trim(); @@ -2119,12 +2479,28 @@ searchOperatorCatalog(); }); + function hasDragType(dataTransfer, expectedType) { + return !!dataTransfer && Array.from(dataTransfer.types || []) + .some(function (type) { return type === expectedType; }); + } + + function isSelectedCutsDrop(dataTransfer) { + return hasDragType(dataTransfer, "text/plain") && + dataTransfer.getData("text/plain") === selectedCutsDragToken; + } + playlistDropZone.addEventListener("dragover", function (event) { + // A playlist row also drops inside this ancestor. Only advertise the + // drop zone for the cut-list payload; otherwise the bubbled row drop + // would append the still-selected cut as an extra playlist entry. + if (!hasDragType(event.dataTransfer, "text/plain")) return; event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; }); playlistDropZone.addEventListener("drop", function (event) { + if (!isSelectedCutsDrop(event.dataTransfer)) return; event.preventDefault(); + event.stopPropagation(); send("drop-selected-cuts", {}); }); playlistMoveUp.addEventListener("click", function () { @@ -2155,20 +2531,43 @@ marketTabs.addEventListener("dragstart", function (event) { const button = event.target.closest("button[data-tab-id]"); if (!button || !event.dataTransfer) return; + clearPendingTabHoverSwap(); + draggedTabId = button.dataset.tabId; + tabDragLastTargetId = null; event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("text/x-mbn-tab", button.dataset.tabId); + event.dataTransfer.setData("text/x-mbn-tab", draggedTabId); + button.classList.add("dragging"); }); marketTabs.addEventListener("dragover", function (event) { - if (event.target.closest("button[data-tab-id]")) event.preventDefault(); + const target = event.target.closest("button[data-tab-id]"); + if (!target || !draggedTabId) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; + const targetId = target.dataset.tabId; + if (targetId === draggedTabId) { + tabDragLastTargetId = null; + return; + } + if (tabDragLastTargetId === targetId) return; + if (pendingTabHoverSwap) return; + tabDragLastTargetId = targetId; + requestTabHoverSwap(targetId); }); marketTabs.addEventListener("drop", function (event) { const target = event.target.closest("button[data-tab-id]"); - if (!target || !event.dataTransfer) return; + if (!target || !event.dataTransfer || !draggedTabId) return; event.preventDefault(); - const source = event.dataTransfer.getData("text/x-mbn-tab"); - if (source && source !== target.dataset.tabId) { - send("swap-tabs", { source: source, target: target.dataset.tabId }); - } + if (event.dataTransfer.getData("text/x-mbn-tab") !== draggedTabId) return; + draggedTabId = null; + tabDragLastTargetId = null; + marketTabs.querySelectorAll("button.dragging") + .forEach(function (button) { button.classList.remove("dragging"); }); + }); + marketTabs.addEventListener("dragend", function () { + draggedTabId = null; + tabDragLastTargetId = null; + marketTabs.querySelectorAll("button.dragging") + .forEach(function (button) { button.classList.remove("dragging"); }); }); function sendMovingAverages() { send("set-moving-averages", { @@ -2498,6 +2897,10 @@ send("search-trading-halts", { query: event.target.value }); } }); + window.addEventListener("pointerup", function () { + deferredPlaylistPlainSelectionRowId = null; + operatorCatalogDragBlockedRowId = null; + }); document.addEventListener("keydown", function (event) { if (event.key === "Delete" && !event.repeat && dialog.hidden && manualModal.hidden && manualListModal.hidden && namedPlaylistModal.hidden && operatorCatalogModal.hidden) { diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html index 34cbaf5..9079185 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html @@ -196,10 +196,12 @@

추가·편집

-