Migrate remaining legacy operator workflows
This commit is contained in:
@@ -12,10 +12,13 @@ const manualListsWorkflow = require("../../Web/manual-lists-workflow.js");
|
||||
|
||||
const repositoryRoot = path.resolve(__dirname, "..", "..");
|
||||
const read = relativePath => fs.readFileSync(path.join(repositoryRoot, relativePath), "utf8");
|
||||
const appHost = read("App.xaml.cs");
|
||||
const app = read("Web/app.js");
|
||||
const index = read("Web/index.html");
|
||||
const styles = read("Web/styles.css");
|
||||
const manualFinancialUi = read("Web/manual-financial-ui.js");
|
||||
const mainWindow = read("MainWindow.xaml.cs");
|
||||
const closeConfirmationBridge = read("MainWindow.CloseConfirmation.cs");
|
||||
const manualFinancialBridge = read("MainWindow.ManualFinancial.cs");
|
||||
const manualListsBridge = read("MainWindow.ManualLists.cs");
|
||||
const namedPlaylistBridge = read("MainWindow.NamedPlaylists.cs");
|
||||
@@ -67,6 +70,24 @@ test("operator styles and scripts load once in dependency order before app.js",
|
||||
]);
|
||||
});
|
||||
|
||||
test("the packaged app registers one primary instance before constructing any runtime", () => {
|
||||
const register = appHost.indexOf("AppInstance.FindOrRegisterForKey(MainInstanceKey)");
|
||||
const currentCheck = appHost.indexOf("if (!registeredInstance.IsCurrent)", register);
|
||||
const redirect = appHost.indexOf("RedirectActivationToAsync(activation)", currentCheck);
|
||||
const secondaryExit = appHost.indexOf("Exit();", redirect);
|
||||
const windowConstruction = appHost.indexOf("_window = new MainWindow();", secondaryExit);
|
||||
|
||||
assert.ok(register >= 0);
|
||||
assert.ok(currentCheck > register);
|
||||
assert.ok(redirect > currentCheck);
|
||||
assert.ok(secondaryExit > redirect);
|
||||
assert.ok(windowConstruction > secondaryExit);
|
||||
assert.match(appHost, /_mainInstance\.Activated \+= OnMainInstanceActivated/u);
|
||||
assert.match(appHost,
|
||||
/OnMainInstanceActivated\([\s\S]*DispatcherQueue\.TryEnqueue\(window\.Activate\)/u);
|
||||
assert.doesNotMatch(appHost, /GetProcessesByName|Process\.Kill|Environment\.Exit/u);
|
||||
});
|
||||
|
||||
test("legacy GraphE, FSell, VIList, ThemeA and EList controls open their real controllers", () => {
|
||||
assert.match(index, /id="themeCatalogManageButton"/u);
|
||||
assert.match(index, /id="expertCatalogManageButton"/u);
|
||||
@@ -90,6 +111,52 @@ test("legacy GraphE, FSell, VIList, ThemeA and EList controls open their real co
|
||||
assert.match(app, /expertCatalogManageButton\.disabled = operatorUiLocked\(\)/u);
|
||||
});
|
||||
|
||||
test("the original stock search and 31-cut surface stays independent of every business tab", () => {
|
||||
const stockPanelStart = index.indexOf('<section class="panel stock-panel">');
|
||||
const stockWorkflow = index.indexOf('id="stockWorkflow"', stockPanelStart);
|
||||
const catalogPanelStart = index.indexOf('<section class="panel catalog-panel">', stockWorkflow);
|
||||
assert.ok(stockPanelStart >= 0);
|
||||
assert.ok(stockWorkflow > stockPanelStart);
|
||||
assert.ok(catalogPanelStart > stockWorkflow);
|
||||
assert.match(styles,
|
||||
/\.console-grid\s*\{[^}]*grid-template-columns:\s*minmax\([^;]*minmax\([^;]*minmax\([^;]*minmax\(/u);
|
||||
assert.match(styles,
|
||||
/\.stock-panel \.stock-workflow\s*\{[^}]*flex:\s*1 1 auto/u);
|
||||
|
||||
const render = functionBody(app, "renderStockWorkflow", "requestStockSearch");
|
||||
assert.match(render, /elements\.stockWorkflow\.hidden = false/u);
|
||||
assert.doesNotMatch(render, /state\.activeMarket/u);
|
||||
|
||||
const binding = functionBody(app, "bindEvents", null);
|
||||
assert.doesNotMatch(binding,
|
||||
/industryWorkflow\.markets\.includes\(state\.activeMarket\)[\s\S]{0,160}state\.stockWorkflowCollapsed = true/u);
|
||||
});
|
||||
|
||||
test("the original first overseas tab is the one and only initial market-data view", () => {
|
||||
assert.match(index,
|
||||
/<button class="nav-item active" data-market="overseas"><span>◉<\/span>해외<\/button>/u);
|
||||
assert.match(index,
|
||||
/<button class="nav-item" data-market="dashboard"><span>⌂<\/span>대시보드<\/button>/u);
|
||||
assert.match(index, /<h1 id="workspaceTitle">해외 그래픽<\/h1>/u);
|
||||
assert.match(app, /const state = \{\s*activeMarket: "overseas",/u);
|
||||
|
||||
const initialize = functionBody(app, "initialize", null);
|
||||
assert.equal([...initialize.matchAll(/requestMarketData\(/gu)].length, 1);
|
||||
assert.match(initialize, /requestMarketData\(state\.activeMarket\);/u);
|
||||
assert.doesNotMatch(initialize, /requestMarketData\("(?:index|dashboard|foreign-stock)"\)/u);
|
||||
|
||||
const request = functionBody(app, "requestMarketData", "handleMarketData");
|
||||
assert.match(request,
|
||||
/postNative\("request-market-data", \{ requestId, view, maximumRowsPerTable: 250 \}\)/u);
|
||||
});
|
||||
|
||||
test("the Web message allowlist has no unused external-open or reload command surface", () => {
|
||||
assert.doesNotMatch(mainWindow, /case "open-external"/u);
|
||||
assert.doesNotMatch(mainWindow, /case "reload"/u);
|
||||
assert.doesNotMatch(app, /postNative\("open-external"/u);
|
||||
assert.doesNotMatch(app, /postNative\("reload"/u);
|
||||
});
|
||||
|
||||
test("first-routing consumes only messages owned by an operator controller", () => {
|
||||
const nativeHandler = functionBody(app, "handleNativeMessage", "bindEvents");
|
||||
const financialOffset = nativeHandler.indexOf("manualFinancialUi?.handleMessage");
|
||||
@@ -162,6 +229,7 @@ test("operator locks and trusted append checks cover every migrated manual surfa
|
||||
const append = functionBody(app, "appendTrustedOperatorEntry", "isPlaylistSnapshotLocked");
|
||||
assert.match(append, /if \(operatorUiLocked\(\)\) throw/u);
|
||||
assert.match(append, /manualListsWorkflow\.restorePlaylistEntry\(entry\)/u);
|
||||
assert.match(append, /manualListsWorkflow\.isPlaylistEntryPlayable\(entry\)/u);
|
||||
assert.match(append, /manualFinancialWorkflow\.isPlaylistEntryPlayable\(entry\)/u);
|
||||
assert.match(append,
|
||||
/catalog\.find\(value => value\.builderKey === entry\.builderKey &&[\s\S]*sceneAliases\(value\)\.includes/u);
|
||||
@@ -170,6 +238,103 @@ test("operator locks and trusted append checks cover every migrated manual surfa
|
||||
/if \(!workflow\.isPlaylistEntryPlayable\(entry\)\) throw new TypeError\("Untrusted entry\."\)/u);
|
||||
});
|
||||
|
||||
test("named PLAY_LIST replacement admits manual rows only through live fresh-read trust", () => {
|
||||
const trusted = functionBody(
|
||||
app,
|
||||
"isTrustedNamedPlaylistEntry",
|
||||
"selectedNamedPlaylistDefinition");
|
||||
assert.match(trusted,
|
||||
/namedManualRestoreWorkflow\.isTrustedEntryForSave\(entry, itemIndex\)/u);
|
||||
assert.match(trusted,
|
||||
/guard && \(!guard\.trusted \|\| \(guard\.pageRecalculationRequired && !guard\.pageRecalculated\)\)/u);
|
||||
|
||||
const projection = functionBody(
|
||||
app,
|
||||
"namedLegacyFieldsForEntry",
|
||||
"isTrustedNamedPlaylistEntry");
|
||||
assert.match(projection, /namedManualRestoreWorkflow\.legacyFieldsForEntry\(entry, itemIndex\)/u);
|
||||
assert.match(projection, /legacyNamedRowWorkflow\.legacySelectionForEntry\(entry\)/u);
|
||||
assert.match(projection, /resolveNamedPlaylistRawRow\(/u);
|
||||
assert.match(projection, /if \(!selection\)[\s\S]*throw new Error/u);
|
||||
|
||||
const replace = functionBody(
|
||||
app,
|
||||
"requestNamedPlaylistReplace",
|
||||
"requestNamedPlaylistDelete");
|
||||
assert.match(replace, /isTrustedEntry: isTrustedNamedPlaylistEntry/u);
|
||||
assert.match(replace, /pageTextForEntry: namedPageTextForEntry/u);
|
||||
assert.match(replace, /fieldsForEntry: namedLegacyFieldsForEntry/u);
|
||||
assert.match(replace, /beginNamedPlaylistOperation\("replace", request/u);
|
||||
});
|
||||
|
||||
test("local FSell and VI restores remain blocked until a correlated fresh read", () => {
|
||||
const normalizeStored = functionBody(
|
||||
app,
|
||||
"normalizeStoredPlaylist",
|
||||
"manualFinancialRestoreRecordForRequest");
|
||||
assert.match(normalizeStored, /manualListsWorkflow\.restorePlaylistEntry\(item\)/u);
|
||||
assert.match(normalizeStored,
|
||||
/namedManualRestoreWorkflow\.createStoredManualListPending\([\s\S]*restoredManualListEntry,[\s\S]*itemIndex/u);
|
||||
assert.match(normalizeStored,
|
||||
/options\.manualFinancialRestores\.push\(pendingManualListEntry\)/u);
|
||||
|
||||
const install = functionBody(
|
||||
app,
|
||||
"installManualFinancialRestores",
|
||||
"handleManualFinancialRestoreLoadResults");
|
||||
assert.match(install, /isStoredManualListPending\(pending\)[\s\S]*"stored-list"/u);
|
||||
|
||||
const handler = functionBody(
|
||||
app,
|
||||
"handleNamedManualListResults",
|
||||
"handleNamedManualListError");
|
||||
assert.match(handler, /\["named-list", "stored-list"\]\.includes\(record\.restoreKind\)/u);
|
||||
assert.match(handler,
|
||||
/record\.restoreKind === "named-list"[\s\S]*trustNamedManualRestoration[\s\S]*matchesMaterializedEntry\(record\.pending, current\)/u);
|
||||
|
||||
const blocker = functionBody(app, "manualFinancialPrepareBlocked", "loadPlaylist");
|
||||
assert.match(blocker,
|
||||
/legacy-manual-lists-workflow[\s\S]*manualListsWorkflow\.isPlaylistEntryPlayable\(item\)/u);
|
||||
});
|
||||
|
||||
test("named DB operations stay locked for the whole asynchronous manual restore", () => {
|
||||
const busy = functionBody(app, "namedPlaylistBusy", "renderNamedPlaylist");
|
||||
assert.match(busy, /state\.manualFinancialRestore\.entries\.size > 0/u);
|
||||
|
||||
const render = functionBody(app, "renderNamedPlaylist", "beginNamedPlaylistOperation");
|
||||
assert.match(render, /const freshRestorePending = state\.manualFinancialRestore\.entries\.size > 0/u);
|
||||
assert.match(render, /const busy = Boolean\(pending\) \|\| freshRestorePending/u);
|
||||
assert.match(render, /else if \(freshRestorePending\)[\s\S]*재검증 중/u);
|
||||
assert.match(render, /namedPlaylistLoadButton\.disabled = !nativeBridge \|\| locked \|\| busy/u);
|
||||
|
||||
for (const name of [
|
||||
"requestNamedPlaylistLoad",
|
||||
"requestNamedPlaylistCreate",
|
||||
"requestNamedPlaylistReplace",
|
||||
"requestNamedPlaylistDelete"
|
||||
]) {
|
||||
const body = functionBody(app, name, null);
|
||||
assert.match(body, /namedPlaylistBusy\(\)/u);
|
||||
}
|
||||
});
|
||||
|
||||
test("playlist editing cannot orphan a queued manual restore and failed rows remain removable", () => {
|
||||
const editable = functionBody(app, "requirePlaylistEditable", "updateClock");
|
||||
assert.match(editable, /restoreRecords[\s\S]*record\.status === "error"/u);
|
||||
assert.match(editable,
|
||||
/state\.namedPlaylist\.pending \|\| state\.namedPlaylist\.pagePlanPending/u);
|
||||
assert.match(editable, /allowFailedManualRestore && failedRestoreOnly/u);
|
||||
|
||||
const remove = functionBody(app, "deleteSelected", "deleteAllPlaylistEntries");
|
||||
assert.match(remove, /deletingFailedRestores/u);
|
||||
assert.match(remove, /requirePlaylistEditable\(deletingFailedRestores\)/u);
|
||||
assert.match(remove, /requestNextManualFinancialRestore\(\)/u);
|
||||
|
||||
const removeAll = functionBody(app, "deleteAllPlaylistEntries", "selectPlaylistBoundary");
|
||||
assert.match(removeAll, /requirePlaylistEditable\(failedRestoreOnly\)/u);
|
||||
assert.match(removeAll, /clearNamedPlaylistLoadState\(\)/u);
|
||||
});
|
||||
|
||||
test("stored GraphE entries remain unplayable until exact non-truncated DB revalidation", () => {
|
||||
const normalizeStored = functionBody(app, "normalizeStoredPlaylist", "manualFinancialRestoreRecordForRequest");
|
||||
assert.match(normalizeStored, /manualFinancialWorkflow\.restorePlaylistEntry\(item\)/u);
|
||||
@@ -203,6 +368,24 @@ test("stored GraphE entries remain unplayable until exact non-truncated DB reval
|
||||
/if \(command === "prepare"\)[\s\S]*if \(manualFinancialPrepareBlocked\(\)\)[\s\S]*return false/u);
|
||||
});
|
||||
|
||||
test("NXT GraphE restore searches the raw master name but verifies the suffixed display identity", () => {
|
||||
const loadResults = functionBody(
|
||||
app,
|
||||
"handleManualFinancialRestoreLoadResults",
|
||||
"handleManualFinancialRestoreLoadError");
|
||||
assert.match(loadResults,
|
||||
/expectedMarket[\s\S]*startsWith\("nxt-"\)[\s\S]*endsWith\(suffix\)[\s\S]*slice\(0, -suffix\.length\)/u);
|
||||
assert.match(loadResults, /query: searchQuery/u);
|
||||
|
||||
const stockResults = functionBody(
|
||||
app,
|
||||
"handleManualFinancialRestoreStockResults",
|
||||
"handleManualFinancialRestoreStockError");
|
||||
assert.match(stockResults,
|
||||
/item\.name === record\.pending\.stockName && item\.market === record\.pending\.market/u);
|
||||
assert.match(stockResults, /manualFinancialWorkflow\.verifyStockForRecord/u);
|
||||
});
|
||||
|
||||
test("native root routes all operator families and invalidates every browser correlation", () => {
|
||||
assert.match(mainWindow, /TryHandleOperatorCatalogRequest\(request\.Type, request\.Payload\)/u);
|
||||
assert.match(mainWindow, /TryHandleManualFinancialRequest\(request\.Type, request\.Payload\)/u);
|
||||
@@ -338,7 +521,7 @@ test("malformed operator payloads retain a family-specific fail-closed fallback"
|
||||
/case "request-named-playlist-list":[\s\S]*case "delete-named-playlist":[\s\S]*PostNamedPlaylistError\([\s\S]*"INVALID_REQUEST"/u);
|
||||
});
|
||||
|
||||
test("window shutdown detaches and disposes both trusted manual stores after their gate is idle", () => {
|
||||
test("window shutdown disposes both trusted manual stores and the one-time importer after their gate is idle", () => {
|
||||
const closed = sliceBetween(mainWindow, "private void OnClosed(", "private sealed record WebRequest(");
|
||||
assert.match(closed, /ShutdownManualOperatorDataRuntime\(\)/u);
|
||||
|
||||
@@ -349,18 +532,81 @@ test("window shutdown detaches and disposes both trusted manual stores after the
|
||||
assert.match(shutdown, /InvalidateManualOperatorBrowserRequests\(\)/u);
|
||||
assert.match(shutdown, /Interlocked\.Exchange\(ref _manualNetSellStore, null\)/u);
|
||||
assert.match(shutdown, /Interlocked\.Exchange\(ref _viManualListStore, null\)/u);
|
||||
assert.match(shutdown, /DisposeManualOperatorStoresWhenIdleAsync\(netSellStore, viStore\)/u);
|
||||
assert.match(shutdown, /Interlocked\.Exchange\(ref _legacyManualOperatorDataImporter, null\)/u);
|
||||
assert.match(shutdown, /DisposeManualOperatorStoresWhenIdleAsync\(netSellStore, viStore, importer\)/u);
|
||||
|
||||
const dispose = sliceBetween(
|
||||
manualListsBridge,
|
||||
"private async Task DisposeManualOperatorStoresWhenIdleAsync(",
|
||||
"private void InvalidateManualOperatorBrowserRequests(");
|
||||
assert.match(dispose, /await _manualOperatorDataGate\.WaitAsync\(\)/u);
|
||||
assert.match(dispose, /importer\?\.Dispose\(\)/u);
|
||||
assert.match(dispose, /viStore\?\.Dispose\(\)/u);
|
||||
assert.match(dispose, /netSellStore\?\.Dispose\(\)/u);
|
||||
assert.match(dispose, /_manualOperatorDataGate\.Release\(\)/u);
|
||||
});
|
||||
|
||||
test("system close requires an explicit default-cancel confirmation without an automatic playout command", () => {
|
||||
assert.match(mainWindow,
|
||||
/InitializeComponent\(\);\s*LogNativeOperatorStartup\(\);\s*EnsureCloseConfirmationAttached\(\);/u);
|
||||
assert.match(mainWindow,
|
||||
/private void OnClosed\([\s\S]*DetachCloseConfirmation\(\);[\s\S]*ShutdownPlayoutRuntime\(\)/u);
|
||||
|
||||
assert.match(closeConfirmationBridge,
|
||||
/appWindow\.Closing \+= OnAppWindowClosing/u);
|
||||
assert.match(closeConfirmationBridge,
|
||||
/if \(Volatile\.Read\(ref _closeConfirmed\) != 0\)[\s\S]*args\.Cancel = true/u);
|
||||
assert.match(closeConfirmationBridge,
|
||||
/Interlocked\.CompareExchange\(ref _closeConfirmationPending, 1, 0\)/u);
|
||||
assert.match(closeConfirmationBridge,
|
||||
/DefaultButton = ContentDialogButton\.Close/u);
|
||||
assert.match(closeConfirmationBridge,
|
||||
/result != ContentDialogResult\.Primary/u);
|
||||
assert.match(closeConfirmationBridge,
|
||||
/Interlocked\.Exchange\(ref _closeConfirmed, 1\);\s*Close\(\);/u);
|
||||
assert.match(closeConfirmationBridge,
|
||||
/appWindow\.Closing -= OnAppWindowClosing/u);
|
||||
assert.match(closeConfirmationBridge,
|
||||
/종료 과정에서는 TAKE OUT을 자동 실행하지 않습니다/u);
|
||||
assert.doesNotMatch(closeConfirmationBridge,
|
||||
/TakeOutAsync|StopAll|StopAllAsync|playout-command/u);
|
||||
});
|
||||
|
||||
test("operator modals isolate global playout and playlist keyboard shortcuts", () => {
|
||||
const modalGate = functionBody(app, "hasOpenOperatorModal", "isolateOperatorModalShortcut");
|
||||
assert.match(modalGate,
|
||||
/dialog\[open\], \[role="dialog"\]\[aria-modal="true"\]/u);
|
||||
assert.match(modalGate, /!element\.hidden && !element\.closest\("\[hidden\]"\)/u);
|
||||
|
||||
const isolation = functionBody(app, "isolateOperatorModalShortcut", null);
|
||||
assert.match(isolation, /if \(!hasOpenOperatorModal\(\)\) return false/u);
|
||||
for (const key of ["F2", "F3", "F8", "Escape", "Home", "End", "Delete"]) {
|
||||
assert.match(isolation, new RegExp(`"${key}"`, "u"));
|
||||
}
|
||||
assert.match(isolation, /event\.ctrlKey && key\.toLowerCase\(\) === "k"/u);
|
||||
assert.match(isolation, /showToast\("편집 창을 먼저 닫은 뒤 송출 명령을 실행하세요\."\)/u);
|
||||
|
||||
const keyHandler = app.slice(app.indexOf('document.addEventListener("keydown"'));
|
||||
const gate = keyHandler.indexOf("if (isolateOperatorModalShortcut(event)) return;");
|
||||
const playout = keyHandler.indexOf('if (["F2", "F3", "F8", "Escape"].includes(event.key))');
|
||||
const playlistDelete = keyHandler.indexOf('if (event.key === "Delete")');
|
||||
assert.ok(gate >= 0 && playout > gate && playlistDelete > gate);
|
||||
});
|
||||
|
||||
test("generic native bridge errors are bounded and visible without inventing correlation", () => {
|
||||
const bridgeError = functionBody(app, "handleBridgeError", "handleNativeMessage");
|
||||
assert.match(bridgeError, /rawMessage\.length <= 512/u);
|
||||
assert.match(bridgeError, /Native Bridge가 올바르지 않은 요청을 거부했습니다/u);
|
||||
assert.match(bridgeError, /addLog\(`Native Bridge 요청 오류 · \$\{message\}`\)/u);
|
||||
assert.match(bridgeError, /showToast\(message\)/u);
|
||||
assert.doesNotMatch(bridgeError,
|
||||
/pendingCommand\s*=\s*null|clearPlayoutError|requestPlayoutCommand|retry/u);
|
||||
|
||||
const nativeSwitch = functionBody(app, "handleNativeMessage", null);
|
||||
assert.match(nativeSwitch,
|
||||
/case "bridge-error":\s*handleBridgeError\(message\.payload\);\s*break;/u);
|
||||
});
|
||||
|
||||
test("GraphE async UI boundaries retain the dispatcher context before posting WebView replies", () => {
|
||||
assert.doesNotMatch(manualFinancialBridge,
|
||||
/operationBody\(requestToken\)\.ConfigureAwait\(false\)/u);
|
||||
|
||||
Reference in New Issue
Block a user