diff --git a/LegacySceneRuntimeFactory.cs b/LegacySceneRuntimeFactory.cs index 63d7687..c2508b1 100644 --- a/LegacySceneRuntimeFactory.cs +++ b/LegacySceneRuntimeFactory.cs @@ -33,9 +33,6 @@ internal static class LegacySceneRuntimeFactory var imageQuoteLoader = new S6001SceneDataLoader(executor); var worldMapLoader = new S8067SceneDataLoader(executor); var candleLoader = new S8010SceneDataLoader(executor); - var fiveRowLoader = new S5074SceneDataLoader(executor); - var sixRowLoader = new S5077SceneDataLoader(executor); - var twelveRowLoader = new S5088SceneDataLoader(executor); var parameterizedRequestResolver = new LegacyParameterizedSceneRequestResolver(executor); var oneColumnLoader = new S5001SceneDataLoader(executor); var futuresPairLoader = new S5032SceneDataLoader(executor); @@ -359,6 +356,46 @@ internal static class LegacySceneRuntimeFactory cancellationToken).ConfigureAwait(false); return new LegacySceneDataPage("s8010", data, 1); }), + .. CreatePagedQuoteRoutes(executor, trustedViSource) + ]); + + LegacySceneRuntimeCoverage.Validate(dataSource); + if (compositionOptions is not null && compositionOptionsSource is not null) + { + throw new ArgumentException( + "Specify either fixed or mutable scene composition options, not both."); + } + + var cueProvider = compositionOptionsSource is null + ? new RegisteredLegacySceneCueProvider(dataSource, options: compositionOptions) + : new RegisteredLegacySceneCueProvider(dataSource, compositionOptionsSource); + return new LegacyPlayoutWorkflow(engine, cueProvider); + } + + /// + /// Creates the read-only PageN boundary used when a fixed 5-, 6-, or 12-row + /// action is first added to the operator playlist. This path does not create a + /// cue, dispatch an engine command, or touch COM/vendor objects. + /// + public static ILegacyScenePagePlanProvider CreatePagePlanProvider( + IDataQueryExecutor executor, + ITrustedViStockNameSnapshotSource? trustedViSource = null) + { + ArgumentNullException.ThrowIfNull(executor); + var dataSource = new LegacySceneDataSourceRouter( + CreatePagedQuoteRoutes(executor, trustedViSource)); + return new RegisteredLegacySceneCueProvider(dataSource); + } + + private static IReadOnlyList CreatePagedQuoteRoutes( + IDataQueryExecutor executor, + ITrustedViStockNameSnapshotSource? trustedViSource) + { + var fiveRowLoader = new S5074SceneDataLoader(executor); + var sixRowLoader = new S5077SceneDataLoader(executor); + var twelveRowLoader = new S5088SceneDataLoader(executor); + return + [ new LegacySceneDataSourceRoute(["5074"], async (entry, page, cancellationToken) => { var selection = RequireSelection(entry); @@ -412,19 +449,7 @@ internal static class LegacySceneRuntimeFactory .ConfigureAwait(false); return new LegacySceneDataPage("s5088", data, data.Rows.Count); }) - ]); - - LegacySceneRuntimeCoverage.Validate(dataSource); - if (compositionOptions is not null && compositionOptionsSource is not null) - { - throw new ArgumentException( - "Specify either fixed or mutable scene composition options, not both."); - } - - var cueProvider = compositionOptionsSource is null - ? new RegisteredLegacySceneCueProvider(dataSource, options: compositionOptions) - : new RegisteredLegacySceneCueProvider(dataSource, compositionOptionsSource); - return new LegacyPlayoutWorkflow(engine, cueProvider); + ]; } private static LegacySceneSelection RequireSelection(LegacyPlaylistEntry entry) => @@ -532,6 +557,9 @@ internal static class LegacySceneRuntimeFactory return value; } + private static string ResolveNxtThemeLoaderTitle(LegacySceneSelection selection) + => RequireLookupValue(selection.Subject, "NXT theme"); + private static bool HasClosedFlag(string value, string flag) => value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) .Contains(flag, StringComparer.Ordinal); @@ -565,16 +593,17 @@ internal static class LegacySceneRuntimeFactory page), "PAGED_THEME" => new ThemePagedQuoteLoadRequest( RequireLookupValue(selection.DataCode, "theme code"), - RequireLookupValue(selection.Subject, "theme"), + RequireLookupValue(selection.ExactSubject ?? selection.Subject, "theme"), ParseThemeSort(selection.GraphicType), ParseThemeValueKind(selection.Subtype), page), "PAGED_NXT_THEME" => new NxtThemePagedQuoteLoadRequest( RequireLookupValue(selection.DataCode, "NXT theme code"), - RequireLookupValue(selection.Subject, "NXT theme"), + ResolveNxtThemeLoaderTitle(selection), ParseThemeSort(selection.Subtype), ParseNxtSession(selection.GraphicType), - page), + page, + selection.ExactSubject), "PAGED_EXPERT" => new ExpertPagedQuoteLoadRequest( RequireLookupValue(selection.DataCode, "expert code"), RequireLookupValue(selection.Subject, "expert"), diff --git a/docs/LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md b/docs/LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md index 3dcb43c..f373742 100644 --- a/docs/LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md +++ b/docs/LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md @@ -7,6 +7,11 @@ Designer가 구독하는 이벤트이며, 선언만 있고 구독되지 않은 Designer 구독은 186개이고, 같은 핸들러를 여러 컨트롤에 연결한 구독을 화면별로 합치면 171개다. 이 문서의 상세 목록은 그 171개를 한 번씩 모두 분류한다. +원본 `timerAliveChecker`는 Designer 구독이 아니다. `MainForm` 생성자 코드가 10초 interval의 +WinForms Timer를 만들고 `timerAliveChecker_Tick`을 동적으로 구독한다. 따라서 186/171 집계에 +억지로 더하지 않고 별도 활성 이벤트로 감사한다. 이 보충 경로도 현재 LegacyParityApp에 +연결됐으므로, 현행 판정에는 미이관 Designer 이벤트나 미이관 동적 활성 이벤트가 없다. + ## 분류 기준 - `IMPL`: 원본의 활성 업무 동작이 C# 소유 상태/워크플로, 엄격한 WebView 브리지 intent, @@ -159,6 +164,26 @@ UC5는 20개 구독이 9개 핸들러를, UC7은 7개 구독이 6개 핸들러 `listView1_MouseDown@2349`, `listView1_MouseMove@2350`, `listView1_MouseUp@2351` - `SAFETY (1)`: `MainForm_FormClosing@2469` +원본 PROGRAM 중 enabled 변경에는 입력 경로별 차이가 있다. `PlayList_CellClick`은 `m_TakeIn`이면 +checkbox 클릭을 거부하지만, `PlayList_KeyPress`의 Space와 `checkBox1_CheckedChanged`의 전체 +변경은 같은 guard가 없어 다음 송출 행을 건너뛰는 데 쓰일 수 있다. 새 앱은 PROGRAM 중 이미 +진행한 prefix와 현재 on-air 행을 고정하고 **미래 행만** Space/전체 enabled 변경을 허용한다. +원본의 운영 편의는 보존하면서 재생 이력과 현재 행을 바꾸지 않는 안전 적응이며 자동 계약 검증은 +완료됐다. 이 변경을 포함한 최신 AppX의 물리 입력 재검증은 아직 대기 중이다. + +## Designer 밖 동적 활성 이벤트 + +- 원본 `MainForm.cs:82-89`의 `timerAliveChecker`는 10초마다 DB 연결 끊김·복구를 확인하고 + 상태 전이 때 로그를 남긴다. Designer 파일에 없다는 이유로 이전 186/171 집계에서 제외된 + 것이며, 비활성 코드가 아니다. +- 현재 `MainWindow.xaml.cs`는 창 초기화 뒤 `StartDatabaseHealthMonitor()`를 호출한다. + `MainWindow.DatabaseHealth.cs`는 같은 10초 첫 지연/주기를 사용해 Oracle/MariaDB를 점검하고 + 전이 때만 native operator log를 남긴다. +- 운영자 DB 입력·송출·자동 갱신이 시작되면 active health check를 취소하고 공용 DB activity + gate를 양보하며, 앱 종료 때 lifetime token과 active check를 모두 취소한다. +- 실제 DB 서버를 강제로 끊는 장애 주입은 하지 않았다. 수명주기·전이 중복 억제·priority gate· + cancellation은 자동 회귀로 고정한다. + ## ThemeA `중복확인` 동등성 원본 `Form/ThemeA.cs:84-109`는 입력 이름과 현재 KRX/NXT 선택을 기준으로 정확히 같은 diff --git a/docs/LEGACY_FEATURE_AUDIT.md b/docs/LEGACY_FEATURE_AUDIT.md index 7dfe2af..481deb1 100644 --- a/docs/LEGACY_FEATURE_AUDIT.md +++ b/docs/LEGACY_FEATURE_AUDIT.md @@ -1,7 +1,7 @@ # 원본 기능 재감사 기준표 감사 기준일: 2026-07-15 -현행 판정 갱신: 2026-07-18 +현행 판정 갱신: 2026-07-22 > 현재 완료·차단·외부 입력 범위는 [`MIGRATION_STATUS.md`](MIGRATION_STATUS.md)가 > 우선한다. 2026-07-15 패키지·연결 설명은 당시 감사 증거로 보존한다. @@ -18,10 +18,23 @@ WebView2에 표시하는 x64 Windows 데스크톱/MSIX 앱이다. 초기 프로토타입 문서의 “미이관 버튼 disabled”, “Tornado2에 연결하지 않음”, “DB write 없음”은 현재 구현 상태가 아니다. 원본 UI 이벤트는 LegacyParityApp → strict bridge → -C# workflow로 연결된다. 2026-07-18에는 원본의 마지막 미연결 활성 이벤트였던 10초 DB -상태 감시를 연결했고, 개발 MSIX 전체 UI/DB와 실제 PGM runtime route 32/34를 검증했다. -미검증 두 route는 원본에도 없는 외부 영상 자산 때문에 fail closed한다. 기본 송출 모드는 -계속 `DryRun`이다. +C# workflow로 연결된다. 2026-07-18에는 Designer가 아닌 `MainForm` 생성자에서 동적으로 +구독되던 10초 DB 상태 감시를 LegacyParityApp 수명주기에 연결했고, 개발 MSIX 전체 UI/DB와 +실제 PGM runtime route 32/34를 검증했다. +2026-07-21에는 실제 DB의 전체 동적 목록을 다시 대조해 고정 결과 상한, 과도한 문자열 정규화, +활성 preview와 원문 편집의 혼용을 제거했다. 기존 행은 목록과 편집 화면에 보존하고, 송출 직전 +fresh identity·payload 검증은 별도 경계로 유지한다. +2026-07-22에는 당시 등록 개발 패키지에서 실제 Windows 입력 여섯 회차를 실행해 +UC1~UC7 전체 버튼, 350ms double-click, drag, 키보드/검색, DB CRUD·fresh read·cleanup과 +선택 행 기준 DryRun NEXT를 확인했다. blocked·state violation·warning은 전 회차 0이다. +그 뒤 즉시 행 선택, PROGRAM 중 미래 행 Space/전체 enabled 변경, ThemeA/UC6 빈 검색 전체 +조회, 비교쌍 명시적 import와 설정 폴더 검사를 교정해 자동 검증을 통과했다. 이 변경을 포함한 +최신 AppX의 실제 물리 입력·DB cleanup·로컬 상태 회차는 아직 재실행 전이다. +최신 결합 코드의 Core/Paged는 각각 `LATEST-CORE-01` 6/6과 `LATEST-PAGED-04` 24/24로 +실제 PGM 재검증을 마쳤다. `s6001` 영상 비의존 4 action/18-stage workflow는 구현·static +PASS지만 실제 PGM은 아직 실행하지 않았다. `s5006` 큐브 배경과 `s6001` 국가 영상 13개를 +쓰는 action은 원본에도 없는 외부 제작 자산 때문에 fail closed한다. 기본 송출 모드는 계속 +`DryRun`이다. ## 상태 정의 @@ -29,7 +42,7 @@ C# workflow로 연결된다. 2026-07-18에는 원본의 마지막 미연결 활 |---|---| | `완료` | 현재 LegacyParityApp 코드와 표에 명시한 자동/통합 검증에 반영됨. package/Live 여부는 각 행의 증적 범위로 별도 판정 | | `부분 실측` | 현재 package 또는 Live의 표에 명시한 범위만 실제 확인했으며 scene/PGM 전체 통합으로 확대할 수 없음 | -| `현재 빌드 실측 제외` | 구현 경로는 있으나 현재 병렬 변경을 합친 package-context/CDP/PGM 결과를 아직 이 감사의 성공 증거로 채택하지 않음 | +| `최종 재검증 대기` | 구현·자동 검증 또는 고정된 이전 실측은 있으나 최신 결합 AppX/PGM의 물리 실행을 아직 성공 증거로 채택하지 않음 | | `외부자산 필요` | 코드로 만들 수 없는 Git/MSIX 밖의 승인 영상·배경이 제공되지 않음 | | `외부 데이터 정합성` | 현재 DB view/data가 원본 규칙의 fresh identity를 공급하지 않아 추정 없이 차단함 | @@ -37,30 +50,30 @@ C# workflow로 연결된다. 2026-07-18에는 원본의 마지막 미연결 활 | ID | 우선순위 | 기능 | 상태 | 현재 판정과 증적 | 남은 경계 | |---|---|---|---|---|---| -| LF-001 | P0 | `s6001` 해외지수 영상 | 외부자산 필요 | builder와 trusted asset 검사는 완료. 승인 Cuts에 국가 영상 13개가 없음 | 제공 후 asset preflight와 해당 scene PGM | -| 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-001 | P0 | `s6001` 해외지수/원자재 | 최종 재검증 대기 | 국가 영상 13개 action은 외부자산 필요. 두바이유·WTI·브렌트유·금 4 action은 필수 built-in 이미지 2개를 쓰는 18-stage workflow 구현·static PASS | 영상 비의존 4 action 실제 PGM. 국가 영상 action은 자산 제공 후 별도 PGM | +| LF-002 | P0 | cut/asset coverage | 완료 | 설정 Cuts root는 active alias 45개 `.t2s`와 필수 built-in asset 9개를 모두 요구한다. optional 외부 asset 14개 누락은 수용하되 영향 action만 제한하며 빈 파일·reparse·중간 junction·root 이탈은 거부한다 | 누락 자산 자체는 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 최종 통합 | 부분 실측 | 5001/5074 재검증과 자산 비의존 30개 route를 실제 PGM에서 순차 검증. 5077 20페이지 전체, 5088 전환, 이전/final unload 포함. `FAILURE=0`, `ERROR=0`, retry 0, `OutcomeUnknown=false` | 외부자산이 없는 s5006/s6001만 제공 후 실제 PGM 필요 | +| LF-005 | P0 | 개발 Tornado2/PGM 최종 통합 | 부분 실측 | 최신 `LATEST-CORE-01`의 5001/5074 6/6, 분할 회차의 자산 비의존 30개 route 30/30, 최신 `LATEST-PAGED-04`의 5077 20페이지·5088 전환·이전/final unload 24/24를 실제 PGM에서 확인. `FAILURE=0`, `ERROR=0`, retry 0, `OutcomeUnknown=false` | s6001 영상 비의존 4 action 실제 PGM 대기. s5006과 s6001 국가 영상 action은 외부자산 제공 전 제한 | | LF-006 | P0 | 원본 35 scene | 완료 | 원본 inventory와 source 기준선 35/35, DTO→mutation→runtime matrix 통과 | 모든 scene의 실제 PGM 완료로 확대 해석 금지 | -| LF-007 | P0 | PageN/NEXT | 완료 | 자동 테스트와 실제 PGM에서 5077 6행 1/20→20/20, 마지막 페이지, playlist NEXT, 5088 12행 1/20 전환을 확인 | 없음 | +| LF-007 | P0 | PageN/NEXT | 완료 | 자동 테스트와 최신 `LATEST-PAGED-04` 실제 PGM에서 5077 6행 1/20→20/20, 마지막 페이지, playlist NEXT, 5088 12행 1/20 전환을 24/24로 확인. `LATEST-CORE-01`의 5074 page 1→2도 6/6 회차에 포함 | 없음 | | 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, 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-010 | P1 | 10개 탭과 action inventory | 완료 | 10탭, fixed 328, 업종 44, 종목 31, 비교 target 24/action 9를 폐쇄형으로 고정. 직전 `screens` 회차에서 UC1 leaf와 parent double-click을 실제 입력해 parent section action 수와 native intent 1건을 대조했다 | 동적 DB 행은 별도 검증 | +| LF-011 | P1 | UC4/UC6/UC7 클릭 | 최종 재검증 대기 | 테마 1,747개, 전문가 6명/추천 32행, 거래정지 112행을 실제 DB와 대조. 직전 `screens` 회차에서 검색·행 선택·정렬·350ms double-click·모든 action을 실제 입력으로 확인했다. UC6 빈 검색 전체 조회와 클릭 즉시 선택 표시·pending action lock은 자동 검증 완료 | 변경 포함 최신 AppX 물리 입력 | +| LF-012 | P1 | GraphE/FSell/VI | 최종 재검증 대기 | GraphE 7,776행 전체 raw 조회·편집·삭제, strict-ready 7,726/송출 보류 50 분리. 기존 이름 기반 GraphE 36행은 36/36 fresh 복원됐다. 직전 `graphe` 491입력과 FSell/VI save+append+close는 PASS. 현재 OperatorData의 개인/외국인/기관/VI발동 4파일은 원본 Data와 length·SHA-256 exact equal이다 | 즉시 선택 교정 포함 최신 AppX 물리 입력. 보류 50행은 fresh PREPARE gate 유지 | +| LF-013 | P1 | PList/AList | 최종 재검증 대기 | 목록·생성·save/load/delete·행 double-click 이관, 개발 DB write/readback/cleanup PASS. PROGRAM 중 Space/전체는 원본 의도를 보존하되 이미 진행한 prefix가 아니라 미래 행 enabled만 변경하도록 자동 검증했다 | 변경 포함 최신 AppX 물리 입력 | +| LF-014 | P1 | ThemeA/EList | 최종 재검증 대기 | ThemeA 1,747개 전체 조회. 기존 편집은 raw `SB_ITEM` 전체를 읽어 비활성·교차 prefix·동일 순번 중복·경계 공백 제목을 보존한다. EList NULL 매수가 5행도 빈 값으로 보존. ThemeA 빈 검색 전체 조회와 클릭 즉시 선택 표시·pending action lock은 자동 검증 완료 | 변경 포함 최신 AppX 물리 입력. scene PGM은 LF-005 | +| LF-015 | P1 | UC5 해외 | 완료 | 해외업종 53개·해외종목 1,474개를 표시하고 이름 없는 16행만 격리. 업종 동명 행은 exact DB tuple로 구분 | s6001 13개 영상은 LF-001 | +| LF-016 | P1 | F2/F3 배경 선택 | 완료 | background root picker, background-none와 trusted path 검사를 구현했다. 특정 `기본.vrv`가 없으면 없는 상태로 진행하며 외부자산 14개 집계에 넣지 않는다 | 사용자가 별도 배경을 지정할 때 trusted 검사를 적용 | | LF-017 | P1 | 시스템 종료 확인 | 완료 | 원본 제목 `매일경제TV`, 중립 문구, 예/아니요와 기본 아니요를 유지하며 자동 TAKE OUT/반대 명령 금지 경계를 코드·자동 테스트로 유지 | 현재 Release package-context 회귀는 LF-005와 함께 판정 | -| LF-018 | P1 | 원본 비교쌍/수동 파일 | 완료 | `종목비교.dat` 8쌍 1회 import·재시작 marker, FSell 3×5행과 VI 9항목 trusted parser/store 검증 | 원본 파일은 계속 읽기 전용 | +| LF-018 | P1 | 원본 비교쌍/수동 파일 | 최종 재검증 대기 | `원본 비교쌍 가져오기`가 trusted 원본 `종목비교.dat` 8쌍을 모두 resolve한 뒤 현재 순서를 보존해 unseen pair만 atomic 병합한다. 현재 OperatorData의 개인/외국인/기관/VI발동 4파일은 원본 bin\Debug\Data와 length·SHA-256 exact equal이며 사용 가능 | 최신 AppX에서 현재 1+원본 8=9, 재실행 추가 0의 실제 로컬 상태 smoke 대기. 원본은 계속 읽기 전용 | | LF-019 | P1 | 실제 DB read→DTO→mutation | 완료 | Oracle/MariaDB 필수 scene selector 33/33, 전체 query 58건 PASS | 외부 asset/PGM 성공과 별도 | -| LF-020 | P2 | FarPoint 대체 | 완료 | 실제 개발 MSIX에서 종목 검색·키보드, 컷 다중 drag, 플레이리스트 이동/drag와 focus 경계를 Windows 입력으로 재검증. DOM/C# 회귀도 유지 | 활성 Print/Excel export는 원본에도 없음 | +| LF-020 | P2 | FarPoint 대체 | 최종 재검증 대기 | 직전 등록 패키지에서 종목 검색·키보드, 컷 다중 drag, 플레이리스트 이동/drag, drag feedback, 송출 뒤 컷 선택 표시와 focus/IME draft 보존을 Windows 입력으로 검증했다. 시스템 double-click 시간(이 장비 500ms)을 반영해 350ms 입력도 한 동작으로 처리한다. 이후 GraphE/ThemeA/비교/VI의 즉시 선택 표시와 선택 의존 action pending lock을 교정해 자동 검증했다 | 변경 포함 최신 AppX 물리 입력. 활성 Print/Excel export는 원본에도 없음 | | LF-021 | P2 | 패키지 단일 인스턴스·모달 격리 | 완료 | 두 번째 activation, child modal Escape, 전역 키 격리, Tab trap, focus 복귀와 PList pending close lock을 자동 검증하고 PList 모달을 실제 입력으로 재검증 | 고객 서명 MSIX의 clean PC 설치 검증은 배포 단계 | | 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개** 전수 분류. 유일하게 남았던 활성 `timerAliveChecker`의 10초 DB 끊김·복구 감시와 전이 로그를 LegacyParityApp 수명주기에 연결 | 상세 줄 단위 근거는 `LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md` | +| LF-024 | P0 | Designer/동적 이벤트 전수 감사 | 완료 | 원본 Designer 구독 **186개**, 화면별 고유 핸들러 **171개** 전수 분류. Designer 밖에서 `MainForm` 생성자가 동적으로 구독하던 `timerAliveChecker`의 10초 DB 끊김·복구 감시와 전이 로그도 LegacyParityApp 수명주기에 연결 | 상세 줄 단위 근거는 `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 감사 상세 @@ -128,7 +141,68 @@ write는 자동 재시도하지 않는다. `OutcomeUnknown`이면 cleanup mutati 보내지 않는 구현·테스트 경계를 유지한다. 이 결과는 개발 DB 검증이며 운영 DB에 임의 쓰기를 허용하거나 수행했다는 뜻이 아니다. -## 기존 PLAY_LIST read-only 재분류 +## 직전 등록 패키지 실제 입력 증거 (2026-07-22) + +당시 등록된 Development Mode AppX의 Web 자산·핵심 DLL을 당시 빌드와 hash 대조한 뒤 실제 +Windows 포인터·키보드 입력으로 다음 회차를 실행했다. 여섯 회차 모두 `PASS`였고 +blocked outbound, state violation, warning은 각각 0이었다. DB 쓰기 회차는 승인된 임시 +식별자만 사용했고 cleanup을 확인했으며, 앱은 정상 종료되고 Tornado2 연결은 0으로 돌아왔다. +이후 교정된 즉시 선택·PROGRAM enabled·빈 검색·설정·비교 importer를 포함한 최신 AppX의 +물리 회차는 아직 실행 전이므로 아래 증거를 최신 코드 최종 실측으로 확대하지 않는다. + +| 증거 | 입력 | 화면/DB 검사 | 승인 write | 핵심 경계 | +|---|---:|---:|---:|---| +| [`latest-readonly`](../artifacts/package-input-smoke/legacy-package-input-20260721T221024787Z-latest-readonly.json) | 73 | 0/0 | 0 | DB write 0, playout intent 0, 최종 playlist 3 | +| [`latest-graphe`](../artifacts/package-input-smoke/legacy-package-input-20260721T221122292Z-latest-graphe.json) | 491 | 5/12 | 12 | GraphE 4종 fresh reopen·350ms double-click·delete/absence, cleanup | +| [`latest-catalog`](../artifacts/package-input-smoke/legacy-package-input-20260721T221252795Z-latest-catalog.json) | 264 | 3/11 | 7 | ThemeA KRX/NXT·EList 검색/double-click/reorder/drag/delete, cleanup | +| [`latest-screens`](../artifacts/package-input-smoke/legacy-package-input-20260721T221352946Z-latest-screens.json) | 664 | 15/15 | 22 | UC1~UC7 전체 버튼·parent batch·GraphE/FSell/VI·모달, cleanup | +| [`latest-plist`](../artifacts/package-input-smoke/legacy-package-input-20260721T221553250Z-latest-plist.json) | 91 | 0/3 | 3 | create/save→fresh readback→delete/absence, cleanup | +| [`latest-dryrun-playout`](../artifacts/package-input-smoke/legacy-package-input-20260721T221635626Z-latest-dryrun-playout.json) | 83 | 0/0 | 0 | 선택 행 NEXT·drag 상태·PROGRAM 선택·tail append, DB write/Tornado2 연결 0 | + +DryRun 송출 회차는 3행 중 `legacy-stock-00000003`을 선택한 상태에서 NEXT가 +`legacy-stock-00000001`로 이동함을 확인했다. PROGRAM 뒤 컷 선택은 0번이고, 안전한 마지막 +double-click만 `legacy-stock-00000004` 한 행을 append해 playlist가 3→4행이 됐다. + +## 현행 PList operator 로드 경계 (2026-07-21) + +현행 `LegacyParityApp`은 활성 정의 23개와 연결된 2,213행을 목록 단위로 거부하지 않고 +모두 로드한다. 최신 DB 감사 결과는 `rowsLoaded=2213`, `sceneAliasResolved=2213`, +`rowLevelDeferred=0`, `wholeListRejected=0`이며, 직전 개발 패키지의 실제 마우스 입력으로 +첫 목록 58행을 팝업 없이 불러왔다. 이 수치는 목록/scene alias 복원 근거이고, 각 행의 +최신 DB payload·asset·PGM 성공 근거는 아니다(`playoutPreflightAsserted=false`). + +2026-07-22 최신 실제 DB smoke는 2,213행 가운데 1,617행을 fresh typed selection으로 +복원하고 596행을 보류했다. 보류는 KRX 테마 493행, NXT 테마 48행, 비교 시장 identity +유실 25행, unique closed mapper 없음 30행이다. 기존 이름 기반 GraphE 36행은 원본 저장 +의미와 같은 compatibility projection을 적용해 36/36 fresh materialization됐다. + +## 동적 DB 목록 호환 경계 (2026-07-21) + +원본에서 보이던 행을 목록 진입 단계의 송출 검증으로 통째로 숨기지 않는다. 실제 read-only 감사와 +직전 개발 패키지 클릭 결과는 다음과 같다. + +- 국내 종목 4,599/4,599, 해외 비교 raw 1,490 중 표시 1,474·선택 가능 1,473, + UC5 업종 53·종목 1,474, UC7 거래정지 112를 확인했다. +- Theme/ThemeA는 KRX 1,571/NXT 176, 합계 1,747개다. 제목 앞뒤 공백 92행과 중복 제목 + 14행도 market·code identity로 구분한다. +- ThemeA 편집은 저장된 `SB_ITEM`을 직접 읽는다. KRX/NXT 교차 prefix 5/3행과 동일 + `SB_I_INDEX` 2그룹·4행을 유실하지 않는다. 신규 테마는 계속 시장별 prefix만 허용한다. +- GraphE는 Revenue 1,961행, Growth 1,119행, Sales 2,349행, OperatingProfit 2,347행, + 합계 7,776행을 모두 raw 편집 대상으로 노출한다. 원본이 빈 항목을 저장한 `_` 슬롯과 빈 + 분기명은 레거시 규칙으로 수용한다. 최신 read-only 감사 결과 strict-ready/deferred는 각각 + Revenue 1,960/1, Growth 1,114/5, Sales 2,329/20, OperatingProfit 2,323/24이며, + 합계 7,726/50이다. 정수 값의 선후행 공백·TAB, 분기명 후행 TAB과 완전히 동등한 중복 DB행은 + 원본 저장 의미대로 수용하되 conflicting 중복과 내부 제어문자는 거부한다. 남은 50행도 기존 값 + 조회·수정·삭제는 가능하지만 PREPARE 전에 fresh projection gate를 통과해야 한다. +- 전문가 6명의 추천 32행 중 매수가 NULL 5행은 빈 셀로 보존한다. 임의 기본값을 만들지 않으며, + 숫자 매수가가 필요한 송출 action만 사용할 수 없다. +- VI와 국내 비교는 빈 검색 4,599행, `KODEX` 238행을 실제 화면에 표시했다. 실제 상한을 + 넘거나 행이 격리되면 화면에 지속 경고를 표시한다. + +이 재감사에서 ThemeA raw 편집 외 추가 P0/P1 DB 로드 차이는 발견되지 않았다. `PLAY_LIST`의 +정의 없는 orphan 88행은 원본 PList도 표시하지 않는 데이터이므로 회귀로 분류하지 않는다. + +## 기존 PLAY_LIST read-only fresh-selection 재분류 (2026-07-12 역사적 증거) 실제 Oracle 활성 정의 23개와 연결된 2,213행은 원문의 7필드, 한국어, enabled, ITEM_INDEX와 원본의 page 공백 제거 규칙을 보존했다. @@ -144,14 +218,19 @@ ITEM_INDEX와 원본의 page 공백 제거 규칙을 보존했다. | 해외종목 | 78 | selection 78 | | GraphE | 36 | fresh materialization 22, 값/identity 14 차단 | | unique closed mapper 없음 | 30 | nearest-scene 추정 없이 차단 | -| 합계 | **2,213** | 통합 selection 1,603 / 명시 차단 610 | +| 합계 | **2,213** | 역사적 통합 selection 1,603 / fresh-selection 증명 보류 610 | -`selectionMapped`는 scene asset, PREPARE나 PGM 성공 수치가 아니다. 상세는 +이 표의 610행은 현행 DB 목록 로드 차단 수치가 아니다. `selectionMapped`는 scene asset, +PREPARE나 PGM 성공 수치도 아니다. 상세는 [`NAMED_PLAYLIST_READ_AUDIT.md`](NAMED_PLAYLIST_READ_AUDIT.md)를 따른다. +최신 재분류는 이 역사적 표에서 GraphE 보류 14행을 모두 복원해 `1,617/596`이다. 다른 보류 +사유와 수치는 바뀌지 않았으며, 이를 과거 2026-07-12 회차의 결과에 소급 적용하지 않는다. + ## 외부자산 목록 -45개 active alias의 `.t2s`는 존재한다. 별도 종속 asset 14개만 제공되지 않았다. +설정 validator는 45개 active alias의 `.t2s`와 필수 built-in asset 9개를 요구하며 현재 +검증을 통과한다. 별도 optional 외부 제작 asset 14개만 제공되지 않았다. - `s5006`: `Video\큐브배경.vrv` - `s6001`: @@ -168,10 +247,9 @@ ITEM_INDEX와 원본의 page 공백 제거 규칙을 보존했다. - `Video\20201008_필리핀.vrv` - `Video\20201008_말레이시아.vrv` - `Video\20201008_인도네시아.vrv` -- 기본 배경: sibling `배경\기본.vrv` 이 파일들의 부재는 code/build 실패가 아니라 **외부자산 미제공**이다. 검사기는 존재, -0바이트, root escape, reparse와 manifest 중복을 검사하고 누락 경로와 영향 builder만 +0바이트, root escape, reparse·중간 junction과 manifest 중복을 검사하고 누락 경로와 영향 builder만 redacted 형태로 보고한다. ## 원본 I/O에서 의도적으로 제외한 항목 @@ -184,9 +262,14 @@ redacted 형태로 보고한다. ## 최종 증적 기록 현재 구현 증적과 최종 검증 범위는 [`MIGRATION_STATUS.md`](MIGRATION_STATUS.md)에 기록한다. -최신 .NET solution 2,817/2,817, LegacyWeb 117/117, JavaScript 502/502이며 실패·skip은 0이다. -Release 빌드는 warning 0, error 0이다. 개발 MSIX 전체 UI/DB와 실제 PGM 32/34 route를 확인했다. -고객 서명 MSIX는 현재 범위에서 보류하고, 외부자산 두 route는 제공 전까지 fail closed한다. +마지막 전체 .NET solution 실행은 3,115/3,115였다. 그 뒤 추가된 교정의 focused 자동 검증은 +통과했지만 전체 solution 합계는 최종 재실행 뒤 갱신한다. 별도 LegacyParityWeb은 127/127, +WebPlayout JavaScript는 424/424이고 직전 Debug build/rebuild는 warning 0, error 0이다. +직전 등록 개발 패키지 UI/DB 여섯 회차는 모두 PASS지만 최신 변경 포함 AppX 물리 재검증은 +대기 중이다. 실제 PGM은 최신 Core 6/6, 30 route 30/30, 최신 Paged 24/24 기준선을 확보했다. +`s6001` 영상 비의존 4 action/18-stage workflow는 static PASS이고 실제 PGM은 실행 전이다. +고객 서명 MSIX는 현재 범위에서 보류한다. `s5006` 큐브와 `s6001` 국가 영상 action은 +외부자산 제공 전까지 fail closed하고, `s6001` 영상 비의존 4 action은 실제 PGM 재검증 대기다. 2026-07-15의 상세 package 증적은 [`LEGACY_PARITY_VALIDATION_20260715.md`](LEGACY_PARITY_VALIDATION_20260715.md)에 역사 기록으로 보존한다. diff --git a/docs/MIGRATION_STATUS.md b/docs/MIGRATION_STATUS.md index 38def46..27fa45a 100644 --- a/docs/MIGRATION_STATUS.md +++ b/docs/MIGRATION_STATUS.md @@ -1,6 +1,6 @@ # MBN_STOCK_WEBVIEW 이관 현재 기준선 -기준 시각: 2026-07-20 +기준 시각: 2026-07-22 기준 소스: `main` 브랜치의 이 문서 포함 최신 기준선 @@ -20,25 +20,76 @@ ## 현재 결론 -전체 이관은 기능·개발 검증 기준으로 거의 완료됐다. 없는 외부 제작 자산은 관련 두 route만 +전체 이관은 기능·개발 검증 기준으로 거의 완료됐다. 없는 외부 제작 자산은 관련 action만 fail closed로 제한한 채 진행하고, 고객 배포는 사용자가 다시 결정할 때까지 현재 범위에서 보류한다. -기존 플레이리스트 610행은 코드 결함이 아니라 과거 identity/현재 DB 식별정보 부족에 따른 외부 -데이터 결정 대기 항목이다. +기존 DB 재생목록은 활성 정의 23개/2,213행 모두 목록과 scene alias까지 로드하도록 복원했다. +최신 실제 DB 호환 감사에서는 1,617행을 fresh typed selection으로 복원했고, 남은 596행만 +추정 없이 보류한다. 보류 사유는 KRX 테마 493행, NXT 테마 48행, 비교 시장 identity 유실 +25행, unique closed mapper 없음 30행이다. 따라서 전체 목록은 열되, 개별 행의 +payload·asset·송출 전 검증은 계속 분리한다. +2026-07-21에는 같은 원칙을 모든 동적 DB 목록에 적용해, 기존 행의 조회·편집과 실제 송출 가능 +판정을 분리했다. 고정 개수 상한, 제목 trim, 중복 제목 제거, 활성 preview 전용 읽기 때문에 정상 +legacy 행까지 숨기던 경로를 제거하고, 불완전한 개별 행만 표시·편집 또는 비활성 상태로 격리한다. | 범위 | 현재 판정 | 검증 범위 | |---|---|---| | 원본 Scene 구현 | 완료 | builder 35/35, 실제 운영 runtime route 34개, 무alias 진단 전용 `s8086` 1개 | | 원본 DB 연결 상태 감시 | 구현·자동 회귀 완료 | LegacyParityApp 시작 후 10초 간격으로 Oracle/MariaDB 상태를 확인하고 상태 전이 때만 native 로그를 남긴다. 운영자 입력·송출·자동 갱신이 시작되면 진단 조회를 취소하고 DB activity gate를 양보한다. 종료 시 monitor도 취소한다. 실제 DB 서버를 강제로 끊는 장애 주입은 하지 않았다. | -| 개발 MSIX 전체 UI/DB | 완료 | 실제 Windows 입력으로 UC1~UC7, GraphE, FSell, VIList, ThemeA, EList, PList 모달, 검색·키보드·컷/플레이리스트 drag를 검증했다. `screens` 15 화면/15 데이터·파일 검사, `graphe` 5 화면/12 DB 검사, `catalog` 3 화면/11 DB 검사, PList create→fresh read→delete가 모두 PASS였고 잔여 테스트 데이터는 0이다. 2026-07-20에는 선택 행 기준 NEXT, drag 피드백, 송출 후 컷 선택 표시를 실제 입력으로 재검증했다. | -| 개발 Tornado2/PGM | **32/34 runtime route 완료** | `5001 → 5074 Page NEXT → TAKE OUT` 재검증과 자산 비의존 30개 route의 LOAD/PREPARE/PLAY/UNLOAD를 실제 PGM에서 확인했다. 30개는 구/신규 프로그램의 동일 저장 편성표 사용자 입력으로도 비교했고, 신규는 실제 PGM 화면 30/30을 보존했다. 확인된 명령은 request/success가 일치하고 `FAILURE=0`, `ERROR=0`, retry 0, `OutcomeUnknown=false`다. | -| PageN/장기 실행 경계 | 완료 | `5077` 6행을 1/20부터 20/20까지 실제 Page NEXT하고, 마지막 페이지에서 playlist NEXT로 `5088` 12행 1/20으로 전환했다. 이전 scene unload와 최종 TAKE OUT/UNLOAD/IDLE을 확인했다. | -| 자동 회귀 | 완료 | 최신 .NET solution 2,817/2,817, LegacyWeb 117/117, JavaScript 502/502; 실패·skip 0. Release 빌드 warning 0, error 0 | +| 개발 MSIX 전체 UI/DB | **직전 패키지 실측 PASS · 최신 코드 물리 재검증 대기** | 등록 개발 패키지에서 실제 Windows 입력으로 UC1~UC7, GraphE, FSell, VIList, ThemeA, EList, PList 모달, 검색·키보드·컷/플레이리스트 drag를 검증했다. `readonly` 73, `graphe` 491, `catalog` 264, `screens` 664, `plist` 91, `dryrun-playout` 83 입력은 모두 PASS이고 blocked·state violation·warning은 전 회차 0이다. 그 뒤 즉시 행 선택, PROGRAM 중 미래 행 Space/전체 enabled 변경, ThemeA/UC6 빈 검색 전체 조회, 비교쌍 명시적 import와 설정 UI를 교정했으며 자동 검증은 통과했다. 이 변경을 포함한 새 AppX의 물리 입력·DB cleanup·로컬 상태 회차는 아직 최종 재실행 전이다. | +| 개발 Tornado2/PGM | **32/34 route 최신 실측 · s6001 4-action 실측 대기** | 최신 `LATEST-CORE-01`에서 `5001 → 선택 행 NEXT 5074 → Page NEXT → TAKE OUT` 6/6, 분할한 30-route 회차에서 30/30, 최신 `LATEST-PAGED-04`에서 5077/5088 경계 24/24를 실제 PGM으로 확인했다. 명령 실패·오류·retry·`OutcomeUnknown`은 0이다. `s6001`의 영상 비의존 4 action/18-stage workflow는 구현·static PASS지만 실제 PGM은 아직 실행하지 않았다. | +| PageN/장기 실행 경계 | **최신 결합 코드 실측 완료** | `LATEST-PAGED-04`에서 `5077` 6행을 1/20부터 20/20까지 실제 Page NEXT하고, 마지막 페이지에서 playlist NEXT로 `5088` 12행 1/20으로 전환했다. 이전 scene unload와 최종 TAKE OUT/UNLOAD/IDLE도 확인했다. `LATEST-CORE-01`의 5074 page 1→2도 포함한다. | +| runtime 폴더/자산 설정 | 구현·자동 회귀 완료 | 선택한 Cuts root는 active `.t2s` alias 45개와 필수 built-in asset 9개를 모두 요구한다. 외부 제작 asset 14개는 없어도 설정을 수용하되 영향 경로만 제한한다. 빈 파일·reparse·중간 junction·root 이탈은 거부한다. 폴더 picker 취소와 최신 AppX 물리 검증은 최종 재실행 대기다. | +| 자동 회귀 | 완료, 최종 합계 갱신 대기 | 마지막 전체 .NET solution 실행은 3,115/3,115였다. 그 뒤 GraphE storage compatibility, 즉시 선택, PROGRAM 미래 행 enabled, ThemeA/UC6 빈 검색, 설정 validator, 비교 importer와 s6001 staged workflow의 focused/static 검증도 통과했다. 전체 solution 합계는 최종 재실행 뒤 갱신한다. 직전 별도 스크립트는 LegacyParityWeb 127/127, WebPlayout JavaScript 424/424이고 Debug build/rebuild는 warning 0, error 0이다. | | 고객 배포 MSIX | 현재 범위에서 보류 | 현재 결과는 unsigned 개발 패키지다. 고객용 version, Publisher, 서명 인증서, 업데이트 방식과 새 PC의 DB/Development Live 로컬 설정 배포안은 사용자가 배포 작업을 재개할 때 결정한다. | +## 2026-07-21~22 기존 DB 로드·편집 호환 재검수 + +실제 Oracle/MariaDB를 read-only로 다시 조회하고 직전 등록 개발 패키지를 DryRun으로 실행해 +다음을 확인했다. 아래 목록 조회와 편집 화면 확인 중 DB write 및 Tornado2/PGM 명령은 0건이다. + +| 화면/경로 | 실제 DB 및 패키지 결과 | 송출 경계 | +|---|---|---| +| 국내 종목/비교/VI | 빈 검색 4,599/4,599, `KODEX` 238/238 표시 | 선택한 typed identity만 사용 | +| 해외 비교 종목 | raw 1,490, 표시 1,474, 선택 가능 1,473, 이름 없음 16행 격리, 쉼표 직렬화가 모호한 1행은 표시하되 비활성 | 비활성 행은 선택·저장·송출 거부 | +| UC5 해외 | 업종 53, 종목 1,474와 이름 없음 16행 격리 | 업종은 이름뿐 아니라 input name·symbol·nation·FDTC exact identity 사용 | +| Theme/ThemeA | 전체 1,747(KRX 1,571/NXT 176), 경계 공백 제목과 중복 제목도 보존 | NXT preview가 빈 4개 테마는 목록에는 보이되 action 차단 | +| ThemeA 기존 편집 | `SB_ITEM` 원문 전체 읽기. KRX/NXT 교차 prefix 5/3행, 동일 순번 2그룹·4행 보존. 대표 중복순번 테마 14행과 후행 공백 제목 11행을 실제로 열어 확인 | 활성 종목 기반 strict preview는 송출용으로 별도 유지; 새로 추가한 종목만 current master 검증 | +| EList/전문가 | 전문가 6명, 추천 32행, 매수가 NULL 5행을 빈 값으로 보존 | NULL 매수가 포함 행은 편집 가능하지만 숫자 송출 전 차단 | +| FSell/VI 로컬 데이터 | `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\OperatorData`의 개인·외국인·기관·VI발동 4파일이 authoritative 원본 `bin\Debug\Data`와 각각 length·SHA-256 exact equal | 미이관 파일이 아니며 현재 앱에서 사용 가능. 실제 파일은 Git/MSIX 밖에서 trusted 검사 유지 | +| GraphE | 4개 테이블 7,776행 모두 raw 조회·편집 가능. Revenue 1,960/1, Growth 1,114/5, Sales 2,329/20, OperatingProfit 2,323/24(strict-ready/deferred), 합계 strict-ready 7,726행·deferred 50행 | 원본 저장 의미와 같은 정수 공백·TAB, 분기명 후행 TAB 및 완전히 동등한 중복행을 수용한다. conflicting 중복과 실제 불완전 payload는 계속 차단한다. 기존 이름 기반 GraphE 재생목록 36행은 36/36 fresh materialization됐다. | +| PList | 정의 23개/2,213행 전체 로드, scene alias 2,213/2,213, whole-list reject 0. fresh selection 1,617, 보류 596 | 행별 fresh payload·asset·PGM 검증은 사용할 때 별도 수행 | + +직전 패키지 실제 클릭 결과는 C# snapshot과 DOM 행 수가 모두 일치했고, 검수 전 편성표 58행은 +끝까지 58행으로 유지됐다. ThemeA와 GraphE의 기존 원문을 단순 조회하거나 편집 화면에 여는 행위가 +곧 송출 가능 승인을 뜻하지는 않는다. + +## 2026-07-22 직전 등록 패키지 실제 입력 기준선 + +당시 등록된 Development Mode 패키지의 Web 자산과 핵심 DLL이 당시 빌드 산출물과 같은지 hash로 +확인한 뒤 여섯 회차를 실행했다. 패키지 입력 도구에도 빌드 AppX와 등록 AppX가 다르면 시작 전에 +fail closed하는 검사를 추가했다. 모든 회차는 앱을 정상 종료했으며, 종료 뒤 Tornado2 연결은 0이었다. +아래 결과는 유효한 직전 물리 입력 기준선이지만, 이후 교정 사항을 포함한 최신 AppX의 최종 물리 +재검증 결과로 확대하지 않는다. + +| 회차 | 실제 입력과 검사 | 결과 | +|---|---|---| +| `latest-readonly` | 73입력, outbound 29건, DB write 0, playout intent 0, 최종 playlist 3행 | PASS, blocked/violation/warning 0 | +| `latest-graphe` | 491입력, 5 화면 검사, 12 DB 검사, 승인 write 12건 | PASS, cleanup 확인, blocked/violation/warning 0 | +| `latest-catalog` | 264입력, ThemeA KRX/NXT와 EList 3 화면 검사, 11 DB 검사, 승인 write 7건 | PASS, cleanup 확인, blocked/violation/warning 0 | +| `latest-screens` | 664입력, UC1~UC7·GraphE·FSell·VI 등 15 화면/15 DB 검사, 승인 변경 22건 | PASS, cleanup 확인, blocked/violation/warning 0 | +| `latest-plist` | 91입력, create/save→fresh readback→delete/absence 3 DB 검사, 승인 write 3건 | PASS, cleanup 확인, blocked/violation/warning 0 | +| `latest-dryrun-playout` | 83입력, 3행 중 선택 행 기준 NEXT, drag feedback/state, PROGRAM 중 컷 선택, 안전한 tail double-click append | PASS, DB write 0, Tornado2 연결 0, blocked/violation/warning 0 | + +`latest-dryrun-playout`의 식별 결과는 staged `legacy-stock-00000002`, 선택 +`legacy-stock-00000003`, NEXT `legacy-stock-00000001`, 안전 append +`legacy-stock-00000004`였다. PROGRAM 뒤 컷 선택은 0번이고 playlist는 3행에서 4행으로 정확히 +한 행만 늘었다. + ## 실제 PGM 검증 경계 -- `5001`과 `5074`는 현재 작업 트리 기준으로 다시 검증했다. 2026-07-11 Round H의 과거 결과에 - 의존하지 않는다. +- 최신 결합 코드의 `MBNWEB-20260722-LATEST-CORE-01`에서 `5001 → 5074 playlist NEXT → + 5074 Page NEXT → TAKE OUT/IDLE` 6/6을 검증했다. 2026-07-11 Round H와 + `CORE5001-05`는 역사적 증거로만 보존한다. - 이어서 외부자산에 의존하지 않는 30개 route를 순차 검증했다. 마지막 `5025` TAKE OUT 뒤 `UNLOAD 1/1`, `FAILURE 0`, `ERROR 0`, 정확한 `IDLE`을 확인했다. - 같은 30개를 구 프로그램과 신규 프로그램에서 동일한 저장 편성표 순서와 실제 포인터 입력으로 @@ -52,22 +103,29 @@ fail closed로 제한한 채 진행하고, 고객 배포는 사용자가 다시 않도록 계약을 교정한 뒤 다시 검증했다. 선물 좌·우 plate가 정상 표시되고 관련 명령 실패는 0이다. - 최초 `5077` 경계 검증에서 실제 데이터 120건임에도 행의 page count가 `1/1`로 고정되는 결함을 발견했다. 첫 PREPARE/TAKE IN 전에 전체 PageN 행을 read-only preflight하여 `1/N`을 고정하도록 - 수정했고, 이후 `5077` 20페이지 전체와 `5088` 전환을 재검증했다. 결함이 있던 최초 실행 결과는 - 성공 증거로 사용하지 않는다. -- `s5006`과 `s6001`은 asset preflight가 fail closed하므로 PREPARE/PLAY를 보내지 않았다. + 수정했다. 최신 결합 코드의 `MBNWEB-20260722-LATEST-PAGED-04`에서 `5077` 20페이지 전체, + `5088` 전환, 이전 scene unload와 최종 TAKE OUT/UNLOAD/IDLE을 24/24로 재검증했다. 결함이 + 있던 최초 실행과 이전 `PAGED-01`은 최신 승인 근거로 사용하지 않는다. +- `s6001` 중 국가 영상이 필요 없는 두바이유·WTI·브렌트유·금 4 action은 global + `images\주유기merge.png`, `images\35752913_l.jpg`를 고정 검증하는 18-stage staged workflow로 + 구현했고 static audit가 통과했다. 실제 PGM 회차는 아직 실행 전이므로 성공 증거에 포함하지 않는다. +- `s5006`의 큐브 배경 1개와 `s6001`의 국가 영상 13개는 asset preflight에서 관련 action만 + fail closed한다. 이 14개가 현재 남은 외부 제작 자산 경계다. 로컬 상세 증거는 다음 위치에 있다. -- [`MBNWEB-20260718-DEV-LIVE-01`](../artifacts/pgm-evidence/MBNWEB-20260718-DEV-LIVE-01) -- [`MBNWEB-20260718-DEV-LIVE-02`](../artifacts/pgm-evidence/MBNWEB-20260718-DEV-LIVE-02) -- [`MBNWEB-20260718-DEV-LIVE-05`](../artifacts/pgm-evidence/MBNWEB-20260718-DEV-LIVE-05) +- [`최신 5001/5074 Core PGM`](../artifacts/pgm-evidence/MBNWEB-20260722-LATEST-CORE-01) +- [`30 route 0~19 PGM`](../artifacts/pgm-evidence/MBNWEB-20260722-THIRTY-02) +- [`8001 known-state TAKE OUT`](../artifacts/pgm-evidence/MBNWEB-20260722-RECOVERY8001-01) +- [`30 route 20~29 PGM`](../artifacts/pgm-evidence/MBNWEB-20260722-THIRTY20-29-01) +- [`최신 5077/5088 20-page PGM`](../artifacts/pgm-evidence/MBNWEB-20260722-LATEST-PAGED-04) - [`구/신규 30개 송출 비교`](LEGACY_NEW_PLAYOUT_COMPARISON_20260719.md) -- [`최종 MSIX 화면/DB PASS`](../artifacts/package-input-smoke/legacy-package-input-20260718T194130241Z.json) -- [`PList DB PASS`](../artifacts/package-input-smoke/legacy-package-input-20260718T193931554Z.json) -- [`GraphE DB PASS`](../artifacts/package-input-smoke/legacy-package-input-20260718T194003350Z.json) -- [`ThemeA/EList DB PASS`](../artifacts/package-input-smoke/legacy-package-input-20260718T194054981Z.json) -- [`최종 read-only 회귀 PASS`](../artifacts/package-input-smoke/legacy-package-input-20260718T194240167Z.json) -- [`2026-07-20 실제 입력 보정 PASS`](../artifacts/package-input-smoke/legacy-package-input-20260720T032627652Z.json) +- [`직전 read-only 입력 PASS`](../artifacts/package-input-smoke/legacy-package-input-20260721T221024787Z-latest-readonly.json) +- [`직전 GraphE 입력/DB PASS`](../artifacts/package-input-smoke/legacy-package-input-20260721T221122292Z-latest-graphe.json) +- [`직전 ThemeA/EList 입력/DB PASS`](../artifacts/package-input-smoke/legacy-package-input-20260721T221252795Z-latest-catalog.json) +- [`직전 전체 화면/DB PASS`](../artifacts/package-input-smoke/legacy-package-input-20260721T221352946Z-latest-screens.json) +- [`직전 PList 입력/DB PASS`](../artifacts/package-input-smoke/legacy-package-input-20260721T221553250Z-latest-plist.json) +- [`직전 DryRun 송출 입력 PASS`](../artifacts/package-input-smoke/legacy-package-input-20260721T221635626Z-latest-dryrun-playout.json) `artifacts`의 개발 장비 증거에는 로컬 실행 상태가 포함될 수 있으므로 고객 MSIX나 Git 배포물에 넣지 않는다. @@ -78,19 +136,28 @@ fail closed로 제한한 채 진행하고, 고객 배포는 사용자가 다시 - `s5006\Video\큐브배경.vrv` - `s6001` 국가 영상 13개 -- `배경\기본.vrv` -- 필요한 스튜디오 `.vrv` 배경 원본 현재 authoritative 원본 Cuts에도 없다. 자산 없이 진행한다는 사용자 결정에 따라 제공 전에는 -관련 경로만 안전하게 차단하며, 이는 현재 작업 전체의 차단 사유가 아니다. 나중에 자산을 받으면 -trusted root·빈 파일·reparse·경로 이탈 검사를 통과시킨 뒤 `s5006`/`s6001`을 실제 PGM에서 검증한다. +관련 action만 안전하게 차단하며, 이는 현재 작업 전체의 차단 사유가 아니다. `s6001`의 영상 +비의존 4 action은 별도 실제 PGM 검증 대상으로 계속 진행한다. 나중에 자산을 받으면 trusted +root·빈 파일·reparse·경로 이탈 검사를 통과시킨 뒤 `s5006`과 국가 영상 action을 실제 PGM에서 검증한다. ### 2. 기존 플레이리스트 데이터 정합성 -총 2,213행 가운데 1,581행은 현재 식별자로 즉시 복원할 수 있고, GraphE fresh read로 22행을 더 -복원할 수 있다. 최종 610행은 과거 저장 identity 또는 현재 DB 식별정보가 부족해 차단한다. 이 중 -KRX 테마 493행과 NXT 테마 48행은 DBA의 데이터 정합성 결정이 필요하다. 이름이나 유사 scene으로 -추정 복원하지 않는다. +현행 operator 경로는 총 2,213행을 모두 DB에서 읽어 화면과 스케줄에 복원하고, 2,213행 모두 +기존 scene alias로 식별한다. 직전 개발 패키지에서도 `DB 불러오기 → 첫 목록 → 불러오기`를 실제 +마우스로 실행해 58행, 팝업 0, scene 미식별 0을 확인했다. 이 결과는 목록 로드/alias 복원 근거다. + +2026-07-12 감사의 `1,603 selectionMapped / 610 보류`는 당시 raw 저장값에서 현재 DB identity를 +fresh하게 다시 증명한 역사적 수치다. 최신 실제 DB 재감사에서는 GraphE 36행을 모두 fresh +materialization하여 `1,617 복원 / 596 보류`로 갱신됐다. 596행은 KRX 테마 493행, NXT 테마 +48행, 비교 시장 identity 유실 25행, unique closed mapper 없음 30행이다. 목록 로드 차단으로 +해석하지 않으며, 이름이나 유사 scene으로 추정하지 않는다. + +원본 `종목비교.dat` 8쌍을 현재 로컬 순서 뒤에 중복 없이 병합하는 명시적 importer도 구현했다. +원본 파일은 고정된 trusted path에서 읽기 전용으로 열고 8쌍을 모두 resolve한 뒤에만 atomic하게 +저장하며, 실패 시 현재 파일을 보존한다. 실제 최신 AppX에서 `현재 1쌍 + 원본 8쌍 = 9쌍`과 두 번째 +실행의 `추가 0`을 확인하는 로컬 상태 smoke는 아직 실행 전이다. ### 3. 고객 배포 @@ -101,5 +168,8 @@ allowlist, vendor hash, STA 직렬화와 대상 identity 검사는 그대로 유 ## 완료 조건 -외부 자산 두 scene의 PGM 검증, 610행에 대한 DBA 결정 또는 명시적 영구 차단 승인, 고객 서명 -MSIX의 clean 설치·업데이트·새 PC 설정 검증이 끝나면 이관을 고객 배포 기준의 완전 완료로 올린다. +최신 AppX의 전체 물리 입력/DB cleanup/설정·비교 import 로컬 상태 재검증, `s6001` 영상 비의존 +4 action의 실제 PGM 검증, 기존 보류 행의 payload/asset/PGM 경계 판정을 마치면 현재 개발 장비의 +대체 운영 기준선을 확정한다. 외부 제작 자산 14개가 없는 action은 제공 전까지 명시적으로 차단한 +상태가 정상 기준이다. 고객 서명 MSIX의 clean 설치·업데이트·새 PC 설정 검증은 사용자가 배포를 +재개할 때 별도 완료 조건으로 적용한다. diff --git a/docs/NAMED_PLAYLIST_READ_AUDIT.md b/docs/NAMED_PLAYLIST_READ_AUDIT.md index 4bfcaa7..2baf2c0 100644 --- a/docs/NAMED_PLAYLIST_READ_AUDIT.md +++ b/docs/NAMED_PLAYLIST_READ_AUDIT.md @@ -2,11 +2,23 @@ 기준일: 2026-07-12 +> 2026-07-22 현행 `LegacyParityApp` 보충 판정: 아래 2026-07-12의 `1,603/610`은 당시 +> raw 행에서 current typed selection을 fresh하게 증명한 역사적 수치이며, DB 재생목록 +> 자체의 로드 성공/실패 수치가 아니다. 최신 operator 호환 감사에서는 활성 정의 23개와 +> 2,213행을 모두 로드했고(`wholeListRejected=0`), 2,213행 모두 기존 scene alias로 +> 식별됐다. 최신 실제 DB compatibility projection은 GraphE 36행을 36/36 복원해 현재 +> `1,617 복원 / 596 보류`다. 직전 개발 패키지에서도 `DB 불러오기 → 첫 목록 선택 → +> 불러오기`를 실제 마우스로 실행해 58행, 팝업 0, scene 미식별 0을 확인했다. 다만 이 +> 감사는 각 행의 asset·Tornado2 PREPARE/PGM 성공까지 주장하지 않는다 +> (`playoutPreflightAsserted=false`). 최신 코드 포함 AppX 물리 재검증은 별도 대기 중이다. + 원본(읽기 전용): `C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\Form\PList.cs` 감사 구현: [LegacyPListReadAuditParser](../src/MBN_STOCK_WEBVIEW.Core/Data/LegacyPListReadAudit.cs), [NamedPlaylistReadOnlyDbAudit](../tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedPlaylistReadOnlyDbAudit.cs), -[NamedManualRestoreReadOnlyAudit](../tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedManualRestoreReadOnlyAudit.cs) +[NamedManualRestoreReadOnlyAudit](../tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedManualRestoreReadOnlyAudit.cs), +[LegacyManualFinancialStorageCompatibility](../src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyManualFinancialStorageCompatibility.cs), +[NamedPlaylistOperatorCompatibilityAudit](../tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedPlaylistOperatorCompatibilityAudit.cs) ## 원본 읽기 의미 @@ -29,7 +41,40 @@ 감사 오류와 정상 출력에는 `DC_TITLE`, `LIST_TEXT`, 종목명, 테마명, 프로그램 코드 또는 인증정보를 포함하지 않는다. -## 실제 Oracle/MariaDB 결과 +## 2026-07-22 현행 fresh-selection 재감사 결과 + +최신 실제 Oracle/MariaDB smoke는 원본 저장 의미와 current typed projection을 다시 대조했다. +숫자 token의 선후행 공백·TAB, 분기명 후행 TAB 및 모든 필드가 완전히 같은 중복 DB행은 원본과 +같은 값으로 수용한다. 내부 제어문자와 서로 충돌하는 중복행은 계속 거부한다. 이 경계로 과거 +보류됐던 GraphE 14행까지 모두 fresh materialization됐으며 임의 종목·장면 추정은 없다. + +| 항목 | 현재 결과 | +|---|---:| +| 활성 `DC_LIST` 정의 | 23 | +| 연결된 `PLAY_LIST` 행 / 로드 / scene alias 식별 | 2,213 / 2,213 / 2,213 | +| whole-list reject | 0 | +| fresh typed selection 복원 | 1,617 | +| fresh selection 보류 | 596 | +| 이름 기반 GraphE fresh materialization | 36/36 | + +| route | 전체 | current 복원 | 보류 | +|---|---:|---:|---:| +| 기존 닫힌 매퍼 | 1,250 | 1,250 | 0 | +| 업종 fixed 닫힌 매퍼 | 24 | 24 | 0 | +| 비교 fresh identity | 99 | 74 | 25 | +| 해외지수 candle fresh identity | 61 | 61 | 0 | +| NXT theme fresh identity | 48 | 0 | 48 | +| KRX theme fresh identity | 587 | 94 | 493 | +| 해외종목 fresh identity | 78 | 78 | 0 | +| GraphE fresh materialization | 36 | 36 | 0 | +| unique closed mapper 없음 | 30 | 0 | 30 | +| 합계 | **2,213** | **1,617** | **596** | + +현재 596행의 보류 원인은 KRX 테마 493행, NXT 테마 48행, 비교 시장 identity 유실 25행, +unique closed mapper 없음 30행뿐이다. 이 수치는 목록 로드 실패가 아니며, current selection을 +하나로 증명하지 못한 행을 nearest scene으로 추정하지 않았다는 뜻이다. + +## 2026-07-12 역사적 fresh-selection 감사 결과 실제 운영 연결에 고정 SQL과 bind 변수로 `SELECT`만 실행했다. DB write, Tornado2 명령과 원본 저장소 변경은 수행하지 않았다. @@ -81,18 +126,18 @@ PREPARE 성공과 Tornado2/PGM 출력은 포함하지 않으며 각각 별도 ga 기본 감사 수치 1,581은 기존 닫힌 매퍼 1,250행, 업종 fixed 24행, 비교 74행, 해외지수 candle 61행, KRX theme 94행 및 해외종목 78행의 합이다. -GraphE 22행은 별도 fresh proof의 성공분이다. 현재 PList의 manual 36행은 모두 +GraphE 22행은 별도 fresh proof의 성공분이다. 해당 회차 PList의 manual 36행은 모두 GraphE이고 FSell/VI 저장 행은 0건이다. 기존 fixed 매퍼는 임의 문자열 허용으로 판정하지 않았다. 원본 runtime `해외.ini`·`환율.ini`·`지수.ini`를 각각 현재 Web 계약에 고정된 SHA-256으로 검증하고, 원본 `MainForm`과 같이 각 줄을 trim한 뒤 현재 -`fixedLegacySignature`의 exact 5필드 규칙으로 변환했다. 원본 328 action에서 -현재 명시적으로 unavailable인 수동 6개를 제외하고, 같은 raw 서명으로 겹치는 +`fixedLegacySignature`의 exact 5필드 규칙으로 변환했다. 원본 INI의 328 source action에서 +당시 runtime action으로 실체가 없는 수동 placeholder 6개를 제외하고, 같은 raw 서명으로 겹치는 후보는 unique로 인정하지 않았다. 이 exact whitelist와 일치하지 않은 30행은 가까운 `s5001` 등을 추정하지 않고 차단했다. -### fresh restore 결과와 차단 사유 +### 당시 fresh restore 결과와 차단 사유 | 사유 | 행 | 의미 | |---|---:|---| @@ -113,7 +158,7 @@ KRX theme 587행 중 94행은 제목 exact lookup, 현재 code 및 validated pre 매핑했다. 두 경로 모두 timeout/불명확 응답을 재시도하지 않으며 raw 이름·code를 감사 출력에 기록하지 않는다. -GraphE 36행의 별도 감사는 같은 identity를 두 번 읽어 64자리 row version이 +당시 GraphE 36행의 별도 감사는 같은 identity를 두 번 읽어 64자리 row version이 변하지 않는지 확인하고 현재 종목 master의 유일한 identity와 cut 계약을 함께 검증했다. 22행만 materialize했고 저장값 무효 9행과 identity 중복 5행은 첫 후보를 고르지 않고 차단했다. 상세 경계는 @@ -124,6 +169,11 @@ FSell/VI는 현재 PList 후보가 0행이지만 운영 production source를 ope version도 같았다. 네 운영 파일의 이름·크기·시각·SHA-256은 감사 전후 동일했고 import marker/intent를 만들거나 파일을 쓰지 않았다. +2026-07-22 현재 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\OperatorData`의 `개인`, `외국인`, +`기관`, `VI발동` 4파일은 authoritative 원본 `bin\Debug\Data` 파일과 각각 length 및 +SHA-256이 정확히 같다. 따라서 이 수동 파일은 미이관 또는 unavailable이 아니라 현재 앱에서 +사용 가능한 로컬 운영 데이터다. 원본과 로컬 파일 모두 이 감사에서 수정하지 않았다. + ## 구현과 자동 근거 - KRX theme: @@ -173,6 +223,7 @@ dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke correlation, GraphE double-read row-version·identity 중복 fail-closed - exact stock label과 nearest-label 거부, 알 수 없는 행의 `Unmapped` fail-closed -실제 기본 감사, 별도 manual fresh 감사와 전체 DB smoke는 -`REAL_DB_SMOKE: PASS`로 끝났다. DB write와 Tornado2/PGM 명령은 0건이며 raw 값이나 -인증정보도 출력하지 않았다. +실제 기본 감사, 별도 manual fresh 감사와 전체 DB smoke는 `REAL_DB_SMOKE: PASS`로 +끝났다. 2026-07-22 최신 전체 DB smoke도 PASS이며 current 결과는 GraphE 7,726/50, +named playlist 1,617/596, GraphE fresh 36/36이다. DB write와 Tornado2/PGM 명령은 +0건이며 raw 값이나 인증정보도 출력하지 않았다. diff --git a/docs/PGM_PARITY_STAGED_RUNBOOK_20260722.md b/docs/PGM_PARITY_STAGED_RUNBOOK_20260722.md new file mode 100644 index 0000000..dccc87d --- /dev/null +++ b/docs/PGM_PARITY_STAGED_RUNBOOK_20260722.md @@ -0,0 +1,223 @@ +# PGM parity staged runbook (2026-07-22) + +This runbook is for the designated local development Tornado2/PGM target only. +It does not replace the target, path, hash, listener, package, or `OutcomeUnknown` +stops implemented by `scripts/Invoke-LegacyPgmParityRun.ps1`. + +## What is different from the older artifact scripts + +- A new evidence directory captures the currently registered package, application, + PGM, executable hashes, process IDs/start times, window handles, TCP listeners, + the CDP target, and all 33 authoritative cut hashes exactly once. The 33rd cut is + `6001`; `5006` remains outside this live inventory until its cube video exists. +- Live staging accepts only the registered x64 loose development package whose + install root is the current workspace's exact + `src/MBN_STOCK_WEBVIEW.LegacyParityApp/bin/x64/Debug//win-x64/AppX` + boundary. A Release, customer-installed, or WindowsApps package is rejected. +- The exact package runtime files pinned are + `MBN_STOCK_WEBVIEW.LegacyParityApp.exe`, + `MBN_STOCK_WEBVIEW.LegacyParityApp.dll`, and + `MBN_STOCK_WEBVIEW.Playout.dll`. +- The `S6001AssetIndependent` workflow additionally pins the authoritative and + runtime copies of `images\주유기merge.png` and `images\35752913_l.jpg` by length + and SHA-256 before any input. The 13 country videos are not silently substituted. +- The app-side PGM connection must be entirely loopback and must have exactly one + reciprocal established row owned by the pinned PGM process. A same-numbered + port on another host cannot pass preflight. +- Every later stage must match all captured identities before the input, after the + input, and after read-only evidence capture. +- PGM uses a DirectX surface, so evidence capture temporarily raises only the exact + pinned PGM or app window without activating it, captures its screen rectangle, + and restores the original top-most state. This prevents unrelated foreground + windows from being recorded while preserving keyboard focus. +- Every invocation performs at most one staged action. There is no command retry. +- A timeout, `OutcomeUnknown`, probe violation, identity change, or broken Network + Monitoring anchor creates `STOPPED.json`. That run directory cannot dispatch a + later command. +- The validation playlist uses only in-memory `legacy-*` rows. It does not load or + save a temporary named DB playlist. The comparison build still creates and then + deletes the same temporary comparison pairs required by the application feature. +- A fresh app instance and a fresh evidence directory are required for each primary + workflow below. `Recovery8001`, `Recovery5077Page2`, and `ThirtyRoutes20To29` are + constrained recovery workflows and may start only from their exact known states + and preserved evidence. + +## Static and dry checks + +Static validation does not inspect or attach to the app/PGM and sends no native +message: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` + .\scripts\Invoke-LegacyPgmParityRun.ps1 -Action StaticAudit +``` + +The current accepted static result pins 33 cuts and reports workflow stage counts +`Core5001=6`, `ThirtyRoutes=125`, `PagedBoundary=24`, +`S6001AssetIndependent=18`, `Recovery8001=1`, `Recovery5077Page2=1`, and +`ThirtyRoutes20To29=41`. Static success is implementation evidence only; it is not +an actual PGM result. + +After the current package app and the designated development PGM are running and +connected, a dry preflight reads identities only. It does not attach to CDP, write +evidence, or send a native message. Supply the exact local PGM executable at run +time; do not commit that machine-local path: + +```powershell +$pgmExecutable = $env:MBN_STOCK_PGM_EXECUTABLE +if ([string]::IsNullOrWhiteSpace($pgmExecutable)) { + throw 'Set MBN_STOCK_PGM_EXECUTABLE to the designated development PGM executable.' +} + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` + .\scripts\Invoke-LegacyPgmParityRun.ps1 -Action DryPreflight ` + -ExpectedPgmExecutable $pgmExecutable +``` + +## Start one workflow + +Choose a primary workflow: `Core5001`, `ThirtyRoutes`, `PagedBoundary`, or +`S6001AssetIndependent`. Use a new, nonexistent evidence directory. Initialization +is read-only with respect to the app and PGM; it freezes the identities and captures +the baseline windows and Network Monitoring text. Do not select `Recovery8001`, +`Recovery5077Page2`, or `ThirtyRoutes20To29` unless the stop handling section's +exact recovery prerequisites are already met. + +```powershell +$run = Join-Path (Resolve-Path '.').Path ` + 'artifacts\pgm-evidence\MBNWEB-YYYYMMDD-PARITY-01' + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` + .\scripts\Invoke-LegacyPgmParityRun.ps1 -Action Initialize ` + -Workflow Core5001 -EvidenceDirectory $run ` + -ExpectedPgmExecutable $pgmExecutable +``` + +Inspect always reports the next required stage without sending input: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` + .\scripts\Invoke-LegacyPgmParityRun.ps1 -Action Inspect ` + -EvidenceDirectory $run +``` + +Dispatch exactly that one stage: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` + .\scripts\Invoke-LegacyPgmParityRun.ps1 -Action RunNext ` + -EvidenceDirectory $run +``` + +Do not loop `RunNext` blindly. Review its result and the new stage directory before +issuing the next invocation. `RunStage` is also available when the action and route +or page index should be written explicitly; it refuses anything out of order. + +## Exact workflow sequences + +`Core5001` contains six one-shot stages: + +1. bootstrap the safety probe and confirm empty connected IDLE; +2. build in-memory 5001/5074 rows; +3. TAKE IN 5001; +4. playlist NEXT to 5074; +5. Page NEXT from 5074 page 1 to page 2; +6. TAKE OUT 5074. + +`ThirtyRoutes` contains 125 stages: bootstrap, four in-memory build phases, then +four stages per route (`activate`, fast TAKE IN for the PGM frame, read-only wait +for settled PROGRAM, TAKE OUT) in this order: + +```text +5016, 50160, 5078, 8067, 5086, 50860, 5023, 5024, 5077, 5082, +5083, 5084, 5085, 5088, 6067, 8035, 5011, 8003, 5037, 8001, +5026, 5087, 5029, 8032, 5032, 5076, 5079, 5080, 5081, 5025 +``` + +`PagedBoundary` contains 24 stages: bootstrap, build 5077/5088, TAKE IN 5077, +19 individual Page NEXT inputs through page 20, playlist NEXT to 5088 page 1, +and TAKE OUT. It requires exact fresh-read boundaries of 5077 = 120 items / 6 per +page / 20 pages and 5088 = 240 items / 12 per page / 20 pages. + +`S6001AssetIndependent` contains 18 stages: bootstrap, one physical double-click +build stage that creates exactly these four `6001` rows, then four stages per row +(`activate`, TAKE IN, read-only settled PROGRAM wait, TAKE OUT): + +```text +fixed-072 유가 두바이 지수 DubaiCrude +fixed-073 유가 WTI 지수 WtiCrude +fixed-074 유가 브렌트유 지수 BrentCrude +fixed-075 금 지수 Gold +``` + +These actions use only the two globally pinned image assets. They do not exercise +or claim parity for the 13 country-video actions. The workflow is implemented and +its static audit passes, but as of this baseline it has **not** been run against +actual PGM. Do not record it as completed until all 18 one-shot stages succeed in a +new evidence directory. + +`Recovery8001` contains one TAKE OUT stage. It is valid only after read-only +inspection proves the interrupted `ThirtyRoutes` run stopped in the exact known, +settled `PROGRAM 8001` state with unchanged identities and +`OutcomeUnknown=false`. It must not be used to guess or repair an unclear state. + +`Recovery5077Page2` contains one TAKE OUT stage. It is valid only after read-only +inspection proves the exact preserved `PagedBoundary` manifest is settled in +`PROGRAM 5077`, page 2/20, with 120 items, page size 6, one completed refresh, +no pending command, and `OutcomeUnknown=false`. It is not a general PageN repair. + +`ThirtyRoutes20To29` contains 41 stages: one read-only manifest reconciliation and +four stages for each route index 20 through 29. It is valid only after the first 20 +routes and the exact 30-row in-memory manifest have been preserved and reconciled. + +## Completed evidence baseline + +The accepted latest 2026-07-22 development PGM baseline is split across these +preserved directories: + +- `MBNWEB-20260722-LATEST-CORE-01`: latest combined `Core5001` 6/6; +- `MBNWEB-20260722-THIRTY-02`: routes 0 through 18 completed and route 19 `8001` + reached a correct PGM frame. The subsequent read-only wait hit the runner's old + 90-second limit because the original 8001 first-refresh interval is 120 seconds. + The wait sent no SDK command; the app remained in known `PROGRAM 8001` with + `OutcomeUnknown=false`. This is a runner timeout, not an app or scene failure; +- `MBNWEB-20260722-RECOVERY8001-01`: after read-only confirmation that the 8001 + refresh had settled, one exact TAKE OUT returned the known state to IDLE; +- `MBNWEB-20260722-THIRTY20-29-01`: constrained resume 41/41, completing routes + 20 through 29; +- `MBNWEB-20260722-LATEST-PAGED-04`: latest combined `PagedBoundary` 24/24, + including 5077 page 1/20, + all 19 Page NEXT inputs through 20/20, playlist NEXT to 5088 page 1/20, and + final TAKE OUT/UNLOAD/IDLE. + +The older `CORE5001-05` and `PAGED-01` directories remain historical evidence but +are not the latest combined-code approval. The split thirty-route evidence verifies +all 30 entries in that workflow, and `LATEST-CORE-01` verifies the additional +`5001`/`5074` pair, yielding 32 of 34 active runtime routes with actual PGM evidence. +Network request/success counts match and +`FAILURE`, `ERROR`, safety-probe `blocked`, `violation`, and `OutcomeUnknown` are +all zero; no command was retried. `LATEST-PAGED-04` independently fixes the current +PageN/long-run boundary. `s5006` remains blocked by its cube video. In `s6001`, the +13 country-video actions remain asset-blocked, while the four image-only actions +remain actual-PGM pending rather than asset-blocked. + +The suspected approximately 65-second 5077 Page NEXT transition was an observation +artifact, not a runtime defect. Each of the 19 Page NEXT stages emitted exactly one +effect, PREPARE, and PLAY; `SCENE_PLAYED` followed PLAY in 144-188 ms. Immediate, +45-second, and 65-second-named captures for pages 9 and 20 are byte-identical, and +all 17 stages with delayed captures match their immediate capture hashes. The +original and packaged `5077.t2s` files also have the same SHA-256: +`845B99DFC499B5BDCD84058D68D91D849D59FB6692CB44784118A99EA68A20E2`. +Do not add waits or change fade units based on the image-view display inconsistency. + +## Stop handling + +If `STOPPED.json` exists, do not delete or edit it and do not start another command +from that run directory. Preserve the complete directory, inspect the last stage's +`node-stderr.json`, `node-result.json`, `pins-*.json`, Network text, and screenshots, +then diagnose from read-only state. Never resume the stopped directory itself. The +normal path is a fresh app instance and a new evidence directory after the state is +known. `Recovery8001` and `Recovery5077Page2` are the only same-app single-TAKE-OUT +exceptions; `ThirtyRoutes20To29` is the only constrained manifest continuation. +Use each only in a new evidence directory after every exact known-state prerequisite +above has been proved read-only; otherwise close normally and restart fresh. diff --git a/docs/PLAYOUT.md b/docs/PLAYOUT.md index 7b9bba2..d73331a 100644 --- a/docs/PLAYOUT.md +++ b/docs/PLAYOUT.md @@ -362,7 +362,9 @@ On-air 표식이 남은 상태에서는 프로세스 감시, 연결 해제, 앱 ### 화면/계약 구현과 운영 동등성 경계 -- PList 기존 한국어 `DC_TITLE`/`LIST_TEXT` 2,213행은 실제 read-only DB에서 무손상 대조했다. 즉시 복원 1,581행과 GraphE fresh read 22행을 합친 `selectionMapped=1,603`/차단 610을 확인했다. 차단 610행은 DBA 결정 없이 추정 복원하지 않는다. +- PList 기존 한국어 `DC_TITLE`/`LIST_TEXT` 2,213행은 실제 read-only DB에서 무손상 대조했다. 현행 operator 경로는 활성 정의 23개/2,213행을 모두 로드하고 scene alias까지 복원한다. 개발 패키지에서도 첫 목록 58행을 실제 마우스로 팝업 없이 불러왔다. 과거 `selectionMapped=1,603`/610 보류는 2026-07-12 당시의 fresh typed-selection 증명 수치이지 목록 로드 실패 수치가 아니다. 개별 행의 DB payload·asset·PREPARE/PGM은 사용할 때 별도 검증한다. +- 2026-07-21 DB 목록 호환 재검수에서 GraphE 7,776행 전체 raw 편집, Theme/ThemeA 1,747개, 전문가 6명/추천 32행, 국내 4,599개, UC5 업종 53개·종목 1,474개를 확인했다. GraphE는 원본의 `_` 빈 슬롯과 빈 분기명을 레거시 저장 규칙으로 수용한 뒤 Revenue 1,960/1, Growth 1,114/5, Sales 2,328/21, OperatingProfit 2,323/24(strict-ready/deferred), 합계 7,725/51로 판정됐다. 목록·원문 편집 가능 상태와 송출 가능 상태는 분리하므로 남은 51행도 조회·편집·삭제할 수 있지만 fresh PREPARE에서 차단한다. 해외 비교 비활성 1행, NULL 매수가 5행, NXT 빈 preview 4개도 화면에서 유실하지 않되 PREPARE/action 전에 차단한다. +- ThemeA 기존 편집은 활성 종목 preview가 아니라 raw `SB_ITEM` 전체를 읽어 비활성 종목, 교차 prefix와 동일 순번 중복을 보존한다. 이 완화는 편집용 raw 행에만 적용되며 송출용 active preview와 신규 테마 시장 prefix 검증에는 적용하지 않는다. - 개발 MSIX에서 PList create/save→fresh readback→delete/absence, ThemeA KRX/NXT와 EList create/edit/save→fresh readback→delete/absence를 실제 입력으로 검증하고 cleanup했다. 운영 DB에 대한 포괄적 쓰기 허가는 이 결과에 포함하지 않는다. - 원본 `종목비교.dat`는 signed package에서 8쌍 one-time import와 재시작 marker를 확인했다. FSell/VI는 격리 fixture의 marker-last와 production opened-handle read를 통과했지만, 현재 운영 profile의 기존 동일 파일을 덮어쓰거나 marker를 새로 만들지 않았다. - `s5006`의 `Video\큐브배경.vrv`와 `s6001` 해외지수 국가 영상 13개는 현재 승인 Cuts에 없으며, alias-only `Missing=0`은 asset-ready 증거가 아니다. diff --git a/docs/SCENE_EQUIVALENCE.md b/docs/SCENE_EQUIVALENCE.md index 0441a67..b91a199 100644 --- a/docs/SCENE_EQUIVALENCE.md +++ b/docs/SCENE_EQUIVALENCE.md @@ -1,6 +1,6 @@ # 35개 Scene 동등성 매트릭스 -현행 판정 기준 시각은 2026-07-20이다. 완료·차단·외부 입력 범위는 +현행 판정 기준 시각은 2026-07-22이다. 완료·차단·외부 입력 범위는 [`MIGRATION_STATUS.md`](MIGRATION_STATUS.md)를 우선 적용한다. 원본 `C:\Users\MD\source\repos\MBN_STOCK_N`은 읽기 전용으로만 분석했으며, 이 문서 작업에서도 원본 파일을 수정하지 않았다. 원본 35개 builder의 파일 해시는 @@ -16,16 +16,49 @@ | 원본 inventory와 hash 기준선 | 완료 | 35/35 builder, 원본 기준선 스크립트 35/35 | | DTO와 mutation builder | 완료 | registry가 정확히 35개 builder를 발견하고 catalog와 1:1 대조 | | loader와 runtime route | 완료 | MainForm 도달 가능 builder 34개, active alias 45개를 fail-closed route로 등록; `s8086`은 원본과 같이 무alias 진단 전용 | -| 자동 테스트 | 완료 | 최신 .NET solution 2,817/2,817, LegacyWeb 117/117, JavaScript 502/502, 원본 baseline 35/35 통과; 실패·skip 0. Release 빌드 warning 0, error 0 | -| 개발 MSIX UI/DB | 완료 | 실제 Windows 입력으로 UC1~UC7, GraphE, FSell, VIList, ThemeA, EList, PList 모달, 검색·키보드·drag와 개발 DB 저장→fresh readback→delete/absence를 검증하고 cleanup 완료 | +| 자동 테스트 | 완료, 최종 합계 갱신 대기 | 마지막 전체 .NET solution 3,115/3,115, 별도 LegacyParityWeb 127/127, WebPlayout JavaScript 424/424 통과. 그 뒤 즉시 선택·PROGRAM enabled·빈 검색·설정·비교 import 및 s6001 staged workflow의 focused/static 검증도 통과했으며, 전체 solution 최종 합계는 재실행 뒤 갱신 | +| 개발 MSIX UI/DB | **직전 패키지 PASS · 최신 코드 물리 재검증 대기** | 직전 등록 AppX에서 실제 Windows 입력으로 UC1~UC7, GraphE, FSell, VIList, ThemeA, EList, PList 모달, 검색·키보드·drag와 개발 DB 저장→fresh readback→delete/absence를 검증하고 cleanup 완료. 이후 교정 사항과 설정/비교 local-state를 포함한 최신 AppX 회차는 실행 전 | | 고객 배포 MSIX | 현재 범위에서 보류 | 현재 결과는 unsigned 개발 패키지. 고객용 version·Publisher·서명 인증서·업데이트와 새 PC 로컬 설정 배포는 사용자가 재개할 때 결정 | | 실제 DB read→DTO→mutation smoke | 완료 | 34개 도달 가능 builder의 data route 통과: 33개 Oracle/MariaDB loader와 `s5025` trusted 외부 CP949 파일. 무alias `s8086` diagnostic과 NXT restore audit를 포함한 실제 Oracle/MariaDB query 58건이 성공했다. 종속 asset 또는 PGM 준비 상태는 이 행의 범위가 아니다. | -| 종속 asset preflight | **외부자산 필요** | 승인 Cuts에서 `s5006`의 `Video\큐브배경.vrv`와 `s6001` 해외지수용 국가별 영상 13개가 없다. 누락·빈 파일·root 탈출·reparse 검사는 완료됐고 제공 전에는 두 route를 fail closed한다. | -| 이번 마이그레이션의 실제 Tornado2/PGM | **32/34 runtime route 완료** | 현재 작업 트리로 5001/5074를 재검증하고 자산 비의존 30개 route를 순차 검증했다. 5077 6행의 1/20→20/20, 마지막 페이지, 5088 12행 전환, 이전 scene unload와 최종 TAKE OUT/IDLE도 확인했다. 미검증은 외부자산이 없는 `s5006`/`s6001`뿐이다. | +| 종속 asset preflight | **필수 통과 · optional 외부자산 14개 제한** | 설정 Cuts root는 active `.t2s` alias 45개와 필수 built-in asset 9개를 모두 요구한다. `s5006`의 `Video\큐브배경.vrv`와 `s6001` 국가 영상 13개만 optional missing으로 수용하며 관련 action만 fail closed한다. 빈 파일·root 탈출·reparse·중간 junction 검사는 유지한다. | +| 이번 마이그레이션의 실제 Tornado2/PGM | **32/34 runtime route 최신 실측 · s6001 4-action 대기** | 최신 `LATEST-CORE-01` 6/6, `THIRTY-02`+`RECOVERY8001-01`+`THIRTY20-29-01`의 30 route 30/30, 최신 `LATEST-PAGED-04` 24/24를 실제 PGM에서 확인했다. 5077 6행 1/20→20/20, 5088 12행 1/20 전환, 이전 scene unload와 최종 TAKE OUT/IDLE도 포함한다. `s6001` 영상 비의존 4 action/18-stage workflow는 구현·static PASS이나 실제 PGM은 실행 전이다. | -2026-07-11 Round H와 실패 회차는 역사적 증거로 보존한다. 현재 32개 route 판정은 -`MBNWEB-20260718-DEV-LIVE-01/02/05`의 실제 PGM 화면, Network Monitoring request/success, -`FAILURE=0`, `ERROR=0`, retry 0, `OutcomeUnknown=false`를 기준으로 한다. +2026-07-11 Round H, `CORE5001-05`, `PAGED-01`과 기존 중단 회차는 역사적 증거로 +보존한다. 현재 32개 route 및 PageN 판정의 기준선은 다음 2026-07-22 staged evidence다. + +- `MBNWEB-20260722-LATEST-CORE-01`: 6/6. 5001 TAKE IN → 5074 playlist NEXT → + 5074 Page NEXT 2/2 → TAKE OUT/IDLE을 확인했다. +- `MBNWEB-20260722-THIRTY-02`: 자산 비의존 route 0~18의 전체 sequence와 + route 19 `8001` TAKE IN/PGM을 확인했다. `8001`의 원본 첫 refresh가 120초인데 + runner wait 상한이 90초였어서 대기 stage만 timeout됐다. 해당 wait는 SDK + 명령을 보내지 않았고, 앱은 `PROGRAM 8001`, `OutcomeUnknown=false`의 알려진 + 상태로 유지됐으므로 앱·scene 실패로 판정하지 않는다. +- `MBNWEB-20260722-RECOVERY8001-01`: read-only로 refresh settle을 확인한 뒤 + 알려진 `PROGRAM 8001` 상태에서 TAKE OUT을 단 한 번 수행해 IDLE로 복귀했다. +- `MBNWEB-20260722-THIRTY20-29-01`: 41/41. route 20~29를 이어서 검증했다. +- `MBNWEB-20260722-LATEST-PAGED-04`: 24/24. 5077 page 1/20에서 19회 Page NEXT로 + 20/20까지 전환하고, playlist NEXT로 5088 page 1/20을 출력한 뒤 + TAKE OUT/UNLOAD/IDLE을 확인했다. + +자산 비의존 30개 route의 실제 PGM 결과는 모두 성공이며 Network Monitoring +request/success가 일치한다. 완료 기준선의 `FAILURE`, `ERROR`, safety-probe +`blocked`, `violation`, `OutcomeUnknown`은 모두 0이고 명령 retry도 0이다. + +staged runner의 authoritative cut inventory는 기존 32개에 `6001`을 추가한 33개다. +`s6001`의 두바이유·WTI·브렌트유·금 action은 필수 built-in +`images\주유기merge.png`, `images\35752913_l.jpg`를 source/runtime hash로 고정하고 +물리 double-click으로 4행을 만든 뒤 action마다 activate→TAKE IN→read-only wait→TAKE OUT을 +한 번씩 수행하도록 구현했다. 전체 18 stage와 static audit는 통과했지만, 아직 실제 PGM 회차를 +실행하지 않았으므로 `TOR-22` 또는 route 완료로 표기하지 않는다. + +5077 Page NEXT가 약 65초 동안 완성되지 않는다는 의심은 PGM 결함이 아니다. +19회 Page NEXT는 각각 effect/`Prepare(10)`/`Play(10)`을 한 번씩만 수행했고, +`Play` 후 `SCENE_PLAYED`는 144~188ms 안에 도착했다. page 9와 page 20의 즉시·45초· +65초 명명 capture는 각 page 내에서 바이트 단위로 동일하고, 지연 capture가 +있는 17개 stage도 모두 즉시 capture와 해시가 같다. 따라서 이미지 관찰 도구의 +표시·판독 artifact로 기록하며 fade 단위, 중복 prepare/play 또는 scene 자산 +회귀로 분류하지 않는다. 원본과 해당 evidence 패키지의 `5077.t2s` SHA-256도 +`845B99DFC499B5BDCD84058D68D91D849D59FB6692CB44784118A99EA68A20E2`로 동일하다. ## 표기 @@ -49,16 +82,17 @@ Mutation 약어는 실제 COM 메서드를 Web 입력에 노출하지 않는 COM 실제 데이터 상태는 다음과 같이 기록한다. - `DB-P`: 실제 Oracle/MariaDB 조회 → typed DTO → mutation preflight가 통과했다. -- `FILE-P`: `s5025`의 trusted 외부 CP949 수동 파일 → DTO → mutation preflight가 통과했다. 실제 파일과 디렉터리는 계속 Git 밖에 둔다. +- `FILE-P`: `s5025`의 trusted CP949 수동 파일 → DTO → mutation preflight가 통과했다. 현재 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\OperatorData`의 개인/외국인/기관/VI발동 4파일은 authoritative 원본 `bin\Debug\Data`와 length·SHA-256이 정확히 같다. 실제 파일과 디렉터리는 계속 Git 밖에 둔다. - `DB-DP`: 원본 MainForm에서 도달하지 않는 `s8086` diagnostic 조회와 mutation preflight가 통과했다. 이 결과로 runtime alias를 만들지는 않는다. - `ASSET-X`: builder가 요구하는 외부 asset이 승인 Cuts에 없거나 강화된 preflight가 끝나지 않아 실제 PREPARE/PGM 준비 상태가 아니다. -- `TOR-18`: 2026-07-18 현재 작업 트리의 Development Live에서 PGM 화면과 Network Monitoring을 함께 검증했다. +- `TOR-22`: 2026-07-22 해당 staged evidence가 고정한 Development Live 빌드에서 PGM 화면과 Network Monitoring을 함께 검증했다. +- `TOR-PENDING`: 실제 PGM용 폐쇄형 workflow와 정적 검증은 완료됐지만 아직 Development Live stage를 실행하지 않았다. - `TOR-X`: 필요한 외부 제작 자산이 없어 PREPARE 전에 fail closed했다. - `TOR-NA`: active alias가 없어 운영 송출 대상이 아니다. ## 자동 테스트 묶음 -아래 묶음은 최신 Core Debug/Release x64 전체 실행에 포함돼 실패 0건으로 통과했다. 모든 행에는 공통으로 `LegacySceneCatalogTests`, `LegacySceneMutationBuilderRegistryTests`, `LegacySceneRuntimeCoverageTests`, `LegacySceneDataSourceRouterTests`가 적용된다. +아래 묶음은 마지막 전체 Core Debug/Release x64 실행에 포함돼 실패 0건으로 통과했다. 이후 변경의 focused 검증도 통과했으며 최종 전체 합계는 재실행 뒤 갱신한다. 모든 행에는 공통으로 `LegacySceneCatalogTests`, `LegacySceneMutationBuilderRegistryTests`, `LegacySceneRuntimeCoverageTests`, `LegacySceneDataSourceRouterTests`가 적용된다. | 코드 | 테스트 파일 | |---|---| @@ -77,40 +111,40 @@ Mutation 약어는 실제 COM 메서드를 Web 입력에 노출하지 않는 COM | Builder / alias / page | 원본 기능 | 새 builder → loader → resolver/route | mutation | 자동 테스트 | 실제 데이터 | 실제 Tornado | |---|---|---|---|---|---|---| -| `s5001` / `5001`, `N5001` / — | 국내·NXT·해외 지수, 환율, 업종, 종목 단일 시세와 등락 표식 | `S5001SceneMutationBuilder` → `S5001SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-18` | +| `s5001` / `5001`, `N5001` / — | 국내·NXT·해외 지수, 환율, 업종, 종목 단일 시세와 등락 표식 | `S5001SceneMutationBuilder` → `S5001SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-22` | | `s5006` / `5006` / — | 국내·NXT 종목 현재·시가·고가·저가와 비율, 등락 상태, 큐브 배경 영상 | `S5006SceneMutationBuilder` → `S5006DomesticSceneDataLoader`/`S5006NxtSceneDataLoader` → market 직접 route | `V, Vis, C, BgV` | `T-PANEL` 통과 | `DB-P`; `Video\큐브배경.vrv` 외부 자산 누락 | `TOR-X` | -| `s5011` / `5011` / — | 국내·NXT 종목 시세, 액면가, 자본금, 시가총액, 순위 | `S5011SceneMutationBuilder` → `S5011SceneDataLoader` → branch 직접 route | `V, Vis, C` | `T-PANEL` 통과 | `DB-P` | `TOR-18` | -| `s5016` / `5016` / — | 미국·중화권·유럽·아시아 지수와 채권·환율·원자재 3열 panel | `S5016SceneMutationBuilder` → `S5016SceneDataLoader` → closed target 직접 route | `V, Vis` | `T-PANEL` 통과 | `DB-P` | `TOR-18` | -| `s50160` / `50160` / — | 원면·국제금·국내금 2열 panel | `S50160SceneMutationBuilder` → `S50160SceneDataLoader` → closed target 직접 route | `V, Vis` | `T-PANEL` 통과 | `DB-P` | `TOR-18` | -| `s5023` / `5023` / — | 코스피·코스닥 일별/월합계 주체별 매매동향 grid | `S5023SceneMutationBuilder` → `S5023SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID` 통과 | `DB-P` | `TOR-18` | -| `s5024` / `5024` / — | 코스피·코스닥 매매동향 막대, 중앙선과 양·음 크기/위치 | `S5024SceneMutationBuilder` → `S5024SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis, Pos, Scale` | `T-GRID` 통과 | `DB-P` | `TOR-18` | -| `s5025` / `5025` / — | 승인된 수동 파일의 개인·외국인·기관 순매도 좌·우 5쌍 | `S5025SceneMutationBuilder` → `S5025SceneDataLoader`/`S5025TrustedManualFileDataSource` → `LegacyGridMarketSceneRequestResolver` | `V` | `T-GRID`, `T-MANUAL` 통과 | `FILE-P`; 외부 CP949 파일 통합 검증 완료 | `TOR-18` | -| `s5026` / `5026` / — | 두 국내 종목 주간 candle과 각 종목 시세/OHLC | `S5026SceneMutationBuilder` → `S5026SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, C, Crop` | `T-COMP` 통과 | `DB-P` | `TOR-18` | -| `s5029` / `5029` / — | 두 종목 candle·수익률 비교와 두 path-shape | `S5029SceneMutationBuilder` → `S5029SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-18` | -| `s5032` / `8018`, `8032`, `5032` 중 선물 조건 / — | 선물을 포함한 좌·우 두 항목 plate | `S5032SceneMutationBuilder` → `S5032SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-18` | -| `s5037` / `5037` / — | 국내 종목 현재가와 매수·매도 거래원별 수량 | `S5037SceneMutationBuilder` → `S5037SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis` | `T-GRID` 통과 | `DB-P` | `TOR-18` | -| `s5074` / `5074` / 5 | Oracle/MariaDB/DataManager 계열의 최대 5행 시세·수익률 목록 | `S5074SceneMutationBuilder` → `S5074SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-18` (page 1/2) | -| `s5076` / `5076` / — | 주요매출 구성, 기준일, 항목 비율과 누적 원형 각도 | `S5076SceneMutationBuilder` → `S5076SceneDataLoader` → subject 직접 route | `V, Vis, Angle` | `T-FOUND` 통과 | `DB-P` | `TOR-18` | -| `s5077` / `5077` / 6 | Oracle/MariaDB/DataManager 계열의 최대 6행 시세·수익률 목록 | `S5077SceneMutationBuilder` → `S5077SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-18` (1/20→20/20) | -| `s5078` / `5078` / — | 미국·국내 섹터지수 값과 양·음 막대 크기 | `S5078SceneMutationBuilder` → `S5078SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, Scale` | `T-CHART` 통과 | `DB-P` | `TOR-18` | -| `s5079` / `5079` / — | 성장성 지표 기간·값과 복수 path | `S5079SceneMutationBuilder` → `S5079SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, Path` | `T-CHART` 통과 | `DB-P` | `TOR-18` | -| `s5080` / `5080` / — | 매출액 분기 시계열과 양·음 막대/중앙선 | `S5080SceneMutationBuilder` → `S5080SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, PosK, Scale` | `T-CHART` 통과 | `DB-P` | `TOR-18` | -| `s5081` / `5081` / — | 영업이익 분기 시계열과 양·음 막대/중앙선 | `S5081SceneMutationBuilder` → `S5081SceneDataLoader` → subject 직접 route | `V, Vis, PosK, Scale` | `T-FOUND` 통과 | `DB-P` | `TOR-18` | -| `s5082` / `5082` / — | 일자·개인·기관·외국인 매매동향 grid와 부호 variant | `S5082SceneMutationBuilder` → `S5082SceneDataLoader` → 직접 route | `V, Vis` | `T-5082` 통과 | `DB-P` | `TOR-18` | -| `s5083` / `5083` / — | 개인·기관·외국인 매매 시계열, baseline과 세 path | `S5083SceneMutationBuilder` → `S5083SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Pos, Path` | `T-CHART` 통과 | `DB-P` | `TOR-18` | -| `s5084` / `5084` / — | 코스피·코스닥 매매 시계열, baseline과 path | `S5084SceneMutationBuilder` → `S5084SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Pos, Path` | `T-CHART` 통과 | `DB-P` | `TOR-18` | -| `s5085` / `5085` / — | 프로그램 매매 grid의 구분별 금액과 부호 색상 | `S5085SceneMutationBuilder` → `S5085SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID`, `T-FOUND` 통과 | `DB-P` | `TOR-18` | -| `s5086` / `5086` / — | 국내·해외 지수/종목/업종 수익률 시계열과 path-shape | `S5086SceneMutationBuilder` → `S5086SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-18` | -| `s50860` / `50860` / — | 국내·해외 지수/종목/업종 line 시계열과 path-shape | `S50860SceneMutationBuilder` → `S50860SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, C, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-18` | -| `s5087` / `5087` / — | 두 국내 종목 candle·수익률 비교와 두 line shape | `S5087SceneMutationBuilder` → `S5087SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-18` | -| `s5088` / `5088` / 12 | Oracle/MariaDB/DataManager 계열의 최대 12행 시세·수익률 목록 | `S5088SceneMutationBuilder` → `S5088SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-18` (5077 뒤 1/20) | -| `s6001` / `6001` / — | 해외지수와 유가·금 단일 plate, 연계 이미지·영상/방향 상태 | `S6001SceneMutationBuilder` → `S6001SceneDataLoader` → closed target 직접 route | `V, A, Vis, C` | `T-PANEL` 통과 | `DB-P`; 해외지수 국가별 영상 13개 `ASSET-X` | `TOR-X`; 자산 제공 전 PREPARE 금지 | -| `s6067` / `6067` / — | 기관 순매수 grid와 부호 색상 | `S6067SceneMutationBuilder` → `S6067SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID`, `T-FOUND` 통과 | `DB-P` | `TOR-18` | -| `s8001` / `8001`, `8002` / — | 코스피·코스닥 업종 square chart와 cube 색상 | `S8001SceneMutationBuilder` → `S8001SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, C` | `T-PARAM` 통과 | `DB-P` | `TOR-18` | -| `s8003` / `8003` / — | 국내 종목 호가·시세와 매수·매도 잔량 막대 | `S8003SceneMutationBuilder` → `S8003SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis, C, Scale` | `T-GRID` 통과 | `DB-P` | `TOR-18` | -| `s8010` / `8035`, `8061`, `8040`, `8046`, `8051`, `8056` / — | 지수·종목·거래정지·해외 candle, 거래량, 예상지수, 이동평균 path | `S8010SceneMutationBuilder` → `S8010SceneDataLoader` → alias/market/mode 직접 route | `V, Vis, C, Pos, Crop, Path` | `T-CANDLE` 통과 | `DB-P` | `TOR-18` | -| `s8018` / `8018`, `8032`, `5032` 중 비선물 조건 / — | 국내·NXT·해외·업종 좌·우 두 항목 plate | `S8018SceneMutationBuilder` → `S8018SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-18` | -| `s8067` / `8067`, `5068`, `5070`, `5072` / — | 글로벌 world-map의 지역별 현재가·등락과 방향/배경 상태 | `S8067SceneMutationBuilder` → `S8067SceneDataLoader` → 직접 route | `V, Vis, C` | `T-FOUND`, `T-PANEL` 통과 | `DB-P` | `TOR-18` | +| `s5011` / `5011` / — | 국내·NXT 종목 시세, 액면가, 자본금, 시가총액, 순위 | `S5011SceneMutationBuilder` → `S5011SceneDataLoader` → branch 직접 route | `V, Vis, C` | `T-PANEL` 통과 | `DB-P` | `TOR-22` | +| `s5016` / `5016` / — | 미국·중화권·유럽·아시아 지수와 채권·환율·원자재 3열 panel | `S5016SceneMutationBuilder` → `S5016SceneDataLoader` → closed target 직접 route | `V, Vis` | `T-PANEL` 통과 | `DB-P` | `TOR-22` | +| `s50160` / `50160` / — | 원면·국제금·국내금 2열 panel | `S50160SceneMutationBuilder` → `S50160SceneDataLoader` → closed target 직접 route | `V, Vis` | `T-PANEL` 통과 | `DB-P` | `TOR-22` | +| `s5023` / `5023` / — | 코스피·코스닥 일별/월합계 주체별 매매동향 grid | `S5023SceneMutationBuilder` → `S5023SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID` 통과 | `DB-P` | `TOR-22` | +| `s5024` / `5024` / — | 코스피·코스닥 매매동향 막대, 중앙선과 양·음 크기/위치 | `S5024SceneMutationBuilder` → `S5024SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis, Pos, Scale` | `T-GRID` 통과 | `DB-P` | `TOR-22` | +| `s5025` / `5025` / — | 승인된 수동 파일의 개인·외국인·기관 순매도 좌·우 5쌍 | `S5025SceneMutationBuilder` → `S5025SceneDataLoader`/`S5025TrustedManualFileDataSource` → `LegacyGridMarketSceneRequestResolver` | `V` | `T-GRID`, `T-MANUAL` 통과 | `FILE-P`; 외부 CP949 파일 통합 검증 완료 | `TOR-22` | +| `s5026` / `5026` / — | 두 국내 종목 주간 candle과 각 종목 시세/OHLC | `S5026SceneMutationBuilder` → `S5026SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, C, Crop` | `T-COMP` 통과 | `DB-P` | `TOR-22` | +| `s5029` / `5029` / — | 두 종목 candle·수익률 비교와 두 path-shape | `S5029SceneMutationBuilder` → `S5029SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-22` | +| `s5032` / `8018`, `8032`, `5032` 중 선물 조건 / — | 선물을 포함한 좌·우 두 항목 plate | `S5032SceneMutationBuilder` → `S5032SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-22` | +| `s5037` / `5037` / — | 국내 종목 현재가와 매수·매도 거래원별 수량 | `S5037SceneMutationBuilder` → `S5037SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis` | `T-GRID` 통과 | `DB-P` | `TOR-22` | +| `s5074` / `5074` / 5 | Oracle/MariaDB/DataManager 계열의 최대 5행 시세·수익률 목록 | `S5074SceneMutationBuilder` → `S5074SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-22` (page 1/2) | +| `s5076` / `5076` / — | 주요매출 구성, 기준일, 항목 비율과 누적 원형 각도 | `S5076SceneMutationBuilder` → `S5076SceneDataLoader` → subject 직접 route | `V, Vis, Angle` | `T-FOUND` 통과 | `DB-P` | `TOR-22` | +| `s5077` / `5077` / 6 | Oracle/MariaDB/DataManager 계열의 최대 6행 시세·수익률 목록 | `S5077SceneMutationBuilder` → `S5077SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-22` (1/20→20/20) | +| `s5078` / `5078` / — | 미국·국내 섹터지수 값과 양·음 막대 크기 | `S5078SceneMutationBuilder` → `S5078SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, Scale` | `T-CHART` 통과 | `DB-P` | `TOR-22` | +| `s5079` / `5079` / — | 성장성 지표 기간·값과 복수 path | `S5079SceneMutationBuilder` → `S5079SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, Path` | `T-CHART` 통과 | `DB-P` | `TOR-22` | +| `s5080` / `5080` / — | 매출액 분기 시계열과 양·음 막대/중앙선 | `S5080SceneMutationBuilder` → `S5080SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, PosK, Scale` | `T-CHART` 통과 | `DB-P` | `TOR-22` | +| `s5081` / `5081` / — | 영업이익 분기 시계열과 양·음 막대/중앙선 | `S5081SceneMutationBuilder` → `S5081SceneDataLoader` → subject 직접 route | `V, Vis, PosK, Scale` | `T-FOUND` 통과 | `DB-P` | `TOR-22` | +| `s5082` / `5082` / — | 일자·개인·기관·외국인 매매동향 grid와 부호 variant | `S5082SceneMutationBuilder` → `S5082SceneDataLoader` → 직접 route | `V, Vis` | `T-5082` 통과 | `DB-P` | `TOR-22` | +| `s5083` / `5083` / — | 개인·기관·외국인 매매 시계열, baseline과 세 path | `S5083SceneMutationBuilder` → `S5083SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Pos, Path` | `T-CHART` 통과 | `DB-P` | `TOR-22` | +| `s5084` / `5084` / — | 코스피·코스닥 매매 시계열, baseline과 path | `S5084SceneMutationBuilder` → `S5084SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Pos, Path` | `T-CHART` 통과 | `DB-P` | `TOR-22` | +| `s5085` / `5085` / — | 프로그램 매매 grid의 구분별 금액과 부호 색상 | `S5085SceneMutationBuilder` → `S5085SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID`, `T-FOUND` 통과 | `DB-P` | `TOR-22` | +| `s5086` / `5086` / — | 국내·해외 지수/종목/업종 수익률 시계열과 path-shape | `S5086SceneMutationBuilder` → `S5086SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-22` | +| `s50860` / `50860` / — | 국내·해외 지수/종목/업종 line 시계열과 path-shape | `S50860SceneMutationBuilder` → `S50860SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, C, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-22` | +| `s5087` / `5087` / — | 두 국내 종목 candle·수익률 비교와 두 line shape | `S5087SceneMutationBuilder` → `S5087SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-22` | +| `s5088` / `5088` / 12 | Oracle/MariaDB/DataManager 계열의 최대 12행 시세·수익률 목록 | `S5088SceneMutationBuilder` → `S5088SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-22` (5077 뒤 1/20) | +| `s6001` / `6001` / — | 해외지수와 유가·금 단일 plate, 연계 이미지·영상/방향 상태 | `S6001SceneMutationBuilder` → `S6001SceneDataLoader` → closed target 직접 route | `V, A, Vis, C` | `T-PANEL` 통과 | `DB-P`; 유가·금 built-in 이미지 2개 통과, 해외지수 국가별 영상 13개 `ASSET-X` | 유가·금 4 action `TOR-PENDING`; 국가 영상 action `TOR-X` | +| `s6067` / `6067` / — | 기관 순매수 grid와 부호 색상 | `S6067SceneMutationBuilder` → `S6067SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID`, `T-FOUND` 통과 | `DB-P` | `TOR-22` | +| `s8001` / `8001`, `8002` / — | 코스피·코스닥 업종 square chart와 cube 색상 | `S8001SceneMutationBuilder` → `S8001SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, C` | `T-PARAM` 통과 | `DB-P` | `TOR-22` | +| `s8003` / `8003` / — | 국내 종목 호가·시세와 매수·매도 잔량 막대 | `S8003SceneMutationBuilder` → `S8003SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis, C, Scale` | `T-GRID` 통과 | `DB-P` | `TOR-22` | +| `s8010` / `8035`, `8061`, `8040`, `8046`, `8051`, `8056` / — | 지수·종목·거래정지·해외 candle, 거래량, 예상지수, 이동평균 path | `S8010SceneMutationBuilder` → `S8010SceneDataLoader` → alias/market/mode 직접 route | `V, Vis, C, Pos, Crop, Path` | `T-CANDLE` 통과 | `DB-P` | `TOR-22` | +| `s8018` / `8018`, `8032`, `5032` 중 비선물 조건 / — | 국내·NXT·해외·업종 좌·우 두 항목 plate | `S8018SceneMutationBuilder` → `S8018SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-22` | +| `s8067` / `8067`, `5068`, `5070`, `5072` / — | 글로벌 world-map의 지역별 현재가·등락과 방향/배경 상태 | `S8067SceneMutationBuilder` → `S8067SceneDataLoader` → 직접 route | `V, Vis, C` | `T-FOUND`, `T-PANEL` 통과 | `DB-P` | `TOR-22` | | `s8086` / active alias 없음 / — | 유가·금 3열 원천. 원본 파일은 있으나 MainForm dispatch 없음 | `S8086SceneMutationBuilder` → `S8086DiagnosticSceneDataLoader`; 앱 runtime route 없음 | `V, C` | `T-FOUND`, `T-PANEL` 통과 | `DB-DP` | `TOR-NA` | ## 공통 호출 순서 동등성 @@ -171,14 +205,16 @@ TAKE IN은 PREPARE 때의 오래된 DTO를 그대로 재생하지 않는다. 원 ## 구현 판정과 실제 송출 범위 35개 builder, 45개 active alias, DTO/mutation, 실제 DB read, 5·6·12행 PageN 경계와 -마지막 페이지, Debug/Release x64 suite와 개발 MSIX UI/DB 검증은 완료됐다. 실제 PGM은 -34개 runtime route 가운데 32개를 현재 작업 트리로 검증했다. `5001`/`5074` 재검증 뒤 -자산 비의존 30개 route를 순차 검증했으며, `5077` 20페이지 전체와 `5088` 전환, -retired scene unload 및 최종 TAKE OUT/IDLE을 포함한다. +마지막 페이지 자동 계약은 완료됐다. 직전 개발 MSIX UI/DB 검증도 PASS지만 이후 UI 교정을 +포함한 최신 AppX 물리 회차는 대기 중이다. 실제 PGM은 34개 runtime route 가운데 32개를 +검증했다. 최신 `5001`/`5074` 6/6과 자산 비의존 30개 route 30/30을 완료했고, +최신 `5077` 20페이지 전체와 `5088` 전환 24/24, retired scene unload 및 최종 +TAKE OUT/IDLE을 포함한다. 이 완료 판정에는 다음 범위 제한이 있다. -1. 실제 PGM 미검증 route는 외부 제작 자산이 없는 `s5006`과 `s6001`뿐이다. +1. 실제 PGM 미검증 route는 `s5006`과 `s6001`이다. 다만 `s6001`의 영상 비의존 4 action은 + 외부자산 없이 검증 가능하며 18-stage workflow의 실제 실행만 남았다. 2. `s5025`의 trusted 외부 CP949 파일·경로는 Git과 고객 MSIX에 포함하지 않는다. 3. `s5006`의 background video와 `s6001` 해외지수 국가 영상 13개는 승인된 asset root와 preflight가 준비된 뒤에만 실제 송출한다. diff --git a/scripts/Invoke-LegacyPackageInputSmoke.ps1 b/scripts/Invoke-LegacyPackageInputSmoke.ps1 index 241ff1c..e569342 100644 --- a/scripts/Invoke-LegacyPackageInputSmoke.ps1 +++ b/scripts/Invoke-LegacyPackageInputSmoke.ps1 @@ -1,11 +1,16 @@ [CmdletBinding()] param( + [ValidateSet('Release', 'DebugAppX')] + [string]$BuildFlavor = 'Release', + [ValidateSet('ReadOnly', 'DryRunPlayout', 'FullUiDb')] [string]$Profile = 'ReadOnly', [ValidateSet('All', 'PList', 'GraphE', 'Catalog', 'Screens')] [string]$FullUiDbScope = 'All', + [string]$RecoverNamedPlaylistTitle, + [ValidateRange(1024, 65535)] [int]$Port = 9339, @@ -19,7 +24,12 @@ param( [int]$LaunchTimeoutSeconds = 30, [ValidateRange(30, 900)] - [int]$HarnessTimeoutSeconds = 180 + [int]$HarnessTimeoutSeconds = 180, + + # The local-settings/import smoke reuses the exact package identity, launch, + # DryRun and normal-close guards from this script. Returning after the + # definitions keeps those guards single-sourced without launching anything. + [switch]$DefinitionsOnly ) Set-StrictMode -Version Latest @@ -196,7 +206,10 @@ public static class LegacyPackageInputNative private const uint MouseAbsolute = 0x8000; private const uint KeyUp = 0x0002; private const int VirtualKeyLeftButton = 0x01; + private const ushort VirtualKeyShift = 0x10; private const ushort VirtualKeyHome = 0x24; + private const ushort VirtualKeyDelete = 0x2E; + private const ushort VirtualKeyEscape = 0x1B; private const int ClientPixelTolerance = 1; public static string ReadPackageFullName(IntPtr process) @@ -410,6 +423,349 @@ public static class LegacyPackageInputNative } } + public static void SendShiftClickRangeThenDeleteAndRestore( + IntPtr window, + int processId, + int forbiddenPort, + double rangeStartCssX, + double rangeStartCssY, + double rangeEndCssX, + double rangeEndCssY, + double restoreCssX, + double restoreCssY, + double viewportCssWidth, + double viewportCssHeight, + double devicePixelRatio) + { + if (window == IntPtr.Zero || processId <= 0 || forbiddenPort < 1 || + forbiddenPort > 65535 || !IsFinitePositive(viewportCssWidth) || + !IsFinitePositive(viewportCssHeight) || !IsFinitePositive(devicePixelRatio) || + devicePixelRatio < 0.25 || devicePixelRatio > 5.0) + { + throw new InvalidOperationException("The Windows Shift-range input contract is invalid."); + } + + AssertKeyReleased(VirtualKeyLeftButton, "The left mouse button was held before Shift selection."); + AssertKeyReleased(VirtualKeyShift, "Shift was held before Shift selection."); + AssertKeyReleased(VirtualKeyDelete, "Delete was held before Shift selection."); + AssertWindowOwnedByProcess(window, processId); + AssertNoForbiddenTcp(processId, forbiddenPort); + ClientGeometry baseline = ReadAndAssertClientGeometry( + window, viewportCssWidth, viewportCssHeight, devicePixelRatio); + AcquireForeground(window, processId, forbiddenPort); + SleepWithWindowGuard( + 500, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline); + + Point rangeStart = CssPointToScreen( + baseline, rangeStartCssX, rangeStartCssY, devicePixelRatio); + Point rangeEnd = CssPointToScreen( + baseline, rangeEndCssX, rangeEndCssY, devicePixelRatio); + Point restore = CssPointToScreen( + baseline, restoreCssX, restoreCssY, devicePixelRatio); + if ((rangeStart.X == rangeEnd.X && rangeStart.Y == rangeEnd.Y) || + (rangeEnd.X == restore.X && rangeEnd.Y == restore.Y)) + { + throw new InvalidOperationException("The Windows Shift-range points are not distinct."); + } + AssertPointOwnedByWindow(window, rangeStart); + AssertPointOwnedByWindow(window, rangeEnd); + AssertPointOwnedByWindow(window, restore); + + bool leftButtonDown = false; + bool shiftDown = false; + bool deleteDown = false; + try + { + SendMouseMove(rangeStart.X, rangeStart.Y); + SleepWithWindowGuard( + 100, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline); + AssertCursorAt(rangeStart); + SendMouseButton(MouseLeftDown); + leftButtonDown = true; + SleepWithWindowGuard( + 45, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline); + AssertCursorAt(rangeStart); + AssertKeyState( + VirtualKeyLeftButton, + true, + "The left mouse button changed during the range-start click."); + SendMouseButton(MouseLeftUp); + leftButtonDown = false; + SleepWithWindowGuard( + 300, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline); + + SendKeyboard(VirtualKeyShift, false); + shiftDown = true; + AssertKeyState(VirtualKeyShift, true, "Shift did not remain held for range selection."); + SendMouseMove(rangeEnd.X, rangeEnd.Y); + SleepWithWindowGuard( + 100, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline); + AssertCursorAt(rangeEnd); + AssertKeyState(VirtualKeyShift, true, "Shift changed before the range click."); + SendMouseButton(MouseLeftDown); + leftButtonDown = true; + SleepWithWindowGuard( + 45, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline); + AssertCursorAt(rangeEnd); + AssertKeyState(VirtualKeyShift, true, "Shift changed during the range click."); + AssertKeyState( + VirtualKeyLeftButton, + true, + "The left mouse button changed during the range-end click."); + SendMouseButton(MouseLeftUp); + leftButtonDown = false; + SendKeyboard(VirtualKeyShift, true); + shiftDown = false; + SleepWithWindowGuard( + 300, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline); + + SendKeyboard(VirtualKeyDelete, false); + deleteDown = true; + SleepWithWindowGuard( + 45, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline); + AssertKeyState(VirtualKeyDelete, true, "Delete changed during the delete key press."); + SendKeyboard(VirtualKeyDelete, true); + deleteDown = false; + SleepWithWindowGuard( + 650, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline); + + AssertPointOwnedByWindow(window, restore); + SendMouseMove(restore.X, restore.Y); + SleepWithWindowGuard( + 100, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline); + AssertCursorAt(restore); + SendMouseButton(MouseLeftDown); + leftButtonDown = true; + SleepWithWindowGuard( + 45, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline); + AssertCursorAt(restore); + AssertKeyState( + VirtualKeyLeftButton, + true, + "The left mouse button changed during restored selection."); + SendMouseButton(MouseLeftUp); + leftButtonDown = false; + SleepWithWindowGuard( + 300, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline); + } + finally + { + if (deleteDown) SendKeyboard(VirtualKeyDelete, true); + if (shiftDown) SendKeyboard(VirtualKeyShift, true); + if (leftButtonDown) SendMouseButton(MouseLeftUp); + AssertKeyReleased(VirtualKeyDelete, "Delete remained held after Shift-range cleanup."); + AssertKeyReleased(VirtualKeyShift, "Shift remained held after Shift-range cleanup."); + AssertKeyReleased( + VirtualKeyLeftButton, + "The left mouse button remained held after Shift-range cleanup."); + } + } + + public static void SendApplicationClick( + IntPtr window, + int processId, + int forbiddenPort, + double cssX, + double cssY, + double viewportCssWidth, + double viewportCssHeight, + double devicePixelRatio, + bool allowForegroundTransitionAfterClick) + { + if (window == IntPtr.Zero || processId <= 0 || forbiddenPort < 1 || + forbiddenPort > 65535 || !IsFinitePositive(viewportCssWidth) || + !IsFinitePositive(viewportCssHeight) || !IsFinitePositive(devicePixelRatio) || + devicePixelRatio < 0.25 || devicePixelRatio > 5.0) + { + throw new InvalidOperationException("The Windows click coordinate contract is invalid."); + } + + AssertKeyReleased(VirtualKeyLeftButton, "The left mouse button was held before input."); + AssertWindowOwnedByProcess(window, processId); + AssertNoForbiddenTcp(processId, forbiddenPort); + ClientGeometry baseline = ReadAndAssertClientGeometry( + window, viewportCssWidth, viewportCssHeight, devicePixelRatio); + AcquireForeground(window, processId, forbiddenPort); + SleepWithWindowGuard( + 150, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline); + + Point point = CssPointToScreen(baseline, cssX, cssY, devicePixelRatio); + AssertPointOwnedByWindow(window, point); + bool leftButtonDown = false; + try + { + SendMouseMove(point.X, point.Y); + SleepWithInputGuard( + 100, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline, point, point, + point, false, false); + SendMouseButton(MouseLeftDown); + leftButtonDown = true; + SleepWithInputGuard( + 45, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline, point, point, + point, true, false); + SendMouseButton(MouseLeftUp); + leftButtonDown = false; + if (allowForegroundTransitionAfterClick) + { + Thread.Sleep(45); + AssertWindowOwnedByProcess(window, processId); + AssertNoForbiddenTcp(processId, forbiddenPort); + } + else + { + SleepWithInputGuard( + 100, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline, point, point, + point, false, false); + } + } + finally + { + if (leftButtonDown) + { + SendMouseButton(MouseLeftUp); + } + AssertKeyReleased( + VirtualKeyLeftButton, + "The left mouse button remained held after click cleanup."); + } + } + + public static void SendApplicationDoubleClick( + IntPtr window, + int processId, + int forbiddenPort, + double cssX, + double cssY, + double viewportCssWidth, + double viewportCssHeight, + double devicePixelRatio, + int interClickDelayMilliseconds) + { + if (window == IntPtr.Zero || processId <= 0 || forbiddenPort < 1 || + forbiddenPort > 65535 || !IsFinitePositive(viewportCssWidth) || + !IsFinitePositive(viewportCssHeight) || !IsFinitePositive(devicePixelRatio) || + devicePixelRatio < 0.25 || devicePixelRatio > 5.0 || + interClickDelayMilliseconds < 50 || interClickDelayMilliseconds > 250) + { + throw new InvalidOperationException("The Windows double-click contract is invalid."); + } + + AssertKeyReleased(VirtualKeyLeftButton, "The left mouse button was held before double-click input."); + AssertWindowOwnedByProcess(window, processId); + AssertNoForbiddenTcp(processId, forbiddenPort); + ClientGeometry baseline = ReadAndAssertClientGeometry( + window, viewportCssWidth, viewportCssHeight, devicePixelRatio); + AcquireForeground(window, processId, forbiddenPort); + SleepWithWindowGuard( + 150, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline); + + Point point = CssPointToScreen(baseline, cssX, cssY, devicePixelRatio); + AssertPointOwnedByWindow(window, point); + bool leftButtonDown = false; + try + { + SendMouseMove(point.X, point.Y); + SleepWithWindowGuard( + 100, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline); + AssertCursorAt(point); + for (int click = 0; click < 2; click++) + { + SendMouseButton(MouseLeftDown); + leftButtonDown = true; + SleepWithWindowGuard( + 45, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline); + AssertCursorAt(point); + AssertKeyState( + VirtualKeyLeftButton, + true, + "The left mouse button changed during double-click input."); + SendMouseButton(MouseLeftUp); + leftButtonDown = false; + if (click == 0) + { + SleepWithWindowGuard( + interClickDelayMilliseconds, + window, + processId, + forbiddenPort, + viewportCssWidth, + viewportCssHeight, + devicePixelRatio, + baseline); + AssertCursorAt(point); + } + } + SleepWithWindowGuard( + 300, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline); + } + finally + { + if (leftButtonDown) SendMouseButton(MouseLeftUp); + AssertKeyReleased( + VirtualKeyLeftButton, + "The left mouse button remained held after double-click cleanup."); + } + } + + public static IntPtr ReadForegroundWindow() + { + return GetForegroundWindow(); + } + + public static void SendEscapeToForegroundWindow(IntPtr expectedWindow) + { + IntPtr foreground = GetForegroundWindow(); + if (expectedWindow == IntPtr.Zero || foreground == IntPtr.Zero || + GetAncestor(foreground, GetAncestorRoot) != expectedWindow) + { + throw new InvalidOperationException( + "The exact folder picker does not own foreground keyboard input."); + } + + AssertKeyReleased(VirtualKeyEscape, "Escape was held before picker cancellation."); + bool escapeDown = false; + try + { + SendKeyboard(VirtualKeyEscape, false); + escapeDown = true; + Thread.Sleep(45); + // Escape cancellation may return foreground to the owner while + // the picker is still being destroyed. Key-up only releases the + // injected key; the caller separately proves that this exact + // picker closes and never sends another Escape. + SendKeyboard(VirtualKeyEscape, true); + escapeDown = false; + } + finally + { + if (escapeDown) + { + SendKeyboard(VirtualKeyEscape, true); + } + AssertKeyReleased(VirtualKeyEscape, "Escape remained held after picker cancellation."); + } + } + private static void AcquireForeground(IntPtr window, int processId, int forbiddenPort) { AssertWindowOwnedByProcess(window, processId); @@ -930,8 +1286,19 @@ $K3dInteropHashVariable = 'MBN_STOCK_K3D_INTEROP_SHA256' $WebViewArgumentsVariable = 'WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS' $RepositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $HarnessPath = Join-Path $PSScriptRoot 'Test-LegacyPackageInput.mjs' -$ExpectedInstallLocation = Join-Path $RepositoryRoot ` - 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Release\net8.0-windows10.0.19041.0\win-x64' +$ExpectedInstallRelativePath = switch ($BuildFlavor) { + 'Release' { + 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Release\net8.0-windows10.0.19041.0\win-x64' + break + } + 'DebugAppX' { + 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Debug\net8.0-windows10.0.19041.0\win-x64\AppX' + break + } + default { Throw-SafeFailure 'The package build flavor is not closed.' } +} +$PackageModeLabel = "Development $BuildFlavor x64" +$ExpectedInstallLocation = Join-Path $RepositoryRoot $ExpectedInstallRelativePath $ExpectedInstallLocation = [IO.Path]::GetFullPath($ExpectedInstallLocation) $ConfigurationPath = Join-Path ` ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) ` @@ -1111,6 +1478,72 @@ function Assert-NoUnsafeEnvironment { } } +function Get-StreamSha256Hex([IO.Stream]$Stream) { + $algorithm = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($algorithm.ComputeHash($Stream)).Replace('-', '') + } + finally { + $algorithm.Dispose() + } +} + +function Assert-DebugAppXMatchesLatestPackage([string]$InstallLocation) { + if ($BuildFlavor -cne 'DebugAppX') { + return + } + + $projectRoot = Join-Path $RepositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp' + $msix = Join-Path $projectRoot ` + 'AppPackages\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64_Debug_Test\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64_Debug.msix' + if (-not [IO.File]::Exists($msix)) { + Throw-SafeFailure 'The latest Debug MSIX is missing; the registered AppX cannot be proven current.' + } + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead($msix) + try { + $expectedFiles = @( + 'MBN_STOCK_WEBVIEW.Core.dll', + 'MBN_STOCK_WEBVIEW.Infrastructure.dll', + 'MBN_STOCK_WEBVIEW.LegacyApplication.dll', + 'MBN_STOCK_WEBVIEW.LegacyBridge.dll', + 'MBN_STOCK_WEBVIEW.LegacyParityApp.dll', + 'Web/app.js' + ) + foreach ($relativePath in $expectedFiles) { + $entry = $archive.GetEntry($relativePath) + $installed = Join-Path $InstallLocation $relativePath.Replace('/', '\') + if ($null -eq $entry -or -not [IO.File]::Exists($installed)) { + Throw-SafeFailure 'The registered Debug AppX is missing a required current package file.' + } + + $entryStream = $entry.Open() + try { + $packageHash = Get-StreamSha256Hex $entryStream + } + finally { + $entryStream.Dispose() + } + $installedHash = (Get-FileHash -LiteralPath $installed -Algorithm SHA256).Hash + if ($installedHash -cne $packageHash) { + Throw-SafeFailure 'The registered Debug AppX is stale relative to the latest Debug MSIX.' + } + } + } + finally { + $archive.Dispose() + } + + $sourceAppJs = Join-Path $projectRoot 'Web\app.js' + $installedAppJs = Join-Path $InstallLocation 'Web\app.js' + if (-not [IO.File]::Exists($sourceAppJs) -or + (Get-FileHash -LiteralPath $sourceAppJs -Algorithm SHA256).Hash -cne + (Get-FileHash -LiteralPath $installedAppJs -Algorithm SHA256).Hash) { + Throw-SafeFailure 'The registered Debug AppX does not contain the current Web application source.' + } +} + function Get-ExactDevelopmentPackage { $packages = @(Get-AppxPackage -Name $PackageName -ErrorAction Stop) if ($packages.Count -ne 1) { @@ -1128,7 +1561,7 @@ function Get-ExactDevelopmentPackage { [string]$package.Version -cne $PackageVersion -or [string]$package.Architecture -cne 'X64' -or -not (Test-PathEqual ([string]$package.InstallLocation) $ExpectedInstallLocation)) { - Throw-SafeFailure 'The registered package is not the exact Release x64 development package.' + Throw-SafeFailure "The registered package is not the exact $BuildFlavor x64 development package." } try { @@ -1148,9 +1581,11 @@ function Get-ExactDevelopmentPackage { $executable = Join-Path ([string]$package.InstallLocation) $ExecutableName if (-not [IO.File]::Exists($executable)) { - Throw-SafeFailure 'The registered Release application executable is missing.' + Throw-SafeFailure "The registered $BuildFlavor application executable is missing." } + Assert-DebugAppXMatchesLatestPackage ([string]$package.InstallLocation) + return [pscustomobject]@{ Package = $package Executable = [IO.Path]::GetFullPath($executable) @@ -1356,19 +1791,50 @@ function ConvertTo-FiniteNumber([object]$Value, [string]$Label) { return $number } -function Write-WindowsInputAcknowledgement([string]$Path, [string]$Token) { +function Get-WindowsInputExchangePath( + [string]$BasePath, + [ValidateRange(1, 2)] + [int]$ExchangeIndex) { + if ($ExchangeIndex -eq 1) { + return $BasePath + } + $directory = [IO.Path]::GetDirectoryName($BasePath) + $extension = [IO.Path]::GetExtension($BasePath) + $name = [IO.Path]::GetFileNameWithoutExtension($BasePath) + return Join-Path $directory ( + $name + '.exchange-' + $ExchangeIndex.ToString('00') + $extension) +} + +function Get-ExpectedWindowsInputExchangeCount( + [string]$HarnessProfile, + [string]$HarnessScope) { + if (($HarnessProfile -ceq 'FullUiDb' -or $HarnessProfile -ceq 'full-ui-db') -and + ($HarnessScope -ceq 'PList' -or $HarnessScope -ceq 'plist' -or + $HarnessScope -ceq 'All' -or $HarnessScope -ceq 'all')) { + return 2 + } + return 1 +} + +function Write-WindowsInputAcknowledgement( + [string]$Path, + [int]$ExchangeIndex, + [string]$Token, + [string]$Operation, + [Collections.IDictionary]$InputCounts) { $acknowledgement = [ordered]@{ schemaVersion = 1 + exchangeIndex = $ExchangeIndex token = $Token result = 'PASS' - operation = 'row-header-drag-after-then-home' + operation = $Operation foregroundValidated = $true packageIdentityValidated = $true - mouseDownCalls = 1 - mouseUpCalls = 1 - homeKeyCalls = 1 inputRetryCount = 0 } + foreach ($key in $InputCounts.Keys) { + $acknowledgement[$key] = $InputCounts[$key] + } $bytes = $Utf8.GetBytes(($acknowledgement | ConvertTo-Json -Compress) + "`n") $parent = [IO.Path]::GetDirectoryName($Path) $temporaryPath = Join-Path $parent ( @@ -1406,13 +1872,209 @@ function Write-WindowsInputAcknowledgement([string]$Path, [string]$Token) { } } +function ConvertTo-ExactInputPoint( + [object]$Point, + [double]$ViewportWidth, + [double]$ViewportHeight, + [string]$Label) { + Assert-ExactJsonPropertyNames $Point @('x', 'y') $Label + $x = ConvertTo-FiniteNumber $Point.x "$Label X" + $y = ConvertTo-FiniteNumber $Point.y "$Label Y" + if ($x -lt 0 -or $x -ge $ViewportWidth -or + $y -lt 0 -or $y -ge $ViewportHeight) { + Throw-SafeFailure "$Label is outside the exact package viewport." + } + return [pscustomobject]@{ X = $x; Y = $y } +} + +function Invoke-ExactPlaylistWindowsInputRequest( + [object]$Request, + [Diagnostics.Process]$Application) { + Assert-ExactJsonPropertyNames $Request @( + 'schemaVersion', 'exchangeIndex', 'token', 'targetUrl', 'operation', + 'sourceRowId', 'targetRowId', 'rangeStartRowId', 'rangeEndRowId', + 'position', 'source', 'target', 'rangeStart', 'rangeEnd', + 'restoreSelection', 'viewport', 'devicePixelRatio', 'beforeOrder', + 'reorderedOrder', 'afterOrder', 'expectedRangeSelectedRowIds', + 'expectedSelectedRowId', 'expectedActiveRowIdAfterHome', + 'expectedFinalActiveRowId', 'expectedFocusedRowId', 'mouseDownCalls', + 'mouseUpCalls', 'homeKeyCalls', 'shiftKeyDownCalls', 'shiftKeyUpCalls', + 'deleteKeyCalls', 'inputRetryCount') 'Playlist Windows input request' + Assert-ExactJsonPropertyNames $Request.viewport @('width', 'height') ` + 'Playlist Windows input viewport' + + $sourceRowId = [string]$Request.sourceRowId + $targetRowId = [string]$Request.targetRowId + $rangeStartRowId = [string]$Request.rangeStartRowId + $rangeEndRowId = [string]$Request.rangeEndRowId + $rowIds = @($sourceRowId, $targetRowId, $rangeStartRowId, $rangeEndRowId) + if ($Request.sourceRowId -isnot [string] -or + $Request.targetRowId -isnot [string] -or + $Request.rangeStartRowId -isnot [string] -or + $Request.rangeEndRowId -isnot [string] -or + $Request.position -isnot [string] -or + [string]$Request.position -cne 'after' -or + $sourceRowId -ceq $targetRowId -or + $rangeStartRowId -ceq $rangeEndRowId -or + (@($rowIds | Select-Object -Unique)).Count -ne 4 -or + $rowIds.Where({ $_ -cnotmatch '\Alegacy-stock-[0-9]{8}\z' }).Count -ne 0 -or + [string]$Request.expectedSelectedRowId -cne $sourceRowId -or + [string]$Request.expectedFinalActiveRowId -cne $sourceRowId -or + [string]$Request.expectedFocusedRowId -cne $sourceRowId -or + (ConvertTo-FiniteNumber $Request.mouseDownCalls 'Playlist mouse-down count') -ne 4 -or + (ConvertTo-FiniteNumber $Request.mouseUpCalls 'Playlist mouse-up count') -ne 4 -or + (ConvertTo-FiniteNumber $Request.homeKeyCalls 'Playlist Home count') -ne 1 -or + (ConvertTo-FiniteNumber $Request.shiftKeyDownCalls 'Playlist Shift-down count') -ne 1 -or + (ConvertTo-FiniteNumber $Request.shiftKeyUpCalls 'Playlist Shift-up count') -ne 1 -or + (ConvertTo-FiniteNumber $Request.deleteKeyCalls 'Playlist Delete count') -ne 1) { + Throw-SafeFailure 'The playlist Windows input request is outside the exact gesture contract.' + } + + $beforeOrder = @($Request.beforeOrder | ForEach-Object { [string]$_ }) + $reorderedOrder = @($Request.reorderedOrder | ForEach-Object { [string]$_ }) + $afterOrder = @($Request.afterOrder | ForEach-Object { [string]$_ }) + $expectedRange = @($Request.expectedRangeSelectedRowIds | ForEach-Object { [string]$_ }) + if ($beforeOrder.Count -ne 5 -or $reorderedOrder.Count -ne 5 -or + $afterOrder.Count -ne 3 -or + (@($beforeOrder | Select-Object -Unique)).Count -ne 5 -or + (@($reorderedOrder | Select-Object -Unique)).Count -ne 5 -or + (@($afterOrder | Select-Object -Unique)).Count -ne 3 -or + -not (Test-ExactStringSequence $expectedRange @($rangeStartRowId, $rangeEndRowId))) { + Throw-SafeFailure 'The playlist Windows input row orders are not the exact five-to-three fixture.' + } + foreach ($rowId in $beforeOrder + $reorderedOrder + $afterOrder) { + if ($rowId -cnotmatch '\Alegacy-stock-[0-9]{8}\z') { + Throw-SafeFailure 'The playlist Windows input request contains an invalid row identity.' + } + } + + $computedReorder = New-Object 'Collections.Generic.List[string]' + foreach ($rowId in $beforeOrder) { + if ($rowId -cne $sourceRowId) { [void]$computedReorder.Add($rowId) } + } + $targetIndex = $computedReorder.IndexOf($targetRowId) + if ($targetIndex -lt 0) { + Throw-SafeFailure 'The playlist drag target is absent after removing its source.' + } + $computedReorder.Insert($targetIndex + 1, $sourceRowId) + if (-not (Test-ExactStringSequence @($computedReorder) $reorderedOrder) -or + $reorderedOrder[-2] -cne $rangeStartRowId -or + $reorderedOrder[-1] -cne $rangeEndRowId -or + [string]$Request.expectedActiveRowIdAfterHome -cne $reorderedOrder[0]) { + Throw-SafeFailure 'The playlist reorder/Home/range contract is not exact.' + } + $computedAfter = @($reorderedOrder | Where-Object { + $_ -cne $rangeStartRowId -and $_ -cne $rangeEndRowId + }) + if (-not (Test-ExactStringSequence $computedAfter $afterOrder)) { + Throw-SafeFailure 'The playlist Shift-range delete result is not exact.' + } + + $viewportWidth = ConvertTo-FiniteNumber $Request.viewport.width ` + 'Playlist Windows input viewport width' + $viewportHeight = ConvertTo-FiniteNumber $Request.viewport.height ` + 'Playlist Windows input viewport height' + $devicePixelRatio = ConvertTo-FiniteNumber $Request.devicePixelRatio ` + 'Playlist Windows input device pixel ratio' + if ($viewportWidth -lt 1 -or $viewportWidth -gt 16384 -or + $viewportHeight -lt 1 -or $viewportHeight -gt 16384 -or + $devicePixelRatio -lt 0.25 -or $devicePixelRatio -gt 5) { + Throw-SafeFailure 'The playlist Windows input viewport contract is invalid.' + } + $source = ConvertTo-ExactInputPoint $Request.source $viewportWidth $viewportHeight 'Playlist drag source' + $target = ConvertTo-ExactInputPoint $Request.target $viewportWidth $viewportHeight 'Playlist drag target' + $rangeStart = ConvertTo-ExactInputPoint $Request.rangeStart $viewportWidth $viewportHeight 'Playlist range start' + $rangeEnd = ConvertTo-ExactInputPoint $Request.rangeEnd $viewportWidth $viewportHeight 'Playlist range end' + $restore = ConvertTo-ExactInputPoint $Request.restoreSelection $viewportWidth $viewportHeight 'Playlist restore selection' + + [LegacyPackageInputNative]::SendRowHeaderDragAfterThenHome( + $Application.MainWindowHandle, $Application.Id, 30001, + $source.X, $source.Y, $target.X, $target.Y, + $viewportWidth, $viewportHeight, $devicePixelRatio) + [LegacyPackageInputNative]::SendShiftClickRangeThenDeleteAndRestore( + $Application.MainWindowHandle, $Application.Id, 30001, + $rangeStart.X, $rangeStart.Y, $rangeEnd.X, $rangeEnd.Y, + $restore.X, $restore.Y, + $viewportWidth, $viewportHeight, $devicePixelRatio) +} + +function Invoke-ExactNamedPlaylistDoubleClickRequest( + [object]$Request, + [Diagnostics.Process]$Application) { + Assert-ExactJsonPropertyNames $Request @( + 'schemaVersion', 'exchangeIndex', 'token', 'targetUrl', 'operation', + 'definitionId', 'definitionTitle', 'point', 'viewport', + 'devicePixelRatio', 'expectedPlaylistContent', 'mouseDownCalls', + 'mouseUpCalls', 'doubleClickIntervalMilliseconds', + 'inputRetryCount') 'Named-playlist double-click request' + Assert-ExactJsonPropertyNames $Request.viewport @('width', 'height') ` + 'Named-playlist double-click viewport' + if ($Request.definitionId -isnot [string] -or + [string]::IsNullOrEmpty([string]$Request.definitionId) -or + ([string]$Request.definitionId).Length -gt 256 -or + [string]$Request.definitionId -cmatch '[\x00-\x1F\x7F]' -or + $Request.definitionTitle -isnot [string] -or + ([string]$Request.definitionTitle).Length -ne 26 -or + [string]$Request.definitionTitle -cnotmatch + '\ACDX_P_[0-9]{13}_[0-9a-f]{6}\z' -or + (ConvertTo-FiniteNumber $Request.mouseDownCalls 'PList double-click mouse-down count') -ne 2 -or + (ConvertTo-FiniteNumber $Request.mouseUpCalls 'PList double-click mouse-up count') -ne 2 -or + (ConvertTo-FiniteNumber $Request.doubleClickIntervalMilliseconds ` + 'PList double-click interval') -ne 100) { + Throw-SafeFailure 'The named-playlist double-click identity/count contract is invalid.' + } + + $content = @($Request.expectedPlaylistContent) + if ($content.Count -ne 3) { + Throw-SafeFailure 'The named-playlist double-click does not preserve exactly three rows.' + } + foreach ($row in $content) { + Assert-ExactJsonPropertyNames $row @( + 'isEnabled', 'marketText', 'stockName', 'graphicType', + 'subtype', 'pageText') 'Named-playlist expected row' + if ($row.isEnabled -ne $true -or + $row.marketText -isnot [string] -or + $row.stockName -isnot [string] -or + $row.graphicType -isnot [string] -or + $row.subtype -isnot [string] -or + $row.pageText -isnot [string] -or + [string]$row.graphicType -cne '1열판기본') { + Throw-SafeFailure 'The named-playlist expected row is outside the exact saved fixture.' + } + } + $subtypes = @($content | ForEach-Object { [string]$_.subtype }) + if (-not (Test-ExactStringSequence $subtypes @('현재가', '현재가', '시간외단일가'))) { + Throw-SafeFailure 'The named-playlist expected row order is not the saved three-row fixture.' + } + + $viewportWidth = ConvertTo-FiniteNumber $Request.viewport.width ` + 'PList double-click viewport width' + $viewportHeight = ConvertTo-FiniteNumber $Request.viewport.height ` + 'PList double-click viewport height' + $devicePixelRatio = ConvertTo-FiniteNumber $Request.devicePixelRatio ` + 'PList double-click device pixel ratio' + if ($viewportWidth -lt 1 -or $viewportWidth -gt 16384 -or + $viewportHeight -lt 1 -or $viewportHeight -gt 16384 -or + $devicePixelRatio -lt 0.25 -or $devicePixelRatio -gt 5) { + Throw-SafeFailure 'The named-playlist double-click viewport contract is invalid.' + } + $point = ConvertTo-ExactInputPoint $Request.point $viewportWidth $viewportHeight ` + 'Named-playlist double-click point' + [LegacyPackageInputNative]::SendApplicationDoubleClick( + $Application.MainWindowHandle, $Application.Id, 30001, + $point.X, $point.Y, $viewportWidth, $viewportHeight, + $devicePixelRatio, 100) +} + function Invoke-ExactWindowsInputRequest( [string]$RequestPath, [string]$AcknowledgementPath, + [int]$ExpectedExchangeIndex, [Diagnostics.Process]$Application, [string]$ExpectedApplicationPath) { $script:WindowsInputRequestCalls++ - if ($script:WindowsInputRequestCalls -ne 1 -or [IO.File]::Exists($AcknowledgementPath)) { + if ($script:WindowsInputRequestCalls -ne $ExpectedExchangeIndex -or + [IO.File]::Exists($AcknowledgementPath)) { Throw-SafeFailure 'The Windows input one-shot budget or acknowledgement precondition changed.' } @@ -1421,122 +2083,74 @@ function Invoke-ExactWindowsInputRequest( 'Windows input request' ` ([datetime]::UtcNow.AddSeconds(3)) $request = $requestEvidence.Value - Assert-ExactJsonPropertyNames $request @( - 'schemaVersion', 'token', 'targetUrl', 'operation', 'sourceRowId', - 'targetRowId', 'position', 'source', 'target', 'viewport', - 'devicePixelRatio', 'beforeOrder', 'afterOrder', 'expectedSelectedRowId', - 'expectedActiveRowIdAfterHome', 'expectedFocusedRowId', 'mouseDownCalls', - 'mouseUpCalls', 'homeKeyCalls') 'Windows input request' - Assert-ExactJsonPropertyNames $request.source @('x', 'y') 'Windows input source' - Assert-ExactJsonPropertyNames $request.target @('x', 'y') 'Windows input target' - Assert-ExactJsonPropertyNames $request.viewport @('width', 'height') 'Windows input viewport' - - $sourceRowId = [string]$request.sourceRowId - $targetRowId = [string]$request.targetRowId $token = [string]$request.token $schemaVersion = ConvertTo-FiniteNumber $request.schemaVersion 'Windows input schema version' - $mouseDownCalls = ConvertTo-FiniteNumber $request.mouseDownCalls 'Windows input mouse-down count' - $mouseUpCalls = ConvertTo-FiniteNumber $request.mouseUpCalls 'Windows input mouse-up count' - $homeKeyCalls = ConvertTo-FiniteNumber $request.homeKeyCalls 'Windows input Home-key count' if ($request.token -isnot [string] -or $request.targetUrl -isnot [string] -or $request.operation -isnot [string] -or - $request.sourceRowId -isnot [string] -or - $request.targetRowId -isnot [string] -or - $request.position -isnot [string] -or - $request.expectedSelectedRowId -isnot [string] -or - $request.expectedActiveRowIdAfterHome -isnot [string] -or - $request.expectedFocusedRowId -isnot [string] -or $schemaVersion -ne 1 -or + (ConvertTo-FiniteNumber $request.exchangeIndex 'Windows input exchange index') -ne + $ExpectedExchangeIndex -or $token -cnotmatch '\A[0-9A-F]{32}\z' -or [string]$request.targetUrl -cne 'https://legacy-parity.mbn.local/index.html' -or - [string]$request.operation -cne 'row-header-drag-after-then-home' -or - [string]$request.position -cne 'after' -or - $sourceRowId -cnotmatch '\Alegacy-stock-[0-9]{8}\z' -or - $targetRowId -cnotmatch '\Alegacy-stock-[0-9]{8}\z' -or - $sourceRowId -ceq $targetRowId -or - [string]$request.expectedSelectedRowId -cne $sourceRowId -or - [string]$request.expectedFocusedRowId -cne $sourceRowId -or - $mouseDownCalls -ne 1 -or - $mouseUpCalls -ne 1 -or - $homeKeyCalls -ne 1) { - Throw-SafeFailure 'The Windows input request does not match the approved one-shot operation.' + (ConvertTo-FiniteNumber $request.inputRetryCount 'Windows input retry count') -ne 0) { + Throw-SafeFailure 'The Windows input request does not match the common closed contract.' } - - $beforeOrder = @($request.beforeOrder | ForEach-Object { [string]$_ }) - $afterOrder = @($request.afterOrder | ForEach-Object { [string]$_ }) - if ($beforeOrder.Count -ne 3 -or $afterOrder.Count -ne 3 -or - (@($beforeOrder | Select-Object -Unique)).Count -ne 3 -or - (@($afterOrder | Select-Object -Unique)).Count -ne 3 -or - $beforeOrder -cnotcontains $sourceRowId -or - $beforeOrder -cnotcontains $targetRowId) { - Throw-SafeFailure 'The Windows input request row order is not a unique three-row fixture.' - } - foreach ($rowId in $beforeOrder + $afterOrder) { - if ($rowId -cnotmatch '\Alegacy-stock-[0-9]{8}\z') { - Throw-SafeFailure 'The Windows input request contains an invalid row identity.' - } - } - - $computed = New-Object 'Collections.Generic.List[string]' - foreach ($rowId in $beforeOrder) { - if ($rowId -cne $sourceRowId) { - [void]$computed.Add($rowId) - } - } - $targetIndex = $computed.IndexOf($targetRowId) - if ($targetIndex -lt 0) { - Throw-SafeFailure 'The Windows input target is absent after removing the source row.' - } - $computed.Insert($targetIndex + 1, $sourceRowId) - for ($index = 0; $index -lt $computed.Count; $index++) { - if ($computed[$index] -cne $afterOrder[$index]) { - Throw-SafeFailure 'The Windows input request is not the exact single-row move-after transform.' - } - } - if ([string]$request.expectedActiveRowIdAfterHome -cne $afterOrder[0]) { - Throw-SafeFailure 'The Windows input Home boundary expectation is not the first resulting row.' - } - - $viewportWidth = ConvertTo-FiniteNumber $request.viewport.width 'Windows input viewport width' - $viewportHeight = ConvertTo-FiniteNumber $request.viewport.height 'Windows input viewport height' - $sourceX = ConvertTo-FiniteNumber $request.source.x 'Windows input source X' - $sourceY = ConvertTo-FiniteNumber $request.source.y 'Windows input source Y' - $targetX = ConvertTo-FiniteNumber $request.target.x 'Windows input target X' - $targetY = ConvertTo-FiniteNumber $request.target.y 'Windows input target Y' - $devicePixelRatio = ConvertTo-FiniteNumber $request.devicePixelRatio 'Windows input device pixel ratio' - if ($viewportWidth -lt 1 -or $viewportWidth -gt 16384 -or - $viewportHeight -lt 1 -or $viewportHeight -gt 16384 -or - $sourceX -lt 0 -or $sourceX -ge $viewportWidth -or - $sourceY -lt 0 -or $sourceY -ge $viewportHeight -or - $targetX -lt 0 -or $targetX -ge $viewportWidth -or - $targetY -lt 0 -or $targetY -ge $viewportHeight -or - $devicePixelRatio -lt 0.25 -or $devicePixelRatio -gt 5) { - Throw-SafeFailure 'The Windows input geometry is outside the exact package viewport contract.' - } - + $inputCounts = [ordered]@{} + $operation = [string]$request.operation Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath Assert-NoTornadoConnection $Application.Id - [LegacyPackageInputNative]::SendRowHeaderDragAfterThenHome( - $Application.MainWindowHandle, - $Application.Id, - 30001, - $sourceX, - $sourceY, - $targetX, - $targetY, - $viewportWidth, - $viewportHeight, - $devicePixelRatio) + switch -CaseSensitive ($operation) { + 'row-header-drag-home-shift-range-delete-restore' { + Invoke-ExactPlaylistWindowsInputRequest ` + $request ` + $Application + $inputCounts = [ordered]@{ + mouseDownCalls = 4 + mouseUpCalls = 4 + homeKeyCalls = 1 + shiftKeyDownCalls = 1 + shiftKeyUpCalls = 1 + deleteKeyCalls = 1 + } + break + } + 'named-playlist-load-double-click' { + if ($ExpectedExchangeIndex -ne 2) { + Throw-SafeFailure 'The named-playlist double-click is not the second exchange.' + } + Invoke-ExactNamedPlaylistDoubleClickRequest ` + $request ` + $Application + $inputCounts = [ordered]@{ + mouseDownCalls = 2 + mouseUpCalls = 2 + doubleClickIntervalMilliseconds = 100 + } + break + } + default { + Throw-SafeFailure 'The Windows input operation is outside the closed allowlist.' + } + } Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath Assert-NoTornadoConnection $Application.Id $script:WindowsInputAcknowledgementCalls++ - if ($script:WindowsInputAcknowledgementCalls -ne 1) { + if ($script:WindowsInputAcknowledgementCalls -ne $ExpectedExchangeIndex) { Throw-SafeFailure 'The Windows input acknowledgement budget changed.' } - $acknowledgementEvidence = Write-WindowsInputAcknowledgement $AcknowledgementPath $token + $acknowledgementEvidence = Write-WindowsInputAcknowledgement ` + $AcknowledgementPath ` + $ExpectedExchangeIndex ` + $token ` + $operation ` + $inputCounts return [pscustomobject]@{ + ExchangeIndex = $ExpectedExchangeIndex + Operation = $operation + RequestPath = $RequestPath + AcknowledgementPath = $AcknowledgementPath RequestSha256 = $requestEvidence.Sha256 AcknowledgementSha256 = $acknowledgementEvidence.Sha256 } @@ -1552,10 +2166,11 @@ function Invoke-NodeHarnessUnderTcpWatch( [string]$WindowsInputAcknowledgementPath, [string]$HarnessProfile, [string]$HarnessScope, + [string]$RecoverNamedPlaylistTitle, [int]$TimeoutSeconds, [Diagnostics.Process]$Application, [string]$ExpectedApplicationPath) { - $arguments = @( + $rawArguments = @( $ScriptPath, '--port', [string]$DebugPort, @@ -1573,7 +2188,14 @@ function Invoke-NodeHarnessUnderTcpWatch( $HarnessScope, '--timeout-ms', [string]($TimeoutSeconds * 1000) - ) | ForEach-Object { ConvertTo-QuotedProcessArgument ([string]$_) } + ) + if (-not [string]::IsNullOrEmpty($RecoverNamedPlaylistTitle)) { + $rawArguments += @( + '--recover-named-playlist-title', + $RecoverNamedPlaylistTitle) + } + $arguments = $rawArguments | + ForEach-Object { ConvertTo-QuotedProcessArgument ([string]$_) } $startInfo = New-Object Diagnostics.ProcessStartInfo $startInfo.FileName = $Executable @@ -1594,8 +2216,11 @@ function Invoke-NodeHarnessUnderTcpWatch( $deadline = [datetime]::UtcNow.AddSeconds($TimeoutSeconds + 30) $monitoringFailure = $null - $windowsInputHandled = $false - $windowsInputEvidence = $null + $expectedWindowsInputExchangeCount = Get-ExpectedWindowsInputExchangeCount ` + $HarnessProfile ` + $HarnessScope + $nextWindowsInputExchangeIndex = 1 + $windowsInputEvidence = New-Object 'Collections.Generic.List[object]' try { while (-not $nodeProcess.HasExited) { if ([datetime]::UtcNow -ge $deadline) { @@ -1603,24 +2228,46 @@ function Invoke-NodeHarnessUnderTcpWatch( } Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath Assert-NoTornadoConnection $Application.Id - if ([IO.File]::Exists($WindowsInputRequestPath) -and -not $windowsInputHandled) { - $windowsInputEvidence = Invoke-ExactWindowsInputRequest ` + if ($nextWindowsInputExchangeIndex -le $expectedWindowsInputExchangeCount) { + $nextRequestPath = Get-WindowsInputExchangePath ` $WindowsInputRequestPath ` + $nextWindowsInputExchangeIndex + $nextAcknowledgementPath = Get-WindowsInputExchangePath ` $WindowsInputAcknowledgementPath ` + $nextWindowsInputExchangeIndex + } + if ($nextWindowsInputExchangeIndex -le $expectedWindowsInputExchangeCount -and + [IO.File]::Exists($nextRequestPath)) { + $exchangeEvidence = Invoke-ExactWindowsInputRequest ` + $nextRequestPath ` + $nextAcknowledgementPath ` + $nextWindowsInputExchangeIndex ` $Application ` $ExpectedApplicationPath - $windowsInputHandled = $true + [void]$windowsInputEvidence.Add($exchangeEvidence) + $nextWindowsInputExchangeIndex++ } Start-Sleep -Milliseconds 100 $nodeProcess.Refresh() } - if (-not $windowsInputHandled -or - $script:WindowsInputRequestCalls -ne 1 -or - $script:WindowsInputAcknowledgementCalls -ne 1 -or - -not [IO.File]::Exists($WindowsInputRequestPath) -or - -not [IO.File]::Exists($WindowsInputAcknowledgementPath)) { - Throw-SafeFailure 'The required one-shot Windows input request was not completed.' + if ($nextWindowsInputExchangeIndex -ne ($expectedWindowsInputExchangeCount + 1) -or + $windowsInputEvidence.Count -ne $expectedWindowsInputExchangeCount -or + $script:WindowsInputRequestCalls -ne $expectedWindowsInputExchangeCount -or + $script:WindowsInputAcknowledgementCalls -ne $expectedWindowsInputExchangeCount) { + Throw-SafeFailure 'The required ordered one-shot Windows input exchanges were not completed.' + } + for ($exchangeIndex = 1; + $exchangeIndex -le $expectedWindowsInputExchangeCount; + $exchangeIndex++) { + if (-not [IO.File]::Exists((Get-WindowsInputExchangePath ` + $WindowsInputRequestPath ` + $exchangeIndex)) -or + -not [IO.File]::Exists((Get-WindowsInputExchangePath ` + $WindowsInputAcknowledgementPath ` + $exchangeIndex))) { + Throw-SafeFailure 'A required Windows input exchange file is missing.' + } } Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath Assert-NoTornadoConnection $Application.Id @@ -1665,7 +2312,10 @@ function Invoke-NodeHarnessUnderTcpWatch( return [pscustomobject]@{ ExitCode = $nodeProcess.ExitCode - WindowsInput = $windowsInputEvidence + # Windows PowerShell 5.1 throws "Argument types do not match" when an + # array subexpression directly enumerates List[object]. Materialize the + # completed one-shot evidence before returning it to the sealing phase. + WindowsInput = @($windowsInputEvidence.ToArray()) } } @@ -1762,7 +2412,19 @@ function Get-StartedApplication( $startUtc = $process.StartTime.ToUniversalTime() } catch { - Throw-SafeFailure 'The launched application identity could not be inspected.' + # A packaged full-trust process can appear in the process table a + # few milliseconds before Path/StartTime become queryable. It has + # not received any test input yet, so keep the launch gate closed + # and retry only while it owns no Tornado connection. + Assert-NoTornadoConnection $process.Id + Start-Sleep -Milliseconds 50 + continue + } + + if ([string]::IsNullOrWhiteSpace([string]$path)) { + Assert-NoTornadoConnection $process.Id + Start-Sleep -Milliseconds 50 + continue } if (-not (Test-PathEqual $path $ExpectedPath) -or @@ -1978,6 +2640,12 @@ function Assert-SingleInstanceActivation( $candidate = $additional[0] try { + # The AppLifecycle redirector can exit within a few milliseconds. + # Pin and verify its package handle before reading slower Process + # properties so a legitimate fast redirect is not misclassified as + # an unknown process after it has already terminated. + $candidatePackageFullName = + [LegacyPackageInputNative]::ReadPackageFullName($candidate.Handle) $candidate.Refresh() $candidateId = $candidate.Id $candidateStartUtc = $candidate.StartTime.ToUniversalTime() @@ -1990,12 +2658,12 @@ function Assert-SingleInstanceActivation( } if ($candidateStartUtc -lt $activationRequestedUtc -or + [string]$candidatePackageFullName -cne $PackageFullName -or -not (Test-PathEqual $candidatePath $ExpectedPath) -or $candidateHandle -ne [IntPtr]::Zero -or -not [string]::IsNullOrEmpty($candidateTitle)) { Throw-SafeFailure 'The transient activation redirector identity is not exact.' } - Assert-ExactProcessPackageIdentity $candidate Assert-NoTornadoConnection $candidateId if ($null -eq $redirectorId) { @@ -2224,6 +2892,7 @@ function Read-AndAssertHarnessEvidence( [string]$WindowsInputAcknowledgementPath, [string]$HarnessProfile, [string]$HarnessScope, + [string]$ExpectedRecoveryTitle, [int]$ExpectedPort, [int]$ExpectedTimeoutSeconds) { try { @@ -2243,6 +2912,58 @@ function Read-AndAssertHarnessEvidence( else { 'all' } + $expectedRecoveryRequested = -not [string]::IsNullOrEmpty( + $ExpectedRecoveryTitle) + $recoveryEvidence = if ($null -ne $evidence.fullUiDb) { + $evidence.fullUiDb.namedPlaylistRecovery + } + else { + $null + } + $namedPlaylistDeleteWriteCount = @( + $evidence.safety.approvedDatabaseWriteMessages | + Where-Object { + [string]$_.type -ceq 'delete-selected-named-playlist' + }).Count + $sealedRecoveryMatchCount = if ($expectedRecoveryRequested) { + @($evidence.finalSnapshot.state.namedPlaylist.definitions | + Where-Object { + [string]$_.title -ceq $ExpectedRecoveryTitle + }).Count + } + else { + 0 + } + $namedPlaylistRecoveryContractOk = if (-not $expectedRecoveryRequested) { + $null -eq $recoveryEvidence + } + elseif ($null -eq $recoveryEvidence -or + [string]$recoveryEvidence.targetTitle -cne $ExpectedRecoveryTitle -or + ([string]$recoveryEvidence.targetTitle).Length -ne 26 -or + [string]$recoveryEvidence.targetTitle -cnotmatch + '^CDX_P_[0-9]{13}_[0-9a-f]{6}$' -or + $recoveryEvidence.freshListVerified -ne $true -or + $recoveryEvidence.exactAbsenceVerified -ne $true -or + $sealedRecoveryMatchCount -ne 0) { + $false + } + elseif ([string]$recoveryEvidence.result -ceq 'alreadyAbsent') { + [int]$recoveryEvidence.exactMatchCount -eq 0 -and + [int]$recoveryEvidence.writeCount -eq 0 -and + [string]::IsNullOrEmpty( + [string]$recoveryEvidence.mutationOutcome) -and + $namedPlaylistDeleteWriteCount -eq 1 + } + elseif ([string]$recoveryEvidence.result -ceq 'deleted') { + [int]$recoveryEvidence.exactMatchCount -eq 1 -and + [int]$recoveryEvidence.writeCount -eq 1 -and + @('committedFresh', 'committedOptimistic') -ccontains + [string]$recoveryEvidence.mutationOutcome -and + $namedPlaylistDeleteWriteCount -eq 2 + } + else { + $false + } $databaseWriteContractOk = if ($HarnessProfile -ceq 'FullUiDb') { $evidence.safety.databaseWriteIntentIssued -eq $true -and @($evidence.safety.approvedDatabaseWriteMessages).Count -ge 3 -and @@ -2263,13 +2984,56 @@ function Read-AndAssertHarnessEvidence( [string]$evidence.dryRunPlayout.nextEntryId) -and -not [string]::IsNullOrWhiteSpace( [string]$evidence.dryRunPlayout.stagedEntryId) -and + -not [string]::IsNullOrWhiteSpace( + [string]$evidence.dryRunPlayout.skippedEntryId) -and + [string]$evidence.dryRunPlayout.stagedEntryId -ceq + [string]$evidence.dryRunPlayout.skippedEntryId -and [string]$evidence.dryRunPlayout.selectedEntryId -cne [string]$evidence.dryRunPlayout.nextEntryId -and [string]$evidence.dryRunPlayout.stagedEntryId -cne [string]$evidence.dryRunPlayout.selectedEntryId -and - [int]$evidence.dryRunPlayout.playlistCountBeforeSelection -eq - [int]$evidence.dryRunPlayout.playlistCountAfterDoubleClick -and - [int]$evidence.dryRunPlayout.programCutSelection -ge 0 + [string]$evidence.dryRunPlayout.skippedEntryId -cne + [string]$evidence.dryRunPlayout.nextEntryId -and + -not [string]::IsNullOrWhiteSpace( + [string]$evidence.dryRunPlayout.tailAppendedEntryId) -and + [string]$evidence.dryRunPlayout.tailAppendedEntryId -cne + [string]$evidence.dryRunPlayout.selectedEntryId -and + [string]$evidence.dryRunPlayout.tailAppendedEntryId -cne + [string]$evidence.dryRunPlayout.nextEntryId -and + [int]$evidence.dryRunPlayout.playlistCountAfterDoubleClick -eq + ([int]$evidence.dryRunPlayout.playlistCountBeforeSelection + 1) -and + [int]$evidence.dryRunPlayout.programCutSelection -ge 0 -and + [string]$evidence.dryRunPlayout.programEnableParity.currentEntryId -ceq + [string]$evidence.dryRunPlayout.selectedEntryId -and + [string]$evidence.dryRunPlayout.programEnableParity.skippedEntryId -ceq + [string]$evidence.dryRunPlayout.skippedEntryId -and + [string]$evidence.dryRunPlayout.programEnableParity.nextEntryId -ceq + [string]$evidence.dryRunPlayout.nextEntryId -and + [int]$evidence.dryRunPlayout.programEnableParity.futureRowCount -ge 3 -and + $evidence.dryRunPlayout.programEnableParity.mouseCheckboxDisabled -eq $true -and + [int]$evidence.dryRunPlayout.programEnableParity.setAllIntentCount -eq 2 -and + $evidence.dryRunPlayout.programEnableParity.setAllAppliesEveryRow -eq $true -and + [int]$evidence.dryRunPlayout.programEnableParity.toggleActiveIntentCount -eq 1 -and + $evidence.dryRunPlayout.programEnableParity.endOfPlaylistObserved -eq $true -and + $evidence.dryRunPlayout.programEnableParity.playlistNextRestored -eq $true -and + [int]$evidence.dryRunPlayout.shortcutParity.f8IntentCount -eq 1 -and + [int]$evidence.dryRunPlayout.shortcutParity.escapeIntentCount -eq 1 -and + [string]$evidence.dryRunPlayout.shortcutParity.finalPhase -ceq 'idle' -and + @($evidence.safety.observedOutboundMessageTypes | Where-Object { + [string]$_ -ceq 'take-in' + }).Count -eq 1 -and + @($evidence.safety.observedOutboundMessageTypes | Where-Object { + [string]$_ -ceq 'next-playout' + }).Count -eq 1 -and + @($evidence.safety.observedOutboundMessageTypes | Where-Object { + [string]$_ -ceq 'take-out' + }).Count -eq 1 -and + @($evidence.safety.observedOutboundMessageTypes | Where-Object { + [string]$_ -ceq 'set-all-playlist-enabled' + }).Count -eq 2 -and + @($evidence.safety.observedOutboundMessageTypes | Where-Object { + [string]$_ -ceq 'toggle-active-playlist-enabled' + }).Count -eq 1 } else { $evidence.safety.playoutIntentIssued -eq $false -and @@ -2292,6 +3056,7 @@ function Read-AndAssertHarnessEvidence( $evidence.safety.appCloseRequested -ne $false -or -not $playoutIntentContractOk -or -not $databaseWriteContractOk -or + -not $namedPlaylistRecoveryContractOk -or $evidence.safety.externalCancellationRequested -ne $false -or @($evidence.safety.blockedOutboundMessages).Count -ne 0 -or @($evidence.safety.stateViolations).Count -ne 0 -or @@ -2308,9 +3073,13 @@ function Read-AndAssertHarnessEvidence( 'hover-swap-tabs', 'activate-playlist-row', 'select-playlist-row', 'reorder-playlist-rows', - 'select-playlist-boundary', 'refresh-named-playlists') + 'select-playlist-boundary', 'delete-selected-playlist-rows', + 'refresh-named-playlists') if ($HarnessProfile -ceq 'DryRunPlayout') { - $allowedOutbound += @('take-in', 'next-playout', 'take-out') + $allowedOutbound += @( + 'take-in', 'next-playout', 'take-out', + 'set-all-playlist-enabled', + 'toggle-active-playlist-enabled') } foreach ($messageType in @($evidence.safety.observedOutboundMessageTypes)) { if ($allowedOutbound -cnotcontains [string]$messageType) { @@ -2339,31 +3108,91 @@ function Read-AndAssertHarnessEvidence( Throw-SafeFailure 'The screenshot evidence does not match its sealed file.' } - $requestEvidence = Read-SmallJsonEvidenceFile ` - $WindowsInputRequestPath ` - 'Final Windows input request' - $acknowledgementEvidence = Read-SmallJsonEvidenceFile ` - $WindowsInputAcknowledgementPath ` - 'Final Windows input acknowledgement' - $request = $requestEvidence.Value + $expectedExchangeCount = Get-ExpectedWindowsInputExchangeCount ` + $HarnessProfile ` + $HarnessScope + $sealedExchanges = @($evidence.windowsInput.exchanges) if ([bool]$evidence.windowsInput.required -ne $true -or [string]$evidence.windowsInput.result -cne 'PASS' -or + [int]$evidence.windowsInput.expectedExchangeCount -ne $expectedExchangeCount -or + [int]$evidence.windowsInput.completedExchangeCount -ne $expectedExchangeCount -or + $sealedExchanges.Count -ne $expectedExchangeCount -or -not (Test-PathEqual ([string]$evidence.windowsInput.requestPath) ` $WindowsInputRequestPath) -or -not (Test-PathEqual ([string]$evidence.windowsInput.acknowledgementPath) ` - $WindowsInputAcknowledgementPath) -or - [string]$evidence.windowsInput.requestSha256 -cne $requestEvidence.Sha256 -or - [string]$evidence.windowsInput.acknowledgementSha256 -cne ` - $acknowledgementEvidence.Sha256 -or - -not (Test-ExactStringSequence ` - @($evidence.windowsInput.beforeOrder) ` - @($request.beforeOrder)) -or - -not (Test-ExactStringSequence ` - @($evidence.windowsInput.afterOrder) ` - @($request.afterOrder)) -or - [string]$evidence.windowsInput.activeRowIdAfterHome -cne ` - [string]$request.expectedActiveRowIdAfterHome) { - Throw-SafeFailure 'The sealed Windows input evidence does not match its one-shot request.' + $WindowsInputAcknowledgementPath)) { + Throw-SafeFailure 'The sealed Windows input exchange count/path contract is not exact.' + } + $request = $null + for ($exchangeIndex = 1; + $exchangeIndex -le $expectedExchangeCount; + $exchangeIndex++) { + $requestPath = Get-WindowsInputExchangePath ` + $WindowsInputRequestPath ` + $exchangeIndex + $acknowledgementPath = Get-WindowsInputExchangePath ` + $WindowsInputAcknowledgementPath ` + $exchangeIndex + $requestEvidence = Read-SmallJsonEvidenceFile ` + $requestPath ` + "Final Windows input request $exchangeIndex" + $acknowledgementEvidence = Read-SmallJsonEvidenceFile ` + $acknowledgementPath ` + "Final Windows input acknowledgement $exchangeIndex" + $sealedExchange = $sealedExchanges[$exchangeIndex - 1] + if ([int]$sealedExchange.exchangeIndex -ne $exchangeIndex -or + [string]$sealedExchange.operation -cne + [string]$requestEvidence.Value.operation -or + [string]$sealedExchange.result -cne 'PASS' -or + -not (Test-PathEqual ([string]$sealedExchange.requestPath) $requestPath) -or + -not (Test-PathEqual ` + ([string]$sealedExchange.acknowledgementPath) ` + $acknowledgementPath) -or + [string]$sealedExchange.requestSha256 -cne $requestEvidence.Sha256 -or + [string]$sealedExchange.acknowledgementSha256 -cne + $acknowledgementEvidence.Sha256) { + Throw-SafeFailure 'A sealed Windows input exchange does not match its files.' + } + if ($exchangeIndex -eq 1) { + $request = $requestEvidence.Value + if ([string]$request.operation -cne + 'row-header-drag-home-shift-range-delete-restore' -or + [string]$evidence.windowsInput.requestSha256 -cne + $requestEvidence.Sha256 -or + [string]$evidence.windowsInput.acknowledgementSha256 -cne + $acknowledgementEvidence.Sha256 -or + -not (Test-ExactStringSequence ` + @($evidence.windowsInput.beforeOrder) ` + @($request.beforeOrder)) -or + -not (Test-ExactStringSequence ` + @($evidence.windowsInput.afterOrder) ` + @($request.afterOrder)) -or + -not (Test-ExactStringSequence ` + @($evidence.windowsInput.shiftRangeDeletedRowIds) ` + @($request.expectedRangeSelectedRowIds)) -or + [string]$evidence.windowsInput.activeRowIdAfterHome -cne + [string]$request.expectedActiveRowIdAfterHome -or + [string]$evidence.windowsInput.finalActiveRowId -cne + [string]$request.expectedFinalActiveRowId) { + Throw-SafeFailure 'The sealed playlist Windows input evidence is not exact.' + } + } + elseif ([string]$requestEvidence.Value.operation -cne + 'named-playlist-load-double-click' -or + [string]$requestEvidence.Value.definitionTitle -cne + [string]$evidence.fixture.namedPlaylistTitle -or + @($requestEvidence.Value.expectedPlaylistContent).Count -ne 3 -or + @($evidence.fullUiDb.databaseChecks | Where-Object { + [string]$_.name -ceq + 'PList physical double-click fresh readback' -and + $_.details.contentRestored -eq $true -and + $_.details.startedUnselected -eq $true -and + [int]$_.details.selectionIntentCount -eq 1 -and + [int]$_.details.loadIntentCount -eq 1 -and + [string]$_.details.input -ceq 'Windows SendInput double-click' + }).Count -ne 1) { + Throw-SafeFailure 'The sealed PList physical double-click evidence is not exact.' + } } $snapshot = $evidence.finalSnapshot @@ -2400,6 +3229,14 @@ function Read-AndAssertHarnessEvidence( } $expectedAfterOrder = @($request.afterOrder | ForEach-Object { [string]$_ }) + $expectedFinalOrder = @($expectedAfterOrder) + if ($HarnessProfile -ceq 'DryRunPlayout') { + # DryRun deliberately proves that a PROGRAM cut double-click appends one + # safe tail row. Preserve the sealed Windows drag order as the prefix and + # require that exact new opaque row id at the tail after TAKE OUT. + $expectedFinalOrder += @( + [string]$evidence.dryRunPlayout.tailAppendedEntryId) + } $statePlaylist = @($state.playlist) $domPlaylist = @($dom.playlist) $statePlaylistIds = @($statePlaylist | ForEach-Object { [string]$_.rowId }) @@ -2417,16 +3254,16 @@ function Read-AndAssertHarnessEvidence( }).Count if (($HarnessProfile -ceq 'ReadOnly' -or $HarnessProfile -ceq 'DryRunPlayout') -and ( - -not (Test-ExactStringSequence $statePlaylistIds $expectedAfterOrder) -or - -not (Test-ExactStringSequence $domPlaylistIds $expectedAfterOrder) -or + -not (Test-ExactStringSequence $statePlaylistIds $expectedFinalOrder) -or + -not (Test-ExactStringSequence $domPlaylistIds $expectedFinalOrder) -or -not (Test-ExactStringSequence $selectedStateIds @([string]$request.sourceRowId)) -or -not (Test-ExactStringSequence $selectedDomIds @([string]$request.sourceRowId)) -or -not (Test-ExactStringSequence ` $activeStateIds ` - @([string]$request.expectedActiveRowIdAfterHome)) -or + @([string]$request.expectedFinalActiveRowId)) -or -not (Test-ExactStringSequence ` $activeDomIds ` - @([string]$request.expectedActiveRowIdAfterHome)) -or + @([string]$request.expectedFinalActiveRowId)) -or $dragVisualCount -ne 0 -or @($dom.playlistPointerCapture.rowIds).Count -ne 0)) { Throw-SafeFailure 'The sealed final playlist state does not match the Windows input result.' @@ -2452,6 +3289,22 @@ function Read-AndAssertHarnessEvidence( } } +if ($DefinitionsOnly) { + return +} + +$recoveryRequested = -not [string]::IsNullOrEmpty($RecoverNamedPlaylistTitle) +if ($recoveryRequested -and + ($RecoverNamedPlaylistTitle.Length -ne 26 -or + $RecoverNamedPlaylistTitle -cnotmatch '^CDX_P_[0-9]{13}_[0-9a-f]{6}$')) { + Throw-SafeFailure 'RecoverNamedPlaylistTitle must be one exact generated CDX_P fixture title.' +} +if ($recoveryRequested -and + ($Profile -cne 'FullUiDb' -or + ($FullUiDbScope -cne 'PList' -and $FullUiDbScope -cne 'All'))) { + Throw-SafeFailure 'Named-playlist recovery is allowed only for FullUiDb PList or All.' +} + $timestamp = [datetime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ') $Output = Resolve-EvidencePath $Output "legacy-package-input-$timestamp.json" $Screenshot = Resolve-EvidencePath $Screenshot "legacy-package-input-$timestamp.png" @@ -2461,6 +3314,16 @@ $WindowsInputRequest = [IO.Path]::ChangeExtension( $WindowsInputAcknowledgement = [IO.Path]::ChangeExtension( $Output, '.windows-input-ack.json') +$ExpectedWindowsInputExchangeCount = Get-ExpectedWindowsInputExchangeCount ` + $Profile ` + $FullUiDbScope +$WindowsInputRequestPaths = @(1..$ExpectedWindowsInputExchangeCount | ForEach-Object { + Get-WindowsInputExchangePath $WindowsInputRequest $_ + }) +$WindowsInputAcknowledgementPaths = @(1..$ExpectedWindowsInputExchangeCount | + ForEach-Object { + Get-WindowsInputExchangePath $WindowsInputAcknowledgement $_ + }) $NodePath = Resolve-NodeExecutable $NodePath try { @@ -2473,11 +3336,8 @@ try { [IO.Path]::GetExtension($Screenshot), '.png', [StringComparison]::OrdinalIgnoreCase)) { Throw-SafeFailure 'Output must be .json and screenshot must be .png.' } - $evidencePaths = @( - $Output, - $Screenshot, - $WindowsInputRequest, - $WindowsInputAcknowledgement) + $evidencePaths = @($Output, $Screenshot) + + $WindowsInputRequestPaths + $WindowsInputAcknowledgementPaths for ($leftIndex = 0; $leftIndex -lt $evidencePaths.Count; $leftIndex++) { for ($rightIndex = $leftIndex + 1; $rightIndex -lt $evidencePaths.Count; $rightIndex++) { if (Test-PathEqual $evidencePaths[$leftIndex] $evidencePaths[$rightIndex]) { @@ -2488,8 +3348,14 @@ try { Assert-OutputPathAvailable $Output 'Output' Assert-OutputPathAvailable $Screenshot 'Screenshot' - Assert-OutputPathAvailable $WindowsInputRequest 'Windows input request' - Assert-OutputPathAvailable $WindowsInputAcknowledgement 'Windows input acknowledgement' + for ($exchangeIndex = 1; + $exchangeIndex -le $ExpectedWindowsInputExchangeCount; + $exchangeIndex++) { + Assert-OutputPathAvailable $WindowsInputRequestPaths[$exchangeIndex - 1] ` + "Windows input request $exchangeIndex" + Assert-OutputPathAvailable $WindowsInputAcknowledgementPaths[$exchangeIndex - 1] ` + "Windows input acknowledgement $exchangeIndex" + } Assert-LocalConfigurationIsDryRun Assert-NoUnsafeEnvironment $registration = Get-ExactDevelopmentPackage @@ -2524,6 +3390,7 @@ try { 'read-only' }) ` $(if ($Profile -ceq 'FullUiDb') { $FullUiDbScope.ToLowerInvariant() } else { 'all' }) ` + $RecoverNamedPlaylistTitle ` $HarnessTimeoutSeconds ` $script:ApplicationProcess ` $registration.Executable @@ -2532,12 +3399,13 @@ try { } if (-not [IO.File]::Exists($Output) -or -not [IO.File]::Exists($Screenshot) -or - -not [IO.File]::Exists($WindowsInputRequest) -or - -not [IO.File]::Exists($WindowsInputAcknowledgement) -or (Get-Item -LiteralPath $Output).Length -le 0 -or (Get-Item -LiteralPath $Screenshot).Length -le 0 -or - (Get-Item -LiteralPath $WindowsInputRequest).Length -le 0 -or - (Get-Item -LiteralPath $WindowsInputAcknowledgement).Length -le 0) { + @($WindowsInputRequestPaths + $WindowsInputAcknowledgementPaths | + Where-Object { + -not [IO.File]::Exists($_) -or + (Get-Item -LiteralPath $_).Length -le 0 + }).Count -ne 0) { Throw-SafeFailure 'The real input evidence files are missing or empty.' } $harnessEvidence = Read-AndAssertHarnessEvidence ` @@ -2547,14 +3415,29 @@ try { $WindowsInputAcknowledgement ` $Profile ` $FullUiDbScope ` + $RecoverNamedPlaylistTitle ` $Port ` $HarnessTimeoutSeconds - if ($null -eq $harnessRun.WindowsInput -or - [string]$harnessRun.WindowsInput.RequestSha256 -cne - [string]$harnessEvidence.windowsInput.requestSha256 -or - [string]$harnessRun.WindowsInput.AcknowledgementSha256 -cne - [string]$harnessEvidence.windowsInput.acknowledgementSha256) { - Throw-SafeFailure 'The wrapper and harness Windows input evidence hashes differ.' + $wrapperWindowsInput = @($harnessRun.WindowsInput) + $sealedWindowsInput = @($harnessEvidence.windowsInput.exchanges) + if ($wrapperWindowsInput.Count -ne $ExpectedWindowsInputExchangeCount -or + $sealedWindowsInput.Count -ne $ExpectedWindowsInputExchangeCount) { + Throw-SafeFailure 'The wrapper and harness Windows input exchange counts differ.' + } + for ($exchangeIndex = 1; + $exchangeIndex -le $ExpectedWindowsInputExchangeCount; + $exchangeIndex++) { + $wrapperExchange = $wrapperWindowsInput[$exchangeIndex - 1] + $sealedExchange = $sealedWindowsInput[$exchangeIndex - 1] + if ([int]$wrapperExchange.ExchangeIndex -ne $exchangeIndex -or + [int]$sealedExchange.exchangeIndex -ne $exchangeIndex -or + [string]$wrapperExchange.Operation -cne [string]$sealedExchange.operation -or + [string]$wrapperExchange.RequestSha256 -cne + [string]$sealedExchange.requestSha256 -or + [string]$wrapperExchange.AcknowledgementSha256 -cne + [string]$sealedExchange.acknowledgementSha256) { + Throw-SafeFailure 'The wrapper and harness Windows input exchange hashes differ.' + } } Assert-ExactApplicationStillRunning $script:ApplicationProcess $registration.Executable @@ -2590,7 +3473,7 @@ try { [pscustomobject]@{ result = [string]$harnessEvidence.result packageFullName = $PackageFullName - packageMode = 'Development Release x64' + packageMode = $PackageModeLabel playoutMode = 'DryRun' processId = $script:ApplicationProcess.Id cdpAddress = '127.0.0.1' @@ -2600,10 +3483,11 @@ try { windowsInputRequestCalls = $script:WindowsInputRequestCalls windowsInputAcknowledgementCalls = $script:WindowsInputAcknowledgementCalls windowsInputRequest = $WindowsInputRequest - windowsInputRequestSha256 = [string]$harnessRun.WindowsInput.RequestSha256 + windowsInputRequestSha256 = [string]$wrapperWindowsInput[0].RequestSha256 windowsInputAcknowledgement = $WindowsInputAcknowledgement windowsInputAcknowledgementSha256 = ` - [string]$harnessRun.WindowsInput.AcknowledgementSha256 + [string]$wrapperWindowsInput[0].AcknowledgementSha256 + windowsInputExchanges = @($wrapperWindowsInput) singleInstanceActivationCalls = $singleInstance.ActivationCalls singleInstanceProcessCount = $singleInstance.ProcessCount singleInstanceRedirectorProcessId = $singleInstance.RedirectorProcessId diff --git a/scripts/Invoke-LegacyPackageLocalStateSmoke.ps1 b/scripts/Invoke-LegacyPackageLocalStateSmoke.ps1 new file mode 100644 index 0000000..640cbe1 --- /dev/null +++ b/scripts/Invoke-LegacyPackageLocalStateSmoke.ps1 @@ -0,0 +1,1140 @@ +[CmdletBinding()] +param( + [ValidateRange(1024, 65535)] + [int]$Port = 9347, + + [string]$Output, + + [string]$Screenshot, + + [string]$ExchangeDirectory, + + [string]$LegacyDataDirectory, + + [string]$NodePath, + + [ValidateRange(10, 120)] + [int]$LaunchTimeoutSeconds = 30, + + [ValidateRange(60, 900)] + [int]$HarnessTimeoutSeconds = 240, + + [switch]$StaticAudit +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$requestedPort = $Port +$requestedOutput = $Output +$requestedScreenshot = $Screenshot +$requestedExchangeDirectory = $ExchangeDirectory +$requestedLegacyDataDirectory = $LegacyDataDirectory +$requestedNodePath = $NodePath +$requestedLaunchTimeout = $LaunchTimeoutSeconds +$requestedHarnessTimeout = $HarnessTimeoutSeconds +$sharedScript = Join-Path $PSScriptRoot 'Invoke-LegacyPackageInputSmoke.ps1' +if (-not [IO.File]::Exists($sharedScript)) { + throw [InvalidOperationException]::new('The shared package input safety script is missing.') +} + +# Reuse the exact registered-package, latest DebugAppX, DryRun, TCP ownership, +# process identity and normal-close guards. -DefinitionsOnly performs no launch. +. $sharedScript ` + -BuildFlavor DebugAppX ` + -Profile ReadOnly ` + -Port $requestedPort ` + -Output $requestedOutput ` + -Screenshot $requestedScreenshot ` + -NodePath $requestedNodePath ` + -LaunchTimeoutSeconds $requestedLaunchTimeout ` + -HarnessTimeoutSeconds $requestedHarnessTimeout ` + -DefinitionsOnly + +$Port = $requestedPort +$Output = $requestedOutput +$Screenshot = $requestedScreenshot +$ExchangeDirectory = $requestedExchangeDirectory +$LegacyDataDirectory = $requestedLegacyDataDirectory +$NodePath = $requestedNodePath +$LaunchTimeoutSeconds = $requestedLaunchTimeout +$HarnessTimeoutSeconds = $requestedHarnessTimeout +$HarnessPath = Join-Path $PSScriptRoot 'Test-LegacyPackageLocalStateSmoke.mjs' + +Add-Type -AssemblyName UIAutomationClient +Add-Type -AssemblyName UIAutomationTypes +Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; + +public static class LegacyLocalStateNative +{ + private delegate bool EnumWindowsCallback(IntPtr window, IntPtr parameter); + + [DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowsCallback callback, IntPtr parameter); + + [DllImport("user32.dll")] + private static extern IntPtr GetWindow(IntPtr window, uint command); + + [DllImport("user32.dll")] + private static extern IntPtr GetAncestor(IntPtr window, uint flags); + + [DllImport("kernel32.dll")] + private static extern uint GetCurrentThreadId(); + + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(IntPtr window); + + [DllImport("user32.dll")] + private static extern bool BringWindowToTop(IntPtr window); + + [DllImport("user32.dll")] + private static extern IntPtr SetActiveWindow(IntPtr window); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool AttachThreadInput(uint from, uint to, bool attach); + + [DllImport("user32.dll")] + private static extern bool IsWindow(IntPtr window); + + [DllImport("user32.dll")] + private static extern bool IsWindowVisible(IntPtr window); + + [DllImport("user32.dll")] + private static extern bool IsWindowEnabled(IntPtr window); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetClassName(IntPtr window, StringBuilder value, int maximum); + + private const uint GetWindowOwner = 4; + private const uint GetAncestorRoot = 2; + + public static bool Exists(IntPtr window) + { + return window != IntPtr.Zero && IsWindow(window); + } + + public static bool Visible(IntPtr window) + { + return Exists(window) && IsWindowVisible(window); + } + + public static bool Enabled(IntPtr window) + { + return Exists(window) && IsWindowEnabled(window); + } + + public static int ProcessId(IntPtr window) + { + uint processId; + return GetWindowThreadProcessId(window, out processId) == 0 ? 0 : checked((int)processId); + } + + public static IntPtr Root(IntPtr window) + { + return window == IntPtr.Zero + ? IntPtr.Zero : GetAncestor(window, GetAncestorRoot); + } + + public static bool OwnerChainContains(IntPtr window, IntPtr expectedOwner) + { + if (window == IntPtr.Zero || expectedOwner == IntPtr.Zero) return false; + IntPtr expectedRoot = Root(expectedOwner); + IntPtr current = window; + for (int depth = 0; depth < 12 && current != IntPtr.Zero; depth++) + { + if (current == expectedOwner || Root(current) == expectedRoot) return true; + current = GetWindow(current, GetWindowOwner); + } + return false; + } + + public static string ClassName(IntPtr window) + { + StringBuilder value = new StringBuilder(512); + return window != IntPtr.Zero && GetClassName(window, value, value.Capacity) > 0 + ? value.ToString() : String.Empty; + } + + public static IntPtr[] VisibleTopLevelWindows() + { + List windows = new List(); + EnumWindowsCallback callback = delegate(IntPtr window, IntPtr parameter) + { + if (window != IntPtr.Zero && IsWindowVisible(window)) + { + windows.Add(window); + } + return true; + }; + if (!EnumWindows(callback, IntPtr.Zero)) + { + throw new InvalidOperationException("The native top-level window list is unavailable."); + } + return windows.ToArray(); + } + + public static void AcquireOwnedForeground(IntPtr window, IntPtr expectedOwner) + { + if (!Visible(window) || !Enabled(window) || + !OwnerChainContains(window, expectedOwner)) + { + throw new InvalidOperationException("The owned picker identity changed before foreground acquisition."); + } + + IntPtr foreground = GetForegroundWindow(); + if (Root(foreground) == window) return; + + uint ignored; + uint foregroundThread = foreground == IntPtr.Zero + ? 0 : GetWindowThreadProcessId(foreground, out ignored); + uint targetThread = GetWindowThreadProcessId(window, out ignored); + uint currentThread = GetCurrentThreadId(); + if (targetThread == 0 || currentThread == 0) + { + throw new InvalidOperationException("The picker input threads are unavailable."); + } + + bool attachedForeground = false; + bool attachedTarget = false; + try + { + if (foregroundThread != 0 && foregroundThread != currentThread && + foregroundThread != targetThread) + { + if (!AttachThreadInput(currentThread, foregroundThread, true)) + { + throw new InvalidOperationException("The foreground input queue could not be attached to the picker verifier."); + } + attachedForeground = true; + } + if (targetThread != currentThread) + { + if (!AttachThreadInput(currentThread, targetThread, true)) + { + throw new InvalidOperationException("The picker input queue could not be attached."); + } + attachedTarget = true; + } + + BringWindowToTop(window); + SetActiveWindow(window); + SetForegroundWindow(window); + } + finally + { + if (attachedTarget) AttachThreadInput(currentThread, targetThread, false); + if (attachedForeground) AttachThreadInput(currentThread, foregroundThread, false); + } + + DateTime deadline = DateTime.UtcNow.AddSeconds(3); + while (DateTime.UtcNow < deadline && Root(GetForegroundWindow()) != window) + { + if (!Visible(window) || !Enabled(window) || + !OwnerChainContains(window, expectedOwner)) + { + throw new InvalidOperationException("The owned picker identity changed during foreground acquisition."); + } + Thread.Sleep(10); + } + if (Root(GetForegroundWindow()) != window) + { + throw new InvalidOperationException("The exact owned picker could not acquire foreground input."); + } + } +} +'@ + +$ExpectedSourceSha256 = + '1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2' +$ExpectedInitialDestinationSha256 = + '13C07BA64587549EDA9C1A694F3DFA19875CB1F888F02C9E071F17C47739E087' +$ExpectedInitialPairLine = $Utf8.GetString([Convert]::FromBase64String( + 'MV5LUlgxMDDsp4DsiJgs7L2U7Iqk7ZS8IOyngOyImF5eXl5eXl4=')) +$ComparisonFileName = $Utf8.GetString([Convert]::FromBase64String( + '7KKF66qp67mE6rWQLmRhdA==')) +$ExpectedInitialPairIdentity = $Utf8.GetString([Convert]::FromBase64String( + 'S1JYMTAw7KeA7IiYfOy9lOyKpO2UvCDsp4DsiJg=')) +$FolderSelectLabel = $Utf8.GetString([Convert]::FromBase64String( + '7Y+0642UIOyEoO2DnQ==')) +$CancelLabel = $Utf8.GetString([Convert]::FromBase64String('7Leo7IaM')) +if ([string]::IsNullOrWhiteSpace($LegacyDataDirectory)) { + $LegacyDataDirectory = [IO.Path]::GetFullPath((Join-Path $RepositoryRoot ` + '..\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Data')) +} else { + $LegacyDataDirectory = [IO.Path]::GetFullPath($LegacyDataDirectory) +} +$SourceComparisonPath = Join-Path $LegacyDataDirectory $ComparisonFileName +$DestinationComparisonPath = Join-Path ` + ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) ` + (Join-Path 'MBN_STOCK_WEBVIEW\Data' $ComparisonFileName) +$RuntimeSettingsPath = Join-Path ` + ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) ` + 'MBN_STOCK_WEBVIEW\Config\runtime-folders.local.json' +$Cp949 = [Text.Encoding]::GetEncoding( + 949, + [Text.EncoderExceptionFallback]::new(), + [Text.DecoderExceptionFallback]::new()) +$ExpectedExchangePlan = @( + [pscustomobject]@{ Operation = 'observe-local-files'; TargetId = $null; FolderKind = $null }, + [pscustomobject]@{ Operation = 'application-click'; TargetId = 'settings-navigation'; FolderKind = $null }, + [pscustomobject]@{ Operation = 'folder-picker-click-cancel'; TargetId = 'design-folder-picker'; FolderKind = 'design' }, + [pscustomobject]@{ Operation = 'folder-picker-click-cancel'; TargetId = 'resource-folder-picker'; FolderKind = 'resource' }, + [pscustomobject]@{ Operation = 'folder-picker-click-cancel'; TargetId = 'background-folder-picker'; FolderKind = 'background' }, + [pscustomobject]@{ Operation = 'observe-local-files'; TargetId = $null; FolderKind = $null }, + [pscustomobject]@{ Operation = 'application-click'; TargetId = 'comparison-navigation'; FolderKind = $null }, + [pscustomobject]@{ Operation = 'application-click'; TargetId = 'comparison-import'; FolderKind = $null }, + [pscustomobject]@{ Operation = 'application-click'; TargetId = 'native-confirmation-yes'; FolderKind = $null }, + [pscustomobject]@{ Operation = 'observe-local-files'; TargetId = $null; FolderKind = $null }, + [pscustomobject]@{ Operation = 'application-click'; TargetId = 'comparison-import'; FolderKind = $null }, + [pscustomobject]@{ Operation = 'application-click'; TargetId = 'native-confirmation-yes'; FolderKind = $null }, + [pscustomobject]@{ Operation = 'observe-local-files'; TargetId = $null; FolderKind = $null } +) + +$script:LocalInputRequestCalls = 0 +$script:LocalInputAcknowledgementCalls = 0 +$script:BaselineSettingsState = $null +$script:InitialDestinationState = $null +$script:FirstImportedDestinationState = $null +$script:LastObservedFileState = $null + +function Assert-SafeRegularFile([string]$Path, [string]$Label, [int64]$MaximumBytes) { + if (-not [IO.File]::Exists($Path)) { + Throw-SafeFailure "$Label is missing." + } + $item = Get-Item -LiteralPath $Path -Force + if ($item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -le 0 -or $item.Length -gt $MaximumBytes) { + Throw-SafeFailure "$Label is not a bounded regular file." + } +} + +function Get-ComparisonFileState( + [string]$Path, + [string]$Label, + [bool]$IsReadOnlySource) { + Assert-SafeRegularFile $Path $Label (64 * 1024) + $bytes = [IO.File]::ReadAllBytes($Path) + try { + $text = $Cp949.GetString($bytes) + } + catch { + Throw-SafeFailure "$Label is not strict CP949." + } + $rows = @($text -split "`r?`n" | Where-Object { $_.Length -gt 0 }) + if ($rows.Count -lt 1 -or $rows.Count -gt 500) { + Throw-SafeFailure "$Label row count is outside the closed contract." + } + for ($index = 0; $index -lt $rows.Count; $index++) { + if ($rows[$index] -cnotmatch ('\A' + ($index + 1) + '\^[^\^,]+,[^\^,]+(?:\^[^\^]*){7}\z')) { + Throw-SafeFailure "$Label does not retain the exact legacy row shape." + } + } + $pairField = ($rows[0] -split '\^', 3)[1] + $pair = $pairField -split ',', 2 + return [ordered]@{ + exists = $true + length = [int64]$bytes.Length + sha256 = Get-ByteArraySha256 $bytes + rowCount = $rows.Count + firstPair = $pair[0] + '|' + $pair[1] + isReadOnlySource = $IsReadOnlySource + } +} + +function Get-SettingsFileState { + if (-not [IO.File]::Exists($RuntimeSettingsPath)) { + return [ordered]@{ + exists = $false + length = 0 + sha256 = $null + } + } + Assert-SafeRegularFile $RuntimeSettingsPath 'Runtime folder settings JSON' (16 * 1024) + $bytes = [IO.File]::ReadAllBytes($RuntimeSettingsPath) + try { + [void]$StrictUtf8.GetString($bytes) + } + catch { + Throw-SafeFailure 'Runtime folder settings JSON is not strict UTF-8.' + } + return [ordered]@{ + exists = $true + length = [int64]$bytes.Length + sha256 = Get-ByteArraySha256 $bytes + } +} + +function Get-LocalFileState { + return [ordered]@{ + source = Get-ComparisonFileState ` + $SourceComparisonPath ` + 'Original comparison source' ` + $true + destination = Get-ComparisonFileState ` + $DestinationComparisonPath ` + 'Current local comparison file' ` + $false + settings = Get-SettingsFileState + } +} + +function Test-JsonEquivalent($Left, $Right) { + return (($Left | ConvertTo-Json -Depth 12 -Compress) -ceq + ($Right | ConvertTo-Json -Depth 12 -Compress)) +} + +function Assert-LocalFilePreflight { + $state = Get-LocalFileState + if ([string]$state.source.sha256 -cne $ExpectedSourceSha256 -or + [int]$state.source.rowCount -ne 8 -or + [string]$state.destination.sha256 -cne $ExpectedInitialDestinationSha256 -or + [int]$state.destination.rowCount -ne 1 -or + [string]$state.destination.firstPair -cne $ExpectedInitialPairIdentity) { + Throw-SafeFailure ` + ('The original 8-row source or exact current one-row baseline changed; ' + + 'no import was started.') + } + $initialBytes = [IO.File]::ReadAllBytes($DestinationComparisonPath) + if ($Cp949.GetString($initialBytes).TrimEnd("`r", "`n") -cne + $ExpectedInitialPairLine) { + Throw-SafeFailure 'The current one-row comparison baseline is not the expected known pair.' + } + $script:BaselineSettingsState = $state.settings + $script:InitialDestinationState = $state.destination + $script:LastObservedFileState = $state + return $state +} + +function Get-TopLevelWindowHandles { + $handles = New-Object 'Collections.Generic.HashSet[int64]' + foreach ($handle in [LegacyLocalStateNative]::VisibleTopLevelWindows()) { + if ($handle -ne [IntPtr]::Zero) { [void]$handles.Add($handle.ToInt64()) } + } + return $handles +} + +function Get-OwnedFolderPickerCandidates( + [Collections.Generic.HashSet[int64]]$BaselineHandles, + [IntPtr]$MainWindowHandle, + [int]$ApplicationProcessId) { + $candidates = @() + foreach ($handle in [LegacyLocalStateNative]::VisibleTopLevelWindows()) { + try { + if ($handle -eq [IntPtr]::Zero -or + $BaselineHandles.Contains($handle.ToInt64()) -or + -not [LegacyLocalStateNative]::Visible($handle)) { + continue + } + $sameProcess = [LegacyLocalStateNative]::ProcessId($handle) -eq $ApplicationProcessId + $ownerLinked = $sameProcess -or + [LegacyLocalStateNative]::OwnerChainContains($handle, $MainWindowHandle) + if (-not $ownerLinked) { continue } + + $window = [System.Windows.Automation.AutomationElement]::FromHandle($handle) + if ($null -eq $window) { continue } + $windowName = [string]$window.Current.Name + $windowClass = [LegacyLocalStateNative]::ClassName($handle) + if ($windowClass -cne '#32770' -or + (-not $windowName.StartsWith( + $FolderSelectLabel, + [StringComparison]::Ordinal) -and + $windowName -cnotmatch '\ASelect Folder(?:\(&.\))?\z')) { + continue + } + + # The Win32 folder picker may expose its bottom Button windows as + # ControlType.Pane through UI Automation (observed on the Korean + # Windows shell). Keep the label/count gate, but also recognize + # the exact legacy Button class/IDs instead of requiring only the + # synthesized ControlType.Button view. + $controls = $window.FindAll( + [System.Windows.Automation.TreeScope]::Descendants, + [System.Windows.Automation.Condition]::TrueCondition) + $confirmCount = 0 + $cancelCount = 0 + foreach ($control in $controls) { + $automationId = [string]$control.Current.AutomationId + $isAutomationButton = + $control.Current.ControlType -eq [System.Windows.Automation.ControlType]::Button + $isLegacyDialogButton = + [string]$control.Current.ClassName -ceq 'Button' -and + ($automationId -ceq '1' -or $automationId -ceq '2') + if (-not $isAutomationButton -and -not $isLegacyDialogButton) { continue } + + $name = [string]$control.Current.Name + if ($name.StartsWith($FolderSelectLabel, [StringComparison]::Ordinal) -or + $name -cmatch '\A(?:Select Folder|Select)(?:\(&.\))?\z') { + $confirmCount++ + } + if ($name.StartsWith($CancelLabel, [StringComparison]::Ordinal) -or + $name -cmatch '\ACancel(?:\(&.\))?\z') { + $cancelCount++ + } + } + if ($confirmCount -ne 1 -or $cancelCount -ne 1) { continue } + + $windowPatternObject = $null + if (-not $window.TryGetCurrentPattern( + [System.Windows.Automation.WindowPattern]::Pattern, + [ref]$windowPatternObject) -or + $null -eq $windowPatternObject) { + continue + } + $windowPattern = $windowPatternObject -as + [System.Windows.Automation.WindowPattern] + $isModal = [bool]$windowPattern.Current.IsModal + if (-not $isModal) { continue } + + $candidates += [pscustomobject]@{ + Element = $window + Handle = $handle + ProcessId = [LegacyLocalStateNative]::ProcessId($handle) + SameProcess = $sameProcess + OwnerLinked = $ownerLinked + Name = $windowName + ClassName = $windowClass + ConfirmButtonCount = $confirmCount + CancelButtonCount = $cancelCount + IsModal = $isModal + } + } + catch { + # A transient shell window is ignored unless it satisfies every + # stable ownership and folder-button condition in one observation. + } + } + return @($candidates) +} + +function Wait-UniqueOwnedFolderPicker( + [Collections.Generic.HashSet[int64]]$BaselineHandles, + [Diagnostics.Process]$Application, + [string]$ExpectedApplicationPath, + [datetime]$Deadline) { + while ([datetime]::UtcNow -lt $Deadline) { + Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath + Assert-NoTornadoConnection $Application.Id + $candidates = @(Get-OwnedFolderPickerCandidates ` + $BaselineHandles ` + $Application.MainWindowHandle ` + $Application.Id) + if ($candidates.Count -gt 1) { + Throw-SafeFailure 'More than one newly opened app-owned folder picker exists.' + } + if ($candidates.Count -eq 1) { + $picker = $candidates[0] + Assert-NoTornadoConnection $Application.Id + [LegacyLocalStateNative]::AcquireOwnedForeground( + $picker.Handle, + $Application.MainWindowHandle) + Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath + Assert-NoTornadoConnection $Application.Id + $revalidated = @(Get-OwnedFolderPickerCandidates ` + $BaselineHandles ` + $Application.MainWindowHandle ` + $Application.Id) + if ($revalidated.Count -ne 1 -or + $revalidated[0].Handle -ne $picker.Handle) { + Throw-SafeFailure 'The unique app-owned folder picker identity changed during foreground acquisition.' + } + $foregroundRoot = [LegacyLocalStateNative]::Root( + [LegacyPackageInputNative]::ReadForegroundWindow()) + if ($foregroundRoot -eq $picker.Handle) { return $picker } + } + Start-Sleep -Milliseconds 50 + } + Throw-SafeFailure 'The exact app-owned folder picker did not appear in time.' +} + +function Wait-FolderPickerClosed( + [IntPtr]$PickerHandle, + [Diagnostics.Process]$Application, + [string]$ExpectedApplicationPath, + [datetime]$Deadline) { + while ([datetime]::UtcNow -lt $Deadline) { + Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath + Assert-NoTornadoConnection $Application.Id + if (-not [LegacyLocalStateNative]::Exists($PickerHandle)) { + return + } + Start-Sleep -Milliseconds 50 + } + Throw-SafeFailure 'The exact folder picker was not destroyed after the one Escape input.' +} + +function ConvertTo-LocalFiniteNumber($Value, [string]$Label) { + try { + $number = [Convert]::ToDouble($Value, [Globalization.CultureInfo]::InvariantCulture) + } + catch { Throw-SafeFailure "$Label is not numeric." } + if ([double]::IsNaN($number) -or [double]::IsInfinity($number)) { + Throw-SafeFailure "$Label is not finite." + } + return $number +} + +function Write-LocalAcknowledgement( + [string]$Path, + $Request, + $Files, + $Picker) { + $acknowledgement = [ordered]@{ + schemaVersion = 1 + token = [string]$Request.token + sequence = [int]$Request.sequence + result = 'PASS' + operation = [string]$Request.operation + targetId = if ($null -eq $Request.targetId) { $null } else { [string]$Request.targetId } + folderKind = if ($null -eq $Request.folderKind) { $null } else { [string]$Request.folderKind } + packageIdentityValidated = $true + dryRunValidated = $true + tornadoConnectionCount = 0 + mouseDownCalls = [int]$Request.mouseDownCalls + mouseUpCalls = [int]$Request.mouseUpCalls + escapeKeyCalls = [int]$Request.escapeKeyCalls + inputRetryCount = 0 + picker = $Picker + files = $Files + } + $bytes = $Utf8.GetBytes(($acknowledgement | ConvertTo-Json -Depth 12 -Compress) + "`n") + $temporary = $Path + '.' + [Guid]::NewGuid().ToString('N') + '.tmp' + try { + $stream = [IO.FileStream]::new( + $temporary, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::None) + try { + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + } + finally { $stream.Dispose() } + [IO.File]::Move($temporary, $Path) + } + catch { + try { if ([IO.File]::Exists($temporary)) { [IO.File]::Delete($temporary) } } catch { } + Throw-SafeFailure 'The immutable local-state acknowledgement could not be created.' + } + return [pscustomobject]@{ + Sha256 = Get-ByteArraySha256 $bytes + } +} + +function Invoke-LocalStateExchange( + [int]$Sequence, + [string]$RequestPath, + [string]$AcknowledgementPath, + [Diagnostics.Process]$Application, + [string]$ExpectedApplicationPath) { + $script:LocalInputRequestCalls++ + if ($script:LocalInputRequestCalls -ne $Sequence -or + [IO.File]::Exists($AcknowledgementPath)) { + Throw-SafeFailure 'The local-state one-shot input sequence changed.' + } + $requestEvidence = Wait-ReadSmallJsonEvidenceFile ` + $RequestPath ` + "Local-state request $Sequence" ` + ([datetime]::UtcNow.AddSeconds(3)) + $request = $requestEvidence.Value + Assert-ExactJsonPropertyNames $request @( + 'schemaVersion', 'token', 'sequence', 'targetUrl', 'operation', + 'targetId', 'folderKind', 'point', 'viewport', 'devicePixelRatio', + 'mouseDownCalls', 'mouseUpCalls', 'escapeKeyCalls', 'inputRetryCount') ` + "Local-state request $Sequence" + $expected = $ExpectedExchangePlan[$Sequence - 1] + $targetId = if ($null -eq $request.targetId) { $null } else { [string]$request.targetId } + $folderKind = if ($null -eq $request.folderKind) { $null } else { [string]$request.folderKind } + if ([int](ConvertTo-LocalFiniteNumber $request.schemaVersion 'schemaVersion') -ne 1 -or + [int](ConvertTo-LocalFiniteNumber $request.sequence 'sequence') -ne $Sequence -or + $request.token -isnot [string] -or [string]$request.token -cnotmatch '\A[0-9A-F]{32}\z' -or + [string]$request.targetUrl -cne 'https://legacy-parity.mbn.local/index.html' -or + [string]$request.operation -cne [string]$expected.Operation -or + $targetId -cne $expected.TargetId -or $folderKind -cne $expected.FolderKind -or + [int](ConvertTo-LocalFiniteNumber $request.inputRetryCount 'inputRetryCount') -ne 0) { + Throw-SafeFailure "Local-state request $Sequence is outside the closed plan." + } + + Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath + Assert-NoTornadoConnection $Application.Id + $pickerEvidence = $null + if ([string]$expected.Operation -ceq 'observe-local-files') { + if ($null -ne $request.point -or $null -ne $request.viewport -or + $null -ne $request.devicePixelRatio -or + [int]$request.mouseDownCalls -ne 0 -or [int]$request.mouseUpCalls -ne 0 -or + [int]$request.escapeKeyCalls -ne 0) { + Throw-SafeFailure 'A file observation request attempted to carry input geometry.' + } + } + else { + Assert-ExactJsonPropertyNames $request.point @('x', 'y') "Request $Sequence point" + Assert-ExactJsonPropertyNames $request.viewport @('width', 'height') "Request $Sequence viewport" + $x = ConvertTo-LocalFiniteNumber $request.point.x 'point.x' + $y = ConvertTo-LocalFiniteNumber $request.point.y 'point.y' + $width = ConvertTo-LocalFiniteNumber $request.viewport.width 'viewport.width' + $height = ConvertTo-LocalFiniteNumber $request.viewport.height 'viewport.height' + $scale = ConvertTo-LocalFiniteNumber $request.devicePixelRatio 'devicePixelRatio' + $expectedEscapeCount = if ([string]$expected.Operation -ceq + 'folder-picker-click-cancel') { 1 } else { 0 } + if ($width -lt 1 -or $width -gt 16384 -or $height -lt 1 -or $height -gt 16384 -or + $x -lt 0 -or $x -ge $width -or $y -lt 0 -or $y -ge $height -or + $scale -lt 0.25 -or $scale -gt 5 -or + [int]$request.mouseDownCalls -ne 1 -or [int]$request.mouseUpCalls -ne 1 -or + [int]$request.escapeKeyCalls -ne $expectedEscapeCount) { + Throw-SafeFailure "Request $Sequence input geometry or exact count is invalid." + } + $baselineWindows = if ([string]$expected.Operation -ceq + 'folder-picker-click-cancel') { Get-TopLevelWindowHandles } else { $null } + [LegacyPackageInputNative]::SendApplicationClick( + $Application.MainWindowHandle, + $Application.Id, + 30001, + $x, + $y, + $width, + $height, + $scale, + [string]$expected.Operation -ceq 'folder-picker-click-cancel') + if ([string]$expected.Operation -ceq 'folder-picker-click-cancel') { + $picker = Wait-UniqueOwnedFolderPicker ` + $baselineWindows ` + $Application ` + $ExpectedApplicationPath ` + ([datetime]::UtcNow.AddSeconds(10)) + [LegacyPackageInputNative]::SendEscapeToForegroundWindow($picker.Handle) + Wait-FolderPickerClosed ` + $picker.Handle ` + $Application ` + $ExpectedApplicationPath ` + ([datetime]::UtcNow.AddSeconds(10)) + $pickerEvidence = [ordered]@{ + uniqueNewPicker = $true + ownerLinkedToApplication = [bool]$picker.OwnerLinked + sameProcessAsApplication = [bool]$picker.SameProcess + processId = [int]$picker.ProcessId + windowClass = [string]$picker.ClassName + windowTitle = [string]$picker.Name + isModal = [bool]$picker.IsModal + folderSelectButtonCount = [int]$picker.ConfirmButtonCount + cancelButtonCount = [int]$picker.CancelButtonCount + escapeCancelled = $true + closedAfterEscape = $true + } + } + } + Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath + Assert-NoTornadoConnection $Application.Id + # A physical Yes click starts an asynchronous atomic store operation. Do + # not race that write by opening the destination in the click handler. Node + # waits for the native completion receipt and then publishes the dedicated + # observation request (10 or 13), which is the first permitted fresh read. + $files = if ($Sequence -eq 9 -or $Sequence -eq 12) { + $script:LastObservedFileState + } + else { + $fresh = Get-LocalFileState + $script:LastObservedFileState = $fresh + $fresh + } + if (-not (Test-JsonEquivalent $files.settings $script:BaselineSettingsState)) { + Throw-SafeFailure 'The runtime folder settings file changed during a cancel-only smoke.' + } + if ([string]$files.source.sha256 -cne $ExpectedSourceSha256 -or + [int]$files.source.rowCount -ne 8) { + Throw-SafeFailure 'The original read-only comparison source changed during the smoke.' + } + if ($Sequence -lt 9 -and + [string]$files.destination.sha256 -cne $ExpectedInitialDestinationSha256) { + Throw-SafeFailure 'The comparison destination changed before the authorized Yes input.' + } + if ($Sequence -eq 10) { + if ([int]$files.destination.rowCount -ne 9 -or + [string]$files.destination.firstPair -cne $ExpectedInitialPairIdentity -or + [string]$files.destination.sha256 -ceq $ExpectedInitialDestinationSha256) { + Throw-SafeFailure 'The first authorized import is not an appended nine-row file.' + } + $script:FirstImportedDestinationState = $files.destination + } + if ($Sequence -eq 13 -and + (-not (Test-JsonEquivalent $files.destination $script:FirstImportedDestinationState))) { + Throw-SafeFailure 'The second import changed the comparison file instead of added0 idempotence.' + } + + $script:LocalInputAcknowledgementCalls++ + if ($script:LocalInputAcknowledgementCalls -ne $Sequence) { + Throw-SafeFailure 'The local-state acknowledgement budget changed.' + } + $acknowledgement = Write-LocalAcknowledgement ` + $AcknowledgementPath ` + $request ` + $files ` + $pickerEvidence + return [pscustomobject]@{ + Sequence = $Sequence + RequestSha256 = $requestEvidence.Sha256 + AcknowledgementSha256 = $acknowledgement.Sha256 + } +} + +function Invoke-LocalHarnessUnderWatch( + [string]$Executable, + [string]$ScriptPath, + [int]$DebugPort, + [string]$OutputPath, + [string]$ScreenshotPath, + [string]$ExchangePath, + [int]$TimeoutSeconds, + [Diagnostics.Process]$Application, + [string]$ExpectedApplicationPath) { + $rawArguments = @( + $ScriptPath, + '--port', [string]$DebugPort, + '--output', $OutputPath, + '--screenshot', $ScreenshotPath, + '--exchange-directory', $ExchangePath, + '--timeout-ms', [string]($TimeoutSeconds * 1000)) + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $Executable + $startInfo.WorkingDirectory = $RepositoryRoot + $startInfo.Arguments = ($rawArguments | ForEach-Object { + ConvertTo-QuotedProcessArgument ([string]$_) + }) -join ' ' + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardInput = $true + try { $node = [Diagnostics.Process]::Start($startInfo) } + catch { Throw-SafeFailure 'The local-state Node harness could not be started.' } + if ($null -eq $node) { Throw-SafeFailure 'The local-state Node harness did not start.' } + + $deadline = [datetime]::UtcNow.AddSeconds($TimeoutSeconds + 30) + $sequence = 1 + $exchanges = @() + $monitoringFailure = $null + try { + while (-not $node.HasExited) { + if ([datetime]::UtcNow -ge $deadline) { + Throw-SafeFailure 'The local-state harness exceeded its global deadline.' + } + Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath + Assert-NoTornadoConnection $Application.Id + if ($sequence -le $ExpectedExchangePlan.Count) { + $requestPath = Join-Path $ExchangePath ( + $sequence.ToString('00') + '.request.json') + $ackPath = Join-Path $ExchangePath ( + $sequence.ToString('00') + '.ack.json') + if ([IO.File]::Exists($requestPath)) { + $exchanges += Invoke-LocalStateExchange ` + $sequence ` + $requestPath ` + $ackPath ` + $Application ` + $ExpectedApplicationPath + $sequence++ + } + } + Start-Sleep -Milliseconds 50 + $node.Refresh() + } + if ($sequence -ne ($ExpectedExchangePlan.Count + 1) -or + $script:LocalInputRequestCalls -ne $ExpectedExchangePlan.Count -or + $script:LocalInputAcknowledgementCalls -ne $ExpectedExchangePlan.Count) { + Throw-SafeFailure 'The closed thirteen-step local-state sequence did not complete.' + } + } + catch { $monitoringFailure = $_.Exception.Message } + + if ($null -ne $monitoringFailure) { + $stopped = $node.HasExited + if (-not $stopped) { + try { + $node.StandardInput.WriteLine('CANCEL') + $node.StandardInput.Flush() + $stopped = $node.WaitForExit(5000) + } + catch { $stopped = $false } + } + if (-not $stopped) { + try { + $node.Kill() + $stopped = $node.WaitForExit(10000) + } + catch { $stopped = $false } + } + Throw-SafeFailure ( + "$monitoringFailure The input helper was stopped if possible; " + + 'the packaged app was left open and no input was retried.') + } + return [pscustomobject]@{ + ExitCode = $node.ExitCode + Exchanges = @($exchanges) + } +} + +function Read-LocalHarnessEvidenceFile([string]$Path) { + try { + $item = Get-Item -LiteralPath $Path -Force + if ($item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -le 0 -or $item.Length -gt 128KB) { + Throw-SafeFailure 'Local-state harness evidence is not a bounded regular file.' + } + + $bytes = [IO.File]::ReadAllBytes($Path) + if ($bytes.Length -ne $item.Length) { + Throw-SafeFailure 'Local-state harness evidence changed while it was read.' + } + + $value = $StrictUtf8.GetString($bytes) | ConvertFrom-Json -ErrorAction Stop + return [pscustomobject]@{ + Value = $value + Bytes = $bytes + Sha256 = Get-ByteArraySha256 $bytes + } + } + catch [InvalidOperationException] { + throw + } + catch { + Throw-SafeFailure 'Local-state harness evidence is not strict bounded JSON.' + } +} + +function Assert-LocalHarnessEvidence( + [string]$EvidencePath, + [string]$ScreenshotPath, + [string]$ExchangePath) { + $sealed = Read-LocalHarnessEvidenceFile $EvidencePath + $value = $sealed.Value + if ([int]$value.schemaVersion -ne 1 -or + [string]$value.profile -cne 'local-settings-comparison-import' -or + [string]$value.result -cne 'PASS' -or $null -ne $value.error -or + [string]$value.target.expectedUrl -cne + 'https://legacy-parity.mbn.local/index.html' -or + [int]$value.target.port -ne $Port -or + [string]$value.target.url -cne + 'https://legacy-parity.mbn.local/index.html' -or + [string]$value.safety.requiredMode -cne 'dryRun' -or + [string]$value.safety.requiredPhase -cne 'idle' -or + $value.safety.appCloseRequested -ne $false -or + $value.safety.playoutIntentIssued -ne $false -or + [int]$value.safety.inputRetryCount -ne 0 -or + @($value.safety.blockedOutboundMessages).Count -ne 0 -or + @($value.safety.stateViolations).Count -ne 0 -or + $value.settings.allCancelled -ne $true -or + $value.settings.settingsFileUnchanged -ne $true -or + -not (Test-ExactStringSequence @($value.settings.pickerKinds) @( + 'design', 'resource', 'background')) -or + $value.comparison.initialPairPreserved -ne $true -or + $value.comparison.secondImportIdempotent -ne $true -or + [int]$value.comparison.afterFirstImport.receipt.addedPairCount -ne 8 -or + [int]$value.comparison.afterFirstImport.pairCount -ne 9 -or + [int]$value.comparison.afterSecondImport.receipt.addedPairCount -ne 0 -or + [int]$value.comparison.afterSecondImport.pairCount -ne 9 -or + [string]$value.files.destinationAfterFirstImport.sha256 -cne + [string]$value.files.destinationAfterSecondImport.sha256 -or + @($value.exchanges).Count -ne 13) { + Throw-SafeFailure 'The local-state evidence does not satisfy the exact cancel/import contract.' + } + $screenshot = Get-Item -LiteralPath $ScreenshotPath -Force + if ($screenshot.Length -le 0 -or + [int64]$value.screenshot.bytes -ne $screenshot.Length -or + [string]$value.screenshot.sha256 -cne + (Get-FileHash -Algorithm SHA256 -LiteralPath $ScreenshotPath).Hash) { + Throw-SafeFailure 'The local-state screenshot does not match its sealed evidence.' + } + for ($index = 0; $index -lt 13; $index++) { + $sequence = $index + 1 + $entry = $value.exchanges[$index] + $requestPath = Join-Path $ExchangePath ($sequence.ToString('00') + '.request.json') + $ackPath = Join-Path $ExchangePath ($sequence.ToString('00') + '.ack.json') + if ([int]$entry.sequence -ne $sequence -or + -not (Test-PathEqual ([string]$entry.requestPath) $requestPath) -or + -not (Test-PathEqual ([string]$entry.acknowledgementPath) $ackPath) -or + [string]$entry.requestSha256 -cne + (Get-FileHash -Algorithm SHA256 -LiteralPath $requestPath).Hash -or + [string]$entry.acknowledgementSha256 -cne + (Get-FileHash -Algorithm SHA256 -LiteralPath $ackPath).Hash) { + Throw-SafeFailure "Local-state exchange $sequence is not sealed exactly." + } + $expectedOperation = [string]$ExpectedExchangePlan[$index].Operation + if ($expectedOperation -ceq 'observe-local-files') { + if ($null -ne $entry.physicalPointer) { + Throw-SafeFailure "File observation $sequence unexpectedly contains pointer input." + } + } + elseif ($null -eq $entry.physicalPointer -or + $entry.physicalPointer.down.isTrusted -ne $true -or + $entry.physicalPointer.up.isTrusted -ne $true -or + [string]$entry.physicalPointer.down.pointerType -cne 'mouse' -or + [string]$entry.physicalPointer.up.pointerType -cne 'mouse' -or + [string]$entry.physicalPointer.down.targetId -cne + [string]$ExpectedExchangePlan[$index].TargetId -or + [string]$entry.physicalPointer.up.targetId -cne + [string]$ExpectedExchangePlan[$index].TargetId) { + Throw-SafeFailure "Input exchange $sequence lacks exact trusted pointer evidence." + } + } + return $value +} + +if ($StaticAudit) { + [pscustomobject]@{ + result = 'PASS' + profile = 'local-settings-comparison-import' + packageFlavor = 'DebugAppX' + exchangeCount = $ExpectedExchangePlan.Count + physicalClickCount = @($ExpectedExchangePlan | Where-Object { + $_.Operation -ceq 'application-click' -or + $_.Operation -ceq 'folder-picker-click-cancel' + }).Count + folderPickerCancelCount = @($ExpectedExchangePlan | Where-Object { + $_.Operation -ceq 'folder-picker-click-cancel' + }).Count + fileObservationCount = @($ExpectedExchangePlan | Where-Object { + $_.Operation -ceq 'observe-local-files' + }).Count + inputRetryCount = 0 + expectedSourceSha256 = $ExpectedSourceSha256 + expectedSourceRows = 8 + expectedInitialDestinationSha256 = $ExpectedInitialDestinationSha256 + expectedInitialDestinationRows = 1 + sendInputClickAvailable = $null -ne + [LegacyPackageInputNative].GetMethod('SendApplicationClick') + sendInputEscapeAvailable = $null -ne + [LegacyPackageInputNative].GetMethod('SendEscapeToForegroundWindow') + harnessPath = $HarnessPath + harnessSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $HarnessPath).Hash + } | ConvertTo-Json -Compress + return +} + +$timestamp = [datetime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ') +$Output = Resolve-EvidencePath $Output "legacy-package-local-state-$timestamp.json" +$Screenshot = Resolve-EvidencePath $Screenshot "legacy-package-local-state-$timestamp.png" +if ([string]::IsNullOrWhiteSpace($ExchangeDirectory)) { + $ExchangeDirectory = [IO.Path]::ChangeExtension($Output, $null) + '.exchanges' +} +elseif (-not [IO.Path]::IsPathRooted($ExchangeDirectory)) { + $ExchangeDirectory = [IO.Path]::GetFullPath((Join-Path $RepositoryRoot $ExchangeDirectory)) +} +else { $ExchangeDirectory = [IO.Path]::GetFullPath($ExchangeDirectory) } +$NodePath = Resolve-NodeExecutable $NodePath + +try { + if (-not [IO.File]::Exists($HarnessPath)) { + Throw-SafeFailure 'The tracked local-state harness is missing.' + } + Assert-OutputPathAvailable $Output 'Output' + Assert-OutputPathAvailable $Screenshot 'Screenshot' + Assert-OutputPathAvailable $ExchangeDirectory 'Exchange directory' + [void][IO.Directory]::CreateDirectory($ExchangeDirectory) + if (@(Get-ChildItem -LiteralPath $ExchangeDirectory -Force).Count -ne 0) { + Throw-SafeFailure 'The new exchange directory is not empty.' + } + Assert-LocalFilePreflight | Out-Null + Assert-LocalConfigurationIsDryRun + Assert-NoUnsafeEnvironment + $registration = Get-ExactDevelopmentPackage + Assert-NoExistingApplication + Assert-PortIsFree $Port + + $script:Phase = 'package launch' + $script:ApplicationProcess = Start-PackagedDryRunApplication $registration $Port + $script:ApplicationWasLaunched = $true + [void](Wait-ExactLoopbackListener ` + $Port ` + $script:ApplicationProcess ` + ([datetime]::UtcNow.AddSeconds($LaunchTimeoutSeconds))) + Assert-NoTornadoConnection $script:ApplicationProcess.Id + + $script:Phase = 'local settings and comparison input harness' + $harness = Invoke-LocalHarnessUnderWatch ` + $NodePath ` + $HarnessPath ` + $Port ` + $Output ` + $Screenshot ` + $ExchangeDirectory ` + $HarnessTimeoutSeconds ` + $script:ApplicationProcess ` + $registration.Executable + if ($harness.ExitCode -ne 0) { + Throw-SafeFailure "The local-state harness failed with exit code $($harness.ExitCode)." + } + $evidence = Assert-LocalHarnessEvidence $Output $Screenshot $ExchangeDirectory + if ($harness.Exchanges.Count -ne 13) { + Throw-SafeFailure 'The wrapper did not observe all thirteen sealed exchanges.' + } + Assert-ExactApplicationStillRunning $script:ApplicationProcess $registration.Executable + Assert-NoTornadoConnection $script:ApplicationProcess.Id + $finalFiles = Get-LocalFileState + if (-not (Test-JsonEquivalent $finalFiles.settings $script:BaselineSettingsState) -or + -not (Test-JsonEquivalent $finalFiles.destination $script:FirstImportedDestinationState) -or + [string]$finalFiles.source.sha256 -cne $ExpectedSourceSha256) { + Throw-SafeFailure 'Final local files do not match the sealed cancel/idempotence result.' + } + + # This authorized import changed user-local comparison data. Avoid a second + # activation; normal close is the only remaining app action. + $script:Phase = 'normal close' + Invoke-ExactNormalClose $script:ApplicationProcess $registration.Executable + Wait-PortReleased $Port ([datetime]::UtcNow.AddSeconds(15)) + if (@(Get-LegacyApplicationProcesses).Count -ne 0) { + Throw-SafeFailure 'A LegacyParity process remained after normal exit.' + } + + $script:Phase = 'complete' + [pscustomobject]@{ + result = [string]$evidence.result + packageFullName = $PackageFullName + packageMode = $PackageModeLabel + playoutMode = 'DryRun' + processId = $script:ApplicationProcess.Id + tornadoPortConnections = 0 + inputRetryCount = 0 + localInputRequestCalls = $script:LocalInputRequestCalls + localInputAcknowledgementCalls = $script:LocalInputAcknowledgementCalls + settingsPickerCancelCount = 3 + comparisonImportAddedCount = 8 + comparisonSecondImportAddedCount = 0 + output = $Output + outputSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $Output).Hash + screenshot = $Screenshot + screenshotSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $Screenshot).Hash + exchangeDirectory = $ExchangeDirectory + } +} +catch { + $message = $_.Exception.Message + if ($script:CloseMainWindowCalls -gt 0 -or $script:PrimaryInvokeCalls -gt 0) { + Throw-SafeFailure ( + "Local-state smoke became ambiguous during '$($script:Phase)': $message " + + 'No close retry or forced termination was attempted.') + } + $running = $false + if ($script:ApplicationWasLaunched -and $null -ne $script:ApplicationProcess) { + try { + $script:ApplicationProcess.Refresh() + $running = -not $script:ApplicationProcess.HasExited + } + catch { $running = $false } + } + if ($running) { + Throw-SafeFailure ( + "Local-state smoke stopped during '$($script:Phase)': $message " + + 'The app was intentionally left open; no input retry, guessed cleanup, ' + + 'close retry or forced termination was attempted.') + } + throw +} diff --git a/scripts/Invoke-LegacyPgmParityRun.ps1 b/scripts/Invoke-LegacyPgmParityRun.ps1 new file mode 100644 index 0000000..fbe850e --- /dev/null +++ b/scripts/Invoke-LegacyPgmParityRun.ps1 @@ -0,0 +1,1268 @@ +[CmdletBinding()] +param( + [ValidateSet('StaticAudit', 'DryPreflight', 'Initialize', 'Inspect', 'RunStage', 'RunNext')] + [string] $Action = 'StaticAudit', + + [string] $EvidenceDirectory = '', + + [ValidateSet('Core5001', 'ThirtyRoutes', 'PagedBoundary', 'S6001AssetIndependent', 'Recovery8001', 'Recovery5077Page2', 'ThirtyRoutes20To29')] + [string] $Workflow = 'Core5001', + + [string] $Stage = '', + [int] $RouteIndex = -1, + [int] $PageIndex = -1, + [int] $DebugPort = 9339, + [int] $ExpectedPgmPort = 30001, + [int] $StageTimeoutSeconds = 900, + + [string] $ExpectedPackageName = 'Wickedness.MBNStockWebView.LegacyParity', + [string] $ExpectedPgmExecutable = '', + [string] $ExpectedAppWindowTitle = '', + [string] $ExpectedPgmWindowTitle = 'PGM', + [string] $ExpectedNetworkWindowTitle = '', + [string] $OriginalCutsDirectory = '', + [string] $NodeExecutable = '' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($ExpectedNetworkWindowTitle)) { + $ExpectedNetworkWindowTitle = [Text.Encoding]::Unicode.GetString( + [Convert]::FromBase64String('JLG40szGbNAgAKi6yLIw0cG5IAA9zA==')) +} +if ([string]::IsNullOrWhiteSpace($ExpectedAppWindowTitle)) { + $ExpectedAppWindowTitle = [Text.Encoding]::Unicode.GetString( + [Convert]::FromBase64String('VgAtAFMAdABvAGMAawAgAJ3JjK0VyPS8ocGczdzCpMJc0SAAZgBvAHIAIADkuXzHvawcyFQAVgAgACgAMgA2AC4AMAAzAC4AMgA2ACkA')) +} + +$workspaceRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +if ([string]::IsNullOrWhiteSpace($OriginalCutsDirectory)) { + $OriginalCutsDirectory = [IO.Path]::GetFullPath((Join-Path $workspaceRoot ` + '..\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts')) +} else { + $OriginalCutsDirectory = [IO.Path]::GetFullPath($OriginalCutsDirectory) +} +if ([string]::IsNullOrWhiteSpace($ExpectedPgmExecutable) -and + -not [string]::IsNullOrWhiteSpace($env:MBN_STOCK_PGM_EXECUTABLE)) { + $ExpectedPgmExecutable = [IO.Path]::GetFullPath($env:MBN_STOCK_PGM_EXECUTABLE) +} elseif (-not [string]::IsNullOrWhiteSpace($ExpectedPgmExecutable)) { + $ExpectedPgmExecutable = [IO.Path]::GetFullPath($ExpectedPgmExecutable) +} + +$script:Utf8NoBom = [Text.UTF8Encoding]::new($false) +$script:TargetUrl = 'https://legacy-parity.mbn.local/index.html' +$script:NodeStageScript = Join-Path $PSScriptRoot 'LegacyPgmParityStage.mjs' +$script:RouteCodes = @( + '5001', '5074', + '5016', '50160', '5078', '8067', '5086', '50860', '5023', '5024', + '5077', '5082', '5083', '5084', '5085', '5088', '6067', '8035', + '5011', '8003', '5037', '8001', '5026', '5087', '5029', '8032', + '5032', '5076', '5079', '5080', '5081', '5025', '6001' +) +$script:S6001AssetRelativePaths = @( + [Text.Encoding]::Unicode.GetString( + [Convert]::FromBase64String('aQBtAGEAZwBlAHMAXAD8yCDHMK5tAGUAcgBnAGUALgBwAG4AZwA=')), + [Text.Encoding]::Unicode.GetString( + [Convert]::FromBase64String('aQBtAGEAZwBlAHMAXAAzADUANwA1ADIAOQAxADMAXwBsAC4AagBwAGcA')) +) + +function Write-NewText { + param( + [Parameter(Mandatory = $true)] [string] $Path, + [Parameter(Mandatory = $true)] [AllowEmptyString()] [string] $Text + ) + $fullPath = [IO.Path]::GetFullPath($Path) + $stream = [IO.File]::Open($fullPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::Read) + try { + $writer = [IO.StreamWriter]::new($stream, $script:Utf8NoBom) + try { $writer.Write($Text) } finally { $writer.Dispose() } + } finally { + if ($null -ne $stream) { $stream.Dispose() } + } +} + +function Write-NewJson { + param( + [Parameter(Mandatory = $true)] [string] $Path, + [Parameter(Mandatory = $true)] [object] $Value + ) + Write-NewText -Path $Path -Text (($Value | ConvertTo-Json -Depth 40) + [Environment]::NewLine) +} + +function Get-CanonicalJson { + param([Parameter(Mandatory = $true)] [object] $Value) + return ($Value | ConvertTo-Json -Depth 40 -Compress) +} + +function Get-ResolvedNodeExecutable { + if (-not [string]::IsNullOrWhiteSpace($NodeExecutable)) { + $resolved = [IO.Path]::GetFullPath($NodeExecutable) + if (-not [IO.File]::Exists($resolved)) { throw "Node executable does not exist: $resolved" } + return $resolved + } + $command = Get-Command node.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -ne $command) { return [IO.Path]::GetFullPath($command.Source) } + $bundled = Join-Path $env:USERPROFILE '.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe' + if ([IO.File]::Exists($bundled)) { return [IO.Path]::GetFullPath($bundled) } + throw 'Node was not found. Supply -NodeExecutable with an absolute node.exe path.' +} + +function Get-FileIdentity { + param([Parameter(Mandatory = $true)] [string] $Path) + $fullPath = [IO.Path]::GetFullPath($Path) + if (-not [IO.File]::Exists($fullPath)) { throw "Pinned file does not exist: $fullPath" } + $file = [IO.FileInfo]::new($fullPath) + return [ordered]@{ + path = $fullPath + length = $file.Length + lastWriteUtc = $file.LastWriteTimeUtc.ToString('o') + sha256 = (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256).Hash + } +} + +function Initialize-NativeTypes { + if ('CodexLegacyParityNative20260722' -as [type]) { return } + Add-Type -AssemblyName System.Drawing + Add-Type @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; + +public static class CodexLegacyParityNative20260722 +{ + public delegate bool WindowCallback(IntPtr window, IntPtr parameter); + + [StructLayout(LayoutKind.Sequential)] + public struct Rect { public int Left, Top, Right, Bottom; } + + [DllImport("user32.dll", SetLastError=true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool EnumWindows(WindowCallback callback, IntPtr parameter); + + [DllImport("user32.dll", SetLastError=true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool EnumChildWindows(IntPtr parent, WindowCallback callback, IntPtr parameter); + + [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern int GetWindowText(IntPtr window, StringBuilder text, int count); + + [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern int GetClassName(IntPtr window, StringBuilder text, int count); + + [DllImport("user32.dll", SetLastError=true)] + private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsWindowVisible(IntPtr window); + + [DllImport("user32.dll", SetLastError=true)] + private static extern IntPtr SendMessageTimeout(IntPtr window, uint message, UIntPtr wParam, + IntPtr lParam, uint flags, uint timeoutMilliseconds, out UIntPtr result); + + [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern IntPtr SendMessageTimeout(IntPtr window, uint message, UIntPtr wParam, + StringBuilder lParam, uint flags, uint timeoutMilliseconds, out UIntPtr result); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool GetWindowRect(IntPtr window, out Rect rect); + + [DllImport("user32.dll", EntryPoint="GetWindowLongPtrW", SetLastError=true)] + private static extern IntPtr GetWindowLongPtr64(IntPtr window, int index); + + [DllImport("user32.dll", EntryPoint="GetWindowLongW", SetLastError=true)] + private static extern int GetWindowLong32(IntPtr window, int index); + + [DllImport("user32.dll", SetLastError=true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetWindowPos(IntPtr window, IntPtr insertAfter, + int x, int y, int width, int height, uint flags); + + public static bool IsTopMost(IntPtr window) + { + const int GwlExStyle = -20; + const long WsExTopMost = 0x00000008L; + long style = IntPtr.Size == 8 + ? GetWindowLongPtr64(window, GwlExStyle).ToInt64() + : GetWindowLong32(window, GwlExStyle); + return (style & WsExTopMost) != 0; + } + + public static bool SetTopMostForCapture(IntPtr window, bool topMost) + { + IntPtr insertAfter = topMost ? new IntPtr(-1) : new IntPtr(-2); + const uint SwpNoSize = 0x0001; + const uint SwpNoMove = 0x0002; + const uint SwpNoActivate = 0x0010; + const uint SwpShowWindow = 0x0040; + return SetWindowPos(window, insertAfter, 0, 0, 0, 0, + SwpNoSize | SwpNoMove | SwpNoActivate | SwpShowWindow); + } + + [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] + private static extern int GetPackageFullName(IntPtr process, ref uint packageFullNameLength, + StringBuilder packageFullName); + + public static string GetProcessPackageFullName(IntPtr process) + { + const int AppmodelErrorNoPackage = 15700; + const int ErrorInsufficientBuffer = 122; + uint length = 0; + int first = GetPackageFullName(process, ref length, null); + if (first == AppmodelErrorNoPackage) return null; + if (first != ErrorInsufficientBuffer || length == 0) + throw new InvalidOperationException("GetPackageFullName length query failed: " + first); + var buffer = new StringBuilder((int)length); + int second = GetPackageFullName(process, ref length, buffer); + if (second != 0) throw new InvalidOperationException("GetPackageFullName failed: " + second); + return buffer.ToString(); + } + + public static IntPtr[] FindTopLevelWindows(uint processId, string exactTitle) + { + var matches = new List(); + if (!EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + if (GetWindowThreadProcessId(window, out owner) == 0 || owner != processId) return true; + var title = new StringBuilder(1024); + GetWindowText(window, title, title.Capacity); + if (String.Equals(title.ToString(), exactTitle, StringComparison.Ordinal)) matches.Add(window); + return true; + }, IntPtr.Zero)) throw new InvalidOperationException("Top-level window enumeration failed."); + return matches.ToArray(); + } + + public static string GetWindowTitle(IntPtr window) + { + var title = new StringBuilder(1024); + GetWindowText(window, title, title.Capacity); + return title.ToString(); + } + + public static bool GetVisible(IntPtr window) { return IsWindowVisible(window); } + + private static IntPtr FindUniqueRichEdit(IntPtr parent) + { + var matches = new List(); + if (!EnumChildWindows(parent, delegate(IntPtr window, IntPtr parameter) { + var className = new StringBuilder(256); + if (GetClassName(window, className, className.Capacity) > 0 && + String.Equals(className.ToString(), "RichEdit20W", StringComparison.Ordinal)) matches.Add(window); + return true; + }, IntPtr.Zero)) throw new InvalidOperationException("Network child enumeration failed."); + if (matches.Count != 1) + throw new InvalidOperationException("Expected exactly one Network Monitoring RichEdit20W; found " + matches.Count + "."); + return matches[0]; + } + + private static string ReadTextOnce(IntPtr window) + { + const uint WmGetText = 0x000D, WmGetTextLength = 0x000E, Flags = 0x0003; + UIntPtr lengthResult; + if (SendMessageTimeout(window, WmGetTextLength, UIntPtr.Zero, IntPtr.Zero, Flags, 5000, + out lengthResult) == IntPtr.Zero) throw new InvalidOperationException("Network length read timed out."); + long length = checked((long)lengthResult.ToUInt64()); + if (length < 0 || length > 10000000) throw new InvalidOperationException("Network text length is unsafe."); + var text = new StringBuilder(checked((int)length + 65537)); + UIntPtr copied; + if (SendMessageTimeout(window, WmGetText, new UIntPtr((uint)text.Capacity), text, Flags, 5000, + out copied) == IntPtr.Zero || copied.ToUInt64() != (ulong)text.Length) + throw new InvalidOperationException("Network text copy failed."); + return text.ToString(); + } + + public static string ReadStableNetworkText(IntPtr parent) + { + IntPtr edit = FindUniqueRichEdit(parent); + string first = ReadTextOnce(edit); + string second = ReadTextOnce(edit); + if (!String.Equals(first, second, StringComparison.Ordinal)) + throw new InvalidOperationException("Network text changed during the read."); + return first; + } +} +'@ +} + +function Get-ExactWindowDescriptor { + param( + [Parameter(Mandatory = $true)] [int] $ProcessIdValue, + [Parameter(Mandatory = $true)] [string] $ExactTitle, + [bool] $RequireVisible + ) + $matches = @([CodexLegacyParityNative20260722]::FindTopLevelWindows([uint32]$ProcessIdValue, $ExactTitle)) + if ($matches.Count -ne 1) { + throw "Expected one '$ExactTitle' window owned by PID $ProcessIdValue; found $($matches.Count)." + } + $handle = [IntPtr]$matches[0] + $visible = [CodexLegacyParityNative20260722]::GetVisible($handle) + if ($RequireVisible -and -not $visible) { throw "Pinned '$ExactTitle' window is not visible." } + return [ordered]@{ + handle = $handle.ToInt64().ToString([Globalization.CultureInfo]::InvariantCulture) + title = [CodexLegacyParityNative20260722]::GetWindowTitle($handle) + visible = $visible + } +} + +function Get-ProcessPathSafe { + param([Parameter(Mandatory = $true)] [Diagnostics.Process] $Process) + try { return $Process.Path } catch { return $null } +} + +function Find-UniqueProcessByPath { + param([Parameter(Mandatory = $true)] [string] $ExpectedPath) + $fullPath = [IO.Path]::GetFullPath($ExpectedPath) + $matches = @() + foreach ($candidate in @(Get-Process -ErrorAction Stop)) { + $candidatePath = Get-ProcessPathSafe -Process $candidate + if ($null -ne $candidatePath -and [string]::Equals( + [IO.Path]::GetFullPath($candidatePath), $fullPath, [StringComparison]::OrdinalIgnoreCase)) { + $matches += $candidate + } + } + if ($matches.Count -ne 1) { throw "Expected exactly one process at '$fullPath'; found $($matches.Count)." } + return $matches[0] +} + +function Get-ProcessDescriptor { + param( + [Parameter(Mandatory = $true)] [Diagnostics.Process] $Process, + [string] $WindowTitle = '', + [bool] $RequireVisibleWindow = $false + ) + $path = Get-ProcessPathSafe -Process $Process + if ([string]::IsNullOrWhiteSpace($path)) { throw "PID $($Process.Id) has no readable executable path." } + $packageFullName = [CodexLegacyParityNative20260722]::GetProcessPackageFullName($Process.Handle) + $descriptor = [ordered]@{ + processId = $Process.Id + startUtc = $Process.StartTime.ToUniversalTime().ToString('o') + executable = Get-FileIdentity -Path $path + packageFullName = $packageFullName + } + if (-not [string]::IsNullOrWhiteSpace($WindowTitle)) { + $descriptor.window = Get-ExactWindowDescriptor -ProcessIdValue $Process.Id -ExactTitle $WindowTitle -RequireVisible $RequireVisibleWindow + } + return $descriptor +} + +function Get-ListenerDescriptor { + param( + [Parameter(Mandatory = $true)] [int] $Port, + [int] $ExpectedOwner = -1 + ) + $listeners = @(Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction Stop) + if ($listeners.Count -eq 0) { throw "No TCP listener is present on port $Port." } + $owners = @($listeners | Select-Object -ExpandProperty OwningProcess -Unique) + if ($owners.Count -ne 1) { throw "TCP port $Port has more than one listener owner." } + if ($ExpectedOwner -ge 0 -and $owners[0] -ne $ExpectedOwner) { + throw "TCP port $Port is owned by PID $($owners[0]), expected $ExpectedOwner." + } + $endpoints = @($listeners | ForEach-Object { + '{0}:{1}|{2}|{3}' -f $_.LocalAddress, $_.LocalPort, $_.State, $_.OwningProcess + } | Sort-Object) + return [ordered]@{ port = $Port; ownerProcessId = [int]$owners[0]; endpoints = $endpoints } +} + +function Get-EstablishedPgmConnection { + param( + [Parameter(Mandatory = $true)] [int] $AppProcessId, + [Parameter(Mandatory = $true)] [int] $PgmProcessId, + [Parameter(Mandatory = $true)] [int] $PgmPort + ) + $connections = @(Get-NetTCPConnection -State Established -RemotePort $PgmPort -ErrorAction Stop | + Where-Object { $_.OwningProcess -eq $AppProcessId }) + if ($connections.Count -ne 1) { + throw "Expected one established app-to-PGM connection owned by PID $AppProcessId; found $($connections.Count)." + } + $connection = $connections[0] + $localAddress = $null + $remoteAddress = $null + if (-not [Net.IPAddress]::TryParse([string]$connection.LocalAddress, [ref]$localAddress) -or + -not [Net.IPAddress]::TryParse([string]$connection.RemoteAddress, [ref]$remoteAddress) -or + -not [Net.IPAddress]::IsLoopback($localAddress) -or + -not [Net.IPAddress]::IsLoopback($remoteAddress)) { + throw 'The app-to-PGM connection is not entirely loopback.' + } + $reciprocal = @(Get-NetTCPConnection -State Established -LocalPort $PgmPort ` + -RemotePort $connection.LocalPort -ErrorAction Stop | + Where-Object { + $_.OwningProcess -eq $PgmProcessId -and + [string]$_.LocalAddress -ceq [string]$connection.RemoteAddress -and + [string]$_.RemoteAddress -ceq [string]$connection.LocalAddress + }) + if ($reciprocal.Count -ne 1) { + throw "Expected one reciprocal PGM-to-app connection owned by PID $PgmProcessId; found $($reciprocal.Count)." + } + $pgmSide = $reciprocal[0] + return [ordered]@{ + appSide = [ordered]@{ + ownerProcessId = $connection.OwningProcess + localAddress = $connection.LocalAddress + localPort = $connection.LocalPort + remoteAddress = $connection.RemoteAddress + remotePort = $connection.RemotePort + state = [string]$connection.State + } + pgmSide = [ordered]@{ + ownerProcessId = $pgmSide.OwningProcess + localAddress = $pgmSide.LocalAddress + localPort = $pgmSide.LocalPort + remoteAddress = $pgmSide.RemoteAddress + remotePort = $pgmSide.RemotePort + state = [string]$pgmSide.State + } + } +} + +function Get-CdpTargetDescriptor { + param([Parameter(Mandatory = $true)] [int] $Port) + $targets = @(Invoke-RestMethod -Uri "http://127.0.0.1:$Port/json/list" -Method Get -TimeoutSec 10) + $pages = @($targets | Where-Object { $_.type -ceq 'page' -and $_.url -ceq $script:TargetUrl }) + if ($pages.Count -ne 1) { throw "Expected exactly one CDP parity target; found $($pages.Count)." } + $target = $pages[0] + if ([string]::IsNullOrWhiteSpace([string]$target.id) -or + [string]::IsNullOrWhiteSpace([string]$target.webSocketDebuggerUrl)) { + throw 'The parity CDP target is missing an ID or WebSocket endpoint.' + } + $endpoint = [Uri]::new([string]$target.webSocketDebuggerUrl) + if ($endpoint.Scheme -cne 'ws' -or $endpoint.Host -cne '127.0.0.1' -or + $endpoint.Port -ne $Port -or $endpoint.AbsolutePath -cne "/devtools/page/$($target.id)" -or + -not [string]::IsNullOrEmpty($endpoint.Query) -or -not [string]::IsNullOrEmpty($endpoint.Fragment)) { + throw 'The parity CDP WebSocket endpoint identity is not exact.' + } + return [ordered]@{ + id = [string]$target.id + type = [string]$target.type + title = [string]$target.title + url = [string]$target.url + webSocketDebuggerUrl = [string]$target.webSocketDebuggerUrl + } +} + +function Get-CutInventory { + param( + [Parameter(Mandatory = $true)] [string] $SourceDirectory, + [Parameter(Mandatory = $true)] [string] $RuntimeDirectory + ) + $sourceRoot = [IO.Path]::GetFullPath($SourceDirectory) + $runtimeRoot = [IO.Path]::GetFullPath($RuntimeDirectory) + $items = @() + foreach ($code in $script:RouteCodes) { + $sourcePath = Join-Path $sourceRoot "$code.t2s" + $runtimePath = Join-Path $runtimeRoot "$code.t2s" + $source = Get-FileIdentity -Path $sourcePath + $runtime = Get-FileIdentity -Path $runtimePath + if ($source.sha256 -cne $runtime.sha256 -or $source.length -ne $runtime.length) { + throw "Runtime cut $code does not exactly match the authoritative legacy cut." + } + $items += [ordered]@{ + code = $code + sourcePath = $source.path + runtimePath = $runtime.path + length = $runtime.length + sha256 = $runtime.sha256 + } + } + return $items +} + +function Get-AssetInventory { + param( + [Parameter(Mandatory = $true)] [string] $SourceDirectory, + [Parameter(Mandatory = $true)] [string] $RuntimeDirectory + ) + $sourceRoot = [IO.Path]::GetFullPath($SourceDirectory) + $runtimeRoot = [IO.Path]::GetFullPath($RuntimeDirectory) + $items = @() + foreach ($relativePath in $script:S6001AssetRelativePaths) { + $source = Get-FileIdentity -Path (Join-Path $sourceRoot $relativePath) + $runtime = Get-FileIdentity -Path (Join-Path $runtimeRoot $relativePath) + if ($source.sha256 -cne $runtime.sha256 -or $source.length -ne $runtime.length) { + throw "Runtime asset $relativePath does not exactly match the authoritative legacy asset." + } + $items += [ordered]@{ + relativePath = $relativePath + sourcePath = $source.path + runtimePath = $runtime.path + length = $runtime.length + sha256 = $runtime.sha256 + } + } + return $items +} + +function Get-CurrentPins { + Initialize-NativeTypes + if ($DebugPort -le 0 -or $DebugPort -gt 65535) { throw "Invalid CDP port: $DebugPort" } + if ($ExpectedPgmPort -le 0 -or $ExpectedPgmPort -gt 65535) { throw "Invalid PGM port: $ExpectedPgmPort" } + if ([string]::IsNullOrWhiteSpace($ExpectedPgmExecutable)) { + throw 'Pass -ExpectedPgmExecutable or set MBN_STOCK_PGM_EXECUTABLE for runtime checks.' + } + + $packages = @(Get-AppxPackage -Name $ExpectedPackageName -ErrorAction Stop | + Where-Object { $_.Name -ceq $ExpectedPackageName }) + if ($packages.Count -ne 1) { throw "Expected one registered package '$ExpectedPackageName'; found $($packages.Count)." } + $package = $packages[0] + if ($package.IsDevelopmentMode -ne $true -or [string]$package.Architecture -cne 'X64') { + throw 'Only the x64 loose Debug development package is authorized for this run.' + } + $installLocation = [IO.Path]::GetFullPath($package.InstallLocation) + $workspaceRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) + $debugPackageRoot = [IO.Path]::GetFullPath((Join-Path $workspaceRoot ` + 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Debug')) + $debugPrefix = $debugPackageRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar + if (-not $installLocation.StartsWith($debugPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Package InstallLocation is outside the workspace x64 Debug boundary: $installLocation" + } + $debugRelative = $installLocation.Substring($debugPrefix.Length) + $debugSegments = @($debugRelative.Split([IO.Path]::DirectorySeparatorChar) | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($debugSegments.Count -ne 3 -or + $debugSegments[0] -notmatch '^net[0-9]+\.[0-9]+-windows[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' -or + $debugSegments[1] -cne 'win-x64' -or $debugSegments[2] -cne 'AppX') { + throw "Package InstallLocation is not an exact Debug target-framework/win-x64/AppX path: $installLocation" + } + $appExecutable = Join-Path $installLocation 'MBN_STOCK_WEBVIEW.LegacyParityApp.exe' + if (-not [IO.File]::Exists($appExecutable)) { throw "Package app executable was not found under $installLocation." } + + $appProcess = Find-UniqueProcessByPath -ExpectedPath $appExecutable + $pgmProcess = Find-UniqueProcessByPath -ExpectedPath $ExpectedPgmExecutable + $app = Get-ProcessDescriptor -Process $appProcess -WindowTitle $ExpectedAppWindowTitle -RequireVisibleWindow $true + $pgm = Get-ProcessDescriptor -Process $pgmProcess -WindowTitle $ExpectedPgmWindowTitle -RequireVisibleWindow $true + if ($app.packageFullName -cne $package.PackageFullName) { + throw "App PID $($app.processId) does not carry the registered package identity." + } + $pgm.networkWindow = Get-ExactWindowDescriptor -ProcessIdValue $pgmProcess.Id ` + -ExactTitle $ExpectedNetworkWindowTitle -RequireVisible $false + + $pgmListener = Get-ListenerDescriptor -Port $ExpectedPgmPort -ExpectedOwner $pgmProcess.Id + $appConnection = Get-EstablishedPgmConnection -AppProcessId $appProcess.Id ` + -PgmProcessId $pgmProcess.Id -PgmPort $ExpectedPgmPort + $cdpListener = Get-ListenerDescriptor -Port $DebugPort + $cdpOwnerProcess = Get-Process -Id $cdpListener.ownerProcessId -ErrorAction Stop + $cdpOwner = Get-ProcessDescriptor -Process $cdpOwnerProcess + $cdpTarget = Get-CdpTargetDescriptor -Port $DebugPort + + $runtimeFiles = @() + foreach ($relative in @( + 'MBN_STOCK_WEBVIEW.LegacyParityApp.exe', + 'MBN_STOCK_WEBVIEW.LegacyParityApp.dll', + 'MBN_STOCK_WEBVIEW.Playout.dll')) { + $runtimeFiles += Get-FileIdentity -Path (Join-Path $installLocation $relative) + } + + $nodePath = Get-ResolvedNodeExecutable + $cutInventory = Get-CutInventory -SourceDirectory $OriginalCutsDirectory ` + -RuntimeDirectory (Join-Path $installLocation 'Cuts') + $assetInventory = Get-AssetInventory -SourceDirectory $OriginalCutsDirectory ` + -RuntimeDirectory (Join-Path $installLocation 'Cuts') + + return [ordered]@{ + schemaVersion = 1 + package = [ordered]@{ + name = [string]$package.Name + packageFullName = [string]$package.PackageFullName + packageFamilyName = [string]$package.PackageFamilyName + publisher = [string]$package.Publisher + version = $package.Version.ToString() + architecture = [string]$package.Architecture + isDevelopmentMode = [bool]$package.IsDevelopmentMode + installLocation = $installLocation + debugPackageRoot = $debugPackageRoot + } + app = $app + pgm = $pgm + pgmListener = $pgmListener + appToPgmConnection = $appConnection + cdp = [ordered]@{ + listener = $cdpListener + owner = $cdpOwner + target = $cdpTarget + } + runtimeFiles = $runtimeFiles + cutInventory = $cutInventory + assetInventory = $assetInventory + tools = [ordered]@{ + wrapper = Get-FileIdentity -Path $PSCommandPath + node = Get-FileIdentity -Path $nodePath + nodeStage = Get-FileIdentity -Path $script:NodeStageScript + } + } +} + +function Assert-PinsExact { + param([Parameter(Mandatory = $true)] [object] $Pinned) + $current = Get-CurrentPins + $expectedJson = Get-CanonicalJson -Value $Pinned + $currentJson = Get-CanonicalJson -Value $current + if ($currentJson -cne $expectedJson) { + throw 'A pinned package, process, executable hash, window, listener, connection, CDP target, cut, or tool identity changed.' + } + return $current +} + +function Get-NetworkCounts { + param([Parameter(Mandatory = $true)] [AllowEmptyString()] [string] $Text) + $lines = @($Text -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + return [ordered]@{ + helloRequest = @($lines | Where-Object { $_ -match '\[R\]\s+HELLO\b' }).Count + helloSuccess = @($lines | Where-Object { $_ -match '\[S\]\s+SUCCESS\s+HELLO\b' }).Count + loadSceneRequest = @($lines | Where-Object { $_ -match '\[R\]\s+LOAD_SCENE\b' }).Count + loadSceneSuccess = @($lines | Where-Object { $_ -match '\[S\]\s+SUCCESS\s+LOAD_SCENE\b' }).Count + prepareRequest = @($lines | Where-Object { $_ -match '\[R\]\s+SCENE_PREPARE\b' }).Count + prepareSuccess = @($lines | Where-Object { $_ -match '\[S\]\s+SUCCESS\s+SCENE_PREPARE\b' }).Count + playRequest = @($lines | Where-Object { $_ -match '\[R\]\s+PLAY\b' }).Count + playSuccess = @($lines | Where-Object { $_ -match '\[S\]\s+SUCCESS\s+PLAY\b' }).Count + scenePlayed = @($lines | Where-Object { $_ -match '\[A\]\s+SCENE_PLAYED\b' }).Count + unloadRequest = @($lines | Where-Object { $_ -match '\[R\]\s+UNLOAD_SCENE\b' }).Count + unloadSuccess = @($lines | Where-Object { $_ -match '\[S\]\s+SUCCESS\s+UNLOAD_SCENE\b' }).Count + failure = @($lines | Where-Object { $_ -match '\bFAILURE\b' }).Count + error = @($lines | Where-Object { $_ -match '\bERROR\b' }).Count + } +} + +function Read-StableNetworkText { + param([Parameter(Mandatory = $true)] [IntPtr] $NetworkWindow) + $lastError = $null + for ($attempt = 1; $attempt -le 8; $attempt += 1) { + try { return [CodexLegacyParityNative20260722]::ReadStableNetworkText($NetworkWindow) } + catch { + $lastError = $_ + Start-Sleep -Milliseconds 125 + } + } + throw $lastError +} + +function Capture-WindowImage { + param( + [Parameter(Mandatory = $true)] [IntPtr] $Window, + [Parameter(Mandatory = $true)] [string] $Path + ) + if ([IO.File]::Exists($Path)) { throw "Evidence image already exists: $Path" } + $rect = New-Object CodexLegacyParityNative20260722+Rect + if (-not [CodexLegacyParityNative20260722]::GetWindowRect($Window, [ref]$rect)) { + throw 'Window rectangle could not be read.' + } + $width = $rect.Right - $rect.Left + $height = $rect.Bottom - $rect.Top + if ($width -lt 100 -or $height -lt 100 -or $width -gt 8000 -or $height -gt 5000) { + throw "Window rectangle is unsafe: ${width}x${height}." + } + # CopyFromScreen is required for the DirectX PGM surface, but it otherwise + # records an unrelated foreground window when the target is occluded. Raise + # only this exact pinned window without activating it, allow one repaint, + # capture, and restore its original top-most state in a finally block. + $wasTopMost = [CodexLegacyParityNative20260722]::IsTopMost($Window) + if (-not [CodexLegacyParityNative20260722]::SetTopMostForCapture($Window, $true)) { + throw 'The exact evidence window could not be raised for an unobscured capture.' + } + Start-Sleep -Milliseconds 250 + $bitmap = $null + $graphics = $null + try { + $bitmap = [Drawing.Bitmap]::new($width, $height) + $graphics = [Drawing.Graphics]::FromImage($bitmap) + $graphics.CopyFromScreen($rect.Left, $rect.Top, 0, 0, $bitmap.Size) + $bitmap.Save($Path, [Drawing.Imaging.ImageFormat]::Png) + } finally { + if ($null -ne $graphics) { $graphics.Dispose() } + if ($null -ne $bitmap) { $bitmap.Dispose() } + if (-not $wasTopMost -and + -not [CodexLegacyParityNative20260722]::SetTopMostForCapture($Window, $false)) { + throw 'The evidence window top-most state could not be restored.' + } + } +} + +function Get-NetworkDelta { + param( + [Parameter(Mandatory = $true)] [string] $Current, + [string] $PreviousPath = '' + ) + if ([string]::IsNullOrWhiteSpace($PreviousPath)) { + return [ordered]@{ anchorMatched = $null; anchorLength = 0; delta = $Current } + } + $previousFullPath = [IO.Path]::GetFullPath($PreviousPath) + if (-not [IO.File]::Exists($previousFullPath)) { throw "Previous Network evidence is missing: $previousFullPath" } + $previous = [IO.File]::ReadAllText($previousFullPath, [Text.Encoding]::UTF8) + if ($previous.Length -eq 0) { + return [ordered]@{ anchorMatched = $true; anchorLength = 0; delta = $Current } + } + $anchorLength = [Math]::Min(4096, $previous.Length) + $anchor = $previous.Substring($previous.Length - $anchorLength) + $first = $Current.IndexOf($anchor, [StringComparison]::Ordinal) + $last = $Current.LastIndexOf($anchor, [StringComparison]::Ordinal) + if ($first -lt 0 -or $first -ne $last) { + throw 'The rolling Network Monitoring anchor is missing or non-unique.' + } + return [ordered]@{ + anchorMatched = $true + anchorLength = $anchorLength + delta = $Current.Substring($first + $anchorLength) + } +} + +function Capture-ReadOnlyEvidence { + param( + [Parameter(Mandatory = $true)] [object] $Pins, + [Parameter(Mandatory = $true)] [string] $OutputDirectory, + [string] $PreviousNetworkPath = '' + ) + Initialize-NativeTypes + $directory = [IO.Path]::GetFullPath($OutputDirectory) + if (-not [IO.Directory]::Exists($directory)) { throw "Evidence stage directory is missing: $directory" } + + $pgmImage = Join-Path $directory 'pgm-screen.png' + $appImage = Join-Path $directory 'app-screen.png' + $networkPath = Join-Path $directory 'network-full.txt' + $networkDeltaPath = Join-Path $directory 'network-delta.txt' + $summaryPath = Join-Path $directory 'capture.json' + + $pgmWindow = [IntPtr]::new([long]$Pins.pgm.window.handle) + $appWindow = [IntPtr]::new([long]$Pins.app.window.handle) + $networkWindow = [IntPtr]::new([long]$Pins.pgm.networkWindow.handle) + + # Capture the PGM frame first. Capture-WindowImage temporarily changes only + # the pinned window's z-order; it never focuses, restores, clicks, or sends a + # command to either application. + Capture-WindowImage -Window $pgmWindow -Path $pgmImage + Capture-WindowImage -Window $appWindow -Path $appImage + $networkText = Read-StableNetworkText -NetworkWindow $networkWindow + Write-NewText -Path $networkPath -Text $networkText + + $deltaResult = Get-NetworkDelta -Current $networkText -PreviousPath $PreviousNetworkPath + Write-NewText -Path $networkDeltaPath -Text $deltaResult.delta + $summary = [ordered]@{ + schemaVersion = 1 + capturedAtUtc = [DateTime]::UtcNow.ToString('o') + previousNetworkPath = $(if ([string]::IsNullOrWhiteSpace($PreviousNetworkPath)) { $null } else { [IO.Path]::GetFullPath($PreviousNetworkPath) }) + anchorMatched = $deltaResult.anchorMatched + anchorLength = $deltaResult.anchorLength + fullCharacters = $networkText.Length + deltaCharacters = $deltaResult.delta.Length + counts = Get-NetworkCounts -Text $deltaResult.delta + pgmImagePath = $pgmImage + appImagePath = $appImage + networkFullPath = $networkPath + networkDeltaPath = $networkDeltaPath + } + Write-NewJson -Path $summaryPath -Value $summary + return $summary +} + +function Get-WorkflowStages { + param([Parameter(Mandatory = $true)] [string] $Name) + if ($Name -ceq 'Recovery8001') { + return @([pscustomobject]@{ action = 'take-out-recovery-8001'; routeIndex = 19; pageIndex = $null }) + } + if ($Name -ceq 'Recovery5077Page2') { + return @([pscustomobject]@{ action = 'take-out-recovery-5077-page2'; routeIndex = $null; pageIndex = 2 }) + } + if ($Name -ceq 'ThirtyRoutes20To29') { + $resume = @([pscustomobject]@{ action = 'resume-30-manifest'; routeIndex = $null; pageIndex = $null }) + for ($index = 20; $index -lt 30; $index += 1) { + foreach ($actionName in @('activate-30', 'take-in-30-fast', 'wait-30', 'take-out-30')) { + $resume += [pscustomobject]@{ action = $actionName; routeIndex = $index; pageIndex = $null } + } + } + return $resume + } + $items = @([pscustomobject]@{ action = 'bootstrap'; routeIndex = $null; pageIndex = $null }) + switch ($Name) { + 'Core5001' { + foreach ($actionName in @('setup-5001-5074', 'take-in-5001', 'next-5074', 'page-next-5074', 'take-out-5074')) { + $items += [pscustomobject]@{ action = $actionName; routeIndex = $null; pageIndex = $null } + } + } + 'ThirtyRoutes' { + foreach ($actionName in @('build-30-base', 'build-30-comparison', 'build-30-financial', 'build-30-manual')) { + $items += [pscustomobject]@{ action = $actionName; routeIndex = $null; pageIndex = $null } + } + for ($index = 0; $index -lt 30; $index += 1) { + foreach ($actionName in @('activate-30', 'take-in-30-fast', 'wait-30', 'take-out-30')) { + $items += [pscustomobject]@{ action = $actionName; routeIndex = $index; pageIndex = $null } + } + } + } + 'PagedBoundary' { + $items += [pscustomobject]@{ action = 'build-paged'; routeIndex = $null; pageIndex = $null } + $items += [pscustomobject]@{ action = 'take-in-5077'; routeIndex = $null; pageIndex = $null } + for ($page = 1; $page -le 19; $page += 1) { + $items += [pscustomobject]@{ action = 'page-next-5077'; routeIndex = $null; pageIndex = $page } + } + $items += [pscustomobject]@{ action = 'next-5088'; routeIndex = $null; pageIndex = $null } + $items += [pscustomobject]@{ action = 'take-out-5088'; routeIndex = $null; pageIndex = $null } + } + 'S6001AssetIndependent' { + $items += [pscustomobject]@{ action = 'build-s6001-asset-independent'; routeIndex = $null; pageIndex = $null } + for ($index = 0; $index -lt 4; $index += 1) { + foreach ($actionName in @('activate-s6001', 'take-in-s6001', 'wait-s6001', 'take-out-s6001')) { + $items += [pscustomobject]@{ action = $actionName; routeIndex = $index; pageIndex = $null } + } + } + } + default { throw "Unknown workflow: $Name" } + } + return $items +} + +function Get-SuccessfulStageRecords { + param([Parameter(Mandatory = $true)] [string] $Directory) + $records = @() + foreach ($child in @(Get-ChildItem -LiteralPath $Directory -Directory -ErrorAction Stop | + Where-Object { $_.Name -match '^(?!0000-)\d{4}-' } | Sort-Object Name)) { + $successPath = Join-Path $child.FullName 'SUCCESS.json' + if ([IO.File]::Exists($successPath)) { + $records += (Get-Content -LiteralPath $successPath -Raw -Encoding UTF8 | ConvertFrom-Json) + } + } + return $records +} + +function Assert-CompletedStageSequence { + param( + [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [object[]] $Records, + [Parameter(Mandatory = $true)] [object[]] $ExpectedStages + ) + if ($Records.Count -gt $ExpectedStages.Count) { throw 'Evidence contains more successful stages than the workflow.' } + for ($index = 0; $index -lt $Records.Count; $index += 1) { + $record = $Records[$index] + $expected = $ExpectedStages[$index] + $recordRoute = if ($null -ne $record.stage.routeIndex) { [int]$record.stage.routeIndex } else { $null } + $recordPage = if ($null -ne $record.stage.pageIndex) { [int]$record.stage.pageIndex } else { $null } + if ([int]$record.sequence -ne ($index + 1) -or + [string]$record.stage.action -cne [string]$expected.action -or + $recordRoute -ne $expected.routeIndex -or $recordPage -ne $expected.pageIndex) { + throw "Successful evidence sequence is not exact at index $index." + } + } +} + +function Get-PreviousNetworkPath { + param([Parameter(Mandatory = $true)] [string] $Directory) + $candidates = @(Get-ChildItem -LiteralPath $Directory -Recurse -File -Filter 'network-full.txt' -ErrorAction Stop | + Sort-Object FullName) + if ($candidates.Count -eq 0) { return '' } + return $candidates[-1].FullName +} + +function New-StopLatch { + param( + [Parameter(Mandatory = $true)] [string] $Directory, + [Parameter(Mandatory = $true)] [string] $Reason, + [string] $StageDirectory = '', + [object] $Details = $null + ) + $path = Join-Path $Directory 'STOPPED.json' + if ([IO.File]::Exists($path)) { return } + $record = [ordered]@{ + schemaVersion = 1 + stoppedAtUtc = [DateTime]::UtcNow.ToString('o') + reason = $Reason + stageDirectory = $(if ([string]::IsNullOrWhiteSpace($StageDirectory)) { $null } else { $StageDirectory }) + details = $Details + rule = 'No later playout command may be dispatched from this run directory.' + } + try { Write-NewJson -Path $path -Value $record } catch { if (-not [IO.File]::Exists($path)) { throw } } +} + +function ConvertTo-NodeArgumentString { + param([Parameter(Mandatory = $true)] [object[]] $Values) + $quoted = foreach ($value in $Values) { + $text = [string]$value + if ($text.Contains('"') -or $text.Contains([char]0)) { + throw 'A Node stage argument contains a forbidden character.' + } + '"' + $text + '"' + } + return ($quoted -join ' ') +} + +function Invoke-NodeStage { + param( + [Parameter(Mandatory = $true)] [object] $Manifest, + [Parameter(Mandatory = $true)] [object] $ExpectedStage, + [Parameter(Mandatory = $true)] [string] $OutputDirectory + ) + $stdoutPath = Join-Path $OutputDirectory 'node-stdout.json' + $stderrPath = Join-Path $OutputDirectory 'node-stderr.json' + $nodePath = [string]$Manifest.pins.tools.node.path + $nodeScriptPath = [string]$Manifest.pins.tools.nodeStage.path + $arguments = @($nodeScriptPath, '--port', [string]$Manifest.debugPort, '--action', [string]$ExpectedStage.action) + if ($null -ne $ExpectedStage.routeIndex) { $arguments += @('--route-index', [string]$ExpectedStage.routeIndex) } + if ($null -ne $ExpectedStage.pageIndex) { $arguments += @('--page-index', [string]$ExpectedStage.pageIndex) } + + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = $nodePath + $start.WorkingDirectory = $PSScriptRoot + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.Arguments = ConvertTo-NodeArgumentString -Values $arguments + + $process = [Diagnostics.Process]::new() + $process.StartInfo = $start + try { + if (-not $process.Start()) { throw 'The Node stage process did not start.' } + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + $exited = $process.WaitForExit($StageTimeoutSeconds * 1000) + if (-not $exited) { + $terminated = $false + try { + $process.Kill() + $terminated = $process.WaitForExit(5000) + } catch { } + if ($terminated) { + $process.WaitForExit() + Write-NewText -Path $stdoutPath -Text $stdoutTask.GetAwaiter().GetResult() + Write-NewText -Path $stderrPath -Text $stderrTask.GetAwaiter().GetResult() + } + return [ordered]@{ + ok = $false + timedOut = $true + exitCode = $null + parsed = $null + error = "Node stage exceeded $StageTimeoutSeconds seconds. Outcome is unknown." + stdoutPath = $stdoutPath + stderrPath = $stderrPath + } + } + + $process.WaitForExit() + $exitCode = [int]$process.ExitCode + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + Write-NewText -Path $stdoutPath -Text $stdout + Write-NewText -Path $stderrPath -Text $stderr + $stdout = $stdout.Trim() + $stderr = $stderr.Trim() + $parsed = $null + $parseError = $null + try { + $candidate = if (-not [string]::IsNullOrWhiteSpace($stdout)) { $stdout } else { $stderr } + if ([string]::IsNullOrWhiteSpace($candidate)) { throw 'Node stage returned no JSON.' } + $parsed = $candidate | ConvertFrom-Json + } catch { $parseError = $_.Exception.Message } + $ok = $exitCode -eq 0 -and $null -ne $parsed -and $parsed.ok -eq $true + return [ordered]@{ + ok = $ok + timedOut = $false + exitCode = $exitCode + parsed = $parsed + error = $parseError + stdoutPath = $stdoutPath + stderrPath = $stderrPath + } + } finally { + $process.Dispose() + } +} + +function Invoke-StaticAudit { + $nodePath = Get-ResolvedNodeExecutable + if (-not [IO.File]::Exists($script:NodeStageScript)) { throw "Node stage script is missing: $script:NodeStageScript" } + $tokens = $null + $errors = $null + [Management.Automation.Language.Parser]::ParseFile($PSCommandPath, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -ne 0) { throw "PowerShell parser reported $($errors.Count) error(s): $($errors[0].Message)" } + + $nodeCheckOutput = @(& $nodePath --check $script:NodeStageScript 2>&1) + if ($LASTEXITCODE -ne 0) { throw "Node syntax check failed: $($nodeCheckOutput -join [Environment]::NewLine)" } + $nodeSource = [IO.File]::ReadAllText($script:NodeStageScript, [Text.Encoding]::UTF8) + foreach ($required in @('OUTCOME_UNKNOWN', 'TARGET_IDENTITY_MISMATCH', 'take-in-30-fast', 'page-next-5077', 'take-out-recovery-5077-page2', 'build-s6001-asset-independent', 'take-in-s6001')) { + if (-not $nodeSource.Contains($required)) { throw "Node stage script is missing required safety token: $required" } + } + + $sourceRoot = [IO.Path]::GetFullPath($OriginalCutsDirectory) + $cuts = @() + foreach ($code in $script:RouteCodes) { + $path = Join-Path $sourceRoot "$code.t2s" + $cuts += Get-FileIdentity -Path $path + } + $assets = @() + foreach ($relativePath in $script:S6001AssetRelativePaths) { + $assets += Get-FileIdentity -Path (Join-Path $sourceRoot $relativePath) + } + $workflowCounts = [ordered]@{} + foreach ($name in @('Core5001', 'ThirtyRoutes', 'PagedBoundary', 'S6001AssetIndependent', 'Recovery8001', 'Recovery5077Page2', 'ThirtyRoutes20To29')) { + $workflowCounts[$name] = @(Get-WorkflowStages -Name $name).Count + } + return [ordered]@{ + ok = $true + audit = 'static-only' + auditedAtUtc = [DateTime]::UtcNow.ToString('o') + wrapper = Get-FileIdentity -Path $PSCommandPath + node = Get-FileIdentity -Path $nodePath + nodeStage = Get-FileIdentity -Path $script:NodeStageScript + authoritativeCutCount = $cuts.Count + # OrderedDictionary keys are not projected as ordinary properties by + # Select-Object in Windows PowerShell 5.1. Preserve the validated + # identity dictionaries directly so the evidence cannot silently emit + # null paths or hashes. + authoritativeCutHashes = @($cuts) + authoritativeAssetCount = $assets.Count + authoritativeAssetHashes = @($assets) + workflowStageCounts = $workflowCounts + runtimeProcessesQueried = $false + cdpAttached = $false + nativeMessagesSent = 0 + } +} + +function Resolve-EvidenceDirectory { + if ([string]::IsNullOrWhiteSpace($EvidenceDirectory)) { + throw "-EvidenceDirectory is required for action '$Action'." + } + return [IO.Path]::GetFullPath($EvidenceDirectory) +} + +function Invoke-InitializeRun { + $directory = Resolve-EvidenceDirectory + if ([IO.Directory]::Exists($directory) -or [IO.File]::Exists($directory)) { + throw "Initialization never overwrites an existing evidence path: $directory" + } + $pins = Get-CurrentPins + [IO.Directory]::CreateDirectory($directory) | Out-Null + $baselineDirectory = Join-Path $directory '0000-baseline' + [IO.Directory]::CreateDirectory($baselineDirectory) | Out-Null + $manifest = [ordered]@{ + schemaVersion = 1 + runId = [IO.Path]::GetFileName($directory) + initializedAtUtc = [DateTime]::UtcNow.ToString('o') + workflow = $Workflow + debugPort = $DebugPort + targetUrl = $script:TargetUrl + discovery = [ordered]@{ + packageName = $ExpectedPackageName + pgmExecutable = [string]$pins.pgm.executable.path + appWindowTitle = [string]$pins.app.window.title + pgmWindowTitle = [string]$pins.pgm.window.title + networkWindowTitle = [string]$pins.pgm.networkWindow.title + originalCutsDirectory = [IO.Path]::GetFullPath($OriginalCutsDirectory) + nodeExecutable = [string]$pins.tools.node.path + pgmPort = [int]$pins.pgmListener.port + } + rules = @( + 'One Node invocation dispatches at most one user-visible playout command.', + 'No playout command is retried after timeout, OutcomeUnknown, or identity mismatch.', + 'STOPPED.json permanently blocks later commands in this evidence directory.', + 'Playlist rows are built in memory; no named playlist fixture is loaded.' + ) + expectedStages = Get-WorkflowStages -Name $Workflow + pins = $pins + baselineNetworkPath = Join-Path $baselineDirectory 'network-full.txt' + } + Write-NewJson -Path (Join-Path $directory 'manifest.json') -Value $manifest + try { + $capture = Capture-ReadOnlyEvidence -Pins $pins -OutputDirectory $baselineDirectory + Assert-PinsExact -Pinned $pins | Out-Null + Write-NewJson -Path (Join-Path $baselineDirectory 'SUCCESS.json') -Value ([ordered]@{ + initializedAtUtc = [DateTime]::UtcNow.ToString('o') + capture = $capture + }) + } catch { + New-StopLatch -Directory $directory -Reason 'Initialization evidence or post-capture identity validation failed.' ` + -StageDirectory $baselineDirectory -Details $_.Exception.Message + throw + } + return $manifest +} + +function Read-RunManifest { + param([Parameter(Mandatory = $true)] [string] $Directory) + $manifestPath = Join-Path $Directory 'manifest.json' + if (-not [IO.File]::Exists($manifestPath)) { throw "Run manifest is missing: $manifestPath" } + $manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json + if ($manifest.schemaVersion -ne 1 -or $manifest.targetUrl -cne $script:TargetUrl -or + $null -eq $manifest.discovery) { + throw 'Run manifest schema or target identity is unsupported.' + } + return $manifest +} + +function Use-ManifestDiscoverySettings { + param([Parameter(Mandatory = $true)] [object] $Manifest) + $script:DebugPort = [int]$Manifest.debugPort + $script:ExpectedPgmPort = [int]$Manifest.discovery.pgmPort + $script:ExpectedPackageName = [string]$Manifest.discovery.packageName + $script:ExpectedPgmExecutable = [string]$Manifest.discovery.pgmExecutable + $script:ExpectedAppWindowTitle = [string]$Manifest.discovery.appWindowTitle + $script:ExpectedPgmWindowTitle = [string]$Manifest.discovery.pgmWindowTitle + $script:ExpectedNetworkWindowTitle = [string]$Manifest.discovery.networkWindowTitle + $script:OriginalCutsDirectory = [string]$Manifest.discovery.originalCutsDirectory + $script:NodeExecutable = [string]$Manifest.discovery.nodeExecutable +} + +function Invoke-InspectRun { + $directory = Resolve-EvidenceDirectory + $manifest = Read-RunManifest -Directory $directory + Use-ManifestDiscoverySettings -Manifest $manifest + $current = Assert-PinsExact -Pinned $manifest.pins + $completed = @(Get-SuccessfulStageRecords -Directory $directory) + $expected = @(Get-WorkflowStages -Name ([string]$manifest.workflow)) + Assert-CompletedStageSequence -Records $completed -ExpectedStages $expected + return [ordered]@{ + ok = $true + action = 'Inspect' + stopped = [IO.File]::Exists((Join-Path $directory 'STOPPED.json')) + completedStageCount = $completed.Count + totalStageCount = $expected.Count + nextStage = $(if ($completed.Count -lt $expected.Count) { $expected[$completed.Count] } else { $null }) + pins = $current + inspectedAtUtc = [DateTime]::UtcNow.ToString('o') + nativeMessagesSent = 0 + } +} + +function Invoke-RunStage { + param([bool] $UseExpectedStage = $false) + $directory = Resolve-EvidenceDirectory + $stopPath = Join-Path $directory 'STOPPED.json' + if ([IO.File]::Exists($stopPath)) { + throw "This evidence run is permanently stopped. Inspect: $stopPath" + } + $manifest = Read-RunManifest -Directory $directory + Use-ManifestDiscoverySettings -Manifest $manifest + if (-not $UseExpectedStage -and [string]::IsNullOrWhiteSpace($Stage)) { throw '-Stage is required for RunStage.' } + + $lockPath = Join-Path $directory 'RUNNING.lock' + $lockStream = $null + try { + $lockStream = [IO.File]::Open($lockPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + } catch { + throw "Another stage is running, or an interrupted lock requires inspection: $lockPath" + } + + try { + $completed = @(Get-SuccessfulStageRecords -Directory $directory) + $expectedStages = @(Get-WorkflowStages -Name ([string]$manifest.workflow)) + Assert-CompletedStageSequence -Records $completed -ExpectedStages $expectedStages + if ($completed.Count -ge $expectedStages.Count) { throw 'All workflow stages are already complete.' } + $expected = $expectedStages[$completed.Count] + if (-not $UseExpectedStage) { + $requestedRoute = if ($RouteIndex -ge 0) { $RouteIndex } else { $null } + $requestedPage = if ($PageIndex -ge 0) { $PageIndex } else { $null } + if ($Stage -cne $expected.action -or $requestedRoute -ne $expected.routeIndex -or $requestedPage -ne $expected.pageIndex) { + throw "Out-of-order stage request. Expected action=$($expected.action), routeIndex=$($expected.routeIndex), pageIndex=$($expected.pageIndex)." + } + } + + $sequence = $completed.Count + 1 + $safeStage = $expected.action -replace '[^a-z0-9-]', '-' + $stageDirectory = Join-Path $directory ('{0:D4}-{1}-{2}' -f $sequence, $safeStage, [Guid]::NewGuid().ToString('N').Substring(0, 8)) + [IO.Directory]::CreateDirectory($stageDirectory) | Out-Null + Write-NewJson -Path (Join-Path $stageDirectory 'REQUEST.json') -Value ([ordered]@{ + sequence = $sequence + requestedAtUtc = [DateTime]::UtcNow.ToString('o') + stage = $expected + }) + + try { + $beforePins = Assert-PinsExact -Pinned $manifest.pins + Write-NewJson -Path (Join-Path $stageDirectory 'pins-before.json') -Value $beforePins + } catch { + New-StopLatch -Directory $directory -Reason 'Pinned runtime identity changed before stage dispatch.' ` + -StageDirectory $stageDirectory -Details $_.Exception.Message + throw + } + + $nodeResult = Invoke-NodeStage -Manifest $manifest -ExpectedStage $expected -OutputDirectory $stageDirectory + Write-NewJson -Path (Join-Path $stageDirectory 'node-result.json') -Value $nodeResult + + $postIdentityError = $null + try { + $afterPins = Assert-PinsExact -Pinned $manifest.pins + Write-NewJson -Path (Join-Path $stageDirectory 'pins-after.json') -Value $afterPins + } catch { $postIdentityError = $_.Exception.Message } + + $capture = $null + $captureError = $null + if ([string]::IsNullOrWhiteSpace($postIdentityError)) { + try { + $previousNetworkPath = Get-PreviousNetworkPath -Directory $directory + # Exclude the current stage file if an earlier partial capture somehow exists. + if ($previousNetworkPath -like "$stageDirectory*") { + $previousCandidates = @(Get-ChildItem -LiteralPath $directory -Recurse -File -Filter 'network-full.txt' | + Where-Object { $_.FullName -notlike "$stageDirectory*" } | Sort-Object FullName) + $previousNetworkPath = if ($previousCandidates.Count -gt 0) { $previousCandidates[-1].FullName } else { '' } + } + $capture = Capture-ReadOnlyEvidence -Pins $manifest.pins -OutputDirectory $stageDirectory ` + -PreviousNetworkPath $previousNetworkPath + $afterCapturePins = Assert-PinsExact -Pinned $manifest.pins + Write-NewJson -Path (Join-Path $stageDirectory 'pins-after-capture.json') -Value $afterCapturePins + } catch { $captureError = $_.Exception.Message } + } else { + $captureError = 'Capture was skipped because the pinned runtime identity had already changed.' + } + + $failureReasons = @() + if (-not $nodeResult.ok) { $failureReasons += 'Node one-shot stage failed, timed out, or returned invalid JSON.' } + if (-not [string]::IsNullOrWhiteSpace($postIdentityError)) { $failureReasons += 'Pinned runtime identity changed after stage execution.' } + if (-not [string]::IsNullOrWhiteSpace($captureError)) { $failureReasons += 'Read-only evidence capture or rolling Network anchor validation failed.' } + if ($failureReasons.Count -gt 0) { + $failure = [ordered]@{ + failedAtUtc = [DateTime]::UtcNow.ToString('o') + reasons = $failureReasons + node = $nodeResult + postIdentityError = $postIdentityError + captureError = $captureError + capture = $capture + } + Write-NewJson -Path (Join-Path $stageDirectory 'FAILURE.json') -Value $failure + New-StopLatch -Directory $directory -Reason ($failureReasons -join ' ') ` + -StageDirectory $stageDirectory -Details $failure + throw "Stage failed and the run is now stopped: $($failureReasons -join ' ')" + } + + $success = [ordered]@{ + schemaVersion = 1 + sequence = $sequence + completedAtUtc = [DateTime]::UtcNow.ToString('o') + stage = $expected + node = $nodeResult.parsed + capture = $capture + } + Write-NewJson -Path (Join-Path $stageDirectory 'SUCCESS.json') -Value $success + return $success + } finally { + if ($null -ne $lockStream) { $lockStream.Dispose() } + if ([IO.File]::Exists($lockPath)) { [IO.File]::Delete($lockPath) } + } +} + +switch ($Action) { + 'StaticAudit' { + Invoke-StaticAudit | ConvertTo-Json -Depth 40 -Compress + } + 'DryPreflight' { + $pins = Get-CurrentPins + [ordered]@{ + ok = $true + action = 'DryPreflight' + inspectedAtUtc = [DateTime]::UtcNow.ToString('o') + pins = $pins + evidenceWritten = $false + cdpAttached = $false + nativeMessagesSent = 0 + } | ConvertTo-Json -Depth 40 -Compress + } + 'Initialize' { + Invoke-InitializeRun | ConvertTo-Json -Depth 40 -Compress + } + 'Inspect' { + Invoke-InspectRun | ConvertTo-Json -Depth 40 -Compress + } + 'RunStage' { + Invoke-RunStage -UseExpectedStage $false | ConvertTo-Json -Depth 40 -Compress + } + 'RunNext' { + Invoke-RunStage -UseExpectedStage $true | ConvertTo-Json -Depth 40 -Compress + } + default { throw "Unsupported action: $Action" } +} diff --git a/scripts/LegacyPgmParityStage.mjs b/scripts/LegacyPgmParityStage.mjs new file mode 100644 index 0000000..891285d --- /dev/null +++ b/scripts/LegacyPgmParityStage.mjs @@ -0,0 +1,1408 @@ +#!/usr/bin/env node + +/** + * One-shot CDP driver for the development-live legacy parity surface. + * + * The companion PowerShell wrapper pins the package/app/PGM identities and calls + * this script once per user-visible action. This driver deliberately has no + * retry loop for playout commands. A timeout, OutcomeUnknown state, unexpected + * native message, or probe replacement makes the invocation fail so that the + * wrapper can latch the run in a stopped state. + */ + +const PROBE_KEY = '__codexLegacyPgmParityProbe'; +const PROBE_VERSION = '2026-07-22.2'; +const DEFAULT_PORT = 9339; +const TARGET_URL = 'https://legacy-parity.mbn.local/index.html'; +const RPC_TIMEOUT_MS = 15_000; +const STATE_TIMEOUT_MS = 180_000; +const BUILD_TIMEOUT_MS = 45_000; + +const THIRTY_CODES = [ + '5016', '50160', '5078', '8067', '5086', '50860', '5023', '5024', '5077', + '5082', '5083', '5084', '5085', '5088', '6067', '8035', '5011', '8003', + '5037', '8001', '5026', '5087', '5029', '8032', '5032', '5076', '5079', + '5080', '5081', '5025', +]; + +const THIRTY_MANIFEST = [ + ['5016', '\ubbf8\uad6d \uc9c0\uc218[\ub2e4\uc6b0, \ub098\uc2a4\ub2e5, S&P]', '3\uc5f4\ud310'], + ['50160', '\ub18d\uc0b0\ubb3c[\uc6d0\uba74, \ubaa9\ud654(\uba74\ud654)]', '2\uc5f4\ud310-'], + ['5078', '\ub2e4\uc6b0', '\ubbf8\uad6d\uc5c5\uc885'], + ['8067', '\uae00\ub85c\ubc8c \uc99d\uc2dc \uc9c0\ub3c4', '\uae00\ub85c\ubc8c \uc99d\uc2dc \uc9c0\ub3c4'], + ['5086', '\uc6d0\ub2ec\ub7ec_5\uc77c', '\uc218\uc775\ub960 \uadf8\ub798\ud504'], + ['50860', '\uc6d0\ub2ec\ub7ec_5\uc77c', '\ub77c\uc778 \uadf8\ub798\ud504'], + ['5023', '\ucf54\uc2a4\ud53c \ub9e4\ub9e4\ub3d9\ud5a5_\ud45c\uadf8\ub798\ud504', '\ub9e4\ub9e4\ub3d9\ud5a5'], + ['5024', '\ucf54\uc2a4\ud53c \ub9e4\ub9e4\ub3d9\ud5a5_\ub9c9\ub300\uadf8\ub798\ud504', '\ub9e4\ub9e4\ub3d9\ud5a5'], + ['5077', '\ucf54\uc2a4\ud53c \uc2dc\uac00\ucd1d\uc561', '6\uc885\ubaa9 \ud604\uc7ac\uac00'], + ['5082', '\uc8fc\uccb4\ubcc4 \ub9e4\ub9e4\ub3d9\ud5a5_\ud45c\uadf8\ub798\ud504', '\ub9e4\ub9e4\ub3d9\ud5a5'], + ['5083', '\uac1c\uc778 \ub9e4\ub9e4\ub3d9\ud5a5_\ub77c\uc778\uadf8\ub798\ud504', '\ub9e4\ub9e4\ub3d9\ud5a5'], + ['5084', '\ucf54\uc2a4\ud53c \ub9e4\ub9e4\ub3d9\ud5a5_\ub77c\uc778\uadf8\ub798\ud504', '\ub9e4\ub9e4\ub3d9\ud5a5'], + ['5085', '\ud504\ub85c\uadf8\ub7a8 \ub9e4\ub9e4_\ud45c\uadf8\ub798\ud504', '\ub9e4\ub9e4\ub3d9\ud5a5'], + ['5088', '\ucf54\uc2a4\ud53c \uc2dc\uac00\ucd1d\uc561', '12\uc885\ubaa9 \ud604\uc7ac\uac00'], + ['6067', '\uae30\uad00 \uc21c\ub9e4\uc218 \ud604\ud669_\ud45c\uadf8\ub798\ud504', '\ub9e4\ub9e4\ub3d9\ud5a5'], + ['8035', '\uce94\ub4e4\uadf8\ub798\ud504_\uc77c\ubd09', '\ucf54\uc2a4\ud53c'], + ['5011', '\uc0bc\uc131\uc804\uc790', '1\uc5f4\ud310\uc0c1\uc138'], + ['8003', '\uc0bc\uc131\uc804\uc790', '\ud638\uac00\ucc3d'], + ['5037', '\uc0bc\uc131\uc804\uc790', '\uac70\ub798\uc6d0'], + ['8001', '\ucf54\uc2a4\ud53c \ub124\ubaa8\uadf8\ub798\ud504', '\ub124\ubaa8\uadf8\ub798\ud504'], + ['5026', '\uc0bc\uc131\uc99d\uad8c \u2194 \uc0bc\uc131\ucd9c\ud310\uc0ac', 's5026'], + ['5087', '\uc0bc\uc131\uc99d\uad8c \u2194 \uc0bc\uc131\ucd9c\ud310\uc0ac', 's5087'], + ['5029', '\uc0bc\uc131\uc99d\uad8c \u2194 \uc0bc\uc131\ucd9c\ud310\uc0ac', 's5029'], + ['8032', '\ucf54\uc2a4\ub2e5 \uc9c0\uc218 \u2194 \ucf54\uc2a4\ud53c \uc9c0\uc218', 's8018'], + ['5032', '\uc120\ubb3c \uc9c0\uc218 \u2194 \ucf54\uc2a4\ud53c \uc9c0\uc218', 's5032'], + ['5076', 'APS', '\uc8fc\uc694\ub9e4\ucd9c \uad6c\uc131'], + ['5079', 'AJ\ub124\ud2b8\uc6cd\uc2a4', '\uc131\uc7a5\uc131 \uc9c0\ud45c'], + ['5080', '3S', '\ub9e4\ucd9c\uc561'], + ['5081', '3S', '\uc601\uc5c5\uc774\uc775'], + ['5025', '\uac1c\uc778 \uc21c\ub9e4\ub3c4 \uc0c1\uc704(\uc218\ub3d9)', '\uc21c\ub9e4\ub3c4 \uc0c1\uc704'], +].map(([code, stockName, graphicType]) => ({ code, stockName, graphicType })); + +const ROUTE_ROW_IDENTITIES = new Map(THIRTY_MANIFEST.map(item => [item.code, item])); +const FIXED_VISIBLE_ROW_IDENTITIES = new Map([ + ['5016', { market: '\ud574\uc678\uc9c0\uc218', stockName: '\ubbf8\uad6d \uc9c0\uc218[\ub2e4\uc6b0, \ub098\uc2a4\ub2e5, S&P]', graphicType: '3\uc5f4\ud310', subtype: '' }], + ['50160', { market: '\ud574\uc678\uc9c0\uc218', stockName: '\ub18d\uc0b0\ubb3c[\uc6d0\uba74, \ubaa9\ud654(\uba74\ud654)]', graphicType: '2\uc5f4\ud310-', subtype: '' }], + ['5078', { market: '\ud574\uc678\uc9c0\uc218', stockName: '\ub2e4\uc6b0', graphicType: '\ubbf8\uad6d\uc5c5\uc885', subtype: '' }], + ['8067', { market: '\ud574\uc678\uc9c0\uc218', stockName: '\uae00\ub85c\ubc8c \uc99d\uc2dc \uc9c0\ub3c4', graphicType: '\uae00\ub85c\ubc8c \uc99d\uc2dc \uc9c0\ub3c4', subtype: '' }], + ['5086', { market: '\ud658\uc728', stockName: '\uc6d0\ub2ec\ub7ec', graphicType: '\uc218\uc775\ub960 \uadf8\ub798\ud504', subtype: '5\uc77c' }], + ['50860', { market: '\ud658\uc728', stockName: '\uc6d0\ub2ec\ub7ec', graphicType: '\ub77c\uc778 \uadf8\ub798\ud504', subtype: '5\uc77c' }], + ['5023', { market: '\ucf54\uc2a4\ud53c', stockName: '\uc9c0\uc218', graphicType: '\ucf54\uc2a4\ud53c \ub9e4\ub9e4\ub3d9\ud5a5', subtype: '\ud45c\uadf8\ub798\ud504' }], + ['5024', { market: '\ucf54\uc2a4\ud53c', stockName: '\uc9c0\uc218', graphicType: '\ucf54\uc2a4\ud53c \ub9e4\ub9e4\ub3d9\ud5a5', subtype: '\ub9c9\ub300\uadf8\ub798\ud504' }], + ['5077', { market: '\ucf54\uc2a4\ud53c', stockName: '\uc9c0\uc218', graphicType: '6\uc885\ubaa9 \ud604\uc7ac\uac00', subtype: '\ucf54\uc2a4\ud53c \uc2dc\uac00\ucd1d\uc561' }], + ['5082', { market: '\uc804\uccb4', stockName: '\uc9c0\uc218', graphicType: '\uc8fc\uccb4\ubcc4 \ub9e4\ub9e4\ub3d9\ud5a5', subtype: '\ud45c\uadf8\ub798\ud504' }], + ['5083', { market: '\uc804\uccb4', stockName: '\uc9c0\uc218', graphicType: '\uac1c\uc778 \ub9e4\ub9e4\ub3d9\ud5a5', subtype: '\ub77c\uc778\uadf8\ub798\ud504' }], + ['5084', { market: '\ucf54\uc2a4\ud53c', stockName: '\uc9c0\uc218', graphicType: '\ucf54\uc2a4\ud53c \ub9e4\ub9e4\ub3d9\ud5a5', subtype: '\ub77c\uc778\uadf8\ub798\ud504' }], + ['5085', { market: '\uc804\uccb4', stockName: '\uc9c0\uc218', graphicType: '\ud504\ub85c\uadf8\ub7a8 \ub9e4\ub9e4', subtype: '\ud45c\uadf8\ub798\ud504' }], + ['5088', { market: '\ucf54\uc2a4\ud53c', stockName: '\uc9c0\uc218', graphicType: '12\uc885\ubaa9 \ud604\uc7ac\uac00', subtype: '\ucf54\uc2a4\ud53c \uc2dc\uac00\ucd1d\uc561' }], + ['6067', { market: '\uc804\uccb4', stockName: '\uc9c0\uc218', graphicType: '\uae30\uad00 \uc21c\ub9e4\uc218 \ud604\ud669', subtype: '\ud45c\uadf8\ub798\ud504' }], + ['8035', { market: '\ucf54\uc2a4\ud53c', stockName: '\uc9c0\uc218', graphicType: '\uce94\ub4e4\uadf8\ub798\ud504', subtype: '\uc77c\ubd09' }], +]); +for (const [code, identity] of FIXED_VISIBLE_ROW_IDENTITIES) { + ROUTE_ROW_IDENTITIES.set(code, { code, ...identity }); +} +ROUTE_ROW_IDENTITIES.set('5001', { + code: '5001', stockName: '\uc0bc\uc131\uc804\uc790', graphicType: '1\uc5f4\ud310\uae30\ubcf8', subtype: '\ud604\uc7ac\uac00', +}); +ROUTE_ROW_IDENTITIES.set('5074', { + code: '5074', stockName: '\ucf54\uc2a4\ud53c 5\ub2e8 \ud45c\uadf8\ub798\ud504', graphicType: '5\ub2e8 \ud45c\uadf8\ub798\ud504', +}); + +const FIXED_ACTIONS = [ + ['overseas', 'fixed-047'], ['overseas', 'fixed-044'], + ['overseas', 'fixed-076'], ['overseas', 'fixed-063'], + ['exchange', 'fixed-085'], ['exchange', 'fixed-105'], + ['index', 'fixed-234'], ['index', 'fixed-236'], ['index', 'fixed-275'], + ['index', 'fixed-233'], ['index', 'fixed-240'], ['index', 'fixed-235'], + ['index', 'fixed-244'], ['index', 'fixed-302'], ['index', 'fixed-243'], + ['index', 'fixed-136'], +]; + +const FINANCIAL_ACTIONS = [ + ['revenue-composition', 'APS'], + ['growth-metrics', 'AJ\ub124\ud2b8\uc6cd\uc2a4'], + ['sales', '3S'], + ['operating-profit', '3S'], +]; + +const S6001_ASSET_INDEPENDENT_ACTIONS = [ + { actionId: 'fixed-072', stockName: '\uc720\uac00 \ub450\ubc14\uc774 \uc9c0\uc218', subtype: 'DubaiCrude' }, + { actionId: 'fixed-073', stockName: '\uc720\uac00 WTI \uc9c0\uc218', subtype: 'WtiCrude' }, + { actionId: 'fixed-074', stockName: '\uc720\uac00 \ube0c\ub80c\ud2b8\uc720 \uc9c0\uc218', subtype: 'BrentCrude' }, + { actionId: 'fixed-075', stockName: '\uae08 \uc9c0\uc218', subtype: 'Gold' }, +]; + +const ROW_ID_PATTERN = /^legacy-(?:fixed|stock|industry|comparison|manual-financial|manual-list)-\d{8}$/; + +function fail(message, code = 'STAGE_FAILED', details = undefined) { + const error = new Error(message); + error.code = code; + error.details = details; + throw error; +} + +function parseArguments(argv) { + const result = { port: DEFAULT_PORT, action: 'snapshot', routeIndex: null, pageIndex: null }; + for (let index = 2; index < argv.length; index += 1) { + const token = argv[index]; + const value = argv[index + 1]; + if (token === '--port' && value) { + result.port = Number(value); + index += 1; + } else if (token === '--action' && value) { + result.action = value; + index += 1; + } else if (token === '--route-index' && value) { + result.routeIndex = Number(value); + index += 1; + } else if (token === '--page-index' && value) { + result.pageIndex = Number(value); + index += 1; + } else { + fail(`Unknown or incomplete argument: ${token}`, 'INVALID_ARGUMENT'); + } + } + if (!Number.isInteger(result.port) || result.port <= 0 || result.port > 65535) { + fail(`Invalid CDP port: ${result.port}`, 'INVALID_ARGUMENT'); + } + return result; +} + +async function fetchJson(url, timeoutMs = RPC_TIMEOUT_MS) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) { + fail(`HTTP ${response.status} from ${url}`, 'CDP_DISCOVERY_FAILED'); + } + return await response.json(); + } finally { + clearTimeout(timer); + } +} + +async function openTarget(port) { + const targets = await fetchJson(`http://127.0.0.1:${port}/json/list`); + const pages = targets.filter((target) => target.type === 'page' && target.url === TARGET_URL); + if (pages.length !== 1) { + fail(`Expected exactly one parity page at ${TARGET_URL}; found ${pages.length}.`, 'TARGET_IDENTITY_MISMATCH', { + targets: targets.map(({ id, type, url, title }) => ({ id, type, url, title })), + }); + } + if (!pages[0].webSocketDebuggerUrl) { + fail('Parity page has no WebSocket debugger URL.', 'TARGET_IDENTITY_MISMATCH'); + } + const endpoint = new URL(pages[0].webSocketDebuggerUrl); + if (endpoint.protocol !== 'ws:' || endpoint.hostname !== '127.0.0.1' || + Number(endpoint.port) !== port || endpoint.pathname !== `/devtools/page/${pages[0].id}` || + endpoint.username || endpoint.password || endpoint.search || endpoint.hash) { + fail('Parity page WebSocket endpoint identity is not exact.', 'TARGET_IDENTITY_MISMATCH', { + id: pages[0].id, + webSocketDebuggerUrl: pages[0].webSocketDebuggerUrl, + }); + } + return pages[0]; +} + +class CdpClient { + constructor(url) { + this.url = url; + this.socket = null; + this.sequence = 0; + this.pending = new Map(); + } + + async connect() { + this.socket = new WebSocket(this.url); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('CDP WebSocket connection timed out.')), RPC_TIMEOUT_MS); + this.socket.addEventListener('open', () => { + clearTimeout(timer); + resolve(); + }, { once: true }); + this.socket.addEventListener('error', () => { + clearTimeout(timer); + reject(new Error('CDP WebSocket connection failed.')); + }, { once: true }); + }); + this.socket.addEventListener('message', (event) => { + const message = JSON.parse(event.data); + if (!message.id) return; + const pending = this.pending.get(message.id); + if (!pending) return; + this.pending.delete(message.id); + clearTimeout(pending.timer); + if (message.error) pending.reject(new Error(message.error.message || 'CDP RPC failed.')); + else pending.resolve(message.result); + }); + this.socket.addEventListener('close', () => { + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error('CDP WebSocket closed while an RPC was pending.')); + } + this.pending.clear(); + }); + } + + send(method, params = {}, timeoutMs = RPC_TIMEOUT_MS) { + const id = ++this.sequence; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + const error = new Error(`CDP RPC timed out: ${method}`); + error.code = 'OUTCOME_UNKNOWN'; + reject(error); + }, timeoutMs); + this.pending.set(id, { resolve, reject, timer }); + this.socket.send(JSON.stringify({ id, method, params })); + }); + } + + async evaluate(expression, awaitPromise = true, timeoutMs = RPC_TIMEOUT_MS) { + const result = await this.send('Runtime.evaluate', { + expression, + awaitPromise, + returnByValue: true, + userGesture: true, + }, timeoutMs); + if (result.exceptionDetails) { + const description = result.exceptionDetails.exception?.description + || result.exceptionDetails.text + || 'Runtime.evaluate failed.'; + throw new Error(description); + } + return result.result?.value; + } + + close() { + if (this.socket && this.socket.readyState <= WebSocket.OPEN) this.socket.close(); + } +} + +const probeInstallerSource = String.raw` +(() => { + const key = ${JSON.stringify(PROBE_KEY)}; + const version = ${JSON.stringify(PROBE_VERSION)}; + const current = globalThis[key]; + if (current) { + if (current.version !== version || window.chrome.webview.postMessage !== current.wrapper) { + throw new Error('Parity probe exists but its version or wrapper identity changed.'); + } + return { installed: false, version: current.version }; + } + + if (globalThis.__legacyDevelopmentLiveProbe) { + throw new Error('A different development-live probe is already installed; start a fresh app instance.'); + } + + if (!window.chrome?.webview) throw new Error('WebView bridge is unavailable.'); + const original = window.chrome.webview.postMessage; + const allowed = new Set([ + 'ready', 'select-tab', 'activate-fixed-action', 'activate-playlist-row', + 'search-stocks', 'select-stock', 'cut-pointer-down', 'drop-selected-cuts', + 'activate-industry-action', 'search-comparison-domestic', + 'choose-comparison-domestic', 'choose-comparison-fixed', + 'clear-comparison-targets', 'add-comparison-pair', 'select-comparison-pair', + 'delete-selected-comparison-pair', 'activate-comparison-action', + 'open-manual-financial', 'search-manual-financial', + 'activate-manual-financial-result', 'close-manual-financial', + 'add-manual-net-sell-cut', 'close-manual-list', + 'take-in', 'next-playout', 'take-out' + ]); + const playout = new Set(['take-in', 'next-playout', 'take-out']); + const fixedIds = new Set([ + 'fixed-047', 'fixed-044', 'fixed-076', 'fixed-063', 'fixed-085', 'fixed-105', + 'fixed-234', 'fixed-236', 'fixed-275', 'fixed-233', 'fixed-240', 'fixed-235', + 'fixed-244', 'fixed-302', 'fixed-243', 'fixed-136', 'fixed-245' + ]); + const tabs = new Set(['overseas', 'exchange', 'index', 'kospiIndustry', 'comparison']); + const comparisonActions = new Set(['comparison-candle', 'comparison-line', 'return-5d', 'two-column-current']); + const financialScreens = new Set(['revenue-composition', 'growth-metrics', 'sales', 'operating-profit']); + const financialQueries = new Set(['APS', 'AJ\ub124\ud2b8\uc6cd\uc2a4', '3S']); + const blocked = []; + const outbound = []; + const violations = []; + const nativeStates = []; + let state = null; + + const validToken = (value, max = 160) => typeof value === 'string' && value.length > 0 && value.length <= max && + !Array.from(value).some(character => { + const code = character.charCodeAt(0); + return code < 32 || code === 127 || code === 0xfffd; + }); + const empty = payload => payload && typeof payload === 'object' && !Array.isArray(payload) && Object.keys(payload).length === 0; + const validate = (message) => { + if (!message || typeof message !== 'object' || !validToken(message.type, 64) || !allowed.has(message.type)) return false; + const payload = message.payload; + switch (message.type) { + case 'ready': case 'drop-selected-cuts': case 'clear-comparison-targets': + case 'add-comparison-pair': case 'delete-selected-comparison-pair': + case 'close-manual-financial': case 'add-manual-net-sell-cut': + case 'close-manual-list': case 'take-in': case 'next-playout': + case 'take-out': return empty(payload); + case 'select-tab': return tabs.has(payload?.tabId); + case 'activate-fixed-action': return fixedIds.has(payload?.actionId); + case 'activate-playlist-row': return /^legacy-(?:fixed|stock|industry|comparison|manual-financial|manual-list)-[0-9]{8}$/u.test(payload?.rowId || ''); + case 'search-stocks': return payload?.text === '\uc0bc\uc131\uc804\uc790' && payload?.trigger === 'button'; + case 'select-stock': return Number.isInteger(payload?.resultIndex) && payload.resultIndex >= 0; + case 'cut-pointer-down': return [1, 5, 31, 32].includes(payload?.physicalIndex) && + payload?.control === false && payload?.shift === false && payload?.allowAppend === true && + Number.isFinite(payload?.timestampMilliseconds); + case 'activate-industry-action': return ['five-row', 'square'].includes(payload?.actionId); + case 'search-comparison-domestic': return payload?.query === '\uc0bc\uc131'; + case 'choose-comparison-domestic': return validToken(payload?.selectionId); + case 'choose-comparison-fixed': return ['kospi', 'kosdaq', 'futures'].includes(payload?.targetId); + case 'select-comparison-pair': return /^comparison-pair-[0-9]{8}$/u.test(payload?.rowId || ''); + case 'activate-comparison-action': return comparisonActions.has(payload?.actionId); + case 'open-manual-financial': return financialScreens.has(payload?.screen); + case 'search-manual-financial': return financialQueries.has(payload?.query); + case 'activate-manual-financial-result': return validToken(payload?.resultId); + default: return false; + } + }; + + const wrapper = (message) => { + const payload = message?.payload; + if (!validate(message)) { + blocked.push({ at: new Date().toISOString(), reason: 'not-allowlisted', type: message?.type || null, payload }); + throw new Error('Blocked unexpected native message.'); + } + outbound.push({ at: new Date().toISOString(), type: message.type, payload: structuredClone(payload) }); + if (outbound.length > 1200) outbound.shift(); + return original.call(window.chrome.webview, message); + }; + + window.chrome.webview.postMessage = wrapper; + window.chrome.webview.addEventListener('message', (event) => { + const message = event.data; + if (!message || message.type !== 'state' || !message.payload) return; + state = structuredClone(message.payload); + nativeStates.push({ at: new Date().toISOString(), state }); + if (nativeStates.length > 500) nativeStates.shift(); + if (!state.playout || state.playout.outcomeUnknown === true) { + violations.push({ at: new Date().toISOString(), revision: state.revision ?? null, + reason: !state.playout ? 'missing-playout' : 'outcome-unknown' }); + } + }); + + globalThis[key] = { + version, original, wrapper, allowed, playout, blocked, outbound, + violations, nativeStates, get state() { return state; } + }; + return { installed: true, version }; +})() +`; + +function js(value) { + return JSON.stringify(value); +} + +function normalizeValue(value) { + return value == null ? null : String(value); +} + +async function installOrVerifyProbe(client) { + return await client.evaluate(probeInstallerSource); +} + +async function snapshot(client) { + return await client.evaluate(`(() => { + const key = ${js(PROBE_KEY)}; + const probe = globalThis[key]; + const app = document.querySelector('main.operator-shell'); + const native = probe?.state || null; + const playlist = native?.playlist || []; + const activeNative = playlist.find(row => row.isActive) || null; + const playout = native?.playout || null; + const settled = Boolean(playout) && native?.isBusy !== true && document.body.getAttribute('aria-busy') !== 'true' && + playout.isBusy !== true && playout.isPlayCompletionPending !== true && + playout.isTakeOutCompletionPending !== true && playout.refreshActive !== true; + const normalize = (value) => (value == null ? null : String(value)); + return { + href: location.href, + readyState: document.readyState, + appPresent: Boolean(app), + bodyBusy: document.body.getAttribute('aria-busy'), + probe: probe ? { + version: probe.version, + wrapperExact: window.chrome.webview.postMessage === probe.wrapper, + blockedCount: probe.blocked.length, + violationCount: probe.violations.length, + outboundCount: probe.outbound.length, + lastOutbound: probe.outbound.slice(-8), + violations: probe.violations.slice(-8) + } : null, + buttons: Object.fromEntries(['playout-take-in', 'playout-next', 'playout-take-out'] + .map(id => [id, { + disabled: document.getElementById(id)?.disabled === true, + hidden: document.getElementById(id)?.hidden === true + }])), + state: native ? { + revision: native.revision ?? null, + isBusy: native.isBusy === true, + status: normalize(native.statusMessage), + statusKind: normalize(native.statusKind), + dialog: native.dialog ? structuredClone(native.dialog) : null, + commandResult: native.commandResult ? structuredClone(native.commandResult) : null, + mode: normalize(playout?.mode), + connectionState: normalize(playout?.connectionState), + connected: playout?.connectionState === 'connected', + isConnected: playout?.isConnected === true, + isCommandAvailable: playout?.isCommandAvailable === true, + phase: normalize(playout?.phase), + settled, + pendingCommand: playout?.isPlayCompletionPending === true ? 'play-completion' + : playout?.isTakeOutCompletionPending === true ? 'take-out-completion' + : playout?.refreshActive === true ? 'refresh' : null, + outcomeUnknown: playout?.outcomeUnknown === true, + liveTakeInAllowed: playout?.liveTakeInAllowed === true, + currentCode: normalize(playout?.onAirCode), + preparedCode: normalize(playout?.preparedCode), + currentCueIndex: Number.isInteger(playout?.currentCueIndexZeroBased) ? playout.currentCueIndexZeroBased : -1, + currentEntryId: normalize(playout?.currentEntryId), + nextKind: normalize(playout?.nextKind), + refreshCount: Number(playout?.refreshCompletedCount || 0), + refreshLimitReached: playout?.refreshLimitReached === true, + activeRowId: normalize(activeNative?.rowId), + pageText: normalize(activeNative?.pageText), + pageIndex: Number.isInteger(playout?.pageIndexZeroBased) ? playout.pageIndexZeroBased + 1 : 0, + pageCount: Number(playout?.pageCount || 0), + pageSize: Number(playout?.pageSize || 0), + itemCount: Number(playout?.itemCount || 0), + currentPageItemCount: Number(playout?.currentPageItemCount || 0), + playout: playout ? structuredClone(playout) : null, + rows: playlist.map((row) => ({ + id: normalize(row.rowId), graphicCode: normalize(row.sceneAlias ?? row.cutCode), + market: normalize(row.marketText), name: normalize(row.stockName), + graphicType: normalize(row.graphicType), subtype: normalize(row.subtype), + pageText: normalize(row.pageText), enabled: row.isEnabled === true, + active: row.isActive === true, selected: row.isSelected === true, + resolved: row.isSceneResolved === true + })), + active: activeNative ? { + id: normalize(activeNative.rowId), graphicCode: normalize(activeNative.sceneAlias ?? activeNative.cutCode), + market: normalize(activeNative.marketText), name: normalize(activeNative.stockName), + graphicType: normalize(activeNative.graphicType), subtype: normalize(activeNative.subtype), + pageText: normalize(activeNative.pageText) + } : null, + tabs: (native.tabs || []).map(tab => ({ id: normalize(tab.id), active: tab.isActive === true })), + searchResults: (native.searchResults || []).map((item) => ({ + index: Number(item.index), name: normalize(item.displayName) + })), + selectedStockIndex: Number.isInteger(native.selectedStockIndex) ? native.selectedStockIndex : null, + selectedCuts: (native.cutRows || []).filter(cut => cut.isSelected).map((cut) => ({ + physicalIndex: Number(cut.physicalIndex), label: normalize(cut.rawLabel) + })), + cutRows: (native.cutRows || []).map(cut => ({ + physicalIndex: Number(cut.physicalIndex), label: normalize(cut.rawLabel), selected: cut.isSelected === true + })), + fixedActions: (native.fixedCatalog?.sections || []).flatMap(section => + (section.actions || []).map(item => ({ id: normalize(item.id), available: item.isAvailable === true }))), + industry: native.industry ? structuredClone(native.industry) : null, + comparison: native.comparison ? structuredClone(native.comparison) : null, + comparisonPairs: (native.comparison?.savedPairs || []).map(pair => ({ + id: normalize(pair.rowId ?? pair.id ?? pair.pairId), + rowNumber: Number(pair.rowNumber), + displayLabel: normalize(pair.displayLabel), + selected: pair.isSelected === true + })), + manualFinancial: native.manualFinancial ? structuredClone(native.manualFinancial) : null, + manualLists: native.manualLists ? structuredClone(native.manualLists) : null + } : null, + dom: { + connectionText: document.getElementById('playout-connection-state')?.textContent?.trim() || null, + statusText: document.getElementById('playout-status')?.textContent?.trim() || null + } + }; + })()`); +} + +function assertHealthy(result, { requireIdle = false, allowPending = false, allowMissingState = false } = {}) { + if (result.href !== TARGET_URL || !result.appPresent || result.readyState !== 'complete') { + fail('Parity WebView document identity is not exact.', 'TARGET_IDENTITY_MISMATCH', result); + } + if (!result.probe || result.probe.version !== PROBE_VERSION || !result.probe.wrapperExact) { + fail('Parity safety probe is missing or replaced.', 'PROBE_IDENTITY_MISMATCH', result.probe); + } + if (result.probe.blockedCount !== 0 || result.probe.violationCount !== 0) { + fail('Parity safety probe recorded a blocked message or state violation.', 'PROBE_VIOLATION', result.probe); + } + const state = result.state; + if (allowMissingState && !state) return; + if (!state || state.mode !== 'live' || state.connectionState !== 'connected' || + !state.connected || !state.isConnected) { + fail('Development-live state is not connected and authorized.', 'LIVE_STATE_MISMATCH', state); + } + if (state.dialog != null) fail('An operator dialog is present.', 'OPERATOR_DIALOG_PRESENT', state.dialog); + if (state.outcomeUnknown) fail('Native state reports OutcomeUnknown.', 'OUTCOME_UNKNOWN', state); + if (!allowPending && (!state.isCommandAvailable || !state.liveTakeInAllowed || state.pendingCommand || !state.settled)) { + fail('Native playout state is not settled.', 'PLAYOUT_NOT_SETTLED', state); + } + if (requireIdle && state.phase !== 'idle') { + fail(`Expected IDLE phase; found ${state.phase}.`, 'PLAYOUT_PHASE_MISMATCH', state); + } +} + +async function waitFor(client, predicate, description, timeoutMs = STATE_TIMEOUT_MS, + { allowMissingState = false } = {}) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + last = await snapshot(client); + assertHealthy(last, { allowPending: true, allowMissingState }); + if (predicate(last)) return last; + await new Promise((resolve) => setTimeout(resolve, 125)); + } + const error = new Error(`Timed out waiting for ${description}.`); + error.code = 'OUTCOME_UNKNOWN'; + error.details = last; + throw error; +} + +async function postAndWait(client, type, payload, predicate, description, timeoutMs = BUILD_TIMEOUT_MS) { + const before = await snapshot(client); + assertHealthy(before); + const beforeRevision = Number(before.state.revision || 0); + await client.evaluate(`(() => { + window.chrome.webview.postMessage(${js({ type, payload })}); + return true; + })()`); + return await waitFor( + client, + (current) => Number(current.state.revision || 0) > beforeRevision && predicate(current, before), + description, + timeoutMs, + ); +} + +async function resolvePoint(client, selector) { + const point = await client.evaluate(`(() => { + const element = document.querySelector(${js(selector)}); + if (!element) return null; + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + if (rect.width <= 0 || rect.height <= 0 || style.visibility === 'hidden' || style.display === 'none') return null; + element.scrollIntoView({ block: 'center', inline: 'center' }); + const next = element.getBoundingClientRect(); + return { x: next.left + next.width / 2, y: next.top + next.height / 2 }; + })()`); + if (!point) fail(`Visible click target was not found: ${selector}`, 'CLICK_TARGET_MISSING'); + return point; +} + +async function physicalClick(client, selector) { + const point = await resolvePoint(client, selector); + await client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point.x, y: point.y, button: 'none' }); + await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: point.x, y: point.y, button: 'left', clickCount: 1 }); + await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: point.x, y: point.y, button: 'left', clickCount: 1 }); +} + +async function physicalDoubleClickAndWait( + client, selector, predicate, description, timeoutMs = BUILD_TIMEOUT_MS) { + const before = await snapshot(client); + assertHealthy(before); + const outboundBefore = before.probe.outboundCount; + const point = await resolvePoint(client, selector); + await client.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', x: point.x, y: point.y, button: 'none', + }); + await client.send('Input.dispatchMouseEvent', { + type: 'mousePressed', x: point.x, y: point.y, button: 'left', clickCount: 1, + }); + await client.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', x: point.x, y: point.y, button: 'left', clickCount: 1, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + await client.send('Input.dispatchMouseEvent', { + type: 'mousePressed', x: point.x, y: point.y, button: 'left', clickCount: 2, + }); + await client.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', x: point.x, y: point.y, button: 'left', clickCount: 2, + }); + return await waitFor(client, (current) => + current.probe.outboundCount > outboundBefore && predicate(current, before), + description, timeoutMs); +} + +async function physicalClickAndWait(client, selector, predicate, description, timeoutMs = STATE_TIMEOUT_MS) { + const before = await snapshot(client); + assertHealthy(before); + const outboundBefore = before.probe.outboundCount; + await physicalClick(client, selector); + return await waitFor(client, (current) => { + if (current.probe.outboundCount <= outboundBefore) return false; + return predicate(current, before); + }, description, timeoutMs); +} + +async function physicalDragStockCut(client, physicalIndex, label) { + const before = await snapshot(client); + assertHealthy(before); + const geometry = await client.evaluate(`(() => { + const source = document.querySelector('#cut-list .cut-row[data-physical-index=${js(String(physicalIndex))}]'); + const target = document.getElementById('playlist-drop-zone'); + if (!source || !target) return null; + source.scrollIntoView({ block: 'center', inline: 'center' }); + const sourceRect = source.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const sourceStyle = getComputedStyle(source); + const targetStyle = getComputedStyle(target); + if (sourceRect.width <= 0 || sourceRect.height <= 0 || targetRect.width <= 0 || targetRect.height <= 0 || + sourceStyle.visibility === 'hidden' || sourceStyle.display === 'none' || + targetStyle.visibility === 'hidden' || targetStyle.display === 'none') return null; + const start = { x: sourceRect.left + sourceRect.width / 2, y: sourceRect.top + sourceRect.height / 2 }; + return { + start, + threshold: { x: start.x + 20, y: start.y + 20 }, + target: { x: targetRect.left + targetRect.width / 2, y: targetRect.top + Math.min(80, targetRect.height / 2) } + }; + })()`); + if (!geometry) fail(`Visible stock cut drag target was not found: ${label}`, 'DRAG_TARGET_MISSING'); + + await client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...geometry.start, button: 'none' }); + await client.send('Input.dispatchMouseEvent', { + type: 'mousePressed', ...geometry.start, button: 'left', buttons: 1, clickCount: 1, + }); + await client.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', ...geometry.threshold, button: 'left', buttons: 1, + }); + await client.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', ...geometry.target, button: 'left', buttons: 1, + }); + await client.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', ...geometry.target, button: 'left', buttons: 0, clickCount: 1, + }); + + const result = await waitFor(client, (current) => !current.state.isBusy && + current.state.rows.length === before.state.rows.length + 1, `stock cut drag ${label}`, BUILD_TIMEOUT_MS); + const outboundDelta = result.probe.outboundCount - before.probe.outboundCount; + const types = result.probe.lastOutbound.slice(-outboundDelta).map(item => item.type); + if (outboundDelta !== 2 || types[0] !== 'cut-pointer-down' || types[1] !== 'drop-selected-cuts') { + fail(`Stock cut drag did not produce the exact native gesture sequence: ${label}`, + 'DRAG_MESSAGE_SEQUENCE_MISMATCH', { outboundDelta, types }); + } + return result; +} + +function assertRowIds(rows) { + const invalid = rows.filter((row) => !ROW_ID_PATTERN.test(row.id || '')); + if (invalid.length > 0) fail('Playlist contains a non in-memory legacy row identifier.', 'PLAYLIST_ID_MISMATCH', invalid); +} + +function assertExactCodes(result, expected) { + if (result.state.rows.length !== expected.length) { + fail(`Playlist route count mismatch. Expected ${expected.length}; got ${result.state.rows.length}.`, + 'PLAYLIST_ROUTE_MISMATCH', { expected, rows: result.state.rows }); + } + result.state.rows.forEach((row, index) => { + assertRowMatchesCode(row, expected[index], index); + }); + assertRowIds(result.state.rows); +} + +function assertRowMatchesCode(row, code, index = null) { + const expected = ROUTE_ROW_IDENTITIES.get(code); + if (!expected) fail(`No row identity is registered for route ${code}.`, 'PLAYLIST_ROUTE_MISMATCH'); + if ((expected.market != null && row.market !== expected.market) || + row.name !== expected.stockName || row.graphicType !== expected.graphicType || + (expected.subtype != null && row.subtype !== expected.subtype) || row.enabled !== true || row.resolved !== true) { + fail(`Playlist row identity mismatch for route ${code}${index == null ? '' : ` at index ${index}`}.`, + 'PLAYLIST_ROW_MISMATCH', { code, expected, row }); + } +} + +function assertTwoSceneManifest(result) { + assertExactCodes(result, ['5001', '5074']); + const [first, second] = result.state.rows; + if (first.name !== '\uc0bc\uc131\uc804\uc790' || first.market !== '\ucf54\uc2a4\ud53c' || + first.graphicType !== '1\uc5f4\ud310\uae30\ubcf8' || first.subtype !== '\ud604\uc7ac\uac00' || + second.market !== '\uc5c5\uc885_\ucf54\uc2a4\ud53c' || second.name !== '\ucf54\uc2a4\ud53c 5\ub2e8 \ud45c\uadf8\ub798\ud504' || + second.graphicType !== '5\ub2e8 \ud45c\uadf8\ub798\ud504') { + fail('5001/5074 playlist row identity mismatch.', 'PLAYLIST_ROW_MISMATCH', result.state.rows); + } +} + +function assertThirtyManifest(result) { + assertExactCodes(result, THIRTY_CODES); + result.state.rows.forEach((row, index) => { + if (!row.enabled || !/^1\/(?:1|20)$/u.test(row.pageText || '')) { + fail(`30-route playlist page identity mismatch at index ${index}.`, 'PLAYLIST_ROW_MISMATCH', { row }); + } + }); +} + +function assertPagedManifest(result) { + assertExactCodes(result, ['5077', '5088']); + const [first, second] = result.state.rows; + if (!/^(?:[1-9]|1[0-9]|20)\/20$/u.test(first.pageText || '') || + second.pageText !== '1/20' || !first.enabled || !second.enabled) { + fail('5077/5088 page-boundary playlist identity is not exact.', 'PLAYLIST_PAGE_FIXTURE_MISMATCH', result.state.rows); + } +} + +function assertS6001Row(row, expected, index = null) { + if (!row || row.market !== '\ud574\uc678\uc9c0\uc218' || row.name !== expected.stockName || + row.graphicType !== '\uc774\ubbf8\uc9c0 \uadf8\ub798\ud504' || row.subtype !== '' || + row.pageText !== '1/1' || row.enabled !== true || row.resolved !== true || + !ROW_ID_PATTERN.test(row.id || '')) { + fail(`s6001 playlist row identity mismatch${index == null ? '' : ` at index ${index}`}.`, + 'PLAYLIST_ROW_MISMATCH', { expected, row }); + } +} + +function assertS6001Manifest(result) { + if (result.state.rows.length !== S6001_ASSET_INDEPENDENT_ACTIONS.length) { + fail('s6001 asset-independent playlist must contain exactly four rows.', + 'PLAYLIST_ROUTE_MISMATCH', result.state.rows); + } + result.state.rows.forEach((row, index) => + assertS6001Row(row, S6001_ASSET_INDEPENDENT_ACTIONS[index], index)); +} + +async function selectTab(client, tabId) { + const current = await snapshot(client); + if (current.state.tabs.some(tab => tab.id === tabId && tab.active)) return current; + return await postAndWait(client, 'select-tab', { tabId }, + (value) => value.state.tabs.some(tab => tab.id === tabId && tab.active) && !value.state.isBusy, + `tab ${tabId}`); +} + +async function selectSamsung(client) { + const searched = await postAndWait(client, 'search-stocks', { text: '\uc0bc\uc131\uc804\uc790', trigger: 'button' }, + (current) => !current.state.isBusy && + current.state.searchResults.filter(item => item.name === '\uc0bc\uc131\uc804\uc790').length === 1, + 'Samsung stock search'); + const stock = searched.state.searchResults.find(item => item.name === '\uc0bc\uc131\uc804\uc790'); + if (!stock || !Number.isInteger(stock.index)) fail('Samsung search result identity is not unique.', 'SEARCH_RESULT_MISMATCH'); + await postAndWait(client, 'select-stock', { resultIndex: stock.index }, + (current) => !current.state.isBusy && current.state.selectedStockIndex === stock.index, + 'Samsung stock selection'); +} + +async function addStockCut(client, physicalIndex, label, expectedCode) { + const result = await physicalDragStockCut(client, physicalIndex, label); + assertRowMatchesCode(result.state.rows.at(-1), expectedCode); +} + +async function addFixedAction(client, section, actionId, expectedCode) { + const current = await selectTab(client, section); + if (!current.state.fixedActions.some(item => item.id === actionId && item.available)) { + fail(`Fixed action ${section}/${actionId} is unavailable.`, 'FIXED_ACTION_UNAVAILABLE'); + } + const result = await postAndWait(client, 'activate-fixed-action', { actionId }, + (value, before) => !value.state.isBusy && value.state.rows.length === before.state.rows.length + 1, + `fixed action ${section}/${actionId}`); + assertRowMatchesCode(result.state.rows.at(-1), expectedCode); +} + +async function addIndustryAction(client, actionId, expectedCode) { + const current = await selectTab(client, 'kospiIndustry'); + if (!current.state.industry?.actions?.some(item => item.actionId === actionId)) { + fail(`Industry action ${actionId} is unavailable.`, 'INDUSTRY_ACTION_UNAVAILABLE'); + } + const result = await postAndWait(client, 'activate-industry-action', { actionId }, + (value, before) => !value.state.isBusy && value.state.rows.length === before.state.rows.length + 1, + `industry action ${actionId}`); + assertRowMatchesCode(result.state.rows.at(-1), expectedCode); +} + +async function clearComparison(client) { + const current = await snapshot(client); + if (current.state.comparison?.firstTarget == null && current.state.comparison?.secondTarget == null) return current; + return await postAndWait(client, 'clear-comparison-targets', {}, + (value) => value.state.comparison?.firstTarget == null && value.state.comparison?.secondTarget == null, + 'clear comparison targets'); +} + +async function chooseComparison(client, type, payload, slot) { + return await postAndWait(client, type, payload, + (value) => value.state.comparison?.[slot] != null, + `comparison ${slot}`); +} + +async function addComparisonAction(client, actionId, expectedCode) { + const current = await snapshot(client); + if (!current.state.comparison?.actions?.some(item => item.actionId === actionId && item.isAvailable)) { + fail(`Comparison action ${actionId} is unavailable.`, 'COMPARISON_ACTION_UNAVAILABLE'); + } + const result = await postAndWait(client, 'activate-comparison-action', { actionId }, + (value, before) => !value.state.isBusy && value.state.rows.length === before.state.rows.length + 1, + `comparison action ${actionId}`); + assertRowMatchesCode(result.state.rows.at(-1), expectedCode); + return result; +} + +function comparisonPairRows(comparison) { + return (comparison?.savedPairs || []).map((pair) => ({ + rowId: normalizeValue(pair.rowId ?? pair.id ?? pair.pairId), + rowNumber: Number(pair.rowNumber), + displayLabel: normalizeValue(pair.displayLabel), + })); +} + +function comparisonPairsEqual(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} + +async function commitComparisonPair(client, description) { + const before = await snapshot(client); + assertHealthy(before); + const baseline = comparisonPairRows(before.state.comparison); + const baselineIds = new Set(baseline.map((pair) => pair.rowId)); + const result = await postAndWait(client, 'add-comparison-pair', {}, + (value, before) => !value.state.isBusy && + (value.state.comparison?.savedPairs?.length || 0) === (before.state.comparison?.savedPairs?.length || 0) + 1 && + value.state.comparison?.selectedPairId != null, + `comparison pair ${description}`); + const after = comparisonPairRows(result.state.comparison); + const addedPairId = normalizeValue(result.state.comparison?.selectedPairId); + if (!comparisonPairsEqual(after.slice(0, -1), baseline) || + !addedPairId || baselineIds.has(addedPairId) || after.at(-1)?.rowId !== addedPairId) { + fail(`Temporary comparison pair identity changed the saved baseline: ${description}`, + 'COMPARISON_COMMIT_MISMATCH', { baseline, after, addedPairId }); + } + return { result, baseline, addedPairId }; +} + +async function deleteTemporaryComparisonPair(client, description, baseline, addedPairId) { + const before = await snapshot(client); + assertHealthy(before); + if (normalizeValue(before.state.comparison?.selectedPairId) !== addedPairId) { + fail(`The temporary comparison pair is no longer selected: ${description}`, + 'COMPARISON_TEMP_SELECTION_MISMATCH', before.state.comparison); + } + const result = await postAndWait(client, 'delete-selected-comparison-pair', {}, + (value) => !value.state.isBusy && + comparisonPairRows(value.state.comparison).length === baseline.length && + !comparisonPairRows(value.state.comparison).some((pair) => pair.rowId === addedPairId), + `comparison pair cleanup ${description}`); + const after = comparisonPairRows(result.state.comparison); + if (!comparisonPairsEqual(after, baseline)) { + fail(`Temporary comparison pair cleanup changed the saved baseline: ${description}`, + 'COMPARISON_CLEANUP_MISMATCH', { baseline, after, addedPairId }); + } + return result; +} + +async function restoreComparisonSelection(client, selectedPairId) { + const current = await snapshot(client); + const currentSelection = normalizeValue(current.state.comparison?.selectedPairId); + if (currentSelection === selectedPairId || (!selectedPairId && !currentSelection)) return current; + if (!selectedPairId || !comparisonPairRows(current.state.comparison).some((pair) => pair.rowId === selectedPairId)) { + fail('The original saved comparison selection cannot be restored.', + 'COMPARISON_SELECTION_RESTORE_MISMATCH', { selectedPairId, comparison: current.state.comparison }); + } + return await postAndWait(client, 'select-comparison-pair', { rowId: selectedPairId }, + (value) => !value.state.isBusy && value.state.comparison?.selectedPairId === selectedPairId, + 'restore saved comparison selection'); +} + +async function setup5001And5074(client) { + const initial = await snapshot(client); + assertHealthy(initial, { requireIdle: true }); + if (initial.state.rows.length !== 0) fail('5001/5074 setup requires an empty in-memory playlist.', 'PLAYLIST_NOT_EMPTY'); + await selectSamsung(client); + await addStockCut(client, 1, '1\uc5f4\ud310\uae30\ubcf8_\ud604\uc7ac\uac00', '5001'); + await addIndustryAction(client, 'five-row', '5074'); + const result = await snapshot(client); + assertTwoSceneManifest(result); + return result; +} + +async function buildThirtyBase(client) { + const initial = await snapshot(client); + assertHealthy(initial, { requireIdle: true }); + if (initial.state.rows.length !== 0) fail('30-route base build requires an empty in-memory playlist.', 'PLAYLIST_NOT_EMPTY'); + for (let index = 0; index < FIXED_ACTIONS.length; index += 1) { + await addFixedAction(client, FIXED_ACTIONS[index][0], FIXED_ACTIONS[index][1], THIRTY_CODES[index]); + } + await selectSamsung(client); + await addStockCut(client, 5, '1\uc5f4\ud310\uc0c1\uc138_\uc561\uba74\uac00', '5011'); + await addStockCut(client, 31, '\ud638\uac00\ucc3d_\ud45c\uadf8\ub798\ud504', '8003'); + await addStockCut(client, 32, '\uac70\ub798\uc6d0_\ud45c\uadf8\ub798\ud504', '5037'); + await addIndustryAction(client, 'square', '8001'); + const result = await snapshot(client); + assertExactCodes(result, THIRTY_CODES.slice(0, 20)); + return result; +} + +async function buildThirtyComparison(client) { + const initial = await snapshot(client); + assertHealthy(initial, { requireIdle: true }); + assertExactCodes(initial, THIRTY_CODES.slice(0, 20)); + + await selectTab(client, 'comparison'); + let current = await snapshot(client); + const savedPairBaseline = comparisonPairRows(current.state.comparison); + const selectedPairBaseline = normalizeValue(current.state.comparison?.selectedPairId); + await clearComparison(client); + current = await postAndWait(client, 'search-comparison-domestic', { query: '\uc0bc\uc131' }, + (value) => !value.state.isBusy && value.state.comparison?.domesticSearch?.query === '\uc0bc\uc131' && + value.state.comparison?.domesticSearch?.results?.length > 1, + 'comparison domestic search'); + const firstId = current.state.comparison.domesticSearch.results[0].selectionId; + const secondId = current.state.comparison.domesticSearch.results[1].selectionId; + if (!firstId || !secondId || firstId === secondId) fail('Comparison search selections are not exact.', 'COMPARISON_SELECTION_MISMATCH'); + await chooseComparison(client, 'choose-comparison-domestic', { selectionId: firstId }, 'firstTarget'); + await chooseComparison(client, 'choose-comparison-domestic', { selectionId: secondId }, 'secondTarget'); + let temporaryPair = await commitComparisonPair(client, 'Samsung pair'); + await addComparisonAction(client, 'comparison-candle', '5026'); + await addComparisonAction(client, 'comparison-line', '5087'); + await addComparisonAction(client, 'return-5d', '5029'); + await deleteTemporaryComparisonPair(client, 'Samsung pair', temporaryPair.baseline, temporaryPair.addedPairId); + + await clearComparison(client); + await chooseComparison(client, 'choose-comparison-fixed', { targetId: 'kospi' }, 'firstTarget'); + await chooseComparison(client, 'choose-comparison-fixed', { targetId: 'kosdaq' }, 'secondTarget'); + temporaryPair = await commitComparisonPair(client, 'KOSPI/KOSDAQ'); + await addComparisonAction(client, 'two-column-current', '8032'); + await deleteTemporaryComparisonPair(client, 'KOSPI/KOSDAQ', temporaryPair.baseline, temporaryPair.addedPairId); + + await clearComparison(client); + await chooseComparison(client, 'choose-comparison-fixed', { targetId: 'kospi' }, 'firstTarget'); + await chooseComparison(client, 'choose-comparison-fixed', { targetId: 'futures' }, 'secondTarget'); + temporaryPair = await commitComparisonPair(client, 'KOSPI/futures'); + await addComparisonAction(client, 'two-column-current', '5032'); + await deleteTemporaryComparisonPair(client, 'KOSPI/futures', temporaryPair.baseline, temporaryPair.addedPairId); + + await restoreComparisonSelection(client, selectedPairBaseline); + + const result = await snapshot(client); + assertExactCodes(result, THIRTY_CODES.slice(0, 25)); + if (!comparisonPairsEqual(comparisonPairRows(result.state.comparison), savedPairBaseline) || + normalizeValue(result.state.comparison?.selectedPairId) !== selectedPairBaseline) { + fail('Temporary comparison pairs were not removed without changing the saved baseline.', + 'COMPARISON_CLEANUP_MISMATCH', { savedPairBaseline, selectedPairBaseline, comparison: result.state.comparison }); + } + return result; +} + +async function buildThirtyFinancial(client) { + const initial = await snapshot(client); + assertHealthy(initial, { requireIdle: true }); + assertExactCodes(initial, THIRTY_CODES.slice(0, 25)); + let current = initial; + if (current.state.manualFinancial != null) { + current = await postAndWait(client, 'close-manual-financial', {}, + (value) => !value.state.isBusy && value.state.manualFinancial == null, + 'stale manual financial close'); + } + for (let index = 0; index < FINANCIAL_ACTIONS.length; index += 1) { + const [screen, query] = FINANCIAL_ACTIONS[index]; + const beforeLength = current.state.rows.length; + current = await postAndWait(client, 'open-manual-financial', { screen }, + (value) => !value.state.isBusy && value.state.manualFinancial?.screen === screen, + `manual financial ${screen} open`); + current = await postAndWait(client, 'search-manual-financial', { query }, + (value) => !value.state.isBusy && value.state.manualFinancial?.screen === screen && + value.state.manualFinancial?.query === query && + value.state.manualFinancial?.rows?.some(row => row.stockName === query), + `manual financial ${screen} search`); + const exactRows = current.state.manualFinancial.rows.filter(row => row.stockName === query); + if (exactRows.length !== 1 || !exactRows[0].resultId) { + fail(`Manual financial ${screen}/${query} result identity is not unique.`, 'FINANCIAL_RESULT_MISMATCH', exactRows); + } + current = await postAndWait(client, 'activate-manual-financial-result', { resultId: exactRows[0].resultId }, + (value) => !value.state.isBusy && value.state.manualFinancial == null && value.state.rows.length === beforeLength + 1, + `manual financial ${screen} playlist`); + assertRowMatchesCode(current.state.rows.at(-1), THIRTY_CODES[25 + index]); + } + const result = current; + assertExactCodes(result, THIRTY_CODES.slice(0, 29)); + return result; +} + +async function buildThirtyManual(client) { + const initial = await snapshot(client); + assertHealthy(initial, { requireIdle: true }); + assertExactCodes(initial, THIRTY_CODES.slice(0, 29)); + await selectTab(client, 'index'); + const opened = await postAndWait(client, 'activate-fixed-action', { actionId: 'fixed-245' }, + (value) => !value.state.isBusy && value.state.manualLists?.isOpen === true && + value.state.manualLists?.screen === 'net-sell' && value.state.manualLists?.audience === 'individual' && + value.state.manualLists?.netRowsAreFresh === true && value.state.manualLists?.canMaterializePlaylist === true && + value.state.manualLists?.netRows?.length === 5, + 'manual net-sell fresh read'); + if (!opened.state.manualLists?.canMaterializePlaylist) fail('Manual net-sell list cannot materialize a playlist row.', 'MANUAL_LIST_UNAVAILABLE'); + const result = await postAndWait(client, 'add-manual-net-sell-cut', {}, + (current, before) => !current.state.isBusy && current.state.rows.length === before.state.rows.length + 1 && + current.state.manualLists?.isOpen === true, + 'manual net-sell cut addition'); + assertRowMatchesCode(result.state.rows.at(-1), '5025'); + await postAndWait(client, 'close-manual-list', {}, + (value) => !value.state.isBusy && value.state.manualLists?.isOpen === false, + 'manual list close'); + const final = await snapshot(client); + assertThirtyManifest(final); + return final; +} + +async function buildPaged(client) { + const initial = await snapshot(client); + assertHealthy(initial, { requireIdle: true }); + if (initial.state.rows.length !== 0) fail('Paged-boundary build requires an empty in-memory playlist.', 'PLAYLIST_NOT_EMPTY'); + await addFixedAction(client, 'index', 'fixed-275', '5077'); + await addFixedAction(client, 'index', 'fixed-302', '5088'); + const result = await snapshot(client); + assertPagedManifest(result); + if (result.state.rows.some(row => row.pageText !== '1/20')) { + fail('5077/5088 must both materialize with an exact 1/20 page label.', + 'PLAYLIST_PAGE_FIXTURE_MISMATCH', result.state.rows); + } + return result; +} + +async function buildS6001AssetIndependent(client) { + const initial = await snapshot(client); + assertHealthy(initial, { requireIdle: true }); + if (initial.state.rows.length !== 0) { + fail('s6001 asset-independent build requires an empty in-memory playlist.', + 'PLAYLIST_NOT_EMPTY'); + } + await selectTab(client, 'overseas'); + for (let index = 0; index < S6001_ASSET_INDEPENDENT_ACTIONS.length; index += 1) { + const expected = S6001_ASSET_INDEPENDENT_ACTIONS[index]; + const selector = `button[data-action-id=${js(expected.actionId)}]`; + await client.evaluate(`(() => { + const button = document.querySelector(${js(selector)}); + const details = button?.closest('details'); + if (details) details.open = true; + return Boolean(button && details); + })()`); + const result = await physicalDoubleClickAndWait(client, selector, + (current, before) => !current.state.isBusy && + current.state.rows.length === before.state.rows.length + 1, + `s6001 physical double-click ${expected.actionId}`); + assertS6001Row(result.state.rows.at(-1), expected, index); + } + const result = await snapshot(client); + assertS6001Manifest(result); + return result; +} + +async function activateRow(client, row, expectedCode, manifestCheck) { + manifestCheck(await snapshot(client)); + if (!ROW_ID_PATTERN.test(row.id || '')) fail('Target row is not an in-memory legacy row.', 'PLAYLIST_ID_MISMATCH', row); + return await physicalClickAndWait(client, `#playlist-rows .playlist-data-row[data-row-id=${js(row.id)}] .playlist-row-header`, + (current) => current.state.activeRowId === row.id, + `row activation ${expectedCode}`, BUILD_TIMEOUT_MS); +} + +async function clickPlayout(client, buttonId, predicate, description) { + return await physicalClickAndWait(client, `#${buttonId}`, + (current) => predicate(current), description, STATE_TIMEOUT_MS); +} + +async function runAction(client, options) { + const action = options.action; + if (action === 'bootstrap') { + let current = await snapshot(client); + if (!current.state) { + await client.evaluate(`window.chrome.webview.postMessage({ type: 'ready', payload: {} })`); + current = await waitFor(client, (value) => Boolean(value.state), 'initial native state', + STATE_TIMEOUT_MS, { allowMissingState: true }); + } + assertHealthy(current, { requireIdle: true }); + if (current.state.rows.length !== 0 || current.state.currentCode != null || current.state.preparedCode != null) { + fail('Bootstrap requires exact connected IDLE with an empty in-memory playlist.', 'BOOTSTRAP_STATE_MISMATCH', current.state); + } + return current; + } + const initial = await snapshot(client); + assertHealthy(initial, { allowPending: action.startsWith('wait-') }); + + switch (action) { + case 'snapshot': return initial; + case 'setup-5001-5074': return await setup5001And5074(client); + case 'take-in-5001': + assertTwoSceneManifest(initial); + if (initial.state.phase !== 'idle' || initial.state.activeRowId !== initial.state.rows[0].id || + initial.state.currentCueIndex !== -1 || initial.buttons['playout-take-in']?.disabled || + initial.buttons['playout-take-in']?.hidden) { + fail('5001 must be active in exact IDLE before TAKE IN.', 'ACTIVE_ROUTE_MISMATCH'); + } + return await clickPlayout(client, 'playout-take-in', + (current) => current.state.phase === 'program' && current.state.currentCode === '5001' && + current.state.currentCueIndex === 0 && current.state.pageIndex === 1 && current.state.pageCount === 1 && + current.state.nextKind === 'playlistNext' && current.state.settled && + current.state.refreshCount === 1 && current.state.refreshLimitReached, + '5001 TAKE IN'); + case 'next-5074': + assertTwoSceneManifest(initial); + if (initial.state.phase !== 'program' || initial.state.currentCode !== '5001' || + initial.state.currentCueIndex !== 0 || initial.state.nextKind !== 'playlistNext') { + fail('NEXT requires settled 5001 PROGRAM with playlistNext.', 'PLAYOUT_PHASE_MISMATCH'); + } + return await clickPlayout(client, 'playout-next', + (current) => current.state.phase === 'program' && current.state.currentCode === '5074' && + current.state.currentCueIndex === 1 && current.state.pageIndex === 1 && + current.state.pageCount >= 2 && current.state.pageSize === 5 && + current.state.itemCount >= 5 && current.state.currentPageItemCount === 5 && + current.state.nextKind === 'pageNext' && current.state.settled, + 'NEXT to 5074'); + case 'page-next-5074': { + assertTwoSceneManifest(initial); + if (initial.state.phase !== 'program' || initial.state.currentCode !== '5074' || + initial.state.currentCueIndex !== 1 || initial.state.pageIndex !== 1 || initial.state.nextKind !== 'pageNext') { + fail('Page NEXT requires 5074 page 1 on PROGRAM.', 'ACTIVE_ROUTE_MISMATCH'); + } + return await clickPlayout(client, 'playout-next', + (current) => current.state.phase === 'program' && current.state.currentCode === '5074' && + current.state.currentCueIndex === 1 && current.state.pageIndex === 2 && + current.state.pageSize === 5 && current.state.currentPageItemCount === 5 && + current.state.nextKind === 'pageNext' && current.state.settled, + '5074 Page NEXT'); + } + case 'take-out-5074': + assertTwoSceneManifest(initial); + if (initial.state.phase !== 'program' || initial.state.currentCode !== '5074' || initial.state.pageIndex !== 2) { + fail('TAKE OUT requires 5074 page 2 on PROGRAM.', 'ACTIVE_ROUTE_MISMATCH'); + } + return await clickPlayout(client, 'playout-take-out', + (current) => current.state.phase === 'idle' && current.state.currentCode == null && + current.state.preparedCode == null && current.state.currentCueIndex === -1 && current.state.settled, + '5074 TAKE OUT'); + case 'build-30-base': return await buildThirtyBase(client); + case 'build-30-comparison': return await buildThirtyComparison(client); + case 'build-30-financial': return await buildThirtyFinancial(client); + case 'build-30-manual': return await buildThirtyManual(client); + case 'resume-30-manifest': + assertThirtyManifest(initial); + if (initial.state.phase !== 'idle' || initial.state.currentCode != null || + initial.state.preparedCode != null || initial.state.currentCueIndex !== -1) { + fail('30-route resume requires the preserved manifest in exact IDLE.', 'PLAYOUT_PHASE_MISMATCH'); + } + return initial; + case 'take-out-recovery-8001': { + if (options.routeIndex !== 19) { + fail('take-out-recovery-8001 requires --route-index 19.', 'INVALID_ARGUMENT'); + } + assertThirtyManifest(initial); + const row = initial.state.rows[19]; + if (initial.state.phase !== 'program' || initial.state.currentCode !== '8001' || + initial.state.currentCueIndex !== 19 || initial.state.currentEntryId !== row.id || + !initial.state.settled || initial.state.pendingCommand != null || + initial.state.refreshCount !== 1 || !initial.state.refreshLimitReached) { + fail('Recovery TAKE OUT requires known settled 8001 PROGRAM after its one authorized refresh.', + 'PLAYOUT_PHASE_MISMATCH', initial.state); + } + return await clickPlayout(client, 'playout-take-out', + (current) => current.state.phase === 'idle' && current.state.currentCode == null && + current.state.preparedCode == null && current.state.currentCueIndex === -1 && current.state.settled, + 'known 8001 recovery TAKE OUT'); + } + case 'take-out-recovery-5077-page2': { + if (options.pageIndex !== 2) { + fail('take-out-recovery-5077-page2 requires --page-index 2.', 'INVALID_ARGUMENT'); + } + assertPagedManifest(initial); + const row = initial.state.rows[0]; + if (initial.state.phase !== 'program' || initial.state.currentCode !== '5077' || + initial.state.currentCueIndex !== 0 || initial.state.currentEntryId !== row.id || + initial.state.activeRowId !== row.id || initial.state.pageIndex !== 2 || + initial.state.pageCount !== 20 || initial.state.pageSize !== 6 || + initial.state.itemCount !== 120 || initial.state.currentPageItemCount !== 6 || + !initial.state.settled || initial.state.pendingCommand != null || + initial.state.refreshCount !== 1 || !initial.state.refreshLimitReached) { + fail('Recovery TAKE OUT requires known settled 5077 page 2/20 PROGRAM.', + 'PLAYOUT_PHASE_MISMATCH', initial.state); + } + return await clickPlayout(client, 'playout-take-out', + (current) => current.state.phase === 'idle' && current.state.currentCode == null && + current.state.preparedCode == null && current.state.currentCueIndex === -1 && current.state.settled, + 'known 5077 page 2 recovery TAKE OUT'); + } + case 'activate-30': { + if (!Number.isInteger(options.routeIndex) || options.routeIndex < 0 || options.routeIndex >= THIRTY_CODES.length) { + fail('activate-30 requires --route-index 0..29.', 'INVALID_ARGUMENT'); + } + assertThirtyManifest(initial); + if (initial.state.phase !== 'idle' || initial.state.currentCode != null || initial.state.preparedCode != null) { + fail('Route activation requires exact IDLE.', 'PLAYOUT_PHASE_MISMATCH'); + } + return await activateRow(client, initial.state.rows[options.routeIndex], THIRTY_CODES[options.routeIndex], assertThirtyManifest); + } + case 'take-in-30-fast': { + if (!Number.isInteger(options.routeIndex) || options.routeIndex < 0 || options.routeIndex >= THIRTY_CODES.length) { + fail('take-in-30-fast requires --route-index 0..29.', 'INVALID_ARGUMENT'); + } + assertThirtyManifest(initial); + const code = THIRTY_CODES[options.routeIndex]; + const row = initial.state.rows[options.routeIndex]; + if (initial.state.activeRowId !== row.id || initial.state.phase !== 'idle' || + initial.state.currentCueIndex !== -1 || initial.buttons['playout-take-in']?.disabled || + initial.buttons['playout-take-in']?.hidden) { + fail(`TAKE IN requires active ${code} in exact IDLE.`, 'ACTIVE_ROUTE_MISMATCH'); + } + return await clickPlayout(client, 'playout-take-in', + (current) => current.state.phase === 'program' && current.state.currentCode === code && + current.state.currentCueIndex === options.routeIndex && current.state.currentEntryId === row.id && + current.state.pageIndex === 1, + `${code} fast TAKE IN`); + } + case 'wait-30': { + if (!Number.isInteger(options.routeIndex) || options.routeIndex < 0 || options.routeIndex >= THIRTY_CODES.length) { + fail('wait-30 requires --route-index 0..29.', 'INVALID_ARGUMENT'); + } + assertThirtyManifest(initial); + const code = THIRTY_CODES[options.routeIndex]; + const row = initial.state.rows[options.routeIndex]; + return await waitFor(client, + (current) => current.state.phase === 'program' && current.state.currentCode === code && + current.state.currentCueIndex === options.routeIndex && current.state.currentEntryId === row.id && + current.state.settled, + `${code} settled PROGRAM`); + } + case 'take-out-30': { + if (!Number.isInteger(options.routeIndex) || options.routeIndex < 0 || options.routeIndex >= THIRTY_CODES.length) { + fail('take-out-30 requires --route-index 0..29.', 'INVALID_ARGUMENT'); + } + assertThirtyManifest(initial); + const code = THIRTY_CODES[options.routeIndex]; + if (initial.state.phase !== 'program' || initial.state.currentCode !== code || + initial.state.currentCueIndex !== options.routeIndex || !initial.state.settled) { + fail(`TAKE OUT requires settled ${code} PROGRAM.`, 'PLAYOUT_PHASE_MISMATCH'); + } + return await clickPlayout(client, 'playout-take-out', + (current) => current.state.phase === 'idle' && current.state.currentCode == null && + current.state.preparedCode == null && current.state.currentCueIndex === -1 && current.state.settled, + `${code} TAKE OUT`); + } + case 'build-paged': return await buildPaged(client); + case 'build-s6001-asset-independent': return await buildS6001AssetIndependent(client); + case 'activate-s6001': { + if (!Number.isInteger(options.routeIndex) || options.routeIndex < 0 || + options.routeIndex >= S6001_ASSET_INDEPENDENT_ACTIONS.length) { + fail('activate-s6001 requires --route-index 0..3.', 'INVALID_ARGUMENT'); + } + assertS6001Manifest(initial); + if (initial.state.phase !== 'idle' || initial.state.currentCode != null || + initial.state.preparedCode != null) { + fail('s6001 route activation requires exact IDLE.', 'PLAYOUT_PHASE_MISMATCH'); + } + return await activateRow( + client, + initial.state.rows[options.routeIndex], + '6001', + assertS6001Manifest); + } + case 'take-in-s6001': { + if (!Number.isInteger(options.routeIndex) || options.routeIndex < 0 || + options.routeIndex >= S6001_ASSET_INDEPENDENT_ACTIONS.length) { + fail('take-in-s6001 requires --route-index 0..3.', 'INVALID_ARGUMENT'); + } + assertS6001Manifest(initial); + const row = initial.state.rows[options.routeIndex]; + if (initial.state.activeRowId !== row.id || initial.state.phase !== 'idle' || + initial.state.currentCueIndex !== -1 || initial.buttons['playout-take-in']?.disabled || + initial.buttons['playout-take-in']?.hidden) { + fail('s6001 TAKE IN requires its selected row in exact IDLE.', 'ACTIVE_ROUTE_MISMATCH'); + } + return await clickPlayout(client, 'playout-take-in', + (current) => current.state.phase === 'program' && current.state.currentCode === '6001' && + current.state.currentCueIndex === options.routeIndex && + current.state.currentEntryId === row.id && current.state.pageIndex === 1, + `${S6001_ASSET_INDEPENDENT_ACTIONS[options.routeIndex].subtype} s6001 TAKE IN`); + } + case 'wait-s6001': { + if (!Number.isInteger(options.routeIndex) || options.routeIndex < 0 || + options.routeIndex >= S6001_ASSET_INDEPENDENT_ACTIONS.length) { + fail('wait-s6001 requires --route-index 0..3.', 'INVALID_ARGUMENT'); + } + assertS6001Manifest(initial); + const row = initial.state.rows[options.routeIndex]; + return await waitFor(client, + (current) => current.state.phase === 'program' && current.state.currentCode === '6001' && + current.state.currentCueIndex === options.routeIndex && + current.state.currentEntryId === row.id && current.state.settled, + `${S6001_ASSET_INDEPENDENT_ACTIONS[options.routeIndex].subtype} s6001 settled PROGRAM`); + } + case 'take-out-s6001': { + if (!Number.isInteger(options.routeIndex) || options.routeIndex < 0 || + options.routeIndex >= S6001_ASSET_INDEPENDENT_ACTIONS.length) { + fail('take-out-s6001 requires --route-index 0..3.', 'INVALID_ARGUMENT'); + } + assertS6001Manifest(initial); + if (initial.state.phase !== 'program' || initial.state.currentCode !== '6001' || + initial.state.currentCueIndex !== options.routeIndex || !initial.state.settled) { + fail('s6001 TAKE OUT requires its settled PROGRAM row.', 'PLAYOUT_PHASE_MISMATCH'); + } + return await clickPlayout(client, 'playout-take-out', + (current) => current.state.phase === 'idle' && current.state.currentCode == null && + current.state.preparedCode == null && current.state.currentCueIndex === -1 && current.state.settled, + `${S6001_ASSET_INDEPENDENT_ACTIONS[options.routeIndex].subtype} s6001 TAKE OUT`); + } + case 'take-in-5077': + assertPagedManifest(initial); + if (initial.state.phase !== 'idle' || initial.state.activeRowId !== initial.state.rows[0].id) { + fail('5077 must be active in exact IDLE.', 'ACTIVE_ROUTE_MISMATCH'); + } + return await clickPlayout(client, 'playout-take-in', + (current) => current.state.phase === 'program' && current.state.currentCode === '5077' && + current.state.currentCueIndex === 0 && current.state.pageIndex === 1 && current.state.pageCount === 20 && + current.state.pageSize === 6 && current.state.itemCount === 120 && + current.state.currentPageItemCount === 6 && current.state.nextKind === 'pageNext' && current.state.settled, + '5077 TAKE IN'); + case 'page-next-5077': { + if (!Number.isInteger(options.pageIndex) || options.pageIndex < 1 || options.pageIndex > 19) { + fail('page-next-5077 requires --page-index 1..19.', 'INVALID_ARGUMENT'); + } + assertPagedManifest(initial); + if (initial.state.currentCode !== '5077' || initial.state.pageIndex !== options.pageIndex || + initial.state.pageCount !== 20 || initial.state.phase !== 'program' || initial.state.nextKind !== 'pageNext') { + fail(`Page NEXT ${options.pageIndex} requires 5077 page ${options.pageIndex}/20 on PROGRAM.`, 'ACTIVE_ROUTE_MISMATCH'); + } + return await clickPlayout(client, 'playout-next', + (current) => current.state.phase === 'program' && current.state.currentCode === '5077' && + current.state.currentCueIndex === 0 && current.state.pageIndex === options.pageIndex + 1 && + current.state.pageCount === 20 && current.state.pageSize === 6 && current.state.itemCount === 120 && + current.state.currentPageItemCount === 6 && current.state.settled, + `5077 Page NEXT ${options.pageIndex}/20 -> ${options.pageIndex + 1}/20`); + } + case 'next-5088': + assertPagedManifest(initial); + if (initial.state.phase !== 'program' || initial.state.currentCode !== '5077' || + initial.state.pageIndex !== 20 || initial.state.pageCount !== 20 || initial.state.nextKind !== 'playlistNext') { + fail('NEXT requires 5077 page 20/20 and playlistNext.', 'ACTIVE_ROUTE_MISMATCH'); + } + return await clickPlayout(client, 'playout-next', + (current) => current.state.phase === 'program' && current.state.currentCode === '5088' && + current.state.currentCueIndex === 1 && current.state.pageIndex === 1 && current.state.pageCount === 20 && + current.state.pageSize === 12 && current.state.itemCount === 240 && + current.state.currentPageItemCount === 12 && current.state.settled, + 'NEXT 5077 page 20 to 5088 page 1'); + case 'take-out-5088': + assertPagedManifest(initial); + if (initial.state.phase !== 'program' || initial.state.currentCode !== '5088' || + initial.state.currentCueIndex !== 1) { + fail('TAKE OUT requires 5088 on PROGRAM.', 'ACTIVE_ROUTE_MISMATCH'); + } + return await clickPlayout(client, 'playout-take-out', + (current) => current.state.phase === 'idle' && current.state.currentCode == null && + current.state.preparedCode == null && current.state.currentCueIndex === -1 && current.state.settled, + '5088 TAKE OUT'); + default: fail(`Unknown action: ${action}`, 'INVALID_ARGUMENT'); + } +} + +const options = parseArguments(process.argv); +let client = null; +let target = null; +try { + target = await openTarget(options.port); + client = new CdpClient(target.webSocketDebuggerUrl); + await client.connect(); + await client.send('Runtime.enable'); + const probe = await installOrVerifyProbe(client); + const result = await runAction(client, options); + process.stdout.write(`${JSON.stringify({ + ok: true, + action: options.action, + routeIndex: options.routeIndex, + pageIndex: options.pageIndex, + target: { id: target.id, title: target.title, url: target.url }, + probe, + result, + completedAtUtc: new Date().toISOString(), + })}\n`); +} catch (error) { + let finalSnapshot = null; + if (client) { + try { finalSnapshot = await snapshot(client); } catch { /* preserve the original failure */ } + } + process.stderr.write(`${JSON.stringify({ + ok: false, + action: options.action, + routeIndex: options.routeIndex, + pageIndex: options.pageIndex, + code: error.code || 'STAGE_FAILED', + message: error.message, + details: error.details, + target: target ? { id: target.id, title: target.title, url: target.url } : null, + finalSnapshot, + failedAtUtc: new Date().toISOString(), + })}\n`); + process.exitCode = 1; +} finally { + client?.close(); +} diff --git a/scripts/Test-LegacyPackageInput.mjs b/scripts/Test-LegacyPackageInput.mjs index c2054e0..58c01de 100644 --- a/scripts/Test-LegacyPackageInput.mjs +++ b/scripts/Test-LegacyPackageInput.mjs @@ -5,6 +5,7 @@ import { createHash, randomBytes } from "node:crypto"; const exactTargetUrl = "https://legacy-parity.mbn.local/index.html"; const ctrlModifier = 2; const shiftModifier = 8; +const legacyCutDoubleClickWindowMilliseconds = 500; const defaultObservationTimeoutMilliseconds = 45_000; const minimumObservationTimeoutMilliseconds = 5_000; const maximumObservationTimeoutMilliseconds = 900_000; @@ -16,6 +17,7 @@ const acceptedArgumentNames = new Set([ "screenshot", "windows-input-request", "windows-input-ack", + "recover-named-playlist-title", "timeout-ms" ]); const readOnlyForbiddenControlIds = Object.freeze([ @@ -42,6 +44,7 @@ const readOnlyAllowedOutboundMessageTypes = Object.freeze([ "select-playlist-row", "reorder-playlist-rows", "select-playlist-boundary", + "delete-selected-playlist-rows", "refresh-named-playlists" ]); const fullUiDbAllowedOutboundMessageTypes = Object.freeze([ @@ -106,7 +109,9 @@ const fullUiDbAllowedOutboundMessageTypes = Object.freeze([ "search-manual-financial-stocks", "select-manual-financial-stock", "save-manual-financial", + "save-manual-financial-raw", "delete-manual-financial", + "delete-all-manual-financial", "activate-manual-financial-action", "open-manual-list", "close-manual-list", @@ -124,6 +129,7 @@ const fullUiDbAllowedOutboundMessageTypes = Object.freeze([ "search-operator-catalog", "select-operator-catalog-result", "begin-create-theme-catalog", + "change-create-theme-market", "begin-create-expert-catalog", "search-operator-catalog-stocks", "select-operator-catalog-stock", @@ -165,6 +171,7 @@ const databaseWriteIntentMessageTypes = Object.freeze([ "delete-selected-named-playlist", "save-current-named-playlist-to", "save-manual-financial", + "save-manual-financial-raw", "delete-manual-financial", "delete-all-manual-financial", "import-manual-lists", @@ -201,6 +208,21 @@ function failUnknown(message) { throw new HarnessFailure("OUTCOME_UNKNOWN", message); } +function windowsInputExchangePath(basePath, exchangeIndex) { + if (!Number.isSafeInteger(exchangeIndex) || exchangeIndex < 1 || exchangeIndex > 2) { + failKnown("The Windows input exchange index is outside the closed contract."); + } + if (exchangeIndex === 1) return basePath; + const extension = path.extname(basePath); + return path.join( + path.dirname(basePath), + `${path.basename(basePath, extension)}.exchange-${String(exchangeIndex).padStart(2, "0")}${extension}`); +} + +function expectedWindowsInputExchangeCount(profile, scope) { + return profile === "full-ui-db" && new Set(["all", "plist"]).has(scope) ? 2 : 1; +} + function parseArguments(argv) { if (argv.length === 0 || argv.length % 2 !== 0) { failKnown( @@ -208,7 +230,10 @@ function parseArguments(argv) { "--output --screenshot " + "--windows-input-request " + "--windows-input-ack " + - "[--profile read-only|dry-run-playout|full-ui-db] [--timeout-ms ]"); + "[--profile read-only|dry-run-playout|full-ui-db] " + + "[--scope all|plist|graphe|catalog|screens] " + + "[--recover-named-playlist-title ] " + + "[--timeout-ms ]"); } const values = new Map(); @@ -309,14 +334,41 @@ function parseArguments(argv) { if (profile !== "full-ui-db" && scope !== "all") { failKnown("A non-DB-write run requires --scope all."); } + const recoverNamedPlaylistTitle = + values.get("recover-named-playlist-title") || null; + if (recoverNamedPlaylistTitle !== null && + (recoverNamedPlaylistTitle.length !== 26 || + !/^CDX_P_[0-9]{13}_[0-9a-f]{6}$/.test(recoverNamedPlaylistTitle))) { + failKnown( + "--recover-named-playlist-title must be one exact generated CDX_P fixture title."); + } + if (recoverNamedPlaylistTitle !== null && + (profile !== "full-ui-db" || !new Set(["all", "plist"]).has(scope))) { + failKnown( + "--recover-named-playlist-title requires full-ui-db scope all or plist."); + } + const windowsInputExchangeCount = expectedWindowsInputExchangeCount(profile, scope); + for (let exchangeIndex = 2; + exchangeIndex <= windowsInputExchangeCount; + exchangeIndex += 1) { + if (fs.existsSync(windowsInputExchangePath( + windowsInputRequestPath, + exchangeIndex)) || fs.existsSync(windowsInputExchangePath( + windowsInputAckPath, + exchangeIndex))) { + failKnown("A derived Windows input exchange path already exists; evidence is never overwritten."); + } + } return { profile, scope, + recoverNamedPlaylistTitle, port, outputPath, screenshotPath, windowsInputRequestPath, windowsInputAckPath, + windowsInputExchangeCount, timeoutMilliseconds }; } @@ -377,7 +429,9 @@ const allowedOutboundMessageTypes = fullUiDbProfile ...readOnlyAllowedOutboundMessageTypes, "take-in", "next-playout", - "take-out" + "take-out", + "set-all-playlist-enabled", + "toggle-active-playlist-enabled" ]) : readOnlyAllowedOutboundMessageTypes; const hardDeadlineEpochMilliseconds = Date.now() + configuration.timeoutMilliseconds; @@ -432,26 +486,42 @@ const evidence = { screenChecks: [], databaseChecks: [], screenInventory: [], + namedPlaylistRecovery: configuration.recoverNamedPlaylistTitle ? { + targetTitle: configuration.recoverNamedPlaylistTitle, + result: "pending", + exactMatchCount: null, + freshListVerified: false, + writeCount: 0, + mutationOutcome: null, + exactAbsenceVerified: false + } : null, cleanupVerified: false } : null, dryRunPlayout: dryRunPlayoutProfile ? { result: "PENDING", stagedEntryId: null, selectedEntryId: null, + skippedEntryId: null, nextEntryId: null, programCutSelection: null, playlistCountBeforeSelection: null, - playlistCountAfterDoubleClick: null + playlistCountAfterDoubleClick: null, + tailAppendedEntryId: null, + programEnableParity: null, + shortcutParity: null } : null, checkpoints: [], inputs: [], warnings: [], windowsInput: { required: true, + expectedExchangeCount: configuration.windowsInputExchangeCount, + completedExchangeCount: 0, requestPath: configuration.windowsInputRequestPath, acknowledgementPath: configuration.windowsInputAckPath, requestSha256: null, acknowledgementSha256: null, + exchanges: [], result: "PENDING" }, screenshot: null, @@ -761,6 +831,7 @@ function probeSnapshotExpression() { safety: { postMessageWrapped: probe?.postMessageWrapper != null && window.chrome?.webview?.postMessage === probe.postMessageWrapper, + outboundMessageCount: probe?.outboundMessageCount ?? null, outboundMessages: (probe?.outboundMessages || []).map(message => ({ ...message })), approvedDatabaseWriteMessages: (probe?.approvedDatabaseWriteMessages || []).map(message => ({ ...message })), @@ -827,9 +898,12 @@ function probeSnapshotExpression() { title: definition.title, isSelected: definition.isSelected })), + isListTruncated: state.namedPlaylist.isListTruncated, selectedDefinitionId: state.namedPlaylist.selectedDefinitionId, selectedTitle: state.namedPlaylist.selectedTitle, rowCount: state.namedPlaylist.rowCount, + listFreshness: state.namedPlaylist.listFreshness, + documentFreshness: state.namedPlaylist.documentFreshness, isWriteInProgress: state.namedPlaylist.isWriteInProgress, isWriteQuarantined: state.namedPlaylist.isWriteQuarantined, canMutate: state.namedPlaylist.canMutate, @@ -902,6 +976,11 @@ function probeSnapshotExpression() { active: row.dataset.active === "true", current: row.getAttribute("aria-current") === "true", enabled: row.querySelector("input[type='checkbox']")?.checked === true, + checkboxDisabled: + row.querySelector("input[type='checkbox']")?.disabled === true, + checkboxAriaDisabled: + row.querySelector("input[type='checkbox']") + ?.getAttribute("aria-disabled") === "true", dragging: row.classList.contains("dragging"), dragBefore: row.classList.contains("drag-before"), dragAfter: row.classList.contains("drag-after"), @@ -945,6 +1024,23 @@ function probeSnapshotExpression() { "named-playlist-confirm-button")?.disabled === true, namedDefinitionCount: document.querySelectorAll( "#named-playlist-definitions button[data-named-playlist-definition-id]").length, + playlistEnableAll: { + checked: document.getElementById("playlist-enable-all")?.checked === true, + indeterminate: + document.getElementById("playlist-enable-all")?.indeterminate === true, + disabled: document.getElementById("playlist-enable-all")?.disabled === true + }, + operatorCatalogStockQueryValue: + document.getElementById("operator-catalog-stock-query")?.value ?? null, + operatorCatalogNameValue: + document.getElementById("operator-catalog-name")?.value ?? null, + operatorCatalogEditorMarketValue: + document.getElementById("operator-catalog-editor-market")?.value ?? null, + operatorCatalogStockResultCount: document.querySelectorAll( + "#operator-catalog-stock-results " + + "button[data-operator-catalog-stock-result-id]").length, + operatorCatalogStatusText: + (document.getElementById("operator-catalog-status")?.textContent || "").trim(), activeElement: active ? { id: active.id || null, tagName: active.tagName, @@ -1233,12 +1329,12 @@ function assertWorkspaceAndScheduleGeometry(layout, label) { } const widthRatio = layout.region.width / layout.schedule.width; const workspaceShare = layout.region.width / layout.shell.width; - if (!Number.isFinite(widthRatio) || widthRatio < 1.85 || widthRatio > 2.15 || + if (!Number.isFinite(widthRatio) || widthRatio < 1.60 || widthRatio > 1.90 || !Number.isFinite(workspaceShare) || workspaceShare < 0.62 || workspaceShare > 0.70 || Math.abs(layout.schedule.right - layout.shell.right) > 1.5 || Math.abs(layout.schedule.top - layout.shell.top) > 1.5 || Math.abs(layout.schedule.bottom - layout.shell.bottom) > 1.5) { - failKnown(`${label} does not preserve the approximately 2:1 workspace/schedule split.`); + failKnown(`${label} does not preserve the approximately 1.75:1 workspace/schedule split.`); } return { widthRatio, workspaceShare }; } @@ -1751,26 +1847,216 @@ async function dragMarketTab(sourceTabId, targetTabId, label) { return { completed, layout: completedLayout, beforeOrder, expectedOrder }; } +async function dragOperatorCatalogRow(sourceRowId, targetRowId, position, label) { + if (position !== "before" && position !== "after") { + failKnown(`${label} has an invalid operator-catalog drop position.`); + } + + let current = await readSnapshot(); + if (current.state.operatorCatalog?.activeDraftRowId !== sourceRowId) { + await pointerClick( + `document.querySelector('[data-operator-catalog-row-id="${sourceRowId}"] strong')`, + `${label} activate source row`); + current = await waitFor(`${label} active source row`, snapshot => + snapshot.state.operatorCatalog?.activeDraftRowId === sourceRowId, + { timeoutMilliseconds: 30_000, allowUiBusy: true }); + } + await waitForPointerReady(label); + + const beforeOrder = (current.state.operatorCatalog?.draftRows || []) + .map(row => row.rowId); + const sourceIndex = beforeOrder.indexOf(sourceRowId); + const targetIndex = beforeOrder.indexOf(targetRowId); + if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) { + failKnown(`${label} does not have two distinct operator-catalog draft rows.`); + } + const expectedOrder = beforeOrder.filter(rowId => rowId !== sourceRowId); + const remainingTargetIndex = expectedOrder.indexOf(targetRowId); + expectedOrder.splice( + remainingTargetIndex + (position === "after" ? 1 : 0), + 0, + sourceRowId); + const source = await elementGeometry( + `document.querySelector('[data-operator-catalog-row-id="${sourceRowId}"] strong')`, + `${label} source`); + const target = await elementGeometry( + `document.querySelector('[data-operator-catalog-row-id="${targetRowId}"]')`, + `${label} target`, + position === "before" ? 0.25 : 0.75); + const before = await readSnapshot(); + assertSafety(before, `${label} preflight`, false); + evidence.inputs.push({ + type: "operator-catalog-row-drag", + label, + sourceRowId, + targetRowId, + position, + source, + target, + beforeOrder, + expectedOrder + }); + + let dragCompleted = false; + let operationError = null; + let intercepted = null; + dragInterceptionEnabled = true; + try { + await rpc("Input.setInterceptDrags", { enabled: true }); + intercepted = waitForDragIntercept(label); + await pressMouse(source, 0); + pointerReleasePoint = source; + await moveMouse({ x: source.x + 10, y: source.y }, 0, 1); + const dragData = await intercepted; + activeInterceptedDragData = dragData; + const rowItem = dragData?.items?.find(item => + item.mimeType === "text/x-mbn-operator-catalog-row"); + if (!rowItem || rowItem.data !== sourceRowId) { + failKnown(`${label} did not preserve the draft row identity in drag data.`); + } + await rpc("Input.dispatchDragEvent", { + type: "dragEnter", + x: target.x, + y: target.y, + data: dragData + }); + await rpc("Input.dispatchDragEvent", { + type: "dragOver", + x: target.x, + y: target.y, + data: dragData + }); + await sleep(100); + await rpc("Input.dispatchDragEvent", { + type: "drop", + x: target.x, + y: target.y, + data: dragData + }); + activeInterceptedDragData = null; + dragCompleted = true; + await releaseMouse(target, 0); + } catch (error) { + operationError = error; + throw error; + } finally { + if (pendingDragIntercept) { + cancelPendingDragIntercept( + `${label} ended before Chromium supplied intercepted drag data.`); + } + if (intercepted) await intercepted.catch(() => null); + try { + await cleanupDragInputSession(!dragCompleted); + } catch (cleanupError) { + if (!operationError) throw cleanupError; + } + } + + return await waitFor(`${label} native reorder`, snapshot => { + const order = (snapshot.state.operatorCatalog?.draftRows || []) + .map(row => row.rowId); + return snapshot.safety.outboundMessages.length === + before.safety.outboundMessages.length + 1 && + snapshot.safety.outboundMessages.at(-1)?.type === + "reorder-operator-catalog-draft-row" && + order.length === expectedOrder.length && + order.every((rowId, index) => rowId === expectedOrder[index]); + }, { timeoutMilliseconds: 60_000, allowUiBusy: true }); +} + const keyDefinitions = Object.freeze({ ArrowDown: { key: "ArrowDown", code: "ArrowDown", virtualKey: 40 }, ArrowUp: { key: "ArrowUp", code: "ArrowUp", virtualKey: 38 }, Backspace: { key: "Backspace", code: "Backspace", virtualKey: 8 }, + Delete: { key: "Delete", code: "Delete", virtualKey: 46 }, End: { key: "End", code: "End", virtualKey: 35 }, Enter: { key: "Enter", code: "Enter", virtualKey: 13 }, Escape: { key: "Escape", code: "Escape", virtualKey: 27 }, + F8: { key: "F8", code: "F8", virtualKey: 119 }, Home: { key: "Home", code: "Home", virtualKey: 36 }, Space: { key: " ", code: "Space", virtualKey: 32 }, Tab: { key: "Tab", code: "Tab", virtualKey: 9 }, SelectAll: { key: "a", code: "KeyA", virtualKey: 65 } }); -async function pressKey(definitionName, label, modifiers = 0) { +function authorizeDryRunPlayoutShortcut(definitionName, snapshot, label) { + if (!dryRunPlayoutProfile || !new Set(["F8", "Escape"]).has(definitionName)) { + failKnown(`${label} is not an authorized DryRun playout shortcut.`); + } + const expectedPhase = definitionName === "F8" ? "idle" : "program"; + assertSafety( + snapshot, + `${label} authorization`, + false, + false, + expectedPhase === "program"); + const playout = snapshot.state.playout; + if (playout.phase !== expectedPhase || playout.isCommandAvailable !== true || + playout.isBusy === true || playout.outcomeUnknown === true || + playout.isPlayCompletionPending === true || + playout.isTakeOutCompletionPending === true || + snapshot.state.isBusy === true || + snapshot.dom.legacyDialogHidden !== true || + snapshot.dom.namedModalHidden !== true || + snapshot.dom.namedConfirmationHidden !== true || + snapshot.dom.manualFinancialModalHidden !== true || + snapshot.dom.manualListModalHidden !== true || + snapshot.dom.operatorCatalogModalHidden !== true || + pointerIsPressed || dragInterceptionEnabled || activeInterceptedDragData) { + failUnknown(`${label} does not have an exact known-safe DryRun state.`); + } + const authorization = { + definitionName, + expectedPhase, + issuedAt: Date.now(), + stateRevision: snapshot.state.revision, + consumed: false + }; + evidence.inputs.push({ + type: "dry-run-shortcut-authorization", + label, + key: definitionName, + expectedPhase, + stateRevision: snapshot.state.revision + }); + return authorization; +} + +function consumeDryRunPlayoutShortcutAuthorization( + definitionName, + authorization, + label) { + if (!dryRunPlayoutProfile || !authorization || authorization.consumed === true || + authorization.definitionName !== definitionName || + authorization.expectedPhase !== (definitionName === "F8" ? "idle" : "program") || + !Number.isSafeInteger(authorization.stateRevision) || + !Number.isFinite(authorization.issuedAt) || + Date.now() - authorization.issuedAt > 5_000) { + failKnown(`${label} lacks a fresh one-shot DryRun shortcut authorization.`); + } + // Consume before dispatch. A partial key dispatch is never retried with the + // same token, preserving the harness' OutcomeUnknown/no-duplicate boundary. + authorization.consumed = true; +} + +async function pressKey(definitionName, label, modifiers = 0, options = {}) { const definition = keyDefinitions[definitionName]; if (!definition) failKnown(`Unknown key definition ${definitionName}.`); - if (new Set(["F2", "F3", "F8"]).has(definition.key)) { + if (new Set(["F2", "F3"]).has(definition.key)) { failKnown(`The harness refused playout shortcut ${definition.key}.`); } - if (definition.key === "Escape") { + if (definition.key === "F8") { + consumeDryRunPlayoutShortcutAuthorization( + definitionName, + options.dryRunPlayoutShortcutAuthorization, + label); + } else if (definition.key === "Escape" && + options.dryRunPlayoutShortcutAuthorization) { + consumeDryRunPlayoutShortcutAuthorization( + definitionName, + options.dryRunPlayoutShortcutAuthorization, + label); + } else if (definition.key === "Escape") { const modalState = await evaluate(`(() => ({ named: document.getElementById("named-playlist-modal")?.hidden === false, confirmation: document.getElementById("named-playlist-confirmation")?.hidden === false @@ -1871,10 +2157,39 @@ async function respondToNativeDialog(label, confirmation, accept) { return opened; } -async function pointerDoubleClick(expression, label, modifiers = 0) { +async function pointerClickWithNativeDialog( + expression, + label, + confirmation, + message, + accept) { + await pointerClick(expression, label); + const opened = await respondToNativeDialog(label, confirmation, accept); + if (opened.state?.dialog?.message !== message || + opened.dom.legacyDialogText !== message) { + failKnown(`${label} native dialog text did not match the original.`); + } + return opened; +} + +async function pointerDoubleClick( + expression, + label, + modifiers = 0, + interClickDelayMilliseconds = 50) { + if (!Number.isInteger(interClickDelayMilliseconds) || + interClickDelayMilliseconds < 0 || interClickDelayMilliseconds > 1_000) { + failKnown(`${label} has an invalid double-click interval.`); + } await waitForPointerReady(label); const point = await elementGeometry(expression, label); - evidence.inputs.push({ type: "pointer-double-click", label, modifiers, point }); + evidence.inputs.push({ + type: "pointer-double-click", + label, + modifiers, + interClickDelayMilliseconds, + point + }); await moveMouse(point, modifiers, 0); for (const clickCount of [1, 2]) { await rpc("Input.dispatchMouseEvent", { @@ -1897,7 +2212,7 @@ async function pointerDoubleClick(expression, label, modifiers = 0) { modifiers, pointerType: "mouse" }); - if (clickCount === 1) await sleep(50); + if (clickCount === 1) await sleep(interClickDelayMilliseconds); } } @@ -1956,6 +2271,21 @@ function activePlaylistId(snapshot) { return snapshot.state.playlist.find(row => row.isActive)?.rowId || null; } +function outboundMessagesAfter(snapshot, sequence) { + if (!Number.isSafeInteger(sequence) || sequence < 0) { + failKnown("An outbound-message sequence baseline is invalid."); + } + return (snapshot.safety?.outboundMessages || []) + .filter(message => Number(message.sequence) > sequence); +} + +function assertExactOutboundTypes(snapshot, sequence, expectedTypes, label) { + const actual = outboundMessagesAfter(snapshot, sequence) + .map(message => message.type); + assertExactArray(actual, expectedTypes, `${label} outbound intents`); + return outboundMessagesAfter(snapshot, sequence); +} + function focusedCutIndex(snapshot) { return snapshot.state.cutRows.find(row => row.isFocused)?.physicalIndex ?? null; } @@ -2156,6 +2486,52 @@ async function playlistCheckboxGeometry(rowId, label) { })()`, label); } +async function disabledPlaylistCheckboxGeometry(rowId, label) { + const geometry = await evaluate(`(() => { + const row = Array.from(document.querySelectorAll( + "#playlist-rows .playlist-data-row")) + .find(candidate => candidate.dataset.rowId === ${JSON.stringify(rowId)}); + const checkbox = row?.querySelector("input[type='checkbox']") || null; + if (!checkbox) return null; + checkbox.scrollIntoView({ block: "nearest", inline: "nearest" }); + const rectangle = checkbox.getBoundingClientRect(); + return { + x: rectangle.left + rectangle.width / 2, + y: rectangle.top + rectangle.height / 2, + left: rectangle.left, + top: rectangle.top, + right: rectangle.right, + bottom: rectangle.bottom, + width: rectangle.width, + height: rectangle.height, + disabled: checkbox.disabled === true, + ariaDisabled: checkbox.getAttribute("aria-disabled") === "true" + }; + })()`); + if (!geometry || (!geometry.disabled && !geometry.ariaDisabled) || + !Number.isFinite(geometry.x) || !Number.isFinite(geometry.y) || + geometry.width <= 0 || geometry.height <= 0 || + geometry.right <= 0 || geometry.bottom <= 0) { + failKnown(`${label} is not a rendered disabled PROGRAM checkbox.`); + } + return geometry; +} + +async function pointerClickDisabledPlaylistCheckbox(rowId, label) { + await waitForPointerReady(label, false, true); + const point = await disabledPlaylistCheckboxGeometry(rowId, label); + evidence.inputs.push({ + type: "disabled-playlist-checkbox-click", + label, + rowId, + point + }); + await pressMouse(point, 0); + await sleep(25); + await releaseMouse(point, 0); + return point; +} + async function pointerClickPlaylistRow(rowId, label, modifiers = 0) { const point = await rowCellGeometry(rowId, label); evidence.inputs.push({ type: "pointer-click", label, modifiers, point, rowId }); @@ -2260,11 +2636,84 @@ function readSmallJsonFile(filePath, label) { } } +async function publishWindowsInputRequest(request, label, acknowledgementContract) { + const exchangeIndex = evidence.windowsInput.exchanges.length + 1; + if (request.exchangeIndex !== exchangeIndex || + exchangeIndex > configuration.windowsInputExchangeCount) { + failKnown(`${label} is outside the ordered Windows input exchange budget.`); + } + const requestPath = windowsInputExchangePath( + configuration.windowsInputRequestPath, + exchangeIndex); + const acknowledgementPath = windowsInputExchangePath( + configuration.windowsInputAckPath, + exchangeIndex); + if (fs.existsSync(requestPath) || fs.existsSync(acknowledgementPath)) { + failKnown(`${label} would overwrite an existing Windows input exchange.`); + } + + const requestBytes = Buffer.from(`${JSON.stringify(request, null, 2)}\n`, "utf8"); + fs.writeFileSync(requestPath, requestBytes, { flag: "wx" }); + evidence.inputs.push({ + type: "windows-send-input-request", + label, + exchangeIndex, + operation: request.operation + }); + + while (!fs.existsSync(acknowledgementPath)) { + await sleep(50); + } + const acknowledgement = readSmallJsonFile( + acknowledgementPath, + `Windows input acknowledgement ${exchangeIndex}`); + const ack = acknowledgement.value; + const expectedAcknowledgement = { + schemaVersion: 1, + exchangeIndex, + token: request.token, + result: "PASS", + operation: request.operation, + foregroundValidated: true, + packageIdentityValidated: true, + inputRetryCount: 0, + ...acknowledgementContract + }; + const actualKeys = Object.keys(ack || {}).sort(); + const expectedKeys = Object.keys(expectedAcknowledgement).sort(); + if (!ack || JSON.stringify(actualKeys) !== JSON.stringify(expectedKeys) || + expectedKeys.some(key => ack[key] !== expectedAcknowledgement[key])) { + failKnown(`${label} acknowledgement does not match the exact one-shot request.`); + } + + const exchange = { + exchangeIndex, + operation: request.operation, + requestPath, + acknowledgementPath, + requestSha256: sha256(requestBytes), + acknowledgementSha256: sha256(acknowledgement.bytes), + result: "PASS" + }; + evidence.windowsInput.exchanges.push(exchange); + evidence.windowsInput.completedExchangeCount = exchangeIndex; + if (exchangeIndex === 1) { + evidence.windowsInput.requestSha256 = exchange.requestSha256; + evidence.windowsInput.acknowledgementSha256 = exchange.acknowledgementSha256; + } + evidence.windowsInput.result = exchangeIndex === configuration.windowsInputExchangeCount + ? "PASS" + : "PENDING"; + return { ack, exchange }; +} + async function requestWindowsInput( current, sourceRowId, targetRowId, expectedFirstRowId, + rangeStartRowId, + rangeEndRowId, label) { assertSafety(current, `${label} preflight`, false); const preflightSignature = JSON.stringify({ @@ -2290,15 +2739,32 @@ async function requestWindowsInput( current = stabilized; const source = await playlistRowHeaderGeometry(sourceRowId, `${label} source`); const target = await playlistRowHeaderGeometry(targetRowId, `${label} target`, 0.75); + const rangeStart = await rowCellGeometry(rangeStartRowId, `${label} range start`); + const rangeEnd = await rowCellGeometry(rangeEndRowId, `${label} range end`); + const restoreSelection = await rowCellGeometry( + targetRowId, + `${label} future restored-selection position`); await sleep(100); const geometryGuard = await readSnapshot(); const guardedSource = await playlistRowHeaderGeometry( sourceRowId, `${label} guarded source`); const guardedTarget = await playlistRowHeaderGeometry( targetRowId, `${label} guarded target`, 0.75); + const guardedRangeStart = await rowCellGeometry( + rangeStartRowId, + `${label} guarded range start`); + const guardedRangeEnd = await rowCellGeometry( + rangeEndRowId, + `${label} guarded range end`); + const guardedRestoreSelection = await rowCellGeometry( + targetRowId, + `${label} guarded future restored-selection position`); const geometryStable = ["x", "y", "left", "top", "right", "bottom"] .every(key => Math.abs(source[key] - guardedSource[key]) <= 0.5 && - Math.abs(target[key] - guardedTarget[key]) <= 0.5); + Math.abs(target[key] - guardedTarget[key]) <= 0.5 && + Math.abs(rangeStart[key] - guardedRangeStart[key]) <= 0.5 && + Math.abs(rangeEnd[key] - guardedRangeEnd[key]) <= 0.5 && + Math.abs(restoreSelection[key] - guardedRestoreSelection[key]) <= 0.5); const guardSignature = JSON.stringify({ revision: geometryGuard.state.revision, order: geometryGuard.state.playlist.map(row => row.rowId), @@ -2312,84 +2778,111 @@ async function requestWindowsInput( current = geometryGuard; const token = randomBytes(16).toString("hex").toUpperCase(); const beforeOrder = current.state.playlist.map(row => row.rowId); - const afterOrder = beforeOrder.filter(rowId => rowId !== sourceRowId); - const targetIndex = afterOrder.indexOf(targetRowId); + const reorderedOrder = beforeOrder.filter(rowId => rowId !== sourceRowId); + const targetIndex = reorderedOrder.indexOf(targetRowId); if (targetIndex < 0 || sourceRowId === targetRowId || - new Set(beforeOrder).size !== beforeOrder.length) { + beforeOrder.length !== 5 || new Set(beforeOrder).size !== beforeOrder.length || + rangeStartRowId === rangeEndRowId || + !beforeOrder.includes(rangeStartRowId) || !beforeOrder.includes(rangeEndRowId)) { failKnown(`${label} row identities are not safe for a one-row move.`); } - afterOrder.splice(targetIndex + 1, 0, sourceRowId); - if (afterOrder[0] !== expectedFirstRowId) { + reorderedOrder.splice(targetIndex + 1, 0, sourceRowId); + if (reorderedOrder[0] !== expectedFirstRowId || + reorderedOrder.at(-2) !== rangeStartRowId || + reorderedOrder.at(-1) !== rangeEndRowId) { failKnown(`${label} computed an unexpected Home boundary row.`); } + const afterOrder = reorderedOrder.filter(rowId => + rowId !== rangeStartRowId && rowId !== rangeEndRowId); + if (afterOrder.length !== 3 || !afterOrder.includes(sourceRowId)) { + failKnown(`${label} did not compute an exact two-row cleanup result.`); + } + const outboundSequence = current.safety.outboundMessages.length; const request = { schemaVersion: 1, + exchangeIndex: 1, token, targetUrl: exactTargetUrl, - operation: "row-header-drag-after-then-home", + operation: "row-header-drag-home-shift-range-delete-restore", sourceRowId, targetRowId, + rangeStartRowId, + rangeEndRowId, position: "after", source: { x: source.x, y: source.y }, target: { x: target.x, y: target.y }, + rangeStart: { x: rangeStart.x, y: rangeStart.y }, + rangeEnd: { x: rangeEnd.x, y: rangeEnd.y }, + restoreSelection: { x: restoreSelection.x, y: restoreSelection.y }, viewport: { ...current.dom.viewport }, devicePixelRatio: current.devicePixelRatio, beforeOrder, + reorderedOrder, afterOrder, + expectedRangeSelectedRowIds: [rangeStartRowId, rangeEndRowId], expectedSelectedRowId: sourceRowId, expectedActiveRowIdAfterHome: expectedFirstRowId, + expectedFinalActiveRowId: sourceRowId, expectedFocusedRowId: sourceRowId, - mouseDownCalls: 1, - mouseUpCalls: 1, - homeKeyCalls: 1 + mouseDownCalls: 4, + mouseUpCalls: 4, + homeKeyCalls: 1, + shiftKeyDownCalls: 1, + shiftKeyUpCalls: 1, + deleteKeyCalls: 1, + inputRetryCount: 0 }; - const requestBytes = Buffer.from(`${JSON.stringify(request, null, 2)}\n`, "utf8"); - fs.writeFileSync(configuration.windowsInputRequestPath, requestBytes, { flag: "wx" }); - evidence.windowsInput.requestSha256 = sha256(requestBytes); - evidence.inputs.push({ - type: "windows-send-input-request", - label, - sourceRowId, - targetRowId, - position: "after" + await publishWindowsInputRequest(request, label, { + mouseDownCalls: 4, + mouseUpCalls: 4, + homeKeyCalls: 1, + shiftKeyDownCalls: 1, + shiftKeyUpCalls: 1, + deleteKeyCalls: 1 }); - while (!fs.existsSync(configuration.windowsInputAckPath)) { - await sleep(50); - } - const acknowledgement = readSmallJsonFile( - configuration.windowsInputAckPath, - "Windows input acknowledgement"); - const ack = acknowledgement.value; - if (!ack || ack.schemaVersion !== 1 || ack.token !== token || - ack.result !== "PASS" || ack.operation !== request.operation || - ack.foregroundValidated !== true || ack.packageIdentityValidated !== true || - ack.mouseDownCalls !== 1 || ack.mouseUpCalls !== 1 || - ack.homeKeyCalls !== 1 || ack.inputRetryCount !== 0) { - failKnown("Windows input acknowledgement does not match the one-shot request."); - } - evidence.windowsInput.acknowledgementSha256 = sha256(acknowledgement.bytes); - const completed = await waitFor(label, snapshot => JSON.stringify(snapshot.state.playlist.map(row => row.rowId)) === JSON.stringify(afterOrder) && JSON.stringify(selectedPlaylistIds(snapshot)) === JSON.stringify([sourceRowId]) && - activePlaylistId(snapshot) === expectedFirstRowId && + activePlaylistId(snapshot) === sourceRowId && snapshot.dom.activeElement?.playlistRowId === sourceRowId, { timeoutMilliseconds: Math.min(configuration.timeoutMilliseconds, 15_000), allowUiBusy: false }); - evidence.windowsInput.result = "PASS"; + const messages = assertExactOutboundTypes(completed, outboundSequence, [ + "activate-playlist-row", + "reorder-playlist-rows", + "select-playlist-boundary", + "select-playlist-row", + "select-playlist-row", + "delete-selected-playlist-rows", + "select-playlist-row" + ], `${label} exact physical gesture sequence`); + const selections = messages.filter(message => message.type === "select-playlist-row"); + if (selections.length !== 3 || + selections[0].rowId !== rangeStartRowId || selections[0].control !== false || + selections[0].shift !== false || + selections[1].rowId !== rangeEndRowId || selections[1].control !== false || + selections[1].shift !== true || + selections[2].rowId !== sourceRowId || selections[2].control !== false || + selections[2].shift !== false) { + failKnown(`${label} did not preserve the exact plain/Shift/plain selection sequence.`); + } evidence.windowsInput.beforeOrder = beforeOrder; evidence.windowsInput.afterOrder = afterOrder; evidence.windowsInput.activeRowIdAfterHome = expectedFirstRowId; - await checkpoint("windows-sendinput-row-header-drag-and-home", { + evidence.windowsInput.shiftRangeDeletedRowIds = [rangeStartRowId, rangeEndRowId]; + evidence.windowsInput.finalActiveRowId = sourceRowId; + await checkpoint("windows-sendinput-drag-home-shift-range-delete-restore", { sourceRowId, targetRowId, beforeOrder, + reorderedOrder, afterOrder, - expectedFirstRowId + expectedFirstRowId, + deletedRowIds: [rangeStartRowId, rangeEndRowId] }); return completed; } @@ -2421,6 +2914,7 @@ async function installProbe() { pointerListener: null, originalPostMessage: null, postMessageWrapper: null, + outboundMessageCount: 0, outboundMessages: [], approvedDatabaseWriteMessages: [], blockedOutboundMessages: [], @@ -2497,11 +2991,38 @@ async function installProbe() { probe.originalPostMessage = window.chrome.webview.postMessage; probe.postMessageWrapper = message => { const type = typeof message?.type === "string" ? message.type : null; + probe.outboundMessageCount += 1; const record = { + sequence: probe.outboundMessageCount, type, at: new Date().toISOString(), allowAppend: type === "cut-pointer-down" ? message?.payload?.allowAppend ?? null + : null, + enabled: type === "set-all-playlist-enabled" + ? message?.payload?.enabled ?? null + : null, + rowId: type === "select-playlist-row" + ? message?.payload?.rowId ?? null + : null, + control: type === "select-playlist-row" + ? message?.payload?.control === true + : null, + shift: type === "select-playlist-row" + ? message?.payload?.shift === true + : null, + definitionId: new Set([ + "select-named-playlist", + "load-named-playlist-by-id" + ]).has(type) ? message?.payload?.definitionId ?? null : null, + emptyStockQuery: type === "search-operator-catalog-stocks" + ? message?.payload?.query === "" + : null, + market: type === "change-create-theme-market" + ? message?.payload?.market ?? null + : null, + editorName: type === "change-create-theme-market" + ? message?.payload?.editorName ?? null : null }; probe.outboundMessages.push(record); @@ -2826,12 +3347,37 @@ async function executeSafeSequence() { } await checkpoint("playlist-keyboard-focus-split"); + current = await dragCutsToPlaylist( + 1, + 0, + 1, + "stage first disposable Shift-delete row"); + // MainForm detects a double-click from two MouseDown events at the exact + // same cut coordinates within 500 ms, even when both gestures become drags. + // Let that original window expire so fixture setup does not append once for + // the second pointer-down and once again for its completed drop. + await sleep(legacyCutDoubleClickWindowMilliseconds + 100); + current = await dragCutsToPlaylist( + 1, + 0, + 1, + "stage second disposable Shift-delete row"); + const stagedDeleteRows = current.state.playlist.slice(-2); + if (current.state.playlist.length !== 5 || stagedDeleteRows.length !== 2 || + stagedDeleteRows.some(row => + row.graphicType !== "1열판기본" || row.subtype !== "현재가")) { + failKnown("The disposable Shift-delete fixture is not the exact two-row tail."); + } + const [rowD, rowE] = stagedDeleteRows.map(row => row.rowId); + current = await requestWindowsInput( current, rowB, rowA, rowC, - "Windows SendInput row-header drag and Home key"); + rowD, + rowE, + "Windows SendInput drag, Home, Shift range, Delete, and restore"); await pointerClick( 'document.getElementById("db-load-button")', @@ -2980,20 +3526,37 @@ async function executeDryRunPlayoutSequence() { failKnown("DryRun playout preflight lacks the exact legacy selection split."); } - // The preceding interaction sequence deliberately leaves two distinct current- - // quote rows adjacent. After-hours/expected quote tables are session-dependent; - // the original application also rejects those rows when no exact quote exists. - // Use the two current rows so this check isolates NEXT row semantics from market - // session data availability. - const stagedEntryId = current.state.playlist[2].rowId; - const selectedEntryId = current.state.playlist[0].rowId; - const nextEntryId = current.state.playlist[1].rowId; + // The preceding interaction sequence leaves current/current/after-hours rows. + // Physically move the session-dependent after-hours row between the two current + // rows, TAKE IN the first current row, then disable the after-hours successor + // with Space. NEXT must skip it and reach the second current row. Restore the + // original order after TAKE OUT so the outer evidence seal remains deterministic. + const originalOrder = current.state.playlist.map(row => row.rowId); + if (current.state.playlist[0].subtype !== "현재가" || + current.state.playlist[1].subtype !== "현재가" || + current.state.playlist[2].subtype !== "시간외단일가") { + failKnown("DryRun NEXT fixture is not current/current/after-hours."); + } + const selectedEntryId = originalOrder[0]; + const nextEntryId = originalOrder[1]; + const skippedEntryId = originalOrder[2]; + const stagedEntryId = skippedEntryId; const playlistCount = current.state.playlist.length; evidence.dryRunPlayout.stagedEntryId = stagedEntryId; evidence.dryRunPlayout.selectedEntryId = selectedEntryId; + evidence.dryRunPlayout.skippedEntryId = skippedEntryId; evidence.dryRunPlayout.nextEntryId = nextEntryId; evidence.dryRunPlayout.playlistCountBeforeSelection = playlistCount; + await dragPlaylistRow( + skippedEntryId, + selectedEntryId, + "place after-hours row before the known NEXT current row"); + current = await waitFor("DryRun session-dependent row staging", snapshot => + JSON.stringify(snapshot.state.playlist.map(row => row.rowId)) === + JSON.stringify([selectedEntryId, skippedEntryId, nextEntryId]), + { allowUiBusy: false }); + // Move the F8 candidate away from the target first. If the following rapid row // click were dropped, TAKE IN would remain on this distinct staged row and the // selected-row assertion below would fail deterministically. @@ -3006,17 +3569,23 @@ async function executeDryRunPlayoutSequence() { JSON.stringify([stagedEntryId]), { allowUiBusy: false }); - // Keep the real user timing: release the row click and immediately press TAKE IN, - // without waiting for the row-selection state receipt. The native ordered intent - // gate must preserve this input order instead of silently dropping F8/TAKE IN. + // Keep the real user timing: authorize F8 from the exact known IDLE snapshot, + // release the row click, and immediately dispatch the physical key without a + // state read in between. The one-shot authorization cannot be reused if either + // key event becomes ambiguous. + const takeInIntentBaseline = current.safety.outboundMessageCount; + const f8Authorization = authorizeDryRunPlayoutShortcut( + "F8", + current, + "rapid DryRun F8"); await pointerClickPlaylistRow( selectedEntryId, "rapid select playlist row before DryRun TAKE IN"); - await pointerClick( - 'document.getElementById("playout-take-in")', - "rapid DryRun TAKE IN after row selection", + await pressKey( + "F8", + "rapid DryRun F8 after row selection", 0, - { skipPointerReady: true }); + { dryRunPlayoutShortcutAuthorization: f8Authorization }); current = await waitFor("rapid selected-row DryRun TAKE IN", snapshot => snapshot.state.playout.phase === "program" && snapshot.state.playout.currentEntryId === selectedEntryId && @@ -3026,6 +3595,17 @@ async function executeDryRunPlayoutSequence() { if (current.state.playout.nextKind !== "playlistNext") { failKnown("DryRun TAKE IN did not expose playlist NEXT from the selected row."); } + const takeInIntents = outboundMessagesAfter(current, takeInIntentBaseline) + .filter(message => message.type === "take-in"); + if (takeInIntents.length !== 1 || + outboundMessagesAfter(current, takeInIntentBaseline).at(-1)?.type !== "take-in") { + failUnknown("DryRun F8 did not emit exactly one terminal TAKE IN intent."); + } + evidence.dryRunPlayout.shortcutParity = { + f8IntentCount: 1, + escapeIntentCount: 0, + finalPhase: "pending" + }; const selectableCut = current.state.cutRows.find(row => row.isSeparator !== true && row.isSelected !== true); @@ -3050,35 +3630,160 @@ async function executeDryRunPlayoutSequence() { await pointerClick( cutExpression, - "PROGRAM protected double-click first press", + "PROGRAM safe-tail double-click first press", 0, { allowActiveDryRun: true }); await pointerClick( cutExpression, - "PROGRAM protected double-click second press", + "PROGRAM safe-tail double-click second press", 0, { allowActiveDryRun: true }); - await sleep(150); - current = await readSnapshot(); - assertSafety(current, "PROGRAM protected double-click", false, false, true); + current = await waitFor("PROGRAM safe-tail double-click append", snapshot => + snapshot.state.playout.phase === "program" && + snapshot.state.playout.currentEntryId === selectedEntryId && + snapshot.state.playlist.length === playlistCount + 1 && + snapshot.state.isBusy !== true && + snapshot.state.playout.isBusy !== true, + { allowUiBusy: true, allowActiveDryRun: true }); + assertSafety(current, "PROGRAM safe-tail double-click", false, false, true); const recentProgramCutMessages = current.safety.outboundMessages .filter(message => message.type === "cut-pointer-down") .slice(-3); - if (current.state.playlist.length !== playlistCount || + if (current.state.playlist.length !== playlistCount + 1 || recentProgramCutMessages.length !== 3 || - recentProgramCutMessages.some(message => message.allowAppend !== false)) { - failKnown("PROGRAM cut selection changed the playlist or retained append authority."); + recentProgramCutMessages.some(message => message.allowAppend !== true)) { + failKnown("PROGRAM cut selection did not preserve safe tail-append authority."); } evidence.dryRunPlayout.playlistCountAfterDoubleClick = current.state.playlist.length; + evidence.dryRunPlayout.tailAppendedEntryId = + current.state.playlist[current.state.playlist.length - 1].rowId; + + const futureRowIds = current.state.playlist + .slice(current.state.playout.currentCueIndexZeroBased + 1) + .map(row => row.rowId); + if (futureRowIds.length < 3 || futureRowIds[0] !== skippedEntryId || + futureRowIds[1] !== nextEntryId || + futureRowIds[2] !== evidence.dryRunPlayout.tailAppendedEntryId) { + failKnown("PROGRAM enabled-state validation lacks the exact future-row tail."); + } + + let enableIntentBaseline = current.safety.outboundMessageCount; + await pointerClick( + 'document.getElementById("playlist-enable-all")', + "PROGRAM clear every enabled row", + 0, + { allowActiveDryRun: true }); + current = await waitFor("PROGRAM all rows disabled state", snapshot => { + return snapshot.state.playout.phase === "program" && + snapshot.state.playout.currentEntryId === selectedEntryId && + snapshot.state.playlist.every(row => row.isEnabled === false) && + snapshot.state.playout.nextKind === "endOfPlaylist" && + snapshot.dom.playlistEnableAll.disabled === false && + snapshot.dom.playlistEnableAll.checked === false && + snapshot.dom.playlistEnableAll.indeterminate === false && + snapshot.state.isBusy !== true && snapshot.state.playout.isBusy !== true; + }, { timeoutMilliseconds: 60_000, allowUiBusy: true, allowActiveDryRun: true }); + let enableIntents = assertExactOutboundTypes( + current, + enableIntentBaseline, + ["set-all-playlist-enabled"], + "PROGRAM clear every row"); + if (enableIntents[0].enabled !== false) { + failKnown("PROGRAM clear-all did not carry enabled=false."); + } + + enableIntentBaseline = current.safety.outboundMessageCount; + await pointerClick( + 'document.getElementById("playlist-enable-all")', + "PROGRAM restore every enabled row", + 0, + { allowActiveDryRun: true }); + current = await waitFor("PROGRAM all rows enabled state", snapshot => { + return snapshot.state.playout.phase === "program" && + snapshot.state.playout.currentEntryId === selectedEntryId && + snapshot.state.playlist.every(row => row.isEnabled === true) && + snapshot.state.playout.nextKind === "playlistNext" && + snapshot.dom.playlistEnableAll.disabled === false && + snapshot.dom.playlistEnableAll.checked === true && + snapshot.dom.playlistEnableAll.indeterminate === false && + snapshot.state.isBusy !== true && snapshot.state.playout.isBusy !== true; + }, { timeoutMilliseconds: 60_000, allowUiBusy: true, allowActiveDryRun: true }); + enableIntents = assertExactOutboundTypes( + current, + enableIntentBaseline, + ["set-all-playlist-enabled"], + "PROGRAM restore every row"); + if (enableIntents[0].enabled !== true) { + failKnown("PROGRAM restore-all did not carry enabled=true."); + } + + // Original MainForm ignored a mouse press in the checkbox column during + // PROGRAM, but the same row could be made active and toggled with Space. + // Dispatch the blocked pointer path first and prove that no per-row enabled + // intent escapes before using the focused row's real Space key event. + enableIntentBaseline = current.safety.outboundMessageCount; + await pointerClickDisabledPlaylistCheckbox( + skippedEntryId, + "PROGRAM disabled future-row mouse checkbox"); + current = await waitFor("PROGRAM disabled checkbox focus only", snapshot => { + const stateRow = snapshot.state.playlist.find(row => row.rowId === skippedEntryId); + const domRow = snapshot.dom.playlist.find(row => row.rowId === skippedEntryId); + return snapshot.state.playout.phase === "program" && + stateRow?.isEnabled === true && stateRow?.isActive === true && + domRow?.enabled === true && domRow?.checkboxAriaDisabled === true && + domRow?.focused === true && + snapshot.dom.activeElement?.playlistRowId === skippedEntryId && + snapshot.state.playout.nextKind === "playlistNext" && + snapshot.state.isBusy !== true; + }, { timeoutMilliseconds: 60_000, allowUiBusy: true, allowActiveDryRun: true }); + const disabledCheckboxMessages = outboundMessagesAfter( + current, + enableIntentBaseline); + if (disabledCheckboxMessages.some(message => + new Set([ + "set-playlist-enabled", + "set-all-playlist-enabled", + "toggle-active-playlist-enabled" + ]).has(message.type))) { + failKnown("PROGRAM mouse checkbox emitted an enabled-state mutation intent."); + } + + enableIntentBaseline = current.safety.outboundMessageCount; + await pressKey("Space", "PROGRAM future-row Space enabled toggle"); + current = await waitFor("PROGRAM future-row Space disabled state", snapshot => { + const row = snapshot.state.playlist.find(candidate => + candidate.rowId === skippedEntryId); + return snapshot.state.playout.phase === "program" && + row?.isEnabled === false && row?.isActive === true && + snapshot.state.playout.nextKind === "playlistNext" && + snapshot.state.isBusy !== true && snapshot.state.playout.isBusy !== true; + }, { timeoutMilliseconds: 60_000, allowUiBusy: true, allowActiveDryRun: true }); + assertExactOutboundTypes( + current, + enableIntentBaseline, + ["toggle-active-playlist-enabled"], + "PROGRAM future-row Space toggle"); + evidence.dryRunPlayout.programEnableParity = { + currentEntryId: selectedEntryId, + skippedEntryId, + nextEntryId, + futureRowCount: futureRowIds.length, + mouseCheckboxDisabled: true, + setAllIntentCount: 2, + setAllAppliesEveryRow: true, + toggleActiveIntentCount: 1, + endOfPlaylistObserved: true, + playlistNextRestored: true + }; await pointerClick( 'document.getElementById("playout-next")', - "DryRun NEXT from selected TAKE IN row", + "DryRun NEXT skips the Space-disabled future row", 0, { allowActiveDryRun: true }); const revisionBeforeNext = current.state.revision; - current = await waitFor("DryRun NEXT selected-row successor", snapshot => { + current = await waitFor("DryRun NEXT enabled successor after skipped row", snapshot => { if (snapshot.state.revision > revisionBeforeNext && snapshot.state.playout.isBusy !== true && snapshot.state.isBusy !== true && @@ -3088,23 +3793,56 @@ async function executeDryRunPlayoutSequence() { } return snapshot.state.playout.phase === "program" && snapshot.state.playout.currentEntryId === nextEntryId && + snapshot.state.playlist.find(row => row.rowId === skippedEntryId) + ?.isEnabled === false && snapshot.state.playout.isBusy !== true && snapshot.state.isBusy !== true; }, { allowUiBusy: true, allowActiveDryRun: true }); - await pointerClick( - 'document.getElementById("playout-take-out")', - "DryRun TAKE OUT after NEXT", + const takeOutIntentBaseline = current.safety.outboundMessageCount; + const escapeAuthorization = authorizeDryRunPlayoutShortcut( + "Escape", + current, + "DryRun Escape TAKE OUT"); + await pressKey( + "Escape", + "DryRun Escape TAKE OUT after NEXT", 0, - { allowActiveDryRun: true }); + { dryRunPlayoutShortcutAuthorization: escapeAuthorization }); current = await waitFor("DryRun TAKE OUT IDLE", snapshot => snapshot.state.playout.phase === "idle" && snapshot.state.playout.currentEntryId == null && + snapshot.state.playout.preparedCode == null && snapshot.state.playout.onAirCode == null && + snapshot.state.playout.outcomeUnknown !== true && + snapshot.state.playout.isPlayCompletionPending !== true && + snapshot.state.playout.isTakeOutCompletionPending !== true && + snapshot.state.playout.refreshActive !== true && snapshot.state.playout.isBusy !== true && snapshot.state.isBusy !== true, { allowUiBusy: true, allowActiveDryRun: true }); + const takeOutIntents = outboundMessagesAfter(current, takeOutIntentBaseline) + .filter(message => message.type === "take-out"); + if (takeOutIntents.length !== 1 || + outboundMessagesAfter(current, takeOutIntentBaseline).at(-1)?.type !== "take-out") { + failUnknown("DryRun Escape did not emit exactly one terminal TAKE OUT intent."); + } + evidence.dryRunPlayout.shortcutParity.escapeIntentCount = 1; + evidence.dryRunPlayout.shortcutParity.finalPhase = current.state.playout.phase; + + await dragPlaylistRow( + skippedEntryId, + nextEntryId, + "restore original playlist order after DryRun TAKE OUT"); + current = await waitFor("restore original playlist order after DryRun", snapshot => + snapshot.state.playout.phase === "idle" && + JSON.stringify(snapshot.state.playlist.map(row => row.rowId)) === + JSON.stringify([ + ...originalOrder, + evidence.dryRunPlayout.tailAppendedEntryId + ]), + { allowUiBusy: false }); await pointerClickPlaylistRow( originalSelectedIds[0], @@ -3126,6 +3864,7 @@ async function executeDryRunPlayoutSequence() { await checkpoint("dry-run-playout-selection-next", { stagedEntryId, selectedEntryId, + skippedEntryId, nextEntryId, programCutPhysicalIndex: selectableCut.physicalIndex, playlistCount @@ -3147,17 +3886,347 @@ function namedDefinitionExpression(title) { .find(button => button.textContent === ${JSON.stringify(title)})`; } +function playlistContentProjection(snapshot) { + return snapshot.state.playlist.map(row => ({ + isEnabled: row.isEnabled, + marketText: row.marketText, + stockName: row.stockName, + graphicType: row.graphicType, + subtype: row.subtype, + pageText: row.pageText + })); +} + +function namedPlaylistCommandSequence(snapshot) { + const sequence = snapshot?.state?.commandResult?.sequence; + return Number.isSafeInteger(sequence) ? sequence : 0; +} + +function completedNamedPlaylistRefreshAfter(snapshot, baselineSequence) { + const result = snapshot?.state?.commandResult; + return result?.command === "refresh-named-playlists" && + result.succeeded === true && Number.isSafeInteger(result.sequence) && + result.sequence > baselineSequence && + snapshot.state.namedPlaylist?.listFreshness === "fresh"; +} + +async function requestNamedPlaylistLoadDoubleClick(current, title, label) { + assertSafety(current, `${label} preflight`, false); + if (evidence.windowsInput.exchanges.length !== 1 || + configuration.windowsInputExchangeCount !== 2 || + current.dom.namedModalHidden !== false || + current.dom.namedConfirmationHidden !== true || + current.dom.namedTitle !== "DB 재생목록 불러오기" || + current.state.namedPlaylist?.listFreshness !== "fresh") { + failKnown(`${label} is outside the exact fresh PList load-modal state.`); + } + const matches = current.state.namedPlaylist.definitions + .filter(definition => definition.title === title); + if (matches.length !== 1 || typeof matches[0].definitionId !== "string" || + matches[0].definitionId.length === 0 || matches[0].definitionId.length > 256 || + /[\u0000-\u001f\u007f]/u.test(matches[0].definitionId)) { + failKnown(`${label} does not have one safe opaque saved-list identity.`); + } + const definitionId = matches[0].definitionId; + if (matches[0].isSelected === true || + current.state.namedPlaylist.selectedDefinitionId === definitionId || + current.state.namedPlaylist.selectedTitle === title) { + failKnown(`${label} must start on the freshly read, unselected saved row.`); + } + const beforeContent = playlistContentProjection(current); + if (beforeContent.length !== 3) { + failKnown(`${label} requires the exact three-row in-memory fixture.`); + } + const beforeApprovedWriteCount = current.safety.approvedDatabaseWriteMessages.length; + const outboundSequence = current.safety.outboundMessages.length; + const point = await elementGeometry(namedDefinitionExpression(title), label); + await sleep(100); + const guarded = await readSnapshot(); + const guardedPoint = await elementGeometry( + namedDefinitionExpression(title), + `${label} guarded target`); + const geometryStable = ["x", "y", "left", "top", "right", "bottom"] + .every(key => Math.abs(point[key] - guardedPoint[key]) <= 0.5); + if (!geometryStable || guarded.dom.namedModalHidden !== false || + guarded.dom.namedConfirmationHidden !== true || + guarded.state.namedPlaylist?.definitions + ?.filter(definition => definition.title === title && + definition.definitionId === definitionId && + definition.isSelected !== true).length !== 1 || + guarded.state.namedPlaylist?.selectedDefinitionId === definitionId || + guarded.state.namedPlaylist?.selectedTitle === title || + JSON.stringify(playlistContentProjection(guarded)) !== JSON.stringify(beforeContent)) { + failKnown(`${label} geometry or playlist content changed before publication.`); + } + + const token = randomBytes(16).toString("hex").toUpperCase(); + const request = { + schemaVersion: 1, + exchangeIndex: 2, + token, + targetUrl: exactTargetUrl, + operation: "named-playlist-load-double-click", + definitionId, + definitionTitle: title, + point: { x: point.x, y: point.y }, + viewport: { ...guarded.dom.viewport }, + devicePixelRatio: guarded.devicePixelRatio, + expectedPlaylistContent: beforeContent, + mouseDownCalls: 2, + mouseUpCalls: 2, + doubleClickIntervalMilliseconds: 100, + inputRetryCount: 0 + }; + await publishWindowsInputRequest(request, label, { + mouseDownCalls: 2, + mouseUpCalls: 2, + doubleClickIntervalMilliseconds: 100 + }); + + const completed = await waitFor(label, snapshot => + snapshot.state.isBusy === false && + snapshot.dom.namedModalHidden === true && + snapshot.dom.namedConfirmationHidden === true && + snapshot.state.namedPlaylist?.selectedTitle === title && + snapshot.state.namedPlaylist?.rowCount === 3 && + snapshot.state.commandResult?.command === "load-named-playlist-by-id" && + snapshot.state.commandResult?.targetId === definitionId && + snapshot.state.commandResult?.succeeded === true && + JSON.stringify(playlistContentProjection(snapshot)) === JSON.stringify(beforeContent), + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const outbound = outboundMessagesAfter(completed, outboundSequence); + const loads = outbound.filter(message => message.type === "load-named-playlist-by-id"); + const selections = outbound.filter(message => message.type === "select-named-playlist"); + assertExactArray( + outbound.map(message => message.type), + ["select-named-playlist", "load-named-playlist-by-id"], + `${label} outbound intents`); + if (loads.length !== 1 || loads[0].definitionId !== definitionId || + selections.length !== 1 || selections[0].definitionId !== definitionId || + completed.safety.approvedDatabaseWriteMessages.length !== beforeApprovedWriteCount) { + failKnown(`${label} did not select then load the clicked row with no database write.`); + } + await checkpoint("windows-sendinput-named-playlist-load-double-click", { + definitionId, + title, + rowCount: beforeContent.length, + startedUnselected: true, + selectionIntentCount: selections.length, + loadIntentCount: loads.length, + contentRestored: true + }); + return completed; +} + +function namedPlaylistDeleteWriteCount(snapshot) { + return (snapshot?.safety?.approvedDatabaseWriteMessages || []) + .filter(message => message.type === "delete-selected-named-playlist").length; +} + +function assertNamedPlaylistRecoverySafe( + snapshot, + label, + baselineWriteCount = null, + baselineCommandSequence = null) { + const named = snapshot?.state?.namedPlaylist; + if (!named) failKnown(`Named-playlist state is missing at ${label}.`); + if (named.isWriteQuarantined === true || + named.lastMutationOutcome === "outcomeUnknown" || + named.lastMutationOutcome === "quarantined") { + failUnknown(`Named-playlist writes are quarantined or unknown at ${label}; no action is retried.`); + } + if (baselineWriteCount !== null) { + const writeCount = namedPlaylistDeleteWriteCount(snapshot) - baselineWriteCount; + if (writeCount < 0 || writeCount > 1) { + failUnknown(`Named-playlist recovery emitted an invalid delete count at ${label}; no action is retried.`); + } + const commandResult = snapshot.state.commandResult; + if (writeCount === 1 && baselineCommandSequence !== null && + Number.isSafeInteger(commandResult?.sequence) && + commandResult.sequence > baselineCommandSequence && + commandResult.command === "delete-selected-named-playlist" && + commandResult.succeeded === false) { + failKnown(`Named-playlist recovery delete was rejected at ${label}; no action is retried.`); + } + } +} + +async function waitForNamedPlaylistRecovery(label, predicate, options = {}) { + return await waitFor(label, snapshot => { + assertNamedPlaylistRecoverySafe( + snapshot, + label, + options.baselineWriteCount ?? null, + options.baselineCommandSequence ?? null); + return predicate(snapshot); + }, options); +} + +async function recoverNamedPlaylistFixture() { + const title = configuration.recoverNamedPlaylistTitle; + const recovery = evidence.fullUiDb?.namedPlaylistRecovery; + if (!title || !recovery) return; + + let current = await readSnapshot(); + assertNamedPlaylistRecoverySafe(current, "PList recovery preflight"); + const initialWriteCount = namedPlaylistDeleteWriteCount(current); + if (initialWriteCount !== 0) { + failUnknown("PList recovery found a prior delete intent in this one-shot run."); + } + const recoveryRefreshBaseline = namedPlaylistCommandSequence(current); + await pointerClick( + 'document.getElementById("db-load-button")', + "PList recovery fresh list open"); + current = await waitForNamedPlaylistRecovery( + "PList recovery fresh list", + snapshot => snapshot.state.isBusy === false && + snapshot.dom.namedModalHidden === false && + snapshot.dom.namedConfirmationHidden === true && + completedNamedPlaylistRefreshAfter(snapshot, recoveryRefreshBaseline), + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + if (current.state.namedPlaylist.isListTruncated !== false) { + failKnown("PList recovery cannot prove an exact target against a truncated fresh list."); + } + recovery.freshListVerified = true; + + const exactMatches = current.state.namedPlaylist.definitions + .filter(definition => definition.title === title); + recovery.exactMatchCount = exactMatches.length; + if (exactMatches.length > 1) { + failKnown("PList recovery target is duplicated in the fresh DB list; no delete was attempted."); + } + if (current.state.namedPlaylist.canMutate !== true) { + failKnown("PList recovery requires a writable fresh named-playlist list."); + } + + if (exactMatches.length === 0) { + if (namedPlaylistDeleteWriteCount(current) !== initialWriteCount) { + failUnknown("PList recovery observed an unexpected delete intent before absence was sealed."); + } + recovery.result = "alreadyAbsent"; + recovery.writeCount = 0; + recovery.exactAbsenceVerified = true; + recordFullUiDbCheck("databaseChecks", "PList recovery already absent", { + targetTitle: title, + writeCount: 0 + }); + await pointerClick( + 'document.getElementById("named-playlist-cancel-button")', + "PList recovery already-absent close"); + current = await waitForNamedPlaylistRecovery( + "PList recovery already-absent close", + snapshot => snapshot.state.isBusy === false && + snapshot.dom.namedModalHidden === true, + { allowUiBusy: false, baselineWriteCount: initialWriteCount }); + if (namedPlaylistDeleteWriteCount(current) !== initialWriteCount) { + failUnknown("PList recovery emitted a delete after sealing an already-absent target."); + } + return; + } + + const target = exactMatches[0]; + await pointerClick(namedDefinitionExpression(title), "PList recovery exact target select"); + current = await waitForNamedPlaylistRecovery( + "PList recovery exact target selection", + snapshot => snapshot.state.isBusy === false && + snapshot.dom.namedModalHidden === false && + snapshot.state.namedPlaylist.selectedDefinitionId === target.definitionId && + snapshot.state.namedPlaylist.selectedTitle === title && + snapshot.state.namedPlaylist.canDelete === true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + + const beforeDeleteWriteCount = namedPlaylistDeleteWriteCount(current); + const beforeDeleteCommandSequence = Number.isSafeInteger( + current.state.commandResult?.sequence) + ? current.state.commandResult.sequence + : 0; + if (beforeDeleteWriteCount !== initialWriteCount) { + failUnknown("PList recovery observed an unexpected delete before confirmation."); + } + await pointerClick( + 'document.getElementById("named-playlist-delete-button")', + "PList recovery open existing delete confirmation"); + await waitForNamedPlaylistRecovery( + "PList recovery existing delete confirmation", + snapshot => snapshot.state.isBusy === false && + snapshot.dom.namedModalHidden === false && + snapshot.dom.namedConfirmationHidden === false, + { + allowUiBusy: false, + baselineWriteCount: beforeDeleteWriteCount, + baselineCommandSequence: beforeDeleteCommandSequence + }); + await pointerClick( + 'document.getElementById("named-playlist-confirmation-yes")', + "PList recovery confirm exact delete once"); + current = await waitForNamedPlaylistRecovery( + "PList recovery committed exact absence", + snapshot => { + const outcome = snapshot.state.namedPlaylist.lastMutationOutcome; + const writeCount = namedPlaylistDeleteWriteCount(snapshot) - beforeDeleteWriteCount; + const remaining = snapshot.state.namedPlaylist.definitions + .filter(definition => definition.title === title); + return snapshot.state.isBusy === false && + snapshot.state.namedPlaylist.isWriteInProgress !== true && + snapshot.state.namedPlaylist.isListTruncated === false && + snapshot.dom.namedConfirmationHidden === true && + writeCount === 1 && remaining.length === 0 && + (outcome === "committedFresh" || outcome === "committedOptimistic"); + }, + { + timeoutMilliseconds: 60_000, + allowUiBusy: true, + baselineWriteCount: beforeDeleteWriteCount, + baselineCommandSequence: beforeDeleteCommandSequence + }); + + const recoveryWriteCount = + namedPlaylistDeleteWriteCount(current) - beforeDeleteWriteCount; + const exactAbsence = !current.state.namedPlaylist.definitions + .some(definition => definition.title === title); + if (recoveryWriteCount !== 1 || !exactAbsence) { + failUnknown("PList recovery did not seal one exact delete and absence; no action is retried."); + } + recovery.result = "deleted"; + recovery.writeCount = recoveryWriteCount; + recovery.mutationOutcome = current.state.namedPlaylist.lastMutationOutcome; + recovery.exactAbsenceVerified = true; + recordFullUiDbCheck("databaseChecks", "PList recovery exact delete/absence", { + targetTitle: title, + writeCount: recoveryWriteCount, + mutationOutcome: recovery.mutationOutcome + }); + + await pointerClick( + 'document.getElementById("named-playlist-cancel-button")', + "PList recovery close after exact delete"); + await waitForNamedPlaylistRecovery( + "PList recovery close after exact delete", + snapshot => snapshot.state.isBusy === false && + snapshot.dom.namedModalHidden === true, + { + allowUiBusy: false, + baselineWriteCount: beforeDeleteWriteCount, + baselineCommandSequence: beforeDeleteCommandSequence + }); +} + async function executeNamedPlaylistCrud() { const title = evidence.fixture.namedPlaylistTitle; + if (title === configuration.recoverNamedPlaylistTitle) { + failKnown("The generated PList CRUD fixture collided with the explicit recovery target."); + } let current = await readSnapshot(); if (current.state.namedPlaylist.definitions.some(row => row.title === title)) { failKnown("The generated PList fixture already exists before mutation."); } + const saveRefreshBaseline = namedPlaylistCommandSequence(current); await pointerClick('document.getElementById("db-save-button")', "PList open save dialog"); current = await waitFor("PList fresh definition read", snapshot => - snapshot.dom.namedModalHidden === false && snapshot.state.isBusy === false, + snapshot.dom.namedModalHidden === false && snapshot.state.isBusy === false && + completedNamedPlaylistRefreshAfter(snapshot, saveRefreshBaseline), { timeoutMilliseconds: 60_000, allowUiBusy: true }); if (current.state.namedPlaylist.definitions.some(row => row.title === title)) { failKnown("The generated PList fixture appeared during the preflight read."); @@ -3188,10 +4257,10 @@ async function executeNamedPlaylistCrud() { snapshot.dom.namedConfirmationHidden === false && snapshot.state.isBusy === false, { allowUiBusy: false }); - await pointerClickWithJavaScriptDialog( + await pointerClickWithNativeDialog( 'document.getElementById("named-playlist-confirmation-yes")', "PList confirm row save", - "alert", + false, "저장되었습니다", true); current = await waitFor("PList row save commit", snapshot => @@ -3205,32 +4274,43 @@ async function executeNamedPlaylistCrud() { rowCount: current.state.namedPlaylist.rowCount }); + const readbackRefreshBaseline = namedPlaylistCommandSequence(current); await pointerClick('document.getElementById("db-load-button")', "PList reopen for fresh readback"); - current = await waitFor("PList fresh readback", snapshot => - snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === false && - snapshot.state.namedPlaylist.definitions.some(row => row.title === title), + current = await waitFor("PList fresh readback", snapshot => { + const matches = snapshot.state.namedPlaylist?.definitions + ?.filter(row => row.title === title) || []; + return snapshot.state.isBusy === false && + snapshot.dom.namedModalHidden === false && + completedNamedPlaylistRefreshAfter(snapshot, readbackRefreshBaseline) && + matches.length === 1 && typeof matches[0].definitionId === "string" && + matches[0].definitionId.length > 0 && + matches[0].definitionId.length <= 256 && + !/[\u0000-\u001f\u007f]/u.test(matches[0].definitionId) && + matches[0].isSelected !== true && + snapshot.state.namedPlaylist.selectedDefinitionId !== matches[0].definitionId && + snapshot.state.namedPlaylist.selectedTitle !== title; + }, { timeoutMilliseconds: 60_000, allowUiBusy: true }); - await pointerClick(namedDefinitionExpression(title), - "PList select fresh readback"); - current = await waitFor("PList fresh selected readback", snapshot => - snapshot.state.isBusy === false && - snapshot.state.namedPlaylist.selectedTitle === title, - { timeoutMilliseconds: 60_000, allowUiBusy: true }); - await pointerClick('document.getElementById("named-playlist-confirm-button")', - "PList load fresh rows"); - current = await waitFor("PList load close", snapshot => - snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === true && - snapshot.state.playlist.length === 3, - { timeoutMilliseconds: 60_000, allowUiBusy: true }); - recordFullUiDbCheck("databaseChecks", "PList fresh readback", { - rowCount: current.state.playlist.length + current = await requestNamedPlaylistLoadDoubleClick( + current, + title, + "PList physical saved-row double-click load"); + recordFullUiDbCheck("databaseChecks", "PList physical double-click fresh readback", { + rowCount: current.state.playlist.length, + input: "Windows SendInput double-click", + startedUnselected: true, + selectionIntentCount: 1, + loadIntentCount: 1, + contentRestored: true }); + const deleteRefreshBaseline = namedPlaylistCommandSequence(current); await pointerClick('document.getElementById("db-load-button")', "PList reopen for delete"); await waitFor("PList delete list read", snapshot => snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === false && + completedNamedPlaylistRefreshAfter(snapshot, deleteRefreshBaseline) && snapshot.state.namedPlaylist.definitions.some(row => row.title === title), { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerClick(namedDefinitionExpression(title), "PList select delete target"); @@ -3280,7 +4360,7 @@ async function openManualFinancial(screen, label) { } async function closeManualFinancial(label) { - await pointerClick('document.getElementById("manual-financial-close")', label); + await pointerClick('document.getElementById("manual-financial-exit")', label); await waitFor(`${label} complete`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial == null && snapshot.dom.manualFinancialModalHidden === true, @@ -3367,6 +4447,16 @@ async function fillManualFinancialRecord(screen) { } } +function manualFinancialNonStockDraftExpression(screen) { + if (screen === "revenue-composition") { + return 'document.querySelector("[data-manual-base-date]")'; + } + if (screen === "growth-metrics") { + return 'document.querySelector("[data-manual-growth-period=\\"0\\"]")'; + } + return 'document.querySelector("[data-manual-quarter-label=\\"0\\"]")'; +} + async function executeManualFinancialCrud() { const stockName = evidence.fixture.databaseStockDisplayName; await preflightManualFinancialIdentities(); @@ -3392,16 +4482,55 @@ async function executeManualFinancialCrud() { if (candidates.length !== 1) { failKnown(`GraphE ${screen} fixture stock is not unique in the master.`); } - await pointerClick( - `document.querySelector('[data-manual-stock-result-id="${candidates[0].resultId}"]')`, - `GraphE ${screen} select verified stock`); - await waitFor(`GraphE ${screen} verified stock`, snapshot => + const candidateExpression = + `document.querySelector('[data-manual-stock-result-id="${candidates[0].resultId}"]')`; + const draftExpression = manualFinancialNonStockDraftExpression(screen); + await replaceText( + draftExpression, + "SHOULD_CLEAR_ON_STOCK_CHANGE", + `GraphE ${screen} pre-selection draft sentinel`); + const beforeCandidateDoubleClick = await readSnapshot(); + const beforeCandidateMessageSequence = + beforeCandidateDoubleClick.safety.outboundMessageCount; + if (!Number.isSafeInteger(beforeCandidateMessageSequence)) { + failUnknown(`GraphE ${screen} outbound message sequence is unavailable.`); + } + await pointerDoubleClick( + candidateExpression, + `GraphE ${screen} select verified stock by slow Windows double-click`, + 0, + 350); + current = await waitFor(`GraphE ${screen} verified stock`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.verifiedStock?.name === stockName, { allowUiBusy: false }); + await sleep(350); + current = await readSnapshot(); + const candidateMessages = current.safety.outboundMessages.filter(message => + Number.isSafeInteger(message.sequence) && + message.sequence > beforeCandidateMessageSequence); + if (candidateMessages.length !== 1 || + candidateMessages[0].type !== "select-manual-financial-stock") { + failKnown( + `GraphE ${screen} candidate double-click emitted ${candidateMessages.length} ` + + `outbound messages instead of one stock selection.`); + } + const focusedCandidateId = await evaluate( + 'document.activeElement?.dataset?.manualStockResultId ?? null'); + if (focusedCandidateId !== candidates[0].resultId) { + failKnown(`GraphE ${screen} candidate focus was not preserved after double-click.`); + } + const remainingSentinel = await evaluate(`${draftExpression}?.value ?? null`); + if (remainingSentinel !== "") { + failKnown(`GraphE ${screen} stock change did not clear the prior create draft.`); + } await fillManualFinancialRecord(screen); - await pointerClick('document.querySelector("button[data-manual-save]")', - `GraphE ${screen} save`); + await pointerClickWithNativeDialog( + 'document.querySelector("button[data-manual-save]")', + `GraphE ${screen} save`, + false, + "저장되었습니다", + true); current = await waitFor(`GraphE ${screen} create commit and readback`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualFinancial?.status === "writeCommitted" && @@ -3450,7 +4579,7 @@ async function executeManualFinancialCrud() { "button[data-manual-action-id]", "manualActionId", `GraphE ${screen} action`); const beforeDoubleClick = await readSnapshot(); await pointerDoubleClick(dataButtonExpression("manualResultId", rows[0].resultId), - `GraphE ${screen} row double-click`); + `GraphE ${screen} row slow Windows double-click`, 0, 350); await waitFor(`GraphE ${screen} row double-click add`, snapshot => snapshot.state.playlist.length === beforeDoubleClick.state.playlist.length + 1 && snapshot.state.manualFinancial == null && @@ -3477,11 +4606,11 @@ async function executeManualFinancialCrud() { snapshot.state.manualFinancial?.canDelete === true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); - await pointerClickWithJavaScriptDialog( + await pointerClickWithNativeDialog( 'document.querySelector("button[data-manual-delete]")', `GraphE ${screen} delete`, - "confirm", - "선택한 수동 재무 데이터를 삭제하시겠습니까?", + true, + "선택한 종목을 삭제하겠습니까?", true); current = await waitFor(`GraphE ${screen} delete commit`, snapshot => snapshot.state.isBusy === false && @@ -3489,11 +4618,11 @@ async function executeManualFinancialCrud() { snapshot.state.manualFinancial?.writesQuarantined !== true && !snapshot.state.manualFinancial.rows.some(row => row.stockName === stockName), { timeoutMilliseconds: 60_000, allowUiBusy: true }); - await pointerClickWithJavaScriptDialog( + await pointerClickWithNativeDialog( 'document.querySelector("button[data-manual-delete-all]")', `GraphE ${screen} delete-all cancel branch`, - "confirm", - "현재 화면의 수동 재무 데이터를 모두 삭제하시겠습니까?", + true, + "모든 종목을 삭제하겠습니까?", false); recordFullUiDbCheck("databaseChecks", `GraphE ${screen} delete/absence`); await closeManualFinancial(`GraphE ${screen} close after delete`); @@ -3589,6 +4718,80 @@ async function requireExactCatalogResult(query, label, expectedCount) { return { current, matches }; } +async function verifyBlankOperatorCatalogStockSearch(trigger, label) { + if (!new Set(["button", "enter"]).has(trigger)) { + failKnown(`${label} has an invalid blank-search trigger.`); + } + await replaceText( + 'document.getElementById("operator-catalog-stock-query")', + "", + `${label} blank query`); + const before = await readSnapshot(); + const catalog = before.state.operatorCatalog; + if (!catalog || !new Set(["create", "edit"]).has(catalog.mode) || + before.dom.operatorCatalogStockQueryValue !== "" || + before.dom.operatorCatalogModalHidden !== false) { + failKnown(`${label} is not an open stock editor with a fully empty query.`); + } + const draftRowIds = (catalog.draftRows || []).map(row => row.rowId); + const outboundBaseline = before.safety.outboundMessageCount; + if (trigger === "button") { + await pointerClick( + 'document.getElementById("operator-catalog-stock-search")', + `${label} blank search button`); + } else { + const focused = await evaluate( + 'document.activeElement === document.getElementById("operator-catalog-stock-query")'); + if (focused !== true) failKnown(`${label} blank query lost focus before Enter.`); + await pressKey("Enter", `${label} blank search Enter`); + } + + const searched = await waitFor(`${label} bounded all-stock results`, snapshot => { + const currentCatalog = snapshot.state.operatorCatalog; + const resultCount = currentCatalog?.stockResults?.length ?? 0; + const currentDraftRowIds = (currentCatalog?.draftRows || []) + .map(row => row.rowId); + return snapshot.state.isBusy === false && + snapshot.state.revision > before.state.revision && + currentCatalog?.stockQuery === "" && + resultCount > 1 && resultCount <= 10_000 && + currentCatalog?.selectedStockResultId == null && + currentCatalog?.statusKind === "information" && + currentCatalog?.statusMessage === "종목 검색을 완료했습니다." && + currentCatalog?.writesQuarantined !== true && + snapshot.dom.operatorCatalogStockQueryValue === "" && + snapshot.dom.operatorCatalogStockResultCount === resultCount && + snapshot.dom.operatorCatalogStatusText.includes("종목 검색을 완료했습니다.") && + currentDraftRowIds.length === draftRowIds.length && + currentDraftRowIds.every((rowId, index) => rowId === draftRowIds[index]); + }, { timeoutMilliseconds: 90_000, allowUiBusy: true }); + const messages = assertExactOutboundTypes( + searched, + outboundBaseline, + ["search-operator-catalog-stocks"], + `${label} blank ${trigger} search`); + if (messages[0].emptyStockQuery !== true) { + failKnown(`${label} did not submit the exact empty stock query.`); + } + + const resultCount = searched.state.operatorCatalog.stockResults.length; + const truncated = searched.state.operatorCatalog.stockResultsAreTruncated === true; + const truncationVisible = searched.dom.operatorCatalogStatusText + .includes("종목 검색 결과 일부만 표시 중입니다."); + if ((truncated && (resultCount !== 10_000 || !truncationVisible)) || + (!truncated && truncationVisible)) { + failKnown(`${label} did not display the bounded-result truncation state exactly.`); + } + recordFullUiDbCheck("screenChecks", `${label} bounded blank stock search`, { + trigger, + resultCount, + maximumResultCount: 10_000, + truncated, + draftRowsPreserved: true + }); + return searched; +} + async function addOperatorCatalogStock( query, expectedDisplayName, @@ -3636,6 +4839,49 @@ async function addOperatorCatalogStock( return { current: added, row: addedRows[0] }; } +async function addOperatorCatalogStockByDoubleClick( + query, + expectedDisplayName, + label, + interClickDelayMilliseconds = 50) { + const before = await readSnapshot(); + const beforeRows = before.state.operatorCatalog?.draftRows || []; + await replaceText('document.getElementById("operator-catalog-stock-query")', + query, `${label} query`); + const beforeSearch = await readSnapshot(); + await pointerClick('document.getElementById("operator-catalog-stock-search")', + `${label} search`); + const searched = await waitFor(`${label} stock results`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.revision > beforeSearch.state.revision && + snapshot.state.operatorCatalog?.stockQuery === query && + snapshot.state.operatorCatalog?.writesQuarantined !== true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const matches = searched.state.operatorCatalog.stockResults.filter(row => + row.displayName === expectedDisplayName && row.isCompatible === true); + if (matches.length !== 1) { + failKnown(`${label} requires one exact compatible stock result, received ` + + `${matches.length}.`); + } + const resultId = matches[0].resultId; + await pointerDoubleClick( + `document.querySelector('button[data-operator-catalog-stock-result-id="${resultId}"]')`, + `${label} result double-click`, + 0, + interClickDelayMilliseconds); + const added = await waitFor(`${label} double-click added`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog?.draftRows?.length === beforeRows.length + 1 && + snapshot.state.operatorCatalog?.selectedStockResultId == null, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const addedRows = added.state.operatorCatalog.draftRows.filter(row => + !beforeRows.some(existing => existing.rowId === row.rowId)); + if (addedRows.length !== 1) { + failKnown(`${label} double-click did not add exactly one new draft row.`); + } + return { current: added, row: addedRows[0] }; +} + async function setOperatorCatalogBuyAmount(rowId, amount, label) { const expression = `document.querySelector('input[data-operator-catalog-buy-amount-row-id="${rowId}"]')`; @@ -3697,6 +4943,84 @@ async function assertMainThemeAbsent(title, expectedDisplayTitle, label) { return current; } +async function verifyKrxThemeCreateMarketRoundTrip( + title, + draftRowId, + label) { + const before = await readSnapshot(); + const catalog = before.state.operatorCatalog; + if (catalog?.entity !== "theme" || catalog.mode !== "create" || + catalog.newThemeMarket !== "krx" || catalog.editorName !== title || + before.dom.operatorCatalogNameValue !== title || + before.dom.operatorCatalogEditorMarketValue !== "krx" || + !catalog.draftRows?.some(row => row.rowId === draftRowId) || + !catalog.codeLabel) { + failKnown(`${label} did not start from the exact named KRX draft.`); + } + const krxCodeBefore = catalog.codeLabel; + const outboundSequenceBeforeNxt = before.safety.outboundMessageCount; + + await selectOptionValue( + 'document.getElementById("operator-catalog-editor-market")', + "nxt", + `${label} switch editor market to NXT`); + const nxt = await waitFor(`${label} NXT draft`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog?.entity === "theme" && + snapshot.state.operatorCatalog?.mode === "create" && + snapshot.state.operatorCatalog?.newThemeMarket === "nxt" && + snapshot.state.operatorCatalog?.editorName === title && + snapshot.state.operatorCatalog?.codeLabel?.length > 0 && + snapshot.state.operatorCatalog?.codeLabel !== krxCodeBefore && + snapshot.state.operatorCatalog?.draftRows?.length === 0 && + snapshot.state.operatorCatalog?.statusKind === "warning" && + snapshot.dom.operatorCatalogNameValue === title && + snapshot.dom.operatorCatalogEditorMarketValue === "nxt", + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const nxtMessages = assertExactOutboundTypes( + nxt, + outboundSequenceBeforeNxt, + ["change-create-theme-market"], + `${label} NXT selection`); + if ( + nxtMessages[0]?.market !== "nxt" || + nxtMessages[0]?.editorName !== title) { + failKnown(`${label} NXT selection did not send one exact market-change intent.`); + } + + const outboundSequenceBeforeKrx = nxt.safety.outboundMessageCount; + await selectOptionValue( + 'document.getElementById("operator-catalog-editor-market")', + "krx", + `${label} switch editor market back to KRX`); + const restored = await waitFor(`${label} restored KRX draft`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog?.entity === "theme" && + snapshot.state.operatorCatalog?.mode === "create" && + snapshot.state.operatorCatalog?.newThemeMarket === "krx" && + snapshot.state.operatorCatalog?.editorName === title && + snapshot.state.operatorCatalog?.codeLabel?.length > 0 && + snapshot.state.operatorCatalog?.draftRows?.length === 0 && + snapshot.dom.operatorCatalogNameValue === title && + snapshot.dom.operatorCatalogEditorMarketValue === "krx", + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const krxMessages = assertExactOutboundTypes( + restored, + outboundSequenceBeforeKrx, + ["change-create-theme-market"], + `${label} KRX selection`); + if ( + krxMessages[0]?.market !== "krx" || + krxMessages[0]?.editorName !== title) { + failKnown(`${label} KRX selection did not send one exact market-change intent.`); + } + recordFullUiDbCheck( + "screenChecks", + `${label} create-market NXT/KRX physical selection, name preservation, and draft cleanup`, + { removedDraftRowId: draftRowId, namePreserved: true }); + return restored; +} + async function createReadEditDeleteTheme( title, market, @@ -3728,17 +5052,95 @@ async function createReadEditDeleteTheme( snapshot.state.isBusy === false && snapshot.state.operatorCatalog?.checkedThemeName === title && snapshot.state.operatorCatalog?.themeNameAvailability === "available" && - snapshot.state.operatorCatalog?.writesQuarantined !== true, + snapshot.state.operatorCatalog?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); - const added = await addOperatorCatalogStock( - stockQuery, stockDisplayName, `${label} stock`); + if (market === "krx") { + await verifyBlankOperatorCatalogStockSearch( + "button", + `${label} ThemeA editor`); + } + let added = await addOperatorCatalogStockByDoubleClick( + stockQuery, + stockDisplayName, + `${label} stock`, + market === "krx" ? 350 : 50); + if (market === "krx") { + await verifyKrxThemeCreateMarketRoundTrip( + title, + added.row.rowId, + `${label} create editor`); + await pointerClick('document.getElementById("operator-catalog-name-check")', + `${label} duplicate recheck after market round-trip`); + await respondToNativeDialog( + `${label} duplicate recheck after market round-trip`, false, true); + await waitFor(`${label} name available after market round-trip`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog?.newThemeMarket === "krx" && + snapshot.state.operatorCatalog?.editorName === title && + snapshot.state.operatorCatalog?.checkedThemeName === title && + snapshot.state.operatorCatalog?.themeNameAvailability === "available" && + snapshot.state.operatorCatalog?.draftRows?.length === 0 && + snapshot.dom.operatorCatalogNameValue === title && + snapshot.dom.operatorCatalogEditorMarketValue === "krx" && + snapshot.state.operatorCatalog?.writesQuarantined !== true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + added = await addOperatorCatalogStockByDoubleClick( + stockQuery, + stockDisplayName, + `${label} stock re-add after market round-trip`, + 350); + } + let expectedSavedRows = 1; + let preservedRowIds = [added.row.rowId]; + if (market === "krx") { + const second = await addOperatorCatalogStock( + "삼성전자", "삼성전자", `${label} second stock`); + const third = await addOperatorCatalogStock( + "SK하이닉스", "SK하이닉스", `${label} third stock`); + await pointerClick( + `document.querySelector('button[data-operator-catalog-move-row-id="${third.row.rowId}"]` + + `[data-operator-catalog-move-direction="up"]')`, + `${label} third row Up`); + await waitFor(`${label} third row Up state`, snapshot => { + const rows = snapshot.state.operatorCatalog?.draftRows || []; + return rows[0]?.rowId === added.row.rowId && + rows[1]?.rowId === third.row.rowId && + rows[2]?.rowId === second.row.rowId; + }, { allowUiBusy: true }); + await pointerClick( + `document.querySelector('button[data-operator-catalog-move-row-id="${third.row.rowId}"]` + + `[data-operator-catalog-move-direction="down"]')`, + `${label} third row Down`); + await waitFor(`${label} third row Down state`, snapshot => { + const rows = snapshot.state.operatorCatalog?.draftRows || []; + return rows[0]?.rowId === added.row.rowId && + rows[1]?.rowId === second.row.rowId && + rows[2]?.rowId === third.row.rowId; + }, { allowUiBusy: true }); + await pointerClick( + `document.querySelector('[data-operator-catalog-row-id="${third.row.rowId}"] strong')`, + `${label} focus third row for Delete key`); + await waitFor(`${label} active third row`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog?.activeDraftRowId === third.row.rowId, + { timeoutMilliseconds: 30_000, allowUiBusy: true }); + await pressKey("Delete", `${label} third row Delete key`); + await waitFor(`${label} third row Delete-key state`, snapshot => { + const rows = snapshot.state.operatorCatalog?.draftRows || []; + return snapshot.state.isBusy === false && rows.length === 2 && + rows[0]?.rowId === added.row.rowId && rows[1]?.rowId === second.row.rowId; + }, { timeoutMilliseconds: 60_000, allowUiBusy: true }); + expectedSavedRows = 2; + preservedRowIds = [added.row.rowId, second.row.rowId]; + } await pointerClick('document.getElementById("operator-catalog-delete-all")', `${label} Delete All cancel branch`); await respondToNativeDialog(`${label} Delete All cancel branch`, true, false); const afterCancel = await waitFor(`${label} draft preserved`, snapshot => snapshot.state.isBusy === false && - snapshot.state.operatorCatalog?.draftRows?.length === 1 && - snapshot.state.operatorCatalog?.draftRows?.[0]?.rowId === added.row.rowId, + snapshot.state.operatorCatalog?.draftRows?.length === expectedSavedRows && + snapshot.state.operatorCatalog?.draftRows?.every((row, index) => + row.rowId === preservedRowIds[index]) === true, { allowUiBusy: false }); if (afterCancel.state.operatorCatalog.canSave !== true) { failKnown(`${label} is not saveable after the cancelled Delete All branch.`); @@ -3763,7 +5165,7 @@ async function createReadEditDeleteTheme( snapshot.state.operatorCatalog?.mode === "edit" && snapshot.state.operatorCatalog?.selectedResultId === catalogResultId && snapshot.state.operatorCatalog?.editorName === title && - snapshot.state.operatorCatalog?.draftRows?.length === 1 && + snapshot.state.operatorCatalog?.draftRows?.length === expectedSavedRows && snapshot.state.operatorCatalog?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); recordFullUiDbCheck("databaseChecks", `${label} fresh readback`, { @@ -3775,8 +5177,8 @@ async function createReadEditDeleteTheme( const expectedDisplayTitle = market === "nxt" ? `${title}(NXT)` : title; const selected = await searchAndSelectMainTheme( title, expectedDisplayTitle, `${label} UC4`); - if (selected.current.state.theme.previewItems.length !== 1) { - failKnown(`${label} UC4 preview did not contain the saved stock.`); + if (selected.current.state.theme.previewItems.length !== expectedSavedRows) { + failKnown(`${label} UC4 preview did not contain every saved stock.`); } await pointerClick('document.getElementById("uc4-theme-edit")', `${label} UC4 edit`); @@ -3785,10 +5187,13 @@ async function createReadEditDeleteTheme( snapshot.state.operatorCatalog?.entity === "theme" && snapshot.state.operatorCatalog?.mode === "edit" && snapshot.state.operatorCatalog?.editorName === title && - snapshot.state.operatorCatalog?.draftRows?.length === 1, + snapshot.state.operatorCatalog?.draftRows?.length === expectedSavedRows, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await closeOperatorCatalog(`${label} UC4 edit cancel`, true); - recordFullUiDbCheck("screenChecks", `${label} UC4 select/edit`); + recordFullUiDbCheck( + "screenChecks", + `${label} result double-click/up/down/Delete-key/select/edit`, + { rows: expectedSavedRows }); await pointerClick('document.getElementById("uc4-theme-delete")', `${label} UC4 delete`); @@ -3892,10 +5297,14 @@ async function createReadEditDeleteExpert(title) { snapshot.state.operatorCatalog?.mode === "edit" && snapshot.state.operatorCatalog?.editorName === title && snapshot.state.operatorCatalog?.draftRows?.length === 0 && - snapshot.state.operatorCatalog?.writesQuarantined !== true, + snapshot.state.operatorCatalog?.writesQuarantined !== true, { timeoutMilliseconds: 60_000, allowUiBusy: true }); - const first = await addOperatorCatalogStock( + await verifyBlankOperatorCatalogStockSearch( + "enter", + `${label} existing UC6 editor`); + + const first = await addOperatorCatalogStockByDoubleClick( "삼성출판사", "삼성출판사", `${label} first stock`); const second = await addOperatorCatalogStock( "삼성전자", "삼성전자", `${label} second stock`); @@ -3920,13 +5329,28 @@ async function createReadEditDeleteExpert(title) { snapshot.state.operatorCatalog?.draftRows?.[0]?.rowId === first.row.rowId && snapshot.state.operatorCatalog?.draftRows?.[1]?.rowId === second.row.rowId, { allowUiBusy: false }); + await dragOperatorCatalogRow( + second.row.rowId, + first.row.rowId, + "before", + `${label} second row drag before first`); + await dragOperatorCatalogRow( + second.row.rowId, + first.row.rowId, + "after", + `${label} second row drag after first`); await pointerClick( - `document.querySelector('button[data-operator-catalog-remove-row-id="${second.row.rowId}"]')`, - `${label} second row Delete`); - await waitFor(`${label} second row Delete state`, snapshot => + `document.querySelector('[data-operator-catalog-row-id="${second.row.rowId}"] strong')`, + `${label} focus second row for Delete key`); + await waitFor(`${label} active second row`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog?.activeDraftRowId === second.row.rowId, + { timeoutMilliseconds: 30_000, allowUiBusy: true }); + await pressKey("Delete", `${label} second row Delete key`); + await waitFor(`${label} second row Delete-key state`, snapshot => snapshot.state.operatorCatalog?.draftRows?.length === 1 && snapshot.state.operatorCatalog?.draftRows?.[0]?.rowId === first.row.rowId, - { allowUiBusy: false }); + { timeoutMilliseconds: 60_000, allowUiBusy: true }); const readded = await addOperatorCatalogStock( "삼성전자", "삼성전자", `${label} second stock re-add`); await setOperatorCatalogBuyAmount(readded.row.rowId, 202, @@ -3948,7 +5372,9 @@ async function createReadEditDeleteExpert(title) { closed.state.expert?.preview?.items?.length !== 2) { failKnown(`${label} known-committed edit did not refresh the two-row preview.`); } - recordFullUiDbCheck("screenChecks", `${label} UC6 add/edit/up/down/delete`); + recordFullUiDbCheck( + "screenChecks", + `${label} UC6 result-double-click/add/edit/up/down/row-drag/Delete-key`); recordFullUiDbCheck("databaseChecks", `${label} edit/save`, { rows: 2 }); const fresh = await searchAndSelectMainExpert(title, `${label} fresh edit read`); @@ -4103,13 +5529,24 @@ async function verifyFixedAndIndustryScreens() { await doubleClickAndWaitForPlaylistAdd(dataButtonExpression("actionId", "fixed-001"), "UC1 overseas leaf"); const beforeSection = await readSnapshot(); + const fixedSection = beforeSection.state.fixedCatalog.sections[1]; + if (!fixedSection || !Array.isArray(fixedSection.actions) || + fixedSection.actions.length === 0) { + failKnown("UC1 fixed section batch has no actions to verify."); + } await pointerDoubleClick( `document.querySelector('summary[data-section-index="1"]')`, - "UC1 fixed three-row section"); - await waitFor("UC1 fixed section batch", snapshot => - snapshot.state.isBusy === false && - snapshot.state.playlist.length === beforeSection.state.playlist.length + 3, + "UC1 fixed parent batch"); + const afterSection = await waitFor("UC1 fixed parent batch add", snapshot => + snapshot.state.isBusy === false && snapshot.state.fixedSectionBatch == null && + snapshot.state.playlist.length === + beforeSection.state.playlist.length + fixedSection.actions.length, { timeoutMilliseconds: 60_000, allowUiBusy: true }); + assertSafety(afterSection, "UC1 fixed parent batch", false); + if (afterSection.safety.outboundMessages.length !== + beforeSection.safety.outboundMessages.length + 1) { + failKnown("UC1 fixed parent double-click must send exactly one native batch intent."); + } await waitForTabModel("exchange", "fixedCatalog", "UC1 exchange tab"); await doubleClickAndWaitForPlaylistAdd(dataButtonExpression("actionId", "fixed-079"), @@ -4117,7 +5554,9 @@ async function verifyFixedAndIndustryScreens() { await waitForTabModel("index", "fixedCatalog", "UC1 index tab"); await doubleClickAndWaitForPlaylistAdd(dataButtonExpression("actionId", "fixed-125"), "UC1 index leaf"); - recordFullUiDbCheck("screenChecks", "UC1 Expand, leaf, and parent-section buttons"); + recordFullUiDbCheck( + "screenChecks", + "UC1 Expand, leaf activation, and parent batch activation"); for (const tabId of ["kospiIndustry", "kosdaqIndustry"]) { await waitForTabModel(tabId, "industry", `UC2 ${tabId} tab`); @@ -4208,12 +5647,12 @@ async function verifyComparisonScreen() { failKnown("UC3 fresh tab activation did not start with empty draft targets."); } await pointerDoubleClick(dataButtonExpression("comparisonSelectionId", domesticId), - "UC3 choose first domestic target"); + "UC3 choose first domestic target by slow Windows double-click", 0, 350); await waitFor("UC3 first domestic target", snapshot => snapshot.state.comparison?.firstTarget != null, { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerDoubleClick(dataButtonExpression("comparisonSelectionId", secondDomesticId), - "UC3 choose second domestic target"); + "UC3 choose second domestic target by slow Windows double-click", 0, 350); current = await waitFor("UC3 second domestic target", snapshot => snapshot.state.comparison?.firstTarget != null && snapshot.state.comparison?.secondTarget != null, @@ -4270,7 +5709,7 @@ async function verifyComparisonScreen() { { timeoutMilliseconds: 60_000, allowUiBusy: true }); await pointerDoubleClick(dataButtonExpression("comparisonTargetId", "kospi"), - "UC3 fixed target double-click"); + "UC3 fixed target slow Windows double-click", 0, 350); await waitFor("UC3 fixed target draft", snapshot => snapshot.state.comparison?.selectedFixedTargetId === "kospi" && snapshot.state.comparison?.firstTarget?.displayName === "코스피 지수", @@ -4348,7 +5787,7 @@ async function verifyThemeScreen() { } const beforeDouble = await readSnapshot(); await pointerDoubleClick(dataButtonExpression("themeResultId", resultId), - "UC4 theme row double-click"); + "UC4 theme row slow Windows double-click", 0, 350); await waitFor("UC4 theme double-click add", snapshot => snapshot.state.playlist.length === beforeDouble.state.playlist.length + 1, { timeoutMilliseconds: 60_000, allowUiBusy: true }); @@ -4455,6 +5894,16 @@ async function verifyExpertAndTradingHaltScreens() { snapshot.state.tradingHalt?.status === "ready" && snapshot.state.tradingHalt?.results?.length > 0, { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const automaticHaltId = current.state.tradingHalt.results[0].selectionId; + const automaticHaltActions = current.state.tradingHalt.actions || []; + if (current.state.tradingHalt.selected?.selectionId !== automaticHaltId || + automaticHaltActions.length === 0 || + automaticHaltActions.some(action => action.isAvailable !== true)) { + failKnown("UC7 did not activate the first FarPoint row and actions after search."); + } + await clickAndWaitForPlaylistAdd( + dataButtonExpression("haltActionId", automaticHaltActions[0].actionId), + "UC7 action without an extra row click"); const beforeSort = current.state.tradingHalt.nameSort; await pointerClick(dataButtonExpression("haltSort", "true"), "UC7 halt sort"); current = await waitFor("UC7 halt sort state", snapshot => @@ -4467,9 +5916,12 @@ async function verifyExpertAndTradingHaltScreens() { { timeoutMilliseconds: 60_000, allowUiBusy: true }); const haltActions = await clickAllEnabledPlaylistActions( "button[data-halt-action-id]", "haltActionId", "UC7 action"); - recordFullUiDbCheck("screenChecks", "UC7 search, sort, row, all actions", { + recordFullUiDbCheck( + "screenChecks", + "UC7 search auto-first-row action, sort, explicit row, all actions", + { actionButtons: haltActions.length - }); + }); } async function verifyGraphEActivationButtons() { @@ -4490,7 +5942,7 @@ async function openFixedManualList(actionId, screen, audience, label) { } async function closeManualList(label) { - await pointerClick('document.getElementById("manual-list-close")', label); + await pointerClick('document.getElementById("manual-list-cancel")', label); await waitFor(`${label} complete`, snapshot => snapshot.state.isBusy === false && snapshot.state.manualLists?.isOpen === false && @@ -4507,6 +5959,55 @@ function netSellProjection(snapshot) { })); } +function manualNetSellCellExpression(rowIndex, field) { + const fieldIndex = ["leftName", "leftAmount", "rightName", "rightAmount"] + .indexOf(field); + if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= 5 || fieldIndex < 0) { + failKnown("The FSell cell fixture is outside the original five-by-four grid."); + } + return `document.querySelectorAll( + "#manual-list-workspace .manual-list-table tbody tr")[${rowIndex}] + ?.querySelectorAll("input")[${fieldIndex}] || null`; +} + +async function replaceManualNetSellCell(rowIndex, field, value, expectedRows, label) { + const expression = manualNetSellCellExpression(rowIndex, field); + const before = await readSnapshot(); + await replaceText(expression, value, label); + // The original editor commits a cell when focus leaves it. Use a real Tab key + // so this covers the packaged change event rather than assigning DOM state. + await pressKey("Tab", `${label} commit by focus change`); + return await waitFor(`${label} native draft`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.revision > before.state.revision && + snapshot.state.manualLists?.isOpen === true && + snapshot.state.manualLists?.screen === "net-sell" && + snapshot.state.manualLists?.netRowsAreFresh === false && + snapshot.state.manualLists?.writesQuarantined !== true && + JSON.stringify(netSellProjection(snapshot)) === JSON.stringify(expectedRows), + { timeoutMilliseconds: 60_000, allowUiBusy: true }); +} + +async function confirmManualNetSellAndVerifyReadback(expectedRows, label) { + const before = await readSnapshot(); + if (before.state.manualLists?.isOpen !== true || + before.state.manualLists?.screen !== "net-sell" || + before.state.manualLists?.canSave !== true || + before.state.manualLists?.writesQuarantined === true) { + failKnown(`${label} did not start from a saveable FSell editor.`); + } + await pointerClick(modalButtonByTextExpression("확인"), label); + return await waitFor(`${label} one save and correlated readback`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.manualLists?.isOpen === false && + snapshot.dom.manualListModalHidden === true && + snapshot.state.manualLists?.netRowsAreFresh === true && + snapshot.state.manualLists?.writesQuarantined !== true && + JSON.stringify(netSellProjection(snapshot)) === JSON.stringify(expectedRows) && + snapshot.state.playlist.length === before.state.playlist.length + 1, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); +} + function viProjection(snapshot) { return snapshot.state.manualLists.viItems.map(row => ({ index: row.index, @@ -4553,22 +6054,60 @@ async function verifyManualListButtonsAndPersistence() { current = await openFixedManualList( "fixed-245", "net-sell", "individual", "FSell save reopen"); - const beforeFsellSaveCount = current.state.playlist.length; - await pointerClick(modalButtonByTextExpression("확인"), "FSell confirm save"); - await waitFor("FSell save, readback, add, close", snapshot => - snapshot.state.isBusy === false && - snapshot.state.manualLists?.isOpen === false && - snapshot.dom.manualListModalHidden === true && - snapshot.state.playlist.length === beforeFsellSaveCount + 1, - { timeoutMilliseconds: 60_000, allowUiBusy: true }); - current = await openFixedManualList( - "fixed-245", "net-sell", "individual", "FSell fresh read"); if (JSON.stringify(netSellProjection(current)) !== JSON.stringify(individualBaseline)) { - failKnown("FSell fresh read did not preserve the exact five-row baseline."); + failKnown("FSell changed before the isolated cell round-trip began."); } - await closeManualList("FSell fresh read close"); + let temporaryLeftAmount = + `${Date.now()}${randomBytes(3).readUIntBE(0, 3).toString().padStart(8, "0")}`; + if (temporaryLeftAmount === individualBaseline[0].leftAmount) { + temporaryLeftAmount += "0"; + } + const temporaryRows = individualBaseline.map(row => ({ ...row })); + temporaryRows[0].leftAmount = temporaryLeftAmount; + const fsellRoundTrip = { + row: 1, + field: "leftAmount", + temporaryValue: temporaryLeftAmount, + mutationSaveVerified: false, + mutationFreshReadVerified: false, + restoreSaveVerified: false, + restoreFreshReadVerified: false + }; + evidence.fullUiDb.fsellCellRoundTrip = fsellRoundTrip; + + await replaceManualNetSellCell( + 0, "leftAmount", temporaryLeftAmount, temporaryRows, + "FSell row 1 left amount temporary input"); + await confirmManualNetSellAndVerifyReadback( + temporaryRows, "FSell temporary value confirm save"); + fsellRoundTrip.mutationSaveVerified = true; + + current = await openFixedManualList( + "fixed-245", "net-sell", "individual", "FSell temporary value fresh read"); + if (JSON.stringify(netSellProjection(current)) !== JSON.stringify(temporaryRows)) { + failKnown("FSell fresh read did not preserve the physically typed temporary value."); + } + fsellRoundTrip.mutationFreshReadVerified = true; + + await replaceManualNetSellCell( + 0, "leftAmount", individualBaseline[0].leftAmount, individualBaseline, + "FSell row 1 left amount restore input"); + await confirmManualNetSellAndVerifyReadback( + individualBaseline, "FSell original value confirm restore"); + fsellRoundTrip.restoreSaveVerified = true; + + current = await openFixedManualList( + "fixed-245", "net-sell", "individual", "FSell restored fresh read"); + if (JSON.stringify(netSellProjection(current)) !== JSON.stringify(individualBaseline)) { + failKnown("FSell final fresh read did not restore the exact five-row baseline."); + } + fsellRoundTrip.restoreFreshReadVerified = true; + await closeManualList("FSell restored fresh read close"); recordFullUiDbCheck("screenChecks", "FSell all entry/tab/import/refresh/cancel/confirm buttons"); - recordFullUiDbCheck("databaseChecks", "FSell trusted file save/fresh read", { rows: 5 }); + recordFullUiDbCheck( + "databaseChecks", + "FSell physical cell edit/save/fresh read/original restore/fresh read", + { rows: 5, row: 1, field: "leftAmount", restored: true }); current = await openFixedManualList("fixed-262", "vi", null, "VIList entry"); const viBaseline = viProjection(current); @@ -4599,7 +6138,11 @@ async function verifyManualListButtonsAndPersistence() { await waitFor("VIList result selected", snapshot => snapshot.state.manualLists?.selectedSearchResultId === viResult.resultId, { timeoutMilliseconds: 60_000, allowUiBusy: true }); - await pointerDoubleClick(viResultExpression, "VIList result double-click add"); + await pointerDoubleClick( + viResultExpression, + "VIList result slow Windows double-click add", + 0, + 350); current = await waitFor("VIList temporary item add", snapshot => snapshot.state.manualLists?.viItems?.length === viBaseline.length + 1, { timeoutMilliseconds: 60_000, allowUiBusy: true }); @@ -4625,11 +6168,11 @@ async function verifyManualListButtonsAndPersistence() { snapshot.state.manualLists?.viItems?.length === viBaseline.length && !snapshot.state.manualLists.viItems.some(row => row.itemId === temporaryItemId), { allowUiBusy: false }); - await pointerClickWithJavaScriptDialog( + await pointerClickWithNativeDialog( modalButtonByTextExpression("전체 삭제"), "VIList delete-all cancel branch", - "confirm", - "VI 목록을 모두 지우시겠습니까?", + true, + "모두 삭제하겠습니까?", false); const beforeViSave = await readSnapshot(); await pointerClick(modalButtonByTextExpression("확인"), "VIList confirm save"); @@ -4653,10 +6196,10 @@ async function verifyManualListButtonsAndPersistence() { async function clearScreenValidationPlaylist() { const current = await readSnapshot(); if (current.state.playlist.length === 0) return; - await pointerClickWithJavaScriptDialog( + await pointerClickWithNativeDialog( 'document.getElementById("playlist-delete-all")', "screen validation playlist cleanup", - "confirm", + true, "송출 리스트를 모두 삭제하겠습니까?", true); await waitFor("screen validation playlist cleanup state", snapshot => @@ -4689,6 +6232,7 @@ async function executeScreenInventory() { async function executeFullUiDbSequence() { if (!fullUiDbProfile) return; if (configuration.scope === "all" || configuration.scope === "plist") { + await recoverNamedPlaylistFixture(); await executeNamedPlaylistCrud(); } if (configuration.scope === "all" || configuration.scope === "graphe") { diff --git a/scripts/Test-LegacyPackageLocalStateSmoke.mjs b/scripts/Test-LegacyPackageLocalStateSmoke.mjs new file mode 100644 index 0000000..7f9b8e5 --- /dev/null +++ b/scripts/Test-LegacyPackageLocalStateSmoke.mjs @@ -0,0 +1,870 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createHash, randomBytes } from "node:crypto"; + +const exactTargetUrl = "https://legacy-parity.mbn.local/index.html"; +const expectedSourceSha256 = + "1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2"; +const expectedInitialDestinationSha256 = + "13C07BA64587549EDA9C1A694F3DFA19875CB1F888F02C9E071F17C47739E087"; +const expectedImportConfirmation = + "현재 저장된 비교쌍의 순서는 그대로 유지하고, 원본 프로그램의 비교쌍 중 없는 항목만 뒤에 추가할까요?"; +const allowedOutboundTypes = new Set([ + "ready", + "select-tab", + "choose-operator-folder", + "import-legacy-comparison-pairs", + "confirm-native-dialog" +]); +const forbiddenCdpMethods = new Set([ + "Browser.close", + "Page.close", + "Runtime.terminateExecution", + "Target.closeTarget", + "Input.dispatchMouseEvent", + "Input.dispatchKeyEvent" +]); +const targetExpressions = Object.freeze({ + "settings-navigation": 'document.getElementById("workspace-settings-tab")', + "design-folder-picker": 'document.getElementById("operator-design-folder-select")', + "resource-folder-picker": 'document.getElementById("operator-resource-folder-select")', + "background-folder-picker": 'document.getElementById("operator-background-folder-select")', + "comparison-navigation": + 'document.querySelector("button[data-tab-id=\\"comparison\\"]")', + "comparison-import": + 'document.querySelector("button[data-comparison-import=\\"true\\"]")', + "native-confirmation-yes": 'document.getElementById("legacy-dialog-ok")' +}); + +class SmokeFailure extends Error { + constructor(category, message) { + super(`${category}: ${message}`); + this.name = "SmokeFailure"; + this.category = category; + } +} + +function failKnown(message) { + throw new SmokeFailure("KNOWN_FAILURE", message); +} + +function failUnknown(message) { + throw new SmokeFailure("OUTCOME_UNKNOWN", message); +} + +function parseArguments(argv) { + const accepted = new Set([ + "port", "output", "screenshot", "exchange-directory", "timeout-ms" + ]); + if (argv.length === 0 || argv.length % 2 !== 0) { + failKnown( + "Usage: node scripts/Test-LegacyPackageLocalStateSmoke.mjs " + + "--port --output --screenshot " + + "--exchange-directory [--timeout-ms ]"); + } + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const rawName = argv[index]; + const value = argv[index + 1]; + if (!rawName?.startsWith("--") || !value) failKnown("Arguments must be --name value pairs."); + const name = rawName.slice(2); + if (!accepted.has(name) || values.has(name)) failKnown(`Invalid argument --${name}.`); + values.set(name, value); + } + for (const required of ["port", "output", "screenshot", "exchange-directory"]) { + if (!values.has(required)) failKnown(`--${required} is required.`); + } + const port = Number(values.get("port")); + const timeoutMilliseconds = Number(values.get("timeout-ms") || "180000"); + if (!Number.isInteger(port) || port < 1024 || port > 65535) { + failKnown("--port must be from 1024 through 65535."); + } + if (!Number.isInteger(timeoutMilliseconds) || + timeoutMilliseconds < 30_000 || timeoutMilliseconds > 900_000) { + failKnown("--timeout-ms must be from 30000 through 900000."); + } + const outputPath = path.resolve(values.get("output")); + const screenshotPath = path.resolve(values.get("screenshot")); + const exchangeDirectory = path.resolve(values.get("exchange-directory")); + if (!path.isAbsolute(values.get("output")) || path.extname(outputPath).toLowerCase() !== ".json" || + !path.isAbsolute(values.get("screenshot")) || path.extname(screenshotPath).toLowerCase() !== ".png" || + !path.isAbsolute(values.get("exchange-directory"))) { + failKnown("Output, screenshot and exchange paths must be absolute and typed correctly."); + } + const unique = new Set([ + outputPath.toLowerCase(), screenshotPath.toLowerCase(), exchangeDirectory.toLowerCase() + ]); + if (unique.size !== 3) failKnown("Evidence paths must be distinct."); + if (fs.existsSync(outputPath) || fs.existsSync(screenshotPath)) { + failKnown("Evidence files are never overwritten."); + } + if (!fs.existsSync(exchangeDirectory) || + !fs.statSync(exchangeDirectory).isDirectory() || + fs.readdirSync(exchangeDirectory).length !== 0) { + failKnown("The wrapper must provide one existing empty exchange directory."); + } + return { port, timeoutMilliseconds, outputPath, screenshotPath, exchangeDirectory }; +} + +const configuration = parseArguments(process.argv.slice(2)); +const hardDeadline = Date.now() + configuration.timeoutMilliseconds; +const startedAt = new Date().toISOString(); +let socket = null; +let nextRpcId = 1; +let nextInputSequence = 1; +let probeInstalled = false; +let cancellationRequested = false; +const pendingRpc = new Map(); + +const evidence = { + schemaVersion: 1, + profile: "local-settings-comparison-import", + result: "RUNNING", + startedAt, + completedAt: null, + target: { + expectedUrl: exactTargetUrl, + port: configuration.port, + id: null, + url: null + }, + safety: { + requiredMode: "dryRun", + requiredPhase: "idle", + allowedOutboundTypes: [...allowedOutboundTypes], + observedOutboundTypes: [], + blockedOutboundMessages: [], + stateViolations: [], + inputRetryCount: 0, + appCloseRequested: false, + playoutIntentIssued: false + }, + files: { + expectedSourceSha256, + expectedSourceRowCount: 8, + expectedInitialDestinationSha256, + expectedInitialDestinationRowCount: 1, + settingsBaseline: null, + destinationAfterFirstImport: null, + destinationAfterSecondImport: null + }, + settings: { + pickerKinds: [], + allCancelled: false, + settingsFileUnchanged: false + }, + comparison: { + initialPair: null, + afterFirstImport: null, + afterSecondImport: null, + initialPairPreserved: false, + secondImportIdempotent: false + }, + exchanges: [], + checkpoints: [], + finalSnapshot: null, + screenshot: null, + error: null +}; + +process.stdin.setEncoding("utf8"); +process.stdin.on("data", chunk => { + if (String(chunk).split(/\r?\n/u).includes("CANCEL")) cancellationRequested = true; +}); +// The wrapper keeps stdin open so it can fail-stop an in-flight helper. Do not +// let that control channel keep Node alive after PASS or a sealed early failure. +if (typeof process.stdin.unref === "function") process.stdin.unref(); + +function assertDeadline(label) { + if (cancellationRequested) failUnknown(`The wrapper cancelled ${label}; no action is retried.`); + if (Date.now() >= hardDeadline) failUnknown(`The global deadline expired ${label}; no action is retried.`); +} + +async function sleep(milliseconds) { + assertDeadline("before waiting"); + await new Promise(resolve => setTimeout(resolve, Math.min(milliseconds, hardDeadline - Date.now()))); + assertDeadline("after waiting"); +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex").toUpperCase(); +} + +function stablePair(pair) { + if (!pair) return null; + return { + rowId: pair.rowId, + rowNumber: pair.rowNumber, + displayLabel: pair.displayLabel, + isSelected: pair.isSelected === true + }; +} + +async function discoverTarget() { + const discoveryUrl = `http://127.0.0.1:${configuration.port}/json/list`; + let lastError = null; + while (Date.now() < Math.min(hardDeadline, Date.now() + 15_000)) { + try { + const response = await fetch(discoveryUrl, { cache: "no-store" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const targets = await response.json(); + const exact = targets.filter(target => + target?.type === "page" && target.url === exactTargetUrl); + if (exact.length > 1) failKnown("More than one exact package WebView target exists."); + if (exact.length === 1) return exact[0]; + } catch (error) { + if (error instanceof SmokeFailure) throw error; + lastError = String(error?.message || error); + } + await sleep(100); + } + failKnown(`The exact package target was not found${lastError ? ` (${lastError})` : ""}.`); +} + +function validateEndpoint(target) { + if (!target?.id || !target.webSocketDebuggerUrl) failKnown("The target has no debugger URL."); + const endpoint = new URL(target.webSocketDebuggerUrl); + if (endpoint.protocol !== "ws:" || endpoint.hostname !== "127.0.0.1" || + Number(endpoint.port) !== configuration.port || + endpoint.pathname !== `/devtools/page/${target.id}`) { + failKnown("The debugger endpoint is not the exact loopback page endpoint."); + } + return endpoint; +} + +async function connect(endpoint) { + if (typeof WebSocket !== "function") failKnown("This Node runtime has no WebSocket API."); + socket = new WebSocket(endpoint.href); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new SmokeFailure( + "OUTCOME_UNKNOWN", "CDP socket open timed out.")), 5_000); + socket.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true }); + socket.addEventListener("error", () => { clearTimeout(timer); reject(new SmokeFailure( + "OUTCOME_UNKNOWN", "CDP socket failed to open.")); }, { once: true }); + }); + socket.addEventListener("message", event => { + const message = JSON.parse(String(event.data)); + if (!Object.hasOwn(message, "id")) return; + const pending = pendingRpc.get(message.id); + if (!pending) return; + pendingRpc.delete(message.id); + clearTimeout(pending.timer); + if (message.error) pending.reject(new SmokeFailure( + "KNOWN_FAILURE", `${pending.method} failed: ${message.error.message || "CDP error"}`)); + else pending.resolve(message.result); + }); + socket.addEventListener("close", () => { + for (const pending of pendingRpc.values()) { + clearTimeout(pending.timer); + pending.reject(new SmokeFailure( + "OUTCOME_UNKNOWN", `CDP closed while waiting for ${pending.method}.`)); + } + pendingRpc.clear(); + }); +} + +function rpc(method, params = {}, timeoutMilliseconds = 10_000) { + assertDeadline(`before ${method}`); + if (forbiddenCdpMethods.has(method)) { + if (method.includes("close") || method.includes("terminate")) { + evidence.safety.appCloseRequested = true; + } + failKnown(`Forbidden CDP method ${method} was refused.`); + } + if (!socket || socket.readyState !== WebSocket.OPEN) failUnknown("CDP socket is not open."); + const id = nextRpcId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingRpc.delete(id); + reject(new SmokeFailure("OUTCOME_UNKNOWN", `${method} timed out; no retry was attempted.`)); + }, Math.min(timeoutMilliseconds, hardDeadline - Date.now())); + pendingRpc.set(id, { method, resolve, reject, timer }); + socket.send(JSON.stringify({ id, method, params })); + }); +} + +async function evaluate(expression) { + const response = await rpc("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true, + userGesture: false + }); + if (response.exceptionDetails) { + failKnown(response.exceptionDetails.exception?.description || + response.exceptionDetails.text || "Runtime evaluation failed."); + } + return response.result?.value; +} + +async function installProbe() { + const token = `local-state-${Date.now()}-${randomBytes(8).toString("hex")}`; + const installed = await evaluate(`(() => { + if (!window.chrome?.webview) throw new Error("WebView2 bridge is unavailable."); + if (globalThis.__legacyLocalStateProbe) throw new Error("Probe already exists."); + const allowed = new Set(${JSON.stringify([...allowedOutboundTypes])}); + const probe = { + token: ${JSON.stringify(token)}, states: [], outboundMessages: [], pointerEvents: [], + blockedOutboundMessages: [], stateViolations: [], originalPostMessage: null, + postMessageWrapper: null, listener: null, pointerListener: null + }; + probe.listener = event => { + if (event.data?.type !== "state" || !event.data.payload) return; + const payload = event.data.payload; + probe.states.push(structuredClone(payload)); + if (probe.states.length > 500) probe.states.shift(); + const playout = payload.playout; + const reasons = []; + if (!playout) reasons.push("missing-playout"); + else { + if (playout.mode !== "dryRun") reasons.push("mode"); + if (playout.phase !== "idle") reasons.push("phase"); + if (playout.isConnected === true) reasons.push("connected"); + if (playout.preparedCode != null) reasons.push("prepared"); + if (playout.onAirCode != null) reasons.push("on-air"); + if (playout.outcomeUnknown === true) reasons.push("outcome-unknown"); + if (playout.isPlayCompletionPending === true) reasons.push("play-pending"); + if (playout.isTakeOutCompletionPending === true) reasons.push("takeout-pending"); + if (playout.isBusy === true) reasons.push("playout-busy"); + if (playout.refreshActive === true) reasons.push("refresh-active"); + } + if (reasons.length) probe.stateViolations.push({ + at: new Date().toISOString(), revision: payload.revision ?? null, reasons + }); + }; + probe.originalPostMessage = window.chrome.webview.postMessage; + probe.pointerListener = event => { + const element = event.target instanceof Element ? event.target : null; + let targetId = null; + if (element?.closest("#workspace-settings-tab")) targetId = "settings-navigation"; + else if (element?.closest("#operator-design-folder-select")) { + targetId = "design-folder-picker"; + } else if (element?.closest("#operator-resource-folder-select")) { + targetId = "resource-folder-picker"; + } else if (element?.closest("#operator-background-folder-select")) { + targetId = "background-folder-picker"; + } else if (element?.closest('button[data-tab-id="comparison"]')) { + targetId = "comparison-navigation"; + } else if (element?.closest('button[data-comparison-import="true"]')) { + targetId = "comparison-import"; + } else if (element?.closest("#legacy-dialog-ok")) { + targetId = "native-confirmation-yes"; + } + probe.pointerEvents.push({ + sequence: probe.pointerEvents.length + 1, + type: event.type, + targetId, + isTrusted: event.isTrusted === true, + pointerType: event.pointerType, + button: event.button, + buttons: event.buttons, + at: new Date().toISOString() + }); + if (probe.pointerEvents.length > 100) probe.pointerEvents.shift(); + }; + probe.postMessageWrapper = message => { + const record = { + sequence: probe.outboundMessages.length + 1, + type: typeof message?.type === "string" ? message.type : null, + at: new Date().toISOString() + }; + probe.outboundMessages.push(record); + if (!record.type || !allowed.has(record.type)) { + probe.blockedOutboundMessages.push(record); + return; + } + return probe.originalPostMessage.call(window.chrome.webview, message); + }; + try { + window.chrome.webview.postMessage = probe.postMessageWrapper; + if (window.chrome.webview.postMessage !== probe.postMessageWrapper) { + throw new Error("The outbound safety gate could not be installed."); + } + globalThis.__legacyLocalStateProbe = probe; + window.chrome.webview.addEventListener("message", probe.listener); + document.addEventListener("pointerdown", probe.pointerListener, true); + document.addEventListener("pointerup", probe.pointerListener, true); + window.chrome.webview.postMessage({ type: "ready", payload: {} }); + } catch (error) { + window.chrome.webview.postMessage = probe.originalPostMessage; + delete globalThis.__legacyLocalStateProbe; + throw error; + } + return probe.token; + })()`); + if (installed !== token) failKnown("The probe was not installed exactly once."); + probeInstalled = true; +} + +async function removeProbe() { + if (!probeInstalled || !socket || socket.readyState !== WebSocket.OPEN) return; + const removed = await evaluate(`(() => { + const probe = globalThis.__legacyLocalStateProbe; + if (!probe) return false; + window.chrome.webview.removeEventListener("message", probe.listener); + document.removeEventListener("pointerdown", probe.pointerListener, true); + document.removeEventListener("pointerup", probe.pointerListener, true); + if (window.chrome.webview.postMessage !== probe.postMessageWrapper) { + throw new Error("The outbound safety gate identity changed."); + } + window.chrome.webview.postMessage = probe.originalPostMessage; + delete globalThis.__legacyLocalStateProbe; + return true; + })()`); + if (removed !== true) failUnknown("The safety probe was not restored exactly once."); + probeInstalled = false; +} + +async function readSnapshot() { + return await evaluate(`(() => { + const probe = globalThis.__legacyLocalStateProbe; + const state = probe?.states?.at(-1) || null; + const active = document.activeElement; + return { + state: state ? structuredClone(state) : null, + safety: { + wrapped: probe?.postMessageWrapper != null && + window.chrome?.webview?.postMessage === probe.postMessageWrapper, + outboundMessages: (probe?.outboundMessages || []).map(value => ({ ...value })), + blockedOutboundMessages: (probe?.blockedOutboundMessages || []).map(value => ({ ...value })), + stateViolations: (probe?.stateViolations || []).map(value => ({ + ...value, reasons: [...value.reasons] + })) + }, + dom: { + url: location.href, + readyState: document.readyState, + bodyBusy: document.body.getAttribute("aria-busy"), + connectionText: (document.getElementById("playout-connection-state")?.textContent || "").trim(), + settingsVisible: document.getElementById("settings-workspace")?.hidden === false && + document.getElementById("settings-workspace")?.inert !== true, + settingsCurrent: document.getElementById("workspace-settings-tab") + ?.getAttribute("aria-current"), + activeTabId: state?.tabs?.find(tab => tab.isActive === true)?.id || null, + legacyDialogHidden: document.getElementById("legacy-dialog")?.hidden === true, + legacyDialogText: (document.getElementById("legacy-dialog-message")?.textContent || "").trim(), + legacyDialogOkText: (document.getElementById("legacy-dialog-ok")?.textContent || "").trim(), + importStatus: (document.querySelector(".comparison-import-status")?.textContent || "").trim(), + activeElementId: active?.id || null + } + }; + })()`); +} + +function assertSafety(snapshot, label, { allowBusy = false, allowDialog = false } = {}) { + if (!snapshot?.state?.playout) failKnown(`Native state is missing at ${label}.`); + const playout = snapshot.state.playout; + if (snapshot.safety.wrapped !== true) failUnknown(`The safety gate is absent at ${label}.`); + evidence.safety.observedOutboundTypes = snapshot.safety.outboundMessages.map(row => row.type); + evidence.safety.blockedOutboundMessages = snapshot.safety.blockedOutboundMessages; + evidence.safety.stateViolations = snapshot.safety.stateViolations; + evidence.safety.playoutIntentIssued = snapshot.safety.outboundMessages.some(row => + ["prepare-playout", "take-in", "next-playout", "take-out", + "choose-background", "toggle-background"].includes(row.type)); + if (snapshot.safety.blockedOutboundMessages.length) { + failKnown(`An unapproved outbound message was blocked at ${label}.`); + } + if (snapshot.safety.stateViolations.length) failUnknown(`DryRun left its safe state at ${label}.`); + if (playout.mode !== "dryRun" || playout.phase !== "idle" || + playout.isConnected === true || playout.preparedCode != null || + playout.onAirCode != null || playout.outcomeUnknown === true || + playout.isPlayCompletionPending === true || + playout.isTakeOutCompletionPending === true || playout.isBusy === true || + playout.refreshActive === true) { + failUnknown(`Playout is not exact DryRun IDLE at ${label}.`); + } + if (!allowBusy && snapshot.state.isBusy === true) failKnown(`The UI is busy at ${label}.`); + if (!allowDialog && snapshot.dom.legacyDialogHidden !== true) { + failKnown(`An unexpected native dialog is visible at ${label}.`); + } + if (snapshot.dom.url !== exactTargetUrl || snapshot.dom.readyState !== "complete" || + snapshot.dom.connectionText !== "DRY RUN") { + failKnown(`The exact DryRun document is not ready at ${label}.`); + } +} + +async function waitFor(label, predicate, options = {}) { + const allowMissingState = options.allowMissingState === true; + if (allowMissingState && label !== "DryRun IDLE preflight") { + failKnown("Only the input-free initial preflight may await the first native state."); + } + const deadline = Math.min(hardDeadline, Date.now() + (options.timeoutMilliseconds || 45_000)); + let last = null; + while (Date.now() < deadline) { + last = await readSnapshot(); + if (!last?.state?.playout && allowMissingState) { + if (last?.safety?.wrapped !== true) { + failUnknown("The safety gate is absent while awaiting the first native state."); + } + evidence.safety.observedOutboundTypes = + last.safety.outboundMessages.map(row => row.type); + evidence.safety.blockedOutboundMessages = last.safety.blockedOutboundMessages; + evidence.safety.stateViolations = last.safety.stateViolations; + if (last.safety.blockedOutboundMessages.length) { + failKnown("An unapproved outbound message was blocked before initial state."); + } + if (last.safety.stateViolations.length) { + failUnknown("A native state violation was observed before initial state."); + } + if (last.dom.url !== exactTargetUrl || last.dom.readyState !== "complete" || + last.dom.connectionText !== "DRY RUN") { + failKnown("The exact DryRun document changed while awaiting initial state."); + } + await sleep(100); + continue; + } + assertSafety(last, label, options); + if (predicate(last)) return last; + await sleep(100); + } + failUnknown(`Timed out waiting for ${label}; no input was retried.`); +} + +function exchangePath(sequence, suffix) { + return path.join(configuration.exchangeDirectory, + `${String(sequence).padStart(2, "0")}.${suffix}.json`); +} + +function readSmallJson(filePath, label) { + const stat = fs.statSync(filePath); + if (!stat.isFile() || stat.size <= 0 || stat.size > 64 * 1024) { + failKnown(`${label} is empty or too large.`); + } + const bytes = fs.readFileSync(filePath); + let value; + try { value = JSON.parse(bytes.toString("utf8")); } + catch { failKnown(`${label} is not strict JSON.`); } + return { bytes, value, sha256: sha256(bytes) }; +} + +async function elementGeometry(targetId) { + const expression = targetExpressions[targetId]; + if (!expression) failKnown(`Closed target ${targetId} is unavailable.`); + const value = await evaluate(`(() => { + const element = ${expression}; + if (!element) return null; + element.scrollIntoView({ block: "nearest", inline: "nearest" }); + const rect = element.getBoundingClientRect(); + return { + x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, + width: rect.width, height: rect.height, + viewport: { width: document.documentElement.clientWidth, + height: document.documentElement.clientHeight }, + devicePixelRatio: window.devicePixelRatio, + disabled: element.disabled === true, + hidden: element.hidden === true || element.closest("[hidden]") != null, + text: (element.textContent || "").trim(), + ariaCurrent: element.getAttribute("aria-current") + }; + })()`); + if (!value || value.disabled || value.hidden || value.width <= 0 || value.height <= 0 || + !Number.isFinite(value.x) || !Number.isFinite(value.y) || + value.x < 0 || value.y < 0 || value.x >= value.viewport.width || + value.y >= value.viewport.height || + value.devicePixelRatio < 0.25 || value.devicePixelRatio > 5) { + failKnown(`${targetId} is not a unique visible input target.`); + } + return value; +} + +async function requestExchange(operation, targetId = null, folderKind = null) { + const sequence = nextInputSequence++; + const token = randomBytes(16).toString("hex").toUpperCase(); + const geometry = targetId ? await elementGeometry(targetId) : null; + const pointerEventStart = operation === "observe-local-files" ? null : + await evaluate("globalThis.__legacyLocalStateProbe?.pointerEvents?.length ?? null"); + if (operation !== "observe-local-files" && !Number.isInteger(pointerEventStart)) { + failUnknown("The trusted pointer evidence baseline is unavailable."); + } + const request = { + schemaVersion: 1, + token, + sequence, + targetUrl: exactTargetUrl, + operation, + targetId, + folderKind, + point: geometry ? { x: geometry.x, y: geometry.y } : null, + viewport: geometry ? geometry.viewport : null, + devicePixelRatio: geometry ? geometry.devicePixelRatio : null, + mouseDownCalls: operation === "observe-local-files" ? 0 : 1, + mouseUpCalls: operation === "observe-local-files" ? 0 : 1, + escapeKeyCalls: operation === "folder-picker-click-cancel" ? 1 : 0, + inputRetryCount: 0 + }; + const requestPath = exchangePath(sequence, "request"); + const acknowledgementPath = exchangePath(sequence, "ack"); + const requestBytes = Buffer.from(`${JSON.stringify(request)}\n`, "utf8"); + fs.writeFileSync(requestPath, requestBytes, { flag: "wx" }); + const deadline = Math.min(hardDeadline, Date.now() + 45_000); + while (!fs.existsSync(acknowledgementPath)) { + if (Date.now() >= deadline) failUnknown(`Input exchange ${sequence} timed out; no retry was attempted.`); + await sleep(50); + } + const acknowledgement = readSmallJson(acknowledgementPath, + `Input acknowledgement ${sequence}`); + const ack = acknowledgement.value; + if (!ack || ack.schemaVersion !== 1 || ack.token !== token || ack.sequence !== sequence || + ack.result !== "PASS" || ack.operation !== operation || ack.targetId !== targetId || + ack.folderKind !== folderKind || ack.packageIdentityValidated !== true || + ack.dryRunValidated !== true || ack.tornadoConnectionCount !== 0 || + ack.inputRetryCount !== 0 || ack.mouseDownCalls !== request.mouseDownCalls || + ack.mouseUpCalls !== request.mouseUpCalls || ack.escapeKeyCalls !== request.escapeKeyCalls || + !ack.files) { + failKnown(`Input acknowledgement ${sequence} does not match its one-shot request.`); + } + const physicalPointer = operation === "observe-local-files" + ? null : await waitForTrustedPointerClick(targetId, pointerEventStart); + evidence.exchanges.push({ + sequence, operation, targetId, folderKind, + requestPath, requestSha256: sha256(requestBytes), + acknowledgementPath, acknowledgementSha256: acknowledgement.sha256, + picker: ack.picker || null, + physicalPointer, + files: ack.files + }); + return ack; +} + +async function waitForTrustedPointerClick(targetId, startIndex) { + const deadline = Math.min(hardDeadline, Date.now() + 10_000); + while (Date.now() < deadline) { + const events = await evaluate(`(() => { + const events = globalThis.__legacyLocalStateProbe?.pointerEvents || []; + return events.slice(${Number(startIndex)}).map(value => ({ ...value })); + })()`); + const relevant = events.filter(event => event.targetId === targetId); + if (relevant.length >= 2) { + const down = relevant.find(event => event.type === "pointerdown"); + const up = relevant.find(event => event.type === "pointerup"); + if (!down || !up || down.sequence >= up.sequence || down.isTrusted !== true || + up.isTrusted !== true || down.pointerType !== "mouse" || + up.pointerType !== "mouse" || down.button !== 0 || up.button !== 0 || + down.buttons !== 1 || up.buttons !== 0) { + failKnown(`The ${targetId} click did not produce one trusted mouse down/up pair.`); + } + return { down, up }; + } + await sleep(25); + } + failUnknown(`Trusted pointer evidence for ${targetId} did not appear; no click was retried.`); +} + +function assertExactPreflightFiles(files) { + if (files.source?.sha256 !== expectedSourceSha256 || files.source?.rowCount !== 8 || + files.source?.isReadOnlySource !== true || + files.destination?.sha256 !== expectedInitialDestinationSha256 || + files.destination?.rowCount !== 1 || + files.destination?.firstPair !== "KRX100지수|코스피 지수") { + failKnown("The original source or one-row current comparison baseline changed."); + } + evidence.files.settingsBaseline = files.settings; +} + +function assertSettingsUnchanged(files, label) { + if (JSON.stringify(files.settings) !== JSON.stringify(evidence.files.settingsBaseline)) { + failKnown(`The settings JSON changed ${label}.`); + } +} + +async function runSequence() { + await rpc("Runtime.enable"); + await rpc("Page.enable"); + await installProbe(); + const initial = await waitFor("DryRun IDLE preflight", snapshot => + snapshot.state?.playout?.mode === "dryRun" && snapshot.state.isBusy !== true, + { allowBusy: false, allowMissingState: true }); + + const preflight = await requestExchange("observe-local-files"); + assertExactPreflightFiles(preflight.files); + evidence.checkpoints.push({ name: "exact-file-preflight", files: preflight.files }); + + await requestExchange("application-click", "settings-navigation"); + await waitFor("settings workspace", snapshot => + snapshot.dom.settingsVisible === true && snapshot.dom.settingsCurrent === "page", + { allowBusy: false }); + + for (const [targetId, folderKind] of [ + ["design-folder-picker", "design"], + ["resource-folder-picker", "resource"], + ["background-folder-picker", "background"] + ]) { + const ack = await requestExchange("folder-picker-click-cancel", targetId, folderKind); + if (!ack.picker || ack.picker.uniqueNewPicker !== true || + ack.picker.ownerLinkedToApplication !== true || + ack.picker.escapeCancelled !== true || ack.picker.closedAfterEscape !== true) { + failKnown(`The ${folderKind} folder picker was not proven as the app-owned cancelled picker.`); + } + assertSettingsUnchanged(ack.files, `after cancelling ${folderKind}`); + evidence.settings.pickerKinds.push(folderKind); + await waitFor(`${folderKind} picker cancellation return`, snapshot => + snapshot.state.isBusy !== true && snapshot.dom.settingsVisible === true && + snapshot.dom.legacyDialogHidden === true, + { allowBusy: false }); + } + const settingsObservation = await requestExchange("observe-local-files"); + assertSettingsUnchanged(settingsObservation.files, "after all three picker cancellations"); + evidence.settings.allCancelled = true; + evidence.settings.settingsFileUnchanged = true; + + await requestExchange("application-click", "comparison-navigation"); + let comparison = await waitFor("comparison one-row baseline", snapshot => + snapshot.state.isBusy !== true && snapshot.dom.activeTabId === "comparison" && + snapshot.state.comparison?.isBusy !== true && + snapshot.state.comparison?.savedPairs?.length === 1 && + snapshot.state.comparison?.canImportLegacyPairs === true, + { allowBusy: false }); + const initialPair = stablePair(comparison.state.comparison.savedPairs[0]); + if (typeof initialPair?.rowId !== "string" || initialPair.rowId.length === 0 || + initialPair.rowNumber !== 1 || + initialPair.displayLabel !== "KRX100지수 ↔ 코스피 지수") { + failKnown("The visible one-row comparison baseline is not the expected known pair."); + } + evidence.comparison.initialPair = initialPair; + + await requestExchange("application-click", "comparison-import"); + await waitFor("first import confirmation", snapshot => + snapshot.state.dialog?.isConfirmation === true && + snapshot.state.dialog?.message === expectedImportConfirmation && + snapshot.dom.legacyDialogHidden === false && + snapshot.dom.legacyDialogText === expectedImportConfirmation && + snapshot.dom.legacyDialogOkText === "예", + { allowBusy: false, allowDialog: true }); + await requestExchange("application-click", "native-confirmation-yes"); + comparison = await waitFor("first import completion", snapshot => { + const model = snapshot.state.comparison; + const receipt = model?.lastImport; + return snapshot.state.isBusy !== true && model?.isBusy !== true && + snapshot.dom.legacyDialogHidden === true && model?.savedPairs?.length === 9 && + receipt?.sourceSha256 === expectedSourceSha256 && receipt?.sourceRowCount === 8 && + receipt?.addedPairCount === 8 && receipt?.skippedDuplicateCount === 0 && + receipt?.totalPairCount === 9; + }, { allowBusy: true, allowDialog: true, timeoutMilliseconds: 90_000 }); + const afterFirstPairs = comparison.state.comparison.savedPairs.map(stablePair); + if (JSON.stringify(afterFirstPairs[0]) !== JSON.stringify(initialPair)) { + failKnown("The original current comparison pair was not preserved at the front."); + } + evidence.comparison.initialPairPreserved = true; + evidence.comparison.afterFirstImport = { + receipt: comparison.state.comparison.lastImport, + pairCount: afterFirstPairs.length, + pairs: afterFirstPairs + }; + const afterFirstFiles = await requestExchange("observe-local-files"); + assertSettingsUnchanged(afterFirstFiles.files, "after the first comparison import"); + if (afterFirstFiles.files.destination?.rowCount !== 9 || + afterFirstFiles.files.destination?.firstPair !== "KRX100지수|코스피 지수" || + afterFirstFiles.files.destination?.sha256 === expectedInitialDestinationSha256) { + failKnown("The first comparison import file is not an appended nine-row result."); + } + evidence.files.destinationAfterFirstImport = afterFirstFiles.files.destination; + + await requestExchange("application-click", "comparison-import"); + await waitFor("second import confirmation", snapshot => + snapshot.state.dialog?.isConfirmation === true && + snapshot.state.dialog?.message === expectedImportConfirmation && + snapshot.dom.legacyDialogHidden === false, + { allowBusy: false, allowDialog: true }); + await requestExchange("application-click", "native-confirmation-yes"); + comparison = await waitFor("second import idempotent completion", snapshot => { + const model = snapshot.state.comparison; + const receipt = model?.lastImport; + return snapshot.state.isBusy !== true && model?.isBusy !== true && + snapshot.dom.legacyDialogHidden === true && model?.savedPairs?.length === 9 && + receipt?.sourceSha256 === expectedSourceSha256 && receipt?.sourceRowCount === 8 && + receipt?.addedPairCount === 0 && receipt?.skippedDuplicateCount === 8 && + receipt?.totalPairCount === 9; + }, { allowBusy: true, allowDialog: true, timeoutMilliseconds: 90_000 }); + const afterSecondPairs = comparison.state.comparison.savedPairs.map(stablePair); + if (JSON.stringify(afterSecondPairs) !== JSON.stringify(afterFirstPairs)) { + failKnown("The second import changed the ordered comparison pair list."); + } + const finalFiles = await requestExchange("observe-local-files"); + assertSettingsUnchanged(finalFiles.files, "after the second comparison import"); + if (JSON.stringify(finalFiles.files.destination) !== + JSON.stringify(afterFirstFiles.files.destination)) { + failKnown("The second import changed the comparison file hash or contents."); + } + if (finalFiles.files.source?.sha256 !== expectedSourceSha256 || + finalFiles.files.source?.rowCount !== 8) { + failUnknown("The read-only original source changed during the smoke."); + } + evidence.files.destinationAfterSecondImport = finalFiles.files.destination; + evidence.comparison.afterSecondImport = { + receipt: comparison.state.comparison.lastImport, + pairCount: afterSecondPairs.length, + pairs: afterSecondPairs + }; + evidence.comparison.secondImportIdempotent = true; + + const finalSnapshot = await readSnapshot(); + assertSafety(finalSnapshot, "final state", { allowBusy: false }); + evidence.finalSnapshot = finalSnapshot; + await captureScreenshot(); + await removeProbe(); + if (nextInputSequence !== 14 || evidence.exchanges.length !== 13) { + failUnknown("The closed thirteen-step exchange sequence did not complete exactly once."); + } + if (evidence.safety.observedOutboundTypes.filter(type => + type === "import-legacy-comparison-pairs").length !== 2 || + evidence.safety.observedOutboundTypes.filter(type => + type === "confirm-native-dialog").length !== 2 || + evidence.safety.observedOutboundTypes.filter(type => + type === "choose-operator-folder").length !== 3) { + failKnown("The two import confirmations did not emit exactly two request/Yes pairs."); + } + evidence.result = "PASS"; +} + +async function captureScreenshot() { + const response = await rpc("Page.captureScreenshot", { + format: "png", fromSurface: true, captureBeyondViewport: false + }, 20_000); + if (!response?.data) failKnown("CDP returned no screenshot bytes."); + const bytes = Buffer.from(response.data, "base64"); + fs.writeFileSync(configuration.screenshotPath, bytes, { flag: "wx" }); + evidence.screenshot = { + path: configuration.screenshotPath, + bytes: bytes.length, + sha256: sha256(bytes) + }; +} + +function errorProjection(error) { + return { + category: error instanceof SmokeFailure ? error.category : "HARNESS_ERROR", + name: error?.name || "Error", + message: String(error?.message || error) + }; +} + +let exitCode = 0; +try { + const target = await discoverTarget(); + evidence.target.id = target.id; + evidence.target.url = target.url; + await connect(validateEndpoint(target)); + await runSequence(); +} catch (error) { + exitCode = 1; + evidence.result = error instanceof SmokeFailure && error.category === "OUTCOME_UNKNOWN" + ? "OUTCOME_UNKNOWN" : "FAIL"; + evidence.error = errorProjection(error); + try { + if (!evidence.screenshot && socket?.readyState === WebSocket.OPEN) await captureScreenshot(); + } catch { /* Preserve the first failure. */ } + try { + if (probeInstalled && socket?.readyState === WebSocket.OPEN) await removeProbe(); + } catch { /* Preserve the first failure and leave state observable. */ } +} finally { + evidence.completedAt = new Date().toISOString(); + try { + const bytes = Buffer.from(`${JSON.stringify(evidence, null, 2)}\n`, "utf8"); + fs.writeFileSync(configuration.outputPath, bytes, { flag: "wx" }); + } catch { + exitCode = 1; + } + if (socket?.readyState === WebSocket.OPEN) socket.close(); +} + +process.exitCode = exitCode; diff --git a/scripts/Test-LegacyPackageUiOnlySmoke.mjs b/scripts/Test-LegacyPackageUiOnlySmoke.mjs index e8c140e..01a0e3d 100644 --- a/scripts/Test-LegacyPackageUiOnlySmoke.mjs +++ b/scripts/Test-LegacyPackageUiOnlySmoke.mjs @@ -311,9 +311,9 @@ function assertShell(shell) { } if (layout.scheduleVisible !== true || layout.scheduleHitTestVisible !== true || !Number.isFinite(layout.workspaceToScheduleWidthRatio) || - layout.workspaceToScheduleWidthRatio < 1.8 || - layout.workspaceToScheduleWidthRatio > 2.2) { - fail("KNOWN_FAILURE", "The package did not render a visible right schedule beside the approximately 2:1 workspace layout."); + layout.workspaceToScheduleWidthRatio < 1.60 || + layout.workspaceToScheduleWidthRatio > 1.90) { + fail("KNOWN_FAILURE", "The package did not render a visible right schedule beside the approximately 1.75:1 workspace layout."); } for (const [name, control] of Object.entries(shell.controls || {})) { if (!control?.present) fail("KNOWN_FAILURE", `Required shell control ${name} is missing.`); diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs index e6b29f9..517bfba 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs @@ -24,7 +24,7 @@ public sealed record ExpertRecommendationPreview( int PlayIndex, string StockCode, string StockName, - decimal BuyAmount); + decimal? BuyAmount); public sealed record ExpertPreviewResult( ExpertSelectionIdentity Identity, @@ -280,7 +280,6 @@ public sealed partial class LegacyExpertSelectionService : IExpertSelectionServi if (stockCodeValue is DBNull || stockNameValue is DBNull || - buyAmountValue is DBNull || playIndexValue is DBNull) { throw InvalidData(PreviewQueryName, "partial recommendation identity"); @@ -297,7 +296,13 @@ public sealed partial class LegacyExpertSelectionService : IExpertSelectionServi 3, MaximumStockNameLength, PreviewQueryName); - var buyAmount = ReadPositiveDecimal(row, 4, PreviewQueryName); + // The legacy EList grid displayed a database NULL buy amount as an + // empty editable cell. Keep that incomplete value in the read model; + // save and playout boundaries remain responsible for requiring a + // positive integer amount. + var buyAmount = buyAmountValue is DBNull + ? (decimal?)null + : ReadPositiveDecimal(row, 4, PreviewQueryName); var playIndex = ReadPlayIndex(row, 5, PreviewQueryName); if (!playIndexes.Add(playIndex)) { diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyComparisonPairImport.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyComparisonPairImport.cs index 3f47d75..e4801a3 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyComparisonPairImport.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyComparisonPairImport.cs @@ -71,6 +71,12 @@ public interface ILegacyComparisonFileSource CancellationToken cancellationToken = default); } +public interface ILegacyComparisonPairImportService +{ + Task ImportAsync( + CancellationToken cancellationToken = default); +} + /// /// Reads only the original application's fixed comparison-pair file. The Web /// request never supplies a path. The file is opened read-only, bounded, and @@ -510,7 +516,7 @@ public static class LegacyComparisonFileParser innerException); } -public sealed class LegacyComparisonPairImportService +public sealed class LegacyComparisonPairImportService : ILegacyComparisonPairImportService { private const string StockNameColumn = "STOCK_NAME"; private const string StockCodeColumn = "STOCK_CODE"; diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs index b67729a..971b643 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs @@ -95,12 +95,81 @@ public sealed record ManualFinancialSnapshot( ManualFinancialRecord Record, string RowVersion); +/// +/// The exact legacy INPUT_* strings, before the strict scene DTO parser is +/// applied. GraphE edited these strings first and the scene parsed them later. +/// Keeping this representation separate lets historical rows remain editable +/// without weakening the strict record used by playout. +/// +public sealed record ManualFinancialRawRecord( + ManualFinancialIdentity Identity, + IReadOnlyList StorageValues); + +/// +/// Process-local Oracle row identity. The native locator intentionally has no +/// public accessor and ToString never reveals it, so only Core can bind it back +/// to a fixed ROWID query. Browser callers receive an unrelated randomized +/// result id from LegacyApplication. +/// +public sealed class ManualFinancialRawRowHandle +{ + internal ManualFinancialRawRowHandle( + ManualFinancialScreenKind screen, + string locator) + { + Screen = screen; + Locator = locator; + } + + internal ManualFinancialScreenKind Screen { get; } + + internal string Locator { get; } + + public override string ToString() => "Opaque manual-financial row handle"; +} + +public sealed record ManualFinancialRawSnapshot( + ManualFinancialRawRecord Record, + string RowVersion, + ManualFinancialRawRowHandle Handle, + ManualFinancialSnapshot? StrictSnapshot) +{ + public bool IsPlayoutReady => StrictSnapshot is not null; +} + public sealed record ManualFinancialSearchResult( ManualFinancialScreenKind Screen, string Query, DateTimeOffset RetrievedAt, IReadOnlyList Items, - bool IsTruncated); + bool IsTruncated) +{ + /// + /// Rows whose legacy compound fields could not be parsed during this list + /// read. Their safe names may remain visible for parity, while an exact + /// GetAsync stays strict and never materializes an invalid row for editing + /// or playout. + /// + public int RejectedItemCount { get; init; } + + /// + /// Safe STOCK_NAME identities recovered from rows whose remaining legacy + /// compound fields are unreadable. The original GraphE listed these names + /// first and parsed the selected row later; callers may show them, but must + /// keep exact GetAsync strict before editing or playout. + /// + public IReadOnlyList UnreadableStockNames { get; init; } = + Array.Empty(); + + /// + /// Complete raw rows returned by the current Core implementation. Older + /// fakes/adapters may leave this empty and RawItemsComplete false. + /// + public IReadOnlyList RawItems { get; init; } = + Array.Empty(); + + public bool RawItemsComplete { get; init; } +} public sealed record ManualFinancialScreenContract( ManualFinancialScreenKind Screen, @@ -255,11 +324,24 @@ public static class ManualFinancialCutContracts VerifiedManualFinancialStock stock) { var validated = ManualFinancialValidation.Snapshot(snapshot, nameof(snapshot)); + return CreateSelection(validated.Record.Identity, stock); + } + + /// + /// Creates the legacy one-page schedule identity without claiming that the + /// current compound DB strings are scene-ready. PREPARE still performs its + /// fresh strict INPUT_* read before any engine command is dispatched. + /// + public static ManualFinancialCutSelection CreateSelection( + ManualFinancialIdentity identity, + VerifiedManualFinancialStock stock) + { + var validated = ManualFinancialValidation.Identity(identity, nameof(identity)); ArgumentNullException.ThrowIfNull(stock); ValidateProvider(stock.Market, stock.Source, nameof(stock)); _ = ManualFinancialValidation.StockCode(stock.Code, nameof(stock)); if (!string.Equals( - validated.Record.Identity.StockName, + validated.StockName, stock.Name, StringComparison.Ordinal)) { @@ -268,7 +350,7 @@ public static class ManualFinancialCutContracts nameof(stock)); } - var contract = Get(validated.Record.Identity.Screen); + var contract = Get(validated.Screen); var groupCode = stock.Market switch { StockMarket.Kospi => "KOSPI", @@ -282,7 +364,7 @@ public static class ManualFinancialCutContracts contract.CutCode, new LegacySceneSelection( groupCode, - validated.Record.Identity.StockName, + validated.StockName, contract.CanonicalGraphicType, string.Empty, string.Empty), @@ -499,6 +581,27 @@ public interface IManualFinancialScreenService CancellationToken cancellationToken = default); } +/// +/// Compatibility seam for GraphE's row-level raw editor. The opaque handle is +/// accepted only from a Core-produced snapshot; callers cannot provide a table +/// name or native locator string. +/// +public interface IManualFinancialRawScreenService +{ + Task GetRawAsync( + ManualFinancialRawSnapshot listedRow, + CancellationToken cancellationToken = default); + + Task UpdateRawAsync( + ManualFinancialRawSnapshot expected, + ManualFinancialRawRecord replacement, + CancellationToken cancellationToken = default); + + Task DeleteRawAsync( + ManualFinancialRawSnapshot expected, + CancellationToken cancellationToken = default); +} + public class ManualFinancialDataException : Exception { public ManualFinancialDataException(string message) @@ -571,10 +674,16 @@ public sealed class ManualFinancialMutationRejectedException : ManualFinancialMu /// representation, but replace concatenated SQL with fixed bound commands, /// exclusive-table identity checks and optimistic ROW_VERSION checks. /// -public sealed class LegacyManualFinancialScreenService : IManualFinancialScreenService +public sealed class LegacyManualFinancialScreenService : + IManualFinancialScreenService, + IManualFinancialRawScreenService { public const int DefaultMaximumResults = 200; - public const int MaximumResults = 1_000; + // The current legacy INPUT_* tables contain up to 2,349 rows and MainForm + // displayed the complete result without paging. Keep a finite safety bound, + // but place it above the verified development data instead of silently + // truncating the original operator list at 200 or 1,000 rows. + public const int MaximumResults = 5_000; public const int MaximumQueryLength = 64; private readonly IDataQueryExecutor _queryExecutor; @@ -633,14 +742,30 @@ public sealed class LegacyManualFinancialScreenService : IManualFinancialScreenS cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); - var snapshots = ParseRows(table, profile, rowLimit); - RejectDuplicateNames(snapshots, screen); + var snapshots = ParseRows( + table, + profile, + rowLimit, + maximumResults, + tolerateInvalidRows: true, + out var rejectedItemCount, + out var unreadableStockNames, + out var rawItems); return new ManualFinancialSearchResult( screen, normalizedQuery, DateTimeOffset.Now, snapshots.Take(maximumResults).ToArray(), - snapshots.Count > maximumResults); + // The database may have stopped at rowLimit before an invalid row + // was isolated. Preserve that boundary so callers never assume the + // valid subset is complete when more source rows may exist. + table.Rows.Count > maximumResults) + { + RejectedItemCount = rejectedItemCount, + UnreadableStockNames = unreadableStockNames, + RawItems = rawItems, + RawItemsComplete = true + }; } public async Task GetAsync( @@ -650,9 +775,16 @@ public sealed class LegacyManualFinancialScreenService : IManualFinancialScreenS var validatedIdentity = ManualFinancialValidation.Identity(identity, nameof(identity)); var profile = ManualFinancialSqlProfiles.Get(validatedIdentity.Screen); cancellationToken.ThrowIfCancellationRequested(); + var rowLimit = checked(MaximumResults + 1); var spec = new DataQuerySpec( profile.GetSql, - [new DataQueryParameter("stock_name", validatedIdentity.StockName, DbType.String)]); + [ + new DataQueryParameter( + "stock_name", + validatedIdentity.StockName, + DbType.String), + new DataQueryParameter("row_limit", rowLimit, DbType.Int32) + ]); spec.ValidateFor(DataSourceKind.Oracle); var table = await _queryExecutor.ExecuteAsync( DataSourceKind.Oracle, @@ -660,16 +792,94 @@ public sealed class LegacyManualFinancialScreenService : IManualFinancialScreenS spec, cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); - var rows = ParseRows(table, profile, maximumRows: 2); - return rows.Count switch + var rows = ParseRows( + table, + profile, + maximumRows: MaximumResults, + resultLimit: MaximumResults, + tolerateInvalidRows: false, + out _, + out _, + out _); + if (rows.Count == 0) { - 0 => throw new ManualFinancialNotFoundException(validatedIdentity), - 1 when rows[0].Record.Identity == validatedIdentity => rows[0], - 1 => throw InvalidData(profile, "record identity"), - _ => throw new ManualFinancialAmbiguousIdentityException( - validatedIdentity.Screen, - validatedIdentity.StockName) - }; + throw new ManualFinancialNotFoundException(validatedIdentity); + } + + if (rows.Any(row => row.Record.Identity != validatedIdentity)) + { + throw InvalidData(profile, "record identity"); + } + + var first = rows[0]; + if (rows.Skip(1).All(row => EquivalentExactSnapshot(first, row))) + { + return first; + } + + throw new ManualFinancialAmbiguousIdentityException( + validatedIdentity.Screen, + validatedIdentity.StockName); + } + + public async Task GetRawAsync( + ManualFinancialRawSnapshot listedRow, + CancellationToken cancellationToken = default) + { + var listed = ManualFinancialValidation.RawSnapshot(listedRow, nameof(listedRow)); + var profile = ManualFinancialSqlProfiles.Get(listed.Record.Identity.Screen); + cancellationToken.ThrowIfCancellationRequested(); + var spec = new DataQuerySpec( + profile.RawGetSql, + [ + new DataQueryParameter( + "row_handle", + listed.Handle.Locator, + DbType.String), + new DataQueryParameter( + "stock_name", + listed.Record.Identity.StockName, + DbType.String) + ]); + spec.ValidateFor(DataSourceKind.Oracle); + var table = await _queryExecutor.ExecuteAsync( + DataSourceKind.Oracle, + profile.RawGetQueryName, + spec, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + _ = ParseRows( + table, + profile, + maximumRows: 2, + resultLimit: 2, + tolerateInvalidRows: true, + out _, + out _, + out var rows); + if (rows.Count == 0) + { + throw new ManualFinancialNotFoundException(listed.Record.Identity); + } + + if (rows.Count != 1) + { + throw new ManualFinancialAmbiguousIdentityException( + listed.Record.Identity.Screen, + listed.Record.Identity.StockName); + } + + var result = rows[0]; + if (result.Record.Identity != listed.Record.Identity || + !string.Equals( + result.Handle.Locator, + listed.Handle.Locator, + StringComparison.Ordinal)) + { + throw InvalidData(profile, "raw row correlation"); + } + + return result; } public Task CreateAsync( @@ -725,6 +935,45 @@ public sealed class LegacyManualFinancialScreenService : IManualFinancialScreenS cancellationToken); } + public Task UpdateRawAsync( + ManualFinancialRawSnapshot expected, + ManualFinancialRawRecord replacement, + CancellationToken cancellationToken = default) + { + var validatedExpected = ManualFinancialValidation.RawSnapshot( + expected, + nameof(expected)); + var validatedReplacement = ManualFinancialValidation.RawRecord( + replacement, + nameof(replacement)); + if (validatedExpected.Record.Identity != validatedReplacement.Identity) + { + throw new ArgumentException( + "A raw GraphE update cannot rename a stock or change its screen.", + nameof(replacement)); + } + + var profile = ManualFinancialSqlProfiles.Get(validatedReplacement.Identity.Screen); + var parameters = CreateFieldParameters(validatedReplacement.StorageValues); + parameters.Add(StringParameter("row_handle", validatedExpected.Handle.Locator)); + parameters.Add(StringParameter( + "identity_stock_name", + validatedReplacement.Identity.StockName)); + parameters.Add(StringParameter( + "expected_row_version", + validatedExpected.RowVersion)); + return ExecuteMutationAsync( + profile, + ManualFinancialMutationKind.Update, + new ManualFinancialDbCommand( + ManualFinancialDbCommandKind.Update, + profile.RawUpdateSql, + parameters, + ManualFinancialAffectedRowsRule.ExactlyOne), + validatedReplacement.Identity.StockName, + cancellationToken); + } + public Task DeleteAsync( ManualFinancialSnapshot expected, CancellationToken cancellationToken = default) @@ -747,6 +996,30 @@ public sealed class LegacyManualFinancialScreenService : IManualFinancialScreenS cancellationToken); } + public Task DeleteRawAsync( + ManualFinancialRawSnapshot expected, + CancellationToken cancellationToken = default) + { + var validated = ManualFinancialValidation.RawSnapshot(expected, nameof(expected)); + var profile = ManualFinancialSqlProfiles.Get(validated.Record.Identity.Screen); + return ExecuteMutationAsync( + profile, + ManualFinancialMutationKind.DeleteOne, + new ManualFinancialDbCommand( + ManualFinancialDbCommandKind.DeleteOne, + profile.RawDeleteOneSql, + [ + StringParameter("row_handle", validated.Handle.Locator), + StringParameter( + "identity_stock_name", + validated.Record.Identity.StockName), + StringParameter("expected_row_version", validated.RowVersion) + ], + ManualFinancialAffectedRowsRule.ExactlyOne), + validated.Record.Identity.StockName, + cancellationToken); + } + public Task DeleteAllAsync( ManualFinancialScreenKind screen, CancellationToken cancellationToken = default) @@ -832,12 +1105,32 @@ public sealed class LegacyManualFinancialScreenService : IManualFinancialScreenS _ => false }; + private static bool EquivalentExactSnapshot( + ManualFinancialSnapshot left, + ManualFinancialSnapshot right) => + left.Record.Identity == right.Record.Identity && + string.Equals(left.RowVersion, right.RowVersion, StringComparison.Ordinal) && + ManualFinancialValidation.StorageValues(left.Record).SequenceEqual( + ManualFinancialValidation.StorageValues(right.Record), + StringComparer.Ordinal); + private static IReadOnlyList ParseRows( DataTable? table, ManualFinancialSqlProfile profile, - int maximumRows) + int maximumRows, + int resultLimit, + bool tolerateInvalidRows, + out int rejectedItemCount, + out IReadOnlyList unreadableStockNames, + out IReadOnlyList rawItems) { + rejectedItemCount = 0; + var unreadableNames = new List(); + var rawRows = new List(); + unreadableStockNames = Array.Empty(); + rawItems = Array.Empty(); if (table is null || table.Rows.Count > maximumRows || + resultLimit is < 1 || resultLimit > maximumRows || table.Columns.Count != profile.ResultColumns.Count) { throw InvalidData(profile, "schema or row bound"); @@ -856,37 +1149,62 @@ public sealed class LegacyManualFinancialScreenService : IManualFinancialScreenS } } - var result = new List(table.Rows.Count); - foreach (DataRow row in table.Rows) + var visibleRowCount = Math.Min(table.Rows.Count, resultLimit); + var result = new List(visibleRowCount); + for (var index = 0; index < visibleRowCount; index++) { + var row = table.Rows[index]; + ManualFinancialRawSnapshot raw; try { - result.Add(ManualFinancialValidation.Parse(profile.Screen, row)); + raw = ManualFinancialValidation.ParseRaw(profile.Screen, row); } catch (ArgumentException exception) { + if (tolerateInvalidRows) + { + rejectedItemCount++; + try + { + unreadableNames.Add( + ManualFinancialValidation.SearchStockName(row)); + } + catch (ArgumentException) + { + // The count remains visible, but an unsafe or absent name + // cannot become an operator-selectable identity. + } + continue; + } + throw new ManualFinancialDataException( $"Manual-financial data from {profile.SearchQueryName} is invalid: {exception.Message}"); } - } - return result; - } - - private static void RejectDuplicateNames( - IReadOnlyList snapshots, - ManualFinancialScreenKind screen) - { - var names = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var snapshot in snapshots) - { - if (!names.Add(snapshot.Record.Identity.StockName)) + try { - throw new ManualFinancialAmbiguousIdentityException( - screen, - snapshot.Record.Identity.StockName); + var strict = ManualFinancialValidation.Parse(profile.Screen, row); + raw = raw with { StrictSnapshot = strict }; + result.Add(strict); } + catch (ArgumentException exception) + { + if (!tolerateInvalidRows) + { + throw new ManualFinancialDataException( + $"Manual-financial data from {profile.SearchQueryName} is invalid: {exception.Message}"); + } + + rejectedItemCount++; + unreadableNames.Add(raw.Record.Identity.StockName); + } + + rawRows.Add(raw); } + + unreadableStockNames = Array.AsReadOnly(unreadableNames.ToArray()); + rawItems = Array.AsReadOnly(rawRows.ToArray()); + return result; } private static List CreateValueParameters( @@ -951,6 +1269,12 @@ internal static class ManualFinancialValidation internal static string StockName(string value, string parameterName) => Text(value, MaximumStockNameLength, allowEmpty: false, allowUnderscore: true, parameterName); + internal static string SearchStockName(DataRow row) + { + ArgumentNullException.ThrowIfNull(row); + return StockName(RequiredString(row, "STOCK_NAME"), nameof(row)); + } + internal static string StockCode(string value, string parameterName) { ArgumentNullException.ThrowIfNull(value, parameterName); @@ -991,6 +1315,78 @@ internal static class ManualFinancialValidation return new ManualFinancialSnapshot(record, rowVersion); } + internal static ManualFinancialRawRecord RawRecord( + ManualFinancialRawRecord? record, + string parameterName) + { + ArgumentNullException.ThrowIfNull(record, parameterName); + var identity = Identity(record.Identity, parameterName); + ArgumentNullException.ThrowIfNull(record.StorageValues, parameterName); + var expectedCount = ManualFinancialCutContracts.Get(identity.Screen) + .StorageColumns.Count - 1; + if (record.StorageValues.Count != expectedCount) + { + throw new ArgumentException( + "A raw manual-financial record has an invalid field count.", + parameterName); + } + + var values = new string[expectedCount]; + for (var index = 0; index < values.Length; index++) + { + var value = record.StorageValues[index] ?? throw new ArgumentException( + "A raw manual-financial field cannot be null.", + parameterName); + var maximumLength = RawStorageMaximumLength(identity.Screen, index); + if (value.Length > maximumLength || + value.Any(static character => + char.IsSurrogate(character) || character == '\uFFFD')) + { + throw new ArgumentException( + "A raw manual-financial field is invalid.", + parameterName); + } + + values[index] = value; + } + + return new ManualFinancialRawRecord( + identity, + Array.AsReadOnly(values)); + } + + internal static ManualFinancialRawSnapshot RawSnapshot( + ManualFinancialRawSnapshot? snapshot, + string parameterName) + { + ArgumentNullException.ThrowIfNull(snapshot, parameterName); + var record = RawRecord(snapshot.Record, parameterName); + var rowVersion = RowVersion(snapshot.RowVersion, parameterName); + ArgumentNullException.ThrowIfNull(snapshot.Handle, parameterName); + if (snapshot.Handle.Screen != record.Identity.Screen) + { + throw new ArgumentException( + "A raw manual-financial handle does not match its screen.", + parameterName); + } + + _ = RawRowLocator(snapshot.Handle.Locator, parameterName); + ManualFinancialSnapshot? strict = null; + if (snapshot.StrictSnapshot is not null) + { + strict = Snapshot(snapshot.StrictSnapshot, parameterName); + if (strict.Record.Identity != record.Identity || + !string.Equals(strict.RowVersion, rowVersion, StringComparison.Ordinal)) + { + throw new ArgumentException( + "A raw manual-financial strict projection is not correlated.", + parameterName); + } + } + + return new ManualFinancialRawSnapshot(record, rowVersion, snapshot.Handle, strict); + } + internal static ManualFinancialRecord Record( ManualFinancialRecord? record, string parameterName) @@ -1075,6 +1471,30 @@ internal static class ManualFinancialValidation nameof(row)); } + internal static ManualFinancialRawSnapshot ParseRaw( + ManualFinancialScreenKind screen, + DataRow row) + { + ArgumentNullException.ThrowIfNull(row); + var identity = Identity( + new ManualFinancialIdentity(screen, RequiredString(row, "STOCK_NAME")), + nameof(row)); + var columns = ManualFinancialCutContracts.Get(screen).StorageColumns; + var values = columns.Skip(1) + .Select(column => OptionalOracleString(row, column)) + .ToArray(); + var record = RawRecord( + new ManualFinancialRawRecord(identity, values), + nameof(row)); + var rowVersion = RowVersion(RequiredString(row, "ROW_VERSION"), nameof(row)); + var locator = RawRowLocator(RequiredString(row, "ROW_HANDLE"), nameof(row)); + return new ManualFinancialRawSnapshot( + record, + rowVersion, + new ManualFinancialRawRowHandle(screen, locator), + StrictSnapshot: null); + } + private static ManualRevenueCompositionRecord Revenue( ManualFinancialIdentity identity, ManualRevenueCompositionRecord record, @@ -1095,21 +1515,19 @@ internal static class ManualFinancialValidation continue; } - var label = Text( + var label = LegacyStoredText( slice.Label, MaximumTextLength, allowEmpty: false, - allowUnderscore: false, parameterName); - var percentageText = Text( + var percentageText = LegacyStoredText( slice.PercentageText, MaximumNumberTextLength, allowEmpty: false, - allowUnderscore: false, parameterName); var parsed = percentageText == "-" ? 0d - : ParseFinite(percentageText, allowThousands: false, parameterName); + : ParseFinite(percentageText, allowThousands: true, parameterName); Finite(slice.Percentage, parameterName); if (!parsed.Equals(slice.Percentage)) { @@ -1123,11 +1541,10 @@ internal static class ManualFinancialValidation return new ManualRevenueCompositionRecord( identity, - Text( + LegacyStoredText( record.BaseDate, MaximumTextLength, allowEmpty: true, - allowUnderscore: true, parameterName), slices); } @@ -1166,11 +1583,10 @@ internal static class ManualFinancialValidation { ArgumentNullException.ThrowIfNull(periods, parameterName); var values = periods.ToArray() - .Select(value => Text( + .Select(value => LegacyStoredText( value, MaximumTextLength, allowEmpty: true, - allowUnderscore: false, parameterName)) .ToArray(); return new ManualGrowthPeriodLabels(values[0], values[1], values[2], values[3]); @@ -1186,11 +1602,10 @@ internal static class ManualFinancialValidation } return quarters.Select(value => new ManualQuarterValue( - Text( + LegacyStoredText( value.Quarter, MaximumTextLength, - allowEmpty: false, - allowUnderscore: false, + allowEmpty: true, parameterName), value.Value)) .ToArray(); @@ -1244,7 +1659,7 @@ internal static class ManualFinancialValidation { var stored = RequiredString(row, "GUSUNG_" + (index + 1)); var tokens = stored.Split('_', StringSplitOptions.None); - if (tokens.Length != 2 || tokens[0].Length == 0) + if (tokens.Length < 2) { throw new ArgumentException("Quarter storage is invalid.", nameof(row)); } @@ -1259,11 +1674,10 @@ internal static class ManualFinancialValidation } values[index] = new ManualQuarterValue( - Text( - tokens[0], + LegacyStoredText( + LegacyManualFinancialStorageCompatibility.NormalizeQuarterLabel(tokens[0]), MaximumTextLength, - allowEmpty: false, - allowUnderscore: false, + allowEmpty: true, nameof(row)), number); } @@ -1279,33 +1693,40 @@ internal static class ManualFinancialValidation } var tokens = value.Split('_', StringSplitOptions.None); - if (tokens.Length != 2 || tokens[0].Length == 0) + if (tokens.Length < 2) { throw new ArgumentException("Revenue-slice storage is invalid.", nameof(value)); } - var percentageText = tokens[1] is "" or "-" ? tokens[1] : Text( + // GraphE always saved an empty editor slot as "_". Both its editor and + // s5076 inspected only the first two tokens and skipped the slot whenever + // the label token was empty. Treat that representation as an absent slice + // instead of rejecting a row that the original program could load and play. + if (tokens[0].Length == 0) + { + return null; + } + + var percentageText = tokens[1] is "" or "-" ? tokens[1] : LegacyStoredText( tokens[1], MaximumNumberTextLength, allowEmpty: false, - allowUnderscore: false, nameof(value)); var number = percentageText is "" or "-" ? 0d - : ParseFinite(percentageText, allowThousands: false, nameof(value)); + : ParseFinite(percentageText, allowThousands: true, nameof(value)); return new ManualRevenueSlice( - Text( + LegacyStoredText( tokens[0], MaximumTextLength, allowEmpty: false, - allowUnderscore: false, nameof(value)), percentageText.Length == 0 ? "0" : percentageText, number); } private static string SerializeSlice(ManualRevenueSlice? slice) => - slice is null ? string.Empty : slice.Label + "_" + slice.PercentageText; + slice is null ? "_" : slice.Label + "_" + slice.PercentageText; private static string SerializeGrowth(ManualGrowthSeriesValues values) => string.Join('_', values.ToArray().Select(value => value.HasValue @@ -1355,6 +1776,34 @@ internal static class ManualFinancialValidation return value; } + private static string RawRowLocator(string value, string parameterName) + { + ArgumentNullException.ThrowIfNull(value, parameterName); + if (value.Length is < 1 or > 128 || + value.Any(static character => + !char.IsAsciiLetterOrDigit(character) && character is not '+' and not '/')) + { + throw new ArgumentException( + "A raw manual-financial row handle is invalid.", + parameterName); + } + + return value; + } + + private static int RawStorageMaximumLength( + ManualFinancialScreenKind screen, + int index) => screen switch + { + ManualFinancialScreenKind.RevenueComposition when index == 0 => 30, + ManualFinancialScreenKind.GrowthMetrics when index == 4 => 40, + ManualFinancialScreenKind.RevenueComposition or + ManualFinancialScreenKind.GrowthMetrics or + ManualFinancialScreenKind.Sales or + ManualFinancialScreenKind.OperatingProfit => 50, + _ => throw new ArgumentOutOfRangeException(nameof(screen)) + }; + private static string Text( string value, int maximumLength, @@ -1376,6 +1825,25 @@ internal static class ManualFinancialValidation return value; } + private static string LegacyStoredText( + string value, + int maximumLength, + bool allowEmpty, + string parameterName) + { + ArgumentNullException.ThrowIfNull(value, parameterName); + if ((!allowEmpty && value.Length == 0) || + value.Length > maximumLength || + !IsSafe(value)) + { + throw new ArgumentException( + "A legacy manual-financial text value is invalid.", + parameterName); + } + + return value; + } + private static double ParseFinite( string value, bool allowThousands, @@ -1416,23 +1884,38 @@ internal sealed record ManualFinancialSqlProfile( string TableName, string SearchQueryName, string GetQueryName, + string RawGetQueryName, string SearchSql, string GetSql, + string RawGetSql, string ExclusiveTableLockSql, string InsertSql, string UpdateSql, + string RawUpdateSql, string DeleteOneSql, + string RawDeleteOneSql, string DeleteAllSql, IReadOnlyList ResultColumns); internal static class ManualFinancialSqlProfiles { + private const string RowVersionExpressionMarker = "__ROW_VERSION_EXPRESSION__"; + private static readonly string[] PieColumns = - ["STOCK_NAME", "BASE_DATE", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "ROW_VERSION"]; + ["STOCK_NAME", "BASE_DATE", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "ROW_VERSION", "ROW_HANDLE"]; private static readonly string[] GrowColumns = - ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "BASE_DATE", "ROW_VERSION"]; + ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "BASE_DATE", "ROW_VERSION", "ROW_HANDLE"]; private static readonly string[] SixValueColumns = - ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6", "ROW_VERSION"]; + ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6", "ROW_VERSION", "ROW_HANDLE"]; + + private static readonly string PieRowVersionExpression = + CreateRowVersionExpression(PieColumns[..^2]); + private static readonly string GrowRowVersionExpression = + CreateRowVersionExpression(GrowColumns[..^2]); + private static readonly string SellRowVersionExpression = + CreateRowVersionExpression(SixValueColumns[..^2]); + private static readonly string ProfitRowVersionExpression = + CreateRowVersionExpression(SixValueColumns[..^2]); private static readonly IReadOnlyDictionary Profiles = new ReadOnlyDictionary( @@ -1443,12 +1926,16 @@ internal static class ManualFinancialSqlProfiles "INPUT_PIE", "MANUAL_FINANCIAL_PIE_SEARCH", "MANUAL_FINANCIAL_PIE_GET", - PieSearchSql, - PieGetSql, + "MANUAL_FINANCIAL_PIE_RAW_GET", + WithRowVersion(PieSearchSql, PieRowVersionExpression), + WithRowVersion(PieGetSql, PieRowVersionExpression), + WithRowVersion(PieRawGetSql, PieRowVersionExpression), "LOCK TABLE INPUT_PIE IN EXCLUSIVE MODE", PieInsertSql, - PieUpdateSql, - PieDeleteOneSql, + WithRowVersion(PieUpdateSql, PieRowVersionExpression), + WithRowVersion(PieRawUpdateSql, PieRowVersionExpression), + WithRowVersion(PieDeleteOneSql, PieRowVersionExpression), + WithRowVersion(PieRawDeleteOneSql, PieRowVersionExpression), "DELETE FROM INPUT_PIE", Array.AsReadOnly(PieColumns)), [ManualFinancialScreenKind.GrowthMetrics] = new( @@ -1456,12 +1943,16 @@ internal static class ManualFinancialSqlProfiles "INPUT_GROW", "MANUAL_FINANCIAL_GROW_SEARCH", "MANUAL_FINANCIAL_GROW_GET", - GrowSearchSql, - GrowGetSql, + "MANUAL_FINANCIAL_GROW_RAW_GET", + WithRowVersion(GrowSearchSql, GrowRowVersionExpression), + WithRowVersion(GrowGetSql, GrowRowVersionExpression), + WithRowVersion(GrowRawGetSql, GrowRowVersionExpression), "LOCK TABLE INPUT_GROW IN EXCLUSIVE MODE", GrowInsertSql, - GrowUpdateSql, - GrowDeleteOneSql, + WithRowVersion(GrowUpdateSql, GrowRowVersionExpression), + WithRowVersion(GrowRawUpdateSql, GrowRowVersionExpression), + WithRowVersion(GrowDeleteOneSql, GrowRowVersionExpression), + WithRowVersion(GrowRawDeleteOneSql, GrowRowVersionExpression), "DELETE FROM INPUT_GROW", Array.AsReadOnly(GrowColumns)), [ManualFinancialScreenKind.Sales] = new( @@ -1469,12 +1960,16 @@ internal static class ManualFinancialSqlProfiles "INPUT_SELL", "MANUAL_FINANCIAL_SELL_SEARCH", "MANUAL_FINANCIAL_SELL_GET", - SellSearchSql, - SellGetSql, + "MANUAL_FINANCIAL_SELL_RAW_GET", + WithRowVersion(SellSearchSql, SellRowVersionExpression), + WithRowVersion(SellGetSql, SellRowVersionExpression), + WithRowVersion(SellRawGetSql, SellRowVersionExpression), "LOCK TABLE INPUT_SELL IN EXCLUSIVE MODE", SellInsertSql, - SellUpdateSql, - SellDeleteOneSql, + WithRowVersion(SellUpdateSql, SellRowVersionExpression), + WithRowVersion(SellRawUpdateSql, SellRowVersionExpression), + WithRowVersion(SellDeleteOneSql, SellRowVersionExpression), + WithRowVersion(SellRawDeleteOneSql, SellRowVersionExpression), "DELETE FROM INPUT_SELL", Array.AsReadOnly(SixValueColumns)), [ManualFinancialScreenKind.OperatingProfit] = new( @@ -1482,12 +1977,16 @@ internal static class ManualFinancialSqlProfiles "INPUT_PROFIT", "MANUAL_FINANCIAL_PROFIT_SEARCH", "MANUAL_FINANCIAL_PROFIT_GET", - ProfitSearchSql, - ProfitGetSql, + "MANUAL_FINANCIAL_PROFIT_RAW_GET", + WithRowVersion(ProfitSearchSql, ProfitRowVersionExpression), + WithRowVersion(ProfitGetSql, ProfitRowVersionExpression), + WithRowVersion(ProfitRawGetSql, ProfitRowVersionExpression), "LOCK TABLE INPUT_PROFIT IN EXCLUSIVE MODE", ProfitInsertSql, - ProfitUpdateSql, - ProfitDeleteOneSql, + WithRowVersion(ProfitUpdateSql, ProfitRowVersionExpression), + WithRowVersion(ProfitRawUpdateSql, ProfitRowVersionExpression), + WithRowVersion(ProfitDeleteOneSql, ProfitRowVersionExpression), + WithRowVersion(ProfitRawDeleteOneSql, ProfitRowVersionExpression), "DELETE FROM INPUT_PROFIT", Array.AsReadOnly(SixValueColumns)) }); @@ -1497,19 +1996,58 @@ internal static class ManualFinancialSqlProfiles ? profile : throw new ArgumentOutOfRangeException(nameof(screen)); + private static string CreateRowVersionExpression(IReadOnlyList columns) + { + if (columns.Count == 0 || columns.Any(static column => + !DataQueryParameter.IsValidName(column))) + { + throw new InvalidOperationException( + "A manual-financial row-version column set is invalid."); + } + + // Oracle treats an empty VARCHAR2 as NULL. Each NULL therefore gets a + // one-character N tag, while each non-null value gets a V tag followed + // by a fixed ten-digit character length and the exact value. This code + // is prefix-decodable even when values contain NUL, CHR(31), digits or + // text that previously acted as a structural delimiter. + var encodedColumns = columns.Select(static column => + $"CASE WHEN {column} IS NULL THEN 'N' " + + $"ELSE 'V' || LPAD(TO_CHAR(LENGTH({column}), " + + $"'FM9999999990'), 10, '0') || {column} END"); + return "RAWTOHEX(STANDARD_HASH(" + + string.Join(" || ", encodedColumns) + + ", 'SHA256'))"; + } + + private static string WithRowVersion(string sqlTemplate, string expression) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sqlTemplate); + ArgumentException.ThrowIfNullOrWhiteSpace(expression); + var first = sqlTemplate.IndexOf( + RowVersionExpressionMarker, + StringComparison.Ordinal); + if (first < 0 || sqlTemplate.IndexOf( + RowVersionExpressionMarker, + first + RowVersionExpressionMarker.Length, + StringComparison.Ordinal) >= 0) + { + throw new InvalidOperationException( + "A manual-financial SQL template must contain one row-version marker."); + } + + return sqlTemplate.Replace( + RowVersionExpressionMarker, + expression, + StringComparison.Ordinal); + } + private const string PieSearchSql = """ - SELECT STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, ROW_VERSION + SELECT STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, ROW_VERSION, ROW_HANDLE FROM ( SELECT STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, - RAWTOHEX(STANDARD_HASH( - NVL(STOCK_NAME, CHR(0)) || CHR(31) || - NVL(BASE_DATE, CHR(0)) || CHR(31) || - NVL(GUSUNG_1, CHR(0)) || CHR(31) || - NVL(GUSUNG_2, CHR(0)) || CHR(31) || - NVL(GUSUNG_3, CHR(0)) || CHR(31) || - NVL(GUSUNG_4, CHR(0)) || CHR(31) || - NVL(GUSUNG_5, CHR(0)), 'SHA256')) ROW_VERSION + __ROW_VERSION_EXPRESSION__ ROW_VERSION, + ROWIDTOCHAR(ROWID) ROW_HANDLE FROM INPUT_PIE WHERE (:search_empty = 1 OR UPPER(STOCK_NAME) LIKE '%' || :search_term || '%' ESCAPE '!') @@ -1520,21 +2058,25 @@ internal static class ManualFinancialSqlProfiles private const string PieGetSql = """ - SELECT STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, ROW_VERSION + SELECT STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, ROW_VERSION, ROW_HANDLE FROM ( SELECT STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, - RAWTOHEX(STANDARD_HASH( - NVL(STOCK_NAME, CHR(0)) || CHR(31) || - NVL(BASE_DATE, CHR(0)) || CHR(31) || - NVL(GUSUNG_1, CHR(0)) || CHR(31) || - NVL(GUSUNG_2, CHR(0)) || CHR(31) || - NVL(GUSUNG_3, CHR(0)) || CHR(31) || - NVL(GUSUNG_4, CHR(0)) || CHR(31) || - NVL(GUSUNG_5, CHR(0)), 'SHA256')) ROW_VERSION + __ROW_VERSION_EXPRESSION__ ROW_VERSION, + ROWIDTOCHAR(ROWID) ROW_HANDLE FROM INPUT_PIE WHERE STOCK_NAME = :stock_name ) - WHERE ROWNUM <= 2 + WHERE ROWNUM <= :row_limit + """; + + private const string PieRawGetSql = + """ + SELECT STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, + __ROW_VERSION_EXPRESSION__ ROW_VERSION, + ROWIDTOCHAR(ROWID) ROW_HANDLE + FROM INPUT_PIE + WHERE ROWID = CHARTOROWID(:row_handle) + AND STOCK_NAME = :stock_name """; private const string PieInsertSql = @@ -1556,44 +2098,47 @@ internal static class ManualFinancialSqlProfiles GUSUNG_4 = :field_5, GUSUNG_5 = :field_6 WHERE STOCK_NAME = :identity_stock_name - AND RAWTOHEX(STANDARD_HASH( - NVL(STOCK_NAME, CHR(0)) || CHR(31) || - NVL(BASE_DATE, CHR(0)) || CHR(31) || - NVL(GUSUNG_1, CHR(0)) || CHR(31) || - NVL(GUSUNG_2, CHR(0)) || CHR(31) || - NVL(GUSUNG_3, CHR(0)) || CHR(31) || - NVL(GUSUNG_4, CHR(0)) || CHR(31) || - NVL(GUSUNG_5, CHR(0)), 'SHA256')) = :expected_row_version + AND __ROW_VERSION_EXPRESSION__ = :expected_row_version AND (SELECT COUNT(*) FROM INPUT_PIE WHERE STOCK_NAME = :unique_stock_name) = 1 """; + private const string PieRawUpdateSql = + """ + UPDATE INPUT_PIE + SET BASE_DATE = :field_1, + GUSUNG_1 = :field_2, + GUSUNG_2 = :field_3, + GUSUNG_3 = :field_4, + GUSUNG_4 = :field_5, + GUSUNG_5 = :field_6 + WHERE ROWID = CHARTOROWID(:row_handle) + AND STOCK_NAME = :identity_stock_name + AND __ROW_VERSION_EXPRESSION__ = :expected_row_version + """; + private const string PieDeleteOneSql = """ DELETE FROM INPUT_PIE WHERE STOCK_NAME = :identity_stock_name - AND RAWTOHEX(STANDARD_HASH( - NVL(STOCK_NAME, CHR(0)) || CHR(31) || - NVL(BASE_DATE, CHR(0)) || CHR(31) || - NVL(GUSUNG_1, CHR(0)) || CHR(31) || - NVL(GUSUNG_2, CHR(0)) || CHR(31) || - NVL(GUSUNG_3, CHR(0)) || CHR(31) || - NVL(GUSUNG_4, CHR(0)) || CHR(31) || - NVL(GUSUNG_5, CHR(0)), 'SHA256')) = :expected_row_version + AND __ROW_VERSION_EXPRESSION__ = :expected_row_version AND (SELECT COUNT(*) FROM INPUT_PIE WHERE STOCK_NAME = :unique_stock_name) = 1 """; + private const string PieRawDeleteOneSql = + """ + DELETE FROM INPUT_PIE + WHERE ROWID = CHARTOROWID(:row_handle) + AND STOCK_NAME = :identity_stock_name + AND __ROW_VERSION_EXPRESSION__ = :expected_row_version + """; + private const string GrowSearchSql = """ - SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE, ROW_VERSION + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE, ROW_VERSION, ROW_HANDLE FROM ( SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE, - RAWTOHEX(STANDARD_HASH( - NVL(STOCK_NAME, CHR(0)) || CHR(31) || - NVL(GUSUNG_1, CHR(0)) || CHR(31) || - NVL(GUSUNG_2, CHR(0)) || CHR(31) || - NVL(GUSUNG_3, CHR(0)) || CHR(31) || - NVL(GUSUNG_4, CHR(0)) || CHR(31) || - NVL(BASE_DATE, CHR(0)), 'SHA256')) ROW_VERSION + __ROW_VERSION_EXPRESSION__ ROW_VERSION, + ROWIDTOCHAR(ROWID) ROW_HANDLE FROM INPUT_GROW WHERE (:search_empty = 1 OR UPPER(STOCK_NAME) LIKE '%' || :search_term || '%' ESCAPE '!') @@ -1604,20 +2149,25 @@ internal static class ManualFinancialSqlProfiles private const string GrowGetSql = """ - SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE, ROW_VERSION + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE, ROW_VERSION, ROW_HANDLE FROM ( SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE, - RAWTOHEX(STANDARD_HASH( - NVL(STOCK_NAME, CHR(0)) || CHR(31) || - NVL(GUSUNG_1, CHR(0)) || CHR(31) || - NVL(GUSUNG_2, CHR(0)) || CHR(31) || - NVL(GUSUNG_3, CHR(0)) || CHR(31) || - NVL(GUSUNG_4, CHR(0)) || CHR(31) || - NVL(BASE_DATE, CHR(0)), 'SHA256')) ROW_VERSION + __ROW_VERSION_EXPRESSION__ ROW_VERSION, + ROWIDTOCHAR(ROWID) ROW_HANDLE FROM INPUT_GROW WHERE STOCK_NAME = :stock_name ) - WHERE ROWNUM <= 2 + WHERE ROWNUM <= :row_limit + """; + + private const string GrowRawGetSql = + """ + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE, + __ROW_VERSION_EXPRESSION__ ROW_VERSION, + ROWIDTOCHAR(ROWID) ROW_HANDLE + FROM INPUT_GROW + WHERE ROWID = CHARTOROWID(:row_handle) + AND STOCK_NAME = :stock_name """; private const string GrowInsertSql = @@ -1638,43 +2188,46 @@ internal static class ManualFinancialSqlProfiles GUSUNG_4 = :field_4, BASE_DATE = :field_5 WHERE STOCK_NAME = :identity_stock_name - AND RAWTOHEX(STANDARD_HASH( - NVL(STOCK_NAME, CHR(0)) || CHR(31) || - NVL(GUSUNG_1, CHR(0)) || CHR(31) || - NVL(GUSUNG_2, CHR(0)) || CHR(31) || - NVL(GUSUNG_3, CHR(0)) || CHR(31) || - NVL(GUSUNG_4, CHR(0)) || CHR(31) || - NVL(BASE_DATE, CHR(0)), 'SHA256')) = :expected_row_version + AND __ROW_VERSION_EXPRESSION__ = :expected_row_version AND (SELECT COUNT(*) FROM INPUT_GROW WHERE STOCK_NAME = :unique_stock_name) = 1 """; + private const string GrowRawUpdateSql = + """ + UPDATE INPUT_GROW + SET GUSUNG_1 = :field_1, + GUSUNG_2 = :field_2, + GUSUNG_3 = :field_3, + GUSUNG_4 = :field_4, + BASE_DATE = :field_5 + WHERE ROWID = CHARTOROWID(:row_handle) + AND STOCK_NAME = :identity_stock_name + AND __ROW_VERSION_EXPRESSION__ = :expected_row_version + """; + private const string GrowDeleteOneSql = """ DELETE FROM INPUT_GROW WHERE STOCK_NAME = :identity_stock_name - AND RAWTOHEX(STANDARD_HASH( - NVL(STOCK_NAME, CHR(0)) || CHR(31) || - NVL(GUSUNG_1, CHR(0)) || CHR(31) || - NVL(GUSUNG_2, CHR(0)) || CHR(31) || - NVL(GUSUNG_3, CHR(0)) || CHR(31) || - NVL(GUSUNG_4, CHR(0)) || CHR(31) || - NVL(BASE_DATE, CHR(0)), 'SHA256')) = :expected_row_version + AND __ROW_VERSION_EXPRESSION__ = :expected_row_version AND (SELECT COUNT(*) FROM INPUT_GROW WHERE STOCK_NAME = :unique_stock_name) = 1 """; + private const string GrowRawDeleteOneSql = + """ + DELETE FROM INPUT_GROW + WHERE ROWID = CHARTOROWID(:row_handle) + AND STOCK_NAME = :identity_stock_name + AND __ROW_VERSION_EXPRESSION__ = :expected_row_version + """; + private const string SellSearchSql = """ - SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, ROW_VERSION + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, ROW_VERSION, ROW_HANDLE FROM ( SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, - RAWTOHEX(STANDARD_HASH( - NVL(STOCK_NAME, CHR(0)) || CHR(31) || - NVL(GUSUNG_1, CHR(0)) || CHR(31) || - NVL(GUSUNG_2, CHR(0)) || CHR(31) || - NVL(GUSUNG_3, CHR(0)) || CHR(31) || - NVL(GUSUNG_4, CHR(0)) || CHR(31) || - NVL(GUSUNG_5, CHR(0)) || CHR(31) || - NVL(GUSUNG_6, CHR(0)), 'SHA256')) ROW_VERSION + __ROW_VERSION_EXPRESSION__ ROW_VERSION, + ROWIDTOCHAR(ROWID) ROW_HANDLE FROM INPUT_SELL WHERE (:search_empty = 1 OR UPPER(STOCK_NAME) LIKE '%' || :search_term || '%' ESCAPE '!') @@ -1685,21 +2238,25 @@ internal static class ManualFinancialSqlProfiles private const string SellGetSql = """ - SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, ROW_VERSION + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, ROW_VERSION, ROW_HANDLE FROM ( SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, - RAWTOHEX(STANDARD_HASH( - NVL(STOCK_NAME, CHR(0)) || CHR(31) || - NVL(GUSUNG_1, CHR(0)) || CHR(31) || - NVL(GUSUNG_2, CHR(0)) || CHR(31) || - NVL(GUSUNG_3, CHR(0)) || CHR(31) || - NVL(GUSUNG_4, CHR(0)) || CHR(31) || - NVL(GUSUNG_5, CHR(0)) || CHR(31) || - NVL(GUSUNG_6, CHR(0)), 'SHA256')) ROW_VERSION + __ROW_VERSION_EXPRESSION__ ROW_VERSION, + ROWIDTOCHAR(ROWID) ROW_HANDLE FROM INPUT_SELL WHERE STOCK_NAME = :stock_name ) - WHERE ROWNUM <= 2 + WHERE ROWNUM <= :row_limit + """; + + private const string SellRawGetSql = + """ + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, + __ROW_VERSION_EXPRESSION__ ROW_VERSION, + ROWIDTOCHAR(ROWID) ROW_HANDLE + FROM INPUT_SELL + WHERE ROWID = CHARTOROWID(:row_handle) + AND STOCK_NAME = :stock_name """; private const string SellInsertSql = @@ -1721,45 +2278,47 @@ internal static class ManualFinancialSqlProfiles GUSUNG_5 = :field_5, GUSUNG_6 = :field_6 WHERE STOCK_NAME = :identity_stock_name - AND RAWTOHEX(STANDARD_HASH( - NVL(STOCK_NAME, CHR(0)) || CHR(31) || - NVL(GUSUNG_1, CHR(0)) || CHR(31) || - NVL(GUSUNG_2, CHR(0)) || CHR(31) || - NVL(GUSUNG_3, CHR(0)) || CHR(31) || - NVL(GUSUNG_4, CHR(0)) || CHR(31) || - NVL(GUSUNG_5, CHR(0)) || CHR(31) || - NVL(GUSUNG_6, CHR(0)), 'SHA256')) = :expected_row_version + AND __ROW_VERSION_EXPRESSION__ = :expected_row_version AND (SELECT COUNT(*) FROM INPUT_SELL WHERE STOCK_NAME = :unique_stock_name) = 1 """; + private const string SellRawUpdateSql = + """ + UPDATE INPUT_SELL + SET GUSUNG_1 = :field_1, + GUSUNG_2 = :field_2, + GUSUNG_3 = :field_3, + GUSUNG_4 = :field_4, + GUSUNG_5 = :field_5, + GUSUNG_6 = :field_6 + WHERE ROWID = CHARTOROWID(:row_handle) + AND STOCK_NAME = :identity_stock_name + AND __ROW_VERSION_EXPRESSION__ = :expected_row_version + """; + private const string SellDeleteOneSql = """ DELETE FROM INPUT_SELL WHERE STOCK_NAME = :identity_stock_name - AND RAWTOHEX(STANDARD_HASH( - NVL(STOCK_NAME, CHR(0)) || CHR(31) || - NVL(GUSUNG_1, CHR(0)) || CHR(31) || - NVL(GUSUNG_2, CHR(0)) || CHR(31) || - NVL(GUSUNG_3, CHR(0)) || CHR(31) || - NVL(GUSUNG_4, CHR(0)) || CHR(31) || - NVL(GUSUNG_5, CHR(0)) || CHR(31) || - NVL(GUSUNG_6, CHR(0)), 'SHA256')) = :expected_row_version + AND __ROW_VERSION_EXPRESSION__ = :expected_row_version AND (SELECT COUNT(*) FROM INPUT_SELL WHERE STOCK_NAME = :unique_stock_name) = 1 """; + private const string SellRawDeleteOneSql = + """ + DELETE FROM INPUT_SELL + WHERE ROWID = CHARTOROWID(:row_handle) + AND STOCK_NAME = :identity_stock_name + AND __ROW_VERSION_EXPRESSION__ = :expected_row_version + """; + private const string ProfitSearchSql = """ - SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, ROW_VERSION + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, ROW_VERSION, ROW_HANDLE FROM ( SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, - RAWTOHEX(STANDARD_HASH( - NVL(STOCK_NAME, CHR(0)) || CHR(31) || - NVL(GUSUNG_1, CHR(0)) || CHR(31) || - NVL(GUSUNG_2, CHR(0)) || CHR(31) || - NVL(GUSUNG_3, CHR(0)) || CHR(31) || - NVL(GUSUNG_4, CHR(0)) || CHR(31) || - NVL(GUSUNG_5, CHR(0)) || CHR(31) || - NVL(GUSUNG_6, CHR(0)), 'SHA256')) ROW_VERSION + __ROW_VERSION_EXPRESSION__ ROW_VERSION, + ROWIDTOCHAR(ROWID) ROW_HANDLE FROM INPUT_PROFIT WHERE (:search_empty = 1 OR UPPER(STOCK_NAME) LIKE '%' || :search_term || '%' ESCAPE '!') @@ -1770,21 +2329,25 @@ internal static class ManualFinancialSqlProfiles private const string ProfitGetSql = """ - SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, ROW_VERSION + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, ROW_VERSION, ROW_HANDLE FROM ( SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, - RAWTOHEX(STANDARD_HASH( - NVL(STOCK_NAME, CHR(0)) || CHR(31) || - NVL(GUSUNG_1, CHR(0)) || CHR(31) || - NVL(GUSUNG_2, CHR(0)) || CHR(31) || - NVL(GUSUNG_3, CHR(0)) || CHR(31) || - NVL(GUSUNG_4, CHR(0)) || CHR(31) || - NVL(GUSUNG_5, CHR(0)) || CHR(31) || - NVL(GUSUNG_6, CHR(0)), 'SHA256')) ROW_VERSION + __ROW_VERSION_EXPRESSION__ ROW_VERSION, + ROWIDTOCHAR(ROWID) ROW_HANDLE FROM INPUT_PROFIT WHERE STOCK_NAME = :stock_name ) - WHERE ROWNUM <= 2 + WHERE ROWNUM <= :row_limit + """; + + private const string ProfitRawGetSql = + """ + SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, + __ROW_VERSION_EXPRESSION__ ROW_VERSION, + ROWIDTOCHAR(ROWID) ROW_HANDLE + FROM INPUT_PROFIT + WHERE ROWID = CHARTOROWID(:row_handle) + AND STOCK_NAME = :stock_name """; private const string ProfitInsertSql = @@ -1806,29 +2369,37 @@ internal static class ManualFinancialSqlProfiles GUSUNG_5 = :field_5, GUSUNG_6 = :field_6 WHERE STOCK_NAME = :identity_stock_name - AND RAWTOHEX(STANDARD_HASH( - NVL(STOCK_NAME, CHR(0)) || CHR(31) || - NVL(GUSUNG_1, CHR(0)) || CHR(31) || - NVL(GUSUNG_2, CHR(0)) || CHR(31) || - NVL(GUSUNG_3, CHR(0)) || CHR(31) || - NVL(GUSUNG_4, CHR(0)) || CHR(31) || - NVL(GUSUNG_5, CHR(0)) || CHR(31) || - NVL(GUSUNG_6, CHR(0)), 'SHA256')) = :expected_row_version + AND __ROW_VERSION_EXPRESSION__ = :expected_row_version AND (SELECT COUNT(*) FROM INPUT_PROFIT WHERE STOCK_NAME = :unique_stock_name) = 1 """; + private const string ProfitRawUpdateSql = + """ + UPDATE INPUT_PROFIT + SET GUSUNG_1 = :field_1, + GUSUNG_2 = :field_2, + GUSUNG_3 = :field_3, + GUSUNG_4 = :field_4, + GUSUNG_5 = :field_5, + GUSUNG_6 = :field_6 + WHERE ROWID = CHARTOROWID(:row_handle) + AND STOCK_NAME = :identity_stock_name + AND __ROW_VERSION_EXPRESSION__ = :expected_row_version + """; + private const string ProfitDeleteOneSql = """ DELETE FROM INPUT_PROFIT WHERE STOCK_NAME = :identity_stock_name - AND RAWTOHEX(STANDARD_HASH( - NVL(STOCK_NAME, CHR(0)) || CHR(31) || - NVL(GUSUNG_1, CHR(0)) || CHR(31) || - NVL(GUSUNG_2, CHR(0)) || CHR(31) || - NVL(GUSUNG_3, CHR(0)) || CHR(31) || - NVL(GUSUNG_4, CHR(0)) || CHR(31) || - NVL(GUSUNG_5, CHR(0)) || CHR(31) || - NVL(GUSUNG_6, CHR(0)), 'SHA256')) = :expected_row_version + AND __ROW_VERSION_EXPRESSION__ = :expected_row_version AND (SELECT COUNT(*) FROM INPUT_PROFIT WHERE STOCK_NAME = :unique_stock_name) = 1 """; + + private const string ProfitRawDeleteOneSql = + """ + DELETE FROM INPUT_PROFIT + WHERE ROWID = CHARTOROWID(:row_handle) + AND STOCK_NAME = :identity_stock_name + AND __ROW_VERSION_EXPRESSION__ = :expected_row_version + """; } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs index 6b1ab9b..bdae599 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs @@ -815,19 +815,32 @@ public sealed class LegacyNamedPlaylistPersistenceService : INamedPlaylistPersis } var selection = item.Selection; - var canonical = new LegacySceneSelection( - ValidateText( - selection.GroupCode, - MaximumSelectionFieldLength, - allowEmpty: true, - allowCaret: false, - parameterName), - ValidateText( + var groupCode = ValidateText( + selection.GroupCode, + MaximumSelectionFieldLength, + allowEmpty: true, + allowCaret: false, + parameterName); + var dataCode = ValidateText( + selection.DataCode, + MaximumSelectionFieldLength, + allowEmpty: true, + allowCaret: false, + parameterName); + var subject = CanPreserveClosedThemeSubject( + groupCode, + selection.Subject, + dataCode) + ? selection.Subject + : ValidateText( selection.Subject, MaximumSubjectFieldLength, allowEmpty: true, allowCaret: false, - parameterName), + parameterName); + var canonical = new LegacySceneSelection( + groupCode, + subject, ValidateText( selection.GraphicType, MaximumSelectionFieldLength, @@ -840,12 +853,7 @@ public sealed class LegacyNamedPlaylistPersistenceService : INamedPlaylistPersis allowEmpty: true, allowCaret: false, parameterName), - ValidateText( - selection.DataCode, - MaximumSelectionFieldLength, - allowEmpty: true, - allowCaret: false, - parameterName)); + dataCode); var validated = item with { Selection = canonical }; if (SerializeItem(validated).Length > MaximumListTextLength) { @@ -905,6 +913,33 @@ public sealed class LegacyNamedPlaylistPersistenceService : INamedPlaylistPersis return value; } + private static bool CanPreserveClosedThemeSubject( + string groupCode, + string subject, + string dataCode) + { + if (dataCode.Length is < 3 or > 8 || + dataCode.Any(character => !char.IsAsciiDigit(character))) + { + return false; + } + + var isNxt = groupCode is "테마_NXT" or "PAGED_NXT_THEME"; + if (!isNxt && groupCode is not ("테마" or "PAGED_THEME")) + { + return false; + } + + const string suffix = "(NXT)"; + var exactTitle = isNxt && subject.EndsWith(suffix, StringComparison.Ordinal) + ? subject[..^suffix.Length] + : isNxt ? string.Empty : subject; + return exactTitle.Length is >= 1 and <= 128 && + !string.IsNullOrWhiteSpace(exactTitle) && + !subject.Contains('^') && + IsSafeText(subject); + } + private static bool IsSafeText(string value) => !value.Any(static character => char.IsControl(character) || diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs index 0ebf3d3..18db508 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs @@ -7,8 +7,8 @@ namespace MMoneyCoderSharp.Data; /// /// Exact US foreign-industry/index identity from T_WORLD_IX_EQ_MASTER. -/// KoreanName is F_KNAM (the one-column lookup key), InputName is -/// F_INPUT_NAME (the candle lookup key), and Symbol is F_SYMB. +/// The complete tuple is the identity. Individual display or downstream +/// lookup fields are not required to be globally unique. /// public sealed record OverseasIndustryIndexItem( string KoreanName, @@ -46,7 +46,7 @@ public sealed class LegacyOverseasIndustryIndexSearchService : IOverseasIndustryIndexSearchService { public const int DefaultMaximumResults = 100; - public const int MaximumResults = 500; + public const int MaximumResults = 2_000; public const int MaximumQueryLength = 64; private const int MaximumKoreanNameLength = 200; @@ -61,18 +61,9 @@ public sealed class LegacyOverseasIndustryIndexSearchService SELECT F_KNAM, F_INPUT_NAME, F_SYMB FROM ( SELECT F_KNAM, F_INPUT_NAME, F_SYMB - FROM ( - SELECT F_KNAM, F_INPUT_NAME, F_SYMB, - COUNT(*) OVER (PARTITION BY F_KNAM) AS KNAM_COUNT, - COUNT(*) OVER (PARTITION BY F_INPUT_NAME) AS INPUT_NAME_COUNT, - COUNT(*) OVER (PARTITION BY F_SYMB) AS SYMBOL_COUNT - FROM T_WORLD_IX_EQ_MASTER - WHERE F_FDTC = '0' - AND F_NATC = 'US' - ) - WHERE KNAM_COUNT = 1 - AND INPUT_NAME_COUNT = 1 - AND SYMBOL_COUNT = 1 + FROM T_WORLD_IX_EQ_MASTER + WHERE F_FDTC = '0' + AND F_NATC = 'US' AND UPPER(F_KNAM) LIKE '%' || :search_term || '%' ESCAPE '!' ORDER BY UPPER(F_KNAM), F_INPUT_NAME, F_SYMB ) @@ -174,9 +165,7 @@ public sealed class LegacyOverseasIndustryIndexSearchService } } - var koreanNames = new HashSet(StringComparer.Ordinal); - var inputNames = new HashSet(StringComparer.Ordinal); - var symbols = new HashSet(StringComparer.Ordinal); + var identities = new HashSet(); var results = new List(table.Rows.Count); foreach (DataRow row in table.Rows) { @@ -184,17 +173,13 @@ public sealed class LegacyOverseasIndustryIndexSearchService var inputName = ReadCanonicalValue(row, 1, MaximumInputNameLength); var symbol = ReadCanonicalValue(row, 2, MaximumSymbolLength); - // Current one-column, candle, and quote loaders use F_KNAM, - // F_INPUT_NAME, and F_SYMB respectively. A duplicate in any key can - // resolve a different master row from the operator's chosen entry. - if (!koreanNames.Add(koreanName) || - !inputNames.Add(inputName) || - !symbols.Add(symbol)) + var identity = new OverseasIndustryIndexItem(koreanName, inputName, symbol); + if (!identities.Add(identity)) { - throw InvalidData("ambiguous duplicate downstream lookup key"); + throw InvalidData("duplicate exact identity"); } - results.Add(new OverseasIndustryIndexItem(koreanName, inputName, symbol)); + results.Add(identity); } return results; diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasStockSearch.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasStockSearch.cs index d29c7a4..081f242 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasStockSearch.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasStockSearch.cs @@ -24,7 +24,8 @@ public interface IOverseasStockSearchService /// public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchService { - public const int MaximumResults = 500; + public const int MaximumResults = 2_000; + public const int MaximumIsolatedRows = 128; // UC5 populated every returned row. Keep the bounded migration at its // highest allowed size so the compatibility limit is not silently lower // than necessary; callers surface IsTruncated when the bound is reached. @@ -79,7 +80,10 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi } cancellationToken.ThrowIfCancellationRequested(); - var rowLimit = checked(maximumResults + 1); + // Read enough additional rows to keep malformed display-only entries + // from consuming the operator-visible result budget. The query remains + // finite and one extra valid row is retained for truncation detection. + var rowLimit = checked(maximumResults + MaximumIsolatedRows + 1); var spec = CreateQuerySpec(normalizedQuery, rowLimit); var table = await _executor.ExecuteAsync( DataSourceKind.Oracle, @@ -88,12 +92,16 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); - var matches = ValidateRows(table, rowLimit); + var validation = ValidateRows(table, rowLimit); + var matches = validation.Items; return new WorldStockSearchResult( normalizedQuery, DateTimeOffset.Now, matches.Take(maximumResults).ToArray(), - matches.Count > maximumResults); + matches.Count > maximumResults || + table.Rows.Count == rowLimit || + validation.IsolatedInvalidRowCount > 0, + validation.IsolatedInvalidRowCount); } private static DataQuerySpec CreateQuerySpec(string query, int rowLimit) @@ -117,7 +125,7 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi return spec; } - private static IReadOnlyList ValidateRows(DataTable? table, int rowLimit) + private static RowValidationResult ValidateRows(DataTable? table, int rowLimit) { if (table is null || table.Columns.Count != 3 || table.Rows.Count > rowLimit || !HasExactStringColumn(table.Columns[0], "F_INPUT_NAME") || @@ -129,8 +137,20 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi var identities = new HashSet(); var matches = new List(table.Rows.Count); + var isolatedInvalidRowCount = 0; foreach (DataRow row in table.Rows) { + // Sixteen rows in the current US/TW master have no display name. + // The original grid did not let those rows prevent every valid row + // from loading. Preserve that behavior without making an unnamed + // identity selectable or serializable. + if (row[0] is DBNull || + row[0] is string blankName && blankName.Trim(' ').Length == 0) + { + isolatedInvalidRowCount++; + continue; + } + var inputName = ReadCanonical(row, 0, MaximumInputNameLength); var symbol = ReadCanonical(row, 1, MaximumSymbolLength); var nationCode = ReadCanonical(row, 2, MaximumNationCodeLength); @@ -149,7 +169,7 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi matches.Add(identity); } - return matches; + return new RowValidationResult(matches, isolatedInvalidRowCount); } private static string ValidateAndNormalizeQuery(string query) @@ -215,4 +235,8 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi private static WorldStockSearchDataException InvalidData(string detail) => new($"Overseas stock data from {QueryName} contains an invalid {detail}."); + + private sealed record RowValidationResult( + IReadOnlyList Items, + int IsolatedInvalidRowCount); } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs index 57c7e6a..e3b08b7 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs @@ -33,6 +33,17 @@ public interface IStockSearchService CancellationToken cancellationToken = default); } +/// +/// Keeps the legacy ThemeA/UC6 stock-picker "show all" action explicit and +/// bounded without making an empty query valid for every stock-search caller. +/// +public interface IOperatorCatalogStockSearchService : IStockSearchService +{ + Task SearchAllAsync( + int maximumResults = LegacyStockSearchService.DefaultMaximumResults, + CancellationToken cancellationToken = default); +} + public sealed class StockSearchDataException : Exception { public StockSearchDataException(string message) @@ -49,7 +60,7 @@ public sealed class StockSearchDataException : Exception public sealed class LegacyStockSearchService : IStockSearchService { public const int DefaultMaximumResults = 100; - public const int MaximumResults = 500; + public const int MaximumResults = 10_000; public const int MaximumQueryLength = 64; private const int MaximumStockNameLength = 200; @@ -70,6 +81,23 @@ public sealed class LegacyStockSearchService : IStockSearchService CancellationToken cancellationToken = default) { var normalizedQuery = ValidateAndNormalizeQuery(query); + return await SearchNormalizedAsync( + normalizedQuery, + maximumResults, + cancellationToken) + .ConfigureAwait(false); + } + + internal Task SearchAllForOperatorCatalogAsync( + int maximumResults, + CancellationToken cancellationToken) => + SearchNormalizedAsync(string.Empty, maximumResults, cancellationToken); + + private async Task SearchNormalizedAsync( + string normalizedQuery, + int maximumResults, + CancellationToken cancellationToken) + { if (maximumResults is < 1 or > MaximumResults) { throw new ArgumentOutOfRangeException( @@ -217,6 +245,42 @@ internal sealed record StockSearchQueryProfile( } } +/// +/// Operator-catalog-only adapter for the original blank stock-picker action. +/// Non-empty searches retain the normal validation contract; only SearchAllAsync +/// can issue a bound empty LIKE term. +/// +public sealed class LegacyOperatorCatalogStockSearchService : + IOperatorCatalogStockSearchService +{ + private readonly LegacyStockSearchService _stockSearchService; + + public LegacyOperatorCatalogStockSearchService(IDataQueryExecutor executor) + : this(new LegacyStockSearchService(executor)) + { + } + + public LegacyOperatorCatalogStockSearchService( + LegacyStockSearchService stockSearchService) + { + _stockSearchService = stockSearchService ?? + throw new ArgumentNullException(nameof(stockSearchService)); + } + + public Task SearchAsync( + string query, + int maximumResults = LegacyStockSearchService.DefaultMaximumResults, + CancellationToken cancellationToken = default) => + _stockSearchService.SearchAsync(query, maximumResults, cancellationToken); + + public Task SearchAllAsync( + int maximumResults = LegacyStockSearchService.DefaultMaximumResults, + CancellationToken cancellationToken = default) => + _stockSearchService.SearchAllForOperatorCatalogAsync( + maximumResults, + cancellationToken); +} + internal static class StockSearchQueries { internal static IReadOnlyList All { get; } = diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs index 4aee461..b7677a9 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs @@ -17,10 +17,20 @@ public sealed record ThemeCatalogDefinition( string ThemeTitle, IReadOnlyList Items); +public sealed record ThemeCatalogStoredResult( + ThemeSelectionIdentity Identity, + DateTimeOffset RetrievedAt, + IReadOnlyList Items, + bool IsTruncated); + public interface IThemeCatalogPersistenceService { bool CanMutate { get; } + Task ReadStoredAsync( + ThemeSelectionIdentity identity, + CancellationToken cancellationToken = default); + Task ThemeNameExistsAsync( ThemeMarket market, string themeTitle, @@ -227,8 +237,14 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog CancellationToken cancellationToken = default) { var expected = ValidateIdentity(expectedIdentity, nameof(expectedIdentity)); - var title = ValidateThemeTitle(newTitle, expected.Market, nameof(newTitle)); - var canonicalItems = ValidateItems(items, expected.Market, nameof(items)); + var title = string.Equals(newTitle, expected.ThemeTitle, StringComparison.Ordinal) + ? ValidateStoredThemeTitle(newTitle, nameof(newTitle)) + : ValidateThemeTitle(newTitle, expected.Market, nameof(newTitle)); + var canonicalItems = ValidateItems( + items, + expected.Market, + allowLegacyCrossMarketPrefix: true, + nameof(items)); var profile = ThemeMutationProfiles.For(expected.Market); var commands = new List(canonicalItems.Count + 2) { @@ -333,7 +349,11 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog var code = ValidateNewThemeCode(definition.ThemeCode, parameterName); var title = ValidateThemeTitle(definition.ThemeTitle, definition.Market, parameterName); - var items = ValidateItems(definition.Items, definition.Market, parameterName); + var items = ValidateItems( + definition.Items, + definition.Market, + allowLegacyCrossMarketPrefix: false, + parameterName); return new ThemeCatalogDefinition(definition.Market, code, title, items); } @@ -353,13 +373,14 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog return identity with { ThemeCode = ValidateExistingThemeCode(identity.ThemeCode, parameterName), - ThemeTitle = ValidateThemeTitle(identity.ThemeTitle, identity.Market, parameterName) + ThemeTitle = ValidateStoredThemeTitle(identity.ThemeTitle, parameterName) }; } private static IReadOnlyList ValidateItems( IReadOnlyList items, ThemeMarket market, + bool allowLegacyCrossMarketPrefix, string parameterName) { ArgumentNullException.ThrowIfNull(items); @@ -384,20 +405,22 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog } var code = ValidateCanonicalText(item.ItemCode, 13, parameterName); - var codePattern = market == ThemeMarket.Krx - ? KrxItemCodePattern() - : NxtItemCodePattern(); - if (!codePattern.IsMatch(code)) + var matchesExpectedMarket = market == ThemeMarket.Krx + ? KrxItemCodePattern().IsMatch(code) + : NxtItemCodePattern().IsMatch(code); + var matchesClosedLegacyPrefix = + KrxItemCodePattern().IsMatch(code) || NxtItemCodePattern().IsMatch(code); + if (!matchesExpectedMarket && + (!allowLegacyCrossMarketPrefix || !matchesClosedLegacyPrefix)) { throw new ArgumentException( "A theme item code does not match its market.", parameterName); } - var name = ValidateCanonicalText( - item.ItemName, - MaximumItemNameLength, - parameterName); + var name = allowLegacyCrossMarketPrefix + ? ValidateStoredText(item.ItemName, MaximumItemNameLength, parameterName) + : ValidateCanonicalText(item.ItemName, MaximumItemNameLength, parameterName); canonical[index] = new ThemeCatalogItem(index, code, name); } @@ -445,6 +468,25 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog return title; } + private static string ValidateStoredThemeTitle(string value, string parameterName) + => ValidateStoredText(value, MaximumThemeTitleLength, parameterName); + + private static string ValidateStoredText( + string value, + int maximumLength, + string parameterName) + { + ArgumentNullException.ThrowIfNull(value); + if (value.Length < 1 || value.Length > maximumLength || + string.IsNullOrWhiteSpace(value) || + !IsSafeText(value)) + { + throw new ArgumentException("A stored theme value is invalid.", parameterName); + } + + return value; + } + private static string ValidateCanonicalText( string value, int maximumLength, diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogStoredRead.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogStoredRead.cs new file mode 100644 index 0000000..f2f5d61 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogStoredRead.cs @@ -0,0 +1,218 @@ +#nullable enable + +using System.Data; +using System.Globalization; + +namespace MMoneyCoderSharp.Data; + +public sealed partial class LegacyThemeCatalogPersistenceService +{ + internal const string StoredKrxQueryName = "THEME_STORED_ITEMS_KRX"; + internal const string StoredNxtQueryName = "THEME_STORED_ITEMS_NXT"; + + private static readonly string[] StoredColumns = + [ + "THEME_TITLE", + "THEME_CODE", + "THEME_MARKET", + "ITEM_NAME", + "ITEM_CODE", + "ITEM_INDEX" + ]; + + private const string StoredKrxSql = """ + SELECT THEME_TITLE, THEME_CODE, THEME_MARKET, + ITEM_NAME, ITEM_CODE, ITEM_INDEX + FROM ( + SELECT l.SB_TITLE THEME_TITLE, + l.SB_CODE THEME_CODE, + l.SB_MARKET THEME_MARKET, + i.SB_I_NAME ITEM_NAME, + i.SB_I_CODE ITEM_CODE, + TO_CHAR(i.SB_I_INDEX, 'FM999999990') ITEM_INDEX + FROM SB_LIST l + LEFT JOIN SB_ITEM i ON i.SB_M_CODE = l.SB_CODE + WHERE l.SB_CODE = :theme_code + AND l.SB_TITLE = :theme_title + AND l.SB_MARKET = 'KRX' + AND (SELECT COUNT(*) + FROM SB_LIST d + WHERE d.SB_MARKET = l.SB_MARKET + AND d.SB_CODE = l.SB_CODE) = 1 + ORDER BY i.SB_I_INDEX, i.SB_I_CODE, i.SB_I_NAME + ) + WHERE ROWNUM <= :row_limit + """; + + private const string StoredNxtSql = """ + SELECT l.SB_TITLE THEME_TITLE, + l.SB_CODE THEME_CODE, + l.SB_MARKET THEME_MARKET, + i.SB_I_NAME ITEM_NAME, + i.SB_I_CODE ITEM_CODE, + CAST(i.SB_I_INDEX AS CHAR) ITEM_INDEX + FROM SB_LIST l + LEFT JOIN SB_ITEM i ON BINARY i.SB_M_CODE = BINARY l.SB_CODE + WHERE BINARY l.SB_CODE = BINARY @theme_code + AND BINARY l.SB_TITLE = BINARY @theme_title + AND l.SB_MARKET = 'NXT' + AND (SELECT COUNT(*) + FROM SB_LIST d + WHERE d.SB_MARKET = l.SB_MARKET + AND BINARY d.SB_CODE = BINARY l.SB_CODE) = 1 + ORDER BY i.SB_I_INDEX, i.SB_I_CODE, i.SB_I_NAME + LIMIT @row_limit + """; + + public async Task ReadStoredAsync( + ThemeSelectionIdentity identity, + CancellationToken cancellationToken = default) + { + var expected = ValidateIdentity(identity, nameof(identity)); + cancellationToken.ThrowIfCancellationRequested(); + + var source = expected.Market == ThemeMarket.Krx + ? DataSourceKind.Oracle + : DataSourceKind.MariaDb; + var queryName = expected.Market == ThemeMarket.Krx + ? StoredKrxQueryName + : StoredNxtQueryName; + var rowLimit = checked(MaximumItems + 1); + var query = new DataQuerySpec( + expected.Market == ThemeMarket.Krx ? StoredKrxSql : StoredNxtSql, + [ + new DataQueryParameter("theme_code", expected.ThemeCode, DbType.String), + new DataQueryParameter("theme_title", expected.ThemeTitle, DbType.String), + new DataQueryParameter("row_limit", rowLimit, DbType.Int32) + ]); + query.ValidateFor(source); + var table = await _queryExecutor.ExecuteAsync( + source, + queryName, + query, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + var items = MapStoredRows(table, expected, rowLimit, queryName); + return new ThemeCatalogStoredResult( + expected, + DateTimeOffset.Now, + items.Take(MaximumItems).ToArray(), + items.Count > MaximumItems); + } + + private static IReadOnlyList MapStoredRows( + DataTable? table, + ThemeSelectionIdentity expected, + int rowLimit, + string queryName) + { + ValidateStoredSchema(table, rowLimit, queryName); + if (table!.Rows.Count == 0) + { + throw InvalidStoredData(queryName, "missing selected theme"); + } + + var result = new List(table.Rows.Count); + var sawEmptySentinel = false; + foreach (DataRow row in table.Rows) + { + if (row[0] is not string title || row[1] is not string code || + row[2] is not string market || + !string.Equals(title, expected.ThemeTitle, StringComparison.Ordinal) || + !string.Equals(code, expected.ThemeCode, StringComparison.Ordinal) || + !string.Equals( + market, + expected.Market == ThemeMarket.Krx ? "KRX" : "NXT", + StringComparison.Ordinal)) + { + throw InvalidStoredData(queryName, "selected theme identity"); + } + + var nameValue = row[3]; + var codeValue = row[4]; + var indexValue = row[5]; + if (nameValue is DBNull && codeValue is DBNull && indexValue is DBNull) + { + if (table.Rows.Count != 1) + { + throw InvalidStoredData(queryName, "empty item sentinel"); + } + + sawEmptySentinel = true; + continue; + } + + if (nameValue is not string itemName || codeValue is not string itemCode || + indexValue is not string rawIndex || + itemName.Length is < 1 or > 200 || string.IsNullOrWhiteSpace(itemName) || + !IsSafeText(itemName) || + itemCode.Length is < 2 or > 13 || + !string.Equals(itemCode, itemCode.Trim(), StringComparison.Ordinal) || + !IsSafeText(itemCode) || + !IsClosedStoredItemCode(itemCode) || + !int.TryParse( + rawIndex, + NumberStyles.None, + CultureInfo.InvariantCulture, + out var inputIndex) || + inputIndex is < 0 or > 9_999 || + !string.Equals( + rawIndex, + inputIndex.ToString(CultureInfo.InvariantCulture), + StringComparison.Ordinal)) + { + throw InvalidStoredData(queryName, "stored item"); + } + + result.Add(new ThemeCatalogItem(inputIndex, itemCode, itemName)); + } + + if (sawEmptySentinel) + { + return Array.Empty(); + } + + for (var index = 1; index < result.Count; index++) + { + if (result[index - 1].InputIndex > result[index].InputIndex) + { + throw InvalidStoredData(queryName, "stored item order"); + } + } + + return result; + } + + private static void ValidateStoredSchema( + DataTable? table, + int rowLimit, + string queryName) + { + if (table is null || table.Columns.Count != StoredColumns.Length || + table.Rows.Count > rowLimit) + { + throw InvalidStoredData(queryName, "schema or row bound"); + } + + for (var index = 0; index < StoredColumns.Length; index++) + { + if (!string.Equals( + table.Columns[index].ColumnName, + StoredColumns[index], + StringComparison.Ordinal) || + table.Columns[index].DataType != typeof(string)) + { + throw InvalidStoredData(queryName, "schema"); + } + } + } + + private static bool IsClosedStoredItemCode(string value) => + KrxItemCodePattern().IsMatch(value) || NxtItemCodePattern().IsMatch(value); + + private static ThemeSelectionDataException InvalidStoredData( + string queryName, + string detail) => + new($"Stored ThemeA data from {queryName} contains an invalid {detail}."); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs index c33788e..95f8629 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs @@ -82,8 +82,11 @@ public sealed class ThemeSelectionDataException : Exception /// public sealed partial class LegacyThemeSelectionService : IThemeSelectionService { - public const int DefaultMaximumResults = 100; - public const int MaximumResults = 500; + // The deployed UC4 selector loads the whole current KRX/NXT theme catalog. + // Keep a finite defensive ceiling, but do not silently cut the current + // catalog at the earlier migration-only 100/500 row limits. + public const int DefaultMaximumResults = 5_000; + public const int MaximumResults = 5_000; public const int MaximumQueryLength = 64; public const int DefaultMaximumPreviewItems = 240; public const int MaximumPreviewItems = 240; @@ -188,7 +191,7 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService var codes = new HashSet(StringComparer.Ordinal); foreach (DataRow row in table!.Rows) { - var title = ReadCanonicalString(row, 0, MaximumThemeTitleLength, profile.SearchName); + var title = ReadStoredThemeTitle(row, 0, profile.SearchName); var code = ReadThemeCode(row, 1, profile.SearchName); var market = ReadCanonicalString(row, 2, 3, profile.SearchName); if (!string.Equals(market, profile.DatabaseMarket, StringComparison.Ordinal)) @@ -196,8 +199,9 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService throw InvalidData(profile.SearchName, "market identity"); } - // PAGED_THEME resolves by title and UC4 previews by code. Both keys - // must be unique inside one market or the selection is ambiguous. + // The deployed catalog contains distinct codes that intentionally + // share a title. Code remains the closed per-market identity; the + // exact stored title is carried alongside it for correlated reads. if (!codes.Add(code)) { throw InvalidData(profile.SearchName, "duplicate theme code"); @@ -229,7 +233,7 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService var sawEmptyThemeRow = false; foreach (DataRow row in table.Rows) { - var title = ReadCanonicalString(row, 0, MaximumThemeTitleLength, profile.PreviewName); + var title = ReadStoredThemeTitle(row, 0, profile.PreviewName); var code = ReadThemeCode(row, 1, profile.PreviewName); var market = ReadCanonicalString(row, 2, 3, profile.PreviewName); if (!string.Equals(title, identity.ThemeTitle, StringComparison.Ordinal) || @@ -318,10 +322,7 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService nameof(identity)); } - var title = ValidateCanonicalInput( - identity.ThemeTitle, - MaximumThemeTitleLength, - nameof(identity)); + var title = ValidateStoredThemeTitle(identity.ThemeTitle, nameof(identity)); var code = ValidateCanonicalInput(identity.ThemeCode, 8, nameof(identity)); if (!ThemeCodePattern().IsMatch(code)) { @@ -366,6 +367,22 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService return value; } + private static string ValidateStoredThemeTitle(string value, string parameterName) + { + ArgumentNullException.ThrowIfNull(value); + if (value.Length is < 1 or > MaximumThemeTitleLength || + string.IsNullOrWhiteSpace(value) || + !IsSafeText(value)) + { + throw new ArgumentException("The stored theme title is invalid.", parameterName); + } + + // Legacy SB_LIST titles can contain meaningful leading or trailing + // spaces. Preserve the exact database value as part of the identity; + // trimming it can collapse distinct deployed rows. + return value; + } + private static void ValidateNxtSession(ThemeSession session, string parameterName) { if (session is not (ThemeSession.PreMarket or ThemeSession.AfterMarket)) @@ -429,6 +446,22 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService return value; } + private static string ReadStoredThemeTitle( + DataRow row, + int ordinal, + string queryName) + { + if (row[ordinal] is not string value || + value.Length is < 1 or > MaximumThemeTitleLength || + string.IsNullOrWhiteSpace(value) || + !IsSafeText(value)) + { + throw InvalidData(queryName, "stored theme title"); + } + + return value; + } + private static string ReadThemeCode(DataRow row, int ordinal, string queryName) { var code = ReadCanonicalString(row, ordinal, 8, queryName); @@ -537,13 +570,8 @@ internal static class ThemeQueries FROM SB_LIST s WHERE s.SB_MARKET = 'KRX' AND s.SB_TITLE IS NOT NULL - AND s.SB_TITLE = TRIM(s.SB_TITLE) AND LENGTH(s.SB_TITLE) BETWEEN 1 AND 128 AND REGEXP_LIKE(s.SB_CODE, '^[0-9]{3,8}$', 'c') - AND (SELECT COUNT(*) - FROM SB_LIST d - WHERE d.SB_MARKET = s.SB_MARKET - AND d.SB_TITLE = s.SB_TITLE) = 1 AND (SELECT COUNT(*) FROM SB_LIST d WHERE d.SB_MARKET = s.SB_MARKET @@ -591,13 +619,8 @@ internal static class ThemeQueries FROM SB_LIST s WHERE s.SB_MARKET = 'NXT' AND s.SB_TITLE IS NOT NULL - AND BINARY s.SB_TITLE = BINARY TRIM(s.SB_TITLE) AND CHAR_LENGTH(s.SB_TITLE) BETWEEN 1 AND 128 AND s.SB_CODE REGEXP BINARY '^[0-9]{3,8}$' - AND (SELECT COUNT(*) - FROM SB_LIST d - WHERE d.SB_MARKET = s.SB_MARKET - AND BINARY d.SB_TITLE = BINARY s.SB_TITLE) = 1 AND (SELECT COUNT(*) FROM SB_LIST d WHERE d.SB_MARKET = s.SB_MARKET diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs index 2754547..0809a40 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs @@ -14,7 +14,8 @@ public sealed record WorldStockSearchResult( string Query, DateTimeOffset RetrievedAt, IReadOnlyList Items, - bool IsTruncated); + bool IsTruncated, + int IsolatedInvalidRowCount = 0); public interface IWorldStockSearchService { @@ -41,7 +42,8 @@ public sealed class WorldStockSearchDataException : Exception public sealed class LegacyWorldStockSearchService : IWorldStockSearchService { public const int DefaultMaximumResults = 100; - public const int MaximumResults = 500; + public const int MaximumResults = 5_000; + public const int MaximumIsolatedRows = 128; public const int MaximumQueryLength = 64; private const int MaximumInputNameLength = 200; @@ -51,6 +53,18 @@ public sealed class LegacyWorldStockSearchService : IWorldStockSearchService private const string NationCodeColumn = "F_NATC"; private const string QueryName = "WORLD_STOCK_SEARCH"; + private const string InitialListSql = """ + SELECT F_INPUT_NAME, F_SYMB, F_NATC + FROM ( + SELECT F_INPUT_NAME, F_SYMB, F_NATC + FROM T_WORLD_IX_EQ_MASTER + WHERE F_FDTC = '1' + AND F_NATC IN ('US', 'TW') + ORDER BY UPPER(F_INPUT_NAME), F_SYMB, F_NATC + ) + WHERE ROWNUM <= :row_limit + """; + private const string SearchSql = """ SELECT F_INPUT_NAME, F_SYMB, F_NATC FROM ( @@ -86,31 +100,44 @@ public sealed class LegacyWorldStockSearchService : IWorldStockSearchService cancellationToken.ThrowIfCancellationRequested(); - var rowLimit = checked(maximumResults + 1); - var spec = CreateQuerySpec( - EscapeLikePattern(normalizedQuery.ToUpperInvariant()), - rowLimit); + // Keep blank legacy display rows from consuming the visible result budget while + // retaining a finite provider bound and one extra valid row for truncation proof. + var rowLimit = checked(maximumResults + MaximumIsolatedRows + 1); + var spec = CreateQuerySpec(normalizedQuery, rowLimit); var table = await _executor .ExecuteAsync(DataSourceKind.Oracle, QueryName, spec, cancellationToken) .ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); - var matches = ValidateRows(table, rowLimit); + var validation = ValidateRows(table, rowLimit); + var matches = validation.Items; return new WorldStockSearchResult( normalizedQuery, DateTimeOffset.Now, matches.Take(maximumResults).ToArray(), - matches.Count > maximumResults); + matches.Count > maximumResults || + table.Rows.Count == rowLimit || + validation.IsolatedInvalidRowCount > 0, + validation.IsolatedInvalidRowCount); } - private static DataQuerySpec CreateQuerySpec(string escapedQuery, int rowLimit) + private static DataQuerySpec CreateQuerySpec(string query, int rowLimit) { - var spec = new DataQuerySpec( - SearchSql, - [ - new DataQueryParameter("search_term", escapedQuery, DbType.String), - new DataQueryParameter("row_limit", rowLimit, DbType.Int32) - ]); + var parameters = new List + { + new("row_limit", rowLimit, DbType.Int32) + }; + var sql = InitialListSql; + if (query.Length != 0) + { + sql = SearchSql; + parameters.Insert(0, new DataQueryParameter( + "search_term", + EscapeLikePattern(query.ToUpperInvariant()), + DbType.String)); + } + + var spec = new DataQuerySpec(sql, parameters); spec.ValidateFor(DataSourceKind.Oracle); return spec; } @@ -137,7 +164,7 @@ public sealed class LegacyWorldStockSearchService : IWorldStockSearchService .Replace("%", "!%", StringComparison.Ordinal) .Replace("_", "!_", StringComparison.Ordinal); - private static IReadOnlyList ValidateRows( + private static RowValidationResult ValidateRows( DataTable? table, int rowLimit) { @@ -152,17 +179,27 @@ public sealed class LegacyWorldStockSearchService : IWorldStockSearchService } var identities = new HashSet(); - var inputNames = new HashSet(StringComparer.Ordinal); var matches = new List(table.Rows.Count); + var isolatedInvalidRowCount = 0; foreach (DataRow row in table.Rows) { - if (row[0] is not string inputName || - row[1] is not string symbol || - row[2] is not string nationCode) + if (row[0] is DBNull || + row[0] is string blankName && blankName.Trim(' ').Length == 0) + { + isolatedInvalidRowCount++; + continue; + } + + if (row[0] is not string rawInputName || + row[1] is not string rawSymbol || + row[2] is not string rawNationCode) { throw InvalidSchema(); } + var inputName = rawInputName.Trim(' '); + var symbol = rawSymbol.Trim(' '); + var nationCode = rawNationCode.Trim(' '); if (!IsCanonicalDatabaseValue(inputName, MaximumInputNameLength) || !IsCanonicalDatabaseValue(symbol, MaximumSymbolLength) || nationCode is not ("US" or "TW")) @@ -178,19 +215,10 @@ public sealed class LegacyWorldStockSearchService : IWorldStockSearchService $"World-stock search data from {QueryName} contains a duplicate identity."); } - // The legacy comparison resolver selects the master row by - // F_INPUT_NAME alone. Even distinct symbols or nations are unsafe to - // expose when that lookup key would resolve ambiguously downstream. - if (!inputNames.Add(inputName)) - { - throw new WorldStockSearchDataException( - $"World-stock search data from {QueryName} contains a duplicate F_INPUT_NAME lookup key."); - } - matches.Add(item); } - return matches; + return new RowValidationResult(matches, isolatedInvalidRowCount); } private static bool HasExactStringColumn(DataColumn column, string name) => @@ -225,4 +253,8 @@ public sealed class LegacyWorldStockSearchService : IWorldStockSearchService private static WorldStockSearchDataException InvalidSchema() => new($"World-stock search data from {QueryName} does not match the required schema."); + + private sealed record RowValidationResult( + IReadOnlyList Items, + int IsolatedInvalidRowCount); } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneBuilders5078To5084.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneBuilders5078To5084.cs index 04085a1..71f0533 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneBuilders5078To5084.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneBuilders5078To5084.cs @@ -320,7 +320,8 @@ public sealed class S5080SceneMutationBuilder : ILegacySceneMutationBuilder()) { @@ -295,14 +297,15 @@ public sealed class S5080SceneDataLoader "INPUT_SELL", spec, cancellationToken).ConfigureAwait(false); - var row = ChartSceneDataLoaderReader.SingleRow(table, Columns); + var row = LegacyManualFinancialStorageCompatibility.SingleEquivalentSceneRow( + table, + Columns); var quarters = new QuarterlySalesData[6]; for (var index = 0; index < quarters.Length; index++) { - var raw = ChartSceneDataLoaderReader.Text( + var raw = LegacyManualFinancialStorageCompatibility.RequiredQuarterStorageString( row, - "GUSUNG_" + (index + 1).ToString(CultureInfo.InvariantCulture), - allowEmpty: true); + "GUSUNG_" + (index + 1).ToString(CultureInfo.InvariantCulture)); quarters[index] = ParseQuarter(raw); } @@ -316,7 +319,7 @@ public sealed class S5080SceneDataLoader private static QuarterlySalesData ParseQuarter(string value) { var tokens = value.Split('_', StringSplitOptions.None); - if (tokens.Length != 2 || string.IsNullOrWhiteSpace(tokens[0])) + if (tokens.Length < 2) { throw ChartSceneDataLoaderReader.InvalidResult(); } @@ -332,7 +335,9 @@ public sealed class S5080SceneDataLoader throw ChartSceneDataLoaderReader.InvalidResult(); } - return new QuarterlySalesData(tokens[0], number); + return new QuarterlySalesData( + LegacyManualFinancialStorageCompatibility.NormalizeQuarterLabel(tokens[0]), + number); } } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/FoundationalSceneBuilders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/FoundationalSceneBuilders.cs index 9fb0a4b..5bbd74c 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/FoundationalSceneBuilders.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/FoundationalSceneBuilders.cs @@ -22,7 +22,10 @@ public sealed class S5076SceneMutationBuilder : ILegacySceneMutationBuilder(34) { @@ -45,7 +48,10 @@ public sealed class S5076SceneMutationBuilder : ILegacySceneMutationBuilder item.Value).ToArray(); diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyManualFinancialStorageCompatibility.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyManualFinancialStorageCompatibility.cs new file mode 100644 index 0000000..7b94ee4 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyManualFinancialStorageCompatibility.cs @@ -0,0 +1,92 @@ +#nullable enable + +using System.Data; + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +/// +/// Narrow compatibility rules shared only by the four GraphE INPUT_* scenes. +/// Raw editor values remain unchanged; these rules apply to the typed playout +/// projection after a fresh database read. +/// +internal static class LegacyManualFinancialStorageCompatibility +{ + public static string NormalizeQuarterLabel(string value) + { + ArgumentNullException.ThrowIfNull(value); + + // The legacy scene passed quarter labels directly to K3D. A trailing + // horizontal tab was visually inert, but the current scene text guard + // correctly rejects control characters. Remove only that inert suffix; + // any remaining label controls are rejected by the scene text guard. + return value.TrimEnd('\t'); + } + + public static string RequiredQuarterStorageString(DataRow row, string columnName) + { + ArgumentNullException.ThrowIfNull(row); + if (!row.Table.Columns.Contains(columnName) || + row[columnName] is not string value || + value.Length > 4_096) + { + throw InvalidResult(); + } + + return value; + } + + public static DataRow SingleEquivalentSceneRow( + DataTable? table, + params string[] sceneDrivingColumns) + { + ArgumentNullException.ThrowIfNull(sceneDrivingColumns); + if (table is null || table.Rows.Count == 0 || + table.Columns.Count != sceneDrivingColumns.Length) + { + throw InvalidResult(); + } + + for (var index = 0; index < sceneDrivingColumns.Length; index++) + { + if (!string.Equals( + table.Columns[index].ColumnName, + sceneDrivingColumns[index], + StringComparison.OrdinalIgnoreCase)) + { + throw InvalidResult(); + } + } + + var first = table.Rows[0]; + for (var rowIndex = 1; rowIndex < table.Rows.Count; rowIndex++) + { + var candidate = table.Rows[rowIndex]; + foreach (var column in sceneDrivingColumns) + { + if (!EquivalentStoredValue(first[column], candidate[column])) + { + throw InvalidResult(); + } + } + } + + return first; + } + + private static bool EquivalentStoredValue(object? left, object? right) + { + var leftIsNull = left is null or DBNull; + var rightIsNull = right is null or DBNull; + if (leftIsNull || rightIsNull) + { + return leftIsNull && rightIsNull; + } + + return left is string leftText && + right is string rightText && + string.Equals(leftText, rightText, StringComparison.Ordinal); + } + + private static LegacySceneDataException InvalidResult() => + new("The manual-financial scene query did not return one equivalent row set."); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs index 0c609aa..6e85ee0 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs @@ -1,6 +1,7 @@ #nullable enable using System.Data; +using System.Globalization; using MMoneyCoderSharp.Data; namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; @@ -82,7 +83,14 @@ public sealed class LegacyParameterizedSceneRequestResolver if (selection.GroupCode is "해외업종" or "FOREIGN_INDUSTRY") { - return new S5001ForeignIndustryLoadRequest(subject); + var identity = ParseForeignIndustryIdentity(selection.DataCode); + return identity is null + ? new S5001ForeignIndustryLoadRequest(subject) + : new S5001ExactForeignIndustryLoadRequest( + subject, + identity.Value.InputName, + identity.Value.Symbol, + identity.Value.NationCode); } if (selection.GroupCode is "해외종목" or "FOREIGN_STOCK") @@ -465,6 +473,45 @@ public sealed class LegacyParameterizedSceneRequestResolver return (parts[0], parts[1]); } + /// + /// Current UC5 industry rows persist inputName|symbol|US|0. Historical + /// playlist rows have no data code and deliberately retain their fail-closed + /// name-only lookup. + /// + private static (string InputName, string Symbol, string NationCode)? + ParseForeignIndustryIdentity(string value) + { + if (string.IsNullOrEmpty(value)) + { + return null; + } + + var parts = value.Split('|', StringSplitOptions.None); + if (parts.Length != 4 || + !IsCanonicalForeignIndustryText(parts[0]) || + !LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol(parts[1]) || + !string.Equals(parts[2], "US", StringComparison.Ordinal) || + !string.Equals(parts[3], "0", StringComparison.Ordinal)) + { + throw new LegacySceneDataException("The foreign-industry identity is invalid."); + } + + return (parts[0], parts[1], parts[2]); + } + + private static bool IsCanonicalForeignIndustryText(string value) => + value.Length is >= 1 and <= 200 && + !string.IsNullOrWhiteSpace(value) && + string.Equals(value, value.Trim(), StringComparison.Ordinal) && + !value.Any(character => + char.IsControl(character) || + char.IsSurrogate(character) || + character == '\uFFFD' || + CharUnicodeInfo.GetUnicodeCategory(character) is + UnicodeCategory.Format or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator); + private S8018SessionPhase CurrentSession() { var now = _timeProvider.GetLocalNow(); diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs index 6d5b9db..0c6d29e 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs @@ -12,7 +12,8 @@ public sealed record LegacySceneSelection( string Subject, string GraphicType, string Subtype, - string DataCode); + string DataCode, + string? ExactSubject = null); /// /// Current operator checkbox state used by s8010. The original scene constructor read @@ -113,7 +114,16 @@ public sealed record LegacyPlaylistEntry( int? FadeDuration = null, LegacySceneSelection? Selection = null, LegacyPlaylistPageNavigation? PageNavigation = null, - ILegacySceneMovingAverageSelectionSource? MovingAverageSelectionSource = null); + ILegacySceneMovingAverageSelectionSource? MovingAverageSelectionSource = null) +{ + /// + /// Closed sentinel used for a losslessly loaded legacy PLAY_LIST row whose + /// original visible fields do not name any registered scene. It is a safe + /// token, not a scene or file name, and is rejected by the data router before + /// an engine command can be created. + /// + public const string UnresolvedCutCode = "UNRESOLVED_NAMED_PLAYLIST"; +} public sealed record LegacySceneCuePage( PlayoutCue Cue, @@ -520,7 +530,21 @@ public sealed class LegacyPlayoutWorkflow } } + public Task NextAsync( + CancellationToken cancellationToken = default) => + NextAsync(null, cancellationToken); + + /// + /// Advances NEXT after adopting only a verified mutable tail from the live + /// operator playlist. The current/on-air prefix must remain strictly equivalent + /// except for : MainForm allowed its + /// PROGRAM Space/all gestures to change that visible flag even on current and + /// past rows, while NEXT consulted only later rows. Additions or deletions remain + /// confined after the current row so the scene already owned by the engine cannot + /// change. + /// public async Task NextAsync( + IReadOnlyList? operatorPlaylist, CancellationToken cancellationToken = default) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -531,6 +555,13 @@ public sealed class LegacyPlayoutWorkflow return Rejected(PlayoutOperation.Next, "TAKE IN 이후에만 NEXT를 실행할 수 있습니다."); } + if (operatorPlaylist is not null && !TryAdoptOperatorPlaylistTail(operatorPlaylist)) + { + return Rejected( + PlayoutOperation.Next, + "송출 중 변경된 플레이리스트의 현재 행 이전 구간을 안전하게 확인할 수 없습니다."); + } + if (_onAir.Plan is { } currentPlan && _onAir.PageIndexZeroBased + 1 < currentPlan.TotalPages) { @@ -592,6 +623,54 @@ public sealed class LegacyPlayoutWorkflow } } + private bool TryAdoptOperatorPlaylistTail( + IReadOnlyList operatorPlaylist) + { + if (_onAir is null || _onAir.CueIndexZeroBased < 0) + { + return false; + } + + var currentIndex = _onAir.CueIndexZeroBased; + IReadOnlyList candidate; + try + { + candidate = ValidatePlaylist(operatorPlaylist, currentIndex); + } + catch (Exception exception) when ( + exception is ArgumentException or InvalidOperationException) + { + return false; + } + + if (candidate.Count <= currentIndex || _playlist.Count <= currentIndex) + { + return false; + } + + for (var index = 0; index <= currentIndex; index++) + { + // MainForm allowed the PROGRAM operator to change the active-row + // checkbox with Space and every checkbox through its "all" control. + // Those changes included the on-air/past prefix even though NEXT only + // consulted rows after m_crow. Accept that one harmless visible field, + // while keeping every identity, scene, selection, page and live-source + // field in the retained prefix strictly equal. + var candidateWithRetainedEnabledState = candidate[index] with + { + IsEnabled = _playlist[index].IsEnabled + }; + if (!Equals(candidateWithRetainedEnabledState, _playlist[index])) + { + return false; + } + } + + _playlist = candidate; + PublishState(); + return true; + } + /// /// Reproduces MainForm's timer-driven same-scene refresh. The active non-paged /// entry and current page are queried again and updated through @@ -873,7 +952,8 @@ public sealed class LegacyPlayoutWorkflow IsSafeLookupText(selection.Subject) && IsSafeLookupText(selection.GraphicType) && IsSafeLookupText(selection.Subtype) && - IsSafeLookupText(selection.DataCode); + IsSafeLookupText(selection.DataCode) && + (selection.ExactSubject is null || IsSafeLookupText(selection.ExactSubject)); } private static bool IsSafePageNavigation(LegacyPlaylistPageNavigation? navigation) diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDataSourceRouter.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDataSourceRouter.cs index c066685..92ae31b 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDataSourceRouter.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDataSourceRouter.cs @@ -119,8 +119,19 @@ public sealed class LegacySceneDataSourceRouter : throw new ArgumentOutOfRangeException(nameof(pageIndexZeroBased)); } + RejectUnresolvedNamedPlaylistEntry(entry); + if (!_loaders.TryGetValue(entry.CutCode, out var loader)) { + if (string.Equals( + entry.CutCode, + LegacyPlaylistEntry.UnresolvedCutCode, + StringComparison.Ordinal)) + { + throw new LegacySceneDataException( + "이 DB 재생목록 행은 기존 컷 이름으로 장면을 찾을 수 없습니다. 다른 행은 계속 사용할 수 있습니다."); + } + throw new LegacySceneDataException( "The selected cut does not have a registered scene-data loader."); } @@ -133,12 +144,34 @@ public sealed class LegacySceneDataSourceRouter : CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(entry); + RejectUnresolvedNamedPlaylistEntry(entry); if (!_pagePlanLoaders.TryGetValue(entry.CutCode, out var loader)) { + if (string.Equals( + entry.CutCode, + LegacyPlaylistEntry.UnresolvedCutCode, + StringComparison.Ordinal)) + { + throw new LegacySceneDataException( + "이 DB 재생목록 행은 기존 컷 이름으로 장면을 찾을 수 없습니다. 다른 행은 계속 사용할 수 있습니다."); + } + throw new LegacySceneDataException( "The selected cut does not have a registered page preflight route."); } return loader(entry, cancellationToken); } + + private static void RejectUnresolvedNamedPlaylistEntry(LegacyPlaylistEntry entry) + { + if (string.Equals( + entry.CutCode, + LegacyPlaylistEntry.UnresolvedCutCode, + StringComparison.Ordinal)) + { + throw new LegacySceneDataException( + "기존 DB 재생목록 행의 장면 이름을 확인할 수 없습니다. 다른 행은 계속 사용할 수 있습니다."); + } + } } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ManualSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ManualSceneDataLoaders.cs index d90ec13..28cf821 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ManualSceneDataLoaders.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ManualSceneDataLoaders.cs @@ -34,7 +34,7 @@ public sealed class S5076SceneDataLoader "INPUT_PIE", spec, cancellationToken).ConfigureAwait(false); - var row = SceneDataTableReader.SingleRow( + var row = LegacyManualFinancialStorageCompatibility.SingleEquivalentSceneRow( table, "STOCK_NAME", "BASE_DATE", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5"); @@ -47,7 +47,7 @@ public sealed class S5076SceneDataLoader return new S5076SceneData( SceneDataTableReader.RequiredString(row, "STOCK_NAME"), - SceneDataTableReader.RequiredString(row, "BASE_DATE"), + SceneDataTableReader.RequiredString(row, "BASE_DATE", allowEmpty: true), slices); } @@ -59,11 +59,19 @@ public sealed class S5076SceneDataLoader } var parts = value.Split('_'); - if (parts.Length != 2 || parts[0].Length == 0) + if (parts.Length < 2) { throw new LegacySceneDataException("INPUT_PIE contains an invalid revenue slice."); } + // Form/GraphE.cs persisted an unused slice as "_" and s5076.cs skipped + // every slice whose first token was empty. Keep that exact compatibility + // behavior at the fresh PREPARE read boundary. + if (parts[0].Length == 0) + { + return null; + } + var label = parts[0]; var percentageText = parts[1]; if (percentageText is "" or "-") @@ -73,7 +81,7 @@ public sealed class S5076SceneDataLoader if (!double.TryParse( percentageText, - NumberStyles.Float, + NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var percentage) || !double.IsFinite(percentage)) @@ -113,14 +121,16 @@ public sealed class S5081SceneDataLoader "INPUT_PROFIT", spec, cancellationToken).ConfigureAwait(false); - var row = SceneDataTableReader.SingleRow( + var row = LegacyManualFinancialStorageCompatibility.SingleEquivalentSceneRow( table, "STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6"); var quarters = new QuarterlyProfitData[6]; for (var index = 0; index < quarters.Length; index++) { - var value = SceneDataTableReader.RequiredString(row, $"GUSUNG_{index + 1}"); + var value = LegacyManualFinancialStorageCompatibility.RequiredQuarterStorageString( + row, + $"GUSUNG_{index + 1}"); quarters[index] = ParseQuarter(value); } @@ -132,12 +142,17 @@ public sealed class S5081SceneDataLoader private static QuarterlyProfitData ParseQuarter(string value) { var parts = value.Split('_'); - if (parts.Length != 2 || parts[0].Length == 0) + if (parts.Length < 2) + { + throw new LegacySceneDataException("INPUT_PROFIT contains an invalid quarter value."); + } + + var label = LegacyManualFinancialStorageCompatibility.NormalizeQuarterLabel(parts[0]); + if (label.Any(char.IsControl)) { throw new LegacySceneDataException("INPUT_PROFIT contains an invalid quarter value."); } - var label = parts[0]; var numberText = parts[1]; if (numberText.Length == 0) { diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneDataLoaders.cs index 48a7b44..9f99566 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneDataLoaders.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneDataLoaders.cs @@ -88,7 +88,8 @@ public sealed record NxtThemePagedQuoteLoadRequest( string ThemeName, PagedQuoteThemeSort Sort, NxtMarketSession Session, - int PageNumberOneBased = 1) : PagedQuoteLoadRequest(PageNumberOneBased); + int PageNumberOneBased = 1, + string? ExactThemeName = null) : PagedQuoteLoadRequest(PageNumberOneBased); public sealed record ExpertPagedQuoteLoadRequest( string ExpertCode, @@ -172,6 +173,18 @@ internal sealed class PagedQuoteSceneDataLoaderCore { private static readonly string[] ResultColumns = ["NAME", "PRIMARY_VALUE", "SECONDARY_VALUE", "RATE", "DIRECTION"]; + private static readonly string[] ExpertResultColumns = + [ + "EXPERT_CODE", + "EXPERT_NAME", + "PLAY_INDEX", + "STOCK_CODE", + "NAME", + "PRIMARY_VALUE", + "SECONDARY_VALUE", + "RATE", + "DIRECTION" + ]; private readonly IDataQueryExecutor _executor; private readonly ScenePageSize _pageSize; @@ -209,11 +222,17 @@ internal sealed class PagedQuoteSceneDataLoaderCore _tableName, plan.Query, cancellationToken).ConfigureAwait(false); - var rows = MapRows( - table, - queryRows, - plan.HeaderStyle, - allowEmpty: includeTruncationProbe); + var rows = request is ExpertPagedQuoteLoadRequest expertRequest + ? MapExpertRows( + table, + queryRows, + expertRequest, + allowEmpty: includeTruncationProbe) + : MapRows( + table, + queryRows, + plan.HeaderStyle, + allowEmpty: includeTruncationProbe); if (includeTruncationProbe && rows.Count == 0) { return new PagedQuoteSceneData( @@ -292,6 +311,106 @@ internal sealed class PagedQuoteSceneDataLoaderCore return result; } + private static IReadOnlyList MapExpertRows( + DataTable? table, + int maximumRows, + ExpertPagedQuoteLoadRequest request, + bool allowEmpty) + { + if (table is null || (!allowEmpty && table.Rows.Count < 1) || + table.Rows.Count > maximumRows) + { + throw new LegacySceneDataException( + "The expert paged quote query returned an invalid row count."); + } + + var actualColumns = table.Columns + .Cast() + .Select(column => column.ColumnName) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (table.Columns.Count != ExpertResultColumns.Length || + ExpertResultColumns.Any(column => !actualColumns.Contains(column))) + { + throw new LegacySceneDataException( + "The expert paged quote query returned an unexpected schema."); + } + + var result = new PagedQuoteRowData[table.Rows.Count]; + for (var index = 0; index < table.Rows.Count; index++) + { + var row = table.Rows[index]; + var expertCode = RequiredText(row, "EXPERT_CODE"); + var expertName = RequiredText(row, "EXPERT_NAME"); + var playIndex = RequiredNonNegativeInteger(row, "PLAY_INDEX"); + var stockCode = RequiredText(row, "STOCK_CODE"); + var stockName = RequiredText(row, "NAME"); + if (!string.Equals(expertCode, request.ExpertCode, StringComparison.Ordinal) || + !string.Equals(expertName, request.ExpertName, StringComparison.Ordinal)) + { + throw new LegacySceneDataException( + "The expert paged quote query returned a mismatched expert identity."); + } + + if (row["PRIMARY_VALUE"] is DBNull) + { + throw MissingExpertBuyAmount( + expertCode, + expertName, + playIndex, + stockCode, + stockName); + } + + var mapped = new PagedQuoteRowData( + stockName, + RequiredDecimal(row, "PRIMARY_VALUE"), + RequiredDecimal(row, "SECONDARY_VALUE"), + RequiredRate(row, "RATE"), + RequiredDirection(row, "DIRECTION")); + if (decimal.Truncate(mapped.PrimaryValue) != mapped.PrimaryValue || + decimal.Truncate(mapped.SecondaryValue) != mapped.SecondaryValue) + { + throw InvalidValue(); + } + + result[index] = mapped; + } + + return result; + } + + private static int RequiredNonNegativeInteger(DataRow row, string column) + { + try + { + var value = Convert.ToDecimal( + RequiredValue(row, column), + CultureInfo.InvariantCulture); + return value is >= 0 and <= 9_999 && decimal.Truncate(value) == value + ? decimal.ToInt32(value) + : throw InvalidValue(); + } + catch (Exception exception) when ( + exception is FormatException or InvalidCastException or OverflowException) + { + throw InvalidValue(); + } + } + + private static LegacySceneDataException MissingExpertBuyAmount( + string expertCode, + string expertName, + int playIndex, + string stockCode, + string stockName) => + new( + $"전문가 추천 송출을 준비할 수 없습니다. " + + $"전문가: {expertName} ({expertCode}), " + + $"PLAYINDEX: {playIndex}, " + + $"종목: {stockName} ({stockCode}), " + + "누락 필드: RC_BUY_AMOUNT(매수가). " + + "전문가 추천 데이터를 수정한 뒤 다시 PREPARE 하세요."); + private static string RequiredText(DataRow row, string column) { var value = RequiredValue(row, column); @@ -489,17 +608,30 @@ internal static class PagedQuoteQueryFactory { var code = RequiredThemeCode(request.ThemeCode); var displayTitle = RequiredSelection(request.ThemeName); - const string displaySuffix = "(NXT)"; - if (!displayTitle.EndsWith(displaySuffix, StringComparison.Ordinal) || - displayTitle.Length == displaySuffix.Length) + string title; + if (request.ExactThemeName is not null) { - throw InvalidRequest(); + // Native selector rows carry the exact SB_LIST key separately. It is + // intentionally not trimmed or suffix-normalized: stored titles may + // themselves end in one or more literal "(NXT)" tokens or spaces. + title = RequiredExactThemeTitle(request.ExactThemeName); } - - var title = RequiredSelection(displayTitle[..^displaySuffix.Length]); - if (title.Contains(displaySuffix, StringComparison.Ordinal)) + else { - throw InvalidRequest(); + // Historical seven-field playlists carry only the visible terminal + // transport suffix. Keep that legacy parsing contract unchanged. + const string displaySuffix = "(NXT)"; + if (!displayTitle.EndsWith(displaySuffix, StringComparison.Ordinal) || + displayTitle.Length == displaySuffix.Length) + { + throw InvalidRequest(); + } + + title = RequiredSelection(displayTitle[..^displaySuffix.Length]); + if (title.Contains(displaySuffix, StringComparison.Ordinal)) + { + throw InvalidRequest(); + } } ValidateThemeSort(request.Sort); ValidateNxtSession(request.Session); @@ -579,6 +711,23 @@ internal static class PagedQuoteQueryFactory private static string RequiredSelection(string? value) => LegacySceneBuilderGuard.Text(value, "Selection"); + private static string RequiredExactThemeTitle(string? value) + { + var title = RequiredSelection(value); + if (title.Length > 128 || title.Any(character => + char.IsSurrogate(character) || + character == '\uFFFD' || + CharUnicodeInfo.GetUnicodeCategory(character) is + UnicodeCategory.Format or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator)) + { + throw InvalidRequest(); + } + + return title; + } + private static void ValidateMarket(PagedQuoteMarket market) { if (!Enum.IsDefined(market)) @@ -1089,22 +1238,33 @@ internal static class PagedQuoteQueryFactory JOIN t_kosdaq_stock b ON a.f_stock_code = b.f_stock_code WHERE b.f_mkt_halt = 'N' AND a.f_curr_price <> 0 ) - SELECT NAME, PRIMARY_VALUE, SECONDARY_VALUE, RATE, DIRECTION + SELECT EXPERT_CODE, EXPERT_NAME, PLAY_INDEX, STOCK_CODE, + NAME, PRIMARY_VALUE, SECONDARY_VALUE, RATE, DIRECTION FROM ( - SELECT r.RC_STOCK_NAME NAME, + SELECT e.EP_CODE EXPERT_CODE, + e.EP_NAME EXPERT_NAME, + r.PLAYINDEX PLAY_INDEX, + r.RC_STOCK_CODE STOCK_CODE, + r.RC_STOCK_NAME NAME, r.RC_BUY_AMOUNT PRIMARY_VALUE, q.CURRENT_PRICE SECONDARY_VALUE, - ROUND(((q.CURRENT_PRICE - r.RC_BUY_AMOUNT) / - r.RC_BUY_AMOUNT) * 100, 2) RATE, - CASE WHEN q.CURRENT_PRICE - r.RC_BUY_AMOUNT > 0 THEN '2' + CASE + WHEN r.RC_BUY_AMOUNT IS NULL OR r.RC_BUY_AMOUNT = 0 OR + q.CURRENT_PRICE IS NULL THEN NULL + ELSE ROUND(((q.CURRENT_PRICE - r.RC_BUY_AMOUNT) / + r.RC_BUY_AMOUNT) * 100, 2) + END RATE, + CASE WHEN r.RC_BUY_AMOUNT IS NULL OR q.CURRENT_PRICE IS NULL THEN NULL + WHEN q.CURRENT_PRICE - r.RC_BUY_AMOUNT > 0 THEN '2' WHEN q.CURRENT_PRICE - r.RC_BUY_AMOUNT < 0 THEN '5' ELSE '3' END DIRECTION FROM EXPERT_LIST e JOIN RECOMMEND_LIST r ON e.EP_CODE = r.RC_CODE - JOIN QUOTES q ON r.RC_STOCK_CODE = q.STOCK_CODE + LEFT JOIN QUOTES q ON r.RC_STOCK_CODE = q.STOCK_CODE WHERE e.EP_CODE = :expertCode AND e.EP_NAME = :expertName - AND r.RC_BUY_AMOUNT <> 0 + AND (r.RC_BUY_AMOUNT IS NULL OR + (r.RC_BUY_AMOUNT <> 0 AND q.STOCK_CODE IS NOT NULL)) ORDER BY r.PLAYINDEX ) WHERE ROWNUM <= :maxRows diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneDataLoaders.cs index 062cd84..a16ac31 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneDataLoaders.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneDataLoaders.cs @@ -60,6 +60,19 @@ public sealed record S5001ForeignIndustryLoadRequest(string IndustryName) : S500 public override S5001Branch Branch => S5001Branch.ForeignIndustry; } +/// +/// Internal-only exact UC5 industry identity. Native selector rows carry the +/// master input name and symbol in addition to the visible Korean name. +/// +internal sealed record S5001ExactForeignIndustryLoadRequest( + string IndustryName, + string InputName, + string ForeignSymbol, + string ForeignNationCode) : S5001SceneLoadRequest +{ + public override S5001Branch Branch => S5001Branch.ForeignIndustry; +} + public sealed record S5001ForeignStockLoadRequest(string StockName) : S5001SceneLoadRequest { public override S5001Branch Branch => S5001Branch.ForeignStock; @@ -110,6 +123,7 @@ public sealed class S5001SceneDataLoader S5001ExchangeRateLoadRequest exchange => await LoadExchangeAsync(exchange, cancellationToken).ConfigureAwait(false), S5001DomesticIndustryLoadRequest industry => await LoadDomesticIndustryAsync(industry, cancellationToken).ConfigureAwait(false), S5001ForeignIndustryLoadRequest industry => await LoadForeignIndustryAsync(industry, cancellationToken).ConfigureAwait(false), + S5001ExactForeignIndustryLoadRequest industry => await LoadExactForeignIndustryAsync(industry, cancellationToken).ConfigureAwait(false), S5001ForeignStockLoadRequest stock => await LoadForeignStockAsync(stock, cancellationToken).ConfigureAwait(false), S5001ExactForeignStockLoadRequest stock => await LoadExactForeignStockAsync(stock, cancellationToken).ConfigureAwait(false), S5001DomesticStockLoadRequest stock => await LoadDomesticStockAsync(stock, cancellationToken).ConfigureAwait(false), @@ -231,6 +245,58 @@ public sealed class S5001SceneDataLoader new S5001ForeignIndustrySceneData(title, direction, price, change, rate)); } + private async Task LoadExactForeignIndustryAsync( + S5001ExactForeignIndustryLoadRequest request, + CancellationToken cancellationToken) + { + var name = ExactForeignIndustryText( + request.IndustryName, + nameof(request.IndustryName)); + var inputName = ExactForeignIndustryText( + request.InputName, + nameof(request.InputName)); + var symbol = ParameterizedSceneRowReader.Selector( + request.ForeignSymbol, + nameof(request.ForeignSymbol)); + var nationCode = ParameterizedSceneRowReader.Selector( + request.ForeignNationCode, + nameof(request.ForeignNationCode)); + if (!LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol(symbol) || + !string.Equals(nationCode, "US", StringComparison.Ordinal)) + { + throw new LegacySceneDataException("The foreign-industry identity is invalid."); + } + + var row = await ExecuteSingleAsync( + ParameterizedMarketSceneQueries.ForeignIndustry( + name, + inputName, + symbol, + nationCode), + cancellationToken).ConfigureAwait(false); + return DecimalScene(row, static (title, direction, price, change, rate) => + new S5001ForeignIndustrySceneData(title, direction, price, change, rate)); + } + + private static string ExactForeignIndustryText(string? value, string name) + { + var text = LegacySceneBuilderGuard.Text(value, name); + if (text.Length > 200 || + !string.Equals(text, text.Trim(), StringComparison.Ordinal) || + text.Any(character => + char.IsSurrogate(character) || + character == '\uFFFD' || + CharUnicodeInfo.GetUnicodeCategory(character) is + UnicodeCategory.Format or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator)) + { + throw new LegacySceneDataException("The foreign-industry identity is invalid."); + } + + return text; + } + private async Task LoadForeignStockAsync( S5001ForeignStockLoadRequest request, CancellationToken cancellationToken) diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneQueries.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneQueries.cs index b558fa4..eee2bf0 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneQueries.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneQueries.cs @@ -57,6 +57,23 @@ internal static class ParameterizedMarketSceneQueries ForeignIndustryQuery, new DataQueryParameter("industryName", industryName, DbType.String)); + /// + /// Exact UC5 industry route. All master identity fields selected by the + /// operator are bound so duplicate Korean display names remain distinct. + /// + public static ParameterizedSceneQuery ForeignIndustry( + string industryName, + string inputName, + string symbol, + string nationCode) => + Oracle( + "SCENE_FOREIGN_INDUSTRY", + ForeignIndustryExactQuery, + new DataQueryParameter("industryName", industryName, DbType.String), + new DataQueryParameter("inputName", inputName, DbType.String), + new DataQueryParameter("symbol", symbol, DbType.String), + new DataQueryParameter("nationCode", nationCode, DbType.String)); + public static ParameterizedSceneQuery ForeignStock(string stockName) => Oracle( "SCENE_FOREIGN_STOCK", @@ -509,6 +526,23 @@ internal static class ParameterizedMarketSceneQueries FETCH FIRST 2 ROWS ONLY """; + private const string ForeignIndustryExactQuery = """ + SELECT b.f_knam TITLE, + a.f_last PRICE, + a.f_sign DIRECTION, + ABS(ROUND(a.f_diff, 2)) NET_CHANGE, + ROUND(a.f_rate, 2) RATE + FROM t_world_ix_eq_sise a + JOIN t_world_ix_eq_master b ON a.f_symb = b.f_symb + WHERE b.f_knam = :industryName + AND b.f_input_name = :inputName + AND b.f_symb = :symbol + AND b.f_natc = :nationCode + AND b.f_fdtc = '0' + AND a.f_fdtc = '0' + FETCH FIRST 2 ROWS ONLY + """; + private const string ForeignStockQuery = """ SELECT b.f_input_name TITLE, a.f_last PRICE, diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/RegisteredLegacySceneCueProvider.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/RegisteredLegacySceneCueProvider.cs index 5ef9795..5c56e7e 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/RegisteredLegacySceneCueProvider.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/RegisteredLegacySceneCueProvider.cs @@ -29,9 +29,9 @@ public sealed record LegacySceneCueCompositionOptions( } /// -/// Supplies an immutable composition snapshot for one cue build. Native operator controls -/// may replace the snapshot only while the playout workflow is idle; Web content never -/// supplies an asset path. +/// Supplies an immutable composition snapshot for one cue build. The native app may +/// atomically promote a separately validated next-cue draft between builds; Web content +/// never supplies an asset path. /// public interface ILegacySceneCueCompositionOptionsSource { diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonProductionAdapters.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonProductionAdapters.cs index ce00c50..fefddf4 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonProductionAdapters.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonProductionAdapters.cs @@ -8,7 +8,9 @@ namespace MBN_STOCK_WEBVIEW.LegacyApplication; /// Adapts the already parameterized domestic/world lookup services to the closed UC3 /// comparison identity model. Database identities never cross the WebView boundary. /// -public sealed class LegacyComparisonDataService : ILegacyComparisonDataService +public sealed class LegacyComparisonDataService : + ILegacyComparisonDataService, + ILegacyComparisonDataDiagnosticsService { private readonly ILegacyStockLookup _domesticLookup; private readonly IWorldStockSearchService _worldLookup; @@ -22,6 +24,15 @@ public sealed class LegacyComparisonDataService : ILegacyComparisonDataService } public async Task> SearchDomesticAsync( + string query, + int maximumResults, + CancellationToken cancellationToken = default) => + (await SearchDomesticWithDiagnosticsAsync( + query, + maximumResults, + cancellationToken).ConfigureAwait(false)).Items; + + public async Task SearchDomesticWithDiagnosticsAsync( string query, int maximumResults, CancellationToken cancellationToken = default) @@ -32,30 +43,60 @@ public sealed class LegacyComparisonDataService : ILegacyComparisonDataService var targets = new List( Math.Min(result.DisplayNames.Count, maximumResults)); var identities = new HashSet(StringComparer.Ordinal); + var isolatedInvalidRowCount = 0; + var isLimitTruncated = false; foreach (var displayName in result.DisplayNames) { cancellationToken.ThrowIfCancellationRequested(); var identity = result.ResolveLastByDisplayName(displayName); if (identity is null) { + isolatedInvalidRowCount++; continue; } - var target = ToComparisonTarget(identity); + LegacyComparisonTarget target; + try + { + target = ToComparisonTarget(identity); + } + catch (ArgumentException) + { + isolatedInvalidRowCount++; + continue; + } if (identities.Add(target.TargetId)) { - targets.Add(target); - if (targets.Count == maximumResults) + if (targets.Count < maximumResults) { - break; + targets.Add(target); + } + else + { + isLimitTruncated = true; } } } - return targets.AsReadOnly(); + return new LegacyComparisonSearchData( + targets.AsReadOnly(), + isLimitTruncated || isolatedInvalidRowCount > 0, + isolatedInvalidRowCount) + { + DeferredUnsafeRowCount = targets.Count(target => !target.CanSelect) + }; } public async Task> SearchWorldAsync( + string query, + int maximumResults, + CancellationToken cancellationToken = default) => + (await SearchWorldWithDiagnosticsAsync( + query, + maximumResults, + cancellationToken).ConfigureAwait(false)).Items; + + public async Task SearchWorldWithDiagnosticsAsync( string query, int maximumResults, CancellationToken cancellationToken = default) @@ -63,12 +104,31 @@ public sealed class LegacyComparisonDataService : ILegacyComparisonDataService ValidateLimit(maximumResults, LegacyComparisonWorkflowCatalog.WorldMaximumResults); var result = await _worldLookup.SearchAsync(query, maximumResults, cancellationToken) .ConfigureAwait(false); - return Array.AsReadOnly(result.Items - .Select(item => LegacyComparisonTarget.CreateWorldStock( - item.InputName, - item.Symbol, - item.NationCode)) - .ToArray()); + var targets = new List(result.Items.Count); + var isolatedInvalidRowCount = result.IsolatedInvalidRowCount; + foreach (var item in result.Items) + { + try + { + var target = LegacyComparisonTarget.CreateWorldStock( + item.InputName, + item.Symbol, + item.NationCode); + targets.Add(target); + } + catch (ArgumentException) + { + isolatedInvalidRowCount++; + } + } + + return new LegacyComparisonSearchData( + targets.AsReadOnly(), + result.IsTruncated || isolatedInvalidRowCount > 0, + isolatedInvalidRowCount) + { + DeferredUnsafeRowCount = targets.Count(target => !target.CanSelect) + }; } public async Task ResolveStoredTargetAsync( diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowCatalog.cs index ac97699..50bfd6d 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowCatalog.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowCatalog.cs @@ -1,5 +1,6 @@ using System.Collections.ObjectModel; using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; namespace MBN_STOCK_WEBVIEW.LegacyApplication; @@ -30,8 +31,8 @@ internal sealed record LegacyComparisonCompatibility( internal static class LegacyComparisonWorkflowCatalog { public const int DefaultFadeDuration = 6; - public const int DomesticMaximumResults = 200; - public const int WorldMaximumResults = 100; + public const int DomesticMaximumResults = LegacyStockSearchService.MaximumResults; + public const int WorldMaximumResults = LegacyWorldStockSearchService.MaximumResults; private static readonly ReadOnlyCollection FixedTargetList = Array.AsReadOnly(new[] @@ -152,13 +153,23 @@ internal static class LegacyComparisonWorkflowCatalog selectionId, target.DisplayName, metadata, - target.Kind); + target.Kind) + { + CanSelect = target.CanSelect, + UnavailableReason = target.UnavailableReason + }; } public static LegacyComparisonStoredTargetReference ToStoredReference( LegacyComparisonTarget target) { ArgumentNullException.ThrowIfNull(target); + if (!target.CanSelect) + { + throw new InvalidOperationException( + "A deferred comparison row cannot be serialized."); + } + return target.Kind switch { LegacyComparisonTargetKind.MarketTarget => @@ -184,7 +195,7 @@ internal static class LegacyComparisonWorkflowCatalog public static bool MatchesStoredReference( LegacyComparisonTarget target, LegacyComparisonStoredTargetReference stored) => - ToStoredReference(target) == stored; + target.CanSelect && ToStoredReference(target) == stored; public static LegacyComparisonTarget? TryResolveFixedStoredTarget( LegacyComparisonStoredTargetReference stored) => @@ -332,6 +343,11 @@ internal static class LegacyComparisonWorkflowCatalog return Unavailable("pair-incomplete-or-invalid"); } + if (!first.CanSelect || !second.CanSelect) + { + return Unavailable("deferred-unsafe-comparison-row"); + } + if (action.Kind is LegacyComparisonActionKind.Comparison or LegacyComparisonActionKind.Return) { return first.Kind == LegacyComparisonTargetKind.KrxStock && diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs index f16b487..f9d220d 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs @@ -12,6 +12,7 @@ public sealed class LegacyComparisonWorkflowController private readonly ILegacyComparisonDataService _dataService; private readonly ILegacyComparisonPairStore _pairStore; private readonly LegacyComparisonUiLayout _uiLayout; + private readonly ILegacyComparisonPairImportService? _pairImporter; private List _domesticResults = []; private List _worldResults = []; private List _savedPairs = []; @@ -28,7 +29,14 @@ public sealed class LegacyComparisonWorkflowController private string _statusMessage = string.Empty; private LegacyComparisonSearchStatus _domesticStatus = LegacyComparisonSearchStatus.Idle; private LegacyComparisonSearchStatus _worldStatus = LegacyComparisonSearchStatus.Idle; + private bool _domesticIsTruncated; + private bool _worldIsTruncated; + private int _domesticIsolatedInvalidRowCount; + private int _worldIsolatedInvalidRowCount; + private int _domesticDeferredUnsafeRowCount; + private int _worldDeferredUnsafeRowCount; private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral; + private LegacyComparisonImportReceipt? _lastImport; private long _revision; private long _pairSequence; private long _draftSequence; @@ -40,15 +48,19 @@ public sealed class LegacyComparisonWorkflowController public LegacyComparisonWorkflowController( ILegacyComparisonDataService dataService, ILegacyComparisonPairStore pairStore, - LegacyComparisonUiLayout? uiLayout = null) + LegacyComparisonUiLayout? uiLayout = null, + ILegacyComparisonPairImportService? pairImporter = null) { _dataService = dataService ?? throw new ArgumentNullException(nameof(dataService)); _pairStore = pairStore ?? throw new ArgumentNullException(nameof(pairStore)); _uiLayout = uiLayout ?? LegacyComparisonUiLayout.Default; + _pairImporter = pairImporter; } public LegacyComparisonWorkflowSnapshot Current => CreateSnapshot(); + public bool CanImportLegacyPairs => _pairImporter is not null; + /// /// Recreates UC3's per-control state before its saved CP949 pairs are loaded for /// a new tab activation. Search and pair-load generations invalidate completions @@ -75,7 +87,14 @@ public sealed class LegacyComparisonWorkflowController _statusMessage = string.Empty; _domesticStatus = LegacyComparisonSearchStatus.Idle; _worldStatus = LegacyComparisonSearchStatus.Idle; + _domesticIsTruncated = false; + _worldIsTruncated = false; + _domesticIsolatedInvalidRowCount = 0; + _worldIsolatedInvalidRowCount = 0; + _domesticDeferredUnsafeRowCount = 0; + _worldDeferredUnsafeRowCount = 0; _statusKind = LegacyOperatorStatusKind.Neutral; + _lastImport = null; _isBusy = false; Touch(); return CreateSnapshot(); @@ -91,25 +110,33 @@ public sealed class LegacyComparisonWorkflowController _domesticStatus = LegacyComparisonSearchStatus.Loading; _domesticError = string.Empty; _domesticResults = []; + _domesticIsTruncated = false; + _domesticIsolatedInvalidRowCount = 0; + _domesticDeferredUnsafeRowCount = 0; _selectedDomesticResultId = null; Touch(); try { - var results = await _dataService.SearchDomesticAsync( + var search = await SearchDomesticWithDiagnosticsAsync( normalizedQuery, - LegacyComparisonWorkflowCatalog.DomesticMaximumResults, cancellationToken).ConfigureAwait(false); if (version != Volatile.Read(ref _domesticSearchVersion)) { return CreateSnapshot(); } + ValidateDiagnostics( + search, + LegacyComparisonWorkflowCatalog.DomesticMaximumResults); _domesticResults = ValidateSearchResults( - results, + search.Items, LegacyComparisonWorkflowCatalog.DomesticMaximumResults, domestic: true, version); + _domesticIsTruncated = search.IsTruncated; + _domesticIsolatedInvalidRowCount = search.IsolatedInvalidRowCount; + _domesticDeferredUnsafeRowCount = search.DeferredUnsafeRowCount; _domesticStatus = LegacyComparisonSearchStatus.Ready; _domesticError = string.Empty; Touch(); @@ -131,6 +158,9 @@ public sealed class LegacyComparisonWorkflowController if (version == Volatile.Read(ref _domesticSearchVersion)) { _domesticResults = []; + _domesticIsTruncated = false; + _domesticIsolatedInvalidRowCount = 0; + _domesticDeferredUnsafeRowCount = 0; _domesticStatus = LegacyComparisonSearchStatus.Error; _domesticError = "국내·NXT 종목 검색 결과를 안전하게 확인하지 못했습니다."; Touch(); @@ -151,25 +181,33 @@ public sealed class LegacyComparisonWorkflowController _worldStatus = LegacyComparisonSearchStatus.Loading; _worldError = string.Empty; _worldResults = []; + _worldIsTruncated = false; + _worldIsolatedInvalidRowCount = 0; + _worldDeferredUnsafeRowCount = 0; _selectedWorldResultId = null; Touch(); try { - var results = await _dataService.SearchWorldAsync( + var search = await SearchWorldWithDiagnosticsAsync( normalizedQuery, - LegacyComparisonWorkflowCatalog.WorldMaximumResults, cancellationToken).ConfigureAwait(false); if (version != Volatile.Read(ref _worldSearchVersion)) { return CreateSnapshot(); } + ValidateDiagnostics( + search, + LegacyComparisonWorkflowCatalog.WorldMaximumResults); _worldResults = ValidateSearchResults( - results, + search.Items, LegacyComparisonWorkflowCatalog.WorldMaximumResults, domestic: false, version); + _worldIsTruncated = search.IsTruncated; + _worldIsolatedInvalidRowCount = search.IsolatedInvalidRowCount; + _worldDeferredUnsafeRowCount = search.DeferredUnsafeRowCount; _worldStatus = LegacyComparisonSearchStatus.Ready; _worldError = string.Empty; Touch(); @@ -191,6 +229,9 @@ public sealed class LegacyComparisonWorkflowController if (version == Volatile.Read(ref _worldSearchVersion)) { _worldResults = []; + _worldIsTruncated = false; + _worldIsolatedInvalidRowCount = 0; + _worldDeferredUnsafeRowCount = 0; _worldStatus = LegacyComparisonSearchStatus.Error; _worldError = "미국·대만 종목 검색 결과를 안전하게 확인하지 못했습니다."; Touch(); @@ -323,6 +364,92 @@ public sealed class LegacyComparisonWorkflowController return CreateSnapshot(); } + /// + /// Resolves every row from the original application's fixed, read-only file before + /// crossing the local write boundary. Existing operator rows keep their exact order; + /// only ordered pairs that have not already been saved are appended. + /// + public async Task ImportLegacyPairsAsync( + CancellationToken cancellationToken = default) + { + if (_pairImporter is null) + { + throw new InvalidOperationException( + "원본 비교쌍 가져오기가 현재 데이터베이스 구성에 연결되지 않았습니다."); + } + + BeginBusy(); + try + { + var resolved = await _pairImporter.ImportAsync(cancellationToken) + .ConfigureAwait(false); + var importedPairs = ValidateImportedPairs(resolved); + + var signatures = new HashSet( + _savedPairs.Select(static pair => + LegacyComparisonWorkflowCatalog.PairSignature(pair.First, pair.Second)), + StringComparer.Ordinal); + var candidate = new List(_savedPairs); + var nextSequence = _pairSequence; + foreach (var pair in importedPairs) + { + cancellationToken.ThrowIfCancellationRequested(); + var signature = LegacyComparisonWorkflowCatalog.PairSignature( + pair.First, + pair.Second); + if (!signatures.Add(signature)) + { + continue; + } + + candidate.Add(new SavedPairState( + $"comparison-pair-{++nextSequence:D8}", + pair.First, + pair.Second)); + } + + if (candidate.Count > LegacyComparisonFileParser.MaximumRows) + { + throw new InvalidOperationException( + "원본 비교쌍을 병합하면 저장 가능한 최대 행 수를 초과합니다."); + } + + var addedCount = candidate.Count - _savedPairs.Count; + if (addedCount > 0) + { + await WritePairsAsync(candidate, cancellationToken).ConfigureAwait(false); + _savedPairs = candidate; + _pairSequence = nextSequence; + _selectedPairId ??= candidate.FirstOrDefault()?.RowId; + } + + _lastImport = new LegacyComparisonImportReceipt( + resolved.SourceSha256, + resolved.SourceRowCount, + addedCount, + resolved.SourceRowCount - addedCount, + candidate.Count, + resolved.RetrievedAt); + _statusMessage = addedCount == 0 + ? $"원본 비교쌍 {resolved.SourceRowCount}개가 모두 이미 저장되어 있습니다." + : $"원본 비교쌍 {resolved.SourceRowCount}개를 확인해 새 비교쌍 {addedCount}개를 추가했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + } + catch + { + _statusMessage = + "원본 비교쌍을 모두 확인하거나 로컬 파일에 원자적으로 병합하지 못해 변경하지 않았습니다."; + _statusKind = LegacyOperatorStatusKind.Error; + throw; + } + finally + { + EndBusy(); + } + + return CreateSnapshot(); + } + public async Task AddDraftPairAsync( CancellationToken cancellationToken = default) { @@ -331,6 +458,12 @@ public sealed class LegacyComparisonWorkflowController throw new InvalidOperationException("Select two comparison targets first."); } + if (!_firstTarget.CanSelect || !_secondTarget.CanSelect) + { + throw new InvalidOperationException( + "A deferred comparison row cannot be saved as a comparison pair."); + } + if (_savedPairs.Count >= LegacyComparisonFileParser.MaximumRows) { throw new InvalidOperationException("The saved comparison pair limit was reached."); @@ -449,6 +582,15 @@ public sealed class LegacyComparisonWorkflowController string.Equals(item.SelectionId, selectionId, StringComparison.Ordinal)); if (result is not null) { + if (!result.Target.CanSelect) + { + _statusMessage = result.Target.UnavailableReason ?? + "이 비교 대상은 선택할 수 없습니다."; + _statusKind = LegacyOperatorStatusKind.Warning; + Touch(); + return CreateSnapshot(); + } + if (domestic) { _selectedDomesticResultId = selectionId; @@ -471,11 +613,21 @@ public sealed class LegacyComparisonWorkflowController bool domestic) { ArgumentNullException.ThrowIfNull(selectionId); - if (results.Any(item => string.Equals( - item.SelectionId, - selectionId, - StringComparison.Ordinal))) + var result = results.FirstOrDefault(item => string.Equals( + item.SelectionId, + selectionId, + StringComparison.Ordinal)); + if (result is not null) { + if (!result.Target.CanSelect) + { + _statusMessage = result.Target.UnavailableReason ?? + "이 비교 대상은 선택할 수 없습니다."; + _statusKind = LegacyOperatorStatusKind.Warning; + Touch(); + return CreateSnapshot(); + } + if (domestic) { _selectedDomesticResultId = selectionId; @@ -511,6 +663,11 @@ public sealed class LegacyComparisonWorkflowController } resolved = LegacyComparisonWorkflowCatalog.RequireCanonicalTarget(resolved); + if (!resolved.CanSelect) + { + throw new InvalidOperationException( + "A stored comparison target resolves only to a deferred unsafe row."); + } if (!LegacyComparisonWorkflowCatalog.MatchesStoredReference(resolved, stored)) { throw new InvalidOperationException("A stored comparison target resolved to a different identity."); @@ -528,20 +685,7 @@ public sealed class LegacyComparisonWorkflowController BeginBusy(); try { - var writeRows = candidate.Select((pair, index) => - { - var first = LegacyComparisonWorkflowCatalog.ToStoredReference(pair.First); - var second = LegacyComparisonWorkflowCatalog.ToStoredReference(pair.Second); - return new LegacyComparisonPairWriteRow( - index + 1, - first.Name, - first.Market, - second.Name, - second.Market); - }).ToArray(); - await _pairStore.WriteCp949Async( - Array.AsReadOnly(writeRows), - cancellationToken).ConfigureAwait(false); + await WritePairsAsync(candidate, cancellationToken).ConfigureAwait(false); _savedPairs = candidate.ToList(); _selectedPairId = nextSelection; @@ -562,6 +706,129 @@ public sealed class LegacyComparisonWorkflowController return CreateSnapshot(); } + private async Task WritePairsAsync( + IReadOnlyList candidate, + CancellationToken cancellationToken) + { + var writeRows = candidate.Select((pair, index) => + { + var first = LegacyComparisonWorkflowCatalog.ToStoredReference(pair.First); + var second = LegacyComparisonWorkflowCatalog.ToStoredReference(pair.Second); + return new LegacyComparisonPairWriteRow( + index + 1, + first.Name, + first.Market, + second.Name, + second.Market); + }).ToArray(); + await _pairStore.WriteCp949Async( + Array.AsReadOnly(writeRows), + cancellationToken).ConfigureAwait(false); + } + + private static IReadOnlyList<(LegacyComparisonTarget First, LegacyComparisonTarget Second)> + ValidateImportedPairs(LegacyComparisonImportResult? result) + { + if (result is null || result.SourceRowCount is <= 0 or > LegacyComparisonFileParser.MaximumRows || + result.Pairs is null || result.Pairs.Count != result.SourceRowCount || + string.IsNullOrEmpty(result.SourceSha256) || result.SourceSha256.Length != 64 || + result.SourceSha256.Any(static value => + value is not (>= '0' and <= '9') and not (>= 'A' and <= 'F'))) + { + throw new InvalidOperationException("The resolved comparison import is invalid."); + } + + var pairs = new List<(LegacyComparisonTarget First, LegacyComparisonTarget Second)>( + result.Pairs.Count); + for (var index = 0; index < result.Pairs.Count; index++) + { + var pair = result.Pairs[index] ?? + throw new InvalidOperationException("The resolved comparison import is invalid."); + if (pair.RowNumber != index + 1) + { + throw new InvalidOperationException("The resolved comparison import is invalid."); + } + + pairs.Add(( + ToCanonicalImportedTarget(pair.First), + ToCanonicalImportedTarget(pair.Second))); + } + + return pairs.AsReadOnly(); + } + + private static LegacyComparisonTarget ToCanonicalImportedTarget( + LegacyComparisonImportTarget? target) + { + if (target is null) + { + throw new InvalidOperationException("The resolved comparison target is invalid."); + } + + LegacyComparisonTarget canonical = target.Kind switch + { + LegacyComparisonImportTargetKind.MarketTarget + when target.MarketTarget is not null && + target.Market is null && target.StockName is null && + target.StockCode is null && target.InputName is null && + target.Symbol is null && target.Nation is null => + LegacyComparisonWorkflowCatalog.FixedTargets.SingleOrDefault(item => + string.Equals( + item.MarketTarget, + target.MarketTarget, + StringComparison.Ordinal)) ?? + throw new InvalidOperationException( + "The resolved comparison market target is unknown."), + LegacyComparisonImportTargetKind.KrxStock + when target.MarketTarget is null && target.Market is not null && + target.StockName is not null && target.StockCode is not null && + target.InputName is null && target.Symbol is null && + target.Nation is null => + LegacyComparisonTarget.CreateKrxStock( + ParseImportedDomesticMarket(target.Market), + target.StockName, + target.StockCode), + LegacyComparisonImportTargetKind.NxtStock + when target.MarketTarget is null && target.Market is not null && + target.StockName is not null && target.StockCode is not null && + target.InputName is null && target.Symbol is null && + target.Nation is null => + LegacyComparisonTarget.CreateNxtStock( + ParseImportedDomesticMarket(target.Market), + target.StockName, + target.StockCode), + LegacyComparisonImportTargetKind.WorldStock + when target.MarketTarget is null && target.Market is null && + target.StockName is null && target.StockCode is null && + target.InputName is not null && target.Symbol is not null && + target.Nation is not null => + LegacyComparisonTarget.CreateWorldStock( + target.InputName, + target.Symbol, + target.Nation), + _ => throw new InvalidOperationException( + "The resolved comparison target shape is invalid.") + }; + + canonical = LegacyComparisonWorkflowCatalog.RequireCanonicalTarget(canonical); + if (!canonical.CanSelect) + { + throw new InvalidOperationException( + "The resolved comparison target cannot be stored safely."); + } + + return canonical; + } + + private static LegacyComparisonDomesticMarket ParseImportedDomesticMarket(string market) => + market switch + { + "kospi" => LegacyComparisonDomesticMarket.Kospi, + "kosdaq" => LegacyComparisonDomesticMarket.Kosdaq, + _ => throw new InvalidOperationException( + "The resolved comparison domestic market is invalid.") + }; + private static List ValidateSearchResults( IReadOnlyList? results, int maximumResults, @@ -574,14 +841,14 @@ public sealed class LegacyComparisonWorkflowController } var validated = new List(results.Count); - var targetIds = new HashSet(StringComparer.Ordinal); + var identities = new HashSet(); for (var index = 0; index < results.Count; index++) { var target = LegacyComparisonWorkflowCatalog.RequireCanonicalTarget(results[index]); var allowedKind = domestic ? target.Kind is LegacyComparisonTargetKind.KrxStock or LegacyComparisonTargetKind.NxtStock : target.Kind == LegacyComparisonTargetKind.WorldStock; - if (!allowedKind || !targetIds.Add(target.TargetId)) + if (!allowedKind || !identities.Add(target)) { throw new InvalidOperationException("The comparison search result identity is invalid."); } @@ -594,6 +861,62 @@ public sealed class LegacyComparisonWorkflowController return validated; } + private async Task SearchDomesticWithDiagnosticsAsync( + string query, + CancellationToken cancellationToken) + { + if (_dataService is ILegacyComparisonDataDiagnosticsService diagnostics) + { + return await diagnostics.SearchDomesticWithDiagnosticsAsync( + query, + LegacyComparisonWorkflowCatalog.DomesticMaximumResults, + cancellationToken).ConfigureAwait(false); + } + + var items = await _dataService.SearchDomesticAsync( + query, + LegacyComparisonWorkflowCatalog.DomesticMaximumResults, + cancellationToken).ConfigureAwait(false); + return new LegacyComparisonSearchData(items, false, 0); + } + + private async Task SearchWorldWithDiagnosticsAsync( + string query, + CancellationToken cancellationToken) + { + if (_dataService is ILegacyComparisonDataDiagnosticsService diagnostics) + { + return await diagnostics.SearchWorldWithDiagnosticsAsync( + query, + LegacyComparisonWorkflowCatalog.WorldMaximumResults, + cancellationToken).ConfigureAwait(false); + } + + var items = await _dataService.SearchWorldAsync( + query, + LegacyComparisonWorkflowCatalog.WorldMaximumResults, + cancellationToken).ConfigureAwait(false); + return new LegacyComparisonSearchData(items, false, 0); + } + + private static void ValidateDiagnostics( + LegacyComparisonSearchData? search, + int maximumResults) + { + if (search is null || search.Items is null || + search.Items.Count > maximumResults || + search.IsolatedInvalidRowCount is < 0 || + search.IsolatedInvalidRowCount > maximumResults || + search.DeferredUnsafeRowCount is < 0 || + search.DeferredUnsafeRowCount > search.Items.Count || + search.DeferredUnsafeRowCount != search.Items.Count(item => !item.CanSelect) || + search.IsolatedInvalidRowCount > 0 && !search.IsTruncated) + { + throw new InvalidOperationException( + "The comparison search diagnostics are invalid."); + } + } + private LegacyComparisonWorkflowSnapshot CreateSnapshot() { var selectedPair = SelectedPairIndex() is var selectedIndex && selectedIndex >= 0 @@ -630,14 +953,24 @@ public sealed class LegacyComparisonWorkflowController Array.AsReadOnly(domesticResults), _selectedDomesticResultId, domesticResults.Length, - _domesticError), + _domesticError) + { + IsTruncated = _domesticIsTruncated, + IsolatedInvalidRowCount = _domesticIsolatedInvalidRowCount, + DeferredUnsafeRowCount = _domesticDeferredUnsafeRowCount + }, new LegacyComparisonSearchSnapshot( _worldQuery, _worldStatus, Array.AsReadOnly(worldResults), _selectedWorldResultId, worldResults.Length, - _worldError), + _worldError) + { + IsTruncated = _worldIsTruncated, + IsolatedInvalidRowCount = _worldIsolatedInvalidRowCount, + DeferredUnsafeRowCount = _worldDeferredUnsafeRowCount + }, Array.AsReadOnly(fixedTargets), _selectedFixedTargetId, _firstTarget is null @@ -652,7 +985,11 @@ public sealed class LegacyComparisonWorkflowController selectedPair?.First, selectedPair?.Second), _statusMessage, - _statusKind); + _statusKind) + { + CanImportLegacyPairs = CanImportLegacyPairs, + LastImport = _lastImport + }; } private int SelectedPairIndex() => _selectedPairId is null diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowModels.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowModels.cs index e5effb2..a0b03a3 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowModels.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowModels.cs @@ -1,5 +1,6 @@ using System.Globalization; using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; namespace MBN_STOCK_WEBVIEW.LegacyApplication; @@ -81,6 +82,14 @@ public sealed record LegacyComparisonTarget public string? Nation { get; } + public bool CanSelect => + Kind != LegacyComparisonTargetKind.WorldStock || + !InputName!.Contains(',', StringComparison.Ordinal); + + public string? UnavailableReason => CanSelect + ? null + : "쉼표가 포함된 해외 종목명은 비교 대상 구분이 모호해 선택할 수 없습니다."; + public static LegacyComparisonTarget CreateMarketTarget( string targetId, string displayName, @@ -134,11 +143,6 @@ public sealed record LegacyComparisonTarget string nation) { LegacyComparisonValueGuard.RequireText(inputName, 200, nameof(inputName)); - if (inputName.Contains(',', StringComparison.Ordinal)) - { - throw new ArgumentException("A world stock input name cannot contain a comma.", nameof(inputName)); - } - LegacyComparisonValueGuard.RequireSymbol(symbol, nameof(symbol)); var normalizedNation = LegacyComparisonValueGuard.RequireNation(nation, nameof(nation)); return new LegacyComparisonTarget( @@ -186,6 +190,32 @@ public interface ILegacyComparisonDataService CancellationToken cancellationToken = default); } +public sealed record LegacyComparisonSearchData( + IReadOnlyList Items, + bool IsTruncated, + int IsolatedInvalidRowCount) +{ + public int DeferredUnsafeRowCount { get; init; } +} + +/// +/// Optional production telemetry used to preserve safe legacy rows when one provider row +/// cannot become a selectable native identity. Test and offline adapters may continue to +/// implement the list-only compatibility boundary above. +/// +public interface ILegacyComparisonDataDiagnosticsService +{ + Task SearchDomesticWithDiagnosticsAsync( + string query, + int maximumResults, + CancellationToken cancellationToken = default); + + Task SearchWorldWithDiagnosticsAsync( + string query, + int maximumResults, + CancellationToken cancellationToken = default); +} + /// /// File IO stays behind this boundary. A production adapter owns the fixed path and CP949 /// encoding; no UI request can provide a path or raw serialized row. @@ -210,7 +240,12 @@ public sealed record LegacyComparisonTargetView( string SelectionId, string DisplayName, string Metadata, - LegacyComparisonTargetKind Kind); + LegacyComparisonTargetKind Kind) +{ + public bool CanSelect { get; init; } = true; + + public string? UnavailableReason { get; init; } +} public sealed record LegacyComparisonSearchSnapshot( string Query, @@ -218,7 +253,14 @@ public sealed record LegacyComparisonSearchSnapshot( IReadOnlyList Results, string? SelectedResultId, int TotalRowCount, - string ErrorMessage); + string ErrorMessage) +{ + public bool IsTruncated { get; init; } + + public int IsolatedInvalidRowCount { get; init; } + + public int DeferredUnsafeRowCount { get; init; } +} public sealed record LegacyComparisonSavedPairView( string RowId, @@ -228,6 +270,14 @@ public sealed record LegacyComparisonSavedPairView( LegacyComparisonTargetView Second, bool IsSelected); +public sealed record LegacyComparisonImportReceipt( + string SourceSha256, + int SourceRowCount, + int AddedPairCount, + int SkippedDuplicateCount, + int TotalPairCount, + DateTimeOffset ImportedAt); + public sealed record LegacyComparisonActionView( string ActionId, string Label, @@ -248,7 +298,12 @@ public sealed record LegacyComparisonWorkflowSnapshot( string? SelectedPairId, IReadOnlyList Actions, string StatusMessage, - LegacyOperatorStatusKind StatusKind); + LegacyOperatorStatusKind StatusKind) +{ + public bool CanImportLegacyPairs { get; init; } + + public LegacyComparisonImportReceipt? LastImport { get; init; } +} /// /// Generic, already validated playlist materialization. Only an action id crosses the UI @@ -321,11 +376,7 @@ internal static class LegacyComparisonValueGuard public static void RequireSymbol(string value, string parameterName) { - ArgumentNullException.ThrowIfNull(value, parameterName); - if (value.Length is <= 0 or > 64 || - value.Any(static character => - !char.IsAsciiLetterOrDigit(character) && - character is not '@' and not '.' and not '_' and not ':' and not '-')) + if (!LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol(value)) { throw new ArgumentException("A safe world symbol is required.", parameterName); } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs index 358b2d9..6185b7b 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs @@ -22,7 +22,7 @@ public sealed record LegacyExpertRecommendationView( int PlayIndex, string StockCode, string StockName, - decimal BuyAmount); + decimal? BuyAmount); public sealed record LegacyExpertSearchSnapshot( string Query, @@ -475,8 +475,9 @@ public sealed class LegacyExpertWorkflowController item.PlayIndex <= previousIndex || !IsAsciiAlphaNumeric(item.StockCode, 32) || !IsCanonicalText(item.StockName, 200) || - item.BuyAmount <= 0 || item.BuyAmount > 9_007_199_254_740_991m || - decimal.Truncate(item.BuyAmount) != item.BuyAmount || + item.BuyAmount is { } buyAmount && + (buyAmount <= 0 || buyAmount > 9_007_199_254_740_991m || + decimal.Truncate(buyAmount) != buyAmount) || !indexes.Add(item.PlayIndex)) { throw new ExpertSelectionDataException( diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyCutInfoSceneAliasResolver.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyCutInfoSceneAliasResolver.cs new file mode 100644 index 0000000..1f7d396 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyCutInfoSceneAliasResolver.cs @@ -0,0 +1,339 @@ +#nullable enable + +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +/// +/// Reproduces the ordered, ASCII-space-insensitive alias selection performed by +/// the original MBN_STOCK_N Class/CutList.cs. The returned value is additionally +/// closed against so legacy text can never become +/// an operator-controlled scene name or path. +/// +public static class LegacyCutInfoSceneAliasResolver +{ + private static readonly string[] OriginalAliases = + [ + "50160", + "5081", + "5080", + "5079", + "5076", + "N5001", + "5001", + "5006", + "5011", + "5026", + "5087", + "5029", + "5086", + "8035", + "8061", + "8051", + "8040", + "8046", + "8056", + "8003", + "5037", + "5016", + "5068", + "5070", + "5072", + "8067", + "5078", + "6001", + "5082", + "5023", + "5083", + "5084", + "5024", + "6067", + "5085", + "5025", + "8001", + "8002", + "5074", + "5077", + "5088", + "5032", + "8032", + "8018", + "50860" + ]; + + static LegacyCutInfoSceneAliasResolver() + { + var unregistered = OriginalAliases + .Where(alias => LegacySceneCatalog.FindByCutAlias(alias).Count == 0) + .ToArray(); + if (unregistered.Length != 0) + { + throw new InvalidOperationException( + "The original CutList contains an alias that is not registered in the scene catalog."); + } + } + + public static bool TryResolve( + string groupCode, + string subject, + string graphicType, + string subtype, + out string sceneAlias) + { + sceneAlias = string.Empty; + if (groupCode is null || subject is null || graphicType is null || subtype is null) + { + return false; + } + + // MainForm.Show_PlayList uses this shorter key for the image-graph row. + // Every other active branch concatenates the four visible legacy fields. + var legacyCutInfo = string.Equals( + graphicType, + "이미지 그래프", + StringComparison.Ordinal) + ? subject + graphicType + : groupCode + subject + graphicType + subtype; + + // The original removes U+0020 only. Other whitespace remains significant. + var normalized = legacyCutInfo.Replace(" ", string.Empty, StringComparison.Ordinal); + var candidate = ResolveOriginalCandidate(normalized); + if (candidate is null || LegacySceneCatalog.FindByCutAlias(candidate).Count == 0) + { + return false; + } + + sceneAlias = candidate; + return true; + } + + private static string? ResolveOriginalCandidate(string info) + { + if (Contains(info, "2열판-")) + { + return "50160"; + } + + if (Contains(info, "영업이익")) + { + return "5081"; + } + + if (Contains(info, "매출액")) + { + return "5080"; + } + + if (Contains(info, "성장성지표")) + { + return "5079"; + } + + if (Contains(info, "주요매출구성")) + { + return "5076"; + } + + if (Contains(info, "(NXT)1열판기본")) + { + return "N5001"; + } + + if (Contains(info, "1열판기본")) + { + return "5001"; + } + + if (Contains(info, "1열판상세")) + { + return Contains(info, "시가") ? "5006" : "5011"; + } + + if (Contains(info, "1열판")) + { + return "5001"; + } + + if (Contains(info, "종목별비교분석_캔들그래프")) + { + return "5026"; + } + + if (Contains(info, "종목별비교분석_라인그래프")) + { + return "5087"; + } + + if (Contains(info, "종목별수익률비교")) + { + return "5029"; + } + + if (Contains(info, "수익률그래프")) + { + return "5086"; + } + + if (Contains(info, "캔들그래프")) + { + if (Contains(info, "일봉")) + { + return "8035"; + } + + if (Contains(info, "5일")) + { + return "8061"; + } + + if (Contains(info, "120일")) + { + return "8051"; + } + + if (Contains(info, "20일")) + { + return "8040"; + } + + if (Contains(info, "60일")) + { + return "8046"; + } + + if (Contains(info, "240일")) + { + return "8056"; + } + + return null; + } + + if (Contains(info, "호가창")) + { + return "8003"; + } + + if (Contains(info, "거래원")) + { + return "5037"; + } + + if (Contains(info, "3열판")) + { + return "5016"; + } + + if (Contains(info, "미국증시지도")) + { + return "5068"; + } + + if (Contains(info, "유럽증시지도")) + { + return "5070"; + } + + if (Contains(info, "아시아증시지도")) + { + return "5072"; + } + + if (Contains(info, "글로벌증시지도")) + { + return "8067"; + } + + if (Contains(info, "미국업종") || Contains(info, "섹터지수")) + { + return "5078"; + } + + if (Contains(info, "이미지그래프")) + { + return "6001"; + } + + if (Contains(info, "주체별매매동향")) + { + return "5082"; + } + + if (Contains(info, "매매동향표그래프")) + { + return "5023"; + } + + if (Contains(info, "개인매매동향") || + Contains(info, "기관매매동향") || + Contains(info, "외국인매매동향")) + { + return "5083"; + } + + if (Contains(info, "매매동향라인그래프")) + { + return "5084"; + } + + if (Contains(info, "매매동향막대그래프")) + { + return "5024"; + } + + if (Contains(info, "기관순매수현황")) + { + return "6067"; + } + + if (Contains(info, "프로그램매매")) + { + return "5085"; + } + + if (Contains(info, "순매도상위")) + { + return "5025"; + } + + if (Contains(info, "네모그래프")) + { + return Contains(info, "코스피") ? "8001" : "8002"; + } + + if (Contains(info, "5단표그래프")) + { + return "5074"; + } + + if (Contains(info, "6종목현재가")) + { + return "5077"; + } + + if (Contains(info, "12종목현재가")) + { + return "5088"; + } + + if (Contains(info, "2열판")) + { + if (Contains(info, "지수") || Contains(info, "종목")) + { + return Contains(info, "선물") ? "5032" : "8032"; + } + + if (Contains(info, "코스피")) + { + return "8018"; + } + + return Contains(info, "코스닥") ? "8032" : null; + } + + return Contains(info, "라인그래프") ? "50860" : null; + } + + private static bool Contains(string source, string value) => + source.Contains(value, StringComparison.Ordinal); +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs index c71dc0f..77b0b83 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs @@ -4,10 +4,19 @@ using CoreMovingAverageSelection = MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageSelection; using CoreMovingAverageSource = MBN_STOCK_WEBVIEW.Core.Playout.Scenes.MutableLegacySceneMovingAverageSelectionSource; +using CorePagePlanProvider = + MBN_STOCK_WEBVIEW.Core.Playout.Scenes.ILegacyScenePagePlanProvider; +using CorePlaylistEntry = + MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacyPlaylistEntry; +using CoreScenePaging = + MBN_STOCK_WEBVIEW.Core.Playout.ScenePaging; +using CoreScenePageSize = + MBN_STOCK_WEBVIEW.Core.Playout.ScenePageSize; using CoreSceneSelection = MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection; using S5011DetailBranch = MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5011DetailBranch; +using MMoneyCoderSharp.Data; namespace MBN_STOCK_WEBVIEW.LegacyApplication; @@ -164,6 +173,7 @@ public sealed class LegacyOperatorController private readonly LegacyOverseasSelectionWorkflow? _overseasWorkflow; private readonly LegacyManualListsWorkflow? _manualListsWorkflow; private readonly LegacyOperatorCatalogWorkflowController? _operatorCatalogWorkflow; + private readonly CorePagePlanProvider? _fixedPagePlanProvider; private readonly CoreMovingAverageSource _movingAverageSource = new( new CoreMovingAverageSelection(true, true)); private readonly SemaphoreSlim _tabActivationGate = new(1, 1); @@ -232,7 +242,8 @@ public sealed class LegacyOperatorController LegacyOverseasSelectionWorkflow? overseasWorkflow = null, LegacyManualListsWorkflow? manualListsWorkflow = null, LegacyOperatorCatalogWorkflowController? operatorCatalogWorkflow = null, - IReadOnlyList? cutRows = null) + IReadOnlyList? cutRows = null, + CorePagePlanProvider? fixedPagePlanProvider = null) { _stockLookup = stockLookup ?? throw new ArgumentNullException(nameof(stockLookup)); _cutRows = CreateValidatedCutRows(cutRows); @@ -249,6 +260,7 @@ public sealed class LegacyOperatorController _overseasWorkflow = overseasWorkflow; _manualListsWorkflow = manualListsWorkflow; _operatorCatalogWorkflow = operatorCatalogWorkflow; + _fixedPagePlanProvider = fixedPagePlanProvider; _tradingHaltWorkflow?.SetMovingAverages( _movingAverages.Ma5, _movingAverages.Ma20); @@ -1040,6 +1052,25 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } + public async Task ChangeCreateThemeCatalogMarketAsync( + MMoneyCoderSharp.Data.ThemeMarket market, + string editorName, + CancellationToken cancellationToken = default) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA 관리 기능이 구성되지 않았습니다."); + } + + await _operatorCatalogWorkflow.ChangeCreateThemeMarketAsync( + market, + editorName, + cancellationToken).ConfigureAwait(false); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + public async Task BeginCreateExpertCatalogAsync( CancellationToken cancellationToken = default) { @@ -1395,8 +1426,7 @@ public sealed class LegacyOperatorController _manualFinancialSnapshot, string.Empty, cancellationToken).ConfigureAwait(false); - _statusMessage = $"{_manualFinancialSnapshot.Definition.Label} 목록을 불러왔습니다."; - _statusKind = LegacyOperatorStatusKind.Information; + ApplyManualFinancialListStatus(_manualFinancialSnapshot); Touch(); return CreateSnapshot(); } @@ -1427,6 +1457,7 @@ public sealed class LegacyOperatorController snapshot, query, cancellationToken).ConfigureAwait(false); + ApplyManualFinancialListStatus(_manualFinancialSnapshot); Touch(); return CreateSnapshot(); } @@ -1518,15 +1549,43 @@ public sealed class LegacyOperatorController return ReportError("수동 재무 입력 화면을 먼저 여세요."); } - _manualFinancialSnapshot = await workflow.LoadAsync( - snapshot, - resultId, - cancellationToken).ConfigureAwait(false); + // Commit the attempted row selection and clear any previous detail before + // the strict exact read. If a malformed historical row fails, an earlier + // valid record can never remain eligible for save, delete, or playout. + _manualFinancialSnapshot = workflow.BeginLoad(snapshot, resultId); + try + { + _manualFinancialSnapshot = await workflow.LoadAsync( + _manualFinancialSnapshot, + resultId, + cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is ManualFinancialDataException or + ArgumentException or InvalidOperationException or KeyNotFoundException) + { + _dialog = new LegacyDialogState(exception.Message); + _statusMessage = exception.Message; + _statusKind = LegacyOperatorStatusKind.Warning; + Touch(); + return CreateSnapshot(); + } // GraphE identities contain STOCK_NAME only. Resolve the exact stock master // immediately after the fresh detail read when it is unambiguous; otherwise // leave the candidates visible so the operator can resolve them explicitly. - var stockName = _manualFinancialSnapshot.SelectedRecord!.Identity.StockName; + var selectedIdentity = _manualFinancialSnapshot.SelectedRawRecord?.Identity ?? + _manualFinancialSnapshot.SelectedRecord?.Identity; + if (selectedIdentity is null) + { + _dialog = new LegacyDialogState( + "선택한 GraphE 행의 종목 식별정보를 읽을 수 없습니다."); + _statusMessage = _dialog.Message; + _statusKind = LegacyOperatorStatusKind.Warning; + Touch(); + return CreateSnapshot(); + } + + var stockName = selectedIdentity.StockName; _manualFinancialSnapshot = await workflow.SearchStocksAsync( _manualFinancialSnapshot, stockName, @@ -1548,6 +1607,26 @@ public sealed class LegacyOperatorController } } + + if (_manualFinancialSnapshot.WritesQuarantined) + { + _statusMessage = _manualFinancialSnapshot.LastMessage ?? + "GraphE 저장 작업은 현재 차단되어 있습니다."; + _statusKind = LegacyOperatorStatusKind.Warning; + } + else if (!_manualFinancialSnapshot.IsPlayoutReady) + { + _statusMessage = + "기존 GraphE 저장값을 원문 그대로 불러왔습니다. 편집·삭제와 스케줄 추가는 가능하며 실제 송출 전 장면 검증에서 다시 확인합니다."; + _statusKind = LegacyOperatorStatusKind.Warning; + } + else + { + _statusMessage = + $"{stockName} {_manualFinancialSnapshot.Definition.Label} 데이터를 불러왔습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + } + Touch(); return CreateSnapshot(); } @@ -1574,6 +1653,13 @@ public sealed class LegacyOperatorController throw new InvalidOperationException( "The manual-financial result is no longer available."); } + if ((!snapshot.IsRawDetailFresh && !snapshot.IsDetailFresh) || + snapshot.SelectedIdentity is null || + !string.Equals(snapshot.SelectedRowId, resultId, StringComparison.Ordinal)) + { + throw new LegacyManualFinancialWorkflowDataException( + "선택한 기존 GraphE 행의 저장값을 읽을 수 없습니다."); + } var draft = workflow.MaterializePlaylistDraft( snapshot, @@ -1626,6 +1712,13 @@ public sealed class LegacyOperatorController return ReportError("수동 재무 입력 화면을 먼저 여세요."); } + // GraphE's search button and Enter handler both returned without any + // state or focus change when the stock-name box was empty. + if (string.IsNullOrWhiteSpace(stockName)) + { + return CreateSnapshot(); + } + _manualFinancialSnapshot = await workflow.SearchStocksAsync( snapshot, stockName, @@ -1642,6 +1735,11 @@ public sealed class LegacyOperatorController return ReportError("수동 재무 입력 화면을 먼저 여세요."); } + if (string.IsNullOrWhiteSpace(resultId)) + { + return CreateSnapshot(); + } + try { _manualFinancialSnapshot = workflow.SelectStock(snapshot, resultId); @@ -1676,6 +1774,7 @@ public sealed class LegacyOperatorController request, cancellationToken).ConfigureAwait(false); ApplyManualFinancialResultStatus(_manualFinancialSnapshot, "저장"); + ShowManualFinancialSaveDialog(_manualFinancialSnapshot); Touch(); return CreateSnapshot(); } @@ -1687,10 +1786,11 @@ public sealed class LegacyOperatorController } } - public async Task DeleteManualFinancialAsync( - bool all, + public async Task SaveManualFinancialRawAsync( + MMoneyCoderSharp.Data.ManualFinancialRawRecord record, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(record); if (!TryGetManualFinancial(out var workflow, out var snapshot)) { return ReportError("수동 재무 입력 화면을 먼저 여세요."); @@ -1698,18 +1798,13 @@ public sealed class LegacyOperatorController try { - LegacyManualFinancialWriteRequest request = all - ? workflow.CreateDeleteAllRequest( - snapshot, - snapshot.Definition.DeleteAllConfirmationToken) - : workflow.CreateDeleteRequest(snapshot); + var request = workflow.CreateRawUpdateRequest(snapshot, record); _manualFinancialSnapshot = await workflow.ExecuteAsync( snapshot, request, cancellationToken).ConfigureAwait(false); - ApplyManualFinancialResultStatus( - _manualFinancialSnapshot, - all ? "전체 삭제" : "삭제"); + ApplyManualFinancialResultStatus(_manualFinancialSnapshot, "저장"); + ShowManualFinancialSaveDialog(_manualFinancialSnapshot); Touch(); return CreateSnapshot(); } @@ -1721,6 +1816,54 @@ public sealed class LegacyOperatorController } } + public Task DeleteManualFinancialAsync( + bool all, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!TryGetManualFinancial(out var workflow, out var snapshot)) + { + return Task.FromResult(ReportError("수동 재무 입력 화면을 먼저 여세요.")); + } + + if (_dialog is not null) + { + return Task.FromResult(CreateSnapshot()); + } + + // GraphE returned immediately when no row had been selected; it did not + // show either the Yes/No question or a follow-up error MessageBox. + if (!all && snapshot.SelectedRowId is null) + { + return Task.FromResult(CreateSnapshot()); + } + + try + { + // Validate the current selection/write authority before showing the + // native-compatible question, but do not cross the write boundary. + _ = all + ? workflow.CreateDeleteAllRequest( + snapshot, + snapshot.Definition.DeleteAllConfirmationToken) + : workflow.CreateDeleteRequest(snapshot); + _pendingNativeConfirmation = PendingNativeConfirmation.ForManualFinancialDelete( + all, + snapshot.Revision); + _dialog = new LegacyDialogState( + all ? "모든 종목을 삭제하겠습니까?" : "선택한 종목을 삭제하겠습니까?", + IsConfirmation: true); + Touch(); + return Task.FromResult(CreateSnapshot()); + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) + { + _dialog = new LegacyDialogState(exception.Message); + Touch(); + return Task.FromResult(CreateSnapshot()); + } + } + public LegacyOperatorSnapshot ActivateManualFinancialAction(string actionId) { ArgumentNullException.ThrowIfNull(actionId); @@ -1957,8 +2100,25 @@ public sealed class LegacyOperatorController public LegacyOperatorSnapshot DeleteAllManualViItems() { - _manualListsWorkflow?.DeleteAllViItems(); - ApplyManualListsStatus(); + if (_manualListsWorkflow is null) + { + return ReportError("VI 수동 목록 기능을 사용할 수 없습니다."); + } + + var manualLists = _manualListsWorkflow.Current; + if (!manualLists.IsOpen || manualLists.Screen != LegacyManualListScreen.Vi) + { + return ReportError("VI 수동 목록 화면을 먼저 여세요."); + } + + if (_dialog is not null) + { + return CreateSnapshot(); + } + + _pendingNativeConfirmation = PendingNativeConfirmation.ForManualViDeleteAll( + manualLists.ViItems.Select(static item => item.ItemId).ToArray()); + _dialog = new LegacyDialogState("모두 삭제하겠습니까?", IsConfirmation: true); Touch(); return CreateSnapshot(); } @@ -2129,9 +2289,9 @@ public sealed class LegacyOperatorController return ReportError("현재 탭에 속하지 않는 항목입니다."); } - AppendFixedRow(materialized); - _statusMessage = $"{materialized.Action.Label} 항목을 추가했습니다."; - _statusKind = LegacyOperatorStatusKind.Information; + var initialPage = CreateFixedInitialPageFallback(materialized.Action); + AppendFixedRow(materialized, initialPage.PageText); + ApplyFixedActionAddedStatus(materialized.Action, initialPage.PageCountUnavailable); Touch(); return CreateSnapshot(); } @@ -2162,7 +2322,46 @@ public sealed class LegacyOperatorController if (manualTarget is null) { - return ActivateFixedAction(actionId); + var fixedMarket = GetActiveFixedMarket(); + if (fixedMarket is null) + { + return ReportError("현재 탭에서는 고정 컷을 선택할 수 없습니다."); + } + + try + { + var materialized = _fixedCatalog.Materialize( + actionId, + _timeProvider.GetLocalNow()); + if (materialized.Action.Market != fixedMarket.Value) + { + return ReportError("현재 탭에 속하지 않는 항목입니다."); + } + + var initialPage = await ResolveFixedInitialPageAsync( + materialized, + cancellationToken).ConfigureAwait(false); + AppendFixedRow(materialized, initialPage.PageText); + ApplyFixedActionAddedStatus( + materialized.Action, + initialPage.PageCountUnavailable); + Touch(); + return CreateSnapshot(); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException exception) + { + _dialog = new LegacyDialogState(exception.Message); + Touch(); + return CreateSnapshot(); + } + catch (KeyNotFoundException) + { + return ReportError("등록되지 않은 고정 컷 항목입니다."); + } } var market = GetActiveFixedMarket(); @@ -2234,19 +2433,23 @@ public sealed class LegacyOperatorController // Every non-manual child is validated before the first playlist row // is appended or the first modal editor is opened. + var materialized = _fixedCatalog.Materialize( + action.Id, + localNow); + var initialPage = await ResolveFixedInitialPageAsync( + materialized, + cancellationToken).ConfigureAwait(false); items.Add(new PendingFixedSectionItem( action, - _fixedCatalog.Materialize( - action.Id, - localNow))); + materialized, + initialPage)); } var manualCount = items.Count(item => item.ManualTarget is not null); if (manualCount == 0) { CommitFixedSectionBatch(items); - _statusMessage = $"{section.Name} 항목 {items.Count}개를 추가했습니다."; - _statusKind = LegacyOperatorStatusKind.Information; + ApplyFixedSectionAddedStatus(section.Name, items); Touch(); return CreateSnapshot(); } @@ -2260,6 +2463,10 @@ public sealed class LegacyOperatorController return await OpenPendingFixedSectionManualAsync(cancellationToken) .ConfigureAwait(false); } + catch (OperationCanceledException) + { + throw; + } catch (InvalidOperationException) { _pendingFixedSectionBatch = null; @@ -2713,6 +2920,36 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } + public LegacyOperatorSnapshot RequestLegacyComparisonPairImport() + { + if (!IsComparisonActive() || _comparisonWorkflow is null) + { + return ReportError("비교 메뉴를 먼저 선택하세요."); + } + + if (!_comparisonWorkflow.CanImportLegacyPairs) + { + _dialog = new LegacyDialogState( + "원본 비교쌍 가져오기는 현재 데이터베이스 구성에서 사용할 수 없습니다."); + Touch(); + return CreateSnapshot(); + } + + if (_dialog is not null) + { + return CreateSnapshot(); + } + + _pendingNativeConfirmation = PendingNativeConfirmation.ForComparisonImport( + _comparisonWorkflow.Current.Revision); + _dialog = new LegacyDialogState( + "현재 저장된 비교쌍의 순서는 그대로 유지하고, 원본 프로그램의 비교쌍 중 없는 항목만 뒤에 추가할까요?", + "MmoneyCoder", + IsConfirmation: true); + Touch(); + return CreateSnapshot(); + } + public LegacyOperatorSnapshot ActivateComparisonAction(string actionId) { ArgumentNullException.ThrowIfNull(actionId); @@ -2844,6 +3081,12 @@ public sealed class LegacyOperatorController nxtSession, cancellationToken).ConfigureAwait(false); _themeActivationListLoaded = true; + _statusMessage = _themeSnapshot.SearchIsTruncated + ? $"테마 검색 결과가 최대 {LegacyThemeWorkflow.MaximumSearchResults:N0}건으로 제한되었습니다. 검색어를 좁혀 주세요." + : $"테마 {_themeSnapshot.SearchResults.Count:N0}건을 찾았습니다."; + _statusKind = _themeSnapshot.SearchIsTruncated + ? LegacyOperatorStatusKind.Warning + : LegacyOperatorStatusKind.Information; Touch(); return CreateSnapshot(); } @@ -3246,13 +3489,16 @@ public sealed class LegacyOperatorController public LegacyOperatorSnapshot DeleteAllPlaylistRows() { - if (_playlist.Count == 0) + if (_dialog is not null) { return CreateSnapshot(); } - _playlist.Clear(); - ResetPlaylistSelection(); + _pendingNativeConfirmation = PendingNativeConfirmation.ForPlaylistDeleteAll( + _playlist.Select(static row => row.RowId).ToArray()); + _dialog = new LegacyDialogState( + "송출 리스트를 모두 삭제하겠습니까?", + IsConfirmation: true); Touch(); return CreateSnapshot(); } @@ -3423,37 +3669,31 @@ public sealed class LegacyOperatorController var namedPlaylist = await _namedPlaylistWorkflow.LoadSelectedAsync( cancellationToken).ConfigureAwait(false); var loadedRows = namedPlaylist.Rows - .Select(row => - { - try - { - return CreateNamedPlaylistOperatorRow(row); - } - catch (InvalidDataException exception) - { - exception.Data["NamedPlaylistItemIndex"] = row.ItemIndex; - throw; - } - }) + .Select(CreateNamedPlaylistOperatorRow) .ToArray(); _playlist.Clear(); _playlist.AddRange(loadedRows); ResetPlaylistSelection(); SelectInitialPlaylistCandidate(); + _dialog = null; ApplyNamedPlaylistStatus(namedPlaylist); + var unresolvedCount = loadedRows.Count(row => !row.IsSceneResolved); + if (unresolvedCount > 0) + { + _statusMessage = + $"DB 재생목록을 불러왔습니다. 장면 이름을 찾지 못한 {unresolvedCount}개 행은 해당 행을 송출할 때만 차단됩니다."; + _statusKind = LegacyOperatorStatusKind.Warning; + } } catch (OperationCanceledException) { throw; } - catch (InvalidDataException exception) + catch (InvalidDataException) { - var rowNumber = exception.Data["NamedPlaylistItemIndex"] is int itemIndex - ? $" {itemIndex + 1}행에" - : string.Empty; _dialog = new LegacyDialogState( - $"DB 재생목록{rowNumber} 현재 버전에서 안전하게 식별할 수 없는 컷이 있어 기존 송출 목록을 유지했습니다."); + "DB 재생목록의 저장 형식을 읽을 수 없어 기존 송출 목록을 유지했습니다."); _statusMessage = _dialog.Message; _statusKind = LegacyOperatorStatusKind.Error; } @@ -3502,7 +3742,9 @@ public sealed class LegacyOperatorController StringComparison.Ordinal) && loaded.NamedPlaylist?.ReadStatus == LegacyNamedPlaylistReadStatus.Ready && loaded.NamedPlaylist?.DocumentFreshness == LegacyNamedPlaylistFreshness.Fresh && - loaded.StatusKind == LegacyOperatorStatusKind.Information; + (loaded.StatusKind is LegacyOperatorStatusKind.Information or + LegacyOperatorStatusKind.Warning) && + loaded.Dialog is null; CompleteCommand(command, definitionId, succeeded); Touch(); return CreateSnapshot(); @@ -3558,16 +3800,12 @@ public sealed class LegacyOperatorController throw new InvalidOperationException("The playlist is too large to save."); } - // DC_LIST/PLAY_LIST compatibility is exactly the original seven visible/hidden - // fields. Do not persist the newer internal scene selection or scene alias. + // DC_LIST/PLAY_LIST compatibility remains the original seven fields. + // Closed native theme rows project their exact DB title into the legacy + // subject field because ExactSubject is intentionally not an eighth field. var rows = _playlist.Select(row => { - var persistedSelection = new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection( - row.MarketText, - row.StockName, - row.GraphicType, - row.Subtype, - row.StockCode); + var persistedSelection = CreateNamedPlaylistStoredSelection(row); var entry = new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacyPlaylistEntry( row.RowId, row.SceneAlias, @@ -3603,6 +3841,7 @@ public sealed class LegacyOperatorController public async Task SaveCurrentNamedPlaylistToAsync( string definitionId, + bool showSavedAlert = false, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(definitionId); @@ -3630,6 +3869,13 @@ public sealed class LegacyOperatorController saved.NamedPlaylist?.LastMutationOutcome is LegacyNamedPlaylistMutationOutcome.CommittedFresh or LegacyNamedPlaylistMutationOutcome.CommittedOptimistic; + if (succeeded && showSavedAlert) + { + // PList displayed this one-button MessageBox after the confirmed save. + // Keep it in authoritative state so WebView focus/modal behavior is the + // same as every other MmoneyCoder dialog. + _dialog = new LegacyDialogState("저장되었습니다"); + } CompleteCommand(command, definitionId, succeeded); Touch(); return CreateSnapshot(); @@ -3696,6 +3942,25 @@ public sealed class LegacyOperatorController pending.ComparisonRevision, cancellationToken) .ConfigureAwait(false), + PendingNativeConfirmationKind.ImportLegacyComparisonPairs => + await ExecuteConfirmedComparisonImportAsync( + pending.ComparisonRevision, + cancellationToken) + .ConfigureAwait(false), + PendingNativeConfirmationKind.DeleteAllPlaylistRows => + ExecuteConfirmedPlaylistDeleteAll(pending.PlaylistRowIds!), + PendingNativeConfirmationKind.DeleteManualFinancial => + await ExecuteConfirmedManualFinancialDeleteAsync( + all: false, + pending.ManualFinancialRevision, + cancellationToken).ConfigureAwait(false), + PendingNativeConfirmationKind.DeleteAllManualFinancial => + await ExecuteConfirmedManualFinancialDeleteAsync( + all: true, + pending.ManualFinancialRevision, + cancellationToken).ConfigureAwait(false), + PendingNativeConfirmationKind.DeleteAllManualViItems => + ExecuteConfirmedManualViDeleteAll(pending.ManualViItemIds!), _ => throw new InvalidOperationException("Unknown native confirmation kind.") }; } @@ -3729,6 +3994,82 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } + private LegacyOperatorSnapshot ExecuteConfirmedPlaylistDeleteAll( + IReadOnlyList expectedRowIds) + { + var currentRowIds = _playlist.Select(static row => row.RowId).ToArray(); + if (!currentRowIds.SequenceEqual(expectedRowIds, StringComparer.Ordinal)) + { + return ReportError( + "송출 리스트가 변경되어 전체 삭제를 시작하지 않았습니다."); + } + + _playlist.Clear(); + ResetPlaylistSelection(); + Touch(); + return CreateSnapshot(); + } + + private async Task ExecuteConfirmedManualFinancialDeleteAsync( + bool all, + long expectedRevision, + CancellationToken cancellationToken) + { + if (!TryGetManualFinancial(out var workflow, out var snapshot) || + snapshot.Revision != expectedRevision) + { + return ReportError( + "수동 재무 삭제 대상이 변경되어 DB 삭제를 시작하지 않았습니다."); + } + + try + { + LegacyManualFinancialWriteRequest request = all + ? workflow.CreateDeleteAllRequest( + snapshot, + snapshot.Definition.DeleteAllConfirmationToken) + : workflow.CreateDeleteRequest(snapshot); + _manualFinancialSnapshot = await workflow.ExecuteAsync( + snapshot, + request, + cancellationToken).ConfigureAwait(false); + ApplyManualFinancialResultStatus( + _manualFinancialSnapshot, + all ? "전체 삭제" : "삭제"); + Touch(); + return CreateSnapshot(); + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) + { + _dialog = new LegacyDialogState(exception.Message); + Touch(); + return CreateSnapshot(); + } + } + + private LegacyOperatorSnapshot ExecuteConfirmedManualViDeleteAll( + IReadOnlyList expectedItemIds) + { + if (_manualListsWorkflow is null) + { + return ReportError("VI 수동 목록 기능을 사용할 수 없습니다."); + } + + var current = _manualListsWorkflow.Current; + var currentItemIds = current.ViItems.Select(static item => item.ItemId).ToArray(); + if (!current.IsOpen || current.Screen != LegacyManualListScreen.Vi || + !currentItemIds.SequenceEqual(expectedItemIds, StringComparer.Ordinal)) + { + return ReportError( + "VI 목록이 변경되어 전체 삭제를 시작하지 않았습니다."); + } + + _manualListsWorkflow.DeleteAllViItems(); + ApplyManualListsStatus(); + Touch(); + return CreateSnapshot(); + } + private async Task ExecuteConfirmedThemeDeleteAsync( MMoneyCoderSharp.Data.ThemeSelectionIdentity identity, CancellationToken cancellationToken) @@ -3844,6 +4185,51 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } + private async Task ExecuteConfirmedComparisonImportAsync( + long comparisonRevision, + CancellationToken cancellationToken) + { + if (!IsComparisonActive() || _comparisonWorkflow is null || + _comparisonWorkflow.Current.Revision != comparisonRevision) + { + return ReportError( + "비교쌍 목록이 바뀌어 원본 비교쌍 가져오기를 시작하지 않았습니다."); + } + + try + { + await _comparisonWorkflow.ImportLegacyPairsAsync(cancellationToken) + .ConfigureAwait(false); + Touch(); + return CreateSnapshot(); + } + catch (LegacyComparisonImportException exception) + { + _dialog = new LegacyDialogState(exception.Failure switch + { + LegacyComparisonImportFailure.SourceUnavailable => + "원본 프로그램의 고정된 Data\\종목비교.dat 파일을 읽을 수 없습니다.", + LegacyComparisonImportFailure.SourceInvalid => + "원본 종목비교.dat 파일 형식이 올바르지 않아 가져오지 않았습니다.", + LegacyComparisonImportFailure.IdentityNotFound => + "원본 비교쌍 중 현재 종목 DB에서 정확히 찾을 수 없는 항목이 있어 아무것도 가져오지 않았습니다.", + LegacyComparisonImportFailure.IdentityAmbiguous => + "원본 비교쌍 중 현재 종목 DB에서 하나로 확정할 수 없는 항목이 있어 아무것도 가져오지 않았습니다.", + LegacyComparisonImportFailure.DatabaseDataInvalid => + "현재 종목 DB 조회 결과가 안전한 식별 형식과 일치하지 않아 아무것도 가져오지 않았습니다.", + _ => "원본 비교쌍을 안전하게 가져오지 못했습니다." + }); + Touch(); + return CreateSnapshot(); + } + catch (InvalidOperationException exception) + { + _dialog = new LegacyDialogState(exception.Message); + Touch(); + return CreateSnapshot(); + } + } + public LegacyOperatorSnapshot ReportError(string message) { _statusMessage = message ?? string.Empty; @@ -4199,11 +4585,47 @@ public sealed class LegacyOperatorController return new MMoneyCoderSharp.Data.NamedPlaylistPageState(currentPage, totalPages); } + private static CoreSceneSelection CreateNamedPlaylistStoredSelection( + LegacyOperatorPlaylistRow row) + { + var native = row.PlayoutSelection; + if (native?.ExactSubject is { } exactTitle && + native.GroupCode is "PAGED_THEME" or "PAGED_NXT_THEME") + { + var isNxt = native.GroupCode == "PAGED_NXT_THEME"; + var expectedMarket = isNxt ? "테마_NXT" : "테마"; + if (!string.Equals(row.MarketText, expectedMarket, StringComparison.Ordinal) || + !string.Equals(native.DataCode, row.StockCode, StringComparison.Ordinal) || + native.DataCode.Length is < 3 or > 8 || + native.DataCode.Any(character => !char.IsAsciiDigit(character)) || + !IsSafeStoredThemeTitle(exactTitle, 128)) + { + throw new InvalidOperationException( + "The native theme row has an invalid exact stored identity."); + } + + return new CoreSceneSelection( + row.MarketText, + isNxt ? exactTitle + "(NXT)" : exactTitle, + row.GraphicType, + row.Subtype, + row.StockCode); + } + + return new CoreSceneSelection( + row.MarketText, + row.StockName, + row.GraphicType, + row.Subtype, + row.StockCode); + } + private LegacyOperatorPlaylistRow CreateNamedPlaylistOperatorRow( LegacyNamedPlaylistRowView row) { var selection = row.Selection; - var sceneAlias = ResolveNamedPlaylistSceneAlias(selection); + var sceneAlias = ResolveNamedPlaylistSceneAlias(selection) ?? + MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacyPlaylistEntry.UnresolvedCutCode; var playoutSelection = TryCreateNamedFixedSelection( selection, out var fixedSelection, @@ -4211,10 +4633,22 @@ public sealed class LegacyOperatorController ? fixedSelection : TryCreateNamedStockDetailSelection(selection, out var stockDetailSelection) ? stockDetailSelection + : TryCreateNamedLegacyIndustryFixedSelection( + selection, + out var legacyIndustryFixedSelection) + ? legacyIndustryFixedSelection : TryCreateNamedIndustrySelection(selection, out var industrySelection) ? industrySelection + : TryCreateNamedLegacyComparisonSelection( + selection, + out var legacyComparisonSelection) + ? legacyComparisonSelection : TryCreateNamedComparisonSelection(selection, out var comparisonSelection) ? comparisonSelection + : TryCreateNamedThemeSelection(selection, out var themeSelection) + ? themeSelection + : TryCreateNamedExpertSelection(selection, out var expertSelection) + ? expertSelection : TryCreateNamedManualFinancialSelection( selection, out var manualFinancialSelection) @@ -4249,13 +4683,20 @@ public sealed class LegacyOperatorController PlayoutSelection: playoutSelection)); } - private string ResolveNamedPlaylistSceneAlias( + private string? ResolveNamedPlaylistSceneAlias( LegacyNamedPlaylistSelectionView selection) { var rawLabel = selection.Subtype.Length == 0 ? selection.GraphicType : $"{selection.GraphicType}_{selection.Subtype}"; + // An original fixed-index row can use a stock-market value such as 코스피. + // Resolve the exact fixed signature before the broader stock-cut label map. + if (TryCreateNamedFixedSelection(selection, out _, out var fixedAction)) + { + return fixedAction!.Code; + } + // Stock and trading-halt rows retain the original stock-market and cut-label // columns, so the closed stock catalog remains the authority for their alias. if (selection.GroupCode is "코스피" or "코스닥" or @@ -4275,11 +4716,6 @@ public sealed class LegacyOperatorController } } - if (TryCreateNamedFixedSelection(selection, out _, out var fixedAction)) - { - return fixedAction!.Code; - } - var industryMarket = selection.GroupCode switch { "업종_코스피" => MMoneyCoderSharp.Data.IndustryMarket.Kospi, @@ -4319,7 +4755,7 @@ public sealed class LegacyOperatorController } } - if (string.Equals(selection.GroupCode, "전문가", StringComparison.Ordinal) && + if (selection.GroupCode is "전문가" or "전문가 추천" && string.Equals(selection.GraphicType, "5단 표그래프", StringComparison.Ordinal) && string.Equals(selection.Subtype, "현재가", StringComparison.Ordinal)) { @@ -4372,8 +4808,241 @@ public sealed class LegacyOperatorController return manualFinancial[0].Code; } - throw new InvalidDataException( - "The named-playlist row does not map to a registered scene alias."); + return LegacyCutInfoSceneAliasResolver.TryResolve( + selection.GroupCode, + selection.Subject, + selection.GraphicType, + selection.Subtype, + out var legacyAlias) + ? legacyAlias + : null; + } + + private bool TryCreateNamedLegacyIndustryFixedSelection( + LegacyNamedPlaylistSelectionView selection, + out CoreSceneSelection normalized) + { + normalized = null!; + var market = selection.GroupCode switch + { + "업종_코스피" when selection.Subject == "코스피" => + MMoneyCoderSharp.Data.IndustryMarket.Kospi, + "업종_코스닥" when selection.Subject == "코스닥" => + MMoneyCoderSharp.Data.IndustryMarket.Kosdaq, + _ => (MMoneyCoderSharp.Data.IndustryMarket?)null + }; + if (market is not { } exactMarket || selection.Subtype.Length != 0 || + selection.DataCode.Length != 0) + { + return false; + } + + var marketGroup = exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi + ? "INDUSTRY_KOSPI" + : "INDUSTRY_KOSDAQ"; + var marketName = exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi + ? "KOSPI" + : "KOSDAQ"; + normalized = selection.GraphicType switch + { + "5단 표그래프" => new CoreSceneSelection( + exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi + ? "PAGED_DOMESTIC_KOSPI" + : "PAGED_DOMESTIC_KOSDAQ", + "INDEX", + string.Empty, + "INDUSTRY", + _timeProvider.GetLocalNow().ToString( + "yyyy-MM-dd", + System.Globalization.CultureInfo.InvariantCulture)), + "네모그래프" => new CoreSceneSelection( + marketGroup, + marketName, + "네모그래프", + string.Empty, + string.Empty), + "섹터지수" => new CoreSceneSelection( + marketGroup, + marketName, + "SECTOR_INDEX", + string.Empty, + string.Empty), + _ => null! + }; + return normalized is not null; + } + + private static bool TryCreateNamedLegacyComparisonSelection( + LegacyNamedPlaylistSelectionView selection, + out CoreSceneSelection normalized) + { + normalized = null!; + if (!string.Equals(selection.GroupCode, "지수", StringComparison.Ordinal) || + !string.Equals(selection.GraphicType, "2열판", StringComparison.Ordinal) || + selection.DataCode.Length != 0) + { + return false; + } + + var actionId = selection.Subtype switch + { + "" => "two-column-current", + "예상체결" => "two-column-expected", + _ => null + }; + var names = selection.Subject.Split(',', StringSplitOptions.None); + if (actionId is null || names.Length != 2) + { + return false; + } + + var first = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget( + new LegacyComparisonStoredTargetReference(names[0], string.Empty)); + var second = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget( + new LegacyComparisonStoredTargetReference(names[1], string.Empty)); + if (first is null || second is null) + { + return false; + } + + try + { + normalized = LegacyComparisonWorkflowCatalog.Materialize( + actionId, + first, + second, + "legacy-named-restore").Selection; + return true; + } + catch (InvalidOperationException) + { + return false; + } + } + + private bool TryCreateNamedThemeSelection( + LegacyNamedPlaylistSelectionView selection, + out CoreSceneSelection normalized) + { + normalized = null!; + var isNxt = string.Equals(selection.GroupCode, "테마_NXT", StringComparison.Ordinal); + if (!isNxt && !string.Equals(selection.GroupCode, "테마", StringComparison.Ordinal) || + selection.DataCode.Length is < 3 or > 8 || + selection.DataCode.Any(character => !char.IsAsciiDigit(character))) + { + return false; + } + + var subtype = selection.Subtype switch + { + "테마-현재가(입력순)" => (Value: "CURRENT", Sort: "INPUT_ORDER"), + "테마-현재가(상승률순)" => (Value: "CURRENT", Sort: "GAIN_DESC"), + "테마-현재가(하락률순)" => (Value: "CURRENT", Sort: "GAIN_ASC"), + "테마-현재가" => (Value: "CURRENT", Sort: "GAIN_ASC"), + "테마-예상체결가(입력순)" => (Value: "EXPECTED", Sort: "INPUT_ORDER"), + "테마-예상체결가(상승률순)" => (Value: "EXPECTED", Sort: "GAIN_DESC"), + "테마-예상체결가(하락률순)" => (Value: "EXPECTED", Sort: "GAIN_ASC"), + "테마-예상체결가" => (Value: "EXPECTED", Sort: "GAIN_ASC"), + _ => default + }; + if (subtype.Value is null || isNxt && subtype.Value != "CURRENT" || + selection.GraphicType switch + { + "5단 표그래프" => false, + "6종목 현재가" or "12종목 현재가" => subtype.Value != "CURRENT", + _ => true + }) + { + return false; + } + + if (isNxt) + { + const string suffix = "(NXT)"; + if (!selection.Subject.EndsWith(suffix, StringComparison.Ordinal) || + selection.Subject.Length == suffix.Length) + { + return false; + } + + var exactTitle = selection.Subject[..^suffix.Length]; + if (!IsSafeStoredThemeTitle(exactTitle, 128)) + { + return false; + } + + normalized = new CoreSceneSelection( + "PAGED_NXT_THEME", + selection.Subject, + _timeProvider.GetLocalNow().Hour <= 12 ? "PRE_MARKET" : "AFTER_MARKET", + subtype.Sort, + selection.DataCode, + exactTitle); + return true; + } + + if (!IsSafeStoredThemeTitle(selection.Subject, 128)) + { + return false; + } + + normalized = new CoreSceneSelection( + "PAGED_THEME", + selection.Subject, + subtype.Sort, + subtype.Value, + selection.DataCode, + selection.Subject); + return true; + } + + private static bool IsSafeStoredThemeTitle(string value, int maximumLength) => + value.Length is >= 1 && + value.Length <= maximumLength && + !string.IsNullOrWhiteSpace(value) && + !value.Contains('^') && + !value.Any(character => + char.IsControl(character) || + char.IsSurrogate(character) || + character == '\uFFFD' || + System.Globalization.CharUnicodeInfo.GetUnicodeCategory(character) is + System.Globalization.UnicodeCategory.Format or + System.Globalization.UnicodeCategory.LineSeparator or + System.Globalization.UnicodeCategory.ParagraphSeparator); + + private static bool TryCreateNamedExpertSelection( + LegacyNamedPlaylistSelectionView selection, + out CoreSceneSelection normalized) + { + normalized = null!; + if (selection.GroupCode is not ("전문가" or "전문가 추천") || + !string.Equals(selection.GraphicType, "5단 표그래프", StringComparison.Ordinal) || + !string.Equals(selection.Subtype, "현재가", StringComparison.Ordinal) || + selection.DataCode.Length != 4 || + selection.DataCode.Any(character => !char.IsAsciiDigit(character))) + { + return false; + } + + try + { + LegacyComparisonValueGuard.RequireText( + selection.Subject, + 128, + nameof(selection.Subject)); + } + catch (ArgumentException) + { + return false; + } + + normalized = new CoreSceneSelection( + "PAGED_EXPERT", + selection.Subject, + "INPUT_ORDER", + "CURRENT", + selection.DataCode); + return true; } private bool TryCreateNamedFixedSelection( @@ -4388,6 +5057,9 @@ public sealed class LegacyOperatorController "해외지수" => LegacyFixedMarket.Overseas, "환율" => LegacyFixedMarket.Exchange, "주요지수" => LegacyFixedMarket.Index, + _ when selection.DataCode.Length == 0 && + string.Equals(selection.Subject, "지수", StringComparison.Ordinal) => + LegacyFixedMarket.Index, _ => (LegacyFixedMarket?)null }; if (fixedMarket is not { } market) @@ -4397,9 +5069,8 @@ public sealed class LegacyOperatorController var matches = _fixedCatalog.GetActions(market) .Where(candidate => - string.Equals(candidate.Label, selection.Subject, StringComparison.Ordinal) && - string.Equals(candidate.Section, selection.GraphicType, StringComparison.Ordinal) && - string.Equals(candidate.Detail, selection.Subtype, StringComparison.Ordinal)) + MatchesCurrentNamedFixedSelection(candidate, selection) || + MatchesLegacyNamedFixedSelection(candidate, selection)) .Take(2) .ToArray(); if (matches.Length != 1 || !matches[0].Available) @@ -4408,16 +5079,92 @@ public sealed class LegacyOperatorController } action = matches[0]; - var materialized = _fixedCatalog.Materialize(action.Id, DateTimeOffset.Now).Selection; + var isCurrentShape = MatchesCurrentNamedFixedSelection(action, selection); + var isLegacyShape = MatchesLegacyNamedFixedSelection(action, selection); + var materialized = _fixedCatalog.Materialize( + action.Id, + _timeProvider.GetLocalNow()).Selection; normalized = new CoreSceneSelection( materialized.GroupCode, materialized.Subject, materialized.GraphicType, materialized.Subtype, - selection.DataCode); + isLegacyShape && !isCurrentShape + ? materialized.DataCode + : selection.DataCode); return true; } + private static bool MatchesCurrentNamedFixedSelection( + LegacyFixedAction action, + LegacyNamedPlaylistSelectionView selection) => + string.Equals(action.Label, selection.Subject, StringComparison.Ordinal) && + string.Equals(action.Section, selection.GraphicType, StringComparison.Ordinal) && + string.Equals(action.Detail, selection.Subtype, StringComparison.Ordinal); + + private static bool MatchesLegacyNamedFixedSelection( + LegacyFixedAction action, + LegacyNamedPlaylistSelectionView selection) + { + var visible = ProjectLegacyFixedVisibleSelection(action); + return string.Equals(visible.GroupCode, selection.GroupCode, StringComparison.Ordinal) && + string.Equals(visible.Subject, selection.Subject, StringComparison.Ordinal) && + string.Equals(visible.GraphicType, selection.GraphicType, StringComparison.Ordinal) && + string.Equals(visible.Subtype, selection.Subtype, StringComparison.Ordinal) && + string.Equals(visible.DataCode, selection.DataCode, StringComparison.Ordinal); + } + + private static LegacyFixedSelection ProjectLegacyFixedVisibleSelection( + LegacyFixedAction action) + { + var split = action.Label.IndexOf('_'); + var first = split < 0 ? action.Label : action.Label[..split]; + var remainder = split < 0 ? string.Empty : action.Label[(split + 1)..]; + if (action.Market is LegacyFixedMarket.Overseas or LegacyFixedMarket.Exchange) + { + return new LegacyFixedSelection( + action.Market == LegacyFixedMarket.Overseas ? "해외지수" : "환율", + first, + action.Section, + remainder, + string.Empty); + } + + if (action.Section is "5단 표그래프" or "6종목 현재가" or "12종목 현재가") + { + return new LegacyFixedSelection( + LegacyFixedIndexGroup(action.Label), + "지수", + action.Section, + action.Label, + string.Empty); + } + + if (string.Equals(action.Section, "매매동향", StringComparison.Ordinal)) + { + return new LegacyFixedSelection( + LegacyFixedIndexGroup(action.Label), + "지수", + first, + remainder, + string.Empty); + } + + return new LegacyFixedSelection( + action.Section, + "지수", + first, + remainder, + string.Empty); + } + + private static string LegacyFixedIndexGroup(string label) => + label.Contains("코스피_NXT", StringComparison.Ordinal) ? "코스피_NXT" : + label.Contains("코스닥_NXT", StringComparison.Ordinal) ? "코스닥_NXT" : + label.Contains("코스피", StringComparison.Ordinal) ? "코스피" : + label.Contains("코스닥", StringComparison.Ordinal) ? "코스닥" : + "전체"; + private static bool TryCreateNamedStockDetailSelection( LegacyNamedPlaylistSelectionView selection, out CoreSceneSelection normalized) @@ -5025,6 +5772,36 @@ public sealed class LegacyOperatorController return workflow is not null && snapshot is not null; } + private void ApplyManualFinancialListStatus( + LegacyManualFinancialWorkflowSnapshot snapshot) + { + var warnings = new List(); + if (snapshot.ListRejectedItemCount > 0) + { + var displayedLegacyRawCount = snapshot.Rows.Count(row => + !row.IsPlayoutReady); + var hiddenUnreadableCount = + snapshot.ListRejectedItemCount - displayedLegacyRawCount; + warnings.Add(hiddenUnreadableCount == 0 + ? $"기존 원문 형식 데이터 {displayedLegacyRawCount}행도 " + + "목록에 표시되며 선택·편집할 수 있습니다." + : $"기존 원문 형식 데이터 {displayedLegacyRawCount}행은 표시했고, " + + $"이름을 읽을 수 없는 {hiddenUnreadableCount}행은 제외했습니다."); + } + if (snapshot.ListIsTruncated) + { + warnings.Add( + $"검색 결과가 {LegacyManualFinancialWorkflow.MaximumListResults}행을 " + + "초과해 일부만 표시됩니다."); + } + + _statusMessage = $"{snapshot.Definition.Label} 목록을 불러왔습니다." + + (warnings.Count == 0 ? string.Empty : " " + string.Join(' ', warnings)); + _statusKind = warnings.Count == 0 + ? LegacyOperatorStatusKind.Information + : LegacyOperatorStatusKind.Warning; + } + private void ApplyManualFinancialResultStatus( LegacyManualFinancialWorkflowSnapshot snapshot, string operation) @@ -5054,6 +5831,26 @@ public sealed class LegacyOperatorController }; } + private void ShowManualFinancialSaveDialog( + LegacyManualFinancialWorkflowSnapshot snapshot) + { + // GraphE used a one-button MmoneyCoder MessageBox after every explicit + // save attempt. Only a correlated write plus fresh read-back receives + // the legacy success wording. Known rollback/read-back/unknown outcomes + // retain their already-sanitized, specific status instead of being + // mislabeled as a generic database outage. + _dialog = snapshot.Status switch + { + LegacyManualFinancialWorkflowStatus.WriteCommitted => + new LegacyDialogState("저장되었습니다"), + LegacyManualFinancialWorkflowStatus.WriteRejected or + LegacyManualFinancialWorkflowStatus.ReadBackRequired or + LegacyManualFinancialWorkflowStatus.OutcomeUnknown => + new LegacyDialogState(_statusMessage), + _ => null + }; + } + private void ApplyManualListsStatus() { if (_manualListsWorkflow is null) @@ -5120,9 +5917,7 @@ public sealed class LegacyOperatorController } _pendingFixedSectionBatch = null; - _statusMessage = - $"{pending.SectionName} 항목 {pending.Items.Count}개를 원본 순서로 추가했습니다."; - _statusKind = LegacyOperatorStatusKind.Information; + ApplyFixedSectionAddedStatus(pending.SectionName, pending.Items); Touch(); return CreateSnapshot(); } @@ -5141,7 +5936,10 @@ public sealed class LegacyOperatorController { if (item.Materialized is not null) { - AppendFixedRow(item.Materialized); + AppendFixedRow( + item.Materialized, + item.InitialPage?.PageText ?? throw new InvalidOperationException( + "A materialized fixed-section item has no initial page state.")); } else if (item.ManualDraft is not null) { @@ -5188,6 +5986,125 @@ public sealed class LegacyOperatorController _ => null }; + private async Task ResolveFixedInitialPageAsync( + LegacyMaterializedFixedAction materialized, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var pageSize = GetFixedPageSize(materialized.Action); + if (pageSize is null) + { + return FixedInitialPage.SinglePage; + } + + if (_fixedPagePlanProvider is null) + { + return FixedInitialPage.Unavailable; + } + + var selection = materialized.Selection; + var entry = new CorePlaylistEntry( + materialized.Action.Id, + materialized.Action.Code, + IsEnabled: true, + FadeDuration: 6, + Selection: new CoreSceneSelection( + selection.GroupCode, + selection.Subject, + selection.GraphicType, + selection.Subtype, + selection.DataCode)); + try + { + var pagePlan = await _fixedPagePlanProvider.CreatePagePlanAsync( + entry, + cancellationToken).ConfigureAwait(false); + ArgumentNullException.ThrowIfNull(pagePlan); + var expected = CoreScenePaging.CreatePlan(pagePlan.ItemCount, pageSize.Value); + if (!expected.IsValid || + expected.Plan is null || + !string.Equals( + pagePlan.BuilderKey, + materialized.Action.BuilderKey, + StringComparison.Ordinal) || + pagePlan.PageSize != pageSize.Value || + pagePlan.PageCount != expected.Plan.TotalPages || + pagePlan.AccessibleItemCount != expected.Plan.AccessibleItemCount || + pagePlan.IsTruncated != (expected.Plan.ExcludedItemCount > 0)) + { + throw new InvalidOperationException( + "The fixed-action page preflight returned an invalid paging plan."); + } + + return new FixedInitialPage($"1/{pagePlan.PageCount}", false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + // PageN/Nxt_PageN in UC1 caught data-read failures and still appended + // the row as 1/0. Keep that exact failure representation; never guess N. + return FixedInitialPage.Unavailable; + } + } + + private static FixedInitialPage CreateFixedInitialPageFallback( + LegacyFixedAction action) => GetFixedPageSize(action) is null + ? FixedInitialPage.SinglePage + : FixedInitialPage.Unavailable; + + private static CoreScenePageSize? GetFixedPageSize(LegacyFixedAction action) + { + ArgumentNullException.ThrowIfNull(action); + var exact = (action.Code, action.BuilderKey) switch + { + ("5074", "s5074") => CoreScenePageSize.Five, + ("5077", "s5077") => CoreScenePageSize.Six, + ("5088", "s5088") => CoreScenePageSize.Twelve, + _ => (CoreScenePageSize?)null + }; + if (exact is not null) + { + return exact; + } + + if (action.Code is "5074" or "5077" or "5088" || + action.BuilderKey is "s5074" or "s5077" or "s5088") + { + throw new InvalidOperationException( + "The fixed-action paging alias and builder do not match."); + } + + return null; + } + + private void ApplyFixedActionAddedStatus( + LegacyFixedAction action, + bool pageCountUnavailable) + { + _statusMessage = pageCountUnavailable + ? $"{action.Label} 항목을 추가했지만 현재 데이터 건수를 읽지 못해 페이지를 1/0으로 표시합니다." + : $"{action.Label} 항목을 추가했습니다."; + _statusKind = pageCountUnavailable + ? LegacyOperatorStatusKind.Warning + : LegacyOperatorStatusKind.Information; + } + + private void ApplyFixedSectionAddedStatus( + string sectionName, + IReadOnlyList items) + { + var unavailableCount = items.Count(item => item.PageCountUnavailable); + _statusMessage = unavailableCount == 0 + ? $"{sectionName} 항목 {items.Count}개를 원본 순서로 추가했습니다." + : $"{sectionName} 항목 {items.Count}개를 원본 순서로 추가했습니다. 데이터 건수를 읽지 못한 {unavailableCount}개 항목은 페이지를 1/0으로 표시합니다."; + _statusKind = unavailableCount == 0 + ? LegacyOperatorStatusKind.Information + : LegacyOperatorStatusKind.Warning; + } + private void ApplyOperatorCatalogStatus() { if (_operatorCatalogWorkflow is null) @@ -5223,26 +6140,23 @@ public sealed class LegacyOperatorController PlayoutSelection: draft.Selection)); } - private void AppendFixedRow(LegacyMaterializedFixedAction materialized) + private void AppendFixedRow( + LegacyMaterializedFixedAction materialized, + string pageText) { + ArgumentException.ThrowIfNullOrWhiteSpace(pageText); var action = materialized.Action; var selection = materialized.Selection; - var marketText = action.Market switch - { - LegacyFixedMarket.Overseas => "해외지수", - LegacyFixedMarket.Exchange => "환율", - LegacyFixedMarket.Index => "주요지수", - _ => throw new ArgumentOutOfRangeException() - }; + var visible = ProjectLegacyFixedVisibleSelection(action); AppendPlaylistRow(new LegacyOperatorPlaylistRow( $"legacy-fixed-{++_playlistSequence:D8}", true, - marketText, - action.Label, - action.Section, - action.Detail, - "1/1", - selection.DataCode, + visible.GroupCode, + visible.Subject, + visible.GraphicType, + visible.Subtype, + pageText, + visible.DataCode, action.Label, 6, SceneAlias: action.Code, @@ -5331,7 +6245,8 @@ public sealed class LegacyOperatorController draft.Selection.Subject, draft.Selection.GraphicType, draft.Selection.Subtype, - draft.Selection.DataCode))); + draft.Selection.DataCode, + draft.Selection.ExactSubject))); } private void AppendComparisonRow(LegacyGenericPlaylistDraft draft) @@ -5412,9 +6327,9 @@ public sealed class LegacyOperatorController LegacyOperatorTabId.KosdaqIndustry => "코스닥", LegacyOperatorTabId.Comparison => "비교", LegacyOperatorTabId.Theme => "테마", - LegacyOperatorTabId.OverseasStocks => "해외종목", + LegacyOperatorTabId.OverseasStocks => "해외시장", LegacyOperatorTabId.Expert => "전문가", - LegacyOperatorTabId.TradingHalt => "정지", + LegacyOperatorTabId.TradingHalt => "거래정지", _ => throw new ArgumentOutOfRangeException(nameof(tabId), tabId, null) }; @@ -5530,9 +6445,13 @@ public sealed class LegacyOperatorController private static LegacyOperatorPlaylistRow NormalizeMovingAverageIdentity( LegacyOperatorPlaylistRow row) { - var entry = row.ToPlayoutEntry(); + var cutCode = row.SceneAlias.Length > 0 + ? row.SceneAlias + : LegacyStockCutCatalog.ResolveCutAlias( + row.RawCutLabel, + row.StockName.Contains("(NXT)", StringComparison.Ordinal)); if (!MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageOverlay - .Supports(entry.CutCode)) + .Supports(cutCode)) { return row; } @@ -5679,14 +6598,25 @@ public sealed class LegacyOperatorController LegacyManualListScreen Screen, S5025ManualAudience Audience); + private readonly record struct FixedInitialPage( + string PageText, + bool PageCountUnavailable) + { + internal static FixedInitialPage SinglePage { get; } = new("1/1", false); + + internal static FixedInitialPage Unavailable { get; } = new("1/0", true); + } + private sealed class PendingFixedSectionItem { internal PendingFixedSectionItem( LegacyFixedAction action, - LegacyMaterializedFixedAction materialized) + LegacyMaterializedFixedAction materialized, + FixedInitialPage initialPage) { Action = action; Materialized = materialized; + InitialPage = initialPage; } internal PendingFixedSectionItem( @@ -5701,10 +6631,15 @@ public sealed class LegacyOperatorController internal LegacyMaterializedFixedAction? Materialized { get; } + internal FixedInitialPage? InitialPage { get; } + internal FixedManualTarget? ManualTarget { get; } internal LegacyManualListPlaylistDraft? ManualDraft { get; private set; } + internal bool PageCountUnavailable => + InitialPage?.PageCountUnavailable == true; + internal bool IsStaged => Materialized is not null || ManualDraft is not null; internal void StageManual(LegacyManualListPlaylistDraft draft) @@ -5792,49 +6727,85 @@ public sealed class LegacyOperatorController DeleteTheme, DeleteExpert, ClearThemeDraft, - DeleteAllComparisonPairs + DeleteAllComparisonPairs, + ImportLegacyComparisonPairs, + DeleteAllPlaylistRows, + DeleteManualFinancial, + DeleteAllManualFinancial, + DeleteAllManualViItems } - private sealed record PendingNativeConfirmation( - PendingNativeConfirmationKind Kind, - MMoneyCoderSharp.Data.ThemeSelectionIdentity? ThemeIdentity, - MMoneyCoderSharp.Data.ExpertSelectionIdentity? ExpertIdentity, - long CatalogRevision, - long ComparisonRevision) + private sealed record PendingNativeConfirmation(PendingNativeConfirmationKind Kind) { + internal MMoneyCoderSharp.Data.ThemeSelectionIdentity? ThemeIdentity { get; init; } + + internal MMoneyCoderSharp.Data.ExpertSelectionIdentity? ExpertIdentity { get; init; } + + internal long CatalogRevision { get; init; } + + internal long ComparisonRevision { get; init; } + + internal IReadOnlyList? PlaylistRowIds { get; init; } + + internal long ManualFinancialRevision { get; init; } + + internal IReadOnlyList? ManualViItemIds { get; init; } + internal static PendingNativeConfirmation ForTheme( MMoneyCoderSharp.Data.ThemeSelectionIdentity identity) => - new( - PendingNativeConfirmationKind.DeleteTheme, - identity, - null, - 0, - 0); + new(PendingNativeConfirmationKind.DeleteTheme) + { + ThemeIdentity = identity + }; internal static PendingNativeConfirmation ForExpert( MMoneyCoderSharp.Data.ExpertSelectionIdentity identity) => - new( - PendingNativeConfirmationKind.DeleteExpert, - null, - identity, - 0, - 0); + new(PendingNativeConfirmationKind.DeleteExpert) + { + ExpertIdentity = identity + }; internal static PendingNativeConfirmation ForThemeDraftClear(long revision) => - new( - PendingNativeConfirmationKind.ClearThemeDraft, - null, - null, - revision, - 0); + new(PendingNativeConfirmationKind.ClearThemeDraft) + { + CatalogRevision = revision + }; internal static PendingNativeConfirmation ForComparisonDeleteAll(long revision) => - new( - PendingNativeConfirmationKind.DeleteAllComparisonPairs, - null, - null, - 0, - revision); + new(PendingNativeConfirmationKind.DeleteAllComparisonPairs) + { + ComparisonRevision = revision + }; + + internal static PendingNativeConfirmation ForComparisonImport(long revision) => + new(PendingNativeConfirmationKind.ImportLegacyComparisonPairs) + { + ComparisonRevision = revision + }; + + internal static PendingNativeConfirmation ForPlaylistDeleteAll( + IReadOnlyList rowIds) => + new(PendingNativeConfirmationKind.DeleteAllPlaylistRows) + { + PlaylistRowIds = rowIds + }; + + internal static PendingNativeConfirmation ForManualFinancialDelete( + bool all, + long revision) => + new(all + ? PendingNativeConfirmationKind.DeleteAllManualFinancial + : PendingNativeConfirmationKind.DeleteManualFinancial) + { + ManualFinancialRevision = revision + }; + + internal static PendingNativeConfirmation ForManualViDeleteAll( + IReadOnlyList itemIds) => + new(PendingNativeConfirmationKind.DeleteAllManualViItems) + { + ManualViItemIds = itemIds + }; } private void Touch() => _revision++; diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorSettings.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorSettings.cs index 36c66e2..361c7d9 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorSettings.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorSettings.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; namespace MBN_STOCK_WEBVIEW.LegacyApplication; @@ -41,7 +42,8 @@ public enum LegacyOperatorFolderValidationFailure public sealed record LegacyOperatorValidatedFolder( string CanonicalPath, string AbbreviatedPath, - bool DatabaseIniDetected); + bool DatabaseIniDetected, + int MissingOptionalExternalAssetCount); public sealed record LegacyOperatorFolderValidationResult( LegacyOperatorValidatedFolder? Folder, @@ -65,7 +67,7 @@ public static class LegacyOperatorFolderValidator private const string InvalidFolderMessage = "선택한 폴더를 안전하게 사용할 수 없습니다."; private const string MissingSceneMessage = - "선택한 디자인 폴더에 필수 장면 파일이 없습니다."; + "완전한 Cuts 폴더가 아닙니다. 필수 장면과 기본 자산을 확인하세요. 기존 설정은 유지됩니다."; private const string InvalidResourceMessage = "선택한 설정 폴더의 UI 설정 파일을 검증할 수 없습니다."; @@ -80,6 +82,53 @@ public static class LegacyOperatorFolderValidator "종목비교.ini" ]; + private static readonly IReadOnlyList RequiredSceneFileNameValues = + Array.AsReadOnly(LegacySceneRuntimeCoverage.ExpectedCutCodes + .Select(code => string.Concat(code, ".t2s")) + .ToArray()); + + private static readonly IReadOnlyList RequiredBuiltInAssetRelativePathValues = + Array.AsReadOnly(new[] + { + @"images\주유기merge.png", + @"images\35752913_l.jpg", + @"Images\그림_빨강.png", + @"Images\그림_검정.png", + @"Images\그림_파랑.png", + @"Images\프리마켓.png", + @"Images\애프터마켓.png", + @"Images\KRX.png", + @"Images\NXT.png" + }); + + private static readonly IReadOnlyList OptionalExternalAssetRelativePathValues = + Array.AsReadOnly(new[] + { + @"Video\큐브배경.vrv", + @"Video\20201008_미국.vrv", + @"Video\20201008_독일.vrv", + @"Video\20201008_영국.vrv", + @"Video\20201008_프랑스.vrv", + @"Video\20201008_일본.vrv", + @"Video\20201008_중국.vrv", + @"Video\20201008_홍콩.vrv", + @"Video\20201008_대만.vrv", + @"Video\20201008_싱가포르.vrv", + @"Video\20201008_태국.vrv", + @"Video\20201008_필리핀.vrv", + @"Video\20201008_말레이시아.vrv", + @"Video\20201008_인도네시아.vrv" + }); + + public static IReadOnlyList RequiredSceneFileNames => + RequiredSceneFileNameValues; + + public static IReadOnlyList RequiredBuiltInAssetRelativePaths => + RequiredBuiltInAssetRelativePathValues; + + public static IReadOnlyList OptionalExternalAssetRelativePaths => + OptionalExternalAssetRelativePathValues; + public static bool TryValidate( LegacyOperatorSettingsFolderKind kind, string? path, @@ -106,15 +155,52 @@ public static class LegacyOperatorFolderValidator } var databaseIniDetected = false; + var missingOptionalExternalAssetCount = 0; switch (kind) { case LegacyOperatorSettingsFolderKind.Scene: - if (!TryValidateRegularFile( - Path.Combine(canonicalPath, "5001.t2s"), - required: true, - out failure)) + foreach (var fileName in RequiredSceneFileNameValues) { - return Invalid(failure, MissingSceneMessage); + if (!TryValidateContainedRegularFile( + canonicalPath, + fileName, + required: true, + out _, + out failure)) + { + return Invalid(failure, MissingSceneMessage); + } + } + + foreach (var relativePath in RequiredBuiltInAssetRelativePathValues) + { + if (!TryValidateContainedRegularFile( + canonicalPath, + relativePath, + required: true, + out _, + out failure)) + { + return Invalid(failure, MissingSceneMessage); + } + } + + foreach (var relativePath in OptionalExternalAssetRelativePathValues) + { + if (!TryValidateContainedRegularFile( + canonicalPath, + relativePath, + required: false, + out var detected, + out failure)) + { + return Invalid(failure, MissingSceneMessage); + } + + if (!detected) + { + missingOptionalExternalAssetCount++; + } } break; @@ -174,7 +260,8 @@ public static class LegacyOperatorFolderValidator new LegacyOperatorValidatedFolder( canonicalPath, AbbreviateForDisplay(canonicalPath), - databaseIniDetected), + databaseIniDetected, + missingOptionalExternalAssetCount), LegacyOperatorFolderValidationFailure.None, WarningMessage: null); } @@ -319,16 +406,18 @@ public static class LegacyOperatorFolderValidator bool required, out LegacyOperatorFolderValidationFailure failure) { + return TryValidateRegularFile(path, required, out _, out failure); + } + + private static bool TryValidateRegularFile( + string path, + bool required, + out bool detected, + out LegacyOperatorFolderValidationFailure failure) + { + detected = false; try { - if (!File.Exists(path)) - { - failure = required - ? LegacyOperatorFolderValidationFailure.RequiredFileMissing - : LegacyOperatorFolderValidationFailure.None; - return !required; - } - var attributes = File.GetAttributes(path); if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint | @@ -338,9 +427,91 @@ public static class LegacyOperatorFolderValidator return false; } + if (new FileInfo(path).Length <= 0) + { + failure = LegacyOperatorFolderValidationFailure.RequiredFileUnsafe; + return false; + } + + detected = true; failure = LegacyOperatorFolderValidationFailure.None; return true; } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException) + { + failure = required + ? LegacyOperatorFolderValidationFailure.RequiredFileMissing + : LegacyOperatorFolderValidationFailure.None; + return !required; + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or UnauthorizedAccessException or + NotSupportedException or System.Security.SecurityException) + { + failure = LegacyOperatorFolderValidationFailure.FileUnavailable; + return false; + } + } + + private static bool TryValidateContainedRegularFile( + string root, + string relativePath, + bool required, + out bool detected, + out LegacyOperatorFolderValidationFailure failure) + { + detected = false; + try + { + if (string.IsNullOrWhiteSpace(relativePath) || + Path.IsPathFullyQualified(relativePath) || + relativePath.Any(char.IsControl)) + { + failure = LegacyOperatorFolderValidationFailure.InvalidPath; + return false; + } + + var fullPath = Path.GetFullPath(Path.Combine(root, relativePath)); + var rootPrefix = string.Concat( + Path.TrimEndingDirectorySeparator(root), + Path.DirectorySeparatorChar); + if (!fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase)) + { + failure = LegacyOperatorFolderValidationFailure.InvalidPath; + return false; + } + + var relativeDirectory = Path.GetDirectoryName(relativePath); + if (!string.IsNullOrEmpty(relativeDirectory)) + { + var current = root; + foreach (var segment in relativeDirectory.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, segment); + try + { + var attributes = File.GetAttributes(current); + if ((attributes & FileAttributes.Directory) == 0 || + (attributes & (FileAttributes.ReparsePoint | + FileAttributes.Device)) != 0) + { + failure = LegacyOperatorFolderValidationFailure.RequiredFileUnsafe; + return false; + } + } + catch (Exception exception) when ( + exception is FileNotFoundException or DirectoryNotFoundException) + { + break; + } + } + } + + return TryValidateRegularFile(fullPath, required, out detected, out failure); + } catch (Exception exception) when ( exception is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException or System.Security.SecurityException) @@ -367,6 +538,12 @@ public static class LegacyOperatorFolderValidator return false; } + if (new FileInfo(path).Length <= 0) + { + failure = LegacyOperatorFolderValidationFailure.RequiredFileUnsafe; + return false; + } + detected = true; failure = LegacyOperatorFolderValidationFailure.None; return true; diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs index 28d0f98..207b698 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs @@ -108,6 +108,7 @@ public sealed class LegacyOverseasStockSearchSnapshot DateTimeOffset? retrievedAt, IReadOnlyList results, bool isTruncated, + int isolatedInvalidRowCount, LegacyOverseasStockSearchRow? selected, string error) { @@ -116,6 +117,7 @@ public sealed class LegacyOverseasStockSearchSnapshot RetrievedAt = retrievedAt; Results = results; IsTruncated = isTruncated; + IsolatedInvalidRowCount = isolatedInvalidRowCount; Selected = selected; Error = error; } @@ -132,6 +134,8 @@ public sealed class LegacyOverseasStockSearchSnapshot public bool IsTruncated { get; } + public int IsolatedInvalidRowCount { get; } + public LegacyOverseasStockSearchRow? Selected { get; } public string? SelectedId => Selected?.SelectionId; @@ -227,7 +231,7 @@ public sealed class LegacyOverseasSelectionWorkflow // UC5 itself did not impose a UI result cap. The database boundary remains // bounded, but use that boundary's full capacity and expose truncation. public const int DefaultMaximumResults = LegacyOverseasStockSearchService.MaximumResults; - public const int MaximumResults = 500; + public const int MaximumResults = LegacyOverseasStockSearchService.MaximumResults; public const int MaximumQueryLength = 64; private const int DefaultFadeDuration = 6; @@ -263,6 +267,7 @@ public sealed class LegacyOverseasSelectionWorkflow private DateTimeOffset? _stockRetrievedAt; private IReadOnlyList _stockResults = NoStockResults; private bool _stockIsTruncated; + private int _stockIsolatedInvalidRowCount; private LegacyOverseasStockSearchRow? _selectedStock; private string _stockError = string.Empty; private long _stockGeneration; @@ -346,6 +351,7 @@ public sealed class LegacyOverseasSelectionWorkflow _stockRetrievedAt = null; _stockResults = NoStockResults; _stockIsTruncated = false; + _stockIsolatedInvalidRowCount = 0; _selectedStock = null; _stockError = string.Empty; Touch(); @@ -486,6 +492,7 @@ public sealed class LegacyOverseasSelectionWorkflow _stockRetrievedAt = null; _stockResults = NoStockResults; _stockIsTruncated = false; + _stockIsolatedInvalidRowCount = 0; _selectedStock = null; _stockError = string.Empty; Touch(); @@ -523,6 +530,7 @@ public sealed class LegacyOverseasSelectionWorkflow _stockRetrievedAt = result.RetrievedAt; _stockResults = validated; _stockIsTruncated = result.IsTruncated; + _stockIsolatedInvalidRowCount = result.IsolatedInvalidRowCount; // Match UC5's FarPoint RowMode active-row transition after the // first result row is inserted. _selectedStock = validated.Count == 0 ? null : validated[0]; @@ -558,6 +566,7 @@ public sealed class LegacyOverseasSelectionWorkflow _stockRetrievedAt = null; _stockResults = NoStockResults; _stockIsTruncated = false; + _stockIsolatedInvalidRowCount = 0; _selectedStock = null; _stockError = StockError; Touch(); @@ -812,6 +821,7 @@ public sealed class LegacyOverseasSelectionWorkflow _stockRetrievedAt, _stockResults, _stockIsTruncated, + _stockIsolatedInvalidRowCount, _selectedStock, _stockError), _movingAverages, @@ -835,18 +845,14 @@ public sealed class LegacyOverseasSelectionWorkflow throw InvalidData("industry result bound"); } - var koreanNames = new HashSet(StringComparer.Ordinal); - var inputNames = new HashSet(StringComparer.Ordinal); - var symbols = new HashSet(StringComparer.Ordinal); + var identities = new HashSet(); for (var index = 0; index < items.Length; index++) { var item = items[index] ?? throw InvalidData("industry identity"); if (!IsSafeName(item.KoreanName) || !IsSafeName(item.InputName) || !IsSafeSymbol(item.Symbol) || - !koreanNames.Add(item.KoreanName) || - !inputNames.Add(item.InputName) || - !symbols.Add(item.Symbol) || + !identities.Add(item) || index > 0 && CompareIndustry(items[index - 1], item) > 0) { throw InvalidData("industry identity or ordering"); @@ -867,7 +873,11 @@ public sealed class LegacyOverseasSelectionWorkflow long generation) { if (result is null || result.Items is null || result.RetrievedAt == default || - !string.Equals(result.Query, expectedQuery, StringComparison.Ordinal)) + !string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) || + result.IsolatedInvalidRowCount < 0 || + result.IsolatedInvalidRowCount > + LegacyOverseasStockSearchService.MaximumIsolatedRows + 1 || + result.IsolatedInvalidRowCount > 0 && !result.IsTruncated) { throw InvalidData("stock search envelope"); } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs index fe03949..8ab4d33 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs @@ -25,6 +25,11 @@ public sealed record LegacyOperatorPlaylistRow( LegacySceneSelection? PlayoutSelection = null, bool IsActive = false) { + public bool IsSceneResolved => !string.Equals( + SceneAlias, + LegacyPlaylistEntry.UnresolvedCutCode, + StringComparison.Ordinal); + public LegacyPlaylistEntry ToPlayoutEntry() { var cutCode = SceneAlias.Length > 0 @@ -46,7 +51,7 @@ public sealed record LegacyOperatorPlaylistRow( CreatePageNavigation()); } - private LegacyPlaylistPageNavigation CreatePageNavigation() + private LegacyPlaylistPageNavigation? CreatePageNavigation() { var pageSize = GraphicType switch { @@ -78,8 +83,13 @@ public sealed record LegacyOperatorPlaylistRow( totalPages is < 1 or > ScenePaging.MaximumPageCount || currentPage < 1 || currentPage > totalPages) { - throw new InvalidOperationException( - "A PageN playlist row must use current/total with a total from 1 through 20."); + // MainForm persisted the visible PageN text verbatim, including 1/0 when + // the backing query returned no rows. Loading that historical value must + // not reject the complete named playlist. A null navigation contract keeps + // the row lossless and lets the scene provider derive current paging if the + // operator later selects this row for PREPARE/TAKE IN. Empty or otherwise + // invalid current data is then rejected for this row only, before dispatch. + return null; } return new LegacyPlaylistPageNavigation(pageSize, totalPages); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs index e8a8016..0e243de 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs @@ -74,9 +74,7 @@ public sealed class LegacyThemeSearchRow Identity = identity; Market = identity.Market; Session = identity.Session; - DisplayTitle = identity.Market == ThemeMarket.Nxt - ? $"{identity.ThemeTitle}(NXT)" - : identity.ThemeTitle; + DisplayTitle = LegacyThemeWorkflow.DisplayTitle(identity); Source = source; } @@ -147,9 +145,7 @@ public sealed class LegacyThemeWorkflowSnapshot : new LegacySelectedThemeSummary( selectedIdentity.Market, selectedIdentity.Session, - selectedIdentity.Market == ThemeMarket.Nxt - ? $"{selectedIdentity.ThemeTitle}(NXT)" - : selectedIdentity.ThemeTitle, + LegacyThemeWorkflow.DisplayTitle(selectedIdentity), PreviewItems.Count, previewIsTruncated); ActionStates = LegacyThemeWorkflow.CreateActionStates( @@ -194,7 +190,8 @@ public sealed record LegacyThemePlaylistSelection( string Subject, string GraphicType, string Subtype, - string DataCode); + string DataCode, + string? ExactSubject = null); public sealed record LegacyThemePagePlan( int PreviewItemCount, @@ -237,7 +234,7 @@ public sealed class LegacyThemeWorkflowDataException : Exception /// public sealed class LegacyThemeWorkflow { - public const int MaximumSearchResults = 100; + public const int MaximumSearchResults = LegacyThemeSelectionService.MaximumResults; public const int MaximumPreviewItems = 240; public const int MaximumPageCount = 20; @@ -452,9 +449,7 @@ public sealed class LegacyThemeWorkflow var sortId = SortId(snapshot.Sort); var valueKind = ValueKindId(action.ValueKind); - var displayTitle = identity.Market == ThemeMarket.Nxt - ? $"{identity.ThemeTitle}(NXT)" - : identity.ThemeTitle; + var displayTitle = DisplayTitle(identity); var playlistGraphicType = PlaylistGraphicType(action); var playlistSubtype = $"테마-{ValueKindLabel(action.ValueKind)}({SortLabel(snapshot.Sort)})"; @@ -464,13 +459,15 @@ public sealed class LegacyThemeWorkflow identity.ThemeTitle, sortId, valueKind, - identity.ThemeCode) + identity.ThemeCode, + identity.ThemeTitle) : new LegacyThemePlaylistSelection( "PAGED_NXT_THEME", displayTitle, SessionId(CurrentNxtSession), sortId, - identity.ThemeCode); + identity.ThemeCode, + identity.ThemeTitle); var maximumItems = checked(action.PageSize * MaximumPageCount); var accessibleItems = Math.Min(snapshot.PreviewItems.Count, maximumItems); @@ -683,13 +680,18 @@ public sealed class LegacyThemeWorkflow identity.Session != ThemeSession.NotApplicable || identity.Market == ThemeMarket.Nxt && identity.Session != requestedNxtSession || !IsThemeCode(identity.ThemeCode) || - !IsCanonicalText(identity.ThemeTitle, 128) || - identity.ThemeTitle.Contains("(NXT)", StringComparison.Ordinal)) + !IsStoredThemeTitle(identity.ThemeTitle, 128)) { throw InvalidData("theme identity"); } } + internal static string DisplayTitle(ThemeSelectionIdentity identity) => + identity.Market == ThemeMarket.Nxt && + !identity.ThemeTitle.EndsWith("(NXT)", StringComparison.Ordinal) + ? $"{identity.ThemeTitle}(NXT)" + : identity.ThemeTitle; + private static string NormalizeQuery(string query) { ArgumentNullException.ThrowIfNull(query); @@ -802,6 +804,12 @@ public sealed class LegacyThemeWorkflow string.Equals(value, value.Trim(), StringComparison.Ordinal) && IsSafeText(value); + private static bool IsStoredThemeTitle(string value, int maximumLength) => + value is not null && value.Length is >= 1 && + value.Length <= maximumLength && + !string.IsNullOrWhiteSpace(value) && + IsSafeText(value); + private static bool IsSafeText(string value) { foreach (var rune in value.EnumerateRunes()) diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualFinancial/LegacyManualFinancialWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualFinancial/LegacyManualFinancialWorkflow.cs index 0f2f8c0..1a3c25a 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualFinancial/LegacyManualFinancialWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualFinancial/LegacyManualFinancialWorkflow.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Collections.ObjectModel; +using System.Globalization; using MMoneyCoderSharp.Data; namespace MBN_STOCK_WEBVIEW.LegacyApplication; @@ -77,15 +78,91 @@ public sealed class LegacyManualFinancialListRow { ResultId = resultId; Snapshot = snapshot; + StockName = snapshot.Record.Identity.StockName; + PreviewValues = CreatePreviewValues(snapshot.Record); + } + + internal LegacyManualFinancialListRow( + string resultId, + ManualFinancialRawSnapshot snapshot) + { + ResultId = resultId; + RawSnapshot = snapshot; + Snapshot = snapshot.StrictSnapshot; + StockName = snapshot.Record.Identity.StockName; + PreviewValues = Array.AsReadOnly( + new[] { StockName } + .Concat(snapshot.Record.StorageValues) + .ToArray()); + } + + internal LegacyManualFinancialListRow(string resultId, string stockName) + { + ResultId = resultId; + StockName = stockName; + PreviewValues = Array.AsReadOnly([stockName]); } public string ResultId { get; } - public string StockName => Snapshot.Record.Identity.StockName; + public string StockName { get; } - public string RowVersion => Snapshot.RowVersion; + /// + /// Read-only GraphE list projection in the fixed INPUT_* storage-column + /// order. It contains operator display values only: no row version, ROWID, + /// native row handle, stock code, or database authority is exposed. + /// + public IReadOnlyList PreviewValues { get; } - internal ManualFinancialSnapshot Snapshot { get; } + public string RowVersion => RawSnapshot?.RowVersion ?? Snapshot?.RowVersion ?? string.Empty; + + public bool IsDataReadable => RawSnapshot is not null || Snapshot is not null; + + public bool IsPlayoutReady => Snapshot is not null; + + internal ManualFinancialSnapshot? Snapshot { get; } + + internal ManualFinancialRawSnapshot? RawSnapshot { get; } + + private static IReadOnlyList CreatePreviewValues( + ManualFinancialRecord record) + { + var values = record switch + { + ManualRevenueCompositionRecord revenue => + new[] { revenue.Identity.StockName, revenue.BaseDate } + .Concat(revenue.Slices.Select(static slice => slice is null + ? string.Empty + : $"{slice.Label}_{slice.PercentageText}")), + ManualGrowthMetricsRecord growth => + new[] { growth.Identity.StockName } + .Concat(new[] + { + FormatGrowthSeries(growth.SalesGrowth), + FormatGrowthSeries(growth.OperatingProfitGrowth), + FormatGrowthSeries(growth.NetAssetGrowth), + FormatGrowthSeries(growth.NetIncomeGrowth), + string.Join('_', growth.Periods.ToArray()) + }), + ManualSalesRecord sales => + new[] { sales.Identity.StockName } + .Concat(sales.Quarters.Select(FormatQuarter)), + ManualOperatingProfitRecord profit => + new[] { profit.Identity.StockName } + .Concat(profit.Quarters.Select(FormatQuarter)), + _ => throw new ArgumentOutOfRangeException(nameof(record)) + }; + + return Array.AsReadOnly(values.ToArray()); + } + + private static string FormatGrowthSeries(ManualGrowthSeriesValues series) => + string.Join('_', series.ToArray().Select(static value => value?.ToString( + "G17", + CultureInfo.InvariantCulture) ?? string.Empty)); + + private static string FormatQuarter(ManualQuarterValue quarter) => + $"{quarter.Quarter}_{quarter.Value.ToString(CultureInfo.InvariantCulture)}"; } public sealed class LegacyManualFinancialStockRow @@ -124,7 +201,8 @@ public sealed record LegacyManualFinancialPlaylistDraft( string StockCode, bool IsNxt, ManualFinancialCutSelection CutSelection, - ManualFinancialSnapshot OperatorSnapshot); + ManualFinancialSnapshot? OperatorSnapshot, + ManualFinancialRawSnapshot? OperatorRawSnapshot); public sealed record LegacyManualFinancialWorkflowSnapshot { @@ -160,14 +238,22 @@ public sealed record LegacyManualFinancialWorkflowSnapshot public bool ListIsTruncated { get; internal init; } + public int ListRejectedItemCount { get; internal init; } + public string? SelectedRowId { get; internal init; } public ManualFinancialRecord? SelectedRecord => Detail?.Record; - public string? SelectedRowVersion => Detail?.RowVersion; + public ManualFinancialRawRecord? SelectedRawRecord => RawDetail?.Record; + + public string? SelectedRowVersion => RawDetail?.RowVersion ?? Detail?.RowVersion; public bool IsDetailFresh { get; internal init; } + public bool IsRawDetailFresh { get; internal init; } + + public bool IsPlayoutReady => IsDetailFresh && Detail is not null; + public string StockQuery { get; internal init; } public IReadOnlyList StockCandidates { get; internal init; } @@ -194,27 +280,42 @@ public sealed record LegacyManualFinancialWorkflowSnapshot public ManualFinancialMutationReceipt? LastReceipt { get; internal init; } + public bool CanEdit => + !WritesQuarantined && + (IsRawDetailFresh && RawDetail is not null || + IsDetailFresh && Detail is not null); + public bool CanSave => !WritesQuarantined && - Verified is not null && - (Detail is null || - IsDetailFresh && - string.Equals(VerifiedForRowVersion, Detail.RowVersion, StringComparison.Ordinal) && - string.Equals( - Verified.Name, - Detail.Record.Identity.StockName, - StringComparison.Ordinal)); + (RawDetail is not null + ? IsRawDetailFresh + : Verified is not null && + (Detail is null || + IsDetailFresh && + string.Equals( + VerifiedForRowVersion, + Detail.RowVersion, + StringComparison.Ordinal) && + string.Equals( + Verified.Name, + Detail.Record.Identity.StockName, + StringComparison.Ordinal))); public bool CanDelete => - !WritesQuarantined && IsDetailFresh && Detail is not null; + !WritesQuarantined && + (IsRawDetailFresh && RawDetail is not null || + IsDetailFresh && Detail is not null); public bool CanMaterializePlaylist => - CanDelete && + !WritesQuarantined && Verified is not null && - string.Equals(VerifiedForRowVersion, Detail!.RowVersion, StringComparison.Ordinal) && + SelectedIdentity is { } identity && + SelectedRowVersion is { } rowVersion && + (IsRawDetailFresh || IsDetailFresh) && + string.Equals(VerifiedForRowVersion, rowVersion, StringComparison.Ordinal) && string.Equals( Verified.Name, - Detail.Record.Identity.StockName, + identity.StockName, StringComparison.Ordinal); internal object Authority { get; } @@ -223,6 +324,11 @@ public sealed record LegacyManualFinancialWorkflowSnapshot internal ManualFinancialSnapshot? Detail { get; init; } + internal ManualFinancialRawSnapshot? RawDetail { get; init; } + + internal ManualFinancialIdentity? SelectedIdentity => + RawDetail?.Record.Identity ?? Detail?.Record.Identity; + internal VerifiedManualFinancialStock? Verified { get; init; } internal string? VerifiedForRowVersion { get; init; } @@ -255,6 +361,8 @@ public abstract class LegacyManualFinancialWriteRequest public string StockName { get; } + public virtual string? ExpectedRowVersion => null; + internal object Authority { get; } internal long SourceRevision { get; } @@ -313,7 +421,7 @@ public sealed class LegacyManualFinancialUpdateRequest : LegacyManualFinancialWr Stock = verifiedStock; } - public string ExpectedRowVersion => Expected.RowVersion; + public override string ExpectedRowVersion => Expected.RowVersion; public ManualFinancialRecord Replacement { get; } @@ -324,6 +432,33 @@ public sealed class LegacyManualFinancialUpdateRequest : LegacyManualFinancialWr private VerifiedManualFinancialStock Stock { get; } } +public sealed class LegacyManualFinancialRawUpdateRequest : LegacyManualFinancialWriteRequest +{ + internal LegacyManualFinancialRawUpdateRequest( + object authority, + long sourceRevision, + long sourceWriteEpoch, + ManualFinancialRawSnapshot expected, + ManualFinancialRawRecord replacement) + : base( + authority, + sourceRevision, + sourceWriteEpoch, + ManualFinancialMutationKind.Update, + expected.Record.Identity.Screen, + expected.Record.Identity.StockName) + { + Expected = expected; + Replacement = replacement; + } + + public override string ExpectedRowVersion => Expected.RowVersion; + + public ManualFinancialRawRecord Replacement { get; } + + internal ManualFinancialRawSnapshot Expected { get; } +} + public sealed class LegacyManualFinancialDeleteRequest : LegacyManualFinancialWriteRequest { internal LegacyManualFinancialDeleteRequest( @@ -342,11 +477,34 @@ public sealed class LegacyManualFinancialDeleteRequest : LegacyManualFinancialWr Expected = expected; } - public string ExpectedRowVersion => Expected.RowVersion; + public override string ExpectedRowVersion => Expected.RowVersion; internal ManualFinancialSnapshot Expected { get; } } +public sealed class LegacyManualFinancialRawDeleteRequest : LegacyManualFinancialWriteRequest +{ + internal LegacyManualFinancialRawDeleteRequest( + object authority, + long sourceRevision, + long sourceWriteEpoch, + ManualFinancialRawSnapshot expected) + : base( + authority, + sourceRevision, + sourceWriteEpoch, + ManualFinancialMutationKind.DeleteOne, + expected.Record.Identity.Screen, + expected.Record.Identity.StockName) + { + Expected = expected; + } + + public override string ExpectedRowVersion => Expected.RowVersion; + + internal ManualFinancialRawSnapshot Expected { get; } +} + public sealed class LegacyManualFinancialDeleteAllRequest : LegacyManualFinancialWriteRequest { internal LegacyManualFinancialDeleteAllRequest( @@ -458,8 +616,9 @@ internal sealed class LegacyManualFinancialWriteSafetyState /// public sealed class LegacyManualFinancialWorkflow { - public const int MaximumListResults = 200; - public const int MaximumStockResults = 100; + public const int MaximumListResults = + LegacyManualFinancialScreenService.MaximumResults; + public const int MaximumStockResults = LegacyStockSearchService.MaximumResults; private const string ValidationRowVersion = "0000000000000000000000000000000000000000000000000000000000000000"; @@ -480,6 +639,7 @@ public sealed class LegacyManualFinancialWorkflow private static readonly LegacyManualFinancialWriteSafetyState ProcessWriteSafety = new(); private readonly IManualFinancialScreenService _screenService; + private readonly IManualFinancialRawScreenService? _rawScreenService; private readonly IStockSearchService _stockSearchService; private readonly LegacyManualFinancialWriteSafetyState _writeSafety; private readonly object _authority = new(); @@ -502,6 +662,7 @@ public sealed class LegacyManualFinancialWorkflow LegacyManualFinancialWriteSafetyState writeSafety) { _screenService = screenService ?? throw new ArgumentNullException(nameof(screenService)); + _rawScreenService = screenService as IManualFinancialRawScreenService; _stockSearchService = stockSearchService ?? throw new ArgumentNullException(nameof(stockSearchService)); _writeSafety = writeSafety ?? throw new ArgumentNullException(nameof(writeSafety)); @@ -539,27 +700,77 @@ public sealed class LegacyManualFinancialWorkflow cancellationToken).ConfigureAwait(false); if (result is null || result.Screen != snapshot.Screen || !string.Equals(result.Query, normalizedQuery, StringComparison.Ordinal) || - result.Items is null || result.Items.Count > MaximumListResults) + result.Items is null || result.Items.Count > MaximumListResults || + result.UnreadableStockNames is null || + result.RawItems is null || + (result.RawItemsComplete + ? result.RawItems.Count > MaximumListResults + : result.Items.Count + result.UnreadableStockNames.Count > MaximumListResults) || + result.RejectedItemCount is < 0 or > MaximumListResults || + result.UnreadableStockNames.Count > result.RejectedItemCount) { throw InvalidData("The manual-financial list response is not correlated to its request."); } - var rows = new List(result.Items.Count); - var identities = new HashSet(StringComparer.Ordinal); - foreach (var item in result.Items) + var rows = new List(result.RawItemsComplete + ? result.RawItems.Count + : result.Items.Count + result.UnreadableStockNames.Count); + if (result.RawItemsComplete) { - var validated = ValidateSnapshot(item, snapshot.Screen, "list response"); - if (!identities.Add(validated.Record.Identity.StockName)) + foreach (var item in result.RawItems) { - throw InvalidData("The manual-financial list contains an ambiguous STOCK_NAME identity."); + var validated = ValidateRawSnapshot( + item, + snapshot.Screen, + "raw list response"); + rows.Add(new LegacyManualFinancialListRow( + NewOpaqueId("mf-row"), + validated)); + } + } + else + { + foreach (var item in result.Items) + { + var validated = ValidateSnapshot(item, snapshot.Screen, "list response"); + rows.Add(new LegacyManualFinancialListRow(NewOpaqueId("mf-row"), validated)); + } + foreach (var stockName in result.UnreadableStockNames) + { + rows.Add(new LegacyManualFinancialListRow( + NewOpaqueId("mf-row"), + ValidateListStockName(stockName))); } - - rows.Add(new LegacyManualFinancialListRow(NewOpaqueId("mf-row"), validated)); } rows.Sort(static (left, right) => StringComparer.Ordinal.Compare(left.StockName, right.StockName)); + var writesQuarantined = IsQuarantined(out var reason); + var hiddenUnreadableCount = + result.RejectedItemCount - result.UnreadableStockNames.Count; + var listMessages = new List(); + if (result.RejectedItemCount > 0) + { + listMessages.Add(hiddenUnreadableCount == 0 + ? $"기존 원문 형식 데이터 {result.RejectedItemCount}행도 " + + "목록에 표시되며 선택·편집할 수 있습니다." + : $"기존 원문 형식 데이터 {result.UnreadableStockNames.Count}행은 " + + $"목록에 표시했고, 이름을 읽을 수 없는 {hiddenUnreadableCount}행은 제외했습니다."); + } + if (result.IsTruncated) + { + listMessages.Add( + $"검색 결과가 {MaximumListResults}행을 초과해 일부만 표시됩니다."); + } + if (!string.IsNullOrWhiteSpace(reason)) + { + listMessages.Add(reason); + } + var listMessage = listMessages.Count == 0 + ? null + : string.Join(' ', listMessages); + return snapshot with { Revision = checked(snapshot.Revision + 1), @@ -568,9 +779,12 @@ public sealed class LegacyManualFinancialWorkflow NameSortDirection = LegacyManualFinancialNameSortDirection.Ascending, Rows = Array.AsReadOnly(rows.ToArray()), ListIsTruncated = result.IsTruncated, + ListRejectedItemCount = result.RejectedItemCount, SelectedRowId = null, Detail = null, + RawDetail = null, IsDetailFresh = false, + IsRawDetailFresh = false, StockQuery = string.Empty, StockCandidates = Array.Empty(), StockSearchIsTruncated = false, @@ -578,8 +792,8 @@ public sealed class LegacyManualFinancialWorkflow Verified = null, VerifiedForRowVersion = null, Status = LegacyManualFinancialWorkflowStatus.ListLoaded, - WritesQuarantined = IsQuarantined(out var reason), - LastMessage = reason, + WritesQuarantined = writesQuarantined, + LastMessage = listMessage, LastRequestId = null, LastReceipt = null }; @@ -604,6 +818,35 @@ public sealed class LegacyManualFinancialWorkflow }; } + public LegacyManualFinancialWorkflowSnapshot BeginLoad( + LegacyManualFinancialWorkflowSnapshot snapshot, + string resultId) + { + snapshot = RequireOwned(snapshot); + var isCurrentRow = !string.IsNullOrWhiteSpace(resultId) && + snapshot.Rows.Any(row => string.Equals( + row.ResultId, + resultId, + StringComparison.Ordinal)); + return snapshot with + { + Revision = checked(snapshot.Revision + 1), + SelectedRowId = isCurrentRow ? resultId : null, + Detail = null, + RawDetail = null, + IsDetailFresh = false, + IsRawDetailFresh = false, + StockQuery = string.Empty, + StockCandidates = Array.Empty(), + StockSearchIsTruncated = false, + SelectedStockResultId = null, + Verified = null, + VerifiedForRowVersion = null, + Status = LegacyManualFinancialWorkflowStatus.ListLoaded, + LastMessage = null + }; + } + public LegacyManualFinancialWorkflowSnapshot FindByName( LegacyManualFinancialWorkflowSnapshot snapshot, string query, @@ -659,7 +902,9 @@ public sealed class LegacyManualFinancialWorkflow ? snapshot.Rows[matchIndex].ResultId : snapshot.SelectedRowId, Detail = matchIndex >= 0 ? null : snapshot.Detail, + RawDetail = matchIndex >= 0 ? null : snapshot.RawDetail, IsDetailFresh = matchIndex < 0 && snapshot.IsDetailFresh, + IsRawDetailFresh = matchIndex < 0 && snapshot.IsRawDetailFresh, StockQuery = matchIndex >= 0 ? string.Empty : snapshot.StockQuery, StockCandidates = matchIndex >= 0 ? Array.Empty() @@ -688,22 +933,53 @@ public sealed class LegacyManualFinancialWorkflow { snapshot = RequireOwned(snapshot); var row = FindRow(snapshot.Rows, resultId); - var identity = row.Snapshot.Record.Identity; - var detail = await _screenService.GetAsync(identity, cancellationToken).ConfigureAwait(false); - var validated = ValidateSnapshot(detail, snapshot.Screen, "detail response"); - if (!EqualsIdentity(identity, validated.Record.Identity)) + var identity = new ManualFinancialIdentity(snapshot.Screen, row.StockName); + ManualFinancialSnapshot? validated; + ManualFinancialRawSnapshot? validatedRaw = null; + IReadOnlyList rows; + if (row.RawSnapshot is not null && _rawScreenService is not null) { - throw InvalidData("The manual-financial detail response is not correlated to its request."); + var raw = await _rawScreenService.GetRawAsync( + row.RawSnapshot, + cancellationToken).ConfigureAwait(false); + validatedRaw = ValidateRawSnapshot(raw, snapshot.Screen, "raw detail response"); + if (!EqualsIdentity(identity, validatedRaw.Record.Identity)) + { + throw InvalidData( + "The raw manual-financial detail response is not correlated to its request."); + } + + validated = validatedRaw.StrictSnapshot; + rows = ReplaceRow(snapshot.Rows, resultId, validatedRaw); + } + else + { + var detail = await _screenService.GetAsync( + identity, + cancellationToken).ConfigureAwait(false); + validated = ValidateSnapshot(detail, snapshot.Screen, "detail response"); + if (!EqualsIdentity(identity, validated.Record.Identity)) + { + throw InvalidData( + "The manual-financial detail response is not correlated to its request."); + } + + rows = ReplaceRow(snapshot.Rows, resultId, validated); } - var rows = ReplaceRow(snapshot.Rows, resultId, validated); + var writesQuarantined = IsQuarantined(out var reason); + var message = reason ?? (validated is null + ? "기존 GraphE 저장값을 원문 그대로 불러왔습니다. 송출 시에는 장면 데이터 검증을 다시 수행합니다." + : null); return snapshot with { Revision = checked(snapshot.Revision + 1), Rows = rows, SelectedRowId = resultId, Detail = validated, - IsDetailFresh = true, + RawDetail = validatedRaw, + IsDetailFresh = validated is not null, + IsRawDetailFresh = validatedRaw is not null, StockQuery = string.Empty, StockCandidates = Array.Empty(), StockSearchIsTruncated = false, @@ -711,8 +987,8 @@ public sealed class LegacyManualFinancialWorkflow Verified = null, VerifiedForRowVersion = null, Status = LegacyManualFinancialWorkflowStatus.DetailLoaded, - WritesQuarantined = IsQuarantined(out var reason), - LastMessage = reason, + WritesQuarantined = writesQuarantined, + LastMessage = message, LastRequestId = null, LastReceipt = null }; @@ -727,7 +1003,9 @@ public sealed class LegacyManualFinancialWorkflow Revision = checked(snapshot.Revision + 1), SelectedRowId = null, Detail = null, + RawDetail = null, IsDetailFresh = false, + IsRawDetailFresh = false, StockQuery = string.Empty, StockCandidates = Array.Empty(), StockSearchIsTruncated = false, @@ -748,7 +1026,15 @@ public sealed class LegacyManualFinancialWorkflow CancellationToken cancellationToken = default) { snapshot = RequireOwned(snapshot); - var normalizedName = NormalizeQuery(stockName, allowEmpty: false); + var normalizedName = NormalizeQuery(stockName, allowEmpty: true); + // Form/GraphE.cs returned immediately from both the button and Enter + // handlers when the stock-search text box was empty. Preserve the + // complete editor/candidate state and avoid touching the provider. + if (normalizedName.Length == 0) + { + return snapshot; + } + var result = await _stockSearchService.SearchAsync( normalizedName, MaximumStockResults, @@ -791,8 +1077,14 @@ public sealed class LegacyManualFinancialWorkflow string stockResultId) { snapshot = RequireOwned(snapshot); + if (string.IsNullOrWhiteSpace(stockResultId)) + { + return snapshot; + } + var row = FindStock(snapshot.StockCandidates, stockResultId); - var identity = snapshot.Detail?.Record.Identity ?? + var identity = snapshot.RawDetail?.Record.Identity ?? + snapshot.Detail?.Record.Identity ?? new ManualFinancialIdentity(snapshot.Screen, row.Item.Name); var candidates = snapshot.StockCandidates.Select(candidate => candidate.Item).ToArray(); var verified = ManualFinancialCutContracts.VerifyStock( @@ -800,9 +1092,7 @@ public sealed class LegacyManualFinancialWorkflow candidates, row.Item.Market, row.Item.Code); - var verifiedForRowVersion = snapshot.Detail is null - ? null - : snapshot.Detail.RowVersion; + var verifiedForRowVersion = snapshot.SelectedRowVersion; return snapshot with { @@ -863,10 +1153,41 @@ public sealed class LegacyManualFinancialWorkflow verified); } - public LegacyManualFinancialDeleteRequest CreateDeleteRequest( + public LegacyManualFinancialRawUpdateRequest CreateRawUpdateRequest( + LegacyManualFinancialWorkflowSnapshot snapshot, + ManualFinancialRawRecord replacement) + { + snapshot = RequireWritable(snapshot); + var expected = RequireFreshRawDetail(snapshot); + var validated = ValidateRawRecord(replacement, snapshot.Screen, nameof(replacement)); + if (!EqualsIdentity(expected.Record.Identity, validated.Identity)) + { + throw new ArgumentException( + "A raw manual-financial update cannot rename or change screens.", + nameof(replacement)); + } + + return new LegacyManualFinancialRawUpdateRequest( + _authority, + snapshot.Revision, + snapshot.WriteEpoch, + expected, + validated); + } + + public LegacyManualFinancialWriteRequest CreateDeleteRequest( LegacyManualFinancialWorkflowSnapshot snapshot) { snapshot = RequireWritable(snapshot); + if (snapshot.IsRawDetailFresh && snapshot.RawDetail is not null) + { + return new LegacyManualFinancialRawDeleteRequest( + _authority, + snapshot.Revision, + snapshot.WriteEpoch, + snapshot.RawDetail); + } + var expected = RequireFreshDetail(snapshot); return new LegacyManualFinancialDeleteRequest( _authority, @@ -945,10 +1266,19 @@ public sealed class LegacyManualFinancialWorkflow update.Expected, update.Replacement, cancellationToken).ConfigureAwait(false), + LegacyManualFinancialRawUpdateRequest update => + await RequireRawService().UpdateRawAsync( + update.Expected, + update.Replacement, + cancellationToken).ConfigureAwait(false), LegacyManualFinancialDeleteRequest delete => await _screenService.DeleteAsync( delete.Expected, cancellationToken).ConfigureAwait(false), + LegacyManualFinancialRawDeleteRequest delete => + await RequireRawService().DeleteRawAsync( + delete.Expected, + cancellationToken).ConfigureAwait(false), LegacyManualFinancialDeleteAllRequest => await _screenService.DeleteAllAsync( snapshot.Screen, @@ -990,7 +1320,15 @@ public sealed class LegacyManualFinancialWorkflow writeEpoch, cancellationToken) .ConfigureAwait(false), - LegacyManualFinancialDeleteRequest => + LegacyManualFinancialRawUpdateRequest rawUpdate => + await CompleteRawSaveAsync( + snapshot, + rawUpdate, + receipt, + writeEpoch, + cancellationToken) + .ConfigureAwait(false), + LegacyManualFinancialDeleteRequest or LegacyManualFinancialRawDeleteRequest => CompleteDelete(snapshot, request, receipt, writeEpoch), LegacyManualFinancialDeleteAllRequest => CompleteDeleteAll(snapshot, request, receipt, writeEpoch), @@ -1020,22 +1358,32 @@ public sealed class LegacyManualFinancialWorkflow "Playlist materialization is blocked while a write outcome is unknown."); } - var detail = RequireFreshDetail(snapshot); - var verified = RequireVerifiedForDetail(snapshot, detail); - var cut = ManualFinancialCutContracts.CreateSelection(detail, verified); + var identity = snapshot.SelectedIdentity ?? throw new InvalidOperationException( + "A fresh manual-financial detail read is required."); + var verified = RequireVerifiedForSelectedDetail(snapshot, identity); + var raw = snapshot.IsRawDetailFresh ? snapshot.RawDetail : null; + var detail = snapshot.IsDetailFresh ? snapshot.Detail : null; + if (raw is null && detail is null) + { + throw new InvalidOperationException( + "A fresh manual-financial detail read is required."); + } + + var cut = ManualFinancialCutContracts.CreateSelection(identity, verified); return new LegacyManualFinancialPlaylistDraft( definition.ActionId, definition.BuilderKey, definition.Code, definition.Aliases, - detail.Record.Identity.StockName, + identity.StockName, definition.Label, "manual-financial", MarketName(verified.Market), verified.Code, verified.Market is StockMarket.NxtKospi or StockMarket.NxtKosdaq, cut, - CloneSnapshot(detail)); + detail is null ? null : CloneSnapshot(detail), + raw is null ? null : CloneRawSnapshot(raw)); } private async Task CompleteSaveAsync( @@ -1066,7 +1414,9 @@ public sealed class LegacyManualFinancialWorkflow WriteEpoch = writeEpoch, SelectedRowId = null, Detail = null, + RawDetail = null, IsDetailFresh = false, + IsRawDetailFresh = false, Verified = null, VerifiedForRowVersion = null, Status = LegacyManualFinancialWorkflowStatus.ReadBackRequired, @@ -1085,7 +1435,9 @@ public sealed class LegacyManualFinancialWorkflow Rows = rows, SelectedRowId = resultId, Detail = detail, + RawDetail = null, IsDetailFresh = true, + IsRawDetailFresh = false, SelectedStockResultId = snapshot.SelectedStockResultId, Verified = request.VerifiedStock, VerifiedForRowVersion = detail.RowVersion, @@ -1097,16 +1449,92 @@ public sealed class LegacyManualFinancialWorkflow }; } + private async Task CompleteRawSaveAsync( + LegacyManualFinancialWorkflowSnapshot snapshot, + LegacyManualFinancialRawUpdateRequest request, + ManualFinancialMutationReceipt receipt, + long writeEpoch, + CancellationToken cancellationToken) + { + ManualFinancialRawSnapshot raw; + try + { + raw = ValidateRawSnapshot( + await RequireRawService().GetRawAsync( + request.Expected, + cancellationToken).ConfigureAwait(false), + request.Screen, + "raw post-save read-back"); + if (!EqualsIdentity(request.Replacement.Identity, raw.Record.Identity)) + { + throw InvalidData( + "The raw post-save read-back is not correlated to its write."); + } + } + catch (Exception exception) + { + return snapshot with + { + Revision = checked(snapshot.Revision + 1), + WriteEpoch = writeEpoch, + SelectedRowId = null, + Detail = null, + RawDetail = null, + IsDetailFresh = false, + IsRawDetailFresh = false, + Verified = null, + VerifiedForRowVersion = null, + Status = LegacyManualFinancialWorkflowStatus.ReadBackRequired, + WritesQuarantined = false, + LastMessage = + $"The write committed, but a fresh raw read-back is required: {exception.Message}", + LastRequestId = request.RequestId, + LastReceipt = receipt + }; + } + + var resultId = snapshot.SelectedRowId ?? NewOpaqueId("mf-row"); + var rows = snapshot.Rows.Any(row => string.Equals( + row.ResultId, + resultId, + StringComparison.Ordinal)) + ? ReplaceRow(snapshot.Rows, resultId, raw) + : SortRows( + snapshot.Rows.Append(new LegacyManualFinancialListRow(resultId, raw)), + snapshot.NameSortDirection); + return snapshot with + { + Revision = checked(snapshot.Revision + 1), + WriteEpoch = writeEpoch, + Rows = rows, + SelectedRowId = resultId, + Detail = raw.StrictSnapshot, + RawDetail = raw, + IsDetailFresh = raw.StrictSnapshot is not null, + IsRawDetailFresh = true, + Verified = snapshot.Verified, + VerifiedForRowVersion = snapshot.Verified is null ? null : raw.RowVersion, + Status = LegacyManualFinancialWorkflowStatus.WriteCommitted, + WritesQuarantined = false, + LastMessage = raw.IsPlayoutReady + ? null + : "기존 GraphE 원문 값으로 저장했습니다. 송출 시 장면 데이터 검증을 다시 수행합니다.", + LastRequestId = request.RequestId, + LastReceipt = receipt + }; + } + private static LegacyManualFinancialWorkflowSnapshot CompleteDelete( LegacyManualFinancialWorkflowSnapshot snapshot, LegacyManualFinancialWriteRequest request, ManualFinancialMutationReceipt receipt, long writeEpoch) { + var selectedRowId = snapshot.SelectedRowId; var rows = snapshot.Rows - .Where(row => !string.Equals( - row.StockName, - request.StockName, + .Where(row => selectedRowId is null || !string.Equals( + row.ResultId, + selectedRowId, StringComparison.Ordinal)) .ToArray(); return snapshot with @@ -1116,7 +1544,9 @@ public sealed class LegacyManualFinancialWorkflow Rows = Array.AsReadOnly(rows), SelectedRowId = null, Detail = null, + RawDetail = null, IsDetailFresh = false, + IsRawDetailFresh = false, StockQuery = string.Empty, StockCandidates = Array.Empty(), StockSearchIsTruncated = false, @@ -1142,9 +1572,12 @@ public sealed class LegacyManualFinancialWorkflow WriteEpoch = writeEpoch, Rows = Array.Empty(), ListIsTruncated = false, + ListRejectedItemCount = 0, SelectedRowId = null, Detail = null, + RawDetail = null, IsDetailFresh = false, + IsRawDetailFresh = false, StockQuery = string.Empty, StockCandidates = Array.Empty(), StockSearchIsTruncated = false, @@ -1251,6 +1684,10 @@ public sealed class LegacyManualFinancialWorkflow private bool IsQuarantined(out string? reason) => _writeSafety.IsQuarantined(out reason); + private IManualFinancialRawScreenService RequireRawService() => + _rawScreenService ?? throw new InvalidOperationException( + "Raw GraphE row compatibility is not available from this data service."); + private static ManualFinancialSnapshot RequireFreshDetail( LegacyManualFinancialWorkflowSnapshot snapshot) => snapshot.IsDetailFresh && snapshot.Detail is not null @@ -1258,6 +1695,13 @@ public sealed class LegacyManualFinancialWorkflow : throw new InvalidOperationException( "A fresh manual-financial detail read is required."); + private static ManualFinancialRawSnapshot RequireFreshRawDetail( + LegacyManualFinancialWorkflowSnapshot snapshot) => + snapshot.IsRawDetailFresh && snapshot.RawDetail is not null + ? snapshot.RawDetail + : throw new InvalidOperationException( + "A fresh raw manual-financial detail read is required."); + private static VerifiedManualFinancialStock RequireVerifiedForDetail( LegacyManualFinancialWorkflowSnapshot snapshot, ManualFinancialSnapshot detail) @@ -1279,6 +1723,28 @@ public sealed class LegacyManualFinancialWorkflow return snapshot.Verified; } + private static VerifiedManualFinancialStock RequireVerifiedForSelectedDetail( + LegacyManualFinancialWorkflowSnapshot snapshot, + ManualFinancialIdentity identity) + { + if (snapshot.Verified is null || + snapshot.SelectedRowVersion is not { } rowVersion || + !string.Equals( + snapshot.VerifiedForRowVersion, + rowVersion, + StringComparison.Ordinal) || + !string.Equals( + snapshot.Verified.Name, + identity.StockName, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The selected detail requires a fresh exact stock-master verification."); + } + + return snapshot.Verified; + } + private static LegacyManualFinancialListRow FindRow( IReadOnlyList rows, string resultId) @@ -1309,12 +1775,22 @@ public sealed class LegacyManualFinancialWorkflow : row) .ToArray()); + private static IReadOnlyList ReplaceRow( + IReadOnlyList rows, + string resultId, + ManualFinancialRawSnapshot detail) => + Array.AsReadOnly(rows.Select(row => + string.Equals(row.ResultId, resultId, StringComparison.Ordinal) + ? new LegacyManualFinancialListRow(resultId, detail) + : row) + .ToArray()); + private static IReadOnlyList UpsertRow( LegacyManualFinancialWorkflowSnapshot snapshot, ManualFinancialSnapshot detail, out string resultId) { - var existing = snapshot.Rows.SingleOrDefault(row => string.Equals( + var existing = snapshot.Rows.FirstOrDefault(row => string.Equals( row.StockName, detail.Record.Identity.StockName, StringComparison.Ordinal)); @@ -1368,6 +1844,97 @@ public sealed class LegacyManualFinancialWorkflow ? receipt.AffectedRows >= 0 : receipt.AffectedRows == 1); + private static ManualFinancialRawSnapshot ValidateRawSnapshot( + ManualFinancialRawSnapshot? snapshot, + ManualFinancialScreenKind screen, + string source) + { + try + { + ArgumentNullException.ThrowIfNull(snapshot); + var record = ValidateRawRecord(snapshot.Record, screen, nameof(snapshot)); + if (snapshot.RowVersion is null || snapshot.RowVersion.Length != 64 || + snapshot.RowVersion.Any(static character => + !char.IsAsciiDigit(character) && character is not (>= 'A' and <= 'F')) || + snapshot.Handle is null) + { + throw new ArgumentException("The raw row envelope is invalid."); + } + + ManualFinancialSnapshot? strict = null; + if (snapshot.StrictSnapshot is not null) + { + strict = ValidateSnapshot(snapshot.StrictSnapshot, screen, source); + if (!EqualsIdentity(strict.Record.Identity, record.Identity) || + !string.Equals( + strict.RowVersion, + snapshot.RowVersion, + StringComparison.Ordinal)) + { + throw new ArgumentException( + "The strict projection is not correlated to the raw row."); + } + } + + return new ManualFinancialRawSnapshot( + record, + snapshot.RowVersion, + snapshot.Handle, + strict); + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) + { + throw InvalidData($"The manual-financial {source} is invalid: {exception.Message}"); + } + } + + private static ManualFinancialRawRecord ValidateRawRecord( + ManualFinancialRawRecord? record, + ManualFinancialScreenKind screen, + string parameterName) + { + ArgumentNullException.ThrowIfNull(record, parameterName); + ArgumentNullException.ThrowIfNull(record.Identity, parameterName); + if (record.Identity.Screen != screen || + record.Identity.StockName is null || + record.Identity.StockName.Length is < 1 or > 200 || + record.Identity.StockName != record.Identity.StockName.Trim() || + record.StorageValues is null) + { + throw new ArgumentException( + "The raw manual-financial record identity is invalid.", + parameterName); + } + + var expectedCount = ManualFinancialCutContracts.Get(screen).StorageColumns.Count - 1; + if (record.StorageValues.Count != expectedCount) + { + throw new ArgumentException( + "The raw manual-financial record field count is invalid.", + parameterName); + } + + var values = new string[expectedCount]; + for (var index = 0; index < values.Length; index++) + { + var value = record.StorageValues[index]; + if (value is null || value.Length > 200 || + value.Any(static character => + char.IsSurrogate(character) || character == '\uFFFD')) + { + throw new ArgumentException( + "A raw manual-financial field is invalid.", + parameterName); + } + + values[index] = value; + } + + return new ManualFinancialRawRecord( + new ManualFinancialIdentity(screen, record.Identity.StockName), + Array.AsReadOnly(values)); + } + private static ManualFinancialSnapshot ValidateSnapshot( ManualFinancialSnapshot? snapshot, ManualFinancialScreenKind screen, @@ -1427,6 +1994,20 @@ public sealed class LegacyManualFinancialWorkflow private static ManualFinancialSnapshot CloneSnapshot(ManualFinancialSnapshot snapshot) => new(CloneRecord(snapshot.Record), snapshot.RowVersion); + private static ManualFinancialRawSnapshot CloneRawSnapshot( + ManualFinancialRawSnapshot snapshot) => + new( + new ManualFinancialRawRecord( + new ManualFinancialIdentity( + snapshot.Record.Identity.Screen, + snapshot.Record.Identity.StockName), + Array.AsReadOnly(snapshot.Record.StorageValues.ToArray())), + snapshot.RowVersion, + snapshot.Handle, + snapshot.StrictSnapshot is null + ? null + : CloneSnapshot(snapshot.StrictSnapshot)); + private static ManualFinancialRecord CloneRecord(ManualFinancialRecord record) { ArgumentNullException.ThrowIfNull(record); @@ -1553,6 +2134,26 @@ public sealed class LegacyManualFinancialWorkflow return normalized; } + private static string ValidateListStockName(string value) + { + if (value is null || value.Length is < 1 or > 200 || + value != value.Trim() || + value.Contains('^') || + value.Any(static character => + char.IsControl(character) || char.IsSurrogate(character) || + character == '\uFFFD' || + char.GetUnicodeCategory(character) is + System.Globalization.UnicodeCategory.Format or + System.Globalization.UnicodeCategory.LineSeparator or + System.Globalization.UnicodeCategory.ParagraphSeparator)) + { + throw InvalidData( + "The manual-financial unreadable-row identity is invalid."); + } + + return value; + } + private static bool EqualsIdentity( ManualFinancialIdentity left, ManualFinancialIdentity right) => diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualLists/LegacyManualListsWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualLists/LegacyManualListsWorkflow.cs index 206e675..0a96328 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualLists/LegacyManualListsWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualLists/LegacyManualListsWorkflow.cs @@ -124,6 +124,8 @@ public sealed record LegacyManualListsSnapshot( bool CanMaterializePlaylist) { public string? SelectedSearchResultId { get; init; } + + public bool SearchIsTruncated { get; init; } } public sealed record LegacyManualListPlaylistDraft( @@ -150,6 +152,7 @@ public sealed class LegacyManualListsWorkflow public const int MaximumPageCount = 20; public const int MaximumNetSellValueLength = 256; public const int MaximumSearchLength = 100; + public const int MaximumSearchResults = 10_000; private const string ViSnapshotSubject = "@MBN_VI_SNAPSHOT_V1"; private const string OutcomeUnknownMessage = @@ -169,6 +172,7 @@ public sealed class LegacyManualListsWorkflow private string? _viVersion; private string _searchQuery = string.Empty; private string? _selectedSearchResultId; + private bool _searchResultsAreTruncated; private string _statusMessage = string.Empty; private LegacyOperatorStatusKind _statusKind; private long _opaqueSequence; @@ -198,6 +202,7 @@ public sealed class LegacyManualListsWorkflow _searchResults.Clear(); _searchQuery = string.Empty; _selectedSearchResultId = null; + _searchResultsAreTruncated = false; await ReadNetSellAsync(cancellationToken).ConfigureAwait(false); return Current; } @@ -210,6 +215,7 @@ public sealed class LegacyManualListsWorkflow _searchResults.Clear(); _searchQuery = string.Empty; _selectedSearchResultId = null; + _searchResultsAreTruncated = false; await ReadViAsync(cancellationToken).ConfigureAwait(false); return Current; } @@ -221,6 +227,7 @@ public sealed class LegacyManualListsWorkflow _searchResults.Clear(); _searchQuery = string.Empty; _selectedSearchResultId = null; + _searchResultsAreTruncated = false; SetStatus(string.Empty, LegacyOperatorStatusKind.Neutral); return Current; } @@ -307,11 +314,11 @@ public sealed class LegacyManualListsWorkflow _searchQuery = query.Trim(); _searchResults.Clear(); _selectedSearchResultId = null; + _searchResultsAreTruncated = false; if (_screen != LegacyManualListScreen.Vi || - _searchQuery.Length == 0 || _searchQuery.Length > MaximumSearchLength) { - SetStatus("검색할 종목명을 입력하세요.", LegacyOperatorStatusKind.Warning); + SetStatus("검색어를 확인하세요.", LegacyOperatorStatusKind.Warning); return Current; } @@ -319,11 +326,17 @@ public sealed class LegacyManualListsWorkflow { var data = await _stockLookup.SearchAsync(_searchQuery, cancellationToken) .ConfigureAwait(false); - foreach (var displayName in data.DisplayNames.Take(200)) + foreach (var displayName in data.DisplayNames) { var identity = data.ResolveLastByDisplayName(displayName); if (identity is not null) { + if (_searchResults.Count == MaximumSearchResults) + { + _searchResultsAreTruncated = true; + break; + } + _searchResults.Add(new ViSearchResult( NextOpaqueId("manual-vi-result"), identity)); @@ -331,10 +344,14 @@ public sealed class LegacyManualListsWorkflow } SetStatus( - _searchResults.Count == 0 + _searchResultsAreTruncated + ? $"검색 결과가 {MaximumSearchResults:N0}건을 초과해 일부만 표시합니다." + : _searchResults.Count == 0 ? "검색 결과가 없습니다." : $"{_searchResults.Count}개 종목을 찾았습니다.", - LegacyOperatorStatusKind.Information); + _searchResultsAreTruncated + ? LegacyOperatorStatusKind.Warning + : LegacyOperatorStatusKind.Information); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -894,7 +911,8 @@ public sealed class LegacyManualListsWorkflow canSave, canMaterialize) { - SelectedSearchResultId = _selectedSearchResultId + SelectedSearchResultId = _selectedSearchResultId, + SearchIsTruncated = _searchResultsAreTruncated }; } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs index c5725f6..9351e35 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs @@ -1228,19 +1228,32 @@ public sealed class LegacyNamedPlaylistWorkflowController parameterName); } - var selection = new LegacySceneSelection( - ValidateCanonicalText( - item.Selection.GroupCode, - MaximumSelectionFieldLength, - allowEmpty: true, - allowCaret: false, - parameterName), - ValidateCanonicalText( + var groupCode = ValidateCanonicalText( + item.Selection.GroupCode, + MaximumSelectionFieldLength, + allowEmpty: true, + allowCaret: false, + parameterName); + var dataCode = ValidateCanonicalText( + item.Selection.DataCode, + MaximumSelectionFieldLength, + allowEmpty: true, + allowCaret: false, + parameterName); + var subject = CanPreserveClosedThemeSubject( + groupCode, + item.Selection.Subject, + dataCode) + ? item.Selection.Subject + : ValidateCanonicalText( item.Selection.Subject, MaximumSubjectFieldLength, allowEmpty: true, allowCaret: false, - parameterName), + parameterName); + var selection = new LegacySceneSelection( + groupCode, + subject, ValidateCanonicalText( item.Selection.GraphicType, MaximumSelectionFieldLength, @@ -1253,12 +1266,7 @@ public sealed class LegacyNamedPlaylistWorkflowController allowEmpty: true, allowCaret: false, parameterName), - ValidateCanonicalText( - item.Selection.DataCode, - MaximumSelectionFieldLength, - allowEmpty: true, - allowCaret: false, - parameterName)); + dataCode); var validated = new NamedPlaylistStoredItem( expectedIndex, item.IsEnabled, @@ -1301,6 +1309,33 @@ public sealed class LegacyNamedPlaylistWorkflowController return value; } + private static bool CanPreserveClosedThemeSubject( + string groupCode, + string subject, + string dataCode) + { + if (dataCode.Length is < 3 or > 8 || + dataCode.Any(character => !char.IsAsciiDigit(character))) + { + return false; + } + + var isNxt = groupCode is "테마_NXT" or "PAGED_NXT_THEME"; + if (!isNxt && groupCode is not ("테마" or "PAGED_THEME")) + { + return false; + } + + const string suffix = "(NXT)"; + var exactTitle = isNxt && subject.EndsWith(suffix, StringComparison.Ordinal) + ? subject[..^suffix.Length] + : isNxt ? string.Empty : subject; + return exactTitle.Length is >= 1 and <= 128 && + !string.IsNullOrWhiteSpace(exactTitle) && + !subject.Contains('^') && + IsSafeText(subject); + } + private static void ValidateProgramCode(string value) { if (value is null || value.Length != 8 || diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs index 1a06bf0..4a5e794 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs @@ -202,8 +202,9 @@ internal sealed class LegacyOperatorCatalogWriteSafetyState /// public sealed class LegacyOperatorCatalogWorkflowController { - public const int MaximumCatalogResults = 500; - public const int MaximumStockResults = 100; + public const int MaximumCatalogResults = LegacyThemeSelectionService.MaximumResults; + public const int MaximumExpertCatalogResults = LegacyExpertSelectionService.MaximumResults; + public const int MaximumStockResults = LegacyStockSearchService.MaximumResults; public const string ThemeNameDuplicateMessage = "이미 사용중인 테마명입니다."; public const string ThemeNameAvailableMessage = "사용 가능한 테마명입니다."; public const string ThemeSaveSuccessMessage = "ThemeA를 저장했습니다."; @@ -221,7 +222,7 @@ public sealed class LegacyOperatorCatalogWorkflowController private readonly IExpertSelectionService _expertSelectionService; private readonly IThemeCatalogPersistenceService _themePersistenceService; private readonly IExpertCatalogPersistenceService _expertPersistenceService; - private readonly IStockSearchService _stockSearchService; + private readonly IOperatorCatalogStockSearchService _stockSearchService; private readonly IStockMasterIdentityValidationService _identityValidationService; private readonly IOperatorCatalogSchemaValidationService _schemaValidationService; private readonly LegacyOperatorCatalogWriteSafetyState _writeSafety; @@ -258,7 +259,7 @@ public sealed class LegacyOperatorCatalogWorkflowController IExpertSelectionService expertSelectionService, IThemeCatalogPersistenceService themePersistenceService, IExpertCatalogPersistenceService expertPersistenceService, - IStockSearchService stockSearchService, + IOperatorCatalogStockSearchService stockSearchService, IStockMasterIdentityValidationService identityValidationService, IOperatorCatalogSchemaValidationService schemaValidationService) : this( @@ -278,7 +279,7 @@ public sealed class LegacyOperatorCatalogWorkflowController IExpertSelectionService expertSelectionService, IThemeCatalogPersistenceService themePersistenceService, IExpertCatalogPersistenceService expertPersistenceService, - IStockSearchService stockSearchService, + IOperatorCatalogStockSearchService stockSearchService, IStockMasterIdentityValidationService identityValidationService, IOperatorCatalogSchemaValidationService schemaValidationService, LegacyOperatorCatalogWriteSafetyState writeSafety) @@ -475,22 +476,22 @@ public sealed class LegacyOperatorCatalogWorkflowController _draftRows.Clear(); if (selected.ThemeIdentity is { } themeIdentity) { - var preview = await _themeSelectionService.PreviewAsync( + var stored = await _themePersistenceService.ReadStoredAsync( themeIdentity, - LegacyThemeSelectionService.MaximumPreviewItems, cancellationToken).ConfigureAwait(false); - if (preview.IsTruncated || preview.Identity != themeIdentity) + if (stored.IsTruncated || stored.Identity != themeIdentity) { throw new ThemeSelectionDataException( - "A complete, correlated ThemeA preview is required for editing."); + "A complete, correlated stored ThemeA definition is required for editing."); } - foreach (var item in preview.Items.OrderBy(static item => item.InputIndex)) + foreach (var item in stored.Items.OrderBy(static item => item.InputIndex)) { _draftRows.Add(DraftRowState.ForTheme( NewOpaqueId("catalog-draft"), item.ItemCode, - item.ItemName)); + item.ItemName, + requiresCurrentIdentityValidation: false)); } _expectedThemeIdentity = themeIdentity; @@ -595,6 +596,110 @@ public sealed class LegacyOperatorCatalogWorkflowController return CreateSnapshot(); } + /// + /// Changes the market of the legacy ThemeA create dialog without closing and + /// recreating it. The next code is resolved before any correlated editor state + /// is committed, so a failed read cannot leave a mixed market/code/draft state. + /// + public async Task ChangeCreateThemeMarketAsync( + ThemeMarket market, + string editorName, + CancellationToken cancellationToken = default) + { + EnsureOpenEntity(LegacyOperatorCatalogEntity.Theme); + if (_mode != LegacyOperatorCatalogMode.Create) + { + throw new InvalidOperationException( + "The ThemeA market can be changed only while creating a new item."); + } + + if (market is not (ThemeMarket.Krx or ThemeMarket.Nxt)) + { + throw new ArgumentOutOfRangeException(nameof(market)); + } + + var validatedEditorName = ValidateOptionalEditorText(editorName, 128); + if (!CanMutateCurrentEntity()) + { + return RejectUnavailableWrite(); + } + + if (market == _newThemeMarket) + { + _editorName = validatedEditorName; + ResetThemeNameAvailability(); + SetStatus( + "현재 ThemeA 시장을 유지했습니다.", + LegacyOperatorCatalogStatusKind.Information); + Touch(); + return CreateSnapshot(); + } + + try + { + // Keep this read ahead of every editor mutation. A provider failure must + // preserve the old market, code, rows, selection, and editor name exactly. + var nextCode = ValidateRequiredText( + await _themePersistenceService.SuggestNextCodeAsync( + market, + cancellationToken).ConfigureAwait(false), + 64); + cancellationToken.ThrowIfCancellationRequested(); + + var removedCount = _draftRows.RemoveAll(row => + !IsDraftRowCompatible(row, market)); + _newThemeMarket = market; + _codeLabel = nextCode; + _editorName = validatedEditorName; + _expectedThemeIdentity = null; + _expectedExpertIdentity = null; + if (_activeDraftRowId is not null && !_draftRows.Any(row => + string.Equals(row.RowId, _activeDraftRowId, StringComparison.Ordinal))) + { + _activeDraftRowId = _draftRows.FirstOrDefault()?.RowId; + } + + if (_selectedStockResultId is not null) + { + var selectedStock = _stockResults.SingleOrDefault(row => string.Equals( + row.ResultId, + _selectedStockResultId, + StringComparison.Ordinal)); + if (selectedStock is null || !IsCompatible(selectedStock.Item, market)) + { + _selectedStockResultId = null; + } + } + + ResetThemeNameAvailability(); + if (removedCount > 0) + { + SetStatus( + $"ThemeA 시장을 {ThemeMarketDisplayName(market)}로 변경하고 새 시장과 맞지 않는 초안 종목 {removedCount}개를 제외했습니다.", + LegacyOperatorCatalogStatusKind.Warning); + } + else + { + SetStatus( + $"ThemeA 시장을 {ThemeMarketDisplayName(market)}로 변경했습니다.", + LegacyOperatorCatalogStatusKind.Information); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + SetStatus( + "새 시장의 ThemeA 코드를 확인하지 못해 시장과 초안을 변경하지 않았습니다.", + LegacyOperatorCatalogStatusKind.Error); + } + + Touch(); + return CreateSnapshot(); + } + public async Task CheckThemeNameAvailabilityAsync( string editorName, CancellationToken cancellationToken = default) @@ -685,14 +790,26 @@ public sealed class LegacyOperatorCatalogWorkflowController CancellationToken cancellationToken = default) { EnsureDraftRowsEditable(); - _stockQuery = ValidateRequiredText(query, LegacyStockSearchService.MaximumQueryLength); + _stockQuery = ValidateOptionalStockQuery(query); _selectedStockResultId = null; try { - var result = await _stockSearchService.SearchAsync( - _stockQuery, - MaximumStockResults, - cancellationToken).ConfigureAwait(false); + var result = _stockQuery.Length == 0 + ? await _stockSearchService.SearchAllAsync( + MaximumStockResults, + cancellationToken).ConfigureAwait(false) + : await _stockSearchService.SearchAsync( + _stockQuery, + MaximumStockResults, + cancellationToken).ConfigureAwait(false); + if (result is null || + !string.Equals(result.Query, _stockQuery, StringComparison.Ordinal) || + result.Items is null || result.Items.Count > MaximumStockResults) + { + throw new InvalidDataException( + "The operator-catalog stock result is not correlated or bounded."); + } + _stockResults.Clear(); _stockResults.AddRange(result.Items.Select(item => new StockResultState( NewOpaqueId("catalog-stock"), @@ -757,7 +874,8 @@ public sealed class LegacyOperatorCatalogWorkflowController row = DraftRowState.ForTheme( NewOpaqueId("catalog-draft"), itemCode, - itemName); + itemName, + requiresCurrentIdentityValidation: true); } else { @@ -963,11 +1081,18 @@ public sealed class LegacyOperatorCatalogWorkflowController return RejectUnavailableWrite(); } - _editorName = _entity == LegacyOperatorCatalogEntity.Expert && - _mode == LegacyOperatorCatalogMode.Edit - ? (_expectedExpertIdentity ?? throw new InvalidOperationException( - "The trusted EList edit identity is missing.")).ExpertName - : ValidateRequiredText(editorName, 128); + _editorName = _entity switch + { + LegacyOperatorCatalogEntity.Expert when _mode == LegacyOperatorCatalogMode.Edit => + (_expectedExpertIdentity ?? throw new InvalidOperationException( + "The trusted EList edit identity is missing.")).ExpertName, + LegacyOperatorCatalogEntity.Theme when _mode == LegacyOperatorCatalogMode.Edit => + ValidateExistingThemeEditorName( + editorName, + _expectedThemeIdentity ?? throw new InvalidOperationException( + "The trusted ThemeA edit identity is missing.")), + _ => ValidateRequiredText(editorName, 128) + }; if (_entity == LegacyOperatorCatalogEntity.Theme) { @@ -1001,9 +1126,15 @@ public sealed class LegacyOperatorCatalogWorkflowController var expected = _expectedThemeIdentity ?? throw new InvalidOperationException( "The trusted ThemeA edit identity is missing."); + var newItems = _draftRows + .Where(static row => row.RequiresCurrentIdentityValidation) + .Select((row, index) => new ThemeCatalogItem(index, row.Code, row.Name)) + .ToArray(); return await ExecuteWriteAsync( ThemeSaveSuccessMessage, - token => _identityValidationService.ValidateThemeItemsAsync(items, token), + token => newItems.Length == 0 + ? Task.CompletedTask + : _identityValidationService.ValidateThemeItemsAsync(newItems, token), token => _themePersistenceService.ReplaceAsync( expected, _editorName, @@ -1278,7 +1409,7 @@ public sealed class LegacyOperatorCatalogWorkflowController { var result = await _expertSelectionService.SearchAsync( _query, - MaximumCatalogResults, + MaximumExpertCatalogResults, cancellationToken).ConfigureAwait(false); _query = result.Query; _resultsAreTruncated = result.IsTruncated; @@ -1303,7 +1434,7 @@ public sealed class LegacyOperatorCatalogWorkflowController new LegacyOperatorCatalogDraftRowView( row.RowId, _entity == LegacyOperatorCatalogEntity.Theme && - row.Code.Length > 0 && row.Code[0] is 'X' or 'T' && + _newThemeMarket == ThemeMarket.Nxt && !row.Name.EndsWith("(NXT)", StringComparison.Ordinal) ? row.Name + "(NXT)" : row.Name, @@ -1483,13 +1614,32 @@ public sealed class LegacyOperatorCatalogWorkflowController private bool IsCompatible(StockSearchItem item) => _entity switch { LegacyOperatorCatalogEntity.Expert => true, - LegacyOperatorCatalogEntity.Theme when _newThemeMarket == ThemeMarket.Krx => - item.Market is StockMarket.Kospi or StockMarket.Kosdaq, - LegacyOperatorCatalogEntity.Theme => - item.Market is StockMarket.NxtKospi or StockMarket.NxtKosdaq, + LegacyOperatorCatalogEntity.Theme => IsCompatible(item, _newThemeMarket), _ => false }; + private static bool IsCompatible(StockSearchItem item, ThemeMarket market) => market switch + { + ThemeMarket.Krx => item.Market is StockMarket.Kospi or StockMarket.Kosdaq, + ThemeMarket.Nxt => item.Market is StockMarket.NxtKospi or StockMarket.NxtKosdaq, + _ => false + }; + + private static bool IsDraftRowCompatible(DraftRowState row, ThemeMarket market) => + row.Code.Length > 0 && (market switch + { + ThemeMarket.Krx => row.Code[0] is 'P' or 'D', + ThemeMarket.Nxt => row.Code[0] is 'X' or 'T', + _ => false + }); + + private static string ThemeMarketDisplayName(ThemeMarket market) => market switch + { + ThemeMarket.Krx => "KRX", + ThemeMarket.Nxt => "NXT", + _ => throw new ArgumentOutOfRangeException(nameof(market)) + }; + private static string ThemeItemCode(StockSearchItem item) => item.Market switch { StockMarket.Kospi => "P" + item.Code, @@ -1540,6 +1690,55 @@ public sealed class LegacyOperatorCatalogWorkflowController return value; } + private static string ValidateOptionalEditorText(string value, int maximumLength) + { + ArgumentNullException.ThrowIfNull(value); + if (value.Length > maximumLength || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || + !IsSafeText(value, allowEmpty: true)) + { + throw new ArgumentException("A safe canonical editor value is required.", nameof(value)); + } + + return value; + } + + private static string ValidateOptionalStockQuery(string value) + { + ArgumentNullException.ThrowIfNull(value); + var normalized = value.Trim(); + if (value.Length > LegacyStockSearchService.MaximumQueryLength || + normalized.Length > LegacyStockSearchService.MaximumQueryLength || + !IsSafeText(value, allowEmpty: true) || + !IsSafeText(normalized, allowEmpty: true)) + { + throw new ArgumentException( + "A safe operator-catalog stock query is required.", + nameof(value)); + } + + return normalized; + } + + private static string ValidateExistingThemeEditorName( + string value, + ThemeSelectionIdentity expected) + { + if (!string.Equals(value, expected.ThemeTitle, StringComparison.Ordinal)) + { + return ValidateRequiredText(value, 128); + } + + ArgumentNullException.ThrowIfNull(value); + if (value.Length is < 1 or > 128 || string.IsNullOrWhiteSpace(value) || + !IsSafeText(value, allowEmpty: false)) + { + throw new ArgumentException("A safe stored theme name is required.", nameof(value)); + } + + return value; + } + private static bool IsSafeText(string value, bool allowEmpty) { if (!allowEmpty && value.Length == 0) @@ -1625,20 +1824,28 @@ public sealed class LegacyOperatorCatalogWorkflowController string Code, string Name, string MarketLabel, - decimal? BuyAmount) + decimal? BuyAmount, + bool RequiresCurrentIdentityValidation) { internal static DraftRowState ForTheme( string rowId, string code, - string name) => - new(rowId, code, name, ThemeMarketLabel(code), null); + string name, + bool requiresCurrentIdentityValidation) => + new( + rowId, + code, + name, + ThemeMarketLabel(code), + null, + requiresCurrentIdentityValidation); internal static DraftRowState ForExpert( string rowId, string code, string name, decimal? buyAmount) => - new(rowId, code, name, "종목", buyAmount); + new(rowId, code, name, "종목", buyAmount, false); private static string ThemeMarketLabel(string code) => code.Length > 0 ? code[0] switch diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs index 4ef8ebd..3d50572 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs @@ -179,6 +179,10 @@ public sealed class LegacyTradingHaltWorkflowController } _results = ValidateResult(result, normalizedQuery, generation); + // UC7 starts with an empty FarPoint sheet. Its first AddRows call + // makes row zero active, so the operator can press 현재가/120일 + // immediately after a successful search without another click. + _selected = _results.FirstOrDefault(); _isTruncated = result.IsTruncated; _status = LegacyTradingHaltRequestStatus.Ready; _errorMessage = string.Empty; diff --git a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs index a4145b4..750a196 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs @@ -127,6 +127,8 @@ public sealed record LegacyClearComparisonTargetsIntent : LegacyUiIntent; public sealed record LegacyAddComparisonPairIntent : LegacyUiIntent; +public sealed record LegacyImportComparisonPairsIntent : LegacyUiIntent; + public sealed record LegacySelectComparisonPairIntent(string RowId) : LegacyUiIntent; public sealed record LegacyMoveComparisonPairIntent(LegacyComparisonPairMoveDirection Direction) @@ -191,6 +193,10 @@ public sealed record LegacySelectOperatorCatalogResultIntent(string ResultId) : public sealed record LegacyBeginCreateThemeCatalogIntent(ThemeMarket Market) : LegacyUiIntent; +public sealed record LegacyChangeCreateThemeMarketIntent( + ThemeMarket Market, + string EditorName) : LegacyUiIntent; + public sealed record LegacyBeginCreateExpertCatalogIntent : LegacyUiIntent; public sealed record LegacySearchOperatorCatalogStocksIntent(string Query) : LegacyUiIntent; @@ -259,6 +265,9 @@ public sealed record LegacySelectManualFinancialStockIntent(string ResultId) : L public sealed record LegacySaveManualFinancialIntent(ManualFinancialRecord Record) : LegacyUiIntent; +public sealed record LegacySaveManualFinancialRawIntent(ManualFinancialRawRecord Record) + : LegacyUiIntent; + public sealed record LegacyDeleteManualFinancialIntent : LegacyUiIntent; public sealed record LegacyDeleteAllManualFinancialIntent : LegacyUiIntent; @@ -318,7 +327,9 @@ public sealed record LegacyCreateNamedPlaylistIntent(string Title, string Reques public sealed record LegacySaveCurrentNamedPlaylistIntent : LegacyUiIntent; -public sealed record LegacySaveCurrentNamedPlaylistToIntent(string DefinitionId) : LegacyUiIntent; +public sealed record LegacySaveCurrentNamedPlaylistToIntent( + string DefinitionId, + bool ShowSavedAlert = false) : LegacyUiIntent; public sealed record LegacyDeleteSelectedNamedPlaylistIntent(string RequestId) : LegacyUiIntent; @@ -328,27 +339,34 @@ public sealed record LegacyInteractionMetrics( int CutDragWidthDips, int CutDragHeightDips, uint SourceDpi = 96, - double HostRasterizationScale = 1d) + double HostRasterizationScale = 1d, + int DoubleClickTimeMilliseconds = 500) { public const uint CssPixelsPerInch = 96; public const int FallbackCutDragWidth = 4; public const int FallbackCutDragHeight = 4; + public const int FallbackDoubleClickTimeMilliseconds = 500; + public const int MaximumDoubleClickTimeMilliseconds = 5_000; public static LegacyInteractionMetrics Default { get; } = new( FallbackCutDragWidth, FallbackCutDragHeight, CssPixelsPerInch, - 1d); + 1d, + FallbackDoubleClickTimeMilliseconds); public static LegacyInteractionMetrics FromPixelsAtDpi( int cutDragWidthPixels, int cutDragHeightPixels, uint sourceDpi, - double hostRasterizationScale = 1d) => + double hostRasterizationScale = 1d, + int doubleClickTimeMilliseconds = FallbackDoubleClickTimeMilliseconds) => sourceDpi == 0 ? Default with { - HostRasterizationScale = NormalizeHostScale(hostRasterizationScale) + HostRasterizationScale = NormalizeHostScale(hostRasterizationScale), + DoubleClickTimeMilliseconds = NormalizeDoubleClickTime( + doubleClickTimeMilliseconds) } : new( ToDips( @@ -360,7 +378,8 @@ public sealed record LegacyInteractionMetrics( sourceDpi, FallbackCutDragHeight), sourceDpi, - NormalizeHostScale(hostRasterizationScale)); + NormalizeHostScale(hostRasterizationScale), + NormalizeDoubleClickTime(doubleClickTimeMilliseconds)); internal static LegacyInteractionMetrics Normalize(LegacyInteractionMetrics? metrics) => metrics is null @@ -373,13 +392,19 @@ public sealed record LegacyInteractionMetrics( ? metrics.CutDragHeightDips : FallbackCutDragHeight, metrics.SourceDpi > 0 ? metrics.SourceDpi : CssPixelsPerInch, - NormalizeHostScale(metrics.HostRasterizationScale)); + NormalizeHostScale(metrics.HostRasterizationScale), + NormalizeDoubleClickTime(metrics.DoubleClickTimeMilliseconds)); private static double NormalizeHostScale(double hostRasterizationScale) => double.IsFinite(hostRasterizationScale) && hostRasterizationScale > 0d ? hostRasterizationScale : 1d; + private static int NormalizeDoubleClickTime(int milliseconds) => + milliseconds is > 0 and <= MaximumDoubleClickTimeMilliseconds + ? milliseconds + : FallbackDoubleClickTimeMilliseconds; + private static int ToDips(int pixelsAtSourceDpi, uint sourceDpi, int fallback) => pixelsAtSourceDpi <= 0 ? fallback @@ -485,6 +510,7 @@ public static class LegacyBridgeProtocol "swap-comparison-targets" => ParseComparisonSwap(payload), "clear-comparison-targets" => ParseComparisonClear(payload), "add-comparison-pair" => ParseComparisonAdd(payload), + "import-legacy-comparison-pairs" => ParseComparisonImport(payload), "select-comparison-pair" => ParseComparisonPairSelection(payload), "move-comparison-pair" => ParseComparisonPairMove(payload), "delete-selected-comparison-pair" => ParseComparisonDeleteSelected(payload), @@ -530,6 +556,8 @@ public static class LegacyBridgeProtocol "search-operator-catalog" => ParseOperatorCatalogSearch(payload), "select-operator-catalog-result" => ParseOperatorCatalogResult(payload), "begin-create-theme-catalog" => ParseOperatorCatalogThemeCreate(payload), + "change-create-theme-market" => + ParseOperatorCatalogThemeMarketChange(payload), "begin-create-expert-catalog" => ParseOperatorCatalogEmpty( payload, static () => new LegacyBeginCreateExpertCatalogIntent()), @@ -591,6 +619,7 @@ public static class LegacyBridgeProtocol "search-manual-financial-stocks" => ParseManualFinancialStockSearch(payload), "select-manual-financial-stock" => ParseManualFinancialStockSelection(payload), "save-manual-financial" => ParseManualFinancialSave(payload), + "save-manual-financial-raw" => ParseManualFinancialRawSave(payload), "delete-manual-financial" => ParseManualFinancialEmpty( payload, static () => new LegacyDeleteManualFinancialIntent()), @@ -675,6 +704,7 @@ public static class LegacyBridgeProtocol row.IsEnabled, row.IsSelected, row.IsActive, + row.IsSceneResolved, row.MarketText, row.StockName, row.GraphicType, @@ -746,6 +776,7 @@ public static class LegacyBridgeProtocol snapshot.Overseas.Stock.Query, snapshot.Overseas.Stock.ResultCount, snapshot.Overseas.Stock.IsTruncated, + snapshot.Overseas.Stock.IsolatedInvalidRowCount, snapshot.Overseas.Stock.SelectedId, selectedName = snapshot.Overseas.Stock.Selected?.InputName, snapshot.Overseas.Stock.Error, @@ -782,10 +813,15 @@ public static class LegacyBridgeProtocol row.SelectionId, row.DisplayName, row.Metadata, - row.Kind + row.Kind, + row.CanSelect, + row.UnavailableReason }), snapshot.Comparison.DomesticSearch.SelectedResultId, snapshot.Comparison.DomesticSearch.TotalRowCount, + snapshot.Comparison.DomesticSearch.IsTruncated, + snapshot.Comparison.DomesticSearch.IsolatedInvalidRowCount, + snapshot.Comparison.DomesticSearch.DeferredUnsafeRowCount, snapshot.Comparison.DomesticSearch.ErrorMessage }, worldSearch = new @@ -797,10 +833,15 @@ public static class LegacyBridgeProtocol row.SelectionId, row.DisplayName, row.Metadata, - row.Kind + row.Kind, + row.CanSelect, + row.UnavailableReason }), snapshot.Comparison.WorldSearch.SelectedResultId, snapshot.Comparison.WorldSearch.TotalRowCount, + snapshot.Comparison.WorldSearch.IsTruncated, + snapshot.Comparison.WorldSearch.IsolatedInvalidRowCount, + snapshot.Comparison.WorldSearch.DeferredUnsafeRowCount, snapshot.Comparison.WorldSearch.ErrorMessage }, fixedTargets = snapshot.Comparison.FixedTargets.Select(row => new @@ -835,7 +876,17 @@ public static class LegacyBridgeProtocol snapshot.Comparison.SelectedPairId, snapshot.Comparison.Actions, snapshot.Comparison.StatusMessage, - snapshot.Comparison.StatusKind + snapshot.Comparison.StatusKind, + snapshot.Comparison.CanImportLegacyPairs, + lastImport = snapshot.Comparison.LastImport is null ? null : new + { + snapshot.Comparison.LastImport.SourceSha256, + snapshot.Comparison.LastImport.SourceRowCount, + snapshot.Comparison.LastImport.AddedPairCount, + snapshot.Comparison.LastImport.SkippedDuplicateCount, + snapshot.Comparison.LastImport.TotalPairCount, + snapshot.Comparison.LastImport.ImportedAt + } }, theme = snapshot.Theme is null ? null : new { @@ -1006,13 +1057,21 @@ public static class LegacyBridgeProtocol rows = snapshot.ManualFinancial.Rows.Select(row => new { row.ResultId, - row.StockName + row.StockName, + row.PreviewValues, + row.IsDataReadable, + row.IsPlayoutReady }), snapshot.ManualFinancial.ListIsTruncated, + snapshot.ManualFinancial.ListRejectedItemCount, snapshot.ManualFinancial.SelectedRowId, selectedRecord = ProjectManualFinancialRecord( snapshot.ManualFinancial.SelectedRecord), + selectedRawRecord = ProjectManualFinancialRawRecord( + snapshot.ManualFinancial.SelectedRawRecord), snapshot.ManualFinancial.IsDetailFresh, + snapshot.ManualFinancial.IsRawDetailFresh, + snapshot.ManualFinancial.IsPlayoutReady, snapshot.ManualFinancial.StockQuery, stockCandidates = snapshot.ManualFinancial.StockCandidates.Select(row => new { @@ -1030,6 +1089,7 @@ public static class LegacyBridgeProtocol snapshot.ManualFinancial.Status, snapshot.ManualFinancial.WritesQuarantined, snapshot.ManualFinancial.LastMessage, + snapshot.ManualFinancial.CanEdit, snapshot.ManualFinancial.CanSave, snapshot.ManualFinancial.CanDelete, snapshot.ManualFinancial.CanMaterializePlaylist, @@ -1084,6 +1144,7 @@ public static class LegacyBridgeProtocol row.MarketText }), snapshot.ManualLists.SelectedSearchResultId, + snapshot.ManualLists.SearchIsTruncated, snapshot.ManualLists.StatusMessage, snapshot.ManualLists.StatusKind, snapshot.ManualLists.CanSave, @@ -1160,7 +1221,9 @@ public static class LegacyBridgeProtocol coordinateSpace = "host-dip", sourceDpi = normalizedInteractionMetrics.SourceDpi, hostRasterizationScale = - normalizedInteractionMetrics.HostRasterizationScale + normalizedInteractionMetrics.HostRasterizationScale, + doubleClickTimeMilliseconds = + normalizedInteractionMetrics.DoubleClickTimeMilliseconds }, operatorSettings = operatorSettings is null ? null : new { @@ -1614,6 +1677,9 @@ public static class LegacyBridgeProtocol private static LegacyUiIntent? ParseComparisonAdd(JsonElement payload) => HasOnlyProperties(payload) ? new LegacyAddComparisonPairIntent() : null; + private static LegacyUiIntent? ParseComparisonImport(JsonElement payload) => + HasOnlyProperties(payload) ? new LegacyImportComparisonPairsIntent() : null; + private static LegacyUiIntent? ParseComparisonPairSelection(JsonElement payload) => HasOnlyProperties(payload, "rowId") && TryString(payload, "rowId", 128, out var rowId) && @@ -1841,13 +1907,40 @@ public static class LegacyBridgeProtocol : null; } + private static LegacyUiIntent? ParseOperatorCatalogThemeMarketChange(JsonElement payload) + { + if (!HasOnlyProperties(payload, "market", "editorName") || + !TryString(payload, "market", 8, out var marketText) || + !TrySafeString( + payload, + "editorName", + 128, + allowEmpty: true, + allowUnderscore: true, + out var editorName) || + !string.Equals(editorName, editorName.Trim(), StringComparison.Ordinal)) + { + return null; + } + + var market = marketText switch + { + "krx" => ThemeMarket.Krx, + "nxt" => ThemeMarket.Nxt, + _ => (ThemeMarket?)null + }; + return market.HasValue + ? new LegacyChangeCreateThemeMarketIntent(market.Value, editorName) + : null; + } + private static LegacyUiIntent? ParseOperatorCatalogStockSearch(JsonElement payload) => HasOnlyProperties(payload, "query") && TrySafeString( payload, "query", LegacyStockSearchService.MaximumQueryLength, - allowEmpty: false, + allowEmpty: true, allowUnderscore: true, out var query) ? new LegacySearchOperatorCatalogStocksIntent(query) @@ -2038,12 +2131,31 @@ public static class LegacyBridgeProtocol ? new LegacyLoadNamedPlaylistByIdIntent(definitionId) : null; - private static LegacyUiIntent? ParseNamedPlaylistSaveTo(JsonElement payload) => - HasOnlyProperties(payload, "definitionId") && - TryString(payload, "definitionId", 128, out var definitionId) && - IsSafeIdentifier(definitionId) - ? new LegacySaveCurrentNamedPlaylistToIntent(definitionId) - : null; + private static LegacyUiIntent? ParseNamedPlaylistSaveTo(JsonElement payload) + { + var hasSavedAlert = payload.TryGetProperty("showSavedAlert", out var alertProperty); + if (!(hasSavedAlert + ? HasOnlyProperties(payload, "definitionId", "showSavedAlert") + : HasOnlyProperties(payload, "definitionId")) || + !TryString(payload, "definitionId", 128, out var definitionId) || + !IsSafeIdentifier(definitionId)) + { + return null; + } + + var showSavedAlert = false; + if (hasSavedAlert) + { + if (alertProperty.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + { + return null; + } + + showSavedAlert = alertProperty.GetBoolean(); + } + + return new LegacySaveCurrentNamedPlaylistToIntent(definitionId, showSavedAlert); + } private static LegacyUiIntent? ParseNamedPlaylistCreate(JsonElement payload) => HasOnlyProperties(payload, "title", "requestId") && @@ -2138,7 +2250,7 @@ public static class LegacyBridgeProtocol payload, "stockName", 64, - allowEmpty: false, + allowEmpty: true, allowUnderscore: true, out var stockName) ? new LegacySearchManualFinancialStocksIntent(stockName) @@ -2147,7 +2259,7 @@ public static class LegacyBridgeProtocol private static LegacyUiIntent? ParseManualFinancialStockSelection(JsonElement payload) => HasOnlyProperties(payload, "resultId") && TryString(payload, "resultId", 128, out var resultId) && - IsSafeIdentifier(resultId) + (resultId.Length == 0 || IsSafeIdentifier(resultId)) ? new LegacySelectManualFinancialStockIntent(resultId) : null; @@ -2164,6 +2276,64 @@ public static class LegacyBridgeProtocol return record is null ? null : new LegacySaveManualFinancialIntent(record); } + private static LegacyUiIntent? ParseManualFinancialRawSave(JsonElement payload) + { + if (!HasOnlyProperties(payload, "record") || + !payload.TryGetProperty("record", out var recordElement) || + recordElement.ValueKind != JsonValueKind.Object || + !HasOnlyProperties( + recordElement, + "screen", + "stockName", + "storageValues") || + !TryString(recordElement, "screen", 32, out var screenText) || + !TryManualFinancialScreen(screenText, out var screen) || + !TrySafeString( + recordElement, + "stockName", + 200, + allowEmpty: false, + allowUnderscore: true, + out var stockName) || + stockName != stockName.Trim() || stockName.Contains('^') || + !recordElement.TryGetProperty("storageValues", out var storageElement) || + storageElement.ValueKind != JsonValueKind.Array) + { + return null; + } + + var expectedCount = ManualFinancialCutContracts.Get(screen).StorageColumns.Count - 1; + if (storageElement.GetArrayLength() != expectedCount) + { + return null; + } + + var values = new string[expectedCount]; + var index = 0; + foreach (var item in storageElement.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.String) + { + return null; + } + + var value = item.GetString() ?? string.Empty; + if (value.Length > 200 || + value.Any(static character => + char.IsSurrogate(character) || character == '\uFFFD')) + { + return null; + } + + values[index++] = value; + } + + return new LegacySaveManualFinancialRawIntent( + new ManualFinancialRawRecord( + new ManualFinancialIdentity(screen, stockName), + Array.AsReadOnly(values))); + } + private static LegacyUiIntent? ParseManualFinancialAction(JsonElement payload) => HasOnlyProperties(payload, "actionId") && TryString(payload, "actionId", 64, out var actionId) && @@ -2240,7 +2410,7 @@ public static class LegacyBridgeProtocol payload, "query", LegacyManualListsWorkflow.MaximumSearchLength, - allowEmpty: false, + allowEmpty: true, allowUnderscore: true, out var query) ? new LegacySearchManualViIntent(query) @@ -2610,6 +2780,16 @@ public static class LegacyBridgeProtocol _ => throw new ArgumentOutOfRangeException(nameof(record)) }; + private static object? ProjectManualFinancialRawRecord( + ManualFinancialRawRecord? record) => record is null + ? null + : new + { + screen = ManualFinancialScreenId(record.Identity.Screen), + stockName = record.Identity.StockName, + storageValues = record.StorageValues + }; + private static bool TrySafeString( JsonElement element, string propertyName, diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj index 0fd5c11..334cdf5 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj @@ -37,6 +37,10 @@ + + + + @@ -73,6 +77,16 @@ + + + + + + @@ -106,6 +120,12 @@ PreserveNewest PreserveNewest + + Web\Previews\%(Filename)%(Extension) + Web\Previews\%(Filename)%(Extension) + PreserveNewest + PreserveNewest +