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]>
320 lines
15 KiB
Rust
320 lines
15 KiB
Rust
//! 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<u8> {
|
|
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",
|
|
);
|
|
}
|