351 lines
17 KiB
JavaScript
351 lines
17 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 }));
|
||
}
|
||
|
||
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("#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",
|
||
);
|
||
|
||
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("#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.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.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("#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.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");
|
||
|
||
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");
|
||
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 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"), "cpu");
|
||
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 === "cpu" &&
|
||
documentObject.querySelector("#live-map-rate").value === "2" &&
|
||
!documentObject.querySelector("#prepare-live-button").disabled,
|
||
"8x8 CPU 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}`);
|
||
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();
|
||
}
|
||
});
|