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]>
228 lines
9.8 KiB
Rust
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)?;
|
|
}
|
|
}
|