Files
micarrsim/tests/integration/array-builder-ui.test.js
T

835 lines
34 KiB
JavaScript

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 렌더",
);
await waitFor(
() => documentObject.documentElement.dataset.beamPatternReady === "true",
"기본 수신 빔 패턴 렌더",
);
const beamPlot = documentObject.querySelector("#beam-pattern-plot");
const initialBeamPath = beamPlot.querySelector(".beam-pattern-response")
?.getAttribute("d");
assert(initialBeamPath?.startsWith("M"), "polar response path is rendered");
assertEqual(
beamPlot.querySelectorAll(".beam-pattern-ring").length,
5,
"polar plot has four inner dB rings and the 0 dB ring",
);
assertEqual(beamPlot.dataset.cut, "xz", "default great-circle cut");
assertEqual(beamPlot.dataset.frequencyHz, "1000", "default analog frequency");
assert(
Number(beamPlot.dataset.sampleCount) >= 720,
"auto angular sampling uses at least 720 directions",
);
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;
dispatchValue(
windowObject,
documentObject.querySelector("#beam-pattern-frequency"),
49_125,
"input",
);
await waitFor(
() => beamPlot.dataset.frequencyHz === "49125" &&
Number(beamPlot.dataset.sampleCount) > 720,
"above-Nyquist-style analog pattern with auto angular sampling",
);
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(
() => beamPlot.dataset.sampleCount === "360" &&
documentObject.querySelector("#beam-pattern-hpbw").textContent.includes(
"해상도 부족",
),
"coarse angular grid exposes unresolved metrics",
);
dispatchValue(
windowObject,
documentObject.querySelector("#beam-pattern-frequency"),
"",
"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-samples"),
"auto",
);
dispatchValue(
windowObject,
documentObject.querySelector("#beam-pattern-frequency"),
1_000,
"input",
);
await waitFor(
() => documentObject.documentElement.dataset.beamPatternReady === "true" &&
beamPlot.dataset.frequencyHz === "1000",
"beam preview recovery",
);
assertEqual(
documentObject.querySelector("#save-state").textContent,
initialSaveState,
"beam preview settings are outside Array dirty state",
);
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 waitFor(
() => documentObject.documentElement.dataset.beamPatternPreview === "false" &&
beamPlot.querySelector(".beam-pattern-response")?.getAttribute("d") !==
initialBeamPath,
"beam pattern follows 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",
);
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");
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 compactBeamPlotRect = documentObject.querySelector("#beam-pattern-plot")
.getBoundingClientRect();
const compactBeamCardRect = documentObject.querySelector("#beam-pattern-panel")
.getBoundingClientRect();
assert(
compactBeamPlotRect.left >= compactBeamCardRect.left - 1 &&
compactBeamPlotRect.right <= compactBeamCardRect.right + 1,
"compact polar plot stays inside its analysis card",
);
assert(
Math.abs(compactBeamPlotRect.width - compactBeamPlotRect.height) <= 2,
"compact polar plot remains square",
);
const compactBeamFields = [
...documentObject.querySelectorAll(".beam-pattern-fields > .field"),
].map((field) => field.getBoundingClientRect());
assert(
compactBeamFields.every(
(rect, index) => index === 0 || rect.top >= compactBeamFields[index - 1].bottom - 1,
),
"compact beam controls stack without clipping",
);
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();
}
});