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", () => {
|
||||
|
||||
@@ -219,6 +219,153 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
|
||||
Assert.True(saved.CommandResult?.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_to_opaque_id_treats_known_commit_with_failed_readback_as_success()
|
||||
{
|
||||
var persistence = new OperatorNamedPlaylistPersistence([])
|
||||
{
|
||||
FailNextLoad = true
|
||||
};
|
||||
var controller = CreateController(persistence);
|
||||
var listed = await controller.RefreshNamedPlaylistsAsync();
|
||||
var definitionId = Assert.Single(
|
||||
listed.NamedPlaylist!.Definitions).DefinitionId;
|
||||
|
||||
var saved = await controller.SaveCurrentNamedPlaylistToAsync(definitionId);
|
||||
|
||||
Assert.Equal(1, persistence.ReplaceCalls);
|
||||
Assert.Equal(
|
||||
LegacyNamedPlaylistMutationOutcome.CommittedOptimistic,
|
||||
saved.NamedPlaylist?.LastMutationOutcome);
|
||||
Assert.Equal(LegacyOperatorStatusKind.Warning, saved.StatusKind);
|
||||
Assert.Equal("save-current-named-playlist-to", saved.CommandResult?.Command);
|
||||
Assert.Equal(definitionId, saved.CommandResult?.TargetId);
|
||||
Assert.True(saved.CommandResult?.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_to_opaque_id_does_not_reuse_a_stale_success_after_validation_rejection()
|
||||
{
|
||||
var persistence = new OperatorNamedPlaylistPersistence([]);
|
||||
var controller = CreateController(persistence);
|
||||
var listed = await controller.RefreshNamedPlaylistsAsync();
|
||||
var definitionId = Assert.Single(
|
||||
listed.NamedPlaylist!.Definitions).DefinitionId;
|
||||
var first = await controller.SaveCurrentNamedPlaylistToAsync(definitionId);
|
||||
Assert.True(first.CommandResult?.Succeeded);
|
||||
Assert.Equal(1, persistence.ReplaceCalls);
|
||||
|
||||
var action = LegacyFixedActionCatalog.Default
|
||||
.GetActions(LegacyFixedMarket.Overseas)
|
||||
.First(candidate => candidate.Available);
|
||||
for (var index = 0;
|
||||
index <= LegacyNamedPlaylistWorkflowController.MaximumItems;
|
||||
index++)
|
||||
{
|
||||
controller.ActivateFixedAction(action.Id);
|
||||
}
|
||||
|
||||
var rejected = await controller.SaveCurrentNamedPlaylistToAsync(definitionId);
|
||||
|
||||
Assert.Equal(1, persistence.ReplaceCalls);
|
||||
Assert.Equal(
|
||||
LegacyNamedPlaylistMutationOutcome.CommittedFresh,
|
||||
rejected.NamedPlaylist?.LastMutationOutcome);
|
||||
Assert.Equal(LegacyOperatorStatusKind.Warning, rejected.StatusKind);
|
||||
Assert.Equal("save-current-named-playlist-to", rejected.CommandResult?.Command);
|
||||
Assert.Equal(definitionId, rejected.CommandResult?.TargetId);
|
||||
Assert.False(rejected.CommandResult?.Succeeded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("refresh-named-playlists")]
|
||||
[InlineData("select-named-playlist")]
|
||||
[InlineData("create-named-playlist")]
|
||||
[InlineData("delete-selected-named-playlist")]
|
||||
[InlineData("load-named-playlist-by-id")]
|
||||
[InlineData("save-current-named-playlist-to")]
|
||||
public void Native_dispatch_failure_emits_exact_terminal_receipt(
|
||||
string command)
|
||||
{
|
||||
var controller = CreateController(
|
||||
new OperatorNamedPlaylistPersistence([]));
|
||||
const string definitionId = "named-definition-opaque";
|
||||
|
||||
var failed = controller.ReportNamedPlaylistCommandFailure(
|
||||
command,
|
||||
definitionId,
|
||||
"The native request was rejected before completion.");
|
||||
|
||||
Assert.Equal(command, failed.CommandResult?.Command);
|
||||
Assert.Equal(definitionId, failed.CommandResult?.TargetId);
|
||||
Assert.False(failed.CommandResult?.Succeeded);
|
||||
Assert.Equal(LegacyOperatorStatusKind.Error, failed.StatusKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Native_dispatch_failure_rejects_unrelated_command_names()
|
||||
{
|
||||
var controller = CreateController(
|
||||
new OperatorNamedPlaylistPersistence([]));
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
controller.ReportNamedPlaylistCommandFailure(
|
||||
"load-selected-named-playlist",
|
||||
"named-definition-opaque",
|
||||
"Rejected."));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("refresh-named-playlists")]
|
||||
[InlineData("select-named-playlist")]
|
||||
[InlineData("create-named-playlist")]
|
||||
[InlineData("delete-selected-named-playlist")]
|
||||
public void Busy_cycle_completion_echoes_exact_dispatch_id(string command)
|
||||
{
|
||||
var controller = CreateController(
|
||||
new OperatorNamedPlaylistPersistence([]));
|
||||
|
||||
var completed = controller.CompleteNamedPlaylistDispatch(
|
||||
command,
|
||||
"named-ui-42",
|
||||
succeeded: true);
|
||||
|
||||
Assert.Equal(command, completed.CommandResult?.Command);
|
||||
Assert.Equal("named-ui-42", completed.CommandResult?.TargetId);
|
||||
Assert.True(completed.CommandResult?.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ambiguous_native_save_failure_latches_no_retry_quarantine()
|
||||
{
|
||||
var persistence = new OperatorNamedPlaylistPersistence([]);
|
||||
var controller = CreateController(persistence);
|
||||
const string definitionId = "named-definition-opaque";
|
||||
|
||||
var failed = controller.ReportNamedPlaylistWriteOutcomeUnknown(
|
||||
"save-current-named-playlist-to",
|
||||
definitionId,
|
||||
"The accepted save ended without a definitive database outcome.");
|
||||
|
||||
Assert.Equal("save-current-named-playlist-to", failed.CommandResult?.Command);
|
||||
Assert.Equal(definitionId, failed.CommandResult?.TargetId);
|
||||
Assert.False(failed.CommandResult?.Succeeded);
|
||||
Assert.True(failed.NamedPlaylist?.IsWriteQuarantined);
|
||||
Assert.Equal(
|
||||
LegacyNamedPlaylistMutationOutcome.OutcomeUnknown,
|
||||
failed.NamedPlaylist?.LastMutationOutcome);
|
||||
|
||||
var listed = await controller.RefreshNamedPlaylistsAsync();
|
||||
controller.SelectNamedPlaylist(
|
||||
Assert.Single(listed.NamedPlaylist!.Definitions).DefinitionId);
|
||||
var blocked = await controller.SaveCurrentNamedPlaylistAsync();
|
||||
|
||||
Assert.Equal(0, persistence.ReplaceCalls);
|
||||
Assert.Equal(
|
||||
LegacyNamedPlaylistMutationOutcome.Quarantined,
|
||||
blocked.NamedPlaylist?.LastMutationOutcome);
|
||||
}
|
||||
|
||||
private static LegacyOperatorController CreateController(
|
||||
INamedPlaylistPersistenceService persistence) =>
|
||||
new(
|
||||
@@ -259,6 +406,8 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
|
||||
|
||||
public IReadOnlyList<NamedPlaylistStoredItem>? LastReplaceItems { get; private set; }
|
||||
|
||||
public bool FailNextLoad { get; set; }
|
||||
|
||||
public Task<NamedPlaylistListResult> ListAsync(
|
||||
int maximumResults = LegacyNamedPlaylistPersistenceService.DefaultMaximumDefinitions,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -282,6 +431,13 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
++LoadCalls;
|
||||
if (FailNextLoad)
|
||||
{
|
||||
FailNextLoad = false;
|
||||
return Task.FromException<NamedPlaylistDocument>(
|
||||
new NamedPlaylistDataException("The committed save could not be read back."));
|
||||
}
|
||||
|
||||
if (!_exists || !string.Equals(programCode, ProgramCode, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromException<NamedPlaylistDocument>(
|
||||
|
||||
@@ -101,8 +101,11 @@ public sealed class LegacyNamedPlaylistWorkflowTests
|
||||
|
||||
Assert.Equal(LegacyNamedPlaylistMutationOutcome.CommittedFresh, created.Outcome);
|
||||
Assert.Equal("00000003", persistence.LastCreatedProgramCode);
|
||||
Assert.Equal("새 프로그램", created.Snapshot.SelectedTitle);
|
||||
Assert.Equal(3, created.Snapshot.Definitions.Count);
|
||||
Assert.Equal(
|
||||
created.Snapshot.Definitions[0].DefinitionId,
|
||||
created.Snapshot.SelectedDefinitionId);
|
||||
var firstTitleAfterCreate = created.Snapshot.Definitions[0].Title;
|
||||
Assert.Empty(created.Snapshot.Rows);
|
||||
Assert.DoesNotContain(
|
||||
typeof(LegacyNamedPlaylistProgramDraft).GetProperties(),
|
||||
@@ -113,14 +116,45 @@ public sealed class LegacyNamedPlaylistWorkflowTests
|
||||
|
||||
Assert.Equal(LegacyNamedPlaylistMutationOutcome.CommittedFresh, deleted.Outcome);
|
||||
Assert.Equal(1, persistence.DeleteCalls);
|
||||
Assert.Equal("00000003", persistence.LastDeletedProgramCode);
|
||||
Assert.Null(deleted.Snapshot.SelectedDefinitionId);
|
||||
Assert.NotEqual("00000003", persistence.LastDeletedProgramCode);
|
||||
Assert.Equal(
|
||||
deleted.Snapshot.Definitions[0].DefinitionId,
|
||||
deleted.Snapshot.SelectedDefinitionId);
|
||||
Assert.DoesNotContain(
|
||||
deleted.Snapshot.Definitions,
|
||||
definition => string.Equals(definition.Title, "새 프로그램", StringComparison.Ordinal));
|
||||
definition => string.Equals(
|
||||
definition.Title,
|
||||
firstTitleAfterCreate,
|
||||
StringComparison.Ordinal));
|
||||
Assert.Contains(
|
||||
deleted.Snapshot.Definitions,
|
||||
definition => string.Equals(
|
||||
definition.Title,
|
||||
"새 프로그램",
|
||||
StringComparison.Ordinal));
|
||||
Assert.Equal(LegacyNamedPlaylistFreshness.Fresh, deleted.Snapshot.ListFreshness);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EveryNonEmptyListRebind_SelectsFirstDefinitionLikeWinFormsPList()
|
||||
{
|
||||
var persistence = CreateSeededPersistence();
|
||||
var controller = new LegacyNamedPlaylistWorkflowController(persistence);
|
||||
|
||||
var opened = await controller.RefreshAsync();
|
||||
|
||||
Assert.Equal(opened.Definitions[0].DefinitionId, opened.SelectedDefinitionId);
|
||||
var second = opened.Definitions[1];
|
||||
controller.SelectDefinition(second.DefinitionId);
|
||||
|
||||
var refreshed = await controller.RefreshAsync();
|
||||
|
||||
Assert.Equal(refreshed.Definitions[0].DefinitionId, refreshed.SelectedDefinitionId);
|
||||
Assert.NotEqual(second.Title, refreshed.SelectedTitle);
|
||||
Assert.True(refreshed.CanLoad);
|
||||
Assert.True(refreshed.CanDelete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KnownRollback_PreservesLoadedRowsAndNeverRetries()
|
||||
{
|
||||
|
||||
@@ -448,35 +448,44 @@ public sealed class LegacyBridgeProtocolTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseNamedPlaylistIntents_AcceptsOnlyOpaqueIdsTitlesAndEmptyCommands()
|
||||
public void ParseNamedPlaylistIntents_AcceptsOnlyOpaqueIdsTitlesAndDispatchIds()
|
||||
{
|
||||
Assert.True(LegacyBridgeProtocol.TryParseIntent(
|
||||
"{\"type\":\"refresh-named-playlists\",\"payload\":{}}",
|
||||
"{\"type\":\"refresh-named-playlists\",\"payload\":{\"requestId\":\"named-ui-1\"}}",
|
||||
out var refresh,
|
||||
out _));
|
||||
Assert.IsType<LegacyRefreshNamedPlaylistsIntent>(refresh);
|
||||
Assert.Equal(
|
||||
"named-ui-1",
|
||||
Assert.IsType<LegacyRefreshNamedPlaylistsIntent>(refresh).RequestId);
|
||||
|
||||
Assert.True(LegacyBridgeProtocol.TryParseIntent(
|
||||
"{\"type\":\"select-named-playlist\",\"payload\":{\"definitionId\":\"named-definition-AAAAAAAAAAAB\"}}",
|
||||
"{\"type\":\"select-named-playlist\",\"payload\":{\"definitionId\":\"named-definition-AAAAAAAAAAAB\",\"requestId\":\"named-ui-2\"}}",
|
||||
out var selection,
|
||||
out _));
|
||||
Assert.Equal(
|
||||
"named-definition-AAAAAAAAAAAB",
|
||||
Assert.IsType<LegacySelectNamedPlaylistIntent>(selection).DefinitionId);
|
||||
var selectionIntent = Assert.IsType<LegacySelectNamedPlaylistIntent>(selection);
|
||||
Assert.Equal("named-definition-AAAAAAAAAAAB", selectionIntent.DefinitionId);
|
||||
Assert.Equal("named-ui-2", selectionIntent.RequestId);
|
||||
|
||||
Assert.True(LegacyBridgeProtocol.TryParseIntent(
|
||||
"{\"type\":\"create-named-playlist\",\"payload\":{\"title\":\"아침 방송 A^B\"}}",
|
||||
"{\"type\":\"create-named-playlist\",\"payload\":{\"title\":\"아침 방송 A^B\",\"requestId\":\"named-ui-2\"}}",
|
||||
out var create,
|
||||
out _));
|
||||
var createIntent = Assert.IsType<LegacyCreateNamedPlaylistIntent>(create);
|
||||
Assert.Equal("아침 방송 A^B", createIntent.Title);
|
||||
Assert.Equal("named-ui-2", createIntent.RequestId);
|
||||
|
||||
Assert.True(LegacyBridgeProtocol.TryParseIntent(
|
||||
"{\"type\":\"delete-selected-named-playlist\",\"payload\":{\"requestId\":\"named-ui-3\"}}",
|
||||
out var delete,
|
||||
out _));
|
||||
Assert.Equal(
|
||||
"아침 방송 A^B",
|
||||
Assert.IsType<LegacyCreateNamedPlaylistIntent>(create).Title);
|
||||
"named-ui-3",
|
||||
Assert.IsType<LegacyDeleteSelectedNamedPlaylistIntent>(delete).RequestId);
|
||||
|
||||
foreach (var type in new[]
|
||||
{
|
||||
"load-selected-named-playlist",
|
||||
"save-current-named-playlist",
|
||||
"delete-selected-named-playlist"
|
||||
"save-current-named-playlist"
|
||||
})
|
||||
{
|
||||
Assert.True(LegacyBridgeProtocol.TryParseIntent(
|
||||
@@ -490,12 +499,24 @@ public sealed class LegacyBridgeProtocolTests
|
||||
"{\"type\":\"select-named-playlist\",\"payload\":{\"definitionId\":\"named-definition-AAAAAAAAAAAB\",\"programCode\":\"00000001\"}}",
|
||||
out _,
|
||||
out _));
|
||||
Assert.False(LegacyBridgeProtocol.TryParseIntent(
|
||||
"{\"type\":\"select-named-playlist\",\"payload\":{\"definitionId\":\"named-definition-AAAAAAAAAAAB\"}}",
|
||||
out _,
|
||||
out _));
|
||||
Assert.False(LegacyBridgeProtocol.TryParseIntent(
|
||||
"{\"type\":\"save-current-named-playlist\",\"payload\":{\"rows\":[]}}",
|
||||
out _,
|
||||
out _));
|
||||
Assert.False(LegacyBridgeProtocol.TryParseIntent(
|
||||
"{\"type\":\"create-named-playlist\",\"payload\":{\"title\":\" padded \"}}",
|
||||
"{\"type\":\"create-named-playlist\",\"payload\":{\"title\":\" padded \",\"requestId\":\"named-ui-4\"}}",
|
||||
out _,
|
||||
out _));
|
||||
Assert.False(LegacyBridgeProtocol.TryParseIntent(
|
||||
"{\"type\":\"refresh-named-playlists\",\"payload\":{}}",
|
||||
out _,
|
||||
out _));
|
||||
Assert.False(LegacyBridgeProtocol.TryParseIntent(
|
||||
"{\"type\":\"delete-selected-named-playlist\",\"payload\":{}}",
|
||||
out _,
|
||||
out _));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Text.Json;
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
using MBN_STOCK_WEBVIEW.LegacyBridge;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests;
|
||||
|
||||
public sealed class LegacyInteractionMetricsBridgeTests
|
||||
{
|
||||
[Fact]
|
||||
public void SerializeState_ProjectsExplicitSystemDragMetricsWithCamelCaseNames()
|
||||
{
|
||||
var json = LegacyBridgeProtocol.SerializeState(
|
||||
CreateSnapshot(),
|
||||
interactionMetrics: new LegacyInteractionMetrics(7, 9, 96, 1.5));
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var payload = document.RootElement.GetProperty("payload");
|
||||
var metrics = payload.GetProperty("interactionMetrics");
|
||||
|
||||
Assert.Equal(7, metrics.GetProperty("cutDragWidthDips").GetInt32());
|
||||
Assert.Equal(9, metrics.GetProperty("cutDragHeightDips").GetInt32());
|
||||
Assert.Equal("host-dip", metrics.GetProperty("coordinateSpace").GetString());
|
||||
Assert.Equal(96u, metrics.GetProperty("sourceDpi").GetUInt32());
|
||||
Assert.Equal(1.5d, metrics.GetProperty("hostRasterizationScale").GetDouble());
|
||||
Assert.False(metrics.TryGetProperty("CutDragWidthDips", out _));
|
||||
Assert.False(metrics.TryGetProperty("CutDragHeightDips", out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(6, 9, 144u, 4, 6)]
|
||||
[InlineData(5, 5, 120u, 4, 4)]
|
||||
[InlineData(7, 7, 168u, 4, 4)]
|
||||
public void FromPixelsAtDpi_ConvertsWindowsPixelsToCssPixelCoordinates(
|
||||
int physicalWidth,
|
||||
int physicalHeight,
|
||||
uint dpi,
|
||||
int expectedCssWidth,
|
||||
int expectedCssHeight)
|
||||
{
|
||||
var metrics = LegacyInteractionMetrics.FromPixelsAtDpi(
|
||||
physicalWidth,
|
||||
physicalHeight,
|
||||
dpi);
|
||||
|
||||
Assert.Equal(expectedCssWidth, metrics.CutDragWidthDips);
|
||||
Assert.Equal(expectedCssHeight, metrics.CutDragHeightDips);
|
||||
Assert.Equal(dpi, metrics.SourceDpi);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromPixelsAtDpi_RejectsUnknownDpiAndInvalidAxesSafely()
|
||||
{
|
||||
var unknownDpi = LegacyInteractionMetrics.FromPixelsAtDpi(8, 8, 0, 1.5);
|
||||
Assert.Equal(4, unknownDpi.CutDragWidthDips);
|
||||
Assert.Equal(4, unknownDpi.CutDragHeightDips);
|
||||
Assert.Equal(1.5d, unknownDpi.HostRasterizationScale);
|
||||
|
||||
var metrics = LegacyInteractionMetrics.FromPixelsAtDpi(0, -1, 192);
|
||||
Assert.Equal(4, metrics.CutDragWidthDips);
|
||||
Assert.Equal(4, metrics.CutDragHeightDips);
|
||||
Assert.Equal(192u, metrics.SourceDpi);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeState_NormalizesInvalidSystemMetricsPerAxisToFourPixels()
|
||||
{
|
||||
var json = LegacyBridgeProtocol.SerializeState(
|
||||
CreateSnapshot(),
|
||||
interactionMetrics: new LegacyInteractionMetrics(0, -1, 0, double.NaN));
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var metrics = document.RootElement
|
||||
.GetProperty("payload")
|
||||
.GetProperty("interactionMetrics");
|
||||
|
||||
Assert.Equal(4, metrics.GetProperty("cutDragWidthDips").GetInt32());
|
||||
Assert.Equal(4, metrics.GetProperty("cutDragHeightDips").GetInt32());
|
||||
Assert.Equal("host-dip", metrics.GetProperty("coordinateSpace").GetString());
|
||||
Assert.Equal(96u, metrics.GetProperty("sourceDpi").GetUInt32());
|
||||
Assert.Equal(1d, metrics.GetProperty("hostRasterizationScale").GetDouble());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeState_ExistingCallShapeReceivesSafeDefaultMetrics()
|
||||
{
|
||||
var json = LegacyBridgeProtocol.SerializeState(CreateSnapshot());
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var metrics = document.RootElement
|
||||
.GetProperty("payload")
|
||||
.GetProperty("interactionMetrics");
|
||||
|
||||
Assert.Equal(4, metrics.GetProperty("cutDragWidthDips").GetInt32());
|
||||
Assert.Equal(4, metrics.GetProperty("cutDragHeightDips").GetInt32());
|
||||
Assert.Equal("host-dip", metrics.GetProperty("coordinateSpace").GetString());
|
||||
Assert.Equal(96u, metrics.GetProperty("sourceDpi").GetUInt32());
|
||||
Assert.Equal(1d, metrics.GetProperty("hostRasterizationScale").GetDouble());
|
||||
}
|
||||
|
||||
private static LegacyOperatorSnapshot CreateSnapshot() => new(
|
||||
1,
|
||||
string.Empty,
|
||||
[],
|
||||
null,
|
||||
[],
|
||||
[],
|
||||
null,
|
||||
string.Empty,
|
||||
LegacyOperatorStatusKind.Neutral);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ public sealed class LegacyHiddenNonOriginalControlsTests
|
||||
"styles.css"));
|
||||
|
||||
[Fact]
|
||||
public void Non_original_shortcuts_are_hidden_but_original_dissolve_control_is_visible()
|
||||
public void Non_original_shortcuts_and_original_hidden_dissolve_panel_are_not_operator_controls()
|
||||
{
|
||||
Assert.Contains("class=\"manual-list-actions\" aria-label=\"수동 송출 목록\" hidden",
|
||||
Markup,
|
||||
@@ -25,13 +25,14 @@ public sealed class LegacyHiddenNonOriginalControlsTests
|
||||
Assert.Contains("id=\"playout-prepare\" class=\"prepare\" type=\"button\" disabled hidden",
|
||||
Markup,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("class=\"fade-control\" for=\"playout-fade-duration\">DissolveTime</label>",
|
||||
Assert.Contains("class=\"fade-control\" for=\"playout-fade-duration\" hidden",
|
||||
Markup,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("id=\"playout-fade-duration\" disabled aria-label=\"DissolveTime\"",
|
||||
Assert.Contains("aria-hidden=\"true\">DissolveTime</label>",
|
||||
Markup,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("id=\"playout-fade-duration\" disabled aria-label=\"DissolveTime\" hidden",
|
||||
Assert.Contains(
|
||||
"id=\"playout-fade-duration\" disabled hidden aria-hidden=\"true\" tabindex=\"-1\"",
|
||||
Markup,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("*[hidden] { display: none !important; }", Styles,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
|
||||
|
||||
public sealed class LegacyInteractionMetricsNativeContractTests
|
||||
{
|
||||
private static readonly string RepositoryRoot = FindRepositoryRoot();
|
||||
private static readonly string AppRoot = Path.Combine(
|
||||
RepositoryRoot,
|
||||
"src",
|
||||
"MBN_STOCK_WEBVIEW.LegacyParityApp");
|
||||
private static readonly string Window = File.ReadAllText(
|
||||
Path.Combine(AppRoot, "MainWindow.xaml.cs"));
|
||||
|
||||
[Fact]
|
||||
public void NativeWindowReadsBothWindowsDragThresholdsInCssDipCoordinates()
|
||||
{
|
||||
Assert.Contains("private const int SmCxDrag = 68;", Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("private const int SmCyDrag = 69;", Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("[DllImport(\"user32.dll\", ExactSpelling = true)]", Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"GetSystemMetricsForDpi(\n" +
|
||||
" SmCxDrag,\n" +
|
||||
" LegacyInteractionMetrics.CssPixelsPerInch)",
|
||||
Window.Replace("\r\n", "\n", StringComparison.Ordinal),
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"GetSystemMetricsForDpi(\n" +
|
||||
" SmCyDrag,\n" +
|
||||
" LegacyInteractionMetrics.CssPixelsPerInch)",
|
||||
Window.Replace("\r\n", "\n", StringComparison.Ordinal),
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("GetSystemMetrics(", Window, StringComparison.Ordinal);
|
||||
Assert.Contains("LegacyInteractionMetrics.FromPixelsAtDpi(", Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("logical DIP coordinate space used by XAML", Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("Root.XamlRoot?.RasterizationScale", Window,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NativeWindowRefreshesMetricsAfterRuntimeSettingsAndDpiChanges()
|
||||
{
|
||||
Assert.Contains("interactionMetrics: _interactionMetrics", Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("private const uint WmSettingChange = 0x001A;", Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("private const uint WmDisplayChange = 0x007E;", Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("private const uint WmDpiChanged = 0x02E0;", Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("AttachInteractionMetricsRefresh();", Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("DetachInteractionMetricsRefresh();", Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("SetWindowSubclass(", Window, StringComparison.Ordinal);
|
||||
Assert.Contains("RemoveWindowSubclass(", Window, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"message is WmSettingChange or WmDisplayChange or WmDpiChanged",
|
||||
Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("QueueInteractionMetricsRefresh();", Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("DispatcherQueuePriority.High", Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.True(
|
||||
CountOccurrences(Window, "RefreshInteractionMetrics();") >= 3,
|
||||
"Metrics must be refreshed at setup, runtime change, and state posting.");
|
||||
Assert.DoesNotContain(
|
||||
"private readonly LegacyInteractionMetrics _interactionMetrics",
|
||||
Window,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NativeWindowDisablesFutureWebZoomAndFallsBackWithoutPrivateApis()
|
||||
{
|
||||
Assert.Contains(
|
||||
"coreWebView.Settings.AreBrowserAcceleratorKeysEnabled = false;",
|
||||
Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("coreWebView.Settings.IsZoomControlEnabled = false;", Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("coreWebView.Settings.IsPinchZoomEnabled = false;", Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("CoreWebView2Controller.ZoomFactor", Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("catch (DllNotFoundException)", Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("catch (EntryPointNotFoundException)", Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("catch (BadImageFormatException)", Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("catch (PlatformNotSupportedException)", Window,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Equal(
|
||||
4,
|
||||
CountOccurrences(Window, "return CreateFallbackInteractionMetrics();"));
|
||||
Assert.Contains(
|
||||
"LegacyInteractionMetrics.Default with\n" +
|
||||
" {\n" +
|
||||
" HostRasterizationScale = ReadHostRasterizationScale()",
|
||||
Window.Replace("\r\n", "\n", StringComparison.Ordinal),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string value, string search)
|
||||
{
|
||||
var count = 0;
|
||||
var index = 0;
|
||||
while ((index = value.IndexOf(search, index, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
index += search.Length;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "MBN_STOCK_WEBVIEW.sln")))
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Repository root was not found.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
|
||||
|
||||
public sealed class LegacyNamedPlaylistDispatchFailureContractTests
|
||||
{
|
||||
private static readonly string RepositoryRoot = FindRepositoryRoot();
|
||||
private static readonly string MainWindow = File.ReadAllText(Path.Combine(
|
||||
RepositoryRoot,
|
||||
"src",
|
||||
"MBN_STOCK_WEBVIEW.LegacyParityApp",
|
||||
"MainWindow.xaml.cs"));
|
||||
|
||||
[Fact]
|
||||
public void NativeRejectionsAndUnexpectedExceptionsTerminateCorrelatedCommands()
|
||||
{
|
||||
Assert.Contains(
|
||||
"PostState(IsNamedPlaylistModalIntent(intent)",
|
||||
MainWindow,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"var failureState = ReportIntentFailure(",
|
||||
MainWindow,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"ObserveOperatorMutationQuarantine(failureState);",
|
||||
MainWindow,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"writeOutcomeMayBeUnknown: dispatchStarted",
|
||||
MainWindow,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"_controller.ReportNamedPlaylistWriteOutcomeUnknown(",
|
||||
MainWindow,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("RefreshNamedPlaylistsForDispatchAsync(", MainWindow,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("SelectNamedPlaylistForDispatch(", MainWindow,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("CreateNamedPlaylistForDispatchAsync(", MainWindow,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("DeleteNamedPlaylistForDispatchAsync(", MainWindow,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("_controller.CompleteNamedPlaylistDispatch(", MainWindow,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"_controller.ReportNamedPlaylistCommandFailure(\n" +
|
||||
" \"load-named-playlist-by-id\"",
|
||||
MainWindow.Replace("\r\n", "\n", StringComparison.Ordinal),
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"_controller.ReportNamedPlaylistCommandFailure(\n" +
|
||||
" \"save-current-named-playlist-to\"",
|
||||
MainWindow.Replace("\r\n", "\n", StringComparison.Ordinal),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectionUsesAnExactReceiptWithoutDisablingTheSecondDoubleClick()
|
||||
{
|
||||
var normalized = MainWindow.Replace("\r\n", "\n", StringComparison.Ordinal);
|
||||
|
||||
Assert.Contains(
|
||||
"LegacySelectNamedPlaylistIntent selection =>\n" +
|
||||
" SelectNamedPlaylistForDispatch(",
|
||||
normalized,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"intent is LegacyRefreshNamedPlaylistsIntent or\n" +
|
||||
" LegacySelectNamedPlaylistIntent or",
|
||||
normalized,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"LegacyRefreshNamedPlaylistsIntent or\n" +
|
||||
" LegacySelectNamedPlaylistIntent or",
|
||||
normalized,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"\"select-named-playlist\",\n" +
|
||||
" requestId,\n" +
|
||||
" succeeded",
|
||||
normalized,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "MBN_STOCK_WEBVIEW.sln")))
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Repository root was not found.");
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ public sealed class LegacyPlayoutWebContractTests
|
||||
Path.Combine(WebRoot, "playout-ui.js"));
|
||||
|
||||
[Fact]
|
||||
public void ShellExposesOriginalPlayoutBackgroundAndFadeControls()
|
||||
public void ShellExposesBackgroundControlsButKeepsOriginalHiddenFadeStateInert()
|
||||
{
|
||||
foreach (var id in new[]
|
||||
{
|
||||
@@ -40,10 +40,11 @@ public sealed class LegacyPlayoutWebContractTests
|
||||
var fadeMarkup = ExtractElement(Markup, "playout-fade-duration", "</select>");
|
||||
Assert.Equal(20, CountOccurrences(fadeMarkup, "<option value="));
|
||||
Assert.Contains(
|
||||
"class=\"fade-control\" for=\"playout-fade-duration\">DissolveTime</label>",
|
||||
"class=\"fade-control\" for=\"playout-fade-duration\" hidden",
|
||||
Markup,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(" hidden", fadeMarkup, StringComparison.Ordinal);
|
||||
Assert.Contains("hidden aria-hidden=\"true\" tabindex=\"-1\"", fadeMarkup,
|
||||
StringComparison.Ordinal);
|
||||
for (var index = 0; index < 20; index++)
|
||||
{
|
||||
Assert.Contains(
|
||||
@@ -65,8 +66,7 @@ public sealed class LegacyPlayoutWebContractTests
|
||||
"next-playout",
|
||||
"take-out",
|
||||
"choose-background",
|
||||
"toggle-background",
|
||||
"set-fade-duration"
|
||||
"toggle-background"
|
||||
})
|
||||
{
|
||||
Assert.Contains($"\"{intent}\"", Script, StringComparison.Ordinal);
|
||||
@@ -82,6 +82,14 @@ public sealed class LegacyPlayoutWebContractTests
|
||||
Assert.Contains("post(\"take-in\", {})", Script, StringComparison.Ordinal);
|
||||
Assert.Contains("post(\"next-playout\", {})", Script, StringComparison.Ordinal);
|
||||
Assert.Contains("post(\"take-out\", {})", Script, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("post(\"set-fade-duration\"", Script,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("fadeDuration.addEventListener", Script,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("fadeDuration.disabled = true;", Script,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("fadeDuration.value = String(playout.fadeDuration);", Script,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -92,7 +100,11 @@ public sealed class LegacyPlayoutWebContractTests
|
||||
Assert.Contains("event.key === \"F3\"", Script, StringComparison.Ordinal);
|
||||
Assert.Contains("event.key === \"F8\"", Script, StringComparison.Ordinal);
|
||||
Assert.Contains("event.key === \"Escape\"", Script, StringComparison.Ordinal);
|
||||
Assert.Contains("event.defaultPrevented || event.repeat || hasOpenDialog()", Script,
|
||||
Assert.Contains("if (event.defaultPrevented) return;", Script,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("if (hasOpenDialog())", Script,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("if (legacyShortcut) event.preventDefault();", Script,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
@@ -56,18 +56,36 @@ public sealed class LegacyRowDoubleClickParityTests
|
||||
var doubleClick = Extract(
|
||||
"namedPlaylistDefinitions.addEventListener(\"dblclick\"",
|
||||
"namedPlaylistDefinitions.addEventListener(\"click\"");
|
||||
var action = Extract(
|
||||
"function runNamedPlaylistDoubleClick",
|
||||
"function lockNamedPlaylistControls");
|
||||
var confirmation = Extract(
|
||||
"function resolveNamedPlaylistConfirmation",
|
||||
"function renderNamedPlaylist");
|
||||
var matcher = Extract(
|
||||
"function isMatchingNamedPlaylistReceipt",
|
||||
"function openNamedPlaylistConfirmation");
|
||||
var render = Extract("function renderNamedPlaylist", "function renderOperatorCatalog");
|
||||
|
||||
Assert.Contains("\"load-named-playlist-by-id\"", doubleClick,
|
||||
Assert.Contains("namedPlaylistSelection.defer", doubleClick,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("\"save-current-named-playlist-to\"", doubleClick,
|
||||
Assert.Contains("runNamedPlaylistDoubleClick", doubleClick,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("\"load-named-playlist-by-id\"", action,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("openNamedPlaylistConfirmation", action,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("message: \"저장하겠습니까?\"", action,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("showSavedAlert: false", action, StringComparison.Ordinal);
|
||||
Assert.Contains("closeOnNo: true", action, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("window.confirm", action + doubleClick, StringComparison.Ordinal);
|
||||
Assert.Contains("\"save-current-named-playlist-to\"", confirmation,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("window.confirm", doubleClick, StringComparison.Ordinal);
|
||||
Assert.Contains("definitionId: definitionId", doubleClick, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("select-named-playlist", doubleClick, StringComparison.Ordinal);
|
||||
Assert.Contains("commandResult.succeeded === true", render, StringComparison.Ordinal);
|
||||
Assert.Contains("commandResult.targetId === pendingNamedPlaylistCommand.definitionId",
|
||||
render,
|
||||
Assert.Contains("commandResult.targetId === pending.targetId",
|
||||
matcher,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("closeNamedPlaylist()", render, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
@@ -161,8 +161,11 @@ public sealed class LegacyWebContractTests
|
||||
Assert.Contains("event.dataTransfer.setData(playlistDragMime, rowId)", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("selectedRowIds", App, StringComparison.Ordinal);
|
||||
Assert.Contains("element.draggable = !cutDragLocked", App, StringComparison.Ordinal);
|
||||
Assert.Contains("if (playlistMutationLocked || !event.dataTransfer)", App,
|
||||
Assert.Contains("element.draggable = false", App, StringComparison.Ordinal);
|
||||
Assert.Contains("beginCutDragGesture(element, event, position)", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("window.LegacyCutDrag.shouldStart", App, StringComparison.Ordinal);
|
||||
Assert.Contains("if (playlistMutationLocked || !rowId || !event.dataTransfer", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("if (!playlistMutationLocked) send(\"delete-selected-playlist-rows\"", App,
|
||||
StringComparison.Ordinal);
|
||||
@@ -181,7 +184,7 @@ public sealed class LegacyWebContractTests
|
||||
"} else if ((event.key === \"Home\"",
|
||||
StringComparison.Ordinal);
|
||||
var homeEndBranchEnd = App.IndexOf(
|
||||
"} else if (event.key === \"Escape\"",
|
||||
"\n });",
|
||||
homeEndBranchStart,
|
||||
StringComparison.Ordinal);
|
||||
Assert.True(homeEndBranchStart >= 0 && homeEndBranchEnd > homeEndBranchStart);
|
||||
@@ -348,9 +351,9 @@ public sealed class LegacyWebContractTests
|
||||
{
|
||||
"refresh-named-playlists",
|
||||
"select-named-playlist",
|
||||
"load-selected-named-playlist",
|
||||
"load-named-playlist-by-id",
|
||||
"create-named-playlist",
|
||||
"save-current-named-playlist",
|
||||
"save-current-named-playlist-to",
|
||||
"delete-selected-named-playlist"
|
||||
})
|
||||
{
|
||||
@@ -359,9 +362,16 @@ public sealed class LegacyWebContractTests
|
||||
|
||||
Assert.Contains("state.namedPlaylist", App, StringComparison.Ordinal);
|
||||
Assert.Contains("dataset.namedPlaylistDefinitionId", App, StringComparison.Ordinal);
|
||||
Assert.Contains("definitionId: definition.dataset.namedPlaylistDefinitionId", App,
|
||||
Assert.Contains(
|
||||
"beginNamedPlaylistSelection(definition.dataset.namedPlaylistDefinitionId)", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"dbLoadButton.disabled = operatorMutationLocked || busy || !named",
|
||||
App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("requestId: requestId", App, StringComparison.Ordinal);
|
||||
Assert.Contains("commandResult.targetId === pending.targetId", App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("dbLoadButton.disabled = busy || !named", App, StringComparison.Ordinal);
|
||||
Assert.Contains("named.isWriteQuarantined", App, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("programCode", App, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("listText", App, StringComparison.OrdinalIgnoreCase);
|
||||
@@ -375,8 +385,13 @@ public sealed class LegacyWebContractTests
|
||||
[Fact]
|
||||
public void SeparatorRowsPreserveOriginalDragGesture()
|
||||
{
|
||||
Assert.Contains("element.draggable = true", App, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("draggable = !row.isSeparator", App, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"element.dataset.cutDragEnabled = cutSelectionEnabled ? \"true\" : \"false\"",
|
||||
App,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains("element.draggable = false", App, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("cutDragEnabled = !row.isSeparator", App,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user