"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 searchAndSelectStock(h, identity, query = identity.name, expectedSelection = true) { assert.equal(h.controller.searchStocks(query), true); const pending = h.controller.render().activeRead; assert.equal(pending.type, workflow.stockMasterContract.request); assert.equal(h.controller.handleMessage(workflow.stockMasterContract.result, { requestId: pending.request.requestId, query: pending.request.query, retrievedAt, totalRowCount: 1, truncated: false, results: [identity] }), true); assert.equal(h.controller.selectStockResult(0), expectedSelection); } 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 stock identities, duplicate checks and reordering remain explicit in replace requests", () => { const h = harness(); h.controller.openTheme(themeContext()); completeSchema(h); assert.equal(h.controller.addThemeItem("P", "000660", "SK하이닉스"), false); searchAndSelectStock(h, { market: "kospi", source: "oracle", code: "000660", name: "SK하이닉스" }); assert.equal(h.controller.addThemeItem(), 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"); searchAndSelectStock(h, { market: "kospi", source: "oracle", code: "005930", name: "삼성전자" }, "삼성전자", false); assert.equal(h.controller.addThemeItem(), false); searchAndSelectStock(h, { market: "nxt-kospi", source: "mariaDb", code: "005930", name: "삼성전자(NXT)" }); assert.equal(h.controller.addThemeItem(), true); searchAndSelectStock(h, { market: "nxt-kosdaq", source: "mariaDb", code: "000660", name: "SK하이닉스(NXT)" }); assert.equal(h.controller.addThemeItem(), true); assert.deepEqual(h.controller.render().draft.items, [ { inputIndex: 0, itemCode: "X005930", itemName: "삼성전자" }, { inputIndex: 1, itemCode: "T000660", itemName: "SK하이닉스" } ]); }); 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); searchAndSelectStock(h, { market: "kospi", source: "oracle", code: "000660", name: "SK하이닉스" }); assert.equal(h.controller.addExpertRecommendation("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("stock search stale and truncated responses fail closed without selecting free text", () => { const h = harness(); h.controller.openTheme(themeContext()); completeSchema(h); assert.equal(h.controller.searchStocks("삼성"), true); const pending = h.controller.render().activeRead; assert.equal(h.controller.handleMessage(workflow.stockMasterContract.result, { requestId: "stale-request", query: pending.request.query, retrievedAt, totalRowCount: 0, truncated: false, results: [] }), false); assert.equal(h.controller.render().activeRead.request.requestId, pending.request.requestId); assert.equal(h.controller.handleMessage(workflow.stockMasterContract.result, { requestId: pending.request.requestId, query: pending.request.query, retrievedAt, totalRowCount: 1, truncated: true, results: [{ market: "kospi", source: "oracle", code: "005930", name: "삼성전자" }] }), true); assert.equal(h.controller.render().stockSearch.selected, null); assert.equal(h.controller.render().stockSearch.results.length, 0); assert.equal(h.controller.addThemeItem("P", "005930", "삼성전자"), false); }); 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; this.parentElement = null; this.isContentEditable = false; } append(...values) { values.forEach(value => this.appendChild(value)); } appendChild(value) { value.parentElement = this; this.children.push(value); return value; } replaceChildren(...values) { this.children.forEach(value => { value.parentElement = null; }); this.children = []; this.append(...values); } setAttribute(name, value) { this.attributes.set(name, String(value)); } getAttribute(name) { return this.attributes.has(name) ? this.attributes.get(name) : null; } removeAttribute(name) { this.attributes.delete(name); } addEventListener(name, callback) { this.listeners.set(name, callback); } contains(value) { if (value === this) return true; return this.children.some(child => child.contains(value)); } dispatchEvent(event) { if (!event.target) event.target = this; event.currentTarget = this; const callback = this.listeners.get(event.type); if (callback) callback(event); if (!event.propagationStopped && this.parentElement) this.parentElement.dispatchEvent(event); return !event.defaultPrevented; } } 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; } function fakeEvent(type, values = {}) { return { type, defaultPrevented: false, propagationStopped: false, preventDefault() { this.defaultPrevented = true; }, stopPropagation() { this.propagationStopped = true; }, ...values }; } function draftRow(document, index) { return findRole(document.body, "draft-list").children[index]; } 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("AI")); 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, "AI"); assert.ok(findRole(document.body, "stock-query")); assert.equal(findRole(document.body, "stock-code"), null); assert.equal(findRole(document.body, "stock-name"), null); const source = fs.readFileSync( path.resolve(__dirname, "../../Web/operator-catalog-ui.js"), "utf8"); assert.doesNotMatch(source, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|\beval\s*\(/); }); test("Delete removes only the focused or selected expert draft row through the row removal mutation", () => { const h = harness(); const document = new FakeDocument(); h.controller.mount(document.body); h.controller.openExpert(expertContext()); let row = draftRow(document, 1); row.dispatchEvent(fakeEvent("focus")); assert.equal(h.controller.render().selectedDraftIndex, 1); row = draftRow(document, 1); const deleteKey = fakeEvent("keydown", { key: "Delete" }); row.dispatchEvent(deleteKey); assert.equal(deleteKey.defaultPrevented, true); assert.equal(deleteKey.propagationStopped, true); assert.deepEqual(h.controller.render().draft.recommendations, [ { playIndex: 0, stockCode: "005930", stockName: "삼성전자", buyAmount: 70000 } ]); row = draftRow(document, 0); const rowDeleteButton = row.children[3]; rowDeleteButton.dispatchEvent(fakeEvent("click")); assert.deepEqual(h.controller.render().draft.recommendations, []); }); test("Delete removes focused and selected ThemeA rows, reindexes, and stops at the empty boundary", () => { const h = harness(); const document = new FakeDocument(); const context = themeContext(); context.preview.totalRowCount = 3; context.preview.items.push({ inputIndex: 9, itemCode: "P000660", itemName: "SK하이닉스" }); h.controller.mount(document.body); h.controller.openTheme(context); const nativeRequestCount = h.posted.length; let row = draftRow(document, 1); row.dispatchEvent(fakeEvent("focus")); const focusedDelete = fakeEvent("keydown", { key: "Delete" }); row.dispatchEvent(focusedDelete); assert.equal(focusedDelete.defaultPrevented, true); assert.equal(focusedDelete.propagationStopped, true); assert.equal(h.controller.render().selectedDraftIndex, 1); assert.deepEqual(h.controller.render().draft.items, [ { inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" }, { inputIndex: 1, itemCode: "P000660", itemName: "SK하이닉스" } ]); const selectedDelete = fakeEvent("keydown", { key: "Delete" }); findRole(document.body, "draft-list").dispatchEvent(selectedDelete); assert.equal(selectedDelete.defaultPrevented, true); assert.equal(selectedDelete.propagationStopped, true); assert.equal(h.controller.render().selectedDraftIndex, 0); assert.deepEqual(h.controller.render().draft.items, [ { inputIndex: 0, itemCode: "P005930", itemName: "삼성전자" } ]); row = draftRow(document, 0); row.dispatchEvent(fakeEvent("keydown", { key: "Delete" })); assert.equal(h.controller.render().selectedDraftIndex, -1); assert.deepEqual(h.controller.render().draft.items, []); const emptyBoundary = fakeEvent("keydown", { key: "Delete" }); findRole(document.body, "draft-list").dispatchEvent(emptyBoundary); assert.equal(emptyBoundary.defaultPrevented, false); assert.equal(emptyBoundary.propagationStopped, false); assert.deepEqual(h.controller.render().draft.items, []); assert.equal(h.posted.length, nativeRequestCount); }); test("Delete ignores editor inputs, contenteditable regions, closed modal, and outside events", () => { for (const entity of ["theme", "expert"]) { const h = harness(); const document = new FakeDocument(); const outside = document.createElement("button"); document.body.appendChild(outside); h.controller.mount(document.body); if (entity === "theme") h.controller.openTheme(themeContext()); else h.controller.openExpert(expertContext()); let row = draftRow(document, 0); row.dispatchEvent(fakeEvent("focus")); const textarea = document.createElement("textarea"); const contentEditable = document.createElement("div"); contentEditable.setAttribute("contenteditable", "true"); document.body.children.find(element => element.tagName === "DIALOG") .append(textarea, contentEditable); for (const editable of [findRole(document.body, "identity-name"), textarea, contentEditable]) { const editableDelete = fakeEvent("keydown", { key: "Delete" }); editable.dispatchEvent(editableDelete); assert.equal(editableDelete.defaultPrevented, false, entity); assert.equal(editableDelete.propagationStopped, false, entity); } const draftLength = () => entity === "theme" ? h.controller.render().draft.items.length : h.controller.render().draft.recommendations.length; assert.equal(draftLength(), 2, entity); const outsideDelete = fakeEvent("keydown", { key: "Delete" }); outside.dispatchEvent(outsideDelete); assert.equal(outsideDelete.defaultPrevented, false, entity); assert.equal(outsideDelete.propagationStopped, false, entity); assert.equal(draftLength(), 2, entity); row = draftRow(document, 0); h.controller.close(); const closedDelete = fakeEvent("keydown", { key: "Delete" }); row.dispatchEvent(closedDelete); assert.equal(closedDelete.defaultPrevented, false, entity); assert.equal(closedDelete.propagationStopped, false, entity); assert.equal(draftLength(), 2, entity); } }); test("ThemeA draft Delete is consumed but blocked while locked or a DB write is active", () => { const h = harness(); const document = new FakeDocument(); h.controller.mount(document.body); h.controller.openTheme(themeContext()); completeSchema(h); let row = draftRow(document, 0); row.dispatchEvent(fakeEvent("focus")); h.setExternalLocked(true); h.controller.render(); row = draftRow(document, 0); const lockedDelete = fakeEvent("keydown", { key: "Delete" }); row.dispatchEvent(lockedDelete); assert.equal(lockedDelete.defaultPrevented, true); assert.equal(lockedDelete.propagationStopped, true); assert.equal(h.controller.render().draft.items.length, 2); h.setExternalLocked(false); h.controller.render(); assert.equal(h.controller.submit(), true); assert.ok(h.controller.render().mutation); row = draftRow(document, 0); const writingDelete = fakeEvent("keydown", { key: "Delete" }); row.dispatchEvent(writingDelete); assert.equal(writingDelete.defaultPrevented, true); assert.equal(writingDelete.propagationStopped, true); assert.equal(h.controller.render().draft.items.length, 2); }); test("expert draft Delete is consumed but blocked while locked or a DB write is active", () => { const h = harness(); const document = new FakeDocument(); h.controller.mount(document.body); h.controller.openExpert(expertContext()); completeSchema(h); let row = draftRow(document, 0); row.dispatchEvent(fakeEvent("focus")); h.setExternalLocked(true); h.controller.render(); row = draftRow(document, 0); const lockedDelete = fakeEvent("keydown", { key: "Delete" }); row.dispatchEvent(lockedDelete); assert.equal(lockedDelete.defaultPrevented, true); assert.equal(lockedDelete.propagationStopped, true); assert.equal(h.controller.render().draft.recommendations.length, 2); h.setExternalLocked(false); h.controller.render(); assert.equal(h.controller.submit(), true); assert.ok(h.controller.render().mutation); row = draftRow(document, 0); const writingDelete = fakeEvent("keydown", { key: "Delete" }); row.dispatchEvent(writingDelete); assert.equal(writingDelete.defaultPrevented, true); assert.equal(writingDelete.propagationStopped, true); assert.equal(h.controller.render().draft.recommendations.length, 2); });