mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 13:26:54 -06:00
253 lines
11 KiB
Rust
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());
|
|
}
|