Skip to main content

exspec_core/
metrics.rs

1use serde::Serialize;
2
3use crate::extractor::FileAnalysis;
4
5/// Project-wide metrics computed from all file analyses.
6#[derive(Debug, Clone, Default, Serialize)]
7pub struct ProjectMetrics {
8    pub mock_density_avg: f64,
9    pub mock_class_max: usize,
10    pub parameterized_ratio: f64,
11    pub pbt_ratio: f64,
12    pub assertion_density_avg: f64,
13    pub contract_coverage: f64,
14    pub test_source_ratio: f64,
15}
16
17pub fn compute_metrics(analyses: &[FileAnalysis], source_file_count: usize) -> ProjectMetrics {
18    let total_files = analyses.len();
19    let total_functions: usize = analyses.iter().map(|a| a.functions.len()).sum();
20
21    if total_functions == 0 {
22        let pbt_ratio = if total_files > 0 {
23            analyses.iter().filter(|a| a.has_pbt_import).count() as f64 / total_files as f64
24        } else {
25            0.0
26        };
27        let contract_coverage = if total_files > 0 {
28            analyses.iter().filter(|a| a.has_contract_import).count() as f64 / total_files as f64
29        } else {
30            0.0
31        };
32        let test_source_ratio = if source_file_count > 0 {
33            total_files as f64 / source_file_count as f64
34        } else {
35            0.0
36        };
37        return ProjectMetrics {
38            pbt_ratio,
39            contract_coverage,
40            test_source_ratio,
41            ..Default::default()
42        };
43    }
44
45    let all_funcs: Vec<_> = analyses.iter().flat_map(|a| &a.functions).collect();
46
47    let mock_density_avg = all_funcs
48        .iter()
49        .map(|f| f.analysis.mock_count)
50        .sum::<usize>() as f64
51        / total_functions as f64;
52    let mock_class_max = all_funcs
53        .iter()
54        .map(|f| f.analysis.mock_classes.len())
55        .max()
56        .unwrap_or(0);
57    let total_parameterized: usize = analyses.iter().map(|a| a.parameterized_count).sum();
58    let parameterized_ratio = total_parameterized as f64 / total_functions as f64;
59    let pbt_ratio =
60        analyses.iter().filter(|a| a.has_pbt_import).count() as f64 / total_files as f64;
61    let assertion_density_avg = all_funcs
62        .iter()
63        .map(|f| f.analysis.assertion_count)
64        .sum::<usize>() as f64
65        / total_functions as f64;
66    let contract_coverage =
67        analyses.iter().filter(|a| a.has_contract_import).count() as f64 / total_files as f64;
68    let test_source_ratio = if source_file_count > 0 {
69        total_files as f64 / source_file_count as f64
70    } else {
71        0.0
72    };
73
74    ProjectMetrics {
75        mock_density_avg,
76        mock_class_max,
77        parameterized_ratio,
78        pbt_ratio,
79        assertion_density_avg,
80        contract_coverage,
81        test_source_ratio,
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::extractor::{TestAnalysis, TestFunction};
89
90    fn make_func(
91        name: &str,
92        file: &str,
93        assertion_count: usize,
94        mock_count: usize,
95        mock_classes: Vec<String>,
96    ) -> TestFunction {
97        TestFunction {
98            name: name.to_string(),
99            file: file.to_string(),
100            line: 1,
101            end_line: 10,
102            analysis: TestAnalysis {
103                assertion_count,
104                mock_count,
105                mock_classes,
106                ..Default::default()
107            },
108        }
109    }
110
111    fn make_analysis(
112        file: &str,
113        functions: Vec<TestFunction>,
114        has_pbt: bool,
115        has_contract: bool,
116        parameterized: usize,
117    ) -> FileAnalysis {
118        FileAnalysis {
119            file: file.to_string(),
120            functions,
121            has_pbt_import: has_pbt,
122            has_contract_import: has_contract,
123            has_error_test: false,
124            has_relational_assertion: false,
125            parameterized_count: parameterized,
126        }
127    }
128
129    #[test]
130    fn empty_analyses_returns_all_zeros() {
131        let m = compute_metrics(&[], 0);
132        assert_eq!(m.mock_density_avg, 0.0);
133        assert_eq!(m.mock_class_max, 0);
134        assert_eq!(m.parameterized_ratio, 0.0);
135        assert_eq!(m.pbt_ratio, 0.0);
136        assert_eq!(m.assertion_density_avg, 0.0);
137        assert_eq!(m.contract_coverage, 0.0);
138        assert_eq!(m.test_source_ratio, 0.0);
139    }
140
141    #[test]
142    fn single_file_single_function_correct_values() {
143        let funcs = vec![make_func("test_a", "test.py", 3, 2, vec!["Db".into()])];
144        let analyses = vec![make_analysis("test.py", funcs, true, true, 1)];
145        let m = compute_metrics(&analyses, 5);
146        assert_eq!(m.mock_density_avg, 2.0);
147        assert_eq!(m.mock_class_max, 1);
148        assert_eq!(m.parameterized_ratio, 1.0); // 1/1
149        assert_eq!(m.pbt_ratio, 1.0); // 1/1
150        assert_eq!(m.assertion_density_avg, 3.0);
151        assert_eq!(m.contract_coverage, 1.0); // 1/1
152        assert!((m.test_source_ratio - 0.2).abs() < f64::EPSILON); // 1/5
153    }
154
155    #[test]
156    fn multiple_files_proper_aggregation() {
157        let funcs1 = vec![
158            make_func("test_a", "a.py", 2, 4, vec!["Db".into(), "Api".into()]),
159            make_func("test_b", "a.py", 1, 0, vec![]),
160        ];
161        let funcs2 = vec![make_func("test_c", "b.py", 3, 2, vec!["Cache".into()])];
162        let analyses = vec![
163            make_analysis("a.py", funcs1, true, false, 1),
164            make_analysis("b.py", funcs2, false, true, 0),
165        ];
166        let m = compute_metrics(&analyses, 4);
167        // mock_density_avg: (4+0+2)/3 = 2.0
168        assert_eq!(m.mock_density_avg, 2.0);
169        // mock_class_max: max(2, 0, 1) = 2
170        assert_eq!(m.mock_class_max, 2);
171        // parameterized_ratio: (1+0)/3 = 0.333...
172        assert!((m.parameterized_ratio - 1.0 / 3.0).abs() < 0.001);
173        // pbt_ratio: 1/2 = 0.5
174        assert_eq!(m.pbt_ratio, 0.5);
175        // assertion_density_avg: (2+1+3)/3 = 2.0
176        assert_eq!(m.assertion_density_avg, 2.0);
177        // contract_coverage: 1/2 = 0.5
178        assert_eq!(m.contract_coverage, 0.5);
179        // test_source_ratio: 2/4 = 0.5
180        assert_eq!(m.test_source_ratio, 0.5);
181    }
182
183    #[test]
184    fn zero_source_files_test_source_ratio_zero() {
185        let funcs = vec![make_func("test_a", "test.py", 1, 0, vec![])];
186        let analyses = vec![make_analysis("test.py", funcs, false, false, 0)];
187        let m = compute_metrics(&analyses, 0);
188        assert_eq!(m.test_source_ratio, 0.0);
189    }
190
191    #[test]
192    fn zero_functions_all_ratios_zero() {
193        let analyses = vec![make_analysis("test.py", vec![], false, false, 0)];
194        let m = compute_metrics(&analyses, 5);
195        assert_eq!(m.mock_density_avg, 0.0);
196        assert_eq!(m.mock_class_max, 0);
197        assert_eq!(m.parameterized_ratio, 0.0);
198        assert_eq!(m.assertion_density_avg, 0.0);
199        // pbt/contract are file-level
200        assert_eq!(m.pbt_ratio, 0.0); // 0/1
201        assert_eq!(m.contract_coverage, 0.0); // 0/1
202    }
203
204    #[test]
205    fn mock_class_max_is_per_function_max() {
206        let funcs = vec![
207            make_func(
208                "test_a",
209                "test.py",
210                1,
211                3,
212                vec!["A".into(), "B".into(), "C".into()],
213            ),
214            make_func("test_b", "test.py", 1, 1, vec!["D".into()]),
215        ];
216        let analyses = vec![make_analysis("test.py", funcs, false, false, 0)];
217        let m = compute_metrics(&analyses, 1);
218        assert_eq!(m.mock_class_max, 3); // max(3, 1)
219    }
220
221    #[test]
222    fn parameterized_ratio_sums_across_files() {
223        let funcs1 = vec![
224            make_func("test_a", "a.py", 1, 0, vec![]),
225            make_func("test_b", "a.py", 1, 0, vec![]),
226        ];
227        let funcs2 = vec![make_func("test_c", "b.py", 1, 0, vec![])];
228        let analyses = vec![
229            make_analysis("a.py", funcs1, false, false, 1),
230            make_analysis("b.py", funcs2, false, false, 1),
231        ];
232        let m = compute_metrics(&analyses, 1);
233        // (1+1)/3 = 0.666...
234        assert!((m.parameterized_ratio - 2.0 / 3.0).abs() < 0.001);
235    }
236
237    #[test]
238    fn pbt_contract_count_files_with_import() {
239        let analyses = vec![
240            make_analysis(
241                "a.py",
242                vec![make_func("t", "a.py", 1, 0, vec![])],
243                true,
244                false,
245                0,
246            ),
247            make_analysis(
248                "b.py",
249                vec![make_func("t", "b.py", 1, 0, vec![])],
250                false,
251                true,
252                0,
253            ),
254            make_analysis(
255                "c.py",
256                vec![make_func("t", "c.py", 1, 0, vec![])],
257                true,
258                true,
259                0,
260            ),
261        ];
262        let m = compute_metrics(&analyses, 1);
263        // pbt: 2/3
264        assert!((m.pbt_ratio - 2.0 / 3.0).abs() < 0.001);
265        // contract: 2/3
266        assert!((m.contract_coverage - 2.0 / 3.0).abs() < 0.001);
267    }
268}