This repository has been archived on 2026-08-20. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
el-retired/_archive/rust-bootstrap/engrams/el-test/src/report.rs
T

320 lines
11 KiB
Rust

//! 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<TestResult>,
}
impl TestReport {
/// Build a report from a slice of individual test results.
pub fn from_results(results: Vec<TestResult>) -> 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<serde_json::Value> = self
.results
.iter()
.map(|r| {
let assertions: Vec<serde_json::Value> = 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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str(&format!(
"<testsuite name=\"el\" tests=\"{}\" failures=\"{}\" errors=\"{}\" skipped=\"{}\" time=\"{}\">\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!(
" <testcase classname=\"{classname}\" name=\"{name_escaped}\" time=\"{time:.3}\"/>\n"
));
}
TestStatus::Fail => {
xml.push_str(&format!(
" <testcase classname=\"{classname}\" name=\"{name_escaped}\" time=\"{time:.3}\">\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!(
" <failure message=\"{msg}\">{details_esc}</failure>\n"
));
}
xml.push_str(" </testcase>\n");
}
TestStatus::Skip => {
xml.push_str(&format!(
" <testcase classname=\"{classname}\" name=\"{name_escaped}\" time=\"{time:.3}\">\n <skipped/>\n </testcase>\n"
));
}
TestStatus::Error => {
let err_msg = xml_escape(r.error.as_deref().unwrap_or("unknown error"));
xml.push_str(&format!(
" <testcase classname=\"{classname}\" name=\"{name_escaped}\" time=\"{time:.3}\">\n <error message=\"{err_msg}\"/>\n </testcase>\n"
));
}
}
}
xml.push_str("</testsuite>\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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[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("<?xml"));
assert!(xml.contains("<testsuite"));
assert!(xml.contains("</testsuite>"));
}
#[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("<failure"));
}
#[test]
fn test_to_junit_xml_fail_testcase() {
let report = TestReport::from_results(vec![make_fail("failing")]);
let xml = report.to_junit_xml();
assert!(xml.contains("<failure"));
}
#[test]
fn test_to_junit_xml_skipped() {
let report = TestReport::from_results(vec![make_skip("e2e")]);
let xml = report.to_junit_xml();
assert!(xml.contains("<skipped"));
}
#[test]
fn test_duration_sum() {
let report = TestReport::from_results(vec![make_pass("a"), make_pass("b")]);
assert_eq!(report.duration_ms, 10); // 5 + 5
}
#[test]
fn test_xml_escape() {
let escaped = super::xml_escape("a < b & c > d \"e\" 'f'");
assert!(escaped.contains("&lt;"));
assert!(escaped.contains("&amp;"));
assert!(escaped.contains("&gt;"));
assert!(escaped.contains("&quot;"));
}
}