fix: harden array builder editing workflows
This commit is contained in:
@@ -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