Complete legacy operator UI and playout migration
This commit is contained in:
353
tests/Web/manual-lists-ui.test.cjs
Normal file
353
tests/Web/manual-lists-ui.test.cjs
Normal file
@@ -0,0 +1,353 @@
|
||||
"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,
|
||||
writeQuarantined: false,
|
||||
message: null
|
||||
}), true);
|
||||
}
|
||||
|
||||
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("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);
|
||||
});
|
||||
Reference in New Issue
Block a user