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 let name = var_name.strip_prefix('$').unwrap_or(var_name);
79 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 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
94fn 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 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 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
129fn 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
149fn 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
172fn 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 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 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 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 detect_docblock_test_methods(root, source, &mut test_matches);
266
267 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 let has_wait = has_any_match(wait_query, "wait", fn_node, source_bytes);
310
311 let has_skip_call = has_any_match(skip_query, "skip", fn_node, source_bytes);
313
314 let assertion_message_count =
316 count_assertion_messages_php(assertion_query, fn_node, source_bytes);
317
318 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 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 if !name.starts_with("test") {
370 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 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 #[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]
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 #[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 #[test]
601 fn t001_mockery_should_receive_counts_as_assertion() {
602 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
819 fn file_analysis_no_pbt_import() {
820 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 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 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 #[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 #[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 #[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 #[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 #[test]
1333 fn t001_expect_exception_counts_as_assertion() {
1334 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 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 #[test]
1363 fn t001_t103_round_trip_expect_exception() {
1364 let source = fixture("t103_pass.php");
1366 let extractor = PhpExtractor::new();
1367
1368 let fa = extractor.extract_file_analysis(&source, "t103_pass.php");
1370 let funcs = extractor.extract_test_functions(&source, "t103_pass.php");
1371
1372 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 #[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 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 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 #[test]
1743 fn t001_facade_should_receive_counts_as_assertion() {
1744 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 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 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 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 #[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 #[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 #[test]
1973 fn helper_tracing_tc01_calls_helper_with_assert() {
1974 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 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 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 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 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 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 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}