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