"use strict"; const test = require("node:test"); const assert = require("node:assert/strict"); const workflow = require("../../Web/named-foreign-stock-restore-workflow.js"); const actions = [ ["1열판기본", "", "stock-current", "s5001", "5001", "CURRENT", null], ["캔들그래프", "5일", "stock-candle-5d", "s8010", "8061", "PRICE", 5], ["캔들그래프", "20일", "stock-candle-20d", "s8010", "8040", "PRICE", 20], ["캔들그래프", "60일", "stock-candle-60d", "s8010", "8046", "PRICE", 60], ["캔들그래프", "120일", "stock-candle-120d", "s8010", "8051", "PRICE", 120], ["캔들그래프", "240일", "stock-candle-240d", "s8010", "8056", "PRICE", 240] ]; function raw(graphicType = "1열판기본", subtype = "", itemIndex = 0, enabled = true) { return { itemIndex, enabled, groupCode: "해외종목", subject: "NVIDIA", graphicType, subtype, pageText: "1/1", dataCode: "" }; } function options(id = "foreign-stock-entry") { return { id, fadeDuration: 6, ma5: true, ma20: false }; } function outcome(pending, overrides = {}) { return { itemIndex: pending.itemIndex, status: "resolved", actionId: pending.actionId, inputName: pending.inputName, symbol: "NAS@NVDA", nationCode: "US", foreignDomesticCode: "1", builderKey: pending.builderKey, cutCode: pending.cutCode, valueType: pending.valueType, periodDays: pending.periodDays, ...overrides }; } function response(requestId, pendingRows, rows = pendingRows.map(value => outcome(value))) { return { requestId, retrievedAt: "2026-07-12T12:34:56+09:00", totalRowCount: rows.length, rows }; } test("only the exact legacy overseas-stock grammar classifies all six UC5 actions", () => { actions.forEach(([graphicType, subtype, actionId, builderKey, cutCode, valueType, periodDays], index) => { const pending = workflow.classify(raw(graphicType, subtype, index, index !== 5), options(`foreign-${index}`)); assert.ok(pending); assert.equal(workflow.isPending(pending), true); assert.equal(pending.actionId, actionId); assert.equal(pending.builderKey, builderKey); assert.equal(pending.cutCode, cutCode); assert.equal(pending.valueType, valueType); assert.equal(pending.periodDays, periodDays); assert.equal(pending.enabled, index !== 5); assert.deepEqual(pending.movingAverages, builderKey === "s8010" ? { ma5: true, ma20: false } : { ma5: false, ma20: false }); }); for (const invalid of [ { ...raw(), groupCode: "FOREIGN_STOCK", dataCode: "NAS@NVDA|US|1" }, { ...raw(), groupCode: " 해외종목" }, { ...raw(), subject: " NVIDIA" }, { ...raw(), graphicType: "CURRENT" }, { ...raw("캔들그래프", "5일"), subtype: "1년" }, { ...raw(), pageText: "1/2" }, { ...raw(), dataCode: "NAS@NVDA" }, { ...raw(), itemIndex: 1000 } ]) { assert.equal(workflow.classify(invalid, options("invalid-row")), null); } assert.equal(workflow.classify(raw(), { ...options(), extra: true }), null); }); test("a correlated unique Oracle identity materializes current and candle entries", () => { for (const [graphicType, subtype, , builderKey, cutCode] of actions) { const pending = workflow.classify(raw(graphicType, subtype, 7, false), options(`entry-${cutCode}`)); const normalized = workflow.normalizeResponse( response("load-one", [pending]), "load-one", [pending]); assert.ok(normalized); const entry = workflow.materialize(pending, normalized.rows[0]); assert.ok(entry); assert.equal(workflow.isMaterializedEntry(entry), true); assert.equal(entry.builderKey, builderKey); assert.equal(entry.code, cutCode); assert.equal(entry.enabled, false); assert.equal(entry.symbol, "NAS@NVDA"); assert.equal(entry.nationCode, "US"); assert.equal(entry.fdtc, "1"); assert.equal(entry.operator.source, workflow.source); assert.equal(entry.selection.groupCode, "FOREIGN_STOCK"); assert.equal(entry.selection.dataCode, "NAS@NVDA|US|1"); assert.equal(entry.selection.graphicType, builderKey === "s8010" ? "MA5" : "1열판기본"); } }); test("a correlated Hangul-letter provider symbol remains a bound closed identity", () => { const pending = workflow.classify( raw("캔들그래프", "5일", 8), options("hangul-symbol")); const normalized = workflow.normalizeResponse(response( "hangul-symbol-load", [pending], [outcome(pending, { symbol: "NAS@해외 종목1" })]), "hangul-symbol-load", [pending]); assert.ok(normalized); const entry = workflow.materialize(pending, normalized.rows[0]); assert.ok(entry); assert.equal(entry.selection.dataCode, "NAS@해외 종목1|US|1"); assert.ok(workflow.restorePlaylistEntry(entry)); }); test("safe supplementary input-name and letter symbols match the native scalar contract", () => { const pending = workflow.classify( { ...raw("1열판기본", "", 10), subject: "회사🚀" }, options("supplementary-symbol")); const normalized = workflow.normalizeResponse(response( "supplementary-symbol-load", [pending], [outcome(pending, { inputName: "회사🚀", symbol: "NAS@𐐀1" })]), "supplementary-symbol-load", [pending]); assert.ok(normalized); const entry = workflow.materialize(pending, normalized.rows[0]); assert.ok(entry); assert.equal(entry.selection.dataCode, "NAS@𐐀1|US|1"); assert.ok(workflow.restorePlaylistEntry(entry)); }); test("batch count, order, request, action and identity correlation are strict", () => { const first = workflow.classify(raw("캔들그래프", "5일", 4), options("first")); const secondRaw = { ...raw("1열판기본", "", 9), subject: "TSMC" }; const second = workflow.classify(secondRaw, options("second")); const rows = [outcome(first), outcome(second, { inputName: "TSMC", symbol: "TPE@2330", nationCode: "TW" })]; const exact = response("load-batch", [first, second], rows); assert.ok(workflow.normalizeResponse(exact, "load-batch", [first, second])); assert.equal(workflow.normalizeResponse(exact, "stale", [first, second]), null); assert.equal(workflow.normalizeResponse({ ...exact, totalRowCount: 1 }, "load-batch", [first, second]), null); assert.equal(workflow.normalizeResponse({ ...exact, rows: [...rows].reverse() }, "load-batch", [first, second]), null); assert.equal(workflow.normalizeResponse({ ...exact, extra: true }, "load-batch", [first, second]), null); for (const [key, value] of [ ["actionId", "stock-current"], ["inputName", "TSMC"], ["nationCode", "KR"], ["foreignDomesticCode", "0"], ["builderKey", "s5001"], ["cutCode", "5001"], ["valueType", "VOLUME"], ["periodDays", 20] ]) { const tampered = [{ ...rows[0], [key]: value }, rows[1]]; assert.equal(workflow.normalizeResponse({ ...exact, rows: tampered }, "load-batch", [first, second]), null, key); } }); test("terminal per-row failures, forged capability objects and retryable errors fail closed", () => { const pending = workflow.classify(raw("캔들그래프", "120일"), options("failed")); for (const failure of [ "INVALID_ROW", "IDENTITY_NOT_FOUND", "IDENTITY_AMBIGUOUS", "DATABASE_DATA_INVALID", "RESTORE_FAILED" ]) { const normalized = workflow.normalizeResponse(response("failed-load", [pending], [{ itemIndex: pending.itemIndex, status: "failed", failure }]), "failed-load", [pending]); assert.ok(normalized); assert.equal(workflow.materialize(pending, normalized.rows[0]), null); } const normalized = workflow.normalizeResponse(response("resolved-load", [pending]), "resolved-load", [pending]); assert.equal(workflow.materialize({ ...pending }, normalized.rows[0]), null); assert.equal(workflow.materialize(pending, { ...normalized.rows[0] }), null); const error = { requestId: "error-load", code: "PLAYOUT_PRIORITY", message: "송출 우선 처리로 취소했습니다.", retryable: false }; assert.deepEqual(workflow.normalizeError(error, "error-load"), error); assert.equal(workflow.normalizeError({ ...error, retryable: true }, "error-load"), null); assert.equal(workflow.normalizeError(error, "stale"), null); }); test("verified identity, moving averages and original seven-field signature round-trip together", () => { const storedRaw = raw("캔들그래프", "240일", 3, false); const pending = workflow.classify(storedRaw, options("round-trip")); const normalized = workflow.normalizeResponse(response("round-trip-load", [pending]), "round-trip-load", [pending]); const entry = workflow.materialize(pending, normalized.rows[0]); const restored = workflow.restorePlaylistEntry(entry); assert.ok(restored); assert.deepEqual(restored.operator.movingAverages, { ma5: true, ma20: false }); assert.deepEqual(workflow.legacySelectionForEntry(restored), { groupCode: "해외종목", subject: "NVIDIA", graphicType: "캔들그래프", subtype: "240일", dataCode: "" }); assert.equal(workflow.matchesRaw(restored, storedRaw), true); assert.equal(workflow.matchesRaw(restored, { ...storedRaw, subtype: "120일" }), false); for (const tampered of [ { ...entry, code: "8040" }, { ...entry, selection: { ...entry.selection, dataCode: "TPE@2330|TW|1" } }, { ...entry, operator: { ...entry.operator, identity: { ...entry.operator.identity, symbol: "TPE@2330" } } }, { ...entry, operator: { ...entry.operator, rawSelection: { ...entry.operator.rawSelection, subtype: "120일" } } }, { ...entry, operator: { ...entry.operator, movingAverages: { ma5: false, ma20: false } } }, { ...entry, extra: true } ]) { assert.equal(workflow.restorePlaylistEntry(tampered), null); } }); test("enabled replacement preserves foreign-stock identity proof without mutating the frozen entry", () => { const storedRaw = raw("1열판기본", "", 2, true); const pending = workflow.classify(storedRaw, options("foreign-toggle")); const normalized = workflow.normalizeResponse(response("foreign-toggle-load", [pending]), "foreign-toggle-load", [pending]); const entry = workflow.materialize(pending, normalized.rows[0]); const replacement = workflow.withEnabled(entry, false); assert.notEqual(replacement, entry); assert.equal(Object.isFrozen(replacement), true); assert.equal(entry.enabled, true); assert.equal(replacement.enabled, false); assert.equal(workflow.isMaterializedEntry(replacement), true); assert.equal(workflow.matchesRaw(replacement, { ...storedRaw, enabled: false }), true); assert.deepEqual(workflow.legacySelectionForEntry(replacement), workflow.legacySelectionForEntry(entry)); assert.equal(workflow.withEnabled({ ...entry }, false), null); });