Files
figcraft/crates/engine/src/branch_tests.rs
T
Filip StrajnarandClaude Opus 5.5 fb55872450 M9: one auto layout edit on each side of a branch is one conflict (#2026)
A padding changed on both sides of a branch gave a conflict for every size and place auto
layout worked out from it: the component's, each instance's and their hugging parents' width
and height, each instance's layout, and every child's position (10 in a file with one instance,
138 with 15). The merge was refused until each had a side, yet the merged file laid itself out
again from the padding whatever they got. A `layout` conflict also took all auto layout fields
together, so a gap changed only on the branch was lost when main won the padding.

`merge::plan` now leaves out properties both sides changed to values both files work out for
themselves (`Plan::derived`): a hugging auto layout frame's or a filling child's size, text
sizing to its characters, an auto layout child's place (when both sides turn and scale it the
same way), and a value a layer in an instance has as its main's layer has it. Merging keeps the
file merged into's own value and normalising lays it out from the merged inputs.

`layout` and `childLayout` merge field by field: a field changed on one side (or the same way on
both) merges, and each field changed both ways is a conflict of its own (`Key::Field`,
`<id>/layout.padding`), written whole from the merged fields and each picked side's value. The
review shows it as "auto layout padding" with both sides' values ("8, 8, 8, 8").

Tests: `branch_tests::one_conflicting_auto_layout_edit_is_one_conflict` (the issue's file: one
conflict, either side laid out as if set directly), `auto_layout_fields_changed_one_way_survive_a_conflict`
(merge and update from main), `sizes_auto_layout_works_out_never_conflict`; the merge fuzz test
leaves worked-out values out of what each side must show. Docs: collaboration-parity.md, mcp.md,
ROADMAP.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvG6E2LxvJ6MuFmiLA6Zqm
2026-11-06 12:17:11 +01:00

