feat: protect unsaved camera work
This commit is contained in:
@@ -79,6 +79,9 @@ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\serve.
|
||||
Array와 Scene을 먼저 불러와야 Experiment의 `arrayId`와 `sceneId` 관계를 안전하게 검증할 수
|
||||
있습니다. Scene을 다시 불러오면 이전 asset 연결은 해제됩니다.
|
||||
|
||||
Camera에서 수정한 Scene·Experiment, 완료 Result와 전체 배열 비교 기록은 각각 JSON으로 저장할 수
|
||||
있습니다. 저장하지 않은 항목이나 실행 중 계산이 있으면 페이지를 닫을 때 브라우저가 확인합니다.
|
||||
|
||||
## Scene JSON과 로컬 assets
|
||||
|
||||
Scene JSON에는 원본 이미지나 오디오 byte가 들어가지 않습니다. 대신 asset id, 종류, 파일명과
|
||||
|
||||
@@ -65,6 +65,14 @@ WebGPU가 감지돼도 실제 장치는 실행 시 확인합니다. 계산 중 *
|
||||
- rectangular와 Fermat spiral preset을 같은 Scene/Experiment에서 연속 실행하면 **배열 비교 기록**에
|
||||
backend, timing과 평가 요약이 남습니다.
|
||||
- 입력을 바꾼 뒤 화면에 남은 결과는 이전 실행 snapshot이며 UI가 이전 설정 결과임을 표시합니다.
|
||||
- Camera에서 거리·음속·seed를 바꾼 Scene과 scan·STFT·band를 바꾼 Experiment는 우측의 각
|
||||
**JSON 저장** 버튼으로 보존할 수 있습니다.
|
||||
- **전체 기록 JSON**은 세션에 남은 모든 배열 비교 요약을
|
||||
`micarrsim.comparison-history` 문서로 저장합니다.
|
||||
|
||||
미저장 Scene·Experiment·Result·비교 기록은 우측 저장 상태에 표시됩니다. 직전 Result를 저장하지
|
||||
않고 새 실행을 시작하면 교체 여부를 확인하며, 미저장 항목이나 실행 중 계산이 있는 상태에서
|
||||
페이지를 닫으면 브라우저가 한 번 더 확인합니다.
|
||||
|
||||
## Result JSON
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
</section>
|
||||
|
||||
<section class="history-section">
|
||||
<div class="section-heading"><div><p class="eyebrow">Same-scene comparison</p><h2>배열 비교 기록</h2></div><button class="text-button" id="clear-history-button" type="button">기록 비우기</button></div>
|
||||
<div class="section-heading"><div><p class="eyebrow">Same-scene comparison</p><h2>배열 비교 기록</h2></div><div class="history-actions"><button class="text-button" id="export-history-button" type="button" disabled>전체 기록 JSON</button><button class="text-button" id="clear-history-button" type="button">기록 비우기</button></div></div>
|
||||
<p class="section-copy">비교 배열 또는 Array JSON을 바꿔 다시 실행하면 같은 scene·scan·STFT 결과를 이 세션에서 나란히 비교합니다.</p>
|
||||
<div class="history-list" id="history-list"></div>
|
||||
</section>
|
||||
@@ -124,7 +124,13 @@
|
||||
<div class="band-editor-list" id="band-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="output-card"><button class="button button--ghost button--full" id="export-experiment-button" type="button">Experiment JSON 저장</button></section>
|
||||
<section class="output-card">
|
||||
<div class="output-actions">
|
||||
<button class="button button--ghost" id="export-scene-button" type="button">Scene JSON 저장</button>
|
||||
<button class="button button--ghost" id="export-experiment-button" type="button">Experiment JSON 저장</button>
|
||||
</div>
|
||||
<p class="output-save-state" id="output-save-state">저장하지 않은 변경 없음</p>
|
||||
</section>
|
||||
|
||||
<section class="validation-card" aria-live="polite">
|
||||
<div class="validation-dot" id="validation-dot"></div>
|
||||
|
||||
@@ -82,7 +82,10 @@ const elements = {
|
||||
postprocess: byId("postprocess"),
|
||||
addBandButton: byId("add-band-button"),
|
||||
bandList: byId("band-list"),
|
||||
exportSceneButton: byId("export-scene-button"),
|
||||
exportExperimentButton: byId("export-experiment-button"),
|
||||
exportHistoryButton: byId("export-history-button"),
|
||||
outputSaveState: byId("output-save-state"),
|
||||
validationCard: document.querySelector(".validation-card"),
|
||||
validationTitle: byId("validation-title"),
|
||||
validationMessage: byId("validation-message"),
|
||||
@@ -101,6 +104,9 @@ const state = {
|
||||
active: null,
|
||||
pendingInputOperations: 0,
|
||||
importGenerations: { array: 0, scene: 0, experiment: 0 },
|
||||
sceneDirty: false,
|
||||
experimentDirty: false,
|
||||
resultDirty: false,
|
||||
};
|
||||
|
||||
const sceneVisualization = createSceneVisualization(elements.sceneCanvas);
|
||||
@@ -141,6 +147,29 @@ function setRunStatus(message) {
|
||||
elements.runStatus.textContent = message;
|
||||
}
|
||||
|
||||
function unsavedWorkLabels() {
|
||||
const labels = [];
|
||||
if (state.sceneDirty) labels.push("Scene");
|
||||
if (state.experimentDirty) labels.push("Experiment");
|
||||
if (state.resultDirty) labels.push("Result");
|
||||
if (state.history.some((item) => !item.comparisonSaved)) labels.push("비교 기록");
|
||||
return labels;
|
||||
}
|
||||
|
||||
function syncUnsavedState() {
|
||||
const labels = unsavedWorkLabels();
|
||||
document.documentElement.dataset.unsavedWork = labels.join(",");
|
||||
elements.outputSaveState.textContent = labels.length > 0
|
||||
? `미저장 · ${labels.join(" · ")}`
|
||||
: "저장하지 않은 변경 없음";
|
||||
elements.outputSaveState.classList.toggle("is-dirty", labels.length > 0);
|
||||
elements.exportHistoryButton.disabled = state.history.length === 0;
|
||||
}
|
||||
|
||||
function hasUnsavedWork() {
|
||||
return Boolean(state.active) || unsavedWorkLabels().length > 0;
|
||||
}
|
||||
|
||||
function setTransientError(error, prefix = "오류") {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setRunStatus(`${prefix} · ${message}`);
|
||||
@@ -194,6 +223,9 @@ function readFiniteInput(element) {
|
||||
}
|
||||
|
||||
function markChanged(options = {}) {
|
||||
const dirty = options.dirty ?? "experiment";
|
||||
if (dirty === "scene" || dirty === "both") state.sceneDirty = true;
|
||||
if (dirty === "experiment" || dirty === "both") state.experimentDirty = true;
|
||||
state.revision += 1;
|
||||
if (state.lastRun) state.lastRun.stale = true;
|
||||
if (["error", "canceled"].includes(document.documentElement.dataset.runState)) {
|
||||
@@ -207,6 +239,7 @@ function markChanged(options = {}) {
|
||||
renderSceneVisualization();
|
||||
renderResult();
|
||||
renderHistory();
|
||||
syncUnsavedState();
|
||||
scheduleValidation();
|
||||
}
|
||||
|
||||
@@ -496,6 +529,7 @@ function renderResult() {
|
||||
|
||||
function renderHistory() {
|
||||
elements.historyList.replaceChildren();
|
||||
elements.exportHistoryButton.disabled = state.history.length === 0;
|
||||
const signature = comparisonSignature();
|
||||
const items = state.history.filter((item) => item.comparisonSignature === signature);
|
||||
if (items.length === 0) {
|
||||
@@ -718,6 +752,17 @@ async function runSimulation() {
|
||||
await validateCurrent();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
state.resultDirty &&
|
||||
!window.confirm("직전 Result를 아직 저장하지 않았습니다. 새 실행으로 교체할까요?")
|
||||
) {
|
||||
setRunStatus("직전 Result를 먼저 저장한 뒤 다시 실행할 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
if (state.resultDirty) {
|
||||
state.resultDirty = false;
|
||||
syncUnsavedState();
|
||||
}
|
||||
const snapshot = {
|
||||
arrayDefinition: structuredClone(state.arrayDefinition),
|
||||
scene: structuredClone(state.scene),
|
||||
@@ -818,8 +863,11 @@ async function runSimulation() {
|
||||
inputHash,
|
||||
});
|
||||
historyItem.comparisonSignature = comparisonSignature(snapshot.scene, snapshot.experiment);
|
||||
historyItem.comparisonSaved = false;
|
||||
state.history.unshift(historyItem);
|
||||
state.history.length = Math.min(state.history.length, MAX_HISTORY_ITEMS);
|
||||
state.resultDirty = true;
|
||||
syncUnsavedState();
|
||||
document.documentElement.dataset.runState = "done";
|
||||
document.documentElement.dataset.runBackend = run.backend;
|
||||
document.documentElement.dataset.resultBandCount = String(run.bands.length);
|
||||
@@ -921,12 +969,18 @@ async function importDocument(kind, input) {
|
||||
state.scene = value;
|
||||
state.experiment.sceneId = value.id;
|
||||
alignExperimentToScene(state.experiment, value);
|
||||
state.sceneDirty = false;
|
||||
assetRuntime.reset();
|
||||
} else {
|
||||
state.experiment = value;
|
||||
state.experimentDirty = false;
|
||||
}
|
||||
state.visibleBandIds = new Set(state.experiment.frequencyBands.map((band) => band.id));
|
||||
markChanged({ syncControls: true, renderBands: true });
|
||||
markChanged({
|
||||
syncControls: true,
|
||||
renderBands: true,
|
||||
dirty: kind === "experiment" ? "none" : "experiment",
|
||||
});
|
||||
renderAssets();
|
||||
setRunStatus(`${file.name} ${kind} 문서를 불러왔습니다.`);
|
||||
} catch (error) {
|
||||
@@ -949,19 +1003,19 @@ function installDocumentInputListeners() {
|
||||
const value = readFiniteInput(elements.sceneDistance);
|
||||
if (value === null) return syncControls();
|
||||
shiftSceneDistance(state.scene, state.experiment, value);
|
||||
markChanged();
|
||||
markChanged({ dirty: "both" });
|
||||
});
|
||||
elements.speedOfSound.addEventListener("change", () => {
|
||||
const value = readFiniteInput(elements.speedOfSound);
|
||||
if (value === null) return syncControls();
|
||||
state.scene.speedOfSound = value;
|
||||
markChanged();
|
||||
markChanged({ dirty: "scene" });
|
||||
});
|
||||
elements.sceneSeed.addEventListener("change", () => {
|
||||
const value = readFiniteInput(elements.sceneSeed);
|
||||
if (value === null) return syncControls();
|
||||
state.scene.seed = value;
|
||||
markChanged();
|
||||
markChanged({ dirty: "scene" });
|
||||
});
|
||||
elements.backend.addEventListener("change", () => {
|
||||
state.experiment.beamforming.backend = elements.backend.value;
|
||||
@@ -1103,6 +1157,20 @@ function installActionListeners() {
|
||||
});
|
||||
elements.overlayOpacity.addEventListener("input", renderSceneVisualization);
|
||||
elements.showTruth.addEventListener("change", renderSceneVisualization);
|
||||
elements.exportSceneButton.addEventListener("click", async () => {
|
||||
const issues = await validateScene(state.scene);
|
||||
if (issues.length > 0) {
|
||||
setTransientError(
|
||||
new Error(issues.slice(0, 4).map(formatIssue).join(" · ")),
|
||||
"Scene 저장 실패",
|
||||
);
|
||||
return;
|
||||
}
|
||||
downloadJson(state.scene, `${safeFileStem(state.scene.id)}.scene.json`);
|
||||
state.sceneDirty = false;
|
||||
syncUnsavedState();
|
||||
setRunStatus("Scene JSON을 저장했습니다.");
|
||||
});
|
||||
elements.exportExperimentButton.addEventListener("click", async () => {
|
||||
const issues = await validateExperiment(state.experiment, {
|
||||
arrayDefinition: state.arrayDefinition,
|
||||
@@ -1116,6 +1184,8 @@ function installActionListeners() {
|
||||
return;
|
||||
}
|
||||
downloadJson(state.experiment, `${safeFileStem(state.experiment.id)}.experiment.json`);
|
||||
state.experimentDirty = false;
|
||||
syncUnsavedState();
|
||||
setRunStatus("Experiment JSON을 저장했습니다.");
|
||||
});
|
||||
elements.exportResultButton.addEventListener("click", () => {
|
||||
@@ -1124,14 +1194,38 @@ function installActionListeners() {
|
||||
state.lastRun.resultDocument,
|
||||
`${safeFileStem(state.lastRun.snapshot.experiment.id)}.result.json`,
|
||||
);
|
||||
state.resultDirty = false;
|
||||
syncUnsavedState();
|
||||
setRunStatus("검증된 Result JSON을 저장했습니다.");
|
||||
});
|
||||
elements.exportHistoryButton.addEventListener("click", () => {
|
||||
if (state.history.length === 0) return;
|
||||
const items = state.history.map(({ comparisonSignature, comparisonSaved, ...item }) => item);
|
||||
downloadJson(
|
||||
{
|
||||
schemaVersion: "1.0.0",
|
||||
kind: "micarrsim.comparison-history",
|
||||
createdAt: new Date().toISOString(),
|
||||
items,
|
||||
},
|
||||
"micarrsim.comparison-history.json",
|
||||
);
|
||||
for (const item of state.history) item.comparisonSaved = true;
|
||||
syncUnsavedState();
|
||||
setRunStatus("전체 배열 비교 기록 JSON을 저장했습니다.");
|
||||
});
|
||||
elements.clearHistoryButton.addEventListener("click", () => {
|
||||
const signature = comparisonSignature();
|
||||
state.history = state.history.filter((item) => item.comparisonSignature !== signature);
|
||||
renderHistory();
|
||||
syncUnsavedState();
|
||||
});
|
||||
window.addEventListener("beforeunload", () => {
|
||||
window.addEventListener("beforeunload", (event) => {
|
||||
if (window.top !== window || !hasUnsavedWork()) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
});
|
||||
window.addEventListener("unload", () => {
|
||||
cancelSimulation();
|
||||
sceneVisualization.destroy();
|
||||
arrayVisualization.destroy();
|
||||
@@ -1164,6 +1258,7 @@ async function initialize() {
|
||||
renderResult();
|
||||
renderHistory();
|
||||
renderSceneVisualization();
|
||||
syncUnsavedState();
|
||||
await validateCurrent();
|
||||
setEditableControlsDisabled(false);
|
||||
renderBandEditors();
|
||||
|
||||
@@ -224,6 +224,7 @@ export function createHistorySummary({ arrayDefinition, scene, experiment, run,
|
||||
const firstMatchedPeak = evaluation?.peaks.find((peak) => peak.sourceId) ?? evaluation?.peaks[0];
|
||||
return {
|
||||
id: `${Date.now()}-${inputHash.slice(0, 8)}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
arrayId: arrayDefinition.id,
|
||||
arrayName: arrayDefinition.name,
|
||||
sceneId: scene.id,
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
.metric-card small { display: block; margin-top: 5px; color: var(--muted); font-size: .62rem; }
|
||||
.text-button, .mini-button { padding: 5px 7px; border: 1px solid transparent; border-radius: 8px; color: var(--muted); background: transparent; cursor: pointer; font-size: .68rem; }
|
||||
.history-list { display: grid; gap: 8px; margin-top: 14px; }
|
||||
.history-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px; }
|
||||
.history-empty { padding: 12px; color: var(--muted); font-size: .72rem; text-align: center; }
|
||||
.history-card { display: grid; grid-template-columns: minmax(150px, 1.5fr) repeat(4, minmax(85px, .75fr)); gap: 10px; align-items: center; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px; background: #0b1714; }
|
||||
.history-card strong, .history-card span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@@ -78,6 +79,9 @@
|
||||
.asset-status.is-error span { color: var(--danger); }
|
||||
.asset-empty { color: var(--muted); font-size: .68rem; }
|
||||
.builder-link { display: block; margin-top: 11px; color: var(--cyan); font-size: .68rem; text-decoration: none; }
|
||||
.output-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.output-save-state { margin: 9px 0 0; color: var(--muted); font-size: .65rem; text-align: center; }
|
||||
.output-save-state.is-dirty { color: var(--amber); }
|
||||
.field { display: grid; min-width: 0; gap: 6px; }
|
||||
.field > span { color: var(--muted); font-size: .67rem; font-weight: 650; }
|
||||
.field input, .field select, .band-editor input { width: 100%; min-height: 37px; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: #0b1714; }
|
||||
@@ -121,7 +125,7 @@
|
||||
.history-card strong { grid-column: 1 / -1; }
|
||||
}
|
||||
@media (max-width: 430px) {
|
||||
.field-grid, .band-editor { grid-template-columns: 1fr; }
|
||||
.field-grid, .band-editor, .output-actions { grid-template-columns: 1fr; }
|
||||
.band-editor__remove { min-height: 34px; }
|
||||
.metric-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ micarrsim의 저장 형식은 JSON Schema Draft 2020-12와 추가 관계 검증
|
||||
- `micarrsim.scene`: 음원·ambient recipe, 위치 ground truth와 로컬 asset manifest
|
||||
- `micarrsim.experiment`: scan plane/grid, 주파수 대역, STFT와 DAS 설정
|
||||
- `micarrsim.result`: raw/display acoustic map, 복수 peak와 source 매칭, 평가·성능 정보
|
||||
- `micarrsim.comparison-history`: 세션의 배열별 Result 요약을 모은 편의 export. core schema
|
||||
검증 대상은 아니며 각 원본 Result JSON을 대신하지 않는다.
|
||||
|
||||
scene JSON에는 192채널 원시 sample이나 원본 asset byte를 넣지 않는다. 합성 tone/noise는 seed가
|
||||
포함된 recipe로 재생성하고, 녹음 파일과 이미지는 `id`, `kind`, `fileName`, `mimeType`,
|
||||
|
||||
@@ -80,8 +80,9 @@ DAS는 STFT 후 각 scan point와 마이크 사이 거리로 steering phase를
|
||||
## 5. UI 계획
|
||||
|
||||
`acoustic-camera`는 폭 600px 이상인 좌측 scene canvas에 기기 이미지, ground-truth hint,
|
||||
주파수별 acoustic overlay를 표시한다. 우측에는 배열 geometry를 확대·회전 가능한 평면도로
|
||||
표시하고 그 아래에 제어 패널을 둔다. 작은 화면에서는 두 열을 세로로 쌓는다.
|
||||
주파수별 acoustic overlay를 표시한다. 우측에는 배열 geometry를 좌표와 함께 정적 평면도로
|
||||
표시하고 그 아래에 제어 패널을 둔다. 작은 화면에서는 두 열을 세로로 쌓는다. 확대·회전 상호작용은
|
||||
향후 필요성 검증 뒤 추가한다.
|
||||
|
||||
필수 입력은 음원 수와 위치, AC 거리, 다중 주파수 구간과 색, ambient 파일, scan grid 크기,
|
||||
후처리 선택이다. 여기에 sample rate, 음속/온도, STFT window·hop, scan 영역, dynamic range,
|
||||
@@ -114,7 +115,7 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
|
||||
|
||||
### 단계 0 — 기반과 계약 · 완료
|
||||
|
||||
- workspace, formatter, linter, unit test, WebGPU feature detection 구성
|
||||
- 무설치 ES module workspace, browser test runner, WebGPU feature detection 구성
|
||||
- 네 가지 JSON Schema와 최소 예제 작성
|
||||
- 좌표계·단위·오디오 normalization 결정 기록
|
||||
- synthetic single-tone scene과 192채널 기준 배열 작성
|
||||
@@ -133,7 +134,7 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
|
||||
|
||||
- 이미지와 음원 파일 불러오기, 위치·시간·gain 편집, ambient mix와 미리 듣기
|
||||
- deterministic seed, clipping 경고, Scene JSON과 mono Preview WAV 내보내기
|
||||
- 데이터셋 adapter 인터페이스와 synthetic adapter 구현
|
||||
- synthetic tone/noise fixture와 로컬 asset manifest 경계 구현
|
||||
|
||||
완료 조건: 만든 scene을 다시 열었을 때 음원, 위치, 파형, 정답이 동일하다.
|
||||
|
||||
@@ -166,8 +167,10 @@ flattening이 모델 타당성을 훼손하는지 청취 및 spectrum 통계로
|
||||
|
||||
- WebGPU 미지원/장치 한도: 시작 시 capability를 표시하고 CPU fallback 및 grid 상한을 둔다.
|
||||
- 좌표·phase 부호 오류: analytic tone test와 배열 대칭성 test를 먼저 만든다.
|
||||
- 브라우저 메모리: 전체 시간×192 신호를 항상 보관하지 않고 chunk/STFT frame 단위로 처리한다.
|
||||
- 데이터셋 부적합/라이선스: adapter 경계를 유지하고 synthetic fixture를 기본 CI 입력으로 쓴다.
|
||||
- 브라우저 메모리: 현재 전체 192채널 signal buffer에 512 MiB 한도를 적용한다. 장시간 입력은
|
||||
향후 chunk/STFT streaming backend로 확장한다.
|
||||
- 데이터셋 부적합/라이선스: 로컬 asset 계약을 유지하고 synthetic fixture를 기본 test 입력으로
|
||||
쓴다. 실제 데이터 adapter는 단계 5의 라이선스 검토 뒤 추가한다.
|
||||
- 실제 AC와 simulation gap: 감도, 위상, 위치 오차를 이후 calibration/noise model로 주입 가능하게 한다.
|
||||
- post-processing이 기본 결과를 가림: raw DAS map과 후처리 map을 항상 별도로 보존한다.
|
||||
|
||||
|
||||
+10
-3
@@ -44,9 +44,10 @@
|
||||
<li>완료되면 <strong>Result JSON</strong>을 눌러 다운로드 폴더에 저장합니다.</li>
|
||||
</ol>
|
||||
<aside class="callout">
|
||||
브라우저가 자동으로 열리지 않으면
|
||||
<a href="http://127.0.0.1:4173/">http://127.0.0.1:4173/</a>을 직접 여세요.
|
||||
종료할 때는 서버 콘솔에서 <kbd>Ctrl</kbd>+<kbd>C</kbd>를 누릅니다.
|
||||
브라우저가 자동으로 열리지 않으면 서버 콘솔에 표시된 주소(기본
|
||||
<code>http://127.0.0.1:4173/</code>)를 입력하거나
|
||||
<a href="/">현재 서버의 micarrsim 홈</a>을 여세요. 종료할 때는 서버 콘솔에서
|
||||
<kbd>Ctrl</kbd>+<kbd>C</kbd>를 누릅니다.
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
@@ -123,12 +124,18 @@
|
||||
<div><dt>evaluation</dt><dd>검출 threshold, 반경, tolerance 등 지표 계산 조건</dd></div>
|
||||
<div><dt>timing / environment</dt><dd>backend, grid·scan 범위, 장치 정보와 구간별 시간</dd></div>
|
||||
<div><dt>inputHash</dt><dd>Array·Scene·Experiment 입력을 묶은 SHA-256 재현성 식별자</dd></div>
|
||||
<div><dt>comparison-history</dt><dd>세션의 배열별 시간·위치 오차·PSR 요약을 한 번에 저장</dd></div>
|
||||
</dl>
|
||||
<aside class="callout">
|
||||
평가는 tone 음원을 해당 주파수 대역이 tone 주파수를 포함할 때만 정답으로 사용합니다.
|
||||
noise와 asset 음원은 현재 모든 대역의 정답으로 간주합니다. 녹음 파일의 실제 대역별
|
||||
에너지를 자동 판정하는 기능은 아직 없습니다.
|
||||
</aside>
|
||||
<p class="guide-copy">
|
||||
Camera에서 바꾼 Scene과 Experiment는 각각 저장할 수 있습니다. 완료 Result와 배열 비교
|
||||
기록도 별도 JSON으로 저장하세요. 저장하지 않은 항목이나 실행 중인 계산이 있으면
|
||||
페이지를 닫을 때 브라우저가 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
||||
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 });
|
||||
};
|
||||
@@ -73,6 +74,10 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
||||
"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(
|
||||
@@ -85,6 +90,20 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
||||
assert(documentObject.querySelectorAll("#metric-grid .metric-card").length >= 9, "result metrics 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");
|
||||
|
||||
@@ -94,6 +113,10 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
||||
"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"),
|
||||
@@ -156,7 +179,30 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름
|
||||
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");
|
||||
} finally {
|
||||
windowObject.confirm = originalConfirm;
|
||||
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
|
||||
windowObject.URL.revokeObjectURL = originalRevoke;
|
||||
for (const url of objectUrls) originalRevoke(url);
|
||||
|
||||
Reference in New Issue
Block a user