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    // --- #30: Round-trip test: same fixture through T001 (assertion) and T103 (error_test) ---
1342
1343    #[test]
1344    fn t001_t103_round_trip_expect_exception() {
1345        // Given: t103_pass.php contains $this->expectException()
1346        let source = fixture("t103_pass.php");
1347        let extractor = PhpExtractor::new();
1348
1349        // When: extract_file_analysis (T103) and extract_test_functions (T001)
1350        let fa = extractor.extract_file_analysis(&source, "t103_pass.php");
1351        let funcs = extractor.extract_test_functions(&source, "t103_pass.php");
1352
1353        // Then: T103 detects error test AND T001 counts as assertion (both queries aligned)
1354        assert!(
1355            fa.has_error_test,
1356            "error_test.scm should detect $this->expectException()"
1357        );
1358        assert!(!funcs.is_empty(), "should extract at least 1 test function");
1359        assert!(
1360            funcs[0].analysis.assertion_count >= 1,
1361            "assertion.scm should count $this->expectException() as assertion, got {}",
1362            funcs[0].analysis.assertion_count
1363        );
1364    }
1365
1366    // --- #44: T001 FP: arbitrary-object ->assert*() and self::assert*() ---
1367
1368    #[test]
1369    fn t001_response_assert_status() {
1370        let source = fixture("t001_pass_obj_assert.php");
1371        let extractor = PhpExtractor::new();
1372        let funcs = extractor.extract_test_functions(&source, "t001_pass_obj_assert.php");
1373        let f = funcs
1374            .iter()
1375            .find(|f| f.name == "test_response_assert_status")
1376            .unwrap();
1377        assert!(
1378            f.analysis.assertion_count >= 1,
1379            "$response->assertStatus() should count as assertion, got {}",
1380            f.analysis.assertion_count
1381        );
1382    }
1383
1384    #[test]
1385    fn t001_chained_response_assertions() {
1386        let source = fixture("t001_pass_obj_assert.php");
1387        let extractor = PhpExtractor::new();
1388        let funcs = extractor.extract_test_functions(&source, "t001_pass_obj_assert.php");
1389        let f = funcs
1390            .iter()
1391            .find(|f| f.name == "test_chained_response_assertions")
1392            .unwrap();
1393        assert!(
1394            f.analysis.assertion_count >= 2,
1395            "chained ->assertStatus()->assertJsonCount() should count >= 2, got {}",
1396            f.analysis.assertion_count
1397        );
1398    }
1399
1400    #[test]
1401    fn t001_assertion_helper_not_counted() {
1402        let source = fixture("t001_pass_obj_assert.php");
1403        let extractor = PhpExtractor::new();
1404        let funcs = extractor.extract_test_functions(&source, "t001_pass_obj_assert.php");
1405        let f = funcs
1406            .iter()
1407            .find(|f| f.name == "test_assertion_helper_not_counted")
1408            .unwrap();
1409        assert_eq!(
1410            f.analysis.assertion_count, 0,
1411            "assertionHelper() should NOT count as assertion, got {}",
1412            f.analysis.assertion_count
1413        );
1414    }
1415
1416    #[test]
1417    fn t001_self_assert_equals() {
1418        let source = fixture("t001_pass_self_assert.php");
1419        let extractor = PhpExtractor::new();
1420        let funcs = extractor.extract_test_functions(&source, "t001_pass_self_assert.php");
1421        let f = funcs
1422            .iter()
1423            .find(|f| f.name == "test_self_assert_equals")
1424            .unwrap();
1425        assert!(
1426            f.analysis.assertion_count >= 1,
1427            "self::assertEquals() should count as assertion, got {}",
1428            f.analysis.assertion_count
1429        );
1430    }
1431
1432    #[test]
1433    fn t001_static_assert_true() {
1434        let source = fixture("t001_pass_self_assert.php");
1435        let extractor = PhpExtractor::new();
1436        let funcs = extractor.extract_test_functions(&source, "t001_pass_self_assert.php");
1437        let f = funcs
1438            .iter()
1439            .find(|f| f.name == "test_static_assert_true")
1440            .unwrap();
1441        assert!(
1442            f.analysis.assertion_count >= 1,
1443            "static::assertTrue() should count as assertion, got {}",
1444            f.analysis.assertion_count
1445        );
1446    }
1447
1448    #[test]
1449    fn t001_artisan_expects_output() {
1450        let source = fixture("t001_pass_artisan_expects.php");
1451        let extractor = PhpExtractor::new();
1452        let funcs = extractor.extract_test_functions(&source, "t001_pass_artisan_expects.php");
1453        let f = funcs
1454            .iter()
1455            .find(|f| f.name == "test_artisan_expects_output")
1456            .unwrap();
1457        assert!(
1458            f.analysis.assertion_count >= 2,
1459            "expectsOutput + assertExitCode should count >= 2, got {}",
1460            f.analysis.assertion_count
1461        );
1462    }
1463
1464    #[test]
1465    fn t001_artisan_expects_question() {
1466        let source = fixture("t001_pass_artisan_expects.php");
1467        let extractor = PhpExtractor::new();
1468        let funcs = extractor.extract_test_functions(&source, "t001_pass_artisan_expects.php");
1469        let f = funcs
1470            .iter()
1471            .find(|f| f.name == "test_artisan_expects_question")
1472            .unwrap();
1473        assert!(
1474            f.analysis.assertion_count >= 1,
1475            "expectsQuestion() should count as assertion, got {}",
1476            f.analysis.assertion_count
1477        );
1478    }
1479
1480    #[test]
1481    fn t001_expect_not_to_perform_assertions() {
1482        let source = fixture("t001_pass_artisan_expects.php");
1483        let extractor = PhpExtractor::new();
1484        let funcs = extractor.extract_test_functions(&source, "t001_pass_artisan_expects.php");
1485        let f = funcs
1486            .iter()
1487            .find(|f| f.name == "test_expect_not_to_perform_assertions")
1488            .unwrap();
1489        assert!(
1490            f.analysis.assertion_count >= 1,
1491            "expectNotToPerformAssertions() should count as assertion, got {}",
1492            f.analysis.assertion_count
1493        );
1494    }
1495
1496    #[test]
1497    fn t001_expect_output_string() {
1498        let source = fixture("t001_pass_artisan_expects.php");
1499        let extractor = PhpExtractor::new();
1500        let funcs = extractor.extract_test_functions(&source, "t001_pass_artisan_expects.php");
1501        let f = funcs
1502            .iter()
1503            .find(|f| f.name == "test_expect_output_string")
1504            .unwrap();
1505        assert!(
1506            f.analysis.assertion_count >= 1,
1507            "expectOutputString() should count as assertion, got {}",
1508            f.analysis.assertion_count
1509        );
1510    }
1511
1512    #[test]
1513    fn t001_existing_this_assert_still_works() {
1514        // Regression: $this->assertEquals still detected after Pattern A change
1515        let source = fixture("t001_pass.php");
1516        let extractor = PhpExtractor::new();
1517        let funcs = extractor.extract_test_functions(&source, "t001_pass.php");
1518        assert_eq!(funcs.len(), 1);
1519        assert!(
1520            funcs[0].analysis.assertion_count >= 1,
1521            "$this->assertEquals() regression: should still count, got {}",
1522            funcs[0].analysis.assertion_count
1523        );
1524    }
1525
1526    #[test]
1527    fn t001_bare_assert_counted() {
1528        let source = fixture("t001_pass_obj_assert.php");
1529        let extractor = PhpExtractor::new();
1530        let funcs = extractor.extract_test_functions(&source, "t001_pass_obj_assert.php");
1531        let f = funcs
1532            .iter()
1533            .find(|f| f.name == "test_bare_assert_call")
1534            .unwrap();
1535        assert!(
1536            f.analysis.assertion_count >= 1,
1537            "->assert() bare call should count as assertion, got {}",
1538            f.analysis.assertion_count
1539        );
1540    }
1541
1542    #[test]
1543    fn t001_parent_assert_same() {
1544        // parent:: is relative_scope in tree-sitter-php; intentionally counted as oracle
1545        let source = fixture("t001_pass_self_assert.php");
1546        let extractor = PhpExtractor::new();
1547        let funcs = extractor.extract_test_functions(&source, "t001_pass_self_assert.php");
1548        let f = funcs
1549            .iter()
1550            .find(|f| f.name == "test_parent_assert_same")
1551            .unwrap();
1552        assert!(
1553            f.analysis.assertion_count >= 1,
1554            "parent::assertSame() should count as assertion, got {}",
1555            f.analysis.assertion_count
1556        );
1557    }
1558
1559    #[test]
1560    fn t001_artisan_expects_no_output() {
1561        let source = fixture("t001_pass_artisan_expects.php");
1562        let extractor = PhpExtractor::new();
1563        let funcs = extractor.extract_test_functions(&source, "t001_pass_artisan_expects.php");
1564        let f = funcs
1565            .iter()
1566            .find(|f| f.name == "test_artisan_expects_no_output")
1567            .unwrap();
1568        assert!(
1569            f.analysis.assertion_count >= 1,
1570            "expectsNoOutput() should count as assertion, got {}",
1571            f.analysis.assertion_count
1572        );
1573    }
1574
1575    #[test]
1576    fn t001_named_class_assert_equals() {
1577        let source = fixture("t001_pass_named_class_assert.php");
1578        let extractor = PhpExtractor::new();
1579        let funcs = extractor.extract_test_functions(&source, "t001_pass_named_class_assert.php");
1580        let f = funcs
1581            .iter()
1582            .find(|f| f.name == "test_assert_class_equals")
1583            .unwrap();
1584        assert!(
1585            f.analysis.assertion_count >= 1,
1586            "Assert::assertEquals() should count as assertion, got {}",
1587            f.analysis.assertion_count
1588        );
1589    }
1590
1591    #[test]
1592    fn t001_fqcn_assert_same() {
1593        let source = fixture("t001_pass_named_class_assert.php");
1594        let extractor = PhpExtractor::new();
1595        let funcs = extractor.extract_test_functions(&source, "t001_pass_named_class_assert.php");
1596        let f = funcs
1597            .iter()
1598            .find(|f| f.name == "test_fqcn_assert_same")
1599            .unwrap();
1600        assert!(
1601            f.analysis.assertion_count >= 1,
1602            "PHPUnit\\Framework\\Assert::assertSame() should count as assertion, got {}",
1603            f.analysis.assertion_count
1604        );
1605    }
1606
1607    #[test]
1608    fn t001_named_class_assert_true() {
1609        let source = fixture("t001_pass_named_class_assert.php");
1610        let extractor = PhpExtractor::new();
1611        let funcs = extractor.extract_test_functions(&source, "t001_pass_named_class_assert.php");
1612        let f = funcs
1613            .iter()
1614            .find(|f| f.name == "test_assert_class_true")
1615            .unwrap();
1616        assert!(
1617            f.analysis.assertion_count >= 1,
1618            "Assert::assertTrue() should count as assertion, got {}",
1619            f.analysis.assertion_count
1620        );
1621    }
1622
1623    #[test]
1624    fn t001_non_this_expects_not_counted() {
1625        let source = fixture("t001_violation_non_this_expects.php");
1626        let extractor = PhpExtractor::new();
1627        let funcs =
1628            extractor.extract_test_functions(&source, "t001_violation_non_this_expects.php");
1629        let f = funcs
1630            .iter()
1631            .find(|f| f.name == "test_event_emitter_expects_not_assertion")
1632            .unwrap();
1633        assert_eq!(
1634            f.analysis.assertion_count, 0,
1635            "$emitter->expects() should NOT count as assertion, got {}",
1636            f.analysis.assertion_count
1637        );
1638    }
1639
1640    #[test]
1641    fn t001_mock_expects_not_this_not_counted() {
1642        let source = fixture("t001_violation_non_this_expects.php");
1643        let extractor = PhpExtractor::new();
1644        let funcs =
1645            extractor.extract_test_functions(&source, "t001_violation_non_this_expects.php");
1646        let f = funcs
1647            .iter()
1648            .find(|f| f.name == "test_mock_expects_not_this")
1649            .unwrap();
1650        assert_eq!(
1651            f.analysis.assertion_count, 0,
1652            "$mock->expects() should NOT count as assertion, got {}",
1653            f.analysis.assertion_count
1654        );
1655    }
1656
1657    #[test]
1658    fn t001_facade_event_assert_dispatched() {
1659        let source = fixture("t001_pass_facade_assert.php");
1660        let extractor = PhpExtractor::new();
1661        let funcs = extractor.extract_test_functions(&source, "t001_pass_facade_assert.php");
1662        let f = funcs
1663            .iter()
1664            .find(|f| f.name == "test_event_assert_dispatched")
1665            .unwrap();
1666        assert!(
1667            f.analysis.assertion_count >= 1,
1668            "Event::assertDispatched() should count as assertion, got {}",
1669            f.analysis.assertion_count
1670        );
1671    }
1672
1673    #[test]
1674    fn t001_facade_sleep_assert_sequence() {
1675        let source = fixture("t001_pass_facade_assert.php");
1676        let extractor = PhpExtractor::new();
1677        let funcs = extractor.extract_test_functions(&source, "t001_pass_facade_assert.php");
1678        let f = funcs
1679            .iter()
1680            .find(|f| f.name == "test_sleep_assert_sequence")
1681            .unwrap();
1682        assert!(
1683            f.analysis.assertion_count >= 1,
1684            "Sleep::assertSequence() should count as assertion, got {}",
1685            f.analysis.assertion_count
1686        );
1687    }
1688
1689    #[test]
1690    fn t001_facade_exceptions_assert_reported() {
1691        let source = fixture("t001_pass_facade_assert.php");
1692        let extractor = PhpExtractor::new();
1693        let funcs = extractor.extract_test_functions(&source, "t001_pass_facade_assert.php");
1694        let f = funcs
1695            .iter()
1696            .find(|f| f.name == "test_exceptions_assert_reported")
1697            .unwrap();
1698        assert!(
1699            f.analysis.assertion_count >= 1,
1700            "Exceptions::assertReported() should count as assertion, got {}",
1701            f.analysis.assertion_count
1702        );
1703    }
1704
1705    #[test]
1706    fn t001_facade_bus_assert_dispatched() {
1707        let source = fixture("t001_pass_facade_assert.php");
1708        let extractor = PhpExtractor::new();
1709        let funcs = extractor.extract_test_functions(&source, "t001_pass_facade_assert.php");
1710        let f = funcs
1711            .iter()
1712            .find(|f| f.name == "test_bus_assert_dispatched")
1713            .unwrap();
1714        assert!(
1715            f.analysis.assertion_count >= 1,
1716            "Bus::assertDispatched() should count as assertion, got {}",
1717            f.analysis.assertion_count
1718        );
1719    }
1720
1721    // --- T001 FP fix: Facade::shouldReceive() static Mockery calls (#58) ---
1722
1723    #[test]
1724    fn t001_facade_should_receive_counts_as_assertion() {
1725        // TC-01: Log::shouldReceive('error')->once() -> assertion_count >= 1
1726        let source = fixture("t001_pass_facade_mockery.php");
1727        let extractor = PhpExtractor::new();
1728        let funcs = extractor.extract_test_functions(&source, "t001_pass_facade_mockery.php");
1729        let f = funcs
1730            .iter()
1731            .find(|f| f.name == "test_log_should_receive")
1732            .unwrap();
1733        assert!(
1734            f.analysis.assertion_count >= 1,
1735            "Log::shouldReceive() should count as assertion, got {}",
1736            f.analysis.assertion_count
1737        );
1738    }
1739
1740    #[test]
1741    fn t001_facade_should_have_received_counts_as_assertion() {
1742        // TC-02: Log::shouldHaveReceived('info') -> assertion_count >= 1
1743        let source = fixture("t001_pass_facade_mockery.php");
1744        let extractor = PhpExtractor::new();
1745        let funcs = extractor.extract_test_functions(&source, "t001_pass_facade_mockery.php");
1746        let f = funcs
1747            .iter()
1748            .find(|f| f.name == "test_log_should_have_received")
1749            .unwrap();
1750        assert!(
1751            f.analysis.assertion_count >= 1,
1752            "Log::shouldHaveReceived() should count as assertion, got {}",
1753            f.analysis.assertion_count
1754        );
1755    }
1756
1757    #[test]
1758    fn t001_facade_should_not_have_received_counts_as_assertion() {
1759        // TC-03: Log::shouldNotHaveReceived('debug') -> assertion_count >= 1
1760        let source = fixture("t001_pass_facade_mockery.php");
1761        let extractor = PhpExtractor::new();
1762        let funcs = extractor.extract_test_functions(&source, "t001_pass_facade_mockery.php");
1763        let f = funcs
1764            .iter()
1765            .find(|f| f.name == "test_log_should_not_have_received")
1766            .unwrap();
1767        assert!(
1768            f.analysis.assertion_count >= 1,
1769            "Log::shouldNotHaveReceived() should count as assertion, got {}",
1770            f.analysis.assertion_count
1771        );
1772    }
1773
1774    #[test]
1775    fn t001_facade_mockery_fixture_no_blocks() {
1776        // TC-04: facade fixture全体 -> T001 BLOCK 0件
1777        use exspec_core::rules::{evaluate_rules, Config, RuleId, Severity};
1778
1779        let source = fixture("t001_pass_facade_mockery.php");
1780        let extractor = PhpExtractor::new();
1781        let funcs = extractor.extract_test_functions(&source, "t001_pass_facade_mockery.php");
1782        let config = Config::default();
1783        let diags: Vec<_> = evaluate_rules(&funcs, &config)
1784            .into_iter()
1785            .filter(|d| d.rule == RuleId::new("T001") && d.severity == Severity::Block)
1786            .collect();
1787        assert!(
1788            diags.is_empty(),
1789            "Expected 0 T001 BLOCKs for facade mockery fixture, got {}: {:?}",
1790            diags.len(),
1791            diags.iter().map(|d| &d.message).collect::<Vec<_>>()
1792        );
1793    }
1794
1795    // --- #64: T001 FP: skip-only test exclusion ---
1796
1797    #[test]
1798    fn t001_skip_only_mark_test_skipped() {
1799        let source = fixture("t001_pass_skip_only.php");
1800        let extractor = PhpExtractor::new();
1801        let funcs = extractor.extract_test_functions(&source, "t001_pass_skip_only.php");
1802        let f = funcs
1803            .iter()
1804            .find(|f| f.name == "testSkippedFeature")
1805            .expect("testSkippedFeature not found");
1806        assert!(
1807            f.analysis.has_skip_call,
1808            "markTestSkipped() should set has_skip_call=true"
1809        );
1810    }
1811
1812    #[test]
1813    fn t001_skip_only_mark_test_incomplete() {
1814        let source = fixture("t001_pass_skip_only.php");
1815        let extractor = PhpExtractor::new();
1816        let funcs = extractor.extract_test_functions(&source, "t001_pass_skip_only.php");
1817        let f = funcs
1818            .iter()
1819            .find(|f| f.name == "testIncompleteFeature")
1820            .expect("testIncompleteFeature not found");
1821        assert!(
1822            f.analysis.has_skip_call,
1823            "markTestIncomplete() should set has_skip_call=true"
1824        );
1825    }
1826
1827    #[test]
1828    fn t001_skip_with_logic_no_t001() {
1829        use exspec_core::rules::{evaluate_rules, Config, RuleId, Severity};
1830
1831        let source = fixture("t001_pass_skip_with_logic.php");
1832        let extractor = PhpExtractor::new();
1833        let funcs = extractor.extract_test_functions(&source, "t001_pass_skip_with_logic.php");
1834        assert_eq!(funcs.len(), 1);
1835        assert!(
1836            funcs[0].analysis.has_skip_call,
1837            "skip + logic should still set has_skip_call=true"
1838        );
1839        let diags: Vec<_> = evaluate_rules(&funcs, &Config::default())
1840            .into_iter()
1841            .filter(|d| d.rule == RuleId::new("T001") && d.severity == Severity::Block)
1842            .collect();
1843        assert!(
1844            diags.is_empty(),
1845            "T001 should not fire for skip + logic test, got {:?}",
1846            diags.iter().map(|d| &d.message).collect::<Vec<_>>()
1847        );
1848    }
1849
1850    #[test]
1851    fn t001_skip_only_no_t001_block() {
1852        use exspec_core::rules::{evaluate_rules, Config, RuleId, Severity};
1853
1854        let source = fixture("t001_pass_skip_only.php");
1855        let extractor = PhpExtractor::new();
1856        let funcs = extractor.extract_test_functions(&source, "t001_pass_skip_only.php");
1857        let diags: Vec<_> = evaluate_rules(&funcs, &Config::default())
1858            .into_iter()
1859            .filter(|d| d.rule == RuleId::new("T001") && d.severity == Severity::Block)
1860            .collect();
1861        assert!(
1862            diags.is_empty(),
1863            "Expected 0 T001 BLOCKs for skip-only fixture, got {}: {:?}",
1864            diags.len(),
1865            diags.iter().map(|d| &d.message).collect::<Vec<_>>()
1866        );
1867    }
1868
1869    #[test]
1870    fn t110_skip_only_fixture_produces_info() {
1871        use exspec_core::rules::{evaluate_rules, Config, RuleId, Severity};
1872
1873        let source = fixture("t110_violation.php");
1874        let extractor = PhpExtractor::new();
1875        let funcs = extractor.extract_test_functions(&source, "t110_violation.php");
1876        let diags: Vec<_> = evaluate_rules(&funcs, &Config::default())
1877            .into_iter()
1878            .filter(|d| d.rule == RuleId::new("T110") && d.severity == Severity::Info)
1879            .collect();
1880        assert_eq!(diags.len(), 1, "Expected exactly one T110 INFO: {diags:?}");
1881    }
1882
1883    #[test]
1884    fn t110_existing_skip_only_fixture_produces_two_infos() {
1885        use exspec_core::rules::{evaluate_rules, Config, RuleId, Severity};
1886
1887        let source = fixture("t001_pass_skip_only.php");
1888        let extractor = PhpExtractor::new();
1889        let funcs = extractor.extract_test_functions(&source, "t001_pass_skip_only.php");
1890        let diags: Vec<_> = evaluate_rules(&funcs, &Config::default())
1891            .into_iter()
1892            .filter(|d| d.rule == RuleId::new("T110") && d.severity == Severity::Info)
1893            .collect();
1894        assert_eq!(
1895            diags.len(),
1896            2,
1897            "Expected both existing skip-only tests to emit T110 INFO: {diags:?}"
1898        );
1899    }
1900
1901    #[test]
1902    fn t110_mark_test_incomplete_path_produces_info() {
1903        use exspec_core::rules::{evaluate_rules, Config, RuleId, Severity};
1904
1905        let source = fixture("t001_pass_skip_only.php");
1906        let extractor = PhpExtractor::new();
1907        let funcs = extractor.extract_test_functions(&source, "t001_pass_skip_only.php");
1908        let diags: Vec<_> = evaluate_rules(&funcs, &Config::default())
1909            .into_iter()
1910            .filter(|d| {
1911                d.rule == RuleId::new("T110")
1912                    && d.severity == Severity::Info
1913                    && d.message.contains("testIncompleteFeature")
1914            })
1915            .collect();
1916        assert_eq!(
1917            diags.len(),
1918            1,
1919            "Expected markTestIncomplete path to emit one T110 INFO: {diags:?}"
1920        );
1921    }
1922
1923    #[test]
1924    fn query_capture_names_skip_test() {
1925        let q = make_query(include_str!("../queries/skip_test.scm"));
1926        assert!(
1927            q.capture_index_for_name("skip").is_some(),
1928            "skip_test.scm must define @skip capture"
1929        );
1930    }
1931
1932    // --- T001 FP fix: addToAssertionCount() (#63) ---
1933
1934    #[test]
1935    fn t001_add_to_assertion_count() {
1936        let source = fixture("t001_pass_add_to_assertion_count.php");
1937        let extractor = PhpExtractor::new();
1938        let funcs =
1939            extractor.extract_test_functions(&source, "t001_pass_add_to_assertion_count.php");
1940        let f = funcs
1941            .iter()
1942            .find(|f| f.name == "test_add_to_assertion_count")
1943            .expect("test function not found");
1944        assert!(
1945            f.analysis.assertion_count >= 1,
1946            "addToAssertionCount() should count as assertion, got {}",
1947            f.analysis.assertion_count
1948        );
1949    }
1950}