181aad91e6
Sync and the server - crates/sync: revision ledger (stale writers conflict, edit beats delete), client planner, three-way merge, conflict names, path and hash gates - apps/notescraft-sync-server: pairing, device tokens, change log with long poll, blobs, accounts with many vaults, roles, invites, audit log, quota, GC, readiness, request ids - server history (revisions, restore, retention), chunked resumable uploads, live editing endpoints (SSE rooms, presence, flush through the ledger) - engine: HttpRemote, sync_once, SyncWorker, sync.* commands, share.* and server.* commands, live session client, note.capture, vault catalog and vaults.* commands Editing together - crates/collab: operational transformation core (TextOp, transform, client state machine, room, presence) - ui-egui: Settings > Sync, sync pill, conflicts, Share dialog, live pill and remote carets, launcher Connect and Join flows App shell - vault launcher (desktop and phone layouts) wired into the app host (--launcher, File > Vaults...) - compact (phone) layout with a bottom tab bar Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Android and iOS shells are not built: the build machine has no NDK or Xcode. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
77 lines
2.6 KiB
Rust
77 lines
2.6 KiB
Rust
//! Randomised properties of `merge3` with a tiny deterministic generator (no extra dependency).
|
|
|
|
use notescraft_sync::{Merge, merge3};
|
|
|
|
struct Rng(u64);
|
|
|
|
impl Rng {
|
|
fn next(&mut self) -> u64 {
|
|
self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
|
self.0 >> 33
|
|
}
|
|
|
|
fn below(&mut self, n: usize) -> usize {
|
|
usize::try_from(self.next()).unwrap_or(0) % n.max(1)
|
|
}
|
|
}
|
|
|
|
fn base_text(rng: &mut Rng) -> Vec<String> {
|
|
let count = 1 + rng.below(30);
|
|
(0..count).map(|n| format!("line {n} {}\n", rng.below(1000))).collect()
|
|
}
|
|
|
|
/// A few random edits: change, insert or delete a line.
|
|
fn mutate(rng: &mut Rng, lines: &[String], tag: &str) -> Vec<String> {
|
|
let mut out = lines.to_vec();
|
|
for step in 0..rng.below(4) {
|
|
let at = rng.below(out.len() + 1);
|
|
match rng.below(3) {
|
|
0 if at < out.len() => out[at] = format!("{tag} changed {step}\n"),
|
|
1 => out.insert(at, format!("{tag} added {step}\n")),
|
|
_ if at < out.len() => {
|
|
out.remove(at);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
fn one_sided_edits_always_merge_to_the_edit() {
|
|
let mut rng = Rng(7);
|
|
for _ in 0..500 {
|
|
let base = base_text(&mut rng);
|
|
let edited = mutate(&mut rng, &base, "a");
|
|
let (base, edited) = (base.concat(), edited.concat());
|
|
assert_eq!(merge3(&base, &edited, &base), Merge::Clean(edited.clone()), "ours only");
|
|
assert_eq!(merge3(&base, &base, &edited), Merge::Clean(edited), "theirs only");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn clean_merges_are_symmetric_and_keep_both_sides_edits() {
|
|
let mut rng = Rng(11);
|
|
let mut clean = 0;
|
|
for _ in 0..1000 {
|
|
let base = base_text(&mut rng);
|
|
let ours = mutate(&mut rng, &base, "ours").concat();
|
|
let theirs = mutate(&mut rng, &base, "theirs").concat();
|
|
let base = base.concat();
|
|
let left = merge3(&base, &ours, &theirs);
|
|
let right = merge3(&base, &theirs, &ours);
|
|
match (&left, &right) {
|
|
(Merge::Clean(a), Merge::Clean(b)) => {
|
|
clean += 1;
|
|
assert_eq!(a, b, "merge must not depend on the side order");
|
|
for line in ours.lines().chain(theirs.lines()).filter(|l| l.contains("added") || l.contains("changed")) {
|
|
assert!(a.contains(line), "lost edit {line:?}");
|
|
}
|
|
}
|
|
(Merge::Conflict, Merge::Conflict) => {}
|
|
_ => panic!("asymmetric result for base {base:?} ours {ours:?} theirs {theirs:?}"),
|
|
}
|
|
}
|
|
assert!(clean > 100, "too few clean merges ({clean}) to mean anything");
|
|
}
|