Files
vectorcraft/crates/engine/src/tests_history_mem.rs
arhammusheer 5beac4f122 #1244: smaller objects: Node 352 -> 152 bytes, NodeKind 200 -> 80, and a cheaper undo estimate
Rarely set fields move into one optional boxed NodeExtras, and large NodeKind payloads (images,
live shapes, blends, envelopes) are boxed. Every object, and every undo step that copies it,
shrinks: a 50k-path move keeps ~24.5 MB per step instead of ~40 MB. Files stay byte-identical
(Node keeps its flat JSON through a borrowed Serialize and a from-wire Deserialize, no flatten),
and loading is slightly faster. Undo and redo reuse their step's size estimate instead of
walking the document again (8.3 -> 5.6 ms for a 50k-path move).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc

# Conflicts:
#	crates/engine/src/cmd/graph.rs
#	crates/svg/src/export.rs
2026-10-12 11:02:32 +07:00

156 lines
6.5 KiB
Rust

//! The undo history's memory (#1244): steps keep no lookup tables, unchanged selections are
//! shared between steps, and past a memory budget the oldest steps go (never the last few).
use serde_json::json;
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::shapes;
use super::*;
/// A session with one document holding `n` rectangles, all selected.
fn session_with_rects(n: usize) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 2000, "height": 2000})).unwrap();
s.edit("Add", |d, _| {
let layer = d.layers[0].id;
for i in 0..n {
let id = d.alloc_id();
let r = shapes::rectangle(kurbo::Rect::new(i as f64, 0.0, i as f64 + 10.0, 10.0));
d.insert(Some(layer), usize::MAX, Node::path(id, r, Appearance::default_art()))?;
}
Ok(())
})
.unwrap();
s.execute("select.all", &json!({})).unwrap();
s
}
fn first_x(s: &Session) -> f64 {
let d = &s.doc().unwrap().doc;
let id = d.layers[0].children().unwrap()[0].id;
d.node(id).unwrap().geometric_bounds().unwrap().x0
}
#[test]
fn past_the_budget_the_oldest_steps_go_but_the_last_few_stay() {
let n = 500;
let mut s = session_with_rects(n);
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
let step = s.doc().unwrap().history.undo.last().unwrap().bytes;
// Each move keeps every rectangle's old node: about 700 bytes each (measured: ~775).
assert!((300 * n..1500 * n).contains(&step), "{step} bytes for moving {n} rectangles");
// A budget of about three moves.
s.active_mut().unwrap().history.byte_budget = 3 * step;
for _ in 0..20 {
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
}
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), History::MIN_STEPS, "only the last few steps are kept");
assert!(st.history.undo.iter().all(|e| e.label == "Move"), "the newest ones: {:?}", st.history.undo.iter().map(|e| &e.label).collect::<Vec<_>>());
// The kept steps undo as before.
let x = first_x(&s);
for _ in 0..History::MIN_STEPS {
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(first_x(&s), x - History::MIN_STEPS as f64);
assert!(s.execute("edit.undo", &json!({})).is_err(), "the older steps went");
// Redo too stays within the budget, keeping the next few steps.
let st = s.doc().unwrap();
assert_eq!(st.history.redo.len(), History::MIN_STEPS);
for _ in 0..History::MIN_STEPS {
s.execute("edit.redo", &json!({})).unwrap();
}
assert_eq!(first_x(&s), x);
}
#[test]
fn within_the_budget_the_step_limit_still_counts() {
let mut s = session_with_rects(10);
s.active_mut().unwrap().history.limit = 12;
for _ in 0..30 {
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
}
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), 12);
assert!(st.history.bytes() < st.history.byte_budget);
}
#[test]
fn undo_steps_keep_no_lookup_tables() {
let mut s = session_with_rects(200);
for _ in 0..5 {
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
// Lookups build the current document's table.
first_x(&s);
assert!(s.doc().unwrap().doc.has_lookup_cache());
}
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let st = s.doc().unwrap();
assert!(!st.history.undo.is_empty() && !st.history.redo.is_empty());
for e in st.history.undo.iter().chain(&st.history.redo) {
assert!(!e.doc.has_lookup_cache(), "step {} keeps a lookup table", e.label);
}
// The document undone to builds its table again on its first lookup.
first_x(&s);
assert!(s.doc().unwrap().doc.has_lookup_cache());
// A drag's snapshot goes into the history without its table too.
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 3, "dy": 0})).unwrap();
first_x(&s);
s.commit_interaction().unwrap();
assert!(!s.doc().unwrap().history.undo.last().unwrap().doc.has_lookup_cache());
}
#[test]
fn steps_share_an_unchanged_selection() {
let mut s = session_with_rects(100);
for _ in 0..4 {
s.execute("object.move", &json!({"dx": 1, "dy": 0})).unwrap();
}
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 3, "dy": 0})).unwrap();
s.preview("object.move", &json!({"dx": 4, "dy": 0})).unwrap();
s.commit_interaction().unwrap();
let st = s.doc().unwrap();
let moves: Vec<_> = st.history.undo.iter().filter(|e| e.label == "Move").collect();
assert_eq!(moves.len(), 5);
assert_eq!(moves[0].selection.objects.len(), 100);
assert!(moves.windows(2).all(|w| Arc::ptr_eq(&w[0].selection, &w[1].selection)), "one selection for all the moves");
// Undo and redo keep it shared, and restore it.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let st = s.doc().unwrap();
assert!(Arc::ptr_eq(&st.history.redo[0].selection, &st.history.redo[1].selection));
assert_eq!(st.selection.objects.len(), 100);
}
/// Undo and redo don't estimate the steps again: the step an undo (redo) makes takes the undone
/// step's figures the other way round, which are what a fresh estimate gives.
#[test]
fn undo_and_redo_reuse_the_estimates() {
let mut s = session_with_rects(200);
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
// A deletion is lopsided: the step before it keeps the objects, the one after keeps nothing.
s.execute("edit.clear", &json!({})).unwrap();
let fresh = |e: &HistoryEntry, now: &Document| e.doc.retained_bytes(now);
let st = s.doc().unwrap();
let delete = st.history.undo.last().unwrap();
assert!(delete.bytes > 200 * 300, "the deleted objects: {}", delete.bytes);
s.execute("edit.undo", &json!({})).unwrap();
let st = s.doc().unwrap();
let redo = st.history.redo.last().unwrap();
assert_eq!(redo.bytes, fresh(redo, &st.doc));
assert!(redo.bytes < 4096, "the document without the objects keeps little of its own: {}", redo.bytes);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.redo", &json!({})).unwrap();
let st = s.doc().unwrap();
let undo = st.history.undo.last().unwrap();
assert_eq!(undo.label, "Move");
assert_eq!(undo.bytes, fresh(undo, &st.doc));
s.execute("edit.redo", &json!({})).unwrap();
let st = s.doc().unwrap();
let delete = st.history.undo.last().unwrap();
assert_eq!(delete.bytes, fresh(delete, &st.doc));
}