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