1432 lines
61 KiB
JavaScript
1432 lines
61 KiB
JavaScript
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();
|
||
}
|
||
});
|