Files
figcraft/crates/engine/src/version_tests.rs
T
bflatastic 1caf771e82 M8: comments and version history behaviour suite and fixes
A behaviour suite for comments and version history written from the public documentation
(`crates/engine/src/history_behaviour_tests.rs`, 15 tests, about 80 table rows plus stepwise checks),
each row with a one-line derivation; where the documentation is silent the row says so and pins
today's behaviour. 4 of the 15 tests failed on the old code. Fixes, all in shared engine code:

- **Live sessions: comment threads made in the session couldn't be used.** Their ids come from the
  creator's id space (e.g. 4294967297) and every thread command (`comment.reply`, `edit`, `react`,
  `resolve`, `move`, `delete`, `markRead`, `open`, `link`) refused them as "out of range".
  Comment ids are now parsed as ids, not small indexes.
- **Restoring a version lost newer comments.** Comments are a conversation about the file, kept
  apart from its design history: restoring now keeps the current comments; a comment on a layer
  the version doesn't have stays where it was (on the nearest surviving parent, or the canvas).
- **Restoring now keeps the replaced design in the history** as an autosave first, unless a
  version of it was just saved or restored (so work isn't only in the session's undo history).
  The checkpoint marker is now "none yet" for a freshly opened file and compares contents when
  normalising copied the document.
- **A titled autosave becomes a named version** (`version.rename` clears `auto`), so it is no longer
  dropped when autosaves make room.
- **Merging a branch adds to the main file's history:** the main file as it was (an autosave,
  when it changed since its last version), then `Merged “<branch>”`.
- `version.restore` refuses up front for a view-only live session (nothing goes into the history).

Pinned where the documentation is silent: pins go to the innermost frame under them (not to a
leaf layer); copies, duplicates and duplicated pages come without comments; replying reopens a
resolved thread; only authors delete their messages (no accounts); stepping past the last thread
stays on it; comments show on the canvas by default; versions stay with whoever saved them in a
live session.

`crates/engine/src/collab_live_tests.rs`: the test `Room` is `pub(crate)` so the suite can run a
live session. `crates/engine/src/cmd/props.rs`: one line reformatted by `cargo fmt` (it was
unformatted on the base, which failed the fmt check). `crates/render/src/effects_behaviour_tests.rs`:
the paint suite expected an offset drop shadow to grow the export on every side, but the later export
suite made exports grow only where the shadow falls (40 x 50 for a shadow 10 down); the base failed
the gate on it, so the expectation now says "grows" (wider or taller). No new commands.

Docs: collaboration-parity, target-app-parity (area 16 85%/60% -> 85%/62%, 2-5 h -> 2-4 h; full
parity ~306-595 h -> ~306-594 h; percentages unchanged at this rounding), ROADMAP (also corrects the
stale area 16 row, 80%/58%/4-8 h), gaps (gap 4's landed list), mcp.md.
2026-10-12 02:09:10 +07:00

175 lines
7.3 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(), 3, "restoring keeps the history (plus a checkpoint of what the first restore replaced)");
}
#[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();
// Only the checkpoint the restore made of the file as opened is left.
assert_eq!(list(&mut t).iter().map(|v| v["auto"] == true).collect::<Vec<_>>(), vec![true]);
let _ = std::fs::remove_dir_all(&dir);
}