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); const originalConfirm = windowObject.confirm.bind(windowObject); 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"); assert( documentObject.documentElement.dataset.unsavedWork.includes("Experiment"), "edited Experiment is marked unsaved", ); 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"); assert(documentObject.documentElement.dataset.unsavedWork.includes("Result"), "completed Result is unsaved"); assert( documentObject.documentElement.dataset.unsavedWork.includes("비교 기록"), "comparison summary is unsaved", ); let replacementConfirmCount = 0; windowObject.confirm = () => { replacementConfirmCount += 1; return false; }; documentObject.querySelector("#run-button").click(); await waitFor(() => replacementConfirmCount === 1, "unsaved Result replacement confirmation"); assertEqual(documentObject.documentElement.dataset.runState, "done", "declined replacement keeps result"); windowObject.confirm = originalConfirm; 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()); assert( !documentObject.documentElement.dataset.unsavedWork.includes("Result"), "Result export clears Result dirty state", ); 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"); 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( () => 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", ); dispatchValue( windowObject, documentObject.querySelector("#array-preset"), "fermat-spiral-192", ); await waitFor( () => documentObject.querySelector("#array-title").textContent.includes("Fermat") && documentObject.documentElement.dataset.cameraValid === "true" && !documentObject.querySelector("#run-button").disabled, "Fermat comparison array", ); 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"); const historyText = documentObject.querySelector("#history-list").textContent; assert(historyText.includes("rectangular baseline"), "baseline history retained"); assert(historyText.includes("Fermat spiral"), "comparison array history added"); documentObject.querySelector("#export-history-button").click(); const historyDownload = await waitFor( () => downloads.find((download) => download.fileName.endsWith(".comparison-history.json")), "comparison history download", ); const historyDocument = await fetch(historyDownload.url).then((response) => response.json()); assertEqual(historyDocument.kind, "micarrsim.comparison-history", "comparison history kind"); assertEqual(historyDocument.items.length, 2, "both comparison summaries exported"); assert( !documentObject.documentElement.dataset.unsavedWork.includes("비교 기록"), "history export clears comparison dirty state", ); documentObject.querySelector("#export-result-button").click(); documentObject.querySelector("#export-experiment-button").click(); await waitFor( () => !documentObject.documentElement.dataset.unsavedWork.includes("Result") && !documentObject.documentElement.dataset.unsavedWork.includes("Experiment"), "final Camera documents saved", ); assertEqual(documentObject.documentElement.dataset.unsavedWork, "", "all Camera work is saved"); } finally { windowObject.confirm = originalConfirm; windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick; windowObject.URL.revokeObjectURL = originalRevoke; for (const url of objectUrls) originalRevoke(url); frame.remove(); } });