feat: restore legacy modal and drag behavior
This commit is contained in:
140
tests/LegacyParityWeb/legacy-cut-drag.test.cjs
Normal file
140
tests/LegacyParityWeb/legacy-cut-drag.test.cjs
Normal file
@@ -0,0 +1,140 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const repositoryRoot = path.resolve(__dirname, "..", "..");
|
||||
const cutDrag = require(path.join(
|
||||
repositoryRoot,
|
||||
"src",
|
||||
"MBN_STOCK_WEBVIEW.LegacyParityApp",
|
||||
"Web",
|
||||
"legacy-cut-drag.js"));
|
||||
|
||||
test("creates the original asymmetric mouse-down rectangle", () => {
|
||||
const rectangle = cutDrag.createDragRectangle(
|
||||
{ x: 100, y: 80 },
|
||||
{ width: 10, height: 6 });
|
||||
|
||||
assert.deepEqual(rectangle, {
|
||||
left: 90,
|
||||
top: 74,
|
||||
width: 10,
|
||||
height: 6,
|
||||
right: 100,
|
||||
bottom: 80
|
||||
});
|
||||
assert.equal(Object.isFrozen(rectangle), true);
|
||||
});
|
||||
|
||||
test("matches Rectangle.Contains right/bottom-exclusive drag decisions", () => {
|
||||
const down = { x: 100, y: 80 };
|
||||
const rectangle = cutDrag.createDragRectangle(
|
||||
down,
|
||||
{ width: 10, height: 6 });
|
||||
|
||||
assert.equal(cutDrag.shouldStart(rectangle, down), true, "same point");
|
||||
assert.equal(
|
||||
cutDrag.shouldStart(rectangle, { x: 101, y: 79 }), true, "right");
|
||||
assert.equal(
|
||||
cutDrag.shouldStart(rectangle, { x: 99, y: 81 }), true, "down");
|
||||
assert.equal(
|
||||
cutDrag.shouldStart(rectangle, { x: 89, y: 79 }), true, "left boundary crossed");
|
||||
assert.equal(
|
||||
cutDrag.shouldStart(rectangle, { x: 99, y: 73 }), true, "top boundary crossed");
|
||||
assert.equal(
|
||||
cutDrag.shouldStart(rectangle, { x: 90, y: 74 }), false, "inside upper-left");
|
||||
|
||||
assert.equal(
|
||||
cutDrag.shouldStart(rectangle, { x: 90, y: 79 }), false, "left is inclusive");
|
||||
assert.equal(
|
||||
cutDrag.shouldStart(rectangle, { x: 99, y: 74 }), false, "top is inclusive");
|
||||
assert.equal(
|
||||
cutDrag.shouldStart(rectangle, { x: 100, y: 79 }), true, "right is exclusive");
|
||||
assert.equal(
|
||||
cutDrag.shouldStart(rectangle, { x: 99, y: 80 }), true, "bottom is exclusive");
|
||||
});
|
||||
|
||||
test("validates finite coordinates and positive finite drag sizes", () => {
|
||||
const validSize = { width: 4, height: 4 };
|
||||
|
||||
for (const down of [
|
||||
null,
|
||||
{ x: Number.NaN, y: 0 },
|
||||
{ x: 0, y: Number.POSITIVE_INFINITY },
|
||||
{ x: "0", y: 0 }
|
||||
]) {
|
||||
assert.throws(
|
||||
() => cutDrag.createDragRectangle(down, validSize),
|
||||
TypeError);
|
||||
}
|
||||
|
||||
for (const dragSize of [
|
||||
null,
|
||||
{ width: 0, height: 1 },
|
||||
{ width: 1, height: -1 },
|
||||
{ width: Number.NaN, height: 1 },
|
||||
{ width: 1, height: Number.POSITIVE_INFINITY }
|
||||
]) {
|
||||
assert.throws(
|
||||
() => cutDrag.createDragRectangle({ x: 0, y: 0 }, dragSize),
|
||||
RangeError);
|
||||
}
|
||||
|
||||
const rectangle = cutDrag.createDragRectangle(
|
||||
{ x: 4, y: 4 },
|
||||
validSize);
|
||||
assert.throws(
|
||||
() => cutDrag.shouldStart(rectangle, { x: Number.NaN, y: 0 }),
|
||||
TypeError);
|
||||
});
|
||||
|
||||
test("maps DIP metrics to CSS pixels at 100 and 150 percent host DPI", () => {
|
||||
assert.deepEqual(cutDrag.toCssDragSize(
|
||||
{ width: 4, height: 6 }, 1, 1),
|
||||
{ width: 4, height: 6 });
|
||||
assert.deepEqual(cutDrag.toCssDragSize(
|
||||
{ width: 4, height: 6 }, 1.5, 1.5),
|
||||
{ width: 4, height: 6 });
|
||||
});
|
||||
|
||||
test("accounts for a retained WebView browser zoom in pointer CSS coordinates", () => {
|
||||
assert.deepEqual(cutDrag.toCssDragSize(
|
||||
{ width: 4, height: 6 }, 1.5, 1.875),
|
||||
{ width: 3.2, height: 4.8 });
|
||||
assert.deepEqual(cutDrag.toCssDragSize(
|
||||
{ width: 4, height: 6 }, 1, 1.25),
|
||||
{ width: 3.2, height: 4.8 });
|
||||
});
|
||||
|
||||
test("keeps fractional CSS boundaries after retained zoom conversion", () => {
|
||||
const size = cutDrag.toCssDragSize({ width: 4, height: 4 }, 1, 1.25);
|
||||
const rectangle = cutDrag.createDragRectangle({ x: 10.5, y: 10.5 }, size);
|
||||
|
||||
assert.equal(cutDrag.shouldStart(rectangle, { x: 7.31, y: 7.31 }), false);
|
||||
assert.equal(cutDrag.shouldStart(rectangle, { x: 7.29, y: 7.31 }), true);
|
||||
assert.equal(cutDrag.shouldStart(rectangle, { x: 7.31, y: 7.29 }), true);
|
||||
assert.equal(cutDrag.shouldStart(rectangle, { x: 10.5, y: 10.49 }), true);
|
||||
});
|
||||
|
||||
test("uses a neutral zoom ratio for missing or abnormal scale measurements", () => {
|
||||
for (const scales of [
|
||||
[0, 0],
|
||||
[Number.NaN, Number.NaN],
|
||||
[1, Number.POSITIVE_INFINITY],
|
||||
[1, 0.1],
|
||||
[1, 10]
|
||||
]) {
|
||||
assert.deepEqual(cutDrag.toCssDragSize(
|
||||
{ width: 4, height: 6 }, scales[0], scales[1]),
|
||||
{ width: 4, height: 6 });
|
||||
}
|
||||
});
|
||||
|
||||
test("publishes the same API as a browser global", () => {
|
||||
assert.equal(globalThis.LegacyCutDrag, cutDrag);
|
||||
assert.equal(typeof cutDrag.createDragRectangle, "function");
|
||||
assert.equal(typeof cutDrag.shouldStart, "function");
|
||||
assert.equal(typeof cutDrag.toCssDragSize, "function");
|
||||
});
|
||||
294
tests/LegacyParityWeb/legacy-modal-focus.test.cjs
Normal file
294
tests/LegacyParityWeb/legacy-modal-focus.test.cjs
Normal file
@@ -0,0 +1,294 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const repositoryRoot = path.resolve(__dirname, "..", "..");
|
||||
const modulePath = path.join(repositoryRoot, "src",
|
||||
"MBN_STOCK_WEBVIEW.LegacyParityApp", "Web", "legacy-modal-focus.js");
|
||||
const modalFocus = require(modulePath);
|
||||
|
||||
class FakeElement {
|
||||
constructor(documentObject, tagName, attributes) {
|
||||
this.ownerDocument = documentObject;
|
||||
this.tagName = String(tagName || "div").toUpperCase();
|
||||
this.parentElement = null;
|
||||
this.children = [];
|
||||
this.hidden = false;
|
||||
this.inert = false;
|
||||
this.disabled = false;
|
||||
this.style = { display: "block", visibility: "visible", contentVisibility: "visible" };
|
||||
this.attributes = new Map();
|
||||
Object.entries(attributes || {}).forEach(([name, value]) => {
|
||||
this.attributes.set(name, String(value));
|
||||
});
|
||||
}
|
||||
|
||||
append(...elements) {
|
||||
elements.forEach(element => {
|
||||
element.parentElement = this;
|
||||
this.children.push(element);
|
||||
});
|
||||
}
|
||||
|
||||
contains(element) {
|
||||
return element === this || this.children.some(child => child.contains(element));
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.ownerDocument.activeElement = this;
|
||||
}
|
||||
|
||||
getAttribute(name) {
|
||||
return this.attributes.has(name) ? this.attributes.get(name) : null;
|
||||
}
|
||||
|
||||
hasAttribute(name) {
|
||||
return this.attributes.has(name);
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
}
|
||||
|
||||
querySelector(selector) {
|
||||
const descendants = this.querySelectorAll("*");
|
||||
if (selector.includes("data-modal-initial-focus")) {
|
||||
return descendants.find(element =>
|
||||
element.hasAttribute("data-modal-initial-focus") || element.hasAttribute("autofocus")) || null;
|
||||
}
|
||||
if (selector[0] === "#") {
|
||||
return descendants.find(element => element.getAttribute("id") === selector.slice(1)) || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
querySelectorAll() {
|
||||
return this.children.flatMap(child => [child, ...child.querySelectorAll("*")]);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDocument {
|
||||
constructor() {
|
||||
this.body = new FakeElement(this, "body");
|
||||
this.activeElement = this.body;
|
||||
this.listeners = new Map();
|
||||
this.defaultView = {
|
||||
getComputedStyle(element) { return element.style; }
|
||||
};
|
||||
}
|
||||
|
||||
addEventListener(type, listener) {
|
||||
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
||||
this.listeners.get(type).add(listener);
|
||||
}
|
||||
|
||||
removeEventListener(type, listener) {
|
||||
if (this.listeners.has(type)) this.listeners.get(type).delete(listener);
|
||||
}
|
||||
|
||||
contains(element) {
|
||||
return this.body.contains(element);
|
||||
}
|
||||
|
||||
querySelectorAll() {
|
||||
return this.body.querySelectorAll("*").filter(element => {
|
||||
const role = element.getAttribute("role");
|
||||
return role === "dialog" || role === "alertdialog";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function element(documentObject, tagName, attributes) {
|
||||
return new FakeElement(documentObject, tagName, attributes);
|
||||
}
|
||||
|
||||
function modalFixture() {
|
||||
const documentObject = new FakeDocument();
|
||||
const opener = element(documentObject, "button", { id: "opener" });
|
||||
const backdrop = element(documentObject, "div");
|
||||
const dialog = element(documentObject, "section", { role: "dialog" });
|
||||
const first = element(documentObject, "button", { id: "first" });
|
||||
const preferred = element(documentObject, "input", { id: "preferred" });
|
||||
const last = element(documentObject, "button", { id: "last" });
|
||||
dialog.append(first, preferred, last);
|
||||
backdrop.append(dialog);
|
||||
documentObject.body.append(opener, backdrop);
|
||||
backdrop.hidden = true;
|
||||
opener.focus();
|
||||
return { documentObject, opener, backdrop, dialog, first, preferred, last };
|
||||
}
|
||||
|
||||
function keyEvent(key, shiftKey) {
|
||||
return {
|
||||
key,
|
||||
shiftKey: !!shiftKey,
|
||||
defaultPrevented: false,
|
||||
preventDefault() { this.defaultPrevented = true; }
|
||||
};
|
||||
}
|
||||
|
||||
test("exports the same API as a CommonJS module and a browser global", () => {
|
||||
assert.equal(typeof modalFocus.create, "function");
|
||||
const source = fs.readFileSync(modulePath, "utf8");
|
||||
const context = { globalThis: {} };
|
||||
vm.runInNewContext(source, context);
|
||||
assert.equal(typeof context.globalThis.LegacyModalFocus.create, "function");
|
||||
});
|
||||
|
||||
test("selects the last visible modal and ignores hidden modal ancestors", () => {
|
||||
const fixture = modalFixture();
|
||||
const alertBackdrop = element(fixture.documentObject, "div");
|
||||
const alert = element(fixture.documentObject, "div", { role: "alertdialog" });
|
||||
alertBackdrop.append(alert);
|
||||
fixture.documentObject.body.append(alertBackdrop);
|
||||
|
||||
assert.equal(modalFocus.findVisibleModal(fixture.documentObject), alert);
|
||||
alertBackdrop.hidden = true;
|
||||
assert.equal(modalFocus.findVisibleModal(fixture.documentObject), null);
|
||||
fixture.backdrop.hidden = false;
|
||||
assert.equal(modalFocus.findVisibleModal(fixture.documentObject), fixture.dialog);
|
||||
});
|
||||
|
||||
test("focusable discovery excludes disabled, hidden, inert, and negative-tab controls", () => {
|
||||
const fixture = modalFixture();
|
||||
fixture.backdrop.hidden = false;
|
||||
fixture.first.disabled = true;
|
||||
fixture.preferred.setAttribute("tabindex", "-1");
|
||||
const hiddenParent = element(fixture.documentObject, "div");
|
||||
hiddenParent.hidden = true;
|
||||
const hiddenButton = element(fixture.documentObject, "button");
|
||||
hiddenParent.append(hiddenButton);
|
||||
const inertParent = element(fixture.documentObject, "div", { inert: "" });
|
||||
const inertButton = element(fixture.documentObject, "button");
|
||||
inertParent.append(inertButton);
|
||||
fixture.dialog.append(hiddenParent, inertParent);
|
||||
|
||||
assert.deepEqual(modalFocus.getFocusableElements(
|
||||
fixture.dialog, fixture.documentObject), [fixture.last]);
|
||||
});
|
||||
|
||||
test("open focuses the preferred control and close restores the opener", () => {
|
||||
const fixture = modalFixture();
|
||||
const manager = modalFocus.create({ document: fixture.documentObject });
|
||||
fixture.backdrop.hidden = false;
|
||||
manager.sync("#preferred");
|
||||
assert.equal(fixture.documentObject.activeElement, fixture.preferred);
|
||||
assert.equal(manager.getActiveModal(), fixture.dialog);
|
||||
|
||||
fixture.backdrop.hidden = true;
|
||||
manager.sync();
|
||||
assert.equal(fixture.documentObject.activeElement, fixture.opener);
|
||||
assert.equal(manager.getActiveModal(), null);
|
||||
});
|
||||
|
||||
test("open falls back to the first enabled visible control", () => {
|
||||
const fixture = modalFixture();
|
||||
const manager = modalFocus.create({ document: fixture.documentObject });
|
||||
fixture.backdrop.hidden = false;
|
||||
manager.sync("#missing");
|
||||
assert.equal(fixture.documentObject.activeElement, fixture.first);
|
||||
});
|
||||
|
||||
test("Tab and Shift+Tab wrap inside the active modal without handling Escape", () => {
|
||||
const fixture = modalFixture();
|
||||
const manager = modalFocus.create({ document: fixture.documentObject });
|
||||
fixture.backdrop.hidden = false;
|
||||
manager.sync();
|
||||
|
||||
fixture.last.focus();
|
||||
const forward = keyEvent("Tab", false);
|
||||
assert.equal(manager.handleKeydown(forward), true);
|
||||
assert.equal(forward.defaultPrevented, true);
|
||||
assert.equal(fixture.documentObject.activeElement, fixture.first);
|
||||
|
||||
const backward = keyEvent("Tab", true);
|
||||
assert.equal(manager.handleKeydown(backward), true);
|
||||
assert.equal(backward.defaultPrevented, true);
|
||||
assert.equal(fixture.documentObject.activeElement, fixture.last);
|
||||
|
||||
fixture.preferred.focus();
|
||||
const middle = keyEvent("Tab", false);
|
||||
assert.equal(manager.handleKeydown(middle), false);
|
||||
assert.equal(middle.defaultPrevented, false);
|
||||
assert.equal(fixture.documentObject.activeElement, fixture.preferred);
|
||||
|
||||
const escape = keyEvent("Escape", false);
|
||||
assert.equal(manager.handleKeydown(escape), false);
|
||||
assert.equal(escape.defaultPrevented, false);
|
||||
assert.equal(manager.getActiveModal(), fixture.dialog);
|
||||
});
|
||||
|
||||
test("a dialog without controls retains Tab focus on its container", () => {
|
||||
const documentObject = new FakeDocument();
|
||||
const dialog = element(documentObject, "section", { role: "dialog" });
|
||||
documentObject.body.append(dialog);
|
||||
const manager = modalFocus.create({ document: documentObject });
|
||||
manager.sync();
|
||||
const tab = keyEvent("Tab", false);
|
||||
assert.equal(manager.handleKeydown(tab), true);
|
||||
assert.equal(tab.defaultPrevented, true);
|
||||
assert.equal(dialog.getAttribute("tabindex"), "-1");
|
||||
assert.equal(documentObject.activeElement, dialog);
|
||||
});
|
||||
|
||||
test("closing a nested alert restores its modal opener before the root opener", () => {
|
||||
const fixture = modalFixture();
|
||||
const manager = modalFocus.create({ document: fixture.documentObject });
|
||||
fixture.backdrop.hidden = false;
|
||||
manager.sync("#preferred");
|
||||
|
||||
const alertBackdrop = element(fixture.documentObject, "div");
|
||||
const alert = element(fixture.documentObject, "div", { role: "alertdialog" });
|
||||
const alertOk = element(fixture.documentObject, "button", { id: "alert-ok" });
|
||||
alert.append(alertOk);
|
||||
alertBackdrop.append(alert);
|
||||
fixture.documentObject.body.append(alertBackdrop);
|
||||
manager.sync("#alert-ok");
|
||||
assert.equal(fixture.documentObject.activeElement, alertOk);
|
||||
|
||||
alertBackdrop.hidden = true;
|
||||
manager.sync();
|
||||
assert.equal(manager.getActiveModal(), fixture.dialog);
|
||||
assert.equal(fixture.documentObject.activeElement, fixture.preferred);
|
||||
|
||||
fixture.backdrop.hidden = true;
|
||||
manager.sync();
|
||||
assert.equal(fixture.documentObject.activeElement, fixture.opener);
|
||||
});
|
||||
|
||||
test("rememberOpener preserves the owner when application code changes focus during open", () => {
|
||||
const fixture = modalFixture();
|
||||
const manager = modalFocus.create({ document: fixture.documentObject });
|
||||
manager.rememberOpener(fixture.dialog);
|
||||
fixture.backdrop.hidden = false;
|
||||
fixture.first.focus();
|
||||
manager.sync("#preferred");
|
||||
fixture.backdrop.hidden = true;
|
||||
manager.sync();
|
||||
assert.equal(fixture.documentObject.activeElement, fixture.opener);
|
||||
});
|
||||
|
||||
test("an opener resolver restores a logically equivalent control after DOM replacement", () => {
|
||||
const fixture = modalFixture();
|
||||
const manager = modalFocus.create({ document: fixture.documentObject });
|
||||
let currentOpener = fixture.opener;
|
||||
manager.rememberOpener(fixture.dialog, () => currentOpener);
|
||||
fixture.backdrop.hidden = false;
|
||||
manager.sync("#preferred");
|
||||
|
||||
const replacement = element(fixture.documentObject, "button", { id: "opener" });
|
||||
const openerIndex = fixture.documentObject.body.children.indexOf(fixture.opener);
|
||||
fixture.opener.parentElement = null;
|
||||
replacement.parentElement = fixture.documentObject.body;
|
||||
fixture.documentObject.body.children[openerIndex] = replacement;
|
||||
currentOpener = replacement;
|
||||
|
||||
fixture.backdrop.hidden = true;
|
||||
manager.sync();
|
||||
assert.equal(fixture.documentObject.activeElement, replacement);
|
||||
assert.notEqual(fixture.documentObject.activeElement, fixture.opener);
|
||||
});
|
||||
372
tests/LegacyParityWeb/legacy-modal-workflow-parity.test.cjs
Normal file
372
tests/LegacyParityWeb/legacy-modal-workflow-parity.test.cjs
Normal file
@@ -0,0 +1,372 @@
|
||||
"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 markup = fs.readFileSync(path.join(webRoot, "index.html"), "utf8");
|
||||
const styles = fs.readFileSync(path.join(webRoot, "styles.css"), "utf8");
|
||||
|
||||
function section(startMarker, endMarker) {
|
||||
const start = app.indexOf(startMarker);
|
||||
assert.notEqual(start, -1, "missing start marker: " + startMarker);
|
||||
const end = app.indexOf(endMarker, start + startMarker.length);
|
||||
assert.notEqual(end, -1, "missing end marker: " + endMarker);
|
||||
return app.slice(start, end);
|
||||
}
|
||||
|
||||
function assertOrdered(source, markers) {
|
||||
let cursor = -1;
|
||||
for (const marker of markers) {
|
||||
const next = source.indexOf(marker, cursor + 1);
|
||||
assert.ok(next > cursor, "expected marker in order: " + marker);
|
||||
cursor = next;
|
||||
}
|
||||
}
|
||||
|
||||
test("modal focus support loads before app.js and is started once", () => {
|
||||
const scripts = Array.from(markup.matchAll(/<script\s+src="([^"]+)"/g),
|
||||
match => match[1]);
|
||||
const focusIndex = scripts.indexOf("legacy-modal-focus.js");
|
||||
const namedSelectionIndex = scripts.indexOf("legacy-named-playlist-selection.js");
|
||||
const appIndex = scripts.indexOf("app.js");
|
||||
|
||||
assert.ok(focusIndex >= 0, "legacy modal focus module must be loaded");
|
||||
assert.ok(namedSelectionIndex > focusIndex,
|
||||
"named-playlist selection support must load after modal support");
|
||||
assert.ok(appIndex > namedSelectionIndex,
|
||||
"named-playlist selection support must load before app.js");
|
||||
assert.ok(appIndex > focusIndex, "focus module must load before app.js");
|
||||
assert.equal(scripts.filter(name => name === "legacy-modal-focus.js").length, 1);
|
||||
assert.equal(scripts.filter(
|
||||
name => name === "legacy-named-playlist-selection.js").length, 1);
|
||||
assert.equal(scripts.filter(name => name === "app.js").length, 1);
|
||||
|
||||
assert.match(app, /window\.LegacyModalFocus\.create\(\{ document: document \}\)/);
|
||||
assert.match(app, /window\.LegacyNamedPlaylistSelection\.create\(\)/);
|
||||
assert.equal((app.match(/modalFocusManager\.start\(\)/g) || []).length, 1);
|
||||
});
|
||||
|
||||
test("the one-button MessageBox stays visually above every child dialog", () => {
|
||||
function zIndex(className) {
|
||||
const match = styles.match(new RegExp(
|
||||
"\\." + className + "\\s*\\{[^}]*z-index:\\s*(\\d+)", "s"));
|
||||
assert.ok(match, "missing z-index for " + className);
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
const alertLayer = zIndex("dialog-backdrop");
|
||||
assert.ok(alertLayer > zIndex("operator-catalog-backdrop"));
|
||||
assert.ok(alertLayer > zIndex("named-playlist-confirmation-backdrop"));
|
||||
});
|
||||
|
||||
test("modal open, render, and close paths preserve and restore focus", () => {
|
||||
const namedOpen = section("function openNamedPlaylist", "function closeNamedPlaylist");
|
||||
assertOrdered(namedOpen, [
|
||||
"rememberModalOpener(namedPlaylistDialog)",
|
||||
"namedPlaylistModal.hidden = false",
|
||||
"syncModalFocus()"
|
||||
]);
|
||||
|
||||
const namedClose = section("function closeNamedPlaylist", "function isNamedPlaylistModalLocked");
|
||||
assertOrdered(namedClose, [
|
||||
"namedPlaylistModal.hidden = true",
|
||||
"syncModalFocus()"
|
||||
]);
|
||||
|
||||
for (const [functionName, nextFunction, hiddenElement, dialogElement] of [
|
||||
["renderManualFinancial", "isCanonicalNamedPlaylistTitle", "manualModal", "manualDialog"],
|
||||
["renderManualLists", "openNamedPlaylist", "manualListModal", "manualListDialog"],
|
||||
["renderOperatorCatalog", "renderDialog", "operatorCatalogModal", "operatorCatalogDialog"]
|
||||
]) {
|
||||
const body = section("function " + functionName, "function " + nextFunction);
|
||||
assertOrdered(body, [
|
||||
"if (" + hiddenElement + ".hidden) rememberModalOpener(" + dialogElement + ")",
|
||||
hiddenElement + ".hidden = false"
|
||||
]);
|
||||
}
|
||||
|
||||
const dialogRender = section("function renderDialog", "function render(state)");
|
||||
assertOrdered(dialogRender, [
|
||||
"if (dialog.hidden) rememberModalOpener(legacyAlertDialog)",
|
||||
"dialog.hidden = false"
|
||||
]);
|
||||
|
||||
const rootRender = section("function render(state)", "searchButton.addEventListener");
|
||||
assertOrdered(rootRender, ["renderDialog(state)", "syncModalFocus()"]);
|
||||
assert.doesNotMatch(rootRender, /dialogOk\.focus\(/,
|
||||
"focus must be delegated to the modal manager so nested opener state is retained");
|
||||
});
|
||||
|
||||
test("Escape dismisses only the one-button alert and is inert for child dialogs", () => {
|
||||
const keydown = section(
|
||||
'document.addEventListener("keydown", function (event)',
|
||||
'dialogOk.addEventListener("click"');
|
||||
assertOrdered(keydown, [
|
||||
'event.key === "Escape" && modalFocusManager.getActiveModal()',
|
||||
"event.preventDefault()",
|
||||
"modalFocusManager.getActiveModal() === legacyAlertDialog",
|
||||
'send("dismiss-dialog", {})'
|
||||
]);
|
||||
assert.doesNotMatch(keydown,
|
||||
/(?:closeNamedPlaylist|close-manual|close-operator)\s*\(/);
|
||||
assert.match(markup,
|
||||
/id="named-playlist-confirmation"[\s\S]*role="alertdialog"[\s\S]*id="named-playlist-confirmation-yes"[\s\S]*id="named-playlist-confirmation-no"/);
|
||||
});
|
||||
|
||||
test("category-tree opener survives rerender by resolving its stable id or node key", () => {
|
||||
const opener = section("function createModalOpenerReference", "function rememberModalOpener");
|
||||
assertOrdered(opener, [
|
||||
"categoryTree.contains(opener)",
|
||||
"captureTreeUiState(renderedTreeTabId)",
|
||||
"controlId: opener.id || null",
|
||||
"nodeKey: node ? node.dataset.treeNodeKey : null",
|
||||
"return function resolveTreeModalOpener()",
|
||||
"document.getElementById(descriptor.controlId)",
|
||||
"findTreeNodeByKey(descriptor.nodeKey)",
|
||||
"resolved.setSelectionRange(descriptor.selectionStart, descriptor.selectionEnd)",
|
||||
"return resolved"
|
||||
]);
|
||||
assert.match(app,
|
||||
/modalFocusManager\.rememberOpener\(modal, createModalOpenerReference\(\)\)/);
|
||||
|
||||
const capture = section("function captureTreeUiState", "function beginTreeRender");
|
||||
assertOrdered(capture, [
|
||||
"categoryTree.contains(document.activeElement)",
|
||||
"id: activeElement.id",
|
||||
"selectionStart: Number.isInteger(activeElement.selectionStart)",
|
||||
"selectionEnd: Number.isInteger(activeElement.selectionEnd)",
|
||||
"focusKey: active ? active.dataset.treeNodeKey : previous.focusKey || null",
|
||||
"controlFocus: activeElement ? controlFocus : previous.controlFocus || null"
|
||||
]);
|
||||
|
||||
const finish = section("function finishTreeRender", "function send(type, payload)");
|
||||
assertOrdered(finish, [
|
||||
"const canRestoreFocus = treeFocusOwnedBeforeRender",
|
||||
"if (canRestoreFocus && saved.controlFocus && saved.controlFocus.id)",
|
||||
"document.getElementById(saved.controlFocus.id)",
|
||||
"categoryTree.contains(control)",
|
||||
"control.focus({ preventScroll: true })",
|
||||
"Number.isInteger(saved.controlFocus.selectionStart)",
|
||||
"Number.isInteger(saved.controlFocus.selectionEnd)",
|
||||
"control.setSelectionRange(",
|
||||
"saved.controlFocus.selectionStart",
|
||||
"saved.controlFocus.selectionEnd"
|
||||
]);
|
||||
});
|
||||
|
||||
test("named playlist save uses a nested Yes/No dialog then alerts and closes on exact success", () => {
|
||||
const confirm = section(
|
||||
'namedPlaylistConfirmButton.addEventListener("click"',
|
||||
"manualButtons.forEach");
|
||||
assertOrdered(confirm, [
|
||||
"openNamedPlaylistConfirmation({",
|
||||
'kind: "save"',
|
||||
"definitionId: definitionId",
|
||||
'message: "저장하겠습니까?"',
|
||||
"showSavedAlert: true",
|
||||
"closeOnNo: true"
|
||||
]);
|
||||
assert.doesNotMatch(confirm, /window\.confirm/);
|
||||
|
||||
const resolution = section(
|
||||
"function resolveNamedPlaylistConfirmation",
|
||||
"function renderNamedPlaylist");
|
||||
const confirmationOpen = section(
|
||||
"function openNamedPlaylistConfirmation",
|
||||
"function restoreNamedPlaylistAfterConfirmation");
|
||||
assertOrdered(confirmationOpen, [
|
||||
"rememberModalOpener(namedPlaylistConfirmationDialog)",
|
||||
"namedPlaylistConfirmationModal.hidden = false",
|
||||
"syncModalFocus()"
|
||||
]);
|
||||
assertOrdered(resolution, [
|
||||
"namedPlaylistConfirmationModal.hidden = true",
|
||||
"syncModalFocus()",
|
||||
"if (!confirmed)",
|
||||
"confirmation.closeOnNo === true",
|
||||
"closeNamedPlaylist()",
|
||||
'"save-current-named-playlist-to"',
|
||||
"confirmation.showSavedAlert === true"
|
||||
]);
|
||||
|
||||
const renderNamed = section("function renderNamedPlaylist", "function clearOperatorCatalogDragVisuals");
|
||||
assertOrdered(renderNamed, [
|
||||
"isMatchingNamedPlaylistReceipt(",
|
||||
"pendingNamedPlaylistCommand",
|
||||
"commandResult",
|
||||
"pendingNamedPlaylistCommand = null",
|
||||
'window.alert("저장되었습니다")',
|
||||
"closeNamedPlaylist()"
|
||||
]);
|
||||
});
|
||||
|
||||
test("named playlist double-click save confirms but never requests a success alert", () => {
|
||||
const action = section(
|
||||
"function runNamedPlaylistDoubleClick",
|
||||
"function lockNamedPlaylistControls");
|
||||
assertOrdered(action, [
|
||||
'mode === "save"',
|
||||
"openNamedPlaylistConfirmation({",
|
||||
'kind: "save"',
|
||||
"definitionId: definitionId",
|
||||
'message: "저장하겠습니까?"',
|
||||
"showSavedAlert: false",
|
||||
"closeOnNo: true"
|
||||
]);
|
||||
|
||||
const doubleClick = section(
|
||||
'namedPlaylistDefinitions.addEventListener("dblclick"',
|
||||
'namedPlaylistDefinitions.addEventListener("click"');
|
||||
assertOrdered(doubleClick, [
|
||||
"namedPlaylistSelection.isPending()",
|
||||
"namedPlaylistSelection.defer(definitionId",
|
||||
"mode: namedPlaylistMode",
|
||||
"runNamedPlaylistDoubleClick(definitionId, namedPlaylistMode)"
|
||||
]);
|
||||
assert.doesNotMatch(action + doubleClick,
|
||||
/window\.(?:alert|confirm)|showSavedAlert\s*:\s*true/);
|
||||
});
|
||||
|
||||
test("named playlist row selection is immediate and blocks wrong-target actions", () => {
|
||||
const selectionStart = section(
|
||||
"function beginNamedPlaylistSelection",
|
||||
"function runNamedPlaylistDoubleClick");
|
||||
assertOrdered(selectionStart, [
|
||||
"namedPlaylistSelection.begin(",
|
||||
"markNamedPlaylistVisualSelection(definitionId)",
|
||||
"lockNamedPlaylistSelectionActions()",
|
||||
'send("select-named-playlist", {',
|
||||
"definitionId: definitionId",
|
||||
"requestId: started.requestId"
|
||||
]);
|
||||
assert.doesNotMatch(selectionStart, /setTimeout|namedPlaylistResultClickTimer/);
|
||||
|
||||
const click = section(
|
||||
'namedPlaylistDefinitions.addEventListener("click"',
|
||||
'namedPlaylistNewTitle.addEventListener("input"');
|
||||
assert.match(click,
|
||||
/beginNamedPlaylistSelection\(definition\.dataset\.namedPlaylistDefinitionId\)/);
|
||||
assert.doesNotMatch(click, /setTimeout|namedPlaylistResultClickTimer/);
|
||||
|
||||
const renderNamed = section(
|
||||
"function renderNamedPlaylist",
|
||||
"function clearOperatorCatalogDragVisuals");
|
||||
assertOrdered(renderNamed, [
|
||||
"const commandResult = state.commandResult",
|
||||
"namedPlaylistSelection.reconcile(",
|
||||
"commandResult",
|
||||
"const deferredSelectionAction = selectionResult.deferredAction",
|
||||
"const definitionLocked = isNamedPlaylistDefinitionLocked()",
|
||||
"runNamedPlaylistDoubleClick("
|
||||
]);
|
||||
assert.match(app,
|
||||
/function isNamedPlaylistActionLocked\(\)[\s\S]*namedPlaylistSelection\.isPending\(\)/);
|
||||
});
|
||||
|
||||
test("named playlist delete uses the same confirmation but No keeps the child open", () => {
|
||||
const remove = section(
|
||||
'namedPlaylistDeleteButton.addEventListener("click"',
|
||||
'namedPlaylistConfirmButton.addEventListener("click"');
|
||||
assertOrdered(remove, [
|
||||
"openNamedPlaylistConfirmation({",
|
||||
'kind: "delete"',
|
||||
'message: "프로그램이 삭제됩니다. 계속 하시겠습니까?"',
|
||||
"closeOnNo: false"
|
||||
]);
|
||||
assert.doesNotMatch(remove, /window\.confirm/);
|
||||
});
|
||||
|
||||
test("named playlist requests terminate only on their exact correlated receipt", () => {
|
||||
const close = section("function closeNamedPlaylist", "function isNamedPlaylistModalLocked");
|
||||
assertOrdered(close, [
|
||||
"if (isNamedPlaylistModalLocked() || namedPlaylistSelection.isPending()) return false",
|
||||
"namedPlaylistModal.hidden = true"
|
||||
]);
|
||||
|
||||
const locking = section("function isNamedPlaylistModalLocked", "function renderNamedPlaylist");
|
||||
for (const lockSource of [
|
||||
"uiBusy",
|
||||
"pendingNamedPlaylistCommand !== null",
|
||||
"namedPlaylistConfirmation !== null",
|
||||
"namedPlaylistState.isWriteInProgress === true"
|
||||
]) {
|
||||
assert.match(locking, new RegExp(lockSource.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
}
|
||||
assertOrdered(locking, [
|
||||
'const requestId = "named-ui-" + String(nextNamedPlaylistRequestId++)',
|
||||
"pendingNamedPlaylistCommand = {",
|
||||
"command: type",
|
||||
"targetId: requestId",
|
||||
"minimumSequence: lastCommandSequence + 1",
|
||||
"closeOnSuccess: false",
|
||||
"Object.assign({}, payload || {}, { requestId: requestId })",
|
||||
"send(type, correlatedPayload)",
|
||||
"lockNamedPlaylistControls()"
|
||||
]);
|
||||
assertOrdered(locking, [
|
||||
"pendingNamedPlaylistCommand = {",
|
||||
"command: command",
|
||||
"targetId: definitionId",
|
||||
"definitionId: definitionId",
|
||||
"minimumSequence: lastCommandSequence + 1",
|
||||
"closeOnSuccess: true",
|
||||
"send(command, { definitionId: definitionId })",
|
||||
"lockNamedPlaylistControls()"
|
||||
]);
|
||||
|
||||
const renderNamed = section("function renderNamedPlaylist", "function clearOperatorCatalogDragVisuals");
|
||||
assertOrdered(renderNamed, [
|
||||
"isMatchingNamedPlaylistReceipt(",
|
||||
"pendingNamedPlaylistCommand",
|
||||
"commandResult",
|
||||
"pendingNamedPlaylistCommand = null",
|
||||
"succeeded && completed.closeOnSuccess"
|
||||
]);
|
||||
assertOrdered(renderNamed, [
|
||||
"const modalLocked = isNamedPlaylistActionLocked()",
|
||||
"const closeLocked = isNamedPlaylistModalLocked() ||",
|
||||
"namedPlaylistSelection.isPending()",
|
||||
"namedPlaylistClose.disabled = closeLocked",
|
||||
"namedPlaylistCancelButton.disabled = closeLocked"
|
||||
]);
|
||||
assert.match(renderNamed, /dbLoadButton\.disabled = operatorMutationLocked/);
|
||||
assert.match(renderNamed, /dbSaveButton\.disabled = operatorMutationLocked/);
|
||||
assert.match(locking,
|
||||
/function isNamedPlaylistActionLocked\(\)[\s\S]*operatorMutationLocked/);
|
||||
assert.doesNotMatch(app,
|
||||
/isNamedPlaylistTerminalRejection|minimumRevision|namedPlaylistBusyCycleObserved/,
|
||||
"uncorrelated state changes must never unlock a DB request");
|
||||
});
|
||||
|
||||
test("named playlist receipt matcher consumes busy rejection but defers busy success", () => {
|
||||
const helperSource = section(
|
||||
"function isMatchingNamedPlaylistReceipt",
|
||||
"function openNamedPlaylistConfirmation");
|
||||
const matches = new Function(
|
||||
helperSource + "\nreturn isMatchingNamedPlaylistReceipt;")();
|
||||
const pending = {
|
||||
minimumSequence: 7,
|
||||
command: "create-named-playlist",
|
||||
targetId: "named-ui-4"
|
||||
};
|
||||
const exact = {
|
||||
sequence: 7,
|
||||
command: "create-named-playlist",
|
||||
targetId: "named-ui-4",
|
||||
succeeded: true
|
||||
};
|
||||
|
||||
assert.equal(matches(false, pending, exact), true);
|
||||
assert.equal(matches(true, pending, exact), false);
|
||||
assert.equal(matches(true, pending, { ...exact, succeeded: false }), true,
|
||||
"an exact gate rejection must be consumed before another busy request overwrites it");
|
||||
assert.equal(matches(false, pending, { ...exact, sequence: 6 }), false);
|
||||
assert.equal(matches(false, pending, { ...exact, command: "refresh-named-playlists" }), false);
|
||||
assert.equal(matches(false, pending, { ...exact, targetId: "named-ui-3" }), false);
|
||||
});
|
||||
108
tests/LegacyParityWeb/legacy-named-playlist-selection.test.cjs
Normal file
108
tests/LegacyParityWeb/legacy-named-playlist-selection.test.cjs
Normal file
@@ -0,0 +1,108 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const selection = require("../../src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/legacy-named-playlist-selection.js");
|
||||
|
||||
const first = "named-definition-first";
|
||||
const second = "named-definition-second";
|
||||
|
||||
test("a second-row click blocks actions until native state selects that exact row", () => {
|
||||
const workflow = selection.create();
|
||||
|
||||
const started = workflow.begin(second, first, "named-ui-1", 1);
|
||||
|
||||
assert.deepEqual(started, {
|
||||
accepted: true,
|
||||
shouldSend: true,
|
||||
definitionId: second,
|
||||
requestId: "named-ui-1"
|
||||
});
|
||||
assert.equal(workflow.isPending(), true);
|
||||
assert.equal(workflow.pendingDefinitionId(), second);
|
||||
assert.deepEqual(workflow.reconcile({
|
||||
sequence: 1,
|
||||
command: "refresh-named-playlists",
|
||||
targetId: "named-ui-old",
|
||||
succeeded: true
|
||||
}, first), {
|
||||
completed: false,
|
||||
succeeded: false,
|
||||
deferredAction: null
|
||||
});
|
||||
assert.equal(workflow.isPending(), true);
|
||||
|
||||
const completed = workflow.reconcile({
|
||||
sequence: 1,
|
||||
command: "select-named-playlist",
|
||||
targetId: "named-ui-1",
|
||||
succeeded: true
|
||||
}, second);
|
||||
|
||||
assert.equal(completed.completed, true);
|
||||
assert.equal(completed.succeeded, true);
|
||||
assert.equal(workflow.isPending(), false);
|
||||
});
|
||||
|
||||
test("repeated click and double click queue one exact action without a duplicate select", () => {
|
||||
const workflow = selection.create();
|
||||
assert.equal(workflow.begin(second, first, "named-ui-2", 2).shouldSend, true);
|
||||
|
||||
const repeated = workflow.begin(second, first);
|
||||
const deferred = Object.freeze({ definitionId: second, mode: "save" });
|
||||
|
||||
assert.equal(repeated.accepted, true);
|
||||
assert.equal(repeated.shouldSend, false);
|
||||
assert.equal(repeated.requestId, "named-ui-2");
|
||||
assert.equal(workflow.defer(second, deferred), true);
|
||||
const completed = workflow.reconcile({
|
||||
sequence: 2,
|
||||
command: "select-named-playlist",
|
||||
targetId: "named-ui-2",
|
||||
succeeded: true
|
||||
}, second);
|
||||
assert.deepEqual(completed.deferredAction, deferred);
|
||||
assert.equal(completed.succeeded, true);
|
||||
});
|
||||
|
||||
test("a failed native selection clears the lock and discards the deferred mutation", () => {
|
||||
const workflow = selection.create();
|
||||
workflow.begin(second, first, "named-ui-3", 3);
|
||||
workflow.defer(second, { definitionId: second, mode: "load" });
|
||||
|
||||
const failed = workflow.reconcile({
|
||||
sequence: 3,
|
||||
command: "select-named-playlist",
|
||||
targetId: "named-ui-3",
|
||||
succeeded: false
|
||||
}, first);
|
||||
|
||||
assert.deepEqual(failed, {
|
||||
completed: true,
|
||||
succeeded: false,
|
||||
deferredAction: null
|
||||
});
|
||||
assert.equal(workflow.isPending(), false);
|
||||
});
|
||||
|
||||
test("an already selected row never creates a pending native request", () => {
|
||||
const workflow = selection.create();
|
||||
|
||||
const current = workflow.begin(first, first);
|
||||
|
||||
assert.equal(current.accepted, true);
|
||||
assert.equal(current.shouldSend, false);
|
||||
assert.equal(current.requestId, null);
|
||||
assert.equal(workflow.isPending(), false);
|
||||
});
|
||||
|
||||
test("a different row cannot replace an in-flight selection target", () => {
|
||||
const workflow = selection.create();
|
||||
workflow.begin(second, first, "named-ui-4", 4);
|
||||
|
||||
const conflicting = workflow.begin(first, first);
|
||||
|
||||
assert.equal(conflicting.accepted, false);
|
||||
assert.equal(conflicting.shouldSend, false);
|
||||
assert.equal(workflow.pendingDefinitionId(), second);
|
||||
});
|
||||
@@ -69,15 +69,19 @@ test("legacy stock double click is represented by pointer-down facts", () => {
|
||||
assert.match(app, /stockResults\.querySelectorAll\("button\[data-result-index\]"\)/);
|
||||
});
|
||||
|
||||
test("cut rows are reconciled in place so a state reply cannot cancel native drag", () => {
|
||||
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, /if \(playlistMutationLocked \|\| !event\.dataTransfer\)/);
|
||||
assert.match(cutFactory, /beginCutDragGesture\(element, event, position\)/);
|
||||
assert.match(cutFactory, /addEventListener\("pointermove", onCutPointerMove\)/);
|
||||
assert.match(cutFactory, /addEventListener\("lostpointercapture", onCutPointerCancel\)/);
|
||||
assert.match(cutFactory, /const cutDragLocked = isOperatorMutationLocked\(state\)/);
|
||||
assert.match(cutFactory, /element\.draggable = !cutDragLocked/);
|
||||
assert.match(cutFactory, /element\.draggable = false/);
|
||||
assert.match(cutFactory, /dataset\.cutDragEnabled = cutSelectionEnabled/);
|
||||
assert.doesNotMatch(cutFactory, /addEventListener\("dragstart"/);
|
||||
assert.doesNotMatch(app, /cutList\.replaceChildren/);
|
||||
});
|
||||
|
||||
@@ -163,7 +167,7 @@ test("IDLE Escape keeps the emergency StopAll path available", () => {
|
||||
/takeOut\.disabled[\s\S]{0,120}playout\.phase !== "prepared"/);
|
||||
});
|
||||
|
||||
test("named-playlist modal shortcuts cannot leak into the playout listener", () => {
|
||||
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);
|
||||
@@ -175,8 +179,11 @@ test("named-playlist modal shortcuts cannot leak into the playout listener", ()
|
||||
assert.ok(appHandlerStart >= 0 && appHandlerEnd > appHandlerBodyStart);
|
||||
const handleAppKeydown = new Function(
|
||||
"event",
|
||||
"namedPlaylistModal",
|
||||
"closeNamedPlaylist",
|
||||
"modalFocusManager",
|
||||
"cutDragGesture",
|
||||
"clearCutDragGesture",
|
||||
"legacyAlertDialog",
|
||||
"send",
|
||||
app.slice(appHandlerBodyStart, appHandlerEnd));
|
||||
|
||||
const playoutHandlerStart = playout.lastIndexOf(keydownMarker);
|
||||
@@ -201,28 +208,39 @@ test("named-playlist modal shortcuts cannot leak into the playout listener", ()
|
||||
const modal = { hidden: false };
|
||||
|
||||
for (const key of ["F2", "F3", "F8"]) {
|
||||
const modalKey = {
|
||||
key,
|
||||
repeat: false,
|
||||
defaultPrevented: false,
|
||||
preventDefault() { this.defaultPrevented = true; }
|
||||
};
|
||||
handlePlayoutKeydown(
|
||||
{ key, repeat: false, defaultPrevented: false, preventDefault() {} },
|
||||
modalKey,
|
||||
() => !modal.hidden,
|
||||
backgroundFile,
|
||||
backgroundNone,
|
||||
takeIn,
|
||||
takeOut);
|
||||
assert.equal(modalKey.defaultPrevented, true, key + " browser default must be consumed");
|
||||
}
|
||||
assert.deepEqual(clicks, []);
|
||||
|
||||
let closeCount = 0;
|
||||
const escape = new Event("keydown", { cancelable: true });
|
||||
Object.defineProperties(escape, {
|
||||
key: { value: "Escape" },
|
||||
repeat: { value: false }
|
||||
});
|
||||
handleAppKeydown(escape, modal, () => {
|
||||
closeCount += 1;
|
||||
modal.hidden = true;
|
||||
});
|
||||
assert.equal(closeCount, 1);
|
||||
assert.equal(modal.hidden, true);
|
||||
const childDialog = {};
|
||||
const legacyAlertDialog = {};
|
||||
const sent = [];
|
||||
handleAppKeydown(
|
||||
escape,
|
||||
{ getActiveModal() { return childDialog; } },
|
||||
null,
|
||||
() => {},
|
||||
legacyAlertDialog,
|
||||
(type) => sent.push(type));
|
||||
assert.equal(modal.hidden, false);
|
||||
assert.equal(escape.defaultPrevented, true);
|
||||
|
||||
handlePlayoutKeydown(
|
||||
@@ -233,6 +251,57 @@ test("named-playlist modal shortcuts cannot leak into the playout listener", ()
|
||||
takeIn,
|
||||
takeOut);
|
||||
assert.deepEqual(clicks, []);
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
const alertEscape = new Event("keydown", { cancelable: true });
|
||||
Object.defineProperties(alertEscape, {
|
||||
key: { value: "Escape" },
|
||||
repeat: { value: false }
|
||||
});
|
||||
handleAppKeydown(
|
||||
alertEscape,
|
||||
{ getActiveModal() { return legacyAlertDialog; } },
|
||||
null,
|
||||
() => {},
|
||||
legacyAlertDialog,
|
||||
(type) => sent.push(type));
|
||||
assert.equal(alertEscape.defaultPrevented, true);
|
||||
assert.deepEqual(sent, ["dismiss-dialog"]);
|
||||
});
|
||||
|
||||
test("started cut drag owns playout keys and Escape cancels without TAKE OUT", () => {
|
||||
const keydownMarker = 'document.addEventListener("keydown", function (event) {';
|
||||
const start = app.lastIndexOf(keydownMarker) + keydownMarker.length;
|
||||
const end = app.indexOf("\n });", start);
|
||||
const handle = new Function(
|
||||
"event",
|
||||
"modalFocusManager",
|
||||
"cutDragGesture",
|
||||
"clearCutDragGesture",
|
||||
"legacyAlertDialog",
|
||||
"send",
|
||||
app.slice(start, end));
|
||||
const sent = [];
|
||||
let clears = 0;
|
||||
const manager = { getActiveModal() { return null; } };
|
||||
for (const key of ["F2", "F3", "F8", "Escape"]) {
|
||||
const event = {
|
||||
key,
|
||||
repeat: false,
|
||||
defaultPrevented: false,
|
||||
preventDefault() { this.defaultPrevented = true; }
|
||||
};
|
||||
handle(
|
||||
event,
|
||||
manager,
|
||||
{ started: true },
|
||||
() => { clears += 1; },
|
||||
{},
|
||||
(type) => sent.push(type));
|
||||
assert.equal(event.defaultPrevented, true, key + " must stay inside drag loop");
|
||||
}
|
||||
assert.equal(clears, 1, "only Escape cancels the active drag");
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test("playlist controls send intent for selection, enabled state, movement and deletion", () => {
|
||||
@@ -267,7 +336,7 @@ test("playlist controls send intent for selection, enabled state, movement and d
|
||||
const homeEndBranchStart = app.lastIndexOf('} else if ((event.key === "Home"');
|
||||
const homeEndBranch = app.slice(
|
||||
homeEndBranchStart,
|
||||
app.indexOf('} else if (event.key === "Escape"', homeEndBranchStart));
|
||||
app.indexOf("\n });", homeEndBranchStart));
|
||||
assert.doesNotMatch(homeEndBranch, /event\.preventDefault\(\)/);
|
||||
const boundaryHelperStart = app.indexOf("function selectPlaylistBoundary");
|
||||
const boundaryHelper = app.slice(
|
||||
@@ -468,76 +537,26 @@ test("playlist native drag sends one source row and rejects checkbox drag", () =
|
||||
assert.equal(Object.hasOwn(sent[0].payload, "selectedRowIds"), false);
|
||||
});
|
||||
|
||||
test("playlist drop zone accepts only the exact selected-cut drag token", () => {
|
||||
class MiniDataTransfer {
|
||||
constructor() { this.values = new Map(); }
|
||||
get types() { return [...this.values.keys()]; }
|
||||
setData(type, value) { this.values.set(type, value); }
|
||||
getData(type) { return this.values.get(type) || ""; }
|
||||
}
|
||||
|
||||
const sourceStart = app.indexOf("function hasDragType");
|
||||
const sourceEnd = app.indexOf(
|
||||
'playlistMoveUp.addEventListener("click"', sourceStart);
|
||||
assert.ok(sourceStart >= 0 && sourceEnd > sourceStart);
|
||||
const playlistDropZone = new EventTarget();
|
||||
const sent = [];
|
||||
const bindDropZone = new Function(
|
||||
"playlistDropZone",
|
||||
"send",
|
||||
"selectedCutsDragToken",
|
||||
"playlistMutationLocked",
|
||||
app.slice(sourceStart, sourceEnd));
|
||||
bindDropZone(
|
||||
playlistDropZone,
|
||||
(type, payload) => sent.push({ type, payload }),
|
||||
"legacy-selected-cuts",
|
||||
false);
|
||||
|
||||
const playlistTransfer = new MiniDataTransfer();
|
||||
playlistTransfer.setData("text/x-mbn-playlist-row", "row-a");
|
||||
const playlistDrop = new Event("drop", { cancelable: true, bubbles: true });
|
||||
Object.assign(playlistDrop, { dataTransfer: playlistTransfer });
|
||||
playlistDropZone.dispatchEvent(playlistDrop);
|
||||
assert.equal(playlistDrop.defaultPrevented, false);
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
const externalText = new MiniDataTransfer();
|
||||
externalText.setData("text/plain", "external-text");
|
||||
const externalDrop = new Event("drop", { cancelable: true, bubbles: true });
|
||||
Object.assign(externalDrop, { dataTransfer: externalText });
|
||||
playlistDropZone.dispatchEvent(externalDrop);
|
||||
assert.equal(externalDrop.defaultPrevented, false);
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
const cutTransfer = new MiniDataTransfer();
|
||||
cutTransfer.setData("text/plain", "legacy-selected-cuts");
|
||||
const cutOver = new Event("dragover", { cancelable: true });
|
||||
Object.assign(cutOver, { dataTransfer: cutTransfer });
|
||||
playlistDropZone.dispatchEvent(cutOver);
|
||||
assert.equal(cutOver.defaultPrevented, true);
|
||||
const cutDrop = new Event("drop", { cancelable: true, bubbles: true });
|
||||
Object.assign(cutDrop, { dataTransfer: cutTransfer });
|
||||
playlistDropZone.dispatchEvent(cutDrop);
|
||||
assert.equal(cutDrop.defaultPrevented, true);
|
||||
assert.deepEqual(sent, [{ type: "drop-selected-cuts", payload: {} }]);
|
||||
|
||||
const lockedDropZone = new EventTarget();
|
||||
const lockedSent = [];
|
||||
bindDropZone(
|
||||
lockedDropZone,
|
||||
(type, payload) => lockedSent.push({ type, payload }),
|
||||
"legacy-selected-cuts",
|
||||
true);
|
||||
const lockedOver = new Event("dragover", { cancelable: true });
|
||||
Object.assign(lockedOver, { dataTransfer: cutTransfer });
|
||||
lockedDropZone.dispatchEvent(lockedOver);
|
||||
assert.equal(lockedOver.defaultPrevented, false);
|
||||
const lockedDrop = new Event("drop", { cancelable: true, bubbles: true });
|
||||
Object.assign(lockedDrop, { dataTransfer: cutTransfer });
|
||||
lockedDropZone.dispatchEvent(lockedDrop);
|
||||
assert.equal(lockedDrop.defaultPrevented, false);
|
||||
assert.deepEqual(lockedSent, []);
|
||||
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", () => {
|
||||
@@ -931,14 +950,16 @@ test("UC1 tree render preserves state but resets Expand and first root on tab re
|
||||
|
||||
const categoryTree = new MiniTree();
|
||||
const catalogExpandAll = { checked: false };
|
||||
const harness = new Function("categoryTree", "catalogExpandAll", "document", `
|
||||
const harness = new Function(
|
||||
"categoryTree", "catalogExpandAll", "document", "modalFocusManager", `
|
||||
let renderedTreeTabId = null;
|
||||
let treeInteractionLocked = false;
|
||||
let treeFocusOwnedBeforeRender = false;
|
||||
const treeUiStateByTab = new Map();
|
||||
const pendingTreeResetTabs = new Set();
|
||||
${app.slice(helperStart, helperEnd)}
|
||||
return { beginTreeRender, finishTreeRender, selectTreeNode };
|
||||
`)(categoryTree, catalogExpandAll, document);
|
||||
`)(categoryTree, catalogExpandAll, document, { getActiveModal() { return null; } });
|
||||
const state = tabId => ({
|
||||
isBusy: false,
|
||||
fixedSectionBatch: null,
|
||||
@@ -1140,13 +1161,14 @@ test("theme expert and trading halt actions preserve original single click wirin
|
||||
test("named DB playlists and playout use separate closed native intents", () => {
|
||||
for (const intent of [
|
||||
"refresh-named-playlists", "select-named-playlist",
|
||||
"load-selected-named-playlist", "create-named-playlist",
|
||||
"save-current-named-playlist", "delete-selected-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, /definitionId: definition\.dataset\.namedPlaylistDefinitionId/);
|
||||
assert.match(app, /dbLoadButton\.disabled = busy \|\| !named/);
|
||||
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);
|
||||
|
||||
@@ -1155,8 +1177,14 @@ test("named DB playlists and playout use separate closed native intents", () =>
|
||||
}
|
||||
for (const intent of [
|
||||
"prepare-playout", "take-in", "next-playout", "take-out",
|
||||
"choose-background", "toggle-background", "set-fade-duration"
|
||||
"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/);
|
||||
@@ -1207,8 +1235,9 @@ test("FSell and VI dialogs send opaque native intents and never own file or stoc
|
||||
});
|
||||
|
||||
test("separator rows preserve the original drag gesture", () => {
|
||||
assert.match(app, /element\.draggable = true/);
|
||||
assert.doesNotMatch(app, /draggable = !row\.isSeparator/);
|
||||
assert.match(app, /element\.dataset\.cutDragEnabled = cutSelectionEnabled \? "true" : "false"/);
|
||||
assert.match(app, /element\.draggable = false/);
|
||||
assert.doesNotMatch(app, /cutDragEnabled = !row\.isSeparator/);
|
||||
});
|
||||
|
||||
test("a C# busy snapshot freezes controls instead of silently accepting queued input", () => {
|
||||
|
||||
Reference in New Issue
Block a user