diff --git a/LegacySceneRuntimeFactory.cs b/LegacySceneRuntimeFactory.cs
index 63d7687..c2508b1 100644
--- a/LegacySceneRuntimeFactory.cs
+++ b/LegacySceneRuntimeFactory.cs
@@ -33,9 +33,6 @@ internal static class LegacySceneRuntimeFactory
var imageQuoteLoader = new S6001SceneDataLoader(executor);
var worldMapLoader = new S8067SceneDataLoader(executor);
var candleLoader = new S8010SceneDataLoader(executor);
- var fiveRowLoader = new S5074SceneDataLoader(executor);
- var sixRowLoader = new S5077SceneDataLoader(executor);
- var twelveRowLoader = new S5088SceneDataLoader(executor);
var parameterizedRequestResolver = new LegacyParameterizedSceneRequestResolver(executor);
var oneColumnLoader = new S5001SceneDataLoader(executor);
var futuresPairLoader = new S5032SceneDataLoader(executor);
@@ -359,6 +356,46 @@ internal static class LegacySceneRuntimeFactory
cancellationToken).ConfigureAwait(false);
return new LegacySceneDataPage("s8010", data, 1);
}),
+ .. CreatePagedQuoteRoutes(executor, trustedViSource)
+ ]);
+
+ LegacySceneRuntimeCoverage.Validate(dataSource);
+ if (compositionOptions is not null && compositionOptionsSource is not null)
+ {
+ throw new ArgumentException(
+ "Specify either fixed or mutable scene composition options, not both.");
+ }
+
+ var cueProvider = compositionOptionsSource is null
+ ? new RegisteredLegacySceneCueProvider(dataSource, options: compositionOptions)
+ : new RegisteredLegacySceneCueProvider(dataSource, compositionOptionsSource);
+ return new LegacyPlayoutWorkflow(engine, cueProvider);
+ }
+
+ ///
+ /// Creates the read-only PageN boundary used when a fixed 5-, 6-, or 12-row
+ /// action is first added to the operator playlist. This path does not create a
+ /// cue, dispatch an engine command, or touch COM/vendor objects.
+ ///
+ public static ILegacyScenePagePlanProvider CreatePagePlanProvider(
+ IDataQueryExecutor executor,
+ ITrustedViStockNameSnapshotSource? trustedViSource = null)
+ {
+ ArgumentNullException.ThrowIfNull(executor);
+ var dataSource = new LegacySceneDataSourceRouter(
+ CreatePagedQuoteRoutes(executor, trustedViSource));
+ return new RegisteredLegacySceneCueProvider(dataSource);
+ }
+
+ private static IReadOnlyList CreatePagedQuoteRoutes(
+ IDataQueryExecutor executor,
+ ITrustedViStockNameSnapshotSource? trustedViSource)
+ {
+ var fiveRowLoader = new S5074SceneDataLoader(executor);
+ var sixRowLoader = new S5077SceneDataLoader(executor);
+ var twelveRowLoader = new S5088SceneDataLoader(executor);
+ return
+ [
new LegacySceneDataSourceRoute(["5074"], async (entry, page, cancellationToken) =>
{
var selection = RequireSelection(entry);
@@ -412,19 +449,7 @@ internal static class LegacySceneRuntimeFactory
.ConfigureAwait(false);
return new LegacySceneDataPage("s5088", data, data.Rows.Count);
})
- ]);
-
- LegacySceneRuntimeCoverage.Validate(dataSource);
- if (compositionOptions is not null && compositionOptionsSource is not null)
- {
- throw new ArgumentException(
- "Specify either fixed or mutable scene composition options, not both.");
- }
-
- var cueProvider = compositionOptionsSource is null
- ? new RegisteredLegacySceneCueProvider(dataSource, options: compositionOptions)
- : new RegisteredLegacySceneCueProvider(dataSource, compositionOptionsSource);
- return new LegacyPlayoutWorkflow(engine, cueProvider);
+ ];
}
private static LegacySceneSelection RequireSelection(LegacyPlaylistEntry entry) =>
@@ -532,6 +557,9 @@ internal static class LegacySceneRuntimeFactory
return value;
}
+ private static string ResolveNxtThemeLoaderTitle(LegacySceneSelection selection)
+ => RequireLookupValue(selection.Subject, "NXT theme");
+
private static bool HasClosedFlag(string value, string flag) =>
value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Contains(flag, StringComparer.Ordinal);
@@ -565,16 +593,17 @@ internal static class LegacySceneRuntimeFactory
page),
"PAGED_THEME" => new ThemePagedQuoteLoadRequest(
RequireLookupValue(selection.DataCode, "theme code"),
- RequireLookupValue(selection.Subject, "theme"),
+ RequireLookupValue(selection.ExactSubject ?? selection.Subject, "theme"),
ParseThemeSort(selection.GraphicType),
ParseThemeValueKind(selection.Subtype),
page),
"PAGED_NXT_THEME" => new NxtThemePagedQuoteLoadRequest(
RequireLookupValue(selection.DataCode, "NXT theme code"),
- RequireLookupValue(selection.Subject, "NXT theme"),
+ ResolveNxtThemeLoaderTitle(selection),
ParseThemeSort(selection.Subtype),
ParseNxtSession(selection.GraphicType),
- page),
+ page,
+ selection.ExactSubject),
"PAGED_EXPERT" => new ExpertPagedQuoteLoadRequest(
RequireLookupValue(selection.DataCode, "expert code"),
RequireLookupValue(selection.Subject, "expert"),
diff --git a/docs/LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md b/docs/LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md
index 3dcb43c..f373742 100644
--- a/docs/LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md
+++ b/docs/LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md
@@ -7,6 +7,11 @@ Designer가 구독하는 이벤트이며, 선언만 있고 구독되지 않은
Designer 구독은 186개이고, 같은 핸들러를 여러 컨트롤에 연결한 구독을 화면별로 합치면
171개다. 이 문서의 상세 목록은 그 171개를 한 번씩 모두 분류한다.
+원본 `timerAliveChecker`는 Designer 구독이 아니다. `MainForm` 생성자 코드가 10초 interval의
+WinForms Timer를 만들고 `timerAliveChecker_Tick`을 동적으로 구독한다. 따라서 186/171 집계에
+억지로 더하지 않고 별도 활성 이벤트로 감사한다. 이 보충 경로도 현재 LegacyParityApp에
+연결됐으므로, 현행 판정에는 미이관 Designer 이벤트나 미이관 동적 활성 이벤트가 없다.
+
## 분류 기준
- `IMPL`: 원본의 활성 업무 동작이 C# 소유 상태/워크플로, 엄격한 WebView 브리지 intent,
@@ -159,6 +164,26 @@ UC5는 20개 구독이 9개 핸들러를, UC7은 7개 구독이 6개 핸들러
`listView1_MouseDown@2349`, `listView1_MouseMove@2350`, `listView1_MouseUp@2351`
- `SAFETY (1)`: `MainForm_FormClosing@2469`
+원본 PROGRAM 중 enabled 변경에는 입력 경로별 차이가 있다. `PlayList_CellClick`은 `m_TakeIn`이면
+checkbox 클릭을 거부하지만, `PlayList_KeyPress`의 Space와 `checkBox1_CheckedChanged`의 전체
+변경은 같은 guard가 없어 다음 송출 행을 건너뛰는 데 쓰일 수 있다. 새 앱은 PROGRAM 중 이미
+진행한 prefix와 현재 on-air 행을 고정하고 **미래 행만** Space/전체 enabled 변경을 허용한다.
+원본의 운영 편의는 보존하면서 재생 이력과 현재 행을 바꾸지 않는 안전 적응이며 자동 계약 검증은
+완료됐다. 이 변경을 포함한 최신 AppX의 물리 입력 재검증은 아직 대기 중이다.
+
+## Designer 밖 동적 활성 이벤트
+
+- 원본 `MainForm.cs:82-89`의 `timerAliveChecker`는 10초마다 DB 연결 끊김·복구를 확인하고
+ 상태 전이 때 로그를 남긴다. Designer 파일에 없다는 이유로 이전 186/171 집계에서 제외된
+ 것이며, 비활성 코드가 아니다.
+- 현재 `MainWindow.xaml.cs`는 창 초기화 뒤 `StartDatabaseHealthMonitor()`를 호출한다.
+ `MainWindow.DatabaseHealth.cs`는 같은 10초 첫 지연/주기를 사용해 Oracle/MariaDB를 점검하고
+ 전이 때만 native operator log를 남긴다.
+- 운영자 DB 입력·송출·자동 갱신이 시작되면 active health check를 취소하고 공용 DB activity
+ gate를 양보하며, 앱 종료 때 lifetime token과 active check를 모두 취소한다.
+- 실제 DB 서버를 강제로 끊는 장애 주입은 하지 않았다. 수명주기·전이 중복 억제·priority gate·
+ cancellation은 자동 회귀로 고정한다.
+
## ThemeA `중복확인` 동등성
원본 `Form/ThemeA.cs:84-109`는 입력 이름과 현재 KRX/NXT 선택을 기준으로 정확히 같은
diff --git a/docs/LEGACY_FEATURE_AUDIT.md b/docs/LEGACY_FEATURE_AUDIT.md
index 7dfe2af..481deb1 100644
--- a/docs/LEGACY_FEATURE_AUDIT.md
+++ b/docs/LEGACY_FEATURE_AUDIT.md
@@ -1,7 +1,7 @@
# 원본 기능 재감사 기준표
감사 기준일: 2026-07-15
-현행 판정 갱신: 2026-07-18
+현행 판정 갱신: 2026-07-22
> 현재 완료·차단·외부 입력 범위는 [`MIGRATION_STATUS.md`](MIGRATION_STATUS.md)가
> 우선한다. 2026-07-15 패키지·연결 설명은 당시 감사 증거로 보존한다.
@@ -18,10 +18,23 @@ WebView2에 표시하는 x64 Windows 데스크톱/MSIX 앱이다.
초기 프로토타입 문서의 “미이관 버튼 disabled”, “Tornado2에 연결하지 않음”, “DB write
없음”은 현재 구현 상태가 아니다. 원본 UI 이벤트는 LegacyParityApp → strict bridge →
-C# workflow로 연결된다. 2026-07-18에는 원본의 마지막 미연결 활성 이벤트였던 10초 DB
-상태 감시를 연결했고, 개발 MSIX 전체 UI/DB와 실제 PGM runtime route 32/34를 검증했다.
-미검증 두 route는 원본에도 없는 외부 영상 자산 때문에 fail closed한다. 기본 송출 모드는
-계속 `DryRun`이다.
+C# workflow로 연결된다. 2026-07-18에는 Designer가 아닌 `MainForm` 생성자에서 동적으로
+구독되던 10초 DB 상태 감시를 LegacyParityApp 수명주기에 연결했고, 개발 MSIX 전체 UI/DB와
+실제 PGM runtime route 32/34를 검증했다.
+2026-07-21에는 실제 DB의 전체 동적 목록을 다시 대조해 고정 결과 상한, 과도한 문자열 정규화,
+활성 preview와 원문 편집의 혼용을 제거했다. 기존 행은 목록과 편집 화면에 보존하고, 송출 직전
+fresh identity·payload 검증은 별도 경계로 유지한다.
+2026-07-22에는 당시 등록 개발 패키지에서 실제 Windows 입력 여섯 회차를 실행해
+UC1~UC7 전체 버튼, 350ms double-click, drag, 키보드/검색, DB CRUD·fresh read·cleanup과
+선택 행 기준 DryRun NEXT를 확인했다. blocked·state violation·warning은 전 회차 0이다.
+그 뒤 즉시 행 선택, PROGRAM 중 미래 행 Space/전체 enabled 변경, ThemeA/UC6 빈 검색 전체
+조회, 비교쌍 명시적 import와 설정 폴더 검사를 교정해 자동 검증을 통과했다. 이 변경을 포함한
+최신 AppX의 실제 물리 입력·DB cleanup·로컬 상태 회차는 아직 재실행 전이다.
+최신 결합 코드의 Core/Paged는 각각 `LATEST-CORE-01` 6/6과 `LATEST-PAGED-04` 24/24로
+실제 PGM 재검증을 마쳤다. `s6001` 영상 비의존 4 action/18-stage workflow는 구현·static
+PASS지만 실제 PGM은 아직 실행하지 않았다. `s5006` 큐브 배경과 `s6001` 국가 영상 13개를
+쓰는 action은 원본에도 없는 외부 제작 자산 때문에 fail closed한다. 기본 송출 모드는 계속
+`DryRun`이다.
## 상태 정의
@@ -29,7 +42,7 @@ C# workflow로 연결된다. 2026-07-18에는 원본의 마지막 미연결 활
|---|---|
| `완료` | 현재 LegacyParityApp 코드와 표에 명시한 자동/통합 검증에 반영됨. package/Live 여부는 각 행의 증적 범위로 별도 판정 |
| `부분 실측` | 현재 package 또는 Live의 표에 명시한 범위만 실제 확인했으며 scene/PGM 전체 통합으로 확대할 수 없음 |
-| `현재 빌드 실측 제외` | 구현 경로는 있으나 현재 병렬 변경을 합친 package-context/CDP/PGM 결과를 아직 이 감사의 성공 증거로 채택하지 않음 |
+| `최종 재검증 대기` | 구현·자동 검증 또는 고정된 이전 실측은 있으나 최신 결합 AppX/PGM의 물리 실행을 아직 성공 증거로 채택하지 않음 |
| `외부자산 필요` | 코드로 만들 수 없는 Git/MSIX 밖의 승인 영상·배경이 제공되지 않음 |
| `외부 데이터 정합성` | 현재 DB view/data가 원본 규칙의 fresh identity를 공급하지 않아 추정 없이 차단함 |
@@ -37,30 +50,30 @@ C# workflow로 연결된다. 2026-07-18에는 원본의 마지막 미연결 활
| ID | 우선순위 | 기능 | 상태 | 현재 판정과 증적 | 남은 경계 |
|---|---|---|---|---|---|
-| LF-001 | P0 | `s6001` 해외지수 영상 | 외부자산 필요 | builder와 trusted asset 검사는 완료. 승인 Cuts에 국가 영상 13개가 없음 | 제공 후 asset preflight와 해당 scene PGM |
-| LF-002 | P0 | cut/asset coverage | 완료 | active alias 45개 `.t2s` 존재. alias missing/unsafe 0, asset unsafe 0, asset missing 14를 분리 보고 | 누락 자산 자체는 LF-001/LF-003 |
+| LF-001 | P0 | `s6001` 해외지수/원자재 | 최종 재검증 대기 | 국가 영상 13개 action은 외부자산 필요. 두바이유·WTI·브렌트유·금 4 action은 필수 built-in 이미지 2개를 쓰는 18-stage workflow 구현·static PASS | 영상 비의존 4 action 실제 PGM. 국가 영상 action은 자산 제공 후 별도 PGM |
+| LF-002 | P0 | cut/asset coverage | 완료 | 설정 Cuts root는 active alias 45개 `.t2s`와 필수 built-in asset 9개를 모두 요구한다. optional 외부 asset 14개 누락은 수용하되 영향 action만 제한하며 빈 파일·reparse·중간 junction·root 이탈은 거부한다 | 누락 자산 자체는 LF-001/LF-003 |
| LF-003 | P0 | `s5006` 큐브 배경 | 외부자산 필요 | `Video\큐브배경.vrv` 1개가 승인 Cuts에 없음. builder 누락이 아님 | 제공 후 s5006 DryRun/PGM |
| LF-004 | P0 | 실제 개발 DB write | 완료 | GraphE 4 + PList/AList 2 + ThemeA KRX/NXT 2 + EList 1, 총 9개가 create/save→fresh readback→delete→absence PASS, 잔여 0 | 운영 DB write를 뜻하지 않음 |
-| LF-005 | P0 | 개발 Tornado2/PGM 최종 통합 | 부분 실측 | 5001/5074 재검증과 자산 비의존 30개 route를 실제 PGM에서 순차 검증. 5077 20페이지 전체, 5088 전환, 이전/final unload 포함. `FAILURE=0`, `ERROR=0`, retry 0, `OutcomeUnknown=false` | 외부자산이 없는 s5006/s6001만 제공 후 실제 PGM 필요 |
+| LF-005 | P0 | 개발 Tornado2/PGM 최종 통합 | 부분 실측 | 최신 `LATEST-CORE-01`의 5001/5074 6/6, 분할 회차의 자산 비의존 30개 route 30/30, 최신 `LATEST-PAGED-04`의 5077 20페이지·5088 전환·이전/final unload 24/24를 실제 PGM에서 확인. `FAILURE=0`, `ERROR=0`, retry 0, `OutcomeUnknown=false` | s6001 영상 비의존 4 action 실제 PGM 대기. s5006과 s6001 국가 영상 action은 외부자산 제공 전 제한 |
| LF-006 | P0 | 원본 35 scene | 완료 | 원본 inventory와 source 기준선 35/35, DTO→mutation→runtime matrix 통과 | 모든 scene의 실제 PGM 완료로 확대 해석 금지 |
-| LF-007 | P0 | PageN/NEXT | 완료 | 자동 테스트와 실제 PGM에서 5077 6행 1/20→20/20, 마지막 페이지, playlist NEXT, 5088 12행 1/20 전환을 확인 | 없음 |
+| LF-007 | P0 | PageN/NEXT | 완료 | 자동 테스트와 최신 `LATEST-PAGED-04` 실제 PGM에서 5077 6행 1/20→20/20, 마지막 페이지, playlist NEXT, 5088 12행 1/20 전환을 24/24로 확인. `LATEST-CORE-01`의 5074 page 1→2도 6/6 회차에 포함 | 없음 |
| LF-008 | P0 | 원클릭 TAKE IN | 완료 | 보이는 TAKE IN/F8 한 번으로 fresh page load 후 PREPARE 1회→TAKE IN 1회. native PREPARE는 진단용으로 유지하되 hidden | unknown에서 재시도 금지 |
| LF-009 | P0 | C# 권위/strict bridge | 완료 | Web은 intent만 전송. `LegacyBridgeProtocol` allowlist와 `LegacyOperatorController` snapshot이 DB identity·playlist·scene·송출 상태의 권위 | 새 intent에도 동일 경계 유지 |
-| LF-010 | P1 | 10개 탭과 action inventory | 완료 | 10탭, fixed 328, 업종 44, 종목 31, 비교 target 24/action 9를 폐쇄형으로 고정 | 동적 DB 행은 별도 검증 |
-| LF-011 | P1 | UC4/UC6/UC7 클릭 | 완료 | 테마·전문가·거래정지는 원본 단일 클릭. UC4/UC6 행은 fixed tree handler와 독립, stale generation 거부 | 없음 |
-| LF-012 | P1 | GraphE/FSell/VI | 완료 | GraphE CRUD·찾기·정렬·행 double-click, FSell/VI 확인 시 save+playlist append+close, 취소 경계 이관 | s5025/VI 전용 PGM은 LF-005 |
-| LF-013 | P1 | PList/AList | 완료 | 목록·생성·save/load/delete·행 double-click 이관, 개발 DB write/readback/cleanup PASS | 기존 blocked raw row는 추정 복원 금지 |
-| LF-014 | P1 | ThemeA/EList | 완료 | 검색·신규·편집·삭제·종목 구성·Delete key, ThemeA exact-title 중복확인, EList opaque `rowId` drag 재정렬 이관. 실제 UI에서 ThemeA 500건 중복 read와 EList 5행 reorder 확인 | scene PGM은 LF-005 |
-| LF-015 | P1 | UC5 해외 | 완료 | 해외업종·해외종목·해외지수 조회와 playlist 경로 이관 | s6001 13개 영상은 LF-001 |
-| LF-016 | P1 | F2/F3 기본 배경 | 외부자산 필요 | sibling background root, picker, background-none와 trusted path 검사는 완료. 이 장비에 `기본.vrv`가 없음 | 제공 전 fail closed |
+| LF-010 | P1 | 10개 탭과 action inventory | 완료 | 10탭, fixed 328, 업종 44, 종목 31, 비교 target 24/action 9를 폐쇄형으로 고정. 직전 `screens` 회차에서 UC1 leaf와 parent double-click을 실제 입력해 parent section action 수와 native intent 1건을 대조했다 | 동적 DB 행은 별도 검증 |
+| LF-011 | P1 | UC4/UC6/UC7 클릭 | 최종 재검증 대기 | 테마 1,747개, 전문가 6명/추천 32행, 거래정지 112행을 실제 DB와 대조. 직전 `screens` 회차에서 검색·행 선택·정렬·350ms double-click·모든 action을 실제 입력으로 확인했다. UC6 빈 검색 전체 조회와 클릭 즉시 선택 표시·pending action lock은 자동 검증 완료 | 변경 포함 최신 AppX 물리 입력 |
+| LF-012 | P1 | GraphE/FSell/VI | 최종 재검증 대기 | GraphE 7,776행 전체 raw 조회·편집·삭제, strict-ready 7,726/송출 보류 50 분리. 기존 이름 기반 GraphE 36행은 36/36 fresh 복원됐다. 직전 `graphe` 491입력과 FSell/VI save+append+close는 PASS. 현재 OperatorData의 개인/외국인/기관/VI발동 4파일은 원본 Data와 length·SHA-256 exact equal이다 | 즉시 선택 교정 포함 최신 AppX 물리 입력. 보류 50행은 fresh PREPARE gate 유지 |
+| LF-013 | P1 | PList/AList | 최종 재검증 대기 | 목록·생성·save/load/delete·행 double-click 이관, 개발 DB write/readback/cleanup PASS. PROGRAM 중 Space/전체는 원본 의도를 보존하되 이미 진행한 prefix가 아니라 미래 행 enabled만 변경하도록 자동 검증했다 | 변경 포함 최신 AppX 물리 입력 |
+| LF-014 | P1 | ThemeA/EList | 최종 재검증 대기 | ThemeA 1,747개 전체 조회. 기존 편집은 raw `SB_ITEM` 전체를 읽어 비활성·교차 prefix·동일 순번 중복·경계 공백 제목을 보존한다. EList NULL 매수가 5행도 빈 값으로 보존. ThemeA 빈 검색 전체 조회와 클릭 즉시 선택 표시·pending action lock은 자동 검증 완료 | 변경 포함 최신 AppX 물리 입력. scene PGM은 LF-005 |
+| LF-015 | P1 | UC5 해외 | 완료 | 해외업종 53개·해외종목 1,474개를 표시하고 이름 없는 16행만 격리. 업종 동명 행은 exact DB tuple로 구분 | s6001 13개 영상은 LF-001 |
+| LF-016 | P1 | F2/F3 배경 선택 | 완료 | background root picker, background-none와 trusted path 검사를 구현했다. 특정 `기본.vrv`가 없으면 없는 상태로 진행하며 외부자산 14개 집계에 넣지 않는다 | 사용자가 별도 배경을 지정할 때 trusted 검사를 적용 |
| LF-017 | P1 | 시스템 종료 확인 | 완료 | 원본 제목 `매일경제TV`, 중립 문구, 예/아니요와 기본 아니요를 유지하며 자동 TAKE OUT/반대 명령 금지 경계를 코드·자동 테스트로 유지 | 현재 Release package-context 회귀는 LF-005와 함께 판정 |
-| LF-018 | P1 | 원본 비교쌍/수동 파일 | 완료 | `종목비교.dat` 8쌍 1회 import·재시작 marker, FSell 3×5행과 VI 9항목 trusted parser/store 검증 | 원본 파일은 계속 읽기 전용 |
+| LF-018 | P1 | 원본 비교쌍/수동 파일 | 최종 재검증 대기 | `원본 비교쌍 가져오기`가 trusted 원본 `종목비교.dat` 8쌍을 모두 resolve한 뒤 현재 순서를 보존해 unseen pair만 atomic 병합한다. 현재 OperatorData의 개인/외국인/기관/VI발동 4파일은 원본 bin\Debug\Data와 length·SHA-256 exact equal이며 사용 가능 | 최신 AppX에서 현재 1+원본 8=9, 재실행 추가 0의 실제 로컬 상태 smoke 대기. 원본은 계속 읽기 전용 |
| LF-019 | P1 | 실제 DB read→DTO→mutation | 완료 | Oracle/MariaDB 필수 scene selector 33/33, 전체 query 58건 PASS | 외부 asset/PGM 성공과 별도 |
-| LF-020 | P2 | FarPoint 대체 | 완료 | 실제 개발 MSIX에서 종목 검색·키보드, 컷 다중 drag, 플레이리스트 이동/drag와 focus 경계를 Windows 입력으로 재검증. DOM/C# 회귀도 유지 | 활성 Print/Excel export는 원본에도 없음 |
+| LF-020 | P2 | FarPoint 대체 | 최종 재검증 대기 | 직전 등록 패키지에서 종목 검색·키보드, 컷 다중 drag, 플레이리스트 이동/drag, drag feedback, 송출 뒤 컷 선택 표시와 focus/IME draft 보존을 Windows 입력으로 검증했다. 시스템 double-click 시간(이 장비 500ms)을 반영해 350ms 입력도 한 동작으로 처리한다. 이후 GraphE/ThemeA/비교/VI의 즉시 선택 표시와 선택 의존 action pending lock을 교정해 자동 검증했다 | 변경 포함 최신 AppX 물리 입력. 활성 Print/Excel export는 원본에도 없음 |
| LF-021 | P2 | 패키지 단일 인스턴스·모달 격리 | 완료 | 두 번째 activation, child modal Escape, 전역 키 격리, Tab trap, focus 복귀와 PList pending close lock을 자동 검증하고 PList 모달을 실제 입력으로 재검증 | 고객 서명 MSIX의 clean PC 설치 검증은 배포 단계 |
| LF-022 | P2 | 영구 운영 로그/bridge 오류 | 완료 | bounded/redacted log와 toast, malformed payload가 송출 상태를 바꾸지 않음 | authoritative Network Monitoring과 함께 판독 |
| LF-023 | P1 | NXT 저장 playlist restore | 외부 데이터 정합성 | stale code를 제목/current identity로 재검증하는 닫힌 경로는 완료. 현재 `v_all_stock` 결과가 비어 preview를 fail closed | DBA/data 정합성 없이는 direct-table 추정 금지 |
-| LF-024 | P0 | Designer 이벤트 전수 감사 | 완료 | 원본 Designer 구독 **186개**, 화면별 고유 핸들러 **171개** 전수 분류. 유일하게 남았던 활성 `timerAliveChecker`의 10초 DB 끊김·복구 감시와 전이 로그를 LegacyParityApp 수명주기에 연결 | 상세 줄 단위 근거는 `LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md` |
+| LF-024 | P0 | Designer/동적 이벤트 전수 감사 | 완료 | 원본 Designer 구독 **186개**, 화면별 고유 핸들러 **171개** 전수 분류. Designer 밖에서 `MainForm` 생성자가 동적으로 구독하던 `timerAliveChecker`의 10초 DB 끊김·복구 감시와 전이 로그도 LegacyParityApp 수명주기에 연결 | 상세 줄 단위 근거는 `LEGACY_DESIGNER_EVENT_PARITY_AUDIT.md` |
| LF-025 | P0 | VS Development Live 시작 구성 | 완료 | `.slnLaunch`와 패키지 프로필이 LegacyParityApp을 시작 대상으로 고정. 실제 Release에서 `--development-live`가 무시돼 DryRun, TCP/Network 변경 0임을 확인 | Live는 exact argument + Debug + strict local authorization을 계속 요구 |
## 이벤트·drag 감사 상세
@@ -128,7 +141,68 @@ write는 자동 재시도하지 않는다. `OutcomeUnknown`이면 cleanup mutati
보내지 않는 구현·테스트 경계를 유지한다. 이 결과는 개발 DB 검증이며 운영 DB에 임의 쓰기를
허용하거나 수행했다는 뜻이 아니다.
-## 기존 PLAY_LIST read-only 재분류
+## 직전 등록 패키지 실제 입력 증거 (2026-07-22)
+
+당시 등록된 Development Mode AppX의 Web 자산·핵심 DLL을 당시 빌드와 hash 대조한 뒤 실제
+Windows 포인터·키보드 입력으로 다음 회차를 실행했다. 여섯 회차 모두 `PASS`였고
+blocked outbound, state violation, warning은 각각 0이었다. DB 쓰기 회차는 승인된 임시
+식별자만 사용했고 cleanup을 확인했으며, 앱은 정상 종료되고 Tornado2 연결은 0으로 돌아왔다.
+이후 교정된 즉시 선택·PROGRAM enabled·빈 검색·설정·비교 importer를 포함한 최신 AppX의
+물리 회차는 아직 실행 전이므로 아래 증거를 최신 코드 최종 실측으로 확대하지 않는다.
+
+| 증거 | 입력 | 화면/DB 검사 | 승인 write | 핵심 경계 |
+|---|---:|---:|---:|---|
+| [`latest-readonly`](../artifacts/package-input-smoke/legacy-package-input-20260721T221024787Z-latest-readonly.json) | 73 | 0/0 | 0 | DB write 0, playout intent 0, 최종 playlist 3 |
+| [`latest-graphe`](../artifacts/package-input-smoke/legacy-package-input-20260721T221122292Z-latest-graphe.json) | 491 | 5/12 | 12 | GraphE 4종 fresh reopen·350ms double-click·delete/absence, cleanup |
+| [`latest-catalog`](../artifacts/package-input-smoke/legacy-package-input-20260721T221252795Z-latest-catalog.json) | 264 | 3/11 | 7 | ThemeA KRX/NXT·EList 검색/double-click/reorder/drag/delete, cleanup |
+| [`latest-screens`](../artifacts/package-input-smoke/legacy-package-input-20260721T221352946Z-latest-screens.json) | 664 | 15/15 | 22 | UC1~UC7 전체 버튼·parent batch·GraphE/FSell/VI·모달, cleanup |
+| [`latest-plist`](../artifacts/package-input-smoke/legacy-package-input-20260721T221553250Z-latest-plist.json) | 91 | 0/3 | 3 | create/save→fresh readback→delete/absence, cleanup |
+| [`latest-dryrun-playout`](../artifacts/package-input-smoke/legacy-package-input-20260721T221635626Z-latest-dryrun-playout.json) | 83 | 0/0 | 0 | 선택 행 NEXT·drag 상태·PROGRAM 선택·tail append, DB write/Tornado2 연결 0 |
+
+DryRun 송출 회차는 3행 중 `legacy-stock-00000003`을 선택한 상태에서 NEXT가
+`legacy-stock-00000001`로 이동함을 확인했다. PROGRAM 뒤 컷 선택은 0번이고, 안전한 마지막
+double-click만 `legacy-stock-00000004` 한 행을 append해 playlist가 3→4행이 됐다.
+
+## 현행 PList operator 로드 경계 (2026-07-21)
+
+현행 `LegacyParityApp`은 활성 정의 23개와 연결된 2,213행을 목록 단위로 거부하지 않고
+모두 로드한다. 최신 DB 감사 결과는 `rowsLoaded=2213`, `sceneAliasResolved=2213`,
+`rowLevelDeferred=0`, `wholeListRejected=0`이며, 직전 개발 패키지의 실제 마우스 입력으로
+첫 목록 58행을 팝업 없이 불러왔다. 이 수치는 목록/scene alias 복원 근거이고, 각 행의
+최신 DB payload·asset·PGM 성공 근거는 아니다(`playoutPreflightAsserted=false`).
+
+2026-07-22 최신 실제 DB smoke는 2,213행 가운데 1,617행을 fresh typed selection으로
+복원하고 596행을 보류했다. 보류는 KRX 테마 493행, NXT 테마 48행, 비교 시장 identity
+유실 25행, unique closed mapper 없음 30행이다. 기존 이름 기반 GraphE 36행은 원본 저장
+의미와 같은 compatibility projection을 적용해 36/36 fresh materialization됐다.
+
+## 동적 DB 목록 호환 경계 (2026-07-21)
+
+원본에서 보이던 행을 목록 진입 단계의 송출 검증으로 통째로 숨기지 않는다. 실제 read-only 감사와
+직전 개발 패키지 클릭 결과는 다음과 같다.
+
+- 국내 종목 4,599/4,599, 해외 비교 raw 1,490 중 표시 1,474·선택 가능 1,473,
+ UC5 업종 53·종목 1,474, UC7 거래정지 112를 확인했다.
+- Theme/ThemeA는 KRX 1,571/NXT 176, 합계 1,747개다. 제목 앞뒤 공백 92행과 중복 제목
+ 14행도 market·code identity로 구분한다.
+- ThemeA 편집은 저장된 `SB_ITEM`을 직접 읽는다. KRX/NXT 교차 prefix 5/3행과 동일
+ `SB_I_INDEX` 2그룹·4행을 유실하지 않는다. 신규 테마는 계속 시장별 prefix만 허용한다.
+- GraphE는 Revenue 1,961행, Growth 1,119행, Sales 2,349행, OperatingProfit 2,347행,
+ 합계 7,776행을 모두 raw 편집 대상으로 노출한다. 원본이 빈 항목을 저장한 `_` 슬롯과 빈
+ 분기명은 레거시 규칙으로 수용한다. 최신 read-only 감사 결과 strict-ready/deferred는 각각
+ Revenue 1,960/1, Growth 1,114/5, Sales 2,329/20, OperatingProfit 2,323/24이며,
+ 합계 7,726/50이다. 정수 값의 선후행 공백·TAB, 분기명 후행 TAB과 완전히 동등한 중복 DB행은
+ 원본 저장 의미대로 수용하되 conflicting 중복과 내부 제어문자는 거부한다. 남은 50행도 기존 값
+ 조회·수정·삭제는 가능하지만 PREPARE 전에 fresh projection gate를 통과해야 한다.
+- 전문가 6명의 추천 32행 중 매수가 NULL 5행은 빈 셀로 보존한다. 임의 기본값을 만들지 않으며,
+ 숫자 매수가가 필요한 송출 action만 사용할 수 없다.
+- VI와 국내 비교는 빈 검색 4,599행, `KODEX` 238행을 실제 화면에 표시했다. 실제 상한을
+ 넘거나 행이 격리되면 화면에 지속 경고를 표시한다.
+
+이 재감사에서 ThemeA raw 편집 외 추가 P0/P1 DB 로드 차이는 발견되지 않았다. `PLAY_LIST`의
+정의 없는 orphan 88행은 원본 PList도 표시하지 않는 데이터이므로 회귀로 분류하지 않는다.
+
+## 기존 PLAY_LIST read-only fresh-selection 재분류 (2026-07-12 역사적 증거)
실제 Oracle 활성 정의 23개와 연결된 2,213행은 원문의 7필드, 한국어, enabled,
ITEM_INDEX와 원본의 page 공백 제거 규칙을 보존했다.
@@ -144,14 +218,19 @@ ITEM_INDEX와 원본의 page 공백 제거 규칙을 보존했다.
| 해외종목 | 78 | selection 78 |
| GraphE | 36 | fresh materialization 22, 값/identity 14 차단 |
| unique closed mapper 없음 | 30 | nearest-scene 추정 없이 차단 |
-| 합계 | **2,213** | 통합 selection 1,603 / 명시 차단 610 |
+| 합계 | **2,213** | 역사적 통합 selection 1,603 / fresh-selection 증명 보류 610 |
-`selectionMapped`는 scene asset, PREPARE나 PGM 성공 수치가 아니다. 상세는
+이 표의 610행은 현행 DB 목록 로드 차단 수치가 아니다. `selectionMapped`는 scene asset,
+PREPARE나 PGM 성공 수치도 아니다. 상세는
[`NAMED_PLAYLIST_READ_AUDIT.md`](NAMED_PLAYLIST_READ_AUDIT.md)를 따른다.
+최신 재분류는 이 역사적 표에서 GraphE 보류 14행을 모두 복원해 `1,617/596`이다. 다른 보류
+사유와 수치는 바뀌지 않았으며, 이를 과거 2026-07-12 회차의 결과에 소급 적용하지 않는다.
+
## 외부자산 목록
-45개 active alias의 `.t2s`는 존재한다. 별도 종속 asset 14개만 제공되지 않았다.
+설정 validator는 45개 active alias의 `.t2s`와 필수 built-in asset 9개를 요구하며 현재
+검증을 통과한다. 별도 optional 외부 제작 asset 14개만 제공되지 않았다.
- `s5006`: `Video\큐브배경.vrv`
- `s6001`:
@@ -168,10 +247,9 @@ ITEM_INDEX와 원본의 page 공백 제거 규칙을 보존했다.
- `Video\20201008_필리핀.vrv`
- `Video\20201008_말레이시아.vrv`
- `Video\20201008_인도네시아.vrv`
-- 기본 배경: sibling `배경\기본.vrv`
이 파일들의 부재는 code/build 실패가 아니라 **외부자산 미제공**이다. 검사기는 존재,
-0바이트, root escape, reparse와 manifest 중복을 검사하고 누락 경로와 영향 builder만
+0바이트, root escape, reparse·중간 junction과 manifest 중복을 검사하고 누락 경로와 영향 builder만
redacted 형태로 보고한다.
## 원본 I/O에서 의도적으로 제외한 항목
@@ -184,9 +262,14 @@ redacted 형태로 보고한다.
## 최종 증적 기록
현재 구현 증적과 최종 검증 범위는 [`MIGRATION_STATUS.md`](MIGRATION_STATUS.md)에 기록한다.
-최신 .NET solution 2,817/2,817, LegacyWeb 117/117, JavaScript 502/502이며 실패·skip은 0이다.
-Release 빌드는 warning 0, error 0이다. 개발 MSIX 전체 UI/DB와 실제 PGM 32/34 route를 확인했다.
-고객 서명 MSIX는 현재 범위에서 보류하고, 외부자산 두 route는 제공 전까지 fail closed한다.
+마지막 전체 .NET solution 실행은 3,115/3,115였다. 그 뒤 추가된 교정의 focused 자동 검증은
+통과했지만 전체 solution 합계는 최종 재실행 뒤 갱신한다. 별도 LegacyParityWeb은 127/127,
+WebPlayout JavaScript는 424/424이고 직전 Debug build/rebuild는 warning 0, error 0이다.
+직전 등록 개발 패키지 UI/DB 여섯 회차는 모두 PASS지만 최신 변경 포함 AppX 물리 재검증은
+대기 중이다. 실제 PGM은 최신 Core 6/6, 30 route 30/30, 최신 Paged 24/24 기준선을 확보했다.
+`s6001` 영상 비의존 4 action/18-stage workflow는 static PASS이고 실제 PGM은 실행 전이다.
+고객 서명 MSIX는 현재 범위에서 보류한다. `s5006` 큐브와 `s6001` 국가 영상 action은
+외부자산 제공 전까지 fail closed하고, `s6001` 영상 비의존 4 action은 실제 PGM 재검증 대기다.
2026-07-15의 상세 package 증적은
[`LEGACY_PARITY_VALIDATION_20260715.md`](LEGACY_PARITY_VALIDATION_20260715.md)에 역사 기록으로
보존한다.
diff --git a/docs/MIGRATION_STATUS.md b/docs/MIGRATION_STATUS.md
index 38def46..27fa45a 100644
--- a/docs/MIGRATION_STATUS.md
+++ b/docs/MIGRATION_STATUS.md
@@ -1,6 +1,6 @@
# MBN_STOCK_WEBVIEW 이관 현재 기준선
-기준 시각: 2026-07-20
+기준 시각: 2026-07-22
기준 소스: `main` 브랜치의 이 문서 포함 최신 기준선
@@ -20,25 +20,76 @@
## 현재 결론
-전체 이관은 기능·개발 검증 기준으로 거의 완료됐다. 없는 외부 제작 자산은 관련 두 route만
+전체 이관은 기능·개발 검증 기준으로 거의 완료됐다. 없는 외부 제작 자산은 관련 action만
fail closed로 제한한 채 진행하고, 고객 배포는 사용자가 다시 결정할 때까지 현재 범위에서 보류한다.
-기존 플레이리스트 610행은 코드 결함이 아니라 과거 identity/현재 DB 식별정보 부족에 따른 외부
-데이터 결정 대기 항목이다.
+기존 DB 재생목록은 활성 정의 23개/2,213행 모두 목록과 scene alias까지 로드하도록 복원했다.
+최신 실제 DB 호환 감사에서는 1,617행을 fresh typed selection으로 복원했고, 남은 596행만
+추정 없이 보류한다. 보류 사유는 KRX 테마 493행, NXT 테마 48행, 비교 시장 identity 유실
+25행, unique closed mapper 없음 30행이다. 따라서 전체 목록은 열되, 개별 행의
+payload·asset·송출 전 검증은 계속 분리한다.
+2026-07-21에는 같은 원칙을 모든 동적 DB 목록에 적용해, 기존 행의 조회·편집과 실제 송출 가능
+판정을 분리했다. 고정 개수 상한, 제목 trim, 중복 제목 제거, 활성 preview 전용 읽기 때문에 정상
+legacy 행까지 숨기던 경로를 제거하고, 불완전한 개별 행만 표시·편집 또는 비활성 상태로 격리한다.
| 범위 | 현재 판정 | 검증 범위 |
|---|---|---|
| 원본 Scene 구현 | 완료 | builder 35/35, 실제 운영 runtime route 34개, 무alias 진단 전용 `s8086` 1개 |
| 원본 DB 연결 상태 감시 | 구현·자동 회귀 완료 | LegacyParityApp 시작 후 10초 간격으로 Oracle/MariaDB 상태를 확인하고 상태 전이 때만 native 로그를 남긴다. 운영자 입력·송출·자동 갱신이 시작되면 진단 조회를 취소하고 DB activity gate를 양보한다. 종료 시 monitor도 취소한다. 실제 DB 서버를 강제로 끊는 장애 주입은 하지 않았다. |
-| 개발 MSIX 전체 UI/DB | 완료 | 실제 Windows 입력으로 UC1~UC7, GraphE, FSell, VIList, ThemeA, EList, PList 모달, 검색·키보드·컷/플레이리스트 drag를 검증했다. `screens` 15 화면/15 데이터·파일 검사, `graphe` 5 화면/12 DB 검사, `catalog` 3 화면/11 DB 검사, PList create→fresh read→delete가 모두 PASS였고 잔여 테스트 데이터는 0이다. 2026-07-20에는 선택 행 기준 NEXT, drag 피드백, 송출 후 컷 선택 표시를 실제 입력으로 재검증했다. |
-| 개발 Tornado2/PGM | **32/34 runtime route 완료** | `5001 → 5074 Page NEXT → TAKE OUT` 재검증과 자산 비의존 30개 route의 LOAD/PREPARE/PLAY/UNLOAD를 실제 PGM에서 확인했다. 30개는 구/신규 프로그램의 동일 저장 편성표 사용자 입력으로도 비교했고, 신규는 실제 PGM 화면 30/30을 보존했다. 확인된 명령은 request/success가 일치하고 `FAILURE=0`, `ERROR=0`, retry 0, `OutcomeUnknown=false`다. |
-| PageN/장기 실행 경계 | 완료 | `5077` 6행을 1/20부터 20/20까지 실제 Page NEXT하고, 마지막 페이지에서 playlist NEXT로 `5088` 12행 1/20으로 전환했다. 이전 scene unload와 최종 TAKE OUT/UNLOAD/IDLE을 확인했다. |
-| 자동 회귀 | 완료 | 최신 .NET solution 2,817/2,817, LegacyWeb 117/117, JavaScript 502/502; 실패·skip 0. Release 빌드 warning 0, error 0 |
+| 개발 MSIX 전체 UI/DB | **직전 패키지 실측 PASS · 최신 코드 물리 재검증 대기** | 등록 개발 패키지에서 실제 Windows 입력으로 UC1~UC7, GraphE, FSell, VIList, ThemeA, EList, PList 모달, 검색·키보드·컷/플레이리스트 drag를 검증했다. `readonly` 73, `graphe` 491, `catalog` 264, `screens` 664, `plist` 91, `dryrun-playout` 83 입력은 모두 PASS이고 blocked·state violation·warning은 전 회차 0이다. 그 뒤 즉시 행 선택, PROGRAM 중 미래 행 Space/전체 enabled 변경, ThemeA/UC6 빈 검색 전체 조회, 비교쌍 명시적 import와 설정 UI를 교정했으며 자동 검증은 통과했다. 이 변경을 포함한 새 AppX의 물리 입력·DB cleanup·로컬 상태 회차는 아직 최종 재실행 전이다. |
+| 개발 Tornado2/PGM | **32/34 route 최신 실측 · s6001 4-action 실측 대기** | 최신 `LATEST-CORE-01`에서 `5001 → 선택 행 NEXT 5074 → Page NEXT → TAKE OUT` 6/6, 분할한 30-route 회차에서 30/30, 최신 `LATEST-PAGED-04`에서 5077/5088 경계 24/24를 실제 PGM으로 확인했다. 명령 실패·오류·retry·`OutcomeUnknown`은 0이다. `s6001`의 영상 비의존 4 action/18-stage workflow는 구현·static PASS지만 실제 PGM은 아직 실행하지 않았다. |
+| PageN/장기 실행 경계 | **최신 결합 코드 실측 완료** | `LATEST-PAGED-04`에서 `5077` 6행을 1/20부터 20/20까지 실제 Page NEXT하고, 마지막 페이지에서 playlist NEXT로 `5088` 12행 1/20으로 전환했다. 이전 scene unload와 최종 TAKE OUT/UNLOAD/IDLE도 확인했다. `LATEST-CORE-01`의 5074 page 1→2도 포함한다. |
+| runtime 폴더/자산 설정 | 구현·자동 회귀 완료 | 선택한 Cuts root는 active `.t2s` alias 45개와 필수 built-in asset 9개를 모두 요구한다. 외부 제작 asset 14개는 없어도 설정을 수용하되 영향 경로만 제한한다. 빈 파일·reparse·중간 junction·root 이탈은 거부한다. 폴더 picker 취소와 최신 AppX 물리 검증은 최종 재실행 대기다. |
+| 자동 회귀 | 완료, 최종 합계 갱신 대기 | 마지막 전체 .NET solution 실행은 3,115/3,115였다. 그 뒤 GraphE storage compatibility, 즉시 선택, PROGRAM 미래 행 enabled, ThemeA/UC6 빈 검색, 설정 validator, 비교 importer와 s6001 staged workflow의 focused/static 검증도 통과했다. 전체 solution 합계는 최종 재실행 뒤 갱신한다. 직전 별도 스크립트는 LegacyParityWeb 127/127, WebPlayout JavaScript 424/424이고 Debug build/rebuild는 warning 0, error 0이다. |
| 고객 배포 MSIX | 현재 범위에서 보류 | 현재 결과는 unsigned 개발 패키지다. 고객용 version, Publisher, 서명 인증서, 업데이트 방식과 새 PC의 DB/Development Live 로컬 설정 배포안은 사용자가 배포 작업을 재개할 때 결정한다. |
+## 2026-07-21~22 기존 DB 로드·편집 호환 재검수
+
+실제 Oracle/MariaDB를 read-only로 다시 조회하고 직전 등록 개발 패키지를 DryRun으로 실행해
+다음을 확인했다. 아래 목록 조회와 편집 화면 확인 중 DB write 및 Tornado2/PGM 명령은 0건이다.
+
+| 화면/경로 | 실제 DB 및 패키지 결과 | 송출 경계 |
+|---|---|---|
+| 국내 종목/비교/VI | 빈 검색 4,599/4,599, `KODEX` 238/238 표시 | 선택한 typed identity만 사용 |
+| 해외 비교 종목 | raw 1,490, 표시 1,474, 선택 가능 1,473, 이름 없음 16행 격리, 쉼표 직렬화가 모호한 1행은 표시하되 비활성 | 비활성 행은 선택·저장·송출 거부 |
+| UC5 해외 | 업종 53, 종목 1,474와 이름 없음 16행 격리 | 업종은 이름뿐 아니라 input name·symbol·nation·FDTC exact identity 사용 |
+| Theme/ThemeA | 전체 1,747(KRX 1,571/NXT 176), 경계 공백 제목과 중복 제목도 보존 | NXT preview가 빈 4개 테마는 목록에는 보이되 action 차단 |
+| ThemeA 기존 편집 | `SB_ITEM` 원문 전체 읽기. KRX/NXT 교차 prefix 5/3행, 동일 순번 2그룹·4행 보존. 대표 중복순번 테마 14행과 후행 공백 제목 11행을 실제로 열어 확인 | 활성 종목 기반 strict preview는 송출용으로 별도 유지; 새로 추가한 종목만 current master 검증 |
+| EList/전문가 | 전문가 6명, 추천 32행, 매수가 NULL 5행을 빈 값으로 보존 | NULL 매수가 포함 행은 편집 가능하지만 숫자 송출 전 차단 |
+| FSell/VI 로컬 데이터 | `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\OperatorData`의 개인·외국인·기관·VI발동 4파일이 authoritative 원본 `bin\Debug\Data`와 각각 length·SHA-256 exact equal | 미이관 파일이 아니며 현재 앱에서 사용 가능. 실제 파일은 Git/MSIX 밖에서 trusted 검사 유지 |
+| GraphE | 4개 테이블 7,776행 모두 raw 조회·편집 가능. Revenue 1,960/1, Growth 1,114/5, Sales 2,329/20, OperatingProfit 2,323/24(strict-ready/deferred), 합계 strict-ready 7,726행·deferred 50행 | 원본 저장 의미와 같은 정수 공백·TAB, 분기명 후행 TAB 및 완전히 동등한 중복행을 수용한다. conflicting 중복과 실제 불완전 payload는 계속 차단한다. 기존 이름 기반 GraphE 재생목록 36행은 36/36 fresh materialization됐다. |
+| PList | 정의 23개/2,213행 전체 로드, scene alias 2,213/2,213, whole-list reject 0. fresh selection 1,617, 보류 596 | 행별 fresh payload·asset·PGM 검증은 사용할 때 별도 수행 |
+
+직전 패키지 실제 클릭 결과는 C# snapshot과 DOM 행 수가 모두 일치했고, 검수 전 편성표 58행은
+끝까지 58행으로 유지됐다. ThemeA와 GraphE의 기존 원문을 단순 조회하거나 편집 화면에 여는 행위가
+곧 송출 가능 승인을 뜻하지는 않는다.
+
+## 2026-07-22 직전 등록 패키지 실제 입력 기준선
+
+당시 등록된 Development Mode 패키지의 Web 자산과 핵심 DLL이 당시 빌드 산출물과 같은지 hash로
+확인한 뒤 여섯 회차를 실행했다. 패키지 입력 도구에도 빌드 AppX와 등록 AppX가 다르면 시작 전에
+fail closed하는 검사를 추가했다. 모든 회차는 앱을 정상 종료했으며, 종료 뒤 Tornado2 연결은 0이었다.
+아래 결과는 유효한 직전 물리 입력 기준선이지만, 이후 교정 사항을 포함한 최신 AppX의 최종 물리
+재검증 결과로 확대하지 않는다.
+
+| 회차 | 실제 입력과 검사 | 결과 |
+|---|---|---|
+| `latest-readonly` | 73입력, outbound 29건, DB write 0, playout intent 0, 최종 playlist 3행 | PASS, blocked/violation/warning 0 |
+| `latest-graphe` | 491입력, 5 화면 검사, 12 DB 검사, 승인 write 12건 | PASS, cleanup 확인, blocked/violation/warning 0 |
+| `latest-catalog` | 264입력, ThemeA KRX/NXT와 EList 3 화면 검사, 11 DB 검사, 승인 write 7건 | PASS, cleanup 확인, blocked/violation/warning 0 |
+| `latest-screens` | 664입력, UC1~UC7·GraphE·FSell·VI 등 15 화면/15 DB 검사, 승인 변경 22건 | PASS, cleanup 확인, blocked/violation/warning 0 |
+| `latest-plist` | 91입력, create/save→fresh readback→delete/absence 3 DB 검사, 승인 write 3건 | PASS, cleanup 확인, blocked/violation/warning 0 |
+| `latest-dryrun-playout` | 83입력, 3행 중 선택 행 기준 NEXT, drag feedback/state, PROGRAM 중 컷 선택, 안전한 tail double-click append | PASS, DB write 0, Tornado2 연결 0, blocked/violation/warning 0 |
+
+`latest-dryrun-playout`의 식별 결과는 staged `legacy-stock-00000002`, 선택
+`legacy-stock-00000003`, NEXT `legacy-stock-00000001`, 안전 append
+`legacy-stock-00000004`였다. PROGRAM 뒤 컷 선택은 0번이고 playlist는 3행에서 4행으로 정확히
+한 행만 늘었다.
+
## 실제 PGM 검증 경계
-- `5001`과 `5074`는 현재 작업 트리 기준으로 다시 검증했다. 2026-07-11 Round H의 과거 결과에
- 의존하지 않는다.
+- 최신 결합 코드의 `MBNWEB-20260722-LATEST-CORE-01`에서 `5001 → 5074 playlist NEXT →
+ 5074 Page NEXT → TAKE OUT/IDLE` 6/6을 검증했다. 2026-07-11 Round H와
+ `CORE5001-05`는 역사적 증거로만 보존한다.
- 이어서 외부자산에 의존하지 않는 30개 route를 순차 검증했다. 마지막 `5025` TAKE OUT 뒤
`UNLOAD 1/1`, `FAILURE 0`, `ERROR 0`, 정확한 `IDLE`을 확인했다.
- 같은 30개를 구 프로그램과 신규 프로그램에서 동일한 저장 편성표 순서와 실제 포인터 입력으로
@@ -52,22 +103,29 @@ fail closed로 제한한 채 진행하고, 고객 배포는 사용자가 다시
않도록 계약을 교정한 뒤 다시 검증했다. 선물 좌·우 plate가 정상 표시되고 관련 명령 실패는 0이다.
- 최초 `5077` 경계 검증에서 실제 데이터 120건임에도 행의 page count가 `1/1`로 고정되는 결함을
발견했다. 첫 PREPARE/TAKE IN 전에 전체 PageN 행을 read-only preflight하여 `1/N`을 고정하도록
- 수정했고, 이후 `5077` 20페이지 전체와 `5088` 전환을 재검증했다. 결함이 있던 최초 실행 결과는
- 성공 증거로 사용하지 않는다.
-- `s5006`과 `s6001`은 asset preflight가 fail closed하므로 PREPARE/PLAY를 보내지 않았다.
+ 수정했다. 최신 결합 코드의 `MBNWEB-20260722-LATEST-PAGED-04`에서 `5077` 20페이지 전체,
+ `5088` 전환, 이전 scene unload와 최종 TAKE OUT/UNLOAD/IDLE을 24/24로 재검증했다. 결함이
+ 있던 최초 실행과 이전 `PAGED-01`은 최신 승인 근거로 사용하지 않는다.
+- `s6001` 중 국가 영상이 필요 없는 두바이유·WTI·브렌트유·금 4 action은 global
+ `images\주유기merge.png`, `images\35752913_l.jpg`를 고정 검증하는 18-stage staged workflow로
+ 구현했고 static audit가 통과했다. 실제 PGM 회차는 아직 실행 전이므로 성공 증거에 포함하지 않는다.
+- `s5006`의 큐브 배경 1개와 `s6001`의 국가 영상 13개는 asset preflight에서 관련 action만
+ fail closed한다. 이 14개가 현재 남은 외부 제작 자산 경계다.
로컬 상세 증거는 다음 위치에 있다.
-- [`MBNWEB-20260718-DEV-LIVE-01`](../artifacts/pgm-evidence/MBNWEB-20260718-DEV-LIVE-01)
-- [`MBNWEB-20260718-DEV-LIVE-02`](../artifacts/pgm-evidence/MBNWEB-20260718-DEV-LIVE-02)
-- [`MBNWEB-20260718-DEV-LIVE-05`](../artifacts/pgm-evidence/MBNWEB-20260718-DEV-LIVE-05)
+- [`최신 5001/5074 Core PGM`](../artifacts/pgm-evidence/MBNWEB-20260722-LATEST-CORE-01)
+- [`30 route 0~19 PGM`](../artifacts/pgm-evidence/MBNWEB-20260722-THIRTY-02)
+- [`8001 known-state TAKE OUT`](../artifacts/pgm-evidence/MBNWEB-20260722-RECOVERY8001-01)
+- [`30 route 20~29 PGM`](../artifacts/pgm-evidence/MBNWEB-20260722-THIRTY20-29-01)
+- [`최신 5077/5088 20-page PGM`](../artifacts/pgm-evidence/MBNWEB-20260722-LATEST-PAGED-04)
- [`구/신규 30개 송출 비교`](LEGACY_NEW_PLAYOUT_COMPARISON_20260719.md)
-- [`최종 MSIX 화면/DB PASS`](../artifacts/package-input-smoke/legacy-package-input-20260718T194130241Z.json)
-- [`PList DB PASS`](../artifacts/package-input-smoke/legacy-package-input-20260718T193931554Z.json)
-- [`GraphE DB PASS`](../artifacts/package-input-smoke/legacy-package-input-20260718T194003350Z.json)
-- [`ThemeA/EList DB PASS`](../artifacts/package-input-smoke/legacy-package-input-20260718T194054981Z.json)
-- [`최종 read-only 회귀 PASS`](../artifacts/package-input-smoke/legacy-package-input-20260718T194240167Z.json)
-- [`2026-07-20 실제 입력 보정 PASS`](../artifacts/package-input-smoke/legacy-package-input-20260720T032627652Z.json)
+- [`직전 read-only 입력 PASS`](../artifacts/package-input-smoke/legacy-package-input-20260721T221024787Z-latest-readonly.json)
+- [`직전 GraphE 입력/DB PASS`](../artifacts/package-input-smoke/legacy-package-input-20260721T221122292Z-latest-graphe.json)
+- [`직전 ThemeA/EList 입력/DB PASS`](../artifacts/package-input-smoke/legacy-package-input-20260721T221252795Z-latest-catalog.json)
+- [`직전 전체 화면/DB PASS`](../artifacts/package-input-smoke/legacy-package-input-20260721T221352946Z-latest-screens.json)
+- [`직전 PList 입력/DB PASS`](../artifacts/package-input-smoke/legacy-package-input-20260721T221553250Z-latest-plist.json)
+- [`직전 DryRun 송출 입력 PASS`](../artifacts/package-input-smoke/legacy-package-input-20260721T221635626Z-latest-dryrun-playout.json)
`artifacts`의 개발 장비 증거에는 로컬 실행 상태가 포함될 수 있으므로 고객 MSIX나 Git 배포물에
넣지 않는다.
@@ -78,19 +136,28 @@ fail closed로 제한한 채 진행하고, 고객 배포는 사용자가 다시
- `s5006\Video\큐브배경.vrv`
- `s6001` 국가 영상 13개
-- `배경\기본.vrv`
-- 필요한 스튜디오 `.vrv` 배경 원본
현재 authoritative 원본 Cuts에도 없다. 자산 없이 진행한다는 사용자 결정에 따라 제공 전에는
-관련 경로만 안전하게 차단하며, 이는 현재 작업 전체의 차단 사유가 아니다. 나중에 자산을 받으면
-trusted root·빈 파일·reparse·경로 이탈 검사를 통과시킨 뒤 `s5006`/`s6001`을 실제 PGM에서 검증한다.
+관련 action만 안전하게 차단하며, 이는 현재 작업 전체의 차단 사유가 아니다. `s6001`의 영상
+비의존 4 action은 별도 실제 PGM 검증 대상으로 계속 진행한다. 나중에 자산을 받으면 trusted
+root·빈 파일·reparse·경로 이탈 검사를 통과시킨 뒤 `s5006`과 국가 영상 action을 실제 PGM에서 검증한다.
### 2. 기존 플레이리스트 데이터 정합성
-총 2,213행 가운데 1,581행은 현재 식별자로 즉시 복원할 수 있고, GraphE fresh read로 22행을 더
-복원할 수 있다. 최종 610행은 과거 저장 identity 또는 현재 DB 식별정보가 부족해 차단한다. 이 중
-KRX 테마 493행과 NXT 테마 48행은 DBA의 데이터 정합성 결정이 필요하다. 이름이나 유사 scene으로
-추정 복원하지 않는다.
+현행 operator 경로는 총 2,213행을 모두 DB에서 읽어 화면과 스케줄에 복원하고, 2,213행 모두
+기존 scene alias로 식별한다. 직전 개발 패키지에서도 `DB 불러오기 → 첫 목록 → 불러오기`를 실제
+마우스로 실행해 58행, 팝업 0, scene 미식별 0을 확인했다. 이 결과는 목록 로드/alias 복원 근거다.
+
+2026-07-12 감사의 `1,603 selectionMapped / 610 보류`는 당시 raw 저장값에서 현재 DB identity를
+fresh하게 다시 증명한 역사적 수치다. 최신 실제 DB 재감사에서는 GraphE 36행을 모두 fresh
+materialization하여 `1,617 복원 / 596 보류`로 갱신됐다. 596행은 KRX 테마 493행, NXT 테마
+48행, 비교 시장 identity 유실 25행, unique closed mapper 없음 30행이다. 목록 로드 차단으로
+해석하지 않으며, 이름이나 유사 scene으로 추정하지 않는다.
+
+원본 `종목비교.dat` 8쌍을 현재 로컬 순서 뒤에 중복 없이 병합하는 명시적 importer도 구현했다.
+원본 파일은 고정된 trusted path에서 읽기 전용으로 열고 8쌍을 모두 resolve한 뒤에만 atomic하게
+저장하며, 실패 시 현재 파일을 보존한다. 실제 최신 AppX에서 `현재 1쌍 + 원본 8쌍 = 9쌍`과 두 번째
+실행의 `추가 0`을 확인하는 로컬 상태 smoke는 아직 실행 전이다.
### 3. 고객 배포
@@ -101,5 +168,8 @@ allowlist, vendor hash, STA 직렬화와 대상 identity 검사는 그대로 유
## 완료 조건
-외부 자산 두 scene의 PGM 검증, 610행에 대한 DBA 결정 또는 명시적 영구 차단 승인, 고객 서명
-MSIX의 clean 설치·업데이트·새 PC 설정 검증이 끝나면 이관을 고객 배포 기준의 완전 완료로 올린다.
+최신 AppX의 전체 물리 입력/DB cleanup/설정·비교 import 로컬 상태 재검증, `s6001` 영상 비의존
+4 action의 실제 PGM 검증, 기존 보류 행의 payload/asset/PGM 경계 판정을 마치면 현재 개발 장비의
+대체 운영 기준선을 확정한다. 외부 제작 자산 14개가 없는 action은 제공 전까지 명시적으로 차단한
+상태가 정상 기준이다. 고객 서명 MSIX의 clean 설치·업데이트·새 PC 설정 검증은 사용자가 배포를
+재개할 때 별도 완료 조건으로 적용한다.
diff --git a/docs/NAMED_PLAYLIST_READ_AUDIT.md b/docs/NAMED_PLAYLIST_READ_AUDIT.md
index 4bfcaa7..2baf2c0 100644
--- a/docs/NAMED_PLAYLIST_READ_AUDIT.md
+++ b/docs/NAMED_PLAYLIST_READ_AUDIT.md
@@ -2,11 +2,23 @@
기준일: 2026-07-12
+> 2026-07-22 현행 `LegacyParityApp` 보충 판정: 아래 2026-07-12의 `1,603/610`은 당시
+> raw 행에서 current typed selection을 fresh하게 증명한 역사적 수치이며, DB 재생목록
+> 자체의 로드 성공/실패 수치가 아니다. 최신 operator 호환 감사에서는 활성 정의 23개와
+> 2,213행을 모두 로드했고(`wholeListRejected=0`), 2,213행 모두 기존 scene alias로
+> 식별됐다. 최신 실제 DB compatibility projection은 GraphE 36행을 36/36 복원해 현재
+> `1,617 복원 / 596 보류`다. 직전 개발 패키지에서도 `DB 불러오기 → 첫 목록 선택 →
+> 불러오기`를 실제 마우스로 실행해 58행, 팝업 0, scene 미식별 0을 확인했다. 다만 이
+> 감사는 각 행의 asset·Tornado2 PREPARE/PGM 성공까지 주장하지 않는다
+> (`playoutPreflightAsserted=false`). 최신 코드 포함 AppX 물리 재검증은 별도 대기 중이다.
+
원본(읽기 전용): `C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\Form\PList.cs`
감사 구현:
[LegacyPListReadAuditParser](../src/MBN_STOCK_WEBVIEW.Core/Data/LegacyPListReadAudit.cs),
[NamedPlaylistReadOnlyDbAudit](../tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedPlaylistReadOnlyDbAudit.cs),
-[NamedManualRestoreReadOnlyAudit](../tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedManualRestoreReadOnlyAudit.cs)
+[NamedManualRestoreReadOnlyAudit](../tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedManualRestoreReadOnlyAudit.cs),
+[LegacyManualFinancialStorageCompatibility](../src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyManualFinancialStorageCompatibility.cs),
+[NamedPlaylistOperatorCompatibilityAudit](../tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedPlaylistOperatorCompatibilityAudit.cs)
## 원본 읽기 의미
@@ -29,7 +41,40 @@
감사 오류와 정상 출력에는 `DC_TITLE`, `LIST_TEXT`, 종목명, 테마명, 프로그램
코드 또는 인증정보를 포함하지 않는다.
-## 실제 Oracle/MariaDB 결과
+## 2026-07-22 현행 fresh-selection 재감사 결과
+
+최신 실제 Oracle/MariaDB smoke는 원본 저장 의미와 current typed projection을 다시 대조했다.
+숫자 token의 선후행 공백·TAB, 분기명 후행 TAB 및 모든 필드가 완전히 같은 중복 DB행은 원본과
+같은 값으로 수용한다. 내부 제어문자와 서로 충돌하는 중복행은 계속 거부한다. 이 경계로 과거
+보류됐던 GraphE 14행까지 모두 fresh materialization됐으며 임의 종목·장면 추정은 없다.
+
+| 항목 | 현재 결과 |
+|---|---:|
+| 활성 `DC_LIST` 정의 | 23 |
+| 연결된 `PLAY_LIST` 행 / 로드 / scene alias 식별 | 2,213 / 2,213 / 2,213 |
+| whole-list reject | 0 |
+| fresh typed selection 복원 | 1,617 |
+| fresh selection 보류 | 596 |
+| 이름 기반 GraphE fresh materialization | 36/36 |
+
+| route | 전체 | current 복원 | 보류 |
+|---|---:|---:|---:|
+| 기존 닫힌 매퍼 | 1,250 | 1,250 | 0 |
+| 업종 fixed 닫힌 매퍼 | 24 | 24 | 0 |
+| 비교 fresh identity | 99 | 74 | 25 |
+| 해외지수 candle fresh identity | 61 | 61 | 0 |
+| NXT theme fresh identity | 48 | 0 | 48 |
+| KRX theme fresh identity | 587 | 94 | 493 |
+| 해외종목 fresh identity | 78 | 78 | 0 |
+| GraphE fresh materialization | 36 | 36 | 0 |
+| unique closed mapper 없음 | 30 | 0 | 30 |
+| 합계 | **2,213** | **1,617** | **596** |
+
+현재 596행의 보류 원인은 KRX 테마 493행, NXT 테마 48행, 비교 시장 identity 유실 25행,
+unique closed mapper 없음 30행뿐이다. 이 수치는 목록 로드 실패가 아니며, current selection을
+하나로 증명하지 못한 행을 nearest scene으로 추정하지 않았다는 뜻이다.
+
+## 2026-07-12 역사적 fresh-selection 감사 결과
실제 운영 연결에 고정 SQL과 bind 변수로 `SELECT`만 실행했다. DB write,
Tornado2 명령과 원본 저장소 변경은 수행하지 않았다.
@@ -81,18 +126,18 @@ PREPARE 성공과 Tornado2/PGM 출력은 포함하지 않으며 각각 별도 ga
기본 감사 수치 1,581은 기존 닫힌 매퍼 1,250행, 업종 fixed 24행, 비교
74행, 해외지수 candle 61행, KRX theme 94행 및 해외종목 78행의 합이다.
-GraphE 22행은 별도 fresh proof의 성공분이다. 현재 PList의 manual 36행은 모두
+GraphE 22행은 별도 fresh proof의 성공분이다. 해당 회차 PList의 manual 36행은 모두
GraphE이고 FSell/VI 저장 행은 0건이다.
기존 fixed 매퍼는 임의 문자열 허용으로 판정하지 않았다. 원본 runtime
`해외.ini`·`환율.ini`·`지수.ini`를 각각 현재 Web 계약에 고정된 SHA-256으로
검증하고, 원본 `MainForm`과 같이 각 줄을 trim한 뒤 현재
-`fixedLegacySignature`의 exact 5필드 규칙으로 변환했다. 원본 328 action에서
-현재 명시적으로 unavailable인 수동 6개를 제외하고, 같은 raw 서명으로 겹치는
+`fixedLegacySignature`의 exact 5필드 규칙으로 변환했다. 원본 INI의 328 source action에서
+당시 runtime action으로 실체가 없는 수동 placeholder 6개를 제외하고, 같은 raw 서명으로 겹치는
후보는 unique로 인정하지 않았다. 이 exact whitelist와 일치하지 않은 30행은
가까운 `s5001` 등을 추정하지 않고 차단했다.
-### fresh restore 결과와 차단 사유
+### 당시 fresh restore 결과와 차단 사유
| 사유 | 행 | 의미 |
|---|---:|---|
@@ -113,7 +158,7 @@ KRX theme 587행 중 94행은 제목 exact lookup, 현재 code 및 validated pre
매핑했다. 두 경로 모두 timeout/불명확 응답을 재시도하지 않으며 raw 이름·code를
감사 출력에 기록하지 않는다.
-GraphE 36행의 별도 감사는 같은 identity를 두 번 읽어 64자리 row version이
+당시 GraphE 36행의 별도 감사는 같은 identity를 두 번 읽어 64자리 row version이
변하지 않는지 확인하고 현재 종목 master의 유일한 identity와 cut 계약을 함께
검증했다. 22행만 materialize했고 저장값 무효 9행과 identity 중복 5행은 첫 후보를
고르지 않고 차단했다. 상세 경계는
@@ -124,6 +169,11 @@ FSell/VI는 현재 PList 후보가 0행이지만 운영 production source를 ope
version도 같았다. 네 운영 파일의 이름·크기·시각·SHA-256은 감사 전후 동일했고
import marker/intent를 만들거나 파일을 쓰지 않았다.
+2026-07-22 현재 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\OperatorData`의 `개인`, `외국인`,
+`기관`, `VI발동` 4파일은 authoritative 원본 `bin\Debug\Data` 파일과 각각 length 및
+SHA-256이 정확히 같다. 따라서 이 수동 파일은 미이관 또는 unavailable이 아니라 현재 앱에서
+사용 가능한 로컬 운영 데이터다. 원본과 로컬 파일 모두 이 감사에서 수정하지 않았다.
+
## 구현과 자동 근거
- KRX theme:
@@ -173,6 +223,7 @@ dotnet run --project .\tools\MBN_STOCK_WEBVIEW.DbSmoke\MBN_STOCK_WEBVIEW.DbSmoke
correlation, GraphE double-read row-version·identity 중복 fail-closed
- exact stock label과 nearest-label 거부, 알 수 없는 행의 `Unmapped` fail-closed
-실제 기본 감사, 별도 manual fresh 감사와 전체 DB smoke는
-`REAL_DB_SMOKE: PASS`로 끝났다. DB write와 Tornado2/PGM 명령은 0건이며 raw 값이나
-인증정보도 출력하지 않았다.
+실제 기본 감사, 별도 manual fresh 감사와 전체 DB smoke는 `REAL_DB_SMOKE: PASS`로
+끝났다. 2026-07-22 최신 전체 DB smoke도 PASS이며 current 결과는 GraphE 7,726/50,
+named playlist 1,617/596, GraphE fresh 36/36이다. DB write와 Tornado2/PGM 명령은
+0건이며 raw 값이나 인증정보도 출력하지 않았다.
diff --git a/docs/PGM_PARITY_STAGED_RUNBOOK_20260722.md b/docs/PGM_PARITY_STAGED_RUNBOOK_20260722.md
new file mode 100644
index 0000000..dccc87d
--- /dev/null
+++ b/docs/PGM_PARITY_STAGED_RUNBOOK_20260722.md
@@ -0,0 +1,223 @@
+# PGM parity staged runbook (2026-07-22)
+
+This runbook is for the designated local development Tornado2/PGM target only.
+It does not replace the target, path, hash, listener, package, or `OutcomeUnknown`
+stops implemented by `scripts/Invoke-LegacyPgmParityRun.ps1`.
+
+## What is different from the older artifact scripts
+
+- A new evidence directory captures the currently registered package, application,
+ PGM, executable hashes, process IDs/start times, window handles, TCP listeners,
+ the CDP target, and all 33 authoritative cut hashes exactly once. The 33rd cut is
+ `6001`; `5006` remains outside this live inventory until its cube video exists.
+- Live staging accepts only the registered x64 loose development package whose
+ install root is the current workspace's exact
+ `src/MBN_STOCK_WEBVIEW.LegacyParityApp/bin/x64/Debug//win-x64/AppX`
+ boundary. A Release, customer-installed, or WindowsApps package is rejected.
+- The exact package runtime files pinned are
+ `MBN_STOCK_WEBVIEW.LegacyParityApp.exe`,
+ `MBN_STOCK_WEBVIEW.LegacyParityApp.dll`, and
+ `MBN_STOCK_WEBVIEW.Playout.dll`.
+- The `S6001AssetIndependent` workflow additionally pins the authoritative and
+ runtime copies of `images\주유기merge.png` and `images\35752913_l.jpg` by length
+ and SHA-256 before any input. The 13 country videos are not silently substituted.
+- The app-side PGM connection must be entirely loopback and must have exactly one
+ reciprocal established row owned by the pinned PGM process. A same-numbered
+ port on another host cannot pass preflight.
+- Every later stage must match all captured identities before the input, after the
+ input, and after read-only evidence capture.
+- PGM uses a DirectX surface, so evidence capture temporarily raises only the exact
+ pinned PGM or app window without activating it, captures its screen rectangle,
+ and restores the original top-most state. This prevents unrelated foreground
+ windows from being recorded while preserving keyboard focus.
+- Every invocation performs at most one staged action. There is no command retry.
+- A timeout, `OutcomeUnknown`, probe violation, identity change, or broken Network
+ Monitoring anchor creates `STOPPED.json`. That run directory cannot dispatch a
+ later command.
+- The validation playlist uses only in-memory `legacy-*` rows. It does not load or
+ save a temporary named DB playlist. The comparison build still creates and then
+ deletes the same temporary comparison pairs required by the application feature.
+- A fresh app instance and a fresh evidence directory are required for each primary
+ workflow below. `Recovery8001`, `Recovery5077Page2`, and `ThirtyRoutes20To29` are
+ constrained recovery workflows and may start only from their exact known states
+ and preserved evidence.
+
+## Static and dry checks
+
+Static validation does not inspect or attach to the app/PGM and sends no native
+message:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File `
+ .\scripts\Invoke-LegacyPgmParityRun.ps1 -Action StaticAudit
+```
+
+The current accepted static result pins 33 cuts and reports workflow stage counts
+`Core5001=6`, `ThirtyRoutes=125`, `PagedBoundary=24`,
+`S6001AssetIndependent=18`, `Recovery8001=1`, `Recovery5077Page2=1`, and
+`ThirtyRoutes20To29=41`. Static success is implementation evidence only; it is not
+an actual PGM result.
+
+After the current package app and the designated development PGM are running and
+connected, a dry preflight reads identities only. It does not attach to CDP, write
+evidence, or send a native message. Supply the exact local PGM executable at run
+time; do not commit that machine-local path:
+
+```powershell
+$pgmExecutable = $env:MBN_STOCK_PGM_EXECUTABLE
+if ([string]::IsNullOrWhiteSpace($pgmExecutable)) {
+ throw 'Set MBN_STOCK_PGM_EXECUTABLE to the designated development PGM executable.'
+}
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File `
+ .\scripts\Invoke-LegacyPgmParityRun.ps1 -Action DryPreflight `
+ -ExpectedPgmExecutable $pgmExecutable
+```
+
+## Start one workflow
+
+Choose a primary workflow: `Core5001`, `ThirtyRoutes`, `PagedBoundary`, or
+`S6001AssetIndependent`. Use a new, nonexistent evidence directory. Initialization
+is read-only with respect to the app and PGM; it freezes the identities and captures
+the baseline windows and Network Monitoring text. Do not select `Recovery8001`,
+`Recovery5077Page2`, or `ThirtyRoutes20To29` unless the stop handling section's
+exact recovery prerequisites are already met.
+
+```powershell
+$run = Join-Path (Resolve-Path '.').Path `
+ 'artifacts\pgm-evidence\MBNWEB-YYYYMMDD-PARITY-01'
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File `
+ .\scripts\Invoke-LegacyPgmParityRun.ps1 -Action Initialize `
+ -Workflow Core5001 -EvidenceDirectory $run `
+ -ExpectedPgmExecutable $pgmExecutable
+```
+
+Inspect always reports the next required stage without sending input:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File `
+ .\scripts\Invoke-LegacyPgmParityRun.ps1 -Action Inspect `
+ -EvidenceDirectory $run
+```
+
+Dispatch exactly that one stage:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File `
+ .\scripts\Invoke-LegacyPgmParityRun.ps1 -Action RunNext `
+ -EvidenceDirectory $run
+```
+
+Do not loop `RunNext` blindly. Review its result and the new stage directory before
+issuing the next invocation. `RunStage` is also available when the action and route
+or page index should be written explicitly; it refuses anything out of order.
+
+## Exact workflow sequences
+
+`Core5001` contains six one-shot stages:
+
+1. bootstrap the safety probe and confirm empty connected IDLE;
+2. build in-memory 5001/5074 rows;
+3. TAKE IN 5001;
+4. playlist NEXT to 5074;
+5. Page NEXT from 5074 page 1 to page 2;
+6. TAKE OUT 5074.
+
+`ThirtyRoutes` contains 125 stages: bootstrap, four in-memory build phases, then
+four stages per route (`activate`, fast TAKE IN for the PGM frame, read-only wait
+for settled PROGRAM, TAKE OUT) in this order:
+
+```text
+5016, 50160, 5078, 8067, 5086, 50860, 5023, 5024, 5077, 5082,
+5083, 5084, 5085, 5088, 6067, 8035, 5011, 8003, 5037, 8001,
+5026, 5087, 5029, 8032, 5032, 5076, 5079, 5080, 5081, 5025
+```
+
+`PagedBoundary` contains 24 stages: bootstrap, build 5077/5088, TAKE IN 5077,
+19 individual Page NEXT inputs through page 20, playlist NEXT to 5088 page 1,
+and TAKE OUT. It requires exact fresh-read boundaries of 5077 = 120 items / 6 per
+page / 20 pages and 5088 = 240 items / 12 per page / 20 pages.
+
+`S6001AssetIndependent` contains 18 stages: bootstrap, one physical double-click
+build stage that creates exactly these four `6001` rows, then four stages per row
+(`activate`, TAKE IN, read-only settled PROGRAM wait, TAKE OUT):
+
+```text
+fixed-072 유가 두바이 지수 DubaiCrude
+fixed-073 유가 WTI 지수 WtiCrude
+fixed-074 유가 브렌트유 지수 BrentCrude
+fixed-075 금 지수 Gold
+```
+
+These actions use only the two globally pinned image assets. They do not exercise
+or claim parity for the 13 country-video actions. The workflow is implemented and
+its static audit passes, but as of this baseline it has **not** been run against
+actual PGM. Do not record it as completed until all 18 one-shot stages succeed in a
+new evidence directory.
+
+`Recovery8001` contains one TAKE OUT stage. It is valid only after read-only
+inspection proves the interrupted `ThirtyRoutes` run stopped in the exact known,
+settled `PROGRAM 8001` state with unchanged identities and
+`OutcomeUnknown=false`. It must not be used to guess or repair an unclear state.
+
+`Recovery5077Page2` contains one TAKE OUT stage. It is valid only after read-only
+inspection proves the exact preserved `PagedBoundary` manifest is settled in
+`PROGRAM 5077`, page 2/20, with 120 items, page size 6, one completed refresh,
+no pending command, and `OutcomeUnknown=false`. It is not a general PageN repair.
+
+`ThirtyRoutes20To29` contains 41 stages: one read-only manifest reconciliation and
+four stages for each route index 20 through 29. It is valid only after the first 20
+routes and the exact 30-row in-memory manifest have been preserved and reconciled.
+
+## Completed evidence baseline
+
+The accepted latest 2026-07-22 development PGM baseline is split across these
+preserved directories:
+
+- `MBNWEB-20260722-LATEST-CORE-01`: latest combined `Core5001` 6/6;
+- `MBNWEB-20260722-THIRTY-02`: routes 0 through 18 completed and route 19 `8001`
+ reached a correct PGM frame. The subsequent read-only wait hit the runner's old
+ 90-second limit because the original 8001 first-refresh interval is 120 seconds.
+ The wait sent no SDK command; the app remained in known `PROGRAM 8001` with
+ `OutcomeUnknown=false`. This is a runner timeout, not an app or scene failure;
+- `MBNWEB-20260722-RECOVERY8001-01`: after read-only confirmation that the 8001
+ refresh had settled, one exact TAKE OUT returned the known state to IDLE;
+- `MBNWEB-20260722-THIRTY20-29-01`: constrained resume 41/41, completing routes
+ 20 through 29;
+- `MBNWEB-20260722-LATEST-PAGED-04`: latest combined `PagedBoundary` 24/24,
+ including 5077 page 1/20,
+ all 19 Page NEXT inputs through 20/20, playlist NEXT to 5088 page 1/20, and
+ final TAKE OUT/UNLOAD/IDLE.
+
+The older `CORE5001-05` and `PAGED-01` directories remain historical evidence but
+are not the latest combined-code approval. The split thirty-route evidence verifies
+all 30 entries in that workflow, and `LATEST-CORE-01` verifies the additional
+`5001`/`5074` pair, yielding 32 of 34 active runtime routes with actual PGM evidence.
+Network request/success counts match and
+`FAILURE`, `ERROR`, safety-probe `blocked`, `violation`, and `OutcomeUnknown` are
+all zero; no command was retried. `LATEST-PAGED-04` independently fixes the current
+PageN/long-run boundary. `s5006` remains blocked by its cube video. In `s6001`, the
+13 country-video actions remain asset-blocked, while the four image-only actions
+remain actual-PGM pending rather than asset-blocked.
+
+The suspected approximately 65-second 5077 Page NEXT transition was an observation
+artifact, not a runtime defect. Each of the 19 Page NEXT stages emitted exactly one
+effect, PREPARE, and PLAY; `SCENE_PLAYED` followed PLAY in 144-188 ms. Immediate,
+45-second, and 65-second-named captures for pages 9 and 20 are byte-identical, and
+all 17 stages with delayed captures match their immediate capture hashes. The
+original and packaged `5077.t2s` files also have the same SHA-256:
+`845B99DFC499B5BDCD84058D68D91D849D59FB6692CB44784118A99EA68A20E2`.
+Do not add waits or change fade units based on the image-view display inconsistency.
+
+## Stop handling
+
+If `STOPPED.json` exists, do not delete or edit it and do not start another command
+from that run directory. Preserve the complete directory, inspect the last stage's
+`node-stderr.json`, `node-result.json`, `pins-*.json`, Network text, and screenshots,
+then diagnose from read-only state. Never resume the stopped directory itself. The
+normal path is a fresh app instance and a new evidence directory after the state is
+known. `Recovery8001` and `Recovery5077Page2` are the only same-app single-TAKE-OUT
+exceptions; `ThirtyRoutes20To29` is the only constrained manifest continuation.
+Use each only in a new evidence directory after every exact known-state prerequisite
+above has been proved read-only; otherwise close normally and restart fresh.
diff --git a/docs/PLAYOUT.md b/docs/PLAYOUT.md
index 7b9bba2..d73331a 100644
--- a/docs/PLAYOUT.md
+++ b/docs/PLAYOUT.md
@@ -362,7 +362,9 @@ On-air 표식이 남은 상태에서는 프로세스 감시, 연결 해제, 앱
### 화면/계약 구현과 운영 동등성 경계
-- PList 기존 한국어 `DC_TITLE`/`LIST_TEXT` 2,213행은 실제 read-only DB에서 무손상 대조했다. 즉시 복원 1,581행과 GraphE fresh read 22행을 합친 `selectionMapped=1,603`/차단 610을 확인했다. 차단 610행은 DBA 결정 없이 추정 복원하지 않는다.
+- PList 기존 한국어 `DC_TITLE`/`LIST_TEXT` 2,213행은 실제 read-only DB에서 무손상 대조했다. 현행 operator 경로는 활성 정의 23개/2,213행을 모두 로드하고 scene alias까지 복원한다. 개발 패키지에서도 첫 목록 58행을 실제 마우스로 팝업 없이 불러왔다. 과거 `selectionMapped=1,603`/610 보류는 2026-07-12 당시의 fresh typed-selection 증명 수치이지 목록 로드 실패 수치가 아니다. 개별 행의 DB payload·asset·PREPARE/PGM은 사용할 때 별도 검증한다.
+- 2026-07-21 DB 목록 호환 재검수에서 GraphE 7,776행 전체 raw 편집, Theme/ThemeA 1,747개, 전문가 6명/추천 32행, 국내 4,599개, UC5 업종 53개·종목 1,474개를 확인했다. GraphE는 원본의 `_` 빈 슬롯과 빈 분기명을 레거시 저장 규칙으로 수용한 뒤 Revenue 1,960/1, Growth 1,114/5, Sales 2,328/21, OperatingProfit 2,323/24(strict-ready/deferred), 합계 7,725/51로 판정됐다. 목록·원문 편집 가능 상태와 송출 가능 상태는 분리하므로 남은 51행도 조회·편집·삭제할 수 있지만 fresh PREPARE에서 차단한다. 해외 비교 비활성 1행, NULL 매수가 5행, NXT 빈 preview 4개도 화면에서 유실하지 않되 PREPARE/action 전에 차단한다.
+- ThemeA 기존 편집은 활성 종목 preview가 아니라 raw `SB_ITEM` 전체를 읽어 비활성 종목, 교차 prefix와 동일 순번 중복을 보존한다. 이 완화는 편집용 raw 행에만 적용되며 송출용 active preview와 신규 테마 시장 prefix 검증에는 적용하지 않는다.
- 개발 MSIX에서 PList create/save→fresh readback→delete/absence, ThemeA KRX/NXT와 EList create/edit/save→fresh readback→delete/absence를 실제 입력으로 검증하고 cleanup했다. 운영 DB에 대한 포괄적 쓰기 허가는 이 결과에 포함하지 않는다.
- 원본 `종목비교.dat`는 signed package에서 8쌍 one-time import와 재시작 marker를 확인했다. FSell/VI는 격리 fixture의 marker-last와 production opened-handle read를 통과했지만, 현재 운영 profile의 기존 동일 파일을 덮어쓰거나 marker를 새로 만들지 않았다.
- `s5006`의 `Video\큐브배경.vrv`와 `s6001` 해외지수 국가 영상 13개는 현재 승인 Cuts에 없으며, alias-only `Missing=0`은 asset-ready 증거가 아니다.
diff --git a/docs/SCENE_EQUIVALENCE.md b/docs/SCENE_EQUIVALENCE.md
index 0441a67..b91a199 100644
--- a/docs/SCENE_EQUIVALENCE.md
+++ b/docs/SCENE_EQUIVALENCE.md
@@ -1,6 +1,6 @@
# 35개 Scene 동등성 매트릭스
-현행 판정 기준 시각은 2026-07-20이다. 완료·차단·외부 입력 범위는
+현행 판정 기준 시각은 2026-07-22이다. 완료·차단·외부 입력 범위는
[`MIGRATION_STATUS.md`](MIGRATION_STATUS.md)를 우선 적용한다. 원본
`C:\Users\MD\source\repos\MBN_STOCK_N`은 읽기 전용으로만 분석했으며, 이 문서 작업에서도
원본 파일을 수정하지 않았다. 원본 35개 builder의 파일 해시는
@@ -16,16 +16,49 @@
| 원본 inventory와 hash 기준선 | 완료 | 35/35 builder, 원본 기준선 스크립트 35/35 |
| DTO와 mutation builder | 완료 | registry가 정확히 35개 builder를 발견하고 catalog와 1:1 대조 |
| loader와 runtime route | 완료 | MainForm 도달 가능 builder 34개, active alias 45개를 fail-closed route로 등록; `s8086`은 원본과 같이 무alias 진단 전용 |
-| 자동 테스트 | 완료 | 최신 .NET solution 2,817/2,817, LegacyWeb 117/117, JavaScript 502/502, 원본 baseline 35/35 통과; 실패·skip 0. Release 빌드 warning 0, error 0 |
-| 개발 MSIX UI/DB | 완료 | 실제 Windows 입력으로 UC1~UC7, GraphE, FSell, VIList, ThemeA, EList, PList 모달, 검색·키보드·drag와 개발 DB 저장→fresh readback→delete/absence를 검증하고 cleanup 완료 |
+| 자동 테스트 | 완료, 최종 합계 갱신 대기 | 마지막 전체 .NET solution 3,115/3,115, 별도 LegacyParityWeb 127/127, WebPlayout JavaScript 424/424 통과. 그 뒤 즉시 선택·PROGRAM enabled·빈 검색·설정·비교 import 및 s6001 staged workflow의 focused/static 검증도 통과했으며, 전체 solution 최종 합계는 재실행 뒤 갱신 |
+| 개발 MSIX UI/DB | **직전 패키지 PASS · 최신 코드 물리 재검증 대기** | 직전 등록 AppX에서 실제 Windows 입력으로 UC1~UC7, GraphE, FSell, VIList, ThemeA, EList, PList 모달, 검색·키보드·drag와 개발 DB 저장→fresh readback→delete/absence를 검증하고 cleanup 완료. 이후 교정 사항과 설정/비교 local-state를 포함한 최신 AppX 회차는 실행 전 |
| 고객 배포 MSIX | 현재 범위에서 보류 | 현재 결과는 unsigned 개발 패키지. 고객용 version·Publisher·서명 인증서·업데이트와 새 PC 로컬 설정 배포는 사용자가 재개할 때 결정 |
| 실제 DB read→DTO→mutation smoke | 완료 | 34개 도달 가능 builder의 data route 통과: 33개 Oracle/MariaDB loader와 `s5025` trusted 외부 CP949 파일. 무alias `s8086` diagnostic과 NXT restore audit를 포함한 실제 Oracle/MariaDB query 58건이 성공했다. 종속 asset 또는 PGM 준비 상태는 이 행의 범위가 아니다. |
-| 종속 asset preflight | **외부자산 필요** | 승인 Cuts에서 `s5006`의 `Video\큐브배경.vrv`와 `s6001` 해외지수용 국가별 영상 13개가 없다. 누락·빈 파일·root 탈출·reparse 검사는 완료됐고 제공 전에는 두 route를 fail closed한다. |
-| 이번 마이그레이션의 실제 Tornado2/PGM | **32/34 runtime route 완료** | 현재 작업 트리로 5001/5074를 재검증하고 자산 비의존 30개 route를 순차 검증했다. 5077 6행의 1/20→20/20, 마지막 페이지, 5088 12행 전환, 이전 scene unload와 최종 TAKE OUT/IDLE도 확인했다. 미검증은 외부자산이 없는 `s5006`/`s6001`뿐이다. |
+| 종속 asset preflight | **필수 통과 · optional 외부자산 14개 제한** | 설정 Cuts root는 active `.t2s` alias 45개와 필수 built-in asset 9개를 모두 요구한다. `s5006`의 `Video\큐브배경.vrv`와 `s6001` 국가 영상 13개만 optional missing으로 수용하며 관련 action만 fail closed한다. 빈 파일·root 탈출·reparse·중간 junction 검사는 유지한다. |
+| 이번 마이그레이션의 실제 Tornado2/PGM | **32/34 runtime route 최신 실측 · s6001 4-action 대기** | 최신 `LATEST-CORE-01` 6/6, `THIRTY-02`+`RECOVERY8001-01`+`THIRTY20-29-01`의 30 route 30/30, 최신 `LATEST-PAGED-04` 24/24를 실제 PGM에서 확인했다. 5077 6행 1/20→20/20, 5088 12행 1/20 전환, 이전 scene unload와 최종 TAKE OUT/IDLE도 포함한다. `s6001` 영상 비의존 4 action/18-stage workflow는 구현·static PASS이나 실제 PGM은 실행 전이다. |
-2026-07-11 Round H와 실패 회차는 역사적 증거로 보존한다. 현재 32개 route 판정은
-`MBNWEB-20260718-DEV-LIVE-01/02/05`의 실제 PGM 화면, Network Monitoring request/success,
-`FAILURE=0`, `ERROR=0`, retry 0, `OutcomeUnknown=false`를 기준으로 한다.
+2026-07-11 Round H, `CORE5001-05`, `PAGED-01`과 기존 중단 회차는 역사적 증거로
+보존한다. 현재 32개 route 및 PageN 판정의 기준선은 다음 2026-07-22 staged evidence다.
+
+- `MBNWEB-20260722-LATEST-CORE-01`: 6/6. 5001 TAKE IN → 5074 playlist NEXT →
+ 5074 Page NEXT 2/2 → TAKE OUT/IDLE을 확인했다.
+- `MBNWEB-20260722-THIRTY-02`: 자산 비의존 route 0~18의 전체 sequence와
+ route 19 `8001` TAKE IN/PGM을 확인했다. `8001`의 원본 첫 refresh가 120초인데
+ runner wait 상한이 90초였어서 대기 stage만 timeout됐다. 해당 wait는 SDK
+ 명령을 보내지 않았고, 앱은 `PROGRAM 8001`, `OutcomeUnknown=false`의 알려진
+ 상태로 유지됐으므로 앱·scene 실패로 판정하지 않는다.
+- `MBNWEB-20260722-RECOVERY8001-01`: read-only로 refresh settle을 확인한 뒤
+ 알려진 `PROGRAM 8001` 상태에서 TAKE OUT을 단 한 번 수행해 IDLE로 복귀했다.
+- `MBNWEB-20260722-THIRTY20-29-01`: 41/41. route 20~29를 이어서 검증했다.
+- `MBNWEB-20260722-LATEST-PAGED-04`: 24/24. 5077 page 1/20에서 19회 Page NEXT로
+ 20/20까지 전환하고, playlist NEXT로 5088 page 1/20을 출력한 뒤
+ TAKE OUT/UNLOAD/IDLE을 확인했다.
+
+자산 비의존 30개 route의 실제 PGM 결과는 모두 성공이며 Network Monitoring
+request/success가 일치한다. 완료 기준선의 `FAILURE`, `ERROR`, safety-probe
+`blocked`, `violation`, `OutcomeUnknown`은 모두 0이고 명령 retry도 0이다.
+
+staged runner의 authoritative cut inventory는 기존 32개에 `6001`을 추가한 33개다.
+`s6001`의 두바이유·WTI·브렌트유·금 action은 필수 built-in
+`images\주유기merge.png`, `images\35752913_l.jpg`를 source/runtime hash로 고정하고
+물리 double-click으로 4행을 만든 뒤 action마다 activate→TAKE IN→read-only wait→TAKE OUT을
+한 번씩 수행하도록 구현했다. 전체 18 stage와 static audit는 통과했지만, 아직 실제 PGM 회차를
+실행하지 않았으므로 `TOR-22` 또는 route 완료로 표기하지 않는다.
+
+5077 Page NEXT가 약 65초 동안 완성되지 않는다는 의심은 PGM 결함이 아니다.
+19회 Page NEXT는 각각 effect/`Prepare(10)`/`Play(10)`을 한 번씩만 수행했고,
+`Play` 후 `SCENE_PLAYED`는 144~188ms 안에 도착했다. page 9와 page 20의 즉시·45초·
+65초 명명 capture는 각 page 내에서 바이트 단위로 동일하고, 지연 capture가
+있는 17개 stage도 모두 즉시 capture와 해시가 같다. 따라서 이미지 관찰 도구의
+표시·판독 artifact로 기록하며 fade 단위, 중복 prepare/play 또는 scene 자산
+회귀로 분류하지 않는다. 원본과 해당 evidence 패키지의 `5077.t2s` SHA-256도
+`845B99DFC499B5BDCD84058D68D91D849D59FB6692CB44784118A99EA68A20E2`로 동일하다.
## 표기
@@ -49,16 +82,17 @@ Mutation 약어는 실제 COM 메서드를 Web 입력에 노출하지 않는 COM
실제 데이터 상태는 다음과 같이 기록한다.
- `DB-P`: 실제 Oracle/MariaDB 조회 → typed DTO → mutation preflight가 통과했다.
-- `FILE-P`: `s5025`의 trusted 외부 CP949 수동 파일 → DTO → mutation preflight가 통과했다. 실제 파일과 디렉터리는 계속 Git 밖에 둔다.
+- `FILE-P`: `s5025`의 trusted CP949 수동 파일 → DTO → mutation preflight가 통과했다. 현재 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\OperatorData`의 개인/외국인/기관/VI발동 4파일은 authoritative 원본 `bin\Debug\Data`와 length·SHA-256이 정확히 같다. 실제 파일과 디렉터리는 계속 Git 밖에 둔다.
- `DB-DP`: 원본 MainForm에서 도달하지 않는 `s8086` diagnostic 조회와 mutation preflight가 통과했다. 이 결과로 runtime alias를 만들지는 않는다.
- `ASSET-X`: builder가 요구하는 외부 asset이 승인 Cuts에 없거나 강화된 preflight가 끝나지 않아 실제 PREPARE/PGM 준비 상태가 아니다.
-- `TOR-18`: 2026-07-18 현재 작업 트리의 Development Live에서 PGM 화면과 Network Monitoring을 함께 검증했다.
+- `TOR-22`: 2026-07-22 해당 staged evidence가 고정한 Development Live 빌드에서 PGM 화면과 Network Monitoring을 함께 검증했다.
+- `TOR-PENDING`: 실제 PGM용 폐쇄형 workflow와 정적 검증은 완료됐지만 아직 Development Live stage를 실행하지 않았다.
- `TOR-X`: 필요한 외부 제작 자산이 없어 PREPARE 전에 fail closed했다.
- `TOR-NA`: active alias가 없어 운영 송출 대상이 아니다.
## 자동 테스트 묶음
-아래 묶음은 최신 Core Debug/Release x64 전체 실행에 포함돼 실패 0건으로 통과했다. 모든 행에는 공통으로 `LegacySceneCatalogTests`, `LegacySceneMutationBuilderRegistryTests`, `LegacySceneRuntimeCoverageTests`, `LegacySceneDataSourceRouterTests`가 적용된다.
+아래 묶음은 마지막 전체 Core Debug/Release x64 실행에 포함돼 실패 0건으로 통과했다. 이후 변경의 focused 검증도 통과했으며 최종 전체 합계는 재실행 뒤 갱신한다. 모든 행에는 공통으로 `LegacySceneCatalogTests`, `LegacySceneMutationBuilderRegistryTests`, `LegacySceneRuntimeCoverageTests`, `LegacySceneDataSourceRouterTests`가 적용된다.
| 코드 | 테스트 파일 |
|---|---|
@@ -77,40 +111,40 @@ Mutation 약어는 실제 COM 메서드를 Web 입력에 노출하지 않는 COM
| Builder / alias / page | 원본 기능 | 새 builder → loader → resolver/route | mutation | 자동 테스트 | 실제 데이터 | 실제 Tornado |
|---|---|---|---|---|---|---|
-| `s5001` / `5001`, `N5001` / — | 국내·NXT·해외 지수, 환율, 업종, 종목 단일 시세와 등락 표식 | `S5001SceneMutationBuilder` → `S5001SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-18` |
+| `s5001` / `5001`, `N5001` / — | 국내·NXT·해외 지수, 환율, 업종, 종목 단일 시세와 등락 표식 | `S5001SceneMutationBuilder` → `S5001SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-22` |
| `s5006` / `5006` / — | 국내·NXT 종목 현재·시가·고가·저가와 비율, 등락 상태, 큐브 배경 영상 | `S5006SceneMutationBuilder` → `S5006DomesticSceneDataLoader`/`S5006NxtSceneDataLoader` → market 직접 route | `V, Vis, C, BgV` | `T-PANEL` 통과 | `DB-P`; `Video\큐브배경.vrv` 외부 자산 누락 | `TOR-X` |
-| `s5011` / `5011` / — | 국내·NXT 종목 시세, 액면가, 자본금, 시가총액, 순위 | `S5011SceneMutationBuilder` → `S5011SceneDataLoader` → branch 직접 route | `V, Vis, C` | `T-PANEL` 통과 | `DB-P` | `TOR-18` |
-| `s5016` / `5016` / — | 미국·중화권·유럽·아시아 지수와 채권·환율·원자재 3열 panel | `S5016SceneMutationBuilder` → `S5016SceneDataLoader` → closed target 직접 route | `V, Vis` | `T-PANEL` 통과 | `DB-P` | `TOR-18` |
-| `s50160` / `50160` / — | 원면·국제금·국내금 2열 panel | `S50160SceneMutationBuilder` → `S50160SceneDataLoader` → closed target 직접 route | `V, Vis` | `T-PANEL` 통과 | `DB-P` | `TOR-18` |
-| `s5023` / `5023` / — | 코스피·코스닥 일별/월합계 주체별 매매동향 grid | `S5023SceneMutationBuilder` → `S5023SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID` 통과 | `DB-P` | `TOR-18` |
-| `s5024` / `5024` / — | 코스피·코스닥 매매동향 막대, 중앙선과 양·음 크기/위치 | `S5024SceneMutationBuilder` → `S5024SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis, Pos, Scale` | `T-GRID` 통과 | `DB-P` | `TOR-18` |
-| `s5025` / `5025` / — | 승인된 수동 파일의 개인·외국인·기관 순매도 좌·우 5쌍 | `S5025SceneMutationBuilder` → `S5025SceneDataLoader`/`S5025TrustedManualFileDataSource` → `LegacyGridMarketSceneRequestResolver` | `V` | `T-GRID`, `T-MANUAL` 통과 | `FILE-P`; 외부 CP949 파일 통합 검증 완료 | `TOR-18` |
-| `s5026` / `5026` / — | 두 국내 종목 주간 candle과 각 종목 시세/OHLC | `S5026SceneMutationBuilder` → `S5026SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, C, Crop` | `T-COMP` 통과 | `DB-P` | `TOR-18` |
-| `s5029` / `5029` / — | 두 종목 candle·수익률 비교와 두 path-shape | `S5029SceneMutationBuilder` → `S5029SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-18` |
-| `s5032` / `8018`, `8032`, `5032` 중 선물 조건 / — | 선물을 포함한 좌·우 두 항목 plate | `S5032SceneMutationBuilder` → `S5032SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-18` |
-| `s5037` / `5037` / — | 국내 종목 현재가와 매수·매도 거래원별 수량 | `S5037SceneMutationBuilder` → `S5037SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis` | `T-GRID` 통과 | `DB-P` | `TOR-18` |
-| `s5074` / `5074` / 5 | Oracle/MariaDB/DataManager 계열의 최대 5행 시세·수익률 목록 | `S5074SceneMutationBuilder` → `S5074SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-18` (page 1/2) |
-| `s5076` / `5076` / — | 주요매출 구성, 기준일, 항목 비율과 누적 원형 각도 | `S5076SceneMutationBuilder` → `S5076SceneDataLoader` → subject 직접 route | `V, Vis, Angle` | `T-FOUND` 통과 | `DB-P` | `TOR-18` |
-| `s5077` / `5077` / 6 | Oracle/MariaDB/DataManager 계열의 최대 6행 시세·수익률 목록 | `S5077SceneMutationBuilder` → `S5077SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-18` (1/20→20/20) |
-| `s5078` / `5078` / — | 미국·국내 섹터지수 값과 양·음 막대 크기 | `S5078SceneMutationBuilder` → `S5078SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, Scale` | `T-CHART` 통과 | `DB-P` | `TOR-18` |
-| `s5079` / `5079` / — | 성장성 지표 기간·값과 복수 path | `S5079SceneMutationBuilder` → `S5079SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, Path` | `T-CHART` 통과 | `DB-P` | `TOR-18` |
-| `s5080` / `5080` / — | 매출액 분기 시계열과 양·음 막대/중앙선 | `S5080SceneMutationBuilder` → `S5080SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, PosK, Scale` | `T-CHART` 통과 | `DB-P` | `TOR-18` |
-| `s5081` / `5081` / — | 영업이익 분기 시계열과 양·음 막대/중앙선 | `S5081SceneMutationBuilder` → `S5081SceneDataLoader` → subject 직접 route | `V, Vis, PosK, Scale` | `T-FOUND` 통과 | `DB-P` | `TOR-18` |
-| `s5082` / `5082` / — | 일자·개인·기관·외국인 매매동향 grid와 부호 variant | `S5082SceneMutationBuilder` → `S5082SceneDataLoader` → 직접 route | `V, Vis` | `T-5082` 통과 | `DB-P` | `TOR-18` |
-| `s5083` / `5083` / — | 개인·기관·외국인 매매 시계열, baseline과 세 path | `S5083SceneMutationBuilder` → `S5083SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Pos, Path` | `T-CHART` 통과 | `DB-P` | `TOR-18` |
-| `s5084` / `5084` / — | 코스피·코스닥 매매 시계열, baseline과 path | `S5084SceneMutationBuilder` → `S5084SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Pos, Path` | `T-CHART` 통과 | `DB-P` | `TOR-18` |
-| `s5085` / `5085` / — | 프로그램 매매 grid의 구분별 금액과 부호 색상 | `S5085SceneMutationBuilder` → `S5085SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID`, `T-FOUND` 통과 | `DB-P` | `TOR-18` |
-| `s5086` / `5086` / — | 국내·해외 지수/종목/업종 수익률 시계열과 path-shape | `S5086SceneMutationBuilder` → `S5086SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-18` |
-| `s50860` / `50860` / — | 국내·해외 지수/종목/업종 line 시계열과 path-shape | `S50860SceneMutationBuilder` → `S50860SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, C, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-18` |
-| `s5087` / `5087` / — | 두 국내 종목 candle·수익률 비교와 두 line shape | `S5087SceneMutationBuilder` → `S5087SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-18` |
-| `s5088` / `5088` / 12 | Oracle/MariaDB/DataManager 계열의 최대 12행 시세·수익률 목록 | `S5088SceneMutationBuilder` → `S5088SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-18` (5077 뒤 1/20) |
-| `s6001` / `6001` / — | 해외지수와 유가·금 단일 plate, 연계 이미지·영상/방향 상태 | `S6001SceneMutationBuilder` → `S6001SceneDataLoader` → closed target 직접 route | `V, A, Vis, C` | `T-PANEL` 통과 | `DB-P`; 해외지수 국가별 영상 13개 `ASSET-X` | `TOR-X`; 자산 제공 전 PREPARE 금지 |
-| `s6067` / `6067` / — | 기관 순매수 grid와 부호 색상 | `S6067SceneMutationBuilder` → `S6067SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID`, `T-FOUND` 통과 | `DB-P` | `TOR-18` |
-| `s8001` / `8001`, `8002` / — | 코스피·코스닥 업종 square chart와 cube 색상 | `S8001SceneMutationBuilder` → `S8001SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, C` | `T-PARAM` 통과 | `DB-P` | `TOR-18` |
-| `s8003` / `8003` / — | 국내 종목 호가·시세와 매수·매도 잔량 막대 | `S8003SceneMutationBuilder` → `S8003SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis, C, Scale` | `T-GRID` 통과 | `DB-P` | `TOR-18` |
-| `s8010` / `8035`, `8061`, `8040`, `8046`, `8051`, `8056` / — | 지수·종목·거래정지·해외 candle, 거래량, 예상지수, 이동평균 path | `S8010SceneMutationBuilder` → `S8010SceneDataLoader` → alias/market/mode 직접 route | `V, Vis, C, Pos, Crop, Path` | `T-CANDLE` 통과 | `DB-P` | `TOR-18` |
-| `s8018` / `8018`, `8032`, `5032` 중 비선물 조건 / — | 국내·NXT·해외·업종 좌·우 두 항목 plate | `S8018SceneMutationBuilder` → `S8018SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-18` |
-| `s8067` / `8067`, `5068`, `5070`, `5072` / — | 글로벌 world-map의 지역별 현재가·등락과 방향/배경 상태 | `S8067SceneMutationBuilder` → `S8067SceneDataLoader` → 직접 route | `V, Vis, C` | `T-FOUND`, `T-PANEL` 통과 | `DB-P` | `TOR-18` |
+| `s5011` / `5011` / — | 국내·NXT 종목 시세, 액면가, 자본금, 시가총액, 순위 | `S5011SceneMutationBuilder` → `S5011SceneDataLoader` → branch 직접 route | `V, Vis, C` | `T-PANEL` 통과 | `DB-P` | `TOR-22` |
+| `s5016` / `5016` / — | 미국·중화권·유럽·아시아 지수와 채권·환율·원자재 3열 panel | `S5016SceneMutationBuilder` → `S5016SceneDataLoader` → closed target 직접 route | `V, Vis` | `T-PANEL` 통과 | `DB-P` | `TOR-22` |
+| `s50160` / `50160` / — | 원면·국제금·국내금 2열 panel | `S50160SceneMutationBuilder` → `S50160SceneDataLoader` → closed target 직접 route | `V, Vis` | `T-PANEL` 통과 | `DB-P` | `TOR-22` |
+| `s5023` / `5023` / — | 코스피·코스닥 일별/월합계 주체별 매매동향 grid | `S5023SceneMutationBuilder` → `S5023SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID` 통과 | `DB-P` | `TOR-22` |
+| `s5024` / `5024` / — | 코스피·코스닥 매매동향 막대, 중앙선과 양·음 크기/위치 | `S5024SceneMutationBuilder` → `S5024SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis, Pos, Scale` | `T-GRID` 통과 | `DB-P` | `TOR-22` |
+| `s5025` / `5025` / — | 승인된 수동 파일의 개인·외국인·기관 순매도 좌·우 5쌍 | `S5025SceneMutationBuilder` → `S5025SceneDataLoader`/`S5025TrustedManualFileDataSource` → `LegacyGridMarketSceneRequestResolver` | `V` | `T-GRID`, `T-MANUAL` 통과 | `FILE-P`; 외부 CP949 파일 통합 검증 완료 | `TOR-22` |
+| `s5026` / `5026` / — | 두 국내 종목 주간 candle과 각 종목 시세/OHLC | `S5026SceneMutationBuilder` → `S5026SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, C, Crop` | `T-COMP` 통과 | `DB-P` | `TOR-22` |
+| `s5029` / `5029` / — | 두 종목 candle·수익률 비교와 두 path-shape | `S5029SceneMutationBuilder` → `S5029SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-22` |
+| `s5032` / `8018`, `8032`, `5032` 중 선물 조건 / — | 선물을 포함한 좌·우 두 항목 plate | `S5032SceneMutationBuilder` → `S5032SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-22` |
+| `s5037` / `5037` / — | 국내 종목 현재가와 매수·매도 거래원별 수량 | `S5037SceneMutationBuilder` → `S5037SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis` | `T-GRID` 통과 | `DB-P` | `TOR-22` |
+| `s5074` / `5074` / 5 | Oracle/MariaDB/DataManager 계열의 최대 5행 시세·수익률 목록 | `S5074SceneMutationBuilder` → `S5074SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-22` (page 1/2) |
+| `s5076` / `5076` / — | 주요매출 구성, 기준일, 항목 비율과 누적 원형 각도 | `S5076SceneMutationBuilder` → `S5076SceneDataLoader` → subject 직접 route | `V, Vis, Angle` | `T-FOUND` 통과 | `DB-P` | `TOR-22` |
+| `s5077` / `5077` / 6 | Oracle/MariaDB/DataManager 계열의 최대 6행 시세·수익률 목록 | `S5077SceneMutationBuilder` → `S5077SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-22` (1/20→20/20) |
+| `s5078` / `5078` / — | 미국·국내 섹터지수 값과 양·음 막대 크기 | `S5078SceneMutationBuilder` → `S5078SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, Scale` | `T-CHART` 통과 | `DB-P` | `TOR-22` |
+| `s5079` / `5079` / — | 성장성 지표 기간·값과 복수 path | `S5079SceneMutationBuilder` → `S5079SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, Path` | `T-CHART` 통과 | `DB-P` | `TOR-22` |
+| `s5080` / `5080` / — | 매출액 분기 시계열과 양·음 막대/중앙선 | `S5080SceneMutationBuilder` → `S5080SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Vis, PosK, Scale` | `T-CHART` 통과 | `DB-P` | `TOR-22` |
+| `s5081` / `5081` / — | 영업이익 분기 시계열과 양·음 막대/중앙선 | `S5081SceneMutationBuilder` → `S5081SceneDataLoader` → subject 직접 route | `V, Vis, PosK, Scale` | `T-FOUND` 통과 | `DB-P` | `TOR-22` |
+| `s5082` / `5082` / — | 일자·개인·기관·외국인 매매동향 grid와 부호 variant | `S5082SceneMutationBuilder` → `S5082SceneDataLoader` → 직접 route | `V, Vis` | `T-5082` 통과 | `DB-P` | `TOR-22` |
+| `s5083` / `5083` / — | 개인·기관·외국인 매매 시계열, baseline과 세 path | `S5083SceneMutationBuilder` → `S5083SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Pos, Path` | `T-CHART` 통과 | `DB-P` | `TOR-22` |
+| `s5084` / `5084` / — | 코스피·코스닥 매매 시계열, baseline과 path | `S5084SceneMutationBuilder` → `S5084SceneDataLoader` → `ChartLegacySceneRequestResolver` | `V, Pos, Path` | `T-CHART` 통과 | `DB-P` | `TOR-22` |
+| `s5085` / `5085` / — | 프로그램 매매 grid의 구분별 금액과 부호 색상 | `S5085SceneMutationBuilder` → `S5085SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID`, `T-FOUND` 통과 | `DB-P` | `TOR-22` |
+| `s5086` / `5086` / — | 국내·해외 지수/종목/업종 수익률 시계열과 path-shape | `S5086SceneMutationBuilder` → `S5086SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-22` |
+| `s50860` / `50860` / — | 국내·해외 지수/종목/업종 line 시계열과 path-shape | `S50860SceneMutationBuilder` → `S50860SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, C, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-22` |
+| `s5087` / `5087` / — | 두 국내 종목 candle·수익률 비교와 두 line shape | `S5087SceneMutationBuilder` → `S5087SceneDataLoader` → `ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-22` |
+| `s5088` / `5088` / 12 | Oracle/MariaDB/DataManager 계열의 최대 12행 시세·수익률 목록 | `S5088SceneMutationBuilder` → `S5088SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-22` (5077 뒤 1/20) |
+| `s6001` / `6001` / — | 해외지수와 유가·금 단일 plate, 연계 이미지·영상/방향 상태 | `S6001SceneMutationBuilder` → `S6001SceneDataLoader` → closed target 직접 route | `V, A, Vis, C` | `T-PANEL` 통과 | `DB-P`; 유가·금 built-in 이미지 2개 통과, 해외지수 국가별 영상 13개 `ASSET-X` | 유가·금 4 action `TOR-PENDING`; 국가 영상 action `TOR-X` |
+| `s6067` / `6067` / — | 기관 순매수 grid와 부호 색상 | `S6067SceneMutationBuilder` → `S6067SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID`, `T-FOUND` 통과 | `DB-P` | `TOR-22` |
+| `s8001` / `8001`, `8002` / — | 코스피·코스닥 업종 square chart와 cube 색상 | `S8001SceneMutationBuilder` → `S8001SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, C` | `T-PARAM` 통과 | `DB-P` | `TOR-22` |
+| `s8003` / `8003` / — | 국내 종목 호가·시세와 매수·매도 잔량 막대 | `S8003SceneMutationBuilder` → `S8003SceneDataLoader` → `LegacyGridMarketSceneRequestResolver` | `V, Vis, C, Scale` | `T-GRID` 통과 | `DB-P` | `TOR-22` |
+| `s8010` / `8035`, `8061`, `8040`, `8046`, `8051`, `8056` / — | 지수·종목·거래정지·해외 candle, 거래량, 예상지수, 이동평균 path | `S8010SceneMutationBuilder` → `S8010SceneDataLoader` → alias/market/mode 직접 route | `V, Vis, C, Pos, Crop, Path` | `T-CANDLE` 통과 | `DB-P` | `TOR-22` |
+| `s8018` / `8018`, `8032`, `5032` 중 비선물 조건 / — | 국내·NXT·해외·업종 좌·우 두 항목 plate | `S8018SceneMutationBuilder` → `S8018SceneDataLoader` → `LegacyParameterizedSceneRequestResolver` | `V, Vis, A` | `T-PARAM` 통과 | `DB-P` | `TOR-22` |
+| `s8067` / `8067`, `5068`, `5070`, `5072` / — | 글로벌 world-map의 지역별 현재가·등락과 방향/배경 상태 | `S8067SceneMutationBuilder` → `S8067SceneDataLoader` → 직접 route | `V, Vis, C` | `T-FOUND`, `T-PANEL` 통과 | `DB-P` | `TOR-22` |
| `s8086` / active alias 없음 / — | 유가·금 3열 원천. 원본 파일은 있으나 MainForm dispatch 없음 | `S8086SceneMutationBuilder` → `S8086DiagnosticSceneDataLoader`; 앱 runtime route 없음 | `V, C` | `T-FOUND`, `T-PANEL` 통과 | `DB-DP` | `TOR-NA` |
## 공통 호출 순서 동등성
@@ -171,14 +205,16 @@ TAKE IN은 PREPARE 때의 오래된 DTO를 그대로 재생하지 않는다. 원
## 구현 판정과 실제 송출 범위
35개 builder, 45개 active alias, DTO/mutation, 실제 DB read, 5·6·12행 PageN 경계와
-마지막 페이지, Debug/Release x64 suite와 개발 MSIX UI/DB 검증은 완료됐다. 실제 PGM은
-34개 runtime route 가운데 32개를 현재 작업 트리로 검증했다. `5001`/`5074` 재검증 뒤
-자산 비의존 30개 route를 순차 검증했으며, `5077` 20페이지 전체와 `5088` 전환,
-retired scene unload 및 최종 TAKE OUT/IDLE을 포함한다.
+마지막 페이지 자동 계약은 완료됐다. 직전 개발 MSIX UI/DB 검증도 PASS지만 이후 UI 교정을
+포함한 최신 AppX 물리 회차는 대기 중이다. 실제 PGM은 34개 runtime route 가운데 32개를
+검증했다. 최신 `5001`/`5074` 6/6과 자산 비의존 30개 route 30/30을 완료했고,
+최신 `5077` 20페이지 전체와 `5088` 전환 24/24, retired scene unload 및 최종
+TAKE OUT/IDLE을 포함한다.
이 완료 판정에는 다음 범위 제한이 있다.
-1. 실제 PGM 미검증 route는 외부 제작 자산이 없는 `s5006`과 `s6001`뿐이다.
+1. 실제 PGM 미검증 route는 `s5006`과 `s6001`이다. 다만 `s6001`의 영상 비의존 4 action은
+ 외부자산 없이 검증 가능하며 18-stage workflow의 실제 실행만 남았다.
2. `s5025`의 trusted 외부 CP949 파일·경로는 Git과 고객 MSIX에 포함하지 않는다.
3. `s5006`의 background video와 `s6001` 해외지수 국가 영상 13개는 승인된 asset root와
preflight가 준비된 뒤에만 실제 송출한다.
diff --git a/scripts/Invoke-LegacyPackageInputSmoke.ps1 b/scripts/Invoke-LegacyPackageInputSmoke.ps1
index 241ff1c..e569342 100644
--- a/scripts/Invoke-LegacyPackageInputSmoke.ps1
+++ b/scripts/Invoke-LegacyPackageInputSmoke.ps1
@@ -1,11 +1,16 @@
[CmdletBinding()]
param(
+ [ValidateSet('Release', 'DebugAppX')]
+ [string]$BuildFlavor = 'Release',
+
[ValidateSet('ReadOnly', 'DryRunPlayout', 'FullUiDb')]
[string]$Profile = 'ReadOnly',
[ValidateSet('All', 'PList', 'GraphE', 'Catalog', 'Screens')]
[string]$FullUiDbScope = 'All',
+ [string]$RecoverNamedPlaylistTitle,
+
[ValidateRange(1024, 65535)]
[int]$Port = 9339,
@@ -19,7 +24,12 @@ param(
[int]$LaunchTimeoutSeconds = 30,
[ValidateRange(30, 900)]
- [int]$HarnessTimeoutSeconds = 180
+ [int]$HarnessTimeoutSeconds = 180,
+
+ # The local-settings/import smoke reuses the exact package identity, launch,
+ # DryRun and normal-close guards from this script. Returning after the
+ # definitions keeps those guards single-sourced without launching anything.
+ [switch]$DefinitionsOnly
)
Set-StrictMode -Version Latest
@@ -196,7 +206,10 @@ public static class LegacyPackageInputNative
private const uint MouseAbsolute = 0x8000;
private const uint KeyUp = 0x0002;
private const int VirtualKeyLeftButton = 0x01;
+ private const ushort VirtualKeyShift = 0x10;
private const ushort VirtualKeyHome = 0x24;
+ private const ushort VirtualKeyDelete = 0x2E;
+ private const ushort VirtualKeyEscape = 0x1B;
private const int ClientPixelTolerance = 1;
public static string ReadPackageFullName(IntPtr process)
@@ -410,6 +423,349 @@ public static class LegacyPackageInputNative
}
}
+ public static void SendShiftClickRangeThenDeleteAndRestore(
+ IntPtr window,
+ int processId,
+ int forbiddenPort,
+ double rangeStartCssX,
+ double rangeStartCssY,
+ double rangeEndCssX,
+ double rangeEndCssY,
+ double restoreCssX,
+ double restoreCssY,
+ double viewportCssWidth,
+ double viewportCssHeight,
+ double devicePixelRatio)
+ {
+ if (window == IntPtr.Zero || processId <= 0 || forbiddenPort < 1 ||
+ forbiddenPort > 65535 || !IsFinitePositive(viewportCssWidth) ||
+ !IsFinitePositive(viewportCssHeight) || !IsFinitePositive(devicePixelRatio) ||
+ devicePixelRatio < 0.25 || devicePixelRatio > 5.0)
+ {
+ throw new InvalidOperationException("The Windows Shift-range input contract is invalid.");
+ }
+
+ AssertKeyReleased(VirtualKeyLeftButton, "The left mouse button was held before Shift selection.");
+ AssertKeyReleased(VirtualKeyShift, "Shift was held before Shift selection.");
+ AssertKeyReleased(VirtualKeyDelete, "Delete was held before Shift selection.");
+ AssertWindowOwnedByProcess(window, processId);
+ AssertNoForbiddenTcp(processId, forbiddenPort);
+ ClientGeometry baseline = ReadAndAssertClientGeometry(
+ window, viewportCssWidth, viewportCssHeight, devicePixelRatio);
+ AcquireForeground(window, processId, forbiddenPort);
+ SleepWithWindowGuard(
+ 500, window, processId, forbiddenPort, viewportCssWidth,
+ viewportCssHeight, devicePixelRatio, baseline);
+
+ Point rangeStart = CssPointToScreen(
+ baseline, rangeStartCssX, rangeStartCssY, devicePixelRatio);
+ Point rangeEnd = CssPointToScreen(
+ baseline, rangeEndCssX, rangeEndCssY, devicePixelRatio);
+ Point restore = CssPointToScreen(
+ baseline, restoreCssX, restoreCssY, devicePixelRatio);
+ if ((rangeStart.X == rangeEnd.X && rangeStart.Y == rangeEnd.Y) ||
+ (rangeEnd.X == restore.X && rangeEnd.Y == restore.Y))
+ {
+ throw new InvalidOperationException("The Windows Shift-range points are not distinct.");
+ }
+ AssertPointOwnedByWindow(window, rangeStart);
+ AssertPointOwnedByWindow(window, rangeEnd);
+ AssertPointOwnedByWindow(window, restore);
+
+ bool leftButtonDown = false;
+ bool shiftDown = false;
+ bool deleteDown = false;
+ try
+ {
+ SendMouseMove(rangeStart.X, rangeStart.Y);
+ SleepWithWindowGuard(
+ 100, window, processId, forbiddenPort, viewportCssWidth,
+ viewportCssHeight, devicePixelRatio, baseline);
+ AssertCursorAt(rangeStart);
+ SendMouseButton(MouseLeftDown);
+ leftButtonDown = true;
+ SleepWithWindowGuard(
+ 45, window, processId, forbiddenPort, viewportCssWidth,
+ viewportCssHeight, devicePixelRatio, baseline);
+ AssertCursorAt(rangeStart);
+ AssertKeyState(
+ VirtualKeyLeftButton,
+ true,
+ "The left mouse button changed during the range-start click.");
+ SendMouseButton(MouseLeftUp);
+ leftButtonDown = false;
+ SleepWithWindowGuard(
+ 300, window, processId, forbiddenPort, viewportCssWidth,
+ viewportCssHeight, devicePixelRatio, baseline);
+
+ SendKeyboard(VirtualKeyShift, false);
+ shiftDown = true;
+ AssertKeyState(VirtualKeyShift, true, "Shift did not remain held for range selection.");
+ SendMouseMove(rangeEnd.X, rangeEnd.Y);
+ SleepWithWindowGuard(
+ 100, window, processId, forbiddenPort, viewportCssWidth,
+ viewportCssHeight, devicePixelRatio, baseline);
+ AssertCursorAt(rangeEnd);
+ AssertKeyState(VirtualKeyShift, true, "Shift changed before the range click.");
+ SendMouseButton(MouseLeftDown);
+ leftButtonDown = true;
+ SleepWithWindowGuard(
+ 45, window, processId, forbiddenPort, viewportCssWidth,
+ viewportCssHeight, devicePixelRatio, baseline);
+ AssertCursorAt(rangeEnd);
+ AssertKeyState(VirtualKeyShift, true, "Shift changed during the range click.");
+ AssertKeyState(
+ VirtualKeyLeftButton,
+ true,
+ "The left mouse button changed during the range-end click.");
+ SendMouseButton(MouseLeftUp);
+ leftButtonDown = false;
+ SendKeyboard(VirtualKeyShift, true);
+ shiftDown = false;
+ SleepWithWindowGuard(
+ 300, window, processId, forbiddenPort, viewportCssWidth,
+ viewportCssHeight, devicePixelRatio, baseline);
+
+ SendKeyboard(VirtualKeyDelete, false);
+ deleteDown = true;
+ SleepWithWindowGuard(
+ 45, window, processId, forbiddenPort, viewportCssWidth,
+ viewportCssHeight, devicePixelRatio, baseline);
+ AssertKeyState(VirtualKeyDelete, true, "Delete changed during the delete key press.");
+ SendKeyboard(VirtualKeyDelete, true);
+ deleteDown = false;
+ SleepWithWindowGuard(
+ 650, window, processId, forbiddenPort, viewportCssWidth,
+ viewportCssHeight, devicePixelRatio, baseline);
+
+ AssertPointOwnedByWindow(window, restore);
+ SendMouseMove(restore.X, restore.Y);
+ SleepWithWindowGuard(
+ 100, window, processId, forbiddenPort, viewportCssWidth,
+ viewportCssHeight, devicePixelRatio, baseline);
+ AssertCursorAt(restore);
+ SendMouseButton(MouseLeftDown);
+ leftButtonDown = true;
+ SleepWithWindowGuard(
+ 45, window, processId, forbiddenPort, viewportCssWidth,
+ viewportCssHeight, devicePixelRatio, baseline);
+ AssertCursorAt(restore);
+ AssertKeyState(
+ VirtualKeyLeftButton,
+ true,
+ "The left mouse button changed during restored selection.");
+ SendMouseButton(MouseLeftUp);
+ leftButtonDown = false;
+ SleepWithWindowGuard(
+ 300, window, processId, forbiddenPort, viewportCssWidth,
+ viewportCssHeight, devicePixelRatio, baseline);
+ }
+ finally
+ {
+ if (deleteDown) SendKeyboard(VirtualKeyDelete, true);
+ if (shiftDown) SendKeyboard(VirtualKeyShift, true);
+ if (leftButtonDown) SendMouseButton(MouseLeftUp);
+ AssertKeyReleased(VirtualKeyDelete, "Delete remained held after Shift-range cleanup.");
+ AssertKeyReleased(VirtualKeyShift, "Shift remained held after Shift-range cleanup.");
+ AssertKeyReleased(
+ VirtualKeyLeftButton,
+ "The left mouse button remained held after Shift-range cleanup.");
+ }
+ }
+
+ public static void SendApplicationClick(
+ IntPtr window,
+ int processId,
+ int forbiddenPort,
+ double cssX,
+ double cssY,
+ double viewportCssWidth,
+ double viewportCssHeight,
+ double devicePixelRatio,
+ bool allowForegroundTransitionAfterClick)
+ {
+ if (window == IntPtr.Zero || processId <= 0 || forbiddenPort < 1 ||
+ forbiddenPort > 65535 || !IsFinitePositive(viewportCssWidth) ||
+ !IsFinitePositive(viewportCssHeight) || !IsFinitePositive(devicePixelRatio) ||
+ devicePixelRatio < 0.25 || devicePixelRatio > 5.0)
+ {
+ throw new InvalidOperationException("The Windows click coordinate contract is invalid.");
+ }
+
+ AssertKeyReleased(VirtualKeyLeftButton, "The left mouse button was held before input.");
+ AssertWindowOwnedByProcess(window, processId);
+ AssertNoForbiddenTcp(processId, forbiddenPort);
+ ClientGeometry baseline = ReadAndAssertClientGeometry(
+ window, viewportCssWidth, viewportCssHeight, devicePixelRatio);
+ AcquireForeground(window, processId, forbiddenPort);
+ SleepWithWindowGuard(
+ 150, window, processId, forbiddenPort, viewportCssWidth,
+ viewportCssHeight, devicePixelRatio, baseline);
+
+ Point point = CssPointToScreen(baseline, cssX, cssY, devicePixelRatio);
+ AssertPointOwnedByWindow(window, point);
+ bool leftButtonDown = false;
+ try
+ {
+ SendMouseMove(point.X, point.Y);
+ SleepWithInputGuard(
+ 100, window, processId, forbiddenPort, viewportCssWidth,
+ viewportCssHeight, devicePixelRatio, baseline, point, point,
+ point, false, false);
+ SendMouseButton(MouseLeftDown);
+ leftButtonDown = true;
+ SleepWithInputGuard(
+ 45, window, processId, forbiddenPort, viewportCssWidth,
+ viewportCssHeight, devicePixelRatio, baseline, point, point,
+ point, true, false);
+ SendMouseButton(MouseLeftUp);
+ leftButtonDown = false;
+ if (allowForegroundTransitionAfterClick)
+ {
+ Thread.Sleep(45);
+ AssertWindowOwnedByProcess(window, processId);
+ AssertNoForbiddenTcp(processId, forbiddenPort);
+ }
+ else
+ {
+ SleepWithInputGuard(
+ 100, window, processId, forbiddenPort, viewportCssWidth,
+ viewportCssHeight, devicePixelRatio, baseline, point, point,
+ point, false, false);
+ }
+ }
+ finally
+ {
+ if (leftButtonDown)
+ {
+ SendMouseButton(MouseLeftUp);
+ }
+ AssertKeyReleased(
+ VirtualKeyLeftButton,
+ "The left mouse button remained held after click cleanup.");
+ }
+ }
+
+ public static void SendApplicationDoubleClick(
+ IntPtr window,
+ int processId,
+ int forbiddenPort,
+ double cssX,
+ double cssY,
+ double viewportCssWidth,
+ double viewportCssHeight,
+ double devicePixelRatio,
+ int interClickDelayMilliseconds)
+ {
+ if (window == IntPtr.Zero || processId <= 0 || forbiddenPort < 1 ||
+ forbiddenPort > 65535 || !IsFinitePositive(viewportCssWidth) ||
+ !IsFinitePositive(viewportCssHeight) || !IsFinitePositive(devicePixelRatio) ||
+ devicePixelRatio < 0.25 || devicePixelRatio > 5.0 ||
+ interClickDelayMilliseconds < 50 || interClickDelayMilliseconds > 250)
+ {
+ throw new InvalidOperationException("The Windows double-click contract is invalid.");
+ }
+
+ AssertKeyReleased(VirtualKeyLeftButton, "The left mouse button was held before double-click input.");
+ AssertWindowOwnedByProcess(window, processId);
+ AssertNoForbiddenTcp(processId, forbiddenPort);
+ ClientGeometry baseline = ReadAndAssertClientGeometry(
+ window, viewportCssWidth, viewportCssHeight, devicePixelRatio);
+ AcquireForeground(window, processId, forbiddenPort);
+ SleepWithWindowGuard(
+ 150, window, processId, forbiddenPort, viewportCssWidth,
+ viewportCssHeight, devicePixelRatio, baseline);
+
+ Point point = CssPointToScreen(baseline, cssX, cssY, devicePixelRatio);
+ AssertPointOwnedByWindow(window, point);
+ bool leftButtonDown = false;
+ try
+ {
+ SendMouseMove(point.X, point.Y);
+ SleepWithWindowGuard(
+ 100, window, processId, forbiddenPort, viewportCssWidth,
+ viewportCssHeight, devicePixelRatio, baseline);
+ AssertCursorAt(point);
+ for (int click = 0; click < 2; click++)
+ {
+ SendMouseButton(MouseLeftDown);
+ leftButtonDown = true;
+ SleepWithWindowGuard(
+ 45, window, processId, forbiddenPort, viewportCssWidth,
+ viewportCssHeight, devicePixelRatio, baseline);
+ AssertCursorAt(point);
+ AssertKeyState(
+ VirtualKeyLeftButton,
+ true,
+ "The left mouse button changed during double-click input.");
+ SendMouseButton(MouseLeftUp);
+ leftButtonDown = false;
+ if (click == 0)
+ {
+ SleepWithWindowGuard(
+ interClickDelayMilliseconds,
+ window,
+ processId,
+ forbiddenPort,
+ viewportCssWidth,
+ viewportCssHeight,
+ devicePixelRatio,
+ baseline);
+ AssertCursorAt(point);
+ }
+ }
+ SleepWithWindowGuard(
+ 300, window, processId, forbiddenPort, viewportCssWidth,
+ viewportCssHeight, devicePixelRatio, baseline);
+ }
+ finally
+ {
+ if (leftButtonDown) SendMouseButton(MouseLeftUp);
+ AssertKeyReleased(
+ VirtualKeyLeftButton,
+ "The left mouse button remained held after double-click cleanup.");
+ }
+ }
+
+ public static IntPtr ReadForegroundWindow()
+ {
+ return GetForegroundWindow();
+ }
+
+ public static void SendEscapeToForegroundWindow(IntPtr expectedWindow)
+ {
+ IntPtr foreground = GetForegroundWindow();
+ if (expectedWindow == IntPtr.Zero || foreground == IntPtr.Zero ||
+ GetAncestor(foreground, GetAncestorRoot) != expectedWindow)
+ {
+ throw new InvalidOperationException(
+ "The exact folder picker does not own foreground keyboard input.");
+ }
+
+ AssertKeyReleased(VirtualKeyEscape, "Escape was held before picker cancellation.");
+ bool escapeDown = false;
+ try
+ {
+ SendKeyboard(VirtualKeyEscape, false);
+ escapeDown = true;
+ Thread.Sleep(45);
+ // Escape cancellation may return foreground to the owner while
+ // the picker is still being destroyed. Key-up only releases the
+ // injected key; the caller separately proves that this exact
+ // picker closes and never sends another Escape.
+ SendKeyboard(VirtualKeyEscape, true);
+ escapeDown = false;
+ }
+ finally
+ {
+ if (escapeDown)
+ {
+ SendKeyboard(VirtualKeyEscape, true);
+ }
+ AssertKeyReleased(VirtualKeyEscape, "Escape remained held after picker cancellation.");
+ }
+ }
+
private static void AcquireForeground(IntPtr window, int processId, int forbiddenPort)
{
AssertWindowOwnedByProcess(window, processId);
@@ -930,8 +1286,19 @@ $K3dInteropHashVariable = 'MBN_STOCK_K3D_INTEROP_SHA256'
$WebViewArgumentsVariable = 'WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS'
$RepositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$HarnessPath = Join-Path $PSScriptRoot 'Test-LegacyPackageInput.mjs'
-$ExpectedInstallLocation = Join-Path $RepositoryRoot `
- 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Release\net8.0-windows10.0.19041.0\win-x64'
+$ExpectedInstallRelativePath = switch ($BuildFlavor) {
+ 'Release' {
+ 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Release\net8.0-windows10.0.19041.0\win-x64'
+ break
+ }
+ 'DebugAppX' {
+ 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Debug\net8.0-windows10.0.19041.0\win-x64\AppX'
+ break
+ }
+ default { Throw-SafeFailure 'The package build flavor is not closed.' }
+}
+$PackageModeLabel = "Development $BuildFlavor x64"
+$ExpectedInstallLocation = Join-Path $RepositoryRoot $ExpectedInstallRelativePath
$ExpectedInstallLocation = [IO.Path]::GetFullPath($ExpectedInstallLocation)
$ConfigurationPath = Join-Path `
([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) `
@@ -1111,6 +1478,72 @@ function Assert-NoUnsafeEnvironment {
}
}
+function Get-StreamSha256Hex([IO.Stream]$Stream) {
+ $algorithm = [Security.Cryptography.SHA256]::Create()
+ try {
+ return [BitConverter]::ToString($algorithm.ComputeHash($Stream)).Replace('-', '')
+ }
+ finally {
+ $algorithm.Dispose()
+ }
+}
+
+function Assert-DebugAppXMatchesLatestPackage([string]$InstallLocation) {
+ if ($BuildFlavor -cne 'DebugAppX') {
+ return
+ }
+
+ $projectRoot = Join-Path $RepositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp'
+ $msix = Join-Path $projectRoot `
+ 'AppPackages\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64_Debug_Test\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64_Debug.msix'
+ if (-not [IO.File]::Exists($msix)) {
+ Throw-SafeFailure 'The latest Debug MSIX is missing; the registered AppX cannot be proven current.'
+ }
+
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
+ $archive = [IO.Compression.ZipFile]::OpenRead($msix)
+ try {
+ $expectedFiles = @(
+ 'MBN_STOCK_WEBVIEW.Core.dll',
+ 'MBN_STOCK_WEBVIEW.Infrastructure.dll',
+ 'MBN_STOCK_WEBVIEW.LegacyApplication.dll',
+ 'MBN_STOCK_WEBVIEW.LegacyBridge.dll',
+ 'MBN_STOCK_WEBVIEW.LegacyParityApp.dll',
+ 'Web/app.js'
+ )
+ foreach ($relativePath in $expectedFiles) {
+ $entry = $archive.GetEntry($relativePath)
+ $installed = Join-Path $InstallLocation $relativePath.Replace('/', '\')
+ if ($null -eq $entry -or -not [IO.File]::Exists($installed)) {
+ Throw-SafeFailure 'The registered Debug AppX is missing a required current package file.'
+ }
+
+ $entryStream = $entry.Open()
+ try {
+ $packageHash = Get-StreamSha256Hex $entryStream
+ }
+ finally {
+ $entryStream.Dispose()
+ }
+ $installedHash = (Get-FileHash -LiteralPath $installed -Algorithm SHA256).Hash
+ if ($installedHash -cne $packageHash) {
+ Throw-SafeFailure 'The registered Debug AppX is stale relative to the latest Debug MSIX.'
+ }
+ }
+ }
+ finally {
+ $archive.Dispose()
+ }
+
+ $sourceAppJs = Join-Path $projectRoot 'Web\app.js'
+ $installedAppJs = Join-Path $InstallLocation 'Web\app.js'
+ if (-not [IO.File]::Exists($sourceAppJs) -or
+ (Get-FileHash -LiteralPath $sourceAppJs -Algorithm SHA256).Hash -cne
+ (Get-FileHash -LiteralPath $installedAppJs -Algorithm SHA256).Hash) {
+ Throw-SafeFailure 'The registered Debug AppX does not contain the current Web application source.'
+ }
+}
+
function Get-ExactDevelopmentPackage {
$packages = @(Get-AppxPackage -Name $PackageName -ErrorAction Stop)
if ($packages.Count -ne 1) {
@@ -1128,7 +1561,7 @@ function Get-ExactDevelopmentPackage {
[string]$package.Version -cne $PackageVersion -or
[string]$package.Architecture -cne 'X64' -or
-not (Test-PathEqual ([string]$package.InstallLocation) $ExpectedInstallLocation)) {
- Throw-SafeFailure 'The registered package is not the exact Release x64 development package.'
+ Throw-SafeFailure "The registered package is not the exact $BuildFlavor x64 development package."
}
try {
@@ -1148,9 +1581,11 @@ function Get-ExactDevelopmentPackage {
$executable = Join-Path ([string]$package.InstallLocation) $ExecutableName
if (-not [IO.File]::Exists($executable)) {
- Throw-SafeFailure 'The registered Release application executable is missing.'
+ Throw-SafeFailure "The registered $BuildFlavor application executable is missing."
}
+ Assert-DebugAppXMatchesLatestPackage ([string]$package.InstallLocation)
+
return [pscustomobject]@{
Package = $package
Executable = [IO.Path]::GetFullPath($executable)
@@ -1356,19 +1791,50 @@ function ConvertTo-FiniteNumber([object]$Value, [string]$Label) {
return $number
}
-function Write-WindowsInputAcknowledgement([string]$Path, [string]$Token) {
+function Get-WindowsInputExchangePath(
+ [string]$BasePath,
+ [ValidateRange(1, 2)]
+ [int]$ExchangeIndex) {
+ if ($ExchangeIndex -eq 1) {
+ return $BasePath
+ }
+ $directory = [IO.Path]::GetDirectoryName($BasePath)
+ $extension = [IO.Path]::GetExtension($BasePath)
+ $name = [IO.Path]::GetFileNameWithoutExtension($BasePath)
+ return Join-Path $directory (
+ $name + '.exchange-' + $ExchangeIndex.ToString('00') + $extension)
+}
+
+function Get-ExpectedWindowsInputExchangeCount(
+ [string]$HarnessProfile,
+ [string]$HarnessScope) {
+ if (($HarnessProfile -ceq 'FullUiDb' -or $HarnessProfile -ceq 'full-ui-db') -and
+ ($HarnessScope -ceq 'PList' -or $HarnessScope -ceq 'plist' -or
+ $HarnessScope -ceq 'All' -or $HarnessScope -ceq 'all')) {
+ return 2
+ }
+ return 1
+}
+
+function Write-WindowsInputAcknowledgement(
+ [string]$Path,
+ [int]$ExchangeIndex,
+ [string]$Token,
+ [string]$Operation,
+ [Collections.IDictionary]$InputCounts) {
$acknowledgement = [ordered]@{
schemaVersion = 1
+ exchangeIndex = $ExchangeIndex
token = $Token
result = 'PASS'
- operation = 'row-header-drag-after-then-home'
+ operation = $Operation
foregroundValidated = $true
packageIdentityValidated = $true
- mouseDownCalls = 1
- mouseUpCalls = 1
- homeKeyCalls = 1
inputRetryCount = 0
}
+ foreach ($key in $InputCounts.Keys) {
+ $acknowledgement[$key] = $InputCounts[$key]
+ }
$bytes = $Utf8.GetBytes(($acknowledgement | ConvertTo-Json -Compress) + "`n")
$parent = [IO.Path]::GetDirectoryName($Path)
$temporaryPath = Join-Path $parent (
@@ -1406,13 +1872,209 @@ function Write-WindowsInputAcknowledgement([string]$Path, [string]$Token) {
}
}
+function ConvertTo-ExactInputPoint(
+ [object]$Point,
+ [double]$ViewportWidth,
+ [double]$ViewportHeight,
+ [string]$Label) {
+ Assert-ExactJsonPropertyNames $Point @('x', 'y') $Label
+ $x = ConvertTo-FiniteNumber $Point.x "$Label X"
+ $y = ConvertTo-FiniteNumber $Point.y "$Label Y"
+ if ($x -lt 0 -or $x -ge $ViewportWidth -or
+ $y -lt 0 -or $y -ge $ViewportHeight) {
+ Throw-SafeFailure "$Label is outside the exact package viewport."
+ }
+ return [pscustomobject]@{ X = $x; Y = $y }
+}
+
+function Invoke-ExactPlaylistWindowsInputRequest(
+ [object]$Request,
+ [Diagnostics.Process]$Application) {
+ Assert-ExactJsonPropertyNames $Request @(
+ 'schemaVersion', 'exchangeIndex', 'token', 'targetUrl', 'operation',
+ 'sourceRowId', 'targetRowId', 'rangeStartRowId', 'rangeEndRowId',
+ 'position', 'source', 'target', 'rangeStart', 'rangeEnd',
+ 'restoreSelection', 'viewport', 'devicePixelRatio', 'beforeOrder',
+ 'reorderedOrder', 'afterOrder', 'expectedRangeSelectedRowIds',
+ 'expectedSelectedRowId', 'expectedActiveRowIdAfterHome',
+ 'expectedFinalActiveRowId', 'expectedFocusedRowId', 'mouseDownCalls',
+ 'mouseUpCalls', 'homeKeyCalls', 'shiftKeyDownCalls', 'shiftKeyUpCalls',
+ 'deleteKeyCalls', 'inputRetryCount') 'Playlist Windows input request'
+ Assert-ExactJsonPropertyNames $Request.viewport @('width', 'height') `
+ 'Playlist Windows input viewport'
+
+ $sourceRowId = [string]$Request.sourceRowId
+ $targetRowId = [string]$Request.targetRowId
+ $rangeStartRowId = [string]$Request.rangeStartRowId
+ $rangeEndRowId = [string]$Request.rangeEndRowId
+ $rowIds = @($sourceRowId, $targetRowId, $rangeStartRowId, $rangeEndRowId)
+ if ($Request.sourceRowId -isnot [string] -or
+ $Request.targetRowId -isnot [string] -or
+ $Request.rangeStartRowId -isnot [string] -or
+ $Request.rangeEndRowId -isnot [string] -or
+ $Request.position -isnot [string] -or
+ [string]$Request.position -cne 'after' -or
+ $sourceRowId -ceq $targetRowId -or
+ $rangeStartRowId -ceq $rangeEndRowId -or
+ (@($rowIds | Select-Object -Unique)).Count -ne 4 -or
+ $rowIds.Where({ $_ -cnotmatch '\Alegacy-stock-[0-9]{8}\z' }).Count -ne 0 -or
+ [string]$Request.expectedSelectedRowId -cne $sourceRowId -or
+ [string]$Request.expectedFinalActiveRowId -cne $sourceRowId -or
+ [string]$Request.expectedFocusedRowId -cne $sourceRowId -or
+ (ConvertTo-FiniteNumber $Request.mouseDownCalls 'Playlist mouse-down count') -ne 4 -or
+ (ConvertTo-FiniteNumber $Request.mouseUpCalls 'Playlist mouse-up count') -ne 4 -or
+ (ConvertTo-FiniteNumber $Request.homeKeyCalls 'Playlist Home count') -ne 1 -or
+ (ConvertTo-FiniteNumber $Request.shiftKeyDownCalls 'Playlist Shift-down count') -ne 1 -or
+ (ConvertTo-FiniteNumber $Request.shiftKeyUpCalls 'Playlist Shift-up count') -ne 1 -or
+ (ConvertTo-FiniteNumber $Request.deleteKeyCalls 'Playlist Delete count') -ne 1) {
+ Throw-SafeFailure 'The playlist Windows input request is outside the exact gesture contract.'
+ }
+
+ $beforeOrder = @($Request.beforeOrder | ForEach-Object { [string]$_ })
+ $reorderedOrder = @($Request.reorderedOrder | ForEach-Object { [string]$_ })
+ $afterOrder = @($Request.afterOrder | ForEach-Object { [string]$_ })
+ $expectedRange = @($Request.expectedRangeSelectedRowIds | ForEach-Object { [string]$_ })
+ if ($beforeOrder.Count -ne 5 -or $reorderedOrder.Count -ne 5 -or
+ $afterOrder.Count -ne 3 -or
+ (@($beforeOrder | Select-Object -Unique)).Count -ne 5 -or
+ (@($reorderedOrder | Select-Object -Unique)).Count -ne 5 -or
+ (@($afterOrder | Select-Object -Unique)).Count -ne 3 -or
+ -not (Test-ExactStringSequence $expectedRange @($rangeStartRowId, $rangeEndRowId))) {
+ Throw-SafeFailure 'The playlist Windows input row orders are not the exact five-to-three fixture.'
+ }
+ foreach ($rowId in $beforeOrder + $reorderedOrder + $afterOrder) {
+ if ($rowId -cnotmatch '\Alegacy-stock-[0-9]{8}\z') {
+ Throw-SafeFailure 'The playlist Windows input request contains an invalid row identity.'
+ }
+ }
+
+ $computedReorder = New-Object 'Collections.Generic.List[string]'
+ foreach ($rowId in $beforeOrder) {
+ if ($rowId -cne $sourceRowId) { [void]$computedReorder.Add($rowId) }
+ }
+ $targetIndex = $computedReorder.IndexOf($targetRowId)
+ if ($targetIndex -lt 0) {
+ Throw-SafeFailure 'The playlist drag target is absent after removing its source.'
+ }
+ $computedReorder.Insert($targetIndex + 1, $sourceRowId)
+ if (-not (Test-ExactStringSequence @($computedReorder) $reorderedOrder) -or
+ $reorderedOrder[-2] -cne $rangeStartRowId -or
+ $reorderedOrder[-1] -cne $rangeEndRowId -or
+ [string]$Request.expectedActiveRowIdAfterHome -cne $reorderedOrder[0]) {
+ Throw-SafeFailure 'The playlist reorder/Home/range contract is not exact.'
+ }
+ $computedAfter = @($reorderedOrder | Where-Object {
+ $_ -cne $rangeStartRowId -and $_ -cne $rangeEndRowId
+ })
+ if (-not (Test-ExactStringSequence $computedAfter $afterOrder)) {
+ Throw-SafeFailure 'The playlist Shift-range delete result is not exact.'
+ }
+
+ $viewportWidth = ConvertTo-FiniteNumber $Request.viewport.width `
+ 'Playlist Windows input viewport width'
+ $viewportHeight = ConvertTo-FiniteNumber $Request.viewport.height `
+ 'Playlist Windows input viewport height'
+ $devicePixelRatio = ConvertTo-FiniteNumber $Request.devicePixelRatio `
+ 'Playlist Windows input device pixel ratio'
+ if ($viewportWidth -lt 1 -or $viewportWidth -gt 16384 -or
+ $viewportHeight -lt 1 -or $viewportHeight -gt 16384 -or
+ $devicePixelRatio -lt 0.25 -or $devicePixelRatio -gt 5) {
+ Throw-SafeFailure 'The playlist Windows input viewport contract is invalid.'
+ }
+ $source = ConvertTo-ExactInputPoint $Request.source $viewportWidth $viewportHeight 'Playlist drag source'
+ $target = ConvertTo-ExactInputPoint $Request.target $viewportWidth $viewportHeight 'Playlist drag target'
+ $rangeStart = ConvertTo-ExactInputPoint $Request.rangeStart $viewportWidth $viewportHeight 'Playlist range start'
+ $rangeEnd = ConvertTo-ExactInputPoint $Request.rangeEnd $viewportWidth $viewportHeight 'Playlist range end'
+ $restore = ConvertTo-ExactInputPoint $Request.restoreSelection $viewportWidth $viewportHeight 'Playlist restore selection'
+
+ [LegacyPackageInputNative]::SendRowHeaderDragAfterThenHome(
+ $Application.MainWindowHandle, $Application.Id, 30001,
+ $source.X, $source.Y, $target.X, $target.Y,
+ $viewportWidth, $viewportHeight, $devicePixelRatio)
+ [LegacyPackageInputNative]::SendShiftClickRangeThenDeleteAndRestore(
+ $Application.MainWindowHandle, $Application.Id, 30001,
+ $rangeStart.X, $rangeStart.Y, $rangeEnd.X, $rangeEnd.Y,
+ $restore.X, $restore.Y,
+ $viewportWidth, $viewportHeight, $devicePixelRatio)
+}
+
+function Invoke-ExactNamedPlaylistDoubleClickRequest(
+ [object]$Request,
+ [Diagnostics.Process]$Application) {
+ Assert-ExactJsonPropertyNames $Request @(
+ 'schemaVersion', 'exchangeIndex', 'token', 'targetUrl', 'operation',
+ 'definitionId', 'definitionTitle', 'point', 'viewport',
+ 'devicePixelRatio', 'expectedPlaylistContent', 'mouseDownCalls',
+ 'mouseUpCalls', 'doubleClickIntervalMilliseconds',
+ 'inputRetryCount') 'Named-playlist double-click request'
+ Assert-ExactJsonPropertyNames $Request.viewport @('width', 'height') `
+ 'Named-playlist double-click viewport'
+ if ($Request.definitionId -isnot [string] -or
+ [string]::IsNullOrEmpty([string]$Request.definitionId) -or
+ ([string]$Request.definitionId).Length -gt 256 -or
+ [string]$Request.definitionId -cmatch '[\x00-\x1F\x7F]' -or
+ $Request.definitionTitle -isnot [string] -or
+ ([string]$Request.definitionTitle).Length -ne 26 -or
+ [string]$Request.definitionTitle -cnotmatch
+ '\ACDX_P_[0-9]{13}_[0-9a-f]{6}\z' -or
+ (ConvertTo-FiniteNumber $Request.mouseDownCalls 'PList double-click mouse-down count') -ne 2 -or
+ (ConvertTo-FiniteNumber $Request.mouseUpCalls 'PList double-click mouse-up count') -ne 2 -or
+ (ConvertTo-FiniteNumber $Request.doubleClickIntervalMilliseconds `
+ 'PList double-click interval') -ne 100) {
+ Throw-SafeFailure 'The named-playlist double-click identity/count contract is invalid.'
+ }
+
+ $content = @($Request.expectedPlaylistContent)
+ if ($content.Count -ne 3) {
+ Throw-SafeFailure 'The named-playlist double-click does not preserve exactly three rows.'
+ }
+ foreach ($row in $content) {
+ Assert-ExactJsonPropertyNames $row @(
+ 'isEnabled', 'marketText', 'stockName', 'graphicType',
+ 'subtype', 'pageText') 'Named-playlist expected row'
+ if ($row.isEnabled -ne $true -or
+ $row.marketText -isnot [string] -or
+ $row.stockName -isnot [string] -or
+ $row.graphicType -isnot [string] -or
+ $row.subtype -isnot [string] -or
+ $row.pageText -isnot [string] -or
+ [string]$row.graphicType -cne '1열판기본') {
+ Throw-SafeFailure 'The named-playlist expected row is outside the exact saved fixture.'
+ }
+ }
+ $subtypes = @($content | ForEach-Object { [string]$_.subtype })
+ if (-not (Test-ExactStringSequence $subtypes @('현재가', '현재가', '시간외단일가'))) {
+ Throw-SafeFailure 'The named-playlist expected row order is not the saved three-row fixture.'
+ }
+
+ $viewportWidth = ConvertTo-FiniteNumber $Request.viewport.width `
+ 'PList double-click viewport width'
+ $viewportHeight = ConvertTo-FiniteNumber $Request.viewport.height `
+ 'PList double-click viewport height'
+ $devicePixelRatio = ConvertTo-FiniteNumber $Request.devicePixelRatio `
+ 'PList double-click device pixel ratio'
+ if ($viewportWidth -lt 1 -or $viewportWidth -gt 16384 -or
+ $viewportHeight -lt 1 -or $viewportHeight -gt 16384 -or
+ $devicePixelRatio -lt 0.25 -or $devicePixelRatio -gt 5) {
+ Throw-SafeFailure 'The named-playlist double-click viewport contract is invalid.'
+ }
+ $point = ConvertTo-ExactInputPoint $Request.point $viewportWidth $viewportHeight `
+ 'Named-playlist double-click point'
+ [LegacyPackageInputNative]::SendApplicationDoubleClick(
+ $Application.MainWindowHandle, $Application.Id, 30001,
+ $point.X, $point.Y, $viewportWidth, $viewportHeight,
+ $devicePixelRatio, 100)
+}
+
function Invoke-ExactWindowsInputRequest(
[string]$RequestPath,
[string]$AcknowledgementPath,
+ [int]$ExpectedExchangeIndex,
[Diagnostics.Process]$Application,
[string]$ExpectedApplicationPath) {
$script:WindowsInputRequestCalls++
- if ($script:WindowsInputRequestCalls -ne 1 -or [IO.File]::Exists($AcknowledgementPath)) {
+ if ($script:WindowsInputRequestCalls -ne $ExpectedExchangeIndex -or
+ [IO.File]::Exists($AcknowledgementPath)) {
Throw-SafeFailure 'The Windows input one-shot budget or acknowledgement precondition changed.'
}
@@ -1421,122 +2083,74 @@ function Invoke-ExactWindowsInputRequest(
'Windows input request' `
([datetime]::UtcNow.AddSeconds(3))
$request = $requestEvidence.Value
- Assert-ExactJsonPropertyNames $request @(
- 'schemaVersion', 'token', 'targetUrl', 'operation', 'sourceRowId',
- 'targetRowId', 'position', 'source', 'target', 'viewport',
- 'devicePixelRatio', 'beforeOrder', 'afterOrder', 'expectedSelectedRowId',
- 'expectedActiveRowIdAfterHome', 'expectedFocusedRowId', 'mouseDownCalls',
- 'mouseUpCalls', 'homeKeyCalls') 'Windows input request'
- Assert-ExactJsonPropertyNames $request.source @('x', 'y') 'Windows input source'
- Assert-ExactJsonPropertyNames $request.target @('x', 'y') 'Windows input target'
- Assert-ExactJsonPropertyNames $request.viewport @('width', 'height') 'Windows input viewport'
-
- $sourceRowId = [string]$request.sourceRowId
- $targetRowId = [string]$request.targetRowId
$token = [string]$request.token
$schemaVersion = ConvertTo-FiniteNumber $request.schemaVersion 'Windows input schema version'
- $mouseDownCalls = ConvertTo-FiniteNumber $request.mouseDownCalls 'Windows input mouse-down count'
- $mouseUpCalls = ConvertTo-FiniteNumber $request.mouseUpCalls 'Windows input mouse-up count'
- $homeKeyCalls = ConvertTo-FiniteNumber $request.homeKeyCalls 'Windows input Home-key count'
if ($request.token -isnot [string] -or
$request.targetUrl -isnot [string] -or
$request.operation -isnot [string] -or
- $request.sourceRowId -isnot [string] -or
- $request.targetRowId -isnot [string] -or
- $request.position -isnot [string] -or
- $request.expectedSelectedRowId -isnot [string] -or
- $request.expectedActiveRowIdAfterHome -isnot [string] -or
- $request.expectedFocusedRowId -isnot [string] -or
$schemaVersion -ne 1 -or
+ (ConvertTo-FiniteNumber $request.exchangeIndex 'Windows input exchange index') -ne
+ $ExpectedExchangeIndex -or
$token -cnotmatch '\A[0-9A-F]{32}\z' -or
[string]$request.targetUrl -cne 'https://legacy-parity.mbn.local/index.html' -or
- [string]$request.operation -cne 'row-header-drag-after-then-home' -or
- [string]$request.position -cne 'after' -or
- $sourceRowId -cnotmatch '\Alegacy-stock-[0-9]{8}\z' -or
- $targetRowId -cnotmatch '\Alegacy-stock-[0-9]{8}\z' -or
- $sourceRowId -ceq $targetRowId -or
- [string]$request.expectedSelectedRowId -cne $sourceRowId -or
- [string]$request.expectedFocusedRowId -cne $sourceRowId -or
- $mouseDownCalls -ne 1 -or
- $mouseUpCalls -ne 1 -or
- $homeKeyCalls -ne 1) {
- Throw-SafeFailure 'The Windows input request does not match the approved one-shot operation.'
+ (ConvertTo-FiniteNumber $request.inputRetryCount 'Windows input retry count') -ne 0) {
+ Throw-SafeFailure 'The Windows input request does not match the common closed contract.'
}
-
- $beforeOrder = @($request.beforeOrder | ForEach-Object { [string]$_ })
- $afterOrder = @($request.afterOrder | ForEach-Object { [string]$_ })
- if ($beforeOrder.Count -ne 3 -or $afterOrder.Count -ne 3 -or
- (@($beforeOrder | Select-Object -Unique)).Count -ne 3 -or
- (@($afterOrder | Select-Object -Unique)).Count -ne 3 -or
- $beforeOrder -cnotcontains $sourceRowId -or
- $beforeOrder -cnotcontains $targetRowId) {
- Throw-SafeFailure 'The Windows input request row order is not a unique three-row fixture.'
- }
- foreach ($rowId in $beforeOrder + $afterOrder) {
- if ($rowId -cnotmatch '\Alegacy-stock-[0-9]{8}\z') {
- Throw-SafeFailure 'The Windows input request contains an invalid row identity.'
- }
- }
-
- $computed = New-Object 'Collections.Generic.List[string]'
- foreach ($rowId in $beforeOrder) {
- if ($rowId -cne $sourceRowId) {
- [void]$computed.Add($rowId)
- }
- }
- $targetIndex = $computed.IndexOf($targetRowId)
- if ($targetIndex -lt 0) {
- Throw-SafeFailure 'The Windows input target is absent after removing the source row.'
- }
- $computed.Insert($targetIndex + 1, $sourceRowId)
- for ($index = 0; $index -lt $computed.Count; $index++) {
- if ($computed[$index] -cne $afterOrder[$index]) {
- Throw-SafeFailure 'The Windows input request is not the exact single-row move-after transform.'
- }
- }
- if ([string]$request.expectedActiveRowIdAfterHome -cne $afterOrder[0]) {
- Throw-SafeFailure 'The Windows input Home boundary expectation is not the first resulting row.'
- }
-
- $viewportWidth = ConvertTo-FiniteNumber $request.viewport.width 'Windows input viewport width'
- $viewportHeight = ConvertTo-FiniteNumber $request.viewport.height 'Windows input viewport height'
- $sourceX = ConvertTo-FiniteNumber $request.source.x 'Windows input source X'
- $sourceY = ConvertTo-FiniteNumber $request.source.y 'Windows input source Y'
- $targetX = ConvertTo-FiniteNumber $request.target.x 'Windows input target X'
- $targetY = ConvertTo-FiniteNumber $request.target.y 'Windows input target Y'
- $devicePixelRatio = ConvertTo-FiniteNumber $request.devicePixelRatio 'Windows input device pixel ratio'
- if ($viewportWidth -lt 1 -or $viewportWidth -gt 16384 -or
- $viewportHeight -lt 1 -or $viewportHeight -gt 16384 -or
- $sourceX -lt 0 -or $sourceX -ge $viewportWidth -or
- $sourceY -lt 0 -or $sourceY -ge $viewportHeight -or
- $targetX -lt 0 -or $targetX -ge $viewportWidth -or
- $targetY -lt 0 -or $targetY -ge $viewportHeight -or
- $devicePixelRatio -lt 0.25 -or $devicePixelRatio -gt 5) {
- Throw-SafeFailure 'The Windows input geometry is outside the exact package viewport contract.'
- }
-
+ $inputCounts = [ordered]@{}
+ $operation = [string]$request.operation
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
Assert-NoTornadoConnection $Application.Id
- [LegacyPackageInputNative]::SendRowHeaderDragAfterThenHome(
- $Application.MainWindowHandle,
- $Application.Id,
- 30001,
- $sourceX,
- $sourceY,
- $targetX,
- $targetY,
- $viewportWidth,
- $viewportHeight,
- $devicePixelRatio)
+ switch -CaseSensitive ($operation) {
+ 'row-header-drag-home-shift-range-delete-restore' {
+ Invoke-ExactPlaylistWindowsInputRequest `
+ $request `
+ $Application
+ $inputCounts = [ordered]@{
+ mouseDownCalls = 4
+ mouseUpCalls = 4
+ homeKeyCalls = 1
+ shiftKeyDownCalls = 1
+ shiftKeyUpCalls = 1
+ deleteKeyCalls = 1
+ }
+ break
+ }
+ 'named-playlist-load-double-click' {
+ if ($ExpectedExchangeIndex -ne 2) {
+ Throw-SafeFailure 'The named-playlist double-click is not the second exchange.'
+ }
+ Invoke-ExactNamedPlaylistDoubleClickRequest `
+ $request `
+ $Application
+ $inputCounts = [ordered]@{
+ mouseDownCalls = 2
+ mouseUpCalls = 2
+ doubleClickIntervalMilliseconds = 100
+ }
+ break
+ }
+ default {
+ Throw-SafeFailure 'The Windows input operation is outside the closed allowlist.'
+ }
+ }
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
Assert-NoTornadoConnection $Application.Id
$script:WindowsInputAcknowledgementCalls++
- if ($script:WindowsInputAcknowledgementCalls -ne 1) {
+ if ($script:WindowsInputAcknowledgementCalls -ne $ExpectedExchangeIndex) {
Throw-SafeFailure 'The Windows input acknowledgement budget changed.'
}
- $acknowledgementEvidence = Write-WindowsInputAcknowledgement $AcknowledgementPath $token
+ $acknowledgementEvidence = Write-WindowsInputAcknowledgement `
+ $AcknowledgementPath `
+ $ExpectedExchangeIndex `
+ $token `
+ $operation `
+ $inputCounts
return [pscustomobject]@{
+ ExchangeIndex = $ExpectedExchangeIndex
+ Operation = $operation
+ RequestPath = $RequestPath
+ AcknowledgementPath = $AcknowledgementPath
RequestSha256 = $requestEvidence.Sha256
AcknowledgementSha256 = $acknowledgementEvidence.Sha256
}
@@ -1552,10 +2166,11 @@ function Invoke-NodeHarnessUnderTcpWatch(
[string]$WindowsInputAcknowledgementPath,
[string]$HarnessProfile,
[string]$HarnessScope,
+ [string]$RecoverNamedPlaylistTitle,
[int]$TimeoutSeconds,
[Diagnostics.Process]$Application,
[string]$ExpectedApplicationPath) {
- $arguments = @(
+ $rawArguments = @(
$ScriptPath,
'--port',
[string]$DebugPort,
@@ -1573,7 +2188,14 @@ function Invoke-NodeHarnessUnderTcpWatch(
$HarnessScope,
'--timeout-ms',
[string]($TimeoutSeconds * 1000)
- ) | ForEach-Object { ConvertTo-QuotedProcessArgument ([string]$_) }
+ )
+ if (-not [string]::IsNullOrEmpty($RecoverNamedPlaylistTitle)) {
+ $rawArguments += @(
+ '--recover-named-playlist-title',
+ $RecoverNamedPlaylistTitle)
+ }
+ $arguments = $rawArguments |
+ ForEach-Object { ConvertTo-QuotedProcessArgument ([string]$_) }
$startInfo = New-Object Diagnostics.ProcessStartInfo
$startInfo.FileName = $Executable
@@ -1594,8 +2216,11 @@ function Invoke-NodeHarnessUnderTcpWatch(
$deadline = [datetime]::UtcNow.AddSeconds($TimeoutSeconds + 30)
$monitoringFailure = $null
- $windowsInputHandled = $false
- $windowsInputEvidence = $null
+ $expectedWindowsInputExchangeCount = Get-ExpectedWindowsInputExchangeCount `
+ $HarnessProfile `
+ $HarnessScope
+ $nextWindowsInputExchangeIndex = 1
+ $windowsInputEvidence = New-Object 'Collections.Generic.List[object]'
try {
while (-not $nodeProcess.HasExited) {
if ([datetime]::UtcNow -ge $deadline) {
@@ -1603,24 +2228,46 @@ function Invoke-NodeHarnessUnderTcpWatch(
}
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
Assert-NoTornadoConnection $Application.Id
- if ([IO.File]::Exists($WindowsInputRequestPath) -and -not $windowsInputHandled) {
- $windowsInputEvidence = Invoke-ExactWindowsInputRequest `
+ if ($nextWindowsInputExchangeIndex -le $expectedWindowsInputExchangeCount) {
+ $nextRequestPath = Get-WindowsInputExchangePath `
$WindowsInputRequestPath `
+ $nextWindowsInputExchangeIndex
+ $nextAcknowledgementPath = Get-WindowsInputExchangePath `
$WindowsInputAcknowledgementPath `
+ $nextWindowsInputExchangeIndex
+ }
+ if ($nextWindowsInputExchangeIndex -le $expectedWindowsInputExchangeCount -and
+ [IO.File]::Exists($nextRequestPath)) {
+ $exchangeEvidence = Invoke-ExactWindowsInputRequest `
+ $nextRequestPath `
+ $nextAcknowledgementPath `
+ $nextWindowsInputExchangeIndex `
$Application `
$ExpectedApplicationPath
- $windowsInputHandled = $true
+ [void]$windowsInputEvidence.Add($exchangeEvidence)
+ $nextWindowsInputExchangeIndex++
}
Start-Sleep -Milliseconds 100
$nodeProcess.Refresh()
}
- if (-not $windowsInputHandled -or
- $script:WindowsInputRequestCalls -ne 1 -or
- $script:WindowsInputAcknowledgementCalls -ne 1 -or
- -not [IO.File]::Exists($WindowsInputRequestPath) -or
- -not [IO.File]::Exists($WindowsInputAcknowledgementPath)) {
- Throw-SafeFailure 'The required one-shot Windows input request was not completed.'
+ if ($nextWindowsInputExchangeIndex -ne ($expectedWindowsInputExchangeCount + 1) -or
+ $windowsInputEvidence.Count -ne $expectedWindowsInputExchangeCount -or
+ $script:WindowsInputRequestCalls -ne $expectedWindowsInputExchangeCount -or
+ $script:WindowsInputAcknowledgementCalls -ne $expectedWindowsInputExchangeCount) {
+ Throw-SafeFailure 'The required ordered one-shot Windows input exchanges were not completed.'
+ }
+ for ($exchangeIndex = 1;
+ $exchangeIndex -le $expectedWindowsInputExchangeCount;
+ $exchangeIndex++) {
+ if (-not [IO.File]::Exists((Get-WindowsInputExchangePath `
+ $WindowsInputRequestPath `
+ $exchangeIndex)) -or
+ -not [IO.File]::Exists((Get-WindowsInputExchangePath `
+ $WindowsInputAcknowledgementPath `
+ $exchangeIndex))) {
+ Throw-SafeFailure 'A required Windows input exchange file is missing.'
+ }
}
Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
Assert-NoTornadoConnection $Application.Id
@@ -1665,7 +2312,10 @@ function Invoke-NodeHarnessUnderTcpWatch(
return [pscustomobject]@{
ExitCode = $nodeProcess.ExitCode
- WindowsInput = $windowsInputEvidence
+ # Windows PowerShell 5.1 throws "Argument types do not match" when an
+ # array subexpression directly enumerates List[object]. Materialize the
+ # completed one-shot evidence before returning it to the sealing phase.
+ WindowsInput = @($windowsInputEvidence.ToArray())
}
}
@@ -1762,7 +2412,19 @@ function Get-StartedApplication(
$startUtc = $process.StartTime.ToUniversalTime()
}
catch {
- Throw-SafeFailure 'The launched application identity could not be inspected.'
+ # A packaged full-trust process can appear in the process table a
+ # few milliseconds before Path/StartTime become queryable. It has
+ # not received any test input yet, so keep the launch gate closed
+ # and retry only while it owns no Tornado connection.
+ Assert-NoTornadoConnection $process.Id
+ Start-Sleep -Milliseconds 50
+ continue
+ }
+
+ if ([string]::IsNullOrWhiteSpace([string]$path)) {
+ Assert-NoTornadoConnection $process.Id
+ Start-Sleep -Milliseconds 50
+ continue
}
if (-not (Test-PathEqual $path $ExpectedPath) -or
@@ -1978,6 +2640,12 @@ function Assert-SingleInstanceActivation(
$candidate = $additional[0]
try {
+ # The AppLifecycle redirector can exit within a few milliseconds.
+ # Pin and verify its package handle before reading slower Process
+ # properties so a legitimate fast redirect is not misclassified as
+ # an unknown process after it has already terminated.
+ $candidatePackageFullName =
+ [LegacyPackageInputNative]::ReadPackageFullName($candidate.Handle)
$candidate.Refresh()
$candidateId = $candidate.Id
$candidateStartUtc = $candidate.StartTime.ToUniversalTime()
@@ -1990,12 +2658,12 @@ function Assert-SingleInstanceActivation(
}
if ($candidateStartUtc -lt $activationRequestedUtc -or
+ [string]$candidatePackageFullName -cne $PackageFullName -or
-not (Test-PathEqual $candidatePath $ExpectedPath) -or
$candidateHandle -ne [IntPtr]::Zero -or
-not [string]::IsNullOrEmpty($candidateTitle)) {
Throw-SafeFailure 'The transient activation redirector identity is not exact.'
}
- Assert-ExactProcessPackageIdentity $candidate
Assert-NoTornadoConnection $candidateId
if ($null -eq $redirectorId) {
@@ -2224,6 +2892,7 @@ function Read-AndAssertHarnessEvidence(
[string]$WindowsInputAcknowledgementPath,
[string]$HarnessProfile,
[string]$HarnessScope,
+ [string]$ExpectedRecoveryTitle,
[int]$ExpectedPort,
[int]$ExpectedTimeoutSeconds) {
try {
@@ -2243,6 +2912,58 @@ function Read-AndAssertHarnessEvidence(
else {
'all'
}
+ $expectedRecoveryRequested = -not [string]::IsNullOrEmpty(
+ $ExpectedRecoveryTitle)
+ $recoveryEvidence = if ($null -ne $evidence.fullUiDb) {
+ $evidence.fullUiDb.namedPlaylistRecovery
+ }
+ else {
+ $null
+ }
+ $namedPlaylistDeleteWriteCount = @(
+ $evidence.safety.approvedDatabaseWriteMessages |
+ Where-Object {
+ [string]$_.type -ceq 'delete-selected-named-playlist'
+ }).Count
+ $sealedRecoveryMatchCount = if ($expectedRecoveryRequested) {
+ @($evidence.finalSnapshot.state.namedPlaylist.definitions |
+ Where-Object {
+ [string]$_.title -ceq $ExpectedRecoveryTitle
+ }).Count
+ }
+ else {
+ 0
+ }
+ $namedPlaylistRecoveryContractOk = if (-not $expectedRecoveryRequested) {
+ $null -eq $recoveryEvidence
+ }
+ elseif ($null -eq $recoveryEvidence -or
+ [string]$recoveryEvidence.targetTitle -cne $ExpectedRecoveryTitle -or
+ ([string]$recoveryEvidence.targetTitle).Length -ne 26 -or
+ [string]$recoveryEvidence.targetTitle -cnotmatch
+ '^CDX_P_[0-9]{13}_[0-9a-f]{6}$' -or
+ $recoveryEvidence.freshListVerified -ne $true -or
+ $recoveryEvidence.exactAbsenceVerified -ne $true -or
+ $sealedRecoveryMatchCount -ne 0) {
+ $false
+ }
+ elseif ([string]$recoveryEvidence.result -ceq 'alreadyAbsent') {
+ [int]$recoveryEvidence.exactMatchCount -eq 0 -and
+ [int]$recoveryEvidence.writeCount -eq 0 -and
+ [string]::IsNullOrEmpty(
+ [string]$recoveryEvidence.mutationOutcome) -and
+ $namedPlaylistDeleteWriteCount -eq 1
+ }
+ elseif ([string]$recoveryEvidence.result -ceq 'deleted') {
+ [int]$recoveryEvidence.exactMatchCount -eq 1 -and
+ [int]$recoveryEvidence.writeCount -eq 1 -and
+ @('committedFresh', 'committedOptimistic') -ccontains
+ [string]$recoveryEvidence.mutationOutcome -and
+ $namedPlaylistDeleteWriteCount -eq 2
+ }
+ else {
+ $false
+ }
$databaseWriteContractOk = if ($HarnessProfile -ceq 'FullUiDb') {
$evidence.safety.databaseWriteIntentIssued -eq $true -and
@($evidence.safety.approvedDatabaseWriteMessages).Count -ge 3 -and
@@ -2263,13 +2984,56 @@ function Read-AndAssertHarnessEvidence(
[string]$evidence.dryRunPlayout.nextEntryId) -and
-not [string]::IsNullOrWhiteSpace(
[string]$evidence.dryRunPlayout.stagedEntryId) -and
+ -not [string]::IsNullOrWhiteSpace(
+ [string]$evidence.dryRunPlayout.skippedEntryId) -and
+ [string]$evidence.dryRunPlayout.stagedEntryId -ceq
+ [string]$evidence.dryRunPlayout.skippedEntryId -and
[string]$evidence.dryRunPlayout.selectedEntryId -cne
[string]$evidence.dryRunPlayout.nextEntryId -and
[string]$evidence.dryRunPlayout.stagedEntryId -cne
[string]$evidence.dryRunPlayout.selectedEntryId -and
- [int]$evidence.dryRunPlayout.playlistCountBeforeSelection -eq
- [int]$evidence.dryRunPlayout.playlistCountAfterDoubleClick -and
- [int]$evidence.dryRunPlayout.programCutSelection -ge 0
+ [string]$evidence.dryRunPlayout.skippedEntryId -cne
+ [string]$evidence.dryRunPlayout.nextEntryId -and
+ -not [string]::IsNullOrWhiteSpace(
+ [string]$evidence.dryRunPlayout.tailAppendedEntryId) -and
+ [string]$evidence.dryRunPlayout.tailAppendedEntryId -cne
+ [string]$evidence.dryRunPlayout.selectedEntryId -and
+ [string]$evidence.dryRunPlayout.tailAppendedEntryId -cne
+ [string]$evidence.dryRunPlayout.nextEntryId -and
+ [int]$evidence.dryRunPlayout.playlistCountAfterDoubleClick -eq
+ ([int]$evidence.dryRunPlayout.playlistCountBeforeSelection + 1) -and
+ [int]$evidence.dryRunPlayout.programCutSelection -ge 0 -and
+ [string]$evidence.dryRunPlayout.programEnableParity.currentEntryId -ceq
+ [string]$evidence.dryRunPlayout.selectedEntryId -and
+ [string]$evidence.dryRunPlayout.programEnableParity.skippedEntryId -ceq
+ [string]$evidence.dryRunPlayout.skippedEntryId -and
+ [string]$evidence.dryRunPlayout.programEnableParity.nextEntryId -ceq
+ [string]$evidence.dryRunPlayout.nextEntryId -and
+ [int]$evidence.dryRunPlayout.programEnableParity.futureRowCount -ge 3 -and
+ $evidence.dryRunPlayout.programEnableParity.mouseCheckboxDisabled -eq $true -and
+ [int]$evidence.dryRunPlayout.programEnableParity.setAllIntentCount -eq 2 -and
+ $evidence.dryRunPlayout.programEnableParity.setAllAppliesEveryRow -eq $true -and
+ [int]$evidence.dryRunPlayout.programEnableParity.toggleActiveIntentCount -eq 1 -and
+ $evidence.dryRunPlayout.programEnableParity.endOfPlaylistObserved -eq $true -and
+ $evidence.dryRunPlayout.programEnableParity.playlistNextRestored -eq $true -and
+ [int]$evidence.dryRunPlayout.shortcutParity.f8IntentCount -eq 1 -and
+ [int]$evidence.dryRunPlayout.shortcutParity.escapeIntentCount -eq 1 -and
+ [string]$evidence.dryRunPlayout.shortcutParity.finalPhase -ceq 'idle' -and
+ @($evidence.safety.observedOutboundMessageTypes | Where-Object {
+ [string]$_ -ceq 'take-in'
+ }).Count -eq 1 -and
+ @($evidence.safety.observedOutboundMessageTypes | Where-Object {
+ [string]$_ -ceq 'next-playout'
+ }).Count -eq 1 -and
+ @($evidence.safety.observedOutboundMessageTypes | Where-Object {
+ [string]$_ -ceq 'take-out'
+ }).Count -eq 1 -and
+ @($evidence.safety.observedOutboundMessageTypes | Where-Object {
+ [string]$_ -ceq 'set-all-playlist-enabled'
+ }).Count -eq 2 -and
+ @($evidence.safety.observedOutboundMessageTypes | Where-Object {
+ [string]$_ -ceq 'toggle-active-playlist-enabled'
+ }).Count -eq 1
}
else {
$evidence.safety.playoutIntentIssued -eq $false -and
@@ -2292,6 +3056,7 @@ function Read-AndAssertHarnessEvidence(
$evidence.safety.appCloseRequested -ne $false -or
-not $playoutIntentContractOk -or
-not $databaseWriteContractOk -or
+ -not $namedPlaylistRecoveryContractOk -or
$evidence.safety.externalCancellationRequested -ne $false -or
@($evidence.safety.blockedOutboundMessages).Count -ne 0 -or
@($evidence.safety.stateViolations).Count -ne 0 -or
@@ -2308,9 +3073,13 @@ function Read-AndAssertHarnessEvidence(
'hover-swap-tabs',
'activate-playlist-row',
'select-playlist-row', 'reorder-playlist-rows',
- 'select-playlist-boundary', 'refresh-named-playlists')
+ 'select-playlist-boundary', 'delete-selected-playlist-rows',
+ 'refresh-named-playlists')
if ($HarnessProfile -ceq 'DryRunPlayout') {
- $allowedOutbound += @('take-in', 'next-playout', 'take-out')
+ $allowedOutbound += @(
+ 'take-in', 'next-playout', 'take-out',
+ 'set-all-playlist-enabled',
+ 'toggle-active-playlist-enabled')
}
foreach ($messageType in @($evidence.safety.observedOutboundMessageTypes)) {
if ($allowedOutbound -cnotcontains [string]$messageType) {
@@ -2339,31 +3108,91 @@ function Read-AndAssertHarnessEvidence(
Throw-SafeFailure 'The screenshot evidence does not match its sealed file.'
}
- $requestEvidence = Read-SmallJsonEvidenceFile `
- $WindowsInputRequestPath `
- 'Final Windows input request'
- $acknowledgementEvidence = Read-SmallJsonEvidenceFile `
- $WindowsInputAcknowledgementPath `
- 'Final Windows input acknowledgement'
- $request = $requestEvidence.Value
+ $expectedExchangeCount = Get-ExpectedWindowsInputExchangeCount `
+ $HarnessProfile `
+ $HarnessScope
+ $sealedExchanges = @($evidence.windowsInput.exchanges)
if ([bool]$evidence.windowsInput.required -ne $true -or
[string]$evidence.windowsInput.result -cne 'PASS' -or
+ [int]$evidence.windowsInput.expectedExchangeCount -ne $expectedExchangeCount -or
+ [int]$evidence.windowsInput.completedExchangeCount -ne $expectedExchangeCount -or
+ $sealedExchanges.Count -ne $expectedExchangeCount -or
-not (Test-PathEqual ([string]$evidence.windowsInput.requestPath) `
$WindowsInputRequestPath) -or
-not (Test-PathEqual ([string]$evidence.windowsInput.acknowledgementPath) `
- $WindowsInputAcknowledgementPath) -or
- [string]$evidence.windowsInput.requestSha256 -cne $requestEvidence.Sha256 -or
- [string]$evidence.windowsInput.acknowledgementSha256 -cne `
- $acknowledgementEvidence.Sha256 -or
- -not (Test-ExactStringSequence `
- @($evidence.windowsInput.beforeOrder) `
- @($request.beforeOrder)) -or
- -not (Test-ExactStringSequence `
- @($evidence.windowsInput.afterOrder) `
- @($request.afterOrder)) -or
- [string]$evidence.windowsInput.activeRowIdAfterHome -cne `
- [string]$request.expectedActiveRowIdAfterHome) {
- Throw-SafeFailure 'The sealed Windows input evidence does not match its one-shot request.'
+ $WindowsInputAcknowledgementPath)) {
+ Throw-SafeFailure 'The sealed Windows input exchange count/path contract is not exact.'
+ }
+ $request = $null
+ for ($exchangeIndex = 1;
+ $exchangeIndex -le $expectedExchangeCount;
+ $exchangeIndex++) {
+ $requestPath = Get-WindowsInputExchangePath `
+ $WindowsInputRequestPath `
+ $exchangeIndex
+ $acknowledgementPath = Get-WindowsInputExchangePath `
+ $WindowsInputAcknowledgementPath `
+ $exchangeIndex
+ $requestEvidence = Read-SmallJsonEvidenceFile `
+ $requestPath `
+ "Final Windows input request $exchangeIndex"
+ $acknowledgementEvidence = Read-SmallJsonEvidenceFile `
+ $acknowledgementPath `
+ "Final Windows input acknowledgement $exchangeIndex"
+ $sealedExchange = $sealedExchanges[$exchangeIndex - 1]
+ if ([int]$sealedExchange.exchangeIndex -ne $exchangeIndex -or
+ [string]$sealedExchange.operation -cne
+ [string]$requestEvidence.Value.operation -or
+ [string]$sealedExchange.result -cne 'PASS' -or
+ -not (Test-PathEqual ([string]$sealedExchange.requestPath) $requestPath) -or
+ -not (Test-PathEqual `
+ ([string]$sealedExchange.acknowledgementPath) `
+ $acknowledgementPath) -or
+ [string]$sealedExchange.requestSha256 -cne $requestEvidence.Sha256 -or
+ [string]$sealedExchange.acknowledgementSha256 -cne
+ $acknowledgementEvidence.Sha256) {
+ Throw-SafeFailure 'A sealed Windows input exchange does not match its files.'
+ }
+ if ($exchangeIndex -eq 1) {
+ $request = $requestEvidence.Value
+ if ([string]$request.operation -cne
+ 'row-header-drag-home-shift-range-delete-restore' -or
+ [string]$evidence.windowsInput.requestSha256 -cne
+ $requestEvidence.Sha256 -or
+ [string]$evidence.windowsInput.acknowledgementSha256 -cne
+ $acknowledgementEvidence.Sha256 -or
+ -not (Test-ExactStringSequence `
+ @($evidence.windowsInput.beforeOrder) `
+ @($request.beforeOrder)) -or
+ -not (Test-ExactStringSequence `
+ @($evidence.windowsInput.afterOrder) `
+ @($request.afterOrder)) -or
+ -not (Test-ExactStringSequence `
+ @($evidence.windowsInput.shiftRangeDeletedRowIds) `
+ @($request.expectedRangeSelectedRowIds)) -or
+ [string]$evidence.windowsInput.activeRowIdAfterHome -cne
+ [string]$request.expectedActiveRowIdAfterHome -or
+ [string]$evidence.windowsInput.finalActiveRowId -cne
+ [string]$request.expectedFinalActiveRowId) {
+ Throw-SafeFailure 'The sealed playlist Windows input evidence is not exact.'
+ }
+ }
+ elseif ([string]$requestEvidence.Value.operation -cne
+ 'named-playlist-load-double-click' -or
+ [string]$requestEvidence.Value.definitionTitle -cne
+ [string]$evidence.fixture.namedPlaylistTitle -or
+ @($requestEvidence.Value.expectedPlaylistContent).Count -ne 3 -or
+ @($evidence.fullUiDb.databaseChecks | Where-Object {
+ [string]$_.name -ceq
+ 'PList physical double-click fresh readback' -and
+ $_.details.contentRestored -eq $true -and
+ $_.details.startedUnselected -eq $true -and
+ [int]$_.details.selectionIntentCount -eq 1 -and
+ [int]$_.details.loadIntentCount -eq 1 -and
+ [string]$_.details.input -ceq 'Windows SendInput double-click'
+ }).Count -ne 1) {
+ Throw-SafeFailure 'The sealed PList physical double-click evidence is not exact.'
+ }
}
$snapshot = $evidence.finalSnapshot
@@ -2400,6 +3229,14 @@ function Read-AndAssertHarnessEvidence(
}
$expectedAfterOrder = @($request.afterOrder | ForEach-Object { [string]$_ })
+ $expectedFinalOrder = @($expectedAfterOrder)
+ if ($HarnessProfile -ceq 'DryRunPlayout') {
+ # DryRun deliberately proves that a PROGRAM cut double-click appends one
+ # safe tail row. Preserve the sealed Windows drag order as the prefix and
+ # require that exact new opaque row id at the tail after TAKE OUT.
+ $expectedFinalOrder += @(
+ [string]$evidence.dryRunPlayout.tailAppendedEntryId)
+ }
$statePlaylist = @($state.playlist)
$domPlaylist = @($dom.playlist)
$statePlaylistIds = @($statePlaylist | ForEach-Object { [string]$_.rowId })
@@ -2417,16 +3254,16 @@ function Read-AndAssertHarnessEvidence(
}).Count
if (($HarnessProfile -ceq 'ReadOnly' -or
$HarnessProfile -ceq 'DryRunPlayout') -and (
- -not (Test-ExactStringSequence $statePlaylistIds $expectedAfterOrder) -or
- -not (Test-ExactStringSequence $domPlaylistIds $expectedAfterOrder) -or
+ -not (Test-ExactStringSequence $statePlaylistIds $expectedFinalOrder) -or
+ -not (Test-ExactStringSequence $domPlaylistIds $expectedFinalOrder) -or
-not (Test-ExactStringSequence $selectedStateIds @([string]$request.sourceRowId)) -or
-not (Test-ExactStringSequence $selectedDomIds @([string]$request.sourceRowId)) -or
-not (Test-ExactStringSequence `
$activeStateIds `
- @([string]$request.expectedActiveRowIdAfterHome)) -or
+ @([string]$request.expectedFinalActiveRowId)) -or
-not (Test-ExactStringSequence `
$activeDomIds `
- @([string]$request.expectedActiveRowIdAfterHome)) -or
+ @([string]$request.expectedFinalActiveRowId)) -or
$dragVisualCount -ne 0 -or
@($dom.playlistPointerCapture.rowIds).Count -ne 0)) {
Throw-SafeFailure 'The sealed final playlist state does not match the Windows input result.'
@@ -2452,6 +3289,22 @@ function Read-AndAssertHarnessEvidence(
}
}
+if ($DefinitionsOnly) {
+ return
+}
+
+$recoveryRequested = -not [string]::IsNullOrEmpty($RecoverNamedPlaylistTitle)
+if ($recoveryRequested -and
+ ($RecoverNamedPlaylistTitle.Length -ne 26 -or
+ $RecoverNamedPlaylistTitle -cnotmatch '^CDX_P_[0-9]{13}_[0-9a-f]{6}$')) {
+ Throw-SafeFailure 'RecoverNamedPlaylistTitle must be one exact generated CDX_P fixture title.'
+}
+if ($recoveryRequested -and
+ ($Profile -cne 'FullUiDb' -or
+ ($FullUiDbScope -cne 'PList' -and $FullUiDbScope -cne 'All'))) {
+ Throw-SafeFailure 'Named-playlist recovery is allowed only for FullUiDb PList or All.'
+}
+
$timestamp = [datetime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ')
$Output = Resolve-EvidencePath $Output "legacy-package-input-$timestamp.json"
$Screenshot = Resolve-EvidencePath $Screenshot "legacy-package-input-$timestamp.png"
@@ -2461,6 +3314,16 @@ $WindowsInputRequest = [IO.Path]::ChangeExtension(
$WindowsInputAcknowledgement = [IO.Path]::ChangeExtension(
$Output,
'.windows-input-ack.json')
+$ExpectedWindowsInputExchangeCount = Get-ExpectedWindowsInputExchangeCount `
+ $Profile `
+ $FullUiDbScope
+$WindowsInputRequestPaths = @(1..$ExpectedWindowsInputExchangeCount | ForEach-Object {
+ Get-WindowsInputExchangePath $WindowsInputRequest $_
+ })
+$WindowsInputAcknowledgementPaths = @(1..$ExpectedWindowsInputExchangeCount |
+ ForEach-Object {
+ Get-WindowsInputExchangePath $WindowsInputAcknowledgement $_
+ })
$NodePath = Resolve-NodeExecutable $NodePath
try {
@@ -2473,11 +3336,8 @@ try {
[IO.Path]::GetExtension($Screenshot), '.png', [StringComparison]::OrdinalIgnoreCase)) {
Throw-SafeFailure 'Output must be .json and screenshot must be .png.'
}
- $evidencePaths = @(
- $Output,
- $Screenshot,
- $WindowsInputRequest,
- $WindowsInputAcknowledgement)
+ $evidencePaths = @($Output, $Screenshot) +
+ $WindowsInputRequestPaths + $WindowsInputAcknowledgementPaths
for ($leftIndex = 0; $leftIndex -lt $evidencePaths.Count; $leftIndex++) {
for ($rightIndex = $leftIndex + 1; $rightIndex -lt $evidencePaths.Count; $rightIndex++) {
if (Test-PathEqual $evidencePaths[$leftIndex] $evidencePaths[$rightIndex]) {
@@ -2488,8 +3348,14 @@ try {
Assert-OutputPathAvailable $Output 'Output'
Assert-OutputPathAvailable $Screenshot 'Screenshot'
- Assert-OutputPathAvailable $WindowsInputRequest 'Windows input request'
- Assert-OutputPathAvailable $WindowsInputAcknowledgement 'Windows input acknowledgement'
+ for ($exchangeIndex = 1;
+ $exchangeIndex -le $ExpectedWindowsInputExchangeCount;
+ $exchangeIndex++) {
+ Assert-OutputPathAvailable $WindowsInputRequestPaths[$exchangeIndex - 1] `
+ "Windows input request $exchangeIndex"
+ Assert-OutputPathAvailable $WindowsInputAcknowledgementPaths[$exchangeIndex - 1] `
+ "Windows input acknowledgement $exchangeIndex"
+ }
Assert-LocalConfigurationIsDryRun
Assert-NoUnsafeEnvironment
$registration = Get-ExactDevelopmentPackage
@@ -2524,6 +3390,7 @@ try {
'read-only'
}) `
$(if ($Profile -ceq 'FullUiDb') { $FullUiDbScope.ToLowerInvariant() } else { 'all' }) `
+ $RecoverNamedPlaylistTitle `
$HarnessTimeoutSeconds `
$script:ApplicationProcess `
$registration.Executable
@@ -2532,12 +3399,13 @@ try {
}
if (-not [IO.File]::Exists($Output) -or
-not [IO.File]::Exists($Screenshot) -or
- -not [IO.File]::Exists($WindowsInputRequest) -or
- -not [IO.File]::Exists($WindowsInputAcknowledgement) -or
(Get-Item -LiteralPath $Output).Length -le 0 -or
(Get-Item -LiteralPath $Screenshot).Length -le 0 -or
- (Get-Item -LiteralPath $WindowsInputRequest).Length -le 0 -or
- (Get-Item -LiteralPath $WindowsInputAcknowledgement).Length -le 0) {
+ @($WindowsInputRequestPaths + $WindowsInputAcknowledgementPaths |
+ Where-Object {
+ -not [IO.File]::Exists($_) -or
+ (Get-Item -LiteralPath $_).Length -le 0
+ }).Count -ne 0) {
Throw-SafeFailure 'The real input evidence files are missing or empty.'
}
$harnessEvidence = Read-AndAssertHarnessEvidence `
@@ -2547,14 +3415,29 @@ try {
$WindowsInputAcknowledgement `
$Profile `
$FullUiDbScope `
+ $RecoverNamedPlaylistTitle `
$Port `
$HarnessTimeoutSeconds
- if ($null -eq $harnessRun.WindowsInput -or
- [string]$harnessRun.WindowsInput.RequestSha256 -cne
- [string]$harnessEvidence.windowsInput.requestSha256 -or
- [string]$harnessRun.WindowsInput.AcknowledgementSha256 -cne
- [string]$harnessEvidence.windowsInput.acknowledgementSha256) {
- Throw-SafeFailure 'The wrapper and harness Windows input evidence hashes differ.'
+ $wrapperWindowsInput = @($harnessRun.WindowsInput)
+ $sealedWindowsInput = @($harnessEvidence.windowsInput.exchanges)
+ if ($wrapperWindowsInput.Count -ne $ExpectedWindowsInputExchangeCount -or
+ $sealedWindowsInput.Count -ne $ExpectedWindowsInputExchangeCount) {
+ Throw-SafeFailure 'The wrapper and harness Windows input exchange counts differ.'
+ }
+ for ($exchangeIndex = 1;
+ $exchangeIndex -le $ExpectedWindowsInputExchangeCount;
+ $exchangeIndex++) {
+ $wrapperExchange = $wrapperWindowsInput[$exchangeIndex - 1]
+ $sealedExchange = $sealedWindowsInput[$exchangeIndex - 1]
+ if ([int]$wrapperExchange.ExchangeIndex -ne $exchangeIndex -or
+ [int]$sealedExchange.exchangeIndex -ne $exchangeIndex -or
+ [string]$wrapperExchange.Operation -cne [string]$sealedExchange.operation -or
+ [string]$wrapperExchange.RequestSha256 -cne
+ [string]$sealedExchange.requestSha256 -or
+ [string]$wrapperExchange.AcknowledgementSha256 -cne
+ [string]$sealedExchange.acknowledgementSha256) {
+ Throw-SafeFailure 'The wrapper and harness Windows input exchange hashes differ.'
+ }
}
Assert-ExactApplicationStillRunning $script:ApplicationProcess $registration.Executable
@@ -2590,7 +3473,7 @@ try {
[pscustomobject]@{
result = [string]$harnessEvidence.result
packageFullName = $PackageFullName
- packageMode = 'Development Release x64'
+ packageMode = $PackageModeLabel
playoutMode = 'DryRun'
processId = $script:ApplicationProcess.Id
cdpAddress = '127.0.0.1'
@@ -2600,10 +3483,11 @@ try {
windowsInputRequestCalls = $script:WindowsInputRequestCalls
windowsInputAcknowledgementCalls = $script:WindowsInputAcknowledgementCalls
windowsInputRequest = $WindowsInputRequest
- windowsInputRequestSha256 = [string]$harnessRun.WindowsInput.RequestSha256
+ windowsInputRequestSha256 = [string]$wrapperWindowsInput[0].RequestSha256
windowsInputAcknowledgement = $WindowsInputAcknowledgement
windowsInputAcknowledgementSha256 = `
- [string]$harnessRun.WindowsInput.AcknowledgementSha256
+ [string]$wrapperWindowsInput[0].AcknowledgementSha256
+ windowsInputExchanges = @($wrapperWindowsInput)
singleInstanceActivationCalls = $singleInstance.ActivationCalls
singleInstanceProcessCount = $singleInstance.ProcessCount
singleInstanceRedirectorProcessId = $singleInstance.RedirectorProcessId
diff --git a/scripts/Invoke-LegacyPackageLocalStateSmoke.ps1 b/scripts/Invoke-LegacyPackageLocalStateSmoke.ps1
new file mode 100644
index 0000000..640cbe1
--- /dev/null
+++ b/scripts/Invoke-LegacyPackageLocalStateSmoke.ps1
@@ -0,0 +1,1140 @@
+[CmdletBinding()]
+param(
+ [ValidateRange(1024, 65535)]
+ [int]$Port = 9347,
+
+ [string]$Output,
+
+ [string]$Screenshot,
+
+ [string]$ExchangeDirectory,
+
+ [string]$LegacyDataDirectory,
+
+ [string]$NodePath,
+
+ [ValidateRange(10, 120)]
+ [int]$LaunchTimeoutSeconds = 30,
+
+ [ValidateRange(60, 900)]
+ [int]$HarnessTimeoutSeconds = 240,
+
+ [switch]$StaticAudit
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$requestedPort = $Port
+$requestedOutput = $Output
+$requestedScreenshot = $Screenshot
+$requestedExchangeDirectory = $ExchangeDirectory
+$requestedLegacyDataDirectory = $LegacyDataDirectory
+$requestedNodePath = $NodePath
+$requestedLaunchTimeout = $LaunchTimeoutSeconds
+$requestedHarnessTimeout = $HarnessTimeoutSeconds
+$sharedScript = Join-Path $PSScriptRoot 'Invoke-LegacyPackageInputSmoke.ps1'
+if (-not [IO.File]::Exists($sharedScript)) {
+ throw [InvalidOperationException]::new('The shared package input safety script is missing.')
+}
+
+# Reuse the exact registered-package, latest DebugAppX, DryRun, TCP ownership,
+# process identity and normal-close guards. -DefinitionsOnly performs no launch.
+. $sharedScript `
+ -BuildFlavor DebugAppX `
+ -Profile ReadOnly `
+ -Port $requestedPort `
+ -Output $requestedOutput `
+ -Screenshot $requestedScreenshot `
+ -NodePath $requestedNodePath `
+ -LaunchTimeoutSeconds $requestedLaunchTimeout `
+ -HarnessTimeoutSeconds $requestedHarnessTimeout `
+ -DefinitionsOnly
+
+$Port = $requestedPort
+$Output = $requestedOutput
+$Screenshot = $requestedScreenshot
+$ExchangeDirectory = $requestedExchangeDirectory
+$LegacyDataDirectory = $requestedLegacyDataDirectory
+$NodePath = $requestedNodePath
+$LaunchTimeoutSeconds = $requestedLaunchTimeout
+$HarnessTimeoutSeconds = $requestedHarnessTimeout
+$HarnessPath = Join-Path $PSScriptRoot 'Test-LegacyPackageLocalStateSmoke.mjs'
+
+Add-Type -AssemblyName UIAutomationClient
+Add-Type -AssemblyName UIAutomationTypes
+Add-Type -TypeDefinition @'
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading;
+
+public static class LegacyLocalStateNative
+{
+ private delegate bool EnumWindowsCallback(IntPtr window, IntPtr parameter);
+
+ [DllImport("user32.dll")]
+ private static extern bool EnumWindows(EnumWindowsCallback callback, IntPtr parameter);
+
+ [DllImport("user32.dll")]
+ private static extern IntPtr GetWindow(IntPtr window, uint command);
+
+ [DllImport("user32.dll")]
+ private static extern IntPtr GetAncestor(IntPtr window, uint flags);
+
+ [DllImport("kernel32.dll")]
+ private static extern uint GetCurrentThreadId();
+
+ [DllImport("user32.dll")]
+ private static extern IntPtr GetForegroundWindow();
+
+ [DllImport("user32.dll")]
+ private static extern bool SetForegroundWindow(IntPtr window);
+
+ [DllImport("user32.dll")]
+ private static extern bool BringWindowToTop(IntPtr window);
+
+ [DllImport("user32.dll")]
+ private static extern IntPtr SetActiveWindow(IntPtr window);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern bool AttachThreadInput(uint from, uint to, bool attach);
+
+ [DllImport("user32.dll")]
+ private static extern bool IsWindow(IntPtr window);
+
+ [DllImport("user32.dll")]
+ private static extern bool IsWindowVisible(IntPtr window);
+
+ [DllImport("user32.dll")]
+ private static extern bool IsWindowEnabled(IntPtr window);
+
+ [DllImport("user32.dll")]
+ private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ private static extern int GetClassName(IntPtr window, StringBuilder value, int maximum);
+
+ private const uint GetWindowOwner = 4;
+ private const uint GetAncestorRoot = 2;
+
+ public static bool Exists(IntPtr window)
+ {
+ return window != IntPtr.Zero && IsWindow(window);
+ }
+
+ public static bool Visible(IntPtr window)
+ {
+ return Exists(window) && IsWindowVisible(window);
+ }
+
+ public static bool Enabled(IntPtr window)
+ {
+ return Exists(window) && IsWindowEnabled(window);
+ }
+
+ public static int ProcessId(IntPtr window)
+ {
+ uint processId;
+ return GetWindowThreadProcessId(window, out processId) == 0 ? 0 : checked((int)processId);
+ }
+
+ public static IntPtr Root(IntPtr window)
+ {
+ return window == IntPtr.Zero
+ ? IntPtr.Zero : GetAncestor(window, GetAncestorRoot);
+ }
+
+ public static bool OwnerChainContains(IntPtr window, IntPtr expectedOwner)
+ {
+ if (window == IntPtr.Zero || expectedOwner == IntPtr.Zero) return false;
+ IntPtr expectedRoot = Root(expectedOwner);
+ IntPtr current = window;
+ for (int depth = 0; depth < 12 && current != IntPtr.Zero; depth++)
+ {
+ if (current == expectedOwner || Root(current) == expectedRoot) return true;
+ current = GetWindow(current, GetWindowOwner);
+ }
+ return false;
+ }
+
+ public static string ClassName(IntPtr window)
+ {
+ StringBuilder value = new StringBuilder(512);
+ return window != IntPtr.Zero && GetClassName(window, value, value.Capacity) > 0
+ ? value.ToString() : String.Empty;
+ }
+
+ public static IntPtr[] VisibleTopLevelWindows()
+ {
+ List windows = new List();
+ EnumWindowsCallback callback = delegate(IntPtr window, IntPtr parameter)
+ {
+ if (window != IntPtr.Zero && IsWindowVisible(window))
+ {
+ windows.Add(window);
+ }
+ return true;
+ };
+ if (!EnumWindows(callback, IntPtr.Zero))
+ {
+ throw new InvalidOperationException("The native top-level window list is unavailable.");
+ }
+ return windows.ToArray();
+ }
+
+ public static void AcquireOwnedForeground(IntPtr window, IntPtr expectedOwner)
+ {
+ if (!Visible(window) || !Enabled(window) ||
+ !OwnerChainContains(window, expectedOwner))
+ {
+ throw new InvalidOperationException("The owned picker identity changed before foreground acquisition.");
+ }
+
+ IntPtr foreground = GetForegroundWindow();
+ if (Root(foreground) == window) return;
+
+ uint ignored;
+ uint foregroundThread = foreground == IntPtr.Zero
+ ? 0 : GetWindowThreadProcessId(foreground, out ignored);
+ uint targetThread = GetWindowThreadProcessId(window, out ignored);
+ uint currentThread = GetCurrentThreadId();
+ if (targetThread == 0 || currentThread == 0)
+ {
+ throw new InvalidOperationException("The picker input threads are unavailable.");
+ }
+
+ bool attachedForeground = false;
+ bool attachedTarget = false;
+ try
+ {
+ if (foregroundThread != 0 && foregroundThread != currentThread &&
+ foregroundThread != targetThread)
+ {
+ if (!AttachThreadInput(currentThread, foregroundThread, true))
+ {
+ throw new InvalidOperationException("The foreground input queue could not be attached to the picker verifier.");
+ }
+ attachedForeground = true;
+ }
+ if (targetThread != currentThread)
+ {
+ if (!AttachThreadInput(currentThread, targetThread, true))
+ {
+ throw new InvalidOperationException("The picker input queue could not be attached.");
+ }
+ attachedTarget = true;
+ }
+
+ BringWindowToTop(window);
+ SetActiveWindow(window);
+ SetForegroundWindow(window);
+ }
+ finally
+ {
+ if (attachedTarget) AttachThreadInput(currentThread, targetThread, false);
+ if (attachedForeground) AttachThreadInput(currentThread, foregroundThread, false);
+ }
+
+ DateTime deadline = DateTime.UtcNow.AddSeconds(3);
+ while (DateTime.UtcNow < deadline && Root(GetForegroundWindow()) != window)
+ {
+ if (!Visible(window) || !Enabled(window) ||
+ !OwnerChainContains(window, expectedOwner))
+ {
+ throw new InvalidOperationException("The owned picker identity changed during foreground acquisition.");
+ }
+ Thread.Sleep(10);
+ }
+ if (Root(GetForegroundWindow()) != window)
+ {
+ throw new InvalidOperationException("The exact owned picker could not acquire foreground input.");
+ }
+ }
+}
+'@
+
+$ExpectedSourceSha256 =
+ '1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2'
+$ExpectedInitialDestinationSha256 =
+ '13C07BA64587549EDA9C1A694F3DFA19875CB1F888F02C9E071F17C47739E087'
+$ExpectedInitialPairLine = $Utf8.GetString([Convert]::FromBase64String(
+ 'MV5LUlgxMDDsp4DsiJgs7L2U7Iqk7ZS8IOyngOyImF5eXl5eXl4='))
+$ComparisonFileName = $Utf8.GetString([Convert]::FromBase64String(
+ '7KKF66qp67mE6rWQLmRhdA=='))
+$ExpectedInitialPairIdentity = $Utf8.GetString([Convert]::FromBase64String(
+ 'S1JYMTAw7KeA7IiYfOy9lOyKpO2UvCDsp4DsiJg='))
+$FolderSelectLabel = $Utf8.GetString([Convert]::FromBase64String(
+ '7Y+0642UIOyEoO2DnQ=='))
+$CancelLabel = $Utf8.GetString([Convert]::FromBase64String('7Leo7IaM'))
+if ([string]::IsNullOrWhiteSpace($LegacyDataDirectory)) {
+ $LegacyDataDirectory = [IO.Path]::GetFullPath((Join-Path $RepositoryRoot `
+ '..\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Data'))
+} else {
+ $LegacyDataDirectory = [IO.Path]::GetFullPath($LegacyDataDirectory)
+}
+$SourceComparisonPath = Join-Path $LegacyDataDirectory $ComparisonFileName
+$DestinationComparisonPath = Join-Path `
+ ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) `
+ (Join-Path 'MBN_STOCK_WEBVIEW\Data' $ComparisonFileName)
+$RuntimeSettingsPath = Join-Path `
+ ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) `
+ 'MBN_STOCK_WEBVIEW\Config\runtime-folders.local.json'
+$Cp949 = [Text.Encoding]::GetEncoding(
+ 949,
+ [Text.EncoderExceptionFallback]::new(),
+ [Text.DecoderExceptionFallback]::new())
+$ExpectedExchangePlan = @(
+ [pscustomobject]@{ Operation = 'observe-local-files'; TargetId = $null; FolderKind = $null },
+ [pscustomobject]@{ Operation = 'application-click'; TargetId = 'settings-navigation'; FolderKind = $null },
+ [pscustomobject]@{ Operation = 'folder-picker-click-cancel'; TargetId = 'design-folder-picker'; FolderKind = 'design' },
+ [pscustomobject]@{ Operation = 'folder-picker-click-cancel'; TargetId = 'resource-folder-picker'; FolderKind = 'resource' },
+ [pscustomobject]@{ Operation = 'folder-picker-click-cancel'; TargetId = 'background-folder-picker'; FolderKind = 'background' },
+ [pscustomobject]@{ Operation = 'observe-local-files'; TargetId = $null; FolderKind = $null },
+ [pscustomobject]@{ Operation = 'application-click'; TargetId = 'comparison-navigation'; FolderKind = $null },
+ [pscustomobject]@{ Operation = 'application-click'; TargetId = 'comparison-import'; FolderKind = $null },
+ [pscustomobject]@{ Operation = 'application-click'; TargetId = 'native-confirmation-yes'; FolderKind = $null },
+ [pscustomobject]@{ Operation = 'observe-local-files'; TargetId = $null; FolderKind = $null },
+ [pscustomobject]@{ Operation = 'application-click'; TargetId = 'comparison-import'; FolderKind = $null },
+ [pscustomobject]@{ Operation = 'application-click'; TargetId = 'native-confirmation-yes'; FolderKind = $null },
+ [pscustomobject]@{ Operation = 'observe-local-files'; TargetId = $null; FolderKind = $null }
+)
+
+$script:LocalInputRequestCalls = 0
+$script:LocalInputAcknowledgementCalls = 0
+$script:BaselineSettingsState = $null
+$script:InitialDestinationState = $null
+$script:FirstImportedDestinationState = $null
+$script:LastObservedFileState = $null
+
+function Assert-SafeRegularFile([string]$Path, [string]$Label, [int64]$MaximumBytes) {
+ if (-not [IO.File]::Exists($Path)) {
+ Throw-SafeFailure "$Label is missing."
+ }
+ $item = Get-Item -LiteralPath $Path -Force
+ if ($item.PSIsContainer -or
+ ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ $item.Length -le 0 -or $item.Length -gt $MaximumBytes) {
+ Throw-SafeFailure "$Label is not a bounded regular file."
+ }
+}
+
+function Get-ComparisonFileState(
+ [string]$Path,
+ [string]$Label,
+ [bool]$IsReadOnlySource) {
+ Assert-SafeRegularFile $Path $Label (64 * 1024)
+ $bytes = [IO.File]::ReadAllBytes($Path)
+ try {
+ $text = $Cp949.GetString($bytes)
+ }
+ catch {
+ Throw-SafeFailure "$Label is not strict CP949."
+ }
+ $rows = @($text -split "`r?`n" | Where-Object { $_.Length -gt 0 })
+ if ($rows.Count -lt 1 -or $rows.Count -gt 500) {
+ Throw-SafeFailure "$Label row count is outside the closed contract."
+ }
+ for ($index = 0; $index -lt $rows.Count; $index++) {
+ if ($rows[$index] -cnotmatch ('\A' + ($index + 1) + '\^[^\^,]+,[^\^,]+(?:\^[^\^]*){7}\z')) {
+ Throw-SafeFailure "$Label does not retain the exact legacy row shape."
+ }
+ }
+ $pairField = ($rows[0] -split '\^', 3)[1]
+ $pair = $pairField -split ',', 2
+ return [ordered]@{
+ exists = $true
+ length = [int64]$bytes.Length
+ sha256 = Get-ByteArraySha256 $bytes
+ rowCount = $rows.Count
+ firstPair = $pair[0] + '|' + $pair[1]
+ isReadOnlySource = $IsReadOnlySource
+ }
+}
+
+function Get-SettingsFileState {
+ if (-not [IO.File]::Exists($RuntimeSettingsPath)) {
+ return [ordered]@{
+ exists = $false
+ length = 0
+ sha256 = $null
+ }
+ }
+ Assert-SafeRegularFile $RuntimeSettingsPath 'Runtime folder settings JSON' (16 * 1024)
+ $bytes = [IO.File]::ReadAllBytes($RuntimeSettingsPath)
+ try {
+ [void]$StrictUtf8.GetString($bytes)
+ }
+ catch {
+ Throw-SafeFailure 'Runtime folder settings JSON is not strict UTF-8.'
+ }
+ return [ordered]@{
+ exists = $true
+ length = [int64]$bytes.Length
+ sha256 = Get-ByteArraySha256 $bytes
+ }
+}
+
+function Get-LocalFileState {
+ return [ordered]@{
+ source = Get-ComparisonFileState `
+ $SourceComparisonPath `
+ 'Original comparison source' `
+ $true
+ destination = Get-ComparisonFileState `
+ $DestinationComparisonPath `
+ 'Current local comparison file' `
+ $false
+ settings = Get-SettingsFileState
+ }
+}
+
+function Test-JsonEquivalent($Left, $Right) {
+ return (($Left | ConvertTo-Json -Depth 12 -Compress) -ceq
+ ($Right | ConvertTo-Json -Depth 12 -Compress))
+}
+
+function Assert-LocalFilePreflight {
+ $state = Get-LocalFileState
+ if ([string]$state.source.sha256 -cne $ExpectedSourceSha256 -or
+ [int]$state.source.rowCount -ne 8 -or
+ [string]$state.destination.sha256 -cne $ExpectedInitialDestinationSha256 -or
+ [int]$state.destination.rowCount -ne 1 -or
+ [string]$state.destination.firstPair -cne $ExpectedInitialPairIdentity) {
+ Throw-SafeFailure `
+ ('The original 8-row source or exact current one-row baseline changed; ' +
+ 'no import was started.')
+ }
+ $initialBytes = [IO.File]::ReadAllBytes($DestinationComparisonPath)
+ if ($Cp949.GetString($initialBytes).TrimEnd("`r", "`n") -cne
+ $ExpectedInitialPairLine) {
+ Throw-SafeFailure 'The current one-row comparison baseline is not the expected known pair.'
+ }
+ $script:BaselineSettingsState = $state.settings
+ $script:InitialDestinationState = $state.destination
+ $script:LastObservedFileState = $state
+ return $state
+}
+
+function Get-TopLevelWindowHandles {
+ $handles = New-Object 'Collections.Generic.HashSet[int64]'
+ foreach ($handle in [LegacyLocalStateNative]::VisibleTopLevelWindows()) {
+ if ($handle -ne [IntPtr]::Zero) { [void]$handles.Add($handle.ToInt64()) }
+ }
+ return $handles
+}
+
+function Get-OwnedFolderPickerCandidates(
+ [Collections.Generic.HashSet[int64]]$BaselineHandles,
+ [IntPtr]$MainWindowHandle,
+ [int]$ApplicationProcessId) {
+ $candidates = @()
+ foreach ($handle in [LegacyLocalStateNative]::VisibleTopLevelWindows()) {
+ try {
+ if ($handle -eq [IntPtr]::Zero -or
+ $BaselineHandles.Contains($handle.ToInt64()) -or
+ -not [LegacyLocalStateNative]::Visible($handle)) {
+ continue
+ }
+ $sameProcess = [LegacyLocalStateNative]::ProcessId($handle) -eq $ApplicationProcessId
+ $ownerLinked = $sameProcess -or
+ [LegacyLocalStateNative]::OwnerChainContains($handle, $MainWindowHandle)
+ if (-not $ownerLinked) { continue }
+
+ $window = [System.Windows.Automation.AutomationElement]::FromHandle($handle)
+ if ($null -eq $window) { continue }
+ $windowName = [string]$window.Current.Name
+ $windowClass = [LegacyLocalStateNative]::ClassName($handle)
+ if ($windowClass -cne '#32770' -or
+ (-not $windowName.StartsWith(
+ $FolderSelectLabel,
+ [StringComparison]::Ordinal) -and
+ $windowName -cnotmatch '\ASelect Folder(?:\(&.\))?\z')) {
+ continue
+ }
+
+ # The Win32 folder picker may expose its bottom Button windows as
+ # ControlType.Pane through UI Automation (observed on the Korean
+ # Windows shell). Keep the label/count gate, but also recognize
+ # the exact legacy Button class/IDs instead of requiring only the
+ # synthesized ControlType.Button view.
+ $controls = $window.FindAll(
+ [System.Windows.Automation.TreeScope]::Descendants,
+ [System.Windows.Automation.Condition]::TrueCondition)
+ $confirmCount = 0
+ $cancelCount = 0
+ foreach ($control in $controls) {
+ $automationId = [string]$control.Current.AutomationId
+ $isAutomationButton =
+ $control.Current.ControlType -eq [System.Windows.Automation.ControlType]::Button
+ $isLegacyDialogButton =
+ [string]$control.Current.ClassName -ceq 'Button' -and
+ ($automationId -ceq '1' -or $automationId -ceq '2')
+ if (-not $isAutomationButton -and -not $isLegacyDialogButton) { continue }
+
+ $name = [string]$control.Current.Name
+ if ($name.StartsWith($FolderSelectLabel, [StringComparison]::Ordinal) -or
+ $name -cmatch '\A(?:Select Folder|Select)(?:\(&.\))?\z') {
+ $confirmCount++
+ }
+ if ($name.StartsWith($CancelLabel, [StringComparison]::Ordinal) -or
+ $name -cmatch '\ACancel(?:\(&.\))?\z') {
+ $cancelCount++
+ }
+ }
+ if ($confirmCount -ne 1 -or $cancelCount -ne 1) { continue }
+
+ $windowPatternObject = $null
+ if (-not $window.TryGetCurrentPattern(
+ [System.Windows.Automation.WindowPattern]::Pattern,
+ [ref]$windowPatternObject) -or
+ $null -eq $windowPatternObject) {
+ continue
+ }
+ $windowPattern = $windowPatternObject -as
+ [System.Windows.Automation.WindowPattern]
+ $isModal = [bool]$windowPattern.Current.IsModal
+ if (-not $isModal) { continue }
+
+ $candidates += [pscustomobject]@{
+ Element = $window
+ Handle = $handle
+ ProcessId = [LegacyLocalStateNative]::ProcessId($handle)
+ SameProcess = $sameProcess
+ OwnerLinked = $ownerLinked
+ Name = $windowName
+ ClassName = $windowClass
+ ConfirmButtonCount = $confirmCount
+ CancelButtonCount = $cancelCount
+ IsModal = $isModal
+ }
+ }
+ catch {
+ # A transient shell window is ignored unless it satisfies every
+ # stable ownership and folder-button condition in one observation.
+ }
+ }
+ return @($candidates)
+}
+
+function Wait-UniqueOwnedFolderPicker(
+ [Collections.Generic.HashSet[int64]]$BaselineHandles,
+ [Diagnostics.Process]$Application,
+ [string]$ExpectedApplicationPath,
+ [datetime]$Deadline) {
+ while ([datetime]::UtcNow -lt $Deadline) {
+ Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
+ Assert-NoTornadoConnection $Application.Id
+ $candidates = @(Get-OwnedFolderPickerCandidates `
+ $BaselineHandles `
+ $Application.MainWindowHandle `
+ $Application.Id)
+ if ($candidates.Count -gt 1) {
+ Throw-SafeFailure 'More than one newly opened app-owned folder picker exists.'
+ }
+ if ($candidates.Count -eq 1) {
+ $picker = $candidates[0]
+ Assert-NoTornadoConnection $Application.Id
+ [LegacyLocalStateNative]::AcquireOwnedForeground(
+ $picker.Handle,
+ $Application.MainWindowHandle)
+ Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
+ Assert-NoTornadoConnection $Application.Id
+ $revalidated = @(Get-OwnedFolderPickerCandidates `
+ $BaselineHandles `
+ $Application.MainWindowHandle `
+ $Application.Id)
+ if ($revalidated.Count -ne 1 -or
+ $revalidated[0].Handle -ne $picker.Handle) {
+ Throw-SafeFailure 'The unique app-owned folder picker identity changed during foreground acquisition.'
+ }
+ $foregroundRoot = [LegacyLocalStateNative]::Root(
+ [LegacyPackageInputNative]::ReadForegroundWindow())
+ if ($foregroundRoot -eq $picker.Handle) { return $picker }
+ }
+ Start-Sleep -Milliseconds 50
+ }
+ Throw-SafeFailure 'The exact app-owned folder picker did not appear in time.'
+}
+
+function Wait-FolderPickerClosed(
+ [IntPtr]$PickerHandle,
+ [Diagnostics.Process]$Application,
+ [string]$ExpectedApplicationPath,
+ [datetime]$Deadline) {
+ while ([datetime]::UtcNow -lt $Deadline) {
+ Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
+ Assert-NoTornadoConnection $Application.Id
+ if (-not [LegacyLocalStateNative]::Exists($PickerHandle)) {
+ return
+ }
+ Start-Sleep -Milliseconds 50
+ }
+ Throw-SafeFailure 'The exact folder picker was not destroyed after the one Escape input.'
+}
+
+function ConvertTo-LocalFiniteNumber($Value, [string]$Label) {
+ try {
+ $number = [Convert]::ToDouble($Value, [Globalization.CultureInfo]::InvariantCulture)
+ }
+ catch { Throw-SafeFailure "$Label is not numeric." }
+ if ([double]::IsNaN($number) -or [double]::IsInfinity($number)) {
+ Throw-SafeFailure "$Label is not finite."
+ }
+ return $number
+}
+
+function Write-LocalAcknowledgement(
+ [string]$Path,
+ $Request,
+ $Files,
+ $Picker) {
+ $acknowledgement = [ordered]@{
+ schemaVersion = 1
+ token = [string]$Request.token
+ sequence = [int]$Request.sequence
+ result = 'PASS'
+ operation = [string]$Request.operation
+ targetId = if ($null -eq $Request.targetId) { $null } else { [string]$Request.targetId }
+ folderKind = if ($null -eq $Request.folderKind) { $null } else { [string]$Request.folderKind }
+ packageIdentityValidated = $true
+ dryRunValidated = $true
+ tornadoConnectionCount = 0
+ mouseDownCalls = [int]$Request.mouseDownCalls
+ mouseUpCalls = [int]$Request.mouseUpCalls
+ escapeKeyCalls = [int]$Request.escapeKeyCalls
+ inputRetryCount = 0
+ picker = $Picker
+ files = $Files
+ }
+ $bytes = $Utf8.GetBytes(($acknowledgement | ConvertTo-Json -Depth 12 -Compress) + "`n")
+ $temporary = $Path + '.' + [Guid]::NewGuid().ToString('N') + '.tmp'
+ try {
+ $stream = [IO.FileStream]::new(
+ $temporary,
+ [IO.FileMode]::CreateNew,
+ [IO.FileAccess]::Write,
+ [IO.FileShare]::None)
+ try {
+ $stream.Write($bytes, 0, $bytes.Length)
+ $stream.Flush($true)
+ }
+ finally { $stream.Dispose() }
+ [IO.File]::Move($temporary, $Path)
+ }
+ catch {
+ try { if ([IO.File]::Exists($temporary)) { [IO.File]::Delete($temporary) } } catch { }
+ Throw-SafeFailure 'The immutable local-state acknowledgement could not be created.'
+ }
+ return [pscustomobject]@{
+ Sha256 = Get-ByteArraySha256 $bytes
+ }
+}
+
+function Invoke-LocalStateExchange(
+ [int]$Sequence,
+ [string]$RequestPath,
+ [string]$AcknowledgementPath,
+ [Diagnostics.Process]$Application,
+ [string]$ExpectedApplicationPath) {
+ $script:LocalInputRequestCalls++
+ if ($script:LocalInputRequestCalls -ne $Sequence -or
+ [IO.File]::Exists($AcknowledgementPath)) {
+ Throw-SafeFailure 'The local-state one-shot input sequence changed.'
+ }
+ $requestEvidence = Wait-ReadSmallJsonEvidenceFile `
+ $RequestPath `
+ "Local-state request $Sequence" `
+ ([datetime]::UtcNow.AddSeconds(3))
+ $request = $requestEvidence.Value
+ Assert-ExactJsonPropertyNames $request @(
+ 'schemaVersion', 'token', 'sequence', 'targetUrl', 'operation',
+ 'targetId', 'folderKind', 'point', 'viewport', 'devicePixelRatio',
+ 'mouseDownCalls', 'mouseUpCalls', 'escapeKeyCalls', 'inputRetryCount') `
+ "Local-state request $Sequence"
+ $expected = $ExpectedExchangePlan[$Sequence - 1]
+ $targetId = if ($null -eq $request.targetId) { $null } else { [string]$request.targetId }
+ $folderKind = if ($null -eq $request.folderKind) { $null } else { [string]$request.folderKind }
+ if ([int](ConvertTo-LocalFiniteNumber $request.schemaVersion 'schemaVersion') -ne 1 -or
+ [int](ConvertTo-LocalFiniteNumber $request.sequence 'sequence') -ne $Sequence -or
+ $request.token -isnot [string] -or [string]$request.token -cnotmatch '\A[0-9A-F]{32}\z' -or
+ [string]$request.targetUrl -cne 'https://legacy-parity.mbn.local/index.html' -or
+ [string]$request.operation -cne [string]$expected.Operation -or
+ $targetId -cne $expected.TargetId -or $folderKind -cne $expected.FolderKind -or
+ [int](ConvertTo-LocalFiniteNumber $request.inputRetryCount 'inputRetryCount') -ne 0) {
+ Throw-SafeFailure "Local-state request $Sequence is outside the closed plan."
+ }
+
+ Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
+ Assert-NoTornadoConnection $Application.Id
+ $pickerEvidence = $null
+ if ([string]$expected.Operation -ceq 'observe-local-files') {
+ if ($null -ne $request.point -or $null -ne $request.viewport -or
+ $null -ne $request.devicePixelRatio -or
+ [int]$request.mouseDownCalls -ne 0 -or [int]$request.mouseUpCalls -ne 0 -or
+ [int]$request.escapeKeyCalls -ne 0) {
+ Throw-SafeFailure 'A file observation request attempted to carry input geometry.'
+ }
+ }
+ else {
+ Assert-ExactJsonPropertyNames $request.point @('x', 'y') "Request $Sequence point"
+ Assert-ExactJsonPropertyNames $request.viewport @('width', 'height') "Request $Sequence viewport"
+ $x = ConvertTo-LocalFiniteNumber $request.point.x 'point.x'
+ $y = ConvertTo-LocalFiniteNumber $request.point.y 'point.y'
+ $width = ConvertTo-LocalFiniteNumber $request.viewport.width 'viewport.width'
+ $height = ConvertTo-LocalFiniteNumber $request.viewport.height 'viewport.height'
+ $scale = ConvertTo-LocalFiniteNumber $request.devicePixelRatio 'devicePixelRatio'
+ $expectedEscapeCount = if ([string]$expected.Operation -ceq
+ 'folder-picker-click-cancel') { 1 } else { 0 }
+ if ($width -lt 1 -or $width -gt 16384 -or $height -lt 1 -or $height -gt 16384 -or
+ $x -lt 0 -or $x -ge $width -or $y -lt 0 -or $y -ge $height -or
+ $scale -lt 0.25 -or $scale -gt 5 -or
+ [int]$request.mouseDownCalls -ne 1 -or [int]$request.mouseUpCalls -ne 1 -or
+ [int]$request.escapeKeyCalls -ne $expectedEscapeCount) {
+ Throw-SafeFailure "Request $Sequence input geometry or exact count is invalid."
+ }
+ $baselineWindows = if ([string]$expected.Operation -ceq
+ 'folder-picker-click-cancel') { Get-TopLevelWindowHandles } else { $null }
+ [LegacyPackageInputNative]::SendApplicationClick(
+ $Application.MainWindowHandle,
+ $Application.Id,
+ 30001,
+ $x,
+ $y,
+ $width,
+ $height,
+ $scale,
+ [string]$expected.Operation -ceq 'folder-picker-click-cancel')
+ if ([string]$expected.Operation -ceq 'folder-picker-click-cancel') {
+ $picker = Wait-UniqueOwnedFolderPicker `
+ $baselineWindows `
+ $Application `
+ $ExpectedApplicationPath `
+ ([datetime]::UtcNow.AddSeconds(10))
+ [LegacyPackageInputNative]::SendEscapeToForegroundWindow($picker.Handle)
+ Wait-FolderPickerClosed `
+ $picker.Handle `
+ $Application `
+ $ExpectedApplicationPath `
+ ([datetime]::UtcNow.AddSeconds(10))
+ $pickerEvidence = [ordered]@{
+ uniqueNewPicker = $true
+ ownerLinkedToApplication = [bool]$picker.OwnerLinked
+ sameProcessAsApplication = [bool]$picker.SameProcess
+ processId = [int]$picker.ProcessId
+ windowClass = [string]$picker.ClassName
+ windowTitle = [string]$picker.Name
+ isModal = [bool]$picker.IsModal
+ folderSelectButtonCount = [int]$picker.ConfirmButtonCount
+ cancelButtonCount = [int]$picker.CancelButtonCount
+ escapeCancelled = $true
+ closedAfterEscape = $true
+ }
+ }
+ }
+ Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
+ Assert-NoTornadoConnection $Application.Id
+ # A physical Yes click starts an asynchronous atomic store operation. Do
+ # not race that write by opening the destination in the click handler. Node
+ # waits for the native completion receipt and then publishes the dedicated
+ # observation request (10 or 13), which is the first permitted fresh read.
+ $files = if ($Sequence -eq 9 -or $Sequence -eq 12) {
+ $script:LastObservedFileState
+ }
+ else {
+ $fresh = Get-LocalFileState
+ $script:LastObservedFileState = $fresh
+ $fresh
+ }
+ if (-not (Test-JsonEquivalent $files.settings $script:BaselineSettingsState)) {
+ Throw-SafeFailure 'The runtime folder settings file changed during a cancel-only smoke.'
+ }
+ if ([string]$files.source.sha256 -cne $ExpectedSourceSha256 -or
+ [int]$files.source.rowCount -ne 8) {
+ Throw-SafeFailure 'The original read-only comparison source changed during the smoke.'
+ }
+ if ($Sequence -lt 9 -and
+ [string]$files.destination.sha256 -cne $ExpectedInitialDestinationSha256) {
+ Throw-SafeFailure 'The comparison destination changed before the authorized Yes input.'
+ }
+ if ($Sequence -eq 10) {
+ if ([int]$files.destination.rowCount -ne 9 -or
+ [string]$files.destination.firstPair -cne $ExpectedInitialPairIdentity -or
+ [string]$files.destination.sha256 -ceq $ExpectedInitialDestinationSha256) {
+ Throw-SafeFailure 'The first authorized import is not an appended nine-row file.'
+ }
+ $script:FirstImportedDestinationState = $files.destination
+ }
+ if ($Sequence -eq 13 -and
+ (-not (Test-JsonEquivalent $files.destination $script:FirstImportedDestinationState))) {
+ Throw-SafeFailure 'The second import changed the comparison file instead of added0 idempotence.'
+ }
+
+ $script:LocalInputAcknowledgementCalls++
+ if ($script:LocalInputAcknowledgementCalls -ne $Sequence) {
+ Throw-SafeFailure 'The local-state acknowledgement budget changed.'
+ }
+ $acknowledgement = Write-LocalAcknowledgement `
+ $AcknowledgementPath `
+ $request `
+ $files `
+ $pickerEvidence
+ return [pscustomobject]@{
+ Sequence = $Sequence
+ RequestSha256 = $requestEvidence.Sha256
+ AcknowledgementSha256 = $acknowledgement.Sha256
+ }
+}
+
+function Invoke-LocalHarnessUnderWatch(
+ [string]$Executable,
+ [string]$ScriptPath,
+ [int]$DebugPort,
+ [string]$OutputPath,
+ [string]$ScreenshotPath,
+ [string]$ExchangePath,
+ [int]$TimeoutSeconds,
+ [Diagnostics.Process]$Application,
+ [string]$ExpectedApplicationPath) {
+ $rawArguments = @(
+ $ScriptPath,
+ '--port', [string]$DebugPort,
+ '--output', $OutputPath,
+ '--screenshot', $ScreenshotPath,
+ '--exchange-directory', $ExchangePath,
+ '--timeout-ms', [string]($TimeoutSeconds * 1000))
+ $startInfo = [Diagnostics.ProcessStartInfo]::new()
+ $startInfo.FileName = $Executable
+ $startInfo.WorkingDirectory = $RepositoryRoot
+ $startInfo.Arguments = ($rawArguments | ForEach-Object {
+ ConvertTo-QuotedProcessArgument ([string]$_)
+ }) -join ' '
+ $startInfo.UseShellExecute = $false
+ $startInfo.CreateNoWindow = $true
+ $startInfo.RedirectStandardInput = $true
+ try { $node = [Diagnostics.Process]::Start($startInfo) }
+ catch { Throw-SafeFailure 'The local-state Node harness could not be started.' }
+ if ($null -eq $node) { Throw-SafeFailure 'The local-state Node harness did not start.' }
+
+ $deadline = [datetime]::UtcNow.AddSeconds($TimeoutSeconds + 30)
+ $sequence = 1
+ $exchanges = @()
+ $monitoringFailure = $null
+ try {
+ while (-not $node.HasExited) {
+ if ([datetime]::UtcNow -ge $deadline) {
+ Throw-SafeFailure 'The local-state harness exceeded its global deadline.'
+ }
+ Assert-ExactApplicationStillRunning $Application $ExpectedApplicationPath
+ Assert-NoTornadoConnection $Application.Id
+ if ($sequence -le $ExpectedExchangePlan.Count) {
+ $requestPath = Join-Path $ExchangePath (
+ $sequence.ToString('00') + '.request.json')
+ $ackPath = Join-Path $ExchangePath (
+ $sequence.ToString('00') + '.ack.json')
+ if ([IO.File]::Exists($requestPath)) {
+ $exchanges += Invoke-LocalStateExchange `
+ $sequence `
+ $requestPath `
+ $ackPath `
+ $Application `
+ $ExpectedApplicationPath
+ $sequence++
+ }
+ }
+ Start-Sleep -Milliseconds 50
+ $node.Refresh()
+ }
+ if ($sequence -ne ($ExpectedExchangePlan.Count + 1) -or
+ $script:LocalInputRequestCalls -ne $ExpectedExchangePlan.Count -or
+ $script:LocalInputAcknowledgementCalls -ne $ExpectedExchangePlan.Count) {
+ Throw-SafeFailure 'The closed thirteen-step local-state sequence did not complete.'
+ }
+ }
+ catch { $monitoringFailure = $_.Exception.Message }
+
+ if ($null -ne $monitoringFailure) {
+ $stopped = $node.HasExited
+ if (-not $stopped) {
+ try {
+ $node.StandardInput.WriteLine('CANCEL')
+ $node.StandardInput.Flush()
+ $stopped = $node.WaitForExit(5000)
+ }
+ catch { $stopped = $false }
+ }
+ if (-not $stopped) {
+ try {
+ $node.Kill()
+ $stopped = $node.WaitForExit(10000)
+ }
+ catch { $stopped = $false }
+ }
+ Throw-SafeFailure (
+ "$monitoringFailure The input helper was stopped if possible; " +
+ 'the packaged app was left open and no input was retried.')
+ }
+ return [pscustomobject]@{
+ ExitCode = $node.ExitCode
+ Exchanges = @($exchanges)
+ }
+}
+
+function Read-LocalHarnessEvidenceFile([string]$Path) {
+ try {
+ $item = Get-Item -LiteralPath $Path -Force
+ if ($item.PSIsContainer -or
+ ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ $item.Length -le 0 -or $item.Length -gt 128KB) {
+ Throw-SafeFailure 'Local-state harness evidence is not a bounded regular file.'
+ }
+
+ $bytes = [IO.File]::ReadAllBytes($Path)
+ if ($bytes.Length -ne $item.Length) {
+ Throw-SafeFailure 'Local-state harness evidence changed while it was read.'
+ }
+
+ $value = $StrictUtf8.GetString($bytes) | ConvertFrom-Json -ErrorAction Stop
+ return [pscustomobject]@{
+ Value = $value
+ Bytes = $bytes
+ Sha256 = Get-ByteArraySha256 $bytes
+ }
+ }
+ catch [InvalidOperationException] {
+ throw
+ }
+ catch {
+ Throw-SafeFailure 'Local-state harness evidence is not strict bounded JSON.'
+ }
+}
+
+function Assert-LocalHarnessEvidence(
+ [string]$EvidencePath,
+ [string]$ScreenshotPath,
+ [string]$ExchangePath) {
+ $sealed = Read-LocalHarnessEvidenceFile $EvidencePath
+ $value = $sealed.Value
+ if ([int]$value.schemaVersion -ne 1 -or
+ [string]$value.profile -cne 'local-settings-comparison-import' -or
+ [string]$value.result -cne 'PASS' -or $null -ne $value.error -or
+ [string]$value.target.expectedUrl -cne
+ 'https://legacy-parity.mbn.local/index.html' -or
+ [int]$value.target.port -ne $Port -or
+ [string]$value.target.url -cne
+ 'https://legacy-parity.mbn.local/index.html' -or
+ [string]$value.safety.requiredMode -cne 'dryRun' -or
+ [string]$value.safety.requiredPhase -cne 'idle' -or
+ $value.safety.appCloseRequested -ne $false -or
+ $value.safety.playoutIntentIssued -ne $false -or
+ [int]$value.safety.inputRetryCount -ne 0 -or
+ @($value.safety.blockedOutboundMessages).Count -ne 0 -or
+ @($value.safety.stateViolations).Count -ne 0 -or
+ $value.settings.allCancelled -ne $true -or
+ $value.settings.settingsFileUnchanged -ne $true -or
+ -not (Test-ExactStringSequence @($value.settings.pickerKinds) @(
+ 'design', 'resource', 'background')) -or
+ $value.comparison.initialPairPreserved -ne $true -or
+ $value.comparison.secondImportIdempotent -ne $true -or
+ [int]$value.comparison.afterFirstImport.receipt.addedPairCount -ne 8 -or
+ [int]$value.comparison.afterFirstImport.pairCount -ne 9 -or
+ [int]$value.comparison.afterSecondImport.receipt.addedPairCount -ne 0 -or
+ [int]$value.comparison.afterSecondImport.pairCount -ne 9 -or
+ [string]$value.files.destinationAfterFirstImport.sha256 -cne
+ [string]$value.files.destinationAfterSecondImport.sha256 -or
+ @($value.exchanges).Count -ne 13) {
+ Throw-SafeFailure 'The local-state evidence does not satisfy the exact cancel/import contract.'
+ }
+ $screenshot = Get-Item -LiteralPath $ScreenshotPath -Force
+ if ($screenshot.Length -le 0 -or
+ [int64]$value.screenshot.bytes -ne $screenshot.Length -or
+ [string]$value.screenshot.sha256 -cne
+ (Get-FileHash -Algorithm SHA256 -LiteralPath $ScreenshotPath).Hash) {
+ Throw-SafeFailure 'The local-state screenshot does not match its sealed evidence.'
+ }
+ for ($index = 0; $index -lt 13; $index++) {
+ $sequence = $index + 1
+ $entry = $value.exchanges[$index]
+ $requestPath = Join-Path $ExchangePath ($sequence.ToString('00') + '.request.json')
+ $ackPath = Join-Path $ExchangePath ($sequence.ToString('00') + '.ack.json')
+ if ([int]$entry.sequence -ne $sequence -or
+ -not (Test-PathEqual ([string]$entry.requestPath) $requestPath) -or
+ -not (Test-PathEqual ([string]$entry.acknowledgementPath) $ackPath) -or
+ [string]$entry.requestSha256 -cne
+ (Get-FileHash -Algorithm SHA256 -LiteralPath $requestPath).Hash -or
+ [string]$entry.acknowledgementSha256 -cne
+ (Get-FileHash -Algorithm SHA256 -LiteralPath $ackPath).Hash) {
+ Throw-SafeFailure "Local-state exchange $sequence is not sealed exactly."
+ }
+ $expectedOperation = [string]$ExpectedExchangePlan[$index].Operation
+ if ($expectedOperation -ceq 'observe-local-files') {
+ if ($null -ne $entry.physicalPointer) {
+ Throw-SafeFailure "File observation $sequence unexpectedly contains pointer input."
+ }
+ }
+ elseif ($null -eq $entry.physicalPointer -or
+ $entry.physicalPointer.down.isTrusted -ne $true -or
+ $entry.physicalPointer.up.isTrusted -ne $true -or
+ [string]$entry.physicalPointer.down.pointerType -cne 'mouse' -or
+ [string]$entry.physicalPointer.up.pointerType -cne 'mouse' -or
+ [string]$entry.physicalPointer.down.targetId -cne
+ [string]$ExpectedExchangePlan[$index].TargetId -or
+ [string]$entry.physicalPointer.up.targetId -cne
+ [string]$ExpectedExchangePlan[$index].TargetId) {
+ Throw-SafeFailure "Input exchange $sequence lacks exact trusted pointer evidence."
+ }
+ }
+ return $value
+}
+
+if ($StaticAudit) {
+ [pscustomobject]@{
+ result = 'PASS'
+ profile = 'local-settings-comparison-import'
+ packageFlavor = 'DebugAppX'
+ exchangeCount = $ExpectedExchangePlan.Count
+ physicalClickCount = @($ExpectedExchangePlan | Where-Object {
+ $_.Operation -ceq 'application-click' -or
+ $_.Operation -ceq 'folder-picker-click-cancel'
+ }).Count
+ folderPickerCancelCount = @($ExpectedExchangePlan | Where-Object {
+ $_.Operation -ceq 'folder-picker-click-cancel'
+ }).Count
+ fileObservationCount = @($ExpectedExchangePlan | Where-Object {
+ $_.Operation -ceq 'observe-local-files'
+ }).Count
+ inputRetryCount = 0
+ expectedSourceSha256 = $ExpectedSourceSha256
+ expectedSourceRows = 8
+ expectedInitialDestinationSha256 = $ExpectedInitialDestinationSha256
+ expectedInitialDestinationRows = 1
+ sendInputClickAvailable = $null -ne
+ [LegacyPackageInputNative].GetMethod('SendApplicationClick')
+ sendInputEscapeAvailable = $null -ne
+ [LegacyPackageInputNative].GetMethod('SendEscapeToForegroundWindow')
+ harnessPath = $HarnessPath
+ harnessSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $HarnessPath).Hash
+ } | ConvertTo-Json -Compress
+ return
+}
+
+$timestamp = [datetime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ')
+$Output = Resolve-EvidencePath $Output "legacy-package-local-state-$timestamp.json"
+$Screenshot = Resolve-EvidencePath $Screenshot "legacy-package-local-state-$timestamp.png"
+if ([string]::IsNullOrWhiteSpace($ExchangeDirectory)) {
+ $ExchangeDirectory = [IO.Path]::ChangeExtension($Output, $null) + '.exchanges'
+}
+elseif (-not [IO.Path]::IsPathRooted($ExchangeDirectory)) {
+ $ExchangeDirectory = [IO.Path]::GetFullPath((Join-Path $RepositoryRoot $ExchangeDirectory))
+}
+else { $ExchangeDirectory = [IO.Path]::GetFullPath($ExchangeDirectory) }
+$NodePath = Resolve-NodeExecutable $NodePath
+
+try {
+ if (-not [IO.File]::Exists($HarnessPath)) {
+ Throw-SafeFailure 'The tracked local-state harness is missing.'
+ }
+ Assert-OutputPathAvailable $Output 'Output'
+ Assert-OutputPathAvailable $Screenshot 'Screenshot'
+ Assert-OutputPathAvailable $ExchangeDirectory 'Exchange directory'
+ [void][IO.Directory]::CreateDirectory($ExchangeDirectory)
+ if (@(Get-ChildItem -LiteralPath $ExchangeDirectory -Force).Count -ne 0) {
+ Throw-SafeFailure 'The new exchange directory is not empty.'
+ }
+ Assert-LocalFilePreflight | Out-Null
+ Assert-LocalConfigurationIsDryRun
+ Assert-NoUnsafeEnvironment
+ $registration = Get-ExactDevelopmentPackage
+ Assert-NoExistingApplication
+ Assert-PortIsFree $Port
+
+ $script:Phase = 'package launch'
+ $script:ApplicationProcess = Start-PackagedDryRunApplication $registration $Port
+ $script:ApplicationWasLaunched = $true
+ [void](Wait-ExactLoopbackListener `
+ $Port `
+ $script:ApplicationProcess `
+ ([datetime]::UtcNow.AddSeconds($LaunchTimeoutSeconds)))
+ Assert-NoTornadoConnection $script:ApplicationProcess.Id
+
+ $script:Phase = 'local settings and comparison input harness'
+ $harness = Invoke-LocalHarnessUnderWatch `
+ $NodePath `
+ $HarnessPath `
+ $Port `
+ $Output `
+ $Screenshot `
+ $ExchangeDirectory `
+ $HarnessTimeoutSeconds `
+ $script:ApplicationProcess `
+ $registration.Executable
+ if ($harness.ExitCode -ne 0) {
+ Throw-SafeFailure "The local-state harness failed with exit code $($harness.ExitCode)."
+ }
+ $evidence = Assert-LocalHarnessEvidence $Output $Screenshot $ExchangeDirectory
+ if ($harness.Exchanges.Count -ne 13) {
+ Throw-SafeFailure 'The wrapper did not observe all thirteen sealed exchanges.'
+ }
+ Assert-ExactApplicationStillRunning $script:ApplicationProcess $registration.Executable
+ Assert-NoTornadoConnection $script:ApplicationProcess.Id
+ $finalFiles = Get-LocalFileState
+ if (-not (Test-JsonEquivalent $finalFiles.settings $script:BaselineSettingsState) -or
+ -not (Test-JsonEquivalent $finalFiles.destination $script:FirstImportedDestinationState) -or
+ [string]$finalFiles.source.sha256 -cne $ExpectedSourceSha256) {
+ Throw-SafeFailure 'Final local files do not match the sealed cancel/idempotence result.'
+ }
+
+ # This authorized import changed user-local comparison data. Avoid a second
+ # activation; normal close is the only remaining app action.
+ $script:Phase = 'normal close'
+ Invoke-ExactNormalClose $script:ApplicationProcess $registration.Executable
+ Wait-PortReleased $Port ([datetime]::UtcNow.AddSeconds(15))
+ if (@(Get-LegacyApplicationProcesses).Count -ne 0) {
+ Throw-SafeFailure 'A LegacyParity process remained after normal exit.'
+ }
+
+ $script:Phase = 'complete'
+ [pscustomobject]@{
+ result = [string]$evidence.result
+ packageFullName = $PackageFullName
+ packageMode = $PackageModeLabel
+ playoutMode = 'DryRun'
+ processId = $script:ApplicationProcess.Id
+ tornadoPortConnections = 0
+ inputRetryCount = 0
+ localInputRequestCalls = $script:LocalInputRequestCalls
+ localInputAcknowledgementCalls = $script:LocalInputAcknowledgementCalls
+ settingsPickerCancelCount = 3
+ comparisonImportAddedCount = 8
+ comparisonSecondImportAddedCount = 0
+ output = $Output
+ outputSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $Output).Hash
+ screenshot = $Screenshot
+ screenshotSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $Screenshot).Hash
+ exchangeDirectory = $ExchangeDirectory
+ }
+}
+catch {
+ $message = $_.Exception.Message
+ if ($script:CloseMainWindowCalls -gt 0 -or $script:PrimaryInvokeCalls -gt 0) {
+ Throw-SafeFailure (
+ "Local-state smoke became ambiguous during '$($script:Phase)': $message " +
+ 'No close retry or forced termination was attempted.')
+ }
+ $running = $false
+ if ($script:ApplicationWasLaunched -and $null -ne $script:ApplicationProcess) {
+ try {
+ $script:ApplicationProcess.Refresh()
+ $running = -not $script:ApplicationProcess.HasExited
+ }
+ catch { $running = $false }
+ }
+ if ($running) {
+ Throw-SafeFailure (
+ "Local-state smoke stopped during '$($script:Phase)': $message " +
+ 'The app was intentionally left open; no input retry, guessed cleanup, ' +
+ 'close retry or forced termination was attempted.')
+ }
+ throw
+}
diff --git a/scripts/Invoke-LegacyPgmParityRun.ps1 b/scripts/Invoke-LegacyPgmParityRun.ps1
new file mode 100644
index 0000000..fbe850e
--- /dev/null
+++ b/scripts/Invoke-LegacyPgmParityRun.ps1
@@ -0,0 +1,1268 @@
+[CmdletBinding()]
+param(
+ [ValidateSet('StaticAudit', 'DryPreflight', 'Initialize', 'Inspect', 'RunStage', 'RunNext')]
+ [string] $Action = 'StaticAudit',
+
+ [string] $EvidenceDirectory = '',
+
+ [ValidateSet('Core5001', 'ThirtyRoutes', 'PagedBoundary', 'S6001AssetIndependent', 'Recovery8001', 'Recovery5077Page2', 'ThirtyRoutes20To29')]
+ [string] $Workflow = 'Core5001',
+
+ [string] $Stage = '',
+ [int] $RouteIndex = -1,
+ [int] $PageIndex = -1,
+ [int] $DebugPort = 9339,
+ [int] $ExpectedPgmPort = 30001,
+ [int] $StageTimeoutSeconds = 900,
+
+ [string] $ExpectedPackageName = 'Wickedness.MBNStockWebView.LegacyParity',
+ [string] $ExpectedPgmExecutable = '',
+ [string] $ExpectedAppWindowTitle = '',
+ [string] $ExpectedPgmWindowTitle = 'PGM',
+ [string] $ExpectedNetworkWindowTitle = '',
+ [string] $OriginalCutsDirectory = '',
+ [string] $NodeExecutable = ''
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+if ([string]::IsNullOrWhiteSpace($ExpectedNetworkWindowTitle)) {
+ $ExpectedNetworkWindowTitle = [Text.Encoding]::Unicode.GetString(
+ [Convert]::FromBase64String('JLG40szGbNAgAKi6yLIw0cG5IAA9zA=='))
+}
+if ([string]::IsNullOrWhiteSpace($ExpectedAppWindowTitle)) {
+ $ExpectedAppWindowTitle = [Text.Encoding]::Unicode.GetString(
+ [Convert]::FromBase64String('VgAtAFMAdABvAGMAawAgAJ3JjK0VyPS8ocGczdzCpMJc0SAAZgBvAHIAIADkuXzHvawcyFQAVgAgACgAMgA2AC4AMAAzAC4AMgA2ACkA'))
+}
+
+$workspaceRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
+if ([string]::IsNullOrWhiteSpace($OriginalCutsDirectory)) {
+ $OriginalCutsDirectory = [IO.Path]::GetFullPath((Join-Path $workspaceRoot `
+ '..\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts'))
+} else {
+ $OriginalCutsDirectory = [IO.Path]::GetFullPath($OriginalCutsDirectory)
+}
+if ([string]::IsNullOrWhiteSpace($ExpectedPgmExecutable) -and
+ -not [string]::IsNullOrWhiteSpace($env:MBN_STOCK_PGM_EXECUTABLE)) {
+ $ExpectedPgmExecutable = [IO.Path]::GetFullPath($env:MBN_STOCK_PGM_EXECUTABLE)
+} elseif (-not [string]::IsNullOrWhiteSpace($ExpectedPgmExecutable)) {
+ $ExpectedPgmExecutable = [IO.Path]::GetFullPath($ExpectedPgmExecutable)
+}
+
+$script:Utf8NoBom = [Text.UTF8Encoding]::new($false)
+$script:TargetUrl = 'https://legacy-parity.mbn.local/index.html'
+$script:NodeStageScript = Join-Path $PSScriptRoot 'LegacyPgmParityStage.mjs'
+$script:RouteCodes = @(
+ '5001', '5074',
+ '5016', '50160', '5078', '8067', '5086', '50860', '5023', '5024',
+ '5077', '5082', '5083', '5084', '5085', '5088', '6067', '8035',
+ '5011', '8003', '5037', '8001', '5026', '5087', '5029', '8032',
+ '5032', '5076', '5079', '5080', '5081', '5025', '6001'
+)
+$script:S6001AssetRelativePaths = @(
+ [Text.Encoding]::Unicode.GetString(
+ [Convert]::FromBase64String('aQBtAGEAZwBlAHMAXAD8yCDHMK5tAGUAcgBnAGUALgBwAG4AZwA=')),
+ [Text.Encoding]::Unicode.GetString(
+ [Convert]::FromBase64String('aQBtAGEAZwBlAHMAXAAzADUANwA1ADIAOQAxADMAXwBsAC4AagBwAGcA'))
+)
+
+function Write-NewText {
+ param(
+ [Parameter(Mandatory = $true)] [string] $Path,
+ [Parameter(Mandatory = $true)] [AllowEmptyString()] [string] $Text
+ )
+ $fullPath = [IO.Path]::GetFullPath($Path)
+ $stream = [IO.File]::Open($fullPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::Read)
+ try {
+ $writer = [IO.StreamWriter]::new($stream, $script:Utf8NoBom)
+ try { $writer.Write($Text) } finally { $writer.Dispose() }
+ } finally {
+ if ($null -ne $stream) { $stream.Dispose() }
+ }
+}
+
+function Write-NewJson {
+ param(
+ [Parameter(Mandatory = $true)] [string] $Path,
+ [Parameter(Mandatory = $true)] [object] $Value
+ )
+ Write-NewText -Path $Path -Text (($Value | ConvertTo-Json -Depth 40) + [Environment]::NewLine)
+}
+
+function Get-CanonicalJson {
+ param([Parameter(Mandatory = $true)] [object] $Value)
+ return ($Value | ConvertTo-Json -Depth 40 -Compress)
+}
+
+function Get-ResolvedNodeExecutable {
+ if (-not [string]::IsNullOrWhiteSpace($NodeExecutable)) {
+ $resolved = [IO.Path]::GetFullPath($NodeExecutable)
+ if (-not [IO.File]::Exists($resolved)) { throw "Node executable does not exist: $resolved" }
+ return $resolved
+ }
+ $command = Get-Command node.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
+ if ($null -ne $command) { return [IO.Path]::GetFullPath($command.Source) }
+ $bundled = Join-Path $env:USERPROFILE '.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe'
+ if ([IO.File]::Exists($bundled)) { return [IO.Path]::GetFullPath($bundled) }
+ throw 'Node was not found. Supply -NodeExecutable with an absolute node.exe path.'
+}
+
+function Get-FileIdentity {
+ param([Parameter(Mandatory = $true)] [string] $Path)
+ $fullPath = [IO.Path]::GetFullPath($Path)
+ if (-not [IO.File]::Exists($fullPath)) { throw "Pinned file does not exist: $fullPath" }
+ $file = [IO.FileInfo]::new($fullPath)
+ return [ordered]@{
+ path = $fullPath
+ length = $file.Length
+ lastWriteUtc = $file.LastWriteTimeUtc.ToString('o')
+ sha256 = (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256).Hash
+ }
+}
+
+function Initialize-NativeTypes {
+ if ('CodexLegacyParityNative20260722' -as [type]) { return }
+ Add-Type -AssemblyName System.Drawing
+ Add-Type @'
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using System.Text;
+
+public static class CodexLegacyParityNative20260722
+{
+ public delegate bool WindowCallback(IntPtr window, IntPtr parameter);
+
+ [StructLayout(LayoutKind.Sequential)]
+ public struct Rect { public int Left, Top, Right, Bottom; }
+
+ [DllImport("user32.dll", SetLastError=true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool EnumWindows(WindowCallback callback, IntPtr parameter);
+
+ [DllImport("user32.dll", SetLastError=true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool EnumChildWindows(IntPtr parent, WindowCallback callback, IntPtr parameter);
+
+ [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
+ private static extern int GetWindowText(IntPtr window, StringBuilder text, int count);
+
+ [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
+ private static extern int GetClassName(IntPtr window, StringBuilder text, int count);
+
+ [DllImport("user32.dll", SetLastError=true)]
+ private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool IsWindowVisible(IntPtr window);
+
+ [DllImport("user32.dll", SetLastError=true)]
+ private static extern IntPtr SendMessageTimeout(IntPtr window, uint message, UIntPtr wParam,
+ IntPtr lParam, uint flags, uint timeoutMilliseconds, out UIntPtr result);
+
+ [DllImport("user32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
+ private static extern IntPtr SendMessageTimeout(IntPtr window, uint message, UIntPtr wParam,
+ StringBuilder lParam, uint flags, uint timeoutMilliseconds, out UIntPtr result);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool GetWindowRect(IntPtr window, out Rect rect);
+
+ [DllImport("user32.dll", EntryPoint="GetWindowLongPtrW", SetLastError=true)]
+ private static extern IntPtr GetWindowLongPtr64(IntPtr window, int index);
+
+ [DllImport("user32.dll", EntryPoint="GetWindowLongW", SetLastError=true)]
+ private static extern int GetWindowLong32(IntPtr window, int index);
+
+ [DllImport("user32.dll", SetLastError=true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool SetWindowPos(IntPtr window, IntPtr insertAfter,
+ int x, int y, int width, int height, uint flags);
+
+ public static bool IsTopMost(IntPtr window)
+ {
+ const int GwlExStyle = -20;
+ const long WsExTopMost = 0x00000008L;
+ long style = IntPtr.Size == 8
+ ? GetWindowLongPtr64(window, GwlExStyle).ToInt64()
+ : GetWindowLong32(window, GwlExStyle);
+ return (style & WsExTopMost) != 0;
+ }
+
+ public static bool SetTopMostForCapture(IntPtr window, bool topMost)
+ {
+ IntPtr insertAfter = topMost ? new IntPtr(-1) : new IntPtr(-2);
+ const uint SwpNoSize = 0x0001;
+ const uint SwpNoMove = 0x0002;
+ const uint SwpNoActivate = 0x0010;
+ const uint SwpShowWindow = 0x0040;
+ return SetWindowPos(window, insertAfter, 0, 0, 0, 0,
+ SwpNoSize | SwpNoMove | SwpNoActivate | SwpShowWindow);
+ }
+
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode)]
+ private static extern int GetPackageFullName(IntPtr process, ref uint packageFullNameLength,
+ StringBuilder packageFullName);
+
+ public static string GetProcessPackageFullName(IntPtr process)
+ {
+ const int AppmodelErrorNoPackage = 15700;
+ const int ErrorInsufficientBuffer = 122;
+ uint length = 0;
+ int first = GetPackageFullName(process, ref length, null);
+ if (first == AppmodelErrorNoPackage) return null;
+ if (first != ErrorInsufficientBuffer || length == 0)
+ throw new InvalidOperationException("GetPackageFullName length query failed: " + first);
+ var buffer = new StringBuilder((int)length);
+ int second = GetPackageFullName(process, ref length, buffer);
+ if (second != 0) throw new InvalidOperationException("GetPackageFullName failed: " + second);
+ return buffer.ToString();
+ }
+
+ public static IntPtr[] FindTopLevelWindows(uint processId, string exactTitle)
+ {
+ var matches = new List();
+ if (!EnumWindows(delegate(IntPtr window, IntPtr parameter) {
+ uint owner;
+ if (GetWindowThreadProcessId(window, out owner) == 0 || owner != processId) return true;
+ var title = new StringBuilder(1024);
+ GetWindowText(window, title, title.Capacity);
+ if (String.Equals(title.ToString(), exactTitle, StringComparison.Ordinal)) matches.Add(window);
+ return true;
+ }, IntPtr.Zero)) throw new InvalidOperationException("Top-level window enumeration failed.");
+ return matches.ToArray();
+ }
+
+ public static string GetWindowTitle(IntPtr window)
+ {
+ var title = new StringBuilder(1024);
+ GetWindowText(window, title, title.Capacity);
+ return title.ToString();
+ }
+
+ public static bool GetVisible(IntPtr window) { return IsWindowVisible(window); }
+
+ private static IntPtr FindUniqueRichEdit(IntPtr parent)
+ {
+ var matches = new List();
+ if (!EnumChildWindows(parent, delegate(IntPtr window, IntPtr parameter) {
+ var className = new StringBuilder(256);
+ if (GetClassName(window, className, className.Capacity) > 0 &&
+ String.Equals(className.ToString(), "RichEdit20W", StringComparison.Ordinal)) matches.Add(window);
+ return true;
+ }, IntPtr.Zero)) throw new InvalidOperationException("Network child enumeration failed.");
+ if (matches.Count != 1)
+ throw new InvalidOperationException("Expected exactly one Network Monitoring RichEdit20W; found " + matches.Count + ".");
+ return matches[0];
+ }
+
+ private static string ReadTextOnce(IntPtr window)
+ {
+ const uint WmGetText = 0x000D, WmGetTextLength = 0x000E, Flags = 0x0003;
+ UIntPtr lengthResult;
+ if (SendMessageTimeout(window, WmGetTextLength, UIntPtr.Zero, IntPtr.Zero, Flags, 5000,
+ out lengthResult) == IntPtr.Zero) throw new InvalidOperationException("Network length read timed out.");
+ long length = checked((long)lengthResult.ToUInt64());
+ if (length < 0 || length > 10000000) throw new InvalidOperationException("Network text length is unsafe.");
+ var text = new StringBuilder(checked((int)length + 65537));
+ UIntPtr copied;
+ if (SendMessageTimeout(window, WmGetText, new UIntPtr((uint)text.Capacity), text, Flags, 5000,
+ out copied) == IntPtr.Zero || copied.ToUInt64() != (ulong)text.Length)
+ throw new InvalidOperationException("Network text copy failed.");
+ return text.ToString();
+ }
+
+ public static string ReadStableNetworkText(IntPtr parent)
+ {
+ IntPtr edit = FindUniqueRichEdit(parent);
+ string first = ReadTextOnce(edit);
+ string second = ReadTextOnce(edit);
+ if (!String.Equals(first, second, StringComparison.Ordinal))
+ throw new InvalidOperationException("Network text changed during the read.");
+ return first;
+ }
+}
+'@
+}
+
+function Get-ExactWindowDescriptor {
+ param(
+ [Parameter(Mandatory = $true)] [int] $ProcessIdValue,
+ [Parameter(Mandatory = $true)] [string] $ExactTitle,
+ [bool] $RequireVisible
+ )
+ $matches = @([CodexLegacyParityNative20260722]::FindTopLevelWindows([uint32]$ProcessIdValue, $ExactTitle))
+ if ($matches.Count -ne 1) {
+ throw "Expected one '$ExactTitle' window owned by PID $ProcessIdValue; found $($matches.Count)."
+ }
+ $handle = [IntPtr]$matches[0]
+ $visible = [CodexLegacyParityNative20260722]::GetVisible($handle)
+ if ($RequireVisible -and -not $visible) { throw "Pinned '$ExactTitle' window is not visible." }
+ return [ordered]@{
+ handle = $handle.ToInt64().ToString([Globalization.CultureInfo]::InvariantCulture)
+ title = [CodexLegacyParityNative20260722]::GetWindowTitle($handle)
+ visible = $visible
+ }
+}
+
+function Get-ProcessPathSafe {
+ param([Parameter(Mandatory = $true)] [Diagnostics.Process] $Process)
+ try { return $Process.Path } catch { return $null }
+}
+
+function Find-UniqueProcessByPath {
+ param([Parameter(Mandatory = $true)] [string] $ExpectedPath)
+ $fullPath = [IO.Path]::GetFullPath($ExpectedPath)
+ $matches = @()
+ foreach ($candidate in @(Get-Process -ErrorAction Stop)) {
+ $candidatePath = Get-ProcessPathSafe -Process $candidate
+ if ($null -ne $candidatePath -and [string]::Equals(
+ [IO.Path]::GetFullPath($candidatePath), $fullPath, [StringComparison]::OrdinalIgnoreCase)) {
+ $matches += $candidate
+ }
+ }
+ if ($matches.Count -ne 1) { throw "Expected exactly one process at '$fullPath'; found $($matches.Count)." }
+ return $matches[0]
+}
+
+function Get-ProcessDescriptor {
+ param(
+ [Parameter(Mandatory = $true)] [Diagnostics.Process] $Process,
+ [string] $WindowTitle = '',
+ [bool] $RequireVisibleWindow = $false
+ )
+ $path = Get-ProcessPathSafe -Process $Process
+ if ([string]::IsNullOrWhiteSpace($path)) { throw "PID $($Process.Id) has no readable executable path." }
+ $packageFullName = [CodexLegacyParityNative20260722]::GetProcessPackageFullName($Process.Handle)
+ $descriptor = [ordered]@{
+ processId = $Process.Id
+ startUtc = $Process.StartTime.ToUniversalTime().ToString('o')
+ executable = Get-FileIdentity -Path $path
+ packageFullName = $packageFullName
+ }
+ if (-not [string]::IsNullOrWhiteSpace($WindowTitle)) {
+ $descriptor.window = Get-ExactWindowDescriptor -ProcessIdValue $Process.Id -ExactTitle $WindowTitle -RequireVisible $RequireVisibleWindow
+ }
+ return $descriptor
+}
+
+function Get-ListenerDescriptor {
+ param(
+ [Parameter(Mandatory = $true)] [int] $Port,
+ [int] $ExpectedOwner = -1
+ )
+ $listeners = @(Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction Stop)
+ if ($listeners.Count -eq 0) { throw "No TCP listener is present on port $Port." }
+ $owners = @($listeners | Select-Object -ExpandProperty OwningProcess -Unique)
+ if ($owners.Count -ne 1) { throw "TCP port $Port has more than one listener owner." }
+ if ($ExpectedOwner -ge 0 -and $owners[0] -ne $ExpectedOwner) {
+ throw "TCP port $Port is owned by PID $($owners[0]), expected $ExpectedOwner."
+ }
+ $endpoints = @($listeners | ForEach-Object {
+ '{0}:{1}|{2}|{3}' -f $_.LocalAddress, $_.LocalPort, $_.State, $_.OwningProcess
+ } | Sort-Object)
+ return [ordered]@{ port = $Port; ownerProcessId = [int]$owners[0]; endpoints = $endpoints }
+}
+
+function Get-EstablishedPgmConnection {
+ param(
+ [Parameter(Mandatory = $true)] [int] $AppProcessId,
+ [Parameter(Mandatory = $true)] [int] $PgmProcessId,
+ [Parameter(Mandatory = $true)] [int] $PgmPort
+ )
+ $connections = @(Get-NetTCPConnection -State Established -RemotePort $PgmPort -ErrorAction Stop |
+ Where-Object { $_.OwningProcess -eq $AppProcessId })
+ if ($connections.Count -ne 1) {
+ throw "Expected one established app-to-PGM connection owned by PID $AppProcessId; found $($connections.Count)."
+ }
+ $connection = $connections[0]
+ $localAddress = $null
+ $remoteAddress = $null
+ if (-not [Net.IPAddress]::TryParse([string]$connection.LocalAddress, [ref]$localAddress) -or
+ -not [Net.IPAddress]::TryParse([string]$connection.RemoteAddress, [ref]$remoteAddress) -or
+ -not [Net.IPAddress]::IsLoopback($localAddress) -or
+ -not [Net.IPAddress]::IsLoopback($remoteAddress)) {
+ throw 'The app-to-PGM connection is not entirely loopback.'
+ }
+ $reciprocal = @(Get-NetTCPConnection -State Established -LocalPort $PgmPort `
+ -RemotePort $connection.LocalPort -ErrorAction Stop |
+ Where-Object {
+ $_.OwningProcess -eq $PgmProcessId -and
+ [string]$_.LocalAddress -ceq [string]$connection.RemoteAddress -and
+ [string]$_.RemoteAddress -ceq [string]$connection.LocalAddress
+ })
+ if ($reciprocal.Count -ne 1) {
+ throw "Expected one reciprocal PGM-to-app connection owned by PID $PgmProcessId; found $($reciprocal.Count)."
+ }
+ $pgmSide = $reciprocal[0]
+ return [ordered]@{
+ appSide = [ordered]@{
+ ownerProcessId = $connection.OwningProcess
+ localAddress = $connection.LocalAddress
+ localPort = $connection.LocalPort
+ remoteAddress = $connection.RemoteAddress
+ remotePort = $connection.RemotePort
+ state = [string]$connection.State
+ }
+ pgmSide = [ordered]@{
+ ownerProcessId = $pgmSide.OwningProcess
+ localAddress = $pgmSide.LocalAddress
+ localPort = $pgmSide.LocalPort
+ remoteAddress = $pgmSide.RemoteAddress
+ remotePort = $pgmSide.RemotePort
+ state = [string]$pgmSide.State
+ }
+ }
+}
+
+function Get-CdpTargetDescriptor {
+ param([Parameter(Mandatory = $true)] [int] $Port)
+ $targets = @(Invoke-RestMethod -Uri "http://127.0.0.1:$Port/json/list" -Method Get -TimeoutSec 10)
+ $pages = @($targets | Where-Object { $_.type -ceq 'page' -and $_.url -ceq $script:TargetUrl })
+ if ($pages.Count -ne 1) { throw "Expected exactly one CDP parity target; found $($pages.Count)." }
+ $target = $pages[0]
+ if ([string]::IsNullOrWhiteSpace([string]$target.id) -or
+ [string]::IsNullOrWhiteSpace([string]$target.webSocketDebuggerUrl)) {
+ throw 'The parity CDP target is missing an ID or WebSocket endpoint.'
+ }
+ $endpoint = [Uri]::new([string]$target.webSocketDebuggerUrl)
+ if ($endpoint.Scheme -cne 'ws' -or $endpoint.Host -cne '127.0.0.1' -or
+ $endpoint.Port -ne $Port -or $endpoint.AbsolutePath -cne "/devtools/page/$($target.id)" -or
+ -not [string]::IsNullOrEmpty($endpoint.Query) -or -not [string]::IsNullOrEmpty($endpoint.Fragment)) {
+ throw 'The parity CDP WebSocket endpoint identity is not exact.'
+ }
+ return [ordered]@{
+ id = [string]$target.id
+ type = [string]$target.type
+ title = [string]$target.title
+ url = [string]$target.url
+ webSocketDebuggerUrl = [string]$target.webSocketDebuggerUrl
+ }
+}
+
+function Get-CutInventory {
+ param(
+ [Parameter(Mandatory = $true)] [string] $SourceDirectory,
+ [Parameter(Mandatory = $true)] [string] $RuntimeDirectory
+ )
+ $sourceRoot = [IO.Path]::GetFullPath($SourceDirectory)
+ $runtimeRoot = [IO.Path]::GetFullPath($RuntimeDirectory)
+ $items = @()
+ foreach ($code in $script:RouteCodes) {
+ $sourcePath = Join-Path $sourceRoot "$code.t2s"
+ $runtimePath = Join-Path $runtimeRoot "$code.t2s"
+ $source = Get-FileIdentity -Path $sourcePath
+ $runtime = Get-FileIdentity -Path $runtimePath
+ if ($source.sha256 -cne $runtime.sha256 -or $source.length -ne $runtime.length) {
+ throw "Runtime cut $code does not exactly match the authoritative legacy cut."
+ }
+ $items += [ordered]@{
+ code = $code
+ sourcePath = $source.path
+ runtimePath = $runtime.path
+ length = $runtime.length
+ sha256 = $runtime.sha256
+ }
+ }
+ return $items
+}
+
+function Get-AssetInventory {
+ param(
+ [Parameter(Mandatory = $true)] [string] $SourceDirectory,
+ [Parameter(Mandatory = $true)] [string] $RuntimeDirectory
+ )
+ $sourceRoot = [IO.Path]::GetFullPath($SourceDirectory)
+ $runtimeRoot = [IO.Path]::GetFullPath($RuntimeDirectory)
+ $items = @()
+ foreach ($relativePath in $script:S6001AssetRelativePaths) {
+ $source = Get-FileIdentity -Path (Join-Path $sourceRoot $relativePath)
+ $runtime = Get-FileIdentity -Path (Join-Path $runtimeRoot $relativePath)
+ if ($source.sha256 -cne $runtime.sha256 -or $source.length -ne $runtime.length) {
+ throw "Runtime asset $relativePath does not exactly match the authoritative legacy asset."
+ }
+ $items += [ordered]@{
+ relativePath = $relativePath
+ sourcePath = $source.path
+ runtimePath = $runtime.path
+ length = $runtime.length
+ sha256 = $runtime.sha256
+ }
+ }
+ return $items
+}
+
+function Get-CurrentPins {
+ Initialize-NativeTypes
+ if ($DebugPort -le 0 -or $DebugPort -gt 65535) { throw "Invalid CDP port: $DebugPort" }
+ if ($ExpectedPgmPort -le 0 -or $ExpectedPgmPort -gt 65535) { throw "Invalid PGM port: $ExpectedPgmPort" }
+ if ([string]::IsNullOrWhiteSpace($ExpectedPgmExecutable)) {
+ throw 'Pass -ExpectedPgmExecutable or set MBN_STOCK_PGM_EXECUTABLE for runtime checks.'
+ }
+
+ $packages = @(Get-AppxPackage -Name $ExpectedPackageName -ErrorAction Stop |
+ Where-Object { $_.Name -ceq $ExpectedPackageName })
+ if ($packages.Count -ne 1) { throw "Expected one registered package '$ExpectedPackageName'; found $($packages.Count)." }
+ $package = $packages[0]
+ if ($package.IsDevelopmentMode -ne $true -or [string]$package.Architecture -cne 'X64') {
+ throw 'Only the x64 loose Debug development package is authorized for this run.'
+ }
+ $installLocation = [IO.Path]::GetFullPath($package.InstallLocation)
+ $workspaceRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
+ $debugPackageRoot = [IO.Path]::GetFullPath((Join-Path $workspaceRoot `
+ 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Debug'))
+ $debugPrefix = $debugPackageRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
+ if (-not $installLocation.StartsWith($debugPrefix, [StringComparison]::OrdinalIgnoreCase)) {
+ throw "Package InstallLocation is outside the workspace x64 Debug boundary: $installLocation"
+ }
+ $debugRelative = $installLocation.Substring($debugPrefix.Length)
+ $debugSegments = @($debugRelative.Split([IO.Path]::DirectorySeparatorChar) |
+ Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
+ if ($debugSegments.Count -ne 3 -or
+ $debugSegments[0] -notmatch '^net[0-9]+\.[0-9]+-windows[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' -or
+ $debugSegments[1] -cne 'win-x64' -or $debugSegments[2] -cne 'AppX') {
+ throw "Package InstallLocation is not an exact Debug target-framework/win-x64/AppX path: $installLocation"
+ }
+ $appExecutable = Join-Path $installLocation 'MBN_STOCK_WEBVIEW.LegacyParityApp.exe'
+ if (-not [IO.File]::Exists($appExecutable)) { throw "Package app executable was not found under $installLocation." }
+
+ $appProcess = Find-UniqueProcessByPath -ExpectedPath $appExecutable
+ $pgmProcess = Find-UniqueProcessByPath -ExpectedPath $ExpectedPgmExecutable
+ $app = Get-ProcessDescriptor -Process $appProcess -WindowTitle $ExpectedAppWindowTitle -RequireVisibleWindow $true
+ $pgm = Get-ProcessDescriptor -Process $pgmProcess -WindowTitle $ExpectedPgmWindowTitle -RequireVisibleWindow $true
+ if ($app.packageFullName -cne $package.PackageFullName) {
+ throw "App PID $($app.processId) does not carry the registered package identity."
+ }
+ $pgm.networkWindow = Get-ExactWindowDescriptor -ProcessIdValue $pgmProcess.Id `
+ -ExactTitle $ExpectedNetworkWindowTitle -RequireVisible $false
+
+ $pgmListener = Get-ListenerDescriptor -Port $ExpectedPgmPort -ExpectedOwner $pgmProcess.Id
+ $appConnection = Get-EstablishedPgmConnection -AppProcessId $appProcess.Id `
+ -PgmProcessId $pgmProcess.Id -PgmPort $ExpectedPgmPort
+ $cdpListener = Get-ListenerDescriptor -Port $DebugPort
+ $cdpOwnerProcess = Get-Process -Id $cdpListener.ownerProcessId -ErrorAction Stop
+ $cdpOwner = Get-ProcessDescriptor -Process $cdpOwnerProcess
+ $cdpTarget = Get-CdpTargetDescriptor -Port $DebugPort
+
+ $runtimeFiles = @()
+ foreach ($relative in @(
+ 'MBN_STOCK_WEBVIEW.LegacyParityApp.exe',
+ 'MBN_STOCK_WEBVIEW.LegacyParityApp.dll',
+ 'MBN_STOCK_WEBVIEW.Playout.dll')) {
+ $runtimeFiles += Get-FileIdentity -Path (Join-Path $installLocation $relative)
+ }
+
+ $nodePath = Get-ResolvedNodeExecutable
+ $cutInventory = Get-CutInventory -SourceDirectory $OriginalCutsDirectory `
+ -RuntimeDirectory (Join-Path $installLocation 'Cuts')
+ $assetInventory = Get-AssetInventory -SourceDirectory $OriginalCutsDirectory `
+ -RuntimeDirectory (Join-Path $installLocation 'Cuts')
+
+ return [ordered]@{
+ schemaVersion = 1
+ package = [ordered]@{
+ name = [string]$package.Name
+ packageFullName = [string]$package.PackageFullName
+ packageFamilyName = [string]$package.PackageFamilyName
+ publisher = [string]$package.Publisher
+ version = $package.Version.ToString()
+ architecture = [string]$package.Architecture
+ isDevelopmentMode = [bool]$package.IsDevelopmentMode
+ installLocation = $installLocation
+ debugPackageRoot = $debugPackageRoot
+ }
+ app = $app
+ pgm = $pgm
+ pgmListener = $pgmListener
+ appToPgmConnection = $appConnection
+ cdp = [ordered]@{
+ listener = $cdpListener
+ owner = $cdpOwner
+ target = $cdpTarget
+ }
+ runtimeFiles = $runtimeFiles
+ cutInventory = $cutInventory
+ assetInventory = $assetInventory
+ tools = [ordered]@{
+ wrapper = Get-FileIdentity -Path $PSCommandPath
+ node = Get-FileIdentity -Path $nodePath
+ nodeStage = Get-FileIdentity -Path $script:NodeStageScript
+ }
+ }
+}
+
+function Assert-PinsExact {
+ param([Parameter(Mandatory = $true)] [object] $Pinned)
+ $current = Get-CurrentPins
+ $expectedJson = Get-CanonicalJson -Value $Pinned
+ $currentJson = Get-CanonicalJson -Value $current
+ if ($currentJson -cne $expectedJson) {
+ throw 'A pinned package, process, executable hash, window, listener, connection, CDP target, cut, or tool identity changed.'
+ }
+ return $current
+}
+
+function Get-NetworkCounts {
+ param([Parameter(Mandatory = $true)] [AllowEmptyString()] [string] $Text)
+ $lines = @($Text -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
+ return [ordered]@{
+ helloRequest = @($lines | Where-Object { $_ -match '\[R\]\s+HELLO\b' }).Count
+ helloSuccess = @($lines | Where-Object { $_ -match '\[S\]\s+SUCCESS\s+HELLO\b' }).Count
+ loadSceneRequest = @($lines | Where-Object { $_ -match '\[R\]\s+LOAD_SCENE\b' }).Count
+ loadSceneSuccess = @($lines | Where-Object { $_ -match '\[S\]\s+SUCCESS\s+LOAD_SCENE\b' }).Count
+ prepareRequest = @($lines | Where-Object { $_ -match '\[R\]\s+SCENE_PREPARE\b' }).Count
+ prepareSuccess = @($lines | Where-Object { $_ -match '\[S\]\s+SUCCESS\s+SCENE_PREPARE\b' }).Count
+ playRequest = @($lines | Where-Object { $_ -match '\[R\]\s+PLAY\b' }).Count
+ playSuccess = @($lines | Where-Object { $_ -match '\[S\]\s+SUCCESS\s+PLAY\b' }).Count
+ scenePlayed = @($lines | Where-Object { $_ -match '\[A\]\s+SCENE_PLAYED\b' }).Count
+ unloadRequest = @($lines | Where-Object { $_ -match '\[R\]\s+UNLOAD_SCENE\b' }).Count
+ unloadSuccess = @($lines | Where-Object { $_ -match '\[S\]\s+SUCCESS\s+UNLOAD_SCENE\b' }).Count
+ failure = @($lines | Where-Object { $_ -match '\bFAILURE\b' }).Count
+ error = @($lines | Where-Object { $_ -match '\bERROR\b' }).Count
+ }
+}
+
+function Read-StableNetworkText {
+ param([Parameter(Mandatory = $true)] [IntPtr] $NetworkWindow)
+ $lastError = $null
+ for ($attempt = 1; $attempt -le 8; $attempt += 1) {
+ try { return [CodexLegacyParityNative20260722]::ReadStableNetworkText($NetworkWindow) }
+ catch {
+ $lastError = $_
+ Start-Sleep -Milliseconds 125
+ }
+ }
+ throw $lastError
+}
+
+function Capture-WindowImage {
+ param(
+ [Parameter(Mandatory = $true)] [IntPtr] $Window,
+ [Parameter(Mandatory = $true)] [string] $Path
+ )
+ if ([IO.File]::Exists($Path)) { throw "Evidence image already exists: $Path" }
+ $rect = New-Object CodexLegacyParityNative20260722+Rect
+ if (-not [CodexLegacyParityNative20260722]::GetWindowRect($Window, [ref]$rect)) {
+ throw 'Window rectangle could not be read.'
+ }
+ $width = $rect.Right - $rect.Left
+ $height = $rect.Bottom - $rect.Top
+ if ($width -lt 100 -or $height -lt 100 -or $width -gt 8000 -or $height -gt 5000) {
+ throw "Window rectangle is unsafe: ${width}x${height}."
+ }
+ # CopyFromScreen is required for the DirectX PGM surface, but it otherwise
+ # records an unrelated foreground window when the target is occluded. Raise
+ # only this exact pinned window without activating it, allow one repaint,
+ # capture, and restore its original top-most state in a finally block.
+ $wasTopMost = [CodexLegacyParityNative20260722]::IsTopMost($Window)
+ if (-not [CodexLegacyParityNative20260722]::SetTopMostForCapture($Window, $true)) {
+ throw 'The exact evidence window could not be raised for an unobscured capture.'
+ }
+ Start-Sleep -Milliseconds 250
+ $bitmap = $null
+ $graphics = $null
+ try {
+ $bitmap = [Drawing.Bitmap]::new($width, $height)
+ $graphics = [Drawing.Graphics]::FromImage($bitmap)
+ $graphics.CopyFromScreen($rect.Left, $rect.Top, 0, 0, $bitmap.Size)
+ $bitmap.Save($Path, [Drawing.Imaging.ImageFormat]::Png)
+ } finally {
+ if ($null -ne $graphics) { $graphics.Dispose() }
+ if ($null -ne $bitmap) { $bitmap.Dispose() }
+ if (-not $wasTopMost -and
+ -not [CodexLegacyParityNative20260722]::SetTopMostForCapture($Window, $false)) {
+ throw 'The evidence window top-most state could not be restored.'
+ }
+ }
+}
+
+function Get-NetworkDelta {
+ param(
+ [Parameter(Mandatory = $true)] [string] $Current,
+ [string] $PreviousPath = ''
+ )
+ if ([string]::IsNullOrWhiteSpace($PreviousPath)) {
+ return [ordered]@{ anchorMatched = $null; anchorLength = 0; delta = $Current }
+ }
+ $previousFullPath = [IO.Path]::GetFullPath($PreviousPath)
+ if (-not [IO.File]::Exists($previousFullPath)) { throw "Previous Network evidence is missing: $previousFullPath" }
+ $previous = [IO.File]::ReadAllText($previousFullPath, [Text.Encoding]::UTF8)
+ if ($previous.Length -eq 0) {
+ return [ordered]@{ anchorMatched = $true; anchorLength = 0; delta = $Current }
+ }
+ $anchorLength = [Math]::Min(4096, $previous.Length)
+ $anchor = $previous.Substring($previous.Length - $anchorLength)
+ $first = $Current.IndexOf($anchor, [StringComparison]::Ordinal)
+ $last = $Current.LastIndexOf($anchor, [StringComparison]::Ordinal)
+ if ($first -lt 0 -or $first -ne $last) {
+ throw 'The rolling Network Monitoring anchor is missing or non-unique.'
+ }
+ return [ordered]@{
+ anchorMatched = $true
+ anchorLength = $anchorLength
+ delta = $Current.Substring($first + $anchorLength)
+ }
+}
+
+function Capture-ReadOnlyEvidence {
+ param(
+ [Parameter(Mandatory = $true)] [object] $Pins,
+ [Parameter(Mandatory = $true)] [string] $OutputDirectory,
+ [string] $PreviousNetworkPath = ''
+ )
+ Initialize-NativeTypes
+ $directory = [IO.Path]::GetFullPath($OutputDirectory)
+ if (-not [IO.Directory]::Exists($directory)) { throw "Evidence stage directory is missing: $directory" }
+
+ $pgmImage = Join-Path $directory 'pgm-screen.png'
+ $appImage = Join-Path $directory 'app-screen.png'
+ $networkPath = Join-Path $directory 'network-full.txt'
+ $networkDeltaPath = Join-Path $directory 'network-delta.txt'
+ $summaryPath = Join-Path $directory 'capture.json'
+
+ $pgmWindow = [IntPtr]::new([long]$Pins.pgm.window.handle)
+ $appWindow = [IntPtr]::new([long]$Pins.app.window.handle)
+ $networkWindow = [IntPtr]::new([long]$Pins.pgm.networkWindow.handle)
+
+ # Capture the PGM frame first. Capture-WindowImage temporarily changes only
+ # the pinned window's z-order; it never focuses, restores, clicks, or sends a
+ # command to either application.
+ Capture-WindowImage -Window $pgmWindow -Path $pgmImage
+ Capture-WindowImage -Window $appWindow -Path $appImage
+ $networkText = Read-StableNetworkText -NetworkWindow $networkWindow
+ Write-NewText -Path $networkPath -Text $networkText
+
+ $deltaResult = Get-NetworkDelta -Current $networkText -PreviousPath $PreviousNetworkPath
+ Write-NewText -Path $networkDeltaPath -Text $deltaResult.delta
+ $summary = [ordered]@{
+ schemaVersion = 1
+ capturedAtUtc = [DateTime]::UtcNow.ToString('o')
+ previousNetworkPath = $(if ([string]::IsNullOrWhiteSpace($PreviousNetworkPath)) { $null } else { [IO.Path]::GetFullPath($PreviousNetworkPath) })
+ anchorMatched = $deltaResult.anchorMatched
+ anchorLength = $deltaResult.anchorLength
+ fullCharacters = $networkText.Length
+ deltaCharacters = $deltaResult.delta.Length
+ counts = Get-NetworkCounts -Text $deltaResult.delta
+ pgmImagePath = $pgmImage
+ appImagePath = $appImage
+ networkFullPath = $networkPath
+ networkDeltaPath = $networkDeltaPath
+ }
+ Write-NewJson -Path $summaryPath -Value $summary
+ return $summary
+}
+
+function Get-WorkflowStages {
+ param([Parameter(Mandatory = $true)] [string] $Name)
+ if ($Name -ceq 'Recovery8001') {
+ return @([pscustomobject]@{ action = 'take-out-recovery-8001'; routeIndex = 19; pageIndex = $null })
+ }
+ if ($Name -ceq 'Recovery5077Page2') {
+ return @([pscustomobject]@{ action = 'take-out-recovery-5077-page2'; routeIndex = $null; pageIndex = 2 })
+ }
+ if ($Name -ceq 'ThirtyRoutes20To29') {
+ $resume = @([pscustomobject]@{ action = 'resume-30-manifest'; routeIndex = $null; pageIndex = $null })
+ for ($index = 20; $index -lt 30; $index += 1) {
+ foreach ($actionName in @('activate-30', 'take-in-30-fast', 'wait-30', 'take-out-30')) {
+ $resume += [pscustomobject]@{ action = $actionName; routeIndex = $index; pageIndex = $null }
+ }
+ }
+ return $resume
+ }
+ $items = @([pscustomobject]@{ action = 'bootstrap'; routeIndex = $null; pageIndex = $null })
+ switch ($Name) {
+ 'Core5001' {
+ foreach ($actionName in @('setup-5001-5074', 'take-in-5001', 'next-5074', 'page-next-5074', 'take-out-5074')) {
+ $items += [pscustomobject]@{ action = $actionName; routeIndex = $null; pageIndex = $null }
+ }
+ }
+ 'ThirtyRoutes' {
+ foreach ($actionName in @('build-30-base', 'build-30-comparison', 'build-30-financial', 'build-30-manual')) {
+ $items += [pscustomobject]@{ action = $actionName; routeIndex = $null; pageIndex = $null }
+ }
+ for ($index = 0; $index -lt 30; $index += 1) {
+ foreach ($actionName in @('activate-30', 'take-in-30-fast', 'wait-30', 'take-out-30')) {
+ $items += [pscustomobject]@{ action = $actionName; routeIndex = $index; pageIndex = $null }
+ }
+ }
+ }
+ 'PagedBoundary' {
+ $items += [pscustomobject]@{ action = 'build-paged'; routeIndex = $null; pageIndex = $null }
+ $items += [pscustomobject]@{ action = 'take-in-5077'; routeIndex = $null; pageIndex = $null }
+ for ($page = 1; $page -le 19; $page += 1) {
+ $items += [pscustomobject]@{ action = 'page-next-5077'; routeIndex = $null; pageIndex = $page }
+ }
+ $items += [pscustomobject]@{ action = 'next-5088'; routeIndex = $null; pageIndex = $null }
+ $items += [pscustomobject]@{ action = 'take-out-5088'; routeIndex = $null; pageIndex = $null }
+ }
+ 'S6001AssetIndependent' {
+ $items += [pscustomobject]@{ action = 'build-s6001-asset-independent'; routeIndex = $null; pageIndex = $null }
+ for ($index = 0; $index -lt 4; $index += 1) {
+ foreach ($actionName in @('activate-s6001', 'take-in-s6001', 'wait-s6001', 'take-out-s6001')) {
+ $items += [pscustomobject]@{ action = $actionName; routeIndex = $index; pageIndex = $null }
+ }
+ }
+ }
+ default { throw "Unknown workflow: $Name" }
+ }
+ return $items
+}
+
+function Get-SuccessfulStageRecords {
+ param([Parameter(Mandatory = $true)] [string] $Directory)
+ $records = @()
+ foreach ($child in @(Get-ChildItem -LiteralPath $Directory -Directory -ErrorAction Stop |
+ Where-Object { $_.Name -match '^(?!0000-)\d{4}-' } | Sort-Object Name)) {
+ $successPath = Join-Path $child.FullName 'SUCCESS.json'
+ if ([IO.File]::Exists($successPath)) {
+ $records += (Get-Content -LiteralPath $successPath -Raw -Encoding UTF8 | ConvertFrom-Json)
+ }
+ }
+ return $records
+}
+
+function Assert-CompletedStageSequence {
+ param(
+ [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [object[]] $Records,
+ [Parameter(Mandatory = $true)] [object[]] $ExpectedStages
+ )
+ if ($Records.Count -gt $ExpectedStages.Count) { throw 'Evidence contains more successful stages than the workflow.' }
+ for ($index = 0; $index -lt $Records.Count; $index += 1) {
+ $record = $Records[$index]
+ $expected = $ExpectedStages[$index]
+ $recordRoute = if ($null -ne $record.stage.routeIndex) { [int]$record.stage.routeIndex } else { $null }
+ $recordPage = if ($null -ne $record.stage.pageIndex) { [int]$record.stage.pageIndex } else { $null }
+ if ([int]$record.sequence -ne ($index + 1) -or
+ [string]$record.stage.action -cne [string]$expected.action -or
+ $recordRoute -ne $expected.routeIndex -or $recordPage -ne $expected.pageIndex) {
+ throw "Successful evidence sequence is not exact at index $index."
+ }
+ }
+}
+
+function Get-PreviousNetworkPath {
+ param([Parameter(Mandatory = $true)] [string] $Directory)
+ $candidates = @(Get-ChildItem -LiteralPath $Directory -Recurse -File -Filter 'network-full.txt' -ErrorAction Stop |
+ Sort-Object FullName)
+ if ($candidates.Count -eq 0) { return '' }
+ return $candidates[-1].FullName
+}
+
+function New-StopLatch {
+ param(
+ [Parameter(Mandatory = $true)] [string] $Directory,
+ [Parameter(Mandatory = $true)] [string] $Reason,
+ [string] $StageDirectory = '',
+ [object] $Details = $null
+ )
+ $path = Join-Path $Directory 'STOPPED.json'
+ if ([IO.File]::Exists($path)) { return }
+ $record = [ordered]@{
+ schemaVersion = 1
+ stoppedAtUtc = [DateTime]::UtcNow.ToString('o')
+ reason = $Reason
+ stageDirectory = $(if ([string]::IsNullOrWhiteSpace($StageDirectory)) { $null } else { $StageDirectory })
+ details = $Details
+ rule = 'No later playout command may be dispatched from this run directory.'
+ }
+ try { Write-NewJson -Path $path -Value $record } catch { if (-not [IO.File]::Exists($path)) { throw } }
+}
+
+function ConvertTo-NodeArgumentString {
+ param([Parameter(Mandatory = $true)] [object[]] $Values)
+ $quoted = foreach ($value in $Values) {
+ $text = [string]$value
+ if ($text.Contains('"') -or $text.Contains([char]0)) {
+ throw 'A Node stage argument contains a forbidden character.'
+ }
+ '"' + $text + '"'
+ }
+ return ($quoted -join ' ')
+}
+
+function Invoke-NodeStage {
+ param(
+ [Parameter(Mandatory = $true)] [object] $Manifest,
+ [Parameter(Mandatory = $true)] [object] $ExpectedStage,
+ [Parameter(Mandatory = $true)] [string] $OutputDirectory
+ )
+ $stdoutPath = Join-Path $OutputDirectory 'node-stdout.json'
+ $stderrPath = Join-Path $OutputDirectory 'node-stderr.json'
+ $nodePath = [string]$Manifest.pins.tools.node.path
+ $nodeScriptPath = [string]$Manifest.pins.tools.nodeStage.path
+ $arguments = @($nodeScriptPath, '--port', [string]$Manifest.debugPort, '--action', [string]$ExpectedStage.action)
+ if ($null -ne $ExpectedStage.routeIndex) { $arguments += @('--route-index', [string]$ExpectedStage.routeIndex) }
+ if ($null -ne $ExpectedStage.pageIndex) { $arguments += @('--page-index', [string]$ExpectedStage.pageIndex) }
+
+ $start = [Diagnostics.ProcessStartInfo]::new()
+ $start.FileName = $nodePath
+ $start.WorkingDirectory = $PSScriptRoot
+ $start.UseShellExecute = $false
+ $start.CreateNoWindow = $true
+ $start.RedirectStandardOutput = $true
+ $start.RedirectStandardError = $true
+ $start.Arguments = ConvertTo-NodeArgumentString -Values $arguments
+
+ $process = [Diagnostics.Process]::new()
+ $process.StartInfo = $start
+ try {
+ if (-not $process.Start()) { throw 'The Node stage process did not start.' }
+ $stdoutTask = $process.StandardOutput.ReadToEndAsync()
+ $stderrTask = $process.StandardError.ReadToEndAsync()
+ $exited = $process.WaitForExit($StageTimeoutSeconds * 1000)
+ if (-not $exited) {
+ $terminated = $false
+ try {
+ $process.Kill()
+ $terminated = $process.WaitForExit(5000)
+ } catch { }
+ if ($terminated) {
+ $process.WaitForExit()
+ Write-NewText -Path $stdoutPath -Text $stdoutTask.GetAwaiter().GetResult()
+ Write-NewText -Path $stderrPath -Text $stderrTask.GetAwaiter().GetResult()
+ }
+ return [ordered]@{
+ ok = $false
+ timedOut = $true
+ exitCode = $null
+ parsed = $null
+ error = "Node stage exceeded $StageTimeoutSeconds seconds. Outcome is unknown."
+ stdoutPath = $stdoutPath
+ stderrPath = $stderrPath
+ }
+ }
+
+ $process.WaitForExit()
+ $exitCode = [int]$process.ExitCode
+ $stdout = $stdoutTask.GetAwaiter().GetResult()
+ $stderr = $stderrTask.GetAwaiter().GetResult()
+ Write-NewText -Path $stdoutPath -Text $stdout
+ Write-NewText -Path $stderrPath -Text $stderr
+ $stdout = $stdout.Trim()
+ $stderr = $stderr.Trim()
+ $parsed = $null
+ $parseError = $null
+ try {
+ $candidate = if (-not [string]::IsNullOrWhiteSpace($stdout)) { $stdout } else { $stderr }
+ if ([string]::IsNullOrWhiteSpace($candidate)) { throw 'Node stage returned no JSON.' }
+ $parsed = $candidate | ConvertFrom-Json
+ } catch { $parseError = $_.Exception.Message }
+ $ok = $exitCode -eq 0 -and $null -ne $parsed -and $parsed.ok -eq $true
+ return [ordered]@{
+ ok = $ok
+ timedOut = $false
+ exitCode = $exitCode
+ parsed = $parsed
+ error = $parseError
+ stdoutPath = $stdoutPath
+ stderrPath = $stderrPath
+ }
+ } finally {
+ $process.Dispose()
+ }
+}
+
+function Invoke-StaticAudit {
+ $nodePath = Get-ResolvedNodeExecutable
+ if (-not [IO.File]::Exists($script:NodeStageScript)) { throw "Node stage script is missing: $script:NodeStageScript" }
+ $tokens = $null
+ $errors = $null
+ [Management.Automation.Language.Parser]::ParseFile($PSCommandPath, [ref]$tokens, [ref]$errors) | Out-Null
+ if ($errors.Count -ne 0) { throw "PowerShell parser reported $($errors.Count) error(s): $($errors[0].Message)" }
+
+ $nodeCheckOutput = @(& $nodePath --check $script:NodeStageScript 2>&1)
+ if ($LASTEXITCODE -ne 0) { throw "Node syntax check failed: $($nodeCheckOutput -join [Environment]::NewLine)" }
+ $nodeSource = [IO.File]::ReadAllText($script:NodeStageScript, [Text.Encoding]::UTF8)
+ foreach ($required in @('OUTCOME_UNKNOWN', 'TARGET_IDENTITY_MISMATCH', 'take-in-30-fast', 'page-next-5077', 'take-out-recovery-5077-page2', 'build-s6001-asset-independent', 'take-in-s6001')) {
+ if (-not $nodeSource.Contains($required)) { throw "Node stage script is missing required safety token: $required" }
+ }
+
+ $sourceRoot = [IO.Path]::GetFullPath($OriginalCutsDirectory)
+ $cuts = @()
+ foreach ($code in $script:RouteCodes) {
+ $path = Join-Path $sourceRoot "$code.t2s"
+ $cuts += Get-FileIdentity -Path $path
+ }
+ $assets = @()
+ foreach ($relativePath in $script:S6001AssetRelativePaths) {
+ $assets += Get-FileIdentity -Path (Join-Path $sourceRoot $relativePath)
+ }
+ $workflowCounts = [ordered]@{}
+ foreach ($name in @('Core5001', 'ThirtyRoutes', 'PagedBoundary', 'S6001AssetIndependent', 'Recovery8001', 'Recovery5077Page2', 'ThirtyRoutes20To29')) {
+ $workflowCounts[$name] = @(Get-WorkflowStages -Name $name).Count
+ }
+ return [ordered]@{
+ ok = $true
+ audit = 'static-only'
+ auditedAtUtc = [DateTime]::UtcNow.ToString('o')
+ wrapper = Get-FileIdentity -Path $PSCommandPath
+ node = Get-FileIdentity -Path $nodePath
+ nodeStage = Get-FileIdentity -Path $script:NodeStageScript
+ authoritativeCutCount = $cuts.Count
+ # OrderedDictionary keys are not projected as ordinary properties by
+ # Select-Object in Windows PowerShell 5.1. Preserve the validated
+ # identity dictionaries directly so the evidence cannot silently emit
+ # null paths or hashes.
+ authoritativeCutHashes = @($cuts)
+ authoritativeAssetCount = $assets.Count
+ authoritativeAssetHashes = @($assets)
+ workflowStageCounts = $workflowCounts
+ runtimeProcessesQueried = $false
+ cdpAttached = $false
+ nativeMessagesSent = 0
+ }
+}
+
+function Resolve-EvidenceDirectory {
+ if ([string]::IsNullOrWhiteSpace($EvidenceDirectory)) {
+ throw "-EvidenceDirectory is required for action '$Action'."
+ }
+ return [IO.Path]::GetFullPath($EvidenceDirectory)
+}
+
+function Invoke-InitializeRun {
+ $directory = Resolve-EvidenceDirectory
+ if ([IO.Directory]::Exists($directory) -or [IO.File]::Exists($directory)) {
+ throw "Initialization never overwrites an existing evidence path: $directory"
+ }
+ $pins = Get-CurrentPins
+ [IO.Directory]::CreateDirectory($directory) | Out-Null
+ $baselineDirectory = Join-Path $directory '0000-baseline'
+ [IO.Directory]::CreateDirectory($baselineDirectory) | Out-Null
+ $manifest = [ordered]@{
+ schemaVersion = 1
+ runId = [IO.Path]::GetFileName($directory)
+ initializedAtUtc = [DateTime]::UtcNow.ToString('o')
+ workflow = $Workflow
+ debugPort = $DebugPort
+ targetUrl = $script:TargetUrl
+ discovery = [ordered]@{
+ packageName = $ExpectedPackageName
+ pgmExecutable = [string]$pins.pgm.executable.path
+ appWindowTitle = [string]$pins.app.window.title
+ pgmWindowTitle = [string]$pins.pgm.window.title
+ networkWindowTitle = [string]$pins.pgm.networkWindow.title
+ originalCutsDirectory = [IO.Path]::GetFullPath($OriginalCutsDirectory)
+ nodeExecutable = [string]$pins.tools.node.path
+ pgmPort = [int]$pins.pgmListener.port
+ }
+ rules = @(
+ 'One Node invocation dispatches at most one user-visible playout command.',
+ 'No playout command is retried after timeout, OutcomeUnknown, or identity mismatch.',
+ 'STOPPED.json permanently blocks later commands in this evidence directory.',
+ 'Playlist rows are built in memory; no named playlist fixture is loaded.'
+ )
+ expectedStages = Get-WorkflowStages -Name $Workflow
+ pins = $pins
+ baselineNetworkPath = Join-Path $baselineDirectory 'network-full.txt'
+ }
+ Write-NewJson -Path (Join-Path $directory 'manifest.json') -Value $manifest
+ try {
+ $capture = Capture-ReadOnlyEvidence -Pins $pins -OutputDirectory $baselineDirectory
+ Assert-PinsExact -Pinned $pins | Out-Null
+ Write-NewJson -Path (Join-Path $baselineDirectory 'SUCCESS.json') -Value ([ordered]@{
+ initializedAtUtc = [DateTime]::UtcNow.ToString('o')
+ capture = $capture
+ })
+ } catch {
+ New-StopLatch -Directory $directory -Reason 'Initialization evidence or post-capture identity validation failed.' `
+ -StageDirectory $baselineDirectory -Details $_.Exception.Message
+ throw
+ }
+ return $manifest
+}
+
+function Read-RunManifest {
+ param([Parameter(Mandatory = $true)] [string] $Directory)
+ $manifestPath = Join-Path $Directory 'manifest.json'
+ if (-not [IO.File]::Exists($manifestPath)) { throw "Run manifest is missing: $manifestPath" }
+ $manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
+ if ($manifest.schemaVersion -ne 1 -or $manifest.targetUrl -cne $script:TargetUrl -or
+ $null -eq $manifest.discovery) {
+ throw 'Run manifest schema or target identity is unsupported.'
+ }
+ return $manifest
+}
+
+function Use-ManifestDiscoverySettings {
+ param([Parameter(Mandatory = $true)] [object] $Manifest)
+ $script:DebugPort = [int]$Manifest.debugPort
+ $script:ExpectedPgmPort = [int]$Manifest.discovery.pgmPort
+ $script:ExpectedPackageName = [string]$Manifest.discovery.packageName
+ $script:ExpectedPgmExecutable = [string]$Manifest.discovery.pgmExecutable
+ $script:ExpectedAppWindowTitle = [string]$Manifest.discovery.appWindowTitle
+ $script:ExpectedPgmWindowTitle = [string]$Manifest.discovery.pgmWindowTitle
+ $script:ExpectedNetworkWindowTitle = [string]$Manifest.discovery.networkWindowTitle
+ $script:OriginalCutsDirectory = [string]$Manifest.discovery.originalCutsDirectory
+ $script:NodeExecutable = [string]$Manifest.discovery.nodeExecutable
+}
+
+function Invoke-InspectRun {
+ $directory = Resolve-EvidenceDirectory
+ $manifest = Read-RunManifest -Directory $directory
+ Use-ManifestDiscoverySettings -Manifest $manifest
+ $current = Assert-PinsExact -Pinned $manifest.pins
+ $completed = @(Get-SuccessfulStageRecords -Directory $directory)
+ $expected = @(Get-WorkflowStages -Name ([string]$manifest.workflow))
+ Assert-CompletedStageSequence -Records $completed -ExpectedStages $expected
+ return [ordered]@{
+ ok = $true
+ action = 'Inspect'
+ stopped = [IO.File]::Exists((Join-Path $directory 'STOPPED.json'))
+ completedStageCount = $completed.Count
+ totalStageCount = $expected.Count
+ nextStage = $(if ($completed.Count -lt $expected.Count) { $expected[$completed.Count] } else { $null })
+ pins = $current
+ inspectedAtUtc = [DateTime]::UtcNow.ToString('o')
+ nativeMessagesSent = 0
+ }
+}
+
+function Invoke-RunStage {
+ param([bool] $UseExpectedStage = $false)
+ $directory = Resolve-EvidenceDirectory
+ $stopPath = Join-Path $directory 'STOPPED.json'
+ if ([IO.File]::Exists($stopPath)) {
+ throw "This evidence run is permanently stopped. Inspect: $stopPath"
+ }
+ $manifest = Read-RunManifest -Directory $directory
+ Use-ManifestDiscoverySettings -Manifest $manifest
+ if (-not $UseExpectedStage -and [string]::IsNullOrWhiteSpace($Stage)) { throw '-Stage is required for RunStage.' }
+
+ $lockPath = Join-Path $directory 'RUNNING.lock'
+ $lockStream = $null
+ try {
+ $lockStream = [IO.File]::Open($lockPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
+ } catch {
+ throw "Another stage is running, or an interrupted lock requires inspection: $lockPath"
+ }
+
+ try {
+ $completed = @(Get-SuccessfulStageRecords -Directory $directory)
+ $expectedStages = @(Get-WorkflowStages -Name ([string]$manifest.workflow))
+ Assert-CompletedStageSequence -Records $completed -ExpectedStages $expectedStages
+ if ($completed.Count -ge $expectedStages.Count) { throw 'All workflow stages are already complete.' }
+ $expected = $expectedStages[$completed.Count]
+ if (-not $UseExpectedStage) {
+ $requestedRoute = if ($RouteIndex -ge 0) { $RouteIndex } else { $null }
+ $requestedPage = if ($PageIndex -ge 0) { $PageIndex } else { $null }
+ if ($Stage -cne $expected.action -or $requestedRoute -ne $expected.routeIndex -or $requestedPage -ne $expected.pageIndex) {
+ throw "Out-of-order stage request. Expected action=$($expected.action), routeIndex=$($expected.routeIndex), pageIndex=$($expected.pageIndex)."
+ }
+ }
+
+ $sequence = $completed.Count + 1
+ $safeStage = $expected.action -replace '[^a-z0-9-]', '-'
+ $stageDirectory = Join-Path $directory ('{0:D4}-{1}-{2}' -f $sequence, $safeStage, [Guid]::NewGuid().ToString('N').Substring(0, 8))
+ [IO.Directory]::CreateDirectory($stageDirectory) | Out-Null
+ Write-NewJson -Path (Join-Path $stageDirectory 'REQUEST.json') -Value ([ordered]@{
+ sequence = $sequence
+ requestedAtUtc = [DateTime]::UtcNow.ToString('o')
+ stage = $expected
+ })
+
+ try {
+ $beforePins = Assert-PinsExact -Pinned $manifest.pins
+ Write-NewJson -Path (Join-Path $stageDirectory 'pins-before.json') -Value $beforePins
+ } catch {
+ New-StopLatch -Directory $directory -Reason 'Pinned runtime identity changed before stage dispatch.' `
+ -StageDirectory $stageDirectory -Details $_.Exception.Message
+ throw
+ }
+
+ $nodeResult = Invoke-NodeStage -Manifest $manifest -ExpectedStage $expected -OutputDirectory $stageDirectory
+ Write-NewJson -Path (Join-Path $stageDirectory 'node-result.json') -Value $nodeResult
+
+ $postIdentityError = $null
+ try {
+ $afterPins = Assert-PinsExact -Pinned $manifest.pins
+ Write-NewJson -Path (Join-Path $stageDirectory 'pins-after.json') -Value $afterPins
+ } catch { $postIdentityError = $_.Exception.Message }
+
+ $capture = $null
+ $captureError = $null
+ if ([string]::IsNullOrWhiteSpace($postIdentityError)) {
+ try {
+ $previousNetworkPath = Get-PreviousNetworkPath -Directory $directory
+ # Exclude the current stage file if an earlier partial capture somehow exists.
+ if ($previousNetworkPath -like "$stageDirectory*") {
+ $previousCandidates = @(Get-ChildItem -LiteralPath $directory -Recurse -File -Filter 'network-full.txt' |
+ Where-Object { $_.FullName -notlike "$stageDirectory*" } | Sort-Object FullName)
+ $previousNetworkPath = if ($previousCandidates.Count -gt 0) { $previousCandidates[-1].FullName } else { '' }
+ }
+ $capture = Capture-ReadOnlyEvidence -Pins $manifest.pins -OutputDirectory $stageDirectory `
+ -PreviousNetworkPath $previousNetworkPath
+ $afterCapturePins = Assert-PinsExact -Pinned $manifest.pins
+ Write-NewJson -Path (Join-Path $stageDirectory 'pins-after-capture.json') -Value $afterCapturePins
+ } catch { $captureError = $_.Exception.Message }
+ } else {
+ $captureError = 'Capture was skipped because the pinned runtime identity had already changed.'
+ }
+
+ $failureReasons = @()
+ if (-not $nodeResult.ok) { $failureReasons += 'Node one-shot stage failed, timed out, or returned invalid JSON.' }
+ if (-not [string]::IsNullOrWhiteSpace($postIdentityError)) { $failureReasons += 'Pinned runtime identity changed after stage execution.' }
+ if (-not [string]::IsNullOrWhiteSpace($captureError)) { $failureReasons += 'Read-only evidence capture or rolling Network anchor validation failed.' }
+ if ($failureReasons.Count -gt 0) {
+ $failure = [ordered]@{
+ failedAtUtc = [DateTime]::UtcNow.ToString('o')
+ reasons = $failureReasons
+ node = $nodeResult
+ postIdentityError = $postIdentityError
+ captureError = $captureError
+ capture = $capture
+ }
+ Write-NewJson -Path (Join-Path $stageDirectory 'FAILURE.json') -Value $failure
+ New-StopLatch -Directory $directory -Reason ($failureReasons -join ' ') `
+ -StageDirectory $stageDirectory -Details $failure
+ throw "Stage failed and the run is now stopped: $($failureReasons -join ' ')"
+ }
+
+ $success = [ordered]@{
+ schemaVersion = 1
+ sequence = $sequence
+ completedAtUtc = [DateTime]::UtcNow.ToString('o')
+ stage = $expected
+ node = $nodeResult.parsed
+ capture = $capture
+ }
+ Write-NewJson -Path (Join-Path $stageDirectory 'SUCCESS.json') -Value $success
+ return $success
+ } finally {
+ if ($null -ne $lockStream) { $lockStream.Dispose() }
+ if ([IO.File]::Exists($lockPath)) { [IO.File]::Delete($lockPath) }
+ }
+}
+
+switch ($Action) {
+ 'StaticAudit' {
+ Invoke-StaticAudit | ConvertTo-Json -Depth 40 -Compress
+ }
+ 'DryPreflight' {
+ $pins = Get-CurrentPins
+ [ordered]@{
+ ok = $true
+ action = 'DryPreflight'
+ inspectedAtUtc = [DateTime]::UtcNow.ToString('o')
+ pins = $pins
+ evidenceWritten = $false
+ cdpAttached = $false
+ nativeMessagesSent = 0
+ } | ConvertTo-Json -Depth 40 -Compress
+ }
+ 'Initialize' {
+ Invoke-InitializeRun | ConvertTo-Json -Depth 40 -Compress
+ }
+ 'Inspect' {
+ Invoke-InspectRun | ConvertTo-Json -Depth 40 -Compress
+ }
+ 'RunStage' {
+ Invoke-RunStage -UseExpectedStage $false | ConvertTo-Json -Depth 40 -Compress
+ }
+ 'RunNext' {
+ Invoke-RunStage -UseExpectedStage $true | ConvertTo-Json -Depth 40 -Compress
+ }
+ default { throw "Unsupported action: $Action" }
+}
diff --git a/scripts/LegacyPgmParityStage.mjs b/scripts/LegacyPgmParityStage.mjs
new file mode 100644
index 0000000..891285d
--- /dev/null
+++ b/scripts/LegacyPgmParityStage.mjs
@@ -0,0 +1,1408 @@
+#!/usr/bin/env node
+
+/**
+ * One-shot CDP driver for the development-live legacy parity surface.
+ *
+ * The companion PowerShell wrapper pins the package/app/PGM identities and calls
+ * this script once per user-visible action. This driver deliberately has no
+ * retry loop for playout commands. A timeout, OutcomeUnknown state, unexpected
+ * native message, or probe replacement makes the invocation fail so that the
+ * wrapper can latch the run in a stopped state.
+ */
+
+const PROBE_KEY = '__codexLegacyPgmParityProbe';
+const PROBE_VERSION = '2026-07-22.2';
+const DEFAULT_PORT = 9339;
+const TARGET_URL = 'https://legacy-parity.mbn.local/index.html';
+const RPC_TIMEOUT_MS = 15_000;
+const STATE_TIMEOUT_MS = 180_000;
+const BUILD_TIMEOUT_MS = 45_000;
+
+const THIRTY_CODES = [
+ '5016', '50160', '5078', '8067', '5086', '50860', '5023', '5024', '5077',
+ '5082', '5083', '5084', '5085', '5088', '6067', '8035', '5011', '8003',
+ '5037', '8001', '5026', '5087', '5029', '8032', '5032', '5076', '5079',
+ '5080', '5081', '5025',
+];
+
+const THIRTY_MANIFEST = [
+ ['5016', '\ubbf8\uad6d \uc9c0\uc218[\ub2e4\uc6b0, \ub098\uc2a4\ub2e5, S&P]', '3\uc5f4\ud310'],
+ ['50160', '\ub18d\uc0b0\ubb3c[\uc6d0\uba74, \ubaa9\ud654(\uba74\ud654)]', '2\uc5f4\ud310-'],
+ ['5078', '\ub2e4\uc6b0', '\ubbf8\uad6d\uc5c5\uc885'],
+ ['8067', '\uae00\ub85c\ubc8c \uc99d\uc2dc \uc9c0\ub3c4', '\uae00\ub85c\ubc8c \uc99d\uc2dc \uc9c0\ub3c4'],
+ ['5086', '\uc6d0\ub2ec\ub7ec_5\uc77c', '\uc218\uc775\ub960 \uadf8\ub798\ud504'],
+ ['50860', '\uc6d0\ub2ec\ub7ec_5\uc77c', '\ub77c\uc778 \uadf8\ub798\ud504'],
+ ['5023', '\ucf54\uc2a4\ud53c \ub9e4\ub9e4\ub3d9\ud5a5_\ud45c\uadf8\ub798\ud504', '\ub9e4\ub9e4\ub3d9\ud5a5'],
+ ['5024', '\ucf54\uc2a4\ud53c \ub9e4\ub9e4\ub3d9\ud5a5_\ub9c9\ub300\uadf8\ub798\ud504', '\ub9e4\ub9e4\ub3d9\ud5a5'],
+ ['5077', '\ucf54\uc2a4\ud53c \uc2dc\uac00\ucd1d\uc561', '6\uc885\ubaa9 \ud604\uc7ac\uac00'],
+ ['5082', '\uc8fc\uccb4\ubcc4 \ub9e4\ub9e4\ub3d9\ud5a5_\ud45c\uadf8\ub798\ud504', '\ub9e4\ub9e4\ub3d9\ud5a5'],
+ ['5083', '\uac1c\uc778 \ub9e4\ub9e4\ub3d9\ud5a5_\ub77c\uc778\uadf8\ub798\ud504', '\ub9e4\ub9e4\ub3d9\ud5a5'],
+ ['5084', '\ucf54\uc2a4\ud53c \ub9e4\ub9e4\ub3d9\ud5a5_\ub77c\uc778\uadf8\ub798\ud504', '\ub9e4\ub9e4\ub3d9\ud5a5'],
+ ['5085', '\ud504\ub85c\uadf8\ub7a8 \ub9e4\ub9e4_\ud45c\uadf8\ub798\ud504', '\ub9e4\ub9e4\ub3d9\ud5a5'],
+ ['5088', '\ucf54\uc2a4\ud53c \uc2dc\uac00\ucd1d\uc561', '12\uc885\ubaa9 \ud604\uc7ac\uac00'],
+ ['6067', '\uae30\uad00 \uc21c\ub9e4\uc218 \ud604\ud669_\ud45c\uadf8\ub798\ud504', '\ub9e4\ub9e4\ub3d9\ud5a5'],
+ ['8035', '\uce94\ub4e4\uadf8\ub798\ud504_\uc77c\ubd09', '\ucf54\uc2a4\ud53c'],
+ ['5011', '\uc0bc\uc131\uc804\uc790', '1\uc5f4\ud310\uc0c1\uc138'],
+ ['8003', '\uc0bc\uc131\uc804\uc790', '\ud638\uac00\ucc3d'],
+ ['5037', '\uc0bc\uc131\uc804\uc790', '\uac70\ub798\uc6d0'],
+ ['8001', '\ucf54\uc2a4\ud53c \ub124\ubaa8\uadf8\ub798\ud504', '\ub124\ubaa8\uadf8\ub798\ud504'],
+ ['5026', '\uc0bc\uc131\uc99d\uad8c \u2194 \uc0bc\uc131\ucd9c\ud310\uc0ac', 's5026'],
+ ['5087', '\uc0bc\uc131\uc99d\uad8c \u2194 \uc0bc\uc131\ucd9c\ud310\uc0ac', 's5087'],
+ ['5029', '\uc0bc\uc131\uc99d\uad8c \u2194 \uc0bc\uc131\ucd9c\ud310\uc0ac', 's5029'],
+ ['8032', '\ucf54\uc2a4\ub2e5 \uc9c0\uc218 \u2194 \ucf54\uc2a4\ud53c \uc9c0\uc218', 's8018'],
+ ['5032', '\uc120\ubb3c \uc9c0\uc218 \u2194 \ucf54\uc2a4\ud53c \uc9c0\uc218', 's5032'],
+ ['5076', 'APS', '\uc8fc\uc694\ub9e4\ucd9c \uad6c\uc131'],
+ ['5079', 'AJ\ub124\ud2b8\uc6cd\uc2a4', '\uc131\uc7a5\uc131 \uc9c0\ud45c'],
+ ['5080', '3S', '\ub9e4\ucd9c\uc561'],
+ ['5081', '3S', '\uc601\uc5c5\uc774\uc775'],
+ ['5025', '\uac1c\uc778 \uc21c\ub9e4\ub3c4 \uc0c1\uc704(\uc218\ub3d9)', '\uc21c\ub9e4\ub3c4 \uc0c1\uc704'],
+].map(([code, stockName, graphicType]) => ({ code, stockName, graphicType }));
+
+const ROUTE_ROW_IDENTITIES = new Map(THIRTY_MANIFEST.map(item => [item.code, item]));
+const FIXED_VISIBLE_ROW_IDENTITIES = new Map([
+ ['5016', { market: '\ud574\uc678\uc9c0\uc218', stockName: '\ubbf8\uad6d \uc9c0\uc218[\ub2e4\uc6b0, \ub098\uc2a4\ub2e5, S&P]', graphicType: '3\uc5f4\ud310', subtype: '' }],
+ ['50160', { market: '\ud574\uc678\uc9c0\uc218', stockName: '\ub18d\uc0b0\ubb3c[\uc6d0\uba74, \ubaa9\ud654(\uba74\ud654)]', graphicType: '2\uc5f4\ud310-', subtype: '' }],
+ ['5078', { market: '\ud574\uc678\uc9c0\uc218', stockName: '\ub2e4\uc6b0', graphicType: '\ubbf8\uad6d\uc5c5\uc885', subtype: '' }],
+ ['8067', { market: '\ud574\uc678\uc9c0\uc218', stockName: '\uae00\ub85c\ubc8c \uc99d\uc2dc \uc9c0\ub3c4', graphicType: '\uae00\ub85c\ubc8c \uc99d\uc2dc \uc9c0\ub3c4', subtype: '' }],
+ ['5086', { market: '\ud658\uc728', stockName: '\uc6d0\ub2ec\ub7ec', graphicType: '\uc218\uc775\ub960 \uadf8\ub798\ud504', subtype: '5\uc77c' }],
+ ['50860', { market: '\ud658\uc728', stockName: '\uc6d0\ub2ec\ub7ec', graphicType: '\ub77c\uc778 \uadf8\ub798\ud504', subtype: '5\uc77c' }],
+ ['5023', { market: '\ucf54\uc2a4\ud53c', stockName: '\uc9c0\uc218', graphicType: '\ucf54\uc2a4\ud53c \ub9e4\ub9e4\ub3d9\ud5a5', subtype: '\ud45c\uadf8\ub798\ud504' }],
+ ['5024', { market: '\ucf54\uc2a4\ud53c', stockName: '\uc9c0\uc218', graphicType: '\ucf54\uc2a4\ud53c \ub9e4\ub9e4\ub3d9\ud5a5', subtype: '\ub9c9\ub300\uadf8\ub798\ud504' }],
+ ['5077', { market: '\ucf54\uc2a4\ud53c', stockName: '\uc9c0\uc218', graphicType: '6\uc885\ubaa9 \ud604\uc7ac\uac00', subtype: '\ucf54\uc2a4\ud53c \uc2dc\uac00\ucd1d\uc561' }],
+ ['5082', { market: '\uc804\uccb4', stockName: '\uc9c0\uc218', graphicType: '\uc8fc\uccb4\ubcc4 \ub9e4\ub9e4\ub3d9\ud5a5', subtype: '\ud45c\uadf8\ub798\ud504' }],
+ ['5083', { market: '\uc804\uccb4', stockName: '\uc9c0\uc218', graphicType: '\uac1c\uc778 \ub9e4\ub9e4\ub3d9\ud5a5', subtype: '\ub77c\uc778\uadf8\ub798\ud504' }],
+ ['5084', { market: '\ucf54\uc2a4\ud53c', stockName: '\uc9c0\uc218', graphicType: '\ucf54\uc2a4\ud53c \ub9e4\ub9e4\ub3d9\ud5a5', subtype: '\ub77c\uc778\uadf8\ub798\ud504' }],
+ ['5085', { market: '\uc804\uccb4', stockName: '\uc9c0\uc218', graphicType: '\ud504\ub85c\uadf8\ub7a8 \ub9e4\ub9e4', subtype: '\ud45c\uadf8\ub798\ud504' }],
+ ['5088', { market: '\ucf54\uc2a4\ud53c', stockName: '\uc9c0\uc218', graphicType: '12\uc885\ubaa9 \ud604\uc7ac\uac00', subtype: '\ucf54\uc2a4\ud53c \uc2dc\uac00\ucd1d\uc561' }],
+ ['6067', { market: '\uc804\uccb4', stockName: '\uc9c0\uc218', graphicType: '\uae30\uad00 \uc21c\ub9e4\uc218 \ud604\ud669', subtype: '\ud45c\uadf8\ub798\ud504' }],
+ ['8035', { market: '\ucf54\uc2a4\ud53c', stockName: '\uc9c0\uc218', graphicType: '\uce94\ub4e4\uadf8\ub798\ud504', subtype: '\uc77c\ubd09' }],
+]);
+for (const [code, identity] of FIXED_VISIBLE_ROW_IDENTITIES) {
+ ROUTE_ROW_IDENTITIES.set(code, { code, ...identity });
+}
+ROUTE_ROW_IDENTITIES.set('5001', {
+ code: '5001', stockName: '\uc0bc\uc131\uc804\uc790', graphicType: '1\uc5f4\ud310\uae30\ubcf8', subtype: '\ud604\uc7ac\uac00',
+});
+ROUTE_ROW_IDENTITIES.set('5074', {
+ code: '5074', stockName: '\ucf54\uc2a4\ud53c 5\ub2e8 \ud45c\uadf8\ub798\ud504', graphicType: '5\ub2e8 \ud45c\uadf8\ub798\ud504',
+});
+
+const FIXED_ACTIONS = [
+ ['overseas', 'fixed-047'], ['overseas', 'fixed-044'],
+ ['overseas', 'fixed-076'], ['overseas', 'fixed-063'],
+ ['exchange', 'fixed-085'], ['exchange', 'fixed-105'],
+ ['index', 'fixed-234'], ['index', 'fixed-236'], ['index', 'fixed-275'],
+ ['index', 'fixed-233'], ['index', 'fixed-240'], ['index', 'fixed-235'],
+ ['index', 'fixed-244'], ['index', 'fixed-302'], ['index', 'fixed-243'],
+ ['index', 'fixed-136'],
+];
+
+const FINANCIAL_ACTIONS = [
+ ['revenue-composition', 'APS'],
+ ['growth-metrics', 'AJ\ub124\ud2b8\uc6cd\uc2a4'],
+ ['sales', '3S'],
+ ['operating-profit', '3S'],
+];
+
+const S6001_ASSET_INDEPENDENT_ACTIONS = [
+ { actionId: 'fixed-072', stockName: '\uc720\uac00 \ub450\ubc14\uc774 \uc9c0\uc218', subtype: 'DubaiCrude' },
+ { actionId: 'fixed-073', stockName: '\uc720\uac00 WTI \uc9c0\uc218', subtype: 'WtiCrude' },
+ { actionId: 'fixed-074', stockName: '\uc720\uac00 \ube0c\ub80c\ud2b8\uc720 \uc9c0\uc218', subtype: 'BrentCrude' },
+ { actionId: 'fixed-075', stockName: '\uae08 \uc9c0\uc218', subtype: 'Gold' },
+];
+
+const ROW_ID_PATTERN = /^legacy-(?:fixed|stock|industry|comparison|manual-financial|manual-list)-\d{8}$/;
+
+function fail(message, code = 'STAGE_FAILED', details = undefined) {
+ const error = new Error(message);
+ error.code = code;
+ error.details = details;
+ throw error;
+}
+
+function parseArguments(argv) {
+ const result = { port: DEFAULT_PORT, action: 'snapshot', routeIndex: null, pageIndex: null };
+ for (let index = 2; index < argv.length; index += 1) {
+ const token = argv[index];
+ const value = argv[index + 1];
+ if (token === '--port' && value) {
+ result.port = Number(value);
+ index += 1;
+ } else if (token === '--action' && value) {
+ result.action = value;
+ index += 1;
+ } else if (token === '--route-index' && value) {
+ result.routeIndex = Number(value);
+ index += 1;
+ } else if (token === '--page-index' && value) {
+ result.pageIndex = Number(value);
+ index += 1;
+ } else {
+ fail(`Unknown or incomplete argument: ${token}`, 'INVALID_ARGUMENT');
+ }
+ }
+ if (!Number.isInteger(result.port) || result.port <= 0 || result.port > 65535) {
+ fail(`Invalid CDP port: ${result.port}`, 'INVALID_ARGUMENT');
+ }
+ return result;
+}
+
+async function fetchJson(url, timeoutMs = RPC_TIMEOUT_MS) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ const response = await fetch(url, { signal: controller.signal });
+ if (!response.ok) {
+ fail(`HTTP ${response.status} from ${url}`, 'CDP_DISCOVERY_FAILED');
+ }
+ return await response.json();
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+async function openTarget(port) {
+ const targets = await fetchJson(`http://127.0.0.1:${port}/json/list`);
+ const pages = targets.filter((target) => target.type === 'page' && target.url === TARGET_URL);
+ if (pages.length !== 1) {
+ fail(`Expected exactly one parity page at ${TARGET_URL}; found ${pages.length}.`, 'TARGET_IDENTITY_MISMATCH', {
+ targets: targets.map(({ id, type, url, title }) => ({ id, type, url, title })),
+ });
+ }
+ if (!pages[0].webSocketDebuggerUrl) {
+ fail('Parity page has no WebSocket debugger URL.', 'TARGET_IDENTITY_MISMATCH');
+ }
+ const endpoint = new URL(pages[0].webSocketDebuggerUrl);
+ if (endpoint.protocol !== 'ws:' || endpoint.hostname !== '127.0.0.1' ||
+ Number(endpoint.port) !== port || endpoint.pathname !== `/devtools/page/${pages[0].id}` ||
+ endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
+ fail('Parity page WebSocket endpoint identity is not exact.', 'TARGET_IDENTITY_MISMATCH', {
+ id: pages[0].id,
+ webSocketDebuggerUrl: pages[0].webSocketDebuggerUrl,
+ });
+ }
+ return pages[0];
+}
+
+class CdpClient {
+ constructor(url) {
+ this.url = url;
+ this.socket = null;
+ this.sequence = 0;
+ this.pending = new Map();
+ }
+
+ async connect() {
+ this.socket = new WebSocket(this.url);
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error('CDP WebSocket connection timed out.')), RPC_TIMEOUT_MS);
+ this.socket.addEventListener('open', () => {
+ clearTimeout(timer);
+ resolve();
+ }, { once: true });
+ this.socket.addEventListener('error', () => {
+ clearTimeout(timer);
+ reject(new Error('CDP WebSocket connection failed.'));
+ }, { once: true });
+ });
+ this.socket.addEventListener('message', (event) => {
+ const message = JSON.parse(event.data);
+ if (!message.id) return;
+ const pending = this.pending.get(message.id);
+ if (!pending) return;
+ this.pending.delete(message.id);
+ clearTimeout(pending.timer);
+ if (message.error) pending.reject(new Error(message.error.message || 'CDP RPC failed.'));
+ else pending.resolve(message.result);
+ });
+ this.socket.addEventListener('close', () => {
+ for (const pending of this.pending.values()) {
+ clearTimeout(pending.timer);
+ pending.reject(new Error('CDP WebSocket closed while an RPC was pending.'));
+ }
+ this.pending.clear();
+ });
+ }
+
+ send(method, params = {}, timeoutMs = RPC_TIMEOUT_MS) {
+ const id = ++this.sequence;
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ this.pending.delete(id);
+ const error = new Error(`CDP RPC timed out: ${method}`);
+ error.code = 'OUTCOME_UNKNOWN';
+ reject(error);
+ }, timeoutMs);
+ this.pending.set(id, { resolve, reject, timer });
+ this.socket.send(JSON.stringify({ id, method, params }));
+ });
+ }
+
+ async evaluate(expression, awaitPromise = true, timeoutMs = RPC_TIMEOUT_MS) {
+ const result = await this.send('Runtime.evaluate', {
+ expression,
+ awaitPromise,
+ returnByValue: true,
+ userGesture: true,
+ }, timeoutMs);
+ if (result.exceptionDetails) {
+ const description = result.exceptionDetails.exception?.description
+ || result.exceptionDetails.text
+ || 'Runtime.evaluate failed.';
+ throw new Error(description);
+ }
+ return result.result?.value;
+ }
+
+ close() {
+ if (this.socket && this.socket.readyState <= WebSocket.OPEN) this.socket.close();
+ }
+}
+
+const probeInstallerSource = String.raw`
+(() => {
+ const key = ${JSON.stringify(PROBE_KEY)};
+ const version = ${JSON.stringify(PROBE_VERSION)};
+ const current = globalThis[key];
+ if (current) {
+ if (current.version !== version || window.chrome.webview.postMessage !== current.wrapper) {
+ throw new Error('Parity probe exists but its version or wrapper identity changed.');
+ }
+ return { installed: false, version: current.version };
+ }
+
+ if (globalThis.__legacyDevelopmentLiveProbe) {
+ throw new Error('A different development-live probe is already installed; start a fresh app instance.');
+ }
+
+ if (!window.chrome?.webview) throw new Error('WebView bridge is unavailable.');
+ const original = window.chrome.webview.postMessage;
+ const allowed = new Set([
+ 'ready', 'select-tab', 'activate-fixed-action', 'activate-playlist-row',
+ 'search-stocks', 'select-stock', 'cut-pointer-down', 'drop-selected-cuts',
+ 'activate-industry-action', 'search-comparison-domestic',
+ 'choose-comparison-domestic', 'choose-comparison-fixed',
+ 'clear-comparison-targets', 'add-comparison-pair', 'select-comparison-pair',
+ 'delete-selected-comparison-pair', 'activate-comparison-action',
+ 'open-manual-financial', 'search-manual-financial',
+ 'activate-manual-financial-result', 'close-manual-financial',
+ 'add-manual-net-sell-cut', 'close-manual-list',
+ 'take-in', 'next-playout', 'take-out'
+ ]);
+ const playout = new Set(['take-in', 'next-playout', 'take-out']);
+ const fixedIds = new Set([
+ 'fixed-047', 'fixed-044', 'fixed-076', 'fixed-063', 'fixed-085', 'fixed-105',
+ 'fixed-234', 'fixed-236', 'fixed-275', 'fixed-233', 'fixed-240', 'fixed-235',
+ 'fixed-244', 'fixed-302', 'fixed-243', 'fixed-136', 'fixed-245'
+ ]);
+ const tabs = new Set(['overseas', 'exchange', 'index', 'kospiIndustry', 'comparison']);
+ const comparisonActions = new Set(['comparison-candle', 'comparison-line', 'return-5d', 'two-column-current']);
+ const financialScreens = new Set(['revenue-composition', 'growth-metrics', 'sales', 'operating-profit']);
+ const financialQueries = new Set(['APS', 'AJ\ub124\ud2b8\uc6cd\uc2a4', '3S']);
+ const blocked = [];
+ const outbound = [];
+ const violations = [];
+ const nativeStates = [];
+ let state = null;
+
+ const validToken = (value, max = 160) => typeof value === 'string' && value.length > 0 && value.length <= max &&
+ !Array.from(value).some(character => {
+ const code = character.charCodeAt(0);
+ return code < 32 || code === 127 || code === 0xfffd;
+ });
+ const empty = payload => payload && typeof payload === 'object' && !Array.isArray(payload) && Object.keys(payload).length === 0;
+ const validate = (message) => {
+ if (!message || typeof message !== 'object' || !validToken(message.type, 64) || !allowed.has(message.type)) return false;
+ const payload = message.payload;
+ switch (message.type) {
+ case 'ready': case 'drop-selected-cuts': case 'clear-comparison-targets':
+ case 'add-comparison-pair': case 'delete-selected-comparison-pair':
+ case 'close-manual-financial': case 'add-manual-net-sell-cut':
+ case 'close-manual-list': case 'take-in': case 'next-playout':
+ case 'take-out': return empty(payload);
+ case 'select-tab': return tabs.has(payload?.tabId);
+ case 'activate-fixed-action': return fixedIds.has(payload?.actionId);
+ case 'activate-playlist-row': return /^legacy-(?:fixed|stock|industry|comparison|manual-financial|manual-list)-[0-9]{8}$/u.test(payload?.rowId || '');
+ case 'search-stocks': return payload?.text === '\uc0bc\uc131\uc804\uc790' && payload?.trigger === 'button';
+ case 'select-stock': return Number.isInteger(payload?.resultIndex) && payload.resultIndex >= 0;
+ case 'cut-pointer-down': return [1, 5, 31, 32].includes(payload?.physicalIndex) &&
+ payload?.control === false && payload?.shift === false && payload?.allowAppend === true &&
+ Number.isFinite(payload?.timestampMilliseconds);
+ case 'activate-industry-action': return ['five-row', 'square'].includes(payload?.actionId);
+ case 'search-comparison-domestic': return payload?.query === '\uc0bc\uc131';
+ case 'choose-comparison-domestic': return validToken(payload?.selectionId);
+ case 'choose-comparison-fixed': return ['kospi', 'kosdaq', 'futures'].includes(payload?.targetId);
+ case 'select-comparison-pair': return /^comparison-pair-[0-9]{8}$/u.test(payload?.rowId || '');
+ case 'activate-comparison-action': return comparisonActions.has(payload?.actionId);
+ case 'open-manual-financial': return financialScreens.has(payload?.screen);
+ case 'search-manual-financial': return financialQueries.has(payload?.query);
+ case 'activate-manual-financial-result': return validToken(payload?.resultId);
+ default: return false;
+ }
+ };
+
+ const wrapper = (message) => {
+ const payload = message?.payload;
+ if (!validate(message)) {
+ blocked.push({ at: new Date().toISOString(), reason: 'not-allowlisted', type: message?.type || null, payload });
+ throw new Error('Blocked unexpected native message.');
+ }
+ outbound.push({ at: new Date().toISOString(), type: message.type, payload: structuredClone(payload) });
+ if (outbound.length > 1200) outbound.shift();
+ return original.call(window.chrome.webview, message);
+ };
+
+ window.chrome.webview.postMessage = wrapper;
+ window.chrome.webview.addEventListener('message', (event) => {
+ const message = event.data;
+ if (!message || message.type !== 'state' || !message.payload) return;
+ state = structuredClone(message.payload);
+ nativeStates.push({ at: new Date().toISOString(), state });
+ if (nativeStates.length > 500) nativeStates.shift();
+ if (!state.playout || state.playout.outcomeUnknown === true) {
+ violations.push({ at: new Date().toISOString(), revision: state.revision ?? null,
+ reason: !state.playout ? 'missing-playout' : 'outcome-unknown' });
+ }
+ });
+
+ globalThis[key] = {
+ version, original, wrapper, allowed, playout, blocked, outbound,
+ violations, nativeStates, get state() { return state; }
+ };
+ return { installed: true, version };
+})()
+`;
+
+function js(value) {
+ return JSON.stringify(value);
+}
+
+function normalizeValue(value) {
+ return value == null ? null : String(value);
+}
+
+async function installOrVerifyProbe(client) {
+ return await client.evaluate(probeInstallerSource);
+}
+
+async function snapshot(client) {
+ return await client.evaluate(`(() => {
+ const key = ${js(PROBE_KEY)};
+ const probe = globalThis[key];
+ const app = document.querySelector('main.operator-shell');
+ const native = probe?.state || null;
+ const playlist = native?.playlist || [];
+ const activeNative = playlist.find(row => row.isActive) || null;
+ const playout = native?.playout || null;
+ const settled = Boolean(playout) && native?.isBusy !== true && document.body.getAttribute('aria-busy') !== 'true' &&
+ playout.isBusy !== true && playout.isPlayCompletionPending !== true &&
+ playout.isTakeOutCompletionPending !== true && playout.refreshActive !== true;
+ const normalize = (value) => (value == null ? null : String(value));
+ return {
+ href: location.href,
+ readyState: document.readyState,
+ appPresent: Boolean(app),
+ bodyBusy: document.body.getAttribute('aria-busy'),
+ probe: probe ? {
+ version: probe.version,
+ wrapperExact: window.chrome.webview.postMessage === probe.wrapper,
+ blockedCount: probe.blocked.length,
+ violationCount: probe.violations.length,
+ outboundCount: probe.outbound.length,
+ lastOutbound: probe.outbound.slice(-8),
+ violations: probe.violations.slice(-8)
+ } : null,
+ buttons: Object.fromEntries(['playout-take-in', 'playout-next', 'playout-take-out']
+ .map(id => [id, {
+ disabled: document.getElementById(id)?.disabled === true,
+ hidden: document.getElementById(id)?.hidden === true
+ }])),
+ state: native ? {
+ revision: native.revision ?? null,
+ isBusy: native.isBusy === true,
+ status: normalize(native.statusMessage),
+ statusKind: normalize(native.statusKind),
+ dialog: native.dialog ? structuredClone(native.dialog) : null,
+ commandResult: native.commandResult ? structuredClone(native.commandResult) : null,
+ mode: normalize(playout?.mode),
+ connectionState: normalize(playout?.connectionState),
+ connected: playout?.connectionState === 'connected',
+ isConnected: playout?.isConnected === true,
+ isCommandAvailable: playout?.isCommandAvailable === true,
+ phase: normalize(playout?.phase),
+ settled,
+ pendingCommand: playout?.isPlayCompletionPending === true ? 'play-completion'
+ : playout?.isTakeOutCompletionPending === true ? 'take-out-completion'
+ : playout?.refreshActive === true ? 'refresh' : null,
+ outcomeUnknown: playout?.outcomeUnknown === true,
+ liveTakeInAllowed: playout?.liveTakeInAllowed === true,
+ currentCode: normalize(playout?.onAirCode),
+ preparedCode: normalize(playout?.preparedCode),
+ currentCueIndex: Number.isInteger(playout?.currentCueIndexZeroBased) ? playout.currentCueIndexZeroBased : -1,
+ currentEntryId: normalize(playout?.currentEntryId),
+ nextKind: normalize(playout?.nextKind),
+ refreshCount: Number(playout?.refreshCompletedCount || 0),
+ refreshLimitReached: playout?.refreshLimitReached === true,
+ activeRowId: normalize(activeNative?.rowId),
+ pageText: normalize(activeNative?.pageText),
+ pageIndex: Number.isInteger(playout?.pageIndexZeroBased) ? playout.pageIndexZeroBased + 1 : 0,
+ pageCount: Number(playout?.pageCount || 0),
+ pageSize: Number(playout?.pageSize || 0),
+ itemCount: Number(playout?.itemCount || 0),
+ currentPageItemCount: Number(playout?.currentPageItemCount || 0),
+ playout: playout ? structuredClone(playout) : null,
+ rows: playlist.map((row) => ({
+ id: normalize(row.rowId), graphicCode: normalize(row.sceneAlias ?? row.cutCode),
+ market: normalize(row.marketText), name: normalize(row.stockName),
+ graphicType: normalize(row.graphicType), subtype: normalize(row.subtype),
+ pageText: normalize(row.pageText), enabled: row.isEnabled === true,
+ active: row.isActive === true, selected: row.isSelected === true,
+ resolved: row.isSceneResolved === true
+ })),
+ active: activeNative ? {
+ id: normalize(activeNative.rowId), graphicCode: normalize(activeNative.sceneAlias ?? activeNative.cutCode),
+ market: normalize(activeNative.marketText), name: normalize(activeNative.stockName),
+ graphicType: normalize(activeNative.graphicType), subtype: normalize(activeNative.subtype),
+ pageText: normalize(activeNative.pageText)
+ } : null,
+ tabs: (native.tabs || []).map(tab => ({ id: normalize(tab.id), active: tab.isActive === true })),
+ searchResults: (native.searchResults || []).map((item) => ({
+ index: Number(item.index), name: normalize(item.displayName)
+ })),
+ selectedStockIndex: Number.isInteger(native.selectedStockIndex) ? native.selectedStockIndex : null,
+ selectedCuts: (native.cutRows || []).filter(cut => cut.isSelected).map((cut) => ({
+ physicalIndex: Number(cut.physicalIndex), label: normalize(cut.rawLabel)
+ })),
+ cutRows: (native.cutRows || []).map(cut => ({
+ physicalIndex: Number(cut.physicalIndex), label: normalize(cut.rawLabel), selected: cut.isSelected === true
+ })),
+ fixedActions: (native.fixedCatalog?.sections || []).flatMap(section =>
+ (section.actions || []).map(item => ({ id: normalize(item.id), available: item.isAvailable === true }))),
+ industry: native.industry ? structuredClone(native.industry) : null,
+ comparison: native.comparison ? structuredClone(native.comparison) : null,
+ comparisonPairs: (native.comparison?.savedPairs || []).map(pair => ({
+ id: normalize(pair.rowId ?? pair.id ?? pair.pairId),
+ rowNumber: Number(pair.rowNumber),
+ displayLabel: normalize(pair.displayLabel),
+ selected: pair.isSelected === true
+ })),
+ manualFinancial: native.manualFinancial ? structuredClone(native.manualFinancial) : null,
+ manualLists: native.manualLists ? structuredClone(native.manualLists) : null
+ } : null,
+ dom: {
+ connectionText: document.getElementById('playout-connection-state')?.textContent?.trim() || null,
+ statusText: document.getElementById('playout-status')?.textContent?.trim() || null
+ }
+ };
+ })()`);
+}
+
+function assertHealthy(result, { requireIdle = false, allowPending = false, allowMissingState = false } = {}) {
+ if (result.href !== TARGET_URL || !result.appPresent || result.readyState !== 'complete') {
+ fail('Parity WebView document identity is not exact.', 'TARGET_IDENTITY_MISMATCH', result);
+ }
+ if (!result.probe || result.probe.version !== PROBE_VERSION || !result.probe.wrapperExact) {
+ fail('Parity safety probe is missing or replaced.', 'PROBE_IDENTITY_MISMATCH', result.probe);
+ }
+ if (result.probe.blockedCount !== 0 || result.probe.violationCount !== 0) {
+ fail('Parity safety probe recorded a blocked message or state violation.', 'PROBE_VIOLATION', result.probe);
+ }
+ const state = result.state;
+ if (allowMissingState && !state) return;
+ if (!state || state.mode !== 'live' || state.connectionState !== 'connected' ||
+ !state.connected || !state.isConnected) {
+ fail('Development-live state is not connected and authorized.', 'LIVE_STATE_MISMATCH', state);
+ }
+ if (state.dialog != null) fail('An operator dialog is present.', 'OPERATOR_DIALOG_PRESENT', state.dialog);
+ if (state.outcomeUnknown) fail('Native state reports OutcomeUnknown.', 'OUTCOME_UNKNOWN', state);
+ if (!allowPending && (!state.isCommandAvailable || !state.liveTakeInAllowed || state.pendingCommand || !state.settled)) {
+ fail('Native playout state is not settled.', 'PLAYOUT_NOT_SETTLED', state);
+ }
+ if (requireIdle && state.phase !== 'idle') {
+ fail(`Expected IDLE phase; found ${state.phase}.`, 'PLAYOUT_PHASE_MISMATCH', state);
+ }
+}
+
+async function waitFor(client, predicate, description, timeoutMs = STATE_TIMEOUT_MS,
+ { allowMissingState = false } = {}) {
+ const deadline = Date.now() + timeoutMs;
+ let last = null;
+ while (Date.now() < deadline) {
+ last = await snapshot(client);
+ assertHealthy(last, { allowPending: true, allowMissingState });
+ if (predicate(last)) return last;
+ await new Promise((resolve) => setTimeout(resolve, 125));
+ }
+ const error = new Error(`Timed out waiting for ${description}.`);
+ error.code = 'OUTCOME_UNKNOWN';
+ error.details = last;
+ throw error;
+}
+
+async function postAndWait(client, type, payload, predicate, description, timeoutMs = BUILD_TIMEOUT_MS) {
+ const before = await snapshot(client);
+ assertHealthy(before);
+ const beforeRevision = Number(before.state.revision || 0);
+ await client.evaluate(`(() => {
+ window.chrome.webview.postMessage(${js({ type, payload })});
+ return true;
+ })()`);
+ return await waitFor(
+ client,
+ (current) => Number(current.state.revision || 0) > beforeRevision && predicate(current, before),
+ description,
+ timeoutMs,
+ );
+}
+
+async function resolvePoint(client, selector) {
+ const point = await client.evaluate(`(() => {
+ const element = document.querySelector(${js(selector)});
+ if (!element) return null;
+ const rect = element.getBoundingClientRect();
+ const style = getComputedStyle(element);
+ if (rect.width <= 0 || rect.height <= 0 || style.visibility === 'hidden' || style.display === 'none') return null;
+ element.scrollIntoView({ block: 'center', inline: 'center' });
+ const next = element.getBoundingClientRect();
+ return { x: next.left + next.width / 2, y: next.top + next.height / 2 };
+ })()`);
+ if (!point) fail(`Visible click target was not found: ${selector}`, 'CLICK_TARGET_MISSING');
+ return point;
+}
+
+async function physicalClick(client, selector) {
+ const point = await resolvePoint(client, selector);
+ await client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point.x, y: point.y, button: 'none' });
+ await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: point.x, y: point.y, button: 'left', clickCount: 1 });
+ await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: point.x, y: point.y, button: 'left', clickCount: 1 });
+}
+
+async function physicalDoubleClickAndWait(
+ client, selector, predicate, description, timeoutMs = BUILD_TIMEOUT_MS) {
+ const before = await snapshot(client);
+ assertHealthy(before);
+ const outboundBefore = before.probe.outboundCount;
+ const point = await resolvePoint(client, selector);
+ await client.send('Input.dispatchMouseEvent', {
+ type: 'mouseMoved', x: point.x, y: point.y, button: 'none',
+ });
+ await client.send('Input.dispatchMouseEvent', {
+ type: 'mousePressed', x: point.x, y: point.y, button: 'left', clickCount: 1,
+ });
+ await client.send('Input.dispatchMouseEvent', {
+ type: 'mouseReleased', x: point.x, y: point.y, button: 'left', clickCount: 1,
+ });
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ await client.send('Input.dispatchMouseEvent', {
+ type: 'mousePressed', x: point.x, y: point.y, button: 'left', clickCount: 2,
+ });
+ await client.send('Input.dispatchMouseEvent', {
+ type: 'mouseReleased', x: point.x, y: point.y, button: 'left', clickCount: 2,
+ });
+ return await waitFor(client, (current) =>
+ current.probe.outboundCount > outboundBefore && predicate(current, before),
+ description, timeoutMs);
+}
+
+async function physicalClickAndWait(client, selector, predicate, description, timeoutMs = STATE_TIMEOUT_MS) {
+ const before = await snapshot(client);
+ assertHealthy(before);
+ const outboundBefore = before.probe.outboundCount;
+ await physicalClick(client, selector);
+ return await waitFor(client, (current) => {
+ if (current.probe.outboundCount <= outboundBefore) return false;
+ return predicate(current, before);
+ }, description, timeoutMs);
+}
+
+async function physicalDragStockCut(client, physicalIndex, label) {
+ const before = await snapshot(client);
+ assertHealthy(before);
+ const geometry = await client.evaluate(`(() => {
+ const source = document.querySelector('#cut-list .cut-row[data-physical-index=${js(String(physicalIndex))}]');
+ const target = document.getElementById('playlist-drop-zone');
+ if (!source || !target) return null;
+ source.scrollIntoView({ block: 'center', inline: 'center' });
+ const sourceRect = source.getBoundingClientRect();
+ const targetRect = target.getBoundingClientRect();
+ const sourceStyle = getComputedStyle(source);
+ const targetStyle = getComputedStyle(target);
+ if (sourceRect.width <= 0 || sourceRect.height <= 0 || targetRect.width <= 0 || targetRect.height <= 0 ||
+ sourceStyle.visibility === 'hidden' || sourceStyle.display === 'none' ||
+ targetStyle.visibility === 'hidden' || targetStyle.display === 'none') return null;
+ const start = { x: sourceRect.left + sourceRect.width / 2, y: sourceRect.top + sourceRect.height / 2 };
+ return {
+ start,
+ threshold: { x: start.x + 20, y: start.y + 20 },
+ target: { x: targetRect.left + targetRect.width / 2, y: targetRect.top + Math.min(80, targetRect.height / 2) }
+ };
+ })()`);
+ if (!geometry) fail(`Visible stock cut drag target was not found: ${label}`, 'DRAG_TARGET_MISSING');
+
+ await client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...geometry.start, button: 'none' });
+ await client.send('Input.dispatchMouseEvent', {
+ type: 'mousePressed', ...geometry.start, button: 'left', buttons: 1, clickCount: 1,
+ });
+ await client.send('Input.dispatchMouseEvent', {
+ type: 'mouseMoved', ...geometry.threshold, button: 'left', buttons: 1,
+ });
+ await client.send('Input.dispatchMouseEvent', {
+ type: 'mouseMoved', ...geometry.target, button: 'left', buttons: 1,
+ });
+ await client.send('Input.dispatchMouseEvent', {
+ type: 'mouseReleased', ...geometry.target, button: 'left', buttons: 0, clickCount: 1,
+ });
+
+ const result = await waitFor(client, (current) => !current.state.isBusy &&
+ current.state.rows.length === before.state.rows.length + 1, `stock cut drag ${label}`, BUILD_TIMEOUT_MS);
+ const outboundDelta = result.probe.outboundCount - before.probe.outboundCount;
+ const types = result.probe.lastOutbound.slice(-outboundDelta).map(item => item.type);
+ if (outboundDelta !== 2 || types[0] !== 'cut-pointer-down' || types[1] !== 'drop-selected-cuts') {
+ fail(`Stock cut drag did not produce the exact native gesture sequence: ${label}`,
+ 'DRAG_MESSAGE_SEQUENCE_MISMATCH', { outboundDelta, types });
+ }
+ return result;
+}
+
+function assertRowIds(rows) {
+ const invalid = rows.filter((row) => !ROW_ID_PATTERN.test(row.id || ''));
+ if (invalid.length > 0) fail('Playlist contains a non in-memory legacy row identifier.', 'PLAYLIST_ID_MISMATCH', invalid);
+}
+
+function assertExactCodes(result, expected) {
+ if (result.state.rows.length !== expected.length) {
+ fail(`Playlist route count mismatch. Expected ${expected.length}; got ${result.state.rows.length}.`,
+ 'PLAYLIST_ROUTE_MISMATCH', { expected, rows: result.state.rows });
+ }
+ result.state.rows.forEach((row, index) => {
+ assertRowMatchesCode(row, expected[index], index);
+ });
+ assertRowIds(result.state.rows);
+}
+
+function assertRowMatchesCode(row, code, index = null) {
+ const expected = ROUTE_ROW_IDENTITIES.get(code);
+ if (!expected) fail(`No row identity is registered for route ${code}.`, 'PLAYLIST_ROUTE_MISMATCH');
+ if ((expected.market != null && row.market !== expected.market) ||
+ row.name !== expected.stockName || row.graphicType !== expected.graphicType ||
+ (expected.subtype != null && row.subtype !== expected.subtype) || row.enabled !== true || row.resolved !== true) {
+ fail(`Playlist row identity mismatch for route ${code}${index == null ? '' : ` at index ${index}`}.`,
+ 'PLAYLIST_ROW_MISMATCH', { code, expected, row });
+ }
+}
+
+function assertTwoSceneManifest(result) {
+ assertExactCodes(result, ['5001', '5074']);
+ const [first, second] = result.state.rows;
+ if (first.name !== '\uc0bc\uc131\uc804\uc790' || first.market !== '\ucf54\uc2a4\ud53c' ||
+ first.graphicType !== '1\uc5f4\ud310\uae30\ubcf8' || first.subtype !== '\ud604\uc7ac\uac00' ||
+ second.market !== '\uc5c5\uc885_\ucf54\uc2a4\ud53c' || second.name !== '\ucf54\uc2a4\ud53c 5\ub2e8 \ud45c\uadf8\ub798\ud504' ||
+ second.graphicType !== '5\ub2e8 \ud45c\uadf8\ub798\ud504') {
+ fail('5001/5074 playlist row identity mismatch.', 'PLAYLIST_ROW_MISMATCH', result.state.rows);
+ }
+}
+
+function assertThirtyManifest(result) {
+ assertExactCodes(result, THIRTY_CODES);
+ result.state.rows.forEach((row, index) => {
+ if (!row.enabled || !/^1\/(?:1|20)$/u.test(row.pageText || '')) {
+ fail(`30-route playlist page identity mismatch at index ${index}.`, 'PLAYLIST_ROW_MISMATCH', { row });
+ }
+ });
+}
+
+function assertPagedManifest(result) {
+ assertExactCodes(result, ['5077', '5088']);
+ const [first, second] = result.state.rows;
+ if (!/^(?:[1-9]|1[0-9]|20)\/20$/u.test(first.pageText || '') ||
+ second.pageText !== '1/20' || !first.enabled || !second.enabled) {
+ fail('5077/5088 page-boundary playlist identity is not exact.', 'PLAYLIST_PAGE_FIXTURE_MISMATCH', result.state.rows);
+ }
+}
+
+function assertS6001Row(row, expected, index = null) {
+ if (!row || row.market !== '\ud574\uc678\uc9c0\uc218' || row.name !== expected.stockName ||
+ row.graphicType !== '\uc774\ubbf8\uc9c0 \uadf8\ub798\ud504' || row.subtype !== '' ||
+ row.pageText !== '1/1' || row.enabled !== true || row.resolved !== true ||
+ !ROW_ID_PATTERN.test(row.id || '')) {
+ fail(`s6001 playlist row identity mismatch${index == null ? '' : ` at index ${index}`}.`,
+ 'PLAYLIST_ROW_MISMATCH', { expected, row });
+ }
+}
+
+function assertS6001Manifest(result) {
+ if (result.state.rows.length !== S6001_ASSET_INDEPENDENT_ACTIONS.length) {
+ fail('s6001 asset-independent playlist must contain exactly four rows.',
+ 'PLAYLIST_ROUTE_MISMATCH', result.state.rows);
+ }
+ result.state.rows.forEach((row, index) =>
+ assertS6001Row(row, S6001_ASSET_INDEPENDENT_ACTIONS[index], index));
+}
+
+async function selectTab(client, tabId) {
+ const current = await snapshot(client);
+ if (current.state.tabs.some(tab => tab.id === tabId && tab.active)) return current;
+ return await postAndWait(client, 'select-tab', { tabId },
+ (value) => value.state.tabs.some(tab => tab.id === tabId && tab.active) && !value.state.isBusy,
+ `tab ${tabId}`);
+}
+
+async function selectSamsung(client) {
+ const searched = await postAndWait(client, 'search-stocks', { text: '\uc0bc\uc131\uc804\uc790', trigger: 'button' },
+ (current) => !current.state.isBusy &&
+ current.state.searchResults.filter(item => item.name === '\uc0bc\uc131\uc804\uc790').length === 1,
+ 'Samsung stock search');
+ const stock = searched.state.searchResults.find(item => item.name === '\uc0bc\uc131\uc804\uc790');
+ if (!stock || !Number.isInteger(stock.index)) fail('Samsung search result identity is not unique.', 'SEARCH_RESULT_MISMATCH');
+ await postAndWait(client, 'select-stock', { resultIndex: stock.index },
+ (current) => !current.state.isBusy && current.state.selectedStockIndex === stock.index,
+ 'Samsung stock selection');
+}
+
+async function addStockCut(client, physicalIndex, label, expectedCode) {
+ const result = await physicalDragStockCut(client, physicalIndex, label);
+ assertRowMatchesCode(result.state.rows.at(-1), expectedCode);
+}
+
+async function addFixedAction(client, section, actionId, expectedCode) {
+ const current = await selectTab(client, section);
+ if (!current.state.fixedActions.some(item => item.id === actionId && item.available)) {
+ fail(`Fixed action ${section}/${actionId} is unavailable.`, 'FIXED_ACTION_UNAVAILABLE');
+ }
+ const result = await postAndWait(client, 'activate-fixed-action', { actionId },
+ (value, before) => !value.state.isBusy && value.state.rows.length === before.state.rows.length + 1,
+ `fixed action ${section}/${actionId}`);
+ assertRowMatchesCode(result.state.rows.at(-1), expectedCode);
+}
+
+async function addIndustryAction(client, actionId, expectedCode) {
+ const current = await selectTab(client, 'kospiIndustry');
+ if (!current.state.industry?.actions?.some(item => item.actionId === actionId)) {
+ fail(`Industry action ${actionId} is unavailable.`, 'INDUSTRY_ACTION_UNAVAILABLE');
+ }
+ const result = await postAndWait(client, 'activate-industry-action', { actionId },
+ (value, before) => !value.state.isBusy && value.state.rows.length === before.state.rows.length + 1,
+ `industry action ${actionId}`);
+ assertRowMatchesCode(result.state.rows.at(-1), expectedCode);
+}
+
+async function clearComparison(client) {
+ const current = await snapshot(client);
+ if (current.state.comparison?.firstTarget == null && current.state.comparison?.secondTarget == null) return current;
+ return await postAndWait(client, 'clear-comparison-targets', {},
+ (value) => value.state.comparison?.firstTarget == null && value.state.comparison?.secondTarget == null,
+ 'clear comparison targets');
+}
+
+async function chooseComparison(client, type, payload, slot) {
+ return await postAndWait(client, type, payload,
+ (value) => value.state.comparison?.[slot] != null,
+ `comparison ${slot}`);
+}
+
+async function addComparisonAction(client, actionId, expectedCode) {
+ const current = await snapshot(client);
+ if (!current.state.comparison?.actions?.some(item => item.actionId === actionId && item.isAvailable)) {
+ fail(`Comparison action ${actionId} is unavailable.`, 'COMPARISON_ACTION_UNAVAILABLE');
+ }
+ const result = await postAndWait(client, 'activate-comparison-action', { actionId },
+ (value, before) => !value.state.isBusy && value.state.rows.length === before.state.rows.length + 1,
+ `comparison action ${actionId}`);
+ assertRowMatchesCode(result.state.rows.at(-1), expectedCode);
+ return result;
+}
+
+function comparisonPairRows(comparison) {
+ return (comparison?.savedPairs || []).map((pair) => ({
+ rowId: normalizeValue(pair.rowId ?? pair.id ?? pair.pairId),
+ rowNumber: Number(pair.rowNumber),
+ displayLabel: normalizeValue(pair.displayLabel),
+ }));
+}
+
+function comparisonPairsEqual(left, right) {
+ return JSON.stringify(left) === JSON.stringify(right);
+}
+
+async function commitComparisonPair(client, description) {
+ const before = await snapshot(client);
+ assertHealthy(before);
+ const baseline = comparisonPairRows(before.state.comparison);
+ const baselineIds = new Set(baseline.map((pair) => pair.rowId));
+ const result = await postAndWait(client, 'add-comparison-pair', {},
+ (value, before) => !value.state.isBusy &&
+ (value.state.comparison?.savedPairs?.length || 0) === (before.state.comparison?.savedPairs?.length || 0) + 1 &&
+ value.state.comparison?.selectedPairId != null,
+ `comparison pair ${description}`);
+ const after = comparisonPairRows(result.state.comparison);
+ const addedPairId = normalizeValue(result.state.comparison?.selectedPairId);
+ if (!comparisonPairsEqual(after.slice(0, -1), baseline) ||
+ !addedPairId || baselineIds.has(addedPairId) || after.at(-1)?.rowId !== addedPairId) {
+ fail(`Temporary comparison pair identity changed the saved baseline: ${description}`,
+ 'COMPARISON_COMMIT_MISMATCH', { baseline, after, addedPairId });
+ }
+ return { result, baseline, addedPairId };
+}
+
+async function deleteTemporaryComparisonPair(client, description, baseline, addedPairId) {
+ const before = await snapshot(client);
+ assertHealthy(before);
+ if (normalizeValue(before.state.comparison?.selectedPairId) !== addedPairId) {
+ fail(`The temporary comparison pair is no longer selected: ${description}`,
+ 'COMPARISON_TEMP_SELECTION_MISMATCH', before.state.comparison);
+ }
+ const result = await postAndWait(client, 'delete-selected-comparison-pair', {},
+ (value) => !value.state.isBusy &&
+ comparisonPairRows(value.state.comparison).length === baseline.length &&
+ !comparisonPairRows(value.state.comparison).some((pair) => pair.rowId === addedPairId),
+ `comparison pair cleanup ${description}`);
+ const after = comparisonPairRows(result.state.comparison);
+ if (!comparisonPairsEqual(after, baseline)) {
+ fail(`Temporary comparison pair cleanup changed the saved baseline: ${description}`,
+ 'COMPARISON_CLEANUP_MISMATCH', { baseline, after, addedPairId });
+ }
+ return result;
+}
+
+async function restoreComparisonSelection(client, selectedPairId) {
+ const current = await snapshot(client);
+ const currentSelection = normalizeValue(current.state.comparison?.selectedPairId);
+ if (currentSelection === selectedPairId || (!selectedPairId && !currentSelection)) return current;
+ if (!selectedPairId || !comparisonPairRows(current.state.comparison).some((pair) => pair.rowId === selectedPairId)) {
+ fail('The original saved comparison selection cannot be restored.',
+ 'COMPARISON_SELECTION_RESTORE_MISMATCH', { selectedPairId, comparison: current.state.comparison });
+ }
+ return await postAndWait(client, 'select-comparison-pair', { rowId: selectedPairId },
+ (value) => !value.state.isBusy && value.state.comparison?.selectedPairId === selectedPairId,
+ 'restore saved comparison selection');
+}
+
+async function setup5001And5074(client) {
+ const initial = await snapshot(client);
+ assertHealthy(initial, { requireIdle: true });
+ if (initial.state.rows.length !== 0) fail('5001/5074 setup requires an empty in-memory playlist.', 'PLAYLIST_NOT_EMPTY');
+ await selectSamsung(client);
+ await addStockCut(client, 1, '1\uc5f4\ud310\uae30\ubcf8_\ud604\uc7ac\uac00', '5001');
+ await addIndustryAction(client, 'five-row', '5074');
+ const result = await snapshot(client);
+ assertTwoSceneManifest(result);
+ return result;
+}
+
+async function buildThirtyBase(client) {
+ const initial = await snapshot(client);
+ assertHealthy(initial, { requireIdle: true });
+ if (initial.state.rows.length !== 0) fail('30-route base build requires an empty in-memory playlist.', 'PLAYLIST_NOT_EMPTY');
+ for (let index = 0; index < FIXED_ACTIONS.length; index += 1) {
+ await addFixedAction(client, FIXED_ACTIONS[index][0], FIXED_ACTIONS[index][1], THIRTY_CODES[index]);
+ }
+ await selectSamsung(client);
+ await addStockCut(client, 5, '1\uc5f4\ud310\uc0c1\uc138_\uc561\uba74\uac00', '5011');
+ await addStockCut(client, 31, '\ud638\uac00\ucc3d_\ud45c\uadf8\ub798\ud504', '8003');
+ await addStockCut(client, 32, '\uac70\ub798\uc6d0_\ud45c\uadf8\ub798\ud504', '5037');
+ await addIndustryAction(client, 'square', '8001');
+ const result = await snapshot(client);
+ assertExactCodes(result, THIRTY_CODES.slice(0, 20));
+ return result;
+}
+
+async function buildThirtyComparison(client) {
+ const initial = await snapshot(client);
+ assertHealthy(initial, { requireIdle: true });
+ assertExactCodes(initial, THIRTY_CODES.slice(0, 20));
+
+ await selectTab(client, 'comparison');
+ let current = await snapshot(client);
+ const savedPairBaseline = comparisonPairRows(current.state.comparison);
+ const selectedPairBaseline = normalizeValue(current.state.comparison?.selectedPairId);
+ await clearComparison(client);
+ current = await postAndWait(client, 'search-comparison-domestic', { query: '\uc0bc\uc131' },
+ (value) => !value.state.isBusy && value.state.comparison?.domesticSearch?.query === '\uc0bc\uc131' &&
+ value.state.comparison?.domesticSearch?.results?.length > 1,
+ 'comparison domestic search');
+ const firstId = current.state.comparison.domesticSearch.results[0].selectionId;
+ const secondId = current.state.comparison.domesticSearch.results[1].selectionId;
+ if (!firstId || !secondId || firstId === secondId) fail('Comparison search selections are not exact.', 'COMPARISON_SELECTION_MISMATCH');
+ await chooseComparison(client, 'choose-comparison-domestic', { selectionId: firstId }, 'firstTarget');
+ await chooseComparison(client, 'choose-comparison-domestic', { selectionId: secondId }, 'secondTarget');
+ let temporaryPair = await commitComparisonPair(client, 'Samsung pair');
+ await addComparisonAction(client, 'comparison-candle', '5026');
+ await addComparisonAction(client, 'comparison-line', '5087');
+ await addComparisonAction(client, 'return-5d', '5029');
+ await deleteTemporaryComparisonPair(client, 'Samsung pair', temporaryPair.baseline, temporaryPair.addedPairId);
+
+ await clearComparison(client);
+ await chooseComparison(client, 'choose-comparison-fixed', { targetId: 'kospi' }, 'firstTarget');
+ await chooseComparison(client, 'choose-comparison-fixed', { targetId: 'kosdaq' }, 'secondTarget');
+ temporaryPair = await commitComparisonPair(client, 'KOSPI/KOSDAQ');
+ await addComparisonAction(client, 'two-column-current', '8032');
+ await deleteTemporaryComparisonPair(client, 'KOSPI/KOSDAQ', temporaryPair.baseline, temporaryPair.addedPairId);
+
+ await clearComparison(client);
+ await chooseComparison(client, 'choose-comparison-fixed', { targetId: 'kospi' }, 'firstTarget');
+ await chooseComparison(client, 'choose-comparison-fixed', { targetId: 'futures' }, 'secondTarget');
+ temporaryPair = await commitComparisonPair(client, 'KOSPI/futures');
+ await addComparisonAction(client, 'two-column-current', '5032');
+ await deleteTemporaryComparisonPair(client, 'KOSPI/futures', temporaryPair.baseline, temporaryPair.addedPairId);
+
+ await restoreComparisonSelection(client, selectedPairBaseline);
+
+ const result = await snapshot(client);
+ assertExactCodes(result, THIRTY_CODES.slice(0, 25));
+ if (!comparisonPairsEqual(comparisonPairRows(result.state.comparison), savedPairBaseline) ||
+ normalizeValue(result.state.comparison?.selectedPairId) !== selectedPairBaseline) {
+ fail('Temporary comparison pairs were not removed without changing the saved baseline.',
+ 'COMPARISON_CLEANUP_MISMATCH', { savedPairBaseline, selectedPairBaseline, comparison: result.state.comparison });
+ }
+ return result;
+}
+
+async function buildThirtyFinancial(client) {
+ const initial = await snapshot(client);
+ assertHealthy(initial, { requireIdle: true });
+ assertExactCodes(initial, THIRTY_CODES.slice(0, 25));
+ let current = initial;
+ if (current.state.manualFinancial != null) {
+ current = await postAndWait(client, 'close-manual-financial', {},
+ (value) => !value.state.isBusy && value.state.manualFinancial == null,
+ 'stale manual financial close');
+ }
+ for (let index = 0; index < FINANCIAL_ACTIONS.length; index += 1) {
+ const [screen, query] = FINANCIAL_ACTIONS[index];
+ const beforeLength = current.state.rows.length;
+ current = await postAndWait(client, 'open-manual-financial', { screen },
+ (value) => !value.state.isBusy && value.state.manualFinancial?.screen === screen,
+ `manual financial ${screen} open`);
+ current = await postAndWait(client, 'search-manual-financial', { query },
+ (value) => !value.state.isBusy && value.state.manualFinancial?.screen === screen &&
+ value.state.manualFinancial?.query === query &&
+ value.state.manualFinancial?.rows?.some(row => row.stockName === query),
+ `manual financial ${screen} search`);
+ const exactRows = current.state.manualFinancial.rows.filter(row => row.stockName === query);
+ if (exactRows.length !== 1 || !exactRows[0].resultId) {
+ fail(`Manual financial ${screen}/${query} result identity is not unique.`, 'FINANCIAL_RESULT_MISMATCH', exactRows);
+ }
+ current = await postAndWait(client, 'activate-manual-financial-result', { resultId: exactRows[0].resultId },
+ (value) => !value.state.isBusy && value.state.manualFinancial == null && value.state.rows.length === beforeLength + 1,
+ `manual financial ${screen} playlist`);
+ assertRowMatchesCode(current.state.rows.at(-1), THIRTY_CODES[25 + index]);
+ }
+ const result = current;
+ assertExactCodes(result, THIRTY_CODES.slice(0, 29));
+ return result;
+}
+
+async function buildThirtyManual(client) {
+ const initial = await snapshot(client);
+ assertHealthy(initial, { requireIdle: true });
+ assertExactCodes(initial, THIRTY_CODES.slice(0, 29));
+ await selectTab(client, 'index');
+ const opened = await postAndWait(client, 'activate-fixed-action', { actionId: 'fixed-245' },
+ (value) => !value.state.isBusy && value.state.manualLists?.isOpen === true &&
+ value.state.manualLists?.screen === 'net-sell' && value.state.manualLists?.audience === 'individual' &&
+ value.state.manualLists?.netRowsAreFresh === true && value.state.manualLists?.canMaterializePlaylist === true &&
+ value.state.manualLists?.netRows?.length === 5,
+ 'manual net-sell fresh read');
+ if (!opened.state.manualLists?.canMaterializePlaylist) fail('Manual net-sell list cannot materialize a playlist row.', 'MANUAL_LIST_UNAVAILABLE');
+ const result = await postAndWait(client, 'add-manual-net-sell-cut', {},
+ (current, before) => !current.state.isBusy && current.state.rows.length === before.state.rows.length + 1 &&
+ current.state.manualLists?.isOpen === true,
+ 'manual net-sell cut addition');
+ assertRowMatchesCode(result.state.rows.at(-1), '5025');
+ await postAndWait(client, 'close-manual-list', {},
+ (value) => !value.state.isBusy && value.state.manualLists?.isOpen === false,
+ 'manual list close');
+ const final = await snapshot(client);
+ assertThirtyManifest(final);
+ return final;
+}
+
+async function buildPaged(client) {
+ const initial = await snapshot(client);
+ assertHealthy(initial, { requireIdle: true });
+ if (initial.state.rows.length !== 0) fail('Paged-boundary build requires an empty in-memory playlist.', 'PLAYLIST_NOT_EMPTY');
+ await addFixedAction(client, 'index', 'fixed-275', '5077');
+ await addFixedAction(client, 'index', 'fixed-302', '5088');
+ const result = await snapshot(client);
+ assertPagedManifest(result);
+ if (result.state.rows.some(row => row.pageText !== '1/20')) {
+ fail('5077/5088 must both materialize with an exact 1/20 page label.',
+ 'PLAYLIST_PAGE_FIXTURE_MISMATCH', result.state.rows);
+ }
+ return result;
+}
+
+async function buildS6001AssetIndependent(client) {
+ const initial = await snapshot(client);
+ assertHealthy(initial, { requireIdle: true });
+ if (initial.state.rows.length !== 0) {
+ fail('s6001 asset-independent build requires an empty in-memory playlist.',
+ 'PLAYLIST_NOT_EMPTY');
+ }
+ await selectTab(client, 'overseas');
+ for (let index = 0; index < S6001_ASSET_INDEPENDENT_ACTIONS.length; index += 1) {
+ const expected = S6001_ASSET_INDEPENDENT_ACTIONS[index];
+ const selector = `button[data-action-id=${js(expected.actionId)}]`;
+ await client.evaluate(`(() => {
+ const button = document.querySelector(${js(selector)});
+ const details = button?.closest('details');
+ if (details) details.open = true;
+ return Boolean(button && details);
+ })()`);
+ const result = await physicalDoubleClickAndWait(client, selector,
+ (current, before) => !current.state.isBusy &&
+ current.state.rows.length === before.state.rows.length + 1,
+ `s6001 physical double-click ${expected.actionId}`);
+ assertS6001Row(result.state.rows.at(-1), expected, index);
+ }
+ const result = await snapshot(client);
+ assertS6001Manifest(result);
+ return result;
+}
+
+async function activateRow(client, row, expectedCode, manifestCheck) {
+ manifestCheck(await snapshot(client));
+ if (!ROW_ID_PATTERN.test(row.id || '')) fail('Target row is not an in-memory legacy row.', 'PLAYLIST_ID_MISMATCH', row);
+ return await physicalClickAndWait(client, `#playlist-rows .playlist-data-row[data-row-id=${js(row.id)}] .playlist-row-header`,
+ (current) => current.state.activeRowId === row.id,
+ `row activation ${expectedCode}`, BUILD_TIMEOUT_MS);
+}
+
+async function clickPlayout(client, buttonId, predicate, description) {
+ return await physicalClickAndWait(client, `#${buttonId}`,
+ (current) => predicate(current), description, STATE_TIMEOUT_MS);
+}
+
+async function runAction(client, options) {
+ const action = options.action;
+ if (action === 'bootstrap') {
+ let current = await snapshot(client);
+ if (!current.state) {
+ await client.evaluate(`window.chrome.webview.postMessage({ type: 'ready', payload: {} })`);
+ current = await waitFor(client, (value) => Boolean(value.state), 'initial native state',
+ STATE_TIMEOUT_MS, { allowMissingState: true });
+ }
+ assertHealthy(current, { requireIdle: true });
+ if (current.state.rows.length !== 0 || current.state.currentCode != null || current.state.preparedCode != null) {
+ fail('Bootstrap requires exact connected IDLE with an empty in-memory playlist.', 'BOOTSTRAP_STATE_MISMATCH', current.state);
+ }
+ return current;
+ }
+ const initial = await snapshot(client);
+ assertHealthy(initial, { allowPending: action.startsWith('wait-') });
+
+ switch (action) {
+ case 'snapshot': return initial;
+ case 'setup-5001-5074': return await setup5001And5074(client);
+ case 'take-in-5001':
+ assertTwoSceneManifest(initial);
+ if (initial.state.phase !== 'idle' || initial.state.activeRowId !== initial.state.rows[0].id ||
+ initial.state.currentCueIndex !== -1 || initial.buttons['playout-take-in']?.disabled ||
+ initial.buttons['playout-take-in']?.hidden) {
+ fail('5001 must be active in exact IDLE before TAKE IN.', 'ACTIVE_ROUTE_MISMATCH');
+ }
+ return await clickPlayout(client, 'playout-take-in',
+ (current) => current.state.phase === 'program' && current.state.currentCode === '5001' &&
+ current.state.currentCueIndex === 0 && current.state.pageIndex === 1 && current.state.pageCount === 1 &&
+ current.state.nextKind === 'playlistNext' && current.state.settled &&
+ current.state.refreshCount === 1 && current.state.refreshLimitReached,
+ '5001 TAKE IN');
+ case 'next-5074':
+ assertTwoSceneManifest(initial);
+ if (initial.state.phase !== 'program' || initial.state.currentCode !== '5001' ||
+ initial.state.currentCueIndex !== 0 || initial.state.nextKind !== 'playlistNext') {
+ fail('NEXT requires settled 5001 PROGRAM with playlistNext.', 'PLAYOUT_PHASE_MISMATCH');
+ }
+ return await clickPlayout(client, 'playout-next',
+ (current) => current.state.phase === 'program' && current.state.currentCode === '5074' &&
+ current.state.currentCueIndex === 1 && current.state.pageIndex === 1 &&
+ current.state.pageCount >= 2 && current.state.pageSize === 5 &&
+ current.state.itemCount >= 5 && current.state.currentPageItemCount === 5 &&
+ current.state.nextKind === 'pageNext' && current.state.settled,
+ 'NEXT to 5074');
+ case 'page-next-5074': {
+ assertTwoSceneManifest(initial);
+ if (initial.state.phase !== 'program' || initial.state.currentCode !== '5074' ||
+ initial.state.currentCueIndex !== 1 || initial.state.pageIndex !== 1 || initial.state.nextKind !== 'pageNext') {
+ fail('Page NEXT requires 5074 page 1 on PROGRAM.', 'ACTIVE_ROUTE_MISMATCH');
+ }
+ return await clickPlayout(client, 'playout-next',
+ (current) => current.state.phase === 'program' && current.state.currentCode === '5074' &&
+ current.state.currentCueIndex === 1 && current.state.pageIndex === 2 &&
+ current.state.pageSize === 5 && current.state.currentPageItemCount === 5 &&
+ current.state.nextKind === 'pageNext' && current.state.settled,
+ '5074 Page NEXT');
+ }
+ case 'take-out-5074':
+ assertTwoSceneManifest(initial);
+ if (initial.state.phase !== 'program' || initial.state.currentCode !== '5074' || initial.state.pageIndex !== 2) {
+ fail('TAKE OUT requires 5074 page 2 on PROGRAM.', 'ACTIVE_ROUTE_MISMATCH');
+ }
+ return await clickPlayout(client, 'playout-take-out',
+ (current) => current.state.phase === 'idle' && current.state.currentCode == null &&
+ current.state.preparedCode == null && current.state.currentCueIndex === -1 && current.state.settled,
+ '5074 TAKE OUT');
+ case 'build-30-base': return await buildThirtyBase(client);
+ case 'build-30-comparison': return await buildThirtyComparison(client);
+ case 'build-30-financial': return await buildThirtyFinancial(client);
+ case 'build-30-manual': return await buildThirtyManual(client);
+ case 'resume-30-manifest':
+ assertThirtyManifest(initial);
+ if (initial.state.phase !== 'idle' || initial.state.currentCode != null ||
+ initial.state.preparedCode != null || initial.state.currentCueIndex !== -1) {
+ fail('30-route resume requires the preserved manifest in exact IDLE.', 'PLAYOUT_PHASE_MISMATCH');
+ }
+ return initial;
+ case 'take-out-recovery-8001': {
+ if (options.routeIndex !== 19) {
+ fail('take-out-recovery-8001 requires --route-index 19.', 'INVALID_ARGUMENT');
+ }
+ assertThirtyManifest(initial);
+ const row = initial.state.rows[19];
+ if (initial.state.phase !== 'program' || initial.state.currentCode !== '8001' ||
+ initial.state.currentCueIndex !== 19 || initial.state.currentEntryId !== row.id ||
+ !initial.state.settled || initial.state.pendingCommand != null ||
+ initial.state.refreshCount !== 1 || !initial.state.refreshLimitReached) {
+ fail('Recovery TAKE OUT requires known settled 8001 PROGRAM after its one authorized refresh.',
+ 'PLAYOUT_PHASE_MISMATCH', initial.state);
+ }
+ return await clickPlayout(client, 'playout-take-out',
+ (current) => current.state.phase === 'idle' && current.state.currentCode == null &&
+ current.state.preparedCode == null && current.state.currentCueIndex === -1 && current.state.settled,
+ 'known 8001 recovery TAKE OUT');
+ }
+ case 'take-out-recovery-5077-page2': {
+ if (options.pageIndex !== 2) {
+ fail('take-out-recovery-5077-page2 requires --page-index 2.', 'INVALID_ARGUMENT');
+ }
+ assertPagedManifest(initial);
+ const row = initial.state.rows[0];
+ if (initial.state.phase !== 'program' || initial.state.currentCode !== '5077' ||
+ initial.state.currentCueIndex !== 0 || initial.state.currentEntryId !== row.id ||
+ initial.state.activeRowId !== row.id || initial.state.pageIndex !== 2 ||
+ initial.state.pageCount !== 20 || initial.state.pageSize !== 6 ||
+ initial.state.itemCount !== 120 || initial.state.currentPageItemCount !== 6 ||
+ !initial.state.settled || initial.state.pendingCommand != null ||
+ initial.state.refreshCount !== 1 || !initial.state.refreshLimitReached) {
+ fail('Recovery TAKE OUT requires known settled 5077 page 2/20 PROGRAM.',
+ 'PLAYOUT_PHASE_MISMATCH', initial.state);
+ }
+ return await clickPlayout(client, 'playout-take-out',
+ (current) => current.state.phase === 'idle' && current.state.currentCode == null &&
+ current.state.preparedCode == null && current.state.currentCueIndex === -1 && current.state.settled,
+ 'known 5077 page 2 recovery TAKE OUT');
+ }
+ case 'activate-30': {
+ if (!Number.isInteger(options.routeIndex) || options.routeIndex < 0 || options.routeIndex >= THIRTY_CODES.length) {
+ fail('activate-30 requires --route-index 0..29.', 'INVALID_ARGUMENT');
+ }
+ assertThirtyManifest(initial);
+ if (initial.state.phase !== 'idle' || initial.state.currentCode != null || initial.state.preparedCode != null) {
+ fail('Route activation requires exact IDLE.', 'PLAYOUT_PHASE_MISMATCH');
+ }
+ return await activateRow(client, initial.state.rows[options.routeIndex], THIRTY_CODES[options.routeIndex], assertThirtyManifest);
+ }
+ case 'take-in-30-fast': {
+ if (!Number.isInteger(options.routeIndex) || options.routeIndex < 0 || options.routeIndex >= THIRTY_CODES.length) {
+ fail('take-in-30-fast requires --route-index 0..29.', 'INVALID_ARGUMENT');
+ }
+ assertThirtyManifest(initial);
+ const code = THIRTY_CODES[options.routeIndex];
+ const row = initial.state.rows[options.routeIndex];
+ if (initial.state.activeRowId !== row.id || initial.state.phase !== 'idle' ||
+ initial.state.currentCueIndex !== -1 || initial.buttons['playout-take-in']?.disabled ||
+ initial.buttons['playout-take-in']?.hidden) {
+ fail(`TAKE IN requires active ${code} in exact IDLE.`, 'ACTIVE_ROUTE_MISMATCH');
+ }
+ return await clickPlayout(client, 'playout-take-in',
+ (current) => current.state.phase === 'program' && current.state.currentCode === code &&
+ current.state.currentCueIndex === options.routeIndex && current.state.currentEntryId === row.id &&
+ current.state.pageIndex === 1,
+ `${code} fast TAKE IN`);
+ }
+ case 'wait-30': {
+ if (!Number.isInteger(options.routeIndex) || options.routeIndex < 0 || options.routeIndex >= THIRTY_CODES.length) {
+ fail('wait-30 requires --route-index 0..29.', 'INVALID_ARGUMENT');
+ }
+ assertThirtyManifest(initial);
+ const code = THIRTY_CODES[options.routeIndex];
+ const row = initial.state.rows[options.routeIndex];
+ return await waitFor(client,
+ (current) => current.state.phase === 'program' && current.state.currentCode === code &&
+ current.state.currentCueIndex === options.routeIndex && current.state.currentEntryId === row.id &&
+ current.state.settled,
+ `${code} settled PROGRAM`);
+ }
+ case 'take-out-30': {
+ if (!Number.isInteger(options.routeIndex) || options.routeIndex < 0 || options.routeIndex >= THIRTY_CODES.length) {
+ fail('take-out-30 requires --route-index 0..29.', 'INVALID_ARGUMENT');
+ }
+ assertThirtyManifest(initial);
+ const code = THIRTY_CODES[options.routeIndex];
+ if (initial.state.phase !== 'program' || initial.state.currentCode !== code ||
+ initial.state.currentCueIndex !== options.routeIndex || !initial.state.settled) {
+ fail(`TAKE OUT requires settled ${code} PROGRAM.`, 'PLAYOUT_PHASE_MISMATCH');
+ }
+ return await clickPlayout(client, 'playout-take-out',
+ (current) => current.state.phase === 'idle' && current.state.currentCode == null &&
+ current.state.preparedCode == null && current.state.currentCueIndex === -1 && current.state.settled,
+ `${code} TAKE OUT`);
+ }
+ case 'build-paged': return await buildPaged(client);
+ case 'build-s6001-asset-independent': return await buildS6001AssetIndependent(client);
+ case 'activate-s6001': {
+ if (!Number.isInteger(options.routeIndex) || options.routeIndex < 0 ||
+ options.routeIndex >= S6001_ASSET_INDEPENDENT_ACTIONS.length) {
+ fail('activate-s6001 requires --route-index 0..3.', 'INVALID_ARGUMENT');
+ }
+ assertS6001Manifest(initial);
+ if (initial.state.phase !== 'idle' || initial.state.currentCode != null ||
+ initial.state.preparedCode != null) {
+ fail('s6001 route activation requires exact IDLE.', 'PLAYOUT_PHASE_MISMATCH');
+ }
+ return await activateRow(
+ client,
+ initial.state.rows[options.routeIndex],
+ '6001',
+ assertS6001Manifest);
+ }
+ case 'take-in-s6001': {
+ if (!Number.isInteger(options.routeIndex) || options.routeIndex < 0 ||
+ options.routeIndex >= S6001_ASSET_INDEPENDENT_ACTIONS.length) {
+ fail('take-in-s6001 requires --route-index 0..3.', 'INVALID_ARGUMENT');
+ }
+ assertS6001Manifest(initial);
+ const row = initial.state.rows[options.routeIndex];
+ if (initial.state.activeRowId !== row.id || initial.state.phase !== 'idle' ||
+ initial.state.currentCueIndex !== -1 || initial.buttons['playout-take-in']?.disabled ||
+ initial.buttons['playout-take-in']?.hidden) {
+ fail('s6001 TAKE IN requires its selected row in exact IDLE.', 'ACTIVE_ROUTE_MISMATCH');
+ }
+ return await clickPlayout(client, 'playout-take-in',
+ (current) => current.state.phase === 'program' && current.state.currentCode === '6001' &&
+ current.state.currentCueIndex === options.routeIndex &&
+ current.state.currentEntryId === row.id && current.state.pageIndex === 1,
+ `${S6001_ASSET_INDEPENDENT_ACTIONS[options.routeIndex].subtype} s6001 TAKE IN`);
+ }
+ case 'wait-s6001': {
+ if (!Number.isInteger(options.routeIndex) || options.routeIndex < 0 ||
+ options.routeIndex >= S6001_ASSET_INDEPENDENT_ACTIONS.length) {
+ fail('wait-s6001 requires --route-index 0..3.', 'INVALID_ARGUMENT');
+ }
+ assertS6001Manifest(initial);
+ const row = initial.state.rows[options.routeIndex];
+ return await waitFor(client,
+ (current) => current.state.phase === 'program' && current.state.currentCode === '6001' &&
+ current.state.currentCueIndex === options.routeIndex &&
+ current.state.currentEntryId === row.id && current.state.settled,
+ `${S6001_ASSET_INDEPENDENT_ACTIONS[options.routeIndex].subtype} s6001 settled PROGRAM`);
+ }
+ case 'take-out-s6001': {
+ if (!Number.isInteger(options.routeIndex) || options.routeIndex < 0 ||
+ options.routeIndex >= S6001_ASSET_INDEPENDENT_ACTIONS.length) {
+ fail('take-out-s6001 requires --route-index 0..3.', 'INVALID_ARGUMENT');
+ }
+ assertS6001Manifest(initial);
+ if (initial.state.phase !== 'program' || initial.state.currentCode !== '6001' ||
+ initial.state.currentCueIndex !== options.routeIndex || !initial.state.settled) {
+ fail('s6001 TAKE OUT requires its settled PROGRAM row.', 'PLAYOUT_PHASE_MISMATCH');
+ }
+ return await clickPlayout(client, 'playout-take-out',
+ (current) => current.state.phase === 'idle' && current.state.currentCode == null &&
+ current.state.preparedCode == null && current.state.currentCueIndex === -1 && current.state.settled,
+ `${S6001_ASSET_INDEPENDENT_ACTIONS[options.routeIndex].subtype} s6001 TAKE OUT`);
+ }
+ case 'take-in-5077':
+ assertPagedManifest(initial);
+ if (initial.state.phase !== 'idle' || initial.state.activeRowId !== initial.state.rows[0].id) {
+ fail('5077 must be active in exact IDLE.', 'ACTIVE_ROUTE_MISMATCH');
+ }
+ return await clickPlayout(client, 'playout-take-in',
+ (current) => current.state.phase === 'program' && current.state.currentCode === '5077' &&
+ current.state.currentCueIndex === 0 && current.state.pageIndex === 1 && current.state.pageCount === 20 &&
+ current.state.pageSize === 6 && current.state.itemCount === 120 &&
+ current.state.currentPageItemCount === 6 && current.state.nextKind === 'pageNext' && current.state.settled,
+ '5077 TAKE IN');
+ case 'page-next-5077': {
+ if (!Number.isInteger(options.pageIndex) || options.pageIndex < 1 || options.pageIndex > 19) {
+ fail('page-next-5077 requires --page-index 1..19.', 'INVALID_ARGUMENT');
+ }
+ assertPagedManifest(initial);
+ if (initial.state.currentCode !== '5077' || initial.state.pageIndex !== options.pageIndex ||
+ initial.state.pageCount !== 20 || initial.state.phase !== 'program' || initial.state.nextKind !== 'pageNext') {
+ fail(`Page NEXT ${options.pageIndex} requires 5077 page ${options.pageIndex}/20 on PROGRAM.`, 'ACTIVE_ROUTE_MISMATCH');
+ }
+ return await clickPlayout(client, 'playout-next',
+ (current) => current.state.phase === 'program' && current.state.currentCode === '5077' &&
+ current.state.currentCueIndex === 0 && current.state.pageIndex === options.pageIndex + 1 &&
+ current.state.pageCount === 20 && current.state.pageSize === 6 && current.state.itemCount === 120 &&
+ current.state.currentPageItemCount === 6 && current.state.settled,
+ `5077 Page NEXT ${options.pageIndex}/20 -> ${options.pageIndex + 1}/20`);
+ }
+ case 'next-5088':
+ assertPagedManifest(initial);
+ if (initial.state.phase !== 'program' || initial.state.currentCode !== '5077' ||
+ initial.state.pageIndex !== 20 || initial.state.pageCount !== 20 || initial.state.nextKind !== 'playlistNext') {
+ fail('NEXT requires 5077 page 20/20 and playlistNext.', 'ACTIVE_ROUTE_MISMATCH');
+ }
+ return await clickPlayout(client, 'playout-next',
+ (current) => current.state.phase === 'program' && current.state.currentCode === '5088' &&
+ current.state.currentCueIndex === 1 && current.state.pageIndex === 1 && current.state.pageCount === 20 &&
+ current.state.pageSize === 12 && current.state.itemCount === 240 &&
+ current.state.currentPageItemCount === 12 && current.state.settled,
+ 'NEXT 5077 page 20 to 5088 page 1');
+ case 'take-out-5088':
+ assertPagedManifest(initial);
+ if (initial.state.phase !== 'program' || initial.state.currentCode !== '5088' ||
+ initial.state.currentCueIndex !== 1) {
+ fail('TAKE OUT requires 5088 on PROGRAM.', 'ACTIVE_ROUTE_MISMATCH');
+ }
+ return await clickPlayout(client, 'playout-take-out',
+ (current) => current.state.phase === 'idle' && current.state.currentCode == null &&
+ current.state.preparedCode == null && current.state.currentCueIndex === -1 && current.state.settled,
+ '5088 TAKE OUT');
+ default: fail(`Unknown action: ${action}`, 'INVALID_ARGUMENT');
+ }
+}
+
+const options = parseArguments(process.argv);
+let client = null;
+let target = null;
+try {
+ target = await openTarget(options.port);
+ client = new CdpClient(target.webSocketDebuggerUrl);
+ await client.connect();
+ await client.send('Runtime.enable');
+ const probe = await installOrVerifyProbe(client);
+ const result = await runAction(client, options);
+ process.stdout.write(`${JSON.stringify({
+ ok: true,
+ action: options.action,
+ routeIndex: options.routeIndex,
+ pageIndex: options.pageIndex,
+ target: { id: target.id, title: target.title, url: target.url },
+ probe,
+ result,
+ completedAtUtc: new Date().toISOString(),
+ })}\n`);
+} catch (error) {
+ let finalSnapshot = null;
+ if (client) {
+ try { finalSnapshot = await snapshot(client); } catch { /* preserve the original failure */ }
+ }
+ process.stderr.write(`${JSON.stringify({
+ ok: false,
+ action: options.action,
+ routeIndex: options.routeIndex,
+ pageIndex: options.pageIndex,
+ code: error.code || 'STAGE_FAILED',
+ message: error.message,
+ details: error.details,
+ target: target ? { id: target.id, title: target.title, url: target.url } : null,
+ finalSnapshot,
+ failedAtUtc: new Date().toISOString(),
+ })}\n`);
+ process.exitCode = 1;
+} finally {
+ client?.close();
+}
diff --git a/scripts/Test-LegacyPackageInput.mjs b/scripts/Test-LegacyPackageInput.mjs
index c2054e0..58c01de 100644
--- a/scripts/Test-LegacyPackageInput.mjs
+++ b/scripts/Test-LegacyPackageInput.mjs
@@ -5,6 +5,7 @@ import { createHash, randomBytes } from "node:crypto";
const exactTargetUrl = "https://legacy-parity.mbn.local/index.html";
const ctrlModifier = 2;
const shiftModifier = 8;
+const legacyCutDoubleClickWindowMilliseconds = 500;
const defaultObservationTimeoutMilliseconds = 45_000;
const minimumObservationTimeoutMilliseconds = 5_000;
const maximumObservationTimeoutMilliseconds = 900_000;
@@ -16,6 +17,7 @@ const acceptedArgumentNames = new Set([
"screenshot",
"windows-input-request",
"windows-input-ack",
+ "recover-named-playlist-title",
"timeout-ms"
]);
const readOnlyForbiddenControlIds = Object.freeze([
@@ -42,6 +44,7 @@ const readOnlyAllowedOutboundMessageTypes = Object.freeze([
"select-playlist-row",
"reorder-playlist-rows",
"select-playlist-boundary",
+ "delete-selected-playlist-rows",
"refresh-named-playlists"
]);
const fullUiDbAllowedOutboundMessageTypes = Object.freeze([
@@ -106,7 +109,9 @@ const fullUiDbAllowedOutboundMessageTypes = Object.freeze([
"search-manual-financial-stocks",
"select-manual-financial-stock",
"save-manual-financial",
+ "save-manual-financial-raw",
"delete-manual-financial",
+ "delete-all-manual-financial",
"activate-manual-financial-action",
"open-manual-list",
"close-manual-list",
@@ -124,6 +129,7 @@ const fullUiDbAllowedOutboundMessageTypes = Object.freeze([
"search-operator-catalog",
"select-operator-catalog-result",
"begin-create-theme-catalog",
+ "change-create-theme-market",
"begin-create-expert-catalog",
"search-operator-catalog-stocks",
"select-operator-catalog-stock",
@@ -165,6 +171,7 @@ const databaseWriteIntentMessageTypes = Object.freeze([
"delete-selected-named-playlist",
"save-current-named-playlist-to",
"save-manual-financial",
+ "save-manual-financial-raw",
"delete-manual-financial",
"delete-all-manual-financial",
"import-manual-lists",
@@ -201,6 +208,21 @@ function failUnknown(message) {
throw new HarnessFailure("OUTCOME_UNKNOWN", message);
}
+function windowsInputExchangePath(basePath, exchangeIndex) {
+ if (!Number.isSafeInteger(exchangeIndex) || exchangeIndex < 1 || exchangeIndex > 2) {
+ failKnown("The Windows input exchange index is outside the closed contract.");
+ }
+ if (exchangeIndex === 1) return basePath;
+ const extension = path.extname(basePath);
+ return path.join(
+ path.dirname(basePath),
+ `${path.basename(basePath, extension)}.exchange-${String(exchangeIndex).padStart(2, "0")}${extension}`);
+}
+
+function expectedWindowsInputExchangeCount(profile, scope) {
+ return profile === "full-ui-db" && new Set(["all", "plist"]).has(scope) ? 2 : 1;
+}
+
function parseArguments(argv) {
if (argv.length === 0 || argv.length % 2 !== 0) {
failKnown(
@@ -208,7 +230,10 @@ function parseArguments(argv) {
"--output --screenshot " +
"--windows-input-request " +
"--windows-input-ack " +
- "[--profile read-only|dry-run-playout|full-ui-db] [--timeout-ms ]");
+ "[--profile read-only|dry-run-playout|full-ui-db] " +
+ "[--scope all|plist|graphe|catalog|screens] " +
+ "[--recover-named-playlist-title ] " +
+ "[--timeout-ms ]");
}
const values = new Map();
@@ -309,14 +334,41 @@ function parseArguments(argv) {
if (profile !== "full-ui-db" && scope !== "all") {
failKnown("A non-DB-write run requires --scope all.");
}
+ const recoverNamedPlaylistTitle =
+ values.get("recover-named-playlist-title") || null;
+ if (recoverNamedPlaylistTitle !== null &&
+ (recoverNamedPlaylistTitle.length !== 26 ||
+ !/^CDX_P_[0-9]{13}_[0-9a-f]{6}$/.test(recoverNamedPlaylistTitle))) {
+ failKnown(
+ "--recover-named-playlist-title must be one exact generated CDX_P fixture title.");
+ }
+ if (recoverNamedPlaylistTitle !== null &&
+ (profile !== "full-ui-db" || !new Set(["all", "plist"]).has(scope))) {
+ failKnown(
+ "--recover-named-playlist-title requires full-ui-db scope all or plist.");
+ }
+ const windowsInputExchangeCount = expectedWindowsInputExchangeCount(profile, scope);
+ for (let exchangeIndex = 2;
+ exchangeIndex <= windowsInputExchangeCount;
+ exchangeIndex += 1) {
+ if (fs.existsSync(windowsInputExchangePath(
+ windowsInputRequestPath,
+ exchangeIndex)) || fs.existsSync(windowsInputExchangePath(
+ windowsInputAckPath,
+ exchangeIndex))) {
+ failKnown("A derived Windows input exchange path already exists; evidence is never overwritten.");
+ }
+ }
return {
profile,
scope,
+ recoverNamedPlaylistTitle,
port,
outputPath,
screenshotPath,
windowsInputRequestPath,
windowsInputAckPath,
+ windowsInputExchangeCount,
timeoutMilliseconds
};
}
@@ -377,7 +429,9 @@ const allowedOutboundMessageTypes = fullUiDbProfile
...readOnlyAllowedOutboundMessageTypes,
"take-in",
"next-playout",
- "take-out"
+ "take-out",
+ "set-all-playlist-enabled",
+ "toggle-active-playlist-enabled"
])
: readOnlyAllowedOutboundMessageTypes;
const hardDeadlineEpochMilliseconds = Date.now() + configuration.timeoutMilliseconds;
@@ -432,26 +486,42 @@ const evidence = {
screenChecks: [],
databaseChecks: [],
screenInventory: [],
+ namedPlaylistRecovery: configuration.recoverNamedPlaylistTitle ? {
+ targetTitle: configuration.recoverNamedPlaylistTitle,
+ result: "pending",
+ exactMatchCount: null,
+ freshListVerified: false,
+ writeCount: 0,
+ mutationOutcome: null,
+ exactAbsenceVerified: false
+ } : null,
cleanupVerified: false
} : null,
dryRunPlayout: dryRunPlayoutProfile ? {
result: "PENDING",
stagedEntryId: null,
selectedEntryId: null,
+ skippedEntryId: null,
nextEntryId: null,
programCutSelection: null,
playlistCountBeforeSelection: null,
- playlistCountAfterDoubleClick: null
+ playlistCountAfterDoubleClick: null,
+ tailAppendedEntryId: null,
+ programEnableParity: null,
+ shortcutParity: null
} : null,
checkpoints: [],
inputs: [],
warnings: [],
windowsInput: {
required: true,
+ expectedExchangeCount: configuration.windowsInputExchangeCount,
+ completedExchangeCount: 0,
requestPath: configuration.windowsInputRequestPath,
acknowledgementPath: configuration.windowsInputAckPath,
requestSha256: null,
acknowledgementSha256: null,
+ exchanges: [],
result: "PENDING"
},
screenshot: null,
@@ -761,6 +831,7 @@ function probeSnapshotExpression() {
safety: {
postMessageWrapped: probe?.postMessageWrapper != null &&
window.chrome?.webview?.postMessage === probe.postMessageWrapper,
+ outboundMessageCount: probe?.outboundMessageCount ?? null,
outboundMessages: (probe?.outboundMessages || []).map(message => ({ ...message })),
approvedDatabaseWriteMessages:
(probe?.approvedDatabaseWriteMessages || []).map(message => ({ ...message })),
@@ -827,9 +898,12 @@ function probeSnapshotExpression() {
title: definition.title,
isSelected: definition.isSelected
})),
+ isListTruncated: state.namedPlaylist.isListTruncated,
selectedDefinitionId: state.namedPlaylist.selectedDefinitionId,
selectedTitle: state.namedPlaylist.selectedTitle,
rowCount: state.namedPlaylist.rowCount,
+ listFreshness: state.namedPlaylist.listFreshness,
+ documentFreshness: state.namedPlaylist.documentFreshness,
isWriteInProgress: state.namedPlaylist.isWriteInProgress,
isWriteQuarantined: state.namedPlaylist.isWriteQuarantined,
canMutate: state.namedPlaylist.canMutate,
@@ -902,6 +976,11 @@ function probeSnapshotExpression() {
active: row.dataset.active === "true",
current: row.getAttribute("aria-current") === "true",
enabled: row.querySelector("input[type='checkbox']")?.checked === true,
+ checkboxDisabled:
+ row.querySelector("input[type='checkbox']")?.disabled === true,
+ checkboxAriaDisabled:
+ row.querySelector("input[type='checkbox']")
+ ?.getAttribute("aria-disabled") === "true",
dragging: row.classList.contains("dragging"),
dragBefore: row.classList.contains("drag-before"),
dragAfter: row.classList.contains("drag-after"),
@@ -945,6 +1024,23 @@ function probeSnapshotExpression() {
"named-playlist-confirm-button")?.disabled === true,
namedDefinitionCount: document.querySelectorAll(
"#named-playlist-definitions button[data-named-playlist-definition-id]").length,
+ playlistEnableAll: {
+ checked: document.getElementById("playlist-enable-all")?.checked === true,
+ indeterminate:
+ document.getElementById("playlist-enable-all")?.indeterminate === true,
+ disabled: document.getElementById("playlist-enable-all")?.disabled === true
+ },
+ operatorCatalogStockQueryValue:
+ document.getElementById("operator-catalog-stock-query")?.value ?? null,
+ operatorCatalogNameValue:
+ document.getElementById("operator-catalog-name")?.value ?? null,
+ operatorCatalogEditorMarketValue:
+ document.getElementById("operator-catalog-editor-market")?.value ?? null,
+ operatorCatalogStockResultCount: document.querySelectorAll(
+ "#operator-catalog-stock-results " +
+ "button[data-operator-catalog-stock-result-id]").length,
+ operatorCatalogStatusText:
+ (document.getElementById("operator-catalog-status")?.textContent || "").trim(),
activeElement: active ? {
id: active.id || null,
tagName: active.tagName,
@@ -1233,12 +1329,12 @@ function assertWorkspaceAndScheduleGeometry(layout, label) {
}
const widthRatio = layout.region.width / layout.schedule.width;
const workspaceShare = layout.region.width / layout.shell.width;
- if (!Number.isFinite(widthRatio) || widthRatio < 1.85 || widthRatio > 2.15 ||
+ if (!Number.isFinite(widthRatio) || widthRatio < 1.60 || widthRatio > 1.90 ||
!Number.isFinite(workspaceShare) || workspaceShare < 0.62 || workspaceShare > 0.70 ||
Math.abs(layout.schedule.right - layout.shell.right) > 1.5 ||
Math.abs(layout.schedule.top - layout.shell.top) > 1.5 ||
Math.abs(layout.schedule.bottom - layout.shell.bottom) > 1.5) {
- failKnown(`${label} does not preserve the approximately 2:1 workspace/schedule split.`);
+ failKnown(`${label} does not preserve the approximately 1.75:1 workspace/schedule split.`);
}
return { widthRatio, workspaceShare };
}
@@ -1751,26 +1847,216 @@ async function dragMarketTab(sourceTabId, targetTabId, label) {
return { completed, layout: completedLayout, beforeOrder, expectedOrder };
}
+async function dragOperatorCatalogRow(sourceRowId, targetRowId, position, label) {
+ if (position !== "before" && position !== "after") {
+ failKnown(`${label} has an invalid operator-catalog drop position.`);
+ }
+
+ let current = await readSnapshot();
+ if (current.state.operatorCatalog?.activeDraftRowId !== sourceRowId) {
+ await pointerClick(
+ `document.querySelector('[data-operator-catalog-row-id="${sourceRowId}"] strong')`,
+ `${label} activate source row`);
+ current = await waitFor(`${label} active source row`, snapshot =>
+ snapshot.state.operatorCatalog?.activeDraftRowId === sourceRowId,
+ { timeoutMilliseconds: 30_000, allowUiBusy: true });
+ }
+ await waitForPointerReady(label);
+
+ const beforeOrder = (current.state.operatorCatalog?.draftRows || [])
+ .map(row => row.rowId);
+ const sourceIndex = beforeOrder.indexOf(sourceRowId);
+ const targetIndex = beforeOrder.indexOf(targetRowId);
+ if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) {
+ failKnown(`${label} does not have two distinct operator-catalog draft rows.`);
+ }
+ const expectedOrder = beforeOrder.filter(rowId => rowId !== sourceRowId);
+ const remainingTargetIndex = expectedOrder.indexOf(targetRowId);
+ expectedOrder.splice(
+ remainingTargetIndex + (position === "after" ? 1 : 0),
+ 0,
+ sourceRowId);
+ const source = await elementGeometry(
+ `document.querySelector('[data-operator-catalog-row-id="${sourceRowId}"] strong')`,
+ `${label} source`);
+ const target = await elementGeometry(
+ `document.querySelector('[data-operator-catalog-row-id="${targetRowId}"]')`,
+ `${label} target`,
+ position === "before" ? 0.25 : 0.75);
+ const before = await readSnapshot();
+ assertSafety(before, `${label} preflight`, false);
+ evidence.inputs.push({
+ type: "operator-catalog-row-drag",
+ label,
+ sourceRowId,
+ targetRowId,
+ position,
+ source,
+ target,
+ beforeOrder,
+ expectedOrder
+ });
+
+ let dragCompleted = false;
+ let operationError = null;
+ let intercepted = null;
+ dragInterceptionEnabled = true;
+ try {
+ await rpc("Input.setInterceptDrags", { enabled: true });
+ intercepted = waitForDragIntercept(label);
+ await pressMouse(source, 0);
+ pointerReleasePoint = source;
+ await moveMouse({ x: source.x + 10, y: source.y }, 0, 1);
+ const dragData = await intercepted;
+ activeInterceptedDragData = dragData;
+ const rowItem = dragData?.items?.find(item =>
+ item.mimeType === "text/x-mbn-operator-catalog-row");
+ if (!rowItem || rowItem.data !== sourceRowId) {
+ failKnown(`${label} did not preserve the draft row identity in drag data.`);
+ }
+ await rpc("Input.dispatchDragEvent", {
+ type: "dragEnter",
+ x: target.x,
+ y: target.y,
+ data: dragData
+ });
+ await rpc("Input.dispatchDragEvent", {
+ type: "dragOver",
+ x: target.x,
+ y: target.y,
+ data: dragData
+ });
+ await sleep(100);
+ await rpc("Input.dispatchDragEvent", {
+ type: "drop",
+ x: target.x,
+ y: target.y,
+ data: dragData
+ });
+ activeInterceptedDragData = null;
+ dragCompleted = true;
+ await releaseMouse(target, 0);
+ } catch (error) {
+ operationError = error;
+ throw error;
+ } finally {
+ if (pendingDragIntercept) {
+ cancelPendingDragIntercept(
+ `${label} ended before Chromium supplied intercepted drag data.`);
+ }
+ if (intercepted) await intercepted.catch(() => null);
+ try {
+ await cleanupDragInputSession(!dragCompleted);
+ } catch (cleanupError) {
+ if (!operationError) throw cleanupError;
+ }
+ }
+
+ return await waitFor(`${label} native reorder`, snapshot => {
+ const order = (snapshot.state.operatorCatalog?.draftRows || [])
+ .map(row => row.rowId);
+ return snapshot.safety.outboundMessages.length ===
+ before.safety.outboundMessages.length + 1 &&
+ snapshot.safety.outboundMessages.at(-1)?.type ===
+ "reorder-operator-catalog-draft-row" &&
+ order.length === expectedOrder.length &&
+ order.every((rowId, index) => rowId === expectedOrder[index]);
+ }, { timeoutMilliseconds: 60_000, allowUiBusy: true });
+}
+
const keyDefinitions = Object.freeze({
ArrowDown: { key: "ArrowDown", code: "ArrowDown", virtualKey: 40 },
ArrowUp: { key: "ArrowUp", code: "ArrowUp", virtualKey: 38 },
Backspace: { key: "Backspace", code: "Backspace", virtualKey: 8 },
+ Delete: { key: "Delete", code: "Delete", virtualKey: 46 },
End: { key: "End", code: "End", virtualKey: 35 },
Enter: { key: "Enter", code: "Enter", virtualKey: 13 },
Escape: { key: "Escape", code: "Escape", virtualKey: 27 },
+ F8: { key: "F8", code: "F8", virtualKey: 119 },
Home: { key: "Home", code: "Home", virtualKey: 36 },
Space: { key: " ", code: "Space", virtualKey: 32 },
Tab: { key: "Tab", code: "Tab", virtualKey: 9 },
SelectAll: { key: "a", code: "KeyA", virtualKey: 65 }
});
-async function pressKey(definitionName, label, modifiers = 0) {
+function authorizeDryRunPlayoutShortcut(definitionName, snapshot, label) {
+ if (!dryRunPlayoutProfile || !new Set(["F8", "Escape"]).has(definitionName)) {
+ failKnown(`${label} is not an authorized DryRun playout shortcut.`);
+ }
+ const expectedPhase = definitionName === "F8" ? "idle" : "program";
+ assertSafety(
+ snapshot,
+ `${label} authorization`,
+ false,
+ false,
+ expectedPhase === "program");
+ const playout = snapshot.state.playout;
+ if (playout.phase !== expectedPhase || playout.isCommandAvailable !== true ||
+ playout.isBusy === true || playout.outcomeUnknown === true ||
+ playout.isPlayCompletionPending === true ||
+ playout.isTakeOutCompletionPending === true ||
+ snapshot.state.isBusy === true ||
+ snapshot.dom.legacyDialogHidden !== true ||
+ snapshot.dom.namedModalHidden !== true ||
+ snapshot.dom.namedConfirmationHidden !== true ||
+ snapshot.dom.manualFinancialModalHidden !== true ||
+ snapshot.dom.manualListModalHidden !== true ||
+ snapshot.dom.operatorCatalogModalHidden !== true ||
+ pointerIsPressed || dragInterceptionEnabled || activeInterceptedDragData) {
+ failUnknown(`${label} does not have an exact known-safe DryRun state.`);
+ }
+ const authorization = {
+ definitionName,
+ expectedPhase,
+ issuedAt: Date.now(),
+ stateRevision: snapshot.state.revision,
+ consumed: false
+ };
+ evidence.inputs.push({
+ type: "dry-run-shortcut-authorization",
+ label,
+ key: definitionName,
+ expectedPhase,
+ stateRevision: snapshot.state.revision
+ });
+ return authorization;
+}
+
+function consumeDryRunPlayoutShortcutAuthorization(
+ definitionName,
+ authorization,
+ label) {
+ if (!dryRunPlayoutProfile || !authorization || authorization.consumed === true ||
+ authorization.definitionName !== definitionName ||
+ authorization.expectedPhase !== (definitionName === "F8" ? "idle" : "program") ||
+ !Number.isSafeInteger(authorization.stateRevision) ||
+ !Number.isFinite(authorization.issuedAt) ||
+ Date.now() - authorization.issuedAt > 5_000) {
+ failKnown(`${label} lacks a fresh one-shot DryRun shortcut authorization.`);
+ }
+ // Consume before dispatch. A partial key dispatch is never retried with the
+ // same token, preserving the harness' OutcomeUnknown/no-duplicate boundary.
+ authorization.consumed = true;
+}
+
+async function pressKey(definitionName, label, modifiers = 0, options = {}) {
const definition = keyDefinitions[definitionName];
if (!definition) failKnown(`Unknown key definition ${definitionName}.`);
- if (new Set(["F2", "F3", "F8"]).has(definition.key)) {
+ if (new Set(["F2", "F3"]).has(definition.key)) {
failKnown(`The harness refused playout shortcut ${definition.key}.`);
}
- if (definition.key === "Escape") {
+ if (definition.key === "F8") {
+ consumeDryRunPlayoutShortcutAuthorization(
+ definitionName,
+ options.dryRunPlayoutShortcutAuthorization,
+ label);
+ } else if (definition.key === "Escape" &&
+ options.dryRunPlayoutShortcutAuthorization) {
+ consumeDryRunPlayoutShortcutAuthorization(
+ definitionName,
+ options.dryRunPlayoutShortcutAuthorization,
+ label);
+ } else if (definition.key === "Escape") {
const modalState = await evaluate(`(() => ({
named: document.getElementById("named-playlist-modal")?.hidden === false,
confirmation: document.getElementById("named-playlist-confirmation")?.hidden === false
@@ -1871,10 +2157,39 @@ async function respondToNativeDialog(label, confirmation, accept) {
return opened;
}
-async function pointerDoubleClick(expression, label, modifiers = 0) {
+async function pointerClickWithNativeDialog(
+ expression,
+ label,
+ confirmation,
+ message,
+ accept) {
+ await pointerClick(expression, label);
+ const opened = await respondToNativeDialog(label, confirmation, accept);
+ if (opened.state?.dialog?.message !== message ||
+ opened.dom.legacyDialogText !== message) {
+ failKnown(`${label} native dialog text did not match the original.`);
+ }
+ return opened;
+}
+
+async function pointerDoubleClick(
+ expression,
+ label,
+ modifiers = 0,
+ interClickDelayMilliseconds = 50) {
+ if (!Number.isInteger(interClickDelayMilliseconds) ||
+ interClickDelayMilliseconds < 0 || interClickDelayMilliseconds > 1_000) {
+ failKnown(`${label} has an invalid double-click interval.`);
+ }
await waitForPointerReady(label);
const point = await elementGeometry(expression, label);
- evidence.inputs.push({ type: "pointer-double-click", label, modifiers, point });
+ evidence.inputs.push({
+ type: "pointer-double-click",
+ label,
+ modifiers,
+ interClickDelayMilliseconds,
+ point
+ });
await moveMouse(point, modifiers, 0);
for (const clickCount of [1, 2]) {
await rpc("Input.dispatchMouseEvent", {
@@ -1897,7 +2212,7 @@ async function pointerDoubleClick(expression, label, modifiers = 0) {
modifiers,
pointerType: "mouse"
});
- if (clickCount === 1) await sleep(50);
+ if (clickCount === 1) await sleep(interClickDelayMilliseconds);
}
}
@@ -1956,6 +2271,21 @@ function activePlaylistId(snapshot) {
return snapshot.state.playlist.find(row => row.isActive)?.rowId || null;
}
+function outboundMessagesAfter(snapshot, sequence) {
+ if (!Number.isSafeInteger(sequence) || sequence < 0) {
+ failKnown("An outbound-message sequence baseline is invalid.");
+ }
+ return (snapshot.safety?.outboundMessages || [])
+ .filter(message => Number(message.sequence) > sequence);
+}
+
+function assertExactOutboundTypes(snapshot, sequence, expectedTypes, label) {
+ const actual = outboundMessagesAfter(snapshot, sequence)
+ .map(message => message.type);
+ assertExactArray(actual, expectedTypes, `${label} outbound intents`);
+ return outboundMessagesAfter(snapshot, sequence);
+}
+
function focusedCutIndex(snapshot) {
return snapshot.state.cutRows.find(row => row.isFocused)?.physicalIndex ?? null;
}
@@ -2156,6 +2486,52 @@ async function playlistCheckboxGeometry(rowId, label) {
})()`, label);
}
+async function disabledPlaylistCheckboxGeometry(rowId, label) {
+ const geometry = await evaluate(`(() => {
+ const row = Array.from(document.querySelectorAll(
+ "#playlist-rows .playlist-data-row"))
+ .find(candidate => candidate.dataset.rowId === ${JSON.stringify(rowId)});
+ const checkbox = row?.querySelector("input[type='checkbox']") || null;
+ if (!checkbox) return null;
+ checkbox.scrollIntoView({ block: "nearest", inline: "nearest" });
+ const rectangle = checkbox.getBoundingClientRect();
+ return {
+ x: rectangle.left + rectangle.width / 2,
+ y: rectangle.top + rectangle.height / 2,
+ left: rectangle.left,
+ top: rectangle.top,
+ right: rectangle.right,
+ bottom: rectangle.bottom,
+ width: rectangle.width,
+ height: rectangle.height,
+ disabled: checkbox.disabled === true,
+ ariaDisabled: checkbox.getAttribute("aria-disabled") === "true"
+ };
+ })()`);
+ if (!geometry || (!geometry.disabled && !geometry.ariaDisabled) ||
+ !Number.isFinite(geometry.x) || !Number.isFinite(geometry.y) ||
+ geometry.width <= 0 || geometry.height <= 0 ||
+ geometry.right <= 0 || geometry.bottom <= 0) {
+ failKnown(`${label} is not a rendered disabled PROGRAM checkbox.`);
+ }
+ return geometry;
+}
+
+async function pointerClickDisabledPlaylistCheckbox(rowId, label) {
+ await waitForPointerReady(label, false, true);
+ const point = await disabledPlaylistCheckboxGeometry(rowId, label);
+ evidence.inputs.push({
+ type: "disabled-playlist-checkbox-click",
+ label,
+ rowId,
+ point
+ });
+ await pressMouse(point, 0);
+ await sleep(25);
+ await releaseMouse(point, 0);
+ return point;
+}
+
async function pointerClickPlaylistRow(rowId, label, modifiers = 0) {
const point = await rowCellGeometry(rowId, label);
evidence.inputs.push({ type: "pointer-click", label, modifiers, point, rowId });
@@ -2260,11 +2636,84 @@ function readSmallJsonFile(filePath, label) {
}
}
+async function publishWindowsInputRequest(request, label, acknowledgementContract) {
+ const exchangeIndex = evidence.windowsInput.exchanges.length + 1;
+ if (request.exchangeIndex !== exchangeIndex ||
+ exchangeIndex > configuration.windowsInputExchangeCount) {
+ failKnown(`${label} is outside the ordered Windows input exchange budget.`);
+ }
+ const requestPath = windowsInputExchangePath(
+ configuration.windowsInputRequestPath,
+ exchangeIndex);
+ const acknowledgementPath = windowsInputExchangePath(
+ configuration.windowsInputAckPath,
+ exchangeIndex);
+ if (fs.existsSync(requestPath) || fs.existsSync(acknowledgementPath)) {
+ failKnown(`${label} would overwrite an existing Windows input exchange.`);
+ }
+
+ const requestBytes = Buffer.from(`${JSON.stringify(request, null, 2)}\n`, "utf8");
+ fs.writeFileSync(requestPath, requestBytes, { flag: "wx" });
+ evidence.inputs.push({
+ type: "windows-send-input-request",
+ label,
+ exchangeIndex,
+ operation: request.operation
+ });
+
+ while (!fs.existsSync(acknowledgementPath)) {
+ await sleep(50);
+ }
+ const acknowledgement = readSmallJsonFile(
+ acknowledgementPath,
+ `Windows input acknowledgement ${exchangeIndex}`);
+ const ack = acknowledgement.value;
+ const expectedAcknowledgement = {
+ schemaVersion: 1,
+ exchangeIndex,
+ token: request.token,
+ result: "PASS",
+ operation: request.operation,
+ foregroundValidated: true,
+ packageIdentityValidated: true,
+ inputRetryCount: 0,
+ ...acknowledgementContract
+ };
+ const actualKeys = Object.keys(ack || {}).sort();
+ const expectedKeys = Object.keys(expectedAcknowledgement).sort();
+ if (!ack || JSON.stringify(actualKeys) !== JSON.stringify(expectedKeys) ||
+ expectedKeys.some(key => ack[key] !== expectedAcknowledgement[key])) {
+ failKnown(`${label} acknowledgement does not match the exact one-shot request.`);
+ }
+
+ const exchange = {
+ exchangeIndex,
+ operation: request.operation,
+ requestPath,
+ acknowledgementPath,
+ requestSha256: sha256(requestBytes),
+ acknowledgementSha256: sha256(acknowledgement.bytes),
+ result: "PASS"
+ };
+ evidence.windowsInput.exchanges.push(exchange);
+ evidence.windowsInput.completedExchangeCount = exchangeIndex;
+ if (exchangeIndex === 1) {
+ evidence.windowsInput.requestSha256 = exchange.requestSha256;
+ evidence.windowsInput.acknowledgementSha256 = exchange.acknowledgementSha256;
+ }
+ evidence.windowsInput.result = exchangeIndex === configuration.windowsInputExchangeCount
+ ? "PASS"
+ : "PENDING";
+ return { ack, exchange };
+}
+
async function requestWindowsInput(
current,
sourceRowId,
targetRowId,
expectedFirstRowId,
+ rangeStartRowId,
+ rangeEndRowId,
label) {
assertSafety(current, `${label} preflight`, false);
const preflightSignature = JSON.stringify({
@@ -2290,15 +2739,32 @@ async function requestWindowsInput(
current = stabilized;
const source = await playlistRowHeaderGeometry(sourceRowId, `${label} source`);
const target = await playlistRowHeaderGeometry(targetRowId, `${label} target`, 0.75);
+ const rangeStart = await rowCellGeometry(rangeStartRowId, `${label} range start`);
+ const rangeEnd = await rowCellGeometry(rangeEndRowId, `${label} range end`);
+ const restoreSelection = await rowCellGeometry(
+ targetRowId,
+ `${label} future restored-selection position`);
await sleep(100);
const geometryGuard = await readSnapshot();
const guardedSource = await playlistRowHeaderGeometry(
sourceRowId, `${label} guarded source`);
const guardedTarget = await playlistRowHeaderGeometry(
targetRowId, `${label} guarded target`, 0.75);
+ const guardedRangeStart = await rowCellGeometry(
+ rangeStartRowId,
+ `${label} guarded range start`);
+ const guardedRangeEnd = await rowCellGeometry(
+ rangeEndRowId,
+ `${label} guarded range end`);
+ const guardedRestoreSelection = await rowCellGeometry(
+ targetRowId,
+ `${label} guarded future restored-selection position`);
const geometryStable = ["x", "y", "left", "top", "right", "bottom"]
.every(key => Math.abs(source[key] - guardedSource[key]) <= 0.5 &&
- Math.abs(target[key] - guardedTarget[key]) <= 0.5);
+ Math.abs(target[key] - guardedTarget[key]) <= 0.5 &&
+ Math.abs(rangeStart[key] - guardedRangeStart[key]) <= 0.5 &&
+ Math.abs(rangeEnd[key] - guardedRangeEnd[key]) <= 0.5 &&
+ Math.abs(restoreSelection[key] - guardedRestoreSelection[key]) <= 0.5);
const guardSignature = JSON.stringify({
revision: geometryGuard.state.revision,
order: geometryGuard.state.playlist.map(row => row.rowId),
@@ -2312,84 +2778,111 @@ async function requestWindowsInput(
current = geometryGuard;
const token = randomBytes(16).toString("hex").toUpperCase();
const beforeOrder = current.state.playlist.map(row => row.rowId);
- const afterOrder = beforeOrder.filter(rowId => rowId !== sourceRowId);
- const targetIndex = afterOrder.indexOf(targetRowId);
+ const reorderedOrder = beforeOrder.filter(rowId => rowId !== sourceRowId);
+ const targetIndex = reorderedOrder.indexOf(targetRowId);
if (targetIndex < 0 || sourceRowId === targetRowId ||
- new Set(beforeOrder).size !== beforeOrder.length) {
+ beforeOrder.length !== 5 || new Set(beforeOrder).size !== beforeOrder.length ||
+ rangeStartRowId === rangeEndRowId ||
+ !beforeOrder.includes(rangeStartRowId) || !beforeOrder.includes(rangeEndRowId)) {
failKnown(`${label} row identities are not safe for a one-row move.`);
}
- afterOrder.splice(targetIndex + 1, 0, sourceRowId);
- if (afterOrder[0] !== expectedFirstRowId) {
+ reorderedOrder.splice(targetIndex + 1, 0, sourceRowId);
+ if (reorderedOrder[0] !== expectedFirstRowId ||
+ reorderedOrder.at(-2) !== rangeStartRowId ||
+ reorderedOrder.at(-1) !== rangeEndRowId) {
failKnown(`${label} computed an unexpected Home boundary row.`);
}
+ const afterOrder = reorderedOrder.filter(rowId =>
+ rowId !== rangeStartRowId && rowId !== rangeEndRowId);
+ if (afterOrder.length !== 3 || !afterOrder.includes(sourceRowId)) {
+ failKnown(`${label} did not compute an exact two-row cleanup result.`);
+ }
+ const outboundSequence = current.safety.outboundMessages.length;
const request = {
schemaVersion: 1,
+ exchangeIndex: 1,
token,
targetUrl: exactTargetUrl,
- operation: "row-header-drag-after-then-home",
+ operation: "row-header-drag-home-shift-range-delete-restore",
sourceRowId,
targetRowId,
+ rangeStartRowId,
+ rangeEndRowId,
position: "after",
source: { x: source.x, y: source.y },
target: { x: target.x, y: target.y },
+ rangeStart: { x: rangeStart.x, y: rangeStart.y },
+ rangeEnd: { x: rangeEnd.x, y: rangeEnd.y },
+ restoreSelection: { x: restoreSelection.x, y: restoreSelection.y },
viewport: { ...current.dom.viewport },
devicePixelRatio: current.devicePixelRatio,
beforeOrder,
+ reorderedOrder,
afterOrder,
+ expectedRangeSelectedRowIds: [rangeStartRowId, rangeEndRowId],
expectedSelectedRowId: sourceRowId,
expectedActiveRowIdAfterHome: expectedFirstRowId,
+ expectedFinalActiveRowId: sourceRowId,
expectedFocusedRowId: sourceRowId,
- mouseDownCalls: 1,
- mouseUpCalls: 1,
- homeKeyCalls: 1
+ mouseDownCalls: 4,
+ mouseUpCalls: 4,
+ homeKeyCalls: 1,
+ shiftKeyDownCalls: 1,
+ shiftKeyUpCalls: 1,
+ deleteKeyCalls: 1,
+ inputRetryCount: 0
};
- const requestBytes = Buffer.from(`${JSON.stringify(request, null, 2)}\n`, "utf8");
- fs.writeFileSync(configuration.windowsInputRequestPath, requestBytes, { flag: "wx" });
- evidence.windowsInput.requestSha256 = sha256(requestBytes);
- evidence.inputs.push({
- type: "windows-send-input-request",
- label,
- sourceRowId,
- targetRowId,
- position: "after"
+ await publishWindowsInputRequest(request, label, {
+ mouseDownCalls: 4,
+ mouseUpCalls: 4,
+ homeKeyCalls: 1,
+ shiftKeyDownCalls: 1,
+ shiftKeyUpCalls: 1,
+ deleteKeyCalls: 1
});
- while (!fs.existsSync(configuration.windowsInputAckPath)) {
- await sleep(50);
- }
- const acknowledgement = readSmallJsonFile(
- configuration.windowsInputAckPath,
- "Windows input acknowledgement");
- const ack = acknowledgement.value;
- if (!ack || ack.schemaVersion !== 1 || ack.token !== token ||
- ack.result !== "PASS" || ack.operation !== request.operation ||
- ack.foregroundValidated !== true || ack.packageIdentityValidated !== true ||
- ack.mouseDownCalls !== 1 || ack.mouseUpCalls !== 1 ||
- ack.homeKeyCalls !== 1 || ack.inputRetryCount !== 0) {
- failKnown("Windows input acknowledgement does not match the one-shot request.");
- }
- evidence.windowsInput.acknowledgementSha256 = sha256(acknowledgement.bytes);
-
const completed = await waitFor(label, snapshot =>
JSON.stringify(snapshot.state.playlist.map(row => row.rowId)) ===
JSON.stringify(afterOrder) &&
JSON.stringify(selectedPlaylistIds(snapshot)) === JSON.stringify([sourceRowId]) &&
- activePlaylistId(snapshot) === expectedFirstRowId &&
+ activePlaylistId(snapshot) === sourceRowId &&
snapshot.dom.activeElement?.playlistRowId === sourceRowId,
{
timeoutMilliseconds: Math.min(configuration.timeoutMilliseconds, 15_000),
allowUiBusy: false
});
- evidence.windowsInput.result = "PASS";
+ const messages = assertExactOutboundTypes(completed, outboundSequence, [
+ "activate-playlist-row",
+ "reorder-playlist-rows",
+ "select-playlist-boundary",
+ "select-playlist-row",
+ "select-playlist-row",
+ "delete-selected-playlist-rows",
+ "select-playlist-row"
+ ], `${label} exact physical gesture sequence`);
+ const selections = messages.filter(message => message.type === "select-playlist-row");
+ if (selections.length !== 3 ||
+ selections[0].rowId !== rangeStartRowId || selections[0].control !== false ||
+ selections[0].shift !== false ||
+ selections[1].rowId !== rangeEndRowId || selections[1].control !== false ||
+ selections[1].shift !== true ||
+ selections[2].rowId !== sourceRowId || selections[2].control !== false ||
+ selections[2].shift !== false) {
+ failKnown(`${label} did not preserve the exact plain/Shift/plain selection sequence.`);
+ }
evidence.windowsInput.beforeOrder = beforeOrder;
evidence.windowsInput.afterOrder = afterOrder;
evidence.windowsInput.activeRowIdAfterHome = expectedFirstRowId;
- await checkpoint("windows-sendinput-row-header-drag-and-home", {
+ evidence.windowsInput.shiftRangeDeletedRowIds = [rangeStartRowId, rangeEndRowId];
+ evidence.windowsInput.finalActiveRowId = sourceRowId;
+ await checkpoint("windows-sendinput-drag-home-shift-range-delete-restore", {
sourceRowId,
targetRowId,
beforeOrder,
+ reorderedOrder,
afterOrder,
- expectedFirstRowId
+ expectedFirstRowId,
+ deletedRowIds: [rangeStartRowId, rangeEndRowId]
});
return completed;
}
@@ -2421,6 +2914,7 @@ async function installProbe() {
pointerListener: null,
originalPostMessage: null,
postMessageWrapper: null,
+ outboundMessageCount: 0,
outboundMessages: [],
approvedDatabaseWriteMessages: [],
blockedOutboundMessages: [],
@@ -2497,11 +2991,38 @@ async function installProbe() {
probe.originalPostMessage = window.chrome.webview.postMessage;
probe.postMessageWrapper = message => {
const type = typeof message?.type === "string" ? message.type : null;
+ probe.outboundMessageCount += 1;
const record = {
+ sequence: probe.outboundMessageCount,
type,
at: new Date().toISOString(),
allowAppend: type === "cut-pointer-down"
? message?.payload?.allowAppend ?? null
+ : null,
+ enabled: type === "set-all-playlist-enabled"
+ ? message?.payload?.enabled ?? null
+ : null,
+ rowId: type === "select-playlist-row"
+ ? message?.payload?.rowId ?? null
+ : null,
+ control: type === "select-playlist-row"
+ ? message?.payload?.control === true
+ : null,
+ shift: type === "select-playlist-row"
+ ? message?.payload?.shift === true
+ : null,
+ definitionId: new Set([
+ "select-named-playlist",
+ "load-named-playlist-by-id"
+ ]).has(type) ? message?.payload?.definitionId ?? null : null,
+ emptyStockQuery: type === "search-operator-catalog-stocks"
+ ? message?.payload?.query === ""
+ : null,
+ market: type === "change-create-theme-market"
+ ? message?.payload?.market ?? null
+ : null,
+ editorName: type === "change-create-theme-market"
+ ? message?.payload?.editorName ?? null
: null
};
probe.outboundMessages.push(record);
@@ -2826,12 +3347,37 @@ async function executeSafeSequence() {
}
await checkpoint("playlist-keyboard-focus-split");
+ current = await dragCutsToPlaylist(
+ 1,
+ 0,
+ 1,
+ "stage first disposable Shift-delete row");
+ // MainForm detects a double-click from two MouseDown events at the exact
+ // same cut coordinates within 500 ms, even when both gestures become drags.
+ // Let that original window expire so fixture setup does not append once for
+ // the second pointer-down and once again for its completed drop.
+ await sleep(legacyCutDoubleClickWindowMilliseconds + 100);
+ current = await dragCutsToPlaylist(
+ 1,
+ 0,
+ 1,
+ "stage second disposable Shift-delete row");
+ const stagedDeleteRows = current.state.playlist.slice(-2);
+ if (current.state.playlist.length !== 5 || stagedDeleteRows.length !== 2 ||
+ stagedDeleteRows.some(row =>
+ row.graphicType !== "1열판기본" || row.subtype !== "현재가")) {
+ failKnown("The disposable Shift-delete fixture is not the exact two-row tail.");
+ }
+ const [rowD, rowE] = stagedDeleteRows.map(row => row.rowId);
+
current = await requestWindowsInput(
current,
rowB,
rowA,
rowC,
- "Windows SendInput row-header drag and Home key");
+ rowD,
+ rowE,
+ "Windows SendInput drag, Home, Shift range, Delete, and restore");
await pointerClick(
'document.getElementById("db-load-button")',
@@ -2980,20 +3526,37 @@ async function executeDryRunPlayoutSequence() {
failKnown("DryRun playout preflight lacks the exact legacy selection split.");
}
- // The preceding interaction sequence deliberately leaves two distinct current-
- // quote rows adjacent. After-hours/expected quote tables are session-dependent;
- // the original application also rejects those rows when no exact quote exists.
- // Use the two current rows so this check isolates NEXT row semantics from market
- // session data availability.
- const stagedEntryId = current.state.playlist[2].rowId;
- const selectedEntryId = current.state.playlist[0].rowId;
- const nextEntryId = current.state.playlist[1].rowId;
+ // The preceding interaction sequence leaves current/current/after-hours rows.
+ // Physically move the session-dependent after-hours row between the two current
+ // rows, TAKE IN the first current row, then disable the after-hours successor
+ // with Space. NEXT must skip it and reach the second current row. Restore the
+ // original order after TAKE OUT so the outer evidence seal remains deterministic.
+ const originalOrder = current.state.playlist.map(row => row.rowId);
+ if (current.state.playlist[0].subtype !== "현재가" ||
+ current.state.playlist[1].subtype !== "현재가" ||
+ current.state.playlist[2].subtype !== "시간외단일가") {
+ failKnown("DryRun NEXT fixture is not current/current/after-hours.");
+ }
+ const selectedEntryId = originalOrder[0];
+ const nextEntryId = originalOrder[1];
+ const skippedEntryId = originalOrder[2];
+ const stagedEntryId = skippedEntryId;
const playlistCount = current.state.playlist.length;
evidence.dryRunPlayout.stagedEntryId = stagedEntryId;
evidence.dryRunPlayout.selectedEntryId = selectedEntryId;
+ evidence.dryRunPlayout.skippedEntryId = skippedEntryId;
evidence.dryRunPlayout.nextEntryId = nextEntryId;
evidence.dryRunPlayout.playlistCountBeforeSelection = playlistCount;
+ await dragPlaylistRow(
+ skippedEntryId,
+ selectedEntryId,
+ "place after-hours row before the known NEXT current row");
+ current = await waitFor("DryRun session-dependent row staging", snapshot =>
+ JSON.stringify(snapshot.state.playlist.map(row => row.rowId)) ===
+ JSON.stringify([selectedEntryId, skippedEntryId, nextEntryId]),
+ { allowUiBusy: false });
+
// Move the F8 candidate away from the target first. If the following rapid row
// click were dropped, TAKE IN would remain on this distinct staged row and the
// selected-row assertion below would fail deterministically.
@@ -3006,17 +3569,23 @@ async function executeDryRunPlayoutSequence() {
JSON.stringify([stagedEntryId]),
{ allowUiBusy: false });
- // Keep the real user timing: release the row click and immediately press TAKE IN,
- // without waiting for the row-selection state receipt. The native ordered intent
- // gate must preserve this input order instead of silently dropping F8/TAKE IN.
+ // Keep the real user timing: authorize F8 from the exact known IDLE snapshot,
+ // release the row click, and immediately dispatch the physical key without a
+ // state read in between. The one-shot authorization cannot be reused if either
+ // key event becomes ambiguous.
+ const takeInIntentBaseline = current.safety.outboundMessageCount;
+ const f8Authorization = authorizeDryRunPlayoutShortcut(
+ "F8",
+ current,
+ "rapid DryRun F8");
await pointerClickPlaylistRow(
selectedEntryId,
"rapid select playlist row before DryRun TAKE IN");
- await pointerClick(
- 'document.getElementById("playout-take-in")',
- "rapid DryRun TAKE IN after row selection",
+ await pressKey(
+ "F8",
+ "rapid DryRun F8 after row selection",
0,
- { skipPointerReady: true });
+ { dryRunPlayoutShortcutAuthorization: f8Authorization });
current = await waitFor("rapid selected-row DryRun TAKE IN", snapshot =>
snapshot.state.playout.phase === "program" &&
snapshot.state.playout.currentEntryId === selectedEntryId &&
@@ -3026,6 +3595,17 @@ async function executeDryRunPlayoutSequence() {
if (current.state.playout.nextKind !== "playlistNext") {
failKnown("DryRun TAKE IN did not expose playlist NEXT from the selected row.");
}
+ const takeInIntents = outboundMessagesAfter(current, takeInIntentBaseline)
+ .filter(message => message.type === "take-in");
+ if (takeInIntents.length !== 1 ||
+ outboundMessagesAfter(current, takeInIntentBaseline).at(-1)?.type !== "take-in") {
+ failUnknown("DryRun F8 did not emit exactly one terminal TAKE IN intent.");
+ }
+ evidence.dryRunPlayout.shortcutParity = {
+ f8IntentCount: 1,
+ escapeIntentCount: 0,
+ finalPhase: "pending"
+ };
const selectableCut = current.state.cutRows.find(row =>
row.isSeparator !== true && row.isSelected !== true);
@@ -3050,35 +3630,160 @@ async function executeDryRunPlayoutSequence() {
await pointerClick(
cutExpression,
- "PROGRAM protected double-click first press",
+ "PROGRAM safe-tail double-click first press",
0,
{ allowActiveDryRun: true });
await pointerClick(
cutExpression,
- "PROGRAM protected double-click second press",
+ "PROGRAM safe-tail double-click second press",
0,
{ allowActiveDryRun: true });
- await sleep(150);
- current = await readSnapshot();
- assertSafety(current, "PROGRAM protected double-click", false, false, true);
+ current = await waitFor("PROGRAM safe-tail double-click append", snapshot =>
+ snapshot.state.playout.phase === "program" &&
+ snapshot.state.playout.currentEntryId === selectedEntryId &&
+ snapshot.state.playlist.length === playlistCount + 1 &&
+ snapshot.state.isBusy !== true &&
+ snapshot.state.playout.isBusy !== true,
+ { allowUiBusy: true, allowActiveDryRun: true });
+ assertSafety(current, "PROGRAM safe-tail double-click", false, false, true);
const recentProgramCutMessages = current.safety.outboundMessages
.filter(message => message.type === "cut-pointer-down")
.slice(-3);
- if (current.state.playlist.length !== playlistCount ||
+ if (current.state.playlist.length !== playlistCount + 1 ||
recentProgramCutMessages.length !== 3 ||
- recentProgramCutMessages.some(message => message.allowAppend !== false)) {
- failKnown("PROGRAM cut selection changed the playlist or retained append authority.");
+ recentProgramCutMessages.some(message => message.allowAppend !== true)) {
+ failKnown("PROGRAM cut selection did not preserve safe tail-append authority.");
}
evidence.dryRunPlayout.playlistCountAfterDoubleClick =
current.state.playlist.length;
+ evidence.dryRunPlayout.tailAppendedEntryId =
+ current.state.playlist[current.state.playlist.length - 1].rowId;
+
+ const futureRowIds = current.state.playlist
+ .slice(current.state.playout.currentCueIndexZeroBased + 1)
+ .map(row => row.rowId);
+ if (futureRowIds.length < 3 || futureRowIds[0] !== skippedEntryId ||
+ futureRowIds[1] !== nextEntryId ||
+ futureRowIds[2] !== evidence.dryRunPlayout.tailAppendedEntryId) {
+ failKnown("PROGRAM enabled-state validation lacks the exact future-row tail.");
+ }
+
+ let enableIntentBaseline = current.safety.outboundMessageCount;
+ await pointerClick(
+ 'document.getElementById("playlist-enable-all")',
+ "PROGRAM clear every enabled row",
+ 0,
+ { allowActiveDryRun: true });
+ current = await waitFor("PROGRAM all rows disabled state", snapshot => {
+ return snapshot.state.playout.phase === "program" &&
+ snapshot.state.playout.currentEntryId === selectedEntryId &&
+ snapshot.state.playlist.every(row => row.isEnabled === false) &&
+ snapshot.state.playout.nextKind === "endOfPlaylist" &&
+ snapshot.dom.playlistEnableAll.disabled === false &&
+ snapshot.dom.playlistEnableAll.checked === false &&
+ snapshot.dom.playlistEnableAll.indeterminate === false &&
+ snapshot.state.isBusy !== true && snapshot.state.playout.isBusy !== true;
+ }, { timeoutMilliseconds: 60_000, allowUiBusy: true, allowActiveDryRun: true });
+ let enableIntents = assertExactOutboundTypes(
+ current,
+ enableIntentBaseline,
+ ["set-all-playlist-enabled"],
+ "PROGRAM clear every row");
+ if (enableIntents[0].enabled !== false) {
+ failKnown("PROGRAM clear-all did not carry enabled=false.");
+ }
+
+ enableIntentBaseline = current.safety.outboundMessageCount;
+ await pointerClick(
+ 'document.getElementById("playlist-enable-all")',
+ "PROGRAM restore every enabled row",
+ 0,
+ { allowActiveDryRun: true });
+ current = await waitFor("PROGRAM all rows enabled state", snapshot => {
+ return snapshot.state.playout.phase === "program" &&
+ snapshot.state.playout.currentEntryId === selectedEntryId &&
+ snapshot.state.playlist.every(row => row.isEnabled === true) &&
+ snapshot.state.playout.nextKind === "playlistNext" &&
+ snapshot.dom.playlistEnableAll.disabled === false &&
+ snapshot.dom.playlistEnableAll.checked === true &&
+ snapshot.dom.playlistEnableAll.indeterminate === false &&
+ snapshot.state.isBusy !== true && snapshot.state.playout.isBusy !== true;
+ }, { timeoutMilliseconds: 60_000, allowUiBusy: true, allowActiveDryRun: true });
+ enableIntents = assertExactOutboundTypes(
+ current,
+ enableIntentBaseline,
+ ["set-all-playlist-enabled"],
+ "PROGRAM restore every row");
+ if (enableIntents[0].enabled !== true) {
+ failKnown("PROGRAM restore-all did not carry enabled=true.");
+ }
+
+ // Original MainForm ignored a mouse press in the checkbox column during
+ // PROGRAM, but the same row could be made active and toggled with Space.
+ // Dispatch the blocked pointer path first and prove that no per-row enabled
+ // intent escapes before using the focused row's real Space key event.
+ enableIntentBaseline = current.safety.outboundMessageCount;
+ await pointerClickDisabledPlaylistCheckbox(
+ skippedEntryId,
+ "PROGRAM disabled future-row mouse checkbox");
+ current = await waitFor("PROGRAM disabled checkbox focus only", snapshot => {
+ const stateRow = snapshot.state.playlist.find(row => row.rowId === skippedEntryId);
+ const domRow = snapshot.dom.playlist.find(row => row.rowId === skippedEntryId);
+ return snapshot.state.playout.phase === "program" &&
+ stateRow?.isEnabled === true && stateRow?.isActive === true &&
+ domRow?.enabled === true && domRow?.checkboxAriaDisabled === true &&
+ domRow?.focused === true &&
+ snapshot.dom.activeElement?.playlistRowId === skippedEntryId &&
+ snapshot.state.playout.nextKind === "playlistNext" &&
+ snapshot.state.isBusy !== true;
+ }, { timeoutMilliseconds: 60_000, allowUiBusy: true, allowActiveDryRun: true });
+ const disabledCheckboxMessages = outboundMessagesAfter(
+ current,
+ enableIntentBaseline);
+ if (disabledCheckboxMessages.some(message =>
+ new Set([
+ "set-playlist-enabled",
+ "set-all-playlist-enabled",
+ "toggle-active-playlist-enabled"
+ ]).has(message.type))) {
+ failKnown("PROGRAM mouse checkbox emitted an enabled-state mutation intent.");
+ }
+
+ enableIntentBaseline = current.safety.outboundMessageCount;
+ await pressKey("Space", "PROGRAM future-row Space enabled toggle");
+ current = await waitFor("PROGRAM future-row Space disabled state", snapshot => {
+ const row = snapshot.state.playlist.find(candidate =>
+ candidate.rowId === skippedEntryId);
+ return snapshot.state.playout.phase === "program" &&
+ row?.isEnabled === false && row?.isActive === true &&
+ snapshot.state.playout.nextKind === "playlistNext" &&
+ snapshot.state.isBusy !== true && snapshot.state.playout.isBusy !== true;
+ }, { timeoutMilliseconds: 60_000, allowUiBusy: true, allowActiveDryRun: true });
+ assertExactOutboundTypes(
+ current,
+ enableIntentBaseline,
+ ["toggle-active-playlist-enabled"],
+ "PROGRAM future-row Space toggle");
+ evidence.dryRunPlayout.programEnableParity = {
+ currentEntryId: selectedEntryId,
+ skippedEntryId,
+ nextEntryId,
+ futureRowCount: futureRowIds.length,
+ mouseCheckboxDisabled: true,
+ setAllIntentCount: 2,
+ setAllAppliesEveryRow: true,
+ toggleActiveIntentCount: 1,
+ endOfPlaylistObserved: true,
+ playlistNextRestored: true
+ };
await pointerClick(
'document.getElementById("playout-next")',
- "DryRun NEXT from selected TAKE IN row",
+ "DryRun NEXT skips the Space-disabled future row",
0,
{ allowActiveDryRun: true });
const revisionBeforeNext = current.state.revision;
- current = await waitFor("DryRun NEXT selected-row successor", snapshot => {
+ current = await waitFor("DryRun NEXT enabled successor after skipped row", snapshot => {
if (snapshot.state.revision > revisionBeforeNext &&
snapshot.state.playout.isBusy !== true &&
snapshot.state.isBusy !== true &&
@@ -3088,23 +3793,56 @@ async function executeDryRunPlayoutSequence() {
}
return snapshot.state.playout.phase === "program" &&
snapshot.state.playout.currentEntryId === nextEntryId &&
+ snapshot.state.playlist.find(row => row.rowId === skippedEntryId)
+ ?.isEnabled === false &&
snapshot.state.playout.isBusy !== true &&
snapshot.state.isBusy !== true;
},
{ allowUiBusy: true, allowActiveDryRun: true });
- await pointerClick(
- 'document.getElementById("playout-take-out")',
- "DryRun TAKE OUT after NEXT",
+ const takeOutIntentBaseline = current.safety.outboundMessageCount;
+ const escapeAuthorization = authorizeDryRunPlayoutShortcut(
+ "Escape",
+ current,
+ "DryRun Escape TAKE OUT");
+ await pressKey(
+ "Escape",
+ "DryRun Escape TAKE OUT after NEXT",
0,
- { allowActiveDryRun: true });
+ { dryRunPlayoutShortcutAuthorization: escapeAuthorization });
current = await waitFor("DryRun TAKE OUT IDLE", snapshot =>
snapshot.state.playout.phase === "idle" &&
snapshot.state.playout.currentEntryId == null &&
+ snapshot.state.playout.preparedCode == null &&
snapshot.state.playout.onAirCode == null &&
+ snapshot.state.playout.outcomeUnknown !== true &&
+ snapshot.state.playout.isPlayCompletionPending !== true &&
+ snapshot.state.playout.isTakeOutCompletionPending !== true &&
+ snapshot.state.playout.refreshActive !== true &&
snapshot.state.playout.isBusy !== true &&
snapshot.state.isBusy !== true,
{ allowUiBusy: true, allowActiveDryRun: true });
+ const takeOutIntents = outboundMessagesAfter(current, takeOutIntentBaseline)
+ .filter(message => message.type === "take-out");
+ if (takeOutIntents.length !== 1 ||
+ outboundMessagesAfter(current, takeOutIntentBaseline).at(-1)?.type !== "take-out") {
+ failUnknown("DryRun Escape did not emit exactly one terminal TAKE OUT intent.");
+ }
+ evidence.dryRunPlayout.shortcutParity.escapeIntentCount = 1;
+ evidence.dryRunPlayout.shortcutParity.finalPhase = current.state.playout.phase;
+
+ await dragPlaylistRow(
+ skippedEntryId,
+ nextEntryId,
+ "restore original playlist order after DryRun TAKE OUT");
+ current = await waitFor("restore original playlist order after DryRun", snapshot =>
+ snapshot.state.playout.phase === "idle" &&
+ JSON.stringify(snapshot.state.playlist.map(row => row.rowId)) ===
+ JSON.stringify([
+ ...originalOrder,
+ evidence.dryRunPlayout.tailAppendedEntryId
+ ]),
+ { allowUiBusy: false });
await pointerClickPlaylistRow(
originalSelectedIds[0],
@@ -3126,6 +3864,7 @@ async function executeDryRunPlayoutSequence() {
await checkpoint("dry-run-playout-selection-next", {
stagedEntryId,
selectedEntryId,
+ skippedEntryId,
nextEntryId,
programCutPhysicalIndex: selectableCut.physicalIndex,
playlistCount
@@ -3147,17 +3886,347 @@ function namedDefinitionExpression(title) {
.find(button => button.textContent === ${JSON.stringify(title)})`;
}
+function playlistContentProjection(snapshot) {
+ return snapshot.state.playlist.map(row => ({
+ isEnabled: row.isEnabled,
+ marketText: row.marketText,
+ stockName: row.stockName,
+ graphicType: row.graphicType,
+ subtype: row.subtype,
+ pageText: row.pageText
+ }));
+}
+
+function namedPlaylistCommandSequence(snapshot) {
+ const sequence = snapshot?.state?.commandResult?.sequence;
+ return Number.isSafeInteger(sequence) ? sequence : 0;
+}
+
+function completedNamedPlaylistRefreshAfter(snapshot, baselineSequence) {
+ const result = snapshot?.state?.commandResult;
+ return result?.command === "refresh-named-playlists" &&
+ result.succeeded === true && Number.isSafeInteger(result.sequence) &&
+ result.sequence > baselineSequence &&
+ snapshot.state.namedPlaylist?.listFreshness === "fresh";
+}
+
+async function requestNamedPlaylistLoadDoubleClick(current, title, label) {
+ assertSafety(current, `${label} preflight`, false);
+ if (evidence.windowsInput.exchanges.length !== 1 ||
+ configuration.windowsInputExchangeCount !== 2 ||
+ current.dom.namedModalHidden !== false ||
+ current.dom.namedConfirmationHidden !== true ||
+ current.dom.namedTitle !== "DB 재생목록 불러오기" ||
+ current.state.namedPlaylist?.listFreshness !== "fresh") {
+ failKnown(`${label} is outside the exact fresh PList load-modal state.`);
+ }
+ const matches = current.state.namedPlaylist.definitions
+ .filter(definition => definition.title === title);
+ if (matches.length !== 1 || typeof matches[0].definitionId !== "string" ||
+ matches[0].definitionId.length === 0 || matches[0].definitionId.length > 256 ||
+ /[\u0000-\u001f\u007f]/u.test(matches[0].definitionId)) {
+ failKnown(`${label} does not have one safe opaque saved-list identity.`);
+ }
+ const definitionId = matches[0].definitionId;
+ if (matches[0].isSelected === true ||
+ current.state.namedPlaylist.selectedDefinitionId === definitionId ||
+ current.state.namedPlaylist.selectedTitle === title) {
+ failKnown(`${label} must start on the freshly read, unselected saved row.`);
+ }
+ const beforeContent = playlistContentProjection(current);
+ if (beforeContent.length !== 3) {
+ failKnown(`${label} requires the exact three-row in-memory fixture.`);
+ }
+ const beforeApprovedWriteCount = current.safety.approvedDatabaseWriteMessages.length;
+ const outboundSequence = current.safety.outboundMessages.length;
+ const point = await elementGeometry(namedDefinitionExpression(title), label);
+ await sleep(100);
+ const guarded = await readSnapshot();
+ const guardedPoint = await elementGeometry(
+ namedDefinitionExpression(title),
+ `${label} guarded target`);
+ const geometryStable = ["x", "y", "left", "top", "right", "bottom"]
+ .every(key => Math.abs(point[key] - guardedPoint[key]) <= 0.5);
+ if (!geometryStable || guarded.dom.namedModalHidden !== false ||
+ guarded.dom.namedConfirmationHidden !== true ||
+ guarded.state.namedPlaylist?.definitions
+ ?.filter(definition => definition.title === title &&
+ definition.definitionId === definitionId &&
+ definition.isSelected !== true).length !== 1 ||
+ guarded.state.namedPlaylist?.selectedDefinitionId === definitionId ||
+ guarded.state.namedPlaylist?.selectedTitle === title ||
+ JSON.stringify(playlistContentProjection(guarded)) !== JSON.stringify(beforeContent)) {
+ failKnown(`${label} geometry or playlist content changed before publication.`);
+ }
+
+ const token = randomBytes(16).toString("hex").toUpperCase();
+ const request = {
+ schemaVersion: 1,
+ exchangeIndex: 2,
+ token,
+ targetUrl: exactTargetUrl,
+ operation: "named-playlist-load-double-click",
+ definitionId,
+ definitionTitle: title,
+ point: { x: point.x, y: point.y },
+ viewport: { ...guarded.dom.viewport },
+ devicePixelRatio: guarded.devicePixelRatio,
+ expectedPlaylistContent: beforeContent,
+ mouseDownCalls: 2,
+ mouseUpCalls: 2,
+ doubleClickIntervalMilliseconds: 100,
+ inputRetryCount: 0
+ };
+ await publishWindowsInputRequest(request, label, {
+ mouseDownCalls: 2,
+ mouseUpCalls: 2,
+ doubleClickIntervalMilliseconds: 100
+ });
+
+ const completed = await waitFor(label, snapshot =>
+ snapshot.state.isBusy === false &&
+ snapshot.dom.namedModalHidden === true &&
+ snapshot.dom.namedConfirmationHidden === true &&
+ snapshot.state.namedPlaylist?.selectedTitle === title &&
+ snapshot.state.namedPlaylist?.rowCount === 3 &&
+ snapshot.state.commandResult?.command === "load-named-playlist-by-id" &&
+ snapshot.state.commandResult?.targetId === definitionId &&
+ snapshot.state.commandResult?.succeeded === true &&
+ JSON.stringify(playlistContentProjection(snapshot)) === JSON.stringify(beforeContent),
+ { timeoutMilliseconds: 60_000, allowUiBusy: true });
+ const outbound = outboundMessagesAfter(completed, outboundSequence);
+ const loads = outbound.filter(message => message.type === "load-named-playlist-by-id");
+ const selections = outbound.filter(message => message.type === "select-named-playlist");
+ assertExactArray(
+ outbound.map(message => message.type),
+ ["select-named-playlist", "load-named-playlist-by-id"],
+ `${label} outbound intents`);
+ if (loads.length !== 1 || loads[0].definitionId !== definitionId ||
+ selections.length !== 1 || selections[0].definitionId !== definitionId ||
+ completed.safety.approvedDatabaseWriteMessages.length !== beforeApprovedWriteCount) {
+ failKnown(`${label} did not select then load the clicked row with no database write.`);
+ }
+ await checkpoint("windows-sendinput-named-playlist-load-double-click", {
+ definitionId,
+ title,
+ rowCount: beforeContent.length,
+ startedUnselected: true,
+ selectionIntentCount: selections.length,
+ loadIntentCount: loads.length,
+ contentRestored: true
+ });
+ return completed;
+}
+
+function namedPlaylistDeleteWriteCount(snapshot) {
+ return (snapshot?.safety?.approvedDatabaseWriteMessages || [])
+ .filter(message => message.type === "delete-selected-named-playlist").length;
+}
+
+function assertNamedPlaylistRecoverySafe(
+ snapshot,
+ label,
+ baselineWriteCount = null,
+ baselineCommandSequence = null) {
+ const named = snapshot?.state?.namedPlaylist;
+ if (!named) failKnown(`Named-playlist state is missing at ${label}.`);
+ if (named.isWriteQuarantined === true ||
+ named.lastMutationOutcome === "outcomeUnknown" ||
+ named.lastMutationOutcome === "quarantined") {
+ failUnknown(`Named-playlist writes are quarantined or unknown at ${label}; no action is retried.`);
+ }
+ if (baselineWriteCount !== null) {
+ const writeCount = namedPlaylistDeleteWriteCount(snapshot) - baselineWriteCount;
+ if (writeCount < 0 || writeCount > 1) {
+ failUnknown(`Named-playlist recovery emitted an invalid delete count at ${label}; no action is retried.`);
+ }
+ const commandResult = snapshot.state.commandResult;
+ if (writeCount === 1 && baselineCommandSequence !== null &&
+ Number.isSafeInteger(commandResult?.sequence) &&
+ commandResult.sequence > baselineCommandSequence &&
+ commandResult.command === "delete-selected-named-playlist" &&
+ commandResult.succeeded === false) {
+ failKnown(`Named-playlist recovery delete was rejected at ${label}; no action is retried.`);
+ }
+ }
+}
+
+async function waitForNamedPlaylistRecovery(label, predicate, options = {}) {
+ return await waitFor(label, snapshot => {
+ assertNamedPlaylistRecoverySafe(
+ snapshot,
+ label,
+ options.baselineWriteCount ?? null,
+ options.baselineCommandSequence ?? null);
+ return predicate(snapshot);
+ }, options);
+}
+
+async function recoverNamedPlaylistFixture() {
+ const title = configuration.recoverNamedPlaylistTitle;
+ const recovery = evidence.fullUiDb?.namedPlaylistRecovery;
+ if (!title || !recovery) return;
+
+ let current = await readSnapshot();
+ assertNamedPlaylistRecoverySafe(current, "PList recovery preflight");
+ const initialWriteCount = namedPlaylistDeleteWriteCount(current);
+ if (initialWriteCount !== 0) {
+ failUnknown("PList recovery found a prior delete intent in this one-shot run.");
+ }
+ const recoveryRefreshBaseline = namedPlaylistCommandSequence(current);
+ await pointerClick(
+ 'document.getElementById("db-load-button")',
+ "PList recovery fresh list open");
+ current = await waitForNamedPlaylistRecovery(
+ "PList recovery fresh list",
+ snapshot => snapshot.state.isBusy === false &&
+ snapshot.dom.namedModalHidden === false &&
+ snapshot.dom.namedConfirmationHidden === true &&
+ completedNamedPlaylistRefreshAfter(snapshot, recoveryRefreshBaseline),
+ { timeoutMilliseconds: 60_000, allowUiBusy: true });
+ if (current.state.namedPlaylist.isListTruncated !== false) {
+ failKnown("PList recovery cannot prove an exact target against a truncated fresh list.");
+ }
+ recovery.freshListVerified = true;
+
+ const exactMatches = current.state.namedPlaylist.definitions
+ .filter(definition => definition.title === title);
+ recovery.exactMatchCount = exactMatches.length;
+ if (exactMatches.length > 1) {
+ failKnown("PList recovery target is duplicated in the fresh DB list; no delete was attempted.");
+ }
+ if (current.state.namedPlaylist.canMutate !== true) {
+ failKnown("PList recovery requires a writable fresh named-playlist list.");
+ }
+
+ if (exactMatches.length === 0) {
+ if (namedPlaylistDeleteWriteCount(current) !== initialWriteCount) {
+ failUnknown("PList recovery observed an unexpected delete intent before absence was sealed.");
+ }
+ recovery.result = "alreadyAbsent";
+ recovery.writeCount = 0;
+ recovery.exactAbsenceVerified = true;
+ recordFullUiDbCheck("databaseChecks", "PList recovery already absent", {
+ targetTitle: title,
+ writeCount: 0
+ });
+ await pointerClick(
+ 'document.getElementById("named-playlist-cancel-button")',
+ "PList recovery already-absent close");
+ current = await waitForNamedPlaylistRecovery(
+ "PList recovery already-absent close",
+ snapshot => snapshot.state.isBusy === false &&
+ snapshot.dom.namedModalHidden === true,
+ { allowUiBusy: false, baselineWriteCount: initialWriteCount });
+ if (namedPlaylistDeleteWriteCount(current) !== initialWriteCount) {
+ failUnknown("PList recovery emitted a delete after sealing an already-absent target.");
+ }
+ return;
+ }
+
+ const target = exactMatches[0];
+ await pointerClick(namedDefinitionExpression(title), "PList recovery exact target select");
+ current = await waitForNamedPlaylistRecovery(
+ "PList recovery exact target selection",
+ snapshot => snapshot.state.isBusy === false &&
+ snapshot.dom.namedModalHidden === false &&
+ snapshot.state.namedPlaylist.selectedDefinitionId === target.definitionId &&
+ snapshot.state.namedPlaylist.selectedTitle === title &&
+ snapshot.state.namedPlaylist.canDelete === true,
+ { timeoutMilliseconds: 60_000, allowUiBusy: true });
+
+ const beforeDeleteWriteCount = namedPlaylistDeleteWriteCount(current);
+ const beforeDeleteCommandSequence = Number.isSafeInteger(
+ current.state.commandResult?.sequence)
+ ? current.state.commandResult.sequence
+ : 0;
+ if (beforeDeleteWriteCount !== initialWriteCount) {
+ failUnknown("PList recovery observed an unexpected delete before confirmation.");
+ }
+ await pointerClick(
+ 'document.getElementById("named-playlist-delete-button")',
+ "PList recovery open existing delete confirmation");
+ await waitForNamedPlaylistRecovery(
+ "PList recovery existing delete confirmation",
+ snapshot => snapshot.state.isBusy === false &&
+ snapshot.dom.namedModalHidden === false &&
+ snapshot.dom.namedConfirmationHidden === false,
+ {
+ allowUiBusy: false,
+ baselineWriteCount: beforeDeleteWriteCount,
+ baselineCommandSequence: beforeDeleteCommandSequence
+ });
+ await pointerClick(
+ 'document.getElementById("named-playlist-confirmation-yes")',
+ "PList recovery confirm exact delete once");
+ current = await waitForNamedPlaylistRecovery(
+ "PList recovery committed exact absence",
+ snapshot => {
+ const outcome = snapshot.state.namedPlaylist.lastMutationOutcome;
+ const writeCount = namedPlaylistDeleteWriteCount(snapshot) - beforeDeleteWriteCount;
+ const remaining = snapshot.state.namedPlaylist.definitions
+ .filter(definition => definition.title === title);
+ return snapshot.state.isBusy === false &&
+ snapshot.state.namedPlaylist.isWriteInProgress !== true &&
+ snapshot.state.namedPlaylist.isListTruncated === false &&
+ snapshot.dom.namedConfirmationHidden === true &&
+ writeCount === 1 && remaining.length === 0 &&
+ (outcome === "committedFresh" || outcome === "committedOptimistic");
+ },
+ {
+ timeoutMilliseconds: 60_000,
+ allowUiBusy: true,
+ baselineWriteCount: beforeDeleteWriteCount,
+ baselineCommandSequence: beforeDeleteCommandSequence
+ });
+
+ const recoveryWriteCount =
+ namedPlaylistDeleteWriteCount(current) - beforeDeleteWriteCount;
+ const exactAbsence = !current.state.namedPlaylist.definitions
+ .some(definition => definition.title === title);
+ if (recoveryWriteCount !== 1 || !exactAbsence) {
+ failUnknown("PList recovery did not seal one exact delete and absence; no action is retried.");
+ }
+ recovery.result = "deleted";
+ recovery.writeCount = recoveryWriteCount;
+ recovery.mutationOutcome = current.state.namedPlaylist.lastMutationOutcome;
+ recovery.exactAbsenceVerified = true;
+ recordFullUiDbCheck("databaseChecks", "PList recovery exact delete/absence", {
+ targetTitle: title,
+ writeCount: recoveryWriteCount,
+ mutationOutcome: recovery.mutationOutcome
+ });
+
+ await pointerClick(
+ 'document.getElementById("named-playlist-cancel-button")',
+ "PList recovery close after exact delete");
+ await waitForNamedPlaylistRecovery(
+ "PList recovery close after exact delete",
+ snapshot => snapshot.state.isBusy === false &&
+ snapshot.dom.namedModalHidden === true,
+ {
+ allowUiBusy: false,
+ baselineWriteCount: beforeDeleteWriteCount,
+ baselineCommandSequence: beforeDeleteCommandSequence
+ });
+}
+
async function executeNamedPlaylistCrud() {
const title = evidence.fixture.namedPlaylistTitle;
+ if (title === configuration.recoverNamedPlaylistTitle) {
+ failKnown("The generated PList CRUD fixture collided with the explicit recovery target.");
+ }
let current = await readSnapshot();
if (current.state.namedPlaylist.definitions.some(row => row.title === title)) {
failKnown("The generated PList fixture already exists before mutation.");
}
+ const saveRefreshBaseline = namedPlaylistCommandSequence(current);
await pointerClick('document.getElementById("db-save-button")',
"PList open save dialog");
current = await waitFor("PList fresh definition read", snapshot =>
- snapshot.dom.namedModalHidden === false && snapshot.state.isBusy === false,
+ snapshot.dom.namedModalHidden === false && snapshot.state.isBusy === false &&
+ completedNamedPlaylistRefreshAfter(snapshot, saveRefreshBaseline),
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
if (current.state.namedPlaylist.definitions.some(row => row.title === title)) {
failKnown("The generated PList fixture appeared during the preflight read.");
@@ -3188,10 +4257,10 @@ async function executeNamedPlaylistCrud() {
snapshot.dom.namedConfirmationHidden === false &&
snapshot.state.isBusy === false,
{ allowUiBusy: false });
- await pointerClickWithJavaScriptDialog(
+ await pointerClickWithNativeDialog(
'document.getElementById("named-playlist-confirmation-yes")',
"PList confirm row save",
- "alert",
+ false,
"저장되었습니다",
true);
current = await waitFor("PList row save commit", snapshot =>
@@ -3205,32 +4274,43 @@ async function executeNamedPlaylistCrud() {
rowCount: current.state.namedPlaylist.rowCount
});
+ const readbackRefreshBaseline = namedPlaylistCommandSequence(current);
await pointerClick('document.getElementById("db-load-button")',
"PList reopen for fresh readback");
- current = await waitFor("PList fresh readback", snapshot =>
- snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === false &&
- snapshot.state.namedPlaylist.definitions.some(row => row.title === title),
+ current = await waitFor("PList fresh readback", snapshot => {
+ const matches = snapshot.state.namedPlaylist?.definitions
+ ?.filter(row => row.title === title) || [];
+ return snapshot.state.isBusy === false &&
+ snapshot.dom.namedModalHidden === false &&
+ completedNamedPlaylistRefreshAfter(snapshot, readbackRefreshBaseline) &&
+ matches.length === 1 && typeof matches[0].definitionId === "string" &&
+ matches[0].definitionId.length > 0 &&
+ matches[0].definitionId.length <= 256 &&
+ !/[\u0000-\u001f\u007f]/u.test(matches[0].definitionId) &&
+ matches[0].isSelected !== true &&
+ snapshot.state.namedPlaylist.selectedDefinitionId !== matches[0].definitionId &&
+ snapshot.state.namedPlaylist.selectedTitle !== title;
+ },
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
- await pointerClick(namedDefinitionExpression(title),
- "PList select fresh readback");
- current = await waitFor("PList fresh selected readback", snapshot =>
- snapshot.state.isBusy === false &&
- snapshot.state.namedPlaylist.selectedTitle === title,
- { timeoutMilliseconds: 60_000, allowUiBusy: true });
- await pointerClick('document.getElementById("named-playlist-confirm-button")',
- "PList load fresh rows");
- current = await waitFor("PList load close", snapshot =>
- snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === true &&
- snapshot.state.playlist.length === 3,
- { timeoutMilliseconds: 60_000, allowUiBusy: true });
- recordFullUiDbCheck("databaseChecks", "PList fresh readback", {
- rowCount: current.state.playlist.length
+ current = await requestNamedPlaylistLoadDoubleClick(
+ current,
+ title,
+ "PList physical saved-row double-click load");
+ recordFullUiDbCheck("databaseChecks", "PList physical double-click fresh readback", {
+ rowCount: current.state.playlist.length,
+ input: "Windows SendInput double-click",
+ startedUnselected: true,
+ selectionIntentCount: 1,
+ loadIntentCount: 1,
+ contentRestored: true
});
+ const deleteRefreshBaseline = namedPlaylistCommandSequence(current);
await pointerClick('document.getElementById("db-load-button")',
"PList reopen for delete");
await waitFor("PList delete list read", snapshot =>
snapshot.state.isBusy === false && snapshot.dom.namedModalHidden === false &&
+ completedNamedPlaylistRefreshAfter(snapshot, deleteRefreshBaseline) &&
snapshot.state.namedPlaylist.definitions.some(row => row.title === title),
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
await pointerClick(namedDefinitionExpression(title), "PList select delete target");
@@ -3280,7 +4360,7 @@ async function openManualFinancial(screen, label) {
}
async function closeManualFinancial(label) {
- await pointerClick('document.getElementById("manual-financial-close")', label);
+ await pointerClick('document.getElementById("manual-financial-exit")', label);
await waitFor(`${label} complete`, snapshot =>
snapshot.state.isBusy === false && snapshot.state.manualFinancial == null &&
snapshot.dom.manualFinancialModalHidden === true,
@@ -3367,6 +4447,16 @@ async function fillManualFinancialRecord(screen) {
}
}
+function manualFinancialNonStockDraftExpression(screen) {
+ if (screen === "revenue-composition") {
+ return 'document.querySelector("[data-manual-base-date]")';
+ }
+ if (screen === "growth-metrics") {
+ return 'document.querySelector("[data-manual-growth-period=\\"0\\"]")';
+ }
+ return 'document.querySelector("[data-manual-quarter-label=\\"0\\"]")';
+}
+
async function executeManualFinancialCrud() {
const stockName = evidence.fixture.databaseStockDisplayName;
await preflightManualFinancialIdentities();
@@ -3392,16 +4482,55 @@ async function executeManualFinancialCrud() {
if (candidates.length !== 1) {
failKnown(`GraphE ${screen} fixture stock is not unique in the master.`);
}
- await pointerClick(
- `document.querySelector('[data-manual-stock-result-id="${candidates[0].resultId}"]')`,
- `GraphE ${screen} select verified stock`);
- await waitFor(`GraphE ${screen} verified stock`, snapshot =>
+ const candidateExpression =
+ `document.querySelector('[data-manual-stock-result-id="${candidates[0].resultId}"]')`;
+ const draftExpression = manualFinancialNonStockDraftExpression(screen);
+ await replaceText(
+ draftExpression,
+ "SHOULD_CLEAR_ON_STOCK_CHANGE",
+ `GraphE ${screen} pre-selection draft sentinel`);
+ const beforeCandidateDoubleClick = await readSnapshot();
+ const beforeCandidateMessageSequence =
+ beforeCandidateDoubleClick.safety.outboundMessageCount;
+ if (!Number.isSafeInteger(beforeCandidateMessageSequence)) {
+ failUnknown(`GraphE ${screen} outbound message sequence is unavailable.`);
+ }
+ await pointerDoubleClick(
+ candidateExpression,
+ `GraphE ${screen} select verified stock by slow Windows double-click`,
+ 0,
+ 350);
+ current = await waitFor(`GraphE ${screen} verified stock`, snapshot =>
snapshot.state.isBusy === false &&
snapshot.state.manualFinancial?.verifiedStock?.name === stockName,
{ allowUiBusy: false });
+ await sleep(350);
+ current = await readSnapshot();
+ const candidateMessages = current.safety.outboundMessages.filter(message =>
+ Number.isSafeInteger(message.sequence) &&
+ message.sequence > beforeCandidateMessageSequence);
+ if (candidateMessages.length !== 1 ||
+ candidateMessages[0].type !== "select-manual-financial-stock") {
+ failKnown(
+ `GraphE ${screen} candidate double-click emitted ${candidateMessages.length} ` +
+ `outbound messages instead of one stock selection.`);
+ }
+ const focusedCandidateId = await evaluate(
+ 'document.activeElement?.dataset?.manualStockResultId ?? null');
+ if (focusedCandidateId !== candidates[0].resultId) {
+ failKnown(`GraphE ${screen} candidate focus was not preserved after double-click.`);
+ }
+ const remainingSentinel = await evaluate(`${draftExpression}?.value ?? null`);
+ if (remainingSentinel !== "") {
+ failKnown(`GraphE ${screen} stock change did not clear the prior create draft.`);
+ }
await fillManualFinancialRecord(screen);
- await pointerClick('document.querySelector("button[data-manual-save]")',
- `GraphE ${screen} save`);
+ await pointerClickWithNativeDialog(
+ 'document.querySelector("button[data-manual-save]")',
+ `GraphE ${screen} save`,
+ false,
+ "저장되었습니다",
+ true);
current = await waitFor(`GraphE ${screen} create commit and readback`, snapshot =>
snapshot.state.isBusy === false &&
snapshot.state.manualFinancial?.status === "writeCommitted" &&
@@ -3450,7 +4579,7 @@ async function executeManualFinancialCrud() {
"button[data-manual-action-id]", "manualActionId", `GraphE ${screen} action`);
const beforeDoubleClick = await readSnapshot();
await pointerDoubleClick(dataButtonExpression("manualResultId", rows[0].resultId),
- `GraphE ${screen} row double-click`);
+ `GraphE ${screen} row slow Windows double-click`, 0, 350);
await waitFor(`GraphE ${screen} row double-click add`, snapshot =>
snapshot.state.playlist.length === beforeDoubleClick.state.playlist.length + 1 &&
snapshot.state.manualFinancial == null &&
@@ -3477,11 +4606,11 @@ async function executeManualFinancialCrud() {
snapshot.state.manualFinancial?.canDelete === true,
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
- await pointerClickWithJavaScriptDialog(
+ await pointerClickWithNativeDialog(
'document.querySelector("button[data-manual-delete]")',
`GraphE ${screen} delete`,
- "confirm",
- "선택한 수동 재무 데이터를 삭제하시겠습니까?",
+ true,
+ "선택한 종목을 삭제하겠습니까?",
true);
current = await waitFor(`GraphE ${screen} delete commit`, snapshot =>
snapshot.state.isBusy === false &&
@@ -3489,11 +4618,11 @@ async function executeManualFinancialCrud() {
snapshot.state.manualFinancial?.writesQuarantined !== true &&
!snapshot.state.manualFinancial.rows.some(row => row.stockName === stockName),
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
- await pointerClickWithJavaScriptDialog(
+ await pointerClickWithNativeDialog(
'document.querySelector("button[data-manual-delete-all]")',
`GraphE ${screen} delete-all cancel branch`,
- "confirm",
- "현재 화면의 수동 재무 데이터를 모두 삭제하시겠습니까?",
+ true,
+ "모든 종목을 삭제하겠습니까?",
false);
recordFullUiDbCheck("databaseChecks", `GraphE ${screen} delete/absence`);
await closeManualFinancial(`GraphE ${screen} close after delete`);
@@ -3589,6 +4718,80 @@ async function requireExactCatalogResult(query, label, expectedCount) {
return { current, matches };
}
+async function verifyBlankOperatorCatalogStockSearch(trigger, label) {
+ if (!new Set(["button", "enter"]).has(trigger)) {
+ failKnown(`${label} has an invalid blank-search trigger.`);
+ }
+ await replaceText(
+ 'document.getElementById("operator-catalog-stock-query")',
+ "",
+ `${label} blank query`);
+ const before = await readSnapshot();
+ const catalog = before.state.operatorCatalog;
+ if (!catalog || !new Set(["create", "edit"]).has(catalog.mode) ||
+ before.dom.operatorCatalogStockQueryValue !== "" ||
+ before.dom.operatorCatalogModalHidden !== false) {
+ failKnown(`${label} is not an open stock editor with a fully empty query.`);
+ }
+ const draftRowIds = (catalog.draftRows || []).map(row => row.rowId);
+ const outboundBaseline = before.safety.outboundMessageCount;
+ if (trigger === "button") {
+ await pointerClick(
+ 'document.getElementById("operator-catalog-stock-search")',
+ `${label} blank search button`);
+ } else {
+ const focused = await evaluate(
+ 'document.activeElement === document.getElementById("operator-catalog-stock-query")');
+ if (focused !== true) failKnown(`${label} blank query lost focus before Enter.`);
+ await pressKey("Enter", `${label} blank search Enter`);
+ }
+
+ const searched = await waitFor(`${label} bounded all-stock results`, snapshot => {
+ const currentCatalog = snapshot.state.operatorCatalog;
+ const resultCount = currentCatalog?.stockResults?.length ?? 0;
+ const currentDraftRowIds = (currentCatalog?.draftRows || [])
+ .map(row => row.rowId);
+ return snapshot.state.isBusy === false &&
+ snapshot.state.revision > before.state.revision &&
+ currentCatalog?.stockQuery === "" &&
+ resultCount > 1 && resultCount <= 10_000 &&
+ currentCatalog?.selectedStockResultId == null &&
+ currentCatalog?.statusKind === "information" &&
+ currentCatalog?.statusMessage === "종목 검색을 완료했습니다." &&
+ currentCatalog?.writesQuarantined !== true &&
+ snapshot.dom.operatorCatalogStockQueryValue === "" &&
+ snapshot.dom.operatorCatalogStockResultCount === resultCount &&
+ snapshot.dom.operatorCatalogStatusText.includes("종목 검색을 완료했습니다.") &&
+ currentDraftRowIds.length === draftRowIds.length &&
+ currentDraftRowIds.every((rowId, index) => rowId === draftRowIds[index]);
+ }, { timeoutMilliseconds: 90_000, allowUiBusy: true });
+ const messages = assertExactOutboundTypes(
+ searched,
+ outboundBaseline,
+ ["search-operator-catalog-stocks"],
+ `${label} blank ${trigger} search`);
+ if (messages[0].emptyStockQuery !== true) {
+ failKnown(`${label} did not submit the exact empty stock query.`);
+ }
+
+ const resultCount = searched.state.operatorCatalog.stockResults.length;
+ const truncated = searched.state.operatorCatalog.stockResultsAreTruncated === true;
+ const truncationVisible = searched.dom.operatorCatalogStatusText
+ .includes("종목 검색 결과 일부만 표시 중입니다.");
+ if ((truncated && (resultCount !== 10_000 || !truncationVisible)) ||
+ (!truncated && truncationVisible)) {
+ failKnown(`${label} did not display the bounded-result truncation state exactly.`);
+ }
+ recordFullUiDbCheck("screenChecks", `${label} bounded blank stock search`, {
+ trigger,
+ resultCount,
+ maximumResultCount: 10_000,
+ truncated,
+ draftRowsPreserved: true
+ });
+ return searched;
+}
+
async function addOperatorCatalogStock(
query,
expectedDisplayName,
@@ -3636,6 +4839,49 @@ async function addOperatorCatalogStock(
return { current: added, row: addedRows[0] };
}
+async function addOperatorCatalogStockByDoubleClick(
+ query,
+ expectedDisplayName,
+ label,
+ interClickDelayMilliseconds = 50) {
+ const before = await readSnapshot();
+ const beforeRows = before.state.operatorCatalog?.draftRows || [];
+ await replaceText('document.getElementById("operator-catalog-stock-query")',
+ query, `${label} query`);
+ const beforeSearch = await readSnapshot();
+ await pointerClick('document.getElementById("operator-catalog-stock-search")',
+ `${label} search`);
+ const searched = await waitFor(`${label} stock results`, snapshot =>
+ snapshot.state.isBusy === false &&
+ snapshot.state.revision > beforeSearch.state.revision &&
+ snapshot.state.operatorCatalog?.stockQuery === query &&
+ snapshot.state.operatorCatalog?.writesQuarantined !== true,
+ { timeoutMilliseconds: 60_000, allowUiBusy: true });
+ const matches = searched.state.operatorCatalog.stockResults.filter(row =>
+ row.displayName === expectedDisplayName && row.isCompatible === true);
+ if (matches.length !== 1) {
+ failKnown(`${label} requires one exact compatible stock result, received ` +
+ `${matches.length}.`);
+ }
+ const resultId = matches[0].resultId;
+ await pointerDoubleClick(
+ `document.querySelector('button[data-operator-catalog-stock-result-id="${resultId}"]')`,
+ `${label} result double-click`,
+ 0,
+ interClickDelayMilliseconds);
+ const added = await waitFor(`${label} double-click added`, snapshot =>
+ snapshot.state.isBusy === false &&
+ snapshot.state.operatorCatalog?.draftRows?.length === beforeRows.length + 1 &&
+ snapshot.state.operatorCatalog?.selectedStockResultId == null,
+ { timeoutMilliseconds: 60_000, allowUiBusy: true });
+ const addedRows = added.state.operatorCatalog.draftRows.filter(row =>
+ !beforeRows.some(existing => existing.rowId === row.rowId));
+ if (addedRows.length !== 1) {
+ failKnown(`${label} double-click did not add exactly one new draft row.`);
+ }
+ return { current: added, row: addedRows[0] };
+}
+
async function setOperatorCatalogBuyAmount(rowId, amount, label) {
const expression =
`document.querySelector('input[data-operator-catalog-buy-amount-row-id="${rowId}"]')`;
@@ -3697,6 +4943,84 @@ async function assertMainThemeAbsent(title, expectedDisplayTitle, label) {
return current;
}
+async function verifyKrxThemeCreateMarketRoundTrip(
+ title,
+ draftRowId,
+ label) {
+ const before = await readSnapshot();
+ const catalog = before.state.operatorCatalog;
+ if (catalog?.entity !== "theme" || catalog.mode !== "create" ||
+ catalog.newThemeMarket !== "krx" || catalog.editorName !== title ||
+ before.dom.operatorCatalogNameValue !== title ||
+ before.dom.operatorCatalogEditorMarketValue !== "krx" ||
+ !catalog.draftRows?.some(row => row.rowId === draftRowId) ||
+ !catalog.codeLabel) {
+ failKnown(`${label} did not start from the exact named KRX draft.`);
+ }
+ const krxCodeBefore = catalog.codeLabel;
+ const outboundSequenceBeforeNxt = before.safety.outboundMessageCount;
+
+ await selectOptionValue(
+ 'document.getElementById("operator-catalog-editor-market")',
+ "nxt",
+ `${label} switch editor market to NXT`);
+ const nxt = await waitFor(`${label} NXT draft`, snapshot =>
+ snapshot.state.isBusy === false &&
+ snapshot.state.operatorCatalog?.entity === "theme" &&
+ snapshot.state.operatorCatalog?.mode === "create" &&
+ snapshot.state.operatorCatalog?.newThemeMarket === "nxt" &&
+ snapshot.state.operatorCatalog?.editorName === title &&
+ snapshot.state.operatorCatalog?.codeLabel?.length > 0 &&
+ snapshot.state.operatorCatalog?.codeLabel !== krxCodeBefore &&
+ snapshot.state.operatorCatalog?.draftRows?.length === 0 &&
+ snapshot.state.operatorCatalog?.statusKind === "warning" &&
+ snapshot.dom.operatorCatalogNameValue === title &&
+ snapshot.dom.operatorCatalogEditorMarketValue === "nxt",
+ { timeoutMilliseconds: 60_000, allowUiBusy: true });
+ const nxtMessages = assertExactOutboundTypes(
+ nxt,
+ outboundSequenceBeforeNxt,
+ ["change-create-theme-market"],
+ `${label} NXT selection`);
+ if (
+ nxtMessages[0]?.market !== "nxt" ||
+ nxtMessages[0]?.editorName !== title) {
+ failKnown(`${label} NXT selection did not send one exact market-change intent.`);
+ }
+
+ const outboundSequenceBeforeKrx = nxt.safety.outboundMessageCount;
+ await selectOptionValue(
+ 'document.getElementById("operator-catalog-editor-market")',
+ "krx",
+ `${label} switch editor market back to KRX`);
+ const restored = await waitFor(`${label} restored KRX draft`, snapshot =>
+ snapshot.state.isBusy === false &&
+ snapshot.state.operatorCatalog?.entity === "theme" &&
+ snapshot.state.operatorCatalog?.mode === "create" &&
+ snapshot.state.operatorCatalog?.newThemeMarket === "krx" &&
+ snapshot.state.operatorCatalog?.editorName === title &&
+ snapshot.state.operatorCatalog?.codeLabel?.length > 0 &&
+ snapshot.state.operatorCatalog?.draftRows?.length === 0 &&
+ snapshot.dom.operatorCatalogNameValue === title &&
+ snapshot.dom.operatorCatalogEditorMarketValue === "krx",
+ { timeoutMilliseconds: 60_000, allowUiBusy: true });
+ const krxMessages = assertExactOutboundTypes(
+ restored,
+ outboundSequenceBeforeKrx,
+ ["change-create-theme-market"],
+ `${label} KRX selection`);
+ if (
+ krxMessages[0]?.market !== "krx" ||
+ krxMessages[0]?.editorName !== title) {
+ failKnown(`${label} KRX selection did not send one exact market-change intent.`);
+ }
+ recordFullUiDbCheck(
+ "screenChecks",
+ `${label} create-market NXT/KRX physical selection, name preservation, and draft cleanup`,
+ { removedDraftRowId: draftRowId, namePreserved: true });
+ return restored;
+}
+
async function createReadEditDeleteTheme(
title,
market,
@@ -3728,17 +5052,95 @@ async function createReadEditDeleteTheme(
snapshot.state.isBusy === false &&
snapshot.state.operatorCatalog?.checkedThemeName === title &&
snapshot.state.operatorCatalog?.themeNameAvailability === "available" &&
- snapshot.state.operatorCatalog?.writesQuarantined !== true,
+ snapshot.state.operatorCatalog?.writesQuarantined !== true,
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
- const added = await addOperatorCatalogStock(
- stockQuery, stockDisplayName, `${label} stock`);
+ if (market === "krx") {
+ await verifyBlankOperatorCatalogStockSearch(
+ "button",
+ `${label} ThemeA editor`);
+ }
+ let added = await addOperatorCatalogStockByDoubleClick(
+ stockQuery,
+ stockDisplayName,
+ `${label} stock`,
+ market === "krx" ? 350 : 50);
+ if (market === "krx") {
+ await verifyKrxThemeCreateMarketRoundTrip(
+ title,
+ added.row.rowId,
+ `${label} create editor`);
+ await pointerClick('document.getElementById("operator-catalog-name-check")',
+ `${label} duplicate recheck after market round-trip`);
+ await respondToNativeDialog(
+ `${label} duplicate recheck after market round-trip`, false, true);
+ await waitFor(`${label} name available after market round-trip`, snapshot =>
+ snapshot.state.isBusy === false &&
+ snapshot.state.operatorCatalog?.newThemeMarket === "krx" &&
+ snapshot.state.operatorCatalog?.editorName === title &&
+ snapshot.state.operatorCatalog?.checkedThemeName === title &&
+ snapshot.state.operatorCatalog?.themeNameAvailability === "available" &&
+ snapshot.state.operatorCatalog?.draftRows?.length === 0 &&
+ snapshot.dom.operatorCatalogNameValue === title &&
+ snapshot.dom.operatorCatalogEditorMarketValue === "krx" &&
+ snapshot.state.operatorCatalog?.writesQuarantined !== true,
+ { timeoutMilliseconds: 60_000, allowUiBusy: true });
+ added = await addOperatorCatalogStockByDoubleClick(
+ stockQuery,
+ stockDisplayName,
+ `${label} stock re-add after market round-trip`,
+ 350);
+ }
+ let expectedSavedRows = 1;
+ let preservedRowIds = [added.row.rowId];
+ if (market === "krx") {
+ const second = await addOperatorCatalogStock(
+ "삼성전자", "삼성전자", `${label} second stock`);
+ const third = await addOperatorCatalogStock(
+ "SK하이닉스", "SK하이닉스", `${label} third stock`);
+ await pointerClick(
+ `document.querySelector('button[data-operator-catalog-move-row-id="${third.row.rowId}"]` +
+ `[data-operator-catalog-move-direction="up"]')`,
+ `${label} third row Up`);
+ await waitFor(`${label} third row Up state`, snapshot => {
+ const rows = snapshot.state.operatorCatalog?.draftRows || [];
+ return rows[0]?.rowId === added.row.rowId &&
+ rows[1]?.rowId === third.row.rowId &&
+ rows[2]?.rowId === second.row.rowId;
+ }, { allowUiBusy: true });
+ await pointerClick(
+ `document.querySelector('button[data-operator-catalog-move-row-id="${third.row.rowId}"]` +
+ `[data-operator-catalog-move-direction="down"]')`,
+ `${label} third row Down`);
+ await waitFor(`${label} third row Down state`, snapshot => {
+ const rows = snapshot.state.operatorCatalog?.draftRows || [];
+ return rows[0]?.rowId === added.row.rowId &&
+ rows[1]?.rowId === second.row.rowId &&
+ rows[2]?.rowId === third.row.rowId;
+ }, { allowUiBusy: true });
+ await pointerClick(
+ `document.querySelector('[data-operator-catalog-row-id="${third.row.rowId}"] strong')`,
+ `${label} focus third row for Delete key`);
+ await waitFor(`${label} active third row`, snapshot =>
+ snapshot.state.isBusy === false &&
+ snapshot.state.operatorCatalog?.activeDraftRowId === third.row.rowId,
+ { timeoutMilliseconds: 30_000, allowUiBusy: true });
+ await pressKey("Delete", `${label} third row Delete key`);
+ await waitFor(`${label} third row Delete-key state`, snapshot => {
+ const rows = snapshot.state.operatorCatalog?.draftRows || [];
+ return snapshot.state.isBusy === false && rows.length === 2 &&
+ rows[0]?.rowId === added.row.rowId && rows[1]?.rowId === second.row.rowId;
+ }, { timeoutMilliseconds: 60_000, allowUiBusy: true });
+ expectedSavedRows = 2;
+ preservedRowIds = [added.row.rowId, second.row.rowId];
+ }
await pointerClick('document.getElementById("operator-catalog-delete-all")',
`${label} Delete All cancel branch`);
await respondToNativeDialog(`${label} Delete All cancel branch`, true, false);
const afterCancel = await waitFor(`${label} draft preserved`, snapshot =>
snapshot.state.isBusy === false &&
- snapshot.state.operatorCatalog?.draftRows?.length === 1 &&
- snapshot.state.operatorCatalog?.draftRows?.[0]?.rowId === added.row.rowId,
+ snapshot.state.operatorCatalog?.draftRows?.length === expectedSavedRows &&
+ snapshot.state.operatorCatalog?.draftRows?.every((row, index) =>
+ row.rowId === preservedRowIds[index]) === true,
{ allowUiBusy: false });
if (afterCancel.state.operatorCatalog.canSave !== true) {
failKnown(`${label} is not saveable after the cancelled Delete All branch.`);
@@ -3763,7 +5165,7 @@ async function createReadEditDeleteTheme(
snapshot.state.operatorCatalog?.mode === "edit" &&
snapshot.state.operatorCatalog?.selectedResultId === catalogResultId &&
snapshot.state.operatorCatalog?.editorName === title &&
- snapshot.state.operatorCatalog?.draftRows?.length === 1 &&
+ snapshot.state.operatorCatalog?.draftRows?.length === expectedSavedRows &&
snapshot.state.operatorCatalog?.writesQuarantined !== true,
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
recordFullUiDbCheck("databaseChecks", `${label} fresh readback`, {
@@ -3775,8 +5177,8 @@ async function createReadEditDeleteTheme(
const expectedDisplayTitle = market === "nxt" ? `${title}(NXT)` : title;
const selected = await searchAndSelectMainTheme(
title, expectedDisplayTitle, `${label} UC4`);
- if (selected.current.state.theme.previewItems.length !== 1) {
- failKnown(`${label} UC4 preview did not contain the saved stock.`);
+ if (selected.current.state.theme.previewItems.length !== expectedSavedRows) {
+ failKnown(`${label} UC4 preview did not contain every saved stock.`);
}
await pointerClick('document.getElementById("uc4-theme-edit")',
`${label} UC4 edit`);
@@ -3785,10 +5187,13 @@ async function createReadEditDeleteTheme(
snapshot.state.operatorCatalog?.entity === "theme" &&
snapshot.state.operatorCatalog?.mode === "edit" &&
snapshot.state.operatorCatalog?.editorName === title &&
- snapshot.state.operatorCatalog?.draftRows?.length === 1,
+ snapshot.state.operatorCatalog?.draftRows?.length === expectedSavedRows,
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
await closeOperatorCatalog(`${label} UC4 edit cancel`, true);
- recordFullUiDbCheck("screenChecks", `${label} UC4 select/edit`);
+ recordFullUiDbCheck(
+ "screenChecks",
+ `${label} result double-click/up/down/Delete-key/select/edit`,
+ { rows: expectedSavedRows });
await pointerClick('document.getElementById("uc4-theme-delete")',
`${label} UC4 delete`);
@@ -3892,10 +5297,14 @@ async function createReadEditDeleteExpert(title) {
snapshot.state.operatorCatalog?.mode === "edit" &&
snapshot.state.operatorCatalog?.editorName === title &&
snapshot.state.operatorCatalog?.draftRows?.length === 0 &&
- snapshot.state.operatorCatalog?.writesQuarantined !== true,
+ snapshot.state.operatorCatalog?.writesQuarantined !== true,
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
- const first = await addOperatorCatalogStock(
+ await verifyBlankOperatorCatalogStockSearch(
+ "enter",
+ `${label} existing UC6 editor`);
+
+ const first = await addOperatorCatalogStockByDoubleClick(
"삼성출판사", "삼성출판사", `${label} first stock`);
const second = await addOperatorCatalogStock(
"삼성전자", "삼성전자", `${label} second stock`);
@@ -3920,13 +5329,28 @@ async function createReadEditDeleteExpert(title) {
snapshot.state.operatorCatalog?.draftRows?.[0]?.rowId === first.row.rowId &&
snapshot.state.operatorCatalog?.draftRows?.[1]?.rowId === second.row.rowId,
{ allowUiBusy: false });
+ await dragOperatorCatalogRow(
+ second.row.rowId,
+ first.row.rowId,
+ "before",
+ `${label} second row drag before first`);
+ await dragOperatorCatalogRow(
+ second.row.rowId,
+ first.row.rowId,
+ "after",
+ `${label} second row drag after first`);
await pointerClick(
- `document.querySelector('button[data-operator-catalog-remove-row-id="${second.row.rowId}"]')`,
- `${label} second row Delete`);
- await waitFor(`${label} second row Delete state`, snapshot =>
+ `document.querySelector('[data-operator-catalog-row-id="${second.row.rowId}"] strong')`,
+ `${label} focus second row for Delete key`);
+ await waitFor(`${label} active second row`, snapshot =>
+ snapshot.state.isBusy === false &&
+ snapshot.state.operatorCatalog?.activeDraftRowId === second.row.rowId,
+ { timeoutMilliseconds: 30_000, allowUiBusy: true });
+ await pressKey("Delete", `${label} second row Delete key`);
+ await waitFor(`${label} second row Delete-key state`, snapshot =>
snapshot.state.operatorCatalog?.draftRows?.length === 1 &&
snapshot.state.operatorCatalog?.draftRows?.[0]?.rowId === first.row.rowId,
- { allowUiBusy: false });
+ { timeoutMilliseconds: 60_000, allowUiBusy: true });
const readded = await addOperatorCatalogStock(
"삼성전자", "삼성전자", `${label} second stock re-add`);
await setOperatorCatalogBuyAmount(readded.row.rowId, 202,
@@ -3948,7 +5372,9 @@ async function createReadEditDeleteExpert(title) {
closed.state.expert?.preview?.items?.length !== 2) {
failKnown(`${label} known-committed edit did not refresh the two-row preview.`);
}
- recordFullUiDbCheck("screenChecks", `${label} UC6 add/edit/up/down/delete`);
+ recordFullUiDbCheck(
+ "screenChecks",
+ `${label} UC6 result-double-click/add/edit/up/down/row-drag/Delete-key`);
recordFullUiDbCheck("databaseChecks", `${label} edit/save`, { rows: 2 });
const fresh = await searchAndSelectMainExpert(title, `${label} fresh edit read`);
@@ -4103,13 +5529,24 @@ async function verifyFixedAndIndustryScreens() {
await doubleClickAndWaitForPlaylistAdd(dataButtonExpression("actionId", "fixed-001"),
"UC1 overseas leaf");
const beforeSection = await readSnapshot();
+ const fixedSection = beforeSection.state.fixedCatalog.sections[1];
+ if (!fixedSection || !Array.isArray(fixedSection.actions) ||
+ fixedSection.actions.length === 0) {
+ failKnown("UC1 fixed section batch has no actions to verify.");
+ }
await pointerDoubleClick(
`document.querySelector('summary[data-section-index="1"]')`,
- "UC1 fixed three-row section");
- await waitFor("UC1 fixed section batch", snapshot =>
- snapshot.state.isBusy === false &&
- snapshot.state.playlist.length === beforeSection.state.playlist.length + 3,
+ "UC1 fixed parent batch");
+ const afterSection = await waitFor("UC1 fixed parent batch add", snapshot =>
+ snapshot.state.isBusy === false && snapshot.state.fixedSectionBatch == null &&
+ snapshot.state.playlist.length ===
+ beforeSection.state.playlist.length + fixedSection.actions.length,
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
+ assertSafety(afterSection, "UC1 fixed parent batch", false);
+ if (afterSection.safety.outboundMessages.length !==
+ beforeSection.safety.outboundMessages.length + 1) {
+ failKnown("UC1 fixed parent double-click must send exactly one native batch intent.");
+ }
await waitForTabModel("exchange", "fixedCatalog", "UC1 exchange tab");
await doubleClickAndWaitForPlaylistAdd(dataButtonExpression("actionId", "fixed-079"),
@@ -4117,7 +5554,9 @@ async function verifyFixedAndIndustryScreens() {
await waitForTabModel("index", "fixedCatalog", "UC1 index tab");
await doubleClickAndWaitForPlaylistAdd(dataButtonExpression("actionId", "fixed-125"),
"UC1 index leaf");
- recordFullUiDbCheck("screenChecks", "UC1 Expand, leaf, and parent-section buttons");
+ recordFullUiDbCheck(
+ "screenChecks",
+ "UC1 Expand, leaf activation, and parent batch activation");
for (const tabId of ["kospiIndustry", "kosdaqIndustry"]) {
await waitForTabModel(tabId, "industry", `UC2 ${tabId} tab`);
@@ -4208,12 +5647,12 @@ async function verifyComparisonScreen() {
failKnown("UC3 fresh tab activation did not start with empty draft targets.");
}
await pointerDoubleClick(dataButtonExpression("comparisonSelectionId", domesticId),
- "UC3 choose first domestic target");
+ "UC3 choose first domestic target by slow Windows double-click", 0, 350);
await waitFor("UC3 first domestic target", snapshot =>
snapshot.state.comparison?.firstTarget != null,
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
await pointerDoubleClick(dataButtonExpression("comparisonSelectionId", secondDomesticId),
- "UC3 choose second domestic target");
+ "UC3 choose second domestic target by slow Windows double-click", 0, 350);
current = await waitFor("UC3 second domestic target", snapshot =>
snapshot.state.comparison?.firstTarget != null &&
snapshot.state.comparison?.secondTarget != null,
@@ -4270,7 +5709,7 @@ async function verifyComparisonScreen() {
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
await pointerDoubleClick(dataButtonExpression("comparisonTargetId", "kospi"),
- "UC3 fixed target double-click");
+ "UC3 fixed target slow Windows double-click", 0, 350);
await waitFor("UC3 fixed target draft", snapshot =>
snapshot.state.comparison?.selectedFixedTargetId === "kospi" &&
snapshot.state.comparison?.firstTarget?.displayName === "코스피 지수",
@@ -4348,7 +5787,7 @@ async function verifyThemeScreen() {
}
const beforeDouble = await readSnapshot();
await pointerDoubleClick(dataButtonExpression("themeResultId", resultId),
- "UC4 theme row double-click");
+ "UC4 theme row slow Windows double-click", 0, 350);
await waitFor("UC4 theme double-click add", snapshot =>
snapshot.state.playlist.length === beforeDouble.state.playlist.length + 1,
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
@@ -4455,6 +5894,16 @@ async function verifyExpertAndTradingHaltScreens() {
snapshot.state.tradingHalt?.status === "ready" &&
snapshot.state.tradingHalt?.results?.length > 0,
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
+ const automaticHaltId = current.state.tradingHalt.results[0].selectionId;
+ const automaticHaltActions = current.state.tradingHalt.actions || [];
+ if (current.state.tradingHalt.selected?.selectionId !== automaticHaltId ||
+ automaticHaltActions.length === 0 ||
+ automaticHaltActions.some(action => action.isAvailable !== true)) {
+ failKnown("UC7 did not activate the first FarPoint row and actions after search.");
+ }
+ await clickAndWaitForPlaylistAdd(
+ dataButtonExpression("haltActionId", automaticHaltActions[0].actionId),
+ "UC7 action without an extra row click");
const beforeSort = current.state.tradingHalt.nameSort;
await pointerClick(dataButtonExpression("haltSort", "true"), "UC7 halt sort");
current = await waitFor("UC7 halt sort state", snapshot =>
@@ -4467,9 +5916,12 @@ async function verifyExpertAndTradingHaltScreens() {
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
const haltActions = await clickAllEnabledPlaylistActions(
"button[data-halt-action-id]", "haltActionId", "UC7 action");
- recordFullUiDbCheck("screenChecks", "UC7 search, sort, row, all actions", {
+ recordFullUiDbCheck(
+ "screenChecks",
+ "UC7 search auto-first-row action, sort, explicit row, all actions",
+ {
actionButtons: haltActions.length
- });
+ });
}
async function verifyGraphEActivationButtons() {
@@ -4490,7 +5942,7 @@ async function openFixedManualList(actionId, screen, audience, label) {
}
async function closeManualList(label) {
- await pointerClick('document.getElementById("manual-list-close")', label);
+ await pointerClick('document.getElementById("manual-list-cancel")', label);
await waitFor(`${label} complete`, snapshot =>
snapshot.state.isBusy === false &&
snapshot.state.manualLists?.isOpen === false &&
@@ -4507,6 +5959,55 @@ function netSellProjection(snapshot) {
}));
}
+function manualNetSellCellExpression(rowIndex, field) {
+ const fieldIndex = ["leftName", "leftAmount", "rightName", "rightAmount"]
+ .indexOf(field);
+ if (!Number.isInteger(rowIndex) || rowIndex < 0 || rowIndex >= 5 || fieldIndex < 0) {
+ failKnown("The FSell cell fixture is outside the original five-by-four grid.");
+ }
+ return `document.querySelectorAll(
+ "#manual-list-workspace .manual-list-table tbody tr")[${rowIndex}]
+ ?.querySelectorAll("input")[${fieldIndex}] || null`;
+}
+
+async function replaceManualNetSellCell(rowIndex, field, value, expectedRows, label) {
+ const expression = manualNetSellCellExpression(rowIndex, field);
+ const before = await readSnapshot();
+ await replaceText(expression, value, label);
+ // The original editor commits a cell when focus leaves it. Use a real Tab key
+ // so this covers the packaged change event rather than assigning DOM state.
+ await pressKey("Tab", `${label} commit by focus change`);
+ return await waitFor(`${label} native draft`, snapshot =>
+ snapshot.state.isBusy === false &&
+ snapshot.state.revision > before.state.revision &&
+ snapshot.state.manualLists?.isOpen === true &&
+ snapshot.state.manualLists?.screen === "net-sell" &&
+ snapshot.state.manualLists?.netRowsAreFresh === false &&
+ snapshot.state.manualLists?.writesQuarantined !== true &&
+ JSON.stringify(netSellProjection(snapshot)) === JSON.stringify(expectedRows),
+ { timeoutMilliseconds: 60_000, allowUiBusy: true });
+}
+
+async function confirmManualNetSellAndVerifyReadback(expectedRows, label) {
+ const before = await readSnapshot();
+ if (before.state.manualLists?.isOpen !== true ||
+ before.state.manualLists?.screen !== "net-sell" ||
+ before.state.manualLists?.canSave !== true ||
+ before.state.manualLists?.writesQuarantined === true) {
+ failKnown(`${label} did not start from a saveable FSell editor.`);
+ }
+ await pointerClick(modalButtonByTextExpression("확인"), label);
+ return await waitFor(`${label} one save and correlated readback`, snapshot =>
+ snapshot.state.isBusy === false &&
+ snapshot.state.manualLists?.isOpen === false &&
+ snapshot.dom.manualListModalHidden === true &&
+ snapshot.state.manualLists?.netRowsAreFresh === true &&
+ snapshot.state.manualLists?.writesQuarantined !== true &&
+ JSON.stringify(netSellProjection(snapshot)) === JSON.stringify(expectedRows) &&
+ snapshot.state.playlist.length === before.state.playlist.length + 1,
+ { timeoutMilliseconds: 60_000, allowUiBusy: true });
+}
+
function viProjection(snapshot) {
return snapshot.state.manualLists.viItems.map(row => ({
index: row.index,
@@ -4553,22 +6054,60 @@ async function verifyManualListButtonsAndPersistence() {
current = await openFixedManualList(
"fixed-245", "net-sell", "individual", "FSell save reopen");
- const beforeFsellSaveCount = current.state.playlist.length;
- await pointerClick(modalButtonByTextExpression("확인"), "FSell confirm save");
- await waitFor("FSell save, readback, add, close", snapshot =>
- snapshot.state.isBusy === false &&
- snapshot.state.manualLists?.isOpen === false &&
- snapshot.dom.manualListModalHidden === true &&
- snapshot.state.playlist.length === beforeFsellSaveCount + 1,
- { timeoutMilliseconds: 60_000, allowUiBusy: true });
- current = await openFixedManualList(
- "fixed-245", "net-sell", "individual", "FSell fresh read");
if (JSON.stringify(netSellProjection(current)) !== JSON.stringify(individualBaseline)) {
- failKnown("FSell fresh read did not preserve the exact five-row baseline.");
+ failKnown("FSell changed before the isolated cell round-trip began.");
}
- await closeManualList("FSell fresh read close");
+ let temporaryLeftAmount =
+ `${Date.now()}${randomBytes(3).readUIntBE(0, 3).toString().padStart(8, "0")}`;
+ if (temporaryLeftAmount === individualBaseline[0].leftAmount) {
+ temporaryLeftAmount += "0";
+ }
+ const temporaryRows = individualBaseline.map(row => ({ ...row }));
+ temporaryRows[0].leftAmount = temporaryLeftAmount;
+ const fsellRoundTrip = {
+ row: 1,
+ field: "leftAmount",
+ temporaryValue: temporaryLeftAmount,
+ mutationSaveVerified: false,
+ mutationFreshReadVerified: false,
+ restoreSaveVerified: false,
+ restoreFreshReadVerified: false
+ };
+ evidence.fullUiDb.fsellCellRoundTrip = fsellRoundTrip;
+
+ await replaceManualNetSellCell(
+ 0, "leftAmount", temporaryLeftAmount, temporaryRows,
+ "FSell row 1 left amount temporary input");
+ await confirmManualNetSellAndVerifyReadback(
+ temporaryRows, "FSell temporary value confirm save");
+ fsellRoundTrip.mutationSaveVerified = true;
+
+ current = await openFixedManualList(
+ "fixed-245", "net-sell", "individual", "FSell temporary value fresh read");
+ if (JSON.stringify(netSellProjection(current)) !== JSON.stringify(temporaryRows)) {
+ failKnown("FSell fresh read did not preserve the physically typed temporary value.");
+ }
+ fsellRoundTrip.mutationFreshReadVerified = true;
+
+ await replaceManualNetSellCell(
+ 0, "leftAmount", individualBaseline[0].leftAmount, individualBaseline,
+ "FSell row 1 left amount restore input");
+ await confirmManualNetSellAndVerifyReadback(
+ individualBaseline, "FSell original value confirm restore");
+ fsellRoundTrip.restoreSaveVerified = true;
+
+ current = await openFixedManualList(
+ "fixed-245", "net-sell", "individual", "FSell restored fresh read");
+ if (JSON.stringify(netSellProjection(current)) !== JSON.stringify(individualBaseline)) {
+ failKnown("FSell final fresh read did not restore the exact five-row baseline.");
+ }
+ fsellRoundTrip.restoreFreshReadVerified = true;
+ await closeManualList("FSell restored fresh read close");
recordFullUiDbCheck("screenChecks", "FSell all entry/tab/import/refresh/cancel/confirm buttons");
- recordFullUiDbCheck("databaseChecks", "FSell trusted file save/fresh read", { rows: 5 });
+ recordFullUiDbCheck(
+ "databaseChecks",
+ "FSell physical cell edit/save/fresh read/original restore/fresh read",
+ { rows: 5, row: 1, field: "leftAmount", restored: true });
current = await openFixedManualList("fixed-262", "vi", null, "VIList entry");
const viBaseline = viProjection(current);
@@ -4599,7 +6138,11 @@ async function verifyManualListButtonsAndPersistence() {
await waitFor("VIList result selected", snapshot =>
snapshot.state.manualLists?.selectedSearchResultId === viResult.resultId,
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
- await pointerDoubleClick(viResultExpression, "VIList result double-click add");
+ await pointerDoubleClick(
+ viResultExpression,
+ "VIList result slow Windows double-click add",
+ 0,
+ 350);
current = await waitFor("VIList temporary item add", snapshot =>
snapshot.state.manualLists?.viItems?.length === viBaseline.length + 1,
{ timeoutMilliseconds: 60_000, allowUiBusy: true });
@@ -4625,11 +6168,11 @@ async function verifyManualListButtonsAndPersistence() {
snapshot.state.manualLists?.viItems?.length === viBaseline.length &&
!snapshot.state.manualLists.viItems.some(row => row.itemId === temporaryItemId),
{ allowUiBusy: false });
- await pointerClickWithJavaScriptDialog(
+ await pointerClickWithNativeDialog(
modalButtonByTextExpression("전체 삭제"),
"VIList delete-all cancel branch",
- "confirm",
- "VI 목록을 모두 지우시겠습니까?",
+ true,
+ "모두 삭제하겠습니까?",
false);
const beforeViSave = await readSnapshot();
await pointerClick(modalButtonByTextExpression("확인"), "VIList confirm save");
@@ -4653,10 +6196,10 @@ async function verifyManualListButtonsAndPersistence() {
async function clearScreenValidationPlaylist() {
const current = await readSnapshot();
if (current.state.playlist.length === 0) return;
- await pointerClickWithJavaScriptDialog(
+ await pointerClickWithNativeDialog(
'document.getElementById("playlist-delete-all")',
"screen validation playlist cleanup",
- "confirm",
+ true,
"송출 리스트를 모두 삭제하겠습니까?",
true);
await waitFor("screen validation playlist cleanup state", snapshot =>
@@ -4689,6 +6232,7 @@ async function executeScreenInventory() {
async function executeFullUiDbSequence() {
if (!fullUiDbProfile) return;
if (configuration.scope === "all" || configuration.scope === "plist") {
+ await recoverNamedPlaylistFixture();
await executeNamedPlaylistCrud();
}
if (configuration.scope === "all" || configuration.scope === "graphe") {
diff --git a/scripts/Test-LegacyPackageLocalStateSmoke.mjs b/scripts/Test-LegacyPackageLocalStateSmoke.mjs
new file mode 100644
index 0000000..7f9b8e5
--- /dev/null
+++ b/scripts/Test-LegacyPackageLocalStateSmoke.mjs
@@ -0,0 +1,870 @@
+import fs from "node:fs";
+import path from "node:path";
+import { createHash, randomBytes } from "node:crypto";
+
+const exactTargetUrl = "https://legacy-parity.mbn.local/index.html";
+const expectedSourceSha256 =
+ "1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2";
+const expectedInitialDestinationSha256 =
+ "13C07BA64587549EDA9C1A694F3DFA19875CB1F888F02C9E071F17C47739E087";
+const expectedImportConfirmation =
+ "현재 저장된 비교쌍의 순서는 그대로 유지하고, 원본 프로그램의 비교쌍 중 없는 항목만 뒤에 추가할까요?";
+const allowedOutboundTypes = new Set([
+ "ready",
+ "select-tab",
+ "choose-operator-folder",
+ "import-legacy-comparison-pairs",
+ "confirm-native-dialog"
+]);
+const forbiddenCdpMethods = new Set([
+ "Browser.close",
+ "Page.close",
+ "Runtime.terminateExecution",
+ "Target.closeTarget",
+ "Input.dispatchMouseEvent",
+ "Input.dispatchKeyEvent"
+]);
+const targetExpressions = Object.freeze({
+ "settings-navigation": 'document.getElementById("workspace-settings-tab")',
+ "design-folder-picker": 'document.getElementById("operator-design-folder-select")',
+ "resource-folder-picker": 'document.getElementById("operator-resource-folder-select")',
+ "background-folder-picker": 'document.getElementById("operator-background-folder-select")',
+ "comparison-navigation":
+ 'document.querySelector("button[data-tab-id=\\"comparison\\"]")',
+ "comparison-import":
+ 'document.querySelector("button[data-comparison-import=\\"true\\"]")',
+ "native-confirmation-yes": 'document.getElementById("legacy-dialog-ok")'
+});
+
+class SmokeFailure extends Error {
+ constructor(category, message) {
+ super(`${category}: ${message}`);
+ this.name = "SmokeFailure";
+ this.category = category;
+ }
+}
+
+function failKnown(message) {
+ throw new SmokeFailure("KNOWN_FAILURE", message);
+}
+
+function failUnknown(message) {
+ throw new SmokeFailure("OUTCOME_UNKNOWN", message);
+}
+
+function parseArguments(argv) {
+ const accepted = new Set([
+ "port", "output", "screenshot", "exchange-directory", "timeout-ms"
+ ]);
+ if (argv.length === 0 || argv.length % 2 !== 0) {
+ failKnown(
+ "Usage: node scripts/Test-LegacyPackageLocalStateSmoke.mjs " +
+ "--port --output --screenshot " +
+ "--exchange-directory [--timeout-ms ]");
+ }
+ const values = new Map();
+ for (let index = 0; index < argv.length; index += 2) {
+ const rawName = argv[index];
+ const value = argv[index + 1];
+ if (!rawName?.startsWith("--") || !value) failKnown("Arguments must be --name value pairs.");
+ const name = rawName.slice(2);
+ if (!accepted.has(name) || values.has(name)) failKnown(`Invalid argument --${name}.`);
+ values.set(name, value);
+ }
+ for (const required of ["port", "output", "screenshot", "exchange-directory"]) {
+ if (!values.has(required)) failKnown(`--${required} is required.`);
+ }
+ const port = Number(values.get("port"));
+ const timeoutMilliseconds = Number(values.get("timeout-ms") || "180000");
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) {
+ failKnown("--port must be from 1024 through 65535.");
+ }
+ if (!Number.isInteger(timeoutMilliseconds) ||
+ timeoutMilliseconds < 30_000 || timeoutMilliseconds > 900_000) {
+ failKnown("--timeout-ms must be from 30000 through 900000.");
+ }
+ const outputPath = path.resolve(values.get("output"));
+ const screenshotPath = path.resolve(values.get("screenshot"));
+ const exchangeDirectory = path.resolve(values.get("exchange-directory"));
+ if (!path.isAbsolute(values.get("output")) || path.extname(outputPath).toLowerCase() !== ".json" ||
+ !path.isAbsolute(values.get("screenshot")) || path.extname(screenshotPath).toLowerCase() !== ".png" ||
+ !path.isAbsolute(values.get("exchange-directory"))) {
+ failKnown("Output, screenshot and exchange paths must be absolute and typed correctly.");
+ }
+ const unique = new Set([
+ outputPath.toLowerCase(), screenshotPath.toLowerCase(), exchangeDirectory.toLowerCase()
+ ]);
+ if (unique.size !== 3) failKnown("Evidence paths must be distinct.");
+ if (fs.existsSync(outputPath) || fs.existsSync(screenshotPath)) {
+ failKnown("Evidence files are never overwritten.");
+ }
+ if (!fs.existsSync(exchangeDirectory) ||
+ !fs.statSync(exchangeDirectory).isDirectory() ||
+ fs.readdirSync(exchangeDirectory).length !== 0) {
+ failKnown("The wrapper must provide one existing empty exchange directory.");
+ }
+ return { port, timeoutMilliseconds, outputPath, screenshotPath, exchangeDirectory };
+}
+
+const configuration = parseArguments(process.argv.slice(2));
+const hardDeadline = Date.now() + configuration.timeoutMilliseconds;
+const startedAt = new Date().toISOString();
+let socket = null;
+let nextRpcId = 1;
+let nextInputSequence = 1;
+let probeInstalled = false;
+let cancellationRequested = false;
+const pendingRpc = new Map();
+
+const evidence = {
+ schemaVersion: 1,
+ profile: "local-settings-comparison-import",
+ result: "RUNNING",
+ startedAt,
+ completedAt: null,
+ target: {
+ expectedUrl: exactTargetUrl,
+ port: configuration.port,
+ id: null,
+ url: null
+ },
+ safety: {
+ requiredMode: "dryRun",
+ requiredPhase: "idle",
+ allowedOutboundTypes: [...allowedOutboundTypes],
+ observedOutboundTypes: [],
+ blockedOutboundMessages: [],
+ stateViolations: [],
+ inputRetryCount: 0,
+ appCloseRequested: false,
+ playoutIntentIssued: false
+ },
+ files: {
+ expectedSourceSha256,
+ expectedSourceRowCount: 8,
+ expectedInitialDestinationSha256,
+ expectedInitialDestinationRowCount: 1,
+ settingsBaseline: null,
+ destinationAfterFirstImport: null,
+ destinationAfterSecondImport: null
+ },
+ settings: {
+ pickerKinds: [],
+ allCancelled: false,
+ settingsFileUnchanged: false
+ },
+ comparison: {
+ initialPair: null,
+ afterFirstImport: null,
+ afterSecondImport: null,
+ initialPairPreserved: false,
+ secondImportIdempotent: false
+ },
+ exchanges: [],
+ checkpoints: [],
+ finalSnapshot: null,
+ screenshot: null,
+ error: null
+};
+
+process.stdin.setEncoding("utf8");
+process.stdin.on("data", chunk => {
+ if (String(chunk).split(/\r?\n/u).includes("CANCEL")) cancellationRequested = true;
+});
+// The wrapper keeps stdin open so it can fail-stop an in-flight helper. Do not
+// let that control channel keep Node alive after PASS or a sealed early failure.
+if (typeof process.stdin.unref === "function") process.stdin.unref();
+
+function assertDeadline(label) {
+ if (cancellationRequested) failUnknown(`The wrapper cancelled ${label}; no action is retried.`);
+ if (Date.now() >= hardDeadline) failUnknown(`The global deadline expired ${label}; no action is retried.`);
+}
+
+async function sleep(milliseconds) {
+ assertDeadline("before waiting");
+ await new Promise(resolve => setTimeout(resolve, Math.min(milliseconds, hardDeadline - Date.now())));
+ assertDeadline("after waiting");
+}
+
+function sha256(bytes) {
+ return createHash("sha256").update(bytes).digest("hex").toUpperCase();
+}
+
+function stablePair(pair) {
+ if (!pair) return null;
+ return {
+ rowId: pair.rowId,
+ rowNumber: pair.rowNumber,
+ displayLabel: pair.displayLabel,
+ isSelected: pair.isSelected === true
+ };
+}
+
+async function discoverTarget() {
+ const discoveryUrl = `http://127.0.0.1:${configuration.port}/json/list`;
+ let lastError = null;
+ while (Date.now() < Math.min(hardDeadline, Date.now() + 15_000)) {
+ try {
+ const response = await fetch(discoveryUrl, { cache: "no-store" });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const targets = await response.json();
+ const exact = targets.filter(target =>
+ target?.type === "page" && target.url === exactTargetUrl);
+ if (exact.length > 1) failKnown("More than one exact package WebView target exists.");
+ if (exact.length === 1) return exact[0];
+ } catch (error) {
+ if (error instanceof SmokeFailure) throw error;
+ lastError = String(error?.message || error);
+ }
+ await sleep(100);
+ }
+ failKnown(`The exact package target was not found${lastError ? ` (${lastError})` : ""}.`);
+}
+
+function validateEndpoint(target) {
+ if (!target?.id || !target.webSocketDebuggerUrl) failKnown("The target has no debugger URL.");
+ const endpoint = new URL(target.webSocketDebuggerUrl);
+ if (endpoint.protocol !== "ws:" || endpoint.hostname !== "127.0.0.1" ||
+ Number(endpoint.port) !== configuration.port ||
+ endpoint.pathname !== `/devtools/page/${target.id}`) {
+ failKnown("The debugger endpoint is not the exact loopback page endpoint.");
+ }
+ return endpoint;
+}
+
+async function connect(endpoint) {
+ if (typeof WebSocket !== "function") failKnown("This Node runtime has no WebSocket API.");
+ socket = new WebSocket(endpoint.href);
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new SmokeFailure(
+ "OUTCOME_UNKNOWN", "CDP socket open timed out.")), 5_000);
+ socket.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true });
+ socket.addEventListener("error", () => { clearTimeout(timer); reject(new SmokeFailure(
+ "OUTCOME_UNKNOWN", "CDP socket failed to open.")); }, { once: true });
+ });
+ socket.addEventListener("message", event => {
+ const message = JSON.parse(String(event.data));
+ if (!Object.hasOwn(message, "id")) return;
+ const pending = pendingRpc.get(message.id);
+ if (!pending) return;
+ pendingRpc.delete(message.id);
+ clearTimeout(pending.timer);
+ if (message.error) pending.reject(new SmokeFailure(
+ "KNOWN_FAILURE", `${pending.method} failed: ${message.error.message || "CDP error"}`));
+ else pending.resolve(message.result);
+ });
+ socket.addEventListener("close", () => {
+ for (const pending of pendingRpc.values()) {
+ clearTimeout(pending.timer);
+ pending.reject(new SmokeFailure(
+ "OUTCOME_UNKNOWN", `CDP closed while waiting for ${pending.method}.`));
+ }
+ pendingRpc.clear();
+ });
+}
+
+function rpc(method, params = {}, timeoutMilliseconds = 10_000) {
+ assertDeadline(`before ${method}`);
+ if (forbiddenCdpMethods.has(method)) {
+ if (method.includes("close") || method.includes("terminate")) {
+ evidence.safety.appCloseRequested = true;
+ }
+ failKnown(`Forbidden CDP method ${method} was refused.`);
+ }
+ if (!socket || socket.readyState !== WebSocket.OPEN) failUnknown("CDP socket is not open.");
+ const id = nextRpcId++;
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ pendingRpc.delete(id);
+ reject(new SmokeFailure("OUTCOME_UNKNOWN", `${method} timed out; no retry was attempted.`));
+ }, Math.min(timeoutMilliseconds, hardDeadline - Date.now()));
+ pendingRpc.set(id, { method, resolve, reject, timer });
+ socket.send(JSON.stringify({ id, method, params }));
+ });
+}
+
+async function evaluate(expression) {
+ const response = await rpc("Runtime.evaluate", {
+ expression,
+ awaitPromise: true,
+ returnByValue: true,
+ userGesture: false
+ });
+ if (response.exceptionDetails) {
+ failKnown(response.exceptionDetails.exception?.description ||
+ response.exceptionDetails.text || "Runtime evaluation failed.");
+ }
+ return response.result?.value;
+}
+
+async function installProbe() {
+ const token = `local-state-${Date.now()}-${randomBytes(8).toString("hex")}`;
+ const installed = await evaluate(`(() => {
+ if (!window.chrome?.webview) throw new Error("WebView2 bridge is unavailable.");
+ if (globalThis.__legacyLocalStateProbe) throw new Error("Probe already exists.");
+ const allowed = new Set(${JSON.stringify([...allowedOutboundTypes])});
+ const probe = {
+ token: ${JSON.stringify(token)}, states: [], outboundMessages: [], pointerEvents: [],
+ blockedOutboundMessages: [], stateViolations: [], originalPostMessage: null,
+ postMessageWrapper: null, listener: null, pointerListener: null
+ };
+ probe.listener = event => {
+ if (event.data?.type !== "state" || !event.data.payload) return;
+ const payload = event.data.payload;
+ probe.states.push(structuredClone(payload));
+ if (probe.states.length > 500) probe.states.shift();
+ const playout = payload.playout;
+ const reasons = [];
+ if (!playout) reasons.push("missing-playout");
+ else {
+ if (playout.mode !== "dryRun") reasons.push("mode");
+ if (playout.phase !== "idle") reasons.push("phase");
+ if (playout.isConnected === true) reasons.push("connected");
+ if (playout.preparedCode != null) reasons.push("prepared");
+ if (playout.onAirCode != null) reasons.push("on-air");
+ if (playout.outcomeUnknown === true) reasons.push("outcome-unknown");
+ if (playout.isPlayCompletionPending === true) reasons.push("play-pending");
+ if (playout.isTakeOutCompletionPending === true) reasons.push("takeout-pending");
+ if (playout.isBusy === true) reasons.push("playout-busy");
+ if (playout.refreshActive === true) reasons.push("refresh-active");
+ }
+ if (reasons.length) probe.stateViolations.push({
+ at: new Date().toISOString(), revision: payload.revision ?? null, reasons
+ });
+ };
+ probe.originalPostMessage = window.chrome.webview.postMessage;
+ probe.pointerListener = event => {
+ const element = event.target instanceof Element ? event.target : null;
+ let targetId = null;
+ if (element?.closest("#workspace-settings-tab")) targetId = "settings-navigation";
+ else if (element?.closest("#operator-design-folder-select")) {
+ targetId = "design-folder-picker";
+ } else if (element?.closest("#operator-resource-folder-select")) {
+ targetId = "resource-folder-picker";
+ } else if (element?.closest("#operator-background-folder-select")) {
+ targetId = "background-folder-picker";
+ } else if (element?.closest('button[data-tab-id="comparison"]')) {
+ targetId = "comparison-navigation";
+ } else if (element?.closest('button[data-comparison-import="true"]')) {
+ targetId = "comparison-import";
+ } else if (element?.closest("#legacy-dialog-ok")) {
+ targetId = "native-confirmation-yes";
+ }
+ probe.pointerEvents.push({
+ sequence: probe.pointerEvents.length + 1,
+ type: event.type,
+ targetId,
+ isTrusted: event.isTrusted === true,
+ pointerType: event.pointerType,
+ button: event.button,
+ buttons: event.buttons,
+ at: new Date().toISOString()
+ });
+ if (probe.pointerEvents.length > 100) probe.pointerEvents.shift();
+ };
+ probe.postMessageWrapper = message => {
+ const record = {
+ sequence: probe.outboundMessages.length + 1,
+ type: typeof message?.type === "string" ? message.type : null,
+ at: new Date().toISOString()
+ };
+ probe.outboundMessages.push(record);
+ if (!record.type || !allowed.has(record.type)) {
+ probe.blockedOutboundMessages.push(record);
+ return;
+ }
+ return probe.originalPostMessage.call(window.chrome.webview, message);
+ };
+ try {
+ window.chrome.webview.postMessage = probe.postMessageWrapper;
+ if (window.chrome.webview.postMessage !== probe.postMessageWrapper) {
+ throw new Error("The outbound safety gate could not be installed.");
+ }
+ globalThis.__legacyLocalStateProbe = probe;
+ window.chrome.webview.addEventListener("message", probe.listener);
+ document.addEventListener("pointerdown", probe.pointerListener, true);
+ document.addEventListener("pointerup", probe.pointerListener, true);
+ window.chrome.webview.postMessage({ type: "ready", payload: {} });
+ } catch (error) {
+ window.chrome.webview.postMessage = probe.originalPostMessage;
+ delete globalThis.__legacyLocalStateProbe;
+ throw error;
+ }
+ return probe.token;
+ })()`);
+ if (installed !== token) failKnown("The probe was not installed exactly once.");
+ probeInstalled = true;
+}
+
+async function removeProbe() {
+ if (!probeInstalled || !socket || socket.readyState !== WebSocket.OPEN) return;
+ const removed = await evaluate(`(() => {
+ const probe = globalThis.__legacyLocalStateProbe;
+ if (!probe) return false;
+ window.chrome.webview.removeEventListener("message", probe.listener);
+ document.removeEventListener("pointerdown", probe.pointerListener, true);
+ document.removeEventListener("pointerup", probe.pointerListener, true);
+ if (window.chrome.webview.postMessage !== probe.postMessageWrapper) {
+ throw new Error("The outbound safety gate identity changed.");
+ }
+ window.chrome.webview.postMessage = probe.originalPostMessage;
+ delete globalThis.__legacyLocalStateProbe;
+ return true;
+ })()`);
+ if (removed !== true) failUnknown("The safety probe was not restored exactly once.");
+ probeInstalled = false;
+}
+
+async function readSnapshot() {
+ return await evaluate(`(() => {
+ const probe = globalThis.__legacyLocalStateProbe;
+ const state = probe?.states?.at(-1) || null;
+ const active = document.activeElement;
+ return {
+ state: state ? structuredClone(state) : null,
+ safety: {
+ wrapped: probe?.postMessageWrapper != null &&
+ window.chrome?.webview?.postMessage === probe.postMessageWrapper,
+ outboundMessages: (probe?.outboundMessages || []).map(value => ({ ...value })),
+ blockedOutboundMessages: (probe?.blockedOutboundMessages || []).map(value => ({ ...value })),
+ stateViolations: (probe?.stateViolations || []).map(value => ({
+ ...value, reasons: [...value.reasons]
+ }))
+ },
+ dom: {
+ url: location.href,
+ readyState: document.readyState,
+ bodyBusy: document.body.getAttribute("aria-busy"),
+ connectionText: (document.getElementById("playout-connection-state")?.textContent || "").trim(),
+ settingsVisible: document.getElementById("settings-workspace")?.hidden === false &&
+ document.getElementById("settings-workspace")?.inert !== true,
+ settingsCurrent: document.getElementById("workspace-settings-tab")
+ ?.getAttribute("aria-current"),
+ activeTabId: state?.tabs?.find(tab => tab.isActive === true)?.id || null,
+ legacyDialogHidden: document.getElementById("legacy-dialog")?.hidden === true,
+ legacyDialogText: (document.getElementById("legacy-dialog-message")?.textContent || "").trim(),
+ legacyDialogOkText: (document.getElementById("legacy-dialog-ok")?.textContent || "").trim(),
+ importStatus: (document.querySelector(".comparison-import-status")?.textContent || "").trim(),
+ activeElementId: active?.id || null
+ }
+ };
+ })()`);
+}
+
+function assertSafety(snapshot, label, { allowBusy = false, allowDialog = false } = {}) {
+ if (!snapshot?.state?.playout) failKnown(`Native state is missing at ${label}.`);
+ const playout = snapshot.state.playout;
+ if (snapshot.safety.wrapped !== true) failUnknown(`The safety gate is absent at ${label}.`);
+ evidence.safety.observedOutboundTypes = snapshot.safety.outboundMessages.map(row => row.type);
+ evidence.safety.blockedOutboundMessages = snapshot.safety.blockedOutboundMessages;
+ evidence.safety.stateViolations = snapshot.safety.stateViolations;
+ evidence.safety.playoutIntentIssued = snapshot.safety.outboundMessages.some(row =>
+ ["prepare-playout", "take-in", "next-playout", "take-out",
+ "choose-background", "toggle-background"].includes(row.type));
+ if (snapshot.safety.blockedOutboundMessages.length) {
+ failKnown(`An unapproved outbound message was blocked at ${label}.`);
+ }
+ if (snapshot.safety.stateViolations.length) failUnknown(`DryRun left its safe state at ${label}.`);
+ if (playout.mode !== "dryRun" || playout.phase !== "idle" ||
+ playout.isConnected === true || playout.preparedCode != null ||
+ playout.onAirCode != null || playout.outcomeUnknown === true ||
+ playout.isPlayCompletionPending === true ||
+ playout.isTakeOutCompletionPending === true || playout.isBusy === true ||
+ playout.refreshActive === true) {
+ failUnknown(`Playout is not exact DryRun IDLE at ${label}.`);
+ }
+ if (!allowBusy && snapshot.state.isBusy === true) failKnown(`The UI is busy at ${label}.`);
+ if (!allowDialog && snapshot.dom.legacyDialogHidden !== true) {
+ failKnown(`An unexpected native dialog is visible at ${label}.`);
+ }
+ if (snapshot.dom.url !== exactTargetUrl || snapshot.dom.readyState !== "complete" ||
+ snapshot.dom.connectionText !== "DRY RUN") {
+ failKnown(`The exact DryRun document is not ready at ${label}.`);
+ }
+}
+
+async function waitFor(label, predicate, options = {}) {
+ const allowMissingState = options.allowMissingState === true;
+ if (allowMissingState && label !== "DryRun IDLE preflight") {
+ failKnown("Only the input-free initial preflight may await the first native state.");
+ }
+ const deadline = Math.min(hardDeadline, Date.now() + (options.timeoutMilliseconds || 45_000));
+ let last = null;
+ while (Date.now() < deadline) {
+ last = await readSnapshot();
+ if (!last?.state?.playout && allowMissingState) {
+ if (last?.safety?.wrapped !== true) {
+ failUnknown("The safety gate is absent while awaiting the first native state.");
+ }
+ evidence.safety.observedOutboundTypes =
+ last.safety.outboundMessages.map(row => row.type);
+ evidence.safety.blockedOutboundMessages = last.safety.blockedOutboundMessages;
+ evidence.safety.stateViolations = last.safety.stateViolations;
+ if (last.safety.blockedOutboundMessages.length) {
+ failKnown("An unapproved outbound message was blocked before initial state.");
+ }
+ if (last.safety.stateViolations.length) {
+ failUnknown("A native state violation was observed before initial state.");
+ }
+ if (last.dom.url !== exactTargetUrl || last.dom.readyState !== "complete" ||
+ last.dom.connectionText !== "DRY RUN") {
+ failKnown("The exact DryRun document changed while awaiting initial state.");
+ }
+ await sleep(100);
+ continue;
+ }
+ assertSafety(last, label, options);
+ if (predicate(last)) return last;
+ await sleep(100);
+ }
+ failUnknown(`Timed out waiting for ${label}; no input was retried.`);
+}
+
+function exchangePath(sequence, suffix) {
+ return path.join(configuration.exchangeDirectory,
+ `${String(sequence).padStart(2, "0")}.${suffix}.json`);
+}
+
+function readSmallJson(filePath, label) {
+ const stat = fs.statSync(filePath);
+ if (!stat.isFile() || stat.size <= 0 || stat.size > 64 * 1024) {
+ failKnown(`${label} is empty or too large.`);
+ }
+ const bytes = fs.readFileSync(filePath);
+ let value;
+ try { value = JSON.parse(bytes.toString("utf8")); }
+ catch { failKnown(`${label} is not strict JSON.`); }
+ return { bytes, value, sha256: sha256(bytes) };
+}
+
+async function elementGeometry(targetId) {
+ const expression = targetExpressions[targetId];
+ if (!expression) failKnown(`Closed target ${targetId} is unavailable.`);
+ const value = await evaluate(`(() => {
+ const element = ${expression};
+ if (!element) return null;
+ element.scrollIntoView({ block: "nearest", inline: "nearest" });
+ const rect = element.getBoundingClientRect();
+ return {
+ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2,
+ width: rect.width, height: rect.height,
+ viewport: { width: document.documentElement.clientWidth,
+ height: document.documentElement.clientHeight },
+ devicePixelRatio: window.devicePixelRatio,
+ disabled: element.disabled === true,
+ hidden: element.hidden === true || element.closest("[hidden]") != null,
+ text: (element.textContent || "").trim(),
+ ariaCurrent: element.getAttribute("aria-current")
+ };
+ })()`);
+ if (!value || value.disabled || value.hidden || value.width <= 0 || value.height <= 0 ||
+ !Number.isFinite(value.x) || !Number.isFinite(value.y) ||
+ value.x < 0 || value.y < 0 || value.x >= value.viewport.width ||
+ value.y >= value.viewport.height ||
+ value.devicePixelRatio < 0.25 || value.devicePixelRatio > 5) {
+ failKnown(`${targetId} is not a unique visible input target.`);
+ }
+ return value;
+}
+
+async function requestExchange(operation, targetId = null, folderKind = null) {
+ const sequence = nextInputSequence++;
+ const token = randomBytes(16).toString("hex").toUpperCase();
+ const geometry = targetId ? await elementGeometry(targetId) : null;
+ const pointerEventStart = operation === "observe-local-files" ? null :
+ await evaluate("globalThis.__legacyLocalStateProbe?.pointerEvents?.length ?? null");
+ if (operation !== "observe-local-files" && !Number.isInteger(pointerEventStart)) {
+ failUnknown("The trusted pointer evidence baseline is unavailable.");
+ }
+ const request = {
+ schemaVersion: 1,
+ token,
+ sequence,
+ targetUrl: exactTargetUrl,
+ operation,
+ targetId,
+ folderKind,
+ point: geometry ? { x: geometry.x, y: geometry.y } : null,
+ viewport: geometry ? geometry.viewport : null,
+ devicePixelRatio: geometry ? geometry.devicePixelRatio : null,
+ mouseDownCalls: operation === "observe-local-files" ? 0 : 1,
+ mouseUpCalls: operation === "observe-local-files" ? 0 : 1,
+ escapeKeyCalls: operation === "folder-picker-click-cancel" ? 1 : 0,
+ inputRetryCount: 0
+ };
+ const requestPath = exchangePath(sequence, "request");
+ const acknowledgementPath = exchangePath(sequence, "ack");
+ const requestBytes = Buffer.from(`${JSON.stringify(request)}\n`, "utf8");
+ fs.writeFileSync(requestPath, requestBytes, { flag: "wx" });
+ const deadline = Math.min(hardDeadline, Date.now() + 45_000);
+ while (!fs.existsSync(acknowledgementPath)) {
+ if (Date.now() >= deadline) failUnknown(`Input exchange ${sequence} timed out; no retry was attempted.`);
+ await sleep(50);
+ }
+ const acknowledgement = readSmallJson(acknowledgementPath,
+ `Input acknowledgement ${sequence}`);
+ const ack = acknowledgement.value;
+ if (!ack || ack.schemaVersion !== 1 || ack.token !== token || ack.sequence !== sequence ||
+ ack.result !== "PASS" || ack.operation !== operation || ack.targetId !== targetId ||
+ ack.folderKind !== folderKind || ack.packageIdentityValidated !== true ||
+ ack.dryRunValidated !== true || ack.tornadoConnectionCount !== 0 ||
+ ack.inputRetryCount !== 0 || ack.mouseDownCalls !== request.mouseDownCalls ||
+ ack.mouseUpCalls !== request.mouseUpCalls || ack.escapeKeyCalls !== request.escapeKeyCalls ||
+ !ack.files) {
+ failKnown(`Input acknowledgement ${sequence} does not match its one-shot request.`);
+ }
+ const physicalPointer = operation === "observe-local-files"
+ ? null : await waitForTrustedPointerClick(targetId, pointerEventStart);
+ evidence.exchanges.push({
+ sequence, operation, targetId, folderKind,
+ requestPath, requestSha256: sha256(requestBytes),
+ acknowledgementPath, acknowledgementSha256: acknowledgement.sha256,
+ picker: ack.picker || null,
+ physicalPointer,
+ files: ack.files
+ });
+ return ack;
+}
+
+async function waitForTrustedPointerClick(targetId, startIndex) {
+ const deadline = Math.min(hardDeadline, Date.now() + 10_000);
+ while (Date.now() < deadline) {
+ const events = await evaluate(`(() => {
+ const events = globalThis.__legacyLocalStateProbe?.pointerEvents || [];
+ return events.slice(${Number(startIndex)}).map(value => ({ ...value }));
+ })()`);
+ const relevant = events.filter(event => event.targetId === targetId);
+ if (relevant.length >= 2) {
+ const down = relevant.find(event => event.type === "pointerdown");
+ const up = relevant.find(event => event.type === "pointerup");
+ if (!down || !up || down.sequence >= up.sequence || down.isTrusted !== true ||
+ up.isTrusted !== true || down.pointerType !== "mouse" ||
+ up.pointerType !== "mouse" || down.button !== 0 || up.button !== 0 ||
+ down.buttons !== 1 || up.buttons !== 0) {
+ failKnown(`The ${targetId} click did not produce one trusted mouse down/up pair.`);
+ }
+ return { down, up };
+ }
+ await sleep(25);
+ }
+ failUnknown(`Trusted pointer evidence for ${targetId} did not appear; no click was retried.`);
+}
+
+function assertExactPreflightFiles(files) {
+ if (files.source?.sha256 !== expectedSourceSha256 || files.source?.rowCount !== 8 ||
+ files.source?.isReadOnlySource !== true ||
+ files.destination?.sha256 !== expectedInitialDestinationSha256 ||
+ files.destination?.rowCount !== 1 ||
+ files.destination?.firstPair !== "KRX100지수|코스피 지수") {
+ failKnown("The original source or one-row current comparison baseline changed.");
+ }
+ evidence.files.settingsBaseline = files.settings;
+}
+
+function assertSettingsUnchanged(files, label) {
+ if (JSON.stringify(files.settings) !== JSON.stringify(evidence.files.settingsBaseline)) {
+ failKnown(`The settings JSON changed ${label}.`);
+ }
+}
+
+async function runSequence() {
+ await rpc("Runtime.enable");
+ await rpc("Page.enable");
+ await installProbe();
+ const initial = await waitFor("DryRun IDLE preflight", snapshot =>
+ snapshot.state?.playout?.mode === "dryRun" && snapshot.state.isBusy !== true,
+ { allowBusy: false, allowMissingState: true });
+
+ const preflight = await requestExchange("observe-local-files");
+ assertExactPreflightFiles(preflight.files);
+ evidence.checkpoints.push({ name: "exact-file-preflight", files: preflight.files });
+
+ await requestExchange("application-click", "settings-navigation");
+ await waitFor("settings workspace", snapshot =>
+ snapshot.dom.settingsVisible === true && snapshot.dom.settingsCurrent === "page",
+ { allowBusy: false });
+
+ for (const [targetId, folderKind] of [
+ ["design-folder-picker", "design"],
+ ["resource-folder-picker", "resource"],
+ ["background-folder-picker", "background"]
+ ]) {
+ const ack = await requestExchange("folder-picker-click-cancel", targetId, folderKind);
+ if (!ack.picker || ack.picker.uniqueNewPicker !== true ||
+ ack.picker.ownerLinkedToApplication !== true ||
+ ack.picker.escapeCancelled !== true || ack.picker.closedAfterEscape !== true) {
+ failKnown(`The ${folderKind} folder picker was not proven as the app-owned cancelled picker.`);
+ }
+ assertSettingsUnchanged(ack.files, `after cancelling ${folderKind}`);
+ evidence.settings.pickerKinds.push(folderKind);
+ await waitFor(`${folderKind} picker cancellation return`, snapshot =>
+ snapshot.state.isBusy !== true && snapshot.dom.settingsVisible === true &&
+ snapshot.dom.legacyDialogHidden === true,
+ { allowBusy: false });
+ }
+ const settingsObservation = await requestExchange("observe-local-files");
+ assertSettingsUnchanged(settingsObservation.files, "after all three picker cancellations");
+ evidence.settings.allCancelled = true;
+ evidence.settings.settingsFileUnchanged = true;
+
+ await requestExchange("application-click", "comparison-navigation");
+ let comparison = await waitFor("comparison one-row baseline", snapshot =>
+ snapshot.state.isBusy !== true && snapshot.dom.activeTabId === "comparison" &&
+ snapshot.state.comparison?.isBusy !== true &&
+ snapshot.state.comparison?.savedPairs?.length === 1 &&
+ snapshot.state.comparison?.canImportLegacyPairs === true,
+ { allowBusy: false });
+ const initialPair = stablePair(comparison.state.comparison.savedPairs[0]);
+ if (typeof initialPair?.rowId !== "string" || initialPair.rowId.length === 0 ||
+ initialPair.rowNumber !== 1 ||
+ initialPair.displayLabel !== "KRX100지수 ↔ 코스피 지수") {
+ failKnown("The visible one-row comparison baseline is not the expected known pair.");
+ }
+ evidence.comparison.initialPair = initialPair;
+
+ await requestExchange("application-click", "comparison-import");
+ await waitFor("first import confirmation", snapshot =>
+ snapshot.state.dialog?.isConfirmation === true &&
+ snapshot.state.dialog?.message === expectedImportConfirmation &&
+ snapshot.dom.legacyDialogHidden === false &&
+ snapshot.dom.legacyDialogText === expectedImportConfirmation &&
+ snapshot.dom.legacyDialogOkText === "예",
+ { allowBusy: false, allowDialog: true });
+ await requestExchange("application-click", "native-confirmation-yes");
+ comparison = await waitFor("first import completion", snapshot => {
+ const model = snapshot.state.comparison;
+ const receipt = model?.lastImport;
+ return snapshot.state.isBusy !== true && model?.isBusy !== true &&
+ snapshot.dom.legacyDialogHidden === true && model?.savedPairs?.length === 9 &&
+ receipt?.sourceSha256 === expectedSourceSha256 && receipt?.sourceRowCount === 8 &&
+ receipt?.addedPairCount === 8 && receipt?.skippedDuplicateCount === 0 &&
+ receipt?.totalPairCount === 9;
+ }, { allowBusy: true, allowDialog: true, timeoutMilliseconds: 90_000 });
+ const afterFirstPairs = comparison.state.comparison.savedPairs.map(stablePair);
+ if (JSON.stringify(afterFirstPairs[0]) !== JSON.stringify(initialPair)) {
+ failKnown("The original current comparison pair was not preserved at the front.");
+ }
+ evidence.comparison.initialPairPreserved = true;
+ evidence.comparison.afterFirstImport = {
+ receipt: comparison.state.comparison.lastImport,
+ pairCount: afterFirstPairs.length,
+ pairs: afterFirstPairs
+ };
+ const afterFirstFiles = await requestExchange("observe-local-files");
+ assertSettingsUnchanged(afterFirstFiles.files, "after the first comparison import");
+ if (afterFirstFiles.files.destination?.rowCount !== 9 ||
+ afterFirstFiles.files.destination?.firstPair !== "KRX100지수|코스피 지수" ||
+ afterFirstFiles.files.destination?.sha256 === expectedInitialDestinationSha256) {
+ failKnown("The first comparison import file is not an appended nine-row result.");
+ }
+ evidence.files.destinationAfterFirstImport = afterFirstFiles.files.destination;
+
+ await requestExchange("application-click", "comparison-import");
+ await waitFor("second import confirmation", snapshot =>
+ snapshot.state.dialog?.isConfirmation === true &&
+ snapshot.state.dialog?.message === expectedImportConfirmation &&
+ snapshot.dom.legacyDialogHidden === false,
+ { allowBusy: false, allowDialog: true });
+ await requestExchange("application-click", "native-confirmation-yes");
+ comparison = await waitFor("second import idempotent completion", snapshot => {
+ const model = snapshot.state.comparison;
+ const receipt = model?.lastImport;
+ return snapshot.state.isBusy !== true && model?.isBusy !== true &&
+ snapshot.dom.legacyDialogHidden === true && model?.savedPairs?.length === 9 &&
+ receipt?.sourceSha256 === expectedSourceSha256 && receipt?.sourceRowCount === 8 &&
+ receipt?.addedPairCount === 0 && receipt?.skippedDuplicateCount === 8 &&
+ receipt?.totalPairCount === 9;
+ }, { allowBusy: true, allowDialog: true, timeoutMilliseconds: 90_000 });
+ const afterSecondPairs = comparison.state.comparison.savedPairs.map(stablePair);
+ if (JSON.stringify(afterSecondPairs) !== JSON.stringify(afterFirstPairs)) {
+ failKnown("The second import changed the ordered comparison pair list.");
+ }
+ const finalFiles = await requestExchange("observe-local-files");
+ assertSettingsUnchanged(finalFiles.files, "after the second comparison import");
+ if (JSON.stringify(finalFiles.files.destination) !==
+ JSON.stringify(afterFirstFiles.files.destination)) {
+ failKnown("The second import changed the comparison file hash or contents.");
+ }
+ if (finalFiles.files.source?.sha256 !== expectedSourceSha256 ||
+ finalFiles.files.source?.rowCount !== 8) {
+ failUnknown("The read-only original source changed during the smoke.");
+ }
+ evidence.files.destinationAfterSecondImport = finalFiles.files.destination;
+ evidence.comparison.afterSecondImport = {
+ receipt: comparison.state.comparison.lastImport,
+ pairCount: afterSecondPairs.length,
+ pairs: afterSecondPairs
+ };
+ evidence.comparison.secondImportIdempotent = true;
+
+ const finalSnapshot = await readSnapshot();
+ assertSafety(finalSnapshot, "final state", { allowBusy: false });
+ evidence.finalSnapshot = finalSnapshot;
+ await captureScreenshot();
+ await removeProbe();
+ if (nextInputSequence !== 14 || evidence.exchanges.length !== 13) {
+ failUnknown("The closed thirteen-step exchange sequence did not complete exactly once.");
+ }
+ if (evidence.safety.observedOutboundTypes.filter(type =>
+ type === "import-legacy-comparison-pairs").length !== 2 ||
+ evidence.safety.observedOutboundTypes.filter(type =>
+ type === "confirm-native-dialog").length !== 2 ||
+ evidence.safety.observedOutboundTypes.filter(type =>
+ type === "choose-operator-folder").length !== 3) {
+ failKnown("The two import confirmations did not emit exactly two request/Yes pairs.");
+ }
+ evidence.result = "PASS";
+}
+
+async function captureScreenshot() {
+ const response = await rpc("Page.captureScreenshot", {
+ format: "png", fromSurface: true, captureBeyondViewport: false
+ }, 20_000);
+ if (!response?.data) failKnown("CDP returned no screenshot bytes.");
+ const bytes = Buffer.from(response.data, "base64");
+ fs.writeFileSync(configuration.screenshotPath, bytes, { flag: "wx" });
+ evidence.screenshot = {
+ path: configuration.screenshotPath,
+ bytes: bytes.length,
+ sha256: sha256(bytes)
+ };
+}
+
+function errorProjection(error) {
+ return {
+ category: error instanceof SmokeFailure ? error.category : "HARNESS_ERROR",
+ name: error?.name || "Error",
+ message: String(error?.message || error)
+ };
+}
+
+let exitCode = 0;
+try {
+ const target = await discoverTarget();
+ evidence.target.id = target.id;
+ evidence.target.url = target.url;
+ await connect(validateEndpoint(target));
+ await runSequence();
+} catch (error) {
+ exitCode = 1;
+ evidence.result = error instanceof SmokeFailure && error.category === "OUTCOME_UNKNOWN"
+ ? "OUTCOME_UNKNOWN" : "FAIL";
+ evidence.error = errorProjection(error);
+ try {
+ if (!evidence.screenshot && socket?.readyState === WebSocket.OPEN) await captureScreenshot();
+ } catch { /* Preserve the first failure. */ }
+ try {
+ if (probeInstalled && socket?.readyState === WebSocket.OPEN) await removeProbe();
+ } catch { /* Preserve the first failure and leave state observable. */ }
+} finally {
+ evidence.completedAt = new Date().toISOString();
+ try {
+ const bytes = Buffer.from(`${JSON.stringify(evidence, null, 2)}\n`, "utf8");
+ fs.writeFileSync(configuration.outputPath, bytes, { flag: "wx" });
+ } catch {
+ exitCode = 1;
+ }
+ if (socket?.readyState === WebSocket.OPEN) socket.close();
+}
+
+process.exitCode = exitCode;
diff --git a/scripts/Test-LegacyPackageUiOnlySmoke.mjs b/scripts/Test-LegacyPackageUiOnlySmoke.mjs
index e8c140e..01a0e3d 100644
--- a/scripts/Test-LegacyPackageUiOnlySmoke.mjs
+++ b/scripts/Test-LegacyPackageUiOnlySmoke.mjs
@@ -311,9 +311,9 @@ function assertShell(shell) {
}
if (layout.scheduleVisible !== true || layout.scheduleHitTestVisible !== true ||
!Number.isFinite(layout.workspaceToScheduleWidthRatio) ||
- layout.workspaceToScheduleWidthRatio < 1.8 ||
- layout.workspaceToScheduleWidthRatio > 2.2) {
- fail("KNOWN_FAILURE", "The package did not render a visible right schedule beside the approximately 2:1 workspace layout.");
+ layout.workspaceToScheduleWidthRatio < 1.60 ||
+ layout.workspaceToScheduleWidthRatio > 1.90) {
+ fail("KNOWN_FAILURE", "The package did not render a visible right schedule beside the approximately 1.75:1 workspace layout.");
}
for (const [name, control] of Object.entries(shell.controls || {})) {
if (!control?.present) fail("KNOWN_FAILURE", `Required shell control ${name} is missing.`);
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs
index e6b29f9..517bfba 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs
@@ -24,7 +24,7 @@ public sealed record ExpertRecommendationPreview(
int PlayIndex,
string StockCode,
string StockName,
- decimal BuyAmount);
+ decimal? BuyAmount);
public sealed record ExpertPreviewResult(
ExpertSelectionIdentity Identity,
@@ -280,7 +280,6 @@ public sealed partial class LegacyExpertSelectionService : IExpertSelectionServi
if (stockCodeValue is DBNull ||
stockNameValue is DBNull ||
- buyAmountValue is DBNull ||
playIndexValue is DBNull)
{
throw InvalidData(PreviewQueryName, "partial recommendation identity");
@@ -297,7 +296,13 @@ public sealed partial class LegacyExpertSelectionService : IExpertSelectionServi
3,
MaximumStockNameLength,
PreviewQueryName);
- var buyAmount = ReadPositiveDecimal(row, 4, PreviewQueryName);
+ // The legacy EList grid displayed a database NULL buy amount as an
+ // empty editable cell. Keep that incomplete value in the read model;
+ // save and playout boundaries remain responsible for requiring a
+ // positive integer amount.
+ var buyAmount = buyAmountValue is DBNull
+ ? (decimal?)null
+ : ReadPositiveDecimal(row, 4, PreviewQueryName);
var playIndex = ReadPlayIndex(row, 5, PreviewQueryName);
if (!playIndexes.Add(playIndex))
{
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyComparisonPairImport.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyComparisonPairImport.cs
index 3f47d75..e4801a3 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyComparisonPairImport.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Data/LegacyComparisonPairImport.cs
@@ -71,6 +71,12 @@ public interface ILegacyComparisonFileSource
CancellationToken cancellationToken = default);
}
+public interface ILegacyComparisonPairImportService
+{
+ Task ImportAsync(
+ CancellationToken cancellationToken = default);
+}
+
///
/// Reads only the original application's fixed comparison-pair file. The Web
/// request never supplies a path. The file is opened read-only, bounded, and
@@ -510,7 +516,7 @@ public static class LegacyComparisonFileParser
innerException);
}
-public sealed class LegacyComparisonPairImportService
+public sealed class LegacyComparisonPairImportService : ILegacyComparisonPairImportService
{
private const string StockNameColumn = "STOCK_NAME";
private const string StockCodeColumn = "STOCK_CODE";
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs
index b67729a..971b643 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs
@@ -95,12 +95,81 @@ public sealed record ManualFinancialSnapshot(
ManualFinancialRecord Record,
string RowVersion);
+///
+/// The exact legacy INPUT_* strings, before the strict scene DTO parser is
+/// applied. GraphE edited these strings first and the scene parsed them later.
+/// Keeping this representation separate lets historical rows remain editable
+/// without weakening the strict record used by playout.
+///
+public sealed record ManualFinancialRawRecord(
+ ManualFinancialIdentity Identity,
+ IReadOnlyList StorageValues);
+
+///
+/// Process-local Oracle row identity. The native locator intentionally has no
+/// public accessor and ToString never reveals it, so only Core can bind it back
+/// to a fixed ROWID query. Browser callers receive an unrelated randomized
+/// result id from LegacyApplication.
+///
+public sealed class ManualFinancialRawRowHandle
+{
+ internal ManualFinancialRawRowHandle(
+ ManualFinancialScreenKind screen,
+ string locator)
+ {
+ Screen = screen;
+ Locator = locator;
+ }
+
+ internal ManualFinancialScreenKind Screen { get; }
+
+ internal string Locator { get; }
+
+ public override string ToString() => "Opaque manual-financial row handle";
+}
+
+public sealed record ManualFinancialRawSnapshot(
+ ManualFinancialRawRecord Record,
+ string RowVersion,
+ ManualFinancialRawRowHandle Handle,
+ ManualFinancialSnapshot? StrictSnapshot)
+{
+ public bool IsPlayoutReady => StrictSnapshot is not null;
+}
+
public sealed record ManualFinancialSearchResult(
ManualFinancialScreenKind Screen,
string Query,
DateTimeOffset RetrievedAt,
IReadOnlyList Items,
- bool IsTruncated);
+ bool IsTruncated)
+{
+ ///
+ /// Rows whose legacy compound fields could not be parsed during this list
+ /// read. Their safe names may remain visible for parity, while an exact
+ /// GetAsync stays strict and never materializes an invalid row for editing
+ /// or playout.
+ ///
+ public int RejectedItemCount { get; init; }
+
+ ///
+ /// Safe STOCK_NAME identities recovered from rows whose remaining legacy
+ /// compound fields are unreadable. The original GraphE listed these names
+ /// first and parsed the selected row later; callers may show them, but must
+ /// keep exact GetAsync strict before editing or playout.
+ ///
+ public IReadOnlyList UnreadableStockNames { get; init; } =
+ Array.Empty();
+
+ ///
+ /// Complete raw rows returned by the current Core implementation. Older
+ /// fakes/adapters may leave this empty and RawItemsComplete false.
+ ///
+ public IReadOnlyList RawItems { get; init; } =
+ Array.Empty();
+
+ public bool RawItemsComplete { get; init; }
+}
public sealed record ManualFinancialScreenContract(
ManualFinancialScreenKind Screen,
@@ -255,11 +324,24 @@ public static class ManualFinancialCutContracts
VerifiedManualFinancialStock stock)
{
var validated = ManualFinancialValidation.Snapshot(snapshot, nameof(snapshot));
+ return CreateSelection(validated.Record.Identity, stock);
+ }
+
+ ///
+ /// Creates the legacy one-page schedule identity without claiming that the
+ /// current compound DB strings are scene-ready. PREPARE still performs its
+ /// fresh strict INPUT_* read before any engine command is dispatched.
+ ///
+ public static ManualFinancialCutSelection CreateSelection(
+ ManualFinancialIdentity identity,
+ VerifiedManualFinancialStock stock)
+ {
+ var validated = ManualFinancialValidation.Identity(identity, nameof(identity));
ArgumentNullException.ThrowIfNull(stock);
ValidateProvider(stock.Market, stock.Source, nameof(stock));
_ = ManualFinancialValidation.StockCode(stock.Code, nameof(stock));
if (!string.Equals(
- validated.Record.Identity.StockName,
+ validated.StockName,
stock.Name,
StringComparison.Ordinal))
{
@@ -268,7 +350,7 @@ public static class ManualFinancialCutContracts
nameof(stock));
}
- var contract = Get(validated.Record.Identity.Screen);
+ var contract = Get(validated.Screen);
var groupCode = stock.Market switch
{
StockMarket.Kospi => "KOSPI",
@@ -282,7 +364,7 @@ public static class ManualFinancialCutContracts
contract.CutCode,
new LegacySceneSelection(
groupCode,
- validated.Record.Identity.StockName,
+ validated.StockName,
contract.CanonicalGraphicType,
string.Empty,
string.Empty),
@@ -499,6 +581,27 @@ public interface IManualFinancialScreenService
CancellationToken cancellationToken = default);
}
+///
+/// Compatibility seam for GraphE's row-level raw editor. The opaque handle is
+/// accepted only from a Core-produced snapshot; callers cannot provide a table
+/// name or native locator string.
+///
+public interface IManualFinancialRawScreenService
+{
+ Task GetRawAsync(
+ ManualFinancialRawSnapshot listedRow,
+ CancellationToken cancellationToken = default);
+
+ Task UpdateRawAsync(
+ ManualFinancialRawSnapshot expected,
+ ManualFinancialRawRecord replacement,
+ CancellationToken cancellationToken = default);
+
+ Task DeleteRawAsync(
+ ManualFinancialRawSnapshot expected,
+ CancellationToken cancellationToken = default);
+}
+
public class ManualFinancialDataException : Exception
{
public ManualFinancialDataException(string message)
@@ -571,10 +674,16 @@ public sealed class ManualFinancialMutationRejectedException : ManualFinancialMu
/// representation, but replace concatenated SQL with fixed bound commands,
/// exclusive-table identity checks and optimistic ROW_VERSION checks.
///
-public sealed class LegacyManualFinancialScreenService : IManualFinancialScreenService
+public sealed class LegacyManualFinancialScreenService :
+ IManualFinancialScreenService,
+ IManualFinancialRawScreenService
{
public const int DefaultMaximumResults = 200;
- public const int MaximumResults = 1_000;
+ // The current legacy INPUT_* tables contain up to 2,349 rows and MainForm
+ // displayed the complete result without paging. Keep a finite safety bound,
+ // but place it above the verified development data instead of silently
+ // truncating the original operator list at 200 or 1,000 rows.
+ public const int MaximumResults = 5_000;
public const int MaximumQueryLength = 64;
private readonly IDataQueryExecutor _queryExecutor;
@@ -633,14 +742,30 @@ public sealed class LegacyManualFinancialScreenService : IManualFinancialScreenS
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
- var snapshots = ParseRows(table, profile, rowLimit);
- RejectDuplicateNames(snapshots, screen);
+ var snapshots = ParseRows(
+ table,
+ profile,
+ rowLimit,
+ maximumResults,
+ tolerateInvalidRows: true,
+ out var rejectedItemCount,
+ out var unreadableStockNames,
+ out var rawItems);
return new ManualFinancialSearchResult(
screen,
normalizedQuery,
DateTimeOffset.Now,
snapshots.Take(maximumResults).ToArray(),
- snapshots.Count > maximumResults);
+ // The database may have stopped at rowLimit before an invalid row
+ // was isolated. Preserve that boundary so callers never assume the
+ // valid subset is complete when more source rows may exist.
+ table.Rows.Count > maximumResults)
+ {
+ RejectedItemCount = rejectedItemCount,
+ UnreadableStockNames = unreadableStockNames,
+ RawItems = rawItems,
+ RawItemsComplete = true
+ };
}
public async Task GetAsync(
@@ -650,9 +775,16 @@ public sealed class LegacyManualFinancialScreenService : IManualFinancialScreenS
var validatedIdentity = ManualFinancialValidation.Identity(identity, nameof(identity));
var profile = ManualFinancialSqlProfiles.Get(validatedIdentity.Screen);
cancellationToken.ThrowIfCancellationRequested();
+ var rowLimit = checked(MaximumResults + 1);
var spec = new DataQuerySpec(
profile.GetSql,
- [new DataQueryParameter("stock_name", validatedIdentity.StockName, DbType.String)]);
+ [
+ new DataQueryParameter(
+ "stock_name",
+ validatedIdentity.StockName,
+ DbType.String),
+ new DataQueryParameter("row_limit", rowLimit, DbType.Int32)
+ ]);
spec.ValidateFor(DataSourceKind.Oracle);
var table = await _queryExecutor.ExecuteAsync(
DataSourceKind.Oracle,
@@ -660,16 +792,94 @@ public sealed class LegacyManualFinancialScreenService : IManualFinancialScreenS
spec,
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
- var rows = ParseRows(table, profile, maximumRows: 2);
- return rows.Count switch
+ var rows = ParseRows(
+ table,
+ profile,
+ maximumRows: MaximumResults,
+ resultLimit: MaximumResults,
+ tolerateInvalidRows: false,
+ out _,
+ out _,
+ out _);
+ if (rows.Count == 0)
{
- 0 => throw new ManualFinancialNotFoundException(validatedIdentity),
- 1 when rows[0].Record.Identity == validatedIdentity => rows[0],
- 1 => throw InvalidData(profile, "record identity"),
- _ => throw new ManualFinancialAmbiguousIdentityException(
- validatedIdentity.Screen,
- validatedIdentity.StockName)
- };
+ throw new ManualFinancialNotFoundException(validatedIdentity);
+ }
+
+ if (rows.Any(row => row.Record.Identity != validatedIdentity))
+ {
+ throw InvalidData(profile, "record identity");
+ }
+
+ var first = rows[0];
+ if (rows.Skip(1).All(row => EquivalentExactSnapshot(first, row)))
+ {
+ return first;
+ }
+
+ throw new ManualFinancialAmbiguousIdentityException(
+ validatedIdentity.Screen,
+ validatedIdentity.StockName);
+ }
+
+ public async Task GetRawAsync(
+ ManualFinancialRawSnapshot listedRow,
+ CancellationToken cancellationToken = default)
+ {
+ var listed = ManualFinancialValidation.RawSnapshot(listedRow, nameof(listedRow));
+ var profile = ManualFinancialSqlProfiles.Get(listed.Record.Identity.Screen);
+ cancellationToken.ThrowIfCancellationRequested();
+ var spec = new DataQuerySpec(
+ profile.RawGetSql,
+ [
+ new DataQueryParameter(
+ "row_handle",
+ listed.Handle.Locator,
+ DbType.String),
+ new DataQueryParameter(
+ "stock_name",
+ listed.Record.Identity.StockName,
+ DbType.String)
+ ]);
+ spec.ValidateFor(DataSourceKind.Oracle);
+ var table = await _queryExecutor.ExecuteAsync(
+ DataSourceKind.Oracle,
+ profile.RawGetQueryName,
+ spec,
+ cancellationToken).ConfigureAwait(false);
+ cancellationToken.ThrowIfCancellationRequested();
+ _ = ParseRows(
+ table,
+ profile,
+ maximumRows: 2,
+ resultLimit: 2,
+ tolerateInvalidRows: true,
+ out _,
+ out _,
+ out var rows);
+ if (rows.Count == 0)
+ {
+ throw new ManualFinancialNotFoundException(listed.Record.Identity);
+ }
+
+ if (rows.Count != 1)
+ {
+ throw new ManualFinancialAmbiguousIdentityException(
+ listed.Record.Identity.Screen,
+ listed.Record.Identity.StockName);
+ }
+
+ var result = rows[0];
+ if (result.Record.Identity != listed.Record.Identity ||
+ !string.Equals(
+ result.Handle.Locator,
+ listed.Handle.Locator,
+ StringComparison.Ordinal))
+ {
+ throw InvalidData(profile, "raw row correlation");
+ }
+
+ return result;
}
public Task CreateAsync(
@@ -725,6 +935,45 @@ public sealed class LegacyManualFinancialScreenService : IManualFinancialScreenS
cancellationToken);
}
+ public Task UpdateRawAsync(
+ ManualFinancialRawSnapshot expected,
+ ManualFinancialRawRecord replacement,
+ CancellationToken cancellationToken = default)
+ {
+ var validatedExpected = ManualFinancialValidation.RawSnapshot(
+ expected,
+ nameof(expected));
+ var validatedReplacement = ManualFinancialValidation.RawRecord(
+ replacement,
+ nameof(replacement));
+ if (validatedExpected.Record.Identity != validatedReplacement.Identity)
+ {
+ throw new ArgumentException(
+ "A raw GraphE update cannot rename a stock or change its screen.",
+ nameof(replacement));
+ }
+
+ var profile = ManualFinancialSqlProfiles.Get(validatedReplacement.Identity.Screen);
+ var parameters = CreateFieldParameters(validatedReplacement.StorageValues);
+ parameters.Add(StringParameter("row_handle", validatedExpected.Handle.Locator));
+ parameters.Add(StringParameter(
+ "identity_stock_name",
+ validatedReplacement.Identity.StockName));
+ parameters.Add(StringParameter(
+ "expected_row_version",
+ validatedExpected.RowVersion));
+ return ExecuteMutationAsync(
+ profile,
+ ManualFinancialMutationKind.Update,
+ new ManualFinancialDbCommand(
+ ManualFinancialDbCommandKind.Update,
+ profile.RawUpdateSql,
+ parameters,
+ ManualFinancialAffectedRowsRule.ExactlyOne),
+ validatedReplacement.Identity.StockName,
+ cancellationToken);
+ }
+
public Task DeleteAsync(
ManualFinancialSnapshot expected,
CancellationToken cancellationToken = default)
@@ -747,6 +996,30 @@ public sealed class LegacyManualFinancialScreenService : IManualFinancialScreenS
cancellationToken);
}
+ public Task DeleteRawAsync(
+ ManualFinancialRawSnapshot expected,
+ CancellationToken cancellationToken = default)
+ {
+ var validated = ManualFinancialValidation.RawSnapshot(expected, nameof(expected));
+ var profile = ManualFinancialSqlProfiles.Get(validated.Record.Identity.Screen);
+ return ExecuteMutationAsync(
+ profile,
+ ManualFinancialMutationKind.DeleteOne,
+ new ManualFinancialDbCommand(
+ ManualFinancialDbCommandKind.DeleteOne,
+ profile.RawDeleteOneSql,
+ [
+ StringParameter("row_handle", validated.Handle.Locator),
+ StringParameter(
+ "identity_stock_name",
+ validated.Record.Identity.StockName),
+ StringParameter("expected_row_version", validated.RowVersion)
+ ],
+ ManualFinancialAffectedRowsRule.ExactlyOne),
+ validated.Record.Identity.StockName,
+ cancellationToken);
+ }
+
public Task DeleteAllAsync(
ManualFinancialScreenKind screen,
CancellationToken cancellationToken = default)
@@ -832,12 +1105,32 @@ public sealed class LegacyManualFinancialScreenService : IManualFinancialScreenS
_ => false
};
+ private static bool EquivalentExactSnapshot(
+ ManualFinancialSnapshot left,
+ ManualFinancialSnapshot right) =>
+ left.Record.Identity == right.Record.Identity &&
+ string.Equals(left.RowVersion, right.RowVersion, StringComparison.Ordinal) &&
+ ManualFinancialValidation.StorageValues(left.Record).SequenceEqual(
+ ManualFinancialValidation.StorageValues(right.Record),
+ StringComparer.Ordinal);
+
private static IReadOnlyList ParseRows(
DataTable? table,
ManualFinancialSqlProfile profile,
- int maximumRows)
+ int maximumRows,
+ int resultLimit,
+ bool tolerateInvalidRows,
+ out int rejectedItemCount,
+ out IReadOnlyList unreadableStockNames,
+ out IReadOnlyList rawItems)
{
+ rejectedItemCount = 0;
+ var unreadableNames = new List();
+ var rawRows = new List();
+ unreadableStockNames = Array.Empty();
+ rawItems = Array.Empty();
if (table is null || table.Rows.Count > maximumRows ||
+ resultLimit is < 1 || resultLimit > maximumRows ||
table.Columns.Count != profile.ResultColumns.Count)
{
throw InvalidData(profile, "schema or row bound");
@@ -856,37 +1149,62 @@ public sealed class LegacyManualFinancialScreenService : IManualFinancialScreenS
}
}
- var result = new List(table.Rows.Count);
- foreach (DataRow row in table.Rows)
+ var visibleRowCount = Math.Min(table.Rows.Count, resultLimit);
+ var result = new List(visibleRowCount);
+ for (var index = 0; index < visibleRowCount; index++)
{
+ var row = table.Rows[index];
+ ManualFinancialRawSnapshot raw;
try
{
- result.Add(ManualFinancialValidation.Parse(profile.Screen, row));
+ raw = ManualFinancialValidation.ParseRaw(profile.Screen, row);
}
catch (ArgumentException exception)
{
+ if (tolerateInvalidRows)
+ {
+ rejectedItemCount++;
+ try
+ {
+ unreadableNames.Add(
+ ManualFinancialValidation.SearchStockName(row));
+ }
+ catch (ArgumentException)
+ {
+ // The count remains visible, but an unsafe or absent name
+ // cannot become an operator-selectable identity.
+ }
+ continue;
+ }
+
throw new ManualFinancialDataException(
$"Manual-financial data from {profile.SearchQueryName} is invalid: {exception.Message}");
}
- }
- return result;
- }
-
- private static void RejectDuplicateNames(
- IReadOnlyList snapshots,
- ManualFinancialScreenKind screen)
- {
- var names = new HashSet(StringComparer.OrdinalIgnoreCase);
- foreach (var snapshot in snapshots)
- {
- if (!names.Add(snapshot.Record.Identity.StockName))
+ try
{
- throw new ManualFinancialAmbiguousIdentityException(
- screen,
- snapshot.Record.Identity.StockName);
+ var strict = ManualFinancialValidation.Parse(profile.Screen, row);
+ raw = raw with { StrictSnapshot = strict };
+ result.Add(strict);
}
+ catch (ArgumentException exception)
+ {
+ if (!tolerateInvalidRows)
+ {
+ throw new ManualFinancialDataException(
+ $"Manual-financial data from {profile.SearchQueryName} is invalid: {exception.Message}");
+ }
+
+ rejectedItemCount++;
+ unreadableNames.Add(raw.Record.Identity.StockName);
+ }
+
+ rawRows.Add(raw);
}
+
+ unreadableStockNames = Array.AsReadOnly(unreadableNames.ToArray());
+ rawItems = Array.AsReadOnly(rawRows.ToArray());
+ return result;
}
private static List CreateValueParameters(
@@ -951,6 +1269,12 @@ internal static class ManualFinancialValidation
internal static string StockName(string value, string parameterName) =>
Text(value, MaximumStockNameLength, allowEmpty: false, allowUnderscore: true, parameterName);
+ internal static string SearchStockName(DataRow row)
+ {
+ ArgumentNullException.ThrowIfNull(row);
+ return StockName(RequiredString(row, "STOCK_NAME"), nameof(row));
+ }
+
internal static string StockCode(string value, string parameterName)
{
ArgumentNullException.ThrowIfNull(value, parameterName);
@@ -991,6 +1315,78 @@ internal static class ManualFinancialValidation
return new ManualFinancialSnapshot(record, rowVersion);
}
+ internal static ManualFinancialRawRecord RawRecord(
+ ManualFinancialRawRecord? record,
+ string parameterName)
+ {
+ ArgumentNullException.ThrowIfNull(record, parameterName);
+ var identity = Identity(record.Identity, parameterName);
+ ArgumentNullException.ThrowIfNull(record.StorageValues, parameterName);
+ var expectedCount = ManualFinancialCutContracts.Get(identity.Screen)
+ .StorageColumns.Count - 1;
+ if (record.StorageValues.Count != expectedCount)
+ {
+ throw new ArgumentException(
+ "A raw manual-financial record has an invalid field count.",
+ parameterName);
+ }
+
+ var values = new string[expectedCount];
+ for (var index = 0; index < values.Length; index++)
+ {
+ var value = record.StorageValues[index] ?? throw new ArgumentException(
+ "A raw manual-financial field cannot be null.",
+ parameterName);
+ var maximumLength = RawStorageMaximumLength(identity.Screen, index);
+ if (value.Length > maximumLength ||
+ value.Any(static character =>
+ char.IsSurrogate(character) || character == '\uFFFD'))
+ {
+ throw new ArgumentException(
+ "A raw manual-financial field is invalid.",
+ parameterName);
+ }
+
+ values[index] = value;
+ }
+
+ return new ManualFinancialRawRecord(
+ identity,
+ Array.AsReadOnly(values));
+ }
+
+ internal static ManualFinancialRawSnapshot RawSnapshot(
+ ManualFinancialRawSnapshot? snapshot,
+ string parameterName)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot, parameterName);
+ var record = RawRecord(snapshot.Record, parameterName);
+ var rowVersion = RowVersion(snapshot.RowVersion, parameterName);
+ ArgumentNullException.ThrowIfNull(snapshot.Handle, parameterName);
+ if (snapshot.Handle.Screen != record.Identity.Screen)
+ {
+ throw new ArgumentException(
+ "A raw manual-financial handle does not match its screen.",
+ parameterName);
+ }
+
+ _ = RawRowLocator(snapshot.Handle.Locator, parameterName);
+ ManualFinancialSnapshot? strict = null;
+ if (snapshot.StrictSnapshot is not null)
+ {
+ strict = Snapshot(snapshot.StrictSnapshot, parameterName);
+ if (strict.Record.Identity != record.Identity ||
+ !string.Equals(strict.RowVersion, rowVersion, StringComparison.Ordinal))
+ {
+ throw new ArgumentException(
+ "A raw manual-financial strict projection is not correlated.",
+ parameterName);
+ }
+ }
+
+ return new ManualFinancialRawSnapshot(record, rowVersion, snapshot.Handle, strict);
+ }
+
internal static ManualFinancialRecord Record(
ManualFinancialRecord? record,
string parameterName)
@@ -1075,6 +1471,30 @@ internal static class ManualFinancialValidation
nameof(row));
}
+ internal static ManualFinancialRawSnapshot ParseRaw(
+ ManualFinancialScreenKind screen,
+ DataRow row)
+ {
+ ArgumentNullException.ThrowIfNull(row);
+ var identity = Identity(
+ new ManualFinancialIdentity(screen, RequiredString(row, "STOCK_NAME")),
+ nameof(row));
+ var columns = ManualFinancialCutContracts.Get(screen).StorageColumns;
+ var values = columns.Skip(1)
+ .Select(column => OptionalOracleString(row, column))
+ .ToArray();
+ var record = RawRecord(
+ new ManualFinancialRawRecord(identity, values),
+ nameof(row));
+ var rowVersion = RowVersion(RequiredString(row, "ROW_VERSION"), nameof(row));
+ var locator = RawRowLocator(RequiredString(row, "ROW_HANDLE"), nameof(row));
+ return new ManualFinancialRawSnapshot(
+ record,
+ rowVersion,
+ new ManualFinancialRawRowHandle(screen, locator),
+ StrictSnapshot: null);
+ }
+
private static ManualRevenueCompositionRecord Revenue(
ManualFinancialIdentity identity,
ManualRevenueCompositionRecord record,
@@ -1095,21 +1515,19 @@ internal static class ManualFinancialValidation
continue;
}
- var label = Text(
+ var label = LegacyStoredText(
slice.Label,
MaximumTextLength,
allowEmpty: false,
- allowUnderscore: false,
parameterName);
- var percentageText = Text(
+ var percentageText = LegacyStoredText(
slice.PercentageText,
MaximumNumberTextLength,
allowEmpty: false,
- allowUnderscore: false,
parameterName);
var parsed = percentageText == "-"
? 0d
- : ParseFinite(percentageText, allowThousands: false, parameterName);
+ : ParseFinite(percentageText, allowThousands: true, parameterName);
Finite(slice.Percentage, parameterName);
if (!parsed.Equals(slice.Percentage))
{
@@ -1123,11 +1541,10 @@ internal static class ManualFinancialValidation
return new ManualRevenueCompositionRecord(
identity,
- Text(
+ LegacyStoredText(
record.BaseDate,
MaximumTextLength,
allowEmpty: true,
- allowUnderscore: true,
parameterName),
slices);
}
@@ -1166,11 +1583,10 @@ internal static class ManualFinancialValidation
{
ArgumentNullException.ThrowIfNull(periods, parameterName);
var values = periods.ToArray()
- .Select(value => Text(
+ .Select(value => LegacyStoredText(
value,
MaximumTextLength,
allowEmpty: true,
- allowUnderscore: false,
parameterName))
.ToArray();
return new ManualGrowthPeriodLabels(values[0], values[1], values[2], values[3]);
@@ -1186,11 +1602,10 @@ internal static class ManualFinancialValidation
}
return quarters.Select(value => new ManualQuarterValue(
- Text(
+ LegacyStoredText(
value.Quarter,
MaximumTextLength,
- allowEmpty: false,
- allowUnderscore: false,
+ allowEmpty: true,
parameterName),
value.Value))
.ToArray();
@@ -1244,7 +1659,7 @@ internal static class ManualFinancialValidation
{
var stored = RequiredString(row, "GUSUNG_" + (index + 1));
var tokens = stored.Split('_', StringSplitOptions.None);
- if (tokens.Length != 2 || tokens[0].Length == 0)
+ if (tokens.Length < 2)
{
throw new ArgumentException("Quarter storage is invalid.", nameof(row));
}
@@ -1259,11 +1674,10 @@ internal static class ManualFinancialValidation
}
values[index] = new ManualQuarterValue(
- Text(
- tokens[0],
+ LegacyStoredText(
+ LegacyManualFinancialStorageCompatibility.NormalizeQuarterLabel(tokens[0]),
MaximumTextLength,
- allowEmpty: false,
- allowUnderscore: false,
+ allowEmpty: true,
nameof(row)),
number);
}
@@ -1279,33 +1693,40 @@ internal static class ManualFinancialValidation
}
var tokens = value.Split('_', StringSplitOptions.None);
- if (tokens.Length != 2 || tokens[0].Length == 0)
+ if (tokens.Length < 2)
{
throw new ArgumentException("Revenue-slice storage is invalid.", nameof(value));
}
- var percentageText = tokens[1] is "" or "-" ? tokens[1] : Text(
+ // GraphE always saved an empty editor slot as "_". Both its editor and
+ // s5076 inspected only the first two tokens and skipped the slot whenever
+ // the label token was empty. Treat that representation as an absent slice
+ // instead of rejecting a row that the original program could load and play.
+ if (tokens[0].Length == 0)
+ {
+ return null;
+ }
+
+ var percentageText = tokens[1] is "" or "-" ? tokens[1] : LegacyStoredText(
tokens[1],
MaximumNumberTextLength,
allowEmpty: false,
- allowUnderscore: false,
nameof(value));
var number = percentageText is "" or "-"
? 0d
- : ParseFinite(percentageText, allowThousands: false, nameof(value));
+ : ParseFinite(percentageText, allowThousands: true, nameof(value));
return new ManualRevenueSlice(
- Text(
+ LegacyStoredText(
tokens[0],
MaximumTextLength,
allowEmpty: false,
- allowUnderscore: false,
nameof(value)),
percentageText.Length == 0 ? "0" : percentageText,
number);
}
private static string SerializeSlice(ManualRevenueSlice? slice) =>
- slice is null ? string.Empty : slice.Label + "_" + slice.PercentageText;
+ slice is null ? "_" : slice.Label + "_" + slice.PercentageText;
private static string SerializeGrowth(ManualGrowthSeriesValues values) =>
string.Join('_', values.ToArray().Select(value => value.HasValue
@@ -1355,6 +1776,34 @@ internal static class ManualFinancialValidation
return value;
}
+ private static string RawRowLocator(string value, string parameterName)
+ {
+ ArgumentNullException.ThrowIfNull(value, parameterName);
+ if (value.Length is < 1 or > 128 ||
+ value.Any(static character =>
+ !char.IsAsciiLetterOrDigit(character) && character is not '+' and not '/'))
+ {
+ throw new ArgumentException(
+ "A raw manual-financial row handle is invalid.",
+ parameterName);
+ }
+
+ return value;
+ }
+
+ private static int RawStorageMaximumLength(
+ ManualFinancialScreenKind screen,
+ int index) => screen switch
+ {
+ ManualFinancialScreenKind.RevenueComposition when index == 0 => 30,
+ ManualFinancialScreenKind.GrowthMetrics when index == 4 => 40,
+ ManualFinancialScreenKind.RevenueComposition or
+ ManualFinancialScreenKind.GrowthMetrics or
+ ManualFinancialScreenKind.Sales or
+ ManualFinancialScreenKind.OperatingProfit => 50,
+ _ => throw new ArgumentOutOfRangeException(nameof(screen))
+ };
+
private static string Text(
string value,
int maximumLength,
@@ -1376,6 +1825,25 @@ internal static class ManualFinancialValidation
return value;
}
+ private static string LegacyStoredText(
+ string value,
+ int maximumLength,
+ bool allowEmpty,
+ string parameterName)
+ {
+ ArgumentNullException.ThrowIfNull(value, parameterName);
+ if ((!allowEmpty && value.Length == 0) ||
+ value.Length > maximumLength ||
+ !IsSafe(value))
+ {
+ throw new ArgumentException(
+ "A legacy manual-financial text value is invalid.",
+ parameterName);
+ }
+
+ return value;
+ }
+
private static double ParseFinite(
string value,
bool allowThousands,
@@ -1416,23 +1884,38 @@ internal sealed record ManualFinancialSqlProfile(
string TableName,
string SearchQueryName,
string GetQueryName,
+ string RawGetQueryName,
string SearchSql,
string GetSql,
+ string RawGetSql,
string ExclusiveTableLockSql,
string InsertSql,
string UpdateSql,
+ string RawUpdateSql,
string DeleteOneSql,
+ string RawDeleteOneSql,
string DeleteAllSql,
IReadOnlyList ResultColumns);
internal static class ManualFinancialSqlProfiles
{
+ private const string RowVersionExpressionMarker = "__ROW_VERSION_EXPRESSION__";
+
private static readonly string[] PieColumns =
- ["STOCK_NAME", "BASE_DATE", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "ROW_VERSION"];
+ ["STOCK_NAME", "BASE_DATE", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "ROW_VERSION", "ROW_HANDLE"];
private static readonly string[] GrowColumns =
- ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "BASE_DATE", "ROW_VERSION"];
+ ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "BASE_DATE", "ROW_VERSION", "ROW_HANDLE"];
private static readonly string[] SixValueColumns =
- ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6", "ROW_VERSION"];
+ ["STOCK_NAME", "GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6", "ROW_VERSION", "ROW_HANDLE"];
+
+ private static readonly string PieRowVersionExpression =
+ CreateRowVersionExpression(PieColumns[..^2]);
+ private static readonly string GrowRowVersionExpression =
+ CreateRowVersionExpression(GrowColumns[..^2]);
+ private static readonly string SellRowVersionExpression =
+ CreateRowVersionExpression(SixValueColumns[..^2]);
+ private static readonly string ProfitRowVersionExpression =
+ CreateRowVersionExpression(SixValueColumns[..^2]);
private static readonly IReadOnlyDictionary
Profiles = new ReadOnlyDictionary(
@@ -1443,12 +1926,16 @@ internal static class ManualFinancialSqlProfiles
"INPUT_PIE",
"MANUAL_FINANCIAL_PIE_SEARCH",
"MANUAL_FINANCIAL_PIE_GET",
- PieSearchSql,
- PieGetSql,
+ "MANUAL_FINANCIAL_PIE_RAW_GET",
+ WithRowVersion(PieSearchSql, PieRowVersionExpression),
+ WithRowVersion(PieGetSql, PieRowVersionExpression),
+ WithRowVersion(PieRawGetSql, PieRowVersionExpression),
"LOCK TABLE INPUT_PIE IN EXCLUSIVE MODE",
PieInsertSql,
- PieUpdateSql,
- PieDeleteOneSql,
+ WithRowVersion(PieUpdateSql, PieRowVersionExpression),
+ WithRowVersion(PieRawUpdateSql, PieRowVersionExpression),
+ WithRowVersion(PieDeleteOneSql, PieRowVersionExpression),
+ WithRowVersion(PieRawDeleteOneSql, PieRowVersionExpression),
"DELETE FROM INPUT_PIE",
Array.AsReadOnly(PieColumns)),
[ManualFinancialScreenKind.GrowthMetrics] = new(
@@ -1456,12 +1943,16 @@ internal static class ManualFinancialSqlProfiles
"INPUT_GROW",
"MANUAL_FINANCIAL_GROW_SEARCH",
"MANUAL_FINANCIAL_GROW_GET",
- GrowSearchSql,
- GrowGetSql,
+ "MANUAL_FINANCIAL_GROW_RAW_GET",
+ WithRowVersion(GrowSearchSql, GrowRowVersionExpression),
+ WithRowVersion(GrowGetSql, GrowRowVersionExpression),
+ WithRowVersion(GrowRawGetSql, GrowRowVersionExpression),
"LOCK TABLE INPUT_GROW IN EXCLUSIVE MODE",
GrowInsertSql,
- GrowUpdateSql,
- GrowDeleteOneSql,
+ WithRowVersion(GrowUpdateSql, GrowRowVersionExpression),
+ WithRowVersion(GrowRawUpdateSql, GrowRowVersionExpression),
+ WithRowVersion(GrowDeleteOneSql, GrowRowVersionExpression),
+ WithRowVersion(GrowRawDeleteOneSql, GrowRowVersionExpression),
"DELETE FROM INPUT_GROW",
Array.AsReadOnly(GrowColumns)),
[ManualFinancialScreenKind.Sales] = new(
@@ -1469,12 +1960,16 @@ internal static class ManualFinancialSqlProfiles
"INPUT_SELL",
"MANUAL_FINANCIAL_SELL_SEARCH",
"MANUAL_FINANCIAL_SELL_GET",
- SellSearchSql,
- SellGetSql,
+ "MANUAL_FINANCIAL_SELL_RAW_GET",
+ WithRowVersion(SellSearchSql, SellRowVersionExpression),
+ WithRowVersion(SellGetSql, SellRowVersionExpression),
+ WithRowVersion(SellRawGetSql, SellRowVersionExpression),
"LOCK TABLE INPUT_SELL IN EXCLUSIVE MODE",
SellInsertSql,
- SellUpdateSql,
- SellDeleteOneSql,
+ WithRowVersion(SellUpdateSql, SellRowVersionExpression),
+ WithRowVersion(SellRawUpdateSql, SellRowVersionExpression),
+ WithRowVersion(SellDeleteOneSql, SellRowVersionExpression),
+ WithRowVersion(SellRawDeleteOneSql, SellRowVersionExpression),
"DELETE FROM INPUT_SELL",
Array.AsReadOnly(SixValueColumns)),
[ManualFinancialScreenKind.OperatingProfit] = new(
@@ -1482,12 +1977,16 @@ internal static class ManualFinancialSqlProfiles
"INPUT_PROFIT",
"MANUAL_FINANCIAL_PROFIT_SEARCH",
"MANUAL_FINANCIAL_PROFIT_GET",
- ProfitSearchSql,
- ProfitGetSql,
+ "MANUAL_FINANCIAL_PROFIT_RAW_GET",
+ WithRowVersion(ProfitSearchSql, ProfitRowVersionExpression),
+ WithRowVersion(ProfitGetSql, ProfitRowVersionExpression),
+ WithRowVersion(ProfitRawGetSql, ProfitRowVersionExpression),
"LOCK TABLE INPUT_PROFIT IN EXCLUSIVE MODE",
ProfitInsertSql,
- ProfitUpdateSql,
- ProfitDeleteOneSql,
+ WithRowVersion(ProfitUpdateSql, ProfitRowVersionExpression),
+ WithRowVersion(ProfitRawUpdateSql, ProfitRowVersionExpression),
+ WithRowVersion(ProfitDeleteOneSql, ProfitRowVersionExpression),
+ WithRowVersion(ProfitRawDeleteOneSql, ProfitRowVersionExpression),
"DELETE FROM INPUT_PROFIT",
Array.AsReadOnly(SixValueColumns))
});
@@ -1497,19 +1996,58 @@ internal static class ManualFinancialSqlProfiles
? profile
: throw new ArgumentOutOfRangeException(nameof(screen));
+ private static string CreateRowVersionExpression(IReadOnlyList columns)
+ {
+ if (columns.Count == 0 || columns.Any(static column =>
+ !DataQueryParameter.IsValidName(column)))
+ {
+ throw new InvalidOperationException(
+ "A manual-financial row-version column set is invalid.");
+ }
+
+ // Oracle treats an empty VARCHAR2 as NULL. Each NULL therefore gets a
+ // one-character N tag, while each non-null value gets a V tag followed
+ // by a fixed ten-digit character length and the exact value. This code
+ // is prefix-decodable even when values contain NUL, CHR(31), digits or
+ // text that previously acted as a structural delimiter.
+ var encodedColumns = columns.Select(static column =>
+ $"CASE WHEN {column} IS NULL THEN 'N' " +
+ $"ELSE 'V' || LPAD(TO_CHAR(LENGTH({column}), " +
+ $"'FM9999999990'), 10, '0') || {column} END");
+ return "RAWTOHEX(STANDARD_HASH(" +
+ string.Join(" || ", encodedColumns) +
+ ", 'SHA256'))";
+ }
+
+ private static string WithRowVersion(string sqlTemplate, string expression)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(sqlTemplate);
+ ArgumentException.ThrowIfNullOrWhiteSpace(expression);
+ var first = sqlTemplate.IndexOf(
+ RowVersionExpressionMarker,
+ StringComparison.Ordinal);
+ if (first < 0 || sqlTemplate.IndexOf(
+ RowVersionExpressionMarker,
+ first + RowVersionExpressionMarker.Length,
+ StringComparison.Ordinal) >= 0)
+ {
+ throw new InvalidOperationException(
+ "A manual-financial SQL template must contain one row-version marker.");
+ }
+
+ return sqlTemplate.Replace(
+ RowVersionExpressionMarker,
+ expression,
+ StringComparison.Ordinal);
+ }
+
private const string PieSearchSql =
"""
- SELECT STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, ROW_VERSION
+ SELECT STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, ROW_VERSION, ROW_HANDLE
FROM (
SELECT STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5,
- RAWTOHEX(STANDARD_HASH(
- NVL(STOCK_NAME, CHR(0)) || CHR(31) ||
- NVL(BASE_DATE, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_1, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_2, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_3, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_4, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_5, CHR(0)), 'SHA256')) ROW_VERSION
+ __ROW_VERSION_EXPRESSION__ ROW_VERSION,
+ ROWIDTOCHAR(ROWID) ROW_HANDLE
FROM INPUT_PIE
WHERE (:search_empty = 1 OR
UPPER(STOCK_NAME) LIKE '%' || :search_term || '%' ESCAPE '!')
@@ -1520,21 +2058,25 @@ internal static class ManualFinancialSqlProfiles
private const string PieGetSql =
"""
- SELECT STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, ROW_VERSION
+ SELECT STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, ROW_VERSION, ROW_HANDLE
FROM (
SELECT STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5,
- RAWTOHEX(STANDARD_HASH(
- NVL(STOCK_NAME, CHR(0)) || CHR(31) ||
- NVL(BASE_DATE, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_1, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_2, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_3, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_4, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_5, CHR(0)), 'SHA256')) ROW_VERSION
+ __ROW_VERSION_EXPRESSION__ ROW_VERSION,
+ ROWIDTOCHAR(ROWID) ROW_HANDLE
FROM INPUT_PIE
WHERE STOCK_NAME = :stock_name
)
- WHERE ROWNUM <= 2
+ WHERE ROWNUM <= :row_limit
+ """;
+
+ private const string PieRawGetSql =
+ """
+ SELECT STOCK_NAME, BASE_DATE, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5,
+ __ROW_VERSION_EXPRESSION__ ROW_VERSION,
+ ROWIDTOCHAR(ROWID) ROW_HANDLE
+ FROM INPUT_PIE
+ WHERE ROWID = CHARTOROWID(:row_handle)
+ AND STOCK_NAME = :stock_name
""";
private const string PieInsertSql =
@@ -1556,44 +2098,47 @@ internal static class ManualFinancialSqlProfiles
GUSUNG_4 = :field_5,
GUSUNG_5 = :field_6
WHERE STOCK_NAME = :identity_stock_name
- AND RAWTOHEX(STANDARD_HASH(
- NVL(STOCK_NAME, CHR(0)) || CHR(31) ||
- NVL(BASE_DATE, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_1, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_2, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_3, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_4, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_5, CHR(0)), 'SHA256')) = :expected_row_version
+ AND __ROW_VERSION_EXPRESSION__ = :expected_row_version
AND (SELECT COUNT(*) FROM INPUT_PIE WHERE STOCK_NAME = :unique_stock_name) = 1
""";
+ private const string PieRawUpdateSql =
+ """
+ UPDATE INPUT_PIE
+ SET BASE_DATE = :field_1,
+ GUSUNG_1 = :field_2,
+ GUSUNG_2 = :field_3,
+ GUSUNG_3 = :field_4,
+ GUSUNG_4 = :field_5,
+ GUSUNG_5 = :field_6
+ WHERE ROWID = CHARTOROWID(:row_handle)
+ AND STOCK_NAME = :identity_stock_name
+ AND __ROW_VERSION_EXPRESSION__ = :expected_row_version
+ """;
+
private const string PieDeleteOneSql =
"""
DELETE FROM INPUT_PIE
WHERE STOCK_NAME = :identity_stock_name
- AND RAWTOHEX(STANDARD_HASH(
- NVL(STOCK_NAME, CHR(0)) || CHR(31) ||
- NVL(BASE_DATE, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_1, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_2, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_3, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_4, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_5, CHR(0)), 'SHA256')) = :expected_row_version
+ AND __ROW_VERSION_EXPRESSION__ = :expected_row_version
AND (SELECT COUNT(*) FROM INPUT_PIE WHERE STOCK_NAME = :unique_stock_name) = 1
""";
+ private const string PieRawDeleteOneSql =
+ """
+ DELETE FROM INPUT_PIE
+ WHERE ROWID = CHARTOROWID(:row_handle)
+ AND STOCK_NAME = :identity_stock_name
+ AND __ROW_VERSION_EXPRESSION__ = :expected_row_version
+ """;
+
private const string GrowSearchSql =
"""
- SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE, ROW_VERSION
+ SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE, ROW_VERSION, ROW_HANDLE
FROM (
SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE,
- RAWTOHEX(STANDARD_HASH(
- NVL(STOCK_NAME, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_1, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_2, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_3, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_4, CHR(0)) || CHR(31) ||
- NVL(BASE_DATE, CHR(0)), 'SHA256')) ROW_VERSION
+ __ROW_VERSION_EXPRESSION__ ROW_VERSION,
+ ROWIDTOCHAR(ROWID) ROW_HANDLE
FROM INPUT_GROW
WHERE (:search_empty = 1 OR
UPPER(STOCK_NAME) LIKE '%' || :search_term || '%' ESCAPE '!')
@@ -1604,20 +2149,25 @@ internal static class ManualFinancialSqlProfiles
private const string GrowGetSql =
"""
- SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE, ROW_VERSION
+ SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE, ROW_VERSION, ROW_HANDLE
FROM (
SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE,
- RAWTOHEX(STANDARD_HASH(
- NVL(STOCK_NAME, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_1, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_2, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_3, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_4, CHR(0)) || CHR(31) ||
- NVL(BASE_DATE, CHR(0)), 'SHA256')) ROW_VERSION
+ __ROW_VERSION_EXPRESSION__ ROW_VERSION,
+ ROWIDTOCHAR(ROWID) ROW_HANDLE
FROM INPUT_GROW
WHERE STOCK_NAME = :stock_name
)
- WHERE ROWNUM <= 2
+ WHERE ROWNUM <= :row_limit
+ """;
+
+ private const string GrowRawGetSql =
+ """
+ SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, BASE_DATE,
+ __ROW_VERSION_EXPRESSION__ ROW_VERSION,
+ ROWIDTOCHAR(ROWID) ROW_HANDLE
+ FROM INPUT_GROW
+ WHERE ROWID = CHARTOROWID(:row_handle)
+ AND STOCK_NAME = :stock_name
""";
private const string GrowInsertSql =
@@ -1638,43 +2188,46 @@ internal static class ManualFinancialSqlProfiles
GUSUNG_4 = :field_4,
BASE_DATE = :field_5
WHERE STOCK_NAME = :identity_stock_name
- AND RAWTOHEX(STANDARD_HASH(
- NVL(STOCK_NAME, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_1, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_2, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_3, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_4, CHR(0)) || CHR(31) ||
- NVL(BASE_DATE, CHR(0)), 'SHA256')) = :expected_row_version
+ AND __ROW_VERSION_EXPRESSION__ = :expected_row_version
AND (SELECT COUNT(*) FROM INPUT_GROW WHERE STOCK_NAME = :unique_stock_name) = 1
""";
+ private const string GrowRawUpdateSql =
+ """
+ UPDATE INPUT_GROW
+ SET GUSUNG_1 = :field_1,
+ GUSUNG_2 = :field_2,
+ GUSUNG_3 = :field_3,
+ GUSUNG_4 = :field_4,
+ BASE_DATE = :field_5
+ WHERE ROWID = CHARTOROWID(:row_handle)
+ AND STOCK_NAME = :identity_stock_name
+ AND __ROW_VERSION_EXPRESSION__ = :expected_row_version
+ """;
+
private const string GrowDeleteOneSql =
"""
DELETE FROM INPUT_GROW
WHERE STOCK_NAME = :identity_stock_name
- AND RAWTOHEX(STANDARD_HASH(
- NVL(STOCK_NAME, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_1, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_2, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_3, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_4, CHR(0)) || CHR(31) ||
- NVL(BASE_DATE, CHR(0)), 'SHA256')) = :expected_row_version
+ AND __ROW_VERSION_EXPRESSION__ = :expected_row_version
AND (SELECT COUNT(*) FROM INPUT_GROW WHERE STOCK_NAME = :unique_stock_name) = 1
""";
+ private const string GrowRawDeleteOneSql =
+ """
+ DELETE FROM INPUT_GROW
+ WHERE ROWID = CHARTOROWID(:row_handle)
+ AND STOCK_NAME = :identity_stock_name
+ AND __ROW_VERSION_EXPRESSION__ = :expected_row_version
+ """;
+
private const string SellSearchSql =
"""
- SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, ROW_VERSION
+ SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, ROW_VERSION, ROW_HANDLE
FROM (
SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6,
- RAWTOHEX(STANDARD_HASH(
- NVL(STOCK_NAME, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_1, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_2, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_3, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_4, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_5, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_6, CHR(0)), 'SHA256')) ROW_VERSION
+ __ROW_VERSION_EXPRESSION__ ROW_VERSION,
+ ROWIDTOCHAR(ROWID) ROW_HANDLE
FROM INPUT_SELL
WHERE (:search_empty = 1 OR
UPPER(STOCK_NAME) LIKE '%' || :search_term || '%' ESCAPE '!')
@@ -1685,21 +2238,25 @@ internal static class ManualFinancialSqlProfiles
private const string SellGetSql =
"""
- SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, ROW_VERSION
+ SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, ROW_VERSION, ROW_HANDLE
FROM (
SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6,
- RAWTOHEX(STANDARD_HASH(
- NVL(STOCK_NAME, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_1, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_2, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_3, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_4, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_5, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_6, CHR(0)), 'SHA256')) ROW_VERSION
+ __ROW_VERSION_EXPRESSION__ ROW_VERSION,
+ ROWIDTOCHAR(ROWID) ROW_HANDLE
FROM INPUT_SELL
WHERE STOCK_NAME = :stock_name
)
- WHERE ROWNUM <= 2
+ WHERE ROWNUM <= :row_limit
+ """;
+
+ private const string SellRawGetSql =
+ """
+ SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6,
+ __ROW_VERSION_EXPRESSION__ ROW_VERSION,
+ ROWIDTOCHAR(ROWID) ROW_HANDLE
+ FROM INPUT_SELL
+ WHERE ROWID = CHARTOROWID(:row_handle)
+ AND STOCK_NAME = :stock_name
""";
private const string SellInsertSql =
@@ -1721,45 +2278,47 @@ internal static class ManualFinancialSqlProfiles
GUSUNG_5 = :field_5,
GUSUNG_6 = :field_6
WHERE STOCK_NAME = :identity_stock_name
- AND RAWTOHEX(STANDARD_HASH(
- NVL(STOCK_NAME, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_1, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_2, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_3, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_4, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_5, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_6, CHR(0)), 'SHA256')) = :expected_row_version
+ AND __ROW_VERSION_EXPRESSION__ = :expected_row_version
AND (SELECT COUNT(*) FROM INPUT_SELL WHERE STOCK_NAME = :unique_stock_name) = 1
""";
+ private const string SellRawUpdateSql =
+ """
+ UPDATE INPUT_SELL
+ SET GUSUNG_1 = :field_1,
+ GUSUNG_2 = :field_2,
+ GUSUNG_3 = :field_3,
+ GUSUNG_4 = :field_4,
+ GUSUNG_5 = :field_5,
+ GUSUNG_6 = :field_6
+ WHERE ROWID = CHARTOROWID(:row_handle)
+ AND STOCK_NAME = :identity_stock_name
+ AND __ROW_VERSION_EXPRESSION__ = :expected_row_version
+ """;
+
private const string SellDeleteOneSql =
"""
DELETE FROM INPUT_SELL
WHERE STOCK_NAME = :identity_stock_name
- AND RAWTOHEX(STANDARD_HASH(
- NVL(STOCK_NAME, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_1, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_2, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_3, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_4, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_5, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_6, CHR(0)), 'SHA256')) = :expected_row_version
+ AND __ROW_VERSION_EXPRESSION__ = :expected_row_version
AND (SELECT COUNT(*) FROM INPUT_SELL WHERE STOCK_NAME = :unique_stock_name) = 1
""";
+ private const string SellRawDeleteOneSql =
+ """
+ DELETE FROM INPUT_SELL
+ WHERE ROWID = CHARTOROWID(:row_handle)
+ AND STOCK_NAME = :identity_stock_name
+ AND __ROW_VERSION_EXPRESSION__ = :expected_row_version
+ """;
+
private const string ProfitSearchSql =
"""
- SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, ROW_VERSION
+ SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, ROW_VERSION, ROW_HANDLE
FROM (
SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6,
- RAWTOHEX(STANDARD_HASH(
- NVL(STOCK_NAME, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_1, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_2, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_3, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_4, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_5, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_6, CHR(0)), 'SHA256')) ROW_VERSION
+ __ROW_VERSION_EXPRESSION__ ROW_VERSION,
+ ROWIDTOCHAR(ROWID) ROW_HANDLE
FROM INPUT_PROFIT
WHERE (:search_empty = 1 OR
UPPER(STOCK_NAME) LIKE '%' || :search_term || '%' ESCAPE '!')
@@ -1770,21 +2329,25 @@ internal static class ManualFinancialSqlProfiles
private const string ProfitGetSql =
"""
- SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, ROW_VERSION
+ SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6, ROW_VERSION, ROW_HANDLE
FROM (
SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6,
- RAWTOHEX(STANDARD_HASH(
- NVL(STOCK_NAME, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_1, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_2, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_3, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_4, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_5, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_6, CHR(0)), 'SHA256')) ROW_VERSION
+ __ROW_VERSION_EXPRESSION__ ROW_VERSION,
+ ROWIDTOCHAR(ROWID) ROW_HANDLE
FROM INPUT_PROFIT
WHERE STOCK_NAME = :stock_name
)
- WHERE ROWNUM <= 2
+ WHERE ROWNUM <= :row_limit
+ """;
+
+ private const string ProfitRawGetSql =
+ """
+ SELECT STOCK_NAME, GUSUNG_1, GUSUNG_2, GUSUNG_3, GUSUNG_4, GUSUNG_5, GUSUNG_6,
+ __ROW_VERSION_EXPRESSION__ ROW_VERSION,
+ ROWIDTOCHAR(ROWID) ROW_HANDLE
+ FROM INPUT_PROFIT
+ WHERE ROWID = CHARTOROWID(:row_handle)
+ AND STOCK_NAME = :stock_name
""";
private const string ProfitInsertSql =
@@ -1806,29 +2369,37 @@ internal static class ManualFinancialSqlProfiles
GUSUNG_5 = :field_5,
GUSUNG_6 = :field_6
WHERE STOCK_NAME = :identity_stock_name
- AND RAWTOHEX(STANDARD_HASH(
- NVL(STOCK_NAME, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_1, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_2, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_3, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_4, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_5, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_6, CHR(0)), 'SHA256')) = :expected_row_version
+ AND __ROW_VERSION_EXPRESSION__ = :expected_row_version
AND (SELECT COUNT(*) FROM INPUT_PROFIT WHERE STOCK_NAME = :unique_stock_name) = 1
""";
+ private const string ProfitRawUpdateSql =
+ """
+ UPDATE INPUT_PROFIT
+ SET GUSUNG_1 = :field_1,
+ GUSUNG_2 = :field_2,
+ GUSUNG_3 = :field_3,
+ GUSUNG_4 = :field_4,
+ GUSUNG_5 = :field_5,
+ GUSUNG_6 = :field_6
+ WHERE ROWID = CHARTOROWID(:row_handle)
+ AND STOCK_NAME = :identity_stock_name
+ AND __ROW_VERSION_EXPRESSION__ = :expected_row_version
+ """;
+
private const string ProfitDeleteOneSql =
"""
DELETE FROM INPUT_PROFIT
WHERE STOCK_NAME = :identity_stock_name
- AND RAWTOHEX(STANDARD_HASH(
- NVL(STOCK_NAME, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_1, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_2, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_3, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_4, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_5, CHR(0)) || CHR(31) ||
- NVL(GUSUNG_6, CHR(0)), 'SHA256')) = :expected_row_version
+ AND __ROW_VERSION_EXPRESSION__ = :expected_row_version
AND (SELECT COUNT(*) FROM INPUT_PROFIT WHERE STOCK_NAME = :unique_stock_name) = 1
""";
+
+ private const string ProfitRawDeleteOneSql =
+ """
+ DELETE FROM INPUT_PROFIT
+ WHERE ROWID = CHARTOROWID(:row_handle)
+ AND STOCK_NAME = :identity_stock_name
+ AND __ROW_VERSION_EXPRESSION__ = :expected_row_version
+ """;
}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs
index 6b1ab9b..bdae599 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs
@@ -815,19 +815,32 @@ public sealed class LegacyNamedPlaylistPersistenceService : INamedPlaylistPersis
}
var selection = item.Selection;
- var canonical = new LegacySceneSelection(
- ValidateText(
- selection.GroupCode,
- MaximumSelectionFieldLength,
- allowEmpty: true,
- allowCaret: false,
- parameterName),
- ValidateText(
+ var groupCode = ValidateText(
+ selection.GroupCode,
+ MaximumSelectionFieldLength,
+ allowEmpty: true,
+ allowCaret: false,
+ parameterName);
+ var dataCode = ValidateText(
+ selection.DataCode,
+ MaximumSelectionFieldLength,
+ allowEmpty: true,
+ allowCaret: false,
+ parameterName);
+ var subject = CanPreserveClosedThemeSubject(
+ groupCode,
+ selection.Subject,
+ dataCode)
+ ? selection.Subject
+ : ValidateText(
selection.Subject,
MaximumSubjectFieldLength,
allowEmpty: true,
allowCaret: false,
- parameterName),
+ parameterName);
+ var canonical = new LegacySceneSelection(
+ groupCode,
+ subject,
ValidateText(
selection.GraphicType,
MaximumSelectionFieldLength,
@@ -840,12 +853,7 @@ public sealed class LegacyNamedPlaylistPersistenceService : INamedPlaylistPersis
allowEmpty: true,
allowCaret: false,
parameterName),
- ValidateText(
- selection.DataCode,
- MaximumSelectionFieldLength,
- allowEmpty: true,
- allowCaret: false,
- parameterName));
+ dataCode);
var validated = item with { Selection = canonical };
if (SerializeItem(validated).Length > MaximumListTextLength)
{
@@ -905,6 +913,33 @@ public sealed class LegacyNamedPlaylistPersistenceService : INamedPlaylistPersis
return value;
}
+ private static bool CanPreserveClosedThemeSubject(
+ string groupCode,
+ string subject,
+ string dataCode)
+ {
+ if (dataCode.Length is < 3 or > 8 ||
+ dataCode.Any(character => !char.IsAsciiDigit(character)))
+ {
+ return false;
+ }
+
+ var isNxt = groupCode is "테마_NXT" or "PAGED_NXT_THEME";
+ if (!isNxt && groupCode is not ("테마" or "PAGED_THEME"))
+ {
+ return false;
+ }
+
+ const string suffix = "(NXT)";
+ var exactTitle = isNxt && subject.EndsWith(suffix, StringComparison.Ordinal)
+ ? subject[..^suffix.Length]
+ : isNxt ? string.Empty : subject;
+ return exactTitle.Length is >= 1 and <= 128 &&
+ !string.IsNullOrWhiteSpace(exactTitle) &&
+ !subject.Contains('^') &&
+ IsSafeText(subject);
+ }
+
private static bool IsSafeText(string value) =>
!value.Any(static character =>
char.IsControl(character) ||
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs
index 0ebf3d3..18db508 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs
@@ -7,8 +7,8 @@ namespace MMoneyCoderSharp.Data;
///
/// Exact US foreign-industry/index identity from T_WORLD_IX_EQ_MASTER.
-/// KoreanName is F_KNAM (the one-column lookup key), InputName is
-/// F_INPUT_NAME (the candle lookup key), and Symbol is F_SYMB.
+/// The complete tuple is the identity. Individual display or downstream
+/// lookup fields are not required to be globally unique.
///
public sealed record OverseasIndustryIndexItem(
string KoreanName,
@@ -46,7 +46,7 @@ public sealed class LegacyOverseasIndustryIndexSearchService
: IOverseasIndustryIndexSearchService
{
public const int DefaultMaximumResults = 100;
- public const int MaximumResults = 500;
+ public const int MaximumResults = 2_000;
public const int MaximumQueryLength = 64;
private const int MaximumKoreanNameLength = 200;
@@ -61,18 +61,9 @@ public sealed class LegacyOverseasIndustryIndexSearchService
SELECT F_KNAM, F_INPUT_NAME, F_SYMB
FROM (
SELECT F_KNAM, F_INPUT_NAME, F_SYMB
- FROM (
- SELECT F_KNAM, F_INPUT_NAME, F_SYMB,
- COUNT(*) OVER (PARTITION BY F_KNAM) AS KNAM_COUNT,
- COUNT(*) OVER (PARTITION BY F_INPUT_NAME) AS INPUT_NAME_COUNT,
- COUNT(*) OVER (PARTITION BY F_SYMB) AS SYMBOL_COUNT
- FROM T_WORLD_IX_EQ_MASTER
- WHERE F_FDTC = '0'
- AND F_NATC = 'US'
- )
- WHERE KNAM_COUNT = 1
- AND INPUT_NAME_COUNT = 1
- AND SYMBOL_COUNT = 1
+ FROM T_WORLD_IX_EQ_MASTER
+ WHERE F_FDTC = '0'
+ AND F_NATC = 'US'
AND UPPER(F_KNAM) LIKE '%' || :search_term || '%' ESCAPE '!'
ORDER BY UPPER(F_KNAM), F_INPUT_NAME, F_SYMB
)
@@ -174,9 +165,7 @@ public sealed class LegacyOverseasIndustryIndexSearchService
}
}
- var koreanNames = new HashSet(StringComparer.Ordinal);
- var inputNames = new HashSet(StringComparer.Ordinal);
- var symbols = new HashSet(StringComparer.Ordinal);
+ var identities = new HashSet();
var results = new List(table.Rows.Count);
foreach (DataRow row in table.Rows)
{
@@ -184,17 +173,13 @@ public sealed class LegacyOverseasIndustryIndexSearchService
var inputName = ReadCanonicalValue(row, 1, MaximumInputNameLength);
var symbol = ReadCanonicalValue(row, 2, MaximumSymbolLength);
- // Current one-column, candle, and quote loaders use F_KNAM,
- // F_INPUT_NAME, and F_SYMB respectively. A duplicate in any key can
- // resolve a different master row from the operator's chosen entry.
- if (!koreanNames.Add(koreanName) ||
- !inputNames.Add(inputName) ||
- !symbols.Add(symbol))
+ var identity = new OverseasIndustryIndexItem(koreanName, inputName, symbol);
+ if (!identities.Add(identity))
{
- throw InvalidData("ambiguous duplicate downstream lookup key");
+ throw InvalidData("duplicate exact identity");
}
- results.Add(new OverseasIndustryIndexItem(koreanName, inputName, symbol));
+ results.Add(identity);
}
return results;
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasStockSearch.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasStockSearch.cs
index d29c7a4..081f242 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasStockSearch.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Data/OverseasStockSearch.cs
@@ -24,7 +24,8 @@ public interface IOverseasStockSearchService
///
public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchService
{
- public const int MaximumResults = 500;
+ public const int MaximumResults = 2_000;
+ public const int MaximumIsolatedRows = 128;
// UC5 populated every returned row. Keep the bounded migration at its
// highest allowed size so the compatibility limit is not silently lower
// than necessary; callers surface IsTruncated when the bound is reached.
@@ -79,7 +80,10 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi
}
cancellationToken.ThrowIfCancellationRequested();
- var rowLimit = checked(maximumResults + 1);
+ // Read enough additional rows to keep malformed display-only entries
+ // from consuming the operator-visible result budget. The query remains
+ // finite and one extra valid row is retained for truncation detection.
+ var rowLimit = checked(maximumResults + MaximumIsolatedRows + 1);
var spec = CreateQuerySpec(normalizedQuery, rowLimit);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
@@ -88,12 +92,16 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
- var matches = ValidateRows(table, rowLimit);
+ var validation = ValidateRows(table, rowLimit);
+ var matches = validation.Items;
return new WorldStockSearchResult(
normalizedQuery,
DateTimeOffset.Now,
matches.Take(maximumResults).ToArray(),
- matches.Count > maximumResults);
+ matches.Count > maximumResults ||
+ table.Rows.Count == rowLimit ||
+ validation.IsolatedInvalidRowCount > 0,
+ validation.IsolatedInvalidRowCount);
}
private static DataQuerySpec CreateQuerySpec(string query, int rowLimit)
@@ -117,7 +125,7 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi
return spec;
}
- private static IReadOnlyList ValidateRows(DataTable? table, int rowLimit)
+ private static RowValidationResult ValidateRows(DataTable? table, int rowLimit)
{
if (table is null || table.Columns.Count != 3 || table.Rows.Count > rowLimit ||
!HasExactStringColumn(table.Columns[0], "F_INPUT_NAME") ||
@@ -129,8 +137,20 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi
var identities = new HashSet();
var matches = new List(table.Rows.Count);
+ var isolatedInvalidRowCount = 0;
foreach (DataRow row in table.Rows)
{
+ // Sixteen rows in the current US/TW master have no display name.
+ // The original grid did not let those rows prevent every valid row
+ // from loading. Preserve that behavior without making an unnamed
+ // identity selectable or serializable.
+ if (row[0] is DBNull ||
+ row[0] is string blankName && blankName.Trim(' ').Length == 0)
+ {
+ isolatedInvalidRowCount++;
+ continue;
+ }
+
var inputName = ReadCanonical(row, 0, MaximumInputNameLength);
var symbol = ReadCanonical(row, 1, MaximumSymbolLength);
var nationCode = ReadCanonical(row, 2, MaximumNationCodeLength);
@@ -149,7 +169,7 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi
matches.Add(identity);
}
- return matches;
+ return new RowValidationResult(matches, isolatedInvalidRowCount);
}
private static string ValidateAndNormalizeQuery(string query)
@@ -215,4 +235,8 @@ public sealed class LegacyOverseasStockSearchService : IOverseasStockSearchServi
private static WorldStockSearchDataException InvalidData(string detail) =>
new($"Overseas stock data from {QueryName} contains an invalid {detail}.");
+
+ private sealed record RowValidationResult(
+ IReadOnlyList Items,
+ int IsolatedInvalidRowCount);
}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs
index 57c7e6a..e3b08b7 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs
@@ -33,6 +33,17 @@ public interface IStockSearchService
CancellationToken cancellationToken = default);
}
+///
+/// Keeps the legacy ThemeA/UC6 stock-picker "show all" action explicit and
+/// bounded without making an empty query valid for every stock-search caller.
+///
+public interface IOperatorCatalogStockSearchService : IStockSearchService
+{
+ Task SearchAllAsync(
+ int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
+ CancellationToken cancellationToken = default);
+}
+
public sealed class StockSearchDataException : Exception
{
public StockSearchDataException(string message)
@@ -49,7 +60,7 @@ public sealed class StockSearchDataException : Exception
public sealed class LegacyStockSearchService : IStockSearchService
{
public const int DefaultMaximumResults = 100;
- public const int MaximumResults = 500;
+ public const int MaximumResults = 10_000;
public const int MaximumQueryLength = 64;
private const int MaximumStockNameLength = 200;
@@ -70,6 +81,23 @@ public sealed class LegacyStockSearchService : IStockSearchService
CancellationToken cancellationToken = default)
{
var normalizedQuery = ValidateAndNormalizeQuery(query);
+ return await SearchNormalizedAsync(
+ normalizedQuery,
+ maximumResults,
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ internal Task SearchAllForOperatorCatalogAsync(
+ int maximumResults,
+ CancellationToken cancellationToken) =>
+ SearchNormalizedAsync(string.Empty, maximumResults, cancellationToken);
+
+ private async Task SearchNormalizedAsync(
+ string normalizedQuery,
+ int maximumResults,
+ CancellationToken cancellationToken)
+ {
if (maximumResults is < 1 or > MaximumResults)
{
throw new ArgumentOutOfRangeException(
@@ -217,6 +245,42 @@ internal sealed record StockSearchQueryProfile(
}
}
+///
+/// Operator-catalog-only adapter for the original blank stock-picker action.
+/// Non-empty searches retain the normal validation contract; only SearchAllAsync
+/// can issue a bound empty LIKE term.
+///
+public sealed class LegacyOperatorCatalogStockSearchService :
+ IOperatorCatalogStockSearchService
+{
+ private readonly LegacyStockSearchService _stockSearchService;
+
+ public LegacyOperatorCatalogStockSearchService(IDataQueryExecutor executor)
+ : this(new LegacyStockSearchService(executor))
+ {
+ }
+
+ public LegacyOperatorCatalogStockSearchService(
+ LegacyStockSearchService stockSearchService)
+ {
+ _stockSearchService = stockSearchService ??
+ throw new ArgumentNullException(nameof(stockSearchService));
+ }
+
+ public Task SearchAsync(
+ string query,
+ int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
+ CancellationToken cancellationToken = default) =>
+ _stockSearchService.SearchAsync(query, maximumResults, cancellationToken);
+
+ public Task SearchAllAsync(
+ int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
+ CancellationToken cancellationToken = default) =>
+ _stockSearchService.SearchAllForOperatorCatalogAsync(
+ maximumResults,
+ cancellationToken);
+}
+
internal static class StockSearchQueries
{
internal static IReadOnlyList All { get; } =
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs
index 4aee461..b7677a9 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs
@@ -17,10 +17,20 @@ public sealed record ThemeCatalogDefinition(
string ThemeTitle,
IReadOnlyList Items);
+public sealed record ThemeCatalogStoredResult(
+ ThemeSelectionIdentity Identity,
+ DateTimeOffset RetrievedAt,
+ IReadOnlyList Items,
+ bool IsTruncated);
+
public interface IThemeCatalogPersistenceService
{
bool CanMutate { get; }
+ Task ReadStoredAsync(
+ ThemeSelectionIdentity identity,
+ CancellationToken cancellationToken = default);
+
Task ThemeNameExistsAsync(
ThemeMarket market,
string themeTitle,
@@ -227,8 +237,14 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog
CancellationToken cancellationToken = default)
{
var expected = ValidateIdentity(expectedIdentity, nameof(expectedIdentity));
- var title = ValidateThemeTitle(newTitle, expected.Market, nameof(newTitle));
- var canonicalItems = ValidateItems(items, expected.Market, nameof(items));
+ var title = string.Equals(newTitle, expected.ThemeTitle, StringComparison.Ordinal)
+ ? ValidateStoredThemeTitle(newTitle, nameof(newTitle))
+ : ValidateThemeTitle(newTitle, expected.Market, nameof(newTitle));
+ var canonicalItems = ValidateItems(
+ items,
+ expected.Market,
+ allowLegacyCrossMarketPrefix: true,
+ nameof(items));
var profile = ThemeMutationProfiles.For(expected.Market);
var commands = new List(canonicalItems.Count + 2)
{
@@ -333,7 +349,11 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog
var code = ValidateNewThemeCode(definition.ThemeCode, parameterName);
var title = ValidateThemeTitle(definition.ThemeTitle, definition.Market, parameterName);
- var items = ValidateItems(definition.Items, definition.Market, parameterName);
+ var items = ValidateItems(
+ definition.Items,
+ definition.Market,
+ allowLegacyCrossMarketPrefix: false,
+ parameterName);
return new ThemeCatalogDefinition(definition.Market, code, title, items);
}
@@ -353,13 +373,14 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog
return identity with
{
ThemeCode = ValidateExistingThemeCode(identity.ThemeCode, parameterName),
- ThemeTitle = ValidateThemeTitle(identity.ThemeTitle, identity.Market, parameterName)
+ ThemeTitle = ValidateStoredThemeTitle(identity.ThemeTitle, parameterName)
};
}
private static IReadOnlyList ValidateItems(
IReadOnlyList items,
ThemeMarket market,
+ bool allowLegacyCrossMarketPrefix,
string parameterName)
{
ArgumentNullException.ThrowIfNull(items);
@@ -384,20 +405,22 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog
}
var code = ValidateCanonicalText(item.ItemCode, 13, parameterName);
- var codePattern = market == ThemeMarket.Krx
- ? KrxItemCodePattern()
- : NxtItemCodePattern();
- if (!codePattern.IsMatch(code))
+ var matchesExpectedMarket = market == ThemeMarket.Krx
+ ? KrxItemCodePattern().IsMatch(code)
+ : NxtItemCodePattern().IsMatch(code);
+ var matchesClosedLegacyPrefix =
+ KrxItemCodePattern().IsMatch(code) || NxtItemCodePattern().IsMatch(code);
+ if (!matchesExpectedMarket &&
+ (!allowLegacyCrossMarketPrefix || !matchesClosedLegacyPrefix))
{
throw new ArgumentException(
"A theme item code does not match its market.",
parameterName);
}
- var name = ValidateCanonicalText(
- item.ItemName,
- MaximumItemNameLength,
- parameterName);
+ var name = allowLegacyCrossMarketPrefix
+ ? ValidateStoredText(item.ItemName, MaximumItemNameLength, parameterName)
+ : ValidateCanonicalText(item.ItemName, MaximumItemNameLength, parameterName);
canonical[index] = new ThemeCatalogItem(index, code, name);
}
@@ -445,6 +468,25 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog
return title;
}
+ private static string ValidateStoredThemeTitle(string value, string parameterName)
+ => ValidateStoredText(value, MaximumThemeTitleLength, parameterName);
+
+ private static string ValidateStoredText(
+ string value,
+ int maximumLength,
+ string parameterName)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ if (value.Length < 1 || value.Length > maximumLength ||
+ string.IsNullOrWhiteSpace(value) ||
+ !IsSafeText(value))
+ {
+ throw new ArgumentException("A stored theme value is invalid.", parameterName);
+ }
+
+ return value;
+ }
+
private static string ValidateCanonicalText(
string value,
int maximumLength,
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogStoredRead.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogStoredRead.cs
new file mode 100644
index 0000000..f2f5d61
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogStoredRead.cs
@@ -0,0 +1,218 @@
+#nullable enable
+
+using System.Data;
+using System.Globalization;
+
+namespace MMoneyCoderSharp.Data;
+
+public sealed partial class LegacyThemeCatalogPersistenceService
+{
+ internal const string StoredKrxQueryName = "THEME_STORED_ITEMS_KRX";
+ internal const string StoredNxtQueryName = "THEME_STORED_ITEMS_NXT";
+
+ private static readonly string[] StoredColumns =
+ [
+ "THEME_TITLE",
+ "THEME_CODE",
+ "THEME_MARKET",
+ "ITEM_NAME",
+ "ITEM_CODE",
+ "ITEM_INDEX"
+ ];
+
+ private const string StoredKrxSql = """
+ SELECT THEME_TITLE, THEME_CODE, THEME_MARKET,
+ ITEM_NAME, ITEM_CODE, ITEM_INDEX
+ FROM (
+ SELECT l.SB_TITLE THEME_TITLE,
+ l.SB_CODE THEME_CODE,
+ l.SB_MARKET THEME_MARKET,
+ i.SB_I_NAME ITEM_NAME,
+ i.SB_I_CODE ITEM_CODE,
+ TO_CHAR(i.SB_I_INDEX, 'FM999999990') ITEM_INDEX
+ FROM SB_LIST l
+ LEFT JOIN SB_ITEM i ON i.SB_M_CODE = l.SB_CODE
+ WHERE l.SB_CODE = :theme_code
+ AND l.SB_TITLE = :theme_title
+ AND l.SB_MARKET = 'KRX'
+ AND (SELECT COUNT(*)
+ FROM SB_LIST d
+ WHERE d.SB_MARKET = l.SB_MARKET
+ AND d.SB_CODE = l.SB_CODE) = 1
+ ORDER BY i.SB_I_INDEX, i.SB_I_CODE, i.SB_I_NAME
+ )
+ WHERE ROWNUM <= :row_limit
+ """;
+
+ private const string StoredNxtSql = """
+ SELECT l.SB_TITLE THEME_TITLE,
+ l.SB_CODE THEME_CODE,
+ l.SB_MARKET THEME_MARKET,
+ i.SB_I_NAME ITEM_NAME,
+ i.SB_I_CODE ITEM_CODE,
+ CAST(i.SB_I_INDEX AS CHAR) ITEM_INDEX
+ FROM SB_LIST l
+ LEFT JOIN SB_ITEM i ON BINARY i.SB_M_CODE = BINARY l.SB_CODE
+ WHERE BINARY l.SB_CODE = BINARY @theme_code
+ AND BINARY l.SB_TITLE = BINARY @theme_title
+ AND l.SB_MARKET = 'NXT'
+ AND (SELECT COUNT(*)
+ FROM SB_LIST d
+ WHERE d.SB_MARKET = l.SB_MARKET
+ AND BINARY d.SB_CODE = BINARY l.SB_CODE) = 1
+ ORDER BY i.SB_I_INDEX, i.SB_I_CODE, i.SB_I_NAME
+ LIMIT @row_limit
+ """;
+
+ public async Task ReadStoredAsync(
+ ThemeSelectionIdentity identity,
+ CancellationToken cancellationToken = default)
+ {
+ var expected = ValidateIdentity(identity, nameof(identity));
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var source = expected.Market == ThemeMarket.Krx
+ ? DataSourceKind.Oracle
+ : DataSourceKind.MariaDb;
+ var queryName = expected.Market == ThemeMarket.Krx
+ ? StoredKrxQueryName
+ : StoredNxtQueryName;
+ var rowLimit = checked(MaximumItems + 1);
+ var query = new DataQuerySpec(
+ expected.Market == ThemeMarket.Krx ? StoredKrxSql : StoredNxtSql,
+ [
+ new DataQueryParameter("theme_code", expected.ThemeCode, DbType.String),
+ new DataQueryParameter("theme_title", expected.ThemeTitle, DbType.String),
+ new DataQueryParameter("row_limit", rowLimit, DbType.Int32)
+ ]);
+ query.ValidateFor(source);
+ var table = await _queryExecutor.ExecuteAsync(
+ source,
+ queryName,
+ query,
+ cancellationToken).ConfigureAwait(false);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var items = MapStoredRows(table, expected, rowLimit, queryName);
+ return new ThemeCatalogStoredResult(
+ expected,
+ DateTimeOffset.Now,
+ items.Take(MaximumItems).ToArray(),
+ items.Count > MaximumItems);
+ }
+
+ private static IReadOnlyList MapStoredRows(
+ DataTable? table,
+ ThemeSelectionIdentity expected,
+ int rowLimit,
+ string queryName)
+ {
+ ValidateStoredSchema(table, rowLimit, queryName);
+ if (table!.Rows.Count == 0)
+ {
+ throw InvalidStoredData(queryName, "missing selected theme");
+ }
+
+ var result = new List(table.Rows.Count);
+ var sawEmptySentinel = false;
+ foreach (DataRow row in table.Rows)
+ {
+ if (row[0] is not string title || row[1] is not string code ||
+ row[2] is not string market ||
+ !string.Equals(title, expected.ThemeTitle, StringComparison.Ordinal) ||
+ !string.Equals(code, expected.ThemeCode, StringComparison.Ordinal) ||
+ !string.Equals(
+ market,
+ expected.Market == ThemeMarket.Krx ? "KRX" : "NXT",
+ StringComparison.Ordinal))
+ {
+ throw InvalidStoredData(queryName, "selected theme identity");
+ }
+
+ var nameValue = row[3];
+ var codeValue = row[4];
+ var indexValue = row[5];
+ if (nameValue is DBNull && codeValue is DBNull && indexValue is DBNull)
+ {
+ if (table.Rows.Count != 1)
+ {
+ throw InvalidStoredData(queryName, "empty item sentinel");
+ }
+
+ sawEmptySentinel = true;
+ continue;
+ }
+
+ if (nameValue is not string itemName || codeValue is not string itemCode ||
+ indexValue is not string rawIndex ||
+ itemName.Length is < 1 or > 200 || string.IsNullOrWhiteSpace(itemName) ||
+ !IsSafeText(itemName) ||
+ itemCode.Length is < 2 or > 13 ||
+ !string.Equals(itemCode, itemCode.Trim(), StringComparison.Ordinal) ||
+ !IsSafeText(itemCode) ||
+ !IsClosedStoredItemCode(itemCode) ||
+ !int.TryParse(
+ rawIndex,
+ NumberStyles.None,
+ CultureInfo.InvariantCulture,
+ out var inputIndex) ||
+ inputIndex is < 0 or > 9_999 ||
+ !string.Equals(
+ rawIndex,
+ inputIndex.ToString(CultureInfo.InvariantCulture),
+ StringComparison.Ordinal))
+ {
+ throw InvalidStoredData(queryName, "stored item");
+ }
+
+ result.Add(new ThemeCatalogItem(inputIndex, itemCode, itemName));
+ }
+
+ if (sawEmptySentinel)
+ {
+ return Array.Empty();
+ }
+
+ for (var index = 1; index < result.Count; index++)
+ {
+ if (result[index - 1].InputIndex > result[index].InputIndex)
+ {
+ throw InvalidStoredData(queryName, "stored item order");
+ }
+ }
+
+ return result;
+ }
+
+ private static void ValidateStoredSchema(
+ DataTable? table,
+ int rowLimit,
+ string queryName)
+ {
+ if (table is null || table.Columns.Count != StoredColumns.Length ||
+ table.Rows.Count > rowLimit)
+ {
+ throw InvalidStoredData(queryName, "schema or row bound");
+ }
+
+ for (var index = 0; index < StoredColumns.Length; index++)
+ {
+ if (!string.Equals(
+ table.Columns[index].ColumnName,
+ StoredColumns[index],
+ StringComparison.Ordinal) ||
+ table.Columns[index].DataType != typeof(string))
+ {
+ throw InvalidStoredData(queryName, "schema");
+ }
+ }
+ }
+
+ private static bool IsClosedStoredItemCode(string value) =>
+ KrxItemCodePattern().IsMatch(value) || NxtItemCodePattern().IsMatch(value);
+
+ private static ThemeSelectionDataException InvalidStoredData(
+ string queryName,
+ string detail) =>
+ new($"Stored ThemeA data from {queryName} contains an invalid {detail}.");
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs
index c33788e..95f8629 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs
@@ -82,8 +82,11 @@ public sealed class ThemeSelectionDataException : Exception
///
public sealed partial class LegacyThemeSelectionService : IThemeSelectionService
{
- public const int DefaultMaximumResults = 100;
- public const int MaximumResults = 500;
+ // The deployed UC4 selector loads the whole current KRX/NXT theme catalog.
+ // Keep a finite defensive ceiling, but do not silently cut the current
+ // catalog at the earlier migration-only 100/500 row limits.
+ public const int DefaultMaximumResults = 5_000;
+ public const int MaximumResults = 5_000;
public const int MaximumQueryLength = 64;
public const int DefaultMaximumPreviewItems = 240;
public const int MaximumPreviewItems = 240;
@@ -188,7 +191,7 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService
var codes = new HashSet(StringComparer.Ordinal);
foreach (DataRow row in table!.Rows)
{
- var title = ReadCanonicalString(row, 0, MaximumThemeTitleLength, profile.SearchName);
+ var title = ReadStoredThemeTitle(row, 0, profile.SearchName);
var code = ReadThemeCode(row, 1, profile.SearchName);
var market = ReadCanonicalString(row, 2, 3, profile.SearchName);
if (!string.Equals(market, profile.DatabaseMarket, StringComparison.Ordinal))
@@ -196,8 +199,9 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService
throw InvalidData(profile.SearchName, "market identity");
}
- // PAGED_THEME resolves by title and UC4 previews by code. Both keys
- // must be unique inside one market or the selection is ambiguous.
+ // The deployed catalog contains distinct codes that intentionally
+ // share a title. Code remains the closed per-market identity; the
+ // exact stored title is carried alongside it for correlated reads.
if (!codes.Add(code))
{
throw InvalidData(profile.SearchName, "duplicate theme code");
@@ -229,7 +233,7 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService
var sawEmptyThemeRow = false;
foreach (DataRow row in table.Rows)
{
- var title = ReadCanonicalString(row, 0, MaximumThemeTitleLength, profile.PreviewName);
+ var title = ReadStoredThemeTitle(row, 0, profile.PreviewName);
var code = ReadThemeCode(row, 1, profile.PreviewName);
var market = ReadCanonicalString(row, 2, 3, profile.PreviewName);
if (!string.Equals(title, identity.ThemeTitle, StringComparison.Ordinal) ||
@@ -318,10 +322,7 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService
nameof(identity));
}
- var title = ValidateCanonicalInput(
- identity.ThemeTitle,
- MaximumThemeTitleLength,
- nameof(identity));
+ var title = ValidateStoredThemeTitle(identity.ThemeTitle, nameof(identity));
var code = ValidateCanonicalInput(identity.ThemeCode, 8, nameof(identity));
if (!ThemeCodePattern().IsMatch(code))
{
@@ -366,6 +367,22 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService
return value;
}
+ private static string ValidateStoredThemeTitle(string value, string parameterName)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ if (value.Length is < 1 or > MaximumThemeTitleLength ||
+ string.IsNullOrWhiteSpace(value) ||
+ !IsSafeText(value))
+ {
+ throw new ArgumentException("The stored theme title is invalid.", parameterName);
+ }
+
+ // Legacy SB_LIST titles can contain meaningful leading or trailing
+ // spaces. Preserve the exact database value as part of the identity;
+ // trimming it can collapse distinct deployed rows.
+ return value;
+ }
+
private static void ValidateNxtSession(ThemeSession session, string parameterName)
{
if (session is not (ThemeSession.PreMarket or ThemeSession.AfterMarket))
@@ -429,6 +446,22 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService
return value;
}
+ private static string ReadStoredThemeTitle(
+ DataRow row,
+ int ordinal,
+ string queryName)
+ {
+ if (row[ordinal] is not string value ||
+ value.Length is < 1 or > MaximumThemeTitleLength ||
+ string.IsNullOrWhiteSpace(value) ||
+ !IsSafeText(value))
+ {
+ throw InvalidData(queryName, "stored theme title");
+ }
+
+ return value;
+ }
+
private static string ReadThemeCode(DataRow row, int ordinal, string queryName)
{
var code = ReadCanonicalString(row, ordinal, 8, queryName);
@@ -537,13 +570,8 @@ internal static class ThemeQueries
FROM SB_LIST s
WHERE s.SB_MARKET = 'KRX'
AND s.SB_TITLE IS NOT NULL
- AND s.SB_TITLE = TRIM(s.SB_TITLE)
AND LENGTH(s.SB_TITLE) BETWEEN 1 AND 128
AND REGEXP_LIKE(s.SB_CODE, '^[0-9]{3,8}$', 'c')
- AND (SELECT COUNT(*)
- FROM SB_LIST d
- WHERE d.SB_MARKET = s.SB_MARKET
- AND d.SB_TITLE = s.SB_TITLE) = 1
AND (SELECT COUNT(*)
FROM SB_LIST d
WHERE d.SB_MARKET = s.SB_MARKET
@@ -591,13 +619,8 @@ internal static class ThemeQueries
FROM SB_LIST s
WHERE s.SB_MARKET = 'NXT'
AND s.SB_TITLE IS NOT NULL
- AND BINARY s.SB_TITLE = BINARY TRIM(s.SB_TITLE)
AND CHAR_LENGTH(s.SB_TITLE) BETWEEN 1 AND 128
AND s.SB_CODE REGEXP BINARY '^[0-9]{3,8}$'
- AND (SELECT COUNT(*)
- FROM SB_LIST d
- WHERE d.SB_MARKET = s.SB_MARKET
- AND BINARY d.SB_TITLE = BINARY s.SB_TITLE) = 1
AND (SELECT COUNT(*)
FROM SB_LIST d
WHERE d.SB_MARKET = s.SB_MARKET
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs
index 2754547..0809a40 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs
@@ -14,7 +14,8 @@ public sealed record WorldStockSearchResult(
string Query,
DateTimeOffset RetrievedAt,
IReadOnlyList Items,
- bool IsTruncated);
+ bool IsTruncated,
+ int IsolatedInvalidRowCount = 0);
public interface IWorldStockSearchService
{
@@ -41,7 +42,8 @@ public sealed class WorldStockSearchDataException : Exception
public sealed class LegacyWorldStockSearchService : IWorldStockSearchService
{
public const int DefaultMaximumResults = 100;
- public const int MaximumResults = 500;
+ public const int MaximumResults = 5_000;
+ public const int MaximumIsolatedRows = 128;
public const int MaximumQueryLength = 64;
private const int MaximumInputNameLength = 200;
@@ -51,6 +53,18 @@ public sealed class LegacyWorldStockSearchService : IWorldStockSearchService
private const string NationCodeColumn = "F_NATC";
private const string QueryName = "WORLD_STOCK_SEARCH";
+ private const string InitialListSql = """
+ SELECT F_INPUT_NAME, F_SYMB, F_NATC
+ FROM (
+ SELECT F_INPUT_NAME, F_SYMB, F_NATC
+ FROM T_WORLD_IX_EQ_MASTER
+ WHERE F_FDTC = '1'
+ AND F_NATC IN ('US', 'TW')
+ ORDER BY UPPER(F_INPUT_NAME), F_SYMB, F_NATC
+ )
+ WHERE ROWNUM <= :row_limit
+ """;
+
private const string SearchSql = """
SELECT F_INPUT_NAME, F_SYMB, F_NATC
FROM (
@@ -86,31 +100,44 @@ public sealed class LegacyWorldStockSearchService : IWorldStockSearchService
cancellationToken.ThrowIfCancellationRequested();
- var rowLimit = checked(maximumResults + 1);
- var spec = CreateQuerySpec(
- EscapeLikePattern(normalizedQuery.ToUpperInvariant()),
- rowLimit);
+ // Keep blank legacy display rows from consuming the visible result budget while
+ // retaining a finite provider bound and one extra valid row for truncation proof.
+ var rowLimit = checked(maximumResults + MaximumIsolatedRows + 1);
+ var spec = CreateQuerySpec(normalizedQuery, rowLimit);
var table = await _executor
.ExecuteAsync(DataSourceKind.Oracle, QueryName, spec, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
- var matches = ValidateRows(table, rowLimit);
+ var validation = ValidateRows(table, rowLimit);
+ var matches = validation.Items;
return new WorldStockSearchResult(
normalizedQuery,
DateTimeOffset.Now,
matches.Take(maximumResults).ToArray(),
- matches.Count > maximumResults);
+ matches.Count > maximumResults ||
+ table.Rows.Count == rowLimit ||
+ validation.IsolatedInvalidRowCount > 0,
+ validation.IsolatedInvalidRowCount);
}
- private static DataQuerySpec CreateQuerySpec(string escapedQuery, int rowLimit)
+ private static DataQuerySpec CreateQuerySpec(string query, int rowLimit)
{
- var spec = new DataQuerySpec(
- SearchSql,
- [
- new DataQueryParameter("search_term", escapedQuery, DbType.String),
- new DataQueryParameter("row_limit", rowLimit, DbType.Int32)
- ]);
+ var parameters = new List
+ {
+ new("row_limit", rowLimit, DbType.Int32)
+ };
+ var sql = InitialListSql;
+ if (query.Length != 0)
+ {
+ sql = SearchSql;
+ parameters.Insert(0, new DataQueryParameter(
+ "search_term",
+ EscapeLikePattern(query.ToUpperInvariant()),
+ DbType.String));
+ }
+
+ var spec = new DataQuerySpec(sql, parameters);
spec.ValidateFor(DataSourceKind.Oracle);
return spec;
}
@@ -137,7 +164,7 @@ public sealed class LegacyWorldStockSearchService : IWorldStockSearchService
.Replace("%", "!%", StringComparison.Ordinal)
.Replace("_", "!_", StringComparison.Ordinal);
- private static IReadOnlyList ValidateRows(
+ private static RowValidationResult ValidateRows(
DataTable? table,
int rowLimit)
{
@@ -152,17 +179,27 @@ public sealed class LegacyWorldStockSearchService : IWorldStockSearchService
}
var identities = new HashSet();
- var inputNames = new HashSet(StringComparer.Ordinal);
var matches = new List(table.Rows.Count);
+ var isolatedInvalidRowCount = 0;
foreach (DataRow row in table.Rows)
{
- if (row[0] is not string inputName ||
- row[1] is not string symbol ||
- row[2] is not string nationCode)
+ if (row[0] is DBNull ||
+ row[0] is string blankName && blankName.Trim(' ').Length == 0)
+ {
+ isolatedInvalidRowCount++;
+ continue;
+ }
+
+ if (row[0] is not string rawInputName ||
+ row[1] is not string rawSymbol ||
+ row[2] is not string rawNationCode)
{
throw InvalidSchema();
}
+ var inputName = rawInputName.Trim(' ');
+ var symbol = rawSymbol.Trim(' ');
+ var nationCode = rawNationCode.Trim(' ');
if (!IsCanonicalDatabaseValue(inputName, MaximumInputNameLength) ||
!IsCanonicalDatabaseValue(symbol, MaximumSymbolLength) ||
nationCode is not ("US" or "TW"))
@@ -178,19 +215,10 @@ public sealed class LegacyWorldStockSearchService : IWorldStockSearchService
$"World-stock search data from {QueryName} contains a duplicate identity.");
}
- // The legacy comparison resolver selects the master row by
- // F_INPUT_NAME alone. Even distinct symbols or nations are unsafe to
- // expose when that lookup key would resolve ambiguously downstream.
- if (!inputNames.Add(inputName))
- {
- throw new WorldStockSearchDataException(
- $"World-stock search data from {QueryName} contains a duplicate F_INPUT_NAME lookup key.");
- }
-
matches.Add(item);
}
- return matches;
+ return new RowValidationResult(matches, isolatedInvalidRowCount);
}
private static bool HasExactStringColumn(DataColumn column, string name) =>
@@ -225,4 +253,8 @@ public sealed class LegacyWorldStockSearchService : IWorldStockSearchService
private static WorldStockSearchDataException InvalidSchema() =>
new($"World-stock search data from {QueryName} does not match the required schema.");
+
+ private sealed record RowValidationResult(
+ IReadOnlyList Items,
+ int IsolatedInvalidRowCount);
}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneBuilders5078To5084.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneBuilders5078To5084.cs
index 04085a1..71f0533 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneBuilders5078To5084.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ChartSceneBuilders5078To5084.cs
@@ -320,7 +320,8 @@ public sealed class S5080SceneMutationBuilder : ILegacySceneMutationBuilder())
{
@@ -295,14 +297,15 @@ public sealed class S5080SceneDataLoader
"INPUT_SELL",
spec,
cancellationToken).ConfigureAwait(false);
- var row = ChartSceneDataLoaderReader.SingleRow(table, Columns);
+ var row = LegacyManualFinancialStorageCompatibility.SingleEquivalentSceneRow(
+ table,
+ Columns);
var quarters = new QuarterlySalesData[6];
for (var index = 0; index < quarters.Length; index++)
{
- var raw = ChartSceneDataLoaderReader.Text(
+ var raw = LegacyManualFinancialStorageCompatibility.RequiredQuarterStorageString(
row,
- "GUSUNG_" + (index + 1).ToString(CultureInfo.InvariantCulture),
- allowEmpty: true);
+ "GUSUNG_" + (index + 1).ToString(CultureInfo.InvariantCulture));
quarters[index] = ParseQuarter(raw);
}
@@ -316,7 +319,7 @@ public sealed class S5080SceneDataLoader
private static QuarterlySalesData ParseQuarter(string value)
{
var tokens = value.Split('_', StringSplitOptions.None);
- if (tokens.Length != 2 || string.IsNullOrWhiteSpace(tokens[0]))
+ if (tokens.Length < 2)
{
throw ChartSceneDataLoaderReader.InvalidResult();
}
@@ -332,7 +335,9 @@ public sealed class S5080SceneDataLoader
throw ChartSceneDataLoaderReader.InvalidResult();
}
- return new QuarterlySalesData(tokens[0], number);
+ return new QuarterlySalesData(
+ LegacyManualFinancialStorageCompatibility.NormalizeQuarterLabel(tokens[0]),
+ number);
}
}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/FoundationalSceneBuilders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/FoundationalSceneBuilders.cs
index 9fb0a4b..5bbd74c 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/FoundationalSceneBuilders.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/FoundationalSceneBuilders.cs
@@ -22,7 +22,10 @@ public sealed class S5076SceneMutationBuilder : ILegacySceneMutationBuilder(34)
{
@@ -45,7 +48,10 @@ public sealed class S5076SceneMutationBuilder : ILegacySceneMutationBuilder item.Value).ToArray();
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyManualFinancialStorageCompatibility.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyManualFinancialStorageCompatibility.cs
new file mode 100644
index 0000000..7b94ee4
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyManualFinancialStorageCompatibility.cs
@@ -0,0 +1,92 @@
+#nullable enable
+
+using System.Data;
+
+namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+///
+/// Narrow compatibility rules shared only by the four GraphE INPUT_* scenes.
+/// Raw editor values remain unchanged; these rules apply to the typed playout
+/// projection after a fresh database read.
+///
+internal static class LegacyManualFinancialStorageCompatibility
+{
+ public static string NormalizeQuarterLabel(string value)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+
+ // The legacy scene passed quarter labels directly to K3D. A trailing
+ // horizontal tab was visually inert, but the current scene text guard
+ // correctly rejects control characters. Remove only that inert suffix;
+ // any remaining label controls are rejected by the scene text guard.
+ return value.TrimEnd('\t');
+ }
+
+ public static string RequiredQuarterStorageString(DataRow row, string columnName)
+ {
+ ArgumentNullException.ThrowIfNull(row);
+ if (!row.Table.Columns.Contains(columnName) ||
+ row[columnName] is not string value ||
+ value.Length > 4_096)
+ {
+ throw InvalidResult();
+ }
+
+ return value;
+ }
+
+ public static DataRow SingleEquivalentSceneRow(
+ DataTable? table,
+ params string[] sceneDrivingColumns)
+ {
+ ArgumentNullException.ThrowIfNull(sceneDrivingColumns);
+ if (table is null || table.Rows.Count == 0 ||
+ table.Columns.Count != sceneDrivingColumns.Length)
+ {
+ throw InvalidResult();
+ }
+
+ for (var index = 0; index < sceneDrivingColumns.Length; index++)
+ {
+ if (!string.Equals(
+ table.Columns[index].ColumnName,
+ sceneDrivingColumns[index],
+ StringComparison.OrdinalIgnoreCase))
+ {
+ throw InvalidResult();
+ }
+ }
+
+ var first = table.Rows[0];
+ for (var rowIndex = 1; rowIndex < table.Rows.Count; rowIndex++)
+ {
+ var candidate = table.Rows[rowIndex];
+ foreach (var column in sceneDrivingColumns)
+ {
+ if (!EquivalentStoredValue(first[column], candidate[column]))
+ {
+ throw InvalidResult();
+ }
+ }
+ }
+
+ return first;
+ }
+
+ private static bool EquivalentStoredValue(object? left, object? right)
+ {
+ var leftIsNull = left is null or DBNull;
+ var rightIsNull = right is null or DBNull;
+ if (leftIsNull || rightIsNull)
+ {
+ return leftIsNull && rightIsNull;
+ }
+
+ return left is string leftText &&
+ right is string rightText &&
+ string.Equals(leftText, rightText, StringComparison.Ordinal);
+ }
+
+ private static LegacySceneDataException InvalidResult() =>
+ new("The manual-financial scene query did not return one equivalent row set.");
+}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs
index 0c609aa..6e85ee0 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyParameterizedSceneRequestResolver.cs
@@ -1,6 +1,7 @@
#nullable enable
using System.Data;
+using System.Globalization;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
@@ -82,7 +83,14 @@ public sealed class LegacyParameterizedSceneRequestResolver
if (selection.GroupCode is "해외업종" or "FOREIGN_INDUSTRY")
{
- return new S5001ForeignIndustryLoadRequest(subject);
+ var identity = ParseForeignIndustryIdentity(selection.DataCode);
+ return identity is null
+ ? new S5001ForeignIndustryLoadRequest(subject)
+ : new S5001ExactForeignIndustryLoadRequest(
+ subject,
+ identity.Value.InputName,
+ identity.Value.Symbol,
+ identity.Value.NationCode);
}
if (selection.GroupCode is "해외종목" or "FOREIGN_STOCK")
@@ -465,6 +473,45 @@ public sealed class LegacyParameterizedSceneRequestResolver
return (parts[0], parts[1]);
}
+ ///
+ /// Current UC5 industry rows persist inputName|symbol|US|0. Historical
+ /// playlist rows have no data code and deliberately retain their fail-closed
+ /// name-only lookup.
+ ///
+ private static (string InputName, string Symbol, string NationCode)?
+ ParseForeignIndustryIdentity(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return null;
+ }
+
+ var parts = value.Split('|', StringSplitOptions.None);
+ if (parts.Length != 4 ||
+ !IsCanonicalForeignIndustryText(parts[0]) ||
+ !LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol(parts[1]) ||
+ !string.Equals(parts[2], "US", StringComparison.Ordinal) ||
+ !string.Equals(parts[3], "0", StringComparison.Ordinal))
+ {
+ throw new LegacySceneDataException("The foreign-industry identity is invalid.");
+ }
+
+ return (parts[0], parts[1], parts[2]);
+ }
+
+ private static bool IsCanonicalForeignIndustryText(string value) =>
+ value.Length is >= 1 and <= 200 &&
+ !string.IsNullOrWhiteSpace(value) &&
+ string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
+ !value.Any(character =>
+ char.IsControl(character) ||
+ char.IsSurrogate(character) ||
+ character == '\uFFFD' ||
+ CharUnicodeInfo.GetUnicodeCategory(character) is
+ UnicodeCategory.Format or
+ UnicodeCategory.LineSeparator or
+ UnicodeCategory.ParagraphSeparator);
+
private S8018SessionPhase CurrentSession()
{
var now = _timeProvider.GetLocalNow();
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs
index 6d5b9db..0c6d29e 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs
@@ -12,7 +12,8 @@ public sealed record LegacySceneSelection(
string Subject,
string GraphicType,
string Subtype,
- string DataCode);
+ string DataCode,
+ string? ExactSubject = null);
///
/// Current operator checkbox state used by s8010. The original scene constructor read
@@ -113,7 +114,16 @@ public sealed record LegacyPlaylistEntry(
int? FadeDuration = null,
LegacySceneSelection? Selection = null,
LegacyPlaylistPageNavigation? PageNavigation = null,
- ILegacySceneMovingAverageSelectionSource? MovingAverageSelectionSource = null);
+ ILegacySceneMovingAverageSelectionSource? MovingAverageSelectionSource = null)
+{
+ ///
+ /// Closed sentinel used for a losslessly loaded legacy PLAY_LIST row whose
+ /// original visible fields do not name any registered scene. It is a safe
+ /// token, not a scene or file name, and is rejected by the data router before
+ /// an engine command can be created.
+ ///
+ public const string UnresolvedCutCode = "UNRESOLVED_NAMED_PLAYLIST";
+}
public sealed record LegacySceneCuePage(
PlayoutCue Cue,
@@ -520,7 +530,21 @@ public sealed class LegacyPlayoutWorkflow
}
}
+ public Task NextAsync(
+ CancellationToken cancellationToken = default) =>
+ NextAsync(null, cancellationToken);
+
+ ///
+ /// Advances NEXT after adopting only a verified mutable tail from the live
+ /// operator playlist. The current/on-air prefix must remain strictly equivalent
+ /// except for : MainForm allowed its
+ /// PROGRAM Space/all gestures to change that visible flag even on current and
+ /// past rows, while NEXT consulted only later rows. Additions or deletions remain
+ /// confined after the current row so the scene already owned by the engine cannot
+ /// change.
+ ///
public async Task NextAsync(
+ IReadOnlyList? operatorPlaylist,
CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
@@ -531,6 +555,13 @@ public sealed class LegacyPlayoutWorkflow
return Rejected(PlayoutOperation.Next, "TAKE IN 이후에만 NEXT를 실행할 수 있습니다.");
}
+ if (operatorPlaylist is not null && !TryAdoptOperatorPlaylistTail(operatorPlaylist))
+ {
+ return Rejected(
+ PlayoutOperation.Next,
+ "송출 중 변경된 플레이리스트의 현재 행 이전 구간을 안전하게 확인할 수 없습니다.");
+ }
+
if (_onAir.Plan is { } currentPlan &&
_onAir.PageIndexZeroBased + 1 < currentPlan.TotalPages)
{
@@ -592,6 +623,54 @@ public sealed class LegacyPlayoutWorkflow
}
}
+ private bool TryAdoptOperatorPlaylistTail(
+ IReadOnlyList operatorPlaylist)
+ {
+ if (_onAir is null || _onAir.CueIndexZeroBased < 0)
+ {
+ return false;
+ }
+
+ var currentIndex = _onAir.CueIndexZeroBased;
+ IReadOnlyList candidate;
+ try
+ {
+ candidate = ValidatePlaylist(operatorPlaylist, currentIndex);
+ }
+ catch (Exception exception) when (
+ exception is ArgumentException or InvalidOperationException)
+ {
+ return false;
+ }
+
+ if (candidate.Count <= currentIndex || _playlist.Count <= currentIndex)
+ {
+ return false;
+ }
+
+ for (var index = 0; index <= currentIndex; index++)
+ {
+ // MainForm allowed the PROGRAM operator to change the active-row
+ // checkbox with Space and every checkbox through its "all" control.
+ // Those changes included the on-air/past prefix even though NEXT only
+ // consulted rows after m_crow. Accept that one harmless visible field,
+ // while keeping every identity, scene, selection, page and live-source
+ // field in the retained prefix strictly equal.
+ var candidateWithRetainedEnabledState = candidate[index] with
+ {
+ IsEnabled = _playlist[index].IsEnabled
+ };
+ if (!Equals(candidateWithRetainedEnabledState, _playlist[index]))
+ {
+ return false;
+ }
+ }
+
+ _playlist = candidate;
+ PublishState();
+ return true;
+ }
+
///
/// Reproduces MainForm's timer-driven same-scene refresh. The active non-paged
/// entry and current page are queried again and updated through
@@ -873,7 +952,8 @@ public sealed class LegacyPlayoutWorkflow
IsSafeLookupText(selection.Subject) &&
IsSafeLookupText(selection.GraphicType) &&
IsSafeLookupText(selection.Subtype) &&
- IsSafeLookupText(selection.DataCode);
+ IsSafeLookupText(selection.DataCode) &&
+ (selection.ExactSubject is null || IsSafeLookupText(selection.ExactSubject));
}
private static bool IsSafePageNavigation(LegacyPlaylistPageNavigation? navigation)
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDataSourceRouter.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDataSourceRouter.cs
index c066685..92ae31b 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDataSourceRouter.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacySceneDataSourceRouter.cs
@@ -119,8 +119,19 @@ public sealed class LegacySceneDataSourceRouter :
throw new ArgumentOutOfRangeException(nameof(pageIndexZeroBased));
}
+ RejectUnresolvedNamedPlaylistEntry(entry);
+
if (!_loaders.TryGetValue(entry.CutCode, out var loader))
{
+ if (string.Equals(
+ entry.CutCode,
+ LegacyPlaylistEntry.UnresolvedCutCode,
+ StringComparison.Ordinal))
+ {
+ throw new LegacySceneDataException(
+ "이 DB 재생목록 행은 기존 컷 이름으로 장면을 찾을 수 없습니다. 다른 행은 계속 사용할 수 있습니다.");
+ }
+
throw new LegacySceneDataException(
"The selected cut does not have a registered scene-data loader.");
}
@@ -133,12 +144,34 @@ public sealed class LegacySceneDataSourceRouter :
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
+ RejectUnresolvedNamedPlaylistEntry(entry);
if (!_pagePlanLoaders.TryGetValue(entry.CutCode, out var loader))
{
+ if (string.Equals(
+ entry.CutCode,
+ LegacyPlaylistEntry.UnresolvedCutCode,
+ StringComparison.Ordinal))
+ {
+ throw new LegacySceneDataException(
+ "이 DB 재생목록 행은 기존 컷 이름으로 장면을 찾을 수 없습니다. 다른 행은 계속 사용할 수 있습니다.");
+ }
+
throw new LegacySceneDataException(
"The selected cut does not have a registered page preflight route.");
}
return loader(entry, cancellationToken);
}
+
+ private static void RejectUnresolvedNamedPlaylistEntry(LegacyPlaylistEntry entry)
+ {
+ if (string.Equals(
+ entry.CutCode,
+ LegacyPlaylistEntry.UnresolvedCutCode,
+ StringComparison.Ordinal))
+ {
+ throw new LegacySceneDataException(
+ "기존 DB 재생목록 행의 장면 이름을 확인할 수 없습니다. 다른 행은 계속 사용할 수 있습니다.");
+ }
+ }
}
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ManualSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ManualSceneDataLoaders.cs
index d90ec13..28cf821 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ManualSceneDataLoaders.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ManualSceneDataLoaders.cs
@@ -34,7 +34,7 @@ public sealed class S5076SceneDataLoader
"INPUT_PIE",
spec,
cancellationToken).ConfigureAwait(false);
- var row = SceneDataTableReader.SingleRow(
+ var row = LegacyManualFinancialStorageCompatibility.SingleEquivalentSceneRow(
table,
"STOCK_NAME", "BASE_DATE",
"GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5");
@@ -47,7 +47,7 @@ public sealed class S5076SceneDataLoader
return new S5076SceneData(
SceneDataTableReader.RequiredString(row, "STOCK_NAME"),
- SceneDataTableReader.RequiredString(row, "BASE_DATE"),
+ SceneDataTableReader.RequiredString(row, "BASE_DATE", allowEmpty: true),
slices);
}
@@ -59,11 +59,19 @@ public sealed class S5076SceneDataLoader
}
var parts = value.Split('_');
- if (parts.Length != 2 || parts[0].Length == 0)
+ if (parts.Length < 2)
{
throw new LegacySceneDataException("INPUT_PIE contains an invalid revenue slice.");
}
+ // Form/GraphE.cs persisted an unused slice as "_" and s5076.cs skipped
+ // every slice whose first token was empty. Keep that exact compatibility
+ // behavior at the fresh PREPARE read boundary.
+ if (parts[0].Length == 0)
+ {
+ return null;
+ }
+
var label = parts[0];
var percentageText = parts[1];
if (percentageText is "" or "-")
@@ -73,7 +81,7 @@ public sealed class S5076SceneDataLoader
if (!double.TryParse(
percentageText,
- NumberStyles.Float,
+ NumberStyles.Float | NumberStyles.AllowThousands,
CultureInfo.InvariantCulture,
out var percentage) ||
!double.IsFinite(percentage))
@@ -113,14 +121,16 @@ public sealed class S5081SceneDataLoader
"INPUT_PROFIT",
spec,
cancellationToken).ConfigureAwait(false);
- var row = SceneDataTableReader.SingleRow(
+ var row = LegacyManualFinancialStorageCompatibility.SingleEquivalentSceneRow(
table,
"STOCK_NAME",
"GUSUNG_1", "GUSUNG_2", "GUSUNG_3", "GUSUNG_4", "GUSUNG_5", "GUSUNG_6");
var quarters = new QuarterlyProfitData[6];
for (var index = 0; index < quarters.Length; index++)
{
- var value = SceneDataTableReader.RequiredString(row, $"GUSUNG_{index + 1}");
+ var value = LegacyManualFinancialStorageCompatibility.RequiredQuarterStorageString(
+ row,
+ $"GUSUNG_{index + 1}");
quarters[index] = ParseQuarter(value);
}
@@ -132,12 +142,17 @@ public sealed class S5081SceneDataLoader
private static QuarterlyProfitData ParseQuarter(string value)
{
var parts = value.Split('_');
- if (parts.Length != 2 || parts[0].Length == 0)
+ if (parts.Length < 2)
+ {
+ throw new LegacySceneDataException("INPUT_PROFIT contains an invalid quarter value.");
+ }
+
+ var label = LegacyManualFinancialStorageCompatibility.NormalizeQuarterLabel(parts[0]);
+ if (label.Any(char.IsControl))
{
throw new LegacySceneDataException("INPUT_PROFIT contains an invalid quarter value.");
}
- var label = parts[0];
var numberText = parts[1];
if (numberText.Length == 0)
{
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneDataLoaders.cs
index 48a7b44..9f99566 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneDataLoaders.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/PagedQuoteSceneDataLoaders.cs
@@ -88,7 +88,8 @@ public sealed record NxtThemePagedQuoteLoadRequest(
string ThemeName,
PagedQuoteThemeSort Sort,
NxtMarketSession Session,
- int PageNumberOneBased = 1) : PagedQuoteLoadRequest(PageNumberOneBased);
+ int PageNumberOneBased = 1,
+ string? ExactThemeName = null) : PagedQuoteLoadRequest(PageNumberOneBased);
public sealed record ExpertPagedQuoteLoadRequest(
string ExpertCode,
@@ -172,6 +173,18 @@ internal sealed class PagedQuoteSceneDataLoaderCore
{
private static readonly string[] ResultColumns =
["NAME", "PRIMARY_VALUE", "SECONDARY_VALUE", "RATE", "DIRECTION"];
+ private static readonly string[] ExpertResultColumns =
+ [
+ "EXPERT_CODE",
+ "EXPERT_NAME",
+ "PLAY_INDEX",
+ "STOCK_CODE",
+ "NAME",
+ "PRIMARY_VALUE",
+ "SECONDARY_VALUE",
+ "RATE",
+ "DIRECTION"
+ ];
private readonly IDataQueryExecutor _executor;
private readonly ScenePageSize _pageSize;
@@ -209,11 +222,17 @@ internal sealed class PagedQuoteSceneDataLoaderCore
_tableName,
plan.Query,
cancellationToken).ConfigureAwait(false);
- var rows = MapRows(
- table,
- queryRows,
- plan.HeaderStyle,
- allowEmpty: includeTruncationProbe);
+ var rows = request is ExpertPagedQuoteLoadRequest expertRequest
+ ? MapExpertRows(
+ table,
+ queryRows,
+ expertRequest,
+ allowEmpty: includeTruncationProbe)
+ : MapRows(
+ table,
+ queryRows,
+ plan.HeaderStyle,
+ allowEmpty: includeTruncationProbe);
if (includeTruncationProbe && rows.Count == 0)
{
return new PagedQuoteSceneData(
@@ -292,6 +311,106 @@ internal sealed class PagedQuoteSceneDataLoaderCore
return result;
}
+ private static IReadOnlyList MapExpertRows(
+ DataTable? table,
+ int maximumRows,
+ ExpertPagedQuoteLoadRequest request,
+ bool allowEmpty)
+ {
+ if (table is null || (!allowEmpty && table.Rows.Count < 1) ||
+ table.Rows.Count > maximumRows)
+ {
+ throw new LegacySceneDataException(
+ "The expert paged quote query returned an invalid row count.");
+ }
+
+ var actualColumns = table.Columns
+ .Cast()
+ .Select(column => column.ColumnName)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ if (table.Columns.Count != ExpertResultColumns.Length ||
+ ExpertResultColumns.Any(column => !actualColumns.Contains(column)))
+ {
+ throw new LegacySceneDataException(
+ "The expert paged quote query returned an unexpected schema.");
+ }
+
+ var result = new PagedQuoteRowData[table.Rows.Count];
+ for (var index = 0; index < table.Rows.Count; index++)
+ {
+ var row = table.Rows[index];
+ var expertCode = RequiredText(row, "EXPERT_CODE");
+ var expertName = RequiredText(row, "EXPERT_NAME");
+ var playIndex = RequiredNonNegativeInteger(row, "PLAY_INDEX");
+ var stockCode = RequiredText(row, "STOCK_CODE");
+ var stockName = RequiredText(row, "NAME");
+ if (!string.Equals(expertCode, request.ExpertCode, StringComparison.Ordinal) ||
+ !string.Equals(expertName, request.ExpertName, StringComparison.Ordinal))
+ {
+ throw new LegacySceneDataException(
+ "The expert paged quote query returned a mismatched expert identity.");
+ }
+
+ if (row["PRIMARY_VALUE"] is DBNull)
+ {
+ throw MissingExpertBuyAmount(
+ expertCode,
+ expertName,
+ playIndex,
+ stockCode,
+ stockName);
+ }
+
+ var mapped = new PagedQuoteRowData(
+ stockName,
+ RequiredDecimal(row, "PRIMARY_VALUE"),
+ RequiredDecimal(row, "SECONDARY_VALUE"),
+ RequiredRate(row, "RATE"),
+ RequiredDirection(row, "DIRECTION"));
+ if (decimal.Truncate(mapped.PrimaryValue) != mapped.PrimaryValue ||
+ decimal.Truncate(mapped.SecondaryValue) != mapped.SecondaryValue)
+ {
+ throw InvalidValue();
+ }
+
+ result[index] = mapped;
+ }
+
+ return result;
+ }
+
+ private static int RequiredNonNegativeInteger(DataRow row, string column)
+ {
+ try
+ {
+ var value = Convert.ToDecimal(
+ RequiredValue(row, column),
+ CultureInfo.InvariantCulture);
+ return value is >= 0 and <= 9_999 && decimal.Truncate(value) == value
+ ? decimal.ToInt32(value)
+ : throw InvalidValue();
+ }
+ catch (Exception exception) when (
+ exception is FormatException or InvalidCastException or OverflowException)
+ {
+ throw InvalidValue();
+ }
+ }
+
+ private static LegacySceneDataException MissingExpertBuyAmount(
+ string expertCode,
+ string expertName,
+ int playIndex,
+ string stockCode,
+ string stockName) =>
+ new(
+ $"전문가 추천 송출을 준비할 수 없습니다. " +
+ $"전문가: {expertName} ({expertCode}), " +
+ $"PLAYINDEX: {playIndex}, " +
+ $"종목: {stockName} ({stockCode}), " +
+ "누락 필드: RC_BUY_AMOUNT(매수가). " +
+ "전문가 추천 데이터를 수정한 뒤 다시 PREPARE 하세요.");
+
private static string RequiredText(DataRow row, string column)
{
var value = RequiredValue(row, column);
@@ -489,17 +608,30 @@ internal static class PagedQuoteQueryFactory
{
var code = RequiredThemeCode(request.ThemeCode);
var displayTitle = RequiredSelection(request.ThemeName);
- const string displaySuffix = "(NXT)";
- if (!displayTitle.EndsWith(displaySuffix, StringComparison.Ordinal) ||
- displayTitle.Length == displaySuffix.Length)
+ string title;
+ if (request.ExactThemeName is not null)
{
- throw InvalidRequest();
+ // Native selector rows carry the exact SB_LIST key separately. It is
+ // intentionally not trimmed or suffix-normalized: stored titles may
+ // themselves end in one or more literal "(NXT)" tokens or spaces.
+ title = RequiredExactThemeTitle(request.ExactThemeName);
}
-
- var title = RequiredSelection(displayTitle[..^displaySuffix.Length]);
- if (title.Contains(displaySuffix, StringComparison.Ordinal))
+ else
{
- throw InvalidRequest();
+ // Historical seven-field playlists carry only the visible terminal
+ // transport suffix. Keep that legacy parsing contract unchanged.
+ const string displaySuffix = "(NXT)";
+ if (!displayTitle.EndsWith(displaySuffix, StringComparison.Ordinal) ||
+ displayTitle.Length == displaySuffix.Length)
+ {
+ throw InvalidRequest();
+ }
+
+ title = RequiredSelection(displayTitle[..^displaySuffix.Length]);
+ if (title.Contains(displaySuffix, StringComparison.Ordinal))
+ {
+ throw InvalidRequest();
+ }
}
ValidateThemeSort(request.Sort);
ValidateNxtSession(request.Session);
@@ -579,6 +711,23 @@ internal static class PagedQuoteQueryFactory
private static string RequiredSelection(string? value) =>
LegacySceneBuilderGuard.Text(value, "Selection");
+ private static string RequiredExactThemeTitle(string? value)
+ {
+ var title = RequiredSelection(value);
+ if (title.Length > 128 || title.Any(character =>
+ char.IsSurrogate(character) ||
+ character == '\uFFFD' ||
+ CharUnicodeInfo.GetUnicodeCategory(character) is
+ UnicodeCategory.Format or
+ UnicodeCategory.LineSeparator or
+ UnicodeCategory.ParagraphSeparator))
+ {
+ throw InvalidRequest();
+ }
+
+ return title;
+ }
+
private static void ValidateMarket(PagedQuoteMarket market)
{
if (!Enum.IsDefined(market))
@@ -1089,22 +1238,33 @@ internal static class PagedQuoteQueryFactory
JOIN t_kosdaq_stock b ON a.f_stock_code = b.f_stock_code
WHERE b.f_mkt_halt = 'N' AND a.f_curr_price <> 0
)
- SELECT NAME, PRIMARY_VALUE, SECONDARY_VALUE, RATE, DIRECTION
+ SELECT EXPERT_CODE, EXPERT_NAME, PLAY_INDEX, STOCK_CODE,
+ NAME, PRIMARY_VALUE, SECONDARY_VALUE, RATE, DIRECTION
FROM (
- SELECT r.RC_STOCK_NAME NAME,
+ SELECT e.EP_CODE EXPERT_CODE,
+ e.EP_NAME EXPERT_NAME,
+ r.PLAYINDEX PLAY_INDEX,
+ r.RC_STOCK_CODE STOCK_CODE,
+ r.RC_STOCK_NAME NAME,
r.RC_BUY_AMOUNT PRIMARY_VALUE,
q.CURRENT_PRICE SECONDARY_VALUE,
- ROUND(((q.CURRENT_PRICE - r.RC_BUY_AMOUNT) /
- r.RC_BUY_AMOUNT) * 100, 2) RATE,
- CASE WHEN q.CURRENT_PRICE - r.RC_BUY_AMOUNT > 0 THEN '2'
+ CASE
+ WHEN r.RC_BUY_AMOUNT IS NULL OR r.RC_BUY_AMOUNT = 0 OR
+ q.CURRENT_PRICE IS NULL THEN NULL
+ ELSE ROUND(((q.CURRENT_PRICE - r.RC_BUY_AMOUNT) /
+ r.RC_BUY_AMOUNT) * 100, 2)
+ END RATE,
+ CASE WHEN r.RC_BUY_AMOUNT IS NULL OR q.CURRENT_PRICE IS NULL THEN NULL
+ WHEN q.CURRENT_PRICE - r.RC_BUY_AMOUNT > 0 THEN '2'
WHEN q.CURRENT_PRICE - r.RC_BUY_AMOUNT < 0 THEN '5'
ELSE '3' END DIRECTION
FROM EXPERT_LIST e
JOIN RECOMMEND_LIST r ON e.EP_CODE = r.RC_CODE
- JOIN QUOTES q ON r.RC_STOCK_CODE = q.STOCK_CODE
+ LEFT JOIN QUOTES q ON r.RC_STOCK_CODE = q.STOCK_CODE
WHERE e.EP_CODE = :expertCode
AND e.EP_NAME = :expertName
- AND r.RC_BUY_AMOUNT <> 0
+ AND (r.RC_BUY_AMOUNT IS NULL OR
+ (r.RC_BUY_AMOUNT <> 0 AND q.STOCK_CODE IS NOT NULL))
ORDER BY r.PLAYINDEX
)
WHERE ROWNUM <= :maxRows
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneDataLoaders.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneDataLoaders.cs
index 062cd84..a16ac31 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneDataLoaders.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneDataLoaders.cs
@@ -60,6 +60,19 @@ public sealed record S5001ForeignIndustryLoadRequest(string IndustryName) : S500
public override S5001Branch Branch => S5001Branch.ForeignIndustry;
}
+///
+/// Internal-only exact UC5 industry identity. Native selector rows carry the
+/// master input name and symbol in addition to the visible Korean name.
+///
+internal sealed record S5001ExactForeignIndustryLoadRequest(
+ string IndustryName,
+ string InputName,
+ string ForeignSymbol,
+ string ForeignNationCode) : S5001SceneLoadRequest
+{
+ public override S5001Branch Branch => S5001Branch.ForeignIndustry;
+}
+
public sealed record S5001ForeignStockLoadRequest(string StockName) : S5001SceneLoadRequest
{
public override S5001Branch Branch => S5001Branch.ForeignStock;
@@ -110,6 +123,7 @@ public sealed class S5001SceneDataLoader
S5001ExchangeRateLoadRequest exchange => await LoadExchangeAsync(exchange, cancellationToken).ConfigureAwait(false),
S5001DomesticIndustryLoadRequest industry => await LoadDomesticIndustryAsync(industry, cancellationToken).ConfigureAwait(false),
S5001ForeignIndustryLoadRequest industry => await LoadForeignIndustryAsync(industry, cancellationToken).ConfigureAwait(false),
+ S5001ExactForeignIndustryLoadRequest industry => await LoadExactForeignIndustryAsync(industry, cancellationToken).ConfigureAwait(false),
S5001ForeignStockLoadRequest stock => await LoadForeignStockAsync(stock, cancellationToken).ConfigureAwait(false),
S5001ExactForeignStockLoadRequest stock => await LoadExactForeignStockAsync(stock, cancellationToken).ConfigureAwait(false),
S5001DomesticStockLoadRequest stock => await LoadDomesticStockAsync(stock, cancellationToken).ConfigureAwait(false),
@@ -231,6 +245,58 @@ public sealed class S5001SceneDataLoader
new S5001ForeignIndustrySceneData(title, direction, price, change, rate));
}
+ private async Task LoadExactForeignIndustryAsync(
+ S5001ExactForeignIndustryLoadRequest request,
+ CancellationToken cancellationToken)
+ {
+ var name = ExactForeignIndustryText(
+ request.IndustryName,
+ nameof(request.IndustryName));
+ var inputName = ExactForeignIndustryText(
+ request.InputName,
+ nameof(request.InputName));
+ var symbol = ParameterizedSceneRowReader.Selector(
+ request.ForeignSymbol,
+ nameof(request.ForeignSymbol));
+ var nationCode = ParameterizedSceneRowReader.Selector(
+ request.ForeignNationCode,
+ nameof(request.ForeignNationCode));
+ if (!LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol(symbol) ||
+ !string.Equals(nationCode, "US", StringComparison.Ordinal))
+ {
+ throw new LegacySceneDataException("The foreign-industry identity is invalid.");
+ }
+
+ var row = await ExecuteSingleAsync(
+ ParameterizedMarketSceneQueries.ForeignIndustry(
+ name,
+ inputName,
+ symbol,
+ nationCode),
+ cancellationToken).ConfigureAwait(false);
+ return DecimalScene(row, static (title, direction, price, change, rate) =>
+ new S5001ForeignIndustrySceneData(title, direction, price, change, rate));
+ }
+
+ private static string ExactForeignIndustryText(string? value, string name)
+ {
+ var text = LegacySceneBuilderGuard.Text(value, name);
+ if (text.Length > 200 ||
+ !string.Equals(text, text.Trim(), StringComparison.Ordinal) ||
+ text.Any(character =>
+ char.IsSurrogate(character) ||
+ character == '\uFFFD' ||
+ CharUnicodeInfo.GetUnicodeCategory(character) is
+ UnicodeCategory.Format or
+ UnicodeCategory.LineSeparator or
+ UnicodeCategory.ParagraphSeparator))
+ {
+ throw new LegacySceneDataException("The foreign-industry identity is invalid.");
+ }
+
+ return text;
+ }
+
private async Task LoadForeignStockAsync(
S5001ForeignStockLoadRequest request,
CancellationToken cancellationToken)
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneQueries.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneQueries.cs
index b558fa4..eee2bf0 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneQueries.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ParameterizedMarketSceneQueries.cs
@@ -57,6 +57,23 @@ internal static class ParameterizedMarketSceneQueries
ForeignIndustryQuery,
new DataQueryParameter("industryName", industryName, DbType.String));
+ ///
+ /// Exact UC5 industry route. All master identity fields selected by the
+ /// operator are bound so duplicate Korean display names remain distinct.
+ ///
+ public static ParameterizedSceneQuery ForeignIndustry(
+ string industryName,
+ string inputName,
+ string symbol,
+ string nationCode) =>
+ Oracle(
+ "SCENE_FOREIGN_INDUSTRY",
+ ForeignIndustryExactQuery,
+ new DataQueryParameter("industryName", industryName, DbType.String),
+ new DataQueryParameter("inputName", inputName, DbType.String),
+ new DataQueryParameter("symbol", symbol, DbType.String),
+ new DataQueryParameter("nationCode", nationCode, DbType.String));
+
public static ParameterizedSceneQuery ForeignStock(string stockName) =>
Oracle(
"SCENE_FOREIGN_STOCK",
@@ -509,6 +526,23 @@ internal static class ParameterizedMarketSceneQueries
FETCH FIRST 2 ROWS ONLY
""";
+ private const string ForeignIndustryExactQuery = """
+ SELECT b.f_knam TITLE,
+ a.f_last PRICE,
+ a.f_sign DIRECTION,
+ ABS(ROUND(a.f_diff, 2)) NET_CHANGE,
+ ROUND(a.f_rate, 2) RATE
+ FROM t_world_ix_eq_sise a
+ JOIN t_world_ix_eq_master b ON a.f_symb = b.f_symb
+ WHERE b.f_knam = :industryName
+ AND b.f_input_name = :inputName
+ AND b.f_symb = :symbol
+ AND b.f_natc = :nationCode
+ AND b.f_fdtc = '0'
+ AND a.f_fdtc = '0'
+ FETCH FIRST 2 ROWS ONLY
+ """;
+
private const string ForeignStockQuery = """
SELECT b.f_input_name TITLE,
a.f_last PRICE,
diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/RegisteredLegacySceneCueProvider.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/RegisteredLegacySceneCueProvider.cs
index 5ef9795..5c56e7e 100644
--- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/RegisteredLegacySceneCueProvider.cs
+++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/RegisteredLegacySceneCueProvider.cs
@@ -29,9 +29,9 @@ public sealed record LegacySceneCueCompositionOptions(
}
///
-/// Supplies an immutable composition snapshot for one cue build. Native operator controls
-/// may replace the snapshot only while the playout workflow is idle; Web content never
-/// supplies an asset path.
+/// Supplies an immutable composition snapshot for one cue build. The native app may
+/// atomically promote a separately validated next-cue draft between builds; Web content
+/// never supplies an asset path.
///
public interface ILegacySceneCueCompositionOptionsSource
{
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonProductionAdapters.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonProductionAdapters.cs
index ce00c50..fefddf4 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonProductionAdapters.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonProductionAdapters.cs
@@ -8,7 +8,9 @@ namespace MBN_STOCK_WEBVIEW.LegacyApplication;
/// Adapts the already parameterized domestic/world lookup services to the closed UC3
/// comparison identity model. Database identities never cross the WebView boundary.
///
-public sealed class LegacyComparisonDataService : ILegacyComparisonDataService
+public sealed class LegacyComparisonDataService :
+ ILegacyComparisonDataService,
+ ILegacyComparisonDataDiagnosticsService
{
private readonly ILegacyStockLookup _domesticLookup;
private readonly IWorldStockSearchService _worldLookup;
@@ -22,6 +24,15 @@ public sealed class LegacyComparisonDataService : ILegacyComparisonDataService
}
public async Task> SearchDomesticAsync(
+ string query,
+ int maximumResults,
+ CancellationToken cancellationToken = default) =>
+ (await SearchDomesticWithDiagnosticsAsync(
+ query,
+ maximumResults,
+ cancellationToken).ConfigureAwait(false)).Items;
+
+ public async Task SearchDomesticWithDiagnosticsAsync(
string query,
int maximumResults,
CancellationToken cancellationToken = default)
@@ -32,30 +43,60 @@ public sealed class LegacyComparisonDataService : ILegacyComparisonDataService
var targets = new List(
Math.Min(result.DisplayNames.Count, maximumResults));
var identities = new HashSet(StringComparer.Ordinal);
+ var isolatedInvalidRowCount = 0;
+ var isLimitTruncated = false;
foreach (var displayName in result.DisplayNames)
{
cancellationToken.ThrowIfCancellationRequested();
var identity = result.ResolveLastByDisplayName(displayName);
if (identity is null)
{
+ isolatedInvalidRowCount++;
continue;
}
- var target = ToComparisonTarget(identity);
+ LegacyComparisonTarget target;
+ try
+ {
+ target = ToComparisonTarget(identity);
+ }
+ catch (ArgumentException)
+ {
+ isolatedInvalidRowCount++;
+ continue;
+ }
if (identities.Add(target.TargetId))
{
- targets.Add(target);
- if (targets.Count == maximumResults)
+ if (targets.Count < maximumResults)
{
- break;
+ targets.Add(target);
+ }
+ else
+ {
+ isLimitTruncated = true;
}
}
}
- return targets.AsReadOnly();
+ return new LegacyComparisonSearchData(
+ targets.AsReadOnly(),
+ isLimitTruncated || isolatedInvalidRowCount > 0,
+ isolatedInvalidRowCount)
+ {
+ DeferredUnsafeRowCount = targets.Count(target => !target.CanSelect)
+ };
}
public async Task> SearchWorldAsync(
+ string query,
+ int maximumResults,
+ CancellationToken cancellationToken = default) =>
+ (await SearchWorldWithDiagnosticsAsync(
+ query,
+ maximumResults,
+ cancellationToken).ConfigureAwait(false)).Items;
+
+ public async Task SearchWorldWithDiagnosticsAsync(
string query,
int maximumResults,
CancellationToken cancellationToken = default)
@@ -63,12 +104,31 @@ public sealed class LegacyComparisonDataService : ILegacyComparisonDataService
ValidateLimit(maximumResults, LegacyComparisonWorkflowCatalog.WorldMaximumResults);
var result = await _worldLookup.SearchAsync(query, maximumResults, cancellationToken)
.ConfigureAwait(false);
- return Array.AsReadOnly(result.Items
- .Select(item => LegacyComparisonTarget.CreateWorldStock(
- item.InputName,
- item.Symbol,
- item.NationCode))
- .ToArray());
+ var targets = new List(result.Items.Count);
+ var isolatedInvalidRowCount = result.IsolatedInvalidRowCount;
+ foreach (var item in result.Items)
+ {
+ try
+ {
+ var target = LegacyComparisonTarget.CreateWorldStock(
+ item.InputName,
+ item.Symbol,
+ item.NationCode);
+ targets.Add(target);
+ }
+ catch (ArgumentException)
+ {
+ isolatedInvalidRowCount++;
+ }
+ }
+
+ return new LegacyComparisonSearchData(
+ targets.AsReadOnly(),
+ result.IsTruncated || isolatedInvalidRowCount > 0,
+ isolatedInvalidRowCount)
+ {
+ DeferredUnsafeRowCount = targets.Count(target => !target.CanSelect)
+ };
}
public async Task ResolveStoredTargetAsync(
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowCatalog.cs
index ac97699..50bfd6d 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowCatalog.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowCatalog.cs
@@ -1,5 +1,6 @@
using System.Collections.ObjectModel;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
@@ -30,8 +31,8 @@ internal sealed record LegacyComparisonCompatibility(
internal static class LegacyComparisonWorkflowCatalog
{
public const int DefaultFadeDuration = 6;
- public const int DomesticMaximumResults = 200;
- public const int WorldMaximumResults = 100;
+ public const int DomesticMaximumResults = LegacyStockSearchService.MaximumResults;
+ public const int WorldMaximumResults = LegacyWorldStockSearchService.MaximumResults;
private static readonly ReadOnlyCollection FixedTargetList =
Array.AsReadOnly(new[]
@@ -152,13 +153,23 @@ internal static class LegacyComparisonWorkflowCatalog
selectionId,
target.DisplayName,
metadata,
- target.Kind);
+ target.Kind)
+ {
+ CanSelect = target.CanSelect,
+ UnavailableReason = target.UnavailableReason
+ };
}
public static LegacyComparisonStoredTargetReference ToStoredReference(
LegacyComparisonTarget target)
{
ArgumentNullException.ThrowIfNull(target);
+ if (!target.CanSelect)
+ {
+ throw new InvalidOperationException(
+ "A deferred comparison row cannot be serialized.");
+ }
+
return target.Kind switch
{
LegacyComparisonTargetKind.MarketTarget =>
@@ -184,7 +195,7 @@ internal static class LegacyComparisonWorkflowCatalog
public static bool MatchesStoredReference(
LegacyComparisonTarget target,
LegacyComparisonStoredTargetReference stored) =>
- ToStoredReference(target) == stored;
+ target.CanSelect && ToStoredReference(target) == stored;
public static LegacyComparisonTarget? TryResolveFixedStoredTarget(
LegacyComparisonStoredTargetReference stored) =>
@@ -332,6 +343,11 @@ internal static class LegacyComparisonWorkflowCatalog
return Unavailable("pair-incomplete-or-invalid");
}
+ if (!first.CanSelect || !second.CanSelect)
+ {
+ return Unavailable("deferred-unsafe-comparison-row");
+ }
+
if (action.Kind is LegacyComparisonActionKind.Comparison or LegacyComparisonActionKind.Return)
{
return first.Kind == LegacyComparisonTargetKind.KrxStock &&
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs
index f16b487..f9d220d 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs
@@ -12,6 +12,7 @@ public sealed class LegacyComparisonWorkflowController
private readonly ILegacyComparisonDataService _dataService;
private readonly ILegacyComparisonPairStore _pairStore;
private readonly LegacyComparisonUiLayout _uiLayout;
+ private readonly ILegacyComparisonPairImportService? _pairImporter;
private List _domesticResults = [];
private List _worldResults = [];
private List _savedPairs = [];
@@ -28,7 +29,14 @@ public sealed class LegacyComparisonWorkflowController
private string _statusMessage = string.Empty;
private LegacyComparisonSearchStatus _domesticStatus = LegacyComparisonSearchStatus.Idle;
private LegacyComparisonSearchStatus _worldStatus = LegacyComparisonSearchStatus.Idle;
+ private bool _domesticIsTruncated;
+ private bool _worldIsTruncated;
+ private int _domesticIsolatedInvalidRowCount;
+ private int _worldIsolatedInvalidRowCount;
+ private int _domesticDeferredUnsafeRowCount;
+ private int _worldDeferredUnsafeRowCount;
private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral;
+ private LegacyComparisonImportReceipt? _lastImport;
private long _revision;
private long _pairSequence;
private long _draftSequence;
@@ -40,15 +48,19 @@ public sealed class LegacyComparisonWorkflowController
public LegacyComparisonWorkflowController(
ILegacyComparisonDataService dataService,
ILegacyComparisonPairStore pairStore,
- LegacyComparisonUiLayout? uiLayout = null)
+ LegacyComparisonUiLayout? uiLayout = null,
+ ILegacyComparisonPairImportService? pairImporter = null)
{
_dataService = dataService ?? throw new ArgumentNullException(nameof(dataService));
_pairStore = pairStore ?? throw new ArgumentNullException(nameof(pairStore));
_uiLayout = uiLayout ?? LegacyComparisonUiLayout.Default;
+ _pairImporter = pairImporter;
}
public LegacyComparisonWorkflowSnapshot Current => CreateSnapshot();
+ public bool CanImportLegacyPairs => _pairImporter is not null;
+
///
/// Recreates UC3's per-control state before its saved CP949 pairs are loaded for
/// a new tab activation. Search and pair-load generations invalidate completions
@@ -75,7 +87,14 @@ public sealed class LegacyComparisonWorkflowController
_statusMessage = string.Empty;
_domesticStatus = LegacyComparisonSearchStatus.Idle;
_worldStatus = LegacyComparisonSearchStatus.Idle;
+ _domesticIsTruncated = false;
+ _worldIsTruncated = false;
+ _domesticIsolatedInvalidRowCount = 0;
+ _worldIsolatedInvalidRowCount = 0;
+ _domesticDeferredUnsafeRowCount = 0;
+ _worldDeferredUnsafeRowCount = 0;
_statusKind = LegacyOperatorStatusKind.Neutral;
+ _lastImport = null;
_isBusy = false;
Touch();
return CreateSnapshot();
@@ -91,25 +110,33 @@ public sealed class LegacyComparisonWorkflowController
_domesticStatus = LegacyComparisonSearchStatus.Loading;
_domesticError = string.Empty;
_domesticResults = [];
+ _domesticIsTruncated = false;
+ _domesticIsolatedInvalidRowCount = 0;
+ _domesticDeferredUnsafeRowCount = 0;
_selectedDomesticResultId = null;
Touch();
try
{
- var results = await _dataService.SearchDomesticAsync(
+ var search = await SearchDomesticWithDiagnosticsAsync(
normalizedQuery,
- LegacyComparisonWorkflowCatalog.DomesticMaximumResults,
cancellationToken).ConfigureAwait(false);
if (version != Volatile.Read(ref _domesticSearchVersion))
{
return CreateSnapshot();
}
+ ValidateDiagnostics(
+ search,
+ LegacyComparisonWorkflowCatalog.DomesticMaximumResults);
_domesticResults = ValidateSearchResults(
- results,
+ search.Items,
LegacyComparisonWorkflowCatalog.DomesticMaximumResults,
domestic: true,
version);
+ _domesticIsTruncated = search.IsTruncated;
+ _domesticIsolatedInvalidRowCount = search.IsolatedInvalidRowCount;
+ _domesticDeferredUnsafeRowCount = search.DeferredUnsafeRowCount;
_domesticStatus = LegacyComparisonSearchStatus.Ready;
_domesticError = string.Empty;
Touch();
@@ -131,6 +158,9 @@ public sealed class LegacyComparisonWorkflowController
if (version == Volatile.Read(ref _domesticSearchVersion))
{
_domesticResults = [];
+ _domesticIsTruncated = false;
+ _domesticIsolatedInvalidRowCount = 0;
+ _domesticDeferredUnsafeRowCount = 0;
_domesticStatus = LegacyComparisonSearchStatus.Error;
_domesticError = "국내·NXT 종목 검색 결과를 안전하게 확인하지 못했습니다.";
Touch();
@@ -151,25 +181,33 @@ public sealed class LegacyComparisonWorkflowController
_worldStatus = LegacyComparisonSearchStatus.Loading;
_worldError = string.Empty;
_worldResults = [];
+ _worldIsTruncated = false;
+ _worldIsolatedInvalidRowCount = 0;
+ _worldDeferredUnsafeRowCount = 0;
_selectedWorldResultId = null;
Touch();
try
{
- var results = await _dataService.SearchWorldAsync(
+ var search = await SearchWorldWithDiagnosticsAsync(
normalizedQuery,
- LegacyComparisonWorkflowCatalog.WorldMaximumResults,
cancellationToken).ConfigureAwait(false);
if (version != Volatile.Read(ref _worldSearchVersion))
{
return CreateSnapshot();
}
+ ValidateDiagnostics(
+ search,
+ LegacyComparisonWorkflowCatalog.WorldMaximumResults);
_worldResults = ValidateSearchResults(
- results,
+ search.Items,
LegacyComparisonWorkflowCatalog.WorldMaximumResults,
domestic: false,
version);
+ _worldIsTruncated = search.IsTruncated;
+ _worldIsolatedInvalidRowCount = search.IsolatedInvalidRowCount;
+ _worldDeferredUnsafeRowCount = search.DeferredUnsafeRowCount;
_worldStatus = LegacyComparisonSearchStatus.Ready;
_worldError = string.Empty;
Touch();
@@ -191,6 +229,9 @@ public sealed class LegacyComparisonWorkflowController
if (version == Volatile.Read(ref _worldSearchVersion))
{
_worldResults = [];
+ _worldIsTruncated = false;
+ _worldIsolatedInvalidRowCount = 0;
+ _worldDeferredUnsafeRowCount = 0;
_worldStatus = LegacyComparisonSearchStatus.Error;
_worldError = "미국·대만 종목 검색 결과를 안전하게 확인하지 못했습니다.";
Touch();
@@ -323,6 +364,92 @@ public sealed class LegacyComparisonWorkflowController
return CreateSnapshot();
}
+ ///
+ /// Resolves every row from the original application's fixed, read-only file before
+ /// crossing the local write boundary. Existing operator rows keep their exact order;
+ /// only ordered pairs that have not already been saved are appended.
+ ///
+ public async Task ImportLegacyPairsAsync(
+ CancellationToken cancellationToken = default)
+ {
+ if (_pairImporter is null)
+ {
+ throw new InvalidOperationException(
+ "원본 비교쌍 가져오기가 현재 데이터베이스 구성에 연결되지 않았습니다.");
+ }
+
+ BeginBusy();
+ try
+ {
+ var resolved = await _pairImporter.ImportAsync(cancellationToken)
+ .ConfigureAwait(false);
+ var importedPairs = ValidateImportedPairs(resolved);
+
+ var signatures = new HashSet(
+ _savedPairs.Select(static pair =>
+ LegacyComparisonWorkflowCatalog.PairSignature(pair.First, pair.Second)),
+ StringComparer.Ordinal);
+ var candidate = new List(_savedPairs);
+ var nextSequence = _pairSequence;
+ foreach (var pair in importedPairs)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var signature = LegacyComparisonWorkflowCatalog.PairSignature(
+ pair.First,
+ pair.Second);
+ if (!signatures.Add(signature))
+ {
+ continue;
+ }
+
+ candidate.Add(new SavedPairState(
+ $"comparison-pair-{++nextSequence:D8}",
+ pair.First,
+ pair.Second));
+ }
+
+ if (candidate.Count > LegacyComparisonFileParser.MaximumRows)
+ {
+ throw new InvalidOperationException(
+ "원본 비교쌍을 병합하면 저장 가능한 최대 행 수를 초과합니다.");
+ }
+
+ var addedCount = candidate.Count - _savedPairs.Count;
+ if (addedCount > 0)
+ {
+ await WritePairsAsync(candidate, cancellationToken).ConfigureAwait(false);
+ _savedPairs = candidate;
+ _pairSequence = nextSequence;
+ _selectedPairId ??= candidate.FirstOrDefault()?.RowId;
+ }
+
+ _lastImport = new LegacyComparisonImportReceipt(
+ resolved.SourceSha256,
+ resolved.SourceRowCount,
+ addedCount,
+ resolved.SourceRowCount - addedCount,
+ candidate.Count,
+ resolved.RetrievedAt);
+ _statusMessage = addedCount == 0
+ ? $"원본 비교쌍 {resolved.SourceRowCount}개가 모두 이미 저장되어 있습니다."
+ : $"원본 비교쌍 {resolved.SourceRowCount}개를 확인해 새 비교쌍 {addedCount}개를 추가했습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ }
+ catch
+ {
+ _statusMessage =
+ "원본 비교쌍을 모두 확인하거나 로컬 파일에 원자적으로 병합하지 못해 변경하지 않았습니다.";
+ _statusKind = LegacyOperatorStatusKind.Error;
+ throw;
+ }
+ finally
+ {
+ EndBusy();
+ }
+
+ return CreateSnapshot();
+ }
+
public async Task AddDraftPairAsync(
CancellationToken cancellationToken = default)
{
@@ -331,6 +458,12 @@ public sealed class LegacyComparisonWorkflowController
throw new InvalidOperationException("Select two comparison targets first.");
}
+ if (!_firstTarget.CanSelect || !_secondTarget.CanSelect)
+ {
+ throw new InvalidOperationException(
+ "A deferred comparison row cannot be saved as a comparison pair.");
+ }
+
if (_savedPairs.Count >= LegacyComparisonFileParser.MaximumRows)
{
throw new InvalidOperationException("The saved comparison pair limit was reached.");
@@ -449,6 +582,15 @@ public sealed class LegacyComparisonWorkflowController
string.Equals(item.SelectionId, selectionId, StringComparison.Ordinal));
if (result is not null)
{
+ if (!result.Target.CanSelect)
+ {
+ _statusMessage = result.Target.UnavailableReason ??
+ "이 비교 대상은 선택할 수 없습니다.";
+ _statusKind = LegacyOperatorStatusKind.Warning;
+ Touch();
+ return CreateSnapshot();
+ }
+
if (domestic)
{
_selectedDomesticResultId = selectionId;
@@ -471,11 +613,21 @@ public sealed class LegacyComparisonWorkflowController
bool domestic)
{
ArgumentNullException.ThrowIfNull(selectionId);
- if (results.Any(item => string.Equals(
- item.SelectionId,
- selectionId,
- StringComparison.Ordinal)))
+ var result = results.FirstOrDefault(item => string.Equals(
+ item.SelectionId,
+ selectionId,
+ StringComparison.Ordinal));
+ if (result is not null)
{
+ if (!result.Target.CanSelect)
+ {
+ _statusMessage = result.Target.UnavailableReason ??
+ "이 비교 대상은 선택할 수 없습니다.";
+ _statusKind = LegacyOperatorStatusKind.Warning;
+ Touch();
+ return CreateSnapshot();
+ }
+
if (domestic)
{
_selectedDomesticResultId = selectionId;
@@ -511,6 +663,11 @@ public sealed class LegacyComparisonWorkflowController
}
resolved = LegacyComparisonWorkflowCatalog.RequireCanonicalTarget(resolved);
+ if (!resolved.CanSelect)
+ {
+ throw new InvalidOperationException(
+ "A stored comparison target resolves only to a deferred unsafe row.");
+ }
if (!LegacyComparisonWorkflowCatalog.MatchesStoredReference(resolved, stored))
{
throw new InvalidOperationException("A stored comparison target resolved to a different identity.");
@@ -528,20 +685,7 @@ public sealed class LegacyComparisonWorkflowController
BeginBusy();
try
{
- var writeRows = candidate.Select((pair, index) =>
- {
- var first = LegacyComparisonWorkflowCatalog.ToStoredReference(pair.First);
- var second = LegacyComparisonWorkflowCatalog.ToStoredReference(pair.Second);
- return new LegacyComparisonPairWriteRow(
- index + 1,
- first.Name,
- first.Market,
- second.Name,
- second.Market);
- }).ToArray();
- await _pairStore.WriteCp949Async(
- Array.AsReadOnly(writeRows),
- cancellationToken).ConfigureAwait(false);
+ await WritePairsAsync(candidate, cancellationToken).ConfigureAwait(false);
_savedPairs = candidate.ToList();
_selectedPairId = nextSelection;
@@ -562,6 +706,129 @@ public sealed class LegacyComparisonWorkflowController
return CreateSnapshot();
}
+ private async Task WritePairsAsync(
+ IReadOnlyList candidate,
+ CancellationToken cancellationToken)
+ {
+ var writeRows = candidate.Select((pair, index) =>
+ {
+ var first = LegacyComparisonWorkflowCatalog.ToStoredReference(pair.First);
+ var second = LegacyComparisonWorkflowCatalog.ToStoredReference(pair.Second);
+ return new LegacyComparisonPairWriteRow(
+ index + 1,
+ first.Name,
+ first.Market,
+ second.Name,
+ second.Market);
+ }).ToArray();
+ await _pairStore.WriteCp949Async(
+ Array.AsReadOnly(writeRows),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ private static IReadOnlyList<(LegacyComparisonTarget First, LegacyComparisonTarget Second)>
+ ValidateImportedPairs(LegacyComparisonImportResult? result)
+ {
+ if (result is null || result.SourceRowCount is <= 0 or > LegacyComparisonFileParser.MaximumRows ||
+ result.Pairs is null || result.Pairs.Count != result.SourceRowCount ||
+ string.IsNullOrEmpty(result.SourceSha256) || result.SourceSha256.Length != 64 ||
+ result.SourceSha256.Any(static value =>
+ value is not (>= '0' and <= '9') and not (>= 'A' and <= 'F')))
+ {
+ throw new InvalidOperationException("The resolved comparison import is invalid.");
+ }
+
+ var pairs = new List<(LegacyComparisonTarget First, LegacyComparisonTarget Second)>(
+ result.Pairs.Count);
+ for (var index = 0; index < result.Pairs.Count; index++)
+ {
+ var pair = result.Pairs[index] ??
+ throw new InvalidOperationException("The resolved comparison import is invalid.");
+ if (pair.RowNumber != index + 1)
+ {
+ throw new InvalidOperationException("The resolved comparison import is invalid.");
+ }
+
+ pairs.Add((
+ ToCanonicalImportedTarget(pair.First),
+ ToCanonicalImportedTarget(pair.Second)));
+ }
+
+ return pairs.AsReadOnly();
+ }
+
+ private static LegacyComparisonTarget ToCanonicalImportedTarget(
+ LegacyComparisonImportTarget? target)
+ {
+ if (target is null)
+ {
+ throw new InvalidOperationException("The resolved comparison target is invalid.");
+ }
+
+ LegacyComparisonTarget canonical = target.Kind switch
+ {
+ LegacyComparisonImportTargetKind.MarketTarget
+ when target.MarketTarget is not null &&
+ target.Market is null && target.StockName is null &&
+ target.StockCode is null && target.InputName is null &&
+ target.Symbol is null && target.Nation is null =>
+ LegacyComparisonWorkflowCatalog.FixedTargets.SingleOrDefault(item =>
+ string.Equals(
+ item.MarketTarget,
+ target.MarketTarget,
+ StringComparison.Ordinal)) ??
+ throw new InvalidOperationException(
+ "The resolved comparison market target is unknown."),
+ LegacyComparisonImportTargetKind.KrxStock
+ when target.MarketTarget is null && target.Market is not null &&
+ target.StockName is not null && target.StockCode is not null &&
+ target.InputName is null && target.Symbol is null &&
+ target.Nation is null =>
+ LegacyComparisonTarget.CreateKrxStock(
+ ParseImportedDomesticMarket(target.Market),
+ target.StockName,
+ target.StockCode),
+ LegacyComparisonImportTargetKind.NxtStock
+ when target.MarketTarget is null && target.Market is not null &&
+ target.StockName is not null && target.StockCode is not null &&
+ target.InputName is null && target.Symbol is null &&
+ target.Nation is null =>
+ LegacyComparisonTarget.CreateNxtStock(
+ ParseImportedDomesticMarket(target.Market),
+ target.StockName,
+ target.StockCode),
+ LegacyComparisonImportTargetKind.WorldStock
+ when target.MarketTarget is null && target.Market is null &&
+ target.StockName is null && target.StockCode is null &&
+ target.InputName is not null && target.Symbol is not null &&
+ target.Nation is not null =>
+ LegacyComparisonTarget.CreateWorldStock(
+ target.InputName,
+ target.Symbol,
+ target.Nation),
+ _ => throw new InvalidOperationException(
+ "The resolved comparison target shape is invalid.")
+ };
+
+ canonical = LegacyComparisonWorkflowCatalog.RequireCanonicalTarget(canonical);
+ if (!canonical.CanSelect)
+ {
+ throw new InvalidOperationException(
+ "The resolved comparison target cannot be stored safely.");
+ }
+
+ return canonical;
+ }
+
+ private static LegacyComparisonDomesticMarket ParseImportedDomesticMarket(string market) =>
+ market switch
+ {
+ "kospi" => LegacyComparisonDomesticMarket.Kospi,
+ "kosdaq" => LegacyComparisonDomesticMarket.Kosdaq,
+ _ => throw new InvalidOperationException(
+ "The resolved comparison domestic market is invalid.")
+ };
+
private static List ValidateSearchResults(
IReadOnlyList? results,
int maximumResults,
@@ -574,14 +841,14 @@ public sealed class LegacyComparisonWorkflowController
}
var validated = new List(results.Count);
- var targetIds = new HashSet(StringComparer.Ordinal);
+ var identities = new HashSet();
for (var index = 0; index < results.Count; index++)
{
var target = LegacyComparisonWorkflowCatalog.RequireCanonicalTarget(results[index]);
var allowedKind = domestic
? target.Kind is LegacyComparisonTargetKind.KrxStock or LegacyComparisonTargetKind.NxtStock
: target.Kind == LegacyComparisonTargetKind.WorldStock;
- if (!allowedKind || !targetIds.Add(target.TargetId))
+ if (!allowedKind || !identities.Add(target))
{
throw new InvalidOperationException("The comparison search result identity is invalid.");
}
@@ -594,6 +861,62 @@ public sealed class LegacyComparisonWorkflowController
return validated;
}
+ private async Task SearchDomesticWithDiagnosticsAsync(
+ string query,
+ CancellationToken cancellationToken)
+ {
+ if (_dataService is ILegacyComparisonDataDiagnosticsService diagnostics)
+ {
+ return await diagnostics.SearchDomesticWithDiagnosticsAsync(
+ query,
+ LegacyComparisonWorkflowCatalog.DomesticMaximumResults,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ var items = await _dataService.SearchDomesticAsync(
+ query,
+ LegacyComparisonWorkflowCatalog.DomesticMaximumResults,
+ cancellationToken).ConfigureAwait(false);
+ return new LegacyComparisonSearchData(items, false, 0);
+ }
+
+ private async Task SearchWorldWithDiagnosticsAsync(
+ string query,
+ CancellationToken cancellationToken)
+ {
+ if (_dataService is ILegacyComparisonDataDiagnosticsService diagnostics)
+ {
+ return await diagnostics.SearchWorldWithDiagnosticsAsync(
+ query,
+ LegacyComparisonWorkflowCatalog.WorldMaximumResults,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ var items = await _dataService.SearchWorldAsync(
+ query,
+ LegacyComparisonWorkflowCatalog.WorldMaximumResults,
+ cancellationToken).ConfigureAwait(false);
+ return new LegacyComparisonSearchData(items, false, 0);
+ }
+
+ private static void ValidateDiagnostics(
+ LegacyComparisonSearchData? search,
+ int maximumResults)
+ {
+ if (search is null || search.Items is null ||
+ search.Items.Count > maximumResults ||
+ search.IsolatedInvalidRowCount is < 0 ||
+ search.IsolatedInvalidRowCount > maximumResults ||
+ search.DeferredUnsafeRowCount is < 0 ||
+ search.DeferredUnsafeRowCount > search.Items.Count ||
+ search.DeferredUnsafeRowCount != search.Items.Count(item => !item.CanSelect) ||
+ search.IsolatedInvalidRowCount > 0 && !search.IsTruncated)
+ {
+ throw new InvalidOperationException(
+ "The comparison search diagnostics are invalid.");
+ }
+ }
+
private LegacyComparisonWorkflowSnapshot CreateSnapshot()
{
var selectedPair = SelectedPairIndex() is var selectedIndex && selectedIndex >= 0
@@ -630,14 +953,24 @@ public sealed class LegacyComparisonWorkflowController
Array.AsReadOnly(domesticResults),
_selectedDomesticResultId,
domesticResults.Length,
- _domesticError),
+ _domesticError)
+ {
+ IsTruncated = _domesticIsTruncated,
+ IsolatedInvalidRowCount = _domesticIsolatedInvalidRowCount,
+ DeferredUnsafeRowCount = _domesticDeferredUnsafeRowCount
+ },
new LegacyComparisonSearchSnapshot(
_worldQuery,
_worldStatus,
Array.AsReadOnly(worldResults),
_selectedWorldResultId,
worldResults.Length,
- _worldError),
+ _worldError)
+ {
+ IsTruncated = _worldIsTruncated,
+ IsolatedInvalidRowCount = _worldIsolatedInvalidRowCount,
+ DeferredUnsafeRowCount = _worldDeferredUnsafeRowCount
+ },
Array.AsReadOnly(fixedTargets),
_selectedFixedTargetId,
_firstTarget is null
@@ -652,7 +985,11 @@ public sealed class LegacyComparisonWorkflowController
selectedPair?.First,
selectedPair?.Second),
_statusMessage,
- _statusKind);
+ _statusKind)
+ {
+ CanImportLegacyPairs = CanImportLegacyPairs,
+ LastImport = _lastImport
+ };
}
private int SelectedPairIndex() => _selectedPairId is null
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowModels.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowModels.cs
index e5effb2..a0b03a3 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowModels.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowModels.cs
@@ -1,5 +1,6 @@
using System.Globalization;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
@@ -81,6 +82,14 @@ public sealed record LegacyComparisonTarget
public string? Nation { get; }
+ public bool CanSelect =>
+ Kind != LegacyComparisonTargetKind.WorldStock ||
+ !InputName!.Contains(',', StringComparison.Ordinal);
+
+ public string? UnavailableReason => CanSelect
+ ? null
+ : "쉼표가 포함된 해외 종목명은 비교 대상 구분이 모호해 선택할 수 없습니다.";
+
public static LegacyComparisonTarget CreateMarketTarget(
string targetId,
string displayName,
@@ -134,11 +143,6 @@ public sealed record LegacyComparisonTarget
string nation)
{
LegacyComparisonValueGuard.RequireText(inputName, 200, nameof(inputName));
- if (inputName.Contains(',', StringComparison.Ordinal))
- {
- throw new ArgumentException("A world stock input name cannot contain a comma.", nameof(inputName));
- }
-
LegacyComparisonValueGuard.RequireSymbol(symbol, nameof(symbol));
var normalizedNation = LegacyComparisonValueGuard.RequireNation(nation, nameof(nation));
return new LegacyComparisonTarget(
@@ -186,6 +190,32 @@ public interface ILegacyComparisonDataService
CancellationToken cancellationToken = default);
}
+public sealed record LegacyComparisonSearchData(
+ IReadOnlyList Items,
+ bool IsTruncated,
+ int IsolatedInvalidRowCount)
+{
+ public int DeferredUnsafeRowCount { get; init; }
+}
+
+///
+/// Optional production telemetry used to preserve safe legacy rows when one provider row
+/// cannot become a selectable native identity. Test and offline adapters may continue to
+/// implement the list-only compatibility boundary above.
+///
+public interface ILegacyComparisonDataDiagnosticsService
+{
+ Task SearchDomesticWithDiagnosticsAsync(
+ string query,
+ int maximumResults,
+ CancellationToken cancellationToken = default);
+
+ Task SearchWorldWithDiagnosticsAsync(
+ string query,
+ int maximumResults,
+ CancellationToken cancellationToken = default);
+}
+
///
/// File IO stays behind this boundary. A production adapter owns the fixed path and CP949
/// encoding; no UI request can provide a path or raw serialized row.
@@ -210,7 +240,12 @@ public sealed record LegacyComparisonTargetView(
string SelectionId,
string DisplayName,
string Metadata,
- LegacyComparisonTargetKind Kind);
+ LegacyComparisonTargetKind Kind)
+{
+ public bool CanSelect { get; init; } = true;
+
+ public string? UnavailableReason { get; init; }
+}
public sealed record LegacyComparisonSearchSnapshot(
string Query,
@@ -218,7 +253,14 @@ public sealed record LegacyComparisonSearchSnapshot(
IReadOnlyList Results,
string? SelectedResultId,
int TotalRowCount,
- string ErrorMessage);
+ string ErrorMessage)
+{
+ public bool IsTruncated { get; init; }
+
+ public int IsolatedInvalidRowCount { get; init; }
+
+ public int DeferredUnsafeRowCount { get; init; }
+}
public sealed record LegacyComparisonSavedPairView(
string RowId,
@@ -228,6 +270,14 @@ public sealed record LegacyComparisonSavedPairView(
LegacyComparisonTargetView Second,
bool IsSelected);
+public sealed record LegacyComparisonImportReceipt(
+ string SourceSha256,
+ int SourceRowCount,
+ int AddedPairCount,
+ int SkippedDuplicateCount,
+ int TotalPairCount,
+ DateTimeOffset ImportedAt);
+
public sealed record LegacyComparisonActionView(
string ActionId,
string Label,
@@ -248,7 +298,12 @@ public sealed record LegacyComparisonWorkflowSnapshot(
string? SelectedPairId,
IReadOnlyList Actions,
string StatusMessage,
- LegacyOperatorStatusKind StatusKind);
+ LegacyOperatorStatusKind StatusKind)
+{
+ public bool CanImportLegacyPairs { get; init; }
+
+ public LegacyComparisonImportReceipt? LastImport { get; init; }
+}
///
/// Generic, already validated playlist materialization. Only an action id crosses the UI
@@ -321,11 +376,7 @@ internal static class LegacyComparisonValueGuard
public static void RequireSymbol(string value, string parameterName)
{
- ArgumentNullException.ThrowIfNull(value, parameterName);
- if (value.Length is <= 0 or > 64 ||
- value.Any(static character =>
- !char.IsAsciiLetterOrDigit(character) &&
- character is not '@' and not '.' and not '_' and not ':' and not '-'))
+ if (!LegacyNamedForeignStockRestoreService.IsCanonicalBoundSymbol(value))
{
throw new ArgumentException("A safe world symbol is required.", parameterName);
}
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs
index 358b2d9..6185b7b 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs
@@ -22,7 +22,7 @@ public sealed record LegacyExpertRecommendationView(
int PlayIndex,
string StockCode,
string StockName,
- decimal BuyAmount);
+ decimal? BuyAmount);
public sealed record LegacyExpertSearchSnapshot(
string Query,
@@ -475,8 +475,9 @@ public sealed class LegacyExpertWorkflowController
item.PlayIndex <= previousIndex ||
!IsAsciiAlphaNumeric(item.StockCode, 32) ||
!IsCanonicalText(item.StockName, 200) ||
- item.BuyAmount <= 0 || item.BuyAmount > 9_007_199_254_740_991m ||
- decimal.Truncate(item.BuyAmount) != item.BuyAmount ||
+ item.BuyAmount is { } buyAmount &&
+ (buyAmount <= 0 || buyAmount > 9_007_199_254_740_991m ||
+ decimal.Truncate(buyAmount) != buyAmount) ||
!indexes.Add(item.PlayIndex))
{
throw new ExpertSelectionDataException(
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyCutInfoSceneAliasResolver.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyCutInfoSceneAliasResolver.cs
new file mode 100644
index 0000000..1f7d396
--- /dev/null
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyCutInfoSceneAliasResolver.cs
@@ -0,0 +1,339 @@
+#nullable enable
+
+using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
+
+namespace MBN_STOCK_WEBVIEW.LegacyApplication;
+
+///
+/// Reproduces the ordered, ASCII-space-insensitive alias selection performed by
+/// the original MBN_STOCK_N Class/CutList.cs. The returned value is additionally
+/// closed against so legacy text can never become
+/// an operator-controlled scene name or path.
+///
+public static class LegacyCutInfoSceneAliasResolver
+{
+ private static readonly string[] OriginalAliases =
+ [
+ "50160",
+ "5081",
+ "5080",
+ "5079",
+ "5076",
+ "N5001",
+ "5001",
+ "5006",
+ "5011",
+ "5026",
+ "5087",
+ "5029",
+ "5086",
+ "8035",
+ "8061",
+ "8051",
+ "8040",
+ "8046",
+ "8056",
+ "8003",
+ "5037",
+ "5016",
+ "5068",
+ "5070",
+ "5072",
+ "8067",
+ "5078",
+ "6001",
+ "5082",
+ "5023",
+ "5083",
+ "5084",
+ "5024",
+ "6067",
+ "5085",
+ "5025",
+ "8001",
+ "8002",
+ "5074",
+ "5077",
+ "5088",
+ "5032",
+ "8032",
+ "8018",
+ "50860"
+ ];
+
+ static LegacyCutInfoSceneAliasResolver()
+ {
+ var unregistered = OriginalAliases
+ .Where(alias => LegacySceneCatalog.FindByCutAlias(alias).Count == 0)
+ .ToArray();
+ if (unregistered.Length != 0)
+ {
+ throw new InvalidOperationException(
+ "The original CutList contains an alias that is not registered in the scene catalog.");
+ }
+ }
+
+ public static bool TryResolve(
+ string groupCode,
+ string subject,
+ string graphicType,
+ string subtype,
+ out string sceneAlias)
+ {
+ sceneAlias = string.Empty;
+ if (groupCode is null || subject is null || graphicType is null || subtype is null)
+ {
+ return false;
+ }
+
+ // MainForm.Show_PlayList uses this shorter key for the image-graph row.
+ // Every other active branch concatenates the four visible legacy fields.
+ var legacyCutInfo = string.Equals(
+ graphicType,
+ "이미지 그래프",
+ StringComparison.Ordinal)
+ ? subject + graphicType
+ : groupCode + subject + graphicType + subtype;
+
+ // The original removes U+0020 only. Other whitespace remains significant.
+ var normalized = legacyCutInfo.Replace(" ", string.Empty, StringComparison.Ordinal);
+ var candidate = ResolveOriginalCandidate(normalized);
+ if (candidate is null || LegacySceneCatalog.FindByCutAlias(candidate).Count == 0)
+ {
+ return false;
+ }
+
+ sceneAlias = candidate;
+ return true;
+ }
+
+ private static string? ResolveOriginalCandidate(string info)
+ {
+ if (Contains(info, "2열판-"))
+ {
+ return "50160";
+ }
+
+ if (Contains(info, "영업이익"))
+ {
+ return "5081";
+ }
+
+ if (Contains(info, "매출액"))
+ {
+ return "5080";
+ }
+
+ if (Contains(info, "성장성지표"))
+ {
+ return "5079";
+ }
+
+ if (Contains(info, "주요매출구성"))
+ {
+ return "5076";
+ }
+
+ if (Contains(info, "(NXT)1열판기본"))
+ {
+ return "N5001";
+ }
+
+ if (Contains(info, "1열판기본"))
+ {
+ return "5001";
+ }
+
+ if (Contains(info, "1열판상세"))
+ {
+ return Contains(info, "시가") ? "5006" : "5011";
+ }
+
+ if (Contains(info, "1열판"))
+ {
+ return "5001";
+ }
+
+ if (Contains(info, "종목별비교분석_캔들그래프"))
+ {
+ return "5026";
+ }
+
+ if (Contains(info, "종목별비교분석_라인그래프"))
+ {
+ return "5087";
+ }
+
+ if (Contains(info, "종목별수익률비교"))
+ {
+ return "5029";
+ }
+
+ if (Contains(info, "수익률그래프"))
+ {
+ return "5086";
+ }
+
+ if (Contains(info, "캔들그래프"))
+ {
+ if (Contains(info, "일봉"))
+ {
+ return "8035";
+ }
+
+ if (Contains(info, "5일"))
+ {
+ return "8061";
+ }
+
+ if (Contains(info, "120일"))
+ {
+ return "8051";
+ }
+
+ if (Contains(info, "20일"))
+ {
+ return "8040";
+ }
+
+ if (Contains(info, "60일"))
+ {
+ return "8046";
+ }
+
+ if (Contains(info, "240일"))
+ {
+ return "8056";
+ }
+
+ return null;
+ }
+
+ if (Contains(info, "호가창"))
+ {
+ return "8003";
+ }
+
+ if (Contains(info, "거래원"))
+ {
+ return "5037";
+ }
+
+ if (Contains(info, "3열판"))
+ {
+ return "5016";
+ }
+
+ if (Contains(info, "미국증시지도"))
+ {
+ return "5068";
+ }
+
+ if (Contains(info, "유럽증시지도"))
+ {
+ return "5070";
+ }
+
+ if (Contains(info, "아시아증시지도"))
+ {
+ return "5072";
+ }
+
+ if (Contains(info, "글로벌증시지도"))
+ {
+ return "8067";
+ }
+
+ if (Contains(info, "미국업종") || Contains(info, "섹터지수"))
+ {
+ return "5078";
+ }
+
+ if (Contains(info, "이미지그래프"))
+ {
+ return "6001";
+ }
+
+ if (Contains(info, "주체별매매동향"))
+ {
+ return "5082";
+ }
+
+ if (Contains(info, "매매동향표그래프"))
+ {
+ return "5023";
+ }
+
+ if (Contains(info, "개인매매동향") ||
+ Contains(info, "기관매매동향") ||
+ Contains(info, "외국인매매동향"))
+ {
+ return "5083";
+ }
+
+ if (Contains(info, "매매동향라인그래프"))
+ {
+ return "5084";
+ }
+
+ if (Contains(info, "매매동향막대그래프"))
+ {
+ return "5024";
+ }
+
+ if (Contains(info, "기관순매수현황"))
+ {
+ return "6067";
+ }
+
+ if (Contains(info, "프로그램매매"))
+ {
+ return "5085";
+ }
+
+ if (Contains(info, "순매도상위"))
+ {
+ return "5025";
+ }
+
+ if (Contains(info, "네모그래프"))
+ {
+ return Contains(info, "코스피") ? "8001" : "8002";
+ }
+
+ if (Contains(info, "5단표그래프"))
+ {
+ return "5074";
+ }
+
+ if (Contains(info, "6종목현재가"))
+ {
+ return "5077";
+ }
+
+ if (Contains(info, "12종목현재가"))
+ {
+ return "5088";
+ }
+
+ if (Contains(info, "2열판"))
+ {
+ if (Contains(info, "지수") || Contains(info, "종목"))
+ {
+ return Contains(info, "선물") ? "5032" : "8032";
+ }
+
+ if (Contains(info, "코스피"))
+ {
+ return "8018";
+ }
+
+ return Contains(info, "코스닥") ? "8032" : null;
+ }
+
+ return Contains(info, "라인그래프") ? "50860" : null;
+ }
+
+ private static bool Contains(string source, string value) =>
+ source.Contains(value, StringComparison.Ordinal);
+}
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs
index c71dc0f..77b0b83 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs
@@ -4,10 +4,19 @@ using CoreMovingAverageSelection =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageSelection;
using CoreMovingAverageSource =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.MutableLegacySceneMovingAverageSelectionSource;
+using CorePagePlanProvider =
+ MBN_STOCK_WEBVIEW.Core.Playout.Scenes.ILegacyScenePagePlanProvider;
+using CorePlaylistEntry =
+ MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacyPlaylistEntry;
+using CoreScenePaging =
+ MBN_STOCK_WEBVIEW.Core.Playout.ScenePaging;
+using CoreScenePageSize =
+ MBN_STOCK_WEBVIEW.Core.Playout.ScenePageSize;
using CoreSceneSelection =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection;
using S5011DetailBranch =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5011DetailBranch;
+using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
@@ -164,6 +173,7 @@ public sealed class LegacyOperatorController
private readonly LegacyOverseasSelectionWorkflow? _overseasWorkflow;
private readonly LegacyManualListsWorkflow? _manualListsWorkflow;
private readonly LegacyOperatorCatalogWorkflowController? _operatorCatalogWorkflow;
+ private readonly CorePagePlanProvider? _fixedPagePlanProvider;
private readonly CoreMovingAverageSource _movingAverageSource = new(
new CoreMovingAverageSelection(true, true));
private readonly SemaphoreSlim _tabActivationGate = new(1, 1);
@@ -232,7 +242,8 @@ public sealed class LegacyOperatorController
LegacyOverseasSelectionWorkflow? overseasWorkflow = null,
LegacyManualListsWorkflow? manualListsWorkflow = null,
LegacyOperatorCatalogWorkflowController? operatorCatalogWorkflow = null,
- IReadOnlyList? cutRows = null)
+ IReadOnlyList? cutRows = null,
+ CorePagePlanProvider? fixedPagePlanProvider = null)
{
_stockLookup = stockLookup ?? throw new ArgumentNullException(nameof(stockLookup));
_cutRows = CreateValidatedCutRows(cutRows);
@@ -249,6 +260,7 @@ public sealed class LegacyOperatorController
_overseasWorkflow = overseasWorkflow;
_manualListsWorkflow = manualListsWorkflow;
_operatorCatalogWorkflow = operatorCatalogWorkflow;
+ _fixedPagePlanProvider = fixedPagePlanProvider;
_tradingHaltWorkflow?.SetMovingAverages(
_movingAverages.Ma5,
_movingAverages.Ma20);
@@ -1040,6 +1052,25 @@ public sealed class LegacyOperatorController
return CreateSnapshot();
}
+ public async Task ChangeCreateThemeCatalogMarketAsync(
+ MMoneyCoderSharp.Data.ThemeMarket market,
+ string editorName,
+ CancellationToken cancellationToken = default)
+ {
+ if (_operatorCatalogWorkflow is null)
+ {
+ return ReportError("ThemeA 관리 기능이 구성되지 않았습니다.");
+ }
+
+ await _operatorCatalogWorkflow.ChangeCreateThemeMarketAsync(
+ market,
+ editorName,
+ cancellationToken).ConfigureAwait(false);
+ ApplyOperatorCatalogStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
public async Task BeginCreateExpertCatalogAsync(
CancellationToken cancellationToken = default)
{
@@ -1395,8 +1426,7 @@ public sealed class LegacyOperatorController
_manualFinancialSnapshot,
string.Empty,
cancellationToken).ConfigureAwait(false);
- _statusMessage = $"{_manualFinancialSnapshot.Definition.Label} 목록을 불러왔습니다.";
- _statusKind = LegacyOperatorStatusKind.Information;
+ ApplyManualFinancialListStatus(_manualFinancialSnapshot);
Touch();
return CreateSnapshot();
}
@@ -1427,6 +1457,7 @@ public sealed class LegacyOperatorController
snapshot,
query,
cancellationToken).ConfigureAwait(false);
+ ApplyManualFinancialListStatus(_manualFinancialSnapshot);
Touch();
return CreateSnapshot();
}
@@ -1518,15 +1549,43 @@ public sealed class LegacyOperatorController
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
}
- _manualFinancialSnapshot = await workflow.LoadAsync(
- snapshot,
- resultId,
- cancellationToken).ConfigureAwait(false);
+ // Commit the attempted row selection and clear any previous detail before
+ // the strict exact read. If a malformed historical row fails, an earlier
+ // valid record can never remain eligible for save, delete, or playout.
+ _manualFinancialSnapshot = workflow.BeginLoad(snapshot, resultId);
+ try
+ {
+ _manualFinancialSnapshot = await workflow.LoadAsync(
+ _manualFinancialSnapshot,
+ resultId,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception exception) when (exception is ManualFinancialDataException or
+ ArgumentException or InvalidOperationException or KeyNotFoundException)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ _statusMessage = exception.Message;
+ _statusKind = LegacyOperatorStatusKind.Warning;
+ Touch();
+ return CreateSnapshot();
+ }
// GraphE identities contain STOCK_NAME only. Resolve the exact stock master
// immediately after the fresh detail read when it is unambiguous; otherwise
// leave the candidates visible so the operator can resolve them explicitly.
- var stockName = _manualFinancialSnapshot.SelectedRecord!.Identity.StockName;
+ var selectedIdentity = _manualFinancialSnapshot.SelectedRawRecord?.Identity ??
+ _manualFinancialSnapshot.SelectedRecord?.Identity;
+ if (selectedIdentity is null)
+ {
+ _dialog = new LegacyDialogState(
+ "선택한 GraphE 행의 종목 식별정보를 읽을 수 없습니다.");
+ _statusMessage = _dialog.Message;
+ _statusKind = LegacyOperatorStatusKind.Warning;
+ Touch();
+ return CreateSnapshot();
+ }
+
+ var stockName = selectedIdentity.StockName;
_manualFinancialSnapshot = await workflow.SearchStocksAsync(
_manualFinancialSnapshot,
stockName,
@@ -1548,6 +1607,26 @@ public sealed class LegacyOperatorController
}
}
+
+ if (_manualFinancialSnapshot.WritesQuarantined)
+ {
+ _statusMessage = _manualFinancialSnapshot.LastMessage ??
+ "GraphE 저장 작업은 현재 차단되어 있습니다.";
+ _statusKind = LegacyOperatorStatusKind.Warning;
+ }
+ else if (!_manualFinancialSnapshot.IsPlayoutReady)
+ {
+ _statusMessage =
+ "기존 GraphE 저장값을 원문 그대로 불러왔습니다. 편집·삭제와 스케줄 추가는 가능하며 실제 송출 전 장면 검증에서 다시 확인합니다.";
+ _statusKind = LegacyOperatorStatusKind.Warning;
+ }
+ else
+ {
+ _statusMessage =
+ $"{stockName} {_manualFinancialSnapshot.Definition.Label} 데이터를 불러왔습니다.";
+ _statusKind = LegacyOperatorStatusKind.Information;
+ }
+
Touch();
return CreateSnapshot();
}
@@ -1574,6 +1653,13 @@ public sealed class LegacyOperatorController
throw new InvalidOperationException(
"The manual-financial result is no longer available.");
}
+ if ((!snapshot.IsRawDetailFresh && !snapshot.IsDetailFresh) ||
+ snapshot.SelectedIdentity is null ||
+ !string.Equals(snapshot.SelectedRowId, resultId, StringComparison.Ordinal))
+ {
+ throw new LegacyManualFinancialWorkflowDataException(
+ "선택한 기존 GraphE 행의 저장값을 읽을 수 없습니다.");
+ }
var draft = workflow.MaterializePlaylistDraft(
snapshot,
@@ -1626,6 +1712,13 @@ public sealed class LegacyOperatorController
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
}
+ // GraphE's search button and Enter handler both returned without any
+ // state or focus change when the stock-name box was empty.
+ if (string.IsNullOrWhiteSpace(stockName))
+ {
+ return CreateSnapshot();
+ }
+
_manualFinancialSnapshot = await workflow.SearchStocksAsync(
snapshot,
stockName,
@@ -1642,6 +1735,11 @@ public sealed class LegacyOperatorController
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
}
+ if (string.IsNullOrWhiteSpace(resultId))
+ {
+ return CreateSnapshot();
+ }
+
try
{
_manualFinancialSnapshot = workflow.SelectStock(snapshot, resultId);
@@ -1676,6 +1774,7 @@ public sealed class LegacyOperatorController
request,
cancellationToken).ConfigureAwait(false);
ApplyManualFinancialResultStatus(_manualFinancialSnapshot, "저장");
+ ShowManualFinancialSaveDialog(_manualFinancialSnapshot);
Touch();
return CreateSnapshot();
}
@@ -1687,10 +1786,11 @@ public sealed class LegacyOperatorController
}
}
- public async Task DeleteManualFinancialAsync(
- bool all,
+ public async Task SaveManualFinancialRawAsync(
+ MMoneyCoderSharp.Data.ManualFinancialRawRecord record,
CancellationToken cancellationToken = default)
{
+ ArgumentNullException.ThrowIfNull(record);
if (!TryGetManualFinancial(out var workflow, out var snapshot))
{
return ReportError("수동 재무 입력 화면을 먼저 여세요.");
@@ -1698,18 +1798,13 @@ public sealed class LegacyOperatorController
try
{
- LegacyManualFinancialWriteRequest request = all
- ? workflow.CreateDeleteAllRequest(
- snapshot,
- snapshot.Definition.DeleteAllConfirmationToken)
- : workflow.CreateDeleteRequest(snapshot);
+ var request = workflow.CreateRawUpdateRequest(snapshot, record);
_manualFinancialSnapshot = await workflow.ExecuteAsync(
snapshot,
request,
cancellationToken).ConfigureAwait(false);
- ApplyManualFinancialResultStatus(
- _manualFinancialSnapshot,
- all ? "전체 삭제" : "삭제");
+ ApplyManualFinancialResultStatus(_manualFinancialSnapshot, "저장");
+ ShowManualFinancialSaveDialog(_manualFinancialSnapshot);
Touch();
return CreateSnapshot();
}
@@ -1721,6 +1816,54 @@ public sealed class LegacyOperatorController
}
}
+ public Task DeleteManualFinancialAsync(
+ bool all,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!TryGetManualFinancial(out var workflow, out var snapshot))
+ {
+ return Task.FromResult(ReportError("수동 재무 입력 화면을 먼저 여세요."));
+ }
+
+ if (_dialog is not null)
+ {
+ return Task.FromResult(CreateSnapshot());
+ }
+
+ // GraphE returned immediately when no row had been selected; it did not
+ // show either the Yes/No question or a follow-up error MessageBox.
+ if (!all && snapshot.SelectedRowId is null)
+ {
+ return Task.FromResult(CreateSnapshot());
+ }
+
+ try
+ {
+ // Validate the current selection/write authority before showing the
+ // native-compatible question, but do not cross the write boundary.
+ _ = all
+ ? workflow.CreateDeleteAllRequest(
+ snapshot,
+ snapshot.Definition.DeleteAllConfirmationToken)
+ : workflow.CreateDeleteRequest(snapshot);
+ _pendingNativeConfirmation = PendingNativeConfirmation.ForManualFinancialDelete(
+ all,
+ snapshot.Revision);
+ _dialog = new LegacyDialogState(
+ all ? "모든 종목을 삭제하겠습니까?" : "선택한 종목을 삭제하겠습니까?",
+ IsConfirmation: true);
+ Touch();
+ return Task.FromResult(CreateSnapshot());
+ }
+ catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ Touch();
+ return Task.FromResult(CreateSnapshot());
+ }
+ }
+
public LegacyOperatorSnapshot ActivateManualFinancialAction(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
@@ -1957,8 +2100,25 @@ public sealed class LegacyOperatorController
public LegacyOperatorSnapshot DeleteAllManualViItems()
{
- _manualListsWorkflow?.DeleteAllViItems();
- ApplyManualListsStatus();
+ if (_manualListsWorkflow is null)
+ {
+ return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
+ }
+
+ var manualLists = _manualListsWorkflow.Current;
+ if (!manualLists.IsOpen || manualLists.Screen != LegacyManualListScreen.Vi)
+ {
+ return ReportError("VI 수동 목록 화면을 먼저 여세요.");
+ }
+
+ if (_dialog is not null)
+ {
+ return CreateSnapshot();
+ }
+
+ _pendingNativeConfirmation = PendingNativeConfirmation.ForManualViDeleteAll(
+ manualLists.ViItems.Select(static item => item.ItemId).ToArray());
+ _dialog = new LegacyDialogState("모두 삭제하겠습니까?", IsConfirmation: true);
Touch();
return CreateSnapshot();
}
@@ -2129,9 +2289,9 @@ public sealed class LegacyOperatorController
return ReportError("현재 탭에 속하지 않는 항목입니다.");
}
- AppendFixedRow(materialized);
- _statusMessage = $"{materialized.Action.Label} 항목을 추가했습니다.";
- _statusKind = LegacyOperatorStatusKind.Information;
+ var initialPage = CreateFixedInitialPageFallback(materialized.Action);
+ AppendFixedRow(materialized, initialPage.PageText);
+ ApplyFixedActionAddedStatus(materialized.Action, initialPage.PageCountUnavailable);
Touch();
return CreateSnapshot();
}
@@ -2162,7 +2322,46 @@ public sealed class LegacyOperatorController
if (manualTarget is null)
{
- return ActivateFixedAction(actionId);
+ var fixedMarket = GetActiveFixedMarket();
+ if (fixedMarket is null)
+ {
+ return ReportError("현재 탭에서는 고정 컷을 선택할 수 없습니다.");
+ }
+
+ try
+ {
+ var materialized = _fixedCatalog.Materialize(
+ actionId,
+ _timeProvider.GetLocalNow());
+ if (materialized.Action.Market != fixedMarket.Value)
+ {
+ return ReportError("현재 탭에 속하지 않는 항목입니다.");
+ }
+
+ var initialPage = await ResolveFixedInitialPageAsync(
+ materialized,
+ cancellationToken).ConfigureAwait(false);
+ AppendFixedRow(materialized, initialPage.PageText);
+ ApplyFixedActionAddedStatus(
+ materialized.Action,
+ initialPage.PageCountUnavailable);
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (InvalidOperationException exception)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (KeyNotFoundException)
+ {
+ return ReportError("등록되지 않은 고정 컷 항목입니다.");
+ }
}
var market = GetActiveFixedMarket();
@@ -2234,19 +2433,23 @@ public sealed class LegacyOperatorController
// Every non-manual child is validated before the first playlist row
// is appended or the first modal editor is opened.
+ var materialized = _fixedCatalog.Materialize(
+ action.Id,
+ localNow);
+ var initialPage = await ResolveFixedInitialPageAsync(
+ materialized,
+ cancellationToken).ConfigureAwait(false);
items.Add(new PendingFixedSectionItem(
action,
- _fixedCatalog.Materialize(
- action.Id,
- localNow)));
+ materialized,
+ initialPage));
}
var manualCount = items.Count(item => item.ManualTarget is not null);
if (manualCount == 0)
{
CommitFixedSectionBatch(items);
- _statusMessage = $"{section.Name} 항목 {items.Count}개를 추가했습니다.";
- _statusKind = LegacyOperatorStatusKind.Information;
+ ApplyFixedSectionAddedStatus(section.Name, items);
Touch();
return CreateSnapshot();
}
@@ -2260,6 +2463,10 @@ public sealed class LegacyOperatorController
return await OpenPendingFixedSectionManualAsync(cancellationToken)
.ConfigureAwait(false);
}
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
catch (InvalidOperationException)
{
_pendingFixedSectionBatch = null;
@@ -2713,6 +2920,36 @@ public sealed class LegacyOperatorController
return CreateSnapshot();
}
+ public LegacyOperatorSnapshot RequestLegacyComparisonPairImport()
+ {
+ if (!IsComparisonActive() || _comparisonWorkflow is null)
+ {
+ return ReportError("비교 메뉴를 먼저 선택하세요.");
+ }
+
+ if (!_comparisonWorkflow.CanImportLegacyPairs)
+ {
+ _dialog = new LegacyDialogState(
+ "원본 비교쌍 가져오기는 현재 데이터베이스 구성에서 사용할 수 없습니다.");
+ Touch();
+ return CreateSnapshot();
+ }
+
+ if (_dialog is not null)
+ {
+ return CreateSnapshot();
+ }
+
+ _pendingNativeConfirmation = PendingNativeConfirmation.ForComparisonImport(
+ _comparisonWorkflow.Current.Revision);
+ _dialog = new LegacyDialogState(
+ "현재 저장된 비교쌍의 순서는 그대로 유지하고, 원본 프로그램의 비교쌍 중 없는 항목만 뒤에 추가할까요?",
+ "MmoneyCoder",
+ IsConfirmation: true);
+ Touch();
+ return CreateSnapshot();
+ }
+
public LegacyOperatorSnapshot ActivateComparisonAction(string actionId)
{
ArgumentNullException.ThrowIfNull(actionId);
@@ -2844,6 +3081,12 @@ public sealed class LegacyOperatorController
nxtSession,
cancellationToken).ConfigureAwait(false);
_themeActivationListLoaded = true;
+ _statusMessage = _themeSnapshot.SearchIsTruncated
+ ? $"테마 검색 결과가 최대 {LegacyThemeWorkflow.MaximumSearchResults:N0}건으로 제한되었습니다. 검색어를 좁혀 주세요."
+ : $"테마 {_themeSnapshot.SearchResults.Count:N0}건을 찾았습니다.";
+ _statusKind = _themeSnapshot.SearchIsTruncated
+ ? LegacyOperatorStatusKind.Warning
+ : LegacyOperatorStatusKind.Information;
Touch();
return CreateSnapshot();
}
@@ -3246,13 +3489,16 @@ public sealed class LegacyOperatorController
public LegacyOperatorSnapshot DeleteAllPlaylistRows()
{
- if (_playlist.Count == 0)
+ if (_dialog is not null)
{
return CreateSnapshot();
}
- _playlist.Clear();
- ResetPlaylistSelection();
+ _pendingNativeConfirmation = PendingNativeConfirmation.ForPlaylistDeleteAll(
+ _playlist.Select(static row => row.RowId).ToArray());
+ _dialog = new LegacyDialogState(
+ "송출 리스트를 모두 삭제하겠습니까?",
+ IsConfirmation: true);
Touch();
return CreateSnapshot();
}
@@ -3423,37 +3669,31 @@ public sealed class LegacyOperatorController
var namedPlaylist = await _namedPlaylistWorkflow.LoadSelectedAsync(
cancellationToken).ConfigureAwait(false);
var loadedRows = namedPlaylist.Rows
- .Select(row =>
- {
- try
- {
- return CreateNamedPlaylistOperatorRow(row);
- }
- catch (InvalidDataException exception)
- {
- exception.Data["NamedPlaylistItemIndex"] = row.ItemIndex;
- throw;
- }
- })
+ .Select(CreateNamedPlaylistOperatorRow)
.ToArray();
_playlist.Clear();
_playlist.AddRange(loadedRows);
ResetPlaylistSelection();
SelectInitialPlaylistCandidate();
+ _dialog = null;
ApplyNamedPlaylistStatus(namedPlaylist);
+ var unresolvedCount = loadedRows.Count(row => !row.IsSceneResolved);
+ if (unresolvedCount > 0)
+ {
+ _statusMessage =
+ $"DB 재생목록을 불러왔습니다. 장면 이름을 찾지 못한 {unresolvedCount}개 행은 해당 행을 송출할 때만 차단됩니다.";
+ _statusKind = LegacyOperatorStatusKind.Warning;
+ }
}
catch (OperationCanceledException)
{
throw;
}
- catch (InvalidDataException exception)
+ catch (InvalidDataException)
{
- var rowNumber = exception.Data["NamedPlaylistItemIndex"] is int itemIndex
- ? $" {itemIndex + 1}행에"
- : string.Empty;
_dialog = new LegacyDialogState(
- $"DB 재생목록{rowNumber} 현재 버전에서 안전하게 식별할 수 없는 컷이 있어 기존 송출 목록을 유지했습니다.");
+ "DB 재생목록의 저장 형식을 읽을 수 없어 기존 송출 목록을 유지했습니다.");
_statusMessage = _dialog.Message;
_statusKind = LegacyOperatorStatusKind.Error;
}
@@ -3502,7 +3742,9 @@ public sealed class LegacyOperatorController
StringComparison.Ordinal) &&
loaded.NamedPlaylist?.ReadStatus == LegacyNamedPlaylistReadStatus.Ready &&
loaded.NamedPlaylist?.DocumentFreshness == LegacyNamedPlaylistFreshness.Fresh &&
- loaded.StatusKind == LegacyOperatorStatusKind.Information;
+ (loaded.StatusKind is LegacyOperatorStatusKind.Information or
+ LegacyOperatorStatusKind.Warning) &&
+ loaded.Dialog is null;
CompleteCommand(command, definitionId, succeeded);
Touch();
return CreateSnapshot();
@@ -3558,16 +3800,12 @@ public sealed class LegacyOperatorController
throw new InvalidOperationException("The playlist is too large to save.");
}
- // DC_LIST/PLAY_LIST compatibility is exactly the original seven visible/hidden
- // fields. Do not persist the newer internal scene selection or scene alias.
+ // DC_LIST/PLAY_LIST compatibility remains the original seven fields.
+ // Closed native theme rows project their exact DB title into the legacy
+ // subject field because ExactSubject is intentionally not an eighth field.
var rows = _playlist.Select(row =>
{
- var persistedSelection = new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection(
- row.MarketText,
- row.StockName,
- row.GraphicType,
- row.Subtype,
- row.StockCode);
+ var persistedSelection = CreateNamedPlaylistStoredSelection(row);
var entry = new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacyPlaylistEntry(
row.RowId,
row.SceneAlias,
@@ -3603,6 +3841,7 @@ public sealed class LegacyOperatorController
public async Task SaveCurrentNamedPlaylistToAsync(
string definitionId,
+ bool showSavedAlert = false,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(definitionId);
@@ -3630,6 +3869,13 @@ public sealed class LegacyOperatorController
saved.NamedPlaylist?.LastMutationOutcome is
LegacyNamedPlaylistMutationOutcome.CommittedFresh or
LegacyNamedPlaylistMutationOutcome.CommittedOptimistic;
+ if (succeeded && showSavedAlert)
+ {
+ // PList displayed this one-button MessageBox after the confirmed save.
+ // Keep it in authoritative state so WebView focus/modal behavior is the
+ // same as every other MmoneyCoder dialog.
+ _dialog = new LegacyDialogState("저장되었습니다");
+ }
CompleteCommand(command, definitionId, succeeded);
Touch();
return CreateSnapshot();
@@ -3696,6 +3942,25 @@ public sealed class LegacyOperatorController
pending.ComparisonRevision,
cancellationToken)
.ConfigureAwait(false),
+ PendingNativeConfirmationKind.ImportLegacyComparisonPairs =>
+ await ExecuteConfirmedComparisonImportAsync(
+ pending.ComparisonRevision,
+ cancellationToken)
+ .ConfigureAwait(false),
+ PendingNativeConfirmationKind.DeleteAllPlaylistRows =>
+ ExecuteConfirmedPlaylistDeleteAll(pending.PlaylistRowIds!),
+ PendingNativeConfirmationKind.DeleteManualFinancial =>
+ await ExecuteConfirmedManualFinancialDeleteAsync(
+ all: false,
+ pending.ManualFinancialRevision,
+ cancellationToken).ConfigureAwait(false),
+ PendingNativeConfirmationKind.DeleteAllManualFinancial =>
+ await ExecuteConfirmedManualFinancialDeleteAsync(
+ all: true,
+ pending.ManualFinancialRevision,
+ cancellationToken).ConfigureAwait(false),
+ PendingNativeConfirmationKind.DeleteAllManualViItems =>
+ ExecuteConfirmedManualViDeleteAll(pending.ManualViItemIds!),
_ => throw new InvalidOperationException("Unknown native confirmation kind.")
};
}
@@ -3729,6 +3994,82 @@ public sealed class LegacyOperatorController
return CreateSnapshot();
}
+ private LegacyOperatorSnapshot ExecuteConfirmedPlaylistDeleteAll(
+ IReadOnlyList expectedRowIds)
+ {
+ var currentRowIds = _playlist.Select(static row => row.RowId).ToArray();
+ if (!currentRowIds.SequenceEqual(expectedRowIds, StringComparer.Ordinal))
+ {
+ return ReportError(
+ "송출 리스트가 변경되어 전체 삭제를 시작하지 않았습니다.");
+ }
+
+ _playlist.Clear();
+ ResetPlaylistSelection();
+ Touch();
+ return CreateSnapshot();
+ }
+
+ private async Task ExecuteConfirmedManualFinancialDeleteAsync(
+ bool all,
+ long expectedRevision,
+ CancellationToken cancellationToken)
+ {
+ if (!TryGetManualFinancial(out var workflow, out var snapshot) ||
+ snapshot.Revision != expectedRevision)
+ {
+ return ReportError(
+ "수동 재무 삭제 대상이 변경되어 DB 삭제를 시작하지 않았습니다.");
+ }
+
+ try
+ {
+ LegacyManualFinancialWriteRequest request = all
+ ? workflow.CreateDeleteAllRequest(
+ snapshot,
+ snapshot.Definition.DeleteAllConfirmationToken)
+ : workflow.CreateDeleteRequest(snapshot);
+ _manualFinancialSnapshot = await workflow.ExecuteAsync(
+ snapshot,
+ request,
+ cancellationToken).ConfigureAwait(false);
+ ApplyManualFinancialResultStatus(
+ _manualFinancialSnapshot,
+ all ? "전체 삭제" : "삭제");
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+
+ private LegacyOperatorSnapshot ExecuteConfirmedManualViDeleteAll(
+ IReadOnlyList expectedItemIds)
+ {
+ if (_manualListsWorkflow is null)
+ {
+ return ReportError("VI 수동 목록 기능을 사용할 수 없습니다.");
+ }
+
+ var current = _manualListsWorkflow.Current;
+ var currentItemIds = current.ViItems.Select(static item => item.ItemId).ToArray();
+ if (!current.IsOpen || current.Screen != LegacyManualListScreen.Vi ||
+ !currentItemIds.SequenceEqual(expectedItemIds, StringComparer.Ordinal))
+ {
+ return ReportError(
+ "VI 목록이 변경되어 전체 삭제를 시작하지 않았습니다.");
+ }
+
+ _manualListsWorkflow.DeleteAllViItems();
+ ApplyManualListsStatus();
+ Touch();
+ return CreateSnapshot();
+ }
+
private async Task ExecuteConfirmedThemeDeleteAsync(
MMoneyCoderSharp.Data.ThemeSelectionIdentity identity,
CancellationToken cancellationToken)
@@ -3844,6 +4185,51 @@ public sealed class LegacyOperatorController
return CreateSnapshot();
}
+ private async Task ExecuteConfirmedComparisonImportAsync(
+ long comparisonRevision,
+ CancellationToken cancellationToken)
+ {
+ if (!IsComparisonActive() || _comparisonWorkflow is null ||
+ _comparisonWorkflow.Current.Revision != comparisonRevision)
+ {
+ return ReportError(
+ "비교쌍 목록이 바뀌어 원본 비교쌍 가져오기를 시작하지 않았습니다.");
+ }
+
+ try
+ {
+ await _comparisonWorkflow.ImportLegacyPairsAsync(cancellationToken)
+ .ConfigureAwait(false);
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (LegacyComparisonImportException exception)
+ {
+ _dialog = new LegacyDialogState(exception.Failure switch
+ {
+ LegacyComparisonImportFailure.SourceUnavailable =>
+ "원본 프로그램의 고정된 Data\\종목비교.dat 파일을 읽을 수 없습니다.",
+ LegacyComparisonImportFailure.SourceInvalid =>
+ "원본 종목비교.dat 파일 형식이 올바르지 않아 가져오지 않았습니다.",
+ LegacyComparisonImportFailure.IdentityNotFound =>
+ "원본 비교쌍 중 현재 종목 DB에서 정확히 찾을 수 없는 항목이 있어 아무것도 가져오지 않았습니다.",
+ LegacyComparisonImportFailure.IdentityAmbiguous =>
+ "원본 비교쌍 중 현재 종목 DB에서 하나로 확정할 수 없는 항목이 있어 아무것도 가져오지 않았습니다.",
+ LegacyComparisonImportFailure.DatabaseDataInvalid =>
+ "현재 종목 DB 조회 결과가 안전한 식별 형식과 일치하지 않아 아무것도 가져오지 않았습니다.",
+ _ => "원본 비교쌍을 안전하게 가져오지 못했습니다."
+ });
+ Touch();
+ return CreateSnapshot();
+ }
+ catch (InvalidOperationException exception)
+ {
+ _dialog = new LegacyDialogState(exception.Message);
+ Touch();
+ return CreateSnapshot();
+ }
+ }
+
public LegacyOperatorSnapshot ReportError(string message)
{
_statusMessage = message ?? string.Empty;
@@ -4199,11 +4585,47 @@ public sealed class LegacyOperatorController
return new MMoneyCoderSharp.Data.NamedPlaylistPageState(currentPage, totalPages);
}
+ private static CoreSceneSelection CreateNamedPlaylistStoredSelection(
+ LegacyOperatorPlaylistRow row)
+ {
+ var native = row.PlayoutSelection;
+ if (native?.ExactSubject is { } exactTitle &&
+ native.GroupCode is "PAGED_THEME" or "PAGED_NXT_THEME")
+ {
+ var isNxt = native.GroupCode == "PAGED_NXT_THEME";
+ var expectedMarket = isNxt ? "테마_NXT" : "테마";
+ if (!string.Equals(row.MarketText, expectedMarket, StringComparison.Ordinal) ||
+ !string.Equals(native.DataCode, row.StockCode, StringComparison.Ordinal) ||
+ native.DataCode.Length is < 3 or > 8 ||
+ native.DataCode.Any(character => !char.IsAsciiDigit(character)) ||
+ !IsSafeStoredThemeTitle(exactTitle, 128))
+ {
+ throw new InvalidOperationException(
+ "The native theme row has an invalid exact stored identity.");
+ }
+
+ return new CoreSceneSelection(
+ row.MarketText,
+ isNxt ? exactTitle + "(NXT)" : exactTitle,
+ row.GraphicType,
+ row.Subtype,
+ row.StockCode);
+ }
+
+ return new CoreSceneSelection(
+ row.MarketText,
+ row.StockName,
+ row.GraphicType,
+ row.Subtype,
+ row.StockCode);
+ }
+
private LegacyOperatorPlaylistRow CreateNamedPlaylistOperatorRow(
LegacyNamedPlaylistRowView row)
{
var selection = row.Selection;
- var sceneAlias = ResolveNamedPlaylistSceneAlias(selection);
+ var sceneAlias = ResolveNamedPlaylistSceneAlias(selection) ??
+ MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacyPlaylistEntry.UnresolvedCutCode;
var playoutSelection = TryCreateNamedFixedSelection(
selection,
out var fixedSelection,
@@ -4211,10 +4633,22 @@ public sealed class LegacyOperatorController
? fixedSelection
: TryCreateNamedStockDetailSelection(selection, out var stockDetailSelection)
? stockDetailSelection
+ : TryCreateNamedLegacyIndustryFixedSelection(
+ selection,
+ out var legacyIndustryFixedSelection)
+ ? legacyIndustryFixedSelection
: TryCreateNamedIndustrySelection(selection, out var industrySelection)
? industrySelection
+ : TryCreateNamedLegacyComparisonSelection(
+ selection,
+ out var legacyComparisonSelection)
+ ? legacyComparisonSelection
: TryCreateNamedComparisonSelection(selection, out var comparisonSelection)
? comparisonSelection
+ : TryCreateNamedThemeSelection(selection, out var themeSelection)
+ ? themeSelection
+ : TryCreateNamedExpertSelection(selection, out var expertSelection)
+ ? expertSelection
: TryCreateNamedManualFinancialSelection(
selection,
out var manualFinancialSelection)
@@ -4249,13 +4683,20 @@ public sealed class LegacyOperatorController
PlayoutSelection: playoutSelection));
}
- private string ResolveNamedPlaylistSceneAlias(
+ private string? ResolveNamedPlaylistSceneAlias(
LegacyNamedPlaylistSelectionView selection)
{
var rawLabel = selection.Subtype.Length == 0
? selection.GraphicType
: $"{selection.GraphicType}_{selection.Subtype}";
+ // An original fixed-index row can use a stock-market value such as 코스피.
+ // Resolve the exact fixed signature before the broader stock-cut label map.
+ if (TryCreateNamedFixedSelection(selection, out _, out var fixedAction))
+ {
+ return fixedAction!.Code;
+ }
+
// Stock and trading-halt rows retain the original stock-market and cut-label
// columns, so the closed stock catalog remains the authority for their alias.
if (selection.GroupCode is "코스피" or "코스닥" or
@@ -4275,11 +4716,6 @@ public sealed class LegacyOperatorController
}
}
- if (TryCreateNamedFixedSelection(selection, out _, out var fixedAction))
- {
- return fixedAction!.Code;
- }
-
var industryMarket = selection.GroupCode switch
{
"업종_코스피" => MMoneyCoderSharp.Data.IndustryMarket.Kospi,
@@ -4319,7 +4755,7 @@ public sealed class LegacyOperatorController
}
}
- if (string.Equals(selection.GroupCode, "전문가", StringComparison.Ordinal) &&
+ if (selection.GroupCode is "전문가" or "전문가 추천" &&
string.Equals(selection.GraphicType, "5단 표그래프", StringComparison.Ordinal) &&
string.Equals(selection.Subtype, "현재가", StringComparison.Ordinal))
{
@@ -4372,8 +4808,241 @@ public sealed class LegacyOperatorController
return manualFinancial[0].Code;
}
- throw new InvalidDataException(
- "The named-playlist row does not map to a registered scene alias.");
+ return LegacyCutInfoSceneAliasResolver.TryResolve(
+ selection.GroupCode,
+ selection.Subject,
+ selection.GraphicType,
+ selection.Subtype,
+ out var legacyAlias)
+ ? legacyAlias
+ : null;
+ }
+
+ private bool TryCreateNamedLegacyIndustryFixedSelection(
+ LegacyNamedPlaylistSelectionView selection,
+ out CoreSceneSelection normalized)
+ {
+ normalized = null!;
+ var market = selection.GroupCode switch
+ {
+ "업종_코스피" when selection.Subject == "코스피" =>
+ MMoneyCoderSharp.Data.IndustryMarket.Kospi,
+ "업종_코스닥" when selection.Subject == "코스닥" =>
+ MMoneyCoderSharp.Data.IndustryMarket.Kosdaq,
+ _ => (MMoneyCoderSharp.Data.IndustryMarket?)null
+ };
+ if (market is not { } exactMarket || selection.Subtype.Length != 0 ||
+ selection.DataCode.Length != 0)
+ {
+ return false;
+ }
+
+ var marketGroup = exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
+ ? "INDUSTRY_KOSPI"
+ : "INDUSTRY_KOSDAQ";
+ var marketName = exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
+ ? "KOSPI"
+ : "KOSDAQ";
+ normalized = selection.GraphicType switch
+ {
+ "5단 표그래프" => new CoreSceneSelection(
+ exactMarket == MMoneyCoderSharp.Data.IndustryMarket.Kospi
+ ? "PAGED_DOMESTIC_KOSPI"
+ : "PAGED_DOMESTIC_KOSDAQ",
+ "INDEX",
+ string.Empty,
+ "INDUSTRY",
+ _timeProvider.GetLocalNow().ToString(
+ "yyyy-MM-dd",
+ System.Globalization.CultureInfo.InvariantCulture)),
+ "네모그래프" => new CoreSceneSelection(
+ marketGroup,
+ marketName,
+ "네모그래프",
+ string.Empty,
+ string.Empty),
+ "섹터지수" => new CoreSceneSelection(
+ marketGroup,
+ marketName,
+ "SECTOR_INDEX",
+ string.Empty,
+ string.Empty),
+ _ => null!
+ };
+ return normalized is not null;
+ }
+
+ private static bool TryCreateNamedLegacyComparisonSelection(
+ LegacyNamedPlaylistSelectionView selection,
+ out CoreSceneSelection normalized)
+ {
+ normalized = null!;
+ if (!string.Equals(selection.GroupCode, "지수", StringComparison.Ordinal) ||
+ !string.Equals(selection.GraphicType, "2열판", StringComparison.Ordinal) ||
+ selection.DataCode.Length != 0)
+ {
+ return false;
+ }
+
+ var actionId = selection.Subtype switch
+ {
+ "" => "two-column-current",
+ "예상체결" => "two-column-expected",
+ _ => null
+ };
+ var names = selection.Subject.Split(',', StringSplitOptions.None);
+ if (actionId is null || names.Length != 2)
+ {
+ return false;
+ }
+
+ var first = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget(
+ new LegacyComparisonStoredTargetReference(names[0], string.Empty));
+ var second = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget(
+ new LegacyComparisonStoredTargetReference(names[1], string.Empty));
+ if (first is null || second is null)
+ {
+ return false;
+ }
+
+ try
+ {
+ normalized = LegacyComparisonWorkflowCatalog.Materialize(
+ actionId,
+ first,
+ second,
+ "legacy-named-restore").Selection;
+ return true;
+ }
+ catch (InvalidOperationException)
+ {
+ return false;
+ }
+ }
+
+ private bool TryCreateNamedThemeSelection(
+ LegacyNamedPlaylistSelectionView selection,
+ out CoreSceneSelection normalized)
+ {
+ normalized = null!;
+ var isNxt = string.Equals(selection.GroupCode, "테마_NXT", StringComparison.Ordinal);
+ if (!isNxt && !string.Equals(selection.GroupCode, "테마", StringComparison.Ordinal) ||
+ selection.DataCode.Length is < 3 or > 8 ||
+ selection.DataCode.Any(character => !char.IsAsciiDigit(character)))
+ {
+ return false;
+ }
+
+ var subtype = selection.Subtype switch
+ {
+ "테마-현재가(입력순)" => (Value: "CURRENT", Sort: "INPUT_ORDER"),
+ "테마-현재가(상승률순)" => (Value: "CURRENT", Sort: "GAIN_DESC"),
+ "테마-현재가(하락률순)" => (Value: "CURRENT", Sort: "GAIN_ASC"),
+ "테마-현재가" => (Value: "CURRENT", Sort: "GAIN_ASC"),
+ "테마-예상체결가(입력순)" => (Value: "EXPECTED", Sort: "INPUT_ORDER"),
+ "테마-예상체결가(상승률순)" => (Value: "EXPECTED", Sort: "GAIN_DESC"),
+ "테마-예상체결가(하락률순)" => (Value: "EXPECTED", Sort: "GAIN_ASC"),
+ "테마-예상체결가" => (Value: "EXPECTED", Sort: "GAIN_ASC"),
+ _ => default
+ };
+ if (subtype.Value is null || isNxt && subtype.Value != "CURRENT" ||
+ selection.GraphicType switch
+ {
+ "5단 표그래프" => false,
+ "6종목 현재가" or "12종목 현재가" => subtype.Value != "CURRENT",
+ _ => true
+ })
+ {
+ return false;
+ }
+
+ if (isNxt)
+ {
+ const string suffix = "(NXT)";
+ if (!selection.Subject.EndsWith(suffix, StringComparison.Ordinal) ||
+ selection.Subject.Length == suffix.Length)
+ {
+ return false;
+ }
+
+ var exactTitle = selection.Subject[..^suffix.Length];
+ if (!IsSafeStoredThemeTitle(exactTitle, 128))
+ {
+ return false;
+ }
+
+ normalized = new CoreSceneSelection(
+ "PAGED_NXT_THEME",
+ selection.Subject,
+ _timeProvider.GetLocalNow().Hour <= 12 ? "PRE_MARKET" : "AFTER_MARKET",
+ subtype.Sort,
+ selection.DataCode,
+ exactTitle);
+ return true;
+ }
+
+ if (!IsSafeStoredThemeTitle(selection.Subject, 128))
+ {
+ return false;
+ }
+
+ normalized = new CoreSceneSelection(
+ "PAGED_THEME",
+ selection.Subject,
+ subtype.Sort,
+ subtype.Value,
+ selection.DataCode,
+ selection.Subject);
+ return true;
+ }
+
+ private static bool IsSafeStoredThemeTitle(string value, int maximumLength) =>
+ value.Length is >= 1 &&
+ value.Length <= maximumLength &&
+ !string.IsNullOrWhiteSpace(value) &&
+ !value.Contains('^') &&
+ !value.Any(character =>
+ char.IsControl(character) ||
+ char.IsSurrogate(character) ||
+ character == '\uFFFD' ||
+ System.Globalization.CharUnicodeInfo.GetUnicodeCategory(character) is
+ System.Globalization.UnicodeCategory.Format or
+ System.Globalization.UnicodeCategory.LineSeparator or
+ System.Globalization.UnicodeCategory.ParagraphSeparator);
+
+ private static bool TryCreateNamedExpertSelection(
+ LegacyNamedPlaylistSelectionView selection,
+ out CoreSceneSelection normalized)
+ {
+ normalized = null!;
+ if (selection.GroupCode is not ("전문가" or "전문가 추천") ||
+ !string.Equals(selection.GraphicType, "5단 표그래프", StringComparison.Ordinal) ||
+ !string.Equals(selection.Subtype, "현재가", StringComparison.Ordinal) ||
+ selection.DataCode.Length != 4 ||
+ selection.DataCode.Any(character => !char.IsAsciiDigit(character)))
+ {
+ return false;
+ }
+
+ try
+ {
+ LegacyComparisonValueGuard.RequireText(
+ selection.Subject,
+ 128,
+ nameof(selection.Subject));
+ }
+ catch (ArgumentException)
+ {
+ return false;
+ }
+
+ normalized = new CoreSceneSelection(
+ "PAGED_EXPERT",
+ selection.Subject,
+ "INPUT_ORDER",
+ "CURRENT",
+ selection.DataCode);
+ return true;
}
private bool TryCreateNamedFixedSelection(
@@ -4388,6 +5057,9 @@ public sealed class LegacyOperatorController
"해외지수" => LegacyFixedMarket.Overseas,
"환율" => LegacyFixedMarket.Exchange,
"주요지수" => LegacyFixedMarket.Index,
+ _ when selection.DataCode.Length == 0 &&
+ string.Equals(selection.Subject, "지수", StringComparison.Ordinal) =>
+ LegacyFixedMarket.Index,
_ => (LegacyFixedMarket?)null
};
if (fixedMarket is not { } market)
@@ -4397,9 +5069,8 @@ public sealed class LegacyOperatorController
var matches = _fixedCatalog.GetActions(market)
.Where(candidate =>
- string.Equals(candidate.Label, selection.Subject, StringComparison.Ordinal) &&
- string.Equals(candidate.Section, selection.GraphicType, StringComparison.Ordinal) &&
- string.Equals(candidate.Detail, selection.Subtype, StringComparison.Ordinal))
+ MatchesCurrentNamedFixedSelection(candidate, selection) ||
+ MatchesLegacyNamedFixedSelection(candidate, selection))
.Take(2)
.ToArray();
if (matches.Length != 1 || !matches[0].Available)
@@ -4408,16 +5079,92 @@ public sealed class LegacyOperatorController
}
action = matches[0];
- var materialized = _fixedCatalog.Materialize(action.Id, DateTimeOffset.Now).Selection;
+ var isCurrentShape = MatchesCurrentNamedFixedSelection(action, selection);
+ var isLegacyShape = MatchesLegacyNamedFixedSelection(action, selection);
+ var materialized = _fixedCatalog.Materialize(
+ action.Id,
+ _timeProvider.GetLocalNow()).Selection;
normalized = new CoreSceneSelection(
materialized.GroupCode,
materialized.Subject,
materialized.GraphicType,
materialized.Subtype,
- selection.DataCode);
+ isLegacyShape && !isCurrentShape
+ ? materialized.DataCode
+ : selection.DataCode);
return true;
}
+ private static bool MatchesCurrentNamedFixedSelection(
+ LegacyFixedAction action,
+ LegacyNamedPlaylistSelectionView selection) =>
+ string.Equals(action.Label, selection.Subject, StringComparison.Ordinal) &&
+ string.Equals(action.Section, selection.GraphicType, StringComparison.Ordinal) &&
+ string.Equals(action.Detail, selection.Subtype, StringComparison.Ordinal);
+
+ private static bool MatchesLegacyNamedFixedSelection(
+ LegacyFixedAction action,
+ LegacyNamedPlaylistSelectionView selection)
+ {
+ var visible = ProjectLegacyFixedVisibleSelection(action);
+ return string.Equals(visible.GroupCode, selection.GroupCode, StringComparison.Ordinal) &&
+ string.Equals(visible.Subject, selection.Subject, StringComparison.Ordinal) &&
+ string.Equals(visible.GraphicType, selection.GraphicType, StringComparison.Ordinal) &&
+ string.Equals(visible.Subtype, selection.Subtype, StringComparison.Ordinal) &&
+ string.Equals(visible.DataCode, selection.DataCode, StringComparison.Ordinal);
+ }
+
+ private static LegacyFixedSelection ProjectLegacyFixedVisibleSelection(
+ LegacyFixedAction action)
+ {
+ var split = action.Label.IndexOf('_');
+ var first = split < 0 ? action.Label : action.Label[..split];
+ var remainder = split < 0 ? string.Empty : action.Label[(split + 1)..];
+ if (action.Market is LegacyFixedMarket.Overseas or LegacyFixedMarket.Exchange)
+ {
+ return new LegacyFixedSelection(
+ action.Market == LegacyFixedMarket.Overseas ? "해외지수" : "환율",
+ first,
+ action.Section,
+ remainder,
+ string.Empty);
+ }
+
+ if (action.Section is "5단 표그래프" or "6종목 현재가" or "12종목 현재가")
+ {
+ return new LegacyFixedSelection(
+ LegacyFixedIndexGroup(action.Label),
+ "지수",
+ action.Section,
+ action.Label,
+ string.Empty);
+ }
+
+ if (string.Equals(action.Section, "매매동향", StringComparison.Ordinal))
+ {
+ return new LegacyFixedSelection(
+ LegacyFixedIndexGroup(action.Label),
+ "지수",
+ first,
+ remainder,
+ string.Empty);
+ }
+
+ return new LegacyFixedSelection(
+ action.Section,
+ "지수",
+ first,
+ remainder,
+ string.Empty);
+ }
+
+ private static string LegacyFixedIndexGroup(string label) =>
+ label.Contains("코스피_NXT", StringComparison.Ordinal) ? "코스피_NXT" :
+ label.Contains("코스닥_NXT", StringComparison.Ordinal) ? "코스닥_NXT" :
+ label.Contains("코스피", StringComparison.Ordinal) ? "코스피" :
+ label.Contains("코스닥", StringComparison.Ordinal) ? "코스닥" :
+ "전체";
+
private static bool TryCreateNamedStockDetailSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
@@ -5025,6 +5772,36 @@ public sealed class LegacyOperatorController
return workflow is not null && snapshot is not null;
}
+ private void ApplyManualFinancialListStatus(
+ LegacyManualFinancialWorkflowSnapshot snapshot)
+ {
+ var warnings = new List();
+ if (snapshot.ListRejectedItemCount > 0)
+ {
+ var displayedLegacyRawCount = snapshot.Rows.Count(row =>
+ !row.IsPlayoutReady);
+ var hiddenUnreadableCount =
+ snapshot.ListRejectedItemCount - displayedLegacyRawCount;
+ warnings.Add(hiddenUnreadableCount == 0
+ ? $"기존 원문 형식 데이터 {displayedLegacyRawCount}행도 " +
+ "목록에 표시되며 선택·편집할 수 있습니다."
+ : $"기존 원문 형식 데이터 {displayedLegacyRawCount}행은 표시했고, " +
+ $"이름을 읽을 수 없는 {hiddenUnreadableCount}행은 제외했습니다.");
+ }
+ if (snapshot.ListIsTruncated)
+ {
+ warnings.Add(
+ $"검색 결과가 {LegacyManualFinancialWorkflow.MaximumListResults}행을 " +
+ "초과해 일부만 표시됩니다.");
+ }
+
+ _statusMessage = $"{snapshot.Definition.Label} 목록을 불러왔습니다." +
+ (warnings.Count == 0 ? string.Empty : " " + string.Join(' ', warnings));
+ _statusKind = warnings.Count == 0
+ ? LegacyOperatorStatusKind.Information
+ : LegacyOperatorStatusKind.Warning;
+ }
+
private void ApplyManualFinancialResultStatus(
LegacyManualFinancialWorkflowSnapshot snapshot,
string operation)
@@ -5054,6 +5831,26 @@ public sealed class LegacyOperatorController
};
}
+ private void ShowManualFinancialSaveDialog(
+ LegacyManualFinancialWorkflowSnapshot snapshot)
+ {
+ // GraphE used a one-button MmoneyCoder MessageBox after every explicit
+ // save attempt. Only a correlated write plus fresh read-back receives
+ // the legacy success wording. Known rollback/read-back/unknown outcomes
+ // retain their already-sanitized, specific status instead of being
+ // mislabeled as a generic database outage.
+ _dialog = snapshot.Status switch
+ {
+ LegacyManualFinancialWorkflowStatus.WriteCommitted =>
+ new LegacyDialogState("저장되었습니다"),
+ LegacyManualFinancialWorkflowStatus.WriteRejected or
+ LegacyManualFinancialWorkflowStatus.ReadBackRequired or
+ LegacyManualFinancialWorkflowStatus.OutcomeUnknown =>
+ new LegacyDialogState(_statusMessage),
+ _ => null
+ };
+ }
+
private void ApplyManualListsStatus()
{
if (_manualListsWorkflow is null)
@@ -5120,9 +5917,7 @@ public sealed class LegacyOperatorController
}
_pendingFixedSectionBatch = null;
- _statusMessage =
- $"{pending.SectionName} 항목 {pending.Items.Count}개를 원본 순서로 추가했습니다.";
- _statusKind = LegacyOperatorStatusKind.Information;
+ ApplyFixedSectionAddedStatus(pending.SectionName, pending.Items);
Touch();
return CreateSnapshot();
}
@@ -5141,7 +5936,10 @@ public sealed class LegacyOperatorController
{
if (item.Materialized is not null)
{
- AppendFixedRow(item.Materialized);
+ AppendFixedRow(
+ item.Materialized,
+ item.InitialPage?.PageText ?? throw new InvalidOperationException(
+ "A materialized fixed-section item has no initial page state."));
}
else if (item.ManualDraft is not null)
{
@@ -5188,6 +5986,125 @@ public sealed class LegacyOperatorController
_ => null
};
+ private async Task ResolveFixedInitialPageAsync(
+ LegacyMaterializedFixedAction materialized,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var pageSize = GetFixedPageSize(materialized.Action);
+ if (pageSize is null)
+ {
+ return FixedInitialPage.SinglePage;
+ }
+
+ if (_fixedPagePlanProvider is null)
+ {
+ return FixedInitialPage.Unavailable;
+ }
+
+ var selection = materialized.Selection;
+ var entry = new CorePlaylistEntry(
+ materialized.Action.Id,
+ materialized.Action.Code,
+ IsEnabled: true,
+ FadeDuration: 6,
+ Selection: new CoreSceneSelection(
+ selection.GroupCode,
+ selection.Subject,
+ selection.GraphicType,
+ selection.Subtype,
+ selection.DataCode));
+ try
+ {
+ var pagePlan = await _fixedPagePlanProvider.CreatePagePlanAsync(
+ entry,
+ cancellationToken).ConfigureAwait(false);
+ ArgumentNullException.ThrowIfNull(pagePlan);
+ var expected = CoreScenePaging.CreatePlan(pagePlan.ItemCount, pageSize.Value);
+ if (!expected.IsValid ||
+ expected.Plan is null ||
+ !string.Equals(
+ pagePlan.BuilderKey,
+ materialized.Action.BuilderKey,
+ StringComparison.Ordinal) ||
+ pagePlan.PageSize != pageSize.Value ||
+ pagePlan.PageCount != expected.Plan.TotalPages ||
+ pagePlan.AccessibleItemCount != expected.Plan.AccessibleItemCount ||
+ pagePlan.IsTruncated != (expected.Plan.ExcludedItemCount > 0))
+ {
+ throw new InvalidOperationException(
+ "The fixed-action page preflight returned an invalid paging plan.");
+ }
+
+ return new FixedInitialPage($"1/{pagePlan.PageCount}", false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception)
+ {
+ // PageN/Nxt_PageN in UC1 caught data-read failures and still appended
+ // the row as 1/0. Keep that exact failure representation; never guess N.
+ return FixedInitialPage.Unavailable;
+ }
+ }
+
+ private static FixedInitialPage CreateFixedInitialPageFallback(
+ LegacyFixedAction action) => GetFixedPageSize(action) is null
+ ? FixedInitialPage.SinglePage
+ : FixedInitialPage.Unavailable;
+
+ private static CoreScenePageSize? GetFixedPageSize(LegacyFixedAction action)
+ {
+ ArgumentNullException.ThrowIfNull(action);
+ var exact = (action.Code, action.BuilderKey) switch
+ {
+ ("5074", "s5074") => CoreScenePageSize.Five,
+ ("5077", "s5077") => CoreScenePageSize.Six,
+ ("5088", "s5088") => CoreScenePageSize.Twelve,
+ _ => (CoreScenePageSize?)null
+ };
+ if (exact is not null)
+ {
+ return exact;
+ }
+
+ if (action.Code is "5074" or "5077" or "5088" ||
+ action.BuilderKey is "s5074" or "s5077" or "s5088")
+ {
+ throw new InvalidOperationException(
+ "The fixed-action paging alias and builder do not match.");
+ }
+
+ return null;
+ }
+
+ private void ApplyFixedActionAddedStatus(
+ LegacyFixedAction action,
+ bool pageCountUnavailable)
+ {
+ _statusMessage = pageCountUnavailable
+ ? $"{action.Label} 항목을 추가했지만 현재 데이터 건수를 읽지 못해 페이지를 1/0으로 표시합니다."
+ : $"{action.Label} 항목을 추가했습니다.";
+ _statusKind = pageCountUnavailable
+ ? LegacyOperatorStatusKind.Warning
+ : LegacyOperatorStatusKind.Information;
+ }
+
+ private void ApplyFixedSectionAddedStatus(
+ string sectionName,
+ IReadOnlyList items)
+ {
+ var unavailableCount = items.Count(item => item.PageCountUnavailable);
+ _statusMessage = unavailableCount == 0
+ ? $"{sectionName} 항목 {items.Count}개를 원본 순서로 추가했습니다."
+ : $"{sectionName} 항목 {items.Count}개를 원본 순서로 추가했습니다. 데이터 건수를 읽지 못한 {unavailableCount}개 항목은 페이지를 1/0으로 표시합니다.";
+ _statusKind = unavailableCount == 0
+ ? LegacyOperatorStatusKind.Information
+ : LegacyOperatorStatusKind.Warning;
+ }
+
private void ApplyOperatorCatalogStatus()
{
if (_operatorCatalogWorkflow is null)
@@ -5223,26 +6140,23 @@ public sealed class LegacyOperatorController
PlayoutSelection: draft.Selection));
}
- private void AppendFixedRow(LegacyMaterializedFixedAction materialized)
+ private void AppendFixedRow(
+ LegacyMaterializedFixedAction materialized,
+ string pageText)
{
+ ArgumentException.ThrowIfNullOrWhiteSpace(pageText);
var action = materialized.Action;
var selection = materialized.Selection;
- var marketText = action.Market switch
- {
- LegacyFixedMarket.Overseas => "해외지수",
- LegacyFixedMarket.Exchange => "환율",
- LegacyFixedMarket.Index => "주요지수",
- _ => throw new ArgumentOutOfRangeException()
- };
+ var visible = ProjectLegacyFixedVisibleSelection(action);
AppendPlaylistRow(new LegacyOperatorPlaylistRow(
$"legacy-fixed-{++_playlistSequence:D8}",
true,
- marketText,
- action.Label,
- action.Section,
- action.Detail,
- "1/1",
- selection.DataCode,
+ visible.GroupCode,
+ visible.Subject,
+ visible.GraphicType,
+ visible.Subtype,
+ pageText,
+ visible.DataCode,
action.Label,
6,
SceneAlias: action.Code,
@@ -5331,7 +6245,8 @@ public sealed class LegacyOperatorController
draft.Selection.Subject,
draft.Selection.GraphicType,
draft.Selection.Subtype,
- draft.Selection.DataCode)));
+ draft.Selection.DataCode,
+ draft.Selection.ExactSubject)));
}
private void AppendComparisonRow(LegacyGenericPlaylistDraft draft)
@@ -5412,9 +6327,9 @@ public sealed class LegacyOperatorController
LegacyOperatorTabId.KosdaqIndustry => "코스닥",
LegacyOperatorTabId.Comparison => "비교",
LegacyOperatorTabId.Theme => "테마",
- LegacyOperatorTabId.OverseasStocks => "해외종목",
+ LegacyOperatorTabId.OverseasStocks => "해외시장",
LegacyOperatorTabId.Expert => "전문가",
- LegacyOperatorTabId.TradingHalt => "정지",
+ LegacyOperatorTabId.TradingHalt => "거래정지",
_ => throw new ArgumentOutOfRangeException(nameof(tabId), tabId, null)
};
@@ -5530,9 +6445,13 @@ public sealed class LegacyOperatorController
private static LegacyOperatorPlaylistRow NormalizeMovingAverageIdentity(
LegacyOperatorPlaylistRow row)
{
- var entry = row.ToPlayoutEntry();
+ var cutCode = row.SceneAlias.Length > 0
+ ? row.SceneAlias
+ : LegacyStockCutCatalog.ResolveCutAlias(
+ row.RawCutLabel,
+ row.StockName.Contains("(NXT)", StringComparison.Ordinal));
if (!MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneMovingAverageOverlay
- .Supports(entry.CutCode))
+ .Supports(cutCode))
{
return row;
}
@@ -5679,14 +6598,25 @@ public sealed class LegacyOperatorController
LegacyManualListScreen Screen,
S5025ManualAudience Audience);
+ private readonly record struct FixedInitialPage(
+ string PageText,
+ bool PageCountUnavailable)
+ {
+ internal static FixedInitialPage SinglePage { get; } = new("1/1", false);
+
+ internal static FixedInitialPage Unavailable { get; } = new("1/0", true);
+ }
+
private sealed class PendingFixedSectionItem
{
internal PendingFixedSectionItem(
LegacyFixedAction action,
- LegacyMaterializedFixedAction materialized)
+ LegacyMaterializedFixedAction materialized,
+ FixedInitialPage initialPage)
{
Action = action;
Materialized = materialized;
+ InitialPage = initialPage;
}
internal PendingFixedSectionItem(
@@ -5701,10 +6631,15 @@ public sealed class LegacyOperatorController
internal LegacyMaterializedFixedAction? Materialized { get; }
+ internal FixedInitialPage? InitialPage { get; }
+
internal FixedManualTarget? ManualTarget { get; }
internal LegacyManualListPlaylistDraft? ManualDraft { get; private set; }
+ internal bool PageCountUnavailable =>
+ InitialPage?.PageCountUnavailable == true;
+
internal bool IsStaged => Materialized is not null || ManualDraft is not null;
internal void StageManual(LegacyManualListPlaylistDraft draft)
@@ -5792,49 +6727,85 @@ public sealed class LegacyOperatorController
DeleteTheme,
DeleteExpert,
ClearThemeDraft,
- DeleteAllComparisonPairs
+ DeleteAllComparisonPairs,
+ ImportLegacyComparisonPairs,
+ DeleteAllPlaylistRows,
+ DeleteManualFinancial,
+ DeleteAllManualFinancial,
+ DeleteAllManualViItems
}
- private sealed record PendingNativeConfirmation(
- PendingNativeConfirmationKind Kind,
- MMoneyCoderSharp.Data.ThemeSelectionIdentity? ThemeIdentity,
- MMoneyCoderSharp.Data.ExpertSelectionIdentity? ExpertIdentity,
- long CatalogRevision,
- long ComparisonRevision)
+ private sealed record PendingNativeConfirmation(PendingNativeConfirmationKind Kind)
{
+ internal MMoneyCoderSharp.Data.ThemeSelectionIdentity? ThemeIdentity { get; init; }
+
+ internal MMoneyCoderSharp.Data.ExpertSelectionIdentity? ExpertIdentity { get; init; }
+
+ internal long CatalogRevision { get; init; }
+
+ internal long ComparisonRevision { get; init; }
+
+ internal IReadOnlyList? PlaylistRowIds { get; init; }
+
+ internal long ManualFinancialRevision { get; init; }
+
+ internal IReadOnlyList? ManualViItemIds { get; init; }
+
internal static PendingNativeConfirmation ForTheme(
MMoneyCoderSharp.Data.ThemeSelectionIdentity identity) =>
- new(
- PendingNativeConfirmationKind.DeleteTheme,
- identity,
- null,
- 0,
- 0);
+ new(PendingNativeConfirmationKind.DeleteTheme)
+ {
+ ThemeIdentity = identity
+ };
internal static PendingNativeConfirmation ForExpert(
MMoneyCoderSharp.Data.ExpertSelectionIdentity identity) =>
- new(
- PendingNativeConfirmationKind.DeleteExpert,
- null,
- identity,
- 0,
- 0);
+ new(PendingNativeConfirmationKind.DeleteExpert)
+ {
+ ExpertIdentity = identity
+ };
internal static PendingNativeConfirmation ForThemeDraftClear(long revision) =>
- new(
- PendingNativeConfirmationKind.ClearThemeDraft,
- null,
- null,
- revision,
- 0);
+ new(PendingNativeConfirmationKind.ClearThemeDraft)
+ {
+ CatalogRevision = revision
+ };
internal static PendingNativeConfirmation ForComparisonDeleteAll(long revision) =>
- new(
- PendingNativeConfirmationKind.DeleteAllComparisonPairs,
- null,
- null,
- 0,
- revision);
+ new(PendingNativeConfirmationKind.DeleteAllComparisonPairs)
+ {
+ ComparisonRevision = revision
+ };
+
+ internal static PendingNativeConfirmation ForComparisonImport(long revision) =>
+ new(PendingNativeConfirmationKind.ImportLegacyComparisonPairs)
+ {
+ ComparisonRevision = revision
+ };
+
+ internal static PendingNativeConfirmation ForPlaylistDeleteAll(
+ IReadOnlyList rowIds) =>
+ new(PendingNativeConfirmationKind.DeleteAllPlaylistRows)
+ {
+ PlaylistRowIds = rowIds
+ };
+
+ internal static PendingNativeConfirmation ForManualFinancialDelete(
+ bool all,
+ long revision) =>
+ new(all
+ ? PendingNativeConfirmationKind.DeleteAllManualFinancial
+ : PendingNativeConfirmationKind.DeleteManualFinancial)
+ {
+ ManualFinancialRevision = revision
+ };
+
+ internal static PendingNativeConfirmation ForManualViDeleteAll(
+ IReadOnlyList itemIds) =>
+ new(PendingNativeConfirmationKind.DeleteAllManualViItems)
+ {
+ ManualViItemIds = itemIds
+ };
}
private void Touch() => _revision++;
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorSettings.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorSettings.cs
index 36c66e2..361c7d9 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorSettings.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorSettings.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
@@ -41,7 +42,8 @@ public enum LegacyOperatorFolderValidationFailure
public sealed record LegacyOperatorValidatedFolder(
string CanonicalPath,
string AbbreviatedPath,
- bool DatabaseIniDetected);
+ bool DatabaseIniDetected,
+ int MissingOptionalExternalAssetCount);
public sealed record LegacyOperatorFolderValidationResult(
LegacyOperatorValidatedFolder? Folder,
@@ -65,7 +67,7 @@ public static class LegacyOperatorFolderValidator
private const string InvalidFolderMessage =
"선택한 폴더를 안전하게 사용할 수 없습니다.";
private const string MissingSceneMessage =
- "선택한 디자인 폴더에 필수 장면 파일이 없습니다.";
+ "완전한 Cuts 폴더가 아닙니다. 필수 장면과 기본 자산을 확인하세요. 기존 설정은 유지됩니다.";
private const string InvalidResourceMessage =
"선택한 설정 폴더의 UI 설정 파일을 검증할 수 없습니다.";
@@ -80,6 +82,53 @@ public static class LegacyOperatorFolderValidator
"종목비교.ini"
];
+ private static readonly IReadOnlyList RequiredSceneFileNameValues =
+ Array.AsReadOnly(LegacySceneRuntimeCoverage.ExpectedCutCodes
+ .Select(code => string.Concat(code, ".t2s"))
+ .ToArray());
+
+ private static readonly IReadOnlyList RequiredBuiltInAssetRelativePathValues =
+ Array.AsReadOnly(new[]
+ {
+ @"images\주유기merge.png",
+ @"images\35752913_l.jpg",
+ @"Images\그림_빨강.png",
+ @"Images\그림_검정.png",
+ @"Images\그림_파랑.png",
+ @"Images\프리마켓.png",
+ @"Images\애프터마켓.png",
+ @"Images\KRX.png",
+ @"Images\NXT.png"
+ });
+
+ private static readonly IReadOnlyList OptionalExternalAssetRelativePathValues =
+ Array.AsReadOnly(new[]
+ {
+ @"Video\큐브배경.vrv",
+ @"Video\20201008_미국.vrv",
+ @"Video\20201008_독일.vrv",
+ @"Video\20201008_영국.vrv",
+ @"Video\20201008_프랑스.vrv",
+ @"Video\20201008_일본.vrv",
+ @"Video\20201008_중국.vrv",
+ @"Video\20201008_홍콩.vrv",
+ @"Video\20201008_대만.vrv",
+ @"Video\20201008_싱가포르.vrv",
+ @"Video\20201008_태국.vrv",
+ @"Video\20201008_필리핀.vrv",
+ @"Video\20201008_말레이시아.vrv",
+ @"Video\20201008_인도네시아.vrv"
+ });
+
+ public static IReadOnlyList RequiredSceneFileNames =>
+ RequiredSceneFileNameValues;
+
+ public static IReadOnlyList RequiredBuiltInAssetRelativePaths =>
+ RequiredBuiltInAssetRelativePathValues;
+
+ public static IReadOnlyList OptionalExternalAssetRelativePaths =>
+ OptionalExternalAssetRelativePathValues;
+
public static bool TryValidate(
LegacyOperatorSettingsFolderKind kind,
string? path,
@@ -106,15 +155,52 @@ public static class LegacyOperatorFolderValidator
}
var databaseIniDetected = false;
+ var missingOptionalExternalAssetCount = 0;
switch (kind)
{
case LegacyOperatorSettingsFolderKind.Scene:
- if (!TryValidateRegularFile(
- Path.Combine(canonicalPath, "5001.t2s"),
- required: true,
- out failure))
+ foreach (var fileName in RequiredSceneFileNameValues)
{
- return Invalid(failure, MissingSceneMessage);
+ if (!TryValidateContainedRegularFile(
+ canonicalPath,
+ fileName,
+ required: true,
+ out _,
+ out failure))
+ {
+ return Invalid(failure, MissingSceneMessage);
+ }
+ }
+
+ foreach (var relativePath in RequiredBuiltInAssetRelativePathValues)
+ {
+ if (!TryValidateContainedRegularFile(
+ canonicalPath,
+ relativePath,
+ required: true,
+ out _,
+ out failure))
+ {
+ return Invalid(failure, MissingSceneMessage);
+ }
+ }
+
+ foreach (var relativePath in OptionalExternalAssetRelativePathValues)
+ {
+ if (!TryValidateContainedRegularFile(
+ canonicalPath,
+ relativePath,
+ required: false,
+ out var detected,
+ out failure))
+ {
+ return Invalid(failure, MissingSceneMessage);
+ }
+
+ if (!detected)
+ {
+ missingOptionalExternalAssetCount++;
+ }
}
break;
@@ -174,7 +260,8 @@ public static class LegacyOperatorFolderValidator
new LegacyOperatorValidatedFolder(
canonicalPath,
AbbreviateForDisplay(canonicalPath),
- databaseIniDetected),
+ databaseIniDetected,
+ missingOptionalExternalAssetCount),
LegacyOperatorFolderValidationFailure.None,
WarningMessage: null);
}
@@ -319,16 +406,18 @@ public static class LegacyOperatorFolderValidator
bool required,
out LegacyOperatorFolderValidationFailure failure)
{
+ return TryValidateRegularFile(path, required, out _, out failure);
+ }
+
+ private static bool TryValidateRegularFile(
+ string path,
+ bool required,
+ out bool detected,
+ out LegacyOperatorFolderValidationFailure failure)
+ {
+ detected = false;
try
{
- if (!File.Exists(path))
- {
- failure = required
- ? LegacyOperatorFolderValidationFailure.RequiredFileMissing
- : LegacyOperatorFolderValidationFailure.None;
- return !required;
- }
-
var attributes = File.GetAttributes(path);
if ((attributes & (FileAttributes.Directory |
FileAttributes.ReparsePoint |
@@ -338,9 +427,91 @@ public static class LegacyOperatorFolderValidator
return false;
}
+ if (new FileInfo(path).Length <= 0)
+ {
+ failure = LegacyOperatorFolderValidationFailure.RequiredFileUnsafe;
+ return false;
+ }
+
+ detected = true;
failure = LegacyOperatorFolderValidationFailure.None;
return true;
}
+ catch (Exception exception) when (
+ exception is FileNotFoundException or DirectoryNotFoundException)
+ {
+ failure = required
+ ? LegacyOperatorFolderValidationFailure.RequiredFileMissing
+ : LegacyOperatorFolderValidationFailure.None;
+ return !required;
+ }
+ catch (Exception exception) when (
+ exception is ArgumentException or IOException or UnauthorizedAccessException or
+ NotSupportedException or System.Security.SecurityException)
+ {
+ failure = LegacyOperatorFolderValidationFailure.FileUnavailable;
+ return false;
+ }
+ }
+
+ private static bool TryValidateContainedRegularFile(
+ string root,
+ string relativePath,
+ bool required,
+ out bool detected,
+ out LegacyOperatorFolderValidationFailure failure)
+ {
+ detected = false;
+ try
+ {
+ if (string.IsNullOrWhiteSpace(relativePath) ||
+ Path.IsPathFullyQualified(relativePath) ||
+ relativePath.Any(char.IsControl))
+ {
+ failure = LegacyOperatorFolderValidationFailure.InvalidPath;
+ return false;
+ }
+
+ var fullPath = Path.GetFullPath(Path.Combine(root, relativePath));
+ var rootPrefix = string.Concat(
+ Path.TrimEndingDirectorySeparator(root),
+ Path.DirectorySeparatorChar);
+ if (!fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ failure = LegacyOperatorFolderValidationFailure.InvalidPath;
+ return false;
+ }
+
+ var relativeDirectory = Path.GetDirectoryName(relativePath);
+ if (!string.IsNullOrEmpty(relativeDirectory))
+ {
+ var current = root;
+ foreach (var segment in relativeDirectory.Split(
+ [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
+ StringSplitOptions.RemoveEmptyEntries))
+ {
+ current = Path.Combine(current, segment);
+ try
+ {
+ var attributes = File.GetAttributes(current);
+ if ((attributes & FileAttributes.Directory) == 0 ||
+ (attributes & (FileAttributes.ReparsePoint |
+ FileAttributes.Device)) != 0)
+ {
+ failure = LegacyOperatorFolderValidationFailure.RequiredFileUnsafe;
+ return false;
+ }
+ }
+ catch (Exception exception) when (
+ exception is FileNotFoundException or DirectoryNotFoundException)
+ {
+ break;
+ }
+ }
+ }
+
+ return TryValidateRegularFile(fullPath, required, out detected, out failure);
+ }
catch (Exception exception) when (
exception is ArgumentException or IOException or UnauthorizedAccessException or
NotSupportedException or System.Security.SecurityException)
@@ -367,6 +538,12 @@ public static class LegacyOperatorFolderValidator
return false;
}
+ if (new FileInfo(path).Length <= 0)
+ {
+ failure = LegacyOperatorFolderValidationFailure.RequiredFileUnsafe;
+ return false;
+ }
+
detected = true;
failure = LegacyOperatorFolderValidationFailure.None;
return true;
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs
index 28d0f98..207b698 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs
@@ -108,6 +108,7 @@ public sealed class LegacyOverseasStockSearchSnapshot
DateTimeOffset? retrievedAt,
IReadOnlyList results,
bool isTruncated,
+ int isolatedInvalidRowCount,
LegacyOverseasStockSearchRow? selected,
string error)
{
@@ -116,6 +117,7 @@ public sealed class LegacyOverseasStockSearchSnapshot
RetrievedAt = retrievedAt;
Results = results;
IsTruncated = isTruncated;
+ IsolatedInvalidRowCount = isolatedInvalidRowCount;
Selected = selected;
Error = error;
}
@@ -132,6 +134,8 @@ public sealed class LegacyOverseasStockSearchSnapshot
public bool IsTruncated { get; }
+ public int IsolatedInvalidRowCount { get; }
+
public LegacyOverseasStockSearchRow? Selected { get; }
public string? SelectedId => Selected?.SelectionId;
@@ -227,7 +231,7 @@ public sealed class LegacyOverseasSelectionWorkflow
// UC5 itself did not impose a UI result cap. The database boundary remains
// bounded, but use that boundary's full capacity and expose truncation.
public const int DefaultMaximumResults = LegacyOverseasStockSearchService.MaximumResults;
- public const int MaximumResults = 500;
+ public const int MaximumResults = LegacyOverseasStockSearchService.MaximumResults;
public const int MaximumQueryLength = 64;
private const int DefaultFadeDuration = 6;
@@ -263,6 +267,7 @@ public sealed class LegacyOverseasSelectionWorkflow
private DateTimeOffset? _stockRetrievedAt;
private IReadOnlyList _stockResults = NoStockResults;
private bool _stockIsTruncated;
+ private int _stockIsolatedInvalidRowCount;
private LegacyOverseasStockSearchRow? _selectedStock;
private string _stockError = string.Empty;
private long _stockGeneration;
@@ -346,6 +351,7 @@ public sealed class LegacyOverseasSelectionWorkflow
_stockRetrievedAt = null;
_stockResults = NoStockResults;
_stockIsTruncated = false;
+ _stockIsolatedInvalidRowCount = 0;
_selectedStock = null;
_stockError = string.Empty;
Touch();
@@ -486,6 +492,7 @@ public sealed class LegacyOverseasSelectionWorkflow
_stockRetrievedAt = null;
_stockResults = NoStockResults;
_stockIsTruncated = false;
+ _stockIsolatedInvalidRowCount = 0;
_selectedStock = null;
_stockError = string.Empty;
Touch();
@@ -523,6 +530,7 @@ public sealed class LegacyOverseasSelectionWorkflow
_stockRetrievedAt = result.RetrievedAt;
_stockResults = validated;
_stockIsTruncated = result.IsTruncated;
+ _stockIsolatedInvalidRowCount = result.IsolatedInvalidRowCount;
// Match UC5's FarPoint RowMode active-row transition after the
// first result row is inserted.
_selectedStock = validated.Count == 0 ? null : validated[0];
@@ -558,6 +566,7 @@ public sealed class LegacyOverseasSelectionWorkflow
_stockRetrievedAt = null;
_stockResults = NoStockResults;
_stockIsTruncated = false;
+ _stockIsolatedInvalidRowCount = 0;
_selectedStock = null;
_stockError = StockError;
Touch();
@@ -812,6 +821,7 @@ public sealed class LegacyOverseasSelectionWorkflow
_stockRetrievedAt,
_stockResults,
_stockIsTruncated,
+ _stockIsolatedInvalidRowCount,
_selectedStock,
_stockError),
_movingAverages,
@@ -835,18 +845,14 @@ public sealed class LegacyOverseasSelectionWorkflow
throw InvalidData("industry result bound");
}
- var koreanNames = new HashSet(StringComparer.Ordinal);
- var inputNames = new HashSet(StringComparer.Ordinal);
- var symbols = new HashSet(StringComparer.Ordinal);
+ var identities = new HashSet();
for (var index = 0; index < items.Length; index++)
{
var item = items[index] ?? throw InvalidData("industry identity");
if (!IsSafeName(item.KoreanName) ||
!IsSafeName(item.InputName) ||
!IsSafeSymbol(item.Symbol) ||
- !koreanNames.Add(item.KoreanName) ||
- !inputNames.Add(item.InputName) ||
- !symbols.Add(item.Symbol) ||
+ !identities.Add(item) ||
index > 0 && CompareIndustry(items[index - 1], item) > 0)
{
throw InvalidData("industry identity or ordering");
@@ -867,7 +873,11 @@ public sealed class LegacyOverseasSelectionWorkflow
long generation)
{
if (result is null || result.Items is null || result.RetrievedAt == default ||
- !string.Equals(result.Query, expectedQuery, StringComparison.Ordinal))
+ !string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) ||
+ result.IsolatedInvalidRowCount < 0 ||
+ result.IsolatedInvalidRowCount >
+ LegacyOverseasStockSearchService.MaximumIsolatedRows + 1 ||
+ result.IsolatedInvalidRowCount > 0 && !result.IsTruncated)
{
throw InvalidData("stock search envelope");
}
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs
index fe03949..8ab4d33 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs
@@ -25,6 +25,11 @@ public sealed record LegacyOperatorPlaylistRow(
LegacySceneSelection? PlayoutSelection = null,
bool IsActive = false)
{
+ public bool IsSceneResolved => !string.Equals(
+ SceneAlias,
+ LegacyPlaylistEntry.UnresolvedCutCode,
+ StringComparison.Ordinal);
+
public LegacyPlaylistEntry ToPlayoutEntry()
{
var cutCode = SceneAlias.Length > 0
@@ -46,7 +51,7 @@ public sealed record LegacyOperatorPlaylistRow(
CreatePageNavigation());
}
- private LegacyPlaylistPageNavigation CreatePageNavigation()
+ private LegacyPlaylistPageNavigation? CreatePageNavigation()
{
var pageSize = GraphicType switch
{
@@ -78,8 +83,13 @@ public sealed record LegacyOperatorPlaylistRow(
totalPages is < 1 or > ScenePaging.MaximumPageCount ||
currentPage < 1 || currentPage > totalPages)
{
- throw new InvalidOperationException(
- "A PageN playlist row must use current/total with a total from 1 through 20.");
+ // MainForm persisted the visible PageN text verbatim, including 1/0 when
+ // the backing query returned no rows. Loading that historical value must
+ // not reject the complete named playlist. A null navigation contract keeps
+ // the row lossless and lets the scene provider derive current paging if the
+ // operator later selects this row for PREPARE/TAKE IN. Empty or otherwise
+ // invalid current data is then rejected for this row only, before dispatch.
+ return null;
}
return new LegacyPlaylistPageNavigation(pageSize, totalPages);
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs
index e8a8016..0e243de 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs
@@ -74,9 +74,7 @@ public sealed class LegacyThemeSearchRow
Identity = identity;
Market = identity.Market;
Session = identity.Session;
- DisplayTitle = identity.Market == ThemeMarket.Nxt
- ? $"{identity.ThemeTitle}(NXT)"
- : identity.ThemeTitle;
+ DisplayTitle = LegacyThemeWorkflow.DisplayTitle(identity);
Source = source;
}
@@ -147,9 +145,7 @@ public sealed class LegacyThemeWorkflowSnapshot
: new LegacySelectedThemeSummary(
selectedIdentity.Market,
selectedIdentity.Session,
- selectedIdentity.Market == ThemeMarket.Nxt
- ? $"{selectedIdentity.ThemeTitle}(NXT)"
- : selectedIdentity.ThemeTitle,
+ LegacyThemeWorkflow.DisplayTitle(selectedIdentity),
PreviewItems.Count,
previewIsTruncated);
ActionStates = LegacyThemeWorkflow.CreateActionStates(
@@ -194,7 +190,8 @@ public sealed record LegacyThemePlaylistSelection(
string Subject,
string GraphicType,
string Subtype,
- string DataCode);
+ string DataCode,
+ string? ExactSubject = null);
public sealed record LegacyThemePagePlan(
int PreviewItemCount,
@@ -237,7 +234,7 @@ public sealed class LegacyThemeWorkflowDataException : Exception
///
public sealed class LegacyThemeWorkflow
{
- public const int MaximumSearchResults = 100;
+ public const int MaximumSearchResults = LegacyThemeSelectionService.MaximumResults;
public const int MaximumPreviewItems = 240;
public const int MaximumPageCount = 20;
@@ -452,9 +449,7 @@ public sealed class LegacyThemeWorkflow
var sortId = SortId(snapshot.Sort);
var valueKind = ValueKindId(action.ValueKind);
- var displayTitle = identity.Market == ThemeMarket.Nxt
- ? $"{identity.ThemeTitle}(NXT)"
- : identity.ThemeTitle;
+ var displayTitle = DisplayTitle(identity);
var playlistGraphicType = PlaylistGraphicType(action);
var playlistSubtype =
$"테마-{ValueKindLabel(action.ValueKind)}({SortLabel(snapshot.Sort)})";
@@ -464,13 +459,15 @@ public sealed class LegacyThemeWorkflow
identity.ThemeTitle,
sortId,
valueKind,
- identity.ThemeCode)
+ identity.ThemeCode,
+ identity.ThemeTitle)
: new LegacyThemePlaylistSelection(
"PAGED_NXT_THEME",
displayTitle,
SessionId(CurrentNxtSession),
sortId,
- identity.ThemeCode);
+ identity.ThemeCode,
+ identity.ThemeTitle);
var maximumItems = checked(action.PageSize * MaximumPageCount);
var accessibleItems = Math.Min(snapshot.PreviewItems.Count, maximumItems);
@@ -683,13 +680,18 @@ public sealed class LegacyThemeWorkflow
identity.Session != ThemeSession.NotApplicable ||
identity.Market == ThemeMarket.Nxt && identity.Session != requestedNxtSession ||
!IsThemeCode(identity.ThemeCode) ||
- !IsCanonicalText(identity.ThemeTitle, 128) ||
- identity.ThemeTitle.Contains("(NXT)", StringComparison.Ordinal))
+ !IsStoredThemeTitle(identity.ThemeTitle, 128))
{
throw InvalidData("theme identity");
}
}
+ internal static string DisplayTitle(ThemeSelectionIdentity identity) =>
+ identity.Market == ThemeMarket.Nxt &&
+ !identity.ThemeTitle.EndsWith("(NXT)", StringComparison.Ordinal)
+ ? $"{identity.ThemeTitle}(NXT)"
+ : identity.ThemeTitle;
+
private static string NormalizeQuery(string query)
{
ArgumentNullException.ThrowIfNull(query);
@@ -802,6 +804,12 @@ public sealed class LegacyThemeWorkflow
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
IsSafeText(value);
+ private static bool IsStoredThemeTitle(string value, int maximumLength) =>
+ value is not null && value.Length is >= 1 &&
+ value.Length <= maximumLength &&
+ !string.IsNullOrWhiteSpace(value) &&
+ IsSafeText(value);
+
private static bool IsSafeText(string value)
{
foreach (var rune in value.EnumerateRunes())
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualFinancial/LegacyManualFinancialWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualFinancial/LegacyManualFinancialWorkflow.cs
index 0f2f8c0..1a3c25a 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualFinancial/LegacyManualFinancialWorkflow.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualFinancial/LegacyManualFinancialWorkflow.cs
@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Collections.ObjectModel;
+using System.Globalization;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
@@ -77,15 +78,91 @@ public sealed class LegacyManualFinancialListRow
{
ResultId = resultId;
Snapshot = snapshot;
+ StockName = snapshot.Record.Identity.StockName;
+ PreviewValues = CreatePreviewValues(snapshot.Record);
+ }
+
+ internal LegacyManualFinancialListRow(
+ string resultId,
+ ManualFinancialRawSnapshot snapshot)
+ {
+ ResultId = resultId;
+ RawSnapshot = snapshot;
+ Snapshot = snapshot.StrictSnapshot;
+ StockName = snapshot.Record.Identity.StockName;
+ PreviewValues = Array.AsReadOnly(
+ new[] { StockName }
+ .Concat(snapshot.Record.StorageValues)
+ .ToArray());
+ }
+
+ internal LegacyManualFinancialListRow(string resultId, string stockName)
+ {
+ ResultId = resultId;
+ StockName = stockName;
+ PreviewValues = Array.AsReadOnly([stockName]);
}
public string ResultId { get; }
- public string StockName => Snapshot.Record.Identity.StockName;
+ public string StockName { get; }
- public string RowVersion => Snapshot.RowVersion;
+ ///
+ /// Read-only GraphE list projection in the fixed INPUT_* storage-column
+ /// order. It contains operator display values only: no row version, ROWID,
+ /// native row handle, stock code, or database authority is exposed.
+ ///
+ public IReadOnlyList PreviewValues { get; }
- internal ManualFinancialSnapshot Snapshot { get; }
+ public string RowVersion => RawSnapshot?.RowVersion ?? Snapshot?.RowVersion ?? string.Empty;
+
+ public bool IsDataReadable => RawSnapshot is not null || Snapshot is not null;
+
+ public bool IsPlayoutReady => Snapshot is not null;
+
+ internal ManualFinancialSnapshot? Snapshot { get; }
+
+ internal ManualFinancialRawSnapshot? RawSnapshot { get; }
+
+ private static IReadOnlyList CreatePreviewValues(
+ ManualFinancialRecord record)
+ {
+ var values = record switch
+ {
+ ManualRevenueCompositionRecord revenue =>
+ new[] { revenue.Identity.StockName, revenue.BaseDate }
+ .Concat(revenue.Slices.Select(static slice => slice is null
+ ? string.Empty
+ : $"{slice.Label}_{slice.PercentageText}")),
+ ManualGrowthMetricsRecord growth =>
+ new[] { growth.Identity.StockName }
+ .Concat(new[]
+ {
+ FormatGrowthSeries(growth.SalesGrowth),
+ FormatGrowthSeries(growth.OperatingProfitGrowth),
+ FormatGrowthSeries(growth.NetAssetGrowth),
+ FormatGrowthSeries(growth.NetIncomeGrowth),
+ string.Join('_', growth.Periods.ToArray())
+ }),
+ ManualSalesRecord sales =>
+ new[] { sales.Identity.StockName }
+ .Concat(sales.Quarters.Select(FormatQuarter)),
+ ManualOperatingProfitRecord profit =>
+ new[] { profit.Identity.StockName }
+ .Concat(profit.Quarters.Select(FormatQuarter)),
+ _ => throw new ArgumentOutOfRangeException(nameof(record))
+ };
+
+ return Array.AsReadOnly(values.ToArray());
+ }
+
+ private static string FormatGrowthSeries(ManualGrowthSeriesValues series) =>
+ string.Join('_', series.ToArray().Select(static value => value?.ToString(
+ "G17",
+ CultureInfo.InvariantCulture) ?? string.Empty));
+
+ private static string FormatQuarter(ManualQuarterValue quarter) =>
+ $"{quarter.Quarter}_{quarter.Value.ToString(CultureInfo.InvariantCulture)}";
}
public sealed class LegacyManualFinancialStockRow
@@ -124,7 +201,8 @@ public sealed record LegacyManualFinancialPlaylistDraft(
string StockCode,
bool IsNxt,
ManualFinancialCutSelection CutSelection,
- ManualFinancialSnapshot OperatorSnapshot);
+ ManualFinancialSnapshot? OperatorSnapshot,
+ ManualFinancialRawSnapshot? OperatorRawSnapshot);
public sealed record LegacyManualFinancialWorkflowSnapshot
{
@@ -160,14 +238,22 @@ public sealed record LegacyManualFinancialWorkflowSnapshot
public bool ListIsTruncated { get; internal init; }
+ public int ListRejectedItemCount { get; internal init; }
+
public string? SelectedRowId { get; internal init; }
public ManualFinancialRecord? SelectedRecord => Detail?.Record;
- public string? SelectedRowVersion => Detail?.RowVersion;
+ public ManualFinancialRawRecord? SelectedRawRecord => RawDetail?.Record;
+
+ public string? SelectedRowVersion => RawDetail?.RowVersion ?? Detail?.RowVersion;
public bool IsDetailFresh { get; internal init; }
+ public bool IsRawDetailFresh { get; internal init; }
+
+ public bool IsPlayoutReady => IsDetailFresh && Detail is not null;
+
public string StockQuery { get; internal init; }
public IReadOnlyList StockCandidates { get; internal init; }
@@ -194,27 +280,42 @@ public sealed record LegacyManualFinancialWorkflowSnapshot
public ManualFinancialMutationReceipt? LastReceipt { get; internal init; }
+ public bool CanEdit =>
+ !WritesQuarantined &&
+ (IsRawDetailFresh && RawDetail is not null ||
+ IsDetailFresh && Detail is not null);
+
public bool CanSave =>
!WritesQuarantined &&
- Verified is not null &&
- (Detail is null ||
- IsDetailFresh &&
- string.Equals(VerifiedForRowVersion, Detail.RowVersion, StringComparison.Ordinal) &&
- string.Equals(
- Verified.Name,
- Detail.Record.Identity.StockName,
- StringComparison.Ordinal));
+ (RawDetail is not null
+ ? IsRawDetailFresh
+ : Verified is not null &&
+ (Detail is null ||
+ IsDetailFresh &&
+ string.Equals(
+ VerifiedForRowVersion,
+ Detail.RowVersion,
+ StringComparison.Ordinal) &&
+ string.Equals(
+ Verified.Name,
+ Detail.Record.Identity.StockName,
+ StringComparison.Ordinal)));
public bool CanDelete =>
- !WritesQuarantined && IsDetailFresh && Detail is not null;
+ !WritesQuarantined &&
+ (IsRawDetailFresh && RawDetail is not null ||
+ IsDetailFresh && Detail is not null);
public bool CanMaterializePlaylist =>
- CanDelete &&
+ !WritesQuarantined &&
Verified is not null &&
- string.Equals(VerifiedForRowVersion, Detail!.RowVersion, StringComparison.Ordinal) &&
+ SelectedIdentity is { } identity &&
+ SelectedRowVersion is { } rowVersion &&
+ (IsRawDetailFresh || IsDetailFresh) &&
+ string.Equals(VerifiedForRowVersion, rowVersion, StringComparison.Ordinal) &&
string.Equals(
Verified.Name,
- Detail.Record.Identity.StockName,
+ identity.StockName,
StringComparison.Ordinal);
internal object Authority { get; }
@@ -223,6 +324,11 @@ public sealed record LegacyManualFinancialWorkflowSnapshot
internal ManualFinancialSnapshot? Detail { get; init; }
+ internal ManualFinancialRawSnapshot? RawDetail { get; init; }
+
+ internal ManualFinancialIdentity? SelectedIdentity =>
+ RawDetail?.Record.Identity ?? Detail?.Record.Identity;
+
internal VerifiedManualFinancialStock? Verified { get; init; }
internal string? VerifiedForRowVersion { get; init; }
@@ -255,6 +361,8 @@ public abstract class LegacyManualFinancialWriteRequest
public string StockName { get; }
+ public virtual string? ExpectedRowVersion => null;
+
internal object Authority { get; }
internal long SourceRevision { get; }
@@ -313,7 +421,7 @@ public sealed class LegacyManualFinancialUpdateRequest : LegacyManualFinancialWr
Stock = verifiedStock;
}
- public string ExpectedRowVersion => Expected.RowVersion;
+ public override string ExpectedRowVersion => Expected.RowVersion;
public ManualFinancialRecord Replacement { get; }
@@ -324,6 +432,33 @@ public sealed class LegacyManualFinancialUpdateRequest : LegacyManualFinancialWr
private VerifiedManualFinancialStock Stock { get; }
}
+public sealed class LegacyManualFinancialRawUpdateRequest : LegacyManualFinancialWriteRequest
+{
+ internal LegacyManualFinancialRawUpdateRequest(
+ object authority,
+ long sourceRevision,
+ long sourceWriteEpoch,
+ ManualFinancialRawSnapshot expected,
+ ManualFinancialRawRecord replacement)
+ : base(
+ authority,
+ sourceRevision,
+ sourceWriteEpoch,
+ ManualFinancialMutationKind.Update,
+ expected.Record.Identity.Screen,
+ expected.Record.Identity.StockName)
+ {
+ Expected = expected;
+ Replacement = replacement;
+ }
+
+ public override string ExpectedRowVersion => Expected.RowVersion;
+
+ public ManualFinancialRawRecord Replacement { get; }
+
+ internal ManualFinancialRawSnapshot Expected { get; }
+}
+
public sealed class LegacyManualFinancialDeleteRequest : LegacyManualFinancialWriteRequest
{
internal LegacyManualFinancialDeleteRequest(
@@ -342,11 +477,34 @@ public sealed class LegacyManualFinancialDeleteRequest : LegacyManualFinancialWr
Expected = expected;
}
- public string ExpectedRowVersion => Expected.RowVersion;
+ public override string ExpectedRowVersion => Expected.RowVersion;
internal ManualFinancialSnapshot Expected { get; }
}
+public sealed class LegacyManualFinancialRawDeleteRequest : LegacyManualFinancialWriteRequest
+{
+ internal LegacyManualFinancialRawDeleteRequest(
+ object authority,
+ long sourceRevision,
+ long sourceWriteEpoch,
+ ManualFinancialRawSnapshot expected)
+ : base(
+ authority,
+ sourceRevision,
+ sourceWriteEpoch,
+ ManualFinancialMutationKind.DeleteOne,
+ expected.Record.Identity.Screen,
+ expected.Record.Identity.StockName)
+ {
+ Expected = expected;
+ }
+
+ public override string ExpectedRowVersion => Expected.RowVersion;
+
+ internal ManualFinancialRawSnapshot Expected { get; }
+}
+
public sealed class LegacyManualFinancialDeleteAllRequest : LegacyManualFinancialWriteRequest
{
internal LegacyManualFinancialDeleteAllRequest(
@@ -458,8 +616,9 @@ internal sealed class LegacyManualFinancialWriteSafetyState
///
public sealed class LegacyManualFinancialWorkflow
{
- public const int MaximumListResults = 200;
- public const int MaximumStockResults = 100;
+ public const int MaximumListResults =
+ LegacyManualFinancialScreenService.MaximumResults;
+ public const int MaximumStockResults = LegacyStockSearchService.MaximumResults;
private const string ValidationRowVersion =
"0000000000000000000000000000000000000000000000000000000000000000";
@@ -480,6 +639,7 @@ public sealed class LegacyManualFinancialWorkflow
private static readonly LegacyManualFinancialWriteSafetyState ProcessWriteSafety = new();
private readonly IManualFinancialScreenService _screenService;
+ private readonly IManualFinancialRawScreenService? _rawScreenService;
private readonly IStockSearchService _stockSearchService;
private readonly LegacyManualFinancialWriteSafetyState _writeSafety;
private readonly object _authority = new();
@@ -502,6 +662,7 @@ public sealed class LegacyManualFinancialWorkflow
LegacyManualFinancialWriteSafetyState writeSafety)
{
_screenService = screenService ?? throw new ArgumentNullException(nameof(screenService));
+ _rawScreenService = screenService as IManualFinancialRawScreenService;
_stockSearchService = stockSearchService ??
throw new ArgumentNullException(nameof(stockSearchService));
_writeSafety = writeSafety ?? throw new ArgumentNullException(nameof(writeSafety));
@@ -539,27 +700,77 @@ public sealed class LegacyManualFinancialWorkflow
cancellationToken).ConfigureAwait(false);
if (result is null || result.Screen != snapshot.Screen ||
!string.Equals(result.Query, normalizedQuery, StringComparison.Ordinal) ||
- result.Items is null || result.Items.Count > MaximumListResults)
+ result.Items is null || result.Items.Count > MaximumListResults ||
+ result.UnreadableStockNames is null ||
+ result.RawItems is null ||
+ (result.RawItemsComplete
+ ? result.RawItems.Count > MaximumListResults
+ : result.Items.Count + result.UnreadableStockNames.Count > MaximumListResults) ||
+ result.RejectedItemCount is < 0 or > MaximumListResults ||
+ result.UnreadableStockNames.Count > result.RejectedItemCount)
{
throw InvalidData("The manual-financial list response is not correlated to its request.");
}
- var rows = new List(result.Items.Count);
- var identities = new HashSet(StringComparer.Ordinal);
- foreach (var item in result.Items)
+ var rows = new List(result.RawItemsComplete
+ ? result.RawItems.Count
+ : result.Items.Count + result.UnreadableStockNames.Count);
+ if (result.RawItemsComplete)
{
- var validated = ValidateSnapshot(item, snapshot.Screen, "list response");
- if (!identities.Add(validated.Record.Identity.StockName))
+ foreach (var item in result.RawItems)
{
- throw InvalidData("The manual-financial list contains an ambiguous STOCK_NAME identity.");
+ var validated = ValidateRawSnapshot(
+ item,
+ snapshot.Screen,
+ "raw list response");
+ rows.Add(new LegacyManualFinancialListRow(
+ NewOpaqueId("mf-row"),
+ validated));
+ }
+ }
+ else
+ {
+ foreach (var item in result.Items)
+ {
+ var validated = ValidateSnapshot(item, snapshot.Screen, "list response");
+ rows.Add(new LegacyManualFinancialListRow(NewOpaqueId("mf-row"), validated));
+ }
+ foreach (var stockName in result.UnreadableStockNames)
+ {
+ rows.Add(new LegacyManualFinancialListRow(
+ NewOpaqueId("mf-row"),
+ ValidateListStockName(stockName)));
}
-
- rows.Add(new LegacyManualFinancialListRow(NewOpaqueId("mf-row"), validated));
}
rows.Sort(static (left, right) =>
StringComparer.Ordinal.Compare(left.StockName, right.StockName));
+ var writesQuarantined = IsQuarantined(out var reason);
+ var hiddenUnreadableCount =
+ result.RejectedItemCount - result.UnreadableStockNames.Count;
+ var listMessages = new List();
+ if (result.RejectedItemCount > 0)
+ {
+ listMessages.Add(hiddenUnreadableCount == 0
+ ? $"기존 원문 형식 데이터 {result.RejectedItemCount}행도 " +
+ "목록에 표시되며 선택·편집할 수 있습니다."
+ : $"기존 원문 형식 데이터 {result.UnreadableStockNames.Count}행은 " +
+ $"목록에 표시했고, 이름을 읽을 수 없는 {hiddenUnreadableCount}행은 제외했습니다.");
+ }
+ if (result.IsTruncated)
+ {
+ listMessages.Add(
+ $"검색 결과가 {MaximumListResults}행을 초과해 일부만 표시됩니다.");
+ }
+ if (!string.IsNullOrWhiteSpace(reason))
+ {
+ listMessages.Add(reason);
+ }
+ var listMessage = listMessages.Count == 0
+ ? null
+ : string.Join(' ', listMessages);
+
return snapshot with
{
Revision = checked(snapshot.Revision + 1),
@@ -568,9 +779,12 @@ public sealed class LegacyManualFinancialWorkflow
NameSortDirection = LegacyManualFinancialNameSortDirection.Ascending,
Rows = Array.AsReadOnly(rows.ToArray()),
ListIsTruncated = result.IsTruncated,
+ ListRejectedItemCount = result.RejectedItemCount,
SelectedRowId = null,
Detail = null,
+ RawDetail = null,
IsDetailFresh = false,
+ IsRawDetailFresh = false,
StockQuery = string.Empty,
StockCandidates = Array.Empty(),
StockSearchIsTruncated = false,
@@ -578,8 +792,8 @@ public sealed class LegacyManualFinancialWorkflow
Verified = null,
VerifiedForRowVersion = null,
Status = LegacyManualFinancialWorkflowStatus.ListLoaded,
- WritesQuarantined = IsQuarantined(out var reason),
- LastMessage = reason,
+ WritesQuarantined = writesQuarantined,
+ LastMessage = listMessage,
LastRequestId = null,
LastReceipt = null
};
@@ -604,6 +818,35 @@ public sealed class LegacyManualFinancialWorkflow
};
}
+ public LegacyManualFinancialWorkflowSnapshot BeginLoad(
+ LegacyManualFinancialWorkflowSnapshot snapshot,
+ string resultId)
+ {
+ snapshot = RequireOwned(snapshot);
+ var isCurrentRow = !string.IsNullOrWhiteSpace(resultId) &&
+ snapshot.Rows.Any(row => string.Equals(
+ row.ResultId,
+ resultId,
+ StringComparison.Ordinal));
+ return snapshot with
+ {
+ Revision = checked(snapshot.Revision + 1),
+ SelectedRowId = isCurrentRow ? resultId : null,
+ Detail = null,
+ RawDetail = null,
+ IsDetailFresh = false,
+ IsRawDetailFresh = false,
+ StockQuery = string.Empty,
+ StockCandidates = Array.Empty(),
+ StockSearchIsTruncated = false,
+ SelectedStockResultId = null,
+ Verified = null,
+ VerifiedForRowVersion = null,
+ Status = LegacyManualFinancialWorkflowStatus.ListLoaded,
+ LastMessage = null
+ };
+ }
+
public LegacyManualFinancialWorkflowSnapshot FindByName(
LegacyManualFinancialWorkflowSnapshot snapshot,
string query,
@@ -659,7 +902,9 @@ public sealed class LegacyManualFinancialWorkflow
? snapshot.Rows[matchIndex].ResultId
: snapshot.SelectedRowId,
Detail = matchIndex >= 0 ? null : snapshot.Detail,
+ RawDetail = matchIndex >= 0 ? null : snapshot.RawDetail,
IsDetailFresh = matchIndex < 0 && snapshot.IsDetailFresh,
+ IsRawDetailFresh = matchIndex < 0 && snapshot.IsRawDetailFresh,
StockQuery = matchIndex >= 0 ? string.Empty : snapshot.StockQuery,
StockCandidates = matchIndex >= 0
? Array.Empty()
@@ -688,22 +933,53 @@ public sealed class LegacyManualFinancialWorkflow
{
snapshot = RequireOwned(snapshot);
var row = FindRow(snapshot.Rows, resultId);
- var identity = row.Snapshot.Record.Identity;
- var detail = await _screenService.GetAsync(identity, cancellationToken).ConfigureAwait(false);
- var validated = ValidateSnapshot(detail, snapshot.Screen, "detail response");
- if (!EqualsIdentity(identity, validated.Record.Identity))
+ var identity = new ManualFinancialIdentity(snapshot.Screen, row.StockName);
+ ManualFinancialSnapshot? validated;
+ ManualFinancialRawSnapshot? validatedRaw = null;
+ IReadOnlyList rows;
+ if (row.RawSnapshot is not null && _rawScreenService is not null)
{
- throw InvalidData("The manual-financial detail response is not correlated to its request.");
+ var raw = await _rawScreenService.GetRawAsync(
+ row.RawSnapshot,
+ cancellationToken).ConfigureAwait(false);
+ validatedRaw = ValidateRawSnapshot(raw, snapshot.Screen, "raw detail response");
+ if (!EqualsIdentity(identity, validatedRaw.Record.Identity))
+ {
+ throw InvalidData(
+ "The raw manual-financial detail response is not correlated to its request.");
+ }
+
+ validated = validatedRaw.StrictSnapshot;
+ rows = ReplaceRow(snapshot.Rows, resultId, validatedRaw);
+ }
+ else
+ {
+ var detail = await _screenService.GetAsync(
+ identity,
+ cancellationToken).ConfigureAwait(false);
+ validated = ValidateSnapshot(detail, snapshot.Screen, "detail response");
+ if (!EqualsIdentity(identity, validated.Record.Identity))
+ {
+ throw InvalidData(
+ "The manual-financial detail response is not correlated to its request.");
+ }
+
+ rows = ReplaceRow(snapshot.Rows, resultId, validated);
}
- var rows = ReplaceRow(snapshot.Rows, resultId, validated);
+ var writesQuarantined = IsQuarantined(out var reason);
+ var message = reason ?? (validated is null
+ ? "기존 GraphE 저장값을 원문 그대로 불러왔습니다. 송출 시에는 장면 데이터 검증을 다시 수행합니다."
+ : null);
return snapshot with
{
Revision = checked(snapshot.Revision + 1),
Rows = rows,
SelectedRowId = resultId,
Detail = validated,
- IsDetailFresh = true,
+ RawDetail = validatedRaw,
+ IsDetailFresh = validated is not null,
+ IsRawDetailFresh = validatedRaw is not null,
StockQuery = string.Empty,
StockCandidates = Array.Empty(),
StockSearchIsTruncated = false,
@@ -711,8 +987,8 @@ public sealed class LegacyManualFinancialWorkflow
Verified = null,
VerifiedForRowVersion = null,
Status = LegacyManualFinancialWorkflowStatus.DetailLoaded,
- WritesQuarantined = IsQuarantined(out var reason),
- LastMessage = reason,
+ WritesQuarantined = writesQuarantined,
+ LastMessage = message,
LastRequestId = null,
LastReceipt = null
};
@@ -727,7 +1003,9 @@ public sealed class LegacyManualFinancialWorkflow
Revision = checked(snapshot.Revision + 1),
SelectedRowId = null,
Detail = null,
+ RawDetail = null,
IsDetailFresh = false,
+ IsRawDetailFresh = false,
StockQuery = string.Empty,
StockCandidates = Array.Empty(),
StockSearchIsTruncated = false,
@@ -748,7 +1026,15 @@ public sealed class LegacyManualFinancialWorkflow
CancellationToken cancellationToken = default)
{
snapshot = RequireOwned(snapshot);
- var normalizedName = NormalizeQuery(stockName, allowEmpty: false);
+ var normalizedName = NormalizeQuery(stockName, allowEmpty: true);
+ // Form/GraphE.cs returned immediately from both the button and Enter
+ // handlers when the stock-search text box was empty. Preserve the
+ // complete editor/candidate state and avoid touching the provider.
+ if (normalizedName.Length == 0)
+ {
+ return snapshot;
+ }
+
var result = await _stockSearchService.SearchAsync(
normalizedName,
MaximumStockResults,
@@ -791,8 +1077,14 @@ public sealed class LegacyManualFinancialWorkflow
string stockResultId)
{
snapshot = RequireOwned(snapshot);
+ if (string.IsNullOrWhiteSpace(stockResultId))
+ {
+ return snapshot;
+ }
+
var row = FindStock(snapshot.StockCandidates, stockResultId);
- var identity = snapshot.Detail?.Record.Identity ??
+ var identity = snapshot.RawDetail?.Record.Identity ??
+ snapshot.Detail?.Record.Identity ??
new ManualFinancialIdentity(snapshot.Screen, row.Item.Name);
var candidates = snapshot.StockCandidates.Select(candidate => candidate.Item).ToArray();
var verified = ManualFinancialCutContracts.VerifyStock(
@@ -800,9 +1092,7 @@ public sealed class LegacyManualFinancialWorkflow
candidates,
row.Item.Market,
row.Item.Code);
- var verifiedForRowVersion = snapshot.Detail is null
- ? null
- : snapshot.Detail.RowVersion;
+ var verifiedForRowVersion = snapshot.SelectedRowVersion;
return snapshot with
{
@@ -863,10 +1153,41 @@ public sealed class LegacyManualFinancialWorkflow
verified);
}
- public LegacyManualFinancialDeleteRequest CreateDeleteRequest(
+ public LegacyManualFinancialRawUpdateRequest CreateRawUpdateRequest(
+ LegacyManualFinancialWorkflowSnapshot snapshot,
+ ManualFinancialRawRecord replacement)
+ {
+ snapshot = RequireWritable(snapshot);
+ var expected = RequireFreshRawDetail(snapshot);
+ var validated = ValidateRawRecord(replacement, snapshot.Screen, nameof(replacement));
+ if (!EqualsIdentity(expected.Record.Identity, validated.Identity))
+ {
+ throw new ArgumentException(
+ "A raw manual-financial update cannot rename or change screens.",
+ nameof(replacement));
+ }
+
+ return new LegacyManualFinancialRawUpdateRequest(
+ _authority,
+ snapshot.Revision,
+ snapshot.WriteEpoch,
+ expected,
+ validated);
+ }
+
+ public LegacyManualFinancialWriteRequest CreateDeleteRequest(
LegacyManualFinancialWorkflowSnapshot snapshot)
{
snapshot = RequireWritable(snapshot);
+ if (snapshot.IsRawDetailFresh && snapshot.RawDetail is not null)
+ {
+ return new LegacyManualFinancialRawDeleteRequest(
+ _authority,
+ snapshot.Revision,
+ snapshot.WriteEpoch,
+ snapshot.RawDetail);
+ }
+
var expected = RequireFreshDetail(snapshot);
return new LegacyManualFinancialDeleteRequest(
_authority,
@@ -945,10 +1266,19 @@ public sealed class LegacyManualFinancialWorkflow
update.Expected,
update.Replacement,
cancellationToken).ConfigureAwait(false),
+ LegacyManualFinancialRawUpdateRequest update =>
+ await RequireRawService().UpdateRawAsync(
+ update.Expected,
+ update.Replacement,
+ cancellationToken).ConfigureAwait(false),
LegacyManualFinancialDeleteRequest delete =>
await _screenService.DeleteAsync(
delete.Expected,
cancellationToken).ConfigureAwait(false),
+ LegacyManualFinancialRawDeleteRequest delete =>
+ await RequireRawService().DeleteRawAsync(
+ delete.Expected,
+ cancellationToken).ConfigureAwait(false),
LegacyManualFinancialDeleteAllRequest =>
await _screenService.DeleteAllAsync(
snapshot.Screen,
@@ -990,7 +1320,15 @@ public sealed class LegacyManualFinancialWorkflow
writeEpoch,
cancellationToken)
.ConfigureAwait(false),
- LegacyManualFinancialDeleteRequest =>
+ LegacyManualFinancialRawUpdateRequest rawUpdate =>
+ await CompleteRawSaveAsync(
+ snapshot,
+ rawUpdate,
+ receipt,
+ writeEpoch,
+ cancellationToken)
+ .ConfigureAwait(false),
+ LegacyManualFinancialDeleteRequest or LegacyManualFinancialRawDeleteRequest =>
CompleteDelete(snapshot, request, receipt, writeEpoch),
LegacyManualFinancialDeleteAllRequest =>
CompleteDeleteAll(snapshot, request, receipt, writeEpoch),
@@ -1020,22 +1358,32 @@ public sealed class LegacyManualFinancialWorkflow
"Playlist materialization is blocked while a write outcome is unknown.");
}
- var detail = RequireFreshDetail(snapshot);
- var verified = RequireVerifiedForDetail(snapshot, detail);
- var cut = ManualFinancialCutContracts.CreateSelection(detail, verified);
+ var identity = snapshot.SelectedIdentity ?? throw new InvalidOperationException(
+ "A fresh manual-financial detail read is required.");
+ var verified = RequireVerifiedForSelectedDetail(snapshot, identity);
+ var raw = snapshot.IsRawDetailFresh ? snapshot.RawDetail : null;
+ var detail = snapshot.IsDetailFresh ? snapshot.Detail : null;
+ if (raw is null && detail is null)
+ {
+ throw new InvalidOperationException(
+ "A fresh manual-financial detail read is required.");
+ }
+
+ var cut = ManualFinancialCutContracts.CreateSelection(identity, verified);
return new LegacyManualFinancialPlaylistDraft(
definition.ActionId,
definition.BuilderKey,
definition.Code,
definition.Aliases,
- detail.Record.Identity.StockName,
+ identity.StockName,
definition.Label,
"manual-financial",
MarketName(verified.Market),
verified.Code,
verified.Market is StockMarket.NxtKospi or StockMarket.NxtKosdaq,
cut,
- CloneSnapshot(detail));
+ detail is null ? null : CloneSnapshot(detail),
+ raw is null ? null : CloneRawSnapshot(raw));
}
private async Task CompleteSaveAsync(
@@ -1066,7 +1414,9 @@ public sealed class LegacyManualFinancialWorkflow
WriteEpoch = writeEpoch,
SelectedRowId = null,
Detail = null,
+ RawDetail = null,
IsDetailFresh = false,
+ IsRawDetailFresh = false,
Verified = null,
VerifiedForRowVersion = null,
Status = LegacyManualFinancialWorkflowStatus.ReadBackRequired,
@@ -1085,7 +1435,9 @@ public sealed class LegacyManualFinancialWorkflow
Rows = rows,
SelectedRowId = resultId,
Detail = detail,
+ RawDetail = null,
IsDetailFresh = true,
+ IsRawDetailFresh = false,
SelectedStockResultId = snapshot.SelectedStockResultId,
Verified = request.VerifiedStock,
VerifiedForRowVersion = detail.RowVersion,
@@ -1097,16 +1449,92 @@ public sealed class LegacyManualFinancialWorkflow
};
}
+ private async Task CompleteRawSaveAsync(
+ LegacyManualFinancialWorkflowSnapshot snapshot,
+ LegacyManualFinancialRawUpdateRequest request,
+ ManualFinancialMutationReceipt receipt,
+ long writeEpoch,
+ CancellationToken cancellationToken)
+ {
+ ManualFinancialRawSnapshot raw;
+ try
+ {
+ raw = ValidateRawSnapshot(
+ await RequireRawService().GetRawAsync(
+ request.Expected,
+ cancellationToken).ConfigureAwait(false),
+ request.Screen,
+ "raw post-save read-back");
+ if (!EqualsIdentity(request.Replacement.Identity, raw.Record.Identity))
+ {
+ throw InvalidData(
+ "The raw post-save read-back is not correlated to its write.");
+ }
+ }
+ catch (Exception exception)
+ {
+ return snapshot with
+ {
+ Revision = checked(snapshot.Revision + 1),
+ WriteEpoch = writeEpoch,
+ SelectedRowId = null,
+ Detail = null,
+ RawDetail = null,
+ IsDetailFresh = false,
+ IsRawDetailFresh = false,
+ Verified = null,
+ VerifiedForRowVersion = null,
+ Status = LegacyManualFinancialWorkflowStatus.ReadBackRequired,
+ WritesQuarantined = false,
+ LastMessage =
+ $"The write committed, but a fresh raw read-back is required: {exception.Message}",
+ LastRequestId = request.RequestId,
+ LastReceipt = receipt
+ };
+ }
+
+ var resultId = snapshot.SelectedRowId ?? NewOpaqueId("mf-row");
+ var rows = snapshot.Rows.Any(row => string.Equals(
+ row.ResultId,
+ resultId,
+ StringComparison.Ordinal))
+ ? ReplaceRow(snapshot.Rows, resultId, raw)
+ : SortRows(
+ snapshot.Rows.Append(new LegacyManualFinancialListRow(resultId, raw)),
+ snapshot.NameSortDirection);
+ return snapshot with
+ {
+ Revision = checked(snapshot.Revision + 1),
+ WriteEpoch = writeEpoch,
+ Rows = rows,
+ SelectedRowId = resultId,
+ Detail = raw.StrictSnapshot,
+ RawDetail = raw,
+ IsDetailFresh = raw.StrictSnapshot is not null,
+ IsRawDetailFresh = true,
+ Verified = snapshot.Verified,
+ VerifiedForRowVersion = snapshot.Verified is null ? null : raw.RowVersion,
+ Status = LegacyManualFinancialWorkflowStatus.WriteCommitted,
+ WritesQuarantined = false,
+ LastMessage = raw.IsPlayoutReady
+ ? null
+ : "기존 GraphE 원문 값으로 저장했습니다. 송출 시 장면 데이터 검증을 다시 수행합니다.",
+ LastRequestId = request.RequestId,
+ LastReceipt = receipt
+ };
+ }
+
private static LegacyManualFinancialWorkflowSnapshot CompleteDelete(
LegacyManualFinancialWorkflowSnapshot snapshot,
LegacyManualFinancialWriteRequest request,
ManualFinancialMutationReceipt receipt,
long writeEpoch)
{
+ var selectedRowId = snapshot.SelectedRowId;
var rows = snapshot.Rows
- .Where(row => !string.Equals(
- row.StockName,
- request.StockName,
+ .Where(row => selectedRowId is null || !string.Equals(
+ row.ResultId,
+ selectedRowId,
StringComparison.Ordinal))
.ToArray();
return snapshot with
@@ -1116,7 +1544,9 @@ public sealed class LegacyManualFinancialWorkflow
Rows = Array.AsReadOnly(rows),
SelectedRowId = null,
Detail = null,
+ RawDetail = null,
IsDetailFresh = false,
+ IsRawDetailFresh = false,
StockQuery = string.Empty,
StockCandidates = Array.Empty(),
StockSearchIsTruncated = false,
@@ -1142,9 +1572,12 @@ public sealed class LegacyManualFinancialWorkflow
WriteEpoch = writeEpoch,
Rows = Array.Empty(),
ListIsTruncated = false,
+ ListRejectedItemCount = 0,
SelectedRowId = null,
Detail = null,
+ RawDetail = null,
IsDetailFresh = false,
+ IsRawDetailFresh = false,
StockQuery = string.Empty,
StockCandidates = Array.Empty(),
StockSearchIsTruncated = false,
@@ -1251,6 +1684,10 @@ public sealed class LegacyManualFinancialWorkflow
private bool IsQuarantined(out string? reason) =>
_writeSafety.IsQuarantined(out reason);
+ private IManualFinancialRawScreenService RequireRawService() =>
+ _rawScreenService ?? throw new InvalidOperationException(
+ "Raw GraphE row compatibility is not available from this data service.");
+
private static ManualFinancialSnapshot RequireFreshDetail(
LegacyManualFinancialWorkflowSnapshot snapshot) =>
snapshot.IsDetailFresh && snapshot.Detail is not null
@@ -1258,6 +1695,13 @@ public sealed class LegacyManualFinancialWorkflow
: throw new InvalidOperationException(
"A fresh manual-financial detail read is required.");
+ private static ManualFinancialRawSnapshot RequireFreshRawDetail(
+ LegacyManualFinancialWorkflowSnapshot snapshot) =>
+ snapshot.IsRawDetailFresh && snapshot.RawDetail is not null
+ ? snapshot.RawDetail
+ : throw new InvalidOperationException(
+ "A fresh raw manual-financial detail read is required.");
+
private static VerifiedManualFinancialStock RequireVerifiedForDetail(
LegacyManualFinancialWorkflowSnapshot snapshot,
ManualFinancialSnapshot detail)
@@ -1279,6 +1723,28 @@ public sealed class LegacyManualFinancialWorkflow
return snapshot.Verified;
}
+ private static VerifiedManualFinancialStock RequireVerifiedForSelectedDetail(
+ LegacyManualFinancialWorkflowSnapshot snapshot,
+ ManualFinancialIdentity identity)
+ {
+ if (snapshot.Verified is null ||
+ snapshot.SelectedRowVersion is not { } rowVersion ||
+ !string.Equals(
+ snapshot.VerifiedForRowVersion,
+ rowVersion,
+ StringComparison.Ordinal) ||
+ !string.Equals(
+ snapshot.Verified.Name,
+ identity.StockName,
+ StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ "The selected detail requires a fresh exact stock-master verification.");
+ }
+
+ return snapshot.Verified;
+ }
+
private static LegacyManualFinancialListRow FindRow(
IReadOnlyList rows,
string resultId)
@@ -1309,12 +1775,22 @@ public sealed class LegacyManualFinancialWorkflow
: row)
.ToArray());
+ private static IReadOnlyList ReplaceRow(
+ IReadOnlyList rows,
+ string resultId,
+ ManualFinancialRawSnapshot detail) =>
+ Array.AsReadOnly(rows.Select(row =>
+ string.Equals(row.ResultId, resultId, StringComparison.Ordinal)
+ ? new LegacyManualFinancialListRow(resultId, detail)
+ : row)
+ .ToArray());
+
private static IReadOnlyList UpsertRow(
LegacyManualFinancialWorkflowSnapshot snapshot,
ManualFinancialSnapshot detail,
out string resultId)
{
- var existing = snapshot.Rows.SingleOrDefault(row => string.Equals(
+ var existing = snapshot.Rows.FirstOrDefault(row => string.Equals(
row.StockName,
detail.Record.Identity.StockName,
StringComparison.Ordinal));
@@ -1368,6 +1844,97 @@ public sealed class LegacyManualFinancialWorkflow
? receipt.AffectedRows >= 0
: receipt.AffectedRows == 1);
+ private static ManualFinancialRawSnapshot ValidateRawSnapshot(
+ ManualFinancialRawSnapshot? snapshot,
+ ManualFinancialScreenKind screen,
+ string source)
+ {
+ try
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+ var record = ValidateRawRecord(snapshot.Record, screen, nameof(snapshot));
+ if (snapshot.RowVersion is null || snapshot.RowVersion.Length != 64 ||
+ snapshot.RowVersion.Any(static character =>
+ !char.IsAsciiDigit(character) && character is not (>= 'A' and <= 'F')) ||
+ snapshot.Handle is null)
+ {
+ throw new ArgumentException("The raw row envelope is invalid.");
+ }
+
+ ManualFinancialSnapshot? strict = null;
+ if (snapshot.StrictSnapshot is not null)
+ {
+ strict = ValidateSnapshot(snapshot.StrictSnapshot, screen, source);
+ if (!EqualsIdentity(strict.Record.Identity, record.Identity) ||
+ !string.Equals(
+ strict.RowVersion,
+ snapshot.RowVersion,
+ StringComparison.Ordinal))
+ {
+ throw new ArgumentException(
+ "The strict projection is not correlated to the raw row.");
+ }
+ }
+
+ return new ManualFinancialRawSnapshot(
+ record,
+ snapshot.RowVersion,
+ snapshot.Handle,
+ strict);
+ }
+ catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
+ {
+ throw InvalidData($"The manual-financial {source} is invalid: {exception.Message}");
+ }
+ }
+
+ private static ManualFinancialRawRecord ValidateRawRecord(
+ ManualFinancialRawRecord? record,
+ ManualFinancialScreenKind screen,
+ string parameterName)
+ {
+ ArgumentNullException.ThrowIfNull(record, parameterName);
+ ArgumentNullException.ThrowIfNull(record.Identity, parameterName);
+ if (record.Identity.Screen != screen ||
+ record.Identity.StockName is null ||
+ record.Identity.StockName.Length is < 1 or > 200 ||
+ record.Identity.StockName != record.Identity.StockName.Trim() ||
+ record.StorageValues is null)
+ {
+ throw new ArgumentException(
+ "The raw manual-financial record identity is invalid.",
+ parameterName);
+ }
+
+ var expectedCount = ManualFinancialCutContracts.Get(screen).StorageColumns.Count - 1;
+ if (record.StorageValues.Count != expectedCount)
+ {
+ throw new ArgumentException(
+ "The raw manual-financial record field count is invalid.",
+ parameterName);
+ }
+
+ var values = new string[expectedCount];
+ for (var index = 0; index < values.Length; index++)
+ {
+ var value = record.StorageValues[index];
+ if (value is null || value.Length > 200 ||
+ value.Any(static character =>
+ char.IsSurrogate(character) || character == '\uFFFD'))
+ {
+ throw new ArgumentException(
+ "A raw manual-financial field is invalid.",
+ parameterName);
+ }
+
+ values[index] = value;
+ }
+
+ return new ManualFinancialRawRecord(
+ new ManualFinancialIdentity(screen, record.Identity.StockName),
+ Array.AsReadOnly(values));
+ }
+
private static ManualFinancialSnapshot ValidateSnapshot(
ManualFinancialSnapshot? snapshot,
ManualFinancialScreenKind screen,
@@ -1427,6 +1994,20 @@ public sealed class LegacyManualFinancialWorkflow
private static ManualFinancialSnapshot CloneSnapshot(ManualFinancialSnapshot snapshot) =>
new(CloneRecord(snapshot.Record), snapshot.RowVersion);
+ private static ManualFinancialRawSnapshot CloneRawSnapshot(
+ ManualFinancialRawSnapshot snapshot) =>
+ new(
+ new ManualFinancialRawRecord(
+ new ManualFinancialIdentity(
+ snapshot.Record.Identity.Screen,
+ snapshot.Record.Identity.StockName),
+ Array.AsReadOnly(snapshot.Record.StorageValues.ToArray())),
+ snapshot.RowVersion,
+ snapshot.Handle,
+ snapshot.StrictSnapshot is null
+ ? null
+ : CloneSnapshot(snapshot.StrictSnapshot));
+
private static ManualFinancialRecord CloneRecord(ManualFinancialRecord record)
{
ArgumentNullException.ThrowIfNull(record);
@@ -1553,6 +2134,26 @@ public sealed class LegacyManualFinancialWorkflow
return normalized;
}
+ private static string ValidateListStockName(string value)
+ {
+ if (value is null || value.Length is < 1 or > 200 ||
+ value != value.Trim() ||
+ value.Contains('^') ||
+ value.Any(static character =>
+ char.IsControl(character) || char.IsSurrogate(character) ||
+ character == '\uFFFD' ||
+ char.GetUnicodeCategory(character) is
+ System.Globalization.UnicodeCategory.Format or
+ System.Globalization.UnicodeCategory.LineSeparator or
+ System.Globalization.UnicodeCategory.ParagraphSeparator))
+ {
+ throw InvalidData(
+ "The manual-financial unreadable-row identity is invalid.");
+ }
+
+ return value;
+ }
+
private static bool EqualsIdentity(
ManualFinancialIdentity left,
ManualFinancialIdentity right) =>
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualLists/LegacyManualListsWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualLists/LegacyManualListsWorkflow.cs
index 206e675..0a96328 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualLists/LegacyManualListsWorkflow.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualLists/LegacyManualListsWorkflow.cs
@@ -124,6 +124,8 @@ public sealed record LegacyManualListsSnapshot(
bool CanMaterializePlaylist)
{
public string? SelectedSearchResultId { get; init; }
+
+ public bool SearchIsTruncated { get; init; }
}
public sealed record LegacyManualListPlaylistDraft(
@@ -150,6 +152,7 @@ public sealed class LegacyManualListsWorkflow
public const int MaximumPageCount = 20;
public const int MaximumNetSellValueLength = 256;
public const int MaximumSearchLength = 100;
+ public const int MaximumSearchResults = 10_000;
private const string ViSnapshotSubject = "@MBN_VI_SNAPSHOT_V1";
private const string OutcomeUnknownMessage =
@@ -169,6 +172,7 @@ public sealed class LegacyManualListsWorkflow
private string? _viVersion;
private string _searchQuery = string.Empty;
private string? _selectedSearchResultId;
+ private bool _searchResultsAreTruncated;
private string _statusMessage = string.Empty;
private LegacyOperatorStatusKind _statusKind;
private long _opaqueSequence;
@@ -198,6 +202,7 @@ public sealed class LegacyManualListsWorkflow
_searchResults.Clear();
_searchQuery = string.Empty;
_selectedSearchResultId = null;
+ _searchResultsAreTruncated = false;
await ReadNetSellAsync(cancellationToken).ConfigureAwait(false);
return Current;
}
@@ -210,6 +215,7 @@ public sealed class LegacyManualListsWorkflow
_searchResults.Clear();
_searchQuery = string.Empty;
_selectedSearchResultId = null;
+ _searchResultsAreTruncated = false;
await ReadViAsync(cancellationToken).ConfigureAwait(false);
return Current;
}
@@ -221,6 +227,7 @@ public sealed class LegacyManualListsWorkflow
_searchResults.Clear();
_searchQuery = string.Empty;
_selectedSearchResultId = null;
+ _searchResultsAreTruncated = false;
SetStatus(string.Empty, LegacyOperatorStatusKind.Neutral);
return Current;
}
@@ -307,11 +314,11 @@ public sealed class LegacyManualListsWorkflow
_searchQuery = query.Trim();
_searchResults.Clear();
_selectedSearchResultId = null;
+ _searchResultsAreTruncated = false;
if (_screen != LegacyManualListScreen.Vi ||
- _searchQuery.Length == 0 ||
_searchQuery.Length > MaximumSearchLength)
{
- SetStatus("검색할 종목명을 입력하세요.", LegacyOperatorStatusKind.Warning);
+ SetStatus("검색어를 확인하세요.", LegacyOperatorStatusKind.Warning);
return Current;
}
@@ -319,11 +326,17 @@ public sealed class LegacyManualListsWorkflow
{
var data = await _stockLookup.SearchAsync(_searchQuery, cancellationToken)
.ConfigureAwait(false);
- foreach (var displayName in data.DisplayNames.Take(200))
+ foreach (var displayName in data.DisplayNames)
{
var identity = data.ResolveLastByDisplayName(displayName);
if (identity is not null)
{
+ if (_searchResults.Count == MaximumSearchResults)
+ {
+ _searchResultsAreTruncated = true;
+ break;
+ }
+
_searchResults.Add(new ViSearchResult(
NextOpaqueId("manual-vi-result"),
identity));
@@ -331,10 +344,14 @@ public sealed class LegacyManualListsWorkflow
}
SetStatus(
- _searchResults.Count == 0
+ _searchResultsAreTruncated
+ ? $"검색 결과가 {MaximumSearchResults:N0}건을 초과해 일부만 표시합니다."
+ : _searchResults.Count == 0
? "검색 결과가 없습니다."
: $"{_searchResults.Count}개 종목을 찾았습니다.",
- LegacyOperatorStatusKind.Information);
+ _searchResultsAreTruncated
+ ? LegacyOperatorStatusKind.Warning
+ : LegacyOperatorStatusKind.Information);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@@ -894,7 +911,8 @@ public sealed class LegacyManualListsWorkflow
canSave,
canMaterialize)
{
- SelectedSearchResultId = _selectedSearchResultId
+ SelectedSearchResultId = _selectedSearchResultId,
+ SearchIsTruncated = _searchResultsAreTruncated
};
}
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs
index c5725f6..9351e35 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs
@@ -1228,19 +1228,32 @@ public sealed class LegacyNamedPlaylistWorkflowController
parameterName);
}
- var selection = new LegacySceneSelection(
- ValidateCanonicalText(
- item.Selection.GroupCode,
- MaximumSelectionFieldLength,
- allowEmpty: true,
- allowCaret: false,
- parameterName),
- ValidateCanonicalText(
+ var groupCode = ValidateCanonicalText(
+ item.Selection.GroupCode,
+ MaximumSelectionFieldLength,
+ allowEmpty: true,
+ allowCaret: false,
+ parameterName);
+ var dataCode = ValidateCanonicalText(
+ item.Selection.DataCode,
+ MaximumSelectionFieldLength,
+ allowEmpty: true,
+ allowCaret: false,
+ parameterName);
+ var subject = CanPreserveClosedThemeSubject(
+ groupCode,
+ item.Selection.Subject,
+ dataCode)
+ ? item.Selection.Subject
+ : ValidateCanonicalText(
item.Selection.Subject,
MaximumSubjectFieldLength,
allowEmpty: true,
allowCaret: false,
- parameterName),
+ parameterName);
+ var selection = new LegacySceneSelection(
+ groupCode,
+ subject,
ValidateCanonicalText(
item.Selection.GraphicType,
MaximumSelectionFieldLength,
@@ -1253,12 +1266,7 @@ public sealed class LegacyNamedPlaylistWorkflowController
allowEmpty: true,
allowCaret: false,
parameterName),
- ValidateCanonicalText(
- item.Selection.DataCode,
- MaximumSelectionFieldLength,
- allowEmpty: true,
- allowCaret: false,
- parameterName));
+ dataCode);
var validated = new NamedPlaylistStoredItem(
expectedIndex,
item.IsEnabled,
@@ -1301,6 +1309,33 @@ public sealed class LegacyNamedPlaylistWorkflowController
return value;
}
+ private static bool CanPreserveClosedThemeSubject(
+ string groupCode,
+ string subject,
+ string dataCode)
+ {
+ if (dataCode.Length is < 3 or > 8 ||
+ dataCode.Any(character => !char.IsAsciiDigit(character)))
+ {
+ return false;
+ }
+
+ var isNxt = groupCode is "테마_NXT" or "PAGED_NXT_THEME";
+ if (!isNxt && groupCode is not ("테마" or "PAGED_THEME"))
+ {
+ return false;
+ }
+
+ const string suffix = "(NXT)";
+ var exactTitle = isNxt && subject.EndsWith(suffix, StringComparison.Ordinal)
+ ? subject[..^suffix.Length]
+ : isNxt ? string.Empty : subject;
+ return exactTitle.Length is >= 1 and <= 128 &&
+ !string.IsNullOrWhiteSpace(exactTitle) &&
+ !subject.Contains('^') &&
+ IsSafeText(subject);
+ }
+
private static void ValidateProgramCode(string value)
{
if (value is null || value.Length != 8 ||
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs
index 1a06bf0..4a5e794 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs
@@ -202,8 +202,9 @@ internal sealed class LegacyOperatorCatalogWriteSafetyState
///
public sealed class LegacyOperatorCatalogWorkflowController
{
- public const int MaximumCatalogResults = 500;
- public const int MaximumStockResults = 100;
+ public const int MaximumCatalogResults = LegacyThemeSelectionService.MaximumResults;
+ public const int MaximumExpertCatalogResults = LegacyExpertSelectionService.MaximumResults;
+ public const int MaximumStockResults = LegacyStockSearchService.MaximumResults;
public const string ThemeNameDuplicateMessage = "이미 사용중인 테마명입니다.";
public const string ThemeNameAvailableMessage = "사용 가능한 테마명입니다.";
public const string ThemeSaveSuccessMessage = "ThemeA를 저장했습니다.";
@@ -221,7 +222,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
private readonly IExpertSelectionService _expertSelectionService;
private readonly IThemeCatalogPersistenceService _themePersistenceService;
private readonly IExpertCatalogPersistenceService _expertPersistenceService;
- private readonly IStockSearchService _stockSearchService;
+ private readonly IOperatorCatalogStockSearchService _stockSearchService;
private readonly IStockMasterIdentityValidationService _identityValidationService;
private readonly IOperatorCatalogSchemaValidationService _schemaValidationService;
private readonly LegacyOperatorCatalogWriteSafetyState _writeSafety;
@@ -258,7 +259,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
IExpertSelectionService expertSelectionService,
IThemeCatalogPersistenceService themePersistenceService,
IExpertCatalogPersistenceService expertPersistenceService,
- IStockSearchService stockSearchService,
+ IOperatorCatalogStockSearchService stockSearchService,
IStockMasterIdentityValidationService identityValidationService,
IOperatorCatalogSchemaValidationService schemaValidationService)
: this(
@@ -278,7 +279,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
IExpertSelectionService expertSelectionService,
IThemeCatalogPersistenceService themePersistenceService,
IExpertCatalogPersistenceService expertPersistenceService,
- IStockSearchService stockSearchService,
+ IOperatorCatalogStockSearchService stockSearchService,
IStockMasterIdentityValidationService identityValidationService,
IOperatorCatalogSchemaValidationService schemaValidationService,
LegacyOperatorCatalogWriteSafetyState writeSafety)
@@ -475,22 +476,22 @@ public sealed class LegacyOperatorCatalogWorkflowController
_draftRows.Clear();
if (selected.ThemeIdentity is { } themeIdentity)
{
- var preview = await _themeSelectionService.PreviewAsync(
+ var stored = await _themePersistenceService.ReadStoredAsync(
themeIdentity,
- LegacyThemeSelectionService.MaximumPreviewItems,
cancellationToken).ConfigureAwait(false);
- if (preview.IsTruncated || preview.Identity != themeIdentity)
+ if (stored.IsTruncated || stored.Identity != themeIdentity)
{
throw new ThemeSelectionDataException(
- "A complete, correlated ThemeA preview is required for editing.");
+ "A complete, correlated stored ThemeA definition is required for editing.");
}
- foreach (var item in preview.Items.OrderBy(static item => item.InputIndex))
+ foreach (var item in stored.Items.OrderBy(static item => item.InputIndex))
{
_draftRows.Add(DraftRowState.ForTheme(
NewOpaqueId("catalog-draft"),
item.ItemCode,
- item.ItemName));
+ item.ItemName,
+ requiresCurrentIdentityValidation: false));
}
_expectedThemeIdentity = themeIdentity;
@@ -595,6 +596,110 @@ public sealed class LegacyOperatorCatalogWorkflowController
return CreateSnapshot();
}
+ ///
+ /// Changes the market of the legacy ThemeA create dialog without closing and
+ /// recreating it. The next code is resolved before any correlated editor state
+ /// is committed, so a failed read cannot leave a mixed market/code/draft state.
+ ///
+ public async Task ChangeCreateThemeMarketAsync(
+ ThemeMarket market,
+ string editorName,
+ CancellationToken cancellationToken = default)
+ {
+ EnsureOpenEntity(LegacyOperatorCatalogEntity.Theme);
+ if (_mode != LegacyOperatorCatalogMode.Create)
+ {
+ throw new InvalidOperationException(
+ "The ThemeA market can be changed only while creating a new item.");
+ }
+
+ if (market is not (ThemeMarket.Krx or ThemeMarket.Nxt))
+ {
+ throw new ArgumentOutOfRangeException(nameof(market));
+ }
+
+ var validatedEditorName = ValidateOptionalEditorText(editorName, 128);
+ if (!CanMutateCurrentEntity())
+ {
+ return RejectUnavailableWrite();
+ }
+
+ if (market == _newThemeMarket)
+ {
+ _editorName = validatedEditorName;
+ ResetThemeNameAvailability();
+ SetStatus(
+ "현재 ThemeA 시장을 유지했습니다.",
+ LegacyOperatorCatalogStatusKind.Information);
+ Touch();
+ return CreateSnapshot();
+ }
+
+ try
+ {
+ // Keep this read ahead of every editor mutation. A provider failure must
+ // preserve the old market, code, rows, selection, and editor name exactly.
+ var nextCode = ValidateRequiredText(
+ await _themePersistenceService.SuggestNextCodeAsync(
+ market,
+ cancellationToken).ConfigureAwait(false),
+ 64);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var removedCount = _draftRows.RemoveAll(row =>
+ !IsDraftRowCompatible(row, market));
+ _newThemeMarket = market;
+ _codeLabel = nextCode;
+ _editorName = validatedEditorName;
+ _expectedThemeIdentity = null;
+ _expectedExpertIdentity = null;
+ if (_activeDraftRowId is not null && !_draftRows.Any(row =>
+ string.Equals(row.RowId, _activeDraftRowId, StringComparison.Ordinal)))
+ {
+ _activeDraftRowId = _draftRows.FirstOrDefault()?.RowId;
+ }
+
+ if (_selectedStockResultId is not null)
+ {
+ var selectedStock = _stockResults.SingleOrDefault(row => string.Equals(
+ row.ResultId,
+ _selectedStockResultId,
+ StringComparison.Ordinal));
+ if (selectedStock is null || !IsCompatible(selectedStock.Item, market))
+ {
+ _selectedStockResultId = null;
+ }
+ }
+
+ ResetThemeNameAvailability();
+ if (removedCount > 0)
+ {
+ SetStatus(
+ $"ThemeA 시장을 {ThemeMarketDisplayName(market)}로 변경하고 새 시장과 맞지 않는 초안 종목 {removedCount}개를 제외했습니다.",
+ LegacyOperatorCatalogStatusKind.Warning);
+ }
+ else
+ {
+ SetStatus(
+ $"ThemeA 시장을 {ThemeMarketDisplayName(market)}로 변경했습니다.",
+ LegacyOperatorCatalogStatusKind.Information);
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch
+ {
+ SetStatus(
+ "새 시장의 ThemeA 코드를 확인하지 못해 시장과 초안을 변경하지 않았습니다.",
+ LegacyOperatorCatalogStatusKind.Error);
+ }
+
+ Touch();
+ return CreateSnapshot();
+ }
+
public async Task CheckThemeNameAvailabilityAsync(
string editorName,
CancellationToken cancellationToken = default)
@@ -685,14 +790,26 @@ public sealed class LegacyOperatorCatalogWorkflowController
CancellationToken cancellationToken = default)
{
EnsureDraftRowsEditable();
- _stockQuery = ValidateRequiredText(query, LegacyStockSearchService.MaximumQueryLength);
+ _stockQuery = ValidateOptionalStockQuery(query);
_selectedStockResultId = null;
try
{
- var result = await _stockSearchService.SearchAsync(
- _stockQuery,
- MaximumStockResults,
- cancellationToken).ConfigureAwait(false);
+ var result = _stockQuery.Length == 0
+ ? await _stockSearchService.SearchAllAsync(
+ MaximumStockResults,
+ cancellationToken).ConfigureAwait(false)
+ : await _stockSearchService.SearchAsync(
+ _stockQuery,
+ MaximumStockResults,
+ cancellationToken).ConfigureAwait(false);
+ if (result is null ||
+ !string.Equals(result.Query, _stockQuery, StringComparison.Ordinal) ||
+ result.Items is null || result.Items.Count > MaximumStockResults)
+ {
+ throw new InvalidDataException(
+ "The operator-catalog stock result is not correlated or bounded.");
+ }
+
_stockResults.Clear();
_stockResults.AddRange(result.Items.Select(item => new StockResultState(
NewOpaqueId("catalog-stock"),
@@ -757,7 +874,8 @@ public sealed class LegacyOperatorCatalogWorkflowController
row = DraftRowState.ForTheme(
NewOpaqueId("catalog-draft"),
itemCode,
- itemName);
+ itemName,
+ requiresCurrentIdentityValidation: true);
}
else
{
@@ -963,11 +1081,18 @@ public sealed class LegacyOperatorCatalogWorkflowController
return RejectUnavailableWrite();
}
- _editorName = _entity == LegacyOperatorCatalogEntity.Expert &&
- _mode == LegacyOperatorCatalogMode.Edit
- ? (_expectedExpertIdentity ?? throw new InvalidOperationException(
- "The trusted EList edit identity is missing.")).ExpertName
- : ValidateRequiredText(editorName, 128);
+ _editorName = _entity switch
+ {
+ LegacyOperatorCatalogEntity.Expert when _mode == LegacyOperatorCatalogMode.Edit =>
+ (_expectedExpertIdentity ?? throw new InvalidOperationException(
+ "The trusted EList edit identity is missing.")).ExpertName,
+ LegacyOperatorCatalogEntity.Theme when _mode == LegacyOperatorCatalogMode.Edit =>
+ ValidateExistingThemeEditorName(
+ editorName,
+ _expectedThemeIdentity ?? throw new InvalidOperationException(
+ "The trusted ThemeA edit identity is missing.")),
+ _ => ValidateRequiredText(editorName, 128)
+ };
if (_entity == LegacyOperatorCatalogEntity.Theme)
{
@@ -1001,9 +1126,15 @@ public sealed class LegacyOperatorCatalogWorkflowController
var expected = _expectedThemeIdentity ?? throw new InvalidOperationException(
"The trusted ThemeA edit identity is missing.");
+ var newItems = _draftRows
+ .Where(static row => row.RequiresCurrentIdentityValidation)
+ .Select((row, index) => new ThemeCatalogItem(index, row.Code, row.Name))
+ .ToArray();
return await ExecuteWriteAsync(
ThemeSaveSuccessMessage,
- token => _identityValidationService.ValidateThemeItemsAsync(items, token),
+ token => newItems.Length == 0
+ ? Task.CompletedTask
+ : _identityValidationService.ValidateThemeItemsAsync(newItems, token),
token => _themePersistenceService.ReplaceAsync(
expected,
_editorName,
@@ -1278,7 +1409,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
{
var result = await _expertSelectionService.SearchAsync(
_query,
- MaximumCatalogResults,
+ MaximumExpertCatalogResults,
cancellationToken).ConfigureAwait(false);
_query = result.Query;
_resultsAreTruncated = result.IsTruncated;
@@ -1303,7 +1434,7 @@ public sealed class LegacyOperatorCatalogWorkflowController
new LegacyOperatorCatalogDraftRowView(
row.RowId,
_entity == LegacyOperatorCatalogEntity.Theme &&
- row.Code.Length > 0 && row.Code[0] is 'X' or 'T' &&
+ _newThemeMarket == ThemeMarket.Nxt &&
!row.Name.EndsWith("(NXT)", StringComparison.Ordinal)
? row.Name + "(NXT)"
: row.Name,
@@ -1483,13 +1614,32 @@ public sealed class LegacyOperatorCatalogWorkflowController
private bool IsCompatible(StockSearchItem item) => _entity switch
{
LegacyOperatorCatalogEntity.Expert => true,
- LegacyOperatorCatalogEntity.Theme when _newThemeMarket == ThemeMarket.Krx =>
- item.Market is StockMarket.Kospi or StockMarket.Kosdaq,
- LegacyOperatorCatalogEntity.Theme =>
- item.Market is StockMarket.NxtKospi or StockMarket.NxtKosdaq,
+ LegacyOperatorCatalogEntity.Theme => IsCompatible(item, _newThemeMarket),
_ => false
};
+ private static bool IsCompatible(StockSearchItem item, ThemeMarket market) => market switch
+ {
+ ThemeMarket.Krx => item.Market is StockMarket.Kospi or StockMarket.Kosdaq,
+ ThemeMarket.Nxt => item.Market is StockMarket.NxtKospi or StockMarket.NxtKosdaq,
+ _ => false
+ };
+
+ private static bool IsDraftRowCompatible(DraftRowState row, ThemeMarket market) =>
+ row.Code.Length > 0 && (market switch
+ {
+ ThemeMarket.Krx => row.Code[0] is 'P' or 'D',
+ ThemeMarket.Nxt => row.Code[0] is 'X' or 'T',
+ _ => false
+ });
+
+ private static string ThemeMarketDisplayName(ThemeMarket market) => market switch
+ {
+ ThemeMarket.Krx => "KRX",
+ ThemeMarket.Nxt => "NXT",
+ _ => throw new ArgumentOutOfRangeException(nameof(market))
+ };
+
private static string ThemeItemCode(StockSearchItem item) => item.Market switch
{
StockMarket.Kospi => "P" + item.Code,
@@ -1540,6 +1690,55 @@ public sealed class LegacyOperatorCatalogWorkflowController
return value;
}
+ private static string ValidateOptionalEditorText(string value, int maximumLength)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ if (value.Length > maximumLength ||
+ !string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
+ !IsSafeText(value, allowEmpty: true))
+ {
+ throw new ArgumentException("A safe canonical editor value is required.", nameof(value));
+ }
+
+ return value;
+ }
+
+ private static string ValidateOptionalStockQuery(string value)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ var normalized = value.Trim();
+ if (value.Length > LegacyStockSearchService.MaximumQueryLength ||
+ normalized.Length > LegacyStockSearchService.MaximumQueryLength ||
+ !IsSafeText(value, allowEmpty: true) ||
+ !IsSafeText(normalized, allowEmpty: true))
+ {
+ throw new ArgumentException(
+ "A safe operator-catalog stock query is required.",
+ nameof(value));
+ }
+
+ return normalized;
+ }
+
+ private static string ValidateExistingThemeEditorName(
+ string value,
+ ThemeSelectionIdentity expected)
+ {
+ if (!string.Equals(value, expected.ThemeTitle, StringComparison.Ordinal))
+ {
+ return ValidateRequiredText(value, 128);
+ }
+
+ ArgumentNullException.ThrowIfNull(value);
+ if (value.Length is < 1 or > 128 || string.IsNullOrWhiteSpace(value) ||
+ !IsSafeText(value, allowEmpty: false))
+ {
+ throw new ArgumentException("A safe stored theme name is required.", nameof(value));
+ }
+
+ return value;
+ }
+
private static bool IsSafeText(string value, bool allowEmpty)
{
if (!allowEmpty && value.Length == 0)
@@ -1625,20 +1824,28 @@ public sealed class LegacyOperatorCatalogWorkflowController
string Code,
string Name,
string MarketLabel,
- decimal? BuyAmount)
+ decimal? BuyAmount,
+ bool RequiresCurrentIdentityValidation)
{
internal static DraftRowState ForTheme(
string rowId,
string code,
- string name) =>
- new(rowId, code, name, ThemeMarketLabel(code), null);
+ string name,
+ bool requiresCurrentIdentityValidation) =>
+ new(
+ rowId,
+ code,
+ name,
+ ThemeMarketLabel(code),
+ null,
+ requiresCurrentIdentityValidation);
internal static DraftRowState ForExpert(
string rowId,
string code,
string name,
decimal? buyAmount) =>
- new(rowId, code, name, "종목", buyAmount);
+ new(rowId, code, name, "종목", buyAmount, false);
private static string ThemeMarketLabel(string code) => code.Length > 0
? code[0] switch
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs
index 4ef8ebd..3d50572 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs
@@ -179,6 +179,10 @@ public sealed class LegacyTradingHaltWorkflowController
}
_results = ValidateResult(result, normalizedQuery, generation);
+ // UC7 starts with an empty FarPoint sheet. Its first AddRows call
+ // makes row zero active, so the operator can press 현재가/120일
+ // immediately after a successful search without another click.
+ _selected = _results.FirstOrDefault();
_isTruncated = result.IsTruncated;
_status = LegacyTradingHaltRequestStatus.Ready;
_errorMessage = string.Empty;
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs
index a4145b4..750a196 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs
+++ b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs
@@ -127,6 +127,8 @@ public sealed record LegacyClearComparisonTargetsIntent : LegacyUiIntent;
public sealed record LegacyAddComparisonPairIntent : LegacyUiIntent;
+public sealed record LegacyImportComparisonPairsIntent : LegacyUiIntent;
+
public sealed record LegacySelectComparisonPairIntent(string RowId) : LegacyUiIntent;
public sealed record LegacyMoveComparisonPairIntent(LegacyComparisonPairMoveDirection Direction)
@@ -191,6 +193,10 @@ public sealed record LegacySelectOperatorCatalogResultIntent(string ResultId) :
public sealed record LegacyBeginCreateThemeCatalogIntent(ThemeMarket Market) : LegacyUiIntent;
+public sealed record LegacyChangeCreateThemeMarketIntent(
+ ThemeMarket Market,
+ string EditorName) : LegacyUiIntent;
+
public sealed record LegacyBeginCreateExpertCatalogIntent : LegacyUiIntent;
public sealed record LegacySearchOperatorCatalogStocksIntent(string Query) : LegacyUiIntent;
@@ -259,6 +265,9 @@ public sealed record LegacySelectManualFinancialStockIntent(string ResultId) : L
public sealed record LegacySaveManualFinancialIntent(ManualFinancialRecord Record)
: LegacyUiIntent;
+public sealed record LegacySaveManualFinancialRawIntent(ManualFinancialRawRecord Record)
+ : LegacyUiIntent;
+
public sealed record LegacyDeleteManualFinancialIntent : LegacyUiIntent;
public sealed record LegacyDeleteAllManualFinancialIntent : LegacyUiIntent;
@@ -318,7 +327,9 @@ public sealed record LegacyCreateNamedPlaylistIntent(string Title, string Reques
public sealed record LegacySaveCurrentNamedPlaylistIntent : LegacyUiIntent;
-public sealed record LegacySaveCurrentNamedPlaylistToIntent(string DefinitionId) : LegacyUiIntent;
+public sealed record LegacySaveCurrentNamedPlaylistToIntent(
+ string DefinitionId,
+ bool ShowSavedAlert = false) : LegacyUiIntent;
public sealed record LegacyDeleteSelectedNamedPlaylistIntent(string RequestId) : LegacyUiIntent;
@@ -328,27 +339,34 @@ public sealed record LegacyInteractionMetrics(
int CutDragWidthDips,
int CutDragHeightDips,
uint SourceDpi = 96,
- double HostRasterizationScale = 1d)
+ double HostRasterizationScale = 1d,
+ int DoubleClickTimeMilliseconds = 500)
{
public const uint CssPixelsPerInch = 96;
public const int FallbackCutDragWidth = 4;
public const int FallbackCutDragHeight = 4;
+ public const int FallbackDoubleClickTimeMilliseconds = 500;
+ public const int MaximumDoubleClickTimeMilliseconds = 5_000;
public static LegacyInteractionMetrics Default { get; } = new(
FallbackCutDragWidth,
FallbackCutDragHeight,
CssPixelsPerInch,
- 1d);
+ 1d,
+ FallbackDoubleClickTimeMilliseconds);
public static LegacyInteractionMetrics FromPixelsAtDpi(
int cutDragWidthPixels,
int cutDragHeightPixels,
uint sourceDpi,
- double hostRasterizationScale = 1d) =>
+ double hostRasterizationScale = 1d,
+ int doubleClickTimeMilliseconds = FallbackDoubleClickTimeMilliseconds) =>
sourceDpi == 0
? Default with
{
- HostRasterizationScale = NormalizeHostScale(hostRasterizationScale)
+ HostRasterizationScale = NormalizeHostScale(hostRasterizationScale),
+ DoubleClickTimeMilliseconds = NormalizeDoubleClickTime(
+ doubleClickTimeMilliseconds)
}
: new(
ToDips(
@@ -360,7 +378,8 @@ public sealed record LegacyInteractionMetrics(
sourceDpi,
FallbackCutDragHeight),
sourceDpi,
- NormalizeHostScale(hostRasterizationScale));
+ NormalizeHostScale(hostRasterizationScale),
+ NormalizeDoubleClickTime(doubleClickTimeMilliseconds));
internal static LegacyInteractionMetrics Normalize(LegacyInteractionMetrics? metrics) =>
metrics is null
@@ -373,13 +392,19 @@ public sealed record LegacyInteractionMetrics(
? metrics.CutDragHeightDips
: FallbackCutDragHeight,
metrics.SourceDpi > 0 ? metrics.SourceDpi : CssPixelsPerInch,
- NormalizeHostScale(metrics.HostRasterizationScale));
+ NormalizeHostScale(metrics.HostRasterizationScale),
+ NormalizeDoubleClickTime(metrics.DoubleClickTimeMilliseconds));
private static double NormalizeHostScale(double hostRasterizationScale) =>
double.IsFinite(hostRasterizationScale) && hostRasterizationScale > 0d
? hostRasterizationScale
: 1d;
+ private static int NormalizeDoubleClickTime(int milliseconds) =>
+ milliseconds is > 0 and <= MaximumDoubleClickTimeMilliseconds
+ ? milliseconds
+ : FallbackDoubleClickTimeMilliseconds;
+
private static int ToDips(int pixelsAtSourceDpi, uint sourceDpi, int fallback) =>
pixelsAtSourceDpi <= 0
? fallback
@@ -485,6 +510,7 @@ public static class LegacyBridgeProtocol
"swap-comparison-targets" => ParseComparisonSwap(payload),
"clear-comparison-targets" => ParseComparisonClear(payload),
"add-comparison-pair" => ParseComparisonAdd(payload),
+ "import-legacy-comparison-pairs" => ParseComparisonImport(payload),
"select-comparison-pair" => ParseComparisonPairSelection(payload),
"move-comparison-pair" => ParseComparisonPairMove(payload),
"delete-selected-comparison-pair" => ParseComparisonDeleteSelected(payload),
@@ -530,6 +556,8 @@ public static class LegacyBridgeProtocol
"search-operator-catalog" => ParseOperatorCatalogSearch(payload),
"select-operator-catalog-result" => ParseOperatorCatalogResult(payload),
"begin-create-theme-catalog" => ParseOperatorCatalogThemeCreate(payload),
+ "change-create-theme-market" =>
+ ParseOperatorCatalogThemeMarketChange(payload),
"begin-create-expert-catalog" => ParseOperatorCatalogEmpty(
payload,
static () => new LegacyBeginCreateExpertCatalogIntent()),
@@ -591,6 +619,7 @@ public static class LegacyBridgeProtocol
"search-manual-financial-stocks" => ParseManualFinancialStockSearch(payload),
"select-manual-financial-stock" => ParseManualFinancialStockSelection(payload),
"save-manual-financial" => ParseManualFinancialSave(payload),
+ "save-manual-financial-raw" => ParseManualFinancialRawSave(payload),
"delete-manual-financial" => ParseManualFinancialEmpty(
payload,
static () => new LegacyDeleteManualFinancialIntent()),
@@ -675,6 +704,7 @@ public static class LegacyBridgeProtocol
row.IsEnabled,
row.IsSelected,
row.IsActive,
+ row.IsSceneResolved,
row.MarketText,
row.StockName,
row.GraphicType,
@@ -746,6 +776,7 @@ public static class LegacyBridgeProtocol
snapshot.Overseas.Stock.Query,
snapshot.Overseas.Stock.ResultCount,
snapshot.Overseas.Stock.IsTruncated,
+ snapshot.Overseas.Stock.IsolatedInvalidRowCount,
snapshot.Overseas.Stock.SelectedId,
selectedName = snapshot.Overseas.Stock.Selected?.InputName,
snapshot.Overseas.Stock.Error,
@@ -782,10 +813,15 @@ public static class LegacyBridgeProtocol
row.SelectionId,
row.DisplayName,
row.Metadata,
- row.Kind
+ row.Kind,
+ row.CanSelect,
+ row.UnavailableReason
}),
snapshot.Comparison.DomesticSearch.SelectedResultId,
snapshot.Comparison.DomesticSearch.TotalRowCount,
+ snapshot.Comparison.DomesticSearch.IsTruncated,
+ snapshot.Comparison.DomesticSearch.IsolatedInvalidRowCount,
+ snapshot.Comparison.DomesticSearch.DeferredUnsafeRowCount,
snapshot.Comparison.DomesticSearch.ErrorMessage
},
worldSearch = new
@@ -797,10 +833,15 @@ public static class LegacyBridgeProtocol
row.SelectionId,
row.DisplayName,
row.Metadata,
- row.Kind
+ row.Kind,
+ row.CanSelect,
+ row.UnavailableReason
}),
snapshot.Comparison.WorldSearch.SelectedResultId,
snapshot.Comparison.WorldSearch.TotalRowCount,
+ snapshot.Comparison.WorldSearch.IsTruncated,
+ snapshot.Comparison.WorldSearch.IsolatedInvalidRowCount,
+ snapshot.Comparison.WorldSearch.DeferredUnsafeRowCount,
snapshot.Comparison.WorldSearch.ErrorMessage
},
fixedTargets = snapshot.Comparison.FixedTargets.Select(row => new
@@ -835,7 +876,17 @@ public static class LegacyBridgeProtocol
snapshot.Comparison.SelectedPairId,
snapshot.Comparison.Actions,
snapshot.Comparison.StatusMessage,
- snapshot.Comparison.StatusKind
+ snapshot.Comparison.StatusKind,
+ snapshot.Comparison.CanImportLegacyPairs,
+ lastImport = snapshot.Comparison.LastImport is null ? null : new
+ {
+ snapshot.Comparison.LastImport.SourceSha256,
+ snapshot.Comparison.LastImport.SourceRowCount,
+ snapshot.Comparison.LastImport.AddedPairCount,
+ snapshot.Comparison.LastImport.SkippedDuplicateCount,
+ snapshot.Comparison.LastImport.TotalPairCount,
+ snapshot.Comparison.LastImport.ImportedAt
+ }
},
theme = snapshot.Theme is null ? null : new
{
@@ -1006,13 +1057,21 @@ public static class LegacyBridgeProtocol
rows = snapshot.ManualFinancial.Rows.Select(row => new
{
row.ResultId,
- row.StockName
+ row.StockName,
+ row.PreviewValues,
+ row.IsDataReadable,
+ row.IsPlayoutReady
}),
snapshot.ManualFinancial.ListIsTruncated,
+ snapshot.ManualFinancial.ListRejectedItemCount,
snapshot.ManualFinancial.SelectedRowId,
selectedRecord = ProjectManualFinancialRecord(
snapshot.ManualFinancial.SelectedRecord),
+ selectedRawRecord = ProjectManualFinancialRawRecord(
+ snapshot.ManualFinancial.SelectedRawRecord),
snapshot.ManualFinancial.IsDetailFresh,
+ snapshot.ManualFinancial.IsRawDetailFresh,
+ snapshot.ManualFinancial.IsPlayoutReady,
snapshot.ManualFinancial.StockQuery,
stockCandidates = snapshot.ManualFinancial.StockCandidates.Select(row => new
{
@@ -1030,6 +1089,7 @@ public static class LegacyBridgeProtocol
snapshot.ManualFinancial.Status,
snapshot.ManualFinancial.WritesQuarantined,
snapshot.ManualFinancial.LastMessage,
+ snapshot.ManualFinancial.CanEdit,
snapshot.ManualFinancial.CanSave,
snapshot.ManualFinancial.CanDelete,
snapshot.ManualFinancial.CanMaterializePlaylist,
@@ -1084,6 +1144,7 @@ public static class LegacyBridgeProtocol
row.MarketText
}),
snapshot.ManualLists.SelectedSearchResultId,
+ snapshot.ManualLists.SearchIsTruncated,
snapshot.ManualLists.StatusMessage,
snapshot.ManualLists.StatusKind,
snapshot.ManualLists.CanSave,
@@ -1160,7 +1221,9 @@ public static class LegacyBridgeProtocol
coordinateSpace = "host-dip",
sourceDpi = normalizedInteractionMetrics.SourceDpi,
hostRasterizationScale =
- normalizedInteractionMetrics.HostRasterizationScale
+ normalizedInteractionMetrics.HostRasterizationScale,
+ doubleClickTimeMilliseconds =
+ normalizedInteractionMetrics.DoubleClickTimeMilliseconds
},
operatorSettings = operatorSettings is null ? null : new
{
@@ -1614,6 +1677,9 @@ public static class LegacyBridgeProtocol
private static LegacyUiIntent? ParseComparisonAdd(JsonElement payload) =>
HasOnlyProperties(payload) ? new LegacyAddComparisonPairIntent() : null;
+ private static LegacyUiIntent? ParseComparisonImport(JsonElement payload) =>
+ HasOnlyProperties(payload) ? new LegacyImportComparisonPairsIntent() : null;
+
private static LegacyUiIntent? ParseComparisonPairSelection(JsonElement payload) =>
HasOnlyProperties(payload, "rowId") &&
TryString(payload, "rowId", 128, out var rowId) &&
@@ -1841,13 +1907,40 @@ public static class LegacyBridgeProtocol
: null;
}
+ private static LegacyUiIntent? ParseOperatorCatalogThemeMarketChange(JsonElement payload)
+ {
+ if (!HasOnlyProperties(payload, "market", "editorName") ||
+ !TryString(payload, "market", 8, out var marketText) ||
+ !TrySafeString(
+ payload,
+ "editorName",
+ 128,
+ allowEmpty: true,
+ allowUnderscore: true,
+ out var editorName) ||
+ !string.Equals(editorName, editorName.Trim(), StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ var market = marketText switch
+ {
+ "krx" => ThemeMarket.Krx,
+ "nxt" => ThemeMarket.Nxt,
+ _ => (ThemeMarket?)null
+ };
+ return market.HasValue
+ ? new LegacyChangeCreateThemeMarketIntent(market.Value, editorName)
+ : null;
+ }
+
private static LegacyUiIntent? ParseOperatorCatalogStockSearch(JsonElement payload) =>
HasOnlyProperties(payload, "query") &&
TrySafeString(
payload,
"query",
LegacyStockSearchService.MaximumQueryLength,
- allowEmpty: false,
+ allowEmpty: true,
allowUnderscore: true,
out var query)
? new LegacySearchOperatorCatalogStocksIntent(query)
@@ -2038,12 +2131,31 @@ public static class LegacyBridgeProtocol
? new LegacyLoadNamedPlaylistByIdIntent(definitionId)
: null;
- private static LegacyUiIntent? ParseNamedPlaylistSaveTo(JsonElement payload) =>
- HasOnlyProperties(payload, "definitionId") &&
- TryString(payload, "definitionId", 128, out var definitionId) &&
- IsSafeIdentifier(definitionId)
- ? new LegacySaveCurrentNamedPlaylistToIntent(definitionId)
- : null;
+ private static LegacyUiIntent? ParseNamedPlaylistSaveTo(JsonElement payload)
+ {
+ var hasSavedAlert = payload.TryGetProperty("showSavedAlert", out var alertProperty);
+ if (!(hasSavedAlert
+ ? HasOnlyProperties(payload, "definitionId", "showSavedAlert")
+ : HasOnlyProperties(payload, "definitionId")) ||
+ !TryString(payload, "definitionId", 128, out var definitionId) ||
+ !IsSafeIdentifier(definitionId))
+ {
+ return null;
+ }
+
+ var showSavedAlert = false;
+ if (hasSavedAlert)
+ {
+ if (alertProperty.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
+ {
+ return null;
+ }
+
+ showSavedAlert = alertProperty.GetBoolean();
+ }
+
+ return new LegacySaveCurrentNamedPlaylistToIntent(definitionId, showSavedAlert);
+ }
private static LegacyUiIntent? ParseNamedPlaylistCreate(JsonElement payload) =>
HasOnlyProperties(payload, "title", "requestId") &&
@@ -2138,7 +2250,7 @@ public static class LegacyBridgeProtocol
payload,
"stockName",
64,
- allowEmpty: false,
+ allowEmpty: true,
allowUnderscore: true,
out var stockName)
? new LegacySearchManualFinancialStocksIntent(stockName)
@@ -2147,7 +2259,7 @@ public static class LegacyBridgeProtocol
private static LegacyUiIntent? ParseManualFinancialStockSelection(JsonElement payload) =>
HasOnlyProperties(payload, "resultId") &&
TryString(payload, "resultId", 128, out var resultId) &&
- IsSafeIdentifier(resultId)
+ (resultId.Length == 0 || IsSafeIdentifier(resultId))
? new LegacySelectManualFinancialStockIntent(resultId)
: null;
@@ -2164,6 +2276,64 @@ public static class LegacyBridgeProtocol
return record is null ? null : new LegacySaveManualFinancialIntent(record);
}
+ private static LegacyUiIntent? ParseManualFinancialRawSave(JsonElement payload)
+ {
+ if (!HasOnlyProperties(payload, "record") ||
+ !payload.TryGetProperty("record", out var recordElement) ||
+ recordElement.ValueKind != JsonValueKind.Object ||
+ !HasOnlyProperties(
+ recordElement,
+ "screen",
+ "stockName",
+ "storageValues") ||
+ !TryString(recordElement, "screen", 32, out var screenText) ||
+ !TryManualFinancialScreen(screenText, out var screen) ||
+ !TrySafeString(
+ recordElement,
+ "stockName",
+ 200,
+ allowEmpty: false,
+ allowUnderscore: true,
+ out var stockName) ||
+ stockName != stockName.Trim() || stockName.Contains('^') ||
+ !recordElement.TryGetProperty("storageValues", out var storageElement) ||
+ storageElement.ValueKind != JsonValueKind.Array)
+ {
+ return null;
+ }
+
+ var expectedCount = ManualFinancialCutContracts.Get(screen).StorageColumns.Count - 1;
+ if (storageElement.GetArrayLength() != expectedCount)
+ {
+ return null;
+ }
+
+ var values = new string[expectedCount];
+ var index = 0;
+ foreach (var item in storageElement.EnumerateArray())
+ {
+ if (item.ValueKind != JsonValueKind.String)
+ {
+ return null;
+ }
+
+ var value = item.GetString() ?? string.Empty;
+ if (value.Length > 200 ||
+ value.Any(static character =>
+ char.IsSurrogate(character) || character == '\uFFFD'))
+ {
+ return null;
+ }
+
+ values[index++] = value;
+ }
+
+ return new LegacySaveManualFinancialRawIntent(
+ new ManualFinancialRawRecord(
+ new ManualFinancialIdentity(screen, stockName),
+ Array.AsReadOnly(values)));
+ }
+
private static LegacyUiIntent? ParseManualFinancialAction(JsonElement payload) =>
HasOnlyProperties(payload, "actionId") &&
TryString(payload, "actionId", 64, out var actionId) &&
@@ -2240,7 +2410,7 @@ public static class LegacyBridgeProtocol
payload,
"query",
LegacyManualListsWorkflow.MaximumSearchLength,
- allowEmpty: false,
+ allowEmpty: true,
allowUnderscore: true,
out var query)
? new LegacySearchManualViIntent(query)
@@ -2610,6 +2780,16 @@ public static class LegacyBridgeProtocol
_ => throw new ArgumentOutOfRangeException(nameof(record))
};
+ private static object? ProjectManualFinancialRawRecord(
+ ManualFinancialRawRecord? record) => record is null
+ ? null
+ : new
+ {
+ screen = ManualFinancialScreenId(record.Identity.Screen),
+ stockName = record.Identity.StockName,
+ storageValues = record.StorageValues
+ };
+
private static bool TrySafeString(
JsonElement element,
string propertyName,
diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj
index 0fd5c11..334cdf5 100644
--- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj
+++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj
@@ -37,6 +37,10 @@
+
+
+
+
@@ -73,6 +77,16 @@
+
+
+
+
+
+
@@ -106,6 +120,12 @@
PreserveNewest
PreserveNewest
+
+ Web\Previews\%(Filename)%(Extension)
+ Web\Previews\%(Filename)%(Extension)
+ PreserveNewest
+ PreserveNewest
+