Skip to main content

exspec_lang_php/
lib.rs

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