"use strict"; const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const test = require("node:test"); const repositoryRoot = path.resolve(__dirname, "..", ".."); const app = fs.readFileSync(path.join(repositoryRoot, "src", "MBN_STOCK_WEBVIEW.LegacyParityApp", "Web", "app.js"), "utf8"); function section(startMarker, endMarker) { const start = app.indexOf(startMarker); const end = app.indexOf(endMarker, start + startMarker.length); assert.ok(start >= 0 && end > start, `Unable to extract ${startMarker} through ${endMarker}`); return app.slice(start, end); } function workspaceHarness(source, keyVariable, renderFunction, workspaceSelector, inputs) { Object.entries(inputs).forEach(([selector, input]) => { if (selector.startsWith("#") && !input.id) input.id = selector.slice(1); }); let replacements = 0; const workspace = { querySelector(selector) { return inputs[selector] || null; } }; const categoryTree = { querySelector(selector) { return selector === workspaceSelector ? workspace : null; }, replaceChildren() { replacements += 1; } }; const documentObject = { activeElement: null }; const draftSupport = section( "function rememberSearchDraft", "function isImeComposing"); const harness = new Function("categoryTree", "document", "draftIds", ` const searchDraftInputIds = new Set(draftIds); const searchDrafts = new Map(); ${draftSupport} let ${keyVariable} = ""; let comparisonTargetPendingSelection = null; function clearComparisonTargetPendingSelection() {} function applyPendingRowSelection() {} ${source} return { render: ${renderFunction}, prime: function (value) { ${keyVariable} = value; }, remember: rememberSearchDraft }; `)(categoryTree, documentObject, Object.keys(inputs).filter(selector => selector.startsWith("#")) .map(selector => selector.slice(1))); return { ...harness, workspace, document: documentObject, replacements: () => replacements }; } test("expert search keeps the same focused input across equivalent native states", () => { const input = { value: "ㄱㅈ" }; const harness = workspaceHarness( section("function updateExpertWorkspace", "function updateTradingHaltWorkspace"), "expertRenderStructureKey", "renderExpert", ".expert-workspace", { "#expert-search": input }); const state = { expert: { search: { query: "", results: [], isTruncated: false }, selectedExpert: null, preview: { items: [] }, actions: [] } }; const key = JSON.stringify({ results: state.expert.search.results, isTruncated: state.expert.search.isTruncated, selectedExpert: state.expert.selectedExpert, preview: state.expert.preview, actions: state.expert.actions }); harness.prime(key); harness.document.activeElement = input; harness.render(state); assert.equal(input.value, "ㄱㅈ"); assert.equal(harness.replacements(), 0); harness.document.activeElement = null; state.expert.search.query = "김전문"; harness.render(state); assert.equal(input.value, "김전문"); assert.equal(harness.replacements(), 0); }); test("an unsubmitted search draft survives blur and unrelated native renders", () => { const input = { value: "" }; const harness = workspaceHarness( section("function updateExpertWorkspace", "function updateTradingHaltWorkspace"), "expertRenderStructureKey", "renderExpert", ".expert-workspace", { "#expert-search": input }); const state = { expert: { search: { query: "", results: [], isTruncated: false }, selectedExpert: null, preview: { items: [] }, actions: [] } }; harness.prime(JSON.stringify({ results: [], isTruncated: false, selectedExpert: null, preview: state.expert.preview, actions: [] })); harness.document.activeElement = input; input.value = "김전문"; harness.remember(input); harness.document.activeElement = null; harness.render(state); assert.equal(input.value, "김전문"); state.expert.search.query = "김전문"; harness.render(state); state.expert.search.query = "박전문"; harness.render(state); assert.equal(input.value, "박전문", "after the submitted value is acknowledged, later native state may replace it"); }); test("modal search drafts survive recreation with Korean text, focus and caret", () => { const support = section( "function rememberSearchDraft", "function isImeComposing"); const documentObject = { activeElement: null, getElementById: () => null }; const root = { current: null, contains(input) { return input === this.current; } }; documentObject.getElementById = function (id) { return root.current && root.current.id === id ? root.current : null; }; function input(value) { return { id: "manual-financial-find-query", value, disabled: false, selectionStart: 1, selectionEnd: 2, selectionDirection: "forward", focus() { documentObject.activeElement = this; }, setSelectionRange(start, end, direction) { this.selectionStart = start; this.selectionEnd = end; this.selectionDirection = direction; } }; } const api = new Function("document", "searchDraftInputIds", ` const searchDrafts = new Map(); ${support} return { remember: rememberSearchDraft, sync: syncSearchDraft, capture: captureSearchDraftFocus, restore: restoreSearchDraftFocus }; `)(documentObject, new Set(["manual-financial-find-query"])); const original = input("ㅎㄱ"); root.current = original; documentObject.activeElement = original; api.sync(original, "", "manual-financial:sales"); api.remember(original); const saved = api.capture(root); const recreated = input(""); recreated.selectionStart = 0; recreated.selectionEnd = 0; root.current = recreated; documentObject.activeElement = null; api.sync(recreated, "", "manual-financial:sales"); assert.equal(recreated.value, "ㅎㄱ", "an unsubmitted Korean draft must survive blur"); assert.equal(api.restore(root, saved), true); assert.strictEqual(documentObject.activeElement, recreated); assert.deepEqual( [recreated.selectionStart, recreated.selectionEnd, recreated.selectionDirection], [1, 2, "forward"]); const otherScreen = input(""); root.current = otherScreen; documentObject.activeElement = null; api.sync(otherScreen, "네이티브", "manual-financial:growth-metrics"); assert.equal(otherScreen.value, "네이티브", "a draft from another modal scope must not leak into the new screen"); }); test("GraphE, VI and operator catalog searches use modal draft focus support", () => { const manualFinancial = section( "function renderManualFinancial", "function isCanonicalNamedPlaylistTitle"); const manualLists = section( "function renderManualLists", "function openNamedPlaylist"); const operatorCatalog = section( "function renderOperatorCatalog", "function renderDialog"); assert.match(manualFinancial, /captureSearchDraftFocus\(manualWorkspace\)/); assert.match(manualFinancial, /syncSearchDraft\(listQuery, manual\.query, manualSearchScope\)/); assert.match(manualFinancial, /syncSearchDraft\(findQuery, manual\.findQuery, manualSearchScope\)/); assert.match(manualFinancial, /restoreSearchDraftFocus\(manualWorkspace, manualFinancialSearchFocus\)/); assert.match(manualLists, /input\.id = "manual-vi-search"/); assert.match(manualLists, /syncSearchDraft\(input, manual\.searchQuery, "manual-list:vi"\)/); assert.match(manualLists, /restoreSearchDraftFocus\(manualListWorkspace, manualListSearchFocus\)/); assert.match(operatorCatalog, /syncSearchDraft\([^]*?operatorCatalogQuery,[^]*?catalog\.query,[^]*?"operator-catalog:" \+ catalog\.entity\)/); assert.match(operatorCatalog, /syncSearchDraft\([^]*?operatorCatalogStockQuery,[^]*?catalog\.stockQuery,[^]*?"operator-catalog-stock:" \+ draftKey\)/); assert.match(operatorCatalog, /restoreSearchDraftFocus\(operatorCatalogDialog, operatorCatalogSearchFocus\)/); }); test("comparison searches keep both focused editor identities across status polls", () => { const domestic = { value: "ㅅㅅ" }; const world = { value: "apple" }; const harness = workspaceHarness( section("function updateComparisonWorkspace", "function themeActionIsClickable"), "comparisonRenderStructureKey", "renderComparison", ".comparison-workspace", { "#comparison-domestic-search": domestic, "#comparison-world-search": world }); const state = { comparison: { domesticSearch: { query: "", results: [], selectedResultId: null, isTruncated: false }, worldSearch: { query: "", results: [], selectedResultId: null, isTruncated: false }, fixedTargets: [], selectedFixedTargetId: null, firstTarget: null, secondTarget: null, savedPairs: [], actions: [] } }; const key = JSON.stringify({ domestic: { results: [], selectedResultId: null, isTruncated: false }, world: { results: [], selectedResultId: null, isTruncated: false }, fixedTargets: [], selectedFixedTargetId: null, firstTarget: null, secondTarget: null, savedPairs: [], actions: [] }); harness.prime(key); harness.document.activeElement = domestic; harness.render(state); assert.equal(domestic.value, "ㅅㅅ"); assert.equal(world.value, ""); assert.equal(harness.replacements(), 0); }); test("trading-halt search keeps its focused draft across equivalent status polls", () => { const input = { value: "ㅎㄱ" }; const harness = workspaceHarness( section("function updateTradingHaltWorkspace", "function blankManualRecord"), "tradingHaltRenderStructureKey", "renderTradingHalt", ".halt-workspace", { "#halt-search": input }); const state = { tradingHalt: { query: "", results: [], nameSort: "ascending", actions: [] } }; harness.prime(JSON.stringify({ results: [], nameSort: "ascending", actions: [] })); harness.document.activeElement = input; harness.render(state); assert.equal(input.value, "ㅎㄱ"); assert.equal(harness.replacements(), 0); }); test("all recreated editors use stable state keys and transient busy locking", () => { const fixedCatalog = section("function renderFixedCatalog", "function markIndustryRowSelected"); const manualFinancial = section("function renderManualFinancial", "function isCanonicalNamedPlaylistTitle"); const manualLists = section("function renderManualLists", "function openNamedPlaylist"); const operatorCatalog = section("function renderOperatorCatalog", "operatorCatalogClose.addEventListener"); const treeFinish = section("function finishTreeRender", "function send("); assert.doesNotMatch(fixedCatalog, /if \(!catalog[^]*?categoryTree\.replaceChildren\(\)/); assert.match(manualFinancial, /renderStateKey = JSON\.stringify\(manual\)/); assert.match(manualFinancial, /setTransientControlsBusy\(manualDialog, state\.isBusy === true\)/); const manualStableReturn = manualFinancial.indexOf( "if (!manualModal.hidden && renderStateKey === manualFinancialRenderStateKey)"); const manualDraftCapture = manualFinancial.indexOf( "captureManualDraftForChangedModel();", manualStableReturn); const manualWorkspaceReplace = manualFinancial.indexOf( "manualWorkspace.replaceChildren(toolbar, body);", manualStableReturn); assert.ok(manualStableReturn >= 0 && manualDraftCapture > manualStableReturn && manualWorkspaceReplace > manualDraftCapture, "a changed GraphE model must capture the current editor before replacing it"); assert.match(manualLists, /setTransientControlsBusy\(manualListDialog, state\.isBusy === true\)/); assert.match(operatorCatalog, /renderStateKey = JSON\.stringify\(catalog\)/); assert.match(operatorCatalog, /setTransientControlsBusy\(operatorCatalogDialog, busy\)/); assert.doesNotMatch(manualLists, /disabled\s*=\s*busy\s*\|\|/); assert.doesNotMatch(operatorCatalog, /disabled\s*=\s*busy\s*\|\|/); assert.doesNotMatch(manualLists, /querySelectorAll\("button, input"\)[^]*?control\.disabled = true/); assert.match(manualLists, /setTransientControlsBusy\(manualListDialog, true\)[^]*?set-manual-net-sell-cell/); const operatorStableReturn = operatorCatalog.indexOf( "if (!operatorCatalogModal.hidden && renderStateKey === operatorCatalogRenderStateKey)"); const operatorModelReset = operatorCatalog.indexOf( "setTransientControlsBusy(operatorCatalogDialog, false);", operatorStableReturn); const operatorSemanticRender = operatorCatalog.indexOf( "operatorCatalogThemeTab.disabled = editing;", operatorStableReturn); assert.ok(operatorStableReturn >= 0 && operatorModelReset > operatorStableReturn && operatorSemanticRender > operatorModelReset, "changed operator models must clear transient busy state before semantic rendering"); assert.match(treeFinish, /!canRestoreControl && focused/); assert.match(treeFinish, /document\.activeElement !== control/); }); test("transient busy snapshots can be cleared before a changed model is rendered", () => { const source = section("function setTransientControlsBusy", "function isOperatorMutationLocked"); const setBusy = new Function(`${source}; return setTransientControlsBusy;`)(); const control = { disabled: true, dataset: {} }; const root = { querySelectorAll() { return [control]; } }; setBusy(root, true); assert.equal(control.dataset.busyDisabled, "true"); setBusy(root, false); assert.equal(control.disabled, true); assert.equal(control.dataset.busyDisabled, undefined); control.disabled = false; setBusy(root, true); setBusy(root, false); assert.equal(control.disabled, false); assert.equal(control.dataset.busyDisabled, undefined); }); test("GraphE captures the current editor values before a changed model rebuild", () => { const source = section("function blankManualRecord", "function renderManualFinancial"); const inputs = new Map([ ["#manual-financial-stock-name", { value: " 삼성전자 " }], ["[data-manual-quarter-label=\"0\"]", { value: "2026Q1" }], ["[data-manual-quarter-value=\"0\"]", { value: "123" }] ]); const workspace = { querySelector(selector) { return inputs.get(selector) || null; } }; const harness = new Function("manualWorkspace", ` let manualDraft = { screen: "sales", stockName: "기존값", quarters: Array.from({ length: 6 }, function () { return { quarter: "", value: 0 }; }) }; ${source} return { capture: rememberManualDraft, draft: function () { return manualDraft; } }; `)(workspace); harness.capture(); assert.equal(harness.draft().stockName, "삼성전자"); assert.deepEqual(harness.draft().quarters[0], { quarter: "2026Q1", value: 123 }); }); test("GraphE preserves edits on model refresh but protects an intentional new-form reset", () => { const source = section( "function captureManualDraftForChangedModel", "function renderManualFinancial"); let captures = 0; const harness = new Function("manualModal", "rememberManualDraft", ` let manualDraftResetPending = false; ${source} return { capture: captureManualDraftForChangedModel, reset: function () { manualDraftResetPending = true; }, pending: function () { return manualDraftResetPending; } }; `)({ hidden: false }, function () { captures += 1; }); harness.capture(); assert.equal(captures, 1); harness.reset(); harness.capture(); assert.equal(captures, 1); assert.equal(harness.pending(), false); harness.capture(); assert.equal(captures, 2); });