feat: restore legacy modal and drag behavior

This commit is contained in:
2026-07-16 01:33:48 +09:00
parent 83398044c6
commit d7ec571343
30 changed files with 3597 additions and 321 deletions

View File

@@ -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", () => {