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 logarithmicFrequencyPosition(frequencyHz) { return Math.round(Math.log10(frequencyHz) * 1000); } 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 렌더", ); await waitFor( () => documentObject.documentElement.dataset.beamPatternReady === "true", "기본 수신 빔 패턴 렌더", ); const beamCuts = ["xz", "yz", "xy"]; const beamPlots = Object.fromEntries(beamCuts.map((cut) => [ cut, documentObject.querySelector("#beam-pattern-plot-" + cut), ])); const initialBeamPaths = Object.fromEntries(beamCuts.map((cut) => [ cut, beamPlots[cut].querySelector(".beam-pattern-response")?.getAttribute("d"), ])); for (const cut of beamCuts) { const beamPlot = beamPlots[cut]; assert( initialBeamPaths[cut]?.startsWith("M"), cut.toUpperCase() + " polar response path is rendered", ); assertEqual( beamPlot.querySelectorAll(".beam-pattern-ring").length, 5, cut.toUpperCase() + " polar plot has four inner dB rings and the 0 dB ring", ); assertEqual(beamPlot.dataset.cut, cut, cut.toUpperCase() + " cut provenance"); assertEqual( beamPlot.dataset.frequencyHz, "1000", cut.toUpperCase() + " default analog frequency", ); assert( Number(beamPlot.dataset.sampleCount) >= 720, cut.toUpperCase() + " auto angular sampling uses at least 720 directions", ); } assert( !documentObject.querySelector("#beam-pattern-cuts-view").hidden && documentObject.querySelector("#beam-pattern-surface-view").hidden, "three polar cuts are the simultaneous default view", ); assertEqual( documentObject.querySelectorAll("#beam-pattern-cuts-view .beam-pattern-figure").length, 3, "XZ, YZ, and XY figures are present together", ); assert( documentObject.querySelector("#beam-pattern-model-note").textContent.includes( "Nyquist 초과", ), "UI explains that the geometry-only pattern does not fold analog frequency", ); const initialSaveState = documentObject.querySelector("#save-state").textContent; const frequencySlider = documentObject.querySelector("#beam-pattern-frequency"); dispatchValue( windowObject, frequencySlider, logarithmicFrequencyPosition(49_125), "input", ); await waitFor( () => documentObject.documentElement.dataset.beamPatternPreview === "true" && beamCuts.every((cut) => Number(beamPlots[cut].dataset.frequencyHz) > 48_000 && beamPlots[cut].dataset.sampleCount === "720"), "logarithmic slider input renders a bounded live preview in every cut", ); const previewFrequencyHz = Number(frequencySlider.getAttribute("aria-valuenow")); assert( previewFrequencyHz > 48_000 && previewFrequencyHz < 50_000, "log slider position maps to the intended above-Nyquist-style analog frequency", ); assert( documentObject.querySelector("#beam-pattern-frequency-value").textContent.includes("kHz"), "log slider exposes its physical frequency value", ); frequencySlider.dispatchEvent(new windowObject.Event("change", { bubbles: true })); await waitFor( () => documentObject.documentElement.dataset.beamPatternPreview === "false" && beamCuts.every((cut) => Number(beamPlots[cut].dataset.sampleCount) > 720), "logarithmic slider change renders full auto resolution in every cut", ); assert( documentObject.querySelector("#beam-pattern-wavelength").textContent.includes("mm"), "high analog frequency uses its short physical wavelength", ); dispatchValue( windowObject, documentObject.querySelector("#beam-pattern-samples"), 360, ); await waitFor( () => beamCuts.every((cut) => beamPlots[cut].dataset.sampleCount === "360") && documentObject.querySelector("#beam-pattern-hpbw").textContent.includes( "해상도 부족", ), "coarse angular grid exposes unresolved metrics for the simultaneous cuts", ); dispatchValue( windowObject, documentObject.querySelector("#beam-pattern-speed-of-sound"), "", "input", ); await waitFor( () => documentObject.documentElement.dataset.beamPatternReady === "error", "invalid beam preview setting", ); assert( !documentObject.querySelector("#export-button").disabled, "invalid preview never blocks valid Array export", ); dispatchValue( windowObject, documentObject.querySelector("#beam-pattern-speed-of-sound"), 343, "input", ); dispatchValue( windowObject, frequencySlider, logarithmicFrequencyPosition(1_000), "input", ); frequencySlider.dispatchEvent(new windowObject.Event("change", { bubbles: true })); dispatchValue( windowObject, documentObject.querySelector("#beam-pattern-samples"), "auto", ); await waitFor( () => documentObject.documentElement.dataset.beamPatternReady === "true" && documentObject.documentElement.dataset.beamPatternPreview === "false" && beamCuts.every((cut) => beamPlots[cut].dataset.frequencyHz === "1000" && Number(beamPlots[cut].dataset.sampleCount) >= 720), "beam preview recovery", ); assertEqual( documentObject.querySelector("#save-state").textContent, initialSaveState, "beam preview settings are outside Array dirty state", ); documentObject.querySelector("#beam-pattern-view-3d-button").click(); const beamSurface = documentObject.querySelector("#beam-pattern-surface"); await waitFor( () => documentObject.documentElement.dataset.beamPatternView === "surface" && documentObject.documentElement.dataset.beamPatternReady === "true" && documentObject.documentElement.dataset.beamPatternPreview === "false" && !documentObject.querySelector("#beam-pattern-surface-view").hidden && documentObject.querySelector("#beam-pattern-cuts-view").hidden && Number(beamSurface.dataset.sampleCount) > 0, "3D beam surface render", ); assert( /^\d+x\d+$/.test(beamSurface.dataset.gridShape), "3D beam canvas records its raw sphere grid", ); assertEqual( documentObject.querySelector("#beam-pattern-view-3d-button").getAttribute("aria-pressed"), "true", "3D beam view toggle is pressed", ); assert( documentObject.querySelector("#beam-pattern-surface-caption").textContent.includes( "raw grid samples", ), "3D beam caption identifies raw sampled directions", ); assert( documentObject.querySelector("#beam-pattern-surface-caption").textContent.includes( "dB-floor radial mapping", ), "3D beam caption discloses the display-only radial mapping", ); await nextPaint(windowObject); const beamAxisView = documentObject.querySelector("#beam-pattern-axis-view"); assertEqual(beamAxisView.value, "iso", "3D beam surface starts in isometric view"); assertEqual(beamSurface.dataset.viewAxis, "iso", "beam canvas exposes isometric provenance"); assert( beamSurface.getAttribute("aria-label").includes("등각 원점 보기"), "beam canvas accessible name exposes its current view", ); const beamComputeBeforeAxisView = documentObject.documentElement.dataset.beamPatternComputeMs; const beamFrequencyBeforeAxisView = beamSurface.dataset.frequencyHz; const surfaceBeforeAxisView = beamSurface.toDataURL(); beamAxisView.focus(); dispatchValue(windowObject, beamAxisView, "+z"); await nextPaint(windowObject); assertEqual(beamSurface.dataset.viewAxis, "+z", "beam surface aligns from +Z"); assert( beamSurface.toDataURL() !== surfaceBeforeAxisView, "beam surface bitmap changes for the exact +Z camera", ); dispatchValue(windowObject, beamAxisView, "+x"); await nextPaint(windowObject); assertEqual(beamSurface.dataset.viewAxis, "+x", "beam surface aligns from +X"); assertEqual( documentObject.activeElement, beamAxisView, "beam axis selector keeps keyboard focus for consecutive comparisons", ); assertEqual( documentObject.documentElement.dataset.beamPatternComputeMs, beamComputeBeforeAxisView, "beam camera changes do not recompute the physical response", ); assertEqual( beamSurface.dataset.frequencyHz, beamFrequencyBeforeAxisView, "beam camera changes preserve the evaluated analog frequency", ); assertEqual( documentObject.querySelector("#save-state").textContent, initialSaveState, "beam camera changes stay outside Array dirty state", ); const surfaceBeforeOrbit = beamSurface.toDataURL(); const beamSurfaceRect = beamSurface.getBoundingClientRect(); const beamOrbitPointerId = 71; beamSurface.dispatchEvent(new windowObject.PointerEvent("pointerdown", { bubbles: true, cancelable: true, pointerId: beamOrbitPointerId, pointerType: "mouse", button: 0, buttons: 1, clientX: beamSurfaceRect.left + beamSurfaceRect.width * 0.5, clientY: beamSurfaceRect.top + beamSurfaceRect.height * 0.5, })); assertEqual(beamSurface.dataset.orbiting, "true", "3D beam drag begins orbiting"); beamSurface.dispatchEvent(new windowObject.PointerEvent("pointermove", { bubbles: true, cancelable: true, pointerId: beamOrbitPointerId, pointerType: "mouse", button: -1, buttons: 1, clientX: beamSurfaceRect.left + beamSurfaceRect.width * 0.7, clientY: beamSurfaceRect.top + beamSurfaceRect.height * 0.36, })); beamSurface.dispatchEvent(new windowObject.PointerEvent("pointerup", { bubbles: true, cancelable: true, pointerId: beamOrbitPointerId, pointerType: "mouse", button: 0, buttons: 0, })); await nextPaint(windowObject); assert(!beamSurface.dataset.orbiting, "3D beam drag finishes orbiting"); assert( beamSurface.toDataURL() !== surfaceBeforeOrbit, "3D beam surface bitmap changes after orbit rotation", ); assertEqual(beamSurface.dataset.viewAxis, "custom", "beam drag enters free-orbit mode"); assertEqual(beamAxisView.value, "custom", "beam view selector reflects free orbit"); assert( beamSurface.getAttribute("aria-label").includes("자유 회전"), "beam canvas accessible name follows free orbit", ); beamSurface.dispatchEvent(new windowObject.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "r", })); await nextPaint(windowObject); assertEqual(beamSurface.dataset.viewAxis, "iso", "beam R shortcut restores isometric view"); assertEqual(beamAxisView.value, "iso", "beam selector follows the R shortcut"); assert( beamSurface.getAttribute("aria-label").includes("등각 원점 보기"), "beam accessible name follows the R shortcut", ); assertEqual( documentObject.documentElement.dataset.beamPatternComputeMs, beamComputeBeforeAxisView, "beam camera reset also avoids a physical-response recompute", ); documentObject.querySelector("#beam-pattern-view-cuts-button").click(); await waitFor( () => documentObject.documentElement.dataset.beamPatternView === "cuts" && documentObject.documentElement.dataset.beamPatternReady === "true" && !documentObject.querySelector("#beam-pattern-cuts-view").hidden && documentObject.querySelector("#beam-pattern-surface-view").hidden, "return to simultaneous polar cuts", ); 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"); assertEqual(canvas.dataset.viewMode, "2d", "array canvas starts in editable 2D mode"); assert( documentObject.querySelector("#canvas-front-badge").textContent.includes( "+Z FRONT · 화면 바깥쪽", ), "2D canvas states the +Z front direction", ); const coordinatesBeforeArray3d = selectedCoordinates(documentObject); const microphoneCountBeforeArray3d = microphoneCount(documentObject); documentObject.querySelector("#array-view-3d-button").click(); await waitFor( () => canvas.dataset.viewMode === "3d" && canvas.getAttribute("aria-label").includes("3D 검사"), "array 3D inspection mode", ); assert( documentObject.querySelector("#add-tool-button").disabled && documentObject.querySelector("#select-tool-button").disabled, "array canvas edit tools are disabled in 3D inspection", ); assert( documentObject.querySelector("#canvas-front-badge").textContent.includes( "시점 등각", ), "3D canvas identifies its isometric camera", ); assert( documentObject.querySelector("#canvas-instructions").textContent.includes("검사 전용"), "3D array guidance explains that coordinate editing is disabled", ); canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "a", })); await nextPaint(windowObject); assertEqual(canvas.dataset.tool, "select", "A shortcut cannot enable add in 3D inspection"); assertEqual( microphoneCount(documentObject), microphoneCountBeforeArray3d, "3D inspection cannot add a microphone", ); assertEqual( selectedCoordinates(documentObject).join(","), coordinatesBeforeArray3d.join(","), "3D inspection leaves microphone coordinates unchanged", ); const arrayAxisControl = documentObject.querySelector("#array-axis-view-control"); const arrayAxisView = documentObject.querySelector("#array-axis-view"); assert(!arrayAxisControl.hidden, "array axis selector appears only for 3D inspection"); assertEqual(arrayAxisView.value, "iso", "array 3D starts in isometric view"); assert( canvas.getAttribute("aria-label").includes("등각 원점 보기"), "array canvas accessible name exposes its current view", ); const arraySaveStateBeforeAxisView = documentObject.querySelector("#save-state").textContent; const array3dBeforeAxisView = canvas.toDataURL(); arrayAxisView.focus(); dispatchValue(windowObject, arrayAxisView, "+z"); await nextPaint(windowObject); assertEqual(canvas.dataset.viewAxis, "+z", "array canvas aligns from +Z FRONT"); dispatchValue(windowObject, arrayAxisView, "+x"); await nextPaint(windowObject); assertEqual(canvas.dataset.viewAxis, "+x", "array canvas aligns from +X"); assertEqual( documentObject.activeElement, arrayAxisView, "array axis selector keeps keyboard focus for consecutive comparisons", ); assert( canvas.toDataURL() !== array3dBeforeAxisView, "array 3D bitmap changes after axis alignment", ); assertEqual( selectedCoordinates(documentObject).join(","), coordinatesBeforeArray3d.join(","), "axis camera changes leave microphone coordinates unchanged", ); assertEqual( documentObject.querySelector("#save-state").textContent, arraySaveStateBeforeAxisView, "axis camera changes do not dirty the Array document", ); const selectionBeforeArrayOrbit = documentObject .querySelector('#microphone-list [aria-selected="true"]')?.dataset.microphoneId; const array3dBeforeOrbit = canvas.toDataURL(); const array3dRect = canvas.getBoundingClientRect(); const arrayOrbitPointerId = 72; canvas.dispatchEvent(new windowObject.PointerEvent("pointerdown", { bubbles: true, cancelable: true, pointerId: arrayOrbitPointerId, pointerType: "mouse", button: 0, buttons: 1, clientX: array3dRect.left + array3dRect.width * 0.5, clientY: array3dRect.top + array3dRect.height * 0.5, })); assertEqual(canvas.dataset.orbiting, "true", "array 3D drag begins orbiting"); canvas.dispatchEvent(new windowObject.PointerEvent("pointermove", { bubbles: true, cancelable: true, pointerId: arrayOrbitPointerId, pointerType: "mouse", button: -1, buttons: 1, clientX: array3dRect.left + array3dRect.width * 0.68, clientY: array3dRect.top + array3dRect.height * 0.4, })); canvas.dispatchEvent(new windowObject.PointerEvent("pointerup", { bubbles: true, cancelable: true, pointerId: arrayOrbitPointerId, pointerType: "mouse", button: 0, buttons: 0, })); await nextPaint(windowObject); assertEqual(canvas.dataset.orbiting, "false", "array 3D drag finishes orbiting"); assert( canvas.toDataURL() !== array3dBeforeOrbit, "array 3D canvas bitmap changes after orbit rotation", ); assertEqual(canvas.dataset.viewAxis, "custom", "array drag enters free-orbit mode"); assertEqual(arrayAxisView.value, "custom", "array view selector reflects free orbit"); assert( canvas.getAttribute("aria-label").includes("자유 회전"), "array canvas accessible name follows free orbit", ); assertEqual( documentObject.querySelector('#microphone-list [aria-selected="true"]') ?.dataset.microphoneId, selectionBeforeArrayOrbit, "array orbit drag preserves the current channel selection", ); canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "r", })); await nextPaint(windowObject); assertEqual(canvas.dataset.viewAxis, "iso", "array R shortcut restores isometric view"); assertEqual(arrayAxisView.value, "iso", "array selector follows the R shortcut"); assert( canvas.getAttribute("aria-label").includes("등각 원점 보기"), "array accessible name follows the R shortcut", ); assertEqual( documentObject.querySelector("#save-state").textContent, arraySaveStateBeforeAxisView, "array camera reset stays outside Array dirty state", ); documentObject.querySelector("#array-view-2d-button").click(); await waitFor( () => canvas.dataset.viewMode === "2d" && canvas.getAttribute("aria-label").includes("평면 편집"), "return to array 2D editing", ); assert(arrayAxisControl.hidden, "array axis selector hides in 2D editing"); assert( !documentObject.querySelector("#add-tool-button").disabled && !documentObject.querySelector("#select-tool-button").disabled, "array canvas edit tools are restored in 2D", ); assert( documentObject.querySelector("#canvas-front-badge").textContent.includes( "+Z FRONT · 화면 바깥쪽", ), "returning to 2D restores its +Z front cue", ); documentObject.querySelector( '#microphone-list [data-microphone-id="mic-001"]', ).click(); assertEqual( selectedCoordinates(documentObject).join(","), coordinatesBeforeArray3d.join(","), "2D edit resumes from the original microphone coordinates", ); 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 waitFor( () => documentObject.documentElement.dataset.beamPatternPreview === "false" && beamCuts.every((cut) => beamPlots[cut].querySelector(".beam-pattern-response")?.getAttribute("d") !== initialBeamPaths[cut]), "all beam-pattern cuts follow edited microphone geometry", ); 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", ); assert( documentObject.querySelector("#preset-hint").textContent.includes("실제 내접원"), "ring hint describes a true circle instead of the boundary perimeter", ); for (let index = 0; index < microphoneCount(documentObject); index += 1) { microphoneButtons(documentObject)[index].click(); const [x, y] = selectedCoordinates(documentObject); assertNear(Math.hypot(x, y), 0.215, 1e-8, "visible ring radius " + index); } dispatchValue( windowObject, documentObject.querySelector("#preset-type"), "archimedean", ); assert( !documentObject.querySelector("#preset-turns").closest(".field").hidden, "Archimedean preset exposes its turn count", ); assert( documentObject.querySelector("#preset-hint").textContent.includes("r∝θ"), "Archimedean hint identifies the curved spiral law", ); dispatchValue(windowObject, documentObject.querySelector("#preset-count"), 9); dispatchValue(windowObject, documentObject.querySelector("#preset-turns"), 1.5); documentObject.querySelector("#generate-preset-button").click(); await waitFor(() => microphoneCount(documentObject) === 9, "Archimedean generation"); microphoneButtons(documentObject)[0].click(); assertEqual( selectedCoordinates(documentObject).join(","), "0,0,0", "Archimedean spiral visibly starts at the center", ); microphoneButtons(documentObject).at(-1).click(); const archimedeanEnd = selectedCoordinates(documentObject); assertNear(archimedeanEnd[0], -0.215, 1e-8, "1.5-turn spiral ends at -x"); assertNear(archimedeanEnd[1], 0, 1e-8, "1.5-turn spiral endpoint y"); dispatchValue(windowObject, documentObject.querySelector("#preset-type"), "spiral"); assert( documentObject.querySelector("#preset-turns").closest(".field").hidden, "Fermat preset does not claim an Archimedean turn count", ); assert( documentObject.querySelector("#preset-hint").textContent.includes("해바라기형"), "Fermat hint explains why its points do not look like one connected curve", ); 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), 9, "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"); assert(!Object.hasOwn(exported, "beamPattern"), "beam preview 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 defaultCameraExperiment = await cameraWindow .fetch("/assets/sample-scenes/single-tone.experiment.json") .then((response) => response.json()); 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, defaultCameraExperiment.arrayId, "Camera preserves the Experiment Array provenance label", ); assert( cameraExperiment.arrayId !== editedExport.id, "opening a Builder Array does not rewrite Experiment provenance", ); } 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", ); const compactBeamCardRect = documentObject.querySelector("#beam-pattern-panel") .getBoundingClientRect(); const compactBeamPlotRects = beamCuts.map((cut) => beamPlots[cut].getBoundingClientRect()); assert( compactBeamPlotRects.every((rect) => rect.left >= compactBeamCardRect.left - 1 && rect.right <= compactBeamCardRect.right + 1), "all compact polar plots stay inside their analysis card", ); assert( compactBeamPlotRects.every((rect) => Math.abs(rect.width - rect.height) <= 2), "all compact polar plots remain square", ); assert( compactBeamPlotRects.every( (rect, index) => index === 0 || rect.top > compactBeamPlotRects[index - 1].bottom, ), "compact XZ, YZ, and XY plots stack without overlap", ); const compactBeamFields = [ ...documentObject.querySelectorAll(".beam-pattern-fields > .field"), ].filter((field) => !field.hidden).map((field) => field.getBoundingClientRect()); assert( compactBeamFields.every( (rect, index) => index === 0 || rect.top >= compactBeamFields[index - 1].bottom - 1, ), "compact beam controls stack without clipping", ); documentObject.querySelector("#beam-pattern-view-3d-button").click(); await waitFor( () => documentObject.documentElement.dataset.beamPatternView === "surface" && documentObject.documentElement.dataset.beamPatternReady === "true" && !documentObject.querySelector("#beam-pattern-surface-view").hidden && Number(beamSurface.dataset.sampleCount) > 0, "compact 3D beam surface", ); await nextPaint(windowObject); const compactBeamSurfaceRect = beamSurface.getBoundingClientRect(); const compactBeamSurfaceCardRect = documentObject.querySelector("#beam-pattern-panel") .getBoundingClientRect(); assert( compactBeamSurfaceRect.left >= compactBeamSurfaceCardRect.left - 1 && compactBeamSurfaceRect.right <= compactBeamSurfaceCardRect.right + 1, "compact 3D beam surface stays inside its analysis card", ); assertNear( compactBeamSurfaceRect.width / compactBeamSurfaceRect.height, 16 / 10, 0.03, "compact 3D beam surface preserves its aspect ratio", ); assertEqual( windowObject.getComputedStyle(beamSurface).touchAction, "pan-y", "compact beam 3D view preserves vertical page scrolling", ); const compactBeamAxisRect = documentObject.querySelector("#beam-pattern-axis-view") .getBoundingClientRect(); assert( compactBeamAxisRect.left >= compactBeamSurfaceCardRect.left - 1 && compactBeamAxisRect.right <= compactBeamSurfaceCardRect.right + 1, "compact beam axis selector stays inside its analysis card", ); assert( documentObject.documentElement.scrollWidth <= documentObject.documentElement.clientWidth + 1, "3D beam mode has no horizontal overflow at 390px", ); assert( documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 350, "Array canvas remains usable at 390px", ); documentObject.querySelector("#array-view-3d-button").click(); await waitFor(() => canvas.dataset.viewMode === "3d", "compact array 3D mode"); const compactAxisControlRect = arrayAxisControl.getBoundingClientRect(); const compactToolbarRect = documentObject.querySelector(".workspace-toolbar") .getBoundingClientRect(); assert( compactAxisControlRect.left >= compactToolbarRect.left - 1 && compactAxisControlRect.right <= compactToolbarRect.right + 1, "compact array axis selector stays inside its toolbar", ); const compactFrontBadge = documentObject.querySelector("#canvas-front-badge"); assert( compactFrontBadge.scrollWidth <= compactFrontBadge.clientWidth + 1 && windowObject.getComputedStyle(compactFrontBadge).whiteSpace !== "nowrap", "compact 3D direction badge wraps without clipping axis and FRONT cues", ); assertEqual( windowObject.getComputedStyle(canvas).touchAction, "pan-y", "compact array 3D view preserves vertical page scrolling", ); assert( documentObject.documentElement.scrollWidth <= documentObject.documentElement.clientWidth + 1, "array axis selector creates no horizontal overflow at 390px", ); documentObject.querySelector("#array-view-2d-button").click(); await waitFor(() => canvas.dataset.viewMode === "2d", "return from compact array 3D"); 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(); } });