Files
figcraft/crates/engine/src/branch_tests.rs
T
bflatastic c50bbd08bd M8: branch version history and finer list merges
- A branch keeps its own version history. On a branch tab, `file.saveVersion`, `version.autosave` and the `version.*` commands (list, preview, restore, rename, delete, duplicate) act on the branch's versions; the main file's history is untouched. The Version history panel on a branch tab lists the branch's versions (its banner gives way to the version banner while one is viewed).
- Branch versions are saved in the `.figcraft` file with the branch (`branches/<id>/versions/index.json` plus one snapshot each, the same layout as the file's own history; images shared; damaged entries skipped and reported; the snapshot byte budget is shared across branches).
- Merging adds a version to the main file's history (`Merged "<name>"`, after an autosave of main when it had changed); the merged or archived branch keeps its own history and opens with it.
- Fills, strokes and effects merge item by item in `figcraft_collab::merge`: different items changed on each side, or items added at the end on one side (or the same ones on both), merge without a conflict; the same item changed both ways, an item removed on one side, or different items added on both sides still conflict as the whole list. Both sides' writes become the merged list, so merging and Update from main write the same result.
2026-10-12 02:30:25 +07:00

265 lines
14 KiB
Rust

//! Branches through commands: create, edit on both sides, review, resolve, merge as one undo step,
//! update from main, archive, saving with the file, and a fuzz test over random real commands.
use figcraft_collab::merge::{Side, plan};
use figcraft_collab::sim::Rng;
use figcraft_collab::{Change, DocProp, Mode, Replica, Target};
use figcraft_doc::{Document, NodeId, NodeKind};
use serde_json::{Value, json};
use crate::Session;
use crate::collab_tests::{base, random_command};
fn ok(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
/// The base file open, with a branch of it made and shown: (session, branch id, the rectangles).
fn branched() -> (Session, String, Vec<NodeId>) {
let mut s = Session::with_document(base());
let rects: Vec<NodeId> = s.doc().unwrap().nodes().filter(|n| matches!(n.kind, NodeKind::Rectangle)).map(|n| n.id).collect();
let b = ok(&mut s, "branch.create", json!({"name": "Explore"}));
assert_eq!(b["index"], 1);
assert_eq!(s.active, 1);
(s, b["id"].as_str().unwrap().to_string(), rects)
}
fn fill(s: &Session, id: NodeId) -> String {
serde_json::to_string(&s.doc().unwrap().node(id).unwrap().fills).unwrap()
}
#[test]
fn edits_on_both_sides_merge_as_one_undo_step() {
let (mut s, id, r) = branched();
ok(&mut s, "fill.set", json!({"ids": [r[0]], "color": "#FF0000"}));
let made = ok(&mut s, "shape.rectangle", json!({"x": 900, "y": 900, "width": 10, "height": 10}))["id"].clone();
// Main is edited meanwhile; the branch edit made it unsaved.
ok(&mut s, "doc.switch", json!({"index": 0}));
assert!(s.ds().unwrap().is_dirty());
ok(&mut s, "fill.set", json!({"ids": [r[1]], "color": "#00FF00"}));
let review = ok(&mut s, "branch.review", json!({"id": id}));
assert_eq!(review["conflicts"], json!([]));
assert!(review["branch"]["layers"].as_array().unwrap().iter().any(|l| l["id"] == json!(r[0].0)), "{review}");
assert!(review["main"]["layers"].as_array().unwrap().iter().any(|l| l["id"] == json!(r[1].0)));
let main_before = fill(&s, r[1]);
let merged = ok(&mut s, "branch.merge", json!({"id": id}));
assert_eq!(merged["merged"], true);
assert_eq!(s.docs.len(), 1, "the branch tab closes");
let doc = s.doc().unwrap();
assert!(doc.node(figcraft_doc::NodeId(made.as_u64().unwrap())).is_some());
assert!(fill(&s, r[0]).contains("1.0") && fill(&s, r[1]) == main_before);
assert_eq!(ok(&mut s, "branch.list", json!({}))[0]["state"], "merged");
// One undo step takes the merge back.
ok(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().node(figcraft_doc::NodeId(made.as_u64().unwrap())).is_none());
assert_eq!(fill(&s, r[1]), main_before);
assert!(s.execute("branch.merge", &json!({"id": id})).is_err(), "a merged branch can't merge again");
}
#[test]
fn conflicts_must_be_resolved_and_either_side_can_win() {
for (pick, want) in [("branch", "#FF0000"), ("main", "#0000FF")] {
let (mut s, id, r) = branched();
ok(&mut s, "fill.set", json!({"ids": [r[0]], "color": "#FF0000"}));
ok(&mut s, "doc.switch", json!({"index": 0}));
ok(&mut s, "fill.set", json!({"ids": [r[0]], "color": "#0000FF"}));
let review = ok(&mut s, "branch.review", json!({"id": id}));
let c = &review["conflicts"][0];
assert_eq!(review["conflicts"].as_array().unwrap().len(), 1, "{review}");
let key = c["key"].as_str().unwrap().to_string();
assert_eq!(key, format!("{}/fills", r[0]));
assert_eq!(c["property"], "fills");
assert!(c["branch"].as_str().unwrap().contains("FF0000") && c["main"].as_str().unwrap().contains("0000FF"), "{c}");
let err = s.execute("branch.merge", &json!({"id": id})).unwrap_err().to_string();
assert!(err.contains(&key), "{err}");
assert!(s.execute("branch.merge", &json!({"id": id, "resolutions": {&key: "both"}})).is_err());
ok(&mut s, "branch.merge", json!({"id": id, "resolutions": {&key: pick}}));
let want = Session::with_document({
let mut t = Session::with_document(s.doc().unwrap().clone());
ok(&mut t, "fill.set", json!({"ids": [r[0]], "color": want}));
t.doc().unwrap().clone()
});
assert_eq!(fill(&s, r[0]), fill(&want, r[0]), "{pick} wins");
}
}
#[test]
fn deleting_what_main_edited_is_an_explicit_conflict() {
let (mut s, id, r) = branched();
ok(&mut s, "edit.delete", json!({"ids": [r[2]]}));
ok(&mut s, "doc.switch", json!({"index": 0}));
ok(&mut s, "node.set", json!({"id": r[2], "props": {"name": "Keep me"}}));
let review = ok(&mut s, "branch.review", json!({"id": id}));
let c = &review["conflicts"][0];
assert_eq!(
(c["key"].as_str().unwrap(), &c["deletedBy"], &c["branch"], &c["main"]),
(format!("{}/layer", r[2]).as_str(), &json!("branch"), &json!("deleted"), &json!("edited"))
);
assert!(s.execute("branch.merge", &json!({"id": id})).is_err(), "never dropped silently");
ok(&mut s, "branch.merge", json!({"id": id, "resolutions": {c["key"].as_str().unwrap(): "main"}}));
assert_eq!(s.doc().unwrap().node(r[2]).unwrap().name, "Keep me");
}
#[test]
fn update_from_main_then_merge() {
let (mut s, _, r) = branched();
ok(&mut s, "node.set", json!({"id": r[0], "props": {"opacity": 0.5}}));
ok(&mut s, "doc.switch", json!({"index": 0}));
ok(&mut s, "node.set", json!({"id": r[1], "props": {"opacity": 0.25}}));
ok(&mut s, "node.set", json!({"id": r[0], "props": {"name": "Main name"}}));
ok(&mut s, "doc.switch", json!({"index": 1}));
ok(&mut s, "branch.updateFromMain", json!({}));
let tab = s.doc().unwrap();
assert_eq!((tab.node(r[0]).unwrap().opacity, tab.node(r[1]).unwrap().opacity), (0.5, 0.25));
assert_eq!(tab.node(r[0]).unwrap().name, "Main name");
assert!(s.ds().unwrap().history.undo.is_empty(), "undo can't step past the new base");
// Main's changes are now in the branch's base: nothing on main to review any more.
let review = ok(&mut s, "branch.review", json!({}));
assert_eq!(review["main"]["layers"], json!([]));
// A conflicting update needs a resolution too.
ok(&mut s, "node.set", json!({"id": r[2], "props": {"opacity": 0.1}}));
ok(&mut s, "doc.switch", json!({"index": 0}));
ok(&mut s, "node.set", json!({"id": r[2], "props": {"opacity": 0.9}}));
ok(&mut s, "doc.switch", json!({"index": 1}));
let key = format!("{}/opacity", r[2]);
assert!(s.execute("branch.updateFromMain", &json!({})).is_err());
ok(&mut s, "branch.updateFromMain", json!({"resolutions": {&key: "branch"}}));
assert_eq!(s.doc().unwrap().node(r[2]).unwrap().opacity, 0.1);
ok(&mut s, "branch.merge", json!({}));
let doc = s.doc().unwrap();
assert_eq!(s.active, 0);
assert_eq!((doc.node(r[0]).unwrap().opacity, doc.node(r[2]).unwrap().opacity), (0.5, 0.1));
}
#[test]
fn styles_variables_and_comments_merge() {
let (mut s, id, r) = branched();
ok(&mut s, "fill.set", json!({"ids": [r[0]], "color": "#123456"}));
ok(&mut s, "style.create", json!({"type": "paint", "name": "Brand", "from": r[0]}));
let page = s.ds().unwrap().page;
ok(&mut s, "comment.add", json!({"x": 1, "y": 2, "text": "On the branch", "page": page}));
ok(&mut s, "doc.switch", json!({"index": 0}));
ok(&mut s, "variable.create", json!({"collection": "Tokens", "name": "accent", "type": "color", "value": "#FF00FF"}));
let review = ok(&mut s, "branch.review", json!({"id": id}));
let what: Vec<&str> = review["branch"]["document"].as_array().unwrap().iter().map(|d| d["what"].as_str().unwrap()).collect();
assert!(what.contains(&"Style “Brand”") && what.contains(&"Comment “On the branch”"), "{what:?}");
assert!(review["main"]["document"].as_array().unwrap().iter().any(|d| d["what"] == "Variable “accent”" && d["change"] == "added"));
ok(&mut s, "branch.merge", json!({"id": id}));
let doc = s.doc().unwrap();
assert!(doc.styles.iter().any(|st| st.name == "Brand"));
assert!(doc.comments.iter().any(|c| c.first.text == "On the branch"));
assert!(doc.variables.variables.iter().any(|v| v.name == "accent"));
}
#[test]
fn branch_tabs_save_with_the_main_file_and_come_back() {
let dir = std::env::temp_dir().join(format!("figcraft-branches-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("file.figcraft").to_string_lossy().to_string();
let (mut s, id, r) = branched();
ok(&mut s, "node.set", json!({"id": r[0], "props": {"name": "Branch edit"}}));
// Saving on the branch tab saves the main file, branch included.
ok(&mut s, "file.saveAs", json!({"path": path}));
assert_eq!(s.docs[0].path.as_deref(), Some(path.as_str()));
assert!(!s.docs[0].is_dirty() && !s.docs[1].is_dirty());
// Closing the main file closes its branch tabs.
ok(&mut s, "file.close", json!({"index": 0}));
assert!(s.docs.is_empty());
let mut t = Session::empty();
ok(&mut t, "file.open", json!({"path": path}));
let list = ok(&mut t, "branch.list", json!({}));
assert_eq!((list[0]["id"].as_str().unwrap(), &list[0]["name"], &list[0]["state"]), (id.as_str(), &json!("Explore"), &json!("active")));
ok(&mut t, "branch.open", json!({"id": id}));
assert_eq!(t.doc().unwrap().node(r[0]).unwrap().name, "Branch edit");
assert!(!t.docs[0].is_dirty(), "opening a branch is no edit");
// New layers on the reopened branch are made in its own id space.
let made = ok(&mut t, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))["id"].as_u64().unwrap();
assert!(made >> 32 > 0, "{made}");
// Archiving keeps it in the file and closes its tab.
ok(&mut t, "branch.archive", json!({}));
assert_eq!(t.docs.len(), 1);
assert_eq!(ok(&mut t, "branch.list", json!({}))[0]["state"], "archived");
assert!(t.execute("branch.merge", &json!({"id": id})).is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn branch_commands_refuse_what_makes_no_sense() {
let (mut s, _, _) = branched();
assert!(s.execute("branch.create", &json!({})).is_err(), "no branches of branches");
assert!(s.execute("branch.open", &json!({"id": "nope"})).is_err());
ok(&mut s, "doc.switch", json!({"index": 0}));
assert!(s.execute("branch.review", &json!({})).is_err(), "main needs an id");
assert!(s.execute("branch.merge", &json!({"id": "nope"})).is_err());
}
/// Every change a side made (that the other side didn't overwrite) shows in `doc`.
fn shows(changes: &[Change], doc: &Document) -> Result<(), String> {
let r = Replica::new(doc.clone());
for c in changes {
let ok = match c {
Change::Create { id, .. } => doc.contains(*id),
Change::Delete { id } => !doc.contains(*id),
Change::Set { prop: figcraft_collab::Prop::Position, .. } => true,
Change::Set { id, prop, value } => r.value(&Target::Node(*id, *prop)).ok().flatten().as_ref() == Some(value),
Change::SetDoc { prop: DocProp::StyleOrder | DocProp::CommentOrder | DocProp::CollectionOrder | DocProp::VariableOrder, .. } => true,
Change::SetDoc { prop, value } => r.value(&Target::Doc(*prop)).ok().flatten().as_ref() == Some(value),
Change::Put { entry, value } => r.value(&Target::Entry(entry.clone())).ok().flatten().as_ref() == Some(value),
Change::Remove { entry: figcraft_collab::Entry::Image(_) } => true,
Change::Remove { entry } => r.value(&Target::Entry(entry.clone())).ok().flatten().is_none(),
};
if !ok {
return Err(format!("{c:?}"));
}
}
Ok(())
}
/// The fuzz test: random real commands on a branch and on main. Without conflicts the merge (both
/// ways) equals applying both change sets and shows every change of either side; with conflicts,
/// resolving them all for either side gives a valid document.
#[test]
fn random_edits_on_both_sides_merge() {
let (mut disjoint, mut conflicted) = (0, 0);
for seed in 0..60 {
let mut rng = Rng::new(seed);
let start = base();
let mut on_branch = start.clone();
on_branch.set_client_id(start.highest_id_space() + 1);
let mut sides = [Session::with_document(on_branch), Session::with_document(start.clone())];
for s in &mut sides {
for _ in 0..1 + rng.below(5) {
let (cmd, params) = random_command(s.doc().unwrap(), &mut rng, false);
let _ = s.execute(cmd, &params);
}
}
let [b, m] = sides.map(|s| s.doc().unwrap().clone());
let p = plan(&start, &b, &m).unwrap();
if p.conflicts.is_empty() {
disjoint += 1;
let none = |_: &figcraft_collab::merge::Key| None;
let (into_main, _) = p.merge(&m, Side::Branch, &none).unwrap_or_else(|e| panic!("seed {seed}: {e}"));
let (into_branch, _) = p.merge(&b, Side::Main, &none).unwrap_or_else(|e| panic!("seed {seed}: {e}"));
let mut both = Replica::new(start.clone());
both.apply(&p.main, Mode::Strict).unwrap();
both.apply(&p.branch, Mode::Strict).unwrap();
let mut want = both.document().clone();
(want.name, want.current_page) = (m.name.clone(), m.current_page);
assert_eq!(into_main, want, "seed {seed}");
for doc in [&into_main, &into_branch] {
assert!(doc.validate().is_empty(), "seed {seed}: {:?}", doc.validate());
for side in [&p.branch, &p.main] {
shows(side, doc).unwrap_or_else(|c| panic!("seed {seed}: lost {c}"));
}
}
} else {
conflicted += 1;
for side in [Side::Branch, Side::Main] {
let (doc, _) = p.merge(&m, Side::Branch, &|_| Some(side)).unwrap_or_else(|e| panic!("seed {seed} ({side:?} wins): {e}"));
assert!(doc.validate().is_empty(), "seed {seed}: {:?}", doc.validate());
}
}
}
assert!(disjoint >= 10 && conflicted >= 5, "{disjoint} disjoint, {conflicted} with conflicts");
}