const test = require("node:test"); const assert = require("node:assert/strict"); const workflow = require("../../Web/named-manual-restore-workflow.js"); const manualLists = require("../../Web/manual-lists-workflow.js"); const manualFinancial = require("../../Web/manual-financial-workflow.js"); const namedPlaylists = require("../../Web/named-playlist-workflow.js"); function raw(overrides = {}) { return { itemIndex: 0, enabled: true, groupCode: "INDIVIDUAL", subject: "INDEX", graphicType: "MANUAL_NET_SELL", subtype: "MANUAL_NET_SELL", dataCode: "", pageText: "1/1", ...overrides }; } const options = { id: "db-invalid-00000001-0", fadeDuration: 6 }; const rows = Array.from({ length: 5 }, (_, index) => ({ leftName: `L${index}`, leftAmount: `${index}`, rightName: `R${index}`, rightAmount: `${index + 10}` })); test("s5025 raw restoration accepts only canonical or exact legacy audience signatures", () => { const canonical = workflow.classify(raw(), options); assert.equal(canonical.kind, "net-sell"); assert.equal(canonical.audience, "INDIVIDUAL"); const legacy = workflow.classify(raw({ groupCode: "외국인 순매도 상위(수동)", subject: "지수", graphicType: "순매도 상위", subtype: "순매도 상위" }), options); assert.equal(legacy.audience, "FOREIGN"); assert.equal(legacy.historical, true); assert.equal(workflow.classify(raw({ groupCode: "외국인 순매도 상위", subject: "지수", graphicType: "순매도 상위", subtype: "순매도 상위" }), options), null); assert.equal(workflow.classify(raw({ dataCode: "path.dat" }), options), null); assert.equal(workflow.classify(raw({ pageText: "1/2" }), options), null); }); test("s5025 becomes playable only after one correlated trusted five-row read", () => { const pending = workflow.classify(raw({ enabled: false }), options); const request = workflow.createManualListReadRequest("named-net-read", pending); const entry = workflow.materializeManualList(pending, { requestId: "named-net-read", audience: "INDIVIDUAL", rows }, request); assert.ok(entry); assert.equal(entry.enabled, false); assert.equal(entry.builderKey, "s5025"); assert.equal(manualLists.isPlaylistEntryPlayable(entry), true); assert.equal(manualLists.restorePlaylistEntry(entry).operator.audience, "INDIVIDUAL"); assert.equal(workflow.materializeManualList(pending, { requestId: "stale-read", audience: "INDIVIDUAL", rows }, request), null); }); test("versioned and historical VI raw rows require the current exact native snapshot", () => { const version = "a".repeat(64); const current = workflow.classify(raw({ groupCode: "PAGED_VI", subject: manualLists.viSnapshotSubject, graphicType: "INPUT_ORDER", subtype: "CURRENT", dataCode: version }), options); const request = workflow.createManualListReadRequest("named-vi-read", current); const payload = { requestId: "named-vi-read", itemCount: 2, pageCount: 1, items: [{ code: "005930", name: "삼성전자" }, { code: "000660", name: "SK하이닉스" }], version }; assert.equal(workflow.materializeManualList(current, payload, request).builderKey, "s5074"); assert.equal(workflow.materializeManualList(current, { ...payload, version: "b".repeat(64) }, request), null); assert.equal(workflow.materializeManualList( workflow.classify(raw({ groupCode: "PAGED_VI", subject: manualLists.viSnapshotSubject, graphicType: "INPUT_ORDER", subtype: "CURRENT", dataCode: version, pageText: "1/2" }), options), payload, request), null); const historical = workflow.classify(raw({ groupCode: "", subject: "삼성전자,SK하이닉스", graphicType: "5단 표그래프", subtype: "VI 발동(수동)", dataCode: "" }), options); const historicalRequest = workflow.createManualListReadRequest("named-vi-history", historical); assert.ok(workflow.materializeManualList(historical, { ...payload, requestId: "named-vi-history" }, historicalRequest)); assert.equal(workflow.materializeManualList(historical, { ...payload, requestId: "named-vi-history", items: [...payload.items].reverse() }, historicalRequest), null); const duplicate = workflow.classify(raw({ groupCode: "", subject: "삼성전자,삼성전자", graphicType: "5단 표그래프", subtype: "VI 발동(수동)" }), options); const duplicateRequest = workflow.createManualListReadRequest("named-vi-duplicate", duplicate); assert.ok(workflow.materializeManualList(duplicate, { ...payload, requestId: "named-vi-duplicate", items: [payload.items[0], payload.items[0]] }, duplicateRequest)); assert.equal(workflow.materializeManualList(duplicate, { ...payload, requestId: "named-vi-duplicate" }, duplicateRequest), null); assert.equal(workflow.classify(raw({ groupCode: "", subject: "삼성전자,SK하이닉스", graphicType: "5단 표그래프", subtype: "VI 발동(수동)", pageText: "1/2" }), options), null); assert.equal(workflow.classify(raw({ groupCode: "", subject: "삼성전자, SK하이닉스", graphicType: "5단 표그래프", subtype: "VI 발동(수동)" }), options), null); }); test("GraphE raw rows yield only a fresh-load descriptor with exact screen, market and name", () => { for (const profile of manualFinancial.sourceContract.screens) { const pending = workflow.classify(raw({ groupCode: "코스피", subject: "삼성전자", graphicType: profile.label, subtype: "", dataCode: "" }), options); assert.equal(pending.kind, "financial"); assert.equal(pending.screen, profile.screen); assert.equal(pending.builderKey, profile.builderKey); assert.equal(pending.market, "kospi"); assert.deepEqual(pending.identity, { screen: profile.screen, stockName: "삼성전자" }); } const profile = manualFinancial.sourceContract.screens[0]; assert.equal(workflow.classify(raw({ groupCode: "KOSPI", subject: "삼성전자", graphicType: profile.label, subtype: "", dataCode: "" }), options), null); assert.equal(workflow.classify(raw({ groupCode: "코스피", subject: "삼성전자", graphicType: profile.graphicType, subtype: "", dataCode: "" }), options), null); assert.equal(workflow.classify(raw({ groupCode: "UNKNOWN", subject: "삼성전자", graphicType: profile.graphicType, subtype: "", dataCode: "" }), options), null); assert.equal(workflow.classify(raw({ groupCode: "KOSPI", subject: "삼성전자", graphicType: profile.graphicType, subtype: "", dataCode: "005930" }), options), null); assert.equal(workflow.classify(raw({ groupCode: "KOSPI", subject: "삼성전자", graphicType: profile.graphicType, subtype: "", dataCode: "", pageText: "1/2" }), options), null); }); function createFreshGraphEntry() { const screen = "sales"; const record = { kind: screen, stockName: "삼성전자", quarters: ["1Q", "2Q", "3Q", "4Q", "5Q", "6Q"] .map((quarter, index) => ({ quarter, value: index + 1 })) }; const rowVersion = "A".repeat(64); const loadRequest = manualFinancial.createLoadRequest("named-save-load", screen, "삼성전자"); const load = manualFinancial.normalizeLoadResponse({ requestId: "named-save-load", screen, retrievedAt: "2026-07-12T01:00:00+09:00", snapshot: { stockName: "삼성전자", rowVersion, record } }, loadRequest); const stockResponse = manualFinancial.normalizeStockSearchResponse({ requestId: "named-save-stock", query: "삼성전자", retrievedAt: "2026-07-12T01:00:01+09:00", totalRowCount: 1, truncated: false, results: [{ market: "kospi", source: "oracle", name: "삼성전자", code: "005930" }] }); const stock = manualFinancial.verifyStockForRecord(record, stockResponse, "kospi", "005930"); const selectionRequest = manualFinancial.createSelectionRequest("named-save-selection", load.snapshot, stock); const selection = manualFinancial.normalizeSelectionResponse({ requestId: "named-save-selection", screen, stockName: "삼성전자", rowVersion, builderKey: "s5080", code: "5080", aliases: ["5080"], selection: { groupCode: "KOSPI", subject: "삼성전자", graphicType: "SALES", subtype: "", dataCode: "" }, currentPage: 1, totalPages: 1 }, selectionRequest); return manualFinancial.createPlaylistEntry(load.snapshot, stock, selection, { id: "named-save-graph", fadeDuration: 6 }); } test("named save accepts only live fresh-read manual identities", () => { const netRead = manualLists.normalizeNetSellDataResponse({ requestId: "named-save-net-read", audience: "FOREIGN", rows }, { requestId: "named-save-net-read", audience: "FOREIGN" }); const net = manualLists.createNetSellPlaylistEntry("FOREIGN", { id: "named-save-net", fadeDuration: 6 }, netRead); assert.equal(workflow.isTrustedEntryForSave(net, 0), true); assert.equal(workflow.isTrustedEntryForSave(JSON.parse(JSON.stringify(net)), 0), false); const version = "b".repeat(64); const viPayload = { requestId: "named-save-vi-read", itemCount: 2, pageCount: 1, items: [{ code: "P005930", name: "삼성전자" }, { code: "P000660", name: "SK하이닉스" }], version }; const viRead = manualLists.normalizeViDataResponse(viPayload, viPayload.requestId); const vi = manualLists.createViPlaylistEntry(viRead.items, viRead.version, { id: "named-save-vi", fadeDuration: 6 }, viRead); assert.equal(workflow.isTrustedEntryForSave(vi, 1), true); vi.pageCount = 2; assert.equal(workflow.isTrustedEntryForSave(vi, 1), false); const graph = createFreshGraphEntry(); assert.equal(workflow.isTrustedEntryForSave(graph, 2), true); assert.equal(workflow.isTrustedEntryForSave(JSON.parse(JSON.stringify(graph)), 2), false); vi.pageCount = 1; const replace = namedPlaylists.createReplaceRequest( "named-save-replace", "12345678", [net, vi, graph], { isTrustedEntry: workflow.isTrustedEntryForSave, pageTextForEntry: entry => entry.operator?.kind === "vi" ? `1/${entry.pageCount}` : "1/1", fieldsForEntry: workflow.legacyFieldsForEntry }); assert.deepEqual(replace.items.map(item => [ item.groupCode, item.subject, item.graphicType, item.subtype, item.pageText, item.dataCode ]), [ ["외국인 순매도 상위(수동)", "지수", "순매도 상위", "순매도 상위", "1/1", ""], ["", "삼성전자,SK하이닉스", "5단 표그래프", "VI 발동(수동)", "1/1", ""], ["코스피", "삼성전자", "매출액", "", "1/1", ""] ]); }); test("stored local FSell and VI entries require the same fresh-read materialization", () => { const net = manualLists.createNetSellPlaylistEntry("INSTITUTION", { id: "stored-local-net", fadeDuration: 6, enabled: false }); const pendingNet = workflow.createStoredManualListPending( JSON.parse(JSON.stringify(net)), 0); assert.equal(workflow.isStoredManualListPending(pendingNet), true); const netRequest = workflow.createManualListReadRequest("stored-net-read", pendingNet); const restoredNet = workflow.materializeManualList(pendingNet, { requestId: "stored-net-read", audience: "INSTITUTION", rows }, netRequest); assert.ok(restoredNet); assert.equal(restoredNet.enabled, false); assert.equal(manualLists.isPlaylistEntryPlayable(restoredNet), true); const version = "c".repeat(64); const items = [{ code: "P005930", name: "삼성전자" }]; const vi = manualLists.createViPlaylistEntry(items, version, { id: "stored-local-vi", fadeDuration: 6 }); const pendingVi = workflow.createStoredManualListPending( JSON.parse(JSON.stringify(vi)), 1); const viRequest = workflow.createManualListReadRequest("stored-vi-read", pendingVi); const viPayload = { requestId: "stored-vi-read", itemCount: 1, pageCount: 1, items, version }; assert.equal(workflow.materializeManualList(pendingVi, viPayload, viRequest).builderKey, "s5074"); assert.equal(workflow.materializeManualList( pendingVi, { ...viPayload, version: "d".repeat(64) }, viRequest), null); assert.equal(workflow.createStoredManualListPending({ ...JSON.parse(JSON.stringify(vi)), selection: { ...vi.selection, dataCode: "tampered" } }, 1), null); });