438 lines
16 KiB
JavaScript
438 lines
16 KiB
JavaScript
"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);
|
|
});
|