Complete legacy operator UI and playout migration

This commit is contained in:
2026-07-12 02:05:49 +09:00
parent d2e16bf0c2
commit ffb8f43c19
131 changed files with 48473 additions and 228 deletions

View 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);
});