1use crate::config::CliConfig;
4use crate::error::CliResult;
5use crate::output::ProgressReporter;
6use serde::{Deserialize, Serialize};
7use std::time::{Duration, Instant};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct TestResult {
12 pub name: String,
14 pub passed: bool,
16 pub error: Option<String>,
18 pub duration: Duration,
20 pub output: String,
22}
23
24impl TestResult {
25 #[must_use]
27 pub fn pass(name: impl Into<String>, duration: Duration) -> Self {
28 Self {
29 name: name.into(),
30 passed: true,
31 error: None,
32 duration,
33 output: String::new(),
34 }
35 }
36
37 #[must_use]
39 pub fn fail(name: impl Into<String>, error: impl Into<String>, duration: Duration) -> Self {
40 Self {
41 name: name.into(),
42 passed: false,
43 error: Some(error.into()),
44 duration,
45 output: String::new(),
46 }
47 }
48
49 #[must_use]
51 pub fn with_output(mut self, output: impl Into<String>) -> Self {
52 self.output = output.into();
53 self
54 }
55}
56
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59pub struct TestResults {
60 pub results: Vec<TestResult>,
62 pub duration: Duration,
64}
65
66impl TestResults {
67 #[must_use]
69 pub fn new() -> Self {
70 Self::default()
71 }
72
73 pub fn add(&mut self, result: TestResult) {
75 self.results.push(result);
76 }
77
78 #[must_use]
80 pub fn passed(&self) -> usize {
81 self.results.iter().filter(|r| r.passed).count()
82 }
83
84 #[must_use]
86 pub fn failed(&self) -> usize {
87 self.results.iter().filter(|r| !r.passed).count()
88 }
89
90 #[must_use]
92 pub fn total(&self) -> usize {
93 self.results.len()
94 }
95
96 #[must_use]
98 pub fn all_passed(&self) -> bool {
99 self.results.iter().all(|r| r.passed)
100 }
101
102 #[must_use]
104 pub fn failures(&self) -> Vec<&TestResult> {
105 self.results.iter().filter(|r| !r.passed).collect()
106 }
107}
108
109#[derive(Debug)]
111pub struct TestRunner {
112 config: CliConfig,
113 reporter: ProgressReporter,
114}
115
116impl TestRunner {
117 #[must_use]
119 pub fn new(config: CliConfig) -> Self {
120 let reporter =
121 ProgressReporter::new(config.color.should_color(), config.verbosity.is_quiet());
122 Self { config, reporter }
123 }
124
125 pub fn run(&mut self, filter: Option<&str>) -> CliResult<TestResults> {
131 let start = Instant::now();
132 let mut results = TestResults::new();
133
134 let tests = Self::discover_tests(filter);
136
137 if tests.is_empty() {
138 self.reporter.warning("No tests found");
139 results.duration = start.elapsed();
140 return Ok(results);
141 }
142
143 self.reporter.header("Running Tests");
144 self.reporter
145 .start_progress(tests.len() as u64, "Starting...");
146
147 for test_name in tests {
148 self.reporter.set_message(&test_name);
149
150 let test_start = Instant::now();
151 let result = Self::run_single_test(&test_name, test_start);
152
153 if result.passed {
154 self.reporter.success(&test_name);
155 } else {
156 self.reporter.failure(&format!(
157 "{}: {}",
158 test_name,
159 result.error.as_deref().unwrap_or("unknown error")
160 ));
161
162 if self.config.fail_fast {
163 results.add(result);
164 break;
165 }
166 }
167
168 results.add(result);
169 self.reporter.increment(1);
170 }
171
172 self.reporter.finish();
173 results.duration = start.elapsed();
174
175 self.reporter.summary(
176 results.passed(),
177 results.failed(),
178 0, results.duration,
180 );
181
182 Ok(results)
183 }
184
185 fn discover_tests(filter: Option<&str>) -> Vec<String> {
187 let mut cmd = std::process::Command::new("cargo");
188 cmd.args(["test", "--", "--list", "--format", "terse"]);
189
190 if let Some(pattern) = filter {
191 cmd.arg(pattern);
192 }
193
194 match cmd.output() {
195 Ok(output) => {
196 if output.status.success() {
197 String::from_utf8_lossy(&output.stdout)
198 .lines()
199 .filter(|line| line.ends_with(": test"))
200 .map(|line| line.trim_end_matches(": test").to_string())
201 .collect()
202 } else {
203 Vec::new()
204 }
205 }
206 Err(_) => Vec::new(),
207 }
208 }
209
210 fn run_single_test(name: &str, start: Instant) -> TestResult {
212 let output = std::process::Command::new("cargo")
213 .args(["test", "--", "--exact", name, "--nocapture"])
214 .output();
215
216 match output {
217 Ok(result) => {
218 let stdout = String::from_utf8_lossy(&result.stdout);
219 let stderr = String::from_utf8_lossy(&result.stderr);
220 let combined_output = format!("{stdout}\n{stderr}");
221
222 if result.status.success() {
223 TestResult::pass(name, start.elapsed()).with_output(&combined_output)
224 } else {
225 let error_msg = if stderr.contains("FAILED") {
226 stderr
227 .lines()
228 .find(|l| l.contains("FAILED") || l.contains("panicked"))
229 .unwrap_or("Test failed")
230 .to_string()
231 } else {
232 "Test execution failed".to_string()
233 };
234 TestResult::fail(name, error_msg, start.elapsed()).with_output(&combined_output)
235 }
236 }
237 Err(e) => TestResult::fail(
238 name,
239 format!("Failed to execute test: {e}"),
240 start.elapsed(),
241 ),
242 }
243 }
244
245 #[must_use]
247 pub const fn reporter(&self) -> &ProgressReporter {
248 &self.reporter
249 }
250}
251
252#[cfg(test)]
253#[allow(clippy::unwrap_used, clippy::expect_used)]
254mod tests {
255 use super::*;
256
257 mod test_result_tests {
258 use super::*;
259
260 #[test]
261 fn test_pass_result() {
262 let result = TestResult::pass("test_1", Duration::from_millis(100));
263 assert!(result.passed);
264 assert!(result.error.is_none());
265 assert_eq!(result.name, "test_1");
266 }
267
268 #[test]
269 fn test_fail_result() {
270 let result = TestResult::fail("test_2", "assertion failed", Duration::from_millis(50));
271 assert!(!result.passed);
272 assert_eq!(result.error, Some("assertion failed".to_string()));
273 }
274
275 #[test]
276 fn test_with_output() {
277 let result = TestResult::pass("test_3", Duration::from_millis(10))
278 .with_output("test output here");
279 assert_eq!(result.output, "test output here");
280 }
281 }
282
283 mod test_results_tests {
284 use super::*;
285
286 #[test]
287 fn test_new_results() {
288 let results = TestResults::new();
289 assert_eq!(results.total(), 0);
290 assert_eq!(results.passed(), 0);
291 assert_eq!(results.failed(), 0);
292 }
293
294 #[test]
295 fn test_add_results() {
296 let mut results = TestResults::new();
297 results.add(TestResult::pass("test_1", Duration::from_millis(10)));
298 results.add(TestResult::fail(
299 "test_2",
300 "error",
301 Duration::from_millis(10),
302 ));
303 results.add(TestResult::pass("test_3", Duration::from_millis(10)));
304
305 assert_eq!(results.total(), 3);
306 assert_eq!(results.passed(), 2);
307 assert_eq!(results.failed(), 1);
308 }
309
310 #[test]
311 fn test_all_passed() {
312 let mut results = TestResults::new();
313 results.add(TestResult::pass("test_1", Duration::from_millis(10)));
314 results.add(TestResult::pass("test_2", Duration::from_millis(10)));
315 assert!(results.all_passed());
316
317 results.add(TestResult::fail(
318 "test_3",
319 "error",
320 Duration::from_millis(10),
321 ));
322 assert!(!results.all_passed());
323 }
324
325 #[test]
326 fn test_failures() {
327 let mut results = TestResults::new();
328 results.add(TestResult::pass("test_1", Duration::from_millis(10)));
329 results.add(TestResult::fail(
330 "test_2",
331 "error1",
332 Duration::from_millis(10),
333 ));
334 results.add(TestResult::fail(
335 "test_3",
336 "error2",
337 Duration::from_millis(10),
338 ));
339
340 let failures = results.failures();
341 assert_eq!(failures.len(), 2);
342 assert_eq!(failures[0].name, "test_2");
343 assert_eq!(failures[1].name, "test_3");
344 }
345 }
346
347 mod test_runner_tests {
348 use super::*;
349
350 #[test]
351 fn test_new_runner() {
352 let config = CliConfig::default();
353 let runner = TestRunner::new(config);
354 assert!(runner.reporter().use_color || !runner.reporter().use_color);
355 }
356
357 #[test]
358 #[ignore = "Spawns cargo test --list subprocess - causes nested builds in CI"]
359 fn test_run_no_tests() {
360 let config = CliConfig::default();
361 let mut runner = TestRunner::new(config);
362 let results = runner.run(None).unwrap();
363 assert_eq!(results.total(), 0);
364 }
365
366 #[test]
367 #[ignore = "Spawns cargo test --list subprocess - causes nested builds in CI"]
368 fn test_run_with_filter() {
369 let config = CliConfig::default();
370 let mut runner = TestRunner::new(config);
371 let results = runner.run(Some("game::*")).unwrap();
372 assert_eq!(results.total(), 0);
373 }
374
375 #[test]
376 fn test_runner_with_config() {
377 let config = CliConfig::default();
378 let runner = TestRunner::new(config);
379 let _reporter = runner.reporter();
381 }
382 }
383
384 mod test_result_additional_tests {
385 use super::*;
386
387 #[test]
388 fn test_with_output() {
389 let result =
390 TestResult::pass("test", Duration::from_millis(10)).with_output("Some output text");
391 assert_eq!(result.output, "Some output text");
392 }
393
394 #[test]
395 fn test_debug() {
396 let result = TestResult::pass("test", Duration::from_millis(10));
397 let debug = format!("{result:?}");
398 assert!(debug.contains("TestResult"));
399 }
400
401 #[test]
402 fn test_clone() {
403 let result = TestResult::fail("test", "error", Duration::from_millis(10));
404 let cloned = result.clone();
405 assert_eq!(result.name, cloned.name);
406 assert_eq!(result.error, cloned.error);
407 }
408
409 #[test]
410 fn test_serialize() {
411 let result = TestResult::pass("test", Duration::from_millis(10));
412 let json = serde_json::to_string(&result).unwrap();
413 assert!(json.contains("test"));
414 }
415 }
416
417 mod test_results_additional_tests {
418 use super::*;
419
420 #[test]
421 fn test_default() {
422 let results = TestResults::default();
423 assert!(results.results.is_empty());
424 }
425
426 #[test]
427 fn test_duration_tracking() {
428 let mut results = TestResults::new();
429 results.duration = Duration::from_secs(5);
430 assert_eq!(results.duration.as_secs(), 5);
431 }
432
433 #[test]
434 fn test_serialize() {
435 let mut results = TestResults::new();
436 results.add(TestResult::pass("test1", Duration::from_millis(10)));
437 let json = serde_json::to_string(&results).unwrap();
438 assert!(json.contains("test1"));
439 }
440
441 #[test]
442 fn test_debug() {
443 let results = TestResults::new();
444 let debug = format!("{results:?}");
445 assert!(debug.contains("TestResults"));
446 }
447
448 #[test]
449 fn test_clone() {
450 let mut results = TestResults::new();
451 results.add(TestResult::pass("test", Duration::from_millis(10)));
452 let cloned = results.clone();
453 assert_eq!(results.total(), cloned.total());
454 }
455 }
456}