//! Integration tests for branch-selective ClawSync. //! //! These tests exercise the full pipeline: //! differ::diff_revisions → selector::filter_packets → merger::merge_packets //! //! Scenarios covered: //! - Sync only a named branch to an empty remote //! - UpTo selector limits revisions regardless of branch //! - BranchAt selector combines branch + revision ceiling //! - Multi-branch source: only target branch lands on remote //! - Verify page data integrity after branch-selective merge //! - SyncSelector::All passes everything through (baseline) use clawhdf5_onion::branch::MergeStrategy; use clawhdf5_onion::format::NO_PARENT; use clawhdf5_onion::writer::OnionFile; use clawsync_onion::differ::diff_revisions; use clawsync_onion::merger::merge_packets; use clawsync_onion::selector::{SyncSelector, filter_packets, remote_head_for_selector}; use tempfile::NamedTempFile; // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── fn tmp_onion(n_main: u8) -> 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 mut onion = OnionFile::create(&h5, 4096).unwrap(); for i in 0..n_main { let mut s = onion.begin_session(None).unwrap(); s.record_page(0, &vec![i; 4096]); onion.commit_session(s, Some(&format!("main-{i}"))).unwrap(); } onion } fn empty_onion() -> OnionFile { let f = NamedTempFile::new().unwrap(); let h5 = f.path().with_extension("h5"); std::fs::write(&h5, b"\x89HDF\r\n\x1a\n").unwrap(); OnionFile::create(&h5, 4096).unwrap() } /// Build an onion with `n_main` revisions on main, then fork "feat" and add /// `n_feat` more revisions on feat. fn branched_onion(n_main: u8, n_feat: u8) -> OnionFile { let mut onion = tmp_onion(n_main); 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; 4096]); onion.commit_session(s, Some(&format!("feat-{i}"))).unwrap(); } onion } // ───────────────────────────────────────────────────────────────────────────── // SyncSelector::All — baseline // ───────────────────────────────────────────────────────────────────────────── #[test] fn all_selector_transfers_every_revision() { let src = tmp_onion(5); let mut dst = empty_onion(); let packets = diff_revisions(&src, NO_PARENT).unwrap(); let filtered = filter_packets(packets, &SyncSelector::All, &src).unwrap(); let stats = merge_packets(&mut dst, filtered, false).unwrap(); assert_eq!(stats.revisions_merged, 5); assert_eq!(dst.revision_count(), 5); } // ───────────────────────────────────────────────────────────────────────────── // SyncSelector::UpTo // ───────────────────────────────────────────────────────────────────────────── #[test] fn up_to_selector_limits_revisions() { let src = tmp_onion(6); let mut dst = empty_onion(); let packets = diff_revisions(&src, NO_PARENT).unwrap(); let filtered = filter_packets(packets, &SyncSelector::UpTo(3), &src).unwrap(); // Revisions 0, 1, 2, 3 → 4 packets assert_eq!(filtered.len(), 4); assert_eq!(filtered.last().unwrap().revision, 3); let stats = merge_packets(&mut dst, filtered, false).unwrap(); assert_eq!(stats.revisions_merged, 4); assert_eq!(dst.revision_count(), 4); } #[test] fn up_to_revision_zero_sends_only_first() { let src = tmp_onion(4); let packets = diff_revisions(&src, NO_PARENT).unwrap(); let filtered = filter_packets(packets, &SyncSelector::UpTo(0), &src).unwrap(); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].revision, 0); } #[test] fn up_to_beyond_head_sends_all() { let src = tmp_onion(3); let packets = diff_revisions(&src, NO_PARENT).unwrap(); let filtered = filter_packets(packets, &SyncSelector::UpTo(999), &src).unwrap(); assert_eq!(filtered.len(), 3); } // ───────────────────────────────────────────────────────────────────────────── // SyncSelector::Branch // ───────────────────────────────────────────────────────────────────────────── #[test] fn branch_selector_main_only_sends_main_revisions() { // 3 main + 2 feat revisions let src = branched_onion(3, 2); let mut dst = empty_onion(); let packets = diff_revisions(&src, NO_PARENT).unwrap(); assert_eq!(packets.len(), 5); // all revisions let filtered = filter_packets(packets, &SyncSelector::Branch("main".into()), &src).unwrap(); assert_eq!(filtered.len(), 3); assert!(filtered.iter().all(|p| p.branch_id == 0)); let stats = merge_packets(&mut dst, filtered, false).unwrap(); assert_eq!(stats.revisions_merged, 3); assert_eq!(dst.revision_count(), 3); } #[test] fn branch_selector_feat_only_sends_feat_revisions() { let src = branched_onion(3, 4); let feat_id = src.branch_by_name("feat").unwrap().id; let packets = diff_revisions(&src, NO_PARENT).unwrap(); let filtered = filter_packets(packets, &SyncSelector::Branch("feat".into()), &src).unwrap(); assert_eq!(filtered.len(), 4); assert!(filtered.iter().all(|p| p.branch_id == feat_id)); } #[test] fn branch_selector_nonexistent_errors() { let src = tmp_onion(2); let packets = diff_revisions(&src, NO_PARENT).unwrap(); let result = filter_packets(packets, &SyncSelector::Branch("nope".into()), &src); assert!(result.is_err()); } // ───────────────────────────────────────────────────────────────────────────── // SyncSelector::BranchAt // ───────────────────────────────────────────────────────────────────────────── #[test] fn branch_at_filters_branch_and_revision() { // 2 main revisions (0, 1), then 4 feat revisions (2, 3, 4, 5) let src = branched_onion(2, 4); let feat_id = src.branch_by_name("feat").unwrap().id; let packets = diff_revisions(&src, NO_PARENT).unwrap(); // BranchAt("feat", 3) → feat packets with revision ≤ 3 → revisions 2, 3 let filtered = filter_packets(packets, &SyncSelector::BranchAt("feat".into(), 3), &src).unwrap(); assert_eq!(filtered.len(), 2); assert!(filtered.iter().all(|p| p.branch_id == feat_id)); assert!(filtered.iter().all(|p| p.revision <= 3)); } #[test] fn branch_at_main_with_ceiling() { let src = tmp_onion(5); let packets = diff_revisions(&src, NO_PARENT).unwrap(); let filtered = filter_packets(packets, &SyncSelector::BranchAt("main".into(), 2), &src).unwrap(); assert_eq!(filtered.len(), 3); // revisions 0, 1, 2 assert_eq!(filtered.last().unwrap().revision, 2); } #[test] fn branch_at_nonexistent_branch_errors() { let src = tmp_onion(3); let packets = diff_revisions(&src, NO_PARENT).unwrap(); let result = filter_packets(packets, &SyncSelector::BranchAt("ghost".into(), 1), &src); assert!(result.is_err()); } // ───────────────────────────────────────────────────────────────────────────── // Multi-branch source → branch-filtered merge // ───────────────────────────────────────────────────────────────────────────── #[test] fn merge_feat_branch_only_to_empty_remote() { let src = branched_onion(3, 3); let feat_id = src.branch_by_name("feat").unwrap().id; let mut dst = empty_onion(); let packets = diff_revisions(&src, NO_PARENT).unwrap(); let filtered = filter_packets(packets, &SyncSelector::Branch("feat".into()), &src).unwrap(); // Only feat packets → 3 revisions assert_eq!(filtered.len(), 3); let stats = merge_packets(&mut dst, filtered, false).unwrap(); assert_eq!(stats.revisions_merged, 3); // The remote should have a feat branch with those revisions let dst_revs = dst.list_revisions(); assert_eq!(dst_revs.len(), 3); assert!(dst_revs.iter().all(|r| r.branch_id == feat_id)); } #[test] fn merge_preserves_page_content_after_branch_filter() { let src = branched_onion(2, 2); let mut dst = empty_onion(); let packets = diff_revisions(&src, NO_PARENT).unwrap(); let filtered = filter_packets(packets, &SyncSelector::Branch("main".into()), &src).unwrap(); merge_packets(&mut dst, filtered, false).unwrap(); // Main rev 0: page filled with 0x00, rev 1: filled with 0x01 let rev0_pages = dst.revision_pages(0).unwrap(); assert!(rev0_pages[0].1.iter().all(|&b| b == 0x00)); let rev1_pages = dst.revision_pages(1).unwrap(); assert!(rev1_pages[0].1.iter().all(|&b| b == 0x01)); } // ───────────────────────────────────────────────────────────────────────────── // remote_head_for_selector // ───────────────────────────────────────────────────────────────────────────── #[test] fn remote_head_empty_remote() { assert_eq!(remote_head_for_selector(&SyncSelector::All, 0), NO_PARENT); } #[test] fn remote_head_non_empty_remote() { // Remote has 4 revisions (0..=3) → HEAD = 3 assert_eq!(remote_head_for_selector(&SyncSelector::All, 4), 3); } #[test] fn remote_head_single_revision() { assert_eq!(remote_head_for_selector(&SyncSelector::UpTo(5), 1), 0); } // ───────────────────────────────────────────────────────────────────────────── // Incremental sync (diff from non-zero remote_head) // ───────────────────────────────────────────────────────────────────────────── #[test] fn incremental_sync_sends_only_new_revisions() { let src = tmp_onion(6); // Remote already has revisions 0..=2; diff from HEAD=2 let packets = diff_revisions(&src, 2).unwrap(); let filtered = filter_packets(packets, &SyncSelector::All, &src).unwrap(); assert_eq!(filtered.len(), 3); // revisions 3, 4, 5 let mut dst = tmp_onion(3); // dst already has 0, 1, 2 let stats = merge_packets(&mut dst, filtered, false).unwrap(); assert_eq!(stats.revisions_merged, 3); assert_eq!(dst.revision_count(), 6); } #[test] fn incremental_branch_sync_sends_only_new_branch_revisions() { let src = branched_onion(3, 4); // main: 0,1,2 feat: 3,4,5,6 let feat_id = src.branch_by_name("feat").unwrap().id; // Simulate remote already having feat revisions 3, 4 (remote_head=4) let remote_head = remote_head_for_selector(&SyncSelector::Branch("feat".into()), 5); let packets = diff_revisions(&src, remote_head).unwrap(); let filtered = filter_packets(packets, &SyncSelector::Branch("feat".into()), &src).unwrap(); // Should send feat revisions 5, 6 only assert!(filtered.iter().all(|p| p.branch_id == feat_id)); assert!(filtered.iter().all(|p| p.revision > remote_head)); } // ───────────────────────────────────────────────────────────────────────────── // Merge with branched source using ensure_branch_id auto-registration // ───────────────────────────────────────────────────────────────────────────── #[test] fn merge_auto_registers_unknown_branch() { // Source: 2 main + 2 feat revisions; send ALL to empty remote let src = branched_onion(2, 2); let mut dst = empty_onion(); let packets = diff_revisions(&src, NO_PARENT).unwrap(); // All 4 packets — includes both main (branch_id=0) and feat (branch_id=1) let stats = merge_packets(&mut dst, packets, false).unwrap(); assert_eq!(stats.revisions_merged, 4); // The remote should have both branches registered let branches = dst.list_branches(); assert!( branches.len() >= 2, "expected at least 2 branches, got {:?}", branches.len() ); } // ───────────────────────────────────────────────────────────────────────────── // Merge-then-branch on destination (fork from synced state) // ───────────────────────────────────────────────────────────────────────────── #[test] fn can_branch_on_remote_after_branch_selective_sync() { let src = tmp_onion(3); let mut dst = empty_onion(); let packets = diff_revisions(&src, NO_PARENT).unwrap(); let filtered = filter_packets(packets, &SyncSelector::Branch("main".into()), &src).unwrap(); merge_packets(&mut dst, filtered, false).unwrap(); // The remote should now be able to fork its own branch let result = dst.create_branch("experiment", "main"); assert!( result.is_ok(), "should be able to create branch after sync: {result:?}" ); } // ───────────────────────────────────────────────────────────────────────────── // Idempotency across selectors // ───────────────────────────────────────────────────────────────────────────── #[test] fn branch_filtered_merge_is_idempotent() { let src = branched_onion(3, 2); let mut dst = empty_onion(); let packets1 = diff_revisions(&src, NO_PARENT).unwrap(); let filtered1 = filter_packets(packets1, &SyncSelector::Branch("main".into()), &src).unwrap(); merge_packets(&mut dst, filtered1, false).unwrap(); // Apply again — should skip all let packets2 = diff_revisions(&src, NO_PARENT).unwrap(); let filtered2 = filter_packets(packets2, &SyncSelector::Branch("main".into()), &src).unwrap(); let stats2 = merge_packets(&mut dst, filtered2, false).unwrap(); assert_eq!(stats2.revisions_merged, 0); assert_eq!(stats2.revisions_skipped, 3); } // ───────────────────────────────────────────────────────────────────────────── // merge-then-verify via merge_into (end-to-end with branching) // ───────────────────────────────────────────────────────────────────────────── #[test] fn synced_remote_can_merge_branches() { let src = branched_onion(3, 2); let mut dst = empty_onion(); // Sync everything let packets = diff_revisions(&src, NO_PARENT).unwrap(); merge_packets(&mut dst, packets, false).unwrap(); // The feat branch was auto-registered as "branch-{id}" during sync let feat_id = src.branch_by_name("feat").unwrap().id; let dst_feat_name = format!("branch-{feat_id}"); let result = dst.merge_into(&dst_feat_name, "main", MergeStrategy::LatestWins); assert!(result.is_ok(), "merge_into failed: {result:?}"); // main should now have an extra revision let main_revs: Vec<_> = dst .list_revisions() .into_iter() .filter(|r| r.branch_id == 0) .collect(); assert!(main_revs.len() >= 4); // 3 original + 1 merge commit }