Files

1432 lines
61 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
});