642 lines
30 KiB
JavaScript
642 lines
30 KiB
JavaScript
import { validateResult } from "/packages/contracts/validation.js";
|
||
|
||
async function waitFor(predicate, label, timeoutMs = 30000) {
|
||
const deadline = performance.now() + timeoutMs;
|
||
while (performance.now() < deadline) {
|
||
const value = predicate();
|
||
if (value) return value;
|
||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||
}
|
||
throw new Error(`${label} 대기 시간이 초과되었습니다.`);
|
||
}
|
||
|
||
function dispatchValue(windowObject, element, value, eventName = "change") {
|
||
element.value = String(value);
|
||
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
|
||
}
|
||
|
||
function dispatchFiles(windowObject, input, files) {
|
||
const transfer = new windowObject.DataTransfer();
|
||
for (const file of files) transfer.items.add(file);
|
||
input.files = transfer.files;
|
||
input.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
|
||
}
|
||
|
||
async function loadJson(path) {
|
||
const response = await fetch(path);
|
||
assert(response.ok, `${path} load failed`);
|
||
return response.json();
|
||
}
|
||
|
||
async function nextPaint(windowObject) {
|
||
await new Promise((resolve) => windowObject.requestAnimationFrame(() =>
|
||
windowObject.requestAnimationFrame(resolve)));
|
||
}
|
||
|
||
test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름을 완주한다", async () => {
|
||
const frame = document.createElement("iframe");
|
||
frame.src = "/apps/acoustic-camera/?integration-test=1";
|
||
frame.style.cssText = "position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0";
|
||
document.body.append(frame);
|
||
await new Promise((resolve, reject) => {
|
||
frame.addEventListener("load", resolve, { once: true });
|
||
frame.addEventListener("error", () => reject(new Error("Acoustic Camera iframe load failed")), {
|
||
once: true,
|
||
});
|
||
});
|
||
|
||
const windowObject = frame.contentWindow;
|
||
const documentObject = frame.contentDocument;
|
||
const downloads = [];
|
||
const objectUrls = [];
|
||
const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click;
|
||
const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL);
|
||
const originalConfirm = windowObject.confirm.bind(windowObject);
|
||
windowObject.HTMLAnchorElement.prototype.click = function captureDownload() {
|
||
downloads.push({ fileName: this.download, url: this.href });
|
||
};
|
||
windowObject.URL.revokeObjectURL = (url) => objectUrls.push(url);
|
||
|
||
try {
|
||
await waitFor(
|
||
() => documentObject.documentElement.dataset.appReady === "true",
|
||
"Acoustic Camera 초기화",
|
||
);
|
||
await waitFor(
|
||
() => documentObject.documentElement.dataset.cameraValid === "true",
|
||
"기본 Camera 입력 검증",
|
||
);
|
||
|
||
const workflowSteps = [...documentObject.querySelectorAll("#camera-workflow [data-workflow-step]")];
|
||
assertEqual(workflowSteps.length, 4, "four-step workflow is discoverable");
|
||
assertEqual(
|
||
workflowSteps.map((step) => step.dataset.workflowStep).join(","),
|
||
"1,2,3,4",
|
||
"workflow order",
|
||
);
|
||
assert(documentObject.querySelector("#input-step").textContent.includes("입력 준비"), "input step label");
|
||
assert(documentObject.querySelector("#configure-step").textContent.includes("분석 설정"), "configure step label");
|
||
assert(documentObject.querySelector("#run-step").textContent.includes("실행 방식"), "run step label");
|
||
assert(documentObject.querySelector("#inspect-step").textContent.includes("결과 확인"), "inspect step label");
|
||
assert(documentObject.querySelector("#snapshot-mode").contains(documentObject.querySelector("#run-button")), "snapshot action is grouped with snapshot mode");
|
||
assert(documentObject.querySelector("#moving-mode").contains(documentObject.querySelector("#prepare-live-button")), "live action is grouped with moving mode");
|
||
assert(!documentObject.querySelector("#advanced-settings").open, "advanced settings start collapsed");
|
||
assert(!documentObject.querySelector("#array-details").open, "array developer detail starts collapsed");
|
||
assert(!documentObject.querySelector("#camera-help").open, "limitations help starts collapsed");
|
||
assertEqual(documentObject.querySelector("#algorithm").value, "das", "default DAS algorithm");
|
||
assertEqual(documentObject.querySelectorAll("#algorithm option").length, 3, "three runnable algorithms");
|
||
assert(!documentObject.querySelector("#postprocess"), "disabled postprocess advertisement is absent");
|
||
assertEqual(documentObject.querySelector("#acoustic-profile").value, "free-field", "default free-field profile");
|
||
assertEqual(
|
||
documentObject.querySelector("#frequency-response-mode").value,
|
||
"compensate",
|
||
"known microphone response is compensated by default",
|
||
);
|
||
assert(
|
||
documentObject.querySelector("#acoustic-environment-summary").textContent.includes("sparse path"),
|
||
"bounded acoustic cost is visible",
|
||
);
|
||
assert(
|
||
documentObject.querySelector("#acoustic-environment-summary").textContent.includes("≤ 24 dB"),
|
||
"safe inverse-response boost limit is visible",
|
||
);
|
||
assert(documentObject.querySelector("#workload-summary").textContent.includes("DAS"), "algorithm workload label");
|
||
assert(documentObject.querySelector("#workload-summary").textContent.includes("STFT"), "STFT transform cost");
|
||
assert(
|
||
documentObject.querySelector("#workload-summary").textContent.includes("distance cache") &&
|
||
!documentObject.querySelector("#workload-summary").textContent.includes("steering"),
|
||
"distance cache is labeled accurately",
|
||
);
|
||
|
||
const canvas = documentObject.querySelector("#scene-canvas");
|
||
await waitFor(() => canvas.width > 0 && canvas.height > 0, "scene canvas 초기 렌더");
|
||
await nextPaint(windowObject);
|
||
assert(canvas.getBoundingClientRect().height >= 500, "scene canvas remains the dominant workspace");
|
||
const initialCanvas = canvas.toDataURL();
|
||
|
||
dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8);
|
||
dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8);
|
||
dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu");
|
||
assertEqual(documentObject.querySelector("#detection-threshold").value, "25", "default peak sensitivity");
|
||
dispatchValue(windowObject, documentObject.querySelector("#detection-threshold"), 10);
|
||
await waitFor(
|
||
() => documentObject.documentElement.dataset.cameraValid === "true" &&
|
||
!documentObject.querySelector("#run-button").disabled,
|
||
"8×8 CPU 입력 검증",
|
||
);
|
||
assertEqual(
|
||
documentObject.querySelector("#run-button").textContent.trim(),
|
||
"▶ Snapshot 실행",
|
||
"snapshot label survives runtime validation",
|
||
);
|
||
assertEqual(documentObject.querySelector("#grid-preset").value, "custom", "custom grid preset");
|
||
assert(
|
||
documentObject.documentElement.dataset.unsavedWork.includes("Experiment"),
|
||
"edited Experiment is marked unsaved",
|
||
);
|
||
|
||
documentObject.querySelector("#run-button").click();
|
||
await waitFor(
|
||
() => documentObject.documentElement.dataset.runState === "done",
|
||
"8×8 CPU simulation",
|
||
30000,
|
||
);
|
||
assertEqual(documentObject.documentElement.dataset.runBackend, "cpu", "actual backend");
|
||
assertEqual(documentObject.documentElement.dataset.runAlgorithm, "das", "actual baseline algorithm");
|
||
assertEqual(documentObject.documentElement.dataset.resultBandCount, "1", "result band count");
|
||
assert(documentObject.querySelectorAll("#metric-grid .metric-card").length >= 10, "result metrics rendered");
|
||
assert(documentObject.querySelector("#metric-grid").textContent.includes("음향 모델"), "acoustic model metric rendered");
|
||
assert(!documentObject.querySelector("#metric-grid").classList.contains("is-hidden"), "metrics visible");
|
||
assert(documentObject.querySelector("#result-badge").textContent.includes("CPU"), "backend badge rendered");
|
||
assert(documentObject.documentElement.dataset.unsavedWork.includes("Result"), "completed Result is unsaved");
|
||
assert(
|
||
documentObject.documentElement.dataset.unsavedWork.includes("비교 기록"),
|
||
"comparison summary is unsaved",
|
||
);
|
||
let replacementConfirmCount = 0;
|
||
windowObject.confirm = () => {
|
||
replacementConfirmCount += 1;
|
||
return false;
|
||
};
|
||
documentObject.querySelector("#run-button").click();
|
||
await waitFor(() => replacementConfirmCount === 1, "unsaved Result replacement confirmation");
|
||
assertEqual(documentObject.documentElement.dataset.runState, "done", "declined replacement keeps result");
|
||
windowObject.confirm = originalConfirm;
|
||
await nextPaint(windowObject);
|
||
assert(canvas.toDataURL() !== initialCanvas, "result heatmap changed the scene canvas");
|
||
|
||
documentObject.querySelector("#export-result-button").click();
|
||
const resultDownload = await waitFor(
|
||
() => downloads.find((download) => download.fileName.endsWith(".result.json")),
|
||
"Result JSON download",
|
||
);
|
||
const resultDocument = await fetch(resultDownload.url).then((response) => response.json());
|
||
assert(
|
||
!documentObject.documentElement.dataset.unsavedWork.includes("Result"),
|
||
"Result export clears Result dirty state",
|
||
);
|
||
const [scene, experiment] = await Promise.all([
|
||
loadJson("/assets/sample-scenes/single-tone.scene.json"),
|
||
loadJson("/assets/sample-scenes/single-tone.experiment.json"),
|
||
]);
|
||
experiment.scan.columns = 8;
|
||
experiment.scan.rows = 8;
|
||
experiment.beamforming.backend = "cpu";
|
||
const resultIssues = await validateResult(resultDocument, { scene, experiment });
|
||
assertEqual(resultIssues.length, 0, `downloaded Result validation: ${resultIssues
|
||
.map((issue) => `${issue.path}: ${issue.message}`)
|
||
.join(" · ")}`);
|
||
assertEqual(resultDocument.backend, "cpu", "exported backend");
|
||
assertEqual(resultDocument.algorithm, "das", "exported baseline algorithm");
|
||
assertEqual(resultDocument.bands[0].rawMap.scale, "power", "DAS exports a power map");
|
||
assertEqual(resultDocument.bands[0].rawMap.rows, 8, "exported map rows");
|
||
assertEqual(resultDocument.bands[0].rawMap.columns, 8, "exported map columns");
|
||
assertEqual(resultDocument.bands[0].rawMap.values.length, 64, "exported map value count");
|
||
assert(/^[a-f0-9]{64}$/.test(resultDocument.inputHash), "exported input hash");
|
||
assertEqual(resultDocument.bands[0].evaluation.version, "1.0.0", "evaluation version");
|
||
assertEqual(
|
||
resultDocument.bands[0].evaluation.detectionThresholdRatio,
|
||
0.1,
|
||
"selected peak sensitivity is exported",
|
||
);
|
||
assert(Number.isFinite(resultDocument.bands[0].evaluation.matchToleranceM), "match tolerance exported");
|
||
assert(Number.isFinite(resultDocument.bands[0].metrics.mainLobeWidthM), "main-lobe metric exported");
|
||
assertEqual(resultDocument.environment.scanXMin, experiment.scan.xMin, "scan bounds exported");
|
||
|
||
dispatchValue(windowObject, documentObject.querySelector("#dynamic-range"), 24);
|
||
await waitFor(
|
||
() => documentObject.querySelector("#result-badge").textContent.includes("이전 설정"),
|
||
"changed input stale badge",
|
||
);
|
||
assert(documentObject.querySelector("#canvas-badge").textContent.includes("이전 설정"), "canvas stale badge");
|
||
|
||
dispatchValue(windowObject, documentObject.querySelector("#dynamic-range"), 30);
|
||
await waitFor(
|
||
() => documentObject.documentElement.dataset.cameraValid === "true" &&
|
||
!documentObject.querySelector("#run-button").disabled,
|
||
"original comparison configuration restore",
|
||
);
|
||
|
||
dispatchValue(windowObject, documentObject.querySelector("#backend"), "webgpu");
|
||
dispatchValue(windowObject, documentObject.querySelector("#algorithm"), "gcc-phat");
|
||
await waitFor(
|
||
() => documentObject.querySelector("#algorithm").value === "gcc-phat" &&
|
||
documentObject.querySelector("#backend").value === "auto" &&
|
||
documentObject.querySelector('#backend option[value="webgpu"]').disabled &&
|
||
documentObject.querySelector("#validation-message").textContent.includes("8개 이상"),
|
||
"GCC-PHAT CPU capability and narrow-band warning",
|
||
);
|
||
assert(documentObject.querySelector("#workload-summary").textContent.includes("ref pairs"), "GCC pair cost");
|
||
|
||
dispatchValue(windowObject, documentObject.querySelector("#algorithm"), "das-phat");
|
||
dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu");
|
||
await waitFor(
|
||
() => documentObject.documentElement.dataset.cameraValid === "true" &&
|
||
documentObject.querySelector("#algorithm").value === "das-phat" &&
|
||
!documentObject.querySelector('#backend option[value="webgpu"]').disabled &&
|
||
!documentObject.querySelector("#run-button").disabled,
|
||
"DAS-PHAT comparison configuration",
|
||
);
|
||
|
||
const comparisonPresets = [
|
||
["rectangular-2x2-450mm", "4 ch", "0.450 × 0.450 m"],
|
||
["rectangular-4x3-450mm", "12 ch", "0.450 × 0.450 m"],
|
||
["rectangular-8x6-450mm", "48 ch", "0.450 × 0.450 m"],
|
||
["rectangular-24x16-450mm", "384 ch", "0.450 × 0.450 m"],
|
||
["rectangular-16x12-compact", "192 ch", "0.120 × 0.120 m"],
|
||
["rectangular-16x12-large", "192 ch", "1.200 × 1.200 m"],
|
||
];
|
||
assertEqual(
|
||
documentObject.querySelectorAll("#array-preset option").length,
|
||
9,
|
||
"all built-in and custom array choices are visible",
|
||
);
|
||
for (const [presetId, microphoneCount, aperture] of comparisonPresets) {
|
||
dispatchValue(windowObject, documentObject.querySelector("#array-preset"), presetId);
|
||
await waitFor(
|
||
() => documentObject.querySelector("#array-preset").value === presetId &&
|
||
documentObject.querySelector("#microphone-count").textContent === microphoneCount &&
|
||
documentObject.querySelector("#array-meta").textContent.includes(aperture) &&
|
||
documentObject.documentElement.dataset.cameraValid === "true",
|
||
`${presetId} preset`,
|
||
);
|
||
}
|
||
|
||
dispatchValue(
|
||
windowObject,
|
||
documentObject.querySelector("#array-preset"),
|
||
"fermat-spiral-192",
|
||
);
|
||
await waitFor(
|
||
() => documentObject.querySelector("#array-title").textContent.includes("Fermat") &&
|
||
documentObject.documentElement.dataset.cameraValid === "true" &&
|
||
!documentObject.querySelector("#run-button").disabled,
|
||
"Fermat comparison array",
|
||
);
|
||
|
||
documentObject.querySelector("#run-button").click();
|
||
await waitFor(
|
||
() => documentObject.documentElement.dataset.runState === "running",
|
||
"rerun start",
|
||
);
|
||
assert(!documentObject.querySelector("#cancel-button").classList.contains("is-hidden"), "cancel available");
|
||
await waitFor(
|
||
() => documentObject.documentElement.dataset.runState === "done",
|
||
"8×8 CPU rerun",
|
||
30000,
|
||
);
|
||
assert(!documentObject.querySelector("#result-badge").textContent.includes("이전 설정"), "fresh result badge");
|
||
assertEqual(documentObject.documentElement.dataset.runAlgorithm, "das-phat", "actual comparison algorithm");
|
||
assert(documentObject.querySelector("#result-badge").textContent.includes("DAS-PHAT"), "algorithm result badge");
|
||
assert(documentObject.querySelector("#canvas-badge").textContent.includes("DAS-PHAT"), "algorithm canvas badge");
|
||
assert(documentObject.querySelector("#metric-grid").textContent.includes("Truth concentration"), "generic concentration metric");
|
||
assertEqual(documentObject.querySelectorAll("#history-list .history-card").length, 2, "rerun history");
|
||
const historyText = documentObject.querySelector("#history-list").textContent;
|
||
assert(historyText.includes("rectangular baseline"), "baseline history retained");
|
||
assert(historyText.includes("Fermat spiral"), "comparison array history added");
|
||
assert(historyText.includes("DAS-PHAT") && historyText.includes("DAS"), "both algorithms are compared");
|
||
|
||
documentObject.querySelector("#export-history-button").click();
|
||
const historyDownload = await waitFor(
|
||
() => downloads.find((download) => download.fileName.endsWith(".comparison-history.json")),
|
||
"comparison history download",
|
||
);
|
||
const historyDocument = await fetch(historyDownload.url).then((response) => response.json());
|
||
assertEqual(historyDocument.kind, "micarrsim.comparison-history", "comparison history kind");
|
||
assertEqual(historyDocument.items.length, 2, "both comparison summaries exported");
|
||
assertEqual(
|
||
historyDocument.items.map((item) => item.algorithm).sort().join(","),
|
||
"das,das-phat",
|
||
"both comparison algorithms exported",
|
||
);
|
||
assert(historyDocument.items.every((item) => Number.isFinite(item.localizationMs)), "localization timing exported");
|
||
assert(
|
||
!documentObject.documentElement.dataset.unsavedWork.includes("비교 기록"),
|
||
"history export clears comparison dirty state",
|
||
);
|
||
|
||
documentObject.querySelector("#export-result-button").click();
|
||
documentObject.querySelector("#export-experiment-button").click();
|
||
await waitFor(
|
||
() => !documentObject.documentElement.dataset.unsavedWork.includes("Result") &&
|
||
!documentObject.documentElement.dataset.unsavedWork.includes("Experiment"),
|
||
"final Camera documents saved",
|
||
);
|
||
assertEqual(documentObject.documentElement.dataset.unsavedWork, "", "all Camera work is saved");
|
||
|
||
frame.style.width = "390px";
|
||
await nextPaint(windowObject);
|
||
assert(
|
||
documentObject.documentElement.scrollWidth <= documentObject.documentElement.clientWidth + 1,
|
||
"compact Camera layout has no horizontal overflow",
|
||
);
|
||
const finalWorkflowStep = workflowSteps.at(-1).getBoundingClientRect();
|
||
assert(
|
||
finalWorkflowStep.right <= documentObject.documentElement.clientWidth + 1,
|
||
"all workflow steps remain visible on a compact viewport",
|
||
);
|
||
} finally {
|
||
windowObject.confirm = originalConfirm;
|
||
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
|
||
windowObject.URL.revokeObjectURL = originalRevoke;
|
||
for (const url of objectUrls) originalRevoke(url);
|
||
frame.remove();
|
||
}
|
||
});
|
||
|
||
test("Acoustic Camera가 Experiment 선로드와 Scene·Array 재import에서 다중 분석 대역을 보존한다", async () => {
|
||
const frame = document.createElement("iframe");
|
||
frame.src = "/apps/acoustic-camera/?integration-test=1&band-preservation=1";
|
||
frame.style.cssText = "position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0";
|
||
document.body.append(frame);
|
||
await new Promise((resolve, reject) => {
|
||
frame.addEventListener("load", resolve, { once: true });
|
||
frame.addEventListener(
|
||
"error",
|
||
() => reject(new Error("Acoustic Camera band-preservation iframe load failed")),
|
||
{ once: true },
|
||
);
|
||
});
|
||
|
||
const windowObject = frame.contentWindow;
|
||
const documentObject = frame.contentDocument;
|
||
const downloads = [];
|
||
const objectUrls = [];
|
||
const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click;
|
||
const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL);
|
||
const originalConfirm = windowObject.confirm.bind(windowObject);
|
||
windowObject.HTMLAnchorElement.prototype.click = function captureDownload() {
|
||
if (this.download) downloads.push({ fileName: this.download, url: this.href });
|
||
};
|
||
windowObject.URL.revokeObjectURL = (url) => objectUrls.push(url);
|
||
|
||
const readBandEditors = () =>
|
||
[...documentObject.querySelectorAll("#band-list .band-editor")].map((editor) =>
|
||
Object.fromEntries(
|
||
[...editor.querySelectorAll("input[data-band-key]")].map((input) => [
|
||
input.dataset.bandKey,
|
||
input.type === "number" ? Number(input.value) : input.value,
|
||
]),
|
||
));
|
||
|
||
try {
|
||
await waitFor(
|
||
() => documentObject.documentElement.dataset.appReady === "true" &&
|
||
documentObject.documentElement.dataset.cameraValid === "true",
|
||
"band-preservation Camera initialization",
|
||
);
|
||
|
||
const builderLinks = [...documentObject.querySelectorAll("a.builder-link")];
|
||
assertEqual(builderLinks.length, 2, "Scene and Array Builder links");
|
||
for (const link of builderLinks) {
|
||
assertEqual(link.target, "_blank", "Builder opens without unloading Camera");
|
||
assert(link.relList.contains("noopener"), "Builder tab cannot replace Camera through opener");
|
||
}
|
||
|
||
const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json");
|
||
scene.id = "two-tone-band-preservation";
|
||
scene.name = "Two-tone band preservation";
|
||
const secondSource = structuredClone(scene.sources[0]);
|
||
secondSource.id = "source-2";
|
||
secondSource.name = "1.2 kHz tone";
|
||
secondSource.position = [-0.12, 0.09, 2];
|
||
secondSource.signal.frequency = 1200;
|
||
secondSource.signal.phaseDeg = 45;
|
||
scene.sources.push(secondSource);
|
||
|
||
const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json");
|
||
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",
|
||
"clean-state Experiment-first import",
|
||
);
|
||
assertEqual(
|
||
documentObject.querySelector("#experiment-id").value,
|
||
experiment.id,
|
||
"Experiment loads before its 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",
|
||
);
|
||
await waitFor(
|
||
() => documentObject.documentElement.dataset.cameraValid === "false" &&
|
||
documentObject.querySelector("#validation-message").textContent.includes("sceneId") &&
|
||
documentObject.querySelector("#validation-message").textContent.includes("arrayId"),
|
||
"pending Scene and Array linkage validation",
|
||
);
|
||
assert(
|
||
documentObject.querySelector("#validation-message").textContent.includes("다릅니다"),
|
||
"Experiment-first import explains the pending document ID mismatch",
|
||
);
|
||
assert(
|
||
documentObject.querySelector("#run-status").textContent.includes("분석 대역 2개") &&
|
||
documentObject.querySelector("#run-status").textContent.includes(scene.id) &&
|
||
documentObject.querySelector("#run-status").textContent.includes(arrayDefinition.id) &&
|
||
documentObject.querySelector("#run-status").textContent.includes("연결 대기"),
|
||
"Experiment-first status reports every band and pending reference",
|
||
);
|
||
|
||
let replacementConfirmCount = 0;
|
||
windowObject.confirm = () => {
|
||
replacementConfirmCount += 1;
|
||
return false;
|
||
};
|
||
documentObject.querySelector("#load-moving-example-button").click();
|
||
assertEqual(replacementConfirmCount, 1, "full moving example asks before replacing bands");
|
||
assertEqual(
|
||
JSON.stringify(readBandEditors()),
|
||
JSON.stringify(expectedBands),
|
||
"declining the full example keeps every analysis band",
|
||
);
|
||
assert(
|
||
documentObject.querySelector("#run-status").textContent.includes("유지"),
|
||
"declined replacement explains that inputs were kept",
|
||
);
|
||
windowObject.confirm = originalConfirm;
|
||
|
||
const sceneFile = new windowObject.File(
|
||
[JSON.stringify(scene)],
|
||
"two-tone-band-preservation.scene.json",
|
||
{ type: "application/json" },
|
||
);
|
||
dispatchFiles(windowObject, documentObject.querySelector("#scene-input"), [sceneFile]);
|
||
await waitFor(
|
||
() => documentObject.documentElement.dataset.inputBusy === "false" &&
|
||
documentObject.querySelectorAll("#source-summary .source-line").length === 2 &&
|
||
documentObject.querySelectorAll("#band-list .band-editor").length === 2 &&
|
||
documentObject.documentElement.dataset.cameraValid === "false",
|
||
"source-added Scene reimport",
|
||
);
|
||
assertEqual(
|
||
JSON.stringify(readBandEditors()),
|
||
JSON.stringify(expectedBands),
|
||
"Scene reimport preserves band IDs, ranges, and colors",
|
||
);
|
||
assert(
|
||
documentObject.querySelector("#run-status").textContent.includes("분석 대역 2개"),
|
||
"Camera status keeps the preserved band count visible",
|
||
);
|
||
|
||
const arrayFile = new windowObject.File(
|
||
[JSON.stringify(arrayDefinition)],
|
||
"custom-array-band-preservation.array.json",
|
||
{ type: "application/json" },
|
||
);
|
||
dispatchFiles(windowObject, documentObject.querySelector("#array-input"), [arrayFile]);
|
||
await waitFor(
|
||
() => documentObject.documentElement.dataset.inputBusy === "false" &&
|
||
documentObject.querySelectorAll("#band-list .band-editor").length === 2 &&
|
||
documentObject.documentElement.dataset.cameraValid === "true",
|
||
"referenced Array reimport",
|
||
);
|
||
assertEqual(
|
||
JSON.stringify(readBandEditors()),
|
||
JSON.stringify(expectedBands),
|
||
"Array reimport also preserves every analysis band",
|
||
);
|
||
assert(
|
||
documentObject.querySelector("#run-status").textContent.includes("분석 대역 2개"),
|
||
"ready status keeps the connected Experiment band count visible",
|
||
);
|
||
|
||
documentObject.querySelector("#export-experiment-button").click();
|
||
const experimentDownload = await waitFor(
|
||
() => downloads.find((download) => download.fileName.endsWith(".experiment.json")),
|
||
"preserved Experiment export",
|
||
);
|
||
const exportedExperiment = await fetch(experimentDownload.url).then((response) => response.json());
|
||
assertEqual(
|
||
JSON.stringify(exportedExperiment.frequencyBands),
|
||
JSON.stringify(expectedBands),
|
||
"Experiment export preserves every band after Scene reimport",
|
||
);
|
||
assertEqual(exportedExperiment.sceneId, scene.id, "Experiment rebinds only the Scene ID");
|
||
assertEqual(exportedExperiment.arrayId, arrayDefinition.id, "Experiment rebinds the Array ID");
|
||
} finally {
|
||
windowObject.confirm = originalConfirm;
|
||
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
|
||
windowObject.URL.revokeObjectURL = originalRevoke;
|
||
for (const url of objectUrls) originalRevoke(url);
|
||
frame.remove();
|
||
}
|
||
});
|
||
|
||
test("Acoustic Camera prepares, scrubs, plays, and pauses the moving-source example", async () => {
|
||
const frame = document.createElement("iframe");
|
||
frame.src = "/apps/acoustic-camera/?integration-test=1";
|
||
frame.style.cssText = "position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0";
|
||
document.body.append(frame);
|
||
await new Promise((resolve, reject) => {
|
||
frame.addEventListener("load", resolve, { once: true });
|
||
frame.addEventListener("error", () => reject(new Error("Acoustic Camera live iframe load failed")), {
|
||
once: true,
|
||
});
|
||
});
|
||
|
||
const windowObject = frame.contentWindow;
|
||
const documentObject = frame.contentDocument;
|
||
try {
|
||
await waitFor(
|
||
() => documentObject.documentElement.dataset.appReady === "true",
|
||
"Acoustic Camera live initialization",
|
||
);
|
||
|
||
documentObject.querySelector("#load-moving-example-button").click();
|
||
await waitFor(
|
||
() => documentObject.documentElement.dataset.inputBusy === "false" &&
|
||
documentObject.documentElement.dataset.cameraValid === "true" &&
|
||
documentObject.documentElement.dataset.liveState === "idle" &&
|
||
documentObject.querySelector("#experiment-id").value === "moving-tone-live" &&
|
||
!documentObject.querySelector("#prepare-live-button").disabled,
|
||
"valid moving example trajectory",
|
||
);
|
||
|
||
dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8);
|
||
dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8);
|
||
dispatchValue(windowObject, documentObject.querySelector("#backend"), "auto");
|
||
dispatchValue(windowObject, documentObject.querySelector("#live-map-rate"), 2);
|
||
await waitFor(
|
||
() => documentObject.documentElement.dataset.cameraValid === "true" &&
|
||
documentObject.documentElement.dataset.liveState === "idle" &&
|
||
documentObject.querySelector("#scan-columns").value === "8" &&
|
||
documentObject.querySelector("#scan-rows").value === "8" &&
|
||
documentObject.querySelector("#backend").value === "auto" &&
|
||
documentObject.querySelector("#live-map-rate").value === "2" &&
|
||
!documentObject.querySelector("#prepare-live-button").disabled,
|
||
"8x8 Auto 2 Hz live configuration",
|
||
);
|
||
|
||
documentObject.querySelector("#prepare-live-button").click();
|
||
await waitFor(
|
||
() => documentObject.documentElement.dataset.liveState === "ready",
|
||
"moving-source live preparation",
|
||
45000,
|
||
);
|
||
const frameCount = Number(documentObject.documentElement.dataset.liveFrameCount);
|
||
assert(frameCount > 1, `multiple live frames: ${frameCount}`);
|
||
assertEqual(
|
||
Number(documentObject.documentElement.dataset.livePlanAnalyses),
|
||
frameCount - 1,
|
||
"Auto fallback retries GPU once then reuses one CPU localization plan",
|
||
);
|
||
assert(documentObject.querySelector("#live-status").textContent.includes("CPU"), "live backend is CPU");
|
||
|
||
dispatchValue(windowObject, documentObject.querySelector("#live-time"), 0.6, "input");
|
||
await waitFor(
|
||
() => Number(documentObject.documentElement.dataset.liveTime) >= 0.599,
|
||
"live scrub to a later time",
|
||
);
|
||
const scrubbedTime = Number(documentObject.documentElement.dataset.liveTime);
|
||
assert(Number(documentObject.documentElement.dataset.liveFrame) > 0, "scrub selects a later map frame");
|
||
|
||
documentObject.querySelector("#live-play-button").click();
|
||
await waitFor(
|
||
() => documentObject.documentElement.dataset.liveState === "playing",
|
||
"live playback start",
|
||
);
|
||
await waitFor(
|
||
() => Number(documentObject.documentElement.dataset.liveTime) > scrubbedTime + 0.05,
|
||
"live playback time advance",
|
||
5000,
|
||
);
|
||
|
||
documentObject.querySelector("#live-play-button").click();
|
||
await waitFor(
|
||
() => documentObject.documentElement.dataset.liveState === "ready",
|
||
"live playback pause",
|
||
);
|
||
const pausedTime = Number(documentObject.documentElement.dataset.liveTime);
|
||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||
const timeAfterPause = Number(documentObject.documentElement.dataset.liveTime);
|
||
assert(
|
||
Math.abs(timeAfterPause - pausedTime) <= 0.01,
|
||
`paused live time remains stable: ${pausedTime} -> ${timeAfterPause}`,
|
||
);
|
||
} finally {
|
||
frame.remove();
|
||
}
|
||
});
|