Files
figcraft/crates/engine/src/collab_undo_tests.rs
T
BFlatandClaude Opus 5.5 94ff3d4df6 M9: collaboration: per-user undo, rejections, reconnect (#105)
* M9: collaboration: per-user undo, rejections, reconnect

Step 3 of docs/collab-protocol.md, the parts that don't need the transport:

- Per-user undo and redo (figcraft_collab::Client::{undo, redo}): each undoable batch is a step
  (values before/after by target, nodes created/deleted). Others' batches ordered after the step
  take its targets for good; creates invert to deletes (not when others built inside), deletes
  re-create the subtree. An undo or redo waits for every earlier batch and is remade from the
  confirmed state when it goes out; derived data follows as a follow-up batch.
- Rejections: refused batches leave the history (with an undo waiting on them) and queue a
  Notice; the engine shows it through Session::message (the UI's toast).
- Offline queue and reconnect: Hello.resume {session, client_id, seq}; the server keeps a
  bounded log and answers with the missed batches or a snapshot, plus what it did with the
  client's batches (processed {last_batch, refused}); queued batches then go out.
  Server::state / restore persist the session (client-id counter, log, session id).
- Presence carries editing_text {node, caret, selection}.
- Engine: documents in a live session (LiveDoc, Session::collab_*): commands, gestures and
  groups sync as one step each; edit.undo / edit.redo route to the per-user history; remote
  edits show under a gesture in progress. Outside a session snapshot undo is unchanged.
- Sim: dropped connections, undo/redo, and a checker that no undo reverts a change by someone
  else its author had seen. New tests in undo_tests, reconnect_tests, collab_live_tests,
  collab_undo_tests.
- CLAUDE.md / AGENTS.md: collab in the L2 layer list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M9: collaboration wording in the progress docs after per-user undo and reconnect

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M9: protocol doc: what the engine's live-session tests cover

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:19:24 +07:00

76 lines
2.9 KiB
Rust

//! Per-user undo with real commands: several people running random commands, undoing and
//! redoing, some dropping their connection now and then, all end up with the server's document,
//! and no undo or redo ever reverts a change by someone else that its author had seen.
use figcraft_collab::sim::{Rng, Sim};
use figcraft_collab::{Limits, Recompute};
use crate::Session;
use crate::collab::EngineRecompute;
use crate::collab_tests::{base, random_command};
fn session(seed: u64, clients: usize, rounds: usize, drops: bool, limits: Limits, recompute: &dyn Recompute) -> Sim {
let mut sim = Sim::with_limits(base(), clients, seed, 10, limits).unwrap();
for _ in 0..rounds {
let i = sim.rng().below(clients);
match sim.rng().below(12) {
0 | 1 => {
sim.undo(i, recompute).unwrap();
}
2 => {
sim.redo(i, recompute).unwrap();
}
3 if drops => {
if sim.is_online(i) {
sim.disconnect(i).unwrap();
} else {
sim.reconnect(i).unwrap();
}
}
_ => {
let mut s = Session::with_document(sim.document(i).unwrap().clone());
let mut rng = Rng::new(sim.rng().next_u64());
let (cmd, params) = random_command(s.doc().unwrap(), &mut rng, false);
if s.execute(cmd, &params).is_ok() {
sim.edit(i, s.doc().unwrap()).unwrap_or_else(|e| panic!("seed {seed}: {cmd} {params}: {e}"));
}
}
}
for _ in 0..sim.rng().below(4) {
sim.step(recompute).unwrap();
}
}
for i in 0..clients {
sim.reconnect(i).unwrap();
}
sim.settle(recompute, 1_000_000).unwrap();
if let Err(e) = sim.converged() {
panic!("seed {seed}: {e}");
}
assert!(sim.violations.is_empty(), "seed {seed}: {:?}", sim.violations);
assert!(sim.server.document().validate().is_empty(), "seed {seed}: {:?}", sim.server.document().validate());
sim
}
#[test]
fn people_undoing_real_commands_converge_without_reverting_others() {
let mut undos = 0;
for seed in 0..4 {
undos += session(seed, 3, 60, false, Limits::default(), &EngineRecompute).undos;
}
// Dropped connections, and a log short enough that long absences catch up with snapshots.
for seed in 10..13 {
undos += session(seed, 3, 60, true, Limits { max_log_batches: 4, ..Limits::default() }, &EngineRecompute).undos;
}
assert!(undos > 20, "only {undos} undos and redos accepted");
}
#[test]
#[ignore = "longer run for development: cargo test -p figcraft-engine collab -- --ignored"]
fn many_people_undoing_real_commands_converge() {
for seed in 100..160 {
let limits = Limits { max_log_batches: if seed % 2 == 0 { 5 } else { 10_000 }, ..Limits::default() };
session(seed, 4, 150, seed % 3 == 0, limits, &EngineRecompute);
}
}