419 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::{Key, Side, plan};
use figcraft_collab::sim::Rng;
use figcraft_collab::{Change, DocProp, Mode, Prop, 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());
}
/// Node id `v` of a command's answer (`{"id": …}` or a bare id).
fn idv(v: &Value) -> NodeId {
NodeId(v.get("id").unwrap_or(v).as_u64().unwrap())
}
/// The issue #2026 file, with a branch of it made and shown: a component `Card` (horizontal auto
/// layout hugging its contents, padding 12, one 40×20 rectangle) and an instance of it in a
/// vertical auto layout frame hugging its contents. Returns (session, branch id, card, instance,
/// frame, rectangle).
fn card_file() -> (Session, String, NodeId, NodeId, NodeId, NodeId) {
let mut s = Session::with_document(Document::new("Cards"));
let props = json!({"layout": "horizontal", "padding": 12, "sizingH": "hug", "sizingV": "hug"});
let frame = idv(&ok(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 50, "name": "Card", "props": props})));
let rect = idv(&ok(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20, "parent": frame})));
let card = idv(&ok(&mut s, "component.create", json!({"ids": [frame]})));
let props = json!({"layout": "vertical", "padding": 0, "sizingH": "hug", "sizingV": "hug"});
let stack = idv(&ok(&mut s, "frame.create", json!({"x": 0, "y": 200, "width": 100, "height": 100, "name": "Stack", "props": props})));
let inst = idv(&ok(&mut s, "instance.create", json!({"component": card, "parent": stack})));
let b = ok(&mut s, "branch.create", json!({"name": "padding 8"}));
(s, b["id"].as_str().unwrap().to_string(), card, inst, stack, rect)
}
/// Size and place of every layer of `doc` (what auto layout works out).
fn geometry(doc: &Document) -> Vec<(NodeId, [f64; 8])> {
doc.nodes()
.map(|n| {
let c = n.transform.as_coeffs();
(n.id, [n.width, n.height, c[0], c[1], c[2], c[3], c[4], c[5]])
})
.collect()
}
/// Issue #2026: one auto layout edit on each side is one conflict (the field changed both ways),
/// not one for every size and place auto layout works out from it; the merged file lays out
/// from the padding picked.
#[test]
fn one_conflicting_auto_layout_edit_is_one_conflict() {
for (pick, padding) in [("branch", 8.0), ("main", 16.0)] {
let (mut s, id, card, inst, _, _) = card_file();
ok(&mut s, "node.set", json!({"id": card, "props": {"padding": 8}}));
ok(&mut s, "doc.switch", json!({"index": 0}));
ok(&mut s, "node.set", json!({"id": card, "props": {"padding": 16}}));
// What main looks like with the picked padding set directly.
let mut direct = Session::with_document(s.doc().unwrap().clone());
ok(&mut direct, "node.set", json!({"id": card, "props": {"padding": padding}}));
let review = ok(&mut s, "branch.review", json!({"id": id}));
let conflicts = review["conflicts"].as_array().unwrap();
assert_eq!(conflicts.len(), 1, "{review}");
let key = format!("{card}/layout.padding");
assert_eq!(conflicts[0]["key"], key.as_str());
assert_eq!(
(&conflicts[0]["property"], &conflicts[0]["branch"], &conflicts[0]["main"]),
(&json!("auto layout padding"), &json!("8, 8, 8, 8"), &json!("16, 16, 16, 16"))
);
let err = s.execute("branch.merge", &json!({"id": id})).unwrap_err().to_string();
assert!(err.contains(&key) && !err.contains("width"), "{err}");
ok(&mut s, "branch.merge", json!({"id": id, "resolutions": {&key: pick}}));
let doc = s.doc().unwrap();
for n in [card, inst] {
assert_eq!(doc.node(n).unwrap().layout.as_ref().unwrap().padding, [padding; 4], "{pick} wins");
}
assert_eq!(geometry(doc), geometry(direct.doc().unwrap()), "{pick} wins: laid out from the padding picked");
}
}
/// Auto layout merges field by field: a gap changed only on the branch is kept whichever side's
/// padding wins, both when merging and when updating the branch from main.
#[test]
fn auto_layout_fields_changed_one_way_survive_a_conflict() {
for (cmd, pick, padding) in [("branch.merge", "main", 16.0), ("branch.merge", "branch", 8.0), ("branch.updateFromMain", "main", 16.0)] {
let (mut s, id, card, _, _, _) = card_file();
ok(&mut s, "node.set", json!({"id": card, "props": {"padding": 8, "gap": 30}}));
ok(&mut s, "doc.switch", json!({"index": 0}));
ok(&mut s, "node.set", json!({"id": card, "props": {"padding": 16, "mainAlign": "center"}}));
let key = format!("{card}/layout.padding");
let review = ok(&mut s, "branch.review", json!({"id": id}));
assert_eq!(review["conflicts"].as_array().unwrap().iter().map(|c| c["key"].clone()).collect::<Vec<_>>(), vec![json!(key)], "{review}");
ok(&mut s, cmd, json!({"id": id, "resolutions": {&key: pick}}));
if cmd == "branch.updateFromMain" {
ok(&mut s, "doc.switch", json!({"index": 1}));
}
let layout = s.doc().unwrap().node(card).unwrap().layout.clone().unwrap();
assert_eq!((layout.padding, layout.gap, layout.main_align), ([padding; 4], 30.0, figcraft_doc::MainAlign::Center), "{cmd}, {pick} wins");
}
}
/// Auto layout on one side and a hugging frame's size on both: the sizes auto layout works out
/// on each side are no conflict, and merging leaves no stale size behind.
#[test]
fn sizes_auto_layout_works_out_never_conflict() {
let (mut s, id, card, _, stack, rect) = card_file();
ok(&mut s, "node.set", json!({"id": rect, "props": {"width": 60}}));
ok(&mut s, "doc.switch", json!({"index": 0}));
ok(&mut s, "node.set", json!({"id": card, "props": {"padding": 4}}));
let review = ok(&mut s, "branch.review", json!({"id": id}));
assert_eq!(review["conflicts"], json!([]), "{review}");
let mut direct = Session::with_document(s.doc().unwrap().clone());
ok(&mut direct, "node.set", json!({"id": rect, "props": {"width": 60}}));
ok(&mut s, "branch.merge", json!({"id": id}));
assert_eq!(s.doc().unwrap().node(stack).unwrap().width, 60.0 + 8.0);
assert_eq!(geometry(s.doc().unwrap()), geometry(direct.doc().unwrap()));
}
/// 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(())
}
/// [`base`] with two export settings and two guides on its first frame (lists that merge item by
/// item).
pub(crate) fn base_with_lists() -> Document {
let mut doc = base();
let f = doc.node(doc.first_page().unwrap()).unwrap().children[0];
let n = doc.get_mut(f).unwrap();
n.exports = ["@1", "@2"].map(|suffix| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() }).to_vec();
n.guides = vec![figcraft_doc::Guide { vertical: true, offset: 10.0 }, figcraft_doc::Guide { vertical: false, offset: 20.0 }];
doc
}
/// A random real command: often an edit of a frame's export settings or guides, else
/// [`random_command`].
pub(crate) fn random_edit(doc: &Document, rng: &mut Rng, with_undo: bool) -> (&'static str, Value) {
if rng.chance(0.6) {
return random_command(doc, rng, with_undo);
}
let frames: Vec<NodeId> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Frame)).map(|n| n.id).collect();
let f = rng.pick(&frames).map_or(json!(0), |i| json!(i));
let i = rng.below(3);
match rng.below(6) {
0 => ("export.add", json!({"ids": [f]})),
1 => ("export.update", json!({"ids": [f], "index": i, "suffix": format!("@{}", rng.below(9))})),
2 => ("export.remove", json!({"ids": [f], "index": i})),
3 => ("guide.add", json!({"frame": f, "vertical": rng.chance(0.5), "offset": rng.below(300)})),
4 => ("guide.move", json!({"frame": f, "index": i, "offset": rng.below(300)})),
_ => ("guide.remove", json!({"frame": f, "index": i})),
}
}
/// The fuzz test: random real commands on a branch and on main (export settings and guides
/// included). 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, mut lists) = (0, 0, 0);
for seed in 0..60 {
let mut rng = Rng::new(seed);
let start = base_with_lists();
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_edit(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();
// Export settings or guides of a layer changed on both sides that merged item by item.
let list_writes = |changes: &[Change]| -> std::collections::BTreeSet<Key> {
changes
.iter()
.filter_map(|c| match c {
Change::Set { id, prop: prop @ (Prop::Exports | Prop::Guides), .. } => Some(Key::Node(*id, *prop)),
_ => None,
})
.collect()
};
lists += list_writes(&p.branch).intersection(&list_writes(&p.main)).filter(|k| !p.conflicts.iter().any(|c| c.key == **k)).count();
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}"));
// Values both sides work out for themselves (sizes and places auto layout gives) stay
// as the file merged into has them.
let chosen = |changes: &[Change]| -> Vec<Change> {
changes
.iter()
.filter(|c| !matches!(c, Change::Set { id, prop, .. } if p.derived().contains(&Key::Node(*id, *prop))))
.cloned()
.collect()
};
let mut both = Replica::new(start.clone());
both.apply(&p.main, Mode::Strict).unwrap();
both.apply(&chosen(&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(&chosen(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 && lists >= 3, "{disjoint} disjoint, {conflicted} with conflicts, {lists} lists merged item by item");
}