test: cover resizable workspace layouts

This commit is contained in:
2026-08-28 21:40:12 +09:00
parent 1be1dbf3a4
commit 947237dc7c
2 changed files with 477 additions and 0 deletions
+476
View File
@@ -0,0 +1,476 @@
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),
);
}