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 SKIP_TEST_QUERY: &str = include_str!("../queries/skip_test.scm");
27const HELPER_TRACE_QUERY: &str = include_str!("../queries/helper_trace.scm");
28
29fn python_language() -> tree_sitter::Language {
30 tree_sitter_python::LANGUAGE.into()
31}
32
33fn cached_query<'a>(lock: &'a OnceLock<Query>, source: &str) -> &'a Query {
34 lock.get_or_init(|| Query::new(&python_language(), source).expect("invalid query"))
35}
36
37static TEST_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
38static ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
39static MOCK_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
40static MOCK_ASSIGN_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
41static PARAMETERIZED_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
42static IMPORT_PBT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
43static IMPORT_CONTRACT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
44static HOW_NOT_WHAT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
45static PRIVATE_IN_ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
46static ERROR_TEST_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
47static RELATIONAL_ASSERTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
48static WAIT_AND_SEE_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
49static SKIP_TEST_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
50static HELPER_TRACE_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
51
52pub struct PythonExtractor;
53
54impl PythonExtractor {
55 pub fn new() -> Self {
56 Self
57 }
58
59 pub fn parser() -> Parser {
60 let mut parser = Parser::new();
61 let language = tree_sitter_python::LANGUAGE;
62 parser
63 .set_language(&language.into())
64 .expect("failed to load Python grammar");
65 parser
66 }
67}
68
69impl Default for PythonExtractor {
70 fn default() -> Self {
71 Self::new()
72 }
73}
74
75struct TestMatch {
76 name: String,
77 dedup_id: usize,
78 fn_start_byte: usize,
79 fn_end_byte: usize,
80 fn_start_row: usize,
81 fn_end_row: usize,
82 decorated_start_byte: Option<usize>,
83 decorated_end_byte: Option<usize>,
84 decorated_start_row: Option<usize>,
85}
86
87impl TestMatch {
88 fn effective_byte_range(&self) -> (usize, usize) {
89 (
90 self.decorated_start_byte.unwrap_or(self.fn_start_byte),
91 self.decorated_end_byte.unwrap_or(self.fn_end_byte),
92 )
93 }
94}
95
96fn is_in_non_test_class(root: Node, start_byte: usize, end_byte: usize, source: &[u8]) -> bool {
97 let Some(node) = root.descendant_for_byte_range(start_byte, end_byte) else {
98 return false;
99 };
100 let mut outermost_class_name: Option<String> = None;
102 let mut current = node.parent();
103 while let Some(parent) = current {
104 if parent.kind() == "class_definition" {
105 if let Some(name_node) = parent.child_by_field_name("name") {
106 if let Ok(name) = name_node.utf8_text(source) {
107 outermost_class_name = Some(name.to_string());
108 }
109 } else {
110 outermost_class_name = Some(String::new());
111 }
112 }
113 current = parent.parent();
114 }
115 match outermost_class_name {
116 None => false, Some(name) => !name.starts_with("Test") && !name.starts_with("test_"),
118 }
119}
120
121fn is_pytest_fixture_decorator(decorated_node: Node, source: &[u8]) -> bool {
122 let mut cursor = decorated_node.walk();
123 for child in decorated_node.children(&mut cursor) {
124 if child.kind() != "decorator" {
125 continue;
126 }
127 let Ok(text) = child.utf8_text(source) else {
128 continue;
129 };
130 let trimmed = text.trim_start_matches('@');
132 let name = trimmed.split('(').next().unwrap_or("").trim();
133 if name == "pytest.fixture" || name == "fixture" {
134 return true;
135 }
136 }
137 false
138}
139
140fn extract_functions_from_tree(source: &str, file_path: &str, root: Node) -> Vec<TestFunction> {
141 let test_query = cached_query(&TEST_QUERY_CACHE, TEST_FUNCTION_QUERY);
142 let assertion_query = cached_query(&ASSERTION_QUERY_CACHE, ASSERTION_QUERY);
143 let mock_query = cached_query(&MOCK_QUERY_CACHE, MOCK_USAGE_QUERY);
144 let mock_assign_query = cached_query(&MOCK_ASSIGN_QUERY_CACHE, MOCK_ASSIGNMENT_QUERY);
145 let how_not_what_query = cached_query(&HOW_NOT_WHAT_QUERY_CACHE, HOW_NOT_WHAT_QUERY);
146 let private_query = cached_query(
147 &PRIVATE_IN_ASSERTION_QUERY_CACHE,
148 PRIVATE_IN_ASSERTION_QUERY,
149 );
150 let wait_query = cached_query(&WAIT_AND_SEE_QUERY_CACHE, WAIT_AND_SEE_QUERY);
151 let skip_query = cached_query(&SKIP_TEST_QUERY_CACHE, SKIP_TEST_QUERY);
152
153 let name_idx = test_query
154 .capture_index_for_name("name")
155 .expect("no @name capture");
156 let function_idx = test_query
157 .capture_index_for_name("function")
158 .expect("no @function capture");
159 let decorated_idx = test_query
160 .capture_index_for_name("decorated")
161 .expect("no @decorated capture");
162
163 let source_bytes = source.as_bytes();
164
165 let mut test_matches = Vec::new();
166 let mut decorated_fn_ids = std::collections::HashSet::new();
167 {
168 let mut cursor = QueryCursor::new();
169 let mut matches = cursor.matches(test_query, root, source_bytes);
170 while let Some(m) = matches.next() {
171 let name_capture = match m.captures.iter().find(|c| c.index == name_idx) {
172 Some(c) => c,
173 None => continue,
174 };
175 let name = match name_capture.node.utf8_text(source_bytes) {
176 Ok(s) => s.to_string(),
177 Err(_) => continue,
178 };
179
180 let decorated_capture = m.captures.iter().find(|c| c.index == decorated_idx);
181 let fn_capture = m.captures.iter().find(|c| c.index == function_idx);
182
183 if let Some(dec) = decorated_capture {
184 let inner_fn = dec
185 .node
186 .child_by_field_name("definition")
187 .unwrap_or(dec.node);
188 decorated_fn_ids.insert(inner_fn.id());
191 if is_pytest_fixture_decorator(dec.node, source_bytes) {
194 continue;
195 }
196 test_matches.push(TestMatch {
197 name,
198 dedup_id: inner_fn.id(),
199 fn_start_byte: inner_fn.start_byte(),
200 fn_end_byte: inner_fn.end_byte(),
201 fn_start_row: inner_fn.start_position().row,
202 fn_end_row: inner_fn.end_position().row,
203 decorated_start_byte: Some(dec.node.start_byte()),
204 decorated_end_byte: Some(dec.node.end_byte()),
205 decorated_start_row: Some(dec.node.start_position().row),
206 });
207 } else if let Some(fn_c) = fn_capture {
208 test_matches.push(TestMatch {
209 name,
210 dedup_id: fn_c.node.id(),
211 fn_start_byte: fn_c.node.start_byte(),
212 fn_end_byte: fn_c.node.end_byte(),
213 fn_start_row: fn_c.node.start_position().row,
214 fn_end_row: fn_c.node.end_position().row,
215 decorated_start_byte: None,
216 decorated_end_byte: None,
217 decorated_start_row: None,
218 });
219 }
220 }
221 }
222
223 test_matches
224 .retain(|tm| tm.decorated_start_byte.is_some() || !decorated_fn_ids.contains(&tm.dedup_id));
225
226 test_matches.retain(|tm| {
228 let (check_byte, check_end) = tm.effective_byte_range();
229 !is_in_non_test_class(root, check_byte, check_end, source_bytes)
230 });
231
232 let effective_ranges: Vec<(usize, usize, usize)> = test_matches
235 .iter()
236 .map(|tm| {
237 let (start, end) = tm.effective_byte_range();
238 (tm.dedup_id, start, end)
239 })
240 .collect();
241 test_matches.retain(|tm| {
242 let (start, end) = tm.effective_byte_range();
243 !effective_ranges
244 .iter()
245 .any(|(other_id, other_start, other_end)| {
246 *other_id != tm.dedup_id
247 && *other_start <= start
248 && end <= *other_end
249 && (*other_start < start || end < *other_end)
250 })
251 });
252
253 let mut functions = Vec::new();
254 for tm in &test_matches {
255 let fn_node = match root.descendant_for_byte_range(tm.fn_start_byte, tm.fn_end_byte) {
256 Some(n) => n,
257 None => continue,
258 };
259
260 let line = tm.fn_start_row + 1;
261 let end_line = tm.fn_end_row + 1;
262 let line_count = end_line - line + 1;
263
264 let assertion_count = count_captures(assertion_query, "assertion", fn_node, source_bytes);
265
266 let mock_scope = match (tm.decorated_start_byte, tm.decorated_end_byte) {
267 (Some(start), Some(end)) => root
268 .descendant_for_byte_range(start, end)
269 .unwrap_or(fn_node),
270 _ => fn_node,
271 };
272 let mock_count = count_captures(mock_query, "mock", mock_scope, source_bytes);
273
274 let mock_classes = collect_mock_class_names(
275 mock_assign_query,
276 fn_node,
277 source_bytes,
278 extract_mock_class_name,
279 );
280
281 let how_not_what_count =
282 count_captures(how_not_what_query, "how_pattern", fn_node, source_bytes);
283
284 let private_in_assertion_count = count_captures_within_context(
285 assertion_query,
286 "assertion",
287 private_query,
288 "private_access",
289 fn_node,
290 source_bytes,
291 );
292
293 let fixture_count = count_function_params(fn_node, source_bytes);
295
296 let has_wait = has_any_match(wait_query, "wait", fn_node, source_bytes);
298
299 let has_skip_call = has_any_match(skip_query, "skip", fn_node, source_bytes);
301
302 let assertion_message_count =
304 count_assertion_messages_py(assertion_query, fn_node, source_bytes);
305
306 let duplicate_literal_count = count_duplicate_literals(
308 assertion_query,
309 fn_node,
310 source_bytes,
311 &["integer", "float", "string"],
312 );
313
314 let suppress_row = tm.decorated_start_row.unwrap_or(tm.fn_start_row);
315 let suppressed_rules = extract_suppression_from_previous_line(source, suppress_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 has_skip_call,
331 assertion_message_count,
332 duplicate_literal_count,
333 suppressed_rules,
334 },
335 });
336 }
337
338 functions
339}
340
341impl LanguageExtractor for PythonExtractor {
342 fn extract_test_functions(&self, source: &str, file_path: &str) -> Vec<TestFunction> {
343 let mut parser = Self::parser();
344 let tree = match parser.parse(source, None) {
345 Some(t) => t,
346 None => return Vec::new(),
347 };
348 extract_functions_from_tree(source, file_path, tree.root_node())
349 }
350
351 fn extract_file_analysis(&self, source: &str, file_path: &str) -> FileAnalysis {
352 let mut parser = Self::parser();
353 let tree = match parser.parse(source, None) {
354 Some(t) => t,
355 None => {
356 return FileAnalysis {
357 file: file_path.to_string(),
358 functions: Vec::new(),
359 has_pbt_import: false,
360 has_contract_import: false,
361 has_error_test: false,
362 has_relational_assertion: false,
363 parameterized_count: 0,
364 };
365 }
366 };
367
368 let root = tree.root_node();
369 let source_bytes = source.as_bytes();
370
371 let functions = extract_functions_from_tree(source, file_path, root);
372
373 let param_query = cached_query(&PARAMETERIZED_QUERY_CACHE, PARAMETERIZED_QUERY);
374 let parameterized_count = count_captures(param_query, "parameterized", root, source_bytes);
375
376 let pbt_query = cached_query(&IMPORT_PBT_QUERY_CACHE, IMPORT_PBT_QUERY);
377 let has_pbt_import = has_any_match(pbt_query, "pbt_import", root, source_bytes);
378
379 let contract_query = cached_query(&IMPORT_CONTRACT_QUERY_CACHE, IMPORT_CONTRACT_QUERY);
380 let has_contract_import =
381 has_any_match(contract_query, "contract_import", root, source_bytes);
382
383 let error_test_query = cached_query(&ERROR_TEST_QUERY_CACHE, ERROR_TEST_QUERY);
384 let has_error_test = has_any_match(error_test_query, "error_test", root, source_bytes);
385
386 let relational_query = cached_query(
387 &RELATIONAL_ASSERTION_QUERY_CACHE,
388 RELATIONAL_ASSERTION_QUERY,
389 );
390 let has_relational_assertion =
391 has_any_match(relational_query, "relational", root, source_bytes);
392
393 let mut file_analysis = FileAnalysis {
394 file: file_path.to_string(),
395 functions,
396 has_pbt_import,
397 has_contract_import,
398 has_error_test,
399 has_relational_assertion,
400 parameterized_count,
401 };
402
403 let helper_trace_query = cached_query(&HELPER_TRACE_QUERY_CACHE, HELPER_TRACE_QUERY);
407 let assertion_query_for_trace = cached_query(&ASSERTION_QUERY_CACHE, ASSERTION_QUERY);
408 apply_same_file_helper_tracing(
409 &mut file_analysis,
410 &tree,
411 source_bytes,
412 helper_trace_query,
413 helper_trace_query,
414 assertion_query_for_trace,
415 );
416
417 file_analysis
418 }
419}
420
421fn count_function_params(fn_node: Node, source: &[u8]) -> usize {
425 let mut node = fn_node;
427 while node.kind() != "function_definition" {
428 match node.parent() {
429 Some(p) => node = p,
430 None => return 0,
431 }
432 }
433 let params = match node.child_by_field_name("parameters") {
434 Some(p) => p,
435 None => return 0,
436 };
437 let count = params.named_child_count();
438 if count == 0 {
439 return 0;
440 }
441 if let Some(first) = params.named_child(0) {
443 if first
444 .utf8_text(source)
445 .map(|s| s == "self" || s == "cls")
446 .unwrap_or(false)
447 {
448 return count - 1;
449 }
450 }
451 count
452}
453
454fn count_assertion_messages_py(assertion_query: &Query, fn_node: Node, source: &[u8]) -> usize {
457 let assertion_idx = match assertion_query.capture_index_for_name("assertion") {
458 Some(idx) => idx,
459 None => return 0,
460 };
461 let mut cursor = QueryCursor::new();
462 let mut matches = cursor.matches(assertion_query, fn_node, source);
463 let mut count = 0;
464 while let Some(m) = matches.next() {
465 for cap in m.captures.iter().filter(|c| c.index == assertion_idx) {
466 let node = cap.node;
467 if node.kind() == "assert_statement" {
468 if node.named_child_count() > 1 {
471 count += 1;
472 }
473 } else if node.kind() == "call" {
474 if let Some(args) = node.child_by_field_name("arguments") {
477 let arg_count = args.named_child_count();
492 if arg_count > 0 {
493 if let Some(last_arg) = args.named_child(arg_count - 1) {
494 if last_arg.kind() == "string"
495 || last_arg.kind() == "concatenated_string"
496 {
497 count += 1;
498 }
499 }
500 }
501 }
502 }
503 }
504 }
505 count
506}
507
508fn extract_mock_class_name(var_name: &str) -> String {
509 if let Some(stripped) = var_name.strip_prefix("mock_") {
510 if !stripped.is_empty() {
511 return stripped.to_string();
512 }
513 }
514 var_name.to_string()
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 fn fixture(name: &str) -> String {
522 let path = format!(
523 "{}/tests/fixtures/python/{}",
524 env!("CARGO_MANIFEST_DIR").replace("/crates/lang-python", ""),
525 name
526 );
527 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"))
528 }
529
530 #[test]
533 fn extract_single_test_function() {
534 let source = fixture("t001_pass.py");
535 let extractor = PythonExtractor::new();
536 let funcs = extractor.extract_test_functions(&source, "t001_pass.py");
537 assert_eq!(funcs.len(), 1);
538 assert_eq!(funcs[0].name, "test_create_user");
539 assert_eq!(funcs[0].line, 1);
540 }
541
542 #[test]
543 fn extract_multiple_test_functions_excludes_helpers() {
544 let source = fixture("multiple_tests.py");
545 let extractor = PythonExtractor::new();
546 let funcs = extractor.extract_test_functions(&source, "multiple_tests.py");
547 assert_eq!(funcs.len(), 3);
548 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
549 assert_eq!(names, vec!["test_first", "test_second", "test_third"]);
550 assert!(!names.contains(&"helper"));
551 }
552
553 #[test]
554 fn line_count_calculation() {
555 let source = fixture("t001_pass.py");
556 let extractor = PythonExtractor::new();
557 let funcs = extractor.extract_test_functions(&source, "t001_pass.py");
558 assert_eq!(
559 funcs[0].analysis.line_count,
560 funcs[0].end_line - funcs[0].line + 1
561 );
562 }
563
564 #[test]
567 fn assertion_count_zero_for_violation() {
568 let source = fixture("t001_violation.py");
569 let extractor = PythonExtractor::new();
570 let funcs = extractor.extract_test_functions(&source, "t001_violation.py");
571 assert_eq!(funcs.len(), 1);
572 assert_eq!(funcs[0].analysis.assertion_count, 0);
573 }
574
575 #[test]
576 fn assertion_count_positive_for_pass() {
577 let source = fixture("t001_pass.py");
578 let extractor = PythonExtractor::new();
579 let funcs = extractor.extract_test_functions(&source, "t001_pass.py");
580 assert_eq!(funcs[0].analysis.assertion_count, 1);
581 }
582
583 #[test]
584 fn unittest_self_assert_counted() {
585 let source = fixture("unittest_style.py");
586 let extractor = PythonExtractor::new();
587 let funcs = extractor.extract_test_functions(&source, "unittest_style.py");
588 assert_eq!(funcs.len(), 1);
589 assert_eq!(funcs[0].analysis.assertion_count, 2);
590 }
591
592 #[test]
595 fn mock_count_for_violation() {
596 let source = fixture("t002_violation.py");
597 let extractor = PythonExtractor::new();
598 let funcs = extractor.extract_test_functions(&source, "t002_violation.py");
599 assert_eq!(funcs.len(), 1);
600 assert_eq!(funcs[0].analysis.mock_count, 6);
601 }
602
603 #[test]
604 fn mock_count_for_pass() {
605 let source = fixture("t002_pass.py");
606 let extractor = PythonExtractor::new();
607 let funcs = extractor.extract_test_functions(&source, "t002_pass.py");
608 assert_eq!(funcs.len(), 1);
609 assert_eq!(funcs[0].analysis.mock_count, 1);
610 assert_eq!(funcs[0].analysis.mock_classes, vec!["db"]);
611 }
612
613 #[test]
614 fn mock_class_name_extraction() {
615 assert_eq!(extract_mock_class_name("mock_db"), "db");
616 assert_eq!(
617 extract_mock_class_name("mock_payment_service"),
618 "payment_service"
619 );
620 assert_eq!(extract_mock_class_name("my_mock"), "my_mock");
621 }
622
623 #[test]
626 fn giant_test_line_count() {
627 let source = fixture("t003_violation.py");
628 let extractor = PythonExtractor::new();
629 let funcs = extractor.extract_test_functions(&source, "t003_violation.py");
630 assert_eq!(funcs.len(), 1);
631 assert!(funcs[0].analysis.line_count > 50);
632 }
633
634 #[test]
635 fn short_test_line_count() {
636 let source = fixture("t003_pass.py");
637 let extractor = PythonExtractor::new();
638 let funcs = extractor.extract_test_functions(&source, "t003_pass.py");
639 assert_eq!(funcs.len(), 1);
640 assert!(funcs[0].analysis.line_count <= 50);
641 }
642
643 #[test]
646 fn suppressed_test_has_suppressed_rules() {
647 let source = fixture("suppressed.py");
648 let extractor = PythonExtractor::new();
649 let funcs = extractor.extract_test_functions(&source, "suppressed.py");
650 assert_eq!(funcs.len(), 1);
651 assert_eq!(funcs[0].analysis.mock_count, 6);
652 assert!(funcs[0]
653 .analysis
654 .suppressed_rules
655 .iter()
656 .any(|r| r.0 == "T002"));
657 }
658
659 #[test]
660 fn non_suppressed_test_has_empty_suppressed_rules() {
661 let source = fixture("t002_violation.py");
662 let extractor = PythonExtractor::new();
663 let funcs = extractor.extract_test_functions(&source, "t002_violation.py");
664 assert!(funcs[0].analysis.suppressed_rules.is_empty());
665 }
666
667 #[test]
670 fn parse_python_source() {
671 let source = "def test_example():\n pass\n";
672 let mut parser = PythonExtractor::parser();
673 let tree = parser.parse(source, None).unwrap();
674 assert_eq!(tree.root_node().kind(), "module");
675 }
676
677 #[test]
678 fn python_extractor_implements_language_extractor() {
679 let extractor = PythonExtractor::new();
680 let _: &dyn exspec_core::extractor::LanguageExtractor = &extractor;
681 }
682
683 #[test]
686 fn file_analysis_detects_parameterized() {
687 let source = fixture("t004_pass.py");
688 let extractor = PythonExtractor::new();
689 let fa = extractor.extract_file_analysis(&source, "t004_pass.py");
690 assert!(
691 fa.parameterized_count >= 1,
692 "expected parameterized_count >= 1, got {}",
693 fa.parameterized_count
694 );
695 }
696
697 #[test]
698 fn file_analysis_no_parameterized() {
699 let source = fixture("t004_violation.py");
700 let extractor = PythonExtractor::new();
701 let fa = extractor.extract_file_analysis(&source, "t004_violation.py");
702 assert_eq!(fa.parameterized_count, 0);
703 }
704
705 #[test]
708 fn file_analysis_detects_pbt_import() {
709 let source = fixture("t005_pass.py");
710 let extractor = PythonExtractor::new();
711 let fa = extractor.extract_file_analysis(&source, "t005_pass.py");
712 assert!(fa.has_pbt_import);
713 }
714
715 #[test]
716 fn file_analysis_no_pbt_import() {
717 let source = fixture("t005_violation.py");
718 let extractor = PythonExtractor::new();
719 let fa = extractor.extract_file_analysis(&source, "t005_violation.py");
720 assert!(!fa.has_pbt_import);
721 }
722
723 #[test]
726 fn file_analysis_detects_contract_import() {
727 let source = fixture("t008_pass.py");
728 let extractor = PythonExtractor::new();
729 let fa = extractor.extract_file_analysis(&source, "t008_pass.py");
730 assert!(fa.has_contract_import);
731 }
732
733 #[test]
734 fn file_analysis_no_contract_import() {
735 let source = fixture("t008_violation.py");
736 let extractor = PythonExtractor::new();
737 let fa = extractor.extract_file_analysis(&source, "t008_violation.py");
738 assert!(!fa.has_contract_import);
739 }
740
741 #[test]
744 fn class_method_in_non_test_class_excluded() {
745 let source = fixture("test_class_false_positive.py");
746 let extractor = PythonExtractor::new();
747 let funcs = extractor.extract_test_functions(&source, "test_class_false_positive.py");
748 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
749 assert!(
750 !names.contains(&"test_connection"),
751 "UserService.test_connection should be excluded: {names:?}"
752 );
753 assert!(
754 !names.contains(&"test_health"),
755 "UserService.test_health should be excluded: {names:?}"
756 );
757 }
758
759 #[test]
760 fn class_method_in_test_class_included() {
761 let source = fixture("test_class_false_positive.py");
762 let extractor = PythonExtractor::new();
763 let funcs = extractor.extract_test_functions(&source, "test_class_false_positive.py");
764 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
765 assert!(
766 names.contains(&"test_create"),
767 "TestUser.test_create should be included: {names:?}"
768 );
769 assert!(
770 names.contains(&"test_delete"),
771 "TestUser.test_delete should be included: {names:?}"
772 );
773 }
774
775 #[test]
776 fn standalone_test_function_included() {
777 let source = fixture("test_class_false_positive.py");
778 let extractor = PythonExtractor::new();
779 let funcs = extractor.extract_test_functions(&source, "test_class_false_positive.py");
780 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
781 assert!(
782 names.contains(&"test_standalone"),
783 "module-level test_standalone should be included: {names:?}"
784 );
785 }
786
787 #[test]
788 fn decorated_class_method_in_test_class_included() {
789 let source = fixture("test_class_decorated.py");
790 let extractor = PythonExtractor::new();
791 let funcs = extractor.extract_test_functions(&source, "test_class_decorated.py");
792 assert_eq!(funcs.len(), 1);
793 assert_eq!(funcs[0].name, "test_create");
794 }
795
796 #[test]
799 fn nested_class_test_outer_helper_included() {
800 let source = fixture("nested_class.py");
801 let extractor = PythonExtractor::new();
802 let funcs = extractor.extract_test_functions(&source, "nested_class.py");
803 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
804 assert!(
805 names.contains(&"test_nested_in_test_outer"),
806 "TestOuter > Helper > test_foo should be INCLUDED: {names:?}"
807 );
808 }
809
810 #[test]
811 fn nested_class_non_test_outer_excluded() {
812 let source = fixture("nested_class.py");
813 let extractor = PythonExtractor::new();
814 let funcs = extractor.extract_test_functions(&source, "nested_class.py");
815 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
816 assert!(
817 !names.contains(&"test_nested_in_non_test_outer"),
818 "UserService > TestInner > test_foo should be EXCLUDED: {names:?}"
819 );
820 }
821
822 #[test]
823 fn nested_class_both_non_test_excluded() {
824 let source = fixture("nested_class.py");
825 let extractor = PythonExtractor::new();
826 let funcs = extractor.extract_test_functions(&source, "nested_class.py");
827 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
828 assert!(
829 !names.contains(&"test_connection"),
830 "ServiceA > ServiceB > test_connection should be EXCLUDED: {names:?}"
831 );
832 }
833
834 #[test]
837 fn file_analysis_preserves_test_functions() {
838 let source = fixture("t001_pass.py");
839 let extractor = PythonExtractor::new();
840 let fa = extractor.extract_file_analysis(&source, "t001_pass.py");
841 assert_eq!(fa.functions.len(), 1);
842 assert_eq!(fa.functions[0].name, "test_create_user");
843 }
844
845 #[test]
848 fn how_not_what_count_for_violation() {
849 let source = fixture("t101_violation.py");
850 let extractor = PythonExtractor::new();
851 let funcs = extractor.extract_test_functions(&source, "t101_violation.py");
852 assert_eq!(funcs.len(), 2);
853 assert!(
854 funcs[0].analysis.how_not_what_count > 0,
855 "expected how_not_what_count > 0 for first test, got {}",
856 funcs[0].analysis.how_not_what_count
857 );
858 assert!(
859 funcs[1].analysis.how_not_what_count > 0,
860 "expected how_not_what_count > 0 for second test, got {}",
861 funcs[1].analysis.how_not_what_count
862 );
863 }
864
865 #[test]
866 fn how_not_what_count_zero_for_pass() {
867 let source = fixture("t101_pass.py");
868 let extractor = PythonExtractor::new();
869 let funcs = extractor.extract_test_functions(&source, "t101_pass.py");
870 assert_eq!(funcs.len(), 1);
871 assert_eq!(funcs[0].analysis.how_not_what_count, 0);
872 }
873
874 #[test]
875 fn how_not_what_coexists_with_assertions() {
876 let source = fixture("t101_violation.py");
878 let extractor = PythonExtractor::new();
879 let funcs = extractor.extract_test_functions(&source, "t101_violation.py");
880 assert!(
881 funcs[0].analysis.assertion_count > 0,
882 "should also count as assertions"
883 );
884 assert!(
885 funcs[0].analysis.how_not_what_count > 0,
886 "should count as how-not-what"
887 );
888 }
889
890 fn make_query(scm: &str) -> Query {
893 Query::new(&python_language(), scm).unwrap()
894 }
895
896 #[test]
897 fn query_capture_names_test_function() {
898 let q = make_query(include_str!("../queries/test_function.scm"));
899 assert!(
900 q.capture_index_for_name("name").is_some(),
901 "test_function.scm must define @name capture"
902 );
903 assert!(
904 q.capture_index_for_name("function").is_some(),
905 "test_function.scm must define @function capture"
906 );
907 assert!(
908 q.capture_index_for_name("decorated").is_some(),
909 "test_function.scm must define @decorated capture"
910 );
911 }
912
913 #[test]
914 fn query_capture_names_assertion() {
915 let q = make_query(include_str!("../queries/assertion.scm"));
916 assert!(
917 q.capture_index_for_name("assertion").is_some(),
918 "assertion.scm must define @assertion capture"
919 );
920 }
921
922 #[test]
923 fn query_capture_names_mock_usage() {
924 let q = make_query(include_str!("../queries/mock_usage.scm"));
925 assert!(
926 q.capture_index_for_name("mock").is_some(),
927 "mock_usage.scm must define @mock capture"
928 );
929 }
930
931 #[test]
932 fn query_capture_names_mock_assignment() {
933 let q = make_query(include_str!("../queries/mock_assignment.scm"));
934 assert!(
935 q.capture_index_for_name("var_name").is_some(),
936 "mock_assignment.scm must define @var_name (required by collect_mock_class_names .expect())"
937 );
938 }
939
940 #[test]
941 fn query_capture_names_parameterized() {
942 let q = make_query(include_str!("../queries/parameterized.scm"));
943 assert!(
944 q.capture_index_for_name("parameterized").is_some(),
945 "parameterized.scm must define @parameterized capture"
946 );
947 }
948
949 #[test]
950 fn query_capture_names_import_pbt() {
951 let q = make_query(include_str!("../queries/import_pbt.scm"));
952 assert!(
953 q.capture_index_for_name("pbt_import").is_some(),
954 "import_pbt.scm must define @pbt_import capture"
955 );
956 }
957
958 #[test]
959 fn query_capture_names_import_contract() {
960 let q = make_query(include_str!("../queries/import_contract.scm"));
961 assert!(
962 q.capture_index_for_name("contract_import").is_some(),
963 "import_contract.scm must define @contract_import capture"
964 );
965 }
966
967 #[test]
968 fn query_capture_names_how_not_what() {
969 let q = make_query(include_str!("../queries/how_not_what.scm"));
970 assert!(
971 q.capture_index_for_name("how_pattern").is_some(),
972 "how_not_what.scm must define @how_pattern capture"
973 );
974 }
975
976 #[test]
979 fn fixture_count_for_violation() {
980 let source = fixture("t102_violation.py");
981 let extractor = PythonExtractor::new();
982 let funcs = extractor.extract_test_functions(&source, "t102_violation.py");
983 assert_eq!(funcs.len(), 1);
984 assert_eq!(
985 funcs[0].analysis.fixture_count, 7,
986 "expected 7 parameters as fixture_count"
987 );
988 }
989
990 #[test]
991 fn fixture_count_for_pass() {
992 let source = fixture("t102_pass.py");
993 let extractor = PythonExtractor::new();
994 let funcs = extractor.extract_test_functions(&source, "t102_pass.py");
995 assert_eq!(funcs.len(), 1);
996 assert_eq!(
997 funcs[0].analysis.fixture_count, 2,
998 "expected 2 parameters as fixture_count"
999 );
1000 }
1001
1002 #[test]
1003 fn fixture_count_self_excluded() {
1004 let source = fixture("t102_self_excluded.py");
1005 let extractor = PythonExtractor::new();
1006 let funcs = extractor.extract_test_functions(&source, "t102_self_excluded.py");
1007 assert_eq!(funcs.len(), 1);
1008 assert_eq!(
1009 funcs[0].analysis.fixture_count, 2,
1010 "self should be excluded from fixture_count"
1011 );
1012 }
1013
1014 #[test]
1015 fn fixture_count_cls_excluded() {
1016 let source = fixture("t102_cls_excluded.py");
1017 let extractor = PythonExtractor::new();
1018 let funcs = extractor.extract_test_functions(&source, "t102_cls_excluded.py");
1019 assert_eq!(funcs.len(), 1);
1020 assert_eq!(
1021 funcs[0].analysis.fixture_count, 2,
1022 "cls should be excluded from fixture_count"
1023 );
1024 }
1025
1026 #[test]
1029 fn private_in_assertion_detected() {
1030 let source = fixture("t101_private_violation.py");
1031 let extractor = PythonExtractor::new();
1032 let funcs = extractor.extract_test_functions(&source, "t101_private_violation.py");
1033 let func = funcs
1035 .iter()
1036 .find(|f| f.name == "test_checks_internal_count")
1037 .unwrap();
1038 assert!(
1039 func.analysis.how_not_what_count >= 2,
1040 "expected >= 2 private access in assertions, got {}",
1041 func.analysis.how_not_what_count
1042 );
1043 }
1044
1045 #[test]
1046 fn private_outside_assertion_not_counted() {
1047 let source = fixture("t101_private_violation.py");
1048 let extractor = PythonExtractor::new();
1049 let funcs = extractor.extract_test_functions(&source, "t101_private_violation.py");
1050 let func = funcs
1052 .iter()
1053 .find(|f| f.name == "test_private_outside_assertion")
1054 .unwrap();
1055 assert_eq!(
1056 func.analysis.how_not_what_count, 0,
1057 "private access outside assertion should not count"
1058 );
1059 }
1060
1061 #[test]
1062 fn dunder_not_counted() {
1063 let source = fixture("t101_private_violation.py");
1064 let extractor = PythonExtractor::new();
1065 let funcs = extractor.extract_test_functions(&source, "t101_private_violation.py");
1066 let func = funcs
1068 .iter()
1069 .find(|f| f.name == "test_dunder_not_private")
1070 .unwrap();
1071 assert_eq!(
1072 func.analysis.how_not_what_count, 0,
1073 "__dunder__ should not be counted as private access"
1074 );
1075 }
1076
1077 #[test]
1078 fn private_adds_to_how_not_what() {
1079 let source = fixture("t101_private_violation.py");
1080 let extractor = PythonExtractor::new();
1081 let funcs = extractor.extract_test_functions(&source, "t101_private_violation.py");
1082 let func = funcs
1084 .iter()
1085 .find(|f| f.name == "test_mixed_private_and_mock")
1086 .unwrap();
1087 assert!(
1088 func.analysis.how_not_what_count >= 2,
1089 "expected mock (1) + private (1) = >= 2, got {}",
1090 func.analysis.how_not_what_count
1091 );
1092 }
1093
1094 #[test]
1095 fn query_capture_names_private_in_assertion() {
1096 let q = make_query(include_str!("../queries/private_in_assertion.scm"));
1097 assert!(
1098 q.capture_index_for_name("private_access").is_some(),
1099 "private_in_assertion.scm must define @private_access capture"
1100 );
1101 }
1102
1103 #[test]
1106 fn error_test_pytest_raises() {
1107 let source = fixture("t103_pass_pytest_raises.py");
1108 let extractor = PythonExtractor::new();
1109 let fa = extractor.extract_file_analysis(&source, "t103_pass_pytest_raises.py");
1110 assert!(fa.has_error_test, "pytest.raises should set has_error_test");
1111 }
1112
1113 #[test]
1114 fn error_test_assert_raises() {
1115 let source = fixture("t103_pass_assertRaises.py");
1116 let extractor = PythonExtractor::new();
1117 let fa = extractor.extract_file_analysis(&source, "t103_pass_assertRaises.py");
1118 assert!(
1119 fa.has_error_test,
1120 "self.assertRaises should set has_error_test"
1121 );
1122 }
1123
1124 #[test]
1125 fn error_test_assert_raises_regex() {
1126 let source = fixture("t103_pass_assertRaisesRegex.py");
1127 let extractor = PythonExtractor::new();
1128 let fa = extractor.extract_file_analysis(&source, "t103_pass_assertRaisesRegex.py");
1129 assert!(
1130 fa.has_error_test,
1131 "self.assertRaisesRegex should set has_error_test"
1132 );
1133 }
1134
1135 #[test]
1136 fn error_test_assert_warns() {
1137 let source = fixture("t103_pass_assertWarns.py");
1138 let extractor = PythonExtractor::new();
1139 let fa = extractor.extract_file_analysis(&source, "t103_pass_assertWarns.py");
1140 assert!(
1141 fa.has_error_test,
1142 "self.assertWarns should set has_error_test"
1143 );
1144 }
1145
1146 #[test]
1147 fn error_test_assert_warns_regex() {
1148 let source = fixture("t103_pass_assertWarnsRegex.py");
1149 let extractor = PythonExtractor::new();
1150 let fa = extractor.extract_file_analysis(&source, "t103_pass_assertWarnsRegex.py");
1151 assert!(
1152 fa.has_error_test,
1153 "self.assertWarnsRegex should set has_error_test"
1154 );
1155 }
1156
1157 #[test]
1158 fn error_test_false_positive_non_self_receiver() {
1159 let source = fixture("t103_false_positive_non_self_receiver.py");
1160 let extractor = PythonExtractor::new();
1161 let fa =
1162 extractor.extract_file_analysis(&source, "t103_false_positive_non_self_receiver.py");
1163 assert!(
1164 !fa.has_error_test,
1165 "mock_obj.assertRaises() should NOT set has_error_test"
1166 );
1167 }
1168
1169 #[test]
1170 fn error_test_no_patterns() {
1171 let source = fixture("t103_violation.py");
1172 let extractor = PythonExtractor::new();
1173 let fa = extractor.extract_file_analysis(&source, "t103_violation.py");
1174 assert!(
1175 !fa.has_error_test,
1176 "no error patterns should set has_error_test=false"
1177 );
1178 }
1179
1180 #[test]
1181 fn query_capture_names_error_test() {
1182 let q = make_query(include_str!("../queries/error_test.scm"));
1183 assert!(
1184 q.capture_index_for_name("error_test").is_some(),
1185 "error_test.scm must define @error_test capture"
1186 );
1187 }
1188
1189 #[test]
1192 fn relational_assertion_violation() {
1193 let source = fixture("t105_violation.py");
1194 let extractor = PythonExtractor::new();
1195 let fa = extractor.extract_file_analysis(&source, "t105_violation.py");
1196 assert!(
1197 !fa.has_relational_assertion,
1198 "all equality file should not have relational"
1199 );
1200 }
1201
1202 #[test]
1203 fn relational_assertion_pass_greater_than() {
1204 let source = fixture("t105_pass_relational.py");
1205 let extractor = PythonExtractor::new();
1206 let fa = extractor.extract_file_analysis(&source, "t105_pass_relational.py");
1207 assert!(
1208 fa.has_relational_assertion,
1209 "assert x > 0 should set has_relational_assertion"
1210 );
1211 }
1212
1213 #[test]
1214 fn relational_assertion_pass_contains() {
1215 let source = fixture("t105_pass_contains.py");
1216 let extractor = PythonExtractor::new();
1217 let fa = extractor.extract_file_analysis(&source, "t105_pass_contains.py");
1218 assert!(
1219 fa.has_relational_assertion,
1220 "assert x in y should set has_relational_assertion"
1221 );
1222 }
1223
1224 #[test]
1225 fn relational_assertion_pass_unittest() {
1226 let source = fixture("t105_pass_unittest.py");
1227 let extractor = PythonExtractor::new();
1228 let fa = extractor.extract_file_analysis(&source, "t105_pass_unittest.py");
1229 assert!(
1230 fa.has_relational_assertion,
1231 "self.assertGreater should set has_relational_assertion"
1232 );
1233 }
1234
1235 #[test]
1236 fn query_capture_names_relational_assertion() {
1237 let q = make_query(include_str!("../queries/relational_assertion.scm"));
1238 assert!(
1239 q.capture_index_for_name("relational").is_some(),
1240 "relational_assertion.scm must define @relational capture"
1241 );
1242 }
1243
1244 #[test]
1247 fn wait_and_see_violation_sleep() {
1248 let source = fixture("t108_violation_sleep.py");
1249 let extractor = PythonExtractor::new();
1250 let funcs = extractor.extract_test_functions(&source, "t108_violation_sleep.py");
1251 assert!(!funcs.is_empty());
1252 for func in &funcs {
1253 assert!(
1254 func.analysis.has_wait,
1255 "test '{}' should have has_wait=true",
1256 func.name
1257 );
1258 }
1259 }
1260
1261 #[test]
1262 fn wait_and_see_pass_no_sleep() {
1263 let source = fixture("t108_pass_no_sleep.py");
1264 let extractor = PythonExtractor::new();
1265 let funcs = extractor.extract_test_functions(&source, "t108_pass_no_sleep.py");
1266 assert_eq!(funcs.len(), 1);
1267 assert!(
1268 !funcs[0].analysis.has_wait,
1269 "test without sleep should have has_wait=false"
1270 );
1271 }
1272
1273 #[test]
1274 fn query_capture_names_wait_and_see() {
1275 let q = make_query(include_str!("../queries/wait_and_see.scm"));
1276 assert!(
1277 q.capture_index_for_name("wait").is_some(),
1278 "wait_and_see.scm must define @wait capture"
1279 );
1280 }
1281
1282 #[test]
1285 fn t107_violation_no_messages() {
1286 let source = fixture("t107_violation.py");
1287 let extractor = PythonExtractor::new();
1288 let funcs = extractor.extract_test_functions(&source, "t107_violation.py");
1289 assert_eq!(funcs.len(), 1);
1290 assert!(
1291 funcs[0].analysis.assertion_count >= 2,
1292 "should have multiple assertions"
1293 );
1294 assert_eq!(
1295 funcs[0].analysis.assertion_message_count, 0,
1296 "no assertion should have a message"
1297 );
1298 }
1299
1300 #[test]
1301 fn t107_pass_with_messages() {
1302 let source = fixture("t107_pass_with_messages.py");
1303 let extractor = PythonExtractor::new();
1304 let funcs = extractor.extract_test_functions(&source, "t107_pass_with_messages.py");
1305 assert_eq!(funcs.len(), 1);
1306 assert!(
1307 funcs[0].analysis.assertion_message_count >= 1,
1308 "assertions with messages should be counted"
1309 );
1310 }
1311
1312 #[test]
1313 fn t107_pass_single_assert() {
1314 let source = fixture("t107_pass_single_assert.py");
1315 let extractor = PythonExtractor::new();
1316 let funcs = extractor.extract_test_functions(&source, "t107_pass_single_assert.py");
1317 assert_eq!(funcs.len(), 1);
1318 assert_eq!(
1319 funcs[0].analysis.assertion_count, 1,
1320 "single assertion does not trigger T107"
1321 );
1322 }
1323
1324 #[test]
1327 fn t109_violation_names_detected() {
1328 let source = fixture("t109_violation.py");
1329 let extractor = PythonExtractor::new();
1330 let funcs = extractor.extract_test_functions(&source, "t109_violation.py");
1331 assert!(!funcs.is_empty());
1332 for func in &funcs {
1333 assert!(
1334 exspec_core::rules::is_undescriptive_test_name(&func.name),
1335 "test '{}' should be undescriptive",
1336 func.name
1337 );
1338 }
1339 }
1340
1341 #[test]
1342 fn t109_pass_descriptive_names() {
1343 let source = fixture("t109_pass.py");
1344 let extractor = PythonExtractor::new();
1345 let funcs = extractor.extract_test_functions(&source, "t109_pass.py");
1346 assert!(!funcs.is_empty());
1347 for func in &funcs {
1348 assert!(
1349 !exspec_core::rules::is_undescriptive_test_name(&func.name),
1350 "test '{}' should be descriptive",
1351 func.name
1352 );
1353 }
1354 }
1355
1356 #[test]
1359 fn t106_violation_duplicate_literal() {
1360 let source = fixture("t106_violation.py");
1361 let extractor = PythonExtractor::new();
1362 let funcs = extractor.extract_test_functions(&source, "t106_violation.py");
1363 assert_eq!(funcs.len(), 1);
1364 assert!(
1365 funcs[0].analysis.duplicate_literal_count >= 3,
1366 "42 appears 4 times, should be >= 3: got {}",
1367 funcs[0].analysis.duplicate_literal_count
1368 );
1369 }
1370
1371 #[test]
1372 fn t106_pass_no_duplicates() {
1373 let source = fixture("t106_pass_no_duplicates.py");
1374 let extractor = PythonExtractor::new();
1375 let funcs = extractor.extract_test_functions(&source, "t106_pass_no_duplicates.py");
1376 assert_eq!(funcs.len(), 1);
1377 assert!(
1378 funcs[0].analysis.duplicate_literal_count < 3,
1379 "each literal appears once: got {}",
1380 funcs[0].analysis.duplicate_literal_count
1381 );
1382 }
1383
1384 #[test]
1387 fn t001_pytest_raises_counts_as_assertion() {
1388 let source = fixture("t001_pytest_raises.py");
1390 let extractor = PythonExtractor::new();
1391 let funcs = extractor.extract_test_functions(&source, "t001_pytest_raises.py");
1392 assert_eq!(funcs.len(), 1);
1393 assert!(
1394 funcs[0].analysis.assertion_count >= 1,
1395 "pytest.raises() should count as assertion, got {}",
1396 funcs[0].analysis.assertion_count
1397 );
1398 }
1399
1400 #[test]
1401 fn t001_pytest_raises_with_match_counts_as_assertion() {
1402 let source = fixture("t001_pytest_raises_with_match.py");
1404 let extractor = PythonExtractor::new();
1405 let funcs = extractor.extract_test_functions(&source, "t001_pytest_raises_with_match.py");
1406 assert_eq!(funcs.len(), 1);
1407 assert!(
1408 funcs[0].analysis.assertion_count >= 1,
1409 "pytest.raises() with match should count as assertion, got {}",
1410 funcs[0].analysis.assertion_count
1411 );
1412 }
1413
1414 #[test]
1417 fn t001_pytest_warns_counts_as_assertion() {
1418 let source = fixture("t001_pytest_warns.py");
1420 let extractor = PythonExtractor::new();
1421 let funcs = extractor.extract_test_functions(&source, "t001_pytest_warns.py");
1422 assert_eq!(funcs.len(), 1);
1423 assert!(
1424 funcs[0].analysis.assertion_count >= 1,
1425 "pytest.warns() should count as assertion, got {}",
1426 funcs[0].analysis.assertion_count
1427 );
1428 }
1429
1430 #[test]
1431 fn t001_pytest_warns_with_match_counts_as_assertion() {
1432 let source = fixture("t001_pytest_warns_with_match.py");
1434 let extractor = PythonExtractor::new();
1435 let funcs = extractor.extract_test_functions(&source, "t001_pytest_warns_with_match.py");
1436 assert_eq!(funcs.len(), 1);
1437 assert!(
1438 funcs[0].analysis.assertion_count >= 1,
1439 "pytest.warns() with match should count as assertion, got {}",
1440 funcs[0].analysis.assertion_count
1441 );
1442 }
1443
1444 #[test]
1445 fn t001_self_assert_raises_already_covered() {
1446 let source = "import unittest\n\nclass TestUser(unittest.TestCase):\n def test_invalid(self):\n self.assertRaises(ValueError, create_user, '')\n";
1448 let extractor = PythonExtractor::new();
1449 let funcs = extractor.extract_test_functions(&source, "test_assert_raises.py");
1450 assert_eq!(funcs.len(), 1);
1451 assert!(
1452 funcs[0].analysis.assertion_count >= 1,
1453 "self.assertRaises() should already be covered, got {}",
1454 funcs[0].analysis.assertion_count
1455 );
1456 }
1457
1458 #[test]
1461 fn t001_pytest_fail_counts_as_assertion() {
1462 let source = fixture("t001_pytest_fail.py");
1464 let extractor = PythonExtractor::new();
1465 let funcs = extractor.extract_test_functions(&source, "t001_pytest_fail.py");
1466 assert_eq!(funcs.len(), 2);
1467 assert!(
1468 funcs[0].analysis.assertion_count >= 1,
1469 "pytest.fail() should count as assertion, got {}",
1470 funcs[0].analysis.assertion_count
1471 );
1472 }
1473
1474 #[test]
1475 fn t001_no_assertions_still_fires() {
1476 let source = fixture("t001_pytest_fail.py");
1478 let extractor = PythonExtractor::new();
1479 let funcs = extractor.extract_test_functions(&source, "t001_pytest_fail.py");
1480 assert_eq!(funcs.len(), 2);
1481 assert_eq!(
1482 funcs[1].analysis.assertion_count, 0,
1483 "test_no_assertions should have 0 assertions, got {}",
1484 funcs[1].analysis.assertion_count
1485 );
1486 }
1487
1488 #[test]
1491 fn t001_mock_assert_called_once_counts_as_assertion() {
1492 let source = fixture("t001_pass_mock_assert.py");
1494 let extractor = PythonExtractor::new();
1495 let funcs = extractor.extract_test_functions(&source, "t001_pass_mock_assert.py");
1496 assert!(funcs.len() >= 1);
1497 assert!(
1498 funcs[0].analysis.assertion_count >= 1,
1499 "mock.assert_called_once() should count as assertion, got {}",
1500 funcs[0].analysis.assertion_count
1501 );
1502 }
1503
1504 #[test]
1505 fn t001_mock_assert_called_once_with_counts_as_assertion() {
1506 let source = fixture("t001_pass_mock_assert.py");
1508 let extractor = PythonExtractor::new();
1509 let funcs = extractor.extract_test_functions(&source, "t001_pass_mock_assert.py");
1510 assert!(funcs.len() >= 2);
1511 assert!(
1512 funcs[1].analysis.assertion_count >= 1,
1513 "mock.assert_called_once_with() should count as assertion, got {}",
1514 funcs[1].analysis.assertion_count
1515 );
1516 }
1517
1518 #[test]
1519 fn t001_mock_assert_not_called_counts_as_assertion() {
1520 let source = fixture("t001_pass_mock_assert.py");
1522 let extractor = PythonExtractor::new();
1523 let funcs = extractor.extract_test_functions(&source, "t001_pass_mock_assert.py");
1524 assert!(funcs.len() >= 3);
1525 assert!(
1526 funcs[2].analysis.assertion_count >= 1,
1527 "mock.assert_not_called() should count as assertion, got {}",
1528 funcs[2].analysis.assertion_count
1529 );
1530 }
1531
1532 #[test]
1533 fn t001_mock_assert_has_calls_counts_as_assertion() {
1534 let source = fixture("t001_pass_mock_assert.py");
1536 let extractor = PythonExtractor::new();
1537 let funcs = extractor.extract_test_functions(&source, "t001_pass_mock_assert.py");
1538 assert!(funcs.len() >= 4);
1539 assert!(
1540 funcs[3].analysis.assertion_count >= 1,
1541 "mock.assert_has_calls() should count as assertion, got {}",
1542 funcs[3].analysis.assertion_count
1543 );
1544 }
1545
1546 #[test]
1547 fn t001_chained_mock_assert_counts_as_assertion() {
1548 let source = fixture("t001_pass_mock_assert.py");
1550 let extractor = PythonExtractor::new();
1551 let funcs = extractor.extract_test_functions(&source, "t001_pass_mock_assert.py");
1552 assert!(funcs.len() >= 5);
1553 assert!(
1554 funcs[4].analysis.assertion_count >= 1,
1555 "chained mock.assert_called_once() should count as assertion, got {}",
1556 funcs[4].analysis.assertion_count
1557 );
1558 }
1559
1560 #[test]
1563 fn t001_assert_no_underscore_counts_as_assertion() {
1564 let source = fixture("t001_pass_assert_no_underscore.py");
1566 let extractor = PythonExtractor::new();
1567 let funcs = extractor.extract_test_functions(&source, "t001_pass_assert_no_underscore.py");
1568 assert!(funcs.len() >= 1);
1569 assert!(
1570 funcs[0].analysis.assertion_count >= 1,
1571 "reprec.assertoutcome() should count as assertion, got {}",
1572 funcs[0].analysis.assertion_count
1573 );
1574 }
1575
1576 #[test]
1577 fn t001_assert_status_no_underscore_counts_as_assertion() {
1578 let source = fixture("t001_pass_assert_no_underscore.py");
1580 let extractor = PythonExtractor::new();
1581 let funcs = extractor.extract_test_functions(&source, "t001_pass_assert_no_underscore.py");
1582 assert!(funcs.len() >= 2);
1583 assert!(
1584 funcs[1].analysis.assertion_count >= 1,
1585 "response.assertStatus() should count as assertion, got {}",
1586 funcs[1].analysis.assertion_count
1587 );
1588 }
1589
1590 #[test]
1591 fn t001_self_assert_equal_no_double_count_regression() {
1592 let source = "import unittest\n\nclass TestMath(unittest.TestCase):\n def test_add(self):\n self.assertEqual(1 + 1, 2)\n";
1594 let extractor = PythonExtractor::new();
1595 let funcs = extractor.extract_test_functions(&source, "test_math.py");
1596 assert_eq!(funcs.len(), 1);
1597 assert_eq!(
1598 funcs[0].analysis.assertion_count, 1,
1599 "self.assertEqual should count as exactly 1 assertion, got {}",
1600 funcs[0].analysis.assertion_count
1601 );
1602 }
1603
1604 #[test]
1605 fn t106_pass_trivial_literals() {
1606 let source = fixture("t106_pass_trivial_literals.py");
1607 let extractor = PythonExtractor::new();
1608 let funcs = extractor.extract_test_functions(&source, "t106_pass_trivial_literals.py");
1609 assert_eq!(funcs.len(), 1);
1610 assert!(
1611 funcs[0].analysis.duplicate_literal_count < 3,
1612 "0 is trivial, should not count: got {}",
1613 funcs[0].analysis.duplicate_literal_count
1614 );
1615 }
1616
1617 #[test]
1619 fn t001_custom_helper_with_config_no_violation() {
1620 use exspec_core::query_utils::apply_custom_assertion_fallback;
1621 use exspec_core::rules::{evaluate_rules, Config};
1622
1623 let source = fixture("t001_custom_helper.py");
1624 let extractor = PythonExtractor::new();
1625 let mut analysis = extractor.extract_file_analysis(&source, "t001_custom_helper.py");
1626 let patterns = vec!["util.assertEqual(".to_string()];
1627 apply_custom_assertion_fallback(&mut analysis, &source, &patterns);
1628
1629 let config = Config::default();
1630 let diags = evaluate_rules(&analysis.functions, &config);
1631 let t001_diags: Vec<_> = diags.iter().filter(|d| d.rule.0 == "T001").collect();
1632 assert_eq!(
1636 t001_diags.len(),
1637 1,
1638 "only test_no_assertion_at_all should trigger T001"
1639 );
1640 assert!(
1641 t001_diags[0].message.contains("assertion-free"),
1642 "should be T001 assertion-free"
1643 );
1644 }
1645
1646 #[test]
1648 fn t001_custom_helper_without_config_fires() {
1649 use exspec_core::rules::{evaluate_rules, Config};
1650
1651 let source = fixture("t001_custom_helper.py");
1652 let extractor = PythonExtractor::new();
1653 let analysis = extractor.extract_file_analysis(&source, "t001_custom_helper.py");
1654
1655 let config = Config::default();
1656 let diags = evaluate_rules(&analysis.functions, &config);
1657 let t001_diags: Vec<_> = diags.iter().filter(|d| d.rule.0 == "T001").collect();
1658 assert_eq!(
1662 t001_diags.len(),
1663 1,
1664 "only test_no_assertion_at_all should trigger T001 (util.assertEqual is now detected)"
1665 );
1666 }
1667
1668 #[test]
1670 fn t001_standard_assert_with_custom_config_still_passes() {
1671 use exspec_core::query_utils::apply_custom_assertion_fallback;
1672 use exspec_core::rules::{evaluate_rules, Config};
1673
1674 let source = "def test_standard():\n assert True\n";
1675 let extractor = PythonExtractor::new();
1676 let mut analysis = extractor.extract_file_analysis(source, "test_standard.py");
1677 let patterns = vec!["util.assertEqual(".to_string()];
1678 apply_custom_assertion_fallback(&mut analysis, source, &patterns);
1679
1680 let config = Config::default();
1681 let diags = evaluate_rules(&analysis.functions, &config);
1682 let t001_diags: Vec<_> = diags.iter().filter(|d| d.rule.0 == "T001").collect();
1683 assert!(t001_diags.is_empty(), "standard assert should still work");
1684 }
1685
1686 #[test]
1688 fn t001_custom_pattern_in_comment_prevents_t001() {
1689 use exspec_core::query_utils::apply_custom_assertion_fallback;
1690 use exspec_core::rules::{evaluate_rules, Config};
1691
1692 let source = "def test_commented():\n # util.assertEqual(x, 1)\n pass\n";
1693 let extractor = PythonExtractor::new();
1694 let mut analysis = extractor.extract_file_analysis(source, "test_commented.py");
1695 let patterns = vec!["util.assertEqual(".to_string()];
1696 apply_custom_assertion_fallback(&mut analysis, source, &patterns);
1697
1698 let config = Config::default();
1699 let diags = evaluate_rules(&analysis.functions, &config);
1700 let t001_diags: Vec<_> = diags.iter().filter(|d| d.rule.0 == "T001").collect();
1701 assert!(
1702 t001_diags.is_empty(),
1703 "comment match is included by design - T001 should not fire"
1704 );
1705 }
1706
1707 #[test]
1710 fn t001_skip_only_pytest_skip() {
1711 let source = fixture("t001_pass_skip_only.py");
1712 let extractor = PythonExtractor::new();
1713 let funcs = extractor.extract_test_functions(&source, "t001_pass_skip_only.py");
1714 let f = funcs
1715 .iter()
1716 .find(|f| f.name == "test_skipped_feature")
1717 .expect("test_skipped_feature not found");
1718 assert!(
1719 f.analysis.has_skip_call,
1720 "pytest.skip() should set has_skip_call=true"
1721 );
1722 }
1723
1724 #[test]
1725 fn t001_skip_only_self_skip_test() {
1726 let source = fixture("t001_pass_skip_only.py");
1727 let extractor = PythonExtractor::new();
1728 let funcs = extractor.extract_test_functions(&source, "t001_pass_skip_only.py");
1729 let f = funcs
1730 .iter()
1731 .find(|f| f.name == "test_incomplete")
1732 .expect("test_incomplete not found");
1733 assert!(
1734 f.analysis.has_skip_call,
1735 "self.skipTest() should set has_skip_call=true"
1736 );
1737 }
1738
1739 #[test]
1740 fn t001_skip_only_no_t001_block() {
1741 use exspec_core::rules::{evaluate_rules, Config, RuleId, Severity};
1742
1743 let source = fixture("t001_pass_skip_only.py");
1744 let extractor = PythonExtractor::new();
1745 let funcs = extractor.extract_test_functions(&source, "t001_pass_skip_only.py");
1746 let diags: Vec<_> = evaluate_rules(&funcs, &Config::default())
1747 .into_iter()
1748 .filter(|d| d.rule == RuleId::new("T001") && d.severity == Severity::Block)
1749 .collect();
1750 assert!(
1751 diags.is_empty(),
1752 "Expected 0 T001 BLOCKs for skip-only fixture, got {}: {:?}",
1753 diags.len(),
1754 diags.iter().map(|d| &d.message).collect::<Vec<_>>()
1755 );
1756 }
1757
1758 #[test]
1759 fn t110_skip_only_fixture_produces_info() {
1760 use exspec_core::rules::{evaluate_rules, Config, RuleId, Severity};
1761
1762 let source = fixture("t110_violation.py");
1763 let extractor = PythonExtractor::new();
1764 let funcs = extractor.extract_test_functions(&source, "t110_violation.py");
1765 let diags: Vec<_> = evaluate_rules(&funcs, &Config::default())
1766 .into_iter()
1767 .filter(|d| d.rule == RuleId::new("T110") && d.severity == Severity::Info)
1768 .collect();
1769 assert_eq!(diags.len(), 1, "Expected exactly one T110 INFO: {diags:?}");
1770 }
1771
1772 #[test]
1773 fn t110_existing_skip_only_fixture_produces_two_infos() {
1774 use exspec_core::rules::{evaluate_rules, Config, RuleId, Severity};
1775
1776 let source = fixture("t001_pass_skip_only.py");
1777 let extractor = PythonExtractor::new();
1778 let funcs = extractor.extract_test_functions(&source, "t001_pass_skip_only.py");
1779 let diags: Vec<_> = evaluate_rules(&funcs, &Config::default())
1780 .into_iter()
1781 .filter(|d| d.rule == RuleId::new("T110") && d.severity == Severity::Info)
1782 .collect();
1783 assert_eq!(
1784 diags.len(),
1785 2,
1786 "Expected both existing skip-only tests to emit T110 INFO: {diags:?}"
1787 );
1788 }
1789
1790 #[test]
1791 fn query_capture_names_skip_test() {
1792 let q = make_query(include_str!("../queries/skip_test.scm"));
1793 assert!(
1794 q.capture_index_for_name("skip").is_some(),
1795 "skip_test.scm must define @skip capture"
1796 );
1797 }
1798
1799 #[test]
1802 fn pytest_fixture_decorated_test_excluded() {
1803 let source = fixture("test_fixture_false_positive.py");
1805 let extractor = PythonExtractor::new();
1806 let funcs = extractor.extract_test_functions(&source, "test_fixture_false_positive.py");
1807 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
1808 assert!(
1809 !names.contains(&"test_data"),
1810 "@pytest.fixture test_data should be excluded: {names:?}"
1811 );
1812 }
1813
1814 #[test]
1815 fn pytest_fixture_with_parens_excluded() {
1816 let source = fixture("test_fixture_false_positive.py");
1818 let extractor = PythonExtractor::new();
1819 let funcs = extractor.extract_test_functions(&source, "test_fixture_false_positive.py");
1820 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
1821 assert!(
1822 !names.contains(&"test_config"),
1823 "@pytest.fixture() test_config should be excluded: {names:?}"
1824 );
1825 }
1826
1827 #[test]
1828 fn bare_fixture_decorator_excluded() {
1829 let source = fixture("test_fixture_false_positive.py");
1831 let extractor = PythonExtractor::new();
1832 let funcs = extractor.extract_test_functions(&source, "test_fixture_false_positive.py");
1833 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
1834 assert!(
1835 !names.contains(&"test_input"),
1836 "@fixture test_input should be excluded: {names:?}"
1837 );
1838 }
1839
1840 #[test]
1841 fn patch_decorated_real_test_included() {
1842 let source = fixture("test_fixture_false_positive.py");
1844 let extractor = PythonExtractor::new();
1845 let funcs = extractor.extract_test_functions(&source, "test_fixture_false_positive.py");
1846 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
1847 assert!(
1848 names.contains(&"test_something"),
1849 "@patch decorated test_something should be included: {names:?}"
1850 );
1851 }
1852
1853 #[test]
1854 fn mixed_fixture_and_real_tests_evaluated() {
1855 use exspec_core::rules::{evaluate_rules, Config};
1857
1858 let source = fixture("test_fixture_false_positive.py");
1859 let extractor = PythonExtractor::new();
1860 let funcs = extractor.extract_test_functions(&source, "test_fixture_false_positive.py");
1861 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
1862
1863 assert_eq!(
1865 funcs.len(),
1866 3,
1867 "expected 3 real tests (fixtures excluded): {names:?}"
1868 );
1869
1870 let config = Config::default();
1872 let diags = evaluate_rules(&funcs, &config);
1873 let t001_diags: Vec<_> = diags.iter().filter(|d| d.rule.0 == "T001").collect();
1874 assert_eq!(
1875 t001_diags.len(),
1876 1,
1877 "only test_uses_fixture should trigger T001: {t001_diags:?}"
1878 );
1879 assert!(
1880 t001_diags[0].message.contains("test_uses_fixture"),
1881 "T001 should reference test_uses_fixture: {}",
1882 t001_diags[0].message
1883 );
1884 }
1885
1886 #[test]
1889 fn nested_test_function_excluded_from_extraction() {
1890 let source = fixture("nested_test_function.py");
1891 let extractor = PythonExtractor::new();
1892 let funcs = extractor.extract_test_functions(&source, "nested_test_function.py");
1893 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
1894 assert!(
1895 names.contains(&"test_outer"),
1896 "outer test should be included: {names:?}"
1897 );
1898 assert!(
1899 !names.contains(&"test_inner"),
1900 "nested test should be excluded: {names:?}"
1901 );
1902 }
1903
1904 #[test]
1905 fn parent_assertion_count_correct_with_nested_function() {
1906 let source = fixture("nested_test_function.py");
1907 let extractor = PythonExtractor::new();
1908 let funcs = extractor.extract_test_functions(&source, "nested_test_function.py");
1909 let outer = funcs
1910 .iter()
1911 .find(|f| f.name == "test_outer")
1912 .expect("test_outer should exist");
1913 assert!(
1914 outer.analysis.assertion_count >= 1,
1915 "parent test should still count its own assertion, got {}",
1916 outer.analysis.assertion_count
1917 );
1918 }
1919
1920 #[test]
1921 fn multi_level_nested_test_functions_excluded() {
1922 let source = fixture("nested_test_function.py");
1923 let extractor = PythonExtractor::new();
1924 let funcs = extractor.extract_test_functions(&source, "nested_test_function.py");
1925 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
1926 assert!(
1927 names.contains(&"test_multi_outer"),
1928 "outer test should be included: {names:?}"
1929 );
1930 assert!(
1931 !names.contains(&"test_multi_mid"),
1932 "mid-level nested test should be excluded: {names:?}"
1933 );
1934 assert!(
1935 !names.contains(&"test_multi_inner"),
1936 "inner nested test should be excluded: {names:?}"
1937 );
1938 }
1939
1940 #[test]
1941 fn non_test_nested_function_unchanged() {
1942 let source = fixture("nested_test_function.py");
1943 let extractor = PythonExtractor::new();
1944 let funcs = extractor.extract_test_functions(&source, "nested_test_function.py");
1945 let parent = funcs
1946 .iter()
1947 .find(|f| f.name == "test_with_helper")
1948 .expect("test_with_helper should exist");
1949 assert!(
1950 parent.analysis.assertion_count >= 1,
1951 "non-test helper nesting should not break assertion counting, got {}",
1952 parent.analysis.assertion_count
1953 );
1954 }
1955
1956 #[test]
1957 fn sibling_test_functions_not_affected() {
1958 let source = fixture("nested_test_function.py");
1959 let extractor = PythonExtractor::new();
1960 let funcs = extractor.extract_test_functions(&source, "nested_test_function.py");
1961 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
1962 assert!(
1963 names.contains(&"test_sibling_a"),
1964 "sibling module-level test should remain included: {names:?}"
1965 );
1966 assert!(
1967 names.contains(&"test_sibling_b"),
1968 "sibling module-level test should remain included: {names:?}"
1969 );
1970 }
1971
1972 #[test]
1973 fn async_nested_test_function_excluded() {
1974 let source = fixture("nested_test_function.py");
1975 let extractor = PythonExtractor::new();
1976 let funcs = extractor.extract_test_functions(&source, "nested_test_function.py");
1977 let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
1978 assert!(
1979 names.contains(&"test_async_outer"),
1980 "outer async container test should be included: {names:?}"
1981 );
1982 assert!(
1983 !names.contains(&"test_async_helper"),
1984 "nested async test should be excluded: {names:?}"
1985 );
1986 }
1987
1988 #[test]
1991 fn helper_tracing_tc01_calls_helper_with_assert() {
1992 let source = fixture("t001_pass_helper_tracing.py");
1994 let extractor = PythonExtractor::new();
1995 let fa = extractor.extract_file_analysis(&source, "t001_pass_helper_tracing.py");
1996 let func = fa
1997 .functions
1998 .iter()
1999 .find(|f| f.name == "test_calls_helper_with_assert")
2000 .expect("test_calls_helper_with_assert not found");
2001 assert!(
2002 func.analysis.assertion_count >= 1,
2003 "TC-01: helper with assertion traced → assertion_count >= 1, got {}",
2004 func.analysis.assertion_count
2005 );
2006 }
2007
2008 #[test]
2009 fn helper_tracing_tc02_calls_helper_without_assert() {
2010 let source = fixture("t001_pass_helper_tracing.py");
2012 let extractor = PythonExtractor::new();
2013 let fa = extractor.extract_file_analysis(&source, "t001_pass_helper_tracing.py");
2014 let func = fa
2015 .functions
2016 .iter()
2017 .find(|f| f.name == "test_calls_helper_without_assert")
2018 .expect("test_calls_helper_without_assert not found");
2019 assert_eq!(
2020 func.analysis.assertion_count, 0,
2021 "TC-02: helper without assertion → assertion_count == 0, got {}",
2022 func.analysis.assertion_count
2023 );
2024 }
2025
2026 #[test]
2027 fn helper_tracing_tc03_has_own_assert_plus_helper() {
2028 let source = fixture("t001_pass_helper_tracing.py");
2030 let extractor = PythonExtractor::new();
2031 let fa = extractor.extract_file_analysis(&source, "t001_pass_helper_tracing.py");
2032 let func = fa
2033 .functions
2034 .iter()
2035 .find(|f| f.name == "test_has_own_assert_plus_helper")
2036 .expect("test_has_own_assert_plus_helper not found");
2037 assert!(
2038 func.analysis.assertion_count >= 1,
2039 "TC-03: own assertion present → assertion_count >= 1, got {}",
2040 func.analysis.assertion_count
2041 );
2042 }
2043
2044 #[test]
2045 fn helper_tracing_tc04_calls_undefined_function() {
2046 let source = fixture("t001_pass_helper_tracing.py");
2048 let extractor = PythonExtractor::new();
2049 let fa = extractor.extract_file_analysis(&source, "t001_pass_helper_tracing.py");
2050 let func = fa
2051 .functions
2052 .iter()
2053 .find(|f| f.name == "test_calls_undefined_function")
2054 .expect("test_calls_undefined_function not found");
2055 assert_eq!(
2056 func.analysis.assertion_count, 0,
2057 "TC-04: undefined function call → no crash, assertion_count == 0, got {}",
2058 func.analysis.assertion_count
2059 );
2060 }
2061
2062 #[test]
2063 fn helper_tracing_tc05_two_hop_tracing() {
2064 let source = fixture("t001_pass_helper_tracing.py");
2067 let extractor = PythonExtractor::new();
2068 let fa = extractor.extract_file_analysis(&source, "t001_pass_helper_tracing.py");
2069 let func = fa
2070 .functions
2071 .iter()
2072 .find(|f| f.name == "test_two_hop_tracing")
2073 .expect("test_two_hop_tracing not found");
2074 assert_eq!(
2075 func.analysis.assertion_count, 0,
2076 "TC-05: 2-hop helper not traced → assertion_count == 0, got {}",
2077 func.analysis.assertion_count
2078 );
2079 }
2080
2081 #[test]
2082 fn helper_tracing_tc06_with_assertion_early_return() {
2083 let source = fixture("t001_pass_helper_tracing.py");
2085 let extractor = PythonExtractor::new();
2086 let fa = extractor.extract_file_analysis(&source, "t001_pass_helper_tracing.py");
2087 let func = fa
2088 .functions
2089 .iter()
2090 .find(|f| f.name == "test_with_assertion_early_return")
2091 .expect("test_with_assertion_early_return not found");
2092 assert!(
2093 func.analysis.assertion_count >= 1,
2094 "TC-06: own assertion present → assertion_count >= 1, got {}",
2095 func.analysis.assertion_count
2096 );
2097 }
2098
2099 #[test]
2100 fn helper_tracing_tc07_multiple_calls_same_helper() {
2101 let source = fixture("t001_pass_helper_tracing.py");
2105 let extractor = PythonExtractor::new();
2106 let fa = extractor.extract_file_analysis(&source, "t001_pass_helper_tracing.py");
2107 let func = fa
2108 .functions
2109 .iter()
2110 .find(|f| f.name == "test_multiple_calls_same_helper")
2111 .expect("test_multiple_calls_same_helper not found");
2112 assert_eq!(
2113 func.analysis.assertion_count, 1,
2114 "TC-07: multiple calls to same helper → deduplicated, assertion_count == 1, got {}",
2115 func.analysis.assertion_count
2116 );
2117 }
2118}