Skip to main content

testx/adapters/
php.rs

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        // Try verbose --testdox output first, then standard summary
104        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        // Enrich with failure details
110        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
125/// Parse PHPUnit output.
126///
127/// Format:
128/// ```text
129/// PHPUnit 10.5.0 by Sebastian Bergmann and contributors.
130///
131/// ..F.S                                                               5 / 5 (100%)
132///
133/// Time: 00:00.012, Memory: 8.00 MB
134///
135/// There was 1 failure:
136///
137/// 1) Tests\CalculatorTest::testDivision
138/// Failed asserting that 3 matches expected 4.
139///
140/// FAILURES!
141/// Tests: 5, Assertions: 5, Failures: 1, Skipped: 1.
142/// ```
143fn 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        // Summary: "Tests: 5, Assertions: 5, Failures: 1, Skipped: 1."
150        // Or success: "OK (5 tests, 5 assertions)"
151        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        // "OK (5 tests, 5 assertions)"
203        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    // "Time: 00:00.012, Memory: 8.00 MB"
249    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            // Format: "00:00.012" (MM:SS.mmm)
257            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
267/// Parse PHPUnit --testdox verbose output.
268///
269/// Format:
270/// ```text
271/// Calculator (Tests\Calculator)
272///  ✔ Can add two numbers
273///  ✔ Can subtract two numbers
274///  ✘ Can divide by zero
275///  ⚬ Can multiply large numbers
276/// ```
277fn 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        // Suite header: "ClassName (Namespace\ClassName)" or just "ClassName"
286        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            // Extract suite name: use the part before " (" if present
294            current_suite = trimmed
295                .find(" (")
296                .map(|i| trimmed[..i].to_string())
297                .unwrap_or_else(|| trimmed.to_string());
298            continue;
299        }
300
301        // Test line: " ✔ Can add two numbers" or " ✘ Can divide" or " ⚬ Skipped test"
302        if let Some(test) = parse_testdox_test_line(trimmed) {
303            current_tests.push(test);
304        }
305    }
306
307    // Flush last suite
308    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
318/// Check if a line is a testdox suite header.
319/// Suite headers are non-empty lines that don't start with test markers
320/// and typically contain a class name.
321fn is_testdox_suite_header(line: &str) -> bool {
322    if line.is_empty() {
323        return false;
324    }
325    // Must not start with test markers
326    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    // Must not be a known non-header line
337    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    // Should start with uppercase letter (class name)
349    line.chars().next().is_some_and(|c| c.is_ascii_uppercase())
350}
351
352/// Parse a single testdox test line.
353fn parse_testdox_test_line(line: &str) -> Option<TestCase> {
354    // " ✔ Can add two numbers" or "✔ Can add"
355    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    // Try to extract inline duration: "Can add two numbers (0.123s)"
371    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
381/// Strip a testdox marker character from the beginning of a line.
382fn 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
391/// Extract optional duration from a testdox test name.
392/// "Can add two numbers (0.123s)" -> ("Can add two numbers", Duration)
393fn 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
407/// Parse a testdox duration string: "0.123s", "123ms"
408fn 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/// A parsed failure from PHPUnit output.
421#[derive(Debug, Clone)]
422struct PhpUnitFailure {
423    /// The fully-qualified test method name (e.g., "Tests\CalculatorTest::testDivision")
424    test_method: String,
425    /// The error/assertion message
426    message: String,
427    /// The file location if available
428    location: Option<String>,
429}
430
431/// Parse PHPUnit failure blocks.
432///
433/// Format:
434/// ```text
435/// There was 1 failure:
436///
437/// 1) Tests\CalculatorTest::testDivision
438/// Failed asserting that 3 matches expected 4.
439///
440/// /path/to/tests/CalculatorTest.php:42
441///
442/// --
443///
444/// There were 2 errors:
445///
446/// 1) Tests\AppTest::testBroken
447/// Error: Call to undefined function
448///
449/// /path/to/tests/AppTest.php:15
450/// ```
451fn 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        // Detect failure header: "1) Tests\CalculatorTest::testDivision"
460        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            // Collect message lines until we hit an empty line or another failure header
467            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                // Empty line might precede location or end of block
475                if line.is_empty() {
476                    i += 1;
477                    // Check if next line is a file location
478                    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                // File location line
486                if is_php_file_location(line) {
487                    location = Some(line.to_string());
488                    i += 1;
489                    break;
490                }
491
492                // Next failure header
493                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
517/// Check if a line is a PHPUnit failure header: "1) Tests\CalculatorTest::testDivision"
518fn is_phpunit_failure_header(line: &str) -> bool {
519    if line.len() < 3 {
520        return false;
521    }
522    // Must start with a digit, then ")"
523    let mut chars = line.chars();
524    let first = chars.next().unwrap_or(' ');
525    if !first.is_ascii_digit() {
526        return false;
527    }
528    // Find the ") " pattern
529    line.contains(") ") && line.find(") ").is_some_and(|idx| idx <= 5)
530}
531
532/// Check if a line looks like a PHP file location: "/path/to/file.php:42"
533fn 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
538/// Enrich test cases with failure details.
539fn 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            // Try to match by test name
546            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
556/// Find a matching failure for a test name.
557/// PHPUnit failure headers use "Namespace\Class::method" format.
558/// Test names from testdox are human-readable, from summary they're synthetic.
559fn find_matching_failure<'a>(
560    test_name: &str,
561    failures: &'a [PhpUnitFailure],
562) -> Option<&'a PhpUnitFailure> {
563    // Direct match on method name
564    for failure in failures {
565        // Extract just the method name from "Namespace\Class::method"
566        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        // testdox format converts "testCanAddNumbers" to "Can add numbers"
575        if testdox_matches(test_name, method) {
576            return Some(failure);
577        }
578    }
579    // If there's exactly one failure and one failed test, match them
580    if failures.len() == 1 {
581        return Some(&failures[0]);
582    }
583    None
584}
585
586/// Check if a testdox-style name matches a test method name.
587/// "Can add two numbers" should match "testCanAddTwoNumbers"
588fn testdox_matches(testdox_name: &str, method_name: &str) -> bool {
589    // Strip "test" prefix and convert camelCase to words
590    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
596/// Convert camelCase to space-separated words.
597/// "CanAddTwoNumbers" -> "can add two numbers"
598fn 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        // The failed test should have error details
998        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}