Files
MBN_STOCK_WEBVIEW/tests/Web/manual-lists-ui.test.cjs

461 lines
16 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 ui = require("../../Web/manual-lists-ui.js");
const versionA = "a".repeat(64);
const versionB = "b".repeat(64);
const netRows = () => Array.from({ length: 5 }, (_, index) => ({
leftName: `좌측${index + 1}`,
leftAmount: `${index + 1},000`,
rightName: `우측${index + 1}`,
rightAmount: `-${index + 1},000`
}));
const viItems = () => [
{ code: "P005930", name: "삼성전자" },
{ code: "P000660", name: "SK하이닉스" },
{ code: "D035720", name: "카카오" }
];
function fixture() {
let sequence = 0;
let externallyLocked = false;
const posts = [];
const entries = [];
const toasts = [];
const logs = [];
const controller = ui.createController({
postNative: (type, payload) => posts.push({ type, payload }),
appendPlaylistEntry: entry => entries.push(entry),
isLocked: () => externallyLocked,
showToast: (message, kind) => toasts.push({ message, kind }),
addLog: message => logs.push(message),
createId: () => `manual_ui_${++sequence}`
});
return {
controller,
posts,
entries,
toasts,
logs,
setExternalLock: value => { externallyLocked = value; },
latest: type => [...posts].reverse().find(item => item.type === type)
};
}
function enableManualStore(context) {
const requestId = context.controller.getState().pending.status;
assert.equal(context.controller.handleMessage("manual-operator-data-status", {
requestId,
available: true,
code: "READY",
writeQuarantined: false,
message: null
}), true);
}
test("interrupted import status remains explicit and disables every manual store action", () => {
const context = fixture();
context.controller.openVi();
const requestId = context.controller.getState().pending.status;
assert.equal(context.controller.handleMessage("manual-operator-data-status", {
requestId,
available: false,
code: "IMPORT_INTERRUPTED",
writeQuarantined: false,
message: "A prior import is unresolved; no FSell or VI file was opened."
}), true);
const state = context.controller.getState();
assert.equal(state.available, false);
assert.equal(state.unavailableCode, "IMPORT_INTERRUPTED");
assert.equal(
state.unavailableMessage,
"A prior import is unresolved; no FSell or VI file was opened.");
assert.equal(context.controller.actions.importLegacyManualData(), false);
assert.equal(context.controller.actions.saveVi(), false);
});
test("FSell cut opens only after five saved rows are re-read unchanged", () => {
const context = fixture();
const { controller } = context;
controller.openNetSell("FOREIGN");
enableManualStore(context);
const initialRead = controller.getState().pending.netRead;
assert.equal(controller.handleMessage("manual-net-sell-data", {
requestId: initialRead,
audience: "FOREIGN",
rows: netRows()
}), true);
assert.equal(controller.actions.addNetSellCut(), false);
assert.equal(controller.actions.saveNetSell(), true);
const save = context.latest("save-manual-net-sell-data");
assert.equal(save.payload.rows.length, 5);
assert.equal(controller.handleMessage("manual-net-sell-save-result", {
requestId: save.payload.requestId,
audience: "FOREIGN",
saved: true,
recovered: false
}), true);
assert.equal(controller.actions.addNetSellCut(), false);
const verificationRead = controller.getState().pending.netRead;
assert.equal(controller.handleMessage("manual-net-sell-data", {
requestId: verificationRead,
audience: "FOREIGN",
rows: netRows()
}), true);
assert.equal(controller.getState().netVerified, true);
assert.equal(controller.actions.addNetSellCut(), true);
assert.equal(context.entries[0].builderKey, "s5025");
controller.actions.setNetCell(0, "leftAmount", "변경");
assert.equal(controller.actions.addNetSellCut(), false);
});
test("VI cut binds the latest version and reorder requires save plus matching reread", () => {
const context = fixture();
const { controller } = context;
controller.openVi();
enableManualStore(context);
const read = controller.getState().pending.viRead;
assert.equal(controller.handleMessage("vi-manual-list-data", {
requestId: read,
itemCount: 3,
pageCount: 1,
items: viItems(),
version: versionA
}), true);
assert.equal(controller.actions.addViCut(), true);
assert.equal(context.entries[0].selection.subject, "@MBN_VI_SNAPSHOT_V1");
assert.equal(context.entries[0].selection.dataCode, versionA);
assert.equal(controller.actions.moveViItem(2, -1), true);
assert.deepEqual(controller.getState().viItems.map(item => item.name), [
"삼성전자", "카카오", "SK하이닉스"
]);
assert.equal(controller.getState().viBaseVersion, versionA);
assert.equal(controller.getState().viVersion, null);
assert.equal(controller.actions.addViCut(), false);
assert.equal(controller.actions.saveVi(), true);
const save = context.latest("save-vi-manual-list");
assert.equal(save.payload.expectedVersion, versionA);
assert.equal(controller.handleMessage("vi-manual-list-save-result", {
requestId: save.payload.requestId,
saved: true,
recovered: false,
persisted: true,
itemCount: 3,
pageCount: 1,
version: versionB
}), true);
const verify = controller.getState().pending.viRead;
assert.equal(controller.handleMessage("vi-manual-list-data", {
requestId: verify,
itemCount: 3,
pageCount: 1,
items: save.payload.items,
version: versionB
}), true);
assert.equal(controller.getState().viVerified, true);
assert.equal(controller.actions.addViCut(), true);
assert.equal(context.entries[1].selection.dataCode, versionB);
assert.deepEqual(context.entries[1].operator.items, save.payload.items);
});
test("VI mismatched post-save version never opens the playlist gate", () => {
const context = fixture();
const { controller } = context;
controller.openVi();
enableManualStore(context);
controller.handleMessage("vi-manual-list-data", {
requestId: controller.getState().pending.viRead,
itemCount: 3,
pageCount: 1,
items: viItems(),
version: versionA
});
controller.actions.moveViItem(2, -1);
controller.actions.saveVi();
const save = context.latest("save-vi-manual-list");
controller.handleMessage("vi-manual-list-save-result", {
requestId: save.payload.requestId,
saved: true,
recovered: false,
persisted: true,
itemCount: 3,
pageCount: 1,
version: versionB
});
controller.handleMessage("vi-manual-list-data", {
requestId: controller.getState().pending.viRead,
itemCount: 3,
pageCount: 1,
items: save.payload.items,
version: versionA
});
assert.equal(controller.getState().viVerified, false);
assert.equal(controller.actions.addViCut(), false);
});
test("VI stale-data conflict is terminal, preserves the draft, and requires reread", () => {
const context = fixture();
const { controller } = context;
controller.openVi();
enableManualStore(context);
controller.handleMessage("vi-manual-list-data", {
requestId: controller.getState().pending.viRead,
itemCount: 3,
pageCount: 1,
items: viItems(),
version: versionA
});
controller.actions.moveViItem(2, -1);
const draft = controller.getState().viItems;
assert.equal(controller.actions.saveVi(), true);
const save = context.latest("save-vi-manual-list");
const postCount = context.posts.length;
assert.equal(controller.handleMessage("manual-operator-data-error", {
requestId: save.payload.requestId,
operation: "save-vi",
code: "STALE_DATA",
message: "The VI list changed. Reload before saving.",
retryable: false,
outcomeUnknown: false
}), true);
assert.deepEqual(controller.getState().viItems, draft);
assert.equal(controller.getState().viBaseVersion, null);
assert.equal(controller.getState().viVersion, null);
assert.equal(controller.getState().viVerified, false);
assert.equal(controller.getState().quarantined, false);
assert.equal(controller.actions.saveVi(), false);
assert.equal(context.posts.length, postCount);
});
test("empty VI save is a successful no-op and can never create an empty cut", () => {
const context = fixture();
const { controller } = context;
controller.openVi();
enableManualStore(context);
controller.handleMessage("vi-manual-list-data", {
requestId: controller.getState().pending.viRead,
itemCount: 3,
pageCount: 1,
items: viItems(),
version: versionA
});
controller.actions.clearViItems();
assert.equal(controller.actions.addViCut(), false);
assert.equal(controller.actions.saveVi(), true);
const save = context.latest("save-vi-manual-list");
assert.equal(save.payload.expectedVersion, versionA);
assert.deepEqual(save.payload.items, []);
assert.equal(controller.handleMessage("vi-manual-list-save-result", {
requestId: save.payload.requestId,
saved: true,
recovered: false,
persisted: false,
itemCount: 3,
pageCount: 1,
version: versionA
}), true);
assert.ok(context.toasts.some(item => item.message.includes("변경하지 않았습니다")));
controller.handleMessage("vi-manual-list-data", {
requestId: controller.getState().pending.viRead,
itemCount: 3,
pageCount: 1,
items: viItems(),
version: versionA
});
assert.equal(controller.getState().viItems.length, 3);
controller.actions.clearViItems();
assert.equal(controller.actions.addViCut(), false);
});
test("stale correlations are ignored and OutcomeUnknown quarantines every later save", () => {
const context = fixture();
const { controller } = context;
controller.openVi();
enableManualStore(context);
const staleRead = controller.getState().pending.viRead;
controller.actions.requestViRead();
const currentRead = controller.getState().pending.viRead;
assert.notEqual(staleRead, currentRead);
assert.equal(controller.handleMessage("vi-manual-list-data", {
requestId: staleRead,
itemCount: 3,
pageCount: 1,
items: viItems(),
version: versionA
}), false);
assert.equal(controller.getState().viItems.length, 0);
controller.handleMessage("vi-manual-list-data", {
requestId: currentRead,
itemCount: 3,
pageCount: 1,
items: viItems(),
version: versionA
});
assert.equal(controller.actions.saveVi(), true);
const save = context.latest("save-vi-manual-list");
assert.equal(controller.handleMessage("manual-operator-data-error", {
requestId: save.payload.requestId,
operation: "save-vi",
code: "OUTCOME_UNKNOWN",
message: "저장 결과를 확인할 수 없습니다.",
retryable: false,
outcomeUnknown: true
}), true);
assert.equal(controller.getState().quarantined, true);
const postCount = context.posts.length;
assert.equal(controller.actions.saveVi(), false);
assert.equal(context.posts.length, postCount);
});
test("stock search is exact and correlated before it can append a VI row", () => {
const context = fixture();
const { controller } = context;
controller.openVi();
assert.equal(controller.actions.searchStocks("삼성"), true);
const search = context.latest("search-stocks");
assert.equal(controller.handleMessage("stock-search-results", {
requestId: "stale_request",
query: "삼성",
retrievedAt: "2026-07-12T00:00:00+09:00",
totalRowCount: 1,
truncated: false,
results: [{ market: "kospi", source: "oracle", name: "삼성전자", code: "005930" }]
}), false);
assert.equal(controller.handleMessage("stock-search-results", {
requestId: search.payload.requestId,
query: "삼성",
retrievedAt: "2026-07-12T00:00:00+09:00",
totalRowCount: 1,
truncated: false,
results: [{ market: "kospi", source: "oracle", name: "삼성전자", code: "005930" }]
}), true);
assert.equal(controller.actions.addSearchResult(0), true);
assert.deepEqual(controller.getState().viItems, [{ code: "P005930", name: "삼성전자" }]);
});
test("explicit and host locks block edits, saves, and cut creation", () => {
const context = fixture();
const { controller } = context;
controller.openNetSell("INDIVIDUAL");
enableManualStore(context);
controller.handleMessage("manual-net-sell-data", {
requestId: controller.getState().pending.netRead,
audience: "INDIVIDUAL",
rows: netRows()
});
controller.setLocked(true);
assert.equal(controller.actions.setNetCell(0, "leftName", "변경"), false);
assert.equal(controller.actions.saveNetSell(), false);
controller.setLocked(false);
context.setExternalLock(true);
assert.equal(controller.actions.saveNetSell(), false);
assert.equal(controller.getState().locked, true);
});
test("legacy FSell and VI import requires explicit confirmation and sends no path", () => {
const context = fixture();
const { controller } = context;
const previousConfirm = globalThis.confirm;
try {
controller.openVi();
enableManualStore(context);
controller.handleMessage("vi-manual-list-data", {
requestId: controller.getState().pending.viRead,
itemCount: 0,
pageCount: 0,
items: [],
version: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
});
globalThis.confirm = () => false;
const before = context.posts.length;
assert.equal(controller.actions.importLegacyManualData(), false);
assert.equal(context.posts.length, before);
let prompt = "";
globalThis.confirm = message => {
prompt = message;
return true;
};
assert.equal(controller.actions.importLegacyManualData(), true);
const request = context.latest("import-legacy-manual-operator-data");
assert.deepEqual(Object.keys(request.payload), ["requestId"]);
assert.equal(/path|directory|sourceRoot/iu.test(JSON.stringify(request)), false);
assert.match(prompt, /기존 대상 파일이 하나라도 있으면/u);
assert.match(prompt, /덮어쓰지 않고 중단/u);
assert.equal(controller.handleMessage("legacy-manual-operator-import-result", {
requestId: request.payload.requestId,
markerVersion: 1,
sourceSha256: "b".repeat(64),
importedAt: "2026-07-12T01:02:03+00:00",
netSellRowCount: 15,
viItemCount: 9
}), true);
assert.ok(controller.getState().pending.viRead);
assert.ok(context.toasts.some(item => item.message.includes("가져왔습니다")));
} finally {
if (previousConfirm === undefined) delete globalThis.confirm;
else globalThis.confirm = previousConfirm;
}
});
test("legacy import failure is terminal and never auto-retries", () => {
const context = fixture();
const { controller } = context;
const previousConfirm = globalThis.confirm;
try {
globalThis.confirm = () => true;
controller.openVi();
enableManualStore(context);
controller.handleMessage("vi-manual-list-data", {
requestId: controller.getState().pending.viRead,
itemCount: 0,
pageCount: 0,
items: [],
version: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
});
controller.actions.importLegacyManualData();
const request = context.latest("import-legacy-manual-operator-data");
const before = context.posts.length;
assert.equal(controller.handleMessage("manual-operator-data-error", {
requestId: request.payload.requestId,
operation: "import-legacy-manual-data",
code: "DESTINATION_NOT_EMPTY",
message: "기존 데이터가 있어 아무 파일도 덮어쓰지 않았습니다.",
retryable: false,
outcomeUnknown: false
}), true);
assert.equal(context.posts.length, before);
assert.equal(controller.getState().pending.legacyImport, null);
} finally {
if (previousConfirm === undefined) delete globalThis.confirm;
else globalThis.confirm = previousConfirm;
}
});
test("controller source constructs DOM without HTML injection or filesystem paths", () => {
const source = fs.readFileSync(
path.resolve(__dirname, "..", "..", "Web", "manual-lists-ui.js"),
"utf8");
assert.equal(source.includes("innerHTML"), false);
assert.equal(source.includes("insertAdjacentHTML"), false);
assert.equal(/(?:[A-Z]:\\|file:\/\/)/u.test(source), false);
});