Files
micarrsim/tests/unit/contracts.test.js
T
2026-08-28 16:30:01 +09:00

217 lines
10 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();
}
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("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("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("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;
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")));
});
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",
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 }],
metrics: { energyInsideTruthRatio: 1, precision: 1, recall: 1 },
}],
};
assertEqual((await validateResult(result, { experiment, scene })).length, 0, "valid result issues");
result.bands[0].rawMap.values.pop();
result.bands[0].rawMap.scale = "normalized";
result.bands[0].peaks[0].sourceId = "missing-source";
const 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")));
});