feat: harden end-to-end simulation workflow

This commit is contained in:
Codex
2026-08-28 12:34:11 +09:00
parent 2ce96064cf
commit 3317a03d1a
17 changed files with 577 additions and 24 deletions
+4
View File
@@ -110,6 +110,10 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
assertEqual(resultDocument.bands[0].rawMap.columns, 8, "exported map columns");
assertEqual(resultDocument.bands[0].rawMap.values.length, 64, "exported map value count");
assert(/^[a-f0-9]{64}$/.test(resultDocument.inputHash), "exported input hash");
assertEqual(resultDocument.bands[0].evaluation.version, "1.0.0", "evaluation version");
assert(Number.isFinite(resultDocument.bands[0].evaluation.matchToleranceM), "match tolerance exported");
assert(Number.isFinite(resultDocument.bands[0].metrics.mainLobeWidthM), "main-lobe metric exported");
assertEqual(resultDocument.environment.scanXMin, experiment.scan.xMin, "scan bounds exported");
dispatchValue(windowObject, documentObject.querySelector("#dynamic-range"), 24);
await waitFor(
+14
View File
@@ -0,0 +1,14 @@
test("로컬 서버가 숨김 경로와 쓰기 method를 차단하고 보안 header를 보낸다", async () => {
const hiddenResponse = await fetch("/.git/config");
assertEqual(hiddenResponse.status, 403, "hidden repository path");
const postResponse = await fetch("/", { method: "POST" });
assertEqual(postResponse.status, 405, "unsupported method");
assertEqual(postResponse.headers.get("allow"), "GET, HEAD", "allowed methods");
const rootResponse = await fetch("/");
assertEqual(rootResponse.status, 200, "root remains available");
assertEqual(rootResponse.headers.get("x-content-type-options"), "nosniff", "nosniff header");
assertEqual(rootResponse.headers.get("referrer-policy"), "no-referrer", "referrer policy");
assert(rootResponse.headers.get("content-security-policy")?.includes("worker-src 'self'"), "worker CSP");
});
+285
View File
@@ -0,0 +1,285 @@
import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.js";
import { validateExperiment, validateResult, validateScene } from "/packages/contracts/validation.js";
async function waitFor(predicate, label, timeoutMs = 30000) {
const deadline = performance.now() + timeoutMs;
while (performance.now() < deadline) {
const value = predicate();
if (value) return value;
await new Promise((resolve) => setTimeout(resolve, 40));
}
throw new Error(`${label} timed out.`);
}
function dispatchValue(windowObject, element, value, eventName = "input") {
element.value = String(value);
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
}
function dispatchChecked(windowObject, element, checked) {
element.checked = checked;
element.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
}
function dispatchFiles(windowObject, input, files) {
const transfer = new windowObject.DataTransfer();
files.forEach((file) => transfer.items.add(file));
input.files = transfer.files;
input.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
}
async function mountApp(path, label) {
const frame = document.createElement("iframe");
frame.src = `${path}?integration-test=1`;
frame.style.cssText = "position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0";
document.body.append(frame);
await new Promise((resolve, reject) => {
frame.addEventListener("load", resolve, { once: true });
frame.addEventListener("error", () => reject(new Error(`${label} iframe load failed`)), { once: true });
});
await waitFor(
() => frame.contentDocument.documentElement.dataset.appReady === "true",
`${label} initialization`,
);
return frame;
}
function captureDownloads(windowObject) {
const downloads = [];
const objectUrls = [];
const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click;
const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL);
windowObject.HTMLAnchorElement.prototype.click = function captureDownload() {
downloads.push({ fileName: this.download, url: this.href });
};
windowObject.URL.revokeObjectURL = (url) => objectUrls.push(url);
return {
downloads,
restore() {
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
windowObject.URL.revokeObjectURL = originalRevoke;
for (const url of objectUrls) originalRevoke(url);
},
};
}
function makeExperiment(scene) {
const plane = scene.image.plane;
return {
schemaVersion: "1.0.0",
kind: "micarrsim.experiment",
id: "builder-camera-workflow",
name: "Builder to Camera asset workflow",
arrayId: "rectangular-16x12",
sceneId: scene.id,
scan: {
z: plane.z,
xMin: plane.xMin,
xMax: plane.xMax,
yMin: plane.yMin,
yMax: plane.yMax,
columns: 8,
rows: 8,
},
frequencyBands: [
{ id: "asset-500", minHz: 450, maxHz: 550, color: "#65f2bf" },
{ id: "tone-1k", minHz: 950, maxHz: 1050, color: "#58c7e8" },
],
stft: { fftSize: 128, hopSize: 64, window: "hann" },
beamforming: {
algorithm: "das",
backend: "cpu",
minimumDistance: 0.01,
normalization: "peak",
dynamicRangeDb: 30,
},
};
}
test("Dataset Builder exports asset Scene that Acoustic Camera rebinds and runs", async () => {
const sampleRate = 8000;
const duration = 0.05;
const samples = new Float32Array(sampleRate * duration);
for (let index = 0; index < samples.length; index += 1) {
samples[index] = Math.sin((2 * Math.PI * 500 * index) / sampleRate) * 0.2;
}
const audioBytes = await encodeMonoPcm16Wav(samples, sampleRate).arrayBuffer();
const imageBytes = new TextEncoder().encode(
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240">' +
'<rect width="320" height="240" fill="#102924"/>' +
'<circle cx="210" cy="95" r="35" fill="#65f2bf"/></svg>',
);
let builderFrame = null;
let builderCapture = null;
let cameraFrame = null;
let cameraCapture = null;
try {
builderFrame = await mountApp("/apps/dataset-builder/", "Dataset Builder");
const builderWindow = builderFrame.contentWindow;
const builderDocument = builderFrame.contentDocument;
builderCapture = captureDownloads(builderWindow);
dispatchValue(builderWindow, builderDocument.querySelector("#scene-id"), "workflow-asset-scene");
dispatchValue(builderWindow, builderDocument.querySelector("#scene-name"), "Workflow asset scene");
dispatchValue(builderWindow, builderDocument.querySelector("#scene-duration"), duration);
dispatchValue(builderWindow, builderDocument.querySelector("#source-duration"), duration);
dispatchValue(builderWindow, builderDocument.querySelector("#source-z"), 1.6);
dispatchValue(builderWindow, builderDocument.querySelector("#plane-x-min"), -0.3);
dispatchValue(builderWindow, builderDocument.querySelector("#plane-x-max"), 0.5);
dispatchValue(builderWindow, builderDocument.querySelector("#plane-y-min"), -0.25);
dispatchValue(builderWindow, builderDocument.querySelector("#plane-y-max"), 0.35);
dispatchValue(builderWindow, builderDocument.querySelector("#sample-rate"), sampleRate, "change");
const builderAudioFile = new builderWindow.File([audioBytes.slice(0)], "workflow-tone.wav", {
type: "audio/wav",
});
const builderImageFile = new builderWindow.File([imageBytes.slice(0)], "workflow-layout.svg", {
type: "image/svg+xml",
});
dispatchFiles(builderWindow, builderDocument.querySelector("#audio-input"), [builderAudioFile]);
await waitFor(
() => builderDocument.querySelectorAll(".asset-chip .asset-chip__state").length === 1 &&
builderDocument.querySelector(".asset-chip .asset-chip__state").textContent.includes("연결"),
"Builder audio SHA attachment",
);
dispatchFiles(builderWindow, builderDocument.querySelector("#image-input"), [builderImageFile]);
await waitFor(
() => builderDocument.querySelectorAll(".asset-chip .asset-chip__state").length === 2 &&
[...builderDocument.querySelectorAll(".asset-chip .asset-chip__state")]
.every((element) => element.textContent.includes("연결")),
"Builder image SHA attachment",
);
builderDocument.querySelector("#add-source-button").click();
assertEqual(builderDocument.querySelectorAll(".source-card").length, 2, "two Builder sources");
dispatchValue(builderWindow, builderDocument.querySelector("#source-name"), "Asset source");
dispatchValue(builderWindow, builderDocument.querySelector("#source-x"), -0.1);
dispatchValue(builderWindow, builderDocument.querySelector("#source-y"), -0.08);
dispatchValue(builderWindow, builderDocument.querySelector("#source-type"), "asset", "change");
dispatchChecked(builderWindow, builderDocument.querySelector("#source-loop"), true);
await waitFor(
() => builderDocument.documentElement.dataset.sceneValid === "true" &&
builderDocument.documentElement.dataset.assetsReady === "true" &&
!builderDocument.querySelector("#export-button").disabled,
"Builder asset Scene validation",
);
builderDocument.querySelector("#export-button").click();
const sceneDownload = await waitFor(
() => builderCapture.downloads.find((download) => download.fileName.endsWith(".scene.json")),
"Builder Scene JSON export",
);
const sceneJson = await fetch(sceneDownload.url).then((response) => response.text());
const scene = JSON.parse(sceneJson);
const sceneIssues = await validateScene(scene);
assertEqual(sceneIssues.length, 0, `Builder Scene validation: ${sceneIssues
.map((issue) => `${issue.path}: ${issue.message}`)
.join(" | ")}`);
assertEqual(scene.sampleRate, sampleRate, "exported Scene sample rate");
assertEqual(scene.duration, duration, "exported Scene duration");
assertEqual(scene.sources.length, 2, "exported source count");
assert(scene.sources.some((source) => source.signal.type === "tone"), "exported tone source");
assert(scene.sources.some((source) => source.signal.type === "asset"), "exported asset source");
assertEqual(scene.assets.length, 2, "exported audio and image descriptors");
assertEqual(scene.image.plane.z, 1.6, "exported image plane z");
assertEqual(scene.image.plane.xMin, -0.3, "exported image plane xMin");
assertEqual(scene.image.plane.xMax, 0.5, "exported image plane xMax");
assert(scene.assets.every((asset) => /^[a-f0-9]{64}$/.test(asset.sha256)), "exported SHA manifests");
builderCapture.restore();
builderCapture = null;
builderFrame.remove();
builderFrame = null;
cameraFrame = await mountApp("/apps/acoustic-camera/", "Acoustic Camera");
const cameraWindow = cameraFrame.contentWindow;
const cameraDocument = cameraFrame.contentDocument;
cameraCapture = captureDownloads(cameraWindow);
const sceneFile = new cameraWindow.File([sceneJson], "workflow.scene.json", {
type: "application/json",
});
dispatchFiles(cameraWindow, cameraDocument.querySelector("#scene-input"), [sceneFile]);
await waitFor(
() => cameraDocument.documentElement.dataset.inputBusy === "false" &&
cameraDocument.querySelector("#workspace-title").textContent === scene.name &&
cameraDocument.querySelectorAll("#asset-list .asset-status").length === 2,
"Camera Scene import",
);
assertEqual(cameraDocument.documentElement.dataset.cameraValid, "false", "assets required after import");
assertEqual(cameraDocument.querySelector("#scene-distance").valueAsNumber, scene.image.plane.z, "scan z follows Scene");
assertEqual(cameraDocument.querySelector("#scan-x-min").valueAsNumber, scene.image.plane.xMin, "scan xMin follows Scene");
assertEqual(cameraDocument.querySelector("#scan-x-max").valueAsNumber, scene.image.plane.xMax, "scan xMax follows Scene");
assertEqual(cameraDocument.querySelector("#scan-y-min").valueAsNumber, scene.image.plane.yMin, "scan yMin follows Scene");
assertEqual(cameraDocument.querySelector("#scan-y-max").valueAsNumber, scene.image.plane.yMax, "scan yMax follows Scene");
const experiment = makeExperiment(scene);
const arrayDefinition = await fetch("/assets/array-geometries/rectangular-16x12.array.json")
.then((response) => response.json());
const experimentIssues = await validateExperiment(experiment, { scene, arrayDefinition });
assertEqual(experimentIssues.length, 0, `workflow Experiment validation: ${experimentIssues
.map((issue) => `${issue.path}: ${issue.message}`)
.join(" | ")}`);
const experimentFile = new cameraWindow.File(
[JSON.stringify(experiment)],
"workflow.experiment.json",
{ type: "application/json" },
);
dispatchFiles(cameraWindow, cameraDocument.querySelector("#experiment-input"), [experimentFile]);
await waitFor(
() => cameraDocument.documentElement.dataset.inputBusy === "false" &&
cameraDocument.querySelector("#experiment-id").value === experiment.id &&
cameraDocument.querySelector("#scan-columns").value === "8" &&
cameraDocument.querySelector("#backend").value === "cpu",
"Camera Experiment import",
);
const cameraAudioFile = new cameraWindow.File([audioBytes.slice(0)], "workflow-tone.wav", {
type: "audio/wav",
});
const cameraImageFile = new cameraWindow.File([imageBytes.slice(0)], "workflow-layout.svg", {
type: "image/svg+xml",
});
dispatchFiles(cameraWindow, cameraDocument.querySelector("#asset-input"), [cameraAudioFile, cameraImageFile]);
await waitFor(
() => cameraDocument.documentElement.dataset.inputBusy === "false" &&
cameraDocument.documentElement.dataset.cameraValid === "true" &&
cameraDocument.querySelectorAll("#asset-list .asset-status.is-ready").length === 2 &&
!cameraDocument.querySelector("#run-button").disabled,
"Camera SHA asset rebind",
);
cameraDocument.querySelector("#run-button").click();
await waitFor(
() => cameraDocument.documentElement.dataset.runState === "done",
"Camera CPU Worker run",
30000,
);
assertEqual(cameraDocument.documentElement.dataset.runBackend, "cpu", "workflow backend");
assertEqual(cameraDocument.documentElement.dataset.resultBandCount, "2", "workflow result bands");
cameraDocument.querySelector("#export-result-button").click();
const resultDownload = await waitFor(
() => cameraCapture.downloads.find((download) => download.fileName.endsWith(".result.json")),
"Camera Result JSON export",
);
const result = await fetch(resultDownload.url).then((response) => response.json());
const resultIssues = await validateResult(result, { scene, experiment });
assertEqual(resultIssues.length, 0, `workflow Result validation: ${resultIssues
.map((issue) => `${issue.path}: ${issue.message}`)
.join(" | ")}`);
assertEqual(result.sceneId, scene.id, "Result references Builder Scene");
assertEqual(result.experimentId, experiment.id, "Result references imported Experiment");
assertEqual(result.backend, "cpu", "Result records CPU backend");
assertEqual(result.bands.length, 2, "Result records both bands");
assert(result.bands.every((band) => band.rawMap.rows === 8), "Result map rows");
assert(result.bands.every((band) => band.rawMap.columns === 8), "Result map columns");
assert(result.bands.every((band) => band.rawMap.values.length === 64), "Result map values");
} finally {
cameraCapture?.restore();
builderCapture?.restore();
cameraFrame?.remove();
builderFrame?.remove();
}
});
+2
View File
@@ -17,8 +17,10 @@ try {
await import("/tests/unit/webgpu.test.js");
await import("/tests/integration/cpu-baseline.test.js");
await import("/tests/integration/webgpu.test.js");
await import("/tests/integration/server.test.js");
await import("/tests/integration/builder-ui.test.js");
await import("/tests/integration/camera-ui.test.js");
await import("/tests/integration/workflow-ui.test.js");
} catch (error) {
registeredTests.push({
name: "테스트 모듈을 불러온다",
+66 -3
View File
@@ -1,4 +1,5 @@
import {
alignExperimentToScene,
canonicalStringify,
createResultDocument,
estimateExperimentWorkload,
@@ -108,6 +109,46 @@ test("Camera distance shift keeps the scene and scan on one z plane", () => {
assertEqual(scene.image.plane.z, 3.25, "background image plane z shift");
});
test("Camera aligns an experiment to an imported scene without changing grid resolution", () => {
const experiment = {
scan: { z: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, columns: 32, rows: 24 },
};
const imageScene = {
sources: [{ id: "one", position: [0.2, -0.1, 4.5] }],
image: { plane: { z: 4.5, xMin: -2, xMax: 3, yMin: -1.5, yMax: 2.5 } },
};
assertEqual(alignExperimentToScene(experiment, imageScene), "image-plane", "alignment source");
assertEqual(
JSON.stringify(experiment.scan),
JSON.stringify({
z: 4.5,
xMin: -2,
xMax: 3,
yMin: -1.5,
yMax: 2.5,
columns: 32,
rows: 24,
}),
"image plane becomes scan plane while rows and columns stay unchanged",
);
const sourceScene = {
sources: [
{ id: "one", position: [-0.5, 0.1, 3] },
{ id: "two", position: [0.5, 0.2, 5] },
],
};
assertEqual(alignExperimentToScene(experiment, sourceScene), "source-bounds", "source fallback");
assertEqual(experiment.scan.z, 4, "median source depth");
assertNear(experiment.scan.xMin, -0.75, 1e-12, "source x minimum with padding");
assertNear(experiment.scan.xMax, 0.75, 1e-12, "source x maximum with padding");
assertNear(experiment.scan.yMin, -0.15, 1e-12, "source y minimum with padding");
assertNear(experiment.scan.yMax, 0.45, 1e-12, "source y maximum with padding");
assertEqual(experiment.scan.columns, 32, "columns retained after source fallback");
assertEqual(experiment.scan.rows, 24, "rows retained after source fallback");
});
test("Camera result document is schema-safe and linked to its snapshot", async () => {
const arrayDefinition = {
id: "test-array",
@@ -125,7 +166,7 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
id: "test-experiment",
arrayId: arrayDefinition.id,
sceneId: scene.id,
scan: { rows: 2, columns: 2 },
scan: { rows: 2, columns: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1, z: 2 },
beamforming: { backend: "auto" },
frequencyBands: [{ id: "band-1", color: "#65f2bf" }],
};
@@ -143,7 +184,14 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
}],
};
const evaluations = new Map([["band-1", {
peaks: [{ index: 3, sourceId: "source-1", position: [1, -1, 2], value: 1, localizationErrorM: 0.05 }],
peaks: [{
index: 3,
sourceId: "source-1",
position: [1, -1, 2],
value: 1,
localizationErrorM: 0.05,
localizationErrorPixels: 0.1,
}],
metrics: {
peakToSidelobeDb: 10,
energyInsideTruthRatio: 0.75,
@@ -152,6 +200,18 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
mainLobeWidthM: 0.2,
ignoredNonFinite: Number.NaN,
},
evaluation: {
version: "1.0.0",
truthRadiusM: 0.1,
matchToleranceM: 0.2,
detectionThresholdRatio: 0.5,
detectionThresholdValue: 0.25,
minimumPeakDistanceM: 0.1,
sidelobeExclusionRadiusM: 0.1,
halfPowerRatio: 0.5,
dynamicRangeDb: 30,
maximumDetections: 128,
},
}]]);
const result = createResultDocument({
@@ -168,9 +228,12 @@ test("Camera result document is schema-safe and linked to its snapshot", async (
assert(!("index" in result.bands[0].peaks[0]), "internal peak index is omitted");
assertEqual(
Object.keys(result.bands[0].metrics).join(","),
"peakToSidelobeDb,energyInsideTruthRatio,precision,recall",
"peakToSidelobeDb,energyInsideTruthRatio,precision,recall,mainLobeWidthM",
"only finite schema metrics are exported",
);
assertEqual(result.bands[0].peaks[0].localizationErrorPixels, 0.1, "pixel error is exported");
assertEqual(result.bands[0].evaluation.matchToleranceM, 0.2, "evaluation settings are exported");
assertEqual(result.environment.scanXMin, -1, "scan bounds are recorded");
assertEqual(result.environment.fallbackCode, "webgpu-unavailable", "fallback is recorded");
const issues = await validateResult(result, { experiment, scene });
+5 -2
View File
@@ -153,15 +153,18 @@ test("result exporter가 schema 허용 metric만 선택하고 내부 필드를
assertEqual(Object.keys(exported.metrics).join(","), "precision,recall", "selected metric keys");
assertEqual(exported.peaks[0].sourceId, "truth", "schema peak source match");
assertEqual(exported.peaks[0].localizationErrorM, 0, "schema peak localization error");
assertEqual(exported.peaks[0].localizationErrorPixels, 0, "schema peak pixel error");
assert(!("index" in exported.peaks[0]), "internal peak index omitted");
assert(!("mainLobeWidthM" in exported.metrics), "diagnostic omitted from schema metrics");
assert(!("mainLobeWidthM" in exported.metrics), "explicit metric selection is respected");
assertEqual(exported.evaluation.version, "1.0.0", "evaluation version exported");
assertEqual(exported.evaluation.matchToleranceM, 0, "match tolerance exported");
assertEqual(
Object.keys(selectResultMetrics(evaluation)).join(","),
RESULT_METRIC_KEYS.join(","),
"default schema metric selection",
);
const unsupported = captureError(() => selectResultMetrics(evaluation, ["mainLobeWidthM"]));
const unsupported = captureError(() => selectResultMetrics(evaluation, ["gridResolutionM"]));
assert(unsupported instanceof RangeError, "unsupported result metric is rejected");
});