447 lines
17 KiB
JavaScript
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"
|
|
}
|
|
]);
|
|
});
|