Files
MBN_STOCK_WEBVIEW/tests/LegacyParityWeb/thin-client.test.cjs

1539 lines
60 KiB
JavaScript

"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 class="workspace-nav-label">해외<\/span>[\s\S]*?<\/button>/);
assert.match(markup,
/data-tab-id="tradingHalt"[\s\S]*?<span class="workspace-nav-label">정지<\/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 selection active while append drag stays locked", () => {
const sourceStart = app.indexOf("function isCutSelectionLocked");
const sourceEnd = app.indexOf("function dragDropPosition", sourceStart);
assert.ok(sourceStart >= 0 && sourceEnd > sourceStart);
const isCutSelectionLocked = new Function(`
${app.slice(sourceStart, sourceEnd)}
return isCutSelectionLocked;
`)();
assert.equal(isCutSelectionLocked({
isBusy: false,
fixedSectionBatch: null,
playout: {
phase: "program",
isBusy: false,
outcomeUnknown: false,
isPlayCompletionPending: false,
isTakeOutCompletionPending: false
}
}), false);
assert.equal(isCutSelectionLocked({
isBusy: false,
fixedSectionBatch: null,
playout: {
phase: "program",
isBusy: true,
outcomeUnknown: false,
isPlayCompletionPending: false,
isTakeOutCompletionPending: false
}
}), true);
});
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('<script src="app.js"></script>');
const playoutScript = markup.indexOf('<script src="playout-ui.js"></script>');
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 \(!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 reorder starts only from the 30 px row header pointer handle", () => {
assert.match(markup,
/playlist-header playlist-row">\s*<div aria-hidden="true"><\/div><div>송출<\/div>/u);
assert.match(styles,
/\.playlist-row\s*\{[^}]*grid-template-columns:\s*30px 40px 100px/u);
assert.match(styles, /\.playlist-data-row \.playlist-row-header/u);
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();
this.bounds = { left: 0, top: 0, width: 30, height: 20, right: 30, bottom: 20 };
this.capturedPointerId = null;
}
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) {
if (selector.startsWith(".")) {
const className = selector.slice(1);
return this.classList.contains(className)
? this
: this.parentElement?.closest(selector) || null;
}
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 this.bounds; }
setPointerCapture(pointerId) { this.capturedPointerId = pointerId; }
hasPointerCapture(pointerId) { return this.capturedPointerId === pointerId; }
releasePointerCapture(pointerId) {
if (this.capturedPointerId === pointerId) this.capturedPointerId = null;
}
setAttribute(name, value) { this.attributes.set(name, String(value)); }
getAttribute(name) { return this.attributes.get(name) ?? null; }
querySelector(selector) {
const descendants = this.querySelectorAll("*");
if (selector === "input") {
return descendants.find((element) => element.tagName === "INPUT") || null;
}
if (selector.startsWith(".")) {
return descendants.find((element) =>
element.classList.contains(selector.slice(1))) || null;
}
return null;
}
querySelectorAll(selector) {
const descendants = [];
const visit = (element) => {
element.children.forEach((child) => {
descendants.push(child);
visit(child);
});
};
visit(this);
if (selector === "*") return descendants;
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 [];
}
}
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", "window", `
let deferredPlaylistPlainSelectionRowId = null;
let playlistDragGesture = null;
let playlistMutationLocked = false;
let cutDragSize = { width: 4, height: 4 };
function dragDropPosition(event, element) {
const bounds = element.getBoundingClientRect();
return event.clientY < bounds.top + bounds.height / 2 ? "before" : "after";
}
${app.slice(sourceStart, sourceEnd)}
return {
createPlaylistElement,
state: () => ({ playlistDragGesture, deferredPlaylistPlainSelectionRowId })
};
`);
let pointedElement = null;
const document = {
createElement: (tagName) => new MiniElement(tagName),
elementFromPoint: () => pointedElement
};
const playlistRows = new MiniElement("div");
playlistRows.bounds = { left: 0, top: 0, width: 500, height: 100,
right: 500, bottom: 100 };
const sent = [];
const legacyCutDrag = require(path.join(webRoot, "legacy-cut-drag.js"));
const harness = createHarness(
document, playlistRows, (type, payload) => sent.push({ type, payload }),
{ LegacyCutDrag: legacyCutDrag });
const source = harness.createPlaylistElement({ rowId: "row-a" });
source.bounds = { left: 0, top: 0, width: 500, height: 20, right: 500, bottom: 20 };
source.setAttribute("aria-selected", "true");
playlistRows.appendChild(source);
const selectedPeer = harness.createPlaylistElement({ rowId: "row-b" });
selectedPeer.bounds = { left: 0, top: 20, width: 500, height: 20,
right: 500, bottom: 40 };
selectedPeer.setAttribute("aria-selected", "true");
playlistRows.appendChild(selectedPeer);
const target = harness.createPlaylistElement({ rowId: "row-c" });
target.bounds = { left: 0, top: 40, width: 500, height: 20,
right: 500, bottom: 60 };
target.setAttribute("aria-selected", "false");
playlistRows.appendChild(target);
const sourceHeader = source.children[0];
const checkbox = source.children[1].children[0];
const targetHeader = target.children[0];
sourceHeader.bounds = { left: 0, top: 0, width: 30, height: 20,
right: 30, bottom: 20 };
targetHeader.bounds = { left: 0, top: 40, width: 30, height: 20,
right: 30, bottom: 60 };
assert.equal(source.draggable, false);
assert.equal(checkbox.draggable, false);
assert.equal(source.listeners.has("pointermove"), false);
assert.equal(sourceHeader.listeners.has("pointermove"), true);
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;
assert.equal(harness.state().playlistDragGesture, null);
const dataPointerDown = {
button: 0, pointerId: 1, clientX: 100, clientY: 10,
ctrlKey: false, shiftKey: false, target: source, currentTarget: source
};
source.invoke("pointerdown", dataPointerDown);
assert.equal(harness.state().deferredPlaylistPlainSelectionRowId, "row-a");
assert.equal(harness.state().playlistDragGesture, null);
source.invoke("pointerup", { ...dataPointerDown, button: 0 });
assert.deepEqual(sent, [{
type: "select-playlist-row",
payload: { rowId: "row-a", control: false, shift: false }
}]);
sent.length = 0;
const pointerDown = {
button: 0, pointerId: 7, clientX: 10, clientY: 10,
ctrlKey: false, shiftKey: false, target: sourceHeader,
stopPropagation() {}
};
sourceHeader.invoke("pointerdown", { ...pointerDown, currentTarget: sourceHeader });
assert.deepEqual(sent, [{
type: "activate-playlist-row",
payload: { rowId: "row-a" }
}]);
sent.length = 0;
assert.equal(sourceHeader.hasPointerCapture(7), true);
assert.equal(harness.state().playlistDragGesture.started, false);
// Crossing the system drag rectangle over a normal data cell starts the
// gesture but cannot establish a drop target.
pointedElement = target.children[2];
sourceHeader.invoke("pointermove", {
pointerId: 7, buttons: 1, clientX: 100, clientY: 45,
currentTarget: sourceHeader, preventDefault() {}
});
assert.equal(harness.state().playlistDragGesture.started, true);
assert.equal(harness.state().playlistDragGesture.targetRowId, null);
sourceHeader.invoke("pointerup", {
pointerId: 7, button: 0, clientX: 100, clientY: 45,
currentTarget: sourceHeader, preventDefault() {}
});
assert.deepEqual(sent, []);
// A second gesture dropped on another row's 30 px header sends the existing
// stable-row C# contract exactly once.
sourceHeader.invoke("pointerdown", { ...pointerDown, pointerId: 8,
currentTarget: sourceHeader });
assert.deepEqual(sent, [{
type: "activate-playlist-row",
payload: { rowId: "row-a" }
}]);
sent.length = 0;
pointedElement = targetHeader;
sourceHeader.invoke("pointermove", {
pointerId: 8, buttons: 1, clientX: 10, clientY: 45,
currentTarget: sourceHeader, preventDefault() {}
});
sourceHeader.invoke("pointerup", {
pointerId: 8, button: 0, clientX: 10, clientY: 45,
currentTarget: sourceHeader, preventDefault() {}
});
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);
assert.equal(sourceHeader.hasPointerCapture(8), false);
assert.equal(harness.state().playlistDragGesture, null);
});
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.match(app, /button\.querySelector\("\.workspace-nav-label"\)/);
assert.match(app, /LegacyWorkspaceNavigation\?\.syncMarket/);
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;
let themeResultClickTimer = null;
const treeUiStateByTab = new Map();
const pendingTreeResetTabs = new Set();
function clearDelayedClick() {}
${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", () => {
const renderStart = app.indexOf("function renderIndustry");
const renderEnd = app.indexOf("function renderOverseas", renderStart);
const industryRender = app.slice(renderStart, renderEnd);
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\("set-industry-comparison-text"/);
assert.match(app, /send\("activate-industry-action"/);
assert.doesNotMatch(app, /swap\.textContent = "↕ 교환"/);
assert.match(app,
/summary\.textContent = industry\.market === "kospi" \? "\[코스피\]" : "\[코스닥\]"/);
assert.match(app, /summary\.dataset\.industrySectionIndex = "0"/);
assert.doesNotMatch(app, /const sections = new Map\(\)/);
assert.doesNotMatch(industryRender, /first\.readOnly = true/);
assert.doesNotMatch(industryRender, /second\.readOnly = true/);
assert.match(industryRender, /first\.maxLength = 128/);
assert.match(industryRender, /second\.maxLength = 128/);
});
test("UC2 row selection is immediate and its state receipt preserves the double-click target", () => {
const helperStart = app.indexOf("function markIndustryRowSelected");
const helperEnd = app.indexOf("function renderIndustry", helperStart);
assert.ok(helperStart >= 0 && helperEnd > helperStart);
function row(index) {
return {
id: `industry-row-${index}`,
dataset: { industryIndex: String(index) },
list: null,
selected: false,
classList: {
toggle(_name, selected) { this.owner.selected = selected; },
owner: null
},
setAttribute(name, value) {
if (name === "aria-selected") this.ariaSelected = value;
},
closest(selector) {
return selector === ".industry-list" ? this.list : null;
}
};
}
const first = row(0);
const second = row(1);
first.classList.owner = first;
second.classList.owner = second;
const categoryTree = {
querySelectorAll() { return [first, second]; }
};
const industryList = {
focus(options) { this.focusOptions = options; },
setAttribute(name, value) {
if (name === "aria-activedescendant") this.activeDescendant = value;
}
};
first.list = industryList;
second.list = industryList;
const sent = [];
const helpers = new Function(
"categoryTree",
"send",
`
${app.slice(helperStart, helperEnd)}
return { selectIndustryRow, activateIndustryRow };
`)(
categoryTree,
(type, payload) => sent.push({ type, payload }));
helpers.selectIndustryRow(second);
assert.equal(second.selected, true);
assert.equal(first.selected, false);
assert.deepEqual(sent, [
{ type: "select-industry", payload: { index: 1 } }
]);
helpers.activateIndustryRow(second);
assert.deepEqual(sent[1], {
type: "double-click-industry", payload: { index: 1 }
});
const renderStart = app.indexOf("function renderIndustry");
const renderEnd = app.indexOf("function renderOverseas", renderStart);
const render = app.slice(renderStart, renderEnd);
assert.match(render, /structureKey === industryRenderStructureKey/);
assert.match(render, /existingWorkspace\.querySelectorAll/);
assert.match(render, /previousList\.scrollTop/);
assert.match(render, /list\.scrollTop = previousScrollTop/);
assert.match(render, /replacement\.focus\(\{ preventScroll: true \}\)/);
});
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/);
for (const label of ["◀▶", "추가", "UP", "DOWN", "DEL", "DEL ALL"]) {
assert.match(app, new RegExp("\\\"" + label + "\\\""));
}
assert.doesNotMatch(app, /clear\.dataset\.comparisonClear/);
assert.doesNotMatch(app, /window\.confirm\("저장된 종목 비교쌍을 모두 삭제하겠습니까\?"\)/);
assert.match(app, /\? "confirm-native-dialog"/);
assert.match(app, /send\("cancel-native-dialog", \{\}\)/);
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, /activateIndustryRow\(industryRow\)/);
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("<button[^>]*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 && !cutDragLocked/);
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; \}/);
});