feat: define simulator data contracts
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>micarrsim tests</title>
|
||||
<link rel="stylesheet" href="/packages/ui/app-shell.css" />
|
||||
<style>
|
||||
.test-shell { width: min(900px, calc(100% - 32px)); margin: 40px auto; }
|
||||
.test-summary { color: var(--muted); }
|
||||
.test-list { display: grid; gap: 8px; padding: 0; list-style: none; }
|
||||
.test-result { padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }
|
||||
.test-result--pass { border-left: 4px solid var(--mint); }
|
||||
.test-result--fail { border-left: 4px solid var(--danger); }
|
||||
pre { overflow: auto; color: var(--danger); white-space: pre-wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="test-shell">
|
||||
<p class="eyebrow">Browser test runner</p>
|
||||
<h1>micarrsim tests</h1>
|
||||
<p class="test-summary" id="test-summary">테스트 실행 중…</p>
|
||||
<ol class="test-list" id="test-results"></ol>
|
||||
</main>
|
||||
<script type="module" src="/tests/test-runner.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
const registeredTests = [];
|
||||
|
||||
globalThis.test = (name, callback) => registeredTests.push({ name, callback });
|
||||
globalThis.assert = (condition, message = "assertion failed") => {
|
||||
if (!condition) throw new Error(message);
|
||||
};
|
||||
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");
|
||||
|
||||
const resultList = document.querySelector("#test-results");
|
||||
const summary = document.querySelector("#test-summary");
|
||||
let passed = 0;
|
||||
|
||||
for (const registeredTest of registeredTests) {
|
||||
const item = document.createElement("li");
|
||||
item.className = "test-result";
|
||||
try {
|
||||
await registeredTest.callback();
|
||||
passed += 1;
|
||||
item.classList.add("test-result--pass");
|
||||
item.textContent = `✓ ${registeredTest.name}`;
|
||||
} catch (error) {
|
||||
item.classList.add("test-result--fail");
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = `✕ ${registeredTest.name}`;
|
||||
const details = document.createElement("pre");
|
||||
details.textContent = error instanceof Error ? error.stack : String(error);
|
||||
item.append(title, details);
|
||||
}
|
||||
resultList.append(item);
|
||||
}
|
||||
|
||||
const failed = registeredTests.length - passed;
|
||||
summary.textContent = `${registeredTests.length}개 중 ${passed}개 통과, ${failed}개 실패`;
|
||||
summary.dataset.failed = String(failed);
|
||||
document.documentElement.dataset.testStatus = failed === 0 ? "pass" : "fail";
|
||||
document.title = failed === 0 ? "PASS · micarrsim tests" : "FAIL · micarrsim tests";
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
validateArrayDefinition,
|
||||
validateExperiment,
|
||||
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("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");
|
||||
});
|
||||
|
||||
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(validateScene(scene).length, 0, "scene issues");
|
||||
assertEqual(validateExperiment(experiment, { arrayDefinition, scene }).length, 0, "experiment issues");
|
||||
});
|
||||
|
||||
test("중복 마이크 id를 거부한다", () => {
|
||||
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(validateArrayDefinition(arrayDefinition).some(({ message }) => message.includes("중복")));
|
||||
});
|
||||
|
||||
test("Nyquist를 넘는 tone을 거부한다", () => {
|
||||
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,
|
||||
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")));
|
||||
});
|
||||
|
||||
test("NaN 좌표를 거부한다", () => {
|
||||
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]")));
|
||||
});
|
||||
Reference in New Issue
Block a user