Complete legacy operator UI and playout migration
This commit is contained in:
348
tests/Web/operator-catalog-ui.test.cjs
Normal file
348
tests/Web/operator-catalog-ui.test.cjs
Normal file
@@ -0,0 +1,348 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const workflow = require("../../Web/operator-catalog-workflow.js");
|
||||
const ui = require("../../Web/operator-catalog-ui.js");
|
||||
|
||||
const retrievedAt = "2026-07-12T10:00:00+09:00";
|
||||
|
||||
function themeContext(title = "AI 반도체") {
|
||||
return {
|
||||
selection: {
|
||||
market: "krx",
|
||||
session: "notApplicable",
|
||||
themeCode: "00000001",
|
||||
themeTitle: title,
|
||||
source: "oracle"
|
||||
},
|
||||
preview: {
|
||||
requestId: "theme-preview-1",
|
||||
selection: {
|
||||
market: "krx",
|
||||
session: "notApplicable",
|
||||
themeCode: "00000001",
|
||||
title
|
||||
},
|
||||
retrievedAt,
|
||||
totalRowCount: 2,
|
||||
truncated: false,
|
||||
items: [
|
||||
{ inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
|
||||
{ inputIndex: 4, itemCode: "D035720", itemName: "카카오" }
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function expertContext() {
|
||||
return {
|
||||
selection: { expertCode: "0001", expertName: "홍길동", source: "oracle" },
|
||||
preview: {
|
||||
requestId: "expert-preview-1",
|
||||
selection: { expertCode: "0001", expertName: "홍길동" },
|
||||
retrievedAt,
|
||||
totalRowCount: 2,
|
||||
truncated: false,
|
||||
items: [
|
||||
{ playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 },
|
||||
{ playIndex: 8, stockCode: "035720", stockName: "카카오", buyAmount: 45000 }
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function harness() {
|
||||
const posted = [];
|
||||
const toasts = [];
|
||||
const logs = [];
|
||||
let sequence = 0;
|
||||
let isLocked = false;
|
||||
const controller = ui.createController({
|
||||
postNative(type, payload) { posted.push({ type, payload }); },
|
||||
isLocked() { return isLocked; },
|
||||
showToast(message) { toasts.push(message); },
|
||||
addLog(message) { logs.push(message); },
|
||||
createId() { sequence += 1; return String(sequence); }
|
||||
});
|
||||
return {
|
||||
controller,
|
||||
posted,
|
||||
toasts,
|
||||
logs,
|
||||
setExternalLocked(value) { isLocked = value; }
|
||||
};
|
||||
}
|
||||
|
||||
function completeSchema(h, overrides = {}) {
|
||||
const pending = h.controller.render().activeRead;
|
||||
assert.equal(pending.type, workflow.bridgeContract.requests.schemaPreflight);
|
||||
h.controller.handleMessage(workflow.bridgeContract.events.schemaPreflight, {
|
||||
requestId: pending.request.requestId,
|
||||
validatedAt: retrievedAt,
|
||||
validatedSources: ["oracle", "mariaDb"],
|
||||
themeCanMutate: true,
|
||||
expertCanMutate: true,
|
||||
writeQuarantined: false,
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
|
||||
function mutationResult(active, overrides = {}) {
|
||||
return {
|
||||
requestId: active.requestId,
|
||||
entity: active.entity,
|
||||
operation: active.operation,
|
||||
identityCode: active.identityCode,
|
||||
operationId: "123e4567-e89b-12d3-a456-426614174000",
|
||||
committedAt: retrievedAt,
|
||||
writeQuarantined: false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test("controller exposes the independent integration API", () => {
|
||||
const h = harness();
|
||||
for (const method of [
|
||||
"mount", "openTheme", "openExpert", "handleMessage", "render", "setLocked"
|
||||
]) assert.equal(typeof h.controller[method], "function");
|
||||
});
|
||||
|
||||
test("theme edit starts only from a closed selection and complete preview", () => {
|
||||
const h = harness();
|
||||
const opened = h.controller.openTheme(themeContext());
|
||||
|
||||
assert.equal(opened.entity, "theme");
|
||||
assert.equal(opened.mode, "edit");
|
||||
assert.deepEqual(opened.draft.items, [
|
||||
{ inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
|
||||
{ inputIndex: 1, itemCode: "D035720", itemName: "카카오" }
|
||||
]);
|
||||
assert.equal(h.posted[0].type, workflow.bridgeContract.requests.schemaPreflight);
|
||||
assert.throws(() => h.controller.openTheme({ selection: themeContext().selection }), /exactly/i);
|
||||
});
|
||||
|
||||
test("theme prefixes, duplicate checks and reordering remain explicit in replace requests", () => {
|
||||
const h = harness();
|
||||
h.controller.openTheme(themeContext());
|
||||
completeSchema(h);
|
||||
assert.equal(h.controller.addThemeItem("X", "000660", "SK하이닉스"), false);
|
||||
assert.equal(h.controller.addThemeItem("P", "000660", "SK하이닉스"), true);
|
||||
assert.equal(h.controller.moveDraftItem(2, -1), true);
|
||||
assert.equal(h.controller.updateDraft({ themeTitle: "AI·로봇" }), true);
|
||||
|
||||
assert.deepEqual(h.controller.render().draft.items, [
|
||||
{ inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" },
|
||||
{ inputIndex: 1, itemCode: "P000660", itemName: "SK하이닉스" },
|
||||
{ inputIndex: 2, itemCode: "D035720", itemName: "카카오" }
|
||||
]);
|
||||
assert.equal(h.controller.submit(), true);
|
||||
const sent = h.posted.at(-1);
|
||||
assert.equal(sent.type, workflow.bridgeContract.requests.themeReplace);
|
||||
assert.equal(sent.payload.newTitle, "AI·로봇");
|
||||
assert.deepEqual(sent.payload.items, h.controller.render().draft.items);
|
||||
|
||||
const active = h.controller.render().mutation;
|
||||
h.controller.handleMessage(workflow.bridgeContract.events.mutation,
|
||||
mutationResult(active, { requestId: "stale" }));
|
||||
assert.deepEqual(h.controller.render().mutation, active);
|
||||
h.controller.handleMessage(workflow.bridgeContract.events.mutation, mutationResult(active));
|
||||
assert.equal(h.controller.render().mutation, null);
|
||||
assert.equal(h.controller.render().mode, "list");
|
||||
});
|
||||
|
||||
test("schema, catalog search and next-code reads are serialized and exactly correlated", () => {
|
||||
const h = harness();
|
||||
h.controller.openTheme();
|
||||
assert.equal(h.posted.length, 1);
|
||||
completeSchema(h);
|
||||
assert.equal(h.posted.at(-1).type, workflow.bridgeContract.requests.themeList);
|
||||
|
||||
let pending = h.controller.render().activeRead;
|
||||
h.controller.handleMessage(workflow.bridgeContract.events.themeList, {
|
||||
requestId: pending.request.requestId,
|
||||
query: "",
|
||||
nxtSession: "preMarket",
|
||||
retrievedAt,
|
||||
totalRowCount: 1,
|
||||
truncated: false,
|
||||
canMutate: true,
|
||||
writeQuarantined: false,
|
||||
results: [{
|
||||
market: "krx", session: "notApplicable", themeCode: "00000001",
|
||||
themeTitle: "AI", source: "oracle"
|
||||
}]
|
||||
});
|
||||
assert.equal(h.controller.render().themeResults.length, 1);
|
||||
|
||||
h.controller.search("old", "afterMarket");
|
||||
const old = h.controller.render().activeRead;
|
||||
h.controller.search("latest", "afterMarket");
|
||||
assert.equal(h.controller.render().activeRead.superseded, true);
|
||||
h.controller.handleMessage(workflow.bridgeContract.events.themeList, {
|
||||
requestId: old.request.requestId,
|
||||
query: "old",
|
||||
nxtSession: "afterMarket",
|
||||
retrievedAt,
|
||||
totalRowCount: 0,
|
||||
truncated: false,
|
||||
canMutate: true,
|
||||
writeQuarantined: false,
|
||||
results: []
|
||||
});
|
||||
pending = h.controller.render().activeRead;
|
||||
assert.equal(pending.request.query, "latest");
|
||||
assert.equal(h.controller.render().themeResults.length, 1);
|
||||
h.controller.handleMessage(workflow.bridgeContract.events.themeList, {
|
||||
requestId: pending.request.requestId,
|
||||
query: "latest",
|
||||
nxtSession: "afterMarket",
|
||||
retrievedAt,
|
||||
totalRowCount: 0,
|
||||
truncated: false,
|
||||
canMutate: true,
|
||||
writeQuarantined: false,
|
||||
results: []
|
||||
});
|
||||
assert.equal(h.controller.render().themeResults.length, 0);
|
||||
|
||||
assert.equal(h.controller.beginNewTheme("nxt"), true);
|
||||
pending = h.controller.render().activeRead;
|
||||
h.controller.handleMessage(workflow.bridgeContract.events.themeNextCode, {
|
||||
requestId: pending.request.requestId,
|
||||
market: "nxt",
|
||||
themeCode: "00000007",
|
||||
canMutate: true,
|
||||
writeQuarantined: false
|
||||
});
|
||||
assert.equal(h.controller.render().mode, "new");
|
||||
assert.equal(h.controller.addThemeItem("P", "005930", "삼성전자"), false);
|
||||
assert.equal(h.controller.addThemeItem("X", "005930", "삼성전자"), true);
|
||||
assert.equal(h.controller.addThemeItem("T", "000660", "SK하이닉스"), true);
|
||||
});
|
||||
|
||||
test("expert recommendations preserve exact code, name, buy amount and order", () => {
|
||||
const h = harness();
|
||||
h.controller.openExpert(expertContext());
|
||||
completeSchema(h);
|
||||
assert.equal(h.controller.addExpertRecommendation("005930", "중복", 1), false);
|
||||
assert.equal(h.controller.addExpertRecommendation("000660", "SK하이닉스", "123456"), true);
|
||||
assert.equal(h.controller.moveDraftItem(2, -1), true);
|
||||
assert.equal(h.controller.updateDraft({ expertName: "홍길동 위원" }), true);
|
||||
assert.equal(h.controller.submit(), true);
|
||||
|
||||
const sent = h.posted.at(-1);
|
||||
assert.equal(sent.type, workflow.bridgeContract.requests.expertReplace);
|
||||
assert.equal(sent.payload.newName, "홍길동 위원");
|
||||
assert.deepEqual(sent.payload.recommendations, [
|
||||
{ playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 },
|
||||
{ playIndex: 1, stockCode: "000660", stockName: "SK하이닉스", buyAmount: 123456 },
|
||||
{ playIndex: 2, stockCode: "035720", stockName: "카카오", buyAmount: 45000 }
|
||||
]);
|
||||
});
|
||||
|
||||
test("OutcomeUnknown latches a process quarantine and never retries", () => {
|
||||
const h = harness();
|
||||
h.controller.openExpert(expertContext());
|
||||
completeSchema(h);
|
||||
h.controller.updateDraft({ expertName: "홍길동 위원" });
|
||||
assert.equal(h.controller.submit(), true);
|
||||
const active = h.controller.render().mutation;
|
||||
const writeCount = h.posted.length;
|
||||
h.controller.handleMessage(workflow.bridgeContract.events.error, {
|
||||
requestId: active.requestId,
|
||||
entity: active.entity,
|
||||
operation: active.operation,
|
||||
identityCode: active.identityCode,
|
||||
code: "OUTCOME_UNKNOWN",
|
||||
message: "Restart and reconcile before another write.",
|
||||
retryable: false,
|
||||
outcomeUnknown: true,
|
||||
writeQuarantined: true
|
||||
});
|
||||
|
||||
assert.equal(h.controller.render().quarantined, true);
|
||||
assert.equal(h.controller.render().mutation, null);
|
||||
assert.equal(h.controller.submit(), false);
|
||||
assert.equal(h.posted.length, writeCount);
|
||||
});
|
||||
|
||||
test("a malformed same-request mutation result loses correlation and quarantines", () => {
|
||||
const h = harness();
|
||||
h.controller.openTheme(themeContext());
|
||||
completeSchema(h);
|
||||
assert.equal(h.controller.submit(), true);
|
||||
const active = h.controller.render().mutation;
|
||||
h.controller.handleMessage(workflow.bridgeContract.events.mutation, {
|
||||
...mutationResult(active),
|
||||
operationId: "not-a-uuid"
|
||||
});
|
||||
assert.equal(h.controller.render().quarantined, true);
|
||||
assert.equal(h.controller.render().mutation, null);
|
||||
});
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName, ownerDocument) {
|
||||
this.tagName = tagName.toUpperCase();
|
||||
this.ownerDocument = ownerDocument;
|
||||
this.children = [];
|
||||
this.attributes = new Map();
|
||||
this.listeners = new Map();
|
||||
this.textContent = "";
|
||||
this.value = "";
|
||||
this.hidden = false;
|
||||
this.disabled = false;
|
||||
this.open = false;
|
||||
}
|
||||
append(...values) { values.forEach(value => 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)); }
|
||||
removeAttribute(name) { this.attributes.delete(name); }
|
||||
addEventListener(name, callback) { this.listeners.set(name, callback); }
|
||||
}
|
||||
|
||||
class FakeDocument {
|
||||
constructor() {
|
||||
this.head = new FakeElement("head", this);
|
||||
this.body = new FakeElement("body", this);
|
||||
}
|
||||
createElement(tagName) { return new FakeElement(tagName, this); }
|
||||
getElementById(id) {
|
||||
function find(node) {
|
||||
if (node.id === id) return node;
|
||||
for (const child of node.children) {
|
||||
const match = find(child);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return find(this.head) || find(this.body);
|
||||
}
|
||||
}
|
||||
|
||||
function findRole(node, role) {
|
||||
if (node.attributes?.get("data-role") === role) return node;
|
||||
for (const child of node.children || []) {
|
||||
const match = findRole(child, role);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
test("mount creates a dialog with text-only rendering and its own stylesheet", () => {
|
||||
const h = harness();
|
||||
const document = new FakeDocument();
|
||||
h.controller.mount(document.body);
|
||||
h.controller.openTheme(themeContext("<b>AI</b>"));
|
||||
assert.equal(document.body.children[0].tagName, "DIALOG");
|
||||
assert.equal(document.head.children[0].href, "operator-catalog-ui.css");
|
||||
assert.equal(findRole(document.body, "identity-name").value, "<b>AI</b>");
|
||||
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, "../../Web/operator-catalog-ui.js"),
|
||||
"utf8");
|
||||
assert.doesNotMatch(source, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|\beval\s*\(/);
|
||||
});
|
||||
Reference in New Issue
Block a user