fix: harden array builder editing workflows
This commit is contained in:
@@ -22,11 +22,15 @@ Unit과 integration test가 같은 페이지에서 실행됩니다. WebGPU 장
|
||||
- `server.test.js`: 숨김 저장소 경로 차단, GET/HEAD 제한과 보안 response headers
|
||||
- `builder-ui.test.js`: 파일 선택 한 번으로 오디오 음원 생성, 이미지·오디오 연결, canvas 편집,
|
||||
trajectory keyframe 편집, Preview WAV/Scene 다운로드, SHA 재연결과 잘못된 import 보존
|
||||
- `array-builder-ui.test.js`: 프리셋·직접 추가·좌표·보정·채널 편집, undo/redo,
|
||||
중복 좌표 차단, 표준 Array JSON 저장·재가져오기와 390px layout
|
||||
- `camera-ui.test.js`: Dedicated Worker CPU/WebGPU 실행, 취소, Result schema, rectangular/Fermat와
|
||||
DAS/DAS-PHAT 비교, GCC-PHAT CPU·협대역 제약; 이동 예제 로드, 8×8 CPU·2 Hz live map의
|
||||
Auto→CPU fallback 뒤 공통 analysis plan 재사용, 복수 frame, scrub, 재생 시간 증가와 일시정지
|
||||
- `workflow-ui.test.js`: Dataset Builder의 Scene JSON과 원본 assets를 Acoustic Camera에 넘겨
|
||||
Result JSON을 저장하는 end-to-end 흐름
|
||||
- `split-layout-ui.test.js`: 세 앱 설정 패널의 drag·keyboard 폭 조절, 저장·reset과
|
||||
좁은 화면 적층
|
||||
|
||||
WebGPU adapter가 없는 환경에서는 장치가 필요한 비교 항목을 건너뛰거나 Auto fallback을 검증하므로
|
||||
CPU-only PASS도 가능합니다. 실제 장치의 scaling과 benchmark JSON 저장은
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
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(
|
||||
@@ -103,16 +108,116 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
documentObject.querySelector("#array-canvas").getBoundingClientRect().height >= 500,
|
||||
"canvas dominates the desktop workspace",
|
||||
);
|
||||
assert(
|
||||
!documentObject.querySelector("#canvas-instructions").classList.contains("is-hidden"),
|
||||
"canvas guidance starts visible",
|
||||
);
|
||||
|
||||
const canvas = documentObject.querySelector("#array-canvas");
|
||||
const defaultArray = createArrayDefinition();
|
||||
const initialCanvasRect = canvas.getBoundingClientRect();
|
||||
const initialViewport = fitArrayViewport(
|
||||
initialCanvasRect.width,
|
||||
initialCanvasRect.height,
|
||||
defaultArray,
|
||||
);
|
||||
const firstMicrophonePixel = arrayPositionToCanvas(
|
||||
defaultArray.microphones[0].position,
|
||||
initialViewport,
|
||||
);
|
||||
const initialFirstPosition = selectedCoordinates(documentObject);
|
||||
const dragPointerId = 21;
|
||||
const dispatchDragPointer = (type, offsetX, offsetY, buttons) => {
|
||||
canvas.dispatchEvent(new windowObject.PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerId: dragPointerId,
|
||||
pointerType: "mouse",
|
||||
isPrimary: true,
|
||||
button: type === "pointermove" ? -1 : 0,
|
||||
buttons,
|
||||
clientX: initialCanvasRect.left + firstMicrophonePixel.x + offsetX,
|
||||
clientY: initialCanvasRect.top + firstMicrophonePixel.y + offsetY,
|
||||
}));
|
||||
};
|
||||
dispatchDragPointer("pointerdown", 0, 0, 1);
|
||||
dispatchDragPointer("pointermove", 36, -24, 1);
|
||||
dispatchDragPointer("pointerup", 36, -24, 0);
|
||||
await nextPaint(windowObject);
|
||||
const draggedPosition = selectedCoordinates(documentObject);
|
||||
assert(
|
||||
draggedPosition[0] !== initialFirstPosition[0] &&
|
||||
draggedPosition[1] !== initialFirstPosition[1],
|
||||
"pointer drag changes both array coordinates",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector("#canvas-instructions").classList.contains("is-hidden"),
|
||||
"first canvas edit dismisses the guidance overlay",
|
||||
);
|
||||
documentObject.querySelector("#undo-button").click();
|
||||
assertEqual(
|
||||
selectedCoordinates(documentObject).join(","),
|
||||
initialFirstPosition.join(","),
|
||||
"one undo restores the whole drag",
|
||||
);
|
||||
|
||||
dispatchDragPointer("pointerdown", 0, 0, 1);
|
||||
dispatchDragPointer("pointermove", 28, -18, 1);
|
||||
canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: "Escape",
|
||||
}));
|
||||
await nextPaint(windowObject);
|
||||
assertEqual(
|
||||
selectedCoordinates(documentObject).join(","),
|
||||
initialFirstPosition.join(","),
|
||||
"Escape cancels an in-progress drag",
|
||||
);
|
||||
|
||||
const initialApertureWidth = documentObject.querySelector("#aperture-width").value;
|
||||
dispatchValue(windowObject, documentObject.querySelector("#aperture-width"), 0);
|
||||
assertEqual(
|
||||
documentObject.querySelector("#aperture-width").value,
|
||||
initialApertureWidth,
|
||||
"rejected aperture input is restored to the active value",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector(".validation-card").classList.contains("is-invalid"),
|
||||
"rejected action has an error visual state",
|
||||
);
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#preset-count"), "");
|
||||
dispatchValue(windowObject, documentObject.querySelector("#preset-columns"), 3);
|
||||
dispatchValue(windowObject, documentObject.querySelector("#preset-rows"), 2);
|
||||
documentObject.querySelector("#generate-preset-button").click();
|
||||
await waitFor(() => microphoneCount(documentObject) === 6, "3x2 grid generation");
|
||||
assertEqual(
|
||||
documentObject.querySelector("#preset-count").value,
|
||||
"",
|
||||
"hidden count does not block grid generation",
|
||||
);
|
||||
assertEqual(
|
||||
microphoneButtons(documentObject).map((button) => button.dataset.microphoneId).join(","),
|
||||
"mic-001,mic-002,mic-003,mic-004,mic-005,mic-006",
|
||||
"grid keeps row-major physical channel IDs",
|
||||
);
|
||||
assertEqual(
|
||||
microphoneButtons(documentObject).filter((button) => button.tabIndex === 0).length,
|
||||
1,
|
||||
"microphone list has one roving Tab stop",
|
||||
);
|
||||
microphoneButtons(documentObject)[0].focus();
|
||||
microphoneButtons(documentObject)[0].dispatchEvent(new windowObject.KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: "ArrowDown",
|
||||
}));
|
||||
assertEqual(
|
||||
documentObject.activeElement.dataset.microphoneId,
|
||||
"mic-002",
|
||||
"ArrowDown selects and focuses the next channel",
|
||||
);
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#aperture-shape"), "ellipse");
|
||||
dispatchValue(windowObject, documentObject.querySelector("#preset-type"), "ring");
|
||||
@@ -158,9 +263,14 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
"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);
|
||||
const canvas = documentObject.querySelector("#array-canvas");
|
||||
canvas.focus();
|
||||
canvas.dispatchEvent(new windowObject.KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
@@ -172,6 +282,80 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
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", {
|
||||
@@ -190,6 +374,44 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
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();
|
||||
@@ -228,6 +450,20 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
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(
|
||||
@@ -240,6 +476,27 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
() => 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();
|
||||
@@ -248,6 +505,62 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
-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(
|
||||
@@ -266,12 +579,109 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
"invalid import preserves current array",
|
||||
);
|
||||
|
||||
const cameraFrame = document.createElement("iframe");
|
||||
cameraFrame.src = "/apps/acoustic-camera/?integration-test=1&array-builder-handoff=1";
|
||||
cameraFrame.style.cssText =
|
||||
"position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0";
|
||||
document.body.append(cameraFrame);
|
||||
await new Promise((resolve, reject) => {
|
||||
cameraFrame.addEventListener("load", resolve, { once: true });
|
||||
cameraFrame.addEventListener(
|
||||
"error",
|
||||
() => reject(new Error("Camera handoff iframe load failed")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
const cameraWindow = cameraFrame.contentWindow;
|
||||
const cameraDocument = cameraFrame.contentDocument;
|
||||
const cameraDownloads = [];
|
||||
const originalCameraCreate = cameraWindow.URL.createObjectURL.bind(cameraWindow.URL);
|
||||
const originalCameraRevoke = cameraWindow.URL.revokeObjectURL.bind(cameraWindow.URL);
|
||||
const originalCameraClick = cameraWindow.HTMLAnchorElement.prototype.click;
|
||||
cameraWindow.URL.createObjectURL = (blob) => {
|
||||
const url = "blob:array-camera-test-" + String(cameraDownloads.length + 1);
|
||||
cameraDownloads.push({ blob, url, fileName: null });
|
||||
return url;
|
||||
};
|
||||
cameraWindow.URL.revokeObjectURL = () => {};
|
||||
cameraWindow.HTMLAnchorElement.prototype.click = function captureCameraDownload() {
|
||||
const download = cameraDownloads.find((item) => item.url === this.href);
|
||||
if (download) download.fileName = this.download;
|
||||
};
|
||||
try {
|
||||
await waitFor(
|
||||
() => cameraDocument.documentElement.dataset.appReady === "true",
|
||||
"Camera handoff initialization",
|
||||
);
|
||||
const handoffFile = new cameraWindow.File(
|
||||
[JSON.stringify(editedExport)],
|
||||
"builder-handoff.array.json",
|
||||
{ type: "application/json" },
|
||||
);
|
||||
dispatchFiles(cameraWindow, cameraDocument.querySelector("#array-input"), [handoffFile]);
|
||||
await waitFor(
|
||||
() => cameraDocument.querySelector("#array-preset").value === "custom" &&
|
||||
cameraDocument.querySelector("#microphone-count").textContent.includes(
|
||||
String(editedExport.microphones.length),
|
||||
) &&
|
||||
cameraDocument.documentElement.dataset.cameraValid === "true",
|
||||
"Camera custom Array acceptance",
|
||||
);
|
||||
assertEqual(
|
||||
cameraDocument.querySelector("#array-title").textContent,
|
||||
editedExport.name,
|
||||
"Camera shows the Builder array name",
|
||||
);
|
||||
cameraDocument.querySelector("#export-experiment-button").click();
|
||||
await waitFor(
|
||||
() => cameraDownloads.some((item) => item.fileName),
|
||||
"Camera Experiment export after handoff",
|
||||
);
|
||||
const cameraExperiment = JSON.parse(
|
||||
await cameraDownloads.find((item) => item.fileName).blob.text(),
|
||||
);
|
||||
assertEqual(
|
||||
cameraExperiment.arrayId,
|
||||
editedExport.id,
|
||||
"Camera rebinds Experiment to the Builder Array ID",
|
||||
);
|
||||
} finally {
|
||||
cameraWindow.URL.createObjectURL = originalCameraCreate;
|
||||
cameraWindow.URL.revokeObjectURL = originalCameraRevoke;
|
||||
cameraWindow.HTMLAnchorElement.prototype.click = originalCameraClick;
|
||||
cameraFrame.remove();
|
||||
}
|
||||
|
||||
frame.style.width = "390px";
|
||||
await waitFor(
|
||||
() => documentObject.querySelector("#array-builder-layout").dataset.splitPaneActive === "false",
|
||||
"compact Array Builder layout",
|
||||
);
|
||||
await nextPaint(windowObject);
|
||||
const compactWorkspace = documentObject.querySelector("#array-builder-workspace");
|
||||
const compactControls = documentObject.querySelector("#array-builder-controls");
|
||||
const compactWorkspaceRect = compactWorkspace.getBoundingClientRect();
|
||||
const compactControlsRect = compactControls.getBoundingClientRect();
|
||||
assert(
|
||||
Boolean(
|
||||
compactWorkspace.compareDocumentPosition(compactControls) &
|
||||
windowObject.Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
),
|
||||
"compact canvas workspace also precedes settings in DOM order",
|
||||
);
|
||||
assert(
|
||||
compactWorkspaceRect.bottom <= compactControlsRect.top + 1,
|
||||
"compact canvas workspace appears before the long settings form",
|
||||
);
|
||||
assert(
|
||||
documentObject.querySelector(".array-metadata-section").getBoundingClientRect().top <
|
||||
documentObject.querySelector(".aperture-section").getBoundingClientRect().top &&
|
||||
documentObject.querySelector(".aperture-section").getBoundingClientRect().top <
|
||||
documentObject.querySelector(".preset-section").getBoundingClientRect().top &&
|
||||
documentObject.querySelector(".preset-section").getBoundingClientRect().top <
|
||||
documentObject.querySelector(".microphone-section").getBoundingClientRect().top,
|
||||
"compact controls keep the numbered source and visual order",
|
||||
);
|
||||
assert(
|
||||
documentObject.documentElement.scrollWidth <=
|
||||
documentObject.documentElement.clientWidth + 1,
|
||||
@@ -281,6 +691,16 @@ test("Array Builder가 preset·직접 편집·검증·표준 JSON round trip을
|
||||
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;
|
||||
|
||||
@@ -51,6 +51,7 @@ const LAYOUT_CASES = [
|
||||
defaultGridTracks: 2,
|
||||
homePanelWidth: 360,
|
||||
homeGridTracks: 1,
|
||||
compactWorkspaceFirst: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -444,7 +445,12 @@ async function exerciseSplitLayout(definition) {
|
||||
|
||||
const panelRect = mounted.panel.getBoundingClientRect();
|
||||
const workspaceRect = mounted.workspace.getBoundingClientRect();
|
||||
if (definition.panelSide === "start") {
|
||||
if (definition.compactWorkspaceFirst) {
|
||||
assert(
|
||||
panelRect.top >= workspaceRect.bottom - 1,
|
||||
definition.label + " compact workspace precedes the long settings panel",
|
||||
);
|
||||
} else if (definition.panelSide === "start") {
|
||||
assert(
|
||||
workspaceRect.top >= panelRect.bottom - 1,
|
||||
definition.label + " compact workspace follows the panel vertically",
|
||||
|
||||
@@ -28,11 +28,16 @@ Unit과 integration module은 같은 browser runner에서 순서대로 실행됩
|
||||
- `realtime-localizer.test.js`: 기존 CPU DAS와 bit-exact map 동등성, chunk 경계 독립성,
|
||||
bounded ring/result batch, 취소와 geometry·steering cache 연산 절감
|
||||
- `builder.test.js`: Builder scene model, source/asset·trajectory 조작, 오디오 크기와 WAV 사전 검증
|
||||
- `array-builder.test.js`: Array 문서 모델, 격자·선형·둘레·Fermat·seed random 프리셋,
|
||||
최소 간격, 직접 채널 편집, calibration 보존과 geometry 진단
|
||||
- `array-builder-canvas.test.js`: 동일 축척 좌표 왕복, y-up 변환, pointer clamp, hit test,
|
||||
1/2/5 grid와 keyboard nudge
|
||||
- `evaluation.test.js`: local peak와 검출 감도, truth matching, energy/sidelobe/main-lobe 및 비확률적
|
||||
beam-spread covariance ellipse
|
||||
- `camera.test.js`: canonical input hash, workload 추정, 이동 timeline 계획·재생 보간, Result schema 검증
|
||||
- `webgpu.test.js`: WGSL buffer packing, DAS-PHAT phase normalization, bin/point batching,
|
||||
장치 한계와 typed CPU fallback 정책
|
||||
- `split-pane.test.js`: 조절식 설정 패널의 포인터 좌표, 최소·최대 폭과 양쪽 배치
|
||||
|
||||
WebGPU unit test 대부분은 실제 GPU 없이 planner와 binary layout을 검증합니다. 실제 GPU 수치 일치는
|
||||
integration test에서 adapter가 있을 때 수행합니다.
|
||||
|
||||
@@ -86,6 +86,47 @@ test("Array canvas hit test는 반경 안의 가장 가까운 마이크와 채
|
||||
assertEqual(hitTestMicrophone({ x: 0, y: 0 }, arrayDefinition.microphones, viewport, 2), null, "miss");
|
||||
});
|
||||
|
||||
test("Array canvas hit test는 viewport 변환에서 overflow된 마이크를 건너뛴다", () => {
|
||||
const viewport = {
|
||||
plot: { x: 0, y: 0 },
|
||||
xMin: 0,
|
||||
yMax: 100,
|
||||
scale: 2,
|
||||
};
|
||||
const microphones = [
|
||||
{ id: "overflow", position: [Number.MAX_VALUE, 0, 0] },
|
||||
{ id: "normal", position: [5, 5, 0] },
|
||||
];
|
||||
assertEqual(
|
||||
arrayPositionToCanvas(microphones[0].position, viewport).x,
|
||||
Infinity,
|
||||
"fixture overflows after viewport conversion",
|
||||
);
|
||||
assertEqual(
|
||||
hitTestMicrophone({ x: 10, y: 190 }, microphones, viewport, 2)?.id,
|
||||
"normal",
|
||||
"finite microphone remains hittable",
|
||||
);
|
||||
});
|
||||
|
||||
test("Array canvas fit은 극단적인 유한 좌표에서도 aperture fallback viewport를 유지한다", () => {
|
||||
const arrayDefinition = fixture();
|
||||
arrayDefinition.microphones[0].position = [-Number.MAX_VALUE, 0, 0];
|
||||
arrayDefinition.microphones[1].position = [Number.MAX_VALUE, 0, 0];
|
||||
const viewport = fitArrayViewport(800, 420, arrayDefinition);
|
||||
for (const [name, value] of Object.entries({
|
||||
scale: viewport.scale,
|
||||
metersPerPixel: viewport.metersPerPixel,
|
||||
xMin: viewport.xMin,
|
||||
xMax: viewport.xMax,
|
||||
yMin: viewport.yMin,
|
||||
yMax: viewport.yMax,
|
||||
})) {
|
||||
assert(Number.isFinite(value), name + " remains finite");
|
||||
}
|
||||
assert(viewport.xMin < 0 && viewport.xMax > 0, "fallback keeps the aperture around the origin");
|
||||
});
|
||||
|
||||
test("Array canvas grid와 keyboard nudge의 순수 helper가 1/2/5 및 y-up 규칙을 따른다", () => {
|
||||
const candidates = [niceGridStep(0.008), niceGridStep(0.08), niceGridStep(0.8), niceGridStep(8)];
|
||||
for (const step of candidates) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
MAX_MICROPHONES,
|
||||
addMicrophone,
|
||||
arrayGeometryIssues,
|
||||
arrayStatistics,
|
||||
centerMicrophones,
|
||||
clampPositionToAperture,
|
||||
createArrayDefinition,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
generatePresetMicrophones,
|
||||
inferApertureShape,
|
||||
moveMicrophone,
|
||||
nextMicrophoneId,
|
||||
positionInsideAperture,
|
||||
removeMicrophone,
|
||||
replaceMicrophonesWithPreset,
|
||||
@@ -110,6 +112,45 @@ test("seed random preset은 재현되고 최소 간격과 형상 경계를 지
|
||||
}
|
||||
});
|
||||
|
||||
test("1,024채널 random preset은 공간 index로 고유 좌표를 빠르게 생성한다", () => {
|
||||
const startedAt = performance.now();
|
||||
const microphones = generatePresetMicrophones({
|
||||
type: "random",
|
||||
aperture: { width: 0.45, height: 0.45 },
|
||||
shape: "rectangle",
|
||||
count: 1024,
|
||||
seed: 20260828,
|
||||
minimumSpacingM: 0.002,
|
||||
});
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
assertEqual(microphones.length, 1024, "maximum random channel count");
|
||||
assertEqual(
|
||||
new Set(microphones.map((microphone) => microphone.position.join(","))).size,
|
||||
1024,
|
||||
"maximum random positions are unique",
|
||||
);
|
||||
assert(elapsedMs < 3000, "maximum random generation stays interactive: " + elapsedMs + " ms");
|
||||
|
||||
const failedAt = performance.now();
|
||||
assertThrows(
|
||||
() => generatePresetMicrophones({
|
||||
type: "random",
|
||||
aperture: { width: 0.45, height: 0.45 },
|
||||
shape: "rectangle",
|
||||
count: 1024,
|
||||
seed: 7,
|
||||
minimumSpacingM: 0.03,
|
||||
}),
|
||||
"배치할 수 없습니다",
|
||||
"dense maximum random failure",
|
||||
);
|
||||
const failedElapsedMs = performance.now() - failedAt;
|
||||
assert(
|
||||
failedElapsedMs < 3000,
|
||||
"impossible maximum random layout fails within a bound: " + failedElapsedMs + " ms",
|
||||
);
|
||||
});
|
||||
|
||||
test("불가능한 random 조건과 최대 채널 초과는 기존 배열을 보존한다", () => {
|
||||
const arrayDefinition = createArrayDefinition();
|
||||
const before = JSON.stringify(arrayDefinition.microphones);
|
||||
@@ -185,7 +226,7 @@ test("좌표 clamp, 중심 정렬과 aperture 맞춤이 명시적으로 동작
|
||||
assertNear(arrayDefinition.aperture.height, 0.021, 1e-12, "fitted height");
|
||||
});
|
||||
|
||||
test("설명과 좌표에서 원형 생성 형상을 합리적으로 추론한다", () => {
|
||||
test("문서의 명시적 형상 단서만으로 타원 생성 경계를 추론한다", () => {
|
||||
const spiral = createArrayDefinition({
|
||||
description: "Fermat spiral on circular aperture",
|
||||
presetType: "spiral",
|
||||
@@ -194,4 +235,42 @@ test("설명과 좌표에서 원형 생성 형상을 합리적으로 추론한
|
||||
assertEqual(inferApertureShape(spiral), "ellipse", "description inference");
|
||||
const grid = createArrayDefinition();
|
||||
assertEqual(inferApertureShape(grid), "rectangle", "grid inference");
|
||||
grid.description = "Spring-mounted rectangular array";
|
||||
assertEqual(inferApertureShape(grid), "rectangle", "word boundary avoids spring false positive");
|
||||
});
|
||||
|
||||
test("매우 큰 유한 좌표에서도 배열 중심 통계가 유한하게 유지된다", () => {
|
||||
const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1 });
|
||||
arrayDefinition.microphones[0].position = [Number.MAX_VALUE, Number.MAX_VALUE, 0];
|
||||
arrayDefinition.microphones[1].position = [Number.MAX_VALUE, -Number.MAX_VALUE, 0];
|
||||
const statistics = arrayStatistics(arrayDefinition);
|
||||
assert(Number.isFinite(statistics.centroid[0]), "large x centroid remains finite");
|
||||
assert(Number.isFinite(statistics.centroid[1]), "large y centroid remains finite");
|
||||
assertEqual(statistics.centroid[0], Number.MAX_VALUE, "large x centroid");
|
||||
assertEqual(statistics.centroid[1], 0, "balanced y centroid");
|
||||
});
|
||||
|
||||
test("큰 숫자 suffix ID도 마이크 추가를 멈추지 않고 정확히 증가시킨다", () => {
|
||||
const arrayDefinition = createArrayDefinition({ columns: 1, rows: 1 });
|
||||
arrayDefinition.microphones[0].id = "mic-9007199254740992";
|
||||
assertEqual(
|
||||
nextMicrophoneId(arrayDefinition),
|
||||
"mic-9007199254740993",
|
||||
"BigInt suffix increment",
|
||||
);
|
||||
assertEqual(
|
||||
addMicrophone(arrayDefinition, [0.01, 0, 0]).id,
|
||||
"mic-9007199254740993",
|
||||
"manual add uses the safe ID",
|
||||
);
|
||||
});
|
||||
|
||||
test("고정밀 import 좌표는 실제 값이 다르면 중복으로 오인하지 않는다", () => {
|
||||
const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1 });
|
||||
arrayDefinition.microphones[0].position = [0.0000000001, 0, 0];
|
||||
arrayDefinition.microphones[1].position = [0.0000000002, 0, 0];
|
||||
assert(
|
||||
!arrayGeometryIssues(arrayDefinition).some((issue) => issue.severity === "error"),
|
||||
"distinct high-precision positions",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user