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