//! Property-based tests for [`VersionedFile`]. //! //! These tests verify that the high-level `VersionedFile` API preserves //! reconstruction correctness under arbitrary sequences of interleaved //! branch commits, snapshots, and flush+reload cycles. //! //! ## Key invariant //! //! For any sequence of `commit_on(branch, new_bytes, ...)` calls, reconstructing //! the resulting revision must return exactly `new_bytes` (for the pages that //! were written). This is the correctness guarantee of the per-branch diff //! baseline (`branch_state` map inside `VersionedFile`). //! //! ## Fill-byte constraint //! //! All writes use fill bytes in 1..=255 so that every page always differs from //! `h5_base` (a 4 KiB zero buffer). This avoids the documented edge case where //! committing bytes equal to `h5_base` on a branch that has ancestors with //! different content would silently record an empty diff, making the commit //! indistinguishable from a no-op at the page level. use clawhdf5_onion::versioned_file::VersionedFile; use proptest::prelude::*; use tempfile::TempDir; // ───────────────────────────────────────────────────────────────────────────── // Constants // ───────────────────────────────────────────────────────────────────────────── const PAGE_SIZE: u32 = 4096; // ───────────────────────────────────────────────────────────────────────────── // Generators // ───────────────────────────────────────────────────────────────────────────── /// A single round of operations: optionally create a new branch, then commit /// on `branch_idx % current_branch_count` with `fill`. #[derive(Debug, Clone)] struct Round { /// If true, fork a new branch from main before this commit. create_branch: bool, /// Raw index — clamped to `% len(branches)` during execution. branch_idx: usize, /// Fill byte for the 4 KiB page. Kept in 1..=255 so the page always /// differs from the zero-filled `h5_base`. fill: u8, } fn arb_round() -> impl Strategy { (any::(), 0usize..8usize, 1u8..=255u8).prop_map(|(create_branch, branch_idx, fill)| { Round { create_branch, branch_idx, fill, } }) } fn arb_rounds(min: usize, max: usize) -> impl Strategy> { proptest::collection::vec(arb_round(), min..=max) } // ───────────────────────────────────────────────────────────────────────────── // Test helpers // ───────────────────────────────────────────────────────────────────────────── /// Create a temp dir with a 4 KiB zero base file and a fresh `VersionedFile`. fn make_vf() -> (TempDir, std::path::PathBuf, VersionedFile) { let dir = TempDir::new().unwrap(); let h5 = dir.path().join("base.h5"); std::fs::write(&h5, vec![0u8; PAGE_SIZE as usize]).unwrap(); let vf = VersionedFile::create(&h5, PAGE_SIZE).unwrap(); (dir, h5, vf) } /// Apply `rounds` to `vf`, returning a Vec of `(revision_number, expected_page_bytes)`. /// /// `branches` starts as `["main"]` and grows as new branches are created. fn apply_rounds(vf: &mut VersionedFile, rounds: &[Round]) -> Vec<(u64, Vec)> { let mut branches: Vec = vec!["main".to_string()]; let mut expectations: Vec<(u64, Vec)> = Vec::new(); let mut branch_counter: usize = 0; for round in rounds { // Optionally create a new branch forked from main. if round.create_branch { let name = format!("b{branch_counter}"); branch_counter += 1; // create_branch may fail if a branch with the same name already exists, // which can't happen here since names are unique via the counter. vf.onion_mut().create_branch(&name, "main").unwrap(); branches.push(name); } let branch_name = &branches[round.branch_idx % branches.len()]; let branch_opt = if branch_name == "main" { None } else { Some(branch_name.as_str()) }; let new_bytes = vec![round.fill; PAGE_SIZE as usize]; let rev = vf.commit_on(branch_opt, new_bytes.clone(), None).unwrap(); expectations.push((rev, new_bytes)); } expectations } /// Verify every `(rev, expected_bytes)` pair: reconstruct the revision from /// `vf` and check that page 0 matches `expected_bytes`. fn verify_expectations( vf: &VersionedFile, h5_base: &[u8], expectations: &[(u64, Vec)], ) -> Result<(), TestCaseError> { for (rev, expected) in expectations { let actual = vf .onion() .reconstruct_revision(*rev, h5_base) .map_err(|e| TestCaseError::fail(format!("reconstruct_revision({rev}) failed: {e}")))?; // Check that the first page matches exactly. let page_end = PAGE_SIZE as usize; prop_assert_eq!( &actual[..page_end], expected.as_slice(), "revision {}: page 0 mismatch", rev ); } Ok(()) } // ───────────────────────────────────────────────────────────────────────────── // Property tests // ───────────────────────────────────────────────────────────────────────────── proptest! { #![proptest_config(ProptestConfig { cases: 200, max_shrink_iters: 100, ..ProptestConfig::default() })] /// After any sequence of interleaved commits on multiple branches, every /// committed revision reconstructs to the exact bytes that were passed to /// `commit_on`. /// /// This is the core invariant of the per-branch diff baseline: even with /// arbitrary interleaving of commits across branches, `reconstruct_revision` /// always yields back the bytes that were committed. #[test] fn prop_vf_interleaved_commits_reconstruct_correctly( rounds in arb_rounds(1, 16), ) { let (_dir, h5, mut vf) = make_vf(); let h5_base = std::fs::read(&h5).unwrap(); let expectations = apply_rounds(&mut vf, &rounds); verify_expectations(&vf, &h5_base, &expectations)?; } /// After commits, flushing the sidecar to disk and reloading it via /// `VersionedFile::open` preserves every revision's content. /// /// This checks that the on-disk serialisation + deserialization path is /// lossless for the high-level API. #[test] fn prop_vf_flush_reload_preserves_all_commits( rounds in arb_rounds(1, 12), ) { let (_dir, h5, mut vf) = make_vf(); let h5_base = std::fs::read(&h5).unwrap(); let expectations = apply_rounds(&mut vf, &rounds); // Flush is already called inside commit_on, but call it once more to // exercise the explicit flush path. vf.onion_mut().flush().unwrap(); // Reload from disk. let reloaded = VersionedFile::open(&h5).unwrap(); verify_expectations(&reloaded, &h5_base, &expectations)?; } /// After any commits on main followed by a manual snapshot, the snapshot /// revision reconstructs to the same bytes as the pre-snapshot HEAD. /// Subsequent commits on main still reconstruct correctly. /// /// This verifies that inserting a snapshot does not corrupt the revision /// chain for future commits. #[test] fn prop_vf_snapshot_does_not_corrupt_reconstruction( pre_fills in proptest::collection::vec(1u8..=255u8, 1..=6usize), post_fills in proptest::collection::vec(1u8..=255u8, 0..=4usize), ) { let (_dir, h5, mut vf) = make_vf(); let h5_base = std::fs::read(&h5).unwrap(); let mut expectations: Vec<(u64, Vec)> = Vec::new(); // Pre-snapshot commits on main. for &fill in &pre_fills { let bytes = vec![fill; PAGE_SIZE as usize]; let rev = vf.commit_on(None, bytes.clone(), None).unwrap(); expectations.push((rev, bytes)); } // Snapshot — must reconstruct to the same bytes as the last pre-commit. let snap_rev = vf.snapshot(Some("test snapshot")).unwrap(); let last_expected = expectations.last().unwrap().1.clone(); let snap_actual = vf.onion().reconstruct_revision(snap_rev, &h5_base).unwrap(); prop_assert_eq!( &snap_actual[..PAGE_SIZE as usize], last_expected.as_slice(), "snapshot revision {} does not match pre-snapshot HEAD", snap_rev ); // Post-snapshot commits — reconstruction must remain correct. for &fill in &post_fills { let bytes = vec![fill; PAGE_SIZE as usize]; let rev = vf.commit_on(None, bytes.clone(), None).unwrap(); expectations.push((rev, bytes)); } verify_expectations(&vf, &h5_base, &expectations)?; } }