diff --git a/README.md b/README.md index 5a3dcbc..2067027 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,8 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve. ### 3. Acoustic Camera에서 실행하기 1. 홈으로 돌아가 **Acoustic Camera**를 엽니다. -2. 권장 순서인 **Scene → Array → Experiment → Assets**로 입력합니다. Experiment를 먼저 - 열어도 모든 분석 대역이 유지됩니다. +2. 권장 순서인 **Scene → Array → Experiment → Assets**로 입력합니다. Experiment는 어느 + 시점에 열어도 모든 분석 대역을 유지하고 현재 Scene·Array에 자동으로 연결됩니다. 3. **Scene**에서 Builder가 저장한 `.scene.json`을 선택합니다. 4. 기본 배열을 사용하지 않을 때는 **Array**에서 Array Builder가 저장한 `.array.json`을 선택합니다. 5. 별도 실험 문서를 사용할 때만 **Experiment**에서 `.experiment.json`을 선택합니다. @@ -123,10 +123,10 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve. 7. 입력 검증이 통과하면 위치 추정 알고리즘, backend와 scan grid를 정하고 실행합니다. 8. 완료 후 **Result JSON**을 저장합니다. -Experiment를 먼저 불러오면 문서와 모든 `frequencyBands`를 즉시 편집할 수 있습니다. 현재 -Scene·Array와 `sceneId`·`arrayId`가 다른 동안에는 검증 카드에 연결 대기를 표시하고 실행만 -비활성화합니다. 참조하는 Scene과 Array를 이어서 불러오면 실행할 수 있습니다. Scene을 다시 -불러오면 이전 asset 연결은 해제됩니다. +Experiment를 불러오면 Camera가 먼저 문서 자체의 계약을 검사한 뒤 `sceneId`와 `arrayId`를 +현재 열린 Scene·Array에 자동으로 연결합니다. 가져온 `frequencyBands`와 나머지 분석 설정은 +보존됩니다. 재연결한 문서는 현재 입력을 기준으로 관계 검증을 다시 수행하므로 Nyquist·scan 등 +실제 오류는 그대로 거부됩니다. Scene을 다시 불러오면 이전 asset 연결은 해제됩니다. Camera 안의 **Builder 새 탭** 링크는 편집 중인 Experiment와 분석 대역을 원래 Camera 탭에 유지합니다. Builder에서 JSON을 저장한 뒤 새 Camera를 다시 열지 말고 원래 Camera 탭으로 돌아와 diff --git a/apps/acoustic-camera/README.md b/apps/acoustic-camera/README.md index b2cc67a..f95b866 100644 --- a/apps/acoustic-camera/README.md +++ b/apps/acoustic-camera/README.md @@ -27,7 +27,7 @@ DAS·DAS-PHAT·GCC-PHAT 위치추정으로 주파수 대역별 acoustic map과 g ## 사용자 입력 불러오기 -문서는 다음 순서로 불러오는 것이 안전합니다. +문서는 다음 순서로 불러오면 asset 연결까지 한 번에 확인하기 쉽습니다. 1. **Scene**: `.scene.json` 2. **Array**: 선택 사항인 `.array.json`; 또는 **비교 배열** preset 사용 @@ -35,8 +35,11 @@ DAS·DAS-PHAT·GCC-PHAT 위치추정으로 주파수 대역별 acoustic map과 g 4. **Assets**: Scene이 참조하는 원본 이미지와 오디오 Scene을 불러오면 현재 Experiment의 `sceneId`와 scan plane을 Scene에 맞추고 기존 asset 연결을 -해제합니다. Array를 불러오면 현재 Experiment의 `arrayId`를 맞춥니다. 별도 Experiment는 현재 -Scene과 Array를 참조해야 하므로 마지막에 불러오세요. +해제합니다. Array를 불러오면 현재 Experiment의 `arrayId`를 맞춥니다. 별도 Experiment는 어느 +시점에 불러와도 문서 자체를 먼저 검증한 뒤 `sceneId`와 `arrayId`를 현재 열린 Scene·Array에 +자동으로 연결합니다. 이후 현재 입력을 기준으로 관계 검증을 다시 수행하므로 원래 참조 ID가 달라도 +연결 대기로 실행이 막히지 않으며, 모든 분석 대역은 보존됩니다. Nyquist·scan 같은 실제 관계 오류는 +그대로 거부됩니다. Scene JSON에는 원본 파일 byte가 없습니다. **Assets**에서 Builder에 사용한 파일을 다시 선택하면 Camera가 SHA-256을 계산해 manifest에 연결합니다. 같은 이름이라도 내용이 바뀌면 거절됩니다. diff --git a/apps/acoustic-camera/index.html b/apps/acoustic-camera/index.html index dcac2eb..d633748 100644 --- a/apps/acoustic-camera/index.html +++ b/apps/acoustic-camera/index.html @@ -40,7 +40,7 @@
- +
diff --git a/apps/acoustic-camera/main.js b/apps/acoustic-camera/main.js index 24c20fb..9ae2b44 100644 --- a/apps/acoustic-camera/main.js +++ b/apps/acoustic-camera/main.js @@ -1568,16 +1568,30 @@ async function importDocument(kind, input) { try { const value = await readJsonFile(file); if (generation !== state.importGenerations[kind]) return; + let importedExperimentReferences = null; let issues; if (kind === "array") { issues = await validateArrayDefinition(value); } else if (kind === "scene") { issues = await validateScene(value); } else { - // An Experiment is a useful editable document even before its referenced - // Scene or Array has been loaded. Validate its own contract here, then let - // validateCurrent() keep Run disabled until the document IDs line up. + // Validate the imported document independently before changing any + // references. Then bind it to the open workspace and run the relationship + // checks as well, so import convenience does not weaken the contract. issues = await validateExperiment(value); + if (generation !== state.importGenerations[kind]) return; + if (issues.length === 0) { + importedExperimentReferences = { + sceneId: value.sceneId, + arrayId: value.arrayId, + }; + value.sceneId = state.scene.id; + value.arrayId = state.arrayDefinition.id; + issues = await validateExperiment(value, { + arrayDefinition: state.arrayDefinition, + scene: state.scene, + }); + } } if (generation !== state.importGenerations[kind]) return; if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · ")); @@ -1606,14 +1620,14 @@ async function importDocument(kind, input) { : kind === "experiment" ? ` · 분석 대역 ${state.experiment.frequencyBands.length}개` : ""; - const pendingReferences = kind === "experiment" - ? [ - value.sceneId !== state.scene?.id ? `Scene ${value.sceneId}` : null, - value.arrayId !== state.arrayDefinition?.id ? `Array ${value.arrayId}` : null, - ].filter(Boolean) - : []; - const referenceStatus = pendingReferences.length > 0 - ? ` · ${pendingReferences.join(" · ")} 연결 대기` + const referencesChanged = kind === "experiment" && ( + importedExperimentReferences?.sceneId !== state.scene.id || + importedExperimentReferences?.arrayId !== state.arrayDefinition.id + ); + const referenceStatus = kind === "experiment" + ? referencesChanged + ? ` · 현재 Scene ${state.scene.id} · Array ${state.arrayDefinition.id}로 재연결` + : " · 현재 Scene · Array 연결 확인" : ""; setRunStatus(`${file.name} ${kind} 문서를 불러왔습니다.${bandStatus}${referenceStatus}`); } catch (error) { diff --git a/docs/data-contracts.md b/docs/data-contracts.md index 80d55bd..88c0501 100644 --- a/docs/data-contracts.md +++ b/docs/data-contracts.md @@ -189,6 +189,11 @@ SHA-256을 계산해 scene의 해당 asset `sha256` 필드를 채운 상태로 c 현재 계약 버전은 `1.0.0`이다. 브라우저는 `packages/contracts/schema-validator.js`로 저장소의 실제 schema를 실행한 뒤 `validation.js`에서 다음 관계를 추가 검사한다. +Acoustic Camera에서 Experiment를 가져올 때는 문서 자체를 먼저 검증한 뒤 `sceneId`와 +`arrayId`를 현재 열린 Scene·Array의 id로 연결하고, 그 context에서 관계 검증을 다시 수행한다. +이 가져오기 편의 동작은 계약을 완화하지 않는다. 내보내기와 실행에 쓰는 Experiment도 아래 관계를 +모두 만족해야 한다. + - 중복 id와 array/scene/experiment/result 참조 - asset 종류와 안전한 상대 경로 - Nyquist 및 시간·scan 범위 diff --git a/docs/index.html b/docs/index.html index f97b8e6..49b1124 100644 --- a/docs/index.html +++ b/docs/index.html @@ -109,10 +109,10 @@
  • Scene 저장 후 Camera 열기를 누릅니다.
  • Camera에서 파일을 Scene → Array → Experiment → Assets 순서로 - 가져오는 것을 권장합니다. Experiment를 먼저 열어도 분석 대역은 보존되며, 참조 - Scene·Array가 연결될 때까지 실행만 비활성화됩니다. Array Builder에서 저장한 파일이 - 있다면 Array 단계에서 선택하고, 내 Array/Experiment가 없다면 내장 기본값을 그대로 - 둡니다. + 가져오는 것을 권장합니다. Experiment는 어느 시점에 열어도 분석 대역을 보존하고 + sceneId와 arrayId를 현재 열린 입력에 자동으로 연결합니다. + 재연결 뒤 관계 검증도 다시 수행됩니다. Array Builder에서 저장한 파일이 있다면 Array + 단계에서 선택하고, 내 Array/Experiment가 없다면 내장 기본값을 그대로 둡니다.
  • Assets에서 Builder에 사용한 원본 이미지와 오디오를 한 번에 다시 선택합니다. @@ -268,8 +268,8 @@

    start.cmd -Port 4174처럼 다른 포트를 지정하고 http://127.0.0.1:4174/을 여세요.

    - Experiment를 가져온 뒤 실행할 수 없습니다. -

    Experiment부터 가져와도 frequencyBands는 모두 표시됩니다. 검증 카드에 연결 대기가 보이면 참조하는 Scene과 Array를 이어서 가져와 sceneId와 arrayId를 맞추세요.

    + Experiment를 가져왔는데 검증 오류가 남습니다. +

    참조 ID는 현재 Scene·Array에 자동으로 연결됩니다. 실행할 수 없다면 검증 카드에서 Nyquist, scan 범위, asset처럼 재연결 뒤 확인된 실제 입력 오류를 확인하세요.

    Assets가 준비되지 않습니다. diff --git a/tests/integration/camera-ui.test.js b/tests/integration/camera-ui.test.js index 0478ea0..faac356 100644 --- a/tests/integration/camera-ui.test.js +++ b/tests/integration/camera-ui.test.js @@ -1,4 +1,4 @@ -import { validateResult } from "/packages/contracts/validation.js"; +import { validateExperiment, validateResult } from "/packages/contracts/validation.js"; async function waitFor(predicate, label, timeoutMs = 30000) { const deadline = performance.now() + timeoutMs; @@ -344,7 +344,7 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름 } }); -test("Acoustic Camera가 Experiment 선로드와 Scene·Array 재import에서 다중 분석 대역을 보존한다", async () => { +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"; @@ -393,7 +393,8 @@ test("Acoustic Camera가 Experiment 선로드와 Scene·Array 재import에서 assert(link.relList.contains("noopener"), "Builder tab cannot replace Camera through opener"); } - const scene = await loadJson("/assets/sample-scenes/single-tone.scene.json"); + 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]); @@ -404,7 +405,10 @@ test("Acoustic Camera가 Experiment 선로드와 Scene·Array 재import에서 secondSource.signal.phaseDeg = 45; scene.sources.push(secondSource); - const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json"); + 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"; @@ -427,13 +431,15 @@ test("Acoustic Camera가 Experiment 선로드와 Scene·Array 재import에서 ); dispatchFiles(windowObject, documentObject.querySelector("#experiment-input"), [experimentFile]); await waitFor( - () => documentObject.documentElement.dataset.inputBusy === "false", - "clean-state Experiment-first import", + () => documentObject.documentElement.dataset.inputBusy === "false" && + documentObject.documentElement.dataset.cameraValid === "true" && + !documentObject.querySelector("#run-button").disabled, + "Experiment import and automatic reference binding", ); assertEqual( documentObject.querySelector("#experiment-id").value, experiment.id, - "Experiment loads before its referenced Scene", + "Experiment document loads before its originally referenced Scene", ); assertEqual(readBandEditors().length, 2, "Experiment-first import renders every analysis band"); assertEqual( @@ -441,22 +447,77 @@ test("Acoustic Camera가 Experiment 선로드와 Scene·Array 재import에서 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", + !documentObject.querySelector("#validation-message").textContent.includes("sceneId") && + !documentObject.querySelector("#validation-message").textContent.includes("arrayId") && + !documentObject.querySelector("#run-status").textContent.includes("연결 대기"), + "automatic binding does not leave a reference mismatch warning", ); 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", + documentObject.querySelector("#run-status").textContent.includes("실행 준비 완료"), + "Experiment import reaches a ready state with every band", + ); + + documentObject.querySelector("#export-experiment-button").click(); + const reboundDownload = await waitFor( + () => downloads.find((download) => download.fileName.endsWith(".experiment.json")), + "automatically rebound Experiment export", + ); + const reboundExperiment = await fetch(reboundDownload.url).then((response) => response.json()); + assertEqual(reboundExperiment.sceneId, openScene.id, "import binds the open Scene ID"); + assertEqual( + reboundExperiment.arrayId, + openArrayDefinition.id, + "import binds the open Array ID", + ); + assertEqual( + JSON.stringify(reboundExperiment.frequencyBands), + JSON.stringify(expectedBands), + "automatic reference binding preserves every imported band", + ); + assertEqual( + (await validateExperiment(reboundExperiment, { + scene: openScene, + arrayDefinition: openArrayDefinition, + })).length, + 0, + "rebound Experiment passes relationship validation", + ); + downloads.length = 0; + + const contextInvalidExperiment = structuredClone(experiment); + contextInvalidExperiment.id = "nyquist-invalid-after-rebind"; + contextInvalidExperiment.frequencyBands[0].maxHz = openScene.sampleRate / 2; + assertEqual( + (await validateExperiment(contextInvalidExperiment)).length, + 0, + "Nyquist fixture is valid without a Scene context", + ); + const contextInvalidFile = new windowObject.File( + [JSON.stringify(contextInvalidExperiment)], + "nyquist-invalid-after-rebind.experiment.json", + { type: "application/json" }, + ); + dispatchFiles( + windowObject, + documentObject.querySelector("#experiment-input"), + [contextInvalidFile], + ); + await waitFor( + () => documentObject.documentElement.dataset.inputBusy === "false" && + documentObject.documentElement.dataset.cameraValid === "true", + "relationship-invalid Experiment rejection", + ); + assertEqual( + documentObject.querySelector("#experiment-id").value, + experiment.id, + "context relationship validation rejects the imported Experiment before assignment", + ); + assertEqual( + JSON.stringify(readBandEditors()), + JSON.stringify(expectedBands), + "failed relationship validation leaves the current bands unchanged", ); let replacementConfirmCount = 0; @@ -487,7 +548,7 @@ test("Acoustic Camera가 Experiment 선로드와 Scene·Array 재import에서 () => 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", + documentObject.documentElement.dataset.cameraValid === "true", "source-added Scene reimport", ); assertEqual(