"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"); 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", "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="active"[^>]*data-tab-id="overseas">해외<\/button>/); assert.match(markup, /data-tab-id="tradingHalt">정지<\/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, /if \(playlistMutationLocked\) return/); 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, /element\.draggable = false/); assert.match(cutFactory, /dataset\.cutDragEnabled = cutSelectionEnabled/); assert.doesNotMatch(cutFactory, /addEventListener\("dragstart"/); assert.doesNotMatch(app, /cutList\.replaceChildren/); }); 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 the gray candidate separate from the pink on-air row and page", () => { 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", "legacyAlertDialog", "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, () => {}, legacyAlertDialog, (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, () => {}, legacyAlertDialog, (type) => sent.push(type)); assert.equal(alertEscape.defaultPrevented, true); assert.deepEqual(sent, ["dismiss-dialog"]); }); test("started cut 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", "legacyAlertDialog", "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; }, {}, (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"); 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 \(!playlistMutationLocked\) send\("delete-selected-playlist-rows"/); assert.doesNotMatch(deleteBranch, /event\.preventDefault\(\)/); assert.doesNotMatch(app, /send\("toggle-active-playlist-enabled"/); assert.match(app, /enabled: enabled\.checked !== true/); 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 clearDelayedClick", boundaryHelperStart)); assert.doesNotMatch(boundaryHelper, /\.focus\(/); assert.match(boundaryHelper, /scrollIntoView/); assert.match(app, /row\.isSelected/); assert.match(app, /row\.isActive === true/); assert.match(app, /enabled\.setAttribute\("aria-disabled", playlistMutationLocked/); assert.match(app, /interactionDisabled = playlistMutationLocked/); 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 native drag sends one source row and rejects checkbox drag", () => { class MiniClassList { constructor() { this.names = new Set(); } add(...names) { names.forEach((name) => this.names.add(name)); } remove(...names) { names.forEach((name) => this.names.delete(name)); } contains(name) { return this.names.has(name); } toggle(name, force) { if (force === undefined ? !this.names.has(name) : force) this.names.add(name); else this.names.delete(name); } } class MiniElement extends EventTarget { constructor(tagName) { super(); this.tagName = tagName.toUpperCase(); this.dataset = {}; this.children = []; this.parentElement = null; this.attributes = new Map(); this.classList = new MiniClassList(); this.listeners = new Map(); } set className(value) { this.classList = new MiniClassList(); String(value).split(/\s+/).filter(Boolean) .forEach((name) => this.classList.add(name)); } get className() { return [...this.classList.names].join(" "); } appendChild(child) { child.parentElement = this; this.children.push(child); return child; } contains(candidate) { return candidate === this || this.children.some((child) => child.contains(candidate)); } closest(selector) { const tags = selector.split(",").map((value) => value.trim().toUpperCase()); return tags.includes(this.tagName) ? this : this.parentElement?.closest(selector) || null; } focus() {} addEventListener(type, listener, options) { if (!this.listeners.has(type)) this.listeners.set(type, []); this.listeners.get(type).push(listener); super.addEventListener(type, listener, options); } invoke(type, event) { for (const listener of this.listeners.get(type) || []) { listener.call(this, event); } } getBoundingClientRect() { return { top: 0, height: 20 }; } setAttribute(name, value) { this.attributes.set(name, String(value)); } getAttribute(name) { return this.attributes.get(name) ?? null; } querySelectorAll(selector) { const descendants = []; const visit = (element) => { element.children.forEach((child) => { descendants.push(child); visit(child); }); }; visit(this); if (selector === '.playlist-data-row[aria-selected="true"]') { return descendants.filter((element) => element.classList.contains("playlist-data-row") && element.getAttribute("aria-selected") === "true"); } if (selector === ".dragging, .drag-before, .drag-after") { return descendants.filter((element) => ["dragging", "drag-before", "drag-after"] .some((name) => element.classList.contains(name))); } return []; } } class MiniDataTransfer { constructor() { this.values = new Map(); } setData(type, value) { this.values.set(type, value); } getData(type) { return this.values.get(type) || ""; } } const sourceStart = app.indexOf("function clearPlaylistDragVisuals"); const sourceEnd = app.indexOf("function renderPlaylist", sourceStart); assert.ok(sourceStart >= 0 && sourceEnd > sourceStart); const createHarness = new Function("document", "playlistRows", "send", "playlistDragMime", ` let draggedPlaylistRowId = null; let deferredPlaylistPlainSelectionRowId = null; let playlistDragBlockedRowId = null; let playlistMutationLocked = false; function dragDropPosition(event, element) { const bounds = element.getBoundingClientRect(); return event.clientY < bounds.top + bounds.height / 2 ? "before" : "after"; } ${app.slice(sourceStart, sourceEnd)} return { createPlaylistElement, onPlaylistDragStart, state: () => ({ draggedPlaylistRowId, deferredPlaylistPlainSelectionRowId }) }; `); const document = { createElement: (tagName) => new MiniElement(tagName) }; const playlistRows = new MiniElement("div"); const sent = []; const harness = createHarness( document, playlistRows, (type, payload) => sent.push({ type, payload }), "text/x-mbn-playlist-row"); const source = harness.createPlaylistElement({ rowId: "row-a" }); source.setAttribute("aria-selected", "true"); playlistRows.appendChild(source); const selectedPeer = harness.createPlaylistElement({ rowId: "row-b" }); selectedPeer.setAttribute("aria-selected", "true"); playlistRows.appendChild(selectedPeer); const target = harness.createPlaylistElement({ rowId: "row-c" }); target.setAttribute("aria-selected", "false"); playlistRows.appendChild(target); const checkbox = source.children[0].children[0]; const blockedTransfer = new MiniDataTransfer(); let checkboxDragPrevented = false; source.invoke("pointerdown", { button: 0, currentTarget: source, target: checkbox, ctrlKey: false, shiftKey: false }); assert.deepEqual(sent, [{ type: "activate-playlist-row", payload: { rowId: "row-a" } }]); sent.length = 0; harness.onPlaylistDragStart({ currentTarget: source, // Chromium reports the draggable row as dragstart.target even though // pointerdown originated on the checkbox. target: source, dataTransfer: blockedTransfer, preventDefault: () => { checkboxDragPrevented = true; } }); assert.equal(checkboxDragPrevented, true); assert.equal(blockedTransfer.getData("text/x-mbn-playlist-row"), ""); const pointerDown = new Event("pointerdown", { cancelable: true }); Object.assign(pointerDown, { button: 0, ctrlKey: false, shiftKey: false }); source.dispatchEvent(pointerDown); assert.equal(harness.state().deferredPlaylistPlainSelectionRowId, "row-a"); assert.deepEqual(sent, []); const transfer = new MiniDataTransfer(); const dragStart = new Event("dragstart", { cancelable: true }); Object.assign(dragStart, { dataTransfer: transfer }); source.dispatchEvent(dragStart); assert.equal(harness.state().deferredPlaylistPlainSelectionRowId, null); assert.equal(transfer.getData("text/x-mbn-playlist-row"), "row-a"); assert.deepEqual(sent, []); const dragOver = new Event("dragover", { cancelable: true }); Object.assign(dragOver, { dataTransfer: transfer, clientY: 1 }); target.dispatchEvent(dragOver); assert.equal(dragOver.defaultPrevented, true); const drop = new Event("drop", { cancelable: true }); Object.assign(drop, { dataTransfer: transfer, clientY: 1 }); target.dispatchEvent(drop); assert.deepEqual(sent, [{ type: "reorder-playlist-rows", payload: { sourceRowId: "row-a", targetRowId: "row-c", position: "before" } }]); assert.equal(Object.hasOwn(sent[0].payload, "selectedRowIds"), false); }); test("cut drag uses the original pointer rectangle and drops only inside the playlist", () => { const start = app.indexOf("function pointInsideElement"); const end = app.indexOf("function legacyCutKey", start); assert.ok(start >= 0 && end > start); const drag = app.slice(start, end); assert.match(drag, /LegacyCutDrag\.createDragRectangle\(position, cutDragSize\)/); assert.match(drag, /LegacyCutDrag\.shouldStart\(gesture\.rectangle, pointerPosition\(event\)\)/); assert.match(drag, /event\.buttons & 1/); assert.match(drag, /pointInsideElement\(playlistDropZone, event\)/); assert.match(drag, /send\("drop-selected-cuts", \{\}\)/); assert.match(app, /state\.interactionMetrics/); assert.match(app, /metrics\.coordinateSpace !== "host-dip"/); assert.match(app, /LegacyCutDrag\.toCssDragSize/); assert.match(app, /window\.devicePixelRatio/); assert.match(app, /x: event\.clientX - bounds\.left/); assert.match(app, /x: Math\.round\(position\.x\)/); assert.doesNotMatch(app, /x: Math\.round\(event\.clientX - bounds\.left\)/); assert.match(app, /cutDragGesture\.rectangle = window\.LegacyCutDrag\.createDragRectangle/); assert.doesNotMatch(app, /selectedCutsDragToken|setData\("text\/plain"/); }); test("operator catalog drag rejects an interactive child pointer origin", () => { class MiniDataTransfer { constructor() { this.values = new Map(); } setData(type, value) { this.values.set(type, value); } getData(type) { return this.values.get(type) || ""; } } const sourceStart = app.indexOf("function clearOperatorCatalogDragVisuals"); const sourceEnd = app.indexOf("function renderOperatorCatalog", sourceStart); assert.ok(sourceStart >= 0 && sourceEnd > sourceStart); const createHarness = new Function( "operatorCatalogDraftRows", "send", "operatorCatalogDragMime", ` let draggedOperatorCatalogRowId = null; let operatorCatalogDragBlockedRowId = null; let operatorCatalogMutationLocked = false; function dragDropPosition(event, element) { const bounds = element.getBoundingClientRect(); return event.clientY < bounds.top + bounds.height / 2 ? "before" : "after"; } ${app.slice(sourceStart, sourceEnd)} return { onOperatorCatalogPointerDown, onOperatorCatalogDragStart, state: () => ({ draggedOperatorCatalogRowId, operatorCatalogDragBlockedRowId }) }; `); const harness = createHarness( { querySelectorAll: () => [] }, () => {}, "text/x-mbn-operator-catalog-row"); const row = { dataset: { operatorCatalogRowId: "catalog-draft-row-a" }, classList: { add() {}, remove() {} }, closest: () => null }; const input = { closest: () => input }; harness.onOperatorCatalogPointerDown({ button: 0, currentTarget: row, target: input }); const blockedTransfer = new MiniDataTransfer(); let prevented = false; harness.onOperatorCatalogDragStart({ currentTarget: row, target: row, dataTransfer: blockedTransfer, preventDefault: () => { prevented = true; } }); assert.equal(prevented, true); assert.equal( blockedTransfer.getData("text/x-mbn-operator-catalog-row"), ""); assert.deepEqual(harness.state(), { draggedOperatorCatalogRowId: null, operatorCatalogDragBlockedRowId: null }); harness.onOperatorCatalogPointerDown({ button: 0, currentTarget: row, target: row }); const allowedTransfer = new MiniDataTransfer(); harness.onOperatorCatalogDragStart({ currentTarget: row, target: row, dataTransfer: allowedTransfer, preventDefault: () => { throw new Error("non-interactive drag was rejected"); } }); assert.equal( allowedTransfer.getData("text/x-mbn-operator-catalog-row"), "catalog-draft-row-a"); }); test("market tabs send closed identity intents and reconcile C# order", () => { assert.match(app, /send\("select-tab"/); assert.match(app, /send\("hover-swap-tabs"/); assert.match(app, /expectedSourceIndex: sourceIndex/); assert.match(app, /expectedTargetIndex: targetIndex/); assert.doesNotMatch(app, /send\("swap-tabs"/); assert.match(app, /dataset\.tabId/); assert.match(app, /const existingTabs = new Map\(\)/); assert.match(app, /marketTabs\.insertBefore/); assert.doesNotMatch(app, /selectedTab\s*=/); }); test("market tab hover uses the original narrow-source width threshold in both directions", () => { const sourceStart = app.indexOf("function hasCrossedLegacyTabSwapThreshold"); const sourceEnd = app.indexOf("function onMarketTabDragOver", sourceStart); assert.ok(sourceStart >= 0 && sourceEnd > sourceStart); const crossed = new Function(` ${app.slice(sourceStart, sourceEnd)} return hasCrossedLegacyTabSwapThreshold; `)(); const tab = (left, width) => ({ getBoundingClientRect() { return { left, width }; } }); const narrowSource = tab(0, 40); const wideTarget = tab(100, 100); // Moving right: target.right - source.width = 160, with a strict > comparison. assert.equal(crossed(narrowSource, wideTarget, 159, 0, 1), false); assert.equal(crossed(narrowSource, wideTarget, 160, 0, 1), false); assert.equal(crossed(narrowSource, wideTarget, 161, 0, 1), true); // Moving left: target.left + source.width = 140, with a strict < comparison. assert.equal(crossed(narrowSource, wideTarget, 141, 2, 1), false); assert.equal(crossed(narrowSource, wideTarget, 140, 2, 1), false); assert.equal(crossed(narrowSource, wideTarget, 139, 2, 1), true); // Equal-width and wider sources keep MainForm's immediate-on-entry behavior. assert.equal(crossed(tab(0, 100), wideTarget, 100, 0, 1), true); assert.equal(crossed(tab(0, 120), wideTarget, 200, 2, 1), true); }); test("market tab dragover sends once after crossing and does not poison the busy gate", () => { const sourceStart = app.indexOf("function hasCrossedLegacyTabSwapThreshold"); const sourceEnd = app.indexOf("function requestTabHoverSwap", sourceStart); assert.ok(sourceStart >= 0 && sourceEnd > sourceStart); const source = { dataset: { tabId: "source" }, closest() { return this; }, getBoundingClientRect() { return { left: 0, width: 40 }; } }; const target = { dataset: { tabId: "target" }, closest() { return this; }, getBoundingClientRect() { return { left: 100, width: 100 }; } }; const marketTabs = { querySelectorAll() { return [source, target]; } }; let accepted = false; const requests = []; const harness = new Function( "marketTabs", "requestTabHoverSwap", ` let draggedTabId = "source"; let tabDragLastTargetId = null; let pendingTabHoverSwap = null; ${app.slice(sourceStart, sourceEnd)} return { dragover: onMarketTabDragOver, lastTarget: function () { return tabDragLastTargetId; }, setPending: function (value) { pendingTabHoverSwap = value; } }; `)(marketTabs, targetId => { requests.push(targetId); return accepted; }); const dragover = (eventTarget, clientX) => { let prevented = false; const dataTransfer = { dropEffect: "none" }; harness.dragover({ target: eventTarget, clientX, dataTransfer, preventDefault() { prevented = true; } }); assert.equal(prevented, true); assert.equal(dataTransfer.dropEffect, "move"); }; dragover(target, 159); assert.deepEqual(requests, []); assert.equal(harness.lastTarget(), null); // The threshold is crossed while the request gate rejects the operation. dragover(target, 161); assert.deepEqual(requests, ["target"]); assert.equal(harness.lastTarget(), null); // Once accepted, only the first repeated dragover for this crossing is sent. accepted = true; dragover(target, 170); dragover(target, 180); assert.deepEqual(requests, ["target", "target"]); assert.equal(harness.lastTarget(), "target"); // Re-entering the dragged source ends that crossing; a later target crossing is new. dragover(source, 20); assert.equal(harness.lastTarget(), null); dragover(target, 170); assert.deepEqual(requests, ["target", "target", "target"]); harness.setPending({}); dragover(source, 20); dragover(target, 170); assert.deepEqual(requests, ["target", "target", "target"]); }); test("fixed catalog renders C# view and sends opaque leaf or section intent", () => { assert.match(app, /state\.fixedCatalog/); assert.match(app, /dataset\.actionId/); assert.match(app, /dataset\.sectionIndex/); assert.match(app, /send\("activate-fixed-action"/); assert.match(app, /send\("activate-fixed-section"/); assert.match(app, /send\("activate-industry-section"/); assert.match(app, /send\("activate-comparison-section"/); assert.match(app, /send\("set-moving-averages"/); assert.doesNotMatch(app, /builderKey|sceneCode|cutCode|dataCode/); }); test("UC1 tree separates label selection, disclosure toggling and double-click action", () => { const doubleClickStart = app.indexOf('categoryTree.addEventListener("dblclick"'); const clickStart = app.indexOf( 'categoryTree.addEventListener("click"', doubleClickStart + 1); const changeStart = app.indexOf( 'categoryTree.addEventListener("change"', clickStart + 1); assert.ok(doubleClickStart >= 0 && clickStart > doubleClickStart && changeStart > clickStart); class DelegateTarget { constructor() { this.listeners = new Map(); } addEventListener(type, callback) { this.listeners.set(type, callback); } fire(type, target, detail = 1) { let prevented = false; this.listeners.get(type)({ target, detail, preventDefault() { prevented = true; } }); return prevented; } } function createNodes() { const details = { open: true }; const summary = { dataset: { treeNodeKey: "root|overseas|fixed:0", sectionIndex: "0" }, disabled: false, parentElement: details, getAttribute(name) { return name === "aria-disabled" ? "false" : null; } }; const disclosure = { closest(selector) { if (selector === "[data-tree-node-key]" || selector === "summary[data-tree-root]" || selector === "summary[data-section-index]") return summary; if (selector === "[data-tree-disclosure]") return this; return null; } }; const label = { closest(selector) { return selector === "[data-tree-node-key]" || selector === "summary[data-tree-root]" || selector === "summary[data-section-index]" ? summary : null; } }; const leaf = { dataset: { treeNodeKey: "leaf|overseas|fixed:0|fixed-262", actionId: "fixed-262" }, disabled: false, getAttribute() { return null; }, closest(selector) { return selector === "[data-tree-node-key]" || selector === ".tree-item[data-tree-node-key]" || selector === "button[data-action-id]" ? this : null; } }; return { details, summary, disclosure, label, leaf }; } function createHarness(locked) { const categoryTree = new DelegateTarget(); const sent = []; const selected = []; const expanded = []; new Function( "categoryTree", "send", "selectTreeNode", "syncTreeSectionExpanded", "treeInteractionLocked", app.slice(doubleClickStart, clickStart) + app.slice(clickStart, changeStart))( categoryTree, (type, payload) => sent.push({ type, payload }), node => selected.push(node.dataset.treeNodeKey), details => expanded.push(details.open), locked); return { categoryTree, sent, selected, expanded }; } const nodes = createNodes(); const harness = createHarness(false); assert.equal(harness.categoryTree.fire("click", nodes.label), true); assert.equal(nodes.details.open, true); assert.deepEqual(harness.sent, []); assert.equal(harness.categoryTree.fire("click", nodes.disclosure), true); assert.equal(nodes.details.open, false); assert.deepEqual(harness.expanded, [false]); harness.categoryTree.fire("dblclick", nodes.label, 2); harness.categoryTree.fire("dblclick", nodes.leaf, 2); assert.equal(nodes.details.open, false); assert.deepEqual(harness.sent, [ { type: "activate-fixed-section", payload: { sectionIndex: 0 } }, { type: "activate-fixed-action", payload: { actionId: "fixed-262" } } ]); const lockedNodes = createNodes(); const locked = createHarness(true); locked.categoryTree.fire("click", lockedNodes.disclosure); locked.categoryTree.fire("dblclick", lockedNodes.label, 2); locked.categoryTree.fire("dblclick", lockedNodes.leaf, 2); assert.equal(lockedNodes.details.open, true); assert.deepEqual(locked.sent, []); assert.deepEqual(locked.selected, []); }); test("UC1 tree render preserves state but resets Expand and first root on tab reentry", () => { const helperStart = app.indexOf("function activeTreeTabId"); const helperEnd = app.indexOf("function send(", helperStart); assert.ok(helperStart >= 0 && helperEnd > helperStart); class MiniClassList { constructor() { this.values = new Set(); } add(value) { this.values.add(value); } remove(value) { this.values.delete(value); } contains(value) { return this.values.has(value); } } const document = { activeElement: null }; class MiniNode { constructor(dataset = {}) { this.dataset = dataset; this.classList = new MiniClassList(); this.attributes = new Map(); this.disabled = false; this.textContent = ""; this.scrolled = false; } setAttribute(name, value) { this.attributes.set(name, String(value)); } getAttribute(name) { return this.attributes.get(name) ?? null; } focus() { document.activeElement = this; } scrollIntoView() { this.scrolled = true; } closest(selector) { return selector === "[data-tree-node-key]" ? this : null; } } class MiniDetails { constructor(sectionIndex, leafId) { this.dataset = {}; this.open = false; this.summary = new MiniNode({ sectionIndex: String(sectionIndex) }); this.summary.textContent = "[section-" + sectionIndex + "]"; this.summary.parentElement = this; this.summary.querySelector = selector => selector === "[data-tree-disclosure]" ? {} : null; this.leaf = new MiniNode({ actionId: leafId }); this.leaf.textContent = leafId; } querySelector(selector) { return selector.startsWith(":scope > summary") ? this.summary : null; } querySelectorAll(selector) { return selector === ".tree-item" ? [this.leaf] : []; } } class MiniTree { constructor() { this.details = []; this.scrollTop = 0; } replaceWith(details) { this.details = details; } querySelectorAll(selector) { if (selector === "details" || selector === "details[data-tree-section-key]") { return this.details; } if (selector === "summary[data-tree-root]") { return this.details.map(details => details.summary); } const nodes = this.details.flatMap(details => [details.summary, details.leaf]); if (selector === "[data-tree-node-key]") return nodes; if (selector === "[data-tree-node-key].tree-node-selected") { return nodes.filter(node => node.classList.contains("tree-node-selected")); } return []; } querySelector(selector) { if (selector === "[data-tree-node-key].tree-node-selected") { return this.querySelectorAll(selector)[0] || null; } return null; } contains(node) { return this.details.some(details => details.summary === node || details.leaf === node); } } const categoryTree = new MiniTree(); const catalogExpandAll = { checked: false }; const harness = new Function( "categoryTree", "catalogExpandAll", "document", "modalFocusManager", ` let renderedTreeTabId = null; let treeInteractionLocked = false; let treeFocusOwnedBeforeRender = false; const treeUiStateByTab = new Map(); const pendingTreeResetTabs = new Set(); ${app.slice(helperStart, helperEnd)} return { beginTreeRender, finishTreeRender, selectTreeNode }; `)(categoryTree, catalogExpandAll, document, { getActiveModal() { return null; } }); const state = tabId => ({ isBusy: false, fixedSectionBatch: null, tabs: [{ id: tabId, isActive: true }] }); const sections = () => [new MiniDetails(0, "fixed-1"), new MiniDetails(1, "fixed-2")]; let current = sections(); categoryTree.replaceWith(current); harness.finishTreeRender(harness.beginTreeRender(state("overseas"))); assert.equal(catalogExpandAll.checked, true); assert.ok(current.every(details => details.open)); assert.ok(current[0].summary.classList.contains("tree-node-selected")); assert.equal(document.activeElement, current[0].summary); assert.equal(current[0].summary.scrolled, true); current[0].open = false; harness.selectTreeNode(current[0].leaf, true); categoryTree.scrollTop = 42; const sameTab = harness.beginTreeRender(state("overseas")); current = sections(); categoryTree.replaceWith(current); harness.finishTreeRender(sameTab); assert.equal(current[0].open, false); assert.equal(current[1].open, true); assert.ok(current[0].leaf.classList.contains("tree-node-selected")); assert.equal(document.activeElement, current[0].leaf); assert.equal(categoryTree.scrollTop, 42); catalogExpandAll.checked = false; const reentry = harness.beginTreeRender(state("exchange")); current = sections(); categoryTree.replaceWith(current); harness.finishTreeRender(reentry); assert.equal(catalogExpandAll.checked, true); assert.ok(current.every(details => details.open)); assert.ok(current[0].summary.classList.contains("tree-node-selected")); assert.equal(document.activeElement, current[0].summary); }); test("UC1 Expand collapses all without clearing selection and is inert while locked", () => { const expandStart = app.indexOf('catalogExpandAll.addEventListener("change"'); const doubleClickStart = app.indexOf( 'categoryTree.addEventListener("dblclick"', expandStart + 1); assert.ok(expandStart >= 0 && doubleClickStart > expandStart); function createHarness(locked) { const selected = { value: true }; const details = [{ open: true }, { open: true }]; const categoryTree = { querySelectorAll(selector) { return selector === "details" ? details : []; } }; const catalogExpandAll = { checked: false, addEventListener(type, callback) { this.change = callback; } }; const expanded = []; new Function( "catalogExpandAll", "categoryTree", "syncTreeSectionExpanded", "treeInteractionLocked", app.slice(expandStart, doubleClickStart))( catalogExpandAll, categoryTree, row => expanded.push(row.open), locked); return { catalogExpandAll, details, expanded, selected }; } const normal = createHarness(false); normal.catalogExpandAll.change(); assert.deepEqual(normal.details.map(details => details.open), [false, false]); assert.deepEqual(normal.expanded, [false, false]); assert.equal(normal.selected.value, true); const locked = createHarness(true); locked.catalogExpandAll.change(); assert.deepEqual(locked.details.map(details => details.open), [true, true]); assert.equal(locked.catalogExpandAll.checked, true); assert.deepEqual(locked.expanded, []); assert.equal(locked.selected.value, true); }); test("fixed-262 ignores a single click and emits exactly one intent on double click", () => { const doubleClickStart = app.indexOf('categoryTree.addEventListener("dblclick"'); const clickStart = app.indexOf( 'categoryTree.addEventListener("click"', doubleClickStart + 1); const changeStart = app.indexOf( 'categoryTree.addEventListener("change"', clickStart + 1); assert.ok(doubleClickStart >= 0 && clickStart > doubleClickStart && changeStart > clickStart); class FixedActionElement extends EventTarget { constructor() { super(); this.dataset = { actionId: "fixed-262" }; this.disabled = false; } closest(selector) { return selector === "button[data-action-id]" ? this : null; } } const categoryTree = new FixedActionElement(); const sent = []; const send = (type, payload) => sent.push({ type, payload }); new Function("categoryTree", "send", app.slice(doubleClickStart, clickStart))( categoryTree, send); new Function("categoryTree", "send", app.slice(clickStart, changeStart))( categoryTree, send); categoryTree.dispatchEvent(new Event("click", { bubbles: true, cancelable: true })); assert.deepEqual(sent, []); categoryTree.dispatchEvent(new Event("dblclick", { bubbles: true, cancelable: true })); assert.deepEqual(sent, [{ type: "activate-fixed-action", payload: { actionId: "fixed-262" } }]); }); test("industry workspace sends index and opaque action intents only", () => { assert.match(app, /state\.industry/); assert.match(app, /dataset\.industryIndex/); assert.match(app, /dataset\.industryActionId/); assert.match(app, /send\("select-industry"/); assert.match(app, /send\("double-click-industry"/); assert.match(app, /send\("swap-industries"/); assert.match(app, /send\("activate-industry-action"/); }); test("comparison workspace sends opaque target, pair and action intents", () => { for (const intent of [ "search-comparison-domestic", "search-comparison-world", "select-comparison-domestic", "select-comparison-world", "select-comparison-fixed", "choose-comparison-domestic", "choose-comparison-world", "choose-comparison-fixed", "swap-comparison-targets", "clear-comparison-targets", "add-comparison-pair", "select-comparison-pair", "move-comparison-pair", "delete-selected-comparison-pair", "delete-all-comparison-pairs", "activate-comparison-action" ]) assert.match(app, new RegExp("\\\"" + intent + "\\\"")); assert.match(app, /dataset\.comparisonSelectionId/); assert.match(app, /dataset\.comparisonPairId/); assert.match(app, /dataset\.comparisonActionId/); assert.doesNotMatch(app, /send\([^\n]*stockCode/); }); test("theme workspace sends query, opaque selection, sort and action intents", () => { assert.match(app, /state\.theme/); assert.match(app, /dataset\.themeResultId/); assert.match(app, /dataset\.themeActionId/); assert.match(app, /send\("search-themes"/); assert.match(app, /send\("select-theme"/); assert.match(app, /send\("set-theme-sort"/); assert.match(app, /send\("activate-theme-action"/); }); test("expert and trading halt workspaces send opaque selection and action intents", () => { for (const intent of [ "search-experts", "select-expert", "activate-expert-action", "search-trading-halts", "select-trading-halt", "toggle-trading-halt-sort", "activate-trading-halt-action" ]) assert.match(app, new RegExp("send\\(\\\"" + intent + "\\\"")); assert.match(app, /dataset\.expertSelectionId/); assert.match(app, /dataset\.haltSelectionId/); }); test("theme expert and trading halt actions preserve original single click wiring", () => { const doubleClickStart = app.indexOf('categoryTree.addEventListener("dblclick"'); const clickStart = app.indexOf('categoryTree.addEventListener("click"', doubleClickStart + 1); const changeStart = app.indexOf('categoryTree.addEventListener("change"', clickStart + 1); assert.ok(doubleClickStart >= 0 && clickStart > doubleClickStart && changeStart > clickStart); const doubleClickDelegate = app.slice(doubleClickStart, clickStart); const clickDelegate = app.slice(clickStart, changeStart); for (const intent of [ "activate-theme-action", "activate-expert-action", "activate-trading-halt-action" ]) { assert.match(clickDelegate, new RegExp("send\\(\\\"" + intent + "\\\"")); assert.doesNotMatch(doubleClickDelegate, new RegExp("send\\(\\\"" + intent + "\\\"")); assert.equal(app.split('send("' + intent + '"').length - 1, 1); } assert.match(doubleClickDelegate, /send\("double-click-industry"/); for (const intent of [ "choose-comparison-domestic", "choose-comparison-world", "choose-comparison-fixed" ]) { assert.match(doubleClickDelegate, new RegExp("send\\(.*\\\"" + intent + "\\\"", "s")); assert.doesNotMatch(clickDelegate, new RegExp("send\\(.*\\\"" + intent + "\\\"", "s")); } for (const intent of [ "select-comparison-domestic", "select-comparison-world", "select-comparison-fixed" ]) { assert.match(clickDelegate, new RegExp("send\\(.*\\\"" + intent + "\\\"", "s")); assert.doesNotMatch(doubleClickDelegate, new RegExp("send\\(.*\\\"" + intent + "\\\"", "s")); } assert.match(clickDelegate, /send\("select-theme"/); assert.match(clickDelegate, /send\("select-expert"/); assert.match(clickDelegate, /send\("select-trading-halt"/); }); test("named DB playlists and playout use separate closed native intents", () => { for (const intent of [ "refresh-named-playlists", "select-named-playlist", "load-named-playlist-by-id", "create-named-playlist", "save-current-named-playlist-to", "delete-selected-named-playlist" ]) assert.match(app, new RegExp("\\\"" + intent + "\\\"")); assert.match(app, /state\.namedPlaylist/); assert.match(app, /dataset\.namedPlaylistDefinitionId/); assert.match(app, /beginNamedPlaylistSelection\(definition\.dataset\.namedPlaylistDefinitionId\)/); assert.match(app, /dbLoadButton\.disabled = operatorMutationLocked \|\| busy \|\| !named/); assert.match(app, /named\.isWriteQuarantined/); assert.doesNotMatch(app, /programCode|listText/i); for (const label of ["PREPARE", "TAKE IN", "NEXT", "TAKE OUT"]) { assert.match(markup, new RegExp("]*disabled[^>]*>[^<]*" + label)); } for (const intent of [ "prepare-playout", "take-in", "next-playout", "take-out", "choose-background", "toggle-background" ]) assert.match(playout, new RegExp("\\\"" + intent + "\\\"")); assert.doesNotMatch(playout, /post\("set-fade-duration"/); assert.doesNotMatch(playout, /fadeDuration\.addEventListener/); assert.match(playout, /fadeDuration\.disabled = true;/); assert.match(playout, /fadeDuration\.value = String\(playout\.fadeDuration\);/); assert.match(markup, /id="playout-fade-duration" disabled hidden aria-hidden="true" tabindex="-1"/); assert.match(playout, /takeOut\.disabled = busy \|\| !commandAvailable;/); assert.match(playout, /playout\.isPlayCompletionPending === true/); assert.match(playout, /playout\.outcomeUnknown !== true/); assert.doesNotMatch(playout, /fetch\s*\(|localStorage|sessionStorage|sceneCode|filePath/); }); test("GraphE preserves name header sorting and directional find in native authority", () => { for (const intent of [ "find-manual-financial", "toggle-manual-financial-name-sort" ]) assert.match(app, new RegExp("\\\"" + intent + "\\\"")); assert.match(app, /id = "manual-financial-find-query"/); assert.match(app, /dataset\.manualFindDirection/); assert.match(app, /dataset\.manualNameSort/); assert.match(app, /dataset\.manualGeneration/); assert.match(app, /generation: generation/); assert.match(app, /manual-financial-find-query[\s\S]*down\.click\(\)/); assert.doesNotMatch(app, /(?:INPUT_PIE|INPUT_GROW|INPUT_SELL|INPUT_PROFIT)[\s\S]*manual-financial-find-query/); }); test("FSell and VI dialogs send opaque native intents and never own file or stock authority", () => { for (const id of ["manual-list-modal", "manual-list-workspace", "manual-list-close"]) { assert.match(markup, new RegExp("id=\\\"" + id + "\\\"")); } for (const screen of ["individual", "foreign", "institution", "vi"]) { assert.match(markup, new RegExp("data-manual-list-screen=\\\"" + screen + "\\\"")); } for (const intent of [ "open-manual-list", "close-manual-list", "refresh-manual-list", "set-manual-net-sell-cell", "search-manual-vi", "select-manual-vi-result", "add-manual-vi-result", "move-manual-vi-item", "delete-manual-vi-item", "delete-all-manual-vi-items", "confirm-manual-list", "import-manual-lists" ]) assert.match(app, new RegExp("\\\"" + intent + "\\\"")); for (const obsoleteIntent of ["save-manual-net-sell", "add-manual-net-sell-cut", "save-manual-vi"]) { assert.doesNotMatch(app, new RegExp("\\\"" + obsoleteIntent + "\\\"")); } const start = app.indexOf("function renderManualLists"); const end = app.indexOf("function openNamedPlaylist", start); const manualLists = app.slice(start, end); assert.match(manualLists, /rowId: row\.rowId/); assert.match(manualLists, /resultId: row\.resultId/); assert.match(manualLists, /itemId: row\.itemId/); assert.match(manualLists, /addEventListener\("click"[\s\S]*setTimeout[\s\S]*send\("select-manual-vi-result"/); assert.match(manualLists, /addEventListener\("dblclick"[\s\S]*send\("add-manual-vi-result"/); assert.doesNotMatch(manualLists, /(?:rowVersion|stockCode|item\.code|filePath|directory|localStorage|fetch\s*\()/i); }); test("separator rows preserve the original drag gesture", () => { assert.match(app, /element\.dataset\.cutDragEnabled = cutSelectionEnabled \? "true" : "false"/); assert.match(app, /element\.draggable = false/); assert.doesNotMatch(app, /cutDragEnabled = !row\.isSeparator/); }); test("a C# busy snapshot freezes controls instead of silently accepting queued input", () => { const styles = fs.readFileSync(path.join(webRoot, "styles.css"), "utf8"); assert.match(app, /state\.isBusy === true/); assert.match(app, /classList\.toggle\("busy"/); assert.match(styles, /body\.busy \.operator-shell \{ pointer-events: none; \}/); });