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