feat: align legacy operator and playout behavior
This commit is contained in:
@@ -62,24 +62,183 @@ test("legacy stock double click is represented by pointer-down facts", () => {
|
||||
assert.match(app, /event\.shiftKey/);
|
||||
const cutFactory = app.slice(app.indexOf("function createCutElement"), app.indexOf("function renderCuts"));
|
||||
assert.doesNotMatch(cutFactory, /dblclick/);
|
||||
assert.match(cutFactory, /if \(playlistMutationLocked\) return/);
|
||||
assert.match(app, /send\("clear-cut-selection", \{\}\)/);
|
||||
assert.match(app, /event\.target !== cutList/);
|
||||
assert.match(app, /button\.addEventListener\("keydown"/);
|
||||
assert.match(app, /stockResults\.querySelectorAll\("button\[data-result-index\]"\)/);
|
||||
});
|
||||
|
||||
test("cut rows are reconciled in place so a state reply cannot cancel native drag", () => {
|
||||
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, /const cutDragLocked = isOperatorMutationLocked\(state\)/);
|
||||
assert.match(cutFactory, /element\.draggable = !cutDragLocked/);
|
||||
assert.doesNotMatch(app, /cutList\.replaceChildren/);
|
||||
});
|
||||
|
||||
test("state rendering preserves stock and playlist focus like native controls", () => {
|
||||
assert.match(app, /dataset\.resultIndex/);
|
||||
assert.match(app, /dataset\.rowId/);
|
||||
assert.match(app, /aria-current/);
|
||||
assert.match(app, /scrollIntoView\(\{ block: "nearest", inline: "nearest" \}\)/);
|
||||
assert.doesNotMatch(app, /stockResults\.replaceChildren/);
|
||||
assert.doesNotMatch(app, /playlistRows\.replaceChildren/);
|
||||
});
|
||||
|
||||
test("playlist keeps the gray candidate separate from the pink on-air row and page", () => {
|
||||
const sourceStart = app.indexOf("function playlistPlayoutPresentation");
|
||||
const sourceEnd = app.indexOf("function presentCurrentPlaylistRow", sourceStart);
|
||||
assert.ok(sourceStart >= 0 && sourceEnd > sourceStart);
|
||||
const presentation = new Function(`
|
||||
${app.slice(sourceStart, sourceEnd)}
|
||||
return playlistPlayoutPresentation;
|
||||
`)();
|
||||
const state = {
|
||||
playout: {
|
||||
phase: "program",
|
||||
currentEntryId: "row-current",
|
||||
currentCueIndexZeroBased: 1,
|
||||
pageIndexZeroBased: 1,
|
||||
pageCount: 3
|
||||
}
|
||||
};
|
||||
|
||||
assert.deepEqual(presentation(state, {
|
||||
rowId: "row-current", isSelected: false, pageText: "1/3"
|
||||
}, 1), {
|
||||
isSelected: false,
|
||||
isCurrent: true,
|
||||
isOnAir: true,
|
||||
pageText: "2/3",
|
||||
focusKey: "program:row-current:1:3"
|
||||
});
|
||||
assert.deepEqual(presentation(state, {
|
||||
rowId: "row-candidate", isSelected: true, pageText: "1/1"
|
||||
}, 1), {
|
||||
isSelected: true,
|
||||
isCurrent: false,
|
||||
isOnAir: false,
|
||||
pageText: "1/1",
|
||||
focusKey: ""
|
||||
});
|
||||
assert.equal(presentation({
|
||||
playout: { ...state.playout, currentEntryId: null }
|
||||
}, { rowId: "row-fallback", isSelected: false, pageText: "1/3" }, 1).isCurrent, true);
|
||||
assert.match(app, /presentation\.isOnAir \? " on-air"/);
|
||||
assert.match(app, /presentation\.isSelected \|\| isCandidate \? " selected"/);
|
||||
assert.match(app, /presentation\.pageText/);
|
||||
});
|
||||
|
||||
test("PROGRAM F8 remains TAKE IN while end-of-playlist NEXT is closed", () => {
|
||||
const sourceStart = playout.indexOf("function nextLabel");
|
||||
const sourceEnd = playout.indexOf("function render", sourceStart);
|
||||
assert.ok(sourceStart >= 0 && sourceEnd > sourceStart);
|
||||
const contract = new Function(`
|
||||
${playout.slice(sourceStart, sourceEnd)}
|
||||
return { nextLabel, canTakeInFromState, canAdvanceFromState };
|
||||
`)();
|
||||
|
||||
assert.equal(contract.canTakeInFromState({ phase: "idle" }, true), true);
|
||||
assert.equal(contract.canTakeInFromState({ phase: "prepared" }, false), true);
|
||||
assert.equal(contract.canTakeInFromState({ phase: "program" }, true), true);
|
||||
assert.equal(contract.canTakeInFromState({ phase: "program" }, false), false);
|
||||
assert.equal(contract.canAdvanceFromState({
|
||||
phase: "program", nextKind: "playlistNext"
|
||||
}), true);
|
||||
assert.equal(contract.canAdvanceFromState({
|
||||
phase: "program", nextKind: "endOfPlaylist"
|
||||
}), false);
|
||||
assert.match(contract.nextLabel({ nextKind: "endOfPlaylist" }), /마지막/);
|
||||
});
|
||||
|
||||
test("IDLE Escape keeps the emergency StopAll path available", () => {
|
||||
assert.match(playout, /takeOut\.disabled = busy \|\| !commandAvailable;/);
|
||||
assert.doesNotMatch(
|
||||
playout,
|
||||
/takeOut\.disabled[\s\S]{0,120}playout\.phase !== "prepared"/);
|
||||
});
|
||||
|
||||
test("named-playlist modal shortcuts cannot leak into the playout listener", () => {
|
||||
const appScript = markup.indexOf('<script src="app.js"></script>');
|
||||
const playoutScript = markup.indexOf('<script src="playout-ui.js"></script>');
|
||||
assert.ok(appScript >= 0 && playoutScript > appScript);
|
||||
|
||||
const keydownMarker = 'document.addEventListener("keydown", function (event) {';
|
||||
const appHandlerStart = app.lastIndexOf(keydownMarker);
|
||||
const appHandlerBodyStart = appHandlerStart + keydownMarker.length;
|
||||
const appHandlerEnd = app.indexOf("\n });", appHandlerBodyStart);
|
||||
assert.ok(appHandlerStart >= 0 && appHandlerEnd > appHandlerBodyStart);
|
||||
const handleAppKeydown = new Function(
|
||||
"event",
|
||||
"namedPlaylistModal",
|
||||
"closeNamedPlaylist",
|
||||
app.slice(appHandlerBodyStart, appHandlerEnd));
|
||||
|
||||
const playoutHandlerStart = playout.lastIndexOf(keydownMarker);
|
||||
const playoutHandlerBodyStart = playoutHandlerStart + keydownMarker.length;
|
||||
const playoutHandlerEnd = playout.indexOf("\n });", playoutHandlerBodyStart);
|
||||
assert.ok(playoutHandlerStart >= 0 && playoutHandlerEnd > playoutHandlerBodyStart);
|
||||
const handlePlayoutKeydown = new Function(
|
||||
"event",
|
||||
"hasOpenDialog",
|
||||
"backgroundFile",
|
||||
"backgroundNone",
|
||||
"takeIn",
|
||||
"takeOut",
|
||||
playout.slice(playoutHandlerBodyStart, playoutHandlerEnd));
|
||||
|
||||
const clicks = [];
|
||||
const button = name => ({ disabled: false, click() { clicks.push(name); } });
|
||||
const backgroundFile = button("choose-background");
|
||||
const backgroundNone = button("toggle-background");
|
||||
const takeIn = button("take-in");
|
||||
const takeOut = button("take-out");
|
||||
const modal = { hidden: false };
|
||||
|
||||
for (const key of ["F2", "F3", "F8"]) {
|
||||
handlePlayoutKeydown(
|
||||
{ key, repeat: false, defaultPrevented: false, preventDefault() {} },
|
||||
() => !modal.hidden,
|
||||
backgroundFile,
|
||||
backgroundNone,
|
||||
takeIn,
|
||||
takeOut);
|
||||
}
|
||||
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);
|
||||
assert.equal(escape.defaultPrevented, true);
|
||||
|
||||
handlePlayoutKeydown(
|
||||
escape,
|
||||
() => !modal.hidden,
|
||||
backgroundFile,
|
||||
backgroundNone,
|
||||
takeIn,
|
||||
takeOut);
|
||||
assert.deepEqual(clicks, []);
|
||||
});
|
||||
|
||||
test("playlist controls send intent for selection, enabled state, movement and deletion", () => {
|
||||
for (const intent of [
|
||||
"select-playlist-row",
|
||||
"activate-playlist-row",
|
||||
"set-playlist-enabled",
|
||||
"set-all-playlist-enabled",
|
||||
"reorder-playlist-rows",
|
||||
@@ -92,12 +251,34 @@ test("playlist controls send intent for selection, enabled state, movement and d
|
||||
assert.match(app, /control: event\.ctrlKey/);
|
||||
assert.match(app, /shift: event\.shiftKey/);
|
||||
assert.match(app, /event\.key === "Delete"/);
|
||||
assert.match(app, /toggle-active-playlist-enabled/);
|
||||
const deleteBranch = app.slice(
|
||||
app.lastIndexOf('if (event.key === "Delete"'),
|
||||
app.lastIndexOf('} else if (event.key === " "'));
|
||||
assert.match(
|
||||
deleteBranch,
|
||||
/if \(!playlistMutationLocked\) send\("delete-selected-playlist-rows"/);
|
||||
assert.doesNotMatch(deleteBranch, /event\.preventDefault\(\)/);
|
||||
assert.doesNotMatch(app, /send\("toggle-active-playlist-enabled"/);
|
||||
assert.match(app, /enabled: enabled\.checked !== true/);
|
||||
assert.match(app, /if \(!playlistMutationLocked\)/);
|
||||
assert.match(app, /select-playlist-boundary/);
|
||||
assert.match(app, /event\.key === "Home"/);
|
||||
assert.match(app, /event\.key === "End"/);
|
||||
const homeEndBranchStart = app.lastIndexOf('} else if ((event.key === "Home"');
|
||||
const homeEndBranch = app.slice(
|
||||
homeEndBranchStart,
|
||||
app.indexOf('} else if (event.key === "Escape"', homeEndBranchStart));
|
||||
assert.doesNotMatch(homeEndBranch, /event\.preventDefault\(\)/);
|
||||
const boundaryHelperStart = app.indexOf("function selectPlaylistBoundary");
|
||||
const boundaryHelper = app.slice(
|
||||
boundaryHelperStart,
|
||||
app.indexOf("function clearDelayedClick", boundaryHelperStart));
|
||||
assert.doesNotMatch(boundaryHelper, /\.focus\(/);
|
||||
assert.match(boundaryHelper, /scrollIntoView/);
|
||||
assert.match(app, /row\.isSelected/);
|
||||
assert.match(app, /enabled\.disabled = state\.isBusy === true/);
|
||||
assert.match(app, /row\.isActive === true/);
|
||||
assert.match(app, /enabled\.setAttribute\("aria-disabled", playlistMutationLocked/);
|
||||
assert.match(app, /interactionDisabled = playlistMutationLocked/);
|
||||
assert.match(app, /playlistEnableAll\.indeterminate/);
|
||||
});
|
||||
|
||||
@@ -109,7 +290,7 @@ test("playlist reorder reconciles keyed DOM nodes without discarding focus", ()
|
||||
assert.doesNotMatch(app, /playlistRows\.replaceChild/);
|
||||
});
|
||||
|
||||
test("playlist native drag preserves a multi-selection and rejects checkbox drag", () => {
|
||||
test("playlist native drag sends one source row and rejects checkbox drag", () => {
|
||||
class MiniClassList {
|
||||
constructor() { this.names = new Set(); }
|
||||
add(...names) { names.forEach((name) => this.names.add(name)); }
|
||||
@@ -242,6 +423,11 @@ test("playlist native drag preserves a multi-selection and rejects checkbox drag
|
||||
ctrlKey: false,
|
||||
shiftKey: false
|
||||
});
|
||||
assert.deepEqual(sent, [{
|
||||
type: "activate-playlist-row",
|
||||
payload: { rowId: "row-a" }
|
||||
}]);
|
||||
sent.length = 0;
|
||||
harness.onPlaylistDragStart({
|
||||
currentTarget: source,
|
||||
// Chromium reports the draggable row as dragstart.target even though
|
||||
@@ -279,6 +465,7 @@ test("playlist native drag preserves a multi-selection and rejects checkbox drag
|
||||
type: "reorder-playlist-rows",
|
||||
payload: { sourceRowId: "row-a", targetRowId: "row-c", position: "before" }
|
||||
}]);
|
||||
assert.equal(Object.hasOwn(sent[0].payload, "selectedRowIds"), false);
|
||||
});
|
||||
|
||||
test("playlist drop zone accepts only the exact selected-cut drag token", () => {
|
||||
@@ -295,14 +482,17 @@ test("playlist drop zone accepts only the exact selected-cut drag token", () =>
|
||||
assert.ok(sourceStart >= 0 && sourceEnd > sourceStart);
|
||||
const playlistDropZone = new EventTarget();
|
||||
const sent = [];
|
||||
new Function(
|
||||
const bindDropZone = new Function(
|
||||
"playlistDropZone",
|
||||
"send",
|
||||
"selectedCutsDragToken",
|
||||
app.slice(sourceStart, sourceEnd))(
|
||||
playlistDropZone,
|
||||
(type, payload) => sent.push({ type, payload }),
|
||||
"legacy-selected-cuts");
|
||||
"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");
|
||||
@@ -331,6 +521,23 @@ test("playlist drop zone accepts only the exact selected-cut drag token", () =>
|
||||
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("operator catalog drag rejects an interactive child pointer origin", () => {
|
||||
@@ -423,6 +630,113 @@ test("market tabs send closed identity intents and reconcile C# order", () => {
|
||||
assert.doesNotMatch(app, /selectedTab\s*=/);
|
||||
});
|
||||
|
||||
test("market tab hover uses the original narrow-source width threshold in both directions", () => {
|
||||
const sourceStart = app.indexOf("function hasCrossedLegacyTabSwapThreshold");
|
||||
const sourceEnd = app.indexOf("function onMarketTabDragOver", sourceStart);
|
||||
assert.ok(sourceStart >= 0 && sourceEnd > sourceStart);
|
||||
const crossed = new Function(`
|
||||
${app.slice(sourceStart, sourceEnd)}
|
||||
return hasCrossedLegacyTabSwapThreshold;
|
||||
`)();
|
||||
const tab = (left, width) => ({
|
||||
getBoundingClientRect() { return { left, width }; }
|
||||
});
|
||||
const narrowSource = tab(0, 40);
|
||||
const wideTarget = tab(100, 100);
|
||||
|
||||
// Moving right: target.right - source.width = 160, with a strict > comparison.
|
||||
assert.equal(crossed(narrowSource, wideTarget, 159, 0, 1), false);
|
||||
assert.equal(crossed(narrowSource, wideTarget, 160, 0, 1), false);
|
||||
assert.equal(crossed(narrowSource, wideTarget, 161, 0, 1), true);
|
||||
|
||||
// Moving left: target.left + source.width = 140, with a strict < comparison.
|
||||
assert.equal(crossed(narrowSource, wideTarget, 141, 2, 1), false);
|
||||
assert.equal(crossed(narrowSource, wideTarget, 140, 2, 1), false);
|
||||
assert.equal(crossed(narrowSource, wideTarget, 139, 2, 1), true);
|
||||
|
||||
// Equal-width and wider sources keep MainForm's immediate-on-entry behavior.
|
||||
assert.equal(crossed(tab(0, 100), wideTarget, 100, 0, 1), true);
|
||||
assert.equal(crossed(tab(0, 120), wideTarget, 200, 2, 1), true);
|
||||
});
|
||||
|
||||
test("market tab dragover sends once after crossing and does not poison the busy gate", () => {
|
||||
const sourceStart = app.indexOf("function hasCrossedLegacyTabSwapThreshold");
|
||||
const sourceEnd = app.indexOf("function requestTabHoverSwap", sourceStart);
|
||||
assert.ok(sourceStart >= 0 && sourceEnd > sourceStart);
|
||||
|
||||
const source = {
|
||||
dataset: { tabId: "source" },
|
||||
closest() { return this; },
|
||||
getBoundingClientRect() { return { left: 0, width: 40 }; }
|
||||
};
|
||||
const target = {
|
||||
dataset: { tabId: "target" },
|
||||
closest() { return this; },
|
||||
getBoundingClientRect() { return { left: 100, width: 100 }; }
|
||||
};
|
||||
const marketTabs = {
|
||||
querySelectorAll() { return [source, target]; }
|
||||
};
|
||||
let accepted = false;
|
||||
const requests = [];
|
||||
const harness = new Function(
|
||||
"marketTabs",
|
||||
"requestTabHoverSwap",
|
||||
`
|
||||
let draggedTabId = "source";
|
||||
let tabDragLastTargetId = null;
|
||||
let pendingTabHoverSwap = null;
|
||||
${app.slice(sourceStart, sourceEnd)}
|
||||
return {
|
||||
dragover: onMarketTabDragOver,
|
||||
lastTarget: function () { return tabDragLastTargetId; },
|
||||
setPending: function (value) { pendingTabHoverSwap = value; }
|
||||
};
|
||||
`)(marketTabs, targetId => {
|
||||
requests.push(targetId);
|
||||
return accepted;
|
||||
});
|
||||
const dragover = (eventTarget, clientX) => {
|
||||
let prevented = false;
|
||||
const dataTransfer = { dropEffect: "none" };
|
||||
harness.dragover({
|
||||
target: eventTarget,
|
||||
clientX,
|
||||
dataTransfer,
|
||||
preventDefault() { prevented = true; }
|
||||
});
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(dataTransfer.dropEffect, "move");
|
||||
};
|
||||
|
||||
dragover(target, 159);
|
||||
assert.deepEqual(requests, []);
|
||||
assert.equal(harness.lastTarget(), null);
|
||||
|
||||
// The threshold is crossed while the request gate rejects the operation.
|
||||
dragover(target, 161);
|
||||
assert.deepEqual(requests, ["target"]);
|
||||
assert.equal(harness.lastTarget(), null);
|
||||
|
||||
// Once accepted, only the first repeated dragover for this crossing is sent.
|
||||
accepted = true;
|
||||
dragover(target, 170);
|
||||
dragover(target, 180);
|
||||
assert.deepEqual(requests, ["target", "target"]);
|
||||
assert.equal(harness.lastTarget(), "target");
|
||||
|
||||
// Re-entering the dragged source ends that crossing; a later target crossing is new.
|
||||
dragover(source, 20);
|
||||
assert.equal(harness.lastTarget(), null);
|
||||
dragover(target, 170);
|
||||
assert.deepEqual(requests, ["target", "target", "target"]);
|
||||
|
||||
harness.setPending({});
|
||||
dragover(source, 20);
|
||||
dragover(target, 170);
|
||||
assert.deepEqual(requests, ["target", "target", "target"]);
|
||||
});
|
||||
|
||||
test("fixed catalog renders C# view and sends opaque leaf or section intent", () => {
|
||||
assert.match(app, /state\.fixedCatalog/);
|
||||
assert.match(app, /dataset\.actionId/);
|
||||
@@ -435,6 +749,274 @@ test("fixed catalog renders C# view and sends opaque leaf or section intent", ()
|
||||
assert.doesNotMatch(app, /builderKey|sceneCode|cutCode|dataCode/);
|
||||
});
|
||||
|
||||
test("UC1 tree separates label selection, disclosure toggling and double-click action", () => {
|
||||
const doubleClickStart = app.indexOf('categoryTree.addEventListener("dblclick"');
|
||||
const clickStart = app.indexOf(
|
||||
'categoryTree.addEventListener("click"', doubleClickStart + 1);
|
||||
const changeStart = app.indexOf(
|
||||
'categoryTree.addEventListener("change"', clickStart + 1);
|
||||
assert.ok(doubleClickStart >= 0 && clickStart > doubleClickStart && changeStart > clickStart);
|
||||
|
||||
class DelegateTarget {
|
||||
constructor() { this.listeners = new Map(); }
|
||||
addEventListener(type, callback) { this.listeners.set(type, callback); }
|
||||
fire(type, target, detail = 1) {
|
||||
let prevented = false;
|
||||
this.listeners.get(type)({
|
||||
target,
|
||||
detail,
|
||||
preventDefault() { prevented = true; }
|
||||
});
|
||||
return prevented;
|
||||
}
|
||||
}
|
||||
|
||||
function createNodes() {
|
||||
const details = { open: true };
|
||||
const summary = {
|
||||
dataset: { treeNodeKey: "root|overseas|fixed:0", sectionIndex: "0" },
|
||||
disabled: false,
|
||||
parentElement: details,
|
||||
getAttribute(name) { return name === "aria-disabled" ? "false" : null; }
|
||||
};
|
||||
const disclosure = {
|
||||
closest(selector) {
|
||||
if (selector === "[data-tree-node-key]" ||
|
||||
selector === "summary[data-tree-root]" ||
|
||||
selector === "summary[data-section-index]") return summary;
|
||||
if (selector === "[data-tree-disclosure]") return this;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const label = {
|
||||
closest(selector) {
|
||||
return selector === "[data-tree-node-key]" ||
|
||||
selector === "summary[data-tree-root]" ||
|
||||
selector === "summary[data-section-index]"
|
||||
? summary : null;
|
||||
}
|
||||
};
|
||||
const leaf = {
|
||||
dataset: { treeNodeKey: "leaf|overseas|fixed:0|fixed-262", actionId: "fixed-262" },
|
||||
disabled: false,
|
||||
getAttribute() { return null; },
|
||||
closest(selector) {
|
||||
return selector === "[data-tree-node-key]" ||
|
||||
selector === ".tree-item[data-tree-node-key]" ||
|
||||
selector === "button[data-action-id]"
|
||||
? this : null;
|
||||
}
|
||||
};
|
||||
return { details, summary, disclosure, label, leaf };
|
||||
}
|
||||
|
||||
function createHarness(locked) {
|
||||
const categoryTree = new DelegateTarget();
|
||||
const sent = [];
|
||||
const selected = [];
|
||||
const expanded = [];
|
||||
new Function(
|
||||
"categoryTree", "send", "selectTreeNode", "syncTreeSectionExpanded",
|
||||
"treeInteractionLocked",
|
||||
app.slice(doubleClickStart, clickStart) + app.slice(clickStart, changeStart))(
|
||||
categoryTree,
|
||||
(type, payload) => sent.push({ type, payload }),
|
||||
node => selected.push(node.dataset.treeNodeKey),
|
||||
details => expanded.push(details.open),
|
||||
locked);
|
||||
return { categoryTree, sent, selected, expanded };
|
||||
}
|
||||
|
||||
const nodes = createNodes();
|
||||
const harness = createHarness(false);
|
||||
assert.equal(harness.categoryTree.fire("click", nodes.label), true);
|
||||
assert.equal(nodes.details.open, true);
|
||||
assert.deepEqual(harness.sent, []);
|
||||
|
||||
assert.equal(harness.categoryTree.fire("click", nodes.disclosure), true);
|
||||
assert.equal(nodes.details.open, false);
|
||||
assert.deepEqual(harness.expanded, [false]);
|
||||
|
||||
harness.categoryTree.fire("dblclick", nodes.label, 2);
|
||||
harness.categoryTree.fire("dblclick", nodes.leaf, 2);
|
||||
assert.equal(nodes.details.open, false);
|
||||
assert.deepEqual(harness.sent, [
|
||||
{ type: "activate-fixed-section", payload: { sectionIndex: 0 } },
|
||||
{ type: "activate-fixed-action", payload: { actionId: "fixed-262" } }
|
||||
]);
|
||||
|
||||
const lockedNodes = createNodes();
|
||||
const locked = createHarness(true);
|
||||
locked.categoryTree.fire("click", lockedNodes.disclosure);
|
||||
locked.categoryTree.fire("dblclick", lockedNodes.label, 2);
|
||||
locked.categoryTree.fire("dblclick", lockedNodes.leaf, 2);
|
||||
assert.equal(lockedNodes.details.open, true);
|
||||
assert.deepEqual(locked.sent, []);
|
||||
assert.deepEqual(locked.selected, []);
|
||||
});
|
||||
|
||||
test("UC1 tree render preserves state but resets Expand and first root on tab reentry", () => {
|
||||
const helperStart = app.indexOf("function activeTreeTabId");
|
||||
const helperEnd = app.indexOf("function send(", helperStart);
|
||||
assert.ok(helperStart >= 0 && helperEnd > helperStart);
|
||||
|
||||
class MiniClassList {
|
||||
constructor() { this.values = new Set(); }
|
||||
add(value) { this.values.add(value); }
|
||||
remove(value) { this.values.delete(value); }
|
||||
contains(value) { return this.values.has(value); }
|
||||
}
|
||||
|
||||
const document = { activeElement: null };
|
||||
class MiniNode {
|
||||
constructor(dataset = {}) {
|
||||
this.dataset = dataset;
|
||||
this.classList = new MiniClassList();
|
||||
this.attributes = new Map();
|
||||
this.disabled = false;
|
||||
this.textContent = "";
|
||||
this.scrolled = false;
|
||||
}
|
||||
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
||||
getAttribute(name) { return this.attributes.get(name) ?? null; }
|
||||
focus() { document.activeElement = this; }
|
||||
scrollIntoView() { this.scrolled = true; }
|
||||
closest(selector) { return selector === "[data-tree-node-key]" ? this : null; }
|
||||
}
|
||||
|
||||
class MiniDetails {
|
||||
constructor(sectionIndex, leafId) {
|
||||
this.dataset = {};
|
||||
this.open = false;
|
||||
this.summary = new MiniNode({ sectionIndex: String(sectionIndex) });
|
||||
this.summary.textContent = "[section-" + sectionIndex + "]";
|
||||
this.summary.parentElement = this;
|
||||
this.summary.querySelector = selector => selector === "[data-tree-disclosure]" ? {} : null;
|
||||
this.leaf = new MiniNode({ actionId: leafId });
|
||||
this.leaf.textContent = leafId;
|
||||
}
|
||||
querySelector(selector) {
|
||||
return selector.startsWith(":scope > summary") ? this.summary : null;
|
||||
}
|
||||
querySelectorAll(selector) { return selector === ".tree-item" ? [this.leaf] : []; }
|
||||
}
|
||||
|
||||
class MiniTree {
|
||||
constructor() { this.details = []; this.scrollTop = 0; }
|
||||
replaceWith(details) { this.details = details; }
|
||||
querySelectorAll(selector) {
|
||||
if (selector === "details" || selector === "details[data-tree-section-key]") {
|
||||
return this.details;
|
||||
}
|
||||
if (selector === "summary[data-tree-root]") {
|
||||
return this.details.map(details => details.summary);
|
||||
}
|
||||
const nodes = this.details.flatMap(details => [details.summary, details.leaf]);
|
||||
if (selector === "[data-tree-node-key]") return nodes;
|
||||
if (selector === "[data-tree-node-key].tree-node-selected") {
|
||||
return nodes.filter(node => node.classList.contains("tree-node-selected"));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
querySelector(selector) {
|
||||
if (selector === "[data-tree-node-key].tree-node-selected") {
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
contains(node) {
|
||||
return this.details.some(details => details.summary === node || details.leaf === node);
|
||||
}
|
||||
}
|
||||
|
||||
const categoryTree = new MiniTree();
|
||||
const catalogExpandAll = { checked: false };
|
||||
const harness = new Function("categoryTree", "catalogExpandAll", "document", `
|
||||
let renderedTreeTabId = null;
|
||||
let treeInteractionLocked = false;
|
||||
const treeUiStateByTab = new Map();
|
||||
const pendingTreeResetTabs = new Set();
|
||||
${app.slice(helperStart, helperEnd)}
|
||||
return { beginTreeRender, finishTreeRender, selectTreeNode };
|
||||
`)(categoryTree, catalogExpandAll, document);
|
||||
const state = tabId => ({
|
||||
isBusy: false,
|
||||
fixedSectionBatch: null,
|
||||
tabs: [{ id: tabId, isActive: true }]
|
||||
});
|
||||
const sections = () => [new MiniDetails(0, "fixed-1"), new MiniDetails(1, "fixed-2")];
|
||||
|
||||
let current = sections();
|
||||
categoryTree.replaceWith(current);
|
||||
harness.finishTreeRender(harness.beginTreeRender(state("overseas")));
|
||||
assert.equal(catalogExpandAll.checked, true);
|
||||
assert.ok(current.every(details => details.open));
|
||||
assert.ok(current[0].summary.classList.contains("tree-node-selected"));
|
||||
assert.equal(document.activeElement, current[0].summary);
|
||||
assert.equal(current[0].summary.scrolled, true);
|
||||
|
||||
current[0].open = false;
|
||||
harness.selectTreeNode(current[0].leaf, true);
|
||||
categoryTree.scrollTop = 42;
|
||||
const sameTab = harness.beginTreeRender(state("overseas"));
|
||||
current = sections();
|
||||
categoryTree.replaceWith(current);
|
||||
harness.finishTreeRender(sameTab);
|
||||
assert.equal(current[0].open, false);
|
||||
assert.equal(current[1].open, true);
|
||||
assert.ok(current[0].leaf.classList.contains("tree-node-selected"));
|
||||
assert.equal(document.activeElement, current[0].leaf);
|
||||
assert.equal(categoryTree.scrollTop, 42);
|
||||
|
||||
catalogExpandAll.checked = false;
|
||||
const reentry = harness.beginTreeRender(state("exchange"));
|
||||
current = sections();
|
||||
categoryTree.replaceWith(current);
|
||||
harness.finishTreeRender(reentry);
|
||||
assert.equal(catalogExpandAll.checked, true);
|
||||
assert.ok(current.every(details => details.open));
|
||||
assert.ok(current[0].summary.classList.contains("tree-node-selected"));
|
||||
assert.equal(document.activeElement, current[0].summary);
|
||||
});
|
||||
|
||||
test("UC1 Expand collapses all without clearing selection and is inert while locked", () => {
|
||||
const expandStart = app.indexOf('catalogExpandAll.addEventListener("change"');
|
||||
const doubleClickStart = app.indexOf(
|
||||
'categoryTree.addEventListener("dblclick"', expandStart + 1);
|
||||
assert.ok(expandStart >= 0 && doubleClickStart > expandStart);
|
||||
|
||||
function createHarness(locked) {
|
||||
const selected = { value: true };
|
||||
const details = [{ open: true }, { open: true }];
|
||||
const categoryTree = {
|
||||
querySelectorAll(selector) { return selector === "details" ? details : []; }
|
||||
};
|
||||
const catalogExpandAll = {
|
||||
checked: false,
|
||||
addEventListener(type, callback) { this.change = callback; }
|
||||
};
|
||||
const expanded = [];
|
||||
new Function(
|
||||
"catalogExpandAll", "categoryTree", "syncTreeSectionExpanded",
|
||||
"treeInteractionLocked", app.slice(expandStart, doubleClickStart))(
|
||||
catalogExpandAll, categoryTree, row => expanded.push(row.open), locked);
|
||||
return { catalogExpandAll, details, expanded, selected };
|
||||
}
|
||||
|
||||
const normal = createHarness(false);
|
||||
normal.catalogExpandAll.change();
|
||||
assert.deepEqual(normal.details.map(details => details.open), [false, false]);
|
||||
assert.deepEqual(normal.expanded, [false, false]);
|
||||
assert.equal(normal.selected.value, true);
|
||||
|
||||
const locked = createHarness(true);
|
||||
locked.catalogExpandAll.change();
|
||||
assert.deepEqual(locked.details.map(details => details.open), [true, true]);
|
||||
assert.equal(locked.catalogExpandAll.checked, true);
|
||||
assert.deepEqual(locked.expanded, []);
|
||||
assert.equal(locked.selected.value, true);
|
||||
});
|
||||
|
||||
test("fixed-262 ignores a single click and emits exactly one intent on double click", () => {
|
||||
const doubleClickStart = app.indexOf('categoryTree.addEventListener("dblclick"');
|
||||
const clickStart = app.indexOf(
|
||||
@@ -575,7 +1157,7 @@ test("named DB playlists and playout use separate closed native intents", () =>
|
||||
"prepare-playout", "take-in", "next-playout", "take-out",
|
||||
"choose-background", "toggle-background", "set-fade-duration"
|
||||
]) assert.match(playout, new RegExp("\\\"" + intent + "\\\""));
|
||||
assert.match(playout, /playout\.phase !== "prepared"/);
|
||||
assert.match(playout, /takeOut\.disabled = busy \|\| !commandAvailable;/);
|
||||
assert.match(playout, /playout\.isPlayCompletionPending === true/);
|
||||
assert.match(playout, /playout\.outcomeUnknown !== true/);
|
||||
assert.doesNotMatch(playout, /fetch\s*\(|localStorage|sessionStorage|sceneCode|filePath/);
|
||||
|
||||
Reference in New Issue
Block a user