"use strict"; const test = require("node:test"); const assert = require("node:assert/strict"); const legacyRows = require("../../Web/legacy-named-row-workflow.js"); const namedPlaylists = require("../../Web/named-playlist-workflow.js"); const stocks = require("../../Web/operator-workflow.js"); const fixed = require("../../Web/legacy-fixed-workflow.js"); const themes = require("../../Web/theme-workflow.js"); const experts = require("../../Web/expert-workflow.js"); const comparisons = require("../../Web/comparison-workflow.js"); const industries = require("../../Web/industry-workflow.js"); function rawRow(overrides = {}) { return { itemIndex: 0, enabled: true, groupCode: "코스피", subject: "삼성전자", graphicType: "1열판기본", subtype: "현재가", pageText: "1/1", dataCode: "005930", ...overrides }; } function legacySelection(value) { const { groupCode, subject, graphicType, subtype, dataCode } = value; return { groupCode, subject, graphicType, subtype, dataCode }; } test("the original MainForm Korean stock row maps to exactly one canonical stock cut", () => { const entry = stocks.createStockPlaylistEntry({ market: "kospi", stockName: "삼성전자", displayName: "삼성전자", stockCode: "005930", isNxt: false }, "basic-current", { id: "stock-1", fadeDuration: 6 }); const raw = rawRow(); assert.equal(legacyRows.matchesCanonicalEntry(entry, raw), true); assert.deepEqual(legacyRows.legacySelectionForEntry(entry), legacySelection(raw)); assert.equal(legacyRows.selectUniqueCanonicalEntry([entry], raw), entry); for (const [field, value] of [ ["groupCode", "코스닥"], ["subject", "삼성전자우"], ["graphicType", "1열판상세"], ["subtype", "예상체결가"], ["dataCode", "000660"] ]) { assert.equal(legacyRows.matchesCanonicalEntry(entry, { ...raw, [field]: value }), false, field); } }); test("all 322 closed fixed actions reproduce their original UC1 Korean row signature", () => { const available = fixed.actions.filter(action => action.available); assert.equal(available.length, 322); const signatures = new Set(); for (const action of available) { const signature = legacyRows.fixedLegacySignature(action); assert.ok(signature, action.id); assert.equal(legacyRows.matchesFixedActionRaw(signature, action), true, action.id); const entry = fixed.createFixedPlaylistEntry(action.id, { id: `entry-${action.id}`, fadeDuration: 6, now: new Date(2026, 6, 12, 9, 0, 0) }); assert.equal(legacyRows.matchesCanonicalEntry(entry, signature), true, action.id); assert.deepEqual(legacyRows.legacySelectionForEntry(entry), signature, action.id); signatures.add(JSON.stringify(signature)); } assert.equal(signatures.size, 322, "legacy fixed rows must remain unambiguous"); const dow = fixed.actions.find(action => action.id === "fixed-001"); assert.deepEqual(legacyRows.fixedLegacySignature(dow), { groupCode: "해외지수", subject: "다우", graphicType: "1열판기본", subtype: "", dataCode: "" }); assert.equal(legacyRows.matchesFixedActionRaw({ ...legacyRows.fixedLegacySignature(dow), subtype: "현재가" }, dow), false); }); test("the original UC4 theme rows map only through the closed action and sort grammar", () => { const raw = rawRow({ groupCode: "테마", subject: "반도체", graphicType: "5단 표그래프", subtype: "테마-현재가(상승률순)", pageText: "1/4", dataCode: "0123" }); const descriptor = legacyRows.themeLegacyDescriptor(raw); assert.deepEqual(descriptor, { actionId: "five-row-current", sort: "GAIN_DESC", theme: { market: "krx", themeCode: "0123", title: "반도체", session: null, itemCount: 1 } }); const entry = themes.createThemePlaylistEntry( descriptor.actionId, descriptor.theme, descriptor.sort, { id: "theme-1", fadeDuration: 6 }); assert.equal(legacyRows.matchesCanonicalEntry(entry, raw), true); assert.deepEqual(legacyRows.legacySelectionForEntry(entry), legacySelection(raw)); const originalFallback = { ...raw, subtype: "테마-현재가" }; const fallbackDescriptor = legacyRows.themeLegacyDescriptor(originalFallback); assert.equal(fallbackDescriptor.sort, "GAIN_ASC"); const fallbackEntry = themes.createThemePlaylistEntry( fallbackDescriptor.actionId, fallbackDescriptor.theme, fallbackDescriptor.sort, { id: "theme-fallback", fadeDuration: 6 }); assert.equal(legacyRows.matchesCanonicalEntry(fallbackEntry, originalFallback), true); assert.equal( legacyRows.legacySelectionForEntry(fallbackEntry).subtype, "테마-현재가(하락률순)", "new saves make the original implicit fallback explicit"); assert.equal(legacyRows.themeLegacyDescriptor({ ...raw, subtype: "테마-현재가(상승률)" }), null); const nxtRaw = { ...raw, groupCode: "테마_NXT", subject: "반도체(NXT)", subtype: "테마-현재가(입력순)" }; const morning = legacyRows.themeLegacyDescriptor(nxtRaw, { now: new Date(2026, 6, 12, 12, 59, 59) }); const afternoon = legacyRows.themeLegacyDescriptor(nxtRaw, { now: new Date(2026, 6, 12, 13, 0, 0) }); assert.equal(morning.theme.session, "PRE_MARKET"); assert.equal(afternoon.theme.session, "AFTER_MARKET"); const nxtEntry = themes.createThemePlaylistEntry( morning.actionId, morning.theme, morning.sort, { id: "theme-nxt-legacy", fadeDuration: 6 }); assert.equal(legacyRows.matchesCanonicalEntry(nxtEntry, nxtRaw, { now: new Date(2026, 6, 12, 12, 30, 0) }), true); assert.equal(legacyRows.matchesCanonicalEntry(nxtEntry, nxtRaw, { now: new Date(2026, 6, 12, 13, 0, 0) }), false); assert.equal(legacyRows.legacySelectionForEntry(nxtEntry), null, "new named saves remain blocked because the old row does not persist a session"); assert.equal(legacyRows.matchesCanonicalEntry(entry, { ...raw, dataCode: "87654321" }), false); }); test("the original UC6 expert row maps to the canonical paged expert entry", () => { const raw = rawRow({ groupCode: "전문가 추천", subject: "김전문", graphicType: "5단 표그래프", subtype: "현재가", pageText: "1/2", dataCode: "0123" }); const descriptor = legacyRows.expertLegacyDescriptor(raw); assert.deepEqual(descriptor, { expertCode: "0123", expertName: "김전문", source: "oracle", itemCount: 1, previewTruncated: false }); const entry = experts.createExpertPlaylistEntry(descriptor, { id: "expert-1", fadeDuration: 6 }); assert.equal(legacyRows.matchesCanonicalEntry(entry, raw), true); assert.deepEqual(legacyRows.legacySelectionForEntry(entry), legacySelection(raw)); assert.equal(legacyRows.expertLegacyDescriptor({ ...raw, dataCode: "123" }), null); assert.equal(legacyRows.matchesCanonicalEntry(entry, { ...raw, subject: "다른전문가" }), false); }); test("comparison rows project only identities that the original seven fields preserve", () => { const fixedEntry = comparisons.createComparisonPlaylistEntry("two-column-current", [ comparisons.getMarketTarget("Kospi"), comparisons.getMarketTarget("Kosdaq") ], { id: "comparison-fixed", fadeDuration: 6 }); const fixedRaw = rawRow({ groupCode: "지수", subject: "코스피 지수,코스닥 지수", graphicType: "2열판", subtype: "", dataCode: "" }); assert.deepEqual(legacyRows.comparisonLegacySignature(fixedEntry.operator), legacySelection(fixedRaw)); assert.equal(legacyRows.matchesCanonicalEntry(fixedEntry, fixedRaw), true); assert.deepEqual(legacyRows.legacySelectionForEntry(fixedEntry), legacySelection(fixedRaw)); const exchangeOnly = comparisons.createComparisonPlaylistEntry("two-column-current", [ comparisons.getMarketTarget("WonDollar"), comparisons.getMarketTarget("WonYen") ], { id: "comparison-exchange-lossy", fadeDuration: 6 }); assert.equal(legacyRows.legacySelectionForEntry(exchangeOnly), null); const pair = [ { kind: "krx-stock", market: "kospi", stockName: "삼성전자", stockCode: "005930" }, { kind: "krx-stock", market: "kosdaq", stockName: "에이비엘바이오", stockCode: "298380" } ]; const candle = comparisons.createComparisonPlaylistEntry( "comparison-candle", pair, { id: "comparison-candle", fadeDuration: 6 }); const candleRaw = rawRow({ groupCode: "코스피,코스닥", subject: "삼성전자,에이비엘바이오", graphicType: "종목별 비교분석_캔들 그래프", subtype: "", dataCode: "" }); assert.equal(legacyRows.matchesCanonicalEntry(candle, candleRaw), true); assert.deepEqual(legacyRows.legacySelectionForEntry(candle), legacySelection(candleRaw)); const oneMonth = comparisons.createComparisonPlaylistEntry( "return-1m", pair, { id: "comparison-return", fadeDuration: 6 }); assert.deepEqual(legacyRows.legacySelectionForEntry(oneMonth), { groupCode: "코스피,코스닥", subject: "삼성전자,에이비엘바이오", graphicType: "종목별 수익률 비교", subtype: "1개월", dataCode: "" }); const lossyStockPlate = comparisons.createComparisonPlaylistEntry( "two-column-current", pair, { id: "comparison-lossy", fadeDuration: 6 }); assert.equal(legacyRows.legacySelectionForEntry(lossyStockPlate), null); assert.equal(legacyRows.matchesCanonicalEntry(lossyStockPlate, { ...fixedRaw, groupCode: "종목", subject: "삼성전자,에이비엘바이오" }), false); }); test("identity-free industry market rows map only to the three closed fixed actions", () => { const now = new Date(2026, 6, 12, 9, 0, 0); const cases = [ ["kospi", "five-row", "업종_코스피", "코스피", "5단 표그래프"], ["kospi", "square", "업종_코스피", "코스피", "네모그래프"], ["kospi", "sector", "업종_코스피", "코스피", "섹터지수"], ["kosdaq", "five-row", "업종_코스닥", "코스닥", "5단 표그래프"], ["kosdaq", "square", "업종_코스닥", "코스닥", "네모그래프"], ["kosdaq", "sector", "업종_코스닥", "코스닥", "섹터지수"] ]; for (let index = 0; index < cases.length; index += 1) { const [market, cutId, groupCode, subject, graphicType] = cases[index]; const raw = rawRow({ groupCode, subject, graphicType, subtype: "", dataCode: "" }); const descriptor = legacyRows.industryFixedDescriptor(raw); assert.deepEqual(descriptor, { market, cutId }); const entry = industries.createIndustryPlaylistEntry(market, cutId, { id: `industry-fixed-${index}`, fadeDuration: 6, now }); assert.deepEqual(legacyRows.industryFixedLegacySignature(entry.operator), legacySelection(raw)); assert.equal(legacyRows.matchesCanonicalEntry(entry, raw), true); assert.deepEqual(legacyRows.legacySelectionForEntry(entry), legacySelection(raw)); if (cutId === "five-row") { assert.equal(entry.selection.dataCode, "2026-07-12"); assert.equal(entry.builderKey, "s5074"); } } const selected = { market: "kospi", name: "전기전자", code: "013" }; const individual = industries.createIndustryPlaylistEntry("kospi", "one-column", { id: "industry-identity-required", selected }); assert.equal(legacyRows.industryFixedLegacySignature(individual.operator), null); assert.equal(legacyRows.industryFixedDescriptor({ ...rawRow(), groupCode: "업종_코스피", subject: "전기전자", graphicType: "1열판", subtype: "", dataCode: "" }), null); }); test("named restoration accepts a closed legacy mapping but rejects it by default or after tamper", () => { const raw = rawRow(); const load = namedPlaylists.normalizeLoadResponse({ requestId: "load-legacy-1", definition: { programCode: "00000001", title: "Legacy" }, retrievedAt: "2026-07-12T09:00:00+09:00", totalRowCount: 1, canMutate: true, writeQuarantined: false, rows: [raw] }); const entry = stocks.createStockPlaylistEntry({ market: "kospi", stockName: "삼성전자", displayName: "삼성전자", stockCode: "005930", isNxt: false }, "basic-current", { id: "stock-restore-1", fadeDuration: 6 }); assert.equal(namedPlaylists.restoreRawRows(load, () => entry).rows[0].trusted, false); assert.equal(namedPlaylists.restoreRawRows( load, () => entry, legacyRows.matchesCanonicalEntry).rows[0].trusted, true); const tamperedLoad = namedPlaylists.normalizeLoadResponse({ ...{ requestId: "load-legacy-2", definition: { programCode: "00000001", title: "Legacy" }, retrievedAt: "2026-07-12T09:00:00+09:00", totalRowCount: 1, canMutate: true, writeQuarantined: false }, rows: [{ ...raw, dataCode: "000660" }] }); assert.equal(namedPlaylists.restoreRawRows( tamperedLoad, () => entry, legacyRows.matchesCanonicalEntry).rows[0].trusted, false); assert.throws(() => namedPlaylists.restoreRawRows(load, () => entry, true), /resolver/i); }); test("canonical rows that identify multiple fixed actions remain blocked as ambiguous", () => { const first = fixed.createFixedPlaylistEntry("fixed-136", { id: "candle-1", fadeDuration: 6, now: new Date(2026, 6, 12, 9, 0, 0) }); const second = fixed.createFixedPlaylistEntry("fixed-137", { id: "candle-2", fadeDuration: 6, now: new Date(2026, 6, 12, 9, 0, 0) }); assert.deepEqual(first.selection, second.selection); const raw = legacySelection(first.selection); assert.equal(legacyRows.matchesCanonicalEntry(first, raw), true); assert.equal(legacyRows.matchesCanonicalEntry(second, raw), true); assert.equal(legacyRows.selectUniqueCanonicalEntry([first, second], raw), null); }); test("save projection is closed and refuses unknown or lossy NXT theme entries", () => { assert.equal(legacyRows.legacySelectionForEntry({ selection: { groupCode: "FREE", subject: "text", graphicType: "type", subtype: "sub", dataCode: "code" }, operator: { source: "unknown" } }), null); const fixedEntry = fixed.createFixedPlaylistEntry("fixed-001", { id: "tampered-fixed-save", fadeDuration: 6, now: new Date(2026, 6, 12, 9, 0, 0) }); assert.equal(legacyRows.legacySelectionForEntry({ ...fixedEntry, operator: { ...fixedEntry.operator, actionId: "fixed-002" } }), null); const nxt = themes.createThemePlaylistEntry("five-row-current", { market: "nxt", themeCode: "0123", title: "NXT 테마", session: "PRE_MARKET", itemCount: 5 }, "INPUT_ORDER", { id: "nxt-theme-save", fadeDuration: 6 }); assert.equal(legacyRows.legacySelectionForEntry(nxt), null); assert.equal(legacyRows.legacySelectionForEntry({ ...nxt, selection: { ...nxt.selection, subject: "bad^value" } }), null); }); test("named replace serializes stock, fixed, theme and expert as original Korean rows", () => { const entries = [ stocks.createStockPlaylistEntry({ market: "kospi", stockName: "삼성전자", displayName: "삼성전자", stockCode: "005930", isNxt: false }, "basic-current", { id: "save-stock", fadeDuration: 6 }), fixed.createFixedPlaylistEntry("fixed-001", { id: "save-fixed", fadeDuration: 6, now: new Date(2026, 6, 12, 9, 0, 0) }), themes.createThemePlaylistEntry("five-row-current", { market: "krx", themeCode: "0123", title: "반도체", session: null, itemCount: 5 }, "GAIN_DESC", { id: "save-theme", fadeDuration: 6 }), experts.createExpertPlaylistEntry({ expertCode: "0123", expertName: "김전문", source: "oracle", itemCount: 5, previewTruncated: false }, { id: "save-expert", fadeDuration: 6 }) ]; const request = namedPlaylists.createReplaceRequest( "replace-legacy-projection", "00000001", entries, { isTrustedEntry: entry => legacyRows.legacySelectionForEntry(entry) !== null, fieldsForEntry(entry, _index, pageText) { const selection = legacyRows.legacySelectionForEntry(entry); return [ entry.enabled ? "1" : "0", selection.groupCode, selection.subject, selection.graphicType, selection.subtype, pageText, selection.dataCode ]; } }); assert.deepEqual(request.items, [ { itemIndex: 0, enabled: true, groupCode: "코스피", subject: "삼성전자", graphicType: "1열판기본", subtype: "현재가", pageText: "1/1", dataCode: "005930" }, { itemIndex: 1, enabled: true, groupCode: "해외지수", subject: "다우", graphicType: "1열판기본", subtype: "", pageText: "1/1", dataCode: "" }, { itemIndex: 2, enabled: true, groupCode: "테마", subject: "반도체", graphicType: "5단 표그래프", subtype: "테마-현재가(상승률순)", pageText: "1/1", dataCode: "0123" }, { itemIndex: 3, enabled: true, groupCode: "전문가 추천", subject: "김전문", graphicType: "5단 표그래프", subtype: "현재가", pageText: "1/1", dataCode: "0123" } ]); });