feat: restore legacy named playlist workflows

This commit is contained in:
2026-07-12 11:59:18 +09:00
parent a481c96c39
commit af80c36cde
52 changed files with 9261 additions and 83 deletions

View File

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

View File

@@ -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",

View File

@@ -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);

View File

@@ -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", {

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

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

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

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

View File

@@ -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, {

View File

@@ -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 = {

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

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