Files
micarrsim/tests/unit/contracts.test.js
T

484 lines
21 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 {
validateArrayDefinition,
validateExperiment,
validateResult,
validateScene,
} from "/packages/contracts/validation.js";
async function loadJson(path) {
const response = await fetch(path);
assert(response.ok, `${path} load failed`);
return response.json();
}
const RECTANGULAR_ARRAY_EXAMPLES = [
{ id: "rectangular-2x2-450mm", microphoneCount: 4, columns: 2, rows: 2, width: 0.45, height: 0.45 },
{ id: "rectangular-4x3-450mm", microphoneCount: 12, columns: 4, rows: 3, width: 0.45, height: 0.45 },
{ id: "rectangular-8x6-450mm", microphoneCount: 48, columns: 8, rows: 6, width: 0.45, height: 0.45 },
{ id: "rectangular-16x12", microphoneCount: 192, columns: 16, rows: 12, width: 0.45, height: 0.45 },
{ id: "rectangular-24x16-450mm", microphoneCount: 384, columns: 24, rows: 16, width: 0.45, height: 0.45 },
{ id: "rectangular-16x12-compact", microphoneCount: 192, columns: 16, rows: 12, width: 0.12, height: 0.12 },
{ id: "rectangular-16x12-large", microphoneCount: 192, columns: 16, rows: 12, width: 1.2, height: 1.2 },
];
test("moving-tone sample Scene and Experiment satisfy their linked contracts", async () => {
const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
loadJson("/assets/sample-scenes/moving-tone.scene.json"),
loadJson("/assets/sample-scenes/moving-tone.experiment.json"),
]);
const sceneIssues = await validateScene(scene);
const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene });
assertEqual(sceneIssues.length, 0, `moving Scene issues: ${JSON.stringify(sceneIssues)}`);
assertEqual(experimentIssues.length, 0, `moving Experiment issues: ${JSON.stringify(experimentIssues)}`);
assertEqual(experiment.sceneId, scene.id, "moving example linkage");
assert(
scene.sources.some((source) => Array.isArray(source.trajectory) && source.trajectory.length >= 2),
"moving example contains a usable trajectory",
);
});
test("192채널 기준 배열이 계약을 통과한다", async () => {
const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json");
assertEqual(arrayDefinition.microphones.length, 192, "microphone count");
assertEqual(arrayDefinition.name, "16x12 rectangular baseline", "fixture encoding");
assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "array issues");
});
test("rectangular array examples cover low-to-high channel counts and compact-to-wide apertures", async () => {
const arrays = await Promise.all(RECTANGULAR_ARRAY_EXAMPLES.map(({ id }) =>
loadJson(`/assets/array-geometries/${id}.array.json`)));
for (let index = 0; index < arrays.length; index += 1) {
const arrayDefinition = arrays[index];
const expected = RECTANGULAR_ARRAY_EXAMPLES[index];
assertEqual(arrayDefinition.id, expected.id, `${expected.id} fixture id`);
assertEqual(arrayDefinition.microphones.length, expected.microphoneCount, `${expected.id} channel count`);
assertEqual(arrayDefinition.aperture.width, expected.width, `${expected.id} aperture width`);
assertEqual(arrayDefinition.aperture.height, expected.height, `${expected.id} aperture height`);
assertEqual(
(await validateArrayDefinition(arrayDefinition)).length,
0,
`${expected.id} contract issues`,
);
assertEqual(
new Set(arrayDefinition.microphones.map(({ id }) => id)).size,
expected.microphoneCount,
`${expected.id} microphone ids are unique`,
);
assertEqual(
new Set(arrayDefinition.microphones.map(({ position }) => position.join(","))).size,
expected.microphoneCount,
`${expected.id} positions are unique`,
);
const xCoordinates = arrayDefinition.microphones.map(({ position }) => position[0]);
const yCoordinates = arrayDefinition.microphones.map(({ position }) => position[1]);
assert(
arrayDefinition.microphones.every(({ position }) => position[2] === 0),
`${expected.id} microphones stay on the z=0 plane`,
);
assertEqual(new Set(xCoordinates).size, expected.columns, `${expected.id} column count`);
assertEqual(new Set(yCoordinates).size, expected.rows, `${expected.id} row count`);
const uniqueX = [...new Set(xCoordinates)].sort((left, right) => left - right);
const uniqueY = [...new Set(yCoordinates)].sort((left, right) => left - right);
const expectedXPitch = expected.width / (expected.columns - 1);
const expectedYPitch = expected.height / (expected.rows - 1);
assert(
uniqueX.slice(1).every((value, position) =>
Math.abs(value - uniqueX[position] - expectedXPitch) < 1.1e-6),
`${expected.id} uniform x pitch`,
);
assert(
uniqueY.slice(1).every((value, position) =>
Math.abs(value - uniqueY[position] - expectedYPitch) < 1.1e-6),
`${expected.id} uniform y pitch`,
);
assert(Math.abs(Math.min(...xCoordinates) + expected.width / 2) < 1e-6, `${expected.id} x minimum`);
assert(Math.abs(Math.max(...xCoordinates) - expected.width / 2) < 1e-6, `${expected.id} x maximum`);
assert(Math.abs(Math.min(...yCoordinates) + expected.height / 2) < 1e-6, `${expected.id} y minimum`);
assert(Math.abs(Math.max(...yCoordinates) - expected.height / 2) < 1e-6, `${expected.id} y maximum`);
}
const channelSeries = arrays.slice(0, 5);
assert(
channelSeries.every(({ aperture }) => aperture.width === 0.45 && aperture.height === 0.45),
"channel-count series keeps the 0.45 m aperture fixed",
);
assertEqual(
channelSeries.map(({ microphones }) => microphones.length).join(","),
"4,12,48,192,384",
"channel-count series",
);
const apertureSeries = [
arrays.find(({ id }) => id === "rectangular-16x12-compact"),
arrays.find(({ id }) => id === "rectangular-16x12"),
arrays.find(({ id }) => id === "rectangular-16x12-large"),
];
assert(
apertureSeries.every(({ microphones }) => microphones.length === 192),
"aperture series keeps 192 channels fixed",
);
assertEqual(
apertureSeries.map(({ aperture }) => aperture.width).join(","),
"0.12,0.45,1.2",
"compact-to-large aperture series",
);
assertEqual(
Math.max(...arrays.map(({ microphones }) => microphones.length)),
384,
"high-count example",
);
});
test("192채널 Fermat spiral 비교 배열이 계약과 원형 aperture를 만족한다", async () => {
const arrayDefinition = await loadJson("/assets/array-geometries/fermat-spiral-192.array.json");
assertEqual(arrayDefinition.microphones.length, 192, "spiral microphone count");
assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "spiral array issues");
const maximumRadius = Math.max(
...arrayDefinition.microphones.map(({ position }) => Math.hypot(position[0], position[1])),
);
assert(maximumRadius <= 0.225001, `spiral aperture radius: ${maximumRadius}`);
assertEqual(
new Set(arrayDefinition.microphones.map(({ position }) => position.join(","))).size,
192,
"spiral positions are unique",
);
});
test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () => {
const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
assertEqual((await validateScene(scene)).length, 0, "scene issues");
assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues");
});
test("세 위치 추정 알고리즘과 backend 제약을 계약이 검증한다", async () => {
const [arrayDefinition, scene, baseline] = await Promise.all([
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
for (const algorithm of ["das", "das-phat", "gcc-phat"]) {
const experiment = structuredClone(baseline);
experiment.beamforming.algorithm = algorithm;
experiment.beamforming.backend = algorithm === "gcc-phat" ? "cpu" : "auto";
assertEqual(
(await validateExperiment(experiment, { arrayDefinition, scene })).length,
0,
`${algorithm} experiment issues`,
);
}
const incompatible = structuredClone(baseline);
incompatible.beamforming.algorithm = "gcc-phat";
incompatible.beamforming.backend = "webgpu";
let issues = await validateExperiment(incompatible, { arrayDefinition, scene });
assert(
issues.some(({ path }) => path === "$.beamforming.backend"),
"GCC-PHAT rejects the WebGPU backend",
);
incompatible.beamforming.backend = "cpu";
issues = await validateExperiment(incompatible, {
arrayDefinition: { ...arrayDefinition, microphones: arrayDefinition.microphones.slice(0, 1) },
scene,
});
assert(
issues.some(({ path }) => path === "$.beamforming.algorithm"),
"GCC-PHAT requires two microphones",
);
incompatible.beamforming.algorithm = "music";
issues = await validateExperiment(incompatible, { arrayDefinition, scene });
assert(
issues.some(({ path }) => path === "$.beamforming.algorithm"),
"unsupported algorithms are rejected",
);
});
test("shoebox 음향 환경의 room과 occluder 경계를 검증한다", async () => {
const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json");
scene.acoustics = {
model: "shoebox",
room: { min: [-2, -2, -1], max: [2, 2, 3] },
reflections: { order: 1, wallGainDb: -8, scattering: 0.2 },
diffuseReverb: { enabled: true, rt60Seconds: 0.35, gainDb: -20, tapCount: 12 },
occluders: [{
id: "screen",
min: [-0.2, -0.2, 0.8],
max: [0.2, 0.2, 1],
transmissionDb: -18,
diffractionGainDb: -12,
}],
};
assertEqual((await validateScene(scene)).length, 0, "valid shoebox scene");
scene.acoustics.room.max[0] = scene.acoustics.room.min[0];
let issues = await validateScene(scene);
assert(issues.some(({ path }) => path === "$.acoustics.room"), "unordered room bounds");
scene.acoustics.room.max[0] = 2;
scene.acoustics.occluders[0].max[2] = 4;
issues = await validateScene(scene);
assert(
issues.some(({ path, message }) => path.includes("occluders[0]") && message.includes("room")),
"occluder outside room",
);
scene.acoustics = {
model: "free-field",
room: { min: [-2, -2, -1], max: [2, 2, 3] },
};
issues = await validateScene(scene);
assert(issues.some(({ path }) => path === "$.acoustics"), "unused free-field settings");
});
test("Experiment 피크 검출 임계값 비율은 재현 가능하고 유효한 범위여야 한다", async () => {
const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
assertEqual(experiment.evaluation.detectionThresholdRatio, 0.25, "sample threshold ratio");
assertEqual(
(await validateExperiment(experiment, { arrayDefinition, scene })).length,
0,
"0.25 threshold issues",
);
experiment.evaluation.detectionThresholdRatio = 0;
let issues = await validateExperiment(experiment, { arrayDefinition, scene });
assert(
issues.some(({ path, message }) =>
path === "$.evaluation.detectionThresholdRatio" && message.includes("0보다 크고 1 이하")),
"zero threshold is rejected with actionable guidance",
);
experiment.evaluation.detectionThresholdRatio = 1.01;
issues = await validateExperiment(experiment, { arrayDefinition, scene });
assert(
issues.some(({ path }) => path === "$.evaluation.detectionThresholdRatio"),
"threshold above one is rejected",
);
});
test("이동 음원 trajectory의 keyframe 순서와 장면 시간 범위를 검증한다", async () => {
const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json");
scene.sources[0].trajectory = [
{ time: 0, position: [-0.2, 0, 2] },
{ time: 0.125, position: [0, 0.1, 2] },
{ time: 0.25, position: [0.2, 0, 2] },
];
assertEqual((await validateScene(scene)).length, 0, "valid trajectory issues");
scene.sources[0].trajectory[2].time = 0.125;
let issues = await validateScene(scene);
assert(issues.some(({ path }) => path === "$.sources[0].trajectory[2].time"), "strictly ordered times");
scene.sources[0].trajectory[2].time = 0.3;
issues = await validateScene(scene);
assert(issues.some(({ path }) => path === "$.sources[0].trajectory[2].time"), "scene duration bound");
scene.sources[0].trajectory = [{ time: 0, position: [0, 0, 2] }];
issues = await validateScene(scene);
assert(issues.some(({ path }) => path === "$.sources[0].trajectory"), "two-keyframe minimum");
});
test("중복 마이크 id를 거부한다", async () => {
const arrayDefinition = {
schemaVersion: "1.0.0", kind: "micarrsim.array", id: "duplicate-test", name: "duplicate test", units: "m",
coordinateSystem: { handedness: "right", xAxis: "right", yAxis: "up", zAxis: "toward-scene" },
aperture: { width: 1, height: 1 },
microphones: [
{ id: "mic-1", position: [0, 0, 0] },
{ id: "mic-1", position: [0.1, 0, 0] },
],
};
assert((await validateArrayDefinition(arrayDefinition)).some(({ message }) => message.includes("중복")));
});
test("마이크 주파수 응답점은 안전한 범위에서 주파수 오름차순이어야 한다", async () => {
const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json");
arrayDefinition.microphones[0].frequencyResponse = [
{ frequencyHz: 20, gainDb: -3 },
{ frequencyHz: 1000, gainDb: 1.5, phaseDeg: 12 },
{ frequencyHz: 20_000, gainDb: -2 },
];
assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "valid response curve");
arrayDefinition.microphones[0].frequencyResponse[2].frequencyHz = 1000;
let issues = await validateArrayDefinition(arrayDefinition);
assert(
issues.some(({ path, message }) =>
path === "$.microphones[0].frequencyResponse[2].frequencyHz" &&
message.includes("엄격히 증가")),
"duplicate frequency is rejected with sorting guidance",
);
arrayDefinition.microphones[0].frequencyResponse[2].frequencyHz = 1_000_001;
issues = await validateArrayDefinition(arrayDefinition);
assert(
issues.some(({ path }) => path === "$.microphones[0].frequencyResponse[2].frequencyHz"),
"globally bounded frequency is enforced without a Scene Nyquist dependency",
);
});
test("Nyquist를 넘는 tone을 거부한다", async () => {
const scene = {
schemaVersion: "1.0.0", kind: "micarrsim.scene", id: "nyquist-scene", name: "Nyquist test", units: "m",
sampleRate: 8000, duration: 1, speedOfSound: 343, seed: 1, mix: { normalization: "none", targetPeak: 0.95 },
sources: [{
id: "source", name: "source", position: [0, 0, 1], gainDb: 0, startTime: 0, duration: 1,
signal: { type: "tone", frequency: 5000 },
}],
};
assert((await validateScene(scene)).some(({ path }) => path.endsWith("frequency")));
});
test("NaN 좌표를 거부한다", async () => {
const arrayDefinition = {
schemaVersion: "1.0.0", kind: "micarrsim.array", id: "nan-test", name: "NaN test", units: "m",
coordinateSystem: { handedness: "right", xAxis: "right", yAxis: "up", zAxis: "toward-scene" },
aperture: { width: 1, height: 1 }, microphones: [{ id: "mic-1", position: [Number.NaN, 0, 0] }],
};
assert((await validateArrayDefinition(arrayDefinition)).some(({ path }) => path.endsWith("[0]")));
});
test("JSON Schema의 누락 필드와 범위·알 수 없는 필드를 거부한다", async () => {
const [arrayDefinition, scene, experiment] = await Promise.all([
loadJson("/assets/array-geometries/rectangular-16x12.array.json"),
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
arrayDefinition.microphones[0].phaseOffsetDeg = 999;
scene.unexpected = true;
delete experiment.beamforming.normalization;
experiment.beamforming.dynamicRangeDb = 4;
experiment.beamforming.compensateFrequencyResponse = "false";
experiment.beamforming.maxCompensationDb = 121;
assert((await validateArrayDefinition(arrayDefinition)).some(({ path }) => path.endsWith("phaseOffsetDeg")));
assert((await validateScene(scene)).some(({ path }) => path === "$.unexpected"));
const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene });
assert(experimentIssues.some(({ path }) => path.endsWith("normalization")));
assert(experimentIssues.some(({ path }) => path.endsWith("dynamicRangeDb")));
assert(experimentIssues.some(({ path }) => path.endsWith("compensateFrequencyResponse")));
assert(experimentIssues.some(({ path }) => path.endsWith("maxCompensationDb")));
});
test("image/audio asset 종류와 안전하지 않은 경로를 거부한다", async () => {
const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json");
scene.assets = [
{ id: "audio", kind: "audio", fileName: "../outside.wav", mimeType: "audio/wav" },
{ id: "image", kind: "image", fileName: "machine.png", mimeType: "image/png" },
];
scene.image = {
assetId: "audio",
widthPixels: 600,
heightPixels: 400,
plane: { z: 2, xMin: -0.6, xMax: 0.6, yMin: -0.4, yMax: 0.4 },
};
scene.sources[0].signal = { type: "asset", assetId: "image" };
const issues = await validateScene(scene);
assert(issues.some(({ path }) => path.endsWith("fileName")));
assertEqual(issues.filter(({ message }) => message.includes("asset을 참조")).length, 2);
});
test("복수 음원 대응 result map 계약과 shape 관계를 검증한다", async () => {
const [scene, experiment] = await Promise.all([
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
const result = {
schemaVersion: "1.0.0",
kind: "micarrsim.result",
experimentId: experiment.id,
arrayId: experiment.arrayId,
sceneId: experiment.sceneId,
createdAt: "2026-08-28T00:00:00.000Z",
backend: "cpu",
algorithm: "das",
inputHash: "a".repeat(64),
signalStats: { preNormalizationPeak: 0.5, appliedScale: 1, clippedSampleCount: 0 },
timingMs: { total: 10, signalGeneration: 3, beamforming: 7 },
bands: [{
id: "tone-1k",
color: "#65f2bf",
rawMap: { rows: 50, columns: 50, scale: "power", values: Array(2500).fill(0) },
peaks: [{
sourceId: "source-1",
position: [0.08, 0.04, 2],
value: 1,
localizationErrorM: 0,
beamSpread: {
calibrated: false,
method: "connected-half-power-power-weighted-covariance",
halfPowerRatio: 0.5,
thresholdValue: 0.5,
pixelCount: 1,
centroid: [0.08, 0.04, 2],
covarianceM2: { xx: 0, xy: 0, yy: 0 },
ellipse: {
axisScale: "rms",
angleReference: "counterclockwise-from-positive-x",
majorRmsRadiusM: 0,
minorRmsRadiusM: 0,
angleDeg: 0,
},
},
}],
metrics: { energyInsideTruthRatio: 1, precision: 1, recall: 1 },
}],
};
assertEqual((await validateResult(result, { experiment, scene })).length, 0, "valid result issues");
experiment.beamforming.algorithm = "das-phat";
result.algorithm = "das-phat";
result.bands[0].rawMap.scale = "score";
result.bands[0].peaks[0].beamSpread.method =
"connected-half-maximum-score-weighted-covariance";
assertEqual(
(await validateResult(result, { experiment, scene })).length,
0,
"phase-weighted score result issues",
);
result.bands[0].rawMap.scale = "power";
let issues = await validateResult(result, { experiment, scene });
assert(
issues.some(({ path }) => path.endsWith("rawMap.scale")),
"algorithm and raw map scale must agree",
);
result.bands[0].rawMap.scale = "score";
result.bands[0].peaks[0].beamSpread.method =
"connected-half-power-power-weighted-covariance";
issues = await validateResult(result, { experiment, scene });
assert(
issues.some(({ path }) => path.endsWith("beamSpread.method")),
"score map and beam spread method must agree",
);
result.bands[0].peaks[0].beamSpread.method =
"connected-half-maximum-score-weighted-covariance";
result.algorithm = "das";
issues = await validateResult(result, { experiment, scene });
assert(issues.some(({ path }) => path === "$.algorithm"), "result algorithm must match its experiment");
experiment.beamforming.algorithm = "das";
result.bands[0].rawMap.scale = "power";
result.bands[0].peaks[0].beamSpread.method =
"connected-half-power-power-weighted-covariance";
result.bands[0].rawMap.values.pop();
result.bands[0].rawMap.scale = "normalized";
result.bands[0].peaks[0].sourceId = "missing-source";
issues = await validateResult(result, { experiment, scene });
assert(issues.some(({ message }) => message.includes("rows × columns")));
assert(issues.some(({ path }) => path.endsWith("rawMap.scale")));
assert(issues.some(({ path }) => path.endsWith("sourceId")));
});