198 lines
8.4 KiB
JavaScript
198 lines
8.4 KiB
JavaScript
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");
|
||
});
|