fix(est): retain undo history for large projects

This commit is contained in:
2026-11-05 14:08:10 +09:00
parent b15f7cbf09
commit 114a353b9e
4 changed files with 96 additions and 19 deletions
+48
View File
@@ -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
+3 -1
View File
@@ -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`는 이전 데모의 회귀 시험용이며 현재 진입 화면에서 사용하지 않습니다.
+26 -13
View File
@@ -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();
+19 -5
View File
@@ -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,