Skip to main content

zeroclaw_eval/
report.rs

1//! Pass/fail aggregation and rendering.
2
3use crate::grader::GradeResult;
4
5/// The result of running a single eval case.
6#[derive(Debug)]
7pub struct CaseReport {
8    /// The trace's `model_name`.
9    pub name: String,
10    /// The fixture file name the case came from.
11    pub source: String,
12    /// Per-check grades.
13    pub grades: Vec<GradeResult>,
14    /// Set if the run itself errored (e.g. trace exhausted) — counts as a failure.
15    pub error: Option<String>,
16}
17
18impl CaseReport {
19    /// A case passes when it ran without error and every check passed.
20    pub fn passed(&self) -> bool {
21        self.error.is_none() && self.grades.iter().all(|g| g.passed)
22    }
23
24    fn checks_passed(&self) -> usize {
25        self.grades.iter().filter(|g| g.passed).count()
26    }
27}
28
29/// Aggregated results for a whole suite.
30#[derive(Debug)]
31pub struct SuiteReport {
32    pub cases: Vec<CaseReport>,
33}
34
35impl SuiteReport {
36    pub fn passed_count(&self) -> usize {
37        self.cases.iter().filter(|c| c.passed()).count()
38    }
39
40    pub fn failed_count(&self) -> usize {
41        self.cases.len() - self.passed_count()
42    }
43
44    pub fn all_passed(&self) -> bool {
45        self.cases.iter().all(CaseReport::passed)
46    }
47
48    /// Process exit code for a completed run: 0 iff every case passed.
49    /// Kept as a pure function so the CLI gate is testable at its real boundary.
50    pub fn exit_code(&self) -> i32 {
51        if self.all_passed() { 0 } else { 1 }
52    }
53
54    /// Render a human-readable table. Failing checks are listed beneath their case.
55    pub fn render_table(&self) -> String {
56        let mut s = String::new();
57        s.push('\n');
58        for case in &self.cases {
59            let icon = if case.passed() { "✓" } else { "✗" };
60            if let Some(err) = &case.error {
61                s.push_str(&format!(
62                    "  {icon} {} ({})  —  run error: {err}\n",
63                    case.name, case.source
64                ));
65                continue;
66            }
67            s.push_str(&format!(
68                "  {icon} {} ({})  {}/{} checks\n",
69                case.name,
70                case.source,
71                case.checks_passed(),
72                case.grades.len()
73            ));
74            for g in case.grades.iter().filter(|g| !g.passed) {
75                s.push_str(&format!("      ✗ {}: {}\n", g.check, g.detail));
76            }
77        }
78        s.push('\n');
79        s.push_str(&format!(
80            "  {}/{} cases passed",
81            self.passed_count(),
82            self.cases.len()
83        ));
84        if self.all_passed() {
85            s.push_str("  \u{2713}\n");
86        } else {
87            s.push_str(&format!("  ({} failed)\n", self.failed_count()));
88        }
89        s
90    }
91
92    /// Render the report as pretty JSON for machine consumption / CI artifacts.
93    pub fn to_json(&self) -> String {
94        let cases: Vec<serde_json::Value> = self
95            .cases
96            .iter()
97            .map(|c| {
98                serde_json::json!({
99                    "name": c.name,
100                    "source": c.source,
101                    "passed": c.passed(),
102                    "error": c.error,
103                    "grades": c.grades,
104                })
105            })
106            .collect();
107
108        let value = serde_json::json!({
109            "passed": self.passed_count(),
110            "failed": self.failed_count(),
111            "total": self.cases.len(),
112            "all_passed": self.all_passed(),
113            "cases": cases,
114        });
115        serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string())
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn grade(check: &str, passed: bool, detail: &str) -> GradeResult {
124        GradeResult {
125            check: check.to_string(),
126            passed,
127            detail: detail.to_string(),
128        }
129    }
130
131    fn case(name: &str, grades: Vec<GradeResult>, error: Option<&str>) -> CaseReport {
132        CaseReport {
133            name: name.to_string(),
134            source: "fixture.json".to_string(),
135            grades,
136            error: error.map(str::to_string),
137        }
138    }
139
140    #[test]
141    fn case_passes_only_when_no_error_and_all_checks_pass() {
142        assert!(
143            case(
144                "a",
145                vec![grade("c1", true, ""), grade("c2", true, "")],
146                None
147            )
148            .passed()
149        );
150        // One failing check fails the case.
151        assert!(
152            !case(
153                "a",
154                vec![grade("c1", true, ""), grade("c2", false, "")],
155                None
156            )
157            .passed()
158        );
159        // A run error fails the case even when every check passed.
160        assert!(!case("a", vec![grade("c1", true, "")], Some("trace exhausted")).passed());
161        // No checks and no error passes vacuously.
162        assert!(case("a", vec![], None).passed());
163    }
164
165    #[test]
166    fn suite_counts_reflect_per_case_pass_fail() {
167        let suite = SuiteReport {
168            cases: vec![
169                case("ok", vec![grade("c", true, "")], None),
170                case("bad", vec![grade("c", false, "")], None),
171                case("err", vec![], Some("boom")),
172            ],
173        };
174        assert_eq!(suite.passed_count(), 1);
175        assert_eq!(suite.failed_count(), 2);
176        assert!(!suite.all_passed());
177    }
178
179    #[test]
180    fn exit_code_is_zero_when_all_cases_pass() {
181        let suite = SuiteReport {
182            cases: vec![case("ok", vec![grade("c", true, "")], None)],
183        };
184        assert!(suite.all_passed());
185        assert_eq!(suite.exit_code(), 0);
186    }
187
188    #[test]
189    fn exit_code_is_one_when_any_case_fails() {
190        let suite = SuiteReport {
191            cases: vec![
192                case("ok", vec![grade("c", true, "")], None),
193                case("bad", vec![grade("c", false, "")], None),
194            ],
195        };
196        assert!(!suite.all_passed());
197        assert_eq!(suite.exit_code(), 1);
198    }
199
200    #[test]
201    fn empty_suite_passes_vacuously() {
202        let suite = SuiteReport { cases: vec![] };
203        assert_eq!(suite.passed_count(), 0);
204        assert_eq!(suite.failed_count(), 0);
205        assert!(suite.all_passed());
206    }
207
208    #[test]
209    fn render_table_marks_failures_and_lists_failing_checks() {
210        let suite = SuiteReport {
211            cases: vec![
212                case("ok", vec![grade("c", true, "")], None),
213                case(
214                    "bad",
215                    vec![grade("response_contains", false, "not found")],
216                    None,
217                ),
218            ],
219        };
220        let table = suite.render_table();
221        assert!(table.contains("✓ ok"));
222        assert!(table.contains("✗ bad"));
223        assert!(table.contains("response_contains: not found"));
224        assert!(table.contains("1/2 cases passed"));
225        assert!(table.contains("(1 failed)"));
226    }
227
228    #[test]
229    fn render_table_reports_run_errors() {
230        let suite = SuiteReport {
231            cases: vec![case("err", vec![], Some("trace exhausted"))],
232        };
233        let table = suite.render_table();
234        assert!(table.contains("run error: trace exhausted"));
235    }
236
237    #[test]
238    fn to_json_serializes_aggregate_and_cases() {
239        let suite = SuiteReport {
240            cases: vec![
241                case("ok", vec![grade("c", true, "")], None),
242                case("bad", vec![grade("c", false, "")], None),
243            ],
244        };
245        let json: serde_json::Value = serde_json::from_str(&suite.to_json()).unwrap();
246        assert_eq!(json["passed"].as_u64(), Some(1));
247        assert_eq!(json["failed"].as_u64(), Some(1));
248        assert_eq!(json["total"].as_u64(), Some(2));
249        assert_eq!(json["all_passed"].as_bool(), Some(false));
250        assert_eq!(json["cases"].as_array().unwrap().len(), 2);
251        assert_eq!(json["cases"][0]["name"].as_str(), Some("ok"));
252        assert_eq!(json["cases"][0]["passed"].as_bool(), Some(true));
253    }
254}