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, 4, "four 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()); assertEqual(resultDocument.schemaVersion, "1.1.0", "exported sampling-aware Result version"); 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"); documentObject.querySelector("#load-music-example-button").click(); await waitFor( () => documentObject.querySelector("#algorithm").value === "music" && documentObject.querySelector("#microphone-count").textContent === "12 ch" && documentObject.documentElement.dataset.cameraValid === "true" && !documentObject.querySelector("#run-button").disabled, "quick MUSIC example load", ); assert( documentObject.querySelector("#workload-summary").textContent.includes( "1 analog hypotheses / 1 ADC bins", ) && documentObject.querySelector("#workload-summary").textContent.includes( "cap", ), "MUSIC workload exposes hypotheses and hard cap", ); documentObject.querySelector("#run-button").click(); await waitFor( () => documentObject.documentElement.dataset.runState === "done" && documentObject.documentElement.dataset.runAlgorithm === "music", "quick MUSIC example run", 30000, ); assertEqual( documentObject.documentElement.dataset.resultBandCount, "1", "quick MUSIC example returns one map", ); const downloadCountBeforeMusic = downloads.length; documentObject.querySelector("#export-result-button").click(); const musicDownload = await waitFor( () => downloads.length > downloadCountBeforeMusic ? downloads.at(-1) : null, "MUSIC Result JSON download", ); const musicResult = await fetch(musicDownload.url).then( (response) => response.json(), ); assertEqual(musicResult.algorithm, "music", "MUSIC example export"); assertEqual( musicResult.musicModel.hypothesisSource, "experiment-band-not-scene-truth", "MUSIC export never claims Scene-truth carrier selection", ); assertEqual( musicResult.bands[0].frequencyObservation .configuredAnalogFrequencyHz, 1125, "configured analog carrier is exported", ); 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 = "48 kHz DC-edge tone"; secondSource.position = [-0.12, 0.09, 2]; secondSource.signal.frequency = 48000; secondSource.signal.phaseDeg = 45; const nyquistEdgeSource = structuredClone(scene.sources[0]); nyquistEdgeSource.id = "source-3"; nyquistEdgeSource.name = "72 kHz Nyquist-edge tone"; nyquistEdgeSource.position = [0.18, -0.12, 2]; nyquistEdgeSource.signal.frequency = 72000; nyquistEdgeSource.signal.phaseDeg = 90; scene.sources[0].name = "47 kHz aliased tone"; scene.sources[0].signal.frequency = 47000; scene.sources.push(secondSource, nyquistEdgeSource); scene.ambient.push({ id: "ambient-1", gainDb: -30, signal: { type: "tone", frequency: 46800, phaseDeg: 20 }, }); 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 with independent provenance labels", ); 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("연결 대기"), "provenance labels do not create 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 preservedDownload = await waitFor( () => downloads.find((download) => download.fileName.endsWith(".experiment.json")), "provenance-preserving Experiment export", ); const preservedExperiment = await fetch(preservedDownload.url).then((response) => response.json()); assertEqual( preservedExperiment.sceneId, experiment.sceneId, "import preserves the Experiment Scene provenance label", ); assertEqual( preservedExperiment.arrayId, experiment.arrayId, "import preserves the Experiment Array provenance label", ); assertEqual( JSON.stringify(preservedExperiment.frequencyBands), JSON.stringify(expectedBands), "provenance preservation keeps every imported band", ); assertEqual( (await validateExperiment(preservedExperiment, { scene: openScene, arrayDefinition: openArrayDefinition, })).length, 0, "provenance labels may differ from the open Scene and Array", ); downloads.length = 0; const aliasedExperiment = structuredClone(experiment); aliasedExperiment.id = "above-nyquist-after-rebind"; aliasedExperiment.sceneId = openScene.id; aliasedExperiment.arrayId = openArrayDefinition.id; aliasedExperiment.frequencyBands[0].minHz = openScene.sampleRate / 2 + 800; aliasedExperiment.frequencyBands[0].maxHz = openScene.sampleRate / 2 + 1_000; aliasedExperiment.frequencyBands[1].minHz = openScene.sampleRate / 2 - 1_000; aliasedExperiment.frequencyBands[1].maxHz = openScene.sampleRate / 2 - 800; assertEqual( (await validateExperiment(aliasedExperiment, { scene: openScene, arrayDefinition: openArrayDefinition, })).length, 0, "above-Nyquist observation band remains valid with a Scene context", ); const aliasedFile = new windowObject.File( [JSON.stringify(aliasedExperiment)], "above-nyquist-after-rebind.experiment.json", { type: "application/json" }, ); dispatchFiles( windowObject, documentObject.querySelector("#experiment-input"), [aliasedFile], ); await waitFor( () => documentObject.documentElement.dataset.inputBusy === "false" && documentObject.documentElement.dataset.cameraValid === "true" && documentObject.querySelector("#experiment-id").value === aliasedExperiment.id, "above-Nyquist Experiment import", ); assertEqual( documentObject.querySelector("#experiment-id").value, aliasedExperiment.id, "above-Nyquist Experiment is assigned instead of rejected", ); assertEqual( JSON.stringify(readBandEditors()), JSON.stringify(aliasedExperiment.frequencyBands), "above-Nyquist requested bands remain explicit in the editor", ); const samplingWarning = documentObject.querySelector("#sampling-warning"); assert(!samplingWarning.hidden, "above-Nyquist steering warning remains visible"); assert( samplingWarning.textContent.includes("ADC FFT bin") && samplingWarning.textContent.includes("원래 analog carrier를 사용하지 않습니다") && samplingWarning.textContent.includes("같은 ADC bin 집합") && samplingWarning.textContent.includes("order 1/inverted"), "warning states that localization does not use the original carrier", ); dispatchFiles(windowObject, documentObject.querySelector("#experiment-input"), [experimentFile]); await waitFor( () => documentObject.documentElement.dataset.inputBusy === "false" && documentObject.querySelector("#experiment-id").value === experiment.id, "restore original multi-band Experiment", ); 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 === 3 && 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", ); assert( documentObject.querySelector("#source-summary").textContent.includes( "48000 Hz tone → ADC 0 Hz alias (signed +0 Hz) · DC phase-degenerate edge", ), "Camera summary keeps the aliased exact-DC phase-degenerate edge visible", ); assert( documentObject.querySelector("#source-summary").textContent.includes( "72000 Hz tone → ADC 24000 Hz alias (signed +24000 Hz) · Nyquist phase-degenerate edge", ), "Camera summary keeps the aliased exact-Nyquist phase-degenerate edge visible", ); const sceneAliasWarning = documentObject.querySelector("#sampling-warning"); assert(!sceneAliasWarning.hidden, "aliased Scene tone keeps the sampling warning visible"); assert( sceneAliasWarning.textContent.includes("선택 ADC bin으로 접힌 Scene tone") && sceneAliasWarning.textContent.includes("tone-1k ← source source-1 47000→ADC 1000 Hz") && sceneAliasWarning.textContent.includes("tone-1.2k ← ambient ambient-1 46800→ADC 1200 Hz"), "baseband bands disclose contributing source and ambient aliases", ); 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", ); 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, "aliased Scene tone snapshot setup", ); documentObject.querySelector("#run-button").click(); await waitFor( () => documentObject.documentElement.dataset.runState === "done", "aliased Scene tone snapshot", 30000, ); const metricText = documentObject.querySelector("#metric-grid").textContent; assert( metricText.includes("tone-1k · ADC steering") && metricText.includes("source source-1 47000→ADC 1000 Hz"), "baseband source-alias steering metric is rendered", ); assert( metricText.includes("tone-1.2k · ADC steering") && metricText.includes("ambient ambient-1 46800→ADC 1200 Hz"), "baseband ambient-alias steering metric is rendered", ); 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, experiment.sceneId, "Experiment keeps its Scene provenance label after Scene replacement", ); assertEqual( exportedExperiment.arrayId, experiment.arrayId, "Experiment keeps its Array provenance label after Array replacement", ); } 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(); } }); test("Acoustic Camera full-band Activity keeps raw ADC evidence separate from localization", async () => { const frame = document.createElement("iframe"); frame.src = "/apps/acoustic-camera/?integration-test=activity-full-band-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 Activity iframe load failed")), { once: true }, ); }); const windowObject = frame.contentWindow; const documentObject = frame.contentDocument; const downloads = []; const objectUrls = []; let confirmCount = 0; 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); windowObject.confirm = () => { confirmCount += 1; return true; }; try { await waitFor( () => documentObject.documentElement.dataset.appReady === "true", "Acoustic Camera Activity initialization", ); await waitFor( () => documentObject.documentElement.dataset.cameraValid === "true", "Acoustic Camera Activity initial validation", ); assert( documentObject.querySelector("#activity-export-button").disabled, "Activity export starts disabled without a report", ); assert( documentObject.querySelector("#activity-frame").disabled, "Activity frame scrubber starts disabled without a report", ); documentObject.querySelector("#load-music-example-button").click(); await waitFor( () => documentObject.documentElement.dataset.inputBusy === "false" && documentObject.documentElement.dataset.cameraValid === "true" && documentObject.querySelector("#algorithm").value === "music" && documentObject.querySelector("#microphone-count").textContent === "12 ch", "Activity quick MUSIC example load", ); const sceneWithMissingBackground = await loadJson( "/assets/sample-scenes/music-single-tone.scene.json", ); sceneWithMissingBackground.mix.normalization = "peak"; sceneWithMissingBackground.assets = [ { id: "unbound-background", kind: "image", fileName: "unbound-background.png", mimeType: "image/png", }, ]; sceneWithMissingBackground.image = { assetId: "unbound-background", widthPixels: 640, heightPixels: 480, plane: { z: 2, xMin: -1, xMax: 1, yMin: -0.75, yMax: 0.75, }, }; dispatchFiles( windowObject, documentObject.querySelector("#scene-input"), [ new windowObject.File( [JSON.stringify(sceneWithMissingBackground)], "music-with-unbound-background.scene.json", { type: "application/json" }, ), ], ); await waitFor( () => documentObject.documentElement.dataset.inputBusy === "false" && documentObject.documentElement.dataset.cameraValid === "false" && documentObject.querySelector("#validation-message").textContent.includes( "unbound-background", ) && !documentObject.querySelector("#activity-run-button").disabled, "missing background image does not block audio Activity analysis", ); const activityMonitor = documentObject.querySelector("#activity-monitor"); activityMonitor.open = true; assertEqual( documentObject.querySelector("#activity-algorithm").value, "os-cfar", "default full-band detector is OS-CFAR", ); assert( activityMonitor.querySelector(".activity-monitor__copy").textContent.includes( "Scene", ) && activityMonitor.querySelector(".activity-monitor__copy").textContent.includes( "검출기에 전달하지", ) && activityMonitor.querySelector(".activity-monitor__copy").textContent.includes( "ADC", ) && activityMonitor.querySelector(".activity-monitor__copy").textContent.includes( "alias order", ), "Activity copy separates ADC observation from Scene truth and analog alias order", ); const musicBandMaximum = documentObject.querySelector( '#band-list .band-editor input[data-band-key="maxHz"]', ); const originalMusicBandMaximum = musicBandMaximum.valueAsNumber; dispatchValue( windowObject, musicBandMaximum, originalMusicBandMaximum + 2 * (48000 / 512), ); await waitFor( () => documentObject.documentElement.dataset.cameraValid === "false" && documentObject.querySelector("#run-button").disabled && !documentObject.querySelector("#activity-run-button").disabled, "Activity remains ready when an unused MUSIC band is invalid", ); documentObject.querySelector("#activity-run-button").click(); await waitFor( () => documentObject.documentElement.dataset.activityState === "done" && documentObject.documentElement.dataset.activityAlgorithm === "os-cfar", "default OS-CFAR Activity run", 45000, ); dispatchValue( windowObject, documentObject.querySelector( '#band-list .band-editor input[data-band-key="maxHz"]', ), originalMusicBandMaximum, ); await waitFor( () => documentObject.documentElement.dataset.cameraValid === "false" && documentObject.documentElement.dataset.activityState === "done" && documentObject.querySelector("#validation-message").textContent.includes( "unbound-background", ), "restore localization-only MUSIC band after independent Activity run", ); assertEqual( documentObject.documentElement.dataset.runState, "idle", "Activity run does not create a localization result", ); assert( documentObject.querySelector("#export-result-button").disabled, "localization Result export remains unavailable", ); const caveatText = documentObject.querySelector("#activity-result-list .activity-caveat") ?.textContent ?? ""; assert( caveatText.includes("IID exponential") && caveatText.includes("tested CUT") && caveatText.includes("confidence") && caveatText.includes("frame 전체"), "UI states the nominal per-CUT statistical limits", ); const activityResultText = documentObject.querySelector("#activity-result-list").textContent; assert( documentObject.querySelector("#activity-frame-label").textContent.includes( "start", ) && documentObject.querySelector("#activity-frame-label").textContent.includes( "available", ), "Activity frame distinguishes descriptive start from causal availability", ); assert( activityResultText.includes("CFAR tested 범위") && activityResultText.includes("full reference window") && activityResultText.includes("untested"), "UI discloses the actual CFAR coverage and untouched edge bins", ); assert( activityResultText.includes("end-to-end 전처리는 미래 sample에 의존"), "UI discloses whole-render peak-normalization causality", ); const toneHypothesisInput = await waitFor( () => documentObject.querySelector( '#activity-result-list input[data-activity-analog-hypothesis="true"]' + '[data-activity-bin="12"]', ), "detected 1125 Hz raw Activity row", 15000, ); const toneRow = toneHypothesisInput.closest(".activity-result-row"); assert( toneRow.querySelector("strong").textContent.includes("ADC bin 12") && toneRow.querySelector("strong").textContent.includes("1125"), "detected tone is expressed as an observed ADC bin", ); const rawText = [...toneRow.children] .find((child) => child.tagName === "SPAN")?.textContent ?? ""; assert( rawText.includes("CUT") && rawText.includes("ref") && rawText.includes("threshold") && rawText.includes("ratio"), "detected row exposes raw CUT, reference, threshold, and ratio", ); assertEqual( toneHypothesisInput.valueAsNumber, 1125, "analog hypothesis starts at the observed ADC center without claiming alias order", ); dispatchValue(windowObject, toneHypothesisInput, 49125, "input"); toneRow.querySelector("button[data-add-activity-band]").click(); const addedBandEditor = await waitFor( () => [...documentObject.querySelectorAll("#band-list .band-editor")] .find((editor) => editor.querySelector('input[data-band-key="id"]')?.value === "activity-1"), "above-Nyquist Activity hypothesis band addition", ); const addedMinimumHz = Number( addedBandEditor.querySelector('input[data-band-key="minHz"]').value, ); const addedMaximumHz = Number( addedBandEditor.querySelector('input[data-band-key="maxHz"]').value, ); assert( Math.abs((addedMinimumHz + addedMaximumHz) / 2 - 49125) <= 1e-9, "explicit analog hypothesis center", ); assert( Math.abs(addedMaximumHz - addedMinimumHz - 48000 / 512) <= 1e-9, "one FFT-cell candidate band width", ); assert( (addedMinimumHz + addedMaximumHz) / 2 > 48000 / 2, "above-Nyquist analog hypothesis is retained", ); await waitFor( () => documentObject.documentElement.dataset.cameraValid === "false" && documentObject.documentElement.dataset.activityState === "done" && documentObject.querySelector("#validation-message").textContent.includes( "unbound-background", ), "above-Nyquist Activity band with unrelated missing image", ); assert( documentObject.querySelector("#run-status").textContent.includes("ADC bin 12") && documentObject.querySelector("#run-status").textContent.includes( "다른 alias order", ), "band addition labels ADC folding and keeps alias-order choice explicit", ); const activityDownloadStart = downloads.length; documentObject.querySelector("#activity-export-button").click(); const activityDownload = await waitFor( () => downloads.slice(activityDownloadStart) .find((download) => download.fileName.endsWith(".activity.json")), "OS-CFAR Activity JSON download", ); const activityDocument = await fetch(activityDownload.url).then( (response) => response.json(), ); assertEqual(activityDocument.kind, "micarrsim.activity-report", "Activity document kind"); assertEqual(activityDocument.provenanceOnlyIds, true, "Scene/Array/Experiment are provenance IDs"); assertEqual( activityDocument.detectorSceneTruthInput, false, "outer Activity report denies Scene-truth detector input", ); const workerReport = activityDocument.report; const analysis = workerReport.analysis; assertEqual(workerReport.algorithm, "os-cfar", "exported detector identity"); assertEqual( workerReport.channel.selection, "user-selected-single-physical-microphone", "one explicit physical microphone", ); assertEqual(workerReport.channel.microphoneFusion, "none", "no microphone fusion"); assertEqual( workerReport.input.sceneSourceTruthPassedToDetector, false, "worker does not receive Scene source truth as detector input", ); assertEqual( workerReport.input.sceneSourceTruthUsedByPhysicalSimulator, true, "report distinguishes physical Scene synthesis from detector input", ); assertEqual( workerReport.input.synthesizedMicrophoneCount, 1, "only the explicitly selected microphone is synthesized for Activity", ); assertEqual( workerReport.input.analogAliasOrderResolved, false, "worker reports ADC observation without resolving analog alias order", ); assertEqual( workerReport.input.adcRangeHz.join(","), "0,24000", "Activity export reports only the observable ADC range", ); assertEqual(workerReport.localization, undefined, "worker report has no localization"); assertEqual(workerReport.scene, undefined, "worker report contains no Scene source list"); assertEqual(activityDocument.localization, undefined, "document has no localization payload"); assertEqual( workerReport.signalStats.windowing.arrivingSourceIds, undefined, "Activity artifact omits arriving source identities", ); assertEqual( workerReport.signalStats .explicitSceneSourceIdentitiesAndArrivalsIncluded, false, "report narrows its truth-free claim to explicit identities and arrivals", ); assertEqual( workerReport.signalStats.sourceDerivedAggregatePhysicalAuditIncluded, true, "report discloses aggregate source-derived physical audit statistics", ); assertEqual( workerReport.signalStats.preprocessing.normalizationScope, "selected-physical-microphone-whole-render", "peak normalization is scoped to the selected microphone", ); assertEqual( workerReport.signalStats.preprocessing.futureDependentNormalization, true, "whole-render peak normalization is marked future-dependent", ); assertEqual( workerReport.signalStats.preprocessing.endToEndCausal, false, "peak-normalized Activity pipeline does not claim end-to-end causality", ); assertEqual( workerReport.signalStats.windowing.sourceArrivals, undefined, "Activity artifact omits source-arrival truth", ); assertEqual( workerReport.signalStats.workingSet.maximumTransientId, undefined, "Activity artifact omits source-scoped working-set identity", ); const model = analysis.detectorModel; assertEqual( model.parameters.nominalPfaPerTestedCutUnderIidExponentialModel, 0.001, "nominal Pfa remains explicitly model-qualified", ); assertEqual( model.calibration.status, "nominal-model-only-not-empirically-calibrated", "nominal Pfa is not represented as empirical calibration", ); assertEqual( model.calibration.globalFrameFalseAlarmProbability, null, "no invented frame-global false-alarm probability", ); assert( model.calibration.notCalibratedReasons.includes( "hann-window-bin-independence-is-not-guaranteed", ), "Hann bin dependence limitation is exported", ); assertEqual(model.processing.clustering, false, "no bin clustering"); assertEqual(model.processing.temporalPersistence, false, "no temporal persistence"); assertEqual(model.processing.detectorFusion, false, "no detector fusion"); assertEqual(model.processing.microphoneFusion, false, "no microphone fusion metadata"); assertEqual(model.processing.bandFusion, false, "no band fusion"); const expectedFrameCount = 1 + Math.floor( (workerReport.input.sampleCount - workerReport.input.fftSize) / workerReport.input.hopSize, ); const expectedBinCount = workerReport.input.fftSize / 2 + 1; assertEqual(analysis.frames.length, expectedFrameCount, "every complete frame retained"); assertEqual(analysis.summary.frameCount, expectedFrameCount, "frame summary is lossless"); let exportedBinCount = 0; for (const exportedFrame of analysis.frames) { assertEqual(exportedFrame.bins.length, expectedBinCount, "every ADC bin retained"); assertEqual( exportedFrame.testedBinCount + exportedFrame.untestedBinCount, expectedBinCount, "tested and untested states cover a whole frame", ); for (let binIndex = 0; binIndex < exportedFrame.bins.length; binIndex += 1) { const bin = exportedFrame.bins[binIndex]; assertEqual(bin.adcBin, binIndex, "ADC bin order is preserved"); assert(Number.isFinite(bin.cutPower) && bin.cutPower >= 0, "raw CUT power is finite"); assert( bin.status === "detected" || bin.status === "not-detected" || bin.status === "untested", "every bin has an explicit decision state", ); } exportedBinCount += exportedFrame.bins.length; } assertEqual( exportedBinCount, expectedFrameCount * expectedBinCount, "Activity JSON preserves the full frame/bin Cartesian product", ); assertEqual( analysis.summary.binRecordCount, exportedBinCount, "raw bin-record summary matches the payload", ); assertEqual(analysis.frames[0].bins[0].reason, "real-only-dc-bin", "DC remains untested"); assertEqual( analysis.frames[0].bins.at(-1).reason, "real-only-nyquist-bin", "Nyquist remains untested", ); const exportedTone = analysis.frames .map((exportedFrame) => exportedFrame.bins[12]) .find((bin) => bin.status === "detected"); assert(exportedTone, "the UI row comes from a retained raw detected-bin record"); assert( Number.isFinite(exportedTone.referencePower) && Number.isFinite(exportedTone.thresholdPower), "detected tone retains raw reference and threshold statistics", ); assert( exportedTone.thresholdRatio === null ? exportedTone.thresholdRatioStatus === "positive-infinity-zero-threshold" : Number.isFinite(exportedTone.thresholdRatio) && exportedTone.thresholdRatio > 1, "threshold ratio is raw and JSON-safe", ); const experimentDownloadStart = downloads.length; documentObject.querySelector("#export-experiment-button").click(); const experimentDownload = await waitFor( () => downloads.slice(experimentDownloadStart) .find((download) => download.fileName.endsWith(".experiment.json")), "Activity hypothesis Experiment JSON download", ); const experimentDocument = await fetch(experimentDownload.url).then( (response) => response.json(), ); const addedBand = experimentDocument.frequencyBands.find( (band) => band.id === "activity-1", ); assert(addedBand, "explicit Activity hypothesis is exported as an Experiment band"); assert( Math.abs((addedBand.minHz + addedBand.maxHz) / 2 - 49125) <= 1e-9, "exported analog center", ); assert( (addedBand.minHz + addedBand.maxHz) / 2 > workerReport.input.adcRangeHz[1], "Experiment retains the above-Nyquist analog hypothesis", ); const [musicArray, musicScene] = await Promise.all([ loadJson("/assets/array-geometries/rectangular-4x3-450mm.array.json"), loadJson("/assets/sample-scenes/music-single-tone.scene.json"), ]); assertEqual( (await validateExperiment(experimentDocument, { arrayDefinition: musicArray, scene: musicScene, })).length, 0, "above-Nyquist explicit hypothesis remains a valid Experiment", ); assertEqual(confirmCount, 0, "saved Activity report is replaced without a dirty-report prompt"); dispatchValue( windowObject, documentObject.querySelector("#activity-algorithm"), "dixon-spectral-flux", ); assert( !documentObject.querySelector("#activity-peak-picker-enabled").checked, "spectral-flux peak picker remains opt-in", ); await waitFor( () => !documentObject.querySelector("#activity-run-button").disabled && !documentObject.querySelector("#activity-flux-options").hidden, "raw-only spectral flux readiness", ); documentObject.querySelector("#activity-run-button").click(); await waitFor( () => documentObject.documentElement.dataset.activityState === "done" && documentObject.documentElement.dataset.activityAlgorithm === "dixon-spectral-flux", "raw-only spectral flux Activity run", 45000, ); const fluxText = documentObject.querySelector("#activity-result-list").textContent; assert( fluxText.includes("raw onset novelty") && fluxText.includes("probability") && fluxText.includes("Peak picker를 적용하지 않아"), "spectral flux UI remains raw novelty without an invented event threshold", ); const fluxDownloadStart = downloads.length; documentObject.querySelector("#activity-export-button").click(); const fluxDownload = await waitFor( () => downloads.slice(fluxDownloadStart) .find((download) => download.fileName.endsWith(".activity.json")), "raw spectral-flux Activity JSON download", ); const fluxDocument = await fetch(fluxDownload.url).then( (response) => response.json(), ); assertEqual( fluxDocument.report.algorithm, "dixon-spectral-flux", "raw spectral-flux identity", ); assertEqual( fluxDocument.report.peakPicking, undefined, "no implicit spectral-flux peak picker", ); assertEqual( fluxDocument.report.analysis.rawOdf.length, fluxDocument.report.analysis.frameCount, "all raw spectral-flux frame values retained", ); assertEqual( fluxDocument.report.analysis.positiveDifferences.length, fluxDocument.report.analysis.frameCount * fluxDocument.report.analysis.binCount, "all raw per-frame/per-bin positive differences retained", ); assertEqual(confirmCount, 0, "Activity exports clear report-dirty state"); } finally { windowObject.confirm = originalConfirm; windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick; windowObject.URL.revokeObjectURL = originalRevoke; for (const url of objectUrls) originalRevoke(url); frame.remove(); } });