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