use std::fmt::Write;
use crate::core::{TestKind, TestRun, TestStatus, TestSuite};
pub trait TestReporter {
fn report(&self, run: &TestRun) -> String;
}
pub struct PrettyReporter {
use_colour: bool,
}
impl PrettyReporter {
pub fn new() -> Self {
PrettyReporter { use_colour: true }
}
pub fn colour(mut self, enabled: bool) -> Self {
self.use_colour = enabled;
self
}
}
impl Default for PrettyReporter {
fn default() -> Self {
Self::new()
}
}
impl TestReporter for PrettyReporter {
fn report(&self, run: &TestRun) -> String {
let mut out = String::new();
for suite in &run.suites {
if suite.tests.is_empty() {
continue;
}
let show_header = run.suites.len() > 1;
if show_header {
let label = section_label(suite, self.use_colour);
let _ = writeln!(out, "\n {}", label);
}
for test in &suite.tests {
let icon = if suite.is_doc() {
doc_icon(self.use_colour)
} else {
status_icon(&test.status, self.use_colour)
};
let name = test.name.replace(" :: ", " > ");
let dur = format_duration(test.duration);
let _ = writeln!(out, " {icon} {name} ({dur})");
if let TestStatus::Failed { ref reason, ref location } = test.status {
for line in reason.lines() {
let _ = writeln!(out, " {} {}", dim("→", self.use_colour), line);
}
if let Some(loc) = location {
let _ = writeln!(out, " {} {}", dim("at", self.use_colour), format_location(loc, self.use_colour));
}
}
if let TestStatus::TimedOut { duration, ref location } = test.status {
let _ = writeln!(out, " {} timed out after {duration:?}", dim("→", self.use_colour));
if let Some(loc) = location {
let _ = writeln!(out, " {} {}", dim("at", self.use_colour), format_location(loc, self.use_colour));
}
}
if let Some(ref captured) = test.captured_output {
for line in captured.lines() {
let _ = writeln!(out, " {} {}", dim("│", self.use_colour), line);
}
}
if !suite.is_doc() {
if let TestStatus::Skipped { ref reason } = test.status {
if let Some(r) = reason {
let _ = writeln!(out, " {} skipped: {}", dim("→", self.use_colour), r);
}
}
}
}
}
let passed = run.total_passed();
let failed = run.total_failed();
let dur_s = run.duration.as_secs_f64();
let doc_count: usize = run.suites.iter().filter(|s| s.is_doc()).map(|s| s.tests.len()).sum();
let _ = writeln!(out);
let summary = build_summary(passed, failed, doc_count, self.use_colour);
let _ = writeln!(out, " Tests {}", summary);
let _ = writeln!(out, " Time {dur_s:.2}s");
out
}
}
fn section_label(suite: &TestSuite, colour: bool) -> String {
let label = match suite.kind {
TestKind::Unit => format!("unit tests ({})", suite.source_path),
TestKind::Integration => format!("integration ({})", suite.source_path),
TestKind::Doc => format!("doc-tests ({})", suite.source_path),
};
let padded = format!(" {} ", label);
let width = 54usize.saturating_sub(padded.chars().count());
let left = width / 2;
let right = width - left;
let mut line = String::new();
for _ in 0..left { line.push('─'); }
line.push_str(&padded);
for _ in 0..right { line.push('─'); }
dim(&line, colour)
}
fn doc_icon(colour: bool) -> String {
coloured("?", "33", colour)
}
fn status_icon(status: &TestStatus, colour: bool) -> String {
match status {
TestStatus::Passed => coloured("✓", "32", colour),
TestStatus::Failed { .. } => coloured("✗", "31", colour),
TestStatus::Skipped { .. } => coloured("–", "33", colour),
TestStatus::TimedOut { .. } => coloured("⊗", "31", colour),
}
}
fn build_summary(passed: usize, failed: usize, doc_count: usize, colour: bool) -> String {
let mut parts: Vec<String> = Vec::new();
parts.push(coloured_count(passed, "passed", "32", colour));
if failed > 0 {
parts.push(coloured_count(failed, "failed", "31", colour));
}
if doc_count > 0 {
parts.push(coloured_count(doc_count, "doc-tests", "33", colour));
}
parts.join(", ")
}
pub fn format_duration(d: std::time::Duration) -> String {
let secs = d.as_secs_f64();
if secs >= 1.0 {
format!("{secs:.2}s")
} else {
format!("{:.1}ms", secs * 1000.0)
}
}
fn format_location(loc: &crate::core::SourceLocation, colour: bool) -> String {
let s = match loc.column {
Some(col) => format!("{}:{}:{}", loc.file, loc.line, col),
None => format!("{}:{}", loc.file, loc.line),
};
coloured(&s, "36", colour)
}
fn coloured(s: &str, code: &str, enabled: bool) -> String {
if enabled {
format!("\x1b[{code}m{s}\x1b[0m")
} else {
s.to_owned()
}
}
fn dim(s: &str, enabled: bool) -> String {
coloured(s, "2", enabled)
}
fn coloured_count(n: usize, label: &str, colour_code: &str, enabled: bool) -> String {
format!("{} {}", coloured(&n.to_string(), colour_code, enabled), label)
}
pub struct TapReporter;
impl TestReporter for TapReporter {
fn report(&self, run: &TestRun) -> String {
let mut out = String::new();
let total = run.total();
let _ = writeln!(out, "1..{total}");
let mut index = 0;
for suite in &run.suites {
for test in &suite.tests {
index += 1;
let ok = if test.status.is_passed() { "ok" } else { "not ok" };
let duration_ms = test.duration.as_secs_f64() * 1000.0;
let _ = writeln!(out, "{ok} {index} - {} [{duration_ms:.1}ms]", test.name);
if let TestStatus::Failed { ref reason, .. } = test.status {
for line in reason.lines() {
let _ = writeln!(out, " {line}");
}
}
if let TestStatus::TimedOut { duration, .. } = test.status {
let _ = writeln!(out, " # TIMEOUT after {duration:?}");
}
if let TestStatus::Skipped { ref reason } = test.status {
let reason = reason.as_deref().unwrap_or("no reason given");
let _ = writeln!(out, " # SKIP {reason}");
}
}
}
out
}
}
pub struct JunitReporter {
suite_name: String,
}
impl JunitReporter {
pub fn new() -> Self {
JunitReporter { suite_name: "rvtest".to_owned() }
}
pub fn suite_name(mut self, name: &str) -> Self {
self.suite_name = name.to_owned();
self
}
}
impl Default for JunitReporter {
fn default() -> Self {
Self::new()
}
}
impl TestReporter for JunitReporter {
fn report(&self, run: &TestRun) -> String {
let mut out = String::new();
let _ = writeln!(out, r#"<?xml version="1.0" encoding="UTF-8"?>"#);
let _ = writeln!(
out,
r#"<testsuites name="{}" tests="{}" failures="{}" skipped="{}" time="{:.3}">"#,
self.suite_name,
run.total(),
run.total_failed(),
run.total_skipped(),
run.duration.as_secs_f64(),
);
for suite in &run.suites {
let _ = writeln!(
out,
r#" <testsuite name="{}" tests="{}" failures="{}" skipped="{}" time="{:.3}">"#,
escape_xml(&suite.name),
suite.len(),
suite.failed().count(),
suite.skipped().count(),
suite.duration.as_secs_f64(),
);
for test in &suite.tests {
let classname = test.suite.as_deref().unwrap_or("root");
let dur_s = test.duration.as_secs_f64();
match &test.status {
TestStatus::Passed => {
let _ = writeln!(
out,
r#" <testcase classname="{}" name="{}" time="{:.3}" />"#,
escape_xml(classname),
escape_xml(&test.name),
dur_s,
);
}
TestStatus::Failed { reason, .. } => {
let _ = writeln!(
out,
r#" <testcase classname="{}" name="{}" time="{:.3}">"#,
escape_xml(classname),
escape_xml(&test.name),
dur_s,
);
let _ = writeln!(
out,
r#" <failure message="{}" type="AssertionError"><![CDATA[{}]]></failure>"#,
escape_xml(reason),
reason,
);
let _ = writeln!(out, " </testcase>");
}
TestStatus::Skipped { reason } => {
let msg = reason.as_deref().unwrap_or("skipped");
let _ = writeln!(
out,
r#" <testcase classname="{}" name="{}" time="{:.3}">"#,
escape_xml(classname),
escape_xml(&test.name),
dur_s,
);
let _ = writeln!(
out,
r#" <skipped message="{}" />"#,
escape_xml(msg),
);
let _ = writeln!(out, " </testcase>");
}
TestStatus::TimedOut { duration: to, .. } => {
let _ = writeln!(
out,
r#" <testcase classname="{}" name="{}" time="{:.3}">"#,
escape_xml(classname),
escape_xml(&test.name),
dur_s,
);
let _ = writeln!(
out,
r#" <failure message="timed out after {:?}" type="TimeoutError" />"#,
to,
);
let _ = writeln!(out, " </testcase>");
}
}
}
let _ = writeln!(out, " </testsuite>");
}
let _ = writeln!(out, "</testsuites>");
out
}
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
pub struct JsonReporter;
impl TestReporter for JsonReporter {
fn report(&self, run: &TestRun) -> String {
let mut out = String::new();
let success = if run.success() { "true" } else { "false" };
out.push_str(&format!(
r#"{{"success":{},"total":{},"passed":{},"failed":{},"skipped":{},"duration_secs":{:.3},"suites":["#,
success,
run.total(),
run.total_passed(),
run.total_failed(),
run.total_skipped(),
run.duration.as_secs_f64(),
));
for (si, suite) in run.suites.iter().enumerate() {
if si > 0 {
out.push(',');
}
out.push_str(&format!(
r#"{{"name":"{}","duration_secs":{:.3},"tests":["#,
escape_json(&suite.name),
suite.duration.as_secs_f64(),
));
for (ti, test) in suite.tests.iter().enumerate() {
if ti > 0 {
out.push(',');
}
let (status_str, reason) = match &test.status {
TestStatus::Passed => ("passed", None),
TestStatus::Failed { reason, .. } => ("failed", Some(reason.as_str())),
TestStatus::Skipped { reason } => ("skipped", reason.as_deref()),
TestStatus::TimedOut { .. } => ("timed_out", None),
};
out.push_str(&format!(
r#"{{"name":"{}","status":"{}","duration_secs":{:.3}"#,
escape_json(&test.name),
status_str,
test.duration.as_secs_f64(),
));
if let Some(r) = reason {
out.push_str(&format!(r#","reason":"{}""#, escape_json(r)));
}
if !test.parameters.is_empty() {
out.push_str(r#","parameters":{"#);
for (pi, (k, v)) in test.parameters.iter().enumerate() {
if pi > 0 {
out.push(',');
}
out.push_str(&format!(r#""{}":"{}""#, escape_json(k), escape_json(v)));
}
out.push('}');
}
out.push_str("]}");
}
out.push('}');
}
out.push(']');
out.push('}');
out
}
}
fn escape_json(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
pub struct CompactReporter;
impl TestReporter for CompactReporter {
fn report(&self, run: &TestRun) -> String {
let mut out = String::new();
let total = run.total();
let passed = run.total_passed();
let failed = run.total_failed();
let skipped = run.total_skipped();
for suite in &run.suites {
for test in &suite.tests {
let status = match test.status {
TestStatus::Passed => "PASS",
TestStatus::Failed { .. } => "FAIL",
TestStatus::Skipped { .. } => "SKIP",
TestStatus::TimedOut { .. } => "TIMEOUT",
};
let dur = format_duration(test.duration);
let _ = writeln!(out, "{status} {dur:>7} {}", test.name);
if let Some(ref captured) = test.captured_output {
for line in captured.lines() {
let _ = writeln!(out, " | {}", line);
}
}
}
}
let _ = writeln!(
out,
"\nResults: {passed}/{total} passed, {failed} failed, {skipped} skipped ({:.2}s)",
run.duration.as_secs_f64(),
);
out
}
}
pub struct GithubReporter;
impl TestReporter for GithubReporter {
fn report(&self, run: &TestRun) -> String {
let mut out = String::new();
let mut passed = 0usize;
let mut failed = 0usize;
let mut skipped = 0usize;
for suite in &run.suites {
for test in &suite.tests {
match &test.status {
TestStatus::Passed => passed += 1,
TestStatus::Skipped { .. } => skipped += 1,
TestStatus::Failed { reason, location } => {
failed += 1;
let file = location
.as_ref()
.map(|l| escape_github(l.file.as_str()))
.unwrap_or_else(|| "unknown".to_string());
let line = location
.as_ref()
.map(|l| l.line.to_string())
.unwrap_or_else(|| "1".to_string());
let title = escape_github(&test.name);
let msg = escape_github(reason);
let _ = writeln!(
out,
"::error file={file},line={line},title={title}::{msg}"
);
}
TestStatus::TimedOut { duration, location } => {
failed += 1;
let file = location
.as_ref()
.map(|l| escape_github(l.file.as_str()))
.unwrap_or_else(|| "unknown".to_string());
let line = location
.as_ref()
.map(|l| l.line.to_string())
.unwrap_or_else(|| "1".to_string());
let title = escape_github(&test.name);
let msg = format!("timed out after {duration:?}");
let _ = writeln!(
out,
"::error file={file},line={line},title={title}::{msg}"
);
}
}
}
}
let total = run.total();
let _ = writeln!(
out,
"rvtest: {passed}/{total} passed, {failed} failed, {skipped} skipped ({:.2}s)",
run.duration.as_secs_f64(),
);
out
}
}
fn escape_github(s: &str) -> String {
s.replace('%', "%25")
.replace('\n', "%0A")
.replace('\r', "%0D")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{SourceLocation, TestCase, TestKind, TestRun, TestStatus, TestSuite};
use std::time::{Duration, SystemTime};
fn mixed_run() -> TestRun {
let mut suite = TestSuite::new("Math");
suite.kind = TestKind::Unit;
suite.source_path = "src/lib.rs".into();
suite.duration = Duration::from_millis(50);
suite.tests = vec![
TestCase {
name: "Math :: add".into(), suite: Some("Math".into()), tags: vec![],
status: TestStatus::Passed, duration: Duration::from_millis(5),
assertions: 0, location: None, parameters: vec![], captured_output: None,
},
TestCase {
name: "Math :: sub".into(), suite: Some("Math".into()), tags: vec![],
status: TestStatus::Failed { reason: "assertion failed: 1 + 1 != 3".into(), location: None },
duration: Duration::from_millis(3), assertions: 0, location: None, parameters: vec![], captured_output: None,
},
TestCase {
name: "Math :: slow".into(), suite: Some("Math".into()), tags: vec![],
status: TestStatus::Skipped { reason: Some("not implemented".into()) },
duration: Duration::ZERO, assertions: 0, location: None, parameters: vec![], captured_output: None,
},
];
let mut doc_suite = TestSuite::new("doc-tests (rvtest)");
doc_suite.kind = TestKind::Doc;
doc_suite.source_path = "rvtest".into();
doc_suite.tests = vec![
TestCase {
name: "rvtest - foo".into(), suite: None, tags: vec![],
status: TestStatus::Passed, duration: Duration::from_millis(1),
assertions: 0, location: None, parameters: vec![], captured_output: None,
},
];
TestRun {
suites: vec![suite, doc_suite],
start_time: SystemTime::now(),
end_time: SystemTime::now(),
duration: Duration::from_millis(55),
}
}
fn empty_run() -> TestRun {
TestRun::new()
}
#[test]
fn test_pretty_shows_summary() {
let r = PrettyReporter::new().colour(false).report(&mixed_run());
assert!(r.contains("Tests"));
assert!(r.contains("2 passed"));
assert!(r.contains("1 failed"));
}
#[test]
fn test_pretty_shows_names() {
let r = PrettyReporter::new().colour(false).report(&mixed_run());
assert!(r.contains("Math > add"));
assert!(r.contains("Math > sub"));
assert!(r.contains("Math > slow"));
}
#[test]
fn test_pretty_shows_failure_reason() {
let r = PrettyReporter::new().colour(false).report(&mixed_run());
assert!(r.contains("assertion failed"));
}
#[test]
fn test_pretty_shows_time() {
let r = PrettyReporter::new().colour(false).report(&mixed_run());
assert!(r.contains("Time"));
}
#[test]
fn test_pretty_empty_shows_zeros() {
let r = PrettyReporter::new().colour(false).report(&empty_run());
assert!(r.contains("0 passed"));
}
#[test]
fn test_pretty_shows_skipped_reason() {
let r = PrettyReporter::new().colour(false).report(&mixed_run());
assert!(r.contains("not implemented"));
}
#[test]
fn test_tap_header() {
let r = TapReporter.report(&mixed_run());
assert!(r.starts_with("1..4"));
}
#[test]
fn test_tap_ok_for_passed() {
let r = TapReporter.report(&mixed_run());
assert!(r.contains("ok 1"));
}
#[test]
fn test_tap_not_ok_for_failed() {
let r = TapReporter.report(&mixed_run());
assert!(r.contains("not ok 2"));
}
#[test]
fn test_tap_empty() {
let r = TapReporter.report(&empty_run());
assert_eq!(r.trim(), "1..0");
}
#[test]
fn test_junit_xml_declaration() {
let r = JunitReporter::new().report(&mixed_run());
assert!(r.starts_with("<?xml"));
}
#[test]
fn test_junit_counts() {
let r = JunitReporter::new().report(&mixed_run());
assert!(r.contains("tests=\"4\""));
assert!(r.contains("failures=\"1\""));
assert!(r.contains("skipped=\"1\""));
}
#[test]
fn test_junit_failure_message() {
let r = JunitReporter::new().report(&mixed_run());
assert!(r.contains("failure"));
assert!(r.contains("assertion failed"));
}
#[test]
fn test_junit_empty() {
let r = JunitReporter::new().report(&empty_run());
assert!(r.contains("tests=\"0\""));
}
#[test]
fn test_junit_custom_suite_name() {
let r = JunitReporter::new().suite_name("custom").report(&empty_run());
assert!(r.contains("name=\"custom\""));
}
#[test]
fn test_json_structure() {
let r = JsonReporter.report(&mixed_run());
assert!(r.starts_with("{"));
assert!(r.ends_with("}"));
assert!(r.contains(r#""success":false"#));
assert!(r.contains(r#""total":4"#));
}
#[test]
fn test_json_contains_names() {
let r = JsonReporter.report(&mixed_run());
assert!(r.contains("Math :: add"));
}
#[test]
fn test_json_empty() {
let r = JsonReporter.report(&empty_run());
assert!(r.contains(r#""success":true"#));
assert!(r.contains(r#""total":0"#));
}
#[test]
fn test_compact_status_and_name() {
let r = CompactReporter.report(&mixed_run());
assert!(r.contains("PASS"));
assert!(r.contains("FAIL"));
assert!(r.contains("SKIP"));
assert!(r.contains("Math :: add"));
}
#[test]
fn test_compact_results() {
let r = CompactReporter.report(&mixed_run());
assert!(r.contains("2/4 passed"));
}
#[test]
fn test_compact_empty() {
let r = CompactReporter.report(&empty_run());
assert!(r.contains("0/0 passed"));
}
#[test]
fn test_github_error_annotation() {
let r = GithubReporter.report(&mixed_run());
assert!(r.contains("::error"));
assert!(r.contains("assertion failed"));
}
#[test]
fn test_github_summary() {
let r = GithubReporter.report(&mixed_run());
assert!(r.contains("rvtest:"));
assert!(r.contains("2/4 passed"));
}
#[test]
fn test_github_empty_no_errors() {
let r = GithubReporter.report(&empty_run());
assert!(r.contains("0/0 passed"));
assert!(!r.contains("::error"));
}
#[test]
fn test_format_duration_seconds() {
let s = format_duration(Duration::from_secs_f64(2.5));
assert_eq!(s, "2.50s");
}
#[test]
fn test_format_duration_millis() {
let s = format_duration(Duration::from_millis(500));
assert_eq!(s, "500.0ms");
}
#[test]
fn test_format_duration_zero() {
let s = format_duration(Duration::ZERO);
assert_eq!(s, "0.0ms");
}
#[test]
fn test_escape_xml() {
assert_eq!(escape_xml("a & b < c > d \" e ' f"), "a & b < c > d " e ' f");
}
#[test]
fn test_escape_json_basic() {
assert_eq!(escape_json(r#"a"b\c"#), r#"a\"b\\c"#);
}
#[test]
fn test_escape_json_newline() {
assert_eq!(escape_json("a\nb"), "a\\nb");
}
#[test]
fn test_escape_github_percent() {
assert_eq!(escape_github("100%"), "100%25");
}
#[test]
fn test_escape_github_newline() {
assert_eq!(escape_github("a\nb"), "a%0Ab");
}
fn timed_out_run() -> TestRun {
let mut suite = TestSuite::new("Timeout");
suite.kind = TestKind::Unit;
suite.source_path = "src/lib.rs".into();
suite.duration = Duration::from_secs(5);
suite.tests = vec![
TestCase {
name: "Timeout :: slow".into(), suite: Some("Timeout".into()), tags: vec![],
status: TestStatus::TimedOut { duration: Duration::from_secs(5), location: Some(SourceLocation { file: "src/lib.rs".into(), line: 42, column: Some(7) }) },
duration: Duration::from_secs(5), assertions: 0, location: Some(SourceLocation { file: "src/lib.rs".into(), line: 42, column: Some(7) }), parameters: vec![], captured_output: None,
},
];
TestRun { suites: vec![suite], start_time: SystemTime::now(), end_time: SystemTime::now(), duration: Duration::from_secs(5) }
}
#[test]
fn test_pretty_timed_out_shows_location() {
let r = PrettyReporter::new().colour(false).report(&timed_out_run());
assert!(r.contains("timed out"));
assert!(r.contains("src/lib.rs"));
}
#[test]
fn test_junit_timed_out() {
let r = JunitReporter::new().report(&timed_out_run());
assert!(r.contains("TimeoutError"));
assert!(r.contains("timed out"));
}
#[test]
fn test_json_timed_out() {
let r = JsonReporter.report(&timed_out_run());
assert!(r.contains("timed_out"));
}
#[test]
fn test_github_timed_out() {
let r = GithubReporter.report(&timed_out_run());
assert!(r.contains("::error"));
assert!(r.contains("timed out"));
}
#[test]
fn test_tap_timed_out() {
let r = TapReporter.report(&timed_out_run());
assert!(r.contains("TIMEOUT"));
}
#[test]
fn test_compact_timed_out() {
let r = CompactReporter.report(&timed_out_run());
assert!(r.contains("TIMEOUT"));
}
#[test]
fn test_section_label_unit() {
let mut suite = TestSuite::new("test");
suite.kind = TestKind::Unit;
suite.source_path = "src/lib.rs".into();
let label = section_label(&suite, false);
assert!(label.contains("unit tests"));
assert!(label.contains("src/lib.rs"));
}
#[test]
fn test_section_label_integration() {
let mut suite = TestSuite::new("test");
suite.kind = TestKind::Integration;
suite.source_path = "tests/integration.rs".into();
let label = section_label(&suite, false);
assert!(label.contains("integration"));
assert!(label.contains("tests/integration.rs"));
}
#[test]
fn test_section_label_doc() {
let mut suite = TestSuite::new("test");
suite.kind = TestKind::Doc;
suite.source_path = "rvtest".into();
let label = section_label(&suite, false);
assert!(label.contains("doc-tests"));
}
#[test]
fn test_coloured_on() {
let s = coloured("hello", "31", true);
assert_eq!(s, "\x1b[31mhello\x1b[0m");
}
#[test]
fn test_coloured_off() {
let s = coloured("hello", "31", false);
assert_eq!(s, "hello");
}
#[test]
fn test_dim_colour() {
let s = dim("test", true);
assert_eq!(s, "\x1b[2mtest\x1b[0m");
}
#[test]
fn test_dim_no_colour() {
let s = dim("test", false);
assert_eq!(s, "test");
}
#[test]
fn test_coloured_count() {
let s = coloured_count(42, "passed", "32", true);
assert_eq!(s, "\x1b[32m42\x1b[0m passed");
}
#[test]
fn test_coloured_count_no_colour() {
let s = coloured_count(7, "failed", "31", false);
assert_eq!(s, "7 failed");
}
#[test]
fn test_doc_icon() {
let icon = doc_icon(false);
assert_eq!(icon, "?");
}
#[test]
fn test_build_summary_no_failures() {
let s = build_summary(5, 0, 0, false);
assert_eq!(s, "5 passed");
}
#[test]
fn test_build_summary_with_failures() {
let s = build_summary(3, 1, 0, false);
assert!(s.contains("3 passed"));
assert!(s.contains("1 failed"));
}
#[test]
fn test_build_summary_with_docs() {
let s = build_summary(5, 0, 2, false);
assert!(s.contains("5 passed"));
assert!(s.contains("2 doc-tests"));
}
#[test]
fn test_build_summary_colour() {
let s = build_summary(1, 1, 0, true);
assert!(s.contains("\x1b[32m1\x1b[0m passed"));
assert!(s.contains("\x1b[31m1\x1b[0m failed"));
}
#[test]
fn test_status_icon_all_variants() {
assert_eq!(status_icon(&TestStatus::Passed, false), "✓");
assert_eq!(status_icon(&TestStatus::Failed { reason: "".into(), location: None }, false), "✗");
assert_eq!(status_icon(&TestStatus::Skipped { reason: None }, false), "–");
assert_eq!(status_icon(&TestStatus::TimedOut { duration: Duration::ZERO, location: None }, false), "⊗");
}
#[test]
fn test_status_icon_colour() {
let passed = status_icon(&TestStatus::Passed, true);
assert!(passed.contains("\x1b[32m"));
let failed = status_icon(&TestStatus::Failed { reason: "".into(), location: None }, true);
assert!(failed.contains("\x1b[31m"));
}
#[test]
fn test_format_location_with_column() {
let loc = SourceLocation { file: "src/lib.rs".into(), line: 42, column: Some(7) };
let s = format_location(&loc, false);
assert_eq!(s, "src/lib.rs:42:7");
}
#[test]
fn test_format_location_without_column() {
let loc = SourceLocation { file: "src/main.rs".into(), line: 10, column: None };
let s = format_location(&loc, false);
assert_eq!(s, "src/main.rs:10");
}
#[test]
fn test_format_location_with_colour() {
let loc = SourceLocation { file: "src/lib.rs".into(), line: 1, column: Some(1) };
let s = format_location(&loc, true);
assert!(s.contains("\x1b[36m"));
}
}