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("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"; 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"))); }); 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"))); });