feat: complete acoustic camera workflow

This commit is contained in:
Codex
2026-08-28 12:11:10 +09:00
parent d5f7530620
commit d04a4d9f65
13 changed files with 3632 additions and 16 deletions
+147
View File
@@ -0,0 +1,147 @@
import { validateResult } 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} 대기 시간이 초과되었습니다.`);
}
function dispatchValue(windowObject, element, value, eventName = "change") {
element.value = String(value);
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
}
async function loadJson(path) {
const response = await fetch(path);
assert(response.ok, `${path} load failed`);
return response.json();
}
async function nextPaint(windowObject) {
await new Promise((resolve) => windowObject.requestAnimationFrame(() =>
windowObject.requestAnimationFrame(resolve)));
}
test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름을 완주한다", async () => {
const frame = document.createElement("iframe");
frame.src = "/apps/acoustic-camera/?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("Acoustic Camera iframe load failed")), {
once: true,
});
});
const windowObject = frame.contentWindow;
const documentObject = frame.contentDocument;
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);
try {
await waitFor(
() => documentObject.documentElement.dataset.appReady === "true",
"Acoustic Camera 초기화",
);
await waitFor(
() => documentObject.documentElement.dataset.cameraValid === "true",
"기본 Camera 입력 검증",
);
const canvas = documentObject.querySelector("#scene-canvas");
await waitFor(() => canvas.width > 0 && canvas.height > 0, "scene canvas 초기 렌더");
await nextPaint(windowObject);
const initialCanvas = canvas.toDataURL();
dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8);
dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8);
dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu");
await waitFor(
() => documentObject.documentElement.dataset.cameraValid === "true" &&
!documentObject.querySelector("#run-button").disabled,
"8×8 CPU 입력 검증",
);
assertEqual(documentObject.querySelector("#grid-preset").value, "custom", "custom grid preset");
documentObject.querySelector("#run-button").click();
await waitFor(
() => documentObject.documentElement.dataset.runState === "done",
"8×8 CPU simulation",
30000,
);
assertEqual(documentObject.documentElement.dataset.runBackend, "cpu", "actual backend");
assertEqual(documentObject.documentElement.dataset.resultBandCount, "1", "result band count");
assert(documentObject.querySelectorAll("#metric-grid .metric-card").length >= 9, "result metrics rendered");
assert(!documentObject.querySelector("#metric-grid").classList.contains("is-hidden"), "metrics visible");
assert(documentObject.querySelector("#result-badge").textContent.includes("CPU"), "backend badge rendered");
await nextPaint(windowObject);
assert(canvas.toDataURL() !== initialCanvas, "result heatmap changed the scene canvas");
documentObject.querySelector("#export-result-button").click();
const resultDownload = await waitFor(
() => downloads.find((download) => download.fileName.endsWith(".result.json")),
"Result JSON download",
);
const resultDocument = await fetch(resultDownload.url).then((response) => response.json());
const [scene, experiment] = await Promise.all([
loadJson("/assets/sample-scenes/single-tone.scene.json"),
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
]);
experiment.scan.columns = 8;
experiment.scan.rows = 8;
experiment.beamforming.backend = "cpu";
const resultIssues = await validateResult(resultDocument, { scene, experiment });
assertEqual(resultIssues.length, 0, `downloaded Result validation: ${resultIssues
.map((issue) => `${issue.path}: ${issue.message}`)
.join(" · ")}`);
assertEqual(resultDocument.backend, "cpu", "exported backend");
assertEqual(resultDocument.bands[0].rawMap.rows, 8, "exported map rows");
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");
dispatchValue(windowObject, documentObject.querySelector("#dynamic-range"), 24);
await waitFor(
() => documentObject.querySelector("#result-badge").textContent.includes("이전 설정"),
"changed input stale badge",
);
assert(documentObject.querySelector("#canvas-badge").textContent.includes("이전 설정"), "canvas stale badge");
dispatchValue(windowObject, documentObject.querySelector("#dynamic-range"), 30);
await waitFor(
() => documentObject.documentElement.dataset.cameraValid === "true" &&
!documentObject.querySelector("#run-button").disabled,
"original comparison configuration restore",
);
documentObject.querySelector("#run-button").click();
await waitFor(
() => documentObject.documentElement.dataset.runState === "running",
"rerun start",
);
assert(!documentObject.querySelector("#cancel-button").classList.contains("is-hidden"), "cancel available");
await waitFor(
() => documentObject.documentElement.dataset.runState === "done",
"8×8 CPU rerun",
30000,
);
assert(!documentObject.querySelector("#result-badge").textContent.includes("이전 설정"), "fresh result badge");
assertEqual(documentObject.querySelectorAll("#history-list .history-card").length, 2, "rerun history");
} finally {
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
windowObject.URL.revokeObjectURL = originalRevoke;
for (const url of objectUrls) originalRevoke(url);
frame.remove();
}
});