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