From 3317a03d1a4b40faaa64a1b99c2e231c1e9440ca Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 12:34:11 +0900 Subject: [PATCH] feat: harden end-to-end simulation workflow --- apps/acoustic-camera/main.js | 2 + apps/acoustic-camera/model.js | 73 ++++++- apps/dataset-builder/index.html | 1 + apps/dataset-builder/styles.css | 1 + packages/acoustics-core/evaluation.js | 23 +++ packages/ui/landing.js | 14 +- packages/ui/runtime-status.js | 19 +- packages/ui/webgpu-status.js | 33 +++ schemas/result.schema.json | 27 ++- scripts/serve.ps1 | 25 ++- start.cmd | 2 +- tests/integration/camera-ui.test.js | 4 + tests/integration/server.test.js | 14 ++ tests/integration/workflow-ui.test.js | 285 ++++++++++++++++++++++++++ tests/test-runner.js | 2 + tests/unit/camera.test.js | 69 ++++++- tests/unit/evaluation.test.js | 7 +- 17 files changed, 577 insertions(+), 24 deletions(-) create mode 100644 packages/ui/webgpu-status.js create mode 100644 tests/integration/server.test.js create mode 100644 tests/integration/workflow-ui.test.js diff --git a/apps/acoustic-camera/main.js b/apps/acoustic-camera/main.js index 47eb33a..7224d07 100644 --- a/apps/acoustic-camera/main.js +++ b/apps/acoustic-camera/main.js @@ -8,6 +8,7 @@ import { } from "/packages/contracts/validation.js"; import { createCameraAssetRuntime } from "./asset-runtime.js"; import { + alignExperimentToScene, canonicalStringify, createHistorySummary, createResultDocument, @@ -919,6 +920,7 @@ async function importDocument(kind, input) { } else if (kind === "scene") { state.scene = value; state.experiment.sceneId = value.id; + alignExperimentToScene(state.experiment, value); assetRuntime.reset(); } else { state.experiment = value; diff --git a/apps/acoustic-camera/model.js b/apps/acoustic-camera/model.js index 9fcd277..ef3bde1 100644 --- a/apps/acoustic-camera/model.js +++ b/apps/acoustic-camera/model.js @@ -1,6 +1,12 @@ import { getFrameCount, binsForBand } from "/packages/acoustics-core/stft.js"; -const RESULT_METRIC_KEYS = ["peakToSidelobeDb", "energyInsideTruthRatio", "precision", "recall"]; +const RESULT_METRIC_KEYS = [ + "peakToSidelobeDb", + "energyInsideTruthRatio", + "precision", + "recall", + "mainLobeWidthM", +]; function sortForCanonicalJson(value) { if (Array.isArray(value)) return value.map(sortForCanonicalJson); @@ -60,6 +66,53 @@ export function shiftSceneDistance(scene, experiment, nextDistance) { if (scene.image?.plane) scene.image.plane.z += delta; } +export function alignExperimentToScene(experiment, scene) { + const plane = scene.image?.plane; + if (plane) { + Object.assign(experiment.scan, { + z: plane.z, + xMin: plane.xMin, + xMax: plane.xMax, + yMin: plane.yMin, + yMax: plane.yMax, + }); + return "image-plane"; + } + + const positions = (scene.sources ?? []) + .map((source) => source.position) + .filter((position) => + Array.isArray(position) && + position.length === 3 && + position.every(Number.isFinite), + ); + if (positions.length === 0) return "unchanged"; + + const paddedBounds = (values) => { + const minimum = Math.min(...values); + const maximum = Math.max(...values); + const padding = Math.max(0.25, (maximum - minimum) * 0.15); + return [minimum - padding, maximum + padding]; + }; + const median = (values) => { + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle]; + }; + const [xMin, xMax] = paddedBounds(positions.map((position) => position[0])); + const [yMin, yMax] = paddedBounds(positions.map((position) => position[1])); + Object.assign(experiment.scan, { + z: median(positions.map((position) => position[2])), + xMin, + xMax, + yMin, + yMax, + }); + return "source-bounds"; +} + function finiteMetricSubset(metrics = {}) { return Object.fromEntries( RESULT_METRIC_KEYS @@ -75,6 +128,9 @@ function peakForResult(peak) { }; if (peak.sourceId) result.sourceId = peak.sourceId; if (Number.isFinite(peak.localizationErrorM)) result.localizationErrorM = peak.localizationErrorM; + if (Number.isFinite(peak.localizationErrorPixels)) { + result.localizationErrorPixels = peak.localizationErrorPixels; + } return result; } @@ -85,6 +141,17 @@ function environmentForResult({ arrayDefinition, experiment, run }) { microphoneCount: arrayDefinition.microphones.length, gridPoints: experiment.scan.rows * experiment.scan.columns, }; + for (const [key, value] of Object.entries({ + gridRows: experiment.scan.rows, + gridColumns: experiment.scan.columns, + scanXMin: experiment.scan.xMin, + scanXMax: experiment.scan.xMax, + scanYMin: experiment.scan.yMin, + scanYMax: experiment.scan.yMax, + scanZ: experiment.scan.z, + })) { + if (Number.isFinite(value)) environment[key] = value; + } if (run.fallback?.code) environment.fallbackCode = run.fallback.code; if (run.fallback?.message) environment.fallbackMessage = run.fallback.message; const adapter = run.gpu?.adapterInfo; @@ -127,7 +194,7 @@ export function createResultDocument(options) { environment: environmentForResult({ arrayDefinition, experiment, run }), bands: run.bands.map((band) => { const evaluation = evaluations.get(band.id) ?? { peaks: [], metrics: {} }; - return { + const bandResult = { id: band.id, color: band.color, rawMap: { @@ -145,6 +212,8 @@ export function createResultDocument(options) { peaks: evaluation.peaks.map(peakForResult), metrics: finiteMetricSubset(evaluation.metrics), }; + if (evaluation.evaluation) bandResult.evaluation = { ...evaluation.evaluation }; + return bandResult; }), }; } diff --git a/apps/dataset-builder/index.html b/apps/dataset-builder/index.html index 9dc7a23..945c04c 100644 --- a/apps/dataset-builder/index.html +++ b/apps/dataset-builder/index.html @@ -19,6 +19,7 @@ + Scene 저장 후 Camera 열기 → diff --git a/apps/dataset-builder/styles.css b/apps/dataset-builder/styles.css index 183dd99..36a5a9d 100644 --- a/apps/dataset-builder/styles.css +++ b/apps/dataset-builder/styles.css @@ -32,6 +32,7 @@ cursor: pointer; font-size: 0.82rem; font-weight: 720; + text-decoration: none; transition: border-color 140ms ease, background 140ms ease, transform 140ms ease; } diff --git a/packages/acoustics-core/evaluation.js b/packages/acoustics-core/evaluation.js index f02bc44..ef7beca 100644 --- a/packages/acoustics-core/evaluation.js +++ b/packages/acoustics-core/evaluation.js @@ -5,6 +5,7 @@ export const RESULT_METRIC_KEYS = Object.freeze([ "energyInsideTruthRatio", "precision", "recall", + "mainLobeWidthM", ]); const RESULT_METRIC_KEY_SET = new Set(RESULT_METRIC_KEYS); @@ -457,6 +458,8 @@ export function evaluateDasBand(options) { minimumPeakDistanceM, sidelobeExclusionRadiusM, halfPowerRatio, + dynamicRangeDb, + maximumDetections, }, metrics: { peakToSidelobeDb: peakToSidelobeDb( @@ -470,6 +473,7 @@ export function evaluateDasBand(options) { energyInsideTruthRatio: energyInsideRatio(values, scan, truths, truthRadiusM, globalPeak?.value ?? 0), precision, recall, + mainLobeWidthM: mainLobe?.equivalentDiameterM ?? 0, }, diagnostics: { mainLobe, @@ -513,11 +517,30 @@ export function exportBandEvaluation(evaluation, options = {}) { if (match) { resultPeak.sourceId = match.sourceId; resultPeak.localizationErrorM = match.localizationErrorM; + const resolution = evaluation.diagnostics?.gridResolutionM; + if (resolution?.x > 0 && resolution?.y > 0) { + resultPeak.localizationErrorPixels = Math.hypot( + (match.peak.position[0] - match.truthPosition[0]) / resolution.x, + (match.peak.position[1] - match.truthPosition[1]) / resolution.y, + ); + } } return resultPeak; }); return { peaks, metrics: selectResultMetrics(evaluation, options.metricNames ?? RESULT_METRIC_KEYS), + evaluation: { + version: "1.0.0", + truthRadiusM: evaluation.parameters.truthRadiusM, + matchToleranceM: evaluation.parameters.matchToleranceM, + detectionThresholdRatio: evaluation.parameters.detectionThresholdRatio, + detectionThresholdValue: evaluation.parameters.detectionThresholdValue, + minimumPeakDistanceM: evaluation.parameters.minimumPeakDistanceM, + sidelobeExclusionRadiusM: evaluation.parameters.sidelobeExclusionRadiusM, + halfPowerRatio: evaluation.parameters.halfPowerRatio, + dynamicRangeDb: evaluation.parameters.dynamicRangeDb, + maximumDetections: evaluation.parameters.maximumDetections, + }, }; } diff --git a/packages/ui/landing.js b/packages/ui/landing.js index 105710f..6a30876 100644 --- a/packages/ui/landing.js +++ b/packages/ui/landing.js @@ -1,12 +1,14 @@ +import { detectWebGpuCapability } from "./webgpu-status.js"; + const statusElement = document.querySelector("#webgpu-status"); if (!(statusElement instanceof HTMLElement)) { throw new Error("WebGPU status element is missing."); } -if ("gpu" in navigator) { - statusElement.textContent = "WebGPU 사용 가능"; -} else { - statusElement.textContent = "CPU fallback 사용 예정"; - statusElement.classList.add("status-chip--warning"); -} +detectWebGpuCapability().then((capability) => { + statusElement.textContent = capability.label; + statusElement.title = capability.detail; + statusElement.classList.toggle("status-chip--warning", !capability.available); + statusElement.dataset.backendAvailable = capability.available ? "webgpu" : "cpu"; +}); diff --git a/packages/ui/runtime-status.js b/packages/ui/runtime-status.js index f1d1a25..f5fd7da 100644 --- a/packages/ui/runtime-status.js +++ b/packages/ui/runtime-status.js @@ -1,8 +1,13 @@ -const statusElements = document.querySelectorAll("[data-runtime-status]"); -const hasWebGpu = "gpu" in navigator; +import { detectWebGpuCapability } from "./webgpu-status.js"; -for (const statusElement of statusElements) { - statusElement.textContent = hasWebGpu ? "WebGPU 준비됨" : "CPU fallback"; - statusElement.classList.toggle("status-chip--warning", !hasWebGpu); - statusElement.classList.toggle("status-chip--neutral", hasWebGpu); -} +const statusElements = document.querySelectorAll("[data-runtime-status]"); + +detectWebGpuCapability().then((capability) => { + for (const statusElement of statusElements) { + statusElement.textContent = capability.label; + statusElement.title = capability.detail; + statusElement.classList.toggle("status-chip--warning", !capability.available); + statusElement.classList.toggle("status-chip--neutral", capability.available); + statusElement.dataset.backendAvailable = capability.available ? "webgpu" : "cpu"; + } +}); diff --git a/packages/ui/webgpu-status.js b/packages/ui/webgpu-status.js new file mode 100644 index 0000000..d2170e0 --- /dev/null +++ b/packages/ui/webgpu-status.js @@ -0,0 +1,33 @@ +let capabilityPromise; + +export function detectWebGpuCapability() { + capabilityPromise ??= detect(); + return capabilityPromise; +} + +async function detect() { + if (!navigator.gpu) { + return { available: false, label: "CPU fallback", detail: "WebGPU API 없음" }; + } + try { + const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" }); + if (!adapter) { + return { available: false, label: "CPU fallback", detail: "WebGPU adapter 없음" }; + } + const info = adapter.info; + const adapterName = [info?.vendor, info?.architecture || info?.device] + .filter(Boolean) + .join(" · "); + return { + available: true, + label: adapterName ? `WebGPU · ${adapterName}` : "WebGPU adapter 준비됨", + detail: adapterName || "WebGPU adapter 준비됨", + }; + } catch (error) { + return { + available: false, + label: "CPU fallback", + detail: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/schemas/result.schema.json b/schemas/result.schema.json index b71cebb..71c469c 100644 --- a/schemas/result.schema.json +++ b/schemas/result.schema.json @@ -66,7 +66,28 @@ "sourceId": { "type": "string", "minLength": 1 }, "position": { "$ref": "#/$defs/position" }, "value": { "type": "number", "minimum": 0 }, - "localizationErrorM": { "type": "number", "minimum": 0 } + "localizationErrorM": { "type": "number", "minimum": 0 }, + "localizationErrorPixels": { "type": "number", "minimum": 0 } + } + }, + "evaluation": { + "type": "object", "additionalProperties": false, + "required": [ + "version", "truthRadiusM", "matchToleranceM", "detectionThresholdRatio", + "detectionThresholdValue", "minimumPeakDistanceM", "sidelobeExclusionRadiusM", + "halfPowerRatio", "dynamicRangeDb", "maximumDetections" + ], + "properties": { + "version": { "const": "1.0.0" }, + "truthRadiusM": { "type": "number", "minimum": 0 }, + "matchToleranceM": { "type": "number", "minimum": 0 }, + "detectionThresholdRatio": { "type": "number", "minimum": 0, "maximum": 1 }, + "detectionThresholdValue": { "type": "number", "minimum": 0 }, + "minimumPeakDistanceM": { "type": "number", "minimum": 0 }, + "sidelobeExclusionRadiusM": { "type": "number", "minimum": 0 }, + "halfPowerRatio": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, + "dynamicRangeDb": { "type": "number", "minimum": 0 }, + "maximumDetections": { "type": "integer", "minimum": 1, "maximum": 128 } } }, "processedMap": { @@ -84,6 +105,7 @@ "color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }, "rawMap": { "$ref": "#/$defs/map" }, "displayMap": { "$ref": "#/$defs/map" }, + "evaluation": { "$ref": "#/$defs/evaluation" }, "processedMaps": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/processedMap" } }, "peaks": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/peak" } }, "metrics": { @@ -92,7 +114,8 @@ "peakToSidelobeDb": { "type": "number" }, "energyInsideTruthRatio": { "type": "number", "minimum": 0, "maximum": 1 }, "precision": { "type": "number", "minimum": 0, "maximum": 1 }, - "recall": { "type": "number", "minimum": 0, "maximum": 1 } + "recall": { "type": "number", "minimum": 0, "maximum": 1 }, + "mainLobeWidthM": { "type": "number", "minimum": 0 } } } } diff --git a/scripts/serve.ps1 b/scripts/serve.ps1 index d9d5b13..2e50eb6 100644 --- a/scripts/serve.ps1 +++ b/scripts/serve.ps1 @@ -42,7 +42,15 @@ function Send-TextResponse { } try { - $listener.Start() + try { + $listener.Start() + } + catch [System.Net.HttpListenerException] { + throw [System.InvalidOperationException]::new( + "Port $Port is unavailable. Close the running micarrsim window or choose another port, for example: start.cmd -Port 4174", + $_.Exception + ) + } Write-Host "micarrsim is running at $serverUrl" -ForegroundColor Green Write-Host 'Press Ctrl+C to stop.' -ForegroundColor DarkGray @@ -54,11 +62,26 @@ try { $context = $listener.GetContext() try { + $context.Response.Headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; media-src 'self' blob:; worker-src 'self'; connect-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'none'" + $context.Response.Headers['Referrer-Policy'] = 'no-referrer' + $context.Response.Headers['X-Content-Type-Options'] = 'nosniff' + + if ($context.Request.HttpMethod -notin @('GET', 'HEAD')) { + $context.Response.Headers['Allow'] = 'GET, HEAD' + Send-TextResponse -Response $context.Response -StatusCode 405 -Message 'Method not allowed' + continue + } + $relativePath = [System.Uri]::UnescapeDataString($context.Request.Url.AbsolutePath.TrimStart('/')) $relativePath = $relativePath.Replace('/', [System.IO.Path]::DirectorySeparatorChar) if ([string]::IsNullOrWhiteSpace($relativePath)) { $relativePath = 'index.html' } + $pathSegments = $relativePath -split '[\\/]' + if ($pathSegments | Where-Object { $_.StartsWith('.') }) { + Send-TextResponse -Response $context.Response -StatusCode 403 -Message 'Forbidden' + continue + } $candidatePath = [System.IO.Path]::GetFullPath((Join-Path $projectRoot $relativePath)) $isWithinRoot = $candidatePath.Equals($projectRoot, [System.StringComparison]::OrdinalIgnoreCase) -or diff --git a/start.cmd b/start.cmd index cb15566..fe4de47 100644 --- a/start.cmd +++ b/start.cmd @@ -1,5 +1,5 @@ @echo off setlocal cd /d "%~dp0" -powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\serve.ps1" +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\serve.ps1" %* if errorlevel 1 pause diff --git a/tests/integration/camera-ui.test.js b/tests/integration/camera-ui.test.js index 971fb70..a67eedf 100644 --- a/tests/integration/camera-ui.test.js +++ b/tests/integration/camera-ui.test.js @@ -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( diff --git a/tests/integration/server.test.js b/tests/integration/server.test.js new file mode 100644 index 0000000..38b1c8a --- /dev/null +++ b/tests/integration/server.test.js @@ -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"); +}); diff --git a/tests/integration/workflow-ui.test.js b/tests/integration/workflow-ui.test.js new file mode 100644 index 0000000..51f06d9 --- /dev/null +++ b/tests/integration/workflow-ui.test.js @@ -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( + '' + + '' + + '', + ); + + 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(); + } +}); diff --git a/tests/test-runner.js b/tests/test-runner.js index a97c993..47fc81f 100644 --- a/tests/test-runner.js +++ b/tests/test-runner.js @@ -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: "테스트 모듈을 불러온다", diff --git a/tests/unit/camera.test.js b/tests/unit/camera.test.js index fc9681a..ba25aee 100644 --- a/tests/unit/camera.test.js +++ b/tests/unit/camera.test.js @@ -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 }); diff --git a/tests/unit/evaluation.test.js b/tests/unit/evaluation.test.js index 70a7b5b..3d415f2 100644 --- a/tests/unit/evaluation.test.js +++ b/tests/unit/evaluation.test.js @@ -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"); });