895 lines
34 KiB
JavaScript
895 lines
34 KiB
JavaScript
"use strict";
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const workflow = require("../../Web/manual-financial-workflow.js");
|
|
const ui = require("../../Web/manual-financial-ui.js");
|
|
|
|
const rowVersion = "A".repeat(64);
|
|
const STOCK_RESULT = "stock-search-results";
|
|
|
|
function quarters() {
|
|
return [
|
|
{ quarter: "1Q", value: 10 },
|
|
{ quarter: "2Q", value: -20 },
|
|
{ quarter: "3Q", value: 30 },
|
|
{ quarter: "4Q", value: 40 },
|
|
{ quarter: "5Q", value: 50 },
|
|
{ quarter: "6Q", value: 60 }
|
|
];
|
|
}
|
|
|
|
function records() {
|
|
return {
|
|
"revenue-composition": {
|
|
kind: "revenue-composition",
|
|
stockName: "Alpha",
|
|
baseDate: "2026-06",
|
|
slices: [
|
|
{ label: "Semiconductor", percentageText: "50.0", percentage: 50 },
|
|
{ label: "Service", percentageText: "-", percentage: 0 },
|
|
null,
|
|
{ label: "Overseas", percentageText: "25", percentage: 25 },
|
|
null
|
|
]
|
|
},
|
|
"growth-metrics": {
|
|
kind: "growth-metrics",
|
|
stockName: "Alpha",
|
|
salesGrowth: [1, 2.5, null, -4],
|
|
operatingProfitGrowth: [5, 6, 7, 8],
|
|
netAssetGrowth: [9, null, 11, 12],
|
|
netIncomeGrowth: [13, 14, 15, 16],
|
|
periods: ["1Q", "2Q", "3Q", "4Q"]
|
|
},
|
|
sales: {
|
|
kind: "sales",
|
|
stockName: "Alpha",
|
|
quarters: quarters()
|
|
},
|
|
"operating-profit": {
|
|
kind: "operating-profit",
|
|
stockName: "Alpha",
|
|
quarters: quarters().map(item => ({ ...item, value: item.value * 2 }))
|
|
}
|
|
};
|
|
}
|
|
|
|
function createHarness(options = {}) {
|
|
const posted = [];
|
|
const entries = [];
|
|
const toasts = [];
|
|
const logs = [];
|
|
let ordinal = 0;
|
|
const controller = ui.createController({
|
|
postNative(type, payload) {
|
|
posted.push({ type, payload });
|
|
},
|
|
appendPlaylistEntry(entry) {
|
|
entries.push(entry);
|
|
return true;
|
|
},
|
|
isLocked() {
|
|
return typeof options.isLocked === "function" ? options.isLocked() : false;
|
|
},
|
|
getSelectedStock() {
|
|
return { market: "kospi", source: "oracle", name: "Alpha", code: "000001" };
|
|
},
|
|
showToast(message) {
|
|
toasts.push(message);
|
|
},
|
|
addLog(message) {
|
|
logs.push(message);
|
|
},
|
|
createId() {
|
|
ordinal += 1;
|
|
return `manual-ui-${ordinal}`;
|
|
}
|
|
});
|
|
return { controller, posted, entries, toasts, logs };
|
|
}
|
|
|
|
test("all four GraphE records round-trip through pure form conversion", () => {
|
|
for (const [screen, record] of Object.entries(records())) {
|
|
const form = ui.recordToForm(screen, record);
|
|
assert.ok(form, screen);
|
|
assert.deepEqual(ui.formToRecord(screen, form), workflow.normalizeRecord(screen, record));
|
|
}
|
|
|
|
const revenue = ui.recordToForm("revenue-composition", records()["revenue-composition"]);
|
|
assert.equal(ui.formToRecord("revenue-composition", {
|
|
...revenue,
|
|
slice3Label: "Only label",
|
|
slice3Percentage: ""
|
|
}), null);
|
|
|
|
const growth = ui.recordToForm("growth-metrics", records()["growth-metrics"]);
|
|
assert.equal(ui.formToRecord("growth-metrics", {
|
|
...growth,
|
|
salesGrowth2: "not-a-number"
|
|
}), null);
|
|
|
|
const sales = ui.recordToForm("sales", records().sales);
|
|
assert.equal(ui.formToRecord("sales", { ...sales, value4: "10.5" }), null);
|
|
assert.equal(ui.resolveScreen("manual-operating-profit"), "operating-profit");
|
|
assert.equal(ui.resolveScreen({ id: "manual-revenue-mix" }), "revenue-composition");
|
|
assert.equal(ui.resolveScreen({ screen: "growth-metrics" }), "growth-metrics");
|
|
});
|
|
|
|
test("GraphE display sorting is deterministic for ASCII and Korean with stable ties", () => {
|
|
const rows = [
|
|
{ stockName: "나다", id: 1 },
|
|
{ stockName: "beta", id: 2 },
|
|
{ stockName: "가나", id: 3 },
|
|
{ stockName: "Alpha", id: 4 }
|
|
];
|
|
assert.deepEqual(
|
|
ui.sortDisplaySnapshots(rows, "ascending").map(row => row.stockName),
|
|
["Alpha", "beta", "가나", "나다"]);
|
|
assert.deepEqual(
|
|
ui.sortDisplaySnapshots(rows, "descending").map(row => row.stockName),
|
|
["나다", "가나", "beta", "Alpha"]);
|
|
assert.deepEqual(rows.map(row => row.id), [1, 2, 3, 4]);
|
|
|
|
const ties = [
|
|
{ stockName: "동일", id: "first" },
|
|
{ stockName: "동일", id: "second" }
|
|
];
|
|
assert.deepEqual(
|
|
ui.sortDisplaySnapshots(ties, "descending").map(row => row.id),
|
|
["first", "second"]);
|
|
assert.deepEqual(ui.sortDisplaySnapshots([], "ascending"), []);
|
|
assert.deepEqual(ui.sortDisplaySnapshots([{ stockName: "Only" }], "descending"), [
|
|
{ stockName: "Only" }
|
|
]);
|
|
assert.throws(() => ui.sortDisplaySnapshots(rows, "sideways"), /sort direction/i);
|
|
});
|
|
|
|
test("controller accepts only the pending list correlation and ignores shared stock traffic", () => {
|
|
const harness = createHarness();
|
|
assert.equal(harness.controller.open({ id: "manual-sales" }), true);
|
|
assert.equal(harness.posted.length, 1);
|
|
assert.equal(harness.posted[0].type, workflow.bridgeContract.listRequestType);
|
|
const request = harness.posted[0].payload;
|
|
assert.deepEqual(request, {
|
|
requestId: "manual-ui-1",
|
|
screen: "sales",
|
|
query: "",
|
|
maximumResults: workflow.bridgeContract.defaultMaximumResults
|
|
});
|
|
assert.equal(harness.controller.render().pending.list, request.requestId);
|
|
|
|
const payload = {
|
|
requestId: request.requestId,
|
|
screen: "sales",
|
|
query: "",
|
|
retrievedAt: "2026-07-12T10:00:00+09:00",
|
|
totalRowCount: 1,
|
|
truncated: false,
|
|
items: [{
|
|
stockName: "Alpha",
|
|
rowVersion,
|
|
record: records().sales
|
|
}]
|
|
};
|
|
assert.equal(harness.controller.handleMessage(
|
|
workflow.bridgeContract.listResultType,
|
|
{ ...payload, requestId: "stale-list" }), false);
|
|
assert.equal(harness.controller.render().pending.list, request.requestId);
|
|
assert.equal(harness.controller.render().itemCount, 0);
|
|
|
|
assert.equal(harness.controller.handleMessage(workflow.bridgeContract.listResultType, payload), true);
|
|
assert.equal(harness.controller.render().pending.list, undefined);
|
|
assert.equal(harness.controller.render().itemCount, 1);
|
|
assert.equal(harness.controller.render().status, "ready");
|
|
|
|
assert.equal(harness.controller.handleMessage(STOCK_RESULT, {
|
|
requestId: "another-component",
|
|
query: "Alpha",
|
|
retrievedAt: "2026-07-12T10:01:00+09:00",
|
|
totalRowCount: 0,
|
|
truncated: false,
|
|
results: []
|
|
}), false);
|
|
assert.equal(harness.controller.setLocked(true).locked, true);
|
|
assert.equal(harness.controller.setLocked(false).locked, false);
|
|
});
|
|
|
|
class FakeElement {
|
|
constructor(tagName, ownerDocument) {
|
|
this.tagName = tagName.toUpperCase();
|
|
this.ownerDocument = ownerDocument;
|
|
this.children = [];
|
|
this.attributes = new Map();
|
|
this.listeners = new Map();
|
|
this.dataset = {};
|
|
this.textContent = "";
|
|
this.value = "";
|
|
this.hidden = false;
|
|
this.disabled = false;
|
|
this.readOnly = false;
|
|
this.className = "";
|
|
}
|
|
|
|
append(...values) {
|
|
for (const value of values) this.appendChild(value);
|
|
}
|
|
|
|
appendChild(value) {
|
|
this.children.push(value);
|
|
return value;
|
|
}
|
|
|
|
replaceChildren(...values) {
|
|
this.children = [];
|
|
this.append(...values);
|
|
}
|
|
|
|
setAttribute(name, value) {
|
|
this.attributes.set(name, String(value));
|
|
}
|
|
|
|
addEventListener(name, callback) {
|
|
this.listeners.set(name, callback);
|
|
}
|
|
}
|
|
|
|
class FakeDocument {
|
|
constructor() {
|
|
this.body = new FakeElement("body", this);
|
|
}
|
|
|
|
createElement(tagName) {
|
|
return new FakeElement(tagName, this);
|
|
}
|
|
}
|
|
|
|
function findByClass(node, className) {
|
|
if (String(node.className || "").split(/\s+/).includes(className)) return node;
|
|
for (const child of node.children || []) {
|
|
const found = findByClass(child, className);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function findAllByClass(node, className, found = []) {
|
|
if (String(node.className || "").split(/\s+/).includes(className)) found.push(node);
|
|
for (const child of node.children || []) findAllByClass(child, className, found);
|
|
return found;
|
|
}
|
|
|
|
function findByText(node, textContent) {
|
|
if (node.textContent === textContent) return node;
|
|
for (const child of node.children || []) {
|
|
const found = findByText(child, textContent);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function keyboardEvent(key, options = {}) {
|
|
return {
|
|
key,
|
|
isComposing: options.isComposing === true,
|
|
defaultPrevented: false,
|
|
preventDefault() {
|
|
this.defaultPrevented = true;
|
|
}
|
|
};
|
|
}
|
|
|
|
function recordFor(screen, stockName) {
|
|
return { ...JSON.parse(JSON.stringify(records()[screen])), stockName };
|
|
}
|
|
|
|
function acceptList(harness, screen, stockNames, query = "") {
|
|
const request = harness.posted.at(-1).payload;
|
|
assert.equal(request.screen, screen);
|
|
assert.equal(request.query, query);
|
|
return harness.controller.handleMessage(workflow.bridgeContract.listResultType, {
|
|
requestId: request.requestId,
|
|
screen,
|
|
query,
|
|
retrievedAt: "2026-07-12T10:00:00+09:00",
|
|
totalRowCount: stockNames.length,
|
|
truncated: false,
|
|
items: stockNames.map((stockName, index) => ({
|
|
stockName,
|
|
rowVersion: String.fromCharCode(65 + index).repeat(64),
|
|
record: recordFor(screen, stockName)
|
|
}))
|
|
});
|
|
}
|
|
|
|
function acceptLoad(harness, screen, stockName) {
|
|
const request = harness.posted.at(-1).payload;
|
|
assert.equal(request.screen, screen);
|
|
assert.equal(request.stockName, stockName);
|
|
const itemIndex = ["Alpha", "Alpine", "Beta", "Gamma"].indexOf(stockName);
|
|
return harness.controller.handleMessage(workflow.bridgeContract.loadResultType, {
|
|
requestId: request.requestId,
|
|
screen,
|
|
retrievedAt: "2026-07-12T10:01:00+09:00",
|
|
snapshot: {
|
|
stockName,
|
|
rowVersion: String.fromCharCode(65 + Math.max(itemIndex, 0)).repeat(64),
|
|
record: recordFor(screen, stockName)
|
|
}
|
|
});
|
|
}
|
|
|
|
function acceptExactStockSearch(harness, stockName = "Alpha") {
|
|
const request = harness.posted.at(-1);
|
|
assert.equal(request.type, "search-stocks");
|
|
assert.equal(request.payload.query, stockName);
|
|
return harness.controller.handleMessage(STOCK_RESULT, {
|
|
requestId: request.payload.requestId,
|
|
query: stockName,
|
|
retrievedAt: "2026-07-12T10:02:00+09:00",
|
|
totalRowCount: 1,
|
|
truncated: false,
|
|
results: [{ market: "kospi", source: "oracle", name: stockName, code: "000001" }]
|
|
});
|
|
}
|
|
|
|
function setFindQuery(overlay, value) {
|
|
const query = findByClass(overlay, "mfui-search-input");
|
|
query.value = value;
|
|
query.listeners.get("input")();
|
|
return query;
|
|
}
|
|
|
|
function assertFindControlsDoNotPost(harness, overlay) {
|
|
const before = harness.posted.length;
|
|
findByClass(overlay, "mfui-find-previous").listeners.get("click")();
|
|
findByClass(overlay, "mfui-find-next").listeners.get("click")();
|
|
assert.equal(harness.posted.length, before);
|
|
}
|
|
|
|
function displayedStockNames(overlay) {
|
|
return findAllByClass(overlay, "mfui-list-row").map(row => row.textContent);
|
|
}
|
|
|
|
function assertLocalSortOnly(harness, overlay) {
|
|
const before = harness.controller.render();
|
|
const postedCount = harness.posted.length;
|
|
const playlistCount = harness.entries.length;
|
|
findByClass(overlay, "mfui-list-sort").listeners.get("click")();
|
|
const after = harness.controller.render();
|
|
assert.equal(
|
|
after.sortDirection,
|
|
before.sortDirection === "ascending" ? "descending" : "ascending");
|
|
assert.equal(after.selectedStockName, before.selectedStockName);
|
|
assert.equal(harness.posted.length, postedCount);
|
|
assert.equal(harness.entries.length, playlistCount);
|
|
}
|
|
|
|
test("mount creates the isolated text-only dialog and exposes exactly the integration methods", () => {
|
|
const harness = createHarness();
|
|
const document = new FakeDocument();
|
|
const overlay = harness.controller.mount(document.body);
|
|
assert.equal(overlay.tagName, "DIV");
|
|
assert.equal(overlay.className, "mfui-overlay");
|
|
assert.equal(document.body.children.length, 1);
|
|
assert.deepEqual(Object.keys(harness.controller), [
|
|
"mount", "open", "handleMessage", "render", "setLocked"
|
|
]);
|
|
assert.equal(harness.controller.open("revenue-composition"), true);
|
|
assert.equal(overlay.hidden, false);
|
|
assert.equal(harness.posted[0].type, workflow.bridgeContract.listRequestType);
|
|
});
|
|
|
|
test("GraphE stock-name header toggles descending first and keeps selected identity local", () => {
|
|
const harness = createHarness();
|
|
const overlay = harness.controller.mount(new FakeDocument().body);
|
|
harness.controller.open("sales");
|
|
acceptList(harness, "sales", ["Alpha", "beta", "가나", "나다"]);
|
|
|
|
const header = findByClass(overlay, "mfui-list-sort");
|
|
assert.ok(header);
|
|
assert.equal(header.textContent, "종목명 ↕");
|
|
assert.equal(header.dataset.direction, "ascending");
|
|
assert.equal(header.attributes.get("aria-pressed"), "false");
|
|
assert.deepEqual(displayedStockNames(overlay), ["Alpha", "beta", "가나", "나다"]);
|
|
|
|
const beforeSort = harness.posted.length;
|
|
header.listeners.get("click")();
|
|
assert.equal(harness.controller.render().sortDirection, "descending");
|
|
assert.deepEqual(displayedStockNames(overlay), ["나다", "가나", "beta", "Alpha"]);
|
|
assert.equal(harness.posted.length, beforeSort);
|
|
|
|
findByText(overlay, "beta").listeners.get("click")();
|
|
acceptLoad(harness, "sales", "beta");
|
|
const beforeSelectedSort = harness.posted.length;
|
|
header.listeners.get("click")();
|
|
assert.equal(harness.controller.render().sortDirection, "ascending");
|
|
assert.equal(harness.controller.render().selectedStockName, "beta");
|
|
assert.equal(harness.posted.length, beforeSelectedSort);
|
|
const selectedRows = findAllByClass(overlay, "mfui-list-row")
|
|
.filter(row => row.attributes.get("aria-selected") === "true");
|
|
assert.deepEqual(selectedRows.map(row => row.textContent), ["beta"]);
|
|
});
|
|
|
|
test("GraphE previous and next find follow the current displayed sort order", () => {
|
|
const harness = createHarness();
|
|
const overlay = harness.controller.mount(new FakeDocument().body);
|
|
harness.controller.open("operating-profit");
|
|
acceptList(harness, "operating-profit", ["Alpha", "Beta", "Gamma"]);
|
|
findByClass(overlay, "mfui-list-sort").listeners.get("click")();
|
|
assert.deepEqual(displayedStockNames(overlay), ["Gamma", "Beta", "Alpha"]);
|
|
setFindQuery(overlay, "a");
|
|
|
|
findByClass(overlay, "mfui-find-next").listeners.get("click")();
|
|
assert.equal(harness.posted.at(-1).payload.stockName, "Gamma");
|
|
acceptLoad(harness, "operating-profit", "Gamma");
|
|
findByClass(overlay, "mfui-find-next").listeners.get("click")();
|
|
assert.equal(harness.posted.at(-1).payload.stockName, "Beta");
|
|
acceptLoad(harness, "operating-profit", "Beta");
|
|
findByClass(overlay, "mfui-find-previous").listeners.get("click")();
|
|
assert.equal(harness.posted.at(-1).payload.stockName, "Gamma");
|
|
});
|
|
|
|
test("GraphE list result and each mode open reset display sorting to ascending", () => {
|
|
for (const screen of ["revenue-composition", "growth-metrics", "sales", "operating-profit"]) {
|
|
const harness = createHarness();
|
|
const overlay = harness.controller.mount(new FakeDocument().body);
|
|
harness.controller.open(screen);
|
|
acceptList(harness, screen, ["Alpha", "Beta"]);
|
|
assertLocalSortOnly(harness, overlay);
|
|
assert.equal(harness.controller.render().sortDirection, "descending", screen);
|
|
|
|
const searchForm = findByClass(overlay, "mfui-search");
|
|
const query = setFindQuery(overlay, "Alpha");
|
|
const submitEvent = keyboardEvent("Enter");
|
|
searchForm.listeners.get("submit")(submitEvent);
|
|
assert.equal(submitEvent.defaultPrevented, true);
|
|
assert.equal(harness.controller.render().sortDirection, "descending", screen);
|
|
acceptList(harness, screen, ["Alpha"], "Alpha");
|
|
assert.equal(harness.controller.render().sortDirection, "ascending", screen);
|
|
assert.deepEqual(displayedStockNames(overlay), ["Alpha"], screen);
|
|
|
|
findByClass(overlay, "mfui-list-sort").listeners.get("click")();
|
|
assert.equal(harness.controller.render().sortDirection, "descending", screen);
|
|
harness.controller.open(screen);
|
|
assert.equal(harness.controller.render().sortDirection, "ascending", screen);
|
|
assert.deepEqual(displayedStockNames(overlay), [], screen);
|
|
assert.equal(query.value, "", screen);
|
|
}
|
|
});
|
|
|
|
test("GraphE empty and single-row lists still toggle locally without native traffic", () => {
|
|
const emptyHarness = createHarness();
|
|
const emptyOverlay = emptyHarness.controller.mount(new FakeDocument().body);
|
|
emptyHarness.controller.open("revenue-composition");
|
|
acceptList(emptyHarness, "revenue-composition", []);
|
|
assertLocalSortOnly(emptyHarness, emptyOverlay);
|
|
assert.deepEqual(displayedStockNames(emptyOverlay), []);
|
|
|
|
const singleHarness = createHarness();
|
|
const singleOverlay = singleHarness.controller.mount(new FakeDocument().body);
|
|
singleHarness.controller.open("growth-metrics");
|
|
acceptList(singleHarness, "growth-metrics", ["Alpha"]);
|
|
assertLocalSortOnly(singleHarness, singleOverlay);
|
|
assert.deepEqual(displayedStockNames(singleOverlay), ["Alpha"]);
|
|
});
|
|
|
|
test("GraphE display sorting stays local during list, load, stock, selection, and write pending", () => {
|
|
const readHarness = createHarness();
|
|
const readOverlay = readHarness.controller.mount(new FakeDocument().body);
|
|
readHarness.controller.open("sales");
|
|
assert.ok(readHarness.controller.render().pending.list);
|
|
assertLocalSortOnly(readHarness, readOverlay);
|
|
acceptList(readHarness, "sales", ["Alpha", "Beta"]);
|
|
|
|
setFindQuery(readOverlay, "a");
|
|
findByClass(readOverlay, "mfui-find-next").listeners.get("click")();
|
|
assert.ok(readHarness.controller.render().pending.load);
|
|
assertLocalSortOnly(readHarness, readOverlay);
|
|
acceptLoad(readHarness, "sales", "Alpha");
|
|
|
|
findByClass(readOverlay, "mfui-accent").listeners.get("click")();
|
|
assert.ok(readHarness.controller.render().pending.stock);
|
|
assertLocalSortOnly(readHarness, readOverlay);
|
|
acceptExactStockSearch(readHarness);
|
|
assert.ok(readHarness.controller.render().pending.selection);
|
|
assertLocalSortOnly(readHarness, readOverlay);
|
|
|
|
const writeHarness = createHarness();
|
|
const writeOverlay = writeHarness.controller.mount(new FakeDocument().body);
|
|
writeHarness.controller.open("sales");
|
|
acceptList(writeHarness, "sales", ["Alpha", "Beta"]);
|
|
setFindQuery(writeOverlay, "Alpha");
|
|
findByClass(writeOverlay, "mfui-find-next").listeners.get("click")();
|
|
acceptLoad(writeHarness, "sales", "Alpha");
|
|
findByText(writeOverlay, "수정 저장").listeners.get("click")();
|
|
acceptExactStockSearch(writeHarness);
|
|
assert.ok(writeHarness.controller.render().pending.write);
|
|
assertLocalSortOnly(writeHarness, writeOverlay);
|
|
});
|
|
|
|
test("loaded GraphE list Enter finds downward from the current row without wrapping", () => {
|
|
const harness = createHarness();
|
|
const document = new FakeDocument();
|
|
const overlay = harness.controller.mount(document.body);
|
|
harness.controller.open("sales");
|
|
assert.equal(acceptList(harness, "sales", ["Alpha", "Alpine", "Beta", "Gamma"]), true);
|
|
|
|
const query = findByClass(overlay, "mfui-search-input");
|
|
query.value = " al ";
|
|
query.listeners.get("input")();
|
|
let event = keyboardEvent("Enter");
|
|
query.listeners.get("keydown")(event);
|
|
assert.equal(event.defaultPrevented, true);
|
|
assert.equal(harness.posted.at(-1).type, workflow.bridgeContract.loadRequestType);
|
|
assert.equal(harness.posted.at(-1).payload.stockName, "Alpha");
|
|
assert.equal(acceptLoad(harness, "sales", "Alpha"), true);
|
|
|
|
event = keyboardEvent("Enter");
|
|
query.listeners.get("keydown")(event);
|
|
assert.equal(harness.posted.at(-1).payload.stockName, "Alpine");
|
|
assert.equal(acceptLoad(harness, "sales", "Alpine"), true);
|
|
|
|
const countAtLastMatch = harness.posted.length;
|
|
event = keyboardEvent("Enter");
|
|
query.listeners.get("keydown")(event);
|
|
assert.equal(event.defaultPrevented, true);
|
|
assert.equal(harness.posted.length, countAtLastMatch);
|
|
assert.equal(harness.controller.render().selectedStockName, "Alpine");
|
|
});
|
|
|
|
test("visible GraphE find buttons move around the current row without wrapping", () => {
|
|
const harness = createHarness();
|
|
const document = new FakeDocument();
|
|
const overlay = harness.controller.mount(document.body);
|
|
harness.controller.open("sales");
|
|
acceptList(harness, "sales", ["Alpha", "Alpine", "Beta", "Gamma"]);
|
|
|
|
const previous = findByClass(overlay, "mfui-find-previous");
|
|
const next = findByClass(overlay, "mfui-find-next");
|
|
assert.ok(previous);
|
|
assert.ok(next);
|
|
assert.equal(previous.textContent, "위로 찾기");
|
|
assert.equal(next.textContent, "아래로 찾기");
|
|
assert.equal(previous.attributes.get("aria-label"), "현재 선택 이전의 일치 종목 찾기");
|
|
assert.equal(next.attributes.get("aria-label"), "현재 선택 다음의 일치 종목 찾기");
|
|
|
|
setFindQuery(overlay, " al ");
|
|
const beforeNoSelectionPrevious = harness.posted.length;
|
|
previous.listeners.get("click")();
|
|
assert.equal(harness.posted.length, beforeNoSelectionPrevious);
|
|
|
|
next.listeners.get("click")();
|
|
assert.equal(harness.posted.at(-1).payload.stockName, "Alpha");
|
|
acceptLoad(harness, "sales", "Alpha");
|
|
next.listeners.get("click")();
|
|
assert.equal(harness.posted.at(-1).payload.stockName, "Alpine");
|
|
acceptLoad(harness, "sales", "Alpine");
|
|
|
|
// The WinForms loop used i > 0 and accidentally skipped row 0. The Web port
|
|
// intentionally includes the first row as a valid previous match.
|
|
previous.listeners.get("click")();
|
|
assert.equal(harness.posted.at(-1).payload.stockName, "Alpha");
|
|
acceptLoad(harness, "sales", "Alpha");
|
|
const beforeTop = harness.posted.length;
|
|
previous.listeners.get("click")();
|
|
assert.equal(harness.posted.length, beforeTop);
|
|
});
|
|
|
|
test("GraphE below-find button and Enter use the same next-match request path", () => {
|
|
const buttonHarness = createHarness();
|
|
const buttonOverlay = buttonHarness.controller.mount(new FakeDocument().body);
|
|
buttonHarness.controller.open("growth-metrics");
|
|
acceptList(buttonHarness, "growth-metrics", ["Alpha", "Beta"]);
|
|
setFindQuery(buttonOverlay, " beta ");
|
|
findByClass(buttonOverlay, "mfui-find-next").listeners.get("click")();
|
|
const buttonRequest = buttonHarness.posted.at(-1);
|
|
|
|
const enterHarness = createHarness();
|
|
const enterOverlay = enterHarness.controller.mount(new FakeDocument().body);
|
|
enterHarness.controller.open("growth-metrics");
|
|
acceptList(enterHarness, "growth-metrics", ["Alpha", "Beta"]);
|
|
const enterQuery = setFindQuery(enterOverlay, " beta ");
|
|
const enterEvent = keyboardEvent("Enter");
|
|
enterQuery.listeners.get("keydown")(enterEvent);
|
|
const enterRequest = enterHarness.posted.at(-1);
|
|
|
|
assert.equal(enterEvent.defaultPrevented, true);
|
|
assert.equal(buttonRequest.type, workflow.bridgeContract.loadRequestType);
|
|
assert.equal(enterRequest.type, workflow.bridgeContract.loadRequestType);
|
|
assert.deepEqual(
|
|
{ screen: buttonRequest.payload.screen, stockName: buttonRequest.payload.stockName },
|
|
{ screen: enterRequest.payload.screen, stockName: enterRequest.payload.stockName });
|
|
});
|
|
|
|
test("GraphE next-find supports query changes, no match, and a single match", () => {
|
|
const harness = createHarness();
|
|
const document = new FakeDocument();
|
|
const overlay = harness.controller.mount(document.body);
|
|
harness.controller.open("revenue-composition");
|
|
acceptList(harness, "revenue-composition", ["Alpha", "Alpine", "Beta", "Gamma"]);
|
|
const query = findByClass(overlay, "mfui-search-input");
|
|
|
|
query.value = "Alpha";
|
|
query.listeners.get("input")();
|
|
query.listeners.get("keydown")(keyboardEvent("Enter"));
|
|
acceptLoad(harness, "revenue-composition", "Alpha");
|
|
|
|
query.value = "beta";
|
|
query.listeners.get("input")();
|
|
query.listeners.get("keydown")(keyboardEvent("Enter"));
|
|
assert.equal(harness.posted.at(-1).payload.stockName, "Beta");
|
|
acceptLoad(harness, "revenue-composition", "Beta");
|
|
|
|
const beforeNoMatch = harness.posted.length;
|
|
query.value = "missing";
|
|
query.listeners.get("input")();
|
|
query.listeners.get("keydown")(keyboardEvent("Enter"));
|
|
assert.equal(harness.posted.length, beforeNoMatch);
|
|
|
|
query.value = "beta";
|
|
query.listeners.get("input")();
|
|
query.listeners.get("keydown")(keyboardEvent("Enter"));
|
|
assert.equal(harness.posted.length, beforeNoMatch);
|
|
});
|
|
|
|
test("all four GraphE modes share the loaded-list Enter next-find path", () => {
|
|
for (const screen of ["revenue-composition", "growth-metrics", "sales", "operating-profit"]) {
|
|
const harness = createHarness();
|
|
const document = new FakeDocument();
|
|
const overlay = harness.controller.mount(document.body);
|
|
harness.controller.open(screen);
|
|
acceptList(harness, screen, ["Alpha", "Beta"]);
|
|
const query = findByClass(overlay, "mfui-search-input");
|
|
query.value = "beta";
|
|
query.listeners.get("input")();
|
|
query.listeners.get("keydown")(keyboardEvent("Enter"));
|
|
assert.equal(harness.posted.at(-1).type, workflow.bridgeContract.loadRequestType, screen);
|
|
assert.equal(harness.posted.at(-1).payload.stockName, "Beta", screen);
|
|
}
|
|
});
|
|
|
|
test("all four GraphE modes share visible previous and next find controls", () => {
|
|
for (const screen of ["revenue-composition", "growth-metrics", "sales", "operating-profit"]) {
|
|
const harness = createHarness();
|
|
const document = new FakeDocument();
|
|
const overlay = harness.controller.mount(document.body);
|
|
harness.controller.open(screen);
|
|
acceptList(harness, screen, ["Alpha", "Beta"]);
|
|
setFindQuery(overlay, "a");
|
|
|
|
findByClass(overlay, "mfui-find-next").listeners.get("click")();
|
|
assert.equal(harness.posted.at(-1).payload.stockName, "Alpha", screen);
|
|
acceptLoad(harness, screen, "Alpha");
|
|
findByClass(overlay, "mfui-find-next").listeners.get("click")();
|
|
assert.equal(harness.posted.at(-1).payload.stockName, "Beta", screen);
|
|
acceptLoad(harness, screen, "Beta");
|
|
findByClass(overlay, "mfui-find-previous").listeners.get("click")();
|
|
assert.equal(harness.posted.at(-1).payload.stockName, "Alpha", screen);
|
|
}
|
|
});
|
|
|
|
test("GraphE find buttons no-op while hidden, locked, or any request class is pending", () => {
|
|
let externalLocked = false;
|
|
const listHarness = createHarness({ isLocked: () => externalLocked });
|
|
const listOverlay = listHarness.controller.mount(new FakeDocument().body);
|
|
listHarness.controller.open("sales");
|
|
setFindQuery(listOverlay, "Alpha");
|
|
assert.equal(findByClass(listOverlay, "mfui-find-previous").disabled, true);
|
|
assert.equal(findByClass(listOverlay, "mfui-find-next").disabled, true);
|
|
assertFindControlsDoNotPost(listHarness, listOverlay);
|
|
|
|
acceptList(listHarness, "sales", ["Alpha", "Beta"]);
|
|
findByClass(listOverlay, "mfui-find-next").listeners.get("click")();
|
|
assert.equal(listHarness.controller.render().pending.load, listHarness.posted.at(-1).payload.requestId);
|
|
assertFindControlsDoNotPost(listHarness, listOverlay);
|
|
acceptLoad(listHarness, "sales", "Alpha");
|
|
|
|
externalLocked = true;
|
|
listHarness.controller.render();
|
|
assertFindControlsDoNotPost(listHarness, listOverlay);
|
|
externalLocked = false;
|
|
listHarness.controller.render();
|
|
findByClass(listOverlay, "mfui-close").listeners.get("click")();
|
|
assertFindControlsDoNotPost(listHarness, listOverlay);
|
|
|
|
const selectionHarness = createHarness();
|
|
const selectionOverlay = selectionHarness.controller.mount(new FakeDocument().body);
|
|
selectionHarness.controller.open("sales");
|
|
acceptList(selectionHarness, "sales", ["Alpha", "Beta"]);
|
|
setFindQuery(selectionOverlay, "Alpha");
|
|
findByClass(selectionOverlay, "mfui-find-next").listeners.get("click")();
|
|
acceptLoad(selectionHarness, "sales", "Alpha");
|
|
findByClass(selectionOverlay, "mfui-accent").listeners.get("click")();
|
|
assert.ok(selectionHarness.controller.render().pending.stock);
|
|
assertFindControlsDoNotPost(selectionHarness, selectionOverlay);
|
|
acceptExactStockSearch(selectionHarness);
|
|
assert.ok(selectionHarness.controller.render().pending.selection);
|
|
assertFindControlsDoNotPost(selectionHarness, selectionOverlay);
|
|
|
|
const writeHarness = createHarness();
|
|
const writeOverlay = writeHarness.controller.mount(new FakeDocument().body);
|
|
writeHarness.controller.open("sales");
|
|
acceptList(writeHarness, "sales", ["Alpha", "Beta"]);
|
|
setFindQuery(writeOverlay, "Alpha");
|
|
findByClass(writeOverlay, "mfui-find-next").listeners.get("click")();
|
|
acceptLoad(writeHarness, "sales", "Alpha");
|
|
findByText(writeOverlay, "수정 저장").listeners.get("click")();
|
|
assert.ok(writeHarness.controller.render().pending.stock);
|
|
acceptExactStockSearch(writeHarness);
|
|
assert.ok(writeHarness.controller.render().pending.write);
|
|
assertFindControlsDoNotPost(writeHarness, writeOverlay);
|
|
});
|
|
|
|
test("GraphE Enter preserves initial DB submit and blocks pending, lock, and hidden modal", () => {
|
|
const harness = createHarness();
|
|
const document = new FakeDocument();
|
|
const overlay = harness.controller.mount(document.body);
|
|
harness.controller.open("operating-profit");
|
|
const query = findByClass(overlay, "mfui-search-input");
|
|
const searchForm = findByClass(overlay, "mfui-search");
|
|
|
|
query.value = "Alpha";
|
|
query.listeners.get("input")();
|
|
let event = keyboardEvent("Enter");
|
|
query.listeners.get("keydown")(event);
|
|
assert.equal(event.defaultPrevented, false);
|
|
searchForm.listeners.get("submit")(event);
|
|
assert.equal(event.defaultPrevented, true);
|
|
assert.equal(harness.posted.at(-1).type, workflow.bridgeContract.listRequestType);
|
|
assert.equal(harness.posted.at(-1).payload.query, "Alpha");
|
|
acceptList(harness, "operating-profit", ["Alpha"], "Alpha");
|
|
|
|
event = keyboardEvent("Enter");
|
|
query.listeners.get("keydown")(event);
|
|
assert.equal(harness.posted.at(-1).type, workflow.bridgeContract.loadRequestType);
|
|
const pendingCount = harness.posted.length;
|
|
query.listeners.get("keydown")(keyboardEvent("Enter"));
|
|
assert.equal(harness.posted.length, pendingCount);
|
|
acceptLoad(harness, "operating-profit", "Alpha");
|
|
|
|
harness.controller.setLocked(true);
|
|
query.value = "Alpha";
|
|
query.listeners.get("input")();
|
|
query.listeners.get("keydown")(keyboardEvent("Enter"));
|
|
assert.equal(harness.posted.length, pendingCount);
|
|
harness.controller.setLocked(false);
|
|
|
|
const close = findByClass(overlay, "mfui-close");
|
|
close.listeners.get("click")();
|
|
query.listeners.get("keydown")(keyboardEvent("Enter"));
|
|
assert.equal(harness.posted.length, pendingCount);
|
|
});
|
|
|
|
test("fresh load, exact stock revalidation and native selection create one playable entry", () => {
|
|
const harness = createHarness();
|
|
const document = new FakeDocument();
|
|
const overlay = harness.controller.mount(document.body);
|
|
harness.controller.open("sales");
|
|
const listRequest = harness.posted.at(-1).payload;
|
|
assert.equal(harness.controller.handleMessage(workflow.bridgeContract.listResultType, {
|
|
requestId: listRequest.requestId,
|
|
screen: "sales",
|
|
query: "",
|
|
retrievedAt: "2026-07-12T10:00:00+09:00",
|
|
totalRowCount: 1,
|
|
truncated: false,
|
|
items: [{ stockName: "Alpha", rowVersion, record: records().sales }]
|
|
}), true);
|
|
|
|
const listRow = findByClass(overlay, "mfui-list-row");
|
|
assert.ok(listRow);
|
|
listRow.listeners.get("click")();
|
|
const loadCall = harness.posted.at(-1);
|
|
assert.equal(loadCall.type, workflow.bridgeContract.loadRequestType);
|
|
assert.equal(harness.controller.handleMessage(workflow.bridgeContract.loadResultType, {
|
|
requestId: loadCall.payload.requestId,
|
|
screen: "sales",
|
|
retrievedAt: "2026-07-12T10:01:00+09:00",
|
|
snapshot: { stockName: "Alpha", rowVersion, record: records().sales }
|
|
}), true);
|
|
assert.equal(harness.controller.render().hasSnapshot, true);
|
|
|
|
const playlistButton = findByClass(overlay, "mfui-accent");
|
|
assert.ok(playlistButton);
|
|
playlistButton.listeners.get("click")();
|
|
const truncatedSearch = harness.posted.at(-1);
|
|
assert.equal(truncatedSearch.type, "search-stocks");
|
|
harness.controller.handleMessage(STOCK_RESULT, {
|
|
requestId: truncatedSearch.payload.requestId,
|
|
query: "Alpha",
|
|
retrievedAt: "2026-07-12T10:02:00+09:00",
|
|
totalRowCount: 1,
|
|
truncated: true,
|
|
results: [{ market: "kospi", source: "oracle", name: "Alpha", code: "000001" }]
|
|
});
|
|
assert.equal(harness.entries.length, 0);
|
|
assert.equal(harness.controller.render().stockStatus, "error");
|
|
|
|
playlistButton.listeners.get("click")();
|
|
const stockCall = harness.posted.at(-1);
|
|
harness.controller.handleMessage(STOCK_RESULT, {
|
|
requestId: stockCall.payload.requestId,
|
|
query: "Alpha",
|
|
retrievedAt: "2026-07-12T10:03:00+09:00",
|
|
totalRowCount: 1,
|
|
truncated: false,
|
|
results: [{ market: "kospi", source: "oracle", name: "Alpha", code: "000001" }]
|
|
});
|
|
const selectionCall = harness.posted.at(-1);
|
|
assert.equal(selectionCall.type, workflow.bridgeContract.selectionRequestType);
|
|
const profile = workflow.getScreenDefinition("sales");
|
|
harness.controller.handleMessage(workflow.bridgeContract.selectionResultType, {
|
|
requestId: selectionCall.payload.requestId,
|
|
screen: "sales",
|
|
stockName: "Alpha",
|
|
rowVersion,
|
|
builderKey: profile.builderKey,
|
|
code: profile.code,
|
|
aliases: [profile.code],
|
|
selection: {
|
|
groupCode: "KOSPI",
|
|
subject: "Alpha",
|
|
graphicType: profile.graphicType,
|
|
subtype: "",
|
|
dataCode: ""
|
|
},
|
|
currentPage: 1,
|
|
totalPages: 1
|
|
});
|
|
assert.equal(harness.entries.length, 1);
|
|
assert.equal(workflow.isPlaylistEntryPlayable(harness.entries[0]), true);
|
|
assert.equal(harness.entries[0].code, "5080");
|
|
});
|
|
|
|
test("UI source uses DOM construction and documents the isolated app integration hook", () => {
|
|
const source = fs.readFileSync(
|
|
path.join(__dirname, "..", "..", "Web", "manual-financial-ui.js"),
|
|
"utf8");
|
|
assert.match(source, /App integration hook/);
|
|
assert.match(source, /createElement/);
|
|
assert.doesNotMatch(source, /\.innerHTML\s*=/);
|
|
assert.doesNotMatch(source, /insertAdjacentHTML/);
|
|
});
|
|
|
|
test("pending writes correlate exactly and OutcomeUnknown latches process quarantine", () => {
|
|
const coordinator = ui.createPendingCoordinator();
|
|
const expected = {
|
|
requestId: "write-1",
|
|
screen: "sales",
|
|
stockName: "Alpha",
|
|
expectedRowVersion: rowVersion
|
|
};
|
|
coordinator.set("write", expected, { operation: "delete-one" });
|
|
|
|
const unknown = {
|
|
requestId: "write-1",
|
|
operation: "delete-one",
|
|
screen: "sales",
|
|
stockName: "Alpha",
|
|
code: "OUTCOME_UNKNOWN",
|
|
message: "Commit result is unknown",
|
|
retryable: false,
|
|
outcomeUnknown: true
|
|
};
|
|
assert.equal(coordinator.acceptMutationError({
|
|
...unknown,
|
|
requestId: "stale-write"
|
|
}).status, "stale");
|
|
assert.equal(coordinator.snapshot().write, "write-1");
|
|
assert.equal(ui.getProcessWriteQuarantine().active, false);
|
|
|
|
const accepted = coordinator.acceptMutationError(unknown);
|
|
assert.equal(accepted.status, "accepted");
|
|
assert.equal(accepted.value.outcomeUnknown, true);
|
|
assert.equal(coordinator.snapshot().write, undefined);
|
|
assert.equal(ui.getProcessWriteQuarantine().active, true);
|
|
assert.match(ui.getProcessWriteQuarantine().message, /unknown/i);
|
|
|
|
const laterController = createHarness().controller;
|
|
assert.equal(laterController.render().writeQuarantined, true);
|
|
assert.equal(laterController.render().canWrite, false);
|
|
});
|