diff --git a/LegacySceneRuntimeFactory.cs b/LegacySceneRuntimeFactory.cs index de0c283..63d7687 100644 --- a/LegacySceneRuntimeFactory.cs +++ b/LegacySceneRuntimeFactory.cs @@ -510,13 +510,10 @@ internal static class LegacySceneRuntimeFactory "The foreign candle target is invalid.") }; if (parts.Length != 3 || - parts[0].Length is < 1 or > 64 || - parts[0].Any(character => char.IsControl(character) || char.IsSurrogate(character)) || + !LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol(parts[0]) || parts[1].Length != 2 || parts[1].Any(character => character is < 'A' or > 'Z') || - !string.Equals(parts[2], expectedType, StringComparison.Ordinal) || - target == CandleMarketTarget.OverseasStock && - parts[1] is not ("US" or "TW")) + !string.Equals(parts[2], expectedType, StringComparison.Ordinal)) { throw new LegacySceneDataException( "The foreign candle identity is invalid."); diff --git a/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md b/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md index 0230ce4..a522a3a 100644 --- a/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md +++ b/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md @@ -233,7 +233,9 @@ - Busy/고정 섹션 배치 중에는 action뿐 아니라 로컬 disclosure 변경도 차단한다. - MA5/MA20은 행 추가 시 저장하지 않고 s8010 PREPARE, F8, NEXT, 자동 갱신의 장면 생성 시점마다 현재 전역 체크 상태를 다시 읽는다. named playlist identity에도 MA 플래그를 저장하지 않는다. -- 원본의 `uc_3 == null` 부모 더블클릭 예외와 부분 추가 버그는 재현하지 않는 명시적 결함 제외다. +- 공용 UC1의 부모 일괄 추가 중 부분 mutation은 재현하지 않는다. 다만 업종 탭의 부모는 실제로 + 22개 루프 전에 `uc_3 == null` 예외가 발생해 아무 행도 추가하지 않으므로, 이 항목은 Slice 12의 + 재감사 결과에 따라 실제 관찰 동작인 no-op으로 고정했다. 상세 근거와 결함 제외는 `docs/UC1_TREE_EVENT_PARITY.md`에 기록했다. @@ -329,11 +331,198 @@ 비활성화하고 API 실패나 0 이하는 축별 4 DIP로 fail-safe한다. 실제 drop 내용은 payload가 아니라 C#의 현재 선택 집합과 physical 순서가 계속 권위본이다. +## Slice 11: UC3 종목 비교 중복행과 전체 삭제 + +상태: 코드 및 자동 테스트 완료, 실제 파일·DB·Tornado 명령은 실행하지 않음. + +원본 근거: + +- `Control/UC3.cs:100-145` 비교쌍 추가와 `File_Save` +- `Control/UC3.cs:147-154` 선택 행 삭제 +- `Control/UC3.cs:156-168` 전체 삭제 Yes/No +- `Control/UC3.cs:171-183` Up/Down 저장 + +고정한 계약: + +- 원본 `btnAdd_Click`에는 중복 검사가 없으므로 같은 두 대상을 같은 순서로 여러 번 추가할 수 + 있다. 각 행은 별도 stable row ID를 가지며, 이동·선택 삭제는 그중 활성 행 하나에만 적용한다. +- 화면의 비교 조작 표시는 원본 Designer와 같은 `◀▶`, `추가`, `UP`, `DOWN`, `DEL`, + `DEL ALL`을 사용하고, 원본에 없던 별도 초기화 버튼은 표시하지 않는다. +- CP949 parser/import도 같은 비교쌍의 반복을 허용하되 물리 순서, 연속 RowNumber, 최대 행 수와 + 형식·인코딩 검증은 유지한다. 저장 후 재로드해도 반복 행과 순서가 보존된다. +- `DEL ALL`은 Web의 `window.confirm`이나 직접 파일 변경으로 처리하지 않는다. C#이 정확한 + `MmoneyCoder` caption과 `모두 삭제하겠습니까?` 문구의 Yes/No 상태를 소유하며, No는 + 0회, Yes는 confirmation을 먼저 소비한 뒤 정확히 1회만 mutation한다. +- confirmation 동안에는 화면 재연결용 ready와 confirm/cancel 이외의 bridge intent를 모두 + 무시한다. 이는 원본의 modal `MessageBox`가 MainForm과 다른 child action을 막던 동작을 + native 경계에서도 보장한다. +- Yes/No bridge payload는 빈 객체만 허용하며 row ID, 종목 identity 또는 action kind를 받지 + 않는다. 삭제 대상 목록의 revision이 바뀌면 저장하지 않는다. + +재현하지 않은 원본 버그: + +- 원본 `File_Save`는 RowCount가 0이면 파일을 쓰지 않아 마지막/전체 삭제가 재시작 뒤 되살아난다. + 새 구현은 빈 CP949 파일을 원자적으로 1회 저장해 삭제를 영속화한다. 이는 기존 one-time import와 + 재시작 영속성 계약을 깨지 않기 위한 명시적 데이터 안전 교정이다. + +## Slice 12: UC2 업종 목록·비교 입력과 업종 UC1 단일 루트 + +상태: 코드 및 자동 테스트 완료, 실제 DB/Tornado/UI 명령은 실행하지 않음. + +원본 근거: + +- `Control/UC2.cs:32-76` 목록 조회, 즉시 선택, double-click 회전과 텍스트 swap +- `Control/UC2.Designer.cs:43-106` 목록 위/비교 입력 아래 배치와 편집 가능한 txt1/txt2 +- `Control/UC1.cs:154-180`, `204-220`, `330-351` 업종 루트·리프와 1열/2열 조건 +- `MainForm.cs:2966-2985` 업종 UC1을 `uc_3 == null`로 생성하는 실제 경로 + +고정한 계약: + +- 업종 액션 트리는 원본 INI처럼 `[코스피]` 또는 `[코스닥]` 루트 하나와 원본 순서의 자식 + 22개만 표시한다. 기존 Web의 인위적인 5개 그룹은 제거했다. +- 업종 루트 double-click은 공용 UC1이 22개 루프 전에 null인 `uc_3`를 읽고 바깥 catch가 + 예외를 삼키므로 실제 바이너리처럼 조용한 no-op이다. 자식 double-click만 한 컷을 추가한다. +- 목록 single-click은 지연 없이 C# 활성행을 바꾸고, state 회신은 기존 DOM을 교체하지 않아 + 같은 행의 두 번째 click과 double-click을 잃지 않는다. +- 목록 전체가 하나의 tab stop이며 ArrowUp/Down, Home/End가 원본 ListBox처럼 활성행을 즉시 + 바꾼다. 행 버튼은 개별 Tab 순서에 들어가지 않는다. +- 화면 배치는 목록, `업종비교`, txt1/교환/txt2 순서이고 교환 버튼은 원본 `◀▶` 표기다. +- txt1/txt2는 편집 가능하다. Web은 slot/value만 닫힌 intent로 보내며 C#의 `FirstText`와 + `SecondText`가 권위 상태를 가진다. action intent에는 입력 문자열을 섞지 않는다. +- 목록 double-click은 수동 txt1을 txt2로 민 뒤 새 업종명을 txt1에 넣고, 교환은 한 칸만 + 채워진 경우까지 두 문자열을 그대로 바꾼다. 1열판은 계속 활성 목록 행을 사용하고 2열판만 + 두 편집 문자열을 사용한다. +- 입력 state 회신은 focused input을 덮거나 workspace를 교체하지 않아 blur 직후 swap이나 + 자식 double-click이 사라지지 않는다. +- 원본 쿼리가 돌려준 중복 이름·코드 행은 500행 안전 상한 안에서 순서 그대로 표시하고 클릭한 + 인덱스로 구분한다. 장면 데이터 조회의 exact-one-row 검증은 유지해 모호한 결과는 PREPARE 전에 + fail closed한다. + +명시적 안전 교정: + +- 원본 TextBox 기본 최대 길이 대신 실제 scene selector 경계인 128자로 제한하고 control, + surrogate, U+FFFD를 거부한다. 편집 중 빈 값은 허용하되 2열판 생성 때 공백뿐인 값과 subject + 구분자인 `-`가 든 값은 거부한다. SQL은 계속 parameterized equality로만 실행된다. + +## Slice 13: UC4 테마 검색·세션·미리보기 + +상태: 코드 및 자동 테스트 완료, 실제 DB/Tornado/UI 명령은 실행하지 않음. + +원본 근거: + +- `Control/UC4.cs:109-247`, `429-439` 테마 검색, 선택, 현재가·예상체결가와 6·12종목 버튼 +- `Control/UC4.Designer.cs` 검색 → 결과 → 정렬 → 액션 → 종목 미리보기의 세로 배치 +- `Control/UC4.cs` NXT 조회의 현재 시각 기준 오전/오후 세션 선택 + +고정한 계약: + +- UC4 검색 intent는 검색어만 받는다. 원본 화면에 없던 NXT 세션 선택기는 제거했고, C#이 로컬 + 시각 0~12시에는 장전, 13~23시에는 장후 세션을 탭 진입과 검색 시점마다 결정한다. +- 화면은 검색 입력, 테마 결과, 정렬 라디오, `6종목`, `12종목`, `현재가`, `예상체결가`, 종목 + 미리보기 순서의 한 열 구조다. 결과·미리보기의 인위적인 좌우 2열 구조는 제거했다. +- 검색 결과는 원본처럼 KRX 결과 다음 NXT 결과이며 각 DB 결과 순서를 그대로 유지한다. Web은 + 결과 ID와 action ID만 전송하고 테마 코드·종목 코드·세션을 만들지 않는다. +- 결과 클릭은 C# receipt를 기다리지 않고 즉시 선택 표시를 바꾸되 opaque ID를 정확히 한 번만 + 보낸다. 동일 구조의 receipt는 결과 DOM을 교체하지 않아 연속 클릭과 focus를 잃지 않는다. +- NXT 테마의 예상체결가 버튼은 Web에서 임의로 비활성화하지 않는다. 클릭하면 원본과 같은 + `NXT는 데이터가 없습니다.` 안내를 native 경계에서 표시하고 플레이리스트 mutation은 0회다. +- 플레이리스트에는 원본의 표시 열과 내부 scene identity를 분리해 저장하고, 미리보기가 0행인 + 선택으로는 빈 컷을 만들지 않는다. + +명시적 안전 교정: + +- DB 검색·미리보기 결과는 generation과 opaque selection ID로 연결한다. 늦게 끝난 이전 검색이 + 새 결과나 선택을 덮지 못하며, 동일 이름이 모호하거나 미리보기가 비어 있으면 fail closed한다. + +## Slice 14: UC5 해외지수·해외업종·해외종목 + +상태: 코드 및 자동 테스트 완료, Debug/Release x64와 unsigned MSIX 생성 완료. 실제 DB/Tornado +명령은 실행하지 않음. + +원본 근거: + +- `Control/UC5.cs:33-47`, `50-137`, `141-243` 초기 목록, 검색, 선택과 컷 추가 순서 +- `Control/UC5.Designer.cs` 해외지수 → 해외 업종지수 → 해외 종목의 세로 배치와 액션 버튼 +- `Data/Request/봉차트/CandleforeignStock.cs` 해외종목 봉차트의 이름·심볼·국가 조건 +- `Data/Request/판데이터기본/종목/STOCK_FORGIEN_PAN.cs` 해외종목 1열판의 정확한 종목 identity + +고정한 계약: + +- 화면은 원본과 같은 세로 3그룹이며, 별도 action tree·상세 패널·선택 개수·국가 코드는 + 표시하지 않는다. 검색 Enter, 즉시 선택, 한 번의 Tab 진입과 ArrowUp/Down/Home/End 선택을 + 지원한다. +- 빈 검색은 원본처럼 미국·대만 초기 목록만 조회하고, 검색어가 있으면 `F_FDTC='1'`인 모든 + 해외 국가를 이름으로 조회한다. DB 순서와 같은 이름의 서로 다른 종목을 그대로 보존한다. +- 원본 FarPoint `SheetView`는 `ActiveRowIndex=-1`, `RowCount=0`에서 첫 `AddRows` 직후 활성행을 + 0으로 바꾼다. 고정 지수와 검색 결과의 첫 행을 같은 방식으로 자동 활성화해 별도 선행 클릭 + 없이 원본처럼 액션 버튼을 사용할 수 있다. +- 원본에는 결과 상한이 없으므로 기존 100건 기본 상한을 데이터 경계의 안전 상한인 500건으로 + 넓혔다. 500건을 넘으면 조용히 숨기지 않고 목록 제목에 `검색 결과가 많아 500건까지만 표시합니다.`를 + 표시한다. +- Web에는 generation에 묶인 opaque selection ID와 표시명만 전달한다. 심볼·국가·data code는 + C# 권위 상태에만 보관하며 action intent에는 포함하지 않는다. +- 플레이리스트 표시 열은 원본처럼 `해외지수/해외업종/해외종목`, 종목명, + `1열판기본` 또는 `5/20/60/120/240일`, `1/1`만 사용한다. +- 해외종목은 내부 `symbol|nation|1` identity로 5001 봉차트와 8010 1열판에 전달해 같은 + 표시명도 정확한 심볼·국가로 조회한다. 기존 이름만 저장된 레거시 행은 미국·대만 name-only + 조회와 계속 호환한다. +- 현재 K3D 장면 경계가 처리하는 두 글자 국가 코드는 전 국가에 대해 실행 가능하다. 세 글자 + 국가 코드 결과는 목록에는 보존하되 컷 생성 단계에서 명시적으로 fail closed한다. +- 동일 구조의 native 회신은 목록 DOM과 focus를 교체하지 않으며, 늦게 끝난 검색은 새로운 + generation의 결과나 선택을 덮지 못한다. + +명시적 안전 교정: + +- 원본의 이름 단독 장면 조회가 같은 이름의 여러 국가·심볼을 혼동할 수 있어, 신규 행은 정확한 + 내부 identity를 필수로 사용한다. 이 identity는 WebView 경계를 통과하지 않는다. +- 지원하지 않는 국가 코드, 모호한 name-only 결과, 빈 선택은 PREPARE 전에 차단한다. + +## Slice 15: Release 패키지 컨텍스트와 실제 개발 환경 사전 검증 + +상태: Release x64 개발 등록 패키지의 UI-only/DryRun 검증, 실제 개발 DB SELECT-only 검증, +원본 Cuts 범위 검사와 K3D/Tornado 읽기 전용 사전 검사를 완료했다. COM/KTAP 송출 명령과 DB write는 +실행하지 않았으며 검증 뒤 `MBN_STOCK_WEBVIEW` 프로세스는 남아 있지 않다. + +검증 결과: + +- 패키지는 정확한 `Wickedness.MBNStockWebView.LegacyParity_0.1.0.0_x64__qbv3jkvsn3aj0` Release x64 + 개발 등록에서 실행했다. URL은 `https://legacy-parity.mbn.local/index.html`, 상태는 `DRY RUN`/`IDLE`였고, + guard 이후 native postMessage, mouse/keyboard 입력, DB read/write intent, 단일 인스턴스 재활성화와 + Tornado 포트 연결은 모두 0건이었다. 중립 종료 대화상자의 기본 종료 동작을 정확히 1회 사용해 정상 종료했다. +- 패키지 증거 JSON은 + `C:\Users\MD\source\repos\MBN_STOCK_WEBVIEW\artifacts\package-ui-only-smoke\legacy-package-ui-only-20260718T011058894Z.json` + (SHA-256 `2AD2C06EB14DB9684548A5D086B9FA4EC53E504D6722400E1F16B6E600BCD341`)이고, + 화면 캡처는 같은 폴더의 `legacy-package-ui-only-20260718T011058894Z.png` + (SHA-256 `1CC02BEE872A15E64D4A74F8E489A212B4547464FB27784C4975E523B5132806`)이다. +- 생성된 MSIX는 + `C:\Users\MD\source\repos\MBN_STOCK_WEBVIEW\src\MBN_STOCK_WEBVIEW.LegacyParityApp\AppPackages\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64_Test\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64.msix` + (SHA-256 `3F68625C0D2ACBBFD673F2F2869944C5818A8732E94A91BA7E5C7F2707458228`)이며, + 금지된 외부 자산·비밀 값은 포함하지 않았다. +- 실제 개발 Oracle/MariaDB에 SELECT-only smoke를 실행해 Oracle 21 연결, MariaDB 연결, 종목·해외 선택기, + 전문가·거래정지·PList 조회를 확인했다. 필수 scene data 33/33, 전체 query 58개가 통과했고 DB write는 0건이다. + 현재 NXT theme preview 48건이 빈 결과인 것은 실제 데이터 상태이며 코드 실패로 판정하지 않는다. +- 원본 읽기 전용 Cuts의 reachable builder 34개와 active alias 45개를 검사했다. `.t2s` 누락·unsafe path·root escape는 + 모두 0건이다. 의존 자산은 23개 중 14개가 없으며, `s5006 Video\큐브배경.vrv` 1개와 + `s6001 Video\20201008_<13 countries>.vrv` 13개 패턴이다. 따라서 5001은 실제 검증 후보로 유지하고 + 5006/6001은 자산 제공 전까지 차단한다. +- K3D 등록은 HKLM Registry64, native AMD64 type library와 KAEngine/KAEventHandler 상호 매핑이 모두 유효했다. + 현재 `Tornado2 v2.7.5.49 x64 DSX10.0 S.R2`의 PID 41308, 제목 `PGM`, listener + `0.0.0.0:30001`을 읽기 전용으로 확인했다. COM activation, CONNECT, PREPARE, TAKE IN, NEXT, + TAKE OUT과 DISCONNECT는 실행하지 않았다. + +남은 실제 검증: + +- 현재 PGM의 5001 page 1 검증은 이전 승인을 재사용하지 않고 새 회차·계획 해시로 승인받는다. + 먼저 CONNECT/PREPARE의 known outcome과 Network Monitoring 기록을 확인하고, 그 뒤 TAKE IN/PGM 화면/ + TAKE OUT은 허용 횟수를 명시한 별도 승인 범위에서만 실행한다. 결과가 불명확하면 재시도하지 않는다. +- Network Monitoring 기록과 PGM 출력은 사용자가 함께 확인한 증거를 남긴다. +- 5006/6001은 누락된 외부 동영상 자산이 안전하게 제공되고 coverage가 다시 통과하기 전에는 실제 검증하지 않는다. + ## 다음 대조 순서 -1. 새 package에서 실제 마우스·키보드·컷/playlist drag 회귀 -2. ThemeA/EList 편집 save/delete/close의 세부 확인 문구와 focus 순서 대조 -3. 자동 갱신 UI의 실제 실행 상태 기반 통합 테스트와 종료 상태 행렬 -4. 다음 Designer handler 묶음의 원본 코드·상태 전이 대조 +1. 새 회차 승인으로 현재 PGM 대상 5001 page 1 CONNECT/PREPARE known outcome 검증 +2. Network Monitoring과 PGM 출력 확인 뒤 별도 승인 범위의 TAKE IN/TAKE OUT 검증 +3. s5006/s6001 외부 동영상 자산 보완과 Cuts coverage 재검증 +4. 전체 근거 검토 뒤 변경 사항 커밋·원격 반영 각 항목은 실제 원본 코드 근거와 회귀 테스트가 모두 생기기 전에는 완료로 판정하지 않는다. diff --git a/scripts/Invoke-LegacyPackageInputSmoke.ps1 b/scripts/Invoke-LegacyPackageInputSmoke.ps1 new file mode 100644 index 0000000..dbdae26 --- /dev/null +++ b/scripts/Invoke-LegacyPackageInputSmoke.ps1 @@ -0,0 +1,2542 @@ +[CmdletBinding()] +param( + [ValidateRange(1024, 65535)] + [int]$Port = 9339, + + [string]$Output, + + [string]$Screenshot, + + [string]$NodePath, + + [ValidateRange(10, 120)] + [int]$LaunchTimeoutSeconds = 30, + + [ValidateRange(30, 300)] + [int]$HarnessTimeoutSeconds = 180 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; + +public static class LegacyPackageInputNative +{ + [StructLayout(LayoutKind.Sequential)] + private struct Point + { + public int X; + public int Y; + } + + [StructLayout(LayoutKind.Sequential)] + private struct Rect + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + private struct ClientGeometry + { + public int ScreenLeft; + public int ScreenTop; + public int Width; + public int Height; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MibTcpRowOwnerPid + { + public uint State; + public uint LocalAddress; + public uint LocalPort; + public uint RemoteAddress; + public uint RemotePort; + public uint OwningProcessId; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MibTcp6RowOwnerPid + { + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] + public byte[] LocalAddress; + public uint LocalScopeId; + public uint LocalPort; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] + public byte[] RemoteAddress; + public uint RemoteScopeId; + public uint RemotePort; + public uint State; + public uint OwningProcessId; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MouseInput + { + public int Dx; + public int Dy; + public uint MouseData; + public uint Flags; + public uint Time; + public UIntPtr ExtraInfo; + } + + [StructLayout(LayoutKind.Sequential)] + private struct KeyboardInput + { + public ushort VirtualKey; + public ushort ScanCode; + public uint Flags; + public uint Time; + public UIntPtr ExtraInfo; + } + + [StructLayout(LayoutKind.Explicit)] + private struct InputUnion + { + [FieldOffset(0)] public MouseInput Mouse; + [FieldOffset(0)] public KeyboardInput Keyboard; + } + + [StructLayout(LayoutKind.Sequential)] + private struct Input + { + public uint Type; + public InputUnion Union; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern int GetPackageFullName( + IntPtr process, + ref int packageFullNameLength, + StringBuilder packageFullName); + + [DllImport("kernel32.dll")] + private static extern uint GetCurrentThreadId(); + + [DllImport("iphlpapi.dll", SetLastError = true)] + private static extern uint GetExtendedTcpTable( + IntPtr table, + ref int size, + bool order, + int ipVersion, + int tableClass, + uint reserved); + + [DllImport("user32.dll")] + private static extern bool ClientToScreen(IntPtr window, ref Point point); + + [DllImport("user32.dll")] + private static extern bool GetCursorPos(out Point point); + + [DllImport("user32.dll")] + private static extern IntPtr WindowFromPoint(Point point); + + [DllImport("user32.dll")] + private static extern IntPtr GetAncestor(IntPtr window, uint flags); + + [DllImport("user32.dll")] + private static extern bool GetClientRect(IntPtr window, out Rect rectangle); + + [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")] + private static extern bool IsIconic(IntPtr window); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool AttachThreadInput(uint from, uint to, bool attach); + + [DllImport("user32.dll")] + private static extern int GetSystemMetrics(int index); + + [DllImport("user32.dll")] + private static extern uint SendInput(uint count, Input[] inputs, int size); + + [DllImport("user32.dll")] + private static extern short GetAsyncKeyState(int virtualKey); + + private const int ErrorInsufficientBuffer = 122; + private const int AddressFamilyInterNetwork = 2; + private const int AddressFamilyInterNetworkV6 = 23; + private const int TcpTableOwnerPidAll = 5; + private const uint GetAncestorRoot = 2; + private const uint InputMouse = 0; + private const uint InputKeyboard = 1; + private const uint MouseMove = 0x0001; + private const uint MouseLeftDown = 0x0002; + private const uint MouseLeftUp = 0x0004; + private const uint MouseMoveNoCoalesce = 0x2000; + private const uint MouseVirtualDesk = 0x4000; + private const uint MouseAbsolute = 0x8000; + private const uint KeyUp = 0x0002; + private const int VirtualKeyLeftButton = 0x01; + private const ushort VirtualKeyHome = 0x24; + private const int ClientPixelTolerance = 1; + + public static string ReadPackageFullName(IntPtr process) + { + int length = 0; + int result = GetPackageFullName(process, ref length, null); + if (result != ErrorInsufficientBuffer || length <= 1 || length > 4096) + { + throw new InvalidOperationException("The process package identity is unavailable."); + } + + StringBuilder value = new StringBuilder(length); + result = GetPackageFullName(process, ref length, value); + if (result != 0 || value.Length == 0) + { + throw new InvalidOperationException("The process package identity could not be read."); + } + return value.ToString(); + } + + public static bool HasTcpConnectionForProcessAndPort(int processId, int port) + { + if (processId <= 0 || port < 1 || port > 65535) + { + throw new InvalidOperationException("The TCP ownership query is invalid."); + } + return HasTcp4Connection(processId, port) || HasTcp6Connection(processId, port); + } + + public static void SendRowHeaderDragAfterThenHome( + IntPtr window, + int processId, + int forbiddenPort, + double sourceCssX, + double sourceCssY, + double targetCssX, + double targetCssY, + 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 input coordinate contract is invalid."); + } + + AssertKeyReleased(VirtualKeyLeftButton, "The left mouse button was held before input."); + AssertKeyReleased(VirtualKeyHome, "The Home key was held before input."); + AssertWindowOwnedByProcess(window, processId); + AssertNoForbiddenTcp(processId, forbiddenPort); + ClientGeometry baseline = ReadAndAssertClientGeometry( + window, + viewportCssWidth, + viewportCssHeight, + devicePixelRatio); + + AcquireForeground(window, processId, forbiddenPort); + SleepWithWindowGuard( + 400, + window, + processId, + forbiddenPort, + viewportCssWidth, + viewportCssHeight, + devicePixelRatio, + baseline); + + Point source = CssPointToScreen( + baseline, + sourceCssX, + sourceCssY, + devicePixelRatio); + Point target = CssPointToScreen( + baseline, + targetCssX, + targetCssY, + devicePixelRatio); + if (source.X == target.X && source.Y == target.Y) + { + throw new InvalidOperationException("The Windows drag source and target are identical."); + } + AssertPointOwnedByWindow(window, source); + AssertPointOwnedByWindow(window, target); + + bool leftButtonDown = false; + bool homeKeyDown = false; + try + { + SendMouseMove(source.X, source.Y); + SleepWithInputGuard( + 200, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline, source, target, + source, false, false); + + AssertKeyReleased( + VirtualKeyLeftButton, + "The left mouse button became held before the mouse press."); + AssertKeyReleased( + VirtualKeyHome, + "The Home key became held before the mouse press."); + SendMouseButton(MouseLeftDown); + leftButtonDown = true; + SleepWithInputGuard( + 100, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline, source, target, + source, true, false); + + Point threshold = new Point { + X = source.X + Math.Max(8, (int)Math.Round(12.0 * devicePixelRatio)), + Y = source.Y + }; + AssertPointOwnedByWindow(window, threshold); + SendMousePath( + source, threshold, 6, 35, window, processId, forbiddenPort, + viewportCssWidth, viewportCssHeight, devicePixelRatio, baseline, + source, target); + SendMousePath( + threshold, target, 24, 35, window, processId, forbiddenPort, + viewportCssWidth, viewportCssHeight, devicePixelRatio, baseline, + source, target); + + SendMouseButton(MouseLeftUp); + leftButtonDown = false; + SleepWithInputGuard( + 750, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline, source, target, + target, false, false); + + AssertKeyReleased( + VirtualKeyHome, + "The Home key became held before the keyboard input."); + SendKeyboard(VirtualKeyHome, false); + homeKeyDown = true; + SleepWithInputGuard( + 35, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline, source, target, + target, false, true); + SendKeyboard(VirtualKeyHome, true); + homeKeyDown = false; + SleepWithInputGuard( + 300, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline, source, target, + target, false, false); + } + finally + { + Exception cleanupFailure = null; + if (homeKeyDown) + { + try + { + SendKeyboard(VirtualKeyHome, true); + homeKeyDown = false; + } + catch (Exception error) + { + cleanupFailure = error; + } + } + if (leftButtonDown) + { + try + { + SendMouseButton(MouseLeftUp); + leftButtonDown = false; + } + catch (Exception error) + { + if (cleanupFailure == null) + { + cleanupFailure = error; + } + } + } + + try + { + AssertKeyReleased( + VirtualKeyLeftButton, + "The left mouse button remained held after input cleanup."); + } + catch (Exception error) + { + if (cleanupFailure == null) + { + cleanupFailure = error; + } + } + try + { + AssertKeyReleased( + VirtualKeyHome, + "The Home key remained held after input cleanup."); + } + catch (Exception error) + { + if (cleanupFailure == null) + { + cleanupFailure = error; + } + } + if (cleanupFailure != null) + { + throw new InvalidOperationException( + "Injected input cleanup could not prove that all keys were released.", + cleanupFailure); + } + } + } + + private static void AcquireForeground(IntPtr window, int processId, int forbiddenPort) + { + AssertWindowOwnedByProcess(window, processId); + AssertNoForbiddenTcp(processId, forbiddenPort); + if (GetForegroundWindow() == window) + { + return; + } + + IntPtr foregroundWindow = GetForegroundWindow(); + uint ignored; + uint foregroundThread = foregroundWindow == IntPtr.Zero + ? 0 + : GetWindowThreadProcessId(foregroundWindow, out ignored); + uint targetThread = GetWindowThreadProcessId(window, out ignored); + uint currentThread = GetCurrentThreadId(); + if (targetThread == 0 || currentThread == 0) + { + throw new InvalidOperationException("The foreground 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."); + } + attachedForeground = true; + } + if (targetThread != currentThread) + { + if (!AttachThreadInput(currentThread, targetThread, true)) + { + throw new InvalidOperationException("The package input queue could not be attached."); + } + attachedTarget = true; + } + + if (IsIconic(window)) + { + throw new InvalidOperationException("The package window is minimized; geometry is no longer stable."); + } + 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 && GetForegroundWindow() != window) + { + AssertWindowOwnedByProcess(window, processId); + AssertNoForbiddenTcp(processId, forbiddenPort); + Thread.Sleep(10); + AssertNoForbiddenTcp(processId, forbiddenPort); + } + if (GetForegroundWindow() != window) + { + throw new InvalidOperationException("The exact package window could not own foreground input."); + } + AssertWindowOwnedByProcess(window, processId); + AssertNoForbiddenTcp(processId, forbiddenPort); + } + + private static void AssertWindowOwnedByProcess(IntPtr window, int processId) + { + uint actualProcessId; + uint threadId = GetWindowThreadProcessId(window, out actualProcessId); + if (threadId == 0 || actualProcessId != (uint)processId) + { + throw new InvalidOperationException("The input window is not owned by the exact application process."); + } + } + + private static void AssertNoForbiddenTcp(int processId, int forbiddenPort) + { + if (HasTcpConnectionForProcessAndPort(processId, forbiddenPort)) + { + throw new InvalidOperationException("The DryRun application owns a forbidden TCP connection."); + } + } + + private static ClientGeometry ReadAndAssertClientGeometry( + IntPtr window, + double viewportCssWidth, + double viewportCssHeight, + double devicePixelRatio) + { + Rect client; + if (!GetClientRect(window, out client) || client.Left != 0 || client.Top != 0 || + client.Right <= 0 || client.Bottom <= 0) + { + throw new InvalidOperationException("The package client rectangle is invalid."); + } + + int expectedWidth = (int)Math.Round(viewportCssWidth * devicePixelRatio); + int expectedHeight = (int)Math.Round(viewportCssHeight * devicePixelRatio); + if (expectedWidth <= 0 || expectedHeight <= 0 || + Math.Abs(client.Right - expectedWidth) > ClientPixelTolerance || + Math.Abs(client.Bottom - expectedHeight) > ClientPixelTolerance) + { + throw new InvalidOperationException( + "The browser viewport does not match the package client rectangle."); + } + + Point origin = new Point { X = 0, Y = 0 }; + if (!ClientToScreen(window, ref origin)) + { + throw new InvalidOperationException("The package client origin is unavailable."); + } + return new ClientGeometry { + ScreenLeft = origin.X, + ScreenTop = origin.Y, + Width = client.Right, + Height = client.Bottom + }; + } + + private static void AssertStableClientGeometry( + IntPtr window, + double viewportCssWidth, + double viewportCssHeight, + double devicePixelRatio, + ClientGeometry baseline) + { + ClientGeometry current = ReadAndAssertClientGeometry( + window, + viewportCssWidth, + viewportCssHeight, + devicePixelRatio); + if (current.ScreenLeft != baseline.ScreenLeft || + current.ScreenTop != baseline.ScreenTop || + current.Width != baseline.Width || current.Height != baseline.Height) + { + throw new InvalidOperationException("The package client geometry changed during input."); + } + } + + private static void AssertWindowGuard( + IntPtr window, + int processId, + int forbiddenPort, + double viewportCssWidth, + double viewportCssHeight, + double devicePixelRatio, + ClientGeometry baseline) + { + AssertWindowOwnedByProcess(window, processId); + AssertNoForbiddenTcp(processId, forbiddenPort); + if (GetForegroundWindow() != window || IsIconic(window)) + { + throw new InvalidOperationException("The exact package window lost visible foreground input."); + } + AssertStableClientGeometry( + window, + viewportCssWidth, + viewportCssHeight, + devicePixelRatio, + baseline); + } + + private static void AssertInputGuard( + IntPtr window, + int processId, + int forbiddenPort, + double viewportCssWidth, + double viewportCssHeight, + double devicePixelRatio, + ClientGeometry baseline, + Point source, + Point target, + Point expectedCursor, + bool expectLeftButtonDown, + bool expectHomeDown) + { + AssertWindowGuard( + window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, + devicePixelRatio, baseline); + AssertPointOwnedByWindow(window, source); + AssertPointOwnedByWindow(window, target); + AssertPointOwnedByWindow(window, expectedCursor); + AssertCursorAt(expectedCursor); + AssertKeyState( + VirtualKeyLeftButton, + expectLeftButtonDown, + "The left mouse button state changed during input."); + AssertKeyState( + VirtualKeyHome, + expectHomeDown, + "The Home key state changed during input."); + } + + private static void SleepWithWindowGuard( + int milliseconds, + IntPtr window, + int processId, + int forbiddenPort, + double viewportCssWidth, + double viewportCssHeight, + double devicePixelRatio, + ClientGeometry baseline) + { + int remaining = milliseconds; + while (remaining > 0) + { + AssertWindowGuard( + window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, + devicePixelRatio, baseline); + int slice = Math.Min(10, remaining); + Thread.Sleep(slice); + remaining -= slice; + } + AssertWindowGuard( + window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, + devicePixelRatio, baseline); + } + + private static void SleepWithInputGuard( + int milliseconds, + IntPtr window, + int processId, + int forbiddenPort, + double viewportCssWidth, + double viewportCssHeight, + double devicePixelRatio, + ClientGeometry baseline, + Point source, + Point target, + Point expectedCursor, + bool expectLeftButtonDown, + bool expectHomeDown) + { + int remaining = milliseconds; + while (remaining > 0) + { + AssertInputGuard( + window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, + devicePixelRatio, baseline, source, target, expectedCursor, + expectLeftButtonDown, expectHomeDown); + int slice = Math.Min(10, remaining); + Thread.Sleep(slice); + remaining -= slice; + } + AssertInputGuard( + window, processId, forbiddenPort, viewportCssWidth, viewportCssHeight, + devicePixelRatio, baseline, source, target, expectedCursor, + expectLeftButtonDown, expectHomeDown); + } + + private static void AssertPointOwnedByWindow(IntPtr window, Point point) + { + IntPtr hit = WindowFromPoint(point); + IntPtr root = hit == IntPtr.Zero ? IntPtr.Zero : GetAncestor(hit, GetAncestorRoot); + if (root != window) + { + throw new InvalidOperationException("A Windows input point is obscured by another top-level window."); + } + } + + private static void AssertCursorAt(Point expected) + { + Point actual; + if (!GetCursorPos(out actual) || Math.Abs(actual.X - expected.X) > 2 || + Math.Abs(actual.Y - expected.Y) > 2) + { + throw new InvalidOperationException("The physical cursor diverged during the one-shot input sequence."); + } + } + + private static bool HasTcp4Connection(int processId, int port) + { + return ScanTcpTable( + AddressFamilyInterNetwork, + delegate(MibTcpRowOwnerPid row) + { + return row.OwningProcessId == (uint)processId && + (DecodePort(row.LocalPort) == port || DecodePort(row.RemotePort) == port); + }); + } + + private static bool HasTcp6Connection(int processId, int port) + { + return ScanTcpTable( + AddressFamilyInterNetworkV6, + delegate(MibTcp6RowOwnerPid row) + { + return row.OwningProcessId == (uint)processId && + (DecodePort(row.LocalPort) == port || DecodePort(row.RemotePort) == port); + }); + } + + private static bool ScanTcpTable(int addressFamily, Func predicate) + where TRow : struct + { + int size = 0; + uint result = GetExtendedTcpTable( + IntPtr.Zero, + ref size, + false, + addressFamily, + TcpTableOwnerPidAll, + 0); + if (result != ErrorInsufficientBuffer || size < sizeof(int) || size > 64 * 1024 * 1024) + { + throw new InvalidOperationException("The native TCP table size is unavailable."); + } + + IntPtr table = Marshal.AllocHGlobal(size); + try + { + result = GetExtendedTcpTable( + table, + ref size, + false, + addressFamily, + TcpTableOwnerPidAll, + 0); + if (result != 0) + { + throw new InvalidOperationException("The native TCP table could not be read."); + } + + int count = Marshal.ReadInt32(table); + int rowSize = Marshal.SizeOf(typeof(TRow)); + if (count < 0 || count > 100000 || + (long)sizeof(int) + (long)count * rowSize > size) + { + throw new InvalidOperationException("The native TCP table layout is invalid."); + } + IntPtr rowPointer = IntPtr.Add(table, sizeof(int)); + for (int index = 0; index < count; index++) + { + TRow row = (TRow)Marshal.PtrToStructure(rowPointer, typeof(TRow)); + if (predicate(row)) + { + return true; + } + rowPointer = IntPtr.Add(rowPointer, rowSize); + } + return false; + } + finally + { + Marshal.FreeHGlobal(table); + } + } + + private static int DecodePort(uint value) + { + return (int)(((value & 0x000000ffU) << 8) | ((value & 0x0000ff00U) >> 8)); + } + + private static bool IsFinitePositive(double value) + { + return !Double.IsNaN(value) && !Double.IsInfinity(value) && value > 0; + } + + private static Point CssPointToScreen( + ClientGeometry geometry, + double cssX, + double cssY, + double devicePixelRatio) + { + if (Double.IsNaN(cssX) || Double.IsInfinity(cssX) || + Double.IsNaN(cssY) || Double.IsInfinity(cssY) || cssX < 0 || cssY < 0) + { + throw new InvalidOperationException("A CSS input point is invalid."); + } + + int x = (int)Math.Round(cssX * devicePixelRatio); + int y = (int)Math.Round(cssY * devicePixelRatio); + if (x < 0 || x >= geometry.Width || y < 0 || y >= geometry.Height) + { + throw new InvalidOperationException("A CSS input point is outside the package client area."); + } + return new Point { X = geometry.ScreenLeft + x, Y = geometry.ScreenTop + y }; + } + + private static void SendMousePath( + Point from, + Point to, + int steps, + int delayMilliseconds, + IntPtr window, + int processId, + int forbiddenPort, + double viewportCssWidth, + double viewportCssHeight, + double devicePixelRatio, + ClientGeometry baseline, + Point source, + Point target) + { + for (int index = 1; index <= steps; index++) + { + double amount = (double)index / steps; + int x = (int)Math.Round(from.X + (to.X - from.X) * amount); + int y = (int)Math.Round(from.Y + (to.Y - from.Y) * amount); + SendMouseMove(x, y); + Point current = new Point { X = x, Y = y }; + SleepWithInputGuard( + delayMilliseconds, window, processId, forbiddenPort, viewportCssWidth, + viewportCssHeight, devicePixelRatio, baseline, source, target, + current, true, false); + } + } + + private static void SendMouseMove(int screenX, int screenY) + { + int left = GetSystemMetrics(76); + int top = GetSystemMetrics(77); + int width = GetSystemMetrics(78); + int height = GetSystemMetrics(79); + if (width <= 1 || height <= 1 || screenX < left || screenX >= left + width || + screenY < top || screenY >= top + height) + { + throw new InvalidOperationException("The Windows input point is outside the virtual desktop."); + } + + int absoluteX = (int)Math.Round((screenX - left) * 65535.0 / (width - 1)); + int absoluteY = (int)Math.Round((screenY - top) * 65535.0 / (height - 1)); + Input input = new Input { + Type = InputMouse, + Union = new InputUnion { + Mouse = new MouseInput { + Dx = absoluteX, + Dy = absoluteY, + Flags = MouseMove | MouseMoveNoCoalesce | MouseVirtualDesk | MouseAbsolute + } + } + }; + SendOne(input); + } + + private static void SendMouseButton(uint flags) + { + Input input = new Input { + Type = InputMouse, + Union = new InputUnion { Mouse = new MouseInput { Flags = flags } } + }; + SendOne(input); + } + + private static void SendKeyboard(ushort virtualKey, bool keyUp) + { + Input input = new Input { + Type = InputKeyboard, + Union = new InputUnion { + Keyboard = new KeyboardInput { + VirtualKey = virtualKey, + Flags = keyUp ? KeyUp : 0 + } + } + }; + SendOne(input); + } + + private static void SendOne(Input input) + { + Input[] inputs = new Input[] { input }; + if (SendInput(1, inputs, Marshal.SizeOf(typeof(Input))) != 1) + { + throw new InvalidOperationException("SendInput did not accept the one-shot input event."); + } + } + + private static void AssertKeyReleased(int virtualKey, string message) + { + AssertKeyState(virtualKey, false, message); + } + + private static void AssertKeyState(int virtualKey, bool expectedDown, string message) + { + bool actualDown = (GetAsyncKeyState(virtualKey) & 0x8000) != 0; + if (actualDown != expectedDown) + { + throw new InvalidOperationException(message); + } + } +} +'@ + +$PackageName = 'Wickedness.MBNStockWebView.LegacyParity' +$PackageFamilyName = 'Wickedness.MBNStockWebView.LegacyParity_qbv3jkvsn3aj0' +$PackageFullName = 'Wickedness.MBNStockWebView.LegacyParity_0.1.0.0_x64__qbv3jkvsn3aj0' +$PackagePublisher = 'CN=Comtrophy' +$PackageVersion = '0.1.0.0' +$ApplicationId = 'LegacyParityApp' +$ExecutableName = 'MBN_STOCK_WEBVIEW.LegacyParityApp.exe' +$Utf8 = [Text.UTF8Encoding]::new($false) +$StrictUtf8 = [Text.UTF8Encoding]::new($false, $true) +$ExpectedWindowTitle = $Utf8.GetString([Convert]::FromBase64String( + 'Vi1TdG9jayDspp3qtozsoJXrs7TshqHstpzsi5zsiqTthZwgZm9yIOunpOydvOqyveygnFRWICgyNi4wMy4yNik=')) +$ExpectedDialogTitle = $Utf8.GetString([Convert]::FromBase64String( + '66ek7J286rK97KCcVFY=')) +$ExpectedDialogMessage = $Utf8.GetString([Convert]::FromBase64String( + '7ZSE66Gc6re4656o7J2EIOyiheujjO2VmOqyoOyKteuLiOq5jD8=')) +$ExpectedPrimaryButton = $Utf8.GetString([Convert]::FromBase64String('7JiI')) +$ExpectedCloseButton = $Utf8.GetString([Convert]::FromBase64String('7JWE64uI7JqU')) +$LiveAuthorizationVariable = 'MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT' +$K3dNativeHashVariable = 'MBN_STOCK_K3D_NATIVE_SHA256' +$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' +$ExpectedInstallLocation = [IO.Path]::GetFullPath($ExpectedInstallLocation) +$ConfigurationPath = Join-Path ` + ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) ` + 'MBN_STOCK_WEBVIEW\Config\playout.local.json' + +$script:ApplicationProcess = $null +$script:ApplicationWasLaunched = $false +$script:CloseMainWindowCalls = 0 +$script:PrimaryInvokeCalls = 0 +$script:SingleInstanceActivationCalls = 0 +$script:WindowsInputRequestCalls = 0 +$script:WindowsInputAcknowledgementCalls = 0 +$script:Phase = 'preflight' + +function Throw-SafeFailure([string]$Message) { + throw [InvalidOperationException]::new($Message) +} + +function Test-PathEqual([string]$Left, [string]$Right) { + return [string]::Equals( + [IO.Path]::GetFullPath($Left).TrimEnd('\'), + [IO.Path]::GetFullPath($Right).TrimEnd('\'), + [StringComparison]::OrdinalIgnoreCase) +} + +function Assert-ExactProcessPackageIdentity([Diagnostics.Process]$Process) { + try { + $actualPackageFullName = [LegacyPackageInputNative]::ReadPackageFullName($Process.Handle) + } + catch { + Throw-SafeFailure 'The running application package identity could not be inspected.' + } + + if ([string]$actualPackageFullName -cne $PackageFullName) { + Throw-SafeFailure 'The running application does not have the exact registered package identity.' + } +} + +function Resolve-EvidencePath([string]$Value, [string]$DefaultFileName) { + if ([string]::IsNullOrWhiteSpace($Value)) { + return [IO.Path]::GetFullPath((Join-Path ` + (Join-Path $RepositoryRoot 'artifacts\package-input-smoke') ` + $DefaultFileName)) + } + + if ([IO.Path]::IsPathRooted($Value)) { + return [IO.Path]::GetFullPath($Value) + } + + return [IO.Path]::GetFullPath((Join-Path $RepositoryRoot $Value)) +} + +function Assert-OutputPathAvailable([string]$Path, [string]$Label) { + if ([IO.File]::Exists($Path) -or [IO.Directory]::Exists($Path)) { + Throw-SafeFailure "$Label already exists; evidence files are never overwritten." + } + + $parent = [IO.Path]::GetDirectoryName($Path) + if ([string]::IsNullOrWhiteSpace($parent)) { + Throw-SafeFailure "$Label has no parent directory." + } + + if (-not [IO.Directory]::Exists($parent)) { + [void][IO.Directory]::CreateDirectory($parent) + } + + $parentItem = Get-Item -LiteralPath $parent -Force + if (($parentItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Throw-SafeFailure "$Label parent must not be a reparse point." + } +} + +function Resolve-NodeExecutable([string]$RequestedPath) { + if (-not [string]::IsNullOrWhiteSpace($RequestedPath)) { + $resolved = [IO.Path]::GetFullPath($RequestedPath) + if (-not [IO.File]::Exists($resolved) -or + [IO.Path]::GetFileName($resolved) -cne 'node.exe') { + Throw-SafeFailure 'The requested Node executable does not exist.' + } + + return $resolved + } + + $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) + } + + $command = Get-Command node.exe -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($null -eq $command) { + Throw-SafeFailure 'Node was not found. Supply -NodePath with an absolute node.exe path.' + } + + return [IO.Path]::GetFullPath($command.Source) +} + +function Assert-LocalConfigurationIsDryRun { + if (-not [IO.File]::Exists($ConfigurationPath)) { + return + } + + $item = Get-Item -LiteralPath $ConfigurationPath -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $item.Length -gt 1MB) { + Throw-SafeFailure 'The local playout configuration cannot be safely inspected.' + } + + try { + $raw = [IO.File]::ReadAllText($ConfigurationPath, [Text.Encoding]::UTF8) + $configuration = $raw | ConvertFrom-Json -ErrorAction Stop + } + catch { + # Do not include parser diagnostics: they can echo configuration content. + Throw-SafeFailure 'The local playout configuration could not be parsed safely.' + } + + if ($null -eq $configuration) { + Throw-SafeFailure 'The local playout configuration is empty.' + } + + $modeProperties = @($configuration.PSObject.Properties | Where-Object { + [string]::Equals($_.Name, 'Mode', [StringComparison]::OrdinalIgnoreCase) + }) + $rawModePropertyCount = [regex]::Matches( + $raw, + '(?i)"mode"\s*:').Count + if ($modeProperties.Count -eq 0 -and $rawModePropertyCount -eq 0) { + # PlayoutOptions.Mode defaults to DryRun when the property is absent. + return + } + + if ($modeProperties.Count -ne 1 -or $rawModePropertyCount -ne 1) { + Throw-SafeFailure 'The local playout mode is not uniquely defined.' + } + + $mode = $modeProperties[0].Value + $isDryRun = ($mode -is [string] -and + [string]::Equals($mode, 'DryRun', [StringComparison]::OrdinalIgnoreCase)) -or + ($mode -isnot [string] -and [string]$mode -ceq '1') + if (-not $isDryRun) { + Throw-SafeFailure 'The local playout configuration does not resolve to DryRun.' + } +} + +function Assert-NoUnsafeEnvironment { + $forbiddenExactNames = @( + $LiveAuthorizationVariable, + $K3dNativeHashVariable, + $K3dInteropHashVariable + ) + $targets = @( + [EnvironmentVariableTarget]::Process, + [EnvironmentVariableTarget]::User, + [EnvironmentVariableTarget]::Machine + ) + + foreach ($target in $targets) { + try { + $environment = [Environment]::GetEnvironmentVariables($target) + } + catch { + Throw-SafeFailure "The $target environment could not be inspected." + } + + foreach ($nameObject in $environment.Keys) { + $name = [string]$nameObject + if ($forbiddenExactNames -contains $name -or + $name -like 'MBN_STOCK_PLAYOUT_*' -or + $name -like 'WEBVIEW2_*' -or + $name -like 'COREWEBVIEW2_*') { + # Report names/scopes only. Never report environment values. + Throw-SafeFailure "Unsafe launch environment variable is present: $target/$name." + } + } + } +} + +function Get-ExactDevelopmentPackage { + $packages = @(Get-AppxPackage -Name $PackageName -ErrorAction Stop) + if ($packages.Count -ne 1) { + Throw-SafeFailure 'Exactly one current-user LegacyParity development package is required.' + } + + $package = $packages[0] + if (-not $package.IsDevelopmentMode -or + [string]$package.SignatureKind -cne 'None' -or + [string]$package.Status -cne 'Ok' -or + [string]$package.Name -cne $PackageName -or + [string]$package.PackageFullName -cne $PackageFullName -or + [string]$package.PackageFamilyName -cne $PackageFamilyName -or + [string]$package.Publisher -cne $PackagePublisher -or + [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.' + } + + try { + $manifest = Get-AppxPackageManifest -Package $package -ErrorAction Stop + } + catch { + Throw-SafeFailure 'The registered package manifest could not be inspected.' + } + + $applications = @($manifest.Package.Applications.Application) + if ($applications.Count -ne 1 -or + [string]$applications[0].Id -cne $ApplicationId -or + [string]$applications[0].Executable -cne $ExecutableName -or + [string]$applications[0].EntryPoint -cne 'Windows.FullTrustApplication') { + Throw-SafeFailure 'The registered package application identity is not exact.' + } + + $executable = Join-Path ([string]$package.InstallLocation) $ExecutableName + if (-not [IO.File]::Exists($executable)) { + Throw-SafeFailure 'The registered Release application executable is missing.' + } + + return [pscustomobject]@{ + Package = $package + Executable = [IO.Path]::GetFullPath($executable) + InstallLocation = [IO.Path]::GetFullPath([string]$package.InstallLocation) + } +} + +function Get-LegacyApplicationProcesses { + $live = New-Object 'Collections.Generic.List[Diagnostics.Process]' + foreach ($candidate in @(Get-Process ` + -Name ([IO.Path]::GetFileNameWithoutExtension($ExecutableName)) ` + -ErrorAction SilentlyContinue)) { + try { + $candidate.Refresh() + if (-not $candidate.HasExited) { + [void]$live.Add($candidate) + } + } + catch { + # A process that disappears during enumeration is not a live instance. + } + } + return @($live) +} + +function Assert-NoExistingApplication { + if (@(Get-LegacyApplicationProcesses).Count -ne 0) { + Throw-SafeFailure 'A LegacyParity application process is already running.' + } +} + +function Get-TcpSnapshot { + try { + return @(Get-NetTCPConnection -ErrorAction Stop) + } + catch { + Throw-SafeFailure 'The TCP connection table could not be inspected.' + } +} + +function Assert-PortIsFree([int]$CandidatePort) { + if ($CandidatePort -eq 30001) { + Throw-SafeFailure 'The WebView debugging port must not be the Tornado port.' + } + + $listeners = @(Get-TcpSnapshot | Where-Object { + [string]$_.State -ceq 'Listen' -and + $_.LocalPort -eq $CandidatePort + }) + if ($listeners.Count -ne 0) { + Throw-SafeFailure 'The chosen loopback debugging port is already in use.' + } +} + +function Assert-NoTornadoConnection([int]$ProcessId) { + try { + $hasConnection = [LegacyPackageInputNative]::HasTcpConnectionForProcessAndPort( + $ProcessId, + 30001) + } + catch { + Throw-SafeFailure 'The native TCP ownership table could not be inspected.' + } + if ($hasConnection) { + Throw-SafeFailure 'The DryRun application owns a Tornado port connection.' + } +} + +function ConvertTo-QuotedProcessArgument([string]$Value) { + if ($Value.IndexOfAny([char[]]@('"', "`r", "`n")) -ge 0) { + Throw-SafeFailure 'A process argument contains a forbidden character.' + } + + return '"' + $Value + '"' +} + +function Get-ByteArraySha256([byte[]]$Bytes) { + $algorithm = [Security.Cryptography.SHA256]::Create() + try { + return ([BitConverter]::ToString($algorithm.ComputeHash($Bytes))).Replace('-', '') + } + finally { + $algorithm.Dispose() + } +} + +function Assert-ExactJsonPropertyNames( + [object]$Value, + [string[]]$ExpectedNames, + [string]$Label) { + if ($null -eq $Value) { + Throw-SafeFailure "$Label is missing." + } + + $actualNames = @($Value.PSObject.Properties | ForEach-Object { [string]$_.Name }) + if ($actualNames.Count -ne $ExpectedNames.Count) { + Throw-SafeFailure "$Label has an unexpected schema." + } + foreach ($name in $ExpectedNames) { + if ($actualNames -cnotcontains $name) { + Throw-SafeFailure "$Label has an unexpected schema." + } + } +} + +function Test-ExactStringSequence([object[]]$Left, [object[]]$Right) { + $leftValues = @($Left | ForEach-Object { [string]$_ }) + $rightValues = @($Right | ForEach-Object { [string]$_ }) + if ($leftValues.Count -ne $rightValues.Count) { + return $false + } + for ($index = 0; $index -lt $leftValues.Count; $index++) { + if ($leftValues[$index] -cne $rightValues[$index]) { + return $false + } + } + return $true +} + +function Read-SmallJsonEvidenceFile([string]$Path, [string]$Label) { + 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 16KB) { + Throw-SafeFailure "$Label is not a small regular file." + } + + $bytes = [IO.File]::ReadAllBytes($Path) + if ($bytes.Length -ne $item.Length) { + Throw-SafeFailure "$Label 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 "$Label could not be validated as UTF-8 JSON." + } +} + +function Wait-ReadSmallJsonEvidenceFile( + [string]$Path, + [string]$Label, + [datetime]$Deadline) { + $previousLength = -1L + $stableObservations = 0 + while ([datetime]::UtcNow -lt $Deadline) { + if ([IO.File]::Exists($Path)) { + try { + $item = Get-Item -LiteralPath $Path -Force + $isCandidate = -not $item.PSIsContainer -and + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0 -and + $item.Length -gt 0 -and $item.Length -le 16KB + if ($isCandidate -and $item.Length -eq $previousLength) { + $stableObservations++ + } + else { + $stableObservations = 0 + } + $previousLength = $item.Length + if ($isCandidate -and $stableObservations -ge 1) { + return Read-SmallJsonEvidenceFile $Path $Label + } + } + catch [InvalidOperationException] { + throw + } + catch { + $stableObservations = 0 + $previousLength = -1L + } + } + Start-Sleep -Milliseconds 25 + } + Throw-SafeFailure "$Label was not published as one stable small regular file." +} + +function ConvertTo-FiniteNumber([object]$Value, [string]$Label) { + $isJsonNumber = $Value -is [byte] -or $Value -is [sbyte] -or + $Value -is [int16] -or $Value -is [uint16] -or + $Value -is [int32] -or $Value -is [uint32] -or + $Value -is [int64] -or $Value -is [uint64] -or + $Value -is [single] -or $Value -is [double] -or $Value -is [decimal] + if (-not $isJsonNumber) { + Throw-SafeFailure "$Label is not a JSON number." + } + 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-WindowsInputAcknowledgement([string]$Path, [string]$Token) { + $acknowledgement = [ordered]@{ + schemaVersion = 1 + token = $Token + result = 'PASS' + operation = 'row-header-drag-after-then-home' + foregroundValidated = $true + packageIdentityValidated = $true + mouseDownCalls = 1 + mouseUpCalls = 1 + homeKeyCalls = 1 + inputRetryCount = 0 + } + $bytes = $Utf8.GetBytes(($acknowledgement | ConvertTo-Json -Compress) + "`n") + $parent = [IO.Path]::GetDirectoryName($Path) + $temporaryPath = Join-Path $parent ( + ([IO.Path]::GetFileName($Path)) + '.' + [Guid]::NewGuid().ToString('N') + '.tmp') + try { + $stream = [IO.FileStream]::new( + $temporaryPath, + [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($temporaryPath, $Path) + } + catch { + try { + if ([IO.File]::Exists($temporaryPath)) { + [IO.File]::Delete($temporaryPath) + } + } + catch { + # Cleanup failure cannot authorize another acknowledgement attempt. + } + Throw-SafeFailure 'The one-shot Windows input acknowledgement could not be created.' + } + + return [pscustomobject]@{ + Bytes = $bytes + Sha256 = Get-ByteArraySha256 $bytes + } +} + +function Invoke-ExactWindowsInputRequest( + [string]$RequestPath, + [string]$AcknowledgementPath, + [Diagnostics.Process]$Application, + [string]$ExpectedApplicationPath) { + $script:WindowsInputRequestCalls++ + if ($script:WindowsInputRequestCalls -ne 1 -or [IO.File]::Exists($AcknowledgementPath)) { + Throw-SafeFailure 'The Windows input one-shot budget or acknowledgement precondition changed.' + } + + $requestEvidence = Wait-ReadSmallJsonEvidenceFile ` + $RequestPath ` + '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 + $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.' + } + + $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.' + } + + Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath + Assert-NoTornadoConnection $Application.Id + [LegacyPackageInputNative]::SendRowHeaderDragAfterThenHome( + $Application.MainWindowHandle, + $Application.Id, + 30001, + $sourceX, + $sourceY, + $targetX, + $targetY, + $viewportWidth, + $viewportHeight, + $devicePixelRatio) + Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath + Assert-NoTornadoConnection $Application.Id + + $script:WindowsInputAcknowledgementCalls++ + if ($script:WindowsInputAcknowledgementCalls -ne 1) { + Throw-SafeFailure 'The Windows input acknowledgement budget changed.' + } + $acknowledgementEvidence = Write-WindowsInputAcknowledgement $AcknowledgementPath $token + return [pscustomobject]@{ + RequestSha256 = $requestEvidence.Sha256 + AcknowledgementSha256 = $acknowledgementEvidence.Sha256 + } +} + +function Invoke-NodeHarnessUnderTcpWatch( + [string]$Executable, + [string]$ScriptPath, + [int]$DebugPort, + [string]$OutputPath, + [string]$ScreenshotPath, + [string]$WindowsInputRequestPath, + [string]$WindowsInputAcknowledgementPath, + [int]$TimeoutSeconds, + [Diagnostics.Process]$Application, + [string]$ExpectedApplicationPath) { + $arguments = @( + $ScriptPath, + '--port', + [string]$DebugPort, + '--output', + $OutputPath, + '--screenshot', + $ScreenshotPath, + '--windows-input-request', + $WindowsInputRequestPath, + '--windows-input-ack', + $WindowsInputAcknowledgementPath, + '--timeout-ms', + [string]($TimeoutSeconds * 1000) + ) | ForEach-Object { ConvertTo-QuotedProcessArgument ([string]$_) } + + $startInfo = New-Object Diagnostics.ProcessStartInfo + $startInfo.FileName = $Executable + $startInfo.WorkingDirectory = $RepositoryRoot + $startInfo.Arguments = $arguments -join ' ' + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardInput = $true + try { + $nodeProcess = [Diagnostics.Process]::Start($startInfo) + } + catch { + Throw-SafeFailure 'The real input harness process could not be started.' + } + if ($null -eq $nodeProcess) { + Throw-SafeFailure 'The real input harness process did not start.' + } + + $deadline = [datetime]::UtcNow.AddSeconds($TimeoutSeconds + 30) + $monitoringFailure = $null + $windowsInputHandled = $false + $windowsInputEvidence = $null + try { + while (-not $nodeProcess.HasExited) { + if ([datetime]::UtcNow -ge $deadline) { + Throw-SafeFailure 'The input harness exceeded its global deadline.' + } + Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath + Assert-NoTornadoConnection $Application.Id + if ([IO.File]::Exists($WindowsInputRequestPath) -and -not $windowsInputHandled) { + $windowsInputEvidence = Invoke-ExactWindowsInputRequest ` + $WindowsInputRequestPath ` + $WindowsInputAcknowledgementPath ` + $Application ` + $ExpectedApplicationPath + $windowsInputHandled = $true + } + 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.' + } + Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath + Assert-NoTornadoConnection $Application.Id + } + catch { + $monitoringFailure = $_.Exception.Message + } + + if ($null -ne $monitoringFailure) { + $helperStopped = $nodeProcess.HasExited + if (-not $helperStopped) { + try { + $nodeProcess.StandardInput.WriteLine('CANCEL') + $nodeProcess.StandardInput.Flush() + $helperStopped = $nodeProcess.WaitForExit(5000) + } + catch { + $helperStopped = $false + } + } + if (-not $helperStopped) { + try { + # Only the child Node input driver is terminated after its one-shot + # cancellation cleanup failed. The packaged app remains open. + $nodeProcess.Kill() + $helperStopped = $nodeProcess.WaitForExit(10000) + } + catch { + $helperStopped = $false + } + } + if (-not $helperStopped) { + Throw-SafeFailure ( + "$monitoringFailure The Node input helper could not be stopped; " + + 'the application was left open and no close was attempted.') + } + + Throw-SafeFailure ( + "$monitoringFailure The Node input helper was stopped; " + + 'the application was left open and no close was attempted.') + } + + return [pscustomobject]@{ + ExitCode = $nodeProcess.ExitCode + WindowsInput = $windowsInputEvidence + } +} + +function Test-ProcessDescendsFrom([int]$ProcessId, [int]$ExpectedAncestorId) { + $visited = New-Object 'Collections.Generic.HashSet[int]' + $current = $ProcessId + for ($depth = 0; $depth -lt 12; $depth++) { + if ($current -eq $ExpectedAncestorId) { + return $true + } + + if ($current -le 0 -or -not $visited.Add($current)) { + return $false + } + + try { + $record = Get-CimInstance Win32_Process -Filter "ProcessId = $current" ` + -ErrorAction Stop + } + catch { + return $false + } + if ($null -eq $record) { + return $false + } + + $current = [int]$record.ParentProcessId + } + + return $false +} + +function Wait-ExactLoopbackListener( + [int]$CandidatePort, + [Diagnostics.Process]$Application, + [datetime]$Deadline) { + while ([datetime]::UtcNow -lt $Deadline) { + if ($Application.HasExited) { + Throw-SafeFailure 'The application exited before the WebView listener appeared.' + } + + $listeners = @(Get-TcpSnapshot | Where-Object { + [string]$_.State -ceq 'Listen' -and + $_.LocalPort -eq $CandidatePort + }) + if ($listeners.Count -gt 1) { + Throw-SafeFailure 'More than one process is listening on the WebView debugging port.' + } + + if ($listeners.Count -eq 1) { + $listener = $listeners[0] + if ([string]$listener.LocalAddress -cne '127.0.0.1') { + Throw-SafeFailure 'The WebView debugging listener is not bound to exact IPv4 loopback.' + } + + try { + $owner = Get-Process -Id ([int]$listener.OwningProcess) -ErrorAction Stop + $ownerPath = $owner.Path + } + catch { + Throw-SafeFailure 'The WebView debugging listener owner could not be identified.' + } + + if ([string]$owner.ProcessName -cne 'msedgewebview2' -or + [IO.Path]::GetFileName($ownerPath) -cne 'msedgewebview2.exe' -or + -not (Test-ProcessDescendsFrom $owner.Id $Application.Id)) { + Throw-SafeFailure 'The WebView debugging listener is not owned by this application.' + } + + return $listener + } + + Start-Sleep -Milliseconds 100 + } + + Throw-SafeFailure 'The exact WebView debugging listener did not appear in time.' +} + +function Get-StartedApplication( + [string]$ExpectedPath, + [datetime]$NotBefore, + [datetime]$Deadline) { + while ([datetime]::UtcNow -lt $Deadline) { + $processes = @(Get-LegacyApplicationProcesses) + if ($processes.Count -gt 1) { + Throw-SafeFailure 'More than one LegacyParity process appeared after launch.' + } + + if ($processes.Count -eq 1) { + $process = $processes[0] + try { + $process.Refresh() + $path = $process.Path + $startUtc = $process.StartTime.ToUniversalTime() + } + catch { + Throw-SafeFailure 'The launched application identity could not be inspected.' + } + + if (-not (Test-PathEqual $path $ExpectedPath) -or + $startUtc -lt $NotBefore.AddSeconds(-1)) { + Throw-SafeFailure 'The launched application identity is not exact.' + } + + try { + $cim = Get-CimInstance Win32_Process -Filter "ProcessId = $($process.Id)" ` + -ErrorAction Stop + } + catch { + Throw-SafeFailure 'The launched application command line could not be inspected.' + } + if ($null -eq $cim -or + [string]::IsNullOrWhiteSpace([string]$cim.CommandLine) -or + [string]$cim.CommandLine -match '(?i)(?:^|\s)--development-live(?:\s|$)') { + Throw-SafeFailure 'The Release application launch arguments are not safe.' + } + + Assert-ExactProcessPackageIdentity $process + return $process + } + + Start-Sleep -Milliseconds 100 + } + + Throw-SafeFailure 'The packaged Release application did not start in time.' +} + +function Start-PackagedDryRunApplication( + [pscustomobject]$Registration, + [int]$DebugPort) { + $invokeCommand = Get-Command Invoke-CommandInDesktopPackage -ErrorAction SilentlyContinue + if ($null -eq $invokeCommand -or [string]$invokeCommand.Source -cne 'Appx') { + Throw-SafeFailure 'Invoke-CommandInDesktopPackage from the Appx module is required.' + } + + $childPowerShell = Join-Path $env:SystemRoot ` + 'System32\WindowsPowerShell\v1.0\powershell.exe' + if (-not [IO.File]::Exists($childPowerShell)) { + Throw-SafeFailure 'The child Windows PowerShell executable is missing.' + } + + $executableBase64 = [Convert]::ToBase64String( + [Text.Encoding]::UTF8.GetBytes($Registration.Executable)) + $workingDirectoryBase64 = [Convert]::ToBase64String( + [Text.Encoding]::UTF8.GetBytes($Registration.InstallLocation)) + $browserArguments = "--remote-debugging-address=127.0.0.1 --remote-debugging-port=$DebugPort" + $browserArgumentsBase64 = [Convert]::ToBase64String( + [Text.Encoding]::UTF8.GetBytes($browserArguments)) + $childScript = @" +`$ErrorActionPreference = 'Stop' +`$executable = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$executableBase64')) +`$workingDirectory = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$workingDirectoryBase64')) +`$browserArguments = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$browserArgumentsBase64')) +foreach (`$nameObject in @([Environment]::GetEnvironmentVariables([EnvironmentVariableTarget]::Process).Keys)) { + `$name = [string]`$nameObject + if (`$name -like 'MBN_STOCK_PLAYOUT_*' -or `$name -like 'WEBVIEW2_*' -or + `$name -like 'COREWEBVIEW2_*' -or `$name -ceq '$K3dNativeHashVariable' -or + `$name -ceq '$K3dInteropHashVariable') { + [Environment]::SetEnvironmentVariable(`$name, `$null, [EnvironmentVariableTarget]::Process) + } +} +[Environment]::SetEnvironmentVariable('MBN_STOCK_PLAYOUT_MODE', 'DryRun', [EnvironmentVariableTarget]::Process) +[Environment]::SetEnvironmentVariable('$WebViewArgumentsVariable', `$browserArguments, [EnvironmentVariableTarget]::Process) +`$startInfo = New-Object Diagnostics.ProcessStartInfo +`$startInfo.FileName = `$executable +`$startInfo.WorkingDirectory = `$workingDirectory +`$startInfo.UseShellExecute = `$false +`$startInfo.CreateNoWindow = `$false +`$started = [Diagnostics.Process]::Start(`$startInfo) +if (`$null -eq `$started) { exit 91 } +"@ + $encodedCommand = [Convert]::ToBase64String( + [Text.Encoding]::Unicode.GetBytes($childScript)) + $childArguments = "-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand $encodedCommand" + + $notBefore = [datetime]::UtcNow + try { + [void](Invoke-CommandInDesktopPackage ` + -PackageFamilyName $PackageFamilyName ` + -AppId $ApplicationId ` + -Command $childPowerShell ` + -Args $childArguments ` + -PreventBreakaway ` + -ErrorAction Stop) + } + catch { + Throw-SafeFailure 'The package-context launch helper failed.' + } + + $deadline = [datetime]::UtcNow.AddSeconds($LaunchTimeoutSeconds) + return Get-StartedApplication $Registration.Executable $notBefore $deadline +} + +function Assert-ExactApplicationStillRunning( + [Diagnostics.Process]$Application, + [string]$ExpectedPath) { + try { + $Application.Refresh() + if ($Application.HasExited -or + -not (Test-PathEqual $Application.Path $ExpectedPath) -or + $Application.MainWindowHandle -eq [IntPtr]::Zero -or + [string]$Application.MainWindowTitle -cne $ExpectedWindowTitle) { + Throw-SafeFailure 'The application identity changed during the input smoke test.' + } + Assert-ExactProcessPackageIdentity $Application + } + catch [InvalidOperationException] { + throw + } + catch { + Throw-SafeFailure 'The application identity could not be revalidated.' + } +} + +function Assert-NoExistingContentDialog([IntPtr]$MainWindowHandle) { + Add-Type -AssemblyName UIAutomationClient + Add-Type -AssemblyName UIAutomationTypes + $root = [Windows.Automation.AutomationElement]::FromHandle($MainWindowHandle) + if ($null -eq $root) { + Throw-SafeFailure 'The main window is unavailable to UI Automation.' + } + + $all = $root.FindAll( + [Windows.Automation.TreeScope]::Descendants, + [Windows.Automation.Condition]::TrueCondition) + for ($index = 0; $index -lt $all.Count; $index++) { + $element = $all[$index] + if ($element.Current.ControlType -eq [Windows.Automation.ControlType]::Window -and + [string]$element.Current.ClassName -ceq 'Popup') { + Throw-SafeFailure 'A dialog was already open before the normal close request.' + } + } +} + +function Assert-SingleInstanceActivation( + [Diagnostics.Process]$Application, + [string]$ExpectedPath) { + Assert-ExactApplicationStillRunning $Application $ExpectedPath + $Application.Refresh() + $expectedId = $Application.Id + $expectedStartUtc = $Application.StartTime.ToUniversalTime() + $expectedHandle = $Application.MainWindowHandle + $expectedTitle = $Application.MainWindowTitle + $preActivationProcesses = @(Get-LegacyApplicationProcesses) + if ($preActivationProcesses.Count -ne 1 -or + $preActivationProcesses[0].Id -ne $expectedId) { + Throw-SafeFailure 'Single-instance activation requires exactly the pinned original process.' + } + Assert-NoTornadoConnection $expectedId + + $explorer = Join-Path $env:SystemRoot 'explorer.exe' + if (-not [IO.File]::Exists($explorer)) { + Throw-SafeFailure 'Explorer is unavailable for the single-instance activation check.' + } + + $script:SingleInstanceActivationCalls++ + if ($script:SingleInstanceActivationCalls -ne 1) { + Throw-SafeFailure 'The single-instance activation budget changed.' + } + $activationRequestedUtc = [datetime]::UtcNow + try { + [void](Start-Process ` + -FilePath $explorer ` + -ArgumentList "shell:AppsFolder\$PackageFamilyName!$ApplicationId" ` + -WindowStyle Hidden ` + -PassThru ` + -ErrorAction Stop) + } + catch { + Throw-SafeFailure 'The single-instance package activation could not be requested.' + } + + $redirector = $null + $redirectorId = $null + $redirectorStartUtc = $null + $redirectorExited = $false + $stableOriginalSinceUtc = $null + $deadline = [datetime]::UtcNow.AddSeconds(8) + while ([datetime]::UtcNow -lt $deadline) { + $processes = @(Get-LegacyApplicationProcesses) + $originals = @($processes | Where-Object { $_.Id -eq $expectedId }) + $additional = @($processes | Where-Object { $_.Id -ne $expectedId }) + if ($originals.Count -ne 1 -or $additional.Count -gt 1) { + Throw-SafeFailure 'Package activation did not preserve one original and at most one redirector.' + } + + $process = $originals[0] + try { + $process.Refresh() + $sameIdentity = $process.Id -eq $expectedId -and + (Test-PathEqual $process.Path $ExpectedPath) -and + $process.StartTime.ToUniversalTime() -eq $expectedStartUtc -and + $process.MainWindowHandle -eq $expectedHandle -and + [string]$process.MainWindowTitle -ceq $expectedTitle + } + catch { + $sameIdentity = $false + } + if (-not $sameIdentity) { + Throw-SafeFailure 'Package activation changed the running application identity.' + } + + Assert-ExactProcessPackageIdentity $process + Assert-NoTornadoConnection $expectedId + + if ($additional.Count -eq 1) { + if ($redirectorExited) { + Throw-SafeFailure 'A second redirector appeared after the verified redirector exited.' + } + + $candidate = $additional[0] + try { + $candidate.Refresh() + $candidateId = $candidate.Id + $candidateStartUtc = $candidate.StartTime.ToUniversalTime() + $candidatePath = $candidate.Path + $candidateHandle = $candidate.MainWindowHandle + $candidateTitle = [string]$candidate.MainWindowTitle + } + catch { + Throw-SafeFailure 'The transient activation redirector could not be inspected exactly.' + } + + if ($candidateStartUtc -lt $activationRequestedUtc -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) { + $redirector = $candidate + $redirectorId = $candidateId + $redirectorStartUtc = $candidateStartUtc + } + elseif ($candidateId -ne $redirectorId -or + $candidateStartUtc -ne $redirectorStartUtc) { + Throw-SafeFailure 'More than one transient activation redirector was observed.' + } + + # Command-line inspection is opportunistic because a redirector may exit + # between the native identity checks and the management query. + try { + $cimRows = @(Get-CimInstance Win32_Process ` + -Filter "ProcessId = $candidateId" ` + -ErrorAction Stop) + if ($cimRows.Count -gt 1) { + Throw-SafeFailure 'The transient redirector command line is not unique.' + } + if ($cimRows.Count -eq 1 -and + -not [string]::IsNullOrWhiteSpace([string]$cimRows[0].CommandLine) -and + [string]$cimRows[0].CommandLine -match + '(?i)(?:^|\s)--development-live(?:[=\s]|$)') { + Throw-SafeFailure 'The transient redirector used a development-live argument.' + } + } + catch [InvalidOperationException] { + throw + } + catch { + # The already-verified transient may exit before CIM can inspect it. + } + + $stableOriginalSinceUtc = $null + } + elseif ($null -ne $redirectorId) { + try { + $redirector.Refresh() + $isRedirectorExited = $redirector.HasExited + } + catch { + $isRedirectorExited = $true + } + if (-not $isRedirectorExited) { + Throw-SafeFailure 'The pinned redirector disappeared from exact process enumeration before exit.' + } + + if (-not $redirectorExited) { + $redirectorExited = $true + $stableOriginalSinceUtc = [datetime]::UtcNow + } + elseif ($null -eq $stableOriginalSinceUtc) { + $stableOriginalSinceUtc = [datetime]::UtcNow + } + + if (([datetime]::UtcNow - $stableOriginalSinceUtc).TotalMilliseconds -ge 750) { + $stableOriginalMilliseconds = [int][Math]::Floor( + ([datetime]::UtcNow - $stableOriginalSinceUtc).TotalMilliseconds) + Assert-ExactApplicationStillRunning $Application $ExpectedPath + Assert-NoTornadoConnection $expectedId + return [pscustomobject]@{ + ActivationCalls = $script:SingleInstanceActivationCalls + ProcessCount = 1 + ProcessId = $expectedId + StartTimeUtc = $expectedStartUtc.ToString('o') + MainWindowHandle = [int64]$expectedHandle + RedirectorProcessId = $redirectorId + RedirectorStartTimeUtc = $redirectorStartUtc.ToString('o') + RedirectorExited = $true + StableOriginalMilliseconds = $stableOriginalMilliseconds + } + } + } + + Start-Sleep -Milliseconds 25 + } + + if ($null -eq $redirectorId) { + Throw-SafeFailure 'The single activation did not expose one verifiable transient redirector.' + } + if (-not $redirectorExited) { + Throw-SafeFailure 'The verified transient activation redirector did not exit in time.' + } + Throw-SafeFailure 'The original instance was not stable alone for 750 milliseconds.' +} + +function Wait-ExactNeutralCloseDialog( + [IntPtr]$MainWindowHandle, + [Diagnostics.Process]$Application, + [datetime]$Deadline) { + while ([datetime]::UtcNow -lt $Deadline) { + if ($Application.HasExited) { + Throw-SafeFailure 'The application exited before close confirmation.' + } + Assert-NoTornadoConnection $Application.Id + + $root = [Windows.Automation.AutomationElement]::FromHandle($MainWindowHandle) + if ($null -eq $root) { + Throw-SafeFailure 'The main window disappeared before close confirmation.' + } + + $all = $root.FindAll( + [Windows.Automation.TreeScope]::Descendants, + [Windows.Automation.Condition]::TrueCondition) + $matches = New-Object Collections.Generic.List[object] + for ($index = 0; $index -lt $all.Count; $index++) { + $element = $all[$index] + if ($element.Current.ControlType -eq [Windows.Automation.ControlType]::Window -and + [string]$element.Current.ClassName -ceq 'Popup' -and + [string]$element.Current.Name -ceq $ExpectedDialogTitle) { + [void]$matches.Add($element) + } + } + + if ($matches.Count -gt 1) { + Throw-SafeFailure 'The neutral close dialog is not unique.' + } + if ($matches.Count -eq 1) { + Assert-NoTornadoConnection $Application.Id + return $matches[0] + } + + Start-Sleep -Milliseconds 100 + } + + Throw-SafeFailure 'The neutral close dialog did not appear in time.' +} + +function Invoke-ExactNormalClose( + [Diagnostics.Process]$Application, + [string]$ExpectedPath) { + Assert-ExactApplicationStillRunning $Application $ExpectedPath + $mainWindowHandle = $Application.MainWindowHandle + Assert-NoExistingContentDialog $mainWindowHandle + + $script:CloseMainWindowCalls++ + if ($script:CloseMainWindowCalls -ne 1 -or -not $Application.CloseMainWindow()) { + Throw-SafeFailure 'The single normal close request was not accepted.' + } + + $dialog = Wait-ExactNeutralCloseDialog ` + $mainWindowHandle ` + $Application ` + ([datetime]::UtcNow.AddSeconds(15)) + $descendants = $dialog.FindAll( + [Windows.Automation.TreeScope]::Descendants, + [Windows.Automation.Condition]::TrueCondition) + $buttons = New-Object Collections.Generic.List[object] + $texts = New-Object Collections.Generic.List[object] + for ($index = 0; $index -lt $descendants.Count; $index++) { + $element = $descendants[$index] + if ($element.Current.ControlType -eq [Windows.Automation.ControlType]::Button) { + [void]$buttons.Add($element) + } + elseif ($element.Current.ControlType -eq [Windows.Automation.ControlType]::Text) { + [void]$texts.Add($element) + } + } + + $primary = @($buttons | Where-Object { + [string]$_.Current.AutomationId -ceq 'PrimaryButton' -and + [string]$_.Current.Name -ceq $ExpectedPrimaryButton -and + $_.Current.IsEnabled -and -not $_.Current.IsOffscreen + }) + $close = @($buttons | Where-Object { + [string]$_.Current.AutomationId -ceq 'CloseButton' -and + [string]$_.Current.Name -ceq $ExpectedCloseButton -and + $_.Current.IsEnabled -and -not $_.Current.IsOffscreen + }) + $message = @($texts | Where-Object { + [string]$_.Current.Name -ceq $ExpectedDialogMessage + }) + if ($buttons.Count -ne 2 -or $primary.Count -ne 1 -or + $close.Count -ne 1 -or $message.Count -ne 1) { + Throw-SafeFailure 'The close dialog is not the exact neutral Yes/No confirmation.' + } + + $patternObject = $null + if (-not $primary[0].TryGetCurrentPattern( + [Windows.Automation.InvokePattern]::Pattern, + [ref]$patternObject) -or + $null -eq $patternObject) { + Throw-SafeFailure 'The exact Yes button has no InvokePattern.' + } + + $script:PrimaryInvokeCalls++ + if ($script:PrimaryInvokeCalls -ne 1) { + Throw-SafeFailure 'The close confirmation invocation budget changed.' + } + ($patternObject -as [Windows.Automation.InvokePattern]).Invoke() + + $exitDeadline = [datetime]::UtcNow.AddSeconds(30) + while ([datetime]::UtcNow -lt $exitDeadline) { + $Application.Refresh() + if ($Application.HasExited) { + return + } + Assert-NoTornadoConnection $Application.Id + Start-Sleep -Milliseconds 100 + } + Throw-SafeFailure 'Normal application exit was not observed after the single confirmation.' +} + +function Wait-PortReleased([int]$CandidatePort, [datetime]$Deadline) { + while ([datetime]::UtcNow -lt $Deadline) { + $listeners = @(Get-TcpSnapshot | Where-Object { + [string]$_.State -ceq 'Listen' -and + $_.LocalPort -eq $CandidatePort + }) + if ($listeners.Count -eq 0) { + return + } + + Start-Sleep -Milliseconds 100 + } + + Throw-SafeFailure 'The WebView debugging listener remained after normal application exit.' +} + +function Read-AndAssertHarnessEvidence( + [string]$OutputPath, + [string]$ScreenshotPath, + [string]$WindowsInputRequestPath, + [string]$WindowsInputAcknowledgementPath, + [int]$ExpectedPort, + [int]$ExpectedTimeoutSeconds) { + try { + $evidence = [IO.File]::ReadAllText($OutputPath, [Text.Encoding]::UTF8) | + ConvertFrom-Json -ErrorAction Stop + $result = [string]$evidence.result + $safeResult = $result -ceq 'PASS' -or $result -ceq 'PASS_WITH_WARNING' + $warnings = @($evidence.warnings) + if ([int]$evidence.schemaVersion -ne 1 -or + -not $safeResult -or + ($result -ceq 'PASS_WITH_WARNING' -and $warnings.Count -eq 0) -or + $null -ne $evidence.error -or + [string]$evidence.target.expectedUrl -cne + 'https://legacy-parity.mbn.local/index.html' -or + [int]$evidence.target.port -ne $ExpectedPort -or + [int]$evidence.target.timeoutMilliseconds -ne ($ExpectedTimeoutSeconds * 1000) -or + [string]$evidence.target.url -cne + 'https://legacy-parity.mbn.local/index.html' -or + [string]$evidence.safety.requiredMode -cne 'dryRun' -or + [string]$evidence.safety.requiredPhase -cne 'idle' -or + $evidence.safety.appCloseRequested -ne $false -or + $evidence.safety.playoutIntentIssued -ne $false -or + $evidence.safety.databaseWriteIntentIssued -ne $false -or + $evidence.safety.externalCancellationRequested -ne $false -or + @($evidence.safety.blockedOutboundMessages).Count -ne 0 -or + @($evidence.safety.stateViolations).Count -ne 0 -or + [string]::IsNullOrWhiteSpace([string]$evidence.screenshot.path) -or + -not (Test-PathEqual ([string]$evidence.screenshot.path) $ScreenshotPath)) { + Throw-SafeFailure 'The real input evidence does not satisfy the DryRun safety contract.' + } + + $allowedOutbound = @( + 'ready', 'search-stocks', 'select-stock', 'cut-pointer-down', + 'cut-key-down', 'drop-selected-cuts', 'activate-playlist-row', + 'select-playlist-row', 'reorder-playlist-rows', + 'select-playlist-boundary', 'refresh-named-playlists') + foreach ($messageType in @($evidence.safety.observedOutboundMessageTypes)) { + if ($allowedOutbound -cnotcontains [string]$messageType) { + Throw-SafeFailure 'The evidence contains an outbound message outside the read-only allowlist.' + } + } + + $screenshotItem = Get-Item -LiteralPath $ScreenshotPath -Force + $screenshotHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $ScreenshotPath).Hash + if ($screenshotItem.PSIsContainer -or + ($screenshotItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $screenshotItem.Length -le 0 -or + [int64]$evidence.screenshot.bytes -ne $screenshotItem.Length -or + [string]$evidence.screenshot.sha256 -cne $screenshotHash) { + 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 + if ([bool]$evidence.windowsInput.required -ne $true -or + [string]$evidence.windowsInput.result -cne 'PASS' -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.' + } + + $snapshot = $evidence.finalSnapshot + $state = $snapshot.state + $playout = $state.playout + $dom = $snapshot.dom + if ($null -eq $snapshot -or $null -eq $state -or $null -eq $playout -or + $null -eq $dom -or + $state.isBusy -ne $false -or + [string]$playout.mode -cne 'dryRun' -or + [string]$playout.connectionState -cne 'dryRunReady' -or + [string]$playout.phase -cne 'idle' -or + $playout.isConnected -ne $false -or + $playout.liveTakeInAllowed -ne $false -or + $playout.isPlayCompletionPending -ne $false -or + $playout.isTakeOutCompletionPending -ne $false -or + $playout.outcomeUnknown -ne $false -or + $playout.isBusy -ne $false -or + $playout.refreshActive -ne $false -or + $null -ne $playout.preparedCode -or + $null -ne $playout.onAirCode -or + $null -ne $playout.currentEntryId -or + $snapshot.safety.postMessageWrapped -ne $true -or + @($snapshot.safety.blockedOutboundMessages).Count -ne 0 -or + @($snapshot.safety.stateViolations).Count -ne 0 -or + [string]$dom.readyState -cne 'complete' -or + [string]$dom.url -cne 'https://legacy-parity.mbn.local/index.html' -or + [string]$dom.bodyBusy -cne 'false' -or + [string]$dom.connectionText -cne 'DRY RUN' -or + $dom.legacyDialogHidden -ne $true -or + $dom.namedModalHidden -ne $true -or + $dom.namedConfirmationHidden -ne $true) { + Throw-SafeFailure 'The sealed final application state is not exact DryRun IDLE.' + } + + $expectedAfterOrder = @($request.afterOrder | ForEach-Object { [string]$_ }) + $statePlaylist = @($state.playlist) + $domPlaylist = @($dom.playlist) + $statePlaylistIds = @($statePlaylist | ForEach-Object { [string]$_.rowId }) + $domPlaylistIds = @($domPlaylist | ForEach-Object { [string]$_.rowId }) + $selectedStateIds = @($statePlaylist | Where-Object { $_.isSelected -eq $true } | + ForEach-Object { [string]$_.rowId }) + $activeStateIds = @($statePlaylist | Where-Object { $_.isActive -eq $true } | + ForEach-Object { [string]$_.rowId }) + $selectedDomIds = @($domPlaylist | Where-Object { $_.selected -eq $true } | + ForEach-Object { [string]$_.rowId }) + $activeDomIds = @($domPlaylist | Where-Object { $_.active -eq $true } | + ForEach-Object { [string]$_.rowId }) + $dragVisualCount = @($domPlaylist | Where-Object { + $_.dragging -eq $true -or $_.dragBefore -eq $true -or $_.dragAfter -eq $true + }).Count + if (-not (Test-ExactStringSequence $statePlaylistIds $expectedAfterOrder) -or + -not (Test-ExactStringSequence $domPlaylistIds $expectedAfterOrder) -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 + -not (Test-ExactStringSequence ` + $activeDomIds ` + @([string]$request.expectedActiveRowIdAfterHome)) -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.' + } + + return $evidence + } + catch [InvalidOperationException] { + throw + } + catch { + # Never echo evidence JSON or parser diagnostics. + Throw-SafeFailure 'The real input evidence could not be validated safely.' + } +} + +$timestamp = [datetime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ') +$Output = Resolve-EvidencePath $Output "legacy-package-input-$timestamp.json" +$Screenshot = Resolve-EvidencePath $Screenshot "legacy-package-input-$timestamp.png" +$WindowsInputRequest = [IO.Path]::ChangeExtension( + $Output, + '.windows-input-request.json') +$WindowsInputAcknowledgement = [IO.Path]::ChangeExtension( + $Output, + '.windows-input-ack.json') +$NodePath = Resolve-NodeExecutable $NodePath + +try { + if (-not [IO.File]::Exists($HarnessPath)) { + Throw-SafeFailure 'The tracked package input harness is missing.' + } + if (-not [string]::Equals( + [IO.Path]::GetExtension($Output), '.json', [StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals( + [IO.Path]::GetExtension($Screenshot), '.png', [StringComparison]::OrdinalIgnoreCase)) { + Throw-SafeFailure 'Output must be .json and screenshot must be .png.' + } + $evidencePaths = @( + $Output, + $Screenshot, + $WindowsInputRequest, + $WindowsInputAcknowledgement) + for ($leftIndex = 0; $leftIndex -lt $evidencePaths.Count; $leftIndex++) { + for ($rightIndex = $leftIndex + 1; $rightIndex -lt $evidencePaths.Count; $rightIndex++) { + if (Test-PathEqual $evidencePaths[$leftIndex] $evidencePaths[$rightIndex]) { + Throw-SafeFailure 'All evidence paths must be different.' + } + } + } + + Assert-OutputPathAvailable $Output 'Output' + Assert-OutputPathAvailable $Screenshot 'Screenshot' + Assert-OutputPathAvailable $WindowsInputRequest 'Windows input request' + Assert-OutputPathAvailable $WindowsInputAcknowledgement 'Windows input acknowledgement' + 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 = 'real input harness' + $harnessRun = Invoke-NodeHarnessUnderTcpWatch ` + $NodePath ` + $HarnessPath ` + $Port ` + $Output ` + $Screenshot ` + $WindowsInputRequest ` + $WindowsInputAcknowledgement ` + $HarnessTimeoutSeconds ` + $script:ApplicationProcess ` + $registration.Executable + if ($harnessRun.ExitCode -ne 0) { + Throw-SafeFailure "The real input harness failed with exit code $($harnessRun.ExitCode)." + } + 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) { + Throw-SafeFailure 'The real input evidence files are missing or empty.' + } + $harnessEvidence = Read-AndAssertHarnessEvidence ` + $Output ` + $Screenshot ` + $WindowsInputRequest ` + $WindowsInputAcknowledgement ` + $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.' + } + + Assert-ExactApplicationStillRunning $script:ApplicationProcess $registration.Executable + Assert-NoTornadoConnection $script:ApplicationProcess.Id + + $script:Phase = 'single-instance activation' + $singleInstance = Assert-SingleInstanceActivation ` + $script:ApplicationProcess ` + $registration.Executable + + $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]$harnessEvidence.result + packageFullName = $PackageFullName + packageMode = 'Development Release x64' + playoutMode = 'DryRun' + processId = $script:ApplicationProcess.Id + cdpAddress = '127.0.0.1' + cdpPort = $Port + tornadoPortConnections = 0 + tornadoMonitoringScope = 'application PID from exact launch identity through normal exit' + windowsInputRequestCalls = $script:WindowsInputRequestCalls + windowsInputAcknowledgementCalls = $script:WindowsInputAcknowledgementCalls + windowsInputRequest = $WindowsInputRequest + windowsInputRequestSha256 = [string]$harnessRun.WindowsInput.RequestSha256 + windowsInputAcknowledgement = $WindowsInputAcknowledgement + windowsInputAcknowledgementSha256 = ` + [string]$harnessRun.WindowsInput.AcknowledgementSha256 + singleInstanceActivationCalls = $singleInstance.ActivationCalls + singleInstanceProcessCount = $singleInstance.ProcessCount + singleInstanceRedirectorProcessId = $singleInstance.RedirectorProcessId + singleInstanceRedirectorStartTimeUtc = $singleInstance.RedirectorStartTimeUtc + singleInstanceRedirectorExited = $singleInstance.RedirectorExited + singleInstanceStableOriginalMilliseconds = $singleInstance.StableOriginalMilliseconds + closeMainWindowCalls = $script:CloseMainWindowCalls + primaryInvokeCalls = $script:PrimaryInvokeCalls + output = $Output + outputSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $Output).Hash + screenshot = $Screenshot + screenshotSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $Screenshot).Hash + } +} +catch { + $message = $_.Exception.Message + if ($script:CloseMainWindowCalls -gt 0 -or $script:PrimaryInvokeCalls -gt 0) { + Throw-SafeFailure ( + "Package input 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 (-not $running) { + try { + $running = @(Get-LegacyApplicationProcesses).Count -gt 0 + } + catch { + # An inability to re-enumerate cannot authorize another close attempt. + $running = $false + } + } + + if ($running) { + Throw-SafeFailure ( + "Package input smoke stopped during '$($script:Phase)': $message " + + 'The application was intentionally left open; no close retry or forced termination was attempted.') + } + + throw +} diff --git a/scripts/Invoke-LegacyPackageUiOnlySmoke.ps1 b/scripts/Invoke-LegacyPackageUiOnlySmoke.ps1 new file mode 100644 index 0000000..0ebabac --- /dev/null +++ b/scripts/Invoke-LegacyPackageUiOnlySmoke.ps1 @@ -0,0 +1,242 @@ +[CmdletBinding()] +param( + [ValidateRange(1024, 65535)] + [int]$Port = 9340, + [string]$Output, + [string]$Screenshot, + [string]$NodePath, + [ValidateRange(10, 120)] + [int]$LaunchTimeoutSeconds = 30, + [ValidateRange(5, 120)] + [int]$HarnessTimeoutSeconds = 45 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +using System.Text; +public static class LegacyPackageUiOnlyNative +{ + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern int GetPackageFullName(IntPtr process, ref int length, StringBuilder value); + public static string PackageFullName(IntPtr process) + { + int length = 0; + if (GetPackageFullName(process, ref length, null) != 122 || length < 2 || length > 4096) + throw new InvalidOperationException("Package identity is unavailable."); + var value = new StringBuilder(length); + if (GetPackageFullName(process, ref length, value) != 0 || value.Length == 0) + throw new InvalidOperationException("Package identity could not be read."); + return value.ToString(); + } +} +'@ + +$PackageName = 'Wickedness.MBNStockWebView.LegacyParity' +$PackageFamilyName = 'Wickedness.MBNStockWebView.LegacyParity_qbv3jkvsn3aj0' +$PackageFullName = 'Wickedness.MBNStockWebView.LegacyParity_0.1.0.0_x64__qbv3jkvsn3aj0' +$PackagePublisher = 'CN=Comtrophy' +$ApplicationId = 'LegacyParityApp' +$ExecutableName = 'MBN_STOCK_WEBVIEW.LegacyParityApp.exe' +$RepositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$Harness = Join-Path $PSScriptRoot 'Test-LegacyPackageUiOnlySmoke.mjs' +$ExpectedInstallLocation = [IO.Path]::GetFullPath((Join-Path $RepositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Release\net8.0-windows10.0.19041.0\win-x64')) +$ConfigurationPath = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'MBN_STOCK_WEBVIEW\Config\playout.local.json' +$ExpectedWindowTitle = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('Vi1TdG9jayDspp3qtozsoJXrs7TshqHstpzsi5zsiqTthZwgZm9yIOunpOydvOqyveygnFRWICgyNi4wMy4yNik=')) +$ExpectedDialogTitle = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('66ek7J286rK97KCcVFY=')) +$ExpectedDialogMessage = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7ZSE66Gc6re4656o7J2EIOyiheujjO2VmOqyoOyKteuLiOq5jD8=')) +$ExpectedPrimaryButton = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7JiI')) +$ExpectedCloseButton = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7JWE64uI7JqU')) +$script:Application = $null +$script:Launched = $false +$script:CloseCalls = 0 +$script:ConfirmCalls = 0 + +function Fail([string]$Message) { throw [InvalidOperationException]::new($Message) } +function SamePath([string]$Left, [string]$Right) { [string]::Equals([IO.Path]::GetFullPath($Left).TrimEnd('\'), [IO.Path]::GetFullPath($Right).TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase) } +function Resolve-Evidence([string]$Value, [string]$Name) { + if ([string]::IsNullOrWhiteSpace($Value)) { return [IO.Path]::GetFullPath((Join-Path $RepositoryRoot (Join-Path 'artifacts\package-ui-only-smoke' $Name))) } + if (-not [IO.Path]::IsPathRooted($Value)) { return [IO.Path]::GetFullPath((Join-Path $RepositoryRoot $Value)) } + return [IO.Path]::GetFullPath($Value) +} +function Assert-NewFile([string]$Path, [string]$Label) { + if ([IO.File]::Exists($Path) -or [IO.Directory]::Exists($Path)) { Fail "$Label already exists; evidence is never overwritten." } + $parent = [IO.Path]::GetDirectoryName($Path) + if ([string]::IsNullOrWhiteSpace($parent)) { Fail "$Label has no parent." } + [void][IO.Directory]::CreateDirectory($parent) + if (((Get-Item -LiteralPath $parent -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { Fail "$Label parent must not be a reparse point." } +} +function Resolve-Node([string]$Requested) { + if (-not [string]::IsNullOrWhiteSpace($Requested)) { $candidate = [IO.Path]::GetFullPath($Requested) } + else { $candidate = Join-Path $env:USERPROFILE '.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe' } + if (-not [IO.File]::Exists($candidate) -or [IO.Path]::GetFileName($candidate) -cne 'node.exe') { Fail 'A valid node.exe is required.' } + [IO.Path]::GetFullPath($candidate) +} +function Assert-DryRunConfig { + if (-not [IO.File]::Exists($ConfigurationPath)) { return } + $item = Get-Item -LiteralPath $ConfigurationPath -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or $item.Length -gt 1MB) { Fail 'The local playout configuration cannot be inspected safely.' } + try { $raw = [IO.File]::ReadAllText($ConfigurationPath, [Text.Encoding]::UTF8); $parsed = $raw | ConvertFrom-Json -ErrorAction Stop } catch { Fail 'The local playout configuration is invalid.' } + $matches = [regex]::Matches($raw, '(?i)"mode"\s*:') + $property = @($parsed.PSObject.Properties | Where-Object { [string]::Equals($_.Name, 'Mode', [StringComparison]::OrdinalIgnoreCase) }) + if (($matches.Count -eq 0 -and $property.Count -eq 0)) { return } + if ($matches.Count -ne 1 -or $property.Count -ne 1 -or -not [string]::Equals([string]$property[0].Value, 'DryRun', [StringComparison]::OrdinalIgnoreCase)) { Fail 'The local playout mode is not uniquely DryRun.' } +} +function Assert-NoUnsafeEnvironment { + foreach ($scope in @([EnvironmentVariableTarget]::Process, [EnvironmentVariableTarget]::User, [EnvironmentVariableTarget]::Machine)) { + foreach ($nameObject in [Environment]::GetEnvironmentVariables($scope).Keys) { + $name = [string]$nameObject + if ($name -like 'MBN_STOCK_PLAYOUT_*' -or $name -like 'WEBVIEW2_*' -or $name -like 'COREWEBVIEW2_*' -or $name -in @('MBN_STOCK_K3D_NATIVE_SHA256', 'MBN_STOCK_K3D_INTEROP_SHA256')) { Fail "Unsafe launch environment variable is present: $scope/$name." } + } + } +} +function Get-Registration { + $packages = @(Get-AppxPackage -Name $PackageName -ErrorAction Stop) + if ($packages.Count -ne 1) { Fail 'Exactly one development package is required.' } + $package = $packages[0] + if (-not $package.IsDevelopmentMode -or [string]$package.SignatureKind -cne 'None' -or [string]$package.Status -cne 'Ok' -or [string]$package.PackageFullName -cne $PackageFullName -or [string]$package.PackageFamilyName -cne $PackageFamilyName -or [string]$package.Publisher -cne $PackagePublisher -or [string]$package.Version -cne '0.1.0.0' -or [string]$package.Architecture -cne 'X64' -or -not (SamePath $package.InstallLocation $ExpectedInstallLocation)) { Fail 'The exact Release x64 development registration is not present.' } + $executable = Join-Path $package.InstallLocation $ExecutableName + if (-not [IO.File]::Exists($executable)) { Fail 'The registered executable is missing.' } + [pscustomobject]@{ InstallLocation = [IO.Path]::GetFullPath($package.InstallLocation); Executable = [IO.Path]::GetFullPath($executable) } +} +function Get-AppProcesses { @(Get-Process -Name ([IO.Path]::GetFileNameWithoutExtension($ExecutableName)) -ErrorAction SilentlyContinue | Where-Object { -not $_.HasExited }) } +function Get-ProcessTcpConnections([int]$ProcessId) { + try { @(Get-NetTCPConnection -ErrorAction Stop | Where-Object { [int]$_.OwningProcess -eq $ProcessId }) } + catch { Fail 'The system TCP table could not be inspected.' } +} +function Assert-NoTornado([int]$ProcessId) { + $matches = @(Get-ProcessTcpConnections $ProcessId | Where-Object { $_.LocalPort -eq 30001 -or $_.RemotePort -eq 30001 }) + if ($matches.Count -ne 0) { Fail 'The DryRun package process owns a Tornado port 30001 connection.' } +} +function Assert-OnlyLoopbackTcp([int]$ProcessId) { + $connections = @(Get-ProcessTcpConnections $ProcessId) + foreach ($connection in $connections) { + $isListener = [string]$connection.State -ceq 'Listen' + $localLoopback = [string]$connection.LocalAddress -in @('127.0.0.1', '::1') + $connectedLoopback = ([string]$connection.LocalAddress -in @('127.0.0.1', '::1')) -and ([string]$connection.RemoteAddress -in @('127.0.0.1', '::1')) + if ((-not $isListener -and -not $connectedLoopback) -or ($isListener -and -not $localLoopback)) { + Fail 'The package process owns a non-loopback TCP connection.' + } + } +} +function Assert-PortFree([int]$CandidatePort) { + if ($CandidatePort -eq 30001 -or @(Get-NetTCPConnection -State Listen -LocalPort $CandidatePort -ErrorAction SilentlyContinue).Count -ne 0) { Fail 'The selected CDP port is unavailable or unsafe.' } +} +function Assert-Application([Diagnostics.Process]$Application, [string]$Path) { + $Application.Refresh() + if ($Application.HasExited -or -not (SamePath $Application.Path $Path) -or $Application.MainWindowHandle -eq [IntPtr]::Zero -or [string]$Application.MainWindowTitle -cne $ExpectedWindowTitle -or [LegacyPackageUiOnlyNative]::PackageFullName($Application.Handle) -cne $PackageFullName) { Fail 'The application identity changed or is not exact.' } + Assert-NoTornado $Application.Id + Assert-OnlyLoopbackTcp $Application.Id +} +function Start-Package([pscustomobject]$Registration, [int]$DebugPort) { + $command = Get-Command Invoke-CommandInDesktopPackage -ErrorAction SilentlyContinue + if ($null -eq $command -or [string]$command.Source -cne 'Appx') { Fail 'The Appx package-context launch helper is required.' } + $encodedExe = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Registration.Executable)) + $encodedDir = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Registration.InstallLocation)) + $arguments = "--remote-debugging-address=127.0.0.1 --remote-debugging-port=$DebugPort" + $encodedArgs = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($arguments)) + $child = @" +`$exe=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$encodedExe')) +`$dir=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$encodedDir')) +`$args=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$encodedArgs')) +[Environment]::SetEnvironmentVariable('MBN_STOCK_PLAYOUT_MODE','DryRun',[EnvironmentVariableTarget]::Process) +[Environment]::SetEnvironmentVariable('WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS',`$args,[EnvironmentVariableTarget]::Process) +`$start=New-Object Diagnostics.ProcessStartInfo +`$start.FileName=`$exe; `$start.WorkingDirectory=`$dir; `$start.UseShellExecute=`$false +if (`$null -eq [Diagnostics.Process]::Start(`$start)) { exit 91 } +"@ + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($child)) + $notBefore = [datetime]::UtcNow + Invoke-CommandInDesktopPackage -PackageFamilyName $PackageFamilyName -AppId $ApplicationId -Command (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') -Args "-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand $encoded" -PreventBreakaway -ErrorAction Stop | Out-Null + $deadline = [datetime]::UtcNow.AddSeconds($LaunchTimeoutSeconds) + while ([datetime]::UtcNow -lt $deadline) { + $items = @(Get-AppProcesses) + if ($items.Count -gt 1) { Fail 'More than one package application process appeared.' } + if ($items.Count -eq 1 -and $items[0].StartTime.ToUniversalTime() -ge $notBefore.AddSeconds(-1)) { + $candidate=$items[0] + $candidate.Refresh() + if (-not (SamePath $candidate.Path $Registration.Executable) -or [LegacyPackageUiOnlyNative]::PackageFullName($candidate.Handle) -cne $PackageFullName) { Fail 'The launched package process identity is not exact.' } + if ($candidate.MainWindowHandle -ne [IntPtr]::Zero) { + if ([string]$candidate.MainWindowTitle -cne $ExpectedWindowTitle) { Fail 'The launched package window title is not exact.' } + Assert-Application $candidate $Registration.Executable + return $candidate + } + } + Start-Sleep -Milliseconds 100 + } + Fail 'The exact packaged application did not start in time.' +} +function Test-DescendsFrom([int]$ProcessId, [int]$AncestorId) { + $visited = [Collections.Generic.HashSet[int]]::new(); $current = $ProcessId + while ($current -gt 0 -and $visited.Add($current)) { + if ($current -eq $AncestorId) { return $true } + try { $record = Get-CimInstance Win32_Process -Filter "ProcessId = $current" -ErrorAction Stop } catch { return $false } + if ($null -eq $record) { return $false }; $current = [int]$record.ParentProcessId + } + return $false +} +function Wait-Listener([int]$CandidatePort, [int]$ProcessId) { + $deadline = [datetime]::UtcNow.AddSeconds($LaunchTimeoutSeconds) + while ([datetime]::UtcNow -lt $deadline) { + $listeners = @(Get-NetTCPConnection -State Listen -LocalPort $CandidatePort -ErrorAction SilentlyContinue) + if ($listeners.Count -eq 1) { + try { $owner = Get-Process -Id ([int]$listeners[0].OwningProcess) -ErrorAction Stop } catch { $owner = $null } + if ($null -ne $owner -and [IO.Path]::GetFileName($owner.Path) -ceq 'msedgewebview2.exe' -and (Test-DescendsFrom $owner.Id $ProcessId)) { return } + } + Start-Sleep -Milliseconds 100 + } + Fail 'The exact WebView loopback listener did not appear.' +} +function Invoke-NormalClose([Diagnostics.Process]$Application, [string]$Path) { + Assert-Application $Application $Path + Add-Type -AssemblyName UIAutomationClient; Add-Type -AssemblyName UIAutomationTypes + $script:CloseCalls++; if ($script:CloseCalls -ne 1 -or -not $Application.CloseMainWindow()) { Fail 'The single normal close request was not accepted.' } + $deadline = [datetime]::UtcNow.AddSeconds(15); $dialog = $null + while ([datetime]::UtcNow -lt $deadline) { + $Application.Refresh(); if ($Application.HasExited) { Fail 'The application exited before neutral confirmation.' }; Assert-NoTornado $Application.Id; Assert-OnlyLoopbackTcp $Application.Id + $root=[Windows.Automation.AutomationElement]::FromHandle($Application.MainWindowHandle) + if ($null -ne $root) { $matches=@($root.FindAll([Windows.Automation.TreeScope]::Descendants,[Windows.Automation.Condition]::TrueCondition) | Where-Object { $_.Current.ControlType -eq [Windows.Automation.ControlType]::Window -and $_.Current.ClassName -ceq 'Popup' -and $_.Current.Name -ceq $ExpectedDialogTitle }); if ($matches.Count -gt 1) { Fail 'The neutral close dialog is not unique.' }; if ($matches.Count -eq 1) { $dialog=$matches[0]; break } } + Start-Sleep -Milliseconds 100 + } + if ($null -eq $dialog) { Fail 'The exact neutral close dialog did not appear.' } + $all=$dialog.FindAll([Windows.Automation.TreeScope]::Descendants,[Windows.Automation.Condition]::TrueCondition) + $buttons=@($all | Where-Object { $_.Current.ControlType -eq [Windows.Automation.ControlType]::Button }) + $texts=@($all | Where-Object { $_.Current.ControlType -eq [Windows.Automation.ControlType]::Text }) + $yes=@($buttons | Where-Object { $_.Current.AutomationId -ceq 'PrimaryButton' -and $_.Current.Name -ceq $ExpectedPrimaryButton -and $_.Current.IsEnabled }); $no=@($buttons | Where-Object { $_.Current.AutomationId -ceq 'CloseButton' -and $_.Current.Name -ceq $ExpectedCloseButton -and $_.Current.IsEnabled }); $message=@($texts | Where-Object { $_.Current.Name -ceq $ExpectedDialogMessage }) + if ($buttons.Count -ne 2 -or $yes.Count -ne 1 -or $no.Count -ne 1 -or $message.Count -ne 1) { Fail 'The close confirmation is not the exact neutral dialog.' } + $pattern=$null; if (-not $yes[0].TryGetCurrentPattern([Windows.Automation.InvokePattern]::Pattern,[ref]$pattern)) { Fail 'The exact close confirmation cannot be invoked.' } + $script:ConfirmCalls++; if ($script:ConfirmCalls -ne 1) { Fail 'The close confirmation budget changed.' }; ($pattern -as [Windows.Automation.InvokePattern]).Invoke() + $exitDeadline=[datetime]::UtcNow.AddSeconds(30); while ([datetime]::UtcNow -lt $exitDeadline) { $Application.Refresh(); if ($Application.HasExited) { return }; Assert-NoTornado $Application.Id; Assert-OnlyLoopbackTcp $Application.Id; Start-Sleep -Milliseconds 100 }; Fail 'Normal exit was not observed.' +} + +$stamp=[datetime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ'); $Output=Resolve-Evidence $Output "legacy-package-ui-only-$stamp.json"; $Screenshot=Resolve-Evidence $Screenshot "legacy-package-ui-only-$stamp.png"; $NodePath=Resolve-Node $NodePath +try { + if (-not [IO.File]::Exists($Harness)) { Fail 'The UI-only harness is missing.' } + Assert-NewFile $Output 'Output'; Assert-NewFile $Screenshot 'Screenshot'; Assert-DryRunConfig; Assert-NoUnsafeEnvironment; $registration=Get-Registration; if (@(Get-AppProcesses).Count -ne 0) { Fail 'An application instance is already running.' }; Assert-PortFree $Port + $script:Application=Start-Package $registration $Port; $script:Launched=$true; Wait-Listener $Port $script:Application.Id; Assert-NoTornado $script:Application.Id; Assert-OnlyLoopbackTcp $script:Application.Id + $arguments=@($Harness,'--port',$Port,'--output',$Output,'--screenshot',$Screenshot,'--timeout-ms',($HarnessTimeoutSeconds*1000)); $run=Start-Process -FilePath $NodePath -ArgumentList $arguments -WorkingDirectory $RepositoryRoot -NoNewWindow -PassThru + $harnessDeadline=[datetime]::UtcNow.AddSeconds($HarnessTimeoutSeconds + 10) + while (-not $run.HasExited -and [datetime]::UtcNow -lt $harnessDeadline) { Assert-Application $script:Application $registration.Executable; Start-Sleep -Milliseconds 100; $run.Refresh() } + if (-not $run.HasExited) { Fail 'The UI-only CDP harness did not finish in time; it was not terminated.' } + $run.WaitForExit() + $run.Refresh() + $harnessExitCode=[int]$run.ExitCode + if ($harnessExitCode -ne 0) { Fail "The UI-only CDP harness failed with exit code $harnessExitCode." } + if (-not [IO.File]::Exists($Output) -or -not [IO.File]::Exists($Screenshot)) { Fail 'The UI-only evidence files are missing.' } + $evidence=[IO.File]::ReadAllText($Output,[Text.Encoding]::UTF8) | ConvertFrom-Json -ErrorAction Stop + $screenshotItem=Get-Item -LiteralPath $Screenshot -Force + $screenshotHash=(Get-FileHash -Algorithm SHA256 -LiteralPath $Screenshot).Hash + if ([int]$evidence.schemaVersion -ne 1 -or [string]$evidence.result -cne 'PASS' -or $null -ne $evidence.error -or [string]$evidence.target.expectedUrl -cne 'https://legacy-parity.mbn.local/index.html' -or [string]$evidence.target.url -cne 'https://legacy-parity.mbn.local/index.html' -or [int]$evidence.target.port -ne $Port -or $evidence.safety.noInputDispatched -ne $true -or $evidence.safety.noNativeIntentAfterGuard -ne $true -or @($evidence.safety.observedMessagesAfterGuard).Count -ne 0 -or @($evidence.safety.blockedMessages).Count -ne 0 -or [string]$evidence.shell.connectionText -cne 'DRY RUN' -or -not ([string]$evidence.shell.playoutStatus).StartsWith('IDLE',[StringComparison]::Ordinal) -or [string]$evidence.shell.readyState -cne 'complete' -or -not (SamePath ([string]$evidence.screenshot.path) $Screenshot) -or $screenshotItem.Length -le 0 -or [int64]$evidence.screenshot.bytes -ne $screenshotItem.Length -or [string]$evidence.screenshot.sha256 -cne $screenshotHash) { Fail 'The sealed UI-only evidence is not exact.' } + Assert-Application $script:Application $registration.Executable; Invoke-NormalClose $script:Application $registration.Executable + [pscustomobject]@{ result='PASS'; packageFullName=$PackageFullName; packageMode='Development Release x64'; playoutMode='DryRun'; processId=$script:Application.Id; cdpAddress='127.0.0.1'; cdpPort=$Port; tornadoPortConnections=0; nativePostMessageIntentsAfterGuard=0; mouseKeyboardInput=0; singleInstanceActivation=0; dbReadIntent=0; dbWriteIntent=0; closeMainWindowCalls=$script:CloseCalls; primaryInvokeCalls=$script:ConfirmCalls; output=$Output; screenshot=$Screenshot } +} +catch { + $message=$_.Exception.Message + if ($script:CloseCalls -gt 0 -or $script:ConfirmCalls -gt 0) { Fail "UI-only package smoke became ambiguous: $message No close retry or forced termination was attempted." } + $running=$false; if ($script:Launched -and $null -ne $script:Application) { try { $script:Application.Refresh(); $running=-not $script:Application.HasExited } catch {} } + if ($running) { Fail "UI-only package smoke stopped: $message The application was intentionally left open; no forced termination was attempted." } + throw +} diff --git a/scripts/Test-LegacyPackageInput.mjs b/scripts/Test-LegacyPackageInput.mjs new file mode 100644 index 0000000..e2ec9fa --- /dev/null +++ b/scripts/Test-LegacyPackageInput.mjs @@ -0,0 +1,2036 @@ +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 ctrlModifier = 2; +const shiftModifier = 8; +const defaultObservationTimeoutMilliseconds = 45_000; +const minimumObservationTimeoutMilliseconds = 5_000; +const maximumObservationTimeoutMilliseconds = 300_000; +const acceptedArgumentNames = new Set([ + "port", + "output", + "screenshot", + "windows-input-request", + "windows-input-ack", + "timeout-ms" +]); +const forbiddenControlIds = Object.freeze([ + "playout-prepare", + "playout-take-in", + "playout-next", + "playout-take-out", + "named-playlist-create-button", + "named-playlist-delete-button", + "named-playlist-confirmation-yes", + "operator-catalog-save", + "operator-catalog-delete" +]); +const allowedOutboundMessageTypes = Object.freeze([ + "ready", + "search-stocks", + "select-stock", + "cut-pointer-down", + "cut-key-down", + "drop-selected-cuts", + "activate-playlist-row", + "select-playlist-row", + "reorder-playlist-rows", + "select-playlist-boundary", + "refresh-named-playlists" +]); +const playoutIntentMessageTypes = Object.freeze([ + "prepare-playout", + "take-in", + "next-playout", + "take-out", + "choose-background", + "toggle-background" +]); +const databaseWriteIntentMessageTypes = Object.freeze([ + "create-named-playlist", + "delete-selected-named-playlist", + "save-current-named-playlist-to", + "save-manual-financial", + "delete-manual-financial", + "delete-all-manual-financial", + "import-manual-lists", + "confirm-manual-list", + "save-operator-catalog", + "delete-operator-catalog", + "delete-uc4-selected-theme", + "delete-uc6-selected-expert" +]); +const forbiddenCdpMethods = new Set([ + "Browser.close", + "Page.close", + "Runtime.terminateExecution", + "Target.closeTarget" +]); + +class HarnessFailure extends Error { + constructor(category, message) { + super(`${category}: ${message}`); + this.name = "HarnessFailure"; + this.category = category; + } +} + +function failKnown(message) { + throw new HarnessFailure("KNOWN_FAILURE", message); +} + +function failUnknown(message) { + throw new HarnessFailure("OUTCOME_UNKNOWN", message); +} + +function parseArguments(argv) { + if (argv.length === 0 || argv.length % 2 !== 0) { + failKnown( + "Usage: node scripts/Test-LegacyPackageInput.mjs --port " + + "--output --screenshot " + + "--windows-input-request " + + "--windows-input-ack " + + "[--timeout-ms ]"); + } + + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!name?.startsWith("--") || value === undefined || value.length === 0) { + failKnown("Arguments must be non-empty --name value pairs."); + } + + const key = name.slice(2); + if (!acceptedArgumentNames.has(key)) { + failKnown(`Unknown argument --${key}.`); + } + if (values.has(key)) { + failKnown(`Argument --${key} was supplied more than once.`); + } + values.set(key, value); + } + + if (!values.has("port") || !values.has("output") || + !values.has("screenshot") || !values.has("windows-input-request") || + !values.has("windows-input-ack")) { + failKnown( + "--port, --output, --screenshot, --windows-input-request, and " + + "--windows-input-ack are required."); + } + + const port = Number(values.get("port")); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + failKnown("--port must be an integer from 1 through 65535."); + } + + const outputPath = path.resolve(values.get("output")); + if (!path.isAbsolute(values.get("output")) || + path.extname(outputPath).toLowerCase() !== ".json") { + failKnown("--output must be an absolute .json path."); + } + + const screenshotPath = path.resolve(values.get("screenshot")); + if (!path.isAbsolute(values.get("screenshot")) || + path.extname(screenshotPath).toLowerCase() !== ".png") { + failKnown("--screenshot must be an absolute .png path."); + } + if (outputPath.toLowerCase() === screenshotPath.toLowerCase()) { + failKnown("--output and --screenshot must name different files."); + } + + const windowsInputRequestPath = path.resolve(values.get("windows-input-request")); + const windowsInputAckPath = path.resolve(values.get("windows-input-ack")); + for (const [name, candidate] of [ + ["--windows-input-request", windowsInputRequestPath], + ["--windows-input-ack", windowsInputAckPath] + ]) { + if (!path.isAbsolute(values.get(name.slice(2))) || + path.extname(candidate).toLowerCase() !== ".json") { + failKnown(`${name} must be an absolute .json path.`); + } + } + const uniqueEvidencePaths = new Set([ + outputPath.toLowerCase(), + screenshotPath.toLowerCase(), + windowsInputRequestPath.toLowerCase(), + windowsInputAckPath.toLowerCase() + ]); + if (uniqueEvidencePaths.size !== 4) { + failKnown("All result, screenshot, Windows-input request, and ack paths must differ."); + } + + const timeoutMilliseconds = values.has("timeout-ms") + ? Number(values.get("timeout-ms")) + : defaultObservationTimeoutMilliseconds; + if (!Number.isInteger(timeoutMilliseconds) || + timeoutMilliseconds < minimumObservationTimeoutMilliseconds || + timeoutMilliseconds > maximumObservationTimeoutMilliseconds) { + failKnown( + `--timeout-ms must be an integer from ${minimumObservationTimeoutMilliseconds} ` + + `through ${maximumObservationTimeoutMilliseconds}.`); + } + + if (fs.existsSync(outputPath) || fs.existsSync(screenshotPath) || + fs.existsSync(windowsInputRequestPath) || fs.existsSync(windowsInputAckPath)) { + failKnown("An evidence path already exists; evidence is never overwritten."); + } + + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.mkdirSync(path.dirname(screenshotPath), { recursive: true }); + fs.mkdirSync(path.dirname(windowsInputRequestPath), { recursive: true }); + fs.mkdirSync(path.dirname(windowsInputAckPath), { recursive: true }); + return { + port, + outputPath, + screenshotPath, + windowsInputRequestPath, + windowsInputAckPath, + timeoutMilliseconds + }; +} + +function assertWithinHardDeadline(label) { + if (externalCancellationRequested && !cleanupMode) { + failUnknown(`The wrapper requested cancellation ${label}; no action is retried.`); + } + const deadline = cleanupMode + ? cleanupDeadlineEpochMilliseconds + : hardDeadlineEpochMilliseconds; + if (Date.now() >= deadline) { + failUnknown(`The global input deadline expired ${label}; no action is retried.`); + } +} + +async function sleep(milliseconds) { + assertWithinHardDeadline("before waiting"); + const activeDeadline = cleanupMode + ? cleanupDeadlineEpochMilliseconds + : hardDeadlineEpochMilliseconds; + const remaining = activeDeadline - Date.now(); + await new Promise(resolve => setTimeout(resolve, Math.min(milliseconds, remaining))); + assertWithinHardDeadline("after waiting"); +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex").toUpperCase(); +} + +function errorProjection(error) { + return { + category: error instanceof HarnessFailure ? error.category : "HARNESS_ERROR", + name: error?.name || "Error", + message: String(error?.message || error) + }; +} + +const configuration = parseArguments(process.argv.slice(2)); +const hardDeadlineEpochMilliseconds = Date.now() + configuration.timeoutMilliseconds; +const startedAt = new Date().toISOString(); +const evidence = { + schemaVersion: 1, + result: "RUNNING", + startedAt, + completedAt: null, + target: { + expectedUrl: exactTargetUrl, + port: configuration.port, + timeoutMilliseconds: configuration.timeoutMilliseconds, + id: null, + url: null + }, + safety: { + requiredMode: "dryRun", + requiredPhase: "idle", + forbiddenControlIds, + allowedOutboundMessageTypes, + appCloseRequested: false, + playoutIntentIssued: false, + databaseWriteIntentIssued: false, + observedOutboundMessageTypes: [], + blockedOutboundMessages: [], + stateViolations: [], + externalCancellationRequested: false + }, + fixture: { + query: "삼성", + stockDisplayName: "삼성출판사" + }, + checkpoints: [], + inputs: [], + warnings: [], + windowsInput: { + required: true, + requestPath: configuration.windowsInputRequestPath, + acknowledgementPath: configuration.windowsInputAckPath, + requestSha256: null, + acknowledgementSha256: null, + result: "PENDING" + }, + screenshot: null, + finalSnapshot: null, + error: null +}; + +let socket = null; +let nextRequestId = 1; +const pendingRequests = new Map(); +let externalCancellationRequested = false; +let cancellationInputBuffer = ""; +let pointerIsPressed = false; +let pointerReleasePoint = null; +let pointerReleaseModifiers = 0; +let probeInstalled = false; +let cleanupMode = false; +let cleanupDeadlineEpochMilliseconds = 0; + +process.stdin.setEncoding("utf8"); +process.stdin.on("data", chunk => { + cancellationInputBuffer += chunk; + if (!cancellationInputBuffer.split(/\r?\n/u).some(line => line === "CANCEL")) { + if (cancellationInputBuffer.length > 64) cancellationInputBuffer = ""; + return; + } + externalCancellationRequested = true; + evidence.safety.externalCancellationRequested = true; + for (const pending of pendingRequests.values()) { + clearTimeout(pending.timer); + pending.reject(new HarnessFailure( + "OUTCOME_UNKNOWN", + `Wrapper cancellation interrupted ${pending.method}; the command is not retried.`)); + } + pendingRequests.clear(); +}); +if (typeof process.stdin.unref === "function") process.stdin.unref(); + +async function discoverExactTarget() { + const deadline = Date.now() + Math.min( + configuration.timeoutMilliseconds, + 30_000); + const discoveryUrl = `http://127.0.0.1:${configuration.port}/json/list`; + let lastFailure = null; + + while (Date.now() < deadline) { + try { + const response = await fetch(discoveryUrl, { + redirect: "error", + signal: AbortSignal.timeout(1_000) + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const targets = await response.json(); + if (!Array.isArray(targets)) { + failKnown("CDP discovery did not return a target array."); + } + + const exactTargets = targets.filter(target => + target?.type === "page" && target.url === exactTargetUrl); + if (exactTargets.length > 1) { + failKnown("More than one exact LegacyParityApp WebView target exists."); + } + if (exactTargets.length === 1) { + return exactTargets[0]; + } + } catch (error) { + if (error instanceof HarnessFailure) throw error; + lastFailure = String(error?.message || error); + } + await sleep(100); + } + + failKnown(`The exact package WebView target was not found at ${discoveryUrl}` + + (lastFailure ? ` (${lastFailure}).` : ".")); +} + +function validateWebSocketTarget(target) { + if (!target?.id || !target.webSocketDebuggerUrl) { + failKnown("The exact target does not expose a WebSocket 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 CDP endpoint is not the exact loopback page endpoint."); + } + return endpoint; +} + +async function connect(endpoint) { + if (typeof WebSocket !== "function") { + failKnown("This Node runtime does not provide the WebSocket API."); + } + + socket = new WebSocket(endpoint.href); + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new HarnessFailure("OUTCOME_UNKNOWN", "CDP socket open timed out.")), + 5_000); + socket.addEventListener("open", () => { + clearTimeout(timer); + resolve(); + }, { once: true }); + socket.addEventListener("error", () => { + clearTimeout(timer); + reject(new HarnessFailure("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 = pendingRequests.get(message.id); + if (!pending) return; + pendingRequests.delete(message.id); + clearTimeout(pending.timer); + if (message.error) { + pending.reject(new HarnessFailure( + "KNOWN_FAILURE", + `${pending.method} failed: ${message.error.message || "CDP error"}`)); + } else { + pending.resolve(message.result); + } + }); + + socket.addEventListener("close", () => { + for (const pending of pendingRequests.values()) { + clearTimeout(pending.timer); + pending.reject(new HarnessFailure( + "OUTCOME_UNKNOWN", + `CDP socket closed while waiting for ${pending.method}.`)); + } + pendingRequests.clear(); + }); +} + +function rpc(method, params = {}, timeoutMilliseconds = 10_000) { + assertWithinHardDeadline(`before ${method}`); + if (cleanupMode && method !== "Input.dispatchMouseEvent" && + method !== "Runtime.evaluate") { + failKnown(`Cleanup refused unexpected CDP method ${method}.`); + } + if (forbiddenCdpMethods.has(method)) { + evidence.safety.appCloseRequested = true; + failKnown(`The harness refused forbidden CDP method ${method}.`); + } + if (!socket || socket.readyState !== WebSocket.OPEN) { + failUnknown(`CDP socket is not open for ${method}.`); + } + + const id = nextRequestId++; + return new Promise((resolve, reject) => { + const activeDeadline = cleanupMode + ? cleanupDeadlineEpochMilliseconds + : hardDeadlineEpochMilliseconds; + const remaining = activeDeadline - Date.now(); + const effectiveTimeout = cleanupMode + ? Math.min(timeoutMilliseconds, 2_000, remaining) + : Math.min(timeoutMilliseconds, remaining); + const timer = setTimeout(() => { + pendingRequests.delete(id); + reject(new HarnessFailure( + "OUTCOME_UNKNOWN", + `${method} timed out; the command is not retried.`)); + }, effectiveTimeout); + pendingRequests.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) { + const description = response.exceptionDetails.exception?.description || + response.exceptionDetails.text || "Runtime evaluation failed."; + failKnown(description); + } + return response.result?.value; +} + +function probeSnapshotExpression() { + return `(() => { + const probe = globalThis.__legacyPackageInputProbe; + const state = probe?.states?.at(-1) || null; + const pointerEvents = probe?.pointerEvents || []; + const lastPointerDown = [...pointerEvents].reverse().find(event => + event.type === "pointerdown") || null; + const activePointerId = Number.isInteger(lastPointerDown?.pointerId) + ? lastPointerDown.pointerId + : null; + const capturedCutPhysicalIndices = activePointerId === null + ? [] + : Array.from(document.querySelectorAll("#cut-list .cut-row")) + .filter(row => typeof row.hasPointerCapture === "function" && + row.hasPointerCapture(activePointerId)) + .map(row => Number(row.dataset.physicalIndex)); + const capturedPlaylistRowIds = activePointerId === null + ? [] + : Array.from(document.querySelectorAll( + "#playlist-rows .playlist-data-row .playlist-row-header")) + .filter(header => typeof header.hasPointerCapture === "function" && + header.hasPointerCapture(activePointerId)) + .map(header => header.closest(".playlist-data-row")?.dataset?.rowId || null) + .filter(Boolean); + const active = document.activeElement; + const rowForActive = active?.closest?.(".playlist-data-row") || null; + const visibleModal = Array.from(document.querySelectorAll( + "[role='dialog'], [role='alertdialog']")) + .filter(element => !element.hidden && !element.closest("[hidden]")).at(-1) || null; + const focusables = visibleModal ? Array.from(visibleModal.querySelectorAll( + "button:not([disabled]), input:not([disabled]):not([type='hidden']), " + + "select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])")) + .filter(element => !element.hidden && !element.closest("[hidden]") && + getComputedStyle(element).display !== "none" && + getComputedStyle(element).visibility !== "hidden") : []; + return { + capturedAt: new Date().toISOString(), + devicePixelRatio: window.devicePixelRatio, + stateCount: probe?.stateCount ?? null, + safety: { + postMessageWrapped: probe?.postMessageWrapper != null && + window.chrome?.webview?.postMessage === probe.postMessageWrapper, + outboundMessages: (probe?.outboundMessages || []).map(message => ({ ...message })), + blockedOutboundMessages: (probe?.blockedOutboundMessages || []) + .map(message => ({ ...message })), + stateViolations: (probe?.stateViolations || []).map(violation => ({ + ...violation, + reasons: [...violation.reasons] + })) + }, + pointerEvents: pointerEvents.slice(-40).map(event => ({ ...event })), + state: state ? { + revision: state.revision, + isBusy: state.isBusy, + searchText: state.searchText, + searchResults: (state.searchResults || []).map(row => ({ + index: row.index, + displayName: row.displayName + })), + selectedStockIndex: state.selectedStockIndex, + cutRows: (state.cutRows || []).map(row => ({ + physicalIndex: row.physicalIndex, + displayOrdinal: row.displayOrdinal, + rawLabel: row.rawLabel, + isSeparator: row.isSeparator, + isSelected: row.isSelected, + isFocused: row.isFocused + })), + playlist: (state.playlist || []).map(row => ({ + rowId: row.rowId, + isEnabled: row.isEnabled, + isSelected: row.isSelected, + isActive: row.isActive, + marketText: row.marketText, + stockName: row.stockName, + graphicType: row.graphicType, + subtype: row.subtype, + pageText: row.pageText + })), + dialog: state.dialog, + statusMessage: state.statusMessage, + statusKind: state.statusKind, + commandResult: state.commandResult, + namedPlaylist: state.namedPlaylist ? { + revision: state.namedPlaylist.revision, + definitions: (state.namedPlaylist.definitions || []).map(definition => ({ + definitionId: definition.definitionId, + title: definition.title, + isSelected: definition.isSelected + })), + selectedDefinitionId: state.namedPlaylist.selectedDefinitionId, + selectedTitle: state.namedPlaylist.selectedTitle, + rowCount: state.namedPlaylist.rowCount, + isWriteInProgress: state.namedPlaylist.isWriteInProgress, + isWriteQuarantined: state.namedPlaylist.isWriteQuarantined, + canMutate: state.namedPlaylist.canMutate, + canCreate: state.namedPlaylist.canCreate, + canLoad: state.namedPlaylist.canLoad, + canSave: state.namedPlaylist.canSave, + canDelete: state.namedPlaylist.canDelete, + lastMutationOutcome: state.namedPlaylist.lastMutationOutcome, + statusMessage: state.namedPlaylist.statusMessage, + statusKind: state.namedPlaylist.statusKind + } : null, + interactionMetrics: state.interactionMetrics, + playout: state.playout ? { + mode: state.playout.mode, + connectionState: state.playout.connectionState, + phase: state.playout.phase, + isConnected: state.playout.isConnected, + isCommandAvailable: state.playout.isCommandAvailable, + liveTakeInAllowed: state.playout.liveTakeInAllowed, + isPlayCompletionPending: state.playout.isPlayCompletionPending, + isTakeOutCompletionPending: state.playout.isTakeOutCompletionPending, + outcomeUnknown: state.playout.outcomeUnknown, + preparedCode: state.playout.preparedCode, + onAirCode: state.playout.onAirCode, + currentEntryId: state.playout.currentEntryId, + isBusy: state.playout.isBusy, + refreshActive: state.playout.refreshActive, + message: state.playout.message + } : null + } : null, + dom: { + readyState: document.readyState, + url: location.href, + viewport: { + width: document.documentElement.clientWidth, + height: document.documentElement.clientHeight + }, + bodyBusy: document.body.getAttribute("aria-busy"), + connectionText: (document.getElementById("playout-connection-state")?.textContent || "").trim(), + playoutStatusText: (document.getElementById("playout-status")?.textContent || "").trim(), + operatorStatusText: (document.getElementById("operator-status")?.textContent || "").trim(), + legacyDialogHidden: document.getElementById("legacy-dialog")?.hidden === true, + legacyDialogText: (document.getElementById("legacy-dialog-message")?.textContent || "").trim(), + stockOptions: Array.from(document.querySelectorAll( + "#stock-results button[data-result-index]")) + .map(button => ({ + index: Number(button.dataset.resultIndex), + text: (button.textContent || "").trim(), + selected: button.getAttribute("aria-selected") === "true", + tabIndex: button.tabIndex, + focused: button === active + })), + cuts: Array.from(document.querySelectorAll("#cut-list .cut-row")) + .map(row => ({ + physicalIndex: Number(row.dataset.physicalIndex), + selected: row.getAttribute("aria-selected") === "true", + tabIndex: row.tabIndex, + focused: row === active, + dragging: row.classList.contains("dragging") + })), + playlist: Array.from(document.querySelectorAll( + "#playlist-rows .playlist-data-row")) + .map(row => ({ + rowId: row.dataset.rowId, + selected: row.getAttribute("aria-selected") === "true", + active: row.dataset.active === "true", + current: row.getAttribute("aria-current") === "true", + enabled: row.querySelector("input[type='checkbox']")?.checked === true, + dragging: row.classList.contains("dragging"), + dragBefore: row.classList.contains("drag-before"), + dragAfter: row.classList.contains("drag-after"), + rowHeaderText: (row.querySelector(".playlist-row-header")?.textContent || "").trim(), + rowHeaderWidth: row.querySelector(".playlist-row-header") + ?.getBoundingClientRect().width ?? null, + rowHeaderDragEnabled: row.querySelector(".playlist-row-header") + ?.dataset?.dragEnabled === "true", + focused: row === active || row.contains(active) + })), + cutDropHighlighted: document.getElementById("playlist-drop-zone") + ?.classList.contains("cut-drag-over") === true, + cutPointerCapture: { + pointerId: activePointerId, + physicalIndices: capturedCutPhysicalIndices + }, + playlistPointerCapture: { + pointerId: activePointerId, + rowIds: capturedPlaylistRowIds + }, + namedModalHidden: document.getElementById("named-playlist-modal")?.hidden === true, + namedConfirmationHidden: document.getElementById("named-playlist-confirmation")?.hidden === true, + namedTitle: (document.getElementById("named-playlist-title")?.textContent || "").trim(), + namedConfirmationMessage: (document.getElementById( + "named-playlist-confirmation-message")?.textContent || "").trim(), + namedConfirmDisabled: document.getElementById( + "named-playlist-confirm-button")?.disabled === true, + namedDefinitionCount: document.querySelectorAll( + "#named-playlist-definitions button[data-named-playlist-definition-id]").length, + activeElement: active ? { + id: active.id || null, + tagName: active.tagName, + resultIndex: active.dataset?.resultIndex ?? null, + physicalIndex: active.dataset?.physicalIndex ?? null, + playlistRowId: rowForActive?.dataset?.rowId ?? null + } : null, + visibleModalRole: visibleModal?.getAttribute("role") || null, + visibleModalLabelledBy: visibleModal?.getAttribute("aria-labelledby") || null, + activeInsideVisibleModal: visibleModal ? visibleModal.contains(active) : false, + modalFocusableIds: focusables.map(element => element.id || null) + } + }; + })()`; +} + +async function readSnapshot() { + return await evaluate(probeSnapshotExpression()); +} + +function assertSafety(snapshot, label, allowUiBusy = true) { + if (!snapshot?.state?.playout) { + failKnown(`Native playout state is missing at ${label}.`); + } + const state = snapshot.state; + const playout = state.playout; + if (snapshot.safety?.postMessageWrapped !== true) { + failUnknown(`The outbound WebView safety gate is not installed at ${label}.`); + } + const outboundMessages = snapshot.safety.outboundMessages || []; + const blockedOutboundMessages = snapshot.safety.blockedOutboundMessages || []; + const stateViolations = snapshot.safety.stateViolations || []; + evidence.safety.observedOutboundMessageTypes = outboundMessages + .map(message => message.type); + evidence.safety.blockedOutboundMessages = blockedOutboundMessages + .map(message => ({ ...message })); + evidence.safety.stateViolations = stateViolations.map(violation => ({ + ...violation, + reasons: [...violation.reasons] + })); + evidence.safety.playoutIntentIssued = blockedOutboundMessages.some(message => + message.category === "playout"); + evidence.safety.databaseWriteIntentIssued = blockedOutboundMessages.some(message => + message.category === "database-write"); + if (blockedOutboundMessages.length > 0) { + failKnown(`The outbound WebView safety gate blocked an unapproved message at ${label}.`); + } + if (stateViolations.length > 0) { + failUnknown(`A transient unsafe playout state was observed before ${label}.`); + } + if (playout.mode !== "dryRun") { + failKnown(`Expected DryRun at ${label}, received ${String(playout.mode)}.`); + } + if (playout.phase !== "idle" || playout.preparedCode != null || + playout.onAirCode != null) { + failKnown(`Playout left IDLE at ${label}.`); + } + if (playout.outcomeUnknown === true || + playout.isPlayCompletionPending === true || + playout.isTakeOutCompletionPending === true || + playout.isBusy === true || playout.refreshActive === true) { + failUnknown(`Playout became busy, pending, refreshing, or unknown at ${label}.`); + } + if (!allowUiBusy && state.isBusy === true) { + failKnown(`The operator UI is still busy at ${label}.`); + } + if (snapshot.dom.url !== exactTargetUrl || snapshot.dom.readyState !== "complete") { + failKnown(`The exact package document is not ready at ${label}.`); + } + if (snapshot.dom.connectionText !== "DRY RUN") { + failKnown(`The visible connection badge is not DRY RUN at ${label}.`); + } + if (!snapshot.dom.legacyDialogHidden) { + failKnown(`A legacy alert is visible at ${label}: ${snapshot.dom.legacyDialogText}`); + } +} + +async function waitFor(label, predicate, options = {}) { + const timeoutMilliseconds = options.timeoutMilliseconds || + configuration.timeoutMilliseconds; + const deadline = Date.now() + timeoutMilliseconds; + let lastSnapshot = null; + + while (Date.now() < deadline) { + lastSnapshot = await readSnapshot(); + if (lastSnapshot?.state?.playout) { + assertSafety(lastSnapshot, label, options.allowUiBusy !== false); + } + if (predicate(lastSnapshot)) return lastSnapshot; + await sleep(100); + } + + failUnknown(`Timed out waiting for ${label}; no action was retried. Last snapshot: ` + + JSON.stringify(lastSnapshot)); +} + +async function checkpoint(name, details = null) { + const snapshot = await readSnapshot(); + assertSafety(snapshot, name, false); + evidence.checkpoints.push({ name, details, snapshot }); + return snapshot; +} + +async function elementGeometry(expression, label, verticalFraction = 0.5) { + const geometry = await evaluate(`(() => { + const element = ${expression}; + if (!element) return null; + const rectangle = element.getBoundingClientRect(); + return { + x: rectangle.left + rectangle.width / 2, + y: rectangle.top + rectangle.height * ${Number(verticalFraction)}, + left: rectangle.left, + top: rectangle.top, + right: rectangle.right, + bottom: rectangle.bottom, + width: rectangle.width, + height: rectangle.height, + disabled: element.disabled === true, + ariaDisabled: element.getAttribute("aria-disabled") + }; + })()`); + if (!geometry || geometry.disabled || geometry.ariaDisabled === "true" || + !Number.isFinite(geometry.x) || !Number.isFinite(geometry.y) || + geometry.width <= 0 || geometry.height <= 0 || + geometry.right <= 0 || geometry.bottom <= 0) { + failKnown(`${label} is missing, disabled, or outside the rendered viewport.`); + } + return geometry; +} + +async function playlistDropGeometry(label) { + const geometry = await evaluate(`(() => { + const zone = document.getElementById("playlist-drop-zone"); + if (!zone) return null; + const zoneRectangle = zone.getBoundingClientRect(); + const headerRectangle = zone.querySelector(".playlist-header") + ?.getBoundingClientRect() || null; + const viewport = { + width: document.documentElement.clientWidth, + height: document.documentElement.clientHeight + }; + const inset = 8; + const left = Math.max(0, zoneRectangle.left + inset); + const right = Math.min(viewport.width - 1, zoneRectangle.right - inset); + const top = Math.max( + 0, + (headerRectangle ? headerRectangle.bottom : zoneRectangle.top) + inset); + const bottom = Math.min(viewport.height - 1, zoneRectangle.bottom - inset); + const fractions = [0.5, 0.25, 0.75]; + const points = right > left && bottom > top + ? fractions.map(fraction => { + const x = left + (right - left) / 2; + const y = top + (bottom - top) * fraction; + const hit = document.elementFromPoint(x, y); + return { + x, + y, + hitId: hit?.id || null, + hitClass: hit?.className || null, + insideDropZone: hit === zone || zone.contains(hit) + }; + }) + : []; + return { + zone: { + left: zoneRectangle.left, + top: zoneRectangle.top, + right: zoneRectangle.right, + bottom: zoneRectangle.bottom, + width: zoneRectangle.width, + height: zoneRectangle.height + }, + headerBottom: headerRectangle?.bottom ?? null, + viewport, + visibleInterior: { left, top, right, bottom }, + points + }; + })()`); + if (!geometry || geometry.zone.width <= 0 || geometry.zone.height <= 0 || + geometry.points.length === 0 || + geometry.points.some(point => !Number.isFinite(point.x) || + !Number.isFinite(point.y) || point.insideDropZone !== true)) { + failKnown(`${label} has no verified visible interior drop point.`); + } + return geometry; +} + +async function moveMouse(point, modifiers = 0, buttons = 0) { + await rpc("Input.dispatchMouseEvent", { + type: "mouseMoved", + x: point.x, + y: point.y, + // Edge releases explicit pointer capture if a CDP mouseMoved event says + // no button while its buttons bitmask says the left button is held. + // Keep both fields consistent with the physical state during a drag. + button: (buttons & 1) === 1 ? "left" : "none", + buttons, + modifiers, + pointerType: "mouse" + }); +} + +async function pressMouse(point, modifiers = 0) { + if (pointerIsPressed) failKnown("A second pointer press was attempted before release."); + await moveMouse(point, modifiers, 0); + await rpc("Input.dispatchMouseEvent", { + type: "mousePressed", + x: point.x, + y: point.y, + button: "left", + buttons: 1, + clickCount: 1, + modifiers, + pointerType: "mouse" + }); + pointerIsPressed = true; + pointerReleasePoint = point; + pointerReleaseModifiers = modifiers; +} + +async function releaseMouse(point, modifiers = 0) { + if (!pointerIsPressed) failKnown("A pointer release was attempted without a press."); + await rpc("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: point.x, + y: point.y, + button: "left", + buttons: 0, + clickCount: 1, + modifiers, + pointerType: "mouse" + }); + pointerIsPressed = false; + pointerReleasePoint = null; + pointerReleaseModifiers = 0; +} + +async function pointerClick(expression, label, modifiers = 0) { + const forbiddenId = await evaluate(`(() => { + const element = ${expression}; + return element?.closest?.("button")?.id || element?.id || null; + })()`); + if (forbiddenControlIds.includes(forbiddenId)) { + failKnown(`The harness refused forbidden control #${forbiddenId}.`); + } + + const point = await elementGeometry(expression, label); + evidence.inputs.push({ type: "pointer-click", label, modifiers, point }); + await pressMouse(point, modifiers); + await sleep(25); + await releaseMouse(point, modifiers); + return point; +} + +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 }, + End: { key: "End", code: "End", virtualKey: 35 }, + Escape: { key: "Escape", code: "Escape", virtualKey: 27 }, + 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) { + const definition = keyDefinitions[definitionName]; + if (!definition) failKnown(`Unknown key definition ${definitionName}.`); + if (new Set(["F2", "F3", "F8"]).has(definition.key)) { + failKnown(`The harness refused playout shortcut ${definition.key}.`); + } + if (definition.key === "Escape") { + const modalState = await evaluate(`(() => ({ + named: document.getElementById("named-playlist-modal")?.hidden === false, + confirmation: document.getElementById("named-playlist-confirmation")?.hidden === false + }))()`); + if (!modalState?.named && !modalState?.confirmation) { + failKnown("Escape is allowed only while the named-playlist modal owns input."); + } + } + + evidence.inputs.push({ + type: "key", + label, + key: definition.key, + modifiers + }); + const common = { + key: definition.key, + code: definition.code, + windowsVirtualKeyCode: definition.virtualKey, + nativeVirtualKeyCode: definition.virtualKey, + modifiers + }; + await rpc("Input.dispatchKeyEvent", { type: "keyDown", ...common }); + await rpc("Input.dispatchKeyEvent", { type: "keyUp", ...common }); +} + +async function setSearchText(text) { + await pointerClick( + 'document.getElementById("stock-search")', + "focus stock search"); + await pressKey("SelectAll", "select existing stock query", ctrlModifier); + await pressKey("Backspace", "clear existing stock query"); + evidence.inputs.push({ type: "text", label: "stock query", text }); + await rpc("Input.insertText", { text }); +} + +function selectedPhysicalIndices(snapshot) { + return snapshot.state.cutRows + .filter(row => row.isSelected) + .map(row => row.physicalIndex); +} + +function selectedPlaylistIds(snapshot) { + return snapshot.state.playlist + .filter(row => row.isSelected) + .map(row => row.rowId); +} + +function activePlaylistId(snapshot) { + return snapshot.state.playlist.find(row => row.isActive)?.rowId || null; +} + +function focusedCutIndex(snapshot) { + return snapshot.state.cutRows.find(row => row.isFocused)?.physicalIndex ?? null; +} + +function assertExactArray(actual, expected, label) { + if (actual.length !== expected.length || + actual.some((value, index) => value !== expected[index])) { + failKnown(`${label}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}.`); + } +} + +function cssDragSize(snapshot) { + const metrics = snapshot.state.interactionMetrics; + const devicePixelRatio = snapshot.devicePixelRatio; + if (!metrics || metrics.coordinateSpace !== "host-dip" || + !Number.isInteger(metrics.cutDragWidthDips) || metrics.cutDragWidthDips <= 0 || + !Number.isInteger(metrics.cutDragHeightDips) || metrics.cutDragHeightDips <= 0) { + failKnown("Valid host-DIP cut drag metrics are missing."); + } + const hostScale = Number.isFinite(metrics.hostRasterizationScale) && + metrics.hostRasterizationScale > 0 ? metrics.hostRasterizationScale : 1; + const deviceScale = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 + ? devicePixelRatio : hostScale; + const candidateZoom = deviceScale / hostScale; + const browserZoom = Number.isFinite(candidateZoom) && + candidateZoom >= 0.25 && candidateZoom <= 5 ? candidateZoom : 1; + return { + width: metrics.cutDragWidthDips / browserZoom, + height: metrics.cutDragHeightDips / browserZoom + }; +} + +async function dragCutsToPlaylist(physicalIndex, modifiers, expectedAppendCount, label) { + const before = await readSnapshot(); + assertSafety(before, `${label} preflight`, false); + const beforeCount = before.state.playlist.length; + const beforeRevision = before.state.revision; + const sourceExpression = + `document.querySelector('#cut-list .cut-row[data-physical-index="${physicalIndex}"]')`; + const source = await elementGeometry(sourceExpression, `${label} source`); + const dropGeometry = await playlistDropGeometry(`${label} target`); + const dragInput = { + type: "cut-drag", + label, + physicalIndex, + modifiers, + source, + dropGeometry, + targetSamples: [], + acceptedTarget: null, + expectedAppendCount + }; + evidence.inputs.push(dragInput); + + await pressMouse(source, modifiers); + try { + const held = await waitFor(`${label} pointer-down receipt`, snapshot => + snapshot.state.revision > beforeRevision && + focusedCutIndex(snapshot) === physicalIndex, + { allowUiBusy: false }); + const dragSize = cssDragSize(held); + const inside = { + x: source.x - dragSize.width / 2, + y: source.y - dragSize.height / 2 + }; + const threshold = { + x: Math.min(source.right - 4, source.x + Math.max(8, dragSize.width + 2)), + y: source.y + }; + pointerReleasePoint = source; + pointerReleaseModifiers = modifiers; + await moveMouse(inside, modifiers, 1); + await sleep(30); + const beforeThreshold = await readSnapshot(); + const sourceBeforeThreshold = beforeThreshold.dom.cuts.find(row => + row.physicalIndex === physicalIndex); + if (!sourceBeforeThreshold || sourceBeforeThreshold.dragging || + beforeThreshold.dom.cutDropHighlighted) { + failKnown(`${label} started inside the original drag rectangle.`); + } + + await moveMouse(threshold, modifiers, 1); + await sleep(50); + const thresholdSnapshot = await readSnapshot(); + const thresholdSource = thresholdSnapshot.dom.cuts.find(row => + row.physicalIndex === physicalIndex); + const capturedAtThreshold = thresholdSnapshot.dom.cutPointerCapture + ?.physicalIndices?.includes(physicalIndex) === true; + dragInput.threshold = { + point: threshold, + dragging: thresholdSource?.dragging === true, + captured: capturedAtThreshold, + pointerEvents: thresholdSnapshot.pointerEvents.slice(-12) + }; + if (!thresholdSource?.dragging) { + failKnown( + `${label} did not cross the Windows drag threshold on the source row; ` + + `pointer diagnostics: ${JSON.stringify(dragInput.threshold.pointerEvents)}.`); + } + if (!capturedAtThreshold) { + failKnown( + `${label} crossed the drag threshold but WebView2 did not retain pointer ` + + `capture on the source row; pointer diagnostics: ` + + `${JSON.stringify(dragInput.threshold.pointerEvents)}.`); + } + if (thresholdSnapshot.dom.cutDropHighlighted) { + failKnown(`${label} highlighted the remote drop zone while still on the source row.`); + } + + let overTarget = null; + let acceptedTarget = null; + for (const candidate of dropGeometry.points) { + await moveMouse(candidate, modifiers, 1); + await sleep(50); + const sample = await readSnapshot(); + const sourceAtCandidate = sample.dom.cuts.find(row => + row.physicalIndex === physicalIndex); + const sampleEvidence = { + point: candidate, + dragging: sourceAtCandidate?.dragging === true, + highlighted: sample.dom.cutDropHighlighted, + captured: sample.dom.cutPointerCapture?.physicalIndices + ?.includes(physicalIndex) === true, + pointerEvents: sample.pointerEvents.slice(-12) + }; + dragInput.targetSamples.push(sampleEvidence); + if (sampleEvidence.dragging && sampleEvidence.highlighted && + sampleEvidence.captured) { + overTarget = sample; + acceptedTarget = candidate; + break; + } + } + if (!overTarget || !acceptedTarget) { + failKnown( + `${label} did not enter the real captured-pointer drop state; samples: ` + + `${JSON.stringify(dragInput.targetSamples)}.`); + } + + dragInput.acceptedTarget = acceptedTarget; + await releaseMouse(acceptedTarget, modifiers); + } finally { + if (pointerIsPressed) { + const safePoint = pointerReleasePoint || source; + await releaseMouse(safePoint, pointerReleaseModifiers); + } + } + + const completed = await waitFor(`${label} append`, snapshot => + snapshot.state.isBusy !== true && + snapshot.state.playlist.length === beforeCount + expectedAppendCount, + { allowUiBusy: false }); + if (!completed.dom.cuts.every(row => !row.dragging) || + completed.dom.cutDropHighlighted) { + failKnown(`${label} left drag visuals active after release.`); + } + return completed; +} + +async function rowCellGeometry(rowId, label) { + return await elementGeometry(`(() => { + const row = Array.from(document.querySelectorAll( + "#playlist-rows .playlist-data-row")) + .find(candidate => candidate.dataset.rowId === ${JSON.stringify(rowId)}); + return row?.children?.item(3) || null; + })()`, label); +} + +async function playlistRowHeaderGeometry(rowId, label, verticalFraction = 0.5) { + return await elementGeometry(`(() => { + const row = Array.from(document.querySelectorAll( + "#playlist-rows .playlist-data-row")) + .find(candidate => candidate.dataset.rowId === ${JSON.stringify(rowId)}); + return row?.querySelector(".playlist-row-header") || null; + })()`, label, verticalFraction); +} + +async function playlistCheckboxGeometry(rowId, label) { + return await elementGeometry(`(() => { + const row = Array.from(document.querySelectorAll( + "#playlist-rows .playlist-data-row")) + .find(candidate => candidate.dataset.rowId === ${JSON.stringify(rowId)}); + return row?.querySelector("input[type='checkbox']") || null; + })()`, label); +} + +async function pointerClickPlaylistRow(rowId, label, modifiers = 0) { + const point = await rowCellGeometry(rowId, label); + evidence.inputs.push({ type: "pointer-click", label, modifiers, point, rowId }); + await pressMouse(point, modifiers); + await sleep(25); + await releaseMouse(point, modifiers); +} + +async function dragPlaylistRow(sourceRowId, targetRowId, label) { + const source = await playlistRowHeaderGeometry(sourceRowId, `${label} source`); + const target = await playlistRowHeaderGeometry(targetRowId, `${label} target`, 0.75); + evidence.inputs.push({ + type: "playlist-row-drag", + label, + sourceRowId, + targetRowId, + position: "after", + source, + target + }); + + await pressMouse(source, 0); + try { + pointerReleasePoint = source; + await moveMouse({ x: source.x + 8, y: source.y }, 0, 1); + await sleep(50); + await moveMouse({ + x: (source.x + target.x) / 2, + y: (source.y + target.y) / 2 + }, 0, 1); + await sleep(50); + await moveMouse(target, 0, 1); + await sleep(75); + + const overTarget = await readSnapshot(); + const sourceDom = overTarget.dom.playlist.find(row => row.rowId === sourceRowId); + const targetDom = overTarget.dom.playlist.find(row => row.rowId === targetRowId); + const captured = overTarget.dom.playlistPointerCapture?.rowIds + ?.includes(sourceRowId) === true; + if (!sourceDom?.dragging || !targetDom?.dragAfter || !captured) { + failKnown(`${label} did not produce the captured-pointer row-header drag state.`); + } + await releaseMouse(target, 0); + } finally { + if (pointerIsPressed) { + await releaseMouse(pointerReleasePoint || source, 0); + } + } +} + +async function attemptCheckboxOriginDrag(sourceRowId, targetRowId, label) { + const source = await playlistCheckboxGeometry(sourceRowId, `${label} checkbox`); + const target = await playlistRowHeaderGeometry(targetRowId, `${label} target`, 0.75); + evidence.inputs.push({ + type: "playlist-checkbox-drag-attempt", + label, + sourceRowId, + targetRowId, + source, + target + }); + + await pressMouse(source, 0); + try { + pointerReleasePoint = source; + await moveMouse({ x: source.x + 8, y: source.y }, 0, 1); + await sleep(50); + await moveMouse(target, 0, 1); + await sleep(75); + const during = await readSnapshot(); + if (during.dom.playlist.some(row => + row.dragging || row.dragBefore || row.dragAfter)) { + failKnown(`${label} exposed a row drag visual from a checkbox origin.`); + } + await releaseMouse(target, 0); + } finally { + if (pointerIsPressed) { + await releaseMouse(pointerReleasePoint || source, 0); + } + } +} + +function readSmallJsonFile(filePath, label) { + let stat; + try { + stat = fs.lstatSync(filePath); + } catch (_) { + failKnown(`${label} is unavailable.`); + } + if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > 16_384) { + failKnown(`${label} is not a small regular file.`); + } + try { + const bytes = fs.readFileSync(filePath); + if (bytes.length !== stat.size) failKnown(`${label} changed while it was read.`); + return { + value: JSON.parse(bytes.toString("utf8")), + bytes + }; + } catch (_) { + failKnown(`${label} is not valid UTF-8 JSON.`); + } +} + +async function requestWindowsInput( + current, + sourceRowId, + targetRowId, + expectedFirstRowId, + label) { + assertSafety(current, `${label} preflight`, false); + const preflightSignature = JSON.stringify({ + revision: current.state.revision, + order: current.state.playlist.map(row => row.rowId), + selected: selectedPlaylistIds(current), + active: activePlaylistId(current), + focused: current.dom.activeElement?.playlistRowId ?? null + }); + await sleep(350); + let stabilized = await readSnapshot(); + assertSafety(stabilized, `${label} stabilized preflight`, false); + const stabilizedSignature = JSON.stringify({ + revision: stabilized.state.revision, + order: stabilized.state.playlist.map(row => row.rowId), + selected: selectedPlaylistIds(stabilized), + active: activePlaylistId(stabilized), + focused: stabilized.dom.activeElement?.playlistRowId ?? null + }); + if (stabilizedSignature !== preflightSignature) { + failKnown(`${label} state changed before the one-shot request was published.`); + } + current = stabilized; + const source = await playlistRowHeaderGeometry(sourceRowId, `${label} source`); + const target = await playlistRowHeaderGeometry(targetRowId, `${label} target`, 0.75); + 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 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); + const guardSignature = JSON.stringify({ + revision: geometryGuard.state.revision, + order: geometryGuard.state.playlist.map(row => row.rowId), + selected: selectedPlaylistIds(geometryGuard), + active: activePlaylistId(geometryGuard), + focused: geometryGuard.dom.activeElement?.playlistRowId ?? null + }); + if (!geometryStable || guardSignature !== stabilizedSignature) { + failKnown(`${label} DOM geometry or native state changed before publication.`); + } + 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); + if (targetIndex < 0 || sourceRowId === targetRowId || + new Set(beforeOrder).size !== beforeOrder.length) { + failKnown(`${label} row identities are not safe for a one-row move.`); + } + afterOrder.splice(targetIndex + 1, 0, sourceRowId); + if (afterOrder[0] !== expectedFirstRowId) { + failKnown(`${label} computed an unexpected Home boundary row.`); + } + const request = { + schemaVersion: 1, + token, + targetUrl: exactTargetUrl, + operation: "row-header-drag-after-then-home", + sourceRowId, + targetRowId, + position: "after", + source: { x: source.x, y: source.y }, + target: { x: target.x, y: target.y }, + viewport: { ...current.dom.viewport }, + devicePixelRatio: current.devicePixelRatio, + beforeOrder, + afterOrder, + expectedSelectedRowId: sourceRowId, + expectedActiveRowIdAfterHome: expectedFirstRowId, + expectedFocusedRowId: sourceRowId, + mouseDownCalls: 1, + mouseUpCalls: 1, + homeKeyCalls: 1 + }; + 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" + }); + + 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 && + snapshot.dom.activeElement?.playlistRowId === sourceRowId, + { + timeoutMilliseconds: Math.min(configuration.timeoutMilliseconds, 15_000), + allowUiBusy: false + }); + evidence.windowsInput.result = "PASS"; + evidence.windowsInput.beforeOrder = beforeOrder; + evidence.windowsInput.afterOrder = afterOrder; + evidence.windowsInput.activeRowIdAfterHome = expectedFirstRowId; + await checkpoint("windows-sendinput-row-header-drag-and-home", { + sourceRowId, + targetRowId, + beforeOrder, + afterOrder, + expectedFirstRowId + }); + return completed; +} + +async function installProbe() { + const token = `legacy-package-input-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const installed = await evaluate(`(() => { + if (!window.chrome?.webview) { + throw new Error("WebView2 bridge is unavailable."); + } + if (globalThis.__legacyPackageInputProbe) { + throw new Error("A legacy package input probe is already installed; restart the app."); + } + const probe = { + token: ${JSON.stringify(token)}, + stateCount: 0, + states: [], + listener: null, + pointerEventTypes: [ + "pointerdown", + "gotpointercapture", + "pointermove", + "pointerup", + "pointercancel", + "lostpointercapture" + ], + pointerEvents: [], + pointerListener: null, + originalPostMessage: null, + postMessageWrapper: null, + outboundMessages: [], + blockedOutboundMessages: [], + stateViolations: [] + }; + probe.listener = event => { + if (event.data?.type !== "state" || !event.data.payload) return; + const payload = event.data.payload; + probe.stateCount += 1; + probe.states.push(structuredClone(payload)); + if (probe.states.length > 750) 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.preparedCode != null) reasons.push("prepared-code"); + if (playout.onAirCode != null) reasons.push("on-air-code"); + if (playout.isConnected === true) reasons.push("connected"); + if (playout.outcomeUnknown === true) reasons.push("outcome-unknown"); + if (playout.isPlayCompletionPending === true) reasons.push("play-pending"); + if (playout.isTakeOutCompletionPending === true) reasons.push("take-out-pending"); + if (playout.isBusy === true) reasons.push("playout-busy"); + if (playout.refreshActive === true) reasons.push("refresh-active"); + } + if (reasons.length > 0) { + probe.stateViolations.push({ + at: new Date().toISOString(), + revision: payload.revision ?? null, + reasons + }); + } + }; + probe.pointerListener = event => { + const target = event.target instanceof Element ? event.target : null; + const cut = target?.closest?.("#cut-list .cut-row") || null; + const playlistRow = target?.closest?.("#playlist-rows .playlist-data-row") || null; + const capturedCuts = Number.isInteger(event.pointerId) + ? Array.from(document.querySelectorAll("#cut-list .cut-row")) + .filter(row => typeof row.hasPointerCapture === "function" && + row.hasPointerCapture(event.pointerId)) + .map(row => Number(row.dataset.physicalIndex)) + : []; + probe.pointerEvents.push({ + type: event.type, + pointerId: event.pointerId, + pointerType: event.pointerType, + clientX: event.clientX, + clientY: event.clientY, + button: event.button, + buttons: event.buttons, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + targetId: target?.id || null, + targetClass: target?.className || null, + cutPhysicalIndex: cut ? Number(cut.dataset.physicalIndex) : null, + playlistRowId: playlistRow?.dataset?.rowId || null, + capturedCutPhysicalIndices: capturedCuts, + timeStamp: event.timeStamp + }); + if (probe.pointerEvents.length > 200) probe.pointerEvents.shift(); + }; + const allowedTypes = new Set(${JSON.stringify(allowedOutboundMessageTypes)}); + const playoutTypes = new Set(${JSON.stringify(playoutIntentMessageTypes)}); + const databaseWriteTypes = new Set(${JSON.stringify(databaseWriteIntentMessageTypes)}); + probe.originalPostMessage = window.chrome.webview.postMessage; + probe.postMessageWrapper = message => { + const type = typeof message?.type === "string" ? message.type : null; + const record = { + type, + at: new Date().toISOString() + }; + probe.outboundMessages.push(record); + if (probe.outboundMessages.length > 250) probe.outboundMessages.shift(); + if (!type || !allowedTypes.has(type)) { + const blocked = { + ...record, + category: playoutTypes.has(type) + ? "playout" + : databaseWriteTypes.has(type) + ? "database-write" + : "unapproved" + }; + probe.blockedOutboundMessages.push(blocked); + 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 WebView safety gate could not be installed."); + } + globalThis.__legacyPackageInputProbe = probe; + window.chrome.webview.addEventListener("message", probe.listener); + probe.pointerEventTypes.forEach(type => + document.addEventListener(type, probe.pointerListener, true)); + window.chrome.webview.postMessage({ type: "ready", payload: {} }); + } catch (error) { + window.chrome.webview.postMessage = probe.originalPostMessage; + delete globalThis.__legacyPackageInputProbe; + throw error; + } + return { token: probe.token }; + })()`); + if (installed?.token !== token) failKnown("The state observer was not installed exactly once."); +} + +async function removeProbe() { + if (!socket || socket.readyState !== WebSocket.OPEN) return; + const removed = await evaluate(`(() => { + const probe = globalThis.__legacyPackageInputProbe; + if (!probe) return false; + if (probe.listener) { + window.chrome.webview.removeEventListener("message", probe.listener); + } + if (probe.pointerListener) { + probe.pointerEventTypes.forEach(type => + document.removeEventListener(type, probe.pointerListener, true)); + } + if (probe.originalPostMessage) { + if (window.chrome?.webview?.postMessage !== probe.postMessageWrapper) { + throw new Error("The outbound WebView safety gate identity changed."); + } + window.chrome.webview.postMessage = probe.originalPostMessage; + } + delete globalThis.__legacyPackageInputProbe; + return true; + })()`); + if (removed !== true) { + failUnknown("The outbound WebView safety probe was not restored exactly once."); + } + probeInstalled = false; +} + +async function captureScreenshot() { + const response = await rpc("Page.captureScreenshot", { + format: "png", + fromSurface: true, + captureBeyondViewport: false + }, 20_000); + if (!response?.data) failKnown("CDP did not return screenshot data."); + 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) + }; +} + +async function executeSafeSequence() { + await rpc("Runtime.enable"); + await rpc("Page.enable"); + await installProbe(); + probeInstalled = true; + + const preflight = await waitFor("DryRun IDLE preflight", snapshot => + snapshot?.state?.playout?.mode === "dryRun" && + snapshot.state.playout.phase === "idle" && + snapshot.state.isBusy !== true && + snapshot.dom.connectionText === "DRY RUN", + { allowUiBusy: false }); + assertSafety(preflight, "DryRun IDLE preflight", false); + if (preflight.state.playlist.length !== 0) { + failKnown("The package input test requires an initially empty in-memory playlist."); + } + if (!preflight.dom.namedModalHidden || !preflight.dom.namedConfirmationHidden) { + failKnown("A child modal was already open at preflight."); + } + await checkpoint("preflight"); + + await setSearchText(evidence.fixture.query); + await pointerClick( + 'document.getElementById("stock-search-button")', + "submit stock search"); + let current = await waitFor("stock search result", snapshot => + snapshot?.state?.isBusy === false && + snapshot.state.searchText === evidence.fixture.query && + snapshot.state.searchResults.some(row => + row.displayName === evidence.fixture.stockDisplayName), + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + + const exactStocks = current.state.searchResults.filter(row => + row.displayName === evidence.fixture.stockDisplayName); + if (exactStocks.length !== 1) { + failKnown(`Expected exactly one ${evidence.fixture.stockDisplayName} row, received ${exactStocks.length}.`); + } + const exactStock = exactStocks[0]; + const exactStockExpression = + `document.querySelector('#stock-results button[data-result-index="${exactStock.index}"]')`; + await pointerClick(exactStockExpression, `select ${evidence.fixture.stockDisplayName}`); + current = await waitFor("exact stock selection", snapshot => + snapshot.state.isBusy === false && + snapshot.state.selectedStockIndex === exactStock.index, + { allowUiBusy: false }); + + const selectedPosition = current.state.searchResults.findIndex(row => + row.index === exactStock.index); + const forward = selectedPosition < current.state.searchResults.length - 1; + const firstKey = forward ? "ArrowDown" : "ArrowUp"; + const returnKey = forward ? "ArrowUp" : "ArrowDown"; + const neighbor = current.state.searchResults[selectedPosition + (forward ? 1 : -1)]; + if (!neighbor) failKnown("The exact stock has no adjacent keyboard result."); + await pressKey(firstKey, "move stock result selection to adjacent row"); + current = await waitFor("adjacent stock keyboard selection", snapshot => + snapshot.state.selectedStockIndex === neighbor.index && + snapshot.dom.stockOptions.filter(row => row.selected).length === 1 && + snapshot.dom.stockOptions.some(row => + row.index === neighbor.index && row.selected && row.tabIndex === 0 && row.focused), + { allowUiBusy: false }); + await pressKey(returnKey, "return stock result selection"); + current = await waitFor("returned stock keyboard selection", snapshot => + snapshot.state.selectedStockIndex === exactStock.index && + snapshot.dom.stockOptions.filter(row => row.selected).length === 1 && + snapshot.dom.stockOptions.some(row => + row.index === exactStock.index && row.selected && row.tabIndex === 0 && row.focused), + { allowUiBusy: false }); + await checkpoint("stock-search-and-keyboard", { + resultCount: current.state.searchResults.length, + exactStockIndex: exactStock.index + }); + + const firstThreeCuts = current.state.cutRows.slice(0, 3); + if (firstThreeCuts.length !== 3 || + firstThreeCuts.some((row, index) => + row.physicalIndex !== index || row.isSeparator === true)) { + failKnown("The first three physical cuts are not the expected non-separator fixture."); + } + assertExactArray( + firstThreeCuts.map(row => row.rawLabel), + [ + "1열판기본_예상체결가", + "1열판기본_현재가", + "1열판기본_시간외단일가" + ], + "first three physical cut labels"); + + await pointerClick( + 'document.querySelector("#cut-list .cut-row[data-physical-index=\'0\']")', + "select first cut"); + current = await waitFor("first cut selection", snapshot => + focusedCutIndex(snapshot) === 0 && + JSON.stringify(selectedPhysicalIndices(snapshot)) === JSON.stringify([0]), + { allowUiBusy: false }); + await pressKey("ArrowDown", "move cut focus without changing selection", ctrlModifier); + current = await waitFor("Ctrl ArrowDown cut focus", snapshot => + focusedCutIndex(snapshot) === 1 && + JSON.stringify(selectedPhysicalIndices(snapshot)) === JSON.stringify([0]), + { allowUiBusy: false }); + await pressKey("Space", "select focused cut with Space"); + current = await waitFor("Space cut selection", snapshot => + focusedCutIndex(snapshot) === 1 && + JSON.stringify(selectedPhysicalIndices(snapshot)) === JSON.stringify([1]), + { allowUiBusy: false }); + await pressKey("ArrowDown", "extend cut selection with Shift ArrowDown", shiftModifier); + current = await waitFor("Shift ArrowDown cut range", snapshot => + focusedCutIndex(snapshot) === 2 && + JSON.stringify(selectedPhysicalIndices(snapshot)) === JSON.stringify([1, 2]), + { allowUiBusy: false }); + await checkpoint("cut-keyboard", { + selectedPhysicalIndices: selectedPhysicalIndices(current), + focusedPhysicalIndex: focusedCutIndex(current) + }); + + current = await dragCutsToPlaylist( + 2, + ctrlModifier | shiftModifier, + 2, + "multi-cut captured-pointer drag"); + assertExactArray( + current.state.playlist.map(row => row.stockName), + [evidence.fixture.stockDisplayName, evidence.fixture.stockDisplayName], + "two-cut playlist stock order"); + assertExactArray( + current.state.playlist.map(row => `${row.graphicType}|${row.subtype}`), + ["1열판기본|현재가", "1열판기본|시간외단일가"], + "two-cut playlist mapping"); + await checkpoint("multi-cut-drag", { + playlistRowIds: current.state.playlist.map(row => row.rowId) + }); + + current = await dragCutsToPlaylist( + 0, + 0, + 1, + "single-cut captured-pointer drag"); + if (current.state.playlist.length !== 3) { + failKnown("The two cut drags did not create exactly three in-memory rows."); + } + assertExactArray( + current.state.playlist.map(row => `${row.graphicType}|${row.subtype}`), + [ + "1열판기본|현재가", + "1열판기본|시간외단일가", + "1열판기본|예상체결가" + ], + "three-cut playlist mapping"); + assertExactArray( + current.dom.playlist.map(row => row.rowHeaderText), + ["1", "2", "3"], + "playlist row-header ordinals"); + if (current.dom.playlist.some(row => + !Number.isFinite(row.rowHeaderWidth) || + Math.abs(row.rowHeaderWidth - 30) > 0.5 || + row.rowHeaderDragEnabled !== true)) { + failKnown("Playlist row-header geometry or drag availability differs from FarPoint."); + } + await checkpoint("single-cut-drag", { + playlistRowIds: current.state.playlist.map(row => row.rowId) + }); + + const [rowA, rowB, rowC] = current.state.playlist.map(row => row.rowId); + await pointerClickPlaylistRow(rowA, "select playlist row A"); + current = await waitFor("playlist row A selection", snapshot => + activePlaylistId(snapshot) === rowA && + JSON.stringify(selectedPlaylistIds(snapshot)) === JSON.stringify([rowA]), + { allowUiBusy: false }); + await pointerClickPlaylistRow(rowB, "Ctrl-select playlist row B", ctrlModifier); + current = await waitFor("playlist A+B selection", snapshot => + activePlaylistId(snapshot) === rowB && + JSON.stringify(selectedPlaylistIds(snapshot)) === JSON.stringify([rowA, rowB]), + { allowUiBusy: false }); + + await dragPlaylistRow(rowA, rowC, "single-source playlist row drag"); + current = await waitFor("single-source playlist reorder", snapshot => + JSON.stringify(snapshot.state.playlist.map(row => row.rowId)) === + JSON.stringify([rowB, rowC, rowA]), + { allowUiBusy: false }); + assertExactArray(selectedPlaylistIds(current), [rowA], "post-drag deletion selection"); + if (activePlaylistId(current) !== rowA || + current.dom.activeElement?.playlistRowId !== rowA) { + failKnown("Playlist drag did not retain the source as active and focused."); + } + await checkpoint("playlist-single-source-drag", { + beforeOrder: [rowA, rowB, rowC], + afterOrder: [rowB, rowC, rowA] + }); + assertExactArray( + current.dom.playlist.map(row => row.rowHeaderText), + ["1", "2", "3"], + "reordered playlist row-header ordinals"); + + const orderBeforeCheckboxAttempt = current.state.playlist.map(row => row.rowId); + const enabledBeforeCheckboxAttempt = current.state.playlist.map(row => ({ + rowId: row.rowId, + enabled: row.isEnabled + })); + await attemptCheckboxOriginDrag(rowB, rowA, "checkbox-origin drag rejection"); + current = await waitFor("checkbox-origin drag rejection", snapshot => + snapshot.state.isBusy === false && activePlaylistId(snapshot) === rowB, + { allowUiBusy: false }); + assertExactArray( + current.state.playlist.map(row => row.rowId), + orderBeforeCheckboxAttempt, + "checkbox-origin playlist order"); + if (JSON.stringify(current.state.playlist.map(row => ({ + rowId: row.rowId, + enabled: row.isEnabled + }))) !== JSON.stringify(enabledBeforeCheckboxAttempt)) { + failKnown("Checkbox-origin drag changed a row's enabled state."); + } + assertExactArray(selectedPlaylistIds(current), [rowA], "checkbox-origin deletion selection"); + if (current.dom.playlist.some(row => row.dragging || row.dragBefore || row.dragAfter)) { + failKnown("Checkbox-origin drag left playlist drag visuals active."); + } + await checkpoint("playlist-checkbox-drag-rejected"); + + const focusBeforeBoundary = current.dom.activeElement; + if (focusBeforeBoundary?.playlistRowId !== rowB) { + failKnown("Checkbox-origin pointer did not leave focus inside source row B."); + } + await pressKey("End", "select last playlist boundary without moving focus"); + current = await waitFor("playlist End boundary", snapshot => + activePlaylistId(snapshot) === rowA, + { allowUiBusy: false }); + assertExactArray(selectedPlaylistIds(current), [rowA], "End deletion selection"); + if (JSON.stringify(current.dom.activeElement) !== JSON.stringify(focusBeforeBoundary)) { + failKnown("End moved DOM focus instead of only changing the F8 candidate."); + } + await pressKey("Home", "select first playlist boundary without moving focus"); + current = await waitFor("playlist Home boundary", snapshot => + activePlaylistId(snapshot) === rowB, + { allowUiBusy: false }); + assertExactArray(selectedPlaylistIds(current), [rowA], "Home deletion selection"); + if (JSON.stringify(current.dom.activeElement) !== JSON.stringify(focusBeforeBoundary)) { + failKnown("Home moved DOM focus instead of only changing the F8 candidate."); + } + await checkpoint("playlist-keyboard-focus-split"); + + current = await requestWindowsInput( + current, + rowB, + rowA, + rowC, + "Windows SendInput row-header drag and Home key"); + + await pointerClick( + 'document.getElementById("db-load-button")', + "open DB playlist load modal"); + current = await waitFor("read-only named playlist load modal", snapshot => + snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === false && + snapshot.dom.namedConfirmationHidden === true && + snapshot.dom.activeInsideVisibleModal === true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + if (current.dom.namedTitle !== "DB 재생목록 불러오기") { + failKnown(`Unexpected load-modal title ${current.dom.namedTitle}.`); + } + await pressKey("Escape", "child load modal Escape no-op"); + await sleep(100); + current = await readSnapshot(); + assertSafety(current, "load modal Escape", false); + if (current.dom.namedModalHidden || !current.dom.namedConfirmationHidden || + !current.dom.activeInsideVisibleModal) { + failKnown("Escape closed the read-only named-playlist child modal."); + } + + const focusableCount = current.dom.modalFocusableIds.length; + if (focusableCount < 2) failKnown("The named-playlist modal has fewer than two focusable controls."); + for (let index = 0; index <= focusableCount; index += 1) { + await pressKey("Tab", `load modal Shift+Tab cycle ${index + 1}`, shiftModifier); + const tabSnapshot = await readSnapshot(); + assertSafety(tabSnapshot, `load modal Shift+Tab cycle ${index + 1}`, false); + if (!tabSnapshot.dom.activeInsideVisibleModal) { + failKnown("Shift+Tab escaped the named-playlist modal focus trap."); + } + } + await pointerClick( + 'document.getElementById("named-playlist-cancel-button")', + "close load modal with Cancel"); + current = await waitFor("load modal close and focus restore", snapshot => + snapshot.dom.namedModalHidden === true && + snapshot.dom.activeElement?.id === "db-load-button", + { allowUiBusy: false }); + await checkpoint("named-playlist-load-modal-focus", { + definitionCount: current.state.namedPlaylist?.definitions?.length ?? 0, + focusableCount + }); + + await pointerClick( + 'document.getElementById("db-save-button")', + "open DB playlist save modal"); + current = await waitFor("read-only named playlist save modal", snapshot => + snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === false && + snapshot.dom.namedConfirmationHidden === true && + snapshot.dom.activeInsideVisibleModal === true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + if (current.dom.namedTitle !== "DB 재생목록 저장") { + failKnown(`Unexpected save-modal title ${current.dom.namedTitle}.`); + } + + if (current.dom.namedConfirmDisabled) { + evidence.warnings.push( + "Nested save confirmation was skipped because no existing definition was safely selectable."); + await pointerClick( + 'document.getElementById("named-playlist-cancel-button")', + "close save modal without an existing definition"); + current = await waitFor("save modal fixture skip close", snapshot => + snapshot.dom.namedModalHidden === true && + snapshot.dom.activeElement?.id === "db-save-button", + { allowUiBusy: false }); + } else { + const namedBeforeConfirmation = { + revision: current.state.namedPlaylist?.revision ?? null, + lastMutationOutcome: current.state.namedPlaylist?.lastMutationOutcome ?? null, + isWriteInProgress: current.state.namedPlaylist?.isWriteInProgress ?? null + }; + await pointerClick( + 'document.getElementById("named-playlist-confirm-button")', + "open save confirmation without confirming write"); + current = await waitFor("nested save confirmation", snapshot => + snapshot.dom.namedModalHidden === false && + snapshot.dom.namedConfirmationHidden === false && + snapshot.dom.namedConfirmationMessage === "저장하겠습니까?" && + snapshot.dom.activeElement?.id === "named-playlist-confirmation-yes", + { allowUiBusy: false }); + await pressKey("Escape", "nested save confirmation Escape no-op"); + await sleep(100); + current = await readSnapshot(); + assertSafety(current, "nested confirmation Escape", false); + if (current.dom.namedConfirmationHidden || + current.dom.activeElement?.id !== "named-playlist-confirmation-yes") { + failKnown("Escape changed the nested save confirmation."); + } + await pressKey("Tab", "move nested confirmation focus to No"); + current = await readSnapshot(); + if (current.dom.activeElement?.id !== "named-playlist-confirmation-no") { + failKnown("Tab did not move nested confirmation focus from Yes to No."); + } + await pressKey("Tab", "wrap nested confirmation focus to Yes"); + current = await readSnapshot(); + if (current.dom.activeElement?.id !== "named-playlist-confirmation-yes") { + failKnown("Tab did not wrap nested confirmation focus back to Yes."); + } + await pointerClick( + 'document.getElementById("named-playlist-confirmation-no")', + "decline save confirmation"); + current = await waitFor("declined save confirmation close", snapshot => + snapshot.dom.namedModalHidden === true && + snapshot.dom.namedConfirmationHidden === true && + snapshot.dom.activeElement?.id === "db-save-button", + { allowUiBusy: false }); + const namedAfterConfirmation = { + revision: current.state.namedPlaylist?.revision ?? null, + lastMutationOutcome: current.state.namedPlaylist?.lastMutationOutcome ?? null, + isWriteInProgress: current.state.namedPlaylist?.isWriteInProgress ?? null + }; + if (JSON.stringify(namedAfterConfirmation) !== + JSON.stringify(namedBeforeConfirmation)) { + failKnown("Declining the save confirmation changed named-playlist mutation state."); + } + } + await checkpoint("named-playlist-save-confirmation-no-write"); + + current = await checkpoint("final", { + expectedPlaylistOrder: [rowC, rowA, rowB] + }); + assertExactArray( + current.state.playlist.map(row => row.rowId), + [rowC, rowA, rowB], + "final playlist order"); + assertExactArray(selectedPlaylistIds(current), [rowB], "final deletion selection"); + if (!current.dom.namedModalHidden || !current.dom.namedConfirmationHidden) { + failKnown("A named-playlist modal remained open at final state."); + } + evidence.finalSnapshot = current; +} + +try { + const target = await discoverExactTarget(); + const endpoint = validateWebSocketTarget(target); + evidence.target.id = target.id; + evidence.target.url = target.url; + await connect(endpoint); + await executeSafeSequence(); + await captureScreenshot(); + const sealedSnapshot = await readSnapshot(); + assertSafety(sealedSnapshot, "post-screenshot safety seal", false); + evidence.finalSnapshot = sealedSnapshot; + evidence.result = evidence.warnings.length === 0 ? "PASS" : "PASS_WITH_WARNING"; +} catch (error) { + evidence.result = "FAIL"; + evidence.error = errorProjection(error); + process.exitCode = 1; + + if (socket?.readyState === WebSocket.OPEN) { + try { + evidence.finalSnapshot = await readSnapshot(); + } catch (snapshotError) { + evidence.warnings.push(`Failure snapshot unavailable: ${snapshotError.message}`); + } + try { + await captureScreenshot(); + } catch (screenshotError) { + evidence.warnings.push(`Failure screenshot unavailable: ${screenshotError.message}`); + } + } +} finally { + cleanupMode = true; + cleanupDeadlineEpochMilliseconds = Date.now() + 5_000; + const cleanupFailures = []; + if (pointerIsPressed && socket?.readyState === WebSocket.OPEN) { + try { + await releaseMouse(pointerReleasePoint || { x: 1, y: 1 }, pointerReleaseModifiers); + } catch (releaseError) { + evidence.warnings.push(`Pointer release cleanup failed: ${releaseError.message}`); + cleanupFailures.push(`pointer release: ${releaseError.message}`); + } + } + if (socket?.readyState === WebSocket.OPEN) { + try { + await removeProbe(); + } catch (probeError) { + evidence.warnings.push(`Probe cleanup failed: ${probeError.message}`); + cleanupFailures.push(`probe restore: ${probeError.message}`); + } + socket.close(); + } else if (probeInstalled) { + evidence.warnings.push( + "Probe cleanup failed: the CDP socket was closed before bridge restoration."); + cleanupFailures.push("probe restore: CDP socket closed"); + } + if (cleanupFailures.length > 0 && evidence.result !== "FAIL") { + evidence.result = "FAIL"; + evidence.error = { + category: "OUTCOME_UNKNOWN", + name: "CleanupFailure", + message: "The one-shot input cleanup did not complete." + }; + process.exitCode = 1; + } + evidence.completedAt = new Date().toISOString(); + fs.writeFileSync( + configuration.outputPath, + `${JSON.stringify(evidence, null, 2)}\n`, + { flag: "wx" }); +} + +if (evidence.result === "FAIL") { + console.error(JSON.stringify({ + result: evidence.result, + output: configuration.outputPath, + screenshot: evidence.screenshot?.path || null, + error: evidence.error + })); +} else { + console.log(JSON.stringify({ + result: evidence.result, + output: configuration.outputPath, + screenshot: evidence.screenshot + })); +} diff --git a/scripts/Test-LegacyPackageUiOnlySmoke.mjs b/scripts/Test-LegacyPackageUiOnlySmoke.mjs new file mode 100644 index 0000000..3cfcb5a --- /dev/null +++ b/scripts/Test-LegacyPackageUiOnlySmoke.mjs @@ -0,0 +1,259 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createHash } from "node:crypto"; + +const exactUrl = "https://legacy-parity.mbn.local/index.html"; +const allowedArguments = new Set(["port", "output", "screenshot", "timeout-ms"]); +const forbiddenCdpMethods = new Set([ + "Browser.close", "Page.close", "Target.closeTarget", "Runtime.terminateExecution", + "Input.dispatchMouseEvent", "Input.dispatchKeyEvent", "Input.insertText" +]); + +class SmokeFailure extends Error { + constructor(kind, message) { + super(`${kind}: ${message}`); + this.name = "SmokeFailure"; + this.kind = kind; + } +} + +function fail(kind, message) { throw new SmokeFailure(kind, message); } + +function parseArguments(argv) { + if (argv.length === 0 || argv.length % 2 !== 0) { + fail("KNOWN_FAILURE", "Use --port, --output, --screenshot, and optional --timeout-ms pairs."); + } + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index]?.replace(/^--/u, ""); + const value = argv[index + 1]; + if (!key || !argv[index].startsWith("--") || !value || !allowedArguments.has(key) || values.has(key)) { + fail("KNOWN_FAILURE", "Arguments must be unique supported --name value pairs."); + } + values.set(key, value); + } + for (const key of ["port", "output", "screenshot"]) { + if (!values.has(key)) fail("KNOWN_FAILURE", `Missing --${key}.`); + } + const port = Number(values.get("port")); + if (!Number.isInteger(port) || port < 1024 || port > 65535 || port === 30001) { + fail("KNOWN_FAILURE", "--port must be a non-Tornado port from 1024 through 65535."); + } + const outputPath = path.resolve(values.get("output")); + const screenshotPath = path.resolve(values.get("screenshot")); + if (!path.isAbsolute(values.get("output")) || path.extname(outputPath).toLowerCase() !== ".json" || + !path.isAbsolute(values.get("screenshot")) || path.extname(screenshotPath).toLowerCase() !== ".png" || + outputPath.toLowerCase() === screenshotPath.toLowerCase()) { + fail("KNOWN_FAILURE", "Output must be a distinct absolute .json and screenshot a distinct absolute .png."); + } + const timeoutMilliseconds = values.has("timeout-ms") ? Number(values.get("timeout-ms")) : 30000; + if (!Number.isInteger(timeoutMilliseconds) || timeoutMilliseconds < 5000 || timeoutMilliseconds > 120000) { + fail("KNOWN_FAILURE", "--timeout-ms must be from 5000 through 120000."); + } + if (fs.existsSync(outputPath) || fs.existsSync(screenshotPath)) { + fail("KNOWN_FAILURE", "Evidence files must not already exist."); + } + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.mkdirSync(path.dirname(screenshotPath), { recursive: true }); + return { port, outputPath, screenshotPath, timeoutMilliseconds }; +} + +const configuration = parseArguments(process.argv.slice(2)); +const deadline = Date.now() + configuration.timeoutMilliseconds; +const evidence = { + schemaVersion: 1, + result: "RUNNING", + startedAt: new Date().toISOString(), + completedAt: null, + target: { expectedUrl: exactUrl, port: configuration.port, id: null, url: null }, + safety: { + noInputDispatched: true, + noNativeIntentAfterGuard: true, + // app.js emits this empty, read-only bootstrap before CDP can attach. + // Seeing native-rendered DRY RUN / IDLE below proves that its state reply arrived; + // this harness does not falsely claim to have observed the earlier outbound call. + bootstrapReadyContract: { + type: "ready", + payload: {}, + inferredFromAuthoritativeState: true, + nativeRoute: "LegacyReadyIntent => _controller.Current" + }, + blockedMessages: [], + observedMessagesAfterGuard: [] + }, + shell: null, + screenshot: null, + error: null +}; + +let socket; +let nextId = 1; +const pending = new Map(); +let guardInstalled = false; + +function remaining() { + const value = deadline - Date.now(); + if (value <= 0) fail("OUTCOME_UNKNOWN", "Observation deadline expired; nothing was retried."); + return value; +} + +function sleep(milliseconds) { + return new Promise(resolve => setTimeout(resolve, Math.min(milliseconds, remaining()))); +} + +async function discoverTarget() { + const discoveryUrl = `http://127.0.0.1:${configuration.port}/json/list`; + while (Date.now() < deadline) { + try { + const response = await fetch(discoveryUrl, { redirect: "error", signal: AbortSignal.timeout(1000) }); + const targets = await response.json(); + const matches = Array.isArray(targets) ? targets.filter(target => + target?.type === "page" && target.url === exactUrl) : []; + if (matches.length > 1) fail("KNOWN_FAILURE", "More than one exact package document was exposed."); + if (matches.length === 1 && matches[0].id && matches[0].webSocketDebuggerUrl) return matches[0]; + } catch (error) { + if (error instanceof SmokeFailure) throw error; + } + await sleep(100); + } + fail("KNOWN_FAILURE", "The exact package WebView target was not found."); +} + +async function connect(target) { + 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}`) { + fail("KNOWN_FAILURE", "The target CDP endpoint is not the exact loopback page endpoint."); + } + socket = new WebSocket(endpoint.href); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new SmokeFailure("OUTCOME_UNKNOWN", "CDP open timed out.")), 5000); + socket.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true }); + socket.addEventListener("error", () => { clearTimeout(timer); reject(new SmokeFailure("OUTCOME_UNKNOWN", "CDP open failed.")); }, { once: true }); + }); + socket.addEventListener("message", event => { + const message = JSON.parse(String(event.data)); + if (!Object.hasOwn(message, "id")) return; + const request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + clearTimeout(request.timer); + message.error ? request.reject(new SmokeFailure("KNOWN_FAILURE", `${request.method}: ${message.error.message || "CDP error"}`)) : request.resolve(message.result); + }); +} + +function rpc(method, params = {}) { + if (forbiddenCdpMethods.has(method)) fail("KNOWN_FAILURE", `Forbidden CDP method ${method}.`); + if (!socket || socket.readyState !== WebSocket.OPEN) fail("OUTCOME_UNKNOWN", "CDP is unavailable."); + const id = nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new SmokeFailure("OUTCOME_UNKNOWN", `${method} timed out; nothing was retried.`)); + }, Math.min(10000, remaining())); + pending.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) fail("KNOWN_FAILURE", response.exceptionDetails.text || "Runtime evaluation failed."); + return response.result?.value; +} + +async function installNoIntentGuard() { + const installed = await evaluate(`(() => { + const bridge = window.chrome?.webview; + if (!bridge || globalThis.__legacyUiOnlySmoke) return false; + const probe = { original: bridge.postMessage, blocked: [], observed: [] }; + probe.wrapper = message => { + const record = { type: typeof message?.type === "string" ? message.type : null, at: new Date().toISOString() }; + probe.observed.push(record); + probe.blocked.push(record); + return undefined; + }; + bridge.postMessage = probe.wrapper; + if (bridge.postMessage !== probe.wrapper) throw new Error("postMessage guard identity changed"); + globalThis.__legacyUiOnlySmoke = probe; + return true; + })()`); + if (installed !== true) fail("KNOWN_FAILURE", "The no-intent WebView guard could not be installed exactly once."); + guardInstalled = true; +} + +async function readShell() { + return evaluate(`(() => { + const probe = globalThis.__legacyUiOnlySmoke; + const text = id => document.getElementById(id)?.textContent?.trim() || null; + const button = id => { + const element = document.getElementById(id); + return element ? { present: true, disabled: element.disabled === true, text: element.textContent.trim() } : { present: false }; + }; + return { + url: location.href, + readyState: document.readyState, + bodyBusy: document.body?.getAttribute("aria-busy") || null, + connectionText: text("playout-connection-state"), + playoutStatus: text("playout-status"), + title: document.title, + controls: { + search: button("stock-search-button"), + prepare: button("playout-prepare"), + takeIn: button("playout-take-in"), + next: button("playout-next"), + takeOut: button("playout-take-out") + }, + visibleDialogs: Array.from(document.querySelectorAll("[role='dialog'], [role='alertdialog']")) + .filter(element => !element.hidden && !element.closest("[hidden]")).map(element => element.id || element.getAttribute("aria-label") || "dialog"), + postMessageGuardInstalled: window.chrome?.webview?.postMessage === probe?.wrapper, + observedMessagesAfterGuard: probe?.observed || [], + blockedMessages: probe?.blocked || [] + }; + })()`); +} + +function assertShell(shell) { + if (!shell || shell.url !== exactUrl || shell.readyState !== "complete" || shell.bodyBusy !== "false" || + shell.connectionText !== "DRY RUN" || !shell.playoutStatus?.startsWith("IDLE") || + shell.visibleDialogs.length !== 0 || + shell.postMessageGuardInstalled !== true || shell.observedMessagesAfterGuard.length !== 0 || shell.blockedMessages.length !== 0) { + fail("KNOWN_FAILURE", "The package shell did not remain rendered DryRun/IDLE with zero guarded native intents."); + } + for (const [name, control] of Object.entries(shell.controls || {})) { + if (!control?.present) fail("KNOWN_FAILURE", `Required shell control ${name} is missing.`); + } +} + +async function captureScreenshot() { + const response = await rpc("Page.captureScreenshot", { format: "png", fromSurface: true, captureBeyondViewport: false }); + if (typeof response.data !== "string" || response.data.length === 0) fail("KNOWN_FAILURE", "CDP returned no screenshot bytes."); + const bytes = Buffer.from(response.data, "base64"); + fs.writeFileSync(configuration.screenshotPath, bytes, { flag: "wx" }); + return { path: configuration.screenshotPath, bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex").toUpperCase() }; +} + +try { + const target = await discoverTarget(); + evidence.target.id = target.id; + evidence.target.url = target.url; + await connect(target); + await rpc("Runtime.enable"); + await rpc("Page.enable"); + await installNoIntentGuard(); + await sleep(500); + evidence.shell = await readShell(); + assertShell(evidence.shell); + evidence.safety.observedMessagesAfterGuard = evidence.shell.observedMessagesAfterGuard; + evidence.safety.blockedMessages = evidence.shell.blockedMessages; + evidence.screenshot = await captureScreenshot(); + evidence.result = "PASS"; +} catch (error) { + evidence.result = "FAIL"; + evidence.error = { kind: error instanceof SmokeFailure ? error.kind : "HARNESS_ERROR", message: String(error?.message || error) }; + process.exitCode = 1; +} finally { + evidence.completedAt = new Date().toISOString(); + if (socket?.readyState === WebSocket.OPEN) socket.close(); + fs.writeFileSync(configuration.outputPath, `${JSON.stringify(evidence, null, 2)}\n`, { flag: "wx" }); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs index eec9a7d..e13c9fd 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs @@ -39,8 +39,8 @@ public interface IExpertCatalogPersistenceService /// /// Atomic migration of EList/UC6 expert CRUD on Oracle. Code and name are a -/// closed optimistic identity, while duplicate names and stale edits are checked -/// inside the same serializable transaction that replaces recommendation rows. +/// closed optimistic identity, while stale edits are checked inside the same +/// serializable transaction that replaces recommendation rows. /// public sealed partial class LegacyExpertCatalogPersistenceService : IExpertCatalogPersistenceService { @@ -70,8 +70,6 @@ public sealed partial class LegacyExpertCatalogPersistenceService : IExpertCatal FROM DUAL WHERE NOT EXISTS ( SELECT 1 FROM EXPERT_LIST WHERE EP_CODE = :conflict_code) - AND NOT EXISTS ( - SELECT 1 FROM EXPERT_LIST WHERE EP_NAME = :conflict_name) """; private const string UpdateExpertSql = """ @@ -79,11 +77,6 @@ public sealed partial class LegacyExpertCatalogPersistenceService : IExpertCatal SET EP_NAME = :new_name WHERE target.EP_CODE = :expert_code AND target.EP_NAME = :expected_name - AND NOT EXISTS ( - SELECT 1 - FROM EXPERT_LIST other_row - WHERE other_row.EP_NAME = :duplicate_name - AND other_row.EP_CODE <> :other_code) """; private const string DeleteRecommendationsSql = @@ -170,8 +163,7 @@ public sealed partial class LegacyExpertCatalogPersistenceService : IExpertCatal OperatorCatalogAffectedRowsRule.ExactlyOne, String("expert_code", canonical.Identity.ExpertCode), String("expert_name", canonical.Identity.ExpertName), - String("conflict_code", canonical.Identity.ExpertCode), - String("conflict_name", canonical.Identity.ExpertName)) + String("conflict_code", canonical.Identity.ExpertCode)) }; commands.AddRange(canonical.Recommendations.Select(recommendation => CreateRecommendationCommand(canonical.Identity.ExpertCode, recommendation))); @@ -209,9 +201,7 @@ public sealed partial class LegacyExpertCatalogPersistenceService : IExpertCatal OperatorCatalogAffectedRowsRule.ExactlyOne, String("new_name", canonicalName), String("expert_code", expected.ExpertCode), - String("expected_name", expected.ExpertName), - String("duplicate_name", canonicalName), - String("other_code", expected.ExpertCode)), + String("expected_name", expected.ExpertName)), Command( OperatorCatalogDbCommandKind.DeleteRecommendations, DeleteRecommendationsSql, @@ -374,8 +364,6 @@ public sealed partial class LegacyExpertCatalogPersistenceService : IExpertCatal $"An expert can contain at most {MaximumRecommendations} recommendations."); } - var stockCodes = new HashSet(StringComparer.Ordinal); - var stockNames = new HashSet(StringComparer.Ordinal); var canonical = new ExpertCatalogRecommendation[recommendations.Count]; for (var index = 0; index < recommendations.Count; index++) { @@ -402,13 +390,6 @@ public sealed partial class LegacyExpertCatalogPersistenceService : IExpertCatal recommendation.StockName, MaximumStockNameLength, parameterName); - if (!stockCodes.Add(stockCode) || !stockNames.Add(stockName)) - { - throw new ArgumentException( - "Recommendation stock codes and names must each be unique.", - parameterName); - } - var buyAmount = recommendation.BuyAmount; if (buyAmount <= 0 || buyAmount > MaximumWebSafeInteger || decimal.Truncate(buyAmount) != buyAmount) diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs index 17c6b1e..64ba28b 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs @@ -191,7 +191,6 @@ public sealed partial class LegacyExpertSelectionService : IExpertSelectionServi { ValidateExactStringSchema(table, SearchColumns, rowLimit, SearchQueryName); - var names = new HashSet(StringComparer.Ordinal); var codes = new HashSet(StringComparer.Ordinal); var results = new List(table!.Rows.Count); foreach (DataRow row in table.Rows) @@ -203,11 +202,9 @@ public sealed partial class LegacyExpertSelectionService : IExpertSelectionServi SearchQueryName); var code = ReadExpertCode(row, 1, SearchQueryName); - // The paged scene originally resolves EP_CODE from EP_NAME. Both - // columns must therefore be unique before a selectable row is exposed. - if (!names.Add(name) || !codes.Add(code)) + if (!codes.Add(code)) { - throw InvalidData(SearchQueryName, "duplicate expert name or code"); + throw InvalidData(SearchQueryName, "duplicate expert code"); } results.Add(new ExpertSelectorItem( @@ -229,8 +226,6 @@ public sealed partial class LegacyExpertSelectionService : IExpertSelectionServi throw InvalidData(PreviewQueryName, "missing selected expert"); } - var stockCodes = new HashSet(StringComparer.Ordinal); - var stockNames = new HashSet(StringComparer.Ordinal); var playIndexes = new HashSet(); var items = new List(table.Rows.Count); var sawEmptyExpert = false; @@ -289,13 +284,9 @@ public sealed partial class LegacyExpertSelectionService : IExpertSelectionServi PreviewQueryName); var buyAmount = ReadPositiveDecimal(row, 4, PreviewQueryName); var playIndex = ReadPlayIndex(row, 5, PreviewQueryName); - if (!stockCodes.Add(stockCode) || - !stockNames.Add(stockName) || - !playIndexes.Add(playIndex)) + if (!playIndexes.Add(playIndex)) { - throw InvalidData( - PreviewQueryName, - "duplicate stock code, stock name, or play index"); + throw InvalidData(PreviewQueryName, "duplicate play index"); } items.Add(new ExpertRecommendationPreview( diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/IndustrySelection.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/IndustrySelection.cs index a80e353..de5fb6b 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/IndustrySelection.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/IndustrySelection.cs @@ -86,8 +86,6 @@ public sealed partial class LegacyIndustrySelectionService : IIndustrySelectionS } var results = new List(table.Rows.Count); - var names = new HashSet(StringComparer.Ordinal); - var codes = new HashSet(StringComparer.Ordinal); foreach (DataRow row in table.Rows) { var name = ReadRequiredText(row, "INDUSTRY_NAME", MaximumNameLength); @@ -97,15 +95,9 @@ public sealed partial class LegacyIndustrySelectionService : IIndustrySelectionS throw new IndustrySelectionDataException("The industry selector returned an invalid code."); } - // Every downstream scene query resolves an industry by name. Treat both - // columns as unique identities so a selector can never represent an - // ambiguous name/code pair, even if the returned tuples are distinct. - if (!names.Add(name) || !codes.Add(code)) - { - throw new IndustrySelectionDataException( - "The industry selector returned an ambiguous duplicate name or code."); - } - + // UC2 copied every query row into the ListBox in query order. Preserve + // duplicate names/codes here; the later exact-one-row scene loader still + // fails closed if a selected name is ambiguous for playout data. results.Add(new IndustrySelection(market, name, code)); } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyComparisonPairImport.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyComparisonPairImport.cs index 966a9e3..3f47d75 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyComparisonPairImport.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyComparisonPairImport.cs @@ -421,7 +421,6 @@ public static class LegacyComparisonFileParser } var rows = new List(lines.Length); - var signatures = new HashSet(StringComparer.Ordinal); for (var index = 0; index < lines.Length; index++) { var line = lines[index]; @@ -459,17 +458,6 @@ public static class LegacyComparisonFileParser ValidateMarketAndName(names[0], fields[2]); ValidateMarketAndName(names[1], fields[3]); - var signature = string.Join( - '\u001f', - names[0], - fields[2], - names[1], - fields[3]); - if (!signatures.Add(signature)) - { - throw InvalidSource(); - } - rows.Add(new LegacyComparisonFileRow( index + 1, new LegacyComparisonFileTarget(names[0], fields[2]), diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedForeignStockRestore.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedForeignStockRestore.cs index 15404f6..f8c7d9d 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedForeignStockRestore.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedForeignStockRestore.cs @@ -352,7 +352,11 @@ public sealed class LegacyNamedForeignStockRestoreService CandleActions.TryGetValue(subtype, out action!); } - internal static bool IsCanonicalBoundSymbol(string? value) => + /// + /// Shared native validation for a bound foreign-master symbol. It exposes + /// validation only; it never returns or serializes an operator identity. + /// + public static bool IsCanonicalBoundSymbol(string? value) => value is not null && GetSymbolIssue(value) is null; private static LegacyNamedForeignStockDatabaseIssue? GetSymbolIssue(string value) diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasStockSearch.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasStockSearch.cs new file mode 100644 index 0000000..72f6532 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasStockSearch.cs @@ -0,0 +1,208 @@ +#nullable enable + +using System.Data; +using System.Globalization; + +namespace MMoneyCoderSharp.Data; + +/// +/// UC5-only stock lookup contract. It is intentionally separate from the +/// comparison workflow's US/TW-only world-stock lookup. +/// +public interface IOverseasStockSearchService +{ + Task SearchAsync( + string query, + int maximumResults = LegacyOverseasStockSearchService.DefaultMaximumResults, + CancellationToken cancellationToken = default); +} + +/// +/// Parameterized read-only port of UC5.jongmok_search. The original initial +/// list is restricted to US/TW; a non-empty search intentionally spans every +/// foreign nation with F_FDTC = '1'. +/// +public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchService +{ + public const int MaximumResults = 500; + // 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. + public const int DefaultMaximumResults = MaximumResults; + public const int MaximumQueryLength = 64; + + private const int MaximumInputNameLength = 200; + private const int MaximumSymbolLength = 64; + private const int MaximumNationCodeLength = 8; + private const string QueryName = "OVERSEAS_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 F_INPUT_NAME + ) + WHERE ROWNUM <= :row_limit + """; + + private const string SearchSql = """ + 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 UPPER(F_INPUT_NAME) LIKE '%' || :search_term || '%' ESCAPE '!' + ORDER BY F_INPUT_NAME + ) + WHERE ROWNUM <= :row_limit + """; + + private readonly IDataQueryExecutor _executor; + + public LegacyOverseasStockSearchService(IDataQueryExecutor executor) => + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + + public async Task SearchAsync( + string query, + int maximumResults = DefaultMaximumResults, + CancellationToken cancellationToken = default) + { + var normalizedQuery = ValidateAndNormalizeQuery(query); + if (maximumResults is < 1 or > MaximumResults) + { + throw new ArgumentOutOfRangeException( + nameof(maximumResults), + $"The requested result limit must be between 1 and {MaximumResults}."); + } + + cancellationToken.ThrowIfCancellationRequested(); + var rowLimit = checked(maximumResults + 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); + return new WorldStockSearchResult( + normalizedQuery, + DateTimeOffset.Now, + matches.Take(maximumResults).ToArray(), + matches.Count > maximumResults); + } + + private static DataQuerySpec CreateQuerySpec(string query, int rowLimit) + { + 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; + } + + private static IReadOnlyList ValidateRows(DataTable? table, int rowLimit) + { + if (table is null || table.Columns.Count != 3 || table.Rows.Count > rowLimit || + !HasExactStringColumn(table.Columns[0], "F_INPUT_NAME") || + !HasExactStringColumn(table.Columns[1], "F_SYMB") || + !HasExactStringColumn(table.Columns[2], "F_NATC")) + { + throw InvalidData("schema or row bound"); + } + + var identities = new HashSet(); + var matches = new List(table.Rows.Count); + foreach (DataRow row in table.Rows) + { + var inputName = ReadCanonical(row, 0, MaximumInputNameLength); + var symbol = ReadCanonical(row, 1, MaximumSymbolLength); + var nationCode = ReadCanonical(row, 2, MaximumNationCodeLength); + if (nationCode.Length is not (2 or 3) || + nationCode.Any(character => character is < 'A' or > 'Z')) + { + throw InvalidData("nation code"); + } + + var identity = new WorldStockSearchItem(inputName, symbol, nationCode); + if (!identities.Add(identity)) + { + throw InvalidData("duplicate exact identity"); + } + + matches.Add(identity); + } + + return matches; + } + + private static string ValidateAndNormalizeQuery(string query) + { + ArgumentNullException.ThrowIfNull(query); + var normalized = query.Trim(); + if (!IsSafeText(query) || !IsSafeText(normalized) || + normalized.Length > MaximumQueryLength) + { + throw new ArgumentException( + $"An overseas-stock search query must contain at most {MaximumQueryLength} visible characters.", + nameof(query)); + } + + return normalized; + } + + private static string ReadCanonical(DataRow row, int ordinal, int maximumLength) + { + if (row[ordinal] is not string value || value.Length < 1 || value.Length > maximumLength || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || !IsSafeText(value)) + { + throw InvalidData("value"); + } + + return value; + } + + private static bool HasExactStringColumn(DataColumn column, string name) => + string.Equals(column.ColumnName, name, StringComparison.Ordinal) && + column.DataType == typeof(string); + + private static string EscapeLikePattern(string value) => value + .Replace("!", "!!", StringComparison.Ordinal) + .Replace("%", "!%", StringComparison.Ordinal) + .Replace("_", "!_", StringComparison.Ordinal); + + private static bool IsSafeText(string value) + { + foreach (var character in value) + { + var category = CharUnicodeInfo.GetUnicodeCategory(character); + if (char.IsControl(character) || char.IsSurrogate(character) || character == '\uFFFD' || + category is UnicodeCategory.Format or UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator) + { + return false; + } + } + + return true; + } + + private static WorldStockSearchDataException InvalidData(string detail) => + new($"Overseas stock data from {QueryName} contains an invalid {detail}."); +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/StockMasterIdentityValidation.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/StockMasterIdentityValidation.cs index 0d1e488..77f4a1a 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/StockMasterIdentityValidation.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/StockMasterIdentityValidation.cs @@ -127,15 +127,6 @@ public sealed class LegacyStockMasterIdentityValidationService return Array.Empty(); } - var candidateKeys = new HashSet(); - foreach (var candidate in candidates) - { - if (!candidateKeys.Add(new StockMasterKey(candidate.Code, candidate.Name))) - { - throw InvalidIdentity(); - } - } - var matches = candidates.ToDictionary( static candidate => candidate.Index, static _ => new List()); @@ -194,8 +185,9 @@ public sealed class LegacyStockMasterIdentityValidationService throw InvalidIdentity(); } - var candidatesByKey = candidates.ToDictionary( - static candidate => new StockMasterKey(candidate.Code, candidate.Name)); + var candidatesByKey = candidates + .GroupBy(static candidate => new StockMasterKey(candidate.Code, candidate.Name)) + .ToDictionary(static group => group.Key, static group => group.ToArray()); foreach (DataRow row in table.Rows) { if (row[0] is not string rawName || row[1] is not string rawCode) @@ -208,16 +200,19 @@ public sealed class LegacyStockMasterIdentityValidationService var key = new StockMasterKey(code, name); if (!IsSafeCanonicalText(name, MaximumStockNameLength) || !IsAsciiAlphaNumeric(code, MaximumStockCodeLength) || - !candidatesByKey.TryGetValue(key, out var candidate)) + !candidatesByKey.TryGetValue(key, out var matchingCandidates)) { throw InvalidIdentity(); } - matches[candidate.Index].Add(new StockMasterIdentity( - profile.Market, - profile.Source, - name, - code)); + foreach (var candidate in matchingCandidates) + { + matches[candidate.Index].Add(new StockMasterIdentity( + profile.Market, + profile.Source, + name, + code)); + } } } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs index 1c2ac0c..4aee461 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs @@ -370,8 +370,6 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog $"A theme can contain at most {MaximumItems} items."); } - var codes = new HashSet(StringComparer.Ordinal); - var names = new HashSet(StringComparer.Ordinal); var canonical = new ThemeCatalogItem[items.Count]; for (var index = 0; index < items.Count; index++) { @@ -400,13 +398,6 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog item.ItemName, MaximumItemNameLength, parameterName); - if (!codes.Add(code) || !names.Add(name)) - { - throw new ArgumentException( - "Theme item codes and names must each be unique.", - parameterName); - } - canonical[index] = new ThemeCatalogItem(index, code, name); } @@ -552,9 +543,7 @@ internal sealed record ThemeMutationProfile( OperatorCatalogAffectedRowsRule.ExactlyOne, String("new_title", newTitle), String("theme_code", expected.ThemeCode), - String("expected_title", expected.ThemeTitle), - String("duplicate_title", newTitle), - String("other_code", expected.ThemeCode)); + String("expected_title", expected.ThemeTitle)); internal OperatorCatalogDbCommand CreateDeleteItemsCommand(string code) => Command( @@ -619,12 +608,6 @@ internal static class ThemeMutationProfiles WHERE target.SB_CODE = :theme_code AND target.SB_TITLE = :expected_title AND target.SB_MARKET = 'KRX' - AND NOT EXISTS ( - SELECT 1 - FROM SB_LIST other_row - WHERE other_row.SB_MARKET = 'KRX' - AND other_row.SB_TITLE = :duplicate_title - AND other_row.SB_CODE <> :other_code) """, "DELETE FROM SB_ITEM WHERE SB_M_CODE = :theme_code", """ @@ -656,15 +639,10 @@ internal static class ThemeMutationProfiles """, """ UPDATE SB_LIST target - LEFT JOIN SB_LIST other_row - ON other_row.SB_MARKET = 'NXT' - AND other_row.SB_TITLE = @duplicate_title - AND other_row.SB_CODE <> @other_code SET target.SB_TITLE = @new_title WHERE target.SB_CODE = @theme_code AND target.SB_TITLE = @expected_title AND target.SB_MARKET = 'NXT' - AND other_row.SB_CODE IS NULL """, "DELETE FROM SB_ITEM WHERE SB_M_CODE = @theme_code", """ diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs index 1e7d134..d1d2a9f 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs @@ -139,9 +139,6 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService } var ordered = matches - .OrderBy(static item => item.Identity.Market) - .ThenBy(static item => item.Identity.ThemeTitle, StringComparer.Ordinal) - .ThenBy(static item => item.Identity.ThemeCode, StringComparer.Ordinal) .Take(maximumResults) .ToArray(); @@ -188,7 +185,6 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService { ValidateExactStringSchema(table, SearchColumns, rowLimit, profile.SearchName); - var titles = new HashSet(StringComparer.Ordinal); var codes = new HashSet(StringComparer.Ordinal); foreach (DataRow row in table!.Rows) { @@ -202,9 +198,9 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService // PAGED_THEME resolves by title and UC4 previews by code. Both keys // must be unique inside one market or the selection is ambiguous. - if (!titles.Add(title) || !codes.Add(code)) + if (!codes.Add(code)) { - throw InvalidData(profile.SearchName, "duplicate title or code"); + throw InvalidData(profile.SearchName, "duplicate theme code"); } var session = profile.Market == ThemeMarket.Krx @@ -229,7 +225,6 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService } var items = new List(table.Rows.Count); - var itemCodes = new HashSet(StringComparer.Ordinal); var indexes = new HashSet(); var sawEmptyThemeRow = false; foreach (DataRow row in table.Rows) @@ -282,9 +277,9 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService throw InvalidData(profile.PreviewName, "item index"); } - if (!itemCodes.Add(itemCode) || !indexes.Add(inputIndex)) + if (!indexes.Add(inputIndex)) { - throw InvalidData(profile.PreviewName, "duplicate item code or index"); + throw InvalidData(profile.PreviewName, "duplicate item index"); } items.Add(new ThemeItemPreview(inputIndex, itemCode, itemName)); @@ -554,7 +549,6 @@ internal static class ThemeQueries WHERE d.SB_MARKET = s.SB_MARKET AND d.SB_CODE = s.SB_CODE) = 1 AND UPPER(s.SB_TITLE) LIKE '%' || :search_term || '%' ESCAPE '!' - ORDER BY UPPER(s.SB_TITLE), s.SB_CODE ) WHERE ROWNUM <= :row_limit """, @@ -609,7 +603,6 @@ internal static class ThemeQueries WHERE d.SB_MARKET = s.SB_MARKET AND BINARY d.SB_CODE = BINARY s.SB_CODE) = 1 AND UPPER(s.SB_TITLE) LIKE CONCAT('%', @search_term, '%') ESCAPE '!' - ORDER BY UPPER(s.SB_TITLE), s.SB_CODE LIMIT @row_limit """, """ diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneDataLoader.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneDataLoader.cs index f1a1ad7..ef3e4b3 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneDataLoader.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneDataLoader.cs @@ -131,9 +131,7 @@ internal static class S8010CandleQueryFactory nameof(request.ForeignNationCode)); if (symbol.Length > 64 || nationCode.Length != 2 || - nationCode.Any(character => character is < 'A' or > 'Z') || - request.MarketTarget == CandleMarketTarget.OverseasStock && - nationCode is not ("US" or "TW")) + nationCode.Any(character => character is < 'A' or > 'Z')) { throw InvalidRequest(); } @@ -226,7 +224,7 @@ internal static class S8010CandleQueryFactory selection!, request.ForeignSymbol!, request.ForeignNationCode!, - ForeignInstrumentScope.UsOrTaiwanStock), + ForeignInstrumentScope.ForeignStock), CandleMarketTarget.KospiStock => Stock( request.ChartMode, isIntraday, @@ -562,9 +560,9 @@ internal static class S8010CandleQueryFactory ForeignInstrumentScope.GlobalIndex => ( "0", "AND a.f_fdtc = '0'"), - ForeignInstrumentScope.UsOrTaiwanStock => ( + ForeignInstrumentScope.ForeignStock => ( "1", - "AND a.f_fdtc = '1' AND a.f_natc IN ('US', 'TW')"), + "AND a.f_fdtc = '1'"), _ => throw InvalidRequest() }; var currentSql = $""" @@ -839,7 +837,7 @@ internal static class S8010CandleQueryFactory private enum ForeignInstrumentScope { GlobalIndex, - UsOrTaiwanStock + ForeignStock } private sealed record QueryPair(DataQuerySpec Current, DataQuerySpec Rows); diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs index 3fb0a9c..8087806 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs @@ -87,7 +87,13 @@ public sealed class LegacyParameterizedSceneRequestResolver if (selection.GroupCode is "해외종목" or "FOREIGN_STOCK") { - return new S5001ForeignStockLoadRequest(subject); + var identity = ParseForeignStockIdentity(selection.DataCode); + return identity is null + ? new S5001ForeignStockLoadRequest(subject) + : new S5001ExactForeignStockLoadRequest( + subject, + identity.Value.Symbol, + identity.Value.NationCode); } if (selection.GroupCode.Contains("업종", StringComparison.Ordinal) || @@ -435,6 +441,30 @@ public sealed class LegacyParameterizedSceneRequestResolver }; } + /// + /// New UC5 rows persist symbol|nation|1. Existing legacy rows did + /// not carry a data code and retain their original name-only resolution. + /// + private static (string Symbol, string NationCode)? ParseForeignStockIdentity(string value) + { + if (string.IsNullOrEmpty(value)) + { + return null; + } + + var parts = value.Split('|', StringSplitOptions.None); + if (parts.Length != 3 || + !LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol(parts[0]) || + parts[1].Length != 2 || + parts[1].Any(character => character is < 'A' or > 'Z') || + !string.Equals(parts[2], "1", StringComparison.Ordinal)) + { + throw new LegacySceneDataException("The foreign-stock identity is invalid."); + } + + return (parts[0], parts[1]); + } + private S8018SessionPhase CurrentSession() { var now = _timeProvider.GetLocalNow(); diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneDataLoaders.cs index ae90e77..03ffa5b 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneDataLoaders.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneDataLoaders.cs @@ -65,6 +65,18 @@ public sealed record S5001ForeignStockLoadRequest(string StockName) : S5001Scene public override S5001Branch Branch => S5001Branch.ForeignStock; } +/// +/// Internal-only exact UC5 identity. The public request contract intentionally +/// exposes no symbol or nation fields to callers outside the native scene path. +/// +internal sealed record S5001ExactForeignStockLoadRequest( + string StockName, + string ForeignSymbol, + string ForeignNationCode) : S5001SceneLoadRequest +{ + public override S5001Branch Branch => S5001Branch.ForeignStock; +} + public sealed record S5001DomesticStockLoadRequest( LegacyDomesticEquityMarket Market, S5001DomesticStockMode Mode, @@ -99,6 +111,7 @@ public sealed class S5001SceneDataLoader S5001DomesticIndustryLoadRequest industry => await LoadDomesticIndustryAsync(industry, cancellationToken).ConfigureAwait(false), S5001ForeignIndustryLoadRequest industry => await LoadForeignIndustryAsync(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), _ => throw new LegacySceneDataException("Unsupported s5001 load request.") }; @@ -230,6 +243,31 @@ public sealed class S5001SceneDataLoader new S5001ForeignStockSceneData(title, direction, price, change, rate)); } + private async Task LoadExactForeignStockAsync( + S5001ExactForeignStockLoadRequest request, + CancellationToken cancellationToken) + { + var name = ParameterizedSceneRowReader.Selector(request.StockName, nameof(request.StockName)); + var symbol = ParameterizedSceneRowReader.Selector( + request.ForeignSymbol, + nameof(request.ForeignSymbol)); + var nationCode = ParameterizedSceneRowReader.Selector( + request.ForeignNationCode, + nameof(request.ForeignNationCode)); + if (!LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol(symbol) || + nationCode.Length != 2 || + nationCode.Any(character => character is < 'A' or > 'Z')) + { + throw new LegacySceneDataException("The foreign-stock identity is invalid."); + } + + var row = await ExecuteSingleAsync( + ParameterizedMarketSceneQueries.ForeignStock(name, symbol, nationCode), + cancellationToken).ConfigureAwait(false); + return DecimalScene(row, static (title, direction, price, change, rate) => + new S5001ForeignStockSceneData(title, direction, price, change, rate)); + } + private async Task LoadDomesticStockAsync( S5001DomesticStockLoadRequest 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 6345fed..b558fa4 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneQueries.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneQueries.cs @@ -63,6 +63,22 @@ internal static class ParameterizedMarketSceneQueries ForeignStockQuery, new DataQueryParameter("stockName", stockName, DbType.String)); + /// + /// Exact UC5 identity route. It deliberately does not inherit the old + /// name-only US/TW scope: symbol and nation are both bound, so a shared + /// input name cannot select a different master row. + /// + public static ParameterizedSceneQuery ForeignStock( + string stockName, + string symbol, + string nationCode) => + Oracle( + "SCENE_FOREIGN_STOCK", + ForeignStockExactQuery, + new DataQueryParameter("stockName", stockName, DbType.String), + new DataQueryParameter("symbol", symbol, DbType.String), + new DataQueryParameter("nationCode", nationCode, DbType.String)); + public static ParameterizedSceneQuery CurrentStock( LegacyDomesticEquityMarket market, string stockName, @@ -508,6 +524,22 @@ internal static class ParameterizedMarketSceneQueries FETCH FIRST 2 ROWS ONLY """; + private const string ForeignStockExactQuery = """ + SELECT b.f_input_name 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_input_name = :stockName + AND b.f_symb = :symbol + AND b.f_natc = :nationCode + AND b.f_fdtc = '1' + AND a.f_fdtc = '1' + FETCH FIRST 2 ROWS ONLY + """; + private const string KospiStockQuery = """ SELECT DISTINCT b.f_stock_wanname TITLE, a.f_curr_price PRICE, diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs index b6ef082..a5210f9 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs @@ -333,17 +333,6 @@ public sealed class LegacyComparisonWorkflowController throw new InvalidOperationException("The saved comparison pair limit was reached."); } - var signature = LegacyComparisonWorkflowCatalog.PairSignature( - _firstTarget, - _secondTarget); - if (_savedPairs.Any(pair => string.Equals( - LegacyComparisonWorkflowCatalog.PairSignature(pair.First, pair.Second), - signature, - StringComparison.Ordinal))) - { - throw new InvalidOperationException("The exact comparison pair is already saved."); - } - var rowId = $"comparison-pair-{++_pairSequence:D8}"; var candidate = new List(_savedPairs) { diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs index 4f16669..358b2d9 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs @@ -132,6 +132,27 @@ public sealed class LegacyExpertWorkflowController } } + public LegacyExpertWorkflowSnapshot RemoveSelectedResultPreservingPreview() + { + lock (_gate) + { + if (_selected is null) + { + return CreateSnapshot(); + } + + var removed = _searchResults.RemoveAll(row => row.Identity == _selected.Identity); + if (removed > 0) + { + // UC6 removed the visible expert row but intentionally retained m_code, + // m_name and the recommendation grid until the operator selected again. + Touch(); + } + + return CreateSnapshot(); + } + } + public async Task SearchAsync( string query, CancellationToken cancellationToken = default) @@ -304,14 +325,14 @@ public sealed class LegacyExpertWorkflowController } var itemCount = _previewItems.Count; - if (itemCount is <= 0 or > MaximumAccessibleItems) + if (itemCount > MaximumAccessibleItems) { throw new InvalidOperationException( - "An expert cut requires one through 100 accessible recommendation rows."); + "An expert cut supports zero through 100 accessible recommendation rows."); } var pageCount = checked((itemCount + PageSize - 1) / PageSize); - if (pageCount is <= 0 or > MaximumPageCount) + if (pageCount > MaximumPageCount) { throw new InvalidOperationException("The expert PageN plan exceeds 20 pages."); } @@ -361,7 +382,7 @@ public sealed class LegacyExpertWorkflowController : (previewRows.Length + PageSize - 1) / PageSize; var actionAvailable = _selected is not null && _previewStatus == LegacyExpertRequestStatus.Ready && - previewRows.Length is > 0 and <= MaximumAccessibleItems && + previewRows.Length <= MaximumAccessibleItems && pageCount <= MaximumPageCount; return new LegacyExpertWorkflowSnapshot( _revision, @@ -405,7 +426,6 @@ public sealed class LegacyExpertWorkflowController var states = new List(result.Items.Count); var codes = new HashSet(StringComparer.Ordinal); - var names = new HashSet(StringComparer.Ordinal); ExpertSelectionIdentity? previous = null; for (var index = 0; index < result.Items.Count; index++) { @@ -417,11 +437,11 @@ public sealed class LegacyExpertWorkflowController } ValidateExpertIdentity(item.Identity); - if (!codes.Add(item.Identity.ExpertCode) || !names.Add(item.Identity.ExpertName) || + if (!codes.Add(item.Identity.ExpertCode) || previous is not null && CompareIdentities(previous, item.Identity) > 0) { throw new ExpertSelectionDataException( - "Expert identities must be unique and sorted by name then code."); + "Expert codes must be unique and identities sorted by name then code."); } states.Add(new ExpertState( @@ -448,8 +468,6 @@ public sealed class LegacyExpertWorkflowController var items = new List(result.Items.Count); var indexes = new HashSet(); - var codes = new HashSet(StringComparer.Ordinal); - var names = new HashSet(StringComparer.Ordinal); var previousIndex = -1; foreach (var item in result.Items) { @@ -459,9 +477,7 @@ public sealed class LegacyExpertWorkflowController !IsCanonicalText(item.StockName, 200) || item.BuyAmount <= 0 || item.BuyAmount > 9_007_199_254_740_991m || decimal.Truncate(item.BuyAmount) != item.BuyAmount || - !indexes.Add(item.PlayIndex) || - !codes.Add(item.StockCode) || - !names.Add(item.StockName)) + !indexes.Add(item.PlayIndex)) { throw new ExpertSelectionDataException( "The expert preview contains an invalid or duplicate recommendation."); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs index b5f9517..4cbbe61 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs @@ -3,6 +3,12 @@ using MMoneyCoderSharp.Data; namespace MBN_STOCK_WEBVIEW.LegacyApplication; +public enum LegacyIndustryComparisonSlot +{ + First, + Second +} + public sealed record LegacyIndustrySelectionSnapshot( long Revision, IndustryMarket? Market, @@ -11,7 +17,9 @@ public sealed record LegacyIndustrySelectionSnapshot( int? SelectedIndex, IndustrySelection? SelectedIndustry, IndustrySelection? FirstIndustry, - IndustrySelection? SecondIndustry); + IndustrySelection? SecondIndustry, + string FirstText, + string SecondText); public sealed record LegacyIndustrySceneSelectionDraft( string GroupCode, @@ -52,6 +60,7 @@ public sealed class LegacyIndustryWorkflowException : Exception public sealed class LegacyIndustrySelectionWorkflow { private const int DefaultFadeDuration = 6; + public const int MaximumComparisonTextLength = 128; private static readonly IReadOnlyList NoIndustries = Array.AsReadOnly(Array.Empty()); @@ -68,6 +77,8 @@ public sealed class LegacyIndustrySelectionWorkflow private IndustrySelection? _selectedIndustry; private IndustrySelection? _firstIndustry; private IndustrySelection? _secondIndustry; + private string _firstText = string.Empty; + private string _secondText = string.Empty; private long _revision; private long _loadGeneration; @@ -98,6 +109,8 @@ public sealed class LegacyIndustrySelectionWorkflow _selectedIndustry = null; _firstIndustry = null; _secondIndustry = null; + _firstText = string.Empty; + _secondText = string.Empty; Touch(); return CreateSnapshot(); } @@ -128,6 +141,8 @@ public sealed class LegacyIndustrySelectionWorkflow var previousSelected = changingMarket ? null : _selectedIndustry; var previousFirst = changingMarket ? null : _firstIndustry; var previousSecond = changingMarket ? null : _secondIndustry; + var previousFirstText = changingMarket ? string.Empty : _firstText; + var previousSecondText = changingMarket ? string.Empty : _secondText; _market = market; _industries = normalizedRows; @@ -138,6 +153,8 @@ public sealed class LegacyIndustrySelectionWorkflow : FindIdentityIndex(normalizedRows, _selectedIndustry); _firstIndustry = Reconcile(previousFirst, normalizedRows); _secondIndustry = Reconcile(previousSecond, normalizedRows); + _firstText = previousFirstText; + _secondText = previousSecondText; Touch(); return CreateSnapshot(); } @@ -168,26 +185,71 @@ public sealed class LegacyIndustrySelectionWorkflow // UC2.listbox_DoubleClick copied txt1 to txt2 before assigning the newly // double-clicked item to txt1. _secondIndustry = _firstIndustry; + _secondText = _firstText; _firstIndustry = industry; + _firstText = industry.IndustryName; + Touch(); + return CreateSnapshot(); + } + + public LegacyIndustrySelectionSnapshot SetComparisonText( + LegacyIndustryComparisonSlot slot, + string text) + { + ArgumentNullException.ThrowIfNull(text); + if (slot is not (LegacyIndustryComparisonSlot.First or LegacyIndustryComparisonSlot.Second)) + { + throw new ArgumentOutOfRangeException(nameof(slot), slot, "The comparison slot must be 1 or 2."); + } + + if (text.Length > MaximumComparisonTextLength || + text.Any(static character => + char.IsControl(character) || char.IsSurrogate(character) || character == '\uFFFD')) + { + throw new LegacyIndustryWorkflowException("The industry comparison text is invalid."); + } + + if (slot == LegacyIndustryComparisonSlot.First) + { + if (string.Equals(_firstText, text, StringComparison.Ordinal)) + { + return CreateSnapshot(); + } + + _firstText = text; + _firstIndustry = null; + } + else + { + if (string.Equals(_secondText, text, StringComparison.Ordinal)) + { + return CreateSnapshot(); + } + + _secondText = text; + _secondIndustry = null; + } + Touch(); return CreateSnapshot(); } public LegacyIndustrySelectionSnapshot SwapIndustries() { - if (_firstIndustry is null && _secondIndustry is null) + if (_firstText.Length == 0 && _secondText.Length == 0) { return CreateSnapshot(); } (_firstIndustry, _secondIndustry) = (_secondIndustry, _firstIndustry); + (_firstText, _secondText) = (_secondText, _firstText); Touch(); return CreateSnapshot(); } public LegacyIndustrySelectionSnapshot ClearSelections() { - if (_selectedIndustry is null && _firstIndustry is null && _secondIndustry is null) + if (_selectedIndustry is null && _firstText.Length == 0 && _secondText.Length == 0) { return CreateSnapshot(); } @@ -196,6 +258,8 @@ public sealed class LegacyIndustrySelectionWorkflow _selectedIndustry = null; _firstIndustry = null; _secondIndustry = null; + _firstText = string.Empty; + _secondText = string.Empty; Touch(); return CreateSnapshot(); } @@ -315,15 +379,17 @@ public sealed class LegacyIndustrySelectionWorkflow LegacyIndustryActionDefinition definition, string groupCode) { - if (_firstIndustry is not { } first || _secondIndustry is not { } second || - first.IndustryName.Contains('-', StringComparison.Ordinal) || - second.IndustryName.Contains('-', StringComparison.Ordinal)) + if (_selectedIndustry is null || + string.IsNullOrWhiteSpace(_firstText) || + string.IsNullOrWhiteSpace(_secondText) || + _firstText.Contains('-', StringComparison.Ordinal) || + _secondText.Contains('-', StringComparison.Ordinal)) { throw new LegacyIndustryWorkflowException( - $"The {definition.ActionId} action requires two delimiter-safe industries."); + $"The {definition.ActionId} action requires a selected industry and two delimiter-safe industries."); } - var subject = $"{first.IndustryName}-{second.IndustryName}"; + var subject = $"{_firstText}-{_secondText}"; return new IndustryProjection( subject, new LegacyIndustrySceneSelectionDraft( @@ -361,8 +427,6 @@ public sealed class LegacyIndustrySelectionWorkflow throw new LegacyIndustryWorkflowException("The industry list is invalid."); } - var names = new HashSet(StringComparer.Ordinal); - var codes = new HashSet(StringComparer.Ordinal); var normalized = new IndustrySelection[rows.Count]; for (var index = 0; index < rows.Count; index++) { @@ -380,12 +444,10 @@ public sealed class LegacyIndustrySelectionWorkflow name.Any(char.IsControl) || string.IsNullOrEmpty(code) || code.Length > 32 || - code.Any(character => !char.IsAsciiLetterOrDigit(character)) || - !names.Add(name) || - !codes.Add(code)) + code.Any(character => !char.IsAsciiLetterOrDigit(character))) { throw new LegacyIndustryWorkflowException( - "The industry list contains an unsafe or ambiguous row."); + "The industry list contains an unsafe row."); } normalized[index] = new IndustrySelection(market, name, code); @@ -452,7 +514,9 @@ public sealed class LegacyIndustrySelectionWorkflow _selectedIndex, _selectedIndustry, _firstIndustry, - _secondIndustry); + _secondIndustry, + _firstText, + _secondText); private void Touch() => _revision++; diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs index 432f876..2000543 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs @@ -66,7 +66,10 @@ public sealed record LegacyCutViewRow( bool IsSelected, bool IsFocused = false); -public sealed record LegacyDialogState(string Message); +public sealed record LegacyDialogState( + string Message, + string Caption = "MmoneyCoder", + bool IsConfirmation = false); public sealed record LegacyOperatorCommandResult( long Sequence, @@ -196,6 +199,8 @@ public sealed class LegacyOperatorController private string _statusMessage = string.Empty; private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral; private LegacyDialogState? _dialog; + private PendingNativeConfirmation? _pendingNativeConfirmation; + private bool _closeOperatorCatalogAfterDialog; private LegacyOperatorTabId _activeTab = LegacyOperatorTabId.Overseas; private LegacyMovingAverageSelection _movingAverages = new(true, true); private LegacyThemeWorkflowSnapshot? _themeSnapshot; @@ -231,7 +236,7 @@ public sealed class LegacyOperatorController _timeProvider = timeProvider ?? TimeProvider.System; _industryWorkflow = industryWorkflow; _themeWorkflow = themeWorkflow; - _themeSnapshot = themeWorkflow?.CreateInitial(); + _themeSnapshot = themeWorkflow?.CreateInitial(CurrentThemeNxtSession()); _expertWorkflow = expertWorkflow; _tradingHaltWorkflow = tradingHaltWorkflow; _comparisonWorkflow = comparisonWorkflow; @@ -640,7 +645,7 @@ public sealed class LegacyOperatorController var loaded = await _themeWorkflow.SearchAsync( _themeSnapshot, string.Empty, - MMoneyCoderSharp.Data.ThemeSession.PreMarket, + CurrentThemeNxtSession(), cancellationToken).ConfigureAwait(false); if (!IsCurrentTabActivation(tabId, activationRequest)) { @@ -791,12 +796,6 @@ public sealed class LegacyOperatorController await _operatorCatalogWorkflow.OpenAsync( LegacyOperatorCatalogEntity.Theme, cancellationToken).ConfigureAwait(false); - if (_operatorCatalogWorkflow.Current.CanBeginCreate) - { - await _operatorCatalogWorkflow.BeginCreateThemeAsync( - MMoneyCoderSharp.Data.ThemeMarket.Krx, - cancellationToken).ConfigureAwait(false); - } ApplyOperatorCatalogStatus(); Touch(); @@ -824,18 +823,32 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } - public async Task DeleteSelectedThemeCatalogAsync( + public Task DeleteSelectedThemeCatalogAsync( CancellationToken cancellationToken = default) { - await EditSelectedThemeCatalogAsync(cancellationToken).ConfigureAwait(false); - if (_operatorCatalogWorkflow?.Current.CanDelete == true) + cancellationToken.ThrowIfCancellationRequested(); + if (_operatorCatalogWorkflow is null) { - await _operatorCatalogWorkflow.DeleteAsync(cancellationToken).ConfigureAwait(false); - ApplyOperatorCatalogStatus(); - Touch(); + return Task.FromResult(ReportError("ThemeA 관리 기능이 구성되지 않았습니다.")); } - return CreateSnapshot(); + var identity = _themeSnapshot?.SelectedIdentity; + if (identity is null) + { + return Task.FromResult(ReportError("삭제할 테마를 먼저 선택하세요.")); + } + + if (_dialog is not null) + { + return Task.FromResult(CreateSnapshot()); + } + + _pendingNativeConfirmation = PendingNativeConfirmation.ForTheme(identity); + _dialog = new LegacyDialogState( + "선택한 테마를 삭제하겠습니까?", + IsConfirmation: true); + Touch(); + return Task.FromResult(CreateSnapshot()); } public async Task BeginExpertCatalogFromUc6Async( @@ -884,18 +897,35 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } - public async Task DeleteSelectedExpertCatalogAsync( + public Task DeleteSelectedExpertCatalogAsync( CancellationToken cancellationToken = default) { - await EditSelectedExpertCatalogAsync(cancellationToken).ConfigureAwait(false); - if (_operatorCatalogWorkflow?.Current.CanDelete == true) + cancellationToken.ThrowIfCancellationRequested(); + if (_operatorCatalogWorkflow is null) { - await _operatorCatalogWorkflow.DeleteAsync(cancellationToken).ConfigureAwait(false); - ApplyOperatorCatalogStatus(); - Touch(); + return Task.FromResult(ReportError("EList 관리 기능이 구성되지 않았습니다.")); } - return CreateSnapshot(); + var selected = _expertWorkflow?.Current.SelectedExpert; + if (selected is null) + { + return Task.FromResult(ReportError("삭제할 전문가를 먼저 선택하세요.")); + } + + if (_dialog is not null) + { + return Task.FromResult(CreateSnapshot()); + } + + _pendingNativeConfirmation = PendingNativeConfirmation.ForExpert( + new MMoneyCoderSharp.Data.ExpertSelectionIdentity( + selected.ExpertCode, + selected.ExpertName)); + _dialog = new LegacyDialogState( + "선택한 전문가를 삭제하겠습니까?", + IsConfirmation: true); + Touch(); + return Task.FromResult(CreateSnapshot()); } public LegacyOperatorSnapshot CloseOperatorCatalog() @@ -910,6 +940,43 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } + public async Task CloseOperatorCatalogAsync( + CancellationToken cancellationToken = default) + { + if (_operatorCatalogWorkflow is null) + { + return CreateSnapshot(); + } + + var closing = _operatorCatalogWorkflow.Current; + _operatorCatalogWorkflow.Close(); + _statusMessage = string.Empty; + _statusKind = LegacyOperatorStatusKind.Neutral; + if (closing.IsOpen && + closing.Entity == LegacyOperatorCatalogEntity.Expert && + closing.Mode == LegacyOperatorCatalogMode.Create && + _expertWorkflow is not null) + { + try + { + await _expertWorkflow.SearchAsync(string.Empty, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + _statusMessage = "전문가 목록을 새로 읽지 못했습니다. 다시 조회하세요."; + _statusKind = LegacyOperatorStatusKind.Warning; + } + } + + Touch(); + return CreateSnapshot(); + } + public async Task SearchOperatorCatalogAsync( string query, MMoneyCoderSharp.Data.ThemeSession nxtSession, @@ -1058,7 +1125,7 @@ public sealed class LegacyOperatorController public LegacyOperatorSnapshot SetOperatorCatalogDraftBuyAmount( string rowId, - decimal buyAmount) + decimal? buyAmount) { if (_operatorCatalogWorkflow is null) { @@ -1107,6 +1174,61 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } + public LegacyOperatorSnapshot SelectOperatorCatalogDraftRow(string rowId) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다."); + } + + _operatorCatalogWorkflow.SelectDraftRow(rowId); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot RemoveActiveOperatorCatalogDraftRow() + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다."); + } + + _operatorCatalogWorkflow.RemoveActiveDraftRow(); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot ClearOperatorCatalogDraftRows() + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA 관리 기능이 구성되지 않았습니다."); + } + + var catalog = _operatorCatalogWorkflow.Current; + if (!catalog.IsOpen || catalog.Entity != LegacyOperatorCatalogEntity.Theme || + catalog.Mode is not (LegacyOperatorCatalogMode.Create or + LegacyOperatorCatalogMode.Edit)) + { + return ReportError("ThemeA 편집 목록이 열려 있지 않습니다."); + } + + if (catalog.DraftRows.Count == 0 || _dialog is not null) + { + return CreateSnapshot(); + } + + _pendingNativeConfirmation = PendingNativeConfirmation.ForThemeDraftClear( + catalog.Revision); + _dialog = new LegacyDialogState( + "모두 삭제하겠습니까?", + IsConfirmation: true); + Touch(); + return CreateSnapshot(); + } + public async Task CheckOperatorCatalogThemeNameAsync( string editorName, CancellationToken cancellationToken = default) @@ -1120,6 +1242,12 @@ public sealed class LegacyOperatorController editorName, cancellationToken).ConfigureAwait(false); ApplyOperatorCatalogStatus(); + var availability = _operatorCatalogWorkflow.Current.ThemeNameAvailability; + if (availability is LegacyThemeNameAvailability.Available or + LegacyThemeNameAvailability.Duplicate) + { + _dialog = new LegacyDialogState(_operatorCatalogWorkflow.Current.StatusMessage); + } Touch(); return CreateSnapshot(); } @@ -1133,23 +1261,95 @@ public sealed class LegacyOperatorController return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다."); } + var before = _operatorCatalogWorkflow.Current; + var selectedExpertId = before.Entity == LegacyOperatorCatalogEntity.Expert && + before.Mode == LegacyOperatorCatalogMode.Edit + ? _expertWorkflow?.Current.SelectedExpert?.SelectionId + : null; + if (before.Entity == LegacyOperatorCatalogEntity.Expert && + before.Mode == LegacyOperatorCatalogMode.Create && + string.IsNullOrWhiteSpace(editorName)) + { + _dialog = new LegacyDialogState("이름을 입력하세요"); + Touch(); + return CreateSnapshot(); + } + await _operatorCatalogWorkflow.SaveAsync(editorName, cancellationToken) .ConfigureAwait(false); ApplyOperatorCatalogStatus(); - Touch(); - return CreateSnapshot(); - } + var after = _operatorCatalogWorkflow.Current; - public async Task DeleteOperatorCatalogAsync( - CancellationToken cancellationToken = default) - { - if (_operatorCatalogWorkflow is null) + if (before.Entity == LegacyOperatorCatalogEntity.Theme) { - return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다."); + if (after.Mode == LegacyOperatorCatalogMode.List) + { + _operatorCatalogWorkflow.Close(); + _statusMessage = string.Empty; + _statusKind = LegacyOperatorStatusKind.Neutral; + } + else if (string.Equals( + after.StatusMessage, + LegacyOperatorCatalogWorkflowController.ThemeNameDuplicateMessage, + StringComparison.Ordinal)) + { + _dialog = new LegacyDialogState( + LegacyOperatorCatalogWorkflowController.ThemeNameDuplicateMessage); + } } + else if (after.Mode == LegacyOperatorCatalogMode.List) + { + var refreshFailed = false; + if (before.Mode == LegacyOperatorCatalogMode.Create) + { + _operatorCatalogWorkflow.Close(); + try + { + if (_expertWorkflow is not null) + { + await _expertWorkflow.SearchAsync(string.Empty, cancellationToken) + .ConfigureAwait(false); + } + } + catch + { + // The mutation receipt is already known-committed. A UC6 list refresh + // failure must not be reclassified as an unknown DB-write outcome. + _statusMessage = "전문가 목록을 새로 읽지 못했습니다. 다시 조회하세요."; + _statusKind = LegacyOperatorStatusKind.Warning; + refreshFailed = true; + } + } + else if (before.Mode == LegacyOperatorCatalogMode.Edit) + { + try + { + if (_expertWorkflow is not null && selectedExpertId is not null) + { + await _expertWorkflow.SelectExpertAsync( + selectedExpertId, + cancellationToken) + .ConfigureAwait(false); + } + } + catch + { + // The expert transaction is known-committed. Preview refresh failure + // is a read failure and must never turn into an unknown write outcome. + refreshFailed = true; + _statusMessage = "저장된 추천 종목을 다시 읽지 못했습니다. 전문가를 다시 선택하세요."; + _statusKind = LegacyOperatorStatusKind.Warning; + } + _dialog = new LegacyDialogState("저장되었습니다"); + _closeOperatorCatalogAfterDialog = true; + } - await _operatorCatalogWorkflow.DeleteAsync(cancellationToken).ConfigureAwait(false); - ApplyOperatorCatalogStatus(); + if (!refreshFailed) + { + _statusMessage = string.Empty; + _statusKind = LegacyOperatorStatusKind.Neutral; + } + } Touch(); return CreateSnapshot(); } @@ -2085,6 +2285,32 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } + public LegacyOperatorSnapshot SetIndustryComparisonText( + LegacyIndustryComparisonSlot slot, + string text) + { + ArgumentNullException.ThrowIfNull(text); + if (!IsActiveIndustryReady()) + { + return ReportError("업종 목록을 먼저 불러오세요."); + } + + try + { + _industryWorkflow!.SetComparisonText(slot, text); + Touch(); + return CreateSnapshot(); + } + catch (ArgumentOutOfRangeException) + { + return ReportError("등록되지 않은 업종 비교 입력칸입니다."); + } + catch (LegacyIndustryWorkflowException exception) + { + return ReportError(exception.Message); + } + } + public LegacyOperatorSnapshot ActivateIndustryAction(string actionId) { ArgumentNullException.ThrowIfNull(actionId); @@ -2117,46 +2343,13 @@ public sealed class LegacyOperatorController return ReportError("업종 목록을 먼저 불러오세요."); } - var snapshot = _industryWorkflow!.Current; - var sections = snapshot.Actions - .Select(static action => action.Section) - .Distinct(StringComparer.Ordinal) - .ToArray(); - if (sectionIndex < 0 || sectionIndex >= sections.Length) - { - return ReportError("등록되지 않은 업종 컷 섹션입니다."); - } - - try - { - var section = sections[sectionIndex]; - // Materialize every direct child before mutating the playlist. This keeps - // the original parent double-click all-or-nothing when one child lacks a - // required industry selection. - var drafts = snapshot.Actions - .Where(action => string.Equals( - action.Section, - section, - StringComparison.Ordinal)) - .Select(action => _industryWorkflow.MaterializePlaylistDraft(action.ActionId)) - .ToArray(); - foreach (var draft in drafts) - { - AppendIndustryRow(draft, snapshot.Industries.Count); - } - - _statusMessage = $"{section} 항목 {drafts.Length}개를 추가했습니다."; - _statusKind = LegacyOperatorStatusKind.Information; - Touch(); - return CreateSnapshot(); - } - catch (LegacyIndustryWorkflowException exception) - { - _dialog = new LegacyDialogState( - $"섹션의 모든 항목을 검증하지 못해 아무것도 추가하지 않았습니다. {exception.Message}"); - Touch(); - return CreateSnapshot(); - } + // MainForm constructs both industry UC1 instances with UC3 == null. The + // original root double-click path dereferences UC3 before its 22-child loop, + // then its outer catch silently consumes the exception. Preserve that + // observable no-op; only child double-clicks add industry playlist rows. + return sectionIndex == 0 + ? CreateSnapshot() + : ReportError("등록되지 않은 업종 컷 섹션입니다."); } public LegacyOperatorSnapshot SelectOverseasFixedIndex(string targetId) @@ -2484,8 +2677,17 @@ public sealed class LegacyOperatorController if (all) { - await _comparisonWorkflow.DeleteAllPairsAsync(cancellationToken) - .ConfigureAwait(false); + if (_dialog is not null) + { + return CreateSnapshot(); + } + + _pendingNativeConfirmation = PendingNativeConfirmation.ForComparisonDeleteAll( + _comparisonWorkflow.Current.Revision); + _dialog = new LegacyDialogState( + "모두 삭제하겠습니까?", + "MmoneyCoder", + IsConfirmation: true); } else { @@ -2598,10 +2800,23 @@ public sealed class LegacyOperatorController } } - public async Task SearchThemesAsync( + public Task SearchThemesAsync( + string query, + CancellationToken cancellationToken = default) => + SearchThemesCoreAsync(query, CurrentThemeNxtSession(), cancellationToken); + + // Compatibility overload for native callers. UC4 does not allow a WebView + // payload to select the NXT session; TimeProvider remains authoritative. + public Task SearchThemesAsync( + string query, + MMoneyCoderSharp.Data.ThemeSession ignoredNxtSession, + CancellationToken cancellationToken = default) => + SearchThemesAsync(query, cancellationToken); + + private async Task SearchThemesCoreAsync( string query, MMoneyCoderSharp.Data.ThemeSession nxtSession, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null) @@ -3422,11 +3637,63 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } + public async Task ConfirmDialogAsync( + CancellationToken cancellationToken = default) + { + var pending = _pendingNativeConfirmation; + if (_dialog?.IsConfirmation != true || pending is null) + { + return CreateSnapshot(); + } + + // Consume the confirmation before crossing a database-write boundary. A + // timeout or unknown outcome must never leave a replayable Yes action. + _pendingNativeConfirmation = null; + _dialog = null; + + return pending.Kind switch + { + PendingNativeConfirmationKind.DeleteTheme => + await ExecuteConfirmedThemeDeleteAsync( + pending.ThemeIdentity!, + cancellationToken).ConfigureAwait(false), + PendingNativeConfirmationKind.DeleteExpert => + await ExecuteConfirmedExpertDeleteAsync( + pending.ExpertIdentity!, + cancellationToken).ConfigureAwait(false), + PendingNativeConfirmationKind.ClearThemeDraft => + ExecuteConfirmedThemeDraftClear(pending.CatalogRevision), + PendingNativeConfirmationKind.DeleteAllComparisonPairs => + await ExecuteConfirmedComparisonDeleteAllAsync( + pending.ComparisonRevision, + cancellationToken) + .ConfigureAwait(false), + _ => throw new InvalidOperationException("Unknown native confirmation kind.") + }; + } + + public LegacyOperatorSnapshot CancelDialog() + { + if (_dialog?.IsConfirmation == true) + { + _pendingNativeConfirmation = null; + _dialog = null; + Touch(); + } + + return CreateSnapshot(); + } + public LegacyOperatorSnapshot DismissDialog() { - if (_dialog is not null) + if (_dialog is not null && !_dialog.IsConfirmation) { _dialog = null; + if (_closeOperatorCatalogAfterDialog) + { + _operatorCatalogWorkflow?.Close(); + _closeOperatorCatalogAfterDialog = false; + } ContinuePendingDrop(); Touch(); } @@ -3434,6 +3701,121 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } + private async Task ExecuteConfirmedThemeDeleteAsync( + MMoneyCoderSharp.Data.ThemeSelectionIdentity identity, + CancellationToken cancellationToken) + { + if (_operatorCatalogWorkflow is null || _themeWorkflow is null || + _themeSnapshot?.SelectedIdentity != identity) + { + return ReportError("삭제 대상 테마가 변경되어 DB 삭제를 시작하지 않았습니다."); + } + + await _operatorCatalogWorkflow.OpenThemeForEditAsync(identity, cancellationToken) + .ConfigureAwait(false); + if (!_operatorCatalogWorkflow.Current.CanDelete) + { + ApplyOperatorCatalogStatus(); + _operatorCatalogWorkflow.Close(); + Touch(); + return CreateSnapshot(); + } + + await _operatorCatalogWorkflow.DeleteAsync(cancellationToken).ConfigureAwait(false); + if (_operatorCatalogWorkflow.Current.Mode == LegacyOperatorCatalogMode.List) + { + _themeSnapshot = _themeWorkflow.RemoveSelectedResult(_themeSnapshot); + _operatorCatalogWorkflow.Close(); + _statusMessage = string.Empty; + _statusKind = LegacyOperatorStatusKind.Neutral; + } + else + { + ApplyOperatorCatalogStatus(); + } + + Touch(); + return CreateSnapshot(); + } + + private async Task ExecuteConfirmedExpertDeleteAsync( + MMoneyCoderSharp.Data.ExpertSelectionIdentity identity, + CancellationToken cancellationToken) + { + var selected = _expertWorkflow?.Current.SelectedExpert; + if (_operatorCatalogWorkflow is null || selected is null || + !string.Equals(selected.ExpertCode, identity.ExpertCode, StringComparison.Ordinal) || + !string.Equals(selected.ExpertName, identity.ExpertName, StringComparison.Ordinal)) + { + return ReportError("삭제 대상 전문가가 변경되어 DB 삭제를 시작하지 않았습니다."); + } + + await _operatorCatalogWorkflow.OpenExpertForEditAsync(identity, cancellationToken) + .ConfigureAwait(false); + if (!_operatorCatalogWorkflow.Current.CanDelete) + { + ApplyOperatorCatalogStatus(); + _operatorCatalogWorkflow.Close(); + Touch(); + return CreateSnapshot(); + } + + await _operatorCatalogWorkflow.DeleteAsync(cancellationToken).ConfigureAwait(false); + if (_operatorCatalogWorkflow.Current.Mode == LegacyOperatorCatalogMode.List) + { + _expertWorkflow?.RemoveSelectedResultPreservingPreview(); + _operatorCatalogWorkflow.Close(); + _statusMessage = string.Empty; + _statusKind = LegacyOperatorStatusKind.Neutral; + } + else + { + ApplyOperatorCatalogStatus(); + } + + Touch(); + return CreateSnapshot(); + } + + private LegacyOperatorSnapshot ExecuteConfirmedThemeDraftClear(long catalogRevision) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA 관리 기능이 구성되지 않았습니다."); + } + + var catalog = _operatorCatalogWorkflow.Current; + if (catalog.Revision != catalogRevision || !catalog.IsOpen || + catalog.Entity != LegacyOperatorCatalogEntity.Theme || + catalog.Mode is not (LegacyOperatorCatalogMode.Create or + LegacyOperatorCatalogMode.Edit)) + { + return ReportError("ThemeA 편집 목록이 변경되어 전체 삭제를 실행하지 않았습니다."); + } + + _operatorCatalogWorkflow.ClearThemeDraftRows(); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + private async Task ExecuteConfirmedComparisonDeleteAllAsync( + long comparisonRevision, + CancellationToken cancellationToken) + { + if (!IsComparisonActive() || _comparisonWorkflow is null || + _comparisonWorkflow.Current.Revision != comparisonRevision) + { + return ReportError( + "삭제 대상 종목 비교 목록이 변경되어 전체 삭제를 실행하지 않았습니다."); + } + + await _comparisonWorkflow.DeleteAllPairsAsync(cancellationToken) + .ConfigureAwait(false); + Touch(); + return CreateSnapshot(); + } + public LegacyOperatorSnapshot ReportError(string message) { _statusMessage = message ?? string.Empty; @@ -4055,6 +4437,11 @@ public sealed class LegacyOperatorController private bool IsThemeActive() => _activeTab == LegacyOperatorTabId.Theme; + private MMoneyCoderSharp.Data.ThemeSession CurrentThemeNxtSession() => + _timeProvider.GetLocalNow().Hour <= 12 + ? MMoneyCoderSharp.Data.ThemeSession.PreMarket + : MMoneyCoderSharp.Data.ThemeSession.AfterMarket; + private void BeginTabActivation(LegacyOperatorTabId tabId) { if (tabId == LegacyOperatorTabId.KospiIndustry) @@ -4077,6 +4464,12 @@ public sealed class LegacyOperatorController else if (tabId == LegacyOperatorTabId.Theme) { _themeActivationListLoaded = false; + if (_themeWorkflow is not null && _themeSnapshot is not null) + { + _themeSnapshot = _themeWorkflow.ResetForTabActivation( + _themeSnapshot, + CurrentThemeNxtSession()); + } } else if (tabId == LegacyOperatorTabId.OverseasStocks) { @@ -4382,15 +4775,14 @@ public sealed class LegacyOperatorController private void AppendOverseasRow(LegacyOverseasPlaylistDraft draft) { - var isCandle = string.Equals(draft.BuilderKey, "s8010", StringComparison.Ordinal); AppendPlaylistRow(new LegacyOperatorPlaylistRow( $"legacy-overseas-{++_playlistSequence:D8}", draft.IsEnabled, - draft.Selection.GroupCode, - draft.Title, - isCandle ? "캔들그래프" : draft.Selection.GraphicType, - draft.Selection.Subtype, - draft.InitialPageText, + draft.VisibleFields.Market, + draft.VisibleFields.StockName, + draft.VisibleFields.GraphicType, + draft.VisibleFields.Subtype, + draft.VisibleFields.PageText, draft.Selection.DataCode, draft.Detail, draft.FadeDuration, @@ -4398,23 +4790,20 @@ public sealed class LegacyOperatorController PlayoutSelection: new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection( draft.Selection.GroupCode, draft.Selection.Subject, - isCandle ? string.Empty : draft.Selection.GraphicType, + draft.Selection.GraphicType, draft.Selection.Subtype, draft.Selection.DataCode))); } private void AppendThemeRow(LegacyThemePlaylistDraft draft) { - var separator = draft.Detail.IndexOf(" · ", StringComparison.Ordinal); - var graphicType = separator > 0 ? draft.Detail[..separator] : draft.Detail; - var subtype = separator > 0 ? draft.Detail[(separator + 3)..] : string.Empty; AppendPlaylistRow(new LegacyOperatorPlaylistRow( $"legacy-theme-{++_playlistSequence:D8}", true, draft.Selection.GroupCode == "PAGED_THEME" ? "테마" : "테마_NXT", draft.Title, - graphicType, - subtype, + draft.PlaylistGraphicType, + draft.PlaylistSubtype, draft.PagePlan.InitialPageText, draft.Selection.DataCode, draft.Detail, @@ -4881,5 +5270,55 @@ public sealed class LegacyOperatorController } } + private enum PendingNativeConfirmationKind + { + DeleteTheme, + DeleteExpert, + ClearThemeDraft, + DeleteAllComparisonPairs + } + + private sealed record PendingNativeConfirmation( + PendingNativeConfirmationKind Kind, + MMoneyCoderSharp.Data.ThemeSelectionIdentity? ThemeIdentity, + MMoneyCoderSharp.Data.ExpertSelectionIdentity? ExpertIdentity, + long CatalogRevision, + long ComparisonRevision) + { + internal static PendingNativeConfirmation ForTheme( + MMoneyCoderSharp.Data.ThemeSelectionIdentity identity) => + new( + PendingNativeConfirmationKind.DeleteTheme, + identity, + null, + 0, + 0); + + internal static PendingNativeConfirmation ForExpert( + MMoneyCoderSharp.Data.ExpertSelectionIdentity identity) => + new( + PendingNativeConfirmationKind.DeleteExpert, + null, + identity, + 0, + 0); + + internal static PendingNativeConfirmation ForThemeDraftClear(long revision) => + new( + PendingNativeConfirmationKind.ClearThemeDraft, + null, + null, + revision, + 0); + + internal static PendingNativeConfirmation ForComparisonDeleteAll(long revision) => + new( + PendingNativeConfirmationKind.DeleteAllComparisonPairs, + null, + null, + 0, + revision); + } + private void Touch() => _revision++; } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs index 7bfc397..65dc82a 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs @@ -56,4 +56,10 @@ public sealed record LegacyOperatorPlayoutSnapshot( string Message) { public bool IsTakeOutCompletionPending { get; init; } + + public DateTimeOffset? RefreshNextAtUtc { get; init; } + + public DateTimeOffset? RefreshLastSuccessAtUtc { get; init; } + + public string? RefreshFaultCode { get; init; } } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs index 99c3e7e..c41b952 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs @@ -181,6 +181,16 @@ public sealed record LegacyOverseasPlaylistSelection( string Subtype, string DataCode); +/// Exactly the five visible legacy playlist columns for a UC5 row. +public sealed record LegacyOverseasPlaylistVisibleFields( + string Market, + string StockName, + string GraphicType, + string Subtype, + string PageText); + +public sealed record LegacyOverseasActionAvailability(bool IsAvailable, string? Reason); + public sealed record LegacyOverseasPlaylistDraft( string ActionId, string BuilderKey, @@ -194,6 +204,7 @@ public sealed record LegacyOverseasPlaylistDraft( string Market, LegacyOverseasActionSource Source, LegacyOverseasPlaylistSelection Selection, + LegacyOverseasPlaylistVisibleFields VisibleFields, LegacyOverseasMovingAverageSelection MovingAverages, bool IsEnabled, int FadeDuration); @@ -213,14 +224,16 @@ public sealed class LegacyOverseasWorkflowException : Exception /// public sealed class LegacyOverseasSelectionWorkflow { - public const int DefaultMaximumResults = 100; + // 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 MaximumQueryLength = 64; private const int DefaultFadeDuration = 6; private const string InitialPageText = "1/1"; private const string IndustryError = "미국 해외업종 검색 결과를 안전하게 확인하지 못했습니다."; - private const string StockError = "미국·대만 해외종목 검색 결과를 안전하게 확인하지 못했습니다."; + private const string StockError = "해외종목 검색 결과를 안전하게 확인하지 못했습니다."; private static readonly IReadOnlyList NoIndustryResults = Array.AsReadOnly(Array.Empty()); @@ -229,10 +242,14 @@ public sealed class LegacyOverseasSelectionWorkflow Array.AsReadOnly(Array.Empty()); private readonly IOverseasIndustryIndexSearchService _industrySearchService; - private readonly IWorldStockSearchService _stockSearchService; + private readonly IOverseasStockSearchService _stockSearchService; private readonly object _sync = new(); - private LegacyOverseasFixedIndexTarget? _selectedFixedIndex; + // FarPoint starts with ActiveRowIndex=-1, but its first AddRows call makes + // row zero active. UC5_Load inserts the fixed indices one by one, so the + // first index is immediately the active source for the period buttons. + private LegacyOverseasFixedIndexTarget? _selectedFixedIndex = + LegacyOverseasActionCatalog.FixedIndexTargets[0]; private LegacyOverseasSearchStatus _industryStatus; private string _industryQuery = string.Empty; private DateTimeOffset? _industryRetrievedAt; @@ -254,7 +271,7 @@ public sealed class LegacyOverseasSelectionWorkflow public LegacyOverseasSelectionWorkflow( IOverseasIndustryIndexSearchService industrySearchService, - IWorldStockSearchService stockSearchService) + IOverseasStockSearchService stockSearchService) { _industrySearchService = industrySearchService ?? throw new ArgumentNullException(nameof(industrySearchService)); @@ -273,6 +290,32 @@ public sealed class LegacyOverseasSelectionWorkflow } } + /// + /// UC5 retains the selected symbol/nation identity when materializing a + /// stock cut. Search hits are therefore actionable for every canonical + /// two-letter foreign nation, not only the initial-list US/TW scope. + /// + public LegacyOverseasActionAvailability GetActionAvailability(string actionId) + { + var action = LegacyOverseasActionCatalog.FindAction(actionId) + ?? throw new LegacyOverseasWorkflowException("The overseas action is not registered."); + lock (_sync) + { + return action.Source switch + { + LegacyOverseasActionSource.FixedIndex when _selectedFixedIndex is null => + new(false, "해외지수를 선택하십시오."), + LegacyOverseasActionSource.Industry when _selectedIndustry is null => + new(false, "해외업종을 선택하십시오."), + LegacyOverseasActionSource.Stock when _selectedStock is null => + new(false, "해외종목을 선택하십시오."), + LegacyOverseasActionSource.Stock when _selectedStock!.NationCode.Length != 2 => + new(false, "선택한 해외종목의 국가 코드는 두 글자여야 합니다."), + _ => new(true, null) + }; + } + } + /// /// Recreates UC5's per-control search and selection state while retaining the /// MainForm-owned moving-average checkboxes. Incrementing both generations makes @@ -288,7 +331,9 @@ public sealed class LegacyOverseasSelectionWorkflow _stockGeneration++; } - _selectedFixedIndex = null; + // Resetting the Web projection recreates UC5's populated fixed + // index spread; retain FarPoint's first-row activation behavior. + _selectedFixedIndex = LegacyOverseasActionCatalog.FixedIndexTargets[0]; _industryStatus = LegacyOverseasSearchStatus.Idle; _industryQuery = string.Empty; _industryRetrievedAt = null; @@ -378,6 +423,10 @@ public sealed class LegacyOverseasSelectionWorkflow _industryRetrievedAt = result.RetrievedAt; _industryResults = validated; _industryIsTruncated = result.IsTruncated; + // A first AddRows call in the original RowMode spread activates + // row zero. The action buttons therefore use its hidden identity + // without requiring a preliminary click. + _selectedIndustry = validated.Count == 0 ? null : validated[0]; _industryError = string.Empty; Touch(); return CreateSnapshot(); @@ -474,6 +523,9 @@ public sealed class LegacyOverseasSelectionWorkflow _stockRetrievedAt = result.RetrievedAt; _stockResults = validated; _stockIsTruncated = result.IsTruncated; + // Match UC5's FarPoint RowMode active-row transition after the + // first result row is inserted. + _selectedStock = validated.Count == 0 ? null : validated[0]; _stockError = string.Empty; Touch(); return CreateSnapshot(); @@ -639,7 +691,7 @@ public sealed class LegacyOverseasSelectionWorkflow var movingAverages = MovingAveragesFor(action); return CreateDraft( action, - $"{target.DisplayName} 캔들 {action.PeriodDays}일", + target.DisplayName, "chart", new LegacyOverseasPlaylistSelection( "FOREIGN_INDEX", @@ -672,7 +724,12 @@ public sealed class LegacyOverseasSelectionWorkflow LegacyOverseasActionDefinition action) { var stock = _selectedStock ?? - throw new LegacyOverseasWorkflowException("Select a US/TW overseas stock first."); + throw new LegacyOverseasWorkflowException("Select an overseas stock first."); + var availability = GetActionAvailability(action.ActionId); + if (!availability.IsAvailable) + { + throw new LegacyOverseasWorkflowException(availability.Reason!); + } var movingAverages = MovingAveragesFor(action); var current = action.Kind == LegacyOverseasActionKind.Current; return CreateDraft( @@ -707,6 +764,18 @@ public sealed class LegacyOverseasSelectionWorkflow "overseas", action.Source, selection, + new LegacyOverseasPlaylistVisibleFields( + action.Source switch + { + LegacyOverseasActionSource.FixedIndex => "해외지수", + LegacyOverseasActionSource.Industry => "해외업종", + LegacyOverseasActionSource.Stock => "해외종목", + _ => throw new LegacyOverseasWorkflowException("The overseas action source is invalid.") + }, + title, + action.Kind == LegacyOverseasActionKind.Candle ? "캔들그래프" : "1열판기본", + action.Kind == LegacyOverseasActionKind.Candle ? $"{action.PeriodDays}일" : string.Empty, + InitialPageText), movingAverages, IsEnabled: true, FadeDuration: DefaultFadeDuration); @@ -809,17 +878,14 @@ public sealed class LegacyOverseasSelectionWorkflow throw InvalidData("stock result bound"); } - var inputNames = new HashSet(StringComparer.Ordinal); var identities = new HashSet(); for (var index = 0; index < items.Length; index++) { var item = items[index] ?? throw InvalidData("stock identity"); if (!IsSafeName(item.InputName) || !IsSafeSymbol(item.Symbol) || - item.NationCode is not ("US" or "TW") || - !inputNames.Add(item.InputName) || - !identities.Add(item) || - index > 0 && CompareStock(items[index - 1], item) > 0) + !IsSafeNationCode(item.NationCode) || + !identities.Add(item)) { throw InvalidData("stock identity or ordering"); } @@ -848,23 +914,13 @@ public sealed class LegacyOverseasSelectionWorkflow : string.Compare(left.Symbol, right.Symbol, StringComparison.Ordinal); } - private static int CompareStock(WorldStockSearchItem left, WorldStockSearchItem right) - { - var comparison = CompareUpper(left.InputName, right.InputName); - if (comparison != 0) - { - return comparison; - } - - comparison = string.Compare(left.Symbol, right.Symbol, StringComparison.Ordinal); - return comparison != 0 - ? comparison - : string.Compare(left.NationCode, right.NationCode, StringComparison.Ordinal); - } - private static int CompareUpper(string left, string right) => string.Compare(left.ToUpperInvariant(), right.ToUpperInvariant(), StringComparison.Ordinal); + private static bool IsSafeNationCode(string value) => + value.Length is 2 or 3 && + value.All(character => character is >= 'A' and <= 'Z'); + private static string NormalizeQuery(string query) { ArgumentNullException.ThrowIfNull(query); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs index 7bd12ee..e8a8016 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs @@ -211,6 +211,8 @@ public sealed record LegacyThemePlaylistDraft( string Code, IReadOnlyList Aliases, string Title, + string PlaylistGraphicType, + string PlaylistSubtype, string Detail, string Category, string Market, @@ -253,6 +255,7 @@ public sealed class LegacyThemeWorkflow RegisteredActions.ToDictionary(action => action.Id, StringComparer.Ordinal)); private readonly IThemeSelectionService _selectionService; + private readonly Func _localNow; private readonly object _authority = new(); static LegacyThemeWorkflow() @@ -260,16 +263,22 @@ public sealed class LegacyThemeWorkflow ValidateActions(RegisteredActions); } - public LegacyThemeWorkflow(IThemeSelectionService selectionService) + public LegacyThemeWorkflow( + IThemeSelectionService selectionService, + Func? localNow = null) { _selectionService = selectionService ?? throw new ArgumentNullException(nameof(selectionService)); + _localNow = localNow ?? (() => DateTimeOffset.Now); } public static IReadOnlyList Actions => RegisteredActions; public static IReadOnlyList Sorts => RegisteredSorts; + public ThemeSession CurrentNxtSession => + _localNow().Hour <= 12 ? ThemeSession.PreMarket : ThemeSession.AfterMarket; + public LegacyThemeWorkflowSnapshot CreateInitial( ThemeSession nxtSession = ThemeSession.PreMarket) { @@ -287,6 +296,25 @@ public sealed class LegacyThemeWorkflow previewIsTruncated: false); } + public LegacyThemeWorkflowSnapshot ResetForTabActivation( + LegacyThemeWorkflowSnapshot snapshot, + ThemeSession nxtSession = ThemeSession.PreMarket) + { + RequireSnapshot(snapshot); + RequireNxtSession(nxtSession); + return NewSnapshot( + checked(snapshot.Revision + 1), + query: string.Empty, + nxtSession, + LegacyThemeSort.InputOrder, + [], + searchIsTruncated: false, + selectedResultId: null, + selectedIdentity: null, + previewItems: [], + previewIsTruncated: false); + } + public async Task SearchAsync( LegacyThemeWorkflowSnapshot snapshot, string query, @@ -305,6 +333,9 @@ public sealed class LegacyThemeWorkflow cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); var rows = ValidateSearchResult(result, normalizedQuery, nxtSession); + var retainedSelection = snapshot.SelectedIdentity is null + ? null + : rows.SingleOrDefault(row => row.Identity == snapshot.SelectedIdentity); return NewSnapshot( checked(snapshot.Revision + 1), normalizedQuery, @@ -312,10 +343,10 @@ public sealed class LegacyThemeWorkflow snapshot.Sort, rows, result.IsTruncated, - selectedResultId: null, - selectedIdentity: null, - previewItems: [], - previewIsTruncated: false); + retainedSelection?.ResultId, + retainedSelection?.Identity, + retainedSelection is null ? [] : snapshot.PreviewItems, + retainedSelection is not null && snapshot.PreviewIsTruncated); } public async Task SelectAsync( @@ -349,6 +380,31 @@ public sealed class LegacyThemeWorkflow preview.IsTruncated); } + public LegacyThemeWorkflowSnapshot RemoveSelectedResult( + LegacyThemeWorkflowSnapshot snapshot) + { + RequireSnapshot(snapshot); + if (snapshot.SelectedResultId is null) + { + return snapshot; + } + + return NewSnapshot( + checked(snapshot.Revision + 1), + snapshot.Query, + snapshot.NxtSession, + snapshot.Sort, + snapshot.SearchResults.Where(row => !string.Equals( + row.ResultId, + snapshot.SelectedResultId, + StringComparison.Ordinal)), + snapshot.SearchIsTruncated, + selectedResultId: null, + selectedIdentity: null, + previewItems: [], + previewIsTruncated: false); + } + public LegacyThemeWorkflowSnapshot SelectSort( LegacyThemeWorkflowSnapshot snapshot, string sortId) @@ -391,7 +447,7 @@ public sealed class LegacyThemeWorkflow if (action.ValueKind == LegacyThemeValueKind.Expected && identity.Market == ThemeMarket.Nxt) { - throw new InvalidOperationException("NXT themes do not provide expected-price data."); + throw new InvalidOperationException("NXT는 데이터가 없습니다."); } var sortId = SortId(snapshot.Sort); @@ -399,6 +455,9 @@ public sealed class LegacyThemeWorkflow var displayTitle = identity.Market == ThemeMarket.Nxt ? $"{identity.ThemeTitle}(NXT)" : identity.ThemeTitle; + var playlistGraphicType = PlaylistGraphicType(action); + var playlistSubtype = + $"테마-{ValueKindLabel(action.ValueKind)}({SortLabel(snapshot.Sort)})"; var selection = identity.Market == ThemeMarket.Krx ? new LegacyThemePlaylistSelection( "PAGED_THEME", @@ -409,7 +468,7 @@ public sealed class LegacyThemeWorkflow : new LegacyThemePlaylistSelection( "PAGED_NXT_THEME", displayTitle, - SessionId(identity.Session), + SessionId(CurrentNxtSession), sortId, identity.ThemeCode); @@ -431,7 +490,9 @@ public sealed class LegacyThemeWorkflow action.Code, action.Aliases, displayTitle, - $"{action.Label} · {SortLabel(snapshot.Sort)}", + playlistGraphicType, + playlistSubtype, + playlistSubtype, "plate", "theme", selection, @@ -527,16 +588,12 @@ public sealed class LegacyThemeWorkflow throw InvalidData("search envelope"); } - var titles = new Dictionary> - { - [ThemeMarket.Krx] = new(StringComparer.Ordinal), - [ThemeMarket.Nxt] = new(StringComparer.Ordinal) - }; var codes = new Dictionary> { [ThemeMarket.Krx] = new(StringComparer.Ordinal), [ThemeMarket.Nxt] = new(StringComparer.Ordinal) }; + var sawNxt = false; foreach (var item in serviceItems) { if (item is null) @@ -549,25 +606,30 @@ public sealed class LegacyThemeWorkflow ? DataSourceKind.Oracle : DataSourceKind.MariaDb; if (item.Source != expectedSource || - !titles[item.Identity.Market].Add(item.Identity.ThemeTitle) || !codes[item.Identity.Market].Add(item.Identity.ThemeCode)) { throw InvalidData("search identity or provider"); } + + // UC4 appended the Oracle list and then the MariaDB list. Preserve + // each provider's returned order; do not sort rows by title or code. + if (item.Identity.Market == ThemeMarket.Nxt) + { + sawNxt = true; + } + else if (sawNxt) + { + throw InvalidData("search provider order"); + } } - var ordered = serviceItems - .OrderBy(item => item.Identity.Market) - .ThenBy(item => item.Identity.ThemeTitle, StringComparer.Ordinal) - .ThenBy(item => item.Identity.ThemeCode, StringComparer.Ordinal) - .ToArray(); - var rows = new LegacyThemeSearchRow[ordered.Length]; - for (var index = 0; index < ordered.Length; index++) + var rows = new LegacyThemeSearchRow[serviceItems.Length]; + for (var index = 0; index < serviceItems.Length; index++) { rows[index] = new LegacyThemeSearchRow( $"theme-result-{index + 1:000}", - ordered[index].Identity, - ordered[index].Source); + serviceItems[index].Identity, + serviceItems[index].Source); } return Array.AsReadOnly(rows); @@ -589,7 +651,6 @@ public sealed class LegacyThemeWorkflow throw InvalidData("preview envelope"); } - var itemCodes = new HashSet(StringComparer.Ordinal); var indexes = new HashSet(); var previousIndex = -1; var rows = new LegacyThemePreviewRow[serviceItems.Length]; @@ -598,7 +659,7 @@ public sealed class LegacyThemeWorkflow var item = serviceItems[index] ?? throw InvalidData("preview item"); if (item.InputIndex is < 0 or > 9_999 || item.InputIndex <= previousIndex || - !indexes.Add(item.InputIndex) || !itemCodes.Add(item.ItemCode) || + !indexes.Add(item.InputIndex) || !IsItemCodeForMarket(item.ItemCode, identity.Market) || !IsCanonicalText(item.ItemName, 200)) { @@ -684,6 +745,28 @@ public sealed class LegacyThemeWorkflow "Unknown theme value kind.") }; + private static string ValueKindLabel(LegacyThemeValueKind valueKind) => valueKind switch + { + LegacyThemeValueKind.Current => "현재가", + LegacyThemeValueKind.Expected => "예상체결가", + _ => throw new ArgumentOutOfRangeException( + nameof(valueKind), + valueKind, + "Unknown theme value kind.") + }; + + private static string PlaylistGraphicType(LegacyThemeActionDefinition action) => + action.Id switch + { + "five-row-current" or "five-row-expected" => "5단 표그래프", + "six-row-current" => "6종목 현재가", + "twelve-row-current" => "12종목 현재가", + _ => throw new ArgumentOutOfRangeException( + nameof(action), + action.Id, + "Unknown theme action.") + }; + private static void RequireNxtSession(ThemeSession nxtSession) { if (nxtSession is not (ThemeSession.PreMarket or ThemeSession.AfterMarket)) @@ -780,10 +863,7 @@ public sealed class LegacyThemeWorkflow ? "theme-not-selected" : previewItemCount == 0 ? "theme-preview-is-empty" - : action.ValueKind == LegacyThemeValueKind.Expected && - selectedIdentity.Market == ThemeMarket.Nxt - ? "expected-theme-is-krx-only" - : null; + : null; states[index] = new LegacyThemeActionState( action.Id, reason is null, diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs index b8246e8..1a06bf0 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs @@ -76,6 +76,7 @@ public sealed record LegacyOperatorCatalogWorkflowSnapshot( LegacyThemeNameAvailability ThemeNameAvailability, string CheckedThemeName, IReadOnlyList DraftRows, + string? ActiveDraftRowId, string StockQuery, IReadOnlyList StockResults, bool StockResultsAreTruncated, @@ -203,6 +204,12 @@ public sealed class LegacyOperatorCatalogWorkflowController { public const int MaximumCatalogResults = 500; public const int MaximumStockResults = 100; + public const string ThemeNameDuplicateMessage = "이미 사용중인 테마명입니다."; + public const string ThemeNameAvailableMessage = "사용 가능한 테마명입니다."; + public const string ThemeSaveSuccessMessage = "ThemeA를 저장했습니다."; + public const string ExpertSaveSuccessMessage = "EList를 저장했습니다."; + public const string ThemeDeleteSuccessMessage = "ThemeA를 삭제했습니다."; + public const string ExpertDeleteSuccessMessage = "EList를 삭제했습니다."; private const decimal MaximumWebSafeInteger = 9_007_199_254_740_991m; private const string OutcomeUnknownMessage = @@ -240,6 +247,7 @@ public sealed class LegacyOperatorCatalogWorkflowController private string _checkedThemeName = string.Empty; private string? _selectedResultId; private string? _selectedStockResultId; + private string? _activeDraftRowId; private ThemeSelectionIdentity? _expectedThemeIdentity; private ExpertSelectionIdentity? _expectedExpertIdentity; private string _statusMessage = string.Empty; @@ -298,6 +306,12 @@ public sealed class LegacyOperatorCatalogWorkflowController LegacyOperatorCatalogEntity entity, CancellationToken cancellationToken = default) { + if (_isOpen && _mode != LegacyOperatorCatalogMode.List) + { + throw new InvalidOperationException( + "Close the active legacy child dialog before switching catalogs."); + } + _isOpen = true; _entity = entity; _mode = LegacyOperatorCatalogMode.List; @@ -420,7 +434,7 @@ public sealed class LegacyOperatorCatalogWorkflowController ThemeSession nxtSession, CancellationToken cancellationToken = default) { - EnsureOpen(); + EnsureCatalogListMode(); _query = ValidateQuery(query); if (nxtSession is not (ThemeSession.PreMarket or ThemeSession.AfterMarket)) { @@ -454,7 +468,7 @@ public sealed class LegacyOperatorCatalogWorkflowController string resultId, CancellationToken cancellationToken = default) { - EnsureOpen(); + EnsureCatalogListMode(); var selected = FindResult(resultId); try { @@ -516,6 +530,7 @@ public sealed class LegacyOperatorCatalogWorkflowController throw new InvalidOperationException("The catalog result has no trusted identity."); } + _activeDraftRowId = _draftRows.FirstOrDefault()?.RowId; _selectedResultId = resultId; _mode = LegacyOperatorCatalogMode.Edit; ResetThemeNameAvailability(); @@ -543,6 +558,7 @@ public sealed class LegacyOperatorCatalogWorkflowController CancellationToken cancellationToken = default) { EnsureOpenEntity(LegacyOperatorCatalogEntity.Theme); + EnsureCatalogListMode(); if (!CanMutateCurrentEntity()) { return RejectUnavailableWrite(); @@ -560,6 +576,7 @@ public sealed class LegacyOperatorCatalogWorkflowController _expectedThemeIdentity = null; _expectedExpertIdentity = null; _draftRows.Clear(); + _activeDraftRowId = null; ResetThemeNameAvailability(); ResetStockSearch(); SetStatus("새 ThemeA 항목을 입력하세요.", LegacyOperatorCatalogStatusKind.Information); @@ -599,14 +616,14 @@ public sealed class LegacyOperatorCatalogWorkflowController { _themeNameAvailability = LegacyThemeNameAvailability.Duplicate; SetStatus( - "이미 사용중인 테마명입니다.", + ThemeNameDuplicateMessage, LegacyOperatorCatalogStatusKind.Warning); } else { _themeNameAvailability = LegacyThemeNameAvailability.Available; SetStatus( - "사용 가능한 테마명입니다.", + ThemeNameAvailableMessage, LegacyOperatorCatalogStatusKind.Information); } } @@ -629,6 +646,7 @@ public sealed class LegacyOperatorCatalogWorkflowController CancellationToken cancellationToken = default) { EnsureOpenEntity(LegacyOperatorCatalogEntity.Expert); + EnsureCatalogListMode(); if (!CanMutateCurrentEntity()) { return RejectUnavailableWrite(); @@ -644,6 +662,7 @@ public sealed class LegacyOperatorCatalogWorkflowController _expectedThemeIdentity = null; _expectedExpertIdentity = null; _draftRows.Clear(); + _activeDraftRowId = null; ResetStockSearch(); SetStatus("새 EList 항목을 입력하세요.", LegacyOperatorCatalogStatusKind.Information); } @@ -665,7 +684,7 @@ public sealed class LegacyOperatorCatalogWorkflowController string query, CancellationToken cancellationToken = default) { - EnsureEditing(); + EnsureDraftRowsEditable(); _stockQuery = ValidateRequiredText(query, LegacyStockSearchService.MaximumQueryLength); _selectedStockResultId = null; try @@ -698,7 +717,7 @@ public sealed class LegacyOperatorCatalogWorkflowController public LegacyOperatorCatalogWorkflowSnapshot SelectStock(string resultId) { - EnsureEditing(); + EnsureDraftRowsEditable(); var stock = FindStock(resultId); if (!IsCompatible(stock.Item)) { @@ -716,7 +735,7 @@ public sealed class LegacyOperatorCatalogWorkflowController public LegacyOperatorCatalogWorkflowSnapshot AddSelectedStock(decimal? buyAmount) { - EnsureEditing(); + EnsureDraftRowsEditable(); var selected = _selectedStockResultId is null ? throw new InvalidOperationException("Select a stock-master result first.") : FindStock(_selectedStockResultId); @@ -747,29 +766,15 @@ public sealed class LegacyOperatorCatalogWorkflowController throw new InvalidOperationException("EList recommendation limit reached."); } - if (buyAmount is null || decimal.Truncate(buyAmount.Value) != buyAmount.Value || - buyAmount.Value <= 0 || buyAmount.Value > MaximumWebSafeInteger) - { - throw new ArgumentOutOfRangeException(nameof(buyAmount)); - } - row = DraftRowState.ForExpert( NewOpaqueId("catalog-draft"), selected.Item.Code, selected.Item.Name, - buyAmount.Value); - } - - if (_draftRows.Any(existing => - string.Equals(existing.Code, row.Code, StringComparison.Ordinal) || - string.Equals(existing.Name, row.Name, StringComparison.Ordinal))) - { - SetStatus("같은 코드 또는 이름의 종목이 이미 있습니다.", LegacyOperatorCatalogStatusKind.Warning); - Touch(); - return CreateSnapshot(); + buyAmount: null); } _draftRows.Add(row); + _activeDraftRowId = row.RowId; _selectedStockResultId = null; SetStatus("종목을 편집 목록에 추가했습니다.", LegacyOperatorCatalogStatusKind.Information); Touch(); @@ -780,15 +785,24 @@ public sealed class LegacyOperatorCatalogWorkflowController string rowId, LegacyOperatorCatalogMoveDirection direction) { - EnsureEditing(); + EnsureDraftRowsEditable(); var index = FindDraftIndex(rowId); var target = direction == LegacyOperatorCatalogMoveDirection.Up ? index - 1 : index + 1; + var activeChanged = !string.Equals( + _activeDraftRowId, + rowId, + StringComparison.Ordinal); + _activeDraftRowId = rowId; if (target >= 0 && target < _draftRows.Count) { (_draftRows[index], _draftRows[target]) = (_draftRows[target], _draftRows[index]); SetStatus("표시 순서를 변경했습니다.", LegacyOperatorCatalogStatusKind.Information); Touch(); } + else if (activeChanged) + { + Touch(); + } return CreateSnapshot(); } @@ -800,7 +814,7 @@ public sealed class LegacyOperatorCatalogWorkflowController { ArgumentNullException.ThrowIfNull(sourceRowId); ArgumentNullException.ThrowIfNull(targetRowId); - EnsureEditing(); + EnsureDraftRowsEditable(); var sourceIndex = _draftRows.FindIndex(row => string.Equals(row.RowId, sourceRowId, StringComparison.Ordinal)); @@ -811,6 +825,12 @@ public sealed class LegacyOperatorCatalogWorkflowController return CreateSnapshot(); } + var activeChanged = !string.Equals( + _activeDraftRowId, + sourceRowId, + StringComparison.Ordinal); + _activeDraftRowId = sourceRowId; + var candidate = _draftRows.ToList(); var movingRow = candidate[sourceIndex]; candidate.RemoveAt(sourceIndex); @@ -828,6 +848,10 @@ public sealed class LegacyOperatorCatalogWorkflowController candidate.Select(static row => row.RowId), StringComparer.Ordinal)) { + if (activeChanged) + { + Touch(); + } return CreateSnapshot(); } @@ -840,23 +864,25 @@ public sealed class LegacyOperatorCatalogWorkflowController public LegacyOperatorCatalogWorkflowSnapshot SetDraftBuyAmount( string rowId, - decimal buyAmount) + decimal? buyAmount) { - EnsureEditing(); + EnsureDraftRowsEditable(); if (_entity != LegacyOperatorCatalogEntity.Expert) { throw new InvalidOperationException( "Only EList recommendation rows have a buy amount."); } - if (decimal.Truncate(buyAmount) != buyAmount || - buyAmount <= 0 || buyAmount > MaximumWebSafeInteger) + if (buyAmount is not null && + (decimal.Truncate(buyAmount.Value) != buyAmount.Value || + buyAmount.Value <= 0 || buyAmount.Value > MaximumWebSafeInteger)) { throw new ArgumentOutOfRangeException(nameof(buyAmount)); } var index = FindDraftIndex(rowId); _draftRows[index] = _draftRows[index] with { BuyAmount = buyAmount }; + _activeDraftRowId = rowId; SetStatus("추천 종목의 매수가를 변경했습니다. 저장을 눌러 DB에 반영하세요.", LegacyOperatorCatalogStatusKind.Information); Touch(); @@ -865,19 +891,83 @@ public sealed class LegacyOperatorCatalogWorkflowController public LegacyOperatorCatalogWorkflowSnapshot RemoveDraftRow(string rowId) { - EnsureEditing(); - _draftRows.RemoveAt(FindDraftIndex(rowId)); + EnsureDraftRowsEditable(); + var index = FindDraftIndex(rowId); + _draftRows.RemoveAt(index); + _activeDraftRowId = _draftRows.Count == 0 + ? null + : _draftRows[Math.Min(index, _draftRows.Count - 1)].RowId; SetStatus("편집 목록에서 종목을 삭제했습니다.", LegacyOperatorCatalogStatusKind.Information); Touch(); return CreateSnapshot(); } - public Task SaveAsync( + public LegacyOperatorCatalogWorkflowSnapshot SelectDraftRow(string rowId) + { + EnsureDraftRowsEditable(); + var canonicalRowId = _draftRows[FindDraftIndex(rowId)].RowId; + if (!string.Equals(_activeDraftRowId, canonicalRowId, StringComparison.Ordinal)) + { + _activeDraftRowId = canonicalRowId; + Touch(); + } + + return CreateSnapshot(); + } + + public LegacyOperatorCatalogWorkflowSnapshot RemoveActiveDraftRow() + { + EnsureDraftRowsEditable(); + if (_activeDraftRowId is null) + { + return CreateSnapshot(); + } + + return RemoveDraftRow(_activeDraftRowId); + } + + public LegacyOperatorCatalogWorkflowSnapshot ClearThemeDraftRows() + { + EnsureOpenEntity(LegacyOperatorCatalogEntity.Theme); + EnsureEditing(); + if (_draftRows.Count == 0) + { + return CreateSnapshot(); + } + + _draftRows.Clear(); + _activeDraftRowId = null; + SetStatus("모든 구성 종목을 편집 목록에서 삭제했습니다.", + LegacyOperatorCatalogStatusKind.Information); + Touch(); + return CreateSnapshot(); + } + + public async Task SaveAsync( string editorName, CancellationToken cancellationToken = default) { EnsureEditing(); - _editorName = ValidateRequiredText(editorName, 128); + + // UC6 silently ignored Save when the selected expert had no recommendation rows. + // This check intentionally precedes name validation and does not touch revision/status. + if (_entity == LegacyOperatorCatalogEntity.Expert && + _mode == LegacyOperatorCatalogMode.Edit && + _draftRows.Count == 0) + { + return CreateSnapshot(); + } + + if (!CanMutateCurrentEntity()) + { + return RejectUnavailableWrite(); + } + + _editorName = _entity == LegacyOperatorCatalogEntity.Expert && + _mode == LegacyOperatorCatalogMode.Edit + ? (_expectedExpertIdentity ?? throw new InvalidOperationException( + "The trusted EList edit identity is missing.")).ExpertName + : ValidateRequiredText(editorName, 128); if (_entity == LegacyOperatorCatalogEntity.Theme) { @@ -890,24 +980,43 @@ public sealed class LegacyOperatorCatalogWorkflowController _codeLabel, _editorName, Array.AsReadOnly(items)); - return ExecuteWriteAsync( - "ThemeA를 저장했습니다.", - token => _identityValidationService.ValidateThemeItemsAsync(items, token), + return await ExecuteWriteAsync( + ThemeSaveSuccessMessage, + async token => + { + if (await _themePersistenceService.ThemeNameExistsAsync( + _newThemeMarket, + _editorName, + token).ConfigureAwait(false)) + { + throw new ThemeNameDuplicateException(); + } + + await _identityValidationService.ValidateThemeItemsAsync(items, token) + .ConfigureAwait(false); + }, token => _themePersistenceService.CreateAsync(definition, token), - cancellationToken); + cancellationToken).ConfigureAwait(false); } var expected = _expectedThemeIdentity ?? throw new InvalidOperationException( "The trusted ThemeA edit identity is missing."); - return ExecuteWriteAsync( - "ThemeA를 저장했습니다.", + return await ExecuteWriteAsync( + ThemeSaveSuccessMessage, token => _identityValidationService.ValidateThemeItemsAsync(items, token), token => _themePersistenceService.ReplaceAsync( expected, _editorName, items, token), - cancellationToken); + cancellationToken).ConfigureAwait(false); + } + + if (_draftRows.Any(static row => row.BuyAmount is null)) + { + SetStatus("매수가를 입력하세요.", LegacyOperatorCatalogStatusKind.Warning); + Touch(); + return CreateSnapshot(); } var recommendations = _draftRows.Select((row, index) => @@ -915,26 +1024,29 @@ public sealed class LegacyOperatorCatalogWorkflowController index, row.Code, row.Name, - row.BuyAmount ?? throw new InvalidOperationException( - "An EList recommendation amount is missing."))).ToArray(); + row.BuyAmount!.Value)).ToArray(); if (_mode == LegacyOperatorCatalogMode.Create) { + if (recommendations.Length != 0) + { + throw new InvalidOperationException( + "The legacy EList create dialog cannot contain recommendations."); + } + var definition = new ExpertCatalogDefinition( new ExpertSelectionIdentity(_codeLabel, _editorName), Array.AsReadOnly(recommendations)); - return ExecuteWriteAsync( - "EList를 저장했습니다.", - token => _identityValidationService.ValidateExpertRecommendationsAsync( - recommendations, - token), + return await ExecuteWriteAsync( + ExpertSaveSuccessMessage, + static _ => Task.CompletedTask, token => _expertPersistenceService.CreateAsync(definition, token), - cancellationToken); + cancellationToken).ConfigureAwait(false); } var expectedExpert = _expectedExpertIdentity ?? throw new InvalidOperationException( "The trusted EList edit identity is missing."); - return ExecuteWriteAsync( - "EList를 저장했습니다.", + return await ExecuteWriteAsync( + ExpertSaveSuccessMessage, token => _identityValidationService.ValidateExpertRecommendationsAsync( recommendations, token), @@ -943,7 +1055,7 @@ public sealed class LegacyOperatorCatalogWorkflowController _editorName, recommendations, token), - cancellationToken); + cancellationToken).ConfigureAwait(false); } public Task DeleteAsync( @@ -960,7 +1072,7 @@ public sealed class LegacyOperatorCatalogWorkflowController var expected = _expectedThemeIdentity ?? throw new InvalidOperationException( "The trusted ThemeA delete identity is missing."); return ExecuteWriteAsync( - "ThemeA를 삭제했습니다.", + ThemeDeleteSuccessMessage, validation: null, token => _themePersistenceService.DeleteAsync(expected, token), cancellationToken); @@ -969,7 +1081,7 @@ public sealed class LegacyOperatorCatalogWorkflowController var expectedExpert = _expectedExpertIdentity ?? throw new InvalidOperationException( "The trusted EList delete identity is missing."); return ExecuteWriteAsync( - "EList를 삭제했습니다.", + ExpertDeleteSuccessMessage, validation: null, token => _expertPersistenceService.DeleteAsync(expectedExpert, token), cancellationToken); @@ -1068,6 +1180,13 @@ public sealed class LegacyOperatorCatalogWorkflowController "DB 내용이 바뀌었거나 코드/이름이 중복되어 transaction이 rollback되었습니다. 목록을 다시 읽으세요.", LegacyOperatorCatalogStatusKind.Warning); } + catch (ThemeNameDuplicateException) when (!dispatched) + { + _writeSafety.Complete(); + _checkedThemeName = _editorName; + _themeNameAvailability = LegacyThemeNameAvailability.Duplicate; + SetStatus(ThemeNameDuplicateMessage, LegacyOperatorCatalogStatusKind.Warning); + } catch (StockMasterIdentityDataException) when (!dispatched) { _writeSafety.Complete(); @@ -1183,7 +1302,11 @@ public sealed class LegacyOperatorCatalogWorkflowController var draftViews = _draftRows.Select((row, index) => new LegacyOperatorCatalogDraftRowView( row.RowId, - row.Name, + _entity == LegacyOperatorCatalogEntity.Theme && + row.Code.Length > 0 && row.Code[0] is 'X' or 'T' && + !row.Name.EndsWith("(NXT)", StringComparison.Ordinal) + ? row.Name + "(NXT)" + : row.Name, row.MarketLabel, row.BuyAmount, index)).ToArray(); @@ -1214,6 +1337,7 @@ public sealed class LegacyOperatorCatalogWorkflowController _themeNameAvailability, _checkedThemeName, Array.AsReadOnly(draftViews), + _activeDraftRowId, _stockQuery, Array.AsReadOnly(stockViews), _stockResultsAreTruncated, @@ -1229,6 +1353,8 @@ public sealed class LegacyOperatorCatalogWorkflowController canMutate && _mode == LegacyOperatorCatalogMode.Edit, canMutate && (_mode is LegacyOperatorCatalogMode.Create or LegacyOperatorCatalogMode.Edit) && + (_entity != LegacyOperatorCatalogEntity.Expert || + _mode == LegacyOperatorCatalogMode.Edit) && _selectedStockResultId is not null, quarantined && string.IsNullOrWhiteSpace(_statusMessage) ? quarantineReason ?? OutcomeUnknownMessage @@ -1262,6 +1388,7 @@ public sealed class LegacyOperatorCatalogWorkflowController _codeLabel = string.Empty; _editorName = string.Empty; _draftRows.Clear(); + _activeDraftRowId = null; ResetThemeNameAvailability(); ResetStockSearch(); } @@ -1306,6 +1433,27 @@ public sealed class LegacyOperatorCatalogWorkflowController } } + private void EnsureCatalogListMode() + { + EnsureOpen(); + if (_mode != LegacyOperatorCatalogMode.List) + { + throw new InvalidOperationException( + "The legacy parent catalog is disabled while its child dialog is open."); + } + } + + private void EnsureDraftRowsEditable() + { + EnsureEditing(); + if (_entity == LegacyOperatorCatalogEntity.Expert && + _mode != LegacyOperatorCatalogMode.Edit) + { + throw new InvalidOperationException( + "The legacy EList create dialog only accepts an expert name."); + } + } + private CatalogResultState FindResult(string resultId) { ArgumentException.ThrowIfNullOrWhiteSpace(resultId); @@ -1489,7 +1637,7 @@ public sealed class LegacyOperatorCatalogWorkflowController string rowId, string code, string name, - decimal buyAmount) => + decimal? buyAmount) => new(rowId, code, name, "종목", buyAmount); private static string ThemeMarketLabel(string code) => code.Length > 0 @@ -1505,4 +1653,8 @@ public sealed class LegacyOperatorCatalogWorkflowController } private sealed record StockResultState(string ResultId, StockSearchItem Item); + + private sealed class ThemeNameDuplicateException : Exception + { + } } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs index e463f4a..e60e99d 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs @@ -82,6 +82,10 @@ public sealed record LegacyActivateFixedSectionIntent(int SectionIndex) : Legacy public sealed record LegacySelectIndustryIntent(int Index, bool DoubleClick) : LegacyUiIntent; +public sealed record LegacySetIndustryComparisonTextIntent( + LegacyIndustryComparisonSlot Slot, + string Value) : LegacyUiIntent; + public sealed record LegacySwapIndustriesIntent : LegacyUiIntent; public sealed record LegacyActivateIndustryActionIntent(string ActionId) : LegacyUiIntent; @@ -135,7 +139,9 @@ public sealed record LegacyActivateComparisonActionIntent(string ActionId) : Leg public sealed record LegacyActivateComparisonSectionIntent(int SectionIndex) : LegacyUiIntent; -public sealed record LegacySearchThemesIntent(string Query, ThemeSession NxtSession) +// UC4 has no NXT-session control. The native controller chooses it from its +// local clock, so the WebView can supply only the search text. +public sealed record LegacySearchThemesIntent(string Query) : LegacyUiIntent; public sealed record LegacySelectThemeIntent(string ResultId) : LegacyUiIntent; @@ -207,15 +213,23 @@ public sealed record LegacyReorderOperatorCatalogDraftRowIntent( public sealed record LegacyRemoveOperatorCatalogDraftRowIntent(string RowId) : LegacyUiIntent; +public sealed record LegacySelectOperatorCatalogDraftRowIntent(string RowId) : LegacyUiIntent; + +public sealed record LegacyRemoveActiveOperatorCatalogDraftRowIntent : LegacyUiIntent; + +public sealed record LegacyClearOperatorCatalogDraftRowsIntent : LegacyUiIntent; + public sealed record LegacySetOperatorCatalogDraftBuyAmountIntent( string RowId, - decimal BuyAmount) : LegacyUiIntent; + decimal? BuyAmount) : LegacyUiIntent; public sealed record LegacyCheckOperatorCatalogThemeNameIntent(string Name) : LegacyUiIntent; public sealed record LegacySaveOperatorCatalogIntent(string Name) : LegacyUiIntent; -public sealed record LegacyDeleteOperatorCatalogIntent : LegacyUiIntent; +public sealed record LegacyConfirmNativeDialogIntent : LegacyUiIntent; + +public sealed record LegacyCancelNativeDialogIntent : LegacyUiIntent; public sealed record LegacyOpenManualFinancialIntent(ManualFinancialScreenKind Screen) : LegacyUiIntent; @@ -447,6 +461,7 @@ public static class LegacyBridgeProtocol "activate-fixed-section" => ParseFixedSection(payload), "select-industry" => ParseIndustrySelection(payload, false), "double-click-industry" => ParseIndustrySelection(payload, true), + "set-industry-comparison-text" => ParseIndustryComparisonText(payload), "swap-industries" => ParseSwapIndustries(payload), "activate-industry-action" => ParseIndustryAction(payload), "activate-industry-section" => ParseSectionIndex( @@ -526,14 +541,25 @@ public static class LegacyBridgeProtocol "reorder-operator-catalog-draft-row" => ParseOperatorCatalogDraftReorder(payload), "remove-operator-catalog-draft-row" => ParseOperatorCatalogDraftRemove(payload), + "select-operator-catalog-draft-row" => + ParseOperatorCatalogDraftSelection(payload), + "remove-active-operator-catalog-draft-row" => ParseOperatorCatalogEmpty( + payload, + static () => new LegacyRemoveActiveOperatorCatalogDraftRowIntent()), + "clear-operator-catalog-draft-rows" => ParseOperatorCatalogEmpty( + payload, + static () => new LegacyClearOperatorCatalogDraftRowsIntent()), "set-operator-catalog-draft-buy-amount" => ParseOperatorCatalogDraftBuyAmount(payload), "check-operator-catalog-theme-name" => ParseOperatorCatalogThemeNameCheck(payload), "save-operator-catalog" => ParseOperatorCatalogSave(payload), - "delete-operator-catalog" => ParseOperatorCatalogEmpty( + "confirm-native-dialog" => ParseOperatorCatalogEmpty( payload, - static () => new LegacyDeleteOperatorCatalogIntent()), + static () => new LegacyConfirmNativeDialogIntent()), + "cancel-native-dialog" => ParseOperatorCatalogEmpty( + payload, + static () => new LegacyCancelNativeDialogIntent()), "refresh-named-playlists" => ParseNamedPlaylistRefresh(payload), "select-named-playlist" => ParseNamedPlaylistSelection(payload), "load-named-playlist-by-id" => ParseNamedPlaylistLoadById(payload), @@ -676,8 +702,8 @@ public static class LegacyBridgeProtocol }), snapshot.Industry.SelectedIndex, selectedName = snapshot.Industry.SelectedIndustry?.IndustryName, - firstName = snapshot.Industry.FirstIndustry?.IndustryName, - secondName = snapshot.Industry.SecondIndustry?.IndustryName, + firstName = snapshot.Industry.FirstText, + secondName = snapshot.Industry.SecondText, actions = snapshot.Industry.Actions.Select(action => new { actionId = action.ActionId, @@ -723,27 +749,22 @@ public static class LegacyBridgeProtocol results = snapshot.Overseas.Stock.Results.Select(row => new { row.SelectionId, - displayName = row.InputName, - row.NationCode + displayName = row.InputName }) }, - actions = snapshot.Overseas.Actions.Select(action => new + actions = snapshot.Overseas.Actions.Select(action => { - action.ActionId, - action.Label, - action.Source, - action.Kind, - action.PeriodDays, - isAvailable = action.Source switch + var availability = GetOverseasActionAvailability(snapshot.Overseas, action); + return new { - LegacyOverseasActionSource.FixedIndex => - snapshot.Overseas.SelectedFixedIndex is not null, - LegacyOverseasActionSource.Industry => - snapshot.Overseas.Industry.Selected is not null, - LegacyOverseasActionSource.Stock => - snapshot.Overseas.Stock.Selected is not null, - _ => false - } + action.ActionId, + action.Label, + action.Source, + action.Kind, + action.PeriodDays, + availability.IsAvailable, + availability.Reason + }; }) }, comparison = snapshot.Comparison is null ? null : new @@ -946,6 +967,7 @@ public static class LegacyBridgeProtocol row.BuyAmount, row.Position }), + snapshot.OperatorCatalog.ActiveDraftRowId, snapshot.OperatorCatalog.StockQuery, stockResults = snapshot.OperatorCatalog.StockResults.Select(row => new { @@ -1119,6 +1141,9 @@ public static class LegacyBridgeProtocol playout.RefreshCompletedCount, playout.RefreshMaximumCount, playout.RefreshLimitReached, + playout.RefreshNextAtUtc, + playout.RefreshLastSuccessAtUtc, + playout.RefreshFaultCode, playout.FadeDuration, playout.BackgroundEnabled, playout.BackgroundFileName, @@ -1140,6 +1165,22 @@ public static class LegacyBridgeProtocol return JsonSerializer.Serialize(new { type = "state", payload }, SerializerOptions); } + private static LegacyOverseasActionAvailability GetOverseasActionAvailability( + LegacyOverseasWorkflowSnapshot snapshot, + LegacyOverseasActionDefinition action) => + action.Source switch + { + LegacyOverseasActionSource.FixedIndex when snapshot.SelectedFixedIndex is null => + new(false, "해외지수를 선택하십시오."), + LegacyOverseasActionSource.Industry when snapshot.Industry.Selected is null => + new(false, "해외업종을 선택하십시오."), + LegacyOverseasActionSource.Stock when snapshot.Stock.Selected is null => + new(false, "해외종목을 선택하십시오."), + LegacyOverseasActionSource.Stock when snapshot.Stock.Selected!.NationCode.Length != 2 => + new(false, "선택한 해외종목의 국가 코드는 두 글자여야 합니다."), + _ => new(true, null) + }; + private static LegacyUiIntent? ParseReady(JsonElement payload) => HasOnlyProperties(payload) ? new LegacyReadyIntent() : null; @@ -1400,6 +1441,32 @@ public static class LegacyBridgeProtocol ? new LegacySelectIndustryIntent(index, doubleClick) : null; + private static LegacyUiIntent? ParseIndustryComparisonText(JsonElement payload) + { + if (!HasOnlyProperties(payload, "slot", "value") || + !TryString(payload, "slot", 16, out var slotText) || + !TrySafeString( + payload, + "value", + LegacyIndustrySelectionWorkflow.MaximumComparisonTextLength, + allowEmpty: true, + allowUnderscore: true, + out var value)) + { + return null; + } + + var slot = slotText switch + { + "first" => LegacyIndustryComparisonSlot.First, + "second" => LegacyIndustryComparisonSlot.Second, + _ => (LegacyIndustryComparisonSlot?)null + }; + return slot.HasValue + ? new LegacySetIndustryComparisonTextIntent(slot.Value, value) + : null; + } + private static LegacyUiIntent? ParseSwapIndustries(JsonElement payload) => HasOnlyProperties(payload) ? new LegacySwapIndustriesIntent() : null; @@ -1556,26 +1623,17 @@ public static class LegacyBridgeProtocol private static LegacyUiIntent? ParseThemeSearch(JsonElement payload) { - if (!HasOnlyProperties(payload, "query", "nxtSession") || + if (!HasOnlyProperties(payload, "query") || !TryString( payload, "query", LegacyThemeSelectionService.MaximumQueryLength, - out var query) || - !TryString(payload, "nxtSession", 16, out var sessionText)) + out var query)) { return null; } - var session = sessionText switch - { - "preMarket" => ThemeSession.PreMarket, - "afterMarket" => ThemeSession.AfterMarket, - _ => (ThemeSession?)null - }; - return session.HasValue - ? new LegacySearchThemesIntent(query, session.Value) - : null; + return new LegacySearchThemesIntent(query); } private static LegacyUiIntent? ParseThemeSelection(JsonElement payload) => @@ -1857,17 +1915,35 @@ public static class LegacyBridgeProtocol ? new LegacyRemoveOperatorCatalogDraftRowIntent(rowId) : null; - private static LegacyUiIntent? ParseOperatorCatalogDraftBuyAmount(JsonElement payload) => - HasOnlyProperties(payload, "rowId", "buyAmount") && + private static LegacyUiIntent? ParseOperatorCatalogDraftSelection(JsonElement payload) => + HasOnlyProperties(payload, "rowId") && TryString(payload, "rowId", 128, out var rowId) && - IsSafeIdentifier(rowId) && - payload.TryGetProperty("buyAmount", out var amountElement) && - amountElement.ValueKind == JsonValueKind.Number && - amountElement.TryGetDecimal(out var amount) && - decimal.Truncate(amount) == amount && - amount is > 0 and <= 9_007_199_254_740_991m + IsSafeIdentifier(rowId) + ? new LegacySelectOperatorCatalogDraftRowIntent(rowId) + : null; + + private static LegacyUiIntent? ParseOperatorCatalogDraftBuyAmount(JsonElement payload) + { + if (!HasOnlyProperties(payload, "rowId", "buyAmount") || + !TryString(payload, "rowId", 128, out var rowId) || + !IsSafeIdentifier(rowId) || + !payload.TryGetProperty("buyAmount", out var amountElement)) + { + return null; + } + + if (amountElement.ValueKind == JsonValueKind.Null) + { + return new LegacySetOperatorCatalogDraftBuyAmountIntent(rowId, null); + } + + return amountElement.ValueKind == JsonValueKind.Number && + amountElement.TryGetDecimal(out var amount) && + decimal.Truncate(amount) == amount && + amount is > 0 and <= 9_007_199_254_740_991m ? new LegacySetOperatorCatalogDraftBuyAmountIntent(rowId, amount) : null; + } private static LegacyUiIntent? ParseOperatorCatalogSave(JsonElement payload) => HasOnlyProperties(payload, "name") && @@ -1875,7 +1951,7 @@ public static class LegacyBridgeProtocol payload, "name", 128, - allowEmpty: false, + allowEmpty: true, allowUnderscore: true, out var name) && string.Equals(name, name.Trim(), StringComparison.Ordinal) diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs index e207c70..e993889 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs @@ -20,15 +20,13 @@ public sealed partial class MainWindow private MutableLegacySceneCueCompositionOptionsSource? _compositionOptions; private string? _playoutInitializationError; private string? _playoutInitializationWarning; - private CancellationTokenSource? _refreshCancellation; private Task? _refreshTask; + private readonly LegacyRefreshEpoch _refreshEpoch = + new(LegacyRefreshRuntimeStatus.Empty); private int _playoutBusy; private int _playoutQuarantined; private int _playoutShutdownStarted; private int _operatorMutationQuarantined; - private int _refreshCompletedCount; - private bool _refreshLimitReached; - private string _refreshMessage = string.Empty; private void InitializePlayoutRuntime() { @@ -37,6 +35,7 @@ public sealed partial class MainWindow var explicitLocalConfigurationExists = File.Exists( PlayoutOptionsLoader.DefaultPath); _playoutOptions = PlayoutOptionsLoader.Load(); + ResetRefreshState(); var startupComposition = LegacyParityStartupCompositionResolver.Resolve( _playoutOptions, explicitLocalConfigurationExists); @@ -103,7 +102,15 @@ public sealed partial class MainWindow if (string.IsNullOrWhiteSpace(args.Current.PreparedSceneName) && string.IsNullOrWhiteSpace(args.Current.OnAirSceneName)) { - StopRefreshLoop(); + if (args.Current.State == PlayoutConnectionState.OutcomeUnknown || + Volatile.Read(ref _playoutQuarantined) != 0) + { + StopRefreshLoop(); + } + else + { + ResetRefreshState(); + } } QueueOperatorState(); @@ -418,6 +425,7 @@ public sealed partial class MainWindow { var status = _playoutEngine?.Status; var workflow = _playoutWorkflow?.State; + var refresh = _refreshEpoch.ReadState(); return _playoutOptions is not null && _compositionOptions is not null && status is not null && workflow is not null && Volatile.Read(ref _playoutBusy) == 0 && @@ -428,6 +436,7 @@ public sealed partial class MainWindow string.IsNullOrWhiteSpace(status.OnAirSceneName) && string.IsNullOrWhiteSpace(workflow.PreparedCutCode) && string.IsNullOrWhiteSpace(workflow.OnAirCutCode) && + !refresh.IsActive && (_refreshTask is null || _refreshTask.IsCompleted); } @@ -435,6 +444,7 @@ public sealed partial class MainWindow { var status = _playoutEngine?.Status; var workflow = _playoutWorkflow?.State; + var refresh = _refreshEpoch.ReadState(); if (status is null || workflow is null) { // A disabled or unavailable playout runtime must not prevent the operator @@ -452,6 +462,7 @@ public sealed partial class MainWindow string.IsNullOrWhiteSpace(status.OnAirSceneName) && string.IsNullOrWhiteSpace(workflow.PreparedCutCode) && string.IsNullOrWhiteSpace(workflow.OnAirCutCode) && + !refresh.IsActive && (_refreshTask is null || _refreshTask.IsCompleted); } @@ -495,9 +506,11 @@ public sealed partial class MainWindow LegacyMoveOperatorCatalogDraftRowIntent or LegacyReorderOperatorCatalogDraftRowIntent or LegacyRemoveOperatorCatalogDraftRowIntent or + LegacyRemoveActiveOperatorCatalogDraftRowIntent or + LegacyClearOperatorCatalogDraftRowsIntent or LegacySetOperatorCatalogDraftBuyAmountIntent or LegacySaveOperatorCatalogIntent or - LegacyDeleteOperatorCatalogIntent or + LegacyConfirmNativeDialogIntent or LegacyLoadSelectedNamedPlaylistIntent or LegacyLoadNamedPlaylistByIdIntent or LegacyCreateNamedPlaylistIntent or @@ -548,6 +561,7 @@ public sealed partial class MainWindow { var status = _playoutEngine?.Status; var workflow = _playoutWorkflow?.State; + var refresh = _refreshEpoch.ReadState(); var composition = _compositionOptions?.Current ?? LegacySceneCueCompositionOptions.Default; var quarantined = Volatile.Read(ref _playoutQuarantined) != 0 || @@ -584,10 +598,10 @@ public sealed partial class MainWindow workflow?.IsLastPage ?? true, workflow?.NextKind ?? LegacyWorkflowNextKind.None, Volatile.Read(ref _playoutBusy) != 0, - _refreshTask is { IsCompleted: false }, - Volatile.Read(ref _refreshCompletedCount), - _playoutOptions?.MaximumAutomaticRefreshesPerTakeIn, - _refreshLimitReached, + refresh.IsActive, + refresh.CompletedRefreshCount, + refresh.MaximumRefreshCount, + refresh.IsLimitReached, composition.FadeDuration, backgroundEnabled, backgroundEnabled @@ -596,125 +610,310 @@ public sealed partial class MainWindow CanChangeComposition(), quarantined ? "결과 불명확 상태입니다. 명령을 반복하지 마세요." - : _refreshMessage.Length > 0 - ? _refreshMessage + : refresh.Message.Length > 0 + ? refresh.Message : _playoutInitializationWarning ?? status?.Message ?? _playoutInitializationError ?? "송출 어댑터를 사용할 수 없습니다."); return snapshot with { - IsTakeOutCompletionPending = status?.IsTakeOutCompletionPending ?? false + IsTakeOutCompletionPending = status?.IsTakeOutCompletionPending ?? false, + RefreshNextAtUtc = refresh.NextAtUtc, + RefreshLastSuccessAtUtc = refresh.LastSuccessAtUtc, + RefreshFaultCode = refresh.FaultCode }; } private void StartRefreshLoop(LegacyPlayoutWorkflow workflow) { - StopRefreshLoop(); - Interlocked.Exchange(ref _refreshCompletedCount, 0); - _refreshLimitReached = false; - _refreshMessage = string.Empty; + ResetRefreshState(); var cutCode = workflow.State.OnAirCutCode; if (!LegacySceneRefreshPolicy.TryGetInterval(cutCode, out var firstInterval) || - firstInterval <= TimeSpan.Zero) + firstInterval <= TimeSpan.Zero || + Volatile.Read(ref _playoutShutdownStarted) != 0) { return; } + var scheduler = new LegacyRefreshScheduler( + firstInterval, + LegacySceneRefreshPolicy.RecurringInterval, + _playoutOptions?.MaximumAutomaticRefreshesPerTakeIn); + if (scheduler.HasReachedMaximum) + { + _refreshTask = Task.CompletedTask; + _refreshEpoch.Stop(_ => LegacyRefreshRuntimeStatus.Empty with + { + MaximumRefreshCount = scheduler.MaximumRefreshCount, + IsLimitReached = true, + Message = $"자동 갱신 상한 {scheduler.MaximumRefreshCount}회가 설정되어 자동 갱신을 시작하지 않았습니다." + }); + QueueOperatorState(); + return; + } + var cancellation = CancellationTokenSource.CreateLinkedTokenSource( _lifetimeCancellation.Token); - _refreshCancellation = cancellation; - _refreshTask = RunRefreshLoopAsync(workflow, firstInterval, cancellation); + _refreshEpoch.Replace( + cancellation, + _ => new LegacyRefreshRuntimeStatus( + true, + null, + null, + false, + null, + string.Empty, + scheduler.CompletedRefreshCount, + scheduler.MaximumRefreshCount, + false)); + _refreshTask = RunRefreshLoopAsync(workflow, scheduler, cancellation); } - private void StopRefreshLoop() - { - var cancellation = Interlocked.Exchange(ref _refreshCancellation, null); - if (cancellation is not null) + private void StopRefreshLoop() => + _refreshEpoch.Stop(status => status with { - try - { - cancellation.Cancel(); - } - catch (ObjectDisposedException) - { - } - } - } + IsActive = false, + NextAtUtc = null + }); - private void ResetRefreshState() - { - StopRefreshLoop(); - Interlocked.Exchange(ref _refreshCompletedCount, 0); - _refreshLimitReached = false; - _refreshMessage = string.Empty; - } + private void ResetRefreshState() => + _refreshEpoch.Stop(_ => LegacyRefreshRuntimeStatus.Empty with + { + MaximumRefreshCount = _playoutOptions?.MaximumAutomaticRefreshesPerTakeIn + }); private async Task RunRefreshLoopAsync( LegacyPlayoutWorkflow workflow, - TimeSpan firstInterval, + LegacyRefreshScheduler scheduler, CancellationTokenSource cancellation) { var token = cancellation.Token; - var delay = firstInterval; - var maximum = _playoutOptions?.MaximumAutomaticRefreshesPerTakeIn; try { - while (!token.IsCancellationRequested) + while (true) { var engine = _playoutEngine; - if (engine is null) + if (engine is null || + !ReferenceEquals(_playoutWorkflow, workflow) || + !_refreshEpoch.IsCurrent(cancellation) || + Volatile.Read(ref _playoutShutdownStarted) != 0) { return; } - if (maximum is { } cap && Volatile.Read(ref _refreshCompletedCount) >= cap) + if (engine.Status.State == PlayoutConnectionState.OutcomeUnknown) { - if (!await WaitForPlayCompletionAsync(engine, token) - .ConfigureAwait(false)) + TrySetRefreshFault( + cancellation, + "PLAYOUT_OUTCOME_UNKNOWN", + "송출 상태가 불명확하여 자동 갱신을 시작하지 않았습니다."); + await QuarantinePlayoutAsync().ConfigureAwait(false); + return; + } + + if (scheduler.HasReachedMaximum) + { + if (!await scheduler.DrainMaximumCallbackAsync( + callbackToken => WaitForPlayCompletionAsync( + engine, + callbackToken), + token).ConfigureAwait(false)) { - _refreshMessage = - "자동 갱신 상한에 도달했지만 마지막 재생 완료 이벤트를 확인하지 못했습니다. PGM을 확인하세요."; + TrySetRefreshFault( + cancellation, + "PLAY_CALLBACK_TIMEOUT", + "자동 갱신 상한에 도달했지만 마지막 재생 완료 이벤트를 확인하지 못했습니다. 결과가 불명확하므로 PGM을 확인하세요."); + await QuarantinePlayoutAsync().ConfigureAwait(false); return; } - _refreshLimitReached = true; - _refreshMessage = $"자동 갱신 상한 {cap}회에 도달했습니다."; + if (engine.Status.State == PlayoutConnectionState.OutcomeUnknown) + { + TrySetRefreshFault( + cancellation, + "PLAY_CALLBACK_OUTCOME_UNKNOWN", + "마지막 재생 완료 상태가 불명확합니다. PGM과 Network Monitoring을 확인하세요."); + await QuarantinePlayoutAsync().ConfigureAwait(false); + return; + } + + _refreshEpoch.TryUpdate( + cancellation, + status => status with + { + IsActive = false, + NextAtUtc = null, + CompletedRefreshCount = scheduler.CompletedRefreshCount, + MaximumRefreshCount = scheduler.MaximumRefreshCount, + IsLimitReached = true, + Message = $"자동 갱신 상한 {scheduler.MaximumRefreshCount}회에 도달했습니다." + }); + QueueOperatorState(); return; } - if (!await WaitForPlayCompletionAsync(engine, token) - .ConfigureAwait(false)) + if (!await scheduler.WaitForNextRefreshAsync( + callbackToken => WaitForPlayCompletionAsync( + engine, + callbackToken), + scheduledDelay => + { + if (_refreshEpoch.TryUpdate( + cancellation, + status => status.IsActive + ? status with + { + NextAtUtc = DateTimeOffset.UtcNow.Add(scheduledDelay) + } + : status)) + { + QueueOperatorState(); + } + }, + token).ConfigureAwait(false)) { - _refreshMessage = "재생 완료 이벤트를 확인하지 못해 자동 갱신을 중단했습니다."; + TrySetRefreshFault( + cancellation, + "PLAY_CALLBACK_TIMEOUT", + "재생 완료 이벤트를 확인하지 못해 자동 갱신을 중단했습니다. 결과가 불명확하므로 PGM을 확인하세요."); + await QuarantinePlayoutAsync().ConfigureAwait(false); return; } - await Task.Delay(delay, token).ConfigureAwait(false); - await _playoutCommandGate.WaitAsync(token).ConfigureAwait(false); + if (!ReferenceEquals(_playoutWorkflow, workflow) || + !_refreshEpoch.IsCurrent(cancellation) || + Volatile.Read(ref _playoutShutdownStarted) != 0) + { + return; + } + + if (engine.Status.State == PlayoutConnectionState.OutcomeUnknown) + { + TrySetRefreshFault( + cancellation, + "PLAY_CALLBACK_OUTCOME_UNKNOWN", + "재생 완료 상태가 불명확하여 자동 갱신을 시작하지 않았습니다."); + await QuarantinePlayoutAsync().ConfigureAwait(false); + return; + } + + var intentGateEntered = false; + var playoutGateEntered = false; try { + // Match MainForm's single UI thread: Web DB work and the timer + // refresh are serialized in the same order as ProcessIntentAsync. + await _intentGate.WaitAsync(token).ConfigureAwait(false); + intentGateEntered = true; + _intentBusy = true; + QueueOperatorState(); + + await _playoutCommandGate.WaitAsync(token).ConfigureAwait(false); + playoutGateEntered = true; Interlocked.Exchange(ref _playoutBusy, 1); - var result = await workflow.RefreshOnAirAsync(token).ConfigureAwait(false); + QueueOperatorState(); + + if (!_refreshEpoch.IsCurrent(cancellation) || + Volatile.Read(ref _playoutShutdownStarted) != 0 || + engine.Status.State == PlayoutConnectionState.OutcomeUnknown) + { + if (engine.Status.State == PlayoutConnectionState.OutcomeUnknown) + { + TrySetRefreshFault( + cancellation, + "REFRESH_OUTCOME_UNKNOWN", + "송출 상태가 불명확하여 자동 갱신을 시작하지 않았습니다."); + await QuarantinePlayoutAsync().ConfigureAwait(false); + } + return; + } + + var result = await scheduler.ExecuteRefreshAndTrackSuccessAsync( + workflow.RefreshOnAirAsync, + static refreshResult => refreshResult.IsSuccess, + token).ConfigureAwait(false); if (!result.IsSuccess) { + TrySetRefreshFault( + cancellation, + RefreshFaultCode(result.Code), + "자동 장면 갱신을 중단했습니다. TAKE OUT 전 실제 데이터와 PGM을 확인하세요."); if (result.Code is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut) { await QuarantinePlayoutAsync().ConfigureAwait(false); } - _refreshMessage = - "자동 장면 갱신을 중단했습니다. TAKE OUT 전 PGM을 확인하세요."; return; } - Interlocked.Increment(ref _refreshCompletedCount); - delay = LegacySceneRefreshPolicy.RecurringInterval; + if (!_refreshEpoch.TryUpdate( + cancellation, + _ => new LegacyRefreshRuntimeStatus( + true, + null, + DateTimeOffset.UtcNow, + false, + null, + string.Empty, + scheduler.CompletedRefreshCount, + scheduler.MaximumRefreshCount, + false))) + { + return; + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return; + } + catch (DatabaseInfrastructureException) + { + TrySetRefreshFault( + cancellation, + "DATABASE_UNAVAILABLE", + "자동 장면 갱신 중 데이터베이스 조회가 실패했습니다. TAKE OUT 후 확인하세요."); + return; + } + catch (LegacySceneDataException) + { + TrySetRefreshFault( + cancellation, + "SCENE_DATA_INVALID", + "자동 장면 갱신 데이터가 유효하지 않습니다. TAKE OUT 후 확인하세요."); + return; + } + catch (ArgumentException) + { + TrySetRefreshFault( + cancellation, + "SCENE_ARGUMENT_INVALID", + "자동 장면 갱신 요청 데이터가 유효하지 않습니다. TAKE OUT 후 확인하세요."); + return; + } + catch + { + TrySetRefreshFault( + cancellation, + "REFRESH_OUTCOME_UNKNOWN", + "자동 갱신 결과가 불명확합니다. 명령을 반복하지 마세요."); + await QuarantinePlayoutAsync().ConfigureAwait(false); + return; } finally { - Interlocked.Exchange(ref _playoutBusy, 0); - _playoutCommandGate.Release(); + if (playoutGateEntered) + { + Interlocked.Exchange(ref _playoutBusy, 0); + _playoutCommandGate.Release(); + } + + if (intentGateEntered) + { + _intentBusy = false; + _intentGate.Release(); + } + QueueOperatorState(); } } @@ -724,21 +923,55 @@ public sealed partial class MainWindow } catch { + TrySetRefreshFault( + cancellation, + "REFRESH_SCHEDULER_UNKNOWN", + "자동 갱신 예약 상태를 확인할 수 없습니다. 명령을 반복하지 마세요."); await QuarantinePlayoutAsync().ConfigureAwait(false); - _refreshMessage = "자동 갱신 결과가 불명확합니다. 명령을 반복하지 마세요."; } finally { - if (ReferenceEquals(_refreshCancellation, cancellation)) - { - Interlocked.CompareExchange(ref _refreshCancellation, null, cancellation); - } - + var completedCurrentEpoch = _refreshEpoch.TryComplete( + cancellation, + status => status with + { + IsActive = false, + NextAtUtc = null + }); cancellation.Dispose(); - QueueOperatorState(); + if (completedCurrentEpoch) + { + QueueOperatorState(); + } } } + private bool TrySetRefreshFault( + CancellationTokenSource cancellation, + string code, + string message) => + _refreshEpoch.TryUpdate( + cancellation, + status => status with + { + IsActive = false, + NextAtUtc = null, + IsFaulted = true, + FaultCode = code, + Message = message + }); + + private static string RefreshFaultCode(PlayoutResultCode code) => code switch + { + PlayoutResultCode.OutcomeUnknown => "OUTCOME_UNKNOWN", + PlayoutResultCode.TimedOut => "TIMED_OUT", + PlayoutResultCode.Rejected => "REJECTED", + PlayoutResultCode.Cancelled => "CANCELLED", + PlayoutResultCode.Unavailable => "UNAVAILABLE", + PlayoutResultCode.Failed => "FAILED", + _ => "REFRESH_FAILED" + }; + private static async Task WaitForPlayCompletionAsync( IPlayoutEngine engine, CancellationToken cancellationToken) @@ -886,4 +1119,27 @@ public sealed partial class MainWindow // outcome-unknown session quarantined and will not send a duplicate BYE. } } + + private sealed record LegacyRefreshRuntimeStatus( + bool IsActive, + DateTimeOffset? NextAtUtc, + DateTimeOffset? LastSuccessAtUtc, + bool IsFaulted, + string? FaultCode, + string Message, + int CompletedRefreshCount, + int? MaximumRefreshCount, + bool IsLimitReached) + { + internal static LegacyRefreshRuntimeStatus Empty { get; } = new( + false, + null, + null, + false, + null, + string.Empty, + 0, + null, + false); + } } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs index b081a4a..c44b6f2 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs @@ -58,6 +58,7 @@ public sealed partial class MainWindow : Window IExpertSelectionService expertSelectionService; ITradingHaltSelectionService tradingHaltSelectionService; IOverseasIndustryIndexSearchService overseasIndustrySearchService; + IOverseasStockSearchService overseasStockSearchService; IWorldStockSearchService worldStockSearchService; IManualFinancialScreenService manualFinancialService; IStockSearchService manualFinancialStockSearchService; @@ -82,6 +83,8 @@ public sealed partial class MainWindow : Window _databaseRuntime.Executor); overseasIndustrySearchService = new LegacyOverseasIndustryIndexSearchService( _databaseRuntime.Executor); + overseasStockSearchService = new LegacyOverseasStockSearchService( + _databaseRuntime.Executor); worldStockSearchService = new LegacyWorldStockSearchService( _databaseRuntime.Executor); manualFinancialService = new LegacyManualFinancialScreenService( @@ -125,6 +128,8 @@ public sealed partial class MainWindow : Window initializationError); overseasIndustrySearchService = new UnavailableOverseasIndustryIndexSearchService( initializationError); + overseasStockSearchService = new UnavailableOverseasStockSearchService( + initializationError); worldStockSearchService = new UnavailableWorldStockSearchService( initializationError); manualFinancialService = new UnavailableManualFinancialScreenService( @@ -147,7 +152,7 @@ public sealed partial class MainWindow : Window _industryWorkflow = new LegacyIndustrySelectionWorkflow(industrySelectionService); var overseasWorkflow = new LegacyOverseasSelectionWorkflow( overseasIndustrySearchService, - worldStockSearchService); + overseasStockSearchService); var comparisonStore = new LegacyComparisonPairFileStore( Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @@ -372,6 +377,18 @@ public sealed partial class MainWindow : Window return; } + if (_controller.Current.Dialog?.IsConfirmation == true && + intent is not LegacyReadyIntent and + not LegacyConfirmNativeDialogIntent and + not LegacyCancelNativeDialogIntent) + { + // A native MessageBox is modal in the original application. Ignore + // every forged or already-posted background intent until the + // operator answers the currently visible Yes/No question. + PostState(_controller.Current); + return; + } + if (IsOperatorMutationIntent(intent) && IsOperatorMutationQuarantined()) { PostState(ReportIntentFailure( @@ -413,10 +430,8 @@ public sealed partial class MainWindow : Window LegacyOpenOperatorCatalogIntent or LegacyBeginUc4ThemeCatalogIntent or LegacyEditUc4SelectedThemeIntent or - LegacyDeleteUc4SelectedThemeIntent or LegacyBeginUc6ExpertCatalogIntent or LegacyEditUc6SelectedExpertIntent or - LegacyDeleteUc6SelectedExpertIntent or LegacySearchOperatorCatalogIntent or LegacySelectOperatorCatalogResultIntent or LegacyBeginCreateThemeCatalogIntent or @@ -426,7 +441,7 @@ public sealed partial class MainWindow : Window LegacySetOperatorCatalogDraftBuyAmountIntent or LegacyCheckOperatorCatalogThemeNameIntent or LegacySaveOperatorCatalogIntent or - LegacyDeleteOperatorCatalogIntent or + LegacyConfirmNativeDialogIntent or LegacyAddComparisonPairIntent or LegacyMoveComparisonPairIntent or LegacyDeleteSelectedComparisonPairIntent or @@ -655,6 +670,8 @@ public sealed partial class MainWindow : Window cancellationToken), LegacySelectIndustryIntent industry => _controller.SelectIndustry(industry.Index, industry.DoubleClick), + LegacySetIndustryComparisonTextIntent edit => + _controller.SetIndustryComparisonText(edit.Slot, edit.Value), LegacySwapIndustriesIntent => _controller.SwapIndustries(), LegacyActivateIndustryActionIntent action => @@ -724,7 +741,6 @@ public sealed partial class MainWindow : Window LegacySearchThemesIntent search => await _controller.SearchThemesAsync( search.Query, - search.NxtSession, cancellationToken), LegacySelectThemeIntent selection => await _controller.SelectThemeAsync( @@ -773,7 +789,7 @@ public sealed partial class MainWindow : Window LegacyDeleteUc6SelectedExpertIntent => await _controller.DeleteSelectedExpertCatalogAsync(cancellationToken), LegacyCloseOperatorCatalogIntent => - _controller.CloseOperatorCatalog(), + await _controller.CloseOperatorCatalogAsync(cancellationToken), LegacySearchOperatorCatalogIntent search => await _controller.SearchOperatorCatalogAsync( search.Query, @@ -810,6 +826,12 @@ public sealed partial class MainWindow : Window reorder.Position), LegacyRemoveOperatorCatalogDraftRowIntent remove => _controller.RemoveOperatorCatalogDraftRow(remove.RowId), + LegacySelectOperatorCatalogDraftRowIntent selection => + _controller.SelectOperatorCatalogDraftRow(selection.RowId), + LegacyRemoveActiveOperatorCatalogDraftRowIntent => + _controller.RemoveActiveOperatorCatalogDraftRow(), + LegacyClearOperatorCatalogDraftRowsIntent => + _controller.ClearOperatorCatalogDraftRows(), LegacySetOperatorCatalogDraftBuyAmountIntent amount => _controller.SetOperatorCatalogDraftBuyAmount( amount.RowId, @@ -822,8 +844,10 @@ public sealed partial class MainWindow : Window await _controller.SaveOperatorCatalogAsync( save.Name, cancellationToken), - LegacyDeleteOperatorCatalogIntent => - await _controller.DeleteOperatorCatalogAsync(cancellationToken), + LegacyConfirmNativeDialogIntent => + await _controller.ConfirmDialogAsync(cancellationToken), + LegacyCancelNativeDialogIntent => + _controller.CancelDialog(), LegacyRefreshNamedPlaylistsIntent refresh => await RefreshNamedPlaylistsForDispatchAsync( refresh.RequestId, @@ -1611,6 +1635,23 @@ public sealed partial class MainWindow : Window Task.FromException(new InvalidOperationException(_message)); } + private sealed class UnavailableOverseasStockSearchService : IOverseasStockSearchService + { + private readonly string _message; + + public UnavailableOverseasStockSearchService(string message) + { + _message = message; + } + + public Task SearchAsync( + string query, + int maximumResults = LegacyOverseasStockSearchService.DefaultMaximumResults, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + } + private sealed class UnavailableWorldStockSearchService : IWorldStockSearchService { private readonly string _message; diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js index 4f7bac8..cb4429d 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js @@ -20,8 +20,10 @@ const movingAverage20 = document.getElementById("moving-average-20"); const status = document.getElementById("operator-status"); const dialog = document.getElementById("legacy-dialog"); + const dialogCaption = document.getElementById("legacy-dialog-caption"); const dialogMessage = document.getElementById("legacy-dialog-message"); const dialogOk = document.getElementById("legacy-dialog-ok"); + const dialogNo = document.getElementById("legacy-dialog-no"); const dbSaveButton = document.getElementById("db-save-button"); const dbLoadButton = document.getElementById("db-load-button"); const namedPlaylistModal = document.getElementById("named-playlist-modal"); @@ -57,6 +59,9 @@ const operatorCatalogTitle = document.getElementById("operator-catalog-title"); const operatorCatalogClose = document.getElementById("operator-catalog-close"); const operatorCatalogStatus = document.getElementById("operator-catalog-status"); + const operatorCatalogToolbar = document.getElementById("operator-catalog-toolbar"); + const operatorCatalogBody = document.getElementById("operator-catalog-body"); + const operatorCatalogListPanel = document.getElementById("operator-catalog-list-panel"); const operatorCatalogThemeTab = document.getElementById("operator-catalog-theme-tab"); const operatorCatalogExpertTab = document.getElementById("operator-catalog-expert-tab"); const operatorCatalogQuery = document.getElementById("operator-catalog-query"); @@ -71,14 +76,17 @@ const operatorCatalogName = document.getElementById("operator-catalog-name"); const operatorCatalogNameCheck = document.getElementById("operator-catalog-name-check"); const operatorCatalogNameStatus = document.getElementById("operator-catalog-name-status"); + const operatorCatalogStockSearchLine = document.getElementById("operator-catalog-stock-search-line"); const operatorCatalogStockQuery = document.getElementById("operator-catalog-stock-query"); const operatorCatalogStockSearch = document.getElementById("operator-catalog-stock-search"); const operatorCatalogStockResults = document.getElementById("operator-catalog-stock-results"); const operatorCatalogAmountLabel = document.getElementById("operator-catalog-amount-label"); const operatorCatalogAmount = document.getElementById("operator-catalog-amount"); const operatorCatalogAddStock = document.getElementById("operator-catalog-add-stock"); + const operatorCatalogAddLine = document.getElementById("operator-catalog-add-line"); + const operatorCatalogDraftHeader = document.getElementById("operator-catalog-draft-header"); const operatorCatalogDraftRows = document.getElementById("operator-catalog-draft-rows"); - const operatorCatalogDelete = document.getElementById("operator-catalog-delete"); + const operatorCatalogDeleteAll = document.getElementById("operator-catalog-delete-all"); const operatorCatalogCancel = document.getElementById("operator-catalog-cancel"); const operatorCatalogSave = document.getElementById("operator-catalog-save"); const namedPlaylistDialog = namedPlaylistModal.querySelector("[role='dialog']"); @@ -114,7 +122,6 @@ let operatorCatalogNameDraft = ""; let operatorCatalogDraftKey = ""; let manualResultClickTimer = null; - let themeResultClickTimer = null; let comparisonTargetClickTimer = null; let manualViResultClickTimer = null; let operatorCatalogStockClickTimer = null; @@ -122,11 +129,9 @@ let nextNamedPlaylistRequestId = 1; let lastCommandSequence = 0; let operatorMutationLocked = true; - const playlistDragMime = "text/x-mbn-playlist-row"; const operatorCatalogDragMime = "text/x-mbn-operator-catalog-row"; - let draggedPlaylistRowId = null; let deferredPlaylistPlainSelectionRowId = null; - let playlistDragBlockedRowId = null; + let playlistDragGesture = null; let playlistMutationLocked = false; let cutSelectionEnabled = false; let cutDragSize = { width: 4, height: 4 }; @@ -138,6 +143,10 @@ let draggedOperatorCatalogRowId = null; let operatorCatalogDragBlockedRowId = null; let operatorCatalogMutationLocked = false; + let legacyDialogIsConfirmation = false; + let industryRenderStructureKey = ""; + let overseasRenderStructureKey = ""; + let themeRenderStructureKey = ""; let renderedTreeTabId = null; let treeInteractionLocked = false; let treeFocusOwnedBeforeRender = false; @@ -363,6 +372,16 @@ decorateTreeNodes(tabId); const details = Array.from(categoryTree.querySelectorAll("details[data-tree-section-key]")); const roots = Array.from(categoryTree.querySelectorAll("summary[data-tree-root]")); + if (pendingTreeResetTabs.has(tabId) && roots.length === 0) { + const originalFocusedControl = tabId === "expert" + ? categoryTree.querySelector("#expert-preview") + : null; + if (originalFocusedControl && !modalFocusManager.getActiveModal()) { + originalFocusedControl.focus({ preventScroll: true }); + } + pendingTreeResetTabs.delete(tabId); + return; + } if (pendingTreeResetTabs.has(tabId) && roots.length > 0) { details.forEach(function (section) { section.open = true; @@ -868,74 +887,131 @@ }); } - function onPlaylistDragStart(event) { - const row = event.currentTarget; - const rowId = row.dataset.rowId; - if (playlistMutationLocked || !rowId || !event.dataTransfer || - playlistDragBlockedRowId === rowId || event.target.closest("input, button")) { - playlistDragBlockedRowId = null; - event.preventDefault(); - return; - } - - playlistDragBlockedRowId = null; - deferredPlaylistPlainSelectionRowId = null; - draggedPlaylistRowId = rowId; - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData(playlistDragMime, rowId); - row.classList.add("dragging"); + function playlistPointerPosition(event) { + const bounds = playlistRows.getBoundingClientRect(); + return { + x: event.clientX - bounds.left, + y: event.clientY - bounds.top + }; } - function onPlaylistDragOver(event) { - const target = event.currentTarget; - if (playlistMutationLocked || !draggedPlaylistRowId || - target.dataset.rowId === draggedPlaylistRowId) return; - - event.preventDefault(); - if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; - const position = dragDropPosition(event, target); - target.classList.toggle("drag-before", position === "before"); - target.classList.toggle("drag-after", position === "after"); + function playlistRowHeaderAtPoint(event) { + if (typeof document.elementFromPoint !== "function") return null; + const pointed = document.elementFromPoint(event.clientX, event.clientY); + const header = pointed && typeof pointed.closest === "function" + ? pointed.closest(".playlist-row-header") : null; + return header && playlistRows.contains(header) ? header : null; } - function onPlaylistDragLeave(event) { - event.currentTarget.classList.remove("drag-before", "drag-after"); - } - - function onPlaylistDrop(event) { - const target = event.currentTarget; - const sourceRowId = draggedPlaylistRowId; - const targetRowId = target.dataset.rowId; - if (playlistMutationLocked || !sourceRowId || !targetRowId || - sourceRowId === targetRowId || !event.dataTransfer) { - clearPlaylistDragVisuals(); - draggedPlaylistRowId = null; - return; - } - - const token = event.dataTransfer.getData(playlistDragMime); - if (token !== sourceRowId) { - clearPlaylistDragVisuals(); - draggedPlaylistRowId = null; - return; - } - - event.preventDefault(); - const position = dragDropPosition(event, target); + function clearPlaylistDragGesture(releaseCapture) { + const gesture = playlistDragGesture; + if (!gesture) return; + playlistDragGesture = null; clearPlaylistDragVisuals(); - draggedPlaylistRowId = null; - send("reorder-playlist-rows", { - sourceRowId: sourceRowId, - targetRowId: targetRowId, - position: position + if (releaseCapture !== false && + typeof gesture.handle.hasPointerCapture === "function" && + gesture.handle.hasPointerCapture(gesture.pointerId) && + typeof gesture.handle.releasePointerCapture === "function") { + try { + gesture.handle.releasePointerCapture(gesture.pointerId); + } catch (_) { + // Chromium can release capture before the final cleanup callback. + } + } + } + + function beginPlaylistDragGesture(handle, row, event) { + clearPlaylistDragGesture(); + if (playlistMutationLocked || event.button !== 0 || !row.dataset.rowId) return; + const position = playlistPointerPosition(event); + playlistDragGesture = { + handle: handle, + row: row, + rowId: row.dataset.rowId, + pointerId: event.pointerId, + down: position, + rectangle: window.LegacyCutDrag.createDragRectangle(position, cutDragSize), + started: false, + targetRowId: null, + position: null + }; + if (typeof handle.setPointerCapture === "function") { + try { + handle.setPointerCapture(event.pointerId); + } catch (_) { + // Pointer capture can fail if WebView2 has already released the pointer. + } + } + } + + function onPlaylistPointerMove(event) { + const gesture = playlistDragGesture; + if (!gesture || gesture.handle !== event.currentTarget || + gesture.pointerId !== event.pointerId) return; + if ((event.buttons & 1) !== 1) { + deferredPlaylistPlainSelectionRowId = null; + clearPlaylistDragGesture(); + return; + } + if (!gesture.started && window.LegacyCutDrag.shouldStart( + gesture.rectangle, playlistPointerPosition(event))) { + gesture.started = true; + deferredPlaylistPlainSelectionRowId = null; + gesture.row.classList.add("dragging"); + } + if (!gesture.started) return; + + event.preventDefault(); + playlistRows.querySelectorAll(".drag-before, .drag-after").forEach(function (row) { + row.classList.remove("drag-before", "drag-after"); }); + const targetHeader = playlistRowHeaderAtPoint(event); + const targetRow = targetHeader ? targetHeader.closest(".playlist-data-row") : null; + if (!targetRow || targetRow === gesture.row) { + gesture.targetRowId = null; + gesture.position = null; + return; + } + + gesture.targetRowId = targetRow.dataset.rowId || null; + gesture.position = dragDropPosition(event, targetRow); + targetRow.classList.toggle("drag-before", gesture.position === "before"); + targetRow.classList.toggle("drag-after", gesture.position === "after"); } - function onPlaylistDragEnd() { - draggedPlaylistRowId = null; + function onPlaylistPointerUp(event) { + const gesture = playlistDragGesture; + if (!gesture || gesture.handle !== event.currentTarget || + gesture.pointerId !== event.pointerId) return; + const targetHeader = gesture.started ? playlistRowHeaderAtPoint(event) : null; + const targetRow = targetHeader ? targetHeader.closest(".playlist-data-row") : null; + const targetRowId = targetRow && targetRow !== gesture.row + ? targetRow.dataset.rowId || null : null; + const shouldReorder = gesture.started && !playlistMutationLocked && + targetRowId && targetRowId !== gesture.rowId; + const sourceRowId = gesture.rowId; + const position = targetRow ? dragDropPosition(event, targetRow) : null; + const wasStarted = gesture.started; + clearPlaylistDragGesture(); + if (!wasStarted) return; + deferredPlaylistPlainSelectionRowId = null; - playlistDragBlockedRowId = null; - clearPlaylistDragVisuals(); + event.preventDefault(); + if (shouldReorder) { + send("reorder-playlist-rows", { + sourceRowId: sourceRowId, + targetRowId: targetRowId, + position: position + }); + } + } + + function onPlaylistPointerCancel(event) { + const gesture = playlistDragGesture; + if (!gesture || gesture.handle !== event.currentTarget || + gesture.pointerId !== event.pointerId) return; + deferredPlaylistPlainSelectionRowId = null; + clearPlaylistDragGesture(event.type !== "lostpointercapture"); } function createPlaylistElement(row) { @@ -944,12 +1020,33 @@ element.dataset.rowId = row.rowId; element.setAttribute("role", "option"); element.tabIndex = -1; + element.draggable = false; + + const rowHeader = document.createElement("div"); + rowHeader.className = "playlist-row-header"; + rowHeader.setAttribute("role", "presentation"); + rowHeader.draggable = false; + rowHeader.addEventListener("pointerdown", function (event) { + // FarPoint reports a row-header press separately from normal data-cell + // selection. It changes the active row used by F8, but it does not + // rewrite MainForm's Ctrl/Shift deletion set. + event.stopPropagation(); + element.focus({ preventScroll: true }); + send("activate-playlist-row", { rowId: element.dataset.rowId }); + beginPlaylistDragGesture(rowHeader, element, event); + }); + rowHeader.addEventListener("pointermove", onPlaylistPointerMove); + rowHeader.addEventListener("pointerup", onPlaylistPointerUp); + rowHeader.addEventListener("pointercancel", onPlaylistPointerCancel); + rowHeader.addEventListener("lostpointercapture", onPlaylistPointerCancel); + element.appendChild(rowHeader); const enabledCell = document.createElement("div"); enabledCell.className = "enabled-cell"; const enabled = document.createElement("input"); enabled.type = "checkbox"; enabled.tabIndex = -1; + enabled.draggable = false; enabled.addEventListener("click", function (event) { if (playlistMutationLocked) event.preventDefault(); }); @@ -970,15 +1067,10 @@ element.addEventListener("pointerdown", function (event) { if (event.button !== 0) return; if (enabledCell.contains(event.target)) { - // Native dragstart targets the nearest draggable ancestor (the row), - // not necessarily the checkbox originally pressed. Remember the - // pointer origin so a checkbox gesture cannot reorder the row. - playlistDragBlockedRowId = element.dataset.rowId; element.focus({ preventScroll: true }); send("activate-playlist-row", { rowId: element.dataset.rowId }); return; } - playlistDragBlockedRowId = null; element.focus({ preventScroll: true }); const plainSelection = !event.ctrlKey && !event.shiftKey; const deferSelectedRowPlainClickForDrag = plainSelection && @@ -998,7 +1090,6 @@ }); element.addEventListener("pointerup", function (event) { if (enabledCell.contains(event.target)) { - playlistDragBlockedRowId = null; return; } if (event.button !== 0 || @@ -1011,25 +1102,23 @@ }); }); element.addEventListener("pointercancel", function () { - if (playlistDragBlockedRowId === element.dataset.rowId) { - playlistDragBlockedRowId = null; + if (deferredPlaylistPlainSelectionRowId === element.dataset.rowId) { + deferredPlaylistPlainSelectionRowId = null; } }); - element.addEventListener("dragstart", onPlaylistDragStart); - element.addEventListener("dragover", onPlaylistDragOver); - element.addEventListener("dragleave", onPlaylistDragLeave); - element.addEventListener("drop", onPlaylistDrop); - element.addEventListener("dragend", onPlaylistDragEnd); + element.addEventListener("dragstart", function (event) { + // Only the 30 px row header owns the pointer-capture reorder gesture. + // Suppress Chromium's native text/checkbox/row drag path completely. + event.preventDefault(); + }); return element; } function renderPlaylist(state) { playlistMutationLocked = isOperatorMutationLocked(state); if (playlistMutationLocked) { - draggedPlaylistRowId = null; deferredPlaylistPlainSelectionRowId = null; - playlistDragBlockedRowId = null; - clearPlaylistDragVisuals(); + clearPlaylistDragGesture(); } const existingRows = new Map(); let currentPlayoutRow = null; @@ -1051,14 +1140,28 @@ } const enabled = element.querySelector("input"); + const rowHeader = element.querySelector(".playlist-row-header"); + rowHeader.textContent = String(position + 1); + rowHeader.title = String(position + 1); + rowHeader.setAttribute("aria-label", String(position + 1) + "번 행 이동 핸들"); + rowHeader.dataset.dragEnabled = playlistMutationLocked ? "false" : "true"; enabled.checked = row.isEnabled; enabled.disabled = false; enabled.setAttribute("aria-disabled", playlistMutationLocked ? "true" : "false"); const isCandidate = row.isActive === true; + const isDragSource = playlistDragGesture?.started === true && + playlistDragGesture.rowId === row.rowId; + const dragTargetPosition = playlistDragGesture?.started === true && + playlistDragGesture.targetRowId === row.rowId + ? playlistDragGesture.position + : null; element.className = "playlist-row playlist-data-row" + (presentation.isOnAir ? " on-air" : "") + (presentation.isCurrent ? " playout-current" : "") + - (presentation.isSelected || isCandidate ? " selected" : ""); + (presentation.isSelected || isCandidate ? " selected" : "") + + (isDragSource ? " dragging" : "") + + (dragTargetPosition === "before" ? " drag-before" : "") + + (dragTargetPosition === "after" ? " drag-after" : ""); element.dataset.active = isCandidate ? "true" : "false"; element.setAttribute( "aria-selected", @@ -1066,11 +1169,11 @@ element.setAttribute( "aria-current", presentation.isCurrent ? "true" : "false"); - element.draggable = !playlistMutationLocked; + element.draggable = false; element.setAttribute("aria-disabled", playlistMutationLocked ? "true" : "false"); [row.marketText, row.stockName, row.graphicType, row.subtype, presentation.pageText] .forEach(function (text, cellIndex) { - const cell = element.children.item(cellIndex + 1); + const cell = element.children.item(cellIndex + 2); cell.textContent = text; cell.title = text; }); @@ -1084,6 +1187,10 @@ Array.from(playlistRows.children).forEach(function (element) { if (!retainedRowIds.has(element.dataset.rowId)) element.remove(); }); + if (playlistDragGesture && !retainedRowIds.has(playlistDragGesture.rowId)) { + deferredPlaylistPlainSelectionRowId = null; + clearPlaylistDragGesture(); + } presentCurrentPlaylistRow(currentPlayoutRow, currentPlayoutFocusKey); const interactionDisabled = playlistMutationLocked; @@ -1265,12 +1372,79 @@ categoryTree.replaceChildren(fragment); } + function markIndustryRowSelected(row) { + categoryTree.querySelectorAll("button[data-industry-index]").forEach(function (candidate) { + const selected = candidate === row; + candidate.classList.toggle("selected", selected); + candidate.setAttribute("aria-selected", selected ? "true" : "false"); + }); + const list = row.closest(".industry-list"); + if (list) list.setAttribute("aria-activedescendant", row.id); + } + + function selectIndustryRow(row) { + markIndustryRowSelected(row); + const list = row.closest(".industry-list"); + if (list) list.focus({ preventScroll: true }); + send("select-industry", { index: Number(row.dataset.industryIndex) }); + } + + function activateIndustryRow(row) { + markIndustryRowSelected(row); + const list = row.closest(".industry-list"); + if (list) list.focus({ preventScroll: true }); + send("double-click-industry", { index: Number(row.dataset.industryIndex) }); + } + function renderIndustry(state) { const industry = state.industry; if (!industry || !Array.isArray(industry.industries) || !Array.isArray(industry.actions)) { return; } + const structureKey = JSON.stringify({ + market: industry.market, + rows: industry.industries.map(function (row) { + return [row.index, row.name]; + }), + actions: industry.actions.map(function (action) { + return [action.actionId, action.section, action.label]; + }) + }); + const existingWorkspace = categoryTree.querySelector(".industry-workspace"); + if (existingWorkspace && structureKey === industryRenderStructureKey) { + const firstInput = existingWorkspace.querySelector('input[data-industry-slot="first"]'); + const secondInput = existingWorkspace.querySelector('input[data-industry-slot="second"]'); + if (firstInput && document.activeElement !== firstInput) { + firstInput.value = industry.firstName || ""; + } + if (secondInput && document.activeElement !== secondInput) { + secondInput.value = industry.secondName || ""; + } + const list = existingWorkspace.querySelector(".industry-list"); + let selectedRow = null; + existingWorkspace.querySelectorAll("button[data-industry-index]") + .forEach(function (row) { + const selected = Number(row.dataset.industryIndex) === industry.selectedIndex; + row.classList.toggle("selected", selected); + row.setAttribute("aria-selected", selected ? "true" : "false"); + if (selected) selectedRow = row; + }); + if (list && selectedRow) { + list.setAttribute("aria-activedescendant", selectedRow.id); + } else if (list) { + list.removeAttribute("aria-activedescendant"); + } + return; + } + + const previousList = categoryTree.querySelector(".industry-list"); + const previousScrollTop = previousList ? previousList.scrollTop : 0; + const focusedIndustry = document.activeElement && + document.activeElement.closest("button[data-industry-index]"); + const focusedIndustryIndex = focusedIndustry + ? focusedIndustry.dataset.industryIndex : null; + const workspace = document.createElement("div"); workspace.className = "industry-workspace"; const selector = document.createElement("section"); @@ -1278,48 +1452,56 @@ const slots = document.createElement("div"); slots.className = "industry-slots"; const first = document.createElement("input"); - first.readOnly = true; + first.id = "industry-comparison-first"; + first.dataset.industrySlot = "first"; + first.maxLength = 128; + first.autocomplete = "off"; first.value = industry.firstName || ""; first.setAttribute("aria-label", "첫 번째 업종"); const swap = document.createElement("button"); swap.type = "button"; swap.dataset.industrySwap = "true"; - swap.textContent = "↕ 교환"; + swap.textContent = "◀▶"; const second = document.createElement("input"); - second.readOnly = true; + second.id = "industry-comparison-second"; + second.dataset.industrySlot = "second"; + second.maxLength = 128; + second.autocomplete = "off"; second.value = industry.secondName || ""; second.setAttribute("aria-label", "두 번째 업종"); slots.append(first, swap, second); const list = document.createElement("div"); list.className = "industry-list"; list.setAttribute("role", "listbox"); + list.tabIndex = 0; industry.industries.forEach(function (row) { const button = document.createElement("button"); button.type = "button"; + button.id = "industry-row-" + row.index; button.dataset.industryIndex = String(row.index); + button.tabIndex = -1; + button.setAttribute("role", "option"); button.textContent = row.name; button.className = industry.selectedIndex === row.index ? "selected" : ""; button.setAttribute("aria-selected", industry.selectedIndex === row.index ? "true" : "false"); + if (industry.selectedIndex === row.index) { + list.setAttribute("aria-activedescendant", button.id); + } list.appendChild(button); }); - selector.append(slots, list); + const comparisonTitle = document.createElement("strong"); + comparisonTitle.textContent = "업종비교"; + selector.append(list, comparisonTitle, slots); const actionTree = document.createElement("section"); actionTree.className = "industry-action-tree"; - const sections = new Map(); + const group = document.createElement("details"); + group.open = catalogExpandAll.checked; + const summary = document.createElement("summary"); + summary.textContent = industry.market === "kospi" ? "[코스피]" : "[코스닥]"; + summary.dataset.industrySectionIndex = "0"; + group.appendChild(summary); industry.actions.forEach(function (action) { - let group = sections.get(action.section); - if (!group) { - group = document.createElement("details"); - group.open = catalogExpandAll.checked; - const summary = document.createElement("summary"); - summary.textContent = "[" + action.section + "]"; - summary.dataset.industrySectionIndex = String(sections.size); - summary.title = "더블클릭하면 직계 자식 전체를 원본 순서로 추가합니다."; - group.appendChild(summary); - sections.set(action.section, group); - actionTree.appendChild(group); - } const button = document.createElement("button"); button.type = "button"; button.className = "tree-item"; @@ -1327,130 +1509,257 @@ button.textContent = "▸ " + action.label; group.appendChild(button); }); + actionTree.appendChild(group); workspace.append(selector, actionTree); categoryTree.replaceChildren(workspace); + list.scrollTop = previousScrollTop; + if (focusedIndustryIndex !== null) { + const replacement = Array.from( + list.querySelectorAll("button[data-industry-index]")) + .find(function (row) { + return row.dataset.industryIndex === focusedIndustryIndex; + }); + if (replacement) replacement.focus({ preventScroll: true }); + } + industryRenderStructureKey = structureKey; + } + + function updateOverseasWorkspace(workspace, overseas, isBusy) { + [ + ["fixed", overseas.selectedFixedIndexId], + ["industry", overseas.industry.selectedId], + ["stock", overseas.stock.selectedId] + ].forEach(function (selection) { + const kind = selection[0]; + const selectedId = selection[1]; + const list = workspace.querySelector('[data-overseas-list="' + kind + '"]'); + if (!list) return; + list.setAttribute("aria-disabled", isBusy ? "true" : "false"); + let selected = null; + list.querySelectorAll("button").forEach(function (button) { + const id = kind === "fixed" + ? button.dataset.overseasTargetId : button.dataset.overseasSelectionId; + const isSelected = id === selectedId; + button.classList.toggle("selected", isSelected); + button.setAttribute("aria-selected", isSelected ? "true" : "false"); + button.disabled = isBusy; + if (isSelected) selected = button; + }); + if (selected) list.setAttribute("aria-activedescendant", selected.id); + else list.removeAttribute("aria-activedescendant"); + }); + + ["industry", "stock"].forEach(function (kind) { + const input = workspace.querySelector("#overseas-" + kind + "-search"); + if (input && document.activeElement !== input) input.value = overseas[kind].query || ""; + if (input) input.readOnly = isBusy; + const search = workspace.querySelector('button[data-overseas-search="' + kind + '"]'); + if (search) search.disabled = isBusy; + }); + + const actionStates = new Map(overseas.actions.map(function (action) { + return [action.actionId, action]; + })); + workspace.querySelectorAll("button[data-overseas-action-id]").forEach(function (button) { + const action = actionStates.get(button.dataset.overseasActionId); + if (!action) return; + button.disabled = isBusy || !action.isAvailable; + button.title = action.isAvailable ? "Add to playlist" : action.reason || "Select an item first."; + }); + } + + function markOverseasRowSelected(row) { + const list = row.closest("[data-overseas-list]"); + if (!list) return; + list.querySelectorAll("button").forEach(function (candidate) { + const selected = candidate === row; + candidate.classList.toggle("selected", selected); + candidate.setAttribute("aria-selected", selected ? "true" : "false"); + }); + list.setAttribute("aria-activedescendant", row.id); + } + + function selectOverseasRow(row) { + markOverseasRowSelected(row); + const list = row.closest("[data-overseas-list]"); + if (list) list.focus({ preventScroll: true }); + if (row.dataset.overseasTargetId) { + send("select-overseas-fixed-index", { targetId: row.dataset.overseasTargetId }); + return; + } + send(row.dataset.overseasResultKind === "stock" + ? "select-overseas-stock" : "select-overseas-industry", { + selectionId: row.dataset.overseasSelectionId + }); + } + + function moveOverseasListSelection(list, key) { + if (!list || list.getAttribute("aria-disabled") === "true") return false; + const rows = Array.from(list.querySelectorAll("button")); + if (rows.length === 0) return false; + const selectedIndex = rows.findIndex(function (row) { + return row.getAttribute("aria-selected") === "true"; + }); + let nextIndex; + if (key === "Home") nextIndex = 0; + else if (key === "End") nextIndex = rows.length - 1; + else if (selectedIndex < 0) nextIndex = 0; + else if (key === "ArrowUp") nextIndex = Math.max(0, selectedIndex - 1); + else nextIndex = Math.min(rows.length - 1, selectedIndex + 1); + if (nextIndex === selectedIndex) return false; + const nextRow = rows[nextIndex]; + selectOverseasRow(nextRow); + nextRow.scrollIntoView({ block: "nearest", inline: "nearest" }); + return true; } function renderOverseas(state) { const overseas = state.overseas; if (!overseas || !Array.isArray(overseas.fixedIndexTargets) || - !overseas.industry || !overseas.stock || !Array.isArray(overseas.actions)) { + !overseas.industry || !overseas.stock || !Array.isArray(overseas.actions)) return; + + const structureKey = JSON.stringify({ + fixed: overseas.fixedIndexTargets.map(function (row) { + return [row.targetId, row.displayName]; + }), + industry: (overseas.industry.results || []).map(function (row) { + return [row.selectionId, row.displayName]; + }), + industryTruncated: overseas.industry.isTruncated === true, + stock: (overseas.stock.results || []).map(function (row) { + return [row.selectionId, row.displayName]; + }), + stockTruncated: overseas.stock.isTruncated === true, + actions: overseas.actions.map(function (action) { + return [action.actionId, action.source, action.periodDays]; + }) + }); + const existingWorkspace = categoryTree.querySelector(".overseas-workspace"); + if (existingWorkspace && structureKey === overseasRenderStructureKey) { + updateOverseasWorkspace(existingWorkspace, overseas, state.isBusy === true); return; } - const workspace = document.createElement("div"); - workspace.className = "overseas-workspace"; - - const fixedPanel = document.createElement("section"); - fixedPanel.className = "overseas-panel overseas-fixed-panel"; - const fixedTitle = document.createElement("h3"); - fixedTitle.textContent = "해외지수"; - const fixedList = document.createElement("div"); - fixedList.className = "overseas-fixed-list"; - fixedList.setAttribute("role", "listbox"); - overseas.fixedIndexTargets.forEach(function (target) { + function actionButton(action, label) { const button = document.createElement("button"); button.type = "button"; - button.dataset.overseasTargetId = target.targetId; - button.textContent = target.displayName; - button.className = overseas.selectedFixedIndexId === target.targetId ? "selected" : ""; - button.setAttribute( - "aria-selected", - overseas.selectedFixedIndexId === target.targetId ? "true" : "false"); - button.disabled = state.isBusy === true; - fixedList.appendChild(button); - }); - fixedPanel.append(fixedTitle, fixedList); + button.dataset.overseasActionId = action.actionId; + button.textContent = label; + button.disabled = state.isBusy === true || !action.isAvailable; + button.title = action.isAvailable ? "Add to playlist" : action.reason || "Select an item first."; + return button; + } - function createSearchPanel(kind, title, model) { - const panel = document.createElement("section"); - panel.className = "overseas-panel overseas-search-panel"; + function periodButtons(source) { + const buttons = document.createElement("div"); + buttons.className = "overseas-period-actions"; + overseas.actions.filter(function (action) { + return action.source === source && action.periodDays; + }).forEach(function (action) { + buttons.appendChild(actionButton(action, action.periodDays + "일")); + }); + return buttons; + } + + function resultList(kind, model) { + const list = document.createElement("div"); + list.className = "overseas-results"; + list.dataset.overseasList = kind; + list.setAttribute("role", "listbox"); + list.tabIndex = 0; + list.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false"); + (model.results || []).forEach(function (row, index) { + const button = document.createElement("button"); + button.type = "button"; + button.id = "overseas-" + kind + "-row-" + index; + button.dataset.overseasResultKind = kind; + button.dataset.overseasSelectionId = row.selectionId; + button.tabIndex = -1; + button.textContent = row.displayName; + const selected = model.selectedId === row.selectionId; + button.className = selected ? "selected" : ""; + button.setAttribute("aria-selected", selected ? "true" : "false"); + button.disabled = state.isBusy === true; + if (selected) list.setAttribute("aria-activedescendant", button.id); + list.appendChild(button); + }); + return list; + } + + function searchGroup(kind, title, searchLabel, model, includePeriods) { + const group = document.createElement("section"); + group.className = "overseas-group overseas-search-group"; const heading = document.createElement("h3"); heading.textContent = title; - const line = document.createElement("div"); - line.className = "overseas-search-line"; + if (model.isTruncated === true) { + const truncation = document.createElement("span"); + truncation.className = "overseas-truncation"; + truncation.textContent = "검색 결과가 최대 500건으로 제한되었습니다."; + heading.appendChild(truncation); + } + const searchLine = document.createElement("div"); + searchLine.className = "overseas-search-line"; const input = document.createElement("input"); input.id = "overseas-" + kind + "-search"; input.value = model.query || ""; - input.placeholder = title + " 검색"; input.maxLength = 64; + input.setAttribute("aria-label", searchLabel); input.readOnly = state.isBusy === true; const search = document.createElement("button"); search.type = "button"; search.dataset.overseasSearch = kind; - search.textContent = "검색"; + search.textContent = searchLabel; search.disabled = state.isBusy === true; - line.append(input, search); - - const selected = document.createElement("div"); - selected.className = "overseas-selected"; - selected.textContent = model.selectedName ? "선택: " + model.selectedName : "선택 없음"; - - const results = document.createElement("div"); - results.className = "overseas-results"; - results.setAttribute("role", "listbox"); - (Array.isArray(model.results) ? model.results : []).forEach(function (row) { - const button = document.createElement("button"); - button.type = "button"; - button.dataset.overseasResultKind = kind; - button.dataset.overseasSelectionId = row.selectionId; - button.textContent = row.nationCode - ? row.displayName + " (" + row.nationCode + ")" - : row.displayName; - button.className = model.selectedId === row.selectionId ? "selected" : ""; - button.setAttribute( - "aria-selected", - model.selectedId === row.selectionId ? "true" : "false"); - button.disabled = state.isBusy === true; - results.appendChild(button); + searchLine.append(input, search); + const resultArea = document.createElement("div"); + resultArea.className = "overseas-result-area"; + resultArea.appendChild(resultList(kind, model)); + const actionColumn = periodButtons(kind); + const current = overseas.actions.find(function (action) { + return action.source === kind && !action.periodDays; }); - - const note = document.createElement("div"); - note.className = model.error ? "overseas-note error" : "overseas-note"; - note.textContent = model.error || - (model.isTruncated ? "검색 결과 일부만 표시합니다." : model.resultCount + "건"); - panel.append(heading, line, selected, results, note); - return panel; + if (current) actionColumn.prepend(actionButton(current, "1열판")); + if (includePeriods || current) resultArea.appendChild(actionColumn); + group.append(heading, searchLine, resultArea); + return group; } - const searches = document.createElement("div"); - searches.className = "overseas-searches"; - searches.append( - createSearchPanel("industry", "미국 해외업종", overseas.industry), - createSearchPanel("stock", "미국·대만 해외종목", overseas.stock)); - - const actionPanel = document.createElement("section"); - actionPanel.className = "overseas-panel overseas-action-panel"; - [ - ["fixedIndex", "해외지수 컷"], - ["industry", "해외업종 컷"], - ["stock", "해외종목 컷"] - ].forEach(function (groupDefinition) { - const actions = overseas.actions.filter(function (action) { - return action.source === groupDefinition[0]; - }); - const group = document.createElement("details"); - group.open = catalogExpandAll.checked; - const summary = document.createElement("summary"); - summary.textContent = "[" + groupDefinition[1] + "]"; - group.appendChild(summary); - actions.forEach(function (action) { - const button = document.createElement("button"); - button.type = "button"; - button.className = "tree-item" + (action.isAvailable ? "" : " unavailable"); - button.dataset.overseasActionId = action.actionId; - button.textContent = "▸ " + action.label; - button.title = action.isAvailable - ? "클릭하여 플레이리스트에 추가" - : "해당 지수·업종·종목을 먼저 선택하세요."; - button.disabled = !action.isAvailable || state.isBusy === true; - group.appendChild(button); - }); - actionPanel.appendChild(group); + const workspace = document.createElement("div"); + workspace.className = "overseas-workspace"; + const fixedGroup = document.createElement("section"); + fixedGroup.className = "overseas-group overseas-fixed-group"; + const fixedHeading = document.createElement("h3"); + fixedHeading.textContent = "해외지수"; + const fixedBody = document.createElement("div"); + fixedBody.className = "overseas-fixed-body"; + const fixedList = document.createElement("div"); + fixedList.className = "overseas-fixed-list"; + fixedList.dataset.overseasList = "fixed"; + fixedList.setAttribute("role", "listbox"); + fixedList.tabIndex = 0; + fixedList.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false"); + overseas.fixedIndexTargets.forEach(function (target, index) { + const button = document.createElement("button"); + button.type = "button"; + button.id = "overseas-fixed-row-" + index; + button.dataset.overseasTargetId = target.targetId; + button.tabIndex = -1; + button.textContent = target.displayName; + const selected = overseas.selectedFixedIndexId === target.targetId; + button.className = selected ? "selected" : ""; + button.setAttribute("aria-selected", selected ? "true" : "false"); + button.disabled = state.isBusy === true; + if (selected) fixedList.setAttribute("aria-activedescendant", button.id); + fixedList.appendChild(button); }); - - workspace.append(fixedPanel, searches, actionPanel); + fixedBody.append(fixedList, periodButtons("fixedIndex")); + fixedGroup.append(fixedHeading, fixedBody); + workspace.append( + fixedGroup, + searchGroup("industry", "해외 업종지수", "업종 검색", overseas.industry, false), + searchGroup("stock", "해외 종목", "종목 검색", overseas.stock, true)); categoryTree.replaceChildren(workspace); + overseasRenderStructureKey = structureKey; } function renderComparison(state) { @@ -1535,17 +1844,13 @@ const swap = document.createElement("button"); swap.type = "button"; swap.dataset.comparisonSwap = "true"; - swap.textContent = "↕ 교환"; - const clear = document.createElement("button"); - clear.type = "button"; - clear.dataset.comparisonClear = "true"; - clear.textContent = "초기화"; + swap.textContent = "◀▶"; const add = document.createElement("button"); add.type = "button"; add.dataset.comparisonAdd = "true"; - add.textContent = "비교쌍 저장"; + add.textContent = "추가"; add.disabled = !comparison.firstTarget || !comparison.secondTarget; - draft.append(first, swap, second, clear, add); + draft.append(first, swap, second, add); const saved = document.createElement("div"); saved.className = "comparison-saved"; @@ -1559,7 +1864,7 @@ }); const savedTools = document.createElement("div"); savedTools.className = "comparison-saved-tools"; - [["up", "↑"], ["down", "↓"]].forEach(function (item) { + [["up", "UP"], ["down", "DOWN"]].forEach(function (item) { const button = document.createElement("button"); button.type = "button"; button.dataset.comparisonMove = item[0]; @@ -1569,11 +1874,11 @@ const deleteSelected = document.createElement("button"); deleteSelected.type = "button"; deleteSelected.dataset.comparisonDelete = "selected"; - deleteSelected.textContent = "선택삭제"; + deleteSelected.textContent = "DEL"; const deleteAll = document.createElement("button"); deleteAll.type = "button"; deleteAll.dataset.comparisonDelete = "all"; - deleteAll.textContent = "전체삭제"; + deleteAll.textContent = "DEL ALL"; savedTools.append(deleteSelected, deleteAll); pairPanel.append(draft, saved, savedTools); @@ -1607,12 +1912,84 @@ categoryTree.replaceChildren(workspace); } + function themeActionIsClickable(action) { + return action.isAvailable === true || + (action.actionId === "five-row-expected" && + action.unavailableReason === "expected-theme-is-krx-only"); + } + + function updateThemeWorkspace(workspace, theme) { + const input = workspace.querySelector("#theme-search"); + if (input && document.activeElement !== input) input.value = theme.query || ""; + + const results = workspace.querySelector(".theme-results"); + let selectedResult = null; + results.querySelectorAll("button[data-theme-result-id]").forEach(function (button) { + const selected = button.dataset.themeResultId === theme.selectedResultId; + button.classList.toggle("selected", selected); + button.setAttribute("aria-selected", selected ? "true" : "false"); + if (selected) selectedResult = button; + }); + if (selectedResult) results.setAttribute("aria-activedescendant", selectedResult.id); + else results.removeAttribute("aria-activedescendant"); + + workspace.querySelectorAll("input[data-theme-sort]").forEach(function (radio) { + radio.checked = radio.value === theme.sortId; + }); + + const preview = workspace.querySelector(".theme-preview-list"); + const previewFragment = document.createDocumentFragment(); + theme.previewItems.forEach(function (row) { + const item = document.createElement("li"); + item.textContent = row.itemName; + previewFragment.appendChild(item); + }); + preview.replaceChildren(previewFragment); + + const actionStates = new Map(theme.actions.map(function (action) { + return [action.actionId, action]; + })); + workspace.querySelectorAll("button[data-theme-action-id]").forEach(function (button) { + const action = actionStates.get(button.dataset.themeActionId); + if (!action) return; + const warningHandledByNative = action.actionId === "five-row-expected" && + action.unavailableReason === "expected-theme-is-krx-only"; + button.disabled = !themeActionIsClickable(action); + button.title = warningHandledByNative + ? "NXT는 데이터가 없습니다." + : action.unavailableReason || "클릭하여 플레이리스트에 추가"; + }); + + const editTheme = workspace.querySelector("#uc4-theme-edit"); + const deleteTheme = workspace.querySelector("#uc4-theme-delete"); + editTheme.disabled = !theme.selectedTheme; + deleteTheme.disabled = !theme.selectedTheme; + } + function renderTheme(state) { const theme = state.theme; if (!theme || !Array.isArray(theme.searchResults) || !Array.isArray(theme.actions)) { return; } + const structureKey = JSON.stringify({ + query: theme.query || "", + results: theme.searchResults.map(function (row) { + return [row.resultId, row.displayTitle]; + }), + sorts: theme.sorts.map(function (row) { + return [row.id, row.label]; + }), + actions: theme.actions.map(function (action) { + return [action.actionId, action.label]; + }) + }); + const existingWorkspace = categoryTree.querySelector(".theme-workspace"); + if (existingWorkspace && structureKey === themeRenderStructureKey) { + updateThemeWorkspace(existingWorkspace, theme); + return; + } + const workspace = document.createElement("div"); workspace.className = "theme-workspace"; const search = document.createElement("div"); @@ -1622,64 +1999,74 @@ input.value = theme.query || ""; input.maxLength = 64; input.placeholder = "테마명"; - const session = document.createElement("select"); - session.id = "theme-nxt-session"; - [["preMarket", "NXT 장전"], ["afterMarket", "NXT 시간외"]].forEach(function (option) { - const element = document.createElement("option"); - element.value = option[0]; - element.textContent = option[1]; - session.appendChild(element); - }); - session.value = theme.nxtSession === "afterMarket" ? "afterMarket" : "preMarket"; + input.setAttribute("aria-label", "테마명"); const searchButton = document.createElement("button"); searchButton.type = "button"; searchButton.dataset.themeSearch = "true"; searchButton.textContent = "테마검색"; const addTheme = document.createElement("button"); addTheme.type = "button"; + addTheme.id = "uc4-theme-add"; addTheme.dataset.uc4ThemeCatalog = "create"; addTheme.textContent = "테마추가"; const editTheme = document.createElement("button"); editTheme.type = "button"; + editTheme.id = "uc4-theme-edit"; editTheme.dataset.uc4ThemeCatalog = "edit"; editTheme.textContent = "테마편집"; editTheme.disabled = !theme.selectedTheme; const deleteTheme = document.createElement("button"); deleteTheme.type = "button"; + deleteTheme.id = "uc4-theme-delete"; deleteTheme.dataset.uc4ThemeCatalog = "delete"; deleteTheme.textContent = "테마삭제"; deleteTheme.disabled = !theme.selectedTheme; - search.append(input, session, searchButton, addTheme, editTheme, deleteTheme); + search.append(input, searchButton, addTheme, editTheme, deleteTheme); - const body = document.createElement("div"); - body.className = "theme-body"; + const resultsPanel = document.createElement("section"); + resultsPanel.className = "theme-results-panel"; + const resultsTitle = document.createElement("strong"); + resultsTitle.className = "theme-column-title"; + resultsTitle.textContent = "테마명"; const results = document.createElement("div"); results.className = "theme-results"; results.setAttribute("role", "listbox"); - theme.searchResults.forEach(function (row) { + results.setAttribute("aria-label", "테마명"); + theme.searchResults.forEach(function (row, index) { const button = document.createElement("button"); button.type = "button"; + button.id = "theme-result-row-" + index; button.dataset.themeResultId = row.resultId; button.textContent = row.displayTitle; button.className = theme.selectedResultId === row.resultId ? "selected" : ""; + button.setAttribute("aria-selected", + theme.selectedResultId === row.resultId ? "true" : "false"); results.appendChild(button); }); + resultsPanel.append(resultsTitle, results); - const previewPanel = document.createElement("div"); - previewPanel.className = "theme-preview-panel"; - const selectedTitle = document.createElement("strong"); - selectedTitle.textContent = theme.selectedTheme - ? theme.selectedTheme.displayTitle + " · " + theme.selectedTheme.previewItemCount + "종목" - : "테마를 선택하세요."; - const sort = document.createElement("select"); - sort.dataset.themeSort = "true"; + const sortControls = document.createElement("fieldset"); + sortControls.className = "theme-sort-controls"; + const sortLegend = document.createElement("legend"); + sortLegend.textContent = "정렬"; + sortControls.appendChild(sortLegend); theme.sorts.forEach(function (row) { - const option = document.createElement("option"); - option.value = row.id; - option.textContent = row.label; - sort.appendChild(option); + const label = document.createElement("label"); + const radio = document.createElement("input"); + radio.type = "radio"; + radio.name = "theme-sort"; + radio.value = row.id; + radio.dataset.themeSort = "true"; + radio.checked = row.id === theme.sortId; + label.append(radio, document.createTextNode(row.label)); + sortControls.appendChild(label); }); - sort.value = theme.sortId; + + const previewPanel = document.createElement("section"); + previewPanel.className = "theme-preview-panel"; + const previewTitle = document.createElement("strong"); + previewTitle.className = "theme-column-title"; + previewTitle.textContent = "종목명"; const preview = document.createElement("ol"); preview.className = "theme-preview-list"; theme.previewItems.forEach(function (row) { @@ -1687,22 +2074,38 @@ item.textContent = row.itemName; preview.appendChild(item); }); - previewPanel.append(selectedTitle, sort, preview); - body.append(results, previewPanel); + previewPanel.append(previewTitle, preview); const actions = document.createElement("div"); actions.className = "theme-actions"; - theme.actions.forEach(function (action) { + const originalActionOrder = new Map([ + ["six-row-current", 0], + ["twelve-row-current", 1], + ["five-row-current", 2], + ["five-row-expected", 3] + ]); + const originalActionLabels = new Map([ + ["six-row-current", "6종목 현재가"], + ["twelve-row-current", "12종목 현재가"], + ["five-row-current", "현재가"], + ["five-row-expected", "예상체결가"] + ]); + theme.actions.slice().sort(function (left, right) { + return (originalActionOrder.get(left.actionId) ?? Number.MAX_SAFE_INTEGER) - + (originalActionOrder.get(right.actionId) ?? Number.MAX_SAFE_INTEGER); + }).forEach(function (action) { const button = document.createElement("button"); button.type = "button"; button.dataset.themeActionId = action.actionId; - button.textContent = action.label; - button.disabled = !action.isAvailable; - button.title = action.unavailableReason || "더블클릭하여 플레이리스트에 추가"; + button.textContent = originalActionLabels.get(action.actionId) || action.label; actions.appendChild(button); }); - workspace.append(search, body, actions); + // UC4 is a vertical native panel: search, theme rows, sort radios, + // action buttons, then the selected theme's item preview. + workspace.append(search, resultsPanel, sortControls, actions, previewPanel); categoryTree.replaceChildren(workspace); + themeRenderStructureKey = structureKey; + updateThemeWorkspace(workspace, theme); } function renderExpert(state) { @@ -1723,10 +2126,12 @@ searchButton.textContent = "조회"; const addExpert = document.createElement("button"); addExpert.type = "button"; + addExpert.id = "uc6-expert-add"; addExpert.dataset.uc6ExpertCatalog = "create"; addExpert.textContent = "전문가 추가"; const deleteExpert = document.createElement("button"); deleteExpert.type = "button"; + deleteExpert.id = "uc6-expert-delete"; deleteExpert.dataset.uc6ExpertCatalog = "delete"; deleteExpert.textContent = "전문가 삭제"; deleteExpert.disabled = !expert.selectedExpert; @@ -1746,7 +2151,9 @@ results.appendChild(button); }); const preview = document.createElement("div"); + preview.id = "expert-preview"; preview.className = "expert-preview"; + preview.tabIndex = 0; const heading = document.createElement("strong"); heading.textContent = expert.selectedExpert ? expert.selectedExpert.expertName + " 추천 종목" @@ -1772,6 +2179,7 @@ }); const editRecommendations = document.createElement("button"); editRecommendations.type = "button"; + editRecommendations.id = "uc6-expert-edit"; editRecommendations.dataset.uc6ExpertCatalog = "edit"; editRecommendations.textContent = "추천행 편집·저장·삭제"; editRecommendations.disabled = !expert.selectedExpert; @@ -2788,11 +3196,16 @@ operatorCatalogModal.hidden = false; const isTheme = catalog.entity === "theme"; const editing = catalog.mode === "create" || catalog.mode === "edit"; - operatorCatalogTitle.textContent = isTheme ? "ThemeA 관리" : "EList 관리"; + operatorCatalogTitle.textContent = isTheme + ? "테마추가" + : catalog.mode === "create" ? "전문가 추가" : "전문가 추천 편집"; operatorCatalogThemeTab.classList.toggle("selected", isTheme); operatorCatalogExpertTab.classList.toggle("selected", !isTheme); - operatorCatalogThemeTab.disabled = busy; - operatorCatalogExpertTab.disabled = busy; + operatorCatalogThemeTab.disabled = busy || editing; + operatorCatalogExpertTab.disabled = busy || editing; + operatorCatalogToolbar.hidden = editing; + operatorCatalogListPanel.hidden = editing; + operatorCatalogBody.classList.toggle("editor-only", editing); operatorCatalogClose.disabled = busy; operatorCatalogSearch.disabled = busy; operatorCatalogSession.disabled = busy || !isTheme; @@ -2842,7 +3255,8 @@ ? "코드 " + catalog.codeLabel : ""; operatorCatalogNameLabel.textContent = isTheme ? "테마명" : "전문가명"; operatorCatalogName.placeholder = isTheme ? "ThemeA 이름" : "전문가 이름"; - operatorCatalogName.disabled = busy || !editing; + operatorCatalogName.disabled = busy || !editing || + (!isTheme && catalog.mode === "edit"); const currentThemeName = operatorCatalogNameDraft.trim(); const checkedCurrentThemeName = isTheme && currentThemeName.length > 0 && catalog.checkedThemeName === currentThemeName; @@ -2869,9 +3283,17 @@ } operatorCatalogStockQuery.disabled = busy || !editing; operatorCatalogStockSearch.disabled = busy || !editing; - operatorCatalogAmountLabel.hidden = isTheme; - operatorCatalogAmount.hidden = isTheme; - operatorCatalogAmount.disabled = busy || !editing || isTheme; + const showCatalogRows = isTheme || catalog.mode === "edit"; + operatorCatalogStockSearchLine.hidden = !showCatalogRows; + operatorCatalogStockResults.hidden = !showCatalogRows; + operatorCatalogAddLine.hidden = !showCatalogRows; + operatorCatalogDraftHeader.hidden = !showCatalogRows; + operatorCatalogDraftRows.hidden = !showCatalogRows; + // ThemeA and UC6 both add the selected stock immediately. UC6 then lets + // the operator fill the buy amount in column 3 of the newly added row. + operatorCatalogAmountLabel.hidden = true; + operatorCatalogAmount.hidden = true; + operatorCatalogAmount.disabled = true; operatorCatalogAddStock.disabled = busy || catalog.canAddStock !== true; const stockFragment = document.createDocumentFragment(); @@ -2893,11 +3315,19 @@ }); operatorCatalogStockResults.replaceChildren(stockFragment); + const focusedDraftRow = document.activeElement && + document.activeElement.closest("[data-operator-catalog-row-id]"); + const restoreDraftFocus = focusedDraftRow && + operatorCatalogDraftRows.contains(focusedDraftRow); const draftFragment = document.createDocumentFragment(); (catalog.draftRows || []).forEach(function (row, index) { const line = document.createElement("div"); - line.className = "operator-catalog-draft-row"; + const isActive = row.rowId === catalog.activeDraftRowId; + line.className = "operator-catalog-draft-row" + (isActive ? " selected" : ""); line.dataset.operatorCatalogRowId = row.rowId; + line.tabIndex = isActive || (!catalog.activeDraftRowId && index === 0) ? 0 : -1; + line.setAttribute("role", "row"); + line.setAttribute("aria-selected", isActive ? "true" : "false"); line.draggable = !operatorCatalogMutationLocked; line.setAttribute( "aria-disabled", @@ -2910,6 +3340,20 @@ line.addEventListener("dragleave", onOperatorCatalogDragLeave); line.addEventListener("drop", onOperatorCatalogDrop); line.addEventListener("dragend", onOperatorCatalogDragEnd); + let selectionSent = false; + function selectDraftRow() { + if (!selectionSent && row.rowId !== catalog.activeDraftRowId) { + selectionSent = true; + send("select-operator-catalog-draft-row", { rowId: row.rowId }); + } + } + line.addEventListener("focus", selectDraftRow); + line.addEventListener("click", function (event) { + if (!event.target.closest("button, input")) { + line.focus({ preventScroll: true }); + selectDraftRow(); + } + }); const order = document.createElement("span"); order.textContent = String(row.position + 1); const name = document.createElement("strong"); @@ -2922,35 +3366,45 @@ detail.min = "1"; detail.step = "1"; detail.inputMode = "numeric"; - detail.value = String(row.buyAmount); + detail.value = row.buyAmount == null ? "" : String(row.buyAmount); detail.dataset.operatorCatalogBuyAmountRowId = row.rowId; detail.setAttribute("aria-label", row.displayName + " 매수가"); detail.disabled = busy; } const up = document.createElement("button"); up.type = "button"; - up.textContent = "↑"; + up.textContent = "Up"; up.dataset.operatorCatalogMoveRowId = row.rowId; up.dataset.operatorCatalogMoveDirection = "up"; up.disabled = busy || index === 0; const down = document.createElement("button"); down.type = "button"; - down.textContent = "↓"; + down.textContent = "Down"; down.dataset.operatorCatalogMoveRowId = row.rowId; down.dataset.operatorCatalogMoveDirection = "down"; down.disabled = busy || index + 1 === catalog.draftRows.length; const remove = document.createElement("button"); remove.type = "button"; - remove.textContent = isTheme ? "삭제" : "종목삭제"; + remove.textContent = "Delete"; remove.dataset.operatorCatalogRemoveRowId = row.rowId; remove.disabled = busy; line.append(order, name, detail, up, down, remove); draftFragment.appendChild(line); }); operatorCatalogDraftRows.replaceChildren(draftFragment); + if (restoreDraftFocus && catalog.activeDraftRowId) { + const activeRow = Array.from( + operatorCatalogDraftRows.querySelectorAll("[data-operator-catalog-row-id]")) + .find(function (row) { + return row.dataset.operatorCatalogRowId === catalog.activeDraftRowId; + }); + if (activeRow) activeRow.focus({ preventScroll: true }); + } operatorCatalogSave.disabled = busy || catalog.canSave !== true; - operatorCatalogDelete.disabled = busy || catalog.canDelete !== true; + operatorCatalogDeleteAll.hidden = !isTheme || !editing; + operatorCatalogDeleteAll.disabled = busy || !isTheme || !editing || + (catalog.draftRows || []).length === 0; operatorCatalogCancel.disabled = busy || !editing; const messages = []; if (catalog.statusMessage) messages.push(catalog.statusMessage); @@ -2967,10 +3421,20 @@ function renderDialog(state) { if (state.dialog && state.dialog.message) { + legacyDialogIsConfirmation = state.dialog.isConfirmation === true; + dialogCaption.textContent = state.dialog.caption || "MmoneyCoder"; dialogMessage.textContent = state.dialog.message; + dialogOk.textContent = legacyDialogIsConfirmation ? "예" : "확인"; + dialogOk.disabled = state.isBusy === true; + dialogNo.hidden = !legacyDialogIsConfirmation; + dialogNo.disabled = state.isBusy === true; if (dialog.hidden) rememberModalOpener(legacyAlertDialog); dialog.hidden = false; } else { + legacyDialogIsConfirmation = false; + dialogOk.disabled = false; + dialogNo.hidden = true; + dialogNo.disabled = false; dialog.hidden = true; } } @@ -2995,6 +3459,11 @@ cutDragGesture.down, nextDragSize); } + if (playlistDragGesture && !playlistDragGesture.started) { + playlistDragGesture.rectangle = window.LegacyCutDrag.createDragRectangle( + playlistDragGesture.down, + nextDragSize); + } } function render(state) { @@ -3335,11 +3804,8 @@ }); operatorCatalogNameCheck.addEventListener("click", function () { const name = operatorCatalogName.value.trim(); - if (!name) { - operatorCatalogName.setCustomValidity("이름을 입력하세요."); - operatorCatalogName.reportValidity(); - return; - } + // ThemeA's duplicate-check button silently returned for an empty name. + if (!name) return; operatorCatalogName.setCustomValidity(""); operatorCatalogNameDraft = name; @@ -3362,11 +3828,9 @@ event.preventDefault(); clearDelayedClick(operatorCatalogStockClickTimer); operatorCatalogStockClickTimer = null; - const amount = readOperatorCatalogBuyAmount(); - if (!amount.valid) return; send("activate-operator-catalog-stock", { resultId: row.dataset.operatorCatalogStockResultId, - buyAmount: amount.value + buyAmount: null }); }); operatorCatalogStockResults.addEventListener("click", function (event) { @@ -3379,25 +3843,8 @@ send("select-operator-catalog-stock", { resultId: resultId }); }, 250); }); - function readOperatorCatalogBuyAmount() { - if (!operatorCatalogState) return { valid: false, value: null }; - if (operatorCatalogState.entity === "expert") { - const amount = Number(operatorCatalogAmount.value); - if (!Number.isSafeInteger(amount) || amount <= 0) { - operatorCatalogAmount.setCustomValidity("매수가는 1 이상의 정수여야 합니다."); - operatorCatalogAmount.reportValidity(); - return { valid: false, value: null }; - } - operatorCatalogAmount.setCustomValidity(""); - return { valid: true, value: amount }; - } - return { valid: true, value: null }; - } operatorCatalogAddStock.addEventListener("click", function () { - const amount = readOperatorCatalogBuyAmount(); - if (amount.valid) { - send("add-operator-catalog-stock", { buyAmount: amount.value }); - } + send("add-operator-catalog-stock", { buyAmount: null }); }); operatorCatalogDraftRows.addEventListener("click", function (event) { const move = event.target.closest("button[data-operator-catalog-move-row-id]"); @@ -3418,7 +3865,16 @@ operatorCatalogDraftRows.addEventListener("change", function (event) { const input = event.target.closest("input[data-operator-catalog-buy-amount-row-id]"); if (!input || input.disabled) return; - const amount = Number(input.value); + const text = input.value.trim(); + if (!text) { + input.setCustomValidity(""); + send("set-operator-catalog-draft-buy-amount", { + rowId: input.dataset.operatorCatalogBuyAmountRowId, + buyAmount: null + }); + return; + } + const amount = Number(text); if (!Number.isSafeInteger(amount) || amount <= 0) { input.setCustomValidity("매수가는 1 이상의 정수여야 합니다."); input.reportValidity(); @@ -3433,22 +3889,29 @@ operatorCatalogSave.addEventListener("click", function () { const name = operatorCatalogName.value.trim(); if (!name) { - operatorCatalogName.setCustomValidity("이름을 입력하세요."); - operatorCatalogName.reportValidity(); + // EList showed an exact one-button message; ThemeA's empty-name path did + // not own that message and is kept as a no-op here. + if (operatorCatalogState && operatorCatalogState.entity === "expert") { + send("save-operator-catalog", { name: "" }); + } return; } operatorCatalogName.setCustomValidity(""); operatorCatalogNameDraft = name; send("save-operator-catalog", { name: name }); }); - operatorCatalogDelete.addEventListener("click", function () { - if (window.confirm("선택한 ThemeA/EList 항목을 DB에서 삭제하시겠습니까?")) { - send("delete-operator-catalog", {}); + operatorCatalogDeleteAll.addEventListener("click", function () { + if (!operatorCatalogState || operatorCatalogState.entity !== "theme" || + (operatorCatalogState.mode !== "create" && operatorCatalogState.mode !== "edit")) { + return; } + send("clear-operator-catalog-draft-rows", {}); }); operatorCatalogCancel.addEventListener("click", function () { operatorCatalogDraftKey = ""; - searchOperatorCatalog(); + clearDelayedClick(operatorCatalogStockClickTimer); + operatorCatalogStockClickTimer = null; + send("close-operator-catalog", {}); }); playlistMoveUp.addEventListener("click", function () { @@ -3537,8 +4000,6 @@ const themeResult = event.target.closest("button[data-theme-result-id]"); if (themeResult && !themeResult.disabled) { event.preventDefault(); - clearDelayedClick(themeResultClickTimer); - themeResultClickTimer = null; send("activate-theme-result", { resultId: themeResult.dataset.themeResultId }); @@ -3590,7 +4051,7 @@ } const industryRow = event.target.closest("button[data-industry-index]"); if (industryRow) { - send("double-click-industry", { index: Number(industryRow.dataset.industryIndex) }); + activateIndustryRow(industryRow); return; } const industryAction = event.target.closest("button[data-industry-action-id]"); @@ -3643,8 +4104,7 @@ send("begin-uc4-theme-catalog", {}); } else if (action === "edit") { send("edit-uc4-selected-theme", {}); - } else if (action === "delete" && - window.confirm("선택한 테마를 삭제하겠습니까?")) { + } else if (action === "delete") { send("delete-uc4-selected-theme", {}); } return; @@ -3656,8 +4116,7 @@ send("begin-uc6-expert-catalog", {}); } else if (action === "edit") { send("edit-uc6-selected-expert", {}); - } else if (action === "delete" && - window.confirm("선택한 전문가를 삭제하겠습니까?")) { + } else if (action === "delete") { send("delete-uc6-selected-expert", {}); } return; @@ -3695,18 +4154,13 @@ return; } const overseasTarget = event.target.closest("button[data-overseas-target-id]"); - if (overseasTarget) { - send("select-overseas-fixed-index", { - targetId: overseasTarget.dataset.overseasTargetId - }); + if (overseasTarget && !overseasTarget.disabled) { + selectOverseasRow(overseasTarget); return; } const overseasResult = event.target.closest("button[data-overseas-selection-id]"); - if (overseasResult) { - send(overseasResult.dataset.overseasResultKind === "stock" - ? "select-overseas-stock" : "select-overseas-industry", { - selectionId: overseasResult.dataset.overseasSelectionId - }); + if (overseasResult && !overseasResult.disabled) { + selectOverseasRow(overseasResult); return; } const overseasSearch = event.target.closest("button[data-overseas-search]"); @@ -3776,10 +4230,8 @@ const comparisonDelete = event.target.closest("button[data-comparison-delete]"); if (comparisonDelete) { const all = comparisonDelete.dataset.comparisonDelete === "all"; - if (!all || window.confirm("저장된 종목 비교쌍을 모두 삭제하겠습니까?")) { - send(all ? "delete-all-comparison-pairs" : - "delete-selected-comparison-pair", {}); - } + send(all ? "delete-all-comparison-pairs" : + "delete-selected-comparison-pair", {}); return; } const expertResult = event.target.closest("button[data-expert-selection-id]"); @@ -3808,26 +4260,17 @@ } const themeResult = event.target.closest("button[data-theme-result-id]"); if (themeResult) { - clearDelayedClick(themeResultClickTimer); - const resultId = themeResult.dataset.themeResultId; - themeResultClickTimer = window.setTimeout(function () { - themeResultClickTimer = null; - send("select-theme", { resultId: resultId }); - }, 250); + send("select-theme", { resultId: themeResult.dataset.themeResultId }); return; } if (event.target.closest("button[data-theme-search]")) { const input = categoryTree.querySelector("#theme-search"); - const session = categoryTree.querySelector("#theme-nxt-session"); - send("search-themes", { - query: input ? input.value : "", - nxtSession: session ? session.value : "preMarket" - }); + send("search-themes", { query: input ? input.value : "" }); return; } const industryRow = event.target.closest("button[data-industry-index]"); if (industryRow) { - send("select-industry", { index: Number(industryRow.dataset.industryIndex) }); + selectIndustryRow(industryRow); return; } if (event.target.closest("button[data-industry-swap]")) { @@ -3835,48 +4278,76 @@ } }); categoryTree.addEventListener("change", function (event) { - if (event.target.matches("select[data-theme-sort]")) { + if (event.target.matches("input[data-industry-slot]")) { + send("set-industry-comparison-text", { + slot: event.target.dataset.industrySlot, + value: event.target.value + }); + } else if (event.target.matches("input[data-theme-sort]")) { send("set-theme-sort", { sortId: event.target.value }); } }); categoryTree.addEventListener("keydown", function (event) { - const treeRoot = event.target.closest("summary[data-tree-root]"); - if (treeRoot && ["Enter", " ", "ArrowLeft", "ArrowRight"].includes(event.key)) { + const overseasList = event.target.closest("[data-overseas-list]"); + if (overseasList && ["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) { event.preventDefault(); - if (treeInteractionLocked || treeRoot.getAttribute("aria-disabled") === "true") return; - selectTreeNode(treeRoot, true); - const details = treeRoot.parentElement; - if (event.key === "ArrowLeft") details.open = false; - else if (event.key === "ArrowRight") details.open = true; - syncTreeSectionExpanded(details); - } else if (event.key === "Enter" && - (event.target.id === "overseas-industry-search" || - event.target.id === "overseas-stock-search")) { + event.stopPropagation(); + moveOverseasListSelection(overseasList, event.key); + return; + } + const industryList = event.target.closest(".industry-list"); + if (industryList && ["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) { event.preventDefault(); - const kind = event.target.id === "overseas-stock-search" ? "stock" : "industry"; - send(kind === "stock" ? "search-overseas-stocks" : "search-overseas-industries", { - query: event.target.value + const rows = Array.from( + industryList.querySelectorAll("button[data-industry-index]")); + if (rows.length === 0) return; + const selectedIndex = rows.findIndex(function (row) { + return row.getAttribute("aria-selected") === "true"; }); - } else if (event.key === "Enter" && + let nextIndex; + if (event.key === "Home") nextIndex = 0; + else if (event.key === "End") nextIndex = rows.length - 1; + else if (selectedIndex < 0) nextIndex = 0; + else if (event.key === "ArrowUp") nextIndex = Math.max(0, selectedIndex - 1); + else nextIndex = Math.min(rows.length - 1, selectedIndex + 1); + const nextRow = rows[nextIndex]; + selectIndustryRow(nextRow); + nextRow.scrollIntoView({ block: "nearest", inline: "nearest" }); + } else { + const treeRoot = event.target.closest("summary[data-tree-root]"); + if (treeRoot && ["Enter", " ", "ArrowLeft", "ArrowRight"].includes(event.key)) { + event.preventDefault(); + if (treeInteractionLocked || treeRoot.getAttribute("aria-disabled") === "true") return; + selectTreeNode(treeRoot, true); + const details = treeRoot.parentElement; + if (event.key === "ArrowLeft") details.open = false; + else if (event.key === "ArrowRight") details.open = true; + syncTreeSectionExpanded(details); + } else if (event.key === "Enter" && + (event.target.id === "overseas-industry-search" || + event.target.id === "overseas-stock-search")) { + event.preventDefault(); + const kind = event.target.id === "overseas-stock-search" ? "stock" : "industry"; + send(kind === "stock" ? "search-overseas-stocks" : "search-overseas-industries", { + query: event.target.value + }); + } else if (event.key === "Enter" && (event.target.id === "comparison-domestic-search" || event.target.id === "comparison-world-search")) { - event.preventDefault(); - const kind = event.target.id === "comparison-world-search" ? "world" : "domestic"; - send(kind === "world" ? "search-comparison-world" : - "search-comparison-domestic", { query: event.target.value }); - } else if (event.key === "Enter" && event.target.id === "theme-search") { - event.preventDefault(); - const session = categoryTree.querySelector("#theme-nxt-session"); - send("search-themes", { - query: event.target.value, - nxtSession: session ? session.value : "preMarket" - }); - } else if (event.key === "Enter" && event.target.id === "expert-search") { - event.preventDefault(); - send("search-experts", { query: event.target.value }); - } else if (event.key === "Enter" && event.target.id === "halt-search") { - event.preventDefault(); - send("search-trading-halts", { query: event.target.value }); + event.preventDefault(); + const kind = event.target.id === "comparison-world-search" ? "world" : "domestic"; + send(kind === "world" ? "search-comparison-world" : + "search-comparison-domestic", { query: event.target.value }); + } else if (event.key === "Enter" && event.target.id === "theme-search") { + event.preventDefault(); + send("search-themes", { query: event.target.value }); + } else if (event.key === "Enter" && event.target.id === "expert-search") { + event.preventDefault(); + send("search-experts", { query: event.target.value }); + } else if (event.key === "Enter" && event.target.id === "halt-search") { + event.preventDefault(); + send("search-trading-halts", { query: event.target.value }); + } } }); window.addEventListener("pointerup", function () { @@ -3884,20 +4355,41 @@ operatorCatalogDragBlockedRowId = null; }); document.addEventListener("keydown", function (event) { - const cutDragShortcut = event.key === "Escape" || event.key === "F2" || + const dragLoopShortcut = event.key === "Escape" || event.key === "F2" || event.key === "F3" || event.key === "F8"; - if (cutDragGesture && cutDragGesture.started && cutDragShortcut) { + const pointerDragStarted = (cutDragGesture && cutDragGesture.started) || + (playlistDragGesture && playlistDragGesture.started); + if (pointerDragStarted && dragLoopShortcut) { // Native DoDragDrop runs its own modal loop. MainForm shortcuts cannot run - // until that loop ends; Escape cancels the drag without issuing TAKE OUT. + // until that loop ends. Match that behavior for both pointer-capture drag + // surfaces; Escape cancels without issuing TAKE OUT. event.preventDefault(); - if (event.key === "Escape") clearCutDragGesture(); + if (event.key === "Escape") { + if (cutDragGesture) clearCutDragGesture(); + if (playlistDragGesture) { + deferredPlaylistPlainSelectionRowId = null; + clearPlaylistDragGesture(); + } + } + } else if (event.key === "Delete" && !event.repeat && dialog.hidden && + !operatorCatalogModal.hidden && operatorCatalogState && + (operatorCatalogState.mode === "create" || operatorCatalogState.mode === "edit")) { + const draftRow = event.target.closest("[data-operator-catalog-row-id]"); + const textEditor = event.target.closest("input, textarea, [contenteditable='true']"); + if (draftRow && !textEditor && !operatorCatalogMutationLocked && + draftRow.dataset.operatorCatalogRowId === operatorCatalogState.activeDraftRowId) { + event.preventDefault(); + event.stopPropagation(); + send("remove-active-operator-catalog-draft-row", {}); + } } else if (event.key === "Escape" && modalFocusManager.getActiveModal()) { // The legacy child forms did not assign a CancelButton and none handled // Escape. ShowDialog isolated MainForm, so Escape cannot fall through to // MainForm's TAKE OUT shortcut. The one-button MessageBox is the exception: // its Escape is equivalent to acknowledging OK. event.preventDefault(); - if (!event.repeat && modalFocusManager.getActiveModal() === legacyAlertDialog) { + if (!event.repeat && modalFocusManager.getActiveModal() === legacyAlertDialog && + !legacyDialogIsConfirmation) { send("dismiss-dialog", {}); } } else if (event.key === "Delete" && !event.repeat && dialog.hidden && manualModal.hidden && @@ -3929,7 +4421,14 @@ } }); dialogOk.addEventListener("click", function () { - send("dismiss-dialog", {}); + send(legacyDialogIsConfirmation + ? "confirm-native-dialog" + : "dismiss-dialog", {}); + }); + dialogNo.addEventListener("click", function () { + if (legacyDialogIsConfirmation) { + send("cancel-native-dialog", {}); + } }); if (webview) { diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html index 8c7f31c..634401a 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html @@ -92,7 +92,7 @@
-
송출
종류
종목
컷파일
세부사항
Page
+
송출
종류
종목
컷파일
세부사항
Page
@@ -179,7 +179,7 @@
-
+
-
-
+
+

DB 목록

-
+
-
+
구성 종목 위·아래 버튼으로 송출 순서를 변경합니다.
- + - - + +
@@ -246,9 +246,14 @@