From 83398044c65a9cab8a2b348b701258a616503273 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Wed, 15 Jul 2026 21:04:05 +0900 Subject: [PATCH] feat: align legacy operator and playout behavior --- README.md | 18 +- docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md | 288 +++++++ docs/LEGACY_FEATURE_AUDIT.md | 16 +- docs/LEGACY_PARITY_VALIDATION_20260715.md | 34 +- docs/LEGACY_PLAYOUT_ANALYSIS.md | 5 +- docs/OPERATOR_UI_PARITY.md | 25 +- docs/OPERATOR_WORKFLOW.md | 4 +- docs/PLAYOUT.md | 6 +- docs/SCENE_EQUIVALENCE.md | 5 +- docs/TRUSTED_BACKGROUND_ROOT.md | 23 +- docs/UC1_TREE_EVENT_PARITY.md | 38 + .../Playout/IPlayoutEngine.cs | 15 +- .../Playout/PlayoutBridgeProtocol.cs | 2 +- .../Playout/Scenes/LegacyPlayoutWorkflow.cs | 230 +++++- .../Playout/Scenes/LegacyScenePreview.cs | 2 +- .../Scenes/LegacySceneRefreshPolicy.cs | 26 +- .../Playout/Scenes/PagedQuoteSceneBuilders.cs | 2 +- .../Scenes/ReadOnlyPanelSceneBuilders.cs | 2 +- .../RegisteredLegacySceneCueProvider.cs | 6 +- .../LegacyComparisonWorkflowController.cs | 65 +- .../LegacyFixedActionCatalog.cs | 27 +- .../LegacyIndustrySelectionWorkflow.cs | 20 + .../LegacyOperatorController.cs | 706 ++++++++++++++++-- .../LegacyOperatorPlayoutModels.cs | 5 +- .../LegacyOverseasSelectionWorkflow.cs | 35 + .../LegacyRuntimeStockCutMenuLoader.cs | 355 +++++++++ .../LegacyStockCutCatalog.cs | 46 +- .../TradingHalt/LegacyTradingHaltWorkflow.cs | 27 + .../LegacyBridgeProtocol.cs | 56 +- .../LegacyPlayoutUiIntents.cs | 2 +- .../MainWindow.CloseConfirmation.cs | 11 +- .../MainWindow.Playout.cs | 105 ++- .../MainWindow.xaml.cs | 28 +- .../Web/app.js | 615 +++++++++++++-- .../Web/index.html | 4 +- .../Web/playout-ui.js | 26 +- .../Web/styles.css | 17 +- .../LegacyParityStartupCompositionResolver.cs | 6 +- .../Configuration/PlayoutOptions.cs | 2 +- .../PlayoutSceneCompositionFactory.cs | 45 +- .../Configuration/ValidatedPlayoutOptions.cs | 10 +- .../Interop/DynamicK3dSession.cs | 7 +- .../Interop/K3dComConstants.cs | 3 +- .../TornadoPlayoutEngine.cs | 18 +- .../stock-result-keyboard.test.cjs | 154 ++++ tests/LegacyParityWeb/thin-client.test.cjs | 600 ++++++++++++++- .../CandleSceneBuilderTests.cs | 32 + .../LegacyPlayoutWorkflowTests.cs | 377 +++++++++- .../LegacyScenePreviewTests.cs | 4 +- .../LegacySceneRefreshPolicyTests.cs | 19 +- .../PlayoutBridgeProtocolTests.cs | 2 +- .../ReadOnlyPanelSceneBuildersTests.cs | 2 +- .../RegisteredLegacySceneCueProviderTests.cs | 24 + .../LegacyCutKeyboardParityTests.cs | 169 +++++ .../LegacyFixedActionCatalogTests.cs | 25 +- .../LegacyOperatorControllerTests.cs | 221 +++++- .../LegacyOperatorPlayoutRequestTests.cs | 225 ++++++ .../LegacyOverseasOperatorControllerTests.cs | 11 +- .../LegacyRuntimeStockCutMenuLoaderTests.cs | 420 +++++++++++ .../LegacyStockCutInteractionParityTests.cs | 362 +++++++++ .../LegacyTabActivationParityTests.cs | 331 ++++++++ ...cyNamedPlaylistOperatorIntegrationTests.cs | 42 ++ .../LegacyBridgeProtocolTests.cs | 141 +++- .../LegacyPlayoutBridgeTests.cs | 26 +- ...acyCloseConfirmationNativeContractTests.cs | 6 +- .../LegacyHiddenNonOriginalControlsTests.cs | 9 +- .../LegacyPlayoutNativeContractTests.cs | 61 +- .../LegacyPlayoutWebContractTests.cs | 27 +- ...yRuntimeStockCutMenuNativeContractTests.cs | 85 +++ .../LegacyWebContractTests.cs | 102 ++- .../DynamicK3dSessionTests.cs | 22 +- ...cyParityStartupCompositionResolverTests.cs | 14 +- .../PlayoutFakes.cs | 3 + .../PlayoutSafetyValidationTests.cs | 29 +- .../PlayoutSceneCompositionFactoryTests.cs | 67 ++ .../TornadoPlayoutEngineTests.cs | 65 ++ .../Web/cut-keyboard-app-integration.test.cjs | 103 +++ 77 files changed, 6377 insertions(+), 391 deletions(-) create mode 100644 docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md create mode 100644 docs/UC1_TREE_EVENT_PARITY.md create mode 100644 src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyRuntimeStockCutMenuLoader.cs create mode 100644 tests/LegacyParityWeb/stock-result-keyboard.test.cjs create mode 100644 tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyCutKeyboardParityTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyRuntimeStockCutMenuLoaderTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyStockCutInteractionParityTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeStockCutMenuNativeContractTests.cs create mode 100644 tests/Web/cut-keyboard-app-integration.test.cjs diff --git a/README.md b/README.md index ab35392..db8a1ee 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,9 @@ > 현재 개발 경로는 둘로 분리되어 있습니다. 아래의 기존 구현 범위는 비교용 루트 프로토타입을 설명합니다. 고객 요구처럼 원본 UI와 클릭 이벤트를 먼저 1:1 이관하는 새 병렬 앱의 범위와 실행 방법은 [원본 호환 마이그레이션 기준선](docs/LEGACY_PARITY_REBASELINE.md)을 참조하세요. +원본 이벤트를 코드 단위로 다시 검증하는 현재 작업 순서와 완료 범위는 +[동작 동등성 작업 기록](docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md)을 기준으로 판단합니다. + 현재 기준 기술 스택은 다음과 같습니다. - .NET 8 / C# @@ -21,7 +24,7 @@ - 드래그 정렬 및 위/아래 이동 - 선택 삭제 - 로컬 저장/불러오기 -- Oracle/MariaDB 4개 국내 시장 종목 검색과 실행본 `종목.ini` 31개 컷 선택 +- Oracle/MariaDB 4개 국내 시장 종목 검색과 CP949 `Res\종목.ini` 컷 선택(`%LOCALAPPDATA%` 외부 override → 패키지 후보 → 검증된 31개 내장 목록) - 기존 운영 흐름을 반영한 `PREPARE` / `TAKE IN` / `NEXT` / `TAKE OUT` UI - `F8`, `Esc` 단축키와 원본 배경 선택용 F2의 안전 예약 - 원본의 공급자 비의존 SQL/데이터 요청 71개를 .NET 8 Core 프로젝트로 이관 @@ -50,8 +53,9 @@ Oracle/MariaDB 조회 계층과 Tornado/K3D 어댑터의 코드·계약·자동 1. `MBN_STOCK_WEBVIEW.sln`을 엽니다. 2. 솔루션 구성을 `Debug`, 플랫폼을 `x64`로 선택합니다. -3. `MBN_STOCK_WEBVIEW`를 시작 프로젝트로 설정합니다. -4. 실행 프로필이 `MBN_STOCK_WEBVIEW (Package)`인지 확인합니다. +3. 공유 시작 프로필 `Legacy Parity App (VS F5)` 또는 + `src\MBN_STOCK_WEBVIEW.LegacyParityApp`을 시작 프로젝트로 선택합니다. +4. 실행 프로필이 `MBN_STOCK_WEBVIEW.LegacyParityApp - Explicit DryRun (Package)`인지 확인합니다. 5. `F5`로 빌드·배포·실행합니다. 이 프로젝트는 MSIX 패키지 ID가 필요한 앱입니다. `bin` 아래의 EXE를 직접 실행하지 말고 반드시 Package 프로필이나 설치된 MSIX로 실행하세요. @@ -85,7 +89,7 @@ Tornado2의 `PGM` 창은 본 프로그램의 KTAP 명령을 렌더링하는 출 MSIX 생성: ```powershell -dotnet build MBN_STOCK_WEBVIEW.csproj ` +dotnet build .\src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj ` -c Release ` -p:Platform=x64 ` -p:GenerateAppxPackageOnBuild=true ` @@ -103,9 +107,9 @@ MSIX 설치 폴더는 읽기 전용입니다. 로그, 플레이리스트, 운영 ## 프로젝트 구조 ```text -MBN_STOCK_WEBVIEW.csproj WinUI 3 / WebView2 / 단일 프로젝트 MSIX -MainWindow.xaml(.cs) 네이티브 창, WebView 초기화, 보안 경계, 메시지 브리지 -Web/ FarPoint를 대체하는 로컬 운영 UI +src/MBN_STOCK_WEBVIEW.LegacyParityApp/ WinUI 3 / WebView2 / 원본 동작 호환 MSIX 앱 + MainWindow*.cs, Web/ 네이티브 권위·보안 경계와 로컬 운영 UI +MBN_STOCK_WEBVIEW.csproj 이전 프로토타입 앱(기본 F5 대상 아님) src/MBN_STOCK_WEBVIEW.Core/ 이관된 데이터 요청 및 공급자 비의존 Core src/MBN_STOCK_WEBVIEW.Infrastructure/ Oracle/MariaDB 공급자·설정·복원력·health src/MBN_STOCK_WEBVIEW.Playout/ x64 Tornado/K3D STA·COM 어댑터와 안전 게이트 diff --git a/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md b/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md new file mode 100644 index 0000000..683ec7e --- /dev/null +++ b/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md @@ -0,0 +1,288 @@ +# MBN_STOCK_N 동작 동등성 작업 기록 + +기준 원본은 `C:\Users\MD\source\repos\MBN_STOCK_N`이며 항상 읽기 전용으로 분석한다. +대상은 `MBN_STOCK_WEBVIEW.LegacyParityApp`이다. 화면 모양만 비슷하게 만드는 것이 아니라 +원본 이벤트의 입력, 상태 전이, DB 조회, K3D 호출 순서와 화면 피드백을 코드 단위로 대조한다. + +## 판정 절차 + +1. 원본 이벤트 핸들러와 관련 필드를 추적한다. +2. 정상 동작과 명백한 원본 버그를 구분한다. +3. 정상 동작을 특성화 테스트로 고정한다. +4. C# authoritative state와 WebView 표시를 함께 수정한다. +5. 안전 게이트를 유지한 상태에서 Debug/Release x64 빌드와 전체 테스트를 실행한다. + +## Slice 1: 선택 행, F8, NEXT, TAKE OUT + +상태: 코드 및 자동 테스트 완료, 실제 PGM 명령은 실행하지 않음. + +원본 근거: + +- `MainForm.cs:1078` `PlayList_CellClick` +- `MainForm.cs:1915` `btnTake_Click` +- `MainForm.cs:2020` `ONAirMode` +- `MainForm.cs:2081` `btnTakeout_Click` +- `MainForm.cs:2120` `btnNext_Click` +- `MainForm.cs:2176` `Next_Scene` +- `MainForm.cs:2495` `Show_PlayList` + +고정한 계약: + +- 운영자 선택 후보와 현재 PROGRAM 행을 독립 상태로 유지한다. +- F8은 IDLE/PREPARED/PROGRAM 모두에서 그 시점의 선택 후보를 새로 Prepare 후 Play한다. +- 선택 후보가 비활성이면 아래 방향의 첫 활성 행만 사용하며 순환하지 않는다. +- NEXT는 선택 후보가 아니라 현재 PROGRAM 행을 기준으로 Page NEXT 또는 다음 활성 컷을 결정한다. +- 선택 후보는 회색, 현재 PROGRAM 행은 분홍으로 동시에 표시한다. +- Page NEXT 결과를 상태 표시뿐 아니라 플레이리스트 Page 셀에도 남긴다. +- F8/PREPARE는 페이지형 컷을 page 1로 다시 시작한다. +- 마지막 항목에서는 NEXT를 비활성화하고 vendor 명령을 보내지 않는다. +- TAKE OUT은 `StopAll` 범위이며 IDLE에서도 연결된 엔진에 긴급 정리를 요청할 수 있다. + +남은 세부 감사: + +- Page 총수를 행에 저장된 원본 `current/total`에서 고정할지, PREPARE 시 fresh DB 결과로 + 다시 계산할지에 대한 동등성은 PageN 전용 Slice에서 다시 판정한다. +- column 3의 정확한 한글 문자열로 Page NEXT를 분기하던 원본 조건과 typed builder + 식별자를 사용하는 현재 조건의 차이도 같은 Slice에서 검증한다. + +유지한 안전 차이: + +- SDK Play callback이 pending이면 TAKE OUT 이외 명령을 보내지 않는다. +- timeout 또는 `OutcomeUnknown`은 재시도하지 않고 격리한다. +- 결과가 확인되기 전에는 새 PROGRAM 행으로 확정하지 않는다. +- PROGRAM 중 구조 변경은 현재 단계에서 계속 차단한다. + +재현하지 않은 원본 버그: + +- 마지막 컷에서 이전 `m_ShowIndex` 때문에 같은 scene을 다시 Play하는 동작 +- 빈 `catch`로 잘못된 page 문자열과 DB/엔진 오류를 숨기는 동작 +- Home/End가 timer와 색상 상태를 깨뜨리는 동작 +- 고정 크기 `s_buf[100]`에 의존하는 Space 토글 + +## Slice 2: 체크박스, 키보드, 이동, 삭제, drag + +상태: 코드 및 자동 테스트 완료, 변경을 합친 package의 실제 마우스 검증은 아직 수행하지 않음. + +원본 근거: + +- `MainForm.cs:1078` `PlayList_CellClick` +- `MainForm.cs:1464` `btnup_Click` +- `MainForm.cs:1579` `Spd_UpDown` +- `MainForm.cs:1649` `btndelete_Click` +- `MainForm.cs:1730` `btntdelete_Click` +- `MainForm.cs:3140` `checkBox1_CheckedChanged` +- `MainForm.cs:3622` `PlayList_DragDrop` +- `MainForm.cs:3805` `PlayList_KeyPress` +- `MainForm.cs:3887` `ProcessCmdKey` +- `MainForm.Designer.cs:1598` `AllowRowMove` + +고정한 계약: + +- F8 후보, 현재 PROGRAM, Ctrl/Shift 삭제 집합과 키보드 focus를 서로 다른 상태로 유지한다. +- F8/PREPARE 성공과 disabled-row skip은 실제 F8 후보만 옮기며 `bufdel`에 해당하는 삭제 집합과 + `m_newrow/m_oldrow`에 해당하는 Shift anchor를 바꾸지 않는다. +- 체크박스 열을 누르면 삭제 집합과 Shift anchor는 그대로 두고 그 행만 F8 후보가 된다. +- Space는 포커스된 실제 행의 현재 enabled 값을 반전하며 별도 100칸 배열을 사용하지 않는다. +- Home/End는 원본처럼 자동 갱신 timer를 먼저 멈추고 첫/마지막 행을 후보로 만들며 그 행으로 + scroll한다. DEL 집합, Shift anchor, 현재 입력/ListBox focus, PROGRAM과 Page 표시는 바꾸지 않는다. +- Up/Down은 마지막 활성 행 하나만 인접 행과 교환하고 이동한 stable row identity를 후보로 유지한다. +- playlist drag도 Ctrl/Shift 집합 전체가 아니라 실제 drag source 한 행만 before/after로 이동한다. +- 선택 삭제 뒤 기존 후보가 살아 있으면 같은 row identity를 유지하고, 삭제됐으면 다음 생존 행, + 다음 행이 없으면 이전 생존 행을 명시적인 후보로 만든다. +- 빈 목록에 첫 행을 추가하거나 DB playlist를 불러오면 첫 행이 화면의 후보와 실제 F8 대상이 + 되지만, 실제 행 클릭 전에는 DEL 집합에 암묵적으로 들어가지 않는다. 화면에 후보가 없는데 + 내부적으로 0번 행을 재생하던 fallback은 제거했다. +- PREPARED/PROGRAM/pending/OutcomeUnknown에서는 enabled, 이동, 삭제, row/cut drag를 Web과 + native 양쪽에서 차단한다. 후보 선택과 컷 단순 선택은 로컬 상태이므로 허용한다. + +재현하지 않은 원본 버그: + +- `bufdel[100]`, `s_buf[100]`의 제한, 중복 인덱스와 stale selection +- 선택 삭제가 PROGRAM 행이나 현재 송출 순서를 손상하는 동작 +- 이동·삭제 뒤 `m_rowidx`, `m_newrow`, `m_crow` 숫자 인덱스가 다른 항목을 가리키는 동작 +- Home/End 뒤 focus와 후보가 서로 다른 행을 가리키는 동작 +- 컷 drag payload를 무시하거나 playlist 다중 삭제 집합을 다중 행 drag로 확대하는 동작 + +## Slice 3: PageN 문자열 분기와 저장 total + +상태: 코드 및 집중 자동 테스트 완료, 실제 PGM 명령은 실행하지 않음. + +원본 근거: + +- `MainForm.cs:2120` `btnNext_Click` +- `MainForm.cs:2176` `Next_Scene` +- `MainForm.cs:2495` `Show_PlayList` + +고정한 계약: + +- 실제 operator row는 column 3 `GraphicType`이 정확히 `5단 표그래프`, `6종목 현재가`, + `12종목 현재가`일 때만 같은 행 Page NEXT를 허용한다. +- 위 세 문자열은 각각 5·6·12개 단위와 연결하고, row의 `current/total` 중 total을 + 1..20 범위에서 읽어 PREPARE부터 TAKE OUT까지 고정한다. +- on-air 중 DB 결과가 늘어도 저장 total 뒤의 새 page를 노출하지 않고, 줄어든 결과로 + 현재/다음 page를 만들 수 없으면 vendor NEXT 전에 fail-closed한다. +- 같은 paged scene code로 해석되더라도 column 3 문자열이 정확하지 않으면 일반 행으로 + 취급하여 다음 enabled playlist row로 이동한다. +- paged row의 마지막 page에서 다음 playlist row로 이동한 뒤에는 원본처럼 자동 갱신을 + 다시 시작하지 않는다. 일반 행에서 다음 row로 이동한 경우에만 다시 시작한다. +- 요구사항의 20 page cap, 0건 fail-closed, 마지막 row에서 재생 반복 금지는 유지한다. + 원본의 101/121/241건에서 21 page가 되는 off-by-one, `1/0`, stale scene 재생은 명시적인 + 안전 교정이며 문자 그대로 재현하지 않는다. +- timer refresh의 새 구현은 불완전할 수 있는 `GetPlayingScene` proxy가 아니라 adapter가 + 보존한 on-air scene reference를 갱신한다는 실제 구현 계약으로 주석과 테스트 이름을 바로잡았다. + +자동 검증: + +- Core: 저장 total 고정, DB 증가/감소, refresh, 없는 다음 page 차단, 비정규 문자열의 + playlist NEXT, metadata 없는 직접 Core 호출의 기존 fresh-plan 동작. +- Application: 세 정확한 문자열의 5·6·12 mapping, suffix total 고정, 유사 문자열 차단, + 0/21 및 잘못된 `current/total` 거부. + +## Slice 4: 종목 검색과 런타임 컷 메뉴 + +상태: 코드 및 자동 테스트 완료, 실제 DB/Tornado 명령은 실행하지 않음. + +원본 근거: + +- `MainForm.cs:399`, `MainForm.cs:596` `종목.ini`/`INI_parser` +- `MainForm.cs:844` `button9_Click` +- `MainForm.cs:859` `searchStock` +- `MainForm.cs:3039` `txtSearch_KeyPress` +- `MainForm.cs:3427` `listView1_MouseDown` +- `MainForm.cs:3534` `listView1_MouseMove` +- `MainForm.cs:3635` `PlayList_DragDrop` + +고정한 계약: + +- 빈 검색 버튼은 기존 결과·종목 선택·컷 선택을 유지하고, 빈 Enter만 전체 조회를 실행한다. +- 새 조회는 종목 선택만 지우며 컷 선택은 유지한다. 이후 새 종목을 선택해 drag/drop하면 + 남아 있던 컷을 새 종목 identity로 추가한다. +- 컷 Ctrl/Shift, 정확한 좌표와 500ms 미만 double-click, 물리 순서 drag, 중복 drop을 유지한다. +- 일반 시장의 `-` 구분행은 무시하고 NXT에서는 원본 경고 modal 순서를 유지한다. +- CP949 `종목.ini`는 `%LOCALAPPDATA%` 외부 override, package 후보 순서로 읽는다. 둘 다 없거나 + 유효하지 않으면 검증된 31개 내장 목록으로 폴백한다. 외부 파일이 존재하지만 잘못됐으면 + 다른 파일로 우회하지 않고 즉시 폴백한다. +- 최대 50개 물리행, 64 KiB, 정확한 `[종목]`, 알려진 컷과 구분행만 허용하고 부분 결과와 + 실제 경로는 상태에 노출하지 않는다. 실제 INI 파일은 Git/MSIX에 넣지 않는다. +- 런타임 권위본은 원본의 `bin\Debug\Res\종목.ini`이며 현재 내장 fallback과 SHA-256이 같다. + 이 파일과 `s5001`이 사용하는 `시간외단일가`를 유지한다. 소스 트리 `Res\종목.ini`의 + 서로 다른 `시간외종가` 표기는 실제 실행 기준으로 되돌리지 않는다. + +## Slice 5: 전역 키, 배경과 DissolveTime + +상태: 코드 및 자동 테스트 완료, 실제 PGM 명령은 실행하지 않음. + +원본 근거: + +- `MainForm.cs:964` `MainForm_KeyDown` +- `MainForm.cs:3887` `ProcessCmdKey` +- `MainForm.cs:4008` `btnback_Click` +- `MainForm.cs:4037` `ckback_CheckedChanged` +- `MainForm.cs:488`, `MainForm.cs:1059` `ComboDi.SelectedIndex` + +고정한 계약: + +- modal에서 처리한 Escape/F2/F3/F8은 뒤의 playout key listener로 전달하지 않는다. 이름 있는 + playlist modal의 Escape는 modal만 닫고 TAKE OUT을 보내지 않는다. +- 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이 전역 선택을 덮어쓰던 경로는 제거했다. + +## Slice 6: 탭 재진입과 dragover 교환 + +상태: 코드 및 자동 테스트 완료, 실제 DB 쓰기/Tornado 명령은 실행하지 않음. + +원본 근거: + +- `MainForm.cs:2937` `tabControl1_SelectedIndexChanged` +- `MainForm.cs:3183` `tab_MouseDown` +- `MainForm.cs:3214` `tab_MouseMove` +- `MainForm.cs:3234` `tab_DragOver` + +고정한 계약: + +- 코스피/코스닥은 재진입마다 목록을 다시 조회하고 선택·비교쌍을 초기화한다. +- 비교는 저장된 CP949 비교쌍을 매 재진입마다 다시 읽고, 해외종목·거래정지는 검색 결과, + 선택과 정렬 상태를 새 UC처럼 초기화한다. MA5/MA20은 MainForm 전역 상태로 유지한다. +- 폐기된 탭의 늦은 비동기 결과는 activation generation으로 무시한다. +- 좁은 탭을 넓은 탭 오른쪽으로 옮길 때는 `target.right - source.width` 초과, 왼쪽은 + `target.left + source.width` 미만에서만 교환한다. 같거나 넓은 source는 진입 즉시 교환한다. +- 같은 crossing의 반복 dragover와 busy 거부가 중복/누락 교환을 만들지 않게 했다. + +## Slice 7: UC1 트리 이벤트와 이동평균선 샘플링 + +상태: 코드 및 자동 테스트 완료, 실제 DB/Tornado 명령은 실행하지 않음. + +원본 근거: + +- `Control/UC1.cs:121-139`, `1303-1340`, `1393-1407` +- `Scene/s8010.cs:431-453`, `765-807` +- `MainForm.cs:2495+`, `2937-2999` + +고정한 계약: + +- 루트 라벨 단일 클릭은 선택만 하며 disclosure만 펼침/접기를 수행한다. +- 루트·리프 더블클릭은 펼침 상태를 바꾸지 않고 C# action을 정확히 한 번 보낸다. +- 같은 탭의 상태 render는 open/selection/focus/scroll을 보존하고, 실제 탭 재진입은 원본 UC1처럼 + Expand=true, 전체 펼침, 첫 루트 선택·focus·scroll로 초기화한다. +- Busy/고정 섹션 배치 중에는 action뿐 아니라 로컬 disclosure 변경도 차단한다. +- MA5/MA20은 행 추가 시 저장하지 않고 s8010 PREPARE, F8, NEXT, 자동 갱신의 장면 생성 시점마다 + 현재 전역 체크 상태를 다시 읽는다. named playlist identity에도 MA 플래그를 저장하지 않는다. +- 원본의 `uc_3 == null` 부모 더블클릭 예외와 부분 추가 버그는 재현하지 않는 명시적 결함 제외다. + +상세 근거와 결함 제외는 `docs/UC1_TREE_EVENT_PARITY.md`에 기록했다. + +## Slice 8: K3D 원시 인자, callback 게이트, 컷 ListView 경계 + +상태: 코드 및 자동 테스트 완료, 실제 PGM 명령은 실행하지 않음. + +원본 근거: + +- `MainForm.cs:2628-2639` 공통 `SetBackgroundVideo(path, 2004, 10)` +- `MainForm.cs`의 `SetSceneEffectType(10, FADE, ComboDi.SelectedIndex)` 호출 +- `MainForm.cs:2651+`, `1992`의 3,000,000초 Int32 곱셈/Timer.Interval 결과 +- `MainForm.cs:3427-3525`, `3566-3588`, `3635-3643` 컷 선택·드롭 +- `MainForm.cs:3887-3906` Home/End 후 base key 처리 + +고정한 계약: + +- K3D scene effect 첫 인자는 bool로 정규화하지 않고 원본 raw `10`을 전달한다. +- 공통 배경은 raw `(2004, 10)`, s5006 장면 내부 배경은 `(1, 1)`을 구분한다. +- OnCutOut/OnStopAll 완료 전에는 PREPARE, F8, NEXT, 갱신과 중복 TAKE OUT을 SDK에 보내지 않고 + Web 버튼과 운영 데이터 mutation도 잠근다. pending Play 중 비상 TAKE OUT 1회 경로는 유지한다. +- 5076/5079/5080/5081은 원본의 unchecked Int32 overflow 뒤 Timer.Interval 예외 때문에 실제 자동 + 갱신이 시작되지 않으므로 34.7일 타이머로 바꾸지 않고 명시적으로 비활성화한다. +- 런타임 컷 메뉴 bridge 범위를 원본 물리행과 같은 0..49로 통일한다. +- 연속 Shift/Ctrl+Shift는 Win32 ListView selection mark를 고정하고, 빈 영역 클릭은 선택을 지우되 + native anchor는 유지한다. 빈 영역 뒤 stale 컷 drop은 0행이다. +- PREPARED/PROGRAM 중 컷 pointer double-click의 암묵적 행 추가를 native/Web 양쪽에서 차단한다. +- Home/End의 playlist/timer 부작용 뒤에도 검색 입력 caret과 검색결과 기본 선택 동작을 보존한다. +- DissolveTime의 모든 외부·Core 경계는 원본 ComboBox index인 0..19만 허용한다. + +## Slice 9: 종목 ListBox와 컷 ListView 키보드 + +상태: 코드 및 자동 테스트 완료, 실제 package 키보드 검증은 아직 수행하지 않음. + +고정한 계약: + +- 종목 결과는 하나의 tab stop을 갖고 ArrowUp/Down, Home/End와 10행 viewport의 + PageUp/PageDown 9행 이동을 재현한다. double-click도 선택만 바꾸고 컷을 추가하지 않는다. +- 종목 Home/End는 결과 선택과 focus를 보존한 채 전역 playlist 경계 부수 효과도 실행한다. +- 컷 목록은 물리행과 separator를 포함해 ArrowUp/Down, Home/End, Space를 처리한다. +- 컷 Ctrl/Shift/Ctrl+Shift는 Win32 selection mark 규칙을 C# 권위 상태에서 적용하고 키보드 + 선택은 마우스 전용 `selectedRowsList`에 섞지 않는다. drop은 native 선택 집합을 사용한다. +- PREPARED/PROGRAM/pending/unknown 또는 종목 미선택 상태에서는 컷 선택과 focus 이동을 함께 + 차단해 DOM과 C# 상태가 어긋나지 않게 한다. + +## 다음 대조 순서 + +1. 컷 ListView의 drag 시작 임계값과 실제 package 키보드 회귀 +2. GraphE/FSell/VI/PList/AList/ThemeA/EList modal별 남은 키·focus·취소 이벤트 +3. 자동 갱신 UI의 실제 실행 상태 기반 통합 테스트와 종료 상태 행렬 +4. Debug/Release x64와 새 package에서 실제 마우스·키보드·drag 회귀 + +각 항목은 실제 원본 코드 근거와 회귀 테스트가 모두 생기기 전에는 완료로 판정하지 않는다. diff --git a/docs/LEGACY_FEATURE_AUDIT.md b/docs/LEGACY_FEATURE_AUDIT.md index bff0265..51c36b6 100644 --- a/docs/LEGACY_FEATURE_AUDIT.md +++ b/docs/LEGACY_FEATURE_AUDIT.md @@ -49,10 +49,10 @@ HELLO/BYE 연결은 2026-07-15에 확인했다. scene/PREPARE/TAKE/PGM은 실행 | LF-014 | P1 | ThemeA/EList | 완료 | 검색·신규·편집·삭제·종목 구성·Delete key, ThemeA exact-title 중복확인, EList opaque `rowId` drag 재정렬 이관. 실제 UI에서 ThemeA 500건 중복 read와 EList 5행 reorder 확인 | scene PGM은 LF-005 | | LF-015 | P1 | UC5 해외 | 완료 | 해외업종·해외종목·해외지수 조회와 playlist 경로 이관 | s6001 13개 영상은 LF-001 | | LF-016 | P1 | F2/F3 기본 배경 | 외부자산 필요 | sibling background root, picker, background-none와 trusted path 검사는 완료. 이 장비에 `기본.vrv`가 없음 | 제공 전 fail closed | -| LF-017 | P1 | 시스템 종료 확인 | 완료 | 기본 취소 확인창, 중립 문구, 종료/취소 두 버튼과 자동 TAKE OUT/반대 명령 금지 경계를 코드·자동 테스트로 유지 | 현재 Release package-context 회귀는 LF-005와 함께 판정 | +| 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 대체 | 완료 | 실제 마우스로 playlist 다중행 reorder·checkbox 차단·탭 release 전 dragover 교환·EList reorder 확인. row drop 버블링의 cut append와 Chromium checkbox-origin target 문제를 수정하고 DOM 회귀로 고정 | 활성 Print/Excel export는 원본에도 없음 | +| 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-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 추정 금지 | @@ -69,9 +69,9 @@ intent를 만들지 않는 것이 원본과 동등하다. 종료의 자동 `Stop 현재 `DRAG` 구현은 다음 세 C# 권위 경로를 포함한다. -- playlist 행 drag는 선택된 비연속 행도 현재 표시 순서를 보존한 한 블록으로 임의 - before/after 위치에 옮긴다. 체크박스에서 시작한 drag와 PREPARED/PROGRAM/pending/unknown - 상태의 변경은 차단한다. +- playlist 행 drag는 FarPoint `AllowRowMove`의 source 행 하나를 임의 before/after 위치로 + 옮긴다. Ctrl/Shift 집합은 삭제 선택으로만 유지하며, 체크박스에서 시작한 drag와 + PREPARED/PROGRAM/pending/unknown 상태의 변경은 차단한다. - 시장 탭은 drop 때가 아니라 dragover 대상에 들어갈 때 교환한다. source/target identity와 예상 두 인덱스가 C# 현재 순서와 일치할 때만 적용해 중복 메시지가 순서를 되돌리지 않는다. - 원본 UC6 `FpSpread2.AllowDragDrop`은 ThemeA/EList 편집행의 opaque `rowId` drag로 @@ -82,6 +82,12 @@ ThemeA의 원본 `중복확인`도 이름만 담은 strict intent로 연결했 결과 불명은 `Indeterminate`이며 write나 추정 fallback이 없다. 원본처럼 edit 자기 행을 제외하지 않아 변경하지 않은 이름은 중복으로 판정된다. +명백한 결함 또는 송출 안전을 해치는 동작은 동등성 예외로 유지한다. 마지막 행에 NEXT로 +도착한 뒤 stale `m_ShowIndex` 때문에 같은 scene을 다시 `Play(10)`하는 동작, 실패한 Page NEXT를 +성공 전에 화면 숫자에 반영하는 동작, `s_buf[100]` shadow 값 때문에 첫 Space가 실제 체크값과 +어긋나는 동작은 재현하지 않는다. 마지막 NEXT는 no-wrap/no-command, Page 표시는 성공 뒤 반영, +Space는 현재 authoritative enabled 값을 반전한다. + ## Development Live strict local authorization [`DEVELOPMENT_LIVE_VISUAL_STUDIO.md`](DEVELOPMENT_LIVE_VISUAL_STUDIO.md)의 시작 구성은 diff --git a/docs/LEGACY_PARITY_VALIDATION_20260715.md b/docs/LEGACY_PARITY_VALIDATION_20260715.md index 8800369..bce31af 100644 --- a/docs/LEGACY_PARITY_VALIDATION_20260715.md +++ b/docs/LEGACY_PARITY_VALIDATION_20260715.md @@ -32,7 +32,7 @@ Debug/Release 및 Web 회귀 수는 확정했지만, scene PREPARE/TAKE IN/PGM | 독립 클릭 | UC4 테마, UC6 전문가, UC7 거래정지의 원본 단일 클릭과 UC4/UC6 행별 독립 처리 | | 전용 입력 | GraphE, FSell, VI, PList, AList, ThemeA, EList를 WebView 화면과 C# intent로 연결; ThemeA exact-title 중복확인 포함 | | 해외 | UC5 해외업종·해외종목·해외지수 조회와 playlist 생성 | -| playlist | 단일/Ctrl/Shift 선택, enabled, 위/아래, 선택/전체 삭제와 비연속 다중 선택 block drag; PREPARED/PROGRAM/pending/unknown 변경 차단 | +| playlist | F8 후보와 Ctrl/Shift 삭제 선택 분리, enabled, 위/아래, 선택/전체 삭제와 drag source 단일 행 이동; PREPARED/PROGRAM/pending/unknown 변경 차단 | | FarPoint 대체 | Web table로 표현하되 원본 클릭·더블클릭·정렬·이동·저장, 탭 dragover 교환과 UC6/EList opaque 행 drag 의미 유지 | 상세 이벤트 대조는 [`OPERATOR_UI_PARITY.md`](OPERATOR_UI_PARITY.md)를 따른다. @@ -43,8 +43,8 @@ Debug/Release 및 Web 회귀 수는 확정했지만, scene PREPARE/TAKE IN/PGM [`LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md`](LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md)에 있다. 186개 구독/171개 고유 핸들러 수에는 선언만 있고 구독되지 않은 메서드를 넣지 않았다. -- playlist drag는 opaque source/target `rowId`와 before/after만 Web에서 보내며, C#이 현재 - 선택 집합을 표시 순서대로 한 블록으로 이동한다. 체크박스 dragstart는 거부한다. +- playlist drag는 opaque source/target `rowId`와 before/after만 Web에서 보내며, C#이 실제 + drag source 한 행만 이동한다. Ctrl/Shift 집합은 삭제 선택으로 유지하고 체크박스 dragstart는 거부한다. - 시장 탭은 dragover 대상 진입 시 source/target identity와 예상 인덱스를 보낸다. C# 현재 순서가 예상값과 일치할 때만 교환하므로 같은 전달의 재처리는 no-op이다. - UC6의 `FpSpread2.AllowDragDrop`은 ThemeA/EList 편집 행의 opaque `rowId` 재정렬로 @@ -59,8 +59,10 @@ Debug/Release 및 Web 회귀 수는 확정했지만, scene PREPARE/TAKE IN/PGM - `삼성` 검색 결과는 **63개**였고 `삼성출판사`를 선택했다. 컷 3개를 선택한 뒤 한 번의 drop으로 playlist에 정확히 3행을 추가했다. -- playlist 여러 행을 실제 마우스로 선택해 한 블록으로 재정렬했고, enabled 체크박스에서 - 시작한 drag는 행 이동을 만들지 않았다. +- 당시 package에서는 playlist 여러 행을 한 블록으로 재정렬했고, enabled 체크박스에서 + 시작한 drag는 행 이동을 만들지 않았다. 이후 원본 `AllowRowMove` 재감사에서 블록 이동은 + 근거 없는 확장으로 판정되어 현재 코드는 drag source 한 행만 이동하도록 교정됐다. 따라서 + 이 항목의 과거 block-drag 실측은 현재 단일 행 drag의 package 실측으로 재사용하지 않는다. - 시장 탭은 마우스 버튼을 놓기 전 dragover 대상 진입 시 순서가 바뀌었다. - EList 편집기에서 추천행 **5개**의 순서를 drag로 변경했다. - ThemeA 목록 **500건**을 불러온 상태에서 `중복확인` exact-title read가 성공했다. @@ -74,7 +76,7 @@ Debug/Release 및 Web 회귀 수는 확정했지만, scene PREPARE/TAKE IN/PGM 조상 행으로 보고할 수 있어 단순 `event.target.closest("input")` 검사만으로는 부족했다. pointerdown 때 checkbox origin의 행 ID를 기억하고 이어지는 row dragstart를 거부하며, pointerup/pointercancel/dragend에서 상태를 지우도록 수정했다. thin-client 회귀 테스트는 -이 Chromium target 형태, 다중 선택 보존, row drop의 ancestor 무시, 외부 text 거부와 exact +이 Chromium target 형태, drag source 한 행만 전송, row drop의 ancestor 무시, 외부 text 거부와 exact selected-cut token의 단일 처리를 실제 DOM event 순서로 고정한다. ### Development Live 시작 구성 증적 @@ -168,6 +170,26 @@ drag만 5행 순서를 바꾸는 것도 확인했다. .NET Debug/Release 합계 scene PREPARE/TAKE/PGM을 실행하지 않은 이 Live 연결 회차를 전체 방송 송출 PASS로 확대하지 않는다. +### 코드 대조 Slice 1~9 합본 추가 검증 + +이후 원본 이벤트를 다시 코드 단위로 대조해 F8 후보·PROGRAM·DEL 다중선택·Shift anchor를 +분리하고, 런타임 컷 50행, 종목/컷 키보드, UC1 tree, s8010 이동평균 재샘플링, K3D raw 인자와 +OnCutOut/OnStopAll callback 게이트를 합쳤다. 이 합본의 검증 결과는 다음과 같다. + +| 항목 | 추가 검증 결과 | +|---|---| +| .NET Debug x64 | **2,584/2,584** 통과, 실패·건너뜀 0 | +| .NET Release x64 | **2,584/2,584** 통과, 실패·건너뜀 0 | +| Web Node | **456/456** 통과, 실패·취소·건너뜀·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`/`예`/`아니요` 확인 후 정상 종료 | + +이 unsigned MSIX는 개발 package-context 실행 검증용이며 위 signed 배포 패키지를 대체하지 +않는다. 이 추가 회차에서도 scene/PREPARE/TAKE/NEXT/DB write와 Tornado 연결은 실행하지 않았다. + ## 판정 경계 - 35/35 자동 장면 동등성은 모든 장면의 실제 PGM 출력 완료를 뜻하지 않는다. diff --git a/docs/LEGACY_PLAYOUT_ANALYSIS.md b/docs/LEGACY_PLAYOUT_ANALYSIS.md index 31aeaa9..e3c2a0d 100644 --- a/docs/LEGACY_PLAYOUT_ANALYSIS.md +++ b/docs/LEGACY_PLAYOUT_ANALYSIS.md @@ -18,7 +18,7 @@ 새 `DynamicK3dSession`도 같은 순서를 사용한다. output channel이 명시된 경우에만 `EndTransactionOnChannel`을 사용하며 layout `10`은 `Prepare`/`Play`/`CutOut`에만 전달한다. transaction 중 실패하면 `RollbackTransaction`을 시도하고 원래 실패를 보존한다. -공통 scene fade/background는 Web 입력이 아니라 로컬 trusted 설정이다. 원본 `ComboDi.SelectedIndex`에 맞춘 fade 기본값은 6이다. background kind와 scene root 아래 상대 asset, video loop를 `PlayoutSceneCompositionFactory`가 검사하며 `DryRun`도 실제 COM 전에 파일 존재, 확장자, root 탈출과 reparse point를 fail-closed 검증한다. 실제 asset 경로는 Web preview나 wire status에 노출하지 않는다. +공통 scene background 경로는 Web 입력이 아니라 로컬 trusted 설정과 네이티브 파일 선택에서만 온다. fade는 원본 `DissolveTime`/`ComboDi.SelectedIndex`와 같은 0~19 폐쇄형 숫자 intent이며 기본 인덱스는 6(화면 표시 7)이다. K3D 호출도 원본의 raw Int32를 보존해 `SetSceneEffectType(10, FADE, index)`와 공통 `SetBackgroundVideo(path, 2004, 10)`을 사용하고, s5006 내부 배경의 `(1, 1)`과 섞지 않는다. background kind와 scene root 아래 상대 asset, video loop를 `PlayoutSceneCompositionFactory`가 검사하며 `DryRun`도 실제 COM 전에 파일 존재, 확장자, root 탈출과 reparse point를 fail-closed 검증한다. 실제 asset 경로는 Web preview나 wire status에 노출하지 않는다. ## PREPARE, TAKE IN, NEXT, TAKE OUT 상태 전이 @@ -41,6 +41,8 @@ timeout, dispatch 뒤 cancellation 또는 결과가 불명확한 COM 실패는 ` operator command를 시작할 때 timer를 먼저 멈춘다. TAKE IN과 playlist NEXT 성공 뒤 반복 refresh를 시작하고 Page NEXT 뒤에는 원본처럼 다시 시작하지 않는다. 새 runtime은 이전 tracked PLAY의 callback drain을 먼저 확인한 뒤 해당 cut의 첫 `m_time`, 이후 3초의 전체 cooldown을 시작한다. 원본의 UI timer보다 실제 cadence가 callback drain만큼 길어지는 의도적 안전 적응이며, callback 대기와 delay를 겹쳐 `playCompletionPending`이 사실상 계속 유지되는 상태를 방지한다. +단, 5076/5079/5080/5081의 `m_time=3000000`은 원본에서 `m_time * 1000` Int32가 overflow되어 WinForms `Timer.Interval` 설정이 예외로 끝나므로 자동 refresh를 시작하지 않는다. 이를 34.7일짜리 정상 타이머로 해석하지 않는다. + 마이그레이션의 same-scene refresh는 retained on-air scene에 fresh DTO를 적용해 `BeginTransaction → mutation → QueryVariables → EndTransaction → Prepare(10) → tracked Play(10)`을 한 번 수행한다. 원본의 선행·후행 PLAY 두 번 중 선행 replay와 불완전한 `GetPlayingScene` proxy를 사용하지 않는 명시적 안전 차이다. 모든 PLAY를 callback accounting과 연결하고 중복 송출을 피하기 위한 결정이며, Round H의 정확한 `PLAY = 4`, `SCENE_PLAYED = 4` 예산으로 검증했다. refresh generation과 공개 상태는 atomic epoch로 묶어 이전 CTS의 늦은 delay, success, fault 또는 completion이 새 generation이나 stopped 상태를 덮어쓰지 못한다. 마지막 부분 page는 남은 row만 채우고 나머지 object를 clear/hide한다. `s5088` NXT 비교와 조회 index는 모두 `i + pageIndex * 12`를 사용한다. page 경계값과 partial-page clearing은 자동 테스트로 검증했다. @@ -74,6 +76,7 @@ vendor event handler의 `OnScenePlayed`, `OnCutOut`, `OnStopAll`은 managed call - `OnScenePlayed` 성공 뒤에만 이전 retired scene을 unload/release한다. - pending Play callback이 있으면 TAKE OUT을 제외한 PREPARE/TAKE IN/NEXT/timer refresh를 fail-closed 차단한다. +- pending CutOut/StopAll callback이 있으면 중복 TAKE OUT을 포함한 모든 scene 명령을 차단하고, 일치하는 완료 callback을 drain한 뒤에만 다음 PREPARE를 허용한다. - `CutOut`/`StopAll` dispatch 전에 completion counter를 올리고 동기 호출 실패 시 원복한다. 성공 callback은 대응 counter를 하나 줄이고, stop/cut으로 중단된 Play는 별도 `OnScenePlayed`가 없을 수 있으므로 pending Play accounting을 취소한다. - pending lifecycle callback, queue overflow, callback failure 또는 connection generation 불일치가 있으면 Disconnect와 조기 unload를 하지 않고 session을 abandon/quarantine한다. - 이전 generation의 늦은 callback은 현재 scene state를 변경하지 않는다. diff --git a/docs/OPERATOR_UI_PARITY.md b/docs/OPERATOR_UI_PARITY.md index 37d73f2..e9cc3d1 100644 --- a/docs/OPERATOR_UI_PARITY.md +++ b/docs/OPERATOR_UI_PARITY.md @@ -86,10 +86,10 @@ fixed/industry/comparison 트리의 원본 double-click과 독립적으로 처 | `tabControl1_SelectedIndexChanged` | 탭별 최초 데이터 조회 | 10개 identity별 C# activation gate; 최초 진입 1회 load, 재진입 snapshot 유지 | | 탭 drag 이벤트 | 10개 탭 표시 순서 교환 | dragover 대상 진입 시 identity와 예상 위치를 C#이 검증해 교환; 중복 전달은 no-op, active tab·playlist 불변 | | 검색 버튼/Enter | 종목 검색 | 원본 KOSPI→KOSDAQ→NXT 순서와 마지막 이름 일치 규칙 유지 | -| 컷 click/double-click/drag | 원본 31컷 선택·추가 | plain/Ctrl/Shift/Ctrl+Shift와 원본 double-click 판정, batch 추가 | -| playlist click/key/buttons/drag | 선택, enabled, 이동, 삭제, 다중 선택 행 이동 | 선택된 행은 원래 순서를 보존한 한 블록으로 임의 위치에 이동; C# playlist authority와 PREPARED/PROGRAM/pending/unknown mutation 차단 유지 | +| 컷 click/double-click/key/drag | 원본 런타임 컷 선택·추가 | 최대 50 물리행, pointer double-click, Arrow/Home/End/Space와 Ctrl/Shift selection mark, batch 추가 | +| playlist click/key/buttons/drag | F8 후보, 삭제 선택, enabled, 이동, 삭제, 단일 행 drag | F8 후보·PROGRAM·DEL 집합·Shift anchor를 분리하고 drag는 실제 source 한 행만 이동; 송출 mutation 차단 유지 | | F2/F3 | 배경 선택·없음 | trusted sibling background root만 허용; 누락·reparse·escape fail closed | -| F8 | TAKE IN | idle이면 내부 PREPARE → TAKE IN을 한 번에 실행 | +| F8 | 선택 후보 TAKE IN | IDLE/PREPARED/PROGRAM에서 그 시점의 후보를 새 PREPARE → TAKE IN으로 실행; PROGRAM·후보·DEL 집합·Shift anchor는 독립 | | NEXT | Page NEXT 또는 다음 행 | 같은 scene PageN 여부를 C#이 판정 | | Escape | TAKE OUT | modal이 없을 때만 StopAll 기반 TAKE OUT intent | | FormClosing | 종료 확인 | 기본 취소 확인창; 자동 TAKE OUT/반대 명령 없음 | @@ -99,9 +99,10 @@ fixed/industry/comparison 트리의 원본 double-click과 독립적으로 처 FarPoint UI는 Web table/list로 표현한다. 다음 의미는 원본과 같이 유지한다. - plain/Ctrl/Shift/Ctrl+Shift 선택과 현재 행 +- 첫 행은 자동 F8 후보지만 실제 클릭 전에는 DEL 선택이 아니며, F8/Home/End도 기존 DEL 집합과 Shift anchor를 덮어쓰지 않음 - column header 정렬과 현재 표시 순서 - 위/아래 이동, 선택/전체 삭제와 enabled 토글 -- playlist의 비연속 다중 선택을 한 블록으로 유지하는 행 drag와 임의 before/after drop +- playlist의 실제 drag source 한 행을 이동하는 임의 before/after drop; 비연속 Ctrl/Shift 집합은 삭제 선택으로만 사용 - UC6/EList 추천 행의 opaque `rowId` 기반 drag 재정렬 - single-click과 double-click을 컨트롤별로 구분 - 부모 section 전체 추가는 모든 자식을 먼저 materialize한 뒤 한 번에 commit @@ -117,7 +118,7 @@ Theme 결과 double-click은 원본의 5행 현재가 경로를 사용한다. PL | 원본 동작 | Web 입력 | C# 권위와 중복 안전 | |---|---|---| -| `PlayList_DragDrop/DragOver` | 내부 custom MIME와 opaque source/target `rowId`, before/after | source가 선택 행이면 현재 선택 전체를 표시 순서대로 이동한다. 체크박스에서 시작한 drag는 거부하고 PREPARED/PROGRAM/pending/unknown에서는 변경하지 않는다. | +| 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`가 현재 순서와 일치할 때만 교환하므로 같은 메시지의 중복 전달이 순서를 되돌리지 않는다. | | UC6 `FpSpread2.AllowDragDrop` | ThemeA/EList 편집행의 내부 custom MIME와 opaque `rowId` | 워크플로가 임의 before/after 위치를 계산한다. 존재하지 않는 ID, 같은 행, 추가 payload 필드는 strict bridge에서 거부하거나 no-op 처리한다. | @@ -129,7 +130,7 @@ reorder와 cut append가 함께 발생하지 않는다. 체크박스 origin도 브라우저 동작을 반영해 보강했다. Chromium의 `dragstart.target`은 실제 pointer가 체크박스에서 시작했어도 draggable 조상 행일 수 있다. pointerdown 때 checkbox origin row를 기억해 이어지는 dragstart를 거부하고 pointerup/pointercancel/dragend에서 지운다. -회귀 테스트는 이 target 형태, 다중선택 보존, row MIME의 조상 무시, 외부 text 거부와 exact +회귀 테스트는 이 target 형태, 단일 source 행 이동, row MIME의 조상 무시, 외부 text 거부와 exact selected-cut token의 1회 처리를 DOM event 순서로 재현한다. ### ThemeA 이름 중복확인 @@ -183,7 +184,9 @@ gate 때문에 `DryRun`으로 남았고 Tornado TCP 연결과 Network Monitoring - `삼성` 검색 결과 **63개**, `삼성출판사` 선택, 선택한 컷 3개를 한 번의 drop으로 playlist 3행에 추가 -- playlist multi-row block reorder와 enabled 체크박스 origin drag 차단 +- 당시 package의 playlist multi-row block reorder와 enabled 체크박스 origin drag 차단. + block reorder는 이후 원본 재감사에서 source 단일 행 이동으로 교정됐으므로 현재 코드의 + 단일 행 drag package 실측으로 간주하지 않음 - mouse release 전에 dragover로 시장 탭 순서 교환 - EList 추천행 **5개** drag reorder. 수량 입력칸에서 시작한 실제 마우스 drag는 순서를 바꾸지 않고, 행의 비입력 영역에서 시작한 drag만 순서를 바꾸는 것을 확인 @@ -203,9 +206,11 @@ Live 연결/해제 경로만 검증하며 scene/PGM 방송 송출 성공을 뜻 | `s5077` | 6 | 6종목 테마 경로 | | `s5088` | 12 | 12종목 테마 경로 | -`pageCount = min(20, ceil(itemCount / pageSize))`를 사용한다. 0행, 정확한 경계, -마지막 부분 페이지, 20페이지 cap, 21페이지 요청 거부, 같은 scene의 `GetPlayingScene` -Page NEXT와 다음 enabled playlist 항목 NEXT를 자동 테스트로 비교한다. +행을 만들 때 `pageCount = min(20, ceil(itemCount / pageSize))`를 사용하고, 송출을 +시작할 때 operator row의 `current/total` suffix를 해당 on-air cycle 동안 고정한다. +column 3이 정확히 `5단 표그래프`, `6종목 현재가`, `12종목 현재가`인 행만 Page NEXT를 +허용한다. 0행, 정확한 경계, 마지막 부분 페이지, 20페이지 cap, 21페이지 요청 거부, +retained on-air scene Page NEXT와 다음 enabled playlist 항목 NEXT를 자동 테스트로 비교한다. ## 개발 DB write 증적 diff --git a/docs/OPERATOR_WORKFLOW.md b/docs/OPERATOR_WORKFLOW.md index 5be86ae..e7b6f19 100644 --- a/docs/OPERATOR_WORKFLOW.md +++ b/docs/OPERATOR_WORKFLOW.md @@ -13,8 +13,8 @@ - KOSPI `T_STOCK`, KOSDAQ `T_KOSDAQ_STOCK`, NXT KOSPI `N_STOCK/N_ONLINE`, NXT KOSDAQ `N_KOSDAQ_STOCK/N_KOSDAQ_ONLINE`을 매개변수 쿼리로 검색한다. - LIKE의 `%`, `_`, `!`는 escape하고 검색어·결과 수·열 스키마·종목 코드 형식을 제한한다. - 검색 결과는 시장, 종목명, 종목 코드를 함께 보존하므로 원본처럼 이름을 다시 훑어 코드를 추정하지 않는다. -- 실제 실행 자산 `bin/Debug/Res/종목.ini`의 31개 항목을 원본 순서로 표시한다. -- 실행 자산 SHA-256은 `45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20`으로 고정했다. 소스 `RES/종목.ini`와 다른 `시간외단일가` 표기는 실행 자산을 기준으로 한다. +- MSIX에서도 수정 가능한 `%LOCALAPPDATA%/MBN_STOCK_WEBVIEW/Res/종목.ini`를 먼저 읽고, 외부 파일이 없을 때만 패키지 실행 폴더의 CP949 `Res/종목.ini`를 확인한다. 유효한 파일은 원본 물리 순서와 `-` 구분행을 그대로 표시한다. +- 외부 파일이 존재하지만 잘못됐거나, 두 후보가 모두 없거나 유효하지 않으면 부분 목록을 사용하지 않는다. 실행 자산 SHA-256 `45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20`에서 고정한 31개 내장 목록으로 폴백하며 경로 없는 경고를 표시한다. 실제 파일은 Git/MSIX에 넣지 않는다. - NXT 종목은 원본과 같이 `1열판기본_현재가`와 `N5001`만 허용한다. - 5일선·20일선은 컷 추가 시점의 사본이 아니라 현재 전역 체크 상태를 모든 캔들 항목에 동기화하고 PREPARE 직전에 다시 고정한다. - 저장된 typed 종목 항목은 builder, alias, selection, 종목, 이동평균선이 모두 재생성 결과와 같을 때만 복원한다. 실패한 항목은 일반 catalog 경로로 우회하지 못한다. diff --git a/docs/PLAYOUT.md b/docs/PLAYOUT.md index e16da06..4481cc7 100644 --- a/docs/PLAYOUT.md +++ b/docs/PLAYOUT.md @@ -95,10 +95,10 @@ SDK가 기본 위치에 없다면 x64 SDK의 `TlbImp.exe` 절대 경로를 `-Tlb | `sceneDirectory` | Test/Live에서 사용하는 외부 `.t2s` 루트의 절대 경로. `null`은 안전한 미설정 상태 | | `outputChannel` | 확인된 전용 출력 채널. `null`은 안전한 미설정 상태 | | `layoutIndex` | 씬 플레이어 layout 위치 | -| `legacySceneFadeDuration` | 원본 `ComboDi.SelectedIndex`에 대응하는 fade. 기본값 6, 허용 범위 0~60 | +| `legacySceneFadeDuration` | 원본 `ComboDi.SelectedIndex`에 대응하는 fade. 기본 인덱스 6(화면 7), 허용 범위 0~19(화면 표시 1~20) | | `legacySceneBackgroundKind` | trusted 공통 배경 `None`, `Texture`, `Video`; Web은 임의 경로를 보내지 않고 F2/F3가 native picker/toggle을 호출한다. 현재 picker는 `sceneDirectory` 내부만 허용하므로 원본의 sibling `배경` root와는 아직 동등하지 않음 | | `legacySceneBackgroundAssetPath` | `sceneDirectory` 아래의 상대 asset. 경로 탈출·reparse·누락 파일은 DryRun에서도 거부 | -| `legacySceneBackgroundVideoLoopCount`, `legacySceneBackgroundVideoLoopInfinite` | 공통 video 배경의 bounded loop 설정 | +| `legacySceneBackgroundVideoLoopCount`, `legacySceneBackgroundVideoLoopInfinite` | 공통 video 배경 설정. true는 원본 K3D raw Int32 `10`으로, false는 `0`으로 매핑하며 s5006의 raw `1`과 구분 | | `testProcessWindowTitlePattern` | 전용 테스트 인스턴스만 식별하는 창 제목 패턴 | | `testSceneAllowlist` | 실제 COM을 사용하는 `Test`와 `Live` 모두에서 허용한 scene name(code)의 폐쇄형 목록. 경로나 확장자는 넣지 않음(기존 설정 호환을 위해 이름 유지) | | `trustedLiveOutputEnabled` | 운영자가 로컬 파일에서만 설정하는 라이브 1차 게이트 | @@ -135,7 +135,7 @@ MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS MBN_STOCK_PLAYOUT_RECONNECT_ENABLED ``` -`testSceneAllowlist`, `trustedLiveOutputEnabled`, `maximumAutomaticRefreshesPerTakeIn`은 환경 변수로 변경할 수 없으며 로컬 설정 파일에서만 관리합니다. 이름은 기존 설정 호환을 위해 유지하지만 allowlist는 Test뿐 아니라 Live의 PREPARE, TAKE IN 재검사, NEXT와 timer refresh에도 적용되고 비어 있으면 실제 모드를 시작하지 않습니다. 자동 갱신 상한은 Web payload나 환경 변수로 늘릴 수 없고 COM dispatch 경계에서 다시 검사합니다. 공통 background/fade도 Web payload가 아니라 이 trusted 프로세스 설정 경계에서만 결정합니다. `PlayoutSceneCompositionFactory`는 첫 DryRun 또는 실제 PREPARE 전에 background asset의 상대 경로, 허용 확장자, 존재 여부와 reparse ancestry를 검사하며 실제 경로는 Web status/preview에 노출하지 않습니다. `SceneDirectory`는 Test/Live에서 존재하는 비-reparse 외부 디렉터리여야 하며, 엔진은 상대 `.t2s` 파일을 정규화해 이 루트 밖으로 나가는 경로를 거부합니다. scene file의 basename과 scene name 및 allowlist 항목도 서로 일치해야 합니다. 설정 파일은 실행 계정만 읽을 수 있도록 ACL을 제한합니다. 라이선스 키나 인증정보를 이 파일에 기록하지 않습니다. +`testSceneAllowlist`, `trustedLiveOutputEnabled`, `maximumAutomaticRefreshesPerTakeIn`은 환경 변수로 변경할 수 없으며 로컬 설정 파일에서만 관리합니다. 이름은 기존 설정 호환을 위해 유지하지만 allowlist는 Test뿐 아니라 Live의 PREPARE, TAKE IN 재검사, NEXT와 timer refresh에도 적용되고 비어 있으면 실제 모드를 시작하지 않습니다. 자동 갱신 상한은 Web payload나 환경 변수로 늘릴 수 없고 COM dispatch 경계에서 다시 검사합니다. 공통 background는 Web 경로 payload가 아니라 trusted 프로세스 설정과 네이티브 파일 선택 경계에서만 결정합니다. fade는 원본 `DissolveTime` 20개 항목과 같은 폐쇄형 숫자 intent(0~19)로만 변경하며 기본값은 6입니다. `PlayoutSceneCompositionFactory`는 첫 DryRun 또는 실제 PREPARE 전에 background asset의 상대 경로, 허용 확장자, 존재 여부와 reparse ancestry를 검사하며 실제 경로는 Web status/preview에 노출하지 않습니다. `SceneDirectory`는 Test/Live에서 존재하는 비-reparse 외부 디렉터리여야 하며, 엔진은 상대 `.t2s` 파일을 정규화해 이 루트 밖으로 나가는 경로를 거부합니다. scene file의 basename과 scene name 및 allowlist 항목도 서로 일치해야 합니다. 설정 파일은 실행 계정만 읽을 수 있도록 ACL을 제한합니다. 라이선스 키나 인증정보를 이 파일에 기록하지 않습니다. ### KTAP 포트와 Network Monitoring 판정 diff --git a/docs/SCENE_EQUIVALENCE.md b/docs/SCENE_EQUIVALENCE.md index 8309a21..74e8756 100644 --- a/docs/SCENE_EQUIVALENCE.md +++ b/docs/SCENE_EQUIVALENCE.md @@ -122,7 +122,7 @@ PREPARE의 공통 순서는 다음과 같다. TAKE IN은 PREPARE 때의 오래된 DTO를 그대로 재생하지 않는다. 원본 `ONAirMode`와 같이 현재 playlist snapshot의 같은 entry/page를 실제 DB에서 다시 조회하고 새 scene을 `LoadScene` → transaction → `Prepare(10)`한 뒤, 그 prepare가 명확히 성공한 경우에만 `Play(10)`을 한 번 호출한다. PREPARE를 다시 누르는 원본 toggle은 `TakeOut(All)`/`StopAll`로 state를 정리한다. timeout 또는 `OutcomeUnknown`은 retry 가능한 실패로 낮추지 않는다. -공통 fade와 배경은 Web 입력이 아니라 로컬 trusted playout 설정에서만 온다. `legacySceneFadeDuration`의 원본 기본값은 6이며, `legacySceneBackgroundKind`, scene root 아래의 상대 `legacySceneBackgroundAssetPath`, video loop 설정을 `PlayoutSceneCompositionFactory`가 검증한다. `DryRun`에서도 경로 탈출, 절대 경로, 허용되지 않은 확장자, reparse point, 누락 파일을 실제 COM 전에 fail-closed preflight한다. asset 경로는 Web 상태와 preview에 노출하지 않는다. +공통 배경 경로는 Web 입력이 아니라 로컬 trusted playout 설정과 네이티브 파일 선택에서만 온다. `legacySceneFadeDuration`의 원본 기본 인덱스는 6(화면 표시 7)이고 화면의 `DissolveTime`은 선택 인덱스 0~19만 C#에 보낸다. 실제 K3D에는 원본 raw `SetSceneEffectType(10, FADE, index)` 및 공통 배경 `(path, 2004, 10)`을 전달하며 s5006의 `(path, 1, 1)`과 구분한다. `legacySceneBackgroundKind`, scene root 아래의 상대 `legacySceneBackgroundAssetPath`, video loop 설정을 `PlayoutSceneCompositionFactory`가 검증한다. `DryRun`에서도 경로 탈출, 절대 경로, 허용되지 않은 확장자, reparse point, 누락 파일을 실제 COM 전에 fail-closed preflight한다. asset 경로는 Web 상태와 preview에 노출하지 않는다. 이 순서는 `RegisteredLegacySceneCueProviderTests`, `LegacyPlayoutWorkflowTests`, Playout의 `DynamicK3dSessionTests`, `TornadoPlayoutEngineTests`에서 검증한다. 실제 COM 형식과 vendor 구현은 계속 `IPlayoutEngine` 뒤에 있다. @@ -136,7 +136,7 @@ TAKE IN은 PREPARE 때의 오래된 DTO를 그대로 재생하지 않는다. 원 - `s5088`의 NXT 인덱스는 비교와 조회 모두 `i + pageIndex * 12`를 사용한다. - 현재 항목에 다음 page가 있으면 operator Page NEXT는 playlist index를 유지하되 원본 `Next_Scene(0)`처럼 다음 page 데이터를 조회하고 새 scene을 `LoadScene` → transaction mutation → `QueryVariables` → `EndTransaction` → `Prepare(10)` → `Play(10)`한다. 이 경로는 `GetPlayingScene` in-place refresh가 아니다. - 마지막 page면 다음 활성 playlist 항목으로 이동한다. 비활성 항목은 원본처럼 앞으로 건너뛰며 끝에서 wrap하지 않는다. -- 원본 timer는 TAKE IN과 playlist NEXT 뒤 반복되며 첫 `m_time` 이후 3초 주기로 계속 실행된다. 새 runtime도 반복 갱신을 유지하지만, 이전 tracked `Play`의 `OnScenePlayed` callback을 drain한 뒤에야 첫 `m_time` 또는 3초의 **전체 cooldown**을 시작한다. 이는 callback 대기와 delay가 겹쳐 operator window가 사라지는 일을 막는 의도적 안전 적응이다. 모든 operator command는 현재 epoch를 먼저 중단하고 Page NEXT 뒤에는 refresh를 재시작하지 않는다. +- 원본 timer는 TAKE IN과 playlist NEXT 뒤 반복되며 첫 `m_time` 이후 3초 주기로 계속 실행된다. 새 runtime도 반복 갱신을 유지하지만, 이전 tracked `Play`의 `OnScenePlayed` callback을 drain한 뒤에야 첫 `m_time` 또는 3초의 **전체 cooldown**을 시작한다. 이는 callback 대기와 delay가 겹쳐 operator window가 사라지는 일을 막는 의도적 안전 적응이다. 모든 operator command는 현재 epoch를 먼저 중단하고 Page NEXT 뒤에는 refresh를 재시작하지 않는다. 단, 5076/5079/5080/5081은 원본의 `m_time * 1000` Int32 overflow 뒤 Timer 설정 예외로 실제 갱신이 시작되지 않으므로 명시적으로 비활성화한다. - 원본 `timer1_Tick`에는 mutation 전 선행 `Play(10)`과 `Prepare` 뒤 후행 `Play(10)`이 모두 있다. 마이그레이션은 불완전한 `GetPlayingScene` proxy 대신 retained on-air scene에 fresh DTO mutation을 적용하고 `BeginTransaction → mutation → QueryVariables → EndTransaction → Prepare(10) → tracked Play(10)` 한 번만 보낸다. 선행 replay를 생략한 것은 callback 추적 없이 PLAY를 중복시키지 않기 위한 명시적 안전 차이이며 Round H의 정확한 PLAY/callback 4회 예산에 반영됐다. - refresh CTS와 공개 상태는 atomic epoch로 결합한다. `Replace`/`Stop`/조건부 update/completion이 같은 lock에서 generation을 확인하므로 취소된 이전 loop가 `active`, `NextAt`, success 또는 fault를 새 loop나 중단 상태 위에 다시 쓰지 못한다. @@ -155,6 +155,7 @@ TAKE IN은 PREPARE 때의 오래된 DTO를 그대로 재생하지 않는다. 원 - `OnCutOut`은 해당 layout의 on-air 참조를, `OnStopAll`은 player의 모든 on-air 참조를 정리할 근거다. - 이전 connection generation의 늦은 callback은 현재 state를 바꾸지 않는다. - pending Play callback이 있으면 TAKE OUT을 제외한 PREPARE/TAKE IN/NEXT/timer refresh를 fail-closed 차단한다. 어떤 lifecycle callback이든 pending이면 Disconnect와 session recycle을 하지 않고 abandon/quarantine 및 `OutcomeUnknown`으로 보수적으로 전환한다. +- pending CutOut/StopAll callback이 있으면 반복 TAKE OUT을 포함한 모든 scene 명령을 SDK 전에 차단하며, 일치하는 완료 callback 뒤에만 다음 PREPARE를 허용한다. - `StopAll`/`CutOut`은 SDK dispatch 전에 각 pending completion counter를 올리고 동기 호출 실패 시 되돌린다. 성공 callback은 대응 counter를 정확히 하나 줄이며, 성공한 stop/cut은 중단된 Play가 뒤늦게 `OnScenePlayed`를 보내지 않을 수 있으므로 해당 pending Play accounting을 취소한다. dispatch 뒤 cancellation/timeout은 결과 불명확으로 취급한다. - 장기 실행 시 retired scene은 callback으로 안전성이 확인된 뒤 unload/release한다. diff --git a/docs/TRUSTED_BACKGROUND_ROOT.md b/docs/TRUSTED_BACKGROUND_ROOT.md index 9c5e3c6..3f58701 100644 --- a/docs/TRUSTED_BACKGROUND_ROOT.md +++ b/docs/TRUSTED_BACKGROUND_ROOT.md @@ -6,6 +6,7 @@ ## 원본 동작 - `T2S_FOLDER_PATH`는 실행 폴더의 `Cuts\`이다. +- MainForm 시작 시에는 `Cuts\video\기본.vrv`를 기본 배경으로 지정한다. - F2/`btnback_Click`는 먼저 `Cuts\..\배경\기본.vrv`를 기본값으로 두고, `배경` 폴더에서 vrv/jpg/png 파일 선택 창을 연다. - F3/`ckback_CheckedChanged`가 배경을 끄면 파일 값을 비우고, 다시 켜면 @@ -14,9 +15,10 @@ ## 새 구현 -`SceneDirectory`(Cuts)는 기존 장면과 장면 내부 자산만 계속 담당한다. 운영자 -F2/F3 배경은 별도 `LegacyBackgroundDirectory`에서만 해석하며 두 allowlist -root를 합치거나 서로 대신 사용하지 않는다. +`SceneDirectory`(Cuts)는 기존 장면, 장면 내부 자산과 시작 시 기본 배경 +`video\기본.vrv`를 담당한다. 운영자 F2/F3 배경은 별도 +`LegacyBackgroundDirectory`에서만 해석하며 두 allowlist root를 합치거나 서로 +대신 사용하지 않는다. ```json { @@ -37,6 +39,11 @@ F2/F3는 IDLE에서만 변경할 수 있고 변경은 다음 PREPARE부터 적 통과하지 못하면 재생 가능한 mutation으로 만들지 않는다. F3로 다시 켤 때도 이전 사용자 선택 파일이 아니라 원본과 같은 `기본.vrv`를 새로 검증한다. +명시적인 로컬 배경 설정이 없을 때의 시작 폴백은 오직 +`SceneDirectory\video\기본.vrv`만 검사한다. 이 파일이 없거나 안전 검증에 +실패해도 sibling `배경\기본.vrv`로 대신하지 않고, 경로를 노출하지 않는 경고와 +함께 배경 없음으로 시작한다. 반대로 F2/F3는 시작 폴백 파일을 대신 사용하지 않는다. + ## Fail-closed 검증 배경 root와 파일은 사용할 때마다 다음 조건을 모두 만족해야 한다. @@ -49,11 +56,11 @@ F2/F3는 IDLE에서만 변경할 수 있고 변경은 다음 PREPARE부터 적 - 검사 중 디렉터리 조상과 파일 handle은 delete/rename 공유를 허용하지 않음 root나 파일이 없거나 검증 결과가 불명확하면 경로를 Web/오류에 노출하지 않고 -F2/F3 또는 PREPARE를 거부한다. Cuts root로의 fallback은 없다. +F2/F3 또는 PREPARE를 거부한다. 시작 경로와 F2/F3 경로 사이의 fallback은 없다. ## 현재 자산 확인 -2026-07-12에 원본 `bin\Debug\Cuts`의 형제 `bin\Debug\배경`과 -`배경\기본.vrv`를 확인했으나 둘 다 존재하지 않았다. 따라서 자산이 배치되기 -전까지 현재 환경의 기본 배경은 의도대로 fail closed 상태다. 실제 vrv 파일은 -Git/MSIX에 포함하지 않는다. +2026-07-15에 원본 `bin\Debug\Cuts\video\기본.vrv`와 Cuts의 형제 +`bin\Debug\배경\기본.vrv`를 확인했으나 둘 다 존재하지 않았다. 따라서 자산이 +배치되기 전까지 현재 환경의 기본 배경은 의도대로 fail closed 상태다. 실제 vrv +파일은 Git/MSIX에 포함하지 않는다. diff --git a/docs/UC1_TREE_EVENT_PARITY.md b/docs/UC1_TREE_EVENT_PARITY.md new file mode 100644 index 0000000..e2924bf --- /dev/null +++ b/docs/UC1_TREE_EVENT_PARITY.md @@ -0,0 +1,38 @@ +# UC1 tree event parity + +Source evidence is the read-only original +`C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\Control\UC1.cs`: + +- `UC1_Load` checks `Expand`, selects the first rendered root, and calls + `EnsureVisible`. +- `treeView_MouseDown`, `treeView_BeforeExpand`, and + `treeView_BeforeCollapse` prevent a double-click from also changing a + root's expanded state. +- `treeView_DoubleClick` activates either the selected leaf or every direct + child of the selected root. +- `chE_CheckedChanged` expands or collapses every root. Collapsing does not + clear `SelectedNode`. + +The WebView tree therefore owns only presentation state (open roots, selected +node, focused node, and scroll position). C# remains the authority for every +leaf/root action. A root label click selects without expanding; only its +disclosure glyph changes `open`. A root or leaf double-click emits one closed +native intent and does not invoke the browser's whole-`summary` toggle. + +On each real tab activation/re-entry the presentation state is recreated like +the original `UC1`: `Expand` is checked, all sections are open, and the first +root is selected, focused, and scrolled into view. State-only renders within +the same activation preserve per-section open state, selection, focus, and +scroll position. Busy and fixed-section-batch states reject both actions and +local expand/collapse changes. + +## Intentional defect exclusion + +The original `treeView_DoubleClick` reads `uc_3.FpSpread` in the parent-node +path before checking which UC1 file is active. For UC1 instances constructed +with `uc_3 == null`, that can throw `NullReferenceException`; the surrounding +empty `catch` silently drops the operator action. This is an original defect, +not observable intended behavior, so the migration does **not** reproduce it. +The WebView sends only the selected root identity and C# validates/materializes +the corresponding section without dereferencing an unrelated comparison +control. diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs index 8ada023..822fe23 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs @@ -193,10 +193,14 @@ public sealed record PlayoutSetBackgroundTexture( PlayoutMutationTiming Timing = PlayoutMutationTiming.BeforeTransaction, PlayoutAssetRoot AssetRoot = PlayoutAssetRoot.SceneDirectory) : PlayoutMutation; +/// +/// Raw K3D Int32 argument. MainForm uses 10 for its common background and s5006 +/// uses 1; this value must not be normalized as a Boolean. +/// public sealed record PlayoutSetBackgroundVideo( string AssetPath, int LoopCount, - bool LoopInfinite, + int LoopInfinite, PlayoutMutationTiming Timing = PlayoutMutationTiming.BeforeTransaction, PlayoutAssetRoot AssetRoot = PlayoutAssetRoot.SceneDirectory) : PlayoutMutation; @@ -233,6 +237,12 @@ public sealed record PlayoutStatus( /// public bool IsPlayCompletionPending { get; init; } + /// + /// True after CutOut/StopAll was accepted and before the matching Tornado + /// completion callback. No further scene command may be dispatched in this state. + /// + public bool IsTakeOutCompletionPending { get; init; } + /// /// True only after the adapter entered the KAEngine.KTAPConnect dispatch path. /// Process discovery, registration probes, DryRun, and configuration preflight do not set it. @@ -328,7 +338,8 @@ public interface IPlayoutEngine : IAsyncDisposable /// /// Updates and replays the scene already on air without loading a replacement scene. - /// Used by MainForm-compatible timer refresh through GetPlayingScene. + /// Used by MainForm-compatible timer refresh. The concrete adapter decides how it + /// safely retains or reacquires the on-air scene reference. /// Task UpdateOnAirAsync( PlayoutCue cue, diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs index d530564..3c20d57 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs @@ -289,7 +289,7 @@ public static class PlayoutBridgeProtocol { if (fadeElement.ValueKind != JsonValueKind.Number || !fadeElement.TryGetInt32(out var parsedFade) || - parsedFade is < 0 or > 60) + parsedFade is < 0 or > 19) { return false; } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs index bcf5a09..6d5b9db 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs @@ -14,12 +14,106 @@ public sealed record LegacySceneSelection( string Subtype, string DataCode); +/// +/// Current operator checkbox state used by s8010. The original scene constructor read +/// MainForm.ck1/ck2 every time the scene was rebuilt, including TAKE IN, NEXT and the +/// timer refresh path; it was not part of the persisted playlist identity. +/// +public sealed record LegacySceneMovingAverageSelection(bool Ma5, bool Ma20); + +public interface ILegacySceneMovingAverageSelectionSource +{ + LegacySceneMovingAverageSelection Current { get; } +} + +/// +/// Thread-safe native source shared by an operator request and its retained workflow +/// playlist. Updating it changes only a later s8010 cue build. +/// +public sealed class MutableLegacySceneMovingAverageSelectionSource : + ILegacySceneMovingAverageSelectionSource +{ + private LegacySceneMovingAverageSelection _current; + + public MutableLegacySceneMovingAverageSelectionSource( + LegacySceneMovingAverageSelection initial) + { + _current = initial ?? throw new ArgumentNullException(nameof(initial)); + } + + public LegacySceneMovingAverageSelection Current => Volatile.Read(ref _current); + + public void Update(LegacySceneMovingAverageSelection value) + { + ArgumentNullException.ThrowIfNull(value); + Interlocked.Exchange(ref _current, value); + } +} + +/// +/// Applies the current checkbox state only to the closed s8010 alias family. This keeps +/// the playlist selection stable while giving every cue build a fresh native snapshot. +/// Index and industry visibility suppression remains authoritative in the s8010 builder. +/// +public static class LegacySceneMovingAverageOverlay +{ + private static readonly IReadOnlySet S8010CutCodes = new HashSet( + ["8035", "8061", "8040", "8046", "8051", "8056"], + StringComparer.Ordinal); + + public static bool Supports(string? cutCode) => + cutCode is not null && S8010CutCodes.Contains(cutCode); + + public static LegacyPlaylistEntry ApplyCurrent(LegacyPlaylistEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + if (!Supports(entry.CutCode) || + entry.Selection is null || + entry.MovingAverageSelectionSource is null) + { + return entry; + } + + var current = entry.MovingAverageSelectionSource.Current ?? + throw new InvalidOperationException( + "The moving-average selection source returned no state."); + var graphicType = current switch + { + { Ma5: true, Ma20: true } => "MA5,MA20", + { Ma5: true } => "MA5", + { Ma20: true } => "MA20", + _ => string.Empty + }; + return entry with + { + Selection = entry.Selection with { GraphicType = graphicType } + }; + } +} + +/// +/// Explicit operator-grid PageN contract. A null +/// keeps the provider-derived behavior used by direct Core callers. A non-null contract with +/// null values deliberately disables same-row page navigation; otherwise the page size and +/// page-count suffix are frozen for the complete prepare/on-air cycle. +/// +public sealed record LegacyPlaylistPageNavigation( + ScenePageSize? PageSize, + int? FrozenPageCount) +{ + public static LegacyPlaylistPageNavigation Disabled { get; } = new(null, null); + + public bool IsEnabled => PageSize.HasValue && FrozenPageCount.HasValue; +} + public sealed record LegacyPlaylistEntry( string EntryId, string CutCode, bool IsEnabled = true, int? FadeDuration = null, - LegacySceneSelection? Selection = null); + LegacySceneSelection? Selection = null, + LegacyPlaylistPageNavigation? PageNavigation = null, + ILegacySceneMovingAverageSelectionSource? MovingAverageSelectionSource = null); public sealed record LegacySceneCuePage( PlayoutCue Cue, @@ -89,7 +183,8 @@ public sealed record LegacyPlayoutWorkflowState( string? CurrentEntryId, string? BuilderKey, int CurrentPageItemCount, - IReadOnlyList PreviewFields); + IReadOnlyList PreviewFields, + bool IsPageNavigationEnabled); /// /// Reproduces legacy PREPARE/TAKE IN/NEXT/TAKE OUT state transitions above IPlayoutEngine. @@ -294,13 +389,17 @@ public sealed class LegacyPlayoutWorkflow } /// - /// Reproduces the visible MainForm TAKE IN/F8 path from idle: ONAirMode builds and - /// prepares the selected scene, then Play starts that exact prepared instance. The - /// page DTO is therefore loaded once and the workflow gate covers both engine calls. - /// This is intentionally separate from , which preserves the - /// legacy explicit-PREPARE path and refreshes its page before play. + /// Reproduces MainForm.btnTake_Click/F8. The row selected by the operator is + /// independent from the row currently on air: every F8 runs ONAirMode for the + /// current selection (including the forward disabled-row skip), prepares that + /// scene, and then plays it. NEXT remains based on and is not + /// affected merely because the operator selected another row. + /// + /// PREPARE and PLAY remain inside one workflow gate. A completed PREPARE becomes + /// the new prepared state, but the previous on-air state is retained until PLAY is + /// known to have succeeded. An unknown PLAY outcome is never retried here. /// - public async Task PrepareAndTakeInAsync( + public async Task TakeSelectedAsync( IReadOnlyList playlist, int cueIndexZeroBased, CancellationToken cancellationToken = default) @@ -308,11 +407,12 @@ public sealed class LegacyPlayoutWorkflow await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { - if (_prepared is not null || _onAir is not null) + if (_engine.Status.IsPlayCompletionPending) { return Rejected( PlayoutOperation.TakeIn, - "One-click TAKE IN can start only while the playout workflow is idle."); + "The previous SDK Play completion callback is still pending. " + + "Only TAKE OUT is allowed until Tornado confirms the result."); } var snapshot = ValidatePlaylist(playlist, cueIndexZeroBased); @@ -359,6 +459,17 @@ public sealed class LegacyPlayoutWorkflow } } + /// + /// Compatibility alias for callers compiled against the original one-click API. + /// New operator code should use so that it is clear + /// this operation always follows the current row selection, even during PROGRAM. + /// + public Task PrepareAndTakeInAsync( + IReadOnlyList playlist, + int cueIndexZeroBased, + CancellationToken cancellationToken = default) => + TakeSelectedAsync(playlist, cueIndexZeroBased, cancellationToken); + public async Task TakeInAsync( CancellationToken cancellationToken = default) { @@ -484,8 +595,8 @@ public sealed class LegacyPlayoutWorkflow /// /// Reproduces MainForm's timer-driven same-scene refresh. The active non-paged /// entry and current page are queried again and updated through - /// IPlayoutEngine.UpdateOnAirAsync, whose vendor implementation uses - /// GetPlayingScene. Timer start/stop policy remains an application concern. + /// IPlayoutEngine.UpdateOnAirAsync. The vendor adapter updates its retained on-air + /// scene reference; timer start/stop policy remains an application concern. /// public async Task RefreshOnAirAsync( CancellationToken cancellationToken = default) @@ -557,7 +668,11 @@ public sealed class LegacyPlayoutWorkflow int pageIndexZeroBased, CancellationToken cancellationToken) { - var entry = playlist[cueIndexZeroBased]; + // s8010 sampled MainForm.ck1/ck2 inside every scene constructor call. Resolve + // the current native state here so PREPARE, TAKE IN, NEXT and refresh all use + // the same request-time rule without mutating the retained playlist identity. + var entry = LegacySceneMovingAverageOverlay.ApplyCurrent( + playlist[cueIndexZeroBased]); var page = await _cueProvider.CreatePageAsync( entry, pageIndexZeroBased, @@ -566,6 +681,8 @@ public sealed class LegacyPlayoutWorkflow ArgumentNullException.ThrowIfNull(page.Cue); ScenePagingPlan? plan = null; + var pageNavigationEnabled = false; + var navigation = entry.PageNavigation; if (page.PageSize is { } pageSize) { var planResult = ScenePaging.CreatePlan(page.ItemCount, pageSize); @@ -583,14 +700,66 @@ public sealed class LegacyPlayoutWorkflow throw new LegacySceneDataException("The requested scene page is outside the available range."); } - plan = planResult.Plan; + if (navigation is null) + { + // Backwards compatibility for direct Core callers: without explicit + // operator metadata, the current provider result remains authoritative. + plan = planResult.Plan; + pageNavigationEnabled = true; + } + else if (!navigation.IsEnabled) + { + if (pageIndexZeroBased != 0) + { + throw new LegacySceneDataException( + "This playlist entry does not allow same-row page navigation."); + } + + plan = new ScenePagingPlan( + page.ItemCount, + pageSize, + totalPages: 1, + accessibleItemCount: Math.Min(page.ItemCount, (int)pageSize)); + } + else + { + if (navigation.PageSize != pageSize) + { + throw new LegacySceneDataException( + "The scene provider page size does not match the operator PageN contract."); + } + + var frozenPageCount = navigation.FrozenPageCount!.Value; + if (pageIndexZeroBased >= frozenPageCount) + { + throw new LegacySceneDataException( + "The requested scene page is outside the frozen operator PageN range."); + } + + plan = new ScenePagingPlan( + page.ItemCount, + pageSize, + frozenPageCount, + Math.Min(page.ItemCount, frozenPageCount * (int)pageSize)); + pageNavigationEnabled = true; + } } else if (pageIndexZeroBased != 0 || page.ItemCount < 0) { throw new LegacySceneDataException("A non-paged scene returned invalid page data."); } + else if (navigation?.IsEnabled == true) + { + throw new LegacySceneDataException( + "The scene provider did not return the page size required by the operator PageN contract."); + } - return new ActivePage(cueIndexZeroBased, pageIndexZeroBased, page, plan); + return new ActivePage( + cueIndexZeroBased, + pageIndexZeroBased, + page, + plan, + pageNavigationEnabled); } private static IReadOnlyList ValidatePlaylist( @@ -621,8 +790,9 @@ public sealed class LegacyPlayoutWorkflow { if (entry is null || !IsSafeToken(entry.EntryId) || !IsSafeToken(entry.CutCode) || !ids.Add(entry.EntryId) || - entry.FadeDuration is < 0 or > 60 || - !IsSafeSelection(entry.Selection)) + entry.FadeDuration is < 0 or > 19 || + !IsSafeSelection(entry.Selection) || + !IsSafePageNavigation(entry.PageNavigation)) { throw new ArgumentException("The playlist contains an invalid entry.", parameterName); } @@ -683,7 +853,8 @@ public sealed class LegacyPlayoutWorkflow : Math.Min( pageSize, Math.Max(0, active.Page.ItemCount - (active.PageIndexZeroBased * pageSize))), - active.Page.PreviewFields ?? Array.Empty()); + active.Page.PreviewFields ?? Array.Empty(), + active.IsPageNavigationEnabled); } private static bool IsSafeToken(string? value) => @@ -705,6 +876,23 @@ public sealed class LegacyPlayoutWorkflow IsSafeLookupText(selection.DataCode); } + private static bool IsSafePageNavigation(LegacyPlaylistPageNavigation? navigation) + { + if (navigation is null) + { + return true; + } + + if (!navigation.PageSize.HasValue && !navigation.FrozenPageCount.HasValue) + { + return true; + } + + return navigation.PageSize is + ScenePageSize.Five or ScenePageSize.Six or ScenePageSize.Twelve && + navigation.FrozenPageCount is >= 1 and <= ScenePaging.MaximumPageCount; + } + private static bool IsSafeLookupText(string? value) => value is not null && value.Length <= 256 && @@ -757,11 +945,13 @@ public sealed class LegacyPlayoutWorkflow null, null, 0, - Array.Empty()); + Array.Empty(), + false); private sealed record ActivePage( int CueIndexZeroBased, int PageIndexZeroBased, LegacySceneCuePage Page, - ScenePagingPlan? Plan); + ScenePagingPlan? Plan, + bool IsPageNavigationEnabled); } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyScenePreview.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyScenePreview.cs index 57b4138..2a44a0a 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyScenePreview.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyScenePreview.cs @@ -62,7 +62,7 @@ public static class LegacyScenePreviewFactory "background-texture", "scene", "configured"), PlayoutSetBackgroundVideo video => new( "background-video", "scene", - $"configured;loop={video.LoopCount};infinite={video.LoopInfinite.ToString().ToLowerInvariant()}"), + $"configured;loop={video.LoopCount};loop-mode={video.LoopInfinite}"), _ => throw new LegacySceneDataException( "The scene preview encountered an unsupported typed mutation.") }; diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneRefreshPolicy.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneRefreshPolicy.cs index d125084..d3b8582 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneRefreshPolicy.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneRefreshPolicy.cs @@ -5,8 +5,9 @@ using System.Collections.ObjectModel; namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; /// -/// Closed refresh intervals copied from MainForm.Show_PlayList's m_time assignments. -/// Browser input cannot select or alter these values. +/// Closed refresh behavior reproduced from MainForm.Show_PlayList's m_time assignments +/// and its subsequent WinForms Timer.Interval assignment. Browser input cannot select +/// or alter these values. /// public static class LegacySceneRefreshPolicy { @@ -30,12 +31,8 @@ public static class LegacySceneRefreshPolicy ["5032"] = Seconds(5), ["5037"] = Seconds(5), ["5074"] = Seconds(7), - ["5076"] = Seconds(3_000_000), ["5077"] = Seconds(7), ["5078"] = Seconds(30), - ["5079"] = Seconds(3_000_000), - ["5080"] = Seconds(3_000_000), - ["5081"] = Seconds(3_000_000), ["5082"] = Seconds(6), ["5083"] = Seconds(60), ["5084"] = Seconds(5), @@ -63,7 +60,22 @@ public static class LegacySceneRefreshPolicy ["5072"] = Seconds(30) }); - public static int Count => Intervals.Count; + // MainForm stores 3,000,000 seconds, then evaluates m_time * 1000 as an + // unchecked Int32. The result overflows negative and assigning it to the + // WinForms Timer throws inside btnTake's swallowed catch after timer.Stop(). + // These scenes therefore never arm automatic refresh in the original runtime. + private static readonly IReadOnlySet DisabledByTimerOverflow = + new HashSet(["5076", "5079", "5080", "5081"], StringComparer.Ordinal); + + public static int Count => Intervals.Count + DisabledByTimerOverflow.Count; + + public static bool ContainsAlias(string? cutCode) => + !string.IsNullOrWhiteSpace(cutCode) && + (Intervals.ContainsKey(cutCode) || DisabledByTimerOverflow.Contains(cutCode)); + + public static bool IsDisabledByLegacyTimerOverflow(string? cutCode) => + !string.IsNullOrWhiteSpace(cutCode) && + DisabledByTimerOverflow.Contains(cutCode); public static bool TryGetInterval(string? cutCode, out TimeSpan interval) { diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneBuilders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneBuilders.cs index e42a783..5603d45 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneBuilders.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneBuilders.cs @@ -306,7 +306,7 @@ internal static class PagedQuoteMutations bool useWideRateVariants) { // The original hides all row groups first. Explicit value/variant clearing is the - // approved partial-page correction required for in-place GetPlayingScene updates. + // approved partial-page correction required for retained on-air scene updates. mutations.Add(new PlayoutSetVisible("row" + slot, false)); mutations.Add(new PlayoutSetValue("title" + slot, string.Empty)); mutations.Add(new PlayoutSetValue("price" + slot, string.Empty)); diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs index 6f384fc..db37b77 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs @@ -46,7 +46,7 @@ public sealed class S5006SceneMutationBuilder : ILegacySceneMutationBuilder 60 || + if (options.FadeDuration is < 0 or > 19 || !Enum.IsDefined(options.BackgroundKind) || !Enum.IsDefined(options.BackgroundAssetRoot) || (options.BackgroundKind == LegacySceneBackgroundKind.Video && @@ -307,7 +307,9 @@ public sealed class RegisteredLegacySceneCueProvider : mutations.Add(new PlayoutSetBackgroundVideo( options.BackgroundAssetPath!, options.BackgroundVideoLoopCount, - options.BackgroundVideoLoopInfinite, + // MainForm passes the vendor's raw Int32 flag 10 here. This is + // deliberately distinct from scene s5006, which passes 1. + options.BackgroundVideoLoopInfinite ? 10 : 0, AssetRoot: options.BackgroundAssetRoot)); mutations.Add(new PlayoutUseBackground(true)); break; diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs index 34497e4..b6ef082 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs @@ -33,6 +33,7 @@ public sealed class LegacyComparisonWorkflowController private long _draftSequence; private int _domesticSearchVersion; private int _worldSearchVersion; + private int _activationGeneration; private bool _isBusy; public LegacyComparisonWorkflowController( @@ -45,12 +46,44 @@ public sealed class LegacyComparisonWorkflowController public LegacyComparisonWorkflowSnapshot Current => CreateSnapshot(); + /// + /// Recreates UC3's per-control state before its saved CP949 pairs are loaded for + /// a new tab activation. Search and pair-load generations invalidate completions + /// that belong to the discarded control instance. + /// + public LegacyComparisonWorkflowSnapshot ResetForTabActivation() + { + Interlocked.Increment(ref _activationGeneration); + Interlocked.Increment(ref _domesticSearchVersion); + Interlocked.Increment(ref _worldSearchVersion); + _domesticResults = []; + _worldResults = []; + _savedPairs = []; + _firstTarget = null; + _secondTarget = null; + _selectedDomesticResultId = null; + _selectedWorldResultId = null; + _selectedFixedTargetId = null; + _selectedPairId = null; + _domesticQuery = string.Empty; + _worldQuery = string.Empty; + _domesticError = string.Empty; + _worldError = string.Empty; + _statusMessage = string.Empty; + _domesticStatus = LegacyComparisonSearchStatus.Idle; + _worldStatus = LegacyComparisonSearchStatus.Idle; + _statusKind = LegacyOperatorStatusKind.Neutral; + _isBusy = false; + Touch(); + return CreateSnapshot(); + } + public async Task SearchDomesticAsync( string query, CancellationToken cancellationToken = default) { var normalizedQuery = NormalizeSearchQuery(query); - var version = ++_domesticSearchVersion; + var version = Interlocked.Increment(ref _domesticSearchVersion); _domesticQuery = normalizedQuery; _domesticStatus = LegacyComparisonSearchStatus.Loading; _domesticError = string.Empty; @@ -64,7 +97,7 @@ public sealed class LegacyComparisonWorkflowController normalizedQuery, LegacyComparisonWorkflowCatalog.DomesticMaximumResults, cancellationToken).ConfigureAwait(false); - if (version != _domesticSearchVersion) + if (version != Volatile.Read(ref _domesticSearchVersion)) { return CreateSnapshot(); } @@ -81,7 +114,7 @@ public sealed class LegacyComparisonWorkflowController } catch (OperationCanceledException) { - if (version == _domesticSearchVersion) + if (version == Volatile.Read(ref _domesticSearchVersion)) { _domesticStatus = LegacyComparisonSearchStatus.Idle; _domesticError = string.Empty; @@ -92,7 +125,7 @@ public sealed class LegacyComparisonWorkflowController } catch { - if (version == _domesticSearchVersion) + if (version == Volatile.Read(ref _domesticSearchVersion)) { _domesticResults = []; _domesticStatus = LegacyComparisonSearchStatus.Error; @@ -110,7 +143,7 @@ public sealed class LegacyComparisonWorkflowController { // UC3's blank world query loaded the complete US/TW universe, so blank remains valid. var normalizedQuery = NormalizeSearchQuery(query); - var version = ++_worldSearchVersion; + var version = Interlocked.Increment(ref _worldSearchVersion); _worldQuery = normalizedQuery; _worldStatus = LegacyComparisonSearchStatus.Loading; _worldError = string.Empty; @@ -124,7 +157,7 @@ public sealed class LegacyComparisonWorkflowController normalizedQuery, LegacyComparisonWorkflowCatalog.WorldMaximumResults, cancellationToken).ConfigureAwait(false); - if (version != _worldSearchVersion) + if (version != Volatile.Read(ref _worldSearchVersion)) { return CreateSnapshot(); } @@ -141,7 +174,7 @@ public sealed class LegacyComparisonWorkflowController } catch (OperationCanceledException) { - if (version == _worldSearchVersion) + if (version == Volatile.Read(ref _worldSearchVersion)) { _worldStatus = LegacyComparisonSearchStatus.Idle; _worldError = string.Empty; @@ -152,7 +185,7 @@ public sealed class LegacyComparisonWorkflowController } catch { - if (version == _worldSearchVersion) + if (version == Volatile.Read(ref _worldSearchVersion)) { _worldResults = []; _worldStatus = LegacyComparisonSearchStatus.Error; @@ -220,6 +253,7 @@ public sealed class LegacyComparisonWorkflowController public async Task LoadSavedPairsAsync( CancellationToken cancellationToken = default) { + var activationGeneration = Volatile.Read(ref _activationGeneration); BeginBusy(); try { @@ -251,6 +285,11 @@ public sealed class LegacyComparisonWorkflowController } } + if (activationGeneration != Volatile.Read(ref _activationGeneration)) + { + return CreateSnapshot(); + } + _savedPairs = candidate; _pairSequence = Math.Max(_pairSequence, candidate.Count); _selectedPairId = candidate.FirstOrDefault()?.RowId; @@ -261,13 +300,21 @@ public sealed class LegacyComparisonWorkflowController } catch { + if (activationGeneration != Volatile.Read(ref _activationGeneration)) + { + return CreateSnapshot(); + } + _statusMessage = "CP949 종목비교.dat를 안전하게 불러오지 못했습니다."; _statusKind = LegacyOperatorStatusKind.Error; throw; } finally { - EndBusy(); + if (activationGeneration == Volatile.Read(ref _activationGeneration)) + { + EndBusy(); + } } return CreateSnapshot(); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyFixedActionCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyFixedActionCatalog.cs index 3287e96..014dd03 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyFixedActionCatalog.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyFixedActionCatalog.cs @@ -104,7 +104,6 @@ public sealed class LegacyFixedAction public sealed record LegacyMaterializedFixedAction( LegacyFixedAction Action, LegacyFixedSelection Selection, - LegacyMovingAverageSelection MovingAverages, LegacyFixedRuntimeAsset RuntimeAsset); public sealed class LegacyFixedSection @@ -262,8 +261,7 @@ public sealed class LegacyFixedActionCatalog public LegacyMaterializedFixedAction Materialize( string actionId, - DateTimeOffset localNow, - LegacyMovingAverageSelection movingAverages = default) + DateTimeOffset localNow) { var action = GetAction(actionId); if (!action.Available) @@ -271,14 +269,9 @@ public sealed class LegacyFixedActionCatalog throw new InvalidOperationException(action.Prerequisite); } - var trustedMovingAverages = action.SupportsMovingAverages - ? movingAverages - : default; var graphicType = action.DynamicNxtSession ? localNow.Hour <= 12 ? "PRE_MARKET" : "AFTER_MARKET" - : action.SupportsMovingAverages - ? MovingAverageText(trustedMovingAverages) - : action.Selection.GraphicType; + : action.Selection.GraphicType; var dataCode = action.DynamicDate ? localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) : action.Selection.DataCode; @@ -291,7 +284,6 @@ public sealed class LegacyFixedActionCatalog return new LegacyMaterializedFixedAction( action, selection, - trustedMovingAverages, RuntimeAssets[action.Market]); } @@ -602,21 +594,6 @@ public sealed class LegacyFixedActionCatalog } } - private static string MovingAverageText(LegacyMovingAverageSelection movingAverages) - { - if (movingAverages.Ma5 && movingAverages.Ma20) - { - return "MA5,MA20"; - } - - if (movingAverages.Ma5) - { - return "MA5"; - } - - return movingAverages.Ma20 ? "MA20" : string.Empty; - } - private static LegacyFixedMarket ParseMarket(string value) => value switch { "overseas" => LegacyFixedMarket.Overseas, diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs index 96aa1d1..b5f9517 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs @@ -82,6 +82,26 @@ public sealed class LegacyIndustrySelectionWorkflow public LegacyIndustrySelectionSnapshot Current => CreateSnapshot(); + /// + /// Recreates the observable UC2 state for one tab activation. The generation + /// increment prevents a request started by the discarded control from restoring + /// its rows after the operator has re-entered the tab. + /// + public LegacyIndustrySelectionSnapshot ResetForTabActivation(IndustryMarket market) + { + ValidateMarket(market); + Interlocked.Increment(ref _loadGeneration); + _market = market; + _industries = NoIndustries; + _actions = LegacyIndustryActionCatalog.GetActions(market); + _selectedIndex = null; + _selectedIndustry = null; + _firstIndustry = null; + _secondIndustry = null; + Touch(); + return CreateSnapshot(); + } + public async Task LoadMarketAsync( IndustryMarket market, CancellationToken cancellationToken = default) diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs index e200281..6492c7f 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs @@ -1,5 +1,9 @@ using S5025ManualAudience = MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5025ManualAudience; +using CoreMovingAverageSelection = + MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageSelection; +using CoreMovingAverageSource = + MBN_STOCK_WEBVIEW.Core.Playout.Scenes.MutableLegacySceneMovingAverageSelectionSource; namespace MBN_STOCK_WEBVIEW.LegacyApplication; @@ -29,6 +33,15 @@ public enum LegacyDragDropPosition After } +public enum LegacyCutKeyboardKey +{ + ArrowUp, + ArrowDown, + Home, + End, + Space +} + public enum LegacyOperatorTabId { Overseas, @@ -50,7 +63,8 @@ public sealed record LegacyCutViewRow( int? DisplayOrdinal, string RawLabel, bool IsSeparator, - bool IsSelected); + bool IsSelected, + bool IsFocused = false); public sealed record LegacyDialogState(string Message); @@ -130,6 +144,7 @@ public sealed class LegacyOperatorController private const long DoubleClickWindowMilliseconds = 500; private readonly ILegacyStockLookup _stockLookup; + private readonly IReadOnlyList _cutRows; private readonly LegacyFixedActionCatalog _fixedCatalog; private readonly TimeProvider _timeProvider; private readonly LegacyIndustrySelectionWorkflow? _industryWorkflow; @@ -142,6 +157,8 @@ public sealed class LegacyOperatorController private readonly LegacyOverseasSelectionWorkflow? _overseasWorkflow; private readonly LegacyManualListsWorkflow? _manualListsWorkflow; private readonly LegacyOperatorCatalogWorkflowController? _operatorCatalogWorkflow; + private readonly CoreMovingAverageSource _movingAverageSource = new( + new CoreMovingAverageSelection(true, true)); private readonly SemaphoreSlim _tabActivationGate = new(1, 1); private readonly SortedSet _legacyPointerRows = []; private readonly SortedSet _nativeSelectedCutRows = []; @@ -165,6 +182,7 @@ public sealed class LegacyOperatorController private int? _selectedStockIndex; private int? _cutAnchorIndex; private int _nativeSelectionAnchorIndex; + private int _nativeFocusedCutIndex = -1; private int? _previousPointerRow; private int _previousPointerX; private int _previousPointerY; @@ -182,7 +200,8 @@ public sealed class LegacyOperatorController private LegacyMovingAverageSelection _movingAverages = new(true, true); private LegacyThemeWorkflowSnapshot? _themeSnapshot; private LegacyManualFinancialWorkflowSnapshot? _manualFinancialSnapshot; - private bool _comparisonLoaded; + private bool _industryActivationListLoaded; + private bool _comparisonActivationListLoaded; private bool _themeActivationListLoaded; private bool _expertActivationListLoaded; private long _tabActivationRequest; @@ -203,9 +222,11 @@ public sealed class LegacyOperatorController LegacyNamedPlaylistWorkflowController? namedPlaylistWorkflow = null, LegacyOverseasSelectionWorkflow? overseasWorkflow = null, LegacyManualListsWorkflow? manualListsWorkflow = null, - LegacyOperatorCatalogWorkflowController? operatorCatalogWorkflow = null) + LegacyOperatorCatalogWorkflowController? operatorCatalogWorkflow = null, + IReadOnlyList? cutRows = null) { _stockLookup = stockLookup ?? throw new ArgumentNullException(nameof(stockLookup)); + _cutRows = CreateValidatedCutRows(cutRows); _fixedCatalog = fixedCatalog ?? LegacyFixedActionCatalog.Default; _timeProvider = timeProvider ?? TimeProvider.System; _industryWorkflow = industryWorkflow; @@ -229,7 +250,23 @@ public sealed class LegacyOperatorController public LegacyOperatorSnapshot Current => CreateSnapshot(); - public LegacyOperatorPlayoutRequest CreatePlayoutRequest() + public LegacyOperatorPlayoutRequest CreatePlayoutRequest() => + CreatePlayoutRequestCore(globalFadeDuration: null); + + public LegacyOperatorPlayoutRequest CreatePlayoutRequest(int globalFadeDuration) + { + if (globalFadeDuration is < 0 or > 19) + { + throw new ArgumentOutOfRangeException( + nameof(globalFadeDuration), + "The original DissolveTime index must be between 0 and 19."); + } + + return CreatePlayoutRequestCore(globalFadeDuration); + } + + private LegacyOperatorPlayoutRequest CreatePlayoutRequestCore( + int? globalFadeDuration) { if (_playlist.Count == 0) { @@ -241,21 +278,82 @@ public sealed class LegacyOperatorController : FindPlaylistIndex(_activePlaylistRowId); if (selectedIndex < 0) { - selectedIndex = _playlist.FindIndex(row => - _selectedPlaylistRowIds.Contains(row.RowId)); + throw new InvalidOperationException( + "플레이리스트의 F8 대상 행이 선택되지 않았습니다."); } - if (selectedIndex < 0) + var entries = _playlist.Select(row => { - selectedIndex = Math.Clamp(_playlistCursorIndex, 0, _playlist.Count - 1); - } + var entry = row.ToPlayoutEntry(); + if (MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageOverlay + .Supports(entry.CutCode)) + { + // Keep the row's seven persisted fields unchanged. The retained source + // is sampled again by the workflow whenever s8010 is rebuilt. + entry = entry with { MovingAverageSelectionSource = _movingAverageSource }; + entry = MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageOverlay + .ApplyCurrent(entry); + } - var entries = _playlist.Select(static row => row.ToPlayoutEntry()).ToArray(); + return globalFadeDuration.HasValue + ? entry with { FadeDuration = globalFadeDuration.Value } + : entry; + }).ToArray(); return new LegacyOperatorPlayoutRequest( Array.AsReadOnly(entries), selectedIndex); } + /// + /// Reflects a successfully prepared or played cue back into the operator grid. + /// MainForm stored the current PageN value in column 5 instead of keeping it only + /// in the Tornado workflow. TAKE/PREPARE may move the F8 candidate when a disabled + /// row is skipped, but never rewrites bufdel/m_delcnt or the Shift anchors. NEXT + /// deliberately leaves even that independent candidate alone. + /// + public LegacyOperatorSnapshot ReflectSuccessfulPlayout( + string entryId, + int pageIndexZeroBased, + int pageCount, + bool synchronizeOperatorSelection) + { + ArgumentException.ThrowIfNullOrWhiteSpace(entryId); + if (pageCount <= 0 || pageIndexZeroBased < 0 || pageIndexZeroBased >= pageCount) + { + throw new ArgumentOutOfRangeException( + nameof(pageIndexZeroBased), + "The playout page position is outside the playlist page range."); + } + + var index = FindPlaylistIndex(entryId); + if (index < 0) + { + return CreateSnapshot(); + } + + var changed = false; + var pageText = $"{pageIndexZeroBased + 1}/{pageCount}"; + if (!string.Equals(_playlist[index].PageText, pageText, StringComparison.Ordinal)) + { + _playlist[index] = _playlist[index] with { PageText = pageText }; + changed = true; + } + + if (synchronizeOperatorSelection) + { + var rowId = _playlist[index].RowId; + changed |= !string.Equals(_activePlaylistRowId, rowId, StringComparison.Ordinal); + _activePlaylistRowId = rowId; + } + + if (changed) + { + Touch(); + } + + return CreateSnapshot(); + } + public async Task SearchStocksAsync( string text, LegacySearchTrigger trigger, @@ -333,7 +431,53 @@ public sealed class LegacyOperatorController ApplyNativeListSelection(physicalIndex, control, shift); _cutAnchorIndex = physicalIndex; - _nativeSelectionAnchorIndex = physicalIndex; + _nativeFocusedCutIndex = physicalIndex; + // Win32 ListView's selection mark remains the fixed anchor across + // consecutive Shift/Ctrl+Shift gestures. Only a non-Shift click moves it. + if (!shift) + { + _nativeSelectionAnchorIndex = physicalIndex; + } + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot CutKeyDown( + LegacyCutKeyboardKey key, + bool control, + bool shift) + { + if (_selectedStockIndex is null || _cutRows.Count == 0) + { + return CreateSnapshot(); + } + + var focusedIndex = IsCutIndex(_nativeFocusedCutIndex) + ? _nativeFocusedCutIndex + : 0; + var targetIndex = key switch + { + LegacyCutKeyboardKey.ArrowUp => Math.Max(0, focusedIndex - 1), + LegacyCutKeyboardKey.ArrowDown => Math.Min(_cutRows.Count - 1, focusedIndex + 1), + LegacyCutKeyboardKey.Home => 0, + LegacyCutKeyboardKey.End => _cutRows.Count - 1, + LegacyCutKeyboardKey.Space => focusedIndex, + _ => throw new ArgumentOutOfRangeException(nameof(key), key, null) + }; + + _nativeFocusedCutIndex = targetIndex; + if (key == LegacyCutKeyboardKey.Space) + { + ApplyNativeSpaceSelection(targetIndex, control, shift); + } + else + { + ApplyNativeKeyboardNavigationSelection(targetIndex, control, shift); + } + + // MainForm's selectedRowsList was populated only by listView1_MouseDown. + // Keyboard selection belongs exclusively to the native ListView state used + // by owner drawing and PlayList_DragDrop, so do not alter the legacy list. Touch(); return CreateSnapshot(); } @@ -352,6 +496,31 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } + public LegacyOperatorSnapshot ClearCutSelection() + { + if (_selectedStockIndex is null) + { + return CreateSnapshot(); + } + + var changed = _legacyPointerRows.Count != 0 || + _nativeSelectedCutRows.Count != 0 || + _cutAnchorIndex != -1 || + _previousPointerTimestamp is not null; + _legacyPointerRows.Clear(); + _nativeSelectedCutRows.Clear(); + _cutAnchorIndex = -1; + // A Win32 ListView blank-area click clears Selected items but preserves + // its selection mark, so a following Shift gesture keeps the old anchor. + _previousPointerTimestamp = null; + _previousPointerRow = -1; + if (changed) + { + Touch(); + } + return CreateSnapshot(); + } + public LegacyOperatorSnapshot SetPlaylistRowEnabled(string rowId, bool enabled) { ArgumentNullException.ThrowIfNull(rowId); @@ -422,7 +591,6 @@ public sealed class LegacyOperatorController LegacyOperatorTabId tabId, CancellationToken cancellationToken = default) { - var changed = _activeTab != tabId; SelectTab(tabId); var activationRequest = Volatile.Read(ref _tabActivationRequest); await _tabActivationGate.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -436,7 +604,7 @@ public sealed class LegacyOperatorController var market = GetActiveIndustryMarket(); var touched = false; if (_industryWorkflow is not null && market is not null && - (changed || _industryWorkflow.Current.Market != market.Value)) + !_industryActivationListLoaded) { await _industryWorkflow.LoadMarketAsync(market.Value, cancellationToken) .ConfigureAwait(false); @@ -445,10 +613,12 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } + _industryActivationListLoaded = true; touched = true; } - if (IsComparisonActive() && _comparisonWorkflow is not null && !_comparisonLoaded) + if (IsComparisonActive() && _comparisonWorkflow is not null && + !_comparisonActivationListLoaded) { await _comparisonWorkflow.LoadSavedPairsAsync(cancellationToken) .ConfigureAwait(false); @@ -457,7 +627,7 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } - _comparisonLoaded = true; + _comparisonActivationListLoaded = true; touched = true; } @@ -586,6 +756,7 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } + _movingAverageSource.Update(new CoreMovingAverageSelection(ma5, ma20)); _movingAverages = next; _tradingHaltWorkflow?.SetMovingAverages(ma5, ma20); _overseasWorkflow?.SetMovingAverages(ma5, ma20); @@ -1738,8 +1909,7 @@ public sealed class LegacyOperatorController { var materialized = _fixedCatalog.Materialize( actionId, - _timeProvider.GetLocalNow(), - _movingAverages); + _timeProvider.GetLocalNow()); if (materialized.Action.Market != market.Value) { return ReportError("현재 탭에 속하지 않는 항목입니다."); @@ -1854,8 +2024,7 @@ public sealed class LegacyOperatorController action, _fixedCatalog.Materialize( action.Id, - localNow, - _movingAverages))); + localNow))); } var manualCount = items.Count(item => item.ManualTarget is not null); @@ -2754,6 +2923,28 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } + /// + /// Moves only MainForm's m_rowidx equivalent. A click in the checkbox column + /// selected the next F8 candidate before its separate enabled-state handling, + /// but did not replace the rows accumulated for Ctrl/Shift deletion. + /// + public LegacyOperatorSnapshot ActivatePlaylistRow(string rowId) + { + ArgumentNullException.ThrowIfNull(rowId); + var index = FindPlaylistIndex(rowId); + if (index < 0 || string.Equals( + _activePlaylistRowId, + rowId, + StringComparison.Ordinal)) + { + return CreateSnapshot(); + } + + _activePlaylistRowId = rowId; + Touch(); + return CreateSnapshot(); + } + public LegacyOperatorSnapshot ToggleActivePlaylistRowEnabled() { return _activePlaylistRowId is null @@ -2770,11 +2961,7 @@ public sealed class LegacyOperatorController var index = last ? _playlist.Count - 1 : 0; var rowId = _playlist[index].RowId; - _selectedPlaylistRowIds.Clear(); - _selectedPlaylistRowIds.Add(rowId); _activePlaylistRowId = rowId; - _playlistPriorCursorIndex = index; - _playlistCursorIndex = index; Touch(); return CreateSnapshot(); } @@ -2786,8 +2973,44 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } - _playlist.RemoveAll(row => _selectedPlaylistRowIds.Contains(row.RowId)); - ResetPlaylistSelection(); + var originalRows = _playlist.ToArray(); + var removedIds = new HashSet( + _selectedPlaylistRowIds, + StringComparer.Ordinal); + var activeIndex = FindOriginalPlaylistIndex(originalRows, _activePlaylistRowId); + var cursorIndex = Math.Clamp(_playlistCursorIndex, 0, originalRows.Length - 1); + var priorCursorIndex = Math.Clamp( + _playlistPriorCursorIndex, + 0, + originalRows.Length - 1); + var cursorRowId = originalRows[cursorIndex].RowId; + var priorCursorRowId = originalRows[priorCursorIndex].RowId; + + _playlist.RemoveAll(row => removedIds.Contains(row.RowId)); + _selectedPlaylistRowIds.Clear(); + if (_playlist.Count == 0) + { + ResetPlaylistSelection(); + } + else + { + _activePlaylistRowId = ResolveSurvivingPlaylistRowId( + originalRows, + removedIds, + _activePlaylistRowId, + activeIndex); + _playlistCursorIndex = ResolveSurvivingPlaylistIndex( + originalRows, + removedIds, + cursorRowId, + cursorIndex); + _playlistPriorCursorIndex = ResolveSurvivingPlaylistIndex( + originalRows, + removedIds, + priorCursorRowId, + priorCursorIndex); + } + Touch(); return CreateSnapshot(); } @@ -2855,24 +3078,20 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } - var sourceWasSelected = _selectedPlaylistRowIds.Contains(sourceRowId); - var movingIds = sourceWasSelected - ? new HashSet(_selectedPlaylistRowIds, StringComparer.Ordinal) - : new HashSet([sourceRowId], StringComparer.Ordinal); - if (movingIds.Contains(targetRowId)) - { - return CreateSnapshot(); - } - - var movingRows = _playlist - .Where(row => movingIds.Contains(row.RowId)) - .ToArray(); + // FarPoint's native row move uses the row being dragged as its source. + // MainForm's manually painted Ctrl/Shift deletion selection was not a + // native multi-row drag selection, so it must never turn this into a + // block move. + var movingRow = _playlist[sourceIndex]; var remainingRows = _playlist - .Where(row => !movingIds.Contains(row.RowId)) + .Where(row => !string.Equals( + row.RowId, + sourceRowId, + StringComparison.Ordinal)) .ToList(); var remainingTargetIndex = remainingRows.FindIndex(row => string.Equals(row.RowId, targetRowId, StringComparison.Ordinal)); - if (movingRows.Length == 0 || remainingTargetIndex < 0) + if (remainingTargetIndex < 0) { return CreateSnapshot(); } @@ -2883,32 +3102,22 @@ public sealed class LegacyOperatorController LegacyDragDropPosition.After => remainingTargetIndex + 1, _ => throw new ArgumentOutOfRangeException(nameof(position), position, null) }; - remainingRows.InsertRange(insertionIndex, movingRows); + remainingRows.Insert(insertionIndex, movingRow); var orderChanged = !_playlist .Select(static row => row.RowId) .SequenceEqual( remainingRows.Select(static row => row.RowId), StringComparer.Ordinal); - var selectionChanged = !sourceWasSelected || - !string.Equals(_activePlaylistRowId, sourceRowId, StringComparison.Ordinal); - if (!orderChanged && !selectionChanged) + if (!orderChanged) { return CreateSnapshot(); } - if (orderChanged) - { - _playlist.Clear(); - _playlist.AddRange(remainingRows); - } - - if (!sourceWasSelected) - { - _selectedPlaylistRowIds.Clear(); - _selectedPlaylistRowIds.Add(sourceRowId); - } - + _playlist.Clear(); + _playlist.AddRange(remainingRows); + _selectedPlaylistRowIds.Clear(); + _selectedPlaylistRowIds.Add(sourceRowId); _activePlaylistRowId = sourceRowId; _playlistCursorIndex = FindPlaylistIndex(sourceRowId); _playlistPriorCursorIndex = _playlistCursorIndex; @@ -2991,6 +3200,7 @@ public sealed class LegacyOperatorController _playlist.Clear(); _playlist.AddRange(loadedRows); ResetPlaylistSelection(); + SelectInitialPlaylistCandidate(); ApplyNamedPlaylistStatus(namedPlaylist); } catch (OperationCanceledException) @@ -3239,6 +3449,14 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } + public LegacyOperatorSnapshot ReportWarning(string message) + { + _statusMessage = message ?? string.Empty; + _statusKind = LegacyOperatorStatusKind.Warning; + Touch(); + return CreateSnapshot(); + } + private void ContinuePendingDrop() { while (_pendingDropCutRows is { Count: > 0 }) @@ -3262,7 +3480,7 @@ public sealed class LegacyOperatorController return true; } - var cut = LegacyStockCutCatalog.Rows[physicalIndex]; + var cut = _cutRows[physicalIndex]; var stockName = _searchData.DisplayNames[stockIndex]; var isNxt = stockName.Contains("(NXT)", StringComparison.Ordinal); if (isNxt && !string.Equals( @@ -3281,7 +3499,7 @@ public sealed class LegacyOperatorController var identity = _searchData.ResolveLastByDisplayName(stockName); var rowId = $"legacy-stock-{++_playlistSequence:D8}"; - _playlist.Add(LegacyStockCutCatalog.CreatePlaylistRow( + AppendPlaylistRow(LegacyStockCutCatalog.CreatePlaylistRow( rowId, stockName, identity, @@ -3316,11 +3534,14 @@ public sealed class LegacyOperatorController private void ApplyNativeListSelection(int physicalIndex, bool control, bool shift) { + var anchor = _nativeSelectionAnchorIndex >= 0 + ? _nativeSelectionAnchorIndex + : physicalIndex; if (control && shift) { AddInclusiveRange( _nativeSelectedCutRows, - _nativeSelectionAnchorIndex, + anchor, physicalIndex); } else if (control) @@ -3335,7 +3556,7 @@ public sealed class LegacyOperatorController _nativeSelectedCutRows.Clear(); AddInclusiveRange( _nativeSelectedCutRows, - _nativeSelectionAnchorIndex, + anchor, physicalIndex); } else @@ -3345,6 +3566,71 @@ public sealed class LegacyOperatorController } } + private void ApplyNativeKeyboardNavigationSelection( + int physicalIndex, + bool control, + bool shift) + { + var anchor = IsCutIndex(_nativeSelectionAnchorIndex) + ? _nativeSelectionAnchorIndex + : physicalIndex; + if (control && !shift) + { + // Ctrl+navigation moves the focus rectangle without changing either + // selection or the Win32 ListView selection mark. + return; + } + + if (control && shift) + { + AddInclusiveRange(_nativeSelectedCutRows, anchor, physicalIndex); + return; + } + + _nativeSelectedCutRows.Clear(); + if (shift) + { + AddInclusiveRange(_nativeSelectedCutRows, anchor, physicalIndex); + return; + } + + _nativeSelectedCutRows.Add(physicalIndex); + _nativeSelectionAnchorIndex = physicalIndex; + } + + private void ApplyNativeSpaceSelection(int physicalIndex, bool control, bool shift) + { + var anchor = IsCutIndex(_nativeSelectionAnchorIndex) + ? _nativeSelectionAnchorIndex + : physicalIndex; + if (control && shift) + { + AddInclusiveRange(_nativeSelectedCutRows, anchor, physicalIndex); + return; + } + + if (control) + { + if (!_nativeSelectedCutRows.Add(physicalIndex)) + { + _nativeSelectedCutRows.Remove(physicalIndex); + } + + _nativeSelectionAnchorIndex = physicalIndex; + return; + } + + _nativeSelectedCutRows.Clear(); + if (shift) + { + AddInclusiveRange(_nativeSelectedCutRows, anchor, physicalIndex); + return; + } + + _nativeSelectedCutRows.Add(physicalIndex); + _nativeSelectionAnchorIndex = physicalIndex; + } + private void AddLegacyRangeFromAnchor(int physicalIndex) { // MainForm used beforeRow = -1 and skipped the range when the previous @@ -3434,7 +3720,7 @@ public sealed class LegacyOperatorController var rawCutLabel = selection.Subtype.Length == 0 ? selection.GraphicType : $"{selection.GraphicType}_{selection.Subtype}"; - return new LegacyOperatorPlaylistRow( + return NormalizeMovingAverageIdentity(new LegacyOperatorPlaylistRow( $"legacy-named-{++_playlistSequence:D8}", row.IsEnabled, selection.GroupCode, @@ -3446,7 +3732,7 @@ public sealed class LegacyOperatorController rawCutLabel, 6, SceneAlias: sceneAlias, - PlayoutSelection: playoutSelection); + PlayoutSelection: playoutSelection)); } private string ResolveNamedPlaylistSceneAlias( @@ -3581,19 +3867,24 @@ public sealed class LegacyOperatorController var searchRows = _searchData.DisplayNames .Select((name, index) => new LegacyStockResultRow(index, name)) .ToArray(); - var cutRows = LegacyStockCutCatalog.Rows + var cutRows = _cutRows .Select(row => new LegacyCutViewRow( row.PhysicalIndex, row.DisplayOrdinal, row.RawLabel, row.IsSeparator, - _nativeSelectedCutRows.Contains(row.PhysicalIndex))) + _nativeSelectedCutRows.Contains(row.PhysicalIndex), + row.PhysicalIndex == _nativeFocusedCutIndex)) .ToArray(); var playlistRows = _playlist .Select(row => row with { - IsSelected = _selectedPlaylistRowIds.Contains(row.RowId) + IsSelected = _selectedPlaylistRowIds.Contains(row.RowId), + IsActive = string.Equals( + _activePlaylistRowId, + row.RowId, + StringComparison.Ordinal) }) .ToArray(); var tabs = _tabOrder @@ -3683,14 +3974,39 @@ public sealed class LegacyOperatorController private void BeginTabActivation(LegacyOperatorTabId tabId) { - if (tabId == LegacyOperatorTabId.Theme) + if (tabId == LegacyOperatorTabId.KospiIndustry) + { + _industryActivationListLoaded = false; + _industryWorkflow?.ResetForTabActivation( + MMoneyCoderSharp.Data.IndustryMarket.Kospi); + } + else if (tabId == LegacyOperatorTabId.KosdaqIndustry) + { + _industryActivationListLoaded = false; + _industryWorkflow?.ResetForTabActivation( + MMoneyCoderSharp.Data.IndustryMarket.Kosdaq); + } + else if (tabId == LegacyOperatorTabId.Comparison) + { + _comparisonActivationListLoaded = false; + _comparisonWorkflow?.ResetForTabActivation(); + } + else if (tabId == LegacyOperatorTabId.Theme) { _themeActivationListLoaded = false; } + else if (tabId == LegacyOperatorTabId.OverseasStocks) + { + _overseasWorkflow?.ResetForTabActivation(); + } else if (tabId == LegacyOperatorTabId.Expert) { _expertActivationListLoaded = false; } + else if (tabId == LegacyOperatorTabId.TradingHalt) + { + _tradingHaltWorkflow?.ResetForTabActivation(); + } } private bool IsCurrentTabActivation( @@ -3822,6 +4138,10 @@ public sealed class LegacyOperatorController { var originalCount = _playlist.Count; var originalSequence = _playlistSequence; + var originalActiveRowId = _activePlaylistRowId; + var originalSelectedRowIds = _selectedPlaylistRowIds.ToArray(); + var originalCursorIndex = _playlistCursorIndex; + var originalPriorCursorIndex = _playlistPriorCursorIndex; try { foreach (var item in items) @@ -3849,6 +4169,11 @@ public sealed class LegacyOperatorController } _playlistSequence = originalSequence; + _activePlaylistRowId = originalActiveRowId; + _selectedPlaylistRowIds.Clear(); + _selectedPlaylistRowIds.UnionWith(originalSelectedRowIds); + _playlistCursorIndex = originalCursorIndex; + _playlistPriorCursorIndex = originalPriorCursorIndex; throw; } } @@ -3890,7 +4215,7 @@ public sealed class LegacyOperatorController private void AppendManualListRow(LegacyManualListPlaylistDraft draft) { - _playlist.Add(new LegacyOperatorPlaylistRow( + AppendPlaylistRow(new LegacyOperatorPlaylistRow( $"legacy-manual-list-{++_playlistSequence:D8}", true, draft.MarketText, @@ -3916,7 +4241,7 @@ public sealed class LegacyOperatorController LegacyFixedMarket.Index => "주요지수", _ => throw new ArgumentOutOfRangeException() }; - _playlist.Add(new LegacyOperatorPlaylistRow( + AppendPlaylistRow(new LegacyOperatorPlaylistRow( $"legacy-fixed-{++_playlistSequence:D8}", true, marketText, @@ -3952,7 +4277,7 @@ public sealed class LegacyOperatorController var marketText = draft.Market == MMoneyCoderSharp.Data.IndustryMarket.Kospi ? "업종_코스피" : "업종_코스닥"; - _playlist.Add(new LegacyOperatorPlaylistRow( + AppendPlaylistRow(new LegacyOperatorPlaylistRow( $"legacy-industry-{++_playlistSequence:D8}", draft.IsEnabled, marketText, @@ -3974,12 +4299,13 @@ public sealed class LegacyOperatorController private void AppendOverseasRow(LegacyOverseasPlaylistDraft draft) { - _playlist.Add(new LegacyOperatorPlaylistRow( + var isCandle = string.Equals(draft.BuilderKey, "s8010", StringComparison.Ordinal); + AppendPlaylistRow(new LegacyOperatorPlaylistRow( $"legacy-overseas-{++_playlistSequence:D8}", draft.IsEnabled, draft.Selection.GroupCode, draft.Title, - draft.Selection.GraphicType, + isCandle ? "캔들그래프" : draft.Selection.GraphicType, draft.Selection.Subtype, draft.InitialPageText, draft.Selection.DataCode, @@ -3989,7 +4315,7 @@ public sealed class LegacyOperatorController PlayoutSelection: new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection( draft.Selection.GroupCode, draft.Selection.Subject, - draft.Selection.GraphicType, + isCandle ? string.Empty : draft.Selection.GraphicType, draft.Selection.Subtype, draft.Selection.DataCode))); } @@ -3999,7 +4325,7 @@ public sealed class LegacyOperatorController var separator = draft.Detail.IndexOf(" · ", StringComparison.Ordinal); var graphicType = separator > 0 ? draft.Detail[..separator] : draft.Detail; var subtype = separator > 0 ? draft.Detail[(separator + 3)..] : string.Empty; - _playlist.Add(new LegacyOperatorPlaylistRow( + AppendPlaylistRow(new LegacyOperatorPlaylistRow( $"legacy-theme-{++_playlistSequence:D8}", true, draft.Selection.GroupCode == "PAGED_THEME" ? "테마" : "테마_NXT", @@ -4021,7 +4347,7 @@ public sealed class LegacyOperatorController private void AppendComparisonRow(LegacyGenericPlaylistDraft draft) { - _playlist.Add(new LegacyOperatorPlaylistRow( + AppendPlaylistRow(new LegacyOperatorPlaylistRow( $"legacy-comparison-{++_playlistSequence:D8}", draft.IsEnabled, "비교", @@ -4038,7 +4364,7 @@ public sealed class LegacyOperatorController private void AppendExpertRow(LegacyExpertPlaylistDraft draft) { - _playlist.Add(new LegacyOperatorPlaylistRow( + AppendPlaylistRow(new LegacyOperatorPlaylistRow( $"legacy-expert-{++_playlistSequence:D8}", draft.IsEnabled, "전문가", @@ -4056,7 +4382,7 @@ public sealed class LegacyOperatorController private void AppendTradingHaltRow(LegacyTradingHaltPlaylistDraft draft) { var isCandle = draft.CandlePeriodDays.HasValue; - _playlist.Add(new LegacyOperatorPlaylistRow( + AppendPlaylistRow(new LegacyOperatorPlaylistRow( $"legacy-halt-{++_playlistSequence:D8}", draft.IsEnabled, draft.Market == "kospi" ? "코스피" : "코스닥", @@ -4073,7 +4399,7 @@ public sealed class LegacyOperatorController private void AppendManualFinancialRow(LegacyManualFinancialPlaylistDraft draft) { - _playlist.Add(new LegacyOperatorPlaylistRow( + AppendPlaylistRow(new LegacyOperatorPlaylistRow( $"legacy-manual-financial-{++_playlistSequence:D8}", true, draft.Market, @@ -4103,12 +4429,236 @@ public sealed class LegacyOperatorController _ => throw new ArgumentOutOfRangeException(nameof(tabId), tabId, null) }; - private static bool IsCutIndex(int physicalIndex) => - physicalIndex >= 0 && physicalIndex < LegacyStockCutCatalog.Rows.Count; + private bool IsCutIndex(int physicalIndex) => + physicalIndex >= 0 && physicalIndex < _cutRows.Count; + + private static IReadOnlyList CreateValidatedCutRows( + IReadOnlyList? cutRows) + { + var source = cutRows ?? LegacyStockCutCatalog.Rows; + if (source.Count is < 1 or > LegacyRuntimeStockCutMenuLoader.MaximumRows) + { + throw new ArgumentException( + "The stock cut menu must contain from 1 through 50 physical rows.", + nameof(cutRows)); + } + + var copiedRows = new LegacyCutCatalogRow[source.Count]; + var displayOrdinal = 0; + for (var index = 0; index < source.Count; index++) + { + var row = source[index] ?? throw new ArgumentException( + "The stock cut menu cannot contain a null row.", + nameof(cutRows)); + if (row.PhysicalIndex != index) + { + throw new ArgumentException( + "Stock cut physical indices must be contiguous and zero based.", + nameof(cutRows)); + } + + if (row.IsSeparator) + { + if (row.DisplayOrdinal is not null || + !string.IsNullOrEmpty(row.RawLabel)) + { + throw new ArgumentException( + "A stock cut separator must have no display ordinal or label.", + nameof(cutRows)); + } + } + else + { + displayOrdinal++; + if (row.DisplayOrdinal != displayOrdinal) + { + throw new ArgumentException( + "Stock cut display ordinals must be contiguous across active rows.", + nameof(cutRows)); + } + + var isKnown = LegacyStockCutCatalog.Rows.Any(candidate => + !candidate.IsSeparator && string.Equals( + candidate.RawLabel, + row.RawLabel, + StringComparison.Ordinal)); + if (!isKnown) + { + throw new ArgumentException( + "The stock cut menu contains an unknown cut label.", + nameof(cutRows)); + } + } + + copiedRows[index] = row; + } + + if (displayOrdinal == 0) + { + throw new ArgumentException( + "The stock cut menu must contain at least one active cut.", + nameof(cutRows)); + } + + return Array.AsReadOnly(copiedRows); + } private int FindPlaylistIndex(string rowId) => _playlist.FindIndex(row => string.Equals(row.RowId, rowId, StringComparison.Ordinal)); + private static int FindOriginalPlaylistIndex( + IReadOnlyList rows, + string? rowId) + { + if (rowId is null) + { + return -1; + } + + for (var index = 0; index < rows.Count; index++) + { + if (string.Equals(rows[index].RowId, rowId, StringComparison.Ordinal)) + { + return index; + } + } + + return -1; + } + + private void AppendPlaylistRow(LegacyOperatorPlaylistRow row) + { + ArgumentNullException.ThrowIfNull(row); + row = NormalizeMovingAverageIdentity(row); + var wasEmpty = _playlist.Count == 0; + _playlist.Add(row); + if (wasEmpty) + { + SelectInitialPlaylistCandidate(); + } + } + + private static LegacyOperatorPlaylistRow NormalizeMovingAverageIdentity( + LegacyOperatorPlaylistRow row) + { + var entry = row.ToPlayoutEntry(); + if (!MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageOverlay + .Supports(entry.CutCode)) + { + return row; + } + + var graphicType = RemoveCapturedMovingAverageFlags(row.GraphicType); + var selection = row.PlayoutSelection; + var normalizedSelection = selection is null + ? null + : selection with + { + GraphicType = RemoveCapturedMovingAverageFlags(selection.GraphicType) + }; + return string.Equals(graphicType, row.GraphicType, StringComparison.Ordinal) && + Equals(normalizedSelection, selection) + ? row + : row with + { + GraphicType = graphicType, + PlayoutSelection = normalizedSelection + }; + } + + private static string RemoveCapturedMovingAverageFlags(string graphicType) => + graphicType is "MA5" or "MA20" or "MA5,MA20" + ? string.Empty + : graphicType; + + private void SelectInitialPlaylistCandidate() + { + if (_playlist.Count == 0) + { + return; + } + + _activePlaylistRowId = _playlist[0].RowId; + // MainForm corrected m_rowidx to zero for the first F8 candidate, but it + // did not populate bufdel until an actual playlist row click occurred. + _selectedPlaylistRowIds.Clear(); + _playlistCursorIndex = 0; + _playlistPriorCursorIndex = 0; + } + + private string ResolveSurvivingPlaylistRowId( + IReadOnlyList originalRows, + ISet removedIds, + string? preferredRowId, + int preferredIndex) + { + if (preferredRowId is not null && FindPlaylistIndex(preferredRowId) >= 0) + { + return preferredRowId; + } + + var resolvedOriginalIndex = FindNextThenPreviousSurvivor( + originalRows, + removedIds, + preferredIndex); + return originalRows[resolvedOriginalIndex].RowId; + } + + private int ResolveSurvivingPlaylistIndex( + IReadOnlyList originalRows, + ISet removedIds, + string preferredRowId, + int preferredIndex) + { + var survivingIndex = FindPlaylistIndex(preferredRowId); + if (survivingIndex >= 0) + { + return survivingIndex; + } + + var resolvedOriginalIndex = FindNextThenPreviousSurvivor( + originalRows, + removedIds, + preferredIndex); + return FindPlaylistIndex(originalRows[resolvedOriginalIndex].RowId); + } + + private static int FindNextThenPreviousSurvivor( + IReadOnlyList originalRows, + ISet removedIds, + int preferredIndex) + { + if (preferredIndex < 0) + { + for (var index = 0; index < originalRows.Count; index++) + { + if (!removedIds.Contains(originalRows[index].RowId)) + { + return index; + } + } + } + + var start = Math.Clamp(preferredIndex, 0, originalRows.Count - 1); + for (var index = start + 1; index < originalRows.Count; index++) + { + if (!removedIds.Contains(originalRows[index].RowId)) + { + return index; + } + } + + for (var index = start - 1; index >= 0; index--) + { + if (!removedIds.Contains(originalRows[index].RowId)) + { + return index; + } + } + + throw new InvalidOperationException("삭제 후 남은 플레이리스트 행을 찾지 못했습니다."); + } + private void AddPlaylistRange(int firstIndex, int lastIndex) { var first = Math.Max(0, Math.Min(firstIndex, lastIndex)); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs index 0ce0109..7bfc397 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs @@ -53,4 +53,7 @@ public sealed record LegacyOperatorPlayoutSnapshot( bool BackgroundEnabled, string BackgroundFileName, bool CanChangeBackground, - string Message); + string Message) +{ + public bool IsTakeOutCompletionPending { get; init; } +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs index 6d8a20f..99c3e7e 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs @@ -273,6 +273,41 @@ public sealed class LegacyOverseasSelectionWorkflow } } + /// + /// Recreates UC5's per-control search and selection state while retaining the + /// MainForm-owned moving-average checkboxes. Incrementing both generations makes + /// results from the discarded control stale before they can commit. + /// + public LegacyOverseasWorkflowSnapshot ResetForTabActivation() + { + lock (_sync) + { + checked + { + _industryGeneration++; + _stockGeneration++; + } + + _selectedFixedIndex = null; + _industryStatus = LegacyOverseasSearchStatus.Idle; + _industryQuery = string.Empty; + _industryRetrievedAt = null; + _industryResults = NoIndustryResults; + _industryIsTruncated = false; + _selectedIndustry = null; + _industryError = string.Empty; + _stockStatus = LegacyOverseasSearchStatus.Idle; + _stockQuery = string.Empty; + _stockRetrievedAt = null; + _stockResults = NoStockResults; + _stockIsTruncated = false; + _selectedStock = null; + _stockError = string.Empty; + Touch(); + return CreateSnapshot(); + } + } + public LegacyOverseasWorkflowSnapshot SelectFixedIndex(string targetId) { var target = LegacyOverseasActionCatalog.GetFixedIndexTarget(targetId); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyRuntimeStockCutMenuLoader.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyRuntimeStockCutMenuLoader.cs new file mode 100644 index 0000000..ee49cbe --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyRuntimeStockCutMenuLoader.cs @@ -0,0 +1,355 @@ +using System.Text; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +public enum LegacyRuntimeStockCutMenuFailure +{ + None, + InvalidPath, + FileNotFound, + FileUnavailable, + FileTooLarge, + InvalidEncoding, + InvalidFormat, + TooManyRows, + UnknownCut, + EmptyMenu +} + +public enum LegacyRuntimeStockCutMenuSource +{ + ExternalOverride, + Packaged, + BuiltIn +} + +public sealed record LegacyRuntimeStockCutMenuComposition( + IReadOnlyList Rows, + LegacyRuntimeStockCutMenuSource Source, + LegacyRuntimeStockCutMenuFailure Failure, + string? WarningMessage) +{ + public bool UsesBuiltInCatalog => Source == LegacyRuntimeStockCutMenuSource.BuiltIn; +} + +public static class LegacyRuntimeStockCutMenuLoader +{ + public const int MaximumRows = 50; + public const int MaximumFileBytes = 64 * 1024; + public const string StockSectionHeader = "[종목]"; + public const string FallbackWarningMessage = + "런타임 컷 메뉴를 읽지 못해 내장 컷 목록을 사용합니다."; + + private static readonly Encoding Cp949 = CreateCp949Encoding(); + private static readonly HashSet KnownCutLabels = LegacyStockCutCatalog.Rows + .Where(row => !row.IsSeparator) + .Select(row => row.RawLabel) + .ToHashSet(StringComparer.Ordinal); + + public static LegacyRuntimeStockCutMenuComposition ResolveFromBaseDirectory( + string? baseDirectory) + { + if (!TryCreatePackagedCandidatePath(baseDirectory, out var path)) + { + return CreateFallbackComposition( + LegacyRuntimeStockCutMenuFailure.InvalidPath); + } + + if (TryLoad(path, out var rows, out var failure)) + { + return CreateRuntimeComposition( + rows, + LegacyRuntimeStockCutMenuSource.Packaged); + } + + return CreateFallbackComposition(failure); + } + + public static LegacyRuntimeStockCutMenuComposition ResolveFromCandidates( + string? externalOverridePath, + string? packagedBaseDirectory) + { + if (externalOverridePath is not null) + { + if (!TryNormalizeExternalOverridePath( + externalOverridePath, + out var normalizedExternalPath)) + { + return CreateFallbackComposition( + LegacyRuntimeStockCutMenuFailure.InvalidPath); + } + + if (TryLoad( + normalizedExternalPath, + out var externalRows, + out var externalFailure)) + { + return CreateRuntimeComposition( + externalRows, + LegacyRuntimeStockCutMenuSource.ExternalOverride); + } + + if (externalFailure != LegacyRuntimeStockCutMenuFailure.FileNotFound) + { + return CreateFallbackComposition(externalFailure); + } + } + + return ResolveFromBaseDirectory(packagedBaseDirectory); + } + + public static bool TryLoad( + string? path, + out IReadOnlyList rows, + out LegacyRuntimeStockCutMenuFailure failure) + { + rows = Array.Empty(); + if (string.IsNullOrWhiteSpace(path) || + path.Any(char.IsControl) || + !Path.IsPathFullyQualified(path)) + { + failure = LegacyRuntimeStockCutMenuFailure.InvalidPath; + return false; + } + + try + { + using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4096, + FileOptions.SequentialScan); + if (stream.Length > MaximumFileBytes) + { + failure = LegacyRuntimeStockCutMenuFailure.FileTooLarge; + return false; + } + + var bytes = new byte[(int)stream.Length]; + stream.ReadExactly(bytes); + return TryParse(bytes, out rows, out failure); + } + catch (Exception exception) when ( + exception is FileNotFoundException or + DirectoryNotFoundException) + { + failure = LegacyRuntimeStockCutMenuFailure.FileNotFound; + return false; + } + catch (Exception exception) when ( + exception is IOException or + UnauthorizedAccessException or + NotSupportedException or + ArgumentException or + System.Security.SecurityException) + { + failure = LegacyRuntimeStockCutMenuFailure.FileUnavailable; + return false; + } + } + + public static bool TryParse( + ReadOnlySpan cp949Bytes, + out IReadOnlyList rows, + out LegacyRuntimeStockCutMenuFailure failure) + { + rows = Array.Empty(); + if (cp949Bytes.Length > MaximumFileBytes) + { + failure = LegacyRuntimeStockCutMenuFailure.FileTooLarge; + return false; + } + + string text; + try + { + text = Cp949.GetString(cp949Bytes); + } + catch (DecoderFallbackException) + { + failure = LegacyRuntimeStockCutMenuFailure.InvalidEncoding; + return false; + } + + return TryParseDecodedText(text, out rows, out failure); + } + + private static bool TryParseDecodedText( + string text, + out IReadOnlyList rows, + out LegacyRuntimeStockCutMenuFailure failure) + { + rows = Array.Empty(); + var parsedRows = new List(); + var sawSection = false; + var displayOrdinal = 0; + + using var reader = new StringReader(text); + while (reader.ReadLine() is { } sourceLine) + { + var line = sourceLine.Trim(); + if (line.Length == 0) + { + continue; + } + + if (!sawSection) + { + if (!string.Equals(line, StockSectionHeader, StringComparison.Ordinal)) + { + failure = LegacyRuntimeStockCutMenuFailure.InvalidFormat; + return false; + } + + sawSection = true; + continue; + } + + if (line.StartsWith("[", StringComparison.Ordinal)) + { + failure = LegacyRuntimeStockCutMenuFailure.InvalidFormat; + return false; + } + + if (parsedRows.Count >= MaximumRows) + { + failure = LegacyRuntimeStockCutMenuFailure.TooManyRows; + return false; + } + + var isSeparator = string.Equals(line, "-", StringComparison.Ordinal); + if (!isSeparator && !KnownCutLabels.Contains(line)) + { + failure = LegacyRuntimeStockCutMenuFailure.UnknownCut; + return false; + } + + parsedRows.Add(new LegacyCutCatalogRow( + parsedRows.Count, + isSeparator ? null : ++displayOrdinal, + isSeparator ? string.Empty : line, + isSeparator)); + } + + if (!sawSection) + { + failure = LegacyRuntimeStockCutMenuFailure.InvalidFormat; + return false; + } + + if (displayOrdinal == 0) + { + failure = LegacyRuntimeStockCutMenuFailure.EmptyMenu; + return false; + } + + rows = parsedRows.AsReadOnly(); + failure = LegacyRuntimeStockCutMenuFailure.None; + return true; + } + + private static Encoding CreateCp949Encoding() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + return Encoding.GetEncoding( + 949, + EncoderFallback.ExceptionFallback, + DecoderFallback.ExceptionFallback); + } + + private static LegacyRuntimeStockCutMenuComposition CreateFallbackComposition( + LegacyRuntimeStockCutMenuFailure failure) => + new( + LegacyStockCutCatalog.Rows, + LegacyRuntimeStockCutMenuSource.BuiltIn, + failure, + FallbackWarningMessage); + + private static LegacyRuntimeStockCutMenuComposition CreateRuntimeComposition( + IReadOnlyList rows, + LegacyRuntimeStockCutMenuSource source) => + new( + rows, + source, + LegacyRuntimeStockCutMenuFailure.None, + WarningMessage: null); + + private static bool TryCreatePackagedCandidatePath( + string? baseDirectory, + out string path) + { + path = string.Empty; + if (string.IsNullOrWhiteSpace(baseDirectory) || + baseDirectory.Any(char.IsControl) || + !Path.IsPathFullyQualified(baseDirectory)) + { + return false; + } + + try + { + path = Path.Combine( + Path.GetFullPath(baseDirectory), + "Res", + "종목.ini"); + return true; + } + catch (Exception exception) when ( + exception is ArgumentException or + IOException or + NotSupportedException or + System.Security.SecurityException) + { + return false; + } + } + + private static bool TryNormalizeExternalOverridePath( + string path, + out string normalizedPath) + { + normalizedPath = string.Empty; + if (string.IsNullOrWhiteSpace(path) || + path.Any(char.IsControl) || + !Path.IsPathFullyQualified(path)) + { + return false; + } + + try + { + normalizedPath = Path.GetFullPath(path); + var resourceDirectory = Path.GetDirectoryName(normalizedPath); + var applicationDirectory = resourceDirectory is null + ? null + : Path.GetDirectoryName(resourceDirectory); + return string.Equals( + Path.GetFileName(normalizedPath), + "종목.ini", + StringComparison.OrdinalIgnoreCase) && + string.Equals( + resourceDirectory is null + ? null + : Path.GetFileName(resourceDirectory), + "Res", + StringComparison.OrdinalIgnoreCase) && + string.Equals( + applicationDirectory is null + ? null + : Path.GetFileName(applicationDirectory), + "MBN_STOCK_WEBVIEW", + StringComparison.OrdinalIgnoreCase); + } + catch (Exception exception) when ( + exception is ArgumentException or + IOException or + NotSupportedException or + System.Security.SecurityException) + { + normalizedPath = string.Empty; + return false; + } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs index 8734f8e..6cff600 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs @@ -1,3 +1,4 @@ +using MBN_STOCK_WEBVIEW.Core.Playout; using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; namespace MBN_STOCK_WEBVIEW.LegacyApplication; @@ -21,7 +22,8 @@ public sealed record LegacyOperatorPlaylistRow( int FadeDuration, bool IsSelected = false, string SceneAlias = "", - LegacySceneSelection? PlayoutSelection = null) + LegacySceneSelection? PlayoutSelection = null, + bool IsActive = false) { public LegacyPlaylistEntry ToPlayoutEntry() { @@ -40,7 +42,47 @@ public sealed record LegacyOperatorPlaylistRow( StockName, GraphicType, Subtype, - StockCode)); + StockCode), + CreatePageNavigation()); + } + + private LegacyPlaylistPageNavigation CreatePageNavigation() + { + var pageSize = GraphicType switch + { + "5단 표그래프" => ScenePageSize.Five, + "6종목 현재가" => ScenePageSize.Six, + "12종목 현재가" => ScenePageSize.Twelve, + _ => (ScenePageSize?)null + }; + if (!pageSize.HasValue) + { + // MainForm's PageN branches compared the visible FarPoint column text + // exactly. A scene alias that happens to resolve to a paged builder is still + // an ordinary playlist row when that column uses any other spelling. + return LegacyPlaylistPageNavigation.Disabled; + } + + var separator = PageText.IndexOf('/'); + if (separator <= 0 || separator != PageText.LastIndexOf('/') || + !int.TryParse( + PageText.AsSpan(0, separator), + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out var currentPage) || + !int.TryParse( + PageText.AsSpan(separator + 1), + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out var totalPages) || + totalPages is < 1 or > ScenePaging.MaximumPageCount || + currentPage < 1 || currentPage > totalPages) + { + throw new InvalidOperationException( + "A PageN playlist row must use current/total with a total from 1 through 20."); + } + + return new LegacyPlaylistPageNavigation(pageSize, totalPages); } } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs index bfac150..4ef8ebd 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs @@ -119,6 +119,33 @@ public sealed class LegacyTradingHaltWorkflowController } } + /// + /// Recreates UC7's per-control query, result, selection and column-sort state. + /// MainForm-owned moving-average values remain in force across tab activation. + /// + public LegacyTradingHaltWorkflowSnapshot ResetForTabActivation() + { + lock (_gate) + { + checked + { + _searchGeneration++; + } + + _results = []; + _selected = null; + _query = string.Empty; + _errorMessage = string.Empty; + _statusMessage = string.Empty; + _status = LegacyTradingHaltRequestStatus.Idle; + _nameSort = LegacyTradingHaltNameSort.None; + _statusKind = LegacyOperatorStatusKind.Neutral; + _isTruncated = false; + Touch(); + return CreateSnapshot(); + } + } + public async Task SearchAsync( string query, CancellationToken cancellationToken = default) diff --git a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs index 833f26f..bb99092 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs @@ -24,6 +24,13 @@ public sealed record LegacyCutPointerDownIntent( int Y, long TimestampMilliseconds) : LegacyUiIntent; +public sealed record LegacyCutKeyDownIntent( + LegacyCutKeyboardKey Key, + bool Control, + bool Shift) : LegacyUiIntent; + +public sealed record LegacyClearCutSelectionIntent : LegacyUiIntent; + public sealed record LegacyDropSelectedCutsIntent : LegacyUiIntent; public sealed record LegacySetPlaylistEnabledIntent(string RowId, bool Enabled) @@ -37,6 +44,8 @@ public sealed record LegacySelectPlaylistRowIntent( bool Control, bool Shift) : LegacyUiIntent; +public sealed record LegacyActivatePlaylistRowIntent(string RowId) : LegacyUiIntent; + public sealed record LegacyMoveSelectedPlaylistRowsIntent( LegacyPlaylistMoveDirection Direction) : LegacyUiIntent; @@ -349,10 +358,13 @@ public static class LegacyBridgeProtocol "search-stocks" => ParseSearch(payload), "select-stock" => ParseStockSelection(payload), "cut-pointer-down" => ParseCutPointer(payload), + "cut-key-down" => ParseCutKeyDown(payload), + "clear-cut-selection" => ParseClearCutSelection(payload), "drop-selected-cuts" => ParseDrop(payload), "set-playlist-enabled" => ParseEnabled(payload), "set-all-playlist-enabled" => ParseAllEnabled(payload), "select-playlist-row" => ParsePlaylistSelection(payload), + "activate-playlist-row" => ParsePlaylistActivation(payload), "move-selected-playlist-rows" => ParsePlaylistMove(payload), "reorder-playlist-rows" => ParsePlaylistReorder(payload), "delete-selected-playlist-rows" => ParseDeleteSelected(payload), @@ -565,6 +577,7 @@ public static class LegacyBridgeProtocol row.RowId, row.IsEnabled, row.IsSelected, + row.IsActive, row.MarketText, row.StockName, row.GraphicType, @@ -1020,10 +1033,12 @@ public static class LegacyBridgeProtocol playout.IsCommandAvailable, playout.LiveTakeInAllowed, playout.IsPlayCompletionPending, + playout.IsTakeOutCompletionPending, playout.OutcomeUnknown, playout.PreparedCode, playout.OnAirCode, playout.CurrentCueIndexZeroBased, + playout.CurrentEntryId, playout.PageIndexZeroBased, playout.PageCount, playout.PageSize, @@ -1089,7 +1104,12 @@ public static class LegacyBridgeProtocol "x", "y", "timestampMilliseconds") || - !TryInt32(payload, "physicalIndex", 0, 32, out var physicalIndex) || + !TryInt32( + payload, + "physicalIndex", + 0, + LegacyRuntimeStockCutMenuLoader.MaximumRows - 1, + out var physicalIndex) || !TryBoolean(payload, "control", out var control) || !TryBoolean(payload, "shift", out var shift) || !TryInt32(payload, "x", 0, 16_384, out var x) || @@ -1111,6 +1131,33 @@ public static class LegacyBridgeProtocol private static LegacyUiIntent? ParseDrop(JsonElement payload) => HasOnlyProperties(payload) ? new LegacyDropSelectedCutsIntent() : null; + private static LegacyUiIntent? ParseCutKeyDown(JsonElement payload) + { + if (!HasOnlyProperties(payload, "key", "control", "shift") || + !TryString(payload, "key", 16, out var keyText) || + !TryBoolean(payload, "control", out var control) || + !TryBoolean(payload, "shift", out var shift)) + { + return null; + } + + var key = keyText switch + { + "arrow-up" => LegacyCutKeyboardKey.ArrowUp, + "arrow-down" => LegacyCutKeyboardKey.ArrowDown, + "home" => LegacyCutKeyboardKey.Home, + "end" => LegacyCutKeyboardKey.End, + "space" => LegacyCutKeyboardKey.Space, + _ => (LegacyCutKeyboardKey?)null + }; + return key.HasValue + ? new LegacyCutKeyDownIntent(key.Value, control, shift) + : null; + } + + private static LegacyUiIntent? ParseClearCutSelection(JsonElement payload) => + HasOnlyProperties(payload) ? new LegacyClearCutSelectionIntent() : null; + private static LegacyUiIntent? ParseEnabled(JsonElement payload) { if (!HasOnlyProperties(payload, "rowId", "enabled") || @@ -1144,6 +1191,13 @@ public static class LegacyBridgeProtocol return new LegacySelectPlaylistRowIntent(rowId, control, shift); } + private static LegacyUiIntent? ParsePlaylistActivation(JsonElement payload) => + HasOnlyProperties(payload, "rowId") && + TryString(payload, "rowId", 128, out var rowId) && + IsSafeIdentifier(rowId) + ? new LegacyActivatePlaylistRowIntent(rowId) + : null; + private static LegacyUiIntent? ParsePlaylistMove(JsonElement payload) { if (!HasOnlyProperties(payload, "direction") || diff --git a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs index d7f463d..b989609 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs @@ -51,7 +51,7 @@ internal static class LegacyPlayoutUiIntentParser !payload.TryGetProperty("duration", out var durationElement) || durationElement.ValueKind != JsonValueKind.Number || !durationElement.TryGetInt32(out var duration) || - duration is < 0 or > 60) + duration is < 0 or > 19) { return null; } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.CloseConfirmation.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.CloseConfirmation.cs index 0262a84..e2478df 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.CloseConfirmation.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.CloseConfirmation.cs @@ -59,10 +59,10 @@ public sealed partial class MainWindow var dialog = new ContentDialog { XamlRoot = Root.XamlRoot, - Title = "프로그램 종료", + Title = "매일경제TV", Content = BuildCloseConfirmationMessage(), - PrimaryButtonText = "종료", - CloseButtonText = "취소", + PrimaryButtonText = "예", + CloseButtonText = "아니요", DefaultButton = ContentDialogButton.Close }; @@ -94,7 +94,8 @@ public sealed partial class MainWindow var hasOnAirScene = !string.IsNullOrWhiteSpace(status?.OnAirSceneName) || !string.IsNullOrWhiteSpace(workflow?.OnAirCutCode); var commandPending = Volatile.Read(ref _playoutBusy) != 0 || - status?.IsPlayCompletionPending == true; + status?.IsPlayCompletionPending == true || + status?.IsTakeOutCompletionPending == true; var outcomeUnknown = Volatile.Read(ref _playoutQuarantined) != 0 || status?.State == PlayoutConnectionState.OutcomeUnknown; @@ -116,7 +117,7 @@ public sealed partial class MainWindow "PGM을 확인하세요. 종료 과정에서는 TAKE OUT을 자동 실행하지 않습니다. 그래도 종료하시겠습니까?"; } - return "프로그램을 종료하시겠습니까?"; + return "프로그램을 종료하겠습니까?"; } private void DetachCloseConfirmation() diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs index 0aff3ef..e207c70 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs @@ -136,42 +136,44 @@ public sealed partial class MainWindow try { Interlocked.Exchange(ref _playoutBusy, 1); - StopRefreshLoop(); var nextKindBefore = workflow.State.NextKind; - if (command == LegacyOperatorPlayoutCommand.Next && + var pageNavigationEnabledBefore = workflow.State.IsPageNavigationEnabled; + if (engine.Status.IsTakeOutCompletionPending) + { + return _controller.ReportError( + "Tornado TAKE OUT 완료 이벤트를 기다리는 동안에는 송출 명령을 실행할 수 없습니다."); + } + + if (command != LegacyOperatorPlayoutCommand.TakeOut && engine.Status.IsPlayCompletionPending) { return _controller.ReportError( - "Tornado 재생 완료 이벤트를 기다리는 중입니다. NEXT를 반복하지 마세요."); + "Tornado 재생 완료 이벤트를 기다리는 중입니다. 결과가 확인될 때까지 TAKE OUT만 사용할 수 있습니다."); } + StopRefreshLoop(); PlayoutResult result; switch (command) { case LegacyOperatorPlayoutCommand.Prepare: - var request = _controller.CreatePlayoutRequest(); + var request = _controller.CreatePlayoutRequest( + _compositionOptions!.Current.FadeDuration); result = await workflow.PrepareAsync( request.Playlist, request.SelectedIndexZeroBased, cancellationToken).ConfigureAwait(false); break; case LegacyOperatorPlayoutCommand.TakeIn: - // MainForm.btnTake_Click performed ONAirMode (load/prepare) and - // Play in one visible F8 action. Keep the explicit PREPARE control - // for engineering checks, but preserve the original one-click path - // whenever no scene is already prepared. - if (string.IsNullOrWhiteSpace(workflow.State.PreparedCutCode)) - { - var takeInRequest = _controller.CreatePlayoutRequest(); - result = await workflow.PrepareAndTakeInAsync( - takeInRequest.Playlist, - takeInRequest.SelectedIndexZeroBased, - cancellationToken).ConfigureAwait(false); - } - else - { - result = await workflow.TakeInAsync(cancellationToken).ConfigureAwait(false); - } + // MainForm.btnTake_Click always copied m_rowidx (the operator's + // candidate) to m_crow and ran ONAirMode + Play, even while another + // row was already on air. Re-read the current selection on every F8; + // a stale explicit PREPARE must never override a later row click. + var takeInRequest = _controller.CreatePlayoutRequest( + _compositionOptions!.Current.FadeDuration); + result = await workflow.TakeSelectedAsync( + takeInRequest.Playlist, + takeInRequest.SelectedIndexZeroBased, + cancellationToken).ConfigureAwait(false); break; case LegacyOperatorPlayoutCommand.Next: result = await workflow.NextAsync(cancellationToken).ConfigureAwait(false); @@ -185,6 +187,23 @@ public sealed partial class MainWindow throw new ArgumentOutOfRangeException(nameof(command)); } + if (result.IsSuccess && + command != LegacyOperatorPlayoutCommand.TakeOut && + workflow.State.CurrentEntryId is { Length: > 0 } currentEntryId && + workflow.State.PageCount > 0) + { + // FarPoint column 5 was mutable operator state: F8/PREPARE reset a + // paged cut to 1/N, while Page NEXT left 2/N, 3/N, ... in the row. + // NEXT must not replace the independently selected F8 candidate. + _controller.ReflectSuccessfulPlayout( + currentEntryId, + workflow.State.PageIndexZeroBased, + workflow.State.PageCount, + synchronizeOperatorSelection: + command is LegacyOperatorPlayoutCommand.Prepare or + LegacyOperatorPlayoutCommand.TakeIn); + } + if (result.Code is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut) { await QuarantinePlayoutAsync().ConfigureAwait(false); @@ -197,9 +216,15 @@ public sealed partial class MainWindow return _controller.ReportError(result.Message); } + if (command == LegacyOperatorPlayoutCommand.TakeOut) + { + ResetRefreshState(); + } + if (command == LegacyOperatorPlayoutCommand.TakeIn || command == LegacyOperatorPlayoutCommand.Next && - nextKindBefore == LegacyWorkflowNextKind.PlaylistNext) + nextKindBefore == LegacyWorkflowNextKind.PlaylistNext && + !pageNavigationEnabledBefore) { StartRefreshLoop(workflow); } @@ -237,9 +262,9 @@ public sealed partial class MainWindow int fadeDuration, CancellationToken cancellationToken) { - if (fadeDuration is < 0 or > 60) + if (fadeDuration is < 0 or > 19) { - return _controller.ReportError("Fade 값이 올바르지 않습니다."); + return _controller.ReportError("DissolveTime 값이 올바르지 않습니다."); } if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false)) @@ -251,13 +276,13 @@ public sealed partial class MainWindow { if (!CanChangeComposition()) { - return _controller.ReportError("Fade는 IDLE 상태에서만 변경할 수 있습니다."); + return _controller.ReportError("DissolveTime은 IDLE 상태에서만 변경할 수 있습니다."); } var source = _compositionOptions!; source.Update(source.Current with { FadeDuration = fadeDuration }); return _controller.ReportInformation( - $"Fade {fadeDuration}을 다음 PREPARE부터 적용합니다."); + $"DissolveTime {fadeDuration + 1}을 다음 PREPARE부터 적용합니다."); } finally { @@ -265,6 +290,14 @@ public sealed partial class MainWindow } } + private LegacyOperatorSnapshot SelectPlaylistBoundaryAndStopRefresh(bool last) + { + // MainForm.ProcessCmdKey stopped timer1 before applying either Home or End. + // Ordinary row selection deliberately does not use this path. + StopRefreshLoop(); + return _controller.SelectPlaylistBoundary(last); + } + private async Task ToggleBackgroundAsync( CancellationToken cancellationToken) { @@ -390,6 +423,7 @@ public sealed partial class MainWindow Volatile.Read(ref _playoutBusy) == 0 && Volatile.Read(ref _playoutQuarantined) == 0 && !status.IsPlayCompletionPending && + !status.IsTakeOutCompletionPending && string.IsNullOrWhiteSpace(status.PreparedSceneName) && string.IsNullOrWhiteSpace(status.OnAirSceneName) && string.IsNullOrWhiteSpace(workflow.PreparedCutCode) && @@ -413,6 +447,7 @@ public sealed partial class MainWindow Volatile.Read(ref _playoutShutdownStarted) == 0 && status.State != PlayoutConnectionState.OutcomeUnknown && !status.IsPlayCompletionPending && + !status.IsTakeOutCompletionPending && string.IsNullOrWhiteSpace(status.PreparedSceneName) && string.IsNullOrWhiteSpace(status.OnAirSceneName) && string.IsNullOrWhiteSpace(workflow.PreparedCutCode) && @@ -421,6 +456,9 @@ public sealed partial class MainWindow } private static bool IsOperatorMutationIntent(LegacyUiIntent intent) => intent is + LegacyCutPointerDownIntent or + LegacyCutKeyDownIntent or + LegacyClearCutSelectionIntent or LegacyDropSelectedCutsIntent or LegacySetPlaylistEnabledIntent or LegacySetAllPlaylistEnabledIntent or @@ -521,7 +559,7 @@ public sealed partial class MainWindow : LegacyOperatorPlayoutPhase.Idle; var backgroundEnabled = composition.BackgroundKind != LegacySceneBackgroundKind.None; - return new LegacyOperatorPlayoutSnapshot( + var snapshot = new LegacyOperatorPlayoutSnapshot( status?.Mode ?? PlayoutMode.Disabled, quarantined ? PlayoutConnectionState.OutcomeUnknown @@ -563,6 +601,10 @@ public sealed partial class MainWindow : _playoutInitializationWarning ?? status?.Message ?? _playoutInitializationError ?? "송출 어댑터를 사용할 수 없습니다."); + return snapshot with + { + IsTakeOutCompletionPending = status?.IsTakeOutCompletionPending ?? false + }; } private void StartRefreshLoop(LegacyPlayoutWorkflow workflow) @@ -599,6 +641,14 @@ public sealed partial class MainWindow } } + private void ResetRefreshState() + { + StopRefreshLoop(); + Interlocked.Exchange(ref _refreshCompletedCount, 0); + _refreshLimitReached = false; + _refreshMessage = string.Empty; + } + private async Task RunRefreshLoopAsync( LegacyPlayoutWorkflow workflow, TimeSpan firstInterval, @@ -809,7 +859,8 @@ public sealed partial class MainWindow if (Volatile.Read(ref _playoutQuarantined) == 0 && engine.Status.State != PlayoutConnectionState.OutcomeUnknown && string.IsNullOrWhiteSpace(engine.Status.OnAirSceneName) && - !engine.Status.IsPlayCompletionPending) + !engine.Status.IsPlayCompletionPending && + !engine.Status.IsTakeOutCompletionPending) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await engine.DisconnectAsync(timeout.Token).ConfigureAwait(false); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs index 0a422b7..abd5a6c 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs @@ -24,6 +24,18 @@ public sealed partial class MainWindow : Window { InitializeComponent(); EnsureCloseConfirmationAttached(); + var localApplicationData = Environment.GetFolderPath( + Environment.SpecialFolder.LocalApplicationData); + var runtimeCutMenuOverridePath = string.IsNullOrWhiteSpace(localApplicationData) + ? null + : Path.Combine( + localApplicationData, + "MBN_STOCK_WEBVIEW", + "Res", + "종목.ini"); + var cutMenuComposition = LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates( + runtimeCutMenuOverridePath, + AppContext.BaseDirectory); ILegacyStockLookup stockLookup; IIndustrySelectionService industrySelectionService; @@ -153,11 +165,16 @@ public sealed partial class MainWindow : Window expertCatalogPersistenceService, manualFinancialStockSearchService, stockMasterIdentityValidationService, - operatorCatalogSchemaValidationService)); + operatorCatalogSchemaValidationService), + cutRows: cutMenuComposition.Rows); if (initializationError is not null) { _controller.ReportError(initializationError); } + else if (cutMenuComposition.WarningMessage is { } cutMenuWarning) + { + _controller.ReportWarning(cutMenuWarning); + } InitializePlayoutRuntime(); @@ -470,6 +487,11 @@ public sealed partial class MainWindow : Window pointer.X, pointer.Y, pointer.TimestampMilliseconds), + LegacyCutKeyDownIntent key => _controller.CutKeyDown( + key.Key, + key.Control, + key.Shift), + LegacyClearCutSelectionIntent => _controller.ClearCutSelection(), LegacyDropSelectedCutsIntent => _controller.DropSelectedCutsOnPlaylist(), LegacySetPlaylistEnabledIntent enabled => _controller.SetPlaylistRowEnabled(enabled.RowId, enabled.Enabled), @@ -480,6 +502,8 @@ public sealed partial class MainWindow : Window selection.RowId, selection.Control, selection.Shift), + LegacyActivatePlaylistRowIntent activation => + _controller.ActivatePlaylistRow(activation.RowId), LegacyMoveSelectedPlaylistRowsIntent move => _controller.MoveSelectedPlaylistRows(move.Direction), LegacyReorderPlaylistRowsIntent reorder => @@ -494,7 +518,7 @@ public sealed partial class MainWindow : Window LegacyToggleActivePlaylistEnabledIntent => _controller.ToggleActivePlaylistRowEnabled(), LegacySelectPlaylistBoundaryIntent boundary => - _controller.SelectPlaylistBoundary(boundary.Last), + SelectPlaylistBoundaryAndStopRefresh(boundary.Last), LegacySelectTabIntent tab => await _controller.SelectTabAsync(tab.TabId, cancellationToken), LegacySwapTabsIntent tabs => diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js index 07f87be..5cb5bb4 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js @@ -96,12 +96,194 @@ let deferredPlaylistPlainSelectionRowId = null; let playlistDragBlockedRowId = null; let playlistMutationLocked = false; + let cutSelectionEnabled = false; + let lastPresentedPlayoutRowKey = ""; let draggedTabId = null; let tabDragLastTargetId = null; let pendingTabHoverSwap = null; let draggedOperatorCatalogRowId = null; let operatorCatalogDragBlockedRowId = null; let operatorCatalogMutationLocked = false; + let renderedTreeTabId = null; + let treeInteractionLocked = false; + const treeUiStateByTab = new Map(); + const pendingTreeResetTabs = new Set(); + + function activeTreeTabId(state) { + if (!state || !Array.isArray(state.tabs)) return null; + const active = state.tabs.find(function (tab) { return tab.isActive === true; }); + return active ? active.id : null; + } + + function treeSectionIdentity(summary, index) { + if (summary.dataset.sectionIndex != null) { + return "fixed:" + summary.dataset.sectionIndex; + } + if (summary.dataset.industrySectionIndex != null) { + return "industry:" + summary.dataset.industrySectionIndex; + } + if (summary.dataset.comparisonSectionIndex != null) { + return "comparison:" + summary.dataset.comparisonSectionIndex; + } + return "section:" + index + ":" + (summary.textContent || "").trim(); + } + + function treeLeafIdentity(button, index) { + return button.dataset.actionId || + button.dataset.industryActionId || + button.dataset.comparisonActionId || + button.dataset.overseasActionId || + ("leaf:" + index + ":" + (button.textContent || "").trim()); + } + + function syncTreeSectionExpanded(details) { + const summary = details.querySelector(":scope > summary[data-tree-root]"); + if (summary) summary.setAttribute("aria-expanded", details.open ? "true" : "false"); + } + + function decorateTreeNodes(tabId) { + if (!tabId) return; + categoryTree.querySelectorAll("details").forEach(function (details, sectionIndex) { + const summary = details.querySelector(":scope > summary"); + if (!summary) return; + const sectionKey = tabId + "|" + treeSectionIdentity(summary, sectionIndex); + details.dataset.treeSectionKey = sectionKey; + summary.dataset.treeRoot = "true"; + summary.dataset.treeNodeKey = "root|" + sectionKey; + summary.setAttribute("role", "treeitem"); + summary.setAttribute("aria-selected", "false"); + summary.setAttribute("aria-disabled", treeInteractionLocked ? "true" : "false"); + + if (!summary.querySelector("[data-tree-disclosure]")) { + const labelText = summary.textContent || ""; + const disclosure = document.createElement("span"); + disclosure.className = "tree-disclosure"; + disclosure.dataset.treeDisclosure = "true"; + disclosure.setAttribute("aria-hidden", "true"); + const label = document.createElement("span"); + label.className = "tree-root-label"; + label.dataset.treeRootLabel = "true"; + label.textContent = labelText; + summary.replaceChildren(disclosure, label); + } + + details.querySelectorAll(".tree-item").forEach(function (button, leafIndex) { + button.dataset.treeNodeKey = "leaf|" + sectionKey + "|" + + treeLeafIdentity(button, leafIndex); + button.setAttribute("role", "treeitem"); + if (!button.classList.contains("tree-node-selected")) { + button.setAttribute("aria-selected", "false"); + } + }); + syncTreeSectionExpanded(details); + }); + } + + function findTreeNodeByKey(key) { + if (!key) return null; + return Array.from(categoryTree.querySelectorAll("[data-tree-node-key]")).find( + function (node) { return node.dataset.treeNodeKey === key; }) || null; + } + + function selectTreeNode(node, focus) { + if (!node || !renderedTreeTabId) return; + categoryTree.querySelectorAll("[data-tree-node-key].tree-node-selected") + .forEach(function (selected) { + selected.classList.remove("tree-node-selected"); + selected.setAttribute("aria-selected", "false"); + }); + node.classList.add("tree-node-selected"); + node.setAttribute("aria-selected", "true"); + const previous = treeUiStateByTab.get(renderedTreeTabId) || {}; + treeUiStateByTab.set(renderedTreeTabId, Object.assign({}, previous, { + selectedKey: node.dataset.treeNodeKey + })); + if (focus !== false && typeof node.focus === "function") { + node.focus({ preventScroll: true }); + } + } + + function captureTreeUiState(tabId) { + if (!tabId) return; + decorateTreeNodes(tabId); + const previous = treeUiStateByTab.get(tabId) || {}; + const openSections = new Map(); + categoryTree.querySelectorAll("details[data-tree-section-key]") + .forEach(function (details) { + openSections.set(details.dataset.treeSectionKey, details.open === true); + }); + const selected = categoryTree.querySelector( + "[data-tree-node-key].tree-node-selected"); + const active = categoryTree.contains(document.activeElement) + ? document.activeElement.closest("[data-tree-node-key]") + : null; + treeUiStateByTab.set(tabId, { + openSections: openSections, + selectedKey: selected ? selected.dataset.treeNodeKey : previous.selectedKey || null, + focusKey: active ? active.dataset.treeNodeKey : null, + scrollTop: categoryTree.scrollTop + }); + } + + function beginTreeRender(state) { + const nextTabId = activeTreeTabId(state); + if (renderedTreeTabId) captureTreeUiState(renderedTreeTabId); + if (nextTabId !== renderedTreeTabId) { + renderedTreeTabId = nextTabId; + pendingTreeResetTabs.clear(); + if (nextTabId) pendingTreeResetTabs.add(nextTabId); + if (nextTabId) treeUiStateByTab.delete(nextTabId); + catalogExpandAll.checked = true; + } + treeInteractionLocked = state.isBusy === true || state.fixedSectionBatch != null; + return nextTabId; + } + + function finishTreeRender(tabId) { + if (!tabId) return; + decorateTreeNodes(tabId); + const details = Array.from(categoryTree.querySelectorAll("details[data-tree-section-key]")); + const roots = Array.from(categoryTree.querySelectorAll("summary[data-tree-root]")); + if (pendingTreeResetTabs.has(tabId) && roots.length > 0) { + details.forEach(function (section) { + section.open = true; + syncTreeSectionExpanded(section); + }); + const firstRoot = roots[0]; + selectTreeNode(firstRoot, true); + if (typeof firstRoot.scrollIntoView === "function") { + firstRoot.scrollIntoView({ block: "nearest", inline: "nearest" }); + } + treeUiStateByTab.set(tabId, { + openSections: new Map(details.map(function (section) { + return [section.dataset.treeSectionKey, true]; + })), + selectedKey: firstRoot.dataset.treeNodeKey, + focusKey: firstRoot.dataset.treeNodeKey, + scrollTop: categoryTree.scrollTop + }); + pendingTreeResetTabs.delete(tabId); + return; + } + + const saved = treeUiStateByTab.get(tabId); + if (!saved) return; + if (saved.openSections instanceof Map) { + details.forEach(function (section) { + if (saved.openSections.has(section.dataset.treeSectionKey)) { + section.open = saved.openSections.get(section.dataset.treeSectionKey) === true; + } + syncTreeSectionExpanded(section); + }); + } + const selected = findTreeNodeByKey(saved.selectedKey); + if (selected) selectTreeNode(selected, false); + const focused = findTreeNodeByKey(saved.focusKey); + if (focused && typeof focused.focus === "function") { + focused.focus({ preventScroll: true }); + } + if (Number.isFinite(saved.scrollTop)) categoryTree.scrollTop = saved.scrollTop; + } function send(type, payload) { if (webview && !uiBusy) webview.postMessage({ type: type, payload: payload || {} }); @@ -111,7 +293,8 @@ const playout = state && state.playout; return !state || state.isBusy === true || state.fixedSectionBatch != null || (playout && (playout.isBusy === true || playout.outcomeUnknown === true || - playout.isPlayCompletionPending === true || playout.phase !== "idle")); + playout.isPlayCompletionPending === true || + playout.isTakeOutCompletionPending === true || playout.phase !== "idle")); } function dragDropPosition(event, element) { @@ -119,22 +302,162 @@ return event.clientY < bounds.top + (bounds.height / 2) ? "before" : "after"; } + function playlistPlayoutPresentation(state, row, position) { + const playout = state && state.playout; + const phase = playout && typeof playout.phase === "string" + ? playout.phase : "idle"; + const hasActiveScene = phase === "prepared" || phase === "program"; + const currentEntryId = playout && typeof playout.currentEntryId === "string" && + playout.currentEntryId.length > 0 ? playout.currentEntryId : null; + const currentIndex = playout && Number.isInteger(playout.currentCueIndexZeroBased) + ? playout.currentCueIndexZeroBased : -1; + const isCurrent = hasActiveScene && (currentEntryId !== null + ? currentEntryId === row.rowId + : currentIndex === position); + const pageCount = isCurrent && Number.isInteger(playout.pageCount) && + playout.pageCount > 0 ? playout.pageCount : 0; + const pageIndex = isCurrent && Number.isInteger(playout.pageIndexZeroBased) && + playout.pageIndexZeroBased >= 0 && playout.pageIndexZeroBased < pageCount + ? playout.pageIndexZeroBased : -1; + + return { + isSelected: row.isSelected === true, + isCurrent: isCurrent, + isOnAir: isCurrent && phase === "program", + pageText: pageIndex >= 0 + ? String(pageIndex + 1) + "/" + String(pageCount) + : row.pageText, + focusKey: isCurrent + ? [phase, currentEntryId || row.rowId || currentIndex, pageIndex, pageCount].join(":") + : "" + }; + } + + function presentCurrentPlaylistRow(element, focusKey) { + if (!element || !focusKey) { + lastPresentedPlayoutRowKey = ""; + return; + } + if (lastPresentedPlayoutRowKey === focusKey) return; + + lastPresentedPlayoutRowKey = focusKey; + element.focus({ preventScroll: true }); + if (typeof element.scrollIntoView === "function") { + element.scrollIntoView({ block: "nearest", inline: "nearest" }); + } + } + + function selectPlaylistBoundary(boundary) { + const rows = playlistRows.querySelectorAll(".playlist-data-row"); + const element = boundary === "last" + ? rows.item(rows.length - 1) + : rows.item(0); + if (!element) return; + + // MainForm's KeyPreview changed the playlist selection but returned the + // base key result. Keep the textbox/ListBox caret and focus where the + // operator pressed Home/End; only mirror the playlist scroll/selection. + if (typeof element.scrollIntoView === "function") { + element.scrollIntoView({ block: "nearest", inline: "nearest" }); + } + send("select-playlist-boundary", { boundary: boundary }); + } + function clearDelayedClick(timer) { if (timer !== null) window.clearTimeout(timer); } + function stockResultPageOffset(button) { + const itemHeight = button && Number.isFinite(button.offsetHeight) + ? button.offsetHeight + : 0; + const viewportHeight = Number.isFinite(stockResults.clientHeight) + ? stockResults.clientHeight + : 0; + if (itemHeight <= 0 || viewportHeight <= 0) return 9; + + // The native 174 px ListBox exposes ten 17 px rows and PageUp/PageDown + // retain one boundary row, so its selection moves by visibleRows - 1. + const visibleRows = Math.max(1, Math.floor(viewportHeight / itemHeight)); + return Math.max(1, visibleRows - 1); + } + + function stockResultKeyTarget(results, key) { + if (!results || results.length === 0) return null; + + let selectedPosition = -1; + for (let position = 0; position < results.length; position += 1) { + if (results.item(position).getAttribute("aria-selected") === "true") { + selectedPosition = position; + break; + } + } + + const pageOffset = stockResultPageOffset(results.item(0)); + let targetPosition; + if (key === "End") { + targetPosition = results.length - 1; + } else if (key === "Home") { + targetPosition = 0; + } else if (key === "ArrowUp" || key === "PageUp") { + const offset = key === "ArrowUp" ? 1 : pageOffset; + targetPosition = selectedPosition < 0 ? 0 : selectedPosition - offset; + } else if (key === "ArrowDown" || key === "PageDown") { + const offset = key === "ArrowDown" ? 1 : pageOffset; + targetPosition = selectedPosition < 0 + ? (key === "PageDown" ? offset : 0) + : selectedPosition + offset; + } else { + return null; + } + + return results.item(Math.max(0, Math.min(results.length - 1, targetPosition))); + } + + function selectStockResult(button, focus) { + if (!button) return; + const results = stockResults.querySelectorAll("button[data-result-index]"); + const selectionChanged = button.getAttribute("aria-selected") !== "true"; + for (let position = 0; position < results.length; position += 1) { + const result = results.item(position); + const isSelected = result === button; + result.tabIndex = isSelected ? 0 : -1; + result.classList.toggle("selected", isSelected); + result.setAttribute("aria-selected", isSelected ? "true" : "false"); + } + if (focus === true) { + button.focus({ preventScroll: true }); + if (typeof button.scrollIntoView === "function") { + button.scrollIntoView({ block: "nearest", inline: "nearest" }); + } + } + if (selectionChanged) { + send("select-stock", { resultIndex: Number(button.dataset.resultIndex) }); + } + } + function createStockResultElement(row) { const button = document.createElement("button"); button.type = "button"; button.dataset.resultIndex = String(row.index); button.setAttribute("role", "option"); button.addEventListener("click", function () { - send("select-stock", { resultIndex: Number(button.dataset.resultIndex) }); + selectStockResult(button, false); + }); + button.addEventListener("keydown", function (event) { + const results = stockResults.querySelectorAll("button[data-result-index]"); + const target = stockResultKeyTarget(results, event.key); + if (!target) return; + // Consume only the native ListBox navigation behavior. Home/End continue + // bubbling to MainForm's KeyPreview-equivalent playlist boundary handler. + event.preventDefault(); + selectStockResult(target, true); }); return button; } function renderStockResults(state) { + let hasSelectedResult = false; state.searchResults.forEach(function (row, position) { let button = stockResults.children.item(position); if (!button || button.dataset.resultIndex !== String(row.index)) { @@ -146,16 +469,23 @@ } } - button.className = "stock-result" + - (state.selectedStockIndex === row.index ? " selected" : ""); + const isSelected = state.selectedStockIndex === row.index; + if (isSelected) hasSelectedResult = true; + button.className = "stock-result" + (isSelected ? " selected" : ""); button.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false"); button.textContent = row.displayName; - button.setAttribute("aria-selected", state.selectedStockIndex === row.index ? "true" : "false"); + button.setAttribute("aria-selected", isSelected ? "true" : "false"); + button.tabIndex = isSelected ? 0 : -1; }); while (stockResults.children.length > state.searchResults.length) { stockResults.lastElementChild.remove(); } + + // A WinForms ListBox is one tab stop even before it has a selection. + if (!hasSelectedResult && stockResults.children.length > 0) { + stockResults.children.item(0).tabIndex = 0; + } } function pointerPosition(event) { @@ -166,10 +496,43 @@ }; } + function legacyCutKey(key) { + return { + ArrowUp: "arrow-up", + ArrowDown: "arrow-down", + Home: "home", + End: "end", + " ": "space" + }[key] || null; + } + + function focusCutElement(element, scroll) { + if (!element) return; + Array.from(cutList.children).forEach(function (row) { + row.tabIndex = row === element ? 0 : -1; + }); + element.focus({ preventScroll: true }); + if (scroll && typeof element.scrollIntoView === "function") { + element.scrollIntoView({ block: "nearest", inline: "nearest" }); + } + } + + function cutKeyboardTarget(element, key) { + const rows = Array.from(cutList.children); + const index = rows.indexOf(element); + if (index < 0 || rows.length === 0) return null; + if (key === "ArrowUp") return rows[Math.max(0, index - 1)]; + if (key === "ArrowDown") return rows[Math.min(rows.length - 1, index + 1)]; + if (key === "Home") return rows[0]; + if (key === "End") return rows[rows.length - 1]; + return element; + } + function createCutElement(row) { const element = document.createElement("div"); element.setAttribute("role", "option"); element.dataset.physicalIndex = String(row.physicalIndex); + element.tabIndex = -1; element.draggable = true; const number = document.createElement("span"); @@ -180,6 +543,9 @@ element.append(number, label); element.addEventListener("pointerdown", function (event) { + if (playlistMutationLocked) return event.preventDefault(); + if (!cutSelectionEnabled) return event.preventDefault(); + focusCutElement(element, false); const position = pointerPosition(event); send("cut-pointer-down", { physicalIndex: row.physicalIndex, @@ -190,16 +556,42 @@ timestampMilliseconds: Math.max(0, Math.round(event.timeStamp)) }); }); + element.addEventListener("keydown", function (event) { + const key = legacyCutKey(event.key); + if (!key) return; + // The native ListView consumes these keys. Home/End still bubble to the + // document-level MainForm KeyPreview equivalent for its playlist side effect. + event.preventDefault(); + if (playlistMutationLocked) return; + if (!cutSelectionEnabled) return; + const target = cutKeyboardTarget(element, event.key); + focusCutElement(target, true); + send("cut-key-down", { + key: key, + control: event.ctrlKey, + shift: event.shiftKey + }); + }); element.addEventListener("dragstart", function (event) { - if (event.dataTransfer) { - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("text/plain", selectedCutsDragToken); + if (playlistMutationLocked || !event.dataTransfer) { + event.preventDefault(); + return; } + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", selectedCutsDragToken); }); return element; } function renderCuts(state) { + const cutDragLocked = isOperatorMutationLocked(state); + cutSelectionEnabled = Number.isInteger(state.selectedStockIndex) && !cutDragLocked; + const focusedCutIndex = state.cutRows.findIndex(function (row) { + return row.isFocused === true; + }); + // renderCuts precedes renderPlaylist, so publish the same lock before a + // cut row can initiate an append drag for this state revision. + playlistMutationLocked = cutDragLocked; state.cutRows.forEach(function (row, index) { let element = cutList.children.item(index); if (!element || element.dataset.physicalIndex !== String(row.physicalIndex)) { @@ -216,7 +608,10 @@ (row.isSeparator ? " separator" : ""); element.setAttribute("aria-selected", row.isSelected ? "true" : "false"); element.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false"); - element.draggable = state.isBusy !== true; + element.tabIndex = row.isFocused === true || (focusedCutIndex < 0 && index === 0) + ? 0 + : -1; + element.draggable = !cutDragLocked; }); while (cutList.children.length > state.cutRows.length) { @@ -224,6 +619,11 @@ } } + cutList.addEventListener("pointerdown", function (event) { + if (event.target !== cutList || playlistMutationLocked) return; + send("clear-cut-selection", {}); + }); + function clearPlaylistDragVisuals() { playlistRows.querySelectorAll(".dragging, .drag-before, .drag-after") .forEach(function (row) { @@ -312,7 +712,12 @@ enabledCell.className = "enabled-cell"; const enabled = document.createElement("input"); enabled.type = "checkbox"; + enabled.tabIndex = -1; + enabled.addEventListener("click", function (event) { + if (playlistMutationLocked) event.preventDefault(); + }); enabled.addEventListener("change", function () { + if (playlistMutationLocked) return; send("set-playlist-enabled", { rowId: element.dataset.rowId, enabled: enabled.checked @@ -332,15 +737,17 @@ // not necessarily the checkbox originally pressed. Remember the // pointer origin so a checkbox gesture cannot reorder the row. playlistDragBlockedRowId = element.dataset.rowId; + element.focus({ preventScroll: true }); + send("activate-playlist-row", { rowId: element.dataset.rowId }); return; } playlistDragBlockedRowId = null; element.focus({ preventScroll: true }); const plainSelection = !event.ctrlKey && !event.shiftKey; - const preserveSelectedBlock = plainSelection && + const deferSelectedRowPlainClickForDrag = plainSelection && element.getAttribute("aria-selected") === "true" && playlistRows.querySelectorAll('.playlist-data-row[aria-selected="true"]').length > 1; - if (preserveSelectedBlock) { + if (deferSelectedRowPlainClickForDrag) { deferredPlaylistPlainSelectionRowId = element.dataset.rowId; return; } @@ -388,11 +795,14 @@ clearPlaylistDragVisuals(); } const existingRows = new Map(); + let currentPlayoutRow = null; + let currentPlayoutFocusKey = ""; Array.from(playlistRows.children).forEach(function (element) { existingRows.set(element.dataset.rowId, element); }); state.playlist.forEach(function (row, position) { + const presentation = playlistPlayoutPresentation(state, row, position); let element = existingRows.get(row.rowId); if (!element) { element = createPlaylistElement(row); @@ -405,27 +815,41 @@ const enabled = element.querySelector("input"); enabled.checked = row.isEnabled; - enabled.disabled = state.isBusy === true; - enabled.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false"); + enabled.disabled = false; + enabled.setAttribute("aria-disabled", playlistMutationLocked ? "true" : "false"); + const isCandidate = row.isActive === true; element.className = "playlist-row playlist-data-row" + - (row.isSelected ? " selected" : ""); - element.setAttribute("aria-selected", row.isSelected ? "true" : "false"); + (presentation.isOnAir ? " on-air" : "") + + (presentation.isCurrent ? " playout-current" : "") + + (presentation.isSelected || isCandidate ? " selected" : ""); + element.dataset.active = isCandidate ? "true" : "false"; + element.setAttribute( + "aria-selected", + presentation.isSelected ? "true" : "false"); + element.setAttribute( + "aria-current", + presentation.isCurrent ? "true" : "false"); element.draggable = !playlistMutationLocked; element.setAttribute("aria-disabled", playlistMutationLocked ? "true" : "false"); - [row.marketText, row.stockName, row.graphicType, row.subtype, row.pageText] + [row.marketText, row.stockName, row.graphicType, row.subtype, presentation.pageText] .forEach(function (text, cellIndex) { const cell = element.children.item(cellIndex + 1); cell.textContent = text; cell.title = text; }); + if (presentation.isCurrent) { + currentPlayoutRow = element; + currentPlayoutFocusKey = presentation.focusKey; + } }); const retainedRowIds = new Set(state.playlist.map(function (row) { return row.rowId; })); Array.from(playlistRows.children).forEach(function (element) { if (!retainedRowIds.has(element.dataset.rowId)) element.remove(); }); + presentCurrentPlaylistRow(currentPlayoutRow, currentPlayoutFocusKey); - const interactionDisabled = state.isBusy === true; + const interactionDisabled = playlistMutationLocked; const enabledCount = state.playlist.filter(function (row) { return row.isEnabled; }).length; playlistEnableAll.checked = state.playlist.length === 0 || enabledCount === state.playlist.length; playlistEnableAll.indeterminate = enabledCount > 0 && enabledCount < state.playlist.length; @@ -457,14 +881,69 @@ } } + function hasCrossedLegacyTabSwapThreshold( + source, + target, + clientX, + sourceIndex, + targetIndex + ) { + if (!source || !target || !Number.isFinite(clientX) || + sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return false; + + const sourceRect = source.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + if (sourceRect.width < targetRect.width) { + // MainForm compensates for the narrow source width so merely entering a + // wider target cannot immediately swap it. Its comparisons are strict. + return sourceIndex < targetIndex + ? clientX > targetRect.left + targetRect.width - sourceRect.width + : clientX < targetRect.left + sourceRect.width; + } + + // An equal-width or wider source swaps as soon as it enters another tab. + return true; + } + + function onMarketTabDragOver(event) { + const target = event.target.closest("button[data-tab-id]"); + if (!target || !draggedTabId) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; + const targetId = target.dataset.tabId; + if (targetId === draggedTabId) { + tabDragLastTargetId = null; + return; + } + if (tabDragLastTargetId === targetId || pendingTabHoverSwap) return; + + const buttons = Array.from( + marketTabs.querySelectorAll("button[data-tab-id]")); + const source = buttons.find(function (button) { + return button.dataset.tabId === draggedTabId; + }); + const sourceIndex = buttons.indexOf(source); + const targetIndex = buttons.indexOf(target); + if (!hasCrossedLegacyTabSwapThreshold( + source, + target, + event.clientX, + sourceIndex, + targetIndex)) return; + + // Record the crossing only when the busy/pending gates actually accepted + // the opaque native intent. Repeated dragover events then cannot toggle it. + if (requestTabHoverSwap(targetId)) tabDragLastTargetId = targetId; + } + function requestTabHoverSwap(targetId) { if (uiBusy || !draggedTabId || !targetId || draggedTabId === targetId || - pendingTabHoverSwap) return; + pendingTabHoverSwap) return false; const order = tabOrderFromDom(); const sourceIndex = order.indexOf(draggedTabId); const targetIndex = order.indexOf(targetId); - if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return; + if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return false; const expectedOrder = order.slice(); expectedOrder[sourceIndex] = targetId; @@ -487,6 +966,7 @@ expectedSourceIndex: sourceIndex, expectedTargetIndex: targetIndex }); + return true; } function renderTabs(state) { @@ -878,6 +1358,7 @@ } const button = document.createElement("button"); button.type = "button"; + button.className = "tree-item" + (action.isAvailable ? "" : " unavailable"); button.dataset.comparisonActionId = action.actionId; button.textContent = action.label; button.disabled = !action.isAvailable; @@ -2040,6 +2521,7 @@ function render(state) { const isBusy = state.isBusy === true; + const treeTabId = beginTreeRender(state); uiBusy = isBusy; document.body.classList.toggle("busy", isBusy); document.body.setAttribute("aria-busy", isBusy ? "true" : "false"); @@ -2053,7 +2535,7 @@ movingAverage20.checked = state.movingAverages ? state.movingAverages.ma20 : true; movingAverage5.disabled = isBusy; movingAverage20.disabled = isBusy; - catalogExpandAll.disabled = isBusy; + catalogExpandAll.disabled = treeInteractionLocked; renderFixedCatalog(state); renderIndustry(state); renderOverseas(state); @@ -2061,6 +2543,7 @@ renderTheme(state); renderExpert(state); renderTradingHalt(state); + finishTreeRender(treeTabId); renderManualFinancial(state); renderManualLists(state); renderOperatorCatalog(state); @@ -2493,12 +2976,13 @@ // A playlist row also drops inside this ancestor. Only advertise the // drop zone for the cut-list payload; otherwise the bubbled row drop // would append the still-selected cut as an extra playlist entry. - if (!hasDragType(event.dataTransfer, "text/plain")) return; + if (playlistMutationLocked || + !hasDragType(event.dataTransfer, "text/plain")) return; event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; }); playlistDropZone.addEventListener("drop", function (event) { - if (!isSelectedCutsDrop(event.dataTransfer)) return; + if (playlistMutationLocked || !isSelectedCutsDrop(event.dataTransfer)) return; event.preventDefault(); event.stopPropagation(); send("drop-selected-cuts", {}); @@ -2538,21 +3022,7 @@ event.dataTransfer.setData("text/x-mbn-tab", draggedTabId); button.classList.add("dragging"); }); - marketTabs.addEventListener("dragover", function (event) { - const target = event.target.closest("button[data-tab-id]"); - if (!target || !draggedTabId) return; - event.preventDefault(); - if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; - const targetId = target.dataset.tabId; - if (targetId === draggedTabId) { - tabDragLastTargetId = null; - return; - } - if (tabDragLastTargetId === targetId) return; - if (pendingTabHoverSwap) return; - tabDragLastTargetId = targetId; - requestTabHoverSwap(targetId); - }); + marketTabs.addEventListener("dragover", onMarketTabDragOver); marketTabs.addEventListener("drop", function (event) { const target = event.target.closest("button[data-tab-id]"); if (!target || !event.dataTransfer || !draggedTabId) return; @@ -2578,11 +3048,28 @@ movingAverage5.addEventListener("change", sendMovingAverages); movingAverage20.addEventListener("change", sendMovingAverages); catalogExpandAll.addEventListener("change", function () { + if (treeInteractionLocked) { + const sections = Array.from(categoryTree.querySelectorAll("details")); + catalogExpandAll.checked = sections.length > 0 && sections.every(function (details) { + return details.open === true; + }); + return; + } categoryTree.querySelectorAll("details").forEach(function (details) { details.open = catalogExpandAll.checked; + syncTreeSectionExpanded(details); }); }); categoryTree.addEventListener("dblclick", function (event) { + const treeNode = event.target.closest("[data-tree-node-key]"); + if (treeNode) { + event.preventDefault(); + if (treeInteractionLocked || treeNode.disabled === true || + treeNode.getAttribute("aria-disabled") === "true") return; + selectTreeNode(treeNode, true); + // The disclosure is a separate expand/collapse command, not a node action. + if (event.target.closest("[data-tree-disclosure]")) return; + } const themeResult = event.target.closest("button[data-theme-result-id]"); if (themeResult && !themeResult.disabled) { event.preventDefault(); @@ -2663,6 +3150,28 @@ } }); categoryTree.addEventListener("click", function (event) { + const treeRoot = event.target.closest("summary[data-tree-root]"); + if (treeRoot) { + // A native TreeView selects on its label and expands only from the glyph. + // Cancel 's whole-row default toggle to preserve that split. + event.preventDefault(); + if (treeInteractionLocked || treeRoot.getAttribute("aria-disabled") === "true") return; + selectTreeNode(treeRoot, true); + if (event.target.closest("[data-tree-disclosure]") && (event.detail || 1) < 2) { + const details = treeRoot.parentElement; + details.open = !details.open; + syncTreeSectionExpanded(details); + } + return; + } + const treeLeaf = event.target.closest(".tree-item[data-tree-node-key]"); + if (treeLeaf) { + if (treeInteractionLocked || treeLeaf.disabled === true) { + event.preventDefault(); + return; + } + selectTreeNode(treeLeaf, true); + } const uc4Catalog = event.target.closest("button[data-uc4-theme-catalog]"); if (uc4Catalog && !uc4Catalog.disabled) { const action = uc4Catalog.dataset.uc4ThemeCatalog; @@ -2867,7 +3376,16 @@ } }); categoryTree.addEventListener("keydown", function (event) { - if (event.key === "Enter" && + const treeRoot = event.target.closest("summary[data-tree-root]"); + if (treeRoot && ["Enter", " ", "ArrowLeft", "ArrowRight"].includes(event.key)) { + event.preventDefault(); + if (treeInteractionLocked || treeRoot.getAttribute("aria-disabled") === "true") return; + selectTreeNode(treeRoot, true); + const details = treeRoot.parentElement; + if (event.key === "ArrowLeft") details.open = false; + else if (event.key === "ArrowRight") details.open = true; + syncTreeSectionExpanded(details); + } else if (event.key === "Enter" && (event.target.id === "overseas-industry-search" || event.target.id === "overseas-stock-search")) { event.preventDefault(); @@ -2904,20 +3422,27 @@ document.addEventListener("keydown", function (event) { if (event.key === "Delete" && !event.repeat && dialog.hidden && manualModal.hidden && manualListModal.hidden && namedPlaylistModal.hidden && operatorCatalogModal.hidden) { - event.preventDefault(); - send("delete-selected-playlist-rows", {}); + // 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 && event.target.closest(".playlist-data-row")) { event.preventDefault(); - send("toggle-active-playlist-enabled", {}); + if (!playlistMutationLocked) { + const row = event.target.closest(".playlist-data-row"); + const enabled = row.querySelector('input[type="checkbox"]'); + send("set-playlist-enabled", { + rowId: row.dataset.rowId, + enabled: enabled.checked !== true + }); + } } else if ((event.key === "Home" || event.key === "End") && !event.repeat && dialog.hidden && manualModal.hidden && manualListModal.hidden && namedPlaylistModal.hidden && operatorCatalogModal.hidden) { - event.preventDefault(); - send("select-playlist-boundary", { - boundary: event.key === "Home" ? "first" : "last" - }); + // 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(); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html index 9079185..f33dcd9 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html @@ -69,8 +69,8 @@ - - diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/playout-ui.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/playout-ui.js index 6bff5fe..918eac4 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/playout-ui.js +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/playout-ui.js @@ -62,9 +62,20 @@ function nextLabel(playout) { if (!playout || playout.nextKind === "none") return ""; + if (playout.nextKind === "endOfPlaylist") return " · 마지막 항목"; return playout.nextKind === "pageNext" ? " · 다음 페이지" : " · 다음 항목"; } + function canTakeInFromState(playout, hasPlaylist) { + return playout.phase === "prepared" || + hasPlaylist && (playout.phase === "idle" || playout.phase === "program"); + } + + function canAdvanceFromState(playout) { + return playout.phase === "program" && + playout.nextKind !== "none" && playout.nextKind !== "endOfPlaylist"; + } + function render(state) { currentState = state || currentState; const playout = currentState && currentState.playout; @@ -79,7 +90,8 @@ } const commandAvailable = playout.isCommandAvailable === true && - playout.outcomeUnknown !== true; + playout.outcomeUnknown !== true && + playout.isTakeOutCompletionPending !== true; const hasPlaylist = Array.isArray(currentState.playlist) && currentState.playlist.some(function (row) { return row.isEnabled === true; }); const liveTakeInAllowed = playout.mode !== "live" || @@ -93,15 +105,15 @@ prepare.classList.toggle("active", playout.phase !== "idle"); prepare.disabled = busy || !commandAvailable || !canPrepareToggle || playout.isPlayCompletionPending === true; - const canTakeIn = playout.phase === "prepared" || - (playout.phase === "idle" && hasPlaylist); + const canTakeIn = canTakeInFromState(playout, hasPlaylist); takeIn.disabled = busy || !commandAvailable || !liveTakeInAllowed || !canTakeIn || playout.isPlayCompletionPending === true; next.disabled = busy || !commandAvailable || - playout.phase !== "program" || playout.nextKind === "none" || + !canAdvanceFromState(playout) || playout.isPlayCompletionPending === true; - takeOut.disabled = busy || !commandAvailable || - (playout.phase !== "prepared" && playout.phase !== "program"); + // MainForm's Escape path called StopAll whenever the Tornado player existed, + // even if its local m_TakeIn flag had already drifted back to IDLE. + takeOut.disabled = busy || !commandAvailable; backgroundNone.disabled = busy || playout.canChangeBackground !== true; backgroundFile.disabled = busy || playout.canChangeBackground !== true; fadeDuration.disabled = busy || playout.canChangeBackground !== true; @@ -142,7 +154,7 @@ }); document.addEventListener("keydown", function (event) { - if (event.repeat || hasOpenDialog()) return; + if (event.defaultPrevented || event.repeat || hasOpenDialog()) 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 5b58d6f..23bc424 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css @@ -43,7 +43,7 @@ button:disabled { color: #555; opacity: 1; } .search-line button span { font-size: 21px; margin-right: 20px; } .stock-results { height: 174px; border: 1px solid #999; background: #fff; overflow-y: auto; } -.stock-result { display: block; width: 100%; height: 20px; padding: 1px 5px; border: 0; background: #fff; text-align: left; font-size: 16px; line-height: 18px; } +.stock-result { display: block; width: 100%; height: 17px; padding: 0 5px; border: 0; background: #fff; text-align: left; font-size: 16px; line-height: 17px; } .stock-result.selected { background: #696969; color: #fff; } .manual-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: 9px 0; } @@ -133,6 +133,7 @@ button:disabled { color: #555; opacity: 1; } .cut-row > span { padding: 2px 7px; pointer-events: none; } .cut-row .cut-number { border-right: 1px solid #ddd; text-align: center; color: #145ab4; } .cut-row.selected { background: #dcdcdc; } +.cut-row:focus { outline: 1px dotted #222; outline-offset: -2px; } .cut-row.separator { background: #fff; } .operator-status { min-height: 18px; padding-top: 2px; color: #a40000; font-size: 11px; } @@ -148,6 +149,16 @@ button:disabled { color: #555; opacity: 1; } .tree-root { margin: 5px 0 2px; color: #168000; font-size: 16px; } .tree-root.second { margin-top: 15px; } .catalog-section > summary { margin: 5px 0 2px; color: #168000; font-size: 16px; cursor: default; user-select: none; } +details > summary[data-tree-root] { display: flex; align-items: center; min-height: 24px; list-style: none; cursor: default; user-select: none; } +details > summary[data-tree-root]::-webkit-details-marker { display: none; } +.tree-disclosure { flex: 0 0 22px; width: 22px; text-align: center; } +.tree-disclosure::before { content: "\25B6"; font-size: 10px; } +details[open] > summary[data-tree-root] .tree-disclosure::before { content: "\25BC"; } +.tree-root-label { flex: 1; min-width: 0; } +summary[data-tree-root].tree-node-selected, +.tree-item.tree-node-selected { background: #dcdcdc; outline: none; } +summary[data-tree-root]:focus-visible { background: #dcdcdc; outline: none; } +summary[data-tree-root][aria-disabled="true"] { color: #777; } .catalog-actions { display: grid; } .tree-item { width: 100%; padding: 3px 0 3px 29px; border: 0; background: transparent; color: #111; font: inherit; font-size: 15px; text-align: left; cursor: default; } .tree-item:hover, .tree-item:focus-visible { background: #dcdcdc; outline: none; } @@ -279,7 +290,9 @@ button:disabled { color: #555; opacity: 1; } .playlist-data-row { min-height: 25px; background: #fff; border-bottom: 1px solid #ddd; } .playlist-data-row > div { padding: 3px 6px; border-right: 1px solid #ddd; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .playlist-data-row .enabled-cell { text-align: center; } -.playlist-data-row.selected { background: #dcdcdc; } +.playlist-data-row.on-air { background: #fdc8d2; } +.playlist-data-row.selected, +.playlist-data-row.on-air.selected { background: #dcdcdc; } .playlist-data-row:focus { outline: 1px dotted #333; outline-offset: -2px; } .playlist-data-row[draggable="true"] { cursor: grab; } .playlist-data-row.dragging { opacity: .55; cursor: grabbing; } diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/LegacyParityStartupCompositionResolver.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/LegacyParityStartupCompositionResolver.cs index 97d9e71..1b35df5 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/LegacyParityStartupCompositionResolver.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/LegacyParityStartupCompositionResolver.cs @@ -12,12 +12,12 @@ public sealed record LegacyParityStartupCompositionResolution( /// /// Resolves the startup-only MainForm background behavior used by LegacyParityApp. /// An explicit local file remains authoritative; only an absent file enables the -/// validated legacy 기본.vrv fallback. +/// validated legacy Cuts\video\기본.vrv fallback. /// public static class LegacyParityStartupCompositionResolver { public const string MissingLegacyDefaultWarning = - "신뢰 배경 폴더에서 기본.vrv를 확인할 수 없어 배경없음으로 시작했습니다."; + "신뢰 Cuts 경로에서 video\\기본.vrv를 확인할 수 없어 배경없음으로 시작했습니다."; public static LegacyParityStartupCompositionResolution Resolve( PlayoutOptions options, @@ -34,7 +34,7 @@ public static class LegacyParityStartupCompositionResolver try { return new LegacyParityStartupCompositionResolution( - PlayoutSceneCompositionFactory.CreateLegacyDefault( + PlayoutSceneCompositionFactory.CreateLegacyStartupDefault( options, configured.FadeDuration), string.Empty); diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptions.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptions.cs index 303d130..c843394 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptions.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptions.cs @@ -21,7 +21,7 @@ public sealed class PlayoutOptions public int LayoutIndex { get; set; } = 10; - /// MainForm ComboDi.SelectedIndex default passed to SetSceneEffectType. + /// MainForm ComboDi.SelectedIndex (0..19) passed to SetSceneEffectType. public int LegacySceneFadeDuration { get; set; } = 6; /// diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutSceneCompositionFactory.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutSceneCompositionFactory.cs index 5f4f1d0..2fc99ec 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutSceneCompositionFactory.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutSceneCompositionFactory.cs @@ -4,12 +4,14 @@ using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; namespace MBN_STOCK_WEBVIEW.Playout.Configuration; /// -/// Validates the trusted MainForm-level fade/background configuration before the -/// first DryRun or actual PREPARE. Web content never supplies these values. +/// Validates the MainForm-level fade/background composition before the first DryRun +/// or actual PREPARE. Background paths stay native/trusted; fade is restricted to the +/// original closed DissolveTime selector index (0..19). /// public static class PlayoutSceneCompositionFactory { public const string LegacyDefaultBackgroundFileName = "기본.vrv"; + public const string LegacyStartupDefaultBackgroundAssetPath = @"video\기본.vrv"; private static readonly IReadOnlySet AllowedExtensions = new HashSet( [ @@ -116,6 +118,38 @@ public static class PlayoutSceneCompositionFactory } } + /// + /// Restores the background assigned by MainForm at startup, Cuts\video\기본.vrv. + /// This is intentionally distinct from the F2/F3 default in the sibling 배경 root. + /// + public static LegacySceneCueCompositionOptions CreateLegacyStartupDefault( + PlayoutOptions options, + int fadeDuration) + { + ArgumentNullException.ThrowIfNull(options); + ValidateCommon(fadeDuration, options); + + try + { + var root = TrustedPlayoutAssetPath.ValidateRoot( + options.SceneDirectory?.Trim() ?? string.Empty); + var candidate = TrustedPlayoutAssetPath.ResolveRelativeFile( + root, + LegacyStartupDefaultBackgroundAssetPath, + AllowedExtensions); + return CreateResult( + options, + fadeDuration, + LegacySceneBackgroundKind.Video, + Path.GetRelativePath(root, candidate), + PlayoutAssetRoot.SceneDirectory); + } + catch (TrustedPlayoutAssetException) + { + throw Invalid(); + } + } + public static bool TryGetTrustedBackgroundDirectory( PlayoutOptions options, out string directory) @@ -190,17 +224,18 @@ public static class PlayoutSceneCompositionFactory PlayoutOptions options, int fadeDuration, LegacySceneBackgroundKind kind, - string relativePath) => new( + string relativePath, + PlayoutAssetRoot assetRoot = PlayoutAssetRoot.OperatorBackgroundDirectory) => new( fadeDuration, kind, relativePath, options.LegacySceneBackgroundVideoLoopCount, options.LegacySceneBackgroundVideoLoopInfinite, - PlayoutAssetRoot.OperatorBackgroundDirectory); + assetRoot); private static void ValidateCommon(int fadeDuration, PlayoutOptions options) { - if (fadeDuration is < 0 or > 60 || + if (fadeDuration is < 0 or > 19 || !Enum.IsDefined(options.LegacySceneBackgroundKind) || options.LegacySceneBackgroundVideoLoopCount is < 0 or > 10_000) { diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs index c405861..b2965ea 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs @@ -208,7 +208,7 @@ internal sealed record ValidatedPlayoutOptions( throw new PlayoutRequestException("장면 이름이 올바르지 않습니다."); } - if (cue.FadeDuration is < 0 or > 60) + if (cue.FadeDuration is < 0 or > 19) { throw new PlayoutRequestException("장면 전환 시간이 올바르지 않습니다."); } @@ -388,6 +388,14 @@ internal sealed record ValidatedPlayoutOptions( throw new PlayoutRequestException("장면 배경 영상 반복 값이 올바르지 않습니다."); } + // Installed K3D Interop declares LoopInfinite as Int32. The source + // application uses only 0, 1 (s5006), and 10 (common background). + if (video.LoopInfinite is not (0 or 1 or 10)) + { + throw new PlayoutRequestException( + "장면 배경 영상 무한 반복 값이 허용된 원본 값이 아닙니다."); + } + return video with { AssetPath = ResolveAssetPath(video.AssetPath, video.AssetRoot) diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs index 107b5ce..1d0c998 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs @@ -22,6 +22,8 @@ internal interface IK3dSession : IDisposable bool HasPendingPlayCallbacks { get; } + bool HasPendingTakeOutCallbacks { get; } + bool TryPreventKtapConnect(); void Connect(ValidatedPlayoutOptions options); @@ -230,6 +232,9 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession public bool HasPendingPlayCallbacks => SupportsLifecycleCallbacks && _pendingPlayCallbacks != 0; + public bool HasPendingTakeOutCallbacks => SupportsLifecycleCallbacks && + (_pendingCutOutCallbacks != 0 || _pendingStopAllCallbacks != 0); + public bool TryPreventKtapConnect() { var previous = Interlocked.CompareExchange(ref _ktapDispatchGate, 2, 0); @@ -855,7 +860,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession "SetBackgroundVideo", video.AssetPath, video.LoopCount, - video.LoopInfinite ? 1 : 0); + video.LoopInfinite); break; default: throw new InvalidOperationException("The scene setup mutation is not allowlisted."); diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Interop/K3dComConstants.cs b/src/MBN_STOCK_WEBVIEW.Playout/Interop/K3dComConstants.cs index 677ec4c..98915f6 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Interop/K3dComConstants.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Interop/K3dComConstants.cs @@ -15,6 +15,7 @@ public static class K3dComConstants public const string ApartmentThreadingModel = "Apartment"; public const int LegacyLayoutIndex = 10; - public const int SceneEffectInEnabled = 1; + // MainForm passes the vendor API's raw bInEffect value 10. It is not a Boolean. + public const int SceneEffectInEnabled = 10; public const int SceneChangeEffectFade = 7; } diff --git a/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs b/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs index a31ee26..73342d3 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs @@ -1204,6 +1204,18 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine return MarkOutcomeUnknown(operation); } + if (_session?.HasPendingTakeOutCallbacks == true) + { + const string pendingTakeOutMessage = + "The previous SDK CutOut/StopAll completion callback is still pending. " + + "No further scene command is allowed until Tornado confirms the result."; + PublishStatus(pendingTakeOutMessage); + return Result( + operation, + PlayoutResultCode.Unavailable, + pendingTakeOutMessage); + } + if (RequiresCompletedPlay(operation) && _session?.HasPendingPlayCallbacks == true) { @@ -1810,8 +1822,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine private PlayoutStatus BuildStatus(string message) { var connected = _session is not null && !_outcomeUnknown; - var commandAvailable = _options.Mode == PlayoutMode.DryRun || - (connected && IsProcessEligible(_processSnapshot)); + var commandAvailable = (_options.Mode == PlayoutMode.DryRun || + (connected && IsProcessEligible(_processSnapshot))) && + _session?.HasPendingTakeOutCallbacks != true; var liveAllowed = connected && IsProcessEligible(_processSnapshot) && CanTakeIn(); @@ -1830,6 +1843,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine { OperationTimeoutMilliseconds = (int)_options.OperationTimeout.TotalMilliseconds, IsPlayCompletionPending = _session?.HasPendingPlayCallbacks == true, + IsTakeOutCompletionPending = _session?.HasPendingTakeOutCallbacks == true, LastKtapConnectState = (PlayoutKtapConnectState)Volatile.Read( ref _lastKtapConnectState), KtapHelloObserved = Volatile.Read(ref _ktapHelloObservedState) switch diff --git a/tests/LegacyParityWeb/stock-result-keyboard.test.cjs b/tests/LegacyParityWeb/stock-result-keyboard.test.cjs new file mode 100644 index 0000000..b9ccd1b --- /dev/null +++ b/tests/LegacyParityWeb/stock-result-keyboard.test.cjs @@ -0,0 +1,154 @@ +"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 app = fs.readFileSync(path.join( + repositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", + "Web", + "app.js"), "utf8"); +const styles = fs.readFileSync(path.join( + repositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", + "Web", + "styles.css"), "utf8"); + +function sliceBetween(startMarker, endMarker) { + const start = app.indexOf(startMarker); + const end = app.indexOf(endMarker, start + startMarker.length); + assert.ok(start >= 0, `${startMarker} must exist`); + assert.ok(end > start, `${endMarker} must follow ${startMarker}`); + return app.slice(start, end); +} + +function list(items) { + items.item = function (index) { return items[index] || null; }; + return items; +} + +function option(index, selected) { + const attributes = new Map([["aria-selected", selected ? "true" : "false"]]); + const classes = new Set(selected ? ["selected"] : []); + return { + dataset: { resultIndex: String(index) }, + offsetHeight: 17, + tabIndex: selected ? 0 : -1, + focused: false, + scrolled: false, + classList: { + toggle(name, enabled) { + if (enabled) classes.add(name); + else classes.delete(name); + }, + contains(name) { return classes.has(name); } + }, + getAttribute(name) { return attributes.get(name) || null; }, + setAttribute(name, value) { attributes.set(name, String(value)); }, + focus() { this.focused = true; }, + scrollIntoView() { this.scrolled = true; } + }; +} + +function keyboardHarness(selectedIndex = -1, count = 30) { + const options = list(Array.from( + { length: count }, + (_, index) => option(index, index === selectedIndex))); + const stockResults = { + clientHeight: 170, + querySelectorAll() { return options; } + }; + const source = sliceBetween( + "function stockResultPageOffset", + "function selectStockResult"); + const factory = new Function("stockResults", ` + ${source} + return { stockResultPageOffset, stockResultKeyTarget }; + `); + return Object.assign({ options }, factory(stockResults)); +} + +test("stock result arrows and boundaries match the native single-select ListBox", () => { + const middle = keyboardHarness(10); + assert.equal(middle.stockResultKeyTarget(middle.options, "ArrowUp").dataset.resultIndex, "9"); + assert.equal(middle.stockResultKeyTarget(middle.options, "ArrowDown").dataset.resultIndex, "11"); + assert.equal(middle.stockResultKeyTarget(middle.options, "Home").dataset.resultIndex, "0"); + assert.equal(middle.stockResultKeyTarget(middle.options, "End").dataset.resultIndex, "29"); + + const first = keyboardHarness(0); + assert.equal(first.stockResultKeyTarget(first.options, "ArrowUp").dataset.resultIndex, "0"); + const last = keyboardHarness(29); + assert.equal(last.stockResultKeyTarget(last.options, "ArrowDown").dataset.resultIndex, "29"); + assert.equal(last.stockResultKeyTarget(last.options, "Enter"), null); +}); + +test("PageUp and PageDown move nine rows in the original ten-row viewport", () => { + const middle = keyboardHarness(13); + assert.equal(middle.stockResultPageOffset(middle.options.item(0)), 9); + assert.equal(middle.stockResultKeyTarget(middle.options, "PageUp").dataset.resultIndex, "4"); + assert.equal(middle.stockResultKeyTarget(middle.options, "PageDown").dataset.resultIndex, "22"); + + const nearStart = keyboardHarness(4); + assert.equal(nearStart.stockResultKeyTarget(nearStart.options, "PageUp").dataset.resultIndex, "0"); + const nearEnd = keyboardHarness(25); + assert.equal(nearEnd.stockResultKeyTarget(nearEnd.options, "PageDown").dataset.resultIndex, "29"); + + const none = keyboardHarness(-1); + assert.equal(none.stockResultKeyTarget(none.options, "ArrowDown").dataset.resultIndex, "0"); + assert.equal(none.stockResultKeyTarget(none.options, "PageDown").dataset.resultIndex, "9"); +}); + +test("stock selection uses one roving tab stop and preserves focused selection", () => { + const options = list([option(0, true), option(1, false), option(2, false)]); + const sent = []; + const stockResults = { querySelectorAll() { return options; } }; + const source = sliceBetween( + "function selectStockResult", + "function createStockResultElement"); + const selectStockResult = new Function("stockResults", "send", ` + ${source} + return selectStockResult; + `)(stockResults, (type, payload) => sent.push({ type, payload })); + + selectStockResult(options.item(2), true); + assert.deepEqual(options.map(row => row.tabIndex), [-1, -1, 0]); + assert.deepEqual(options.map(row => row.getAttribute("aria-selected")), + ["false", "false", "true"]); + assert.equal(options.item(2).focused, true); + assert.equal(options.item(2).scrolled, true); + assert.deepEqual(sent, [{ type: "select-stock", payload: { resultIndex: 2 } }]); + + // A second click from a browser dblclick gesture only reaffirms selection; + // it neither emits another selection transition nor appends a playlist row. + selectStockResult(options.item(2), false); + assert.equal(sent.length, 1); +}); + +test("stock option keydown consumes list navigation but lets Home and End bubble", () => { + const factory = sliceBetween( + "function createStockResultElement", + "function renderStockResults"); + for (const key of ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End"]) { + assert.match(app, new RegExp(`key === \\"${key}\\"`)); + } + assert.match(factory, /event\.preventDefault\(\)/); + assert.doesNotMatch(factory, /stopPropagation|stopImmediatePropagation/); + assert.doesNotMatch(factory, /dblclick|add-playlist|append-playlist|cut-pointer-down/); + assert.match(styles, + /\.stock-result \{[^}]*height: 17px;[^}]*line-height: 17px;/s); +}); + +test("rendering keeps exactly one tab stop, falling back to the first unselected row", () => { + const renderer = sliceBetween( + "function renderStockResults", + "function pointerPosition"); + assert.match(renderer, /button\.tabIndex = isSelected \? 0 : -1/); + assert.match(renderer, /if \(!hasSelectedResult && stockResults\.children\.length > 0\)/); + assert.match(renderer, /stockResults\.children\.item\(0\)\.tabIndex = 0/); + assert.doesNotMatch(renderer, /\.focus\(/); +}); diff --git a/tests/LegacyParityWeb/thin-client.test.cjs b/tests/LegacyParityWeb/thin-client.test.cjs index 1d77451..c2df43b 100644 --- a/tests/LegacyParityWeb/thin-client.test.cjs +++ b/tests/LegacyParityWeb/thin-client.test.cjs @@ -62,24 +62,183 @@ test("legacy stock double click is represented by pointer-down facts", () => { assert.match(app, /event\.shiftKey/); const cutFactory = app.slice(app.indexOf("function createCutElement"), app.indexOf("function renderCuts")); assert.doesNotMatch(cutFactory, /dblclick/); + assert.match(cutFactory, /if \(playlistMutationLocked\) return/); + assert.match(app, /send\("clear-cut-selection", \{\}\)/); + assert.match(app, /event\.target !== cutList/); + assert.match(app, /button\.addEventListener\("keydown"/); + assert.match(app, /stockResults\.querySelectorAll\("button\[data-result-index\]"\)/); }); test("cut rows are reconciled in place so a state reply cannot cancel native drag", () => { assert.match(app, /dataset\.physicalIndex/); assert.match(app, /function createCutElement/); + const cutFactory = app.slice( + app.indexOf("function createCutElement"), + app.indexOf("function clearPlaylistDragVisuals")); + assert.match(cutFactory, /if \(playlistMutationLocked \|\| !event\.dataTransfer\)/); + assert.match(cutFactory, /const cutDragLocked = isOperatorMutationLocked\(state\)/); + assert.match(cutFactory, /element\.draggable = !cutDragLocked/); assert.doesNotMatch(app, /cutList\.replaceChildren/); }); test("state rendering preserves stock and playlist focus like native controls", () => { assert.match(app, /dataset\.resultIndex/); assert.match(app, /dataset\.rowId/); + assert.match(app, /aria-current/); + assert.match(app, /scrollIntoView\(\{ block: "nearest", inline: "nearest" \}\)/); assert.doesNotMatch(app, /stockResults\.replaceChildren/); assert.doesNotMatch(app, /playlistRows\.replaceChildren/); }); +test("playlist keeps the gray candidate separate from the pink on-air row and page", () => { + const sourceStart = app.indexOf("function playlistPlayoutPresentation"); + const sourceEnd = app.indexOf("function presentCurrentPlaylistRow", sourceStart); + assert.ok(sourceStart >= 0 && sourceEnd > sourceStart); + const presentation = new Function(` + ${app.slice(sourceStart, sourceEnd)} + return playlistPlayoutPresentation; + `)(); + const state = { + playout: { + phase: "program", + currentEntryId: "row-current", + currentCueIndexZeroBased: 1, + pageIndexZeroBased: 1, + pageCount: 3 + } + }; + + assert.deepEqual(presentation(state, { + rowId: "row-current", isSelected: false, pageText: "1/3" + }, 1), { + isSelected: false, + isCurrent: true, + isOnAir: true, + pageText: "2/3", + focusKey: "program:row-current:1:3" + }); + assert.deepEqual(presentation(state, { + rowId: "row-candidate", isSelected: true, pageText: "1/1" + }, 1), { + isSelected: true, + isCurrent: false, + isOnAir: false, + pageText: "1/1", + focusKey: "" + }); + assert.equal(presentation({ + playout: { ...state.playout, currentEntryId: null } + }, { rowId: "row-fallback", isSelected: false, pageText: "1/3" }, 1).isCurrent, true); + assert.match(app, /presentation\.isOnAir \? " on-air"/); + assert.match(app, /presentation\.isSelected \|\| isCandidate \? " selected"/); + assert.match(app, /presentation\.pageText/); +}); + +test("PROGRAM F8 remains TAKE IN while end-of-playlist NEXT is closed", () => { + const sourceStart = playout.indexOf("function nextLabel"); + const sourceEnd = playout.indexOf("function render", sourceStart); + assert.ok(sourceStart >= 0 && sourceEnd > sourceStart); + const contract = new Function(` + ${playout.slice(sourceStart, sourceEnd)} + return { nextLabel, canTakeInFromState, canAdvanceFromState }; + `)(); + + assert.equal(contract.canTakeInFromState({ phase: "idle" }, true), true); + assert.equal(contract.canTakeInFromState({ phase: "prepared" }, false), true); + assert.equal(contract.canTakeInFromState({ phase: "program" }, true), true); + assert.equal(contract.canTakeInFromState({ phase: "program" }, false), false); + assert.equal(contract.canAdvanceFromState({ + phase: "program", nextKind: "playlistNext" + }), true); + assert.equal(contract.canAdvanceFromState({ + phase: "program", nextKind: "endOfPlaylist" + }), false); + assert.match(contract.nextLabel({ nextKind: "endOfPlaylist" }), /마지막/); +}); + +test("IDLE Escape keeps the emergency StopAll path available", () => { + assert.match(playout, /takeOut\.disabled = busy \|\| !commandAvailable;/); + assert.doesNotMatch( + playout, + /takeOut\.disabled[\s\S]{0,120}playout\.phase !== "prepared"/); +}); + +test("named-playlist modal shortcuts cannot leak into the playout listener", () => { + const appScript = markup.indexOf(''); + const playoutScript = markup.indexOf(''); + assert.ok(appScript >= 0 && playoutScript > appScript); + + const keydownMarker = 'document.addEventListener("keydown", function (event) {'; + const appHandlerStart = app.lastIndexOf(keydownMarker); + const appHandlerBodyStart = appHandlerStart + keydownMarker.length; + const appHandlerEnd = app.indexOf("\n });", appHandlerBodyStart); + assert.ok(appHandlerStart >= 0 && appHandlerEnd > appHandlerBodyStart); + const handleAppKeydown = new Function( + "event", + "namedPlaylistModal", + "closeNamedPlaylist", + app.slice(appHandlerBodyStart, appHandlerEnd)); + + const playoutHandlerStart = playout.lastIndexOf(keydownMarker); + const playoutHandlerBodyStart = playoutHandlerStart + keydownMarker.length; + const playoutHandlerEnd = playout.indexOf("\n });", playoutHandlerBodyStart); + assert.ok(playoutHandlerStart >= 0 && playoutHandlerEnd > playoutHandlerBodyStart); + const handlePlayoutKeydown = new Function( + "event", + "hasOpenDialog", + "backgroundFile", + "backgroundNone", + "takeIn", + "takeOut", + playout.slice(playoutHandlerBodyStart, playoutHandlerEnd)); + + const clicks = []; + const button = name => ({ disabled: false, click() { clicks.push(name); } }); + const backgroundFile = button("choose-background"); + const backgroundNone = button("toggle-background"); + const takeIn = button("take-in"); + const takeOut = button("take-out"); + const modal = { hidden: false }; + + for (const key of ["F2", "F3", "F8"]) { + handlePlayoutKeydown( + { key, repeat: false, defaultPrevented: false, preventDefault() {} }, + () => !modal.hidden, + backgroundFile, + backgroundNone, + takeIn, + takeOut); + } + 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); + assert.equal(escape.defaultPrevented, true); + + handlePlayoutKeydown( + escape, + () => !modal.hidden, + backgroundFile, + backgroundNone, + takeIn, + takeOut); + assert.deepEqual(clicks, []); +}); + test("playlist controls send intent for selection, enabled state, movement and deletion", () => { for (const intent of [ "select-playlist-row", + "activate-playlist-row", "set-playlist-enabled", "set-all-playlist-enabled", "reorder-playlist-rows", @@ -92,12 +251,34 @@ test("playlist controls send intent for selection, enabled state, movement and d assert.match(app, /control: event\.ctrlKey/); assert.match(app, /shift: event\.shiftKey/); assert.match(app, /event\.key === "Delete"/); - assert.match(app, /toggle-active-playlist-enabled/); + const deleteBranch = app.slice( + app.lastIndexOf('if (event.key === "Delete"'), + app.lastIndexOf('} else if (event.key === " "')); + assert.match( + deleteBranch, + /if \(!playlistMutationLocked\) send\("delete-selected-playlist-rows"/); + assert.doesNotMatch(deleteBranch, /event\.preventDefault\(\)/); + assert.doesNotMatch(app, /send\("toggle-active-playlist-enabled"/); + assert.match(app, /enabled: enabled\.checked !== true/); + assert.match(app, /if \(!playlistMutationLocked\)/); assert.match(app, /select-playlist-boundary/); assert.match(app, /event\.key === "Home"/); assert.match(app, /event\.key === "End"/); + const homeEndBranchStart = app.lastIndexOf('} else if ((event.key === "Home"'); + const homeEndBranch = app.slice( + homeEndBranchStart, + app.indexOf('} else if (event.key === "Escape"', homeEndBranchStart)); + assert.doesNotMatch(homeEndBranch, /event\.preventDefault\(\)/); + const boundaryHelperStart = app.indexOf("function selectPlaylistBoundary"); + const boundaryHelper = app.slice( + boundaryHelperStart, + app.indexOf("function clearDelayedClick", boundaryHelperStart)); + assert.doesNotMatch(boundaryHelper, /\.focus\(/); + assert.match(boundaryHelper, /scrollIntoView/); assert.match(app, /row\.isSelected/); - assert.match(app, /enabled\.disabled = state\.isBusy === true/); + assert.match(app, /row\.isActive === true/); + assert.match(app, /enabled\.setAttribute\("aria-disabled", playlistMutationLocked/); + assert.match(app, /interactionDisabled = playlistMutationLocked/); assert.match(app, /playlistEnableAll\.indeterminate/); }); @@ -109,7 +290,7 @@ test("playlist reorder reconciles keyed DOM nodes without discarding focus", () assert.doesNotMatch(app, /playlistRows\.replaceChild/); }); -test("playlist native drag preserves a multi-selection and rejects checkbox drag", () => { +test("playlist native drag sends one source row and rejects checkbox drag", () => { class MiniClassList { constructor() { this.names = new Set(); } add(...names) { names.forEach((name) => this.names.add(name)); } @@ -242,6 +423,11 @@ test("playlist native drag preserves a multi-selection and rejects checkbox drag ctrlKey: false, shiftKey: false }); + assert.deepEqual(sent, [{ + type: "activate-playlist-row", + payload: { rowId: "row-a" } + }]); + sent.length = 0; harness.onPlaylistDragStart({ currentTarget: source, // Chromium reports the draggable row as dragstart.target even though @@ -279,6 +465,7 @@ test("playlist native drag preserves a multi-selection and rejects checkbox drag type: "reorder-playlist-rows", payload: { sourceRowId: "row-a", targetRowId: "row-c", position: "before" } }]); + assert.equal(Object.hasOwn(sent[0].payload, "selectedRowIds"), false); }); test("playlist drop zone accepts only the exact selected-cut drag token", () => { @@ -295,14 +482,17 @@ test("playlist drop zone accepts only the exact selected-cut drag token", () => assert.ok(sourceStart >= 0 && sourceEnd > sourceStart); const playlistDropZone = new EventTarget(); const sent = []; - new Function( + const bindDropZone = new Function( "playlistDropZone", "send", "selectedCutsDragToken", - app.slice(sourceStart, sourceEnd))( - playlistDropZone, - (type, payload) => sent.push({ type, payload }), - "legacy-selected-cuts"); + "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"); @@ -331,6 +521,23 @@ test("playlist drop zone accepts only the exact selected-cut drag token", () => 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("operator catalog drag rejects an interactive child pointer origin", () => { @@ -423,6 +630,113 @@ test("market tabs send closed identity intents and reconcile C# order", () => { assert.doesNotMatch(app, /selectedTab\s*=/); }); +test("market tab hover uses the original narrow-source width threshold in both directions", () => { + const sourceStart = app.indexOf("function hasCrossedLegacyTabSwapThreshold"); + const sourceEnd = app.indexOf("function onMarketTabDragOver", sourceStart); + assert.ok(sourceStart >= 0 && sourceEnd > sourceStart); + const crossed = new Function(` + ${app.slice(sourceStart, sourceEnd)} + return hasCrossedLegacyTabSwapThreshold; + `)(); + const tab = (left, width) => ({ + getBoundingClientRect() { return { left, width }; } + }); + const narrowSource = tab(0, 40); + const wideTarget = tab(100, 100); + + // Moving right: target.right - source.width = 160, with a strict > comparison. + assert.equal(crossed(narrowSource, wideTarget, 159, 0, 1), false); + assert.equal(crossed(narrowSource, wideTarget, 160, 0, 1), false); + assert.equal(crossed(narrowSource, wideTarget, 161, 0, 1), true); + + // Moving left: target.left + source.width = 140, with a strict < comparison. + assert.equal(crossed(narrowSource, wideTarget, 141, 2, 1), false); + assert.equal(crossed(narrowSource, wideTarget, 140, 2, 1), false); + assert.equal(crossed(narrowSource, wideTarget, 139, 2, 1), true); + + // Equal-width and wider sources keep MainForm's immediate-on-entry behavior. + assert.equal(crossed(tab(0, 100), wideTarget, 100, 0, 1), true); + assert.equal(crossed(tab(0, 120), wideTarget, 200, 2, 1), true); +}); + +test("market tab dragover sends once after crossing and does not poison the busy gate", () => { + const sourceStart = app.indexOf("function hasCrossedLegacyTabSwapThreshold"); + const sourceEnd = app.indexOf("function requestTabHoverSwap", sourceStart); + assert.ok(sourceStart >= 0 && sourceEnd > sourceStart); + + const source = { + dataset: { tabId: "source" }, + closest() { return this; }, + getBoundingClientRect() { return { left: 0, width: 40 }; } + }; + const target = { + dataset: { tabId: "target" }, + closest() { return this; }, + getBoundingClientRect() { return { left: 100, width: 100 }; } + }; + const marketTabs = { + querySelectorAll() { return [source, target]; } + }; + let accepted = false; + const requests = []; + const harness = new Function( + "marketTabs", + "requestTabHoverSwap", + ` + let draggedTabId = "source"; + let tabDragLastTargetId = null; + let pendingTabHoverSwap = null; + ${app.slice(sourceStart, sourceEnd)} + return { + dragover: onMarketTabDragOver, + lastTarget: function () { return tabDragLastTargetId; }, + setPending: function (value) { pendingTabHoverSwap = value; } + }; + `)(marketTabs, targetId => { + requests.push(targetId); + return accepted; + }); + const dragover = (eventTarget, clientX) => { + let prevented = false; + const dataTransfer = { dropEffect: "none" }; + harness.dragover({ + target: eventTarget, + clientX, + dataTransfer, + preventDefault() { prevented = true; } + }); + assert.equal(prevented, true); + assert.equal(dataTransfer.dropEffect, "move"); + }; + + dragover(target, 159); + assert.deepEqual(requests, []); + assert.equal(harness.lastTarget(), null); + + // The threshold is crossed while the request gate rejects the operation. + dragover(target, 161); + assert.deepEqual(requests, ["target"]); + assert.equal(harness.lastTarget(), null); + + // Once accepted, only the first repeated dragover for this crossing is sent. + accepted = true; + dragover(target, 170); + dragover(target, 180); + assert.deepEqual(requests, ["target", "target"]); + assert.equal(harness.lastTarget(), "target"); + + // Re-entering the dragged source ends that crossing; a later target crossing is new. + dragover(source, 20); + assert.equal(harness.lastTarget(), null); + dragover(target, 170); + assert.deepEqual(requests, ["target", "target", "target"]); + + harness.setPending({}); + dragover(source, 20); + dragover(target, 170); + assert.deepEqual(requests, ["target", "target", "target"]); +}); + test("fixed catalog renders C# view and sends opaque leaf or section intent", () => { assert.match(app, /state\.fixedCatalog/); assert.match(app, /dataset\.actionId/); @@ -435,6 +749,274 @@ test("fixed catalog renders C# view and sends opaque leaf or section intent", () assert.doesNotMatch(app, /builderKey|sceneCode|cutCode|dataCode/); }); +test("UC1 tree separates label selection, disclosure toggling and double-click action", () => { + const doubleClickStart = app.indexOf('categoryTree.addEventListener("dblclick"'); + const clickStart = app.indexOf( + 'categoryTree.addEventListener("click"', doubleClickStart + 1); + const changeStart = app.indexOf( + 'categoryTree.addEventListener("change"', clickStart + 1); + assert.ok(doubleClickStart >= 0 && clickStart > doubleClickStart && changeStart > clickStart); + + class DelegateTarget { + constructor() { this.listeners = new Map(); } + addEventListener(type, callback) { this.listeners.set(type, callback); } + fire(type, target, detail = 1) { + let prevented = false; + this.listeners.get(type)({ + target, + detail, + preventDefault() { prevented = true; } + }); + return prevented; + } + } + + function createNodes() { + const details = { open: true }; + const summary = { + dataset: { treeNodeKey: "root|overseas|fixed:0", sectionIndex: "0" }, + disabled: false, + parentElement: details, + getAttribute(name) { return name === "aria-disabled" ? "false" : null; } + }; + const disclosure = { + closest(selector) { + if (selector === "[data-tree-node-key]" || + selector === "summary[data-tree-root]" || + selector === "summary[data-section-index]") return summary; + if (selector === "[data-tree-disclosure]") return this; + return null; + } + }; + const label = { + closest(selector) { + return selector === "[data-tree-node-key]" || + selector === "summary[data-tree-root]" || + selector === "summary[data-section-index]" + ? summary : null; + } + }; + const leaf = { + dataset: { treeNodeKey: "leaf|overseas|fixed:0|fixed-262", actionId: "fixed-262" }, + disabled: false, + getAttribute() { return null; }, + closest(selector) { + return selector === "[data-tree-node-key]" || + selector === ".tree-item[data-tree-node-key]" || + selector === "button[data-action-id]" + ? this : null; + } + }; + return { details, summary, disclosure, label, leaf }; + } + + function createHarness(locked) { + const categoryTree = new DelegateTarget(); + const sent = []; + const selected = []; + const expanded = []; + new Function( + "categoryTree", "send", "selectTreeNode", "syncTreeSectionExpanded", + "treeInteractionLocked", + app.slice(doubleClickStart, clickStart) + app.slice(clickStart, changeStart))( + categoryTree, + (type, payload) => sent.push({ type, payload }), + node => selected.push(node.dataset.treeNodeKey), + details => expanded.push(details.open), + locked); + return { categoryTree, sent, selected, expanded }; + } + + const nodes = createNodes(); + const harness = createHarness(false); + assert.equal(harness.categoryTree.fire("click", nodes.label), true); + assert.equal(nodes.details.open, true); + assert.deepEqual(harness.sent, []); + + assert.equal(harness.categoryTree.fire("click", nodes.disclosure), true); + assert.equal(nodes.details.open, false); + assert.deepEqual(harness.expanded, [false]); + + harness.categoryTree.fire("dblclick", nodes.label, 2); + harness.categoryTree.fire("dblclick", nodes.leaf, 2); + assert.equal(nodes.details.open, false); + assert.deepEqual(harness.sent, [ + { type: "activate-fixed-section", payload: { sectionIndex: 0 } }, + { type: "activate-fixed-action", payload: { actionId: "fixed-262" } } + ]); + + const lockedNodes = createNodes(); + const locked = createHarness(true); + locked.categoryTree.fire("click", lockedNodes.disclosure); + locked.categoryTree.fire("dblclick", lockedNodes.label, 2); + locked.categoryTree.fire("dblclick", lockedNodes.leaf, 2); + assert.equal(lockedNodes.details.open, true); + assert.deepEqual(locked.sent, []); + assert.deepEqual(locked.selected, []); +}); + +test("UC1 tree render preserves state but resets Expand and first root on tab reentry", () => { + const helperStart = app.indexOf("function activeTreeTabId"); + const helperEnd = app.indexOf("function send(", helperStart); + assert.ok(helperStart >= 0 && helperEnd > helperStart); + + class MiniClassList { + constructor() { this.values = new Set(); } + add(value) { this.values.add(value); } + remove(value) { this.values.delete(value); } + contains(value) { return this.values.has(value); } + } + + const document = { activeElement: null }; + class MiniNode { + constructor(dataset = {}) { + this.dataset = dataset; + this.classList = new MiniClassList(); + this.attributes = new Map(); + this.disabled = false; + this.textContent = ""; + this.scrolled = false; + } + setAttribute(name, value) { this.attributes.set(name, String(value)); } + getAttribute(name) { return this.attributes.get(name) ?? null; } + focus() { document.activeElement = this; } + scrollIntoView() { this.scrolled = true; } + closest(selector) { return selector === "[data-tree-node-key]" ? this : null; } + } + + class MiniDetails { + constructor(sectionIndex, leafId) { + this.dataset = {}; + this.open = false; + this.summary = new MiniNode({ sectionIndex: String(sectionIndex) }); + this.summary.textContent = "[section-" + sectionIndex + "]"; + this.summary.parentElement = this; + this.summary.querySelector = selector => selector === "[data-tree-disclosure]" ? {} : null; + this.leaf = new MiniNode({ actionId: leafId }); + this.leaf.textContent = leafId; + } + querySelector(selector) { + return selector.startsWith(":scope > summary") ? this.summary : null; + } + querySelectorAll(selector) { return selector === ".tree-item" ? [this.leaf] : []; } + } + + class MiniTree { + constructor() { this.details = []; this.scrollTop = 0; } + replaceWith(details) { this.details = details; } + querySelectorAll(selector) { + if (selector === "details" || selector === "details[data-tree-section-key]") { + return this.details; + } + if (selector === "summary[data-tree-root]") { + return this.details.map(details => details.summary); + } + const nodes = this.details.flatMap(details => [details.summary, details.leaf]); + if (selector === "[data-tree-node-key]") return nodes; + if (selector === "[data-tree-node-key].tree-node-selected") { + return nodes.filter(node => node.classList.contains("tree-node-selected")); + } + return []; + } + querySelector(selector) { + if (selector === "[data-tree-node-key].tree-node-selected") { + return this.querySelectorAll(selector)[0] || null; + } + return null; + } + contains(node) { + return this.details.some(details => details.summary === node || details.leaf === node); + } + } + + const categoryTree = new MiniTree(); + const catalogExpandAll = { checked: false }; + const harness = new Function("categoryTree", "catalogExpandAll", "document", ` + let renderedTreeTabId = null; + let treeInteractionLocked = false; + const treeUiStateByTab = new Map(); + const pendingTreeResetTabs = new Set(); + ${app.slice(helperStart, helperEnd)} + return { beginTreeRender, finishTreeRender, selectTreeNode }; + `)(categoryTree, catalogExpandAll, document); + const state = tabId => ({ + isBusy: false, + fixedSectionBatch: null, + tabs: [{ id: tabId, isActive: true }] + }); + const sections = () => [new MiniDetails(0, "fixed-1"), new MiniDetails(1, "fixed-2")]; + + let current = sections(); + categoryTree.replaceWith(current); + harness.finishTreeRender(harness.beginTreeRender(state("overseas"))); + assert.equal(catalogExpandAll.checked, true); + assert.ok(current.every(details => details.open)); + assert.ok(current[0].summary.classList.contains("tree-node-selected")); + assert.equal(document.activeElement, current[0].summary); + assert.equal(current[0].summary.scrolled, true); + + current[0].open = false; + harness.selectTreeNode(current[0].leaf, true); + categoryTree.scrollTop = 42; + const sameTab = harness.beginTreeRender(state("overseas")); + current = sections(); + categoryTree.replaceWith(current); + harness.finishTreeRender(sameTab); + assert.equal(current[0].open, false); + assert.equal(current[1].open, true); + assert.ok(current[0].leaf.classList.contains("tree-node-selected")); + assert.equal(document.activeElement, current[0].leaf); + assert.equal(categoryTree.scrollTop, 42); + + catalogExpandAll.checked = false; + const reentry = harness.beginTreeRender(state("exchange")); + current = sections(); + categoryTree.replaceWith(current); + harness.finishTreeRender(reentry); + assert.equal(catalogExpandAll.checked, true); + assert.ok(current.every(details => details.open)); + assert.ok(current[0].summary.classList.contains("tree-node-selected")); + assert.equal(document.activeElement, current[0].summary); +}); + +test("UC1 Expand collapses all without clearing selection and is inert while locked", () => { + const expandStart = app.indexOf('catalogExpandAll.addEventListener("change"'); + const doubleClickStart = app.indexOf( + 'categoryTree.addEventListener("dblclick"', expandStart + 1); + assert.ok(expandStart >= 0 && doubleClickStart > expandStart); + + function createHarness(locked) { + const selected = { value: true }; + const details = [{ open: true }, { open: true }]; + const categoryTree = { + querySelectorAll(selector) { return selector === "details" ? details : []; } + }; + const catalogExpandAll = { + checked: false, + addEventListener(type, callback) { this.change = callback; } + }; + const expanded = []; + new Function( + "catalogExpandAll", "categoryTree", "syncTreeSectionExpanded", + "treeInteractionLocked", app.slice(expandStart, doubleClickStart))( + catalogExpandAll, categoryTree, row => expanded.push(row.open), locked); + return { catalogExpandAll, details, expanded, selected }; + } + + const normal = createHarness(false); + normal.catalogExpandAll.change(); + assert.deepEqual(normal.details.map(details => details.open), [false, false]); + assert.deepEqual(normal.expanded, [false, false]); + assert.equal(normal.selected.value, true); + + const locked = createHarness(true); + locked.catalogExpandAll.change(); + assert.deepEqual(locked.details.map(details => details.open), [true, true]); + assert.equal(locked.catalogExpandAll.checked, true); + assert.deepEqual(locked.expanded, []); + assert.equal(locked.selected.value, true); +}); + test("fixed-262 ignores a single click and emits exactly one intent on double click", () => { const doubleClickStart = app.indexOf('categoryTree.addEventListener("dblclick"'); const clickStart = app.indexOf( @@ -575,7 +1157,7 @@ test("named DB playlists and playout use separate closed native intents", () => "prepare-playout", "take-in", "next-playout", "take-out", "choose-background", "toggle-background", "set-fade-duration" ]) assert.match(playout, new RegExp("\\\"" + intent + "\\\"")); - assert.match(playout, /playout\.phase !== "prepared"/); + assert.match(playout, /takeOut\.disabled = busy \|\| !commandAvailable;/); assert.match(playout, /playout\.isPlayCompletionPending === true/); assert.match(playout, /playout\.outcomeUnknown !== true/); assert.doesNotMatch(playout, /fetch\s*\(|localStorage|sessionStorage|sceneCode|filePath/); diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneBuilderTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneBuilderTests.cs index ca82bb1..f002c86 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneBuilderTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneBuilderTests.cs @@ -237,6 +237,38 @@ public sealed class CandleSceneBuilderTests value == item.ExpectedPrice); } + [Theory] + [InlineData(CandleMarketTarget.KospiIndex)] + [InlineData(CandleMarketTarget.KosdaqIndex)] + [InlineData(CandleMarketTarget.Kospi200Index)] + [InlineData(CandleMarketTarget.Krx100Index)] + [InlineData(CandleMarketTarget.FuturesIndex)] + [InlineData(CandleMarketTarget.KospiIndustry)] + [InlineData(CandleMarketTarget.KosdaqIndustry)] + [InlineData(CandleMarketTarget.OverseasIndex)] + public void IndexAndIndustryTargetsForceMovingAveragePathsAndGuideOff( + CandleMarketTarget target) + { + var data = new S8010SceneData( + CandleCutAlias.Cut8061, + target, + CandleChartMode.Price, + "Target", + new CandleQuoteData(100, 0, 0, ScenePriceDirection.Flat), + [Row(1, 100, 110, 90, 100, 10, 101, 102)], + ShowMovingAverage5: true, + ShowMovingAverage20: true); + + var mutations = new S8010SceneMutationBuilder().Build(data); + + Assert.Empty(mutations.OfType() + .Last(path => path.ObjectName == "ma5").Points); + Assert.Empty(mutations.OfType() + .Last(path => path.ObjectName == "ma20").Points); + Assert.False(mutations.OfType() + .Last(visible => visible.ObjectName == "guide").IsVisible); + } + [Fact] public void Equal_range_zero_volume_and_single_partial_path_are_finite() { diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs index d479a92..16f0c06 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs @@ -46,6 +46,159 @@ public sealed class LegacyPlayoutWorkflowTests ], provider.Calls); } + [Fact] + public async Task S8010SamplesCurrentMovingAveragesForPrepareTakeInRefreshAndNext() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["8051"] = (1, null), + ["8040"] = (1, null) + }); + var source = new MutableLegacySceneMovingAverageSelectionSource( + new LegacySceneMovingAverageSelection(false, false)); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + LegacyPlaylistEntry[] playlist = + [ + CandleEntry("first", "8051", source), + CandleEntry("second", "8040", source) + ]; + + Assert.True((await workflow.PrepareAsync(playlist, 0)).IsSuccess); + source.Update(new LegacySceneMovingAverageSelection(true, false)); + Assert.True((await workflow.TakeInAsync()).IsSuccess); + source.Update(new LegacySceneMovingAverageSelection(false, true)); + Assert.True((await workflow.RefreshOnAirAsync()).IsSuccess); + source.Update(new LegacySceneMovingAverageSelection(true, true)); + Assert.True((await workflow.NextAsync()).IsSuccess); + + Assert.Equal(["", "MA5", "MA20", "MA5,MA20"], + provider.SelectionGraphicTypes); + Assert.Equal("second", workflow.State.CurrentEntryId); + } + + [Fact] + public async Task OperatorPageCountRemainsFrozenAcrossGrowthAndRefresh() + { + var definitions = new Dictionary + { + ["5074"] = (6, ScenePageSize.Five), + ["5001"] = (1, null) + }; + var engine = new RecordingEngine(); + var provider = new RecordingProvider(definitions); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + LegacyPlaylistEntry[] playlist = + [ + new( + "paged", + "5074", + PageNavigation: new(ScenePageSize.Five, FrozenPageCount: 2)), + new("ordinary", "5001", PageNavigation: LegacyPlaylistPageNavigation.Disabled) + ]; + + Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess); + Assert.Equal(2, workflow.State.PageCount); + Assert.True(workflow.State.IsPageNavigationEnabled); + + definitions["5074"] = (16, ScenePageSize.Five); + Assert.True((await workflow.RefreshOnAirAsync()).IsSuccess); + Assert.Equal(2, workflow.State.PageCount); + Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind); + + Assert.True((await workflow.NextAsync()).IsSuccess); + Assert.Equal(1, workflow.State.PageIndexZeroBased); + Assert.Equal(2, workflow.State.PageCount); + Assert.True(workflow.State.IsLastPage); + Assert.Equal(LegacyWorkflowNextKind.PlaylistNext, workflow.State.NextKind); + + definitions["5074"] = (101, ScenePageSize.Five); + Assert.True((await workflow.RefreshOnAirAsync()).IsSuccess); + Assert.Equal(2, workflow.State.PageCount); + Assert.Equal(5, workflow.State.CurrentPageItemCount); + Assert.Equal(LegacyWorkflowNextKind.PlaylistNext, workflow.State.NextKind); + } + + [Fact] + public async Task OperatorPageCountRemainsFrozenAfterShrinkAndMissingNextPageFailsClosed() + { + var definitions = new Dictionary + { + ["5074"] = (6, ScenePageSize.Five) + }; + var engine = new RecordingEngine(); + var provider = new RecordingProvider(definitions); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + LegacyPlaylistEntry[] playlist = + [ + new( + "paged", + "5074", + PageNavigation: new(ScenePageSize.Five, FrozenPageCount: 2)) + ]; + Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess); + + definitions["5074"] = (5, ScenePageSize.Five); + Assert.True((await workflow.RefreshOnAirAsync()).IsSuccess); + Assert.Equal(2, workflow.State.PageCount); + Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind); + var engineCallCount = engine.Calls.Count; + + await Assert.ThrowsAsync(() => workflow.NextAsync()); + + Assert.Equal(engineCallCount, engine.Calls.Count); + Assert.Equal(0, workflow.State.PageIndexZeroBased); + Assert.Equal(2, workflow.State.PageCount); + Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind); + } + + [Fact] + public async Task ExplicitlyNonPagedOperatorEntryMovesToNextPlaylistRowDespitePagedProvider() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["5074"] = (11, ScenePageSize.Five), + ["5001"] = (1, null) + }); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + LegacyPlaylistEntry[] playlist = + [ + new("noncanonical", "5074", PageNavigation: LegacyPlaylistPageNavigation.Disabled), + new("ordinary", "5001", PageNavigation: LegacyPlaylistPageNavigation.Disabled) + ]; + + Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess); + Assert.False(workflow.State.IsPageNavigationEnabled); + Assert.Equal(1, workflow.State.PageCount); + Assert.Equal(LegacyWorkflowNextKind.PlaylistNext, workflow.State.NextKind); + + Assert.True((await workflow.NextAsync()).IsSuccess); + Assert.Equal("ordinary", workflow.State.CurrentEntryId); + Assert.DoesNotContain("5074:1", provider.Calls); + Assert.Equal("Next:5001:p0", engine.Calls[^1]); + } + + [Fact] + public async Task DirectCoreEntryWithoutNavigationMetadataRetainsFreshPlanBehavior() + { + var definitions = new Dictionary + { + ["5074"] = (6, ScenePageSize.Five) + }; + var workflow = new LegacyPlayoutWorkflow( + new RecordingEngine(), + new RecordingProvider(definitions)); + + Assert.True((await workflow.TakeSelectedAsync([new("direct", "5074")], 0)).IsSuccess); + Assert.Equal(2, workflow.State.PageCount); + + definitions["5074"] = (11, ScenePageSize.Five); + Assert.True((await workflow.RefreshOnAirAsync()).IsSuccess); + Assert.Equal(3, workflow.State.PageCount); + Assert.True(workflow.State.IsPageNavigationEnabled); + } + [Fact] public async Task Next_AtTheEndDoesNotWrapOrDispatchTheEngine() { @@ -181,7 +334,7 @@ public sealed class LegacyPlayoutWorkflowTests var workflow = new LegacyPlayoutWorkflow(engine, provider); await Assert.ThrowsAsync(() => workflow.PrepareAsync( - [new LegacyPlaylistEntry("entry", "5001", FadeDuration: 61)], + [new LegacyPlaylistEntry("entry", "5001", FadeDuration: 20)], 0)); Assert.Empty(provider.Calls); @@ -243,7 +396,7 @@ public sealed class LegacyPlayoutWorkflowTests } [Fact] - public async Task PrepareAndTakeIn_LoadsOnceAndCallsPrepareThenTakeIn() + public async Task TakeSelected_LoadsOnceAndCallsPrepareThenTakeIn() { var engine = new RecordingEngine(); var provider = new RecordingProvider(new Dictionary @@ -252,7 +405,7 @@ public sealed class LegacyPlayoutWorkflowTests }); var workflow = new LegacyPlayoutWorkflow(engine, provider); - var result = await workflow.PrepareAndTakeInAsync( + var result = await workflow.TakeSelectedAsync( [new("disabled", "5006", false), new("selected", "5001")], 0); @@ -267,7 +420,7 @@ public sealed class LegacyPlayoutWorkflowTests } [Fact] - public async Task PrepareAndTakeIn_WhenTakeInFails_RetainsPreparedStateWithoutRetry() + public async Task TakeSelected_WhenTakeInFails_RetainsPreparedStateWithoutRetry() { var engine = new RecordingEngine(); engine.TakeInResults.Enqueue(PlayoutResultCode.OutcomeUnknown); @@ -277,7 +430,7 @@ public sealed class LegacyPlayoutWorkflowTests }); var workflow = new LegacyPlayoutWorkflow(engine, provider); - var result = await workflow.PrepareAndTakeInAsync([new("selected", "5074")], 0); + var result = await workflow.TakeSelectedAsync([new("selected", "5074")], 0); Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code); Assert.Equal(["5074:0"], provider.Calls); @@ -288,6 +441,198 @@ public sealed class LegacyPlayoutWorkflowTests Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind); } + [Fact] + public async Task TakeSelected_WhileProgram_PreparesAndPlaysTheCurrentOperatorSelection() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["5001"] = (1, null), + ["5006"] = (1, null), + ["5074"] = (1, null) + }); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + LegacyPlaylistEntry[] playlist = + [ + new("program-a", "5001"), + new("between", "5006"), + new("candidate-d", "5074") + ]; + Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess); + + var switchResult = await workflow.TakeSelectedAsync(playlist, 2); + + Assert.True(switchResult.IsSuccess); + Assert.Equal( + ["Prepare:5001:p0", "TakeIn", "Prepare:5074:p0", "TakeIn"], + engine.Calls); + Assert.Equal(["5001:0", "5074:0"], provider.Calls); + Assert.Equal(2, workflow.State.CurrentCueIndexZeroBased); + Assert.Equal("candidate-d", workflow.State.CurrentEntryId); + Assert.Null(workflow.State.PreparedCutCode); + Assert.Equal("5074", workflow.State.OnAirCutCode); + } + + [Fact] + public async Task ManualCandidateRemainsIndependentAcrossNextUntilTheNextF8() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["5001"] = (1, null), + ["5006"] = (1, null), + ["5074"] = (1, null) + }); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + LegacyPlaylistEntry[] playlist = + [ + new("program-a", "5001"), + new("next-b", "5006"), + new("candidate-d", "5074") + ]; + Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess); + const int operatorCandidateIndex = 2; + + Assert.True((await workflow.NextAsync()).IsSuccess); + + Assert.Equal(1, workflow.State.CurrentCueIndexZeroBased); + Assert.Equal("next-b", workflow.State.CurrentEntryId); + Assert.Equal(2, operatorCandidateIndex); + + Assert.True((await workflow.TakeSelectedAsync( + playlist, + operatorCandidateIndex)).IsSuccess); + + Assert.Equal(2, workflow.State.CurrentCueIndexZeroBased); + Assert.Equal("candidate-d", workflow.State.CurrentEntryId); + Assert.Equal( + [ + "Prepare:5001:p0", "TakeIn", + "Next:5006:p0", + "Prepare:5074:p0", "TakeIn" + ], + engine.Calls); + } + + [Fact] + public async Task TakeSelected_AfterExplicitPrepare_UsesTheLaterRowSelection() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["5001"] = (1, null), + ["5074"] = (1, null) + }); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + LegacyPlaylistEntry[] playlist = + [ + new("prepared-a", "5001"), + new("selected-d", "5074") + ]; + Assert.True((await workflow.PrepareAsync(playlist, 0)).IsSuccess); + + var result = await workflow.TakeSelectedAsync(playlist, 1); + + Assert.True(result.IsSuccess); + Assert.Equal( + ["Prepare:5001:p0", "Prepare:5074:p0", "TakeIn"], + engine.Calls); + Assert.Equal(["5001:0", "5074:0"], provider.Calls); + Assert.Equal(1, workflow.State.CurrentCueIndexZeroBased); + Assert.Equal("selected-d", workflow.State.CurrentEntryId); + Assert.Null(workflow.State.PreparedCutCode); + Assert.Equal("5074", workflow.State.OnAirCutCode); + } + + [Fact] + public async Task TakeSelected_WhileProgram_WhenPrepareFails_PreservesCurrentOnAirRow() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["5001"] = (1, null), + ["5074"] = (1, null) + }); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + LegacyPlaylistEntry[] playlist = + [ + new("program-a", "5001"), + new("candidate-d", "5074") + ]; + Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess); + engine.PrepareResults.Enqueue(PlayoutResultCode.Rejected); + + var result = await workflow.TakeSelectedAsync(playlist, 1); + + Assert.Equal(PlayoutResultCode.Rejected, result.Code); + Assert.Equal( + ["Prepare:5001:p0", "TakeIn", "Prepare:5074:p0"], + engine.Calls); + Assert.Equal(0, workflow.State.CurrentCueIndexZeroBased); + Assert.Equal("program-a", workflow.State.CurrentEntryId); + Assert.Null(workflow.State.PreparedCutCode); + Assert.Equal("5001", workflow.State.OnAirCutCode); + } + + [Fact] + public async Task TakeSelected_WhileProgram_WhenPlayIsUnknown_KeepsOldOnAirAndNewPreparedState() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["5001"] = (1, null), + ["5074"] = (1, null) + }); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + LegacyPlaylistEntry[] playlist = + [ + new("program-a", "5001"), + new("candidate-d", "5074") + ]; + Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess); + engine.TakeInResults.Enqueue(PlayoutResultCode.OutcomeUnknown); + + var result = await workflow.TakeSelectedAsync(playlist, 1); + + Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code); + Assert.Equal( + ["Prepare:5001:p0", "TakeIn", "Prepare:5074:p0", "TakeIn"], + engine.Calls); + Assert.Equal(0, workflow.State.CurrentCueIndexZeroBased); + Assert.Equal("program-a", workflow.State.CurrentEntryId); + Assert.Equal("5074", workflow.State.PreparedCutCode); + Assert.Equal("5001", workflow.State.OnAirCutCode); + } + + [Fact] + public async Task TakeSelected_WhilePlayCompletionIsPending_DoesNotQueryOrDispatch() + { + var engine = new RecordingEngine(); + var provider = new RecordingProvider(new Dictionary + { + ["5001"] = (1, null), + ["5074"] = (1, null) + }); + var workflow = new LegacyPlayoutWorkflow(engine, provider); + LegacyPlaylistEntry[] playlist = + [ + new("program-a", "5001"), + new("candidate-d", "5074") + ]; + Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess); + engine.Status = engine.Status with { IsPlayCompletionPending = true }; + var providerCallCount = provider.Calls.Count; + var engineCallCount = engine.Calls.Count; + + var result = await workflow.TakeSelectedAsync(playlist, 1); + + Assert.Equal(PlayoutResultCode.Rejected, result.Code); + Assert.Equal(providerCallCount, provider.Calls.Count); + Assert.Equal(engineCallCount, engine.Calls.Count); + Assert.Equal(0, workflow.State.CurrentCueIndexZeroBased); + Assert.Equal("5001", workflow.State.OnAirCutCode); + } + [Fact] public async Task TakeIn_WhenFreshPrepareIsOutcomeUnknown_DoesNotPlayOrReplacePreparedState() { @@ -312,7 +657,7 @@ public sealed class LegacyPlayoutWorkflowTests } [Fact] - public async Task RefreshOnAir_RequeriesTheCurrentPagedWindowAndUsesGetPlayingSceneEnginePath() + public async Task RefreshOnAir_RequeriesTheCurrentPagedWindowAndUsesOnAirUpdatePath() { var engine = new RecordingEngine(); var provider = new RecordingProvider(new Dictionary @@ -530,6 +875,8 @@ public sealed class LegacyPlayoutWorkflowTests public List PlanCalls { get; } = []; + public List SelectionGraphicTypes { get; } = []; + public Func? SceneNameOverride { get; init; } public string? BuilderKey { get; init; } @@ -543,6 +890,7 @@ public sealed class LegacyPlayoutWorkflowTests { cancellationToken.ThrowIfCancellationRequested(); Calls.Add($"{entry.CutCode}:{pageIndexZeroBased}"); + SelectionGraphicTypes.Add(entry.Selection?.GraphicType); var definition = definitions[entry.CutCode]; var name = SceneNameOverride?.Invoke(entry.CutCode, pageIndexZeroBased) ?? entry.CutCode; return Task.FromResult(new LegacySceneCuePage( @@ -577,6 +925,21 @@ public sealed class LegacyPlayoutWorkflowTests } } + private static LegacyPlaylistEntry CandleEntry( + string entryId, + string cutCode, + ILegacySceneMovingAverageSelectionSource source) => + new( + entryId, + cutCode, + Selection: new LegacySceneSelection( + "KOSPI_STOCK", + "Samsung Electronics", + "persisted-candle-identity", + "PRICE", + "005930"), + MovingAverageSelectionSource: source); + private sealed class RecordingEngine : IPlayoutEngine { public List Calls { get; } = []; @@ -589,7 +952,7 @@ public sealed class LegacyPlayoutWorkflowTests public Action? PrepareCallback { get; set; } - public PlayoutStatus Status { get; } = new( + public PlayoutStatus Status { get; set; } = new( PlayoutMode.DryRun, PlayoutConnectionState.DryRunReady, true, diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyScenePreviewTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyScenePreviewTests.cs index d65f373..d05ef70 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyScenePreviewTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyScenePreviewTests.cs @@ -23,7 +23,7 @@ public sealed class LegacyScenePreviewTests new PlayoutSetPathPoints("line", [new(1, 2), new(3, 4, 5)]), new PlayoutSetPathShapePoints("shape", [new(6, 7)]), new PlayoutSetBackgroundTexture(secretAsset), - new PlayoutSetBackgroundVideo(secretAsset, 2004, true), + new PlayoutSetBackgroundVideo(secretAsset, 2004, 10), new PlayoutUseBackground(true) ]); @@ -42,7 +42,7 @@ public sealed class LegacyScenePreviewTests value => Assert.Equal(new("path-shape", "shape", "count=1;6,7,0"), value), value => Assert.Equal(new("background-texture", "scene", "configured"), value), value => Assert.Equal( - new("background-video", "scene", "configured;loop=2004;infinite=true"), + new("background-video", "scene", "configured;loop=2004;loop-mode=10"), value), value => Assert.Equal(new("background", "scene", "enabled"), value)); Assert.DoesNotContain(secretAsset, string.Join("|", preview.Select(value => value.Value))); diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneRefreshPolicyTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneRefreshPolicyTests.cs index e5e7389..75d5191 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneRefreshPolicyTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacySceneRefreshPolicyTests.cs @@ -9,9 +9,8 @@ public sealed class LegacySceneRefreshPolicyTests { "5001", 2 }, { "N5001", 2 }, { "5006", 5 }, { "5011", 5 }, { "5016", 5 }, { "50160", 30 }, { "5023", 30 }, { "5024", 60 }, { "5025", 30 }, { "5026", 8 }, { "5029", 7 }, { "5032", 5 }, - { "5037", 5 }, { "5074", 7 }, { "5076", 3_000_000 }, { "5077", 7 }, - { "5078", 30 }, { "5079", 3_000_000 }, { "5080", 3_000_000 }, - { "5081", 3_000_000 }, { "5082", 6 }, { "5083", 60 }, { "5084", 5 }, + { "5037", 5 }, { "5074", 7 }, { "5077", 7 }, { "5078", 30 }, + { "5082", 6 }, { "5083", 60 }, { "5084", 5 }, { "5085", 30 }, { "5086", 7 }, { "50860", 7 }, { "5087", 7 }, { "5088", 7 }, { "6001", 30 }, { "6067", 30 }, { "8001", 120 }, { "8002", 120 }, { "8003", 8 }, { "8018", 5 }, { "8032", 5 }, @@ -28,6 +27,18 @@ public sealed class LegacySceneRefreshPolicyTests Assert.Equal(TimeSpan.FromSeconds(seconds), interval); } + [Theory] + [InlineData("5076")] + [InlineData("5079")] + [InlineData("5080")] + [InlineData("5081")] + public void OverflowedMainFormTimerAliasesDoNotArmAutomaticRefresh(string alias) + { + Assert.True(LegacySceneRefreshPolicy.ContainsAlias(alias)); + Assert.True(LegacySceneRefreshPolicy.IsDisabledByLegacyTimerOverflow(alias)); + Assert.False(LegacySceneRefreshPolicy.TryGetInterval(alias, out _)); + } + [Fact] public void PolicyCoversExactlyTheCatalogsActiveAliases() { @@ -39,7 +50,7 @@ public sealed class LegacySceneRefreshPolicyTests Assert.Equal(45, aliases.Length); Assert.Equal(aliases.Length, LegacySceneRefreshPolicy.Count); Assert.All(aliases, alias => - Assert.True(LegacySceneRefreshPolicy.TryGetInterval(alias, out _), alias)); + Assert.True(LegacySceneRefreshPolicy.ContainsAlias(alias), alias)); Assert.False(LegacySceneRefreshPolicy.TryGetInterval("8086", out _)); Assert.False(LegacySceneRefreshPolicy.TryGetInterval("../5001", out _)); Assert.Equal(TimeSpan.FromSeconds(3), LegacySceneRefreshPolicy.RecurringInterval); diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/PlayoutBridgeProtocolTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/PlayoutBridgeProtocolTests.cs index ce57c07..6157904 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/PlayoutBridgeProtocolTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/PlayoutBridgeProtocolTests.cs @@ -147,7 +147,7 @@ public sealed class PlayoutBridgeProtocolTests [InlineData("{\"requestId\":\"REQ\",\"command\":\"next\",\"playlist\":[],\"selectedIndex\":0}")] [InlineData("{\"requestId\":\"REQ\",\"command\":\"prepare\",\"playlist\":[],\"selectedIndex\":0}")] [InlineData("{\"requestId\":\"REQ\",\"command\":\"prepare\",\"playlist\":[{\"id\":\"same\",\"code\":\"5001\",\"enabled\":true},{\"id\":\"same\",\"code\":\"5006\",\"enabled\":true}],\"selectedIndex\":0}")] - [InlineData("{\"requestId\":\"REQ\",\"command\":\"prepare\",\"playlist\":[{\"id\":\"one\",\"code\":\"5001\",\"enabled\":true,\"fadeDuration\":61}],\"selectedIndex\":0}")] + [InlineData("{\"requestId\":\"REQ\",\"command\":\"prepare\",\"playlist\":[{\"id\":\"one\",\"code\":\"5001\",\"enabled\":true,\"fadeDuration\":20}],\"selectedIndex\":0}")] [InlineData("{\"requestId\":\"REQ\",\"command\":\"prepare\",\"playlist\":[{\"id\":\"one\",\"code\":\"5001\",\"enabled\":true,\"selection\":{\"groupCode\":\"코스피\",\"subject\":\"bad\\nvalue\",\"graphicType\":\"\",\"subtype\":\"\",\"dataCode\":\"005930\"}}],\"selectedIndex\":0}")] [InlineData("{\"requestId\":\"REQ\",\"command\":\"prepare\",\"playlist\":[{\"id\":\"one\",\"code\":\"5001\",\"enabled\":true,\"selection\":{\"groupCode\":\"코스피\",\"subject\":\"삼성전자\",\"graphicType\":\"\",\"subtype\":\"\",\"dataCode\":\"005930\",\"objectName\":\"title\"}}],\"selectedIndex\":0}")] public void ParseWorkflowCommand_RejectsCueOverrideDuplicateOrUnsafeShapes(string json) diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ReadOnlyPanelSceneBuildersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ReadOnlyPanelSceneBuildersTests.cs index 4e35083..6b5f757 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ReadOnlyPanelSceneBuildersTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ReadOnlyPanelSceneBuildersTests.cs @@ -546,7 +546,7 @@ public sealed class ReadOnlyPanelSceneBuildersTests PlayoutSetFaceColor color => $"C:{color.ObjectName}:{color.Red}:{color.Green}:{color.Blue}:{color.Alpha}", PlayoutSetBackgroundVideo video => - $"B:{video.AssetPath}:{video.LoopCount}:{(video.LoopInfinite ? 1 : 0)}", + $"B:{video.AssetPath}:{video.LoopCount}:{video.LoopInfinite}", _ => mutation.GetType().Name }; } diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/RegisteredLegacySceneCueProviderTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/RegisteredLegacySceneCueProviderTests.cs index f1d0f35..d147922 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/RegisteredLegacySceneCueProviderTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/RegisteredLegacySceneCueProviderTests.cs @@ -54,6 +54,30 @@ public sealed class RegisteredLegacySceneCueProviderTests page.Cue.Mutations!.Skip(2)); } + [Fact] + public async Task CreatePage_CommonVideoBackgroundUsesOriginalRawK3dLoopValueTen() + { + var provider = new RegisteredLegacySceneCueProvider( + new StubDataSource(new LegacySceneDataPage( + "s5085", + new S5085SceneData( + [ + new ProgramTradingMetricData(ProgramTradingMetric.NetBuy, 0d) + ]), + 1)), + options: new LegacySceneCueCompositionOptions( + 6, + LegacySceneBackgroundKind.Video, + "Video\\studio.vrv")); + + var page = await provider.CreatePageAsync(new("entry", "5085"), 0); + + Assert.Equal( + new PlayoutSetBackgroundVideo("Video\\studio.vrv", 2004, 10), + page.Cue.Mutations![0]); + Assert.Equal(new PlayoutUseBackground(true), page.Cue.Mutations[1]); + } + [Fact] public async Task CreatePagePlan_UsesDedicatedDtoRouteWithoutBuildingACue() { diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyCutKeyboardParityTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyCutKeyboardParityTests.cs new file mode 100644 index 0000000..73770f6 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyCutKeyboardParityTests.cs @@ -0,0 +1,169 @@ +namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests; + +public sealed class LegacyCutKeyboardParityTests +{ + [Fact] + public async Task ControlNavigationMovesOnlyFocusAndShiftKeepsTheNativeAnchor() + { + var controller = await CreateReadyControllerAsync(); + controller.CutPointerDown(2, false, false, 1, 47, 1_000); + + var controlDown = controller.CutKeyDown( + LegacyCutKeyboardKey.ArrowDown, + control: true, + shift: false); + var shiftSpace = controller.CutKeyDown( + LegacyCutKeyboardKey.Space, + control: false, + shift: true); + var plainDown = controller.CutKeyDown( + LegacyCutKeyboardKey.ArrowDown, + control: false, + shift: false); + var shiftUp = controller.CutKeyDown( + LegacyCutKeyboardKey.ArrowUp, + control: false, + shift: true); + + AssertCutState(controlDown, [2], focused: 3); + AssertCutState(shiftSpace, [2, 3], focused: 3); + AssertCutState(plainDown, [4], focused: 4); + AssertCutState(shiftUp, [3, 4], focused: 3); + } + + [Fact] + public async Task ControlShiftNavigationAddsRangeWithoutDiscardingExistingSelection() + { + var controller = await CreateReadyControllerAsync(); + controller.CutPointerDown(1, false, false, 1, 24, 1_000); + controller.CutPointerDown(4, true, false, 1, 93, 2_000); + controller.CutKeyDown( + LegacyCutKeyboardKey.ArrowUp, + control: true, + shift: false); + + var state = controller.CutKeyDown( + LegacyCutKeyboardKey.ArrowUp, + control: true, + shift: true); + + AssertCutState(state, [1, 2, 3, 4], focused: 2); + } + + [Fact] + public async Task HomeAndEndUsePhysicalBoundariesIncludingSeparatorRows() + { + var controller = await CreateReadyControllerAsync(); + controller.CutPointerDown(2, false, false, 1, 47, 1_000); + + var end = controller.CutKeyDown( + LegacyCutKeyboardKey.End, + control: false, + shift: false); + var controlHome = controller.CutKeyDown( + LegacyCutKeyboardKey.Home, + control: true, + shift: false); + var shiftSpace = controller.CutKeyDown( + LegacyCutKeyboardKey.Space, + control: false, + shift: true); + + var last = end.CutRows.Count - 1; + AssertCutState(end, [last], focused: last); + AssertCutState(controlHome, [last], focused: 0); + AssertCutState(shiftSpace, Enumerable.Range(0, last + 1), focused: 0); + } + + [Fact] + public async Task ControlSpaceTogglesFocusedRowAndMovesTheAnchor() + { + var controller = await CreateReadyControllerAsync(); + controller.CutPointerDown(1, false, false, 1, 24, 1_000); + + var toggledOff = controller.CutKeyDown( + LegacyCutKeyboardKey.Space, + control: true, + shift: false); + controller.CutKeyDown( + LegacyCutKeyboardKey.ArrowDown, + control: true, + shift: false); + var extended = controller.CutKeyDown( + LegacyCutKeyboardKey.Space, + control: false, + shift: true); + + AssertCutState(toggledOff, [], focused: 1); + AssertCutState(extended, [1, 2], focused: 2); + } + + [Fact] + public async Task KeyboardSelectionFeedsTheSameNativeDropAuthority() + { + var controller = await CreateReadyControllerAsync(); + + var selected = controller.CutKeyDown( + LegacyCutKeyboardKey.Space, + control: false, + shift: false); + var dropped = controller.DropSelectedCutsOnPlaylist(); + + AssertCutState(selected, [0], focused: 0); + Assert.Equal( + LegacyStockCutCatalog.Rows[0].RawLabel, + Assert.Single(dropped.Playlist).RawCutLabel); + } + + [Fact] + public void KeyboardSelectionIsIgnoredUntilAStockIsSelected() + { + var controller = new LegacyOperatorController(new OneStockLookup()); + var before = controller.Current; + + var after = controller.CutKeyDown( + LegacyCutKeyboardKey.End, + control: false, + shift: false); + + Assert.Equal(before.Revision, after.Revision); + Assert.DoesNotContain(after.CutRows, row => row.IsFocused || row.IsSelected); + } + + private static async Task CreateReadyControllerAsync() + { + var controller = new LegacyOperatorController(new OneStockLookup()); + await controller.SearchStocksAsync("stock", LegacySearchTrigger.Button); + controller.SelectStock(0); + return controller; + } + + private static void AssertCutState( + LegacyOperatorSnapshot snapshot, + IEnumerable selected, + int focused) + { + Assert.Equal( + selected, + snapshot.CutRows + .Where(row => row.IsSelected) + .Select(row => row.PhysicalIndex)); + Assert.Equal( + focused, + Assert.Single(snapshot.CutRows, row => row.IsFocused).PhysicalIndex); + } + + private sealed class OneStockLookup : ILegacyStockLookup + { + public Task SearchAsync( + string query, + CancellationToken cancellationToken = default) => + Task.FromResult(new LegacyStockSearchData( + query, + ["Stock"], + [new LegacyStockIdentity( + LegacyDomesticStockMarket.Kospi, + "Stock", + "000001")])); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyFixedActionCatalogTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyFixedActionCatalogTests.cs index 9590bce..25fe5b5 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyFixedActionCatalogTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyFixedActionCatalogTests.cs @@ -125,33 +125,25 @@ public sealed class LegacyFixedActionCatalogTests Assert.Equal("", result.Selection.DataCode); } - [Theory] - [InlineData(false, false, "")] - [InlineData(true, false, "MA5")] - [InlineData(false, true, "MA20")] - [InlineData(true, true, "MA5,MA20")] - public void Materialize_ClosesMovingAverageFlags(bool ma5, bool ma20, string expected) + [Fact] + public void Materialize_LeavesMovingAverageStateOutOfTheActionIdentity() { var result = LegacyFixedActionCatalog.Default.Materialize( "fixed-136", - new DateTimeOffset(2026, 7, 15, 9, 0, 0, TimeSpan.FromHours(9)), - new LegacyMovingAverageSelection(ma5, ma20)); + new DateTimeOffset(2026, 7, 15, 9, 0, 0, TimeSpan.FromHours(9))); - Assert.Equal(expected, result.Selection.GraphicType); - Assert.Equal(new LegacyMovingAverageSelection(ma5, ma20), result.MovingAverages); + Assert.Equal(string.Empty, result.Selection.GraphicType); Assert.Equal("", result.Action.Selection.GraphicType); } [Fact] - public void Materialize_IgnoresMovingAverageFlagsForOtherBuilders() + public void Materialize_RetainsGraphicTypeForOtherBuilders() { var result = LegacyFixedActionCatalog.Default.Materialize( "fixed-001", - DateTimeOffset.Now, - new LegacyMovingAverageSelection(true, true)); + DateTimeOffset.Now); Assert.Equal("1열판기본", result.Selection.GraphicType); - Assert.Equal(default, result.MovingAverages); } [Fact] @@ -166,8 +158,7 @@ public sealed class LegacyFixedActionCatalogTests { var before = catalog.Materialize( action.Id, - beforeBoundary, - new LegacyMovingAverageSelection(true, true)); + beforeBoundary); Assert.DoesNotContain('$', before.Selection.GroupCode); Assert.DoesNotContain('$', before.Selection.Subject); Assert.DoesNotContain('$', before.Selection.GraphicType); @@ -187,7 +178,7 @@ public sealed class LegacyFixedActionCatalogTests } else if (action.SupportsMovingAverages) { - Assert.Equal("MA5,MA20", before.Selection.GraphicType); + Assert.Equal(action.Selection.GraphicType, before.Selection.GraphicType); } else { diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorControllerTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorControllerTests.cs index a870423..88fe090 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorControllerTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorControllerTests.cs @@ -181,9 +181,12 @@ public sealed class LegacyOperatorControllerTests var candleRow = candle.Playlist[1]; Assert.Equal("8051", candleRow.ToPlayoutEntry().CutCode); Assert.Equal( - "MA5", + string.Empty, Assert.IsType( candleRow.ToPlayoutEntry().Selection).GraphicType); + Assert.Equal( + "MA5", + controller.CreatePlayoutRequest().Playlist[1].Selection!.GraphicType); } [Fact] @@ -242,17 +245,24 @@ public sealed class LegacyOperatorControllerTests { var controller = await ControllerWithPlaylistAsync(0, 1, 2); var rows = controller.Current.Playlist; + controller.SelectPlaylistRow(rows[0].RowId, control: false, shift: false); + controller.SelectPlaylistRow(rows[1].RowId, control: true, shift: false); var last = controller.SelectPlaylistBoundary(last: true); Assert.True(last.Playlist[0].IsEnabled); - Assert.True(last.Playlist[2].IsSelected); + Assert.True(last.Playlist[2].IsActive); + Assert.Equal( + rows.Take(2).Select(row => row.RowId), + last.Playlist.Where(row => row.IsSelected).Select(row => row.RowId)); var toggled = controller.ToggleActivePlaylistRowEnabled(); Assert.False(toggled.Playlist[2].IsEnabled); var first = controller.SelectPlaylistBoundary(last: false); - Assert.True(first.Playlist[0].IsSelected); - Assert.False(first.Playlist[2].IsSelected); + Assert.True(first.Playlist[0].IsActive); + Assert.Equal( + rows.Take(2).Select(row => row.RowId), + first.Playlist.Where(row => row.IsSelected).Select(row => row.RowId)); } [Fact] @@ -307,7 +317,7 @@ public sealed class LegacyOperatorControllerTests } [Fact] - public void MovingAverageFlagsAreCapturedInFixedCandleSelection() + public void FixedCandleIdentityStaysStableWhileF8SamplesCurrentMovingAverages() { var controller = new LegacyOperatorController(Lookup()); var candle = LegacyFixedActionCatalog.Default @@ -319,11 +329,23 @@ public sealed class LegacyOperatorControllerTests var state = controller.ActivateFixedAction(candle.Id); Assert.Equal( - "MA20", + string.Empty, Assert.IsType( Assert.Single(state.Playlist).ToPlayoutEntry().Selection).GraphicType); + Assert.Equal( + "MA20", + Assert.Single(controller.CreatePlayoutRequest().Playlist).Selection!.GraphicType); Assert.False(state.MovingAverages.Ma5); Assert.True(state.MovingAverages.Ma20); + + controller.SetMovingAverages(ma5: true, ma20: false); + + Assert.Equal( + "MA5", + Assert.Single(controller.CreatePlayoutRequest().Playlist).Selection!.GraphicType); + Assert.Equal( + string.Empty, + Assert.Single(controller.Current.Playlist).PlayoutSelection!.GraphicType); } [Fact] @@ -356,6 +378,9 @@ public sealed class LegacyOperatorControllerTests Assert.Equal("현재가", row.Subtype); Assert.Equal("005930", row.StockCode); Assert.Equal("5001", row.ToPlayoutEntry().CutCode); + Assert.True(row.IsActive); + Assert.False(row.IsSelected); + Assert.Equal(0, controller.CreatePlayoutRequest().SelectedIndexZeroBased); } [Fact] @@ -558,6 +583,89 @@ public sealed class LegacyOperatorControllerTests Assert.All(state.Playlist, row => Assert.True(row.IsSelected)); } + [Fact] + public async Task CheckboxActivationChangesF8CandidateWithoutReplacingDeleteSelection() + { + var controller = await ControllerWithPlaylistAsync(0, 1, 2); + var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray(); + controller.SelectPlaylistRow(ids[0], control: false, shift: false); + controller.SelectPlaylistRow(ids[1], control: true, shift: false); + + var state = controller.ActivatePlaylistRow(ids[2]); + + Assert.Equal( + ids[..2], + state.Playlist.Where(row => row.IsSelected).Select(row => row.RowId)); + Assert.Equal(ids[2], Assert.Single(state.Playlist, row => row.IsActive).RowId); + Assert.Equal(2, controller.CreatePlayoutRequest().SelectedIndexZeroBased); + } + + [Fact] + public async Task CheckboxActivationDoesNotMoveTheFollowingShiftSelectionAnchor() + { + var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4); + var ids = controller.Current.Playlist.Select(row => row.RowId).ToArray(); + controller.SelectPlaylistRow(ids[1], control: false, shift: false); + + controller.ActivatePlaylistRow(ids[3]); + var range = controller.SelectPlaylistRow(ids[2], control: false, shift: true); + + Assert.Equal( + ids[1..3], + range.Playlist.Where(row => row.IsSelected).Select(row => row.RowId)); + Assert.Equal(ids[2], Assert.Single(range.Playlist, row => row.IsActive).RowId); + } + + [Fact] + public async Task HomeAndEndChangeOnlyTheF8CandidateAndKeepProgramPageProjection() + { + var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4); + var rows = controller.Current.Playlist; + controller.SelectPlaylistRow(rows[1].RowId, control: false, shift: false); + controller.SelectPlaylistRow(rows[3].RowId, control: true, shift: false); + controller.ReflectSuccessfulPlayout( + rows[1].RowId, + pageIndexZeroBased: 1, + pageCount: 3, + synchronizeOperatorSelection: false); + + var home = controller.SelectPlaylistBoundary(last: false); + Assert.Equal(0, controller.CreatePlayoutRequest().SelectedIndexZeroBased); + Assert.Equal("2/3", home.Playlist[1].PageText); + Assert.True(home.Playlist[0].IsActive); + Assert.Equal( + [rows[1].RowId, rows[3].RowId], + home.Playlist.Where(row => row.IsSelected).Select(row => row.RowId)); + + var end = controller.SelectPlaylistBoundary(last: true); + Assert.Equal(3, controller.CreatePlayoutRequest().SelectedIndexZeroBased); + Assert.Equal("2/3", end.Playlist[1].PageText); + Assert.True(end.Playlist[3].IsActive); + + var shift = controller.SelectPlaylistRow( + rows[2].RowId, + control: false, + shift: true); + Assert.Equal( + [rows[1].RowId, rows[2].RowId], + shift.Playlist.Where(row => row.IsSelected).Select(row => row.RowId)); + } + + [Fact] + public async Task FirstAppendedRowIsAnF8CandidateButNotAnImplicitDeleteSelection() + { + var controller = await ControllerWithPlaylistAsync(0); + var before = controller.Current; + + Assert.True(Assert.Single(before.Playlist).IsActive); + Assert.DoesNotContain(before.Playlist, row => row.IsSelected); + Assert.Equal(0, controller.CreatePlayoutRequest().SelectedIndexZeroBased); + + var after = controller.DeleteSelectedPlaylistRows(); + Assert.Equal(before.Revision, after.Revision); + Assert.Single(after.Playlist); + } + [Fact] public async Task PlaylistEnabledSupportsIndividualToggleAndAllRows() { @@ -591,6 +699,73 @@ public sealed class LegacyOperatorControllerTests [initial[0].RowId, initial[2].RowId], state.Playlist.Select(row => row.RowId)); Assert.DoesNotContain(state.Playlist, row => row.IsSelected); + Assert.Equal(initial[2].RowId, Assert.Single(state.Playlist, row => row.IsActive).RowId); + Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased); + } + + [Fact] + public async Task DeletePreservesAnIndependentCheckboxCandidateByStableIdentity() + { + var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4); + var initial = controller.Current.Playlist.ToArray(); + controller.SelectPlaylistRow(initial[0].RowId, control: false, shift: false); + controller.SelectPlaylistRow(initial[1].RowId, control: true, shift: false); + controller.ActivatePlaylistRow(initial[3].RowId); + + var state = controller.DeleteSelectedPlaylistRows(); + + Assert.Equal( + [initial[2].RowId, initial[3].RowId], + state.Playlist.Select(row => row.RowId)); + Assert.DoesNotContain(state.Playlist, row => row.IsSelected); + Assert.Equal(initial[3].RowId, Assert.Single(state.Playlist, row => row.IsActive).RowId); + Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased); + } + + [Fact] + public async Task DeleteActiveCandidateChoosesTheNextSurvivorThenThePreviousAtEnd() + { + var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4); + var initial = controller.Current.Playlist.ToArray(); + controller.SelectPlaylistRow(initial[1].RowId, control: false, shift: false); + + var afterMiddle = controller.DeleteSelectedPlaylistRows(); + + Assert.Equal(initial[2].RowId, Assert.Single(afterMiddle.Playlist, row => row.IsActive).RowId); + Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased); + + controller.SelectPlaylistRow(initial[3].RowId, control: false, shift: false); + var afterLast = controller.DeleteSelectedPlaylistRows(); + + Assert.Equal(initial[2].RowId, Assert.Single(afterLast.Playlist, row => row.IsActive).RowId); + Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased); + } + + [Fact] + public async Task ShiftSelectionAndDeleteAreNotLimitedToTheOriginalHundredRowBuffers() + { + var controller = new LegacyOperatorController(Lookup()); + await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button); + controller.SelectStock(0); + controller.CutPointerDown(1, false, false, 1, 24, 1_000); + for (var index = 0; index < 101; index++) + { + controller.DropSelectedCutsOnPlaylist(); + } + + var rows = controller.Current.Playlist; + Assert.Equal(101, rows.Count); + controller.SelectPlaylistRow(rows[0].RowId, control: false, shift: false); + var selected = controller.SelectPlaylistRow( + rows[^1].RowId, + control: false, + shift: true); + Assert.Equal(101, selected.Playlist.Count(row => row.IsSelected)); + + var deleted = controller.DeleteSelectedPlaylistRows(); + + Assert.Empty(deleted.Playlist); + Assert.Throws(() => controller.CreatePlayoutRequest()); } [Fact] @@ -610,6 +785,21 @@ public sealed class LegacyOperatorControllerTests Assert.Equal(unchangedRevision, stillEmpty.Revision); } + [Fact] + public async Task FirstAppendAfterDeleteAllReestablishesTheVisibleF8Candidate() + { + var controller = await ControllerWithPlaylistAsync(0, 1); + controller.DeleteAllPlaylistRows(); + + controller.CutPointerDown(2, false, false, 1, 47, 10_000); + var state = controller.DropSelectedCutsOnPlaylist(); + + var row = Assert.Single(state.Playlist); + Assert.True(row.IsActive); + Assert.False(row.IsSelected); + Assert.Equal(0, controller.CreatePlayoutRequest().SelectedIndexZeroBased); + } + [Fact] public async Task MoveUsesOnlyActiveRowAndCollapsesMultiSelectionToMovedRow() { @@ -626,12 +816,17 @@ public sealed class LegacyOperatorControllerTests Assert.Equal( initial[2].RowId, Assert.Single(moved.Playlist, row => row.IsSelected).RowId); + Assert.Equal(initial[2].ToPlayoutEntry(), moved.Playlist[1].ToPlayoutEntry()); + Assert.Equal(initial[2].PageText, moved.Playlist[1].PageText); + Assert.Equal(initial[2].RawCutLabel, moved.Playlist[1].RawCutLabel); + Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased); var restored = controller.MoveSelectedPlaylistRows(LegacyPlaylistMoveDirection.Down); Assert.Equal(initial.Select(row => row.RowId), restored.Playlist.Select(row => row.RowId)); Assert.Equal( initial[2].RowId, Assert.Single(restored.Playlist, row => row.IsSelected).RowId); + Assert.Equal(2, controller.CreatePlayoutRequest().SelectedIndexZeroBased); } [Fact] @@ -651,7 +846,7 @@ public sealed class LegacyOperatorControllerTests } [Fact] - public async Task PlaylistDragMovesNoncontiguousSelectionAsOneOrderedBlockAndIsIdempotent() + public async Task PlaylistDragMovesOnlySourceRowCollapsesSelectionAndIsIdempotent() { var controller = await ControllerWithPlaylistAsync(0, 1, 2, 4, 5, 6); var initial = controller.Current.Playlist.ToArray(); @@ -666,16 +861,19 @@ public sealed class LegacyOperatorControllerTests Assert.Equal( [ initial[0].RowId, + initial[1].RowId, initial[2].RowId, initial[4].RowId, - initial[1].RowId, initial[3].RowId, initial[5].RowId ], moved.Playlist.Select(row => row.RowId)); Assert.Equal( - [initial[1].RowId, initial[3].RowId], - moved.Playlist.Where(row => row.IsSelected).Select(row => row.RowId)); + initial[3].RowId, + Assert.Single(moved.Playlist, row => row.IsSelected).RowId); + Assert.Equal( + initial[3].RowId, + Assert.Single(moved.Playlist, row => row.IsActive).RowId); var revision = moved.Revision; var duplicate = controller.ReorderPlaylistRows( @@ -686,6 +884,9 @@ public sealed class LegacyOperatorControllerTests Assert.Equal( moved.Playlist.Select(row => row.RowId), duplicate.Playlist.Select(row => row.RowId)); + Assert.Equal( + initial[3].RowId, + Assert.Single(duplicate.Playlist, row => row.IsSelected).RowId); } [Fact] diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorPlayoutRequestTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorPlayoutRequestTests.cs index 7795f74..905f76a 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorPlayoutRequestTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorPlayoutRequestTests.cs @@ -1,4 +1,6 @@ using MMoneyCoderSharp.Data; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests; @@ -36,6 +38,229 @@ public sealed class LegacyOperatorPlayoutRequestTests Assert.Equal("005930", entry.Selection?.DataCode)); } + [Theory] + [InlineData(false, false, "")] + [InlineData(true, false, "MA5")] + [InlineData(false, true, "MA20")] + [InlineData(true, true, "MA5,MA20")] + public async Task CandleAddedBeforeToggleSamplesCurrentMovingAveragesAtF8Request( + bool ma5, + bool ma20, + string expectedGraphicType) + { + var controller = new LegacyOperatorController(new StaticLookup()); + await controller.SearchStocksAsync("stock", LegacySearchTrigger.Button); + controller.SelectStock(0); + var candle = LegacyStockCutCatalog.Rows.First(row => + !row.IsSeparator && + LegacyStockCutCatalog.ResolveCutAlias(row.RawLabel, isNxt: false) == "8051"); + AddCut(controller, candle.PhysicalIndex, 1_000); + var rawBeforeToggle = Assert.Single(controller.Current.Playlist); + var rawGraphicType = rawBeforeToggle.ToPlayoutEntry().Selection!.GraphicType; + + controller.SetMovingAverages(ma5, ma20); + var entry = Assert.Single(controller.CreatePlayoutRequest().Playlist); + + Assert.Equal(expectedGraphicType, entry.Selection!.GraphicType); + Assert.Equal(rawGraphicType, + Assert.Single(controller.Current.Playlist).ToPlayoutEntry().Selection!.GraphicType); + Assert.Equal( + new LegacySceneMovingAverageSelection(ma5, ma20), + entry.MovingAverageSelectionSource!.Current); + } + + [Fact] + public async Task MovingAverageOverlayDoesNotAlterNonCandleRequest() + { + var controller = new LegacyOperatorController(new StaticLookup()); + await controller.SearchStocksAsync("stock", LegacySearchTrigger.Button); + controller.SelectStock(0); + var current = LegacyStockCutCatalog.Rows.First(row => + !row.IsSeparator && + LegacyStockCutCatalog.ResolveCutAlias(row.RawLabel, isNxt: false) == "5001"); + AddCut(controller, current.PhysicalIndex, 1_000); + var raw = Assert.Single(controller.Current.Playlist).ToPlayoutEntry(); + + controller.SetMovingAverages(ma5: false, ma20: false); + var requested = Assert.Single(controller.CreatePlayoutRequest().Playlist); + + Assert.Equal(raw.Selection, requested.Selection); + Assert.Null(requested.MovingAverageSelectionSource); + } + + [Theory] + [InlineData(0)] + [InlineData(6)] + [InlineData(19)] + public async Task RequestAppliesCurrentGlobalDissolveIndexToEveryExistingRow( + int globalFadeDuration) + { + var controller = await CreateControllerWithCutsAsync(3); + Assert.All(controller.Current.Playlist, row => Assert.Equal(6, row.FadeDuration)); + + var request = controller.CreatePlayoutRequest(globalFadeDuration); + + Assert.All(request.Playlist, entry => + Assert.Equal(globalFadeDuration, entry.FadeDuration)); + Assert.All(controller.Current.Playlist, row => Assert.Equal(6, row.FadeDuration)); + } + + [Theory] + [InlineData(-1)] + [InlineData(20)] + [InlineData(60)] + public async Task RequestRejectsGlobalDissolveIndexOutsideOriginalComboRange( + int globalFadeDuration) + { + var controller = await CreateControllerWithCutsAsync(1); + + Assert.Throws(() => + controller.CreatePlayoutRequest(globalFadeDuration)); + } + + [Theory] + [InlineData("5단 표그래프", ScenePageSize.Five)] + [InlineData("6종목 현재가", ScenePageSize.Six)] + [InlineData("12종목 현재가", ScenePageSize.Twelve)] + public void ExactLegacyGraphicTypeFreezesTheOperatorPageSuffix( + string graphicType, + ScenePageSize expectedPageSize) + { + var entry = OperatorRow(graphicType, "2/3").ToPlayoutEntry(); + + Assert.Equal(expectedPageSize, entry.PageNavigation?.PageSize); + Assert.Equal(3, entry.PageNavigation?.FrozenPageCount); + Assert.True(entry.PageNavigation?.IsEnabled); + } + + [Theory] + [InlineData("5단 표그래프 ")] + [InlineData("5단표그래프")] + [InlineData("five-row")] + public void NonCanonicalGraphicTypeExplicitlyDisablesPageNext(string graphicType) + { + var entry = OperatorRow(graphicType, "1/9").ToPlayoutEntry(); + + Assert.NotNull(entry.PageNavigation); + Assert.False(entry.PageNavigation.IsEnabled); + Assert.Null(entry.PageNavigation.PageSize); + Assert.Null(entry.PageNavigation.FrozenPageCount); + } + + [Theory] + [InlineData("1/0")] + [InlineData("1/21")] + [InlineData("2/1")] + [InlineData("1 /2")] + public void CanonicalPagedGraphicRejectsInvalidOperatorPageText(string pageText) + { + var row = OperatorRow("5단 표그래프", pageText); + + Assert.Throws(row.ToPlayoutEntry); + } + + [Fact] + public async Task NextProgressUpdatesProgramPageWithoutReplacingOperatorSelection() + { + var controller = await CreateControllerWithCutsAsync(3); + var rows = controller.Current.Playlist; + controller.SelectPlaylistRow(rows[2].RowId, control: false, shift: false); + + var state = controller.ReflectSuccessfulPlayout( + rows[0].RowId, + pageIndexZeroBased: 1, + pageCount: 3, + synchronizeOperatorSelection: false); + + Assert.Equal("2/3", state.Playlist[0].PageText); + Assert.True(state.Playlist[2].IsSelected); + Assert.False(state.Playlist[0].IsSelected); + Assert.Equal(2, controller.CreatePlayoutRequest().SelectedIndexZeroBased); + } + + [Fact] + public async Task TakeSkipMovesOnlyTheF8CandidateAndPreservesDeleteSelection() + { + var controller = await CreateControllerWithCutsAsync(2); + var rows = controller.Current.Playlist; + controller.SelectPlaylistRow(rows[0].RowId, control: false, shift: false); + controller.ReflectSuccessfulPlayout( + rows[0].RowId, + pageIndexZeroBased: 2, + pageCount: 3, + synchronizeOperatorSelection: false); + + var state = controller.ReflectSuccessfulPlayout( + rows[1].RowId, + pageIndexZeroBased: 0, + pageCount: 2, + synchronizeOperatorSelection: true); + + Assert.Equal("1/2", state.Playlist[1].PageText); + Assert.True(state.Playlist[0].IsSelected); + Assert.False(state.Playlist[1].IsSelected); + Assert.True(state.Playlist[1].IsActive); + Assert.Equal(1, controller.CreatePlayoutRequest().SelectedIndexZeroBased); + } + + [Fact] + public async Task SuccessfulF8DoesNotReplaceTheRowsAccumulatedForDelete() + { + var controller = await CreateControllerWithCutsAsync(3); + var rows = controller.Current.Playlist; + controller.SelectPlaylistRow(rows[0].RowId, control: false, shift: false); + controller.SelectPlaylistRow(rows[2].RowId, control: true, shift: false); + + var reflected = controller.ReflectSuccessfulPlayout( + rows[2].RowId, + pageIndexZeroBased: 0, + pageCount: 1, + synchronizeOperatorSelection: true); + + Assert.Equal( + [rows[0].RowId, rows[2].RowId], + reflected.Playlist.Where(row => row.IsSelected).Select(row => row.RowId)); + Assert.True(reflected.Playlist[2].IsActive); + + var deleted = controller.DeleteSelectedPlaylistRows(); + Assert.Equal(rows[1].RowId, Assert.Single(deleted.Playlist).RowId); + } + + private static async Task CreateControllerWithCutsAsync(int count) + { + var controller = new LegacyOperatorController(new StaticLookup()); + await controller.SearchStocksAsync("?쇱꽦", LegacySearchTrigger.Button); + controller.SelectStock(0); + for (var index = 0; index < count; index++) + { + AddCut(controller, index, 1_000 + (index * 1_000)); + } + + return controller; + } + + private static LegacyOperatorPlaylistRow OperatorRow( + string graphicType, + string pageText) => + new( + "row-1", + true, + "테마", + "반도체", + graphicType, + "현재가", + pageText, + "00123", + "paged-cut", + 6, + SceneAlias: "5074", + PlayoutSelection: new( + "테마", + "반도체", + graphicType, + "현재가", + "00123")); + private static void AddCut( LegacyOperatorController controller, int physicalIndex, diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOverseasOperatorControllerTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOverseasOperatorControllerTests.cs index 8f6f355..49b780d 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOverseasOperatorControllerTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOverseasOperatorControllerTests.cs @@ -34,13 +34,16 @@ public sealed class LegacyOverseasOperatorControllerTests Assert.Equal("legacy-overseas-00000001", row.RowId); Assert.Equal("FOREIGN_INDEX", row.MarketText); Assert.Equal("나스닥 캔들 20일", row.StockName); - Assert.Equal("MA20", row.GraphicType); + Assert.Equal("캔들그래프", row.GraphicType); Assert.Equal("PRICE", row.Subtype); Assert.Equal("1/1", row.PageText); Assert.Equal("8040", entry.CutCode); Assert.Equal("FOREIGN_INDEX", selection.GroupCode); Assert.Equal("나스닥", selection.Subject); Assert.Equal("NAS@IXIC|US|0", selection.DataCode); + Assert.Equal( + "MA20", + Assert.Single(controller.CreatePlayoutRequest().Playlist).Selection!.GraphicType); } [Fact] @@ -72,11 +75,15 @@ public sealed class LegacyOverseasOperatorControllerTests Assert.Equal(2, stockState.Playlist.Count); var stockRow = stockState.Playlist[1]; Assert.Equal("8061", stockRow.ToPlayoutEntry().CutCode); + Assert.Equal("캔들그래프", stockRow.GraphicType); Assert.Equal("FOREIGN_STOCK", stockRow.PlayoutSelection!.GroupCode); Assert.Equal("Apple", stockRow.PlayoutSelection.Subject); - Assert.Equal("MA5,MA20", stockRow.PlayoutSelection.GraphicType); + Assert.Equal(string.Empty, stockRow.PlayoutSelection.GraphicType); Assert.Equal("PRICE", stockRow.PlayoutSelection.Subtype); Assert.Equal("NAS@AAPL|US|1", stockRow.PlayoutSelection.DataCode); + Assert.Equal( + "MA5,MA20", + controller.CreatePlayoutRequest().Playlist[1].Selection!.GraphicType); } [Fact] diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyRuntimeStockCutMenuLoaderTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyRuntimeStockCutMenuLoaderTests.cs new file mode 100644 index 0000000..faa11c7 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyRuntimeStockCutMenuLoaderTests.cs @@ -0,0 +1,420 @@ +using System.Text; +using MBN_STOCK_WEBVIEW.LegacyApplication; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests; + +public sealed class LegacyRuntimeStockCutMenuLoaderTests +{ + [Fact] + public void TryParse_UsesCp949AndPreservesTrimmedPhysicalOrder() + { + const string first = "1열판기본_현재가"; + Assert.Equal(first, LegacyStockCutCatalog.Rows[1].RawLabel); + var second = LegacyStockCutCatalog.Rows[0].RawLabel; + var bytes = EncodeCp949($" \r\n [종목] \r\n {first} \r\n\r\n - \r\n{second}\r\n{first}\r\n"); + + var loaded = LegacyRuntimeStockCutMenuLoader.TryParse( + bytes, + out var rows, + out var failure); + + Assert.True(loaded); + Assert.Equal(LegacyRuntimeStockCutMenuFailure.None, failure); + Assert.Collection( + rows, + row => Assert.Equal((0, 1, first, false), Fields(row)), + row => Assert.Equal((1, null, string.Empty, true), Fields(row)), + row => Assert.Equal((2, 2, second, false), Fields(row)), + row => Assert.Equal((3, 3, first, false), Fields(row))); + } + + [Fact] + public void TryLoad_ReadsAStandaloneRuntimeFileWithoutBundlingTheAsset() + { + var directory = Path.Combine( + Path.GetTempPath(), + $"mbn-stock-cut-menu-{Guid.NewGuid():N}"); + var path = Path.Combine(directory, "종목.ini"); + Directory.CreateDirectory(directory); + try + { + var label = LegacyStockCutCatalog.Rows[0].RawLabel; + File.WriteAllBytes(path, EncodeCp949($"[종목]\r\n{label}\r\n")); + + var loaded = LegacyRuntimeStockCutMenuLoader.TryLoad( + path, + out var rows, + out var failure); + + Assert.True(loaded); + Assert.Equal(LegacyRuntimeStockCutMenuFailure.None, failure); + var row = Assert.Single(rows); + Assert.Equal((0, 1, label, false), Fields(row)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Theory] + [InlineData(50, true, LegacyRuntimeStockCutMenuFailure.None)] + [InlineData(51, false, LegacyRuntimeStockCutMenuFailure.TooManyRows)] + public void TryParse_EnforcesTheLegacyFiftyPhysicalRowLimit( + int rowCount, + bool expectedSuccess, + LegacyRuntimeStockCutMenuFailure expectedFailure) + { + var label = LegacyStockCutCatalog.Rows[0].RawLabel; + var text = LegacyRuntimeStockCutMenuLoader.StockSectionHeader + "\r\n" + + string.Join("\r\n", Enumerable.Repeat(label, rowCount)); + + var loaded = LegacyRuntimeStockCutMenuLoader.TryParse( + EncodeCp949(text), + out var rows, + out var failure); + + Assert.Equal(expectedSuccess, loaded); + Assert.Equal(expectedFailure, failure); + Assert.Equal(expectedSuccess ? rowCount : 0, rows.Count); + } + + public static TheoryData MalformedMenus => new() + { + { string.Empty, LegacyRuntimeStockCutMenuFailure.InvalidFormat }, + { "[종목", LegacyRuntimeStockCutMenuFailure.InvalidFormat }, + { LegacyStockCutCatalog.Rows[0].RawLabel, LegacyRuntimeStockCutMenuFailure.InvalidFormat }, + { "[다른메뉴]\r\n-", LegacyRuntimeStockCutMenuFailure.InvalidFormat }, + { "[종목]\r\n[다른메뉴]", LegacyRuntimeStockCutMenuFailure.InvalidFormat }, + { "[종목]", LegacyRuntimeStockCutMenuFailure.EmptyMenu }, + { "[종목]\r\n-", LegacyRuntimeStockCutMenuFailure.EmptyMenu }, + { "[종목]\r\n지원하지않는_컷", LegacyRuntimeStockCutMenuFailure.UnknownCut } + }; + + [Theory] + [MemberData(nameof(MalformedMenus))] + public void TryParse_FailsClosedWithoutReturningPartialRows( + string text, + LegacyRuntimeStockCutMenuFailure expectedFailure) + { + var loaded = LegacyRuntimeStockCutMenuLoader.TryParse( + EncodeCp949(text), + out var rows, + out var failure); + + Assert.False(loaded); + Assert.Empty(rows); + Assert.Equal(expectedFailure, failure); + } + + [Fact] + public void TryParse_RejectsAnIncompleteCp949Sequence() + { + var loaded = LegacyRuntimeStockCutMenuLoader.TryParse( + new byte[] { 0x81 }, + out var rows, + out var failure); + + Assert.False(loaded); + Assert.Empty(rows); + Assert.Equal(LegacyRuntimeStockCutMenuFailure.InvalidEncoding, failure); + } + + [Fact] + public void TryLoad_MissingOrBlankPathLetsTheCallerUseTheBuiltInCatalog() + { + var missing = Path.Combine( + Path.GetTempPath(), + $"missing-cut-menu-{Guid.NewGuid():N}.ini"); + + Assert.False(LegacyRuntimeStockCutMenuLoader.TryLoad( + missing, + out var missingRows, + out var missingFailure)); + Assert.Empty(missingRows); + Assert.Equal(LegacyRuntimeStockCutMenuFailure.FileNotFound, missingFailure); + + Assert.False(LegacyRuntimeStockCutMenuLoader.TryLoad( + " ", + out var blankRows, + out var blankFailure)); + Assert.Empty(blankRows); + Assert.Equal(LegacyRuntimeStockCutMenuFailure.InvalidPath, blankFailure); + Assert.NotEmpty(LegacyStockCutCatalog.Rows); + } + + [Fact] + public void TryLoad_RejectsAnOversizedFileBeforeAllocatingOrDecodingItsContent() + { + var path = Path.Combine( + Path.GetTempPath(), + $"oversized-cut-menu-{Guid.NewGuid():N}.ini"); + try + { + using (var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write)) + { + stream.SetLength(LegacyRuntimeStockCutMenuLoader.MaximumFileBytes + 1L); + } + + var loaded = LegacyRuntimeStockCutMenuLoader.TryLoad( + path, + out var rows, + out var failure); + + Assert.False(loaded); + Assert.Empty(rows); + Assert.Equal(LegacyRuntimeStockCutMenuFailure.FileTooLarge, failure); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ResolveFromBaseDirectory_MissingFileReturnsBoundedPathFreeFallback() + { + var directory = Path.Combine( + Path.GetTempPath(), + $"mbn-stock-cut-composition-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var composition = + LegacyRuntimeStockCutMenuLoader.ResolveFromBaseDirectory(directory); + + Assert.True(composition.UsesBuiltInCatalog); + Assert.Equal(LegacyRuntimeStockCutMenuSource.BuiltIn, composition.Source); + Assert.Same(LegacyStockCutCatalog.Rows, composition.Rows); + Assert.Equal( + LegacyRuntimeStockCutMenuFailure.FileNotFound, + composition.Failure); + Assert.Equal( + LegacyRuntimeStockCutMenuLoader.FallbackWarningMessage, + composition.WarningMessage); + var warning = Assert.IsType(composition.WarningMessage); + Assert.InRange(warning.Length, 1, 80); + Assert.DoesNotContain(directory, warning, + StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("종목.ini", warning, + StringComparison.Ordinal); + Assert.DoesNotContain('\\', warning); + Assert.DoesNotContain('/', warning); + var controller = new LegacyOperatorController( + new EmptyStockLookup(), + cutRows: composition.Rows); + var warningState = controller.ReportWarning(warning); + Assert.Equal(LegacyOperatorStatusKind.Warning, warningState.StatusKind); + Assert.Equal(composition.WarningMessage, warningState.StatusMessage); + + var resourceDirectory = Path.Combine(directory, "Res"); + Directory.CreateDirectory(resourceDirectory); + File.WriteAllBytes( + Path.Combine(resourceDirectory, "종목.ini"), + EncodeCp949("[종목]\r\n지원하지않는_컷\r\n")); + var invalid = + LegacyRuntimeStockCutMenuLoader.ResolveFromBaseDirectory(directory); + Assert.True(invalid.UsesBuiltInCatalog); + Assert.Same(LegacyStockCutCatalog.Rows, invalid.Rows); + Assert.Equal(LegacyRuntimeStockCutMenuFailure.UnknownCut, invalid.Failure); + Assert.Equal(composition.WarningMessage, invalid.WarningMessage); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void ResolveFromBaseDirectory_ValidRuntimeFileReturnsOnlyItsRows() + { + var directory = Path.Combine( + Path.GetTempPath(), + $"mbn-stock-cut-composition-{Guid.NewGuid():N}"); + var resourceDirectory = Path.Combine(directory, "Res"); + Directory.CreateDirectory(resourceDirectory); + try + { + const string label = "1열판기본_현재가"; + File.WriteAllBytes( + Path.Combine(resourceDirectory, "종목.ini"), + EncodeCp949($"[종목]\r\n{label}\r\n")); + + var composition = + LegacyRuntimeStockCutMenuLoader.ResolveFromBaseDirectory(directory); + + Assert.False(composition.UsesBuiltInCatalog); + Assert.Equal(LegacyRuntimeStockCutMenuSource.Packaged, composition.Source); + Assert.Equal(LegacyRuntimeStockCutMenuFailure.None, composition.Failure); + Assert.Null(composition.WarningMessage); + Assert.Equal(label, Assert.Single(composition.Rows).RawLabel); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void ResolveFromCandidates_ExternalOverrideTakesPrecedenceOverPackagedMenu() + { + var paths = CreateCandidatePaths(); + try + { + WriteMenu(paths.ExternalPath, "1열판기본_현재가"); + WriteMenu(paths.PackagedPath, "1열판기본_예상체결가"); + + var composition = LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates( + paths.ExternalPath, + paths.PackagedBaseDirectory); + + Assert.Equal( + LegacyRuntimeStockCutMenuSource.ExternalOverride, + composition.Source); + Assert.False(composition.UsesBuiltInCatalog); + Assert.Null(composition.WarningMessage); + Assert.Equal( + "1열판기본_현재가", + Assert.Single(composition.Rows).RawLabel); + } + finally + { + Directory.Delete(paths.Root, recursive: true); + } + } + + [Fact] + public void ResolveFromCandidates_MissingExternalContinuesToPackagedMenu() + { + var paths = CreateCandidatePaths(); + try + { + WriteMenu(paths.PackagedPath, "1열판기본_예상체결가"); + + var composition = LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates( + paths.ExternalPath, + paths.PackagedBaseDirectory); + + Assert.Equal(LegacyRuntimeStockCutMenuSource.Packaged, composition.Source); + Assert.False(composition.UsesBuiltInCatalog); + Assert.Null(composition.WarningMessage); + Assert.Equal( + "1열판기본_예상체결가", + Assert.Single(composition.Rows).RawLabel); + } + finally + { + Directory.Delete(paths.Root, recursive: true); + } + } + + [Fact] + public void ResolveFromCandidates_InvalidExternalFailsClosedWithoutPackagedFallback() + { + var paths = CreateCandidatePaths(); + try + { + WriteMenu(paths.ExternalPath, "지원하지않는_컷"); + WriteMenu(paths.PackagedPath, "1열판기본_예상체결가"); + + var composition = LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates( + paths.ExternalPath, + paths.PackagedBaseDirectory); + + Assert.Equal(LegacyRuntimeStockCutMenuSource.BuiltIn, composition.Source); + Assert.True(composition.UsesBuiltInCatalog); + Assert.Same(LegacyStockCutCatalog.Rows, composition.Rows); + Assert.Equal(LegacyRuntimeStockCutMenuFailure.UnknownCut, composition.Failure); + Assert.Equal( + LegacyRuntimeStockCutMenuLoader.FallbackWarningMessage, + composition.WarningMessage); + } + finally + { + Directory.Delete(paths.Root, recursive: true); + } + } + + [Fact] + public void ResolveFromBaseDirectory_RejectsInvalidOrControlContainingPathsWithoutLeakage() + { + string?[] invalidPaths = + [ + null, + string.Empty, + " ", + "relative\\package", + "C:\\sensitive-root\nprivate", + "C:\\sensitive-root\0private" + ]; + + foreach (var invalidPath in invalidPaths) + { + var composition = + LegacyRuntimeStockCutMenuLoader.ResolveFromBaseDirectory(invalidPath); + + Assert.True(composition.UsesBuiltInCatalog); + Assert.Equal(LegacyRuntimeStockCutMenuSource.BuiltIn, composition.Source); + Assert.Same(LegacyStockCutCatalog.Rows, composition.Rows); + Assert.Equal( + LegacyRuntimeStockCutMenuFailure.InvalidPath, + composition.Failure); + Assert.Equal( + LegacyRuntimeStockCutMenuLoader.FallbackWarningMessage, + composition.WarningMessage); + Assert.DoesNotContain("sensitive-root", composition.WarningMessage!, + StringComparison.OrdinalIgnoreCase); + } + } + + private static (int PhysicalIndex, int? DisplayOrdinal, string RawLabel, bool IsSeparator) + Fields(LegacyCutCatalogRow row) => + (row.PhysicalIndex, row.DisplayOrdinal, row.RawLabel, row.IsSeparator); + + private static byte[] EncodeCp949(string text) + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + var cp949 = Encoding.GetEncoding( + 949, + EncoderFallback.ExceptionFallback, + DecoderFallback.ExceptionFallback); + return cp949.GetBytes(text); + } + + private static ( + string Root, + string ExternalPath, + string PackagedBaseDirectory, + string PackagedPath) CreateCandidatePaths() + { + var root = Path.Combine( + Path.GetTempPath(), + $"mbn-stock-cut-candidates-{Guid.NewGuid():N}"); + var externalPath = Path.Combine( + root, + "local", + "MBN_STOCK_WEBVIEW", + "Res", + "종목.ini"); + var packagedBaseDirectory = Path.Combine(root, "package"); + return ( + root, + externalPath, + packagedBaseDirectory, + Path.Combine(packagedBaseDirectory, "Res", "종목.ini")); + } + + private static void WriteMenu(string path, string label) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, EncodeCp949($"[종목]\r\n{label}\r\n")); + } + + private sealed class EmptyStockLookup : ILegacyStockLookup + { + public Task SearchAsync( + string query, + CancellationToken cancellationToken = default) => + Task.FromResult(new LegacyStockSearchData(query, [], [])); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyStockCutInteractionParityTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyStockCutInteractionParityTests.cs new file mode 100644 index 0000000..e374ffa --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyStockCutInteractionParityTests.cs @@ -0,0 +1,362 @@ +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests; + +public sealed class LegacyStockCutInteractionParityTests +{ + [Fact] + public async Task EmptySearchButtonPreservesResultsStockAndCutSelection() + { + var lookup = new QueryLookup(); + var controller = new LegacyOperatorController(lookup); + await controller.SearchStocksAsync("first", LegacySearchTrigger.Button); + controller.SelectStock(0); + controller.CutPointerDown(1, control: false, shift: false, x: 2, y: 24, + timestampMilliseconds: 1_000); + + var state = await controller.SearchStocksAsync( + string.Empty, + LegacySearchTrigger.Button); + + Assert.Equal(1, lookup.CallCount); + Assert.Equal(string.Empty, state.SearchText); + Assert.Equal("First stock", Assert.Single(state.SearchResults).DisplayName); + Assert.Equal(0, state.SelectedStockIndex); + Assert.Equal( + [1], + state.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex)); + Assert.Empty(state.Playlist); + } + + [Fact] + public async Task EmptyEnterQueriesAndClearsOnlyTheStockResultSelection() + { + var lookup = new QueryLookup(); + var controller = new LegacyOperatorController(lookup); + await controller.SearchStocksAsync("first", LegacySearchTrigger.Button); + controller.SelectStock(0); + controller.CutPointerDown(1, control: false, shift: false, x: 2, y: 24, + timestampMilliseconds: 1_000); + + var state = await controller.SearchStocksAsync( + string.Empty, + LegacySearchTrigger.Enter); + + Assert.Equal(2, lookup.CallCount); + Assert.Null(state.SelectedStockIndex); + Assert.Equal("All stock", Assert.Single(state.SearchResults).DisplayName); + Assert.Equal( + [1], + state.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex)); + } + + [Fact] + public async Task RetainedCutSelectionUsesTheNewlySelectedStockAtDropTime() + { + var lookup = new QueryLookup(); + var controller = new LegacyOperatorController(lookup); + await controller.SearchStocksAsync("first", LegacySearchTrigger.Button); + controller.SelectStock(0); + controller.CutPointerDown(1, control: false, shift: false, x: 2, y: 24, + timestampMilliseconds: 1_000); + + await controller.SearchStocksAsync("second", LegacySearchTrigger.Button); + controller.SelectStock(0); + var state = controller.DropSelectedCutsOnPlaylist(); + + var row = Assert.Single(state.Playlist); + Assert.Equal("Second stock", row.StockName); + Assert.Equal("000002", row.StockCode); + Assert.Equal(LegacyStockCutCatalog.Rows[1].RawLabel, row.RawCutLabel); + } + + [Fact] + public async Task OrdinaryMarketSeparatorIsIgnoredWithoutOpeningTheNxtWarning() + { + var controller = new LegacyOperatorController(new QueryLookup()); + await controller.SearchStocksAsync("first", LegacySearchTrigger.Button); + controller.SelectStock(0); + controller.CutPointerDown(3, control: false, shift: false, x: 2, y: 70, + timestampMilliseconds: 1_000); + + var state = controller.DropSelectedCutsOnPlaylist(); + + Assert.Empty(state.Playlist); + Assert.Null(state.Dialog); + } + + [Fact] + public async Task InjectedRuntimeMenuIsCopiedAndDrivesViewSelectionAndDropByPhysicalOrder() + { + var injected = RuntimeMenu(); + var expectedFirstLabel = injected[0].RawLabel; + var expectedLastLabel = injected[2].RawLabel; + var controller = new LegacyOperatorController( + new QueryLookup(), + cutRows: injected); + injected[0] = injected[0] with + { + RawLabel = LegacyStockCutCatalog.Rows[2].RawLabel + }; + + Assert.Equal( + [expectedFirstLabel, string.Empty, expectedLastLabel], + controller.Current.CutRows.Select(row => row.RawLabel)); + + await controller.SearchStocksAsync("first", LegacySearchTrigger.Button); + controller.SelectStock(0); + controller.CutPointerDown(0, control: false, shift: false, x: 1, y: 1, + timestampMilliseconds: 1_000); + controller.CutPointerDown(2, control: true, shift: false, x: 1, y: 47, + timestampMilliseconds: 2_000); + var dropped = controller.DropSelectedCutsOnPlaylist(); + + Assert.Equal( + [0, 2], + dropped.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex)); + Assert.Equal( + [expectedFirstLabel, expectedLastLabel], + dropped.Playlist.Select(row => row.RawCutLabel)); + + var revision = dropped.Revision; + var invalidIndex = controller.CutPointerDown( + 3, + control: false, + shift: false, + x: 1, + y: 70, + timestampMilliseconds: 3_000); + Assert.Equal(revision, invalidIndex.Revision); + } + + [Fact] + public async Task InjectedRuntimeMenuPreservesTheNxtCurrentLabelRuleAfterReorder() + { + var menu = RuntimeMenu(); + var controller = new LegacyOperatorController( + new NxtLookup(), + cutRows: menu); + await controller.SearchStocksAsync("nxt", LegacySearchTrigger.Button); + controller.SelectStock(0); + + controller.CutPointerDown(2, control: false, shift: false, x: 1, y: 47, + timestampMilliseconds: 1_000); + var warning = controller.DropSelectedCutsOnPlaylist(); + Assert.Equal("NXT는 제공하지 않습니다.", warning.Dialog?.Message); + Assert.Empty(warning.Playlist); + + controller.DismissDialog(); + controller.CutPointerDown(0, control: false, shift: false, x: 1, y: 1, + timestampMilliseconds: 2_000); + var accepted = controller.DropSelectedCutsOnPlaylist(); + Assert.Equal("N5001", Assert.Single(accepted.Playlist).ToPlayoutEntry().CutCode); + } + + [Fact] + public async Task FiftyRowRuntimeMenuCanSelectAndDropItsLastPhysicalRow() + { + var rows = Enumerable.Range(0, LegacyRuntimeStockCutMenuLoader.MaximumRows) + .Select(index => new LegacyCutCatalogRow( + index, + index + 1, + LegacyStockCutCatalog.Rows[1].RawLabel, + IsSeparator: false)) + .ToArray(); + var controller = new LegacyOperatorController(new QueryLookup(), cutRows: rows); + await controller.SearchStocksAsync("first", LegacySearchTrigger.Button); + controller.SelectStock(0); + + controller.CutPointerDown( + LegacyRuntimeStockCutMenuLoader.MaximumRows - 1, + control: false, + shift: false, + x: 1, + y: 1, + timestampMilliseconds: 1_000); + var state = controller.DropSelectedCutsOnPlaylist(); + + Assert.Equal( + LegacyRuntimeStockCutMenuLoader.MaximumRows - 1, + Assert.Single(state.CutRows, row => row.IsSelected).PhysicalIndex); + Assert.Equal( + LegacyStockCutCatalog.Rows[1].RawLabel, + Assert.Single(state.Playlist).RawCutLabel); + } + + [Fact] + public async Task ConsecutiveShiftGesturesKeepTheOriginalNativeListAnchor() + { + var controller = new LegacyOperatorController(new QueryLookup()); + await controller.SearchStocksAsync("first", LegacySearchTrigger.Button); + controller.SelectStock(0); + controller.CutPointerDown(0, control: false, shift: false, 1, 1, 1_000); + + var first = controller.CutPointerDown( + 2, control: false, shift: true, 1, 47, 2_000); + var second = controller.CutPointerDown( + 4, control: false, shift: true, 1, 93, 3_000); + + Assert.Equal( + [0, 1, 2], + first.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex)); + Assert.Equal( + [0, 1, 2, 3, 4], + second.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex)); + } + + [Fact] + public async Task EmptyCutListAreaClearsSelectionSoAStaleDropAddsNothing() + { + var controller = new LegacyOperatorController(new QueryLookup()); + await controller.SearchStocksAsync("first", LegacySearchTrigger.Button); + controller.SelectStock(0); + controller.CutPointerDown(1, false, false, 1, 24, 1_000); + + var cleared = controller.ClearCutSelection(); + var dropped = controller.DropSelectedCutsOnPlaylist(); + + Assert.DoesNotContain(cleared.CutRows, row => row.IsSelected); + Assert.Empty(dropped.Playlist); + } + + [Theory] + [InlineData(0, 2, 0, 1, 2)] + [InlineData(3, 1, 1, 2, 3)] + public async Task EmptyCutListAreaPreservesNativeAnchorForFollowingShift( + int anchor, + int target, + params int[] expected) + { + var controller = new LegacyOperatorController(new QueryLookup()); + await controller.SearchStocksAsync("first", LegacySearchTrigger.Button); + controller.SelectStock(0); + controller.CutPointerDown(anchor, false, false, 1, 1, 1_000); + controller.ClearCutSelection(); + + var state = controller.CutPointerDown(target, false, true, 2, 2, 2_000); + + Assert.Equal( + expected, + state.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex)); + } + + [Fact] + public void EmptyCutListAreaIsIgnoredUntilAStockIsSelected() + { + var controller = new LegacyOperatorController(new QueryLookup()); + var revision = controller.Current.Revision; + + var state = controller.ClearCutSelection(); + + Assert.Equal(revision, state.Revision); + } + + [Fact] + public async Task BlankClickResetsLegacyAnchorEvenAfterControlClearsTheLastRow() + { + var controller = new LegacyOperatorController(new QueryLookup()); + await controller.SearchStocksAsync("first", LegacySearchTrigger.Button); + controller.SelectStock(0); + controller.CutPointerDown(1, false, false, 1, 24, 1_000); + controller.CutPointerDown(1, true, false, 1, 24, 2_000); + controller.ClearCutSelection(); + + controller.CutPointerDown(2, false, true, 2, 47, 3_000); + var state = controller.CutPointerDown(2, false, false, 2, 47, 3_400); + + Assert.Empty(state.Playlist); + Assert.Equal( + [2], + state.CutRows.Where(row => row.IsSelected).Select(row => row.PhysicalIndex)); + } + + [Theory] + [InlineData("physical-index")] + [InlineData("display-ordinal")] + [InlineData("unknown-label")] + [InlineData("separator-shape")] + [InlineData("too-many")] + public void InjectedRuntimeMenuRejectsInvalidAuthority(string invalidCase) + { + var baseline = RuntimeMenu(); + IReadOnlyList rows = invalidCase switch + { + "physical-index" => + [baseline[0] with { PhysicalIndex = 1 }], + "display-ordinal" => + [baseline[0] with { DisplayOrdinal = 2 }], + "unknown-label" => + [baseline[0] with { RawLabel = "지원하지않는_컷" }], + "separator-shape" => + [new LegacyCutCatalogRow(0, 1, "-", IsSeparator: true)], + "too-many" => Enumerable.Range( + 0, + LegacyRuntimeStockCutMenuLoader.MaximumRows + 1) + .Select(index => new LegacyCutCatalogRow( + index, + index + 1, + LegacyStockCutCatalog.Rows[0].RawLabel, + IsSeparator: false)) + .ToArray(), + _ => throw new ArgumentOutOfRangeException(nameof(invalidCase)) + }; + + Assert.Throws(() => new LegacyOperatorController( + new QueryLookup(), + cutRows: rows)); + } + + private static LegacyCutCatalogRow[] RuntimeMenu() => + [ + new LegacyCutCatalogRow( + 0, + 1, + LegacyStockCutCatalog.Rows[1].RawLabel, + IsSeparator: false), + new LegacyCutCatalogRow(1, null, string.Empty, IsSeparator: true), + new LegacyCutCatalogRow( + 2, + 2, + LegacyStockCutCatalog.Rows[0].RawLabel, + IsSeparator: false) + ]; + + private sealed class QueryLookup : ILegacyStockLookup + { + public int CallCount { get; private set; } + + public Task SearchAsync( + string text, + CancellationToken cancellationToken = default) + { + CallCount++; + var (name, code) = text switch + { + "first" => ("First stock", "000001"), + "second" => ("Second stock", "000002"), + _ => ("All stock", "000003") + }; + return Task.FromResult(new LegacyStockSearchData( + text, + [name], + [new LegacyStockIdentity( + LegacyDomesticStockMarket.Kospi, + name, + code)])); + } + } + + private sealed class NxtLookup : ILegacyStockLookup + { + public Task SearchAsync( + string text, + CancellationToken cancellationToken = default) => + Task.FromResult(new LegacyStockSearchData( + text, + ["Next stock(NXT)"], + [new LegacyStockIdentity( + LegacyDomesticStockMarket.NxtKospi, + "Next stock(NXT)", + "A00001")])); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyTabActivationParityTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyTabActivationParityTests.cs index 326d2fa..e4bf960 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyTabActivationParityTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyTabActivationParityTests.cs @@ -101,6 +101,181 @@ public sealed class LegacyTabActivationParityTests Assert.Single(retried.Theme!.SearchResults); } + [Theory] + [InlineData(LegacyOperatorTabId.KospiIndustry, IndustryMarket.Kospi)] + [InlineData(LegacyOperatorTabId.KosdaqIndustry, IndustryMarket.Kosdaq)] + public async Task IndustryEachActivation_ReloadsAndClearsUc2Selections( + LegacyOperatorTabId tabId, + IndustryMarket market) + { + var service = new CountingIndustryService(); + var controller = new LegacyOperatorController( + Lookup(), + industryWorkflow: new LegacyIndustrySelectionWorkflow(service)); + + await controller.SelectTabAsync(tabId); + controller.SelectIndustry(0, doubleClick: true); + var selected = controller.SelectIndustry(1, doubleClick: true); + Assert.NotNull(selected.Industry!.SelectedIndustry); + Assert.NotNull(selected.Industry.FirstIndustry); + Assert.NotNull(selected.Industry.SecondIndustry); + + await controller.SelectTabAsync(tabId); + Assert.Single(service.Markets); + + await controller.SelectTabAsync(LegacyOperatorTabId.Index); + var reentered = await controller.SelectTabAsync(tabId); + + Assert.Equal([market, market], service.Markets); + Assert.Equal(2, reentered.Industry!.Industries.Count); + Assert.Null(reentered.Industry.SelectedIndex); + Assert.Null(reentered.Industry.SelectedIndustry); + Assert.Null(reentered.Industry.FirstIndustry); + Assert.Null(reentered.Industry.SecondIndustry); + } + + [Fact] + public async Task ComparisonEachActivation_ReloadsPairsAndClearsUc3DraftAndSearch() + { + var store = new CountingComparisonPairStore(); + var controller = new LegacyOperatorController( + Lookup(), + comparisonWorkflow: new LegacyComparisonWorkflowController( + new StaticComparisonDataService(), + store)); + + await controller.SelectTabAsync(LegacyOperatorTabId.Comparison); + var searched = await controller.SearchComparisonDomesticAsync("semiconductor"); + var results = searched.Comparison!.DomesticSearch.Results; + controller.ChooseComparisonDomesticResult(results[0].SelectionId); + var drafted = controller.ChooseComparisonDomesticResult(results[1].SelectionId); + Assert.NotNull(drafted.Comparison!.FirstTarget); + Assert.NotNull(drafted.Comparison.SecondTarget); + + await controller.SelectTabAsync(LegacyOperatorTabId.Comparison); + Assert.Equal(1, store.ReadCount); + + await controller.SelectTabAsync(LegacyOperatorTabId.Index); + var reentered = await controller.SelectTabAsync(LegacyOperatorTabId.Comparison); + + Assert.Equal(2, store.ReadCount); + Assert.Equal(LegacyComparisonSearchStatus.Idle, reentered.Comparison!.DomesticSearch.Status); + Assert.Empty(reentered.Comparison.DomesticSearch.Results); + Assert.Equal(string.Empty, reentered.Comparison.DomesticSearch.Query); + Assert.Null(reentered.Comparison.FirstTarget); + Assert.Null(reentered.Comparison.SecondTarget); + Assert.Empty(reentered.Comparison.SavedPairs); + } + + [Fact] + public async Task ComparisonReentry_InvalidatesPairLoadFromDiscardedUc3() + { + var store = new DelayedComparisonPairStore(); + var controller = new LegacyOperatorController( + Lookup(), + comparisonWorkflow: new LegacyComparisonWorkflowController( + new StaticComparisonDataService(), + store)); + + var firstActivation = controller.SelectTabAsync(LegacyOperatorTabId.Comparison); + await store.FirstReadStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + controller.SelectTab(LegacyOperatorTabId.Index); + controller.SelectTab(LegacyOperatorTabId.Comparison); + store.ReleaseFirstRead.TrySetResult(); + await firstActivation; + + Assert.Equal(LegacyOperatorStatusKind.Neutral, controller.Current.Comparison!.StatusKind); + Assert.Equal(string.Empty, controller.Current.Comparison.StatusMessage); + Assert.Empty(controller.Current.Comparison.SavedPairs); + + var reloaded = await controller.SelectTabAsync(LegacyOperatorTabId.Comparison); + Assert.Equal(2, store.ReadCount); + Assert.Equal(LegacyOperatorStatusKind.Information, reloaded.Comparison!.StatusKind); + } + + [Fact] + public async Task OverseasStocksReentry_ResetsUc5StateAndKeepsMainMovingAverages() + { + var workflow = new LegacyOverseasSelectionWorkflow( + new StaticOverseasIndustryService(), + new StaticOverseasStockService()); + var controller = new LegacyOperatorController(Lookup(), overseasWorkflow: workflow); + controller.SelectTab(LegacyOperatorTabId.OverseasStocks); + controller.SelectOverseasFixedIndex("nasdaq"); + var industries = await controller.SearchOverseasIndustriesAsync("industry"); + controller.SelectOverseasIndustry( + Assert.Single(industries.Overseas!.Industry.Results).SelectionId); + var stocks = await controller.SearchOverseasStocksAsync("stock"); + controller.SelectOverseasStock( + Assert.Single(stocks.Overseas!.Stock.Results).SelectionId); + controller.SetMovingAverages(ma5: false, ma20: true); + + controller.SelectTab(LegacyOperatorTabId.Index); + var reentered = controller.SelectTab(LegacyOperatorTabId.OverseasStocks); + + Assert.Null(reentered.Overseas!.SelectedFixedIndex); + Assert.Equal(LegacyOverseasSearchStatus.Idle, reentered.Overseas.Industry.Status); + Assert.Equal(string.Empty, reentered.Overseas.Industry.Query); + Assert.Empty(reentered.Overseas.Industry.Results); + Assert.Null(reentered.Overseas.Industry.Selected); + Assert.Equal(LegacyOverseasSearchStatus.Idle, reentered.Overseas.Stock.Status); + Assert.Equal(string.Empty, reentered.Overseas.Stock.Query); + Assert.Empty(reentered.Overseas.Stock.Results); + Assert.Null(reentered.Overseas.Stock.Selected); + Assert.Equal( + new LegacyOverseasMovingAverageSelection(false, true), + reentered.Overseas.MovingAverages); + } + + [Fact] + public async Task TradingHaltReentry_ResetsUc7QuerySelectionAndSort() + { + var controller = new LegacyOperatorController( + Lookup(), + tradingHaltWorkflow: new LegacyTradingHaltWorkflowController( + new StaticTradingHaltService())); + controller.SelectTab(LegacyOperatorTabId.TradingHalt); + var searched = await controller.SearchTradingHaltsAsync("halted"); + controller.SelectTradingHalt( + Assert.Single(searched.TradingHalt!.Results).SelectionId); + controller.ToggleTradingHaltSort(); + controller.SetMovingAverages(ma5: false, ma20: true); + + controller.SelectTab(LegacyOperatorTabId.Index); + var reentered = controller.SelectTab(LegacyOperatorTabId.TradingHalt); + + Assert.Equal(LegacyTradingHaltRequestStatus.Idle, reentered.TradingHalt!.Status); + Assert.Equal(string.Empty, reentered.TradingHalt.Query); + Assert.Empty(reentered.TradingHalt.Results); + Assert.Null(reentered.TradingHalt.Selected); + Assert.Equal(LegacyTradingHaltNameSort.None, reentered.TradingHalt.NameSort); + Assert.False(reentered.TradingHalt.Ma5); + Assert.True(reentered.TradingHalt.Ma20); + } + + [Fact] + public async Task OverseasReentry_InvalidatesSearchCompletionFromDiscardedUc5() + { + var service = new DelayedOverseasIndustryService(); + var controller = new LegacyOperatorController( + Lookup(), + overseasWorkflow: new LegacyOverseasSelectionWorkflow( + service, + new StaticOverseasStockService())); + controller.SelectTab(LegacyOperatorTabId.OverseasStocks); + + var pending = controller.SearchOverseasIndustriesAsync("old"); + await service.RequestStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + controller.SelectTab(LegacyOperatorTabId.Index); + controller.SelectTab(LegacyOperatorTabId.OverseasStocks); + service.ReleaseRequest.TrySetResult(); + await pending; + + Assert.Equal(LegacyOverseasSearchStatus.Idle, controller.Current.Overseas!.Industry.Status); + Assert.Equal(string.Empty, controller.Current.Overseas.Industry.Query); + Assert.Empty(controller.Current.Overseas.Industry.Results); + } + private static ILegacyStockLookup Lookup() => new StaticLookup(new LegacyStockSearchData(string.Empty, [], [])); @@ -213,4 +388,160 @@ public sealed class LegacyTabActivationParityTests CancellationToken cancellationToken = default) => Task.FromResult( new ExpertPreviewResult(identity, DateTimeOffset.UtcNow, [], false)); } + + private sealed class CountingIndustryService : IIndustrySelectionService + { + public List Markets { get; } = []; + + public Task> GetAsync( + IndustryMarket market, + CancellationToken cancellationToken = default) + { + Markets.Add(market); + return Task.FromResult>( + [ + new(market, "Industry 1", "001"), + new(market, "Industry 2", "002") + ]); + } + } + + private sealed class StaticComparisonDataService : ILegacyComparisonDataService + { + private static readonly IReadOnlyList Domestic = + [ + LegacyComparisonTarget.CreateKrxStock( + LegacyComparisonDomesticMarket.Kospi, + "Samsung Electronics", + "005930"), + LegacyComparisonTarget.CreateKrxStock( + LegacyComparisonDomesticMarket.Kospi, + "SK Hynix", + "000660") + ]; + + public Task> SearchDomesticAsync( + string query, + int maximumResults, + CancellationToken cancellationToken = default) => Task.FromResult(Domestic); + + public Task> SearchWorldAsync( + string query, + int maximumResults, + CancellationToken cancellationToken = default) => Task.FromResult< + IReadOnlyList>([]); + + public Task ResolveStoredTargetAsync( + LegacyComparisonStoredTargetReference target, + CancellationToken cancellationToken = default) => Task.FromResult< + LegacyComparisonTarget?>(null); + } + + private sealed class CountingComparisonPairStore : ILegacyComparisonPairStore + { + public int ReadCount { get; private set; } + + public Task ReadCp949Async(CancellationToken cancellationToken = default) + { + ReadCount++; + return Task.FromResult(null); + } + + public Task WriteCp949Async( + IReadOnlyList rows, + CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + private sealed class DelayedComparisonPairStore : ILegacyComparisonPairStore + { + public TaskCompletionSource FirstReadStarted { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource ReleaseFirstRead { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public int ReadCount { get; private set; } + + public async Task ReadCp949Async( + CancellationToken cancellationToken = default) + { + ReadCount++; + if (ReadCount == 1) + { + FirstReadStarted.TrySetResult(); + await ReleaseFirstRead.Task.WaitAsync(cancellationToken); + } + + return null; + } + + public Task WriteCp949Async( + IReadOnlyList rows, + CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + private sealed class StaticOverseasIndustryService : IOverseasIndustryIndexSearchService + { + public Task SearchAsync( + string query, + int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults, + CancellationToken cancellationToken = default) => Task.FromResult( + new OverseasIndustryIndexSearchResult( + query.Trim(), + DateTimeOffset.UtcNow, + [new OverseasIndustryIndexItem("Semiconductor", "US Semiconductor", "NAS@SOX")], + false)); + } + + private sealed class StaticOverseasStockService : IWorldStockSearchService + { + public Task SearchAsync( + string query, + int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults, + CancellationToken cancellationToken = default) => Task.FromResult( + new WorldStockSearchResult( + query.Trim(), + DateTimeOffset.UtcNow, + [new WorldStockSearchItem("Apple", "NAS@AAPL", "US")], + false)); + } + + private sealed class DelayedOverseasIndustryService : IOverseasIndustryIndexSearchService + { + public TaskCompletionSource RequestStarted { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource ReleaseRequest { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public async Task SearchAsync( + string query, + int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults, + CancellationToken cancellationToken = default) + { + RequestStarted.TrySetResult(); + await ReleaseRequest.Task.WaitAsync(cancellationToken); + return new OverseasIndustryIndexSearchResult( + query.Trim(), + DateTimeOffset.UtcNow, + [new OverseasIndustryIndexItem("Old", "Old result", "NAS@OLD")], + false); + } + } + + private sealed class StaticTradingHaltService : ITradingHaltSelectionService + { + public Task SearchAsync( + string query = "", + int maximumResults = LegacyTradingHaltSelectionService.DefaultMaximumResults, + CancellationToken cancellationToken = default) => Task.FromResult( + new TradingHaltSelectionResult( + query.Trim(), + DateTimeOffset.UtcNow, + [new TradingHaltSelection( + TradingHaltMarket.Kospi, + "005930", + "Samsung Electronics")], + false)); + } } diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/NamedPlaylist/LegacyNamedPlaylistOperatorIntegrationTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/NamedPlaylist/LegacyNamedPlaylistOperatorIntegrationTests.cs index 0bc04de..082c918 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/NamedPlaylist/LegacyNamedPlaylistOperatorIntegrationTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/NamedPlaylist/LegacyNamedPlaylistOperatorIntegrationTests.cs @@ -89,6 +89,9 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests Assert.Equal("005930", row.StockCode); Assert.Equal("5001", row.SceneAlias); Assert.Equal("코스피", row.PlayoutSelection?.GroupCode); + Assert.True(row.IsActive); + Assert.False(row.IsSelected); + Assert.Equal(0, controller.CreatePlayoutRequest().SelectedIndexZeroBased); } [Fact] @@ -156,6 +159,45 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests saved.NamedPlaylist?.LastMutationOutcome); } + [Fact] + public async Task NamedPlaylistIdentityDoesNotChangeWhenMovingAveragesChange() + { + var persistence = new OperatorNamedPlaylistPersistence([]); + var controller = CreateController(persistence); + controller.SelectTab(LegacyOperatorTabId.Index); + var candle = LegacyFixedActionCatalog.Default + .GetActions(LegacyFixedMarket.Index) + .First(action => action.SupportsMovingAverages); + controller.SetMovingAverages(ma5: false, ma20: false); + var added = controller.ActivateFixedAction(candle.Id); + var raw = Assert.Single(added.Playlist); + Assert.Equal(string.Empty, raw.PlayoutSelection!.GraphicType); + + controller.SetMovingAverages(ma5: true, ma20: false); + Assert.Equal( + "MA5", + Assert.Single(controller.CreatePlayoutRequest().Playlist).Selection!.GraphicType); + var listed = await controller.RefreshNamedPlaylistsAsync(); + controller.SelectNamedPlaylist( + Assert.Single(listed.NamedPlaylist!.Definitions).DefinitionId); + await controller.SaveCurrentNamedPlaylistAsync(); + var firstStored = Assert.Single(persistence.LastReplaceItems!).Selection; + + controller.SetMovingAverages(ma5: false, ma20: true); + Assert.Equal( + "MA20", + Assert.Single(controller.CreatePlayoutRequest().Playlist).Selection!.GraphicType); + await controller.SaveCurrentNamedPlaylistAsync(); + var secondStored = Assert.Single(persistence.LastReplaceItems!).Selection; + + Assert.Equal(firstStored, secondStored); + Assert.Equal(raw.GraphicType, secondStored.GraphicType); + Assert.DoesNotContain("MA5", secondStored.GraphicType, StringComparison.Ordinal); + Assert.DoesNotContain("MA20", secondStored.GraphicType, StringComparison.Ordinal); + Assert.Equal(string.Empty, + Assert.Single(controller.Current.Playlist).PlayoutSelection!.GraphicType); + } + [Fact] public async Task Save_to_opaque_id_selects_and_writes_once_in_one_native_command() { diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyBridgeProtocolTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyBridgeProtocolTests.cs index dd1da5a..e2420ef 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyBridgeProtocolTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyBridgeProtocolTests.cs @@ -38,6 +38,81 @@ public sealed class LegacyBridgeProtocolTests Assert.Equal(1_400, pointer.TimestampMilliseconds); } + [Theory] + [InlineData(49, true)] + [InlineData(50, false)] + public void ParsePointer_UsesTheFullFiftyRowRuntimeMenuBoundary( + int physicalIndex, + bool expected) + { + var json = + "{\"type\":\"cut-pointer-down\",\"payload\":{" + + $"\"physicalIndex\":{physicalIndex},\"control\":false," + + "\"shift\":false,\"x\":0,\"y\":0,\"timestampMilliseconds\":0}}"; + + Assert.Equal(expected, LegacyBridgeProtocol.TryParseIntent( + json, + out var parsed, + out _)); + if (expected) + { + Assert.Equal( + physicalIndex, + Assert.IsType(parsed).PhysicalIndex); + } + } + + [Theory] + [InlineData("arrow-up", LegacyCutKeyboardKey.ArrowUp)] + [InlineData("arrow-down", LegacyCutKeyboardKey.ArrowDown)] + [InlineData("home", LegacyCutKeyboardKey.Home)] + [InlineData("end", LegacyCutKeyboardKey.End)] + [InlineData("space", LegacyCutKeyboardKey.Space)] + public void ParseCutKeyDown_AcceptsOnlyClosedKeysAndModifiers( + string key, + LegacyCutKeyboardKey expected) + { + var json = + "{\"type\":\"cut-key-down\",\"payload\":{" + + $"\"key\":\"{key}\",\"control\":true,\"shift\":false}}}}"; + + Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out _)); + var intent = Assert.IsType(parsed); + Assert.Equal(expected, intent.Key); + Assert.True(intent.Control); + Assert.False(intent.Shift); + } + + [Theory] + [InlineData("page-down", "\"control\":false,\"shift\":false")] + [InlineData("ArrowUp", "\"control\":false,\"shift\":false")] + [InlineData("home", "\"control\":0,\"shift\":false")] + [InlineData("home", "\"control\":false,\"shift\":false,\"physicalIndex\":0")] + public void ParseCutKeyDown_RejectsUnknownOrAdditionalAuthority( + string key, + string fields) + { + var json = + "{\"type\":\"cut-key-down\",\"payload\":{" + + $"\"key\":\"{key}\",{fields}}}}}"; + + Assert.False(LegacyBridgeProtocol.TryParseIntent(json, out _, out _)); + } + + [Fact] + public void ParseClearCutSelection_AcceptsOnlyAnEmptyPayload() + { + Assert.True(LegacyBridgeProtocol.TryParseIntent( + "{\"type\":\"clear-cut-selection\",\"payload\":{}}", + out var parsed, + out _)); + Assert.IsType(parsed); + Assert.False(LegacyBridgeProtocol.TryParseIntent( + "{\"type\":\"clear-cut-selection\",\"payload\":{\"physicalIndex\":0}}", + out _, + out _)); + } + [Fact] public void ParsePlaylistSelection_PreservesModifiersAndRejectsUnsafeAuthority() { @@ -61,6 +136,27 @@ public sealed class LegacyBridgeProtocolTests out _)); } + [Fact] + public void ParsePlaylistActivation_AcceptsOnlyOneOpaqueRowId() + { + Assert.True(LegacyBridgeProtocol.TryParseIntent( + "{\"type\":\"activate-playlist-row\",\"payload\":{\"rowId\":\"legacy-stock-00000003\"}}", + out var parsed, + out _)); + Assert.Equal( + "legacy-stock-00000003", + Assert.IsType(parsed).RowId); + + Assert.False(LegacyBridgeProtocol.TryParseIntent( + "{\"type\":\"activate-playlist-row\",\"payload\":{\"rowId\":\"../row\"}}", + out _, + out _)); + Assert.False(LegacyBridgeProtocol.TryParseIntent( + "{\"type\":\"activate-playlist-row\",\"payload\":{\"rowId\":\"legacy-stock-00000003\",\"enabled\":false}}", + out _, + out _)); + } + [Fact] public void ParsePlaylistReorder_UsesOnlyOpaqueRowsAndClosedDropPosition() { @@ -427,7 +523,7 @@ public sealed class LegacyBridgeProtocolTests "삼성", [new LegacyStockResultRow(0, "삼성전자")], 0, - [new LegacyCutViewRow(1, 2, "1열판기본_현재가", false, true)], + [new LegacyCutViewRow(1, 2, "1열판기본_현재가", false, true, true)], [playlistRow], null, string.Empty, @@ -450,10 +546,45 @@ public sealed class LegacyBridgeProtocolTests Assert.Equal( "1열판기본_현재가", payload.GetProperty("cutRows")[0].GetProperty("rawLabel").GetString()); + Assert.True(payload.GetProperty("cutRows")[0].GetProperty("isFocused").GetBoolean()); Assert.False(payload.GetProperty("playlist")[0].GetProperty("isSelected").GetBoolean()); + Assert.False(payload.GetProperty("playlist")[0].GetProperty("isActive").GetBoolean()); Assert.True(payload.GetProperty("isBusy").GetBoolean()); } + [Fact] + public void SerializedState_PreservesInjectedRuntimeCutMenuOrderAndSeparators() + { + var controller = new LegacyOperatorController( + new EmptyStockLookup(), + cutRows: + [ + new LegacyCutCatalogRow( + 0, + 1, + LegacyStockCutCatalog.Rows[1].RawLabel, + IsSeparator: false), + new LegacyCutCatalogRow(1, null, string.Empty, IsSeparator: true), + new LegacyCutCatalogRow( + 2, + 2, + LegacyStockCutCatalog.Rows[0].RawLabel, + IsSeparator: false) + ]); + + var json = LegacyBridgeProtocol.SerializeState(controller.Current); + + using var document = JsonDocument.Parse(json); + var rows = document.RootElement.GetProperty("payload").GetProperty("cutRows"); + Assert.Equal(3, rows.GetArrayLength()); + Assert.Equal("1열판기본_현재가", rows[0].GetProperty("rawLabel").GetString()); + Assert.Equal(0, rows[0].GetProperty("physicalIndex").GetInt32()); + Assert.True(rows[1].GetProperty("isSeparator").GetBoolean()); + Assert.Equal(string.Empty, rows[1].GetProperty("rawLabel").GetString()); + Assert.Equal("1열판기본_예상체결가", rows[2].GetProperty("rawLabel").GetString()); + Assert.Equal(2, rows[2].GetProperty("displayOrdinal").GetInt32()); + } + [Fact] public async Task SerializedNamedPlaylist_ProjectsOnlyOpaqueListAndSafetyState() { @@ -540,4 +671,12 @@ public sealed class LegacyBridgeProtocolTests string programCode, CancellationToken cancellationToken = default) => Task.CompletedTask; } + + private sealed class EmptyStockLookup : ILegacyStockLookup + { + public Task SearchAsync( + string query, + CancellationToken cancellationToken = default) => + Task.FromResult(new LegacyStockSearchData(query, [], [])); + } } diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyPlayoutBridgeTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyPlayoutBridgeTests.cs index 627af2d..a699d28 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyPlayoutBridgeTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyPlayoutBridgeTests.cs @@ -65,9 +65,25 @@ public sealed class LegacyPlayoutBridgeTests Assert.IsType(choose); } + [Theory] + [InlineData(0)] + [InlineData(6)] + [InlineData(19)] + public void FadeDuration_AcceptsOriginalComboIndexRange(int duration) + { + Assert.True(LegacyBridgeProtocol.TryParseIntent( + "{\"type\":\"set-fade-duration\",\"payload\":{\"duration\":" + + duration + "}}", + out var intent, + out var error), error); + Assert.Equal(duration, + Assert.IsType(intent).Duration); + } + [Theory] [InlineData("-1")] - [InlineData("61")] + [InlineData("20")] + [InlineData("60")] [InlineData("1.5")] [InlineData("\"6\"")] [InlineData("null")] @@ -124,7 +140,10 @@ public sealed class LegacyPlayoutBridgeTests BackgroundEnabled: true, BackgroundFileName: "기본.vrv", CanChangeBackground: false, - Message: "준비 완료"); + Message: "준비 완료") + { + IsTakeOutCompletionPending = true + }; using var document = JsonDocument.Parse( LegacyBridgeProtocol.SerializeState(snapshot, playout: playout)); @@ -137,7 +156,8 @@ public sealed class LegacyPlayoutBridgeTests Assert.Equal("pageNext", projected.GetProperty("nextKind").GetString()); Assert.Equal(2, projected.GetProperty("pageCount").GetInt32()); Assert.Equal("기본.vrv", projected.GetProperty("backgroundFileName").GetString()); - Assert.False(projected.TryGetProperty("currentEntryId", out _)); + Assert.Equal("entry-1", projected.GetProperty("currentEntryId").GetString()); + Assert.True(projected.GetProperty("isTakeOutCompletionPending").GetBoolean()); Assert.False(projected.TryGetProperty("builderKey", out _)); } } diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyCloseConfirmationNativeContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyCloseConfirmationNativeContractTests.cs index 9374d6c..4819837 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyCloseConfirmationNativeContractTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyCloseConfirmationNativeContractTests.cs @@ -22,9 +22,11 @@ public sealed class LegacyCloseConfirmationNativeContractTests Assert.Contains("args.Cancel = true", Confirmation, StringComparison.Ordinal); Assert.Contains("DefaultButton = ContentDialogButton.Close", Confirmation, StringComparison.Ordinal); - Assert.Contains("PrimaryButtonText = \"종료\"", Confirmation, + Assert.Contains("Title = \"매일경제TV\"", Confirmation, StringComparison.Ordinal); - Assert.Contains("CloseButtonText = \"취소\"", Confirmation, + Assert.Contains("PrimaryButtonText = \"예\"", Confirmation, + StringComparison.Ordinal); + Assert.Contains("CloseButtonText = \"아니요\"", Confirmation, StringComparison.Ordinal); Assert.Contains("Interlocked.Exchange(ref _closeConfirmed, 1);", Confirmation, StringComparison.Ordinal); diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyHiddenNonOriginalControlsTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyHiddenNonOriginalControlsTests.cs index a4f08a2..028d842 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_prepare_and_fade_are_hidden_but_remain_native_wired() + public void Non_original_shortcuts_are_hidden_but_original_dissolve_control_is_visible() { Assert.Contains("class=\"manual-list-actions\" aria-label=\"수동 송출 목록\" hidden", Markup, @@ -25,10 +25,13 @@ 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\" hidden", + Assert.Contains("class=\"fade-control\" for=\"playout-fade-duration\">DissolveTime", Markup, StringComparison.Ordinal); - Assert.Contains("id=\"playout-fade-duration\" disabled aria-label=\"장면 효과 시간\" hidden", + Assert.Contains("id=\"playout-fade-duration\" disabled aria-label=\"DissolveTime\"", + Markup, + StringComparison.Ordinal); + Assert.DoesNotContain("id=\"playout-fade-duration\" disabled aria-label=\"DissolveTime\" hidden", Markup, StringComparison.Ordinal); Assert.Contains("*[hidden] { display: none !important; }", Styles, diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyPlayoutNativeContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyPlayoutNativeContractTests.cs index 55d3032..d9fcbd5 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyPlayoutNativeContractTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyPlayoutNativeContractTests.cs @@ -41,14 +41,30 @@ public sealed class LegacyPlayoutNativeContractTests [Fact] public void OperatorCommandsUseTheNativeWorkflowAndCSharpPlaylist() { - Assert.Contains("_controller.CreatePlayoutRequest()", Playout, + Assert.Contains("_controller.CreatePlayoutRequest(", Playout, + StringComparison.Ordinal); + Assert.Contains("_compositionOptions!.Current.FadeDuration", Playout, StringComparison.Ordinal); Assert.Contains("workflow.PrepareAsync(", Playout, StringComparison.Ordinal); - Assert.Contains("workflow.PrepareAndTakeInAsync(", Playout, + Assert.Contains("workflow.TakeSelectedAsync(", Playout, + StringComparison.Ordinal); + Assert.DoesNotContain("workflow.TakeInAsync(", Playout, StringComparison.Ordinal); + Assert.DoesNotContain( + "string.IsNullOrWhiteSpace(workflow.State.PreparedCutCode)", + Playout, StringComparison.Ordinal); - Assert.Contains("workflow.TakeInAsync(", Playout, StringComparison.Ordinal); Assert.Contains("workflow.NextAsync(", Playout, StringComparison.Ordinal); Assert.Contains("workflow.TakeOutAsync(", Playout, StringComparison.Ordinal); + Assert.Contains("_controller.ReflectSuccessfulPlayout(", Playout, + StringComparison.Ordinal); + Assert.Contains("workflow.State.CurrentEntryId", Playout, + StringComparison.Ordinal); + Assert.Contains("LegacyOperatorPlayoutCommand.Prepare or", Playout, + StringComparison.Ordinal); + Assert.Contains( + "command != LegacyOperatorPlayoutCommand.TakeOut", + Playout, + StringComparison.Ordinal); Assert.Contains("LegacyWorkflowNextKind.PlaylistNext", Playout, StringComparison.Ordinal); Assert.Contains("LegacySceneRefreshPolicy.TryGetInterval", Playout, @@ -68,6 +84,41 @@ public sealed class LegacyPlayoutNativeContractTests Assert.DoesNotContain("ReconnectAsync", Playout, StringComparison.Ordinal); } + [Fact] + public void OriginalDissolveComboAcceptsOnlyItsZeroBasedTwentyItemIndex() + { + Assert.Contains("fadeDuration is < 0 or > 19", Playout, + StringComparison.Ordinal); + Assert.DoesNotContain("fadeDuration is < 0 or > 60", Playout, + StringComparison.Ordinal); + } + + [Fact] + public void HomeAndEndStopAutomaticRefreshBeforeChangingTheCandidateRow() + { + Assert.Contains( + "LegacySelectPlaylistBoundaryIntent boundary =>\n" + + " SelectPlaylistBoundaryAndStopRefresh(boundary.Last)", + Window.Replace("\r\n", "\n", StringComparison.Ordinal), + StringComparison.Ordinal); + Assert.Contains( + "private LegacyOperatorSnapshot SelectPlaylistBoundaryAndStopRefresh(bool last)", + Playout, + StringComparison.Ordinal); + var helperStart = Playout.IndexOf( + "private LegacyOperatorSnapshot SelectPlaylistBoundaryAndStopRefresh(bool last)", + StringComparison.Ordinal); + var helperEnd = Playout.IndexOf( + "private async Task ToggleBackgroundAsync", + helperStart, + StringComparison.Ordinal); + Assert.True(helperStart >= 0 && helperEnd > helperStart); + var helper = Playout[helperStart..helperEnd]; + Assert.True( + helper.IndexOf("StopRefreshLoop();", StringComparison.Ordinal) < + helper.IndexOf("_controller.SelectPlaylistBoundary(last)", StringComparison.Ordinal)); + } + [Fact] public void PreparedProgramAndUnknownWriteOutcomesBlockOperatorMutations() { @@ -86,6 +137,10 @@ public sealed class LegacyPlayoutNativeContractTests StringComparison.Ordinal); Assert.Contains("LegacyConfirmManualListIntent", Playout, StringComparison.Ordinal); + Assert.Contains("LegacyCutPointerDownIntent", Playout, + StringComparison.Ordinal); + Assert.Contains("LegacyClearCutSelectionIntent", Playout, + StringComparison.Ordinal); } private static string FindRepositoryRoot() diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyPlayoutWebContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyPlayoutWebContractTests.cs index d445f47..4d03692 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyPlayoutWebContractTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyPlayoutWebContractTests.cs @@ -39,6 +39,18 @@ public sealed class LegacyPlayoutWebContractTests Assert.Contains("TAKE OUT", Markup, StringComparison.Ordinal); var fadeMarkup = ExtractElement(Markup, "playout-fade-duration", ""); Assert.Equal(20, CountOccurrences(fadeMarkup, "", + fadeMarkup, + StringComparison.Ordinal); + } Assert.Contains("", Markup, StringComparison.Ordinal); } @@ -80,7 +92,7 @@ public sealed class LegacyPlayoutWebContractTests Assert.Contains("event.key === \"F3\"", Script, StringComparison.Ordinal); Assert.Contains("event.key === \"F8\"", Script, StringComparison.Ordinal); Assert.Contains("event.key === \"Escape\"", Script, StringComparison.Ordinal); - Assert.Contains("event.repeat || hasOpenDialog()", Script, + Assert.Contains("event.defaultPrevented || event.repeat || hasOpenDialog()", Script, StringComparison.Ordinal); } @@ -91,16 +103,23 @@ public sealed class LegacyPlayoutWebContractTests StringComparison.Ordinal); Assert.Contains("const canPrepareToggle = playout.phase === \"idle\"", Script, StringComparison.Ordinal); - Assert.Contains("const canTakeIn = playout.phase === \"prepared\"", Script, + Assert.Contains("canTakeInFromState(playout, hasPlaylist)", Script, StringComparison.Ordinal); - Assert.Contains("playout.phase === \"idle\" && hasPlaylist", Script, + Assert.Contains("playout.phase === \"idle\" || playout.phase === \"program\"", Script, StringComparison.Ordinal); - Assert.Contains("playout.phase !== \"program\"", Script, + Assert.Contains("canAdvanceFromState(playout)", Script, StringComparison.Ordinal); Assert.Contains("playout.isPlayCompletionPending === true", Script, StringComparison.Ordinal); + Assert.Contains("playout.isTakeOutCompletionPending !== true", Script, + StringComparison.Ordinal); Assert.Contains("playout.nextKind === \"none\"", Script, StringComparison.Ordinal); + Assert.Contains("playout.nextKind === \"endOfPlaylist\"", Script, + StringComparison.Ordinal); + Assert.Contains("takeOut.disabled = busy || !commandAvailable;", Script, + StringComparison.Ordinal); + Assert.Contains("마지막 항목", Script, StringComparison.Ordinal); } private static int CountOccurrences(string source, string value) diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeStockCutMenuNativeContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeStockCutMenuNativeContractTests.cs new file mode 100644 index 0000000..fad4212 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRuntimeStockCutMenuNativeContractTests.cs @@ -0,0 +1,85 @@ +namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests; + +public sealed class LegacyRuntimeStockCutMenuNativeContractTests +{ + 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 MainWindowUsesExternalThenPackagedCandidatesAndInjectsResolvedRows() + { + Assert.Contains( + "Environment.SpecialFolder.LocalApplicationData", + MainWindow, + StringComparison.Ordinal); + Assert.Contains("\"MBN_STOCK_WEBVIEW\"", MainWindow, StringComparison.Ordinal); + Assert.Contains("\"Res\"", MainWindow, StringComparison.Ordinal); + Assert.Contains("\"종목.ini\"", MainWindow, StringComparison.Ordinal); + Assert.Contains( + "LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates(", + MainWindow, + StringComparison.Ordinal); + Assert.Contains("runtimeCutMenuOverridePath", MainWindow, + StringComparison.Ordinal); + Assert.Contains("AppContext.BaseDirectory", MainWindow, StringComparison.Ordinal); + Assert.Contains("cutRows: cutMenuComposition.Rows", MainWindow, + StringComparison.Ordinal); + Assert.DoesNotContain("Environment.CurrentDirectory", MainWindow, + StringComparison.Ordinal); + Assert.DoesNotContain("Directory.GetCurrentDirectory", MainWindow, + StringComparison.Ordinal); + Assert.DoesNotContain("MBN_STOCK_N", MainWindow, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("bin\\Debug\\Res\\종목.ini", MainWindow, + StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void DatabaseInitializationErrorWinsOverTheSingleCutMenuFallbackWarning() + { + var errorBranch = MainWindow.IndexOf( + "if (initializationError is not null)", + StringComparison.Ordinal); + var warningBranch = MainWindow.IndexOf( + "else if (cutMenuComposition.WarningMessage is { } cutMenuWarning)", + errorBranch, + StringComparison.Ordinal); + var errorReport = MainWindow.IndexOf( + "_controller.ReportError(initializationError);", + errorBranch, + StringComparison.Ordinal); + var warningReport = MainWindow.IndexOf( + "_controller.ReportWarning(cutMenuWarning);", + warningBranch, + StringComparison.Ordinal); + + Assert.True(errorBranch >= 0); + Assert.True(errorReport > errorBranch); + Assert.True(warningBranch > errorReport); + Assert.True(warningReport > warningBranch); + Assert.Equal( + warningReport, + MainWindow.LastIndexOf( + "_controller.ReportWarning(cutMenuWarning);", + 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/LegacyWebContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWebContractTests.cs index 2d944bb..1602c0b 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWebContractTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWebContractTests.cs @@ -77,6 +77,15 @@ public sealed class LegacyWebContractTests Assert.Contains("timestampMilliseconds", App, StringComparison.Ordinal); Assert.Contains("event.ctrlKey", App, StringComparison.Ordinal); Assert.Contains("event.shiftKey", App, StringComparison.Ordinal); + Assert.Contains("if (playlistMutationLocked) return", App, + StringComparison.Ordinal); + Assert.Contains("send(\"clear-cut-selection\", {})", App, + StringComparison.Ordinal); + Assert.Contains("event.target !== cutList", App, StringComparison.Ordinal); + Assert.Contains("button.addEventListener(\"keydown\"", App, + StringComparison.Ordinal); + Assert.Contains("stockResults.querySelectorAll(\"button[data-result-index]\")", App, + StringComparison.Ordinal); var cutFactory = App[ App.IndexOf("function createCutElement", StringComparison.Ordinal)..App.IndexOf("function renderCuts", StringComparison.Ordinal)]; Assert.DoesNotContain("dblclick", cutFactory, StringComparison.Ordinal); @@ -95,8 +104,25 @@ public sealed class LegacyWebContractTests { Assert.Contains("dataset.resultIndex", App, StringComparison.Ordinal); Assert.Contains("dataset.rowId", App, StringComparison.Ordinal); + Assert.Contains("playlistPlayoutPresentation", App, StringComparison.Ordinal); + Assert.Contains("playout.currentEntryId", App, StringComparison.Ordinal); + Assert.Contains("playout.currentCueIndexZeroBased", App, StringComparison.Ordinal); + Assert.Contains("presentation.pageText", App, StringComparison.Ordinal); + Assert.Contains("aria-current", App, StringComparison.Ordinal); + Assert.Contains("scrollIntoView({ block: \"nearest\", inline: \"nearest\" })", App, + StringComparison.Ordinal); + Assert.Contains(".playlist-data-row.on-air", Styles, StringComparison.Ordinal); + Assert.Contains("#fdc8d2", Styles, StringComparison.OrdinalIgnoreCase); + Assert.Contains(".playlist-data-row.on-air.selected", Styles, StringComparison.Ordinal); Assert.DoesNotContain("stockResults.replaceChildren", App, StringComparison.Ordinal); Assert.DoesNotContain("playlistRows.replaceChildren", App, StringComparison.Ordinal); + var boundaryStart = App.IndexOf( + "function selectPlaylistBoundary", StringComparison.Ordinal); + var boundaryEnd = App.IndexOf( + "function clearDelayedClick", boundaryStart, StringComparison.Ordinal); + var boundaryHelper = App[boundaryStart..boundaryEnd]; + Assert.DoesNotContain(".focus(", boundaryHelper, StringComparison.Ordinal); + Assert.Contains("scrollIntoView", boundaryHelper, StringComparison.Ordinal); } [Fact] @@ -105,6 +131,7 @@ public sealed class LegacyWebContractTests foreach (var intent in new[] { "select-playlist-row", + "activate-playlist-row", "set-playlist-enabled", "reorder-playlist-rows", "move-selected-playlist-rows", @@ -119,13 +146,49 @@ public sealed class LegacyWebContractTests Assert.Contains("shift: event.shiftKey", App, StringComparison.Ordinal); Assert.Contains("event.key === \"Delete\"", App, StringComparison.Ordinal); Assert.Contains("row.isSelected", App, StringComparison.Ordinal); - Assert.Contains("enabled.disabled = state.isBusy === true", App, StringComparison.Ordinal); + Assert.Contains("row.isActive === true", App, StringComparison.Ordinal); + Assert.Contains("enabled.checked !== true", App, StringComparison.Ordinal); + Assert.Contains("enabled.setAttribute(\"aria-disabled\", playlistMutationLocked", App, + StringComparison.Ordinal); + Assert.Contains("interactionDisabled = playlistMutationLocked", App, + StringComparison.Ordinal); + Assert.DoesNotContain("send(\"toggle-active-playlist-enabled\"", App, + StringComparison.Ordinal); Assert.Contains("event.target.closest(\"input, button\")", App, StringComparison.Ordinal); - Assert.Contains("preserveSelectedBlock", App, StringComparison.Ordinal); + Assert.Contains("deferSelectedRowPlainClickForDrag", App, StringComparison.Ordinal); Assert.Contains("deferredPlaylistPlainSelectionRowId", App, StringComparison.Ordinal); Assert.Contains("event.dataTransfer.setData(playlistDragMime, rowId)", App, StringComparison.Ordinal); + Assert.DoesNotContain("selectedRowIds", App, StringComparison.Ordinal); + Assert.Contains("element.draggable = !cutDragLocked", App, StringComparison.Ordinal); + Assert.Contains("if (playlistMutationLocked || !event.dataTransfer)", App, + StringComparison.Ordinal); + Assert.Contains("if (!playlistMutationLocked) send(\"delete-selected-playlist-rows\"", App, + StringComparison.Ordinal); + var deleteBranchStart = App.LastIndexOf( + "if (event.key === \"Delete\"", + StringComparison.Ordinal); + var deleteBranchEnd = App.LastIndexOf( + "} else if (event.key === \" \"", + StringComparison.Ordinal); + Assert.True(deleteBranchStart >= 0 && deleteBranchEnd > deleteBranchStart); + Assert.DoesNotContain( + "event.preventDefault()", + App[deleteBranchStart..deleteBranchEnd], + StringComparison.Ordinal); + var homeEndBranchStart = App.LastIndexOf( + "} else if ((event.key === \"Home\"", + StringComparison.Ordinal); + var homeEndBranchEnd = App.IndexOf( + "} else if (event.key === \"Escape\"", + homeEndBranchStart, + StringComparison.Ordinal); + Assert.True(homeEndBranchStart >= 0 && homeEndBranchEnd > homeEndBranchStart); + Assert.DoesNotContain( + "event.preventDefault()", + App[homeEndBranchStart..homeEndBranchEnd], + StringComparison.Ordinal); Assert.Contains(".playlist-data-row.drag-before", Styles, StringComparison.Ordinal); Assert.Contains(".playlist-data-row.drag-after", Styles, StringComparison.Ordinal); } @@ -144,11 +207,24 @@ public sealed class LegacyWebContractTests public void MarketTabDragSwapsOnHoverThroughExpectedCSharpPositions() { Assert.Contains("send(\"hover-swap-tabs\"", App, StringComparison.Ordinal); + Assert.Contains("source: draggedTabId", App, StringComparison.Ordinal); Assert.Contains("expectedSourceIndex: sourceIndex", App, StringComparison.Ordinal); Assert.Contains("expectedTargetIndex: targetIndex", App, StringComparison.Ordinal); Assert.Contains("event.dataTransfer.setData(\"text/x-mbn-tab\"", App, StringComparison.Ordinal); - Assert.Contains("marketTabs.addEventListener(\"dragover\"", App, + Assert.Contains("function hasCrossedLegacyTabSwapThreshold", App, + StringComparison.Ordinal); + Assert.Contains("sourceRect.width < targetRect.width", App, + StringComparison.Ordinal); + Assert.Contains( + "clientX > targetRect.left + targetRect.width - sourceRect.width", + App, + StringComparison.Ordinal); + Assert.Contains("clientX < targetRect.left + sourceRect.width", App, + StringComparison.Ordinal); + Assert.Contains("if (requestTabHoverSwap(targetId)) tabDragLastTargetId = targetId", App, + StringComparison.Ordinal); + Assert.Contains("marketTabs.addEventListener(\"dragover\", onMarketTabDragOver)", App, StringComparison.Ordinal); Assert.DoesNotContain("send(\"swap-tabs\"", App, StringComparison.Ordinal); } @@ -170,6 +246,26 @@ public sealed class LegacyWebContractTests Assert.Contains("send(\"set-moving-averages\"", App, StringComparison.Ordinal); } + [Fact] + public void Uc1TreeSeparatesDisclosureFromSelectionAndPreservesRenderState() + { + Assert.Contains("function beginTreeRender", App, StringComparison.Ordinal); + Assert.Contains("function finishTreeRender", App, StringComparison.Ordinal); + Assert.Contains("const treeUiStateByTab = new Map()", App, StringComparison.Ordinal); + Assert.Contains("pendingTreeResetTabs.add(nextTabId)", App, StringComparison.Ordinal); + Assert.Contains("catalogExpandAll.checked = true", App, StringComparison.Ordinal); + Assert.Contains("firstRoot.scrollIntoView", App, StringComparison.Ordinal); + Assert.Contains("event.target.closest(\"[data-tree-disclosure]\")", App, + StringComparison.Ordinal); + Assert.Contains("event.preventDefault()", App, StringComparison.Ordinal); + Assert.Contains("treeInteractionLocked = state.isBusy === true || " + + "state.fixedSectionBatch != null", App, StringComparison.Ordinal); + Assert.Contains("summary[data-tree-root].tree-node-selected", Styles, + StringComparison.Ordinal); + Assert.Contains("summary[data-tree-root]::-webkit-details-marker", Styles, + StringComparison.Ordinal); + } + [Fact] public void IndustryWorkspaceUsesIndexesAndOpaqueActionIds() { diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs index 61bcb16..55f4503 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs @@ -325,7 +325,7 @@ public sealed class DynamicK3dSessionTests "GetScenePlayerOnChannel:9", "LoadScene:test-scene", "SetOutputChannelIndex:9", - "SetSceneEffectType:1:7:12", + "SetSceneEffectType:10:7:12", "BeginTransaction", "GetObject:headline", "SetValue:headline:safe value", @@ -360,12 +360,12 @@ public sealed class DynamicK3dSessionTests [ new PlayoutUseBackground(true), new PlayoutSetBackgroundTexture("Video/background.vrv"), - new PlayoutSetBackgroundVideo("Video/background.vrv", 2, true), + new PlayoutSetBackgroundVideo("Video/background.vrv", 2, 10), new PlayoutSetValue("headline", "value"), new PlayoutSetBackgroundVideo( "Video/background.vrv", 3, - true, + 1, PlayoutMutationTiming.InTransaction), new PlayoutSetAssetValue("badge", "Images/badge.png"), new PlayoutSetVisible("badge", false), @@ -406,10 +406,10 @@ public sealed class DynamicK3dSessionTests { "LoadScene:test-scene", "SetOutputChannelIndex:9", - "SetSceneEffectType:1:7:4", + "SetSceneEffectType:10:7:4", "UseBackground:1", "SetBackgroundTexture:background.vrv", - "SetBackgroundVideo:background.vrv:2:1", + "SetBackgroundVideo:background.vrv:2:10", "BeginTransaction", "GetObject:headline", "SetValue:headline:value", @@ -469,16 +469,16 @@ public sealed class DynamicK3dSessionTests "test-scene.t2s", "test-scene", [new PlayoutField("headline", "page two", true)], - FadeDuration: 59, + FadeDuration: 19, Mutations: [ new PlayoutUseBackground(false), new PlayoutSetBackgroundTexture("Video/background.vrv"), - new PlayoutSetBackgroundVideo("Video/background.vrv", 4, true), + new PlayoutSetBackgroundVideo("Video/background.vrv", 4, 10), new PlayoutSetBackgroundVideo( "Video/background.vrv", 8, - true, + 1, PlayoutMutationTiming.InTransaction), new PlayoutSetVisible("badge", false) ])); @@ -516,7 +516,7 @@ public sealed class DynamicK3dSessionTests { "UseBackground:0", "SetBackgroundTexture:background.vrv", - "SetBackgroundVideo:background.vrv:4:1", + "SetBackgroundVideo:background.vrv:4:10", "BeginTransaction", "GetObject:headline", "SetValue:headline:page two", @@ -774,6 +774,7 @@ public sealed class DynamicK3dSessionTests scene.QueryVariablesFailure = null; session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All); + Assert.True(session.HasPendingTakeOutCallbacks); callbacks.Fire("OnStopAll", 1); Assert.Equal( 1, @@ -847,6 +848,7 @@ public sealed class DynamicK3dSessionTests 1, session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount); Assert.False(session.HasPendingLifecycleCallbacks); + Assert.False(session.HasPendingTakeOutCallbacks); session.Disconnect(); return true; }, @@ -1300,7 +1302,7 @@ public sealed class DynamicK3dSessionTests "KTAPConnect:1:127.0.0.1:30001:0", "GetScenePlayer", "LoadScene:test-scene", - "SetSceneEffectType:1:7:12", + "SetSceneEffectType:10:7:12", "BeginTransaction", "QueryVariables", "EndTransaction", diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/LegacyParityStartupCompositionResolverTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/LegacyParityStartupCompositionResolverTests.cs index ab168cc..cf94d31 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/LegacyParityStartupCompositionResolverTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/LegacyParityStartupCompositionResolverTests.cs @@ -8,7 +8,9 @@ public sealed class LegacyParityStartupCompositionResolverTests [Fact] public void AbsentLocalConfiguration_UsesValidatedLegacyDefaultAtConfiguredFade() { - using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + using var scenes = TemporarySceneDirectory.Create( + "5001.t2s", + PlayoutSceneCompositionFactory.LegacyStartupDefaultBackgroundAssetPath); CreateBackground(scenes, PlayoutSceneCompositionFactory.LegacyDefaultBackgroundFileName); var options = new PlayoutOptions { @@ -23,9 +25,9 @@ public sealed class LegacyParityStartupCompositionResolverTests Assert.False(result.HasWarning); Assert.Equal(11, result.Composition.FadeDuration); Assert.Equal(LegacySceneBackgroundKind.Video, result.Composition.BackgroundKind); - Assert.Equal("기본.vrv", result.Composition.BackgroundAssetPath); + Assert.Equal(@"video\기본.vrv", result.Composition.BackgroundAssetPath); Assert.Equal( - PlayoutAssetRoot.OperatorBackgroundDirectory, + PlayoutAssetRoot.SceneDirectory, result.Composition.BackgroundAssetRoot); } @@ -33,6 +35,7 @@ public sealed class LegacyParityStartupCompositionResolverTests public void AbsentLocalConfigurationAndMissingDefault_RemainsNoneWithBoundedWarning() { using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + CreateBackground(scenes, PlayoutSceneCompositionFactory.LegacyDefaultBackgroundFileName); var options = new PlayoutOptions { SceneDirectory = scenes.Path, @@ -57,8 +60,9 @@ public sealed class LegacyParityStartupCompositionResolverTests [Fact] public void ExplicitLocalNone_IsHonoredEvenWhenLegacyDefaultExists() { - using var scenes = TemporarySceneDirectory.Create("5001.t2s"); - CreateBackground(scenes, PlayoutSceneCompositionFactory.LegacyDefaultBackgroundFileName); + using var scenes = TemporarySceneDirectory.Create( + "5001.t2s", + PlayoutSceneCompositionFactory.LegacyStartupDefaultBackgroundAssetPath); var options = new PlayoutOptions { SceneDirectory = scenes.Path, diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutFakes.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutFakes.cs index 825edf4..773348f 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutFakes.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutFakes.cs @@ -61,6 +61,8 @@ internal sealed class FakeK3dSession : IK3dGuardedConnectSession public bool HasPendingPlayCallbacks { get; set; } + public bool HasPendingTakeOutCallbacks { get; set; } + public bool ClearPendingLifecycleCallbacksAfterProcess { get; set; } = true; public ConcurrentQueue Calls { get; } = new(); @@ -196,6 +198,7 @@ internal sealed class FakeK3dSession : IK3dGuardedConnectSession { HasPendingLifecycleCallbacks = false; HasPendingPlayCallbacks = false; + HasPendingTakeOutCallbacks = false; } return result; diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs index f700661..fbbd4a8 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs @@ -384,7 +384,7 @@ public sealed class PlayoutSafetyValidationTests : IDisposable new PlayoutSetBackgroundVideo( "studio.vrv", LoopCount: 2004, - LoopInfinite: true, + LoopInfinite: 10, AssetRoot: PlayoutAssetRoot.OperatorBackgroundDirectory) ])); @@ -412,7 +412,7 @@ public sealed class PlayoutSafetyValidationTests : IDisposable new PlayoutSetBackgroundVideo( "Video\\background.vrv", LoopCount: 2004, - LoopInfinite: true, + LoopInfinite: 10, AssetRoot: PlayoutAssetRoot.OperatorBackgroundDirectory) ]))); } @@ -436,7 +436,7 @@ public sealed class PlayoutSafetyValidationTests : IDisposable new PlayoutSetBackgroundVideo( "studio.vrv", LoopCount: 2004, - LoopInfinite: true, + LoopInfinite: 10, AssetRoot: PlayoutAssetRoot.OperatorBackgroundDirectory) ])); @@ -460,7 +460,7 @@ public sealed class PlayoutSafetyValidationTests : IDisposable new PlayoutSetBackgroundVideo( "Video/background.vrv", LoopCount: 2004, - LoopInfinite: true) + LoopInfinite: 10) ])); var video = Assert.IsType(Assert.Single(resolved.Mutations!)); @@ -471,6 +471,27 @@ public sealed class PlayoutSafetyValidationTests : IDisposable ignoreCase: true); } + [Theory] + [InlineData(-1)] + [InlineData(2)] + [InlineData(9)] + [InlineData(11)] + public void ResolveCue_RejectsBackgroundVideoLoopModesNotUsedByMainForm(int loopMode) + { + var validated = ValidatedPlayoutOptions.Create(ValidTestOptions()); + + Assert.Throws(() => validated.ResolveCue(new PlayoutCue( + "test-scene.t2s", + "test-scene", + Mutations: + [ + new PlayoutSetBackgroundVideo( + "Video/background.vrv", + LoopCount: 2004, + LoopInfinite: loopMode) + ]))); + } + [Theory] [InlineData("../outside.vrv")] [InlineData("Video/missing.vrv")] diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSceneCompositionFactoryTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSceneCompositionFactoryTests.cs index cf25c43..d63b6a2 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSceneCompositionFactoryTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSceneCompositionFactoryTests.cs @@ -15,6 +15,33 @@ public sealed class PlayoutSceneCompositionFactoryTests Assert.Null(result.BackgroundAssetPath); } + [Theory] + [InlineData(0)] + [InlineData(6)] + [InlineData(19)] + public void Fade_AcceptsOriginalDissolveComboIndexRange(int fadeDuration) + { + var result = PlayoutSceneCompositionFactory.Create(new PlayoutOptions + { + LegacySceneFadeDuration = fadeDuration + }); + + Assert.Equal(fadeDuration, result.FadeDuration); + } + + [Theory] + [InlineData(-1)] + [InlineData(20)] + [InlineData(60)] + public void Fade_RejectsValuesOutsideOriginalDissolveComboIndexRange(int fadeDuration) + { + Assert.Throws(() => + PlayoutSceneCompositionFactory.Create(new PlayoutOptions + { + LegacySceneFadeDuration = fadeDuration + })); + } + [Fact] public void TrustedRelativeVideo_IsVerifiedEvenInDryRun() { @@ -92,4 +119,44 @@ public sealed class PlayoutSceneCompositionFactoryTests new PlayoutOptions { SceneDirectory = scenes.Path }, fadeDuration: 6)); } + + [Fact] + public void LegacyStartupDefault_UsesCutsVideo_NotSiblingBackgroundRoot() + { + using var scenes = TemporarySceneDirectory.Create( + "5001.t2s", + PlayoutSceneCompositionFactory.LegacyStartupDefaultBackgroundAssetPath); + var backgroundRoot = Path.Combine(Directory.GetParent(scenes.Path)!.FullName, "배경"); + Directory.CreateDirectory(backgroundRoot); + File.WriteAllText( + Path.Combine( + backgroundRoot, + PlayoutSceneCompositionFactory.LegacyDefaultBackgroundFileName), + "trusted F2/F3 background"); + + var result = PlayoutSceneCompositionFactory.CreateLegacyStartupDefault( + new PlayoutOptions { SceneDirectory = scenes.Path }, + fadeDuration: 6); + + Assert.Equal(@"video\기본.vrv", result.BackgroundAssetPath); + Assert.Equal(PlayoutAssetRoot.SceneDirectory, result.BackgroundAssetRoot); + } + + [Fact] + public void LegacyStartupDefault_MissingCutsVideo_FailsEvenWhenSiblingDefaultExists() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var backgroundRoot = Path.Combine(Directory.GetParent(scenes.Path)!.FullName, "배경"); + Directory.CreateDirectory(backgroundRoot); + File.WriteAllText( + Path.Combine( + backgroundRoot, + PlayoutSceneCompositionFactory.LegacyDefaultBackgroundFileName), + "trusted F2/F3 background"); + + Assert.Throws(() => + PlayoutSceneCompositionFactory.CreateLegacyStartupDefault( + new PlayoutOptions { SceneDirectory = scenes.Path }, + fadeDuration: 6)); + } } diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs index 2dc17ea..9986008 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs @@ -2448,6 +2448,71 @@ public sealed class TornadoPlayoutEngineTests Assert.True(engine.Status.IsPlayCompletionPending); } + [Fact] + public async Task PendingTakeOutCallback_BlocksEveryFurtherSdkCommandUntilCompletion() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); + var completionReady = false; + var session = new FakeK3dSession + { + SupportsLifecycleCallbacks = true, + ClearPendingLifecycleCallbacksAfterProcess = false + }; + session.TakeOutAction = (_, _) => + { + session.HasPendingLifecycleCallbacks = true; + session.HasPendingTakeOutCallbacks = true; + }; + session.ProcessPendingCallbacksAction = _ => + { + if (!completionReady) + { + return new K3dCallbackDrainResult(0, 0, false); + } + + session.HasPendingLifecycleCallbacks = false; + session.HasPendingTakeOutCallbacks = false; + return new K3dCallbackDrainResult(1, 1, false); + }; + await using var engine = CreateEngine( + TestOptions(scenes.Path, "5001", "5006"), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True((await engine.PrepareAsync( + Cue("5001"), + CancellationToken.None)).IsSuccess); + Assert.True((await engine.TakeOutAsync( + PlayoutTakeOutScope.All, + CancellationToken.None)).IsSuccess); + Assert.True(engine.Status.IsTakeOutCompletionPending); + Assert.False(engine.Status.IsCommandAvailable); + + var prepare = await engine.PrepareAsync(Cue("5006"), CancellationToken.None); + var repeatedTakeOut = await engine.TakeOutAsync( + PlayoutTakeOutScope.All, + CancellationToken.None); + + Assert.Equal(PlayoutResultCode.Unavailable, prepare.Code); + Assert.Equal(PlayoutResultCode.Unavailable, repeatedTakeOut.Code); + Assert.Contains("CutOut/StopAll", prepare.Message, StringComparison.Ordinal); + Assert.Equal(1, session.Calls.Count(call => call == "TakeOut:All")); + Assert.DoesNotContain("Prepare:5006", session.Calls); + + completionReady = true; + await engine.PollProcessOnceAsync(CancellationToken.None); + + Assert.False(engine.Status.IsTakeOutCompletionPending); + Assert.True(engine.Status.IsCommandAvailable); + Assert.True((await engine.PrepareAsync( + Cue("5006"), + CancellationToken.None)).IsSuccess); + Assert.Equal(1, session.Calls.Count(call => call == "Prepare:5006")); + } + [Theory] [InlineData(PlayoutTakeOutScope.All)] [InlineData(PlayoutTakeOutScope.Layout)] diff --git a/tests/Web/cut-keyboard-app-integration.test.cjs b/tests/Web/cut-keyboard-app-integration.test.cjs new file mode 100644 index 0000000..0096d5f --- /dev/null +++ b/tests/Web/cut-keyboard-app-integration.test.cjs @@ -0,0 +1,103 @@ +"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 root = path.resolve(__dirname, "..", ".."); +const app = fs.readFileSync(path.join( + root, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", + "Web", + "app.js"), "utf8"); +const nativeWindow = fs.readFileSync(path.join( + root, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", + "MainWindow.xaml.cs"), "utf8"); +const nativePlayout = fs.readFileSync(path.join( + root, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", + "MainWindow.Playout.cs"), "utf8"); + +function loadCutHelpers(rows) { + const start = app.indexOf("function legacyCutKey"); + const end = app.indexOf("function createCutElement", start); + assert.ok(start >= 0 && end > start); + const context = { + cutList: { children: rows }, + result: null + }; + vm.runInNewContext(` + ${app.slice(start, end)} + result = { legacyCutKey, focusCutElement, cutKeyboardTarget }; + `, context); + return context.result; +} + +function row(id) { + return { + id, + tabIndex: -1, + focusCount: 0, + scrollCount: 0, + focus() { this.focusCount += 1; }, + scrollIntoView() { this.scrollCount += 1; } + }; +} + +test("cut keyboard maps only native ListView keys and uses physical boundaries", () => { + const rows = [row(0), row(1), row(2)]; + const helpers = loadCutHelpers(rows); + + assert.equal(helpers.legacyCutKey("ArrowUp"), "arrow-up"); + assert.equal(helpers.legacyCutKey("ArrowDown"), "arrow-down"); + assert.equal(helpers.legacyCutKey("Home"), "home"); + assert.equal(helpers.legacyCutKey("End"), "end"); + assert.equal(helpers.legacyCutKey(" "), "space"); + assert.equal(helpers.legacyCutKey("PageDown"), null); + assert.equal(helpers.cutKeyboardTarget(rows[0], "ArrowUp").id, 0); + assert.equal(helpers.cutKeyboardTarget(rows[1], "ArrowDown").id, 2); + assert.equal(helpers.cutKeyboardTarget(rows[1], "Home").id, 0); + assert.equal(helpers.cutKeyboardTarget(rows[1], "End").id, 2); +}); + +test("cut focus is roving and scrolls only for keyboard navigation", () => { + const rows = [row(0), row(1), row(2)]; + const helpers = loadCutHelpers(rows); + + helpers.focusCutElement(rows[1], true); + + assert.deepEqual(rows.map(item => item.tabIndex), [-1, 0, -1]); + assert.equal(rows[1].focusCount, 1); + assert.equal(rows[1].scrollCount, 1); +}); + +test("cut key intent preserves Home End bubbling and honors the mutation gate", () => { + const start = app.indexOf("function createCutElement"); + const end = app.indexOf("function renderCuts", start); + const factory = app.slice(start, end); + assert.match(factory, /element\.addEventListener\("keydown"/); + assert.match(factory, /event\.preventDefault\(\)/); + assert.match(factory, /if \(playlistMutationLocked\) return/); + assert.match(factory, /send\("cut-key-down", \{/); + assert.match(factory, /control: event\.ctrlKey/); + assert.match(factory, /shift: event\.shiftKey/); + assert.doesNotMatch(factory, /stopPropagation/); + + assert.match(nativeWindow, /LegacyCutKeyDownIntent key => _controller\.CutKeyDown/); + assert.match(nativePlayout, /LegacyCutKeyDownIntent or/); +}); + +test("rendered cut rows expose authoritative focus without replacing DOM rows", () => { + const start = app.indexOf("function renderCuts"); + const end = app.indexOf("cutList.addEventListener", start); + const render = app.slice(start, end); + assert.match(render, /row\.isFocused === true/); + assert.match(render, /element\.tabIndex/); + assert.doesNotMatch(render, /replaceChildren/); +});