1pub mod observe;
2
3use std::sync::OnceLock;
4
5use exspec_core::extractor::{FileAnalysis, LanguageExtractor, TestAnalysis, TestFunction};
6use exspec_core::query_utils::{
7 apply_same_file_helper_tracing, collect_mock_class_names, count_captures,
8 count_captures_within_context, count_duplicate_literals,
9 extract_suppression_from_previous_line, has_any_match,
10};
11use streaming_iterator::StreamingIterator;
12use tree_sitter::{Node, Parser, Query, QueryCursor};
13
14const TEST_FUNCTION_QUERY: &str = include_str!("../queries/test_function.scm");
15const ASSERTION_QUERY: &str = include_str!("../queries/assertion.scm");
16const MOCK_USAGE_QUERY: &str = include_str!("../queries/mock_usage.scm");
17const MOCK_ASSIGNMENT_QUERY: &str = include_str!("../queries/mock_assignment.scm");
18const PARAMETERIZED_QUERY: &str = include_str!("../queries/parameterized.scm");
19const IMPORT_PBT_QUERY: &str = include_str!("../queries/import_pbt.scm");
20const IMPORT_CONTRACT_QUERY: &str = include_str!("../queries/import_contract.scm");
21const HOW_NOT_WHAT_QUERY: &str = include_str!("../queries/how_not_what.scm");
22const PRIVATE_IN_ASSERTION_QUERY: &str = include_str!("../queries/private_in_assertion.scm");
23const ERROR_TEST_QUERY: &str = include_str!("../queries/error_test.scm");
24const RELATIONAL_ASSERTION_QUERY: &str = include_str!("../queries/relational_assertion.scm");
25const WAIT_AND_SEE_QUERY: &str = include_str!("../queries/wait_and_see.scm");
26const HELPER_TRACE_QUERY: &str = include_str!("../queries/helper_trace.scm");
27
28fn rust_language() -> tree_sitter::Language {
29 tree_sitter_rust::LANGUAGE.into()
30}
31
32fn attribute_has_name(node: &Node, source_bytes: &[u8], name: &str) -> bool {
37 let mut cursor = node.walk();
41 for child in node.children(&mut cursor) {
42 if child.kind() == "identifier" {
44 if let Ok(text) = child.utf8_text(source_bytes) {
45 if text == name {
46 return true;
47 }
48 }
49 }
50 if child.kind() == "attribute" || child.kind() == "meta_item" {
52 let mut inner_cursor = child.walk();
53 for inner in child.children(&mut inner_cursor) {
54 if inner.kind() == "identifier" {
55 if let Ok(text) = inner.utf8_text(source_bytes) {
56 if text == name {
57 return true;
58 }
59 }
60 break;
62 }
63 }
64 }
65 }
66 false
67}
68
69fn cached_query<'a>(lock: &'a OnceLock<Query>, source: &str) -> &'a Query {
70 lock.get_or_init(|| Query::new(&rust_language(), source).expect("invalid query"))
71}
72
73static TEST_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
74static ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
75static MOCK_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
76static MOCK_ASSIGN_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
77static PARAMETERIZED_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
78static IMPORT_PBT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
79static IMPORT_CONTRACT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
80static HOW_NOT_WHAT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
81static PRIVATE_IN_ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
82static ERROR_TEST_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
83static RELATIONAL_ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
84static WAIT_AND_SEE_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
85static HELPER_TRACE_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
86
87pub struct RustExtractor;
88
89impl RustExtractor {
90 pub fn new() -> Self {
91 Self
92 }
93
94 pub fn parser() -> Parser {
95 let mut parser = Parser::new();
96 let language = tree_sitter_rust::LANGUAGE;
97 parser
98 .set_language(&language.into())
99 .expect("failed to load Rust grammar");
100 parser
101 }
102}
103
104impl Default for RustExtractor {
105 fn default() -> Self {
106 Self::new()
107 }
108}
109
110fn extract_mock_class_name(var_name: &str) -> String {
111 if let Some(stripped) = var_name.strip_prefix("mock_") {
113 if !stripped.is_empty() {
114 return stripped.to_string();
115 }
116 }
117 if let Some(stripped) = var_name.strip_prefix("mock") {
119 if !stripped.is_empty() && stripped.starts_with(|c: char| c.is_uppercase()) {
120 return stripped.to_string();
121 }
122 }
123 var_name.to_string()
124}
125
126struct TestMatch {
127 name: String,
128 fn_start_byte: usize,
129 fn_end_byte: usize,
130 fn_start_row: usize,
131 fn_end_row: usize,
132 attr_start_row: usize,
134 has_should_panic: bool,
136}
137
138fn is_constructor_call(node: Node) -> bool {
146 let func = match node.child_by_field_name("function") {
147 Some(f) => f,
148 None => return true, };
150 match func.kind() {
151 "scoped_identifier" => true,
153 "identifier" => true,
155 "field_expression" => {
157 let value = match func.child_by_field_name("value") {
158 Some(v) => v,
159 None => return true,
160 };
161 if value.kind() == "call_expression" {
162 is_constructor_call(value)
164 } else {
165 false
167 }
168 }
169 _ => true,
170 }
171}
172
173fn is_fixture_value(node: Node) -> bool {
178 match node.kind() {
179 "call_expression" => is_constructor_call(node),
180 "struct_expression" | "macro_invocation" => true,
181 _ => true, }
183}
184
185fn count_assertion_messages_rust(assertion_query: &Query, fn_node: Node, source: &[u8]) -> usize {
189 let assertion_idx = match assertion_query.capture_index_for_name("assertion") {
190 Some(idx) => idx,
191 None => return 0,
192 };
193 let mut cursor = QueryCursor::new();
194 let mut matches = cursor.matches(assertion_query, fn_node, source);
195 let mut count = 0;
196 while let Some(m) = matches.next() {
197 for cap in m.captures.iter().filter(|c| c.index == assertion_idx) {
198 let node = cap.node;
199 let macro_name = node
200 .child_by_field_name("macro")
201 .and_then(|n| n.utf8_text(source).ok())
202 .unwrap_or("");
203
204 let token_tree = (0..node.child_count()).find_map(|i| {
206 let child = node.child(i)?;
207 if child.kind() == "token_tree" {
208 Some(child)
209 } else {
210 None
211 }
212 });
213
214 if let Some(tt) = token_tree {
215 let mut comma_count = 0;
220 for i in 0..tt.child_count() {
221 if let Some(child) = tt.child(i) {
222 if child.kind() == "," {
223 comma_count += 1;
224 }
225 }
226 }
227
228 let min_commas = if macro_name.contains("_eq") || macro_name.contains("_ne") {
231 2
232 } else {
233 1
234 };
235 if comma_count >= min_commas {
236 count += 1;
237 }
238 }
239 }
240 }
241 count
242}
243
244fn count_fixture_lets(fn_node: Node) -> usize {
247 let body = match fn_node.child_by_field_name("body") {
248 Some(n) => n,
249 None => return 0,
250 };
251
252 let mut count = 0;
253 let mut cursor = body.walk();
254 if cursor.goto_first_child() {
255 loop {
256 let node = cursor.node();
257 if node.kind() == "let_declaration" {
258 match node.child_by_field_name("value") {
259 Some(value) => {
260 if is_fixture_value(value) {
261 count += 1;
262 }
263 }
264 None => count += 1, }
266 }
267 if !cursor.goto_next_sibling() {
268 break;
269 }
270 }
271 }
272 count
273}
274
275fn extract_functions_from_tree(source: &str, file_path: &str, root: Node) -> Vec<TestFunction> {
276 let test_query = cached_query(&TEST_QUERY_CACHE, TEST_FUNCTION_QUERY);
277 let assertion_query = cached_query(&ASSERTION_QUERY_CACHE, ASSERTION_QUERY);
278 let mock_query = cached_query(&MOCK_QUERY_CACHE, MOCK_USAGE_QUERY);
279 let mock_assign_query = cached_query(&MOCK_ASSIGN_QUERY_CACHE, MOCK_ASSIGNMENT_QUERY);
280 let how_not_what_query = cached_query(&HOW_NOT_WHAT_QUERY_CACHE, HOW_NOT_WHAT_QUERY);
281 let private_query = cached_query(
282 &PRIVATE_IN_ASSERTION_QUERY_CACHE,
283 PRIVATE_IN_ASSERTION_QUERY,
284 );
285 let wait_query = cached_query(&WAIT_AND_SEE_QUERY_CACHE, WAIT_AND_SEE_QUERY);
286
287 let source_bytes = source.as_bytes();
288
289 let attr_idx = test_query
292 .capture_index_for_name("test_attr")
293 .expect("no @test_attr capture");
294
295 let mut test_matches: Vec<TestMatch> = Vec::new();
296 let mut seen_fn_bytes: std::collections::HashSet<usize> = std::collections::HashSet::new();
297
298 {
299 let mut cursor = QueryCursor::new();
300 let mut matches = cursor.matches(test_query, root, source_bytes);
301 while let Some(m) = matches.next() {
302 let attr_capture = match m.captures.iter().find(|c| c.index == attr_idx) {
303 Some(c) => c,
304 None => continue,
305 };
306 let attr_node = attr_capture.node;
307 let attr_start_row = attr_node.start_position().row;
308
309 let mut has_should_panic = false;
312 let mut attr_start_row = attr_start_row;
313 {
314 let mut prev = attr_node.prev_sibling();
315 while let Some(p) = prev {
316 if p.kind() == "attribute_item" {
317 attr_start_row = p.start_position().row;
318 if attribute_has_name(&p, source_bytes, "should_panic") {
319 has_should_panic = true;
320 }
321 } else if p.kind() != "line_comment" && p.kind() != "block_comment" {
322 break;
323 }
324 prev = p.prev_sibling();
325 }
326 }
327
328 let mut sibling = attr_node.next_sibling();
331 while let Some(s) = sibling {
332 if s.kind() == "function_item" {
333 let fn_start_byte = s.start_byte();
334 if seen_fn_bytes.insert(fn_start_byte) {
335 let name = s
336 .child_by_field_name("name")
337 .and_then(|n| n.utf8_text(source_bytes).ok())
338 .unwrap_or("")
339 .to_string();
340 if !name.is_empty() {
341 test_matches.push(TestMatch {
342 name,
343 fn_start_byte,
344 fn_end_byte: s.end_byte(),
345 fn_start_row: s.start_position().row,
346 fn_end_row: s.end_position().row,
347 attr_start_row,
348 has_should_panic,
349 });
350 }
351 }
352 break;
353 }
354 if s.kind() == "attribute_item" {
357 if attribute_has_name(&s, source_bytes, "should_panic") {
358 has_should_panic = true;
359 }
360 } else if s.kind() != "line_comment" && s.kind() != "block_comment" {
361 break;
362 }
363 sibling = s.next_sibling();
364 }
365 }
366 }
367
368 let mut functions = Vec::new();
369 for tm in &test_matches {
370 let fn_node = match root.descendant_for_byte_range(tm.fn_start_byte, tm.fn_end_byte) {
371 Some(n) => n,
372 None => continue,
373 };
374
375 let line = tm.fn_start_row + 1;
376 let end_line = tm.fn_end_row + 1;
377 let line_count = end_line - line + 1;
378
379 let mut assertion_count =
380 count_captures(assertion_query, "assertion", fn_node, source_bytes);
381
382 if tm.has_should_panic {
384 assertion_count += 1;
385 }
386 let mock_count = count_captures(mock_query, "mock", fn_node, source_bytes);
387 let mock_classes = collect_mock_class_names(
388 mock_assign_query,
389 fn_node,
390 source_bytes,
391 extract_mock_class_name,
392 );
393 let how_not_what_count =
394 count_captures(how_not_what_query, "how_pattern", fn_node, source_bytes);
395
396 let private_in_assertion_count = count_captures_within_context(
397 assertion_query,
398 "assertion",
399 private_query,
400 "private_access",
401 fn_node,
402 source_bytes,
403 );
404
405 let fixture_count = count_fixture_lets(fn_node);
406
407 let has_wait = has_any_match(wait_query, "wait", fn_node, source_bytes);
409
410 let assertion_message_count =
412 count_assertion_messages_rust(assertion_query, fn_node, source_bytes);
413
414 let duplicate_literal_count = count_duplicate_literals(
416 assertion_query,
417 fn_node,
418 source_bytes,
419 &["integer_literal", "float_literal", "string_literal"],
420 );
421
422 let suppressed_rules = extract_suppression_from_previous_line(source, tm.attr_start_row);
424
425 functions.push(TestFunction {
426 name: tm.name.clone(),
427 file: file_path.to_string(),
428 line,
429 end_line,
430 analysis: TestAnalysis {
431 assertion_count,
432 mock_count,
433 mock_classes,
434 line_count,
435 how_not_what_count: how_not_what_count + private_in_assertion_count,
436 fixture_count,
437 has_wait,
438 has_skip_call: false,
439 assertion_message_count,
440 duplicate_literal_count,
441 suppressed_rules,
442 },
443 });
444 }
445
446 functions
447}
448
449impl LanguageExtractor for RustExtractor {
450 fn extract_test_functions(&self, source: &str, file_path: &str) -> Vec<TestFunction> {
451 let mut parser = Self::parser();
452 let tree = match parser.parse(source, None) {
453 Some(t) => t,
454 None => return Vec::new(),
455 };
456 extract_functions_from_tree(source, file_path, tree.root_node())
457 }
458
459 fn extract_file_analysis(&self, source: &str, file_path: &str) -> FileAnalysis {
460 let mut parser = Self::parser();
461 let tree = match parser.parse(source, None) {
462 Some(t) => t,
463 None => {
464 return FileAnalysis {
465 file: file_path.to_string(),
466 functions: Vec::new(),
467 has_pbt_import: false,
468 has_contract_import: false,
469 has_error_test: false,
470 has_relational_assertion: false,
471 parameterized_count: 0,
472 };
473 }
474 };
475
476 let root = tree.root_node();
477 let source_bytes = source.as_bytes();
478
479 let functions = extract_functions_from_tree(source, file_path, root);
480
481 let param_query = cached_query(&PARAMETERIZED_QUERY_CACHE, PARAMETERIZED_QUERY);
482 let parameterized_count = count_captures(param_query, "parameterized", root, source_bytes);
483
484 let pbt_query = cached_query(&IMPORT_PBT_QUERY_CACHE, IMPORT_PBT_QUERY);
485 let has_pbt_import = has_any_match(pbt_query, "pbt_import", root, source_bytes);
486
487 let contract_query = cached_query(&IMPORT_CONTRACT_QUERY_CACHE, IMPORT_CONTRACT_QUERY);
488 let has_contract_import =
489 has_any_match(contract_query, "contract_import", root, source_bytes);
490
491 let error_test_query = cached_query(&ERROR_TEST_QUERY_CACHE, ERROR_TEST_QUERY);
492 let has_error_test = has_any_match(error_test_query, "error_test", root, source_bytes);
493
494 let relational_query = cached_query(
495 &RELATIONAL_ASSERTION_QUERY_CACHE,
496 RELATIONAL_ASSERTION_QUERY,
497 );
498 let has_relational_assertion =
499 has_any_match(relational_query, "relational", root, source_bytes);
500
501 let mut file_analysis = FileAnalysis {
502 file: file_path.to_string(),
503 functions,
504 has_pbt_import,
505 has_contract_import,
506 has_error_test,
507 has_relational_assertion,
508 parameterized_count,
509 };
510
511 let helper_trace_query = cached_query(&HELPER_TRACE_QUERY_CACHE, HELPER_TRACE_QUERY);
513 let assertion_query_for_trace = cached_query(&ASSERTION_QUERY_CACHE, ASSERTION_QUERY);
514 apply_same_file_helper_tracing(
515 &mut file_analysis,
516 &tree,
517 source_bytes,
518 helper_trace_query,
519 helper_trace_query,
520 assertion_query_for_trace,
521 );
522
523 file_analysis
524 }
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 fn fixture(name: &str) -> String {
532 let path = format!(
533 "{}/tests/fixtures/rust/{}",
534 env!("CARGO_MANIFEST_DIR").replace("/crates/lang-rust", ""),
535 name
536 );
537 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"))
538 }
539
540 #[test]
543 fn parse_rust_source() {
544 let source = "#[test]\nfn test_example() {\n assert_eq!(1, 1);\n}\n";
545 let mut parser = RustExtractor::parser();
546 let tree = parser.parse(source, None).unwrap();
547 assert_eq!(tree.root_node().kind(), "source_file");
548 }
549
550 #[test]
551 fn rust_extractor_implements_language_extractor() {
552 let extractor = RustExtractor::new();
553 let _: &dyn exspec_core::extractor::LanguageExtractor = &extractor;
554 }
555
556 #[test]
559 fn extract_single_test() {
560 let source = fixture("t001_pass.rs");
562 let extractor = RustExtractor::new();
563 let funcs = extractor.extract_test_functions(&source, "t001_pass.rs");
564 assert_eq!(funcs.len(), 1, "should extract exactly 1 test function");
565 assert_eq!(funcs[0].name, "test_create_user");
566 }
567
568 #[test]
569 fn non_test_function_not_extracted() {
570 let source = "fn helper() -> i32 { 42 }\n";
572 let extractor = RustExtractor::new();
573 let funcs = extractor.extract_test_functions(&source, "helper.rs");
574 assert_eq!(funcs.len(), 0, "non-test fn should not be extracted");
575 }
576
577 #[test]
578 fn extract_tokio_test() {
579 let source =
581 "#[tokio::test]\nasync fn test_async_operation() {\n assert_eq!(1, 1);\n}\n";
582 let extractor = RustExtractor::new();
583 let funcs = extractor.extract_test_functions(&source, "tokio_test.rs");
584 assert_eq!(funcs.len(), 1, "should extract #[tokio::test] function");
585 assert_eq!(funcs[0].name, "test_async_operation");
586 }
587
588 #[test]
591 fn assertion_count_zero_for_violation() {
592 let source = fixture("t001_violation.rs");
594 let extractor = RustExtractor::new();
595 let funcs = extractor.extract_test_functions(&source, "t001_violation.rs");
596 assert_eq!(funcs.len(), 1);
597 assert_eq!(
598 funcs[0].analysis.assertion_count, 0,
599 "violation file should have 0 assertions"
600 );
601 }
602
603 #[test]
604 fn assertion_count_positive_for_pass() {
605 let source = fixture("t001_pass.rs");
607 let extractor = RustExtractor::new();
608 let funcs = extractor.extract_test_functions(&source, "t001_pass.rs");
609 assert_eq!(funcs.len(), 1);
610 assert!(
611 funcs[0].analysis.assertion_count >= 1,
612 "pass file should have >= 1 assertion"
613 );
614 }
615
616 #[test]
617 fn all_assert_macros_counted() {
618 let source = "#[test]\nfn test_all_asserts() {\n assert!(true);\n assert_eq!(1, 1);\n assert_ne!(1, 2);\n}\n";
620 let extractor = RustExtractor::new();
621 let funcs = extractor.extract_test_functions(&source, "test_asserts.rs");
622 assert_eq!(funcs.len(), 1);
623 assert_eq!(
624 funcs[0].analysis.assertion_count, 3,
625 "should count assert!, assert_eq!, assert_ne!"
626 );
627 }
628
629 #[test]
630 fn debug_assert_counted() {
631 let source = "#[test]\nfn test_debug_assert() {\n debug_assert!(true);\n}\n";
633 let extractor = RustExtractor::new();
634 let funcs = extractor.extract_test_functions(&source, "test_debug.rs");
635 assert_eq!(funcs.len(), 1);
636 assert_eq!(
637 funcs[0].analysis.assertion_count, 1,
638 "debug_assert! should be counted"
639 );
640 }
641
642 #[test]
645 fn simple_assert_fn_call_detected() {
646 let source = fixture("t001_pass_helper_delegation.rs");
648 let extractor = RustExtractor::new();
649 let funcs = extractor.extract_test_functions(&source, "t001_pass_helper_delegation.rs");
650 let simple = funcs
651 .iter()
652 .find(|f| f.name == "test_simple_helper")
653 .unwrap();
654 assert!(
655 simple.analysis.assertion_count >= 1,
656 "assert_matches() fn call should be counted as assertion, got {}",
657 simple.analysis.assertion_count
658 );
659 }
660
661 #[test]
662 fn scoped_assert_fn_call_detected() {
663 let source = fixture("t001_pass_helper_delegation.rs");
665 let extractor = RustExtractor::new();
666 let funcs = extractor.extract_test_functions(&source, "t001_pass_helper_delegation.rs");
667 let scoped = funcs
668 .iter()
669 .find(|f| f.name == "test_scoped_helper")
670 .unwrap();
671 assert!(
672 scoped.analysis.assertion_count >= 1,
673 "common::assert_matches() should be counted as assertion, got {}",
674 scoped.analysis.assertion_count
675 );
676 }
677
678 #[test]
679 fn mixed_macro_and_fn_call_counted() {
680 let source = fixture("t001_pass_helper_delegation.rs");
682 let extractor = RustExtractor::new();
683 let funcs = extractor.extract_test_functions(&source, "t001_pass_helper_delegation.rs");
684 let mixed = funcs
685 .iter()
686 .find(|f| f.name == "test_mixed_macro_and_fn")
687 .unwrap();
688 assert_eq!(
689 mixed.analysis.assertion_count, 2,
690 "assert_eq! macro + assert_matches() fn call should total 2, got {}",
691 mixed.analysis.assertion_count
692 );
693 }
694
695 #[test]
696 fn assertion_prefix_not_counted() {
697 let source = "#[test]\nfn test_foo() {\n assertion_helper(expected, actual);\n}\n";
699 let extractor = RustExtractor::new();
700 let funcs = extractor.extract_test_functions(&source, "test_negative.rs");
701 assert_eq!(funcs.len(), 1);
702 assert_eq!(
703 funcs[0].analysis.assertion_count, 0,
704 "assertion_helper() should NOT be counted as assertion"
705 );
706 }
707
708 #[test]
709 fn ordinary_helper_not_counted() {
710 let source = "#[test]\nfn test_foo() {\n helper_check(expected, actual);\n}\n";
712 let extractor = RustExtractor::new();
713 let funcs = extractor.extract_test_functions(&source, "test_negative2.rs");
714 assert_eq!(funcs.len(), 1);
715 assert_eq!(
716 funcs[0].analysis.assertion_count, 0,
717 "helper_check() should NOT be counted as assertion"
718 );
719 }
720
721 #[test]
724 fn mock_pattern_detected() {
725 let source = "#[test]\nfn test_with_mock() {\n let mock_svc = MockService::new();\n assert_eq!(mock_svc.len(), 0);\n}\n";
727 let extractor = RustExtractor::new();
728 let funcs = extractor.extract_test_functions(&source, "test_mock.rs");
729 assert_eq!(funcs.len(), 1);
730 assert!(
731 funcs[0].analysis.mock_count >= 1,
732 "MockService::new() should be detected"
733 );
734 }
735
736 #[test]
737 fn mock_count_for_violation() {
738 let source = fixture("t002_violation.rs");
740 let extractor = RustExtractor::new();
741 let funcs = extractor.extract_test_functions(&source, "t002_violation.rs");
742 assert_eq!(funcs.len(), 1);
743 assert!(
744 funcs[0].analysis.mock_count > 5,
745 "violation file should have > 5 mocks, got {}",
746 funcs[0].analysis.mock_count
747 );
748 }
749
750 #[test]
751 fn mock_count_for_pass() {
752 let source = fixture("t002_pass.rs");
754 let extractor = RustExtractor::new();
755 let funcs = extractor.extract_test_functions(&source, "t002_pass.rs");
756 assert_eq!(funcs.len(), 1);
757 assert_eq!(
758 funcs[0].analysis.mock_count, 1,
759 "pass file should have 1 mock"
760 );
761 assert_eq!(funcs[0].analysis.mock_classes, vec!["repo"]);
762 }
763
764 #[test]
765 fn mock_class_name_extraction() {
766 assert_eq!(extract_mock_class_name("mock_service"), "service");
768 assert_eq!(extract_mock_class_name("mock_db"), "db");
769 assert_eq!(extract_mock_class_name("service"), "service");
770 assert_eq!(extract_mock_class_name("mockService"), "Service");
771 }
772
773 #[test]
776 fn giant_test_line_count() {
777 let source = fixture("t003_violation.rs");
779 let extractor = RustExtractor::new();
780 let funcs = extractor.extract_test_functions(&source, "t003_violation.rs");
781 assert_eq!(funcs.len(), 1);
782 assert!(
783 funcs[0].analysis.line_count > 50,
784 "violation file line_count should > 50, got {}",
785 funcs[0].analysis.line_count
786 );
787 }
788
789 #[test]
790 fn short_test_line_count() {
791 let source = fixture("t003_pass.rs");
793 let extractor = RustExtractor::new();
794 let funcs = extractor.extract_test_functions(&source, "t003_pass.rs");
795 assert_eq!(funcs.len(), 1);
796 assert!(
797 funcs[0].analysis.line_count <= 50,
798 "pass file line_count should <= 50, got {}",
799 funcs[0].analysis.line_count
800 );
801 }
802
803 #[test]
806 fn file_analysis_detects_parameterized() {
807 let source = fixture("t004_pass.rs");
809 let extractor = RustExtractor::new();
810 let fa = extractor.extract_file_analysis(&source, "t004_pass.rs");
811 assert!(
812 fa.parameterized_count >= 1,
813 "should detect #[rstest], got {}",
814 fa.parameterized_count
815 );
816 }
817
818 #[test]
819 fn file_analysis_no_parameterized() {
820 let source = fixture("t004_violation.rs");
822 let extractor = RustExtractor::new();
823 let fa = extractor.extract_file_analysis(&source, "t004_violation.rs");
824 assert_eq!(
825 fa.parameterized_count, 0,
826 "violation file should have 0 parameterized"
827 );
828 }
829
830 #[test]
831 fn file_analysis_pbt_import() {
832 let source = fixture("t005_pass.rs");
834 let extractor = RustExtractor::new();
835 let fa = extractor.extract_file_analysis(&source, "t005_pass.rs");
836 assert!(fa.has_pbt_import, "should detect proptest import");
837 }
838
839 #[test]
840 fn file_analysis_no_pbt_import() {
841 let source = fixture("t005_violation.rs");
843 let extractor = RustExtractor::new();
844 let fa = extractor.extract_file_analysis(&source, "t005_violation.rs");
845 assert!(!fa.has_pbt_import, "should not detect PBT import");
846 }
847
848 #[test]
849 fn file_analysis_no_contract() {
850 let source = fixture("t008_violation.rs");
852 let extractor = RustExtractor::new();
853 let fa = extractor.extract_file_analysis(&source, "t008_violation.rs");
854 assert!(!fa.has_contract_import, "Rust has no contract library");
855 }
856
857 #[test]
860 fn prop_assert_counts_as_assertion() {
861 let source = fixture("t001_proptest_pass.rs");
863 let extractor = RustExtractor::new();
864 let funcs = extractor.extract_test_functions(&source, "t001_proptest_pass.rs");
865 assert_eq!(funcs.len(), 1, "should extract test from proptest! macro");
866 assert!(
867 funcs[0].analysis.assertion_count >= 1,
868 "prop_assert_eq! should be counted, got {}",
869 funcs[0].analysis.assertion_count
870 );
871 }
872
873 #[test]
876 fn suppressed_test_has_suppressed_rules() {
877 let source = fixture("suppressed.rs");
879 let extractor = RustExtractor::new();
880 let funcs = extractor.extract_test_functions(&source, "suppressed.rs");
881 assert_eq!(funcs.len(), 1);
882 assert!(
883 funcs[0]
884 .analysis
885 .suppressed_rules
886 .iter()
887 .any(|r| r.0 == "T001"),
888 "T001 should be suppressed, got: {:?}",
889 funcs[0].analysis.suppressed_rules
890 );
891 }
892
893 fn make_query(scm: &str) -> Query {
896 Query::new(&rust_language(), scm).unwrap()
897 }
898
899 #[test]
900 fn query_capture_names_test_function() {
901 let q = make_query(include_str!("../queries/test_function.scm"));
902 assert!(
903 q.capture_index_for_name("test_attr").is_some(),
904 "test_function.scm must define @test_attr capture"
905 );
906 }
907
908 #[test]
909 fn query_capture_names_assertion() {
910 let q = make_query(include_str!("../queries/assertion.scm"));
911 assert!(
912 q.capture_index_for_name("assertion").is_some(),
913 "assertion.scm must define @assertion capture"
914 );
915 }
916
917 #[test]
918 fn query_capture_names_mock_usage() {
919 let q = make_query(include_str!("../queries/mock_usage.scm"));
920 assert!(
921 q.capture_index_for_name("mock").is_some(),
922 "mock_usage.scm must define @mock capture"
923 );
924 }
925
926 #[test]
927 fn query_capture_names_mock_assignment() {
928 let q = make_query(include_str!("../queries/mock_assignment.scm"));
929 assert!(
930 q.capture_index_for_name("var_name").is_some(),
931 "mock_assignment.scm must define @var_name (required by collect_mock_class_names .expect())"
932 );
933 }
934
935 #[test]
936 fn query_capture_names_parameterized() {
937 let q = make_query(include_str!("../queries/parameterized.scm"));
938 assert!(
939 q.capture_index_for_name("parameterized").is_some(),
940 "parameterized.scm must define @parameterized capture"
941 );
942 }
943
944 #[test]
945 fn query_capture_names_import_pbt() {
946 let q = make_query(include_str!("../queries/import_pbt.scm"));
947 assert!(
948 q.capture_index_for_name("pbt_import").is_some(),
949 "import_pbt.scm must define @pbt_import capture"
950 );
951 }
952
953 #[test]
957 fn query_capture_names_import_contract_comment_only() {
958 let q = make_query(include_str!("../queries/import_contract.scm"));
959 assert!(
960 q.capture_index_for_name("contract_import").is_none(),
961 "Rust import_contract.scm is intentionally comment-only"
962 );
963 }
964
965 #[test]
968 fn error_test_should_panic() {
969 let source = fixture("t103_pass.rs");
970 let extractor = RustExtractor::new();
971 let fa = extractor.extract_file_analysis(&source, "t103_pass.rs");
972 assert!(
973 fa.has_error_test,
974 "#[should_panic] should set has_error_test"
975 );
976 }
977
978 #[test]
979 fn error_test_unwrap_err() {
980 let source = fixture("t103_pass_unwrap_err.rs");
981 let extractor = RustExtractor::new();
982 let fa = extractor.extract_file_analysis(&source, "t103_pass_unwrap_err.rs");
983 assert!(fa.has_error_test, ".unwrap_err() should set has_error_test");
984 }
985
986 #[test]
987 fn error_test_no_patterns() {
988 let source = fixture("t103_violation.rs");
989 let extractor = RustExtractor::new();
990 let fa = extractor.extract_file_analysis(&source, "t103_violation.rs");
991 assert!(
992 !fa.has_error_test,
993 "no error patterns should set has_error_test=false"
994 );
995 }
996
997 #[test]
998 fn error_test_is_err_only_not_sufficient() {
999 let source = fixture("t103_is_err_only.rs");
1000 let extractor = RustExtractor::new();
1001 let fa = extractor.extract_file_analysis(&source, "t103_is_err_only.rs");
1002 assert!(
1003 !fa.has_error_test,
1004 ".is_err() alone should not count as error test (weak proxy)"
1005 );
1006 }
1007
1008 #[test]
1009 fn query_capture_names_error_test() {
1010 let q = make_query(include_str!("../queries/error_test.scm"));
1011 assert!(
1012 q.capture_index_for_name("error_test").is_some(),
1013 "error_test.scm must define @error_test capture"
1014 );
1015 }
1016
1017 #[test]
1020 fn relational_assertion_pass_contains() {
1021 let source = fixture("t105_pass.rs");
1022 let extractor = RustExtractor::new();
1023 let fa = extractor.extract_file_analysis(&source, "t105_pass.rs");
1024 assert!(
1025 fa.has_relational_assertion,
1026 ".contains() should set has_relational_assertion"
1027 );
1028 }
1029
1030 #[test]
1031 fn relational_assertion_violation() {
1032 let source = fixture("t105_violation.rs");
1033 let extractor = RustExtractor::new();
1034 let fa = extractor.extract_file_analysis(&source, "t105_violation.rs");
1035 assert!(
1036 !fa.has_relational_assertion,
1037 "only assert_eq! should not set has_relational_assertion"
1038 );
1039 }
1040
1041 #[test]
1042 fn query_capture_names_relational_assertion() {
1043 let q = make_query(include_str!("../queries/relational_assertion.scm"));
1044 assert!(
1045 q.capture_index_for_name("relational").is_some(),
1046 "relational_assertion.scm must define @relational capture"
1047 );
1048 }
1049
1050 #[test]
1053 fn how_not_what_expect_method() {
1054 let source = fixture("t101_violation.rs");
1055 let extractor = RustExtractor::new();
1056 let funcs = extractor.extract_test_functions(&source, "t101_violation.rs");
1057 assert!(
1058 funcs[0].analysis.how_not_what_count > 0,
1059 "mock.expect_save() should trigger how_not_what, got {}",
1060 funcs[0].analysis.how_not_what_count
1061 );
1062 }
1063
1064 #[test]
1065 fn how_not_what_pass() {
1066 let source = fixture("t101_pass.rs");
1067 let extractor = RustExtractor::new();
1068 let funcs = extractor.extract_test_functions(&source, "t101_pass.rs");
1069 assert_eq!(
1070 funcs[0].analysis.how_not_what_count, 0,
1071 "no mock patterns should have how_not_what_count=0"
1072 );
1073 }
1074
1075 #[test]
1076 fn how_not_what_private_field_limited_by_token_tree() {
1077 let source = fixture("t101_private_violation.rs");
1083 let extractor = RustExtractor::new();
1084 let funcs = extractor.extract_test_functions(&source, "t101_private_violation.rs");
1085 assert_eq!(
1086 funcs[0].analysis.how_not_what_count, 0,
1087 "Rust token_tree limitation: private field access in test is not detected"
1088 );
1089 }
1090
1091 #[test]
1092 fn query_capture_names_how_not_what() {
1093 let q = make_query(include_str!("../queries/how_not_what.scm"));
1094 assert!(
1095 q.capture_index_for_name("how_pattern").is_some(),
1096 "how_not_what.scm must define @how_pattern capture"
1097 );
1098 }
1099
1100 #[test]
1101 fn query_capture_names_private_in_assertion() {
1102 let q = make_query(include_str!("../queries/private_in_assertion.scm"));
1103 assert!(
1104 q.capture_index_for_name("private_access").is_some(),
1105 "private_in_assertion.scm must define @private_access capture"
1106 );
1107 }
1108
1109 #[test]
1112 fn fixture_count_for_violation() {
1113 let source = fixture("t102_violation.rs");
1114 let extractor = RustExtractor::new();
1115 let funcs = extractor.extract_test_functions(&source, "t102_violation.rs");
1116 assert_eq!(
1117 funcs[0].analysis.fixture_count, 7,
1118 "expected 7 let bindings as fixture_count"
1119 );
1120 }
1121
1122 #[test]
1123 fn fixture_count_for_pass() {
1124 let source = fixture("t102_pass.rs");
1125 let extractor = RustExtractor::new();
1126 let funcs = extractor.extract_test_functions(&source, "t102_pass.rs");
1127 assert_eq!(
1128 funcs[0].analysis.fixture_count, 1,
1129 "expected 1 let binding as fixture_count"
1130 );
1131 }
1132
1133 #[test]
1134 fn fixture_count_excludes_method_calls_on_locals() {
1135 let source = fixture("t102_method_chain.rs");
1136 let extractor = RustExtractor::new();
1137 let funcs = extractor.extract_test_functions(&source, "t102_method_chain.rs");
1138 assert_eq!(
1139 funcs[0].analysis.fixture_count, 6,
1140 "scoped calls (3) + struct (1) + macro (1) + builder chain (1) = 6, method calls on locals excluded"
1141 );
1142 }
1143
1144 #[test]
1147 fn wait_and_see_violation_sleep() {
1148 let source = fixture("t108_violation_sleep.rs");
1149 let extractor = RustExtractor::new();
1150 let funcs = extractor.extract_test_functions(&source, "t108_violation_sleep.rs");
1151 assert!(!funcs.is_empty());
1152 for func in &funcs {
1153 assert!(
1154 func.analysis.has_wait,
1155 "test '{}' should have has_wait=true",
1156 func.name
1157 );
1158 }
1159 }
1160
1161 #[test]
1162 fn wait_and_see_pass_no_sleep() {
1163 let source = fixture("t108_pass_no_sleep.rs");
1164 let extractor = RustExtractor::new();
1165 let funcs = extractor.extract_test_functions(&source, "t108_pass_no_sleep.rs");
1166 assert_eq!(funcs.len(), 1);
1167 assert!(
1168 !funcs[0].analysis.has_wait,
1169 "test without sleep should have has_wait=false"
1170 );
1171 }
1172
1173 #[test]
1174 fn query_capture_names_wait_and_see() {
1175 let q = make_query(include_str!("../queries/wait_and_see.scm"));
1176 assert!(
1177 q.capture_index_for_name("wait").is_some(),
1178 "wait_and_see.scm must define @wait capture"
1179 );
1180 }
1181
1182 #[test]
1185 fn t107_violation_no_messages() {
1186 let source = fixture("t107_violation.rs");
1187 let extractor = RustExtractor::new();
1188 let funcs = extractor.extract_test_functions(&source, "t107_violation.rs");
1189 assert_eq!(funcs.len(), 1);
1190 assert!(
1191 funcs[0].analysis.assertion_count >= 2,
1192 "should have multiple assertions"
1193 );
1194 assert_eq!(
1195 funcs[0].analysis.assertion_message_count, 0,
1196 "no assertion should have a message"
1197 );
1198 }
1199
1200 #[test]
1201 fn t107_pass_with_messages() {
1202 let source = fixture("t107_pass_with_messages.rs");
1203 let extractor = RustExtractor::new();
1204 let funcs = extractor.extract_test_functions(&source, "t107_pass_with_messages.rs");
1205 assert_eq!(funcs.len(), 1);
1206 assert!(
1207 funcs[0].analysis.assertion_message_count >= 1,
1208 "assertions with messages should be counted"
1209 );
1210 }
1211
1212 #[test]
1215 fn t109_violation_names_detected() {
1216 let source = fixture("t109_violation.rs");
1217 let extractor = RustExtractor::new();
1218 let funcs = extractor.extract_test_functions(&source, "t109_violation.rs");
1219 assert!(!funcs.is_empty());
1220 for func in &funcs {
1221 assert!(
1222 exspec_core::rules::is_undescriptive_test_name(&func.name),
1223 "test '{}' should be undescriptive",
1224 func.name
1225 );
1226 }
1227 }
1228
1229 #[test]
1230 fn t109_pass_descriptive_names() {
1231 let source = fixture("t109_pass.rs");
1232 let extractor = RustExtractor::new();
1233 let funcs = extractor.extract_test_functions(&source, "t109_pass.rs");
1234 assert!(!funcs.is_empty());
1235 for func in &funcs {
1236 assert!(
1237 !exspec_core::rules::is_undescriptive_test_name(&func.name),
1238 "test '{}' should be descriptive",
1239 func.name
1240 );
1241 }
1242 }
1243
1244 #[test]
1247 fn t106_violation_duplicate_literal() {
1248 let source = fixture("t106_violation.rs");
1249 let extractor = RustExtractor::new();
1250 let funcs = extractor.extract_test_functions(&source, "t106_violation.rs");
1251 assert_eq!(funcs.len(), 1);
1252 assert!(
1253 funcs[0].analysis.duplicate_literal_count >= 3,
1254 "42 appears 3 times, should be >= 3: got {}",
1255 funcs[0].analysis.duplicate_literal_count
1256 );
1257 }
1258
1259 #[test]
1260 fn t106_pass_no_duplicates() {
1261 let source = fixture("t106_pass_no_duplicates.rs");
1262 let extractor = RustExtractor::new();
1263 let funcs = extractor.extract_test_functions(&source, "t106_pass_no_duplicates.rs");
1264 assert_eq!(funcs.len(), 1);
1265 assert!(
1266 funcs[0].analysis.duplicate_literal_count < 3,
1267 "each literal appears once: got {}",
1268 funcs[0].analysis.duplicate_literal_count
1269 );
1270 }
1271
1272 #[test]
1275 fn t001_should_panic_counts_as_assertion() {
1276 let source = fixture("t001_should_panic.rs");
1278 let extractor = RustExtractor::new();
1279 let funcs = extractor.extract_test_functions(&source, "t001_should_panic.rs");
1280 assert_eq!(funcs.len(), 1);
1281 assert!(
1282 funcs[0].analysis.assertion_count >= 1,
1283 "#[should_panic] should count as assertion, got {}",
1284 funcs[0].analysis.assertion_count
1285 );
1286 }
1287
1288 #[test]
1289 fn t001_should_panic_before_test_counts_as_assertion() {
1290 let source = fixture("t001_should_panic_before_test.rs");
1292 let extractor = RustExtractor::new();
1293 let funcs = extractor.extract_test_functions(&source, "t001_should_panic_before_test.rs");
1294 assert_eq!(funcs.len(), 1);
1295 assert!(
1296 funcs[0].analysis.assertion_count >= 1,
1297 "#[should_panic] before #[test] should count as assertion, got {}",
1298 funcs[0].analysis.assertion_count
1299 );
1300 }
1301
1302 #[test]
1303 fn t001_should_panic_in_mod_counts_as_assertion() {
1304 let source = fixture("t001_should_panic_in_mod.rs");
1306 let extractor = RustExtractor::new();
1307 let funcs = extractor.extract_test_functions(&source, "t001_should_panic_in_mod.rs");
1308 assert_eq!(funcs.len(), 1);
1309 assert!(
1310 funcs[0].analysis.assertion_count >= 1,
1311 "#[should_panic] in mod should count as assertion, got {}",
1312 funcs[0].analysis.assertion_count
1313 );
1314 }
1315
1316 #[test]
1317 fn t001_should_panic_substring_not_matched() {
1318 let source = fixture("t001_should_panic_substring_no_match.rs");
1321 let extractor = RustExtractor::new();
1322 let funcs =
1323 extractor.extract_test_functions(&source, "t001_should_panic_substring_no_match.rs");
1324 assert_eq!(funcs.len(), 1);
1325 assert_eq!(
1326 funcs[0].analysis.assertion_count, 0,
1327 "#[my_should_panic_wrapper] should NOT count as assertion (exact match only), got {}",
1328 funcs[0].analysis.assertion_count
1329 );
1330 }
1331
1332 #[test]
1335 fn tc01_assert_pending_macro_counted_as_assertion() {
1336 let source = fixture("t001_pass_custom_assert_macro.rs");
1338 let extractor = RustExtractor::new();
1339 let funcs = extractor.extract_test_functions(&source, "t001_pass_custom_assert_macro.rs");
1340 let func = funcs
1341 .iter()
1342 .find(|f| f.name == "test_with_assert_pending")
1343 .expect("test_with_assert_pending not found");
1344 assert!(
1345 func.analysis.assertion_count >= 1,
1346 "assert_pending! should be counted as assertion, got {}",
1347 func.analysis.assertion_count
1348 );
1349 }
1350
1351 #[test]
1352 fn tc02_assert_ready_ok_macro_counted_as_assertion() {
1353 let source = fixture("t001_pass_custom_assert_macro.rs");
1355 let extractor = RustExtractor::new();
1356 let funcs = extractor.extract_test_functions(&source, "t001_pass_custom_assert_macro.rs");
1357 let func = funcs
1358 .iter()
1359 .find(|f| f.name == "test_with_assert_ready_ok")
1360 .expect("test_with_assert_ready_ok not found");
1361 assert!(
1362 func.analysis.assertion_count >= 1,
1363 "assert_ready_ok! should be counted as assertion, got {}",
1364 func.analysis.assertion_count
1365 );
1366 }
1367
1368 #[test]
1369 fn tc03_assert_data_eq_macro_counted_as_assertion() {
1370 let source = fixture("t001_pass_custom_assert_macro.rs");
1372 let extractor = RustExtractor::new();
1373 let funcs = extractor.extract_test_functions(&source, "t001_pass_custom_assert_macro.rs");
1374 let func = funcs
1375 .iter()
1376 .find(|f| f.name == "test_with_assert_data_eq")
1377 .expect("test_with_assert_data_eq not found");
1378 assert!(
1379 func.analysis.assertion_count >= 1,
1380 "assert_data_eq! should be counted as assertion, got {}",
1381 func.analysis.assertion_count
1382 );
1383 }
1384
1385 #[test]
1386 fn tc04_standard_assert_macros_still_detected_regression() {
1387 let source = "#[test]\nfn test_standard_asserts() {\n assert!(true);\n assert_eq!(1, 1);\n assert_ne!(1, 2);\n debug_assert!(true);\n prop_assert_eq!(1, 1);\n}\n";
1389 let extractor = RustExtractor::new();
1390 let funcs = extractor.extract_test_functions(&source, "regression_standard.rs");
1391 assert_eq!(funcs.len(), 1);
1392 assert_eq!(
1393 funcs[0].analysis.assertion_count, 5,
1394 "assert!, assert_eq!, assert_ne!, debug_assert!, prop_assert_eq! should all be counted, got {}",
1395 funcs[0].analysis.assertion_count
1396 );
1397 }
1398
1399 #[test]
1400 fn tc05_assertion_macro_not_counted_as_assertion() {
1401 let source = "#[test]\nfn test_with_assertion_macro() {\n assertion!(x == 5);\n}\n";
1403 let extractor = RustExtractor::new();
1404 let funcs = extractor.extract_test_functions(&source, "assertion_macro.rs");
1405 assert_eq!(funcs.len(), 1);
1406 assert_eq!(
1407 funcs[0].analysis.assertion_count, 0,
1408 "assertion!() should NOT be counted as assertion, got {}",
1409 funcs[0].analysis.assertion_count
1410 );
1411 }
1412
1413 #[test]
1416 fn helper_tracing_tc01_delegates_to_helper_with_assertion() {
1417 let source = fixture("t001_pass_helper_tracing.rs");
1420 let extractor = RustExtractor::new();
1421 let fa = extractor.extract_file_analysis(&source, "t001_pass_helper_tracing.rs");
1422 let func = fa
1423 .functions
1424 .iter()
1425 .find(|f| f.name == "test_delegates_to_helper_with_assertion")
1426 .expect("test_delegates_to_helper_with_assertion not found");
1427 assert!(
1428 func.analysis.assertion_count >= 1,
1429 "TC-01: helper with assertion traced → assertion_count >= 1, got {}",
1430 func.analysis.assertion_count
1431 );
1432 }
1433
1434 #[test]
1435 fn helper_tracing_tc02_delegates_to_helper_without_assertion() {
1436 let source = fixture("t001_pass_helper_tracing.rs");
1438 let extractor = RustExtractor::new();
1439 let fa = extractor.extract_file_analysis(&source, "t001_pass_helper_tracing.rs");
1440 let func = fa
1441 .functions
1442 .iter()
1443 .find(|f| f.name == "test_delegates_to_helper_without_assertion")
1444 .expect("test_delegates_to_helper_without_assertion not found");
1445 assert_eq!(
1446 func.analysis.assertion_count, 0,
1447 "TC-02: helper without assertion → assertion_count == 0, got {}",
1448 func.analysis.assertion_count
1449 );
1450 }
1451
1452 #[test]
1453 fn helper_tracing_tc03_has_own_assertion_and_calls_helper() {
1454 let source = fixture("t001_pass_helper_tracing.rs");
1456 let extractor = RustExtractor::new();
1457 let fa = extractor.extract_file_analysis(&source, "t001_pass_helper_tracing.rs");
1458 let func = fa
1459 .functions
1460 .iter()
1461 .find(|f| f.name == "test_has_own_assertion_and_calls_helper")
1462 .expect("test_has_own_assertion_and_calls_helper not found");
1463 assert!(
1464 func.analysis.assertion_count >= 1,
1465 "TC-03: own assertion present → assertion_count >= 1, got {}",
1466 func.analysis.assertion_count
1467 );
1468 }
1469
1470 #[test]
1471 fn helper_tracing_tc04_calls_undefined_function() {
1472 let source = fixture("t001_pass_helper_tracing.rs");
1474 let extractor = RustExtractor::new();
1475 let fa = extractor.extract_file_analysis(&source, "t001_pass_helper_tracing.rs");
1476 let func = fa
1477 .functions
1478 .iter()
1479 .find(|f| f.name == "test_calls_undefined_function")
1480 .expect("test_calls_undefined_function not found");
1481 assert_eq!(
1482 func.analysis.assertion_count, 0,
1483 "TC-04: undefined function call → no crash, assertion_count == 0, got {}",
1484 func.analysis.assertion_count
1485 );
1486 }
1487
1488 #[test]
1489 fn helper_tracing_tc05_two_hop_not_traced() {
1490 let source = fixture("t001_pass_helper_tracing.rs");
1493 let extractor = RustExtractor::new();
1494 let fa = extractor.extract_file_analysis(&source, "t001_pass_helper_tracing.rs");
1495 let func = fa
1496 .functions
1497 .iter()
1498 .find(|f| f.name == "test_two_hop_not_traced")
1499 .expect("test_two_hop_not_traced not found");
1500 assert_eq!(
1501 func.analysis.assertion_count, 0,
1502 "TC-05: 2-hop helper not traced → assertion_count == 0, got {}",
1503 func.analysis.assertion_count
1504 );
1505 }
1506
1507 #[test]
1508 fn helper_tracing_tc06_all_functions_have_assertions_early_return() {
1509 use exspec_core::query_utils::apply_same_file_helper_tracing;
1514 use tree_sitter::Query;
1515
1516 let source = "#[test]\nfn test_has_assertion() {\n assert_eq!(1, 1);\n}\n";
1518 let extractor = RustExtractor::new();
1519 let mut fa = extractor.extract_file_analysis(source, "tc06.rs");
1520
1521 assert!(
1523 fa.functions.iter().all(|f| f.analysis.assertion_count > 0),
1524 "TC-06 precondition: all functions must have assertion_count > 0"
1525 );
1526
1527 let language = tree_sitter_rust::LANGUAGE;
1529 let lang: tree_sitter::Language = language.into();
1530 let call_query =
1532 Query::new(&lang, "(call_expression function: (identifier) @call_name)").unwrap();
1533 let def_query = Query::new(
1534 &lang,
1535 "(function_item name: (identifier) @def_name body: (block) @def_body)",
1536 )
1537 .unwrap();
1538 let assertion_query =
1539 Query::new(&lang, "(macro_invocation macro: (identifier) @assertion)").unwrap();
1540
1541 let mut parser = RustExtractor::parser();
1542 let tree = parser.parse(source, None).unwrap();
1543
1544 let before: Vec<usize> = fa
1545 .functions
1546 .iter()
1547 .map(|f| f.analysis.assertion_count)
1548 .collect();
1549
1550 apply_same_file_helper_tracing(
1551 &mut fa,
1552 &tree,
1553 source.as_bytes(),
1554 &call_query,
1555 &def_query,
1556 &assertion_query,
1557 );
1558
1559 let after: Vec<usize> = fa
1560 .functions
1561 .iter()
1562 .map(|f| f.analysis.assertion_count)
1563 .collect();
1564
1565 assert_eq!(
1566 before, after,
1567 "TC-06: assertion_counts must not change when all > 0 (early return)"
1568 );
1569 }
1570
1571 #[test]
1572 fn helper_tracing_tc07_multiple_calls_to_same_helper() {
1573 let source = fixture("t001_pass_helper_tracing.rs");
1576 let extractor = RustExtractor::new();
1577 let fa = extractor.extract_file_analysis(&source, "t001_pass_helper_tracing.rs");
1578 let func = fa
1579 .functions
1580 .iter()
1581 .find(|f| f.name == "test_calls_helper_twice")
1582 .expect("test_calls_helper_twice not found");
1583
1584 assert_eq!(
1588 func.analysis.assertion_count, 1,
1589 "TC-07: multiple calls to same helper should be deduplicated, got {}",
1590 func.analysis.assertion_count
1591 );
1592 }
1593}