//! Test report formatting — human-readable, JSON, and JUnit XML output. use crate::types::{TestResult, TestStatus}; /// Aggregated report for a set of test runs. pub struct TestReport { pub total: u32, pub passed: u32, pub failed: u32, pub skipped: u32, pub errors: u32, pub duration_ms: u64, pub results: Vec, } impl TestReport { /// Build a report from a slice of individual test results. pub fn from_results(results: Vec) -> Self { let total = results.len() as u32; let passed = results.iter().filter(|r| r.status == TestStatus::Pass).count() as u32; let failed = results.iter().filter(|r| r.status == TestStatus::Fail).count() as u32; let skipped = results.iter().filter(|r| r.status == TestStatus::Skip).count() as u32; let errors = results.iter().filter(|r| r.status == TestStatus::Error).count() as u32; let duration_ms = results.iter().map(|r| r.duration_ms).sum(); Self { total, passed, failed, skipped, errors, duration_ms, results } } /// Print a human-readable summary to stdout. pub fn print(&self) { let target_label = format!("({}ms total)", self.duration_ms); println!("\nRunning {} tests...\n", self.total); for r in &self.results { let icon = match r.status { TestStatus::Pass => " ok ", TestStatus::Fail => " FAIL ", TestStatus::Skip => " SKIP ", TestStatus::Error => "ERROR ", }; println!(" [{icon}] {} ({}ms)", r.name, r.duration_ms); // Show failing assertions if r.status == TestStatus::Fail { for (i, a) in r.assertions.iter().enumerate() { if !a.passed { println!(" assert {}", a.expression); if let Some(actual) = &a.actual { println!(" actual: {actual}"); } if let Some(expected) = &a.expected { println!(" expected: {expected}"); } println!(" at assertion {}", i + 1); } } } if let Some(err) = &r.error { println!(" error: {err}"); } } println!("\nResults: {} passed, {} failed, {} skipped {}", self.passed, self.failed, self.skipped, target_label); if self.errors > 0 { println!(" ({} error(s) — see above)", self.errors); } } /// Serialize to JSON. pub fn to_json(&self) -> String { let results: Vec = self .results .iter() .map(|r| { let assertions: Vec = r .assertions .iter() .map(|a| { serde_json::json!({ "expression": a.expression, "passed": a.passed, "actual": a.actual, "expected": a.expected, }) }) .collect(); serde_json::json!({ "name": r.name, "target": r.target.to_string(), "status": r.status.to_string(), "duration_ms": r.duration_ms, "assertions": assertions, "error": r.error, }) }) .collect(); let report = serde_json::json!({ "total": self.total, "passed": self.passed, "failed": self.failed, "skipped": self.skipped, "errors": self.errors, "duration_ms": self.duration_ms, "results": results, }); serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string()) } /// Serialize to JUnit XML (for CI integration). pub fn to_junit_xml(&self) -> String { let mut xml = String::new(); xml.push_str("\n"); xml.push_str(&format!( "\n", self.total, self.failed, self.errors, self.skipped, self.duration_ms as f64 / 1000.0, )); for r in &self.results { let classname = "el_test"; let time = r.duration_ms as f64 / 1000.0; let name_escaped = xml_escape(&r.name); match r.status { TestStatus::Pass => { xml.push_str(&format!( " \n" )); } TestStatus::Fail => { xml.push_str(&format!( " \n" )); for a in r.assertions.iter().filter(|a| !a.passed) { let msg = xml_escape(&a.expression); let details = match (&a.actual, &a.expected) { (Some(act), Some(exp)) => format!("actual: {act}, expected: {exp}"), (Some(act), None) => format!("actual: {act}"), _ => "assertion failed".to_string(), }; let details_esc = xml_escape(&details); xml.push_str(&format!( " {details_esc}\n" )); } xml.push_str(" \n"); } TestStatus::Skip => { xml.push_str(&format!( " \n \n \n" )); } TestStatus::Error => { let err_msg = xml_escape(r.error.as_deref().unwrap_or("unknown error")); xml.push_str(&format!( " \n \n \n" )); } } } xml.push_str("\n"); xml } /// Whether the overall test run passed (no failures or errors). pub fn is_pass(&self) -> bool { self.failed == 0 && self.errors == 0 } } fn xml_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } #[cfg(test)] mod tests { use super::*; use crate::types::{AssertionResult, TestTarget}; fn make_pass(name: &str) -> TestResult { TestResult { name: name.to_string(), target: TestTarget::Unit, status: TestStatus::Pass, duration_ms: 5, assertions: vec![AssertionResult { expression: "x == 42".into(), passed: true, actual: Some("true".into()), expected: None, }], error: None, } } fn make_fail(name: &str) -> TestResult { TestResult { name: name.to_string(), target: TestTarget::Unit, status: TestStatus::Fail, duration_ms: 3, assertions: vec![AssertionResult { expression: "x == 99".into(), passed: false, actual: Some("42".into()), expected: Some("99".into()), }], error: None, } } fn make_skip(name: &str) -> TestResult { TestResult { name: name.to_string(), target: TestTarget::E2e, status: TestStatus::Skip, duration_ms: 0, assertions: vec![], error: Some("ENGRAM_URL not set".into()), } } #[test] fn test_report_counts() { let report = TestReport::from_results(vec![ make_pass("a"), make_fail("b"), make_skip("c"), ]); assert_eq!(report.total, 3); assert_eq!(report.passed, 1); assert_eq!(report.failed, 1); assert_eq!(report.skipped, 1); } #[test] fn test_report_is_pass() { let report = TestReport::from_results(vec![make_pass("a"), make_pass("b")]); assert!(report.is_pass()); } #[test] fn test_report_is_not_pass_on_fail() { let report = TestReport::from_results(vec![make_pass("a"), make_fail("b")]); assert!(!report.is_pass()); } #[test] fn test_to_json_valid() { let report = TestReport::from_results(vec![make_pass("test")]); let json = report.to_json(); let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); assert_eq!(parsed["total"], 1); assert_eq!(parsed["passed"], 1); } #[test] fn test_to_json_contains_results() { let report = TestReport::from_results(vec![make_pass("hello")]); let json = report.to_json(); assert!(json.contains("hello")); } #[test] fn test_to_junit_xml_valid() { let report = TestReport::from_results(vec![make_pass("a"), make_fail("b")]); let xml = report.to_junit_xml(); assert!(xml.starts_with("")); } #[test] fn test_to_junit_xml_pass_testcase() { let report = TestReport::from_results(vec![make_pass("arithmetic")]); let xml = report.to_junit_xml(); assert!(xml.contains("arithmetic")); assert!(!xml.contains(" d \"e\" 'f'"); assert!(escaped.contains("<")); assert!(escaped.contains("&")); assert!(escaped.contains(">")); assert!(escaped.contains(""")); } }