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 workflowSteps = [...documentObject.querySelectorAll("#camera-workflow [data-workflow-step]")]; assertEqual(workflowSteps.length, 4, "four-step workflow is discoverable"); assertEqual( workflowSteps.map((step) => step.dataset.workflowStep).join(","), "1,2,3,4", "workflow order", ); assert(documentObject.querySelector("#input-step").textContent.includes("입력 준비"), "input step label"); assert(documentObject.querySelector("#configure-step").textContent.includes("분석 설정"), "configure step label"); assert(documentObject.querySelector("#run-step").textContent.includes("실행 방식"), "run step label"); assert(documentObject.querySelector("#inspect-step").textContent.includes("결과 확인"), "inspect step label"); assert(documentObject.querySelector("#snapshot-mode").contains(documentObject.querySelector("#run-button")), "snapshot action is grouped with snapshot mode"); assert(documentObject.querySelector("#moving-mode").contains(documentObject.querySelector("#prepare-live-button")), "live action is grouped with moving mode"); assert(!documentObject.querySelector("#advanced-settings").open, "advanced settings start collapsed"); assert(!documentObject.querySelector("#array-details").open, "array developer detail starts collapsed"); assert(!documentObject.querySelector("#camera-help").open, "limitations help starts collapsed"); assertEqual(documentObject.querySelector("#acoustic-profile").value, "free-field", "default free-field profile"); assertEqual( documentObject.querySelector("#frequency-response-mode").value, "compensate", "known microphone response is compensated by default", ); assert( documentObject.querySelector("#acoustic-environment-summary").textContent.includes("sparse path"), "bounded acoustic cost is visible", ); assert( documentObject.querySelector("#acoustic-environment-summary").textContent.includes("≤ 24 dB"), "safe inverse-response boost limit is visible", ); const canvas = documentObject.querySelector("#scene-canvas"); await waitFor(() => canvas.width > 0 && canvas.height > 0, "scene canvas 초기 렌더"); await nextPaint(windowObject); assert(canvas.getBoundingClientRect().height >= 500, "scene canvas remains the dominant workspace"); const initialCanvas = canvas.toDataURL(); dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8); dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8); dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu"); assertEqual(documentObject.querySelector("#detection-threshold").value, "25", "default peak sensitivity"); dispatchValue(windowObject, documentObject.querySelector("#detection-threshold"), 10); await waitFor( () => documentObject.documentElement.dataset.cameraValid === "true" && !documentObject.querySelector("#run-button").disabled, "8×8 CPU 입력 검증", ); assertEqual( documentObject.querySelector("#run-button").textContent.trim(), "▶ Snapshot 실행", "snapshot label survives runtime validation", ); 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 >= 10, "result metrics rendered"); assert(documentObject.querySelector("#metric-grid").textContent.includes("음향 모델"), "acoustic model metric 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"); assertEqual( resultDocument.bands[0].evaluation.detectionThresholdRatio, 0.1, "selected peak sensitivity is exported", ); 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", ); const comparisonPresets = [ ["rectangular-2x2-450mm", "4 ch", "0.450 × 0.450 m"], ["rectangular-4x3-450mm", "12 ch", "0.450 × 0.450 m"], ["rectangular-8x6-450mm", "48 ch", "0.450 × 0.450 m"], ["rectangular-24x16-450mm", "384 ch", "0.450 × 0.450 m"], ["rectangular-16x12-compact", "192 ch", "0.120 × 0.120 m"], ["rectangular-16x12-large", "192 ch", "1.200 × 1.200 m"], ]; assertEqual( documentObject.querySelectorAll("#array-preset option").length, 9, "all built-in and custom array choices are visible", ); for (const [presetId, microphoneCount, aperture] of comparisonPresets) { dispatchValue(windowObject, documentObject.querySelector("#array-preset"), presetId); await waitFor( () => documentObject.querySelector("#array-preset").value === presetId && documentObject.querySelector("#microphone-count").textContent === microphoneCount && documentObject.querySelector("#array-meta").textContent.includes(aperture) && documentObject.documentElement.dataset.cameraValid === "true", `${presetId} preset`, ); } 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"); frame.style.width = "390px"; await nextPaint(windowObject); assert( documentObject.documentElement.scrollWidth <= documentObject.documentElement.clientWidth + 1, "compact Camera layout has no horizontal overflow", ); const finalWorkflowStep = workflowSteps.at(-1).getBoundingClientRect(); assert( finalWorkflowStep.right <= documentObject.documentElement.clientWidth + 1, "all workflow steps remain visible on a compact viewport", ); } finally { windowObject.confirm = originalConfirm; windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick; windowObject.URL.revokeObjectURL = originalRevoke; for (const url of objectUrls) originalRevoke(url); frame.remove(); } }); test("Acoustic Camera prepares, scrubs, plays, and pauses the moving-source example", 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 live iframe load failed")), { once: true, }); }); const windowObject = frame.contentWindow; const documentObject = frame.contentDocument; try { await waitFor( () => documentObject.documentElement.dataset.appReady === "true", "Acoustic Camera live initialization", ); documentObject.querySelector("#load-moving-example-button").click(); await waitFor( () => documentObject.documentElement.dataset.inputBusy === "false" && documentObject.documentElement.dataset.cameraValid === "true" && documentObject.documentElement.dataset.liveState === "idle" && documentObject.querySelector("#experiment-id").value === "moving-tone-live" && !documentObject.querySelector("#prepare-live-button").disabled, "valid moving example trajectory", ); dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8); dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8); dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu"); dispatchValue(windowObject, documentObject.querySelector("#live-map-rate"), 2); await waitFor( () => documentObject.documentElement.dataset.cameraValid === "true" && documentObject.documentElement.dataset.liveState === "idle" && documentObject.querySelector("#scan-columns").value === "8" && documentObject.querySelector("#scan-rows").value === "8" && documentObject.querySelector("#backend").value === "cpu" && documentObject.querySelector("#live-map-rate").value === "2" && !documentObject.querySelector("#prepare-live-button").disabled, "8x8 CPU 2 Hz live configuration", ); documentObject.querySelector("#prepare-live-button").click(); await waitFor( () => documentObject.documentElement.dataset.liveState === "ready", "moving-source live preparation", 45000, ); const frameCount = Number(documentObject.documentElement.dataset.liveFrameCount); assert(frameCount > 1, `multiple live frames: ${frameCount}`); assert(documentObject.querySelector("#live-status").textContent.includes("CPU"), "live backend is CPU"); dispatchValue(windowObject, documentObject.querySelector("#live-time"), 0.6, "input"); await waitFor( () => Number(documentObject.documentElement.dataset.liveTime) >= 0.599, "live scrub to a later time", ); const scrubbedTime = Number(documentObject.documentElement.dataset.liveTime); assert(Number(documentObject.documentElement.dataset.liveFrame) > 0, "scrub selects a later map frame"); documentObject.querySelector("#live-play-button").click(); await waitFor( () => documentObject.documentElement.dataset.liveState === "playing", "live playback start", ); await waitFor( () => Number(documentObject.documentElement.dataset.liveTime) > scrubbedTime + 0.05, "live playback time advance", 5000, ); documentObject.querySelector("#live-play-button").click(); await waitFor( () => documentObject.documentElement.dataset.liveState === "ready", "live playback pause", ); const pausedTime = Number(documentObject.documentElement.dataset.liveTime); await new Promise((resolve) => setTimeout(resolve, 120)); const timeAfterPause = Number(documentObject.documentElement.dataset.liveTime); assert( Math.abs(timeAfterPause - pausedTime) <= 0.01, `paused live time remains stable: ${pausedTime} -> ${timeAfterPause}`, ); } finally { frame.remove(); } });