477 lines
16 KiB
JavaScript
477 lines
16 KiB
JavaScript
const LAYOUT_CASES = [
|
|
{
|
|
label: "Dataset Builder",
|
|
path: "/apps/dataset-builder/",
|
|
panelSide: "end",
|
|
storageKey: "micarrsim.builder.control-panel-width",
|
|
defaultPanelWidth: 430,
|
|
layoutId: "builder-layout",
|
|
panelId: "builder-controls",
|
|
workspaceId: "builder-workspace",
|
|
handleId: "builder-split-handle",
|
|
canvasSelector: ".canvas-card",
|
|
representativeSelectors: ["#scene-name", "#add-source-button"],
|
|
adaptiveGridSelector: "#source-editor-section .field-grid--three",
|
|
defaultGridTracks: 2,
|
|
homePanelWidth: 360,
|
|
homeGridTracks: 1,
|
|
endPanelWidth: 720,
|
|
endGridTracks: 3,
|
|
},
|
|
{
|
|
label: "Acoustic Camera",
|
|
path: "/apps/acoustic-camera/",
|
|
panelSide: "start",
|
|
storageKey: "micarrsim.camera.control-panel-width",
|
|
defaultPanelWidth: 420,
|
|
layoutId: "camera-layout",
|
|
panelId: "camera-controls",
|
|
workspaceId: "camera-workspace",
|
|
handleId: "camera-split-handle",
|
|
canvasSelector: ".scene-canvas-card",
|
|
representativeSelectors: ["#algorithm", "#run-button"],
|
|
adaptiveGridSelector: ".quick-field-grid",
|
|
defaultGridTracks: 2,
|
|
homePanelWidth: 320,
|
|
homeGridTracks: 1,
|
|
},
|
|
];
|
|
|
|
let mountSequence = 0;
|
|
|
|
async function waitFor(predicate, label, timeoutMs = 15000) {
|
|
const deadline = performance.now() + timeoutMs;
|
|
while (performance.now() < deadline) {
|
|
const value = predicate();
|
|
if (value) return value;
|
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
}
|
|
throw new Error(label + " timed out.");
|
|
}
|
|
|
|
async function nextPaint(windowObject) {
|
|
await new Promise((resolve) => {
|
|
windowObject.requestAnimationFrame(() => windowObject.requestAnimationFrame(resolve));
|
|
});
|
|
}
|
|
|
|
function assertNear(actual, expected, tolerance, label) {
|
|
assert(
|
|
Math.abs(actual - expected) <= tolerance,
|
|
label + ": expected " + expected + " ± " + tolerance + ", received " + actual,
|
|
);
|
|
}
|
|
|
|
function topLevelCssTokens(value) {
|
|
const tokens = [];
|
|
let token = "";
|
|
let parenthesisDepth = 0;
|
|
let bracketDepth = 0;
|
|
for (const character of value.trim()) {
|
|
if (/\s/.test(character) && parenthesisDepth === 0 && bracketDepth === 0) {
|
|
if (token) {
|
|
tokens.push(token);
|
|
token = "";
|
|
}
|
|
continue;
|
|
}
|
|
token += character;
|
|
if (character === "(") parenthesisDepth += 1;
|
|
else if (character === ")") parenthesisDepth = Math.max(0, parenthesisDepth - 1);
|
|
else if (character === "[") bracketDepth += 1;
|
|
else if (character === "]") bracketDepth = Math.max(0, bracketDepth - 1);
|
|
}
|
|
if (token) tokens.push(token);
|
|
return tokens;
|
|
}
|
|
|
|
function gridTrackCount(template) {
|
|
if (!template || template === "none") return 0;
|
|
return topLevelCssTokens(template).reduce((count, token) => {
|
|
if (token.startsWith("[") && token.endsWith("]")) return count;
|
|
const repeat = token.match(/^repeat\(\s*(\d+)\s*,([\s\S]+)\)$/i);
|
|
if (repeat) return count + Number(repeat[1]) * gridTrackCount(repeat[2]);
|
|
return count + 1;
|
|
}, 0);
|
|
}
|
|
|
|
function computedGridTrackCount(windowObject, element) {
|
|
return gridTrackCount(windowObject.getComputedStyle(element).gridTemplateColumns);
|
|
}
|
|
|
|
async function mountLayoutApp(definition, width = 1400) {
|
|
const frame = document.createElement("iframe");
|
|
mountSequence += 1;
|
|
frame.src = definition.path + "?integration-test=1&split-layout-test=" + mountSequence;
|
|
frame.style.cssText =
|
|
"position:fixed;left:-20000px;top:0;width:" + width + "px;height:1000px;border:0";
|
|
document.body.append(frame);
|
|
await new Promise((resolve, reject) => {
|
|
frame.addEventListener("load", resolve, { once: true });
|
|
frame.addEventListener(
|
|
"error",
|
|
() => reject(new Error(definition.label + " iframe load failed")),
|
|
{ once: true },
|
|
);
|
|
});
|
|
|
|
await waitFor(
|
|
() => frame.contentDocument.documentElement.dataset.appReady === "true",
|
|
definition.label + " initialization",
|
|
);
|
|
await waitFor(
|
|
() => frame.contentDocument.getElementById(definition.layoutId)?.dataset.splitPaneActive === "true",
|
|
definition.label + " desktop split pane",
|
|
);
|
|
await nextPaint(frame.contentWindow);
|
|
|
|
const documentObject = frame.contentDocument;
|
|
return {
|
|
frame,
|
|
windowObject: frame.contentWindow,
|
|
documentObject,
|
|
layout: documentObject.getElementById(definition.layoutId),
|
|
panel: documentObject.getElementById(definition.panelId),
|
|
workspace: documentObject.getElementById(definition.workspaceId),
|
|
handle: documentObject.getElementById(definition.handleId),
|
|
};
|
|
}
|
|
|
|
function panelWidth(mounted) {
|
|
return mounted.panel.getBoundingClientRect().width;
|
|
}
|
|
|
|
function handleCenterX(mounted) {
|
|
const rect = mounted.handle.getBoundingClientRect();
|
|
return rect.left + rect.width / 2;
|
|
}
|
|
|
|
async function dragPanelWider(mounted, definition, amount = 120) {
|
|
const { windowObject, handle } = mounted;
|
|
const handleRect = handle.getBoundingClientRect();
|
|
const startX = handleRect.left + handleRect.width / 2;
|
|
const clientY = handleRect.top + Math.min(20, handleRect.height / 2);
|
|
const endX = startX + (definition.panelSide === "start" ? amount : -amount);
|
|
const pointerId = 17;
|
|
|
|
handle.dispatchEvent(new windowObject.PointerEvent("pointerdown", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
pointerId,
|
|
pointerType: "mouse",
|
|
isPrimary: true,
|
|
button: 0,
|
|
buttons: 1,
|
|
clientX: startX,
|
|
clientY,
|
|
}));
|
|
windowObject.dispatchEvent(new windowObject.PointerEvent("pointermove", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
pointerId,
|
|
pointerType: "mouse",
|
|
isPrimary: true,
|
|
button: -1,
|
|
buttons: 1,
|
|
clientX: endX,
|
|
clientY,
|
|
}));
|
|
windowObject.dispatchEvent(new windowObject.PointerEvent("pointerup", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
pointerId,
|
|
pointerType: "mouse",
|
|
isPrimary: true,
|
|
button: 0,
|
|
buttons: 0,
|
|
clientX: endX,
|
|
clientY,
|
|
}));
|
|
await nextPaint(windowObject);
|
|
}
|
|
|
|
async function pressKey(mounted, key) {
|
|
mounted.handle.dispatchEvent(new mounted.windowObject.KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
key,
|
|
}));
|
|
await nextPaint(mounted.windowObject);
|
|
}
|
|
|
|
async function doubleClickReset(mounted) {
|
|
mounted.handle.dispatchEvent(new mounted.windowObject.MouseEvent("dblclick", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
button: 0,
|
|
}));
|
|
await nextPaint(mounted.windowObject);
|
|
}
|
|
|
|
function assertFitsViewport(documentObject, element, label) {
|
|
const viewportWidth = documentObject.documentElement.clientWidth;
|
|
const rect = element.getBoundingClientRect();
|
|
assert(rect.width > 0, label + " has a usable width");
|
|
assert(rect.left >= -1, label + " extends past the viewport left edge: " + rect.left);
|
|
assert(
|
|
rect.right <= viewportWidth + 1,
|
|
label + " extends past the viewport right edge: " + rect.right + " > " + viewportWidth,
|
|
);
|
|
}
|
|
|
|
async function exerciseSplitLayout(definition) {
|
|
let mounted = null;
|
|
localStorage.removeItem(definition.storageKey);
|
|
|
|
try {
|
|
mounted = await mountLayoutApp(definition);
|
|
const { documentObject, layout, panel, workspace, handle } = mounted;
|
|
assertEqual(handle.getAttribute("role"), "separator", definition.label + " separator role");
|
|
assertEqual(handle.tabIndex, 0, definition.label + " separator keyboard focus");
|
|
assertEqual(
|
|
handle.getAttribute("aria-orientation"),
|
|
"vertical",
|
|
definition.label + " separator orientation",
|
|
);
|
|
assertEqual(handle.getAttribute("aria-controls"), panel.id, definition.label + " controlled panel");
|
|
assertEqual(handle.getAttribute("aria-disabled"), "false", definition.label + " desktop separator enabled");
|
|
assertEqual(handle.dataset.storageKey, definition.storageKey, definition.label + " storage key contract");
|
|
assertEqual(layout.dataset.splitPaneActive, "true", definition.label + " desktop split state");
|
|
|
|
const initialPanelWidth = panelWidth(mounted);
|
|
const initialWorkspaceWidth = workspace.getBoundingClientRect().width;
|
|
assertNear(
|
|
initialPanelWidth,
|
|
definition.defaultPanelWidth,
|
|
2,
|
|
definition.label + " default panel width",
|
|
);
|
|
const adaptiveGrid = documentObject.querySelector(definition.adaptiveGridSelector);
|
|
assert(adaptiveGrid, definition.label + " adaptive grid exists");
|
|
assertEqual(
|
|
computedGridTrackCount(mounted.windowObject, adaptiveGrid),
|
|
definition.defaultGridTracks,
|
|
definition.label + " default panel grid tracks",
|
|
);
|
|
if (definition.endPanelWidth) {
|
|
await pressKey(mounted, "End");
|
|
assertNear(
|
|
panelWidth(mounted),
|
|
definition.endPanelWidth,
|
|
2,
|
|
definition.label + " End panel width",
|
|
);
|
|
assertEqual(
|
|
computedGridTrackCount(mounted.windowObject, adaptiveGrid),
|
|
definition.endGridTracks,
|
|
definition.label + " wide panel grid tracks",
|
|
);
|
|
}
|
|
await pressKey(mounted, "Home");
|
|
assertNear(
|
|
panelWidth(mounted),
|
|
definition.homePanelWidth,
|
|
2,
|
|
definition.label + " Home panel width",
|
|
);
|
|
assertEqual(
|
|
computedGridTrackCount(mounted.windowObject, adaptiveGrid),
|
|
definition.homeGridTracks,
|
|
definition.label + " narrow panel grid tracks",
|
|
);
|
|
await doubleClickReset(mounted);
|
|
assertNear(
|
|
panelWidth(mounted),
|
|
definition.defaultPanelWidth,
|
|
2,
|
|
definition.label + " initial reset panel width",
|
|
);
|
|
assertEqual(
|
|
computedGridTrackCount(mounted.windowObject, adaptiveGrid),
|
|
definition.defaultGridTracks,
|
|
definition.label + " reset panel grid tracks",
|
|
);
|
|
assertEqual(
|
|
localStorage.getItem(definition.storageKey),
|
|
null,
|
|
definition.label + " initial reset removes its preference",
|
|
);
|
|
|
|
await dragPanelWider(mounted, definition);
|
|
const draggedPanelWidth = panelWidth(mounted);
|
|
const draggedWorkspaceWidth = workspace.getBoundingClientRect().width;
|
|
assertNear(
|
|
draggedPanelWidth,
|
|
initialPanelWidth + 120,
|
|
2,
|
|
definition.label + " pointer drag widens the panel",
|
|
);
|
|
assertNear(
|
|
draggedWorkspaceWidth,
|
|
initialWorkspaceWidth - 120,
|
|
2,
|
|
definition.label + " pointer drag gives panel space from the workspace",
|
|
);
|
|
assertEqual(layout.dataset.splitPaneDragging, "false", definition.label + " drag state is released");
|
|
assert(
|
|
!documentObject.body.classList.contains("is-resizing-pane"),
|
|
definition.label + " drag body state is released",
|
|
);
|
|
assertNear(
|
|
Number(handle.getAttribute("aria-valuenow")),
|
|
draggedPanelWidth,
|
|
1,
|
|
definition.label + " pointer drag updates aria-valuenow",
|
|
);
|
|
assertNear(
|
|
Number(localStorage.getItem(definition.storageKey)),
|
|
draggedPanelWidth,
|
|
1,
|
|
definition.label + " pointer drag persists its width",
|
|
);
|
|
|
|
handle.focus();
|
|
assertEqual(documentObject.activeElement, handle, definition.label + " separator receives focus");
|
|
const beforeArrowRightX = handleCenterX(mounted);
|
|
const beforeArrowRightPanelWidth = panelWidth(mounted);
|
|
await pressKey(mounted, "ArrowRight");
|
|
assertNear(
|
|
handleCenterX(mounted),
|
|
beforeArrowRightX + 16,
|
|
2,
|
|
definition.label + " ArrowRight moves divider right",
|
|
);
|
|
assertNear(
|
|
panelWidth(mounted),
|
|
beforeArrowRightPanelWidth + (definition.panelSide === "start" ? 16 : -16),
|
|
2,
|
|
definition.label + " ArrowRight changes the physical-side panel",
|
|
);
|
|
const afterArrowRightX = handleCenterX(mounted);
|
|
await pressKey(mounted, "ArrowLeft");
|
|
assertNear(
|
|
handleCenterX(mounted),
|
|
afterArrowRightX - 16,
|
|
2,
|
|
definition.label + " ArrowLeft moves divider left",
|
|
);
|
|
assertNear(
|
|
panelWidth(mounted),
|
|
draggedPanelWidth,
|
|
2,
|
|
definition.label + " opposite arrow restores width",
|
|
);
|
|
assertNear(
|
|
Number(localStorage.getItem(definition.storageKey)),
|
|
draggedPanelWidth,
|
|
1,
|
|
definition.label + " keyboard width is persisted",
|
|
);
|
|
|
|
const persistedPanelWidth = Number(localStorage.getItem(definition.storageKey));
|
|
mounted.frame.remove();
|
|
mounted = await mountLayoutApp(definition);
|
|
assertNear(
|
|
panelWidth(mounted),
|
|
persistedPanelWidth,
|
|
2,
|
|
definition.label + " restores the persisted panel width",
|
|
);
|
|
assertNear(
|
|
Number(mounted.handle.getAttribute("aria-valuenow")),
|
|
persistedPanelWidth,
|
|
1,
|
|
definition.label + " restored width is exposed accessibly",
|
|
);
|
|
|
|
await doubleClickReset(mounted);
|
|
assertNear(
|
|
panelWidth(mounted),
|
|
definition.defaultPanelWidth,
|
|
2,
|
|
definition.label + " double click restores the default width",
|
|
);
|
|
const restoredAdaptiveGrid = mounted.documentObject.querySelector(definition.adaptiveGridSelector);
|
|
assertEqual(
|
|
computedGridTrackCount(mounted.windowObject, restoredAdaptiveGrid),
|
|
definition.defaultGridTracks,
|
|
definition.label + " double click restores the default grid tracks",
|
|
);
|
|
assertEqual(
|
|
localStorage.getItem(definition.storageKey),
|
|
null,
|
|
definition.label + " reset removes its preference",
|
|
);
|
|
|
|
mounted.frame.style.width = "390px";
|
|
await waitFor(
|
|
() => mounted.layout.dataset.splitPaneActive === "false"
|
|
&& mounted.handle.getAttribute("aria-disabled") === "true",
|
|
definition.label + " compact split pane disable",
|
|
);
|
|
await nextPaint(mounted.windowObject);
|
|
|
|
const compactDocument = mounted.documentObject;
|
|
const viewportWidth = compactDocument.documentElement.clientWidth;
|
|
assertEqual(mounted.windowObject.innerWidth, 390, definition.label + " compact iframe width");
|
|
assert(
|
|
compactDocument.documentElement.scrollWidth <= viewportWidth + 1,
|
|
definition.label + " compact layout has no horizontal document overflow",
|
|
);
|
|
assertEqual(
|
|
mounted.windowObject.getComputedStyle(mounted.handle).display,
|
|
"none",
|
|
definition.label + " compact separator is hidden",
|
|
);
|
|
assertEqual(mounted.handle.offsetParent, null, definition.label + " compact separator leaves layout flow");
|
|
|
|
const panelRect = mounted.panel.getBoundingClientRect();
|
|
const workspaceRect = mounted.workspace.getBoundingClientRect();
|
|
if (definition.panelSide === "start") {
|
|
assert(
|
|
workspaceRect.top >= panelRect.bottom - 1,
|
|
definition.label + " compact workspace follows the panel vertically",
|
|
);
|
|
} else {
|
|
assert(
|
|
panelRect.top >= workspaceRect.bottom - 1,
|
|
definition.label + " compact panel follows the workspace vertically",
|
|
);
|
|
}
|
|
|
|
const canvasCard = compactDocument.querySelector(definition.canvasSelector);
|
|
assert(canvasCard, definition.label + " compact canvas card exists");
|
|
assertFitsViewport(compactDocument, mounted.layout, definition.label + " layout");
|
|
assertFitsViewport(compactDocument, mounted.panel, definition.label + " panel");
|
|
assertFitsViewport(compactDocument, mounted.workspace, definition.label + " workspace");
|
|
assertFitsViewport(compactDocument, canvasCard, definition.label + " canvas card");
|
|
for (const selector of definition.representativeSelectors) {
|
|
const element = compactDocument.querySelector(selector);
|
|
assert(element, definition.label + " compact representative exists: " + selector);
|
|
assertFitsViewport(compactDocument, element, definition.label + " " + selector);
|
|
}
|
|
|
|
if (definition.label === "Dataset Builder") {
|
|
assert(
|
|
workspaceRect.width >= viewportWidth - 2,
|
|
"Dataset Builder workspace keeps the compact viewport width: " + workspaceRect.width,
|
|
);
|
|
assert(
|
|
canvasCard.getBoundingClientRect().width >= 320,
|
|
"Dataset Builder canvas remains usable after stacking: "
|
|
+ canvasCard.getBoundingClientRect().width,
|
|
);
|
|
}
|
|
} finally {
|
|
mounted?.frame.remove();
|
|
localStorage.removeItem(definition.storageKey);
|
|
}
|
|
}
|
|
|
|
for (const definition of LAYOUT_CASES) {
|
|
test(
|
|
definition.label + " split layout supports resize, persistence, reset, and compact stacking",
|
|
async () => exerciseSplitLayout(definition),
|
|
);
|
|
}
|