1317 lines
54 KiB
JavaScript
1317 lines
54 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 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");
|
|
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");
|
|
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");
|
|
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");
|
|
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");
|
|
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");
|
|
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();
|
|
}
|
|
});
|