feat: align legacy operator and playout behavior

This commit is contained in:
2026-07-15 21:04:05 +09:00
parent 06a16e5741
commit 83398044c6
77 changed files with 6377 additions and 391 deletions

View File

@@ -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/);