252 lines
10 KiB
JavaScript
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);
|
|
});
|