feat: restore legacy modal and drag behavior
This commit is contained in:
@@ -69,15 +69,19 @@ test("legacy stock double click is represented by pointer-down facts", () => {
|
||||
assert.match(app, /stockResults\.querySelectorAll\("button\[data-result-index\]"\)/);
|
||||
});
|
||||
|
||||
test("cut rows are reconciled in place so a state reply cannot cancel native drag", () => {
|
||||
test("cut rows are reconciled in place while the legacy pointer drag remains captured", () => {
|
||||
assert.match(app, /dataset\.physicalIndex/);
|
||||
assert.match(app, /function createCutElement/);
|
||||
const cutFactory = app.slice(
|
||||
app.indexOf("function createCutElement"),
|
||||
app.indexOf("function clearPlaylistDragVisuals"));
|
||||
assert.match(cutFactory, /if \(playlistMutationLocked \|\| !event\.dataTransfer\)/);
|
||||
assert.match(cutFactory, /beginCutDragGesture\(element, event, position\)/);
|
||||
assert.match(cutFactory, /addEventListener\("pointermove", onCutPointerMove\)/);
|
||||
assert.match(cutFactory, /addEventListener\("lostpointercapture", onCutPointerCancel\)/);
|
||||
assert.match(cutFactory, /const cutDragLocked = isOperatorMutationLocked\(state\)/);
|
||||
assert.match(cutFactory, /element\.draggable = !cutDragLocked/);
|
||||
assert.match(cutFactory, /element\.draggable = false/);
|
||||
assert.match(cutFactory, /dataset\.cutDragEnabled = cutSelectionEnabled/);
|
||||
assert.doesNotMatch(cutFactory, /addEventListener\("dragstart"/);
|
||||
assert.doesNotMatch(app, /cutList\.replaceChildren/);
|
||||
});
|
||||
|
||||
@@ -163,7 +167,7 @@ test("IDLE Escape keeps the emergency StopAll path available", () => {
|
||||
/takeOut\.disabled[\s\S]{0,120}playout\.phase !== "prepared"/);
|
||||
});
|
||||
|
||||
test("named-playlist modal shortcuts cannot leak into the playout listener", () => {
|
||||
test("legacy child modal Escape is inert and cannot leak into the playout listener", () => {
|
||||
const appScript = markup.indexOf('<script src="app.js"></script>');
|
||||
const playoutScript = markup.indexOf('<script src="playout-ui.js"></script>');
|
||||
assert.ok(appScript >= 0 && playoutScript > appScript);
|
||||
@@ -175,8 +179,11 @@ test("named-playlist modal shortcuts cannot leak into the playout listener", ()
|
||||
assert.ok(appHandlerStart >= 0 && appHandlerEnd > appHandlerBodyStart);
|
||||
const handleAppKeydown = new Function(
|
||||
"event",
|
||||
"namedPlaylistModal",
|
||||
"closeNamedPlaylist",
|
||||
"modalFocusManager",
|
||||
"cutDragGesture",
|
||||
"clearCutDragGesture",
|
||||
"legacyAlertDialog",
|
||||
"send",
|
||||
app.slice(appHandlerBodyStart, appHandlerEnd));
|
||||
|
||||
const playoutHandlerStart = playout.lastIndexOf(keydownMarker);
|
||||
@@ -201,28 +208,39 @@ test("named-playlist modal shortcuts cannot leak into the playout listener", ()
|
||||
const modal = { hidden: false };
|
||||
|
||||
for (const key of ["F2", "F3", "F8"]) {
|
||||
const modalKey = {
|
||||
key,
|
||||
repeat: false,
|
||||
defaultPrevented: false,
|
||||
preventDefault() { this.defaultPrevented = true; }
|
||||
};
|
||||
handlePlayoutKeydown(
|
||||
{ key, repeat: false, defaultPrevented: false, preventDefault() {} },
|
||||
modalKey,
|
||||
() => !modal.hidden,
|
||||
backgroundFile,
|
||||
backgroundNone,
|
||||
takeIn,
|
||||
takeOut);
|
||||
assert.equal(modalKey.defaultPrevented, true, key + " browser default must be consumed");
|
||||
}
|
||||
assert.deepEqual(clicks, []);
|
||||
|
||||
let closeCount = 0;
|
||||
const escape = new Event("keydown", { cancelable: true });
|
||||
Object.defineProperties(escape, {
|
||||
key: { value: "Escape" },
|
||||
repeat: { value: false }
|
||||
});
|
||||
handleAppKeydown(escape, modal, () => {
|
||||
closeCount += 1;
|
||||
modal.hidden = true;
|
||||
});
|
||||
assert.equal(closeCount, 1);
|
||||
assert.equal(modal.hidden, true);
|
||||
const childDialog = {};
|
||||
const legacyAlertDialog = {};
|
||||
const sent = [];
|
||||
handleAppKeydown(
|
||||
escape,
|
||||
{ getActiveModal() { return childDialog; } },
|
||||
null,
|
||||
() => {},
|
||||
legacyAlertDialog,
|
||||
(type) => sent.push(type));
|
||||
assert.equal(modal.hidden, false);
|
||||
assert.equal(escape.defaultPrevented, true);
|
||||
|
||||
handlePlayoutKeydown(
|
||||
@@ -233,6 +251,57 @@ test("named-playlist modal shortcuts cannot leak into the playout listener", ()
|
||||
takeIn,
|
||||
takeOut);
|
||||
assert.deepEqual(clicks, []);
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
const alertEscape = new Event("keydown", { cancelable: true });
|
||||
Object.defineProperties(alertEscape, {
|
||||
key: { value: "Escape" },
|
||||
repeat: { value: false }
|
||||
});
|
||||
handleAppKeydown(
|
||||
alertEscape,
|
||||
{ getActiveModal() { return legacyAlertDialog; } },
|
||||
null,
|
||||
() => {},
|
||||
legacyAlertDialog,
|
||||
(type) => sent.push(type));
|
||||
assert.equal(alertEscape.defaultPrevented, true);
|
||||
assert.deepEqual(sent, ["dismiss-dialog"]);
|
||||
});
|
||||
|
||||
test("started cut drag owns playout keys and Escape cancels without TAKE OUT", () => {
|
||||
const keydownMarker = 'document.addEventListener("keydown", function (event) {';
|
||||
const start = app.lastIndexOf(keydownMarker) + keydownMarker.length;
|
||||
const end = app.indexOf("\n });", start);
|
||||
const handle = new Function(
|
||||
"event",
|
||||
"modalFocusManager",
|
||||
"cutDragGesture",
|
||||
"clearCutDragGesture",
|
||||
"legacyAlertDialog",
|
||||
"send",
|
||||
app.slice(start, end));
|
||||
const sent = [];
|
||||
let clears = 0;
|
||||
const manager = { getActiveModal() { return null; } };
|
||||
for (const key of ["F2", "F3", "F8", "Escape"]) {
|
||||
const event = {
|
||||
key,
|
||||
repeat: false,
|
||||
defaultPrevented: false,
|
||||
preventDefault() { this.defaultPrevented = true; }
|
||||
};
|
||||
handle(
|
||||
event,
|
||||
manager,
|
||||
{ started: true },
|
||||
() => { clears += 1; },
|
||||
{},
|
||||
(type) => sent.push(type));
|
||||
assert.equal(event.defaultPrevented, true, key + " must stay inside drag loop");
|
||||
}
|
||||
assert.equal(clears, 1, "only Escape cancels the active drag");
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test("playlist controls send intent for selection, enabled state, movement and deletion", () => {
|
||||
@@ -267,7 +336,7 @@ test("playlist controls send intent for selection, enabled state, movement and d
|
||||
const homeEndBranchStart = app.lastIndexOf('} else if ((event.key === "Home"');
|
||||
const homeEndBranch = app.slice(
|
||||
homeEndBranchStart,
|
||||
app.indexOf('} else if (event.key === "Escape"', homeEndBranchStart));
|
||||
app.indexOf("\n });", homeEndBranchStart));
|
||||
assert.doesNotMatch(homeEndBranch, /event\.preventDefault\(\)/);
|
||||
const boundaryHelperStart = app.indexOf("function selectPlaylistBoundary");
|
||||
const boundaryHelper = app.slice(
|
||||
@@ -468,76 +537,26 @@ test("playlist native drag sends one source row and rejects checkbox drag", () =
|
||||
assert.equal(Object.hasOwn(sent[0].payload, "selectedRowIds"), false);
|
||||
});
|
||||
|
||||
test("playlist drop zone accepts only the exact selected-cut drag token", () => {
|
||||
class MiniDataTransfer {
|
||||
constructor() { this.values = new Map(); }
|
||||
get types() { return [...this.values.keys()]; }
|
||||
setData(type, value) { this.values.set(type, value); }
|
||||
getData(type) { return this.values.get(type) || ""; }
|
||||
}
|
||||
|
||||
const sourceStart = app.indexOf("function hasDragType");
|
||||
const sourceEnd = app.indexOf(
|
||||
'playlistMoveUp.addEventListener("click"', sourceStart);
|
||||
assert.ok(sourceStart >= 0 && sourceEnd > sourceStart);
|
||||
const playlistDropZone = new EventTarget();
|
||||
const sent = [];
|
||||
const bindDropZone = new Function(
|
||||
"playlistDropZone",
|
||||
"send",
|
||||
"selectedCutsDragToken",
|
||||
"playlistMutationLocked",
|
||||
app.slice(sourceStart, sourceEnd));
|
||||
bindDropZone(
|
||||
playlistDropZone,
|
||||
(type, payload) => sent.push({ type, payload }),
|
||||
"legacy-selected-cuts",
|
||||
false);
|
||||
|
||||
const playlistTransfer = new MiniDataTransfer();
|
||||
playlistTransfer.setData("text/x-mbn-playlist-row", "row-a");
|
||||
const playlistDrop = new Event("drop", { cancelable: true, bubbles: true });
|
||||
Object.assign(playlistDrop, { dataTransfer: playlistTransfer });
|
||||
playlistDropZone.dispatchEvent(playlistDrop);
|
||||
assert.equal(playlistDrop.defaultPrevented, false);
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
const externalText = new MiniDataTransfer();
|
||||
externalText.setData("text/plain", "external-text");
|
||||
const externalDrop = new Event("drop", { cancelable: true, bubbles: true });
|
||||
Object.assign(externalDrop, { dataTransfer: externalText });
|
||||
playlistDropZone.dispatchEvent(externalDrop);
|
||||
assert.equal(externalDrop.defaultPrevented, false);
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
const cutTransfer = new MiniDataTransfer();
|
||||
cutTransfer.setData("text/plain", "legacy-selected-cuts");
|
||||
const cutOver = new Event("dragover", { cancelable: true });
|
||||
Object.assign(cutOver, { dataTransfer: cutTransfer });
|
||||
playlistDropZone.dispatchEvent(cutOver);
|
||||
assert.equal(cutOver.defaultPrevented, true);
|
||||
const cutDrop = new Event("drop", { cancelable: true, bubbles: true });
|
||||
Object.assign(cutDrop, { dataTransfer: cutTransfer });
|
||||
playlistDropZone.dispatchEvent(cutDrop);
|
||||
assert.equal(cutDrop.defaultPrevented, true);
|
||||
assert.deepEqual(sent, [{ type: "drop-selected-cuts", payload: {} }]);
|
||||
|
||||
const lockedDropZone = new EventTarget();
|
||||
const lockedSent = [];
|
||||
bindDropZone(
|
||||
lockedDropZone,
|
||||
(type, payload) => lockedSent.push({ type, payload }),
|
||||
"legacy-selected-cuts",
|
||||
true);
|
||||
const lockedOver = new Event("dragover", { cancelable: true });
|
||||
Object.assign(lockedOver, { dataTransfer: cutTransfer });
|
||||
lockedDropZone.dispatchEvent(lockedOver);
|
||||
assert.equal(lockedOver.defaultPrevented, false);
|
||||
const lockedDrop = new Event("drop", { cancelable: true, bubbles: true });
|
||||
Object.assign(lockedDrop, { dataTransfer: cutTransfer });
|
||||
lockedDropZone.dispatchEvent(lockedDrop);
|
||||
assert.equal(lockedDrop.defaultPrevented, false);
|
||||
assert.deepEqual(lockedSent, []);
|
||||
test("cut drag uses the original pointer rectangle and drops only inside the playlist", () => {
|
||||
const start = app.indexOf("function pointInsideElement");
|
||||
const end = app.indexOf("function legacyCutKey", start);
|
||||
assert.ok(start >= 0 && end > start);
|
||||
const drag = app.slice(start, end);
|
||||
assert.match(drag, /LegacyCutDrag\.createDragRectangle\(position, cutDragSize\)/);
|
||||
assert.match(drag, /LegacyCutDrag\.shouldStart\(gesture\.rectangle, pointerPosition\(event\)\)/);
|
||||
assert.match(drag, /event\.buttons & 1/);
|
||||
assert.match(drag, /pointInsideElement\(playlistDropZone, event\)/);
|
||||
assert.match(drag, /send\("drop-selected-cuts", \{\}\)/);
|
||||
assert.match(app, /state\.interactionMetrics/);
|
||||
assert.match(app, /metrics\.coordinateSpace !== "host-dip"/);
|
||||
assert.match(app, /LegacyCutDrag\.toCssDragSize/);
|
||||
assert.match(app, /window\.devicePixelRatio/);
|
||||
assert.match(app, /x: event\.clientX - bounds\.left/);
|
||||
assert.match(app, /x: Math\.round\(position\.x\)/);
|
||||
assert.doesNotMatch(app, /x: Math\.round\(event\.clientX - bounds\.left\)/);
|
||||
assert.match(app,
|
||||
/cutDragGesture\.rectangle = window\.LegacyCutDrag\.createDragRectangle/);
|
||||
assert.doesNotMatch(app, /selectedCutsDragToken|setData\("text\/plain"/);
|
||||
});
|
||||
|
||||
test("operator catalog drag rejects an interactive child pointer origin", () => {
|
||||
@@ -931,14 +950,16 @@ test("UC1 tree render preserves state but resets Expand and first root on tab re
|
||||
|
||||
const categoryTree = new MiniTree();
|
||||
const catalogExpandAll = { checked: false };
|
||||
const harness = new Function("categoryTree", "catalogExpandAll", "document", `
|
||||
const harness = new Function(
|
||||
"categoryTree", "catalogExpandAll", "document", "modalFocusManager", `
|
||||
let renderedTreeTabId = null;
|
||||
let treeInteractionLocked = false;
|
||||
let treeFocusOwnedBeforeRender = false;
|
||||
const treeUiStateByTab = new Map();
|
||||
const pendingTreeResetTabs = new Set();
|
||||
${app.slice(helperStart, helperEnd)}
|
||||
return { beginTreeRender, finishTreeRender, selectTreeNode };
|
||||
`)(categoryTree, catalogExpandAll, document);
|
||||
`)(categoryTree, catalogExpandAll, document, { getActiveModal() { return null; } });
|
||||
const state = tabId => ({
|
||||
isBusy: false,
|
||||
fixedSectionBatch: null,
|
||||
@@ -1140,13 +1161,14 @@ test("theme expert and trading halt actions preserve original single click wirin
|
||||
test("named DB playlists and playout use separate closed native intents", () => {
|
||||
for (const intent of [
|
||||
"refresh-named-playlists", "select-named-playlist",
|
||||
"load-selected-named-playlist", "create-named-playlist",
|
||||
"save-current-named-playlist", "delete-selected-named-playlist"
|
||||
"load-named-playlist-by-id", "create-named-playlist",
|
||||
"save-current-named-playlist-to", "delete-selected-named-playlist"
|
||||
]) assert.match(app, new RegExp("\\\"" + intent + "\\\""));
|
||||
assert.match(app, /state\.namedPlaylist/);
|
||||
assert.match(app, /dataset\.namedPlaylistDefinitionId/);
|
||||
assert.match(app, /definitionId: definition\.dataset\.namedPlaylistDefinitionId/);
|
||||
assert.match(app, /dbLoadButton\.disabled = busy \|\| !named/);
|
||||
assert.match(app,
|
||||
/beginNamedPlaylistSelection\(definition\.dataset\.namedPlaylistDefinitionId\)/);
|
||||
assert.match(app, /dbLoadButton\.disabled = operatorMutationLocked \|\| busy \|\| !named/);
|
||||
assert.match(app, /named\.isWriteQuarantined/);
|
||||
assert.doesNotMatch(app, /programCode|listText/i);
|
||||
|
||||
@@ -1155,8 +1177,14 @@ test("named DB playlists and playout use separate closed native intents", () =>
|
||||
}
|
||||
for (const intent of [
|
||||
"prepare-playout", "take-in", "next-playout", "take-out",
|
||||
"choose-background", "toggle-background", "set-fade-duration"
|
||||
"choose-background", "toggle-background"
|
||||
]) assert.match(playout, new RegExp("\\\"" + intent + "\\\""));
|
||||
assert.doesNotMatch(playout, /post\("set-fade-duration"/);
|
||||
assert.doesNotMatch(playout, /fadeDuration\.addEventListener/);
|
||||
assert.match(playout, /fadeDuration\.disabled = true;/);
|
||||
assert.match(playout, /fadeDuration\.value = String\(playout\.fadeDuration\);/);
|
||||
assert.match(markup,
|
||||
/id="playout-fade-duration" disabled hidden aria-hidden="true" tabindex="-1"/);
|
||||
assert.match(playout, /takeOut\.disabled = busy \|\| !commandAvailable;/);
|
||||
assert.match(playout, /playout\.isPlayCompletionPending === true/);
|
||||
assert.match(playout, /playout\.outcomeUnknown !== true/);
|
||||
@@ -1207,8 +1235,9 @@ test("FSell and VI dialogs send opaque native intents and never own file or stoc
|
||||
});
|
||||
|
||||
test("separator rows preserve the original drag gesture", () => {
|
||||
assert.match(app, /element\.draggable = true/);
|
||||
assert.doesNotMatch(app, /draggable = !row\.isSeparator/);
|
||||
assert.match(app, /element\.dataset\.cutDragEnabled = cutSelectionEnabled \? "true" : "false"/);
|
||||
assert.match(app, /element\.draggable = false/);
|
||||
assert.doesNotMatch(app, /cutDragEnabled = !row\.isSeparator/);
|
||||
});
|
||||
|
||||
test("a C# busy snapshot freezes controls instead of silently accepting queued input", () => {
|
||||
|
||||
Reference in New Issue
Block a user