Skip to main content

git_perf/
audit.rs

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
16/// Formats a z-score for display in audit output.
17/// Only finite z-scores are displayed with numeric values.
18/// Infinite and NaN values return an empty string.
19fn 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
27/// Determines the direction arrow based on comparison of head and tail means.
28/// Returns ↑ for greater, ↓ for less, → for equal.
29/// Returns → for NaN values to avoid panicking.
30fn 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/// Resolved audit parameters for a specific measurement.
45#[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
53/// Resolves audit parameters for a specific measurement with proper precedence:
54/// CLI option -> measurement-specific config -> global config -> built-in default
55///
56/// Note: When CLI provides min_count, the caller (audit_multiple) uses the same
57/// value for all measurements. When CLI is None, this function reads per-measurement config.
58pub(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
93/// Discovers all unique measurement names from commits that match the filters and selectors.
94/// This is used to efficiently find which measurements to audit when filters are provided.
95fn 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            // Check if measurement name matches any filter
105            if !crate::filter::matches_any_filter(&measurement.name, filters) {
106                continue;
107            }
108
109            // Check if measurement matches selectors
110            if !measurement.key_values_is_superset_of(selectors) {
111                continue;
112            }
113
114            // This measurement matches - add to set
115            unique_measurements.insert(measurement.name.clone());
116        }
117    }
118
119    // Convert to sorted vector for deterministic ordering
120    let mut result: Vec<String> = unique_measurements.into_iter().collect();
121    result.sort();
122    result
123}
124
125/// Compute group value combinations for splitting measurements by metadata keys.
126///
127/// Returns a vector of group values where each inner vector contains the values
128/// for the split keys. If no splits are specified, returns a single empty group.
129///
130/// # Errors
131/// Returns error if separate_by is non-empty but no measurements have all required keys
132fn compute_group_values(
133    commits: &[Result<Commit>],
134    measurement_name: &str,
135    selectors: &[(String, String)],
136    separate_by: &[String],
137) -> Result<Vec<Vec<String>>> {
138    if separate_by.is_empty() {
139        return Ok(vec![vec![]]);
140    }
141
142    let mut unique_groups = HashSet::new();
143
144    for commit in commits.iter().flatten() {
145        for measurement in &commit.measurements {
146            // Only consider measurements that match the name
147            if measurement.name != measurement_name {
148                continue;
149            }
150
151            // Check if measurement matches selectors
152            if !measurement.key_values_is_superset_of(selectors) {
153                continue;
154            }
155
156            // Extract values for separate_by keys
157            let values: Vec<String> = separate_by
158                .iter()
159                .filter_map(|key| measurement.key_values.get(key).cloned())
160                .collect();
161
162            // Only include if all keys are present
163            if values.len() == separate_by.len() {
164                unique_groups.insert(values);
165            }
166        }
167    }
168
169    if unique_groups.is_empty() {
170        bail!(
171            "Measurement '{}': Invalid separator supplied, no measurements have all required keys: {:?}",
172            measurement_name,
173            separate_by
174        );
175    }
176
177    // Convert to sorted vector for deterministic ordering
178    let mut result: Vec<Vec<String>> = unique_groups.into_iter().collect();
179    result.sort();
180    Ok(result)
181}
182
183/// Formats a group label from separate_by keys and values.
184/// Example: ["os", "arch"] with ["ubuntu", "x64"] -> "os=ubuntu/arch=x64"
185fn format_group_label(separate_by: &[String], group_values: &[String]) -> String {
186    separate_by
187        .iter()
188        .zip(group_values.iter())
189        .map(|(key, value)| format!("{}={}", key, value))
190        .collect::<Vec<_>>()
191        .join("/")
192}
193
194#[allow(clippy::too_many_arguments)]
195pub fn audit_multiple(
196    start_commit: &str,
197    max_count: usize,
198    since: Option<&str>,
199    until: Option<&str>,
200    min_count: Option<u16>,
201    selectors: &[(String, String)],
202    summarize_by: Option<ReductionFunc>,
203    sigma: Option<f64>,
204    dispersion_method: Option<DispersionMethod>,
205    combined_patterns: &[String],
206    separate_by: &[String],
207    _no_change_point_warning: bool, // TODO: Implement change point warning in Phase 2
208) -> Result<()> {
209    // Early return if patterns are empty - nothing to audit
210    if combined_patterns.is_empty() {
211        return Ok(());
212    }
213
214    // Validate that separate_by keys don't overlap with selectors (would produce contradictory filters)
215    let selector_keys: std::collections::HashSet<&str> =
216        selectors.iter().map(|(k, _)| k.as_str()).collect();
217    for key in separate_by {
218        if selector_keys.contains(key.as_str()) {
219            bail!(
220                "separate-by key '{}' already present in selectors; remove it from --selectors or --separate-by",
221                key
222            );
223        }
224    }
225
226    // Compile combined regex patterns (measurements as exact matches + filter patterns)
227    // early to fail fast on invalid patterns
228    let filters = crate::filter::compile_filters(combined_patterns)?;
229
230    // Phase 1: Walk commits ONCE (optimization: scan commits only once)
231    // Collect into Vec so we can reuse the data for multiple measurements
232    let all_commits: Vec<Result<Commit>> =
233        measurement_retrieval::walk_commits_from(start_commit, max_count, since, until)?.collect();
234
235    // Phase 2: Discover all measurements that match the combined patterns from the commit data
236    // The combined_patterns already include both measurements (as exact regex) and filters (OR behavior)
237    let measurements_to_audit = discover_matching_measurements(&all_commits, &filters, selectors);
238
239    // If no measurements were discovered, provide appropriate error message
240    if measurements_to_audit.is_empty() {
241        // Check if we have any commits at all
242        if all_commits.is_empty() {
243            bail!("No commit at HEAD");
244        }
245        // Check if any commits have any measurements at all
246        let has_any_measurements = all_commits.iter().any(|commit_result| {
247            if let Ok(commit) = commit_result {
248                !commit.measurements.is_empty()
249            } else {
250                false
251            }
252        });
253
254        if !has_any_measurements {
255            // No measurements exist in any commits - specific error for this case
256            bail!("No measurement for HEAD");
257        }
258        // Measurements exist but don't match the patterns
259        bail!("No measurements found matching the provided patterns");
260    }
261
262    let mut failed = false;
263    let mut total_groups = 0;
264    let mut passed_groups = 0;
265
266    // Phase 3: For each measurement, audit using the pre-loaded commit data
267    for measurement in measurements_to_audit {
268        let params = resolve_audit_params(
269            &measurement,
270            min_count,
271            summarize_by,
272            sigma,
273            dispersion_method,
274        );
275
276        // Warn if max_count limits historical data below min_measurements requirement
277        if (max_count as u16) < params.min_count {
278            eprintln!(
279                "⚠️  Warning: --max_count ({}) is less than min_measurements ({}) for measurement '{}'.",
280                max_count, params.min_count, measurement
281            );
282            eprintln!(
283                "   This limits available historical data and may prevent achieving statistical significance."
284            );
285        }
286
287        // Compute groups for this measurement
288        let groups = compute_group_values(&all_commits, &measurement, selectors, separate_by)?;
289
290        // Audit each group independently
291        for group_values in &groups {
292            // Build combined selectors (original selectors + group selectors)
293            let mut group_selectors = selectors.to_vec();
294            for (key, value) in separate_by.iter().zip(group_values.iter()) {
295                group_selectors.push((key.clone(), value.clone()));
296            }
297
298            // Format group label for display
299            let group_label = if separate_by.is_empty() {
300                String::new()
301            } else {
302                format!(" ({})", format_group_label(separate_by, group_values))
303            };
304
305            let result = audit_with_commits(
306                &measurement,
307                &all_commits,
308                params.min_count,
309                &group_selectors,
310                params.summarize_by,
311                params.sigma,
312                params.dispersion_method,
313            )?;
314
315            // TODO(Phase 2): Add change point detection warning here
316            // If !_no_change_point_warning, detect change points in current epoch
317            // and warn if any exist, as they make z-score comparisons unreliable:
318            //   ⚠️  WARNING: Change point detected in current epoch at commit a1b2c3d (+23.5%)
319            //       Historical z-score comparison may be unreliable due to regime shift.
320            //       Consider bumping epoch or investigating the change.
321            // See docs/plans/change-point-detection.md for implementation details.
322
323            // Print the result with group label
324            if !separate_by.is_empty() {
325                // Print header for the group
326                println!("Auditing measurement \"{}\"{}:", measurement, group_label);
327                // Indent the result message
328                for line in result.message.lines() {
329                    println!("  {}", line);
330                }
331                println!(); // Add blank line between groups
332            } else {
333                println!("{}", result.message);
334            }
335
336            if !separate_by.is_empty() {
337                total_groups += 1;
338                if result.passed {
339                    passed_groups += 1;
340                }
341            }
342            if !result.passed {
343                failed = true;
344            }
345        }
346    }
347
348    // Print summary if grouping is active
349    if !separate_by.is_empty() {
350        if failed {
351            println!(
352                "Overall: FAILED ({}/{} groups passed)",
353                passed_groups, total_groups
354            );
355        } else {
356            println!(
357                "Overall: PASSED ({}/{} groups passed)",
358                passed_groups, total_groups
359            );
360        }
361    }
362
363    if failed {
364        bail!("One or more measurements failed audit.");
365    }
366
367    Ok(())
368}
369
370/// Audits a measurement using pre-loaded commit data.
371/// This is more efficient than the old `audit` function when auditing multiple measurements,
372/// as it reuses the same commit data instead of walking commits multiple times.
373fn audit_with_commits(
374    measurement: &str,
375    commits: &[Result<Commit>],
376    min_count: u16,
377    selectors: &[(String, String)],
378    summarize_by: ReductionFunc,
379    sigma: f64,
380    dispersion_method: DispersionMethod,
381) -> Result<AuditResult> {
382    // Convert Vec<Result<Commit>> into an iterator of Result<Commit> by cloning references
383    // This is necessary because summarize_measurements expects an iterator of Result<Commit>
384    let commits_iter = commits.iter().map(|r| match r {
385        Ok(commit) => Ok(Commit {
386            commit: commit.commit.clone(),
387            title: commit.title.clone(),
388            author: commit.author.clone(),
389            measurements: commit.measurements.clone(),
390        }),
391        Err(e) => Err(anyhow::anyhow!("{}", e)),
392    });
393
394    // Filter to only this specific measurement with matching selectors
395    let filter_by =
396        |m: &MeasurementData| m.name == measurement && m.key_values_is_superset_of(selectors);
397
398    let mut aggregates = measurement_retrieval::take_while_same_epoch(summarize_measurements(
399        commits_iter,
400        &summarize_by,
401        &filter_by,
402    ));
403
404    let head = aggregates
405        .next()
406        .ok_or(anyhow!("No commit at HEAD"))
407        .and_then(|s| {
408            s.and_then(|cs| {
409                cs.measurement
410                    .map(|m| m.val)
411                    .ok_or(anyhow!("No measurement for HEAD."))
412            })
413        })?;
414
415    let tail: Vec<_> = aggregates
416        .filter_map_ok(|cs| cs.measurement.map(|m| m.val))
417        .try_collect()?;
418
419    audit_with_data(
420        measurement,
421        head,
422        tail,
423        min_count,
424        sigma,
425        dispersion_method,
426        summarize_by,
427    )
428}
429
430/// Core audit logic that can be tested with mock data
431/// This function contains all the mutation-tested logic paths
432fn audit_with_data(
433    measurement: &str,
434    head: f64,
435    tail: Vec<f64>,
436    min_count: u16,
437    sigma: f64,
438    dispersion_method: DispersionMethod,
439    summarize_by: ReductionFunc,
440) -> Result<AuditResult> {
441    // Note: CLI enforces min_count >= 2 via clap::value_parser!(u16).range(2..)
442    // Tests may use lower values for edge case testing, but production code
443    // should never call this with min_count < 2
444    assert!(min_count >= 2, "min_count must be at least 2");
445
446    // Get unit for this measurement from config
447    let unit = config::measurement_unit(measurement);
448    let unit_str = unit.as_deref();
449
450    let head_summary = stats::aggregate_measurements(iter::once(&head));
451    let tail_summary = stats::aggregate_measurements(tail.iter());
452
453    // Generate sparkline and calculate range for all measurements - used in both skip and normal paths
454    let all_measurements = tail.into_iter().chain(iter::once(head)).collect::<Vec<_>>();
455
456    let mut tail_measurements = all_measurements.clone();
457    tail_measurements.pop(); // Remove head to get just tail for median calculation
458    let tail_median = tail_measurements.median().unwrap_or_default();
459
460    // Calculate min and max once for use in both branches
461    let min_val = all_measurements
462        .iter()
463        .min_by(|a, b| a.partial_cmp(b).unwrap())
464        .unwrap();
465    let max_val = all_measurements
466        .iter()
467        .max_by(|a, b| a.partial_cmp(b).unwrap())
468        .unwrap();
469
470    // Tiered approach for sparkline display:
471    // 1. If tail median is non-zero: use median as baseline, show percentages (default behavior)
472    // 2. If tail median is zero: show absolute differences instead
473    let tail_median_is_zero = tail_median.abs() < f64::EPSILON;
474
475    let sparkline = if tail_median_is_zero {
476        // Median is zero - show absolute range
477        format!(
478            " [{} – {}] {}",
479            min_val,
480            max_val,
481            spark(all_measurements.as_slice())
482        )
483    } else {
484        // MUTATION POINT: / vs % (Line 140)
485        // Median is non-zero - use it as baseline for percentage ranges
486        let relative_min = min_val / tail_median - 1.0;
487        let relative_max = max_val / tail_median - 1.0;
488
489        format!(
490            " [{:+.2}% – {:+.2}%] {}",
491            (relative_min * 100.0),
492            (relative_max * 100.0),
493            spark(all_measurements.as_slice())
494        )
495    };
496
497    // Helper function to build the measurement summary text
498    // This is used for both skipped and normal audit results to avoid duplication
499    let build_summary = || -> String {
500        let mut summary = String::new();
501
502        // Use the length of all_measurements vector for total count
503        let total_measurements = all_measurements.len();
504
505        // If only 1 total measurement (head only, no tail), show only head summary
506        if total_measurements == 1 {
507            let head_display = StatsWithUnit {
508                stats: &head_summary,
509                unit: unit_str,
510            };
511            summary.push_str(&format!("Head: {}\n", head_display));
512        } else if total_measurements >= 2 {
513            // 2+ measurements: show aggregation method, z-score, head, tail, and sparkline
514            let direction = get_direction_arrow(head_summary.mean, tail_summary.mean);
515            let z_score = head_summary.z_score_with_method(&tail_summary, dispersion_method);
516            let z_score_display = format_z_score_display(z_score);
517            let method_name = match dispersion_method {
518                DispersionMethod::StandardDeviation => "stddev",
519                DispersionMethod::MedianAbsoluteDeviation => "mad",
520            };
521
522            let head_display = StatsWithUnit {
523                stats: &head_summary,
524                unit: unit_str,
525            };
526            let tail_display = StatsWithUnit {
527                stats: &tail_summary,
528                unit: unit_str,
529            };
530
531            summary.push_str(&format!("Aggregation: {summarize_by}\n"));
532            summary.push_str(&format!(
533                "z-score ({method_name}): {direction}{}\n",
534                z_score_display
535            ));
536            summary.push_str(&format!("Head: {}\n", head_display));
537            summary.push_str(&format!("Tail: {}\n", tail_display));
538            summary.push_str(&sparkline);
539        }
540        // If 0 total measurements, return empty summary
541
542        summary
543    };
544
545    // MUTATION POINT: < vs == (Line 120)
546    if tail_summary.len < min_count.into() {
547        let number_measurements = tail_summary.len;
548        // MUTATION POINT: > vs < (Line 122)
549        let plural_s = if number_measurements == 1 { "" } else { "s" };
550        info!("Only {number_measurements} historical measurement{plural_s} found. Less than requested min_measurements of {min_count}. Skipping test.");
551
552        let mut skip_message = format!(
553            "⏭️ '{measurement}'\nOnly {number_measurements} historical measurement{plural_s} found. Less than requested min_measurements of {min_count}. Skipping test."
554        );
555
556        // Add summary using the same logic as passing/failing cases
557        let summary = build_summary();
558        if !summary.is_empty() {
559            skip_message.push('\n');
560            skip_message.push_str(&summary);
561        }
562
563        return Ok(AuditResult {
564            message: skip_message,
565            passed: true,
566        });
567    }
568
569    // MUTATION POINT: / vs % (Line 150)
570    // Calculate relative deviation - naturally handles infinity when tail_median is zero
571    let head_relative_deviation = (head / tail_median - 1.0).abs() * 100.0;
572
573    // Calculate absolute deviation
574    let head_absolute_deviation = (head - tail_median).abs();
575
576    // Check if we have a minimum relative deviation threshold configured
577    let min_relative_deviation = config::audit_min_relative_deviation(measurement);
578    let min_absolute_deviation = config::audit_min_absolute_deviation(measurement);
579
580    // MUTATION POINT: < vs == (Line 156)
581    let passed_due_to_relative_threshold = min_relative_deviation
582        .map(|threshold| head_relative_deviation < threshold)
583        .unwrap_or(false);
584
585    let passed_due_to_absolute_threshold = min_absolute_deviation
586        .map(|threshold| head_absolute_deviation < threshold)
587        .unwrap_or(false);
588
589    let passed_due_to_threshold =
590        passed_due_to_relative_threshold || passed_due_to_absolute_threshold;
591
592    let text_summary = build_summary();
593
594    // MUTATION POINT: > vs >= (Line 178)
595    let z_score_exceeds_sigma =
596        head_summary.is_significant(&tail_summary, sigma, dispersion_method);
597
598    // MUTATION POINT: ! removal (Line 181)
599    let passed = !z_score_exceeds_sigma || passed_due_to_threshold;
600
601    // Add threshold information to output if applicable
602    // Only show note when the audit would have failed without the threshold
603    let threshold_note = if z_score_exceeds_sigma {
604        let mut notes = Vec::new();
605        if passed_due_to_relative_threshold {
606            notes.push(format!(
607                "Note: Passed due to relative deviation ({:.1}%) being below threshold ({:.1}%)",
608                head_relative_deviation,
609                min_relative_deviation.unwrap()
610            ));
611        }
612        if passed_due_to_absolute_threshold {
613            notes.push(format!(
614                "Note: Passed due to absolute deviation ({:.1}) being below threshold ({:.1})",
615                head_absolute_deviation,
616                min_absolute_deviation.unwrap()
617            ));
618        }
619        if notes.is_empty() {
620            String::new()
621        } else {
622            format!("\n{}", notes.join("\n"))
623        }
624    } else {
625        String::new()
626    };
627
628    // MUTATION POINT: ! removal (Line 194)
629    if !passed {
630        return Ok(AuditResult {
631            message: format!(
632                "❌ '{measurement}'\nHEAD differs significantly from tail measurements.\n{text_summary}{threshold_note}"
633            ),
634            passed: false,
635        });
636    }
637
638    Ok(AuditResult {
639        message: format!("✅ '{measurement}'\n{text_summary}{threshold_note}"),
640        passed: true,
641    })
642}
643
644#[cfg(test)]
645mod test {
646    use crate::test_helpers::with_isolated_test_setup;
647
648    use super::*;
649
650    #[test]
651    fn test_format_z_score_display() {
652        // Test cases for z-score display formatting
653        let test_cases = vec![
654            (2.5_f64, " 2.50"),
655            (0.0_f64, " 0.00"),
656            (-1.5_f64, " -1.50"),
657            (999.999_f64, " 1000.00"),
658            (0.001_f64, " 0.00"),
659            (f64::INFINITY, ""),
660            (f64::NEG_INFINITY, ""),
661            (f64::NAN, ""),
662        ];
663
664        for (z_score, expected) in test_cases {
665            let result = format_z_score_display(z_score);
666            assert_eq!(result, expected, "Failed for z_score: {}", z_score);
667        }
668    }
669
670    #[test]
671    fn test_direction_arrows() {
672        // Test cases for direction arrow logic
673        let test_cases = vec![
674            (5.0_f64, 3.0_f64, "↑"), // head > tail
675            (1.0_f64, 3.0_f64, "↓"), // head < tail
676            (3.0_f64, 3.0_f64, "→"), // head == tail
677        ];
678
679        for (head_mean, tail_mean, expected) in test_cases {
680            let result = get_direction_arrow(head_mean, tail_mean);
681            assert_eq!(
682                result, expected,
683                "Failed for head_mean: {}, tail_mean: {}",
684                head_mean, tail_mean
685            );
686        }
687    }
688
689    #[test]
690    fn test_audit_with_different_dispersion_methods() {
691        // Test that audit produces different results with different dispersion methods
692
693        // Create mock data that would produce different z-scores with stddev vs MAD
694        let head_value = 35.0;
695        let tail_values = [30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 100.0];
696
697        let head_summary = stats::aggregate_measurements(std::iter::once(&head_value));
698        let tail_summary = stats::aggregate_measurements(tail_values.iter());
699
700        // Calculate z-scores with both methods
701        let z_score_stddev =
702            head_summary.z_score_with_method(&tail_summary, DispersionMethod::StandardDeviation);
703        let z_score_mad = head_summary
704            .z_score_with_method(&tail_summary, DispersionMethod::MedianAbsoluteDeviation);
705
706        // With the outlier (100.0), stddev should be much larger than MAD
707        // So z-score with stddev should be smaller than z-score with MAD
708        assert!(
709            z_score_stddev < z_score_mad,
710            "stddev z-score ({}) should be smaller than MAD z-score ({}) with outlier data",
711            z_score_stddev,
712            z_score_mad
713        );
714
715        // Both should be positive since head > tail mean
716        assert!(z_score_stddev > 0.0);
717        assert!(z_score_mad > 0.0);
718    }
719
720    #[test]
721    fn test_dispersion_method_conversion() {
722        // Test that the conversion from CLI types to stats types works correctly
723
724        // Test stddev conversion
725        let cli_stddev = git_perf_cli_types::DispersionMethod::StandardDeviation;
726        let stats_stddev: DispersionMethod = cli_stddev.into();
727        assert_eq!(stats_stddev, DispersionMethod::StandardDeviation);
728
729        // Test MAD conversion
730        let cli_mad = git_perf_cli_types::DispersionMethod::MedianAbsoluteDeviation;
731        let stats_mad: DispersionMethod = cli_mad.into();
732        assert_eq!(stats_mad, DispersionMethod::MedianAbsoluteDeviation);
733    }
734
735    #[test]
736    fn test_audit_multiple_with_no_measurements() {
737        // This test exercises the actual production audit_multiple function
738        // Tests the case where no patterns are provided (empty list)
739        // With no patterns, it should succeed (nothing to audit)
740        with_isolated_test_setup(|_git_dir, _home_path| {
741            let result = audit_multiple(
742                "HEAD",
743                100,
744                None,
745                None,
746                Some(1),
747                &[],
748                Some(ReductionFunc::Mean),
749                Some(2.0),
750                Some(DispersionMethod::StandardDeviation),
751                &[], // Empty combined_patterns
752                &[], // Empty separate_by
753                false,
754            );
755
756            // Should succeed when no measurements need to be audited
757            assert!(
758                result.is_ok(),
759                "audit_multiple should succeed with empty pattern list"
760            );
761        });
762    }
763
764    // MUTATION TESTING COVERAGE TESTS - Exercise actual production code paths
765
766    #[test]
767    fn test_min_count_boundary_condition() {
768        // COVERS MUTATION: tail_summary.len < min_count.into() vs ==
769        // Test with exactly min_count measurements (should NOT skip)
770        let result = audit_with_data(
771            "test_measurement",
772            15.0,
773            vec![10.0, 11.0, 12.0], // Exactly 3 measurements
774            3,                      // min_count = 3
775            2.0,
776            DispersionMethod::StandardDeviation,
777            ReductionFunc::Min,
778        );
779
780        assert!(result.is_ok());
781        let audit_result = result.unwrap();
782        // Should NOT be skipped (would be skipped if < was changed to ==)
783        assert!(!audit_result.message.contains("Skipping test"));
784
785        // Test with fewer than min_count (should skip)
786        let result = audit_with_data(
787            "test_measurement",
788            15.0,
789            vec![10.0, 11.0], // Only 2 measurements
790            3,                // min_count = 3
791            2.0,
792            DispersionMethod::StandardDeviation,
793            ReductionFunc::Min,
794        );
795
796        assert!(result.is_ok());
797        let audit_result = result.unwrap();
798        assert!(audit_result.message.contains("Skipping test"));
799        assert!(audit_result.passed); // Skipped tests are marked as passed
800    }
801
802    #[test]
803    fn test_pluralization_logic() {
804        // COVERS MUTATION: number_measurements > 1 vs ==
805        // Test with 0 measurements (should have 's' - grammatically correct)
806        let result = audit_with_data(
807            "test_measurement",
808            15.0,
809            vec![], // 0 measurements
810            5,      // min_count > 0 to trigger skip
811            2.0,
812            DispersionMethod::StandardDeviation,
813            ReductionFunc::Min,
814        );
815
816        assert!(result.is_ok());
817        let message = result.unwrap().message;
818        assert!(message.contains("0 historical measurements found")); // Has 's'
819        assert!(!message.contains("0 historical measurement found")); // Should not be singular
820
821        // Test with 1 measurement (no 's')
822        let result = audit_with_data(
823            "test_measurement",
824            15.0,
825            vec![10.0], // 1 measurement
826            5,          // min_count > 1 to trigger skip
827            2.0,
828            DispersionMethod::StandardDeviation,
829            ReductionFunc::Min,
830        );
831
832        assert!(result.is_ok());
833        let message = result.unwrap().message;
834        assert!(message.contains("1 historical measurement found")); // No 's'
835
836        // Test with 2+ measurements (should have 's')
837        let result = audit_with_data(
838            "test_measurement",
839            15.0,
840            vec![10.0, 11.0], // 2 measurements
841            5,                // min_count > 2 to trigger skip
842            2.0,
843            DispersionMethod::StandardDeviation,
844            ReductionFunc::Min,
845        );
846
847        assert!(result.is_ok());
848        let message = result.unwrap().message;
849        assert!(message.contains("2 historical measurements found")); // Has 's'
850    }
851
852    #[test]
853    fn test_skip_with_summaries() {
854        // Test that when audit is skipped, summaries are shown based on TOTAL measurement count
855        // Total measurements = 1 head + N tail
856        // and the format matches passing/failing cases
857
858        // Test with 0 tail measurements (1 total): should show Head only
859        let result = audit_with_data(
860            "test_measurement",
861            15.0,
862            vec![], // 0 tail measurements = 1 total measurement
863            5,      // min_count > 0 to trigger skip
864            2.0,
865            DispersionMethod::StandardDeviation,
866            ReductionFunc::Min,
867        );
868
869        assert!(result.is_ok());
870        let message = result.unwrap().message;
871        assert!(message.contains("Skipping test"));
872        assert!(message.contains("Head:")); // Head summary shown
873        assert!(!message.contains("z-score")); // No z-score (only 1 total measurement)
874        assert!(!message.contains("Tail:")); // No tail
875        assert!(!message.contains("[")); // No sparkline
876
877        // Test with 1 tail measurement (2 total): should show everything
878        let result = audit_with_data(
879            "test_measurement",
880            15.0,
881            vec![10.0], // 1 tail measurement = 2 total measurements
882            5,          // min_count > 1 to trigger skip
883            2.0,
884            DispersionMethod::StandardDeviation,
885            ReductionFunc::Min,
886        );
887
888        assert!(result.is_ok());
889        let message = result.unwrap().message;
890        assert!(message.contains("Skipping test"));
891        assert!(message.contains("z-score (stddev):")); // Z-score with method shown
892        assert!(message.contains("Head:")); // Head summary shown
893        assert!(message.contains("Tail:")); // Tail summary shown
894        assert!(message.contains("[")); // Sparkline shown
895                                        // Verify order: z-score, Head, Tail, sparkline
896        let z_pos = message.find("z-score").unwrap();
897        let head_pos = message.find("Head:").unwrap();
898        let tail_pos = message.find("Tail:").unwrap();
899        let spark_pos = message.find("[").unwrap();
900        assert!(z_pos < head_pos, "z-score should come before Head");
901        assert!(head_pos < tail_pos, "Head should come before Tail");
902        assert!(tail_pos < spark_pos, "Tail should come before sparkline");
903
904        // Test with 2 tail measurements (3 total): should show everything
905        let result = audit_with_data(
906            "test_measurement",
907            15.0,
908            vec![10.0, 11.0], // 2 tail measurements = 3 total measurements
909            5,                // min_count > 2 to trigger skip
910            2.0,
911            DispersionMethod::StandardDeviation,
912            ReductionFunc::Min,
913        );
914
915        assert!(result.is_ok());
916        let message = result.unwrap().message;
917        assert!(message.contains("Skipping test"));
918        assert!(message.contains("z-score (stddev):")); // Z-score with method shown
919        assert!(message.contains("Head:")); // Head summary shown
920        assert!(message.contains("Tail:")); // Tail summary shown
921        assert!(message.contains("[")); // Sparkline shown
922                                        // Verify order: z-score, Head, Tail, sparkline
923        let z_pos = message.find("z-score").unwrap();
924        let head_pos = message.find("Head:").unwrap();
925        let tail_pos = message.find("Tail:").unwrap();
926        let spark_pos = message.find("[").unwrap();
927        assert!(z_pos < head_pos, "z-score should come before Head");
928        assert!(head_pos < tail_pos, "Head should come before Tail");
929        assert!(tail_pos < spark_pos, "Tail should come before sparkline");
930
931        // Test with MAD dispersion method to ensure method name is correct
932        let result = audit_with_data(
933            "test_measurement",
934            15.0,
935            vec![10.0, 11.0], // 2 tail measurements = 3 total measurements
936            5,                // min_count > 2 to trigger skip
937            2.0,
938            DispersionMethod::MedianAbsoluteDeviation,
939            ReductionFunc::Min,
940        );
941
942        assert!(result.is_ok());
943        let message = result.unwrap().message;
944        assert!(message.contains("z-score (mad):")); // MAD method shown
945    }
946
947    #[test]
948    fn test_relative_calculations_division_vs_modulo() {
949        // COVERS MUTATIONS: / vs % in relative_min, relative_max, head_relative_deviation
950        // Use values where division and modulo produce very different results
951        let result = audit_with_data(
952            "test_measurement",
953            25.0,                   // head
954            vec![10.0, 10.0, 10.0], // tail, median = 10.0
955            2,
956            10.0, // High sigma to avoid z-score failures
957            DispersionMethod::StandardDeviation,
958            ReductionFunc::Min,
959        );
960
961        assert!(result.is_ok());
962        let audit_result = result.unwrap();
963
964        // With division:
965        // - relative_min = (10.0 / 10.0 - 1.0) * 100 = 0.0%
966        // - relative_max = (25.0 / 10.0 - 1.0) * 100 = 150.0%
967        // With modulo:
968        // - relative_min = (10.0 % 10.0 - 1.0) * 100 = -100.0% (since 10.0 % 10.0 = 0.0)
969        // - relative_max = (25.0 % 10.0 - 1.0) * 100 = -50.0% (since 25.0 % 10.0 = 5.0)
970
971        // Check that the calculation uses division, not modulo
972        // The range should show [+0.00% – +150.00%], not [-100.00% – -50.00%]
973        assert!(audit_result.message.contains("[+0.00% – +150.00%]"));
974
975        // Ensure the modulo results are NOT present
976        assert!(!audit_result.message.contains("[-100.00% – -50.00%]"));
977        assert!(!audit_result.message.contains("-100.00%"));
978        assert!(!audit_result.message.contains("-50.00%"));
979    }
980
981    #[test]
982    fn test_core_pass_fail_logic() {
983        // COVERS MUTATION: !z_score_exceeds_sigma || passed_due_to_threshold
984        // vs z_score_exceeds_sigma || passed_due_to_threshold
985
986        // Case 1: z_score exceeds sigma, no threshold bypass (should fail)
987        let result = audit_with_data(
988            "test_measurement",                 // No config threshold for this name
989            100.0,                              // Very high head value
990            vec![10.0, 10.0, 10.0, 10.0, 10.0], // Low tail values
991            2,
992            0.5, // Low sigma threshold
993            DispersionMethod::StandardDeviation,
994            ReductionFunc::Min,
995        );
996
997        assert!(result.is_ok());
998        let audit_result = result.unwrap();
999        assert!(!audit_result.passed); // Should fail
1000        assert!(audit_result.message.contains("❌"));
1001
1002        // Case 2: z_score within sigma (should pass)
1003        let result = audit_with_data(
1004            "test_measurement",
1005            10.2,                               // Close to tail values
1006            vec![10.0, 10.1, 10.0, 10.1, 10.0], // Some variance to avoid zero stddev
1007            2,
1008            100.0, // Very high sigma threshold
1009            DispersionMethod::StandardDeviation,
1010            ReductionFunc::Min,
1011        );
1012
1013        assert!(result.is_ok());
1014        let audit_result = result.unwrap();
1015        assert!(audit_result.passed); // Should pass
1016        assert!(audit_result.message.contains("✅"));
1017    }
1018
1019    #[test]
1020    fn test_final_result_logic() {
1021        // COVERS MUTATION: if !passed vs if passed
1022        // This tests the final branch that determines success vs failure message
1023
1024        // Test failing case (should get failure message)
1025        let result = audit_with_data(
1026            "test_measurement",
1027            1000.0, // Extreme outlier
1028            vec![10.0, 10.0, 10.0, 10.0, 10.0],
1029            2,
1030            0.1, // Very strict sigma
1031            DispersionMethod::StandardDeviation,
1032            ReductionFunc::Min,
1033        );
1034
1035        assert!(result.is_ok());
1036        let audit_result = result.unwrap();
1037        assert!(!audit_result.passed);
1038        assert!(audit_result.message.contains("❌"));
1039        assert!(audit_result.message.contains("differs significantly"));
1040
1041        // Test passing case (should get success message)
1042        let result = audit_with_data(
1043            "test_measurement",
1044            10.01,                              // Very close to tail
1045            vec![10.0, 10.1, 10.0, 10.1, 10.0], // Varied values to avoid zero variance
1046            2,
1047            100.0, // Very lenient sigma
1048            DispersionMethod::StandardDeviation,
1049            ReductionFunc::Min,
1050        );
1051
1052        assert!(result.is_ok());
1053        let audit_result = result.unwrap();
1054        assert!(audit_result.passed);
1055        assert!(audit_result.message.contains("✅"));
1056        assert!(!audit_result.message.contains("differs significantly"));
1057    }
1058
1059    #[test]
1060    fn test_dispersion_methods_produce_different_results() {
1061        // Test that different dispersion methods work in the production code
1062        let head = 35.0;
1063        let tail = vec![30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 30.0, 100.0];
1064
1065        let result_stddev = audit_with_data(
1066            "test_measurement",
1067            head,
1068            tail.clone(),
1069            2,
1070            2.0,
1071            DispersionMethod::StandardDeviation,
1072            ReductionFunc::Min,
1073        );
1074
1075        let result_mad = audit_with_data(
1076            "test_measurement",
1077            head,
1078            tail,
1079            2,
1080            2.0,
1081            DispersionMethod::MedianAbsoluteDeviation,
1082            ReductionFunc::Min,
1083        );
1084
1085        assert!(result_stddev.is_ok());
1086        assert!(result_mad.is_ok());
1087
1088        let stddev_result = result_stddev.unwrap();
1089        let mad_result = result_mad.unwrap();
1090
1091        // Both should contain method indicators
1092        assert!(stddev_result.message.contains("stddev"));
1093        assert!(mad_result.message.contains("mad"));
1094    }
1095
1096    #[test]
1097    fn test_head_and_tail_have_units_and_auto_scaling() {
1098        // Test that both head and tail measurements display units with auto-scaling
1099
1100        // First, set up a test environment with a configured unit
1101        use crate::test_helpers::setup_test_env_with_config;
1102
1103        let config_content = r#"
1104[measurement."build_time"]
1105unit = "ms"
1106"#;
1107        let (_temp_dir, _dir_guard) = setup_test_env_with_config(config_content);
1108
1109        // Test with large millisecond values that should auto-scale to seconds
1110        let head = 12_345.67; // Will auto-scale to ~12.35s
1111        let tail = vec![10_000.0, 10_500.0, 11_000.0, 11_500.0, 12_000.0]; // Will auto-scale to 10s, 10.5s, 11s, etc.
1112
1113        let result = audit_with_data(
1114            "build_time",
1115            head,
1116            tail,
1117            2,
1118            10.0, // High sigma to ensure it passes
1119            DispersionMethod::StandardDeviation,
1120            ReductionFunc::Min,
1121        );
1122
1123        assert!(result.is_ok());
1124        let audit_result = result.unwrap();
1125        let message = &audit_result.message;
1126
1127        // Verify Head section exists
1128        assert!(
1129            message.contains("Head:"),
1130            "Message should contain Head section"
1131        );
1132
1133        // With auto-scaling, 12345.67ms should become ~12.35s or 12.3s
1134        // Check that the value is auto-scaled (contains 's' for seconds)
1135        assert!(
1136            message.contains("12.3s") || message.contains("12.35s"),
1137            "Head mean should be auto-scaled to seconds, got: {}",
1138            message
1139        );
1140
1141        let head_section: Vec<&str> = message
1142            .lines()
1143            .filter(|line| line.contains("Head:"))
1144            .collect();
1145
1146        assert!(
1147            !head_section.is_empty(),
1148            "Should find Head section in message"
1149        );
1150
1151        let head_line = head_section[0];
1152
1153        // With auto-scaling, all values (mean, stddev, MAD) get their units auto-scaled
1154        // They should all have units now (not just mean)
1155        assert!(
1156            head_line.contains("μ:") && head_line.contains("σ:") && head_line.contains("MAD:"),
1157            "Head line should contain μ, σ, and MAD labels, got: {}",
1158            head_line
1159        );
1160
1161        // Verify Tail section has units
1162        assert!(
1163            message.contains("Tail:"),
1164            "Message should contain Tail section"
1165        );
1166
1167        let tail_section: Vec<&str> = message
1168            .lines()
1169            .filter(|line| line.contains("Tail:"))
1170            .collect();
1171
1172        assert!(
1173            !tail_section.is_empty(),
1174            "Should find Tail section in message"
1175        );
1176
1177        let tail_line = tail_section[0];
1178
1179        // Tail mean should be auto-scaled to seconds (10000-12000ms → 10-12s)
1180        assert!(
1181            tail_line.contains("11s")
1182                || tail_line.contains("11.")
1183                || tail_line.contains("10.")
1184                || tail_line.contains("12."),
1185            "Tail should contain auto-scaled second values, got: {}",
1186            tail_line
1187        );
1188
1189        // Verify the basic format structure is present
1190        assert!(
1191            tail_line.contains("μ:")
1192                && tail_line.contains("σ:")
1193                && tail_line.contains("MAD:")
1194                && tail_line.contains("n:"),
1195            "Tail line should contain all stat labels, got: {}",
1196            tail_line
1197        );
1198    }
1199
1200    #[test]
1201    fn test_threshold_note_only_shown_when_audit_would_fail() {
1202        // Test that the threshold note is only shown when the audit would have
1203        // failed without the threshold (i.e., when z_score_exceeds_sigma is true)
1204        use crate::test_helpers::setup_test_env_with_config;
1205
1206        let config_content = r#"
1207[measurement."build_time"]
1208min_relative_deviation = 10.0
1209"#;
1210        let (_temp_dir, _dir_guard) = setup_test_env_with_config(config_content);
1211
1212        // Case 1: Low z-score AND low relative deviation (threshold is configured but not needed)
1213        // Should pass without showing the note
1214        let result = audit_with_data(
1215            "build_time",
1216            10.1,                               // Very close to tail values
1217            vec![10.0, 10.1, 10.0, 10.1, 10.0], // Low variance
1218            2,
1219            100.0, // Very high sigma threshold - won't be exceeded
1220            DispersionMethod::StandardDeviation,
1221            ReductionFunc::Min,
1222        );
1223
1224        assert!(result.is_ok());
1225        let audit_result = result.unwrap();
1226        assert!(audit_result.passed);
1227        assert!(audit_result.message.contains("✅"));
1228        // The note should NOT be shown because the audit would have passed anyway
1229        assert!(
1230            !audit_result
1231                .message
1232                .contains("Note: Passed due to relative deviation"),
1233            "Note should not appear when audit passes without needing threshold bypass"
1234        );
1235
1236        // Case 2: High z-score but low relative deviation (threshold saves the audit)
1237        // Should pass and show the note
1238        let result = audit_with_data(
1239            "build_time",
1240            1002.0, // High z-score outlier but low relative deviation
1241            vec![1000.0, 1000.1, 1000.0, 1000.1, 1000.0], // Very low variance
1242            2,
1243            0.5, // Low sigma threshold - will be exceeded
1244            DispersionMethod::StandardDeviation,
1245            ReductionFunc::Min,
1246        );
1247
1248        assert!(result.is_ok());
1249        let audit_result = result.unwrap();
1250        assert!(audit_result.passed);
1251        assert!(audit_result.message.contains("✅"));
1252        // The note SHOULD be shown because the audit would have failed without the threshold
1253        assert!(
1254            audit_result
1255                .message
1256                .contains("Note: Passed due to relative deviation"),
1257            "Note should appear when audit passes due to threshold bypass. Got: {}",
1258            audit_result.message
1259        );
1260
1261        // Case 3: High z-score AND high relative deviation (threshold doesn't help)
1262        // Should fail
1263        let result = audit_with_data(
1264            "build_time",
1265            1200.0, // High z-score AND high relative deviation
1266            vec![1000.0, 1000.1, 1000.0, 1000.1, 1000.0], // Very low variance
1267            2,
1268            0.5, // Low sigma threshold - will be exceeded
1269            DispersionMethod::StandardDeviation,
1270            ReductionFunc::Min,
1271        );
1272
1273        assert!(result.is_ok());
1274        let audit_result = result.unwrap();
1275        assert!(!audit_result.passed);
1276        assert!(audit_result.message.contains("❌"));
1277        // No note shown because the audit still failed
1278        assert!(
1279            !audit_result
1280                .message
1281                .contains("Note: Passed due to relative deviation"),
1282            "Note should not appear when audit fails"
1283        );
1284    }
1285
1286    #[test]
1287    fn test_absolute_threshold_note_and_deviation_value() {
1288        // Tests that:
1289        // 1. The note shows the correct absolute deviation value (catches - vs / mutation)
1290        // 2. The boundary: deviation exactly AT threshold fails (catches < vs <= mutation)
1291        use crate::test_helpers::setup_test_env_with_config;
1292
1293        let config_content = r#"
1294[measurement."build_time"]
1295min_absolute_deviation = 50.0
1296"#;
1297        let (_temp_dir, _dir_guard) = setup_test_env_with_config(config_content);
1298
1299        // Case 1: High z-score but low absolute deviation (threshold saves the audit)
1300        // head=1010, tail values very tightly clustered around 1000
1301        // absolute deviation = |1010 - 1000| = 10 < 50 => should pass
1302        // if - were replaced with /, deviation would be |1010/1000| = 1.01, still < 50 (passes anyway)
1303        // So we need values where subtraction and division give meaningfully different results
1304        // head=1005, tail=1000: subtract=5, divide=1.005; but threshold=50, both < 50
1305        // Let's use head=100, tail_median=10: subtract=90, divide=10; threshold=50
1306        // With threshold=50: subtract(90) >= 50 fails, divide(10) < 50 passes
1307        // This catches the - vs / mutation
1308        let result = audit_with_data(
1309            "build_time",
1310            100.0,                              // head value
1311            vec![10.0, 10.0, 10.0, 10.0, 10.0], // tail values, median=10
1312            2,
1313            0.5, // Low sigma - will be exceeded
1314            DispersionMethod::StandardDeviation,
1315            ReductionFunc::Min,
1316        );
1317
1318        assert!(result.is_ok());
1319        let audit_result = result.unwrap();
1320        // absolute deviation = |100 - 10| = 90, which is > 50 threshold => should FAIL
1321        assert!(
1322            !audit_result.passed,
1323            "Should fail: absolute deviation 90 > threshold 50. Got: {}",
1324            audit_result.message
1325        );
1326
1327        // Case 2: absolute deviation exactly equals threshold => should FAIL (< not <=)
1328        // head=1050, tail_median=1000, absolute_deviation=50, threshold=50
1329        // With < : 50 < 50 is false => fails (correct)
1330        // With <= : 50 <= 50 is true => passes (wrong)
1331        let result = audit_with_data(
1332            "build_time",
1333            1050.0,                                       // head value
1334            vec![1000.0, 1000.0, 1000.0, 1000.0, 1000.0], // tail values, median=1000
1335            2,
1336            0.5, // Low sigma - will be exceeded
1337            DispersionMethod::StandardDeviation,
1338            ReductionFunc::Min,
1339        );
1340
1341        assert!(result.is_ok());
1342        let audit_result = result.unwrap();
1343        // absolute deviation = |1050 - 1000| = 50, which equals threshold 50 => should FAIL
1344        assert!(
1345            !audit_result.passed,
1346            "Should fail: absolute deviation 50 == threshold 50 (not strictly less than). Got: {}",
1347            audit_result.message
1348        );
1349
1350        // Case 3: absolute deviation strictly below threshold => should PASS with note
1351        // head=1049, tail_median=1000, absolute_deviation=49, threshold=50
1352        let result = audit_with_data(
1353            "build_time",
1354            1049.0,                                       // head value
1355            vec![1000.0, 1000.0, 1000.0, 1000.0, 1000.0], // tail values, median=1000
1356            2,
1357            0.5, // Low sigma - will be exceeded
1358            DispersionMethod::StandardDeviation,
1359            ReductionFunc::Min,
1360        );
1361
1362        assert!(result.is_ok());
1363        let audit_result = result.unwrap();
1364        assert!(
1365            audit_result.passed,
1366            "Should pass: absolute deviation 49 < threshold 50. Got: {}",
1367            audit_result.message
1368        );
1369        assert!(
1370            audit_result
1371                .message
1372                .contains("Note: Passed due to absolute deviation"),
1373            "Note should appear when audit passes due to absolute threshold. Got: {}",
1374            audit_result.message
1375        );
1376        // Verify the note contains the correct deviation value (catches - vs / mutation)
1377        // If / were used: |1049/1000| = 1.049, note would say "1.0" not "49.0"
1378        assert!(
1379            audit_result.message.contains("49.0"),
1380            "Note should show absolute deviation 49.0, not 1.0 (which would indicate / instead of -). Got: {}",
1381            audit_result.message
1382        );
1383    }
1384
1385    // Integration tests that verify per-measurement config determination
1386    #[cfg(test)]
1387    mod integration {
1388        use super::*;
1389        use crate::config::{
1390            audit_aggregate_by, audit_dispersion_method, audit_min_measurements, audit_sigma,
1391        };
1392        use crate::test_helpers::setup_test_env_with_config;
1393
1394        #[test]
1395        fn test_different_dispersion_methods_per_measurement() {
1396            let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1397                r#"
1398[measurement]
1399dispersion_method = "stddev"
1400
1401[measurement."build_time"]
1402dispersion_method = "mad"
1403
1404[measurement."memory_usage"]
1405dispersion_method = "stddev"
1406"#,
1407            );
1408
1409            // Verify each measurement gets its own config
1410            let build_time_method = audit_dispersion_method("build_time");
1411            let memory_usage_method = audit_dispersion_method("memory_usage");
1412            let other_method = audit_dispersion_method("other_metric");
1413
1414            assert_eq!(
1415                DispersionMethod::from(build_time_method),
1416                DispersionMethod::MedianAbsoluteDeviation,
1417                "build_time should use MAD"
1418            );
1419            assert_eq!(
1420                DispersionMethod::from(memory_usage_method),
1421                DispersionMethod::StandardDeviation,
1422                "memory_usage should use stddev"
1423            );
1424            assert_eq!(
1425                DispersionMethod::from(other_method),
1426                DispersionMethod::StandardDeviation,
1427                "other_metric should use default stddev"
1428            );
1429        }
1430
1431        #[test]
1432        fn test_different_min_measurements_per_measurement() {
1433            let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1434                r#"
1435[measurement]
1436min_measurements = 5
1437
1438[measurement."build_time"]
1439min_measurements = 10
1440
1441[measurement."memory_usage"]
1442min_measurements = 3
1443"#,
1444            );
1445
1446            assert_eq!(
1447                audit_min_measurements("build_time"),
1448                Some(10),
1449                "build_time should require 10 measurements"
1450            );
1451            assert_eq!(
1452                audit_min_measurements("memory_usage"),
1453                Some(3),
1454                "memory_usage should require 3 measurements"
1455            );
1456            assert_eq!(
1457                audit_min_measurements("other_metric"),
1458                Some(5),
1459                "other_metric should use default 5 measurements"
1460            );
1461        }
1462
1463        #[test]
1464        fn test_different_aggregate_by_per_measurement() {
1465            let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1466                r#"
1467[measurement]
1468aggregate_by = "median"
1469
1470[measurement."build_time"]
1471aggregate_by = "max"
1472
1473[measurement."memory_usage"]
1474aggregate_by = "mean"
1475"#,
1476            );
1477
1478            assert_eq!(
1479                audit_aggregate_by("build_time"),
1480                Some(git_perf_cli_types::ReductionFunc::Max),
1481                "build_time should use max"
1482            );
1483            assert_eq!(
1484                audit_aggregate_by("memory_usage"),
1485                Some(git_perf_cli_types::ReductionFunc::Mean),
1486                "memory_usage should use mean"
1487            );
1488            assert_eq!(
1489                audit_aggregate_by("other_metric"),
1490                Some(git_perf_cli_types::ReductionFunc::Median),
1491                "other_metric should use default median"
1492            );
1493        }
1494
1495        #[test]
1496        fn test_different_sigma_per_measurement() {
1497            let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1498                r#"
1499[measurement]
1500sigma = 3.0
1501
1502[measurement."build_time"]
1503sigma = 5.5
1504
1505[measurement."memory_usage"]
1506sigma = 2.0
1507"#,
1508            );
1509
1510            assert_eq!(
1511                audit_sigma("build_time"),
1512                Some(5.5),
1513                "build_time should use sigma 5.5"
1514            );
1515            assert_eq!(
1516                audit_sigma("memory_usage"),
1517                Some(2.0),
1518                "memory_usage should use sigma 2.0"
1519            );
1520            assert_eq!(
1521                audit_sigma("other_metric"),
1522                Some(3.0),
1523                "other_metric should use default sigma 3.0"
1524            );
1525        }
1526
1527        #[test]
1528        fn test_cli_overrides_config() {
1529            let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1530                r#"
1531[measurement."build_time"]
1532min_measurements = 10
1533aggregate_by = "max"
1534sigma = 5.5
1535dispersion_method = "mad"
1536"#,
1537            );
1538
1539            // Test that CLI values override config
1540            let params = super::resolve_audit_params(
1541                "build_time",
1542                Some(2),                                   // CLI min
1543                Some(ReductionFunc::Min),                  // CLI aggregate
1544                Some(3.0),                                 // CLI sigma
1545                Some(DispersionMethod::StandardDeviation), // CLI dispersion
1546            );
1547
1548            assert_eq!(
1549                params.min_count, 2,
1550                "CLI min_measurements should override config"
1551            );
1552            assert_eq!(
1553                params.summarize_by,
1554                ReductionFunc::Min,
1555                "CLI aggregate_by should override config"
1556            );
1557            assert_eq!(params.sigma, 3.0, "CLI sigma should override config");
1558            assert_eq!(
1559                params.dispersion_method,
1560                DispersionMethod::StandardDeviation,
1561                "CLI dispersion should override config"
1562            );
1563        }
1564
1565        #[test]
1566        fn test_config_overrides_defaults() {
1567            let (_temp_dir, _dir_guard) = setup_test_env_with_config(
1568                r#"
1569[measurement."build_time"]
1570min_measurements = 10
1571aggregate_by = "max"
1572sigma = 5.5
1573dispersion_method = "mad"
1574"#,
1575            );
1576
1577            // Test that config values are used when no CLI values provided
1578            let params = super::resolve_audit_params(
1579                "build_time",
1580                None, // No CLI values
1581                None,
1582                None,
1583                None,
1584            );
1585
1586            assert_eq!(
1587                params.min_count, 10,
1588                "Config min_measurements should override default"
1589            );
1590            assert_eq!(
1591                params.summarize_by,
1592                ReductionFunc::Max,
1593                "Config aggregate_by should override default"
1594            );
1595            assert_eq!(params.sigma, 5.5, "Config sigma should override default");
1596            assert_eq!(
1597                params.dispersion_method,
1598                DispersionMethod::MedianAbsoluteDeviation,
1599                "Config dispersion should override default"
1600            );
1601        }
1602
1603        #[test]
1604        fn test_uses_defaults_when_no_config_or_cli() {
1605            let (_temp_dir, _dir_guard) = setup_test_env_with_config("");
1606
1607            // Test that defaults are used when no CLI or config
1608            let params = super::resolve_audit_params(
1609                "non_existent_measurement",
1610                None, // No CLI values
1611                None,
1612                None,
1613                None,
1614            );
1615
1616            assert_eq!(
1617                params.min_count, 2,
1618                "Should use default min_measurements of 2"
1619            );
1620            assert_eq!(
1621                params.summarize_by,
1622                ReductionFunc::Min,
1623                "Should use default aggregate_by of Min"
1624            );
1625            assert_eq!(params.sigma, 4.0, "Should use default sigma of 4.0");
1626            assert_eq!(
1627                params.dispersion_method,
1628                DispersionMethod::StandardDeviation,
1629                "Should use default dispersion of stddev"
1630            );
1631        }
1632    }
1633
1634    #[test]
1635    fn test_discover_matching_measurements() {
1636        use crate::data::{Commit, MeasurementData};
1637        use std::collections::HashMap;
1638
1639        // Create mock commits with various measurements
1640        let commits = vec![
1641            Ok(Commit {
1642                commit: "abc123".to_string(),
1643                title: "test: commit 1".to_string(),
1644                author: "Test Author".to_string(),
1645                measurements: vec![
1646                    MeasurementData {
1647                        epoch: 0,
1648                        name: "bench_cpu".to_string(),
1649                        timestamp: 1000.0,
1650                        val: 100.0,
1651                        key_values: {
1652                            let mut map = HashMap::new();
1653                            map.insert("os".to_string(), "linux".to_string());
1654                            map
1655                        },
1656                    },
1657                    MeasurementData {
1658                        epoch: 0,
1659                        name: "bench_memory".to_string(),
1660                        timestamp: 1000.0,
1661                        val: 200.0,
1662                        key_values: {
1663                            let mut map = HashMap::new();
1664                            map.insert("os".to_string(), "linux".to_string());
1665                            map
1666                        },
1667                    },
1668                    MeasurementData {
1669                        epoch: 0,
1670                        name: "test_unit".to_string(),
1671                        timestamp: 1000.0,
1672                        val: 50.0,
1673                        key_values: {
1674                            let mut map = HashMap::new();
1675                            map.insert("os".to_string(), "linux".to_string());
1676                            map
1677                        },
1678                    },
1679                ],
1680            }),
1681            Ok(Commit {
1682                commit: "def456".to_string(),
1683                title: "test: commit 2".to_string(),
1684                author: "Test Author".to_string(),
1685                measurements: vec![
1686                    MeasurementData {
1687                        epoch: 0,
1688                        name: "bench_cpu".to_string(),
1689                        timestamp: 1000.0,
1690                        val: 105.0,
1691                        key_values: {
1692                            let mut map = HashMap::new();
1693                            map.insert("os".to_string(), "mac".to_string());
1694                            map
1695                        },
1696                    },
1697                    MeasurementData {
1698                        epoch: 0,
1699                        name: "other_metric".to_string(),
1700                        timestamp: 1000.0,
1701                        val: 75.0,
1702                        key_values: {
1703                            let mut map = HashMap::new();
1704                            map.insert("os".to_string(), "linux".to_string());
1705                            map
1706                        },
1707                    },
1708                ],
1709            }),
1710        ];
1711
1712        // Test 1: Single filter pattern matching "bench_*"
1713        let patterns = vec!["bench_.*".to_string()];
1714        let filters = crate::filter::compile_filters(&patterns).unwrap();
1715        let selectors = vec![];
1716        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1717
1718        assert_eq!(discovered.len(), 2);
1719        assert!(discovered.contains(&"bench_cpu".to_string()));
1720        assert!(discovered.contains(&"bench_memory".to_string()));
1721        assert!(!discovered.contains(&"test_unit".to_string()));
1722        assert!(!discovered.contains(&"other_metric".to_string()));
1723
1724        // Test 2: Multiple filter patterns (OR behavior)
1725        let patterns = vec!["bench_cpu".to_string(), "test_.*".to_string()];
1726        let filters = crate::filter::compile_filters(&patterns).unwrap();
1727        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1728
1729        assert_eq!(discovered.len(), 2);
1730        assert!(discovered.contains(&"bench_cpu".to_string()));
1731        assert!(discovered.contains(&"test_unit".to_string()));
1732        assert!(!discovered.contains(&"bench_memory".to_string()));
1733
1734        // Test 3: Filter with selectors
1735        let patterns = vec!["bench_.*".to_string()];
1736        let filters = crate::filter::compile_filters(&patterns).unwrap();
1737        let selectors = vec![("os".to_string(), "linux".to_string())];
1738        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1739
1740        // bench_cpu and bench_memory both have os=linux (in first commit)
1741        // bench_cpu also has os=mac (in second commit) but selector filters it to only linux
1742        assert_eq!(discovered.len(), 2);
1743        assert!(discovered.contains(&"bench_cpu".to_string()));
1744        assert!(discovered.contains(&"bench_memory".to_string()));
1745
1746        // Test 4: No matches
1747        let patterns = vec!["nonexistent.*".to_string()];
1748        let filters = crate::filter::compile_filters(&patterns).unwrap();
1749        let selectors = vec![];
1750        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1751
1752        assert_eq!(discovered.len(), 0);
1753
1754        // Test 5: Empty filters (should match all)
1755        let filters = vec![];
1756        let selectors = vec![];
1757        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1758
1759        // Empty filters should match nothing based on the logic
1760        // Actually, looking at matches_any_filter, empty filters return true
1761        // So this should discover all measurements
1762        assert_eq!(discovered.len(), 4);
1763        assert!(discovered.contains(&"bench_cpu".to_string()));
1764        assert!(discovered.contains(&"bench_memory".to_string()));
1765        assert!(discovered.contains(&"test_unit".to_string()));
1766        assert!(discovered.contains(&"other_metric".to_string()));
1767
1768        // Test 6: Selector filters out everything
1769        let patterns = vec!["bench_.*".to_string()];
1770        let filters = crate::filter::compile_filters(&patterns).unwrap();
1771        let selectors = vec![("os".to_string(), "windows".to_string())];
1772        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1773
1774        assert_eq!(discovered.len(), 0);
1775
1776        // Test 7: Exact match with anchored regex (simulating -m argument)
1777        let patterns = vec!["^bench_cpu$".to_string()];
1778        let filters = crate::filter::compile_filters(&patterns).unwrap();
1779        let selectors = vec![];
1780        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1781
1782        assert_eq!(discovered.len(), 1);
1783        assert!(discovered.contains(&"bench_cpu".to_string()));
1784
1785        // Test 8: Sorted output (verify deterministic ordering)
1786        let patterns = vec![".*".to_string()]; // Match all
1787        let filters = crate::filter::compile_filters(&patterns).unwrap();
1788        let selectors = vec![];
1789        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1790
1791        // Should be sorted alphabetically
1792        assert_eq!(discovered[0], "bench_cpu");
1793        assert_eq!(discovered[1], "bench_memory");
1794        assert_eq!(discovered[2], "other_metric");
1795        assert_eq!(discovered[3], "test_unit");
1796    }
1797
1798    #[test]
1799    fn test_audit_multiple_with_combined_patterns() {
1800        // This test verifies that combining explicit measurements (-m) and filter patterns (--filter)
1801        // works correctly with OR behavior. Both should be audited.
1802        // Note: This is an integration test that uses actual audit_multiple function,
1803        // but we can't easily test it without a real git repo, so we test the pattern combination
1804        // and discovery logic instead.
1805
1806        use crate::data::{Commit, MeasurementData};
1807        use std::collections::HashMap;
1808
1809        // Create mock commits
1810        let commits = vec![Ok(Commit {
1811            commit: "abc123".to_string(),
1812            title: "test: commit".to_string(),
1813            author: "Test Author".to_string(),
1814            measurements: vec![
1815                MeasurementData {
1816                    epoch: 0,
1817                    name: "timer".to_string(),
1818                    timestamp: 1000.0,
1819                    val: 10.0,
1820                    key_values: HashMap::new(),
1821                },
1822                MeasurementData {
1823                    epoch: 0,
1824                    name: "bench_cpu".to_string(),
1825                    timestamp: 1000.0,
1826                    val: 100.0,
1827                    key_values: HashMap::new(),
1828                },
1829                MeasurementData {
1830                    epoch: 0,
1831                    name: "memory".to_string(),
1832                    timestamp: 1000.0,
1833                    val: 500.0,
1834                    key_values: HashMap::new(),
1835                },
1836            ],
1837        })];
1838
1839        // Simulate combining -m timer with --filter "bench_.*"
1840        // This is what combine_measurements_and_filters does in cli.rs
1841        let measurements = vec!["timer".to_string()];
1842        let filter_patterns = vec!["bench_.*".to_string()];
1843        let combined =
1844            crate::filter::combine_measurements_and_filters(&measurements, &filter_patterns);
1845
1846        // combined should have: ["^timer$", "bench_.*"]
1847        assert_eq!(combined.len(), 2);
1848        assert_eq!(combined[0], "^timer$");
1849        assert_eq!(combined[1], "bench_.*");
1850
1851        // Now compile and discover
1852        let filters = crate::filter::compile_filters(&combined).unwrap();
1853        let selectors = vec![];
1854        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1855
1856        // Should discover both timer (exact match) and bench_cpu (pattern match)
1857        assert_eq!(discovered.len(), 2);
1858        assert!(discovered.contains(&"timer".to_string()));
1859        assert!(discovered.contains(&"bench_cpu".to_string()));
1860        assert!(!discovered.contains(&"memory".to_string())); // Not in -m or filter
1861
1862        // Test with multiple explicit measurements and multiple filters
1863        let measurements = vec!["timer".to_string(), "memory".to_string()];
1864        let filter_patterns = vec!["bench_.*".to_string(), "test_.*".to_string()];
1865        let combined =
1866            crate::filter::combine_measurements_and_filters(&measurements, &filter_patterns);
1867
1868        assert_eq!(combined.len(), 4);
1869
1870        let filters = crate::filter::compile_filters(&combined).unwrap();
1871        let discovered = discover_matching_measurements(&commits, &filters, &selectors);
1872
1873        // Should discover timer, memory, and bench_cpu (no test_* in commits)
1874        assert_eq!(discovered.len(), 3);
1875        assert!(discovered.contains(&"timer".to_string()));
1876        assert!(discovered.contains(&"memory".to_string()));
1877        assert!(discovered.contains(&"bench_cpu".to_string()));
1878    }
1879
1880    #[test]
1881    fn test_audit_with_empty_tail() {
1882        // Test for division by zero bug when tail is empty
1883        // This test reproduces the bug where tail_median is 0.0 when tail is empty,
1884        // causing division by zero in sparkline calculation
1885        let result = audit_with_data(
1886            "test_measurement",
1887            10.0,   // head
1888            vec![], // empty tail - triggers the bug
1889            2,      // min_count
1890            2.0,    // sigma
1891            DispersionMethod::StandardDeviation,
1892            ReductionFunc::Min,
1893        );
1894
1895        // Should succeed and skip (not crash with division by zero)
1896        assert!(result.is_ok(), "Should not crash on empty tail");
1897        let audit_result = result.unwrap();
1898
1899        // Should be skipped due to insufficient measurements
1900        assert!(audit_result.passed);
1901        assert!(audit_result.message.contains("Skipping test"));
1902
1903        // The message should not contain inf or NaN
1904        assert!(!audit_result.message.to_lowercase().contains("inf"));
1905        assert!(!audit_result.message.to_lowercase().contains("nan"));
1906    }
1907
1908    #[test]
1909    fn test_audit_with_all_zero_tail() {
1910        // Test for division by zero when all tail measurements are 0.0
1911        // This tests the edge case where median is 0.0 even with measurements
1912        let result = audit_with_data(
1913            "test_measurement",
1914            5.0,                 // non-zero head
1915            vec![0.0, 0.0, 0.0], // all zeros in tail
1916            2,                   // min_count
1917            2.0,                 // sigma
1918            DispersionMethod::StandardDeviation,
1919            ReductionFunc::Min,
1920        );
1921
1922        // Should succeed (not crash with division by zero)
1923        assert!(result.is_ok(), "Should not crash when tail median is 0.0");
1924        let audit_result = result.unwrap();
1925
1926        // The message should not contain inf or NaN
1927        assert!(!audit_result.message.to_lowercase().contains("inf"));
1928        assert!(!audit_result.message.to_lowercase().contains("nan"));
1929    }
1930
1931    #[test]
1932    fn test_tiered_baseline_approach() {
1933        // Test the tiered approach:
1934        // 1. Non-zero median → use median, show percentages
1935        // 2. Zero median → show absolute values
1936
1937        // Case 1: Median is non-zero - use percentages (default behavior)
1938        let result = audit_with_data(
1939            "test_measurement",
1940            15.0,                   // head
1941            vec![10.0, 11.0, 12.0], // median=11.0 (non-zero)
1942            2,
1943            2.0,
1944            DispersionMethod::StandardDeviation,
1945            ReductionFunc::Min,
1946        );
1947
1948        assert!(result.is_ok());
1949        let audit_result = result.unwrap();
1950        // Should use median as baseline and show percentage
1951        assert!(audit_result.message.contains('%'));
1952        assert!(!audit_result.message.to_lowercase().contains("inf"));
1953
1954        // Case 2: Median is zero with non-zero head - use absolute values
1955        let result = audit_with_data(
1956            "test_measurement",
1957            5.0,                 // head (non-zero)
1958            vec![0.0, 0.0, 0.0], // median=0
1959            2,
1960            2.0,
1961            DispersionMethod::StandardDeviation,
1962            ReductionFunc::Min,
1963        );
1964
1965        assert!(result.is_ok());
1966        let audit_result = result.unwrap();
1967        // Should show absolute values instead of percentages
1968        // The message should contain the sparkline but not percentage symbols
1969        assert!(!audit_result.message.to_lowercase().contains("inf"));
1970        assert!(!audit_result.message.to_lowercase().contains("nan"));
1971        // Check that sparkline exists (contains the dash character)
1972        assert!(audit_result.message.contains('–') || audit_result.message.contains('-'));
1973
1974        // Case 3: Everything is zero - show absolute values [0 - 0]
1975        let result = audit_with_data(
1976            "test_measurement",
1977            0.0,                 // head
1978            vec![0.0, 0.0, 0.0], // median=0
1979            2,
1980            2.0,
1981            DispersionMethod::StandardDeviation,
1982            ReductionFunc::Min,
1983        );
1984
1985        assert!(result.is_ok());
1986        let audit_result = result.unwrap();
1987        // Should show absolute range [0 - 0]
1988        assert!(!audit_result.message.to_lowercase().contains("inf"));
1989        assert!(!audit_result.message.to_lowercase().contains("nan"));
1990    }
1991
1992    #[test]
1993    fn test_min_measurements_two_with_no_tail() {
1994        // Test the minimum allowed min_measurements value (2) with no tail measurements.
1995        // This should skip the audit since we have 0 < 2 tail measurements.
1996        let result = audit_with_data(
1997            "test_measurement",
1998            15.0,   // head
1999            vec![], // no tail measurements
2000            2,      // min_count = 2 (minimum allowed by CLI)
2001            2.0,
2002            DispersionMethod::StandardDeviation,
2003            ReductionFunc::Min,
2004        );
2005
2006        assert!(result.is_ok());
2007        let audit_result = result.unwrap();
2008
2009        // Should pass (skipped) since we have 0 < 2 tail measurements
2010        assert!(audit_result.passed);
2011        assert!(audit_result.message.contains("Skipping test"));
2012        assert!(audit_result
2013            .message
2014            .contains("0 historical measurements found"));
2015        assert!(audit_result
2016            .message
2017            .contains("Less than requested min_measurements of 2"));
2018
2019        // Should show Head summary only (total_measurements = 1)
2020        assert!(audit_result.message.contains("Head:"));
2021        assert!(!audit_result.message.contains("z-score"));
2022        assert!(!audit_result.message.contains("Tail:"));
2023    }
2024
2025    #[test]
2026    fn test_min_measurements_two_with_single_tail() {
2027        // Test the minimum allowed min_measurements value (2) with a single tail measurement.
2028        // This should skip since we have 1 < 2 tail measurements.
2029        let result = audit_with_data(
2030            "test_measurement",
2031            15.0,       // head
2032            vec![10.0], // single tail measurement
2033            2,          // min_count = 2 (minimum allowed by CLI)
2034            2.0,
2035            DispersionMethod::StandardDeviation,
2036            ReductionFunc::Min,
2037        );
2038
2039        assert!(result.is_ok());
2040        let audit_result = result.unwrap();
2041
2042        // Should pass (skipped) since we have 1 < 2 tail measurements
2043        assert!(audit_result.passed);
2044        assert!(audit_result.message.contains("Skipping test"));
2045        assert!(audit_result
2046            .message
2047            .contains("1 historical measurement found"));
2048        assert!(audit_result
2049            .message
2050            .contains("Less than requested min_measurements of 2"));
2051
2052        // Should show both Head and Tail summaries with z-score (total_measurements = 2)
2053        assert!(audit_result.message.contains("Head:"));
2054        assert!(audit_result.message.contains("Tail:"));
2055        assert!(audit_result.message.contains("z-score"));
2056        assert!(audit_result.message.contains("["));
2057    }
2058
2059    #[test]
2060    fn test_aggregation_method_display_min() {
2061        // Test that the aggregation method is displayed correctly with ReductionFunc::Min
2062        let result = audit_with_data(
2063            "test_measurement",
2064            15.0,
2065            vec![10.0, 11.0, 12.0],
2066            2,
2067            2.0,
2068            DispersionMethod::StandardDeviation,
2069            ReductionFunc::Min,
2070        );
2071
2072        assert!(result.is_ok());
2073        let audit_result = result.unwrap();
2074        assert!(audit_result.message.contains("Aggregation: min"));
2075    }
2076
2077    #[test]
2078    fn test_aggregation_method_display_max() {
2079        // Test that the aggregation method is displayed correctly with ReductionFunc::Max
2080        let result = audit_with_data(
2081            "test_measurement",
2082            15.0,
2083            vec![10.0, 11.0, 12.0],
2084            2,
2085            2.0,
2086            DispersionMethod::StandardDeviation,
2087            ReductionFunc::Max,
2088        );
2089
2090        assert!(result.is_ok());
2091        let audit_result = result.unwrap();
2092        assert!(audit_result.message.contains("Aggregation: max"));
2093    }
2094
2095    #[test]
2096    fn test_aggregation_method_display_median() {
2097        // Test that the aggregation method is displayed correctly with ReductionFunc::Median
2098        let result = audit_with_data(
2099            "test_measurement",
2100            15.0,
2101            vec![10.0, 11.0, 12.0],
2102            2,
2103            2.0,
2104            DispersionMethod::StandardDeviation,
2105            ReductionFunc::Median,
2106        );
2107
2108        assert!(result.is_ok());
2109        let audit_result = result.unwrap();
2110        assert!(audit_result.message.contains("Aggregation: median"));
2111    }
2112
2113    #[test]
2114    fn test_aggregation_method_display_mean() {
2115        // Test that the aggregation method is displayed correctly with ReductionFunc::Mean
2116        let result = audit_with_data(
2117            "test_measurement",
2118            15.0,
2119            vec![10.0, 11.0, 12.0],
2120            2,
2121            2.0,
2122            DispersionMethod::StandardDeviation,
2123            ReductionFunc::Mean,
2124        );
2125
2126        assert!(result.is_ok());
2127        let audit_result = result.unwrap();
2128        assert!(audit_result.message.contains("Aggregation: mean"));
2129    }
2130
2131    #[test]
2132    fn test_aggregation_method_not_shown_with_single_measurement() {
2133        // Test that aggregation method is NOT shown when there's only 1 measurement
2134        let result = audit_with_data(
2135            "test_measurement",
2136            15.0,
2137            vec![], // No tail measurements, total = 1
2138            2,
2139            2.0,
2140            DispersionMethod::StandardDeviation,
2141            ReductionFunc::Median,
2142        );
2143
2144        assert!(result.is_ok());
2145        let audit_result = result.unwrap();
2146        // Should NOT show aggregation method (only 1 measurement total)
2147        assert!(!audit_result.message.contains("Aggregation:"));
2148        // But should show Head summary
2149        assert!(audit_result.message.contains("Head:"));
2150    }
2151}