//! Integration tests for `clawsync-hdf5`. //! //! These tests exercise the public surface — `DatasetManifest`, `diff_manifests`, //! `apply_patches` (file-path API), and `apply_patches_to_bytes` — end-to-end, //! creating real HDF5 files in memory and on disk as needed. use clawhdf5::{File, FileBuilder}; use clawsync_hdf5::{ DatasetManifest, PatchKind, apply_patches, apply_patches_to_bytes, diff_manifests, }; use tempfile::NamedTempFile; // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── /// Build HDF5 bytes from a list of (dataset_path, values) pairs. fn h5_bytes(datasets: &[(&str, &[f64])]) -> Vec { let mut b = FileBuilder::new(); for (name, data) in datasets { b.create_dataset(name).with_f64_data(data); } b.finish().unwrap() } /// Write `bytes` to a `NamedTempFile` with an `.h5` extension and return both /// the guard (keeps the file alive) and the `.h5` path. fn write_temp_h5(bytes: &[u8]) -> (NamedTempFile, std::path::PathBuf) { let guard = NamedTempFile::new().unwrap(); let path = guard.path().with_extension("h5"); std::fs::write(&path, bytes).unwrap(); (guard, path) } // ───────────────────────────────────────────────────────────────────────────── // 1. patch_adds_new_dataset // ───────────────────────────────────────────────────────────────────────────── /// A dataset present in source but absent from target is added after patching. #[test] fn patch_adds_new_dataset() { let src = h5_bytes(&[ ("temperature", &[20.0, 21.5, 22.0]), ("humidity", &[55.0, 57.0]), ]); let tgt = h5_bytes(&[("temperature", &[20.0, 21.5, 22.0])]); let old_m = DatasetManifest::from_bytes(&tgt).unwrap(); let new_m = DatasetManifest::from_bytes(&src).unwrap(); let diff = diff_manifests(&old_m, &new_m); assert!(!diff.is_empty()); assert_eq!(diff.of_kind(&PatchKind::Added).count(), 1); let (patched, _) = apply_patches_to_bytes(&diff.patches, &src, &tgt).unwrap(); let result_m = DatasetManifest::from_bytes(&patched).unwrap(); // The new dataset must be present with the correct hash. let result_entry = result_m .get("/humidity") .expect("humidity should exist after patching"); let src_entry = new_m.get("/humidity").unwrap(); assert_eq!(result_entry.blake3, src_entry.blake3); } // ───────────────────────────────────────────────────────────────────────────── // 2. patch_modifies_dataset // ───────────────────────────────────────────────────────────────────────────── /// A dataset with updated values in source replaces the stale target version. #[test] fn patch_modifies_dataset() { let src = h5_bytes(&[("pressure", &[1013.25, 1014.0, 1012.5])]); let tgt = h5_bytes(&[("pressure", &[1000.0, 1001.0, 999.5])]); let old_m = DatasetManifest::from_bytes(&tgt).unwrap(); let new_m = DatasetManifest::from_bytes(&src).unwrap(); let diff = diff_manifests(&old_m, &new_m); assert_eq!(diff.of_kind(&PatchKind::Modified).count(), 1); let (patched, _) = apply_patches_to_bytes(&diff.patches, &src, &tgt).unwrap(); let result_m = DatasetManifest::from_bytes(&patched).unwrap(); // Verify the dataset in the result matches source, not old target. assert_eq!( result_m.get("/pressure").unwrap().blake3, new_m.get("/pressure").unwrap().blake3, ); assert_ne!( result_m.get("/pressure").unwrap().blake3, old_m.get("/pressure").unwrap().blake3, ); // Also verify the actual values round-trip correctly. let file = File::from_bytes(patched).unwrap(); let vals = file.dataset("pressure").unwrap().read_f64().unwrap(); assert_eq!(vals, vec![1013.25, 1014.0, 1012.5]); } // ───────────────────────────────────────────────────────────────────────────── // 3. patch_removes_dataset // ───────────────────────────────────────────────────────────────────────────── /// A dataset absent from source but present in target is removed after patching. #[test] fn patch_removes_dataset() { let src = h5_bytes(&[("voltage", &[3.3, 5.0])]); let tgt = h5_bytes(&[("voltage", &[3.3, 5.0]), ("current", &[0.1, 0.2, 0.3])]); let old_m = DatasetManifest::from_bytes(&tgt).unwrap(); let new_m = DatasetManifest::from_bytes(&src).unwrap(); let diff = diff_manifests(&old_m, &new_m); assert_eq!(diff.of_kind(&PatchKind::Removed).count(), 1); assert_eq!( diff.of_kind(&PatchKind::Removed).next().unwrap().path, "/current" ); let (patched, _) = apply_patches_to_bytes(&diff.patches, &src, &tgt).unwrap(); let result_m = DatasetManifest::from_bytes(&patched).unwrap(); assert!( result_m.get("/current").is_none(), "current should have been removed" ); assert!( result_m.get("/voltage").is_some(), "voltage should be retained" ); } // ───────────────────────────────────────────────────────────────────────────── // 4. patch_noop_when_identical // ───────────────────────────────────────────────────────────────────────────── /// When source and target are identical the diff is empty and stats are zeroed. #[test] fn patch_noop_when_identical() { let bytes = h5_bytes(&[("alpha", &[1.0, 2.0, 3.0]), ("beta", &[10.0, 20.0])]); let manifest = DatasetManifest::from_bytes(&bytes).unwrap(); let diff = diff_manifests(&manifest, &manifest); assert!(diff.is_empty(), "diff of identical files must be empty"); // apply_patches with an empty patch list returns default stats. let (out, stats) = apply_patches_to_bytes(&diff.patches, &bytes, &bytes).unwrap(); assert_eq!(out, bytes); assert_eq!(stats.datasets_added, 0); assert_eq!(stats.datasets_modified, 0); assert_eq!(stats.datasets_removed, 0); assert_eq!(stats.bytes_written, 0); } // ───────────────────────────────────────────────────────────────────────────── // 5. patch_multiple_changes // ───────────────────────────────────────────────────────────────────────────── /// A single diff/patch handles additions, modifications, and removals together. #[test] fn patch_multiple_changes() { // source: "kept" (same), "updated" (changed), "added" (new) let src = h5_bytes(&[ ("kept", &[1.0, 2.0]), ("updated", &[99.0, 100.0]), ("added", &[7.0, 8.0, 9.0]), ]); // target: "kept" (same), "updated" (old values), "removed" (extra) let tgt = h5_bytes(&[ ("kept", &[1.0, 2.0]), ("updated", &[0.0, 0.0]), ("removed", &[42.0]), ]); let old_m = DatasetManifest::from_bytes(&tgt).unwrap(); let new_m = DatasetManifest::from_bytes(&src).unwrap(); let diff = diff_manifests(&old_m, &new_m); assert_eq!(diff.of_kind(&PatchKind::Added).count(), 1); assert_eq!(diff.of_kind(&PatchKind::Modified).count(), 1); assert_eq!(diff.of_kind(&PatchKind::Removed).count(), 1); let (patched, _) = apply_patches_to_bytes(&diff.patches, &src, &tgt).unwrap(); let result_m = DatasetManifest::from_bytes(&patched).unwrap(); // "kept" should still exist and be unchanged. assert_eq!( result_m.get("/kept").unwrap().blake3, new_m.get("/kept").unwrap().blake3, ); // "updated" should now match source. assert_eq!( result_m.get("/updated").unwrap().blake3, new_m.get("/updated").unwrap().blake3, ); // "added" should now exist. assert!(result_m.get("/added").is_some()); // "removed" should be gone. assert!(result_m.get("/removed").is_none()); } // ───────────────────────────────────────────────────────────────────────────── // 6. stats_counts_correct // ───────────────────────────────────────────────────────────────────────────── /// `PatchStats` fields accurately reflect the number of each operation kind. #[test] fn stats_counts_correct() { // source: "a" (modified), "c" (unchanged), "d" (new) let src = h5_bytes(&[("a", &[9.0, 9.0]), ("c", &[3.0]), ("d", &[4.0, 5.0])]); // target: "a" (old values), "b" (to be removed), "c" (same as source) let tgt = h5_bytes(&[("a", &[1.0, 2.0]), ("b", &[0.0]), ("c", &[3.0])]); let old_m = DatasetManifest::from_bytes(&tgt).unwrap(); let new_m = DatasetManifest::from_bytes(&src).unwrap(); let diff = diff_manifests(&old_m, &new_m); let (_, stats) = apply_patches_to_bytes(&diff.patches, &src, &tgt).unwrap(); assert_eq!(stats.datasets_added, 1, "d was added"); assert_eq!(stats.datasets_modified, 1, "a was modified"); assert_eq!(stats.datasets_removed, 1, "b was removed"); assert_eq!(stats.datasets_unchanged, 1, "c was unchanged"); // bytes_written must be positive (added + modified + unchanged datasets). assert!(stats.bytes_written > 0); } // ───────────────────────────────────────────────────────────────────────────── // 7. apply_patches_file_path_api // ───────────────────────────────────────────────────────────────────────────── /// The file-path variant of `apply_patches` writes the result to disk and the /// manifest read back from disk matches the source manifest. #[test] fn apply_patches_file_path_api() { let src_bytes = h5_bytes(&[("chan_x", &[1.1, 2.2, 3.3]), ("chan_y", &[4.4, 5.5])]); let tgt_bytes = h5_bytes(&[("chan_x", &[0.0, 0.0, 0.0])]); // Write both files to disk using NamedTempFile. let (_src_guard, src_path) = write_temp_h5(&src_bytes); let (_tgt_guard, tgt_path) = write_temp_h5(&tgt_bytes); let old_m = DatasetManifest::from_path(&tgt_path).unwrap(); let new_m = DatasetManifest::from_path(&src_path).unwrap(); let diff = diff_manifests(&old_m, &new_m); assert!(!diff.is_empty()); // Use the file-path API (the one under test). let stats = apply_patches(&diff.patches, &src_path, &tgt_path).unwrap(); assert!(stats.datasets_added >= 1, "chan_y should have been added"); assert!( stats.datasets_modified >= 1, "chan_x should have been modified" ); // Read the patched file back from disk and verify it matches source. let result_m = DatasetManifest::from_path(&tgt_path).unwrap(); assert_eq!( result_m.get("/chan_x").unwrap().blake3, new_m.get("/chan_x").unwrap().blake3, "chan_x should match source after patch", ); assert!( result_m.get("/chan_y").is_some(), "chan_y should exist after patch", ); // Verify values by reading the dataset directly. let file = File::open(&tgt_path).unwrap(); let x_vals = file.dataset("chan_x").unwrap().read_f64().unwrap(); assert_eq!(x_vals, vec![1.1, 2.2, 3.3]); let y_vals = file.dataset("chan_y").unwrap().read_f64().unwrap(); assert_eq!(y_vals, vec![4.4, 5.5]); } // ───────────────────────────────────────────────────────────────────────────── // 8. manifest_blake3_changes_on_modification // ───────────────────────────────────────────────────────────────────────────── /// After patching, the BLAKE3 hash of a modified dataset in the result manifest /// matches the source manifest — i.e. the hash is faithfully transported. #[test] fn manifest_blake3_changes_on_modification() { let src = h5_bytes(&[("signal", &[0.1, 0.2, 0.3, 0.4, 0.5])]); let tgt = h5_bytes(&[("signal", &[1.0, 1.0, 1.0, 1.0, 1.0])]); let old_m = DatasetManifest::from_bytes(&tgt).unwrap(); let new_m = DatasetManifest::from_bytes(&src).unwrap(); // Sanity: hashes differ before patching. assert_ne!( old_m.get("/signal").unwrap().blake3, new_m.get("/signal").unwrap().blake3, "pre-patch: source and target hashes must differ", ); let diff = diff_manifests(&old_m, &new_m); assert_eq!(diff.of_kind(&PatchKind::Modified).count(), 1); let (patched, _) = apply_patches_to_bytes(&diff.patches, &src, &tgt).unwrap(); let result_m = DatasetManifest::from_bytes(&patched).unwrap(); // Post-patch: result hash must match source, not old target. assert_eq!( result_m.get("/signal").unwrap().blake3, new_m.get("/signal").unwrap().blake3, "post-patch: result hash must match source manifest", ); assert_ne!( result_m.get("/signal").unwrap().blake3, old_m.get("/signal").unwrap().blake3, "post-patch: result hash must not match old target manifest", ); }