feat: restore legacy named playlist workflows
This commit is contained in:
@@ -7,6 +7,7 @@ const stock = require("../../Web/operator-workflow.js");
|
||||
const fixed = require("../../Web/legacy-fixed-workflow.js");
|
||||
const industry = require("../../Web/industry-workflow.js");
|
||||
const overseas = require("../../Web/overseas-workflow.js");
|
||||
const namedForeignStock = require("../../Web/named-foreign-stock-restore-workflow.js");
|
||||
const tradingHalt = require("../../Web/trading-halt-workflow.js");
|
||||
|
||||
const adapters = { stock, fixed, industry, overseas, tradingHalt };
|
||||
@@ -35,6 +36,37 @@ const haltedStock = Object.freeze({
|
||||
function entries() {
|
||||
const fixedAction = fixed.actions.find(value => value.builderKey === "s8010" && value.available);
|
||||
const industryCut = industry.cuts.find(value => value.kind === "candle");
|
||||
const pendingNamedForeign = namedForeignStock.classify({
|
||||
itemIndex: 0,
|
||||
enabled: false,
|
||||
groupCode: "해외종목",
|
||||
subject: "NVIDIA",
|
||||
graphicType: "캔들그래프",
|
||||
subtype: "120일",
|
||||
pageText: "1/1",
|
||||
dataCode: ""
|
||||
}, options("named-overseas-candle"));
|
||||
const namedResponse = namedForeignStock.normalizeResponse({
|
||||
requestId: "named-overseas-load",
|
||||
retrievedAt: "2026-07-12T12:34:56+09:00",
|
||||
totalRowCount: 1,
|
||||
rows: [{
|
||||
itemIndex: 0,
|
||||
status: "resolved",
|
||||
actionId: "stock-candle-120d",
|
||||
inputName: "NVIDIA",
|
||||
symbol: "NAS@NVDA",
|
||||
nationCode: "US",
|
||||
foreignDomesticCode: "1",
|
||||
builderKey: "s8010",
|
||||
cutCode: "8051",
|
||||
valueType: "PRICE",
|
||||
periodDays: 120
|
||||
}]
|
||||
}, "named-overseas-load", [pendingNamedForeign]);
|
||||
const namedForeignEntry = namedForeignStock.materialize(
|
||||
pendingNamedForeign,
|
||||
namedResponse.rows[0]);
|
||||
return [
|
||||
stock.createStockPlaylistEntry(domesticStock, "candle-120d", options("stock-candle")),
|
||||
fixed.createFixedPlaylistEntry(fixedAction.id, options("fixed-candle")),
|
||||
@@ -46,6 +78,7 @@ function entries() {
|
||||
overseasStock,
|
||||
"stock-candle-120d",
|
||||
options("overseas-candle")),
|
||||
namedForeignEntry,
|
||||
tradingHalt.createTradingHaltPlaylistEntry(
|
||||
haltedStock,
|
||||
"candle-120d",
|
||||
@@ -61,13 +94,35 @@ test("global flags map to the exact closed candle graphic type", () => {
|
||||
assert.throws(() => workflow.expectedGraphicType("yes", true));
|
||||
});
|
||||
|
||||
test("all five original candle entry sources can be recreated with global flags", () => {
|
||||
test("all original and DB-restored candle entry sources can be recreated with global flags", () => {
|
||||
for (const entry of entries()) {
|
||||
const recreated = workflow.recreateCandleEntry(entry, true, true, adapters);
|
||||
assert.ok(recreated, entry.operator.source);
|
||||
assert.equal(recreated.selection.graphicType, "MA5,MA20");
|
||||
assert.deepEqual(recreated.operator.movingAverages, { ma5: true, ma20: true });
|
||||
assert.equal(recreated.enabled, entry.enabled);
|
||||
if (entry.operator.source === namedForeignStock.source) {
|
||||
assert.equal(recreated.operator.source, namedForeignStock.source);
|
||||
assert.deepEqual(recreated.operator.rawSelection, entry.operator.rawSelection);
|
||||
assert.ok(namedForeignStock.restorePlaylistEntry(recreated));
|
||||
assert.deepEqual(namedForeignStock.legacySelectionForEntry(recreated), {
|
||||
groupCode: "해외종목",
|
||||
subject: "NVIDIA",
|
||||
graphicType: "캔들그래프",
|
||||
subtype: "120일",
|
||||
dataCode: ""
|
||||
});
|
||||
assert.equal(namedForeignStock.matchesRaw(recreated, {
|
||||
itemIndex: 0,
|
||||
enabled: false,
|
||||
groupCode: "해외종목",
|
||||
subject: "NVIDIA",
|
||||
graphicType: "캔들그래프",
|
||||
subtype: "120일",
|
||||
pageText: "1/1",
|
||||
dataCode: ""
|
||||
}), true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -209,6 +209,25 @@ test("legacy signature round-trips only the exact verified entry", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("enabled replacement preserves the branded foreign-index proof immutably", () => {
|
||||
const storedRaw = raw("S&P500", "20일", 6, true);
|
||||
const pending = workflow.classify(storedRaw, {
|
||||
id: "foreign_toggle", fadeDuration: 6
|
||||
});
|
||||
const normalized = workflow.normalizeResponse(response("load_toggle", [pending]),
|
||||
"load_toggle", [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.equal(workflow.withEnabled({ ...entry }, false), null);
|
||||
});
|
||||
|
||||
test("bridge errors are request-bound and never retryable", () => {
|
||||
const error = {
|
||||
requestId: "load_error",
|
||||
|
||||
@@ -605,6 +605,24 @@ test("playlist entry has the complete standard operator metadata and native trus
|
||||
}, { id: "forged" }), /native-confirmed/);
|
||||
});
|
||||
|
||||
test("enabled replacement keeps native financial trust without mutating the frozen entry", () => {
|
||||
const snapshot = trustedSnapshot("sales");
|
||||
const stock = verifiedStock(snapshot.record);
|
||||
const selection = trustedSelection(snapshot, stock);
|
||||
const entry = workflow.createPlaylistEntry(snapshot, stock, selection, {
|
||||
id: "sales-toggle",
|
||||
fadeDuration: 6
|
||||
});
|
||||
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.isPlaylistEntryPlayable(replacement), true);
|
||||
assert.equal(workflow.withEnabled({ ...entry }, false), null);
|
||||
});
|
||||
|
||||
test("stored entries restore only as non-playable refresh-required descriptors", () => {
|
||||
const snapshot = trustedSnapshot("sales");
|
||||
const stock = verifiedStock(snapshot.record);
|
||||
|
||||
@@ -361,6 +361,29 @@ test("only entries bound to a correlated fresh read are playable", () => {
|
||||
viRead)), false);
|
||||
});
|
||||
|
||||
test("enabled replacement transfers only the correlated manual-list read capability", () => {
|
||||
const netRead = workflow.normalizeNetSellDataResponse({
|
||||
requestId,
|
||||
audience: "FOREIGN",
|
||||
rows: rows()
|
||||
}, { requestId, audience: "FOREIGN" });
|
||||
const entry = workflow.createNetSellPlaylistEntry("FOREIGN", {
|
||||
id: "entry_net_toggle",
|
||||
fadeDuration: 6
|
||||
}, netRead);
|
||||
const replacement = workflow.withEnabled(entry, false);
|
||||
|
||||
assert.notEqual(replacement, entry);
|
||||
assert.equal(entry.enabled, true);
|
||||
assert.equal(replacement.enabled, false);
|
||||
assert.equal(workflow.isPlaylistEntryPlayable(replacement), true);
|
||||
assert.equal(workflow.withEnabled({ ...entry }, false), null);
|
||||
assert.equal(workflow.withEnabled(workflow.createNetSellPlaylistEntry("FOREIGN", {
|
||||
id: "entry_net_toggle_untrusted",
|
||||
fadeDuration: 6
|
||||
}), false), null);
|
||||
});
|
||||
|
||||
test("request identifiers and option bounds are strict", () => {
|
||||
assert.throws(() => workflow.createStatusRequest("bad id"));
|
||||
assert.throws(() => workflow.createNetSellPlaylistEntry("FOREIGN", {
|
||||
|
||||
136
tests/Web/named-foreign-stock-restore-app-integration.test.cjs
Normal file
136
tests/Web/named-foreign-stock-restore-app-integration.test.cjs
Normal file
@@ -0,0 +1,136 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const foreignStock = require("../../Web/named-foreign-stock-restore-workflow.js");
|
||||
const legacyRows = require("../../Web/legacy-named-row-workflow.js");
|
||||
const namedPlaylists = require("../../Web/named-playlist-workflow.js");
|
||||
|
||||
const root = path.resolve(__dirname, "../..");
|
||||
const app = fs.readFileSync(path.join(root, "Web/app.js"), "utf8");
|
||||
const index = fs.readFileSync(path.join(root, "Web/index.html"), "utf8");
|
||||
const bridge = fs.readFileSync(
|
||||
path.join(root, "MainWindow.NamedForeignStockRestore.cs"), "utf8");
|
||||
const namedBridge = fs.readFileSync(path.join(root, "MainWindow.NamedPlaylists.cs"), "utf8");
|
||||
const core = fs.readFileSync(
|
||||
path.join(root, "src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedForeignStockRestore.cs"),
|
||||
"utf8");
|
||||
|
||||
function rawRow(enabled = false) {
|
||||
return {
|
||||
itemIndex: 0,
|
||||
enabled,
|
||||
groupCode: "해외종목",
|
||||
subject: "NVIDIA",
|
||||
graphicType: "캔들그래프",
|
||||
subtype: "240일",
|
||||
pageText: "1/1",
|
||||
dataCode: ""
|
||||
};
|
||||
}
|
||||
|
||||
function materializedEntry() {
|
||||
const raw = rawRow();
|
||||
const pending = foreignStock.classify(raw, {
|
||||
id: "db-foreign-stock-240",
|
||||
fadeDuration: 6,
|
||||
ma5: true,
|
||||
ma20: false
|
||||
});
|
||||
const response = foreignStock.normalizeResponse({
|
||||
requestId: "foreign-stock-load",
|
||||
retrievedAt: "2026-07-12T12:34:56+09:00",
|
||||
totalRowCount: 1,
|
||||
rows: [{
|
||||
itemIndex: 0,
|
||||
status: "resolved",
|
||||
actionId: "stock-candle-240d",
|
||||
inputName: "NVIDIA",
|
||||
symbol: "NAS@NVDA",
|
||||
nationCode: "US",
|
||||
foreignDomesticCode: "1",
|
||||
builderKey: "s8010",
|
||||
cutCode: "8056",
|
||||
valueType: "PRICE",
|
||||
periodDays: 240
|
||||
}]
|
||||
}, "foreign-stock-load", [pending]);
|
||||
return foreignStock.materialize(pending, response.rows[0]);
|
||||
}
|
||||
|
||||
test("named load connects one correlated native foreign-stock restore batch end to end", () => {
|
||||
assert.match(index, /<script src="named-foreign-stock-restore-workflow\.js"><\/script>[\s\S]*<script src="legacy-named-row-workflow\.js"><\/script>/u);
|
||||
assert.match(app, /MbnNamedForeignStockRestoreWorkflow/u);
|
||||
assert.match(app, /namedForeignStockRestoreWorkflow\.classify/u);
|
||||
assert.match(app, /namedForeignStockRestoreWorkflow\.normalizeResponse/u);
|
||||
assert.match(app, /namedForeignStockRestoreWorkflow\.materialize/u);
|
||||
assert.match(app, /case "named-foreign-stock-restore-results"/u);
|
||||
assert.match(app, /case "named-foreign-stock-restore-error"/u);
|
||||
assert.match(namedBridge, /await PostNamedForeignStockRestoreAsync\(/u);
|
||||
assert.match(bridge, /PostMessage\("named-foreign-stock-restore-results"/u);
|
||||
assert.match(bridge, /PostMessage\("named-foreign-stock-restore-error"/u);
|
||||
});
|
||||
|
||||
test("Core uses one exact bound SELECT and native bridge never writes or calls playout", () => {
|
||||
assert.match(core, /F_INPUT_NAME = :input_name/u);
|
||||
assert.match(core, /F_FDTC = '1'/u);
|
||||
assert.match(core, /F_NATC IN \('US', 'TW'\)/u);
|
||||
assert.match(core, /WHERE ROWNUM <= 2/u);
|
||||
assert.match(core, /new DataQueryParameter\("input_name"/u);
|
||||
assert.doesNotMatch(core, /\bLIKE\b/iu);
|
||||
assert.doesNotMatch(core, /\b(?:INSERT\s+INTO|UPDATE\s+[A-Z_]|DELETE\s+FROM|MERGE\s+INTO)\b/iu);
|
||||
assert.doesNotMatch(bridge, /Tornado|TakeIn|PrepareAsync|ExecuteNonQuery/iu);
|
||||
assert.match(bridge, /retryable = false/u);
|
||||
assert.match(bridge, /자동 재시도하지 않습니다/u);
|
||||
});
|
||||
|
||||
test("pending state blocks edits and timeout is terminal without an automatic retry", () => {
|
||||
assert.match(app, /state\.namedPlaylist\.foreignStockRestorePending/u);
|
||||
assert.match(app, /armNamedForeignStockRestoreTimeout/u);
|
||||
assert.match(app, /setTimeout\(\(\) => \{[\s\S]*자동 재시도하지 않습니다\.[\s\S]*\}, 30000\)/u);
|
||||
assert.match(app, /comparisonRestorePending \|\|[\s\S]*foreignStockRestorePending \|\|[\s\S]*foreignIndexCandleRestorePending/u);
|
||||
assert.match(app, /pendingForeignStocks\.length === 0/u);
|
||||
assert.match(app, /clearNamedForeignStockRestoreTimeout\(record\)/u);
|
||||
assert.doesNotMatch(app, /retryNamedForeignStock|requestNamedForeignStockRestore/u);
|
||||
});
|
||||
|
||||
test("verified identity and moving averages preserve the exact original seven fields for save", () => {
|
||||
const entry = materializedEntry();
|
||||
const raw = rawRow();
|
||||
assert.ok(entry);
|
||||
assert.equal(legacyRows.matchesCanonicalEntry(entry, raw), true);
|
||||
const selection = legacyRows.legacySelectionForEntry(entry);
|
||||
assert.deepEqual(selection, {
|
||||
groupCode: "해외종목",
|
||||
subject: "NVIDIA",
|
||||
graphicType: "캔들그래프",
|
||||
subtype: "240일",
|
||||
dataCode: ""
|
||||
});
|
||||
assert.deepEqual(entry.operator.movingAverages, { ma5: true, ma20: false });
|
||||
assert.deepEqual(namedPlaylists.toLegacySevenFields({ ...entry, selection }, "1/1"), [
|
||||
"0", "해외종목", "NVIDIA", "캔들그래프", "240일", "1/1", ""
|
||||
]);
|
||||
assert.match(app, /해외종목 항목의 원본 7필드 서명을 안전하게 왕복할 수 없습니다/u);
|
||||
assert.match(app, /namedForeignStockRestoreWorkflow\.matchesRaw\(restoredForeignStock, rawRow\)/u);
|
||||
assert.match(app, /item\.operator\?\.source === namedForeignStockRestoreWorkflow\.source\) return item/u);
|
||||
});
|
||||
|
||||
test("canonical FOREIGN_STOCK remains on the existing workflow and cannot collide", () => {
|
||||
assert.equal(foreignStock.classify({
|
||||
...rawRow(true),
|
||||
groupCode: "FOREIGN_STOCK",
|
||||
graphicType: "MA5",
|
||||
subtype: "PRICE",
|
||||
dataCode: "NAS@NVDA|US|1"
|
||||
}, {
|
||||
id: "canonical-does-not-route",
|
||||
fadeDuration: 6,
|
||||
ma5: true,
|
||||
ma20: false
|
||||
}), null);
|
||||
assert.match(app, /addOverseasNamedCandidates/u);
|
||||
assert.match(app, /rawRow\.groupCode !== "FOREIGN_STOCK"/u);
|
||||
});
|
||||
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);
|
||||
});
|
||||
83
tests/Web/named-krx-theme-restore-app-integration.test.cjs
Normal file
83
tests/Web/named-krx-theme-restore-app-integration.test.cjs
Normal file
@@ -0,0 +1,83 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = path.resolve(__dirname, "..", "..");
|
||||
const app = fs.readFileSync(path.join(root, "Web", "app.js"), "utf8");
|
||||
const index = fs.readFileSync(path.join(root, "Web", "index.html"), "utf8");
|
||||
const native = fs.readFileSync(path.join(root, "MainWindow.NamedKrxThemeRestore.cs"), "utf8");
|
||||
const namedBridge = fs.readFileSync(path.join(root, "MainWindow.NamedPlaylists.cs"), "utf8");
|
||||
|
||||
function functionBody(name, nextName) {
|
||||
const start = app.indexOf(`function ${name}(`);
|
||||
const end = app.indexOf(`function ${nextName}(`, start + 1);
|
||||
assert.ok(start >= 0 && end > start, `${name} must precede ${nextName}`);
|
||||
return app.slice(start, end);
|
||||
}
|
||||
|
||||
test("named KRX restore is correlated from one native load through page preflight", () => {
|
||||
assert.match(index,
|
||||
/src="named-krx-theme-restore-workflow\.js"[\s\S]*src="named-nxt-theme-restore-workflow\.js"/u);
|
||||
assert.match(namedBridge,
|
||||
/await PostNamedKrxThemeRestoreAsync\([\s\S]*await PostNamedNxtThemeRestoreAsync\(/u);
|
||||
assert.match(native, /PostMessage\("named-krx-theme-restore-results"/u);
|
||||
assert.match(app, /krxThemeRestorePending/u);
|
||||
assert.match(app, /case "named-krx-theme-restore-results"/u);
|
||||
assert.match(app, /case "named-krx-theme-restore-error"/u);
|
||||
const finalize = functionBody("finalizeNamedManualRestores", "handleNamedManualListResults");
|
||||
assert.match(finalize, /state\.namedPlaylist\.krxThemeRestorePending/u);
|
||||
assert.match(finalize, /prepareNamedPagePreflight/u);
|
||||
});
|
||||
|
||||
test("generic theme restore cannot trust a historical KRX code before native verification", () => {
|
||||
const addTheme = functionBody("addThemeNamedCandidates", "addOverseasNamedCandidates");
|
||||
assert.match(addTheme,
|
||||
/rawRow\.groupCode === "테마" \|\| rawRow\.groupCode === "테마_NXT"/u);
|
||||
const load = functionBody("handleNamedPlaylistLoadResults", "failNamedComparisonRestore");
|
||||
assert.match(load, /namedKrxThemeRestoreWorkflow\.classify/u);
|
||||
assert.match(load, /pendingKrxThemes\.length === 0/u);
|
||||
assert.match(load, /krxThemeRestorePending = pendingKrxThemes\.length/u);
|
||||
const resolve = functionBody("resolveNamedPlaylistRawRow", "prepareNamedPagePreflight");
|
||||
assert.match(resolve,
|
||||
/rawRow\?\.groupCode === "테마" \|\| rawRow\?\.groupCode === "테마_NXT"[\s\S]*return null/u);
|
||||
});
|
||||
|
||||
test("current KRX identity stays branded in memory and old code is retained only for save", () => {
|
||||
const materialize = functionBody("materializeNamedRestoration", "namedPlaylistPrepareBlockers");
|
||||
assert.match(materialize,
|
||||
/namedKrxThemeRestoreWorkflow\.isMaterializedEntry\(row\.entry\)/u);
|
||||
const synchronize = functionBody("synchronizeThemeEntries", "clearOverseasTimeout");
|
||||
assert.match(synchronize,
|
||||
/namedKrxThemeRestoreWorkflow\.isMaterializedEntry\(item\)[\s\S]*\? item/u);
|
||||
const fields = functionBody("namedLegacyFieldsForEntry", "isTrustedNamedPlaylistEntry");
|
||||
assert.match(fields, /namedKrxThemeRestoreWorkflow\.legacySelectionForEntry\(entry\)/u);
|
||||
assert.match(fields, /namedKrxThemeRestoreWorkflow\.matchesRaw\(entry, rawRow\)/u);
|
||||
assert.match(fields, /KRX 테마의 과거 code와 현재 송출 code/u);
|
||||
});
|
||||
|
||||
test("KRX failures are terminal and never trigger an automatic retry or playout", () => {
|
||||
const failed = functionBody("failNamedKrxThemeRestore", "handleNamedKrxThemeRestoreResults");
|
||||
assert.match(failed, /krxThemeRestorePending = null/u);
|
||||
assert.doesNotMatch(app, /retryNamedKrxTheme|requestNamedKrxThemeRestore/u);
|
||||
assert.doesNotMatch(native, /IPlayoutEngine|PrepareAsync|TakeInAsync|NextAsync|TakeOutAsync/u);
|
||||
assert.match(native, /retryable = false/u);
|
||||
});
|
||||
|
||||
test("KRX success accounting and post-load cancellation close every correlated busy record", () => {
|
||||
const results = functionBody("handleNamedKrxThemeRestoreResults", "handleNamedKrxThemeRestoreError");
|
||||
assert.match(results,
|
||||
/state\.playlist\[pending\.itemIndex\] = entry;\s*restoredCount \+= 1;/u);
|
||||
|
||||
const clear = functionBody("clearNamedPlaylistLoadState", "saveNamedPlaylistBackup");
|
||||
assert.match(clear, /krxThemeRestorePending = null/u);
|
||||
const busy = functionBody("namedPlaylistBusy", "renderNamedPlaylist");
|
||||
assert.match(busy, /state\.namedPlaylist\.krxThemeRestorePending/u);
|
||||
const loadError = functionBody("handleNamedPlaylistError", "normalizePlayoutState");
|
||||
assert.match(loadError, /error\.operation !== "load"/u);
|
||||
assert.match(loadError,
|
||||
/state\.namedPlaylist\.krxThemeRestorePending\?\.requestId === error\.requestId[\s\S]*krxThemeRestorePending = null/u);
|
||||
assert.match(loadError, /복원 배치는 자동 재시도하지 않습니다/u);
|
||||
});
|
||||
249
tests/Web/named-krx-theme-restore-workflow.test.cjs
Normal file
249
tests/Web/named-krx-theme-restore-workflow.test.cjs
Normal file
@@ -0,0 +1,249 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const workflow = require("../../Web/named-krx-theme-restore-workflow.js");
|
||||
|
||||
function raw(overrides = {}) {
|
||||
return {
|
||||
itemIndex: 4,
|
||||
enabled: true,
|
||||
groupCode: "테마",
|
||||
subject: "반도체",
|
||||
graphicType: "5단 표그래프",
|
||||
subtype: "테마-현재가(입력순)",
|
||||
pageText: "1/9",
|
||||
dataCode: "0123",
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function resolved(overrides = {}) {
|
||||
return {
|
||||
itemIndex: 4,
|
||||
status: "resolved",
|
||||
actionId: "five-row-current",
|
||||
builderKey: "s5074",
|
||||
cutCode: "5074",
|
||||
pageSize: 5,
|
||||
sort: "INPUT_ORDER",
|
||||
themeTitle: "반도체",
|
||||
storedThemeCode: "0123",
|
||||
currentThemeCode: "87654321",
|
||||
previewItemCount: 7,
|
||||
previewIsTruncated: false,
|
||||
codeWasRemapped: true,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function response(row) {
|
||||
return {
|
||||
requestId: "load-1",
|
||||
retrievedAt: "2026-07-12T12:59:00+09:00",
|
||||
totalRowCount: 1,
|
||||
rows: [row]
|
||||
};
|
||||
}
|
||||
|
||||
function normalize(sourceRaw = raw(), outcome = resolved()) {
|
||||
const pending = workflow.classify(sourceRaw, { id: "db-4", fadeDuration: 6 });
|
||||
const normalized = workflow.normalizeResponse(response(outcome), "load-1", [pending]);
|
||||
return { pending, normalized, outcome: normalized?.rows[0] ?? null };
|
||||
}
|
||||
|
||||
test("classify routes every exact KRX theme group row for native fail-closed verification", () => {
|
||||
const pending = workflow.classify(raw({
|
||||
subject: "",
|
||||
graphicType: "unsupported",
|
||||
subtype: "",
|
||||
pageText: "",
|
||||
dataCode: ""
|
||||
}), { id: "db-4", fadeDuration: 6 });
|
||||
assert.equal(workflow.isPending(pending), true);
|
||||
assert.equal(pending.rawSelection.groupCode, "테마");
|
||||
assert.equal(workflow.classify(raw({ groupCode: "테마_NXT" }), {
|
||||
id: "db-4",
|
||||
fadeDuration: 6
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("normalizeResponse accepts one exact correlated current identity", () => {
|
||||
const { normalized } = normalize();
|
||||
assert.equal(normalized.rows[0].currentThemeCode, "87654321");
|
||||
assert.equal(normalized.rows[0].previewItemCount, 7);
|
||||
assert.deepEqual(workflow.bridgeContract, {
|
||||
resultType: "named-krx-theme-restore-results",
|
||||
errorType: "named-krx-theme-restore-error"
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizeResponse enforces exact wire schema, raw action correlation and preview bounds", () => {
|
||||
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
|
||||
for (const invalid of [
|
||||
{ ...resolved(), extra: true },
|
||||
resolved({ storedThemeCode: "999" }),
|
||||
resolved({ themeTitle: "다른제목" }),
|
||||
resolved({ actionId: "six-row-current", builderKey: "s5077", cutCode: "5077", pageSize: 6 }),
|
||||
resolved({ sort: "GAIN_ASC" }),
|
||||
resolved({ previewItemCount: 0 }),
|
||||
resolved({ previewItemCount: 239, previewIsTruncated: true }),
|
||||
resolved({ previewItemCount: 241, previewIsTruncated: true }),
|
||||
resolved({ codeWasRemapped: false })
|
||||
]) {
|
||||
assert.equal(workflow.normalizeResponse(response(invalid), "load-1", [pending]), null);
|
||||
}
|
||||
|
||||
assert.equal(workflow.normalizeResponse({
|
||||
requestId: "load-1",
|
||||
retrievedAt: "2026-07-12T12:59:00+09:00",
|
||||
totalRowCount: 2,
|
||||
rows: [resolved({ itemIndex: 5 }), resolved({ itemIndex: 4 })]
|
||||
}, "load-1", [
|
||||
workflow.classify(raw({ itemIndex: 4 }), { id: "db-4", fadeDuration: 6 }),
|
||||
workflow.classify(raw({ itemIndex: 5 }), { id: "db-5", fadeDuration: 6 })
|
||||
]), null);
|
||||
});
|
||||
|
||||
test("failed rows preserve a closed no-retry reason", () => {
|
||||
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
|
||||
for (const failure of [
|
||||
"INVALID_ROW", "UNSUPPORTED_ACTION", "IDENTITY_NOT_FOUND", "IDENTITY_AMBIGUOUS",
|
||||
"PREVIEW_EMPTY", "DATABASE_DATA_INVALID", "RESTORE_FAILED"
|
||||
]) {
|
||||
const result = workflow.normalizeResponse(response({
|
||||
itemIndex: 4,
|
||||
status: "failed",
|
||||
failure
|
||||
}), "load-1", [pending]);
|
||||
assert.equal(result.rows[0].failure, failure);
|
||||
}
|
||||
assert.equal(workflow.normalizeError({
|
||||
requestId: "load-1",
|
||||
code: "DATABASE_UNAVAILABLE",
|
||||
message: "조회 실패",
|
||||
retryable: false
|
||||
}, "load-1").retryable, false);
|
||||
assert.equal(workflow.normalizeError({
|
||||
requestId: "load-1",
|
||||
code: "DATABASE_UNAVAILABLE",
|
||||
message: "조회 실패",
|
||||
retryable: true
|
||||
}, "load-1"), null);
|
||||
});
|
||||
|
||||
test("materialize uses only current code and retains the old raw identity for lossless save", () => {
|
||||
const sourceRaw = raw({ enabled: false });
|
||||
const { pending, outcome } = normalize(sourceRaw);
|
||||
const entry = workflow.materialize(pending, outcome);
|
||||
|
||||
assert.equal(workflow.isMaterializedEntry(entry), true);
|
||||
assert.equal(entry.enabled, false);
|
||||
assert.equal(entry.selection.groupCode, "PAGED_THEME");
|
||||
assert.equal(entry.selection.dataCode, "87654321");
|
||||
assert.equal(entry.operator.theme.themeCode, "87654321");
|
||||
assert.equal(entry.pagePreview.itemCount, 7);
|
||||
assert.deepEqual(workflow.legacySelectionForEntry(entry), {
|
||||
groupCode: "테마",
|
||||
subject: "반도체",
|
||||
graphicType: "5단 표그래프",
|
||||
subtype: "테마-현재가(입력순)",
|
||||
dataCode: "0123"
|
||||
});
|
||||
assert.equal(workflow.matchesRaw(entry, sourceRaw), true);
|
||||
assert.equal(workflow.matchesRaw(entry, { ...sourceRaw, dataCode: "87654321" }), false);
|
||||
});
|
||||
|
||||
test("expected price and the original no-token loss-rate branch remain closed", () => {
|
||||
const sourceRaw = raw({
|
||||
subtype: "테마-예상체결가",
|
||||
pageText: "1/2"
|
||||
});
|
||||
const native = resolved({
|
||||
actionId: "five-row-expected",
|
||||
sort: "GAIN_ASC"
|
||||
});
|
||||
const { pending, outcome } = normalize(sourceRaw, native);
|
||||
const entry = workflow.materialize(pending, outcome);
|
||||
|
||||
assert.equal(entry.operator.actionId, "five-row-expected");
|
||||
assert.equal(entry.operator.sort, "GAIN_ASC");
|
||||
assert.equal(entry.selection.subtype, "EXPECTED");
|
||||
assert.equal(entry.selection.graphicType, "GAIN_ASC");
|
||||
assert.equal(workflow.matchesRaw(entry, sourceRaw), true);
|
||||
});
|
||||
|
||||
test("5-current, 5-expected, 6 and 12 row wire profiles retain the final sort fallback", () => {
|
||||
const cases = [
|
||||
["5단 표그래프", "테마-현재가", "five-row-current", "s5074", "5074", 5],
|
||||
["5단 표그래프", "테마-예상체결가", "five-row-expected", "s5074", "5074", 5],
|
||||
["6종목 현재가", "테마-현재가", "six-row-current", "s5077", "5077", 6],
|
||||
["12종목 현재가", "테마-현재가", "twelve-row-current", "s5088", "5088", 12]
|
||||
];
|
||||
for (const [graphicType, subtype, actionId, builderKey, cutCode, pageSize] of cases) {
|
||||
const sourceRaw = raw({ graphicType, subtype });
|
||||
const native = resolved({ actionId, builderKey, cutCode, pageSize, sort: "GAIN_ASC" });
|
||||
const { pending, outcome } = normalize(sourceRaw, native);
|
||||
const entry = workflow.materialize(pending, outcome);
|
||||
assert.equal(entry.builderKey, builderKey);
|
||||
assert.equal(entry.pageSize, pageSize);
|
||||
assert.equal(entry.operator.sort, "GAIN_ASC");
|
||||
}
|
||||
});
|
||||
|
||||
test("240 visible rows with truncation is the only accepted truncated preview boundary", () => {
|
||||
const { pending, outcome } = normalize(raw(), resolved({
|
||||
previewItemCount: 240,
|
||||
previewIsTruncated: true
|
||||
}));
|
||||
const entry = workflow.materialize(pending, outcome);
|
||||
assert.equal(entry.operator.namedKrxRestore.previewItemCount, 240);
|
||||
assert.equal(entry.operator.namedKrxRestore.previewIsTruncated, true);
|
||||
assert.equal(entry.pagePreview.pageCount, 20);
|
||||
assert.equal(entry.pagePreview.isTruncated, true);
|
||||
});
|
||||
|
||||
test("materialization accepts only branded normalized responses", () => {
|
||||
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
|
||||
assert.equal(workflow.materialize(pending, resolved()), null);
|
||||
const failed = workflow.normalizeResponse(response({
|
||||
itemIndex: 4,
|
||||
status: "failed",
|
||||
failure: "PREVIEW_EMPTY"
|
||||
}), "load-1", [pending]).rows[0];
|
||||
assert.equal(workflow.materialize(pending, failed), null);
|
||||
assert.equal(workflow.materialize({}, resolved()), null);
|
||||
});
|
||||
|
||||
test("enabled replacement is immutable and preserves only the trusted KRX brand", () => {
|
||||
const sourceRaw = raw({ enabled: true });
|
||||
const { pending, outcome } = normalize(sourceRaw);
|
||||
const entry = workflow.materialize(pending, outcome);
|
||||
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.deepEqual(workflow.legacySelectionForEntry(replacement),
|
||||
workflow.legacySelectionForEntry(entry));
|
||||
assert.equal(workflow.matchesRaw(replacement, { ...sourceRaw, enabled: false }), true);
|
||||
assert.equal(workflow.withEnabled({ ...entry }, false), null);
|
||||
assert.equal(workflow.withEnabled(entry, "false"), null);
|
||||
});
|
||||
|
||||
test("native partial emits the exact result and non-retry error contract", () => {
|
||||
const root = path.resolve(__dirname, "..", "..");
|
||||
const native = fs.readFileSync(path.join(root, "MainWindow.NamedKrxThemeRestore.cs"), "utf8");
|
||||
assert.match(native, /PostMessage\("named-krx-theme-restore-results"/u);
|
||||
assert.match(native, /PostMessage\("named-krx-theme-restore-error"/u);
|
||||
assert.match(native, /identity\.StoredThemeCode/u);
|
||||
assert.match(native, /identity\.CurrentThemeCode/u);
|
||||
assert.match(native, /identity\.PreviewIsTruncated/u);
|
||||
assert.match(native, /retryable = false/u);
|
||||
assert.doesNotMatch(native, /retryable = true/u);
|
||||
assert.doesNotMatch(native, /IPlayoutEngine|PrepareAsync|TakeInAsync|NextAsync|TakeOutAsync/u);
|
||||
});
|
||||
@@ -132,6 +132,24 @@ test("dynamic NXT session changes exactly at local 13:00 without losing native p
|
||||
assert.equal(workflow.matchesRaw(atThirteen, sourceRaw), true);
|
||||
});
|
||||
|
||||
test("enabled replacement is immutable and retains NXT proof across session refresh", () => {
|
||||
const sourceRaw = raw({ enabled: true });
|
||||
const pending = workflow.classify(sourceRaw, { id: "db-toggle", fadeDuration: 6 });
|
||||
const outcome = workflow.normalizeResponse(response(resolved({ itemIndex: 4 })),
|
||||
"load-1", [pending]).rows[0];
|
||||
const entry = workflow.materialize(pending, outcome);
|
||||
const replacement = workflow.withEnabled(entry, false);
|
||||
const refreshed = workflow.refreshDynamicSession(replacement, new Date(2026, 6, 12, 13, 0));
|
||||
|
||||
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(refreshed, { ...sourceRaw, enabled: false }), true);
|
||||
assert.equal(workflow.withEnabled({ ...entry }, false), null);
|
||||
});
|
||||
|
||||
test("materialization refuses a failed outcome and non-pending objects", () => {
|
||||
const pending = workflow.classify(raw(), { id: "db-4", fadeDuration: 6 });
|
||||
assert.equal(workflow.materialize(pending, {
|
||||
|
||||
@@ -209,6 +209,25 @@ test("stock responses preserve US/TW identity and reject duplicate input-name lo
|
||||
})), null);
|
||||
});
|
||||
|
||||
test("provider symbols accept Unicode letters but keep serialized delimiters closed", () => {
|
||||
const unicode = workflow.normalizeStock({
|
||||
inputName: "Provider Unicode Symbol",
|
||||
symbol: "NAS@해외 종목1",
|
||||
nationCode: "US",
|
||||
fdtc: "1"
|
||||
});
|
||||
assert.ok(unicode);
|
||||
const entry = workflow.createOverseasStockPlaylistEntry(
|
||||
unicode,
|
||||
"stock-candle-5d",
|
||||
{ id: "unicode-symbol", fadeDuration: 6, ma5: true, ma20: false });
|
||||
assert.equal(entry.selection.dataCode, "NAS@해외 종목1|US|1");
|
||||
|
||||
for (const symbol of ["NAS|NVDA", "NAS^NVDA", " NAS@NVDA", "NAS@NVDA ", "NAS!NVDA", "NAS\nNVDA", "NAS😀NVDA"]) {
|
||||
assert.equal(workflow.normalizeStock({ ...unicode, symbol }), null, symbol);
|
||||
}
|
||||
});
|
||||
|
||||
test("bridge errors use exact payloads and reject stale request correlation", () => {
|
||||
const industryRequest = workflow.createIndustrySearchRequest("industry-error-1", "AI");
|
||||
const industryError = {
|
||||
|
||||
122
tests/Web/playlist-enabled-toggle-app-integration.test.cjs
Normal file
122
tests/Web/playlist-enabled-toggle-app-integration.test.cjs
Normal file
@@ -0,0 +1,122 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = path.resolve(__dirname, "..", "..");
|
||||
const app = fs.readFileSync(path.join(root, "Web", "app.js"), "utf8");
|
||||
|
||||
function functionSource(name, nextName) {
|
||||
const start = app.indexOf(`function ${name}(`);
|
||||
const end = app.indexOf(`function ${nextName}(`, start + 1);
|
||||
assert.ok(start >= 0 && end > start, `${name} must precede ${nextName}`);
|
||||
return app.slice(start, end).trim();
|
||||
}
|
||||
|
||||
function recreateWith(stubs) {
|
||||
const source = functionSource(
|
||||
"recreatePlaylistEntryWithEnabled",
|
||||
"playlistEntryManualEditBlockReason");
|
||||
return new Function(
|
||||
"namedKrxThemeRestoreWorkflow",
|
||||
"namedNxtThemeRestoreWorkflow",
|
||||
"namedForeignStockRestoreWorkflow",
|
||||
"foreignIndexCandleRestoreWorkflow",
|
||||
"manualFinancialWorkflow",
|
||||
"manualListsWorkflow",
|
||||
`return (${source});`)(
|
||||
stubs.krx,
|
||||
stubs.nxt,
|
||||
stubs.foreignStock,
|
||||
stubs.foreignIndex,
|
||||
stubs.manualFinancial,
|
||||
stubs.manualLists);
|
||||
}
|
||||
|
||||
function stubSet() {
|
||||
const recreate = label => (entry, enabled) => entry.trusted === label
|
||||
? Object.freeze({ ...entry, enabled, retainedBrand: label })
|
||||
: null;
|
||||
return {
|
||||
krx: {
|
||||
isMaterializedEntry: entry => entry.trusted === "krx",
|
||||
withEnabled: recreate("krx")
|
||||
},
|
||||
nxt: {
|
||||
isMaterializedEntry: entry => entry.trusted === "nxt",
|
||||
withEnabled: recreate("nxt")
|
||||
},
|
||||
foreignStock: {
|
||||
source: "legacy-named-foreign-stock-workflow",
|
||||
withEnabled: recreate("foreign-stock")
|
||||
},
|
||||
foreignIndex: { withEnabled: recreate("foreign-index") },
|
||||
manualFinancial: { withEnabled: recreate("manual-financial") },
|
||||
manualLists: { withEnabled: recreate("manual-lists") }
|
||||
};
|
||||
}
|
||||
|
||||
test("a frozen ordinary entry toggles through an immutable replacement", () => {
|
||||
const recreate = recreateWith(stubSet());
|
||||
const entry = Object.freeze({
|
||||
id: "ordinary",
|
||||
code: "5001",
|
||||
enabled: true,
|
||||
operator: Object.freeze({ source: "legacy-stock-workflow" })
|
||||
});
|
||||
|
||||
const replacement = recreate(entry, false);
|
||||
assert.notEqual(replacement, entry);
|
||||
assert.equal(entry.enabled, true);
|
||||
assert.equal(replacement.enabled, false);
|
||||
assert.equal(replacement.id, entry.id);
|
||||
});
|
||||
|
||||
test("every capability-branded entry delegates to its trust-preserving workflow", () => {
|
||||
const recreate = recreateWith(stubSet());
|
||||
const cases = [
|
||||
["krx", { source: "legacy-theme-workflow", namedKrxRestore: {} }],
|
||||
["nxt", { source: "legacy-theme-workflow", namedNxtRestore: {} }],
|
||||
["foreign-stock", { source: "legacy-named-foreign-stock-workflow" }],
|
||||
["foreign-index", { source: "legacy-foreign-index-candle-workflow" }],
|
||||
["manual-financial", { source: "manual-financial-workflow" }],
|
||||
["manual-lists", { source: "legacy-manual-lists-workflow" }]
|
||||
];
|
||||
|
||||
for (const [trusted, operator] of cases) {
|
||||
const entry = Object.freeze({ id: trusted, enabled: true, trusted, operator });
|
||||
const replacement = recreate(entry, false);
|
||||
assert.equal(replacement.retainedBrand, trusted);
|
||||
assert.equal(replacement.enabled, false);
|
||||
assert.equal(entry.enabled, true);
|
||||
}
|
||||
});
|
||||
|
||||
test("a proof-shaped but unbranded named entry fails closed instead of becoming a plain clone", () => {
|
||||
const recreate = recreateWith(stubSet());
|
||||
const forgedKrx = Object.freeze({
|
||||
id: "forged-krx",
|
||||
enabled: true,
|
||||
operator: Object.freeze({
|
||||
source: "legacy-theme-workflow",
|
||||
namedKrxRestore: Object.freeze({ schemaVersion: 1 })
|
||||
})
|
||||
});
|
||||
assert.equal(recreate(forgedKrx, false), null);
|
||||
});
|
||||
|
||||
test("checkbox, enabled-all and current-row paths never assign into playlist entries", () => {
|
||||
const render = functionSource("renderPlaylist", "selectPlaylistRow");
|
||||
const all = functionSource("toggleAllEntriesEnabled", "moveSelected");
|
||||
const current = functionSource("toggleCurrentEntryEnabled", "savePlaylist");
|
||||
|
||||
assert.match(render, /replacePlaylistEntryEnabledAt\(index, checkbox\.checked\)/u);
|
||||
assert.match(render, /renderPlaylist\(\)/u);
|
||||
assert.match(all, /state\.playlist\.map\(item => recreatePlaylistEntryWithEnabled/u);
|
||||
assert.match(all, /replacements\.some\(item => !item\)/u);
|
||||
assert.match(current,
|
||||
/replacePlaylistEntryEnabledAt\(state\.currentIndex, item\.enabled === false\)/u);
|
||||
assert.doesNotMatch(`${render}\n${all}\n${current}`, /item\.enabled\s*=(?!=)/u);
|
||||
});
|
||||
180
tests/Web/playlist-entry-edit-safety-app-integration.test.cjs
Normal file
180
tests/Web/playlist-entry-edit-safety-app-integration.test.cjs
Normal file
@@ -0,0 +1,180 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = path.resolve(__dirname, "..", "..");
|
||||
const app = fs.readFileSync(path.join(root, "Web", "app.js"), "utf8");
|
||||
|
||||
function functionSource(name, nextName) {
|
||||
const start = app.indexOf(`function ${name}(`);
|
||||
const end = app.indexOf(`function ${nextName}(`, start + 1);
|
||||
assert.ok(start >= 0 && end > start, `${name} must precede ${nextName}`);
|
||||
return app.slice(start, end).trim();
|
||||
}
|
||||
|
||||
function evaluateFunction(name, nextName, dependencies = {}) {
|
||||
const names = Object.keys(dependencies);
|
||||
return new Function(...names,
|
||||
`return (${functionSource(name, nextName)});`)(...names.map(key => dependencies[key]));
|
||||
}
|
||||
|
||||
function stateStub() {
|
||||
return {
|
||||
namedPlaylist: { guardByEntryId: new Map() },
|
||||
manualFinancialRestore: { entries: new Map() }
|
||||
};
|
||||
}
|
||||
|
||||
test("every restored workflow boundary blocks direct scene and DB-row edits", () => {
|
||||
const state = stateStub();
|
||||
const blockReason = evaluateFunction(
|
||||
"playlistEntryManualEditBlockReason",
|
||||
"normalizeManualSceneSelection",
|
||||
{ state });
|
||||
const protectedEntries = [
|
||||
["krx", "legacy-theme-workflow", { namedKrxRestore: {} }],
|
||||
["nxt", "legacy-theme-workflow", { namedNxtRestore: {} }],
|
||||
["foreign-stock", "legacy-named-foreign-stock-workflow", {}],
|
||||
["foreign-index", "legacy-foreign-index-candle-workflow", {}],
|
||||
["manual-financial", "manual-financial-workflow", {}],
|
||||
["manual-lists", "legacy-manual-lists-workflow", {}]
|
||||
];
|
||||
for (const [id, source, proof] of protectedEntries) {
|
||||
const entry = Object.freeze({
|
||||
id,
|
||||
enabled: true,
|
||||
operator: Object.freeze({ source, ...proof })
|
||||
});
|
||||
assert.equal(blockReason(entry), "trusted-workflow-entry", id);
|
||||
}
|
||||
|
||||
const guarded = Object.freeze({ id: "guarded", enabled: true });
|
||||
state.namedPlaylist.guardByEntryId.set(guarded.id, Object.freeze({ trusted: false }));
|
||||
assert.equal(blockReason(guarded), "named-playlist-entry");
|
||||
const pending = Object.freeze({ id: "pending", enabled: true });
|
||||
state.manualFinancialRestore.entries.set(pending.id, Object.freeze({ status: "load" }));
|
||||
assert.equal(blockReason(pending), "manual-restore-entry");
|
||||
});
|
||||
|
||||
test("a frozen ordinary entry receives scene values through an immutable replacement", () => {
|
||||
const state = stateStub();
|
||||
const blockReason = evaluateFunction(
|
||||
"playlistEntryManualEditBlockReason",
|
||||
"normalizeManualSceneSelection",
|
||||
{ state });
|
||||
const normalize = evaluateFunction(
|
||||
"normalizeManualSceneSelection",
|
||||
"recreatePlaylistEntryWithSceneSelection");
|
||||
const sceneAliases = item => item.aliases;
|
||||
const recreate = evaluateFunction(
|
||||
"recreatePlaylistEntryWithSceneSelection",
|
||||
"recreatePlaylistEntryWithLiveSelection",
|
||||
{
|
||||
playlistEntryManualEditBlockReason: blockReason,
|
||||
normalizeManualSceneSelection: normalize,
|
||||
sceneAliases
|
||||
});
|
||||
const entry = Object.freeze({
|
||||
id: "ordinary",
|
||||
code: "5001",
|
||||
aliases: Object.freeze(["5001", "N5001"]),
|
||||
enabled: true,
|
||||
fadeDuration: 6,
|
||||
selection: Object.freeze({
|
||||
groupCode: "KOSPI",
|
||||
subject: "OLD",
|
||||
graphicType: "",
|
||||
subtype: "",
|
||||
dataCode: ""
|
||||
})
|
||||
});
|
||||
const replacement = recreate(entry, {
|
||||
groupCode: " NXT_KOSPI ",
|
||||
subject: " 삼성전자 ",
|
||||
graphicType: "CURRENT",
|
||||
subtype: "",
|
||||
dataCode: "005930"
|
||||
}, "N5001", 4);
|
||||
|
||||
assert.notEqual(replacement, entry);
|
||||
assert.equal(entry.code, "5001");
|
||||
assert.equal(entry.fadeDuration, 6);
|
||||
assert.equal(entry.selection.subject, "OLD");
|
||||
assert.equal(replacement.code, "N5001");
|
||||
assert.equal(replacement.fadeDuration, 4);
|
||||
assert.equal(replacement.selection.groupCode, "NXT_KOSPI");
|
||||
assert.equal(replacement.selection.subject, "삼성전자");
|
||||
assert.equal(Object.isFrozen(replacement.selection), true);
|
||||
assert.equal(recreate(entry, replacement.selection, "invalid", 4), null);
|
||||
});
|
||||
|
||||
test("live DB selection also replaces only an unprotected ordinary entry", () => {
|
||||
const state = stateStub();
|
||||
const blockReason = evaluateFunction(
|
||||
"playlistEntryManualEditBlockReason",
|
||||
"normalizeManualSceneSelection",
|
||||
{ state });
|
||||
const normalize = evaluateFunction(
|
||||
"normalizeManualSceneSelection",
|
||||
"recreatePlaylistEntryWithSceneSelection");
|
||||
const recreate = evaluateFunction(
|
||||
"recreatePlaylistEntryWithLiveSelection",
|
||||
"replacePlaylistEntryAt",
|
||||
{
|
||||
playlistEntryManualEditBlockReason: blockReason,
|
||||
normalizeManualSceneSelection: normalize
|
||||
});
|
||||
const ordinary = Object.freeze({
|
||||
id: "ordinary-live",
|
||||
code: "5001",
|
||||
enabled: true,
|
||||
selection: Object.freeze({
|
||||
groupCode: "KOSPI_STOCK",
|
||||
subject: "OLD",
|
||||
graphicType: "CURRENT",
|
||||
subtype: "",
|
||||
dataCode: "000001"
|
||||
})
|
||||
});
|
||||
const selection = {
|
||||
groupCode: "KOSPI_STOCK",
|
||||
subject: "삼성전자",
|
||||
graphicType: "CURRENT",
|
||||
subtype: "",
|
||||
dataCode: "005930"
|
||||
};
|
||||
const replacement = recreate(ordinary, selection);
|
||||
assert.notEqual(replacement, ordinary);
|
||||
assert.equal(ordinary.selection.subject, "OLD");
|
||||
assert.equal(replacement.selection.subject, "삼성전자");
|
||||
assert.equal(replacement.subject, "삼성전자");
|
||||
assert.equal(replacement.dataCode, "005930");
|
||||
|
||||
const protectedEntry = Object.freeze({
|
||||
...ordinary,
|
||||
id: "protected-live",
|
||||
operator: Object.freeze({ source: "legacy-stock-workflow" })
|
||||
});
|
||||
assert.equal(recreate(protectedEntry, selection), null);
|
||||
});
|
||||
|
||||
test("both edit handlers keep snapshot locking and contain no playlist-entry assignment", () => {
|
||||
const render = functionSource("renderSceneSelectionForm", "applySceneSelection");
|
||||
const scene = functionSource("applySceneSelection", "applyCutAliasPreset");
|
||||
const live = functionSource("applyLiveRowToCurrentCut", "renderLiveTables");
|
||||
|
||||
assert.match(render, /playlistEntryManualEditBlockReason\(item\)/u);
|
||||
assert.match(scene, /if \(!requirePlaylistEditable\(\)\) return;/u);
|
||||
assert.match(scene, /playlistEntryManualEditBlockReason\(item\)/u);
|
||||
assert.match(scene, /recreatePlaylistEntryWithSceneSelection/u);
|
||||
assert.match(scene, /replacePlaylistEntryAt\(state\.currentIndex, replacement\)/u);
|
||||
assert.match(live, /if \(!requirePlaylistEditable\(\)\) return;/u);
|
||||
assert.match(live, /playlistEntryManualEditBlockReason\(item\)/u);
|
||||
assert.match(live, /recreatePlaylistEntryWithLiveSelection/u);
|
||||
assert.match(live, /replacePlaylistEntryAt\(state\.currentIndex, replacement\)/u);
|
||||
assert.doesNotMatch(app,
|
||||
/\bitem\.[A-Za-z_$][A-Za-z0-9_$]*\s*=(?!=)/u);
|
||||
});
|
||||
Reference in New Issue
Block a user