1use crate::grader::GradeResult;
4
5#[derive(Debug)]
7pub struct CaseReport {
8 pub name: String,
10 pub source: String,
12 pub grades: Vec<GradeResult>,
14 pub error: Option<String>,
16}
17
18impl CaseReport {
19 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#[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 pub fn exit_code(&self) -> i32 {
51 if self.all_passed() { 0 } else { 1 }
52 }
53
54 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 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 assert!(
152 !case(
153 "a",
154 vec![grade("c1", true, ""), grade("c2", false, "")],
155 None
156 )
157 .passed()
158 );
159 assert!(!case("a", vec![grade("c1", true, "")], Some("trace exhausted")).passed());
161 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}