461 lines
16 KiB
JavaScript
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);
|
|
});
|