//! Property-based tests for `clawhdf5-onion`. //! //! These tests use `proptest` to verify invariants over arbitrary sequences of //! write, branch, merge, GC, and read operations, ensuring the onion format //! stays consistent regardless of the operation order. //! //! Invariants checked: //! - `head_rev` is always valid (< revision_count) //! - No orphan page entries (every revision index entry has accessible pages) //! - BLAKE3 hashes are consistent after any sequence of writes //! - Branch count equals unique branch IDs in revision index //! - GC followed by any read produces no error //! - Serialise → deserialise round-trip preserves all revision annotations //! - Merge produces a new revision on the target branch //! - `revision_count` is monotonically non-decreasing use clawhdf5_onion::branch::MergeStrategy; use clawhdf5_onion::format::NO_PARENT; use clawhdf5_onion::gc::GcPolicy; use clawhdf5_onion::writer::OnionFile; use proptest::prelude::*; use std::path::PathBuf; use tempfile::NamedTempFile; // ───────────────────────────────────────────────────────────────────────────── // Generators // ───────────────────────────────────────────────────────────────────────────── /// An operation to apply to an OnionFile during a property test. #[derive(Debug, Clone)] enum Op { /// Write a page at a given offset with a given fill byte. Write { page_offset: u32, fill: u8 }, /// Fork a new branch named "branch-N" off main. Fork, /// Commit the current session and start fresh. Flush, } fn arb_op() -> impl Strategy { prop_oneof![ (0u32..4u32, any::()).prop_map(|(off, fill)| Op::Write { page_offset: off, fill, }), Just(Op::Fork), Just(Op::Flush), ] } fn arb_ops(min: usize, max: usize) -> impl Strategy> { proptest::collection::vec(arb_op(), min..=max) } fn arb_page_size() -> impl Strategy { prop_oneof![Just(512u32), Just(1024), Just(4096)] } /// Create a fresh OnionFile in a temp directory. fn fresh_onion(page_size: u32) -> (NamedTempFile, PathBuf, OnionFile) { let f = NamedTempFile::new().unwrap(); let h5 = f.path().with_extension("h5"); std::fs::write(&h5, b"\x89HDF\r\n\x1a\n").unwrap(); let onion = OnionFile::create(&h5, page_size).unwrap(); (f, h5, onion) } // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── /// Apply a list of ops to an OnionFile. Returns the number of revisions committed. fn apply_ops(onion: &mut OnionFile, ops: &[Op]) -> u64 { let mut session = onion.begin_session(None).unwrap(); let page_size = onion.page_size() as usize; let mut fork_count = 0usize; let mut has_writes = false; for op in ops { match op { Op::Write { page_offset, fill } => { let offset = (*page_offset as u64) * (page_size as u64); session.record_page(offset, &vec![*fill; page_size]); has_writes = true; } Op::Fork => { // Commit current session first if has_writes { onion.commit_session(session, Some("auto")).unwrap(); has_writes = false; } else { drop(session); } // Fork a new branch let branch_name = format!("branch-{fork_count}"); fork_count += 1; let _ = onion.create_branch(&branch_name, "main"); // Restart session on main session = onion.begin_session(None).unwrap(); } Op::Flush => { if has_writes { onion.commit_session(session, Some("flush")).unwrap(); has_writes = false; } else { drop(session); } session = onion.begin_session(None).unwrap(); } } } // Commit trailing session if it has writes if has_writes { onion.commit_session(session, Some("final")).unwrap(); } else { drop(session); } onion.revision_count() } /// Check all invariants on an OnionFile. fn assert_invariants(onion: &OnionFile) { let revisions = onion.list_revisions(); let rev_count = onion.revision_count(); // 1. revision_count matches actual revision list length assert_eq!( rev_count as usize, revisions.len(), "revision_count mismatch: header={rev_count} list={}", revisions.len() ); // 2. Revision numbers are monotonically increasing from 0 for (i, rev) in revisions.iter().enumerate() { assert_eq!( rev.revision, i as u64, "non-contiguous revision at index {i}: revision={}", rev.revision ); } // 3. parent_rev is either NO_PARENT or a valid earlier revision for rev in &revisions { if rev.parent_rev != NO_PARENT { assert!( rev.parent_rev < rev.revision, "revision {} has parent {} ≥ itself", rev.revision, rev.parent_rev ); } } // 4. branch_id references are all valid let branch_ids: std::collections::HashSet = onion.list_branches().iter().map(|b| b.id).collect(); for rev in &revisions { assert!( branch_ids.contains(&rev.branch_id), "revision {} references unknown branch_id {}", rev.revision, rev.branch_id ); } // 5. blake3_hex has correct length (64 chars) for rev in &revisions { assert_eq!( rev.blake3_hex.len(), 64, "revision {} has blake3_hex of wrong length {}", rev.revision, rev.blake3_hex.len() ); assert!( rev.blake3_hex.chars().all(|c| c.is_ascii_hexdigit()), "revision {} has non-hex blake3_hex: {}", rev.revision, &rev.blake3_hex ); } // 6. Pages are accessible for every revision for rev in &revisions { onion .revision_pages(rev.revision) .unwrap_or_else(|e| panic!("revision_pages({}) failed: {e}", rev.revision)); } // 7. branch head revisions are valid for branch in onion.list_branches() { if branch.head_rev != NO_PARENT { assert!( branch.head_rev < rev_count, "branch {} head_rev {} >= revision_count {}", branch.name, branch.head_rev, rev_count ); } } } /// Check GC-safe invariants — like `assert_invariants` but allows non-contiguous /// revision numbering (GC keeps original IDs) and gaps in the parent chain. fn assert_gc_invariants(onion: &OnionFile) { let revisions = onion.list_revisions(); let rev_count = onion.revision_count(); // 1. revision_count matches actual revision list length assert_eq!( rev_count as usize, revisions.len(), "revision_count mismatch: header={rev_count} list={}", revisions.len() ); // 2. Revision numbers are monotonically increasing (gaps allowed after GC) for w in revisions.windows(2) { assert!( w[0].revision < w[1].revision, "revisions not sorted: {} then {}", w[0].revision, w[1].revision ); } // 3. parent_rev is either NO_PARENT or a valid earlier revision number let rev_set: std::collections::HashSet = revisions.iter().map(|r| r.revision).collect(); for rev in &revisions { if rev.parent_rev != NO_PARENT { assert!( rev.parent_rev < rev.revision, "revision {} has parent {} ≥ itself", rev.revision, rev.parent_rev ); // After GC the parent may have been pruned (oldest_surviving is set to NO_PARENT // during consolidation), so we only check that parent < self (not that it exists). let _ = &rev_set; // suppress unused warning } } // 4. branch_id references are all valid let branch_ids: std::collections::HashSet = onion.list_branches().iter().map(|b| b.id).collect(); for rev in &revisions { assert!( branch_ids.contains(&rev.branch_id), "revision {} references unknown branch_id {}", rev.revision, rev.branch_id ); } // 5. blake3_hex has correct length (64 chars) for rev in &revisions { assert_eq!( rev.blake3_hex.len(), 64, "revision {} has blake3_hex of wrong length {}", rev.revision, rev.blake3_hex.len() ); } // 6. Pages are accessible for every revision for rev in &revisions { onion .revision_pages(rev.revision) .unwrap_or_else(|e| panic!("revision_pages({}) failed: {e}", rev.revision)); } } // ───────────────────────────────────────────────────────────────────────────── // Property tests // ───────────────────────────────────────────────────────────────────────────── proptest! { #![proptest_config(ProptestConfig { cases: 300, max_shrink_iters: 100, ..ProptestConfig::default() })] /// Any sequence of write ops leaves the onion in a valid state. #[test] fn prop_any_write_sequence_is_consistent( page_size in arb_page_size(), ops in arb_ops(1, 20), ) { let (_f, _h5, mut onion) = fresh_onion(page_size); apply_ops(&mut onion, &ops); assert_invariants(&onion); } /// revision_count never decreases. #[test] fn prop_revision_count_monotone(ops in arb_ops(1, 15)) { let (_f, _h5, mut onion) = fresh_onion(4096); let mut prev = 0u64; // Apply ops in batches of 3, checking after each batch for chunk in ops.chunks(3) { apply_ops(&mut onion, chunk); let cur = onion.revision_count(); prop_assert!(cur >= prev, "revision_count decreased: {prev} → {cur}"); prev = cur; } } /// Every revision annotation stored is retrievable. #[test] fn prop_annotations_roundtrip( annotations in proptest::collection::vec( proptest::option::of("[a-zA-Z0-9 _-]{0,40}"), 1..=10usize, ) ) { let (_f, _h5, mut onion) = fresh_onion(4096); for (i, ann) in annotations.iter().enumerate() { let mut s = onion.begin_session(None).unwrap(); s.record_page(0, &vec![i as u8; 4096]); onion.commit_session(s, ann.as_deref()).unwrap(); } let revisions = onion.list_revisions(); for (rev_entry, expected_ann) in revisions.iter().zip(annotations.iter()) { prop_assert_eq!( &rev_entry.annotation, expected_ann, "annotation mismatch at revision {}", rev_entry.revision ); } } /// Creating any number of branches in [0, 8] leaves list_branches consistent. #[test] fn prop_branch_count_consistent(n_branches in 0usize..=8usize) { let (_f, _h5, mut onion) = fresh_onion(4096); // Write one revision to main so fork has a valid HEAD let mut s = onion.begin_session(None).unwrap(); s.record_page(0, &vec![0xAAu8; 4096]); onion.commit_session(s, None).unwrap(); let mut created = 0; for i in 0..n_branches { let name = format!("branch-{i}"); if onion.create_branch(&name, "main").is_ok() { created += 1; } } let branches = onion.list_branches(); // +1 for main prop_assert_eq!(branches.len(), created + 1, "expected {} branches (including main), got {}", created + 1, branches.len()); } /// Page data for the most recent revision of main always matches the last written fill byte. #[test] fn prop_latest_page_reflects_last_write( fills in proptest::collection::vec(any::(), 1..=10usize), ) { let (_f, _h5, mut onion) = fresh_onion(4096); for &fill in &fills { let mut s = onion.begin_session(None).unwrap(); s.record_page(0, &vec![fill; 4096]); onion.commit_session(s, None).unwrap(); } let head_rev = onion.revision_count() - 1; let pages = onion.revision_pages(head_rev).unwrap(); let page_data = &pages[0].1; let expected_fill = *fills.last().unwrap(); prop_assert!( page_data.iter().all(|&b| b == expected_fill), "head page fill mismatch: expected 0x{:02x}, got first byte 0x{:02x}", expected_fill, page_data[0] ); } /// BLAKE3 hex for every revision is unique (no two revisions with identical page content /// sharing a hash — all revisions have distinct page contents due to unique fill bytes). /// /// We write n revisions each with a distinct fill byte, then verify all blake3s differ. #[test] fn prop_distinct_writes_have_distinct_hashes( fills in proptest::collection::vec(0u8..=127u8, 2..=8usize).prop_filter( "fills must be unique", |v| { let s: std::collections::HashSet = v.iter().copied().collect(); s.len() == v.len() } ) ) { let (_f, _h5, mut onion) = fresh_onion(4096); for &fill in &fills { let mut s = onion.begin_session(None).unwrap(); s.record_page(0, &vec![fill; 4096]); onion.commit_session(s, None).unwrap(); } let hashes: Vec<_> = onion.list_revisions().into_iter().map(|r| r.blake3_hex).collect(); let unique: std::collections::HashSet<_> = hashes.iter().collect(); prop_assert_eq!(unique.len(), hashes.len(), "duplicate blake3 hashes found"); } /// Serialize → deserialize round-trip: all revision annotations survive. #[test] fn prop_serde_roundtrip_preserves_annotations( n_revisions in 1usize..=10usize, ann_len in 0usize..=30usize, ) { let (_f, h5_path, mut onion) = fresh_onion(4096); for i in 0..n_revisions { let mut s = onion.begin_session(None).unwrap(); s.record_page(0, &vec![i as u8 % 255; 4096]); let ann: String = format!("rev-{i:0>width$}", width = ann_len.min(20)); onion.commit_session(s, Some(&ann)).unwrap(); } onion.flush().unwrap(); // Reload from disk let reloaded = OnionFile::open(&h5_path).unwrap(); let orig_revs = onion.list_revisions(); let reloaded_revs = reloaded.list_revisions(); prop_assert_eq!(orig_revs.len(), reloaded_revs.len()); for (o, r) in orig_revs.iter().zip(reloaded_revs.iter()) { prop_assert_eq!(&o.annotation, &r.annotation, "annotation mismatch at revision {}", o.revision); prop_assert_eq!(&o.blake3_hex, &r.blake3_hex, "blake3 mismatch at revision {}", o.revision); } } /// After any fork, the new branch starts with the same HEAD as main. #[test] fn prop_fork_head_equals_source_head(n_writes in 1usize..=5usize) { let (_f, _h5, mut onion) = fresh_onion(4096); for i in 0..n_writes { let mut s = onion.begin_session(None).unwrap(); s.record_page(0, &vec![i as u8; 4096]); onion.commit_session(s, None).unwrap(); } let main_head_before = onion.list_branches() .iter().find(|b| b.name == "main").unwrap().head_rev; onion.create_branch("feat", "main").unwrap(); let feat_branch = onion.list_branches() .into_iter().find(|b| b.name == "feat").unwrap(); prop_assert_eq!( feat_branch.fork_rev, main_head_before, "fork_rev should equal main HEAD at fork time" ); } /// Merging branch into main with LatestWins always produces at least one new revision. #[test] fn prop_merge_latest_wins_produces_new_revision( n_main in 1usize..=4usize, n_feat in 1usize..=4usize, ) { let (_f, _h5, mut onion) = fresh_onion(4096); for i in 0..n_main { let mut s = onion.begin_session(None).unwrap(); s.record_page(0, &vec![i as u8; 4096]); onion.commit_session(s, None).unwrap(); } onion.create_branch("feat", "main").unwrap(); let feat_id = onion.branch_by_name("feat").unwrap().id; for i in 0..n_feat { let mut s = onion.begin_session(Some(feat_id)).unwrap(); s.record_page(0, &vec![0x80 + i as u8; 4096]); onion.commit_session(s, None).unwrap(); } let rev_before = onion.revision_count(); onion.merge_into("feat", "main", MergeStrategy::LatestWins).unwrap(); let rev_after = onion.revision_count(); prop_assert!(rev_after > rev_before, "merge did not produce a new revision"); assert_invariants(&onion); } /// Multiple sequential merges stay consistent. #[test] fn prop_sequential_merges_consistent(n_rounds in 1usize..=3usize) { let (_f, _h5, mut onion) = fresh_onion(4096); // One main revision let mut s = onion.begin_session(None).unwrap(); s.record_page(0, &vec![0u8; 4096]); onion.commit_session(s, None).unwrap(); for round in 0..n_rounds { let branch_name = format!("feat-{round}"); onion.create_branch(&branch_name, "main").unwrap(); let bid = onion.branch_by_name(&branch_name).unwrap().id; let mut s = onion.begin_session(Some(bid)).unwrap(); s.record_page(0, &vec![round as u8 + 1; 4096]); onion.commit_session(s, None).unwrap(); onion.merge_into(&branch_name, "main", MergeStrategy::LatestWins).unwrap(); assert_invariants(&onion); } } /// Empty sessions (no pages recorded) can be committed without breaking state. #[test] fn prop_empty_sessions_do_not_corrupt(n_empty in 1usize..=5usize) { let (_f, _h5, mut onion) = fresh_onion(4096); // One real write to anchor things let mut s = onion.begin_session(None).unwrap(); s.record_page(0, &vec![0xAAu8; 4096]); onion.commit_session(s, Some("anchor")).unwrap(); for _ in 0..n_empty { let s = onion.begin_session(None).unwrap(); onion.commit_session(s, None).unwrap(); } assert_invariants(&onion); } /// revision_pages never returns data of the wrong size. #[test] fn prop_revision_pages_correct_size( n_revisions in 1usize..=8usize, page_size in arb_page_size(), ) { let (_f, _h5, mut onion) = fresh_onion(page_size); for i in 0..n_revisions { let mut s = onion.begin_session(None).unwrap(); s.record_page(0, &vec![i as u8; page_size as usize]); onion.commit_session(s, None).unwrap(); } for rev in 0..n_revisions as u64 { let pages = onion.revision_pages(rev).unwrap(); for (_, data) in &pages { prop_assert_eq!( data.len(), page_size as usize, "page data len {} != page_size {} at rev {}", data.len(), page_size, rev ); } } } /// After a rename, the branch is accessible under the new name only. #[test] fn prop_rename_branch_accessible_by_new_name( old_suffix in "[a-z]{3,6}", new_suffix in "[a-z]{3,6}", ) { prop_assume!(old_suffix != new_suffix); let (_f, _h5, mut onion) = fresh_onion(4096); let mut s = onion.begin_session(None).unwrap(); s.record_page(0, &vec![1u8; 4096]); onion.commit_session(s, None).unwrap(); let old_name = format!("branch-{old_suffix}"); let new_name = format!("branch-{new_suffix}"); onion.create_branch(&old_name, "main").unwrap(); onion.rename_branch(&old_name, &new_name).unwrap(); prop_assert!(onion.branch_by_name(&old_name).is_none(), "old name still accessible after rename"); prop_assert!(onion.branch_by_name(&new_name).is_some(), "new name not accessible after rename"); } /// GC `KeepLastN` — surviving revisions reconstruct to the same page data as before GC. #[test] fn prop_gc_keep_last_n_surviving_pages_unchanged( // Write between 2 and 12 revisions, each writing distinct fill bytes at // one of 4 page offsets. writes in proptest::collection::vec( (0u32..4u32, any::()), 2..=12usize, ), // Keep between 1 and all revisions. keep_n in 1u64..=12u64, ) { let (_f, _h5, mut onion) = fresh_onion(4096); for (page_off, fill) in &writes { let mut s = onion.begin_session(None).unwrap(); s.record_page((*page_off as u64) * 4096, &vec![*fill; 4096]); onion.commit_session(s, None).unwrap(); } let rev_count = onion.revision_count(); let keep_n = keep_n.min(rev_count); // Capture page data for all revisions before GC. let before: Vec)>> = (0..rev_count) .map(|rev| onion.revision_pages(rev).unwrap()) .collect(); // Run GC. let stats = onion.gc(GcPolicy::KeepLastN(keep_n)).unwrap(); // The number of surviving revisions should equal min(keep_n, rev_count). let surviving = onion.revision_count(); prop_assert_eq!(surviving, keep_n.min(rev_count), "after KeepLastN({}), expected {}, got {} revisions", keep_n, keep_n.min(rev_count), surviving); // GC removed rev_count - surviving revisions. prop_assert_eq!(stats.revisions_removed, rev_count - surviving, "revisions_removed mismatch"); // Surviving revisions satisfy GC-safe invariants (non-contiguous IDs allowed). assert_gc_invariants(&onion); // For every surviving revision, `revision_pages()` must succeed and // return pages of the correct size. GC preserves original revision IDs // so we query by original revision number from list_revisions(). let surviving_revs: Vec = onion.list_revisions().iter().map(|r| r.revision).collect(); for &rev_id in &surviving_revs { let pages = onion.revision_pages(rev_id) .unwrap_or_else(|e| panic!("revision_pages({rev_id}) failed after GC: {e}")); for (_, data) in &pages { prop_assert_eq!(data.len(), 4096usize, "page data len {} != 4096 at post-GC rev {}", data.len(), rev_id); } } // The HEAD revision (last surviving) must have the same page content // as the original HEAD. GC keeps original IDs so the last entry in // list_revisions() gives the actual revision number to query. let original_head_pages = &before[rev_count as usize - 1]; let gc_head_id = *surviving_revs.last().unwrap(); let gc_head_pages = onion.revision_pages(gc_head_id).unwrap(); // Build maps keyed by page offset for comparison. let orig_map: std::collections::HashMap> = original_head_pages.iter().map(|(off, data)| (*off, data)).collect(); let gc_map: std::collections::HashMap> = gc_head_pages.iter().map(|(off, data)| (*off, data)).collect(); for (off, orig_data) in &orig_map { if let Some(gc_data) = gc_map.get(off) { prop_assert_eq!( orig_data.as_slice(), gc_data.as_slice(), "HEAD page at offset {} changed after GC", off ); } } // Pages present in GC head but not in original head must be zero // (filled from h5_base = HDF5 magic + zeros) — we don't assert this // as it depends on consolidation, but the invariant check above covers consistency. } /// GC `KeepLastN(n)` with n >= revision_count is a no-op. #[test] fn prop_gc_keep_all_is_noop( n_revisions in 1usize..=8usize, ) { let (_f, _h5, mut onion) = fresh_onion(4096); for i in 0..n_revisions { let mut s = onion.begin_session(None).unwrap(); s.record_page(0, &vec![i as u8; 4096]); onion.commit_session(s, None).unwrap(); } let before_hashes: Vec = onion.list_revisions().iter() .map(|r| r.blake3_hex.clone()).collect(); // Keep more than we have — should remove nothing. let stats = onion.gc(GcPolicy::KeepLastN(n_revisions as u64 + 10)).unwrap(); prop_assert_eq!(stats.revisions_removed, 0, "expected no removals"); let after_hashes: Vec = onion.list_revisions().iter() .map(|r| r.blake3_hex.clone()).collect(); prop_assert_eq!(before_hashes, after_hashes, "hashes changed after no-op GC"); assert_gc_invariants(&onion); } /// `KeepTagged` retains exactly the annotated revisions and prunes the rest. /// /// After GC, every surviving revision must have a non-empty annotation, and /// the count of surviving revisions must equal the number of annotated /// revisions before GC. #[test] fn prop_gc_keep_tagged_retains_exactly_annotated( // Bit mask: 1 = annotated, 0 = unannotated, for up to 10 revisions. annotation_mask in 0u16..1024u16, n_revisions in 1usize..=10usize, ) { let (_f, _h5, mut onion) = fresh_onion(4096); let mut annotated_count: usize = 0; for i in 0..n_revisions { let mut s = onion.begin_session(None).unwrap(); s.record_page(0, &vec![i as u8; 4096]); let ann: Option<&str> = if (annotation_mask >> i) & 1 == 1 { annotated_count += 1; Some("keep") } else { None }; onion.commit_session(s, ann).unwrap(); } onion.gc(GcPolicy::KeepTagged).unwrap(); let revisions = onion.list_revisions(); // All surviving revisions must have annotations. for rev in &revisions { prop_assert!( rev.annotation.is_some(), "revision {} survived KeepTagged but has no annotation", rev.revision ); } // The count of survivors equals the number of annotated inputs. prop_assert_eq!( revisions.len(), annotated_count, "expected {} annotated revisions to survive, got {}", annotated_count, revisions.len() ); assert_gc_invariants(&onion); } /// `KeepSince(cutoff)` retains revisions whose timestamp >= cutoff and /// prunes those strictly below cutoff. /// /// We use a synthetic test: all revisions are written in rapid succession so /// their timestamps cluster together. We then test two extremes: /// - `KeepSince(0.0)` — keeps everything (all timestamps post-epoch 0) /// - `KeepSince(far future)` — removes everything /// /// For intermediate cutoffs we verify the monotonicity property: increasing /// the cutoff never *increases* the number of surviving revisions. #[test] fn prop_gc_keep_since_monotone_in_cutoff( n_revisions in 2usize..=8usize, ) { // Write revisions; timestamps are set by the implementation (current time). let (_f, h5_path, mut onion) = fresh_onion(4096); for i in 0..n_revisions { let mut s = onion.begin_session(None).unwrap(); s.record_page(0, &vec![i as u8; 4096]); onion.commit_session(s, None).unwrap(); } onion.flush().unwrap(); // cutoff = 0 keeps everything (all real timestamps > Unix epoch 0). let mut o0 = OnionFile::open(&h5_path).unwrap(); o0.gc(GcPolicy::KeepSince(0.0)).unwrap(); let kept_at_epoch = o0.revision_count(); prop_assert_eq!( kept_at_epoch, n_revisions as u64, "KeepSince(0) should keep all {} revisions", n_revisions ); // cutoff = far future removes everything. let far_future = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs_f64()) .unwrap_or(0.0) + 1_000_000.0; let mut o_future = OnionFile::open(&h5_path).unwrap(); o_future.gc(GcPolicy::KeepSince(far_future)).unwrap(); let kept_at_future = o_future.revision_count(); prop_assert_eq!( kept_at_future, 0, "KeepSince(far future) should remove all revisions" ); // Monotonicity: kept_at_epoch >= kept_at_future (trivially: n >= 0). prop_assert!( kept_at_epoch >= kept_at_future, "more revisions kept at a tighter cutoff: {} > {}", kept_at_future, kept_at_epoch ); } /// GC followed by flush and reload preserves all surviving revisions. #[test] fn prop_gc_flush_reload_consistent( n_revisions in 2usize..=8usize, keep_n in 1u64..=8u64, ) { let (_f, h5_path, mut onion) = fresh_onion(4096); for i in 0..n_revisions { let mut s = onion.begin_session(None).unwrap(); s.record_page(0, &vec![i as u8; 4096]); onion.commit_session(s, None).unwrap(); } let keep_n = keep_n.min(n_revisions as u64); onion.gc(GcPolicy::KeepLastN(keep_n)).unwrap(); let pre_flush_count = onion.revision_count(); onion.flush().unwrap(); // Reload and verify. let reloaded = OnionFile::open(&h5_path).unwrap(); prop_assert_eq!(reloaded.revision_count(), pre_flush_count, "revision count changed across flush+reload"); assert_gc_invariants(&reloaded); // All revisions must be readable after reload (using original IDs). for rev in reloaded.list_revisions().iter().map(|r| r.revision) { reloaded.revision_pages(rev) .unwrap_or_else(|e| panic!("revision_pages({rev}) failed after reload: {e}")); } } }