use crate::config::CliConfig;
use crate::error::CliResult;
use crate::output::ProgressReporter;
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestResult {
pub name: String,
pub passed: bool,
pub error: Option<String>,
pub duration: Duration,
pub output: String,
}
impl TestResult {
#[must_use]
pub fn pass(name: impl Into<String>, duration: Duration) -> Self {
Self {
name: name.into(),
passed: true,
error: None,
duration,
output: String::new(),
}
}
#[must_use]
pub fn fail(name: impl Into<String>, error: impl Into<String>, duration: Duration) -> Self {
Self {
name: name.into(),
passed: false,
error: Some(error.into()),
duration,
output: String::new(),
}
}
#[must_use]
pub fn with_output(mut self, output: impl Into<String>) -> Self {
self.output = output.into();
self
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TestResults {
pub results: Vec<TestResult>,
pub duration: Duration,
}
impl TestResults {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, result: TestResult) {
self.results.push(result);
}
#[must_use]
pub fn passed(&self) -> usize {
self.results.iter().filter(|r| r.passed).count()
}
#[must_use]
pub fn failed(&self) -> usize {
self.results.iter().filter(|r| !r.passed).count()
}
#[must_use]
pub fn total(&self) -> usize {
self.results.len()
}
#[must_use]
pub fn all_passed(&self) -> bool {
self.results.iter().all(|r| r.passed)
}
#[must_use]
pub fn failures(&self) -> Vec<&TestResult> {
self.results.iter().filter(|r| !r.passed).collect()
}
}
#[derive(Debug)]
pub struct TestRunner {
config: CliConfig,
reporter: ProgressReporter,
}
impl TestRunner {
#[must_use]
pub fn new(config: CliConfig) -> Self {
let reporter =
ProgressReporter::new(config.color.should_color(), config.verbosity.is_quiet());
Self { config, reporter }
}
pub fn run(&mut self, filter: Option<&str>) -> CliResult<TestResults> {
let start = Instant::now();
let mut results = TestResults::new();
let tests = Self::discover_tests(filter);
if tests.is_empty() {
self.reporter.warning("No tests found");
results.duration = start.elapsed();
return Ok(results);
}
self.reporter.header("Running Tests");
self.reporter
.start_progress(tests.len() as u64, "Starting...");
for test_name in tests {
self.reporter.set_message(&test_name);
let test_start = Instant::now();
let result = Self::run_single_test(&test_name, test_start);
if result.passed {
self.reporter.success(&test_name);
} else {
self.reporter.failure(&format!(
"{}: {}",
test_name,
result.error.as_deref().unwrap_or("unknown error")
));
if self.config.fail_fast {
results.add(result);
break;
}
}
results.add(result);
self.reporter.increment(1);
}
self.reporter.finish();
results.duration = start.elapsed();
self.reporter.summary(
results.passed(),
results.failed(),
0, results.duration,
);
Ok(results)
}
fn discover_tests(filter: Option<&str>) -> Vec<String> {
let mut cmd = std::process::Command::new("cargo");
cmd.args(["test", "--", "--list", "--format", "terse"]);
if let Some(pattern) = filter {
cmd.arg(pattern);
}
match cmd.output() {
Ok(output) => {
if output.status.success() {
String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|line| line.ends_with(": test"))
.map(|line| line.trim_end_matches(": test").to_string())
.collect()
} else {
Vec::new()
}
}
Err(_) => Vec::new(),
}
}
fn run_single_test(name: &str, start: Instant) -> TestResult {
let output = std::process::Command::new("cargo")
.args(["test", "--", "--exact", name, "--nocapture"])
.output();
match output {
Ok(result) => {
let stdout = String::from_utf8_lossy(&result.stdout);
let stderr = String::from_utf8_lossy(&result.stderr);
let combined_output = format!("{stdout}\n{stderr}");
if result.status.success() {
TestResult::pass(name, start.elapsed()).with_output(&combined_output)
} else {
let error_msg = if stderr.contains("FAILED") {
stderr
.lines()
.find(|l| l.contains("FAILED") || l.contains("panicked"))
.unwrap_or("Test failed")
.to_string()
} else {
"Test execution failed".to_string()
};
TestResult::fail(name, error_msg, start.elapsed()).with_output(&combined_output)
}
}
Err(e) => TestResult::fail(
name,
format!("Failed to execute test: {e}"),
start.elapsed(),
),
}
}
#[must_use]
pub const fn reporter(&self) -> &ProgressReporter {
&self.reporter
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
mod test_result_tests {
use super::*;
#[test]
fn test_pass_result() {
let result = TestResult::pass("test_1", Duration::from_millis(100));
assert!(result.passed);
assert!(result.error.is_none());
assert_eq!(result.name, "test_1");
}
#[test]
fn test_fail_result() {
let result = TestResult::fail("test_2", "assertion failed", Duration::from_millis(50));
assert!(!result.passed);
assert_eq!(result.error, Some("assertion failed".to_string()));
}
#[test]
fn test_with_output() {
let result = TestResult::pass("test_3", Duration::from_millis(10))
.with_output("test output here");
assert_eq!(result.output, "test output here");
}
}
mod test_results_tests {
use super::*;
#[test]
fn test_new_results() {
let results = TestResults::new();
assert_eq!(results.total(), 0);
assert_eq!(results.passed(), 0);
assert_eq!(results.failed(), 0);
}
#[test]
fn test_add_results() {
let mut results = TestResults::new();
results.add(TestResult::pass("test_1", Duration::from_millis(10)));
results.add(TestResult::fail(
"test_2",
"error",
Duration::from_millis(10),
));
results.add(TestResult::pass("test_3", Duration::from_millis(10)));
assert_eq!(results.total(), 3);
assert_eq!(results.passed(), 2);
assert_eq!(results.failed(), 1);
}
#[test]
fn test_all_passed() {
let mut results = TestResults::new();
results.add(TestResult::pass("test_1", Duration::from_millis(10)));
results.add(TestResult::pass("test_2", Duration::from_millis(10)));
assert!(results.all_passed());
results.add(TestResult::fail(
"test_3",
"error",
Duration::from_millis(10),
));
assert!(!results.all_passed());
}
#[test]
fn test_failures() {
let mut results = TestResults::new();
results.add(TestResult::pass("test_1", Duration::from_millis(10)));
results.add(TestResult::fail(
"test_2",
"error1",
Duration::from_millis(10),
));
results.add(TestResult::fail(
"test_3",
"error2",
Duration::from_millis(10),
));
let failures = results.failures();
assert_eq!(failures.len(), 2);
assert_eq!(failures[0].name, "test_2");
assert_eq!(failures[1].name, "test_3");
}
}
mod test_runner_tests {
use super::*;
#[test]
fn test_new_runner() {
let config = CliConfig::default();
let runner = TestRunner::new(config);
assert!(runner.reporter().use_color || !runner.reporter().use_color);
}
#[test]
#[ignore = "Spawns cargo test --list subprocess - causes nested builds in CI"]
fn test_run_no_tests() {
let config = CliConfig::default();
let mut runner = TestRunner::new(config);
let results = runner.run(None).unwrap();
assert_eq!(results.total(), 0);
}
#[test]
#[ignore = "Spawns cargo test --list subprocess - causes nested builds in CI"]
fn test_run_with_filter() {
let config = CliConfig::default();
let mut runner = TestRunner::new(config);
let results = runner.run(Some("game::*")).unwrap();
assert_eq!(results.total(), 0);
}
#[test]
fn test_runner_with_config() {
let config = CliConfig::default();
let runner = TestRunner::new(config);
let _reporter = runner.reporter();
}
}
mod test_result_additional_tests {
use super::*;
#[test]
fn test_with_output() {
let result =
TestResult::pass("test", Duration::from_millis(10)).with_output("Some output text");
assert_eq!(result.output, "Some output text");
}
#[test]
fn test_debug() {
let result = TestResult::pass("test", Duration::from_millis(10));
let debug = format!("{result:?}");
assert!(debug.contains("TestResult"));
}
#[test]
fn test_clone() {
let result = TestResult::fail("test", "error", Duration::from_millis(10));
let cloned = result.clone();
assert_eq!(result.name, cloned.name);
assert_eq!(result.error, cloned.error);
}
#[test]
fn test_serialize() {
let result = TestResult::pass("test", Duration::from_millis(10));
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("test"));
}
}
mod test_results_additional_tests {
use super::*;
#[test]
fn test_default() {
let results = TestResults::default();
assert!(results.results.is_empty());
}
#[test]
fn test_duration_tracking() {
let mut results = TestResults::new();
results.duration = Duration::from_secs(5);
assert_eq!(results.duration.as_secs(), 5);
}
#[test]
fn test_serialize() {
let mut results = TestResults::new();
results.add(TestResult::pass("test1", Duration::from_millis(10)));
let json = serde_json::to_string(&results).unwrap();
assert!(json.contains("test1"));
}
#[test]
fn test_debug() {
let results = TestResults::new();
let debug = format!("{results:?}");
assert!(debug.contains("TestResults"));
}
#[test]
fn test_clone() {
let mut results = TestResults::new();
results.add(TestResult::pass("test", Duration::from_millis(10)));
let cloned = results.clone();
assert_eq!(results.total(), cloned.total());
}
}
}