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