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))); dispatchValue(windowObject, documentObject.querySelector("#tone-frequency"), 5000, "input"); assert( documentObject.querySelector("#source-list").textContent.includes("5000 Hz tone") && !documentObject.querySelector("#source-list").textContent.includes("ADC 3000 Hz alias"), "tone card starts as baseband at 48 kHz sample rate", ); dispatchValue(windowObject, documentObject.querySelector("#sample-rate"), 8000, "change"); assert( documentObject.querySelector("#source-list").textContent.includes("5000 Hz tone → ADC 3000 Hz alias"), "sample-rate change immediately rerenders the tone alias card", ); dispatchValue(windowObject, documentObject.querySelector("#sample-rate"), 48000, "change"); dispatchValue(windowObject, documentObject.querySelector("#tone-frequency"), 1000, "input"); 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 = ''; const imageFile = new windowObject.File([svg], "machine.svg", { type: "image/svg+xml" }); const staleSvg = ''; 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(); } });