fix: harden simulator data contracts
This commit is contained in:
+19
-1
@@ -8,7 +8,25 @@ globalThis.assertEqual = (actual, expected, message = "values differ") => {
|
||||
if (!Object.is(actual, expected)) throw new Error(`${message}: expected ${expected}, received ${actual}`);
|
||||
};
|
||||
|
||||
await import("/tests/unit/contracts.test.js");
|
||||
try {
|
||||
await import("/tests/unit/contracts.test.js");
|
||||
} catch (error) {
|
||||
registeredTests.push({
|
||||
name: "테스트 모듈을 불러온다",
|
||||
callback: () => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (registeredTests.length === 0) {
|
||||
registeredTests.push({
|
||||
name: "하나 이상의 테스트가 등록된다",
|
||||
callback: () => {
|
||||
throw new Error("등록된 테스트가 없습니다.");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const resultList = document.querySelector("#test-results");
|
||||
const summary = document.querySelector("#test-summary");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
validateArrayDefinition,
|
||||
validateExperiment,
|
||||
validateResult,
|
||||
validateScene,
|
||||
} from "/packages/contracts/validation.js";
|
||||
|
||||
@@ -13,7 +14,8 @@ async function loadJson(path) {
|
||||
test("192채널 기준 배열이 계약을 통과한다", async () => {
|
||||
const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json");
|
||||
assertEqual(arrayDefinition.microphones.length, 192, "microphone count");
|
||||
assertEqual(validateArrayDefinition(arrayDefinition).length, 0, "array issues");
|
||||
assertEqual(arrayDefinition.name, "16x12 rectangular baseline", "fixture encoding");
|
||||
assertEqual((await validateArrayDefinition(arrayDefinition)).length, 0, "array issues");
|
||||
});
|
||||
|
||||
test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () => {
|
||||
@@ -22,11 +24,11 @@ test("단일 tone 장면과 실험이 관계 검증을 통과한다", async () =
|
||||
loadJson("/assets/sample-scenes/single-tone.scene.json"),
|
||||
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
||||
]);
|
||||
assertEqual(validateScene(scene).length, 0, "scene issues");
|
||||
assertEqual(validateExperiment(experiment, { arrayDefinition, scene }).length, 0, "experiment issues");
|
||||
assertEqual((await validateScene(scene)).length, 0, "scene issues");
|
||||
assertEqual((await validateExperiment(experiment, { arrayDefinition, scene })).length, 0, "experiment issues");
|
||||
});
|
||||
|
||||
test("중복 마이크 id를 거부한다", () => {
|
||||
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" },
|
||||
@@ -36,26 +38,95 @@ test("중복 마이크 id를 거부한다", () => {
|
||||
{ id: "mic-1", position: [0.1, 0, 0] },
|
||||
],
|
||||
};
|
||||
assert(validateArrayDefinition(arrayDefinition).some(({ message }) => message.includes("중복")));
|
||||
assert((await validateArrayDefinition(arrayDefinition)).some(({ message }) => message.includes("중복")));
|
||||
});
|
||||
|
||||
test("Nyquist를 넘는 tone을 거부한다", () => {
|
||||
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,
|
||||
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(validateScene(scene).some(({ path }) => path.endsWith("frequency")));
|
||||
assert((await validateScene(scene)).some(({ path }) => path.endsWith("frequency")));
|
||||
});
|
||||
|
||||
test("NaN 좌표를 거부한다", () => {
|
||||
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(validateArrayDefinition(arrayDefinition).some(({ path }) => path.endsWith("[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")));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user