fix(est): retain undo history for large projects
This commit is contained in:
@@ -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
|
||||
@@ -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`는 이전 데모의 회귀 시험용이며 현재 진입 화면에서 사용하지 않습니다.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user