Files
figcraft/crates/engine/src/version_tests.rs
T
BFlatandClaude Opus 5.5 72b8c5d154 M6: version history: versions saved in the .figcraft file, version commands and panel (#21)
- Versions now belong to the document (DocState.versions) instead of a process-global map keyed by
  document uid, so they survive closing the file. A new, renamed or deleted version marks the file
  as changed.
- .figcraft: `versions/index.json` (id, name, description, time, image hashes) plus one deflated
  `versions/<id>.json` snapshot per version; images only versions use are stored once and kept with
  them. Snapshots read from a file are parsed on first use (and written back as-is). Readers that
  don't know versions ignore the entries; files without versions load unchanged. Damaged, missing,
  oversized or duplicate entries are skipped and reported, never failing the load. At most 100
  versions per file; names and descriptions are clipped.
- Commands: `file.saveVersion {name?, description?}` returns the version; `version.list`;
  `version.restore {id | index}` (one undo step, keeps the file name, stays on the current page);
  `version.rename {id | index, name, description?}`; `version.delete`; `version.duplicate` (opens
  the snapshot in a new tab). Indexes are validated: a negative index used to restore version 0.
  `req_index` param helper shared with the guide commands.
- Version history panel in the left sidebar in place of Pages and Layers (File > Show version
  history; Save to version history opens it with the form): save form (title, description),
  "Current version" on top and the versions newest first on a timeline with name, relative time and
  description; per-version menu (also right-click): Restore, Rename (title and description inline),
  Duplicate, Delete (confirmed inline). New shared `widgets::button`.
- `figcraft-cli run --out x.figcraft` keeps the version history.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:16:18 +07:00

174 lines
7.1 KiB
Rust

//! Version history: save, list, restore (one undo step), rename, delete, duplicate, persistence in
//! the `.figcraft` file and bad parameters.
use serde_json::{Value, json};
use crate::params::id_of;
use crate::{EngineError, Session};
fn rect(s: &mut Session, x: f64) -> figcraft_doc::NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"]).unwrap()
}
fn count(s: &Session) -> usize {
s.doc().unwrap().node_count()
}
fn list(s: &mut Session) -> Vec<Value> {
s.execute("version.list", &json!({})).unwrap().as_array().unwrap().clone()
}
#[test]
fn save_list_restore_and_undo() {
let mut s = Session::new();
s.execute("file.rename", &json!({"name": "Shop"})).unwrap();
rect(&mut s, 0.0);
let one = count(&s);
let v = s.execute("file.saveVersion", &json!({"name": " First ", "description": "one box"})).unwrap();
assert_eq!(v["name"], "First");
assert_eq!(v["description"], "one box");
assert_eq!(v["index"], 0);
assert!(s.ds().unwrap().is_dirty(), "a new version is an unsaved change");
rect(&mut s, 20.0);
let v2 = s.execute("file.saveVersion", &json!({})).unwrap();
assert_eq!(v2["name"], "Version 2", "untitled versions are numbered");
rect(&mut s, 40.0);
let three = count(&s);
let l = list(&mut s);
assert_eq!(l.len(), 2);
assert_eq!(l[0]["name"], "First");
assert!(l[0]["time"].as_u64().unwrap() > 0);
// The panel request comes with the same list.
assert_eq!(s.execute("file.versionHistory", &json!({})).unwrap(), Value::Array(l.clone()));
assert!(s.ui_requests.iter().any(|r| r.kind == "panel.versionHistory"));
// Restore by id: the content goes back, the file name stays; one undo step brings it back.
s.execute("file.rename", &json!({"name": "Shop v2"})).unwrap();
let r = s.execute("version.restore", &json!({"id": l[0]["id"]})).unwrap();
assert_eq!(r["name"], "First");
assert_eq!(count(&s), one);
assert_eq!(s.doc().unwrap().name, "Shop v2");
assert!(s.selection().is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(count(&s), three);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(count(&s), one);
// By index.
s.execute("version.restore", &json!({"index": 1})).unwrap();
assert_eq!(count(&s), one + 1);
assert_eq!(list(&mut s).len(), 2, "restoring keeps the history");
}
#[test]
fn rename_delete_duplicate() {
let mut s = Session::new();
s.execute("file.rename", &json!({"name": "App"})).unwrap();
rect(&mut s, 0.0);
let a = s.execute("file.saveVersion", &json!({"name": "A", "description": "first"})).unwrap();
rect(&mut s, 20.0);
s.execute("file.saveVersion", &json!({"name": "B"})).unwrap();
// Rename keeps the description unless one is given.
let r = s.execute("version.rename", &json!({"id": a["id"], "name": "Alpha"})).unwrap();
assert_eq!((r["name"].as_str(), r["description"].as_str()), (Some("Alpha"), Some("first")));
s.execute("version.rename", &json!({"index": 0, "name": "Alpha", "description": "changed"})).unwrap();
assert_eq!(list(&mut s)[0]["description"], "changed");
assert!(s.execute("version.rename", &json!({"index": 0, "name": " "})).is_err());
// Long names are clipped.
s.execute("version.rename", &json!({"index": 1, "name": "x".repeat(5000)})).unwrap();
assert_eq!(list(&mut s)[1]["name"].as_str().unwrap().chars().count(), figcraft_format::MAX_VERSION_NAME);
// Duplicate opens the version in a new tab without touching this one.
let before = count(&s);
let d = s.execute("version.duplicate", &json!({"id": a["id"]})).unwrap();
assert_eq!(d["index"], 1);
assert_eq!(s.active, 1);
assert_eq!(s.doc().unwrap().name, "App (Alpha)");
assert_eq!(count(&s), before - 1);
assert!(s.ds().unwrap().versions.is_empty());
s.execute("doc.switch", &json!({"index": 0})).unwrap();
assert_eq!(count(&s), before);
// Delete.
s.execute("version.delete", &json!({"id": a["id"]})).unwrap();
let l = list(&mut s);
assert_eq!(l.len(), 1);
assert_eq!(l[0]["index"], 0);
assert!(s.execute("version.restore", &json!({"id": a["id"]})).is_err());
}
#[test]
fn bad_indexes_and_ids_are_rejected() {
let mut s = Session::new();
rect(&mut s, 0.0);
s.execute("file.saveVersion", &json!({"name": "Only"})).unwrap();
rect(&mut s, 20.0);
let n = count(&s);
for p in [
json!({"index": -1}),
json!({"index": 1}),
json!({"index": 0.5}),
json!({"index": "NaN"}),
json!({"index": 1e300}),
json!({"id": "nope"}),
json!({}),
json!(null),
] {
for cmd in ["version.restore", "version.delete", "version.duplicate"] {
assert!(matches!(s.execute(cmd, &p), Err(EngineError::BadParams { .. })), "{cmd} {p}");
}
let mut q = p.as_object().cloned().unwrap_or_default();
q.insert("name".into(), json!("x"));
assert!(s.execute("version.rename", &Value::Object(q)).is_err(), "rename {p}");
}
// Nothing changed: a negative index never restores version 0.
assert_eq!(count(&s), n);
assert_eq!(list(&mut s).len(), 1);
assert_eq!(s.docs.len(), 1);
}
#[test]
fn history_is_capped() {
let mut s = Session::new();
for i in 0..figcraft_format::MAX_VERSIONS {
s.execute("file.saveVersion", &json!({"name": format!("v{i}")})).unwrap();
}
assert!(matches!(s.execute("file.saveVersion", &json!({})), Err(EngineError::Disabled(..))));
let l = list(&mut s);
assert_eq!(l.len(), figcraft_format::MAX_VERSIONS);
// Ids are unique even when saved within the same millisecond.
let ids: std::collections::BTreeSet<&str> = l.iter().filter_map(|v| v["id"].as_str()).collect();
assert_eq!(ids.len(), l.len());
}
#[test]
fn versions_persist_in_the_file() {
let dir = std::env::temp_dir().join(format!("figcraft-versions-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("history.figcraft").to_string_lossy().to_string();
let mut s = Session::new();
rect(&mut s, 0.0);
let one = count(&s);
s.execute("file.saveVersion", &json!({"name": "Start", "description": "a box"})).unwrap();
rect(&mut s, 20.0);
s.execute("file.save", &json!({"path": path})).unwrap();
assert!(!s.ds().unwrap().is_dirty());
// Reopen in a fresh session: the history is there and restores.
let mut t = Session::empty();
t.execute("file.open", &json!({"path": path})).unwrap();
let l = list(&mut t);
assert_eq!(l.len(), 1);
assert_eq!((l[0]["name"].as_str(), l[0]["description"].as_str()), (Some("Start"), Some("a box")));
assert!(!t.ds().unwrap().is_dirty());
t.execute("version.restore", &json!({"index": 0})).unwrap();
assert_eq!(count(&t), one);
// Deleting a version is an unsaved change; saving drops it from the file.
t.execute("edit.undo", &json!({})).unwrap();
t.execute("version.delete", &json!({"index": 0})).unwrap();
assert!(t.ds().unwrap().is_dirty());
t.execute("file.save", &json!({})).unwrap();
t.execute("file.revert", &json!({})).unwrap();
assert!(list(&mut t).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}