diff --git a/resources/tester/test_est_history.py b/resources/tester/test_est_history.py new file mode 100644 index 000000000..1f6db8667 --- /dev/null +++ b/resources/tester/test_est_history.py @@ -0,0 +1,48 @@ +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_large_edit_history_is_compressed_and_restores_independent_snapshots(): + script = r""" +const fs=require('node:fs'),vm=require('node:vm'); +const context=vm.createContext({Response,Blob,CompressionStream,DecompressionStream, + document:{addEventListener(){}},console}); +vm.runInContext(fs.readFileSync(process.argv[1],'utf8')+';globalThis.app=EstApp',context); +(async()=>{ + const {app}=context; + app.state.project={name:'before',binary:'x'.repeat(11*1024*1024)}; + app.remember(); + app.state.project.name='after'; + const entry=await app.state.history[0],restored=await app.restore(entry); + if(!(entry instanceof Blob)||entry.size>=100000)throw Error('History is not compressed'); + if(restored.name!=='before'||restored.binary.length!==11*1024*1024) + throw Error('Snapshot was changed or truncated'); + restored.name='independent'; + if((await app.restore(entry)).name!=='before')throw Error('Snapshot shares mutable data'); + app.state.project={name:'small'}; + app.state.future=['discard']; + for(let i=0;i<10;i++){app.state.project.name=String(i);app.remember();} + if(app.state.history.length!==8||app.state.future.length)throw Error('History limits'); + if((await app.restore(app.state.history[0])).name!=='2')throw Error('History ordering'); + context.CompressionStream=undefined; + app.state.project={binary:'y'.repeat(11*1024*1024)}; + const fallback=app.snapshot(); + if(typeof fallback!=='string')throw Error('Missing platform fallback'); + if((await app.restore(fallback)).binary.length!==11*1024*1024) + throw Error('Fallback truncated'); + console.log(JSON.stringify({compressedBytes:entry.size,revision:app.state.revision})); +})().catch(error=>{console.error(error);process.exitCode=1}); +""" + result = json.loads( + subprocess.check_output( + ["node", "-e", script, str(ROOT / "tools/est-test/native-app.js")], + text=True, + encoding="utf-8", + ) + ) + assert result["compressedBytes"] < 100_000 + assert result["revision"] == 11 diff --git a/tools/est-test/README.md b/tools/est-test/README.md index df7b3fbbd..1149f7edc 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -10,6 +10,8 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요청은 순서대로 처리하며, 저장 대기 중의 추가 입력이나 새로 연 문서를 이전 저장 응답으로 덮어쓰지 않습니다. 실행 취소는 현재 작업 파일의 연결을 유지합니다. +큰 문서의 실행 취소도 최근 8단계를 보관합니다. 10MB 이상 이력은 브라우저 기본 압축 기능을 사용하며, 복원 중 추가 입력이나 문서 전환이 생기면 이전 내용을 덮어쓰지 않습니다. + 당초 확정 사본에는 재계산 입력을 한 번만 보관하며, 작업본의 도면·원본 압축 자료는 보존합니다. 설치된 `main.estx`의 당초 확정 후 실제 저장 API를 통과했고, 산근 77,395행과 원본 압축 해시 보존·계산 오류 0을 확인했습니다(요청 약 215MB, 저장 약 221MB). 초기 자료는 기술정보 DB의 실제 REA 저장 샘플입니다. `REA 샘플 대조`에서 원본 저장값과 275개 계산 결과를 비교합니다. 테스트용 임의 요율을 원본 기본값으로 넣지 않습니다. 새 원가 서식의 요율은 사용자가 입력합니다. @@ -39,7 +41,7 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요 검사: ```powershell -./venv/Scripts/python.exe -m pytest resources/tester/test_est_rea_parity.py resources/tester/test_est_business.py resources/tester/test_est_standalone.py -q +./venv/Scripts/python.exe -m pytest resources/tester/test_est_rea_parity.py resources/tester/test_est_business.py resources/tester/test_est_standalone.py resources/tester/test_est_history.py -q ``` `native-*.js`가 현재 화면·계산 엔진이며 브라우저와 로컬 서버가 같은 계산 코드를 사용합니다. `build_sample.py`는 REA 자료에서 입력·기대값을 재생성합니다. 기존 `app.js`, `calc.js`, `formula.js`, `workflows.js`는 이전 데모의 회귀 시험용이며 현재 진입 화면에서 사용하지 않습니다. diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js index f92a60efb..bc7cb825a 100644 --- a/tools/est-test/native-actions.js +++ b/tools/est-test/native-actions.js @@ -1,6 +1,7 @@ const EstActions = (() => { let pending = null, - clipboard = []; + clipboard = [], + restoring = false; const uid = () => globalThis.crypto.randomUUID(); function form(title, fields, callback) { const { esc, showDialog } = EstApp; @@ -27,19 +28,31 @@ const EstActions = (() => { if (!rows) throw Error("작업 표를 먼저 선택하세요."); return rows; } - function undo(redo = false) { + async function undo(redo = false) { const { state } = EstApp, source = redo ? state.future : state.history, target = redo ? state.history : state.future; - if (!source.length) return; - target.push(JSON.stringify(state.project)); - const id = state.project.id; - state.project = JSON.parse(source.pop()); - if (id) state.project.id = id; - state.revision++; - state.selection.clear(); - EstApp.cache(); - EstApp.render(); + if (!source.length || restoring) return; + const entry = source.at(-1), + revision = state.revision, + documentKey = state.documentKey; + restoring = true; + try { + const project = typeof entry === "string" ? JSON.parse(entry) : await EstApp.restore(entry); + if (revision !== state.revision || documentKey !== state.documentKey) return; + target.push(EstApp.snapshot()); + if (target.length > 8) target.shift(); + source.pop(); + const id = state.project.id; + state.project = project; + if (id) state.project.id = id; + state.revision++; + state.selection.clear(); + EstApp.cache(); + EstApp.render(); + } finally { + restoring = false; + } } function insertRow() { const rows = activeRows(), @@ -131,8 +144,8 @@ const EstActions = (() => { EstApp.setProject(await api("/api/sample")); } else if (name === "open") EstApp.byId("open-file").click(); else if (name === "save" || name === "save-as") await EstApp.save(name === "save-as"); - else if (name === "undo") undo(); - else if (name === "redo") undo(true); + else if (name === "undo") await undo(); + else if (name === "redo") await undo(true); else if (name === "add-row") insertRow(); else if (name === "add-group") insertGroup(); else if (name === "delete-row") deleteRows(); diff --git a/tools/est-test/native-app.js b/tools/est-test/native-app.js index 33b1d11a4..43a32588c 100644 --- a/tools/est-test/native-app.js +++ b/tools/est-test/native-app.js @@ -55,13 +55,25 @@ const EstApp = (() => { state.notice = "임시 저장 용량이 부족합니다. 저장 버튼으로 작업을 보관하세요."; } } + function snapshot() { + const serialized = JSON.stringify(state.project); + if (serialized.length < 10 * 1024 * 1024 || !globalThis.CompressionStream) return serialized; + return new Response(new Blob([serialized]).stream().pipeThrough(new CompressionStream("gzip"))) + .blob() + .catch(() => serialized); + } + async function restore(snapshot) { + const saved = await snapshot; + return JSON.parse( + typeof saved === "string" + ? saved + : await new Response(saved.stream().pipeThrough(new DecompressionStream("gzip"))).text(), + ); + } function remember() { state.revision++; - const serialized = JSON.stringify(state.project); - if (serialized.length < 10 * 1024 * 1024) { - state.history.push(serialized); - if (state.history.length > 8) state.history.shift(); - } + state.history.push(snapshot()); + if (state.history.length > 8) state.history.shift(); state.future = []; } function calculate() { @@ -267,6 +279,8 @@ const EstApp = (() => { byId, api, cache, + snapshot, + restore, remember, calculate, change,