Skip to main content

exspec_lang_php/
lib.rs

1use std::sync::OnceLock;
2
3use exspec_core::extractor::{FileAnalysis, LanguageExtractor, TestAnalysis, TestFunction};
4use exspec_core::query_utils::{
5    collect_mock_class_names, count_captures, count_captures_within_context,
6    count_duplicate_literals, extract_suppression_from_previous_line, has_any_match,
7};
8use streaming_iterator::StreamingIterator;
9use tree_sitter::{Node, Parser, Query, QueryCursor};
10
11const TEST_FUNCTION_QUERY: &str = include_str!("../queries/test_function.scm");
12const ASSERTION_QUERY: &str = include_str!("../queries/assertion.scm");
13const MOCK_USAGE_QUERY: &str = include_str!("../queries/mock_usage.scm");
14const MOCK_ASSIGNMENT_QUERY: &str = include_str!("../queries/mock_assignment.scm");
15const PARAMETERIZED_QUERY: &str = include_str!("../queries/parameterized.scm");
16const IMPORT_PBT_QUERY: &str = include_str!("../queries/import_pbt.scm");
17const IMPORT_CONTRACT_QUERY: &str = include_str!("../queries/import_contract.scm");
18const HOW_NOT_WHAT_QUERY: &str = include_str!("../queries/how_not_what.scm");
19const PRIVATE_IN_ASSERTION_QUERY: &str = include_str!("../queries/private_in_assertion.scm");
20const ERROR_TEST_QUERY: &str = include_str!("../queries/error_test.scm");
21const RELATIONAL_ASSERTION_QUERY: &str = include_str!("../queries/relational_assertion.scm");
22const WAIT_AND_SEE_QUERY: &str = include_str!("../queries/wait_and_see.scm");
23
24fn php_language() -> tree_sitter::Language {
25    tree_sitter_php::LANGUAGE_PHP.into()
26}
27
28fn cached_query<'a>(lock: &'a OnceLock<Query>, source: &str) -> &'a Query {
29    lock.get_or_init(|| Query::new(&php_language(), source).expect("invalid query"))
30}
31
32static TEST_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
33static ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
34static MOCK_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
35static MOCK_ASSIGN_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
36static PARAMETERIZED_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
37static IMPORT_PBT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
38static IMPORT_CONTRACT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
39static HOW_NOT_WHAT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
40static PRIVATE_IN_ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
41static ERROR_TEST_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
42static RELATIONAL_ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
43static WAIT_AND_SEE_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
44
45pub struct PhpExtractor;
46
47impl PhpExtractor {
48    pub fn new() -> Self {
49        Self
50    }
51
52    pub fn parser() -> Parser {
53        let mut parser = Parser::new();
54        let language = tree_sitter_php::LANGUAGE_PHP;
55        parser
56            .set_language(&language.into())
57            .expect("failed to load PHP grammar");
58        parser
59    }
60}
61
62impl Default for PhpExtractor {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68fn extract_mock_class_name(var_name: &str) -> String {
69    // PHP uses $mockDb or $mock_db patterns
70    // Strip $ prefix first
71    let name = var_name.strip_prefix('$').unwrap_or(var_name);
72    // camelCase: strip "mock" prefix
73    if let Some(stripped) = name.strip_prefix("mock") {
74        if !stripped.is_empty() && stripped.starts_with(|c: char| c.is_uppercase()) {
75            return stripped.to_string();
76        }
77    }
78    // snake_case: strip "mock_" prefix
79    if let Some(stripped) = name.strip_prefix("mock_") {
80        if !stripped.is_empty() {
81            return stripped.to_string();
82        }
83    }
84    name.to_string()
85}
86
87/// Check if the method has a `/** @test */` docblock comment on the preceding line(s).
88fn has_docblock_test_annotation(source: &str, start_row: usize) -> bool {
89    if start_row == 0 {
90        return false;
91    }
92    let lines: Vec<&str> = source.lines().collect();
93    // Look up to 5 lines above for /** ... @test ... */
94    let start = start_row.saturating_sub(5);
95    for i in (start..start_row).rev() {
96        if let Some(line) = lines.get(i) {
97            let trimmed = line.trim();
98            if trimmed.contains("@test") {
99                return true;
100            }
101            // Stop scanning at non-comment lines
102            if !trimmed.starts_with('*')
103                && !trimmed.starts_with("/**")
104                && !trimmed.starts_with("*/")
105                && !trimmed.is_empty()
106            {
107                break;
108            }
109        }
110    }
111    false
112}
113
114struct TestMatch {
115    name: String,
116    fn_start_byte: usize,
117    fn_end_byte: usize,
118    fn_start_row: usize,
119    fn_end_row: usize,
120}
121
122/// Check if a PHP method has a #[DataProvider] attribute.
123fn has_data_provider_attribute(fn_node: Node, source: &[u8]) -> bool {
124    let mut cursor = fn_node.walk();
125    if cursor.goto_first_child() {
126        loop {
127            let node = cursor.node();
128            if node.kind() == "attribute_list" {
129                let text = node.utf8_text(source).unwrap_or("");
130                if text.contains("DataProvider") {
131                    return true;
132                }
133            }
134            if !cursor.goto_next_sibling() {
135                break;
136            }
137        }
138    }
139    false
140}
141
142/// Count the number of parameters in a PHP method (formal_parameters).
143fn count_method_params(fn_node: Node) -> usize {
144    let params_node = match fn_node.child_by_field_name("parameters") {
145        Some(n) => n,
146        None => return 0,
147    };
148
149    let mut count = 0;
150    let mut cursor = params_node.walk();
151    if cursor.goto_first_child() {
152        loop {
153            let node = cursor.node();
154            if node.kind() == "simple_parameter" || node.kind() == "variadic_parameter" {
155                count += 1;
156            }
157            if !cursor.goto_next_sibling() {
158                break;
159            }
160        }
161    }
162    count
163}
164
165/// Count PHPUnit assertion calls that have a message argument (last arg is a string).
166/// In tree-sitter-php, `arguments` contains `argument` children, each wrapping an expression.
167fn count_assertion_messages_php(assertion_query: &Query, fn_node: Node, source: &[u8]) -> usize {
168    let assertion_idx = match assertion_query.capture_index_for_name("assertion") {
169        Some(idx) => idx,
170        None => return 0,
171    };
172    let mut cursor = QueryCursor::new();
173    let mut matches = cursor.matches(assertion_query, fn_node, source);
174    let mut count = 0;
175    while let Some(m) = matches.next() {
176        for cap in m.captures.iter().filter(|c| c.index == assertion_idx) {
177            let node = cap.node;
178            // member_call_expression -> arguments -> argument children
179            if let Some(args) = node.child_by_field_name("arguments") {
180                let arg_count = args.named_child_count();
181                if arg_count > 0 {
182                    if let Some(last_arg_wrapper) = args.named_child(arg_count - 1) {
183                        // argument node wraps the actual expression
184                        let expr = if last_arg_wrapper.kind() == "argument" {
185                            last_arg_wrapper.named_child(0)
186                        } else {
187                            Some(last_arg_wrapper)
188                        };
189                        if let Some(expr_node) = expr {
190                            let kind = expr_node.kind();
191                            if kind == "string" || kind == "encapsed_string" {
192                                count += 1;
193                            }
194                        }
195                    }
196                }
197            }
198        }
199    }
200    count
201}
202
203fn extract_functions_from_tree(source: &str, file_path: &str, root: Node) -> Vec<TestFunction> {
204    let test_query = cached_query(&TEST_QUERY_CACHE, TEST_FUNCTION_QUERY);
205    let assertion_query = cached_query(&ASSERTION_QUERY_CACHE, ASSERTION_QUERY);
206    let mock_query = cached_query(&MOCK_QUERY_CACHE, MOCK_USAGE_QUERY);
207    let mock_assign_query = cached_query(&MOCK_ASSIGN_QUERY_CACHE, MOCK_ASSIGNMENT_QUERY);
208    let how_not_what_query = cached_query(&HOW_NOT_WHAT_QUERY_CACHE, HOW_NOT_WHAT_QUERY);
209    let private_query = cached_query(
210        &PRIVATE_IN_ASSERTION_QUERY_CACHE,
211        PRIVATE_IN_ASSERTION_QUERY,
212    );
213    let wait_query = cached_query(&WAIT_AND_SEE_QUERY_CACHE, WAIT_AND_SEE_QUERY);
214
215    let source_bytes = source.as_bytes();
216
217    // Collect matches from test_function.scm query
218    let name_idx = test_query
219        .capture_index_for_name("name")
220        .expect("no @name capture");
221    let function_idx = test_query
222        .capture_index_for_name("function")
223        .expect("no @function capture");
224
225    let mut test_matches = Vec::new();
226    {
227        let mut cursor = QueryCursor::new();
228        let mut matches = cursor.matches(test_query, root, source_bytes);
229        while let Some(m) = matches.next() {
230            let name_capture = match m.captures.iter().find(|c| c.index == name_idx) {
231                Some(c) => c,
232                None => continue,
233            };
234            let name = match name_capture.node.utf8_text(source_bytes) {
235                Ok(s) => s.to_string(),
236                Err(_) => continue,
237            };
238
239            let fn_capture = match m.captures.iter().find(|c| c.index == function_idx) {
240                Some(c) => c,
241                None => continue,
242            };
243
244            test_matches.push(TestMatch {
245                name,
246                fn_start_byte: fn_capture.node.start_byte(),
247                fn_end_byte: fn_capture.node.end_byte(),
248                fn_start_row: fn_capture.node.start_position().row,
249                fn_end_row: fn_capture.node.end_position().row,
250            });
251        }
252    }
253
254    // Also detect methods with /** @test */ docblock annotation
255    // These are method_declaration nodes where the name does NOT start with test_
256    // but have a @test docblock. We need to walk the tree for these.
257    detect_docblock_test_methods(root, source, &mut test_matches);
258
259    // Dedup: docblock detector may re-add methods already matched by query
260    let mut seen = std::collections::HashSet::new();
261    test_matches.retain(|tm| seen.insert(tm.fn_start_byte));
262
263    let mut functions = Vec::new();
264    for tm in &test_matches {
265        let fn_node = match root.descendant_for_byte_range(tm.fn_start_byte, tm.fn_end_byte) {
266            Some(n) => n,
267            None => continue,
268        };
269
270        let line = tm.fn_start_row + 1;
271        let end_line = tm.fn_end_row + 1;
272        let line_count = end_line - line + 1;
273
274        let assertion_count = count_captures(assertion_query, "assertion", fn_node, source_bytes);
275        let mock_count = count_captures(mock_query, "mock", fn_node, source_bytes);
276        let mock_classes = collect_mock_class_names(
277            mock_assign_query,
278            fn_node,
279            source_bytes,
280            extract_mock_class_name,
281        );
282        let how_not_what_count =
283            count_captures(how_not_what_query, "how_pattern", fn_node, source_bytes);
284
285        let private_in_assertion_count = count_captures_within_context(
286            assertion_query,
287            "assertion",
288            private_query,
289            "private_access",
290            fn_node,
291            source_bytes,
292        );
293
294        let fixture_count = if has_data_provider_attribute(fn_node, source_bytes) {
295            0
296        } else {
297            count_method_params(fn_node)
298        };
299
300        // T108: wait-and-see detection
301        let has_wait = has_any_match(wait_query, "wait", fn_node, source_bytes);
302
303        // T107: assertion message count
304        let assertion_message_count =
305            count_assertion_messages_php(assertion_query, fn_node, source_bytes);
306
307        // T106: duplicate literal count
308        let duplicate_literal_count = count_duplicate_literals(
309            assertion_query,
310            fn_node,
311            source_bytes,
312            &["integer", "float", "string", "encapsed_string"],
313        );
314
315        let suppressed_rules = extract_suppression_from_previous_line(source, tm.fn_start_row);
316
317        functions.push(TestFunction {
318            name: tm.name.clone(),
319            file: file_path.to_string(),
320            line,
321            end_line,
322            analysis: TestAnalysis {
323                assertion_count,
324                mock_count,
325                mock_classes,
326                line_count,
327                how_not_what_count: how_not_what_count + private_in_assertion_count,
328                fixture_count,
329                has_wait,
330                assertion_message_count,
331                duplicate_literal_count,
332                suppressed_rules,
333            },
334        });
335    }
336
337    functions
338}
339
340fn detect_docblock_test_methods(root: Node, source: &str, matches: &mut Vec<TestMatch>) {
341    let source_bytes = source.as_bytes();
342    let mut cursor = root.walk();
343
344    // Walk all method_declaration nodes
345    fn visit(
346        cursor: &mut tree_sitter::TreeCursor,
347        source: &str,
348        source_bytes: &[u8],
349        matches: &mut Vec<TestMatch>,
350    ) {
351        loop {
352            let node = cursor.node();
353            if node.kind() == "method_declaration" {
354                if let Some(name_node) = node.child_by_field_name("name") {
355                    if let Ok(name) = name_node.utf8_text(source_bytes) {
356                        // Skip methods already matched by test* prefix query or #[Test] attribute
357                        if !name.starts_with("test") {
358                            // Check for @test docblock
359                            if has_docblock_test_annotation(source, node.start_position().row) {
360                                matches.push(TestMatch {
361                                    name: name.to_string(),
362                                    fn_start_byte: node.start_byte(),
363                                    fn_end_byte: node.end_byte(),
364                                    fn_start_row: node.start_position().row,
365                                    fn_end_row: node.end_position().row,
366                                });
367                            }
368                        }
369                    }
370                }
371            }
372            if cursor.goto_first_child() {
373                visit(cursor, source, source_bytes, matches);
374                cursor.goto_parent();
375            }
376            if !cursor.goto_next_sibling() {
377                break;
378            }
379        }
380    }
381
382    if cursor.goto_first_child() {
383        visit(&mut cursor, source, source_bytes, matches);
384    }
385}
386
387impl LanguageExtractor for PhpExtractor {
388    fn extract_test_functions(&self, source: &str, file_path: &str) -> Vec<TestFunction> {
389        let mut parser = Self::parser();
390        let tree = match parser.parse(source, None) {
391            Some(t) => t,
392            None => return Vec::new(),
393        };
394        extract_functions_from_tree(source, file_path, tree.root_node())
395    }
396
397    fn extract_file_analysis(&self, source: &str, file_path: &str) -> FileAnalysis {
398        let mut parser = Self::parser();
399        let tree = match parser.parse(source, None) {
400            Some(t) => t,
401            None => {
402                return FileAnalysis {
403                    file: file_path.to_string(),
404                    functions: Vec::new(),
405                    has_pbt_import: false,
406                    has_contract_import: false,
407                    has_error_test: false,
408                    has_relational_assertion: false,
409                    parameterized_count: 0,
410                };
411            }
412        };
413
414        let root = tree.root_node();
415        let source_bytes = source.as_bytes();
416
417        let functions = extract_functions_from_tree(source, file_path, root);
418
419        let param_query = cached_query(&PARAMETERIZED_QUERY_CACHE, PARAMETERIZED_QUERY);
420        let parameterized_count = count_captures(param_query, "parameterized", root, source_bytes);
421
422        let pbt_query = cached_query(&IMPORT_PBT_QUERY_CACHE, IMPORT_PBT_QUERY);
423        let has_pbt_import = has_any_match(pbt_query, "pbt_import", root, source_bytes);
424
425        let contract_query = cached_query(&IMPORT_CONTRACT_QUERY_CACHE, IMPORT_CONTRACT_QUERY);
426        let has_contract_import =
427            has_any_match(contract_query, "contract_import", root, source_bytes);
428
429        let error_test_query = cached_query(&ERROR_TEST_QUERY_CACHE, ERROR_TEST_QUERY);
430        let has_error_test = has_any_match(error_test_query, "error_test", root, source_bytes);
431
432        let relational_query = cached_query(
433            &RELATIONAL_ASSERTION_QUERY_CACHE,
434            RELATIONAL_ASSERTION_QUERY,
435        );
436        let has_relational_assertion =
437            has_any_match(relational_query, "relational", root, source_bytes);
438
439        FileAnalysis {
440            file: file_path.to_string(),
441            functions,
442            has_pbt_import,
443            has_contract_import,
444            has_error_test,
445            has_relational_assertion,
446            parameterized_count,
447        }
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    fn fixture(name: &str) -> String {
456        let path = format!(
457            "{}/tests/fixtures/php/{}",
458            env!("CARGO_MANIFEST_DIR").replace("/crates/lang-php", ""),
459            name
460        );
461        std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"))
462    }
463
464    // --- Phase 1 preserved tests ---
465
466    #[test]
467    fn parse_php_source() {
468        let source = "<?php\nfunction test_example(): void {}\n";
469        let mut parser = PhpExtractor::parser();
470        let tree = parser.parse(source, None).unwrap();
471        assert_eq!(tree.root_node().kind(), "program");
472    }
473
474    #[test]
475    fn php_extractor_implements_language_extractor() {
476        let extractor = PhpExtractor::new();
477        let _: &dyn exspec_core::extractor::LanguageExtractor = &extractor;
478    }
479
480    // --- Test function extraction ---
481
482    #[test]
483    fn extract_single_phpunit_test() {
484        let source = fixture("t001_pass.php");
485        let extractor = PhpExtractor::new();
486        let funcs = extractor.extract_test_functions(&source, "t001_pass.php");
487        assert_eq!(funcs.len(), 1);
488        assert_eq!(funcs[0].name, "test_create_user");
489        assert_eq!(funcs[0].line, 5);
490    }
491
492    #[test]
493    fn extract_multiple_phpunit_tests_excludes_helpers() {
494        let source = fixture("multiple_tests.php");
495        let extractor = PhpExtractor::new();
496        let funcs = extractor.extract_test_functions(&source, "multiple_tests.php");
497        assert_eq!(funcs.len(), 3);
498        let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
499        assert_eq!(names, vec!["test_add", "test_subtract", "test_multiply"]);
500    }
501
502    #[test]
503    fn extract_test_with_attribute() {
504        let source = fixture("t001_pass_attribute.php");
505        let extractor = PhpExtractor::new();
506        let funcs = extractor.extract_test_functions(&source, "t001_pass_attribute.php");
507        assert_eq!(funcs.len(), 1);
508        assert_eq!(funcs[0].name, "createUser");
509    }
510
511    #[test]
512    fn extract_pest_test() {
513        let source = fixture("t001_pass_pest.php");
514        let extractor = PhpExtractor::new();
515        let funcs = extractor.extract_test_functions(&source, "t001_pass_pest.php");
516        assert_eq!(funcs.len(), 1);
517        assert_eq!(funcs[0].name, "creates a user");
518    }
519
520    #[test]
521    fn line_count_calculation() {
522        let source = fixture("t001_pass.php");
523        let extractor = PhpExtractor::new();
524        let funcs = extractor.extract_test_functions(&source, "t001_pass.php");
525        assert_eq!(
526            funcs[0].analysis.line_count,
527            funcs[0].end_line - funcs[0].line + 1
528        );
529    }
530
531    // --- Assertion detection ---
532
533    #[test]
534    fn assertion_count_zero_for_violation() {
535        let source = fixture("t001_violation.php");
536        let extractor = PhpExtractor::new();
537        let funcs = extractor.extract_test_functions(&source, "t001_violation.php");
538        assert_eq!(funcs.len(), 1);
539        assert_eq!(funcs[0].analysis.assertion_count, 0);
540    }
541
542    #[test]
543    fn assertion_count_positive_for_pass() {
544        let source = fixture("t001_pass.php");
545        let extractor = PhpExtractor::new();
546        let funcs = extractor.extract_test_functions(&source, "t001_pass.php");
547        assert_eq!(funcs[0].analysis.assertion_count, 1);
548    }
549
550    #[test]
551    fn pest_expect_assertion_counted() {
552        let source = fixture("t001_pass_pest.php");
553        let extractor = PhpExtractor::new();
554        let funcs = extractor.extract_test_functions(&source, "t001_pass_pest.php");
555        assert!(
556            funcs[0].analysis.assertion_count >= 1,
557            "expected >= 1, got {}",
558            funcs[0].analysis.assertion_count
559        );
560    }
561
562    #[test]
563    fn pest_violation_zero_assertions() {
564        let source = fixture("t001_violation_pest.php");
565        let extractor = PhpExtractor::new();
566        let funcs = extractor.extract_test_functions(&source, "t001_violation_pest.php");
567        assert_eq!(funcs[0].analysis.assertion_count, 0);
568    }
569
570    // --- T001 FP fix: Mockery + PHPUnit mock expectations (#38) ---
571
572    #[test]
573    fn t001_mockery_should_receive_counts_as_assertion() {
574        // TC-01: $mock->shouldReceive('x')->once() -> assertion_count >= 1
575        let source = fixture("t001_pass_mockery.php");
576        let extractor = PhpExtractor::new();
577        let funcs = extractor.extract_test_functions(&source, "t001_pass_mockery.php");
578        assert!(funcs.len() >= 1);
579        assert!(
580            funcs[0].analysis.assertion_count >= 1,
581            "shouldReceive() should count as assertion, got {}",
582            funcs[0].analysis.assertion_count
583        );
584    }
585
586    #[test]
587    fn t001_mockery_should_have_received_counts_as_assertion() {
588        // TC-02: $mock->shouldHaveReceived('x')->once() -> assertion_count >= 1
589        let source = fixture("t001_pass_mockery.php");
590        let extractor = PhpExtractor::new();
591        let funcs = extractor.extract_test_functions(&source, "t001_pass_mockery.php");
592        // test_verifies_post_execution is the 2nd test
593        assert!(funcs.len() >= 2);
594        assert!(
595            funcs[1].analysis.assertion_count >= 1,
596            "shouldHaveReceived() should count as assertion, got {}",
597            funcs[1].analysis.assertion_count
598        );
599    }
600
601    #[test]
602    fn t001_mockery_should_not_have_received_counts_as_assertion() {
603        // TC-03: $mock->shouldNotHaveReceived('x') -> assertion_count >= 1
604        let source = fixture("t001_pass_mockery.php");
605        let extractor = PhpExtractor::new();
606        let funcs = extractor.extract_test_functions(&source, "t001_pass_mockery.php");
607        // test_negative_verification is the 3rd test
608        assert!(funcs.len() >= 3);
609        assert!(
610            funcs[2].analysis.assertion_count >= 1,
611            "shouldNotHaveReceived() should count as assertion, got {}",
612            funcs[2].analysis.assertion_count
613        );
614    }
615
616    #[test]
617    fn t001_phpunit_mock_expects_not_this_not_counted() {
618        // $mock->expects() is NOT counted as assertion (only $this->expects() is)
619        let source = fixture("t001_violation_phpunit_mock.php");
620        let extractor = PhpExtractor::new();
621        let funcs = extractor.extract_test_functions(&source, "t001_violation_phpunit_mock.php");
622        assert_eq!(funcs.len(), 1);
623        assert_eq!(
624            funcs[0].analysis.assertion_count, 0,
625            "$mock->expects() should NOT count as assertion, got {}",
626            funcs[0].analysis.assertion_count
627        );
628    }
629
630    #[test]
631    fn t001_mockery_multiple_expectations_counted() {
632        // TC-06: 3x shouldReceive calls -> assertion_count >= 3
633        let source = fixture("t001_pass_mockery.php");
634        let extractor = PhpExtractor::new();
635        let funcs = extractor.extract_test_functions(&source, "t001_pass_mockery.php");
636        // test_multiple_mock_expectations is the 4th test
637        assert!(funcs.len() >= 4);
638        assert!(
639            funcs[3].analysis.assertion_count >= 3,
640            "3x shouldReceive() should count as >= 3 assertions, got {}",
641            funcs[3].analysis.assertion_count
642        );
643    }
644
645    // --- camelCase test detection ---
646
647    #[test]
648    fn extract_camelcase_phpunit_test() {
649        let source = fixture("t001_pass_camelcase.php");
650        let extractor = PhpExtractor::new();
651        let funcs = extractor.extract_test_functions(&source, "t001_pass_camelcase.php");
652        assert_eq!(funcs.len(), 1);
653        assert_eq!(funcs[0].name, "testCreateUser");
654        assert!(funcs[0].analysis.assertion_count >= 1);
655    }
656
657    #[test]
658    fn extract_docblock_test() {
659        let source = fixture("t001_pass_docblock.php");
660        let extractor = PhpExtractor::new();
661        let funcs = extractor.extract_test_functions(&source, "t001_pass_docblock.php");
662        assert_eq!(funcs.len(), 1);
663        assert_eq!(funcs[0].name, "creates_a_user");
664        assert!(funcs[0].analysis.assertion_count >= 1);
665    }
666
667    // --- Mock class name extraction ---
668
669    #[test]
670    fn mock_class_name_extraction() {
671        assert_eq!(extract_mock_class_name("$mockDb"), "Db");
672        assert_eq!(extract_mock_class_name("$mock_payment"), "payment");
673        assert_eq!(extract_mock_class_name("$service"), "service");
674        assert_eq!(extract_mock_class_name("$mockUserService"), "UserService");
675    }
676
677    // --- Mock detection ---
678
679    #[test]
680    fn mock_count_for_violation() {
681        let source = fixture("t002_violation.php");
682        let extractor = PhpExtractor::new();
683        let funcs = extractor.extract_test_functions(&source, "t002_violation.php");
684        assert_eq!(funcs.len(), 1);
685        assert_eq!(funcs[0].analysis.mock_count, 6);
686    }
687
688    #[test]
689    fn mock_count_for_pass() {
690        let source = fixture("t002_pass.php");
691        let extractor = PhpExtractor::new();
692        let funcs = extractor.extract_test_functions(&source, "t002_pass.php");
693        assert_eq!(funcs.len(), 1);
694        assert_eq!(funcs[0].analysis.mock_count, 1);
695        assert_eq!(funcs[0].analysis.mock_classes, vec!["Repo"]);
696    }
697
698    #[test]
699    fn mock_classes_for_violation() {
700        let source = fixture("t002_violation.php");
701        let extractor = PhpExtractor::new();
702        let funcs = extractor.extract_test_functions(&source, "t002_violation.php");
703        assert!(
704            funcs[0].analysis.mock_classes.len() >= 4,
705            "expected >= 4 mock classes, got: {:?}",
706            funcs[0].analysis.mock_classes
707        );
708    }
709
710    // --- Giant test ---
711
712    #[test]
713    fn giant_test_line_count() {
714        let source = fixture("t003_violation.php");
715        let extractor = PhpExtractor::new();
716        let funcs = extractor.extract_test_functions(&source, "t003_violation.php");
717        assert_eq!(funcs.len(), 1);
718        assert!(funcs[0].analysis.line_count > 50);
719    }
720
721    #[test]
722    fn short_test_line_count() {
723        let source = fixture("t003_pass.php");
724        let extractor = PhpExtractor::new();
725        let funcs = extractor.extract_test_functions(&source, "t003_pass.php");
726        assert_eq!(funcs.len(), 1);
727        assert!(funcs[0].analysis.line_count <= 50);
728    }
729
730    // --- Inline suppression ---
731
732    #[test]
733    fn suppressed_test_has_suppressed_rules() {
734        let source = fixture("suppressed.php");
735        let extractor = PhpExtractor::new();
736        let funcs = extractor.extract_test_functions(&source, "suppressed.php");
737        assert_eq!(funcs.len(), 1);
738        assert_eq!(funcs[0].analysis.mock_count, 6);
739        assert!(funcs[0]
740            .analysis
741            .suppressed_rules
742            .iter()
743            .any(|r| r.0 == "T002"));
744    }
745
746    #[test]
747    fn non_suppressed_test_has_empty_suppressed_rules() {
748        let source = fixture("t002_violation.php");
749        let extractor = PhpExtractor::new();
750        let funcs = extractor.extract_test_functions(&source, "t002_violation.php");
751        assert!(funcs[0].analysis.suppressed_rules.is_empty());
752    }
753
754    // --- File analysis: parameterized ---
755
756    #[test]
757    fn file_analysis_detects_parameterized() {
758        let source = fixture("t004_pass.php");
759        let extractor = PhpExtractor::new();
760        let fa = extractor.extract_file_analysis(&source, "t004_pass.php");
761        assert!(
762            fa.parameterized_count >= 1,
763            "expected parameterized_count >= 1, got {}",
764            fa.parameterized_count
765        );
766    }
767
768    #[test]
769    fn file_analysis_no_parameterized() {
770        let source = fixture("t004_violation.php");
771        let extractor = PhpExtractor::new();
772        let fa = extractor.extract_file_analysis(&source, "t004_violation.php");
773        assert_eq!(fa.parameterized_count, 0);
774    }
775
776    #[test]
777    fn file_analysis_pest_parameterized() {
778        let source = fixture("t004_pass_pest.php");
779        let extractor = PhpExtractor::new();
780        let fa = extractor.extract_file_analysis(&source, "t004_pass_pest.php");
781        assert!(
782            fa.parameterized_count >= 1,
783            "expected parameterized_count >= 1, got {}",
784            fa.parameterized_count
785        );
786    }
787
788    // --- File analysis: PBT import ---
789
790    #[test]
791    fn file_analysis_no_pbt_import() {
792        // PHP PBT is not mature; always returns false
793        let source = fixture("t005_violation.php");
794        let extractor = PhpExtractor::new();
795        let fa = extractor.extract_file_analysis(&source, "t005_violation.php");
796        assert!(!fa.has_pbt_import);
797    }
798
799    // --- File analysis: contract import ---
800
801    #[test]
802    fn file_analysis_detects_contract_import() {
803        let source = fixture("t008_pass.php");
804        let extractor = PhpExtractor::new();
805        let fa = extractor.extract_file_analysis(&source, "t008_pass.php");
806        assert!(fa.has_contract_import);
807    }
808
809    #[test]
810    fn file_analysis_no_contract_import() {
811        let source = fixture("t008_violation.php");
812        let extractor = PhpExtractor::new();
813        let fa = extractor.extract_file_analysis(&source, "t008_violation.php");
814        assert!(!fa.has_contract_import);
815    }
816
817    // --- FQCN attribute detection ---
818
819    #[test]
820    fn extract_fqcn_attribute_test() {
821        let source = fixture("t001_pass_fqcn_attribute.php");
822        let extractor = PhpExtractor::new();
823        let funcs = extractor.extract_test_functions(&source, "t001_pass_fqcn_attribute.php");
824        assert_eq!(funcs.len(), 1);
825        assert_eq!(funcs[0].name, "creates_a_user");
826        assert!(funcs[0].analysis.assertion_count >= 1);
827    }
828
829    // --- Pest arrow function detection ---
830
831    #[test]
832    fn extract_pest_arrow_function() {
833        let source = fixture("t001_pass_pest_arrow.php");
834        let extractor = PhpExtractor::new();
835        let funcs = extractor.extract_test_functions(&source, "t001_pass_pest_arrow.php");
836        assert_eq!(funcs.len(), 1);
837        assert_eq!(funcs[0].name, "creates a user");
838        assert!(funcs[0].analysis.assertion_count >= 1);
839    }
840
841    #[test]
842    fn extract_pest_arrow_function_chained() {
843        let source = fixture("t001_pass_pest_arrow_chained.php");
844        let extractor = PhpExtractor::new();
845        let funcs = extractor.extract_test_functions(&source, "t001_pass_pest_arrow_chained.php");
846        assert_eq!(funcs.len(), 1);
847        assert_eq!(funcs[0].name, "adds numbers");
848        assert!(funcs[0].analysis.assertion_count >= 1);
849    }
850
851    // --- Issue #8: FQCN false positive ---
852
853    #[test]
854    fn fqcn_rejects_non_phpunit_attribute() {
855        let source = fixture("fqcn_false_positive.php");
856        let extractor = PhpExtractor::new();
857        let funcs = extractor.extract_test_functions(&source, "fqcn_false_positive.php");
858        let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
859        assert!(
860            !names.contains(&"custom_attribute_method"),
861            "custom #[\\MyApp\\Attributes\\Test] should NOT be detected: {names:?}"
862        );
863        assert!(
864            names.contains(&"real_phpunit_attribute"),
865            "real #[\\PHPUnit\\...\\Test] should be detected: {names:?}"
866        );
867        assert_eq!(funcs.len(), 1);
868    }
869
870    // --- Issue #7: Docblock double detection ---
871
872    #[test]
873    fn docblock_attribute_no_double_detection() {
874        let source = fixture("docblock_double_detection.php");
875        let extractor = PhpExtractor::new();
876        let funcs = extractor.extract_test_functions(&source, "docblock_double_detection.php");
877        let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
878        assert_eq!(
879            funcs.len(),
880            3,
881            "expected exactly 3 test functions (no duplicates): {names:?}"
882        );
883        assert!(names.contains(&"short_attribute_with_docblock"));
884        assert!(names.contains(&"fqcn_attribute_with_docblock"));
885        assert!(names.contains(&"docblock_only"));
886    }
887
888    // --- File analysis preserves functions ---
889
890    #[test]
891    fn file_analysis_preserves_test_functions() {
892        let source = fixture("t001_pass.php");
893        let extractor = PhpExtractor::new();
894        let fa = extractor.extract_file_analysis(&source, "t001_pass.php");
895        assert_eq!(fa.functions.len(), 1);
896        assert_eq!(fa.functions[0].name, "test_create_user");
897    }
898
899    // --- Query capture name verification (#14) ---
900
901    fn make_query(scm: &str) -> Query {
902        Query::new(&php_language(), scm).unwrap()
903    }
904
905    #[test]
906    fn query_capture_names_test_function() {
907        let q = make_query(include_str!("../queries/test_function.scm"));
908        assert!(
909            q.capture_index_for_name("name").is_some(),
910            "test_function.scm must define @name capture"
911        );
912        assert!(
913            q.capture_index_for_name("function").is_some(),
914            "test_function.scm must define @function capture"
915        );
916    }
917
918    #[test]
919    fn query_capture_names_assertion() {
920        let q = make_query(include_str!("../queries/assertion.scm"));
921        assert!(
922            q.capture_index_for_name("assertion").is_some(),
923            "assertion.scm must define @assertion capture"
924        );
925    }
926
927    #[test]
928    fn query_capture_names_mock_usage() {
929        let q = make_query(include_str!("../queries/mock_usage.scm"));
930        assert!(
931            q.capture_index_for_name("mock").is_some(),
932            "mock_usage.scm must define @mock capture"
933        );
934    }
935
936    #[test]
937    fn query_capture_names_mock_assignment() {
938        let q = make_query(include_str!("../queries/mock_assignment.scm"));
939        assert!(
940            q.capture_index_for_name("var_name").is_some(),
941            "mock_assignment.scm must define @var_name (required by collect_mock_class_names .expect())"
942        );
943    }
944
945    #[test]
946    fn query_capture_names_parameterized() {
947        let q = make_query(include_str!("../queries/parameterized.scm"));
948        assert!(
949            q.capture_index_for_name("parameterized").is_some(),
950            "parameterized.scm must define @parameterized capture"
951        );
952    }
953
954    // Comment-only file by design (PHP PBT is not mature).
955    // This assertion will fail when a real PBT library is added.
956    // When that happens, update the has_any_match call site in extract_file_analysis() accordingly.
957    #[test]
958    fn query_capture_names_import_pbt_comment_only() {
959        let q = make_query(include_str!("../queries/import_pbt.scm"));
960        assert!(
961            q.capture_index_for_name("pbt_import").is_none(),
962            "PHP import_pbt.scm is intentionally comment-only"
963        );
964    }
965
966    #[test]
967    fn query_capture_names_import_contract() {
968        let q = make_query(include_str!("../queries/import_contract.scm"));
969        assert!(
970            q.capture_index_for_name("contract_import").is_some(),
971            "import_contract.scm must define @contract_import capture"
972        );
973    }
974
975    // --- T103: missing-error-test ---
976
977    #[test]
978    fn error_test_expect_exception() {
979        let source = fixture("t103_pass.php");
980        let extractor = PhpExtractor::new();
981        let fa = extractor.extract_file_analysis(&source, "t103_pass.php");
982        assert!(
983            fa.has_error_test,
984            "$this->expectException should set has_error_test"
985        );
986    }
987
988    #[test]
989    fn error_test_pest_to_throw() {
990        let source = fixture("t103_pass_pest.php");
991        let extractor = PhpExtractor::new();
992        let fa = extractor.extract_file_analysis(&source, "t103_pass_pest.php");
993        assert!(
994            fa.has_error_test,
995            "Pest ->toThrow() should set has_error_test"
996        );
997    }
998
999    #[test]
1000    fn error_test_no_patterns() {
1001        let source = fixture("t103_violation.php");
1002        let extractor = PhpExtractor::new();
1003        let fa = extractor.extract_file_analysis(&source, "t103_violation.php");
1004        assert!(
1005            !fa.has_error_test,
1006            "no error patterns should set has_error_test=false"
1007        );
1008    }
1009
1010    #[test]
1011    fn query_capture_names_error_test() {
1012        let q = make_query(include_str!("../queries/error_test.scm"));
1013        assert!(
1014            q.capture_index_for_name("error_test").is_some(),
1015            "error_test.scm must define @error_test capture"
1016        );
1017    }
1018
1019    // --- T105: deterministic-no-metamorphic ---
1020
1021    #[test]
1022    fn relational_assertion_pass_greater_than() {
1023        let source = fixture("t105_pass.php");
1024        let extractor = PhpExtractor::new();
1025        let fa = extractor.extract_file_analysis(&source, "t105_pass.php");
1026        assert!(
1027            fa.has_relational_assertion,
1028            "assertGreaterThan should set has_relational_assertion"
1029        );
1030    }
1031
1032    #[test]
1033    fn relational_assertion_violation() {
1034        let source = fixture("t105_violation.php");
1035        let extractor = PhpExtractor::new();
1036        let fa = extractor.extract_file_analysis(&source, "t105_violation.php");
1037        assert!(
1038            !fa.has_relational_assertion,
1039            "only assertEquals should not set has_relational_assertion"
1040        );
1041    }
1042
1043    #[test]
1044    fn query_capture_names_relational_assertion() {
1045        let q = make_query(include_str!("../queries/relational_assertion.scm"));
1046        assert!(
1047            q.capture_index_for_name("relational").is_some(),
1048            "relational_assertion.scm must define @relational capture"
1049        );
1050    }
1051
1052    // --- T101: how-not-what ---
1053
1054    #[test]
1055    fn how_not_what_expects() {
1056        let source = fixture("t101_violation.php");
1057        let extractor = PhpExtractor::new();
1058        let funcs = extractor.extract_test_functions(&source, "t101_violation.php");
1059        assert!(
1060            funcs[0].analysis.how_not_what_count > 0,
1061            "->expects() should trigger how_not_what, got {}",
1062            funcs[0].analysis.how_not_what_count
1063        );
1064    }
1065
1066    #[test]
1067    fn how_not_what_should_receive() {
1068        let source = fixture("t101_violation.php");
1069        let extractor = PhpExtractor::new();
1070        let funcs = extractor.extract_test_functions(&source, "t101_violation.php");
1071        assert!(
1072            funcs[1].analysis.how_not_what_count > 0,
1073            "->shouldReceive() should trigger how_not_what, got {}",
1074            funcs[1].analysis.how_not_what_count
1075        );
1076    }
1077
1078    #[test]
1079    fn how_not_what_pass() {
1080        let source = fixture("t101_pass.php");
1081        let extractor = PhpExtractor::new();
1082        let funcs = extractor.extract_test_functions(&source, "t101_pass.php");
1083        assert_eq!(
1084            funcs[0].analysis.how_not_what_count, 0,
1085            "no mock patterns should have how_not_what_count=0"
1086        );
1087    }
1088
1089    #[test]
1090    fn how_not_what_private_access() {
1091        let source = fixture("t101_private_violation.php");
1092        let extractor = PhpExtractor::new();
1093        let funcs = extractor.extract_test_functions(&source, "t101_private_violation.php");
1094        assert!(
1095            funcs[0].analysis.how_not_what_count > 0,
1096            "$obj->_name in assertion should trigger how_not_what, got {}",
1097            funcs[0].analysis.how_not_what_count
1098        );
1099    }
1100
1101    #[test]
1102    fn query_capture_names_how_not_what() {
1103        let q = make_query(include_str!("../queries/how_not_what.scm"));
1104        assert!(
1105            q.capture_index_for_name("how_pattern").is_some(),
1106            "how_not_what.scm must define @how_pattern capture"
1107        );
1108    }
1109
1110    #[test]
1111    fn query_capture_names_private_in_assertion() {
1112        let q = make_query(include_str!("../queries/private_in_assertion.scm"));
1113        assert!(
1114            q.capture_index_for_name("private_access").is_some(),
1115            "private_in_assertion.scm must define @private_access capture"
1116        );
1117    }
1118
1119    // --- T102: fixture-sprawl ---
1120
1121    #[test]
1122    fn fixture_count_for_violation() {
1123        let source = fixture("t102_violation.php");
1124        let extractor = PhpExtractor::new();
1125        let funcs = extractor.extract_test_functions(&source, "t102_violation.php");
1126        assert_eq!(
1127            funcs[0].analysis.fixture_count, 7,
1128            "expected 7 parameters as fixture_count"
1129        );
1130    }
1131
1132    #[test]
1133    fn fixture_count_for_pass() {
1134        let source = fixture("t102_pass.php");
1135        let extractor = PhpExtractor::new();
1136        let funcs = extractor.extract_test_functions(&source, "t102_pass.php");
1137        assert_eq!(
1138            funcs[0].analysis.fixture_count, 0,
1139            "expected 0 parameters as fixture_count"
1140        );
1141    }
1142
1143    #[test]
1144    fn fixture_count_zero_for_dataprovider_method() {
1145        let source = fixture("t102_dataprovider.php");
1146        let extractor = PhpExtractor::new();
1147        let funcs = extractor.extract_test_functions(&source, "t102_dataprovider.php");
1148        // test_addition: 3 params but has #[DataProvider] -> fixture_count = 0
1149        let addition = funcs.iter().find(|f| f.name == "test_addition").unwrap();
1150        assert_eq!(
1151            addition.analysis.fixture_count, 0,
1152            "DataProvider params should not count as fixtures"
1153        );
1154        // addition_with_test_attr: 3 params + #[DataProvider] + #[Test] -> fixture_count = 0
1155        let with_attr = funcs
1156            .iter()
1157            .find(|f| f.name == "addition_with_test_attr")
1158            .unwrap();
1159        assert_eq!(
1160            with_attr.analysis.fixture_count, 0,
1161            "DataProvider params should not count as fixtures even with #[Test]"
1162        );
1163        // test_with_fixtures: 6 params, no DataProvider -> fixture_count = 6
1164        let fixtures = funcs
1165            .iter()
1166            .find(|f| f.name == "test_with_fixtures")
1167            .unwrap();
1168        assert_eq!(
1169            fixtures.analysis.fixture_count, 6,
1170            "non-DataProvider params should count as fixtures"
1171        );
1172    }
1173
1174    // --- T108: wait-and-see ---
1175
1176    #[test]
1177    fn wait_and_see_violation_sleep() {
1178        let source = fixture("t108_violation_sleep.php");
1179        let extractor = PhpExtractor::new();
1180        let funcs = extractor.extract_test_functions(&source, "t108_violation_sleep.php");
1181        assert!(!funcs.is_empty());
1182        for func in &funcs {
1183            assert!(
1184                func.analysis.has_wait,
1185                "test '{}' should have has_wait=true",
1186                func.name
1187            );
1188        }
1189    }
1190
1191    #[test]
1192    fn wait_and_see_pass_no_sleep() {
1193        let source = fixture("t108_pass_no_sleep.php");
1194        let extractor = PhpExtractor::new();
1195        let funcs = extractor.extract_test_functions(&source, "t108_pass_no_sleep.php");
1196        assert_eq!(funcs.len(), 1);
1197        assert!(
1198            !funcs[0].analysis.has_wait,
1199            "test without sleep should have has_wait=false"
1200        );
1201    }
1202
1203    #[test]
1204    fn query_capture_names_wait_and_see() {
1205        let q = make_query(include_str!("../queries/wait_and_see.scm"));
1206        assert!(
1207            q.capture_index_for_name("wait").is_some(),
1208            "wait_and_see.scm must define @wait capture"
1209        );
1210    }
1211
1212    // --- T107: assertion-roulette ---
1213
1214    #[test]
1215    fn t107_violation_no_messages() {
1216        let source = fixture("t107_violation.php");
1217        let extractor = PhpExtractor::new();
1218        let funcs = extractor.extract_test_functions(&source, "t107_violation.php");
1219        assert_eq!(funcs.len(), 1);
1220        assert!(
1221            funcs[0].analysis.assertion_count >= 2,
1222            "should have multiple assertions"
1223        );
1224        assert_eq!(
1225            funcs[0].analysis.assertion_message_count, 0,
1226            "no assertion should have a message"
1227        );
1228    }
1229
1230    #[test]
1231    fn t107_pass_with_messages() {
1232        let source = fixture("t107_pass_with_messages.php");
1233        let extractor = PhpExtractor::new();
1234        let funcs = extractor.extract_test_functions(&source, "t107_pass_with_messages.php");
1235        assert_eq!(funcs.len(), 1);
1236        assert!(
1237            funcs[0].analysis.assertion_message_count >= 1,
1238            "assertions with messages should be counted"
1239        );
1240    }
1241
1242    // --- T109: undescriptive-test-name ---
1243
1244    #[test]
1245    fn t109_violation_names_detected() {
1246        let source = fixture("t109_violation.php");
1247        let extractor = PhpExtractor::new();
1248        let funcs = extractor.extract_test_functions(&source, "t109_violation.php");
1249        assert!(!funcs.is_empty());
1250        for func in &funcs {
1251            assert!(
1252                exspec_core::rules::is_undescriptive_test_name(&func.name),
1253                "test '{}' should be undescriptive",
1254                func.name
1255            );
1256        }
1257    }
1258
1259    #[test]
1260    fn t109_pass_descriptive_names() {
1261        let source = fixture("t109_pass.php");
1262        let extractor = PhpExtractor::new();
1263        let funcs = extractor.extract_test_functions(&source, "t109_pass.php");
1264        assert!(!funcs.is_empty());
1265        for func in &funcs {
1266            assert!(
1267                !exspec_core::rules::is_undescriptive_test_name(&func.name),
1268                "test '{}' should be descriptive",
1269                func.name
1270            );
1271        }
1272    }
1273
1274    // --- T106: duplicate-literal-assertion ---
1275
1276    #[test]
1277    fn t106_violation_duplicate_literal() {
1278        let source = fixture("t106_violation.php");
1279        let extractor = PhpExtractor::new();
1280        let funcs = extractor.extract_test_functions(&source, "t106_violation.php");
1281        assert_eq!(funcs.len(), 1);
1282        assert!(
1283            funcs[0].analysis.duplicate_literal_count >= 3,
1284            "42 appears 3 times, should be >= 3: got {}",
1285            funcs[0].analysis.duplicate_literal_count
1286        );
1287    }
1288
1289    #[test]
1290    fn t106_pass_no_duplicates() {
1291        let source = fixture("t106_pass_no_duplicates.php");
1292        let extractor = PhpExtractor::new();
1293        let funcs = extractor.extract_test_functions(&source, "t106_pass_no_duplicates.php");
1294        assert_eq!(funcs.len(), 1);
1295        assert!(
1296            funcs[0].analysis.duplicate_literal_count < 3,
1297            "each literal appears once: got {}",
1298            funcs[0].analysis.duplicate_literal_count
1299        );
1300    }
1301
1302    // --- T001 FP fix: expectException/Message/Code (#25) ---
1303
1304    #[test]
1305    fn t001_expect_exception_counts_as_assertion() {
1306        // TC-07: $this->expectException() only -> T001 should NOT fire
1307        let source = fixture("t001_expect_exception.php");
1308        let extractor = PhpExtractor::new();
1309        let funcs = extractor.extract_test_functions(&source, "t001_expect_exception.php");
1310        assert_eq!(funcs.len(), 1);
1311        assert!(
1312            funcs[0].analysis.assertion_count >= 1,
1313            "$this->expectException() should count as assertion, got {}",
1314            funcs[0].analysis.assertion_count
1315        );
1316    }
1317
1318    #[test]
1319    fn t001_expect_exception_message_counts_as_assertion() {
1320        // TC-08: $this->expectExceptionMessage() only -> T001 should NOT fire
1321        let source = fixture("t001_expect_exception_message.php");
1322        let extractor = PhpExtractor::new();
1323        let funcs = extractor.extract_test_functions(&source, "t001_expect_exception_message.php");
1324        assert_eq!(funcs.len(), 1);
1325        assert!(
1326            funcs[0].analysis.assertion_count >= 1,
1327            "$this->expectExceptionMessage() should count as assertion, got {}",
1328            funcs[0].analysis.assertion_count
1329        );
1330    }
1331
1332    // --- #44: T001 FP: arbitrary-object ->assert*() and self::assert*() ---
1333
1334    #[test]
1335    fn t001_response_assert_status() {
1336        let source = fixture("t001_pass_obj_assert.php");
1337        let extractor = PhpExtractor::new();
1338        let funcs = extractor.extract_test_functions(&source, "t001_pass_obj_assert.php");
1339        let f = funcs
1340            .iter()
1341            .find(|f| f.name == "test_response_assert_status")
1342            .unwrap();
1343        assert!(
1344            f.analysis.assertion_count >= 1,
1345            "$response->assertStatus() should count as assertion, got {}",
1346            f.analysis.assertion_count
1347        );
1348    }
1349
1350    #[test]
1351    fn t001_chained_response_assertions() {
1352        let source = fixture("t001_pass_obj_assert.php");
1353        let extractor = PhpExtractor::new();
1354        let funcs = extractor.extract_test_functions(&source, "t001_pass_obj_assert.php");
1355        let f = funcs
1356            .iter()
1357            .find(|f| f.name == "test_chained_response_assertions")
1358            .unwrap();
1359        assert!(
1360            f.analysis.assertion_count >= 2,
1361            "chained ->assertStatus()->assertJsonCount() should count >= 2, got {}",
1362            f.analysis.assertion_count
1363        );
1364    }
1365
1366    #[test]
1367    fn t001_assertion_helper_not_counted() {
1368        let source = fixture("t001_pass_obj_assert.php");
1369        let extractor = PhpExtractor::new();
1370        let funcs = extractor.extract_test_functions(&source, "t001_pass_obj_assert.php");
1371        let f = funcs
1372            .iter()
1373            .find(|f| f.name == "test_assertion_helper_not_counted")
1374            .unwrap();
1375        assert_eq!(
1376            f.analysis.assertion_count, 0,
1377            "assertionHelper() should NOT count as assertion, got {}",
1378            f.analysis.assertion_count
1379        );
1380    }
1381
1382    #[test]
1383    fn t001_self_assert_equals() {
1384        let source = fixture("t001_pass_self_assert.php");
1385        let extractor = PhpExtractor::new();
1386        let funcs = extractor.extract_test_functions(&source, "t001_pass_self_assert.php");
1387        let f = funcs
1388            .iter()
1389            .find(|f| f.name == "test_self_assert_equals")
1390            .unwrap();
1391        assert!(
1392            f.analysis.assertion_count >= 1,
1393            "self::assertEquals() should count as assertion, got {}",
1394            f.analysis.assertion_count
1395        );
1396    }
1397
1398    #[test]
1399    fn t001_static_assert_true() {
1400        let source = fixture("t001_pass_self_assert.php");
1401        let extractor = PhpExtractor::new();
1402        let funcs = extractor.extract_test_functions(&source, "t001_pass_self_assert.php");
1403        let f = funcs
1404            .iter()
1405            .find(|f| f.name == "test_static_assert_true")
1406            .unwrap();
1407        assert!(
1408            f.analysis.assertion_count >= 1,
1409            "static::assertTrue() should count as assertion, got {}",
1410            f.analysis.assertion_count
1411        );
1412    }
1413
1414    #[test]
1415    fn t001_artisan_expects_output() {
1416        let source = fixture("t001_pass_artisan_expects.php");
1417        let extractor = PhpExtractor::new();
1418        let funcs = extractor.extract_test_functions(&source, "t001_pass_artisan_expects.php");
1419        let f = funcs
1420            .iter()
1421            .find(|f| f.name == "test_artisan_expects_output")
1422            .unwrap();
1423        assert!(
1424            f.analysis.assertion_count >= 2,
1425            "expectsOutput + assertExitCode should count >= 2, got {}",
1426            f.analysis.assertion_count
1427        );
1428    }
1429
1430    #[test]
1431    fn t001_artisan_expects_question() {
1432        let source = fixture("t001_pass_artisan_expects.php");
1433        let extractor = PhpExtractor::new();
1434        let funcs = extractor.extract_test_functions(&source, "t001_pass_artisan_expects.php");
1435        let f = funcs
1436            .iter()
1437            .find(|f| f.name == "test_artisan_expects_question")
1438            .unwrap();
1439        assert!(
1440            f.analysis.assertion_count >= 1,
1441            "expectsQuestion() should count as assertion, got {}",
1442            f.analysis.assertion_count
1443        );
1444    }
1445
1446    #[test]
1447    fn t001_expect_not_to_perform_assertions() {
1448        let source = fixture("t001_pass_artisan_expects.php");
1449        let extractor = PhpExtractor::new();
1450        let funcs = extractor.extract_test_functions(&source, "t001_pass_artisan_expects.php");
1451        let f = funcs
1452            .iter()
1453            .find(|f| f.name == "test_expect_not_to_perform_assertions")
1454            .unwrap();
1455        assert!(
1456            f.analysis.assertion_count >= 1,
1457            "expectNotToPerformAssertions() should count as assertion, got {}",
1458            f.analysis.assertion_count
1459        );
1460    }
1461
1462    #[test]
1463    fn t001_expect_output_string() {
1464        let source = fixture("t001_pass_artisan_expects.php");
1465        let extractor = PhpExtractor::new();
1466        let funcs = extractor.extract_test_functions(&source, "t001_pass_artisan_expects.php");
1467        let f = funcs
1468            .iter()
1469            .find(|f| f.name == "test_expect_output_string")
1470            .unwrap();
1471        assert!(
1472            f.analysis.assertion_count >= 1,
1473            "expectOutputString() should count as assertion, got {}",
1474            f.analysis.assertion_count
1475        );
1476    }
1477
1478    #[test]
1479    fn t001_existing_this_assert_still_works() {
1480        // Regression: $this->assertEquals still detected after Pattern A change
1481        let source = fixture("t001_pass.php");
1482        let extractor = PhpExtractor::new();
1483        let funcs = extractor.extract_test_functions(&source, "t001_pass.php");
1484        assert_eq!(funcs.len(), 1);
1485        assert!(
1486            funcs[0].analysis.assertion_count >= 1,
1487            "$this->assertEquals() regression: should still count, got {}",
1488            funcs[0].analysis.assertion_count
1489        );
1490    }
1491
1492    #[test]
1493    fn t001_bare_assert_counted() {
1494        let source = fixture("t001_pass_obj_assert.php");
1495        let extractor = PhpExtractor::new();
1496        let funcs = extractor.extract_test_functions(&source, "t001_pass_obj_assert.php");
1497        let f = funcs
1498            .iter()
1499            .find(|f| f.name == "test_bare_assert_call")
1500            .unwrap();
1501        assert!(
1502            f.analysis.assertion_count >= 1,
1503            "->assert() bare call should count as assertion, got {}",
1504            f.analysis.assertion_count
1505        );
1506    }
1507
1508    #[test]
1509    fn t001_parent_assert_same() {
1510        // parent:: is relative_scope in tree-sitter-php; intentionally counted as oracle
1511        let source = fixture("t001_pass_self_assert.php");
1512        let extractor = PhpExtractor::new();
1513        let funcs = extractor.extract_test_functions(&source, "t001_pass_self_assert.php");
1514        let f = funcs
1515            .iter()
1516            .find(|f| f.name == "test_parent_assert_same")
1517            .unwrap();
1518        assert!(
1519            f.analysis.assertion_count >= 1,
1520            "parent::assertSame() should count as assertion, got {}",
1521            f.analysis.assertion_count
1522        );
1523    }
1524
1525    #[test]
1526    fn t001_artisan_expects_no_output() {
1527        let source = fixture("t001_pass_artisan_expects.php");
1528        let extractor = PhpExtractor::new();
1529        let funcs = extractor.extract_test_functions(&source, "t001_pass_artisan_expects.php");
1530        let f = funcs
1531            .iter()
1532            .find(|f| f.name == "test_artisan_expects_no_output")
1533            .unwrap();
1534        assert!(
1535            f.analysis.assertion_count >= 1,
1536            "expectsNoOutput() should count as assertion, got {}",
1537            f.analysis.assertion_count
1538        );
1539    }
1540
1541    #[test]
1542    fn t001_named_class_assert_equals() {
1543        let source = fixture("t001_pass_named_class_assert.php");
1544        let extractor = PhpExtractor::new();
1545        let funcs = extractor.extract_test_functions(&source, "t001_pass_named_class_assert.php");
1546        let f = funcs
1547            .iter()
1548            .find(|f| f.name == "test_assert_class_equals")
1549            .unwrap();
1550        assert!(
1551            f.analysis.assertion_count >= 1,
1552            "Assert::assertEquals() should count as assertion, got {}",
1553            f.analysis.assertion_count
1554        );
1555    }
1556
1557    #[test]
1558    fn t001_fqcn_assert_same() {
1559        let source = fixture("t001_pass_named_class_assert.php");
1560        let extractor = PhpExtractor::new();
1561        let funcs = extractor.extract_test_functions(&source, "t001_pass_named_class_assert.php");
1562        let f = funcs
1563            .iter()
1564            .find(|f| f.name == "test_fqcn_assert_same")
1565            .unwrap();
1566        assert!(
1567            f.analysis.assertion_count >= 1,
1568            "PHPUnit\\Framework\\Assert::assertSame() should count as assertion, got {}",
1569            f.analysis.assertion_count
1570        );
1571    }
1572
1573    #[test]
1574    fn t001_named_class_assert_true() {
1575        let source = fixture("t001_pass_named_class_assert.php");
1576        let extractor = PhpExtractor::new();
1577        let funcs = extractor.extract_test_functions(&source, "t001_pass_named_class_assert.php");
1578        let f = funcs
1579            .iter()
1580            .find(|f| f.name == "test_assert_class_true")
1581            .unwrap();
1582        assert!(
1583            f.analysis.assertion_count >= 1,
1584            "Assert::assertTrue() should count as assertion, got {}",
1585            f.analysis.assertion_count
1586        );
1587    }
1588
1589    #[test]
1590    fn t001_non_this_expects_not_counted() {
1591        let source = fixture("t001_violation_non_this_expects.php");
1592        let extractor = PhpExtractor::new();
1593        let funcs =
1594            extractor.extract_test_functions(&source, "t001_violation_non_this_expects.php");
1595        let f = funcs
1596            .iter()
1597            .find(|f| f.name == "test_event_emitter_expects_not_assertion")
1598            .unwrap();
1599        assert_eq!(
1600            f.analysis.assertion_count, 0,
1601            "$emitter->expects() should NOT count as assertion, got {}",
1602            f.analysis.assertion_count
1603        );
1604    }
1605
1606    #[test]
1607    fn t001_mock_expects_not_this_not_counted() {
1608        let source = fixture("t001_violation_non_this_expects.php");
1609        let extractor = PhpExtractor::new();
1610        let funcs =
1611            extractor.extract_test_functions(&source, "t001_violation_non_this_expects.php");
1612        let f = funcs
1613            .iter()
1614            .find(|f| f.name == "test_mock_expects_not_this")
1615            .unwrap();
1616        assert_eq!(
1617            f.analysis.assertion_count, 0,
1618            "$mock->expects() should NOT count as assertion, got {}",
1619            f.analysis.assertion_count
1620        );
1621    }
1622
1623    #[test]
1624    fn t001_facade_event_assert_dispatched() {
1625        let source = fixture("t001_pass_facade_assert.php");
1626        let extractor = PhpExtractor::new();
1627        let funcs = extractor.extract_test_functions(&source, "t001_pass_facade_assert.php");
1628        let f = funcs
1629            .iter()
1630            .find(|f| f.name == "test_event_assert_dispatched")
1631            .unwrap();
1632        assert!(
1633            f.analysis.assertion_count >= 1,
1634            "Event::assertDispatched() should count as assertion, got {}",
1635            f.analysis.assertion_count
1636        );
1637    }
1638
1639    #[test]
1640    fn t001_facade_sleep_assert_sequence() {
1641        let source = fixture("t001_pass_facade_assert.php");
1642        let extractor = PhpExtractor::new();
1643        let funcs = extractor.extract_test_functions(&source, "t001_pass_facade_assert.php");
1644        let f = funcs
1645            .iter()
1646            .find(|f| f.name == "test_sleep_assert_sequence")
1647            .unwrap();
1648        assert!(
1649            f.analysis.assertion_count >= 1,
1650            "Sleep::assertSequence() should count as assertion, got {}",
1651            f.analysis.assertion_count
1652        );
1653    }
1654
1655    #[test]
1656    fn t001_facade_exceptions_assert_reported() {
1657        let source = fixture("t001_pass_facade_assert.php");
1658        let extractor = PhpExtractor::new();
1659        let funcs = extractor.extract_test_functions(&source, "t001_pass_facade_assert.php");
1660        let f = funcs
1661            .iter()
1662            .find(|f| f.name == "test_exceptions_assert_reported")
1663            .unwrap();
1664        assert!(
1665            f.analysis.assertion_count >= 1,
1666            "Exceptions::assertReported() should count as assertion, got {}",
1667            f.analysis.assertion_count
1668        );
1669    }
1670
1671    #[test]
1672    fn t001_facade_bus_assert_dispatched() {
1673        let source = fixture("t001_pass_facade_assert.php");
1674        let extractor = PhpExtractor::new();
1675        let funcs = extractor.extract_test_functions(&source, "t001_pass_facade_assert.php");
1676        let f = funcs
1677            .iter()
1678            .find(|f| f.name == "test_bus_assert_dispatched")
1679            .unwrap();
1680        assert!(
1681            f.analysis.assertion_count >= 1,
1682            "Bus::assertDispatched() should count as assertion, got {}",
1683            f.analysis.assertion_count
1684        );
1685    }
1686
1687    // --- T001 FP fix: Facade::shouldReceive() static Mockery calls (#58) ---
1688
1689    #[test]
1690    fn t001_facade_should_receive_counts_as_assertion() {
1691        // TC-01: Log::shouldReceive('error')->once() -> assertion_count >= 1
1692        let source = fixture("t001_pass_facade_mockery.php");
1693        let extractor = PhpExtractor::new();
1694        let funcs = extractor.extract_test_functions(&source, "t001_pass_facade_mockery.php");
1695        let f = funcs
1696            .iter()
1697            .find(|f| f.name == "test_log_should_receive")
1698            .unwrap();
1699        assert!(
1700            f.analysis.assertion_count >= 1,
1701            "Log::shouldReceive() should count as assertion, got {}",
1702            f.analysis.assertion_count
1703        );
1704    }
1705
1706    #[test]
1707    fn t001_facade_should_have_received_counts_as_assertion() {
1708        // TC-02: Log::shouldHaveReceived('info') -> assertion_count >= 1
1709        let source = fixture("t001_pass_facade_mockery.php");
1710        let extractor = PhpExtractor::new();
1711        let funcs = extractor.extract_test_functions(&source, "t001_pass_facade_mockery.php");
1712        let f = funcs
1713            .iter()
1714            .find(|f| f.name == "test_log_should_have_received")
1715            .unwrap();
1716        assert!(
1717            f.analysis.assertion_count >= 1,
1718            "Log::shouldHaveReceived() should count as assertion, got {}",
1719            f.analysis.assertion_count
1720        );
1721    }
1722
1723    #[test]
1724    fn t001_facade_should_not_have_received_counts_as_assertion() {
1725        // TC-03: Log::shouldNotHaveReceived('debug') -> assertion_count >= 1
1726        let source = fixture("t001_pass_facade_mockery.php");
1727        let extractor = PhpExtractor::new();
1728        let funcs = extractor.extract_test_functions(&source, "t001_pass_facade_mockery.php");
1729        let f = funcs
1730            .iter()
1731            .find(|f| f.name == "test_log_should_not_have_received")
1732            .unwrap();
1733        assert!(
1734            f.analysis.assertion_count >= 1,
1735            "Log::shouldNotHaveReceived() should count as assertion, got {}",
1736            f.analysis.assertion_count
1737        );
1738    }
1739
1740    #[test]
1741    fn t001_facade_mockery_fixture_no_blocks() {
1742        // TC-04: facade fixture全体 -> T001 BLOCK 0件
1743        use exspec_core::rules::{evaluate_rules, Config, RuleId, Severity};
1744
1745        let source = fixture("t001_pass_facade_mockery.php");
1746        let extractor = PhpExtractor::new();
1747        let funcs = extractor.extract_test_functions(&source, "t001_pass_facade_mockery.php");
1748        let config = Config::default();
1749        let diags: Vec<_> = evaluate_rules(&funcs, &config)
1750            .into_iter()
1751            .filter(|d| d.rule == RuleId::new("T001") && d.severity == Severity::Block)
1752            .collect();
1753        assert!(
1754            diags.is_empty(),
1755            "Expected 0 T001 BLOCKs for facade mockery fixture, got {}: {:?}",
1756            diags.len(),
1757            diags.iter().map(|d| &d.message).collect::<Vec<_>>()
1758        );
1759    }
1760}