victauri_test/
reporting.rs1use std::time::Duration;
7
8use crate::assertions::{CheckResult, VerifyReport};
9
10#[derive(Debug)]
12pub struct JunitReport {
13 pub name: String,
15 pub duration: Duration,
17 pub test_cases: Vec<JunitTestCase>,
19}
20
21#[derive(Debug)]
23pub struct JunitTestCase {
24 pub name: String,
26 pub classname: String,
28 pub duration: Duration,
30 pub failure: Option<JunitFailure>,
32}
33
34#[derive(Debug)]
36pub struct JunitFailure {
37 pub failure_type: String,
39 pub message: String,
41}
42
43impl JunitReport {
44 #[must_use]
46 pub fn from_verify_report(report: &VerifyReport, suite_name: &str, duration: Duration) -> Self {
47 let per_case = if report.results.is_empty() {
48 Duration::ZERO
49 } else {
50 duration / report.results.len() as u32
51 };
52
53 let test_cases = report
54 .results
55 .iter()
56 .map(|r| JunitTestCase::from_check_result(r, suite_name, per_case))
57 .collect();
58
59 Self {
60 name: suite_name.to_string(),
61 duration,
62 test_cases,
63 }
64 }
65
66 #[must_use]
68 pub fn to_xml(&self) -> String {
69 let tests = self.test_cases.len();
70 let failures = self
71 .test_cases
72 .iter()
73 .filter(|t| t.failure.is_some())
74 .count();
75 let time = format_duration(self.duration);
76 let name = xml_escape(&self.name);
77
78 let mut xml = String::with_capacity(1024);
79 xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
80 xml.push_str(&format!(
81 "<testsuite name=\"{name}\" tests=\"{tests}\" failures=\"{failures}\" errors=\"0\" time=\"{time}\">\n"
82 ));
83
84 for tc in &self.test_cases {
85 let tc_name = xml_escape(&tc.name);
86 let classname = xml_escape(&tc.classname);
87 let tc_time = format_duration(tc.duration);
88
89 if let Some(failure) = &tc.failure {
90 let ftype = xml_escape(&failure.failure_type);
91 let msg = xml_escape(&failure.message);
92 xml.push_str(&format!(
93 " <testcase name=\"{tc_name}\" classname=\"{classname}\" time=\"{tc_time}\">\n"
94 ));
95 xml.push_str(&format!(
96 " <failure type=\"{ftype}\" message=\"{msg}\" />\n"
97 ));
98 xml.push_str(" </testcase>\n");
99 } else {
100 xml.push_str(&format!(
101 " <testcase name=\"{tc_name}\" classname=\"{classname}\" time=\"{tc_time}\" />\n"
102 ));
103 }
104 }
105
106 xml.push_str("</testsuite>\n");
107 xml
108 }
109}
110
111impl JunitTestCase {
112 #[must_use]
114 pub fn from_check_result(result: &CheckResult, classname: &str, duration: Duration) -> Self {
115 let failure = if result.passed {
116 None
117 } else {
118 Some(JunitFailure {
119 failure_type: "AssertionError".to_string(),
120 message: result.detail.clone(),
121 })
122 };
123
124 Self {
125 name: result.description.clone(),
126 classname: classname.to_string(),
127 duration,
128 failure,
129 }
130 }
131}
132
133pub fn write_junit_report(report: &JunitReport, path: &std::path::Path) -> std::io::Result<()> {
139 std::fs::write(path, report.to_xml())
140}
141
142fn format_duration(d: Duration) -> String {
143 format!("{:.3}", d.as_secs_f64())
144}
145
146fn xml_escape(s: &str) -> String {
147 let mut out = String::with_capacity(s.len());
148 for ch in s.chars() {
149 match ch {
150 '&' => out.push_str("&"),
151 '<' => out.push_str("<"),
152 '>' => out.push_str(">"),
153 '"' => out.push_str("""),
154 '\'' => out.push_str("'"),
155 other => out.push(other),
156 }
157 }
158 out
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 fn pass_result(desc: &str) -> CheckResult {
166 CheckResult {
167 description: desc.to_string(),
168 passed: true,
169 detail: String::new(),
170 }
171 }
172
173 fn fail_result(desc: &str, detail: &str) -> CheckResult {
174 CheckResult {
175 description: desc.to_string(),
176 passed: false,
177 detail: detail.to_string(),
178 }
179 }
180
181 #[test]
182 fn empty_report_produces_valid_xml() {
183 let report = VerifyReport { results: vec![] };
184 let junit = JunitReport::from_verify_report(&report, "smoke", Duration::from_millis(100));
185 let xml = junit.to_xml();
186
187 assert!(xml.contains("<?xml version=\"1.0\""));
188 assert!(xml.contains("tests=\"0\""));
189 assert!(xml.contains("failures=\"0\""));
190 assert!(xml.contains("</testsuite>"));
191 }
192
193 #[test]
194 fn passing_checks_have_no_failure_element() {
195 let report = VerifyReport {
196 results: vec![pass_result("ipc healthy"), pass_result("no console errors")],
197 };
198 let junit = JunitReport::from_verify_report(&report, "health", Duration::from_secs(1));
199 let xml = junit.to_xml();
200
201 assert!(xml.contains("tests=\"2\""));
202 assert!(xml.contains("failures=\"0\""));
203 assert!(!xml.contains("<failure"));
204 assert!(xml.contains("ipc healthy"));
205 assert!(xml.contains("no console errors"));
206 }
207
208 #[test]
209 fn failing_check_includes_failure_element() {
210 let report = VerifyReport {
211 results: vec![
212 pass_result("connected"),
213 fail_result("no ghost commands", "found 3 ghost commands"),
214 ],
215 };
216 let junit = JunitReport::from_verify_report(&report, "ghosts", Duration::from_secs(2));
217 let xml = junit.to_xml();
218
219 assert!(xml.contains("tests=\"2\""));
220 assert!(xml.contains("failures=\"1\""));
221 assert!(xml.contains("<failure type=\"AssertionError\""));
222 assert!(xml.contains("found 3 ghost commands"));
223 }
224
225 #[test]
226 fn xml_escapes_special_chars() {
227 let report = VerifyReport {
228 results: vec![fail_result("value <\"test\"> & 'check'", "a & b < c > d")],
229 };
230 let junit = JunitReport::from_verify_report(&report, "escape", Duration::from_millis(50));
231 let xml = junit.to_xml();
232
233 assert!(xml.contains("<"));
234 assert!(xml.contains(">"));
235 assert!(xml.contains("&"));
236 assert!(xml.contains("""));
237 assert!(xml.contains("'"));
238 }
239
240 #[test]
241 fn from_verify_report_distributes_duration() {
242 let report = VerifyReport {
243 results: vec![pass_result("a"), pass_result("b")],
244 };
245 let junit = JunitReport::from_verify_report(&report, "suite", Duration::from_secs(4));
246
247 assert_eq!(junit.test_cases.len(), 2);
248 assert_eq!(junit.test_cases[0].duration, Duration::from_secs(2));
249 assert_eq!(junit.test_cases[1].duration, Duration::from_secs(2));
250 }
251
252 #[test]
253 fn write_junit_report_creates_file() {
254 let dir = tempfile::tempdir().unwrap();
255 let path = dir.path().join("results.xml");
256
257 let report = VerifyReport {
258 results: vec![pass_result("works")],
259 };
260 let junit = JunitReport::from_verify_report(&report, "test", Duration::from_millis(100));
261 write_junit_report(&junit, &path).unwrap();
262
263 let content = std::fs::read_to_string(&path).unwrap();
264 assert!(content.contains("<?xml version=\"1.0\""));
265 assert!(content.contains("works"));
266 }
267}