diff --git a/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md b/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md index 683ec7e..0230ce4 100644 --- a/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md +++ b/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md @@ -182,19 +182,20 @@ 고정한 계약: -- modal에서 처리한 Escape/F2/F3/F8은 뒤의 playout key listener로 전달하지 않는다. 이름 있는 - playlist modal의 Escape는 modal만 닫고 TAKE OUT을 보내지 않는다. +- 원본 child form에는 `CancelButton`/`KeyPreview`/Escape handler가 없고 `ShowDialog`가 MainForm을 + 비활성화한다. 따라서 modal의 Escape는 form을 닫지도 TAKE OUT을 보내지도 않으며, + F2/F3/F8과 playlist 전역 키도 owner로 전달하지 않는다. - Delete는 원본 KeyPreview처럼 playlist 삭제 intent를 보내되 기본 키를 억제하지 않아, 텍스트 입력이 focus를 가진 경우 문자 삭제도 정상 동작한다. - 시작 기본 배경은 `Cuts\video\기본.vrv`, F2/F3 기본 배경은 `Cuts\..\배경\기본.vrv`로 분리하고 두 신뢰 root가 서로 대신하지 않게 했다. -- 화면에 원본 `DissolveTime` 1..20을 표시하고 C#에는 인덱스 0..19만 보낸다. 기본 인덱스는 - 6(화면 7)이며, PREPARE/F8 때 현재 전역 인덱스를 기존 playlist 모든 entry에 적용한다. - 행 생성 시 저장된 기본 6이 전역 선택을 덮어쓰던 경로는 제거했다. +- 원본 `DissolveTime`을 소유한 `panel3`은 Designer에서 항상 숨겨져 있으므로 WebView selector도 + hidden/disabled로 유지하고 운영자 변경 intent를 노출하지 않는다. C#의 기존 0..19 인덱스와 + 기본값 6은 scene effect 상태로만 보존한다. ## Slice 6: 탭 재진입과 dragover 교환 -상태: 코드 및 자동 테스트 완료, 실제 DB 쓰기/Tornado 명령은 실행하지 않음. +상태: 코드 및 자동 테스트 완료, 실제 DB/Tornado 명령은 실행하지 않음. 원본 근거: @@ -278,11 +279,61 @@ - PREPARED/PROGRAM/pending/unknown 또는 종목 미선택 상태에서는 컷 선택과 focus 이동을 함께 차단해 DOM과 C# 상태가 어긋나지 않게 한다. +## Slice 10: modal 소유권·PList 완료 순서와 컷 drag 임계값 + +상태: 코드·자동 테스트·unsigned Release package-context 시작/정상 종료 완료. 실제 컷 drag +마우스 실측과 DB 쓰기/Tornado 명령은 실행하지 않음. + +원본 근거: + +- `Form/PList.cs:173-196`, `222-237` 행 double-click과 확인 버튼 save/load/close 순서 +- `Form/PList.cs:184`, `226`, `229`의 `저장하겠습니까?`, `저장되었습니다` +- `Form/*Designer.cs`의 child form에 없는 `CancelButton`, `KeyPreview`, Escape handler +- `MainForm.cs:3427-3459`, `3534-3549`, `3635-3643` 컷 mouse-down rectangle, + drag 시작과 체크 행 physical-order drop +- `MainForm.Designer.cs:1391-1399` 숨겨진 `panel3` + +고정한 계약: + +- named playlist, GraphE, FSell/VI, ThemeA/EList modal은 opener를 기억하고 최초 legacy control에 + focus하며 Tab/Shift+Tab을 modal 안에 가둔다. 중첩 alert를 닫으면 child modal의 이전 control로, + child modal을 닫으면 owner control로 focus를 돌려준다. +- child modal의 Escape는 원본처럼 no-op으로 소비하고 MainForm TAKE OUT으로 새지 않는다. +- PList save는 정확히 `저장하겠습니까?`를 묻는다. 확인 버튼 save 성공만 + `저장되었습니다`를 표시하고, 행 double-click save는 성공 알림 없이 닫는다. No를 선택해도 + 원본의 unconditional `Close()`처럼 modal을 닫는다. +- 비동기 DB 명령 중에는 원본 동기 `ShowDialog` 호출이 반환되지 않던 구간처럼 close/cancel과 + 중복 명령을 잠근다. 정확한 command/requestId/definition/sequence receipt만 modal을 닫으며 + 실패 또는 결과 불명확 상태에서는 성공 알림·추정 close를 하지 않는 안전 교정을 유지한다. +- `PList.Get_FileName_Folder`가 새 `DataSource`를 지정할 때 non-empty ListBox의 첫 행이 자동 + 선택되는 동작을 열기·새로고침·추가·삭제 뒤 모두 유지한다. 다른 행 click은 즉시 시각 선택을 + 바꾸되 exact native selection receipt 전에는 확인·삭제를 잠그고, double-click은 같은 opaque + definition id에 묶어 receipt 뒤 한 번만 실행하므로 이전 첫 행을 잘못 저장/삭제하지 않는다. +- DB commit이 확정되고 read-back만 실패한 `CommittedOptimistic`은 원본처럼 저장 완료로 + 처리하되 warning은 유지한다. 반대로 이전 성공 outcome이 남은 채 현재 입력 검증이 실패한 + 경우에는 named workflow revision이 증가하지 않으므로 성공 receipt·알림·close를 만들지 않는다. +- category tree 검색 input을 state render가 다시 만들더라도 id, caret/selection 범위와 focus를 + 복원해 Enter 검색 뒤 원본 textbox의 연속 입력 동작을 유지한다. +- 컷 drag rectangle은 `Point(x-SM_CXDRAG, y-SM_CYDRAG)`와 Windows drag size를 사용하고 + left/top 포함, right/bottom 제외 `Rectangle.Contains` 판정을 그대로 적용한다. 브라우저 native + drag 임계값은 사용하지 않으며 pointer capture 뒤 playlist 경계 안에서 놓았을 때만 기존 + `DropSelectedCutsOnPlaylist`를 한 번 호출한다. +- `SM_CXDRAG/SM_CYDRAG`는 DPI 비인지 `GetSystemMetrics` 값을 그대로 쓰지 않는다. + `GetSystemMetricsForDpi(..., 96)`로 XAML host DIP 값을 읽고 `XamlRoot.RasterizationScale`도 + state에 싣는다. Web은 이를 `window.devicePixelRatio`와 대조해 과거 profile에 남은 browser + zoom까지 제거한 pointer-event CSS pixel 임계값을 계산한다. 100%/150% DPI와 retained zoom을 + 자동 테스트하며 비정상 scale 비율은 1로 fail-safe한다. +- `WM_SETTINGCHANGE`/`WM_DISPLAYCHANGE`/`WM_DPICHANGED`와 모든 state 게시 경계에서 값을 다시 + 읽어 시작 시 한 번만 캐시하지 않는다. cut-pointer-down 회신이 도착했을 때 아직 시작되지 + 않은 pointer gesture의 rectangle도 최신 값으로 다시 만든다. 이후 browser/pinch zoom 입력은 + 비활성화하고 API 실패나 0 이하는 축별 4 DIP로 fail-safe한다. 실제 drop 내용은 payload가 + 아니라 C#의 현재 선택 집합과 physical 순서가 계속 권위본이다. + ## 다음 대조 순서 -1. 컷 ListView의 drag 시작 임계값과 실제 package 키보드 회귀 -2. GraphE/FSell/VI/PList/AList/ThemeA/EList modal별 남은 키·focus·취소 이벤트 +1. 새 package에서 실제 마우스·키보드·컷/playlist drag 회귀 +2. ThemeA/EList 편집 save/delete/close의 세부 확인 문구와 focus 순서 대조 3. 자동 갱신 UI의 실제 실행 상태 기반 통합 테스트와 종료 상태 행렬 -4. Debug/Release x64와 새 package에서 실제 마우스·키보드·drag 회귀 +4. 다음 Designer handler 묶음의 원본 코드·상태 전이 대조 각 항목은 실제 원본 코드 근거와 회귀 테스트가 모두 생기기 전에는 완료로 판정하지 않는다. diff --git a/docs/LEGACY_FEATURE_AUDIT.md b/docs/LEGACY_FEATURE_AUDIT.md index 51c36b6..92755ae 100644 --- a/docs/LEGACY_FEATURE_AUDIT.md +++ b/docs/LEGACY_FEATURE_AUDIT.md @@ -52,8 +52,8 @@ HELLO/BYE 연결은 2026-07-15에 확인했다. scene/PREPARE/TAKE/PGM은 실행 | LF-017 | P1 | 시스템 종료 확인 | 완료 | 원본 제목 `매일경제TV`, 중립 문구, 예/아니요와 기본 아니요를 유지하며 자동 TAKE OUT/반대 명령 금지 경계를 코드·자동 테스트로 유지 | 현재 Release package-context 회귀는 LF-005와 함께 판정 | | LF-018 | P1 | 원본 비교쌍/수동 파일 | 완료 | `종목비교.dat` 8쌍 1회 import·재시작 marker, FSell 3×5행과 VI 9항목 trusted parser/store 검증 | 원본 파일은 계속 읽기 전용 | | LF-019 | P1 | 실제 DB read→DTO→mutation | 완료 | Oracle/MariaDB 필수 scene selector 33/33, 전체 query 58건 PASS | 외부 asset/PGM 성공과 별도 | -| LF-020 | P2 | FarPoint 대체 | 현재 빌드 실측 제외 | source 단일 행 drag, F8 후보·PROGRAM·DEL 집합·Shift anchor 분리, 종목 ListBox와 컷 ListView 키보드/selection mark를 DOM/C# 회귀로 고정. 탭 dragover와 EList reorder는 기존 실측 유지 | 현재 변경을 합친 package에서 playlist 단일 행 drag와 키보드/focus를 다시 실측; 활성 Print/Excel export는 원본에도 없음 | -| LF-021 | P2 | 패키지 단일 인스턴스·모달 격리 | 완료 | 두 번째 activation 전달, 강제 종료 없음. modal에서 F2/F3/F8/Escape와 playlist key 격리 | 새 MSIX 전체 회귀 | +| LF-020 | P2 | FarPoint 대체 | 자동 검증 완료·package 실측 대기 | source 단일 행 drag, F8 후보·PROGRAM·DEL 집합·Shift anchor 분리, 종목 ListBox/컷 ListView 키보드와 Windows drag rectangle을 DOM/C# 회귀로 고정. 탭 dragover와 EList reorder는 기존 실측 유지 | 현재 변경을 합친 package에서 playlist 단일 행 drag와 키보드/focus를 다시 실측; 활성 Print/Excel export는 원본에도 없음 | +| LF-021 | P2 | 패키지 단일 인스턴스·모달 격리 | 자동 검증 완료·package 실측 대기 | 두 번째 activation 전달, 강제 종료 없음. child modal Escape no-op, 전역 키 격리, Tab trap, opener/nested-alert focus 복귀와 PList pending close lock을 고정 | 새 MSIX 전체 회귀 | | LF-022 | P2 | 영구 운영 로그/bridge 오류 | 완료 | bounded/redacted log와 toast, malformed payload가 송출 상태를 바꾸지 않음 | authoritative Network Monitoring과 함께 판독 | | LF-023 | P1 | NXT 저장 playlist restore | 외부 데이터 정합성 | stale code를 제목/current identity로 재검증하는 닫힌 경로는 완료. 현재 `v_all_stock` 결과가 비어 preview를 fail closed | DBA/data 정합성 없이는 direct-table 추정 금지 | | LF-024 | P0 | Designer 이벤트 전수 감사 | 완료 | 원본 Designer 구독 **186개**, 화면별 고유 핸들러 **171개**를 IMPL 131/DEAD 20/VISUAL 10/DRAG 9/SAFETY 1로 전수 분류 | 상세 줄 단위 근거는 `LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md` | diff --git a/docs/LEGACY_PARITY_VALIDATION_20260715.md b/docs/LEGACY_PARITY_VALIDATION_20260715.md index bce31af..1fa46b2 100644 --- a/docs/LEGACY_PARITY_VALIDATION_20260715.md +++ b/docs/LEGACY_PARITY_VALIDATION_20260715.md @@ -170,25 +170,43 @@ drag만 5행 순서를 바꾸는 것도 확인했다. .NET Debug/Release 합계 scene PREPARE/TAKE/PGM을 실행하지 않은 이 Live 연결 회차를 전체 방송 송출 PASS로 확대하지 않는다. -### 코드 대조 Slice 1~9 합본 추가 검증 +### 코드 대조 Slice 1~10 합본 추가 검증 이후 원본 이벤트를 다시 코드 단위로 대조해 F8 후보·PROGRAM·DEL 다중선택·Shift anchor를 분리하고, 런타임 컷 50행, 종목/컷 키보드, UC1 tree, s8010 이동평균 재샘플링, K3D raw 인자와 -OnCutOut/OnStopAll callback 게이트를 합쳤다. 이 합본의 검증 결과는 다음과 같다. + OnCutOut/OnStopAll callback 게이트에 이어 child modal focus/Escape, PList 완료 순서와 + Windows 컷 drag rectangle을 합쳤다. 이 합본의 검증 결과는 다음과 같다. | 항목 | 추가 검증 결과 | |---|---| -| .NET Debug x64 | **2,584/2,584** 통과, 실패·건너뜀 0 | -| .NET Release x64 | **2,584/2,584** 통과, 실패·건너뜀 0 | -| Web Node | **456/456** 통과, 실패·취소·건너뜀·TODO 0 | +| .NET Debug x64 | **2,611/2,611** 통과, 실패·건너뜀 0 | +| .NET Release x64 | **2,611/2,611** 통과, 실패·건너뜀 0 | +| Web Node | **491/491** 통과, 실패·취소·건너뜀·TODO 0 | | 솔루션 Debug/Release x64 | NuGet x64 복원 후 각각 경고 0 / 오류 0 | -| Visual Studio 2026 MSBuild | VS **18.7.4**의 x64 MSBuild로 Debug/Release 솔루션 빌드 모두 성공 | -| 최신 unsigned Release MSIX | **15,228,096 bytes**, SHA-256 `3D7FC0C0837756C860AC660DBCEA30BF543D261FE0EEEAE1CDA294FA95CC3BF1` | -| 패키지 감사 | 75항목, `.t2s`·실설정·라이선스·인증서·K3D vendor 파일 0 | -| package-context DryRun | 최신 Release layout 실행·응답 확인, 인자 없음, 앱 소유 TCP 0, 중립 종료창 `매일경제TV`/`예`/`아니요` 확인 후 정상 종료 | +| Visual Studio 2026 MSBuild | VS **18.7.8**의 x64 MSBuild로 Debug/Release 솔루션 빌드 모두 성공 | +| 최신 unsigned Release MSIX | **15,244,365 bytes**, SHA-256 `A2E9AA95B1F5E20C6E83A3B240A96F0E2792D3A1F809FEC60D829613BDF5CC8E` | +| 패키지 감사 | 78항목, Web 7개 source byte-for-byte 일치, `.t2s`·실설정·라이선스·인증서·K3D vendor 파일 0 | +| package-context DryRun | 최신 Release development registration에서 실행·응답, 인자 없음, `DRY RUN` UI, 앱 소유 TCP 0을 확인. 중립 종료창 `프로그램을 종료하겠습니까?`/`예`/`아니요`를 대조하고 `예`를 1회 Invoke해 정상 종료 | 이 unsigned MSIX는 개발 package-context 실행 검증용이며 위 signed 배포 패키지를 대체하지 않는다. 이 추가 회차에서도 scene/PREPARE/TAKE/NEXT/DB write와 Tornado 연결은 실행하지 않았다. +새 컷 drag와 modal focus는 자동 계약까지 통과했으며 실제 package 마우스 drag 실측은 다음 +대조 회차에 남아 있다. + +컷 drag 임계값 후속 감사에서는 시작 시 한 번 읽던 `GetSystemMetrics` 캐시를 제거했다. +현재 구현은 `GetSystemMetricsForDpi(SM_CXDRAG/SM_CYDRAG, 96)` 결과를 host DIP 단위로 +직렬화하고 `XamlRoot.RasterizationScale`/`window.devicePixelRatio` 비율로 pointer-event CSS +pixel에 맞춘다. 따라서 100%·150% host DPI뿐 아니라 이전 profile에 남은 browser zoom도 +보정하며 비정상 비율은 1로 닫힌다. `WM_SETTINGCHANGE`·`WM_DISPLAYCHANGE`·`WM_DPICHANGED`와 +모든 state 게시 경계에서 값을 새로 읽고, cut-pointer-down 회신은 아직 시작되지 않은 gesture +rectangle도 최신 값으로 다시 만든다. 이후 browser/pinch zoom 입력은 비활성화하며, native +API를 사용할 수 없는 환경에서는 축별 4 DIP로 닫힌다. + +PList 후속 대조에서는 WinForms `DataSource` 재지정의 첫 행 자동 선택을 복원하고, 행 선택에도 +`named-ui-*` requestId와 exact command/target/sequence receipt를 적용했다. 따라서 2행 click +직후 확인·삭제가 과거 1행에 실행되지 않으며, double-click은 동일 ID의 선택 완료 뒤 한 번만 +이어진다. known commit/read-back 실패는 warning을 유지한 성공으로, 현재 입력 검증 실패는 +revision 미증가 실패로 구분하는 자동 회귀도 통과했다. ## 판정 경계 diff --git a/docs/OPERATOR_UI_PARITY.md b/docs/OPERATOR_UI_PARITY.md index e9cc3d1..5e1aa7d 100644 --- a/docs/OPERATOR_UI_PARITY.md +++ b/docs/OPERATOR_UI_PARITY.md @@ -120,6 +120,8 @@ Theme 결과 double-click은 원본의 5행 현재가 경로를 사용한다. PL |---|---|---| | FarPoint `AllowRowMove` | 내부 custom MIME와 opaque source/target `rowId`, before/after | 실제로 끌어 잡은 source 한 행만 이동한다. 수동 Ctrl/Shift 삭제 선택을 다중 행 drag로 확대하지 않으며, 체크박스에서 시작한 drag와 PREPARED/PROGRAM/pending/unknown 변경을 거부한다. | | `tab_DragOver/MouseDown/Move/Up` | drop을 기다리지 않고 dragover로 `hover-swap-tabs` 전송 | C# 탭 identity와 `expectedSourceIndex/expectedTargetIndex`가 현재 순서와 일치할 때만 교환하므로 같은 메시지의 중복 전달이 순서를 되돌리지 않는다. | +| 컷 `ListView.MouseDown/MouseMove` | pointer capture와 Windows `SM_CXDRAG/SM_CYDRAG` 비대칭 rectangle | Chromium native drag 임계값을 쓰지 않는다. DPI-aware API의 96-DPI 값을 host DIP로 전달하고 `XamlRoot.RasterizationScale`/`devicePixelRatio` 비율로 retained browser zoom까지 보정한다. Windows 설정·화면·DPI 변경 및 state 게시 때 갱신하며, left/top 포함·right/bottom 제외 경계를 넘은 뒤 playlist 안에서 놓을 때만 현재 C# 선택 집합을 physical order로 한 번 추가한다. | +| PList `DataSource`/click/double-click | non-empty 목록 첫 행 자동 선택, 행 click 즉시 선택, double-click save/load | 열기·새로고침·추가·삭제 뒤 첫 행을 선택한다. 다른 행 click은 exact requestId 영수증 전까지 확인·삭제를 잠그고 동일 opaque ID의 double-click만 선택 완료 뒤 실행해 이전 행 DB mutation을 차단한다. | | UC6 `FpSpread2.AllowDragDrop` | ThemeA/EList 편집행의 내부 custom MIME와 opaque `rowId` | 워크플로가 임의 before/after 위치를 계산한다. 존재하지 않는 ID, 같은 행, 추가 payload 필드는 strict bridge에서 거부하거나 no-op 처리한다. | 실제 마우스 검증에서는 playlist 행 drop이 조상 drop zone으로 버블링해 현재 선택된 컷을 diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs index 6492c7f..432f876 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs @@ -3374,18 +3374,19 @@ public sealed class LegacyOperatorController Touch(); return CreateSnapshot(); } + var selectedRevision = selected.NamedPlaylist?.Revision ?? -1; var saved = await SaveCurrentNamedPlaylistAsync(cancellationToken) .ConfigureAwait(false); var succeeded = + (saved.NamedPlaylist?.Revision ?? -1) > selectedRevision && string.Equals( saved.NamedPlaylist?.SelectedDefinitionId, definitionId, StringComparison.Ordinal) && saved.NamedPlaylist?.LastMutationOutcome is LegacyNamedPlaylistMutationOutcome.CommittedFresh or - LegacyNamedPlaylistMutationOutcome.CommittedOptimistic && - saved.StatusKind == LegacyOperatorStatusKind.Information; + LegacyNamedPlaylistMutationOutcome.CommittedOptimistic; CompleteCommand(command, definitionId, succeeded); Touch(); return CreateSnapshot(); @@ -3441,6 +3442,88 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } + /// + /// Completes a correlated named-playlist command when the native dispatch + /// boundary rejects the request or encounters an unexpected exception. + /// The terminal receipt prevents the WebView modal from waiting indefinitely; + /// the named-playlist workflow remains the authority for any write quarantine + /// that was already latched before the exception escaped. + /// + public LegacyOperatorSnapshot ReportNamedPlaylistCommandFailure( + string command, + string targetId, + string message) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command); + ArgumentException.ThrowIfNullOrWhiteSpace(targetId); + if (command is not "refresh-named-playlists" and + not "select-named-playlist" and + not "create-named-playlist" and + not "delete-selected-named-playlist" and + not "load-named-playlist-by-id" and + not "save-current-named-playlist-to") + { + throw new ArgumentOutOfRangeException( + nameof(command), + command, + "Only correlated named-playlist commands can be completed here."); + } + + CompleteCommand(command, targetId, succeeded: false); + return ReportError(message); + } + + public LegacyOperatorSnapshot CompleteNamedPlaylistDispatch( + string command, + string requestId, + bool succeeded) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command); + ArgumentException.ThrowIfNullOrWhiteSpace(requestId); + if (command is not "refresh-named-playlists" and + not "select-named-playlist" and + not "create-named-playlist" and + not "delete-selected-named-playlist") + { + throw new ArgumentOutOfRangeException( + nameof(command), + command, + "The command does not use a named-playlist dispatch id."); + } + + CompleteCommand(command, requestId, succeeded); + Touch(); + return CreateSnapshot(); + } + + /// + /// Terminates an accepted save command without making its database outcome + /// retryable. This is intentionally separate from a known pre-dispatch + /// rejection: an escaped post-dispatch exception permanently quarantines every + /// later named-playlist write in this process. + /// + public LegacyOperatorSnapshot ReportNamedPlaylistWriteOutcomeUnknown( + string command, + string targetId, + string message) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command); + ArgumentException.ThrowIfNullOrWhiteSpace(targetId); + if (command is not "create-named-playlist" and + not "delete-selected-named-playlist" and + not "save-current-named-playlist-to") + { + throw new ArgumentOutOfRangeException( + nameof(command), + command, + "The command is not a named-playlist write."); + } + + _namedPlaylistWorkflow?.QuarantineAmbiguousWriteOutcome(); + CompleteCommand(command, targetId, succeeded: false); + return ReportError(message); + } + public LegacyOperatorSnapshot ReportInformation(string message) { _statusMessage = message ?? string.Empty; diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs index cde4bc1..c5725f6 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs @@ -297,6 +297,28 @@ public sealed class LegacyNamedPlaylistWorkflowController } } + /// + /// Conservatively latches the no-retry barrier when an exception escapes the + /// native dispatch boundary after a named-playlist write was accepted. The + /// caller cannot prove whether the database observed the mutation, so every + /// later write in this workflow instance must remain blocked until restart. + /// + public LegacyNamedPlaylistWorkflowSnapshot QuarantineAmbiguousWriteOutcome() + { + lock (_gate) + { + _isWriteQuarantined = true; + _lastMutationOutcome = LegacyNamedPlaylistMutationOutcome.OutcomeUnknown; + _statusMessage = QuarantineMessage; + _statusKind = LegacyOperatorStatusKind.Error; + ++_mutationEpoch; + ++_listGeneration; + ++_loadGeneration; + Touch(); + return CreateSnapshot(); + } + } + public async Task RefreshAsync( int maximumResults = DefaultMaximumDefinitions, CancellationToken cancellationToken = default) @@ -854,9 +876,7 @@ public sealed class LegacyNamedPlaylistWorkflowController definitions, list.IsTruncated, suggested, - LegacyNamedPlaylistFreshness.Fresh, - programCode); - ApplyDocument(document, LegacyNamedPlaylistFreshness.Fresh); + LegacyNamedPlaylistFreshness.Fresh); Touch(); } @@ -968,10 +988,9 @@ public sealed class LegacyNamedPlaylistWorkflowController IReadOnlyList summaries, bool isTruncated, string suggestedProgramCode, - LegacyNamedPlaylistFreshness freshness, - string? preferredSelectionCode = null) + LegacyNamedPlaylistFreshness freshness) { - var selectedCode = preferredSelectionCode ?? _selectedProgramCode; + var previousSelectedCode = _selectedProgramCode; _definitions = summaries.Select(summary => new DefinitionState( NextOpaqueId("named-definition"), summary)).ToList(); @@ -979,12 +998,11 @@ public sealed class LegacyNamedPlaylistWorkflowController _suggestedProgramCode = suggestedProgramCode; _listFreshness = freshness; - var selected = selectedCode is null - ? null - : _definitions.FirstOrDefault(definition => string.Equals( - definition.Summary.ProgramCode, - selectedCode, - StringComparison.Ordinal)); + // WinForms assigns a new DataSource on every PList refresh. A non-empty + // ListBox therefore selects index zero, including after add and delete. + // Keep that operator-visible reset instead of preserving a Web selection + // or selecting the newly created definition. + var selected = _definitions.FirstOrDefault(); if (selected is null) { _selectedProgramCode = null; @@ -999,6 +1017,15 @@ public sealed class LegacyNamedPlaylistWorkflowController _selectedProgramCode = selected.Summary.ProgramCode; _selectedDefinitionId = selected.DefinitionId; _selectedTitle = selected.Summary.Title; + if (!string.Equals( + previousSelectedCode, + selected.Summary.ProgramCode, + StringComparison.Ordinal)) + { + _rows = []; + _documentFreshness = LegacyNamedPlaylistFreshness.None; + ++_loadGeneration; + } } private void ApplyDocument( diff --git a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs index bb99092..e463f4a 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs @@ -289,24 +289,92 @@ public sealed record LegacyConfirmManualListIntent : LegacyUiIntent; public sealed record LegacyImportManualListsIntent : LegacyUiIntent; -public sealed record LegacyRefreshNamedPlaylistsIntent : LegacyUiIntent; +public sealed record LegacyRefreshNamedPlaylistsIntent(string RequestId) : LegacyUiIntent; -public sealed record LegacySelectNamedPlaylistIntent(string DefinitionId) : LegacyUiIntent; +public sealed record LegacySelectNamedPlaylistIntent( + string DefinitionId, + string RequestId) : LegacyUiIntent; public sealed record LegacyLoadSelectedNamedPlaylistIntent : LegacyUiIntent; public sealed record LegacyLoadNamedPlaylistByIdIntent(string DefinitionId) : LegacyUiIntent; -public sealed record LegacyCreateNamedPlaylistIntent(string Title) : LegacyUiIntent; +public sealed record LegacyCreateNamedPlaylistIntent(string Title, string RequestId) : LegacyUiIntent; public sealed record LegacySaveCurrentNamedPlaylistIntent : LegacyUiIntent; public sealed record LegacySaveCurrentNamedPlaylistToIntent(string DefinitionId) : LegacyUiIntent; -public sealed record LegacyDeleteSelectedNamedPlaylistIntent : LegacyUiIntent; +public sealed record LegacyDeleteSelectedNamedPlaylistIntent(string RequestId) : LegacyUiIntent; public sealed record LegacyDismissDialogIntent : LegacyUiIntent; +public sealed record LegacyInteractionMetrics( + int CutDragWidthDips, + int CutDragHeightDips, + uint SourceDpi = 96, + double HostRasterizationScale = 1d) +{ + public const uint CssPixelsPerInch = 96; + public const int FallbackCutDragWidth = 4; + public const int FallbackCutDragHeight = 4; + + public static LegacyInteractionMetrics Default { get; } = new( + FallbackCutDragWidth, + FallbackCutDragHeight, + CssPixelsPerInch, + 1d); + + public static LegacyInteractionMetrics FromPixelsAtDpi( + int cutDragWidthPixels, + int cutDragHeightPixels, + uint sourceDpi, + double hostRasterizationScale = 1d) => + sourceDpi == 0 + ? Default with + { + HostRasterizationScale = NormalizeHostScale(hostRasterizationScale) + } + : new( + ToDips( + cutDragWidthPixels, + sourceDpi, + FallbackCutDragWidth), + ToDips( + cutDragHeightPixels, + sourceDpi, + FallbackCutDragHeight), + sourceDpi, + NormalizeHostScale(hostRasterizationScale)); + + internal static LegacyInteractionMetrics Normalize(LegacyInteractionMetrics? metrics) => + metrics is null + ? Default + : new( + metrics.CutDragWidthDips > 0 + ? metrics.CutDragWidthDips + : FallbackCutDragWidth, + metrics.CutDragHeightDips > 0 + ? metrics.CutDragHeightDips + : FallbackCutDragHeight, + metrics.SourceDpi > 0 ? metrics.SourceDpi : CssPixelsPerInch, + NormalizeHostScale(metrics.HostRasterizationScale)); + + private static double NormalizeHostScale(double hostRasterizationScale) => + double.IsFinite(hostRasterizationScale) && hostRasterizationScale > 0d + ? hostRasterizationScale + : 1d; + + private static int ToDips(int pixelsAtSourceDpi, uint sourceDpi, int fallback) => + pixelsAtSourceDpi <= 0 + ? fallback + : Math.Max( + 1, + (int)Math.Round( + pixelsAtSourceDpi * (double)CssPixelsPerInch / sourceDpi, + MidpointRounding.AwayFromZero)); +} + public static class LegacyBridgeProtocol { public const string TrustedHost = "legacy-parity.mbn.local"; @@ -466,9 +534,7 @@ public static class LegacyBridgeProtocol "delete-operator-catalog" => ParseOperatorCatalogEmpty( payload, static () => new LegacyDeleteOperatorCatalogIntent()), - "refresh-named-playlists" => ParseNamedPlaylistEmpty( - payload, - static () => new LegacyRefreshNamedPlaylistsIntent()), + "refresh-named-playlists" => ParseNamedPlaylistRefresh(payload), "select-named-playlist" => ParseNamedPlaylistSelection(payload), "load-named-playlist-by-id" => ParseNamedPlaylistLoadById(payload), "load-selected-named-playlist" => ParseNamedPlaylistEmpty( @@ -479,9 +545,8 @@ public static class LegacyBridgeProtocol payload, static () => new LegacySaveCurrentNamedPlaylistIntent()), "save-current-named-playlist-to" => ParseNamedPlaylistSaveTo(payload), - "delete-selected-named-playlist" => ParseNamedPlaylistEmpty( - payload, - static () => new LegacyDeleteSelectedNamedPlaylistIntent()), + "delete-selected-named-playlist" => + ParseNamedPlaylistDelete(payload), "open-manual-financial" => ParseManualFinancialOpen(payload), "close-manual-financial" => ParseManualFinancialEmpty( payload, @@ -562,9 +627,12 @@ public static class LegacyBridgeProtocol public static string SerializeState( LegacyOperatorSnapshot snapshot, bool isBusy = false, - LegacyOperatorPlayoutSnapshot? playout = null) + LegacyOperatorPlayoutSnapshot? playout = null, + LegacyInteractionMetrics? interactionMetrics = null) { ArgumentNullException.ThrowIfNull(snapshot); + var normalizedInteractionMetrics = LegacyInteractionMetrics.Normalize( + interactionMetrics); var payload = new { snapshot.Revision, @@ -1057,6 +1125,15 @@ public static class LegacyBridgeProtocol playout.CanChangeBackground, playout.Message }, + interactionMetrics = new + { + cutDragWidthDips = normalizedInteractionMetrics.CutDragWidthDips, + cutDragHeightDips = normalizedInteractionMetrics.CutDragHeightDips, + coordinateSpace = "host-dip", + sourceDpi = normalizedInteractionMetrics.SourceDpi, + hostRasterizationScale = + normalizedInteractionMetrics.HostRasterizationScale + }, isBusy }; @@ -1824,10 +1901,19 @@ public static class LegacyBridgeProtocol HasOnlyProperties(payload) ? factory() : null; private static LegacyUiIntent? ParseNamedPlaylistSelection(JsonElement payload) => - HasOnlyProperties(payload, "definitionId") && + HasOnlyProperties(payload, "definitionId", "requestId") && TryString(payload, "definitionId", 128, out var definitionId) && - IsSafeIdentifier(definitionId) - ? new LegacySelectNamedPlaylistIntent(definitionId) + IsSafeIdentifier(definitionId) && + TryString(payload, "requestId", 128, out var requestId) && + IsSafeIdentifier(requestId) + ? new LegacySelectNamedPlaylistIntent(definitionId, requestId) + : null; + + private static LegacyUiIntent? ParseNamedPlaylistRefresh(JsonElement payload) => + HasOnlyProperties(payload, "requestId") && + TryString(payload, "requestId", 128, out var requestId) && + IsSafeIdentifier(requestId) + ? new LegacyRefreshNamedPlaylistsIntent(requestId) : null; private static LegacyUiIntent? ParseNamedPlaylistLoadById(JsonElement payload) => @@ -1845,7 +1931,7 @@ public static class LegacyBridgeProtocol : null; private static LegacyUiIntent? ParseNamedPlaylistCreate(JsonElement payload) => - HasOnlyProperties(payload, "title") && + HasOnlyProperties(payload, "title", "requestId") && TrySafeString( payload, "title", @@ -1853,8 +1939,17 @@ public static class LegacyBridgeProtocol allowEmpty: false, allowUnderscore: true, out var title) && + TryString(payload, "requestId", 128, out var requestId) && + IsSafeIdentifier(requestId) && string.Equals(title, title.Trim(), StringComparison.Ordinal) - ? new LegacyCreateNamedPlaylistIntent(title) + ? new LegacyCreateNamedPlaylistIntent(title, requestId) + : null; + + private static LegacyUiIntent? ParseNamedPlaylistDelete(JsonElement payload) => + HasOnlyProperties(payload, "requestId") && + TryString(payload, "requestId", 128, out var requestId) && + IsSafeIdentifier(requestId) + ? new LegacyDeleteSelectedNamedPlaylistIntent(requestId) : null; private static LegacyUiIntent? ParseManualFinancialOpen(JsonElement payload) => diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs index abd5a6c..b081a4a 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using MBN_STOCK_WEBVIEW.Infrastructure; using MBN_STOCK_WEBVIEW.LegacyApplication; using MBN_STOCK_WEBVIEW.LegacyBridge; @@ -11,17 +12,31 @@ namespace MBN_STOCK_WEBVIEW.LegacyParityApp; public sealed partial class MainWindow : Window { + private const int SmCxDrag = 68; + private const int SmCyDrag = 69; + private const uint WmSettingChange = 0x001A; + private const uint WmDisplayChange = 0x007E; + private const uint WmDpiChanged = 0x02E0; + private const nuint InteractionMetricsSubclassId = 0x4D424E49; + private readonly CancellationTokenSource _lifetimeCancellation = new(); private readonly SemaphoreSlim _intentGate = new(1, 1); + private readonly WindowSubclassProcedure _windowSubclassProcedure; private readonly LegacyOperatorController _controller; private readonly LegacyIndustrySelectionWorkflow _industryWorkflow; private DatabaseRuntime? _databaseRuntime; private bool _closing; + private bool _interactionMetricsRefreshQueued; private bool _intentBusy; + private bool _windowSubclassAttached; private bool _webViewReady; + private nint _windowHandle; + private LegacyInteractionMetrics _interactionMetrics = + LegacyInteractionMetrics.Default; public MainWindow() { + _windowSubclassProcedure = OnWindowSubclassMessage; InitializeComponent(); EnsureCloseConfirmationAttached(); var localApplicationData = Environment.GetFolderPath( @@ -202,6 +217,9 @@ public sealed partial class MainWindow : Window { EnsureCloseConfirmationAttached(); var windowHandle = WindowNative.GetWindowHandle(this); + _windowHandle = windowHandle; + AttachInteractionMetricsRefresh(); + RefreshInteractionMetrics(); var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle); var appWindow = AppWindow.GetFromWindowId(windowId); appWindow.Title = "V-Stock 증권정보송출시스템 for 매일경제TV (26.03.26)"; @@ -244,8 +262,11 @@ public sealed partial class MainWindow : Window CoreWebView2HostResourceAccessKind.DenyCors); coreWebView.Settings.AreDevToolsEnabled = System.Diagnostics.Debugger.IsAttached; + coreWebView.Settings.AreBrowserAcceleratorKeysEnabled = false; coreWebView.Settings.AreDefaultContextMenusEnabled = false; coreWebView.Settings.IsStatusBarEnabled = false; + coreWebView.Settings.IsZoomControlEnabled = false; + coreWebView.Settings.IsPinchZoomEnabled = false; coreWebView.NavigationStarting += OnNavigationStarting; coreWebView.NavigationCompleted += OnNavigationCompleted; coreWebView.NewWindowRequested += OnNewWindowRequested; @@ -327,6 +348,7 @@ public sealed partial class MainWindow : Window private async Task ProcessIntentAsync(LegacyUiIntent intent) { var gateEntered = false; + var dispatchStarted = false; try { // MainForm performed database search synchronously. The busy snapshot @@ -337,7 +359,11 @@ public sealed partial class MainWindow : Window _lifetimeCancellation.Token); if (!gateEntered) { - PostState(_controller.Current); + PostState(IsNamedPlaylistModalIntent(intent) + ? ReportIntentFailure( + intent, + "다른 요청을 처리 중이어서 DB 재생목록 요청을 시작하지 않았습니다.") + : _controller.Current); return; } @@ -348,7 +374,8 @@ public sealed partial class MainWindow : Window if (IsOperatorMutationIntent(intent) && IsOperatorMutationQuarantined()) { - PostState(_controller.ReportError( + PostState(ReportIntentFailure( + intent, "이전 저장 결과가 불명확하여 이번 실행의 추가 변경이 차단되었습니다. DB 또는 로컬 파일을 읽기 전용으로 대조한 뒤 앱을 다시 시작하세요.")); return; } @@ -357,14 +384,16 @@ public sealed partial class MainWindow : Window IsOperatorMutationIntent(intent) && !IsFixedSectionBatchMutationIntent(intent)) { - PostState(_controller.ReportError( + PostState(ReportIntentFailure( + intent, "고정 컷 섹션의 수동 입력 중에는 현재 입력 단계만 변경할 수 있습니다. 완료하거나 취소한 뒤 다른 편성 작업을 실행하세요.")); return; } if (IsOperatorMutationIntent(intent) && !CanAcceptOperatorMutation()) { - PostState(_controller.ReportError( + PostState(ReportIntentFailure( + intent, "PREPARE 또는 송출 중에는 플레이리스트와 운영 데이터를 변경할 수 없습니다. TAKE OUT 후 다시 시도하세요.")); return; } @@ -433,6 +462,7 @@ public sealed partial class MainWindow : Window PostState(_controller.Current); } + dispatchStarted = true; var state = await HandleIntentAsync(intent, _lifetimeCancellation.Token); ObserveOperatorMutationQuarantine(state); if (!_closing) @@ -450,8 +480,12 @@ public sealed partial class MainWindow : Window catch { _intentBusy = false; - PostState(_controller.ReportError( - "요청을 처리하지 못했습니다. 데이터베이스 연결 상태를 확인하세요.")); + var failureState = ReportIntentFailure( + intent, + "요청을 처리하지 못했습니다. 데이터베이스 연결 상태를 확인하세요.", + writeOutcomeMayBeUnknown: dispatchStarted); + ObserveOperatorMutationQuarantine(failureState); + PostState(failureState); } finally { @@ -467,6 +501,82 @@ public sealed partial class MainWindow : Window } } + private static bool IsNamedPlaylistModalIntent(LegacyUiIntent intent) => + intent is LegacyRefreshNamedPlaylistsIntent or + LegacySelectNamedPlaylistIntent or + LegacyLoadSelectedNamedPlaylistIntent or + LegacyLoadNamedPlaylistByIdIntent or + LegacyCreateNamedPlaylistIntent or + LegacySaveCurrentNamedPlaylistIntent or + LegacySaveCurrentNamedPlaylistToIntent or + LegacyDeleteSelectedNamedPlaylistIntent; + + private LegacyOperatorSnapshot ReportIntentFailure( + LegacyUiIntent intent, + string message, + bool writeOutcomeMayBeUnknown = false) => + intent switch + { + LegacyRefreshNamedPlaylistsIntent refresh + when !string.IsNullOrWhiteSpace(refresh.RequestId) => + _controller.ReportNamedPlaylistCommandFailure( + "refresh-named-playlists", + refresh.RequestId, + message), + LegacySelectNamedPlaylistIntent selection + when !string.IsNullOrWhiteSpace(selection.RequestId) => + _controller.ReportNamedPlaylistCommandFailure( + "select-named-playlist", + selection.RequestId, + message), + LegacyCreateNamedPlaylistIntent create + when writeOutcomeMayBeUnknown && + !string.IsNullOrWhiteSpace(create.RequestId) => + _controller.ReportNamedPlaylistWriteOutcomeUnknown( + "create-named-playlist", + create.RequestId, + message), + LegacyCreateNamedPlaylistIntent create + when !string.IsNullOrWhiteSpace(create.RequestId) => + _controller.ReportNamedPlaylistCommandFailure( + "create-named-playlist", + create.RequestId, + message), + LegacyDeleteSelectedNamedPlaylistIntent delete + when writeOutcomeMayBeUnknown && + !string.IsNullOrWhiteSpace(delete.RequestId) => + _controller.ReportNamedPlaylistWriteOutcomeUnknown( + "delete-selected-named-playlist", + delete.RequestId, + message), + LegacyDeleteSelectedNamedPlaylistIntent delete + when !string.IsNullOrWhiteSpace(delete.RequestId) => + _controller.ReportNamedPlaylistCommandFailure( + "delete-selected-named-playlist", + delete.RequestId, + message), + LegacyLoadNamedPlaylistByIdIntent load + when !string.IsNullOrWhiteSpace(load.DefinitionId) => + _controller.ReportNamedPlaylistCommandFailure( + "load-named-playlist-by-id", + load.DefinitionId, + message), + LegacySaveCurrentNamedPlaylistToIntent save + when writeOutcomeMayBeUnknown && + !string.IsNullOrWhiteSpace(save.DefinitionId) => + _controller.ReportNamedPlaylistWriteOutcomeUnknown( + "save-current-named-playlist-to", + save.DefinitionId, + message), + LegacySaveCurrentNamedPlaylistToIntent save + when !string.IsNullOrWhiteSpace(save.DefinitionId) => + _controller.ReportNamedPlaylistCommandFailure( + "save-current-named-playlist-to", + save.DefinitionId, + message), + _ => _controller.ReportError(message) + }; + private async Task HandleIntentAsync( LegacyUiIntent intent, CancellationToken cancellationToken) @@ -714,10 +824,14 @@ public sealed partial class MainWindow : Window cancellationToken), LegacyDeleteOperatorCatalogIntent => await _controller.DeleteOperatorCatalogAsync(cancellationToken), - LegacyRefreshNamedPlaylistsIntent => - await _controller.RefreshNamedPlaylistsAsync(cancellationToken), + LegacyRefreshNamedPlaylistsIntent refresh => + await RefreshNamedPlaylistsForDispatchAsync( + refresh.RequestId, + cancellationToken), LegacySelectNamedPlaylistIntent selection => - _controller.SelectNamedPlaylist(selection.DefinitionId), + SelectNamedPlaylistForDispatch( + selection.DefinitionId, + selection.RequestId), LegacyLoadSelectedNamedPlaylistIntent => await _controller.LoadSelectedNamedPlaylistAsync(cancellationToken), LegacyLoadNamedPlaylistByIdIntent load => @@ -725,8 +839,9 @@ public sealed partial class MainWindow : Window load.DefinitionId, cancellationToken), LegacyCreateNamedPlaylistIntent create => - await _controller.CreateNamedPlaylistAsync( + await CreateNamedPlaylistForDispatchAsync( create.Title, + create.RequestId, cancellationToken), LegacySaveCurrentNamedPlaylistIntent => await _controller.SaveCurrentNamedPlaylistAsync(cancellationToken), @@ -734,8 +849,10 @@ public sealed partial class MainWindow : Window await _controller.SaveCurrentNamedPlaylistToAsync( save.DefinitionId, cancellationToken), - LegacyDeleteSelectedNamedPlaylistIntent => - await _controller.DeleteSelectedNamedPlaylistAsync(cancellationToken), + LegacyDeleteSelectedNamedPlaylistIntent delete => + await DeleteNamedPlaylistForDispatchAsync( + delete.RequestId, + cancellationToken), LegacyOpenManualFinancialIntent manual => await _controller.OpenManualFinancialAsync( manual.Screen, @@ -835,18 +952,280 @@ public sealed partial class MainWindow : Window }; } + private async Task RefreshNamedPlaylistsForDispatchAsync( + string requestId, + CancellationToken cancellationToken) + { + var state = await _controller.RefreshNamedPlaylistsAsync(cancellationToken); + var succeeded = state.NamedPlaylist?.ReadStatus == + LegacyNamedPlaylistReadStatus.Ready; + return _controller.CompleteNamedPlaylistDispatch( + "refresh-named-playlists", + requestId, + succeeded); + } + + private LegacyOperatorSnapshot SelectNamedPlaylistForDispatch( + string definitionId, + string requestId) + { + var state = _controller.SelectNamedPlaylist(definitionId); + var succeeded = string.Equals( + state.NamedPlaylist?.SelectedDefinitionId, + definitionId, + StringComparison.Ordinal); + return _controller.CompleteNamedPlaylistDispatch( + "select-named-playlist", + requestId, + succeeded); + } + + private async Task CreateNamedPlaylistForDispatchAsync( + string title, + string requestId, + CancellationToken cancellationToken) + { + var previousRevision = _controller.Current.NamedPlaylist?.Revision ?? -1; + var state = await _controller.CreateNamedPlaylistAsync(title, cancellationToken); + var succeeded = (state.NamedPlaylist?.Revision ?? -1) > previousRevision && + IsCommittedNamedPlaylistMutation(state.NamedPlaylist?.LastMutationOutcome); + return _controller.CompleteNamedPlaylistDispatch( + "create-named-playlist", + requestId, + succeeded); + } + + private async Task DeleteNamedPlaylistForDispatchAsync( + string requestId, + CancellationToken cancellationToken) + { + var previousRevision = _controller.Current.NamedPlaylist?.Revision ?? -1; + var state = await _controller.DeleteSelectedNamedPlaylistAsync(cancellationToken); + var succeeded = (state.NamedPlaylist?.Revision ?? -1) > previousRevision && + IsCommittedNamedPlaylistMutation(state.NamedPlaylist?.LastMutationOutcome); + return _controller.CompleteNamedPlaylistDispatch( + "delete-selected-named-playlist", + requestId, + succeeded); + } + + private static bool IsCommittedNamedPlaylistMutation( + LegacyNamedPlaylistMutationOutcome? outcome) => + outcome is LegacyNamedPlaylistMutationOutcome.CommittedFresh or + LegacyNamedPlaylistMutationOutcome.CommittedOptimistic; + private void PostState(LegacyOperatorSnapshot snapshot) { if (!_closing && _webViewReady && Browser.CoreWebView2 is not null) { + // SystemInformation.DragSize was evaluated on every original + // ListView.MouseDown. Refreshing at every native-to-web state boundary, + // in addition to the settings/DPI message hook, prevents a startup-only + // value from surviving a runtime Windows preference change. + RefreshInteractionMetrics(); Browser.CoreWebView2.PostWebMessageAsJson( LegacyBridgeProtocol.SerializeState( snapshot, _intentBusy, - CreatePlayoutSnapshot())); + CreatePlayoutSnapshot(), + interactionMetrics: _interactionMetrics)); } } + private void AttachInteractionMetricsRefresh() + { + if (_windowSubclassAttached || _windowHandle == 0) + { + return; + } + + try + { + _windowSubclassAttached = SetWindowSubclass( + _windowHandle, + _windowSubclassProcedure, + InteractionMetricsSubclassId, + 0); + } + catch (DllNotFoundException) + { + } + catch (EntryPointNotFoundException) + { + } + catch (BadImageFormatException) + { + } + catch (PlatformNotSupportedException) + { + } + } + + private void DetachInteractionMetricsRefresh() + { + if (!_windowSubclassAttached || _windowHandle == 0) + { + return; + } + + try + { + RemoveWindowSubclass( + _windowHandle, + _windowSubclassProcedure, + InteractionMetricsSubclassId); + } + catch (DllNotFoundException) + { + } + catch (EntryPointNotFoundException) + { + } + catch (BadImageFormatException) + { + } + catch (PlatformNotSupportedException) + { + } + finally + { + _windowSubclassAttached = false; + } + } + + private nint OnWindowSubclassMessage( + nint windowHandle, + uint message, + nuint wParam, + nint lParam, + nuint subclassId, + nuint referenceData) + { + if (message is WmSettingChange or WmDisplayChange or WmDpiChanged) + { + QueueInteractionMetricsRefresh(); + } + + return DefSubclassProc(windowHandle, message, wParam, lParam); + } + + private void QueueInteractionMetricsRefresh() + { + if (_closing || _interactionMetricsRefreshQueued) + { + return; + } + + _interactionMetricsRefreshQueued = true; + if (!DispatcherQueue.TryEnqueue( + Microsoft.UI.Dispatching.DispatcherQueuePriority.High, + () => + { + _interactionMetricsRefreshQueued = false; + if (_closing) + { + return; + } + + RefreshInteractionMetrics(); + if (_webViewReady) + { + PostState(_controller.Current); + } + })) + { + _interactionMetricsRefreshQueued = false; + } + } + + private void RefreshInteractionMetrics() + { + _interactionMetrics = ReadSystemInteractionMetrics(); + } + + private LegacyInteractionMetrics ReadSystemInteractionMetrics() + { + try + { + // GetSystemMetrics returns physical pixels and is not DPI-aware. + // Asking the DPI-aware API for its 96-DPI values produces the same + // logical DIP coordinate space used by XAML. The bridge also carries + // XamlRoot.RasterizationScale so JavaScript can account for a zoom + // value retained by an older WebView profile without private API use. + return LegacyInteractionMetrics.FromPixelsAtDpi( + GetSystemMetricsForDpi( + SmCxDrag, + LegacyInteractionMetrics.CssPixelsPerInch), + GetSystemMetricsForDpi( + SmCyDrag, + LegacyInteractionMetrics.CssPixelsPerInch), + LegacyInteractionMetrics.CssPixelsPerInch, + ReadHostRasterizationScale()); + } + catch (DllNotFoundException) + { + return CreateFallbackInteractionMetrics(); + } + catch (EntryPointNotFoundException) + { + return CreateFallbackInteractionMetrics(); + } + catch (BadImageFormatException) + { + return CreateFallbackInteractionMetrics(); + } + catch (PlatformNotSupportedException) + { + return CreateFallbackInteractionMetrics(); + } + } + + private LegacyInteractionMetrics CreateFallbackInteractionMetrics() => + LegacyInteractionMetrics.Default with + { + HostRasterizationScale = ReadHostRasterizationScale() + }; + + private double ReadHostRasterizationScale() + { + var scale = Root.XamlRoot?.RasterizationScale ?? 1d; + return double.IsFinite(scale) && scale > 0d ? scale : 1d; + } + + [DllImport("user32.dll", ExactSpelling = true)] + private static extern int GetSystemMetricsForDpi(int nIndex, uint dpi); + + [DllImport("comctl32.dll", ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetWindowSubclass( + nint windowHandle, + WindowSubclassProcedure subclassProcedure, + nuint subclassId, + nuint referenceData); + + [DllImport("comctl32.dll", ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool RemoveWindowSubclass( + nint windowHandle, + WindowSubclassProcedure subclassProcedure, + nuint subclassId); + + [DllImport("comctl32.dll", ExactSpelling = true)] + private static extern nint DefSubclassProc( + nint windowHandle, + uint message, + nuint wParam, + nint lParam); + + [UnmanagedFunctionPointer(CallingConvention.Winapi)] + private delegate nint WindowSubclassProcedure( + nint windowHandle, + uint message, + nuint wParam, + nint lParam, + nuint subclassId, + nuint referenceData); + private void OnProcessFailed(object? sender, CoreWebView2ProcessFailedEventArgs args) { ObserveOperatorMutationQuarantine( @@ -882,6 +1261,7 @@ public sealed partial class MainWindow : Window Root.Loaded -= OnRootLoaded; Closed -= OnClosed; DetachCloseConfirmation(); + DetachInteractionMetricsRefresh(); _lifetimeCancellation.Cancel(); ShutdownPlayoutRuntime(); ShutdownManualListsRuntime(); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js index 5cb5bb4..4f7bac8 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js @@ -34,6 +34,14 @@ const namedPlaylistDeleteButton = document.getElementById("named-playlist-delete-button"); const namedPlaylistCancelButton = document.getElementById("named-playlist-cancel-button"); const namedPlaylistConfirmButton = document.getElementById("named-playlist-confirm-button"); + const namedPlaylistConfirmationModal = document.getElementById( + "named-playlist-confirmation"); + const namedPlaylistConfirmationMessage = document.getElementById( + "named-playlist-confirmation-message"); + const namedPlaylistConfirmationYes = document.getElementById( + "named-playlist-confirmation-yes"); + const namedPlaylistConfirmationNo = document.getElementById( + "named-playlist-confirmation-no"); const manualButtons = Array.from(document.querySelectorAll("button[data-manual-screen]")); const manualModal = document.getElementById("manual-financial-modal"); const manualTitle = document.getElementById("manual-financial-title"); @@ -73,9 +81,33 @@ const operatorCatalogDelete = document.getElementById("operator-catalog-delete"); const operatorCatalogCancel = document.getElementById("operator-catalog-cancel"); const operatorCatalogSave = document.getElementById("operator-catalog-save"); + const namedPlaylistDialog = namedPlaylistModal.querySelector("[role='dialog']"); + const namedPlaylistConfirmationDialog = namedPlaylistConfirmationModal.querySelector( + "[role='alertdialog']"); + const manualDialog = manualModal.querySelector("[role='dialog']"); + const manualListDialog = manualListModal.querySelector("[role='dialog']"); + const operatorCatalogDialog = operatorCatalogModal.querySelector("[role='dialog']"); + const legacyAlertDialog = dialog.querySelector("[role='alertdialog']"); + if (!window.LegacyModalFocus || typeof window.LegacyModalFocus.create !== "function") { + throw new Error("Legacy modal focus support was not loaded."); + } + if (!window.LegacyCutDrag || + typeof window.LegacyCutDrag.createDragRectangle !== "function" || + typeof window.LegacyCutDrag.shouldStart !== "function" || + typeof window.LegacyCutDrag.toCssDragSize !== "function") { + throw new Error("Legacy cut drag support was not loaded."); + } + if (!window.LegacyNamedPlaylistSelection || + typeof window.LegacyNamedPlaylistSelection.create !== "function") { + throw new Error("Legacy named-playlist selection support was not loaded."); + } + const modalFocusManager = window.LegacyModalFocus.create({ document: document }); + const namedPlaylistSelection = window.LegacyNamedPlaylistSelection.create(); + modalFocusManager.start(); let uiBusy = false; let namedPlaylistMode = null; let namedPlaylistState = null; + let namedPlaylistConfirmation = null; let manualDraft = null; let manualDraftKey = ""; let operatorCatalogState = null; @@ -86,10 +118,10 @@ let comparisonTargetClickTimer = null; let manualViResultClickTimer = null; let operatorCatalogStockClickTimer = null; - let namedPlaylistResultClickTimer = null; let pendingNamedPlaylistCommand = null; + let nextNamedPlaylistRequestId = 1; let lastCommandSequence = 0; - const selectedCutsDragToken = "legacy-selected-cuts"; + let operatorMutationLocked = true; const playlistDragMime = "text/x-mbn-playlist-row"; const operatorCatalogDragMime = "text/x-mbn-operator-catalog-row"; let draggedPlaylistRowId = null; @@ -97,6 +129,8 @@ let playlistDragBlockedRowId = null; let playlistMutationLocked = false; let cutSelectionEnabled = false; + let cutDragSize = { width: 4, height: 4 }; + let cutDragGesture = null; let lastPresentedPlayoutRowKey = ""; let draggedTabId = null; let tabDragLastTargetId = null; @@ -106,9 +140,80 @@ let operatorCatalogMutationLocked = false; let renderedTreeTabId = null; let treeInteractionLocked = false; + let treeFocusOwnedBeforeRender = false; const treeUiStateByTab = new Map(); const pendingTreeResetTabs = new Set(); + function preferredModalFocus(modal) { + if (modal === legacyAlertDialog) return dialogOk; + if (modal === namedPlaylistConfirmationDialog) return namedPlaylistConfirmationYes; + if (modal === namedPlaylistDialog) { + return namedPlaylistDefinitions.querySelector("button[aria-selected='true']") || + namedPlaylistDefinitions.querySelector("button") || namedPlaylistNewTitle; + } + if (modal === manualDialog) { + return manualWorkspace.querySelector("button.selected[data-manual-result-id]") || + manualWorkspace.querySelector("button[data-manual-result-id]") || + manualWorkspace.querySelector(".manual-financial-list-header"); + } + if (modal === manualListDialog) { + return manualListWorkspace.querySelector("input:not([disabled])") || + manualListWorkspace.querySelector("button:not([disabled])"); + } + if (modal === operatorCatalogDialog) { + if (!operatorCatalogEditor.hidden && !operatorCatalogName.disabled) { + return operatorCatalogName; + } + return operatorCatalogResults.querySelector("button[aria-selected='true']") || + operatorCatalogResults.querySelector("button") || operatorCatalogQuery; + } + return null; + } + + function createModalOpenerReference() { + const opener = document.activeElement; + if (!renderedTreeTabId || !categoryTree.contains(opener)) return opener; + + captureTreeUiState(renderedTreeTabId); + const node = opener.closest("[data-tree-node-key]"); + const descriptor = { + tabId: renderedTreeTabId, + controlId: opener.id || null, + nodeKey: node ? node.dataset.treeNodeKey : null, + selectionStart: Number.isInteger(opener.selectionStart) ? opener.selectionStart : null, + selectionEnd: Number.isInteger(opener.selectionEnd) ? opener.selectionEnd : null + }; + return function resolveTreeModalOpener() { + if (descriptor.tabId !== renderedTreeTabId) return null; + let resolved = descriptor.controlId + ? document.getElementById(descriptor.controlId) : null; + if (!resolved && descriptor.nodeKey) { + resolved = findTreeNodeByKey(descriptor.nodeKey); + } + if (!resolved || !categoryTree.contains(resolved) || resolved.disabled === true) { + return null; + } + if (Number.isInteger(descriptor.selectionStart) && + Number.isInteger(descriptor.selectionEnd) && + typeof resolved.setSelectionRange === "function") { + try { + resolved.setSelectionRange(descriptor.selectionStart, descriptor.selectionEnd); + } catch (_) { + // A recreated non-text control may expose, but reject, selection ranges. + } + } + return resolved; + }; + } + + function rememberModalOpener(modal) { + if (modal) modalFocusManager.rememberOpener(modal, createModalOpenerReference()); + } + + function syncModalFocus() { + modalFocusManager.sync(preferredModalFocus); + } + function activeTreeTabId(state) { if (!state || !Array.isArray(state.tabs)) return null; const active = state.tabs.find(function (tab) { return tab.isActive === true; }); @@ -214,19 +319,33 @@ }); const selected = categoryTree.querySelector( "[data-tree-node-key].tree-node-selected"); - const active = categoryTree.contains(document.activeElement) - ? document.activeElement.closest("[data-tree-node-key]") + const activeElement = categoryTree.contains(document.activeElement) + ? document.activeElement + : null; + const active = activeElement + ? activeElement.closest("[data-tree-node-key]") + : null; + const controlFocus = activeElement && activeElement.id + ? { + id: activeElement.id, + selectionStart: Number.isInteger(activeElement.selectionStart) + ? activeElement.selectionStart : null, + selectionEnd: Number.isInteger(activeElement.selectionEnd) + ? activeElement.selectionEnd : null + } : null; treeUiStateByTab.set(tabId, { openSections: openSections, selectedKey: selected ? selected.dataset.treeNodeKey : previous.selectedKey || null, - focusKey: active ? active.dataset.treeNodeKey : null, + focusKey: active ? active.dataset.treeNodeKey : previous.focusKey || null, + controlFocus: activeElement ? controlFocus : previous.controlFocus || null, scrollTop: categoryTree.scrollTop }); } function beginTreeRender(state) { const nextTabId = activeTreeTabId(state); + treeFocusOwnedBeforeRender = categoryTree.contains(document.activeElement); if (renderedTreeTabId) captureTreeUiState(renderedTreeTabId); if (nextTabId !== renderedTreeTabId) { renderedTreeTabId = nextTabId; @@ -250,7 +369,7 @@ syncTreeSectionExpanded(section); }); const firstRoot = roots[0]; - selectTreeNode(firstRoot, true); + selectTreeNode(firstRoot, !modalFocusManager.getActiveModal()); if (typeof firstRoot.scrollIntoView === "function") { firstRoot.scrollIntoView({ block: "nearest", inline: "nearest" }); } @@ -278,15 +397,41 @@ } const selected = findTreeNodeByKey(saved.selectedKey); if (selected) selectTreeNode(selected, false); + const canRestoreFocus = treeFocusOwnedBeforeRender && + !modalFocusManager.getActiveModal(); const focused = findTreeNodeByKey(saved.focusKey); - if (focused && typeof focused.focus === "function") { + if (canRestoreFocus && focused && typeof focused.focus === "function") { focused.focus({ preventScroll: true }); } + if (canRestoreFocus && saved.controlFocus && saved.controlFocus.id) { + const control = document.getElementById(saved.controlFocus.id); + if (control && categoryTree.contains(control) && control.disabled !== true && + typeof control.focus === "function") { + control.focus({ preventScroll: true }); + if (Number.isInteger(saved.controlFocus.selectionStart) && + Number.isInteger(saved.controlFocus.selectionEnd) && + typeof control.setSelectionRange === "function") { + try { + control.setSelectionRange( + saved.controlFocus.selectionStart, + saved.controlFocus.selectionEnd); + } catch (_) { + // Non-text controls can expose selection properties without accepting a range. + } + } + } + } if (Number.isFinite(saved.scrollTop)) categoryTree.scrollTop = saved.scrollTop; } function send(type, payload) { - if (webview && !uiBusy) webview.postMessage({ type: type, payload: payload || {} }); + if (!webview || uiBusy) return false; + try { + webview.postMessage({ type: type, payload: payload || {} }); + return true; + } catch (_) { + return false; + } } function isOperatorMutationLocked(state) { @@ -491,11 +636,94 @@ function pointerPosition(event) { const bounds = cutList.getBoundingClientRect(); return { - x: Math.max(0, Math.round(event.clientX - bounds.left)), - y: Math.max(0, Math.round(event.clientY - bounds.top)) + x: event.clientX - bounds.left, + y: event.clientY - bounds.top }; } + function pointInsideElement(element, event) { + if (!element || typeof element.getBoundingClientRect !== "function") return false; + const bounds = element.getBoundingClientRect(); + return event.clientX >= bounds.left && event.clientX < bounds.right && + event.clientY >= bounds.top && event.clientY < bounds.bottom; + } + + function clearCutDragGesture(releaseCapture) { + const gesture = cutDragGesture; + if (!gesture) return; + cutDragGesture = null; + gesture.element.classList.remove("dragging"); + playlistDropZone.classList.remove("cut-drag-over"); + if (releaseCapture !== false && + typeof gesture.element.hasPointerCapture === "function" && + gesture.element.hasPointerCapture(gesture.pointerId) && + typeof gesture.element.releasePointerCapture === "function") { + try { + gesture.element.releasePointerCapture(gesture.pointerId); + } catch (_) { + // The browser may release capture between the key event and cleanup. + } + } + } + + function beginCutDragGesture(element, event, position) { + clearCutDragGesture(); + if (event.button !== 0) return; + cutDragGesture = { + element: element, + pointerId: event.pointerId, + down: position, + rectangle: window.LegacyCutDrag.createDragRectangle(position, cutDragSize), + started: false + }; + if (typeof element.setPointerCapture === "function") { + try { + element.setPointerCapture(event.pointerId); + } catch (_) { + // Pointer capture can fail if the WebView has already released it. + } + } + } + + function onCutPointerMove(event) { + const gesture = cutDragGesture; + if (!gesture || gesture.element !== event.currentTarget || + gesture.pointerId !== event.pointerId) return; + if ((event.buttons & 1) !== 1) { + clearCutDragGesture(); + return; + } + if (!gesture.started && + window.LegacyCutDrag.shouldStart(gesture.rectangle, pointerPosition(event))) { + gesture.started = true; + gesture.element.classList.add("dragging"); + } + if (gesture.started) { + event.preventDefault(); + playlistDropZone.classList.toggle( + "cut-drag-over", + !playlistMutationLocked && pointInsideElement(playlistDropZone, event)); + } + } + + function onCutPointerUp(event) { + const gesture = cutDragGesture; + if (!gesture || gesture.element !== event.currentTarget || + gesture.pointerId !== event.pointerId) return; + const shouldDrop = gesture.started && !playlistMutationLocked && + pointInsideElement(playlistDropZone, event); + clearCutDragGesture(); + if (shouldDrop) send("drop-selected-cuts", {}); + } + + function onCutPointerCancel(event) { + const gesture = cutDragGesture; + if (gesture && gesture.element === event.currentTarget && + gesture.pointerId === event.pointerId) { + clearCutDragGesture(event.type !== "lostpointercapture"); + } + } + function legacyCutKey(key) { return { ArrowUp: "arrow-up", @@ -533,7 +761,7 @@ element.setAttribute("role", "option"); element.dataset.physicalIndex = String(row.physicalIndex); element.tabIndex = -1; - element.draggable = true; + element.draggable = false; const number = document.createElement("span"); number.className = "cut-number"; @@ -551,11 +779,18 @@ physicalIndex: row.physicalIndex, control: event.ctrlKey, shift: event.shiftKey, - x: position.x, - y: position.y, + // C# retains integer coordinates only for the native double-click + // equality contract. Drag geometry keeps the raw fractional CSS point. + x: Math.round(position.x), + y: Math.round(position.y), timestampMilliseconds: Math.max(0, Math.round(event.timeStamp)) }); + beginCutDragGesture(element, event, position); }); + element.addEventListener("pointermove", onCutPointerMove); + element.addEventListener("pointerup", onCutPointerUp); + element.addEventListener("pointercancel", onCutPointerCancel); + element.addEventListener("lostpointercapture", onCutPointerCancel); element.addEventListener("keydown", function (event) { const key = legacyCutKey(event.key); if (!key) return; @@ -572,14 +807,6 @@ shift: event.shiftKey }); }); - element.addEventListener("dragstart", function (event) { - if (playlistMutationLocked || !event.dataTransfer) { - event.preventDefault(); - return; - } - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("text/plain", selectedCutsDragToken); - }); return element; } @@ -592,6 +819,10 @@ // renderCuts precedes renderPlaylist, so publish the same lock before a // cut row can initiate an append drag for this state revision. playlistMutationLocked = cutDragLocked; + if (!cutSelectionEnabled || cutDragLocked || + (cutDragGesture && !cutList.contains(cutDragGesture.element))) { + clearCutDragGesture(); + } state.cutRows.forEach(function (row, index) { let element = cutList.children.item(index); if (!element || element.dataset.physicalIndex !== String(row.physicalIndex)) { @@ -605,18 +836,24 @@ element.className = "cut-row" + (row.isSelected ? " selected" : "") + - (row.isSeparator ? " separator" : ""); + (row.isSeparator ? " separator" : "") + + (cutDragGesture && cutDragGesture.element === element && cutDragGesture.started + ? " dragging" : ""); element.setAttribute("aria-selected", row.isSelected ? "true" : "false"); element.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false"); element.tabIndex = row.isFocused === true || (focusedCutIndex < 0 && index === 0) ? 0 : -1; - element.draggable = !cutDragLocked; + element.draggable = false; + element.dataset.cutDragEnabled = cutSelectionEnabled ? "true" : "false"; }); while (cutList.children.length > state.cutRows.length) { cutList.lastElementChild.remove(); } + if (cutDragGesture && !cutList.contains(cutDragGesture.element)) { + clearCutDragGesture(); + } } cutList.addEventListener("pointerdown", function (event) { @@ -1772,6 +2009,7 @@ return; } + if (manualModal.hidden) rememberModalOpener(manualDialog); manualModal.hidden = false; manualTitle.textContent = manual.label + " · GraphE 수동 입력"; const detailKey = manual.screen + "|" + (manual.selectedRowId || "new") + "|" + @@ -1914,6 +2152,7 @@ return; } + if (manualListModal.hidden) rememberModalOpener(manualListDialog); manualListModal.hidden = false; const busy = state.isBusy === true; if (manual.screen !== "vi") { @@ -2144,24 +2383,210 @@ } function openNamedPlaylist(mode) { + if (operatorMutationLocked) return false; + namedPlaylistSelection.cancel(); namedPlaylistMode = mode; namedPlaylistTitle.textContent = mode === "save" ? "DB 재생목록 저장" : "DB 재생목록 불러오기"; namedPlaylistConfirmButton.textContent = mode === "save" ? "현재 목록 저장" : "불러오기"; namedPlaylistNewTitle.value = ""; + if (namedPlaylistModal.hidden) rememberModalOpener(namedPlaylistDialog); namedPlaylistModal.hidden = false; if (namedPlaylistState) { renderNamedPlaylist({ namedPlaylist: namedPlaylistState, isBusy: uiBusy }); } - send("refresh-named-playlists", {}); + syncModalFocus(); + beginNamedPlaylistBusyCycle("refresh-named-playlists", {}); + return true; } function closeNamedPlaylist() { - clearDelayedClick(namedPlaylistResultClickTimer); - namedPlaylistResultClickTimer = null; - pendingNamedPlaylistCommand = null; + if (isNamedPlaylistModalLocked() || namedPlaylistSelection.isPending()) return false; namedPlaylistMode = null; namedPlaylistModal.hidden = true; + syncModalFocus(); + return true; + } + + function isNamedPlaylistModalLocked() { + return uiBusy || pendingNamedPlaylistCommand !== null || + namedPlaylistConfirmation !== null || + !!(namedPlaylistState && namedPlaylistState.isWriteInProgress === true); + } + + function isNamedPlaylistActionLocked() { + return isNamedPlaylistModalLocked() || operatorMutationLocked || + namedPlaylistSelection.isPending(); + } + + function isNamedPlaylistDefinitionLocked() { + return operatorMutationLocked || pendingNamedPlaylistCommand !== null || + namedPlaylistConfirmation !== null || + !!(namedPlaylistState && namedPlaylistState.isWriteInProgress === true) || + (uiBusy && !namedPlaylistSelection.isPending()); + } + + function markNamedPlaylistVisualSelection(definitionId) { + namedPlaylistDefinitions + .querySelectorAll("button[data-named-playlist-definition-id]") + .forEach(function (button) { + const selected = button.dataset.namedPlaylistDefinitionId === definitionId; + button.setAttribute("aria-selected", selected ? "true" : "false"); + button.classList.toggle("selected", selected); + }); + } + + function lockNamedPlaylistSelectionActions() { + namedPlaylistClose.disabled = true; + namedPlaylistCancelButton.disabled = true; + namedPlaylistNewTitle.disabled = true; + namedPlaylistCreateButton.disabled = true; + namedPlaylistDeleteButton.disabled = true; + namedPlaylistConfirmButton.disabled = true; + } + + function beginNamedPlaylistSelection(definitionId) { + if (namedPlaylistSelection.isPending()) { + const pending = namedPlaylistSelection.begin( + definitionId, + namedPlaylistState && namedPlaylistState.selectedDefinitionId); + if (pending.accepted) markNamedPlaylistVisualSelection(definitionId); + return pending.accepted; + } + if (isNamedPlaylistModalLocked() || operatorMutationLocked) return false; + + const requestId = "named-ui-" + String(nextNamedPlaylistRequestId++); + const started = namedPlaylistSelection.begin( + definitionId, + namedPlaylistState && namedPlaylistState.selectedDefinitionId, + requestId, + lastCommandSequence + 1); + if (!started.accepted) return false; + markNamedPlaylistVisualSelection(definitionId); + if (!started.shouldSend) return true; + + lockNamedPlaylistSelectionActions(); + if (!send("select-named-playlist", { + definitionId: definitionId, + requestId: started.requestId + })) { + namedPlaylistSelection.cancel(); + restoreNamedPlaylistAfterConfirmation(); + return false; + } + return true; + } + + function runNamedPlaylistDoubleClick(definitionId, mode) { + if (mode === "save") { + return openNamedPlaylistConfirmation({ + kind: "save", + definitionId: definitionId, + message: "저장하겠습니까?", + showSavedAlert: false, + closeOnNo: true + }); + } + return beginNamedPlaylistCommand("load-named-playlist-by-id", definitionId, false); + } + + function lockNamedPlaylistControls() { + namedPlaylistModal.querySelectorAll("button, input").forEach(function (control) { + control.disabled = true; + }); + } + + function beginNamedPlaylistBusyCycle(type, payload) { + if (isNamedPlaylistActionLocked()) return false; + const requestId = "named-ui-" + String(nextNamedPlaylistRequestId++); + pendingNamedPlaylistCommand = { + command: type, + targetId: requestId, + minimumSequence: lastCommandSequence + 1, + closeOnSuccess: false, + showSavedAlert: false + }; + const correlatedPayload = Object.assign({}, payload || {}, { requestId: requestId }); + if (!send(type, correlatedPayload)) { + pendingNamedPlaylistCommand = null; + return false; + } + lockNamedPlaylistControls(); + return true; + } + + function beginNamedPlaylistCommand(command, definitionId, showSavedAlert) { + if (isNamedPlaylistActionLocked() || !definitionId) return false; + pendingNamedPlaylistCommand = { + command: command, + targetId: definitionId, + definitionId: definitionId, + minimumSequence: lastCommandSequence + 1, + closeOnSuccess: true, + showSavedAlert: showSavedAlert === true + }; + if (!send(command, { definitionId: definitionId })) { + pendingNamedPlaylistCommand = null; + return false; + } + lockNamedPlaylistControls(); + return true; + } + + function isMatchingNamedPlaylistReceipt(busy, pending, commandResult) { + const exact = !!pending && !!commandResult && + Number.isSafeInteger(commandResult.sequence) && + commandResult.sequence >= pending.minimumSequence && + commandResult.command === pending.command && + commandResult.targetId === pending.targetId; + return exact && (commandResult.succeeded === false || + (busy !== true && commandResult.succeeded === true)); + } + + function openNamedPlaylistConfirmation(options) { + if (!options || isNamedPlaylistActionLocked()) return false; + namedPlaylistConfirmation = options; + namedPlaylistConfirmationMessage.textContent = options.message; + if (namedPlaylistConfirmationModal.hidden) { + rememberModalOpener(namedPlaylistConfirmationDialog); + } + namedPlaylistConfirmationModal.hidden = false; + lockNamedPlaylistControls(); + syncModalFocus(); + return true; + } + + function restoreNamedPlaylistAfterConfirmation() { + if (!namedPlaylistState || namedPlaylistModal.hidden) return; + renderNamedPlaylist({ + namedPlaylist: namedPlaylistState, + isBusy: uiBusy, + commandResult: null, + statusKind: null + }); + } + + function resolveNamedPlaylistConfirmation(confirmed) { + const confirmation = namedPlaylistConfirmation; + if (!confirmation) return false; + namedPlaylistConfirmation = null; + namedPlaylistConfirmationModal.hidden = true; + syncModalFocus(); + + if (!confirmed) { + if (confirmation.closeOnNo === true) closeNamedPlaylist(); + else restoreNamedPlaylistAfterConfirmation(); + return true; + } + + const started = confirmation.kind === "delete" + ? beginNamedPlaylistBusyCycle("delete-selected-named-playlist", {}) + : beginNamedPlaylistCommand( + "save-current-named-playlist-to", + confirmation.definitionId, + confirmation.showSavedAlert === true); + if (!started) restoreNamedPlaylistAfterConfirmation(); + return started; } function renderNamedPlaylist(state) { @@ -2169,32 +2594,49 @@ namedPlaylistState = named || null; const busy = state.isBusy === true; const commandResult = state.commandResult; + const selectionResult = namedPlaylistSelection.reconcile( + commandResult, + named && named.selectedDefinitionId); + const deferredSelectionAction = selectionResult.deferredAction; if (commandResult && Number.isSafeInteger(commandResult.sequence)) { lastCommandSequence = Math.max(lastCommandSequence, commandResult.sequence); } - if (!busy && pendingNamedPlaylistCommand && commandResult && - commandResult.sequence >= pendingNamedPlaylistCommand.minimumSequence && - commandResult.command === pendingNamedPlaylistCommand.command && - commandResult.targetId === pendingNamedPlaylistCommand.definitionId) { + if (isMatchingNamedPlaylistReceipt( + busy, + pendingNamedPlaylistCommand, + commandResult + )) { + const completed = pendingNamedPlaylistCommand; const succeeded = commandResult.succeeded === true; pendingNamedPlaylistCommand = null; - if (succeeded) { + if (succeeded && completed.closeOnSuccess) { + if (completed.showSavedAlert) window.alert("저장되었습니다"); closeNamedPlaylist(); } } - dbLoadButton.disabled = busy || !named; - dbSaveButton.disabled = busy || !named || named.canMutate !== true; + dbLoadButton.disabled = operatorMutationLocked || busy || !named; + dbSaveButton.disabled = operatorMutationLocked || busy || !named || + named.canMutate !== true; if (!named || namedPlaylistModal.hidden) return; + const modalLocked = isNamedPlaylistActionLocked(); + const definitionLocked = isNamedPlaylistDefinitionLocked(); + const existingDefinitions = new Map(); + namedPlaylistDefinitions + .querySelectorAll("button[data-named-playlist-definition-id]") + .forEach(function (button) { + existingDefinitions.set(button.dataset.namedPlaylistDefinitionId, button); + }); const fragment = document.createDocumentFragment(); named.definitions.forEach(function (definition) { - const button = document.createElement("button"); + const button = existingDefinitions.get(definition.definitionId) || + document.createElement("button"); button.type = "button"; button.dataset.namedPlaylistDefinitionId = definition.definitionId; button.setAttribute("role", "option"); button.setAttribute("aria-selected", definition.isSelected ? "true" : "false"); button.className = definition.isSelected ? "selected" : ""; - button.disabled = busy || named.isWriteInProgress === true; + button.disabled = definitionLocked; button.textContent = definition.title; fragment.appendChild(button); }); @@ -2212,11 +2654,24 @@ namedPlaylistStatus.classList.toggle("error", named.isWriteQuarantined === true || named.statusKind === "error"); - namedPlaylistCreateButton.disabled = busy || named.canCreate !== true || + const closeLocked = isNamedPlaylistModalLocked() || + namedPlaylistSelection.isPending(); + namedPlaylistClose.disabled = closeLocked; + namedPlaylistCancelButton.disabled = closeLocked; + namedPlaylistNewTitle.disabled = modalLocked; + namedPlaylistCreateButton.disabled = modalLocked || named.canCreate !== true || !isCanonicalNamedPlaylistTitle(namedPlaylistNewTitle.value); - namedPlaylistDeleteButton.disabled = busy || named.canDelete !== true; - namedPlaylistConfirmButton.disabled = busy || + namedPlaylistDeleteButton.disabled = modalLocked || named.canDelete !== true; + namedPlaylistConfirmButton.disabled = modalLocked || (namedPlaylistMode === "save" ? named.canSave !== true : named.canLoad !== true); + namedPlaylistConfirmationYes.disabled = operatorMutationLocked || uiBusy || + pendingNamedPlaylistCommand !== null || + !!(namedPlaylistState && namedPlaylistState.isWriteInProgress === true); + if (deferredSelectionAction) { + runNamedPlaylistDoubleClick( + deferredSelectionAction.definitionId, + deferredSelectionAction.mode); + } } function clearOperatorCatalogDragVisuals() { @@ -2329,6 +2784,7 @@ clearOperatorCatalogDragVisuals(); } + if (operatorCatalogModal.hidden) rememberModalOpener(operatorCatalogDialog); operatorCatalogModal.hidden = false; const isTheme = catalog.entity === "theme"; const editing = catalog.mode === "create" || catalog.mode === "edit"; @@ -2512,22 +2968,46 @@ function renderDialog(state) { if (state.dialog && state.dialog.message) { dialogMessage.textContent = state.dialog.message; + if (dialog.hidden) rememberModalOpener(legacyAlertDialog); dialog.hidden = false; - dialogOk.focus(); } else { dialog.hidden = true; } } + function updateCutDragSize(state) { + const metrics = state && state.interactionMetrics; + if (!metrics || metrics.coordinateSpace !== "host-dip" || + !Number.isInteger(metrics.cutDragWidthDips) || + !Number.isInteger(metrics.cutDragHeightDips) || + metrics.cutDragWidthDips <= 0 || metrics.cutDragHeightDips <= 0 || + typeof metrics.hostRasterizationScale !== "number") return; + const nextDragSize = window.LegacyCutDrag.toCssDragSize({ + width: metrics.cutDragWidthDips, + height: metrics.cutDragHeightDips + }, metrics.hostRasterizationScale, window.devicePixelRatio); + cutDragSize = nextDragSize; + if (cutDragGesture && !cutDragGesture.started) { + // The cut-pointer-down reply is also a native state boundary. If a + // Windows preference change raced the previous rendered state, update + // the still-pending gesture before its next pointermove decision. + cutDragGesture.rectangle = window.LegacyCutDrag.createDragRectangle( + cutDragGesture.down, + nextDragSize); + } + } + function render(state) { const isBusy = state.isBusy === true; const treeTabId = beginTreeRender(state); uiBusy = isBusy; + operatorMutationLocked = isOperatorMutationLocked(state); document.body.classList.toggle("busy", isBusy); document.body.setAttribute("aria-busy", isBusy ? "true" : "false"); searchInput.readOnly = isBusy; searchButton.setAttribute("aria-disabled", isBusy ? "true" : "false"); if (document.activeElement !== searchInput) searchInput.value = state.searchText || ""; + updateCutDragSize(state); renderStockResults(state); renderCuts(state); renderTabs(state); @@ -2550,6 +3030,7 @@ renderPlaylist(state); renderNamedPlaylist(state); renderDialog(state); + syncModalFocus(); status.textContent = isBusy ? "종목을 검색하고 있습니다." : (state.statusMessage || ""); } @@ -2576,36 +3057,25 @@ const definition = event.target.closest("button[data-named-playlist-definition-id]"); if (!definition || definition.disabled) return; event.preventDefault(); - clearDelayedClick(namedPlaylistResultClickTimer); - namedPlaylistResultClickTimer = null; const definitionId = definition.dataset.namedPlaylistDefinitionId; - if (namedPlaylistMode === "save" && - !window.confirm("선택한 DB 재생목록에 현재 목록을 저장하시겠습니까?")) { + if (namedPlaylistSelection.isPending()) { + namedPlaylistSelection.defer(definitionId, { + definitionId: definitionId, + mode: namedPlaylistMode + }); return; } - const command = namedPlaylistMode === "save" - ? "save-current-named-playlist-to" : "load-named-playlist-by-id"; - pendingNamedPlaylistCommand = { - command: command, - definitionId: definitionId, - minimumSequence: lastCommandSequence + 1 - }; - send(command, { definitionId: definitionId }); + runNamedPlaylistDoubleClick(definitionId, namedPlaylistMode); }); namedPlaylistDefinitions.addEventListener("click", function (event) { const definition = event.target.closest("button[data-named-playlist-definition-id]"); if (definition && !definition.disabled) { - clearDelayedClick(namedPlaylistResultClickTimer); - namedPlaylistResultClickTimer = window.setTimeout(function () { - namedPlaylistResultClickTimer = null; - send("select-named-playlist", { - definitionId: definition.dataset.namedPlaylistDefinitionId - }); - }, 250); + beginNamedPlaylistSelection(definition.dataset.namedPlaylistDefinitionId); } }); namedPlaylistNewTitle.addEventListener("input", function () { - namedPlaylistCreateButton.disabled = uiBusy || !namedPlaylistState || + namedPlaylistCreateButton.disabled = isNamedPlaylistActionLocked() || + !namedPlaylistState || namedPlaylistState.canCreate !== true || !isCanonicalNamedPlaylistTitle(namedPlaylistNewTitle.value); }); @@ -2618,23 +3088,42 @@ namedPlaylistCreateButton.addEventListener("click", function () { const title = namedPlaylistNewTitle.value; if (isCanonicalNamedPlaylistTitle(title)) { - send("create-named-playlist", { title: title }); + beginNamedPlaylistBusyCycle("create-named-playlist", { title: title }); } }); namedPlaylistDeleteButton.addEventListener("click", function () { - const title = namedPlaylistState && namedPlaylistState.selectedTitle - ? "'" + namedPlaylistState.selectedTitle + "'" : "선택한 재생목록"; - if (window.confirm(title + "을(를) DB에서 삭제하겠습니까?")) { - send("delete-selected-named-playlist", {}); - } + openNamedPlaylistConfirmation({ + kind: "delete", + message: "프로그램이 삭제됩니다. 계속 하시겠습니까?", + closeOnNo: false + }); }); namedPlaylistConfirmButton.addEventListener("click", function () { + const definitionId = namedPlaylistState && namedPlaylistState.selectedDefinitionId; + if (!definitionId) return; if (namedPlaylistMode === "save") { - send("save-current-named-playlist", {}); + openNamedPlaylistConfirmation({ + kind: "save", + definitionId: definitionId, + message: "저장하겠습니까?", + showSavedAlert: true, + closeOnNo: true + }); } else if (namedPlaylistMode === "load") { - send("load-selected-named-playlist", {}); + beginNamedPlaylistCommand( + "load-named-playlist-by-id", + definitionId, + false); } }); + namedPlaylistConfirmationYes.addEventListener("click", function () { + if (!namedPlaylistConfirmationYes.disabled) { + resolveNamedPlaylistConfirmation(true); + } + }); + namedPlaylistConfirmationNo.addEventListener("click", function () { + resolveNamedPlaylistConfirmation(false); + }); manualButtons.forEach(function (button) { button.addEventListener("click", function () { @@ -2962,31 +3451,6 @@ searchOperatorCatalog(); }); - function hasDragType(dataTransfer, expectedType) { - return !!dataTransfer && Array.from(dataTransfer.types || []) - .some(function (type) { return type === expectedType; }); - } - - function isSelectedCutsDrop(dataTransfer) { - return hasDragType(dataTransfer, "text/plain") && - dataTransfer.getData("text/plain") === selectedCutsDragToken; - } - - playlistDropZone.addEventListener("dragover", function (event) { - // A playlist row also drops inside this ancestor. Only advertise the - // drop zone for the cut-list payload; otherwise the bubbled row drop - // would append the still-selected cut as an extra playlist entry. - if (playlistMutationLocked || - !hasDragType(event.dataTransfer, "text/plain")) return; - event.preventDefault(); - if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; - }); - playlistDropZone.addEventListener("drop", function (event) { - if (playlistMutationLocked || !isSelectedCutsDrop(event.dataTransfer)) return; - event.preventDefault(); - event.stopPropagation(); - send("drop-selected-cuts", {}); - }); playlistMoveUp.addEventListener("click", function () { send("move-selected-playlist-rows", { direction: "up" }); }); @@ -3420,13 +3884,31 @@ operatorCatalogDragBlockedRowId = null; }); document.addEventListener("keydown", function (event) { - if (event.key === "Delete" && !event.repeat && dialog.hidden && manualModal.hidden && - manualListModal.hidden && namedPlaylistModal.hidden && operatorCatalogModal.hidden) { + const cutDragShortcut = event.key === "Escape" || event.key === "F2" || + event.key === "F3" || event.key === "F8"; + if (cutDragGesture && cutDragGesture.started && cutDragShortcut) { + // Native DoDragDrop runs its own modal loop. MainForm shortcuts cannot run + // until that loop ends; Escape cancels the drag without issuing TAKE OUT. + event.preventDefault(); + if (event.key === "Escape") clearCutDragGesture(); + } 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) { + send("dismiss-dialog", {}); + } + } else if (event.key === "Delete" && !event.repeat && dialog.hidden && manualModal.hidden && + manualListModal.hidden && namedPlaylistModal.hidden && + namedPlaylistConfirmationModal.hidden && operatorCatalogModal.hidden) { // MainForm.KeyPreview invoked playlist deletion but did not suppress the key, // so an editor that owns focus must still perform its normal text deletion. if (!playlistMutationLocked) send("delete-selected-playlist-rows", {}); } else if (event.key === " " && !event.repeat && dialog.hidden && manualModal.hidden && - manualListModal.hidden && namedPlaylistModal.hidden && operatorCatalogModal.hidden && + manualListModal.hidden && namedPlaylistModal.hidden && + namedPlaylistConfirmationModal.hidden && operatorCatalogModal.hidden && event.target.closest(".playlist-data-row")) { event.preventDefault(); if (!playlistMutationLocked) { @@ -3439,26 +3921,11 @@ } } else if ((event.key === "Home" || event.key === "End") && !event.repeat && dialog.hidden && manualModal.hidden && manualListModal.hidden && - namedPlaylistModal.hidden && operatorCatalogModal.hidden) { + namedPlaylistModal.hidden && namedPlaylistConfirmationModal.hidden && + operatorCatalogModal.hidden) { // MainForm handled its playlist/timer side effect and then returned the // base key result, so a focused textbox/list control still receives Home/End. selectPlaylistBoundary(event.key === "Home" ? "first" : "last"); - } else if (event.key === "Escape" && !event.repeat && !namedPlaylistModal.hidden) { - event.preventDefault(); - closeNamedPlaylist(); - } else if (event.key === "Escape" && !event.repeat && !manualModal.hidden) { - event.preventDefault(); - clearDelayedClick(manualResultClickTimer); - manualResultClickTimer = null; - send("close-manual-financial", {}); - } else if (event.key === "Escape" && !event.repeat && !manualListModal.hidden) { - event.preventDefault(); - send("close-manual-list", {}); - } else if (event.key === "Escape" && !event.repeat && !operatorCatalogModal.hidden) { - event.preventDefault(); - clearDelayedClick(operatorCatalogStockClickTimer); - operatorCatalogStockClickTimer = null; - send("close-operator-catalog", {}); } }); dialogOk.addEventListener("click", function () { diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html index f33dcd9..8c7f31c 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html @@ -69,8 +69,10 @@ - - @@ -135,6 +137,17 @@ + + + + + diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/legacy-cut-drag.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/legacy-cut-drag.js new file mode 100644 index 0000000..a66c14e --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/legacy-cut-drag.js @@ -0,0 +1,109 @@ +(function (root, factory) { + "use strict"; + + const api = factory(); + + if (typeof module === "object" && module.exports) { + module.exports = api; + } + + if (root) { + root.LegacyCutDrag = api; + } +}(typeof globalThis !== "undefined" ? globalThis : this, function () { + "use strict"; + + function assertPoint(point, name) { + if (!point || typeof point !== "object" || + !Number.isFinite(point.x) || !Number.isFinite(point.y)) { + throw new TypeError(name + " must contain finite x and y numbers."); + } + } + + function assertDragSize(dragSize) { + if (!dragSize || typeof dragSize !== "object" || + !Number.isFinite(dragSize.width) || + !Number.isFinite(dragSize.height) || + dragSize.width <= 0 || dragSize.height <= 0) { + throw new RangeError( + "dragSize must contain positive finite width and height values."); + } + } + + function assertRectangle(rectangle) { + if (!rectangle || typeof rectangle !== "object" || + !Number.isFinite(rectangle.left) || + !Number.isFinite(rectangle.top) || + !Number.isFinite(rectangle.right) || + !Number.isFinite(rectangle.bottom) || + !Number.isFinite(rectangle.width) || + !Number.isFinite(rectangle.height) || + rectangle.width <= 0 || rectangle.height <= 0 || + rectangle.right !== rectangle.left + rectangle.width || + rectangle.bottom !== rectangle.top + rectangle.height) { + throw new TypeError("rectangle must be a valid legacy drag rectangle."); + } + } + + function createDragRectangle(down, dragSize) { + assertPoint(down, "down"); + assertDragSize(dragSize); + + const left = down.x - dragSize.width; + const top = down.y - dragSize.height; + + return Object.freeze({ + left: left, + top: top, + width: dragSize.width, + height: dragSize.height, + right: left + dragSize.width, + bottom: top + dragSize.height + }); + } + + function contains(rectangle, point) { + assertRectangle(rectangle); + assertPoint(point, "point"); + + // System.Drawing.Rectangle.Contains includes left/top and excludes + // right/bottom. The original mouse-down point is therefore outside. + return point.x >= rectangle.left && point.x < rectangle.right && + point.y >= rectangle.top && point.y < rectangle.bottom; + } + + function shouldStart(rectangle, point) { + return !contains(rectangle, point); + } + + function toCssDragSize(dipSize, hostRasterizationScale, devicePixelRatio) { + assertDragSize(dipSize); + + // Chromium's devicePixelRatio includes both the XAML monitor scale and + // CoreWebView2 browser zoom. Dividing the DIP metric by their ratio maps + // the Windows threshold into PointerEvent clientX/clientY CSS pixels. + const hostScale = Number.isFinite(hostRasterizationScale) && + hostRasterizationScale > 0 ? hostRasterizationScale : 1; + const deviceScale = Number.isFinite(devicePixelRatio) && + devicePixelRatio > 0 ? devicePixelRatio : hostScale; + const candidateZoom = deviceScale / hostScale; + // WebView2 normalizes controller zoom into its supported practical range. + // Treat values outside that range as an untrustworthy measurement and use + // a neutral ratio instead of producing an unusable threshold. + const browserZoom = Number.isFinite(candidateZoom) && + candidateZoom >= 0.25 && candidateZoom <= 5 + ? candidateZoom + : 1; + return Object.freeze({ + width: dipSize.width / browserZoom, + height: dipSize.height / browserZoom + }); + } + + return Object.freeze({ + createDragRectangle: createDragRectangle, + contains: contains, + shouldStart: shouldStart, + toCssDragSize: toCssDragSize + }); +})); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/legacy-modal-focus.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/legacy-modal-focus.js new file mode 100644 index 0000000..48f354a --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/legacy-modal-focus.js @@ -0,0 +1,354 @@ +(function (root, factory) { + "use strict"; + + const api = factory(); + if (typeof module === "object" && module.exports) { + module.exports = api; + } else { + root.LegacyModalFocus = api; + } +}(typeof globalThis !== "undefined" ? globalThis : this, function () { + "use strict"; + + const defaultModalSelector = "[role='dialog'], [role='alertdialog']"; + const focusableSelector = [ + "a[href]", + "area[href]", + "button", + "input", + "select", + "textarea", + "iframe", + "object", + "embed", + "summary", + "audio[controls]", + "video[controls]", + "[contenteditable]", + "[tabindex]" + ].join(","); + + function attribute(element, name) { + return element && typeof element.getAttribute === "function" + ? element.getAttribute(name) : null; + } + + function hasAttribute(element, name) { + return !!element && typeof element.hasAttribute === "function" && + element.hasAttribute(name); + } + + function computedStyle(element, documentObject) { + const view = documentObject && documentObject.defaultView; + if (!view || typeof view.getComputedStyle !== "function") return null; + try { + return view.getComputedStyle(element); + } catch (_) { + return null; + } + } + + function isVisible(element, documentObject) { + if (!element) return false; + const documentRef = documentObject || element.ownerDocument; + if (documentRef && typeof documentRef.contains === "function" && + !documentRef.contains(element)) return false; + + for (let current = element; current; current = current.parentElement) { + if (current.hidden === true || current.inert === true || + hasAttribute(current, "hidden") || hasAttribute(current, "inert") || + attribute(current, "aria-hidden") === "true") return false; + const style = computedStyle(current, documentRef); + if (style && (style.display === "none" || style.visibility === "hidden" || + style.visibility === "collapse" || style.contentVisibility === "hidden")) { + return false; + } + } + return true; + } + + function isNativeFocusable(element) { + const tagName = String(element && element.tagName || "").toLowerCase(); + if (["button", "select", "textarea", "iframe", "object", "embed", "summary"] + .includes(tagName)) return true; + if (tagName === "input") return String(attribute(element, "type") || "").toLowerCase() !== "hidden"; + if (tagName === "a" || tagName === "area") return hasAttribute(element, "href"); + if (tagName === "audio" || tagName === "video") return hasAttribute(element, "controls"); + return hasAttribute(element, "contenteditable") && + attribute(element, "contenteditable") !== "false"; + } + + function isFocusable(element, documentObject) { + if (!element || typeof element.focus !== "function" || + !isVisible(element, documentObject)) return false; + if (element.disabled === true || hasAttribute(element, "disabled") || + attribute(element, "aria-disabled") === "true") return false; + if (typeof element.matches === "function") { + try { + if (element.matches(":disabled")) return false; + } catch (_) { + // Minimal WebView test doubles do not have to implement selector matching. + } + } + + const tabIndexAttribute = attribute(element, "tabindex"); + if (tabIndexAttribute !== null && Number(tabIndexAttribute) < 0) return false; + return isNativeFocusable(element) || + (tabIndexAttribute !== null && Number(tabIndexAttribute) >= 0); + } + + function getFocusableElements(modal, documentObject) { + if (!modal || typeof modal.querySelectorAll !== "function") return []; + return Array.from(modal.querySelectorAll(focusableSelector)).filter(function (element) { + return isFocusable(element, documentObject || modal.ownerDocument); + }); + } + + function findVisibleModals(documentObject, selector) { + if (!documentObject || typeof documentObject.querySelectorAll !== "function") return []; + return Array.from(documentObject.querySelectorAll(selector || defaultModalSelector)) + .filter(function (modal) { return isVisible(modal, documentObject); }); + } + + function findVisibleModal(documentObject, selector) { + const modals = findVisibleModals(documentObject, selector); + return modals.length > 0 ? modals[modals.length - 1] : null; + } + + function contains(container, element) { + return !!container && !!element && typeof container.contains === "function" && + container.contains(element); + } + + function focus(element) { + if (!element || typeof element.focus !== "function") return false; + try { + element.focus({ preventScroll: true }); + } catch (_) { + try { + element.focus(); + } catch (_) { + return false; + } + } + return true; + } + + function resolveFocusTarget(target) { + let resolved = target; + const visited = new Set(); + while (typeof resolved === "function") { + if (visited.has(resolved)) return null; + visited.add(resolved); + try { + resolved = resolved(); + } catch (_) { + return null; + } + } + return resolved || null; + } + + function preferredElement(modal, preferred, documentObject) { + let candidate = preferred; + if (typeof candidate === "function") candidate = candidate(modal); + if (typeof candidate === "string" && typeof modal.querySelector === "function") { + candidate = modal.querySelector(candidate); + } + if (isFocusable(candidate, documentObject) && contains(modal, candidate)) return candidate; + + if (typeof modal.querySelector === "function") { + candidate = modal.querySelector("[data-modal-initial-focus], [autofocus]"); + if (isFocusable(candidate, documentObject) && contains(modal, candidate)) return candidate; + } + const focusable = getFocusableElements(modal, documentObject); + return focusable.length > 0 ? focusable[0] : null; + } + + function create(options) { + const settings = options || {}; + const documentObject = settings.document || + (typeof document !== "undefined" ? document : null); + if (!documentObject) throw new Error("LegacyModalFocus requires a document."); + + const selector = settings.modalSelector || defaultModalSelector; + const entries = new Map(); + let activeModal = null; + let observer = null; + let started = false; + + function entryFor(modal, opener) { + let entry = entries.get(modal); + if (!entry) { + entry = { opener: opener || null, lastFocused: null }; + entries.set(modal, entry); + } + return entry; + } + + function rememberOpener(modal, opener) { + if (!modal) return null; + const entry = entryFor(modal, opener || documentObject.activeElement); + if (!entry.opener) entry.opener = opener || documentObject.activeElement || null; + return entry.opener; + } + + function modalContaining(element) { + for (const modal of entries.keys()) { + if (contains(modal, element)) return modal; + } + return null; + } + + function unwindReturnTarget(entry, visibleSet) { + let target = resolveFocusTarget(entry && entry.opener); + const visited = new Set(); + while (target) { + const owner = modalContaining(target); + if (!owner || visibleSet.has(owner)) break; + if (visited.has(owner)) return null; + visited.add(owner); + const ownerEntry = entries.get(owner); + target = resolveFocusTarget(ownerEntry && ownerEntry.opener); + } + return isFocusable(target, documentObject) ? target : null; + } + + function cleanClosedEntries(visibleSet) { + for (const modal of Array.from(entries.keys())) { + if (!visibleSet.has(modal)) entries.delete(modal); + } + } + + function sync(preferredFocus) { + const visible = findVisibleModals(documentObject, selector); + const visibleSet = new Set(visible); + const nextModal = visible.length > 0 ? visible[visible.length - 1] : null; + const previousModal = activeModal; + const previousEntry = previousModal && entries.get(previousModal); + + if (previousModal && previousEntry && contains(previousModal, documentObject.activeElement)) { + previousEntry.lastFocused = documentObject.activeElement; + } + + if (nextModal !== previousModal) { + if (nextModal) { + const opener = documentObject.activeElement; + const nextEntry = entryFor(nextModal, contains(nextModal, opener) ? null : opener); + activeModal = nextModal; + + if (previousModal && !visibleSet.has(previousModal)) { + const returned = unwindReturnTarget(previousEntry, visibleSet); + if (returned && contains(nextModal, returned)) { + nextEntry.lastFocused = returned; + focus(returned); + } else { + focus(nextEntry.lastFocused && isFocusable(nextEntry.lastFocused, documentObject) + ? nextEntry.lastFocused + : preferredElement(nextModal, preferredFocus || settings.preferredFocus, + documentObject)); + } + } else { + focus(preferredElement(nextModal, preferredFocus || settings.preferredFocus, + documentObject)); + } + } else { + activeModal = null; + focus(unwindReturnTarget(previousEntry, visibleSet)); + } + } else if (nextModal && preferredFocus && + !contains(nextModal, documentObject.activeElement)) { + focus(preferredElement(nextModal, preferredFocus, documentObject)); + } + + cleanClosedEntries(visibleSet); + return activeModal; + } + + function onFocusIn(event) { + const modal = activeModal || findVisibleModal(documentObject, selector); + if (!modal || !contains(modal, event.target)) return; + entryFor(modal, null).lastFocused = event.target; + } + + function handleKeydown(event) { + if (!event || event.defaultPrevented || event.key !== "Tab") return false; + const modal = activeModal || sync(); + if (!modal) return false; + const focusable = getFocusableElements(modal, documentObject); + if (focusable.length === 0) { + if (typeof event.preventDefault === "function") event.preventDefault(); + if (!hasAttribute(modal, "tabindex") && typeof modal.setAttribute === "function") { + modal.setAttribute("tabindex", "-1"); + } + focus(modal); + return true; + } + + const current = documentObject.activeElement; + const index = focusable.indexOf(current); + let target = null; + if (event.shiftKey === true && index <= 0) { + target = focusable[focusable.length - 1]; + } else if (event.shiftKey !== true && + (index < 0 || index === focusable.length - 1)) { + target = focusable[0]; + } + if (!target) return false; + if (typeof event.preventDefault === "function") event.preventDefault(); + focus(target); + return true; + } + + function start() { + if (started) return manager; + started = true; + documentObject.addEventListener("keydown", handleKeydown, true); + documentObject.addEventListener("focusin", onFocusIn, true); + const Observer = settings.MutationObserver || + (documentObject.defaultView && documentObject.defaultView.MutationObserver); + if (typeof Observer === "function") { + observer = new Observer(function () { sync(); }); + observer.observe(documentObject.body || documentObject.documentElement, { + attributes: true, + attributeFilter: ["hidden", "aria-hidden", "inert", "style", "class"], + childList: true, + subtree: true + }); + } + sync(); + return manager; + } + + function stop() { + if (!started) return; + started = false; + documentObject.removeEventListener("keydown", handleKeydown, true); + documentObject.removeEventListener("focusin", onFocusIn, true); + if (observer) observer.disconnect(); + observer = null; + } + + const manager = { + getActiveModal: function () { return activeModal; }, + getFocusableElements: function (modal) { + return getFocusableElements(modal || activeModal, documentObject); + }, + handleKeydown: handleKeydown, + rememberOpener: rememberOpener, + start: start, + stop: stop, + sync: sync + }; + return manager; + } + + return { + create: create, + findVisibleModal: findVisibleModal, + findVisibleModals: findVisibleModals, + getFocusableElements: getFocusableElements, + isFocusable: isFocusable, + isVisible: isVisible + }; +})); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/legacy-named-playlist-selection.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/legacy-named-playlist-selection.js new file mode 100644 index 0000000..a8afd09 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/legacy-named-playlist-selection.js @@ -0,0 +1,110 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.LegacyNamedPlaylistSelection = api; +})(typeof globalThis !== "undefined" ? globalThis : this, function () { + "use strict"; + + function requireDefinitionId(value) { + if (typeof value !== "string" || value.length === 0) { + throw new TypeError("A named-playlist definition id is required."); + } + return value; + } + + function create() { + let pending = null; + + function begin( + definitionId, + selectedDefinitionId, + requestId, + minimumSequence + ) { + const targetId = requireDefinitionId(definitionId); + if (pending) { + return { + accepted: pending.definitionId === targetId, + shouldSend: false, + definitionId: pending.definitionId, + requestId: pending.requestId + }; + } + + if (selectedDefinitionId === targetId) { + return { + accepted: true, + shouldSend: false, + definitionId: targetId, + requestId: null + }; + } + + const correlationId = requireDefinitionId(requestId); + if (!Number.isSafeInteger(minimumSequence) || minimumSequence < 1) { + throw new TypeError("A positive minimum command sequence is required."); + } + pending = { + definitionId: targetId, + requestId: correlationId, + minimumSequence: minimumSequence, + deferredAction: null + }; + return { + accepted: true, + shouldSend: true, + definitionId: targetId, + requestId: correlationId + }; + } + + function defer(definitionId, action) { + const targetId = requireDefinitionId(definitionId); + if (!pending || pending.definitionId !== targetId || !action) return false; + pending.deferredAction = Object.freeze(Object.assign({}, action)); + return true; + } + + function reconcile(commandResult, selectedDefinitionId) { + if (!pending) { + return { completed: false, succeeded: false, deferredAction: null }; + } + + const exact = !!commandResult && + Number.isSafeInteger(commandResult.sequence) && + commandResult.sequence >= pending.minimumSequence && + commandResult.command === "select-named-playlist" && + commandResult.targetId === pending.requestId; + if (!exact) { + return { completed: false, succeeded: false, deferredAction: null }; + } + + const succeeded = commandResult.succeeded === true && + selectedDefinitionId === pending.definitionId; + const deferredAction = succeeded ? pending.deferredAction : null; + pending = null; + return { + completed: true, + succeeded: succeeded, + deferredAction: deferredAction + }; + } + + function cancel() { + pending = null; + } + + return Object.freeze({ + begin: begin, + defer: defer, + reconcile: reconcile, + cancel: cancel, + isPending: function () { return pending !== null; }, + pendingDefinitionId: function () { + return pending ? pending.definitionId : null; + } + }); + } + + return Object.freeze({ create: create }); +}); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/playout-ui.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/playout-ui.js index 918eac4..1127fe8 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/playout-ui.js +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/playout-ui.js @@ -116,7 +116,10 @@ takeOut.disabled = busy || !commandAvailable; backgroundNone.disabled = busy || playout.canChangeBackground !== true; backgroundFile.disabled = busy || playout.canChangeBackground !== true; - fadeDuration.disabled = busy || playout.canChangeBackground !== true; + // MainForm.Designer keeps panel3 (which owns ComboDi) invisible. Preserve + // the native fade value as read-only state, but never expose an operator + // interaction path from the WebView shell. + fadeDuration.disabled = true; backgroundNone.checked = playout.backgroundEnabled !== true; backgroundName.value = playout.backgroundEnabled === true ? (playout.backgroundFileName || "선택한 배경") : "배경없음"; @@ -149,12 +152,17 @@ backgroundNone.addEventListener("change", function () { post("toggle-background", {}); }); - fadeDuration.addEventListener("change", function () { - post("set-fade-duration", { duration: Number(fadeDuration.value) }); - }); - document.addEventListener("keydown", function (event) { - if (event.defaultPrevented || event.repeat || hasOpenDialog()) return; + if (event.defaultPrevented) return; + const legacyShortcut = event.key === "F2" || event.key === "F3" || + event.key === "F8" || event.key === "Escape"; + if (hasOpenDialog()) { + // ShowDialog/MessageBox own these keys. Consume browser defaults (notably + // Chromium's F3 Find Next) without invoking a MainForm playout shortcut. + if (legacyShortcut) event.preventDefault(); + return; + } + if (event.repeat) return; if (event.key === "F2") { event.preventDefault(); if (!backgroundFile.disabled) backgroundFile.click(); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css index 23bc424..549fd49 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css @@ -71,6 +71,12 @@ button:disabled { color: #555; opacity: 1; } .named-playlist-dialog > footer button { min-width: 96px; height: 32px; } .named-playlist-footer-spacer { flex: 1; } .named-playlist-dialog > footer #named-playlist-confirm-button { border-color: #4774a8; background: linear-gradient(#f8fbff, #d9e8f9); font-weight: 700; } +.named-playlist-confirmation-backdrop { position: fixed; inset: 0; z-index: 26; background: rgba(0, 0, 0, .28); } +.named-playlist-confirmation-backdrop[hidden] { display: none; } +.named-playlist-confirmation-dialog { position: absolute; left: 50%; top: 50%; width: 360px; padding: 24px; transform: translate(-50%, -50%); border: 1px solid #666; background: #fff; box-shadow: 0 10px 36px rgba(0, 0, 0, .4); text-align: center; } +.named-playlist-confirmation-dialog > footer { display: flex; justify-content: center; gap: 10px; margin-top: 24px; } +.named-playlist-confirmation-dialog button { min-width: 88px; padding: 6px 15px; } +.named-playlist-confirmation-dialog #named-playlist-confirmation-yes { border-color: #4774a8; background: linear-gradient(#f8fbff, #d9e8f9); font-weight: 700; } .manual-financial-backdrop { position: fixed; inset: 0; z-index: 18; background: rgba(0, 0, 0, .3); } .manual-financial-backdrop[hidden] { display: none; } @@ -135,6 +141,9 @@ button:disabled { color: #555; opacity: 1; } .cut-row.selected { background: #dcdcdc; } .cut-row:focus { outline: 1px dotted #222; outline-offset: -2px; } .cut-row.separator { background: #fff; } +.cut-row[data-cut-drag-enabled="true"] { cursor: grab; } +.cut-row.dragging { opacity: .58; cursor: grabbing; } +.playlist-grid.cut-drag-over { box-shadow: inset 0 0 0 3px #176b2d; } .operator-status { min-height: 18px; padding-top: 2px; color: #a40000; font-size: 11px; } .catalog-panel { display: flex; flex-direction: column; padding: 0 8px; background: #f3f3f3; } @@ -306,7 +315,7 @@ summary[data-tree-root][aria-disabled="true"] { color: #777; } .playout-actions .prepare.active { color: #fff; background: #1a7700; } .playout-status { border-top: 1px solid #bbb; display: flex; align-items: center; justify-content: center; color: #666; } -.dialog-backdrop { position: fixed; inset: 0; z-index: 20; background: rgba(0, 0, 0, .25); } +.dialog-backdrop { position: fixed; inset: 0; z-index: 30; background: rgba(0, 0, 0, .25); } .dialog-backdrop[hidden] { display: none; } .legacy-dialog { position: absolute; left: 50%; top: 50%; width: 320px; padding: 24px; transform: translate(-50%, -50%); border: 1px solid #777; background: #fff; box-shadow: 0 8px 30px rgba(0, 0, 0, .3); text-align: center; } .legacy-dialog button { min-width: 80px; margin-top: 24px; padding: 6px 15px; } diff --git a/tests/LegacyParityWeb/legacy-cut-drag.test.cjs b/tests/LegacyParityWeb/legacy-cut-drag.test.cjs new file mode 100644 index 0000000..f176bf8 --- /dev/null +++ b/tests/LegacyParityWeb/legacy-cut-drag.test.cjs @@ -0,0 +1,140 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const path = require("node:path"); +const test = require("node:test"); + +const repositoryRoot = path.resolve(__dirname, "..", ".."); +const cutDrag = require(path.join( + repositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", + "Web", + "legacy-cut-drag.js")); + +test("creates the original asymmetric mouse-down rectangle", () => { + const rectangle = cutDrag.createDragRectangle( + { x: 100, y: 80 }, + { width: 10, height: 6 }); + + assert.deepEqual(rectangle, { + left: 90, + top: 74, + width: 10, + height: 6, + right: 100, + bottom: 80 + }); + assert.equal(Object.isFrozen(rectangle), true); +}); + +test("matches Rectangle.Contains right/bottom-exclusive drag decisions", () => { + const down = { x: 100, y: 80 }; + const rectangle = cutDrag.createDragRectangle( + down, + { width: 10, height: 6 }); + + assert.equal(cutDrag.shouldStart(rectangle, down), true, "same point"); + assert.equal( + cutDrag.shouldStart(rectangle, { x: 101, y: 79 }), true, "right"); + assert.equal( + cutDrag.shouldStart(rectangle, { x: 99, y: 81 }), true, "down"); + assert.equal( + cutDrag.shouldStart(rectangle, { x: 89, y: 79 }), true, "left boundary crossed"); + assert.equal( + cutDrag.shouldStart(rectangle, { x: 99, y: 73 }), true, "top boundary crossed"); + assert.equal( + cutDrag.shouldStart(rectangle, { x: 90, y: 74 }), false, "inside upper-left"); + + assert.equal( + cutDrag.shouldStart(rectangle, { x: 90, y: 79 }), false, "left is inclusive"); + assert.equal( + cutDrag.shouldStart(rectangle, { x: 99, y: 74 }), false, "top is inclusive"); + assert.equal( + cutDrag.shouldStart(rectangle, { x: 100, y: 79 }), true, "right is exclusive"); + assert.equal( + cutDrag.shouldStart(rectangle, { x: 99, y: 80 }), true, "bottom is exclusive"); +}); + +test("validates finite coordinates and positive finite drag sizes", () => { + const validSize = { width: 4, height: 4 }; + + for (const down of [ + null, + { x: Number.NaN, y: 0 }, + { x: 0, y: Number.POSITIVE_INFINITY }, + { x: "0", y: 0 } + ]) { + assert.throws( + () => cutDrag.createDragRectangle(down, validSize), + TypeError); + } + + for (const dragSize of [ + null, + { width: 0, height: 1 }, + { width: 1, height: -1 }, + { width: Number.NaN, height: 1 }, + { width: 1, height: Number.POSITIVE_INFINITY } + ]) { + assert.throws( + () => cutDrag.createDragRectangle({ x: 0, y: 0 }, dragSize), + RangeError); + } + + const rectangle = cutDrag.createDragRectangle( + { x: 4, y: 4 }, + validSize); + assert.throws( + () => cutDrag.shouldStart(rectangle, { x: Number.NaN, y: 0 }), + TypeError); +}); + +test("maps DIP metrics to CSS pixels at 100 and 150 percent host DPI", () => { + assert.deepEqual(cutDrag.toCssDragSize( + { width: 4, height: 6 }, 1, 1), + { width: 4, height: 6 }); + assert.deepEqual(cutDrag.toCssDragSize( + { width: 4, height: 6 }, 1.5, 1.5), + { width: 4, height: 6 }); +}); + +test("accounts for a retained WebView browser zoom in pointer CSS coordinates", () => { + assert.deepEqual(cutDrag.toCssDragSize( + { width: 4, height: 6 }, 1.5, 1.875), + { width: 3.2, height: 4.8 }); + assert.deepEqual(cutDrag.toCssDragSize( + { width: 4, height: 6 }, 1, 1.25), + { width: 3.2, height: 4.8 }); +}); + +test("keeps fractional CSS boundaries after retained zoom conversion", () => { + const size = cutDrag.toCssDragSize({ width: 4, height: 4 }, 1, 1.25); + const rectangle = cutDrag.createDragRectangle({ x: 10.5, y: 10.5 }, size); + + assert.equal(cutDrag.shouldStart(rectangle, { x: 7.31, y: 7.31 }), false); + assert.equal(cutDrag.shouldStart(rectangle, { x: 7.29, y: 7.31 }), true); + assert.equal(cutDrag.shouldStart(rectangle, { x: 7.31, y: 7.29 }), true); + assert.equal(cutDrag.shouldStart(rectangle, { x: 10.5, y: 10.49 }), true); +}); + +test("uses a neutral zoom ratio for missing or abnormal scale measurements", () => { + for (const scales of [ + [0, 0], + [Number.NaN, Number.NaN], + [1, Number.POSITIVE_INFINITY], + [1, 0.1], + [1, 10] + ]) { + assert.deepEqual(cutDrag.toCssDragSize( + { width: 4, height: 6 }, scales[0], scales[1]), + { width: 4, height: 6 }); + } +}); + +test("publishes the same API as a browser global", () => { + assert.equal(globalThis.LegacyCutDrag, cutDrag); + assert.equal(typeof cutDrag.createDragRectangle, "function"); + assert.equal(typeof cutDrag.shouldStart, "function"); + assert.equal(typeof cutDrag.toCssDragSize, "function"); +}); diff --git a/tests/LegacyParityWeb/legacy-modal-focus.test.cjs b/tests/LegacyParityWeb/legacy-modal-focus.test.cjs new file mode 100644 index 0000000..1f98fb7 --- /dev/null +++ b/tests/LegacyParityWeb/legacy-modal-focus.test.cjs @@ -0,0 +1,294 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +const repositoryRoot = path.resolve(__dirname, "..", ".."); +const modulePath = path.join(repositoryRoot, "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", "Web", "legacy-modal-focus.js"); +const modalFocus = require(modulePath); + +class FakeElement { + constructor(documentObject, tagName, attributes) { + this.ownerDocument = documentObject; + this.tagName = String(tagName || "div").toUpperCase(); + this.parentElement = null; + this.children = []; + this.hidden = false; + this.inert = false; + this.disabled = false; + this.style = { display: "block", visibility: "visible", contentVisibility: "visible" }; + this.attributes = new Map(); + Object.entries(attributes || {}).forEach(([name, value]) => { + this.attributes.set(name, String(value)); + }); + } + + append(...elements) { + elements.forEach(element => { + element.parentElement = this; + this.children.push(element); + }); + } + + contains(element) { + return element === this || this.children.some(child => child.contains(element)); + } + + focus() { + this.ownerDocument.activeElement = this; + } + + getAttribute(name) { + return this.attributes.has(name) ? this.attributes.get(name) : null; + } + + hasAttribute(name) { + return this.attributes.has(name); + } + + setAttribute(name, value) { + this.attributes.set(name, String(value)); + } + + querySelector(selector) { + const descendants = this.querySelectorAll("*"); + if (selector.includes("data-modal-initial-focus")) { + return descendants.find(element => + element.hasAttribute("data-modal-initial-focus") || element.hasAttribute("autofocus")) || null; + } + if (selector[0] === "#") { + return descendants.find(element => element.getAttribute("id") === selector.slice(1)) || null; + } + return null; + } + + querySelectorAll() { + return this.children.flatMap(child => [child, ...child.querySelectorAll("*")]); + } +} + +class FakeDocument { + constructor() { + this.body = new FakeElement(this, "body"); + this.activeElement = this.body; + this.listeners = new Map(); + this.defaultView = { + getComputedStyle(element) { return element.style; } + }; + } + + addEventListener(type, listener) { + if (!this.listeners.has(type)) this.listeners.set(type, new Set()); + this.listeners.get(type).add(listener); + } + + removeEventListener(type, listener) { + if (this.listeners.has(type)) this.listeners.get(type).delete(listener); + } + + contains(element) { + return this.body.contains(element); + } + + querySelectorAll() { + return this.body.querySelectorAll("*").filter(element => { + const role = element.getAttribute("role"); + return role === "dialog" || role === "alertdialog"; + }); + } +} + +function element(documentObject, tagName, attributes) { + return new FakeElement(documentObject, tagName, attributes); +} + +function modalFixture() { + const documentObject = new FakeDocument(); + const opener = element(documentObject, "button", { id: "opener" }); + const backdrop = element(documentObject, "div"); + const dialog = element(documentObject, "section", { role: "dialog" }); + const first = element(documentObject, "button", { id: "first" }); + const preferred = element(documentObject, "input", { id: "preferred" }); + const last = element(documentObject, "button", { id: "last" }); + dialog.append(first, preferred, last); + backdrop.append(dialog); + documentObject.body.append(opener, backdrop); + backdrop.hidden = true; + opener.focus(); + return { documentObject, opener, backdrop, dialog, first, preferred, last }; +} + +function keyEvent(key, shiftKey) { + return { + key, + shiftKey: !!shiftKey, + defaultPrevented: false, + preventDefault() { this.defaultPrevented = true; } + }; +} + +test("exports the same API as a CommonJS module and a browser global", () => { + assert.equal(typeof modalFocus.create, "function"); + const source = fs.readFileSync(modulePath, "utf8"); + const context = { globalThis: {} }; + vm.runInNewContext(source, context); + assert.equal(typeof context.globalThis.LegacyModalFocus.create, "function"); +}); + +test("selects the last visible modal and ignores hidden modal ancestors", () => { + const fixture = modalFixture(); + const alertBackdrop = element(fixture.documentObject, "div"); + const alert = element(fixture.documentObject, "div", { role: "alertdialog" }); + alertBackdrop.append(alert); + fixture.documentObject.body.append(alertBackdrop); + + assert.equal(modalFocus.findVisibleModal(fixture.documentObject), alert); + alertBackdrop.hidden = true; + assert.equal(modalFocus.findVisibleModal(fixture.documentObject), null); + fixture.backdrop.hidden = false; + assert.equal(modalFocus.findVisibleModal(fixture.documentObject), fixture.dialog); +}); + +test("focusable discovery excludes disabled, hidden, inert, and negative-tab controls", () => { + const fixture = modalFixture(); + fixture.backdrop.hidden = false; + fixture.first.disabled = true; + fixture.preferred.setAttribute("tabindex", "-1"); + const hiddenParent = element(fixture.documentObject, "div"); + hiddenParent.hidden = true; + const hiddenButton = element(fixture.documentObject, "button"); + hiddenParent.append(hiddenButton); + const inertParent = element(fixture.documentObject, "div", { inert: "" }); + const inertButton = element(fixture.documentObject, "button"); + inertParent.append(inertButton); + fixture.dialog.append(hiddenParent, inertParent); + + assert.deepEqual(modalFocus.getFocusableElements( + fixture.dialog, fixture.documentObject), [fixture.last]); +}); + +test("open focuses the preferred control and close restores the opener", () => { + const fixture = modalFixture(); + const manager = modalFocus.create({ document: fixture.documentObject }); + fixture.backdrop.hidden = false; + manager.sync("#preferred"); + assert.equal(fixture.documentObject.activeElement, fixture.preferred); + assert.equal(manager.getActiveModal(), fixture.dialog); + + fixture.backdrop.hidden = true; + manager.sync(); + assert.equal(fixture.documentObject.activeElement, fixture.opener); + assert.equal(manager.getActiveModal(), null); +}); + +test("open falls back to the first enabled visible control", () => { + const fixture = modalFixture(); + const manager = modalFocus.create({ document: fixture.documentObject }); + fixture.backdrop.hidden = false; + manager.sync("#missing"); + assert.equal(fixture.documentObject.activeElement, fixture.first); +}); + +test("Tab and Shift+Tab wrap inside the active modal without handling Escape", () => { + const fixture = modalFixture(); + const manager = modalFocus.create({ document: fixture.documentObject }); + fixture.backdrop.hidden = false; + manager.sync(); + + fixture.last.focus(); + const forward = keyEvent("Tab", false); + assert.equal(manager.handleKeydown(forward), true); + assert.equal(forward.defaultPrevented, true); + assert.equal(fixture.documentObject.activeElement, fixture.first); + + const backward = keyEvent("Tab", true); + assert.equal(manager.handleKeydown(backward), true); + assert.equal(backward.defaultPrevented, true); + assert.equal(fixture.documentObject.activeElement, fixture.last); + + fixture.preferred.focus(); + const middle = keyEvent("Tab", false); + assert.equal(manager.handleKeydown(middle), false); + assert.equal(middle.defaultPrevented, false); + assert.equal(fixture.documentObject.activeElement, fixture.preferred); + + const escape = keyEvent("Escape", false); + assert.equal(manager.handleKeydown(escape), false); + assert.equal(escape.defaultPrevented, false); + assert.equal(manager.getActiveModal(), fixture.dialog); +}); + +test("a dialog without controls retains Tab focus on its container", () => { + const documentObject = new FakeDocument(); + const dialog = element(documentObject, "section", { role: "dialog" }); + documentObject.body.append(dialog); + const manager = modalFocus.create({ document: documentObject }); + manager.sync(); + const tab = keyEvent("Tab", false); + assert.equal(manager.handleKeydown(tab), true); + assert.equal(tab.defaultPrevented, true); + assert.equal(dialog.getAttribute("tabindex"), "-1"); + assert.equal(documentObject.activeElement, dialog); +}); + +test("closing a nested alert restores its modal opener before the root opener", () => { + const fixture = modalFixture(); + const manager = modalFocus.create({ document: fixture.documentObject }); + fixture.backdrop.hidden = false; + manager.sync("#preferred"); + + const alertBackdrop = element(fixture.documentObject, "div"); + const alert = element(fixture.documentObject, "div", { role: "alertdialog" }); + const alertOk = element(fixture.documentObject, "button", { id: "alert-ok" }); + alert.append(alertOk); + alertBackdrop.append(alert); + fixture.documentObject.body.append(alertBackdrop); + manager.sync("#alert-ok"); + assert.equal(fixture.documentObject.activeElement, alertOk); + + alertBackdrop.hidden = true; + manager.sync(); + assert.equal(manager.getActiveModal(), fixture.dialog); + assert.equal(fixture.documentObject.activeElement, fixture.preferred); + + fixture.backdrop.hidden = true; + manager.sync(); + assert.equal(fixture.documentObject.activeElement, fixture.opener); +}); + +test("rememberOpener preserves the owner when application code changes focus during open", () => { + const fixture = modalFixture(); + const manager = modalFocus.create({ document: fixture.documentObject }); + manager.rememberOpener(fixture.dialog); + fixture.backdrop.hidden = false; + fixture.first.focus(); + manager.sync("#preferred"); + fixture.backdrop.hidden = true; + manager.sync(); + assert.equal(fixture.documentObject.activeElement, fixture.opener); +}); + +test("an opener resolver restores a logically equivalent control after DOM replacement", () => { + const fixture = modalFixture(); + const manager = modalFocus.create({ document: fixture.documentObject }); + let currentOpener = fixture.opener; + manager.rememberOpener(fixture.dialog, () => currentOpener); + fixture.backdrop.hidden = false; + manager.sync("#preferred"); + + const replacement = element(fixture.documentObject, "button", { id: "opener" }); + const openerIndex = fixture.documentObject.body.children.indexOf(fixture.opener); + fixture.opener.parentElement = null; + replacement.parentElement = fixture.documentObject.body; + fixture.documentObject.body.children[openerIndex] = replacement; + currentOpener = replacement; + + fixture.backdrop.hidden = true; + manager.sync(); + assert.equal(fixture.documentObject.activeElement, replacement); + assert.notEqual(fixture.documentObject.activeElement, fixture.opener); +}); diff --git a/tests/LegacyParityWeb/legacy-modal-workflow-parity.test.cjs b/tests/LegacyParityWeb/legacy-modal-workflow-parity.test.cjs new file mode 100644 index 0000000..15c01f5 --- /dev/null +++ b/tests/LegacyParityWeb/legacy-modal-workflow-parity.test.cjs @@ -0,0 +1,372 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const repositoryRoot = path.resolve(__dirname, "..", ".."); +const webRoot = path.join(repositoryRoot, "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", "Web"); +const app = fs.readFileSync(path.join(webRoot, "app.js"), "utf8"); +const markup = fs.readFileSync(path.join(webRoot, "index.html"), "utf8"); +const styles = fs.readFileSync(path.join(webRoot, "styles.css"), "utf8"); + +function section(startMarker, endMarker) { + const start = app.indexOf(startMarker); + assert.notEqual(start, -1, "missing start marker: " + startMarker); + const end = app.indexOf(endMarker, start + startMarker.length); + assert.notEqual(end, -1, "missing end marker: " + endMarker); + return app.slice(start, end); +} + +function assertOrdered(source, markers) { + let cursor = -1; + for (const marker of markers) { + const next = source.indexOf(marker, cursor + 1); + assert.ok(next > cursor, "expected marker in order: " + marker); + cursor = next; + } +} + +test("modal focus support loads before app.js and is started once", () => { + const scripts = Array.from(markup.matchAll(/ match[1]); + const focusIndex = scripts.indexOf("legacy-modal-focus.js"); + const namedSelectionIndex = scripts.indexOf("legacy-named-playlist-selection.js"); + const appIndex = scripts.indexOf("app.js"); + + assert.ok(focusIndex >= 0, "legacy modal focus module must be loaded"); + assert.ok(namedSelectionIndex > focusIndex, + "named-playlist selection support must load after modal support"); + assert.ok(appIndex > namedSelectionIndex, + "named-playlist selection support must load before app.js"); + assert.ok(appIndex > focusIndex, "focus module must load before app.js"); + assert.equal(scripts.filter(name => name === "legacy-modal-focus.js").length, 1); + assert.equal(scripts.filter( + name => name === "legacy-named-playlist-selection.js").length, 1); + assert.equal(scripts.filter(name => name === "app.js").length, 1); + + assert.match(app, /window\.LegacyModalFocus\.create\(\{ document: document \}\)/); + assert.match(app, /window\.LegacyNamedPlaylistSelection\.create\(\)/); + assert.equal((app.match(/modalFocusManager\.start\(\)/g) || []).length, 1); +}); + +test("the one-button MessageBox stays visually above every child dialog", () => { + function zIndex(className) { + const match = styles.match(new RegExp( + "\\." + className + "\\s*\\{[^}]*z-index:\\s*(\\d+)", "s")); + assert.ok(match, "missing z-index for " + className); + return Number(match[1]); + } + + const alertLayer = zIndex("dialog-backdrop"); + assert.ok(alertLayer > zIndex("operator-catalog-backdrop")); + assert.ok(alertLayer > zIndex("named-playlist-confirmation-backdrop")); +}); + +test("modal open, render, and close paths preserve and restore focus", () => { + const namedOpen = section("function openNamedPlaylist", "function closeNamedPlaylist"); + assertOrdered(namedOpen, [ + "rememberModalOpener(namedPlaylistDialog)", + "namedPlaylistModal.hidden = false", + "syncModalFocus()" + ]); + + const namedClose = section("function closeNamedPlaylist", "function isNamedPlaylistModalLocked"); + assertOrdered(namedClose, [ + "namedPlaylistModal.hidden = true", + "syncModalFocus()" + ]); + + for (const [functionName, nextFunction, hiddenElement, dialogElement] of [ + ["renderManualFinancial", "isCanonicalNamedPlaylistTitle", "manualModal", "manualDialog"], + ["renderManualLists", "openNamedPlaylist", "manualListModal", "manualListDialog"], + ["renderOperatorCatalog", "renderDialog", "operatorCatalogModal", "operatorCatalogDialog"] + ]) { + const body = section("function " + functionName, "function " + nextFunction); + assertOrdered(body, [ + "if (" + hiddenElement + ".hidden) rememberModalOpener(" + dialogElement + ")", + hiddenElement + ".hidden = false" + ]); + } + + const dialogRender = section("function renderDialog", "function render(state)"); + assertOrdered(dialogRender, [ + "if (dialog.hidden) rememberModalOpener(legacyAlertDialog)", + "dialog.hidden = false" + ]); + + const rootRender = section("function render(state)", "searchButton.addEventListener"); + assertOrdered(rootRender, ["renderDialog(state)", "syncModalFocus()"]); + assert.doesNotMatch(rootRender, /dialogOk\.focus\(/, + "focus must be delegated to the modal manager so nested opener state is retained"); +}); + +test("Escape dismisses only the one-button alert and is inert for child dialogs", () => { + const keydown = section( + 'document.addEventListener("keydown", function (event)', + 'dialogOk.addEventListener("click"'); + assertOrdered(keydown, [ + 'event.key === "Escape" && modalFocusManager.getActiveModal()', + "event.preventDefault()", + "modalFocusManager.getActiveModal() === legacyAlertDialog", + 'send("dismiss-dialog", {})' + ]); + assert.doesNotMatch(keydown, + /(?:closeNamedPlaylist|close-manual|close-operator)\s*\(/); + assert.match(markup, + /id="named-playlist-confirmation"[\s\S]*role="alertdialog"[\s\S]*id="named-playlist-confirmation-yes"[\s\S]*id="named-playlist-confirmation-no"/); +}); + +test("category-tree opener survives rerender by resolving its stable id or node key", () => { + const opener = section("function createModalOpenerReference", "function rememberModalOpener"); + assertOrdered(opener, [ + "categoryTree.contains(opener)", + "captureTreeUiState(renderedTreeTabId)", + "controlId: opener.id || null", + "nodeKey: node ? node.dataset.treeNodeKey : null", + "return function resolveTreeModalOpener()", + "document.getElementById(descriptor.controlId)", + "findTreeNodeByKey(descriptor.nodeKey)", + "resolved.setSelectionRange(descriptor.selectionStart, descriptor.selectionEnd)", + "return resolved" + ]); + assert.match(app, + /modalFocusManager\.rememberOpener\(modal, createModalOpenerReference\(\)\)/); + + const capture = section("function captureTreeUiState", "function beginTreeRender"); + assertOrdered(capture, [ + "categoryTree.contains(document.activeElement)", + "id: activeElement.id", + "selectionStart: Number.isInteger(activeElement.selectionStart)", + "selectionEnd: Number.isInteger(activeElement.selectionEnd)", + "focusKey: active ? active.dataset.treeNodeKey : previous.focusKey || null", + "controlFocus: activeElement ? controlFocus : previous.controlFocus || null" + ]); + + const finish = section("function finishTreeRender", "function send(type, payload)"); + assertOrdered(finish, [ + "const canRestoreFocus = treeFocusOwnedBeforeRender", + "if (canRestoreFocus && saved.controlFocus && saved.controlFocus.id)", + "document.getElementById(saved.controlFocus.id)", + "categoryTree.contains(control)", + "control.focus({ preventScroll: true })", + "Number.isInteger(saved.controlFocus.selectionStart)", + "Number.isInteger(saved.controlFocus.selectionEnd)", + "control.setSelectionRange(", + "saved.controlFocus.selectionStart", + "saved.controlFocus.selectionEnd" + ]); +}); + +test("named playlist save uses a nested Yes/No dialog then alerts and closes on exact success", () => { + const confirm = section( + 'namedPlaylistConfirmButton.addEventListener("click"', + "manualButtons.forEach"); + assertOrdered(confirm, [ + "openNamedPlaylistConfirmation({", + 'kind: "save"', + "definitionId: definitionId", + 'message: "저장하겠습니까?"', + "showSavedAlert: true", + "closeOnNo: true" + ]); + assert.doesNotMatch(confirm, /window\.confirm/); + + const resolution = section( + "function resolveNamedPlaylistConfirmation", + "function renderNamedPlaylist"); + const confirmationOpen = section( + "function openNamedPlaylistConfirmation", + "function restoreNamedPlaylistAfterConfirmation"); + assertOrdered(confirmationOpen, [ + "rememberModalOpener(namedPlaylistConfirmationDialog)", + "namedPlaylistConfirmationModal.hidden = false", + "syncModalFocus()" + ]); + assertOrdered(resolution, [ + "namedPlaylistConfirmationModal.hidden = true", + "syncModalFocus()", + "if (!confirmed)", + "confirmation.closeOnNo === true", + "closeNamedPlaylist()", + '"save-current-named-playlist-to"', + "confirmation.showSavedAlert === true" + ]); + + const renderNamed = section("function renderNamedPlaylist", "function clearOperatorCatalogDragVisuals"); + assertOrdered(renderNamed, [ + "isMatchingNamedPlaylistReceipt(", + "pendingNamedPlaylistCommand", + "commandResult", + "pendingNamedPlaylistCommand = null", + 'window.alert("저장되었습니다")', + "closeNamedPlaylist()" + ]); +}); + +test("named playlist double-click save confirms but never requests a success alert", () => { + const action = section( + "function runNamedPlaylistDoubleClick", + "function lockNamedPlaylistControls"); + assertOrdered(action, [ + 'mode === "save"', + "openNamedPlaylistConfirmation({", + 'kind: "save"', + "definitionId: definitionId", + 'message: "저장하겠습니까?"', + "showSavedAlert: false", + "closeOnNo: true" + ]); + + const doubleClick = section( + 'namedPlaylistDefinitions.addEventListener("dblclick"', + 'namedPlaylistDefinitions.addEventListener("click"'); + assertOrdered(doubleClick, [ + "namedPlaylistSelection.isPending()", + "namedPlaylistSelection.defer(definitionId", + "mode: namedPlaylistMode", + "runNamedPlaylistDoubleClick(definitionId, namedPlaylistMode)" + ]); + assert.doesNotMatch(action + doubleClick, + /window\.(?:alert|confirm)|showSavedAlert\s*:\s*true/); +}); + +test("named playlist row selection is immediate and blocks wrong-target actions", () => { + const selectionStart = section( + "function beginNamedPlaylistSelection", + "function runNamedPlaylistDoubleClick"); + assertOrdered(selectionStart, [ + "namedPlaylistSelection.begin(", + "markNamedPlaylistVisualSelection(definitionId)", + "lockNamedPlaylistSelectionActions()", + 'send("select-named-playlist", {', + "definitionId: definitionId", + "requestId: started.requestId" + ]); + assert.doesNotMatch(selectionStart, /setTimeout|namedPlaylistResultClickTimer/); + + const click = section( + 'namedPlaylistDefinitions.addEventListener("click"', + 'namedPlaylistNewTitle.addEventListener("input"'); + assert.match(click, + /beginNamedPlaylistSelection\(definition\.dataset\.namedPlaylistDefinitionId\)/); + assert.doesNotMatch(click, /setTimeout|namedPlaylistResultClickTimer/); + + const renderNamed = section( + "function renderNamedPlaylist", + "function clearOperatorCatalogDragVisuals"); + assertOrdered(renderNamed, [ + "const commandResult = state.commandResult", + "namedPlaylistSelection.reconcile(", + "commandResult", + "const deferredSelectionAction = selectionResult.deferredAction", + "const definitionLocked = isNamedPlaylistDefinitionLocked()", + "runNamedPlaylistDoubleClick(" + ]); + assert.match(app, + /function isNamedPlaylistActionLocked\(\)[\s\S]*namedPlaylistSelection\.isPending\(\)/); +}); + +test("named playlist delete uses the same confirmation but No keeps the child open", () => { + const remove = section( + 'namedPlaylistDeleteButton.addEventListener("click"', + 'namedPlaylistConfirmButton.addEventListener("click"'); + assertOrdered(remove, [ + "openNamedPlaylistConfirmation({", + 'kind: "delete"', + 'message: "프로그램이 삭제됩니다. 계속 하시겠습니까?"', + "closeOnNo: false" + ]); + assert.doesNotMatch(remove, /window\.confirm/); +}); + +test("named playlist requests terminate only on their exact correlated receipt", () => { + const close = section("function closeNamedPlaylist", "function isNamedPlaylistModalLocked"); + assertOrdered(close, [ + "if (isNamedPlaylistModalLocked() || namedPlaylistSelection.isPending()) return false", + "namedPlaylistModal.hidden = true" + ]); + + const locking = section("function isNamedPlaylistModalLocked", "function renderNamedPlaylist"); + for (const lockSource of [ + "uiBusy", + "pendingNamedPlaylistCommand !== null", + "namedPlaylistConfirmation !== null", + "namedPlaylistState.isWriteInProgress === true" + ]) { + assert.match(locking, new RegExp(lockSource.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } + assertOrdered(locking, [ + 'const requestId = "named-ui-" + String(nextNamedPlaylistRequestId++)', + "pendingNamedPlaylistCommand = {", + "command: type", + "targetId: requestId", + "minimumSequence: lastCommandSequence + 1", + "closeOnSuccess: false", + "Object.assign({}, payload || {}, { requestId: requestId })", + "send(type, correlatedPayload)", + "lockNamedPlaylistControls()" + ]); + assertOrdered(locking, [ + "pendingNamedPlaylistCommand = {", + "command: command", + "targetId: definitionId", + "definitionId: definitionId", + "minimumSequence: lastCommandSequence + 1", + "closeOnSuccess: true", + "send(command, { definitionId: definitionId })", + "lockNamedPlaylistControls()" + ]); + + const renderNamed = section("function renderNamedPlaylist", "function clearOperatorCatalogDragVisuals"); + assertOrdered(renderNamed, [ + "isMatchingNamedPlaylistReceipt(", + "pendingNamedPlaylistCommand", + "commandResult", + "pendingNamedPlaylistCommand = null", + "succeeded && completed.closeOnSuccess" + ]); + assertOrdered(renderNamed, [ + "const modalLocked = isNamedPlaylistActionLocked()", + "const closeLocked = isNamedPlaylistModalLocked() ||", + "namedPlaylistSelection.isPending()", + "namedPlaylistClose.disabled = closeLocked", + "namedPlaylistCancelButton.disabled = closeLocked" + ]); + assert.match(renderNamed, /dbLoadButton\.disabled = operatorMutationLocked/); + assert.match(renderNamed, /dbSaveButton\.disabled = operatorMutationLocked/); + assert.match(locking, + /function isNamedPlaylistActionLocked\(\)[\s\S]*operatorMutationLocked/); + assert.doesNotMatch(app, + /isNamedPlaylistTerminalRejection|minimumRevision|namedPlaylistBusyCycleObserved/, + "uncorrelated state changes must never unlock a DB request"); +}); + +test("named playlist receipt matcher consumes busy rejection but defers busy success", () => { + const helperSource = section( + "function isMatchingNamedPlaylistReceipt", + "function openNamedPlaylistConfirmation"); + const matches = new Function( + helperSource + "\nreturn isMatchingNamedPlaylistReceipt;")(); + const pending = { + minimumSequence: 7, + command: "create-named-playlist", + targetId: "named-ui-4" + }; + const exact = { + sequence: 7, + command: "create-named-playlist", + targetId: "named-ui-4", + succeeded: true + }; + + assert.equal(matches(false, pending, exact), true); + assert.equal(matches(true, pending, exact), false); + assert.equal(matches(true, pending, { ...exact, succeeded: false }), true, + "an exact gate rejection must be consumed before another busy request overwrites it"); + assert.equal(matches(false, pending, { ...exact, sequence: 6 }), false); + assert.equal(matches(false, pending, { ...exact, command: "refresh-named-playlists" }), false); + assert.equal(matches(false, pending, { ...exact, targetId: "named-ui-3" }), false); +}); diff --git a/tests/LegacyParityWeb/legacy-named-playlist-selection.test.cjs b/tests/LegacyParityWeb/legacy-named-playlist-selection.test.cjs new file mode 100644 index 0000000..2fd4b68 --- /dev/null +++ b/tests/LegacyParityWeb/legacy-named-playlist-selection.test.cjs @@ -0,0 +1,108 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const selection = require("../../src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/legacy-named-playlist-selection.js"); + +const first = "named-definition-first"; +const second = "named-definition-second"; + +test("a second-row click blocks actions until native state selects that exact row", () => { + const workflow = selection.create(); + + const started = workflow.begin(second, first, "named-ui-1", 1); + + assert.deepEqual(started, { + accepted: true, + shouldSend: true, + definitionId: second, + requestId: "named-ui-1" + }); + assert.equal(workflow.isPending(), true); + assert.equal(workflow.pendingDefinitionId(), second); + assert.deepEqual(workflow.reconcile({ + sequence: 1, + command: "refresh-named-playlists", + targetId: "named-ui-old", + succeeded: true + }, first), { + completed: false, + succeeded: false, + deferredAction: null + }); + assert.equal(workflow.isPending(), true); + + const completed = workflow.reconcile({ + sequence: 1, + command: "select-named-playlist", + targetId: "named-ui-1", + succeeded: true + }, second); + + assert.equal(completed.completed, true); + assert.equal(completed.succeeded, true); + assert.equal(workflow.isPending(), false); +}); + +test("repeated click and double click queue one exact action without a duplicate select", () => { + const workflow = selection.create(); + assert.equal(workflow.begin(second, first, "named-ui-2", 2).shouldSend, true); + + const repeated = workflow.begin(second, first); + const deferred = Object.freeze({ definitionId: second, mode: "save" }); + + assert.equal(repeated.accepted, true); + assert.equal(repeated.shouldSend, false); + assert.equal(repeated.requestId, "named-ui-2"); + assert.equal(workflow.defer(second, deferred), true); + const completed = workflow.reconcile({ + sequence: 2, + command: "select-named-playlist", + targetId: "named-ui-2", + succeeded: true + }, second); + assert.deepEqual(completed.deferredAction, deferred); + assert.equal(completed.succeeded, true); +}); + +test("a failed native selection clears the lock and discards the deferred mutation", () => { + const workflow = selection.create(); + workflow.begin(second, first, "named-ui-3", 3); + workflow.defer(second, { definitionId: second, mode: "load" }); + + const failed = workflow.reconcile({ + sequence: 3, + command: "select-named-playlist", + targetId: "named-ui-3", + succeeded: false + }, first); + + assert.deepEqual(failed, { + completed: true, + succeeded: false, + deferredAction: null + }); + assert.equal(workflow.isPending(), false); +}); + +test("an already selected row never creates a pending native request", () => { + const workflow = selection.create(); + + const current = workflow.begin(first, first); + + assert.equal(current.accepted, true); + assert.equal(current.shouldSend, false); + assert.equal(current.requestId, null); + assert.equal(workflow.isPending(), false); +}); + +test("a different row cannot replace an in-flight selection target", () => { + const workflow = selection.create(); + workflow.begin(second, first, "named-ui-4", 4); + + const conflicting = workflow.begin(first, first); + + assert.equal(conflicting.accepted, false); + assert.equal(conflicting.shouldSend, false); + assert.equal(workflow.pendingDefinitionId(), second); +}); diff --git a/tests/LegacyParityWeb/thin-client.test.cjs b/tests/LegacyParityWeb/thin-client.test.cjs index c2df43b..7dc9b64 100644 --- a/tests/LegacyParityWeb/thin-client.test.cjs +++ b/tests/LegacyParityWeb/thin-client.test.cjs @@ -69,15 +69,19 @@ test("legacy stock double click is represented by pointer-down facts", () => { assert.match(app, /stockResults\.querySelectorAll\("button\[data-result-index\]"\)/); }); -test("cut rows are reconciled in place so a state reply cannot cancel native drag", () => { +test("cut rows are reconciled in place while the legacy pointer drag remains captured", () => { assert.match(app, /dataset\.physicalIndex/); assert.match(app, /function createCutElement/); const cutFactory = app.slice( app.indexOf("function createCutElement"), app.indexOf("function clearPlaylistDragVisuals")); - assert.match(cutFactory, /if \(playlistMutationLocked \|\| !event\.dataTransfer\)/); + assert.match(cutFactory, /beginCutDragGesture\(element, event, position\)/); + assert.match(cutFactory, /addEventListener\("pointermove", onCutPointerMove\)/); + assert.match(cutFactory, /addEventListener\("lostpointercapture", onCutPointerCancel\)/); assert.match(cutFactory, /const cutDragLocked = isOperatorMutationLocked\(state\)/); - assert.match(cutFactory, /element\.draggable = !cutDragLocked/); + assert.match(cutFactory, /element\.draggable = false/); + assert.match(cutFactory, /dataset\.cutDragEnabled = cutSelectionEnabled/); + assert.doesNotMatch(cutFactory, /addEventListener\("dragstart"/); assert.doesNotMatch(app, /cutList\.replaceChildren/); }); @@ -163,7 +167,7 @@ test("IDLE Escape keeps the emergency StopAll path available", () => { /takeOut\.disabled[\s\S]{0,120}playout\.phase !== "prepared"/); }); -test("named-playlist modal shortcuts cannot leak into the playout listener", () => { +test("legacy child modal Escape is inert and cannot leak into the playout listener", () => { const appScript = markup.indexOf(''); const playoutScript = markup.indexOf(''); assert.ok(appScript >= 0 && playoutScript > appScript); @@ -175,8 +179,11 @@ test("named-playlist modal shortcuts cannot leak into the playout listener", () assert.ok(appHandlerStart >= 0 && appHandlerEnd > appHandlerBodyStart); const handleAppKeydown = new Function( "event", - "namedPlaylistModal", - "closeNamedPlaylist", + "modalFocusManager", + "cutDragGesture", + "clearCutDragGesture", + "legacyAlertDialog", + "send", app.slice(appHandlerBodyStart, appHandlerEnd)); const playoutHandlerStart = playout.lastIndexOf(keydownMarker); @@ -201,28 +208,39 @@ test("named-playlist modal shortcuts cannot leak into the playout listener", () const modal = { hidden: false }; for (const key of ["F2", "F3", "F8"]) { + const modalKey = { + key, + repeat: false, + defaultPrevented: false, + preventDefault() { this.defaultPrevented = true; } + }; handlePlayoutKeydown( - { key, repeat: false, defaultPrevented: false, preventDefault() {} }, + modalKey, () => !modal.hidden, backgroundFile, backgroundNone, takeIn, takeOut); + assert.equal(modalKey.defaultPrevented, true, key + " browser default must be consumed"); } assert.deepEqual(clicks, []); - let closeCount = 0; const escape = new Event("keydown", { cancelable: true }); Object.defineProperties(escape, { key: { value: "Escape" }, repeat: { value: false } }); - handleAppKeydown(escape, modal, () => { - closeCount += 1; - modal.hidden = true; - }); - assert.equal(closeCount, 1); - assert.equal(modal.hidden, true); + const childDialog = {}; + const legacyAlertDialog = {}; + const sent = []; + handleAppKeydown( + escape, + { getActiveModal() { return childDialog; } }, + null, + () => {}, + legacyAlertDialog, + (type) => sent.push(type)); + assert.equal(modal.hidden, false); assert.equal(escape.defaultPrevented, true); handlePlayoutKeydown( @@ -233,6 +251,57 @@ test("named-playlist modal shortcuts cannot leak into the playout listener", () takeIn, takeOut); assert.deepEqual(clicks, []); + assert.deepEqual(sent, []); + + const alertEscape = new Event("keydown", { cancelable: true }); + Object.defineProperties(alertEscape, { + key: { value: "Escape" }, + repeat: { value: false } + }); + handleAppKeydown( + alertEscape, + { getActiveModal() { return legacyAlertDialog; } }, + null, + () => {}, + legacyAlertDialog, + (type) => sent.push(type)); + assert.equal(alertEscape.defaultPrevented, true); + assert.deepEqual(sent, ["dismiss-dialog"]); +}); + +test("started cut drag owns playout keys and Escape cancels without TAKE OUT", () => { + const keydownMarker = 'document.addEventListener("keydown", function (event) {'; + const start = app.lastIndexOf(keydownMarker) + keydownMarker.length; + const end = app.indexOf("\n });", start); + const handle = new Function( + "event", + "modalFocusManager", + "cutDragGesture", + "clearCutDragGesture", + "legacyAlertDialog", + "send", + app.slice(start, end)); + const sent = []; + let clears = 0; + const manager = { getActiveModal() { return null; } }; + for (const key of ["F2", "F3", "F8", "Escape"]) { + const event = { + key, + repeat: false, + defaultPrevented: false, + preventDefault() { this.defaultPrevented = true; } + }; + handle( + event, + manager, + { started: true }, + () => { clears += 1; }, + {}, + (type) => sent.push(type)); + assert.equal(event.defaultPrevented, true, key + " must stay inside drag loop"); + } + assert.equal(clears, 1, "only Escape cancels the active drag"); + assert.deepEqual(sent, []); }); test("playlist controls send intent for selection, enabled state, movement and deletion", () => { @@ -267,7 +336,7 @@ test("playlist controls send intent for selection, enabled state, movement and d const homeEndBranchStart = app.lastIndexOf('} else if ((event.key === "Home"'); const homeEndBranch = app.slice( homeEndBranchStart, - app.indexOf('} else if (event.key === "Escape"', homeEndBranchStart)); + app.indexOf("\n });", homeEndBranchStart)); assert.doesNotMatch(homeEndBranch, /event\.preventDefault\(\)/); const boundaryHelperStart = app.indexOf("function selectPlaylistBoundary"); const boundaryHelper = app.slice( @@ -468,76 +537,26 @@ test("playlist native drag sends one source row and rejects checkbox drag", () = assert.equal(Object.hasOwn(sent[0].payload, "selectedRowIds"), false); }); -test("playlist drop zone accepts only the exact selected-cut drag token", () => { - class MiniDataTransfer { - constructor() { this.values = new Map(); } - get types() { return [...this.values.keys()]; } - setData(type, value) { this.values.set(type, value); } - getData(type) { return this.values.get(type) || ""; } - } - - const sourceStart = app.indexOf("function hasDragType"); - const sourceEnd = app.indexOf( - 'playlistMoveUp.addEventListener("click"', sourceStart); - assert.ok(sourceStart >= 0 && sourceEnd > sourceStart); - const playlistDropZone = new EventTarget(); - const sent = []; - const bindDropZone = new Function( - "playlistDropZone", - "send", - "selectedCutsDragToken", - "playlistMutationLocked", - app.slice(sourceStart, sourceEnd)); - bindDropZone( - playlistDropZone, - (type, payload) => sent.push({ type, payload }), - "legacy-selected-cuts", - false); - - const playlistTransfer = new MiniDataTransfer(); - playlistTransfer.setData("text/x-mbn-playlist-row", "row-a"); - const playlistDrop = new Event("drop", { cancelable: true, bubbles: true }); - Object.assign(playlistDrop, { dataTransfer: playlistTransfer }); - playlistDropZone.dispatchEvent(playlistDrop); - assert.equal(playlistDrop.defaultPrevented, false); - assert.deepEqual(sent, []); - - const externalText = new MiniDataTransfer(); - externalText.setData("text/plain", "external-text"); - const externalDrop = new Event("drop", { cancelable: true, bubbles: true }); - Object.assign(externalDrop, { dataTransfer: externalText }); - playlistDropZone.dispatchEvent(externalDrop); - assert.equal(externalDrop.defaultPrevented, false); - assert.deepEqual(sent, []); - - const cutTransfer = new MiniDataTransfer(); - cutTransfer.setData("text/plain", "legacy-selected-cuts"); - const cutOver = new Event("dragover", { cancelable: true }); - Object.assign(cutOver, { dataTransfer: cutTransfer }); - playlistDropZone.dispatchEvent(cutOver); - assert.equal(cutOver.defaultPrevented, true); - const cutDrop = new Event("drop", { cancelable: true, bubbles: true }); - Object.assign(cutDrop, { dataTransfer: cutTransfer }); - playlistDropZone.dispatchEvent(cutDrop); - assert.equal(cutDrop.defaultPrevented, true); - assert.deepEqual(sent, [{ type: "drop-selected-cuts", payload: {} }]); - - const lockedDropZone = new EventTarget(); - const lockedSent = []; - bindDropZone( - lockedDropZone, - (type, payload) => lockedSent.push({ type, payload }), - "legacy-selected-cuts", - true); - const lockedOver = new Event("dragover", { cancelable: true }); - Object.assign(lockedOver, { dataTransfer: cutTransfer }); - lockedDropZone.dispatchEvent(lockedOver); - assert.equal(lockedOver.defaultPrevented, false); - const lockedDrop = new Event("drop", { cancelable: true, bubbles: true }); - Object.assign(lockedDrop, { dataTransfer: cutTransfer }); - lockedDropZone.dispatchEvent(lockedDrop); - assert.equal(lockedDrop.defaultPrevented, false); - assert.deepEqual(lockedSent, []); +test("cut drag uses the original pointer rectangle and drops only inside the playlist", () => { + const start = app.indexOf("function pointInsideElement"); + const end = app.indexOf("function legacyCutKey", start); + assert.ok(start >= 0 && end > start); + const drag = app.slice(start, end); + assert.match(drag, /LegacyCutDrag\.createDragRectangle\(position, cutDragSize\)/); + assert.match(drag, /LegacyCutDrag\.shouldStart\(gesture\.rectangle, pointerPosition\(event\)\)/); + assert.match(drag, /event\.buttons & 1/); + assert.match(drag, /pointInsideElement\(playlistDropZone, event\)/); + assert.match(drag, /send\("drop-selected-cuts", \{\}\)/); + assert.match(app, /state\.interactionMetrics/); + assert.match(app, /metrics\.coordinateSpace !== "host-dip"/); + assert.match(app, /LegacyCutDrag\.toCssDragSize/); + assert.match(app, /window\.devicePixelRatio/); + assert.match(app, /x: event\.clientX - bounds\.left/); + assert.match(app, /x: Math\.round\(position\.x\)/); + assert.doesNotMatch(app, /x: Math\.round\(event\.clientX - bounds\.left\)/); + assert.match(app, + /cutDragGesture\.rectangle = window\.LegacyCutDrag\.createDragRectangle/); + assert.doesNotMatch(app, /selectedCutsDragToken|setData\("text\/plain"/); }); test("operator catalog drag rejects an interactive child pointer origin", () => { @@ -931,14 +950,16 @@ test("UC1 tree render preserves state but resets Expand and first root on tab re const categoryTree = new MiniTree(); const catalogExpandAll = { checked: false }; - const harness = new Function("categoryTree", "catalogExpandAll", "document", ` + const harness = new Function( + "categoryTree", "catalogExpandAll", "document", "modalFocusManager", ` let renderedTreeTabId = null; let treeInteractionLocked = false; + let treeFocusOwnedBeforeRender = false; const treeUiStateByTab = new Map(); const pendingTreeResetTabs = new Set(); ${app.slice(helperStart, helperEnd)} return { beginTreeRender, finishTreeRender, selectTreeNode }; - `)(categoryTree, catalogExpandAll, document); + `)(categoryTree, catalogExpandAll, document, { getActiveModal() { return null; } }); const state = tabId => ({ isBusy: false, fixedSectionBatch: null, @@ -1140,13 +1161,14 @@ test("theme expert and trading halt actions preserve original single click wirin test("named DB playlists and playout use separate closed native intents", () => { for (const intent of [ "refresh-named-playlists", "select-named-playlist", - "load-selected-named-playlist", "create-named-playlist", - "save-current-named-playlist", "delete-selected-named-playlist" + "load-named-playlist-by-id", "create-named-playlist", + "save-current-named-playlist-to", "delete-selected-named-playlist" ]) assert.match(app, new RegExp("\\\"" + intent + "\\\"")); assert.match(app, /state\.namedPlaylist/); assert.match(app, /dataset\.namedPlaylistDefinitionId/); - assert.match(app, /definitionId: definition\.dataset\.namedPlaylistDefinitionId/); - assert.match(app, /dbLoadButton\.disabled = busy \|\| !named/); + assert.match(app, + /beginNamedPlaylistSelection\(definition\.dataset\.namedPlaylistDefinitionId\)/); + assert.match(app, /dbLoadButton\.disabled = operatorMutationLocked \|\| busy \|\| !named/); assert.match(app, /named\.isWriteQuarantined/); assert.doesNotMatch(app, /programCode|listText/i); @@ -1155,8 +1177,14 @@ test("named DB playlists and playout use separate closed native intents", () => } for (const intent of [ "prepare-playout", "take-in", "next-playout", "take-out", - "choose-background", "toggle-background", "set-fade-duration" + "choose-background", "toggle-background" ]) assert.match(playout, new RegExp("\\\"" + intent + "\\\"")); + assert.doesNotMatch(playout, /post\("set-fade-duration"/); + assert.doesNotMatch(playout, /fadeDuration\.addEventListener/); + assert.match(playout, /fadeDuration\.disabled = true;/); + assert.match(playout, /fadeDuration\.value = String\(playout\.fadeDuration\);/); + assert.match(markup, + /id="playout-fade-duration" disabled hidden aria-hidden="true" tabindex="-1"/); assert.match(playout, /takeOut\.disabled = busy \|\| !commandAvailable;/); assert.match(playout, /playout\.isPlayCompletionPending === true/); assert.match(playout, /playout\.outcomeUnknown !== true/); @@ -1207,8 +1235,9 @@ test("FSell and VI dialogs send opaque native intents and never own file or stoc }); test("separator rows preserve the original drag gesture", () => { - assert.match(app, /element\.draggable = true/); - assert.doesNotMatch(app, /draggable = !row\.isSeparator/); + assert.match(app, /element\.dataset\.cutDragEnabled = cutSelectionEnabled \? "true" : "false"/); + assert.match(app, /element\.draggable = false/); + assert.doesNotMatch(app, /cutDragEnabled = !row\.isSeparator/); }); test("a C# busy snapshot freezes controls instead of silently accepting queued input", () => { diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/NamedPlaylist/LegacyNamedPlaylistOperatorIntegrationTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/NamedPlaylist/LegacyNamedPlaylistOperatorIntegrationTests.cs index 082c918..94a0ac9 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/NamedPlaylist/LegacyNamedPlaylistOperatorIntegrationTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/NamedPlaylist/LegacyNamedPlaylistOperatorIntegrationTests.cs @@ -219,6 +219,153 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests Assert.True(saved.CommandResult?.Succeeded); } + [Fact] + public async Task Save_to_opaque_id_treats_known_commit_with_failed_readback_as_success() + { + var persistence = new OperatorNamedPlaylistPersistence([]) + { + FailNextLoad = true + }; + var controller = CreateController(persistence); + var listed = await controller.RefreshNamedPlaylistsAsync(); + var definitionId = Assert.Single( + listed.NamedPlaylist!.Definitions).DefinitionId; + + var saved = await controller.SaveCurrentNamedPlaylistToAsync(definitionId); + + Assert.Equal(1, persistence.ReplaceCalls); + Assert.Equal( + LegacyNamedPlaylistMutationOutcome.CommittedOptimistic, + saved.NamedPlaylist?.LastMutationOutcome); + Assert.Equal(LegacyOperatorStatusKind.Warning, saved.StatusKind); + Assert.Equal("save-current-named-playlist-to", saved.CommandResult?.Command); + Assert.Equal(definitionId, saved.CommandResult?.TargetId); + Assert.True(saved.CommandResult?.Succeeded); + } + + [Fact] + public async Task Save_to_opaque_id_does_not_reuse_a_stale_success_after_validation_rejection() + { + var persistence = new OperatorNamedPlaylistPersistence([]); + var controller = CreateController(persistence); + var listed = await controller.RefreshNamedPlaylistsAsync(); + var definitionId = Assert.Single( + listed.NamedPlaylist!.Definitions).DefinitionId; + var first = await controller.SaveCurrentNamedPlaylistToAsync(definitionId); + Assert.True(first.CommandResult?.Succeeded); + Assert.Equal(1, persistence.ReplaceCalls); + + var action = LegacyFixedActionCatalog.Default + .GetActions(LegacyFixedMarket.Overseas) + .First(candidate => candidate.Available); + for (var index = 0; + index <= LegacyNamedPlaylistWorkflowController.MaximumItems; + index++) + { + controller.ActivateFixedAction(action.Id); + } + + var rejected = await controller.SaveCurrentNamedPlaylistToAsync(definitionId); + + Assert.Equal(1, persistence.ReplaceCalls); + Assert.Equal( + LegacyNamedPlaylistMutationOutcome.CommittedFresh, + rejected.NamedPlaylist?.LastMutationOutcome); + Assert.Equal(LegacyOperatorStatusKind.Warning, rejected.StatusKind); + Assert.Equal("save-current-named-playlist-to", rejected.CommandResult?.Command); + Assert.Equal(definitionId, rejected.CommandResult?.TargetId); + Assert.False(rejected.CommandResult?.Succeeded); + } + + [Theory] + [InlineData("refresh-named-playlists")] + [InlineData("select-named-playlist")] + [InlineData("create-named-playlist")] + [InlineData("delete-selected-named-playlist")] + [InlineData("load-named-playlist-by-id")] + [InlineData("save-current-named-playlist-to")] + public void Native_dispatch_failure_emits_exact_terminal_receipt( + string command) + { + var controller = CreateController( + new OperatorNamedPlaylistPersistence([])); + const string definitionId = "named-definition-opaque"; + + var failed = controller.ReportNamedPlaylistCommandFailure( + command, + definitionId, + "The native request was rejected before completion."); + + Assert.Equal(command, failed.CommandResult?.Command); + Assert.Equal(definitionId, failed.CommandResult?.TargetId); + Assert.False(failed.CommandResult?.Succeeded); + Assert.Equal(LegacyOperatorStatusKind.Error, failed.StatusKind); + } + + [Fact] + public void Native_dispatch_failure_rejects_unrelated_command_names() + { + var controller = CreateController( + new OperatorNamedPlaylistPersistence([])); + + Assert.Throws(() => + controller.ReportNamedPlaylistCommandFailure( + "load-selected-named-playlist", + "named-definition-opaque", + "Rejected.")); + } + + [Theory] + [InlineData("refresh-named-playlists")] + [InlineData("select-named-playlist")] + [InlineData("create-named-playlist")] + [InlineData("delete-selected-named-playlist")] + public void Busy_cycle_completion_echoes_exact_dispatch_id(string command) + { + var controller = CreateController( + new OperatorNamedPlaylistPersistence([])); + + var completed = controller.CompleteNamedPlaylistDispatch( + command, + "named-ui-42", + succeeded: true); + + Assert.Equal(command, completed.CommandResult?.Command); + Assert.Equal("named-ui-42", completed.CommandResult?.TargetId); + Assert.True(completed.CommandResult?.Succeeded); + } + + [Fact] + public async Task Ambiguous_native_save_failure_latches_no_retry_quarantine() + { + var persistence = new OperatorNamedPlaylistPersistence([]); + var controller = CreateController(persistence); + const string definitionId = "named-definition-opaque"; + + var failed = controller.ReportNamedPlaylistWriteOutcomeUnknown( + "save-current-named-playlist-to", + definitionId, + "The accepted save ended without a definitive database outcome."); + + Assert.Equal("save-current-named-playlist-to", failed.CommandResult?.Command); + Assert.Equal(definitionId, failed.CommandResult?.TargetId); + Assert.False(failed.CommandResult?.Succeeded); + Assert.True(failed.NamedPlaylist?.IsWriteQuarantined); + Assert.Equal( + LegacyNamedPlaylistMutationOutcome.OutcomeUnknown, + failed.NamedPlaylist?.LastMutationOutcome); + + var listed = await controller.RefreshNamedPlaylistsAsync(); + controller.SelectNamedPlaylist( + Assert.Single(listed.NamedPlaylist!.Definitions).DefinitionId); + var blocked = await controller.SaveCurrentNamedPlaylistAsync(); + + Assert.Equal(0, persistence.ReplaceCalls); + Assert.Equal( + LegacyNamedPlaylistMutationOutcome.Quarantined, + blocked.NamedPlaylist?.LastMutationOutcome); + } + private static LegacyOperatorController CreateController( INamedPlaylistPersistenceService persistence) => new( @@ -259,6 +406,8 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests public IReadOnlyList? LastReplaceItems { get; private set; } + public bool FailNextLoad { get; set; } + public Task ListAsync( int maximumResults = LegacyNamedPlaylistPersistenceService.DefaultMaximumDefinitions, CancellationToken cancellationToken = default) @@ -282,6 +431,13 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests CancellationToken cancellationToken = default) { ++LoadCalls; + if (FailNextLoad) + { + FailNextLoad = false; + return Task.FromException( + new NamedPlaylistDataException("The committed save could not be read back.")); + } + if (!_exists || !string.Equals(programCode, ProgramCode, StringComparison.Ordinal)) { return Task.FromException( diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/NamedPlaylist/LegacyNamedPlaylistWorkflowTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/NamedPlaylist/LegacyNamedPlaylistWorkflowTests.cs index ca50a5a..2a614e5 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/NamedPlaylist/LegacyNamedPlaylistWorkflowTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/NamedPlaylist/LegacyNamedPlaylistWorkflowTests.cs @@ -101,8 +101,11 @@ public sealed class LegacyNamedPlaylistWorkflowTests Assert.Equal(LegacyNamedPlaylistMutationOutcome.CommittedFresh, created.Outcome); Assert.Equal("00000003", persistence.LastCreatedProgramCode); - Assert.Equal("새 프로그램", created.Snapshot.SelectedTitle); Assert.Equal(3, created.Snapshot.Definitions.Count); + Assert.Equal( + created.Snapshot.Definitions[0].DefinitionId, + created.Snapshot.SelectedDefinitionId); + var firstTitleAfterCreate = created.Snapshot.Definitions[0].Title; Assert.Empty(created.Snapshot.Rows); Assert.DoesNotContain( typeof(LegacyNamedPlaylistProgramDraft).GetProperties(), @@ -113,14 +116,45 @@ public sealed class LegacyNamedPlaylistWorkflowTests Assert.Equal(LegacyNamedPlaylistMutationOutcome.CommittedFresh, deleted.Outcome); Assert.Equal(1, persistence.DeleteCalls); - Assert.Equal("00000003", persistence.LastDeletedProgramCode); - Assert.Null(deleted.Snapshot.SelectedDefinitionId); + Assert.NotEqual("00000003", persistence.LastDeletedProgramCode); + Assert.Equal( + deleted.Snapshot.Definitions[0].DefinitionId, + deleted.Snapshot.SelectedDefinitionId); Assert.DoesNotContain( deleted.Snapshot.Definitions, - definition => string.Equals(definition.Title, "새 프로그램", StringComparison.Ordinal)); + definition => string.Equals( + definition.Title, + firstTitleAfterCreate, + StringComparison.Ordinal)); + Assert.Contains( + deleted.Snapshot.Definitions, + definition => string.Equals( + definition.Title, + "새 프로그램", + StringComparison.Ordinal)); Assert.Equal(LegacyNamedPlaylistFreshness.Fresh, deleted.Snapshot.ListFreshness); } + [Fact] + public async Task EveryNonEmptyListRebind_SelectsFirstDefinitionLikeWinFormsPList() + { + var persistence = CreateSeededPersistence(); + var controller = new LegacyNamedPlaylistWorkflowController(persistence); + + var opened = await controller.RefreshAsync(); + + Assert.Equal(opened.Definitions[0].DefinitionId, opened.SelectedDefinitionId); + var second = opened.Definitions[1]; + controller.SelectDefinition(second.DefinitionId); + + var refreshed = await controller.RefreshAsync(); + + Assert.Equal(refreshed.Definitions[0].DefinitionId, refreshed.SelectedDefinitionId); + Assert.NotEqual(second.Title, refreshed.SelectedTitle); + Assert.True(refreshed.CanLoad); + Assert.True(refreshed.CanDelete); + } + [Fact] public async Task KnownRollback_PreservesLoadedRowsAndNeverRetries() { diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyBridgeProtocolTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyBridgeProtocolTests.cs index e2420ef..35908ee 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyBridgeProtocolTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyBridgeProtocolTests.cs @@ -448,35 +448,44 @@ public sealed class LegacyBridgeProtocolTests } [Fact] - public void ParseNamedPlaylistIntents_AcceptsOnlyOpaqueIdsTitlesAndEmptyCommands() + public void ParseNamedPlaylistIntents_AcceptsOnlyOpaqueIdsTitlesAndDispatchIds() { Assert.True(LegacyBridgeProtocol.TryParseIntent( - "{\"type\":\"refresh-named-playlists\",\"payload\":{}}", + "{\"type\":\"refresh-named-playlists\",\"payload\":{\"requestId\":\"named-ui-1\"}}", out var refresh, out _)); - Assert.IsType(refresh); + Assert.Equal( + "named-ui-1", + Assert.IsType(refresh).RequestId); Assert.True(LegacyBridgeProtocol.TryParseIntent( - "{\"type\":\"select-named-playlist\",\"payload\":{\"definitionId\":\"named-definition-AAAAAAAAAAAB\"}}", + "{\"type\":\"select-named-playlist\",\"payload\":{\"definitionId\":\"named-definition-AAAAAAAAAAAB\",\"requestId\":\"named-ui-2\"}}", out var selection, out _)); - Assert.Equal( - "named-definition-AAAAAAAAAAAB", - Assert.IsType(selection).DefinitionId); + var selectionIntent = Assert.IsType(selection); + Assert.Equal("named-definition-AAAAAAAAAAAB", selectionIntent.DefinitionId); + Assert.Equal("named-ui-2", selectionIntent.RequestId); Assert.True(LegacyBridgeProtocol.TryParseIntent( - "{\"type\":\"create-named-playlist\",\"payload\":{\"title\":\"아침 방송 A^B\"}}", + "{\"type\":\"create-named-playlist\",\"payload\":{\"title\":\"아침 방송 A^B\",\"requestId\":\"named-ui-2\"}}", out var create, out _)); + var createIntent = Assert.IsType(create); + Assert.Equal("아침 방송 A^B", createIntent.Title); + Assert.Equal("named-ui-2", createIntent.RequestId); + + Assert.True(LegacyBridgeProtocol.TryParseIntent( + "{\"type\":\"delete-selected-named-playlist\",\"payload\":{\"requestId\":\"named-ui-3\"}}", + out var delete, + out _)); Assert.Equal( - "아침 방송 A^B", - Assert.IsType(create).Title); + "named-ui-3", + Assert.IsType(delete).RequestId); foreach (var type in new[] { "load-selected-named-playlist", - "save-current-named-playlist", - "delete-selected-named-playlist" + "save-current-named-playlist" }) { Assert.True(LegacyBridgeProtocol.TryParseIntent( @@ -490,12 +499,24 @@ public sealed class LegacyBridgeProtocolTests "{\"type\":\"select-named-playlist\",\"payload\":{\"definitionId\":\"named-definition-AAAAAAAAAAAB\",\"programCode\":\"00000001\"}}", out _, out _)); + Assert.False(LegacyBridgeProtocol.TryParseIntent( + "{\"type\":\"select-named-playlist\",\"payload\":{\"definitionId\":\"named-definition-AAAAAAAAAAAB\"}}", + out _, + out _)); Assert.False(LegacyBridgeProtocol.TryParseIntent( "{\"type\":\"save-current-named-playlist\",\"payload\":{\"rows\":[]}}", out _, out _)); Assert.False(LegacyBridgeProtocol.TryParseIntent( - "{\"type\":\"create-named-playlist\",\"payload\":{\"title\":\" padded \"}}", + "{\"type\":\"create-named-playlist\",\"payload\":{\"title\":\" padded \",\"requestId\":\"named-ui-4\"}}", + out _, + out _)); + Assert.False(LegacyBridgeProtocol.TryParseIntent( + "{\"type\":\"refresh-named-playlists\",\"payload\":{}}", + out _, + out _)); + Assert.False(LegacyBridgeProtocol.TryParseIntent( + "{\"type\":\"delete-selected-named-playlist\",\"payload\":{}}", out _, out _)); } diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyInteractionMetricsBridgeTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyInteractionMetricsBridgeTests.cs new file mode 100644 index 0000000..a6651e3 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyInteractionMetricsBridgeTests.cs @@ -0,0 +1,110 @@ +using System.Text.Json; +using MBN_STOCK_WEBVIEW.LegacyApplication; +using MBN_STOCK_WEBVIEW.LegacyBridge; + +namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests; + +public sealed class LegacyInteractionMetricsBridgeTests +{ + [Fact] + public void SerializeState_ProjectsExplicitSystemDragMetricsWithCamelCaseNames() + { + var json = LegacyBridgeProtocol.SerializeState( + CreateSnapshot(), + interactionMetrics: new LegacyInteractionMetrics(7, 9, 96, 1.5)); + + using var document = JsonDocument.Parse(json); + var payload = document.RootElement.GetProperty("payload"); + var metrics = payload.GetProperty("interactionMetrics"); + + Assert.Equal(7, metrics.GetProperty("cutDragWidthDips").GetInt32()); + Assert.Equal(9, metrics.GetProperty("cutDragHeightDips").GetInt32()); + Assert.Equal("host-dip", metrics.GetProperty("coordinateSpace").GetString()); + Assert.Equal(96u, metrics.GetProperty("sourceDpi").GetUInt32()); + Assert.Equal(1.5d, metrics.GetProperty("hostRasterizationScale").GetDouble()); + Assert.False(metrics.TryGetProperty("CutDragWidthDips", out _)); + Assert.False(metrics.TryGetProperty("CutDragHeightDips", out _)); + } + + [Theory] + [InlineData(6, 9, 144u, 4, 6)] + [InlineData(5, 5, 120u, 4, 4)] + [InlineData(7, 7, 168u, 4, 4)] + public void FromPixelsAtDpi_ConvertsWindowsPixelsToCssPixelCoordinates( + int physicalWidth, + int physicalHeight, + uint dpi, + int expectedCssWidth, + int expectedCssHeight) + { + var metrics = LegacyInteractionMetrics.FromPixelsAtDpi( + physicalWidth, + physicalHeight, + dpi); + + Assert.Equal(expectedCssWidth, metrics.CutDragWidthDips); + Assert.Equal(expectedCssHeight, metrics.CutDragHeightDips); + Assert.Equal(dpi, metrics.SourceDpi); + } + + [Fact] + public void FromPixelsAtDpi_RejectsUnknownDpiAndInvalidAxesSafely() + { + var unknownDpi = LegacyInteractionMetrics.FromPixelsAtDpi(8, 8, 0, 1.5); + Assert.Equal(4, unknownDpi.CutDragWidthDips); + Assert.Equal(4, unknownDpi.CutDragHeightDips); + Assert.Equal(1.5d, unknownDpi.HostRasterizationScale); + + var metrics = LegacyInteractionMetrics.FromPixelsAtDpi(0, -1, 192); + Assert.Equal(4, metrics.CutDragWidthDips); + Assert.Equal(4, metrics.CutDragHeightDips); + Assert.Equal(192u, metrics.SourceDpi); + } + + [Fact] + public void SerializeState_NormalizesInvalidSystemMetricsPerAxisToFourPixels() + { + var json = LegacyBridgeProtocol.SerializeState( + CreateSnapshot(), + interactionMetrics: new LegacyInteractionMetrics(0, -1, 0, double.NaN)); + + using var document = JsonDocument.Parse(json); + var metrics = document.RootElement + .GetProperty("payload") + .GetProperty("interactionMetrics"); + + Assert.Equal(4, metrics.GetProperty("cutDragWidthDips").GetInt32()); + Assert.Equal(4, metrics.GetProperty("cutDragHeightDips").GetInt32()); + Assert.Equal("host-dip", metrics.GetProperty("coordinateSpace").GetString()); + Assert.Equal(96u, metrics.GetProperty("sourceDpi").GetUInt32()); + Assert.Equal(1d, metrics.GetProperty("hostRasterizationScale").GetDouble()); + } + + [Fact] + public void SerializeState_ExistingCallShapeReceivesSafeDefaultMetrics() + { + var json = LegacyBridgeProtocol.SerializeState(CreateSnapshot()); + + using var document = JsonDocument.Parse(json); + var metrics = document.RootElement + .GetProperty("payload") + .GetProperty("interactionMetrics"); + + Assert.Equal(4, metrics.GetProperty("cutDragWidthDips").GetInt32()); + Assert.Equal(4, metrics.GetProperty("cutDragHeightDips").GetInt32()); + Assert.Equal("host-dip", metrics.GetProperty("coordinateSpace").GetString()); + Assert.Equal(96u, metrics.GetProperty("sourceDpi").GetUInt32()); + Assert.Equal(1d, metrics.GetProperty("hostRasterizationScale").GetDouble()); + } + + private static LegacyOperatorSnapshot CreateSnapshot() => new( + 1, + string.Empty, + [], + null, + [], + [], + null, + string.Empty, + LegacyOperatorStatusKind.Neutral); +} diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyHiddenNonOriginalControlsTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyHiddenNonOriginalControlsTests.cs index 028d842..19869fa 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyHiddenNonOriginalControlsTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyHiddenNonOriginalControlsTests.cs @@ -17,7 +17,7 @@ public sealed class LegacyHiddenNonOriginalControlsTests "styles.css")); [Fact] - public void Non_original_shortcuts_are_hidden_but_original_dissolve_control_is_visible() + public void Non_original_shortcuts_and_original_hidden_dissolve_panel_are_not_operator_controls() { Assert.Contains("class=\"manual-list-actions\" aria-label=\"수동 송출 목록\" hidden", Markup, @@ -25,13 +25,14 @@ public sealed class LegacyHiddenNonOriginalControlsTests Assert.Contains("id=\"playout-prepare\" class=\"prepare\" type=\"button\" disabled hidden", Markup, StringComparison.Ordinal); - Assert.Contains("class=\"fade-control\" for=\"playout-fade-duration\">DissolveTime", + Assert.Contains("class=\"fade-control\" for=\"playout-fade-duration\" hidden", Markup, StringComparison.Ordinal); - Assert.Contains("id=\"playout-fade-duration\" disabled aria-label=\"DissolveTime\"", + Assert.Contains("aria-hidden=\"true\">DissolveTime", Markup, StringComparison.Ordinal); - Assert.DoesNotContain("id=\"playout-fade-duration\" disabled aria-label=\"DissolveTime\" hidden", + Assert.Contains( + "id=\"playout-fade-duration\" disabled hidden aria-hidden=\"true\" tabindex=\"-1\"", Markup, StringComparison.Ordinal); Assert.Contains("*[hidden] { display: none !important; }", Styles, diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyInteractionMetricsNativeContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyInteractionMetricsNativeContractTests.cs new file mode 100644 index 0000000..add8e74 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyInteractionMetricsNativeContractTests.cs @@ -0,0 +1,137 @@ +namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests; + +public sealed class LegacyInteractionMetricsNativeContractTests +{ + private static readonly string RepositoryRoot = FindRepositoryRoot(); + private static readonly string AppRoot = Path.Combine( + RepositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp"); + private static readonly string Window = File.ReadAllText( + Path.Combine(AppRoot, "MainWindow.xaml.cs")); + + [Fact] + public void NativeWindowReadsBothWindowsDragThresholdsInCssDipCoordinates() + { + Assert.Contains("private const int SmCxDrag = 68;", Window, + StringComparison.Ordinal); + Assert.Contains("private const int SmCyDrag = 69;", Window, + StringComparison.Ordinal); + Assert.Contains("[DllImport(\"user32.dll\", ExactSpelling = true)]", Window, + StringComparison.Ordinal); + Assert.Contains( + "GetSystemMetricsForDpi(\n" + + " SmCxDrag,\n" + + " LegacyInteractionMetrics.CssPixelsPerInch)", + Window.Replace("\r\n", "\n", StringComparison.Ordinal), + StringComparison.Ordinal); + Assert.Contains( + "GetSystemMetricsForDpi(\n" + + " SmCyDrag,\n" + + " LegacyInteractionMetrics.CssPixelsPerInch)", + Window.Replace("\r\n", "\n", StringComparison.Ordinal), + StringComparison.Ordinal); + Assert.DoesNotContain("GetSystemMetrics(", Window, StringComparison.Ordinal); + Assert.Contains("LegacyInteractionMetrics.FromPixelsAtDpi(", Window, + StringComparison.Ordinal); + Assert.Contains("logical DIP coordinate space used by XAML", Window, + StringComparison.Ordinal); + Assert.Contains("Root.XamlRoot?.RasterizationScale", Window, + StringComparison.Ordinal); + } + + [Fact] + public void NativeWindowRefreshesMetricsAfterRuntimeSettingsAndDpiChanges() + { + Assert.Contains("interactionMetrics: _interactionMetrics", Window, + StringComparison.Ordinal); + Assert.Contains("private const uint WmSettingChange = 0x001A;", Window, + StringComparison.Ordinal); + Assert.Contains("private const uint WmDisplayChange = 0x007E;", Window, + StringComparison.Ordinal); + Assert.Contains("private const uint WmDpiChanged = 0x02E0;", Window, + StringComparison.Ordinal); + Assert.Contains("AttachInteractionMetricsRefresh();", Window, + StringComparison.Ordinal); + Assert.Contains("DetachInteractionMetricsRefresh();", Window, + StringComparison.Ordinal); + Assert.Contains("SetWindowSubclass(", Window, StringComparison.Ordinal); + Assert.Contains("RemoveWindowSubclass(", Window, StringComparison.Ordinal); + Assert.Contains( + "message is WmSettingChange or WmDisplayChange or WmDpiChanged", + Window, + StringComparison.Ordinal); + Assert.Contains("QueueInteractionMetricsRefresh();", Window, + StringComparison.Ordinal); + Assert.Contains("DispatcherQueuePriority.High", Window, + StringComparison.Ordinal); + Assert.True( + CountOccurrences(Window, "RefreshInteractionMetrics();") >= 3, + "Metrics must be refreshed at setup, runtime change, and state posting."); + Assert.DoesNotContain( + "private readonly LegacyInteractionMetrics _interactionMetrics", + Window, + StringComparison.Ordinal); + } + + [Fact] + public void NativeWindowDisablesFutureWebZoomAndFallsBackWithoutPrivateApis() + { + Assert.Contains( + "coreWebView.Settings.AreBrowserAcceleratorKeysEnabled = false;", + Window, + StringComparison.Ordinal); + Assert.Contains("coreWebView.Settings.IsZoomControlEnabled = false;", Window, + StringComparison.Ordinal); + Assert.Contains("coreWebView.Settings.IsPinchZoomEnabled = false;", Window, + StringComparison.Ordinal); + Assert.DoesNotContain("CoreWebView2Controller.ZoomFactor", Window, + StringComparison.Ordinal); + Assert.Contains("catch (DllNotFoundException)", Window, + StringComparison.Ordinal); + Assert.Contains("catch (EntryPointNotFoundException)", Window, + StringComparison.Ordinal); + Assert.Contains("catch (BadImageFormatException)", Window, + StringComparison.Ordinal); + Assert.Contains("catch (PlatformNotSupportedException)", Window, + StringComparison.Ordinal); + Assert.Equal( + 4, + CountOccurrences(Window, "return CreateFallbackInteractionMetrics();")); + Assert.Contains( + "LegacyInteractionMetrics.Default with\n" + + " {\n" + + " HostRasterizationScale = ReadHostRasterizationScale()", + Window.Replace("\r\n", "\n", StringComparison.Ordinal), + StringComparison.Ordinal); + } + + private static int CountOccurrences(string value, string search) + { + var count = 0; + var index = 0; + while ((index = value.IndexOf(search, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += search.Length; + } + + return count; + } + + private static string FindRepositoryRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "MBN_STOCK_WEBVIEW.sln"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Repository root was not found."); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyNamedPlaylistDispatchFailureContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyNamedPlaylistDispatchFailureContractTests.cs new file mode 100644 index 0000000..e0a9ec3 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyNamedPlaylistDispatchFailureContractTests.cs @@ -0,0 +1,100 @@ +namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests; + +public sealed class LegacyNamedPlaylistDispatchFailureContractTests +{ + private static readonly string RepositoryRoot = FindRepositoryRoot(); + private static readonly string MainWindow = File.ReadAllText(Path.Combine( + RepositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", + "MainWindow.xaml.cs")); + + [Fact] + public void NativeRejectionsAndUnexpectedExceptionsTerminateCorrelatedCommands() + { + Assert.Contains( + "PostState(IsNamedPlaylistModalIntent(intent)", + MainWindow, + StringComparison.Ordinal); + Assert.Contains( + "var failureState = ReportIntentFailure(", + MainWindow, + StringComparison.Ordinal); + Assert.Contains( + "ObserveOperatorMutationQuarantine(failureState);", + MainWindow, + StringComparison.Ordinal); + Assert.Contains( + "writeOutcomeMayBeUnknown: dispatchStarted", + MainWindow, + StringComparison.Ordinal); + Assert.Contains( + "_controller.ReportNamedPlaylistWriteOutcomeUnknown(", + MainWindow, + StringComparison.Ordinal); + Assert.Contains("RefreshNamedPlaylistsForDispatchAsync(", MainWindow, + StringComparison.Ordinal); + Assert.Contains("SelectNamedPlaylistForDispatch(", MainWindow, + StringComparison.Ordinal); + Assert.Contains("CreateNamedPlaylistForDispatchAsync(", MainWindow, + StringComparison.Ordinal); + Assert.Contains("DeleteNamedPlaylistForDispatchAsync(", MainWindow, + StringComparison.Ordinal); + Assert.Contains("_controller.CompleteNamedPlaylistDispatch(", MainWindow, + StringComparison.Ordinal); + Assert.Contains( + "_controller.ReportNamedPlaylistCommandFailure(\n" + + " \"load-named-playlist-by-id\"", + MainWindow.Replace("\r\n", "\n", StringComparison.Ordinal), + StringComparison.Ordinal); + Assert.Contains( + "_controller.ReportNamedPlaylistCommandFailure(\n" + + " \"save-current-named-playlist-to\"", + MainWindow.Replace("\r\n", "\n", StringComparison.Ordinal), + StringComparison.Ordinal); + } + + [Fact] + public void SelectionUsesAnExactReceiptWithoutDisablingTheSecondDoubleClick() + { + var normalized = MainWindow.Replace("\r\n", "\n", StringComparison.Ordinal); + + Assert.Contains( + "LegacySelectNamedPlaylistIntent selection =>\n" + + " SelectNamedPlaylistForDispatch(", + normalized, + StringComparison.Ordinal); + Assert.Contains( + "intent is LegacyRefreshNamedPlaylistsIntent or\n" + + " LegacySelectNamedPlaylistIntent or", + normalized, + StringComparison.Ordinal); + Assert.DoesNotContain( + "LegacyRefreshNamedPlaylistsIntent or\n" + + " LegacySelectNamedPlaylistIntent or", + normalized, + StringComparison.Ordinal); + Assert.Contains( + "\"select-named-playlist\",\n" + + " requestId,\n" + + " succeeded", + normalized, + StringComparison.Ordinal); + } + + private static string FindRepositoryRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "MBN_STOCK_WEBVIEW.sln"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Repository root was not found."); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyPlayoutWebContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyPlayoutWebContractTests.cs index 4d03692..af05662 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyPlayoutWebContractTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyPlayoutWebContractTests.cs @@ -14,7 +14,7 @@ public sealed class LegacyPlayoutWebContractTests Path.Combine(WebRoot, "playout-ui.js")); [Fact] - public void ShellExposesOriginalPlayoutBackgroundAndFadeControls() + public void ShellExposesBackgroundControlsButKeepsOriginalHiddenFadeStateInert() { foreach (var id in new[] { @@ -40,10 +40,11 @@ public sealed class LegacyPlayoutWebContractTests var fadeMarkup = ExtractElement(Markup, "playout-fade-duration", ""); Assert.Equal(20, CountOccurrences(fadeMarkup, "