Files
figcraft/crates/collab/src/diff_tests.rs
T

253 lines
11 KiB
Rust

//! Diffing and applying documents directly (the engine-level round trips run real commands).
use std::collections::BTreeMap;
use std::sync::Arc;
use figcraft_color::{Color, Paint};
use figcraft_doc::{Comment, DevSnapshot, Document, ImageData, Message, Node, NodeId, NodeKind, Style, StyleKind};
use crate::change::{Change, DocProp, Entry, Json, Prop};
use crate::replica::{Mode, Replica};
use crate::{Error, diff, message};
/// `apply(diff(a, b), a) == b`, positions included, also after an encode/decode round trip.
fn round_trip(a: &Replica, b: &Document) -> Vec<Change> {
let d = diff(a, b).unwrap();
let mut r = a.clone();
r.apply(&d.changes, Mode::Strict).unwrap();
assert_eq!(r.document(), b, "{:#?}", d.changes);
assert_eq!(r, d.replica, "positions agree");
let wire: Vec<Change> = message::decode(&message::encode(&d.changes).unwrap(), usize::MAX).unwrap();
assert_eq!(wire, d.changes, "binary round trip");
let json: Vec<Change> = message::decode_json(&message::encode_json(&d.changes).unwrap(), usize::MAX).unwrap();
assert_eq!(json, d.changes, "JSON round trip");
d.changes
}
fn rect(doc: &mut Document, parent: NodeId, name: &str) -> NodeId {
doc.create(parent, NodeKind::Rectangle, name, 0.0, 0.0, 10.0, 10.0).unwrap()
}
fn setup() -> (Replica, NodeId, NodeId, Vec<NodeId>) {
let mut doc = Document::new("File");
let page = doc.first_page().unwrap();
let frame = doc.create(page, NodeKind::Frame, "Frame", 0.0, 0.0, 200.0, 200.0).unwrap();
let kids = (0..5).map(|i| rect(&mut doc, frame, &format!("R{i}"))).collect();
(Replica::new(doc), page, frame, kids)
}
#[test]
fn property_edits_send_only_what_changed() {
let (a, _, _, kids) = setup();
let mut b = a.document().clone();
b.get_mut(kids[1]).unwrap().opacity = 0.5;
b.get_mut(kids[1]).unwrap().fills = vec![Paint::solid(Color::from_rgb8_const(1, 2, 3))];
let changes = round_trip(&a, &b);
assert_eq!(changes.len(), 2);
assert!(changes.iter().all(|c| matches!(c, Change::Set { id, prop: Prop::Opacity | Prop::Fills, .. } if *id == kids[1])));
assert!(round_trip(&a, a.document()).is_empty(), "nothing changed");
}
#[test]
fn creations_moves_reorders_and_deletions_round_trip() {
let (a, page, frame, kids) = setup();
let mut b = a.document().clone();
// A new group with a new child inside the frame, one rectangle moved into it, one deleted,
// one reordered to the bottom, one moved to the page.
let g = b.create(frame, NodeKind::Group, "G", 5.0, 5.0, 1.0, 1.0).unwrap();
let inner = rect(&mut b, g, "Inner");
b.place(kids[0], g, 0).unwrap();
b.remove(kids[1]).unwrap();
b.reorder(kids[4], 0).unwrap();
b.place(kids[2], page, 0).unwrap();
let changes = round_trip(&a, &b);
let creates: Vec<NodeId> = changes.iter().filter_map(|c| if let Change::Create { id, .. } = c { Some(*id) } else { None }).collect();
assert_eq!(creates, vec![g, inner], "parents before children");
assert!(changes.contains(&Change::Delete { id: kids[1] }));
// kids[3] kept its place and key.
assert!(!changes.iter().any(|c| c.node() == Some(kids[3])));
}
#[test]
fn deleting_a_subtree_sends_one_delete_and_moves_out_first() {
let (a, page, frame, kids) = setup();
let mut b = a.document().clone();
b.place(kids[0], page, 0).unwrap();
b.remove(frame).unwrap();
let changes = round_trip(&a, &b);
let deletes: Vec<&Change> = changes.iter().filter(|c| matches!(c, Change::Delete { .. })).collect();
assert_eq!(deletes, vec![&Change::Delete { id: frame }]);
let moved = changes.iter().position(|c| matches!(c, Change::Set { id, prop: Prop::Parent, .. } if *id == kids[0])).unwrap();
let deleted = changes.iter().position(|c| matches!(c, Change::Delete { .. })).unwrap();
assert!(moved < deleted);
}
#[test]
fn pages_add_reorder_and_delete() {
let (a, page, _, _) = setup();
let mut b = a.document().clone();
let p2 = b.add_page("Two");
let p3 = b.add_page("Three");
rect(&mut b, p3, "On three");
b.pages = vec![p3, page, p2];
let after = round_trip(&a, &b);
assert!(after.iter().any(|c| matches!(c, Change::Create { id, parent: None, .. } if *id == p3)));
let a2 = diff(&a, &b).unwrap().replica;
let mut c = b.clone();
c.remove(page).unwrap();
round_trip(&a2, &c);
}
#[test]
fn kind_changes_and_transforms_round_trip() {
let (a, _, frame, kids) = setup();
let mut b = a.document().clone();
b.get_mut(frame).unwrap().kind = NodeKind::Component { description: "A card".into(), props: BTreeMap::new() };
b.get_mut(kids[0]).unwrap().kind = NodeKind::Ellipse { start: 0.0, sweep: 3.0, inner: 0.25 };
b.get_mut(kids[0]).unwrap().transform = figcraft_geom::Affine::new([0.5, 0.1, -0.1, 2.0, 1.0 / 3.0, -7.25]);
round_trip(&a, &b);
}
fn style(id: &str) -> Style {
Style {
id: id.into(),
name: format!("S {id}"),
description: String::new(),
kind: StyleKind::Paint { paints: vec![Paint::solid(Color::BLACK)] },
bound: Default::default(),
}
}
#[test]
fn document_level_data_round_trips() {
let (a, page, _, kids) = setup();
let mut b = a.document().clone();
b.name = "Renamed".into();
b.document_colors.push(Color::from_rgb8_const(9, 9, 9));
b.styles = vec![style("s1"), style("s2"), style("s3")];
b.comments.push(Comment {
id: 7,
page,
x: 1.0,
y: 2.0,
anchor: None,
first: Message { author: "Ada".into(), text: "Hi".into(), created: 1, edited: 0, reactions: vec![] },
resolved: false,
replies: vec![],
read_by: None,
});
b.images.insert("hash".into(), ImageData { bytes: Arc::new(vec![1, 2, 3, 250]), width: 2, height: 1 });
let snap = DevSnapshot { nodes: vec![Arc::new(b.node(kids[0]).unwrap().clone())] };
b.dev_snapshots.insert(kids[0], Arc::new(snap));
let changes = round_trip(&a, &b);
assert!(changes.contains(&Change::SetDoc { prop: DocProp::Name, value: Json::of("Renamed").unwrap() }));
let a2 = diff(&a, &b).unwrap().replica;
// Edit one style, remove one, reorder: per-entry changes plus the order.
let mut c = b.clone();
c.styles[0].name = "First".into();
c.styles.remove(1);
c.styles.reverse();
c.images.clear();
c.comments[0].resolved = true;
let changes = round_trip(&a2, &c);
assert!(changes.contains(&Change::Remove { entry: Entry::Style("s2".into()) }));
assert!(changes.contains(&Change::Remove { entry: Entry::Image("hash".into()) }));
assert!(changes.iter().any(|ch| matches!(ch, Change::SetDoc { prop: DocProp::StyleOrder, .. })));
// Duplicate ids fall back to the whole list.
let a3 = diff(&a2, &c).unwrap().replica;
let mut d = c.clone();
d.styles.push(style("s1"));
let changes = round_trip(&a3, &d);
assert!(matches!(changes.as_slice(), [Change::SetDoc { prop: DocProp::Styles, .. }]));
}
#[test]
fn snapshots_rebuild_the_same_state() {
let (a, page, frame, _) = setup();
let mut b = a.document().clone();
b.add_page("Second");
b.name = "Snap".into();
b.reorder(frame, 0).unwrap();
rect(&mut b, page, "Top");
b.styles.push(style("s"));
let r = diff(&a, &b).unwrap().replica;
let snap = r.snapshot().unwrap();
let back = Replica::from_snapshot(&snap).unwrap();
assert_eq!(back, r);
assert!(Replica::from_snapshot(&[]).is_err(), "a snapshot needs a page");
}
#[test]
fn diffing_costs_the_size_of_the_edit() {
let mut doc = Document::new("Big");
let page = doc.first_page().unwrap();
let ids: Vec<NodeId> = (0..20_000).map(|i| rect(&mut doc, page, &format!("R{i}"))).collect();
let a = Replica::new(doc);
let mut b = a.document().clone();
b.get_mut(ids[123]).unwrap().name = "Edited".into();
let t = std::time::Instant::now();
let changes = round_trip(&a, &b);
assert_eq!(changes.len(), 1);
// Inserting one layer in the middle writes one key; its siblings keep theirs.
let mut c = a.document().clone();
let n = Node::new(c.alloc_id(), NodeKind::Ellipse { start: 0.0, sweep: 1.0, inner: 0.0 }, "Mid", 1.0, 1.0);
c.insert(n, page, Some(10_000)).unwrap();
let changes = round_trip(&a, &c);
assert_eq!(changes.len(), 1);
assert!(t.elapsed().as_secs_f64() < 5.0, "debug builds included");
}
#[test]
fn keys_respace_before_they_grow_long() {
let (mut a, _, frame, _) = setup();
// Keep inserting at the same spot: keys would grow, so the parent is re-spaced now and then.
for i in 0..400 {
let mut b = a.document().clone();
let n = Node::new(b.alloc_id(), NodeKind::Rectangle, format!("N{i}"), 1.0, 1.0);
b.insert(n, frame, Some(1)).unwrap();
round_trip(&a, &b);
a = diff(&a, &b).unwrap().replica;
}
assert!(a.positions().values().all(|k| k.len() <= crate::frac::KEY_RESPACE_LEN));
}
#[test]
fn invalid_changes_are_refused_and_lenient_mode_skips_them() {
let (a, page, frame, kids) = setup();
let set = |id: NodeId, prop: Prop, v: serde_json::Value| Change::Set { id, prop, value: Json(v) };
let frame_kind = || Json::of(&NodeKind::Frame).unwrap();
let bad: Vec<(Change, &str)> = vec![
(set(NodeId(999), Prop::Name, "x".into()), "unknown node"),
(set(kids[0], Prop::Opacity, "loud".into()), "wrong type"),
(set(kids[0], Prop::Opacity, 2.0.into()), "out of range"),
(set(kids[0], Prop::Width, (-1.0).into()), "negative size"),
(set(frame, Prop::Parent, serde_json::json!(kids[0])), "into a leaf"),
(set(frame, Prop::Parent, serde_json::Value::Null), "a frame without a parent"),
(set(page, Prop::Parent, serde_json::json!(frame)), "a page into a frame"),
(set(kids[0], Prop::Position, "not a key".into()), "bad key"),
(set(kids[0], Prop::Kind, serde_json::json!({"type": "page"})), "becoming a page"),
(set(frame, Prop::Kind, serde_json::json!({"type": "rectangle"})), "a leaf with children"),
(Change::Create { id: kids[0], kind: frame_kind(), parent: Some(page), position: "a5".into(), props: vec![] }, "existing id"),
(Change::Create { id: NodeId(500), kind: frame_kind(), parent: None, position: "a5".into(), props: vec![] }, "parentless frame"),
(Change::Create { id: NodeId(500), kind: frame_kind(), parent: Some(NodeId(404)), position: "a5".into(), props: vec![] }, "missing parent"),
(Change::Delete { id: page }, "the last page"),
(Change::Put { entry: Entry::Style("a".into()), value: Json::of(&style("b")).unwrap() }, "entry id mismatch"),
];
for (c, why) in bad {
let mut r = a.clone();
assert!(r.apply(std::slice::from_ref(&c), Mode::Strict).is_err(), "{why}: {c:?}");
let mut r = a.clone();
let out = r.apply(&[c, set(kids[0], Prop::Name, "ok".into())], Mode::Lenient).unwrap();
assert!(out.skipped <= 1, "{why}");
assert_eq!(r.document().node(kids[0]).unwrap().name, "ok");
}
// A cycle.
let mut r = a.clone();
let inner = Change::Create { id: NodeId(600), kind: frame_kind(), parent: Some(frame), position: "a9".into(), props: vec![] };
let cycle = set(frame, Prop::Parent, serde_json::json!(600));
assert!(matches!(r.apply(&[inner, cycle], Mode::Strict), Err(Error::Invalid(_))));
// Deleting what's already gone does nothing.
let mut r = a.clone();
assert!(r.apply(&[Change::Delete { id: NodeId(12345) }, Change::Remove { entry: Entry::Comment(1) }], Mode::Strict).is_ok());
}