Files
clawsync/crates/clawhdf5-onion/tests/proptest_versioned.rs
T
osobhandClaude Sonnet 4.6 1c107fe58a Apply rustfmt to entire workspace
Runs cargo fmt --all; all 573 tests still passing, clippy still clean.
No logic changes — formatting only.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-04-04 20:31:33 -05:00

228 lines
9.8 KiB
Rust

//! 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<Value = Round> {
(any::<bool>(), 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<Value = Vec<Round>> {
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<u8>)> {
let mut branches: Vec<String> = vec!["main".to_string()];
let mut expectations: Vec<(u64, Vec<u8>)> = 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<u8>)],
) -> 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<u8>)> = 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)?;
}
}