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