1use crate::{
2 config,
3 data::{Commit, MeasurementData},
4 defaults,
5 measurement_retrieval::{self, summarize_measurements},
6 stats::{self, DispersionMethod, ReductionFunc, StatsWithUnit, VecAggregation},
7};
8use anyhow::{anyhow, bail, Result};
9use itertools::Itertools;
10use log::info;
11use sparklines::spark;
12use std::cmp::Ordering;
13use std::collections::HashSet;
14use std::iter;
15
16fn format_z_score_display(z_score: f64) -> String {
20 if z_score.is_finite() {
21 format!(" {:.2}", z_score)
22 } else {
23 String::new()
24 }
25}
26
27fn get_direction_arrow(head_mean: f64, tail_mean: f64) -> &'static str {
31 match head_mean.partial_cmp(&tail_mean) {
32 Some(Ordering::Greater) => "↑",
33 Some(Ordering::Less) => "↓",
34 Some(Ordering::Equal) | None => "→",
35 }
36}
37
38#[derive(Debug, PartialEq)]
39struct AuditResult {
40 message: String,
41 passed: bool,
42}
43
44#[derive(Debug, PartialEq)]
46pub(crate) struct ResolvedAuditParams {
47 pub min_count: u16,
48 pub summarize_by: ReductionFunc,
49 pub sigma: f64,
50 pub dispersion_method: DispersionMethod,
51}
52
53pub(crate) fn resolve_audit_params(
59 measurement: &str,
60 cli_min_count: Option<u16>,
61 cli_summarize_by: Option<ReductionFunc>,
62 cli_sigma: Option<f64>,
63 cli_dispersion_method: Option<DispersionMethod>,
64) -> ResolvedAuditParams {
65 let min_count = cli_min_count
66 .or_else(|| config::audit_min_measurements(measurement))
67 .unwrap_or(defaults::DEFAULT_MIN_MEASUREMENTS);
68
69 let summarize_by = cli_summarize_by
70 .or_else(|| config::audit_aggregate_by(measurement).map(ReductionFunc::from))
71 .unwrap_or(ReductionFunc::Min);
72
73 let sigma = cli_sigma
74 .or_else(|| config::audit_sigma(measurement))
75 .unwrap_or(defaults::DEFAULT_SIGMA);
76
77 let dispersion_method = cli_dispersion_method
78 .or_else(|| {
79 Some(DispersionMethod::from(config::audit_dispersion_method(
80 measurement,
81 )))
82 })
83 .unwrap_or(DispersionMethod::StandardDeviation);
84
85 ResolvedAuditParams {
86 min_count,
87 summarize_by,
88 sigma,
89 dispersion_method,
90 }
91}
92
93fn discover_matching_measurements(
96 commits: &[Result<Commit>],
97 filters: &[regex::Regex],
98 selectors: &[(String, String)],
99) -> Vec<String> {
100 let mut unique_measurements = HashSet::new();
101
102 for commit in commits.iter().flatten() {
103 for measurement in &commit.measurements {
104 if !crate::filter::matches_any_filter(&measurement.name, filters) {
106 continue;
107 }
108
109 if !measurement.key_values_is_superset_of(selectors) {
111 continue;
112 }
113
114 unique_measurements.insert(measurement.name.clone());
116 }
117 }
118
119 let mut result: Vec<String> = unique_measurements.into_iter().collect();
121 result.sort();
122 result
123}
124
125#[allow(clippy::too_many_arguments)]
126pub fn audit_multiple(
127 start_commit: &str,
128 max_count: usize,
129 min_count: Option<u16>,
130 selectors: &[(String, String)],
131 summarize_by: Option<ReductionFunc>,
132 sigma: Option<f64>,
133 dispersion_method: Option<DispersionMethod>,
134 combined_patterns: &[String],
135 _no_change_point_warning: bool, ) -> Result<()> {
137 if combined_patterns.is_empty() {
139 return Ok(());
140 }
141
142 let filters = crate::filter::compile_filters(combined_patterns)?;
145
146 let all_commits: Vec<Result<Commit>> =
149 measurement_retrieval::walk_commits_from(start_commit, max_count)?.collect();
150
151 let measurements_to_audit = discover_matching_measurements(&all_commits, &filters, selectors);
154
155 if measurements_to_audit.is_empty() {
157 if all_commits.is_empty() {
159 bail!("No commit at HEAD");
160 }
161 let has_any_measurements = all_commits.iter().any(|commit_result| {
163 if let Ok(commit) = commit_result {
164 !commit.measurements.is_empty()
165 } else {
166 false
167 }
168 });
169
170 if !has_any_measurements {
171 bail!("No measurement for HEAD");
173 }
174 bail!("No measurements found matching the provided patterns");
176 }
177
178 let mut failed = false;
179
180 for measurement in measurements_to_audit {
182 let params = resolve_audit_params(
183 &measurement,
184 min_count,
185 summarize_by,
186 sigma,
187 dispersion_method,
188 );
189
190 if (max_count as u16) < params.min_count {
192 eprintln!(
193 "⚠️ Warning: --max_count ({}) is less than min_measurements ({}) for measurement '{}'.",
194 max_count, params.min_count, measurement
195 );
196 eprintln!(
197 " This limits available historical data and may prevent achieving statistical significance."
198 );
199 }
200
201 let result = audit_with_commits(
202 &measurement,
203 &all_commits,
204 params.min_count,
205 selectors,
206 params.summarize_by,
207 params.sigma,
208 params.dispersion_method,
209 )?;
210
211 println!("{}", result.message);
220
221 if !result.passed {
222 failed = true;
223 }
224 }
225
226 if failed {
227 bail!("One or more measurements failed audit.");
228 }
229
230 Ok(())
231}
232
233fn audit_with_commits(
237 measurement: &str,
238 commits: &[Result<Commit>],
239 min_count: u16,
240 selectors: &[(String, String)],
241 summarize_by: ReductionFunc,
242 sigma: f64,
243 dispersion_method: DispersionMethod,
244) -> Result<AuditResult> {
245 let commits_iter = commits.iter().map(|r| match r {
248 Ok(commit) => Ok(Commit {
249 commit: commit.commit.clone(),
250 title: commit.title.clone(),
251 author: commit.author.clone(),
252 measurements: commit.measurements.clone(),
253 }),
254 Err(e) => Err(anyhow::anyhow!("{}", e)),
255 });
256
257 let filter_by =
259 |m: &MeasurementData| m.name == measurement && m.key_values_is_superset_of(selectors);
260
261 let mut aggregates = measurement_retrieval::take_while_same_epoch(summarize_measurements(
262 commits_iter,
263 &summarize_by,
264 &filter_by,
265 ));
266
267 let head = aggregates
268 .next()
269 .ok_or(anyhow!("No commit at HEAD"))
270 .and_then(|s| {
271 s.and_then(|cs| {
272 cs.measurement
273 .map(|m| m.val)
274 .ok_or(anyhow!("No measurement for HEAD."))
275 })
276 })?;
277
278 let tail: Vec<_> = aggregates
279 .filter_map_ok(|cs| cs.measurement.map(|m| m.val))
280 .try_collect()?;
281
282 audit_with_data(
283 measurement,
284 head,
285 tail,
286 min_count,
287 sigma,
288 dispersion_method,
289 summarize_by,
290 )
291}
292
293fn audit_with_data(
296 measurement: &str,
297 head: f64,
298 tail: Vec<f64>,
299 min_count: u16,
300 sigma: f64,
301 dispersion_method: DispersionMethod,
302 summarize_by: ReductionFunc,
303) -> Result<AuditResult> {
304 assert!(min_count >= 2, "min_count must be at least 2");
308
309 let unit = config::measurement_unit(measurement);
311 let unit_str = unit.as_deref();
312
313 let head_summary = stats::aggregate_measurements(iter::once(&head));
314 let tail_summary = stats::aggregate_measurements(tail.iter());
315
316 let all_measurements = tail.into_iter().chain(iter::once(head)).collect::<Vec<_>>();
318
319 let mut tail_measurements = all_measurements.clone();
320 tail_measurements.pop(); let tail_median = tail_measurements.median().unwrap_or_default();
322
323 let min_val = all_measurements
325 .iter()
326 .min_by(|a, b| a.partial_cmp(b).unwrap())
327 .unwrap();
328 let max_val = all_measurements
329 .iter()
330 .max_by(|a, b| a.partial_cmp(b).unwrap())
331 .unwrap();
332
333 let tail_median_is_zero = tail_median.abs() < f64::EPSILON;
337
338 let sparkline = if tail_median_is_zero {
339 format!(
341 " [{} – {}] {}",
342 min_val,
343 max_val,
344 spark(all_measurements.as_slice())
345 )
346 } else {
347 let relative_min = min_val / tail_median - 1.0;
350 let relative_max = max_val / tail_median - 1.0;
351
352 format!(
353 " [{:+.2}% – {:+.2}%] {}",
354 (relative_min * 100.0),
355 (relative_max * 100.0),
356 spark(all_measurements.as_slice())
357 )
358 };
359
360 let build_summary = || -> String {
363 let mut summary = String::new();
364
365 let total_measurements = all_measurements.len();
367
368 if total_measurements == 1 {
370 let head_display = StatsWithUnit {
371 stats: &head_summary,
372 unit: unit_str,
373 };
374 summary.push_str(&format!("Head: {}\n", head_display));
375 } else if total_measurements >= 2 {
376 let direction = get_direction_arrow(head_summary.mean, tail_summary.mean);
378 let z_score = head_summary.z_score_with_method(&tail_summary, dispersion_method);
379 let z_score_display = format_z_score_display(z_score);
380 let method_name = match dispersion_method {
381 DispersionMethod::StandardDeviation => "stddev",
382 DispersionMethod::MedianAbsoluteDeviation => "mad",
383 };
384
385 let head_display = StatsWithUnit {
386 stats: &head_summary,
387 unit: unit_str,
388 };
389 let tail_display = StatsWithUnit {
390 stats: &tail_summary,
391 unit: unit_str,
392 };
393
394 summary.push_str(&format!("Aggregation: {summarize_by}\n"));
395 summary.push_str(&format!(
396 "z-score ({method_name}): {direction}{}\n",
397 z_score_display
398 ));
399 summary.push_str(&format!("Head: {}\n", head_display));
400 summary.push_str(&format!("Tail: {}\n", tail_display));
401 summary.push_str(&sparkline);
402 }
403 summary
406 };
407
408 if tail_summary.len < min_count.into() {
410 let number_measurements = tail_summary.len;
411 let plural_s = if number_measurements == 1 { "" } else { "s" };
413 info!("Only {number_measurements} historical measurement{plural_s} found. Less than requested min_measurements of {min_count}. Skipping test.");
414
415 let mut skip_message = format!(
416 "⏭️ '{measurement}'\nOnly {number_measurements} historical measurement{plural_s} found. Less than requested min_measurements of {min_count}. Skipping test."
417 );
418
419 let summary = build_summary();
421 if !summary.is_empty() {
422 skip_message.push('\n');
423 skip_message.push_str(&summary);
424 }
425
426 return Ok(AuditResult {
427 message: skip_message,
428 passed: true,
429 });
430 }
431
432 let head_relative_deviation = (head / tail_median - 1.0).abs() * 100.0;
435
436 let head_absolute_deviation = (head - tail_median).abs();
438
439 let min_relative_deviation = config::audit_min_relative_deviation(measurement);
441 let min_absolute_deviation = config::audit_min_absolute_deviation(measurement);
442
443 let passed_due_to_relative_threshold = min_relative_deviation
445 .map(|threshold| head_relative_deviation < threshold)
446 .unwrap_or(false);
447
448 let passed_due_to_absolute_threshold = min_absolute_deviation
449 .map(|threshold| head_absolute_deviation < threshold)
450 .unwrap_or(false);
451
452 let passed_due_to_threshold =
453 passed_due_to_relative_threshold || passed_due_to_absolute_threshold;
454
455 let text_summary = build_summary();
456
457 let z_score_exceeds_sigma =
459 head_summary.is_significant(&tail_summary, sigma, dispersion_method);
460
461 let passed = !z_score_exceeds_sigma || passed_due_to_threshold;
463
464 let threshold_note = if z_score_exceeds_sigma {
467 let mut notes = Vec::new();
468 if passed_due_to_relative_threshold {
469 notes.push(format!(
470 "Note: Passed due to relative deviation ({:.1}%) being below threshold ({:.1}%)",
471 head_relative_deviation,
472 min_relative_deviation.unwrap()
473 ));
474 }
475 if passed_due_to_absolute_threshold {
476 notes.push(format!(
477 "Note: Passed due to absolute deviation ({:.1}) being below threshold ({:.1})",
478 head_absolute_deviation,
479 min_absolute_deviation.unwrap()
480 ));
481 }
482 if notes.is_empty() {
483 String::new()
484 } else {
485 format!("\n{}", notes.join("\n"))
486 }
487 } else {
488 String::new()
489 };
490
491 if !passed {
493 return Ok(AuditResult {
494 message: format!(
495 "❌ '{measurement}'\nHEAD differs significantly from tail measurements.\n{text_summary}{threshold_note}"
496 ),
497 passed: false,
498 });
499 }
500
501 Ok(AuditResult {
502 message: format!("✅ '{measurement}'\n{text_summary}{threshold_note}"),
503 passed: true,
504 })
505}
506
507#[cfg(test)]
508mod test {
509 use crate::test_helpers::with_isolated_test_setup;
510
511 use super::*;
512
513 #[test]
514 fn test_format_z_score_display() {
515 let test_cases = vec![
517 (2.5_f64, " 2.50"),
518 (0.0_f64, " 0.00"),
519 (-1.5_f64, " -1.50"),
520 (999.999_f64, " 1000.00"),
521 (0.001_f64, " 0.00"),
522 (f64::INFINITY, ""),
523 (f64::NEG_INFINITY, ""),
524 (f64::NAN, ""),
525 ];
526
527 for (z_score, expected) in test_cases {
528 let result = format_z_score_display(z_score);
529 assert_eq!(result, expected, "Failed for z_score: {}", z_score);
530 }
531 }
532
533 #[test]
534 fn test_direction_arrows() {
535 let test_cases = vec![
537 (5.0_f64, 3.0_f64, "↑"), (1.0_f64, 3.0_f64, "↓"), (3.0_f64, 3.0_f64, "→"), ];
541
542 for (head_mean, tail_mean, expected) in test_cases {
543 let result = get_direction_arrow(head_mean, tail_mean);
544 assert_eq!(
545 result, expected,
546 "Failed for head_mean: {}, tail_mean: {}",
547 head_mean, tail_mean
548 );
549 }
550 }
551
552 #[test]
553 fn test_audit_with_different_dispersion_methods() {
554 let head_value = 35.0;
558 let tail_values = [30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 100.0];
559
560 let head_summary = stats::aggregate_measurements(std::iter::once(&head_value));
561 let tail_summary = stats::aggregate_measurements(tail_values.iter());
562
563 let z_score_stddev =
565 head_summary.z_score_with_method(&tail_summary, DispersionMethod::StandardDeviation);
566 let z_score_mad = head_summary
567 .z_score_with_method(&tail_summary, DispersionMethod::MedianAbsoluteDeviation);
568
569 assert!(
572 z_score_stddev < z_score_mad,
573 "stddev z-score ({}) should be smaller than MAD z-score ({}) with outlier data",
574 z_score_stddev,
575 z_score_mad
576 );
577
578 assert!(z_score_stddev > 0.0);
580 assert!(z_score_mad > 0.0);
581 }
582
583 #[test]
584 fn test_dispersion_method_conversion() {
585 let cli_stddev = git_perf_cli_types::DispersionMethod::StandardDeviation;
589 let stats_stddev: DispersionMethod = cli_stddev.into();
590 assert_eq!(stats_stddev, DispersionMethod::StandardDeviation);
591
592 let cli_mad = git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation;
594 let stats_mad: DispersionMethod = cli_mad.into();
595 assert_eq!(stats_mad, DispersionMethod::MedianAbsoluteDeviation);
596 }
597
598 #[test]
599 fn test_audit_multiple_with_no_measurements() {
600 with_isolated_test_setup(|_git_dir, _home_path| {
604 let result = audit_multiple(
605 "HEAD",
606 100,
607 Some(1),
608 &[],
609 Some(ReductionFunc::Mean),
610 Some(2.0),
611 Some(DispersionMethod::StandardDeviation),
612 &[], false,
614 );
615
616 assert!(
618 result.is_ok(),
619 "audit_multiple should succeed with empty pattern list"
620 );
621 });
622 }
623
624 #[test]
627 fn test_min_count_boundary_condition() {
628 let result = audit_with_data(
631 "test_measurement",
632 15.0,
633 vec![10.0, 11.0, 12.0], 3, 2.0,
636 DispersionMethod::StandardDeviation,
637 ReductionFunc::Min,
638 );
639
640 assert!(result.is_ok());
641 let audit_result = result.unwrap();
642 assert!(!audit_result.message.contains("Skipping test"));
644
645 let result = audit_with_data(
647 "test_measurement",
648 15.0,
649 vec![10.0, 11.0], 3, 2.0,
652 DispersionMethod::StandardDeviation,
653 ReductionFunc::Min,
654 );
655
656 assert!(result.is_ok());
657 let audit_result = result.unwrap();
658 assert!(audit_result.message.contains("Skipping test"));
659 assert!(audit_result.passed); }
661
662 #[test]
663 fn test_pluralization_logic() {
664 let result = audit_with_data(
667 "test_measurement",
668 15.0,
669 vec![], 5, 2.0,
672 DispersionMethod::StandardDeviation,
673 ReductionFunc::Min,
674 );
675
676 assert!(result.is_ok());
677 let message = result.unwrap().message;
678 assert!(message.contains("0 historical measurements found")); assert!(!message.contains("0 historical measurement found")); let result = audit_with_data(
683 "test_measurement",
684 15.0,
685 vec![10.0], 5, 2.0,
688 DispersionMethod::StandardDeviation,
689 ReductionFunc::Min,
690 );
691
692 assert!(result.is_ok());
693 let message = result.unwrap().message;
694 assert!(message.contains("1 historical measurement found")); let result = audit_with_data(
698 "test_measurement",
699 15.0,
700 vec![10.0, 11.0], 5, 2.0,
703 DispersionMethod::StandardDeviation,
704 ReductionFunc::Min,
705 );
706
707 assert!(result.is_ok());
708 let message = result.unwrap().message;
709 assert!(message.contains("2 historical measurements found")); }
711
712 #[test]
713 fn test_skip_with_summaries() {
714 let result = audit_with_data(
720 "test_measurement",
721 15.0,
722 vec![], 5, 2.0,
725 DispersionMethod::StandardDeviation,
726 ReductionFunc::Min,
727 );
728
729 assert!(result.is_ok());
730 let message = result.unwrap().message;
731 assert!(message.contains("Skipping test"));
732 assert!(message.contains("Head:")); assert!(!message.contains("z-score")); assert!(!message.contains("Tail:")); assert!(!message.contains("[")); let result = audit_with_data(
739 "test_measurement",
740 15.0,
741 vec![10.0], 5, 2.0,
744 DispersionMethod::StandardDeviation,
745 ReductionFunc::Min,
746 );
747
748 assert!(result.is_ok());
749 let message = result.unwrap().message;
750 assert!(message.contains("Skipping test"));
751 assert!(message.contains("z-score (stddev):")); assert!(message.contains("Head:")); assert!(message.contains("Tail:")); assert!(message.contains("[")); let z_pos = message.find("z-score").unwrap();
757 let head_pos = message.find("Head:").unwrap();
758 let tail_pos = message.find("Tail:").unwrap();
759 let spark_pos = message.find("[").unwrap();
760 assert!(z_pos < head_pos, "z-score should come before Head");
761 assert!(head_pos < tail_pos, "Head should come before Tail");
762 assert!(tail_pos < spark_pos, "Tail should come before sparkline");
763
764 let result = audit_with_data(
766 "test_measurement",
767 15.0,
768 vec![10.0, 11.0], 5, 2.0,
771 DispersionMethod::StandardDeviation,
772 ReductionFunc::Min,
773 );
774
775 assert!(result.is_ok());
776 let message = result.unwrap().message;
777 assert!(message.contains("Skipping test"));
778 assert!(message.contains("z-score (stddev):")); assert!(message.contains("Head:")); assert!(message.contains("Tail:")); assert!(message.contains("[")); let z_pos = message.find("z-score").unwrap();
784 let head_pos = message.find("Head:").unwrap();
785 let tail_pos = message.find("Tail:").unwrap();
786 let spark_pos = message.find("[").unwrap();
787 assert!(z_pos < head_pos, "z-score should come before Head");
788 assert!(head_pos < tail_pos, "Head should come before Tail");
789 assert!(tail_pos < spark_pos, "Tail should come before sparkline");
790
791 let result = audit_with_data(
793 "test_measurement",
794 15.0,
795 vec![10.0, 11.0], 5, 2.0,
798 DispersionMethod::MedianAbsoluteDeviation,
799 ReductionFunc::Min,
800 );
801
802 assert!(result.is_ok());
803 let message = result.unwrap().message;
804 assert!(message.contains("z-score (mad):")); }
806
807 #[test]
808 fn test_relative_calculations_division_vs_modulo() {
809 let result = audit_with_data(
812 "test_measurement",
813 25.0, vec![10.0, 10.0, 10.0], 2,
816 10.0, DispersionMethod::StandardDeviation,
818 ReductionFunc::Min,
819 );
820
821 assert!(result.is_ok());
822 let audit_result = result.unwrap();
823
824 assert!(audit_result.message.contains("[+0.00% – +150.00%]"));
834
835 assert!(!audit_result.message.contains("[-100.00% – -50.00%]"));
837 assert!(!audit_result.message.contains("-100.00%"));
838 assert!(!audit_result.message.contains("-50.00%"));
839 }
840
841 #[test]
842 fn test_core_pass_fail_logic() {
843 let result = audit_with_data(
848 "test_measurement", 100.0, vec![10.0, 10.0, 10.0, 10.0, 10.0], 2,
852 0.5, DispersionMethod::StandardDeviation,
854 ReductionFunc::Min,
855 );
856
857 assert!(result.is_ok());
858 let audit_result = result.unwrap();
859 assert!(!audit_result.passed); assert!(audit_result.message.contains("❌"));
861
862 let result = audit_with_data(
864 "test_measurement",
865 10.2, vec![10.0, 10.1, 10.0, 10.1, 10.0], 2,
868 100.0, DispersionMethod::StandardDeviation,
870 ReductionFunc::Min,
871 );
872
873 assert!(result.is_ok());
874 let audit_result = result.unwrap();
875 assert!(audit_result.passed); assert!(audit_result.message.contains("✅"));
877 }
878
879 #[test]
880 fn test_final_result_logic() {
881 let result = audit_with_data(
886 "test_measurement",
887 1000.0, vec![10.0, 10.0, 10.0, 10.0, 10.0],
889 2,
890 0.1, DispersionMethod::StandardDeviation,
892 ReductionFunc::Min,
893 );
894
895 assert!(result.is_ok());
896 let audit_result = result.unwrap();
897 assert!(!audit_result.passed);
898 assert!(audit_result.message.contains("❌"));
899 assert!(audit_result.message.contains("differs significantly"));
900
901 let result = audit_with_data(
903 "test_measurement",
904 10.01, vec![10.0, 10.1, 10.0, 10.1, 10.0], 2,
907 100.0, DispersionMethod::StandardDeviation,
909 ReductionFunc::Min,
910 );
911
912 assert!(result.is_ok());
913 let audit_result = result.unwrap();
914 assert!(audit_result.passed);
915 assert!(audit_result.message.contains("✅"));
916 assert!(!audit_result.message.contains("differs significantly"));
917 }
918
919 #[test]
920 fn test_dispersion_methods_produce_different_results() {
921 let head = 35.0;
923 let tail = vec![30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 100.0];
924
925 let result_stddev = audit_with_data(
926 "test_measurement",
927 head,
928 tail.clone(),
929 2,
930 2.0,
931 DispersionMethod::StandardDeviation,
932 ReductionFunc::Min,
933 );
934
935 let result_mad = audit_with_data(
936 "test_measurement",
937 head,
938 tail,
939 2,
940 2.0,
941 DispersionMethod::MedianAbsoluteDeviation,
942 ReductionFunc::Min,
943 );
944
945 assert!(result_stddev.is_ok());
946 assert!(result_mad.is_ok());
947
948 let stddev_result = result_stddev.unwrap();
949 let mad_result = result_mad.unwrap();
950
951 assert!(stddev_result.message.contains("stddev"));
953 assert!(mad_result.message.contains("mad"));
954 }
955
956 #[test]
957 fn test_head_and_tail_have_units_and_auto_scaling() {
958 use crate::test_helpers::setup_test_env_with_config;
962
963 let config_content = r#"
964[measurement."build_time"]
965unit = "ms"
966"#;
967 let (_temp_dir, _dir_guard) = setup_test_env_with_config(config_content);
968
969 let head = 12_345.67; let tail = vec![10_000.0, 10_500.0, 11_000.0, 11_500.0, 12_000.0]; let result = audit_with_data(
974 "build_time",
975 head,
976 tail,
977 2,
978 10.0, DispersionMethod::StandardDeviation,
980 ReductionFunc::Min,
981 );
982
983 assert!(result.is_ok());
984 let audit_result = result.unwrap();
985 let message = &audit_result.message;
986
987 assert!(
989 message.contains("Head:"),
990 "Message should contain Head section"
991 );
992
993 assert!(
996 message.contains("12.3s") || message.contains("12.35s"),
997 "Head mean should be auto-scaled to seconds, got: {}",
998 message
999 );
1000
1001 let head_section: Vec<&str> = message
1002 .lines()
1003 .filter(|line| line.contains("Head:"))
1004 .collect();
1005
1006 assert!(
1007 !head_section.is_empty(),
1008 "Should find Head section in message"
1009 );
1010
1011 let head_line = head_section[0];
1012
1013 assert!(
1016 head_line.contains("μ:") && head_line.contains("σ:") && head_line.contains("MAD:"),
1017 "Head line should contain μ, σ, and MAD labels, got: {}",
1018 head_line
1019 );
1020
1021 assert!(
1023 message.contains("Tail:"),
1024 "Message should contain Tail section"
1025 );
1026
1027 let tail_section: Vec<&str> = message
1028 .lines()
1029 .filter(|line| line.contains("Tail:"))
1030 .collect();
1031
1032 assert!(
1033 !tail_section.is_empty(),
1034 "Should find Tail section in message"
1035 );
1036
1037 let tail_line = tail_section[0];
1038
1039 assert!(
1041 tail_line.contains("11s")
1042 || tail_line.contains("11.")
1043 || tail_line.contains("10.")
1044 || tail_line.contains("12."),
1045 "Tail should contain auto-scaled second values, got: {}",
1046 tail_line
1047 );
1048
1049 assert!(
1051 tail_line.contains("μ:")
1052 && tail_line.contains("σ:")
1053 && tail_line.contains("MAD:")
1054 && tail_line.contains("n:"),
1055 "Tail line should contain all stat labels, got: {}",
1056 tail_line
1057 );
1058 }
1059
1060 #[test]
1061 fn test_threshold_note_only_shown_when_audit_would_fail() {
1062 use crate::test_helpers::setup_test_env_with_config;
1065
1066 let config_content = r#"
1067[measurement."build_time"]
1068min_relative_deviation = 10.0
1069"#;
1070 let (_temp_dir, _dir_guard) = setup_test_env_with_config(config_content);
1071
1072 let result = audit_with_data(
1075 "build_time",
1076 10.1, vec![10.0, 10.1, 10.0, 10.1, 10.0], 2,
1079 100.0, DispersionMethod::StandardDeviation,
1081 ReductionFunc::Min,
1082 );
1083
1084 assert!(result.is_ok());
1085 let audit_result = result.unwrap();
1086 assert!(audit_result.passed);
1087 assert!(audit_result.message.contains("✅"));
1088 assert!(
1090 !audit_result
1091 .message
1092 .contains("Note: Passed due to relative deviation"),
1093 "Note should not appear when audit passes without needing threshold bypass"
1094 );
1095
1096 let result = audit_with_data(
1099 "build_time",
1100 1002.0, vec![1000.0, 1000.1, 1000.0, 1000.1, 1000.0], 2,
1103 0.5, DispersionMethod::StandardDeviation,
1105 ReductionFunc::Min,
1106 );
1107
1108 assert!(result.is_ok());
1109 let audit_result = result.unwrap();
1110 assert!(audit_result.passed);
1111 assert!(audit_result.message.contains("✅"));
1112 assert!(
1114 audit_result
1115 .message
1116 .contains("Note: Passed due to relative deviation"),
1117 "Note should appear when audit passes due to threshold bypass. Got: {}",
1118 audit_result.message
1119 );
1120
1121 let result = audit_with_data(
1124 "build_time",
1125 1200.0, vec![1000.0, 1000.1, 1000.0, 1000.1, 1000.0], 2,
1128 0.5, DispersionMethod::StandardDeviation,
1130 ReductionFunc::Min,
1131 );
1132
1133 assert!(result.is_ok());
1134 let audit_result = result.unwrap();
1135 assert!(!audit_result.passed);
1136 assert!(audit_result.message.contains("❌"));
1137 assert!(
1139 !audit_result
1140 .message
1141 .contains("Note: Passed due to relative deviation"),
1142 "Note should not appear when audit fails"
1143 );
1144 }
1145
1146 #[test]
1147 fn test_absolute_threshold_note_and_deviation_value() {
1148 use crate::test_helpers::setup_test_env_with_config;
1152
1153 let config_content = r#"
1154[measurement."build_time"]
1155min_absolute_deviation = 50.0
1156"#;
1157 let (_temp_dir, _dir_guard) = setup_test_env_with_config(config_content);
1158
1159 let result = audit_with_data(
1169 "build_time",
1170 100.0, vec![10.0, 10.0, 10.0, 10.0, 10.0], 2,
1173 0.5, DispersionMethod::StandardDeviation,
1175 ReductionFunc::Min,
1176 );
1177
1178 assert!(result.is_ok());
1179 let audit_result = result.unwrap();
1180 assert!(
1182 !audit_result.passed,
1183 "Should fail: absolute deviation 90 > threshold 50. Got: {}",
1184 audit_result.message
1185 );
1186
1187 let result = audit_with_data(
1192 "build_time",
1193 1050.0, vec![1000.0, 1000.0, 1000.0, 1000.0, 1000.0], 2,
1196 0.5, DispersionMethod::StandardDeviation,
1198 ReductionFunc::Min,
1199 );
1200
1201 assert!(result.is_ok());
1202 let audit_result = result.unwrap();
1203 assert!(
1205 !audit_result.passed,
1206 "Should fail: absolute deviation 50 == threshold 50 (not strictly less than). Got: {}",
1207 audit_result.message
1208 );
1209
1210 let result = audit_with_data(
1213 "build_time",
1214 1049.0, vec![1000.0, 1000.0, 1000.0, 1000.0, 1000.0], 2,
1217 0.5, DispersionMethod::StandardDeviation,
1219 ReductionFunc::Min,
1220 );
1221
1222 assert!(result.is_ok());
1223 let audit_result = result.unwrap();
1224 assert!(
1225 audit_result.passed,
1226 "Should pass: absolute deviation 49 < threshold 50. Got: {}",
1227 audit_result.message
1228 );
1229 assert!(
1230 audit_result
1231 .message
1232 .contains("Note: Passed due to absolute deviation"),
1233 "Note should appear when audit passes due to absolute threshold. Got: {}",
1234 audit_result.message
1235 );
1236 assert!(
1239 audit_result.message.contains("49.0"),
1240 "Note should show absolute deviation 49.0, not 1.0 (which would indicate / instead of -). Got: {}",
1241 audit_result.message
1242 );
1243 }
1244
1245 #[cfg(test)]
1247 mod integration {
1248 use super::*;
1249 use crate::config::{
1250 audit_aggregate_by, audit_dispersion_method, audit_min_measurements, audit_sigma,
1251 };
1252 use crate::test_helpers::setup_test_env_with_config;
1253
1254 #[test]
1255 fn test_different_dispersion_methods_per_measurement() {
1256 let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1257 r#"
1258[measurement]
1259dispersion_method = "stddev"
1260
1261[measurement."build_time"]
1262dispersion_method = "mad"
1263
1264[measurement."memory_usage"]
1265dispersion_method = "stddev"
1266"#,
1267 );
1268
1269 let build_time_method = audit_dispersion_method("build_time");
1271 let memory_usage_method = audit_dispersion_method("memory_usage");
1272 let other_method = audit_dispersion_method("other_metric");
1273
1274 assert_eq!(
1275 DispersionMethod::from(build_time_method),
1276 DispersionMethod::MedianAbsoluteDeviation,
1277 "build_time should use MAD"
1278 );
1279 assert_eq!(
1280 DispersionMethod::from(memory_usage_method),
1281 DispersionMethod::StandardDeviation,
1282 "memory_usage should use stddev"
1283 );
1284 assert_eq!(
1285 DispersionMethod::from(other_method),
1286 DispersionMethod::StandardDeviation,
1287 "other_metric should use default stddev"
1288 );
1289 }
1290
1291 #[test]
1292 fn test_different_min_measurements_per_measurement() {
1293 let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1294 r#"
1295[measurement]
1296min_measurements = 5
1297
1298[measurement."build_time"]
1299min_measurements = 10
1300
1301[measurement."memory_usage"]
1302min_measurements = 3
1303"#,
1304 );
1305
1306 assert_eq!(
1307 audit_min_measurements("build_time"),
1308 Some(10),
1309 "build_time should require 10 measurements"
1310 );
1311 assert_eq!(
1312 audit_min_measurements("memory_usage"),
1313 Some(3),
1314 "memory_usage should require 3 measurements"
1315 );
1316 assert_eq!(
1317 audit_min_measurements("other_metric"),
1318 Some(5),
1319 "other_metric should use default 5 measurements"
1320 );
1321 }
1322
1323 #[test]
1324 fn test_different_aggregate_by_per_measurement() {
1325 let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1326 r#"
1327[measurement]
1328aggregate_by = "median"
1329
1330[measurement."build_time"]
1331aggregate_by = "max"
1332
1333[measurement."memory_usage"]
1334aggregate_by = "mean"
1335"#,
1336 );
1337
1338 assert_eq!(
1339 audit_aggregate_by("build_time"),
1340 Some(git_perf_cli_types::ReductionFunc::Max),
1341 "build_time should use max"
1342 );
1343 assert_eq!(
1344 audit_aggregate_by("memory_usage"),
1345 Some(git_perf_cli_types::ReductionFunc::Mean),
1346 "memory_usage should use mean"
1347 );
1348 assert_eq!(
1349 audit_aggregate_by("other_metric"),
1350 Some(git_perf_cli_types::ReductionFunc::Median),
1351 "other_metric should use default median"
1352 );
1353 }
1354
1355 #[test]
1356 fn test_different_sigma_per_measurement() {
1357 let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1358 r#"
1359[measurement]
1360sigma = 3.0
1361
1362[measurement."build_time"]
1363sigma = 5.5
1364
1365[measurement."memory_usage"]
1366sigma = 2.0
1367"#,
1368 );
1369
1370 assert_eq!(
1371 audit_sigma("build_time"),
1372 Some(5.5),
1373 "build_time should use sigma 5.5"
1374 );
1375 assert_eq!(
1376 audit_sigma("memory_usage"),
1377 Some(2.0),
1378 "memory_usage should use sigma 2.0"
1379 );
1380 assert_eq!(
1381 audit_sigma("other_metric"),
1382 Some(3.0),
1383 "other_metric should use default sigma 3.0"
1384 );
1385 }
1386
1387 #[test]
1388 fn test_cli_overrides_config() {
1389 let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1390 r#"
1391[measurement."build_time"]
1392min_measurements = 10
1393aggregate_by = "max"
1394sigma = 5.5
1395dispersion_method = "mad"
1396"#,
1397 );
1398
1399 let params = super::resolve_audit_params(
1401 "build_time",
1402 Some(2), Some(ReductionFunc::Min), Some(3.0), Some(DispersionMethod::StandardDeviation), );
1407
1408 assert_eq!(
1409 params.min_count, 2,
1410 "CLI min_measurements should override config"
1411 );
1412 assert_eq!(
1413 params.summarize_by,
1414 ReductionFunc::Min,
1415 "CLI aggregate_by should override config"
1416 );
1417 assert_eq!(params.sigma, 3.0, "CLI sigma should override config");
1418 assert_eq!(
1419 params.dispersion_method,
1420 DispersionMethod::StandardDeviation,
1421 "CLI dispersion should override config"
1422 );
1423 }
1424
1425 #[test]
1426 fn test_config_overrides_defaults() {
1427 let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1428 r#"
1429[measurement."build_time"]
1430min_measurements = 10
1431aggregate_by = "max"
1432sigma = 5.5
1433dispersion_method = "mad"
1434"#,
1435 );
1436
1437 let params = super::resolve_audit_params(
1439 "build_time",
1440 None, None,
1442 None,
1443 None,
1444 );
1445
1446 assert_eq!(
1447 params.min_count, 10,
1448 "Config min_measurements should override default"
1449 );
1450 assert_eq!(
1451 params.summarize_by,
1452 ReductionFunc::Max,
1453 "Config aggregate_by should override default"
1454 );
1455 assert_eq!(params.sigma, 5.5, "Config sigma should override default");
1456 assert_eq!(
1457 params.dispersion_method,
1458 DispersionMethod::MedianAbsoluteDeviation,
1459 "Config dispersion should override default"
1460 );
1461 }
1462
1463 #[test]
1464 fn test_uses_defaults_when_no_config_or_cli() {
1465 let (_temp_dir, _dir_guard) = setup_test_env_with_config("");
1466
1467 let params = super::resolve_audit_params(
1469 "non_existent_measurement",
1470 None, None,
1472 None,
1473 None,
1474 );
1475
1476 assert_eq!(
1477 params.min_count, 2,
1478 "Should use default min_measurements of 2"
1479 );
1480 assert_eq!(
1481 params.summarize_by,
1482 ReductionFunc::Min,
1483 "Should use default aggregate_by of Min"
1484 );
1485 assert_eq!(params.sigma, 4.0, "Should use default sigma of 4.0");
1486 assert_eq!(
1487 params.dispersion_method,
1488 DispersionMethod::StandardDeviation,
1489 "Should use default dispersion of stddev"
1490 );
1491 }
1492 }
1493
1494 #[test]
1495 fn test_discover_matching_measurements() {
1496 use crate::data::{Commit, MeasurementData};
1497 use std::collections::HashMap;
1498
1499 let commits = vec![
1501 Ok(Commit {
1502 commit: "abc123".to_string(),
1503 title: "test: commit 1".to_string(),
1504 author: "Test Author".to_string(),
1505 measurements: vec![
1506 MeasurementData {
1507 epoch: 0,
1508 name: "bench_cpu".to_string(),
1509 timestamp: 1000.0,
1510 val: 100.0,
1511 key_values: {
1512 let mut map = HashMap::new();
1513 map.insert("os".to_string(), "linux".to_string());
1514 map
1515 },
1516 },
1517 MeasurementData {
1518 epoch: 0,
1519 name: "bench_memory".to_string(),
1520 timestamp: 1000.0,
1521 val: 200.0,
1522 key_values: {
1523 let mut map = HashMap::new();
1524 map.insert("os".to_string(), "linux".to_string());
1525 map
1526 },
1527 },
1528 MeasurementData {
1529 epoch: 0,
1530 name: "test_unit".to_string(),
1531 timestamp: 1000.0,
1532 val: 50.0,
1533 key_values: {
1534 let mut map = HashMap::new();
1535 map.insert("os".to_string(), "linux".to_string());
1536 map
1537 },
1538 },
1539 ],
1540 }),
1541 Ok(Commit {
1542 commit: "def456".to_string(),
1543 title: "test: commit 2".to_string(),
1544 author: "Test Author".to_string(),
1545 measurements: vec![
1546 MeasurementData {
1547 epoch: 0,
1548 name: "bench_cpu".to_string(),
1549 timestamp: 1000.0,
1550 val: 105.0,
1551 key_values: {
1552 let mut map = HashMap::new();
1553 map.insert("os".to_string(), "mac".to_string());
1554 map
1555 },
1556 },
1557 MeasurementData {
1558 epoch: 0,
1559 name: "other_metric".to_string(),
1560 timestamp: 1000.0,
1561 val: 75.0,
1562 key_values: {
1563 let mut map = HashMap::new();
1564 map.insert("os".to_string(), "linux".to_string());
1565 map
1566 },
1567 },
1568 ],
1569 }),
1570 ];
1571
1572 let patterns = vec!["bench_.*".to_string()];
1574 let filters = crate::filter::compile_filters(&patterns).unwrap();
1575 let selectors = vec![];
1576 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1577
1578 assert_eq!(discovered.len(), 2);
1579 assert!(discovered.contains(&"bench_cpu".to_string()));
1580 assert!(discovered.contains(&"bench_memory".to_string()));
1581 assert!(!discovered.contains(&"test_unit".to_string()));
1582 assert!(!discovered.contains(&"other_metric".to_string()));
1583
1584 let patterns = vec!["bench_cpu".to_string(), "test_.*".to_string()];
1586 let filters = crate::filter::compile_filters(&patterns).unwrap();
1587 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1588
1589 assert_eq!(discovered.len(), 2);
1590 assert!(discovered.contains(&"bench_cpu".to_string()));
1591 assert!(discovered.contains(&"test_unit".to_string()));
1592 assert!(!discovered.contains(&"bench_memory".to_string()));
1593
1594 let patterns = vec!["bench_.*".to_string()];
1596 let filters = crate::filter::compile_filters(&patterns).unwrap();
1597 let selectors = vec![("os".to_string(), "linux".to_string())];
1598 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1599
1600 assert_eq!(discovered.len(), 2);
1603 assert!(discovered.contains(&"bench_cpu".to_string()));
1604 assert!(discovered.contains(&"bench_memory".to_string()));
1605
1606 let patterns = vec!["nonexistent.*".to_string()];
1608 let filters = crate::filter::compile_filters(&patterns).unwrap();
1609 let selectors = vec![];
1610 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1611
1612 assert_eq!(discovered.len(), 0);
1613
1614 let filters = vec![];
1616 let selectors = vec![];
1617 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1618
1619 assert_eq!(discovered.len(), 4);
1623 assert!(discovered.contains(&"bench_cpu".to_string()));
1624 assert!(discovered.contains(&"bench_memory".to_string()));
1625 assert!(discovered.contains(&"test_unit".to_string()));
1626 assert!(discovered.contains(&"other_metric".to_string()));
1627
1628 let patterns = vec!["bench_.*".to_string()];
1630 let filters = crate::filter::compile_filters(&patterns).unwrap();
1631 let selectors = vec![("os".to_string(), "windows".to_string())];
1632 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1633
1634 assert_eq!(discovered.len(), 0);
1635
1636 let patterns = vec!["^bench_cpu$".to_string()];
1638 let filters = crate::filter::compile_filters(&patterns).unwrap();
1639 let selectors = vec![];
1640 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1641
1642 assert_eq!(discovered.len(), 1);
1643 assert!(discovered.contains(&"bench_cpu".to_string()));
1644
1645 let patterns = vec![".*".to_string()]; let filters = crate::filter::compile_filters(&patterns).unwrap();
1648 let selectors = vec![];
1649 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1650
1651 assert_eq!(discovered[0], "bench_cpu");
1653 assert_eq!(discovered[1], "bench_memory");
1654 assert_eq!(discovered[2], "other_metric");
1655 assert_eq!(discovered[3], "test_unit");
1656 }
1657
1658 #[test]
1659 fn test_audit_multiple_with_combined_patterns() {
1660 use crate::data::{Commit, MeasurementData};
1667 use std::collections::HashMap;
1668
1669 let commits = vec![Ok(Commit {
1671 commit: "abc123".to_string(),
1672 title: "test: commit".to_string(),
1673 author: "Test Author".to_string(),
1674 measurements: vec![
1675 MeasurementData {
1676 epoch: 0,
1677 name: "timer".to_string(),
1678 timestamp: 1000.0,
1679 val: 10.0,
1680 key_values: HashMap::new(),
1681 },
1682 MeasurementData {
1683 epoch: 0,
1684 name: "bench_cpu".to_string(),
1685 timestamp: 1000.0,
1686 val: 100.0,
1687 key_values: HashMap::new(),
1688 },
1689 MeasurementData {
1690 epoch: 0,
1691 name: "memory".to_string(),
1692 timestamp: 1000.0,
1693 val: 500.0,
1694 key_values: HashMap::new(),
1695 },
1696 ],
1697 })];
1698
1699 let measurements = vec!["timer".to_string()];
1702 let filter_patterns = vec!["bench_.*".to_string()];
1703 let combined =
1704 crate::filter::combine_measurements_and_filters(&measurements, &filter_patterns);
1705
1706 assert_eq!(combined.len(), 2);
1708 assert_eq!(combined[0], "^timer$");
1709 assert_eq!(combined[1], "bench_.*");
1710
1711 let filters = crate::filter::compile_filters(&combined).unwrap();
1713 let selectors = vec![];
1714 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1715
1716 assert_eq!(discovered.len(), 2);
1718 assert!(discovered.contains(&"timer".to_string()));
1719 assert!(discovered.contains(&"bench_cpu".to_string()));
1720 assert!(!discovered.contains(&"memory".to_string())); let measurements = vec!["timer".to_string(), "memory".to_string()];
1724 let filter_patterns = vec!["bench_.*".to_string(), "test_.*".to_string()];
1725 let combined =
1726 crate::filter::combine_measurements_and_filters(&measurements, &filter_patterns);
1727
1728 assert_eq!(combined.len(), 4);
1729
1730 let filters = crate::filter::compile_filters(&combined).unwrap();
1731 let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1732
1733 assert_eq!(discovered.len(), 3);
1735 assert!(discovered.contains(&"timer".to_string()));
1736 assert!(discovered.contains(&"memory".to_string()));
1737 assert!(discovered.contains(&"bench_cpu".to_string()));
1738 }
1739
1740 #[test]
1741 fn test_audit_with_empty_tail() {
1742 let result = audit_with_data(
1746 "test_measurement",
1747 10.0, vec![], 2, 2.0, DispersionMethod::StandardDeviation,
1752 ReductionFunc::Min,
1753 );
1754
1755 assert!(result.is_ok(), "Should not crash on empty tail");
1757 let audit_result = result.unwrap();
1758
1759 assert!(audit_result.passed);
1761 assert!(audit_result.message.contains("Skipping test"));
1762
1763 assert!(!audit_result.message.to_lowercase().contains("inf"));
1765 assert!(!audit_result.message.to_lowercase().contains("nan"));
1766 }
1767
1768 #[test]
1769 fn test_audit_with_all_zero_tail() {
1770 let result = audit_with_data(
1773 "test_measurement",
1774 5.0, vec![0.0, 0.0, 0.0], 2, 2.0, DispersionMethod::StandardDeviation,
1779 ReductionFunc::Min,
1780 );
1781
1782 assert!(result.is_ok(), "Should not crash when tail median is 0.0");
1784 let audit_result = result.unwrap();
1785
1786 assert!(!audit_result.message.to_lowercase().contains("inf"));
1788 assert!(!audit_result.message.to_lowercase().contains("nan"));
1789 }
1790
1791 #[test]
1792 fn test_tiered_baseline_approach() {
1793 let result = audit_with_data(
1799 "test_measurement",
1800 15.0, vec![10.0, 11.0, 12.0], 2,
1803 2.0,
1804 DispersionMethod::StandardDeviation,
1805 ReductionFunc::Min,
1806 );
1807
1808 assert!(result.is_ok());
1809 let audit_result = result.unwrap();
1810 assert!(audit_result.message.contains('%'));
1812 assert!(!audit_result.message.to_lowercase().contains("inf"));
1813
1814 let result = audit_with_data(
1816 "test_measurement",
1817 5.0, vec![0.0, 0.0, 0.0], 2,
1820 2.0,
1821 DispersionMethod::StandardDeviation,
1822 ReductionFunc::Min,
1823 );
1824
1825 assert!(result.is_ok());
1826 let audit_result = result.unwrap();
1827 assert!(!audit_result.message.to_lowercase().contains("inf"));
1830 assert!(!audit_result.message.to_lowercase().contains("nan"));
1831 assert!(audit_result.message.contains('–') || audit_result.message.contains('-'));
1833
1834 let result = audit_with_data(
1836 "test_measurement",
1837 0.0, vec![0.0, 0.0, 0.0], 2,
1840 2.0,
1841 DispersionMethod::StandardDeviation,
1842 ReductionFunc::Min,
1843 );
1844
1845 assert!(result.is_ok());
1846 let audit_result = result.unwrap();
1847 assert!(!audit_result.message.to_lowercase().contains("inf"));
1849 assert!(!audit_result.message.to_lowercase().contains("nan"));
1850 }
1851
1852 #[test]
1853 fn test_min_measurements_two_with_no_tail() {
1854 let result = audit_with_data(
1857 "test_measurement",
1858 15.0, vec![], 2, 2.0,
1862 DispersionMethod::StandardDeviation,
1863 ReductionFunc::Min,
1864 );
1865
1866 assert!(result.is_ok());
1867 let audit_result = result.unwrap();
1868
1869 assert!(audit_result.passed);
1871 assert!(audit_result.message.contains("Skipping test"));
1872 assert!(audit_result
1873 .message
1874 .contains("0 historical measurements found"));
1875 assert!(audit_result
1876 .message
1877 .contains("Less than requested min_measurements of 2"));
1878
1879 assert!(audit_result.message.contains("Head:"));
1881 assert!(!audit_result.message.contains("z-score"));
1882 assert!(!audit_result.message.contains("Tail:"));
1883 }
1884
1885 #[test]
1886 fn test_min_measurements_two_with_single_tail() {
1887 let result = audit_with_data(
1890 "test_measurement",
1891 15.0, vec![10.0], 2, 2.0,
1895 DispersionMethod::StandardDeviation,
1896 ReductionFunc::Min,
1897 );
1898
1899 assert!(result.is_ok());
1900 let audit_result = result.unwrap();
1901
1902 assert!(audit_result.passed);
1904 assert!(audit_result.message.contains("Skipping test"));
1905 assert!(audit_result
1906 .message
1907 .contains("1 historical measurement found"));
1908 assert!(audit_result
1909 .message
1910 .contains("Less than requested min_measurements of 2"));
1911
1912 assert!(audit_result.message.contains("Head:"));
1914 assert!(audit_result.message.contains("Tail:"));
1915 assert!(audit_result.message.contains("z-score"));
1916 assert!(audit_result.message.contains("["));
1917 }
1918
1919 #[test]
1920 fn test_aggregation_method_display_min() {
1921 let result = audit_with_data(
1923 "test_measurement",
1924 15.0,
1925 vec![10.0, 11.0, 12.0],
1926 2,
1927 2.0,
1928 DispersionMethod::StandardDeviation,
1929 ReductionFunc::Min,
1930 );
1931
1932 assert!(result.is_ok());
1933 let audit_result = result.unwrap();
1934 assert!(audit_result.message.contains("Aggregation: min"));
1935 }
1936
1937 #[test]
1938 fn test_aggregation_method_display_max() {
1939 let result = audit_with_data(
1941 "test_measurement",
1942 15.0,
1943 vec![10.0, 11.0, 12.0],
1944 2,
1945 2.0,
1946 DispersionMethod::StandardDeviation,
1947 ReductionFunc::Max,
1948 );
1949
1950 assert!(result.is_ok());
1951 let audit_result = result.unwrap();
1952 assert!(audit_result.message.contains("Aggregation: max"));
1953 }
1954
1955 #[test]
1956 fn test_aggregation_method_display_median() {
1957 let result = audit_with_data(
1959 "test_measurement",
1960 15.0,
1961 vec![10.0, 11.0, 12.0],
1962 2,
1963 2.0,
1964 DispersionMethod::StandardDeviation,
1965 ReductionFunc::Median,
1966 );
1967
1968 assert!(result.is_ok());
1969 let audit_result = result.unwrap();
1970 assert!(audit_result.message.contains("Aggregation: median"));
1971 }
1972
1973 #[test]
1974 fn test_aggregation_method_display_mean() {
1975 let result = audit_with_data(
1977 "test_measurement",
1978 15.0,
1979 vec![10.0, 11.0, 12.0],
1980 2,
1981 2.0,
1982 DispersionMethod::StandardDeviation,
1983 ReductionFunc::Mean,
1984 );
1985
1986 assert!(result.is_ok());
1987 let audit_result = result.unwrap();
1988 assert!(audit_result.message.contains("Aggregation: mean"));
1989 }
1990
1991 #[test]
1992 fn test_aggregation_method_not_shown_with_single_measurement() {
1993 let result = audit_with_data(
1995 "test_measurement",
1996 15.0,
1997 vec![], 2,
1999 2.0,
2000 DispersionMethod::StandardDeviation,
2001 ReductionFunc::Median,
2002 );
2003
2004 assert!(result.is_ok());
2005 let audit_result = result.unwrap();
2006 assert!(!audit_result.message.contains("Aggregation:"));
2008 assert!(audit_result.message.contains("Head:"));
2010 }
2011}