mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 03:16:53 -06:00
* M9: real-time collaboration protocol (design) docs/collab-protocol.md: WebSocket sessions with a server-ordered, per-property last-writer-wins document sync, client-scoped ids, fractional positions for children, ephemeral presence (cursors, selections, follow), per-user undo, hosting from the app first and a self-hosted server next, and the implementation plan. ROADMAP gains M9. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M9: collaboration core: ids, change sets, server ordering, convergence Step 1 of docs/collab-protocol.md, no networking: - Client-scoped node ids: Document::set_client_id makes alloc_id hand out client << 32 | counter; single-user files allocate as before; the id counter is no longer part of document equality. - crates/collab (layer 2): the synced object/property model (every saved Node field, plus document-level styles, comments, variables, images, dev snapshots, library data), Change batches, an O(changed) diff, strict and lenient apply, fractional sibling positions, snapshots. - Server (ordering, validation, rejects with fixes) and Client (optimistic view, replay under remote batches, author-side repair of derived data) as pure state machines; binary (version byte + postcard, JSON values) and JSON message forms, fuzzed decoders. - Engine: normalize as the recompute hook, collab.diff. - Tests: round trips over random real commands, an N-client convergence fuzz test over delayed in-order links, junk batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M9: fmt after merge --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
128 lines
5.2 KiB
Rust
128 lines
5.2 KiB
Rust
//! Client-scoped ids (collaboration), raw structure edits and page-less documents.
|
|
|
|
use crate::{DocError, Document, Node, NodeId, NodeKind};
|
|
use figcraft_geom::Affine;
|
|
|
|
fn rect(doc: &mut Document, parent: NodeId) -> NodeId {
|
|
doc.create(parent, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap()
|
|
}
|
|
|
|
const C3: u64 = 3 << 32;
|
|
const C5: u64 = 5 << 32;
|
|
|
|
#[test]
|
|
fn single_user_allocation_is_unchanged() {
|
|
let mut d = Document::new("a");
|
|
let page = d.first_page().unwrap();
|
|
assert_eq!(page, NodeId(1));
|
|
assert_eq!(d.client_id(), 0);
|
|
assert_eq!(rect(&mut d, page), NodeId(2));
|
|
assert_eq!(d.next_id(), NodeId(3));
|
|
// Inserting any id above the counter moves it, as before.
|
|
d.insert(Node::new(NodeId(C5 | 9), NodeKind::Rectangle, "x", 1.0, 1.0), page, None).unwrap();
|
|
assert_eq!(d.next_id(), NodeId((C5 | 9) + 1));
|
|
}
|
|
|
|
#[test]
|
|
fn clients_allocate_in_their_own_space_and_never_collide() {
|
|
let mut base = Document::new("a");
|
|
let page = base.first_page().unwrap();
|
|
rect(&mut base, page);
|
|
let (mut a, mut b) = (base.clone(), base.clone());
|
|
a.set_client_id(3);
|
|
b.set_client_id(5);
|
|
let ia: Vec<NodeId> = (0..3).map(|_| rect(&mut a, page)).collect();
|
|
let ib: Vec<NodeId> = (0..3).map(|_| rect(&mut b, page)).collect();
|
|
assert_eq!(ia, vec![NodeId(C3 | 1), NodeId(C3 | 2), NodeId(C3 | 3)]);
|
|
assert_eq!(ib, vec![NodeId(C5 | 1), NodeId(C5 | 2), NodeId(C5 | 3)]);
|
|
// Someone else's ids arriving don't move this client's counter; its own higher ids do.
|
|
a.insert(Node::new(NodeId(C5 | 77), NodeKind::Rectangle, "theirs", 1.0, 1.0), page, None).unwrap();
|
|
assert_eq!(a.next_id(), NodeId(C3 | 4));
|
|
a.insert(Node::new(NodeId(C3 | 40), NodeKind::Rectangle, "mine", 1.0, 1.0), page, None).unwrap();
|
|
assert_eq!(a.alloc_id(), NodeId(C3 | 41));
|
|
// Old ids (client 0) don't count either.
|
|
a.reserve_ids(NodeId(1_000));
|
|
assert_eq!(a.next_id(), NodeId(C3 | 42));
|
|
}
|
|
|
|
#[test]
|
|
fn setting_a_client_continues_after_its_existing_ids() {
|
|
let mut d = Document::new("a");
|
|
let page = d.first_page().unwrap();
|
|
d.insert(Node::new(NodeId(C3 | 7), NodeKind::Rectangle, "earlier session", 1.0, 1.0), page, None).unwrap();
|
|
d.set_client_id(3);
|
|
assert_eq!(d.client_id(), 3);
|
|
assert_eq!(d.alloc_id(), NodeId(C3 | 8));
|
|
// Setting the same client again keeps the counter (handed-out ids stay handed out).
|
|
d.set_client_id(3);
|
|
assert_eq!(d.alloc_id(), NodeId(C3 | 9));
|
|
// Back to single-user: after the highest id of any space.
|
|
d.set_client_id(0);
|
|
assert_eq!(d.alloc_id(), NodeId((C3 | 7) + 1));
|
|
// repair() keeps the client.
|
|
d.set_client_id(5);
|
|
d.repair();
|
|
assert_eq!(d.client_id(), 5);
|
|
assert_eq!(d.alloc_id(), NodeId(C5 | 1));
|
|
}
|
|
|
|
#[test]
|
|
fn the_counter_is_saved_but_never_compared_and_the_client_never_saved() {
|
|
let mut a = Document::new("a");
|
|
let mut b = a.clone();
|
|
b.set_client_id(9);
|
|
b.alloc_id();
|
|
assert_eq!(a, b, "same content, different counters");
|
|
let json = serde_json::to_value(&b).unwrap();
|
|
assert_eq!(json["next_id"], serde_json::json!((9u64 << 32) | 2));
|
|
let back: Document = serde_json::from_value(json).unwrap();
|
|
assert_eq!(back.client_id(), 0);
|
|
assert_eq!(back.next_id(), NodeId((9u64 << 32) | 2));
|
|
a.set_client_id(9);
|
|
assert_eq!(a.next_id(), NodeId((9u64 << 32) | 1));
|
|
}
|
|
|
|
#[test]
|
|
fn place_keeps_the_local_transform_and_refuses_bad_moves() {
|
|
let mut d = Document::new("a");
|
|
let page = d.first_page().unwrap();
|
|
let f = d.create(page, NodeKind::Frame, "F", 100.0, 100.0, 50.0, 50.0).unwrap();
|
|
let g = d.create(f, NodeKind::Frame, "G", 10.0, 10.0, 20.0, 20.0).unwrap();
|
|
let r = rect(&mut d, page);
|
|
d.get_mut(r).unwrap().transform = Affine::translate((5.0, 6.0));
|
|
d.place(r, f, 0).unwrap();
|
|
assert_eq!(d.node(r).unwrap().transform, Affine::translate((5.0, 6.0)));
|
|
assert_eq!(d.node(r).unwrap().parent, Some(f));
|
|
assert_eq!(d.node(f).unwrap().children, vec![r, g]);
|
|
d.place(r, f, 99).unwrap();
|
|
assert_eq!(d.node(f).unwrap().children, vec![g, r], "same parent, index clamped");
|
|
assert_eq!(d.place(f, g, 0), Err(DocError::Cycle(f, g)));
|
|
assert_eq!(d.place(f, f, 0), Err(DocError::Cycle(f, f)));
|
|
assert_eq!(d.place(g, r, 0), Err(DocError::NotContainer(r)));
|
|
assert!(d.place(page, f, 0).is_err());
|
|
assert!(d.place(NodeId(999), f, 0).is_err());
|
|
assert!(d.validate().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn pages_can_be_inserted_onto_a_page_less_base() {
|
|
let mut d = Document::without_pages("snapshot");
|
|
assert!(d.pages.is_empty() && d.node_count() == 0);
|
|
let p = Node::new(
|
|
NodeId(C3 | 1),
|
|
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: vec![], prototype: Default::default(), guides: vec![] },
|
|
"P",
|
|
0.0,
|
|
0.0,
|
|
);
|
|
d.insert_page(p.clone()).unwrap();
|
|
assert_eq!(d.pages, vec![NodeId(C3 | 1)]);
|
|
assert!(d.insert_page(p).is_err(), "duplicate");
|
|
assert!(d.insert_page(Node::new(NodeId(4), NodeKind::Frame, "F", 1.0, 1.0)).is_err(), "not a page");
|
|
assert!(d.validate().is_empty());
|
|
// The page-less base repairs into a file with a page.
|
|
let mut e = Document::without_pages("x");
|
|
e.repair();
|
|
assert_eq!(e.pages.len(), 1);
|
|
}
|