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), ); }