diff --git a/src/backends/qemu/libvirt.rs b/src/backends/qemu/libvirt.rs index 4f054cf7..eb4da021 100644 --- a/src/backends/qemu/libvirt.rs +++ b/src/backends/qemu/libvirt.rs @@ -446,7 +446,7 @@ mod tests { /// Test that domain XML detection accepts virtio-scsi model /// variants and rejects others. #[test] - fn xml_matches_virtio_scsi_variants() { + fn test_libvirt_xml_matches_virtio_scsi_variants() { let a = r#""#; let b = r#""#; let c = r#""#; @@ -459,7 +459,7 @@ mod tests { /// Test that a QMP success envelope yields the return payload. #[test] - fn parse_envelope_extracts_return() { + fn test_libvirt_qmp_return_envelope_yields_the_payload_unchanged() { let body = r#"{"return":{"foo":42}}"#; let v = parse_qmp_envelope("query", body).unwrap(); assert_eq!(v.get(), r#"{"foo":42}"#); @@ -468,7 +468,7 @@ mod tests { /// Test that a QMP error envelope becomes a typed error with /// class/desc. #[test] - fn parse_envelope_surfaces_qmp_error() { + fn test_libvirt_qmp_error_envelope_preserves_class_and_desc() { let body = r#"{"error":{"class":"GenericError","desc":"boom"}}"#; let err = parse_qmp_envelope("query", body).unwrap_err(); match err { @@ -480,40 +480,25 @@ mod tests { } } - /// Test that blockstats counters are summed across disks. + /// Test that device counters are added, with flush and unmap folded into + /// other I/O. #[test] - fn parse_blockstats_sums_across_devices() { + fn test_libvirt_blockstats_sums_devices() { let body = r#"[ - {"stats": { - "rd_operations": 100, - "wr_operations": 200, - "flush_operations": 3, - "unmap_operations": 1, - "rd_bytes": 4096, - "wr_bytes": 8192 - }}, - {"stats": { - "rd_operations": 50, - "wr_operations": 25, - "flush_operations": 0, - "unmap_operations": 0, - "rd_bytes": 2048, - "wr_bytes": 1024 - }} + {"stats":{"rd_bytes":10,"wr_bytes":20,"rd_operations":1,"wr_operations":2,"flush_operations":3,"unmap_operations":4}}, + {"stats":{"rd_bytes":5,"wr_bytes":6,"rd_operations":7,"wr_operations":8,"flush_operations":9,"unmap_operations":10}} ]"#; - let p = parse_blockstats(body).unwrap().unwrap(); - assert_eq!(p.read_io_count, 150); - assert_eq!(p.write_io_count, 225); - assert_eq!(p.other_io_count, 4); - assert_eq!(p.read_bytes_total, 6144); - assert_eq!(p.write_bytes_total, 9216); - assert_eq!(p.total_io_count(), 150 + 225 + 4); + let perf = parse_blockstats(body).unwrap().unwrap(); + assert_eq!(perf.read_io_count, 8); + assert_eq!(perf.write_io_count, 10); + assert_eq!(perf.other_io_count, 26); + assert_eq!(perf.read_bytes_total, 15); + assert_eq!(perf.write_bytes_total, 26); } - /// Test that empty blockstats parses as unavailable/`None`. + /// Test that an empty blockstats array yields no sample. #[test] - fn parse_blockstats_empty_returns_unavailable() { - let p = parse_blockstats("[]").unwrap(); - assert!(p.is_none()); + fn test_libvirt_blockstats_empty_array_is_none() { + assert!(parse_blockstats("[]").unwrap().is_none()); } } diff --git a/src/config.rs b/src/config.rs index 3eb38337..150858fe 100644 --- a/src/config.rs +++ b/src/config.rs @@ -255,45 +255,29 @@ pub fn dump_default_config() -> String { mod tests { use super::*; - /// Test that default `Config` validates and uses the threshold - /// engine with a 10s poll. + /// Test that the default `Config` validates. #[test] - fn default_config_is_valid() { + fn test_config_default_is_valid() { let cfg = Config::default(); - assert_eq!(cfg.engine, "threshold"); - assert!((cfg.scale_poll_secs - 10.0).abs() < f64::EPSILON); validate_config(&cfg).unwrap(); } - /// Test that `validate_config` rejects an empty engine name. + /// Test that an empty engine does not validate. #[test] - fn validate_rejects_empty_engine() { + fn test_config_rejects_empty_engine() { let mut cfg = Config::default(); cfg.engine.clear(); - let err = validate_config(&cfg).unwrap_err().to_string(); - assert!(err.contains("engine must be non-empty")); - } - /// Test that `validate_config` rejects non-positive - /// `scale_poll_secs`. - #[test] - fn validate_rejects_non_positive_poll() { - let mut cfg = Config { - scale_poll_secs: 0.0, - ..Default::default() - }; - let err = validate_config(&cfg).unwrap_err().to_string(); - assert!(err.contains("scale_poll_secs must be > 0")); - - cfg.scale_poll_secs = f64::NAN; - let err = validate_config(&cfg).unwrap_err().to_string(); - assert!(err.contains("scale_poll_secs must be > 0")); + assert!( + validate_config(&cfg) + .is_err_and(|err| err.to_string().contains("engine must be non-empty")) + ); } /// Test that minimal valid JSON loads into `Config` with the /// expected engine, poll, and paths. #[test] - fn load_config_round_trips_required_fields() { + fn test_config_load_round_trips_every_field() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.json"); std::fs::write( @@ -320,29 +304,28 @@ mod tests { /// Test that loading JSON with an unknown field returns /// `ConfigError::SerdeJson`. #[test] - fn load_config_rejects_unknown_fields() { + fn test_config_load_rejects_unknown_fields() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.json"); std::fs::write( &path, r#"{ - "engine": "threshold", - "engine_config_dir": "/engines", - "backend_config_dir": "/backends", - "scale_poll_secs": 1.0, - "not_a_real_field": true - }"#, + "engine": "threshold", + "engine_config_dir": "/engines", + "backend_config_dir": "/backends", + "scale_poll_secs": 1.0, + "not_a_real_field": true +}"#, ) .unwrap(); let err = load_config_or_default::(Path::new(path.to_str().unwrap())).unwrap_err(); assert!(matches!(err, ConfigError::SerdeJson(_))); } - /// Test that pretty-printed default JSON includes engine, poll /// interval, and engine config path. #[test] - fn dump_default_config_contains_engine() { + fn test_dump_default_config_contains_engine() { let dumped = dump_default_config(); assert!(dumped.contains(r#""engine": "threshold""#)); assert!(dumped.contains(r#""scale_poll_secs": 10.0"#)); @@ -352,7 +335,7 @@ mod tests { /// Test that serde defaults for min/max threads, host CPU ceiling, /// and cooldown match `Config::default()`. #[test] - fn controller_policy_defaults_round_trip() { + fn test_controller_policy_defaults_round_trip() { let cfg: Config = serde_json::from_str(r#"{"engine": "foo", "engine_config_dir": "/path/to/engines", "backend_config_dir": "/path/to/backends", "scale_poll_secs": 42, "vm_state_path": "/path/to/vm-state.json"}"#).unwrap(); assert_eq!(cfg.min_thread_count, 1); assert_eq!(cfg.max_thread_count, 8); diff --git a/src/controller.rs b/src/controller.rs index 220765d0..d28cbcc0 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -1191,7 +1191,7 @@ mod tests { }; use async_trait::async_trait; - use test_log::test; + use rstest::rstest; use super::*; use crate::{ @@ -1233,7 +1233,7 @@ mod tests { /// count. #[rstest] #[tokio::test] - async fn actuation_enforces_vcpu_cap(#[from(mockdir)] _mockdir: MockDir) { + async fn test_controller_actuation_enforces_vcpu_cap(#[from(mockdir)] _mockdir: MockDir) { let cfg = Config { vm_state_path: Path::new("/ownership.json"), cooldown_secs: 0.0, @@ -1301,7 +1301,7 @@ mod tests { /// and blocks scale-up when host CPU is above the ceiling. #[rstest] #[tokio::test] - async fn actuation_enforces_controller_bounds_and_host_ceiling( + async fn test_controller_actuation_enforces_controller_bounds_and_host_ceiling( #[from(mockdir)] _mockdir: MockDir, ) { let cfg = Config { @@ -1378,7 +1378,9 @@ mod tests { #[cfg(feature = "threshold-engine")] #[rstest] #[tokio::test] - async fn revert_arms_cooldown_blocking_immediate_retry(#[from(mockdir)] _mockdir: MockDir) { + async fn test_controller_revert_arms_cooldown_blocking_immediate_retry( + #[from(mockdir)] _mockdir: MockDir, + ) { let cfg = Config { vm_state_path: Path::new("/ownership.json"), cooldown_secs: 30.0, @@ -1440,7 +1442,7 @@ mod tests { #[cfg(feature = "threshold-engine")] #[rstest] #[tokio::test] - async fn revert_bypasses_existing_cooldown(#[from(mockdir)] _mockdir: MockDir) { + async fn test_controller_revert_bypasses_existing_cooldown(#[from(mockdir)] _mockdir: MockDir) { let cfg = Config { vm_state_path: Path::new("/ownership.json"), cooldown_secs: 60.0, @@ -1648,11 +1650,13 @@ mod tests { )) } - /// Test that discovery sync adds new VMs, retains existing ones, - /// and removes disappeared ones. + /// Test that discovery sync adds new VMs, retains existing ones, and + /// removes disappeared ones. #[rstest] #[tokio::test] - async fn sync_instances_adds_retains_and_removes(#[from(mockdir)] _mockdir: MockDir) { + async fn test_constroller_sync_instances_adds_retains_and_removes( + #[from(mockdir)] _mockdir: MockDir, + ) { let added = Arc::new(AtomicUsize::new(0)); let removed = Arc::new(AtomicUsize::new(0)); let evaluated = Arc::new(AtomicUsize::new(0)); @@ -1693,11 +1697,11 @@ mod tests { assert_eq!(evaluated.load(Ordering::Relaxed), 0); } - /// Test that one tick refreshes state, runs the engine, and drops - /// instances that fail refresh. + /// Test that one tick refreshes state, runs the engine, and drops instances + /// that fail refresh. #[rstest] #[tokio::test] - async fn tick_refreshes_evaluates_and_drops_failed_instances( + async fn test_controller_tick_refreshes_evaluates_and_drops_failed_instances( #[from(mockdir)] _mockdir: MockDir, ) { let added = Arc::new(AtomicUsize::new(0)); diff --git a/src/engines/mod.rs b/src/engines/mod.rs index 15b8c4c9..3ee27d3d 100644 --- a/src/engines/mod.rs +++ b/src/engines/mod.rs @@ -289,7 +289,7 @@ mod tests { /// Test that `ScaleAction::target`/`Display` cover None/Up/Down /// variants. #[test] - fn scale_action_target_and_display() { + fn test_scale_action_target_and_display() { assert_eq!(ScaleAction::None.target(), None); assert_eq!(ScaleAction::Up(4).target(), Some(4)); assert_eq!(ScaleAction::Down(2).target(), Some(2)); @@ -304,7 +304,7 @@ mod tests { /// Test that `InstanceDecision::new` stores the VM id and /// `ScaleAction` unchanged. #[test] - fn instance_decision_new_preserves_fields() { + fn test_instance_decision_new_preserves_fields() { let decision = InstanceDecision::new("vm-1", ScaleAction::Up(5)); assert_eq!(decision.instance_id, "vm-1"); assert_eq!(decision.decision, ScaleAction::Up(5)); @@ -313,7 +313,7 @@ mod tests { /// Test that registry loads the threshold engine by name and errors /// on unknown engines. #[test] - fn load_registered_engine_finds_threshold_and_rejects_unknown() { + fn test_load_registered_engine_finds_threshold_and_rejects_unknown() { let dir = Path::new("/tmp/missing-engines"); let engine = load_registered_engine(&dir, "threshold").unwrap(); assert_eq!(engine.name(), "threshold"); @@ -388,7 +388,7 @@ mod tests { /// Test that default fleet `evaluate` skips ineligible VMs and only /// plans eligible ones. #[tokio::test] - async fn default_fleet_evaluation_skips_ineligible_instances() { + async fn test_default_fleet_evaluation_skips_ineligible_instances() { let engine = PerInstanceEngine { calls: AtomicUsize::new(0), }; diff --git a/src/engines/threshold.rs b/src/engines/threshold.rs index 8b0f9c7e..4a55aa2b 100644 --- a/src/engines/threshold.rs +++ b/src/engines/threshold.rs @@ -1000,7 +1000,7 @@ mod tests { /// Test that an IOPS-rate drop after scale-up triggers revert. #[rstest] #[tokio::test] - async fn scale_up_revert_fires_on_iops_rate_drop( + async fn test_scale_up_revert_fires_on_iops_rate_drop( engine: ThresholdEngine, #[future] instance: Arc, ) { @@ -1035,7 +1035,7 @@ mod tests { /// revert. #[rstest] #[tokio::test] - async fn scale_up_flat_rate_reverts( + async fn test_scale_up_flat_rate_reverts( engine: ThresholdEngine, #[future] instance: Arc, ) { @@ -1069,7 +1069,7 @@ mod tests { /// Test that a sufficient post-scale IOPS-rate gain keeps the scale-up. #[rstest] #[tokio::test] - async fn scale_up_rate_gain_keeps_scale( + async fn test_scale_up_rate_gain_keeps_scale( engine: ThresholdEngine, #[future] instance: Arc, ) { @@ -1233,7 +1233,7 @@ mod tests { /// (demand left; IOPS collapse is expected). #[rstest] #[tokio::test] - async fn scale_down_keeps_shrink_when_util_stays_low(#[future] instance: Arc) { + async fn test_scale_down_keeps_shrink_when_util_stays_low(#[future] instance: Arc) { let instance = instance.await; let engine = ThresholdEngine::new(ThresholdConfig { scale_up_threshold: 0.8, @@ -1271,7 +1271,7 @@ mod tests { /// remains) but IOPS fell beyond the tolerated drop. #[rstest] #[tokio::test] - async fn scale_down_reverts_when_util_stays_high_and_iops_drop( + async fn test_scale_down_reverts_when_util_stays_high_and_iops_drop( #[future] instance: Arc, ) { let instance = instance.await; @@ -1489,7 +1489,7 @@ mod tests { /// Test that percent fields serde as human percents on the wire and /// fractions in memory. #[test] - fn percent_wire_format_round_trips() { + fn test_percent_wire_format_round_trips() { let config: ThresholdConfig = serde_json::from_str(r#"{"scale_up_min_gain_percent":10}"#).unwrap(); assert!((config.scale_up_min_gain - 0.10).abs() < f64::EPSILON); @@ -1518,7 +1518,7 @@ mod tests { /// scale-ups are suppressed. #[rstest] #[test(tokio::test)] - async fn no_scale_up_during_pending_validation( + async fn test_no_scale_up_during_pending_validation( engine: ThresholdEngine, #[future] instance: Arc, ) { @@ -1575,7 +1575,7 @@ mod tests { /// performance doesn't increase much. #[rstest] #[test(tokio::test)] - async fn pending_validation_reverts_regressive_scale( + async fn test_pending_validation_reverts_regressive_scale( _engine: ThresholdEngine, #[future] instance: Arc, ) { @@ -1627,7 +1627,7 @@ mod tests { /// Test that scale-up decision logs current/min IOPS and validation delay. #[rstest] #[tokio::test] - async fn scale_up_log_includes_iops_targets( + async fn test_scale_up_log_includes_iops_targets( engine: ThresholdEngine, #[future] instance: Arc, ) { @@ -1663,7 +1663,7 @@ mod tests { /// Test that a missing `threshold.json` still builds an engine with /// defaults. #[test] - fn missing_config_file_uses_defaults() { + fn test_missing_config_file_uses_defaults() { let dir = tempfile::tempdir().unwrap(); let engine = ThresholdEngine::from_config_dir(&Path::new(dir.path().to_str().unwrap())).unwrap(); @@ -1675,7 +1675,7 @@ mod tests { /// Hold/`None`. #[rstest] #[tokio::test] - async fn evaluate_holds_when_util_below_thresholds(#[future] instance: Arc) { + async fn test_evaluate_holds_when_util_below_thresholds(#[future] instance: Arc) { let instance = instance.await; let engine = ThresholdEngine::new(ThresholdConfig::default()); let action = engine diff --git a/src/instance.rs b/src/instance.rs index 5473cb29..b7782ede 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -805,7 +805,7 @@ mod tests { /// Test that a successful client snapshot updates /// alive/thread_count state. #[tokio::test] - async fn refresh_state_applies_successful_snapshot() { + async fn test_instance_refresh_state_applies_successful_snapshot() { let instance = Instance::new( "vm-ok".to_string(), Path::new(""), @@ -821,7 +821,7 @@ mod tests { /// Test that client errors mark the instance dead and close the /// client. #[tokio::test] - async fn refresh_state_marks_failed_instances_dead_and_closes() { + async fn test_instance_refresh_marks_failed_instances_dead_and_closes() { let closed = Arc::new(AtomicBool::new(false)); let instance = Instance::new( "vm-bad".to_string(), @@ -839,7 +839,7 @@ mod tests { /// Test that `Display` for an instance prints the bare VM id. #[test] - fn display_trims_instance_id() { + fn test_display_trims_instance_id() { let instance = Instance::new( " vm-1 ".to_string(), Path::new(""), @@ -871,28 +871,36 @@ mod tests { } } - /// Test that two workers sharing a name still get independent - /// CPU-delta util samples. + /// Test that utilisation is the tick delta over wall ticks, in + /// current-sample order. #[test] - fn duplicate_worker_names_keep_independent_deltas() { - let previous = vec![task(10, "worker", 100), task(11, "worker", 200)]; - let current = vec![task(11, "worker", 400), task(10, "worker", 200)]; - - let util = compute_per_worker_util(&previous, ¤t, 500.0); - assert_eq!(util.len(), 2); - assert!((util[0].1 - 0.4).abs() < f64::EPSILON); - assert!((util[1].1 - 0.2).abs() < f64::EPSILON); + fn test_util_is_tick_delta_over_wall_ticks() { + let previous = vec![task(10, "a", 100), task(11, "b", 0)]; + let current = vec![task(11, "b", 25), task(10, "a", 150)]; + assert_eq!( + compute_per_worker_util(&previous, ¤t, 100.0), + vec![("b".to_string(), 0.25), ("a".to_string(), 0.5)] + ); } - /// Test that a newly appeared worker does not spike util from - /// lifetime counters. + /// Test that a renamed task, an unknown tid, and a backwards + /// counter are dropped. #[test] - fn new_worker_starts_without_a_lifetime_spike() { - let previous = vec![task(10, "worker0", 100)]; - let current = vec![task(10, "worker0", 200), task(11, "worker1", 900_000)]; - - let util = compute_per_worker_util(&previous, ¤t, 500.0); - assert_eq!(util.len(), 1); - assert!((util[0].1 - 0.2).abs() < f64::EPSILON); + fn test_util_drops_unmatched_tasks() { + let previous = vec![ + task(1, "old", 100), + task(2, "kept", 50), + task(3, "back", 200), + ]; + let current = vec![ + task(1, "new", 150), + task(9, "unknown", 10), + task(3, "back", 100), + task(2, "kept", 75), + ]; + assert_eq!( + compute_per_worker_util(&previous, ¤t, 100.0), + vec![("kept".to_string(), 0.25)] + ); } } diff --git a/src/rolling.rs b/src/rolling.rs index 915281f4..f9efb7b7 100644 --- a/src/rolling.rs +++ b/src/rolling.rs @@ -238,7 +238,7 @@ mod tests { /// Test that the first sample records a baseline and yields no /// rates yet. #[test] - fn first_sample_only_establishes_baseline() { + fn test_rolling_first_sample_only_establishes_baseline() { let mut metrics = RollingMetrics::new(); let t0 = Instant::now(); metrics.push_from_procfs_delta(t0, 10, 20); @@ -247,8 +247,10 @@ mod tests { /// Test that rolling IOPS/CPU-per-IO rates use real elapsed time /// between samples. + // TODO determine if it's ok to have iops_over with a longer Duration + // than wall_ns. #[test] - fn rates_use_real_elapsed_time() { + fn test_rolling_rates_use_real_elapsed_time() { let mut metrics = RollingMetrics::new(); let t0 = Instant::now(); metrics.push_from_procfs_delta(t0, 10, 20); @@ -263,7 +265,7 @@ mod tests { /// Test that a counter reset drops prior rates and starts a new /// baseline. #[test] - fn counter_reset_replaces_baseline() { + fn test_rolling_counter_reset_replaces_baseline() { let mut metrics = RollingMetrics::new(); let t0 = Instant::now(); metrics.push_from_procfs_delta(t0, 100, 100); diff --git a/src/state.rs b/src/state.rs index 29dfcabd..74c16d19 100644 --- a/src/state.rs +++ b/src/state.rs @@ -134,7 +134,7 @@ mod tests { /// Test that managed/unmanaged sets save and reload from a single /// JSON file. #[test] - fn ownership_round_trips_in_one_file() { + fn test_state_disjoint_ownership_round_trips_in_one_file() { let dir = tempfile::tempdir().unwrap(); let store = VmStateStore::new(Path::new(&dir.path().join("ownership.json"))); let mut expected = VmOwnership::default(); @@ -149,7 +149,7 @@ mod tests { /// Test that a missing ownership file loads as empty/default state. #[test] - fn missing_state_uses_defaults() { + fn test_state_uses_defaults() { let dir = tempfile::tempdir().unwrap(); assert_eq!( VmStateStore::new(Path::new(&dir.path().join("missing.json"))) @@ -162,7 +162,7 @@ mod tests { /// Test that a VM listed as both managed and unmanaged is rejected /// on load. #[test] - fn overlapping_classifications_are_rejected() { + fn test_state_overlapping_ownership_is_rejected() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("ownership.json"); std::fs::write( diff --git a/src/util.rs b/src/util.rs index 4994ed74..8d4f6983 100644 --- a/src/util.rs +++ b/src/util.rs @@ -173,7 +173,7 @@ mod tests { /// Test that relative `Path` values are stored unchanged. #[test] - fn relative_paths_are_unchanged() { + fn test_relative_paths_are_unchanged() { let path = Path::new("relative/sock"); assert_eq!(path.as_os_str(), OsStr::new("relative/sock")); } @@ -181,14 +181,14 @@ mod tests { /// Test that absolute `Path` values stay absolute under the default /// root. #[test] - fn absolute_paths_keep_absolute_form_under_default_root() { + fn test_absolute_paths_keep_absolute_form_under_default_root() { let path = Path::new("/var/run/example.sock"); assert_eq!(path.as_os_str(), OsStr::new("/var/run/example.sock")); } /// Test that `FromStr` builds a `Path` from a string. #[test] - fn from_str_builds_path() { + fn test_from_str_builds_path() { let path: Path = "tmp/example".parse().unwrap(); assert_eq!(path.as_os_str(), OsStr::new("tmp/example")); }