"use strict"; const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const test = require("node:test"); const repositoryRoot = path.resolve(__dirname, "..", ".."); const webRoot = path.join(repositoryRoot, "src", "MBN_STOCK_WEBVIEW.LegacyParityApp", "Web"); const app = fs.readFileSync(path.join(webRoot, "app.js"), "utf8"); const playout = fs.readFileSync(path.join(webRoot, "playout-ui.js"), "utf8"); const markup = fs.readFileSync(path.join(webRoot, "index.html"), "utf8"); test("legacy shell exposes the original stock, cut, playlist and playout controls", () => { for (const id of [ "stock-search", "stock-search-button", "stock-results", "market-tabs", "category-tree", "catalog-expand-all", "moving-average-5", "moving-average-20", "cut-list", "playlist-drop-zone", "playlist-rows", "playlist-enable-all", "playlist-move-up", "playlist-move-down", "playlist-delete-selected", "playlist-delete-all", "db-save-button", "db-load-button", "named-playlist-modal", "named-playlist-definitions", "named-playlist-new-title", "named-playlist-create-button", "named-playlist-delete-button", "named-playlist-confirm-button", "legacy-dialog" ]) { assert.match(markup, new RegExp("id=\\\"" + id + "\\\"")); } assert.match(markup, /TAKE IN/); assert.match(markup, /TAKE OUT/); assert.match(markup, /NEXT/); assert.match(markup, /class="connection-state"[^>]*>미연결<\/div>/); assert.match(markup, /class="active"[^>]*data-tab-id="overseas">해외<\/button>/); assert.match(markup, /data-tab-id="tradingHalt">정지<\/button>/); }); test("web layer sends intent and does not own scene, DB identity or playlist state", () => { assert.doesNotMatch(app, /(?:sceneCode|cutCode|stockCode|dataCode|builderKey)/); assert.doesNotMatch(app, /(?:localStorage|sessionStorage|playlist\.push|fetch\s*\()/); assert.doesNotMatch(app, /\b(?:5001|N5001|5006|5011|5086|8035|8061|8040|8046|8051|8056|8003|5037)\b/); }); test("legacy stock double click is represented by pointer-down facts", () => { assert.match(app, /cut-pointer-down/); assert.match(app, /timestampMilliseconds/); assert.match(app, /event\.ctrlKey/); assert.match(app, /event\.shiftKey/); const cutFactory = app.slice(app.indexOf("function createCutElement"), app.indexOf("function renderCuts")); assert.doesNotMatch(cutFactory, /dblclick/); }); test("cut rows are reconciled in place so a state reply cannot cancel native drag", () => { assert.match(app, /dataset\.physicalIndex/); assert.match(app, /function createCutElement/); assert.doesNotMatch(app, /cutList\.replaceChildren/); }); test("state rendering preserves stock and playlist focus like native controls", () => { assert.match(app, /dataset\.resultIndex/); assert.match(app, /dataset\.rowId/); assert.doesNotMatch(app, /stockResults\.replaceChildren/); assert.doesNotMatch(app, /playlistRows\.replaceChildren/); }); test("playlist controls send intent for selection, enabled state, movement and deletion", () => { for (const intent of [ "select-playlist-row", "set-playlist-enabled", "set-all-playlist-enabled", "move-selected-playlist-rows", "delete-selected-playlist-rows", "delete-all-playlist-rows" ]) { assert.match(app, new RegExp("send\\(\\\"" + intent + "\\\"")); } assert.match(app, /control: event\.ctrlKey/); assert.match(app, /shift: event\.shiftKey/); assert.match(app, /event\.key === "Delete"/); assert.match(app, /toggle-active-playlist-enabled/); assert.match(app, /select-playlist-boundary/); assert.match(app, /event\.key === "Home"/); assert.match(app, /event\.key === "End"/); assert.match(app, /row\.isSelected/); assert.match(app, /enabled\.disabled = state\.isBusy === true/); assert.match(app, /playlistEnableAll\.indeterminate/); }); test("playlist reorder reconciles keyed DOM nodes without discarding focus", () => { assert.match(app, /const existingRows = new Map\(\)/); assert.match(app, /existingRows\.get\(row\.rowId\)/); assert.match(app, /playlistRows\.insertBefore\(element, elementAtPosition\)/); assert.match(app, /const retainedRowIds = new Set/); assert.doesNotMatch(app, /playlistRows\.replaceChild/); }); test("market tabs send closed identity intents and reconcile C# order", () => { assert.match(app, /send\("select-tab"/); assert.match(app, /send\("swap-tabs"/); assert.match(app, /dataset\.tabId/); assert.match(app, /const existingTabs = new Map\(\)/); assert.match(app, /marketTabs\.insertBefore/); assert.doesNotMatch(app, /selectedTab\s*=/); }); test("fixed catalog renders C# view and sends opaque leaf or section intent", () => { assert.match(app, /state\.fixedCatalog/); assert.match(app, /dataset\.actionId/); assert.match(app, /dataset\.sectionIndex/); assert.match(app, /send\("activate-fixed-action"/); assert.match(app, /send\("activate-fixed-section"/); assert.match(app, /send\("activate-industry-section"/); assert.match(app, /send\("activate-comparison-section"/); assert.match(app, /send\("set-moving-averages"/); assert.doesNotMatch(app, /builderKey|sceneCode|cutCode|dataCode/); }); test("fixed-262 ignores a single click and emits exactly one intent on double click", () => { const doubleClickStart = app.indexOf('categoryTree.addEventListener("dblclick"'); const clickStart = app.indexOf( 'categoryTree.addEventListener("click"', doubleClickStart + 1); const changeStart = app.indexOf( 'categoryTree.addEventListener("change"', clickStart + 1); assert.ok(doubleClickStart >= 0 && clickStart > doubleClickStart && changeStart > clickStart); class FixedActionElement extends EventTarget { constructor() { super(); this.dataset = { actionId: "fixed-262" }; this.disabled = false; } closest(selector) { return selector === "button[data-action-id]" ? this : null; } } const categoryTree = new FixedActionElement(); const sent = []; const send = (type, payload) => sent.push({ type, payload }); new Function("categoryTree", "send", app.slice(doubleClickStart, clickStart))( categoryTree, send); new Function("categoryTree", "send", app.slice(clickStart, changeStart))( categoryTree, send); categoryTree.dispatchEvent(new Event("click", { bubbles: true, cancelable: true })); assert.deepEqual(sent, []); categoryTree.dispatchEvent(new Event("dblclick", { bubbles: true, cancelable: true })); assert.deepEqual(sent, [{ type: "activate-fixed-action", payload: { actionId: "fixed-262" } }]); }); test("industry workspace sends index and opaque action intents only", () => { assert.match(app, /state\.industry/); assert.match(app, /dataset\.industryIndex/); assert.match(app, /dataset\.industryActionId/); assert.match(app, /send\("select-industry"/); assert.match(app, /send\("double-click-industry"/); assert.match(app, /send\("swap-industries"/); assert.match(app, /send\("activate-industry-action"/); }); test("comparison workspace sends opaque target, pair and action intents", () => { for (const intent of [ "search-comparison-domestic", "search-comparison-world", "select-comparison-domestic", "select-comparison-world", "select-comparison-fixed", "choose-comparison-domestic", "choose-comparison-world", "choose-comparison-fixed", "swap-comparison-targets", "clear-comparison-targets", "add-comparison-pair", "select-comparison-pair", "move-comparison-pair", "delete-selected-comparison-pair", "delete-all-comparison-pairs", "activate-comparison-action" ]) assert.match(app, new RegExp("\\\"" + intent + "\\\"")); assert.match(app, /dataset\.comparisonSelectionId/); assert.match(app, /dataset\.comparisonPairId/); assert.match(app, /dataset\.comparisonActionId/); assert.doesNotMatch(app, /send\([^\n]*stockCode/); }); test("theme workspace sends query, opaque selection, sort and action intents", () => { assert.match(app, /state\.theme/); assert.match(app, /dataset\.themeResultId/); assert.match(app, /dataset\.themeActionId/); assert.match(app, /send\("search-themes"/); assert.match(app, /send\("select-theme"/); assert.match(app, /send\("set-theme-sort"/); assert.match(app, /send\("activate-theme-action"/); }); test("expert and trading halt workspaces send opaque selection and action intents", () => { for (const intent of [ "search-experts", "select-expert", "activate-expert-action", "search-trading-halts", "select-trading-halt", "toggle-trading-halt-sort", "activate-trading-halt-action" ]) assert.match(app, new RegExp("send\\(\\\"" + intent + "\\\"")); assert.match(app, /dataset\.expertSelectionId/); assert.match(app, /dataset\.haltSelectionId/); }); test("theme expert and trading halt actions preserve original single click wiring", () => { const doubleClickStart = app.indexOf('categoryTree.addEventListener("dblclick"'); const clickStart = app.indexOf('categoryTree.addEventListener("click"', doubleClickStart + 1); const changeStart = app.indexOf('categoryTree.addEventListener("change"', clickStart + 1); assert.ok(doubleClickStart >= 0 && clickStart > doubleClickStart && changeStart > clickStart); const doubleClickDelegate = app.slice(doubleClickStart, clickStart); const clickDelegate = app.slice(clickStart, changeStart); for (const intent of [ "activate-theme-action", "activate-expert-action", "activate-trading-halt-action" ]) { assert.match(clickDelegate, new RegExp("send\\(\\\"" + intent + "\\\"")); assert.doesNotMatch(doubleClickDelegate, new RegExp("send\\(\\\"" + intent + "\\\"")); assert.equal(app.split('send("' + intent + '"').length - 1, 1); } assert.match(doubleClickDelegate, /send\("double-click-industry"/); for (const intent of [ "choose-comparison-domestic", "choose-comparison-world", "choose-comparison-fixed" ]) { assert.match(doubleClickDelegate, new RegExp("send\\(.*\\\"" + intent + "\\\"", "s")); assert.doesNotMatch(clickDelegate, new RegExp("send\\(.*\\\"" + intent + "\\\"", "s")); } for (const intent of [ "select-comparison-domestic", "select-comparison-world", "select-comparison-fixed" ]) { assert.match(clickDelegate, new RegExp("send\\(.*\\\"" + intent + "\\\"", "s")); assert.doesNotMatch(doubleClickDelegate, new RegExp("send\\(.*\\\"" + intent + "\\\"", "s")); } assert.match(clickDelegate, /send\("select-theme"/); assert.match(clickDelegate, /send\("select-expert"/); assert.match(clickDelegate, /send\("select-trading-halt"/); }); test("named DB playlists and playout use separate closed native intents", () => { for (const intent of [ "refresh-named-playlists", "select-named-playlist", "load-selected-named-playlist", "create-named-playlist", "save-current-named-playlist", "delete-selected-named-playlist" ]) assert.match(app, new RegExp("\\\"" + intent + "\\\"")); assert.match(app, /state\.namedPlaylist/); assert.match(app, /dataset\.namedPlaylistDefinitionId/); assert.match(app, /definitionId: definition\.dataset\.namedPlaylistDefinitionId/); assert.match(app, /dbLoadButton\.disabled = busy \|\| !named/); assert.match(app, /named\.isWriteQuarantined/); assert.doesNotMatch(app, /programCode|listText/i); for (const label of ["PREPARE", "TAKE IN", "NEXT", "TAKE OUT"]) { assert.match(markup, new RegExp("]*disabled[^>]*>[^<]*" + label)); } for (const intent of [ "prepare-playout", "take-in", "next-playout", "take-out", "choose-background", "toggle-background", "set-fade-duration" ]) assert.match(playout, new RegExp("\\\"" + intent + "\\\"")); assert.match(playout, /playout\.phase !== "prepared"/); assert.match(playout, /playout\.isPlayCompletionPending === true/); assert.match(playout, /playout\.outcomeUnknown !== true/); assert.doesNotMatch(playout, /fetch\s*\(|localStorage|sessionStorage|sceneCode|filePath/); }); test("GraphE preserves name header sorting and directional find in native authority", () => { for (const intent of [ "find-manual-financial", "toggle-manual-financial-name-sort" ]) assert.match(app, new RegExp("\\\"" + intent + "\\\"")); assert.match(app, /id = "manual-financial-find-query"/); assert.match(app, /dataset\.manualFindDirection/); assert.match(app, /dataset\.manualNameSort/); assert.match(app, /dataset\.manualGeneration/); assert.match(app, /generation: generation/); assert.match(app, /manual-financial-find-query[\s\S]*down\.click\(\)/); assert.doesNotMatch(app, /(?:INPUT_PIE|INPUT_GROW|INPUT_SELL|INPUT_PROFIT)[\s\S]*manual-financial-find-query/); }); test("FSell and VI dialogs send opaque native intents and never own file or stock authority", () => { for (const id of ["manual-list-modal", "manual-list-workspace", "manual-list-close"]) { assert.match(markup, new RegExp("id=\\\"" + id + "\\\"")); } for (const screen of ["individual", "foreign", "institution", "vi"]) { assert.match(markup, new RegExp("data-manual-list-screen=\\\"" + screen + "\\\"")); } for (const intent of [ "open-manual-list", "close-manual-list", "refresh-manual-list", "set-manual-net-sell-cell", "search-manual-vi", "select-manual-vi-result", "add-manual-vi-result", "move-manual-vi-item", "delete-manual-vi-item", "delete-all-manual-vi-items", "confirm-manual-list", "import-manual-lists" ]) assert.match(app, new RegExp("\\\"" + intent + "\\\"")); for (const obsoleteIntent of ["save-manual-net-sell", "add-manual-net-sell-cut", "save-manual-vi"]) { assert.doesNotMatch(app, new RegExp("\\\"" + obsoleteIntent + "\\\"")); } const start = app.indexOf("function renderManualLists"); const end = app.indexOf("function openNamedPlaylist", start); const manualLists = app.slice(start, end); assert.match(manualLists, /rowId: row\.rowId/); assert.match(manualLists, /resultId: row\.resultId/); assert.match(manualLists, /itemId: row\.itemId/); assert.match(manualLists, /addEventListener\("click"[\s\S]*setTimeout[\s\S]*send\("select-manual-vi-result"/); assert.match(manualLists, /addEventListener\("dblclick"[\s\S]*send\("add-manual-vi-result"/); assert.doesNotMatch(manualLists, /(?:rowVersion|stockCode|item\.code|filePath|directory|localStorage|fetch\s*\()/i); }); test("separator rows preserve the original drag gesture", () => { assert.match(app, /element\.draggable = true/); assert.doesNotMatch(app, /draggable = !row\.isSeparator/); }); test("a C# busy snapshot freezes controls instead of silently accepting queued input", () => { const styles = fs.readFileSync(path.join(webRoot, "styles.css"), "utf8"); assert.match(app, /state\.isBusy === true/); assert.match(app, /classList\.toggle\("busy"/); assert.match(styles, /body\.busy \.operator-shell \{ pointer-events: none; \}/); });