fix: harden array builder editing workflows

This commit is contained in:
2026-08-28 23:10:41 +09:00
parent 3714146df9
commit 122b6cf7be
14 changed files with 1148 additions and 165 deletions
+5
View File
@@ -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가 있을 때 수행합니다.
+41
View File
@@ -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) {
+80 -1
View File
@@ -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",
);
});