import { validateArrayDefinition } from "/packages/contracts/validation.js"; import { arrayPositionToCanvas, fitArrayViewport, } from "/apps/array-builder/canvas-view.js"; import { createArrayDefinition } from "/apps/array-builder/model.js"; function assertNear(actual, expected, tolerance, label) { assert( Math.abs(actual - expected) <= tolerance, label + ": expected " + expected + ", received " + actual, ); } async function waitFor(predicate, label, timeoutMs = 20000) { 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 + " 대기 시간이 초과되었습니다."); } async function nextPaint(windowObject) { await new Promise((resolve) => windowObject.requestAnimationFrame(() => windowObject.requestAnimationFrame(resolve))); } function dispatchValue(windowObject, element, value, eventName = "change") { element.value = String(value); element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true })); } function dispatchFiles(windowObject, input, files) { const transfer = new windowObject.DataTransfer(); for (const file of files) transfer.items.add(file); input.files = transfer.files; input.dispatchEvent(new windowObject.Event("change", { bubbles: true })); } function microphoneButtons(documentObject) { return [...documentObject.querySelectorAll("#microphone-list [data-microphone-id]")]; } function microphoneCount(documentObject) { return microphoneButtons(documentObject).length; } function selectedCoordinates(documentObject) { return ["microphone-x", "microphone-y", "microphone-z"].map( (id) => Number(documentObject.getElementById(id).value), ); } test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을 완주한다", async () => { const frame = document.createElement("iframe"); frame.src = "/apps/array-builder/?integration-test=1"; frame.style.cssText = "position:fixed;left:-20000px;top:0;width:1400px;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("Array Builder iframe load failed")), { once: true }, ); }); const windowObject = frame.contentWindow; const documentObject = frame.contentDocument; const downloads = []; const originalCreate = windowObject.URL.createObjectURL.bind(windowObject.URL); const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL); const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click; windowObject.URL.createObjectURL = (blob) => { const url = "blob:array-builder-test-" + String(downloads.length + 1); downloads.push({ blob, url, fileName: null }); return url; }; windowObject.URL.revokeObjectURL = () => {}; windowObject.HTMLAnchorElement.prototype.click = function captureDownload() { const download = downloads.find((item) => item.url === this.href); if (download) download.fileName = this.download; }; try { await waitFor( () => documentObject.documentElement.dataset.appReady === "true", "Array Builder 초기화", ); await waitFor( () => documentObject.documentElement.dataset.arrayValid === "true", "기본 Array 검증", ); await waitFor( () => documentObject.querySelector("#array-canvas").width > 0, "Array canvas 렌더", ); assertEqual(microphoneCount(documentObject), 12, "default 4x3 channels"); assertEqual( documentObject.querySelector("#microphone-list [data-microphone-id]").dataset.microphoneId, "mic-001", "physical channel order starts at mic-001", ); assert( documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 500, "canvas dominates the desktop workspace", ); assert( !documentObject.querySelector("#canvas-instructions").classList.contains("is-hidden"), "canvas guidance starts visible", ); const canvas = documentObject.querySelector("#array-canvas"); const defaultArray = createArrayDefinition(); const initialCanvasRect = canvas.getBoundingClientRect(); const initialViewport = fitArrayViewport( initialCanvasRect.width, initialCanvasRect.height, defaultArray, ); const firstMicrophonePixel = arrayPositionToCanvas( defaultArray.microphones[0].position, initialViewport, ); const initialFirstPosition = selectedCoordinates(documentObject); const dragPointerId = 21; const dispatchDragPointer = (type, offsetX, offsetY, buttons) => { canvas.dispatchEvent(new windowObject.PointerEvent(type, { bubbles: true, cancelable: true, pointerId: dragPointerId, pointerType: "mouse", isPrimary: true, button: type === "pointermove" ? -1 : 0, buttons, clientX: initialCanvasRect.left + firstMicrophonePixel.x + offsetX, clientY: initialCanvasRect.top + firstMicrophonePixel.y + offsetY, })); }; dispatchDragPointer("pointerdown", 0, 0, 1); dispatchDragPointer("pointermove", 36, -24, 1); dispatchDragPointer("pointerup", 36, -24, 0); await nextPaint(windowObject); const draggedPosition = selectedCoordinates(documentObject); assert( draggedPosition[0] !== initialFirstPosition[0] && draggedPosition[1] !== initialFirstPosition[1], "pointer drag changes both array coordinates", ); assert( documentObject.querySelector("#canvas-instructions").classList.contains("is-hidden"), "first canvas edit dismisses the guidance overlay", ); documentObject.querySelector("#undo-button").click(); assertEqual( selectedCoordinates(documentObject).join(","), initialFirstPosition.join(","), "one undo restores the whole drag", ); dispatchDragPointer("pointerdown", 0, 0, 1); dispatchDragPointer("pointermove", 28, -18, 1); canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Escape", })); await nextPaint(windowObject); assertEqual( selectedCoordinates(documentObject).join(","), initialFirstPosition.join(","), "Escape cancels an in-progress drag", ); const initialApertureWidth = documentObject.querySelector("#aperture-width").value; dispatchValue(windowObject, documentObject.querySelector("#aperture-width"), 0); assertEqual( documentObject.querySelector("#aperture-width").value, initialApertureWidth, "rejected aperture input is restored to the active value", ); assert( documentObject.querySelector(".validation-card").classList.contains("is-invalid"), "rejected action has an error visual state", ); dispatchValue(windowObject, documentObject.querySelector("#preset-count"), ""); dispatchValue(windowObject, documentObject.querySelector("#preset-columns"), 3); dispatchValue(windowObject, documentObject.querySelector("#preset-rows"), 2); documentObject.querySelector("#generate-preset-button").click(); await waitFor(() => microphoneCount(documentObject) === 6, "3x2 grid generation"); assertEqual( documentObject.querySelector("#preset-count").value, "", "hidden count does not block grid generation", ); assertEqual( microphoneButtons(documentObject).map((button) => button.dataset.microphoneId).join(","), "mic-001,mic-002,mic-003,mic-004,mic-005,mic-006", "grid keeps row-major physical channel IDs", ); assertEqual( microphoneButtons(documentObject).filter((button) => button.tabIndex === 0).length, 1, "microphone list has one roving Tab stop", ); microphoneButtons(documentObject)[0].focus(); microphoneButtons(documentObject)[0].dispatchEvent(new windowObject.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowDown", })); assertEqual( documentObject.activeElement.dataset.microphoneId, "mic-002", "ArrowDown selects and focuses the next channel", ); dispatchValue(windowObject, documentObject.querySelector("#aperture-shape"), "ellipse"); dispatchValue(windowObject, documentObject.querySelector("#preset-type"), "ring"); dispatchValue(windowObject, documentObject.querySelector("#preset-count"), 8); documentObject.querySelector("#generate-preset-button").click(); await waitFor(() => microphoneCount(documentObject) === 8, "ellipse ring generation"); await waitFor( () => documentObject.documentElement.dataset.arrayValid === "true", "ellipse ring validation", ); dispatchValue(windowObject, documentObject.querySelector("#preset-type"), "random"); dispatchValue(windowObject, documentObject.querySelector("#preset-count"), 12); dispatchValue(windowObject, documentObject.querySelector("#preset-seed"), 4242); dispatchValue(windowObject, documentObject.querySelector("#preset-minimum-spacing"), 0.01); documentObject.querySelector("#generate-preset-button").click(); await waitFor(() => microphoneCount(documentObject) === 12, "seed random generation"); const firstRandom = documentObject.querySelector("#microphone-list").textContent; documentObject.querySelector("#generate-preset-button").click(); const repeatedRandom = documentObject.querySelector("#microphone-list").textContent; assertEqual(repeatedRandom, firstRandom, "same seed repeats the visible layout"); documentObject.querySelector("#undo-button").click(); assertEqual(microphoneCount(documentObject), 8, "preset replacement is undoable"); documentObject.querySelector("#redo-button").click(); assertEqual(microphoneCount(documentObject), 12, "preset replacement is redoable"); documentObject.querySelector("#add-microphone-button").click(); await waitFor(() => microphoneCount(documentObject) === 13, "manual microphone add"); assertEqual( documentObject.querySelector("#microphone-id").value, "mic-013", "manual add selects a stable next ID", ); dispatchValue(windowObject, documentObject.querySelector("#microphone-id"), "reference-channel"); dispatchValue(windowObject, documentObject.querySelector("#microphone-x"), 0.01); dispatchValue(windowObject, documentObject.querySelector("#microphone-y"), -0.015); dispatchValue(windowObject, documentObject.querySelector("#microphone-z"), 0.002); dispatchValue(windowObject, documentObject.querySelector("#microphone-sensitivity"), -2.5); dispatchValue(windowObject, documentObject.querySelector("#microphone-phase"), 3.5); assertEqual( documentObject.querySelector("#microphone-id").value, "reference-channel", "manual ID edit", ); documentObject.querySelector("#move-microphone-up-button").click(); assertEqual( microphoneButtons(documentObject).at(-2).dataset.microphoneId, "reference-channel", "channel forward button changes physical order", ); const beforeNudge = selectedCoordinates(documentObject); canvas.focus(); canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowRight", })); await nextPaint(windowObject); const afterNudge = selectedCoordinates(documentObject); assertNear(afterNudge[0], beforeNudge[0] + 0.005, 1e-9, "keyboard nudge uses snap step"); assertNear(afterNudge[1], beforeNudge[1], 1e-9, "keyboard nudge keeps y"); const touchCanvasRect = canvas.getBoundingClientRect(); const touchViewport = fitArrayViewport( touchCanvasRect.width, touchCanvasRect.height, { aperture: { width: Number(documentObject.querySelector("#aperture-width").value), height: Number(documentObject.querySelector("#aperture-height").value), }, microphones: [{ id: "selected", position: afterNudge }], }, ); const selectedTouchPixel = arrayPositionToCanvas(afterNudge, touchViewport); const dispatchTouchDragPointer = (type, pointerId, offsetX, offsetY, buttons) => canvas.dispatchEvent(new windowObject.PointerEvent(type, { bubbles: true, cancelable: true, pointerId, pointerType: "touch", isPrimary: true, button: type === "pointermove" ? -1 : 0, buttons, clientX: touchCanvasRect.left + selectedTouchPixel.x + offsetX, clientY: touchCanvasRect.top + selectedTouchPixel.y + offsetY, })); dispatchTouchDragPointer("pointerdown", 22, 0, 0, 1); const microphoneTouchStart = new windowObject.Event("touchstart", { bubbles: true, cancelable: true, }); Object.defineProperty(microphoneTouchStart, "changedTouches", { value: [{ clientX: touchCanvasRect.left + selectedTouchPixel.x, clientY: touchCanvasRect.top + selectedTouchPixel.y, }], }); canvas.dispatchEvent(microphoneTouchStart); assert(microphoneTouchStart.defaultPrevented, "touch on a microphone reserves the drag gesture"); dispatchTouchDragPointer("pointermove", 22, 3, 2, 1); dispatchTouchDragPointer("pointerup", 22, 3, 2, 0); await nextPaint(windowObject); assertEqual( selectedCoordinates(documentObject).join(","), afterNudge.join(","), "touch jitter below the gesture threshold does not move a microphone", ); const blankTouchStart = new windowObject.Event("touchstart", { bubbles: true, cancelable: true, }); Object.defineProperty(blankTouchStart, "changedTouches", { value: [{ clientX: touchCanvasRect.left + 3, clientY: touchCanvasRect.top + 3 }], }); canvas.dispatchEvent(blankTouchStart); assert(!blankTouchStart.defaultPrevented, "blank canvas touch remains available to page scroll"); dispatchTouchDragPointer("pointerdown", 23, 0, 0, 1); dispatchTouchDragPointer("pointermove", 23, 24, -18, 1); dispatchTouchDragPointer("pointerup", 23, 24, -18, 0); await nextPaint(windowObject); const touchDraggedPosition = selectedCoordinates(documentObject); assert( touchDraggedPosition[0] !== afterNudge[0] && touchDraggedPosition[1] !== afterNudge[1], "touch movement beyond the threshold drags both coordinates", ); documentObject.querySelector("#undo-button").click(); assertEqual( selectedCoordinates(documentObject).join(","), afterNudge.join(","), "one undo restores the whole touch drag", ); documentObject.querySelector("#add-tool-button").click(); const canvasRect = canvas.getBoundingClientRect(); canvas.dispatchEvent(new windowObject.PointerEvent("pointerdown", { bubbles: true, cancelable: true, pointerId: 31, pointerType: "mouse", isPrimary: true, button: 0, buttons: 1, clientX: canvasRect.left + canvasRect.width * 0.7, clientY: canvasRect.top + canvasRect.height * 0.35, })); await waitFor(() => microphoneCount(documentObject) === 14, "canvas microphone add"); documentObject.querySelector("#select-tool-button").click(); documentObject.querySelector("#remove-microphone-button").click(); await waitFor(() => microphoneCount(documentObject) === 13, "manual delete"); documentObject.querySelector("#add-tool-button").click(); const dispatchTouchAddPointer = (type, pointerId, offsetX, offsetY, buttons) => canvas.dispatchEvent(new windowObject.PointerEvent(type, { bubbles: true, cancelable: true, pointerId, pointerType: "touch", isPrimary: true, button: type === "pointermove" ? -1 : 0, buttons, clientX: canvasRect.left + canvasRect.width * 0.7 + offsetX, clientY: canvasRect.top + canvasRect.height * 0.35 + offsetY, })); assert( dispatchTouchAddPointer("pointerdown", 32, 0, 0, 1), "touch add pointerdown remains available to page scroll", ); assertEqual(microphoneCount(documentObject), 13, "touch add waits for pointerup"); assert( dispatchTouchAddPointer("pointermove", 32, 0, 12, 1), "touch add scroll movement is not canceled", ); dispatchTouchAddPointer("pointerup", 32, 0, 12, 0); await nextPaint(windowObject); assertEqual(microphoneCount(documentObject), 13, "touch scroll intent does not add a microphone"); dispatchTouchAddPointer("pointerdown", 33, 0, 0, 1); assertEqual(microphoneCount(documentObject), 13, "touch tap still waits while pressed"); dispatchTouchAddPointer("pointermove", 33, 3, 2, 1); assert( !dispatchTouchAddPointer("pointerup", 33, 3, 2, 0), "completed touch add tap prevents its synthetic click", ); await waitFor(() => microphoneCount(documentObject) === 14, "touch canvas microphone add"); documentObject.querySelector("#select-tool-button").click(); documentObject.querySelector("#remove-microphone-button").click(); await waitFor(() => microphoneCount(documentObject) === 13, "touch-added microphone delete"); const firstButton = microphoneButtons(documentObject)[0]; const secondButton = microphoneButtons(documentObject)[1]; firstButton.click(); const firstPosition = selectedCoordinates(documentObject); secondButton.click(); dispatchValue(windowObject, documentObject.querySelector("#microphone-x"), firstPosition[0]); dispatchValue(windowObject, documentObject.querySelector("#microphone-y"), firstPosition[1]); dispatchValue(windowObject, documentObject.querySelector("#microphone-z"), firstPosition[2]); await waitFor( () => documentObject.documentElement.dataset.arrayValid === "false", "duplicate position rejection", ); assert(documentObject.querySelector("#export-button").disabled, "invalid duplicate disables export"); documentObject.querySelector("#undo-button").click(); await waitFor( () => documentObject.documentElement.dataset.arrayValid === "true", "duplicate undo recovery", ); documentObject.querySelector("#export-button").click(); await waitFor(() => downloads.some((item) => item.fileName), "Array JSON export"); const exportedDownload = downloads.find((item) => item.fileName); assert( exportedDownload.fileName.endsWith(".array.json"), "export uses .array.json filename", ); const exported = JSON.parse(await exportedDownload.blob.text()); assertEqual(exported.kind, "micarrsim.array", "standard array kind"); assertEqual((await validateArrayDefinition(exported)).length, 0, "exported Array contract"); assert(!Object.hasOwn(exported, "shape"), "editor shape is not exported"); assert(!Object.hasOwn(exported, "preset"), "editor preset is not exported"); assert(!Object.hasOwn(exported, "seed"), "editor seed is not exported"); const calibrated = exported.microphones.find( (microphone) => microphone.id === "reference-channel", ); assert(calibrated, "edited channel is exported"); assertEqual(calibrated.sensitivityDb, -2.5, "sensitivity is preserved"); assertEqual(calibrated.phaseOffsetDeg, 3.5, "phase is preserved"); assertEqual( exported.microphones.indexOf(calibrated), exported.microphones.length - 2, "export keeps the edited physical channel order", ); calibrated.frequencyResponse = [ { frequencyHz: 100, gainDb: -2, phaseDeg: -4 }, { frequencyHz: 1000, gainDb: 0.5, phaseDeg: 3 }, ]; assertEqual( (await validateArrayDefinition(exported)).length, 0, "response-enhanced round trip fixture contract", ); dispatchValue(windowObject, documentObject.querySelector("#array-name"), "Temporary name"); const exportedFile = new windowObject.File( [JSON.stringify(exported)], "round-trip.array.json", { type: "application/json" }, ); dispatchFiles(windowObject, documentObject.querySelector("#array-input"), [exportedFile]); await waitFor( () => documentObject.querySelector("#array-name").value === exported.name, "Array round trip import", ); assert(!documentObject.querySelector("#undo-button").disabled, "valid import is undoable"); documentObject.querySelector("#undo-button").click(); assertEqual( documentObject.querySelector("#array-name").value, "Temporary name", "import undo restores the previous document", ); assert( documentObject.querySelector("#save-state").classList.contains("is-dirty"), "document before imported baseline is dirty", ); documentObject.querySelector("#redo-button").click(); assertEqual( documentObject.querySelector("#array-name").value, exported.name, "import redo restores the loaded document", ); assert( !documentObject.querySelector("#save-state").classList.contains("is-dirty"), "loaded baseline is clean after redo", ); microphoneButtons(documentObject).find( (button) => button.dataset.microphoneId === "reference-channel", ).click(); assertEqual( Number(documentObject.querySelector("#microphone-sensitivity").value), -2.5, "import keeps sensitivity", ); assert( documentObject.querySelector("#frequency-response-summary").textContent.includes("2개"), "import exposes preserved frequency-response points", ); const importedReferenceX = Number(documentObject.querySelector("#microphone-x").value); dispatchValue( windowObject, documentObject.querySelector("#microphone-x"), importedReferenceX + 0.001, ); documentObject.querySelector("#export-button").click(); await waitFor( () => downloads.filter((item) => item.fileName).length === 2, "edited response Array export", ); const editedExport = JSON.parse( await downloads.filter((item) => item.fileName).at(-1).blob.text(), ); assertEqual( JSON.stringify( editedExport.microphones.find( (microphone) => microphone.id === "reference-channel", ).frequencyResponse, ), JSON.stringify(calibrated.frequencyResponse), "position edit preserves every frequency-response value", ); const responseVariant = structuredClone(editedExport); responseVariant.microphones.find( (microphone) => microphone.id === "reference-channel", ).frequencyResponse[0].gainDb -= 1; const responseVariantFile = new windowObject.File( [JSON.stringify(responseVariant)], "response-variant.array.json", { type: "application/json" }, ); dispatchFiles( windowObject, documentObject.querySelector("#array-input"), [responseVariantFile], ); await waitFor( () => documentObject.querySelector("#save-state").textContent.includes("불러옴"), "same-length response variant import", ); documentObject.querySelector("#undo-button").click(); assert( documentObject.querySelector("#save-state").classList.contains("is-dirty"), "undo detects changed response values even when the point count is unchanged", ); documentObject.querySelector("#redo-button").click(); assert( !documentObject.querySelector("#save-state").classList.contains("is-dirty"), "response variant baseline is clean after redo", ); const countBeforeInvalidImport = microphoneCount(documentObject); const invalidFile = new windowObject.File( [JSON.stringify({ kind: "not-an-array" })], "invalid.json", { type: "application/json" }, ); dispatchFiles(windowObject, documentObject.querySelector("#array-input"), [invalidFile]); await waitFor( () => documentObject.querySelector("#validation-title").textContent.includes("적용하지 못"), "invalid import feedback", ); assertEqual( microphoneCount(documentObject), countBeforeInvalidImport, "invalid import preserves current array", ); const cameraFrame = document.createElement("iframe"); cameraFrame.src = "/apps/acoustic-camera/?integration-test=1&array-builder-handoff=1"; cameraFrame.style.cssText = "position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0"; document.body.append(cameraFrame); await new Promise((resolve, reject) => { cameraFrame.addEventListener("load", resolve, { once: true }); cameraFrame.addEventListener( "error", () => reject(new Error("Camera handoff iframe load failed")), { once: true }, ); }); const cameraWindow = cameraFrame.contentWindow; const cameraDocument = cameraFrame.contentDocument; const cameraDownloads = []; const originalCameraCreate = cameraWindow.URL.createObjectURL.bind(cameraWindow.URL); const originalCameraRevoke = cameraWindow.URL.revokeObjectURL.bind(cameraWindow.URL); const originalCameraClick = cameraWindow.HTMLAnchorElement.prototype.click; cameraWindow.URL.createObjectURL = (blob) => { const url = "blob:array-camera-test-" + String(cameraDownloads.length + 1); cameraDownloads.push({ blob, url, fileName: null }); return url; }; cameraWindow.URL.revokeObjectURL = () => {}; cameraWindow.HTMLAnchorElement.prototype.click = function captureCameraDownload() { const download = cameraDownloads.find((item) => item.url === this.href); if (download) download.fileName = this.download; }; try { await waitFor( () => cameraDocument.documentElement.dataset.appReady === "true", "Camera handoff initialization", ); const handoffFile = new cameraWindow.File( [JSON.stringify(editedExport)], "builder-handoff.array.json", { type: "application/json" }, ); dispatchFiles(cameraWindow, cameraDocument.querySelector("#array-input"), [handoffFile]); await waitFor( () => cameraDocument.querySelector("#array-preset").value === "custom" && cameraDocument.querySelector("#microphone-count").textContent.includes( String(editedExport.microphones.length), ) && cameraDocument.documentElement.dataset.cameraValid === "true", "Camera custom Array acceptance", ); assertEqual( cameraDocument.querySelector("#array-title").textContent, editedExport.name, "Camera shows the Builder array name", ); cameraDocument.querySelector("#export-experiment-button").click(); await waitFor( () => cameraDownloads.some((item) => item.fileName), "Camera Experiment export after handoff", ); const cameraExperiment = JSON.parse( await cameraDownloads.find((item) => item.fileName).blob.text(), ); assertEqual( cameraExperiment.arrayId, editedExport.id, "Camera rebinds Experiment to the Builder Array ID", ); } finally { cameraWindow.URL.createObjectURL = originalCameraCreate; cameraWindow.URL.revokeObjectURL = originalCameraRevoke; cameraWindow.HTMLAnchorElement.prototype.click = originalCameraClick; cameraFrame.remove(); } frame.style.width = "390px"; await waitFor( () => documentObject.querySelector("#array-builder-layout").dataset.splitPaneActive === "false", "compact Array Builder layout", ); await nextPaint(windowObject); const compactWorkspace = documentObject.querySelector("#array-builder-workspace"); const compactControls = documentObject.querySelector("#array-builder-controls"); const compactWorkspaceRect = compactWorkspace.getBoundingClientRect(); const compactControlsRect = compactControls.getBoundingClientRect(); assert( Boolean( compactWorkspace.compareDocumentPosition(compactControls) & windowObject.Node.DOCUMENT_POSITION_FOLLOWING, ), "compact canvas workspace also precedes settings in DOM order", ); assert( compactWorkspaceRect.bottom <= compactControlsRect.top + 1, "compact canvas workspace appears before the long settings form", ); assert( documentObject.querySelector(".array-metadata-section").getBoundingClientRect().top < documentObject.querySelector(".aperture-section").getBoundingClientRect().top && documentObject.querySelector(".aperture-section").getBoundingClientRect().top < documentObject.querySelector(".preset-section").getBoundingClientRect().top && documentObject.querySelector(".preset-section").getBoundingClientRect().top < documentObject.querySelector(".microphone-section").getBoundingClientRect().top, "compact controls keep the numbered source and visual order", ); assert( documentObject.documentElement.scrollWidth <= documentObject.documentElement.clientWidth + 1, "Array Builder has no horizontal overflow at 390px", ); assert( documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350, "Array canvas remains usable at 390px", ); assertEqual( windowObject.getComputedStyle(documentObject.querySelector("#array-canvas")).touchAction, "pan-y", "mobile canvas permits vertical page scrolling", ); assert( windowObject.getComputedStyle(documentObject.querySelector("#mobile-export-button")).display !== "none", "compact settings end with a visible save action", ); } finally { windowObject.URL.createObjectURL = originalCreate; windowObject.URL.revokeObjectURL = originalRevoke; windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick; frame.remove(); } });