"use strict"; const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const test = require("node:test"); const repositoryRoot = path.resolve(__dirname, "..", ".."); const webRoot = path.join(repositoryRoot, "src", "MBN_STOCK_WEBVIEW.LegacyParityApp", "Web"); const app = fs.readFileSync(path.join(webRoot, "app.js"), "utf8"); const playout = fs.readFileSync(path.join(webRoot, "playout-ui.js"), "utf8"); const markup = fs.readFileSync(path.join(webRoot, "index.html"), "utf8"); const styles = fs.readFileSync(path.join(webRoot, "styles.css"), "utf8"); test("legacy shell exposes the original stock, cut, playlist and playout controls", () => { for (const id of [ "stock-search", "stock-search-button", "stock-results", "market-tabs", "category-tree", "catalog-expand-all", "moving-average-5", "moving-average-20", "cut-list", "cut-drag-feedback", "playlist-drop-zone", "playlist-rows", "playlist-enable-all", "playlist-move-up", "playlist-move-down", "playlist-delete-selected", "playlist-delete-all", "db-save-button", "db-load-button", "named-playlist-modal", "named-playlist-definitions", "named-playlist-new-title", "named-playlist-create-button", "named-playlist-delete-button", "named-playlist-confirm-button", "legacy-dialog" ]) { assert.match(markup, new RegExp("id=\\\"" + id + "\\\"")); } assert.match(markup, /TAKE IN/); assert.match(markup, /TAKE OUT/); assert.match(markup, /NEXT/); assert.match(markup, /class="connection-state"[^>]*>미연결<\/div>/); assert.match(markup, /class="[^"]*\bactive\b[^"]*"[^>]*data-tab-id="overseas"[\s\S]*?해외<\/span>[\s\S]*?<\/button>/); assert.match(markup, /data-tab-id="tradingHalt"[\s\S]*?거래정지<\/span>[\s\S]*?<\/button>/); }); test("web layer sends intent and does not own scene, DB identity or playlist state", () => { assert.doesNotMatch(app, /(?:sceneCode|cutCode|stockCode|dataCode|builderKey)/); assert.doesNotMatch(app, /(?:localStorage|sessionStorage|playlist\.push|fetch\s*\()/); assert.doesNotMatch(app, /\b(?:5001|N5001|5006|5011|5086|8035|8061|8040|8046|8051|8056|8003|5037)\b/); }); test("legacy stock double click is represented by pointer-down facts", () => { assert.match(app, /cut-pointer-down/); assert.match(app, /timestampMilliseconds/); assert.match(app, /event\.ctrlKey/); assert.match(app, /event\.shiftKey/); const cutFactory = app.slice(app.indexOf("function createCutElement"), app.indexOf("function renderCuts")); assert.doesNotMatch(cutFactory, /dblclick/); assert.match(cutFactory, /allowAppend: !playlistMutationLocked/); assert.match(cutFactory, /if \(!playlistMutationLocked\)/); assert.match(app, /send\("clear-cut-selection", \{\}\)/); assert.match(app, /event\.target !== cutList/); assert.match(app, /button\.addEventListener\("keydown"/); assert.match(app, /stockResults\.querySelectorAll\("button\[data-result-index\]"\)/); }); test("cut rows are reconciled in place while the legacy pointer drag remains captured", () => { assert.match(app, /dataset\.physicalIndex/); assert.match(app, /function createCutElement/); const cutFactory = app.slice( app.indexOf("function createCutElement"), app.indexOf("function clearPlaylistDragVisuals")); assert.match(cutFactory, /beginCutDragGesture\(element, event, position\)/); assert.match(cutFactory, /addEventListener\("pointermove", onCutPointerMove\)/); assert.match(cutFactory, /addEventListener\("lostpointercapture", onCutPointerCancel\)/); assert.match(cutFactory, /const cutDragLocked = isOperatorMutationLocked\(state\)/); assert.match(cutFactory, /!isCutSelectionLocked\(state\)/); assert.match(cutFactory, /element\.draggable = false/); assert.match(cutFactory, /dataset\.cutDragEnabled = cutSelectionEnabled && !cutDragLocked/); assert.doesNotMatch(cutFactory, /addEventListener\("dragstart"/); assert.doesNotMatch(app, /cutList\.replaceChildren/); }); test("program keeps cut append available while structural playlist edits stay locked", () => { const sourceStart = app.indexOf("function isOperatorMutationLocked"); const sourceEnd = app.indexOf("function dragDropPosition", sourceStart); assert.ok(sourceStart >= 0 && sourceEnd > sourceStart); const locks = new Function(` ${app.slice(sourceStart, sourceEnd)} return { isOperatorMutationLocked, isPlaylistStructureMutationLocked, isPlaylistProgramEnableMutationLocked, canDeleteSelectedPlaylistRows, isCutSelectionLocked }; `)(); const programState = { isBusy: false, fixedSectionBatch: null, playlist: [ { rowId: "on-air", isSelected: false }, { rowId: "tail", isSelected: true } ], playout: { phase: "program", currentCueIndexZeroBased: 0, isBusy: false, outcomeUnknown: false, isPlayCompletionPending: false, isTakeOutCompletionPending: false } }; assert.equal(locks.isOperatorMutationLocked(programState), false, "safe tail append and independent DB edits remain available"); assert.equal(locks.isPlaylistStructureMutationLocked(programState), true, "reorder, mouse checkbox changes, and whole-list replacement remain blocked"); assert.equal(locks.isPlaylistProgramEnableMutationLocked(programState), false, "legacy Space and all-checkbox enabled-state changes remain available in PROGRAM"); assert.equal(locks.canDeleteSelectedPlaylistRows(programState), true, "a selected row after the on-air row can be deleted"); assert.equal(locks.isCutSelectionLocked(programState), false); programState.playlist[0].isSelected = true; programState.playlist[1].isSelected = false; assert.equal(locks.canDeleteSelectedPlaylistRows(programState), false, "the on-air row cannot be deleted"); programState.playout.isBusy = true; assert.equal(locks.isOperatorMutationLocked(programState), true); assert.equal(locks.isPlaylistProgramEnableMutationLocked(programState), true); assert.equal(locks.isCutSelectionLocked(programState), true); programState.playout.isBusy = false; programState.playout.phase = "prepared"; assert.equal(locks.isPlaylistProgramEnableMutationLocked(programState), true, "PREPARED remains locked without an original parity basis"); programState.playout.phase = "idle"; assert.equal(locks.isPlaylistProgramEnableMutationLocked(programState), false); }); test("cut drag feedback follows the pointer and exposes allowed target state", () => { const sourceStart = app.indexOf("function showCutDragFeedback"); const sourceEnd = app.indexOf("function beginCutDragGesture", sourceStart); assert.ok(sourceStart >= 0 && sourceEnd > sourceStart); const feedback = { hidden: true, dataset: {}, offsetWidth: 120, offsetHeight: 30, classList: { values: new Set(), toggle(name, enabled) { if (enabled) this.values.add(name); else this.values.delete(name); } }, style: {} }; const show = new Function("cutDragFeedback", "document", ` ${app.slice(sourceStart, sourceEnd)} return showCutDragFeedback; `)(feedback, { documentElement: { clientWidth: 800, clientHeight: 600 } }); show({ clientX: 100, clientY: 80 }, false); assert.equal(feedback.hidden, false); assert.equal(feedback.dataset.dropAllowed, "false"); assert.equal(feedback.style.left, "114px"); assert.equal(feedback.style.top, "94px"); assert.equal(feedback.classList.values.has("allowed"), false); show({ clientX: 790, clientY: 590 }, true); assert.equal(feedback.dataset.dropAllowed, "true"); assert.equal(feedback.style.left, "676px"); assert.equal(feedback.style.top, "566px"); assert.equal(feedback.classList.values.has("allowed"), true); assert.match(styles, /\.cut-drag-feedback \{[^}]*pointer-events: none/); assert.match(styles, /\.cut-drag-feedback\.allowed/); }); test("state rendering preserves stock and playlist focus like native controls", () => { assert.match(app, /dataset\.resultIndex/); assert.match(app, /dataset\.rowId/); assert.match(app, /aria-current/); assert.match(app, /scrollIntoView\(\{ block: "nearest", inline: "nearest" \}\)/); assert.doesNotMatch(app, /stockResults\.replaceChildren/); assert.doesNotMatch(app, /playlistRows\.replaceChildren/); }); test("playlist keeps candidate, selection, and pink on-air states independent", () => { const sourceStart = app.indexOf("function playlistPlayoutPresentation"); const sourceEnd = app.indexOf("function presentCurrentPlaylistRow", sourceStart); assert.ok(sourceStart >= 0 && sourceEnd > sourceStart); const presentation = new Function(` ${app.slice(sourceStart, sourceEnd)} return playlistPlayoutPresentation; `)(); const state = { playout: { phase: "program", currentEntryId: "row-current", currentCueIndexZeroBased: 1, pageIndexZeroBased: 1, pageCount: 3 } }; assert.deepEqual(presentation(state, { rowId: "row-current", isSelected: false, pageText: "1/3" }, 1), { isSelected: false, isCurrent: true, isOnAir: true, pageText: "2/3", focusKey: "program:row-current:1:3" }); assert.deepEqual(presentation(state, { rowId: "row-candidate", isSelected: true, pageText: "1/1" }, 1), { isSelected: true, isCurrent: false, isOnAir: false, pageText: "1/1", focusKey: "" }); assert.equal(presentation({ playout: { ...state.playout, currentEntryId: null } }, { rowId: "row-fallback", isSelected: false, pageText: "1/3" }, 1).isCurrent, true); assert.match(app, /presentation\.isOnAir \? " on-air"/); assert.match(app, /presentation\.isSelected \|\| isCandidate \? " selected"/); assert.match(app, /presentation\.pageText/); }); test("PROGRAM F8 remains TAKE IN while end-of-playlist NEXT is closed", () => { const sourceStart = playout.indexOf("function nextLabel"); const sourceEnd = playout.indexOf("function render", sourceStart); assert.ok(sourceStart >= 0 && sourceEnd > sourceStart); const contract = new Function(` ${playout.slice(sourceStart, sourceEnd)} return { nextLabel, canTakeInFromState, canAdvanceFromState }; `)(); assert.equal(contract.canTakeInFromState({ phase: "idle" }, true), true); assert.equal(contract.canTakeInFromState({ phase: "prepared" }, false), true); assert.equal(contract.canTakeInFromState({ phase: "program" }, true), true); assert.equal(contract.canTakeInFromState({ phase: "program" }, false), false); assert.equal(contract.canAdvanceFromState({ phase: "program", nextKind: "playlistNext" }), true); assert.equal(contract.canAdvanceFromState({ phase: "program", nextKind: "endOfPlaylist" }), false); assert.match(contract.nextLabel({ nextKind: "endOfPlaylist" }), /마지막/); }); test("IDLE Escape keeps the emergency StopAll path available", () => { assert.match(playout, /takeOut\.disabled = busy \|\| !commandAvailable;/); assert.doesNotMatch( playout, /takeOut\.disabled[\s\S]{0,120}playout\.phase !== "prepared"/); }); test("legacy child modal Escape is inert and cannot leak into the playout listener", () => { const appScript = markup.indexOf(''); const playoutScript = markup.indexOf(''); assert.ok(appScript >= 0 && playoutScript > appScript); const keydownMarker = 'document.addEventListener("keydown", function (event) {'; const appHandlerStart = app.lastIndexOf(keydownMarker); const appHandlerBodyStart = appHandlerStart + keydownMarker.length; const appHandlerEnd = app.indexOf("\n });", appHandlerBodyStart); assert.ok(appHandlerStart >= 0 && appHandlerEnd > appHandlerBodyStart); const handleAppKeydown = new Function( "event", "modalFocusManager", "cutDragGesture", "clearCutDragGesture", "playlistDragGesture", "clearPlaylistDragGesture", "deferredPlaylistPlainSelectionRowId", "legacyAlertDialog", "legacyDialogIsConfirmation", "send", app.slice(appHandlerBodyStart, appHandlerEnd)); const playoutHandlerStart = playout.lastIndexOf(keydownMarker); const playoutHandlerBodyStart = playoutHandlerStart + keydownMarker.length; const playoutHandlerEnd = playout.indexOf("\n });", playoutHandlerBodyStart); assert.ok(playoutHandlerStart >= 0 && playoutHandlerEnd > playoutHandlerBodyStart); const handlePlayoutKeydown = new Function( "event", "hasOpenDialog", "backgroundFile", "backgroundNone", "takeIn", "takeOut", playout.slice(playoutHandlerBodyStart, playoutHandlerEnd)); const clicks = []; const button = name => ({ disabled: false, click() { clicks.push(name); } }); const backgroundFile = button("choose-background"); const backgroundNone = button("toggle-background"); const takeIn = button("take-in"); const takeOut = button("take-out"); const modal = { hidden: false }; for (const key of ["F2", "F3", "F8"]) { const modalKey = { key, repeat: false, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }; handlePlayoutKeydown( modalKey, () => !modal.hidden, backgroundFile, backgroundNone, takeIn, takeOut); assert.equal(modalKey.defaultPrevented, true, key + " browser default must be consumed"); } assert.deepEqual(clicks, []); const escape = new Event("keydown", { cancelable: true }); Object.defineProperties(escape, { key: { value: "Escape" }, repeat: { value: false } }); const childDialog = {}; const legacyAlertDialog = {}; const sent = []; handleAppKeydown( escape, { getActiveModal() { return childDialog; } }, null, () => {}, null, () => {}, null, legacyAlertDialog, false, (type) => sent.push(type)); assert.equal(modal.hidden, false); assert.equal(escape.defaultPrevented, true); handlePlayoutKeydown( escape, () => !modal.hidden, backgroundFile, backgroundNone, takeIn, takeOut); assert.deepEqual(clicks, []); assert.deepEqual(sent, []); const alertEscape = new Event("keydown", { cancelable: true }); Object.defineProperties(alertEscape, { key: { value: "Escape" }, repeat: { value: false } }); handleAppKeydown( alertEscape, { getActiveModal() { return legacyAlertDialog; } }, null, () => {}, null, () => {}, null, legacyAlertDialog, false, (type) => sent.push(type)); assert.equal(alertEscape.defaultPrevented, true); assert.deepEqual(sent, ["dismiss-dialog"]); }); test("started pointer drag owns playout keys and Escape cancels without TAKE OUT", () => { const keydownMarker = 'document.addEventListener("keydown", function (event) {'; const start = app.lastIndexOf(keydownMarker) + keydownMarker.length; const end = app.indexOf("\n });", start); const handle = new Function( "event", "modalFocusManager", "cutDragGesture", "clearCutDragGesture", "playlistDragGesture", "clearPlaylistDragGesture", "deferredPlaylistPlainSelectionRowId", "legacyAlertDialog", "legacyDialogIsConfirmation", "send", app.slice(start, end)); const sent = []; let clears = 0; const manager = { getActiveModal() { return null; } }; for (const key of ["F2", "F3", "F8", "Escape"]) { const event = { key, repeat: false, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }; handle( event, manager, { started: true }, () => { clears += 1; }, null, () => {}, null, {}, false, (type) => sent.push(type)); assert.equal(event.defaultPrevented, true, key + " must stay inside drag loop"); } assert.equal(clears, 1, "only Escape cancels the active drag"); const playlistEscape = { key: "Escape", repeat: false, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }; let playlistClears = 0; handle( playlistEscape, manager, null, () => {}, { started: true }, () => { playlistClears += 1; }, "row-a", {}, false, (type) => sent.push(type)); assert.equal(playlistEscape.defaultPrevented, true); assert.equal(playlistClears, 1); assert.deepEqual(sent, []); }); test("playlist controls send intent for selection, enabled state, movement and deletion", () => { for (const intent of [ "select-playlist-row", "activate-playlist-row", "set-playlist-enabled", "set-all-playlist-enabled", "reorder-playlist-rows", "move-selected-playlist-rows", "delete-selected-playlist-rows", "delete-all-playlist-rows" ]) { assert.match(app, new RegExp("send\\(\\\"" + intent + "\\\"")); } assert.match(app, /control: event\.ctrlKey/); assert.match(app, /shift: event\.shiftKey/); assert.match(app, /event\.key === "Delete"/); const deleteBranch = app.slice( app.lastIndexOf('if (event.key === "Delete"'), app.lastIndexOf('} else if (event.key === " "')); assert.match( deleteBranch, /if \(!playlistDeleteSelectedLocked\) send\("delete-selected-playlist-rows"/); assert.doesNotMatch(deleteBranch, /event\.preventDefault\(\)/); assert.match(app, /send\("toggle-active-playlist-enabled", \{\}\)/); const spaceBranch = app.slice( app.lastIndexOf('} else if (event.key === " "'), app.lastIndexOf('} else if ((event.key === "Home"')); assert.match(spaceBranch, /if \(!playlistProgramEnableMutationLocked\)/); assert.doesNotMatch(spaceBranch, /set-playlist-enabled/); assert.match(app, /if \(!playlistMutationLocked\)/); assert.match(app, /select-playlist-boundary/); assert.match(app, /event\.key === "Home"/); assert.match(app, /event\.key === "End"/); const homeEndBranchStart = app.lastIndexOf('} else if ((event.key === "Home"'); const homeEndBranch = app.slice( homeEndBranchStart, app.indexOf("\n });", homeEndBranchStart)); assert.doesNotMatch(homeEndBranch, /event\.preventDefault\(\)/); const boundaryHelperStart = app.indexOf("function selectPlaylistBoundary"); const boundaryHelper = app.slice( boundaryHelperStart, app.indexOf("function movePlaylistKeyboardSelection", boundaryHelperStart)); assert.doesNotMatch(boundaryHelper, /\.focus\(/); assert.match(boundaryHelper, /scrollIntoView/); const arrowHelperStart = app.indexOf("function movePlaylistKeyboardSelection"); const arrowHelper = app.slice( arrowHelperStart, app.indexOf("function clearDelayedClick", arrowHelperStart)); assert.match(arrowHelper, /event\.key === "ArrowUp"/); assert.match(arrowHelper, /next\.focus\(\{ preventScroll: true \}\)/); assert.match(arrowHelper, /send\("select-playlist-row"/); assert.match(app, /event\.key === "ArrowDown"/); assert.match(app, /row\.isSelected/); assert.match(app, /row\.isActive === true/); assert.match(app, /enabled\.setAttribute\("aria-disabled", playlistStructureMutationLocked/); assert.match(app, /interactionDisabled = playlistStructureMutationLocked/); assert.match(app, /playlistEnableAll\.disabled = playlistProgramEnableMutationLocked/); assert.match(app, /playlistEnableAll\.indeterminate/); }); test("playlist reorder reconciles keyed DOM nodes without discarding focus", () => { assert.match(app, /const existingRows = new Map\(\)/); assert.match(app, /existingRows\.get\(row\.rowId\)/); assert.match(app, /playlistRows\.insertBefore\(element, elementAtPosition\)/); assert.match(app, /const retainedRowIds = new Set/); assert.doesNotMatch(app, /playlistRows\.replaceChild/); }); test("playlist reorder starts only from the 30 px row header pointer handle", () => { assert.match(markup, /playlist-header playlist-row">\s*