1535 lines
59 KiB
JavaScript
1535 lines
59 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="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, /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.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; \}/);
|
|
});
|