//! Property tests for spec ยง7.3: a state is a function of its set of //! changes, never of the order they arrived in. //! //! Each case seeds a repository with a few files, lets two or three clones //! diverge through random edit sessions (inserts, deletes, replacements, //! new and removed files, so conflicts happen too), then rebuilds the //! union of every clone's changes in many orders: arbitrary orders through //! `State::add_unchecked` and random dependency-respecting orders through //! `State::add`. Every order must validate and give byte-identical //! checkpoints and identical materialized trees. Finally the clones push //! to one origin in a random order and a fresh clone must see that state, //! and a checkpoint of the state must restore it exactly. use oxy_core::objects::Change; use oxy_core::repo::Repo; use oxy_core::state::State; use proptest::prelude::*; use rand::rngs::StdRng; use rand::seq::SliceRandom; use rand::{Rng, SeedableRng}; use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; /// Point `$OXY_HOME` at a throwaway directory once per test process. fn hermetic() { static ONCE: std::sync::Once = std::sync::Once::new(); static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); ONCE.call_once(|| { let d = tempfile::tempdir().unwrap(); std::env::set_var("OXY_HOME", d.path()); DIR.set(d).ok(); }); } /// Files the edits address; the first `SEEDED` exist in the base state. const FILES: [&str; 4] = ["a.txt", "b.txt", "dir/c.txt", "d.txt"]; const SEEDED: usize = 3; /// A few short lines, so identical lines are common and the diff has /// to choose between equally good alignments. const WORDS: [&str; 5] = ["alpha\n", "beta\n", "gamma\n", "delta\n", "alpha\n"]; #[derive(Clone, Debug)] enum Edit { Insert { file: usize, at: usize, word: usize }, Delete { file: usize, at: usize }, Replace { file: usize, at: usize, word: usize }, Remove { file: usize }, } fn edit() -> impl Strategy { let file = 0..FILES.len(); prop_oneof![ 4 => (file.clone(), any::(), 0..WORDS.len()).prop_map(|(file, at, word)| Edit::Insert { file, at, word }), 3 => (file.clone(), any::()).prop_map(|(file, at)| Edit::Delete { file, at }), 3 => (file.clone(), any::(), 0..WORDS.len()).prop_map(|(file, at, word)| Edit::Replace { file, at, word }), 1 => file.prop_map(|file| Edit::Remove { file }), ] } /// One clone's session: a few commits of a few edits each. fn session() -> impl Strategy>> { prop::collection::vec(prop::collection::vec(edit(), 1..5), 1..4) } fn lines(root: &Path, rel: &str) -> Option> { let text = std::fs::read_to_string(root.join(rel)).ok()?; Some(text.split_inclusive('\n').map(String::from).collect()) } fn apply(root: &Path, e: &Edit) { let file = match e { Edit::Remove { file } => { let _ = std::fs::remove_file(root.join(FILES[*file])); return; } Edit::Insert { file, .. } | Edit::Delete { file, .. } | Edit::Replace { file, .. } => *file, }; let mut l = lines(root, FILES[file]).unwrap_or_default(); match *e { Edit::Insert { at, word, .. } => l.insert(at % (l.len() + 1), WORDS[word].to_string()), Edit::Delete { at, .. } if !l.is_empty() => { let i = at % l.len(); l.remove(i); } Edit::Replace { at, word, .. } if !l.is_empty() => { let i = at % l.len(); l[i] = WORDS[word].to_string(); } _ => {} } let path = root.join(FILES[file]); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, l.concat()).unwrap(); } /// Every change reachable from `repo`'s bookmark heads. fn changes_of(repo: &Repo, into: &mut BTreeMap) { let mut stack = repo.bookmark_heads().unwrap(); while let Some(h) = stack.pop() { if into.contains_key(&h) { continue; } let c = repo.store().get_change(&h).unwrap(); stack.extend(c.deps.iter().copied()); into.insert(h, c); } } /// What must not depend on order: the checkpoint bytes, the heads and /// the materialized tree. fn fingerprint( state: &State, ) -> ( Vec, BTreeSet, oxy_core::tree::TreeView, BTreeMap, ) { let snap = state.materialize(); ( state.checkpoint_payload(), state.heads().into_iter().collect(), snap.tree, snap.files, ) } fn run_case(sessions: Vec>>, seed: u64) -> Result<(), TestCaseError> { hermetic(); let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let origin_path = root.join("origin"); Repo::init(&origin_path).unwrap(); let url = origin_path.to_string_lossy().to_string(); // The shared base. let seed_repo = Repo::clone(&url, &root.join("seed")).unwrap(); for (i, f) in FILES.iter().take(SEEDED).enumerate() { let p = root.join("seed").join(f); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(p, WORDS[..=i + 1].concat()).unwrap(); } seed_repo.commit("base", &[]).unwrap(); assert!(seed_repo .push("origin", false) .unwrap() .iter() .all(|r| r.ok)); // Diverge. let mut clones = Vec::new(); for (n, session) in sessions.iter().enumerate() { let wc = root.join(format!("clone{n}")); let repo = Repo::clone(&url, &wc).unwrap(); for (k, commit) in session.iter().enumerate() { for e in commit { apply(&wc, e); } let s = repo.snapshot("oxy status").unwrap(); if s.statuses.is_empty() { continue; } repo.commit(&format!("clone {n} commit {k}"), &[]).unwrap(); } clones.push(repo); } let mut all = BTreeMap::new(); for c in &clones { changes_of(c, &mut all); } let changes: Vec = all.values().cloned().collect(); // The reference: every change, sorted by hash. let mut reference = State::new(); for c in &changes { reference.add_unchecked(c.clone()); } prop_assert!(reference.validate().is_ok()); let want = fingerprint(&reference); // A checkpoint restores the same state. let restored = State::from_checkpoint_payload(&want.0).unwrap(); prop_assert!( fingerprint(&restored) == want, "checkpoint round trip changed the state" ); let mut rng = StdRng::seed_from_u64(seed); for _ in 0..4 { // Any order at all. let mut order = changes.clone(); order.shuffle(&mut rng); let mut st = State::new(); for c in order { st.add_unchecked(c); } prop_assert!(st.validate().is_ok()); prop_assert!( fingerprint(&st) == want, "unchecked order changed the state" ); // A random order that respects dependencies, the order a fetch sees. let mut pending: Vec<&Change> = changes.iter().collect(); let mut st = State::new(); while !pending.is_empty() { let ready: Vec = (0..pending.len()) .filter(|&i| pending[i].deps.iter().all(|d| st.contains(d))) .collect(); prop_assert!(!ready.is_empty(), "dependency cycle"); let pick = ready[rng.gen_range(0..ready.len())]; st.add(pending.swap_remove(pick).clone()).unwrap(); } prop_assert!( fingerprint(&st) == want, "dependency order changed the state" ); } // Through the protocol: push in a random order, then a fresh clone. let mut order: Vec = (0..clones.len()).collect(); order.shuffle(&mut rng); for i in order { let c = &clones[i]; // A clone behind the origin is told to pull first, as a user is. if !c .push("origin", false) .is_ok_and(|r| r.iter().all(|r| r.ok)) { c.pull("origin").unwrap(); prop_assert!( c.push("origin", false).unwrap().iter().all(|r| r.ok), "push after pull" ); } } let fresh = Repo::clone(&url, &root.join("fresh")).unwrap(); let heads = fresh.bookmark_heads().unwrap(); let pushed = fresh.load_state_full(&heads).unwrap(); prop_assert!(fingerprint(&pushed) == want, "the pushed state differs"); Ok(()) } proptest! { #![proptest_config(ProptestConfig { cases: 32, ..ProptestConfig::default() })] #[test] fn order_of_changes_is_irrelevant(sessions in prop::collection::vec(session(), 2..4), seed in any::()) { run_case(sessions, seed)?; } }