From 4a54977341e5d6464e86bebe979b46e97ab6cd6d Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 20 Jul 2026 22:06:12 +0900 Subject: [PATCH] feat: consolidate legacy parity migration baseline --- MBN_STOCK_WEBVIEW.sln | 42 - README.md | 38 +- docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md | 45 + docs/LEGACY_FEATURE_AUDIT.md | 36 +- .../LEGACY_NEW_PLAYOUT_COMPARISON_20260719.md | 125 + docs/LEGACY_PLAYOUT_ANALYSIS.md | 4 + docs/MIGRATION.md | 99 +- docs/MIGRATION_STATUS.md | 105 + docs/OPERATOR_UI_PARITY.md | 4 + docs/PLAYOUT.md | 15 +- docs/PLAYOUT_OPERATIONS.md | 15 +- docs/README.md | 49 + docs/SCENE_EQUIVALENCE.md | 109 +- scripts/Invoke-LegacyPackageInputSmoke.ps1 | 146 +- scripts/Test-LegacyPackageInput.mjs | 2243 ++++++++++++++++- scripts/Test-LegacyParityWeb.ps1 | 13 +- .../Data/LegacyNamedForeignStockRestore.cs | 8 +- .../Data/OverseasStockSearch.cs | 14 +- .../Data/ThemeSelection.cs | 16 +- .../Playout/Scenes/CandleSceneBuilder.cs | 65 +- .../Scenes/ComparisonAndYieldSceneBuilders.cs | 15 +- ...LegacyParameterizedSceneRequestResolver.cs | 2 +- .../Playout/Scenes/PagedQuoteSceneBuilders.cs | 59 +- .../ParameterizedMarketSceneBuilders.cs | 6 +- .../ParameterizedMarketSceneDataLoaders.cs | 4 +- .../Scenes/ReadOnlyPanelSceneBuilders.cs | 25 +- .../RegisteredLegacySceneCueProvider.cs | 46 +- .../Scenes/TabularMarketSceneBuilders.cs | 6 +- .../LegacyOperatorController.cs | 579 ++++- .../LegacyOverseasSelectionWorkflow.cs | 6 +- .../LegacyStockCutCatalog.cs | 31 +- .../LegacyBridgeProtocol.cs | 12 +- .../MainWindow.DatabaseHealth.cs | 191 ++ .../MainWindow.Playout.cs | 78 +- .../MainWindow.xaml.cs | 56 +- .../Web/app.js | 70 +- .../Web/index.html | 5 + .../Web/styles.css | 9 +- tests/LegacyParityWeb/thin-client.test.cjs | 83 +- .../CandleSceneBuilderTests.cs | 92 +- .../ComparisonAndYieldSceneBuildersTests.cs | 29 +- .../LegacyOverseasStockSearchServiceTests.cs | 28 + ...yParameterizedSceneRequestResolverTests.cs | 17 + .../LegacyThemeSelectionServiceTests.cs | 7 + .../PagedQuoteSceneBuildersTests.cs | 29 +- .../PagedQuoteSceneDataLoadersTests.cs | 6 +- .../ParameterizedMarketSceneBuildersTests.cs | 4 +- ...arameterizedMarketSceneDataLoadersTests.cs | 2 +- .../ReadOnlyPanelSceneBuildersTests.cs | 16 +- .../RegisteredLegacySceneCueProviderTests.cs | 60 + .../TabularMarketSceneBuildersTests.cs | 11 +- .../LegacyOperatorControllerTests.cs | 29 + .../LegacyOverseasSelectionWorkflowTests.cs | 23 + .../LegacyStockCutCatalogTests.cs | 26 + ...cyNamedPlaylistOperatorIntegrationTests.cs | 329 +++ .../LegacyBridgeProtocolTests.cs | 19 +- ...atabaseHealthMonitorNativeContractTests.cs | 166 ++ .../LegacyManualFinancialParityWebTests.cs | 19 +- .../LegacyPlayoutNativeContractTests.cs | 57 +- .../LegacyRowDoubleClickParityTests.cs | 13 +- .../LegacyWebContractTests.cs | 21 +- .../Web/cut-keyboard-app-integration.test.cjs | 6 +- ...gacy-theme-parity-app-integration.test.cjs | 10 +- ...layout-page-preflight-integration.test.cjs | 28 + .../MBN_STOCK_WEBVIEW.DbSmoke.csproj | 1 + tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs | 329 ++- 66 files changed, 5391 insertions(+), 460 deletions(-) create mode 100644 docs/LEGACY_NEW_PLAYOUT_COMPARISON_20260719.md create mode 100644 docs/MIGRATION_STATUS.md create mode 100644 docs/README.md create mode 100644 src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.DatabaseHealth.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDatabaseHealthMonitorNativeContractTests.cs create mode 100644 tests/Web/playout-page-preflight-integration.test.cjs diff --git a/MBN_STOCK_WEBVIEW.sln b/MBN_STOCK_WEBVIEW.sln index 07e8a9f..cb8bce2 100644 --- a/MBN_STOCK_WEBVIEW.sln +++ b/MBN_STOCK_WEBVIEW.sln @@ -2,8 +2,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.33103.201 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{84322810-C106-461C-892A-5A3631226260}") = "MBN_STOCK_WEBVIEW", "MBN_STOCK_WEBVIEW.csproj", "{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.Core", "src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj", "{CCFB7CB0-F898-444F-A16B-E28554A2698A}" @@ -46,14 +44,9 @@ Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 ParityDebug|x64 = ParityDebug|x64 - PrototypeDebug|x64 = PrototypeDebug|x64 Release|x64 = Release|x64 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|x64.ActiveCfg = Debug|x64 - {E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|x64.Build.0 = Debug|x64 - {E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|x64.ActiveCfg = Release|x64 - {E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|x64.Build.0 = Release|x64 {CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|x64.ActiveCfg = Debug|Any CPU {CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|x64.Build.0 = Debug|Any CPU {CCFB7CB0-F898-444F-A16B-E28554A2698A}.Release|x64.ActiveCfg = Release|Any CPU @@ -86,56 +79,32 @@ Global {515E56D2-8F90-45A0-83BC-44BD541B10A6}.Debug|x64.Build.0 = Debug|x64 {515E56D2-8F90-45A0-83BC-44BD541B10A6}.Release|x64.ActiveCfg = Release|x64 {515E56D2-8F90-45A0-83BC-44BD541B10A6}.Release|x64.Build.0 = Release|x64 - {E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.ParityDebug|x64.ActiveCfg = Debug|x64 - {E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.PrototypeDebug|x64.ActiveCfg = Debug|x64 - {E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.PrototypeDebug|x64.Build.0 = Debug|x64 - {E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.PrototypeDebug|x64.Deploy.0 = Debug|x64 {CCFB7CB0-F898-444F-A16B-E28554A2698A}.ParityDebug|x64.ActiveCfg = Debug|Any CPU {CCFB7CB0-F898-444F-A16B-E28554A2698A}.ParityDebug|x64.Build.0 = Debug|Any CPU - {CCFB7CB0-F898-444F-A16B-E28554A2698A}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU - {CCFB7CB0-F898-444F-A16B-E28554A2698A}.PrototypeDebug|x64.Build.0 = Debug|Any CPU {7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.ParityDebug|x64.ActiveCfg = Debug|Any CPU {7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.ParityDebug|x64.Build.0 = Debug|Any CPU - {7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU - {7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.PrototypeDebug|x64.Build.0 = Debug|Any CPU {040E8620-035C-4F62-B408-C05C5BDC90FE}.ParityDebug|x64.ActiveCfg = Debug|Any CPU {040E8620-035C-4F62-B408-C05C5BDC90FE}.ParityDebug|x64.Build.0 = Debug|Any CPU - {040E8620-035C-4F62-B408-C05C5BDC90FE}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU - {040E8620-035C-4F62-B408-C05C5BDC90FE}.PrototypeDebug|x64.Build.0 = Debug|Any CPU {1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.ParityDebug|x64.ActiveCfg = Debug|Any CPU {1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.ParityDebug|x64.Build.0 = Debug|Any CPU - {1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU - {1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.PrototypeDebug|x64.Build.0 = Debug|Any CPU {6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.ParityDebug|x64.ActiveCfg = Debug|Any CPU {6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.ParityDebug|x64.Build.0 = Debug|Any CPU - {6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU - {6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.PrototypeDebug|x64.Build.0 = Debug|Any CPU {8275871E-4ECB-465B-AA77-D09E61CC1D84}.ParityDebug|x64.ActiveCfg = Debug|x64 {8275871E-4ECB-465B-AA77-D09E61CC1D84}.ParityDebug|x64.Build.0 = Debug|x64 - {8275871E-4ECB-465B-AA77-D09E61CC1D84}.PrototypeDebug|x64.ActiveCfg = Debug|x64 - {8275871E-4ECB-465B-AA77-D09E61CC1D84}.PrototypeDebug|x64.Build.0 = Debug|x64 {74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}.ParityDebug|x64.ActiveCfg = Debug|x64 {74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}.ParityDebug|x64.Build.0 = Debug|x64 - {74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}.PrototypeDebug|x64.ActiveCfg = Debug|x64 - {74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}.PrototypeDebug|x64.Build.0 = Debug|x64 {515E56D2-8F90-45A0-83BC-44BD541B10A6}.ParityDebug|x64.ActiveCfg = Debug|x64 {515E56D2-8F90-45A0-83BC-44BD541B10A6}.ParityDebug|x64.Build.0 = Debug|x64 - {515E56D2-8F90-45A0-83BC-44BD541B10A6}.PrototypeDebug|x64.ActiveCfg = Debug|x64 - {515E56D2-8F90-45A0-83BC-44BD541B10A6}.PrototypeDebug|x64.Build.0 = Debug|x64 {BD45A78B-3C97-4C55-A917-B46927F00311}.Debug|x64.ActiveCfg = Debug|Any CPU {BD45A78B-3C97-4C55-A917-B46927F00311}.Debug|x64.Build.0 = Debug|Any CPU {BD45A78B-3C97-4C55-A917-B46927F00311}.ParityDebug|x64.ActiveCfg = Debug|Any CPU {BD45A78B-3C97-4C55-A917-B46927F00311}.ParityDebug|x64.Build.0 = Debug|Any CPU - {BD45A78B-3C97-4C55-A917-B46927F00311}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU - {BD45A78B-3C97-4C55-A917-B46927F00311}.PrototypeDebug|x64.Build.0 = Debug|Any CPU {BD45A78B-3C97-4C55-A917-B46927F00311}.Release|x64.ActiveCfg = Release|Any CPU {BD45A78B-3C97-4C55-A917-B46927F00311}.Release|x64.Build.0 = Release|Any CPU {7C8D7811-8902-482B-9EF3-67107A3CB676}.Debug|x64.ActiveCfg = Debug|Any CPU {7C8D7811-8902-482B-9EF3-67107A3CB676}.Debug|x64.Build.0 = Debug|Any CPU {7C8D7811-8902-482B-9EF3-67107A3CB676}.ParityDebug|x64.ActiveCfg = Debug|Any CPU {7C8D7811-8902-482B-9EF3-67107A3CB676}.ParityDebug|x64.Build.0 = Debug|Any CPU - {7C8D7811-8902-482B-9EF3-67107A3CB676}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU - {7C8D7811-8902-482B-9EF3-67107A3CB676}.PrototypeDebug|x64.Build.0 = Debug|Any CPU {7C8D7811-8902-482B-9EF3-67107A3CB676}.Release|x64.ActiveCfg = Release|Any CPU {7C8D7811-8902-482B-9EF3-67107A3CB676}.Release|x64.Build.0 = Release|Any CPU {0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.Debug|x64.ActiveCfg = Debug|x64 @@ -144,7 +113,6 @@ Global {0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.ParityDebug|x64.ActiveCfg = Debug|x64 {0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.ParityDebug|x64.Build.0 = Debug|x64 {0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.ParityDebug|x64.Deploy.0 = Debug|x64 - {0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.PrototypeDebug|x64.ActiveCfg = Debug|x64 {0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.Release|x64.ActiveCfg = Release|x64 {0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.Release|x64.Build.0 = Release|x64 {0F72B3C8-8A81-4D1E-8E57-0C0E9A9960C1}.Release|x64.Deploy.0 = Release|x64 @@ -152,40 +120,30 @@ Global {60A364DD-1283-4DA1-8660-791B5CB4F86F}.Debug|x64.Build.0 = Debug|Any CPU {60A364DD-1283-4DA1-8660-791B5CB4F86F}.ParityDebug|x64.ActiveCfg = Debug|Any CPU {60A364DD-1283-4DA1-8660-791B5CB4F86F}.ParityDebug|x64.Build.0 = Debug|Any CPU - {60A364DD-1283-4DA1-8660-791B5CB4F86F}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU - {60A364DD-1283-4DA1-8660-791B5CB4F86F}.PrototypeDebug|x64.Build.0 = Debug|Any CPU {60A364DD-1283-4DA1-8660-791B5CB4F86F}.Release|x64.ActiveCfg = Release|Any CPU {60A364DD-1283-4DA1-8660-791B5CB4F86F}.Release|x64.Build.0 = Release|Any CPU {8F28CB75-54A4-4C27-A7F8-88A30D485B19}.Debug|x64.ActiveCfg = Debug|Any CPU {8F28CB75-54A4-4C27-A7F8-88A30D485B19}.Debug|x64.Build.0 = Debug|Any CPU {8F28CB75-54A4-4C27-A7F8-88A30D485B19}.ParityDebug|x64.ActiveCfg = Debug|Any CPU {8F28CB75-54A4-4C27-A7F8-88A30D485B19}.ParityDebug|x64.Build.0 = Debug|Any CPU - {8F28CB75-54A4-4C27-A7F8-88A30D485B19}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU - {8F28CB75-54A4-4C27-A7F8-88A30D485B19}.PrototypeDebug|x64.Build.0 = Debug|Any CPU {8F28CB75-54A4-4C27-A7F8-88A30D485B19}.Release|x64.ActiveCfg = Release|Any CPU {8F28CB75-54A4-4C27-A7F8-88A30D485B19}.Release|x64.Build.0 = Release|Any CPU {9333727F-7367-4AF8-BA9D-EF159A3148D9}.Debug|x64.ActiveCfg = Debug|Any CPU {9333727F-7367-4AF8-BA9D-EF159A3148D9}.Debug|x64.Build.0 = Debug|Any CPU {9333727F-7367-4AF8-BA9D-EF159A3148D9}.ParityDebug|x64.ActiveCfg = Debug|Any CPU {9333727F-7367-4AF8-BA9D-EF159A3148D9}.ParityDebug|x64.Build.0 = Debug|Any CPU - {9333727F-7367-4AF8-BA9D-EF159A3148D9}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU - {9333727F-7367-4AF8-BA9D-EF159A3148D9}.PrototypeDebug|x64.Build.0 = Debug|Any CPU {9333727F-7367-4AF8-BA9D-EF159A3148D9}.Release|x64.ActiveCfg = Release|Any CPU {9333727F-7367-4AF8-BA9D-EF159A3148D9}.Release|x64.Build.0 = Release|Any CPU {76A4348A-237C-4634-9861-43102CBA24D6}.Debug|x64.ActiveCfg = Debug|Any CPU {76A4348A-237C-4634-9861-43102CBA24D6}.Debug|x64.Build.0 = Debug|Any CPU {76A4348A-237C-4634-9861-43102CBA24D6}.ParityDebug|x64.ActiveCfg = Debug|Any CPU {76A4348A-237C-4634-9861-43102CBA24D6}.ParityDebug|x64.Build.0 = Debug|Any CPU - {76A4348A-237C-4634-9861-43102CBA24D6}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU - {76A4348A-237C-4634-9861-43102CBA24D6}.PrototypeDebug|x64.Build.0 = Debug|Any CPU {76A4348A-237C-4634-9861-43102CBA24D6}.Release|x64.ActiveCfg = Release|Any CPU {76A4348A-237C-4634-9861-43102CBA24D6}.Release|x64.Build.0 = Release|Any CPU {263E151C-50C1-4B6B-A219-4013273D19AF}.Debug|x64.ActiveCfg = Debug|Any CPU {263E151C-50C1-4B6B-A219-4013273D19AF}.Debug|x64.Build.0 = Debug|Any CPU {263E151C-50C1-4B6B-A219-4013273D19AF}.ParityDebug|x64.ActiveCfg = Debug|Any CPU {263E151C-50C1-4B6B-A219-4013273D19AF}.ParityDebug|x64.Build.0 = Debug|Any CPU - {263E151C-50C1-4B6B-A219-4013273D19AF}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU - {263E151C-50C1-4B6B-A219-4013273D19AF}.PrototypeDebug|x64.Build.0 = Debug|Any CPU {263E151C-50C1-4B6B-A219-4013273D19AF}.Release|x64.ActiveCfg = Release|Any CPU {263E151C-50C1-4B6B-A219-4013273D19AF}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection diff --git a/README.md b/README.md index 2765efd..461bdd5 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,15 @@ 기존 `MBN_STOCK_N` WinForms 애플리케이션을 단계적으로 대체하기 위한 Windows 데스크톱 프로젝트입니다. -> 현재 개발 경로는 둘로 분리되어 있습니다. 아래의 기존 구현 범위는 비교용 루트 프로토타입을 설명합니다. 고객 요구처럼 원본 UI와 클릭 이벤트를 먼저 1:1 이관하는 새 병렬 앱의 범위와 실행 방법은 [원본 호환 마이그레이션 기준선](docs/LEGACY_PARITY_REBASELINE.md)을 참조하세요. +> 현재 유일한 개발·실행 대상은 +> [`src/MBN_STOCK_WEBVIEW.LegacyParityApp`](src/MBN_STOCK_WEBVIEW.LegacyParityApp)입니다. +> Visual Studio F5, 개발 MSIX, 신규 기능과 회귀 검증은 모두 이 앱을 기준으로 합니다. -원본 이벤트를 코드 단위로 다시 검증하는 현재 작업 순서와 완료 범위는 -[동작 동등성 작업 기록](docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md)을 기준으로 판단합니다. -2026-07-18의 실행 파일 옆 `Cuts/Res` 배치, 실제 DB, Release x64 MSIX와 UI-only 결과는 -[Cuts/Res 실행 구조 검증](docs/LEGACY_RUNTIME_ASSET_VALIDATION_20260718.md)에 기록했습니다. +루트 `MBN_STOCK_WEBVIEW.csproj`와 루트 `MainWindow*`/`Web`은 이전 비교 프로토타입입니다. +현행 솔루션과 기본 F5 대상에서는 제외하되, 연결된 과거 비교 테스트를 별도로 정리할 때까지 +참고 소스로 보존합니다. 현재 완료·제한·외부 결정 범위는 +[이관 현재 기준선](docs/MIGRATION_STATUS.md)이 유일한 우선 문서이며, 문서별 용도는 +[문서 안내](docs/README.md)에서 확인할 수 있습니다. 현재 기준 기술 스택은 다음과 같습니다. @@ -38,25 +41,16 @@ - 기본 `DryRun`, Test 전용 인스턴스·채널·씬 allowlist 및 Live 이중 승인 안전 게이트 - MSIX 패키지 매니페스트와 x64 게시 프로필 -Oracle/MariaDB 조회 계층과 Tornado/K3D 어댑터의 코드·계약·자동 검증은 구현했습니다. 이는 운영 Oracle 쓰기나 35개 장면 전체의 실제 PGM 동등성이 끝났다는 뜻이 아닙니다. 앱과 Release 패키지의 기본 모드는 계속 `DryRun`이며 Live allowlist, 명령 예산, callback/`OutcomeUnknown` 게이트를 완화하지 않습니다. 이 개발 PC의 Tornado2/PGM 마이그레이션 검증은 사용자가 2026-07-18에 반복 승인 없이 진행하도록 상시 허용했으며, 적용 범위와 중단 조건은 [개발 환경 상시 권한](docs/DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)에 기록했습니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정·실제 검증 증거·롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 원본 Scene/PageN 대조는 [송출 흐름 분석](docs/LEGACY_PLAYOUT_ANALYSIS.md), 장면별 현황은 [35개 Scene 동등성 매트릭스](docs/SCENE_EQUIVALENCE.md), 전체 412개 실행 action과 전용 편집 화면은 [운영자 UI 동등성 인벤토리](docs/OPERATOR_UI_PARITY.md)를 참고하세요. 재감사에서 확인한 미적용·적용 중·외부자산 필요 항목은 [원본 기능 재감사 기준표](docs/LEGACY_FEATURE_AUDIT.md)가 최종 상태 기준입니다. +Oracle/MariaDB 조회 계층, 원본 10초 DB 상태 감시, Tornado/K3D 어댑터와 35개 scene 계약을 구현했습니다. 2026-07-18 현재 개발 MSIX의 전체 UI/DB 검증과 실제 PGM runtime route 32/34가 끝났으며, 남은 두 route는 원본에도 없는 `s5006`/`s6001` 영상 자산 때문에 fail closed 상태입니다. 앱과 Release 패키지의 기본 모드는 계속 `DryRun`이며 Live allowlist, 명령 예산, callback/`OutcomeUnknown` 게이트를 완화하지 않습니다. 이 개발 PC의 Tornado2/PGM 마이그레이션 검증은 사용자가 반복 승인 없이 진행하도록 상시 허용했으며, 적용 범위와 중단 조건은 [개발 환경 상시 권한](docs/DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)에 기록했습니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정·실제 검증 증거·롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 원본 Scene/PageN 대조는 [송출 흐름 분석](docs/LEGACY_PLAYOUT_ANALYSIS.md), 장면별 현황은 [35개 Scene 동등성 매트릭스](docs/SCENE_EQUIVALENCE.md), 전체 412개 실행 action과 전용 편집 화면은 [운영자 UI 동등성 인벤토리](docs/OPERATOR_UI_PARITY.md)를 참고하세요. -35개 Scene/PageN 런타임과 원본 MainForm·UC1~UC7·GraphE·FSell·VIList·PList·AList·ThemeA·EList의 화면·bridge 계약은 WinUI 3/WebView2에 연결했습니다. 412개 고정 실행 action은 폐쇄형 매트릭스로 고정하며, 수동 재무·순매도·VI·테마·전문가·이름 있는 플레이리스트는 저장 후 재조회와 신뢰 검증이 끝나기 전에는 PREPARE할 수 없습니다. 2026-07-18 실데이터 재검증에서 PList 2,213행은 한국어 7필드 무손상과 typed persistence가 모두 일치했고, 1,581행이 현재 selection으로 복원되며 632행은 명시적 사유로 차단됐습니다. 이 복원 수치는 DB 상태에 따라 달라질 수 있습니다. `종목비교.dat` 8쌍은 설치 패키지에서 1회 이전·재시작 영속성까지 확인했습니다. 이 수치는 scene data·asset·PREPARE·PGM 성공이 아니며, 운영 DB write와 승인된 `5001`/`5074` 밖의 실제 PGM 확인은 여전히 미검증입니다. +35개 Scene/PageN 런타임과 원본 MainForm·UC1~UC7·GraphE·FSell·VIList·PList·AList·ThemeA·EList의 화면·bridge 계약은 WinUI 3/WebView2에 연결했습니다. 실제 Windows 입력으로 검색·키보드·컷/플레이리스트 drag·PList 모달과 화면별 버튼, 개발 DB create/save→fresh readback→delete를 검증했고 cleanup까지 확인했습니다. PList 2,213행 가운데 1,581행은 즉시 복원되고 GraphE fresh read로 22행을 추가 복원할 수 있으며, 최종 610행은 식별정보 부족으로 추정 없이 차단합니다. `5077` 6행 20페이지 전체, 마지막 페이지, `5088` 12행 전환과 장기 실행 뒤 unload도 실제 PGM에서 확인했습니다. 고객 배포용 서명 MSIX, 외부 영상·배경 자산, 610행의 DBA 결정은 아직 남아 있습니다. -## 과거 1.0.5 검증 기준선 (2026-07-12) +## 과거 검증 자료 -아래 항목은 당시 `1.0.5` 패키지와 고정된 일회성 Gate 회차의 역사적 증거다. -현재 `Cuts/Res` 실행 폴더 배치를 포함해 다시 만든 패키지의 파일 수·해시나 권한으로 -재사용하지 않는다. 현행 자산 배치 규약은 [원본 Cuts/Res 런타임 배치](docs/LEGACY_RUNTIME_ASSETS.md), -개발 Tornado2/PGM 작업 권한은 [개발 환경 상시 권한](docs/DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)을 -따른다. - -- 현재 마이그레이션 소스에서 Core 1,383개, Infrastructure 173개, Playout 393개가 Debug/Release x64에서 각각 모두 통과했다. 구성별 1,949개, 두 구성 합계 3,898개이며 실패·skip은 0건이다. Web 구문 27개와 테스트 415/415, 원본 scene 기준선 35/35도 통과했다. -- 원본 `Cuts` 기준 active alias 45개의 `.t2s`는 missing/unsafe 0건이고 종속 asset 23개는 unsafe 0건, missing 14건이다. 누락은 `s5006` 큐브 배경 1개와 `s6001` 국가별 영상 13개다. 실제 Oracle 21c/MariaDB read-only 스모크는 `삼성` 검색과 필수 loader 33/33, 전체 query 58건을 통과했지만 DB→DTO→mutation 검증 범위다. -- clean source commit `df60e09`에서 Release x64 signed MSIX를 새로 빌드했다. `1.0.5.0` SHA-256은 `1B0BA713619CC3BD7CB3E03D78FD1EB1CEAF49E7D50F5C00DDFB483917BA5437`이며, `CN=Comtrophy` SignTool 검증, 290개 패키지 항목, Web 33개 byte-for-byte 일치, 금지 자산·비밀값 0건을 확인했다. -- 설치본 `Wickedness.MBNStockWebView_1.0.5.0_x64__qbv3jkvsn3aj0`을 `C:\Program Files\WindowsApps`의 실제 package context에서 실행했다. 실제 `삼성전자` 검색과 `1열판기본_현재가` 5001 구성, PREPARE → TAKE IN → 자동 refresh 정확히 1회 → `CAPPED · 1/1` → 3.5초 무변화 → TAKE OUT/IDLE을 DryRun에서 확인했다. Oracle/MariaDB는 2/2 정상이고 KTAP 시도·NEXT·DB write·`OutcomeUnknown`은 0건이다. 상세 증거는 [1.0.5 패키지 감사](docs/RELEASE_1_0_5_AUDIT.md)에 있다. -- 실제 Tornado2 검증은 고정 계획 `55C7755C2F874B4B012239117D4AB717BBCE194E7DCC8DA9B0E40D094FF8CA19`의 `MBNWEB-20260711-H` 한 회차로 제한했다. PGM에서 `5001`과 `5074` page 1/page 2를 확인한 뒤 TAKE OUT의 검은 화면을 확인했다. Network Monitoring의 정확한 합계는 `SCENE_PREPARE 5001 = 3`, `SCENE_PREPARE 5074 = 2`, `PLAY = 4`, `SCENE_PLAYED = 4`, `FAILURE = 0`, `ERROR = 0`, retry `0`이며 최종 `OutcomeUnknown = false`다. -- 이후 Round I는 자동 refresh 최대 1회를 넘어 9회가 실행되어 `FAIL` 처리했다. 1.0.5의 native cap은 package-context DryRun을 통과했지만 실제 Tornado 재검증 전이므로 Round I 승인이나 Round H 패키지 증거를 새 패키지에 재사용하지 않는다. -- 실제 Tornado 증거는 승인된 `5001`/`5074`에만 적용한다. 나머지를 포함한 35개 builder, 45개 active alias, 5·6·12행 PageN 경계와 마지막 페이지는 자동 매트릭스 테스트로 검증했다. `s5025`는 Git/MSIX 밖의 승인된 trusted CP949 입력 파일이 있어야 하는 운영 전제조건을 유지한다. +`1.0.5` 패키지와 날짜가 붙은 Gate·Release·Live 문서는 당시 실행의 역사적 증거입니다. +현행 완료 판정이나 새 패키지 승인으로 재사용하지 않습니다. 당시 상세 내용은 +[1.0.5 패키지 감사](docs/RELEASE_1_0_5_AUDIT.md)와 +[동작 동등성 작업 기록](docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md)에 보존되어 있습니다. ## Visual Studio 2026에서 실행 @@ -118,7 +112,7 @@ MSIX 설치 폴더는 읽기 전용입니다. 빌드 시 읽기 전용 원본 `C ```text src/MBN_STOCK_WEBVIEW.LegacyParityApp/ WinUI 3 / WebView2 / 원본 동작 호환 MSIX 앱 MainWindow*.cs, Web/ 네이티브 권위·보안 경계와 로컬 운영 UI -MBN_STOCK_WEBVIEW.csproj 이전 프로토타입 앱(기본 F5 대상 아님) +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 index 8eb05ff..e5f337c 100644 --- a/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md +++ b/docs/LEGACY_BEHAVIOR_PARITY_WORKLOG.md @@ -1,5 +1,9 @@ # MBN_STOCK_N 동작 동등성 작업 기록 +> **문서 성격:** 이 문서는 역사·상세 증거를 보존한다. 현행 완료·차단 판정은 +> [`MIGRATION_STATUS.md`](MIGRATION_STATUS.md)가 우선한다. 아래의 “미실행”, “대기”, +> “별도 승인” 표현과 과거 테스트 수치는 해당 시점의 기록이며 현재 상태가 아니다. + > 2026-07-18 이후 현재 개발 PC의 Tornado2/PGM 작업에는 > [개발 환경 상시 권한](DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)을 적용한다. 아래의 > 과거 회차별 승인 기록은 당시 증거이며 새 개발 작업의 반복 승인 요구로 재사용하지 않는다. @@ -530,3 +534,44 @@ 4. 전체 근거 검토 뒤 변경 사항 커밋·원격 반영 각 항목은 실제 원본 코드 근거와 회귀 테스트가 모두 생기기 전에는 완료로 판정하지 않는다. + +## 2026-07-20 실제 사용자 입력 회귀 보정 + +상태: 보고된 세 가지 조작 문제를 보정하고 Release x64 개발 등록 패키지의 실제 Windows +mouse/keyboard 입력으로 DryRun 종단 검증을 완료했다. Tornado 연결과 DB write는 실행하지 않았다. + +- 원본의 NEXT 기준은 임의로 새로 클릭한 행이 아니라 마지막 TAKE IN으로 PROGRAM이 된 행이다. + 빠른 `행 클릭 -> TAKE IN`이 앞선 행 선택 처리와 겹치면 TAKE IN이 유실될 수 있던 native intent + admission을 순서 보존 방식으로 보정했다. 실제 입력에서 다른 행을 먼저 후보로 만든 뒤 목표 행을 + 클릭하고 즉시 TAKE IN했으며, 목표 행 PROGRAM 후 정확한 다음 enabled 행으로 NEXT됨을 확인했다. +- 좌측 컷 drag는 기존 captured-pointer 임계값과 drop 계약을 유지하면서 마우스를 따라가는 추가 + 가능/불가 feedback을 표시한다. threshold 전에는 숨김, 목록 밖에서는 불가, 편성표 위에서는 가능, + release/cancel/Escape에서는 즉시 제거되는 것을 실제 입력으로 확인했다. +- PREPARED/PROGRAM의 안정 상태에서도 좌측 컷의 순수 선택과 focus는 허용한다. 이때 pointer intent는 + `allowAppend=false`로 닫혀 있어 빠른 세 번 클릭과 double-click도 편성표 행 수를 바꾸지 않는다. +- 첫 검증 fixture의 다음 행은 장중 데이터가 없는 `시간외단일가`여서 원본과 동일하게 장면 생성 전에 + `invalid row count`로 안전 차단됐다. 이는 NEXT 누락이나 결과불명이 아니므로, 검증 도구는 native + error를 즉시 known rejection으로 판정하고 NEXT 행 이동 검증에는 서로 다른 두 `현재가` 행 ID를 + 사용하도록 수정했다. +- 10초 DB 상태 점검은 사용자/송출 DB 작업의 priority waiter를 gate 획득 전 게시하고, health CTS도 + `Interlocked.Exchange`로 게시한다. 상태 점검의 gate 획득과 cancellation 게시 사이에 입력이 와도 + probe가 양보하도록 경쟁 구간을 닫았다. + +실제 패키지 증거: + +- JSON: `artifacts/package-input-smoke/legacy-package-input-20260720T032627652Z.json` + (SHA-256 `9A5F68C58C875A308360D8571ECE7D2B59AF8D258C8F3A3DADC134B1C4F596FE`) +- 화면: `artifacts/package-input-smoke/legacy-package-input-20260720T032627652Z.png` + (SHA-256 `EADF727632DF39F6509D484B613A63A4595BD56BCB5A868C3D8FE38E73DC8DC7`) +- 결과: `PASS`, DryRun playout `PASS`, playlist count `3 -> 3`, blocked outbound `0`, + state violation `0`, DB write `0`, 앱 PID의 Tornado `:30001` 연결 `0`, 정상 종료 및 port 해제 확인. + +회귀 결과: + +- JavaScript: 502/502 +- .NET solution: 2817/2817 +- LegacyWeb native contract: 117/117 +- Release x64 unsigned 개발 패키지 빌드: 경고 0, 오류 0 + +정정: 위 Slice 15에 남은 회차별 신규 승인 문구는 이후 기록된 개발 Tornado2/PGM 상시 권한으로 +대체됐다. 현재 권한과 stop condition은 저장소 `AGENTS.md`가 권위 기준이다. diff --git a/docs/LEGACY_FEATURE_AUDIT.md b/docs/LEGACY_FEATURE_AUDIT.md index 92755ae..7dfe2af 100644 --- a/docs/LEGACY_FEATURE_AUDIT.md +++ b/docs/LEGACY_FEATURE_AUDIT.md @@ -1,6 +1,10 @@ # 원본 기능 재감사 기준표 감사 기준일: 2026-07-15 +현행 판정 갱신: 2026-07-18 + +> 현재 완료·차단·외부 입력 범위는 [`MIGRATION_STATUS.md`](MIGRATION_STATUS.md)가 +> 우선한다. 2026-07-15 패키지·연결 설명은 당시 감사 증거로 보존한다. 원본(읽기 전용): `C:\Users\MD\source\repos\MBN_STOCK_N` @@ -14,10 +18,10 @@ WebView2에 표시하는 x64 Windows 데스크톱/MSIX 앱이다. 초기 프로토타입 문서의 “미이관 버튼 disabled”, “Tornado2에 연결하지 않음”, “DB write 없음”은 현재 구현 상태가 아니다. 원본 UI 이벤트는 LegacyParityApp → strict bridge → -C# workflow로 연결되고 개발 DB write 9개 시나리오의 기존 증적도 있다. 다만 현재 병렬 -변경이 합쳐진 signed Release MSIX 설치·DryRun 실행, 실제 마우스/CDP UI와 제한된 -HELLO/BYE 연결은 2026-07-15에 확인했다. scene/PREPARE/TAKE/PGM은 실행하지 않았으므로 -전체 방송 송출 성공으로 판정하지 않는다. 기본 송출 모드는 계속 `DryRun`이다. +C# workflow로 연결된다. 2026-07-18에는 원본의 마지막 미연결 활성 이벤트였던 10초 DB +상태 감시를 연결했고, 개발 MSIX 전체 UI/DB와 실제 PGM runtime route 32/34를 검증했다. +미검증 두 route는 원본에도 없는 외부 영상 자산 때문에 fail closed한다. 기본 송출 모드는 +계속 `DryRun`이다. ## 상태 정의 @@ -37,9 +41,9 @@ HELLO/BYE 연결은 2026-07-15에 확인했다. scene/PREPARE/TAKE/PGM은 실행 | LF-002 | P0 | cut/asset coverage | 완료 | active alias 45개 `.t2s` 존재. alias missing/unsafe 0, asset unsafe 0, asset missing 14를 분리 보고 | 누락 자산 자체는 LF-001/LF-003 | | LF-003 | P0 | `s5006` 큐브 배경 | 외부자산 필요 | `Video\큐브배경.vrv` 1개가 승인 Cuts에 없음. builder 누락이 아님 | 제공 후 s5006 DryRun/PGM | | LF-004 | P0 | 실제 개발 DB write | 완료 | GraphE 4 + PList/AList 2 + ThemeA KRX/NXT 2 + EList 1, 총 9개가 create/save→fresh readback→delete→absence PASS, 잔여 0 | 운영 DB write를 뜻하지 않음 | -| LF-005 | P0 | 개발 Tornado2/PGM 최종 통합 | 부분 실측 | 현재 signed Release 설치·DryRun 실행과 실제 UI를 확인. 별도 Live에서 HELLO request/success 각 1회와 BYE 1회만 확인했고 scene/PREPARE/TAKE/DB write는 0 | scene PREPARE/TAKE/PGM 전체 통합과 14개 외부 영상 자산 scene은 별도 | +| LF-005 | P0 | 개발 Tornado2/PGM 최종 통합 | 부분 실측 | 5001/5074 재검증과 자산 비의존 30개 route를 실제 PGM에서 순차 검증. 5077 20페이지 전체, 5088 전환, 이전/final unload 포함. `FAILURE=0`, `ERROR=0`, retry 0, `OutcomeUnknown=false` | 외부자산이 없는 s5006/s6001만 제공 후 실제 PGM 필요 | | LF-006 | P0 | 원본 35 scene | 완료 | 원본 inventory와 source 기준선 35/35, DTO→mutation→runtime matrix 통과 | 모든 scene의 실제 PGM 완료로 확대 해석 금지 | -| LF-007 | P0 | PageN/NEXT | 완료 | 5·6·12개 단위, 최대 20페이지, 빈/경계/마지막 부분 페이지와 같은 scene/다음 행 구분을 자동 테스트로 검증 | 현재 package/PGM 실측은 LF-005와 함께 판정 | +| LF-007 | P0 | PageN/NEXT | 완료 | 자동 테스트와 실제 PGM에서 5077 6행 1/20→20/20, 마지막 페이지, playlist NEXT, 5088 12행 1/20 전환을 확인 | 없음 | | LF-008 | P0 | 원클릭 TAKE IN | 완료 | 보이는 TAKE IN/F8 한 번으로 fresh page load 후 PREPARE 1회→TAKE IN 1회. native PREPARE는 진단용으로 유지하되 hidden | unknown에서 재시도 금지 | | LF-009 | P0 | C# 권위/strict bridge | 완료 | Web은 intent만 전송. `LegacyBridgeProtocol` allowlist와 `LegacyOperatorController` snapshot이 DB identity·playlist·scene·송출 상태의 권위 | 새 intent에도 동일 경계 유지 | | LF-010 | P1 | 10개 탭과 action inventory | 완료 | 10탭, fixed 328, 업종 44, 종목 31, 비교 target 24/action 9를 폐쇄형으로 고정 | 동적 DB 행은 별도 검증 | @@ -52,11 +56,11 @@ HELLO/BYE 연결은 2026-07-15에 확인했다. scene/PREPARE/TAKE/PGM은 실행 | LF-017 | P1 | 시스템 종료 확인 | 완료 | 원본 제목 `매일경제TV`, 중립 문구, 예/아니요와 기본 아니요를 유지하며 자동 TAKE OUT/반대 명령 금지 경계를 코드·자동 테스트로 유지 | 현재 Release package-context 회귀는 LF-005와 함께 판정 | | LF-018 | P1 | 원본 비교쌍/수동 파일 | 완료 | `종목비교.dat` 8쌍 1회 import·재시작 marker, FSell 3×5행과 VI 9항목 trusted parser/store 검증 | 원본 파일은 계속 읽기 전용 | | LF-019 | P1 | 실제 DB read→DTO→mutation | 완료 | Oracle/MariaDB 필수 scene selector 33/33, 전체 query 58건 PASS | 외부 asset/PGM 성공과 별도 | -| LF-020 | P2 | FarPoint 대체 | 자동 검증 완료·package 실측 대기 | source 단일 행 drag, F8 후보·PROGRAM·DEL 집합·Shift anchor 분리, 종목 ListBox/컷 ListView 키보드와 Windows drag rectangle을 DOM/C# 회귀로 고정. 탭 dragover와 EList reorder는 기존 실측 유지 | 현재 변경을 합친 package에서 playlist 단일 행 drag와 키보드/focus를 다시 실측; 활성 Print/Excel export는 원본에도 없음 | -| LF-021 | P2 | 패키지 단일 인스턴스·모달 격리 | 자동 검증 완료·package 실측 대기 | 두 번째 activation 전달, 강제 종료 없음. child modal Escape no-op, 전역 키 격리, Tab trap, opener/nested-alert focus 복귀와 PList pending close lock을 고정 | 새 MSIX 전체 회귀 | +| LF-020 | P2 | FarPoint 대체 | 완료 | 실제 개발 MSIX에서 종목 검색·키보드, 컷 다중 drag, 플레이리스트 이동/drag와 focus 경계를 Windows 입력으로 재검증. DOM/C# 회귀도 유지 | 활성 Print/Excel export는 원본에도 없음 | +| LF-021 | P2 | 패키지 단일 인스턴스·모달 격리 | 완료 | 두 번째 activation, child modal Escape, 전역 키 격리, Tab trap, focus 복귀와 PList pending close lock을 자동 검증하고 PList 모달을 실제 입력으로 재검증 | 고객 서명 MSIX의 clean PC 설치 검증은 배포 단계 | | LF-022 | P2 | 영구 운영 로그/bridge 오류 | 완료 | bounded/redacted log와 toast, malformed payload가 송출 상태를 바꾸지 않음 | authoritative Network Monitoring과 함께 판독 | | LF-023 | P1 | NXT 저장 playlist restore | 외부 데이터 정합성 | stale code를 제목/current identity로 재검증하는 닫힌 경로는 완료. 현재 `v_all_stock` 결과가 비어 preview를 fail closed | DBA/data 정합성 없이는 direct-table 추정 금지 | -| LF-024 | P0 | Designer 이벤트 전수 감사 | 완료 | 원본 Designer 구독 **186개**, 화면별 고유 핸들러 **171개**를 IMPL 131/DEAD 20/VISUAL 10/DRAG 9/SAFETY 1로 전수 분류 | 상세 줄 단위 근거는 `LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md` | +| LF-024 | P0 | Designer 이벤트 전수 감사 | 완료 | 원본 Designer 구독 **186개**, 화면별 고유 핸들러 **171개** 전수 분류. 유일하게 남았던 활성 `timerAliveChecker`의 10초 DB 끊김·복구 감시와 전이 로그를 LegacyParityApp 수명주기에 연결 | 상세 줄 단위 근거는 `LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md` | | LF-025 | P0 | VS Development Live 시작 구성 | 완료 | `.slnLaunch`와 패키지 프로필이 LegacyParityApp을 시작 대상으로 고정. 실제 Release에서 `--development-live`가 무시돼 DryRun, TCP/Network 변경 0임을 확인 | Live는 exact argument + Debug + strict local authorization을 계속 요구 | ## 이벤트·drag 감사 상세 @@ -179,12 +183,12 @@ redacted 형태로 보고한다. ## 최종 증적 기록 -현재 구현 증적과 최종 검증 범위는 -[`LEGACY_PARITY_VALIDATION_20260715.md`](LEGACY_PARITY_VALIDATION_20260715.md)에 기록한다. -병렬 변경을 모두 합친 .NET Debug/Release x64는 각각 2,446/2,446, Web Node는 438/438이며 -Visual Studio 2026 Debug/Release x64 빌드는 경고 0·오류 0이다. 새 signed Release x64 MSIX의 -hash·서명·설치 실행, 실제 CDP UI와 제한된 HELLO/BYE 결과도 현재 값으로 기록했다. 단, -scene/PREPARE/TAKE/PGM을 실행하지 않은 현재 연결 회차를 전체 방송 송출 성공으로 확대하지 -않으며 과거 package hash나 PGM 로그를 현재 결과로 재사용하지 않는다. +현재 구현 증적과 최종 검증 범위는 [`MIGRATION_STATUS.md`](MIGRATION_STATUS.md)에 기록한다. +최신 .NET solution 2,817/2,817, LegacyWeb 117/117, JavaScript 502/502이며 실패·skip은 0이다. +Release 빌드는 warning 0, error 0이다. 개발 MSIX 전체 UI/DB와 실제 PGM 32/34 route를 확인했다. +고객 서명 MSIX는 현재 범위에서 보류하고, 외부자산 두 route는 제공 전까지 fail closed한다. +2026-07-15의 상세 package 증적은 +[`LEGACY_PARITY_VALIDATION_20260715.md`](LEGACY_PARITY_VALIDATION_20260715.md)에 역사 기록으로 +보존한다. 원본 저장소는 최종 검증과 배포 이후에도 계속 읽기 전용으로 유지한다. diff --git a/docs/LEGACY_NEW_PLAYOUT_COMPARISON_20260719.md b/docs/LEGACY_NEW_PLAYOUT_COMPARISON_20260719.md new file mode 100644 index 0000000..2d08134 --- /dev/null +++ b/docs/LEGACY_NEW_PLAYOUT_COMPARISON_20260719.md @@ -0,0 +1,125 @@ +# 구 프로그램 ↔ 신규 프로그램 송출 비교 (2026-07-19) + +## 결론 + +외부 제작 자산에 의존하지 않는 30개 운영 route를 구 프로그램과 신규 프로그램에서 같은 +저장 편성표 순서로 각각 실행했다. 두 프로그램 모두 실제 Windows 포인터 입력으로 행을 선택하고 +TAKE IN/TAKE OUT을 수행했으며, Tornado2 Network Monitoring과 PGM 화면을 보존했다. + +- 신규 프로그램: 30/30이 정확한 scene code로 PROGRAM에 진입하고 화면이 표시됐다. +- 신규 명령: LOAD 30/30, PREPARE 56/56, PLAY 56/56, UNLOAD 30/30, + `FAILURE=0`, `ERROR=0`, `OutcomeUnknown=false`. +- 신규 UI 상태 증거: 120개(행 활성화·PROGRAM 조기 확인·갱신 완료·IDLE 복귀 각 30개). +- 신규 PGM 화면 증거: 30개. 최저 밝기 `50160`도 실제 농산물 2열판이며 검은 화면이 아니다. + 최고 밝기 `5032`는 흰 배경이 넓은 지수/선물 2열판의 정상 화면이다. +- 구 프로그램: 동일 30개 입력을 모두 시도했다. 현재 데이터와 저장 identity에서 PGM 오류 없이 + PROGRAM에 진입한 route는 11개, PGM 오류를 동반한 route는 4개, 데이터·매핑 단계에서 차단된 + route는 15개였다. 오류 없는 11개도 mutation을 대조하니 8개가 동등했고, 2개는 구 프로그램의 + 기간 제목 복원 오류, 1개는 구 프로그램의 두 필드 갱신 누락이었다. + +따라서 30개를 모두 **입력·결과 기준으로 비교**할 수는 있었지만, 30개 모두를 픽셀 대 픽셀로 +비교할 수 있었던 것은 아니다. 구 프로그램이 이번 환경에서 깨끗한 PGM 화면을 만들지 못한 +19개는 신규 화면만 실제 확인했으며, 이는 신규 프로그램의 실패가 아니라 구 프로그램 측의 +현행 데이터 복원·PGM 오류 경계다. + +## 장면별 결과 + +아래의 구 프로그램 결과는 이번 동일 저장 편성표 재생 회차에서 관찰한 결과다. 구 프로그램의 +모든 과거 운영 가능성을 부정하는 판정으로 확대하지 않는다. + +| 순번 | code | 장면 | 구 프로그램 관찰 | 신규 프로그램 | +|---:|---:|---|---|---| +| 1 | 5016 | 미국 지수 3열판 | 데이터 단계 차단 | PASS, 실제 PGM | +| 2 | 50160 | 농산물 2열판 | 데이터 단계 차단 | PASS, 실제 PGM | +| 3 | 5078 | 다우 미국업종 | 정상 PROGRAM | PASS, 실제 PGM | +| 4 | 8067 | 글로벌 증시 지도 | 정상 PROGRAM | PASS, 실제 PGM | +| 5 | 5086 | 원달러 5일 수익률 | PROGRAM, 저장 행 제목/기간 복원 차이 | PASS, canonical 제목·기간 PGM | +| 6 | 50860 | 원달러 5일 라인 | PROGRAM, 저장 행 제목/기간 복원 차이 | PASS, canonical 제목·기간 PGM | +| 7 | 5023 | 코스피 매매동향 표 | 저장 매핑 차단 | PASS, 실제 PGM | +| 8 | 5024 | 코스피 매매동향 막대 | 저장 매핑 차단 | PASS, 실제 PGM | +| 9 | 5077 | 시가총액 6종목 | 데이터 단계 차단 | PASS, 실제 PGM | +| 10 | 5082 | 주체별 매매동향 표 | 정상 PROGRAM | PASS, 실제 PGM | +| 11 | 5083 | 개인 매매동향 라인 | 데이터 단계 차단 | PASS, 실제 PGM | +| 12 | 5084 | 코스피 매매동향 라인 | 50860으로 잘못 매핑되어 실패 | PASS, 정확한 5084 PGM | +| 13 | 5085 | 프로그램 매매 표 | PROGRAM, 베이시스·선물 mutation 누락 | PASS, 6개 항목 fresh PGM | +| 14 | 5088 | 시가총액 12종목 | 데이터 단계 차단 | PASS, 실제 PGM | +| 15 | 6067 | 기관 순매수 표 | 정상 PROGRAM | PASS, 실제 PGM | +| 16 | 8035 | 일봉 캔들 | 데이터 단계 차단 | PASS, 실제 PGM | +| 17 | 5011 | 삼성전자 1열판 상세 | PROGRAM, PGM 오류 포함 | PASS, 실제 PGM | +| 18 | 8003 | 삼성전자 호가창 | PROGRAM, PGM 오류 포함 | PASS, 실제 PGM | +| 19 | 5037 | 삼성전자 거래원 | PROGRAM, PGM 오류 포함 | PASS, 실제 PGM | +| 20 | 8001 | 코스피 업종 네모그래프 | PROGRAM, PGM 오류 포함 | PASS, 실제 PGM | +| 21 | 5026 | 종목 비교 캔들 | 데이터 단계 차단 | PASS, 실제 PGM | +| 22 | 5087 | 종목 비교 라인 | 데이터 단계 차단 | PASS, 실제 PGM | +| 23 | 5029 | 종목 비교 5일 수익률 | 저장 매핑 차단 | PASS, 실제 PGM | +| 24 | 8032 | 코스닥 ↔ 코스피 지수 | 데이터 단계 차단 | PASS, 실제 PGM | +| 25 | 5032 | 선물 ↔ 코스피 지수 | 데이터 단계 차단 | PASS, 실제 PGM | +| 26 | 5076 | 주요매출 구성 | 정상 PROGRAM | PASS, 실제 PGM | +| 27 | 5079 | 성장성 지표 | 정상 PROGRAM | PASS, 실제 PGM | +| 28 | 5080 | 매출액 | 정상 PROGRAM | PASS, 실제 PGM | +| 29 | 5081 | 영업이익 | 정상 PROGRAM | PASS, 실제 PGM | +| 30 | 5025 | 개인 순매도 수동 목록 | 데이터 단계 차단 | PASS, 실제 PGM | + +## 구 프로그램이 실제 송출한 11개의 mutation 대조 + +PGM 화면은 같은 scene이라도 캡처한 애니메이션 프레임에 따라 일부 행·막대가 덜 보일 수 있다. +따라서 화면 육안 비교와 함께 각 route의 첫 transaction mutation을 직접 대조했다. + +- 순서와 값까지 동일: `5078`, `5082`, `6067`, `5076`, `5080`, `5081` (6개) +- 값·오브젝트 집합은 동일하고 설정 순서만 다름: `8067`, `5079` (2개) +- `5086`: 날짜·가격·기준가·수익률·그래프 23개 mutation은 같았다. 구 프로그램은 저장 행의 + `원달러_5일`을 그대로 제목에 넣어 `원달러_5일 수익률`, 빈 subtitle을 보냈다. 신규는 원본 + scene의 닫힌 5일 동작에 맞게 `원달러 수익률`, `1주일`을 보냈다. +- `50860`: 시세·등락·날짜·그래프 25개 mutation은 같았다. 구 프로그램은 + `원/달러_5일`, 빈 subtitle을 보냈고 신규는 `원/달러`, `1주일`을 보냈다. +- `5085`: 구 프로그램은 4개 데이터 항목에 해당하는 12개 mutation만 보냈고 `베이시스`와 + `선물`을 갱신하지 않았다. 그 결과 PGM에는 이전 scene 잔존값처럼 `57,800`이 두 칸에 보였다. + 신규는 `순매수`, `차익`, `비차익`, `베이시스`, `코스피200`, `선물` 6개 항목의 18개 + mutation을 보내 fresh DB 값으로 표시했다. + +즉 신규 프로그램은 구 프로그램의 이번 실행 결과를 무조건 복제하지 않는다. 구 프로그램의 +저장 행 인자 왜곡이나 갱신 누락은 원본 scene 계약과 현재 DB identity를 기준으로 교정한다. + +## 이번 회차에서 교정한 내용 + +저장 편성표에는 화면에 보이는 제목·subtype만 남고 신규 내부 selection identity가 없는 행이 +있었다. 신규 프로그램은 추측하지 않고 다음처럼 닫힌 규칙으로 복원하도록 교정했다. + +- `5011`: 상세 분기(액면가 등)를 canonical detail selection으로 정규화 +- `8001`: 업종 네모그래프를 canonical industry selection으로 정규화 +- `5026`, `5087`, `5029`, `8032`, `5032`: 화면 제목과 저장된 정확한 DataCode identity로 + 비교 대상·action을 복원 +- `5076`, `5079`, `5080`, `5081`: 수동 재무 화면을 canonical internal selection으로 정규화 + +집중 회귀 53/53과 Debug x64 빌드가 통과했고, 교정 대상 11개를 먼저 재검증한 뒤 30개 전체를 +다시 사용자 입력으로 실행했다. 최종 전체 회귀는 Debug/Release x64 각각 2,813/2,813, +Web Node 424/424이며 실패·skip은 0이다. + +## 화면 판독과 제한 + +- 초기 자동 캡처 일부가 장면 자동 갱신 후 너무 늦게 찍혀 검은 PGM처럼 보였다. 이는 송출 + 실패가 아니었다. PROGRAM 진입 직후 850 ms에 PGM을 캡처하도록 보정한 뒤 30개를 다시 실행했다. +- `5078`의 구/신규 mutation 값(제목, 분류, 막대 값)은 일치했다. 신규 화면도 같은 다우 업종 + 그래픽을 표시했다. +- 공통 배경 자산을 사용하지 않는 현재 로컬 설정에서는 그래픽 주변이 검게 보일 수 있다. + 사용자가 외부 자산 없이 진행하도록 한 범위이며 scene 데이터 실패로 판정하지 않는다. +- `5025`는 현재 trusted 수동 파일에 두 종목만 있으므로 두 행만 채워지고 나머지는 빈 행이다. + 화면 형식과 송출 계약은 정상이다. +- 외부 자산이 실제로 없는 `s5006`, `s6001`은 이번 30개에 포함하지 않았고 계속 fail closed한다. + +## 증거와 정리 + +- 구 프로그램: [`legacy-pgm`](../artifacts/side-by-side-playout-20260719/legacy-pgm) +- 신규 프로그램: [`new-pgm`](../artifacts/side-by-side-playout-20260719/new-pgm) +- 신규 실제 화면: `new-pgm/pgm-visual-new-v3-<순번>--result.png` +- 신규 사용자 입력 상태: `new-pgm/ui-new-v3-<순번>--*.json` + +장시간 실행으로 Network Monitoring의 시작 anchor가 화면 버퍼에서 밀려났으므로 최종 단일 +delta 파일을 30개 전체 집계 근거로 사용하지 않는다. 대신 시작점이 보존된 장면별 result/takeout +state JSON 60개를 합산했고, 위의 30/56/56/30 성공 수를 얻었다. 현재 남아 있는 Network +Monitoring 구간도 LOAD 16/16, PREPARE 29/29, PLAY 29/29, UNLOAD 17/17, +`FAILURE=0`, `ERROR=0`으로 일치한다. + +검증용 편성표 `000_CODEX_AB_20260719_01`은 실제 UI에서 삭제했다. 삭제는 +`committedFresh`, 정의 24→23이었고, 후속 read-only DB 감사에서 정의 23개·행 2,213개와 +`REAL_DB_SMOKE: PASS`를 확인했다. diff --git a/docs/LEGACY_PLAYOUT_ANALYSIS.md b/docs/LEGACY_PLAYOUT_ANALYSIS.md index e3c2a0d..543b31f 100644 --- a/docs/LEGACY_PLAYOUT_ANALYSIS.md +++ b/docs/LEGACY_PLAYOUT_ANALYSIS.md @@ -1,5 +1,9 @@ # 원본 Tornado 송출 흐름 분석 +> **문서 성격:** 이 문서는 역사·상세 증거를 보존한다. 현행 완료·차단 판정은 +> [`MIGRATION_STATUS.md`](MIGRATION_STATUS.md)가 우선한다. 아래의 “미실행”, “대기”, +> “별도 승인” 표현과 과거 테스트 수치는 해당 시점의 기록이며 현재 상태가 아니다. + 이 문서는 `C:\Users\MD\source\repos\MBN_STOCK_N`의 `MainForm`, `Scene` 35개와 `PageN`/`Nxt_PageN`을 새 송출 runtime과 대조한 기준선이다. 원본은 읽기 전용으로만 조사했으며 소스, DB 비밀번호, 운영 설정과 실제 자산을 새 저장소로 복사하지 않았다. ## MainForm 호출 순서 diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index a47d314..ce23f1a 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -1,91 +1,18 @@ -# MBN_STOCK_N 마이그레이션 현황 +# 마이그레이션 문서 안내 -## 원본 기준선 +현재 실행 대상은 `MBN_STOCK_WEBVIEW.LegacyParityApp`이다. 원본 +`C:\Users\MD\source\repos\MBN_STOCK_N`은 계속 읽기 전용으로 취급한다. -원본은 .NET Framework 4.8 / C# 7.3 기반 WinForms 애플리케이션입니다. 약 38,000줄의 C# 코드와 다음 요소로 구성되어 있습니다. +이관의 최신 완료 범위, 남은 차단 항목과 완료 조건은 +[`MIGRATION_STATUS.md`](MIGRATION_STATUS.md)를 단일 기준으로 사용한다. 다른 문서의 +수치나 판정이 이 문서와 다르면 `MIGRATION_STATUS.md`를 우선한다. -- 대형 `MainForm` 및 7개 WinForms UserControl -- 보조 폼 7개 -- Tornado/K3D 장면 처리 클래스 35개 -- Oracle 및 MariaDB SQL/데이터 요청 계층 -- FarPoint Spread 4 플레이리스트/그리드 +세부 내용을 확인할 때는 다음 문서를 사용한다. -원본 작업 트리의 `MainForm.cs`와 `RES/MmoneyCoder.ini`에는 커밋되지 않은 변경이 있습니다. 원본 저장소는 그대로 보존합니다. 실제 DB INI는 Git이나 MSIX에 넣지 않으며, 일반 개발 빌드에서만 읽기 전용 원본 `bin\Debug\Res`에서 실행 파일 옆 `Res`로 복사해 기존 상대 경로를 재현합니다. +- 전체 문서 분류와 읽는 순서: [`README.md`](README.md) +- 장면별 구현·검증 상태: [`SCENE_EQUIVALENCE.md`](SCENE_EQUIVALENCE.md) +- 실행 구조와 호환 기준선: [`LEGACY_PARITY_REBASELINE.md`](LEGACY_PARITY_REBASELINE.md) +- 송출 실행·검증·중단 절차: [`PLAYOUT_OPERATIONS.md`](PLAYOUT_OPERATIONS.md) -## 화면/계약 적용 현황 - -| 기존 구현 | 새 구현 | -|---|---| -| WinForms MainForm | WinUI 3 `MainWindow` + 로컬 WebView2 UI | -| FarPoint `FpSpread` 플레이리스트 | HTML table + 선택/드래그 정렬/이동/삭제 | -| 실행 폴더 `Cuts`/`Res` | 읽기 전용 원본 `bin\Debug`를 빌드 입력으로 연결해 실행 출력과 MSIX에 비민감 자산 배치 | -| 플레이리스트 임시 저장 | WebView2 사용자 데이터 영역의 `localStorage` | -| Oracle `DC_LIST`/`PLAY_LIST` | 이름 있는 플레이리스트 생성·교체·불러오기·삭제와 fresh page preflight | -| WinForms 단축키 | Web UI `F2` / `F8` / `Esc` 처리 | -| 직접 DBManager 호출 | Core의 `IDataQueryExecutor` 경계 | -| `Data/Request` SQL 정의 | .NET 8 Core 프로젝트로 71개 이관 | -| 동기 Oracle/MySQL 연결 | 비동기 Oracle/MariaDB 공급자, 취소·timeout·선별 재시도 | -| DB 실패 시 `Application.Exit()` | 앱 유지 + WebView 소스별 health/오류/재조회 | -| 종목·지수 선택 데이터 | WebView의 KRX/NXT 종목 및 5개 지수 실데이터 표 | -| 직접 K3D COM 호출 | COM 중립 `IPlayoutEngine` + x64 late-bound 어댑터 | -| UI thread의 Tornado 호출 | bounded STA FIFO, timeout 격리, 재연결·프로세스 감시 | -| 버튼 즉시 상태 변경 | requestId 기반 WebView bridge와 실제 결과 후 상태 갱신 | -| AnyCPU/x86 혼재 | 솔루션 및 게시 프로필 x64 단일화 | -| 상대 경로 Web 파일 | MSIX Content 및 안전한 가상 호스트 매핑 | - -WebView는 `https://app.mbn.local` 가상 호스트로 패키지 내부 파일만 제공합니다. 외부 탐색은 WebView 안에서 차단하고 기본 브라우저로 넘기며, 개발자 도구와 기본 컨텍스트 메뉴는 디버거가 연결된 경우에만 활성화합니다. - -이 표의 `새 구현`은 화면·bridge·DTO·persistence 계약이 존재한다는 뜻이다. 운영 데이터 쓰기, 기존 운영 데이터의 문자·identity 복원, 외부 asset 준비, 실제 Tornado2/PGM 송출까지 모두 확인했다는 뜻은 아니다. 현재 재감사 판정과 남은 증거는 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)를 따른다. - -## 구현 및 후속 운영 경계 - -### 데이터베이스 - -- 완료: `Oracle.ManagedDataAccess.Core 23.26.200`, `MySqlConnector 2.6.1` 고정 -- 완료: 실제 비동기 `IDataQueryExecutor`, 취소, 명령/전체 timeout, 새 연결 재시도 -- 완료: LocalAppData/환경 변수 설정과 공개 오류의 비밀값 비노출 -- 완료: Oracle/MariaDB health, 기존 종목·지수 SQL의 WebView 조회, 단위/실DB 스모크 -- 후속: 운영 보안 정책에 맞춘 Credential Locker/DPAPI 전환과 계정 자동 순환 -- 후속: 사용자 입력이 들어가는 나머지 legacy SQL의 단계적 매개변수화 - -### Tornado/K3D - -원본 `MainForm`/35개 Scene builder/`PageN`의 호출 순서와 현재 어댑터의 정확한 적용 범위는 [원본 Tornado 송출 흐름 분석](LEGACY_PLAYOUT_ANALYSIS.md)에 정리했습니다. - -- 완료: Registry64의 K3D TypeLib/CLSID/ProgID/AMD64/Apartment 등록 검사 -- 완료: 빌드 입력에 Interop DLL을 두지 않는 late binding과 선택적 진단용 `TlbImp` 스크립트 -- 완료: `IPlayoutEngine` 경계, bounded STA FIFO/message pump, timeout 후 `OutcomeUnknown` 격리 -- 완료: 연결/해제, 명시적 재연결(no replay), `Tornado2` 접두사 프로세스 감시 -- 완료: `PREPARE`, `TAKE IN`, `NEXT`, `TAKE OUT` WebView 메시지 및 상태/오류 UI 연결 -- 완료: 기본 DryRun, Test의 단일 loopback 테스트 인스턴스·채널, Test/Live 공통 폐쇄형 씬 allowlist, Live 이중 승인 -- 완료: 현재 Tornado2 PGM 렌더 창에 대한 x64 K3D `KTAPConnect → Disconnect` 실제 왕복 및 Network Monitoring `[R] HELLO`/`[S] SUCCESS HELLO` 확인. 렌더 명령은 호출하지 않음 -- 완료: 네이티브/Interop 이중 SHA-256 핀, 프로세스 수명 파일 잠금, 실제 PGM listener 소유권 및 KTAP 지연 dispatch 차단 -- 과거 기본 경로 증거: 승인된 PGM 고정 runner에서 `5001 → 5006 → TAKE OUT`과 Network Monitoring `HELLO/LOAD_SCENE/SCENE_PREPARE/PLAY/STOPAL/BYE`, 39개 연속 캡처를 검증했다. 이는 현재 WebView의 fresh TAKE IN, Page NEXT, timer refresh 또는 35개 builder 동등성 완료 증거가 아니다. -- 완료: 첫 과거 회차의 출력 전 Prepare 거부 원인이 cue 이중 resolve임을 확인하고, 상대 cue와 승인 검사용 절대 자산 경로를 분리하는 수정 및 회귀 테스트 적용. 결과 불명확 시 재시도 금지 원칙 유지 -- 과거 실패·복구 증거: 승인 회차 `MBNWEB-20260711-A`의 CONNECT/PREPARE 뒤 fresh TAKE IN Oracle 조회가 K3D PLAY 전에 명확히 실패했으며 재시도 없이 STOPAL/UNLOAD/BYE로 회수했다. -- 승인 범위 완료: 이후 `MBNWEB-20260711-H`에서 설치된 MSIX WebView로 `5001 PREPARE → fresh TAKE IN → 자동 refresh 1회 → 5074 playlist NEXT → Page NEXT → TAKE OUT`을 실제 PGM과 Network Monitoring에서 함께 확인했다. `PLAY=4`, `SCENE_PLAYED=4`, FAILURE/ERROR/retry=0, 최종 `OutcomeUnknown=false`였다. 이 증거는 승인된 `5001`/`5074`에만 적용한다. -- 완료: `OnScenePlayed`/`OnCutOut`/`OnStopAll` callback 기반 장기 세션 Scene unload와 pending callback fail-closed accounting -- 화면/계약 구현: 35개 scene builder의 복합 K3D mutation 및 `PageN`/`Nxt_PageN` 5·6·12개/최대 20페이지 포팅과 자동·실제 DB read→DTO→mutation 검증. 실제 장면별 PGM 검증과 asset readiness는 별도 판정 - -### 화면/계약 구현과 운영 동등성 검증 - -- 화면/계약 구현: MainForm과 UC1~UC7의 종목·고정·업종·비교·테마·해외·전문가·거래정지 workflow, GraphE·FSell·VIList·PList/AList·ThemeA·EList 전용 화면과 native bridge -- 자동 계약 검증: 328 fixed + 44 industry + 31 stock + 9 comparison의 412 action 매트릭스, typed DTO, 저장·복원 fail-closed 경계 -- 적용 중: PList의 기존 한국어 `DC_TITLE`/`LIST_TEXT` 2,213행은 원문 round-trip과 typed persistence가 전부 일치했다. KRX·해외종목 및 별도 GraphE fresh proof를 합쳐 `selectionMapped=1,603`, 차단 610이며 이 수치는 scene data·asset·PREPARE·PGM 성공을 뜻하지 않는다. -- 화면/계약 구현·운영 검증 대기: GraphE 36행 중 22행은 fresh row-version/stock identity로 복원되고 14행은 저장값 무효·identity 중복으로 차단된다. FSell/VI PList행은 현재 0건이며 production source read만 FSell 3×5행, VI 9항목·2 page로 확인했다. ThemeA/EList 운영 write와 PGM은 별도 승인 범위다. -- 완료: 원본 `Data\종목비교.dat`는 고정 read-only source, strict parser·hash·fresh identity로 검증한 뒤 signed package에서 8쌍을 한 번 이전했고 정상 재시작 뒤 marker·순서를 확인함 -- 코드·격리 fixture 완료 / 운영 profile 보존: FSell/VI importer는 durable intent·marker-last·no-overwrite를 검증했다. 현재 profile은 이미 byte-identical한 4파일 때문에 `DestinationNotEmpty`로 차단되며 marker를 임의 생성하지 않음 -- 외부자산 필요: `s5006`의 `Video\큐브배경.vrv`, `s6001` 해외지수의 국가별 영상 13개. 현재 alias-only cut 검사를 asset-ready 근거로 사용하지 않음 -- 운영 동등성 미검증: GraphE, PList/AList, ThemeA, EList의 실제 Oracle DB-W와 FSell/VI 운영 파일 write/read-back -- 실제 PGM 제한: Round H의 `5001`/`5074`만 완료 증거가 있으며 나머지 장면·화면은 별도 승인 회차가 필요함 - -## 의도적으로 제외한 항목 - -- WinForms `*.Designer.cs`, `*.resx` -- FarPoint DLL과 라이선스 파일 -- `Interop.K3DAsyncEngineLib.dll` 복사본 -- 원본 `App.config` 바인딩 리디렉션 -- 실제 DB 주소, 계정, 암호가 들어 있는 INI -- Git에서 관리되지 않던 약 212MB의 Tornado 컷/영상 자산 - -이 항목들은 WinUI/WebView/MSIX 경계에 맞춰 재구현하거나, 정식 라이선스 및 운영 자산 위치가 확인된 뒤 어댑터 형태로 연결합니다. +작업 로그, 날짜가 붙은 감사·검증 문서, `RELEASE_*` 및 `LIVE_*` 문서는 당시 실행과 +판정의 증거다. 현재 상태를 판단할 때는 먼저 `MIGRATION_STATUS.md`를 확인한다. diff --git a/docs/MIGRATION_STATUS.md b/docs/MIGRATION_STATUS.md new file mode 100644 index 0000000..38def46 --- /dev/null +++ b/docs/MIGRATION_STATUS.md @@ -0,0 +1,105 @@ +# MBN_STOCK_WEBVIEW 이관 현재 기준선 + +기준 시각: 2026-07-20 + +기준 소스: `main` 브랜치의 이 문서 포함 최신 기준선 + +원본: `C:\Users\MD\source\repos\MBN_STOCK_N` (계속 읽기 전용) + +이 문서는 현재 이관 완료 범위와 남은 차단 항목의 우선 기준이다. 날짜가 더 이른 감사·회차 +문서와 충돌하면 이 문서의 판정을 따른다. 과거 문서는 당시 패키지와 당시 실행 회차의 증거로만 +보존한다. + +## 활성 기준선 + +- 개발·실행·패키징 대상은 `src/MBN_STOCK_WEBVIEW.LegacyParityApp` 하나다. +- 루트 `MBN_STOCK_WEBVIEW.csproj`와 루트 `MainWindow*`/`Web`은 이전 비교 프로토타입이다. + 현행 솔루션과 기본 F5에서는 제외했으며, 연결된 과거 비교 테스트 정리 전까지 참고용으로 보존한다. +- 원본 `MBN_STOCK_N`은 비교 기준으로만 사용하며 계속 읽기 전용이다. +- 이 문서 외의 작업 기록, 날짜별 감사와 실행 회차 문서는 당시 증거다. + +## 현재 결론 + +전체 이관은 기능·개발 검증 기준으로 거의 완료됐다. 없는 외부 제작 자산은 관련 두 route만 +fail closed로 제한한 채 진행하고, 고객 배포는 사용자가 다시 결정할 때까지 현재 범위에서 보류한다. +기존 플레이리스트 610행은 코드 결함이 아니라 과거 identity/현재 DB 식별정보 부족에 따른 외부 +데이터 결정 대기 항목이다. + +| 범위 | 현재 판정 | 검증 범위 | +|---|---|---| +| 원본 Scene 구현 | 완료 | builder 35/35, 실제 운영 runtime route 34개, 무alias 진단 전용 `s8086` 1개 | +| 원본 DB 연결 상태 감시 | 구현·자동 회귀 완료 | LegacyParityApp 시작 후 10초 간격으로 Oracle/MariaDB 상태를 확인하고 상태 전이 때만 native 로그를 남긴다. 운영자 입력·송출·자동 갱신이 시작되면 진단 조회를 취소하고 DB activity gate를 양보한다. 종료 시 monitor도 취소한다. 실제 DB 서버를 강제로 끊는 장애 주입은 하지 않았다. | +| 개발 MSIX 전체 UI/DB | 완료 | 실제 Windows 입력으로 UC1~UC7, GraphE, FSell, VIList, ThemeA, EList, PList 모달, 검색·키보드·컷/플레이리스트 drag를 검증했다. `screens` 15 화면/15 데이터·파일 검사, `graphe` 5 화면/12 DB 검사, `catalog` 3 화면/11 DB 검사, PList create→fresh read→delete가 모두 PASS였고 잔여 테스트 데이터는 0이다. 2026-07-20에는 선택 행 기준 NEXT, drag 피드백, 송출 후 컷 선택 표시를 실제 입력으로 재검증했다. | +| 개발 Tornado2/PGM | **32/34 runtime route 완료** | `5001 → 5074 Page NEXT → TAKE OUT` 재검증과 자산 비의존 30개 route의 LOAD/PREPARE/PLAY/UNLOAD를 실제 PGM에서 확인했다. 30개는 구/신규 프로그램의 동일 저장 편성표 사용자 입력으로도 비교했고, 신규는 실제 PGM 화면 30/30을 보존했다. 확인된 명령은 request/success가 일치하고 `FAILURE=0`, `ERROR=0`, retry 0, `OutcomeUnknown=false`다. | +| PageN/장기 실행 경계 | 완료 | `5077` 6행을 1/20부터 20/20까지 실제 Page NEXT하고, 마지막 페이지에서 playlist NEXT로 `5088` 12행 1/20으로 전환했다. 이전 scene unload와 최종 TAKE OUT/UNLOAD/IDLE을 확인했다. | +| 자동 회귀 | 완료 | 최신 .NET solution 2,817/2,817, LegacyWeb 117/117, JavaScript 502/502; 실패·skip 0. Release 빌드 warning 0, error 0 | +| 고객 배포 MSIX | 현재 범위에서 보류 | 현재 결과는 unsigned 개발 패키지다. 고객용 version, Publisher, 서명 인증서, 업데이트 방식과 새 PC의 DB/Development Live 로컬 설정 배포안은 사용자가 배포 작업을 재개할 때 결정한다. | + +## 실제 PGM 검증 경계 + +- `5001`과 `5074`는 현재 작업 트리 기준으로 다시 검증했다. 2026-07-11 Round H의 과거 결과에 + 의존하지 않는다. +- 이어서 외부자산에 의존하지 않는 30개 route를 순차 검증했다. 마지막 `5025` TAKE OUT 뒤 + `UNLOAD 1/1`, `FAILURE 0`, `ERROR 0`, 정확한 `IDLE`을 확인했다. +- 같은 30개를 구 프로그램과 신규 프로그램에서 동일한 저장 편성표 순서와 실제 포인터 입력으로 + 각각 실행했다. 신규는 UI 상태 증거 120개와 PROGRAM 직후 PGM 화면 30개를 남겼고, + 장면별 합산은 LOAD 30/30, PREPARE 56/56, PLAY 56/56, UNLOAD 30/30, + `FAILURE=0`, `ERROR=0`이다. 구 프로그램은 이번 현재 데이터 회차에서 PGM 오류 없는 PROGRAM + 11개, PGM 오류 포함 4개, 데이터·매핑 차단 15개였다. 오류 없는 11개의 mutation 대조에서는 + 8개가 동등했고, 2개는 저장 기간 제목 복원 차이, 1개는 베이시스·선물 갱신 누락이었다. 상세 판정은 + [`LEGACY_NEW_PLAYOUT_COMPARISON_20260719.md`](LEGACY_NEW_PLAYOUT_COMPARISON_20260719.md)에 고정했다. +- `5032`는 authoritative asset에 실제로 존재하지 않는 장식 오브젝트와 `pattern` 자산을 보내지 + 않도록 계약을 교정한 뒤 다시 검증했다. 선물 좌·우 plate가 정상 표시되고 관련 명령 실패는 0이다. +- 최초 `5077` 경계 검증에서 실제 데이터 120건임에도 행의 page count가 `1/1`로 고정되는 결함을 + 발견했다. 첫 PREPARE/TAKE IN 전에 전체 PageN 행을 read-only preflight하여 `1/N`을 고정하도록 + 수정했고, 이후 `5077` 20페이지 전체와 `5088` 전환을 재검증했다. 결함이 있던 최초 실행 결과는 + 성공 증거로 사용하지 않는다. +- `s5006`과 `s6001`은 asset preflight가 fail closed하므로 PREPARE/PLAY를 보내지 않았다. + +로컬 상세 증거는 다음 위치에 있다. + +- [`MBNWEB-20260718-DEV-LIVE-01`](../artifacts/pgm-evidence/MBNWEB-20260718-DEV-LIVE-01) +- [`MBNWEB-20260718-DEV-LIVE-02`](../artifacts/pgm-evidence/MBNWEB-20260718-DEV-LIVE-02) +- [`MBNWEB-20260718-DEV-LIVE-05`](../artifacts/pgm-evidence/MBNWEB-20260718-DEV-LIVE-05) +- [`구/신규 30개 송출 비교`](LEGACY_NEW_PLAYOUT_COMPARISON_20260719.md) +- [`최종 MSIX 화면/DB PASS`](../artifacts/package-input-smoke/legacy-package-input-20260718T194130241Z.json) +- [`PList DB PASS`](../artifacts/package-input-smoke/legacy-package-input-20260718T193931554Z.json) +- [`GraphE DB PASS`](../artifacts/package-input-smoke/legacy-package-input-20260718T194003350Z.json) +- [`ThemeA/EList DB PASS`](../artifacts/package-input-smoke/legacy-package-input-20260718T194054981Z.json) +- [`최종 read-only 회귀 PASS`](../artifacts/package-input-smoke/legacy-package-input-20260718T194240167Z.json) +- [`2026-07-20 실제 입력 보정 PASS`](../artifacts/package-input-smoke/legacy-package-input-20260720T032627652Z.json) + +`artifacts`의 개발 장비 증거에는 로컬 실행 상태가 포함될 수 있으므로 고객 MSIX나 Git 배포물에 +넣지 않는다. + +## 현재 코드 작업과 분리된 외부 항목 + +### 1. 제작 자산 + +- `s5006\Video\큐브배경.vrv` +- `s6001` 국가 영상 13개 +- `배경\기본.vrv` +- 필요한 스튜디오 `.vrv` 배경 원본 + +현재 authoritative 원본 Cuts에도 없다. 자산 없이 진행한다는 사용자 결정에 따라 제공 전에는 +관련 경로만 안전하게 차단하며, 이는 현재 작업 전체의 차단 사유가 아니다. 나중에 자산을 받으면 +trusted root·빈 파일·reparse·경로 이탈 검사를 통과시킨 뒤 `s5006`/`s6001`을 실제 PGM에서 검증한다. + +### 2. 기존 플레이리스트 데이터 정합성 + +총 2,213행 가운데 1,581행은 현재 식별자로 즉시 복원할 수 있고, GraphE fresh read로 22행을 더 +복원할 수 있다. 최종 610행은 과거 저장 identity 또는 현재 DB 식별정보가 부족해 차단한다. 이 중 +KRX 테마 493행과 NXT 테마 48행은 DBA의 데이터 정합성 결정이 필요하다. 이름이나 유사 scene으로 +추정 복원하지 않는다. + +### 3. 고객 배포 + +현재 작업 범위에서는 보류한다. 사용자가 배포를 재개하면 +고객용 version, Publisher, 서명 인증서, 인증서 보관·갱신 절차, 업데이트 방식, 새 PC의 DB 및 +Development Live 로컬 설정 배포 방식이 확정돼야 한다. Release/default `DryRun`, scene/path +allowlist, vendor hash, STA 직렬화와 대상 identity 검사는 그대로 유지한다. + +## 완료 조건 + +외부 자산 두 scene의 PGM 검증, 610행에 대한 DBA 결정 또는 명시적 영구 차단 승인, 고객 서명 +MSIX의 clean 설치·업데이트·새 PC 설정 검증이 끝나면 이관을 고객 배포 기준의 완전 완료로 올린다. diff --git a/docs/OPERATOR_UI_PARITY.md b/docs/OPERATOR_UI_PARITY.md index 5e1aa7d..6e1c183 100644 --- a/docs/OPERATOR_UI_PARITY.md +++ b/docs/OPERATOR_UI_PARITY.md @@ -1,5 +1,9 @@ # 운영자 UI 동등성 인벤토리 +> **문서 성격:** 이 문서는 역사·상세 증거를 보존한다. 현행 완료·차단 판정은 +> [`MIGRATION_STATUS.md`](MIGRATION_STATUS.md)가 우선한다. 아래의 “미실행”, “대기”, +> “별도 승인” 표현과 과거 테스트 수치는 해당 시점의 기록이며 현재 상태가 아니다. + 기준일: 2026-07-15 ## 제품과 판정 기준 diff --git a/docs/PLAYOUT.md b/docs/PLAYOUT.md index 183905c..f8574a2 100644 --- a/docs/PLAYOUT.md +++ b/docs/PLAYOUT.md @@ -4,6 +4,9 @@ > [개발 환경 상시 권한](DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)에 따라 회차별 대화형 > 승인을 다시 받지 않는다. 운영/방송 대상과 `OutcomeUnknown` 중단 규칙은 그대로다. +> 현재 완료·차단 판정은 [`MIGRATION_STATUS.md`](MIGRATION_STATUS.md)가 우선한다. 아래의 +> 2026-07-11 Round 기록과 1.0.5 해시는 당시 패키지의 역사적 증거다. + ## 안전 기준 기본 모드는 `DryRun`입니다. `DryRun`은 COM 객체를 만들거나 Tornado 출력에 명령을 보내지 않고 WebView 메시지, 큐, 상태 및 오류 표시 흐름만 검증합니다. 현재 방송 PROGRAM(PGM)에 연결된 Tornado 프로세스는 항상 **안전하지 않은 대상**으로 취급합니다. 프로세스 이름이나 실행 여부만으로 테스트 대상이라고 판단하지 않습니다. @@ -346,7 +349,7 @@ CLI용 Test JSON은 위처럼 앱 기본 경로인 `playout.local.json`과 다 3. 잘못된 설정 또는 엔진 부재가 앱 종료가 아니라 연결 상태와 안전한 오류 메시지로 표시됩니다. 4. x64 MSIX를 설치해도 같은 dry-run 흐름이 동작합니다. -최종 서명 Release x64 MSIX를 설치한 package context에서 실제 Oracle/MariaDB를 읽는 `DryRun` 검증을 완료했습니다. Web catalog 35개(34개 runtime route 보유), DB 상태 2/2 정상, alias `5001`/`N5001`, fade 6, mutation preview와 asset 경로 비노출을 확인했습니다. 34개 runtime route가 모두 외부 asset까지 준비됐다는 뜻은 아니다. 이어 `5001 PREPARE → fresh TAKE IN → callback drain 뒤 full 2초 cooldown의 자동 refresh → 5074 playlist NEXT → 같은 entry/scene의 Page NEXT`를 수행했고, 5074 `page 1/20 → page 2/20` 전환과 Page NEXT 뒤 refresh 정지를 확인했습니다. 5·6·12개 단위, 최대 20페이지, partial/마지막 page의 clear·hide, `isLastPage`, `END OF PLAYLIST`와 NEXT 비활성 경계는 원본에서 도출해 고정한 자동 테스트로 검증했습니다. TAKE OUT 뒤에는 scene/refresh가 정리되고 playlist 편집 잠금이 해제되었습니다. 전 과정의 안전 배지는 `DRY RUN · PROGRAM 차단`이었고 COM/KTAP/Tornado2 연결은 발생하지 않았습니다. +2026-07-18 현재 unsigned 개발 MSIX에서 실제 Oracle/MariaDB를 사용하는 전체 UI/DB 검증을 완료했습니다. 이어 지정 개발 Tornado2/PGM에서 `5001 → 5074 Page NEXT → TAKE OUT`을 재검증하고 자산 비의존 30개 route를 순차 검증했습니다. 실제 PGM 완료 범위는 32/34 route이며, 미검증은 원본 Cuts에도 영상이 없는 `s5006`과 `s6001`뿐입니다. `5077`은 fresh DB 120건을 6행씩 1/20부터 20/20까지 이동했고 마지막 페이지에서 `5088` 12행 1/20으로 전환했습니다. 이전 scene unload와 최종 TAKE OUT/UNLOAD/IDLE, `FAILURE=0`, `ERROR=0`, retry 0, `OutcomeUnknown=false`를 확인했습니다. WebView 상태 wire는 `Disconnected`, `Connecting`, `Connected`, `Reconnecting`, `Faulted`, `OutcomeUnknown` 등 native connection state를 별도로 전달합니다. Web catalog는 35개 builder와 도달 가능한 45개 alias, row별 `enabled`를 보존하고 PREPARE 성공 뒤 playlist snapshot을 freeze합니다. pending command, `OutcomeUnknown`과 timeout quarantine 중에도 snapshot 편집 잠금을 유지합니다. native status의 current entry/builder/page size/current rows/last-page와 bounded preview가 authoritative 값이며 asset path는 preview에서 숨깁니다. refresh active/next/last-success/fault도 전달하고 refresh fault는 TAKE OUT 외 mutation 명령을 막습니다. 성공한 TAKE OUT 뒤에는 전용 refresh error marker만 reset하며 다른 unknown latch를 지우지 않습니다. `OutcomeUnknown`과 timeout은 `retryable: false`이며 오류 창을 닫아도 native 잠금은 유지됩니다. 브라우저 응답 제한 시간은 검증된 native operation timeout에 5초 전달 여유를 더해 사용하며, 상관 응답이 오지 않으면 strict `ParseTimeoutQuarantine` 요청을 native에 보냅니다. MainWindow는 await 전에 process-lifetime latch를 세우고 vendor session을 quarantine하므로 WebView reload로도 해제되지 않습니다. 명령 진행 중 trusted navigation, reload 또는 WebView2 process failure로 JavaScript 상관관계가 사라지는 경우도 같은 native latch를 먼저 세웁니다. 늦은 응답이나 UI 재시도로 같은 명령을 다시 보내지 않으며 native 명령이 끝나면 authoritative status를 다시 게시합니다. NEXT는 원본의 `m_TakeIn` 조건처럼 on-air 장면이 있을 때만 native와 Web 양쪽에서 허용됩니다. Test on-air 배지는 실제 PROGRAM과 구분해 `TEST ON AIR`로 표시합니다. @@ -354,13 +357,15 @@ On-air 표식이 남은 상태에서는 프로세스 감시, 연결 해제, 앱 ### 화면/계약 구현과 운영 동등성 경계 -- PList 기존 한국어 `DC_TITLE`/`LIST_TEXT` 2,213행은 실제 read-only DB에서 무손상 대조했다. KRX·해외종목·GraphE fresh proof를 합친 `selectionMapped=1,603`/차단 610은 확인했지만 scene data·asset·PREPARE·PGM 성공은 별도다. 실제 manual named save write→reload는 미검증이다. -- ThemeA/EList의 expected identity와 응답 상관 계약은 구현됐지만 KRX/NXT·동명이름·코드 경계의 실제 create/replace/delete read-back은 검증되지 않았다. +- PList 기존 한국어 `DC_TITLE`/`LIST_TEXT` 2,213행은 실제 read-only DB에서 무손상 대조했다. 즉시 복원 1,581행과 GraphE fresh read 22행을 합친 `selectionMapped=1,603`/차단 610을 확인했다. 차단 610행은 DBA 결정 없이 추정 복원하지 않는다. +- 개발 MSIX에서 PList create/save→fresh readback→delete/absence, ThemeA KRX/NXT와 EList create/edit/save→fresh readback→delete/absence를 실제 입력으로 검증하고 cleanup했다. 운영 DB에 대한 포괄적 쓰기 허가는 이 결과에 포함하지 않는다. - 원본 `종목비교.dat`는 signed package에서 8쌍 one-time import와 재시작 marker를 확인했다. FSell/VI는 격리 fixture의 marker-last와 production opened-handle read를 통과했지만, 현재 운영 profile의 기존 동일 파일을 덮어쓰거나 marker를 새로 만들지 않았다. - `s5006`의 `Video\큐브배경.vrv`와 `s6001` 해외지수 국가 영상 13개는 현재 승인 Cuts에 없으며, alias-only `Missing=0`은 asset-ready 증거가 아니다. -- GraphE·PList/AList·ThemeA·EList 운영 DB-W와 `5001`/`5074` 밖의 실제 PGM은 미검증이다. 아래 Round H 기록은 승인된 두 scene의 역사적 증거로만 보존한다. +- 개발 PGM 32/34 route가 검증됐고, `s5006`/`s6001`은 외부자산 제공 전까지 fail closed한다. 아래 Round H 기록은 현재 결과가 아니라 역사적 증거로 보존한다. -승인 컷의 과거 load/play 경로와 현재 WebView 기반 Scene/PageN 데이터 동등성은 서로 다른 검증 범위입니다. 복합 mutation·페이지 계산·fresh TAKE IN·Page NEXT·timer refresh는 구현 및 자동/실데이터 검증을 마쳤으며 [원본 Tornado 송출 흐름 분석](LEGACY_PLAYOUT_ANALYSIS.md)과 [35개 Scene 매트릭스](SCENE_EQUIVALENCE.md)에 기록합니다. 첫 Live WebView 회차 `MBNWEB-20260711-A`의 DB 실패와 Round G의 refresh starvation은 모두 결과를 추정하거나 반복하지 않고 안전하게 종료했습니다. 수정된 Round H에서는 승인된 5001/5074 범위의 실제 TAKE IN, 자동 refresh, playlist/Page NEXT와 TAKE OUT을 Network Monitoring과 PGM에서 함께 완료했습니다. 이 결과를 나머지 scene의 실제 PGM 검증으로 확대하지 않습니다. +### 역사적 Round 기록 + +첫 Live WebView 회차 `MBNWEB-20260711-A`의 DB 실패와 Round G의 refresh starvation은 모두 결과를 추정하거나 반복하지 않고 안전하게 종료했습니다. 수정된 Round H에서는 당시 승인된 5001/5074 범위의 실제 TAKE IN, 자동 refresh, playlist/Page NEXT와 TAKE OUT을 Network Monitoring과 PGM에서 함께 완료했습니다. 이 기록은 당시 패키지 증거이며 2026-07-18 현재 판정은 [`MIGRATION_STATUS.md`](MIGRATION_STATUS.md)를 따른다. 일반 앱과 정규 Test 경로의 실제 COM 스모크는 별도 테스트 인스턴스와 테스트 씬이 준비된 때에만 진행합니다. 위 안전 게이트를 독립적으로 재확인하고 `mode`를 `Test`로 바꾼 뒤 테스트 모니터에서 `PREPARE → fresh TAKE IN → Page/playlist NEXT → timer refresh → TAKE OUT` 결과를 관찰합니다. 의도하지 않은 PGM/운영 출력 변화가 보이면 추가 명령을 중단합니다. native 결과가 명확하고 Gate A에 포함된 경우에만 TAKE OUT을 한 번 요청하며, timeout·`OutcomeUnknown`·`WEB_TIMEOUT`·refresh fault라면 앱 종료나 반대 명령으로 자동 롤백하지 않고 session을 quarantine한 채 운영자가 실제 출력 상태를 먼저 확인합니다. diff --git a/docs/PLAYOUT_OPERATIONS.md b/docs/PLAYOUT_OPERATIONS.md index a784df4..6e94207 100644 --- a/docs/PLAYOUT_OPERATIONS.md +++ b/docs/PLAYOUT_OPERATIONS.md @@ -5,21 +5,24 @@ > 허용했으므로 반복 승인을 요청하지 않는다. 범위와 기술적 중단 조건은 > [개발 환경 상시 권한](DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md)을 우선 적용한다. -이 문서는 마이그레이션된 WebView → `IPlayoutEngine` → K3D/Tornado2 경로를 검증할 때의 승인, 실행, 감시, 장애 복구와 rollback 기준이다. 기존 [`PLAYOUT.md`](PLAYOUT.md)의 K3D 설치·해시 핀·격리 진단 규칙을 대체하지 않고, 35개 scene과 PageN 동등성 검증에 필요한 운영 절차를 추가한다. 화면/계약 구현과 운영 동등성의 최신 재감사 상태는 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)를 따른다. +> 현재 완료·차단 판정은 [`MIGRATION_STATUS.md`](MIGRATION_STATUS.md)가 우선한다. 아래 +> Round A~M과 1.0.x 기록은 당시 실행의 역사적 증거로 보존한다. + +이 문서는 마이그레이션된 WebView → `IPlayoutEngine` → K3D/Tornado2 경로를 검증할 때의 승인, 실행, 감시, 장애 복구와 rollback 기준이다. 기존 [`PLAYOUT.md`](PLAYOUT.md)의 K3D 설치·해시 핀·격리 진단 규칙을 대체하지 않고, 35개 scene과 PageN 동등성 검증에 필요한 운영 절차를 추가한다. ## 현재 상태 | 항목 | 상태 | |---|---| | 기본 모드 | `DryRun`; COM 객체와 `KTAPConnect`를 만들지 않음 | -| 자동 테스트 | 최종 전체 재검증에서 Core Debug/Release x64 각각 1,383/1,383, Playout 각각 393/393, Infrastructure 각각 173/173(합계 각 1,949/1,949), Web 415/415 통과; 실패·skip 0건 | -| Visual Studio 2026 | MSBuild `18.7.8.30822` Debug x64와 signed Release x64 패키지 빌드 성공 | -| trusted Release x64 MSIX | clean commit `df60e09`에서 `1.0.5.0` 생성·`CN=Comtrophy` 서명/SignTool 검증·x64 update 설치·package context 실행 성공. SHA-256 `1B0BA713619CC3BD7CB3E03D78FD1EB1CEAF49E7D50F5C00DDFB483917BA5437`; 290개 항목, source Web 33개 byte 일치, 금지 자산 0건. 실제 삼성전자 현재가 5001 DryRun에서 자동 refresh 1회 뒤 `CAPPED 1/1`, 3.5초 무변화, TAKE OUT/IDLE, Oracle/MariaDB `Healthy`, KTAP 미호출을 확인 | +| 자동 테스트 | 최신 .NET solution 2,817/2,817, LegacyWeb 117/117, JavaScript 502/502 통과; 실패·skip 0건. Release 빌드 warning 0, error 0 | +| 개발 MSIX UI/DB | 실제 Windows 입력으로 UC1~UC7, GraphE, FSell, VIList, ThemeA, EList, PList 모달과 개발 DB 저장→fresh readback→delete/absence를 검증하고 cleanup 완료 | +| 고객 배포 MSIX | 현재 범위에서 보류. 고객용 version·Publisher·서명 인증서·업데이트와 새 PC 로컬 설정 배포는 사용자가 재개할 때 결정 | | 실제 DB read→DTO→mutation smoke | 34개 도달 가능 data route 통과: 33개 DB + `s5025` trusted 외부 CP949 파일. `s8086` diagnostic과 NXT restore audit를 포함한 Oracle/MariaDB query 58건 통과. 종속 asset·운영 DB-W·PGM 검증은 포함하지 않음 | | `s5025` trusted 수동 파일 | 외부 CP949 source 통합 검증 통과; 실제 파일/환경은 계속 Git 밖에서 회차별 preflight | | `s5006` `Video\큐브배경.vrv` | 현재 승인 Cuts root에서 누락 | | `s6001` 해외지수 영상 | 국가별 `Video\20201008_<국가>.vrv` 13개가 현재 승인 Cuts root에서 누락; 외부자산 필요 | -| 이번 마이그레이션 WebView workflow의 실제 Tornado2 검증 | **최신 성공은 승인된 5001/5074 범위의 Round H. Round J/K/L은 각각 pre-dispatch 명령 0건으로 종료됐고 창 preflight를 교정한 Round M은 Gate A 승인 대기; 35개 전체의 실제 PGM 검증으로 확대 해석하지 않음** | +| 이번 마이그레이션 WebView workflow의 실제 Tornado2 검증 | **32/34 runtime route 완료. 5001/5074 재검증, 자산 비의존 30개 route, 5077 20페이지 전체, 5088 전환, retired/final unload 완료. 미검증은 외부자산이 없는 s5006/s6001뿐** | 1.0.2 Visual Studio 개발 등록본을 signed 1.0.3으로 교체할 때 `Remove-AppxPackage -PreserveApplicationData`를 사용했다. 재등록 구간에서 바뀐 파일은 Windows package Settings transaction log `settings.dat.LOG1/LOG2`뿐이었고, WebView LocalState와 외부 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW` 운영 데이터는 삭제·덮어쓰지 않았다. 이 패키지 검증 회차는 기본 설정 부재와 환경 override 0건을 확인한 `DryRun`으로만 실행했으며 Tornado 실제 명령은 보내지 않았다. @@ -38,7 +41,7 @@ in-place update했다. remove/reset 없이 비교 8쌍과 one-time marker가 유 2026-07-11의 승인 회차 `MBNWEB-20260711-A`에서는 현재 MSIX WebView runtime으로 `CONNECT → PREPARE 5001/page 1`까지 성공했다. Network Monitoring에 `HELLO`, `LOAD_SCENE`, `BEGIN_TRANSACTION`, 데이터 mutation, `QUERY_VARIABLES`, `END_TRANSACTION`, `SCENE_PREPARE`의 request/success가 기록됐고 PREPARE 동안 PGM은 검은 화면이었다. TAKE IN은 두 번째 K3D Prepare/Play 전에 수행하는 fresh Oracle 조회에서 `DATABASE_UNAVAILABLE`로 명확히 실패했다. `PLAY`가 없고 PGM도 검은 상태임을 확인해 TAKE IN을 반복하지 않았으며, 승인 범위의 `TAKE OUT All` 한 번으로 `STOPAL → UNLOAD_SCENE 5001` 성공을 확인하고 `BYE` 뒤 앱을 정상 종료했다. 직후 probe는 Oracle unhealthy/MariaDB healthy와 정상 DNS/TCP 도달을 보였고, 회차 종료 뒤 전체 55-query 스모크는 두 DB 모두 다시 통과했다. 이 회차는 실패·복구 증거이며 동등성 완료 증거가 아니다. 새 package/계획 SHA-256과 새 Gate A/B로 처음부터 다시 시작한다. -이 회차 뒤 health query와 시장 탭 DB 조회를 송출 fresh query와 하나의 DB activity gate로 직렬화했다. 송출 명령과 자동 on-air refresh가 시작되면 진행 중인 health/market 조회를 취소하고 gate를 먼저 확보하며, 30초 periodic health는 송출 중 건너뛴다. native database status에는 단조 증가 sequence를 붙이고 Web은 오래된 응답을 폐기한다. DB health의 `IsTransient`와 숫자 provider error code만 진단 도구에 보존하며 SQL, parameter, host, 계정, 암호, connection string과 provider message는 출력하지 않는다. +현재 LegacyParityApp은 원본과 같은 10초 주기의 Oracle/MariaDB 상태 감시를 시작·종료 수명주기에 연결하고, 연결 상태가 바뀔 때만 native 로그를 남긴다. 송출 명령, 운영자 입력과 자동 on-air refresh가 시작되면 진행 중인 health 조회를 취소하고 하나의 DB activity gate를 먼저 확보한다. DB health의 `IsTransient`와 숫자 provider error code만 진단 도구에 보존하며 SQL, parameter, host, 계정, 암호, connection string과 provider message는 출력하지 않는다. ### Round G 실패 경계와 Round H 최종 실제 검증 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..a9ce66d --- /dev/null +++ b/docs/README.md @@ -0,0 +1,49 @@ +# MBN_STOCK_WEBVIEW 문서 안내 + +이 디렉터리의 문서는 **현재 기준선**, **세부 기준선**, **운영 절차**, **역사·증거**로 +나뉜다. 최신 완료 범위나 남은 작업을 판단할 때는 +[`MIGRATION_STATUS.md`](MIGRATION_STATUS.md)를 가장 먼저 확인한다. + +문서 사이의 수치, 완료 판정 또는 차단 사유가 충돌하면 +`MIGRATION_STATUS.md`의 판정을 우선한다. 날짜가 붙은 감사·검증 문서와 과거 회차 문서는 +작성 당시의 증거로만 사용한다. + +## 1. 현행 상태 + +- [`MIGRATION_STATUS.md`](MIGRATION_STATUS.md): 현재 이관 완료 범위, 검증 경계, + 외부 입력이 필요한 항목과 최종 완료 조건의 단일 기준선 +- [`MIGRATION.md`](MIGRATION.md): 처음 방문한 사용자를 위한 짧은 문서 안내 + +## 2. 세부 기준선 + +- [`SCENE_EQUIVALENCE.md`](SCENE_EQUIVALENCE.md): 35개 Scene의 구현, 데이터 경로, + 자산 및 실제 PGM 검증 상태 +- [`LEGACY_PARITY_REBASELINE.md`](LEGACY_PARITY_REBASELINE.md): 실행 대상, + 원본 보존 원칙, 계층별 책임과 원본 호환 구조 + +## 3. 운영 절차 + +- [`PLAYOUT_OPERATIONS.md`](PLAYOUT_OPERATIONS.md): 송출 연결, 실행, 검증, 중단 및 + 장애 복구 절차 +- [`PLAYOUT.md`](PLAYOUT.md): K3D/Tornado 연동의 저수준 안전 경계와 진단 기준 +- [`DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md`](DEVELOPMENT_ENVIRONMENT_AUTHORIZATION.md): + 지정 개발 환경의 상시 허용 범위와 기술적 중단 조건 +- [`DEVELOPMENT_LIVE_VISUAL_STUDIO.md`](DEVELOPMENT_LIVE_VISUAL_STUDIO.md): + Visual Studio 개발 Live 실행 설정 + +## 4. 역사·증거 + +다음 문서는 추적성과 재현을 위해 보존한다. 현행 판정을 갱신하는 문서가 아니다. + +- [`LEGACY_BEHAVIOR_PARITY_WORKLOG.md`](LEGACY_BEHAVIOR_PARITY_WORKLOG.md): 누적 구현·검증 작업 기록 +- 날짜가 붙은 감사·비교 문서: 예를 들어 + [`LEGACY_PARITY_VALIDATION_20260715.md`](LEGACY_PARITY_VALIDATION_20260715.md), + [`LEGACY_RUNTIME_ASSET_VALIDATION_20260718.md`](LEGACY_RUNTIME_ASSET_VALIDATION_20260718.md), + [`LEGACY_NEW_PLAYOUT_COMPARISON_20260719.md`](LEGACY_NEW_PLAYOUT_COMPARISON_20260719.md) +- 릴리스 감사 문서: + [`RELEASE_1_0_4_AUDIT.md`](RELEASE_1_0_4_AUDIT.md), + [`RELEASE_1_0_5_AUDIT.md`](RELEASE_1_0_5_AUDIT.md) +- `LIVE_*` 회차 문서: 과거 승인, 검증, 조정 및 복구 계획의 실행 증거 + +역사·증거 문서에서 후속 작업처럼 보이는 항목을 발견해도 즉시 현재 작업으로 간주하지 +않는다. 먼저 `MIGRATION_STATUS.md`의 현재 판정과 대조한다. diff --git a/docs/SCENE_EQUIVALENCE.md b/docs/SCENE_EQUIVALENCE.md index 74e8756..0441a67 100644 --- a/docs/SCENE_EQUIVALENCE.md +++ b/docs/SCENE_EQUIVALENCE.md @@ -1,6 +1,11 @@ # 35개 Scene 동등성 매트릭스 -기준 시각은 2026-07-12이다. 원본 `C:\Users\MD\source\repos\MBN_STOCK_N`은 읽기 전용으로만 분석했으며, 이 문서 작업에서도 원본 파일을 수정하지 않았다. 원본 35개 builder의 파일 해시는 [`legacy-scene-source-hashes.json`](legacy-scene-source-hashes.json), 원본 구조 기준선 검사는 [`Test-LegacySceneBaseline.ps1`](../scripts/Test-LegacySceneBaseline.ps1)에 있다. 화면/계약 구현과 운영 동등성의 전체 재감사 판정은 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)를 따른다. +현행 판정 기준 시각은 2026-07-20이다. 완료·차단·외부 입력 범위는 +[`MIGRATION_STATUS.md`](MIGRATION_STATUS.md)를 우선 적용한다. 원본 +`C:\Users\MD\source\repos\MBN_STOCK_N`은 읽기 전용으로만 분석했으며, 이 문서 작업에서도 +원본 파일을 수정하지 않았다. 원본 35개 builder의 파일 해시는 +[`legacy-scene-source-hashes.json`](legacy-scene-source-hashes.json), 원본 구조 기준선 검사는 +[`Test-LegacySceneBaseline.ps1`](../scripts/Test-LegacySceneBaseline.ps1)에 있다. 이 문서에서 **화면/계약 구현 완료**는 DTO → mutation builder, 실제 데이터 loader, playlist selection resolver 또는 명시적 runtime route, 자동 테스트가 모두 존재한다는 뜻이다. **자동 계약 동등성 완료**는 원본 35개 builder의 source hash/inventory 기준선을 고정하고, 원본 구현에서 도출한 오브젝트·mutation·호출 순서·45개 alias·PageN 기대값을 새 구현의 매트릭스와 자동 suite로 검증했다는 뜻이다. 실행 중인 구 구현과 새 구현을 같은 입력으로 직접 호출해 의미를 대조하는 comparator, 종속 asset 준비 또는 실제 PGM 동등성 완료를 뜻하지 않는다. **운영 동등성 검증**은 승인된 asset·scene·회차에만 적용한다. 현재 구현 기준선은 완료됐지만 실제 PGM 증거를 승인되지 않은 나머지 scene으로 확대 해석하지 않는다. @@ -11,14 +16,16 @@ | 원본 inventory와 hash 기준선 | 완료 | 35/35 builder, 원본 기준선 스크립트 35/35 | | DTO와 mutation builder | 완료 | registry가 정확히 35개 builder를 발견하고 catalog와 1:1 대조 | | loader와 runtime route | 완료 | MainForm 도달 가능 builder 34개, active alias 45개를 fail-closed route로 등록; `s8086`은 원본과 같이 무alias 진단 전용 | -| 자동 테스트 | 완료 | Core 1,383/1,383, Playout 393/393, Infrastructure 173/173이 Debug/Release x64에서 각각 통과했다. 구성별 1,949개, 두 구성 합계 3,898개이며 실패·skip은 0건이다. Web 415/415와 원본 baseline 35/35도 통과 | -| Visual Studio 2026 빌드 | 완료 | MSBuild `18.7.8.30822`로 Debug x64와 signed Release x64 앱 패키지 빌드 성공 | -| Release x64 MSIX | 완료 | clean commit `df60e09`의 `1.0.5.0` 서명·설치·내용 감사와 package-context 실행 통과. SHA-256 `1B0BA713619CC3BD7CB3E03D78FD1EB1CEAF49E7D50F5C00DDFB483917BA5437`; 290개 항목, Web 33개 source 일치, 금지 자산 0건. 기본 상태는 `DryRunReady`, Oracle/MariaDB `Healthy`; 삼성전자 현재가 5001 자동 refresh cap 1과 TAKE OUT/IDLE을 확인 | +| 자동 테스트 | 완료 | 최신 .NET solution 2,817/2,817, LegacyWeb 117/117, JavaScript 502/502, 원본 baseline 35/35 통과; 실패·skip 0. Release 빌드 warning 0, error 0 | +| 개발 MSIX UI/DB | 완료 | 실제 Windows 입력으로 UC1~UC7, GraphE, FSell, VIList, ThemeA, EList, PList 모달, 검색·키보드·drag와 개발 DB 저장→fresh readback→delete/absence를 검증하고 cleanup 완료 | +| 고객 배포 MSIX | 현재 범위에서 보류 | 현재 결과는 unsigned 개발 패키지. 고객용 version·Publisher·서명 인증서·업데이트와 새 PC 로컬 설정 배포는 사용자가 재개할 때 결정 | | 실제 DB read→DTO→mutation smoke | 완료 | 34개 도달 가능 builder의 data route 통과: 33개 Oracle/MariaDB loader와 `s5025` trusted 외부 CP949 파일. 무alias `s8086` diagnostic과 NXT restore audit를 포함한 실제 Oracle/MariaDB query 58건이 성공했다. 종속 asset 또는 PGM 준비 상태는 이 행의 범위가 아니다. | -| 종속 asset preflight | **외부자산 필요 / 검사 보강 적용 중** | 승인 Cuts에서 `s5006`의 `Video\큐브배경.vrv`와 `s6001` 해외지수용 국가별 영상 13개가 없다. 현재 작업 트리에는 45 alias와 builder 소유 자산 23개의 누락·빈 파일·root 탈출·reparse 검사가 반영돼 있지만 최종 전체 회귀·패키지 반영 전에는 완료로 올리지 않는다. | -| 이번 마이그레이션의 실제 Tornado2/PGM | **이전 승인 범위 완료 / 1.0.5 재검증 대기** | 계획 SHA-256 `55C7755C2F874B4B012239117D4AB717BBCE194E7DCC8DA9B0E40D094FF8CA19`의 Round H는 5001/5074에 통과했지만 Round I는 refresh 상한 초과로 실패했다. 새 1.0.5 package의 cap은 DryRun만 통과했으며 실제 PGM은 새 계획·Gate A/B 승인 뒤 다시 검증해야 한다. | +| 종속 asset preflight | **외부자산 필요** | 승인 Cuts에서 `s5006`의 `Video\큐브배경.vrv`와 `s6001` 해외지수용 국가별 영상 13개가 없다. 누락·빈 파일·root 탈출·reparse 검사는 완료됐고 제공 전에는 두 route를 fail closed한다. | +| 이번 마이그레이션의 실제 Tornado2/PGM | **32/34 runtime route 완료** | 현재 작업 트리로 5001/5074를 재검증하고 자산 비의존 30개 route를 순차 검증했다. 5077 6행의 1/20→20/20, 마지막 페이지, 5088 12행 전환, 이전 scene unload와 최종 TAKE OUT/IDLE도 확인했다. 미검증은 외부자산이 없는 `s5006`/`s6001`뿐이다. | -Round H의 실제 Network Monitoring 합계는 `SCENE_PREPARE 5001 = 3`, `SCENE_PREPARE 5074 = 2`, `PLAY = 4`, `SCENE_PLAYED = 4`, `FAILURE = 0`, `ERROR = 0`이다. PGM은 5001 데이터, 5074의 page 1과 page 2를 순서대로 렌더링했고 TAKE OUT 뒤 검은 화면이 됐다. 이 증거는 승인된 두 scene에만 유효하다. 과거 실패 회차와 고정 runner는 장애 복구·기본 연결 근거로 보존하되 Round H의 동등성 근거를 대신하거나 확장하지 않는다. +2026-07-11 Round H와 실패 회차는 역사적 증거로 보존한다. 현재 32개 route 판정은 +`MBNWEB-20260718-DEV-LIVE-01/02/05`의 실제 PGM 화면, Network Monitoring request/success, +`FAILURE=0`, `ERROR=0`, retry 0, `OutcomeUnknown=false`를 기준으로 한다. ## 표기 @@ -45,8 +52,8 @@ Mutation 약어는 실제 COM 메서드를 Web 입력에 노출하지 않는 COM - `FILE-P`: `s5025`의 trusted 외부 CP949 수동 파일 → DTO → mutation preflight가 통과했다. 실제 파일과 디렉터리는 계속 Git 밖에 둔다. - `DB-DP`: 원본 MainForm에서 도달하지 않는 `s8086` diagnostic 조회와 mutation preflight가 통과했다. 이 결과로 runtime alias를 만들지는 않는다. - `ASSET-X`: builder가 요구하는 외부 asset이 승인 Cuts에 없거나 강화된 preflight가 끝나지 않아 실제 PREPARE/PGM 준비 상태가 아니다. -- `TOR-W`: 이번 마이그레이션 runtime으로 해당 scene을 실제 Tornado2/PGM에서 검증하지 않았다. 회차 승인 전에는 실행하지 않는다. -- `TOR-H`: Round H의 승인된 WebView Live 회차에서 PGM 화면과 Network Monitoring을 함께 검증했다. +- `TOR-18`: 2026-07-18 현재 작업 트리의 Development Live에서 PGM 화면과 Network Monitoring을 함께 검증했다. +- `TOR-X`: 필요한 외부 제작 자산이 없어 PREPARE 전에 fail closed했다. - `TOR-NA`: active alias가 없어 운영 송출 대상이 아니다. ## 자동 테스트 묶음 @@ -70,40 +77,40 @@ Mutation 약어는 실제 COM 메서드를 Web 입력에 노출하지 않는 COM | Builder / alias / page | 원본 기능 | 새 builder → loader → resolver/route | mutation | 자동 테스트 | 실제 데이터 | 실제 Tornado | |---|---|---|---|---|---|---| -| `s5001` / `5001`, `N5001` / — | 국내·NXT·해외 지수, 환율, 업종, 종목 단일 시세와 등락 표식 | `S5001SceneMutationBuilder` → `S5001SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-H` (`5001`) | -| `s5006` / `5006` / — | 국내·NXT 종목 현재·시가·고가·저가와 비율, 등락 상태, 큐브 배경 영상 | `S5006SceneMutationBuilder` → `S5006DomesticSceneDataLoader`/`S5006NxtSceneDataLoader` → market 직접 route | `V, Vis, C, BgV` | `T-PANEL` 통과 | `DB-P`; `Video\큐브배경.vrv` 외부 자산 누락 | `TOR-W` | -| `s5011` / `5011` / — | 국내·NXT 종목 시세, 액면가, 자본금, 시가총액, 순위 | `S5011SceneMutationBuilder` → `S5011SceneDataLoader` → branch 직접 route | `V, Vis, C` | `T-PANEL` 통과 | `DB-P` | `TOR-W` | -| `s5016` / `5016` / — | 미국·중화권·유럽·아시아 지수와 채권·환율·원자재 3열 panel | `S5016SceneMutationBuilder` → `S5016SceneDataLoader` → closed target 직접 route | `V, Vis` | `T-PANEL` 통과 | `DB-P` | `TOR-W` | -| `s50160` / `50160` / — | 원면·국제금·국내금 2열 panel | `S50160SceneMutationBuilder` → `S50160SceneDataLoader` → closed target 직접 route | `V, Vis` | `T-PANEL` 통과 | `DB-P` | `TOR-W` | -| `s5023` / `5023` / — | 코스피·코스닥 일별/월합계 주체별 매매동향 grid | `S5023SceneMutationBuilder` → `S5023SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID` 통과 | `DB-P` | `TOR-W` | -| `s5024` / `5024` / — | 코스피·코스닥 매매동향 막대, 중앙선과 양·음 크기/위치 | `S5024SceneMutationBuilder` → `S5024SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis, Pos, Scale` | `T-GRID` 통과 | `DB-P` | `TOR-W` | -| `s5025` / `5025` / — | 승인된 수동 파일의 개인·외국인·기관 순매도 좌·우 5쌍 | `S5025SceneMutationBuilder` → `S5025SceneDataLoader`/`S5025TrustedManualFileDataSource` → `LegacyGridMarketSceneRequestResolver` | `V` | `T-GRID`, `T-MANUAL` 통과 | `FILE-P`; 외부 CP949 파일 통합 검증 완료 | `TOR-W` | -| `s5026` / `5026` / — | 두 국내 종목 주간 candle과 각 종목 시세/OHLC | `S5026SceneMutationBuilder` → `S5026SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, C, Crop` | `T-COMP` 통과 | `DB-P` | `TOR-W` | -| `s5029` / `5029` / — | 두 종목 candle·수익률 비교와 두 path-shape | `S5029SceneMutationBuilder` → `S5029SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-W` | -| `s5032` / `8018`, `8032`, `5032` 중 선물 조건 / — | 선물을 포함한 좌·우 두 항목 plate | `S5032SceneMutationBuilder` → `S5032SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-W` | -| `s5037` / `5037` / — | 국내 종목 현재가와 매수·매도 거래원별 수량 | `S5037SceneMutationBuilder` → `S5037SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis` | `T-GRID` 통과 | `DB-P` | `TOR-W` | -| `s5074` / `5074` / 5 | Oracle/MariaDB/DataManager 계열의 최대 5행 시세·수익률 목록 | `S5074SceneMutationBuilder` → `S5074SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-H` (page 1/2) | -| `s5076` / `5076` / — | 주요매출 구성, 기준일, 항목 비율과 누적 원형 각도 | `S5076SceneMutationBuilder` → `S5076SceneDataLoader` → subject 직접 route | `V, Vis, Angle` | `T-FOUND` 통과 | `DB-P` | `TOR-W` | -| `s5077` / `5077` / 6 | Oracle/MariaDB/DataManager 계열의 최대 6행 시세·수익률 목록 | `S5077SceneMutationBuilder` → `S5077SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-W` | -| `s5078` / `5078` / — | 미국·국내 섹터지수 값과 양·음 막대 크기 | `S5078SceneMutationBuilder` → `S5078SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, Scale` | `T-CHART` 통과 | `DB-P` | `TOR-W` | -| `s5079` / `5079` / — | 성장성 지표 기간·값과 복수 path | `S5079SceneMutationBuilder` → `S5079SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, Path` | `T-CHART` 통과 | `DB-P` | `TOR-W` | -| `s5080` / `5080` / — | 매출액 분기 시계열과 양·음 막대/중앙선 | `S5080SceneMutationBuilder` → `S5080SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, PosK, Scale` | `T-CHART` 통과 | `DB-P` | `TOR-W` | -| `s5081` / `5081` / — | 영업이익 분기 시계열과 양·음 막대/중앙선 | `S5081SceneMutationBuilder` → `S5081SceneDataLoader` → subject 직접 route | `V, Vis, PosK, Scale` | `T-FOUND` 통과 | `DB-P` | `TOR-W` | -| `s5082` / `5082` / — | 일자·개인·기관·외국인 매매동향 grid와 부호 variant | `S5082SceneMutationBuilder` → `S5082SceneDataLoader` → 직접 route | `V, Vis` | `T-5082` 통과 | `DB-P` | `TOR-W` | -| `s5083` / `5083` / — | 개인·기관·외국인 매매 시계열, baseline과 세 path | `S5083SceneMutationBuilder` → `S5083SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Pos, Path` | `T-CHART` 통과 | `DB-P` | `TOR-W` | -| `s5084` / `5084` / — | 코스피·코스닥 매매 시계열, baseline과 path | `S5084SceneMutationBuilder` → `S5084SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Pos, Path` | `T-CHART` 통과 | `DB-P` | `TOR-W` | -| `s5085` / `5085` / — | 프로그램 매매 grid의 구분별 금액과 부호 색상 | `S5085SceneMutationBuilder` → `S5085SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID`, `T-FOUND` 통과 | `DB-P` | `TOR-W` | -| `s5086` / `5086` / — | 국내·해외 지수/종목/업종 수익률 시계열과 path-shape | `S5086SceneMutationBuilder` → `S5086SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-W` | -| `s50860` / `50860` / — | 국내·해외 지수/종목/업종 line 시계열과 path-shape | `S50860SceneMutationBuilder` → `S50860SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, C, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-W` | -| `s5087` / `5087` / — | 두 국내 종목 candle·수익률 비교와 두 line shape | `S5087SceneMutationBuilder` → `S5087SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-W` | -| `s5088` / `5088` / 12 | Oracle/MariaDB/DataManager 계열의 최대 12행 시세·수익률 목록 | `S5088SceneMutationBuilder` → `S5088SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-W` | -| `s6001` / `6001` / — | 해외지수와 유가·금 단일 plate, 연계 이미지·영상/방향 상태 | `S6001SceneMutationBuilder` → `S6001SceneDataLoader` → closed target 직접 route | `V, A, Vis, C` | `T-PANEL` 통과; asset manifest fixture는 작업 트리 적용 중 | `DB-P`; 해외지수 국가별 영상 13개 `ASSET-X` | `TOR-W`; 자산 제공 전 PREPARE 금지 | -| `s6067` / `6067` / — | 기관 순매수 grid와 부호 색상 | `S6067SceneMutationBuilder` → `S6067SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID`, `T-FOUND` 통과 | `DB-P` | `TOR-W` | -| `s8001` / `8001`, `8002` / — | 코스피·코스닥 업종 square chart와 cube 색상 | `S8001SceneMutationBuilder` → `S8001SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, C` | `T-PARAM` 통과 | `DB-P` | `TOR-W` | -| `s8003` / `8003` / — | 국내 종목 호가·시세와 매수·매도 잔량 막대 | `S8003SceneMutationBuilder` → `S8003SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis, C, Scale` | `T-GRID` 통과 | `DB-P` | `TOR-W` | -| `s8010` / `8035`, `8061`, `8040`, `8046`, `8051`, `8056` / — | 지수·종목·거래정지·해외 candle, 거래량, 예상지수, 이동평균 path | `S8010SceneMutationBuilder` → `S8010SceneDataLoader` → alias/market/mode 직접 route | `V, Vis, C, Pos, Crop, Path` | `T-CANDLE` 통과 | `DB-P` | `TOR-W` | -| `s8018` / `8018`, `8032`, `5032` 중 비선물 조건 / — | 국내·NXT·해외·업종 좌·우 두 항목 plate | `S8018SceneMutationBuilder` → `S8018SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-W` | -| `s8067` / `8067`, `5068`, `5070`, `5072` / — | 글로벌 world-map의 지역별 현재가·등락과 방향/배경 상태 | `S8067SceneMutationBuilder` → `S8067SceneDataLoader` → 직접 route | `V, Vis, C` | `T-FOUND`, `T-PANEL` 통과 | `DB-P` | `TOR-W` | +| `s5001` / `5001`, `N5001` / — | 국내·NXT·해외 지수, 환율, 업종, 종목 단일 시세와 등락 표식 | `S5001SceneMutationBuilder` → `S5001SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-18` | +| `s5006` / `5006` / — | 국내·NXT 종목 현재·시가·고가·저가와 비율, 등락 상태, 큐브 배경 영상 | `S5006SceneMutationBuilder` → `S5006DomesticSceneDataLoader`/`S5006NxtSceneDataLoader` → market 직접 route | `V, Vis, C, BgV` | `T-PANEL` 통과 | `DB-P`; `Video\큐브배경.vrv` 외부 자산 누락 | `TOR-X` | +| `s5011` / `5011` / — | 국내·NXT 종목 시세, 액면가, 자본금, 시가총액, 순위 | `S5011SceneMutationBuilder` → `S5011SceneDataLoader` → branch 직접 route | `V, Vis, C` | `T-PANEL` 통과 | `DB-P` | `TOR-18` | +| `s5016` / `5016` / — | 미국·중화권·유럽·아시아 지수와 채권·환율·원자재 3열 panel | `S5016SceneMutationBuilder` → `S5016SceneDataLoader` → closed target 직접 route | `V, Vis` | `T-PANEL` 통과 | `DB-P` | `TOR-18` | +| `s50160` / `50160` / — | 원면·국제금·국내금 2열 panel | `S50160SceneMutationBuilder` → `S50160SceneDataLoader` → closed target 직접 route | `V, Vis` | `T-PANEL` 통과 | `DB-P` | `TOR-18` | +| `s5023` / `5023` / — | 코스피·코스닥 일별/월합계 주체별 매매동향 grid | `S5023SceneMutationBuilder` → `S5023SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID` 통과 | `DB-P` | `TOR-18` | +| `s5024` / `5024` / — | 코스피·코스닥 매매동향 막대, 중앙선과 양·음 크기/위치 | `S5024SceneMutationBuilder` → `S5024SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis, Pos, Scale` | `T-GRID` 통과 | `DB-P` | `TOR-18` | +| `s5025` / `5025` / — | 승인된 수동 파일의 개인·외국인·기관 순매도 좌·우 5쌍 | `S5025SceneMutationBuilder` → `S5025SceneDataLoader`/`S5025TrustedManualFileDataSource` → `LegacyGridMarketSceneRequestResolver` | `V` | `T-GRID`, `T-MANUAL` 통과 | `FILE-P`; 외부 CP949 파일 통합 검증 완료 | `TOR-18` | +| `s5026` / `5026` / — | 두 국내 종목 주간 candle과 각 종목 시세/OHLC | `S5026SceneMutationBuilder` → `S5026SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, C, Crop` | `T-COMP` 통과 | `DB-P` | `TOR-18` | +| `s5029` / `5029` / — | 두 종목 candle·수익률 비교와 두 path-shape | `S5029SceneMutationBuilder` → `S5029SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-18` | +| `s5032` / `8018`, `8032`, `5032` 중 선물 조건 / — | 선물을 포함한 좌·우 두 항목 plate | `S5032SceneMutationBuilder` → `S5032SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-18` | +| `s5037` / `5037` / — | 국내 종목 현재가와 매수·매도 거래원별 수량 | `S5037SceneMutationBuilder` → `S5037SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis` | `T-GRID` 통과 | `DB-P` | `TOR-18` | +| `s5074` / `5074` / 5 | Oracle/MariaDB/DataManager 계열의 최대 5행 시세·수익률 목록 | `S5074SceneMutationBuilder` → `S5074SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-18` (page 1/2) | +| `s5076` / `5076` / — | 주요매출 구성, 기준일, 항목 비율과 누적 원형 각도 | `S5076SceneMutationBuilder` → `S5076SceneDataLoader` → subject 직접 route | `V, Vis, Angle` | `T-FOUND` 통과 | `DB-P` | `TOR-18` | +| `s5077` / `5077` / 6 | Oracle/MariaDB/DataManager 계열의 최대 6행 시세·수익률 목록 | `S5077SceneMutationBuilder` → `S5077SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-18` (1/20→20/20) | +| `s5078` / `5078` / — | 미국·국내 섹터지수 값과 양·음 막대 크기 | `S5078SceneMutationBuilder` → `S5078SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, Scale` | `T-CHART` 통과 | `DB-P` | `TOR-18` | +| `s5079` / `5079` / — | 성장성 지표 기간·값과 복수 path | `S5079SceneMutationBuilder` → `S5079SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, Path` | `T-CHART` 통과 | `DB-P` | `TOR-18` | +| `s5080` / `5080` / — | 매출액 분기 시계열과 양·음 막대/중앙선 | `S5080SceneMutationBuilder` → `S5080SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, PosK, Scale` | `T-CHART` 통과 | `DB-P` | `TOR-18` | +| `s5081` / `5081` / — | 영업이익 분기 시계열과 양·음 막대/중앙선 | `S5081SceneMutationBuilder` → `S5081SceneDataLoader` → subject 직접 route | `V, Vis, PosK, Scale` | `T-FOUND` 통과 | `DB-P` | `TOR-18` | +| `s5082` / `5082` / — | 일자·개인·기관·외국인 매매동향 grid와 부호 variant | `S5082SceneMutationBuilder` → `S5082SceneDataLoader` → 직접 route | `V, Vis` | `T-5082` 통과 | `DB-P` | `TOR-18` | +| `s5083` / `5083` / — | 개인·기관·외국인 매매 시계열, baseline과 세 path | `S5083SceneMutationBuilder` → `S5083SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Pos, Path` | `T-CHART` 통과 | `DB-P` | `TOR-18` | +| `s5084` / `5084` / — | 코스피·코스닥 매매 시계열, baseline과 path | `S5084SceneMutationBuilder` → `S5084SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Pos, Path` | `T-CHART` 통과 | `DB-P` | `TOR-18` | +| `s5085` / `5085` / — | 프로그램 매매 grid의 구분별 금액과 부호 색상 | `S5085SceneMutationBuilder` → `S5085SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID`, `T-FOUND` 통과 | `DB-P` | `TOR-18` | +| `s5086` / `5086` / — | 국내·해외 지수/종목/업종 수익률 시계열과 path-shape | `S5086SceneMutationBuilder` → `S5086SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-18` | +| `s50860` / `50860` / — | 국내·해외 지수/종목/업종 line 시계열과 path-shape | `S50860SceneMutationBuilder` → `S50860SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, C, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-18` | +| `s5087` / `5087` / — | 두 국내 종목 candle·수익률 비교와 두 line shape | `S5087SceneMutationBuilder` → `S5087SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-18` | +| `s5088` / `5088` / 12 | Oracle/MariaDB/DataManager 계열의 최대 12행 시세·수익률 목록 | `S5088SceneMutationBuilder` → `S5088SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-18` (5077 뒤 1/20) | +| `s6001` / `6001` / — | 해외지수와 유가·금 단일 plate, 연계 이미지·영상/방향 상태 | `S6001SceneMutationBuilder` → `S6001SceneDataLoader` → closed target 직접 route | `V, A, Vis, C` | `T-PANEL` 통과 | `DB-P`; 해외지수 국가별 영상 13개 `ASSET-X` | `TOR-X`; 자산 제공 전 PREPARE 금지 | +| `s6067` / `6067` / — | 기관 순매수 grid와 부호 색상 | `S6067SceneMutationBuilder` → `S6067SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID`, `T-FOUND` 통과 | `DB-P` | `TOR-18` | +| `s8001` / `8001`, `8002` / — | 코스피·코스닥 업종 square chart와 cube 색상 | `S8001SceneMutationBuilder` → `S8001SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, C` | `T-PARAM` 통과 | `DB-P` | `TOR-18` | +| `s8003` / `8003` / — | 국내 종목 호가·시세와 매수·매도 잔량 막대 | `S8003SceneMutationBuilder` → `S8003SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis, C, Scale` | `T-GRID` 통과 | `DB-P` | `TOR-18` | +| `s8010` / `8035`, `8061`, `8040`, `8046`, `8051`, `8056` / — | 지수·종목·거래정지·해외 candle, 거래량, 예상지수, 이동평균 path | `S8010SceneMutationBuilder` → `S8010SceneDataLoader` → alias/market/mode 직접 route | `V, Vis, C, Pos, Crop, Path` | `T-CANDLE` 통과 | `DB-P` | `TOR-18` | +| `s8018` / `8018`, `8032`, `5032` 중 비선물 조건 / — | 국내·NXT·해외·업종 좌·우 두 항목 plate | `S8018SceneMutationBuilder` → `S8018SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-18` | +| `s8067` / `8067`, `5068`, `5070`, `5072` / — | 글로벌 world-map의 지역별 현재가·등락과 방향/배경 상태 | `S8067SceneMutationBuilder` → `S8067SceneDataLoader` → 직접 route | `V, Vis, C` | `T-FOUND`, `T-PANEL` 통과 | `DB-P` | `TOR-18` | | `s8086` / active alias 없음 / — | 유가·금 3열 원천. 원본 파일은 있으나 MainForm dispatch 없음 | `S8086SceneMutationBuilder` → `S8086DiagnosticSceneDataLoader`; 앱 runtime route 없음 | `V, C` | `T-FOUND`, `T-PANEL` 통과 | `DB-DP` | `TOR-NA` | ## 공통 호출 순서 동등성 @@ -163,11 +170,17 @@ TAKE IN은 PREPARE 때의 오래된 DTO를 그대로 재생하지 않는다. 원 ## 구현 판정과 실제 송출 범위 -35개 builder, 45개 active alias, DTO/mutation, 실제 DB read 55-query, 5·6·12행 PageN 경계와 마지막 페이지, Debug/Release x64 suite와 packaged `DryRun`은 화면/계약 구현 기준을 충족했다. 이는 종속 asset이나 35개 전체의 운영 동등성 완료 판정이 아니다. Round H는 설치된 Release x64 MSIX의 WebView workflow로 승인된 `5001`과 `5074`에 대해서만 PREPARE → fresh TAKE IN → 자동 refresh → playlist NEXT → Page NEXT → TAKE OUT을 실제 Tornado2에서 완료했다. +35개 builder, 45개 active alias, DTO/mutation, 실제 DB read, 5·6·12행 PageN 경계와 +마지막 페이지, Debug/Release x64 suite와 개발 MSIX UI/DB 검증은 완료됐다. 실제 PGM은 +34개 runtime route 가운데 32개를 현재 작업 트리로 검증했다. `5001`/`5074` 재검증 뒤 +자산 비의존 30개 route를 순차 검증했으며, `5077` 20페이지 전체와 `5088` 전환, +retired scene unload 및 최종 TAKE OUT/IDLE을 포함한다. 이 완료 판정에는 다음 범위 제한이 있다. -1. 실제 Tornado2/PGM 화면 증거는 `5001`과 `5074` page 1/page 2뿐이다. 다른 builder의 실제 송출은 해당 scene과 자산을 묶은 새 계획·승인이 필요하다. -2. `s5025`는 승인된 trusted 외부 CP949 파일이 필수이며 파일·경로는 Git과 MSIX에 포함하지 않는다. -3. `s5006`의 background video와 `s6001` 해외지수 국가 영상 13개 등 외부 asset이 필요한 scene은 승인된 asset root와 강화된 preflight가 준비돼야 실제 송출할 수 있다. -4. timeout, `OutcomeUnknown`, refresh fault 또는 callback/연결 장애가 발생한 회차에서는 동일 명령을 반복하지 않고 [`PLAYOUT_OPERATIONS.md`](PLAYOUT_OPERATIONS.md)의 복구 절차를 적용한다. +1. 실제 PGM 미검증 route는 외부 제작 자산이 없는 `s5006`과 `s6001`뿐이다. +2. `s5025`의 trusted 외부 CP949 파일·경로는 Git과 고객 MSIX에 포함하지 않는다. +3. `s5006`의 background video와 `s6001` 해외지수 국가 영상 13개는 승인된 asset root와 + preflight가 준비된 뒤에만 실제 송출한다. +4. timeout, `OutcomeUnknown`, refresh fault 또는 callback/연결 장애가 발생한 회차에서는 동일 + 명령을 반복하지 않고 [`PLAYOUT_OPERATIONS.md`](PLAYOUT_OPERATIONS.md)의 복구 절차를 적용한다. diff --git a/scripts/Invoke-LegacyPackageInputSmoke.ps1 b/scripts/Invoke-LegacyPackageInputSmoke.ps1 index dbdae26..adfc42e 100644 --- a/scripts/Invoke-LegacyPackageInputSmoke.ps1 +++ b/scripts/Invoke-LegacyPackageInputSmoke.ps1 @@ -1,5 +1,11 @@ [CmdletBinding()] param( + [ValidateSet('ReadOnly', 'DryRunPlayout', 'FullUiDb')] + [string]$Profile = 'ReadOnly', + + [ValidateSet('All', 'PList', 'GraphE', 'Catalog', 'Screens')] + [string]$FullUiDbScope = 'All', + [ValidateRange(1024, 65535)] [int]$Port = 9339, @@ -12,7 +18,7 @@ param( [ValidateRange(10, 120)] [int]$LaunchTimeoutSeconds = 30, - [ValidateRange(30, 300)] + [ValidateRange(30, 900)] [int]$HarnessTimeoutSeconds = 180 ) @@ -1544,6 +1550,8 @@ function Invoke-NodeHarnessUnderTcpWatch( [string]$ScreenshotPath, [string]$WindowsInputRequestPath, [string]$WindowsInputAcknowledgementPath, + [string]$HarnessProfile, + [string]$HarnessScope, [int]$TimeoutSeconds, [Diagnostics.Process]$Application, [string]$ExpectedApplicationPath) { @@ -1559,6 +1567,10 @@ function Invoke-NodeHarnessUnderTcpWatch( $WindowsInputRequestPath, '--windows-input-ack', $WindowsInputAcknowledgementPath, + '--profile', + $HarnessProfile, + '--scope', + $HarnessScope, '--timeout-ms', [string]($TimeoutSeconds * 1000) ) | ForEach-Object { ConvertTo-QuotedProcessArgument ([string]$_) } @@ -2210,6 +2222,8 @@ function Read-AndAssertHarnessEvidence( [string]$ScreenshotPath, [string]$WindowsInputRequestPath, [string]$WindowsInputAcknowledgementPath, + [string]$HarnessProfile, + [string]$HarnessScope, [int]$ExpectedPort, [int]$ExpectedTimeoutSeconds) { try { @@ -2218,7 +2232,52 @@ function Read-AndAssertHarnessEvidence( $result = [string]$evidence.result $safeResult = $result -ceq 'PASS' -or $result -ceq 'PASS_WITH_WARNING' $warnings = @($evidence.warnings) + $expectedProfile = switch -CaseSensitive ($HarnessProfile) { + 'FullUiDb' { 'full-ui-db'; break } + 'DryRunPlayout' { 'dry-run-playout'; break } + default { 'read-only' } + } + $expectedScope = if ($HarnessProfile -ceq 'FullUiDb') { + $HarnessScope.ToLowerInvariant() + } + else { + 'all' + } + $databaseWriteContractOk = if ($HarnessProfile -ceq 'FullUiDb') { + $evidence.safety.databaseWriteIntentIssued -eq $true -and + @($evidence.safety.approvedDatabaseWriteMessages).Count -ge 3 -and + [string]$evidence.fullUiDb.result -ceq 'PASS' -and + $evidence.fullUiDb.cleanupVerified -eq $true + } + else { + $evidence.safety.databaseWriteIntentIssued -eq $false -and + @($evidence.safety.approvedDatabaseWriteMessages).Count -eq 0 -and + $null -eq $evidence.fullUiDb + } + $playoutIntentContractOk = if ($HarnessProfile -ceq 'DryRunPlayout') { + $evidence.safety.playoutIntentIssued -eq $true -and + [string]$evidence.dryRunPlayout.result -ceq 'PASS' -and + -not [string]::IsNullOrWhiteSpace( + [string]$evidence.dryRunPlayout.selectedEntryId) -and + -not [string]::IsNullOrWhiteSpace( + [string]$evidence.dryRunPlayout.nextEntryId) -and + -not [string]::IsNullOrWhiteSpace( + [string]$evidence.dryRunPlayout.stagedEntryId) -and + [string]$evidence.dryRunPlayout.selectedEntryId -cne + [string]$evidence.dryRunPlayout.nextEntryId -and + [string]$evidence.dryRunPlayout.stagedEntryId -cne + [string]$evidence.dryRunPlayout.selectedEntryId -and + [int]$evidence.dryRunPlayout.playlistCountBeforeSelection -eq + [int]$evidence.dryRunPlayout.playlistCountAfterDoubleClick -and + [int]$evidence.dryRunPlayout.programCutSelection -ge 0 + } + else { + $evidence.safety.playoutIntentIssued -eq $false -and + $null -eq $evidence.dryRunPlayout + } if ([int]$evidence.schemaVersion -ne 1 -or + [string]$evidence.profile -cne $expectedProfile -or + [string]$evidence.scope -cne $expectedScope -or -not $safeResult -or ($result -ceq 'PASS_WITH_WARNING' -and $warnings.Count -eq 0) -or $null -ne $evidence.error -or @@ -2231,8 +2290,8 @@ function Read-AndAssertHarnessEvidence( [string]$evidence.safety.requiredMode -cne 'dryRun' -or [string]$evidence.safety.requiredPhase -cne 'idle' -or $evidence.safety.appCloseRequested -ne $false -or - $evidence.safety.playoutIntentIssued -ne $false -or - $evidence.safety.databaseWriteIntentIssued -ne $false -or + -not $playoutIntentContractOk -or + -not $databaseWriteContractOk -or $evidence.safety.externalCancellationRequested -ne $false -or @($evidence.safety.blockedOutboundMessages).Count -ne 0 -or @($evidence.safety.stateViolations).Count -ne 0 -or @@ -2241,14 +2300,30 @@ function Read-AndAssertHarnessEvidence( Throw-SafeFailure 'The real input evidence does not satisfy the DryRun safety contract.' } - $allowedOutbound = @( - 'ready', 'search-stocks', 'select-stock', 'cut-pointer-down', - 'cut-key-down', 'drop-selected-cuts', 'activate-playlist-row', - 'select-playlist-row', 'reorder-playlist-rows', - 'select-playlist-boundary', 'refresh-named-playlists') - foreach ($messageType in @($evidence.safety.observedOutboundMessageTypes)) { - if ($allowedOutbound -cnotcontains [string]$messageType) { - Throw-SafeFailure 'The evidence contains an outbound message outside the read-only allowlist.' + if ($HarnessProfile -ceq 'ReadOnly' -or + $HarnessProfile -ceq 'DryRunPlayout') { + $allowedOutbound = @( + 'ready', 'search-stocks', 'select-stock', 'cut-pointer-down', + 'cut-key-down', 'drop-selected-cuts', 'activate-playlist-row', + 'select-playlist-row', 'reorder-playlist-rows', + 'select-playlist-boundary', 'refresh-named-playlists') + if ($HarnessProfile -ceq 'DryRunPlayout') { + $allowedOutbound += @('take-in', 'next-playout', 'take-out') + } + foreach ($messageType in @($evidence.safety.observedOutboundMessageTypes)) { + if ($allowedOutbound -cnotcontains [string]$messageType) { + Throw-SafeFailure 'The evidence contains an outbound message outside the selected non-write allowlist.' + } + } + } + else { + $playoutMessages = @( + 'prepare-playout', 'take-in', 'next-playout', 'take-out', + 'choose-background', 'toggle-background') + foreach ($messageType in @($evidence.safety.observedOutboundMessageTypes)) { + if ($playoutMessages -ccontains [string]$messageType) { + Throw-SafeFailure 'The full UI/DB evidence contains a playout message.' + } } } @@ -2338,7 +2413,9 @@ function Read-AndAssertHarnessEvidence( $dragVisualCount = @($domPlaylist | Where-Object { $_.dragging -eq $true -or $_.dragBefore -eq $true -or $_.dragAfter -eq $true }).Count - if (-not (Test-ExactStringSequence $statePlaylistIds $expectedAfterOrder) -or + if (($HarnessProfile -ceq 'ReadOnly' -or + $HarnessProfile -ceq 'DryRunPlayout') -and ( + -not (Test-ExactStringSequence $statePlaylistIds $expectedAfterOrder) -or -not (Test-ExactStringSequence $domPlaylistIds $expectedAfterOrder) -or -not (Test-ExactStringSequence $selectedStateIds @([string]$request.sourceRowId)) -or -not (Test-ExactStringSequence $selectedDomIds @([string]$request.sourceRowId)) -or @@ -2349,9 +2426,18 @@ function Read-AndAssertHarnessEvidence( $activeDomIds ` @([string]$request.expectedActiveRowIdAfterHome)) -or $dragVisualCount -ne 0 -or - @($dom.playlistPointerCapture.rowIds).Count -ne 0) { + @($dom.playlistPointerCapture.rowIds).Count -ne 0)) { Throw-SafeFailure 'The sealed final playlist state does not match the Windows input result.' } + $expectedFullUiDbPlaylistCount = if ($HarnessScope -ceq 'GraphE' -or + $HarnessScope -ceq 'Screens' -or $HarnessScope -ceq 'All') { 0 } else { 3 } + if ($HarnessProfile -ceq 'FullUiDb' -and ( + $statePlaylist.Count -ne $expectedFullUiDbPlaylistCount -or + $domPlaylist.Count -ne $expectedFullUiDbPlaylistCount -or + $dragVisualCount -ne 0 -or + @($dom.playlistPointerCapture.rowIds).Count -ne 0)) { + Throw-SafeFailure 'The sealed full UI/DB playlist readback is not exact.' + } return $evidence } @@ -2426,6 +2512,16 @@ try { $Screenshot ` $WindowsInputRequest ` $WindowsInputAcknowledgement ` + $(if ($Profile -ceq 'FullUiDb') { + 'full-ui-db' + } + elseif ($Profile -ceq 'DryRunPlayout') { + 'dry-run-playout' + } + else { + 'read-only' + }) ` + $(if ($Profile -ceq 'FullUiDb') { $FullUiDbScope.ToLowerInvariant() } else { 'all' }) ` $HarnessTimeoutSeconds ` $script:ApplicationProcess ` $registration.Executable @@ -2447,6 +2543,8 @@ try { $Screenshot ` $WindowsInputRequest ` $WindowsInputAcknowledgement ` + $Profile ` + $FullUiDbScope ` $Port ` $HarnessTimeoutSeconds if ($null -eq $harnessRun.WindowsInput -or @@ -2460,10 +2558,24 @@ try { Assert-ExactApplicationStillRunning $script:ApplicationProcess $registration.Executable Assert-NoTornadoConnection $script:ApplicationProcess.Id - $script:Phase = 'single-instance activation' - $singleInstance = Assert-SingleInstanceActivation ` - $script:ApplicationProcess ` - $registration.Executable + if ($Profile -cne 'FullUiDb') { + $script:Phase = 'single-instance activation' + $singleInstance = Assert-SingleInstanceActivation ` + $script:ApplicationProcess ` + $registration.Executable + } + else { + # The read-only package smoke already owns the single-instance contract. + # Avoid launching an unrelated redirector after an authorized DB-write run. + $singleInstance = [pscustomobject]@{ + ActivationCalls = 0 + ProcessCount = 1 + RedirectorProcessId = $null + RedirectorStartTimeUtc = $null + RedirectorExited = $null + StableOriginalMilliseconds = $null + } + } $script:Phase = 'normal close' Invoke-ExactNormalClose $script:ApplicationProcess $registration.Executable diff --git a/scripts/Test-LegacyPackageInput.mjs b/scripts/Test-LegacyPackageInput.mjs index e2ec9fa..f99d6d9 100644 --- a/scripts/Test-LegacyPackageInput.mjs +++ b/scripts/Test-LegacyPackageInput.mjs @@ -7,8 +7,10 @@ const ctrlModifier = 2; const shiftModifier = 8; const defaultObservationTimeoutMilliseconds = 45_000; const minimumObservationTimeoutMilliseconds = 5_000; -const maximumObservationTimeoutMilliseconds = 300_000; +const maximumObservationTimeoutMilliseconds = 900_000; const acceptedArgumentNames = new Set([ + "profile", + "scope", "port", "output", "screenshot", @@ -16,7 +18,7 @@ const acceptedArgumentNames = new Set([ "windows-input-ack", "timeout-ms" ]); -const forbiddenControlIds = Object.freeze([ +const readOnlyForbiddenControlIds = Object.freeze([ "playout-prepare", "playout-take-in", "playout-next", @@ -27,7 +29,7 @@ const forbiddenControlIds = Object.freeze([ "operator-catalog-save", "operator-catalog-delete" ]); -const allowedOutboundMessageTypes = Object.freeze([ +const readOnlyAllowedOutboundMessageTypes = Object.freeze([ "ready", "search-stocks", "select-stock", @@ -40,6 +42,115 @@ const allowedOutboundMessageTypes = Object.freeze([ "select-playlist-boundary", "refresh-named-playlists" ]); +const fullUiDbAllowedOutboundMessageTypes = Object.freeze([ + ...readOnlyAllowedOutboundMessageTypes, + "select-tab", + "activate-fixed-action", + "activate-fixed-section", + "select-industry", + "double-click-industry", + "swap-industries", + "set-industry-comparison-text", + "activate-industry-action", + "activate-industry-section", + "search-overseas-industries", + "search-overseas-stocks", + "select-overseas-fixed-index", + "select-overseas-industry", + "select-overseas-stock", + "activate-overseas-action", + "search-comparison-domestic", + "search-comparison-world", + "select-comparison-domestic", + "select-comparison-world", + "select-comparison-fixed", + "choose-comparison-domestic", + "choose-comparison-world", + "choose-comparison-fixed", + "swap-comparison-targets", + "clear-comparison-targets", + "add-comparison-pair", + "select-comparison-pair", + "move-comparison-pair", + "delete-selected-comparison-pair", + "delete-all-comparison-pairs", + "activate-comparison-action", + "activate-comparison-section", + "search-themes", + "select-theme", + "activate-theme-result", + "set-theme-sort", + "activate-theme-action", + "begin-uc4-theme-catalog", + "edit-uc4-selected-theme", + "delete-uc4-selected-theme", + "search-experts", + "select-expert", + "activate-expert-action", + "begin-uc6-expert-catalog", + "edit-uc6-selected-expert", + "delete-uc6-selected-expert", + "search-trading-halts", + "select-trading-halt", + "toggle-trading-halt-sort", + "activate-trading-halt-action", + "open-manual-financial", + "close-manual-financial", + "search-manual-financial", + "find-manual-financial", + "toggle-manual-financial-name-sort", + "load-manual-financial", + "activate-manual-financial-result", + "begin-manual-financial-create", + "search-manual-financial-stocks", + "select-manual-financial-stock", + "save-manual-financial", + "delete-manual-financial", + "activate-manual-financial-action", + "open-manual-list", + "close-manual-list", + "refresh-manual-list", + "set-manual-net-sell-cell", + "search-manual-vi", + "select-manual-vi-result", + "add-manual-vi-result", + "move-manual-vi-item", + "delete-manual-vi-item", + "delete-all-manual-vi-items", + "confirm-manual-list", + "open-operator-catalog", + "close-operator-catalog", + "search-operator-catalog", + "select-operator-catalog-result", + "begin-create-theme-catalog", + "begin-create-expert-catalog", + "search-operator-catalog-stocks", + "select-operator-catalog-stock", + "add-operator-catalog-stock", + "activate-operator-catalog-stock", + "move-operator-catalog-draft-row", + "reorder-operator-catalog-draft-row", + "remove-operator-catalog-draft-row", + "select-operator-catalog-draft-row", + "remove-active-operator-catalog-draft-row", + "clear-operator-catalog-draft-rows", + "set-operator-catalog-draft-buy-amount", + "check-operator-catalog-theme-name", + "save-operator-catalog", + "confirm-native-dialog", + "cancel-native-dialog", + "dismiss-dialog", + "select-named-playlist", + "load-named-playlist-by-id", + "create-named-playlist", + "save-current-named-playlist-to", + "delete-selected-named-playlist", + "move-selected-playlist-rows", + "delete-selected-playlist-rows", + "delete-all-playlist-rows", + "set-all-playlist-enabled", + "set-moving-averages" +]); const playoutIntentMessageTypes = Object.freeze([ "prepare-playout", "take-in", @@ -57,6 +168,10 @@ const databaseWriteIntentMessageTypes = Object.freeze([ "delete-all-manual-financial", "import-manual-lists", "confirm-manual-list", + "add-comparison-pair", + "move-comparison-pair", + "delete-selected-comparison-pair", + "delete-all-comparison-pairs", "save-operator-catalog", "delete-operator-catalog", "delete-uc4-selected-theme", @@ -92,7 +207,7 @@ function parseArguments(argv) { "--output --screenshot " + "--windows-input-request " + "--windows-input-ack " + - "[--timeout-ms ]"); + "[--profile read-only|dry-run-playout|full-ui-db] [--timeout-ms ]"); } const values = new Map(); @@ -182,7 +297,20 @@ function parseArguments(argv) { fs.mkdirSync(path.dirname(screenshotPath), { recursive: true }); fs.mkdirSync(path.dirname(windowsInputRequestPath), { recursive: true }); fs.mkdirSync(path.dirname(windowsInputAckPath), { recursive: true }); + const profile = values.get("profile") || "read-only"; + if (!new Set(["read-only", "dry-run-playout", "full-ui-db"]).has(profile)) { + failKnown("--profile must be read-only, dry-run-playout, or full-ui-db."); + } + const scope = values.get("scope") || "all"; + if (!new Set(["all", "plist", "graphe", "catalog", "screens"]).has(scope)) { + failKnown("--scope must be all, plist, graphe, catalog, or screens."); + } + if (profile !== "full-ui-db" && scope !== "all") { + failKnown("A non-DB-write run requires --scope all."); + } return { + profile, + scope, port, outputPath, screenshotPath, @@ -227,10 +355,36 @@ function errorProjection(error) { } const configuration = parseArguments(process.argv.slice(2)); +const fullUiDbProfile = configuration.profile === "full-ui-db"; +const dryRunPlayoutProfile = configuration.profile === "dry-run-playout"; +const forbiddenControlIds = fullUiDbProfile + ? Object.freeze([ + "playout-background-file", + "playout-prepare", + "playout-take-in", + "playout-next", + "playout-take-out" + ]) + : dryRunPlayoutProfile + ? Object.freeze(readOnlyForbiddenControlIds.filter(id => + !new Set(["playout-take-in", "playout-next", "playout-take-out"]).has(id))) + : readOnlyForbiddenControlIds; +const allowedOutboundMessageTypes = fullUiDbProfile + ? fullUiDbAllowedOutboundMessageTypes + : dryRunPlayoutProfile + ? Object.freeze([ + ...readOnlyAllowedOutboundMessageTypes, + "take-in", + "next-playout", + "take-out" + ]) + : readOnlyAllowedOutboundMessageTypes; const hardDeadlineEpochMilliseconds = Date.now() + configuration.timeoutMilliseconds; const startedAt = new Date().toISOString(); const evidence = { schemaVersion: 1, + profile: configuration.profile, + scope: configuration.scope, result: "RUNNING", startedAt, completedAt: null, @@ -249,6 +403,7 @@ const evidence = { appCloseRequested: false, playoutIntentIssued: false, databaseWriteIntentIssued: false, + approvedDatabaseWriteMessages: [], observedOutboundMessageTypes: [], blockedOutboundMessages: [], stateViolations: [], @@ -256,8 +411,37 @@ const evidence = { }, fixture: { query: "삼성", - stockDisplayName: "삼성출판사" + stockDisplayName: "삼성출판사", + databaseStockDisplayName: "1Q 삼성전자선물단일종목레버리지", + namedPlaylistTitle: fullUiDbProfile + ? `CDX_P_${Date.now()}_${randomBytes(3).toString("hex")}` + : null, + krxThemeTitle: fullUiDbProfile + ? `CDX_K_${Date.now()}_${randomBytes(3).toString("hex")}` + : null, + nxtThemeTitle: fullUiDbProfile + ? `CDX_N_${Date.now()}_${randomBytes(3).toString("hex")}` + : null, + expertTitle: fullUiDbProfile + ? `CDX_E_${Date.now()}_${randomBytes(3).toString("hex")}` + : null }, + fullUiDb: fullUiDbProfile ? { + result: "PENDING", + screenChecks: [], + databaseChecks: [], + screenInventory: [], + cleanupVerified: false + } : null, + dryRunPlayout: dryRunPlayoutProfile ? { + result: "PENDING", + stagedEntryId: null, + selectedEntryId: null, + nextEntryId: null, + programCutSelection: null, + playlistCountBeforeSelection: null, + playlistCountAfterDoubleClick: null + } : null, checkpoints: [], inputs: [], warnings: [], @@ -285,6 +469,8 @@ let pointerReleaseModifiers = 0; let probeInstalled = false; let cleanupMode = false; let cleanupDeadlineEpochMilliseconds = 0; +let expectedJavaScriptDialog = null; +const unexpectedJavaScriptDialogs = []; process.stdin.setEncoding("utf8"); process.stdin.on("data", chunk => { @@ -382,6 +568,24 @@ async function connect(endpoint) { socket.addEventListener("message", event => { const message = JSON.parse(String(event.data)); + if (message.method === "Page.javascriptDialogOpening") { + const dialog = message.params || {}; + const expected = expectedJavaScriptDialog; + if (!expected || dialog.type !== expected.type || + (expected.message && dialog.message !== expected.message)) { + unexpectedJavaScriptDialogs.push({ + type: dialog.type || null, + message: dialog.message || null, + at: new Date().toISOString() + }); + return; + } + expectedJavaScriptDialog = null; + void rpc("Page.handleJavaScriptDialog", { + accept: expected.accept === true + }).then(expected.resolve, expected.reject); + return; + } if (!Object.hasOwn(message, "id")) return; const pending = pendingRequests.get(message.id); if (!pending) return; @@ -482,6 +686,8 @@ function probeSnapshotExpression() { .filter(Boolean); const active = document.activeElement; const rowForActive = active?.closest?.(".playlist-data-row") || null; + const cutDragFeedback = document.getElementById("cut-drag-feedback"); + const cutDragFeedbackBounds = cutDragFeedback?.getBoundingClientRect() || null; const visibleModal = Array.from(document.querySelectorAll( "[role='dialog'], [role='alertdialog']")) .filter(element => !element.hidden && !element.closest("[hidden]")).at(-1) || null; @@ -499,6 +705,8 @@ function probeSnapshotExpression() { postMessageWrapped: probe?.postMessageWrapper != null && window.chrome?.webview?.postMessage === probe.postMessageWrapper, outboundMessages: (probe?.outboundMessages || []).map(message => ({ ...message })), + approvedDatabaseWriteMessages: + (probe?.approvedDatabaseWriteMessages || []).map(message => ({ ...message })), blockedOutboundMessages: (probe?.blockedOutboundMessages || []) .map(message => ({ ...message })), stateViolations: (probe?.stateViolations || []).map(violation => ({ @@ -539,6 +747,22 @@ function probeSnapshotExpression() { statusMessage: state.statusMessage, statusKind: state.statusKind, commandResult: state.commandResult, + tabs: (state.tabs || []).map(tab => ({ ...tab })), + movingAverages: state.movingAverages ? { ...state.movingAverages } : null, + fixedCatalog: state.fixedCatalog ? structuredClone(state.fixedCatalog) : null, + industry: state.industry ? structuredClone(state.industry) : null, + overseas: state.overseas ? structuredClone(state.overseas) : null, + comparison: state.comparison ? structuredClone(state.comparison) : null, + theme: state.theme ? structuredClone(state.theme) : null, + expert: state.expert ? structuredClone(state.expert) : null, + tradingHalt: state.tradingHalt ? structuredClone(state.tradingHalt) : null, + manualFinancial: state.manualFinancial + ? structuredClone(state.manualFinancial) : null, + manualLists: state.manualLists ? structuredClone(state.manualLists) : null, + operatorCatalog: state.operatorCatalog + ? structuredClone(state.operatorCatalog) : null, + fixedSectionBatch: state.fixedSectionBatch + ? structuredClone(state.fixedSectionBatch) : null, namedPlaylist: state.namedPlaylist ? { revision: state.namedPlaylist.revision, definitions: (state.namedPlaylist.definitions || []).map(definition => ({ @@ -574,6 +798,10 @@ function probeSnapshotExpression() { preparedCode: state.playout.preparedCode, onAirCode: state.playout.onAirCode, currentEntryId: state.playout.currentEntryId, + currentCueIndexZeroBased: state.playout.currentCueIndexZeroBased, + pageIndexZeroBased: state.playout.pageIndexZeroBased, + pageCount: state.playout.pageCount, + nextKind: state.playout.nextKind, isBusy: state.playout.isBusy, refreshActive: state.playout.refreshActive, message: state.playout.message @@ -629,6 +857,14 @@ function probeSnapshotExpression() { })), cutDropHighlighted: document.getElementById("playlist-drop-zone") ?.classList.contains("cut-drag-over") === true, + cutDragFeedback: cutDragFeedback ? { + hidden: cutDragFeedback.hidden === true, + dropAllowed: cutDragFeedback.dataset.dropAllowed || null, + left: cutDragFeedbackBounds?.left ?? null, + top: cutDragFeedbackBounds?.top ?? null, + width: cutDragFeedbackBounds?.width ?? null, + height: cutDragFeedbackBounds?.height ?? null + } : null, cutPointerCapture: { pointerId: activePointerId, physicalIndices: capturedCutPhysicalIndices @@ -639,6 +875,12 @@ function probeSnapshotExpression() { }, namedModalHidden: document.getElementById("named-playlist-modal")?.hidden === true, namedConfirmationHidden: document.getElementById("named-playlist-confirmation")?.hidden === true, + manualFinancialModalHidden: + document.getElementById("manual-financial-modal")?.hidden === true, + manualListModalHidden: + document.getElementById("manual-list-modal")?.hidden === true, + operatorCatalogModalHidden: + document.getElementById("operator-catalog-modal")?.hidden === true, namedTitle: (document.getElementById("named-playlist-title")?.textContent || "").trim(), namedConfirmationMessage: (document.getElementById( "named-playlist-confirmation-message")?.textContent || "").trim(), @@ -666,7 +908,12 @@ async function readSnapshot() { return await evaluate(probeSnapshotExpression()); } -function assertSafety(snapshot, label, allowUiBusy = true) { +function assertSafety( + snapshot, + label, + allowUiBusy = true, + allowLegacyDialog = false, + allowActiveDryRun = false) { if (!snapshot?.state?.playout) { failKnown(`Native playout state is missing at ${label}.`); } @@ -675,21 +922,30 @@ function assertSafety(snapshot, label, allowUiBusy = true) { if (snapshot.safety?.postMessageWrapped !== true) { failUnknown(`The outbound WebView safety gate is not installed at ${label}.`); } + if (unexpectedJavaScriptDialogs.length > 0 || expectedJavaScriptDialog) { + failUnknown(`An unexpected or unresolved JavaScript dialog exists at ${label}.`); + } const outboundMessages = snapshot.safety.outboundMessages || []; + const approvedDatabaseWriteMessages = + snapshot.safety.approvedDatabaseWriteMessages || []; const blockedOutboundMessages = snapshot.safety.blockedOutboundMessages || []; const stateViolations = snapshot.safety.stateViolations || []; evidence.safety.observedOutboundMessageTypes = outboundMessages .map(message => message.type); + evidence.safety.approvedDatabaseWriteMessages = approvedDatabaseWriteMessages + .map(message => ({ ...message })); evidence.safety.blockedOutboundMessages = blockedOutboundMessages .map(message => ({ ...message })); evidence.safety.stateViolations = stateViolations.map(violation => ({ ...violation, reasons: [...violation.reasons] })); - evidence.safety.playoutIntentIssued = blockedOutboundMessages.some(message => - message.category === "playout"); - evidence.safety.databaseWriteIntentIssued = blockedOutboundMessages.some(message => - message.category === "database-write"); + evidence.safety.playoutIntentIssued = outboundMessages.some(message => + playoutIntentMessageTypes.includes(message.type)) || + blockedOutboundMessages.some(message => message.category === "playout"); + evidence.safety.databaseWriteIntentIssued = + approvedDatabaseWriteMessages.length > 0 || + blockedOutboundMessages.some(message => message.category === "database-write"); if (blockedOutboundMessages.length > 0) { failKnown(`The outbound WebView safety gate blocked an unapproved message at ${label}.`); } @@ -699,14 +955,16 @@ function assertSafety(snapshot, label, allowUiBusy = true) { if (playout.mode !== "dryRun") { failKnown(`Expected DryRun at ${label}, received ${String(playout.mode)}.`); } - if (playout.phase !== "idle" || playout.preparedCode != null || - playout.onAirCode != null) { + if (!allowActiveDryRun && + (playout.phase !== "idle" || playout.preparedCode != null || + playout.onAirCode != null)) { failKnown(`Playout left IDLE at ${label}.`); } if (playout.outcomeUnknown === true || - playout.isPlayCompletionPending === true || - playout.isTakeOutCompletionPending === true || - playout.isBusy === true || playout.refreshActive === true) { + (!allowActiveDryRun && playout.isPlayCompletionPending === true) || + (!allowActiveDryRun && playout.isTakeOutCompletionPending === true) || + (!allowActiveDryRun && playout.isBusy === true) || + (!allowActiveDryRun && playout.refreshActive === true)) { failUnknown(`Playout became busy, pending, refreshing, or unknown at ${label}.`); } if (!allowUiBusy && state.isBusy === true) { @@ -718,21 +976,26 @@ function assertSafety(snapshot, label, allowUiBusy = true) { if (snapshot.dom.connectionText !== "DRY RUN") { failKnown(`The visible connection badge is not DRY RUN at ${label}.`); } - if (!snapshot.dom.legacyDialogHidden) { + if (!snapshot.dom.legacyDialogHidden && !allowLegacyDialog) { failKnown(`A legacy alert is visible at ${label}: ${snapshot.dom.legacyDialogText}`); } } async function waitFor(label, predicate, options = {}) { const timeoutMilliseconds = options.timeoutMilliseconds || - configuration.timeoutMilliseconds; + Math.min(configuration.timeoutMilliseconds, 60_000); const deadline = Date.now() + timeoutMilliseconds; let lastSnapshot = null; while (Date.now() < deadline) { lastSnapshot = await readSnapshot(); if (lastSnapshot?.state?.playout) { - assertSafety(lastSnapshot, label, options.allowUiBusy !== false); + assertSafety( + lastSnapshot, + label, + options.allowUiBusy !== false, + options.allowLegacyDialog === true, + options.allowActiveDryRun === true); } if (predicate(lastSnapshot)) return lastSnapshot; await sleep(100); @@ -753,6 +1016,7 @@ async function elementGeometry(expression, label, verticalFraction = 0.5) { const geometry = await evaluate(`(() => { const element = ${expression}; if (!element) return null; + element.scrollIntoView({ block: "nearest", inline: "nearest" }); const rectangle = element.getBoundingClientRect(); return { x: rectangle.left + rectangle.width / 2, @@ -883,7 +1147,26 @@ async function releaseMouse(point, modifiers = 0) { pointerReleaseModifiers = 0; } -async function pointerClick(expression, label, modifiers = 0) { +async function waitForPointerReady( + label, + allowLegacyDialog = false, + allowActiveDryRun = false) { + await waitFor(`${label} pointer readiness`, snapshot => + snapshot.state?.isBusy === false && snapshot.dom.bodyBusy === "false", + { + allowUiBusy: true, + allowLegacyDialog, + allowActiveDryRun + }); +} + +async function pointerClick(expression, label, modifiers = 0, options = {}) { + if (options.skipPointerReady !== true) { + await waitForPointerReady( + label, + options.allowLegacyDialog === true, + options.allowActiveDryRun === true); + } const forbiddenId = await evaluate(`(() => { const element = ${expression}; return element?.closest?.("button")?.id || element?.id || null; @@ -905,6 +1188,7 @@ const keyDefinitions = Object.freeze({ ArrowUp: { key: "ArrowUp", code: "ArrowUp", virtualKey: 38 }, Backspace: { key: "Backspace", code: "Backspace", virtualKey: 8 }, End: { key: "End", code: "End", virtualKey: 35 }, + Enter: { key: "Enter", code: "Enter", virtualKey: 13 }, Escape: { key: "Escape", code: "Escape", virtualKey: 27 }, Home: { key: "Home", code: "Home", virtualKey: 36 }, Space: { key: " ", code: "Space", virtualKey: 32 }, @@ -955,6 +1239,138 @@ async function setSearchText(text) { await rpc("Input.insertText", { text }); } +async function replaceText(expression, text, label) { + await pointerClick(expression, `focus ${label}`); + const focused = await evaluate(`document.activeElement === (${expression})`); + if (focused !== true) failKnown(`${label} did not receive pointer focus.`); + await pressKey("SelectAll", `select existing ${label}`, ctrlModifier); + await pressKey("Backspace", `clear existing ${label}`); + evidence.inputs.push({ type: "text", label, text }); + await rpc("Input.insertText", { text }); + const actual = await evaluate(`(${expression})?.value ?? null`); + if (actual !== text) { + failKnown(`${label} did not retain the exact typed value.`); + } +} + +async function selectOptionValue(expression, value, label) { + await pointerClick(expression, `focus ${label}`); + const selection = await evaluate(`(() => { + const select = ${expression}; + if (!(select instanceof HTMLSelectElement)) return null; + const targetIndex = [...select.options].findIndex(option => option.value === ${JSON.stringify(value)}); + return { targetIndex, currentValue: select.value }; + })()`); + if (!selection || selection.targetIndex < 0) { + failKnown(`${label} does not contain the requested option.`); + } + await pressKey("Home", `${label} first option`); + for (let index = 0; index < selection.targetIndex; index++) { + await pressKey("ArrowDown", `${label} next option ${index + 1}`); + } + await pressKey("Enter", `${label} commit option`); + const actual = await evaluate(`(${expression})?.value ?? null`); + if (actual !== value) failKnown(`${label} did not select the requested value.`); +} + +async function respondToNativeDialog(label, confirmation, accept) { + const opened = await waitFor(`${label} dialog`, snapshot => + snapshot.state?.dialog?.isConfirmation === confirmation && + snapshot.dom.legacyDialogHidden === false, + { + timeoutMilliseconds: 30_000, + allowUiBusy: true, + allowLegacyDialog: true + }); + if (confirmation && accept !== true && accept !== false) { + failKnown(`${label} confirmation response is not explicit.`); + } + await pointerClick( + confirmation && accept === false + ? 'document.getElementById("legacy-dialog-no")' + : 'document.getElementById("legacy-dialog-ok")', + `${label} ${confirmation ? (accept ? "yes" : "no") : "ok"}`, + 0, + { allowLegacyDialog: true }); + await waitFor(`${label} dialog close`, snapshot => + snapshot.state?.dialog == null && snapshot.dom.legacyDialogHidden === true, + { + timeoutMilliseconds: 60_000, + allowUiBusy: true, + allowLegacyDialog: true + }); + return opened; +} + +async function pointerDoubleClick(expression, label, modifiers = 0) { + await waitForPointerReady(label); + const point = await elementGeometry(expression, label); + evidence.inputs.push({ type: "pointer-double-click", label, modifiers, point }); + await moveMouse(point, modifiers, 0); + for (const clickCount of [1, 2]) { + await rpc("Input.dispatchMouseEvent", { + type: "mousePressed", + x: point.x, + y: point.y, + button: "left", + buttons: 1, + clickCount, + modifiers, + pointerType: "mouse" + }); + await rpc("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: point.x, + y: point.y, + button: "left", + buttons: 0, + clickCount, + modifiers, + pointerType: "mouse" + }); + if (clickCount === 1) await sleep(50); + } +} + +async function pointerClickWithJavaScriptDialog( + expression, + label, + type, + message, + accept) { + if (expectedJavaScriptDialog) { + failUnknown(`Another JavaScript dialog was pending before ${label}.`); + } + await waitForPointerReady(label); + let resolveDialog; + let rejectDialog; + const handled = new Promise((resolve, reject) => { + resolveDialog = resolve; + rejectDialog = reject; + }); + expectedJavaScriptDialog = { + type, + message, + accept: accept === true, + resolve: resolveDialog, + reject: rejectDialog + }; + try { + await pointerClick(expression, label, 0, { skipPointerReady: true }); + await Promise.race([ + handled, + new Promise((_, reject) => setTimeout( + () => reject(new HarnessFailure( + "OUTCOME_UNKNOWN", + `Timed out waiting for the JavaScript dialog at ${label}.`)), + 10_000)) + ]); + } catch (error) { + if (expectedJavaScriptDialog) expectedJavaScriptDialog = null; + throw error; + } +} + function selectedPhysicalIndices(snapshot) { return snapshot.state.cutRows .filter(row => row.isSelected) @@ -1048,7 +1464,8 @@ async function dragCutsToPlaylist(physicalIndex, modifiers, expectedAppendCount, const sourceBeforeThreshold = beforeThreshold.dom.cuts.find(row => row.physicalIndex === physicalIndex); if (!sourceBeforeThreshold || sourceBeforeThreshold.dragging || - beforeThreshold.dom.cutDropHighlighted) { + beforeThreshold.dom.cutDropHighlighted || + beforeThreshold.dom.cutDragFeedback?.hidden !== true) { failKnown(`${label} started inside the original drag rectangle.`); } @@ -1063,6 +1480,7 @@ async function dragCutsToPlaylist(physicalIndex, modifiers, expectedAppendCount, point: threshold, dragging: thresholdSource?.dragging === true, captured: capturedAtThreshold, + feedback: thresholdSnapshot.dom.cutDragFeedback, pointerEvents: thresholdSnapshot.pointerEvents.slice(-12) }; if (!thresholdSource?.dragging) { @@ -1079,6 +1497,15 @@ async function dragCutsToPlaylist(physicalIndex, modifiers, expectedAppendCount, if (thresholdSnapshot.dom.cutDropHighlighted) { failKnown(`${label} highlighted the remote drop zone while still on the source row.`); } + const thresholdFeedback = thresholdSnapshot.dom.cutDragFeedback; + if (!thresholdFeedback || thresholdFeedback.hidden || + thresholdFeedback.dropAllowed !== "false" || + !Number.isFinite(thresholdFeedback.left) || + !Number.isFinite(thresholdFeedback.top) || + Math.abs(thresholdFeedback.left - threshold.x) > 280 || + Math.abs(thresholdFeedback.top - threshold.y) > 80) { + failKnown(`${label} did not show blocked drag feedback beside the real pointer.`); + } let overTarget = null; let acceptedTarget = null; @@ -1092,13 +1519,15 @@ async function dragCutsToPlaylist(physicalIndex, modifiers, expectedAppendCount, point: candidate, dragging: sourceAtCandidate?.dragging === true, highlighted: sample.dom.cutDropHighlighted, + feedback: sample.dom.cutDragFeedback, captured: sample.dom.cutPointerCapture?.physicalIndices ?.includes(physicalIndex) === true, pointerEvents: sample.pointerEvents.slice(-12) }; dragInput.targetSamples.push(sampleEvidence); if (sampleEvidence.dragging && sampleEvidence.highlighted && - sampleEvidence.captured) { + sampleEvidence.captured && sampleEvidence.feedback?.hidden === false && + sampleEvidence.feedback?.dropAllowed === "true") { overTarget = sample; acceptedTarget = candidate; break; @@ -1124,7 +1553,8 @@ async function dragCutsToPlaylist(physicalIndex, modifiers, expectedAppendCount, snapshot.state.playlist.length === beforeCount + expectedAppendCount, { allowUiBusy: false }); if (!completed.dom.cuts.every(row => !row.dragging) || - completed.dom.cutDropHighlighted) { + completed.dom.cutDropHighlighted || + completed.dom.cutDragFeedback?.hidden !== true) { failKnown(`${label} left drag visuals active after release.`); } return completed; @@ -1422,9 +1852,11 @@ async function installProbe() { originalPostMessage: null, postMessageWrapper: null, outboundMessages: [], + approvedDatabaseWriteMessages: [], blockedOutboundMessages: [], stateViolations: [] }; + const allowActiveDryRun = ${JSON.stringify(dryRunPlayoutProfile)}; probe.listener = event => { if (event.data?.type !== "state" || !event.data.payload) return; const payload = event.data.payload; @@ -1437,15 +1869,19 @@ async function installProbe() { reasons.push("missing-playout"); } else { if (playout.mode !== "dryRun") reasons.push("mode"); - if (playout.phase !== "idle") reasons.push("phase"); - if (playout.preparedCode != null) reasons.push("prepared-code"); - if (playout.onAirCode != null) reasons.push("on-air-code"); + if (!allowActiveDryRun && playout.phase !== "idle") reasons.push("phase"); + if (!allowActiveDryRun && playout.preparedCode != null) reasons.push("prepared-code"); + if (!allowActiveDryRun && playout.onAirCode != null) reasons.push("on-air-code"); if (playout.isConnected === true) reasons.push("connected"); if (playout.outcomeUnknown === true) reasons.push("outcome-unknown"); - if (playout.isPlayCompletionPending === true) reasons.push("play-pending"); - if (playout.isTakeOutCompletionPending === true) reasons.push("take-out-pending"); - if (playout.isBusy === true) reasons.push("playout-busy"); - if (playout.refreshActive === true) reasons.push("refresh-active"); + if (!allowActiveDryRun && playout.isPlayCompletionPending === true) { + reasons.push("play-pending"); + } + if (!allowActiveDryRun && playout.isTakeOutCompletionPending === true) { + reasons.push("take-out-pending"); + } + if (!allowActiveDryRun && playout.isBusy === true) reasons.push("playout-busy"); + if (!allowActiveDryRun && playout.refreshActive === true) reasons.push("refresh-active"); } if (reasons.length > 0) { probe.stateViolations.push({ @@ -1492,7 +1928,10 @@ async function installProbe() { const type = typeof message?.type === "string" ? message.type : null; const record = { type, - at: new Date().toISOString() + at: new Date().toISOString(), + allowAppend: type === "cut-pointer-down" + ? message?.payload?.allowAppend ?? null + : null }; probe.outboundMessages.push(record); if (probe.outboundMessages.length > 250) probe.outboundMessages.shift(); @@ -1508,6 +1947,9 @@ async function installProbe() { probe.blockedOutboundMessages.push(blocked); return; } + if (databaseWriteTypes.has(type)) { + probe.approvedDatabaseWriteMessages.push(record); + } return probe.originalPostMessage.call(window.chrome.webview, message); }; try { @@ -1705,7 +2147,7 @@ async function executeSafeSequence() { }); current = await dragCutsToPlaylist( - 0, + 1, 0, 1, "single-cut captured-pointer drag"); @@ -1717,7 +2159,7 @@ async function executeSafeSequence() { [ "1열판기본|현재가", "1열판기본|시간외단일가", - "1열판기본|예상체결가" + "1열판기본|현재가" ], "three-cut playlist mapping"); assertExactArray( @@ -1950,6 +2392,1741 @@ async function executeSafeSequence() { evidence.finalSnapshot = current; } +async function executeDryRunPlayoutSequence() { + if (!dryRunPlayoutProfile) return; + + let current = await readSnapshot(); + assertSafety(current, "DryRun playout sequence preflight", false); + if (current.state.playlist.length < 3 || + current.state.playlist.some(row => row.isEnabled !== true)) { + failKnown("DryRun NEXT validation requires three enabled in-memory rows."); + } + + const originalSelectedIds = selectedPlaylistIds(current); + const originalActiveId = activePlaylistId(current); + if (originalSelectedIds.length !== 1 || !originalActiveId) { + failKnown("DryRun playout preflight lacks the exact legacy selection split."); + } + + // The preceding interaction sequence deliberately leaves two distinct current- + // quote rows adjacent. After-hours/expected quote tables are session-dependent; + // the original application also rejects those rows when no exact quote exists. + // Use the two current rows so this check isolates NEXT row semantics from market + // session data availability. + const stagedEntryId = current.state.playlist[2].rowId; + const selectedEntryId = current.state.playlist[0].rowId; + const nextEntryId = current.state.playlist[1].rowId; + const playlistCount = current.state.playlist.length; + evidence.dryRunPlayout.stagedEntryId = stagedEntryId; + evidence.dryRunPlayout.selectedEntryId = selectedEntryId; + evidence.dryRunPlayout.nextEntryId = nextEntryId; + evidence.dryRunPlayout.playlistCountBeforeSelection = playlistCount; + + // Move the F8 candidate away from the target first. If the following rapid row + // click were dropped, TAKE IN would remain on this distinct staged row and the + // selected-row assertion below would fail deterministically. + await pointerClickPlaylistRow( + stagedEntryId, + "stage alternate playlist row before rapid DryRun TAKE IN"); + current = await waitFor("alternate DryRun row staging", snapshot => + activePlaylistId(snapshot) === stagedEntryId && + JSON.stringify(selectedPlaylistIds(snapshot)) === + JSON.stringify([stagedEntryId]), + { allowUiBusy: false }); + + // Keep the real user timing: release the row click and immediately press TAKE IN, + // without waiting for the row-selection state receipt. The native ordered intent + // gate must preserve this input order instead of silently dropping F8/TAKE IN. + await pointerClickPlaylistRow( + selectedEntryId, + "rapid select playlist row before DryRun TAKE IN"); + await pointerClick( + 'document.getElementById("playout-take-in")', + "rapid DryRun TAKE IN after row selection", + 0, + { skipPointerReady: true }); + current = await waitFor("rapid selected-row DryRun TAKE IN", snapshot => + snapshot.state.playout.phase === "program" && + snapshot.state.playout.currentEntryId === selectedEntryId && + snapshot.state.playout.isBusy !== true && + snapshot.state.isBusy !== true, + { allowUiBusy: true, allowActiveDryRun: true }); + if (current.state.playout.nextKind !== "playlistNext") { + failKnown("DryRun TAKE IN did not expose playlist NEXT from the selected row."); + } + + const selectableCut = current.state.cutRows.find(row => + row.isSeparator !== true && row.isSelected !== true); + if (!selectableCut) { + failKnown("No alternate cut exists for PROGRAM selection validation."); + } + const cutExpression = + `document.querySelector('#cut-list .cut-row[data-physical-index="${selectableCut.physicalIndex}"]')`; + await pointerClick( + cutExpression, + "select alternate left cut during PROGRAM", + 0, + { allowActiveDryRun: true }); + current = await waitFor("PROGRAM left cut selection", snapshot => + snapshot.state.playout.phase === "program" && + snapshot.state.playlist.length === playlistCount && + snapshot.state.cutRows.some(row => + row.physicalIndex === selectableCut.physicalIndex && + row.isSelected === true && row.isFocused === true), + { allowUiBusy: false, allowActiveDryRun: true }); + evidence.dryRunPlayout.programCutSelection = selectableCut.physicalIndex; + + await pointerClick( + cutExpression, + "PROGRAM protected double-click first press", + 0, + { allowActiveDryRun: true }); + await pointerClick( + cutExpression, + "PROGRAM protected double-click second press", + 0, + { allowActiveDryRun: true }); + await sleep(150); + current = await readSnapshot(); + assertSafety(current, "PROGRAM protected double-click", false, false, true); + const recentProgramCutMessages = current.safety.outboundMessages + .filter(message => message.type === "cut-pointer-down") + .slice(-3); + if (current.state.playlist.length !== playlistCount || + recentProgramCutMessages.length !== 3 || + recentProgramCutMessages.some(message => message.allowAppend !== false)) { + failKnown("PROGRAM cut selection changed the playlist or retained append authority."); + } + evidence.dryRunPlayout.playlistCountAfterDoubleClick = + current.state.playlist.length; + + await pointerClick( + 'document.getElementById("playout-next")', + "DryRun NEXT from selected TAKE IN row", + 0, + { allowActiveDryRun: true }); + const revisionBeforeNext = current.state.revision; + current = await waitFor("DryRun NEXT selected-row successor", snapshot => { + if (snapshot.state.revision > revisionBeforeNext && + snapshot.state.playout.isBusy !== true && + snapshot.state.isBusy !== true && + snapshot.state.statusKind === "error") { + failKnown( + `DryRun NEXT was safely rejected before playout: ${snapshot.state.statusMessage}`); + } + return snapshot.state.playout.phase === "program" && + snapshot.state.playout.currentEntryId === nextEntryId && + snapshot.state.playout.isBusy !== true && + snapshot.state.isBusy !== true; + }, + { allowUiBusy: true, allowActiveDryRun: true }); + + await pointerClick( + 'document.getElementById("playout-take-out")', + "DryRun TAKE OUT after NEXT", + 0, + { allowActiveDryRun: true }); + current = await waitFor("DryRun TAKE OUT IDLE", snapshot => + snapshot.state.playout.phase === "idle" && + snapshot.state.playout.currentEntryId == null && + snapshot.state.playout.onAirCode == null && + snapshot.state.playout.isBusy !== true && + snapshot.state.isBusy !== true, + { allowUiBusy: true, allowActiveDryRun: true }); + + await pointerClickPlaylistRow( + originalSelectedIds[0], + "restore deletion selection after DryRun playout"); + await pointerClick(`(() => { + const row = Array.from(document.querySelectorAll( + "#playlist-rows .playlist-data-row")) + .find(candidate => candidate.dataset.rowId === ${JSON.stringify(originalActiveId)}); + return row?.querySelector(".playlist-row-header") || null; + })()`, "restore active playlist row after DryRun playout"); + current = await waitFor("restore playlist state after DryRun playout", snapshot => + snapshot.state.playout.phase === "idle" && + JSON.stringify(selectedPlaylistIds(snapshot)) === + JSON.stringify(originalSelectedIds) && + activePlaylistId(snapshot) === originalActiveId, + { allowUiBusy: false }); + + evidence.dryRunPlayout.result = "PASS"; + await checkpoint("dry-run-playout-selection-next", { + stagedEntryId, + selectedEntryId, + nextEntryId, + programCutPhysicalIndex: selectableCut.physicalIndex, + playlistCount + }); +} + +function recordFullUiDbCheck(collection, name, details = null) { + if (!evidence.fullUiDb) return; + evidence.fullUiDb[collection].push({ + name, + at: new Date().toISOString(), + details + }); +} + +function namedDefinitionExpression(title) { + return `Array.from(document.querySelectorAll( + "#named-playlist-definitions button[data-named-playlist-definition-id]")) + .find(button => button.textContent === ${JSON.stringify(title)})`; +} + +async function executeNamedPlaylistCrud() { + const title = evidence.fixture.namedPlaylistTitle; + let current = await readSnapshot(); + if (current.state.namedPlaylist.definitions.some(row => row.title === title)) { + failKnown("The generated PList fixture already exists before mutation."); + } + + await pointerClick('document.getElementById("db-save-button")', + "PList open save dialog"); + current = await waitFor("PList fresh definition read", snapshot => + snapshot.dom.namedModalHidden === false && snapshot.state.isBusy === false, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + if (current.state.namedPlaylist.definitions.some(row => row.title === title)) { + failKnown("The generated PList fixture appeared during the preflight read."); + } + await replaceText('document.getElementById("named-playlist-new-title")', + title, "PList unique title"); + await pointerClick('document.getElementById("named-playlist-create-button")', + "PList create definition"); + current = await waitFor("PList definition create commit", snapshot => + snapshot.state.isBusy === false && + snapshot.state.namedPlaylist.definitions.some(row => row.title === title) && + snapshot.state.namedPlaylist.lastMutationOutcome && + snapshot.state.namedPlaylist.lastMutationOutcome.startsWith("committed"), + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const created = current.state.namedPlaylist.definitions.find(row => row.title === title); + if (!created || current.state.namedPlaylist.selectedDefinitionId !== created.definitionId) { + await pointerClick(namedDefinitionExpression(title), + "PList select created definition"); + current = await waitFor("PList created definition selection", snapshot => + snapshot.state.isBusy === false && + snapshot.state.namedPlaylist.selectedTitle === title, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + } + + await pointerClick('document.getElementById("named-playlist-confirm-button")', + "PList request row save"); + await waitFor("PList save confirmation", snapshot => + snapshot.dom.namedConfirmationHidden === false && + snapshot.state.isBusy === false, + { allowUiBusy: false }); + await pointerClickWithJavaScriptDialog( + 'document.getElementById("named-playlist-confirmation-yes")', + "PList confirm row save", + "alert", + "저장되었습니다", + true); + current = await waitFor("PList row save commit", snapshot => + snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === true && + snapshot.state.namedPlaylist.selectedTitle === title && + snapshot.state.namedPlaylist.rowCount === 3 && + snapshot.state.namedPlaylist.lastMutationOutcome && + snapshot.state.namedPlaylist.lastMutationOutcome.startsWith("committed"), + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + recordFullUiDbCheck("databaseChecks", "PList create/save", { + rowCount: current.state.namedPlaylist.rowCount + }); + + await pointerClick('document.getElementById("db-load-button")', + "PList reopen for fresh readback"); + current = await waitFor("PList fresh readback", snapshot => + snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === false && + snapshot.state.namedPlaylist.definitions.some(row => row.title === title), + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await pointerClick(namedDefinitionExpression(title), + "PList select fresh readback"); + current = await waitFor("PList fresh selected readback", snapshot => + snapshot.state.isBusy === false && + snapshot.state.namedPlaylist.selectedTitle === title, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await pointerClick('document.getElementById("named-playlist-confirm-button")', + "PList load fresh rows"); + current = await waitFor("PList load close", snapshot => + snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === true && + snapshot.state.playlist.length === 3, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + recordFullUiDbCheck("databaseChecks", "PList fresh readback", { + rowCount: current.state.playlist.length + }); + + await pointerClick('document.getElementById("db-load-button")', + "PList reopen for delete"); + await waitFor("PList delete list read", snapshot => + snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === false && + snapshot.state.namedPlaylist.definitions.some(row => row.title === title), + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await pointerClick(namedDefinitionExpression(title), "PList select delete target"); + await waitFor("PList delete target selection", snapshot => + snapshot.state.isBusy === false && snapshot.state.namedPlaylist.selectedTitle === title, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await pointerClick('document.getElementById("named-playlist-delete-button")', + "PList open delete confirmation"); + await waitFor("PList delete confirmation", snapshot => + snapshot.dom.namedConfirmationHidden === false && snapshot.state.isBusy === false, + { allowUiBusy: false }); + await pointerClick('document.getElementById("named-playlist-confirmation-yes")', + "PList confirm delete"); + current = await waitFor("PList delete and absence", snapshot => + snapshot.state.isBusy === false && + !snapshot.state.namedPlaylist.definitions.some(row => row.title === title) && + snapshot.state.namedPlaylist.lastMutationOutcome && + snapshot.state.namedPlaylist.lastMutationOutcome.startsWith("committed"), + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await pointerClick('document.getElementById("named-playlist-cancel-button")', + "PList close after delete"); + await waitFor("PList final close", snapshot => + snapshot.dom.namedModalHidden === true && snapshot.state.isBusy === false, + { allowUiBusy: false }); + recordFullUiDbCheck("databaseChecks", "PList delete/absence"); +} + +const manualFinancialScreens = Object.freeze([ + "revenue-composition", + "growth-metrics", + "sales", + "operating-profit" +]); + +function manualScreenButtonExpression(screen) { + return `document.querySelector('button[data-manual-screen="${screen}"]')`; +} + +async function openManualFinancial(screen, label) { + await pointerClick(manualScreenButtonExpression(screen), label); + return await waitFor(`${label} open`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.manualFinancial?.screen === screen && + snapshot.dom.manualFinancialModalHidden === false, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); +} + +async function closeManualFinancial(label) { + await pointerClick('document.getElementById("manual-financial-close")', label); + await waitFor(`${label} complete`, snapshot => + snapshot.state.isBusy === false && snapshot.state.manualFinancial == null && + snapshot.dom.manualFinancialModalHidden === true, + { allowUiBusy: false }); +} + +async function searchManualFinancialIdentity(screen, stockName, label) { + await replaceText('document.getElementById("manual-financial-list-query")', + stockName, `${label} list query`); + await pointerClick('document.querySelector("button[data-manual-list-search]")', + `${label} list search`); + return await waitFor(`${label} list result`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.manualFinancial?.screen === screen && + snapshot.state.manualFinancial.query === stockName, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); +} + +async function preflightManualFinancialIdentities() { + const stockName = evidence.fixture.databaseStockDisplayName; + for (const screen of manualFinancialScreens) { + let current = await openManualFinancial(screen, + `GraphE ${screen} preflight open`); + current = await searchManualFinancialIdentity( + screen, stockName, `GraphE ${screen} preflight`); + if (current.state.manualFinancial.rows.some(row => row.stockName === stockName)) { + failKnown(`GraphE ${screen} fixture stock already exists before any GraphE write.`); + } + await closeManualFinancial(`GraphE ${screen} preflight close`); + } + recordFullUiDbCheck("screenChecks", "GraphE four-screen identity preflight"); +} + +async function fillManualFinancialRecord(screen) { + if (screen === "revenue-composition") { + await replaceText('document.querySelector("[data-manual-base-date]")', + "2026Q3", "GraphE revenue base date"); + const percentages = ["10", "20", "30", "20", "20"]; + for (let index = 0; index < 5; index += 1) { + await replaceText( + `document.querySelector('[data-manual-revenue-label="${index}"]')`, + `항목${index + 1}`, + `GraphE revenue label ${index + 1}`); + await replaceText( + `document.querySelector('[data-manual-revenue-percentage="${index}"]')`, + percentages[index], + `GraphE revenue percentage ${index + 1}`); + } + return; + } + if (screen === "growth-metrics") { + for (let index = 0; index < 4; index += 1) { + await replaceText( + `document.querySelector('[data-manual-growth-period="${index}"]')`, + `2${index + 3}Q${index + 1}`, + `GraphE growth period ${index + 1}`); + } + const series = [ + "salesGrowth", + "operatingProfitGrowth", + "netAssetGrowth", + "netIncomeGrowth" + ]; + for (let seriesIndex = 0; seriesIndex < series.length; seriesIndex += 1) { + for (let index = 0; index < 4; index += 1) { + await replaceText( + `document.querySelector('[data-manual-growth-series="${series[seriesIndex]}"]` + + `[data-manual-growth-value="${index}"]')`, + String(seriesIndex * 4 + index + 1), + `GraphE growth value ${seriesIndex + 1}-${index + 1}`); + } + } + return; + } + for (let index = 0; index < 6; index += 1) { + await replaceText( + `document.querySelector('[data-manual-quarter-label="${index}"]')`, + `${index + 1}Q`, + `GraphE ${screen} quarter ${index + 1}`); + await replaceText( + `document.querySelector('[data-manual-quarter-value="${index}"]')`, + String((index + 1) * 10), + `GraphE ${screen} value ${index + 1}`); + } +} + +async function executeManualFinancialCrud() { + const stockName = evidence.fixture.databaseStockDisplayName; + await preflightManualFinancialIdentities(); + for (const screen of manualFinancialScreens) { + let current = await openManualFinancial(screen, `GraphE ${screen} create open`); + await pointerClick('document.querySelector("button[data-manual-create]")', + `GraphE ${screen} new input`); + await waitFor(`GraphE ${screen} create form`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.manualFinancial?.screen === screen && + snapshot.state.manualFinancial.selectedRecord == null, + { allowUiBusy: false }); + await replaceText('document.getElementById("manual-financial-stock-name")', + stockName, `GraphE ${screen} stock name`); + await pointerClick('document.querySelector("button[data-manual-stock-search]")', + `GraphE ${screen} verify stock`); + current = await waitFor(`GraphE ${screen} stock candidates`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.manualFinancial?.stockCandidates?.some(row => row.name === stockName), + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const candidates = current.state.manualFinancial.stockCandidates + .filter(row => row.name === stockName); + if (candidates.length !== 1) { + failKnown(`GraphE ${screen} fixture stock is not unique in the master.`); + } + await pointerClick( + `document.querySelector('[data-manual-stock-result-id="${candidates[0].resultId}"]')`, + `GraphE ${screen} select verified stock`); + await waitFor(`GraphE ${screen} verified stock`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.manualFinancial?.verifiedStock?.name === stockName, + { allowUiBusy: false }); + await fillManualFinancialRecord(screen); + await pointerClick('document.querySelector("button[data-manual-save]")', + `GraphE ${screen} save`); + current = await waitFor(`GraphE ${screen} create commit and readback`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.manualFinancial?.status === "writeCommitted" && + snapshot.state.manualFinancial?.selectedRecord?.stockName === stockName && + snapshot.state.manualFinancial?.canDelete === true && + snapshot.state.manualFinancial?.writesQuarantined !== true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + recordFullUiDbCheck("databaseChecks", `GraphE ${screen} create/save`, { + status: current.state.manualFinancial.status + }); + await closeManualFinancial(`GraphE ${screen} close after create`); + + await openManualFinancial(screen, `GraphE ${screen} fresh reopen`); + current = await searchManualFinancialIdentity( + screen, stockName, `GraphE ${screen} fresh readback`); + const rows = current.state.manualFinancial.rows + .filter(row => row.stockName === stockName); + if (rows.length !== 1) { + failKnown(`GraphE ${screen} fresh readback is not unique.`); + } + await replaceText('document.getElementById("manual-financial-find-query")', + stockName, `GraphE ${screen} find query`); + await pointerClick('document.querySelector("button[data-manual-find-direction=down]")', + `GraphE ${screen} find down`); + await waitFor(`GraphE ${screen} find down result`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.manualFinancial?.selectedRecord?.stockName === stockName, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await pointerClick('document.querySelector("button[data-manual-find-direction=up]")', + `GraphE ${screen} find up`); + await waitFor(`GraphE ${screen} find up result`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.manualFinancial?.selectedRecord?.stockName === stockName, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await pointerClick('document.querySelector("button[data-manual-name-sort]")', + `GraphE ${screen} toggle name sort`); + await waitFor(`GraphE ${screen} sort complete`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.manualFinancial?.nameSortDirection === "descending", + { allowUiBusy: false }); + recordFullUiDbCheck("databaseChecks", `GraphE ${screen} fresh readback`, { + rows: rows.length + }); + + const actionIds = await clickAllEnabledPlaylistActions( + "button[data-manual-action-id]", "manualActionId", `GraphE ${screen} action`); + const beforeDoubleClick = await readSnapshot(); + await pointerDoubleClick(dataButtonExpression("manualResultId", rows[0].resultId), + `GraphE ${screen} row double-click`); + await waitFor(`GraphE ${screen} row double-click add`, snapshot => + snapshot.state.playlist.length === beforeDoubleClick.state.playlist.length + 1 && + snapshot.state.manualFinancial == null && + snapshot.dom.manualFinancialModalHidden === true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + recordFullUiDbCheck("screenChecks", `GraphE ${screen} actions and row double-click`, { + actionButtons: actionIds.length + }); + + await openManualFinancial(screen, `GraphE ${screen} delete reopen`); + current = await searchManualFinancialIdentity( + screen, stockName, `GraphE ${screen} delete fresh search`); + const deleteRows = current.state.manualFinancial.rows + .filter(row => row.stockName === stockName); + if (deleteRows.length !== 1) { + failKnown(`GraphE ${screen} delete target is not unique after fresh reopen.`); + } + await pointerClick( + dataButtonExpression("manualResultId", deleteRows[0].resultId), + `GraphE ${screen} select delete target`); + await waitFor(`GraphE ${screen} delete target loaded`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.manualFinancial?.selectedRecord?.stockName === stockName && + snapshot.state.manualFinancial?.canDelete === true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + + await pointerClickWithJavaScriptDialog( + 'document.querySelector("button[data-manual-delete]")', + `GraphE ${screen} delete`, + "confirm", + "선택한 수동 재무 데이터를 삭제하시겠습니까?", + true); + current = await waitFor(`GraphE ${screen} delete commit`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.manualFinancial?.status === "writeCommitted" && + snapshot.state.manualFinancial?.writesQuarantined !== true && + !snapshot.state.manualFinancial.rows.some(row => row.stockName === stockName), + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await pointerClickWithJavaScriptDialog( + 'document.querySelector("button[data-manual-delete-all]")', + `GraphE ${screen} delete-all cancel branch`, + "confirm", + "현재 화면의 수동 재무 데이터를 모두 삭제하시겠습니까?", + false); + recordFullUiDbCheck("databaseChecks", `GraphE ${screen} delete/absence`); + await closeManualFinancial(`GraphE ${screen} close after delete`); + } + if (configuration.scope === "graphe") { + await clearScreenValidationPlaylist(); + } +} + +async function selectMainTab(tabId, label) { + const isLoaded = snapshot => + snapshot.state.isBusy === false && + snapshot.state.tabs?.some(tab => tab.id === tabId && tab.isActive) === true && + (tabId !== "theme" || snapshot.state.theme != null) && + (tabId !== "expert" || snapshot.state.expert != null); + const before = await readSnapshot(); + assertSafety(before, `${label} preflight`); + const alreadyActive = before.state.tabs?.some( + tab => tab.id === tabId && tab.isActive) === true; + if (!alreadyActive) { + await pointerClick( + `document.querySelector('button[data-tab-id="${tabId}"]')`, + label); + } + return await waitFor(`${label} load`, snapshot => + isLoaded(snapshot), + { timeoutMilliseconds: 60_000, allowUiBusy: true }); +} + +async function openThemeCatalogList(label) { + await selectMainTab("theme", `${label} select ThemeA tab`); + await pointerClick('document.getElementById("uc4-theme-add")', + `${label} open ThemeA catalog`); + return await waitFor(`${label} ThemeA catalog list`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog?.isOpen === true && + snapshot.state.operatorCatalog?.entity === "theme" && + snapshot.state.operatorCatalog?.mode === "list" && + snapshot.state.operatorCatalog?.schemaValidated === true && + snapshot.state.operatorCatalog?.writesQuarantined !== true && + snapshot.dom.operatorCatalogModalHidden === false, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); +} + +async function closeOperatorCatalog(label, useEditorCancel = false) { + await pointerClick( + useEditorCancel + ? 'document.getElementById("operator-catalog-cancel")' + : 'document.getElementById("operator-catalog-close")', + label); + return await waitFor(`${label} complete`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog == null && + snapshot.dom.operatorCatalogModalHidden === true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); +} + +async function searchOperatorCatalog(query, label) { + await replaceText('document.getElementById("operator-catalog-query")', + query, `${label} query`); + const before = await readSnapshot(); + await pointerClick('document.getElementById("operator-catalog-search")', + `${label} submit`); + return await waitFor(`${label} results`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.revision > before.state.revision && + snapshot.state.operatorCatalog?.isOpen === true && + snapshot.state.operatorCatalog?.mode === "list" && + snapshot.state.operatorCatalog?.query === query && + snapshot.state.operatorCatalog?.writesQuarantined !== true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); +} + +async function requireExactCatalogResult(query, label, expectedCount) { + const current = await searchOperatorCatalog(query, label); + const matches = current.state.operatorCatalog.results + .filter(row => row.displayName === query); + if (matches.length !== expectedCount) { + failKnown(`${label} expected ${expectedCount} exact catalog result(s), ` + + `received ${matches.length}.`); + } + return { current, matches }; +} + +async function addOperatorCatalogStock( + query, + expectedDisplayName, + label) { + const before = await readSnapshot(); + const beforeRows = before.state.operatorCatalog?.draftRows || []; + await replaceText('document.getElementById("operator-catalog-stock-query")', + query, `${label} query`); + const beforeSearch = await readSnapshot(); + await pointerClick('document.getElementById("operator-catalog-stock-search")', + `${label} search`); + const searched = await waitFor(`${label} stock results`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.revision > beforeSearch.state.revision && + snapshot.state.operatorCatalog?.stockQuery === query && + snapshot.state.operatorCatalog?.writesQuarantined !== true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const matches = searched.state.operatorCatalog.stockResults.filter(row => + row.displayName === expectedDisplayName && row.isCompatible === true); + if (matches.length !== 1) { + failKnown(`${label} requires one exact compatible stock result, received ` + + `${matches.length}.`); + } + const resultId = matches[0].resultId; + await pointerClick( + `document.querySelector('button[data-operator-catalog-stock-result-id="${resultId}"]')`, + `${label} select`); + await waitFor(`${label} selected`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog?.selectedStockResultId === resultId && + snapshot.state.operatorCatalog?.canAddStock === true, + { timeoutMilliseconds: 30_000, allowUiBusy: true }); + await pointerClick('document.getElementById("operator-catalog-add-stock")', + `${label} add`); + const added = await waitFor(`${label} added`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog?.draftRows?.length === beforeRows.length + 1 && + snapshot.state.operatorCatalog?.selectedStockResultId == null, + { allowUiBusy: false }); + const addedRows = added.state.operatorCatalog.draftRows.filter(row => + !beforeRows.some(existing => existing.rowId === row.rowId)); + if (addedRows.length !== 1) { + failKnown(`${label} did not add exactly one new draft row.`); + } + return { current: added, row: addedRows[0] }; +} + +async function setOperatorCatalogBuyAmount(rowId, amount, label) { + const expression = + `document.querySelector('input[data-operator-catalog-buy-amount-row-id="${rowId}"]')`; + await replaceText(expression, String(amount), label); + await pointerClick('document.getElementById("operator-catalog-stock-query")', + `${label} commit`); + return await waitFor(`${label} state`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog?.draftRows?.some(row => + row.rowId === rowId && row.buyAmount === amount) === true, + { allowUiBusy: false }); +} + +async function searchAndSelectMainTheme(title, expectedDisplayTitle, label) { + await selectMainTab("theme", `${label} select ThemeA tab`); + await replaceText('document.getElementById("theme-search")', + title, `${label} query`); + const beforeSearch = await readSnapshot(); + await pointerClick('document.querySelector("button[data-theme-search]")', + `${label} search`); + const searched = await waitFor(`${label} results`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.revision > beforeSearch.state.revision && + snapshot.state.theme?.query === title, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const matches = searched.state.theme.searchResults.filter(row => + row.displayTitle === expectedDisplayTitle); + if (matches.length !== 1) { + failKnown(`${label} expected one exact ThemeA result, received ${matches.length}.`); + } + const resultId = matches[0].resultId; + await pointerClick( + `document.querySelector('button[data-theme-result-id="${resultId}"]')`, + `${label} select`); + const selected = await waitFor(`${label} preview`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.theme?.selectedResultId === resultId && + snapshot.state.theme?.selectedTheme?.displayTitle === expectedDisplayTitle, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + return { current: selected, resultId }; +} + +async function assertMainThemeAbsent(title, expectedDisplayTitle, label) { + await selectMainTab("theme", `${label} select ThemeA tab`); + await replaceText('document.getElementById("theme-search")', + title, `${label} query`); + const beforeSearch = await readSnapshot(); + await pointerClick('document.querySelector("button[data-theme-search]")', + `${label} search`); + const current = await waitFor(`${label} results`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.revision > beforeSearch.state.revision && + snapshot.state.theme?.query === title, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + if (current.state.theme.searchResults.some(row => + row.displayTitle === expectedDisplayTitle)) { + failKnown(`${label} still returned the deleted ThemeA row.`); + } + return current; +} + +async function createReadEditDeleteTheme( + title, + market, + stockQuery, + stockDisplayName) { + const label = `ThemeA ${market.toUpperCase()} ${title}`; + await openThemeCatalogList(`${label} create`); + await requireExactCatalogResult(title, `${label} preflight`, 0); + await selectOptionValue( + 'document.getElementById("operator-catalog-new-market")', + market, + `${label} market`); + await pointerClick('document.getElementById("operator-catalog-new")', + `${label} new`); + await waitFor(`${label} create editor`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog?.entity === "theme" && + snapshot.state.operatorCatalog?.mode === "create" && + snapshot.state.operatorCatalog?.newThemeMarket === market && + snapshot.state.operatorCatalog?.codeLabel?.length > 0 && + snapshot.state.operatorCatalog?.writesQuarantined !== true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await replaceText('document.getElementById("operator-catalog-name")', + title, `${label} name`); + await pointerClick('document.getElementById("operator-catalog-name-check")', + `${label} duplicate check`); + await respondToNativeDialog(`${label} duplicate check`, false, true); + await waitFor(`${label} name available`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog?.checkedThemeName === title && + snapshot.state.operatorCatalog?.themeNameAvailability === "available" && + snapshot.state.operatorCatalog?.writesQuarantined !== true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const added = await addOperatorCatalogStock( + stockQuery, stockDisplayName, `${label} stock`); + await pointerClick('document.getElementById("operator-catalog-delete-all")', + `${label} Delete All cancel branch`); + await respondToNativeDialog(`${label} Delete All cancel branch`, true, false); + const afterCancel = await waitFor(`${label} draft preserved`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog?.draftRows?.length === 1 && + snapshot.state.operatorCatalog?.draftRows?.[0]?.rowId === added.row.rowId, + { allowUiBusy: false }); + if (afterCancel.state.operatorCatalog.canSave !== true) { + failKnown(`${label} is not saveable after the cancelled Delete All branch.`); + } + await pointerClick('document.getElementById("operator-catalog-save")', + `${label} save`); + await waitFor(`${label} create commit`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog == null && + snapshot.dom.operatorCatalogModalHidden === true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + recordFullUiDbCheck("databaseChecks", `${label} create/save`); + + await openThemeCatalogList(`${label} fresh read`); + const fresh = await requireExactCatalogResult(title, `${label} fresh read`, 1); + const catalogResultId = fresh.matches[0].resultId; + await pointerClick( + `document.querySelector('button[data-operator-catalog-result-id="${catalogResultId}"]')`, + `${label} fresh read select`); + const loaded = await waitFor(`${label} fresh read editor`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog?.mode === "edit" && + snapshot.state.operatorCatalog?.selectedResultId === catalogResultId && + snapshot.state.operatorCatalog?.editorName === title && + snapshot.state.operatorCatalog?.draftRows?.length === 1 && + snapshot.state.operatorCatalog?.writesQuarantined !== true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + recordFullUiDbCheck("databaseChecks", `${label} fresh readback`, { + codeLabel: loaded.state.operatorCatalog.codeLabel, + rows: loaded.state.operatorCatalog.draftRows.length + }); + await closeOperatorCatalog(`${label} close fresh read`, true); + + const expectedDisplayTitle = market === "nxt" ? `${title}(NXT)` : title; + const selected = await searchAndSelectMainTheme( + title, expectedDisplayTitle, `${label} UC4`); + if (selected.current.state.theme.previewItems.length !== 1) { + failKnown(`${label} UC4 preview did not contain the saved stock.`); + } + await pointerClick('document.getElementById("uc4-theme-edit")', + `${label} UC4 edit`); + await waitFor(`${label} UC4 edit loaded`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog?.entity === "theme" && + snapshot.state.operatorCatalog?.mode === "edit" && + snapshot.state.operatorCatalog?.editorName === title && + snapshot.state.operatorCatalog?.draftRows?.length === 1, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await closeOperatorCatalog(`${label} UC4 edit cancel`, true); + recordFullUiDbCheck("screenChecks", `${label} UC4 select/edit`); + + await pointerClick('document.getElementById("uc4-theme-delete")', + `${label} UC4 delete`); + await respondToNativeDialog(`${label} UC4 delete`, true, true); + await waitFor(`${label} delete commit`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog == null && + snapshot.state.theme?.selectedTheme == null && + !snapshot.state.theme?.searchResults?.some(row => + row.displayTitle === expectedDisplayTitle), + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await assertMainThemeAbsent(title, expectedDisplayTitle, + `${label} fresh absence`); + recordFullUiDbCheck("databaseChecks", `${label} delete/absence`); +} + +async function searchAndSelectMainExpert(title, label) { + await selectMainTab("expert", `${label} select EList tab`); + await replaceText('document.getElementById("expert-search")', + title, `${label} query`); + const beforeSearch = await readSnapshot(); + await pointerClick('document.querySelector("button[data-expert-search]")', + `${label} search`); + const searched = await waitFor(`${label} results`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.revision > beforeSearch.state.revision && + snapshot.state.expert?.search?.query === title, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const matches = searched.state.expert.search.results.filter(row => + row.expertName === title); + if (matches.length !== 1) { + failKnown(`${label} expected one exact EList result, received ${matches.length}.`); + } + const selectionId = matches[0].selectionId; + await pointerClick( + `document.querySelector('button[data-expert-selection-id="${selectionId}"]')`, + `${label} select`); + const selected = await waitFor(`${label} preview`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.expert?.selectedExpert?.selectionId === selectionId && + snapshot.state.expert?.selectedExpert?.expertName === title, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + return { current: selected, selectionId }; +} + +async function assertMainExpertAbsent(title, label) { + await selectMainTab("expert", `${label} select EList tab`); + await replaceText('document.getElementById("expert-search")', + title, `${label} query`); + const beforeSearch = await readSnapshot(); + await pointerClick('document.querySelector("button[data-expert-search]")', + `${label} search`); + const current = await waitFor(`${label} results`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.revision > beforeSearch.state.revision && + snapshot.state.expert?.search?.query === title, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + if (current.state.expert.search.results.some(row => row.expertName === title)) { + failKnown(`${label} still returned the deleted EList row.`); + } + return current; +} + +async function createReadEditDeleteExpert(title) { + const label = `EList ${title}`; + await assertMainExpertAbsent(title, `${label} preflight`); + await pointerClick('document.getElementById("uc6-expert-add")', + `${label} UC6 add`); + await waitFor(`${label} create editor`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog?.isOpen === true && + snapshot.state.operatorCatalog?.entity === "expert" && + snapshot.state.operatorCatalog?.mode === "create" && + snapshot.state.operatorCatalog?.draftRows?.length === 0 && + snapshot.state.operatorCatalog?.writesQuarantined !== true && + snapshot.dom.operatorCatalogModalHidden === false, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await replaceText('document.getElementById("operator-catalog-name")', + title, `${label} name`); + await pointerClick('document.getElementById("operator-catalog-save")', + `${label} create save`); + await waitFor(`${label} create commit`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog == null && + snapshot.dom.operatorCatalogModalHidden === true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + recordFullUiDbCheck("databaseChecks", `${label} create/save`); + + const created = await searchAndSelectMainExpert(title, `${label} fresh create read`); + if (created.current.state.expert.preview.items.length !== 0) { + failKnown(`${label} create readback unexpectedly contained recommendations.`); + } + recordFullUiDbCheck("databaseChecks", `${label} fresh create readback`, { + rows: 0 + }); + await pointerClick('document.getElementById("uc6-expert-edit")', + `${label} UC6 edit`); + await waitFor(`${label} edit loaded`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog?.entity === "expert" && + snapshot.state.operatorCatalog?.mode === "edit" && + snapshot.state.operatorCatalog?.editorName === title && + snapshot.state.operatorCatalog?.draftRows?.length === 0 && + snapshot.state.operatorCatalog?.writesQuarantined !== true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + + const first = await addOperatorCatalogStock( + "삼성출판사", "삼성출판사", `${label} first stock`); + const second = await addOperatorCatalogStock( + "삼성전자", "삼성전자", `${label} second stock`); + await setOperatorCatalogBuyAmount(first.row.rowId, 101, + `${label} first buy amount`); + await setOperatorCatalogBuyAmount(second.row.rowId, 202, + `${label} second buy amount`); + + await pointerClick( + `document.querySelector('button[data-operator-catalog-move-row-id="${second.row.rowId}"]` + + `[data-operator-catalog-move-direction="up"]')`, + `${label} second row Up`); + await waitFor(`${label} second row Up state`, snapshot => + snapshot.state.operatorCatalog?.draftRows?.[0]?.rowId === second.row.rowId && + snapshot.state.operatorCatalog?.draftRows?.[1]?.rowId === first.row.rowId, + { allowUiBusy: false }); + await pointerClick( + `document.querySelector('button[data-operator-catalog-move-row-id="${second.row.rowId}"]` + + `[data-operator-catalog-move-direction="down"]')`, + `${label} second row Down`); + await waitFor(`${label} second row Down state`, snapshot => + snapshot.state.operatorCatalog?.draftRows?.[0]?.rowId === first.row.rowId && + snapshot.state.operatorCatalog?.draftRows?.[1]?.rowId === second.row.rowId, + { allowUiBusy: false }); + await pointerClick( + `document.querySelector('button[data-operator-catalog-remove-row-id="${second.row.rowId}"]')`, + `${label} second row Delete`); + await waitFor(`${label} second row Delete state`, snapshot => + snapshot.state.operatorCatalog?.draftRows?.length === 1 && + snapshot.state.operatorCatalog?.draftRows?.[0]?.rowId === first.row.rowId, + { allowUiBusy: false }); + const readded = await addOperatorCatalogStock( + "삼성전자", "삼성전자", `${label} second stock re-add`); + await setOperatorCatalogBuyAmount(readded.row.rowId, 202, + `${label} re-added buy amount`); + const readyToSave = await readSnapshot(); + if (readyToSave.state.operatorCatalog?.canSave !== true || + readyToSave.state.operatorCatalog?.draftRows?.length !== 2) { + failKnown(`${label} edit is not saveable with exactly two recommendations.`); + } + await pointerClick('document.getElementById("operator-catalog-save")', + `${label} edit save`); + await respondToNativeDialog(`${label} edit save`, false, true); + const closed = await waitFor(`${label} edit commit`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog == null && + snapshot.dom.operatorCatalogModalHidden === true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + if (closed.state.expert?.selectedExpert?.expertName !== title || + closed.state.expert?.preview?.items?.length !== 2) { + failKnown(`${label} known-committed edit did not refresh the two-row preview.`); + } + recordFullUiDbCheck("screenChecks", `${label} UC6 add/edit/up/down/delete`); + recordFullUiDbCheck("databaseChecks", `${label} edit/save`, { rows: 2 }); + + const fresh = await searchAndSelectMainExpert(title, `${label} fresh edit read`); + const previewItems = fresh.current.state.expert.preview.items; + if (previewItems.length !== 2 || + previewItems[0].stockName !== "삼성출판사" || + previewItems[0].buyAmount !== 101 || + previewItems[1].stockName !== "삼성전자" || + previewItems[1].buyAmount !== 202) { + failKnown(`${label} fresh readback did not match the exact saved recommendations.`); + } + recordFullUiDbCheck("databaseChecks", `${label} fresh edit readback`, { + rows: previewItems.length + }); + + await pointerClick('document.getElementById("uc6-expert-delete")', + `${label} UC6 delete`); + await respondToNativeDialog(`${label} UC6 delete`, true, true); + // Original UC6 removes the search row but deliberately retains the selected + // expert and preview until the next search. The following fresh search is the + // authoritative database-absence check. + await waitFor(`${label} delete commit`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.operatorCatalog == null && + !snapshot.state.expert?.search?.results?.some(row => row.expertName === title), + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await assertMainExpertAbsent(title, `${label} fresh absence`); + recordFullUiDbCheck("databaseChecks", `${label} delete/absence`); +} + +async function executeOperatorCatalogCrud() { + await createReadEditDeleteTheme( + evidence.fixture.krxThemeTitle, + "krx", + "삼성출판사", + "삼성출판사"); + await createReadEditDeleteTheme( + evidence.fixture.nxtThemeTitle, + "nxt", + "삼성전자", + "삼성전자(NXT)"); + await createReadEditDeleteExpert(evidence.fixture.expertTitle); +} + +async function captureVisibleScreenInventory(tabId) { + const buttons = await evaluate(`Array.from(document.querySelectorAll("button")) + .filter(button => { + const rectangle = button.getBoundingClientRect(); + return !button.hidden && !button.closest("[hidden]") && + rectangle.width > 0 && rectangle.height > 0 && + getComputedStyle(button).display !== "none" && + getComputedStyle(button).visibility !== "hidden"; + }) + .map(button => ({ + id: button.id || null, + text: (button.textContent || "").trim(), + disabled: button.disabled === true, + data: Object.fromEntries(Object.entries(button.dataset)) + }))`); + evidence.fullUiDb.screenInventory.push({ + tabId, + at: new Date().toISOString(), + buttons + }); +} + +function dataButtonExpression(datasetKey, value) { + return `Array.from(document.querySelectorAll("button")) + .find(button => button.dataset[${JSON.stringify(datasetKey)}] === + ${JSON.stringify(value)})`; +} + +function modalButtonByTextExpression(text) { + return `Array.from(document.querySelectorAll( + "#manual-list-workspace button:not([disabled])")) + .find(button => button.textContent.trim() === ${JSON.stringify(text)})`; +} + +async function waitForTabModel(tabId, stateProperty, label) { + await selectMainTab(tabId, label); + return await waitFor(`${label} model`, snapshot => + snapshot.state.isBusy === false && snapshot.state[stateProperty] != null, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); +} + +async function clickAndWaitForPlaylistAdd(expression, label, minimumAdded = 1) { + const before = await readSnapshot(); + const beforeCount = before.state.playlist.length; + await pointerClick(expression, label); + return await waitFor(`${label} playlist add`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.playlist.length >= beforeCount + minimumAdded, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); +} + +async function doubleClickAndWaitForPlaylistAdd(expression, label, minimumAdded = 1) { + const before = await readSnapshot(); + const beforeCount = before.state.playlist.length; + await pointerDoubleClick(expression, label); + return await waitFor(`${label} playlist add`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.playlist.length >= beforeCount + minimumAdded, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); +} + +async function clickAllEnabledPlaylistActions(selector, datasetKey, label) { + const actionIds = await evaluate(`Array.from(document.querySelectorAll( + ${JSON.stringify(selector)})) + .filter(button => !button.disabled) + .map(button => button.dataset[${JSON.stringify(datasetKey)}])`); + if (!Array.isArray(actionIds) || actionIds.length === 0 || + actionIds.some(value => typeof value !== "string" || value.length === 0)) { + failKnown(`${label} exposed no enabled action buttons.`); + } + for (const actionId of actionIds) { + await clickAndWaitForPlaylistAdd( + dataButtonExpression(datasetKey, actionId), + `${label} ${actionId}`); + } + return actionIds; +} + +async function doubleClickAllEnabledPlaylistActions(selector, datasetKey, label) { + const actionIds = await evaluate(`Array.from(document.querySelectorAll( + ${JSON.stringify(selector)})) + .filter(button => !button.disabled) + .map(button => button.dataset[${JSON.stringify(datasetKey)}])`); + if (!Array.isArray(actionIds) || actionIds.length === 0 || + actionIds.some(value => typeof value !== "string" || value.length === 0)) { + failKnown(`${label} exposed no enabled action buttons.`); + } + for (const actionId of actionIds) { + await doubleClickAndWaitForPlaylistAdd( + dataButtonExpression(datasetKey, actionId), + `${label} ${actionId}`); + } + return actionIds; +} + +async function verifyFixedAndIndustryScreens() { + await waitForTabModel("overseas", "fixedCatalog", "UC1 overseas tab"); + await pointerClick('document.getElementById("catalog-expand-all")', + "UC1 Expand off"); + if (await evaluate('document.getElementById("catalog-expand-all").checked') !== false) { + failKnown("UC1 Expand did not close the fixed tree."); + } + await pointerClick('document.getElementById("catalog-expand-all")', + "UC1 Expand on"); + if (await evaluate('document.getElementById("catalog-expand-all").checked') !== true) { + failKnown("UC1 Expand did not reopen the fixed tree."); + } + await doubleClickAndWaitForPlaylistAdd(dataButtonExpression("actionId", "fixed-001"), + "UC1 overseas leaf"); + const beforeSection = await readSnapshot(); + await pointerDoubleClick( + `document.querySelector('summary[data-section-index="1"]')`, + "UC1 fixed three-row section"); + await waitFor("UC1 fixed section batch", snapshot => + snapshot.state.isBusy === false && + snapshot.state.playlist.length === beforeSection.state.playlist.length + 3, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + + await waitForTabModel("exchange", "fixedCatalog", "UC1 exchange tab"); + await doubleClickAndWaitForPlaylistAdd(dataButtonExpression("actionId", "fixed-079"), + "UC1 exchange leaf"); + await waitForTabModel("index", "fixedCatalog", "UC1 index tab"); + await doubleClickAndWaitForPlaylistAdd(dataButtonExpression("actionId", "fixed-125"), + "UC1 index leaf"); + recordFullUiDbCheck("screenChecks", "UC1 Expand, leaf, and parent-section buttons"); + + for (const tabId of ["kospiIndustry", "kosdaqIndustry"]) { + await waitForTabModel(tabId, "industry", `UC2 ${tabId} tab`); + await pointerClick(dataButtonExpression("industryIndex", "0"), + `UC2 ${tabId} first industry`); + await waitFor(`UC2 ${tabId} first selection`, snapshot => + snapshot.state.isBusy === false && snapshot.state.industry?.selectedIndex === 0, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await pointerDoubleClick(dataButtonExpression("industryIndex", "0"), + `UC2 ${tabId} first industry double-click`); + await waitFor(`UC2 ${tabId} first comparison slot`, snapshot => + snapshot.state.isBusy === false && + Boolean(snapshot.state.industry?.firstName), + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await pointerDoubleClick(dataButtonExpression("industryIndex", "1"), + `UC2 ${tabId} second industry double-click`); + const rotated = await waitFor(`UC2 ${tabId} comparison slots`, snapshot => + snapshot.state.isBusy === false && + Boolean(snapshot.state.industry?.firstName) && + Boolean(snapshot.state.industry?.secondName), + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const firstName = rotated.state.industry.firstName; + const secondName = rotated.state.industry.secondName; + await pointerClick(dataButtonExpression("industrySwap", "true"), + `UC2 ${tabId} swap`); + await waitFor(`UC2 ${tabId} swapped slots`, snapshot => + snapshot.state.industry?.firstName === secondName && + snapshot.state.industry?.secondName === firstName, + { allowUiBusy: false }); + await doubleClickAndWaitForPlaylistAdd( + dataButtonExpression("industryActionId", "one-column"), + `UC2 ${tabId} one-column action`); + } + recordFullUiDbCheck("screenChecks", "UC2 KOSPI/KOSDAQ select, double-click, swap, action"); +} + +function comparisonBaseline(snapshot) { + return snapshot.state.comparison.savedPairs.map(row => ({ + rowId: row.rowId, + displayLabel: row.displayLabel, + first: comparisonTargetIdentity(row.first), + second: comparisonTargetIdentity(row.second) + })); +} + +function comparisonTargetIdentity(target) { + return target == null ? null : { + displayName: target.displayName, + kind: target.kind, + metadata: target.metadata + }; +} + +async function verifyComparisonScreen() { + let current = await waitForTabModel("comparison", "comparison", "UC3 comparison tab"); + const baseline = comparisonBaseline(current); + + await replaceText('document.getElementById("comparison-domestic-search")', + "삼성", "UC3 domestic query"); + await pointerClick(dataButtonExpression("comparisonSearch", "domestic"), + "UC3 domestic search"); + current = await waitFor("UC3 domestic results", snapshot => + snapshot.state.isBusy === false && + snapshot.state.comparison?.domesticSearch?.query === "삼성" && + snapshot.state.comparison.domesticSearch.results.length > 1, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const domesticId = current.state.comparison.domesticSearch.results[0].selectionId; + const secondDomesticId = current.state.comparison.domesticSearch.results[1].selectionId; + await pointerClick(dataButtonExpression("comparisonSelectionId", domesticId), + "UC3 domestic result single-click"); + await waitFor("UC3 domestic result selected", snapshot => + snapshot.state.comparison?.domesticSearch?.selectedResultId === domesticId, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + + await replaceText('document.getElementById("comparison-world-search")', + "3M", "UC3 world query"); + await pointerClick(dataButtonExpression("comparisonSearch", "world"), + "UC3 world search"); + current = await waitFor("UC3 world results", snapshot => + snapshot.state.isBusy === false && + snapshot.state.comparison?.worldSearch?.query === "3M" && + snapshot.state.comparison?.worldSearch?.status === "ready" && + snapshot.state.comparison.worldSearch.results.length > 0, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + + if (current.state.comparison.firstTarget != null || + current.state.comparison.secondTarget != null) { + failKnown("UC3 fresh tab activation did not start with empty draft targets."); + } + await pointerDoubleClick(dataButtonExpression("comparisonSelectionId", domesticId), + "UC3 choose first domestic target"); + await waitFor("UC3 first domestic target", snapshot => + snapshot.state.comparison?.firstTarget != null, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await pointerDoubleClick(dataButtonExpression("comparisonSelectionId", secondDomesticId), + "UC3 choose second domestic target"); + current = await waitFor("UC3 second domestic target", snapshot => + snapshot.state.comparison?.firstTarget != null && + snapshot.state.comparison?.secondTarget != null, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const firstTargetIdentity = comparisonTargetIdentity( + current.state.comparison.firstTarget); + const secondTargetIdentity = comparisonTargetIdentity( + current.state.comparison.secondTarget); + await pointerClick(dataButtonExpression("comparisonSwap", "true"), "UC3 swap draft"); + await waitFor("UC3 swapped draft", snapshot => + JSON.stringify(comparisonTargetIdentity(snapshot.state.comparison?.firstTarget)) === + JSON.stringify(secondTargetIdentity) && + JSON.stringify(comparisonTargetIdentity(snapshot.state.comparison?.secondTarget)) === + JSON.stringify(firstTargetIdentity), + { allowUiBusy: false }); + await pointerClick(dataButtonExpression("comparisonSwap", "true"), + "UC3 restore draft order"); + await waitFor("UC3 restored draft", snapshot => + JSON.stringify(comparisonTargetIdentity(snapshot.state.comparison?.firstTarget)) === + JSON.stringify(firstTargetIdentity) && + JSON.stringify(comparisonTargetIdentity(snapshot.state.comparison?.secondTarget)) === + JSON.stringify(secondTargetIdentity), + { allowUiBusy: false }); + + await pointerClick(dataButtonExpression("comparisonAdd", "true"), + "UC3 add first temporary comparison pair"); + current = await waitFor("UC3 first temporary pair commit", snapshot => + snapshot.state.isBusy === false && + snapshot.state.comparison?.savedPairs?.length === baseline.length + 1 && + snapshot.state.comparison?.selectedPairId != null, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const firstTemporaryRowId = current.state.comparison.selectedPairId; + await pointerClick(dataButtonExpression("comparisonAdd", "true"), + "UC3 add second temporary comparison pair"); + current = await waitFor("UC3 second temporary pair commit", snapshot => + snapshot.state.isBusy === false && + snapshot.state.comparison?.savedPairs?.length === baseline.length + 2 && + snapshot.state.comparison?.selectedPairId != null && + snapshot.state.comparison.selectedPairId !== firstTemporaryRowId, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const secondTemporaryRowId = current.state.comparison.selectedPairId; + await pointerClick(dataButtonExpression("comparisonMove", "up"), + "UC3 second temporary pair UP"); + await waitFor("UC3 second temporary pair UP commit", snapshot => + snapshot.state.isBusy === false && + snapshot.state.comparison?.savedPairs?.[baseline.length]?.rowId === + secondTemporaryRowId, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await pointerClick(dataButtonExpression("comparisonMove", "down"), + "UC3 second temporary pair DOWN"); + await waitFor("UC3 second temporary pair DOWN commit", snapshot => + snapshot.state.isBusy === false && + snapshot.state.comparison?.savedPairs?.at(-1)?.rowId === secondTemporaryRowId, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + + await pointerDoubleClick(dataButtonExpression("comparisonTargetId", "kospi"), + "UC3 fixed target double-click"); + await waitFor("UC3 fixed target draft", snapshot => + snapshot.state.comparison?.selectedFixedTargetId === "kospi" && + snapshot.state.comparison?.firstTarget?.displayName === "코스피 지수", + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + + await pointerClick(dataButtonExpression("comparisonDelete", "all"), + "UC3 DEL ALL"); + await respondToNativeDialog("UC3 DEL ALL cancel", true, false); + const comparisonActions = await doubleClickAllEnabledPlaylistActions( + "button[data-comparison-action-id]", "comparisonActionId", "UC3 action"); + await pointerClick(dataButtonExpression("comparisonDelete", "selected"), + "UC3 delete second temporary pair"); + current = await waitFor("UC3 second temporary pair delete commit", snapshot => + snapshot.state.isBusy === false && + snapshot.state.comparison?.savedPairs?.length === baseline.length + 1 && + !snapshot.state.comparison.savedPairs.some( + row => row.rowId === secondTemporaryRowId), + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await pointerClick(dataButtonExpression("comparisonPairId", firstTemporaryRowId), + "UC3 select first temporary pair"); + await waitFor("UC3 first temporary pair selected", snapshot => + snapshot.state.comparison?.selectedPairId === firstTemporaryRowId, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await pointerClick(dataButtonExpression("comparisonDelete", "selected"), + "UC3 delete first temporary pair"); + current = await waitFor("UC3 first temporary pair delete commit", snapshot => + snapshot.state.isBusy === false && + snapshot.state.comparison?.savedPairs?.length === baseline.length && + !snapshot.state.comparison.savedPairs.some( + row => row.rowId === firstTemporaryRowId), + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + if (JSON.stringify(comparisonBaseline(current)) !== JSON.stringify(baseline)) { + failKnown("UC3 baseline changed after deleting the temporary pair."); + } + + await waitForTabModel("overseas", "fixedCatalog", "UC3 fresh-read detour"); + current = await waitForTabModel("comparison", "comparison", "UC3 fresh-read return"); + if (JSON.stringify(comparisonBaseline(current)) !== JSON.stringify(baseline)) { + failKnown("UC3 persisted baseline did not survive fresh re-entry exactly."); + } + recordFullUiDbCheck("screenChecks", "UC3 all control buttons and temporary pair restore", { + baselineRows: baseline.length, + actionButtons: comparisonActions.length + }); + recordFullUiDbCheck("databaseChecks", "UC3 local pair save/fresh read/delete/restore", { + rows: baseline.length + }); +} + +async function verifyThemeScreen() { + let current = await waitForTabModel("theme", "theme", "UC4 theme tab"); + await replaceText('document.getElementById("theme-search")', "", + "UC4 theme query"); + await pointerClick(dataButtonExpression("themeSearch", "true"), "UC4 theme search"); + current = await waitFor("UC4 theme search results", snapshot => + snapshot.state.isBusy === false && + snapshot.state.theme?.searchResults?.length > 0, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const resultId = current.state.theme.searchResults[0].resultId; + await pointerClick(dataButtonExpression("themeResultId", resultId), + "UC4 theme row single-click"); + await waitFor("UC4 theme preview", snapshot => + snapshot.state.isBusy === false && + snapshot.state.theme?.selectedResultId === resultId && + snapshot.state.theme?.selectedTheme != null && + snapshot.state.theme?.previewItems?.length > 0, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + for (const sortId of ["GAIN_DESC", "GAIN_ASC", "INPUT_ORDER"]) { + await pointerClick( + `document.querySelector('input[data-theme-sort][value="${sortId}"]')`, + `UC4 theme sort ${sortId}`); + await waitFor(`UC4 theme sort ${sortId} state`, snapshot => + snapshot.state.theme?.sortId === sortId, + { allowUiBusy: false }); + } + const beforeDouble = await readSnapshot(); + await pointerDoubleClick(dataButtonExpression("themeResultId", resultId), + "UC4 theme row double-click"); + await waitFor("UC4 theme double-click add", snapshot => + snapshot.state.playlist.length === beforeDouble.state.playlist.length + 1, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const actions = await clickAllEnabledPlaylistActions( + "button[data-theme-action-id]", "themeActionId", "UC4 action"); + recordFullUiDbCheck("screenChecks", "UC4 search, row, sort, double-click, all actions", { + actionButtons: actions.length + }); +} + +async function verifyOverseasScreen() { + let current = await waitForTabModel("overseasStocks", "overseas", "UC5 overseas tab"); + const fixedTarget = current.state.overseas.fixedIndexTargets[1] || + current.state.overseas.fixedIndexTargets[0]; + await pointerClick(dataButtonExpression("overseasTargetId", fixedTarget.targetId), + "UC5 fixed index row"); + await waitFor("UC5 fixed index selected", snapshot => + snapshot.state.overseas?.selectedFixedIndexId === fixedTarget.targetId, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const fixedActions = await clickAllEnabledPlaylistActions( + "button[data-overseas-action-id]", "overseasActionId", "UC5 fixed action"); + + await replaceText('document.getElementById("overseas-industry-search")', "", + "UC5 industry query"); + await pointerClick(dataButtonExpression("overseasSearch", "industry"), + "UC5 industry search"); + current = await waitFor("UC5 industry results", snapshot => + snapshot.state.isBusy === false && + snapshot.state.overseas?.industry?.results?.length > 0, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const industryId = current.state.overseas.industry.results[0].selectionId; + await pointerClick(dataButtonExpression("overseasSelectionId", industryId), + "UC5 industry result"); + await waitFor("UC5 industry selected", snapshot => + snapshot.state.overseas?.industry?.selectedId === industryId, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const industryActionIds = current.state.overseas.actions + .filter(action => action.source === "industry") + .map(action => action.actionId); + for (const actionId of industryActionIds) { + await clickAndWaitForPlaylistAdd(dataButtonExpression("overseasActionId", actionId), + `UC5 industry action ${actionId}`); + } + + await replaceText('document.getElementById("overseas-stock-search")', "", + "UC5 stock query"); + await pointerClick(dataButtonExpression("overseasSearch", "stock"), + "UC5 stock search"); + current = await waitFor("UC5 stock results", snapshot => + snapshot.state.isBusy === false && + snapshot.state.overseas?.stock?.results?.length > 0, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const stockId = current.state.overseas.stock.results[0].selectionId; + await pointerClick(dataButtonExpression("overseasSelectionId", stockId), + "UC5 stock result"); + current = await waitFor("UC5 stock selected", snapshot => + snapshot.state.overseas?.stock?.selectedId === stockId, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const stockActionIds = current.state.overseas.actions + .filter(action => action.source === "stock") + .map(action => action.actionId); + for (const actionId of stockActionIds) { + await clickAndWaitForPlaylistAdd(dataButtonExpression("overseasActionId", actionId), + `UC5 stock action ${actionId}`); + } + recordFullUiDbCheck("screenChecks", "UC5 fixed, industry, stock search/select/actions", { + fixedActions: fixedActions.length, + industryActions: industryActionIds.length, + stockActions: stockActionIds.length + }); +} + +async function verifyExpertAndTradingHaltScreens() { + let current = await waitForTabModel("expert", "expert", "UC6 expert tab"); + await replaceText('document.getElementById("expert-search")', "", "UC6 expert query"); + const expertSearchRevision = current.state.revision; + await pointerClick(dataButtonExpression("expertSearch", "true"), "UC6 expert search"); + current = await waitFor("UC6 expert results", snapshot => + snapshot.state.isBusy === false && + snapshot.state.revision > expertSearchRevision && + snapshot.state.expert?.search?.results?.length > 0, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const expertId = current.state.expert.search.results[0].selectionId; + await pointerClick(dataButtonExpression("expertSelectionId", expertId), + "UC6 expert row"); + await waitFor("UC6 expert preview", snapshot => + snapshot.state.isBusy === false && + snapshot.state.expert?.selectedExpert?.selectionId === expertId && + snapshot.state.expert?.preview?.items?.length > 0, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const expertActions = await clickAllEnabledPlaylistActions( + "button[data-expert-action-id]", "expertActionId", "UC6 action"); + recordFullUiDbCheck("screenChecks", "UC6 search, row, action", { + actionButtons: expertActions.length + }); + + current = await waitForTabModel("tradingHalt", "tradingHalt", "UC7 trading-halt tab"); + await replaceText('document.getElementById("halt-search")', "", "UC7 halt query"); + const haltSearchRevision = current.state.revision; + await pointerClick(dataButtonExpression("haltSearch", "true"), "UC7 halt search"); + current = await waitFor("UC7 halt results", snapshot => + snapshot.state.isBusy === false && + snapshot.state.revision > haltSearchRevision && + snapshot.state.tradingHalt?.status === "ready" && + snapshot.state.tradingHalt?.results?.length > 0, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const beforeSort = current.state.tradingHalt.nameSort; + await pointerClick(dataButtonExpression("haltSort", "true"), "UC7 halt sort"); + current = await waitFor("UC7 halt sort state", snapshot => + snapshot.state.tradingHalt?.nameSort !== beforeSort, + { allowUiBusy: false }); + const haltId = current.state.tradingHalt.results[0].selectionId; + await pointerClick(dataButtonExpression("haltSelectionId", haltId), "UC7 halt row"); + await waitFor("UC7 halt selection", snapshot => + snapshot.state.tradingHalt?.selected?.selectionId === haltId, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const haltActions = await clickAllEnabledPlaylistActions( + "button[data-halt-action-id]", "haltActionId", "UC7 action"); + recordFullUiDbCheck("screenChecks", "UC7 search, sort, row, all actions", { + actionButtons: haltActions.length + }); +} + +async function verifyGraphEActivationButtons() { + await executeManualFinancialCrud(); +} + +async function openFixedManualList(actionId, screen, audience, label) { + await waitForTabModel("index", "fixedCatalog", `${label} index tab`); + await pointerDoubleClick(dataButtonExpression("actionId", actionId), `${label} open`); + return await waitFor(`${label} loaded`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.manualLists?.isOpen === true && + snapshot.state.manualLists?.screen === screen && + (audience == null || snapshot.state.manualLists?.audience === audience) && + snapshot.dom.manualListModalHidden === false && + snapshot.state.manualLists?.writesQuarantined !== true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); +} + +async function closeManualList(label) { + await pointerClick('document.getElementById("manual-list-close")', label); + await waitFor(`${label} complete`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.manualLists?.isOpen === false && + snapshot.dom.manualListModalHidden === true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); +} + +function netSellProjection(snapshot) { + return snapshot.state.manualLists.netRows.map(row => ({ + leftName: row.leftName, + leftAmount: row.leftAmount, + rightName: row.rightName, + rightAmount: row.rightAmount + })); +} + +function viProjection(snapshot) { + return snapshot.state.manualLists.viItems.map(row => ({ + index: row.index, + name: row.name + })); +} + +async function verifyManualListButtonsAndPersistence() { + let current = await openFixedManualList( + "fixed-245", "net-sell", "individual", "FSell individual"); + const individualBaseline = netSellProjection(current); + if (individualBaseline.length !== 5 || current.state.manualLists.canSave !== true) { + failKnown("FSell individual baseline is not the original five-row saveable state."); + } + await pointerClickWithJavaScriptDialog( + modalButtonByTextExpression("원본 1회 가져오기"), + "FSell import cancel branch", + "confirm", + "원본 파일을 현재 빈 저장소로 한 번만 가져옵니다. 기존 파일은 덮어쓰지 않습니다.", + false); + const beforeRefresh = await readSnapshot(); + await pointerClick(modalButtonByTextExpression("재조회"), "FSell refresh"); + await waitFor("FSell refresh result", snapshot => + snapshot.state.isBusy === false && + snapshot.state.revision > beforeRefresh.state.revision && + snapshot.state.manualLists?.netRowsAreFresh === true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + for (const [label, audience] of [["외국인", "foreign"], ["기관", "institution"], + ["개인", "individual"]]) { + await pointerClick(modalButtonByTextExpression(label), `FSell ${label} tab`); + await waitFor(`FSell ${label} tab state`, snapshot => + snapshot.state.isBusy === false && + snapshot.state.manualLists?.audience === audience && + snapshot.state.manualLists?.netRows?.length === 5, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + } + await closeManualList("FSell cancel button"); + + await openFixedManualList("fixed-246", "net-sell", "foreign", "FSell foreign entry"); + await closeManualList("FSell foreign close"); + await openFixedManualList("fixed-247", "net-sell", "institution", + "FSell institution entry"); + await closeManualList("FSell institution close"); + + current = await openFixedManualList( + "fixed-245", "net-sell", "individual", "FSell save reopen"); + const beforeFsellSaveCount = current.state.playlist.length; + await pointerClick(modalButtonByTextExpression("확인"), "FSell confirm save"); + await waitFor("FSell save, readback, add, close", snapshot => + snapshot.state.isBusy === false && + snapshot.state.manualLists?.isOpen === false && + snapshot.dom.manualListModalHidden === true && + snapshot.state.playlist.length === beforeFsellSaveCount + 1, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + current = await openFixedManualList( + "fixed-245", "net-sell", "individual", "FSell fresh read"); + if (JSON.stringify(netSellProjection(current)) !== JSON.stringify(individualBaseline)) { + failKnown("FSell fresh read did not preserve the exact five-row baseline."); + } + await closeManualList("FSell fresh read close"); + recordFullUiDbCheck("screenChecks", "FSell all entry/tab/import/refresh/cancel/confirm buttons"); + recordFullUiDbCheck("databaseChecks", "FSell trusted file save/fresh read", { rows: 5 }); + + current = await openFixedManualList("fixed-262", "vi", null, "VIList entry"); + const viBaseline = viProjection(current); + if (viBaseline.length === 0 || current.state.manualLists.canSave !== true) { + failKnown("VIList baseline is empty or not saveable."); + } + const beforeViRefresh = await readSnapshot(); + await pointerClick(modalButtonByTextExpression("재조회"), "VIList refresh"); + await waitFor("VIList refresh result", snapshot => + snapshot.state.isBusy === false && + snapshot.state.revision > beforeViRefresh.state.revision && + snapshot.state.manualLists?.viItemsAreFresh === true, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await replaceText( + 'document.querySelector("#manual-list-workspace .manual-list-search input")', + "삼성출판사", "VIList stock query"); + await pointerClick(modalButtonByTextExpression("검색"), "VIList search"); + current = await waitFor("VIList search results", snapshot => + snapshot.state.isBusy === false && + snapshot.state.manualLists?.searchResults?.some(row => row.displayName === "삼성출판사"), + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const viResult = current.state.manualLists.searchResults + .find(row => row.displayName === "삼성출판사"); + const viResultExpression = `Array.from(document.querySelectorAll( + "#manual-list-workspace .manual-list-search-results button")) + .find(button => button.textContent.startsWith("삼성출판사 ·"))`; + await pointerClick(viResultExpression, "VIList result single-click"); + await waitFor("VIList result selected", snapshot => + snapshot.state.manualLists?.selectedSearchResultId === viResult.resultId, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + await pointerDoubleClick(viResultExpression, "VIList result double-click add"); + current = await waitFor("VIList temporary item add", snapshot => + snapshot.state.manualLists?.viItems?.length === viBaseline.length + 1, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + const temporaryItemId = current.state.manualLists.viItems.at(-1).itemId; + await pointerClick( + 'document.querySelector("#manual-list-workspace tbody tr:last-child button:nth-of-type(1)")', + "VIList temporary item UP"); + await waitFor("VIList temporary item UP state", snapshot => + snapshot.state.manualLists?.viItems?.at(-2)?.itemId === temporaryItemId, + { allowUiBusy: false }); + await pointerClick( + `Array.from(document.querySelectorAll("#manual-list-workspace tbody tr")) + .find((row, index) => index === ${viBaseline.length - 1}) + ?.querySelector("button:nth-of-type(2)")`, + "VIList temporary item DOWN"); + await waitFor("VIList temporary item DOWN state", snapshot => + snapshot.state.manualLists?.viItems?.at(-1)?.itemId === temporaryItemId, + { allowUiBusy: false }); + await pointerClick( + 'document.querySelector("#manual-list-workspace tbody tr:last-child button:nth-of-type(3)")', + "VIList temporary item delete"); + await waitFor("VIList temporary item delete state", snapshot => + snapshot.state.manualLists?.viItems?.length === viBaseline.length && + !snapshot.state.manualLists.viItems.some(row => row.itemId === temporaryItemId), + { allowUiBusy: false }); + await pointerClickWithJavaScriptDialog( + modalButtonByTextExpression("전체 삭제"), + "VIList delete-all cancel branch", + "confirm", + "VI 목록을 모두 지우시겠습니까?", + false); + const beforeViSave = await readSnapshot(); + await pointerClick(modalButtonByTextExpression("확인"), "VIList confirm save"); + await waitFor("VIList save, readback, add, close", snapshot => + snapshot.state.isBusy === false && + snapshot.state.manualLists?.isOpen === false && + snapshot.dom.manualListModalHidden === true && + snapshot.state.playlist.length === beforeViSave.state.playlist.length + 1, + { timeoutMilliseconds: 60_000, allowUiBusy: true }); + current = await openFixedManualList("fixed-262", "vi", null, "VIList fresh read"); + if (JSON.stringify(viProjection(current)) !== JSON.stringify(viBaseline)) { + failKnown("VIList fresh read did not preserve the exact baseline."); + } + await closeManualList("VIList fresh read close"); + recordFullUiDbCheck("screenChecks", "VIList search/select/double-click/up/down/delete/all/refresh/confirm buttons"); + recordFullUiDbCheck("databaseChecks", "VIList trusted file save/fresh read", { + rows: viBaseline.length + }); +} + +async function clearScreenValidationPlaylist() { + const current = await readSnapshot(); + if (current.state.playlist.length === 0) return; + await pointerClickWithJavaScriptDialog( + 'document.getElementById("playlist-delete-all")', + "screen validation playlist cleanup", + "confirm", + "송출 리스트를 모두 삭제하겠습니까?", + true); + await waitFor("screen validation playlist cleanup state", snapshot => + snapshot.state.isBusy === false && snapshot.state.playlist.length === 0, + { allowUiBusy: false }); +} + +async function executeScreenInventory() { + await verifyFixedAndIndustryScreens(); + await verifyComparisonScreen(); + await verifyThemeScreen(); + await verifyOverseasScreen(); + await verifyExpertAndTradingHaltScreens(); + await verifyGraphEActivationButtons(); + await verifyManualListButtonsAndPersistence(); + for (const [tabId, stateProperty] of [ + ["overseas", "fixedCatalog"], ["exchange", "fixedCatalog"], + ["index", "fixedCatalog"], ["kospiIndustry", "industry"], + ["kosdaqIndustry", "industry"], ["comparison", "comparison"], + ["theme", "theme"], ["overseasStocks", "overseas"], + ["expert", "expert"], ["tradingHalt", "tradingHalt"] + ]) { + await waitForTabModel(tabId, stateProperty, `final screen inventory ${tabId}`); + await captureVisibleScreenInventory(tabId); + } + await clearScreenValidationPlaylist(); + recordFullUiDbCheck("screenChecks", "UC1-UC7 final packaged button inventory"); +} + +async function executeFullUiDbSequence() { + if (!fullUiDbProfile) return; + if (configuration.scope === "all" || configuration.scope === "plist") { + await executeNamedPlaylistCrud(); + } + if (configuration.scope === "all" || configuration.scope === "graphe") { + await executeManualFinancialCrud(); + } + if (configuration.scope === "all" || configuration.scope === "catalog") { + await executeOperatorCatalogCrud(); + } + if (configuration.scope === "all" || configuration.scope === "screens") { + await executeScreenInventory(); + } + evidence.fullUiDb.result = "PASS"; + evidence.fullUiDb.cleanupVerified = true; + const current = await readSnapshot(); + if (!current.dom.namedModalHidden || !current.dom.namedConfirmationHidden || + !current.dom.manualFinancialModalHidden || !current.dom.manualListModalHidden || + !current.dom.operatorCatalogModalHidden) { + failKnown("A child modal remained open after the full UI/DB sequence."); + } + evidence.finalSnapshot = current; +} + try { const target = await discoverExactTarget(); const endpoint = validateWebSocketTarget(target); @@ -1957,6 +4134,8 @@ try { evidence.target.url = target.url; await connect(endpoint); await executeSafeSequence(); + await executeDryRunPlayoutSequence(); + await executeFullUiDbSequence(); await captureScreenshot(); const sealedSnapshot = await readSnapshot(); assertSafety(sealedSnapshot, "post-screenshot safety seal", false); diff --git a/scripts/Test-LegacyParityWeb.ps1 b/scripts/Test-LegacyParityWeb.ps1 index dd702c6..1249c1d 100644 --- a/scripts/Test-LegacyParityWeb.ps1 +++ b/scripts/Test-LegacyParityWeb.ps1 @@ -32,14 +32,23 @@ if (-not [IO.Path]::IsPathRooted($NodePath) -or $repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $appScript = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\Web\app.js' -$testFile = Join-Path $repositoryRoot 'tests\LegacyParityWeb\thin-client.test.cjs' +$testRoot = Join-Path $repositoryRoot 'tests\LegacyParityWeb' +$testFiles = @( + Get-ChildItem -LiteralPath $testRoot -Filter '*.test.cjs' -File | + Sort-Object -Property FullName | + Select-Object -ExpandProperty FullName +) + +if ($testFiles.Count -eq 0) { + throw 'No Legacy parity Web test files were found.' +} & $NodePath --check $appScript if ($LASTEXITCODE -ne 0) { throw "Legacy parity JavaScript syntax validation failed with exit code $LASTEXITCODE." } -& $NodePath --test $testFile +& $NodePath --test @testFiles if ($LASTEXITCODE -ne 0) { throw "Legacy parity Web tests failed with exit code $LASTEXITCODE." } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedForeignStockRestore.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedForeignStockRestore.cs index f8c7d9d..0b37d97 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedForeignStockRestore.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedForeignStockRestore.cs @@ -454,10 +454,8 @@ public sealed class LegacyNamedForeignStockRestoreService var punctuationIssue = character switch { '+' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedPlus, - '&' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAmpersand, '\'' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedApostrophe, ',' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedComma, - '(' or ')' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedParenthesis, '#' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedHash, '%' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedPercent, '*' => LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsterisk, @@ -470,7 +468,11 @@ public sealed class LegacyNamedForeignStockRestoreService return punctuationIssue.Value; } - if (character is not ('@' or '.' or '_' or '$' or ':' or '-' or ' ')) + // Current Oracle identities include ampersands and parenthesized + // provider symbols. They are safe bound values and cannot collide + // with the '|' or '^' serialization delimiters guarded above. + if (character is not ('@' or '.' or '_' or '$' or ':' or '-' or ' ' or + '&' or '(' or ')')) { return LegacyNamedForeignStockDatabaseIssue.SymbolUnsupportedAsciiPunctuation; } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasStockSearch.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasStockSearch.cs index 72f6532..d29c7a4 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasStockSearch.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasStockSearch.cs @@ -169,8 +169,18 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi private static string ReadCanonical(DataRow row, int ordinal, int maximumLength) { - if (row[ordinal] is not string value || value.Length < 1 || value.Length > maximumLength || - !string.Equals(value, value.Trim(), StringComparison.Ordinal) || !IsSafeText(value)) + if (row[ordinal] is not string rawValue || rawValue.Length < 1 || + rawValue.Length > maximumLength || !IsSafeText(rawValue)) + { + throw InvalidData("value"); + } + + // The current legacy master contains one US/TW row whose name and symbol + // are padded with an outer ASCII space. The original UC5 accepted those + // values. Normalize only that closed padding form; the identity set below + // still rejects any collision created by normalization. + var value = rawValue.Trim(' '); + if (value.Length < 1 || value.Length > maximumLength) { throw InvalidData("value"); } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs index d1d2a9f..c33788e 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs @@ -616,10 +616,18 @@ internal static class ThemeQueries LEFT JOIN ( SELECT i.SB_M_CODE, i.SB_I_NAME, i.SB_I_CODE, i.SB_I_INDEX FROM SB_ITEM i - JOIN v_all_stock s - ON SUBSTRING(i.SB_I_CODE, 2, 12) = s.F_STOCK_CODE - WHERE s.F_STOP_GUBUN = 'N' - ) a ON l.SB_CODE = a.SB_M_CODE + JOIN ( + SELECT CONCAT('X', s.F_STOCK_CODE) ITEM_CODE + FROM N_STOCK s + WHERE s.F_STOP_GUBUN = 'N' + UNION ALL + SELECT CONCAT('T', s.F_STOCK_CODE) ITEM_CODE + FROM N_KOSDAQ_STOCK s + WHERE s.F_STOP_GUBUN = 'N' + ) current_nxt + ON BINARY i.SB_I_CODE = BINARY current_nxt.ITEM_CODE + ) a ON BINARY l.SB_CODE = BINARY a.SB_M_CODE + AND l.SB_CODE REGEXP BINARY '^[0-9]{8}$' WHERE l.SB_CODE = @theme_code AND l.SB_TITLE = @theme_title AND l.SB_MARKET = 'NXT' diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneBuilder.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneBuilder.cs index 621f774..485c292 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneBuilder.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/CandleSceneBuilder.cs @@ -70,6 +70,23 @@ public static class CandleCutAliasMetadata CandleCutAlias.Cut8056 => 1464d, _ => throw new LegacySceneDataException("Unknown candle cut alias.") }; + + internal static int GetAssetObjectCapacity(CandleCutAlias alias) => alias switch + { + CandleCutAlias.Cut8035 => 78, + CandleCutAlias.Cut8061 => 5, + CandleCutAlias.Cut8040 => 20, + CandleCutAlias.Cut8046 => 60, + CandleCutAlias.Cut8051 => 120, + CandleCutAlias.Cut8056 => 240, + _ => throw new LegacySceneDataException("Unknown candle cut alias.") + }; + + internal static bool SupportsMovingAveragePaths(CandleCutAlias alias) => + alias != CandleCutAlias.Cut8035; + + internal static bool SupportsGuide(CandleCutAlias alias) => alias is not + (CandleCutAlias.Cut8035 or CandleCutAlias.Cut8061); } public sealed record CandleQuoteData( @@ -101,7 +118,6 @@ public sealed record S8010SceneData( public sealed class S8010SceneMutationBuilder : ILegacySceneMutationBuilder { - private const int MaximumCandleCount = 240; private const double GraphStartX = -730.57d; private const double GraphEndX = 738.65d; private const double TagMinimumX = -657.57d; @@ -122,13 +138,25 @@ public sealed class S8010SceneMutationBuilder : ILegacySceneMutationBuilder(540 + (rows.Count * 12)) + var mutations = new List( + (CandleCutAliasMetadata.GetAssetObjectCapacity(data.CutAlias) * 2) + + 60 + (rows.Count * 12)) { - new PlayoutSetValue("title", InitialTitle(displayName, data.ChartMode)), - // A path mutation with no points reproduces Begin/Clear/End from the legacy setup. - new PlayoutSetPathPoints("ma5", Array.Empty()), - new PlayoutSetPathPoints("ma20", Array.Empty()) + new PlayoutSetValue("title", InitialTitle(displayName, data.ChartMode)) }; + if (CandleCutAliasMetadata.SupportsMovingAveragePaths(data.CutAlias)) + { + // A path mutation with no points reproduces Begin/Clear/End from the legacy setup. + mutations.Add(new PlayoutSetPathPoints("ma5", Array.Empty())); + mutations.Add(new PlayoutSetPathPoints("ma20", Array.Empty())); + } + else + { + // The intraday asset exposes ma5/ma20 as ordinary visible objects, + // not editable paths. Hide the baked defaults without PATH_POINT. + mutations.Add(new PlayoutSetVisible("ma5", false)); + mutations.Add(new PlayoutSetVisible("ma20", false)); + } AddResolvedTitleAndUnit( mutations, @@ -138,7 +166,7 @@ public sealed class S8010SceneMutationBuilder : ILegacySceneMutationBuilder @@ -379,17 +407,19 @@ public sealed class S8010SceneMutationBuilder : ILegacySceneMutationBuilder mutations, + CandleCutAlias alias, CandleChartMode mode) { mutations.Add(new PlayoutSetVisible( "volrumeG", mode == CandleChartMode.PriceWithVolume)); - for (var index = 1; index <= MaximumCandleCount; index++) + var capacity = CandleCutAliasMetadata.GetAssetObjectCapacity(alias); + for (var index = 1; index <= capacity; index++) { mutations.Add(new PlayoutSetVisible("volume" + index, false)); } - for (var index = 1; index <= MaximumCandleCount; index++) + for (var index = 1; index <= capacity; index++) { mutations.Add(new PlayoutSetVisible("candle" + index, false)); } @@ -617,17 +647,28 @@ public sealed class S8010SceneMutationBuilder : ILegacySceneMutationBuilder())); mutations.Add(new PlayoutSetPathPoints( "ma20", data.ShowMovingAverage20 ? paths.MovingAverage20 : Array.Empty())); - mutations.Add(new PlayoutSetVisible("guide", data.ShowMovingAverage20)); + if (CandleCutAliasMetadata.SupportsGuide(data.CutAlias)) + { + mutations.Add(new PlayoutSetVisible("guide", data.ShowMovingAverage20)); + } if (!SupportsMovingAverages(data.MarketTarget)) { - mutations.Add(new PlayoutSetVisible("guide", false)); + if (CandleCutAliasMetadata.SupportsGuide(data.CutAlias)) + { + mutations.Add(new PlayoutSetVisible("guide", false)); + } mutations.Add(new PlayoutSetPathPoints("ma5", Array.Empty())); mutations.Add(new PlayoutSetPathPoints("ma20", Array.Empty())); } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldSceneBuilders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldSceneBuilders.cs index 1e141f0..ca2f435 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldSceneBuilders.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ComparisonAndYieldSceneBuilders.cs @@ -348,8 +348,21 @@ public sealed class S5087SceneMutationBuilder : ILegacySceneMutationBuilder mutation switch + { + PlayoutSetValue value when value.ObjectName is + "date1_2" or "date1_4" or "date2_2" or "date2_4" or + "highPrice1" or "lowPrice1" or "highPrice2" or "lowPrice2" => false, + PlayoutSetPosition position when position.ObjectName is + "baseline1" or "baseline2" or + "highTag1" or "lowTag1" or "highTag2" or "lowTag2" => false, + _ => true + }; } public enum YieldGraphPeriod diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs index 8087806..0c609aa 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs @@ -136,7 +136,7 @@ public sealed class LegacyParameterizedSceneRequestResolver // MainForm selects the special s5032 builder whenever the combined subject // contains the Korean futures label. s5032 then normalizes futures to side two. if (subject.Contains("선물", StringComparison.Ordinal) || - subject.Contains("FUTURES", StringComparison.Ordinal)) + subject.Contains("FUTURES", StringComparison.OrdinalIgnoreCase)) { var values = SplitPair(subject, ','); // Keep this branch closed to the market targets that s5032 renders with diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneBuilders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneBuilders.cs index 5603d45..e19dd64 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneBuilders.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneBuilders.cs @@ -59,7 +59,13 @@ public sealed class S5074SceneMutationBuilder : ILegacySceneMutationBuilder "s5074"; public IReadOnlyList Build(PagedQuoteSceneData data) => - PagedQuoteMutations.Build(data, ScenePageSize.Five, useWideRateVariants: false); + PagedQuoteMutations.Build( + data, + ScenePageSize.Five, + useWideRateVariants: false, + includeTitle: true, + includeUnit: true, + includeColumns: true); } public sealed class S5077SceneMutationBuilder : ILegacySceneMutationBuilder @@ -67,7 +73,13 @@ public sealed class S5077SceneMutationBuilder : ILegacySceneMutationBuilder "s5077"; public IReadOnlyList Build(PagedQuoteSceneData data) => - PagedQuoteMutations.Build(data, ScenePageSize.Six, useWideRateVariants: true); + PagedQuoteMutations.Build( + data, + ScenePageSize.Six, + useWideRateVariants: true, + includeTitle: false, + includeUnit: false, + includeColumns: false); } public sealed class S5088SceneMutationBuilder : ILegacySceneMutationBuilder @@ -75,7 +87,13 @@ public sealed class S5088SceneMutationBuilder : ILegacySceneMutationBuilder "s5088"; public IReadOnlyList Build(PagedQuoteSceneData data) => - PagedQuoteMutations.Build(data, ScenePageSize.Twelve, useWideRateVariants: true); + PagedQuoteMutations.Build( + data, + ScenePageSize.Twelve, + useWideRateVariants: true, + includeTitle: true, + includeUnit: true, + includeColumns: false); } internal static class PagedQuoteMutations @@ -86,7 +104,10 @@ internal static class PagedQuoteMutations public static IReadOnlyList Build( PagedQuoteSceneData data, ScenePageSize pageSize, - bool useWideRateVariants) + bool useWideRateVariants, + bool includeTitle, + bool includeUnit, + bool includeColumns) { ArgumentNullException.ThrowIfNull(data); var title = LegacySceneBuilderGuard.Text(data.Title, nameof(data.Title)); @@ -144,9 +165,16 @@ internal static class PagedQuoteMutations mutations.Add(new PlayoutSetVisible("row" + slot, false)); } - mutations.Add(new PlayoutSetValue("title", BuildTitle(title, data.TitleStyle))); + // The authoritative 5077.t2s has no global title/header objects, while + // 5088.t2s has title/unit but no column1..3. Keep the shared row logic, + // but never send mutations for objects absent from the selected asset. + if (includeTitle) + { + mutations.Add(new PlayoutSetValue("title", BuildTitle(title, data.TitleStyle))); + } + AddMarketMark(mutations, data.Branch, data.NxtSession); - AddHeader(mutations, data.HeaderStyle); + AddHeader(mutations, data.HeaderStyle, includeUnit, includeColumns); for (var slot = 1; slot <= size; slot++) { @@ -228,7 +256,9 @@ internal static class PagedQuoteMutations private static void AddHeader( ICollection mutations, - PagedQuoteHeaderStyle style) + PagedQuoteHeaderStyle style, + bool includeUnit, + bool includeColumns) { var values = style switch { @@ -240,10 +270,17 @@ internal static class PagedQuoteMutations (Unit: "\uB2E8\uC704 : \uC6D0", Column1: "\uB9E4\uC218\uAC00", Column2: "\uD604\uC7AC\uAC00", Column3: "\uC218\uC775\uB960"), _ => throw new LegacySceneDataException("Unknown paged quote header style.") }; - mutations.Add(new PlayoutSetValue("unit", values.Unit)); - mutations.Add(new PlayoutSetValue("column1", values.Column1)); - mutations.Add(new PlayoutSetValue("column2", values.Column2)); - mutations.Add(new PlayoutSetValue("column3", values.Column3)); + if (includeUnit) + { + mutations.Add(new PlayoutSetValue("unit", values.Unit)); + } + + if (includeColumns) + { + mutations.Add(new PlayoutSetValue("column1", values.Column1)); + mutations.Add(new PlayoutSetValue("column2", values.Column2)); + mutations.Add(new PlayoutSetValue("column3", values.Column3)); + } } private static void AddPopulatedRow( diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneBuilders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneBuilders.cs index 7a7df51..a9e2840 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneBuilders.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneBuilders.cs @@ -1154,7 +1154,7 @@ public enum S8001Sector TextilesAndApparel, ElectricalAndElectronics, Chemicals, - NonMetallicMinerals, + Pharmaceuticals, MachineryAndEquipment, ItComponents, Transportation, @@ -1242,7 +1242,7 @@ public sealed class S8001SceneMutationBuilder : ILegacySceneMutationBuilder sector is S8001Sector.MachineryAndEquipment or S8001Sector.ItComponents or S8001Sector.Transportation or S8001Sector.PaperAndWood or @@ -1266,7 +1266,7 @@ public sealed class S8001SceneMutationBuilder : ILegacySceneMutationBuilder "섬유의복", S8001Sector.ElectricalAndElectronics => "전기전자", S8001Sector.Chemicals => "화학", - S8001Sector.NonMetallicMinerals => "비금속광물", + S8001Sector.Pharmaceuticals => "의약품", S8001Sector.MachineryAndEquipment => "기계장비", S8001Sector.ItComponents => "IT부품", S8001Sector.Transportation => "운송", diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneDataLoaders.cs index 03ffa5b..062cd84 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneDataLoaders.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneDataLoaders.cs @@ -913,7 +913,9 @@ public sealed class S8001SceneDataLoader "006" => S8001Sector.TextilesAndApparel, "007" => S8001Sector.PaperAndWood, "008" => S8001Sector.Chemicals, - "009" => S8001Sector.NonMetallicMinerals, + // The live taxonomy now names code 009 "제약" while the authoritative + // 8001 cut keeps the legacy on-air object name "의약품". + "009" => S8001Sector.Pharmaceuticals, "011" => S8001Sector.SteelAndMetal, "012" => S8001Sector.Machinery, "013" => S8001Sector.ElectricalAndElectronics, diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs index db37b77..d6b29b6 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs @@ -148,11 +148,34 @@ public sealed class S5011SceneMutationBuilder : ILegacySceneMutationBuilder mutations, + ScenePriceDirection direction) + { + foreach (var mutation in LegacyPriceDirectionMutations.Build(direction, 0)) + { + // The authoritative 5011.t2s has the bg/changePrice/rate/percent + // variants but no wonText variants or pattern asset object. + if (mutation is PlayoutSetVisible { ObjectName: var objectName } && + objectName.StartsWith("wonText", StringComparison.Ordinal)) + { + continue; + } + + if (mutation is PlayoutSetAssetValue) + { + continue; + } + + mutations.Add(mutation); + } + } + private static void AddDetailMutations( ICollection mutations, S5011DetailData detail) diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/RegisteredLegacySceneCueProvider.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/RegisteredLegacySceneCueProvider.cs index 62b504c..5ef9795 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/RegisteredLegacySceneCueProvider.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/RegisteredLegacySceneCueProvider.cs @@ -173,9 +173,10 @@ public sealed class RegisteredLegacySceneCueProvider : } var builderMutations = _builders.Build(definition.BuilderKey, loaded.Data); - var mutations = new List(builderMutations.Count + 2); + var assetMutations = ApplyAssetContract(entry.CutCode, builderMutations); + var mutations = new List(assetMutations.Count + 2); AddBackgroundMutations(mutations, options); - mutations.AddRange(builderMutations); + mutations.AddRange(assetMutations); return new LegacySceneCuePage( new PlayoutCue( @@ -250,6 +251,47 @@ public sealed class RegisteredLegacySceneCueProvider : _ => throw new LegacySceneDataException("The scene catalog contains an invalid page size.") }; + private static IReadOnlyList ApplyAssetContract( + string cutCode, + IReadOnlyList mutations) + { + if (string.Equals(cutCode, "5032", StringComparison.Ordinal)) + { + return mutations.Where(static mutation => mutation switch + { + PlayoutSetAssetValue { ObjectName: "pattern" } => false, + PlayoutSetVisible visible when IsAbsent5032Decoration(visible.ObjectName) => false, + _ => true + }).ToArray(); + } + + if (!string.Equals(cutCode, "8032", StringComparison.Ordinal)) + { + return mutations; + } + + return mutations.Where(static mutation => mutation switch + { + PlayoutSetAssetValue { ObjectName: "pattern1" or "pattern2" } => false, + PlayoutSetValue { ObjectName: "wonText1" or "wonText2" } => false, + PlayoutSetVisible visible when IsAbsent8032Variant(visible.ObjectName) => false, + _ => true + }).ToArray(); + } + + private static bool IsAbsent8032Variant(string objectName) => objectName is + "percent1_1" or "percent2_1" or "percent3_1" or + "percent1_2" or "percent2_2" or "percent3_2" or + "wonText1_1" or "wonText2_1" or "wonText3_1" or + "wonText1_2" or "wonText2_2" or "wonText3_2"; + + private static bool IsAbsent5032Decoration(string objectName) => objectName is + "bg1_" or "changePrice1_" or "wonText1_" or "rate1_" or "percent1_" or + "bg2_" or "changePrice2_" or "wonText2_" or "rate2_" or "percent2_" or + "bg3_" or "changePrice3_" or "wonText3_" or "rate3_" or "percent3_" or + "unit1_" or "unit2_" or "unit3_" or + "price1_" or "price2_" or "price3_"; + internal static LegacySceneCueCompositionOptions ValidateOptions( LegacySceneCueCompositionOptions options) { diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/TabularMarketSceneBuilders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/TabularMarketSceneBuilders.cs index a4a7962..0ffc93c 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/TabularMarketSceneBuilders.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/TabularMarketSceneBuilders.cs @@ -354,10 +354,11 @@ public sealed class S5037SceneMutationBuilder : ILegacySceneMutationBuilder(44) { new PlayoutSetValue("title", stockName), - new PlayoutSetValue("unit", "\uB2E8\uC704 : \uC6D0"), new PlayoutSetValue("price", FormatInteger(data.CurrentPrice)), new PlayoutSetValue("changePrice", FormatInteger(data.ChangePrice)), new PlayoutSetValue("rate", data.Rate.ToString("##0.00", CultureInfo.InvariantCulture)) @@ -457,10 +458,11 @@ public sealed class S8003SceneMutationBuilder : ILegacySceneMutationBuilder(40) { new PlayoutSetValue("title", stockName), - new PlayoutSetValue("unit", "\uB2E8\uC704 : \uC6D0"), new PlayoutSetValue("price", FormatInteger(data.CurrentPrice)), new PlayoutSetValue("changePrice", FormatInteger(data.ChangePrice)), new PlayoutSetValue("rate", data.Rate.ToString("##0.00", CultureInfo.InvariantCulture)) diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs index 2000543..c71dc0f 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs @@ -4,6 +4,10 @@ using CoreMovingAverageSelection = MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageSelection; using CoreMovingAverageSource = MBN_STOCK_WEBVIEW.Core.Playout.Scenes.MutableLegacySceneMovingAverageSelectionSource; +using CoreSceneSelection = + MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection; +using S5011DetailBranch = + MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5011DetailBranch; namespace MBN_STOCK_WEBVIEW.LegacyApplication; @@ -403,8 +407,17 @@ public sealed class LegacyOperatorController bool shift, int x, int y, - long timestampMilliseconds) + long timestampMilliseconds, + bool allowAppend = true) { + if (!allowAppend) + { + // Selection remains available while a scene is prepared or on air, but + // an on-air click must neither activate a cut nor prime the next IDLE + // click as the second half of a legacy pointer double-click. + _previousPointerTimestamp = null; + } + if (_selectedStockIndex is null || !IsCutIndex(physicalIndex)) { return CreateSnapshot(); @@ -413,7 +426,8 @@ public sealed class LegacyOperatorController var gap = _previousPointerTimestamp is long previousTimestamp ? timestampMilliseconds - previousTimestamp : long.MaxValue; - if (_previousPointerTimestamp is not null && + if (allowAppend && + _previousPointerTimestamp is not null && gap >= 0 && gap < DoubleClickWindowMilliseconds && _previousPointerRow == physicalIndex && _previousPointerX == x && @@ -425,7 +439,7 @@ public sealed class LegacyOperatorController } else { - _previousPointerTimestamp = timestampMilliseconds; + _previousPointerTimestamp = allowAppend ? timestampMilliseconds : null; } _previousPointerRow = physicalIndex; @@ -3409,7 +3423,18 @@ public sealed class LegacyOperatorController var namedPlaylist = await _namedPlaylistWorkflow.LoadSelectedAsync( cancellationToken).ConfigureAwait(false); var loadedRows = namedPlaylist.Rows - .Select(CreateNamedPlaylistOperatorRow) + .Select(row => + { + try + { + return CreateNamedPlaylistOperatorRow(row); + } + catch (InvalidDataException exception) + { + exception.Data["NamedPlaylistItemIndex"] = row.ItemIndex; + throw; + } + }) .ToArray(); _playlist.Clear(); @@ -3422,10 +3447,13 @@ public sealed class LegacyOperatorController { throw; } - catch (InvalidDataException) + catch (InvalidDataException exception) { + var rowNumber = exception.Data["NamedPlaylistItemIndex"] is int itemIndex + ? $" {itemIndex + 1}행에" + : string.Empty; _dialog = new LegacyDialogState( - "DB 재생목록에 현재 버전에서 안전하게 식별할 수 없는 컷이 있어 기존 송출 목록을 유지했습니다."); + $"DB 재생목록{rowNumber} 현재 버전에서 안전하게 식별할 수 없는 컷이 있어 기존 송출 목록을 유지했습니다."); _statusMessage = _dialog.Message; _statusKind = LegacyOperatorStatusKind.Error; } @@ -4176,12 +4204,33 @@ public sealed class LegacyOperatorController { var selection = row.Selection; var sceneAlias = ResolveNamedPlaylistSceneAlias(selection); - var playoutSelection = new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection( - selection.GroupCode, - selection.Subject, - selection.GraphicType, - selection.Subtype, - selection.DataCode); + var playoutSelection = TryCreateNamedFixedSelection( + selection, + out var fixedSelection, + out _) + ? fixedSelection + : TryCreateNamedStockDetailSelection(selection, out var stockDetailSelection) + ? stockDetailSelection + : TryCreateNamedIndustrySelection(selection, out var industrySelection) + ? industrySelection + : TryCreateNamedComparisonSelection(selection, out var comparisonSelection) + ? comparisonSelection + : TryCreateNamedManualFinancialSelection( + selection, + out var manualFinancialSelection) + ? manualFinancialSelection + : TryCreateNamedManualNetSellSelection( + selection, + out var manualNetSellSelection) + ? manualNetSellSelection + : TryCreateNamedVersionedViSelection(selection, out var viSelection) + ? viSelection + : new CoreSceneSelection( + selection.GroupCode, + selection.Subject, + selection.GraphicType, + selection.Subtype, + selection.DataCode); var rawCutLabel = selection.Subtype.Length == 0 ? selection.GraphicType : $"{selection.GraphicType}_{selection.Subtype}"; @@ -4226,26 +4275,9 @@ public sealed class LegacyOperatorController } } - var fixedMarket = selection.GroupCode switch + if (TryCreateNamedFixedSelection(selection, out _, out var fixedAction)) { - "해외지수" => LegacyFixedMarket.Overseas, - "환율" => LegacyFixedMarket.Exchange, - "주요지수" => LegacyFixedMarket.Index, - _ => (LegacyFixedMarket?)null - }; - if (fixedMarket is { } market) - { - var actions = _fixedCatalog.GetActions(market) - .Where(action => - string.Equals(action.Label, selection.Subject, StringComparison.Ordinal) && - string.Equals(action.Section, selection.GraphicType, StringComparison.Ordinal) && - string.Equals(action.Detail, selection.Subtype, StringComparison.Ordinal)) - .Take(2) - .ToArray(); - if (actions.Length == 1) - { - return actions[0].Code; - } + return fixedAction!.Code; } var industryMarket = selection.GroupCode switch @@ -4294,6 +4326,23 @@ public sealed class LegacyOperatorController return "5074"; } + // FSell rows are saved with the closed canonical selection emitted by the + // native manual-list workflow. Older PList rows use the original visible + // seven-field identity; the scene resolver accepts both shapes and performs + // the fresh trusted-file read again before any cue can reach Tornado2. + if (TryCreateNamedManualNetSellSelection(selection, out _)) + { + return "5025"; + } + + // A versioned VI row carries no path or operator-controlled scene name. The + // native cue provider reopens the trusted snapshot and requires this exact + // version at PREPARE, so alias restoration remains fail-closed. + if (TryCreateNamedVersionedViSelection(selection, out _)) + { + return "5074"; + } + if (string.Equals(selection.GroupCode, "비교", StringComparison.Ordinal)) { var alias = selection.GraphicType switch @@ -4327,6 +4376,474 @@ public sealed class LegacyOperatorController "The named-playlist row does not map to a registered scene alias."); } + private bool TryCreateNamedFixedSelection( + LegacyNamedPlaylistSelectionView selection, + out CoreSceneSelection normalized, + out LegacyFixedAction? action) + { + normalized = null!; + action = null; + var fixedMarket = selection.GroupCode switch + { + "해외지수" => LegacyFixedMarket.Overseas, + "환율" => LegacyFixedMarket.Exchange, + "주요지수" => LegacyFixedMarket.Index, + _ => (LegacyFixedMarket?)null + }; + if (fixedMarket is not { } market) + { + return false; + } + + var matches = _fixedCatalog.GetActions(market) + .Where(candidate => + string.Equals(candidate.Label, selection.Subject, StringComparison.Ordinal) && + string.Equals(candidate.Section, selection.GraphicType, StringComparison.Ordinal) && + string.Equals(candidate.Detail, selection.Subtype, StringComparison.Ordinal)) + .Take(2) + .ToArray(); + if (matches.Length != 1 || !matches[0].Available) + { + return false; + } + + action = matches[0]; + var materialized = _fixedCatalog.Materialize(action.Id, DateTimeOffset.Now).Selection; + normalized = new CoreSceneSelection( + materialized.GroupCode, + materialized.Subject, + materialized.GraphicType, + materialized.Subtype, + selection.DataCode); + return true; + } + + private static bool TryCreateNamedStockDetailSelection( + LegacyNamedPlaylistSelectionView selection, + out CoreSceneSelection normalized) + { + normalized = null!; + if (selection.GroupCode is not ("코스피" or "코스닥") || + !string.Equals(selection.GraphicType, "1열판상세", StringComparison.Ordinal)) + { + return false; + } + + var branch = selection.Subtype switch + { + "액면가" => nameof(S5011DetailBranch.FaceValue), + "PBR" => nameof(S5011DetailBranch.Valuation), + "거래량" => nameof(S5011DetailBranch.Volume), + _ => null + }; + if (branch is null) + { + return false; + } + + try + { + LegacyComparisonValueGuard.RequireText( + selection.Subject, + 200, + nameof(selection.Subject)); + LegacyComparisonValueGuard.RequireStockCode( + selection.DataCode, + nameof(selection.DataCode)); + } + catch (ArgumentException) + { + return false; + } + + normalized = new CoreSceneSelection( + selection.GroupCode, + selection.Subject, + selection.GraphicType, + branch, + selection.DataCode); + return true; + } + + private static bool TryCreateNamedIndustrySelection( + LegacyNamedPlaylistSelectionView selection, + out CoreSceneSelection normalized) + { + normalized = null!; + var market = selection.GroupCode switch + { + "업종_코스피" => MMoneyCoderSharp.Data.IndustryMarket.Kospi, + "업종_코스닥" => MMoneyCoderSharp.Data.IndustryMarket.Kosdaq, + _ => (MMoneyCoderSharp.Data.IndustryMarket?)null + }; + if (market is not { } exactMarket) + { + return false; + } + + var rawLabel = selection.Subtype.Length == 0 + ? selection.GraphicType + : $"{selection.GraphicType}_{selection.Subtype}"; + var actions = LegacyIndustryActionCatalog.GetActions(exactMarket) + .Where(action => string.Equals(action.Label, rawLabel, StringComparison.Ordinal)) + .Take(2) + .ToArray(); + if (actions.Length != 1) + { + return false; + } + + try + { + LegacyComparisonValueGuard.RequireText( + selection.Subject, + 256, + nameof(selection.Subject)); + } + catch (ArgumentException) + { + return false; + } + + var action = actions[0]; + var industryGroup = exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi + ? "INDUSTRY_KOSPI" + : "INDUSTRY_KOSDAQ"; + var marketName = exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi + ? "KOSPI" + : "KOSDAQ"; + normalized = action.Kind switch + { + LegacyIndustryActionKind.Single => new CoreSceneSelection( + industryGroup, + selection.Subject, + "1열판", + "CURRENT", + string.Empty), + LegacyIndustryActionKind.Pair => new CoreSceneSelection( + industryGroup, + selection.Subject, + "2열판", + string.Empty, + string.Empty), + LegacyIndustryActionKind.Yield => new CoreSceneSelection( + industryGroup, + selection.Subject, + "YIELD_GRAPH", + action.YieldPeriod!, + string.Empty), + LegacyIndustryActionKind.Candle => new CoreSceneSelection( + exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi + ? "KOSPI_INDUSTRY" + : "KOSDAQ_INDUSTRY", + selection.Subject, + string.Empty, + action.CandleMode == LegacyIndustryCandleMode.Volume ? "VOLUME" : "PRICE", + string.Empty), + LegacyIndustryActionKind.FiveRow when + DateOnly.TryParseExact( + selection.DataCode, + "yyyy-MM-dd", + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, + out _) => new CoreSceneSelection( + exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi + ? "PAGED_DOMESTIC_KOSPI" + : "PAGED_DOMESTIC_KOSDAQ", + "INDEX", + string.Empty, + "INDUSTRY", + selection.DataCode), + LegacyIndustryActionKind.Square => new CoreSceneSelection( + industryGroup, + marketName, + "네모그래프", + string.Empty, + string.Empty), + LegacyIndustryActionKind.Sector => new CoreSceneSelection( + industryGroup, + marketName, + "SECTOR_INDEX", + string.Empty, + string.Empty), + _ => null! + }; + return normalized is not null; + } + + private static bool TryCreateNamedComparisonSelection( + LegacyNamedPlaylistSelectionView selection, + out CoreSceneSelection normalized) + { + normalized = null!; + if (!string.Equals(selection.GroupCode, "비교", StringComparison.Ordinal)) + { + return false; + } + + var names = selection.Subject.Split(" ↔ ", StringSplitOptions.None); + if (names.Length != 2 || names.Any(string.IsNullOrWhiteSpace) || + names.Any(name => !string.Equals(name, name.Trim(), StringComparison.Ordinal))) + { + return false; + } + + LegacyComparisonTarget first; + LegacyComparisonTarget second; + if (selection.DataCode.Length == 0) + { + first = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget( + new LegacyComparisonStoredTargetReference(names[0], string.Empty))!; + second = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget( + new LegacyComparisonStoredTargetReference(names[1], string.Empty))!; + if (first is null || second is null) + { + return false; + } + } + else if (!TryCreateNamedComparisonStocks( + names, + selection.DataCode, + out first, + out second)) + { + return false; + } + + var actions = LegacyComparisonWorkflowCatalog.Actions + .Where(action => string.Equals( + action.Label, + selection.Subtype, + StringComparison.Ordinal)) + .Take(2) + .ToArray(); + if (actions.Length != 1) + { + return false; + } + + try + { + var draft = LegacyComparisonWorkflowCatalog.Materialize( + actions[0].ActionId, + first, + second, + "named-restore"); + if (!string.Equals(draft.BuilderKey, selection.GraphicType, StringComparison.Ordinal) || + !string.Equals(draft.Detail, selection.Subtype, StringComparison.Ordinal) || + !string.Equals( + draft.Selection.DataCode, + selection.DataCode, + StringComparison.Ordinal)) + { + return false; + } + + normalized = draft.Selection; + return true; + } + catch (Exception exception) when (exception is + ArgumentException or InvalidOperationException) + { + return false; + } + } + + private static bool TryCreateNamedComparisonStocks( + IReadOnlyList names, + string dataCode, + out LegacyComparisonTarget first, + out LegacyComparisonTarget second) + { + first = null!; + second = null!; + var identities = dataCode.Split('|', StringSplitOptions.None); + if (identities.Length != 2) + { + return false; + } + + try + { + var targets = new LegacyComparisonTarget[2]; + for (var index = 0; index < targets.Length; index++) + { + var separator = identities[index].IndexOf(':'); + if (separator <= 0 || + separator != identities[index].LastIndexOf(':') || + separator == identities[index].Length - 1) + { + return false; + } + + var market = identities[index][..separator] switch + { + "KOSPI" => LegacyComparisonDomesticMarket.Kospi, + "KOSDAQ" => LegacyComparisonDomesticMarket.Kosdaq, + _ => (LegacyComparisonDomesticMarket?)null + }; + if (market is not { } exactMarket) + { + return false; + } + + targets[index] = LegacyComparisonTarget.CreateKrxStock( + exactMarket, + names[index], + identities[index][(separator + 1)..]); + } + + first = targets[0]; + second = targets[1]; + return true; + } + catch (ArgumentException) + { + return false; + } + } + + private static bool TryCreateNamedManualFinancialSelection( + LegacyNamedPlaylistSelectionView selection, + out CoreSceneSelection normalized) + { + normalized = null!; + if (selection.GroupCode is not + ("KOSPI" or "KOSDAQ" or "NXT_KOSPI" or "NXT_KOSDAQ")) + { + return false; + } + + var definitions = LegacyManualFinancialWorkflow.Screens + .Where(definition => + string.Equals(definition.Label, selection.GraphicType, StringComparison.Ordinal) && + string.Equals( + definition.CanonicalGraphicType, + selection.Subtype, + StringComparison.Ordinal)) + .Take(2) + .ToArray(); + if (definitions.Length != 1) + { + return false; + } + + try + { + LegacyComparisonValueGuard.RequireText( + selection.Subject, + 200, + nameof(selection.Subject)); + LegacyComparisonValueGuard.RequireStockCode( + selection.DataCode, + nameof(selection.DataCode)); + } + catch (ArgumentException) + { + return false; + } + + normalized = new CoreSceneSelection( + selection.GroupCode, + selection.Subject, + definitions[0].CanonicalGraphicType, + string.Empty, + string.Empty); + return true; + } + + private static bool TryCreateNamedManualNetSellSelection( + LegacyNamedPlaylistSelectionView selection, + out CoreSceneSelection normalized) + { + normalized = null!; + if (selection.DataCode.Length != 0) + { + return false; + } + + string? audience = null; + if (selection.GroupCode is "INDIVIDUAL" or "FOREIGN" or "INSTITUTION" && + string.Equals(selection.Subject, "INDEX", StringComparison.Ordinal) && + string.Equals(selection.GraphicType, "MANUAL_NET_SELL", StringComparison.Ordinal) && + string.Equals(selection.Subtype, "MANUAL_NET_SELL", StringComparison.Ordinal)) + { + audience = selection.GroupCode; + } + else if (string.Equals(selection.Subject, "지수", StringComparison.Ordinal) && + string.Equals(selection.GraphicType, "순매도 상위", StringComparison.Ordinal) && + string.Equals(selection.Subtype, "순매도 상위", StringComparison.Ordinal)) + { + audience = selection.GroupCode switch + { + "개인 순매도 상위(수동)" => "INDIVIDUAL", + "외국인 순매도 상위(수동)" => "FOREIGN", + "기관 순매도 상위(수동)" => "INSTITUTION", + _ => null + }; + } + else if (string.Equals(selection.GroupCode, "지수", StringComparison.Ordinal) && + string.Equals(selection.GraphicType, "순매도 상위", StringComparison.Ordinal) && + string.Equals(selection.Subtype, "순매도 상위", StringComparison.Ordinal)) + { + audience = selection.Subject switch + { + "개인 순매도 상위(수동)" => "INDIVIDUAL", + "외국인 순매도 상위(수동)" => "FOREIGN", + "기관 순매도 상위(수동)" => "INSTITUTION", + _ => null + }; + } + + if (audience is null) + { + return false; + } + + normalized = new CoreSceneSelection( + audience, + "INDEX", + "MANUAL_NET_SELL", + "MANUAL_NET_SELL", + string.Empty); + return true; + } + + private static bool TryCreateNamedVersionedViSelection( + LegacyNamedPlaylistSelectionView selection, + out CoreSceneSelection normalized) + { + normalized = null!; + var canonical = string.Equals(selection.GroupCode, "PAGED_VI", StringComparison.Ordinal) && + string.Equals( + selection.Subject, + MBN_STOCK_WEBVIEW.Core.Playout.Scenes.TrustedViManualReference.SubjectSentinel, + StringComparison.Ordinal) && + string.Equals(selection.GraphicType, "INPUT_ORDER", StringComparison.Ordinal) && + string.Equals(selection.Subtype, "CURRENT", StringComparison.Ordinal); + var savedVisibleFields = string.Equals( + selection.GroupCode, + "지수", + StringComparison.Ordinal) && + selection.Subject.Length > 0 && + string.Equals(selection.GraphicType, "5단 표그래프", StringComparison.Ordinal) && + string.Equals(selection.Subtype, "VI 발동(수동)", StringComparison.Ordinal); + if ((!canonical && !savedVisibleFields) || + !MBN_STOCK_WEBVIEW.Core.Playout.Scenes.TrustedViManualReference.IsRowVersion( + selection.DataCode)) + { + return false; + } + + normalized = new CoreSceneSelection( + "PAGED_VI", + MBN_STOCK_WEBVIEW.Core.Playout.Scenes.TrustedViManualReference.SubjectSentinel, + "INPUT_ORDER", + "CURRENT", + selection.DataCode); + return true; + } + private LegacyOperatorSnapshot CreateSnapshot() { var searchRows = _searchData.DisplayNames diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs index c41b952..28d0f98 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs @@ -957,11 +957,7 @@ public sealed class LegacyOverseasSelectionWorkflow IsSafeText(value); private static bool IsSafeSymbol(string value) => - value.Length is > 0 and <= 64 && - string.Equals(value, value.Trim(), StringComparison.Ordinal) && - IsSafeText(value) && - value.All(character => - char.IsLetterOrDigit(character) || character is '@' or '.' or '_' or '$' or ':' or ' ' or '-'); + LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol(value); private static bool IsSafeText(string value) { diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs index 6cff600..fe03949 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs @@ -209,17 +209,38 @@ public static class LegacyStockCutCatalog throw UnknownCut(cut.RawLabel); } + var marketText = identity?.MarketText ?? string.Empty; + var graphicType = cut.RawLabel[..split]; + var subtype = cut.RawLabel[(split + 1)..]; + var stockCode = identity?.StockCode ?? string.Empty; + var runtimeDetailBranch = cut.RawLabel switch + { + "1열판상세_액면가" => nameof(S5011DetailBranch.FaceValue), + "1열판상세_PBR" => nameof(S5011DetailBranch.Valuation), + "1열판상세_거래량" => nameof(S5011DetailBranch.Volume), + _ => null + }; + var playoutSelection = runtimeDetailBranch is null + ? null + : new LegacySceneSelection( + marketText, + stockName, + graphicType, + runtimeDetailBranch, + stockCode); + return new LegacyOperatorPlaylistRow( rowId, true, - identity?.MarketText ?? string.Empty, + marketText, stockName, - cut.RawLabel[..split], - cut.RawLabel[(split + 1)..], + graphicType, + subtype, "1/1", - identity?.StockCode ?? string.Empty, + stockCode, cut.RawLabel, - fadeDuration); + fadeDuration, + PlayoutSelection: playoutSelection); } private static IReadOnlyList CreateRows() diff --git a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs index e60e99d..c525221 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs @@ -22,7 +22,8 @@ public sealed record LegacyCutPointerDownIntent( bool Shift, int X, int Y, - long TimestampMilliseconds) : LegacyUiIntent; + long TimestampMilliseconds, + bool AllowAppend) : LegacyUiIntent; public sealed record LegacyCutKeyDownIntent( LegacyCutKeyboardKey Key, @@ -1221,7 +1222,8 @@ public static class LegacyBridgeProtocol "shift", "x", "y", - "timestampMilliseconds") || + "timestampMilliseconds", + "allowAppend") || !TryInt32( payload, "physicalIndex", @@ -1232,7 +1234,8 @@ public static class LegacyBridgeProtocol !TryBoolean(payload, "shift", out var shift) || !TryInt32(payload, "x", 0, 16_384, out var x) || !TryInt32(payload, "y", 0, 16_384, out var y) || - !TryInt64(payload, "timestampMilliseconds", 0, long.MaxValue, out var timestamp)) + !TryInt64(payload, "timestampMilliseconds", 0, long.MaxValue, out var timestamp) || + !TryBoolean(payload, "allowAppend", out var allowAppend)) { return null; } @@ -1243,7 +1246,8 @@ public static class LegacyBridgeProtocol shift, x, y, - timestamp); + timestamp, + allowAppend); } private static LegacyUiIntent? ParseDrop(JsonElement payload) => diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.DatabaseHealth.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.DatabaseHealth.cs new file mode 100644 index 0000000..94d3b7f --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.DatabaseHealth.cs @@ -0,0 +1,191 @@ +using MBN_STOCK_WEBVIEW.Infrastructure; +using MBN_STOCK_WEBVIEW.Infrastructure.Diagnostics; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.LegacyParityApp; + +public sealed partial class MainWindow +{ + private static readonly TimeSpan DatabaseHealthPollingInterval = + TimeSpan.FromSeconds(10); + + private readonly SemaphoreSlim _databaseActivityGate = new(1, 1); + private readonly INativeOperatorLogWriter _databaseHealthLogWriter = + new NativeOperatorLogWriter(); + private readonly NativeOperatorLogTransitionTracker _databaseHealthTransitions = + new(); + private int _databaseActivityPriorityWaiterCount; + private CancellationTokenSource? _databaseHealthCheckCancellation; + private Task? _databaseHealthMonitorTask; + + private void StartDatabaseHealthMonitor() + { + _databaseHealthMonitorTask ??= + MonitorDatabaseHealthAsync(_lifetimeCancellation.Token); + } + + private async Task MonitorDatabaseHealthAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + // WinForms timerAliveChecker first fired after its 10-second interval. + await Task.Delay(DatabaseHealthPollingInterval, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + + try + { + await CheckAndLogDatabaseHealthAsync(cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + } + } + + private async Task CheckAndLogDatabaseHealthAsync(CancellationToken cancellationToken) + { + if (Volatile.Read(ref _playoutBusy) != 0 || + Volatile.Read(ref _databaseActivityPriorityWaiterCount) != 0 || + !await _databaseActivityGate.WaitAsync(0, cancellationToken) + .ConfigureAwait(false)) + { + return; + } + + CancellationTokenSource? healthCancellation = null; + try + { + if (Volatile.Read(ref _playoutBusy) != 0) + { + return; + } + + if (_databaseRuntime is null) + { + ObserveDatabaseHealthState( + DataSourceKind.Oracle, + DatabaseHealthState.NotConfigured); + ObserveDatabaseHealthState( + DataSourceKind.MariaDb, + DatabaseHealthState.NotConfigured); + return; + } + + healthCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + Interlocked.Exchange( + ref _databaseHealthCheckCancellation, + healthCancellation); + + // Close the race where an operator command starts after this monitor + // acquired the activity gate but before the provider probes begin. + if (Volatile.Read(ref _playoutBusy) != 0 || + Volatile.Read(ref _databaseActivityPriorityWaiterCount) != 0) + { + healthCancellation.Cancel(); + return; + } + + var statuses = await _databaseRuntime.HealthService + .CheckAllAsync(healthCancellation.Token) + .ConfigureAwait(false); + foreach (var status in statuses) + { + ObserveDatabaseHealthState(status.Source, status.State); + } + } + catch (OperationCanceledException) when ( + cancellationToken.IsCancellationRequested || + healthCancellation?.IsCancellationRequested == true) + { + // Shutdown and operator work both have priority over diagnostics. + } + catch + { + ObserveDatabaseHealthState( + DataSourceKind.Oracle, + DatabaseHealthState.Unhealthy); + ObserveDatabaseHealthState( + DataSourceKind.MariaDb, + DatabaseHealthState.Unhealthy); + } + finally + { + if (healthCancellation is not null) + { + Interlocked.CompareExchange( + ref _databaseHealthCheckCancellation, + null, + healthCancellation); + healthCancellation.Dispose(); + } + + _databaseActivityGate.Release(); + } + } + + private async Task AcquirePriorityDatabaseActivityAsync( + CancellationToken cancellationToken) + { + // Signal priority before inspecting the active probe. This closes both + // sides of the acquisition/publish race: + // * a probe that has not acquired the gate sees the waiter and yields; + // * a probe that owns the gate either sees the waiter after publishing + // its CTS or is cancelled through that already-published CTS. + Interlocked.Increment(ref _databaseActivityPriorityWaiterCount); + try + { + CancelActiveDatabaseHealthCheck(); + await _databaseActivityGate.WaitAsync(cancellationToken) + .ConfigureAwait(false); + } + finally + { + Interlocked.Decrement(ref _databaseActivityPriorityWaiterCount); + } + } + + private void CancelActiveDatabaseHealthCheck() + { + var healthCancellation = Volatile.Read(ref _databaseHealthCheckCancellation); + try + { + healthCancellation?.Cancel(); + } + catch (ObjectDisposedException) + { + // Probe completion won the race after the reference was read. + } + } + + private void ObserveDatabaseHealthState( + DataSourceKind source, + DatabaseHealthState state) + { + if (!_databaseHealthTransitions.TryObserveDatabaseState( + source, + state, + out var record)) + { + return; + } + + try + { + _ = _databaseHealthLogWriter.TryWrite(record); + } + catch + { + // Logging is best effort and cannot affect DB or playout work. + } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs index d5d0b90..7feb18d 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs @@ -178,8 +178,9 @@ public sealed partial class MainWindow { case LegacyOperatorPlayoutCommand.Prepare: var request = gateAPrepareRequest ?? - _controller.CreatePlayoutRequest( - _compositionOptions!.Current.FadeDuration); + await CreateFreshPagedPlayoutRequestAsync( + workflow, + cancellationToken).ConfigureAwait(false); result = await workflow.PrepareAsync( request.Playlist, request.SelectedIndexZeroBased, @@ -190,8 +191,9 @@ public sealed partial class MainWindow // 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); + var takeInRequest = await CreateFreshPagedPlayoutRequestAsync( + workflow, + cancellationToken).ConfigureAwait(false); result = await workflow.TakeSelectedAsync( takeInRequest.Playlist, takeInRequest.SelectedIndexZeroBased, @@ -280,6 +282,61 @@ public sealed partial class MainWindow } } + private async Task CreateFreshPagedPlayoutRequestAsync( + LegacyPlayoutWorkflow workflow, + CancellationToken cancellationToken) + { + var fadeDuration = _compositionOptions!.Current.FadeDuration; + var request = _controller.CreatePlayoutRequest(fadeDuration); + + // The first PREPARE/TAKE IN freezes the complete playlist. Resolve every + // operator PageN row from a fresh read-only DB preflight before that snapshot + // is handed to the workflow. A later F8 while PREPARED/PROGRAM reuses the + // already-frozen 1/N values and must not query a second navigation contract. + if (workflow.State.CurrentEntryId is not null) + { + return request; + } + + var pagedEntries = request.Playlist + .Where(static entry => entry.PageNavigation?.PageSize.HasValue == true) + .ToArray(); + if (pagedEntries.Length == 0) + { + return request; + } + + var plans = await workflow.PreflightPagePlansAsync( + Array.AsReadOnly(pagedEntries), + cancellationToken).ConfigureAwait(false); + if (plans.Count != pagedEntries.Length) + { + throw new LegacySceneDataException( + "The playlist page preflight did not return every PageN entry."); + } + + foreach (var plan in plans) + { + var entry = pagedEntries.SingleOrDefault(candidate => + string.Equals(candidate.EntryId, plan.EntryId, StringComparison.Ordinal)); + if (entry?.PageNavigation?.PageSize is not { } expectedPageSize || + plan.PageSize != (int)expectedPageSize || + plan.PageCount is < 1 or > ScenePaging.MaximumPageCount) + { + throw new LegacySceneDataException( + "The playlist page preflight does not match the operator PageN row."); + } + + _controller.ReflectSuccessfulPlayout( + plan.EntryId, + pageIndexZeroBased: 0, + pageCount: plan.PageCount, + synchronizeOperatorSelection: false); + } + + return _controller.CreatePlayoutRequest(fadeDuration); + } + private async Task ExecuteGateAPrepareAsync( string capability, CancellationToken cancellationToken) @@ -596,9 +653,7 @@ public sealed partial class MainWindow } private static bool IsOperatorMutationIntent(LegacyUiIntent intent) => intent is - LegacyCutPointerDownIntent or - LegacyCutKeyDownIntent or - LegacyClearCutSelectionIntent or + LegacyCutPointerDownIntent { AllowAppend: true } or LegacyDropSelectedCutsIntent or LegacySetPlaylistEnabledIntent or LegacySetAllPlaylistEnabledIntent or @@ -948,6 +1003,7 @@ public sealed partial class MainWindow } var intentGateEntered = false; + var databaseGateEntered = false; var playoutGateEntered = false; try { @@ -958,6 +1014,9 @@ public sealed partial class MainWindow _intentBusy = true; QueueOperatorState(); + await AcquirePriorityDatabaseActivityAsync(token).ConfigureAwait(false); + databaseGateEntered = true; + await _playoutCommandGate.WaitAsync(token).ConfigureAwait(false); playoutGateEntered = true; Interlocked.Exchange(ref _playoutBusy, 1); @@ -1058,6 +1117,11 @@ public sealed partial class MainWindow _playoutCommandGate.Release(); } + if (databaseGateEntered) + { + _databaseActivityGate.Release(); + } + if (intentGateEntered) { _intentBusy = false; diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs index 7055bf0..01f82f0 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs @@ -22,6 +22,7 @@ public sealed partial class MainWindow : Window private readonly CancellationTokenSource _lifetimeCancellation = new(); private readonly SemaphoreSlim _intentGate = new(1, 1); + private readonly SemaphoreSlim _orderedPlayoutIntentGate = new(1, 1); private readonly PlayoutLaunchAuthorization _playoutLaunchAuthorization; private readonly WindowSubclassProcedure _windowSubclassProcedure; private readonly LegacyOperatorController _controller; @@ -241,6 +242,7 @@ public sealed partial class MainWindow : Window await InitializeWebViewAsync(); if (!_closing) { + StartDatabaseHealthMonitor(); await ConnectPlayoutAsync(_lifetimeCancellation.Token); } } @@ -380,15 +382,35 @@ public sealed partial class MainWindow : Window private async Task ProcessIntentAsync(LegacyUiIntent intent) { var gateEntered = false; + var orderedPlayoutIntentEntered = false; + var databaseGateEntered = false; var dispatchStarted = false; try { // MainForm performed database search synchronously. The busy snapshot - // freezes the Web controls, while this zero-timeout gate prevents an - // already-posted duplicate from growing an unbounded DB request queue. - gateEntered = await _intentGate.WaitAsync( - 0, - _lifetimeCancellation.Token); + // freezes the Web controls. Playout commands must retain their order behind + // a preceding local row-selection intent, but only one may wait or execute + // at a time so a double post can never become a queued vendor command. + if (IsOrderedPlayoutIntent(intent)) + { + orderedPlayoutIntentEntered = await _orderedPlayoutIntentGate.WaitAsync( + 0, + _lifetimeCancellation.Token); + if (orderedPlayoutIntentEntered) + { + await _intentGate.WaitAsync(_lifetimeCancellation.Token); + gateEntered = true; + } + } + else + { + // Keep the zero-timeout admission used by database and edit intents; + // those requests must not build an unbounded queue behind slow I/O. + gateEntered = await _intentGate.WaitAsync( + 0, + _lifetimeCancellation.Token); + } + if (!gateEntered) { PostState(IsNamedPlaylistModalIntent(intent) @@ -514,6 +536,13 @@ public sealed partial class MainWindow : Window PostState(_controller.Current); } + await AcquirePriorityDatabaseActivityAsync(_lifetimeCancellation.Token); + databaseGateEntered = true; + if (_closing) + { + return; + } + dispatchStarted = true; var state = await HandleIntentAsync(intent, _lifetimeCancellation.Token); ObserveOperatorMutationQuarantine(state); @@ -546,13 +575,26 @@ public sealed partial class MainWindow : Window _intentBusy = false; } + if (databaseGateEntered) + { + _databaseActivityGate.Release(); + } + if (gateEntered) { _intentGate.Release(); } + + if (orderedPlayoutIntentEntered) + { + _orderedPlayoutIntentGate.Release(); + } } } + private static bool IsOrderedPlayoutIntent(LegacyUiIntent intent) => intent is + LegacyExecutePlayoutIntent or LegacyGateAPrepareIntent; + private static bool IsNamedPlaylistModalIntent(LegacyUiIntent intent) => intent is LegacyRefreshNamedPlaylistsIntent or LegacySelectNamedPlaylistIntent or @@ -648,7 +690,8 @@ public sealed partial class MainWindow : Window pointer.Shift, pointer.X, pointer.Y, - pointer.TimestampMilliseconds), + pointer.TimestampMilliseconds, + pointer.AllowAppend), LegacyCutKeyDownIntent key => _controller.CutKeyDown( key.Key, key.Control, @@ -1347,6 +1390,7 @@ public sealed partial class MainWindow : Window DetachCloseConfirmation(); DetachInteractionMetricsRefresh(); _lifetimeCancellation.Cancel(); + CancelActiveDatabaseHealthCheck(); ShutdownPlayoutRuntime(); ShutdownManualListsRuntime(); DataQueryExecutor.Reset(); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js index cb4429d..b10d14c 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js @@ -6,6 +6,7 @@ const searchButton = document.getElementById("stock-search-button"); const stockResults = document.getElementById("stock-results"); const cutList = document.getElementById("cut-list"); + const cutDragFeedback = document.getElementById("cut-drag-feedback"); const playlistRows = document.getElementById("playlist-rows"); const playlistDropZone = document.getElementById("playlist-drop-zone"); const playlistMoveUp = document.getElementById("playlist-move-up"); @@ -123,6 +124,7 @@ let operatorCatalogDraftKey = ""; let manualResultClickTimer = null; let comparisonTargetClickTimer = null; + let themeResultClickTimer = null; let manualViResultClickTimer = null; let operatorCatalogStockClickTimer = null; let pendingNamedPlaylistCommand = null; @@ -357,6 +359,8 @@ treeFocusOwnedBeforeRender = categoryTree.contains(document.activeElement); if (renderedTreeTabId) captureTreeUiState(renderedTreeTabId); if (nextTabId !== renderedTreeTabId) { + clearDelayedClick(themeResultClickTimer); + themeResultClickTimer = null; renderedTreeTabId = nextTabId; pendingTreeResetTabs.clear(); if (nextTabId) pendingTreeResetTabs.add(nextTabId); @@ -461,6 +465,14 @@ playout.isTakeOutCompletionPending === true || playout.phase !== "idle")); } + function isCutSelectionLocked(state) { + const playout = state && state.playout; + return !state || state.isBusy === true || state.fixedSectionBatch != null || + (playout && (playout.isBusy === true || playout.outcomeUnknown === true || + playout.isPlayCompletionPending === true || + playout.isTakeOutCompletionPending === true)); + } + function dragDropPosition(event, element) { const bounds = element.getBoundingClientRect(); return event.clientY < bounds.top + (bounds.height / 2) ? "before" : "after"; @@ -669,10 +681,15 @@ function clearCutDragGesture(releaseCapture) { const gesture = cutDragGesture; - if (!gesture) return; cutDragGesture = null; - gesture.element.classList.remove("dragging"); playlistDropZone.classList.remove("cut-drag-over"); + cutDragFeedback.hidden = true; + cutDragFeedback.classList.remove("allowed"); + delete cutDragFeedback.dataset.dropAllowed; + cutDragFeedback.style.removeProperty("left"); + cutDragFeedback.style.removeProperty("top"); + if (!gesture) return; + gesture.element.classList.remove("dragging"); if (releaseCapture !== false && typeof gesture.element.hasPointerCapture === "function" && gesture.element.hasPointerCapture(gesture.pointerId) && @@ -685,6 +702,21 @@ } } + function showCutDragFeedback(event, allowed) { + cutDragFeedback.hidden = false; + cutDragFeedback.classList.toggle("allowed", allowed); + cutDragFeedback.dataset.dropAllowed = allowed ? "true" : "false"; + const gap = 14; + const viewportWidth = document.documentElement.clientWidth; + const viewportHeight = document.documentElement.clientHeight; + const width = cutDragFeedback.offsetWidth; + const height = cutDragFeedback.offsetHeight; + const left = Math.max(4, Math.min(event.clientX + gap, viewportWidth - width - 4)); + const top = Math.max(4, Math.min(event.clientY + gap, viewportHeight - height - 4)); + cutDragFeedback.style.left = Math.round(left) + "px"; + cutDragFeedback.style.top = Math.round(top) + "px"; + } + function beginCutDragGesture(element, event, position) { clearCutDragGesture(); if (event.button !== 0) return; @@ -719,9 +751,10 @@ } if (gesture.started) { event.preventDefault(); - playlistDropZone.classList.toggle( - "cut-drag-over", - !playlistMutationLocked && pointInsideElement(playlistDropZone, event)); + const dropAllowed = !playlistMutationLocked && + pointInsideElement(playlistDropZone, event); + playlistDropZone.classList.toggle("cut-drag-over", dropAllowed); + showCutDragFeedback(event, dropAllowed); } } @@ -790,7 +823,6 @@ 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); @@ -802,9 +834,12 @@ // equality contract. Drag geometry keeps the raw fractional CSS point. x: Math.round(position.x), y: Math.round(position.y), - timestampMilliseconds: Math.max(0, Math.round(event.timeStamp)) + timestampMilliseconds: Math.max(0, Math.round(event.timeStamp)), + allowAppend: !playlistMutationLocked }); - beginCutDragGesture(element, event, position); + if (!playlistMutationLocked) { + beginCutDragGesture(element, event, position); + } }); element.addEventListener("pointermove", onCutPointerMove); element.addEventListener("pointerup", onCutPointerUp); @@ -816,7 +851,6 @@ // 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); @@ -831,7 +865,8 @@ function renderCuts(state) { const cutDragLocked = isOperatorMutationLocked(state); - cutSelectionEnabled = Number.isInteger(state.selectedStockIndex) && !cutDragLocked; + cutSelectionEnabled = Number.isInteger(state.selectedStockIndex) && + !isCutSelectionLocked(state); const focusedCutIndex = state.cutRows.findIndex(function (row) { return row.isFocused === true; }); @@ -864,7 +899,9 @@ ? 0 : -1; element.draggable = false; - element.dataset.cutDragEnabled = cutSelectionEnabled ? "true" : "false"; + element.dataset.cutDragEnabled = cutSelectionEnabled && !cutDragLocked + ? "true" + : "false"; }); while (cutList.children.length > state.cutRows.length) { @@ -876,7 +913,7 @@ } cutList.addEventListener("pointerdown", function (event) { - if (event.target !== cutList || playlistMutationLocked) return; + if (event.target !== cutList || !cutSelectionEnabled) return; send("clear-cut-selection", {}); }); @@ -4000,6 +4037,8 @@ const themeResult = event.target.closest("button[data-theme-result-id]"); if (themeResult && !themeResult.disabled) { event.preventDefault(); + clearDelayedClick(themeResultClickTimer); + themeResultClickTimer = null; send("activate-theme-result", { resultId: themeResult.dataset.themeResultId }); @@ -4260,7 +4299,12 @@ } const themeResult = event.target.closest("button[data-theme-result-id]"); if (themeResult) { - send("select-theme", { resultId: themeResult.dataset.themeResultId }); + clearDelayedClick(themeResultClickTimer); + const resultId = themeResult.dataset.themeResultId; + themeResultClickTimer = window.setTimeout(function () { + themeResultClickTimer = null; + send("select-theme", { resultId: resultId }); + }, 250); return; } if (event.target.closest("button[data-theme-search]")) { diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html index 634401a..2f6f52e 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html @@ -257,6 +257,11 @@ + + diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css index af7fbe3..7faa8f1 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/styles.css @@ -84,7 +84,7 @@ button:disabled { color: #555; opacity: 1; } .manual-financial-dialog > header { display: flex; align-items: center; justify-content: space-between; padding: 0 12px; border-bottom: 1px solid #999; background: #ececec; } .manual-financial-dialog h2 { margin: 0; font-size: 18px; } .manual-financial-dialog > header button { width: 34px; height: 29px; font-size: 20px; } -.manual-financial-workspace { min-height: 0; display: grid; grid-template-rows: 38px minmax(0, 1fr); gap: 7px; padding: 8px; } +.manual-financial-workspace { min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 7px; padding: 8px; } .manual-financial-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 5px; } .manual-financial-toolbar input, .manual-financial-toolbar button { min-width: 0; height: 30px; } .manual-financial-body { min-height: 0; display: grid; grid-template-columns: 270px minmax(0, 1fr); gap: 8px; } @@ -144,6 +144,13 @@ button:disabled { color: #555; opacity: 1; } .cut-row[data-cut-drag-enabled="true"] { cursor: grab; } .cut-row.dragging { opacity: .58; cursor: grabbing; } .playlist-grid.cut-drag-over { box-shadow: inset 0 0 0 3px #176b2d; } +.cut-drag-feedback { position: fixed; z-index: 10000; display: flex; align-items: center; gap: 7px; max-width: 260px; padding: 7px 10px; border: 1px solid #8a8a8a; border-radius: 4px; background: rgba(255, 255, 255, .97); box-shadow: 0 3px 10px rgba(0, 0, 0, .28); color: #555; font-size: 13px; font-weight: 700; line-height: 1; pointer-events: none; user-select: none; white-space: nowrap; } +.cut-drag-feedback[hidden] { display: none; } +.cut-drag-feedback-icon { display: inline-flex; width: 17px; height: 17px; align-items: center; justify-content: center; border-radius: 50%; background: #777; color: #fff; font-size: 16px; font-weight: 800; } +.cut-drag-feedback-icon::before { content: "×"; } +.cut-drag-feedback.allowed { border-color: #176b2d; color: #125824; } +.cut-drag-feedback.allowed .cut-drag-feedback-icon { background: #176b2d; } +.cut-drag-feedback.allowed .cut-drag-feedback-icon::before { content: "+"; } .operator-status { min-height: 18px; padding-top: 2px; color: #a40000; font-size: 11px; } .catalog-panel { display: flex; flex-direction: column; padding: 0 8px; background: #f3f3f3; } diff --git a/tests/LegacyParityWeb/thin-client.test.cjs b/tests/LegacyParityWeb/thin-client.test.cjs index 8808327..a95b767 100644 --- a/tests/LegacyParityWeb/thin-client.test.cjs +++ b/tests/LegacyParityWeb/thin-client.test.cjs @@ -23,6 +23,7 @@ test("legacy shell exposes the original stock, cut, playlist and playout control "moving-average-5", "moving-average-20", "cut-list", + "cut-drag-feedback", "playlist-drop-zone", "playlist-rows", "playlist-enable-all", @@ -63,7 +64,8 @@ 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(cutFactory, /allowAppend: !playlistMutationLocked/); + assert.match(cutFactory, /if \(!playlistMutationLocked\)/); assert.match(app, /send\("clear-cut-selection", \{\}\)/); assert.match(app, /event\.target !== cutList/); assert.match(app, /button\.addEventListener\("keydown"/); @@ -80,12 +82,85 @@ test("cut rows are reconciled in place while the legacy pointer drag remains cap assert.match(cutFactory, /addEventListener\("pointermove", onCutPointerMove\)/); assert.match(cutFactory, /addEventListener\("lostpointercapture", onCutPointerCancel\)/); assert.match(cutFactory, /const cutDragLocked = isOperatorMutationLocked\(state\)/); + assert.match(cutFactory, /!isCutSelectionLocked\(state\)/); assert.match(cutFactory, /element\.draggable = false/); - assert.match(cutFactory, /dataset\.cutDragEnabled = cutSelectionEnabled/); + assert.match(cutFactory, /dataset\.cutDragEnabled = cutSelectionEnabled && !cutDragLocked/); assert.doesNotMatch(cutFactory, /addEventListener\("dragstart"/); assert.doesNotMatch(app, /cutList\.replaceChildren/); }); +test("program keeps cut selection active while append drag stays locked", () => { + const sourceStart = app.indexOf("function isCutSelectionLocked"); + const sourceEnd = app.indexOf("function dragDropPosition", sourceStart); + assert.ok(sourceStart >= 0 && sourceEnd > sourceStart); + const isCutSelectionLocked = new Function(` + ${app.slice(sourceStart, sourceEnd)} + return isCutSelectionLocked; + `)(); + + assert.equal(isCutSelectionLocked({ + isBusy: false, + fixedSectionBatch: null, + playout: { + phase: "program", + isBusy: false, + outcomeUnknown: false, + isPlayCompletionPending: false, + isTakeOutCompletionPending: false + } + }), false); + assert.equal(isCutSelectionLocked({ + isBusy: false, + fixedSectionBatch: null, + playout: { + phase: "program", + isBusy: true, + outcomeUnknown: false, + isPlayCompletionPending: false, + isTakeOutCompletionPending: false + } + }), true); +}); + +test("cut drag feedback follows the pointer and exposes allowed target state", () => { + const sourceStart = app.indexOf("function showCutDragFeedback"); + const sourceEnd = app.indexOf("function beginCutDragGesture", sourceStart); + assert.ok(sourceStart >= 0 && sourceEnd > sourceStart); + const feedback = { + hidden: true, + dataset: {}, + offsetWidth: 120, + offsetHeight: 30, + classList: { + values: new Set(), + toggle(name, enabled) { + if (enabled) this.values.add(name); + else this.values.delete(name); + } + }, + style: {} + }; + const show = new Function("cutDragFeedback", "document", ` + ${app.slice(sourceStart, sourceEnd)} + return showCutDragFeedback; + `)(feedback, { documentElement: { clientWidth: 800, clientHeight: 600 } }); + + show({ clientX: 100, clientY: 80 }, false); + assert.equal(feedback.hidden, false); + assert.equal(feedback.dataset.dropAllowed, "false"); + assert.equal(feedback.style.left, "114px"); + assert.equal(feedback.style.top, "94px"); + assert.equal(feedback.classList.values.has("allowed"), false); + + show({ clientX: 790, clientY: 590 }, true); + assert.equal(feedback.dataset.dropAllowed, "true"); + assert.equal(feedback.style.left, "676px"); + assert.equal(feedback.style.top, "566px"); + assert.equal(feedback.classList.values.has("allowed"), true); + assert.match(styles, /\.cut-drag-feedback \{[^}]*pointer-events: none/); + assert.match(styles, /\.cut-drag-feedback\.allowed/); +}); + test("state rendering preserves stock and playlist focus like native controls", () => { assert.match(app, /dataset\.resultIndex/); assert.match(app, /dataset\.rowId/); @@ -1074,8 +1149,10 @@ test("UC1 tree render preserves state but resets Expand and first root on tab re let renderedTreeTabId = null; let treeInteractionLocked = false; let treeFocusOwnedBeforeRender = false; + let themeResultClickTimer = null; const treeUiStateByTab = new Map(); const pendingTreeResetTabs = new Set(); + function clearDelayedClick() {} ${app.slice(helperStart, helperEnd)} return { beginTreeRender, finishTreeRender, selectTreeNode }; `)(categoryTree, catalogExpandAll, document, { getActiveModal() { return null; } }); @@ -1444,7 +1521,7 @@ test("FSell and VI dialogs send opaque native intents and never own file or stoc }); test("separator rows preserve the original drag gesture", () => { - assert.match(app, /element\.dataset\.cutDragEnabled = cutSelectionEnabled \? "true" : "false"/); + assert.match(app, /element\.dataset\.cutDragEnabled = cutSelectionEnabled && !cutDragLocked/); assert.match(app, /element\.draggable = false/); assert.doesNotMatch(app, /cutDragEnabled = !row\.isSeparator/); }); diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneBuilderTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneBuilderTests.cs index f002c86..4c12c6e 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneBuilderTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/CandleSceneBuilderTests.cs @@ -10,11 +10,11 @@ public sealed class CandleSceneBuilderTests { public static TheoryData AliasCases => new() { - { CandleCutAlias.Cut8035, CandlePeriod.Intraday, 0f, "EBE44FFF7523C76172C2B4D0BF718D22D42D41C8533D3CF44480D237D7CD5B8B" }, - { CandleCutAlias.Cut8061, CandlePeriod.FiveDays, 1188f, "D1511EE579F58F10552B22D44C02C94E6BD0E0F61F7DC668827B9FABF8E69682" }, - { CandleCutAlias.Cut8040, CandlePeriod.TwentyDays, 1398f, "B1CEA60310E547A2FA1847C5AEBEDE25CF5448AA257EB5A7798BD295591C555F" }, - { CandleCutAlias.Cut8046, CandlePeriod.SixtyDays, 1442f, "24CA30D09E5EAF9034A925509E0C6A06E0B9ED1F5FB5488B9F595E732330DA36" }, - { CandleCutAlias.Cut8051, CandlePeriod.OneHundredTwentyDays, 1452f, "C6F23FD7A55D2DC12CE69323226BA65E05B57ECACB77C90CD8858E49CD65D5CA" }, + { CandleCutAlias.Cut8035, CandlePeriod.Intraday, 0f, "50ECC21B9F7DEB27EA154C61B080899D1D4FF2C1D490F38B8285D9E46B881D6F" }, + { CandleCutAlias.Cut8061, CandlePeriod.FiveDays, 1188f, "CC3D1389889FD7F79850A09F7C470723EE5D3C81A71D3CDC8C506B560C23269C" }, + { CandleCutAlias.Cut8040, CandlePeriod.TwentyDays, 1398f, "FD0B72C1748964FDEB4188CBC9A225951E6020B7D0F169CCA303A70044CDD499" }, + { CandleCutAlias.Cut8046, CandlePeriod.SixtyDays, 1442f, "CA04EC110D1E347D4824DB076781AD1D515A46B2E1FCDD6488196A044661012F" }, + { CandleCutAlias.Cut8051, CandlePeriod.OneHundredTwentyDays, 1452f, "0E3442C5393AE644FA471DAB68696FFBDF1CBD7D420A0155DC8CFCC3BE1202CC" }, { CandleCutAlias.Cut8056, CandlePeriod.TwoHundredFortyDays, 1464f, "D6ABC34111DAC4B32F8D1CA5563EAA248BA5B1C0C782B74E53A9C651BBC98D48" } }; @@ -129,10 +129,10 @@ public sealed class CandleSceneBuilderTests "S:volrumeG=1" ], actual.Take(18)); Assert.Equal("S:volume1=0", actual[18]); - Assert.Equal("S:volume240=0", actual[257]); - Assert.Equal("S:candle1=0", actual[258]); - Assert.Equal("S:candle240=0", actual[497]); - Assert.Equal("V:date_begin=07/01", actual[498]); + Assert.Equal("S:volume5=0", actual[22]); + Assert.Equal("S:candle1=0", actual[23]); + Assert.Equal("S:candle5=0", actual[27]); + Assert.Equal("V:date_begin=07/01", actual[28]); Assert.Contains("C:candlebody1:170:0:0:255", actual); Assert.Contains("C:candlebar2:15:99:189:255", actual); Assert.Contains("C:candlebody3:95:95:95:255", actual); @@ -145,7 +145,7 @@ public sealed class CandleSceneBuilderTests Assert.Empty(mutations.OfType() .Last(path => path.ObjectName == "ma20").Points); Assert.Equal( - "2EEA2C83B0D3086A894A3AE17DD8DABE9447764757B83EE4F17DE2217BD70EBD", + "3493F848239713D995223BD6306CFF54AD0568093314FC1ADA688455919F7527", SnapshotHash(actual)); } @@ -173,13 +173,19 @@ public sealed class CandleSceneBuilderTests ShowMovingAverage20: false); var mutations = new S8010SceneMutationBuilder().Build(data); - var ma5 = mutations.OfType() - .Last(path => path.ObjectName == "ma5"); - Assert.Equal(expectedPeriod, CandleCutAliasMetadata.GetPeriod(alias)); - Assert.Equal(2, ma5.Points.Count); - Assert.Equal(0f, ma5.Points[0].X); - Assert.Equal(expectedSecondX, ma5.Points[1].X); + if (CandleCutAliasMetadata.SupportsMovingAveragePaths(alias)) + { + var ma5 = mutations.OfType() + .Last(path => path.ObjectName == "ma5"); + Assert.Equal(2, ma5.Points.Count); + Assert.Equal(0f, ma5.Points[0].X); + Assert.Equal(expectedSecondX, ma5.Points[1].X); + } + else + { + Assert.DoesNotContain(mutations, mutation => mutation is PlayoutSetPathPoints); + } if (expectedPeriod == CandlePeriod.Intraday) { Assert.Contains(mutations, mutation => @@ -237,6 +243,56 @@ public sealed class CandleSceneBuilderTests value == item.ExpectedPrice); } + [Theory] + [InlineData(CandleCutAlias.Cut8035, 78, false, false)] + [InlineData(CandleCutAlias.Cut8061, 5, true, false)] + [InlineData(CandleCutAlias.Cut8040, 20, true, true)] + [InlineData(CandleCutAlias.Cut8046, 60, true, true)] + [InlineData(CandleCutAlias.Cut8051, 120, true, true)] + [InlineData(CandleCutAlias.Cut8056, 240, true, true)] + public void Alias_asset_profiles_bound_object_names_and_optional_paths( + CandleCutAlias alias, + int capacity, + bool supportsMovingAveragePaths, + bool supportsGuide) + { + var data = new S8010SceneData( + alias, + CandleMarketTarget.KospiStock, + CandleChartMode.Price, + "Stock", + new CandleQuoteData(100, 0, 0, ScenePriceDirection.Flat), + [Row(1, 100, 110, 90, 100, 0, 100, 100, new TimeOnly(9, 0))], + ShowMovingAverage5: true, + ShowMovingAverage20: true); + + var mutations = new S8010SceneMutationBuilder().Build(data); + Assert.Equal(capacity, CandleCutAliasMetadata.GetAssetObjectCapacity(alias)); + Assert.Equal( + capacity, + mutations.OfType() + .Count(item => item.ObjectName.StartsWith("volume", StringComparison.Ordinal) && + !item.IsVisible)); + Assert.Equal( + capacity, + mutations.OfType() + .Count(item => item.ObjectName.StartsWith("candle", StringComparison.Ordinal) && + !item.IsVisible)); + Assert.Equal( + supportsMovingAveragePaths, + mutations.OfType().Any()); + if (!supportsMovingAveragePaths) + { + Assert.Contains(mutations, mutation => + mutation is PlayoutSetVisible { ObjectName: "ma5", IsVisible: false }); + Assert.Contains(mutations, mutation => + mutation is PlayoutSetVisible { ObjectName: "ma20", IsVisible: false }); + } + Assert.Equal( + supportsGuide, + mutations.OfType().Any(item => item.ObjectName == "guide")); + } + [Theory] [InlineData(CandleMarketTarget.KospiIndex)] [InlineData(CandleMarketTarget.KosdaqIndex)] @@ -250,7 +306,7 @@ public sealed class CandleSceneBuilderTests CandleMarketTarget target) { var data = new S8010SceneData( - CandleCutAlias.Cut8061, + CandleCutAlias.Cut8040, target, CandleChartMode.Price, "Target", @@ -273,7 +329,7 @@ public sealed class CandleSceneBuilderTests public void Equal_range_zero_volume_and_single_partial_path_are_finite() { var data = new S8010SceneData( - CandleCutAlias.Cut8061, + CandleCutAlias.Cut8040, CandleMarketTarget.KospiStock, CandleChartMode.PriceWithVolume, "Equal", diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ComparisonAndYieldSceneBuildersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ComparisonAndYieldSceneBuildersTests.cs index 0ed5265..62348fb 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ComparisonAndYieldSceneBuildersTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ComparisonAndYieldSceneBuildersTests.cs @@ -79,7 +79,7 @@ public sealed class ComparisonAndYieldSceneBuildersTests } [Fact] - public void S5087_preserves_its_distinct_hide_order_width_and_baseline_offset() + public void S5087_preserves_its_distinct_graph_geometry_and_omits_absent_asset_guides() { var actual = new S5087SceneMutationBuilder().Build(new S5087SceneData( ComparisonGraphPeriod.FiveDays, @@ -88,9 +88,19 @@ public sealed class ComparisonAndYieldSceneBuildersTests .ToArray(); var expected = ExpectedComparisonHeader(ComparisonHeaderStyle.S5087) - .Concat(ExpectedYieldSeries(1, 700f, -20f)) - .Concat(ExpectedYieldSeries(2, 700f, -170f)); + .Concat(ExpectedS5087YieldSeries(1, 700f, -20f)) + .Concat(ExpectedS5087YieldSeries(2, 700f, -170f)); Assert.Equal(expected, actual); + Assert.DoesNotContain(actual, item => + item.StartsWith("V:date1_2=", StringComparison.Ordinal) || + item.StartsWith("V:date1_4=", StringComparison.Ordinal) || + item.StartsWith("V:date2_2=", StringComparison.Ordinal) || + item.StartsWith("V:date2_4=", StringComparison.Ordinal) || + item.StartsWith("P:baseline", StringComparison.Ordinal) || + item.StartsWith("P:highTag", StringComparison.Ordinal) || + item.StartsWith("P:lowTag", StringComparison.Ordinal) || + item.StartsWith("V:highPrice", StringComparison.Ordinal) || + item.StartsWith("V:lowPrice", StringComparison.Ordinal)); } [Fact] @@ -536,6 +546,19 @@ public sealed class ComparisonAndYieldSceneBuildersTests } } + private static IEnumerable ExpectedS5087YieldSeries( + int series, + float maximumX, + float baseline) => ExpectedYieldSeries(series, maximumX, baseline) + .Where(item => + !item.StartsWith($"V:date{series}_2=", StringComparison.Ordinal) && + !item.StartsWith($"V:date{series}_4=", StringComparison.Ordinal) && + !item.StartsWith($"P:baseline{series}:", StringComparison.Ordinal) && + !item.StartsWith($"P:highTag{series}:", StringComparison.Ordinal) && + !item.StartsWith($"P:lowTag{series}:", StringComparison.Ordinal) && + !item.StartsWith($"V:highPrice{series}=", StringComparison.Ordinal) && + !item.StartsWith($"V:lowPrice{series}=", StringComparison.Ordinal)); + private static void AssertFinite(IEnumerable mutations) { foreach (var mutation in mutations) diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyOverseasStockSearchServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyOverseasStockSearchServiceTests.cs index a10319b..5d0cb4a 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyOverseasStockSearchServiceTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyOverseasStockSearchServiceTests.cs @@ -54,6 +54,34 @@ public sealed class LegacyOverseasStockSearchServiceTests () => new LegacyOverseasStockSearchService(duplicate).SearchAsync("alpha")); } + [Fact] + public async Task NormalizesOuterAsciiPaddingButRejectsAResultingIdentityCollision() + { + var padded = new RecordingExecutor(WorldStockTable( + (" Alpha ", " AAA ", " US "))); + var result = await new LegacyOverseasStockSearchService(padded).SearchAsync("alpha"); + + Assert.Equal( + new WorldStockSearchItem("Alpha", "AAA", "US"), + Assert.Single(result.Items)); + + var collision = new RecordingExecutor(WorldStockTable( + ("Alpha", "AAA", "US"), + (" Alpha ", " AAA ", " US "))); + await Assert.ThrowsAsync( + () => new LegacyOverseasStockSearchService(collision).SearchAsync("alpha")); + } + + [Fact] + public void CurrentProviderPunctuationIsCanonicalButSerializationDelimitersRemainBlocked() + { + Assert.True(LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol("IDX@A&B")); + Assert.True(LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol("IDX@(A)")); + Assert.False(LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol("IDX|A")); + Assert.False(LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol("IDX^A")); + Assert.False(LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol("IDX/A")); + } + [Fact] public async Task DefaultQueryUsesTheFullBoundAndReportsWhenOriginalUnboundedResultsAreTruncated() { diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyParameterizedSceneRequestResolverTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyParameterizedSceneRequestResolverTests.cs index 576c05a..90ed732 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyParameterizedSceneRequestResolverTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyParameterizedSceneRequestResolverTests.cs @@ -76,6 +76,23 @@ public sealed class LegacyParameterizedSceneRequestResolverTests Assert.Equal(LegacyMarketQuoteTarget.Kosdaq, request.PrimaryTarget); } + [Theory] + [InlineData("Futures,Kospi", LegacyMarketQuoteTarget.Kospi)] + [InlineData("Kosdaq,Futures", LegacyMarketQuoteTarget.Kosdaq)] + public async Task Two_column_alias_recognizes_the_canonical_stored_futures_token( + string subject, + LegacyMarketQuoteTarget expectedPrimary) + { + var resolver = new LegacyParameterizedSceneRequestResolver(new RecordingExecutor()); + + var result = await resolver.ResolveTwoColumnRequestAsync(Entry( + "5032", "INDEX", subject, "CURRENT")); + + var routed = Assert.IsType(result); + var request = Assert.IsType(routed.Request); + Assert.Equal(expectedPrimary, request.PrimaryTarget); + } + [Theory] [InlineData( "코스피 지수,삼성전자(NXT)", diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyThemeSelectionServiceTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyThemeSelectionServiceTests.cs index 6476aeb..82cc7b7 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyThemeSelectionServiceTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyThemeSelectionServiceTests.cs @@ -339,6 +339,13 @@ public sealed class LegacyThemeSelectionServiceTests Assert.Equal(DataSourceKind.MariaDb, call.Source); Assert.Equal("THEME_PREVIEW_NXT", call.TableName); Assert.Contains("F_STOP_GUBUN = 'N'", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("FROM N_STOCK", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains("FROM N_KOSDAQ_STOCK", call.Spec.Sql, StringComparison.Ordinal); + Assert.Contains( + "l.SB_CODE REGEXP BINARY '^[0-9]{8}$'", + call.Spec.Sql, + StringComparison.Ordinal); + Assert.DoesNotContain("v_all_stock", call.Spec.Sql, StringComparison.OrdinalIgnoreCase); Assert.Contains("LIMIT @row_limit", call.Spec.Sql, StringComparison.Ordinal); call.Spec.ValidateFor(DataSourceKind.MariaDb); } diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/PagedQuoteSceneBuildersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/PagedQuoteSceneBuildersTests.cs index 1708744..53cb4e5 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/PagedQuoteSceneBuildersTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/PagedQuoteSceneBuildersTests.cs @@ -123,7 +123,7 @@ public sealed class PagedQuoteSceneBuildersTests } [Fact] - public void S5077_expert_header_and_wide_direction_variants_match_golden_order() + public void S5077_omits_absent_global_header_and_matches_wide_direction_golden_order() { var data = Data( count: 6, @@ -138,9 +138,7 @@ public sealed class PagedQuoteSceneBuildersTests [ "S:mart=0", "S:row1=0", "S:row2=0", "S:row3=0", "S:row4=0", "S:row5=0", "S:row6=0", - "V:title=Expert", "S:mart=0", - "V:unit=\uB2E8\uC704 : \uC6D0", "V:column1=\uB9E4\uC218\uAC00", - "V:column2=\uD604\uC7AC\uAC00", "V:column3=\uC218\uC775\uB960", + "S:mart=0", "S:row1=1", "V:title1=Row001", "V:price1=1,001", "V:changePrice1_1=-1", "V:changePrice1_2=-1", "V:changePrice1_3=-1", "V:rate1_1=-0.25", "V:rate1_2=-0.25", "V:rate1_3=-0.25", @@ -150,9 +148,9 @@ public sealed class PagedQuoteSceneBuildersTests "S:rate1_1=0", "S:rate1_2=0", "S:rate1_3=1", "S:percent1_1=0", "S:percent1_2=0", "S:percent1_3=1", "S:down1=1" - ], actual.Take(40)); + ], actual.Take(35)); Assert.Equal( - "5BB1952AF0BF7AAE1CEBED9B318C7216E1A61B9D4B95E729701489AF21AC78E5", + "1B57B6FF7C5B813A7F1825B64326D917AFB10F6C92CB657205FBDCADDB759C55", SnapshotHash(actual)); } @@ -175,10 +173,27 @@ public sealed class PagedQuoteSceneBuildersTests item.StartsWith("V:title", StringComparison.Ordinal) && item.EndsWith('=') && item != "V:title=")); Assert.Equal( - "65D6B6A718EC77A6CA9071273E04D177F8DDFC34D736E9902BCCA0F12D8646C0", + "7FAC4957A218E8773AD3446A4AF88775EB255DA03407D14E52F4B31A6776BBE8", SnapshotHash(actual)); } + [Fact] + public void Builders_emit_only_global_header_objects_present_in_authoritative_assets() + { + var six = new S5077SceneMutationBuilder().Build(Data(6, 1)); + Assert.DoesNotContain(six, mutation => + mutation is PlayoutSetValue { ObjectName: "title" or "unit" or + "column1" or "column2" or "column3" }); + + var twelve = new S5088SceneMutationBuilder().Build(Data(12, 1)); + Assert.Contains(twelve, mutation => + mutation is PlayoutSetValue { ObjectName: "title" }); + Assert.Contains(twelve, mutation => + mutation is PlayoutSetValue { ObjectName: "unit" }); + Assert.DoesNotContain(twelve, mutation => + mutation is PlayoutSetValue { ObjectName: "column1" or "column2" or "column3" }); + } + [Theory] [InlineData("s5074", 5)] [InlineData("s5077", 6)] diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/PagedQuoteSceneDataLoadersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/PagedQuoteSceneDataLoadersTests.cs index 89ce35d..ca0209b 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/PagedQuoteSceneDataLoadersTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/PagedQuoteSceneDataLoadersTests.cs @@ -160,9 +160,9 @@ public sealed class PagedQuoteSceneDataLoadersTests Assert.Equal("Analyst A", executor.Spec.Parameters[1].Value); Assert.Equal(PagedQuoteBranch.Expert, data.Branch); Assert.Equal(PagedQuoteHeaderStyle.ExpertReturns, data.HeaderStyle); - Assert.Contains(new PlayoutSetValue("column1", "\uB9E4\uC218\uAC00"), mutations); - Assert.Contains(new PlayoutSetValue("column2", "\uD604\uC7AC\uAC00"), mutations); - Assert.Contains(new PlayoutSetValue("column3", "\uC218\uC775\uB960"), mutations); + Assert.DoesNotContain(mutations, mutation => + mutation is PlayoutSetValue { ObjectName: "title" or "unit" or + "column1" or "column2" or "column3" }); } [Fact] diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ParameterizedMarketSceneBuildersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ParameterizedMarketSceneBuildersTests.cs index 18769aa..ddc09b1 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ParameterizedMarketSceneBuildersTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ParameterizedMarketSceneBuildersTests.cs @@ -379,7 +379,7 @@ public sealed class ParameterizedMarketSceneBuildersTests new S8001SectorQuote(S8001Sector.MedicalPrecision, S8001SectorSignal.Negative, 10.00m), new S8001SectorQuote(S8001Sector.TextilesAndApparel, S8001SectorSignal.Flat, 0m), new S8001SectorQuote(S8001Sector.Chemicals, S8001SectorSignal.Positive, 1m), - new S8001SectorQuote(S8001Sector.NonMetallicMinerals, S8001SectorSignal.Negative, 1m) + new S8001SectorQuote(S8001Sector.Pharmaceuticals, S8001SectorSignal.Negative, 1m) ])).Select(Describe).ToArray(); Assert.Contains("V:통신_등락=0.99", actual); @@ -397,7 +397,7 @@ public sealed class ParameterizedMarketSceneBuildersTests Assert.Contains("V:섬유의복_등락=0", actual); Assert.DoesNotContain(actual, value => value.StartsWith("C:섬유의복_큐브=", StringComparison.Ordinal)); Assert.Contains("V:화학_등락=1", actual); - Assert.Contains("V:비금속광물_등락=-1", actual); + Assert.Contains("V:의약품_등락=-1", actual); } [Fact] diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ParameterizedMarketSceneDataLoadersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ParameterizedMarketSceneDataLoadersTests.cs index ee2ac2b..07601e2 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ParameterizedMarketSceneDataLoadersTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ParameterizedMarketSceneDataLoadersTests.cs @@ -491,7 +491,7 @@ public sealed class ParameterizedMarketSceneDataLoadersTests Assert.All(codes, code => Assert.Contains($"'{code}'", call.Spec.Sql, StringComparison.Ordinal)); Assert.Equal(15, data.Sectors.Count); Assert.Contains(data.Sectors, row => row.Sector == S8001Sector.Chemicals); - Assert.Contains(data.Sectors, row => row.Sector == S8001Sector.NonMetallicMinerals); + Assert.Contains(data.Sectors, row => row.Sector == S8001Sector.Pharmaceuticals); Assert.NotEmpty(mutations); Assert.Equal(0, executor.StringCalls); } diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ReadOnlyPanelSceneBuildersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ReadOnlyPanelSceneBuildersTests.cs index 6b5f757..a37bb01 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/ReadOnlyPanelSceneBuildersTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/ReadOnlyPanelSceneBuildersTests.cs @@ -101,10 +101,9 @@ public sealed class ReadOnlyPanelSceneBuildersTests "S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0", "S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0", "S:up=1", - "S:bg1_=1", "S:changePrice1_=1", "S:wonText1_=1", "S:rate1_=1", "S:percent1_=1", - "S:bg2_=0", "S:changePrice2_=0", "S:wonText2_=0", "S:rate2_=0", "S:percent2_=0", - "S:bg3_=0", "S:changePrice3_=0", "S:wonText3_=0", "S:rate3_=0", "S:percent3_=0", - @"A:pattern=Images\그림_빨강.png", + "S:bg1_=1", "S:changePrice1_=1", "S:rate1_=1", "S:percent1_=1", + "S:bg2_=0", "S:changePrice2_=0", "S:rate2_=0", "S:percent2_=0", + "S:bg3_=0", "S:changePrice3_=0", "S:rate3_=0", "S:percent3_=0", "S:mart=1" ], actual); } @@ -130,7 +129,11 @@ public sealed class ReadOnlyPanelSceneBuildersTests ], actual.Take(13).Select(Describe)); Assert.Equal("V:changePrice2_=0", Describe(actual[14])); Assert.Equal("S:mart=0", Describe(actual[^1])); - Assert.Equal(44, actual.Count); + Assert.Equal(40, actual.Count); + Assert.DoesNotContain(actual, mutation => mutation is PlayoutSetAssetValue); + Assert.DoesNotContain( + actual.OfType(), + mutation => mutation.ObjectName.StartsWith("wonText", StringComparison.Ordinal)); } [Fact] @@ -153,7 +156,8 @@ public sealed class ReadOnlyPanelSceneBuildersTests "V:r_value1=12,345", "V:r_value2=9", "V:r_value3=1,111", "V:r_value4=2,222" ], actual.Take(13).Select(Describe)); Assert.Equal("V:changePrice3_=-100", Describe(actual[14])); - Assert.Equal(@"A:pattern=Images\그림_파랑.png", Describe(actual[^2])); + Assert.Equal("S:percent3_=1", Describe(actual[^2])); + Assert.DoesNotContain(actual, mutation => mutation is PlayoutSetAssetValue); } [Fact] diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/RegisteredLegacySceneCueProviderTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/RegisteredLegacySceneCueProviderTests.cs index d147922..38745d2 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/RegisteredLegacySceneCueProviderTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/RegisteredLegacySceneCueProviderTests.cs @@ -54,6 +54,66 @@ public sealed class RegisteredLegacySceneCueProviderTests page.Cue.Mutations!.Skip(2)); } + [Fact] + public async Task CreatePage_8032_omits_only_objects_absent_from_its_authoritative_asset() + { + var data = new S8018MarketPairSceneData( + LegacyMarketQuoteTarget.Kosdaq, + LegacyMarketQuoteTarget.Kospi, + new S8018MarketQuote("코스닥", 866.72m, -62.63m, -6.74m, ScenePriceDirection.Down), + new S8018MarketQuote("코스피", 7_648.09m, -655.32m, -7.89m, ScenePriceDirection.Down)); + var provider = new RegisteredLegacySceneCueProvider( + new StubDataSource(new LegacySceneDataPage("s8018", data, 1))); + + var page = await provider.CreatePageAsync(new("entry", "8032"), 0); + var mutations = page.Cue.Mutations!; + + Assert.Contains(new PlayoutSetValue("title1", "코스닥"), mutations); + Assert.Contains(new PlayoutSetValue("price2", "7,648.09"), mutations); + Assert.Contains(new PlayoutSetValue("unitText1", "단위 : p"), mutations); + Assert.DoesNotContain(mutations, mutation => mutation switch + { + PlayoutSetAssetValue { ObjectName: "pattern1" or "pattern2" } => true, + PlayoutSetValue { ObjectName: "wonText1" or "wonText2" } => true, + PlayoutSetVisible visible => visible.ObjectName.StartsWith("percent", StringComparison.Ordinal) || + visible.ObjectName.StartsWith("wonText", StringComparison.Ordinal), + _ => false + }); + } + + [Fact] + public async Task CreatePage_5032_omits_only_decorations_absent_from_its_authoritative_asset() + { + var data = new S5032IndexAndFuturesSceneData( + LegacyMarketQuoteTarget.Kospi, + LegacyMarketQuoteTarget.Futures, + new S5032PrimaryQuote("KOSPI", 7_648.09m, -655.32m, -7.89m, ScenePriceDirection.Down), + new S5032CompanionQuote( + "Futures", + 1_240.9m, + -102.35m, + S5032CompanionSignal.Negative, + 153_907m, + 54_477_919m, + 171_636m)); + var provider = new RegisteredLegacySceneCueProvider( + new StubDataSource(new LegacySceneDataPage("s5032", data, 1))); + + var page = await provider.CreatePageAsync(new("entry", "5032"), 0); + var mutations = page.Cue.Mutations!; + + Assert.Contains(new PlayoutSetValue("title", "KOSPI"), mutations); + Assert.Contains(new PlayoutSetValue("price", "7,648.09"), mutations); + Assert.Contains(new PlayoutSetValue("title1", "Futures"), mutations); + Assert.Contains(new PlayoutSetValue("r_value3", "171,636"), mutations); + Assert.DoesNotContain(mutations, mutation => mutation switch + { + PlayoutSetAssetValue { ObjectName: "pattern" } => true, + PlayoutSetVisible visible => visible.ObjectName.EndsWith('_'), + _ => false + }); + } + [Fact] public async Task CreatePage_CommonVideoBackgroundUsesOriginalRawK3dLoopValueTen() { diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/TabularMarketSceneBuildersTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/TabularMarketSceneBuildersTests.cs index 4a42e2d..c8a8bed 100644 --- a/tests/MBN_STOCK_WEBVIEW.Core.Tests/TabularMarketSceneBuildersTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/TabularMarketSceneBuildersTests.cs @@ -133,7 +133,7 @@ public sealed class TabularMarketSceneBuildersTests Assert.Equal( [ - "V:title=A", "V:unit=\uB2E8\uC704 : \uC6D0", "V:price=10,000", + "V:title=A", "V:price=10,000", "V:changePrice=0", "V:rate=0.00", "S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0", "S:bg1=0", "S:bg2=1", "S:bg3=0", @@ -162,10 +162,13 @@ public sealed class TabularMarketSceneBuildersTests Assert.Equal( ["S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0"], - actual.Skip(5).Take(5)); + actual.Skip(4).Take(5)); if (activeDirection is null) { - Assert.DoesNotContain(actual.Skip(10).Take(1), value => value.EndsWith("=1")); + Assert.DoesNotContain( + actual, + value => value is "S:upup=1" or "S:up=1" or "S:flat=1" or + "S:down=1" or "S:downdown=1"); } else { @@ -193,7 +196,7 @@ public sealed class TabularMarketSceneBuildersTests Assert.Equal( [ - "V:title=A", "V:unit=\uB2E8\uC704 : \uC6D0", "V:price=100", + "V:title=A", "V:price=100", "V:changePrice=5", "V:rate=1.25", "S:upup=0", "S:up=0", "S:flat=0", "S:down=0", "S:downdown=0", "S:up=1", "S:bg1=1", "S:bg2=0", "S:bg3=0", diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorControllerTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorControllerTests.cs index a296a59..a3716b6 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorControllerTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorControllerTests.cs @@ -539,6 +539,35 @@ public sealed class LegacyOperatorControllerTests Assert.Single(state.Playlist); } + [Fact] + public async Task PointerSelectionWithoutAppendPermission_NeverAppendsAndResetsDoubleClick() + { + var controller = new LegacyOperatorController(Lookup()); + await controller.SearchStocksAsync("삼성", LegacySearchTrigger.Button); + controller.SelectStock(0); + + controller.CutPointerDown( + 1, false, false, 20, 45, 1_000, allowAppend: true); + var onAirSelection = controller.CutPointerDown( + 1, false, false, 20, 45, 1_200, allowAppend: false); + + Assert.Empty(onAirSelection.Playlist); + Assert.Equal( + 1, + Assert.Single(onAirSelection.CutRows, row => row.IsSelected).PhysicalIndex); + Assert.Equal( + 1, + Assert.Single(onAirSelection.CutRows, row => row.IsFocused).PhysicalIndex); + + var firstIdleClick = controller.CutPointerDown( + 1, false, false, 20, 45, 1_400, allowAppend: true); + Assert.Empty(firstIdleClick.Playlist); + + var secondIdleClick = controller.CutPointerDown( + 1, false, false, 20, 45, 1_600, allowAppend: true); + Assert.Single(secondIdleClick.Playlist); + } + [Fact] public async Task DifferentPointerPosition_DoesNotCountAsLegacyDoubleClick() { diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOverseasSelectionWorkflowTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOverseasSelectionWorkflowTests.cs index 3947ea0..20b4e43 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOverseasSelectionWorkflowTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOverseasSelectionWorkflowTests.cs @@ -276,6 +276,29 @@ public sealed class LegacyOverseasSelectionWorkflowTests } } + [Fact] + public async Task CurrentProviderPunctuationRemainsAnExactBoundStockIdentity() + { + var workflow = CreateWorkflow(stocks: + [ + new("Ampersand", "IDX@A&B", "US"), + new("Parenthesized", "IDX@(A)", "US") + ]); + + var searched = await workflow.SearchStocksAsync(string.Empty); + + Assert.Equal(LegacyOverseasSearchStatus.Ready, searched.Stock.Status); + Assert.Equal(2, searched.Stock.Results.Count); + workflow.SelectStock(searched.Stock.Results[0].SelectionId); + Assert.Equal( + "IDX@A&B|US|1", + workflow.MaterializePlaylistDraft("stock-current").Selection.DataCode); + workflow.SelectStock(searched.Stock.Results[1].SelectionId); + Assert.Equal( + "IDX@(A)|US|1", + workflow.MaterializePlaylistDraft("stock-candle-5d").Selection.DataCode); + } + [Theory] [InlineData(false, false, "")] [InlineData(true, false, "MA5")] diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyStockCutCatalogTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyStockCutCatalogTests.cs index 4e8f115..c3c6f53 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyStockCutCatalogTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyStockCutCatalogTests.cs @@ -62,4 +62,30 @@ public sealed class LegacyStockCutCatalogTests Assert.Equal("5001", playout.CutCode); Assert.Equal("005930", playout.Selection?.DataCode); } + + [Theory] + [InlineData(5, "액면가", S5011DetailBranch.FaceValue)] + [InlineData(6, "PBR", S5011DetailBranch.Valuation)] + [InlineData(7, "거래량", S5011DetailBranch.Volume)] + public void S5011_rows_preserve_visible_subtype_and_use_canonical_runtime_branch( + int physicalIndex, + string visibleSubtype, + S5011DetailBranch runtimeBranch) + { + var stock = new LegacyStockIdentity( + LegacyDomesticStockMarket.Kospi, + "삼성전자", + "005930"); + + var row = LegacyStockCutCatalog.CreatePlaylistRow( + "row-1", + stock.DisplayName, + stock, + LegacyStockCutCatalog.Rows[physicalIndex]); + + Assert.Equal(visibleSubtype, row.Subtype); + Assert.Equal(runtimeBranch.ToString(), row.PlayoutSelection?.Subtype); + Assert.Equal("5011", row.ToPlayoutEntry().CutCode); + Assert.Equal(runtimeBranch.ToString(), row.ToPlayoutEntry().Selection?.Subtype); + } } diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/NamedPlaylist/LegacyNamedPlaylistOperatorIntegrationTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/NamedPlaylist/LegacyNamedPlaylistOperatorIntegrationTests.cs index 94a0ac9..557739c 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/NamedPlaylist/LegacyNamedPlaylistOperatorIntegrationTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/NamedPlaylist/LegacyNamedPlaylistOperatorIntegrationTests.cs @@ -56,6 +56,52 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests loaded.NamedPlaylist?.DocumentFreshness); } + [Theory] + [InlineData("fixed-047")] + [InlineData("fixed-044")] + [InlineData("fixed-076")] + [InlineData("fixed-063")] + [InlineData("fixed-085")] + [InlineData("fixed-105")] + [InlineData("fixed-234")] + [InlineData("fixed-236")] + [InlineData("fixed-275")] + [InlineData("fixed-233")] + [InlineData("fixed-240")] + [InlineData("fixed-235")] + [InlineData("fixed-244")] + [InlineData("fixed-302")] + [InlineData("fixed-243")] + [InlineData("fixed-136")] + public async Task Save_and_load_round_trip_restores_fixed_playout_selection( + string actionId) + { + var persistence = new OperatorNamedPlaylistPersistence([]); + var controller = CreateController(persistence); + var action = LegacyFixedActionCatalog.Default.GetAction(actionId); + controller.SelectTab(action.Market switch + { + LegacyFixedMarket.Overseas => LegacyOperatorTabId.Overseas, + LegacyFixedMarket.Exchange => LegacyOperatorTabId.Exchange, + LegacyFixedMarket.Index => LegacyOperatorTabId.Index, + _ => throw new ArgumentOutOfRangeException() + }); + controller.ActivateFixedAction(actionId); + var before = Assert.Single(controller.CreatePlayoutRequest().Playlist); + var listed = await controller.RefreshNamedPlaylistsAsync(); + var definitionId = Assert.Single( + listed.NamedPlaylist!.Definitions).DefinitionId; + controller.SelectNamedPlaylist(definitionId); + await controller.SaveCurrentNamedPlaylistAsync(); + + var loaded = await controller.LoadNamedPlaylistByIdAsync(definitionId); + var after = Assert.Single(controller.CreatePlayoutRequest().Playlist); + + Assert.True(loaded.CommandResult?.Succeeded); + Assert.Equal(before.CutCode, after.CutCode); + Assert.Equal(before.Selection, after.Selection); + } + [Fact] public async Task LoadSelected_ReplacesPlaylistWithTypedOriginalSevenFields() { @@ -123,11 +169,294 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests Assert.Equal(before.RowId, Assert.Single(after.Playlist).RowId); Assert.NotNull(after.Dialog); + Assert.Contains("1행", after.Dialog.Message, StringComparison.Ordinal); Assert.Equal(LegacyOperatorStatusKind.Error, after.StatusKind); Assert.Equal("load-named-playlist-by-id", after.CommandResult?.Command); Assert.False(after.CommandResult?.Succeeded); } + [Theory] + [InlineData("INDIVIDUAL", "INDEX", "MANUAL_NET_SELL", "MANUAL_NET_SELL")] + [InlineData("FOREIGN", "INDEX", "MANUAL_NET_SELL", "MANUAL_NET_SELL")] + [InlineData("INSTITUTION", "INDEX", "MANUAL_NET_SELL", "MANUAL_NET_SELL")] + [InlineData("개인 순매도 상위(수동)", "지수", "순매도 상위", "순매도 상위")] + [InlineData("지수", "개인 순매도 상위(수동)", "순매도 상위", "순매도 상위")] + public async Task Load_by_opaque_id_restores_trusted_manual_net_sell_alias( + string groupCode, + string subject, + string graphicType, + string subtype) + { + var persistence = new OperatorNamedPlaylistPersistence( + [ + new NamedPlaylistStoredItem( + 0, + true, + new LegacySceneSelection( + groupCode, + subject, + graphicType, + subtype, + string.Empty), + new NamedPlaylistPageState(1, 1)) + ]); + var controller = CreateController(persistence); + var listed = await controller.RefreshNamedPlaylistsAsync(); + var definitionId = Assert.Single( + listed.NamedPlaylist!.Definitions).DefinitionId; + + var loaded = await controller.LoadNamedPlaylistByIdAsync(definitionId); + + Assert.True(loaded.CommandResult?.Succeeded); + Assert.Null(loaded.Dialog); + var row = Assert.Single(loaded.Playlist); + Assert.Equal("5025", row.SceneAlias); + var expectedAudience = groupCode == "FOREIGN" || subject.StartsWith("외국인", StringComparison.Ordinal) + ? "FOREIGN" + : groupCode == "INSTITUTION" || subject.StartsWith("기관", StringComparison.Ordinal) + ? "INSTITUTION" + : "INDIVIDUAL"; + Assert.Equal(expectedAudience, row.PlayoutSelection?.GroupCode); + Assert.Equal("INDEX", row.PlayoutSelection?.Subject); + Assert.Equal("MANUAL_NET_SELL", row.PlayoutSelection?.GraphicType); + } + + [Fact] + public async Task Load_by_opaque_id_restores_versioned_vi_alias() + { + var persistence = new OperatorNamedPlaylistPersistence( + [ + new NamedPlaylistStoredItem( + 0, + true, + new LegacySceneSelection( + "PAGED_VI", + TrustedViManualReference.SubjectSentinel, + "INPUT_ORDER", + "CURRENT", + new string('a', 64)), + new NamedPlaylistPageState(1, 1)) + ]); + var controller = CreateController(persistence); + var listed = await controller.RefreshNamedPlaylistsAsync(); + var definitionId = Assert.Single( + listed.NamedPlaylist!.Definitions).DefinitionId; + + var loaded = await controller.LoadNamedPlaylistByIdAsync(definitionId); + + Assert.True(loaded.CommandResult?.Succeeded); + Assert.Null(loaded.Dialog); + Assert.Equal("5074", Assert.Single(loaded.Playlist).SceneAlias); + } + + [Fact] + public async Task Load_by_opaque_id_restores_saved_visible_vi_fields_to_versioned_identity() + { + var version = new string('b', 64); + var persistence = new OperatorNamedPlaylistPersistence( + [ + new NamedPlaylistStoredItem( + 0, + true, + new LegacySceneSelection( + "지수", + "삼성전자,SK하이닉스", + "5단 표그래프", + "VI 발동(수동)", + version), + new NamedPlaylistPageState(1, 1)) + ]); + var controller = CreateController(persistence); + var listed = await controller.RefreshNamedPlaylistsAsync(); + var definitionId = Assert.Single( + listed.NamedPlaylist!.Definitions).DefinitionId; + + var loaded = await controller.LoadNamedPlaylistByIdAsync(definitionId); + + var row = Assert.Single(loaded.Playlist); + Assert.True(loaded.CommandResult?.Succeeded); + Assert.Equal("5074", row.SceneAlias); + Assert.Equal("PAGED_VI", row.PlayoutSelection?.GroupCode); + Assert.Equal(TrustedViManualReference.SubjectSentinel, + row.PlayoutSelection?.Subject); + Assert.Equal(version, row.PlayoutSelection?.DataCode); + } + + [Theory] + [InlineData("액면가", S5011DetailBranch.FaceValue)] + [InlineData("PBR", S5011DetailBranch.Valuation)] + [InlineData("거래량", S5011DetailBranch.Volume)] + public async Task Load_by_opaque_id_restores_saved_stock_detail_branch( + string visibleSubtype, + S5011DetailBranch expectedBranch) + { + var persistence = new OperatorNamedPlaylistPersistence( + [ + new NamedPlaylistStoredItem( + 0, + true, + new LegacySceneSelection( + "코스피", + "삼성전자", + "1열판상세", + visibleSubtype, + "005930"), + new NamedPlaylistPageState(1, 1)) + ]); + var controller = CreateController(persistence); + var listed = await controller.RefreshNamedPlaylistsAsync(); + + var loaded = await controller.LoadNamedPlaylistByIdAsync( + Assert.Single(listed.NamedPlaylist!.Definitions).DefinitionId); + + var row = Assert.Single(loaded.Playlist); + Assert.True(loaded.CommandResult?.Succeeded); + Assert.Equal("5011", row.SceneAlias); + Assert.Equal(expectedBranch.ToString(), row.PlayoutSelection?.Subtype); + Assert.Equal("005930", row.PlayoutSelection?.DataCode); + } + + [Fact] + public async Task Load_by_opaque_id_restores_saved_industry_square_selection() + { + var persistence = new OperatorNamedPlaylistPersistence( + [ + new NamedPlaylistStoredItem( + 0, + true, + new LegacySceneSelection( + "업종_코스피", + "코스피 네모그래프", + "네모그래프", + string.Empty, + string.Empty), + new NamedPlaylistPageState(1, 1)) + ]); + var controller = CreateController(persistence); + var listed = await controller.RefreshNamedPlaylistsAsync(); + + var loaded = await controller.LoadNamedPlaylistByIdAsync( + Assert.Single(listed.NamedPlaylist!.Definitions).DefinitionId); + + var row = Assert.Single(loaded.Playlist); + Assert.True(loaded.CommandResult?.Succeeded); + Assert.Equal("8001", row.SceneAlias); + Assert.Equal( + new LegacySceneSelection( + "INDUSTRY_KOSPI", + "KOSPI", + "네모그래프", + string.Empty, + string.Empty), + row.PlayoutSelection); + } + + [Fact] + public async Task Load_by_opaque_id_restores_saved_comparison_selections() + { + var stockIdentity = "KOSPI:016360|KOSPI:068290"; + var persistence = new OperatorNamedPlaylistPersistence( + [ + StoredComparison(0, "s5026", "종목별 비교분석_캔들 그래프", stockIdentity), + StoredComparison(1, "s5087", "종목별 비교분석_라인 그래프", stockIdentity), + StoredComparison(2, "s5029", "5일", stockIdentity), + new NamedPlaylistStoredItem( + 3, + true, + new LegacySceneSelection( + "비교", + "코스닥 지수 ↔ 코스피 지수", + "s8018", + "2열판", + string.Empty), + new NamedPlaylistPageState(1, 1)), + new NamedPlaylistStoredItem( + 4, + true, + new LegacySceneSelection( + "비교", + "선물 지수 ↔ 코스피 지수", + "s5032", + "2열판", + string.Empty), + new NamedPlaylistPageState(1, 1)) + ]); + var controller = CreateController(persistence); + var listed = await controller.RefreshNamedPlaylistsAsync(); + + var loaded = await controller.LoadNamedPlaylistByIdAsync( + Assert.Single(listed.NamedPlaylist!.Definitions).DefinitionId); + + Assert.True(loaded.CommandResult?.Succeeded); + Assert.Equal( + ["5026", "5087", "5029", "8032", "5032"], + loaded.Playlist.Select(row => row.SceneAlias)); + Assert.Equal( + ["COMPARISON_CANDLE", "COMPARISON_LINE", "RETURN_COMPARISON"], + loaded.Playlist.Take(3).Select(row => row.PlayoutSelection?.GraphicType)); + Assert.All(loaded.Playlist.Take(3), row => + { + Assert.Equal("STOCK", row.PlayoutSelection?.GroupCode); + Assert.Equal("삼성증권,삼성출판사", row.PlayoutSelection?.Subject); + Assert.Equal(stockIdentity, row.PlayoutSelection?.DataCode); + }); + Assert.Equal("Kosdaq,Kospi", loaded.Playlist[3].PlayoutSelection?.Subject); + Assert.Equal("Futures,Kospi", loaded.Playlist[4].PlayoutSelection?.Subject); + } + + [Theory] + [InlineData("KOSDAQ", "APS", "주요매출 구성", "REVENUE_COMPOSITION")] + [InlineData("KOSPI", "AJ네트웍스", "성장성 지표", "GROWTH_METRICS")] + [InlineData("KOSDAQ", "3S", "매출액", "SALES")] + [InlineData("KOSDAQ", "3S", "영업이익", "OPERATING_PROFIT")] + public async Task Load_by_opaque_id_restores_saved_manual_financial_selection( + string market, + string stockName, + string visibleGraphicType, + string canonicalGraphicType) + { + var persistence = new OperatorNamedPlaylistPersistence( + [ + new NamedPlaylistStoredItem( + 0, + true, + new LegacySceneSelection( + market, + stockName, + visibleGraphicType, + canonicalGraphicType, + "000001"), + new NamedPlaylistPageState(1, 1)) + ]); + var controller = CreateController(persistence); + var listed = await controller.RefreshNamedPlaylistsAsync(); + + var loaded = await controller.LoadNamedPlaylistByIdAsync( + Assert.Single(listed.NamedPlaylist!.Definitions).DefinitionId); + + var row = Assert.Single(loaded.Playlist); + Assert.True(loaded.CommandResult?.Succeeded); + Assert.Equal(canonicalGraphicType, row.PlayoutSelection?.GraphicType); + Assert.Equal(string.Empty, row.PlayoutSelection?.Subtype); + Assert.Equal(string.Empty, row.PlayoutSelection?.DataCode); + } + + private static NamedPlaylistStoredItem StoredComparison( + int itemIndex, + string builderKey, + string detail, + string dataCode) => + new( + itemIndex, + true, + new LegacySceneSelection( + "비교", + "삼성증권 ↔ 삼성출판사", + builderKey, + detail, + dataCode), + new NamedPlaylistPageState(1, 1)); + [Fact] public async Task SaveCurrent_WritesVisibleSevenFieldsOnceAndReadsBackFresh() { diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyBridgeProtocolTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyBridgeProtocolTests.cs index 5fc2323..8ca3e09 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyBridgeProtocolTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests/LegacyBridgeProtocolTests.cs @@ -29,13 +29,27 @@ public sealed class LegacyBridgeProtocolTests public void ParsePointer_RequiresExactBoundedInput() { const string json = - "{\"type\":\"cut-pointer-down\",\"payload\":{\"physicalIndex\":1,\"control\":false,\"shift\":true,\"x\":20,\"y\":45,\"timestampMilliseconds\":1400}}"; + "{\"type\":\"cut-pointer-down\",\"payload\":{\"physicalIndex\":1,\"control\":false,\"shift\":true,\"x\":20,\"y\":45,\"timestampMilliseconds\":1400,\"allowAppend\":false}}"; Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out _)); var pointer = Assert.IsType(parsed); Assert.Equal(1, pointer.PhysicalIndex); Assert.True(pointer.Shift); Assert.Equal(1_400, pointer.TimestampMilliseconds); + Assert.False(pointer.AllowAppend); + + Assert.False(LegacyBridgeProtocol.TryParseIntent( + "{\"type\":\"cut-pointer-down\",\"payload\":{\"physicalIndex\":1,\"control\":false,\"shift\":true,\"x\":20,\"y\":45,\"timestampMilliseconds\":1400}}", + out _, + out _)); + Assert.False(LegacyBridgeProtocol.TryParseIntent( + "{\"type\":\"cut-pointer-down\",\"payload\":{\"physicalIndex\":1,\"control\":false,\"shift\":true,\"x\":20,\"y\":45,\"timestampMilliseconds\":1400,\"allowAppend\":0}}", + out _, + out _)); + Assert.False(LegacyBridgeProtocol.TryParseIntent( + "{\"type\":\"cut-pointer-down\",\"payload\":{\"physicalIndex\":1,\"control\":false,\"shift\":true,\"x\":20,\"y\":45,\"timestampMilliseconds\":1400,\"allowAppend\":true,\"sceneCode\":\"5001\"}}", + out _, + out _)); } [Theory] @@ -48,7 +62,8 @@ public sealed class LegacyBridgeProtocolTests var json = "{\"type\":\"cut-pointer-down\",\"payload\":{" + $"\"physicalIndex\":{physicalIndex},\"control\":false," + - "\"shift\":false,\"x\":0,\"y\":0,\"timestampMilliseconds\":0}}"; + "\"shift\":false,\"x\":0,\"y\":0,\"timestampMilliseconds\":0," + + "\"allowAppend\":true}}"; Assert.Equal(expected, LegacyBridgeProtocol.TryParseIntent( json, diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDatabaseHealthMonitorNativeContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDatabaseHealthMonitorNativeContractTests.cs new file mode 100644 index 0000000..e7699c1 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyDatabaseHealthMonitorNativeContractTests.cs @@ -0,0 +1,166 @@ +namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests; + +public sealed class LegacyDatabaseHealthMonitorNativeContractTests +{ + private static readonly string RepositoryRoot = FindRepositoryRoot(); + private static readonly string AppRoot = Path.Combine( + RepositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp"); + private static readonly string Window = File.ReadAllText( + Path.Combine(AppRoot, "MainWindow.xaml.cs")); + private static readonly string Monitor = File.ReadAllText( + Path.Combine(AppRoot, "MainWindow.DatabaseHealth.cs")); + private static readonly string Playout = File.ReadAllText( + Path.Combine(AppRoot, "MainWindow.Playout.cs")); + + [Fact] + public void LegacyTenSecondMonitorStartsAndStopsWithTheActiveWindow() + { + Assert.Contains( + "TimeSpan.FromSeconds(10)", + Monitor, + StringComparison.Ordinal); + Assert.Contains( + "await Task.Delay(DatabaseHealthPollingInterval, cancellationToken)", + Monitor, + StringComparison.Ordinal); + Assert.Contains( + "StartDatabaseHealthMonitor();", + Window, + StringComparison.Ordinal); + Assert.Contains( + "_lifetimeCancellation.Cancel();\n CancelActiveDatabaseHealthCheck();", + Normalize(Window), + StringComparison.Ordinal); + } + + [Fact] + public void MonitorUsesBothConfiguredProvidersAndLogsOnlyClosedTransitions() + { + Assert.Contains( + ".HealthService\n .CheckAllAsync(healthCancellation.Token)", + Normalize(Monitor), + StringComparison.Ordinal); + Assert.Contains( + "foreach (var status in statuses)", + Monitor, + StringComparison.Ordinal); + Assert.Contains( + "ObserveDatabaseHealthState(status.Source, status.State);", + Monitor, + StringComparison.Ordinal); + Assert.Contains( + "_databaseHealthTransitions.TryObserveDatabaseState(", + Monitor, + StringComparison.Ordinal); + Assert.Contains( + "_databaseHealthLogWriter.TryWrite(record)", + Monitor, + StringComparison.Ordinal); + Assert.DoesNotContain("status.UserMessage", Monitor, StringComparison.Ordinal); + Assert.DoesNotContain("exception.Message", Monitor, StringComparison.Ordinal); + } + + [Fact] + public void OperatorAndRefreshWorkCancelAndSerializeAgainstHealthProbes() + { + var dispatch = Slice( + Window, + "private async Task ProcessIntentAsync(", + "private static bool IsNamedPlaylistModalIntent("); + var databaseWait = RequiredIndex( + dispatch, + "await AcquirePriorityDatabaseActivityAsync(_lifetimeCancellation.Token)"); + var handle = RequiredIndex(dispatch, "await HandleIntentAsync("); + Assert.True(databaseWait < handle); + Assert.Contains("_databaseActivityGate.Release();", dispatch, + StringComparison.Ordinal); + + var refresh = Slice( + Playout, + "private async Task RunRefreshLoopAsync(", + "private bool TrySetRefreshFault("); + databaseWait = RequiredIndex( + refresh, + "await AcquirePriorityDatabaseActivityAsync(token)"); + var refreshCall = RequiredIndex(refresh, "workflow.RefreshOnAirAsync"); + Assert.True(databaseWait < refreshCall); + Assert.Contains("_databaseActivityGate.Release();", refresh, + StringComparison.Ordinal); + } + + [Fact] + public void OperatorPriorityClosesTheHealthProbeCancellationPublishRace() + { + var acquire = Slice( + Monitor, + "private async Task AcquirePriorityDatabaseActivityAsync(", + "private void CancelActiveDatabaseHealthCheck()"); + var waiterIncrement = RequiredIndex( + acquire, + "Interlocked.Increment(ref _databaseActivityPriorityWaiterCount);"); + var cancel = RequiredIndex(acquire, "CancelActiveDatabaseHealthCheck();"); + var gateWait = RequiredIndex( + acquire, + "await _databaseActivityGate.WaitAsync(cancellationToken)"); + var waiterDecrement = RequiredIndex( + acquire, + "Interlocked.Decrement(ref _databaseActivityPriorityWaiterCount);"); + Assert.True(waiterIncrement < cancel); + Assert.True(cancel < gateWait); + Assert.True(gateWait < waiterDecrement); + + var probe = Slice( + Monitor, + "private async Task CheckAndLogDatabaseHealthAsync(", + "private async Task AcquirePriorityDatabaseActivityAsync("); + var preAcquirePriorityCheck = RequiredIndex( + probe, + "Volatile.Read(ref _databaseActivityPriorityWaiterCount) != 0"); + var healthCancellationPublish = RequiredIndex( + probe, + "Interlocked.Exchange(\n ref _databaseHealthCheckCancellation,\n healthCancellation);"); + var postPublishPriorityCheck = RequiredIndex( + probe, + "Volatile.Read(ref _databaseActivityPriorityWaiterCount) != 0", + healthCancellationPublish); + var providerProbe = RequiredIndex(probe, ".CheckAllAsync(healthCancellation.Token)"); + Assert.True(preAcquirePriorityCheck < healthCancellationPublish); + Assert.True(healthCancellationPublish < postPublishPriorityCheck); + Assert.True(postPublishPriorityCheck < providerProbe); + } + + private static string Normalize(string value) => + value.Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string Slice(string value, string start, string end) + { + var startIndex = RequiredIndex(value, start); + var endIndex = RequiredIndex(value, end, startIndex + start.Length); + return value[startIndex..endIndex]; + } + + private static int RequiredIndex(string value, string needle, int startIndex = 0) + { + var index = value.IndexOf(needle, startIndex, StringComparison.Ordinal); + Assert.True(index >= 0, $"Required marker was not found: {needle}"); + return index; + } + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "MBN_STOCK_WEBVIEW.sln"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Repository root was not found."); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyManualFinancialParityWebTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyManualFinancialParityWebTests.cs index 60444b2..d023548 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyManualFinancialParityWebTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyManualFinancialParityWebTests.cs @@ -86,6 +86,21 @@ public sealed class LegacyManualFinancialParityWebTests Assert.Contains("generation: generation", App, StringComparison.Ordinal); } + [Fact] + public void GraphE_two_row_toolbar_keeps_find_controls_pointer_accessible() + { + Assert.Contains( + ".manual-financial-workspace { min-height: 0; display: grid; " + + "grid-template-rows: auto minmax(0, 1fr);", + Styles, + StringComparison.Ordinal); + Assert.DoesNotContain( + ".manual-financial-workspace { min-height: 0; display: grid; " + + "grid-template-rows: 38px minmax(0, 1fr);", + Styles, + StringComparison.Ordinal); + } + [Fact] public void Manual_Web_section_sends_values_and_opaque_ids_without_DB_or_scene_authority() { @@ -113,7 +128,9 @@ public sealed class LegacyManualFinancialParityWebTests Assert.Contains("LegacyDeleteManualFinancialIntent", MainWindow, StringComparison.Ordinal); Assert.Contains("LegacyDeleteAllManualFinancialIntent", MainWindow, StringComparison.Ordinal); Assert.Contains("_intentGate.WaitAsync(", MainWindow, StringComparison.Ordinal); - Assert.Contains("0,\n _lifetimeCancellation.Token", MainWindow, StringComparison.Ordinal); + Assert.Matches( + new Regex(@"_intentGate\.WaitAsync\(\s*0,\s*_lifetimeCancellation\.Token\)"), + MainWindow); Assert.DoesNotContain("SaveManualFinancialAsync(", MainWindow[..MainWindow.IndexOf( "HandleIntentAsync", StringComparison.Ordinal)], StringComparison.Ordinal); diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyPlayoutNativeContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyPlayoutNativeContractTests.cs index d9a2efc..e2703ac 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyPlayoutNativeContractTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyPlayoutNativeContractTests.cs @@ -72,6 +72,49 @@ public sealed class LegacyPlayoutNativeContractTests Assert.Contains("WaitForPlayCompletionAsync", Playout, StringComparison.Ordinal); } + [Fact] + public void PlayoutIntentsWaitBehindEarlierUiWorkWithoutQueuingDuplicates() + { + var process = Normalize(Slice( + Window, + "private async Task ProcessIntentAsync(LegacyUiIntent intent)", + "private static bool IsOrderedPlayoutIntent")); + var classifier = Slice( + Window, + "private static bool IsOrderedPlayoutIntent", + "private static bool IsNamedPlaylistModalIntent"); + + var admission = RequiredIndex( + process, + "_orderedPlayoutIntentGate.WaitAsync("); + var orderedWait = RequiredIndex( + process, + "await _intentGate.WaitAsync(_lifetimeCancellation.Token)", + admission); + var zeroTimeoutWait = RequiredIndex( + process, + "gateEntered = await _intentGate.WaitAsync(\n 0,", + orderedWait); + var intentRelease = RequiredIndex(process, "_intentGate.Release();", zeroTimeoutWait); + var admissionRelease = RequiredIndex( + process, + "_orderedPlayoutIntentGate.Release();", + intentRelease); + + Assert.True(admission < orderedWait); + Assert.True(orderedWait < zeroTimeoutWait); + Assert.True(zeroTimeoutWait < intentRelease); + Assert.True(intentRelease < admissionRelease); + Assert.Contains("if (orderedPlayoutIntentEntered)", process, + StringComparison.Ordinal); + Assert.Contains("LegacyExecutePlayoutIntent", classifier, + StringComparison.Ordinal); + Assert.Contains("LegacyGateAPrepareIntent", classifier, + StringComparison.Ordinal); + Assert.Contains("_lifetimeCancellation.Token", process, + StringComparison.Ordinal); + } + [Fact] public void AutomaticRefreshUsesTheCoreSchedulerAndGenerationOwnedState() { @@ -291,7 +334,7 @@ public sealed class LegacyPlayoutNativeContractTests } [Fact] - public void PreparedProgramAndUnknownWriteOutcomesBlockOperatorMutations() + public void PreparedProgramBlocksCutAppendButAllowsPureCutSelection() { Assert.Contains("CanAcceptOperatorMutation()", Window, StringComparison.Ordinal); Assert.Contains("IsOperatorMutationQuarantined()", Window, @@ -308,9 +351,17 @@ public sealed class LegacyPlayoutNativeContractTests StringComparison.Ordinal); Assert.Contains("LegacyConfirmManualListIntent", Playout, StringComparison.Ordinal); - Assert.Contains("LegacyCutPointerDownIntent", Playout, + var mutationClassifier = Slice( + Playout, + "private static bool IsOperatorMutationIntent", + "private static bool IsFixedSectionBatchMutationIntent"); + Assert.Contains("LegacyCutPointerDownIntent { AllowAppend: true }", mutationClassifier, StringComparison.Ordinal); - Assert.Contains("LegacyClearCutSelectionIntent", Playout, + Assert.DoesNotContain("LegacyCutKeyDownIntent", mutationClassifier, + StringComparison.Ordinal); + Assert.DoesNotContain("LegacyClearCutSelectionIntent", mutationClassifier, + StringComparison.Ordinal); + Assert.Contains("pointer.AllowAppend", Window, StringComparison.Ordinal); } diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRowDoubleClickParityTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRowDoubleClickParityTests.cs index c97dc67..5ee926d 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRowDoubleClickParityTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyRowDoubleClickParityTests.cs @@ -33,7 +33,7 @@ public sealed class LegacyRowDoubleClickParityTests } [Fact] - public void Theme_double_click_is_atomic_while_single_click_selects_immediately() + public void Theme_double_click_is_atomic_and_cancels_the_delayed_single_click() { var doubleClick = Extract( "categoryTree.addEventListener(\"dblclick\"", @@ -55,12 +55,17 @@ public sealed class LegacyRowDoubleClickParityTests StringComparison.Ordinal); Assert.DoesNotContain("send(\"select-theme\"", doubleClick, StringComparison.Ordinal); + Assert.Contains("clearDelayedClick(themeResultClickTimer)", doubleClick, + StringComparison.Ordinal); Assert.Contains( - "send(\"select-theme\", { resultId: themeResult.dataset.themeResultId });", + "send(\"select-theme\", { resultId: resultId });", themeClick, StringComparison.Ordinal); - Assert.DoesNotContain("window.setTimeout", themeClick, StringComparison.Ordinal); - Assert.DoesNotContain("themeResultClickTimer", App, StringComparison.Ordinal); + Assert.Contains("clearDelayedClick(themeResultClickTimer)", themeClick, + StringComparison.Ordinal); + Assert.Contains("themeResultClickTimer = window.setTimeout", themeClick, + StringComparison.Ordinal); + Assert.Contains("}, 250);", themeClick, StringComparison.Ordinal); } [Fact] diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWebContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWebContractTests.cs index 6cc105f..45910c3 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWebContractTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyWebContractTests.cs @@ -28,6 +28,7 @@ public sealed class LegacyWebContractTests "moving-average-5", "moving-average-20", "cut-list", + "cut-drag-feedback", "playlist-drop-zone", "playlist-rows", "playlist-move-up", @@ -77,7 +78,7 @@ 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, + Assert.Contains("allowAppend: !playlistMutationLocked", App, StringComparison.Ordinal); Assert.Contains("send(\"clear-cut-selection\", {})", App, StringComparison.Ordinal); @@ -432,7 +433,7 @@ public sealed class LegacyWebContractTests public void SeparatorRowsPreserveOriginalDragGesture() { Assert.Contains( - "element.dataset.cutDragEnabled = cutSelectionEnabled ? \"true\" : \"false\"", + "element.dataset.cutDragEnabled = cutSelectionEnabled && !cutDragLocked", App, StringComparison.Ordinal); Assert.Contains("element.draggable = false", App, StringComparison.Ordinal); @@ -440,6 +441,22 @@ public sealed class LegacyWebContractTests StringComparison.Ordinal); } + [Fact] + public void CutDragFeedbackFollowsTheCapturedPointerWithoutOwningInput() + { + Assert.Contains("id=\"cut-drag-feedback\"", Markup, StringComparison.Ordinal); + Assert.Contains("aria-hidden=\"true\"", Markup, StringComparison.Ordinal); + Assert.Contains("function showCutDragFeedback", App, StringComparison.Ordinal); + Assert.Contains("cutDragFeedback.dataset.dropAllowed", App, + StringComparison.Ordinal); + Assert.Contains("showCutDragFeedback(event, dropAllowed)", App, + StringComparison.Ordinal); + Assert.Contains("cutDragFeedback.hidden = true", App, + StringComparison.Ordinal); + Assert.Contains(".cut-drag-feedback", Styles, StringComparison.Ordinal); + Assert.Contains("pointer-events: none", Styles, StringComparison.Ordinal); + } + [Fact] public void BusySnapshotFreezesControlsWithoutQueueingInput() { diff --git a/tests/Web/cut-keyboard-app-integration.test.cjs b/tests/Web/cut-keyboard-app-integration.test.cjs index 0096d5f..8630789 100644 --- a/tests/Web/cut-keyboard-app-integration.test.cjs +++ b/tests/Web/cut-keyboard-app-integration.test.cjs @@ -77,20 +77,20 @@ test("cut focus is roving and scrolls only for keyboard navigation", () => { assert.equal(rows[1].scrollCount, 1); }); -test("cut key intent preserves Home End bubbling and honors the mutation gate", () => { +test("cut key intent preserves Home End bubbling and stable PROGRAM selection", () => { 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, /if \(!cutSelectionEnabled\) 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/); + assert.doesNotMatch(nativePlayout, /LegacyCutKeyDownIntent or/); }); test("rendered cut rows expose authoritative focus without replacing DOM rows", () => { diff --git a/tests/Web/legacy-theme-parity-app-integration.test.cjs b/tests/Web/legacy-theme-parity-app-integration.test.cjs index 1ad3d09..23c6ccc 100644 --- a/tests/Web/legacy-theme-parity-app-integration.test.cjs +++ b/tests/Web/legacy-theme-parity-app-integration.test.cjs @@ -35,7 +35,10 @@ test("UC4 renders original headings and action order in the Web vertical workspa assert.doesNotMatch(app, /theme-nxt-session/u); }); -test("UC4 selection is immediate and selection receipts preserve the result DOM", () => { +test("UC4 defers a single click for double-click activation and preserves the result DOM", () => { + const doubleClick = section( + "categoryTree.addEventListener(\"dblclick\"", + "categoryTree.addEventListener(\"click\""); const click = section( "categoryTree.addEventListener(\"click\"", "categoryTree.addEventListener(\"change\""); @@ -50,8 +53,9 @@ test("UC4 selection is immediate and selection receipts preserve the result DOM" const updater = section("function updateThemeWorkspace(workspace, theme)", "function renderTheme(state)"); assert.match(themeClick, - /send\("select-theme", \{ resultId: themeResult\.dataset\.themeResultId \}\)/u); - assert.doesNotMatch(themeClick, /setTimeout|themeResultClickTimer/u); + /clearDelayedClick\(themeResultClickTimer\);[\s\S]*const resultId = themeResult\.dataset\.themeResultId;[\s\S]*themeResultClickTimer = window\.setTimeout\(function \(\) \{[\s\S]*themeResultClickTimer = null;[\s\S]*send\("select-theme", \{ resultId: resultId \}\);[\s\S]*\}, 250\);/u); + assert.match(doubleClick, + /clearDelayedClick\(themeResultClickTimer\);[\s\S]*themeResultClickTimer = null;[\s\S]*send\("activate-theme-result", \{/u); assert.match(render, /existingWorkspace && structureKey === themeRenderStructureKey[\s\S]*updateThemeWorkspace\(existingWorkspace, theme\);[\s\S]*return;/u); assert.doesNotMatch(updater, /categoryTree\.replaceChildren|results\.replaceChildren/u); diff --git a/tests/Web/playout-page-preflight-integration.test.cjs b/tests/Web/playout-page-preflight-integration.test.cjs new file mode 100644 index 0000000..61b6517 --- /dev/null +++ b/tests/Web/playout-page-preflight-integration.test.cjs @@ -0,0 +1,28 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); + +const source = fs.readFileSync(path.join( + __dirname, + "..", + "..", + "src", + "MBN_STOCK_WEBVIEW.LegacyParityApp", + "MainWindow.Playout.cs"), "utf8"); + +test("first PREPARE and TAKE IN freeze fresh PageN plans for every paged row", () => { + assert.ok((source.match(/CreateFreshPagedPlayoutRequestAsync\(/g) || []).length >= 3); + assert.match(source, + /Where\(static entry => entry\.PageNavigation\?\.PageSize\.HasValue == true\)/u); + assert.match(source, /PreflightPagePlansAsync\(/u); + assert.match(source, + /ReflectSuccessfulPlayout\([\s\S]*plan\.EntryId,[\s\S]*pageIndexZeroBased: 0,[\s\S]*plan\.PageCount,[\s\S]*synchronizeOperatorSelection: false\)/u); +}); + +test("fresh PageN wiring rejects empty or mismatched plans before vendor dispatch", () => { + assert.match(source, /plans\.Count != pagedEntries\.Length/u); + assert.match(source, /plan\.PageSize != \(int\)expectedPageSize/u); + assert.match(source, /plan\.PageCount is < 1 or > ScenePaging\.MaximumPageCount/u); + assert.match(source, /workflow\.State\.CurrentEntryId is not null/u); +}); diff --git a/tools/MBN_STOCK_WEBVIEW.DbSmoke/MBN_STOCK_WEBVIEW.DbSmoke.csproj b/tools/MBN_STOCK_WEBVIEW.DbSmoke/MBN_STOCK_WEBVIEW.DbSmoke.csproj index 4f0be15..a554a02 100644 --- a/tools/MBN_STOCK_WEBVIEW.DbSmoke/MBN_STOCK_WEBVIEW.DbSmoke.csproj +++ b/tools/MBN_STOCK_WEBVIEW.DbSmoke/MBN_STOCK_WEBVIEW.DbSmoke.csproj @@ -11,6 +11,7 @@ + diff --git a/tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs b/tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs index e7a141b..e3f8e69 100644 --- a/tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs +++ b/tools/MBN_STOCK_WEBVIEW.DbSmoke/Program.cs @@ -1,8 +1,10 @@ +using System.Data; using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using MBN_STOCK_WEBVIEW.Infrastructure; +using MBN_STOCK_WEBVIEW.LegacyApplication; using MMoneyCoderSharp.Data; Console.OutputEncoding = Encoding.UTF8; @@ -47,9 +49,14 @@ try await VerifyStockSearchAsync(runtime.Executor, cancellation.Token); await VerifyThemeSelectorAsync(runtime.Executor, cancellation.Token); await VerifyOverseasSelectorsAsync(runtime.Executor, cancellation.Token); + await VerifyComparisonWorldSelectorAsync(runtime.Executor, cancellation.Token); await VerifyExpertSelectorAsync(runtime.Executor, cancellation.Token); await VerifyTradingHaltSelectorAsync(runtime.Executor, cancellation.Token); - await VerifyOperatorCatalogSchemaAsync(runtime.Executor, cancellation.Token); + await VerifyOperatorCatalogSchemaAsync( + runtime.Executor, + runtime.ConnectionFactory, + cancellation.Token); + await VerifyManualFinancialSearchAsync(runtime.Executor, cancellation.Token); await NamedPlaylistReadOnlyDbAudit.RunAsync(runtime.Executor, cancellation.Token); await NamedManualRestoreReadOnlyAudit.RunAsync( runtime.Executor, @@ -98,6 +105,16 @@ catch (OverseasIndustryIndexSearchDataException exception) Console.Error.WriteLine($"REAL_DB_SMOKE: FAIL - {exception.Message}"); return 1; } +catch (WorldStockSearchDataException exception) +{ + Console.Error.WriteLine($"REAL_DB_SMOKE: FAIL - {exception.Message}"); + return 1; +} +catch (ManualFinancialDataException exception) +{ + Console.Error.WriteLine($"REAL_DB_SMOKE: FAIL - {exception.Message}"); + return 1; +} catch (Exception exception) { Console.Error.WriteLine( @@ -215,6 +232,93 @@ static async Task VerifyLegacyComparisonImportAsync( $"sha256={result.SourceSha256}, identities=fresh-read-only"); } +static async Task VerifyManualFinancialSearchAsync( + IDataQueryExecutor executor, + CancellationToken cancellationToken) +{ + var service = new LegacyManualFinancialScreenService(executor); + var stockSearch = await new LegacyStockSearchService(executor) + .SearchAsync("삼성", 100, cancellationToken) + .ConfigureAwait(false); + var absentFixture = await FindAbsentManualFinancialFixtureAsync( + service, + stockSearch.Items + .Select(static item => item.Name) + .Distinct(StringComparer.Ordinal), + cancellationToken) + .ConfigureAwait(false); + + foreach (var screen in Enum.GetValues()) + { + ManualFinancialSearchResult result; + try + { + result = await service.SearchAsync( + screen, + absentFixture, + cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + catch (ManualFinancialDataException exception) + { + throw new ManualFinancialDataException( + $"GraphE search {screen} failed validation: {exception.Message}"); + } + if (result.Items.Any(row => + string.Equals( + row.Record.Identity.StockName, + absentFixture, + StringComparison.Ordinal))) + { + throw new DatabaseConfigurationException( + "The read-only GraphE search fixture must be absent before UI CRUD validation."); + } + + Console.WriteLine( + $"GraphE search {screen}: rows={result.Items.Count.ToString(CultureInfo.InvariantCulture)}, " + + $"fixture={absentFixture}, fixtureAbsent=true, fresh-read-only"); + } +} + +static async Task FindAbsentManualFinancialFixtureAsync( + LegacyManualFinancialScreenService service, + IEnumerable stockNames, + CancellationToken cancellationToken) +{ + foreach (var stockName in stockNames) + { + var absent = true; + foreach (var screen in Enum.GetValues()) + { + try + { + _ = await service.GetAsync( + new ManualFinancialIdentity(screen, stockName), + cancellationToken) + .ConfigureAwait(false); + absent = false; + break; + } + catch (ManualFinancialNotFoundException) + { + } + catch (ManualFinancialDataException) + { + absent = false; + break; + } + } + + if (absent) + { + return stockName; + } + } + + throw new DatabaseConfigurationException( + "No read-only GraphE UI fixture was absent from all four manual-financial tables."); +} + static async Task RunManualImportFixtureAsync() { const string expectedSourceSha256 = @@ -620,6 +724,7 @@ static async Task VerifySnapshotAsync( static async Task VerifyOperatorCatalogSchemaAsync( IDataQueryExecutor executor, + IDatabaseConnectionFactory connectionFactory, CancellationToken cancellationToken) { var result = await new LegacyOperatorCatalogSchemaValidationService(executor) @@ -633,12 +738,54 @@ static async Task VerifyOperatorCatalogSchemaAsync( cancellationToken); _ = await new LegacyExpertCatalogPersistenceService(executor) .SuggestNextCodeAsync(cancellationToken); + var widthSummary = await ReadMariaDbThemeColumnTypesAsync( + connectionFactory, + cancellationToken); Console.WriteLine( "Operator catalog schema: " + string.Join( ",", result.ValidatedSources.Select(static source => source.ToString())) + - " (read-only zero-row preflight and code-range validation)"); + " (read-only zero-row preflight and code-range validation); MariaDB " + + string.Join(", ", widthSummary)); +} + +static async Task> ReadMariaDbThemeColumnTypesAsync( + IDatabaseConnectionFactory connectionFactory, + CancellationToken cancellationToken) +{ + ArgumentNullException.ThrowIfNull(connectionFactory); + await using var connection = connectionFactory.Create(DataSourceKind.MariaDb); + await connection.OpenAsync(cancellationToken); + var values = new List(3); + foreach (var (table, columns) in new[] + { + ("SB_LIST", new HashSet(["SB_CODE"], StringComparer.OrdinalIgnoreCase)), + ("SB_ITEM", new HashSet(["SB_M_CODE", "SB_I_CODE"], StringComparer.OrdinalIgnoreCase)) + }) + { + await using var command = connection.CreateCommand(); + command.CommandText = $"SHOW COLUMNS FROM {table}"; + command.CommandTimeout = connectionFactory.GetCommandTimeoutSeconds( + DataSourceKind.MariaDb); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var column = reader.GetString(0); + if (columns.Contains(column)) + { + values.Add($"{table}.{column}={reader.GetString(1)}"); + } + } + } + + if (values.Count != 3) + { + throw new DatabaseConfigurationException( + "The MariaDB ThemeA column-type audit returned an unexpected shape."); + } + + return values.Order(StringComparer.Ordinal).ToArray(); } static async Task VerifyStockSearchAsync( @@ -703,7 +850,36 @@ static async Task VerifyOverseasSelectorsAsync( Console.WriteLine("WorldStockSelector: BEGIN"); var stocks = await new LegacyWorldStockSearchService(executor) .SearchAsync(string.Empty, maximumResults: 100, cancellationToken); - if (industries.Items.Count == 0 || stocks.Items.Count == 0) + Console.WriteLine("OverseasStockSelector: BEGIN"); + WorldStockSearchResult overseasStocks; + try + { + overseasStocks = await new LegacyOverseasStockSearchService(executor) + .SearchAsync( + string.Empty, + LegacyOverseasStockSearchService.DefaultMaximumResults, + cancellationToken); + } + catch (WorldStockSearchDataException) + { + await PrintUc5OverseasStockDiagnosticsAsync(executor, cancellationToken); + throw; + } + var uc5Workflow = new LegacyOverseasSelectionWorkflow( + new LegacyOverseasIndustryIndexSearchService(executor), + new LegacyOverseasStockSearchService(executor)); + var uc5Snapshot = await uc5Workflow.SearchStocksAsync( + string.Empty, + LegacyOverseasStockSearchService.DefaultMaximumResults, + cancellationToken); + if (uc5Snapshot.Stock.Status != LegacyOverseasSearchStatus.Ready || + uc5Snapshot.Stock.ResultCount != overseasStocks.Items.Count) + { + throw new DatabaseConfigurationException( + "The full UC5 overseas-stock workflow rejected current master rows."); + } + if (industries.Items.Count == 0 || stocks.Items.Count == 0 || + overseasStocks.Items.Count == 0) { throw new DatabaseConfigurationException( "The production overseas selectors returned no rows."); @@ -711,7 +887,110 @@ static async Task VerifyOverseasSelectorsAsync( Console.WriteLine( $"OverseasSelectors: industries={industries.Items.Count.ToString(CultureInfo.InvariantCulture)}, " + - $"stocks={stocks.Items.Count.ToString(CultureInfo.InvariantCulture)}"); + $"comparisonStocks={stocks.Items.Count.ToString(CultureInfo.InvariantCulture)}, " + + $"uc5Stocks={overseasStocks.Items.Count.ToString(CultureInfo.InvariantCulture)}"); +} + +static async Task PrintUc5OverseasStockDiagnosticsAsync( + IDataQueryExecutor executor, + CancellationToken cancellationToken) +{ + const string sql = """ + SELECT F_INPUT_NAME, F_SYMB, F_NATC + FROM ( + SELECT F_INPUT_NAME, F_SYMB, F_NATC + FROM T_WORLD_IX_EQ_MASTER + WHERE F_FDTC = '1' + AND F_NATC IN ('US', 'TW') + ORDER BY F_INPUT_NAME + ) + WHERE ROWNUM <= :row_limit + """; + var spec = new DataQuerySpec( + sql, + [new DataQueryParameter("row_limit", 501, DbType.Int32)]); + spec.ValidateFor(DataSourceKind.Oracle); + var table = await executor.ExecuteAsync( + DataSourceKind.Oracle, + "UC5_OVERSEAS_STOCK_DIAGNOSTIC", + spec, + cancellationToken); + var violations = new List(); + var identities = new HashSet<(string InputName, string Symbol, string NationCode)>(); + var normalizedIdentities = + new HashSet<(string InputName, string Symbol, string NationCode)>(); + for (var rowIndex = 0; rowIndex < table.Rows.Count; rowIndex++) + { + var values = new[] + { + (Name: "F_INPUT_NAME", Value: table.Rows[rowIndex][0], Maximum: 200), + (Name: "F_SYMB", Value: table.Rows[rowIndex][1], Maximum: 64), + (Name: "F_NATC", Value: table.Rows[rowIndex][2], Maximum: 8) + }; + var valid = true; + var text = new string[3]; + for (var columnIndex = 0; columnIndex < values.Length; columnIndex++) + { + var field = values[columnIndex]; + if (field.Value is not string value) + { + violations.Add($"row={rowIndex + 1} field={field.Name} reason=non-string-or-null"); + valid = false; + continue; + } + + text[columnIndex] = value; + var unsafeText = value.Any(character => + { + var category = CharUnicodeInfo.GetUnicodeCategory(character); + return char.IsControl(character) || char.IsSurrogate(character) || + character == '\uFFFD' || category is UnicodeCategory.Format or + UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator; + }); + if (value.Length < 1 || value.Length > field.Maximum || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || unsafeText) + { + violations.Add( + $"row={rowIndex + 1} field={field.Name} reason=non-canonical " + + $"length={value.Length} trimEqual={string.Equals(value, value.Trim(), StringComparison.Ordinal)} " + + $"unsafeText={unsafeText}"); + valid = false; + } + } + + if (valid && (text[2].Length is not (2 or 3) || + text[2].Any(character => character is < 'A' or > 'Z'))) + { + violations.Add($"row={rowIndex + 1} field=F_NATC reason=nation-code"); + valid = false; + } + + if (valid && !identities.Add((text[0], text[1], text[2]))) + { + violations.Add($"row={rowIndex + 1} field=identity reason=duplicate-exact-identity"); + } + + if (text.All(value => value is not null)) + { + var normalized = ( + text[0].Trim(' '), + text[1].Trim(' '), + text[2].Trim(' ')); + if (!normalizedIdentities.Add(normalized)) + { + violations.Add( + $"row={rowIndex + 1} field=identity reason=duplicate-after-ascii-trim"); + } + } + } + + Console.WriteLine( + $"UC5OverseasStockDiagnostic: rows={table.Rows.Count.ToString(CultureInfo.InvariantCulture)}, " + + $"violations={violations.Count.ToString(CultureInfo.InvariantCulture)}"); + foreach (var violation in violations.Take(20)) + { + Console.WriteLine("UC5OverseasStockDiagnostic: " + violation); + } } static async Task VerifyExpertSelectorAsync( @@ -752,6 +1031,48 @@ static async Task VerifyTradingHaltSelectorAsync( $"TradingHaltSelector: {result.Items.Count.ToString(CultureInfo.InvariantCulture)} rows"); } +static async Task VerifyComparisonWorldSelectorAsync( + IDataQueryExecutor executor, + CancellationToken cancellationToken) +{ + var candidates = await new LegacyOverseasStockSearchService(executor).SearchAsync( + string.Empty, + maximumResults: 100, + cancellationToken); + var comparison = new LegacyComparisonDataService( + new LegacyParityStockSearchService(executor), + new LegacyWorldStockSearchService(executor)); + foreach (var query in candidates.Items + .Select(item => item.InputName) + .Where(value => value.Length <= LegacyWorldStockSearchService.MaximumQueryLength) + .Distinct(StringComparer.Ordinal)) + { + try + { + var result = await comparison.SearchWorldAsync( + query, + maximumResults: 100, + cancellationToken); + if (result.Count > 0) + { + Console.WriteLine( + $"ComparisonWorldSelector: query={query}, " + + $"rows={result.Count.ToString(CultureInfo.InvariantCulture)}"); + return; + } + } + catch (Exception exception) when ( + exception is WorldStockSearchDataException or ArgumentException) + { + // Try the next current master identity; ambiguous lookup keys are + // intentionally unavailable to the comparison workflow. + } + } + + throw new WorldStockSearchDataException( + "No unambiguous current comparison-world query was found."); +} + internal sealed record DatabaseSmokeRuntimeSelection( DatabaseRuntime Runtime, MariaDbEndpointReference ReferenceMariaDb);