Something went wrong. Try again.
inkfinite (an infinite canvas/whiteboard) monorepo ink.stormlightlabs.org
svelte rust tauri svg canvas perfect-freehand
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338#![allow(clippy::float_cmp)]
use std::collections::BTreeMap;
use crate::crdt::{AutomergeDocument, CrdtDocument};use crate::proto::{ Bounds, LayerPatch, LayoutAxis, Operation, Query, ShapeAlignment, ShapePatch, TransactionDraft, TransactionId,};use crate::{ ActorId, BindingAnchor, BindingId, BindingKind, BindingRecord, Document, DocumentId, LayerId, LayerRecord, Opacity, Origin, PageId, PageRecord, Provenance, RecordVersion, SemanticMetadata, ShapeId, ShapeKind, ShapeParent, ShapeProperties, ShapeRecord, ShapeStyle, SiblingAnchor, Timestamp, Transform, Vec2,};use proptest::prelude::*;use serde_json::json;
use super::{EngineError, TransactionEngine};use crate::engine::geometry::{world_shape_bounds, world_transform};
fn metadata(actor: &str, name: &str) -> SemanticMetadata { SemanticMetadata { name: Some(name.into()), title: None, role: Some("diagram.process".into()), description: None, body: None, tags: vec!["important".into()], source: None, link: None, custom_metadata: BTreeMap::new(), locked: false, agent_editable: true, provenance: Provenance { actor_id: ActorId::from(actor), origin: Origin::Human, timestamp: Timestamp(0), source: None, }, }}
fn shape(id: &str, x: f64) -> ShapeRecord { ShapeRecord { id: ShapeId::from(id), kind: ShapeKind::from("rect"), parent: ShapeParent::Layer(LayerId::from("layer:one")), transform: Transform { translation: Vec2 { x, y: 20.0 }, rotation: 0.0, scale_x: 1.0, scale_y: 1.0 }, child_ids: Vec::new(), layout: None, properties: ShapeProperties::from([ ("width".into(), json!(10.0)), ("height".into(), json!(5.0)), ("content".into(), json!(format!("text for {id}"))), ]), metadata: metadata("actor:seed", id), style: ShapeStyle { opacity: Opacity::OPAQUE, fill_opacity: None, stroke_opacity: None }, version: RecordVersion(1), }}
pub fn document() -> Document { let page_id = PageId::from("page:one"); let layer_id = LayerId::from("layer:one"); let shape_a = shape("shape:a", 0.0); let shape_b = shape("shape:b", 30.0); let shape_c = shape("shape:c", 80.0); Document { pages: BTreeMap::from([( page_id.clone(), PageRecord { id: page_id.clone(), name: "Page".into(), layer_ids: vec![layer_id.clone()], version: RecordVersion(1), }, )]), page_ids: vec![page_id.clone()], layers: BTreeMap::from([( layer_id.clone(), LayerRecord { id: layer_id, page_id, name: "Layer".into(), shape_ids: vec![shape_a.id.clone(), shape_b.id.clone(), shape_c.id.clone()], visible: true, locked: false, opacity: Opacity::OPAQUE, version: RecordVersion(1), }, )]), shapes: BTreeMap::from([ (shape_a.id.clone(), shape_a), (shape_b.id.clone(), shape_b), (shape_c.id.clone(), shape_c), ]), bindings: BTreeMap::new(), assets: BTreeMap::new(), }}
fn engine() -> TransactionEngine { TransactionEngine::create( DocumentId::from("document:test"), ActorId::from("actor:local"), document(), ) .unwrap()}
fn transaction(engine: &mut TransactionEngine, actor: &str, id: &str, operations: Vec<Operation>) -> TransactionDraft { TransactionDraft { id: TransactionId(id.into()), actor_id: ActorId::from(actor), origin: Origin::Human, base_heads: engine.snapshot().unwrap().heads, description: id.into(), operations, timestamp: Timestamp(1), }}
#[test]fn conversion_changes_kind_without_losing_style_or_metadata() { let mut engine = engine(); let shape_id = ShapeId::from("shape:a"); let before = engine.snapshot().unwrap().document.shapes[&shape_id].clone(); let transaction = transaction( &mut engine, "actor:local", "transaction:convert", vec![Operation::ConvertShape { shape_id: shape_id.clone(), kind: crate::ELLIPSE_KIND.into(), properties: ShapeProperties::from([ ("width".into(), json!(20.0)), ("height".into(), json!(12.0)), ("fill".into(), json!("#fff")), ]), style: None, expected_version: Some(before.version), }], );
engine.commit(transaction).unwrap(); let converted = engine.snapshot().unwrap().document.shapes[&shape_id].clone(); assert_eq!(converted.kind.as_str(), crate::ELLIPSE_KIND); assert_eq!(converted.metadata, before.metadata); assert_eq!(converted.style, before.style); assert_eq!(converted.parent, before.parent); assert_eq!(converted.transform, before.transform);
engine.undo(&ActorId::from("actor:local")).unwrap(); let restored = engine.snapshot().unwrap().document.shapes[&shape_id].clone(); assert_eq!(restored.kind, before.kind); assert_eq!(restored.properties, before.properties); assert_eq!(restored.metadata, before.metadata); assert_eq!(restored.style, before.style);}
#[test]fn card_metadata_survives_commit_and_undo() { let mut engine = engine(); let shape_id = ShapeId::from("shape:a"); let before = engine.snapshot().unwrap().document.shapes[&shape_id].clone(); let mut card = before.metadata.clone(); card.title = Some("Research note".into()); card.body = Some("Read the source".into()); card.source = Some("paper.pdf".into()); card.link = Some("https://example.com".into()); card.tags = vec!["source".into()]; card.custom_metadata.insert("priority".into(), json!(1));
let transaction = transaction( &mut engine, "actor:local", "transaction:card-metadata", vec![Operation::PatchShape { shape_id: shape_id.clone(), patch: ShapePatch { metadata: Some(card.clone()), ..ShapePatch::default() }, expected_version: Some(before.version), }], ); engine.commit(transaction).unwrap();
assert_eq!(engine.snapshot().unwrap().document.shapes[&shape_id].metadata, card); engine.undo(&ActorId::from("actor:local")).unwrap(); assert_eq!( engine.snapshot().unwrap().document.shapes[&shape_id].metadata, before.metadata );}
#[test]fn geometry_is_normalized_and_bounded_at_the_commit_boundary() { let mut engine = engine(); let path_id = ShapeId::from("shape:path"); let stroke_id = ShapeId::from("shape:stroke"); let mut path = shape(path_id.as_str(), 120.0); path.kind = ShapeKind::from(crate::PATH_KIND); path.properties = ShapeProperties::from([ ( "subpaths".into(), json!([{ "segments": [ { "type": "move", "to": { "x": 0.0, "y": 0.0 } }, { "type": "cubic", "control_1": { "x": 0.0, "y": 30.0 }, "control_2": { "x": 30.0, "y": 30.0 }, "to": { "x": 30.0, "y": 0.0 } } ], "closed": true }]), ), ("fill_rule".into(), json!("evenodd")), ]); let mut stroke = shape(stroke_id.as_str(), 180.0); stroke.kind = ShapeKind::from(crate::STROKE_KIND); stroke.properties = ShapeProperties::from([ ("points".into(), json!([[0.0, 0.0], [40.0, 20.0], [80.0, 0.0]])), ("style".into(), json!({ "color": "#000", "opacity": 1.0 })), ( "brush".into(), json!({ "size": 12.0, "thinning": 0.5, "smoothing": 0.5, "streamline": 0.5, "simulatePressure": true }), ), ]); let draft = transaction( &mut engine, "actor:local", "create geometry", vec![ Operation::CreateShape { shape: path, anchor: SiblingAnchor::Last }, Operation::CreateShape { shape: stroke, anchor: SiblingAnchor::Last }, ], ); let result = engine.commit(draft).expect("valid geometry should commit"); assert!(result.affected_regions.iter().any(|region| region.bounds.width > 0.0));
let snapshot = engine.snapshot().expect("snapshot after geometry commit"); let committed_path = &snapshot.document.shapes[&path_id]; assert_eq!(committed_path.properties["fill_rule"], json!("evenodd")); assert_eq!( committed_path.properties["subpaths"][0]["segments"][0]["type"], json!("move") ); let committed_stroke = &snapshot.document.shapes[&stroke_id]; assert_eq!(committed_stroke.properties["brush"]["simulatePressure"], json!(true));}
#[test]fn invalid_path_topology_is_rejected_before_commit() { let mut engine = engine(); let before = engine.snapshot().expect("initial snapshot"); let path_id = ShapeId::from("shape:invalid-path"); let mut invalid = shape(path_id.as_str(), 120.0); invalid.kind = ShapeKind::from(crate::PATH_KIND); invalid.properties = ShapeProperties::from([ ( "subpaths".into(), json!([{ "segments": [ { "type": "move", "to": { "x": 0.0, "y": 0.0 } }, { "type": "line", "to": { "x": 10.0, "y": 0.0 } }, { "type": "move", "to": { "x": 20.0, "y": 0.0 } } ], "closed": false }]), ), ("fill_rule".into(), json!("nonzero")), ]); let draft = transaction( &mut engine, "actor:local", "invalid path", vec![Operation::CreateShape { shape: invalid, anchor: SiblingAnchor::Last }], ); let result = engine.commit(draft); assert!(matches!(result, Err(EngineError::Schema(_)))); assert_eq!(engine.snapshot().expect("snapshot after rejection").heads, before.heads);}
#[test]fn transaction_is_atomic_and_returns_inverse_patch_heads_and_regions() { let mut engine = engine(); let initial = engine.snapshot().unwrap(); let draft = transaction( &mut engine, "actor:local", "rename and move", vec![ Operation::RenamePage { page_id: PageId::from("page:one"), name: "Architecture".into(), expected_version: Some(RecordVersion(1)), }, Operation::PatchShape { shape_id: ShapeId::from("shape:a"), patch: ShapePatch { transform: Some(Transform { translation: Vec2 { x: 15.0, y: 20.0 }, rotation: 0.0, scale_x: 1.0, scale_y: 1.0, }), ..ShapePatch::default() }, expected_version: Some(RecordVersion(1)), }, ], ); let result = engine.commit(draft).unwrap(); assert_ne!(result.heads, initial.heads); assert_eq!(result.inverse.operations.len(), 2); assert_eq!(result.patch.changed.len(), 2); assert_eq!(result.affected_regions.len(), 1);
let stale = TransactionDraft { id: TransactionId("stale".into()), actor_id: ActorId::from("actor:local"), origin: Origin::Human, base_heads: initial.heads, description: "stale rename".into(), operations: vec![Operation::RenamePage { page_id: PageId::from("page:one"), name: "Stale".into(), expected_version: None, }], timestamp: Timestamp(2), }; assert!(matches!(engine.commit(stale), Err(EngineError::StaleHeads))); assert_eq!( engine.snapshot().unwrap().document.pages[&PageId::from("page:one")].name, "Architecture" );}
#[test]fn actor_undo_and_redo_preserve_an_intervening_actor_edit() { let mut engine = engine(); let first = transaction( &mut engine, "actor:a", "move a", vec![Operation::PatchShape { shape_id: ShapeId::from("shape:a"), patch: ShapePatch { transform: Some(Transform { translation: Vec2 { x: 10.0, y: 20.0 }, rotation: 0.0, scale_x: 1.0, scale_y: 1.0, }), ..ShapePatch::default() }, expected_version: Some(RecordVersion(1)), }], ); engine.commit(first).unwrap(); let remote = transaction( &mut engine, "actor:b", "move a vertically", vec![Operation::PatchShape { shape_id: ShapeId::from("shape:a"), patch: ShapePatch { transform: Some(Transform { translation: Vec2 { x: 10.0, y: 45.0 }, rotation: 0.0, scale_x: 1.0, scale_y: 1.0, }), ..ShapePatch::default() }, expected_version: Some(RecordVersion(2)), }], ); engine.commit(remote).unwrap();
engine.undo(&ActorId::from("actor:a")).unwrap(); let undone = engine.snapshot().unwrap().document; assert_eq!(undone.shapes[&ShapeId::from("shape:a")].transform.translation.x, 0.0); assert_eq!(undone.shapes[&ShapeId::from("shape:a")].transform.translation.y, 45.0);
engine.redo(&ActorId::from("actor:a")).unwrap(); let redone = engine.snapshot().unwrap().document; assert_eq!(redone.shapes[&ShapeId::from("shape:a")].transform.translation.x, 10.0); assert_eq!(redone.shapes[&ShapeId::from("shape:a")].transform.translation.y, 45.0);}
#[test]fn actor_undo_rejects_an_intervening_edit_to_the_same_field() { let mut engine = engine(); for (actor, x, version) in [("actor:a", 10.0, RecordVersion(1)), ("actor:b", 20.0, RecordVersion(2))] { let draft = transaction( &mut engine, actor, actor, vec![Operation::PatchShape { shape_id: ShapeId::from("shape:a"), patch: ShapePatch { transform: Some(Transform { translation: Vec2 { x, y: 20.0 }, rotation: 0.0, scale_x: 1.0, scale_y: 1.0, }), ..ShapePatch::default() }, expected_version: Some(version), }], ); engine.commit(draft).unwrap(); } assert!(matches!( engine.undo(&ActorId::from("actor:a")), Err(EngineError::Precondition(_)) )); assert_eq!( engine.snapshot().unwrap().document.shapes[&ShapeId::from("shape:a")] .transform .translation .x, 20.0 );}
#[test]fn delete_restore_and_redo_refresh_multi_record_preconditions() { let mut engine = engine(); let delete = transaction( &mut engine, "actor:a", "delete shape", vec![Operation::DeleteShape { shape_id: ShapeId::from("shape:a"), expected_version: Some(RecordVersion(1)) }], ); engine.commit(delete).unwrap(); assert!( !engine .snapshot() .unwrap() .document .shapes .contains_key(&ShapeId::from("shape:a")) ); engine.undo(&ActorId::from("actor:a")).unwrap(); assert!( engine .snapshot() .unwrap() .document .shapes .contains_key(&ShapeId::from("shape:a")) ); engine.redo(&ActorId::from("actor:a")).unwrap(); assert!( !engine .snapshot() .unwrap() .document .shapes .contains_key(&ShapeId::from("shape:a")) );}
#[test]fn agent_origin_can_query_and_edit_hidden_non_agent_editable_shapes() { let mut initial = document(); initial.layers.get_mut(&LayerId::from("layer:one")).unwrap().visible = false; initial .shapes .get_mut(&ShapeId::from("shape:a")) .unwrap() .metadata .agent_editable = false; let mut engine = TransactionEngine::create( DocumentId::from("document:direct-control"), ActorId::from("actor:local"), initial, ) .unwrap();
assert_eq!( engine .query(&Query { shape_kind: Some("rect".into()), ..Query::default() }) .unwrap() .records .len(), 3 ); let mut edit = transaction( &mut engine, "actor:agent", "edit hidden shape", vec![Operation::PatchShape { shape_id: ShapeId::from("shape:a"), patch: ShapePatch { transform: Some(Transform { translation: Vec2 { x: 5.0, y: 20.0 }, rotation: 0.0, scale_x: 1.0, scale_y: 1.0, }), ..ShapePatch::default() }, expected_version: Some(RecordVersion(1)), }], ); edit.origin = Origin::Agent; engine.commit(edit).unwrap(); assert_eq!( engine.snapshot().unwrap().document.shapes[&ShapeId::from("shape:a")] .transform .translation .x, 5.0 );}
#[test]fn layer_visual_changes_return_regions_and_deletes_honor_locked_descendants() { let mut engine = engine(); let hide = transaction( &mut engine, "actor:a", "hide layer", vec![Operation::PatchLayer { layer_id: LayerId::from("layer:one"), patch: LayerPatch { visible: Some(false), ..LayerPatch::default() }, expected_version: Some(RecordVersion(1)), }], ); assert_eq!(engine.commit(hide).unwrap().affected_regions.len(), 1);
let mut locked_document = document(); locked_document .shapes .get_mut(&ShapeId::from("shape:b")) .unwrap() .metadata .locked = true; let mut locked_engine = TransactionEngine::create( DocumentId::from("document:locked"), ActorId::from("actor:a"), locked_document, ) .unwrap(); let delete_layer = transaction( &mut locked_engine, "actor:a", "delete locked content", vec![Operation::DeleteLayer { layer_id: LayerId::from("layer:one"), contents: crate::proto::LayerContentsDisposition::Delete, expected_version: Some(RecordVersion(1)), }], ); assert!(matches!( locked_engine.commit(delete_layer), Err(EngineError::Permission(_)) ));}
#[test]fn a_locked_layer_can_be_unlocked_but_not_changed_in_the_same_operation() { let mut document = document(); document.layers.get_mut(&LayerId::from("layer:one")).unwrap().locked = true; let mut engine = TransactionEngine::create( DocumentId::from("document:locked-layer"), ActorId::from("actor:a"), document, ) .unwrap(); let unlock = transaction( &mut engine, "actor:a", "unlock layer", vec![Operation::PatchLayer { layer_id: LayerId::from("layer:one"), patch: LayerPatch { locked: Some(false), ..LayerPatch::default() }, expected_version: Some(RecordVersion(1)), }], ); engine.commit(unlock).unwrap(); assert!(!engine.snapshot().unwrap().document.layers[&LayerId::from("layer:one")].locked);}
#[test]fn permissions_preconditions_and_final_invariants_reject_without_mutation() { let mut engine = engine(); let before = engine.snapshot().unwrap(); let bad_version = transaction( &mut engine, "actor:a", "bad version", vec![Operation::PatchLayer { layer_id: LayerId::from("layer:one"), patch: LayerPatch { name: Some("Changed".into()), ..LayerPatch::default() }, expected_version: Some(RecordVersion(99)), }], ); assert!(matches!(engine.commit(bad_version), Err(EngineError::Precondition(_)))); assert_eq!(engine.snapshot().unwrap(), before);
let delete_only_page = transaction( &mut engine, "actor:a", "delete only page", vec![Operation::DeletePage { page_id: PageId::from("page:one"), expected_version: Some(RecordVersion(1)) }], ); assert!(matches!( engine.commit(delete_only_page), Err(EngineError::Invariant(_)) )); assert_eq!(engine.snapshot().unwrap(), before);}
#[test]fn typed_relationships_are_queryable_by_direction_and_type() { let mut engine = engine(); let binding_id = BindingId::from("binding:depends-on"); let relation = BindingRecord { id: binding_id.clone(), kind: BindingKind::from("relation"), source_shape_id: ShapeId::from("shape:a"), target_shape_id: ShapeId::from("shape:b"), source_handle: "end".into(), anchor: BindingAnchor::Center, relation_type: Some("depends_on".into()), version: RecordVersion(1), }; let create = transaction( &mut engine, "actor:local", "create-typed-relationship", vec![Operation::CreateBinding { binding: relation }], ); engine.commit(create).unwrap();
let outgoing = engine .query(&Query { outgoing_from: Some(ShapeId::from("shape:a")), relation_type: Some("depends_on".into()), ..Query::default() }) .unwrap(); assert_eq!( outgoing.records, vec![crate::proto::RecordId::Binding(binding_id.clone())] );
let incoming = engine .query(&Query { incoming_to: Some(ShapeId::from("shape:b")), include_records: true, ..Query::default() }) .unwrap(); assert_eq!(incoming.records, vec![crate::proto::RecordId::Binding(binding_id)]); assert!( matches!(incoming.details.first(), Some(crate::proto::QueryRecord::Binding(binding)) if binding.relation_type.as_deref() == Some("depends_on")) );}
#[test]fn relationship_types_reject_empty_values() { let mut engine = engine(); let relation = BindingRecord { id: BindingId::from("binding:invalid-relation"), kind: BindingKind::from("relation"), source_shape_id: ShapeId::from("shape:a"), target_shape_id: ShapeId::from("shape:b"), source_handle: "end".into(), anchor: BindingAnchor::Center, relation_type: Some(" ".into()), version: RecordVersion(1), }; let create = transaction( &mut engine, "actor:local", "create-invalid-relationship", vec![Operation::CreateBinding { binding: relation }], ); assert!( matches!(engine.commit(create), Err(EngineError::Schema(message)) if message.contains("empty relationship type")) );}
#[test]fn queries_bounds_alignment_and_distribution_share_the_transaction_engine() { let mut engine = engine(); let query = Query { role: Some("diagram.process".into()), tag: Some("important".into()), shape_kind: Some("rect".into()), page_id: Some(PageId::from("page:one")), layer_id: Some(LayerId::from("layer:one")), bounds: Some(Bounds { x: -1.0, y: 0.0, width: 20.0, height: 30.0 }), ..Query::default() }; let result = engine.query(&query).unwrap(); assert_eq!( result.records, vec![crate::proto::RecordId::Shape(ShapeId::from("shape:a"))] );
let detailed = engine .query(&Query { include_records: true, limit: Some(1), ..Query::default() }) .unwrap(); assert!(detailed.total > 1); assert!(detailed.truncated); assert_eq!(detailed.records.len(), 1); assert_eq!(detailed.details.len(), 1);
let align = transaction( &mut engine, "actor:a", "align", vec![Operation::AlignShapes { shape_ids: vec![ShapeId::from("shape:a"), ShapeId::from("shape:b")], alignment: ShapeAlignment::Left, expected_versions: BTreeMap::new(), }], ); engine.commit(align).unwrap(); let snapshot = engine.snapshot().unwrap(); assert_eq!( snapshot.document.shapes[&ShapeId::from("shape:a")] .transform .translation .x, snapshot.document.shapes[&ShapeId::from("shape:b")] .transform .translation .x );}
#[test]fn selection_layout_operations_are_deterministic_and_undoable() { let mut engine = engine(); let ids = vec![ ShapeId::from("shape:a"), ShapeId::from("shape:b"), ShapeId::from("shape:c"), ]; let before = engine.snapshot().unwrap().document; let stack_transaction = transaction( &mut engine, "actor:layout", "stack", vec![Operation::StackShapes { shape_ids: ids.clone(), axis: LayoutAxis::Horizontal, gap: 5.0, expected_versions: BTreeMap::new(), }], ); engine.commit(stack_transaction).unwrap(); let stacked = engine.snapshot().unwrap().document; assert_eq!(stacked.shapes[&ids[0]].transform.translation.x, 0.0); assert_eq!(stacked.shapes[&ids[1]].transform.translation.x, 15.0); assert_eq!(stacked.shapes[&ids[2]].transform.translation.x, 30.0);
engine.undo(&ActorId::from("actor:layout")).unwrap(); let restored = engine.snapshot().unwrap().document; for id in &ids { assert_eq!(restored.shapes[id].transform, before.shapes[id].transform); }
let grid_transaction = transaction( &mut engine, "actor:layout", "grid", vec![Operation::GridShapes { shape_ids: ids.clone(), columns: 2, column_gap: 10.0, row_gap: 10.0, expected_versions: BTreeMap::new(), }], ); engine.commit(grid_transaction).unwrap(); let gridded = engine.snapshot().unwrap().document; assert_eq!(gridded.shapes[&ids[0]].transform.translation, Vec2 { x: 0.0, y: 20.0 }); assert_eq!(gridded.shapes[&ids[1]].transform.translation, Vec2 { x: 20.0, y: 20.0 }); assert_eq!(gridded.shapes[&ids[2]].transform.translation, Vec2 { x: 0.0, y: 35.0 });}
#[test]fn graph_layout_uses_structured_connections_and_is_undoable() { let mut document = document(); document.bindings.insert( BindingId::from("binding:a-b"), BindingRecord { id: BindingId::from("binding:a-b"), kind: BindingKind::from("relation"), source_shape_id: ShapeId::from("shape:a"), target_shape_id: ShapeId::from("shape:b"), source_handle: "end".into(), anchor: BindingAnchor::Center, relation_type: Some("depends_on".into()), version: RecordVersion(1), }, ); document.bindings.insert( BindingId::from("binding:b-c"), BindingRecord { id: BindingId::from("binding:b-c"), kind: BindingKind::from("relation"), source_shape_id: ShapeId::from("shape:b"), target_shape_id: ShapeId::from("shape:c"), source_handle: "end".into(), anchor: BindingAnchor::Center, relation_type: Some("depends_on".into()), version: RecordVersion(1), }, ); let mut engine = TransactionEngine::create( DocumentId::from("document:graph-layout"), ActorId::from("actor:graph-layout"), document, ) .unwrap(); let ids = vec![ ShapeId::from("shape:a"), ShapeId::from("shape:b"), ShapeId::from("shape:c"), ]; let base_heads = engine.snapshot().unwrap().heads; let base_bytes = engine.save().unwrap(); let mut remote = TransactionEngine::load(&base_bytes, ActorId::from("actor:graph-remote")).unwrap(); let before = engine.snapshot().unwrap().document; let stale_transaction = transaction( &mut engine, "actor:graph-layout", "graph-flow-stale", vec![Operation::GraphLayout { shape_ids: ids.clone(), layout: crate::GraphLayoutOptions::default(), expected_versions: BTreeMap::new(), }], ); let mut transaction = stale_transaction.clone(); transaction.id = TransactionId("graph-flow".into()); engine.commit(transaction).unwrap(); assert!(matches!(engine.commit(stale_transaction), Err(EngineError::StaleHeads))); let after = engine.snapshot().unwrap().document; assert!(after.shapes[&ids[0]].transform.translation.y < after.shapes[&ids[1]].transform.translation.y); assert!(after.shapes[&ids[1]].transform.translation.y < after.shapes[&ids[2]].transform.translation.y); assert_eq!(after.bindings, before.bindings); let changes = engine.changes_since(&base_heads).unwrap(); remote.merge_changes(&changes).unwrap(); assert_eq!( remote.snapshot().unwrap().document.shapes[&ids[1]].transform, after.shapes[&ids[1]].transform );
engine.undo(&ActorId::from("actor:graph-layout")).unwrap(); let restored = engine.snapshot().unwrap().document; for id in &ids { assert_eq!(restored.shapes[id].transform, before.shapes[id].transform); } assert_eq!(restored.bindings, before.bindings); engine.redo(&ActorId::from("actor:graph-layout")).unwrap(); let redone = engine.snapshot().unwrap().document; assert!(redone.shapes[&ids[1]].transform.translation.y > redone.shapes[&ids[0]].transform.translation.y); let bytes = engine.save().unwrap(); let mut reopened = TransactionEngine::load(&bytes, ActorId::from("actor:reopened")).unwrap(); assert_eq!( reopened.snapshot().unwrap().document.shapes[&ids[2]].transform, redone.shapes[&ids[2]].transform );}
#[test]fn graph_layout_keeps_locked_nodes_fixed() { let mut document = document(); let locked_id = ShapeId::from("shape:b"); document.shapes.get_mut(&locked_id).unwrap().metadata.locked = true; document.bindings.insert( BindingId::from("binding:a-b"), BindingRecord { id: BindingId::from("binding:a-b"), kind: BindingKind::from("relation"), source_shape_id: ShapeId::from("shape:a"), target_shape_id: locked_id.clone(), source_handle: "end".into(), anchor: BindingAnchor::Center, relation_type: None, version: RecordVersion(1), }, ); let mut engine = TransactionEngine::create( DocumentId::from("document:graph-locked"), ActorId::from("actor:graph-locked"), document, ) .unwrap(); let locked_before = engine.snapshot().unwrap().document.shapes[&locked_id].transform; let transaction = transaction( &mut engine, "actor:graph-locked", "graph-tree", vec![Operation::GraphLayout { shape_ids: vec![ShapeId::from("shape:a"), locked_id.clone(), ShapeId::from("shape:c")], layout: crate::GraphLayoutOptions { algorithm: crate::GraphLayoutAlgorithm::Tree, ..crate::GraphLayoutOptions::default() }, expected_versions: BTreeMap::new(), }], ); engine.commit(transaction).unwrap(); assert_eq!( engine.snapshot().unwrap().document.shapes[&locked_id].transform, locked_before );}
#[test]fn mixed_nested_locked_layout_preserves_bindings_and_world_geometry() { let mut document = document(); let container_id = ShapeId::from("shape:a"); let child_id = ShapeId::from("shape:c"); let locked_id = ShapeId::from("shape:b"); document.shapes.get_mut(&container_id).unwrap().kind = ShapeKind::from(crate::CONTAINER_KIND); document.shapes.get_mut(&container_id).unwrap().layout = Some(crate::ContainerLayout::Free); document.shapes.get_mut(&container_id).unwrap().child_ids = vec![child_id.clone()]; document.shapes.get_mut(&container_id).unwrap().transform.scale_x = 2.0; document.shapes.get_mut(&child_id).unwrap().parent = ShapeParent::Shape(container_id.clone()); document.layers.get_mut(&LayerId::from("layer:one")).unwrap().shape_ids = vec![container_id, locked_id.clone()]; document.shapes.get_mut(&locked_id).unwrap().metadata.locked = true; document.bindings.insert( BindingId::from("binding:layout"), BindingRecord { id: BindingId::from("binding:layout"), kind: BindingKind::from("relation"), source_shape_id: ShapeId::from("shape:a"), target_shape_id: locked_id.clone(), source_handle: "end".into(), anchor: BindingAnchor::Center, relation_type: Some("depends_on".into()), version: RecordVersion(1), }, ); let mut engine = TransactionEngine::create( DocumentId::from("document:layout-nesting"), ActorId::from("actor:layout"), document, ) .unwrap(); let before_binding = engine.snapshot().unwrap().document.bindings; let transaction = transaction( &mut engine, "actor:layout", "align nested selection", vec![Operation::AlignShapes { shape_ids: vec![child_id.clone(), locked_id.clone()], alignment: ShapeAlignment::Left, expected_versions: BTreeMap::new(), }], ); engine.commit(transaction).unwrap(); let after = engine.snapshot().unwrap().document; let child_bounds = world_shape_bounds(&after, &child_id); let locked_bounds = world_shape_bounds(&after, &locked_id); assert_eq!(child_bounds.x, locked_bounds.x); assert_eq!(after.shapes[&locked_id].transform.translation.x, 30.0); assert_eq!(after.bindings, before_binding);}
#[test]fn remote_changes_are_repaired_on_a_fork_before_adoption() { let mut engine = engine(); let base_heads = engine.snapshot().unwrap().heads; let bytes = engine.save().unwrap(); let mut remote = AutomergeDocument::load(&bytes, ActorId::from("actor:remote")).unwrap(); let mut invalid = remote.snapshot().unwrap().document; invalid .pages .get_mut(&PageId::from("page:one")) .unwrap() .layer_ids .clear(); invalid.layers.clear(); invalid.shapes.get_mut(&ShapeId::from("shape:a")).unwrap().parent = ShapeParent::Layer(LayerId::from("layer:missing")); invalid.bindings.insert( BindingId::from("binding:dangling"), BindingRecord { id: BindingId::from("binding:dangling"), kind: BindingKind::from("arrow"), source_shape_id: ShapeId::from("shape:a"), target_shape_id: ShapeId::from("shape:missing"), source_handle: "end".into(), anchor: BindingAnchor::Center, relation_type: None, version: RecordVersion(1), }, ); remote.commit_document(&invalid, "invalid remote hierarchy").unwrap(); let changes = remote.changes_since(&base_heads).unwrap(); let warnings = engine.merge_changes(&changes).unwrap(); assert!(!warnings.is_empty()); let repaired = engine.snapshot().unwrap().document; assert_eq!(repaired.pages[&PageId::from("page:one")].layer_ids.len(), 1); assert!(repaired.bindings.is_empty()); assert!(super::validate_document(&repaired).is_ok());}
#[test]fn two_offline_replicas_converge_independent_of_change_order() { let mut origin = engine(); let bytes = origin.save().unwrap(); let base = origin.snapshot().unwrap().heads; let mut left = TransactionEngine::load(&bytes, ActorId::from("actor:left")).unwrap(); let mut right = TransactionEngine::load(&bytes, ActorId::from("actor:right")).unwrap(); let left_tx = transaction( &mut left, "actor:left", "left edit", vec![Operation::RenamePage { page_id: PageId::from("page:one"), name: "Renamed".into(), expected_version: Some(RecordVersion(1)), }], ); left.commit(left_tx).unwrap(); let right_tx = transaction( &mut right, "actor:right", "right edit", vec![Operation::PatchShape { shape_id: ShapeId::from("shape:c"), patch: ShapePatch { metadata: Some(metadata("actor:right", "Changed")), ..ShapePatch::default() }, expected_version: Some(RecordVersion(1)), }], ); right.commit(right_tx).unwrap(); let left_changes = left.changes_since(&base).unwrap(); let right_changes = right.changes_since(&base).unwrap(); left.merge_changes(&right_changes).unwrap(); right.merge_changes(&left_changes).unwrap(); assert_eq!(left.snapshot().unwrap().document, right.snapshot().unwrap().document);}
#[test]fn reparent_preserves_world_space_transform_and_undo_restores_hierarchy() { let mut document = document(); let layer_id = LayerId::from("layer:one"); let first_id = ShapeId::from("shape:a"); let second_id = ShapeId::from("shape:b"); let child_id = ShapeId::from("shape:c"); for id in [&first_id, &second_id] { let container = document.shapes.get_mut(id).expect("fixture container"); container.kind = ShapeKind::from(crate::CONTAINER_KIND); container.layout = Some(crate::ContainerLayout::Free); container.properties = ShapeProperties::from([("width".into(), json!(100.0)), ("height".into(), json!(100.0))]); } document.layers.get_mut(&layer_id).unwrap().shape_ids = vec![first_id.clone(), second_id.clone()]; document.shapes.get_mut(&first_id).unwrap().child_ids = vec![child_id.clone()]; document.shapes.get_mut(&child_id).unwrap().parent = ShapeParent::Shape(first_id.clone()); document.shapes.get_mut(&first_id).unwrap().transform.translation = Vec2 { x: 20.0, y: 30.0 }; document.shapes.get_mut(&second_id).unwrap().transform.translation = Vec2 { x: 240.0, y: 80.0 }; document.shapes.get_mut(&child_id).unwrap().transform.translation = Vec2 { x: 15.0, y: 25.0 };
let mut engine = TransactionEngine::create( DocumentId::from("document:reparent"), ActorId::from("actor:reparent"), document, ) .unwrap(); let child = child_id.clone(); let before_snapshot = engine.snapshot().unwrap(); let before = world_transform(&before_snapshot.document, &before_snapshot.document.shapes[&child]); let transaction = transaction( &mut engine, "actor:reparent", "reparent child", vec![Operation::ReparentShape { shape_id: child.clone(), parent: ShapeParent::Shape(second_id.clone()), anchor: SiblingAnchor::Last, expected_version: Some(RecordVersion(1)), }], ); engine.commit(transaction).expect("reparent should commit"); let after_snapshot = engine.snapshot().unwrap(); let after_shape = &after_snapshot.document.shapes[&child]; let after = world_transform(&after_snapshot.document, after_shape); assert_eq!(after_shape.parent, ShapeParent::Shape(second_id)); assert!( [ (before.a, after.a), (before.b, after.b), (before.c, after.c), (before.d, after.d), (before.e, after.e), (before.f, after.f), ] .into_iter() .all(|(left, right)| (left - right).abs() < 1e-9) );
engine .undo(&ActorId::from("actor:reparent")) .expect("undo should restore reparent"); let restored = engine.snapshot().unwrap(); assert_eq!(restored.document.shapes[&child].parent, ShapeParent::Shape(first_id));}
#[test]fn locked_container_blocks_edits_to_nested_shapes() { let mut document = document(); let container_id = ShapeId::from("shape:a"); let child_id = ShapeId::from("shape:b"); document.shapes.get_mut(&container_id).unwrap().kind = ShapeKind::from(crate::CONTAINER_KIND); document.shapes.get_mut(&container_id).unwrap().layout = Some(crate::ContainerLayout::Free); document.shapes.get_mut(&container_id).unwrap().child_ids = vec![child_id.clone(), ShapeId::from("shape:c")]; document.shapes.get_mut(&container_id).unwrap().metadata.locked = true; document.shapes.get_mut(&child_id).unwrap().parent = ShapeParent::Shape(container_id.clone()); document.shapes.get_mut(&ShapeId::from("shape:c")).unwrap().parent = ShapeParent::Shape(container_id); document.layers.get_mut(&LayerId::from("layer:one")).unwrap().shape_ids = vec![ShapeId::from("shape:a")]; let mut engine = TransactionEngine::create( DocumentId::from("document:locked-container"), ActorId::from("actor:locked-container"), document, ) .unwrap(); let transaction = transaction( &mut engine, "actor:locked-container", "edit nested shape", vec![Operation::PatchShape { shape_id: child_id, patch: ShapePatch { transform: Some(Transform { translation: Vec2 { x: 5.0, y: 5.0 }, rotation: 0.0, scale_x: 1.0, scale_y: 1.0, }), ..ShapePatch::default() }, expected_version: Some(RecordVersion(1)), }], ); assert!(matches!(engine.commit(transaction), Err(EngineError::Permission(_))));}
#[test]fn offline_list_text_delete_and_reparent_edits_converge() { let mut base_document = document(); base_document.shapes.get_mut(&ShapeId::from("shape:a")).unwrap().kind = ShapeKind::from("container"); base_document.bindings.insert( BindingId::from("binding:one"), BindingRecord { id: BindingId::from("binding:one"), kind: BindingKind::from("arrow"), source_shape_id: ShapeId::from("shape:a"), target_shape_id: ShapeId::from("shape:b"), source_handle: "end".into(), anchor: BindingAnchor::Center, relation_type: None, version: RecordVersion(1), }, ); let mut origin = TransactionEngine::create( DocumentId::from("document:offline"), ActorId::from("actor:origin"), base_document, ) .unwrap(); let bytes = origin.save().unwrap(); let base = origin.snapshot().unwrap().heads; let mut left = TransactionEngine::load(&bytes, ActorId::from("actor:left")).unwrap(); let mut right = TransactionEngine::load(&bytes, ActorId::from("actor:right")).unwrap();
let mut text_properties = left.snapshot().unwrap().document.shapes[&ShapeId::from("shape:a")] .properties .clone(); text_properties.insert("content".into(), json!("offline text edit")); let left_tx = transaction( &mut left, "actor:left", "left nested list text and delete", vec![ Operation::PatchShape { shape_id: ShapeId::from("shape:a"), patch: ShapePatch { properties: Some(text_properties), ..ShapePatch::default() }, expected_version: Some(RecordVersion(1)), }, Operation::CreateShape { shape: shape("shape:d", 120.0), anchor: SiblingAnchor::Last }, Operation::DeleteBinding { binding_id: BindingId::from("binding:one"), expected_version: Some(RecordVersion(1)), }, ], ); left.commit(left_tx).unwrap();
let right_tx = transaction( &mut right, "actor:right", "right reparent and property", vec![ Operation::ReparentShape { shape_id: ShapeId::from("shape:b"), parent: ShapeParent::Shape(ShapeId::from("shape:a")), anchor: SiblingAnchor::Last, expected_version: Some(RecordVersion(1)), }, Operation::PatchShape { shape_id: ShapeId::from("shape:c"), patch: ShapePatch { transform: Some(Transform { translation: Vec2 { x: 80.0, y: 50.0 }, rotation: 0.0, scale_x: 1.0, scale_y: 1.0, }), ..ShapePatch::default() }, expected_version: Some(RecordVersion(1)), }, ], ); right.commit(right_tx).unwrap();
let left_changes = left.changes_since(&base).unwrap(); let right_changes = right.changes_since(&base).unwrap(); left.merge_changes(&right_changes).unwrap(); right.merge_changes(&left_changes).unwrap(); let left_document = left.snapshot().unwrap().document; let right_document = right.snapshot().unwrap().document; assert_eq!(left_document, right_document); assert!(left_document.bindings.is_empty()); assert!(left_document.shapes.contains_key(&ShapeId::from("shape:d"))); assert_eq!( left_document.shapes[&ShapeId::from("shape:b")].parent, ShapeParent::Shape(ShapeId::from("shape:a")) ); assert_eq!( left_document.shapes[&ShapeId::from("shape:a")].properties["content"], json!("offline text edit") );}
proptest! { #![proptest_config(ProptestConfig::with_cases(16))]
#[test] fn apply_then_inverse_restores_the_renamed_value(name in "[A-Za-z][A-Za-z0-9 ]{0,24}") { let mut engine = engine(); let draft = transaction( &mut engine, "actor:property", "property rename", vec![Operation::RenamePage { page_id: PageId::from("page:one"), name, expected_version: Some(RecordVersion(1)), }], ); engine.commit(draft).unwrap(); engine.undo(&ActorId::from("actor:property")).unwrap(); prop_assert_eq!( &engine.snapshot().unwrap().document.pages[&PageId::from("page:one")].name, "Page" ); }
#[test] fn deterministic_repair_is_idempotent(x in -10_000.0f64..10_000.0) { let mut invalid = document(); invalid.shapes.get_mut(&ShapeId::from("shape:a")).unwrap().transform.translation.x = x; invalid.pages.get_mut(&PageId::from("page:one")).unwrap().layer_ids.clear(); invalid.layers.clear(); invalid.shapes.get_mut(&ShapeId::from("shape:a")).unwrap().parent = ShapeParent::Layer(LayerId::from("layer:missing")); let mut first = invalid.clone(); let mut second = invalid; super::repair_document(&mut first).unwrap(); super::repair_document(&mut second).unwrap(); prop_assert_eq!(&first, &second); let before_second_repair = first.clone(); super::repair_document(&mut first).unwrap(); prop_assert_eq!(first, before_second_repair); }
#[test] fn concurrent_property_edits_converge_for_any_finite_coordinates( left_x in -10_000.0f64..10_000.0, right_y in -10_000.0f64..10_000.0, ) { let mut origin = engine(); let bytes = origin.save().unwrap(); let base = origin.snapshot().unwrap().heads; let mut left = TransactionEngine::load(&bytes, ActorId::from("actor:left")).unwrap(); let mut right = TransactionEngine::load(&bytes, ActorId::from("actor:right")).unwrap(); let left_tx = transaction(&mut left, "actor:left", "left coordinate", vec![Operation::PatchShape { shape_id: ShapeId::from("shape:a"), patch: ShapePatch { transform: Some(Transform { translation: Vec2 { x: left_x, y: 20.0 }, rotation: 0.0, scale_x: 1.0, scale_y: 1.0, }), ..ShapePatch::default() }, expected_version: Some(RecordVersion(1)), }]); left.commit(left_tx).unwrap(); let right_tx = transaction(&mut right, "actor:right", "right coordinate", vec![Operation::PatchShape { shape_id: ShapeId::from("shape:a"), patch: ShapePatch { transform: Some(Transform { translation: Vec2 { x: 0.0, y: right_y }, rotation: 0.0, scale_x: 1.0, scale_y: 1.0, }), ..ShapePatch::default() }, expected_version: Some(RecordVersion(1)), }]); right.commit(right_tx).unwrap(); let left_changes = left.changes_since(&base).unwrap(); let right_changes = right.changes_since(&base).unwrap(); left.merge_changes(&right_changes).unwrap(); right.merge_changes(&left_changes).unwrap(); let left_document = left.snapshot().unwrap().document; let right_document = right.snapshot().unwrap().document; prop_assert_eq!(&left_document, &right_document); prop_assert_eq!(left_document.shapes[&ShapeId::from("shape:a")].transform.translation.x, left_x); prop_assert_eq!(left_document.shapes[&ShapeId::from("shape:a")].transform.translation.y, right_y); }}