Files
MBN_STOCK_WEBVIEW/tests/Web/named-foreign-stock-restore-workflow.test.cjs

252 lines
10 KiB
JavaScript

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