feat: restore legacy named playlist workflows
This commit is contained in:
251
tests/Web/named-foreign-stock-restore-workflow.test.cjs
Normal file
251
tests/Web/named-foreign-stock-restore-workflow.test.cjs
Normal file
@@ -0,0 +1,251 @@
|
||||
"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);
|
||||
});
|
||||
Reference in New Issue
Block a user