import { validateExperiment, 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 })); } function dispatchFiles(windowObject, input, files) { const transfer = new windowObject.DataTransfer(); for (const file of files) transfer.items.add(file); input.files = transfer.files; input.dispatchEvent(new windowObject.Event("change", { 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("#algorithm").value, "das", "default DAS algorithm"); assertEqual(documentObject.querySelectorAll("#algorithm option").length, 3, "three runnable algorithms"); assert(!documentObject.querySelector("#postprocess"), "disabled postprocess advertisement is absent"); 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", ); assert(documentObject.querySelector("#workload-summary").textContent.includes("DAS"), "algorithm workload label"); assert(documentObject.querySelector("#workload-summary").textContent.includes("STFT"), "STFT transform cost"); assert( documentObject.querySelector("#workload-summary").textContent.includes("distance cache") && !documentObject.querySelector("#workload-summary").textContent.includes("steering"), "distance cache is labeled accurately", ); 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.runAlgorithm, "das", "actual baseline algorithm"); 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.algorithm, "das", "exported baseline algorithm"); assertEqual(resultDocument.bands[0].rawMap.scale, "power", "DAS exports a power map"); 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", ); dispatchValue(windowObject, documentObject.querySelector("#backend"), "webgpu"); dispatchValue(windowObject, documentObject.querySelector("#algorithm"), "gcc-phat"); await waitFor( () => documentObject.querySelector("#algorithm").value === "gcc-phat" && documentObject.querySelector("#backend").value === "auto" && documentObject.querySelector('#backend option[value="webgpu"]').disabled && documentObject.querySelector("#validation-message").textContent.includes("8개 이상"), "GCC-PHAT CPU capability and narrow-band warning", ); assert(documentObject.querySelector("#workload-summary").textContent.includes("ref pairs"), "GCC pair cost"); dispatchValue(windowObject, documentObject.querySelector("#algorithm"), "das-phat"); dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu"); await waitFor( () => documentObject.documentElement.dataset.cameraValid === "true" && documentObject.querySelector("#algorithm").value === "das-phat" && !documentObject.querySelector('#backend option[value="webgpu"]').disabled && !documentObject.querySelector("#run-button").disabled, "DAS-PHAT comparison configuration", ); 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.documentElement.dataset.runAlgorithm, "das-phat", "actual comparison algorithm"); assert(documentObject.querySelector("#result-badge").textContent.includes("DAS-PHAT"), "algorithm result badge"); assert(documentObject.querySelector("#canvas-badge").textContent.includes("DAS-PHAT"), "algorithm canvas badge"); assert(documentObject.querySelector("#metric-grid").textContent.includes("Truth concentration"), "generic concentration metric"); 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"); assert(historyText.includes("DAS-PHAT") && historyText.includes("DAS"), "both algorithms are compared"); 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"); assertEqual( historyDocument.items.map((item) => item.algorithm).sort().join(","), "das,das-phat", "both comparison algorithms exported", ); assert(historyDocument.items.every((item) => Number.isFinite(item.localizationMs)), "localization timing 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가 Experiment를 현재 입력에 연결하고 Scene·Array 재import에서도 다중 대역을 보존한다", async () => { const frame = document.createElement("iframe"); frame.src = "/apps/acoustic-camera/?integration-test=1&band-preservation=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 band-preservation 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() { if (this.download) downloads.push({ fileName: this.download, url: this.href }); }; windowObject.URL.revokeObjectURL = (url) => objectUrls.push(url); const readBandEditors = () => [...documentObject.querySelectorAll("#band-list .band-editor")].map((editor) => Object.fromEntries( [...editor.querySelectorAll("input[data-band-key]")].map((input) => [ input.dataset.bandKey, input.type === "number" ? Number(input.value) : input.value, ]), )); try { await waitFor( () => documentObject.documentElement.dataset.appReady === "true" && documentObject.documentElement.dataset.cameraValid === "true", "band-preservation Camera initialization", ); const builderLinks = [...documentObject.querySelectorAll("a.builder-link")]; assertEqual(builderLinks.length, 2, "Scene and Array Builder links"); for (const link of builderLinks) { assertEqual(link.target, "_blank", "Builder opens without unloading Camera"); assert(link.relList.contains("noopener"), "Builder tab cannot replace Camera through opener"); } const openScene = await loadJson("/assets/sample-scenes/single-tone.scene.json"); const scene = structuredClone(openScene); scene.id = "two-tone-band-preservation"; scene.name = "Two-tone band preservation"; const secondSource = structuredClone(scene.sources[0]); secondSource.id = "source-2"; secondSource.name = "1.2 kHz tone"; secondSource.position = [-0.12, 0.09, 2]; secondSource.signal.frequency = 1200; secondSource.signal.phaseDeg = 45; scene.sources.push(secondSource); const openArrayDefinition = await loadJson( "/assets/array-geometries/rectangular-16x12.array.json", ); const arrayDefinition = structuredClone(openArrayDefinition); arrayDefinition.id = "custom-array-band-preservation"; arrayDefinition.name = "Custom array band preservation"; const experiment = await loadJson("/assets/sample-scenes/single-tone.experiment.json"); experiment.id = "two-band-import-first"; experiment.name = "Two-band import first"; experiment.sceneId = scene.id; experiment.arrayId = arrayDefinition.id; experiment.frequencyBands.push({ id: "tone-1.2k", minHz: 1100, maxHz: 1300, color: "#58c7ff", }); const expectedBands = structuredClone(experiment.frequencyBands); const experimentFile = new windowObject.File( [JSON.stringify(experiment)], "two-band-import-first.experiment.json", { type: "application/json" }, ); dispatchFiles(windowObject, documentObject.querySelector("#experiment-input"), [experimentFile]); await waitFor( () => documentObject.documentElement.dataset.inputBusy === "false" && documentObject.documentElement.dataset.cameraValid === "true" && !documentObject.querySelector("#run-button").disabled, "Experiment import and automatic reference binding", ); assertEqual( documentObject.querySelector("#experiment-id").value, experiment.id, "Experiment document loads before its originally referenced Scene", ); assertEqual(readBandEditors().length, 2, "Experiment-first import renders every analysis band"); assertEqual( JSON.stringify(readBandEditors()), JSON.stringify(expectedBands), "Experiment-first import preserves band IDs, ranges, and colors", ); assert( !documentObject.querySelector("#validation-message").textContent.includes("sceneId") && !documentObject.querySelector("#validation-message").textContent.includes("arrayId") && !documentObject.querySelector("#run-status").textContent.includes("연결 대기"), "automatic binding does not leave a reference mismatch warning", ); assert( documentObject.querySelector("#run-status").textContent.includes("분석 대역 2개") && documentObject.querySelector("#run-status").textContent.includes("실행 준비 완료"), "Experiment import reaches a ready state with every band", ); documentObject.querySelector("#export-experiment-button").click(); const reboundDownload = await waitFor( () => downloads.find((download) => download.fileName.endsWith(".experiment.json")), "automatically rebound Experiment export", ); const reboundExperiment = await fetch(reboundDownload.url).then((response) => response.json()); assertEqual(reboundExperiment.sceneId, openScene.id, "import binds the open Scene ID"); assertEqual( reboundExperiment.arrayId, openArrayDefinition.id, "import binds the open Array ID", ); assertEqual( JSON.stringify(reboundExperiment.frequencyBands), JSON.stringify(expectedBands), "automatic reference binding preserves every imported band", ); assertEqual( (await validateExperiment(reboundExperiment, { scene: openScene, arrayDefinition: openArrayDefinition, })).length, 0, "rebound Experiment passes relationship validation", ); downloads.length = 0; const contextInvalidExperiment = structuredClone(experiment); contextInvalidExperiment.id = "nyquist-invalid-after-rebind"; contextInvalidExperiment.frequencyBands[0].maxHz = openScene.sampleRate / 2; assertEqual( (await validateExperiment(contextInvalidExperiment)).length, 0, "Nyquist fixture is valid without a Scene context", ); const contextInvalidFile = new windowObject.File( [JSON.stringify(contextInvalidExperiment)], "nyquist-invalid-after-rebind.experiment.json", { type: "application/json" }, ); dispatchFiles( windowObject, documentObject.querySelector("#experiment-input"), [contextInvalidFile], ); await waitFor( () => documentObject.documentElement.dataset.inputBusy === "false" && documentObject.documentElement.dataset.cameraValid === "true", "relationship-invalid Experiment rejection", ); assertEqual( documentObject.querySelector("#experiment-id").value, experiment.id, "context relationship validation rejects the imported Experiment before assignment", ); assertEqual( JSON.stringify(readBandEditors()), JSON.stringify(expectedBands), "failed relationship validation leaves the current bands unchanged", ); let replacementConfirmCount = 0; windowObject.confirm = () => { replacementConfirmCount += 1; return false; }; documentObject.querySelector("#load-moving-example-button").click(); assertEqual(replacementConfirmCount, 1, "full moving example asks before replacing bands"); assertEqual( JSON.stringify(readBandEditors()), JSON.stringify(expectedBands), "declining the full example keeps every analysis band", ); assert( documentObject.querySelector("#run-status").textContent.includes("유지"), "declined replacement explains that inputs were kept", ); windowObject.confirm = originalConfirm; const sceneFile = new windowObject.File( [JSON.stringify(scene)], "two-tone-band-preservation.scene.json", { type: "application/json" }, ); dispatchFiles(windowObject, documentObject.querySelector("#scene-input"), [sceneFile]); await waitFor( () => documentObject.documentElement.dataset.inputBusy === "false" && documentObject.querySelectorAll("#source-summary .source-line").length === 2 && documentObject.querySelectorAll("#band-list .band-editor").length === 2 && documentObject.documentElement.dataset.cameraValid === "true", "source-added Scene reimport", ); assertEqual( JSON.stringify(readBandEditors()), JSON.stringify(expectedBands), "Scene reimport preserves band IDs, ranges, and colors", ); assert( documentObject.querySelector("#run-status").textContent.includes("분석 대역 2개"), "Camera status keeps the preserved band count visible", ); const arrayFile = new windowObject.File( [JSON.stringify(arrayDefinition)], "custom-array-band-preservation.array.json", { type: "application/json" }, ); dispatchFiles(windowObject, documentObject.querySelector("#array-input"), [arrayFile]); await waitFor( () => documentObject.documentElement.dataset.inputBusy === "false" && documentObject.querySelectorAll("#band-list .band-editor").length === 2 && documentObject.documentElement.dataset.cameraValid === "true", "referenced Array reimport", ); assertEqual( JSON.stringify(readBandEditors()), JSON.stringify(expectedBands), "Array reimport also preserves every analysis band", ); assert( documentObject.querySelector("#run-status").textContent.includes("분석 대역 2개"), "ready status keeps the connected Experiment band count visible", ); documentObject.querySelector("#export-experiment-button").click(); const experimentDownload = await waitFor( () => downloads.find((download) => download.fileName.endsWith(".experiment.json")), "preserved Experiment export", ); const exportedExperiment = await fetch(experimentDownload.url).then((response) => response.json()); assertEqual( JSON.stringify(exportedExperiment.frequencyBands), JSON.stringify(expectedBands), "Experiment export preserves every band after Scene reimport", ); assertEqual(exportedExperiment.sceneId, scene.id, "Experiment rebinds only the Scene ID"); assertEqual(exportedExperiment.arrayId, arrayDefinition.id, "Experiment rebinds the Array ID"); } 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"), "auto"); 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 === "auto" && documentObject.querySelector("#live-map-rate").value === "2" && !documentObject.querySelector("#prepare-live-button").disabled, "8x8 Auto 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}`); assertEqual( Number(documentObject.documentElement.dataset.livePlanAnalyses), frameCount - 1, "Auto fallback retries GPU once then reuses one CPU localization plan", ); 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(); } });