Files
figcraft/crates/format/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

153 lines
6.8 KiB
Rust

//! The version history in `.figcraft` files: round trips, files without versions, damaged entries.
use std::io::Write;
use std::sync::Arc;
use figcraft_doc::{Document, NodeKind};
use super::*;
fn png(r: u8) -> Vec<u8> {
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([r, 0, 0, 255]));
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
fn zip_of(members: &[(&str, &[u8])]) -> Vec<u8> {
let mut buf = std::io::Cursor::new(Vec::new());
let mut z = zip::ZipWriter::new(&mut buf);
for (name, bytes) in members {
z.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
z.write_all(bytes).unwrap();
}
z.finish().unwrap();
buf.into_inner()
}
fn doc_json(name: &str) -> Vec<u8> {
serde_json::to_vec(&to_json(&Document::new(name)).unwrap()).unwrap()
}
#[test]
fn roundtrip_with_versions() {
// The version has an image the current document no longer uses, and vice versa.
let mut old = Document::new("File");
let p = old.first_page().unwrap();
old.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
let (old_img, ..) = add_image(&mut old, png(1)).unwrap();
let mut cur = Document::new("File");
let (cur_img, ..) = add_image(&mut cur, png(2)).unwrap();
let versions = vec![Version::new("v1", "First", "with a box", 1_700_000_000, Arc::new(old.clone()))];
let bytes = save_bytes_with_versions(&cur, None, &versions).unwrap();
let l = load_bytes_with_versions(&bytes).unwrap();
assert!(l.issues.is_empty(), "{:?}", l.issues);
assert_eq!(l.versions.len(), 1);
let v = &l.versions[0];
assert_eq!((v.id.as_str(), v.name.as_str(), v.description.as_str(), v.time), ("v1", "First", "with a box", 1_700_000_000));
let snap = v.document().unwrap();
assert_eq!(snap.node_count(), old.node_count());
assert!(snap.images.contains_key(&old_img));
// Images only the history uses stay out of the document.
assert!(l.doc.images.contains_key(&cur_img));
assert!(!l.doc.images.contains_key(&old_img));
// Saving what was read (not yet parsed) keeps it intact.
let again = load_bytes_with_versions(&save_bytes_with_versions(&l.doc, None, &l.versions).unwrap()).unwrap();
assert_eq!(again.versions.len(), 1);
assert_eq!(again.versions[0].document().unwrap().node_count(), old.node_count());
assert!(again.versions[0].images().contains_key(&old_img));
// Readers that ignore versions still open the file.
let (plain, _) = load_bytes(&bytes).unwrap();
assert_eq!(plain.node_count(), cur.node_count());
}
#[test]
fn files_without_versions_load_unchanged() {
let mut d = Document::new("Old");
let (h, ..) = add_image(&mut d, png(3)).unwrap();
let l = load_bytes_with_versions(&save_bytes(&d, None).unwrap()).unwrap();
assert!(l.versions.is_empty() && l.issues.is_empty());
assert!(l.doc.images.contains_key(&h));
// A minimal archive (document.json only) and bare JSON.
assert!(load_bytes_with_versions(&zip_of(&[("document.json", &doc_json("Min"))])).unwrap().versions.is_empty());
assert_eq!(load_bytes_with_versions(&doc_json("Bare")).unwrap().doc.name, "Bare");
}
#[test]
fn damaged_versions_never_fail_the_load() {
let doc = doc_json("D");
// An unreadable index: the file opens without a history.
let l = load_bytes_with_versions(&zip_of(&[("document.json", &doc), ("versions/index.json", b"{nope")])).unwrap();
assert!(l.versions.is_empty());
assert_eq!(l.issues.len(), 1);
// Bad ids, a missing snapshot, duplicates, junk fields and a damaged snapshot.
let index = serde_json::json!({"versions": [
{"id": "../escape", "name": "bad id"},
{"id": 7},
{"id": "missing", "name": "no member"},
{"id": "ok", "name": "Fine", "description": 5, "time": "soon", "images": [1, "unknown-hash"]},
{"id": "ok", "name": "duplicate"},
{"id": "broken", "name": "Broken"},
]});
let bytes = zip_of(&[
("document.json", &doc),
("versions/index.json", &serde_json::to_vec(&index).unwrap()),
("versions/ok.json", &doc_json("Snap")),
("versions/broken.json", b"\xff\xfe not json"),
]);
let l = load_bytes_with_versions(&bytes).unwrap();
let names: Vec<&str> = l.versions.iter().map(|v| v.name.as_str()).collect();
assert_eq!(names, ["Fine", "Broken"]);
assert_eq!(l.issues.len(), 4, "{:?}", l.issues);
let ok = &l.versions[0];
assert_eq!((ok.description.as_str(), ok.time), ("", 0));
assert!(ok.images().is_empty());
assert_eq!(ok.document().unwrap().name, "Snap");
// The damaged snapshot fails only when used.
assert!(l.versions[1].document().is_err());
// A snapshot from a newer format is refused too.
let newer = zip_of(&[
("document.json", &doc),
("versions/index.json", br#"{"versions":[{"id":"n","name":"Newer"}]}"#),
("versions/n.json", br#"{"version": 999}"#),
]);
assert!(matches!(load_bytes_with_versions(&newer).unwrap().versions[0].document(), Err(FormatError::Newer(999))));
}
#[test]
fn oversized_and_excess_versions_are_skipped() {
let doc = doc_json("D");
// Member size guard.
let bytes = zip_of(&[("big", &[0u8; 64])]);
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
assert!(read_member(&mut z, "big", 63).is_err());
assert_eq!(read_member(&mut z, "big", 64).unwrap().len(), 64);
assert!(read_member(&mut z, "absent", 64).is_err());
// More entries than the cap: the first MAX_VERSIONS are kept.
let n = MAX_VERSIONS + 3;
let entries: Vec<serde_json::Value> = (0..n).map(|i| serde_json::json!({"id": format!("v{i}"), "name": format!("V{i}")})).collect();
let index = serde_json::to_vec(&serde_json::json!({ "versions": entries })).unwrap();
let names: Vec<String> = (0..n).map(|i| format!("versions/v{i}.json")).collect();
let mut members: Vec<(&str, &[u8])> = vec![("document.json", &doc), ("versions/index.json", &index)];
members.extend(names.iter().map(|m| (m.as_str(), doc.as_slice())));
let l = load_bytes_with_versions(&zip_of(&members)).unwrap();
assert_eq!(l.versions.len(), MAX_VERSIONS);
assert_eq!(l.issues.len(), 1);
}
#[test]
fn saving_refuses_bad_version_ids() {
let d = Arc::new(Document::new("x"));
assert!(save_bytes_with_versions(&d, None, &[Version::new("a/b", "n", "", 0, d.clone())]).is_err());
assert!(save_bytes_with_versions(&d, None, &[Version::new("a", "n", "", 0, d.clone()), Version::new("a", "m", "", 0, d.clone())]).is_err());
// Names and descriptions are clipped.
let v = Version::new("a", &"n".repeat(MAX_VERSION_NAME + 10), &"é".repeat(MAX_VERSION_DESCRIPTION + 1), 0, d);
assert_eq!(v.name.chars().count(), MAX_VERSION_NAME);
assert_eq!(v.description.chars().count(), MAX_VERSION_DESCRIPTION);
}