Files
MBN_STOCK_WEBVIEW/tests/LegacyParityWeb/legacy-roving-list-parity.test.cjs

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);
});