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::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        // Try verbose --testdox output first, then standard summary
89        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        // Enrich with failure details
95        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
110/// Parse PHPUnit output.
111///
112/// Format:
113/// ```text
114/// PHPUnit 10.5.0 by Sebastian Bergmann and contributors.
115///
116/// ..F.S                                                               5 / 5 (100%)
117///
118/// Time: 00:00.012, Memory: 8.00 MB
119///
120/// There was 1 failure:
121///
122/// 1) Tests\CalculatorTest::testDivision
123/// Failed asserting that 3 matches expected 4.
124///
125/// FAILURES!
126/// Tests: 5, Assertions: 5, Failures: 1, Skipped: 1.
127/// ```
128fn 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        // Summary: "Tests: 5, Assertions: 5, Failures: 1, Skipped: 1."
135        // Or success: "OK (5 tests, 5 assertions)"
136        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        // "OK (5 tests, 5 assertions)"
188        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    // "Time: 00:00.012, Memory: 8.00 MB"
234    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            // Format: "00:00.012" (MM:SS.mmm)
242            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
252/// Parse PHPUnit --testdox verbose output.
253///
254/// Format:
255/// ```text
256/// Calculator (Tests\Calculator)
257///  ✔ Can add two numbers
258///  ✔ Can subtract two numbers
259///  ✘ Can divide by zero
260///  ⚬ Can multiply large numbers
261/// ```
262fn 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        // Suite header: "ClassName (Namespace\ClassName)" or just "ClassName"
271        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            // Extract suite name: use the part before " (" if present
279            current_suite = trimmed
280                .find(" (")
281                .map(|i| trimmed[..i].to_string())
282                .unwrap_or_else(|| trimmed.to_string());
283            continue;
284        }
285
286        // Test line: " ✔ Can add two numbers" or " ✘ Can divide" or " ⚬ Skipped test"
287        if let Some(test) = parse_testdox_test_line(trimmed) {
288            current_tests.push(test);
289        }
290    }
291
292    // Flush last suite
293    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
303/// Check if a line is a testdox suite header.
304/// Suite headers are non-empty lines that don't start with test markers
305/// and typically contain a class name.
306fn is_testdox_suite_header(line: &str) -> bool {
307    if line.is_empty() {
308        return false;
309    }
310    // Must not start with test markers
311    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    // Must not be a known non-header line
322    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    // Should start with uppercase letter (class name)
334    line.chars().next().is_some_and(|c| c.is_ascii_uppercase())
335}
336
337/// Parse a single testdox test line.
338fn parse_testdox_test_line(line: &str) -> Option<TestCase> {
339    // " ✔ Can add two numbers" or "✔ Can add"
340    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    // Try to extract inline duration: "Can add two numbers (0.123s)"
356    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
366/// Strip a testdox marker character from the beginning of a line.
367fn 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
376/// Extract optional duration from a testdox test name.
377/// "Can add two numbers (0.123s)" -> ("Can add two numbers", Duration)
378fn 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
392/// Parse a testdox duration string: "0.123s", "123ms"
393fn 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/// A parsed failure from PHPUnit output.
406#[derive(Debug, Clone)]
407struct PhpUnitFailure {
408    /// The fully-qualified test method name (e.g., "Tests\CalculatorTest::testDivision")
409    test_method: String,
410    /// The error/assertion message
411    message: String,
412    /// The file location if available
413    location: Option<String>,
414}
415
416/// Parse PHPUnit failure blocks.
417///
418/// Format:
419/// ```text
420/// There was 1 failure:
421///
422/// 1) Tests\CalculatorTest::testDivision
423/// Failed asserting that 3 matches expected 4.
424///
425/// /path/to/tests/CalculatorTest.php:42
426///
427/// --
428///
429/// There were 2 errors:
430///
431/// 1) Tests\AppTest::testBroken
432/// Error: Call to undefined function
433///
434/// /path/to/tests/AppTest.php:15
435/// ```
436fn 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        // Detect failure header: "1) Tests\CalculatorTest::testDivision"
445        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            // Collect message lines until we hit an empty line or another failure header
452            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                // Empty line might precede location or end of block
460                if line.is_empty() {
461                    i += 1;
462                    // Check if next line is a file location
463                    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                // File location line
471                if is_php_file_location(line) {
472                    location = Some(line.to_string());
473                    i += 1;
474                    break;
475                }
476
477                // Next failure header
478                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
502/// Check if a line is a PHPUnit failure header: "1) Tests\CalculatorTest::testDivision"
503fn is_phpunit_failure_header(line: &str) -> bool {
504    if line.len() < 3 {
505        return false;
506    }
507    // Must start with a digit, then ")"
508    let mut chars = line.chars();
509    let first = chars.next().unwrap_or(' ');
510    if !first.is_ascii_digit() {
511        return false;
512    }
513    // Find the ") " pattern
514    line.contains(") ") && line.find(") ").is_some_and(|idx| idx <= 5)
515}
516
517/// Check if a line looks like a PHP file location: "/path/to/file.php:42"
518fn 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
523/// Truncate a failure message to a maximum length.
524fn 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
532/// Enrich test cases with failure details.
533fn 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            // Try to match by test name
540            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
550/// Find a matching failure for a test name.
551/// PHPUnit failure headers use "Namespace\Class::method" format.
552/// Test names from testdox are human-readable, from summary they're synthetic.
553fn find_matching_failure<'a>(
554    test_name: &str,
555    failures: &'a [PhpUnitFailure],
556) -> Option<&'a PhpUnitFailure> {
557    // Direct match on method name
558    for failure in failures {
559        // Extract just the method name from "Namespace\Class::method"
560        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        // testdox format converts "testCanAddNumbers" to "Can add numbers"
569        if testdox_matches(test_name, method) {
570            return Some(failure);
571        }
572    }
573    // If there's exactly one failure and one failed test, match them
574    if failures.len() == 1 {
575        return Some(&failures[0]);
576    }
577    None
578}
579
580/// Check if a testdox-style name matches a test method name.
581/// "Can add two numbers" should match "testCanAddTwoNumbers"
582fn testdox_matches(testdox_name: &str, method_name: &str) -> bool {
583    // Strip "test" prefix and convert camelCase to words
584    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
590/// Convert camelCase to space-separated words.
591/// "CanAddTwoNumbers" -> "can add two numbers"
592fn 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); // 500 + "..."
940        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        // The failed test should have error details
992        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}