Files
Marcos Paulo 181aad91e6 feat: sync, accounts, live editing, vault launcher, compact layout
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>
2026-10-20 07:46:24 -03:00

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");
}