1use std::path::Path;
2use std::process::Command;
3use std::time::Duration;
4
5use anyhow::Result;
6
7use super::util::{combined_output, duration_from_secs_safe, truncate};
8use super::{
9 ConfidenceScore, DetectionResult, TestAdapter, TestCase, TestError, TestRunResult, TestStatus,
10 TestSuite,
11};
12
13pub struct PhpAdapter;
14
15impl Default for PhpAdapter {
16 fn default() -> Self {
17 Self::new()
18 }
19}
20
21impl PhpAdapter {
22 pub fn new() -> Self {
23 Self
24 }
25
26 fn has_phpunit_config(project_dir: &Path) -> bool {
27 project_dir.join("phpunit.xml").exists() || project_dir.join("phpunit.xml.dist").exists()
28 }
29
30 fn has_vendor_phpunit(project_dir: &Path) -> bool {
31 project_dir.join("vendor/bin/phpunit").exists()
32 }
33
34 fn has_composer_phpunit(project_dir: &Path) -> bool {
35 let composer = project_dir.join("composer.json");
36 if composer.exists()
37 && let Ok(content) = std::fs::read_to_string(&composer)
38 {
39 return content.contains("phpunit");
40 }
41 false
42 }
43}
44
45impl TestAdapter for PhpAdapter {
46 fn name(&self) -> &str {
47 "PHP"
48 }
49
50 fn check_runner(&self) -> Option<String> {
51 if which::which("php").is_err() {
52 return Some("php not found. Install PHP.".into());
53 }
54 None
55 }
56
57 fn detect(&self, project_dir: &Path) -> Option<DetectionResult> {
58 if !Self::has_phpunit_config(project_dir) && !Self::has_composer_phpunit(project_dir) {
59 return None;
60 }
61
62 let confidence = ConfidenceScore::base(0.50)
63 .signal(0.15, Self::has_phpunit_config(project_dir))
64 .signal(0.10, Self::has_vendor_phpunit(project_dir))
65 .signal(
66 0.10,
67 project_dir.join("tests").is_dir() || project_dir.join("test").is_dir(),
68 )
69 .signal(0.07, which::which("php").is_ok())
70 .finish();
71
72 Some(DetectionResult {
73 language: "PHP".into(),
74 framework: "PHPUnit".into(),
75 confidence,
76 })
77 }
78
79 fn build_command(&self, project_dir: &Path, extra_args: &[String]) -> Result<Command> {
80 let mut cmd;
81
82 if Self::has_vendor_phpunit(project_dir) {
83 cmd = Command::new("./vendor/bin/phpunit");
84 } else {
85 cmd = Command::new("phpunit");
86 }
87
88 for arg in extra_args {
89 cmd.arg(arg);
90 }
91
92 cmd.current_dir(project_dir);
93 Ok(cmd)
94 }
95
96 fn filter_args(&self, pattern: &str) -> Vec<String> {
97 vec!["--filter".to_string(), pattern.to_string()]
98 }
99
100 fn parse_output(&self, stdout: &str, stderr: &str, exit_code: i32) -> TestRunResult {
101 let combined = combined_output(stdout, stderr);
102
103 let mut suites = parse_testdox_output(&combined);
105 if suites.is_empty() || suites.iter().all(|s| s.tests.is_empty()) {
106 suites = parse_phpunit_output(&combined, exit_code);
107 }
108
109 let failures = parse_phpunit_failures(&combined);
111 if !failures.is_empty() {
112 enrich_with_errors(&mut suites, &failures);
113 }
114
115 let duration = parse_phpunit_duration(&combined).unwrap_or(Duration::from_secs(0));
116
117 TestRunResult {
118 suites,
119 duration,
120 raw_exit_code: exit_code,
121 }
122 }
123}
124
125fn parse_phpunit_output(output: &str, exit_code: i32) -> Vec<TestSuite> {
144 let mut tests = Vec::new();
145
146 for line in output.lines() {
147 let trimmed = line.trim();
148
149 if trimmed.starts_with("Tests:") && trimmed.contains("Assertions:") {
152 let mut total = 0usize;
153 let mut failures = 0usize;
154 let mut errors = 0usize;
155 let mut skipped = 0usize;
156
157 for part in trimmed.split(',') {
158 let part = part.trim().trim_end_matches('.');
159 if let Some(rest) = part.strip_prefix("Tests:") {
160 total = rest.trim().parse().unwrap_or(0);
161 } else if let Some(rest) = part.strip_prefix("Failures:") {
162 failures = rest.trim().parse().unwrap_or(0);
163 } else if let Some(rest) = part.strip_prefix("Errors:") {
164 errors = rest.trim().parse().unwrap_or(0);
165 } else if let Some(rest) = part.strip_prefix("Skipped:") {
166 skipped = rest.trim().parse().unwrap_or(0);
167 } else if let Some(rest) = part.strip_prefix("Incomplete:") {
168 skipped += rest.trim().parse::<usize>().unwrap_or(0);
169 }
170 }
171
172 let failed = failures + errors;
173 let passed = total.saturating_sub(failed + skipped);
174
175 for i in 0..passed {
176 tests.push(TestCase {
177 name: format!("test_{}", i + 1),
178 status: TestStatus::Passed,
179 duration: Duration::from_millis(0),
180 error: None,
181 });
182 }
183 for i in 0..failed {
184 tests.push(TestCase {
185 name: format!("failed_test_{}", i + 1),
186 status: TestStatus::Failed,
187 duration: Duration::from_millis(0),
188 error: None,
189 });
190 }
191 for i in 0..skipped {
192 tests.push(TestCase {
193 name: format!("skipped_test_{}", i + 1),
194 status: TestStatus::Skipped,
195 duration: Duration::from_millis(0),
196 error: None,
197 });
198 }
199 break;
200 }
201
202 if trimmed.starts_with("OK (") && trimmed.contains("test") {
204 let inner = trimmed
205 .strip_prefix("OK (")
206 .and_then(|s| s.strip_suffix(')'))
207 .unwrap_or("");
208 for part in inner.split(',') {
209 let part = part.trim();
210 let words: Vec<&str> = part.split_whitespace().collect();
211 if words.len() >= 2 && words[1].starts_with("test") {
212 let count: usize = words[0].parse().unwrap_or(0);
213 for i in 0..count {
214 tests.push(TestCase {
215 name: format!("test_{}", i + 1),
216 status: TestStatus::Passed,
217 duration: Duration::from_millis(0),
218 error: None,
219 });
220 }
221 break;
222 }
223 }
224 break;
225 }
226 }
227
228 if tests.is_empty() {
229 tests.push(TestCase {
230 name: "test_suite".into(),
231 status: if exit_code == 0 {
232 TestStatus::Passed
233 } else {
234 TestStatus::Failed
235 },
236 duration: Duration::from_millis(0),
237 error: None,
238 });
239 }
240
241 vec![TestSuite {
242 name: "tests".into(),
243 tests,
244 }]
245}
246
247fn parse_phpunit_duration(output: &str) -> Option<Duration> {
248 for line in output.lines() {
250 if line.contains("Time:")
251 && line.contains("Memory:")
252 && let Some(idx) = line.find("Time:")
253 {
254 let after = &line[idx + 5..];
255 let time_str = after.split(',').next()?.trim();
256 if let Some(colon_idx) = time_str.find(':') {
258 let mins: f64 = time_str[..colon_idx].parse().unwrap_or(0.0);
259 let secs: f64 = time_str[colon_idx + 1..].parse().unwrap_or(0.0);
260 return Some(duration_from_secs_safe(mins * 60.0 + secs));
261 }
262 }
263 }
264 None
265}
266
267fn parse_testdox_output(output: &str) -> Vec<TestSuite> {
278 let mut suites: Vec<TestSuite> = Vec::new();
279 let mut current_suite = String::new();
280 let mut current_tests: Vec<TestCase> = Vec::new();
281
282 for line in output.lines() {
283 let trimmed = line.trim();
284
285 if is_testdox_suite_header(trimmed) {
287 if !current_suite.is_empty() && !current_tests.is_empty() {
288 suites.push(TestSuite {
289 name: current_suite.clone(),
290 tests: std::mem::take(&mut current_tests),
291 });
292 }
293 current_suite = trimmed
295 .find(" (")
296 .map(|i| trimmed[..i].to_string())
297 .unwrap_or_else(|| trimmed.to_string());
298 continue;
299 }
300
301 if let Some(test) = parse_testdox_test_line(trimmed) {
303 current_tests.push(test);
304 }
305 }
306
307 if !current_suite.is_empty() && !current_tests.is_empty() {
309 suites.push(TestSuite {
310 name: current_suite,
311 tests: current_tests,
312 });
313 }
314
315 suites
316}
317
318fn is_testdox_suite_header(line: &str) -> bool {
322 if line.is_empty() {
323 return false;
324 }
325 if line.starts_with('✔')
327 || line.starts_with('✘')
328 || line.starts_with('⚬')
329 || line.starts_with('✓')
330 || line.starts_with('✗')
331 || line.starts_with('×')
332 || line.starts_with('-')
333 {
334 return false;
335 }
336 if line.starts_with("PHPUnit")
338 || line.starts_with("Time:")
339 || line.starts_with("OK ")
340 || line.starts_with("Tests:")
341 || line.starts_with("FAILURES!")
342 || line.starts_with("ERRORS!")
343 || line.starts_with("There ")
344 || line.contains("test") && line.contains("assertion")
345 {
346 return false;
347 }
348 line.chars().next().is_some_and(|c| c.is_ascii_uppercase())
350}
351
352fn parse_testdox_test_line(line: &str) -> Option<TestCase> {
354 let (status, rest) = if let Some(r) = strip_testdox_marker(line, &['✔', '✓']) {
356 (TestStatus::Passed, r)
357 } else if let Some(r) = strip_testdox_marker(line, &['✘', '✗', '×']) {
358 (TestStatus::Failed, r)
359 } else if let Some(r) = strip_testdox_marker(line, &['⚬', '○', '-']) {
360 (TestStatus::Skipped, r)
361 } else {
362 return None;
363 };
364
365 let name = rest.trim().to_string();
366 if name.is_empty() {
367 return None;
368 }
369
370 let (clean_name, duration) = extract_testdox_duration(&name);
372
373 Some(TestCase {
374 name: clean_name,
375 status,
376 duration,
377 error: None,
378 })
379}
380
381fn strip_testdox_marker<'a>(line: &'a str, markers: &[char]) -> Option<&'a str> {
383 for &marker in markers {
384 if let Some(rest) = line.strip_prefix(marker) {
385 return Some(rest.trim_start());
386 }
387 }
388 None
389}
390
391fn extract_testdox_duration(name: &str) -> (String, Duration) {
394 if let Some(paren_start) = name.rfind('(') {
395 let inside = &name[paren_start + 1..name.len().saturating_sub(1)];
396 let inside = inside.trim();
397 if (inside.ends_with('s') || inside.ends_with("ms"))
398 && let Some(dur) = parse_testdox_duration_str(inside)
399 {
400 let clean = name[..paren_start].trim().to_string();
401 return (clean, dur);
402 }
403 }
404 (name.to_string(), Duration::from_millis(0))
405}
406
407fn parse_testdox_duration_str(s: &str) -> Option<Duration> {
409 if let Some(rest) = s.strip_suffix("ms") {
410 let val: f64 = rest.trim().parse().ok()?;
411 Some(duration_from_secs_safe(val / 1000.0))
412 } else if let Some(rest) = s.strip_suffix('s') {
413 let val: f64 = rest.trim().parse().ok()?;
414 Some(duration_from_secs_safe(val))
415 } else {
416 None
417 }
418}
419
420#[derive(Debug, Clone)]
422struct PhpUnitFailure {
423 test_method: String,
425 message: String,
427 location: Option<String>,
429}
430
431fn parse_phpunit_failures(output: &str) -> Vec<PhpUnitFailure> {
452 let mut failures = Vec::new();
453 let lines: Vec<&str> = output.lines().collect();
454 let mut i = 0;
455
456 while i < lines.len() {
457 let trimmed = lines[i].trim();
458
459 if is_phpunit_failure_header(trimmed) {
461 let test_method = trimmed
462 .find(") ")
463 .map(|idx| trimmed[idx + 2..].trim().to_string())
464 .unwrap_or_default();
465
466 let mut message_lines = Vec::new();
468 let mut location = None;
469 i += 1;
470
471 while i < lines.len() {
472 let line = lines[i].trim();
473
474 if line.is_empty() {
476 i += 1;
477 if i < lines.len() && is_php_file_location(lines[i].trim()) {
479 location = Some(lines[i].trim().to_string());
480 i += 1;
481 }
482 break;
483 }
484
485 if is_php_file_location(line) {
487 location = Some(line.to_string());
488 i += 1;
489 break;
490 }
491
492 if is_phpunit_failure_header(line) {
494 break;
495 }
496
497 message_lines.push(line.to_string());
498 i += 1;
499 }
500
501 if !test_method.is_empty() {
502 failures.push(PhpUnitFailure {
503 test_method,
504 message: truncate(&message_lines.join("\n"), 500),
505 location,
506 });
507 }
508 continue;
509 }
510
511 i += 1;
512 }
513
514 failures
515}
516
517fn is_phpunit_failure_header(line: &str) -> bool {
519 if line.len() < 3 {
520 return false;
521 }
522 let mut chars = line.chars();
524 let first = chars.next().unwrap_or(' ');
525 if !first.is_ascii_digit() {
526 return false;
527 }
528 line.contains(") ") && line.find(") ").is_some_and(|idx| idx <= 5)
530}
531
532fn is_php_file_location(line: &str) -> bool {
534 (line.contains(".php:") || line.contains(".php("))
535 && (line.starts_with('/') || line.starts_with('\\') || line.contains(":\\"))
536}
537
538fn enrich_with_errors(suites: &mut [TestSuite], failures: &[PhpUnitFailure]) {
540 for suite in suites.iter_mut() {
541 for test in suite.tests.iter_mut() {
542 if test.status != TestStatus::Failed || test.error.is_some() {
543 continue;
544 }
545 if let Some(failure) = find_matching_failure(&test.name, failures) {
547 test.error = Some(TestError {
548 message: failure.message.clone(),
549 location: failure.location.clone(),
550 });
551 }
552 }
553 }
554}
555
556fn find_matching_failure<'a>(
560 test_name: &str,
561 failures: &'a [PhpUnitFailure],
562) -> Option<&'a PhpUnitFailure> {
563 for failure in failures {
565 let method = failure
567 .test_method
568 .rsplit("::")
569 .next()
570 .unwrap_or(&failure.test_method);
571 if test_name.eq_ignore_ascii_case(method) {
572 return Some(failure);
573 }
574 if testdox_matches(test_name, method) {
576 return Some(failure);
577 }
578 }
579 if failures.len() == 1 {
581 return Some(&failures[0]);
582 }
583 None
584}
585
586fn testdox_matches(testdox_name: &str, method_name: &str) -> bool {
589 let method = method_name.strip_prefix("test").unwrap_or(method_name);
591 let method_words = camel_case_to_words(method);
592 let testdox_lower = testdox_name.to_lowercase();
593 method_words.to_lowercase() == testdox_lower
594}
595
596fn camel_case_to_words(s: &str) -> String {
599 let mut result = String::new();
600 for (i, ch) in s.chars().enumerate() {
601 if ch.is_ascii_uppercase() && i > 0 {
602 result.push(' ');
603 }
604 result.push(ch.to_ascii_lowercase());
605 }
606 result
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612
613 #[test]
614 fn detect_phpunit_config() {
615 let dir = tempfile::tempdir().unwrap();
616 std::fs::write(
617 dir.path().join("phpunit.xml"),
618 "<phpunit><testsuites/></phpunit>",
619 )
620 .unwrap();
621 let adapter = PhpAdapter::new();
622 let det = adapter.detect(dir.path()).unwrap();
623 assert_eq!(det.language, "PHP");
624 assert_eq!(det.framework, "PHPUnit");
625 }
626
627 #[test]
628 fn detect_phpunit_dist() {
629 let dir = tempfile::tempdir().unwrap();
630 std::fs::write(dir.path().join("phpunit.xml.dist"), "<phpunit/>").unwrap();
631 let adapter = PhpAdapter::new();
632 assert!(adapter.detect(dir.path()).is_some());
633 }
634
635 #[test]
636 fn detect_composer_phpunit() {
637 let dir = tempfile::tempdir().unwrap();
638 std::fs::write(
639 dir.path().join("composer.json"),
640 r#"{"require-dev":{"phpunit/phpunit":"^10"}}"#,
641 )
642 .unwrap();
643 let adapter = PhpAdapter::new();
644 assert!(adapter.detect(dir.path()).is_some());
645 }
646
647 #[test]
648 fn detect_no_php() {
649 let dir = tempfile::tempdir().unwrap();
650 let adapter = PhpAdapter::new();
651 assert!(adapter.detect(dir.path()).is_none());
652 }
653
654 #[test]
655 fn parse_phpunit_failures_summary() {
656 let stdout = r#"
657PHPUnit 10.5.0 by Sebastian Bergmann and contributors.
658
659..F.S 5 / 5 (100%)
660
661Time: 00:00.012, Memory: 8.00 MB
662
663FAILURES!
664Tests: 5, Assertions: 5, Failures: 1, Skipped: 1.
665"#;
666 let adapter = PhpAdapter::new();
667 let result = adapter.parse_output(stdout, "", 1);
668
669 assert_eq!(result.total_tests(), 5);
670 assert_eq!(result.total_passed(), 3);
671 assert_eq!(result.total_failed(), 1);
672 assert_eq!(result.total_skipped(), 1);
673 }
674
675 #[test]
676 fn parse_phpunit_all_pass() {
677 let stdout = r#"
678PHPUnit 10.5.0
679
680..... 5 / 5 (100%)
681
682Time: 00:00.005, Memory: 8.00 MB
683
684OK (5 tests, 5 assertions)
685"#;
686 let adapter = PhpAdapter::new();
687 let result = adapter.parse_output(stdout, "", 0);
688
689 assert_eq!(result.total_tests(), 5);
690 assert_eq!(result.total_passed(), 5);
691 assert!(result.is_success());
692 }
693
694 #[test]
695 fn parse_phpunit_with_errors() {
696 let stdout = "Tests: 3, Assertions: 3, Errors: 1.\n";
697 let adapter = PhpAdapter::new();
698 let result = adapter.parse_output(stdout, "", 1);
699
700 assert_eq!(result.total_tests(), 3);
701 assert_eq!(result.total_failed(), 1);
702 }
703
704 #[test]
705 fn parse_phpunit_empty_output() {
706 let adapter = PhpAdapter::new();
707 let result = adapter.parse_output("", "", 0);
708
709 assert_eq!(result.total_tests(), 1);
710 assert!(result.is_success());
711 }
712
713 #[test]
714 fn parse_phpunit_duration_test() {
715 assert_eq!(
716 parse_phpunit_duration("Time: 00:01.500, Memory: 8.00 MB"),
717 Some(Duration::from_millis(1500))
718 );
719 }
720
721 #[test]
722 fn parse_testdox_basic() {
723 let output = r#"
724Calculator (Tests\Calculator)
725 ✔ Can add two numbers
726 ✔ Can subtract two numbers
727 ✘ Can divide by zero
728"#;
729 let suites = parse_testdox_output(output);
730 assert_eq!(suites.len(), 1);
731 assert_eq!(suites[0].name, "Calculator");
732 assert_eq!(suites[0].tests.len(), 3);
733 assert_eq!(suites[0].tests[0].name, "Can add two numbers");
734 assert_eq!(suites[0].tests[0].status, TestStatus::Passed);
735 assert_eq!(suites[0].tests[2].status, TestStatus::Failed);
736 }
737
738 #[test]
739 fn parse_testdox_multiple_suites() {
740 let output = r#"
741Calculator (Tests\Calculator)
742 ✔ Can add
743 ✔ Can subtract
744
745StringHelper (Tests\StringHelper)
746 ✔ Can uppercase
747 ✘ Can reverse
748 ⚬ Can truncate
749"#;
750 let suites = parse_testdox_output(output);
751 assert_eq!(suites.len(), 2);
752 assert_eq!(suites[0].name, "Calculator");
753 assert_eq!(suites[0].tests.len(), 2);
754 assert_eq!(suites[1].name, "StringHelper");
755 assert_eq!(suites[1].tests.len(), 3);
756 assert_eq!(suites[1].tests[2].status, TestStatus::Skipped);
757 }
758
759 #[test]
760 fn parse_testdox_with_duration() {
761 let output = r#"
762Calculator (Tests\Calculator)
763 ✔ Can add two numbers (0.005s)
764"#;
765 let suites = parse_testdox_output(output);
766 assert_eq!(suites[0].tests[0].name, "Can add two numbers");
767 assert!(suites[0].tests[0].duration.as_micros() > 0);
768 }
769
770 #[test]
771 fn parse_testdox_empty_output() {
772 let suites = parse_testdox_output("");
773 assert!(suites.is_empty());
774 }
775
776 #[test]
777 fn is_testdox_suite_header_various() {
778 assert!(is_testdox_suite_header("Calculator (Tests\\Calculator)"));
779 assert!(is_testdox_suite_header("MyClass"));
780 assert!(!is_testdox_suite_header(""));
781 assert!(!is_testdox_suite_header("✔ Can add"));
782 assert!(!is_testdox_suite_header("PHPUnit 10.5.0"));
783 assert!(!is_testdox_suite_header("Time: 00:00.012, Memory: 8.00 MB"));
784 assert!(!is_testdox_suite_header("FAILURES!"));
785 }
786
787 #[test]
788 fn parse_testdox_test_line_passed() {
789 let test = parse_testdox_test_line("✔ Can add numbers").unwrap();
790 assert_eq!(test.name, "Can add numbers");
791 assert_eq!(test.status, TestStatus::Passed);
792 }
793
794 #[test]
795 fn parse_testdox_test_line_failed() {
796 let test = parse_testdox_test_line("✘ Can divide by zero").unwrap();
797 assert_eq!(test.name, "Can divide by zero");
798 assert_eq!(test.status, TestStatus::Failed);
799 }
800
801 #[test]
802 fn parse_testdox_test_line_skipped() {
803 let test = parse_testdox_test_line("⚬ Pending feature").unwrap();
804 assert_eq!(test.name, "Pending feature");
805 assert_eq!(test.status, TestStatus::Skipped);
806 }
807
808 #[test]
809 fn parse_testdox_test_line_empty() {
810 assert!(parse_testdox_test_line("✔ ").is_none());
811 assert!(parse_testdox_test_line("not a test").is_none());
812 }
813
814 #[test]
815 fn parse_phpunit_failure_blocks() {
816 let output = r#"
817There was 1 failure:
818
8191) Tests\CalculatorTest::testDivision
820Failed asserting that 3 matches expected 4.
821
822/home/user/tests/CalculatorTest.php:42
823
824FAILURES!
825Tests: 3, Assertions: 3, Failures: 1.
826"#;
827 let failures = parse_phpunit_failures(output);
828 assert_eq!(failures.len(), 1);
829 assert_eq!(
830 failures[0].test_method,
831 "Tests\\CalculatorTest::testDivision"
832 );
833 assert!(failures[0].message.contains("Failed asserting"));
834 assert!(
835 failures[0]
836 .location
837 .as_ref()
838 .unwrap()
839 .contains("CalculatorTest.php:42")
840 );
841 }
842
843 #[test]
844 fn parse_phpunit_multiple_failures() {
845 let output = r#"
846There were 2 failures:
847
8481) Tests\MathTest::testAdd
849Expected 5, got 4.
850
851/tests/MathTest.php:10
852
8532) Tests\MathTest::testSub
854Expected 1, got 0.
855
856/tests/MathTest.php:20
857
858FAILURES!
859"#;
860 let failures = parse_phpunit_failures(output);
861 assert_eq!(failures.len(), 2);
862 assert_eq!(failures[0].test_method, "Tests\\MathTest::testAdd");
863 assert_eq!(failures[1].test_method, "Tests\\MathTest::testSub");
864 }
865
866 #[test]
867 fn is_phpunit_failure_header_test() {
868 assert!(is_phpunit_failure_header(
869 "1) Tests\\CalculatorTest::testDivision"
870 ));
871 assert!(is_phpunit_failure_header("2) Tests\\AppTest::testBroken"));
872 assert!(!is_phpunit_failure_header("Not a failure header"));
873 assert!(!is_phpunit_failure_header(""));
874 }
875
876 #[test]
877 fn is_php_file_location_test() {
878 assert!(is_php_file_location("/home/user/tests/Test.php:42"));
879 assert!(is_php_file_location("C:\\Users\\test\\Test.php:10"));
880 assert!(!is_php_file_location("some random text"));
881 assert!(!is_php_file_location("Test.php"));
882 }
883
884 #[test]
885 fn enrich_with_errors_test() {
886 let mut suites = vec![TestSuite {
887 name: "tests".into(),
888 tests: vec![
889 TestCase {
890 name: "Can add".into(),
891 status: TestStatus::Passed,
892 duration: Duration::from_millis(0),
893 error: None,
894 },
895 TestCase {
896 name: "Can divide".into(),
897 status: TestStatus::Failed,
898 duration: Duration::from_millis(0),
899 error: None,
900 },
901 ],
902 }];
903 let failures = vec![PhpUnitFailure {
904 test_method: "Tests\\MathTest::testCanDivide".into(),
905 message: "Division by zero".into(),
906 location: Some("/tests/MathTest.php:20".into()),
907 }];
908 enrich_with_errors(&mut suites, &failures);
909 assert!(suites[0].tests[0].error.is_none());
910 assert!(suites[0].tests[1].error.is_some());
911 assert_eq!(
912 suites[0].tests[1].error.as_ref().unwrap().message,
913 "Division by zero"
914 );
915 }
916
917 #[test]
918 fn testdox_matches_test() {
919 assert!(testdox_matches(
920 "can add two numbers",
921 "testCanAddTwoNumbers"
922 ));
923 assert!(testdox_matches(
924 "Can add two numbers",
925 "testCanAddTwoNumbers"
926 ));
927 assert!(!testdox_matches("can add", "testCanSubtract"));
928 }
929
930 #[test]
931 fn camel_case_to_words_test() {
932 assert_eq!(
933 camel_case_to_words("CanAddTwoNumbers"),
934 "can add two numbers"
935 );
936 assert_eq!(camel_case_to_words("testAdd"), "test add");
937 assert_eq!(camel_case_to_words("simple"), "simple");
938 }
939
940 #[test]
941 fn truncate_test() {
942 assert_eq!(truncate("short", 100), "short");
943 let long = "a".repeat(600);
944 let truncated = truncate(&long, 500);
945 assert_eq!(truncated.len(), 500);
946 assert!(truncated.ends_with("..."));
947 }
948
949 #[test]
950 fn extract_testdox_duration_test() {
951 let (name, dur) = extract_testdox_duration("Can add two numbers (0.005s)");
952 assert_eq!(name, "Can add two numbers");
953 assert_eq!(dur, Duration::from_millis(5));
954 }
955
956 #[test]
957 fn extract_testdox_duration_ms() {
958 let (name, dur) = extract_testdox_duration("Can add (50ms)");
959 assert_eq!(name, "Can add");
960 assert_eq!(dur, Duration::from_millis(50));
961 }
962
963 #[test]
964 fn extract_testdox_duration_none() {
965 let (name, dur) = extract_testdox_duration("Can add two numbers");
966 assert_eq!(name, "Can add two numbers");
967 assert_eq!(dur, Duration::from_millis(0));
968 }
969
970 #[test]
971 fn parse_testdox_integration() {
972 let stdout = r#"
973PHPUnit 10.5.0 by Sebastian Bergmann and contributors.
974
975Calculator (Tests\Calculator)
976 ✔ Can add two numbers
977 ✘ Can divide by zero
978
979Time: 00:00.012, Memory: 8.00 MB
980
981There was 1 failure:
982
9831) Tests\Calculator::testCanDivideByZero
984Failed asserting that false is true.
985
986/tests/Calculator.php:42
987
988FAILURES!
989Tests: 2, Assertions: 2, Failures: 1.
990"#;
991 let adapter = PhpAdapter::new();
992 let result = adapter.parse_output(stdout, "", 1);
993
994 assert_eq!(result.total_tests(), 2);
995 assert_eq!(result.total_passed(), 1);
996 assert_eq!(result.total_failed(), 1);
997 let failed_test = result.suites[0]
999 .tests
1000 .iter()
1001 .find(|t| t.status == TestStatus::Failed)
1002 .unwrap();
1003 assert!(failed_test.error.is_some());
1004 }
1005}