280 lines
16 KiB
JavaScript
280 lines
16 KiB
JavaScript
import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.js";
|
|
|
|
function assertNear(actual, expected, tolerance, label) {
|
|
assert(Math.abs(actual - expected) <= tolerance, `${label}: expected ${expected}, received ${actual}`);
|
|
}
|
|
|
|
async function waitFor(predicate, label, timeoutMs = 15000) {
|
|
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 = "input") {
|
|
element.value = String(value);
|
|
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
|
|
}
|
|
|
|
function dispatchChecked(windowObject, element, checked) {
|
|
element.checked = checked;
|
|
element.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
|
|
}
|
|
|
|
function dispatchFiles(windowObject, input, files) {
|
|
const transfer = new windowObject.DataTransfer();
|
|
files.forEach((file) => transfer.items.add(file));
|
|
input.files = transfer.files;
|
|
input.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
|
|
}
|
|
|
|
async function digestHex(blob) {
|
|
const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer());
|
|
return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
test("시작 화면이 세 앱 진입점과 390px 레이아웃을 유지한다", async () => {
|
|
const landingFrame = document.createElement("iframe");
|
|
landingFrame.src = "/";
|
|
landingFrame.style.cssText = "position:fixed;left:-20000px;top:0;width:390px;height:900px;border:0";
|
|
document.body.append(landingFrame);
|
|
await new Promise((resolve, reject) => {
|
|
landingFrame.addEventListener("load", resolve, { once: true });
|
|
landingFrame.addEventListener("error", () => reject(new Error("Landing iframe load failed")), { once: true });
|
|
});
|
|
|
|
try {
|
|
const landingDocument = landingFrame.contentDocument;
|
|
const launchers = [...landingDocument.querySelectorAll(".launcher-card")];
|
|
assertEqual(launchers.length, 3, "landing exposes three primary tasks");
|
|
assert(launchers[0].textContent.includes("Acoustic Camera"), "analysis is the first landing task");
|
|
const launcherPaths = new Set(launchers.map((launcher) => launcher.getAttribute("href")));
|
|
assert(launcherPaths.has("/apps/dataset-builder/"), "landing links to Dataset Builder");
|
|
assert(launcherPaths.has("/apps/array-builder/"), "landing links to Array Builder");
|
|
assert(launcherPaths.has("/apps/acoustic-camera/"), "landing links to Acoustic Camera");
|
|
assert(!landingDocument.querySelector(".workflow-disclosure").open, "file workflow starts collapsed");
|
|
assert(!landingDocument.querySelector('a[href="/docs/execution-plan.md"]'), "developer plan is absent from landing");
|
|
assert(
|
|
landingDocument.documentElement.scrollWidth <= landingDocument.documentElement.clientWidth + 1,
|
|
"landing has no horizontal overflow at 390px",
|
|
);
|
|
} finally {
|
|
landingFrame.remove();
|
|
}
|
|
});
|
|
|
|
test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 preview WAV를 재현한다", async () => {
|
|
const frame = document.createElement("iframe");
|
|
frame.src = "/apps/dataset-builder/?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("Builder iframe load failed")), { once: true });
|
|
});
|
|
|
|
const windowObject = frame.contentWindow;
|
|
const documentObject = frame.contentDocument;
|
|
const objectUrls = [];
|
|
const downloads = [];
|
|
const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL);
|
|
const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click;
|
|
windowObject.URL.revokeObjectURL = (url) => objectUrls.push(url);
|
|
windowObject.HTMLAnchorElement.prototype.click = function captureDownload() {
|
|
downloads.push({ fileName: this.download, url: this.href });
|
|
};
|
|
|
|
try {
|
|
await waitFor(() => documentObject.documentElement.dataset.appReady === "true", "Builder 초기화");
|
|
await waitFor(() => documentObject.documentElement.dataset.sceneValid === "true", "기본 scene 검증");
|
|
assert(documentObject.querySelector("#scene-id").value !== "single-tone", "new default scene gets a unique id");
|
|
const workflowSteps = [...documentObject.querySelectorAll("#builder-workflow [data-workflow-step]")];
|
|
assertEqual(workflowSteps.length, 4, "four Builder workflow steps are discoverable");
|
|
assertEqual(
|
|
workflowSteps.map((step) => step.dataset.workflowStep).join(","),
|
|
"input,edit,validate,save",
|
|
"Builder workflow order",
|
|
);
|
|
assert(!documentObject.querySelector(".header-more").open, "secondary export actions start collapsed");
|
|
assert(!documentObject.querySelector(".inline-disclosure").open, "advanced scene settings start collapsed");
|
|
assert(
|
|
[...documentObject.querySelectorAll(".disclosure-section")].every((section) => !section.open),
|
|
"optional plane and ambient settings start collapsed",
|
|
);
|
|
assert(
|
|
documentObject.querySelector("#source-editor-section").compareDocumentPosition(
|
|
documentObject.querySelector("#scene-name").closest(".control-section"),
|
|
) & windowObject.Node.DOCUMENT_POSITION_FOLLOWING,
|
|
"selected source editor precedes scene metadata",
|
|
);
|
|
assert(
|
|
documentObject.querySelector("#scene-canvas").getBoundingClientRect().height >= 500,
|
|
"canvas remains the dominant Builder workspace",
|
|
);
|
|
|
|
frame.style.width = "390px";
|
|
await new Promise((resolve) => windowObject.requestAnimationFrame(() => windowObject.requestAnimationFrame(resolve)));
|
|
assert(
|
|
documentObject.documentElement.scrollWidth <= documentObject.documentElement.clientWidth + 1,
|
|
"Builder has no horizontal overflow at 390px",
|
|
);
|
|
const finalWorkflowStep = workflowSteps.at(-1).getBoundingClientRect();
|
|
assert(
|
|
finalWorkflowStep.right <= documentObject.documentElement.clientWidth + 1,
|
|
"all Builder workflow steps remain visible at 390px",
|
|
);
|
|
assert(
|
|
documentObject.querySelector("#scene-canvas").getBoundingClientRect().height >= 350,
|
|
"Builder canvas remains usable at 390px",
|
|
);
|
|
frame.style.width = "1400px";
|
|
await new Promise((resolve) => windowObject.requestAnimationFrame(() => windowObject.requestAnimationFrame(resolve)));
|
|
|
|
const previewSamples = new Float32Array(2000);
|
|
for (let index = 0; index < previewSamples.length; index += 1) {
|
|
previewSamples[index] = Math.sin((2 * Math.PI * 440 * index) / 8000) * 0.12;
|
|
}
|
|
const wavBlob = encodeMonoPcm16Wav(previewSamples, 8000);
|
|
const audioFile = new windowObject.File([wavBlob], "machine.wav", { type: "audio/wav" });
|
|
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480"><rect width="640" height="480" fill="#173128"/><circle cx="420" cy="170" r="70" fill="#65f2bf"/></svg>';
|
|
const imageFile = new windowObject.File([svg], "machine.svg", { type: "image/svg+xml" });
|
|
const staleSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240"><rect width="320" height="240" fill="#ff0000"/></svg>';
|
|
const staleImageFile = new windowObject.File([staleSvg], "stale.svg", { type: "image/svg+xml" });
|
|
|
|
dispatchFiles(windowObject, documentObject.querySelector("#audio-source-input"), [audioFile]);
|
|
await waitFor(
|
|
() => [...documentObject.querySelectorAll(".asset-chip__state")].some((item) => item.textContent === "연결됨"),
|
|
"오디오 파일 음원 생성",
|
|
);
|
|
await waitFor(() => documentObject.querySelectorAll(".source-card").length === 2, "asset source card");
|
|
assertEqual(documentObject.querySelector("#source-type").value, "asset", "import selects asset source");
|
|
assertEqual(documentObject.querySelector("#source-name").value, "machine", "source name comes from file");
|
|
assert(documentObject.querySelector("#source-asset").value !== "", "imported asset is selected");
|
|
dispatchFiles(windowObject, documentObject.querySelector("#image-input"), [staleImageFile]);
|
|
dispatchFiles(windowObject, documentObject.querySelector("#image-input"), [imageFile]);
|
|
await waitFor(() => documentObject.querySelectorAll(".asset-chip__state").length === 2, "이미지 decode");
|
|
await waitFor(() => documentObject.documentElement.dataset.assetsReady === "true", "asset 준비");
|
|
|
|
assertEqual(documentObject.querySelectorAll(".source-card").length, 2, "source card count");
|
|
dispatchChecked(windowObject, documentObject.querySelector("#source-loop"), true);
|
|
|
|
const canvas = documentObject.querySelector("#scene-canvas");
|
|
const canvasRect = canvas.getBoundingClientRect();
|
|
canvas.dispatchEvent(new windowObject.PointerEvent("pointerdown", {
|
|
bubbles: true,
|
|
clientX: canvasRect.left + canvasRect.width * 0.75,
|
|
clientY: canvasRect.top + canvasRect.height * 0.25,
|
|
}));
|
|
assertNear(Number(documentObject.querySelector("#source-x").value), 0.3, 0.002, "canvas x");
|
|
assertNear(Number(documentObject.querySelector("#source-y").value), 0.225, 0.002, "canvas y");
|
|
|
|
dispatchChecked(windowObject, documentObject.querySelector("#source-motion"), true);
|
|
assertEqual(documentObject.querySelectorAll(".trajectory-row").length, 2, "default trajectory points");
|
|
assertEqual(documentObject.querySelector(".trajectory-row input").value, "0", "trajectory starts at zero");
|
|
documentObject.querySelector("#add-keyframe-button").click();
|
|
assertEqual(documentObject.querySelectorAll(".trajectory-row").length, 3, "keyframe added");
|
|
documentObject.querySelectorAll(".trajectory-row")[1].querySelector(".trajectory-row__remove").click();
|
|
assertEqual(documentObject.querySelectorAll(".trajectory-row").length, 2, "keyframe removed");
|
|
dispatchValue(windowObject, documentObject.querySelector("#preview-time"), "0.1");
|
|
assertEqual(documentObject.querySelector("#preview-time-value").textContent, "0.10 s", "preview time label");
|
|
|
|
dispatchValue(windowObject, documentObject.querySelector("#ambient-type"), "asset", "change");
|
|
dispatchChecked(windowObject, documentObject.querySelector("#ambient-loop"), true);
|
|
await waitFor(() => documentObject.documentElement.dataset.sceneValid === "true", "편집 scene 검증");
|
|
|
|
documentObject.querySelector("#wav-button").click();
|
|
const firstWavDownload = await waitFor(
|
|
() => downloads.find((download) => download.fileName.endsWith(".preview-mix.wav")),
|
|
"첫 preview WAV",
|
|
);
|
|
const firstWav = await fetch(firstWavDownload.url).then((response) => response.blob());
|
|
const firstWavHash = await digestHex(firstWav);
|
|
|
|
await waitFor(() => !documentObject.querySelector("#export-button").disabled, "첫 JSON 저장 버튼 복구");
|
|
documentObject.querySelector("#export-button").click();
|
|
const firstJsonDownload = await waitFor(
|
|
() => downloads.find((download) => download.fileName.endsWith(".scene.json")),
|
|
"첫 Scene JSON",
|
|
);
|
|
const firstJsonText = await fetch(firstJsonDownload.url).then((response) => response.text());
|
|
const firstScene = JSON.parse(firstJsonText);
|
|
assertEqual(firstScene.sources.length, 2, "exported source count");
|
|
assertEqual(firstScene.sources[1].signal.loop, true, "source loop exported");
|
|
assertEqual(firstScene.sources[1].trajectory.length, 2, "trajectory exported");
|
|
assertEqual(firstScene.sources[1].trajectory[0].position.join(","), firstScene.sources[1].position.join(","), "fallback position synced");
|
|
assertEqual(firstScene.sources[1].trajectory[1].time, firstScene.duration, "trajectory spans scene");
|
|
assertEqual(firstScene.ambient[0].signal.loop, true, "ambient loop exported");
|
|
assertEqual(firstScene.image.widthPixels, 640, "image width exported");
|
|
assert(firstScene.assets.every((asset) => /^[a-f0-9]{64}$/.test(asset.sha256)), "asset hashes exported");
|
|
|
|
const sceneFile = new windowObject.File([firstJsonText], "roundtrip.scene.json", { type: "application/json" });
|
|
dispatchFiles(windowObject, documentObject.querySelector("#scene-input"), [sceneFile]);
|
|
await waitFor(() => documentObject.querySelector("#save-state").textContent.startsWith("Scene 불러옴"), "Scene 재로드");
|
|
await waitFor(() => documentObject.documentElement.dataset.assetsReady === "false", "재로드 후 원본 파일 요구");
|
|
dispatchFiles(windowObject, documentObject.querySelector("#audio-input"), [audioFile]);
|
|
await waitFor(
|
|
() => [...documentObject.querySelectorAll(".asset-chip")].some((chip) =>
|
|
chip.querySelector(".asset-chip__label").textContent.includes("machine.wav") &&
|
|
chip.querySelector(".asset-chip__state").textContent === "연결됨"),
|
|
"오디오 SHA 재연결",
|
|
);
|
|
dispatchFiles(windowObject, documentObject.querySelector("#image-input"), [imageFile]);
|
|
await waitFor(() => documentObject.documentElement.dataset.assetsReady === "true", "이미지 SHA 재연결");
|
|
|
|
const downloadCountBeforeSecondWav = downloads.length;
|
|
documentObject.querySelector("#wav-button").click();
|
|
const secondWavDownload = await waitFor(
|
|
() => downloads.slice(downloadCountBeforeSecondWav).find((download) => download.fileName.endsWith(".preview-mix.wav")),
|
|
"두 번째 preview WAV",
|
|
);
|
|
const secondWav = await fetch(secondWavDownload.url).then((response) => response.blob());
|
|
assertEqual(await digestHex(secondWav), firstWavHash, "round-trip preview WAV hash");
|
|
|
|
await waitFor(() => !documentObject.querySelector("#export-button").disabled, "두 번째 JSON 저장 버튼 복구");
|
|
const downloadCountBeforeSecondJson = downloads.length;
|
|
documentObject.querySelector("#export-button").click();
|
|
const secondJsonDownload = await waitFor(
|
|
() => downloads.slice(downloadCountBeforeSecondJson).find((download) => download.fileName.endsWith(".scene.json")),
|
|
"두 번째 Scene JSON",
|
|
);
|
|
const secondScene = await fetch(secondJsonDownload.url).then((response) => response.json());
|
|
assertEqual(JSON.stringify(secondScene), JSON.stringify(firstScene), "canonical scene round trip");
|
|
|
|
documentObject.querySelector("#remove-image-button").click();
|
|
await waitFor(() => documentObject.querySelector("#remove-image-button").classList.contains("is-hidden"), "image remove");
|
|
await waitFor(() => !documentObject.querySelector("#export-button").disabled, "grid-only JSON button");
|
|
const downloadCountBeforeGridJson = downloads.length;
|
|
documentObject.querySelector("#export-button").click();
|
|
const gridJsonDownload = await waitFor(
|
|
() => downloads.slice(downloadCountBeforeGridJson).find((download) => download.fileName.endsWith(".scene.json")),
|
|
"grid-only Scene JSON",
|
|
);
|
|
const gridScene = await fetch(gridJsonDownload.url).then((response) => response.json());
|
|
assertEqual(gridScene.image, undefined, "image reference removed");
|
|
|
|
const stableName = documentObject.querySelector("#scene-name").value;
|
|
const invalidFile = new windowObject.File(["{"], "invalid.scene.json", { type: "application/json" });
|
|
dispatchFiles(windowObject, documentObject.querySelector("#scene-input"), [invalidFile]);
|
|
await waitFor(() => documentObject.querySelector("#save-state").textContent.startsWith("불러오기 실패"), "invalid import error");
|
|
assertEqual(documentObject.querySelector("#scene-name").value, stableName, "invalid import preserves scene");
|
|
|
|
dispatchValue(windowObject, documentObject.querySelector("#sample-rate"), "96000", "change");
|
|
await waitFor(() => !documentObject.querySelector("#wav-button").disabled, "cancel fixture validation");
|
|
const downloadCountBeforeCancel = downloads.length;
|
|
documentObject.querySelector("#wav-button").click();
|
|
const cancelSceneFile = new windowObject.File([firstJsonText], "cancel.scene.json", { type: "application/json" });
|
|
dispatchFiles(windowObject, documentObject.querySelector("#scene-input"), [cancelSceneFile]);
|
|
await waitFor(() => documentObject.querySelector("#save-state").textContent.startsWith("Scene 불러옴"), "import during decode");
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
assertEqual(downloads.length, downloadCountBeforeCancel, "cancelled decode does not create stale download");
|
|
} finally {
|
|
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
|
|
windowObject.URL.revokeObjectURL = originalRevoke;
|
|
for (const url of objectUrls) originalRevoke(url);
|
|
frame.remove();
|
|
}
|
|
});
|