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