Files
MBN_STOCK_WEBVIEW/tests/Web/legacy-named-row-workflow.test.cjs

447 lines
17 KiB
JavaScript

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