From 947237dc7c66c2a7ec967b5b2391f670d6e9b3ae Mon Sep 17 00:00:00 2001 From: MasterBabol Date: Fri, 28 Aug 2026 21:40:12 +0900 Subject: [PATCH] test: cover resizable workspace layouts --- tests/integration/split-layout-ui.test.js | 476 ++++++++++++++++++++++ tests/test-runner.js | 1 + 2 files changed, 477 insertions(+) create mode 100644 tests/integration/split-layout-ui.test.js diff --git a/tests/integration/split-layout-ui.test.js b/tests/integration/split-layout-ui.test.js new file mode 100644 index 0000000..ee53428 --- /dev/null +++ b/tests/integration/split-layout-ui.test.js @@ -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), + ); +} diff --git a/tests/test-runner.js b/tests/test-runner.js index 706aa08..59a791d 100644 --- a/tests/test-runner.js +++ b/tests/test-runner.js @@ -22,6 +22,7 @@ try { await import("/tests/integration/cpu-baseline.test.js"); await import("/tests/integration/webgpu.test.js"); await import("/tests/integration/server.test.js"); + await import("/tests/integration/split-layout-ui.test.js"); await import("/tests/integration/builder-ui.test.js"); await import("/tests/integration/camera-ui.test.js"); await import("/tests/integration/workflow-ui.test.js");