Skip to main content

harn_cli/
test_timing.rs

1use serde::Serialize;
2
3/// Aggregate wall-clock durations for a population of test samples.
4///
5/// Metrics are `None` when the population is empty so a serialized summary
6/// distinguishes "not measured" from an observed zero-millisecond duration.
7#[non_exhaustive]
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
9pub struct DurationSummary {
10    /// Number of measured samples.
11    pub sample_count: u64,
12    /// Integer mean, or `None` when there are no samples.
13    pub average_ms: Option<u64>,
14    /// Median sample using the pinned test-runner percentile convention.
15    pub p50_ms: Option<u64>,
16    /// 90th-percentile sample.
17    pub p90_ms: Option<u64>,
18    /// 95th-percentile sample.
19    pub p95_ms: Option<u64>,
20    /// 99th-percentile sample.
21    pub p99_ms: Option<u64>,
22}
23
24impl DurationSummary {
25    /// Summarize millisecond samples without mutating caller-owned data.
26    pub fn from_samples(samples_ms: &[u64]) -> Self {
27        if samples_ms.is_empty() {
28            return Self::default();
29        }
30
31        let mut sorted = samples_ms.to_vec();
32        sorted.sort_unstable();
33
34        let average_ms = sorted
35            .iter()
36            .map(|&sample| u128::from(sample))
37            .sum::<u128>()
38            / sorted.len() as u128;
39
40        Self {
41            sample_count: sorted.len() as u64,
42            average_ms: Some(average_ms as u64),
43            p50_ms: Some(indexed_percentile(&sorted, 50)),
44            p90_ms: Some(indexed_percentile(&sorted, 90)),
45            p95_ms: Some(indexed_percentile(&sorted, 95)),
46            p99_ms: Some(indexed_percentile(&sorted, 99)),
47        }
48    }
49}
50
51/// Preserve the test renderer's existing zero-based `n * percentile / 100`
52/// index semantics while avoiding intermediate multiplication overflow.
53fn indexed_percentile(sorted: &[u64], percentile: u8) -> u64 {
54    let index = (sorted.len() as u128 * u128::from(percentile) / 100) as usize;
55    sorted[index.min(sorted.len() - 1)]
56}
57
58#[cfg(test)]
59mod tests {
60    use super::DurationSummary;
61
62    #[test]
63    fn empty_samples_have_no_observed_metrics() {
64        assert_eq!(
65            DurationSummary::from_samples(&[]),
66            DurationSummary {
67                sample_count: 0,
68                average_ms: None,
69                p50_ms: None,
70                p90_ms: None,
71                p95_ms: None,
72                p99_ms: None,
73            }
74        );
75    }
76
77    #[test]
78    fn one_sample_populates_every_metric() {
79        assert_eq!(
80            DurationSummary::from_samples(&[17]),
81            DurationSummary {
82                sample_count: 1,
83                average_ms: Some(17),
84                p50_ms: Some(17),
85                p90_ms: Some(17),
86                p95_ms: Some(17),
87                p99_ms: Some(17),
88            }
89        );
90    }
91
92    #[test]
93    fn even_population_pins_existing_renderer_indices() {
94        assert_eq!(
95            DurationSummary::from_samples(&[10, 20, 30, 40]),
96            DurationSummary {
97                sample_count: 4,
98                average_ms: Some(25),
99                p50_ms: Some(30),
100                p90_ms: Some(40),
101                p95_ms: Some(40),
102                p99_ms: Some(40),
103            }
104        );
105    }
106
107    #[test]
108    fn input_order_does_not_change_summary() {
109        let ordered = DurationSummary::from_samples(&[1, 2, 3, 4, 5]);
110        let shuffled = DurationSummary::from_samples(&[5, 2, 4, 1, 3]);
111
112        assert_eq!(shuffled, ordered);
113    }
114
115    #[test]
116    fn average_does_not_overflow_u64() {
117        let summary = DurationSummary::from_samples(&[u64::MAX, u64::MAX]);
118
119        assert_eq!(summary.average_ms, Some(u64::MAX));
120    }
121}