"use strict"; const test = require("node:test"); const assert = require("node:assert/strict"); const workflow = require("../../Web/manual-lists-workflow.js"); const requestId = "manual_req_1"; const rowVersion = "a".repeat(64); const rows = () => Array.from({ length: 5 }, (_, index) => ({ leftName: `왼쪽${index + 1}`, leftAmount: `${index + 1},000`, rightName: `오른쪽${index + 1}`, rightAmount: `-${index + 1},000` })); const viItems = () => [ { code: "005930", name: "삼성전자" }, { code: "000660", name: "SK하이닉스" }, { code: "005930", name: "삼성전자" } ]; test("bridge requests expose only closed operations and keys", () => { assert.deepEqual(workflow.createStatusRequest(requestId), { type: "request-manual-operator-data-status", payload: { requestId } }); assert.deepEqual(workflow.createNetSellReadRequest(requestId, "FOREIGN"), { type: "request-manual-net-sell-data", payload: { requestId, audience: "FOREIGN" } }); assert.deepEqual(workflow.createViReadRequest(requestId), { type: "request-vi-manual-list", payload: { requestId } }); const legacyImport = workflow.createLegacyImportRequest(requestId); assert.deepEqual(legacyImport, { type: "import-legacy-manual-operator-data", payload: { requestId } }); assert.equal(/path|directory|sourceRoot/iu.test(JSON.stringify(legacyImport)), false); }); test("legacy import receipt is exact, correlated, versioned, and path-free", () => { const response = { requestId, markerVersion: 1, sourceSha256: "b".repeat(64), importedAt: "2026-07-12T01:02:03+00:00", netSellRowCount: 15, viItemCount: 9 }; assert.deepEqual(workflow.normalizeLegacyImportResponse(response, requestId), response); assert.equal(workflow.normalizeLegacyImportResponse({ ...response, markerVersion: 2 }), null); assert.equal(workflow.normalizeLegacyImportResponse({ ...response, path: "C:\\private" }), null); assert.equal(workflow.normalizeLegacyImportResponse(response, "other_request"), null); }); test("net-sell save request requires exactly five strict rows", () => { const request = workflow.createNetSellSaveRequest(requestId, "INDIVIDUAL", rows()); assert.equal(request.type, "save-manual-net-sell-data"); assert.equal(request.payload.rows.length, 5); assert.throws(() => workflow.createNetSellSaveRequest(requestId, "INDIVIDUAL", rows().slice(0, 4))); assert.throws(() => workflow.createNetSellSaveRequest(requestId, "OTHER", rows())); assert.throws(() => workflow.createNetSellSaveRequest(requestId, "INDIVIDUAL", [ ...rows().slice(0, 4), { ...rows()[4], extra: "not allowed" } ])); }); test("manual value delimiter and control characters fail closed", () => { for (const leftName of ["bad^name", "bad\nname", "bad\u200Ename", "bad\uFFFDname"]) { const invalid = rows(); invalid[0].leftName = leftName; assert.throws(() => workflow.createNetSellSaveRequest(requestId, "FOREIGN", invalid)); } }); test("VI save preserves order and duplicates but never exposes a path", () => { const request = workflow.createViSaveRequest(requestId, viItems(), rowVersion); assert.deepEqual(request, { type: "save-vi-manual-list", payload: { requestId, expectedVersion: rowVersion, items: viItems() } }); assert.equal(JSON.stringify(request).includes("path"), false); assert.throws(() => workflow.createViSaveRequest(requestId, viItems())); assert.throws(() => workflow.createViSaveRequest(requestId, viItems(), "A".repeat(64))); assert.throws(() => workflow.createViSaveRequest(requestId, viItems(), "a".repeat(63))); }); test("VI list rejects comma ambiguity, unsafe text, and more than twenty pages", () => { assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: "1", name: "A,B" }], rowVersion)); assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: "1^2", name: "A" }], rowVersion)); assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: " 1", name: "A" }], rowVersion)); assert.throws(() => workflow.createViSaveRequest(requestId, [{ code: "1", name: "A " }], rowVersion)); assert.throws(() => workflow.createViSaveRequest( requestId, Array.from({ length: 101 }, (_, index) => ({ code: String(index + 1), name: `Item${index + 1}` })), rowVersion)); assert.throws(() => workflow.createViSaveRequest( requestId, Array.from({ length: 101 }, (_, index) => ({ code: String(index + 1), name: `종목${index + 1}` })))); }); test("status response preserves process-lifetime write quarantine", () => { assert.deepEqual(workflow.normalizeStatusResponse({ requestId, available: true, code: "READY", writeQuarantined: true, message: "저장 결과를 확인할 수 없습니다." }, requestId), { requestId, available: true, code: "READY", writeQuarantined: true, message: "저장 결과를 확인할 수 없습니다." }); assert.equal(workflow.normalizeStatusResponse({ requestId, available: true, code: "READY", writeQuarantined: true, message: "저장 결과를 확인할 수 없습니다.", extra: true }), null); assert.equal(workflow.normalizeStatusResponse({ requestId, available: true, code: "IMPORT_INTERRUPTED", writeQuarantined: false, message: null }), null); assert.equal(workflow.normalizeStatusResponse({ requestId, available: false, code: "UNRECOGNIZED_STATE", writeQuarantined: false, message: "unavailable" }), null); }); test("net-sell data response is correlated and exact", () => { const response = { requestId, audience: "INSTITUTION", rows: rows() }; assert.ok(workflow.normalizeNetSellDataResponse(response, { requestId, audience: "INSTITUTION" })); assert.equal(workflow.normalizeNetSellDataResponse(response, { requestId: "other", audience: "INSTITUTION" }), null); }); test("net-sell write result distinguishes verified recovery", () => { const response = { requestId, audience: "FOREIGN", saved: true, recovered: true }; assert.ok(workflow.normalizeNetSellSaveResponse(response, { requestId, audience: "FOREIGN" })); assert.equal(workflow.normalizeNetSellSaveResponse({ ...response, saved: false }), null); }); test("VI data response verifies item and page counts", () => { const response = { requestId, itemCount: 3, pageCount: 1, items: viItems(), version: rowVersion }; assert.ok(workflow.normalizeViDataResponse(response, requestId)); assert.equal(workflow.normalizeViDataResponse({ ...response, pageCount: 2 }), null); assert.equal(workflow.normalizeViDataResponse({ ...response, itemCount: 2 }), null); }); test("VI save response rejects impossible page counts", () => { assert.ok(workflow.normalizeViSaveResponse({ requestId, saved: true, recovered: false, persisted: true, itemCount: 100, pageCount: 20, version: rowVersion }, requestId)); assert.equal(workflow.normalizeViSaveResponse({ requestId, saved: true, recovered: false, persisted: true, itemCount: 100, pageCount: 19, version: rowVersion }), null); assert.ok(workflow.normalizeViSaveResponse({ requestId, saved: true, recovered: false, persisted: false, itemCount: 3, pageCount: 1, version: rowVersion }, requestId)); }); test("OutcomeUnknown errors are never retryable", () => { assert.ok(workflow.normalizeError({ requestId, operation: "save-vi", code: "OUTCOME_UNKNOWN", message: "저장 결과를 확인할 수 없습니다.", retryable: false, outcomeUnknown: true }, requestId)); assert.equal(workflow.normalizeError({ requestId, operation: "save-vi", code: "OUTCOME_UNKNOWN", message: "저장 결과를 확인할 수 없습니다.", retryable: true, outcomeUnknown: true }), null); }); test("manual net-sell playlist mapping is original s5025 contract", () => { const entry = workflow.createNetSellPlaylistEntry("INDIVIDUAL", { id: "entry_1", fadeDuration: 6 }); assert.equal(entry.builderKey, "s5025"); assert.equal(entry.code, "5025"); assert.deepEqual(entry.selection, { groupCode: "INDIVIDUAL", subject: "INDEX", graphicType: "MANUAL_NET_SELL", subtype: "MANUAL_NET_SELL", dataCode: "" }); }); test("VI playlist mapping uses a bounded trusted snapshot reference", () => { const entry = workflow.createViPlaylistEntry(viItems(), rowVersion, { id: "entry_2", fadeDuration: 4 }); assert.equal(entry.builderKey, "s5074"); assert.equal(entry.code, "5074"); assert.equal(entry.itemCount, 3); assert.equal(entry.pageSize, 5); assert.equal(entry.pageCount, 1); assert.equal(entry.selection.groupCode, "PAGED_VI"); assert.equal(entry.selection.subject, workflow.viSnapshotSubject); assert.equal(entry.selection.dataCode, rowVersion); assert.ok(entry.selection.subject.length <= 256); }); test("one hundred realistic long names still create a bounded twenty-page reference", () => { const items = Array.from({ length: 100 }, (_, index) => ({ code: `P${String(index + 1).padStart(6, "0")}`, name: `현실적인긴종목명테스트${index + 1}` })); const entry = workflow.createViPlaylistEntry(items, rowVersion, { id: "entry_20_pages", fadeDuration: 4 }); assert.equal(entry.pageCount, 20); assert.equal(entry.selection.subject, workflow.viSnapshotSubject); assert.equal(entry.selection.dataCode.length, 64); assert.equal(JSON.stringify(entry.selection).includes(items[0].name), false); }); test("empty VI list can be saved but cannot create a playout entry", () => { assert.deepEqual(workflow.createViSaveRequest(requestId, [], rowVersion), { type: "save-vi-manual-list", payload: { requestId, expectedVersion: rowVersion, items: [] } }); assert.throws(() => workflow.createViPlaylistEntry([], rowVersion, { id: "entry_empty", fadeDuration: 6 })); }); test("trusted restore recreates manual entries and rejects tampering", () => { const netSell = workflow.createNetSellPlaylistEntry("FOREIGN", { id: "entry_net", fadeDuration: 6 }); const vi = workflow.createViPlaylistEntry(viItems(), rowVersion, { id: "entry_vi", fadeDuration: 6 }); assert.deepEqual(workflow.restorePlaylistEntry(netSell), netSell); assert.deepEqual(workflow.restorePlaylistEntry(vi), vi); assert.equal(workflow.restorePlaylistEntry({ ...vi, selection: { ...vi.selection, subject: "다른종목" } }), null); assert.equal(workflow.restorePlaylistEntry({ ...vi, extra: true }), null); assert.equal(workflow.restorePlaylistEntry({ ...vi, operator: { ...vi.operator, pagePreview: { ...vi.operator.pagePreview, pageCount: 20 } } }), null); assert.equal(workflow.restorePlaylistEntry({ ...vi, selection: { ...vi.selection, extra: true } }), null); assert.equal(workflow.restorePlaylistEntry({ ...netSell, operator: { ...netSell.operator, audience: "OTHER" } }), null); }); test("only entries bound to a correlated fresh read are playable", () => { const unverifiedNet = workflow.createNetSellPlaylistEntry("FOREIGN", { id: "entry_net_unverified", fadeDuration: 6 }); assert.equal(workflow.isPlaylistEntryPlayable(unverifiedNet), false); const netRead = workflow.normalizeNetSellDataResponse({ requestId, audience: "FOREIGN", rows: rows() }, { requestId, audience: "FOREIGN" }); const verifiedNet = workflow.createNetSellPlaylistEntry("FOREIGN", { id: "entry_net_verified", fadeDuration: 6 }, netRead); assert.equal(workflow.isPlaylistEntryPlayable(verifiedNet), true); assert.equal(workflow.isPlaylistEntryPlayable( workflow.restorePlaylistEntry(JSON.parse(JSON.stringify(verifiedNet)))), false); assert.equal(workflow.isPlaylistEntryPlayable(workflow.createNetSellPlaylistEntry( "INDIVIDUAL", { id: "entry_net_wrong_audience", fadeDuration: 6 }, netRead)), false); const viRead = workflow.normalizeViDataResponse({ requestId, itemCount: viItems().length, pageCount: 1, items: viItems(), version: rowVersion }, requestId); const verifiedVi = workflow.createViPlaylistEntry(viItems(), rowVersion, { id: "entry_vi_verified", fadeDuration: 6 }, viRead); assert.equal(workflow.isPlaylistEntryPlayable(verifiedVi), true); assert.equal(workflow.isPlaylistEntryPlayable(workflow.createViPlaylistEntry( viItems().slice(0, 2), rowVersion, { id: "entry_vi_stale", fadeDuration: 6 }, viRead)), false); }); test("enabled replacement transfers only the correlated manual-list read capability", () => { const netRead = workflow.normalizeNetSellDataResponse({ requestId, audience: "FOREIGN", rows: rows() }, { requestId, audience: "FOREIGN" }); const entry = workflow.createNetSellPlaylistEntry("FOREIGN", { id: "entry_net_toggle", fadeDuration: 6 }, netRead); const replacement = workflow.withEnabled(entry, false); assert.notEqual(replacement, entry); assert.equal(entry.enabled, true); assert.equal(replacement.enabled, false); assert.equal(workflow.isPlaylistEntryPlayable(replacement), true); assert.equal(workflow.withEnabled({ ...entry }, false), null); assert.equal(workflow.withEnabled(workflow.createNetSellPlaylistEntry("FOREIGN", { id: "entry_net_toggle_untrusted", fadeDuration: 6 }), false), null); }); test("request identifiers and option bounds are strict", () => { assert.throws(() => workflow.createStatusRequest("bad id")); assert.throws(() => workflow.createNetSellPlaylistEntry("FOREIGN", { id: "entry", fadeDuration: 61 })); assert.throws(() => workflow.createViPlaylistEntry(viItems(), rowVersion, { id: "entry", fadeDuration: -1 })); assert.throws(() => workflow.createViPlaylistEntry(viItems(), "A".repeat(64), { id: "entry", fadeDuration: 4 })); });