373 lines
12 KiB
JavaScript
373 lines
12 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() {
|
|
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 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("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;
|
|
}
|
|
|
|
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("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);
|
|
});
|