416 lines
14 KiB
JavaScript
416 lines
14 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");
|
|
const styles = fs.readFileSync(path.join(
|
|
repositoryRoot,
|
|
"src",
|
|
"MBN_STOCK_WEBVIEW.LegacyParityApp",
|
|
"Web",
|
|
"styles.css"), "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 listRow(documentObject, selected = false, disabled = false) {
|
|
const attributes = new Map([["aria-selected", selected ? "true" : "false"]]);
|
|
const classes = new Set(selected ? ["selected"] : []);
|
|
return {
|
|
dataset: {},
|
|
disabled,
|
|
tabIndex: -1,
|
|
focused: false,
|
|
scrolled: false,
|
|
classList: {
|
|
toggle(name, enabled) {
|
|
if (enabled) classes.add(name);
|
|
else classes.delete(name);
|
|
},
|
|
contains(name) { return classes.has(name); }
|
|
},
|
|
getAttribute(name) { return attributes.get(name) || null; },
|
|
setAttribute(name, value) { attributes.set(name, String(value)); },
|
|
focus() {
|
|
this.focused = true;
|
|
documentObject.activeElement = this;
|
|
},
|
|
scrollIntoView() { this.scrolled = true; }
|
|
};
|
|
}
|
|
|
|
function listBox(rows) {
|
|
const attributes = new Map();
|
|
return {
|
|
dataset: {},
|
|
tabIndex: -1,
|
|
querySelectorAll() { return rows; },
|
|
setAttribute(name, value) { attributes.set(name, String(value)); },
|
|
getAttribute(name) { return attributes.get(name) || null; }
|
|
};
|
|
}
|
|
|
|
function rovingHarness() {
|
|
const documentObject = { activeElement: null };
|
|
const source = section(
|
|
"function isRovingListNavigationKey",
|
|
"function stockResultPageOffset");
|
|
const api = new Function("document", `
|
|
${source}
|
|
return {
|
|
configureRovingList,
|
|
rovingListKeyTarget,
|
|
focusRovingListRow,
|
|
moveRovingListSelection
|
|
};
|
|
`)(documentObject);
|
|
return { documentObject, ...api };
|
|
}
|
|
|
|
test("restored WinForms lists expose one roving tab stop and skip disabled rows", () => {
|
|
const harness = rovingHarness();
|
|
const rows = [
|
|
listRow(harness.documentObject),
|
|
listRow(harness.documentObject, true),
|
|
listRow(harness.documentObject, false, true)
|
|
];
|
|
const list = listBox(rows);
|
|
|
|
harness.configureRovingList(list, rows, rows[1]);
|
|
|
|
assert.deepEqual(rows.map(row => row.tabIndex), [-1, 0, -1]);
|
|
assert.equal(list.tabIndex, -1);
|
|
assert.equal(list.getAttribute("role"), "listbox");
|
|
assert.deepEqual(rows.map(row => row.getAttribute("role")),
|
|
["option", "option", "option"]);
|
|
assert.deepEqual(rows.map(row => row.dataset.rovingRow),
|
|
["true", "true", "true"]);
|
|
assert.equal(list.dataset.rovingPageSize, "9");
|
|
assert.equal(list.dataset.rovingSelectionFollowsFocus, "true");
|
|
|
|
const empty = listBox([]);
|
|
harness.configureRovingList(empty, [], null);
|
|
assert.equal(empty.tabIndex, 0,
|
|
"an empty native-style ListBox remains one tab stop");
|
|
});
|
|
|
|
test("Arrow and boundary keys select, focus and scroll one row through native intent", () => {
|
|
const harness = rovingHarness();
|
|
const rows = [
|
|
listRow(harness.documentObject),
|
|
listRow(harness.documentObject, true),
|
|
listRow(harness.documentObject),
|
|
listRow(harness.documentObject, false, true)
|
|
];
|
|
const list = listBox(rows);
|
|
harness.configureRovingList(list, rows, rows[1]);
|
|
const activated = [];
|
|
const activate = row => {
|
|
activated.push(row);
|
|
return true;
|
|
};
|
|
|
|
assert.equal(harness.moveRovingListSelection(
|
|
list, rows[1], "ArrowDown", activate), true);
|
|
assert.deepEqual(rows.map(row => row.getAttribute("aria-selected")),
|
|
["false", "false", "true", "false"]);
|
|
assert.deepEqual(rows.map(row => row.tabIndex), [-1, -1, 0, -1]);
|
|
assert.equal(rows[2].focused, true);
|
|
assert.equal(rows[2].scrolled, true);
|
|
assert.deepEqual(activated, [rows[2]]);
|
|
|
|
harness.moveRovingListSelection(list, rows[2], "Home", activate);
|
|
assert.equal(rows[0].getAttribute("aria-selected"), "true");
|
|
assert.equal(rows[0].tabIndex, 0);
|
|
assert.deepEqual(activated, [rows[2], rows[0]]);
|
|
|
|
harness.moveRovingListSelection(list, rows[0], "End", activate);
|
|
assert.equal(rows[2].getAttribute("aria-selected"), "true",
|
|
"End targets the last enabled row");
|
|
assert.deepEqual(activated, [rows[2], rows[0], rows[2]]);
|
|
|
|
harness.moveRovingListSelection(list, rows[2], "ArrowDown", activate);
|
|
assert.equal(activated.length, 3,
|
|
"a boundary key on the current row does not duplicate native selection");
|
|
});
|
|
|
|
test("PageUp and PageDown move by a bounded nine-row native page", () => {
|
|
const harness = rovingHarness();
|
|
const rows = Array.from({ length: 25 }, (_, index) =>
|
|
listRow(harness.documentObject, index === 12));
|
|
const list = listBox(rows);
|
|
harness.configureRovingList(list, rows, rows[12]);
|
|
const activated = [];
|
|
|
|
harness.moveRovingListSelection(list, rows[12], "PageUp", row => {
|
|
activated.push(row);
|
|
return true;
|
|
});
|
|
assert.equal(rows[3].getAttribute("aria-selected"), "true");
|
|
|
|
harness.moveRovingListSelection(list, rows[3], "PageDown", row => {
|
|
activated.push(row);
|
|
return true;
|
|
});
|
|
assert.equal(rows[12].getAttribute("aria-selected"), "true");
|
|
assert.deepEqual(activated, [rows[3], rows[12]]);
|
|
});
|
|
|
|
test("focus-only candidate lists move their sole tab stop without selecting", () => {
|
|
const harness = rovingHarness();
|
|
const rows = [
|
|
listRow(harness.documentObject, true),
|
|
listRow(harness.documentObject),
|
|
listRow(harness.documentObject)
|
|
];
|
|
const list = listBox(rows);
|
|
harness.configureRovingList(list, rows, rows[0], {
|
|
activation: "focus",
|
|
selectionFollowsFocus: false
|
|
});
|
|
let activations = 0;
|
|
|
|
assert.equal(harness.moveRovingListSelection(
|
|
list, rows[0], "ArrowDown", () => { activations += 1; }), true);
|
|
assert.equal(activations, 0);
|
|
assert.deepEqual(rows.map(row => row.tabIndex), [-1, 0, -1]);
|
|
assert.deepEqual(rows.map(row => row.getAttribute("aria-selected")),
|
|
["true", "false", "false"]);
|
|
});
|
|
|
|
test("graphics trees keep one treeitem tab stop and skip collapsed leaves", () => {
|
|
const harness = rovingHarness();
|
|
const root = listRow(harness.documentObject);
|
|
root.dataset.treeNodeKey = "root|section-a";
|
|
root.dataset.treeRoot = "true";
|
|
const openLeaf = listRow(harness.documentObject);
|
|
openLeaf.dataset.treeNodeKey = "leaf|section-a|one";
|
|
openLeaf.closest = () => ({ open: true });
|
|
const collapsedLeaf = listRow(harness.documentObject, true);
|
|
collapsedLeaf.dataset.treeNodeKey = "leaf|section-b|two";
|
|
collapsedLeaf.closest = () => ({ open: false });
|
|
const rows = [root, openLeaf, collapsedLeaf];
|
|
const tree = listBox(rows);
|
|
|
|
harness.configureRovingList(tree, rows, collapsedLeaf, {
|
|
listRole: "tree",
|
|
rowRole: "treeitem",
|
|
selectionFollowsFocus: false
|
|
});
|
|
|
|
assert.equal(tree.getAttribute("role"), "tree");
|
|
assert.deepEqual(rows.map(row => row.getAttribute("role")),
|
|
["treeitem", "treeitem", "treeitem"]);
|
|
assert.deepEqual(rows.map(row => row.tabIndex), [0, -1, -1]);
|
|
assert.strictEqual(
|
|
harness.rovingListKeyTarget(tree, root, "ArrowDown"),
|
|
openLeaf);
|
|
assert.strictEqual(
|
|
harness.rovingListKeyTarget(tree, openLeaf, "End"),
|
|
openLeaf);
|
|
});
|
|
|
|
test("all restored large lists use the shared IME-safe roving contract", () => {
|
|
for (const selector of [
|
|
"data-comparison-selection-id",
|
|
"data-comparison-target-id",
|
|
"data-comparison-pair-id",
|
|
"data-theme-result-id",
|
|
"data-expert-selection-id",
|
|
"data-halt-selection-id",
|
|
"data-manual-result-id",
|
|
"data-manual-stock-result-id",
|
|
"data-manual-vi-result-id",
|
|
"data-named-playlist-definition-id",
|
|
"data-operator-catalog-result-id",
|
|
"data-operator-catalog-stock-result-id"
|
|
]) {
|
|
assert.match(app, new RegExp(`button\\[${selector}\\]`));
|
|
}
|
|
assert.match(app, /configureTreeRovingLists/);
|
|
assert.match(app, /listRole: "tree"/);
|
|
assert.match(app, /rowRole: "treeitem"/);
|
|
assert.match(app,
|
|
/rovingList && isRovingListNavigationKey\(event\.key\)[^]*?!isImeComposing\(event\)/);
|
|
assert.match(app, /function activateRovingListRow\(row\)/);
|
|
assert.match(app, /key === "PageUp"/);
|
|
assert.match(app, /key === "PageDown"/);
|
|
assert.match(app, /row\.dataset\.themeResultId/);
|
|
assert.match(app, /row\.dataset\.namedPlaylistDefinitionId/);
|
|
assert.match(app, /row\.dataset\.operatorCatalogResultId/);
|
|
assert.match(app, /row\.dataset\.operatorCatalogStockResultId/);
|
|
assert.match(app, /send\("load-manual-financial", \{ resultId: row\.dataset\.manualResultId \}\)/);
|
|
assert.match(app, /send\("select-manual-vi-result", \{ resultId: row\.dataset\.manualViResultId \}\)/);
|
|
});
|
|
|
|
test("GraphE previews use only four same-origin packaged images and bounded safe values", () => {
|
|
const source = section(
|
|
"const manualFinancialReferenceImages",
|
|
"function blankManualRecord");
|
|
const api = new Function(`
|
|
${source}
|
|
return {
|
|
images: manualFinancialReferenceImages,
|
|
columns: manualFinancialPreviewColumns,
|
|
values: manualFinancialPreviewValues
|
|
};
|
|
`)();
|
|
|
|
assert.deepEqual({ ...api.images }, {
|
|
"revenue-composition": "./Previews/pie.bmp",
|
|
"growth-metrics": "./Previews/Grow.bmp",
|
|
sales: "./Previews/sell.bmp",
|
|
"operating-profit": "./Previews/profit.bmp"
|
|
});
|
|
assert.equal(api.columns("revenue-composition").length, 7);
|
|
assert.equal(api.columns("growth-metrics").length, 6);
|
|
assert.equal(api.columns("sales").length, 7);
|
|
assert.deepEqual(api.values({
|
|
stockName: "fallback",
|
|
previewValues: ["삼성전자", "2025/12", "반도체_70"]
|
|
}, 7), ["삼성전자", "2025/12", "반도체_70", "", "", "", ""]);
|
|
assert.deepEqual(api.values({ stockName: "이름만", previewValues: [] }, 3),
|
|
["이름만", "", ""]);
|
|
assert.match(source, /image\.alt = label \+ " 기존 송출 화면 참고 이미지"/);
|
|
assert.match(styles, /\.manual-financial-reference img/);
|
|
assert.match(styles, /\.manual-financial-preview-cell/);
|
|
assert.doesNotMatch(source, /ROWID|ROW_HANDLE|rowVersion|stockCode|MmoneyCoder\.ini/);
|
|
});
|
|
|
|
test("choosing a different create-mode stock clears fields without touching rerenders or edits", () => {
|
|
const source = section("function cloneManualRecord", "function manualElement");
|
|
const shouldReset = new Function(`
|
|
${source}
|
|
return shouldResetManualStockDraft;
|
|
`)();
|
|
|
|
assert.equal(shouldReset(false, false, { screen: "sales" }), true);
|
|
assert.equal(shouldReset(true, false, { screen: "sales" }), false,
|
|
"reselecting the current candidate keeps the draft");
|
|
assert.equal(shouldReset(false, true, { screen: "sales" }), false,
|
|
"stored-row edit mode keeps the loaded record");
|
|
assert.equal(shouldReset(false, false, { screen: "sales", rawMode: true }), false,
|
|
"legacy raw editing retains its byte-preserving draft");
|
|
assert.equal(shouldReset(false, false, null), false);
|
|
|
|
const selectionHelper = section(
|
|
"function selectManualFinancialStock",
|
|
"function captureManualDraftForChangedModel");
|
|
assert.match(selectionHelper, /rememberManualDraft\(\)/);
|
|
assert.match(selectionHelper, /shouldResetManualStockDraft/);
|
|
assert.match(selectionHelper, /manualDraft = blankManualRecord\(manualDraft\.screen\)/);
|
|
assert.match(selectionHelper, /manualDraftResetPending = true/);
|
|
assert.match(selectionHelper, /manualDraft = previousDraft/,
|
|
"a rejected send restores the operator's original draft");
|
|
|
|
const clickBranch = section(
|
|
"const stock = event.target.closest(\"button[data-manual-stock-result-id]\")",
|
|
"if (event.target.closest(\"button[data-manual-save]\"))");
|
|
assert.match(clickBranch, /selectManualFinancialStock\(stock\)/);
|
|
assert.match(clickBranch, /focusManualFinancialStock\(stock\)/);
|
|
});
|
|
|
|
function manualStockSelectionHarness() {
|
|
const documentObject = { activeElement: null };
|
|
const stockName = { readOnly: false };
|
|
const manualWorkspace = {
|
|
contains() { return true; },
|
|
querySelector(selector) {
|
|
return selector === "#manual-financial-stock-name" ? stockName : null;
|
|
}
|
|
};
|
|
const sent = [];
|
|
const source = section(
|
|
"function focusManualFinancialStock",
|
|
"function captureManualDraftForChangedModel");
|
|
const api = new Function(
|
|
"document",
|
|
"manualWorkspace",
|
|
"rememberManualDraft",
|
|
"cloneManualRecord",
|
|
"shouldResetManualStockDraft",
|
|
"blankManualRecord",
|
|
"send",
|
|
`
|
|
let manualDraft = { screen: "sales", rawMode: false, retained: "operator input" };
|
|
let manualDraftKey = "sales|new";
|
|
let manualDraftResetPending = false;
|
|
${source}
|
|
return {
|
|
focus: focusManualFinancialStock,
|
|
select: selectManualFinancialStock,
|
|
draft: () => manualDraft,
|
|
resetPending: () => manualDraftResetPending
|
|
};
|
|
`)(
|
|
documentObject,
|
|
manualWorkspace,
|
|
() => {},
|
|
record => JSON.parse(JSON.stringify(record)),
|
|
(selected, readOnly, draft) => !selected && !readOnly && !!draft && !draft.rawMode,
|
|
screen => ({ screen, rawMode: false, cleared: true }),
|
|
(type, payload) => {
|
|
sent.push({ type, payload });
|
|
return true;
|
|
});
|
|
const stock = {
|
|
dataset: { manualStockResultId: "opaque-stock-1" },
|
|
disabled: false,
|
|
focused: false,
|
|
getAttribute(name) {
|
|
return name === "aria-selected" ? "false" : null;
|
|
},
|
|
focus() {
|
|
this.focused = true;
|
|
documentObject.activeElement = this;
|
|
}
|
|
};
|
|
return {
|
|
...api,
|
|
stock,
|
|
sent
|
|
};
|
|
}
|
|
|
|
test("GraphE candidate browser double-click emits one authoritative selection", () => {
|
|
const harness = manualStockSelectionHarness();
|
|
|
|
// Browser order is click, click, dblclick. The click gestures only focus;
|
|
// the dblclick is the one authoritative legacy GraphE selection.
|
|
harness.focus(harness.stock);
|
|
harness.focus(harness.stock);
|
|
harness.select(harness.stock);
|
|
|
|
assert.deepEqual(harness.sent, [{
|
|
type: "select-manual-financial-stock",
|
|
payload: { resultId: "opaque-stock-1" }
|
|
}]);
|
|
assert.equal(harness.stock.focused, true);
|
|
assert.equal(harness.draft().cleared, true);
|
|
assert.equal(harness.resetPending(), true);
|
|
});
|
|
|
|
test("GraphE candidate single-click only focuses and preserves the editor draft", () => {
|
|
const harness = manualStockSelectionHarness();
|
|
harness.focus(harness.stock);
|
|
|
|
assert.equal(harness.stock.focused, true);
|
|
assert.equal(harness.sent.length, 0);
|
|
assert.equal(harness.draft().retained, "operator input");
|
|
assert.equal(harness.resetPending(), false);
|
|
});
|