Skip to main content

exspec_lang_python/
lib.rs

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    // Walk all ancestors, record the outermost class name
101    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, // module-level
117        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        // Strip leading '@' and trailing '(...)' or whitespace to get the decorator name
131        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                // Always register the inner function as "has a decorated match" so the
189                // dedup retain step removes any bare @function match for the same node.
190                decorated_fn_ids.insert(inner_fn.id());
191                // Skip @pytest.fixture / @fixture decorated functions — they are
192                // test data providers, not test functions (prevents T001 FPs).
193                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    // Filter out methods in non-test classes (e.g., UserService.test_connection)
227    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    // pytest does not discover nested test_* functions, so remove any test
233    // match whose full effective range is strictly contained in another.
234    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        // Fixture count: number of function parameters (excluding `self`)
294        let fixture_count = count_function_params(fn_node, source_bytes);
295
296        // T108: wait-and-see detection
297        let has_wait = has_any_match(wait_query, "wait", fn_node, source_bytes);
298
299        // #64: skip-only test detection
300        let has_skip_call = has_any_match(skip_query, "skip", fn_node, source_bytes);
301
302        // T107: assertion message count
303        let assertion_message_count =
304            count_assertion_messages_py(assertion_query, fn_node, source_bytes);
305
306        // T106: duplicate literal count
307        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        // Apply same-file helper tracing (Phase 23b — Python port)
404        // helper_trace.scm contains both @call_name and @def_name/@def_body captures
405        // in a single query. Same object is passed as both call_query and def_query by design.
406        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
421/// Count function parameters excluding `self`.
422/// Uses tree-sitter Node API: function_definition → parameters → named_child_count.
423/// Only named children are actual parameters (commas and parens are anonymous).
424fn count_function_params(fn_node: Node, source: &[u8]) -> usize {
425    // Navigate up to the function_definition node if we're inside it
426    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    // Check if first named child is `self` or `cls` (classmethod)
442    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
454/// Count assertion statements that have a failure message.
455/// Python: `assert expr, "msg"` has named child "msg". `self.assert*(a, b, msg)` has 3+ args.
456fn 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                // assert_statement fields: condition (required), msg (optional)
469                // If named_child_count > 1, the second child is the message
470                if node.named_child_count() > 1 {
471                    count += 1;
472                }
473            } else if node.kind() == "call" {
474                // self.assert*(a, b) -> 2 args, self.assert*(a, b, msg) -> 3 args
475                // arguments node contains the args
476                if let Some(args) = node.child_by_field_name("arguments") {
477                    // For assertTrue(x, msg): 2+ args means message present
478                    // For assertEqual(a, b, msg): 3+ args means message present
479                    // Heuristic: if method name starts with "assert" and has >=3 args,
480                    // or assertTrue/assertFalse with >=2 args, it has a message.
481                    // Simpler: any self.assert* with an odd number of args for comparison
482                    // asserts, or just check if last arg is a string.
483                    //
484                    // Actually simplest: for unittest methods, the message is typically
485                    // the last argument. We can't reliably distinguish without knowing
486                    // the method signature. Let's use: named_child_count >= 3 for
487                    // assertEqual/assertIn etc., >= 2 for assertTrue/assertFalse.
488                    //
489                    // Simplification: just check if there are "many" args relative to
490                    // the minimum needed. For now, check if last arg is a string literal.
491                    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    // --- Cycle 2: Test function extraction ---
531
532    #[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    // --- Cycle 3: Assertion detection ---
565
566    #[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    // --- Cycle 3: Mock detection ---
593
594    #[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    // --- Giant test ---
624
625    #[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    // --- Inline suppression ---
644
645    #[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    // --- Phase 1 preserved tests ---
668
669    #[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    // --- File analysis: parameterized ---
684
685    #[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    // --- File analysis: PBT import ---
706
707    #[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    // --- File analysis: contract import ---
724
725    #[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    // --- Class method false positive filtering ---
742
743    #[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    // --- Issue #6: Nested class outermost ancestor ---
797
798    #[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    // --- File analysis preserves functions ---
835
836    #[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    // --- T101: how-not-what ---
846
847    #[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        // assert_called_with counts as both assertion (T001 pass) and how-not-what (T101 fire)
877        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    // --- Query capture name verification (#14) ---
891
892    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    // --- T102: fixture-sprawl ---
977
978    #[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    // --- T101: private attribute access in assertions (#13) ---
1027
1028    #[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        // test_checks_internal_count has assert service._count and assert service._processed
1034        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        // test_private_outside_assertion: obj._internal is outside assert, assert value == 42 has no private
1051        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        // test_dunder_not_private: __class__, __dict__ should not match
1067        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        // test_mixed_private_and_mock: has assert_called_with (mock) + assert service._last_created (private)
1083        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    // --- T103: missing-error-test ---
1104
1105    #[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    // --- T105: deterministic-no-metamorphic ---
1190
1191    #[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    // --- T108: wait-and-see ---
1245
1246    #[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    // --- T107: assertion-roulette ---
1283
1284    #[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    // --- T109: undescriptive-test-name ---
1325
1326    #[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    // --- T106: duplicate-literal-assertion ---
1357
1358    #[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    // --- T001 FP fix: pytest.raises as assertion (#25) ---
1385
1386    #[test]
1387    fn t001_pytest_raises_counts_as_assertion() {
1388        // TC-01: pytest.raises() only -> T001 should NOT fire
1389        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        // TC-02: pytest.raises() with match -> T001 should NOT fire
1403        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    // --- T001 FP fix: pytest.warns (#34) ---
1415
1416    #[test]
1417    fn t001_pytest_warns_counts_as_assertion() {
1418        // TC-04: pytest.warns() only -> T001 should NOT fire
1419        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        // TC-05: pytest.warns() with match -> T001 should NOT fire
1433        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        // TC-03: self.assertRaises() -> already matched by ^assert pattern
1447        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    // --- T001 FP fix: pytest.fail() (#57) ---
1459
1460    #[test]
1461    fn t001_pytest_fail_counts_as_assertion() {
1462        // TC-01: pytest.fail() only -> T001 should NOT fire
1463        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        // TC-02: no assertions, no pytest.fail() -> T001 BLOCK (control case)
1477        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    // --- T001 FP fix: mock.assert_*() methods (#38) ---
1489
1490    #[test]
1491    fn t001_mock_assert_called_once_counts_as_assertion() {
1492        // TC-07: mock.assert_called_once() -> assertion_count >= 1
1493        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        // TC-08: mock.assert_called_once_with(arg) -> assertion_count >= 1
1507        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        // TC-09: mock.assert_not_called() -> assertion_count >= 1
1521        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        // TC-10: mock.assert_has_calls([...]) -> assertion_count >= 1
1535        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        // TC-11: mock.return_value.assert_called_once() -> assertion_count >= 1
1549        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    // --- T001 FP fix: obj.assert*() without underscore (#62) ---
1561
1562    #[test]
1563    fn t001_assert_no_underscore_counts_as_assertion() {
1564        // obj.assertoutcome() without underscore should count as assertion
1565        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        // obj.assertStatus() without underscore should count as assertion
1579        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        // TC-12: self.assertEqual still works, no double-count
1593        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    // --- TC-11: Python custom helper + config -> T001 does NOT fire ---
1618    #[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        // test_with_custom_helper: should pass (custom pattern match)
1633        // test_with_standard_assert: should pass (standard assert)
1634        // test_no_assertion_at_all: should BLOCK (no assertion)
1635        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    // --- TC-12: Same test WITHOUT config -> T001 fires for custom helper ---
1647    #[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        // Without config, only test_no_assertion_at_all should fire.
1659        // test_with_custom_helper has util.assertEqual() which is now detected
1660        // by the broadened obj.assert*() pattern (#62).
1661        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    // --- TC-13: Standard assert + custom config -> T001 does NOT fire ---
1669    #[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    // --- TC-15: Pattern only in comment -> T001 does NOT fire (documented behavior) ---
1687    #[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    // --- #64: T001 FP: skip-only test exclusion ---
1708
1709    #[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    // --- #56: pytest fixture with test_ prefix false positive ---
1800
1801    #[test]
1802    fn pytest_fixture_decorated_test_excluded() {
1803        // TC-01: @pytest.fixture decorated test_data -> NOT included
1804        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        // TC-02: @pytest.fixture() with parens -> NOT included
1817        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        // TC-03: @fixture (from pytest import fixture) -> NOT included
1830        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        // TC-04: @patch("x") decorated real test -> IS included (no regression)
1843        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        // TC-05: Mixed file -> fixtures excluded, real tests evaluated normally
1856        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        // 3 real tests: test_something, test_real_function, test_uses_fixture
1864        assert_eq!(
1865            funcs.len(),
1866            3,
1867            "expected 3 real tests (fixtures excluded): {names:?}"
1868        );
1869
1870        // T001 should fire only on test_uses_fixture (assertion-free)
1871        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    // --- #41: nested test function false positive ---
1887
1888    #[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    // --- Same-file helper tracing (Phase 23b, TC-01 ~ TC-07) ---
1989
1990    #[test]
1991    fn helper_tracing_tc01_calls_helper_with_assert() {
1992        // TC-01: test that calls a helper with assertion → assertion_count >= 1 after tracing
1993        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        // TC-02: test that calls a helper WITHOUT assertion → assertion_count stays 0
2011        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        // TC-03: test with own assert + calls helper → assertion_count >= 1 (direct assertion)
2029        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        // TC-04: calling a function not defined in the file → no crash, assertion_count stays 0
2047        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        // TC-05: 2-hop helper (intermediate → check_result) — only 1-hop traced.
2065        // intermediate() itself has NO assertion → assertion_count stays 0
2066        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        // TC-06: test with own assertion → helper tracing early returns, assertion_count unchanged
2084        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        // TC-07: test that calls same helper multiple times
2102        // Should deduplicate and count helper assertions once, not once per call.
2103        // check_result has exactly 1 assert → dedup → assertion_count == 1
2104        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}