Files
micarrsim/tests/unit/array-builder.test.js
T

198 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
MAX_MICROPHONES,
addMicrophone,
arrayGeometryIssues,
centerMicrophones,
clampPositionToAperture,
createArrayDefinition,
fitApertureToMicrophones,
generatePresetMicrophones,
inferApertureShape,
moveMicrophone,
positionInsideAperture,
removeMicrophone,
replaceMicrophonesWithPreset,
updateMicrophone,
} from "/apps/array-builder/model.js";
import { validateArrayDefinition } from "/packages/contracts/validation.js";
function assertNear(actual, expected, tolerance, label) {
assert(Math.abs(actual - expected) <= tolerance, label + ": expected " + expected + ", received " + actual);
}
function assertThrows(callback, text, label) {
let error;
try {
callback();
} catch (caught) {
error = caught;
}
assert(error instanceof Error, label + " should throw");
assert(error.message.includes(text), label + ": " + error.message);
}
test("Array Builder 기본 4x3 문서가 표준 Array 계약과 row-major 좌표를 만족한다", async () => {
const arrayDefinition = createArrayDefinition();
assertEqual(arrayDefinition.microphones.length, 12, "default microphone count");
assertEqual(arrayDefinition.microphones[0].id, "mic-001", "first channel id");
assertEqual(arrayDefinition.microphones[11].id, "mic-012", "last channel id");
assertEqual(arrayDefinition.microphones[0].position.join(","), "-0.225,-0.225,0", "bottom-left first");
assertEqual(arrayDefinition.microphones[3].position.join(","), "0.225,-0.225,0", "row advances left to right");
assertEqual(arrayDefinition.microphones[11].position.join(","), "0.225,0.225,0", "top-right last");
assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "default array schema");
});
test("격자·선형·링·Fermat preset이 개수와 생성 경계를 지킨다", () => {
const aperture = { width: 0.4, height: 0.2 };
const grid = generatePresetMicrophones({ type: "grid", aperture, columns: 3, rows: 2 });
assertEqual(grid.length, 6, "grid count");
assertEqual(grid[0].position.join(","), "-0.2,-0.1,0", "grid first");
assertEqual(grid[5].position.join(","), "0.2,0.1,0", "grid last");
for (const type of ["linear", "ring", "spiral"]) {
const microphones = generatePresetMicrophones({
type,
aperture,
shape: "ellipse",
count: 17,
axis: "diagonal",
});
assertEqual(microphones.length, 17, type + " count");
assert(
microphones.every((microphone) => positionInsideAperture(microphone.position, aperture, "ellipse")),
type + " ellipse bounds",
);
}
});
test("타원 격자는 요청한 rows x columns 수를 유지하며 경계 안에 배치된다", () => {
const aperture = { width: 0.45, height: 0.3 };
const microphones = generatePresetMicrophones({
type: "grid",
aperture,
shape: "ellipse",
columns: 7,
rows: 5,
});
assertEqual(microphones.length, 35, "ellipse grid count");
assert(
microphones.every((microphone) => positionInsideAperture(microphone.position, aperture, "ellipse")),
"ellipse-adapted grid bounds",
);
});
test("seed random preset은 재현되고 최소 간격과 형상 경계를 지킨다", () => {
const options = {
type: "random",
aperture: { width: 0.5, height: 0.35 },
shape: "ellipse",
count: 24,
seed: 12345,
minimumSpacingM: 0.01,
};
const first = generatePresetMicrophones(options);
const repeated = generatePresetMicrophones(options);
const different = generatePresetMicrophones({ ...options, seed: 54321 });
assertEqual(JSON.stringify(first), JSON.stringify(repeated), "same seed layout");
assert(JSON.stringify(first) !== JSON.stringify(different), "different seed layout");
assert(
first.every((microphone) => positionInsideAperture(microphone.position, options.aperture, "ellipse")),
"random ellipse bounds",
);
for (let left = 0; left < first.length; left += 1) {
for (let right = left + 1; right < first.length; right += 1) {
const distance = Math.hypot(
first[left].position[0] - first[right].position[0],
first[left].position[1] - first[right].position[1],
);
assert(distance >= 0.01 - 1e-9, "random minimum spacing");
}
}
});
test("불가능한 random 조건과 최대 채널 초과는 기존 배열을 보존한다", () => {
const arrayDefinition = createArrayDefinition();
const before = JSON.stringify(arrayDefinition.microphones);
assertThrows(
() => replaceMicrophonesWithPreset(arrayDefinition, {
type: "random",
aperture: arrayDefinition.aperture,
count: 100,
minimumSpacingM: 0.4,
seed: 1,
}),
"배치할 수 없습니다",
"impossible random spacing",
);
assertEqual(JSON.stringify(arrayDefinition.microphones), before, "failed generation is atomic");
assertThrows(
() => generatePresetMicrophones({
type: "linear",
aperture: arrayDefinition.aperture,
count: MAX_MICROPHONES + 1,
}),
"1–1024",
"microphone maximum",
);
});
test("수동 add/update/reorder/remove가 ID와 calibration을 보존한다", () => {
const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1 });
arrayDefinition.microphones[0].sensitivityDb = -1.5;
arrayDefinition.microphones[0].phaseOffsetDeg = 8;
arrayDefinition.microphones[0].frequencyResponse = [
{ frequencyHz: 100, gainDb: -2 },
{ frequencyHz: 1000, gainDb: 0.5, phaseDeg: 2 },
];
updateMicrophone(arrayDefinition, "mic-001", { position: [-0.1, 0.02, 0.003] });
assertEqual(arrayDefinition.microphones[0].sensitivityDb, -1.5, "sensitivity preserved");
assertEqual(arrayDefinition.microphones[0].frequencyResponse.length, 2, "response preserved");
const added = addMicrophone(arrayDefinition, [0, 0, 0]);
assertEqual(added.id, "mic-003", "stable next id");
assert(moveMicrophone(arrayDefinition, added.id, -1), "channel reordered");
assertEqual(arrayDefinition.microphones[1].id, "mic-003", "new channel order");
assert(removeMicrophone(arrayDefinition, "mic-002"), "channel removed");
assertEqual(addMicrophone(arrayDefinition, [0.05, 0, 0]).id, "mic-004", "deleted suffix is not reused");
while (arrayDefinition.microphones.length > 1) {
removeMicrophone(arrayDefinition, arrayDefinition.microphones.at(-1).id);
}
assertEqual(removeMicrophone(arrayDefinition, arrayDefinition.microphones[0].id), false, "last microphone retained");
});
test("Builder geometry 진단은 중복을 오류, 생성 경계 초과를 경고로 구분한다", () => {
const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1 });
arrayDefinition.microphones[0].position[0] = 0.4;
arrayDefinition.microphones[1].position = [...arrayDefinition.microphones[0].position];
const issues = arrayGeometryIssues(arrayDefinition, { shape: "ellipse" });
assert(issues.some((issue) => issue.severity === "error" && issue.message.includes("중복")), "duplicate error");
assert(issues.some((issue) => issue.severity === "warning" && issue.message.includes("경계 밖")), "bounds warning");
});
test("좌표 clamp, 중심 정렬과 aperture 맞춤이 명시적으로 동작한다", () => {
const aperture = { width: 0.4, height: 0.2 };
const clamped = clampPositionToAperture([0.4, 0.2, 0.03], aperture, "ellipse");
assert(positionInsideAperture(clamped, aperture, "ellipse"), "ellipse clamp");
assertNear(clamped[2], 0.03, 1e-12, "z remains editable");
const arrayDefinition = createArrayDefinition({ columns: 2, rows: 1, width: 0.2, height: 0.1 });
arrayDefinition.microphones[0].position = [0.2, 0.1, 0.02];
arrayDefinition.microphones[1].position = [0.4, 0.1, 0.04];
centerMicrophones(arrayDefinition);
assertEqual(arrayDefinition.microphones[0].position.join(","), "-0.1,0,-0.01", "center first");
assertEqual(arrayDefinition.microphones[1].position.join(","), "0.1,0,0.01", "center second");
fitApertureToMicrophones(arrayDefinition, { shape: "rectangle", paddingM: 0.01 });
assertNear(arrayDefinition.aperture.width, 0.22, 1e-12, "fitted width");
assertNear(arrayDefinition.aperture.height, 0.021, 1e-12, "fitted height");
});
test("설명과 좌표에서 원형 생성 형상을 합리적으로 추론한다", () => {
const spiral = createArrayDefinition({
description: "Fermat spiral on circular aperture",
presetType: "spiral",
count: 32,
});
assertEqual(inferApertureShape(spiral), "ellipse", "description inference");
const grid = createArrayDefinition();
assertEqual(inferApertureShape(grid), "rectangle", "grid inference");
});