import { validateArrayDefinition } from "/packages/contracts/validation.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", ); 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( 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", ); 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", ); const beforeNudge = selectedCoordinates(documentObject); const canvas = documentObject.querySelector("#array-canvas"); 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"); 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"); 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"); 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", ); microphoneButtons(documentObject).find( (button) => button.dataset.microphoneId === "reference-channel", ).click(); assertEqual( Number(documentObject.querySelector("#microphone-sensitivity").value), -2.5, "import keeps sensitivity", ); 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", ); frame.style.width = "390px"; await waitFor( () => documentObject.querySelector("#array-builder-layout").dataset.splitPaneActive === "false", "compact Array Builder layout", ); await nextPaint(windowObject); 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", ); } finally { windowObject.URL.createObjectURL = originalCreate; windowObject.URL.revokeObjectURL = originalRevoke; windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick; frame.remove(); } });