Skip to main content

brokk_bifrost_python/
test_detection.rs

1use crate::declarations::py_node_text;
2use crate::syntax::{decorator_callee, python_static_attribute_path};
3use brokk_bifrost_core::analyzer::ProjectFile;
4use brokk_bifrost_core::analyzer::model::{TestAssertionSmell, TestAssertionWeights};
5use brokk_bifrost_core::analyzer::test_assertions::{
6    CommonTestAssertionKind, TestAssertionSignal, append_test_assertion_findings,
7    classify_common_test_assertion, compact_assertion_excerpt,
8};
9use brokk_bifrost_core::analyzer::tree_walk::{
10    WalkControl, walk_named_tree_preorder, walk_tree_preorder,
11};
12use tree_sitter::Node;
13
14const TEST_ASSERTION_EXCERPT_MAX_LEN: usize = 180;
15
16struct PythonTestCase<'tree> {
17    name: String,
18    definition: Node<'tree>,
19    body: Node<'tree>,
20}
21
22pub fn detect_python_test_assertion_smells(
23    file: &ProjectFile,
24    source: &str,
25    weights: &TestAssertionWeights,
26) -> Vec<TestAssertionSmell> {
27    let Some(tree) = crate::declarations::parse_python_tree(source) else {
28        return Vec::new();
29    };
30    let mut test_cases = Vec::new();
31    collect_python_test_cases(tree.root_node(), source, &mut test_cases);
32    let mut findings = Vec::new();
33    for test_case in test_cases {
34        analyze_python_test_case(file, &test_case, source, weights, &mut findings);
35    }
36    findings
37}
38
39fn collect_python_test_cases<'tree>(
40    node: Node<'tree>,
41    source: &str,
42    out: &mut Vec<PythonTestCase<'tree>>,
43) {
44    walk_named_tree_preorder(node, true, |current| {
45        if current.kind() != "function_definition" {
46            return WalkControl::Continue;
47        }
48        let mut marked = false;
49        if let Some(parent) = current
50            .parent()
51            .filter(|p| p.kind() == "decorated_definition")
52        {
53            let mut cursor = parent.walk();
54            for decorator in parent
55                .named_children(&mut cursor)
56                .filter(|n| n.kind() == "decorator")
57            {
58                let Some(path) = decorator_callee(decorator).and_then(python_static_attribute_path)
59                else {
60                    continue;
61                };
62                let names: Vec<_> = path.iter().map(|n| py_node_text(*n, source)).collect();
63                if names == ["pytest", "fixture"] {
64                    return WalkControl::SkipChildren;
65                }
66                marked |= names.len() >= 3 && names[..2] == ["pytest", "mark"];
67            }
68        }
69        if let (Some(name), Some(body)) = (
70            current.child_by_field_name("name"),
71            current.child_by_field_name("body"),
72        ) {
73            let name = py_node_text(name, source);
74            if name.starts_with("test_") || marked {
75                out.push(PythonTestCase {
76                    name: name.to_string(),
77                    definition: current,
78                    body,
79                });
80            }
81        }
82        WalkControl::Continue
83    });
84}
85
86pub fn python_tree_contains_tests(root: Node<'_>, source: &str) -> bool {
87    let mut test_cases = Vec::new();
88    collect_python_test_cases(root, source, &mut test_cases);
89    !test_cases.is_empty()
90}
91
92fn analyze_python_test_case(
93    file: &ProjectFile,
94    test_case: &PythonTestCase<'_>,
95    source: &str,
96    weights: &TestAssertionWeights,
97    out: &mut Vec<TestAssertionSmell>,
98) {
99    let assertions = collect_python_assertions(test_case.body, source, weights);
100    append_test_assertion_findings(
101        file,
102        format!("{}::{}", file, test_case.name),
103        compact_assertion_excerpt(
104            py_node_text(test_case.body, source),
105            TEST_ASSERTION_EXCERPT_MAX_LEN,
106        ),
107        test_case.definition.start_byte(),
108        &assertions,
109        weights,
110        out,
111    );
112}
113
114fn collect_python_assertions(
115    body: Node<'_>,
116    source: &str,
117    weights: &TestAssertionWeights,
118) -> Vec<TestAssertionSignal> {
119    let mut assertions = Vec::new();
120    walk_named_tree_preorder(body, true, |node| {
121        if matches!(
122            node.kind(),
123            "function_definition" | "class_definition" | "lambda" | "string" | "comment"
124        ) {
125            return WalkControl::SkipChildren;
126        }
127        let mut operands = None;
128        let kind = if node.kind() == "assert_statement" {
129            let Some(expr) = node.named_child(0).map(unparenthesized) else {
130                return WalkControl::Continue;
131            };
132            match expr.kind() {
133                "true" | "false" => Some(CommonTestAssertionKind::ConstantTruth),
134                "comparison_operator" if expr.named_child_count() == 2 => {
135                    let left = expr.named_child(0).unwrap();
136                    let right = expr.named_child(1).unwrap();
137                    let operator = expr
138                        .child_by_field_name("operators")
139                        .expect("comparison has an operator");
140                    match operator.kind() {
141                        "==" => {
142                            operands = Some((left, right));
143                            Some(CommonTestAssertionKind::Meaningful)
144                        }
145                        "is" | "is not" if unparenthesized(right).kind() == "none" => {
146                            Some(CommonTestAssertionKind::NullnessOnly)
147                        }
148                        _ => Some(CommonTestAssertionKind::Meaningful),
149                    }
150                }
151                _ => Some(CommonTestAssertionKind::Meaningful),
152            }
153        } else if node.kind() == "call" {
154            let function = node
155                .child_by_field_name("function")
156                .expect("call has a function");
157            if function.kind() == "attribute"
158                && function
159                    .child_by_field_name("attribute")
160                    .is_some_and(|name| {
161                        matches!(
162                            py_node_text(name, source),
163                            "assert_called"
164                                | "assert_called_once"
165                                | "assert_called_once_with"
166                                | "assert_called_with"
167                                | "assert_not_called"
168                        )
169                    })
170            {
171                Some(CommonTestAssertionKind::Meaningful)
172            } else {
173                let Some(path) = python_static_attribute_path(function) else {
174                    return WalkControl::Continue;
175                };
176                let names: Vec<_> = path.iter().map(|n| py_node_text(*n, source)).collect();
177                let arguments = node
178                    .child_by_field_name("arguments")
179                    .expect("call has arguments");
180                let mut cursor = arguments.walk();
181                let args: Vec<_> = arguments
182                    .named_children(&mut cursor)
183                    .filter(|n| !n.is_extra())
184                    .collect();
185                match names.as_slice() {
186                    ["self", "assertEqual" | "assertIs"] if args.len() >= 2 => {
187                        operands = Some((args[0], args[1]));
188                        Some(CommonTestAssertionKind::Meaningful)
189                    }
190                    ["self", "assertTrue"]
191                        if args
192                            .first()
193                            .is_some_and(|n| unparenthesized(*n).kind() == "true") =>
194                    {
195                        Some(CommonTestAssertionKind::ConstantTruth)
196                    }
197                    ["self", "assertFalse"]
198                        if args
199                            .first()
200                            .is_some_and(|n| unparenthesized(*n).kind() == "false") =>
201                    {
202                        Some(CommonTestAssertionKind::ConstantTruth)
203                    }
204                    ["self", "assertIsNone" | "assertIsNotNone"] => {
205                        Some(CommonTestAssertionKind::NullnessOnly)
206                    }
207                    ["self", "assertTrue" | "assertFalse" | "assertRaises"]
208                    | ["pytest", "raises"] => Some(CommonTestAssertionKind::Meaningful),
209                    _ => None,
210                }
211            }
212        } else {
213            None
214        };
215        let Some(mut kind) = kind else {
216            return WalkControl::Continue;
217        };
218        let mut oversized = None;
219        if let Some((left, right)) = operands {
220            if normalize_python_expr(left, source) == normalize_python_expr(right, source) {
221                kind = if is_python_literal(unparenthesized(left)) {
222                    CommonTestAssertionKind::ConstantEquality
223                } else {
224                    CommonTestAssertionKind::SelfComparison
225                };
226            } else {
227                oversized = oversized_python_literal(left, right, source, weights);
228            }
229        }
230        let classification = classify_common_test_assertion(kind, weights);
231        let (kind, score, shallow, meaningful, reasons) = if let Some(literal) = oversized {
232            (
233                "overspecified-literal",
234                weights.overspecified_literal_weight,
235                false,
236                false,
237                vec![format!("overspecified-literal:{literal}")],
238            )
239        } else {
240            (
241                classification.kind,
242                classification.score,
243                classification.shallow,
244                classification.score == 0,
245                vec![classification.kind.to_string()],
246            )
247        };
248        assertions.push(TestAssertionSignal {
249            kind: kind.to_string(),
250            score,
251            shallow,
252            meaningful,
253            reasons,
254            excerpt: compact_assertion_excerpt(
255                py_node_text(node, source),
256                TEST_ASSERTION_EXCERPT_MAX_LEN,
257            ),
258            start_byte: node.start_byte(),
259        });
260        WalkControl::SkipChildren
261    });
262    assertions
263}
264
265fn unparenthesized(mut node: Node<'_>) -> Node<'_> {
266    while node.kind() == "parenthesized_expression" {
267        let mut cursor = node.walk();
268        node = node
269            .named_children(&mut cursor)
270            .find(|child| !child.is_extra())
271            .expect("parenthesized expression has an operand");
272    }
273    node
274}
275
276// Compare grammar-selected tokens. Whitespace in string contents remains data;
277// comments and grouping parentheses do not change expression identity.
278fn normalize_python_expr<'source>(
279    node: Node<'_>,
280    source: &'source str,
281) -> Vec<(u16, &'source str)> {
282    let mut tokens = Vec::new();
283    walk_tree_preorder(node, true, |current| {
284        if current.is_extra() {
285            return WalkControl::SkipChildren;
286        }
287        if current.child_count() == 0
288            && !current
289                .parent()
290                .is_some_and(|p| p.kind() == "parenthesized_expression" && !current.is_named())
291        {
292            tokens.push((current.kind_id(), py_node_text(current, source)));
293        }
294        WalkControl::Continue
295    });
296    tokens
297}
298
299fn is_python_literal(node: Node<'_>) -> bool {
300    match node.kind() {
301        "integer" | "float" | "true" | "false" | "none" => true,
302        "unary_operator" => {
303            node.child_by_field_name("operator")
304                .is_some_and(|operator| matches!(operator.kind(), "+" | "-"))
305                && node
306                    .child_by_field_name("argument")
307                    .is_some_and(|argument| {
308                        matches!(unparenthesized(argument).kind(), "integer" | "float")
309                    })
310        }
311        "string" => {
312            let mut cursor = node.walk();
313            !node
314                .named_children(&mut cursor)
315                .any(|child| child.kind() == "interpolation")
316        }
317        _ => false,
318    }
319}
320
321fn oversized_python_literal<'source>(
322    left: Node<'_>,
323    right: Node<'_>,
324    source: &'source str,
325    weights: &TestAssertionWeights,
326) -> Option<&'source str> {
327    [left, right]
328        .into_iter()
329        .map(unparenthesized)
330        .find_map(|node| {
331            if node.kind() != "string" {
332                return None;
333            }
334            let mut length = 0;
335            let mut cursor = node.walk();
336            for child in node.named_children(&mut cursor) {
337                match child.kind() {
338                    "interpolation" => return None,
339                    "string_content" => length += child.end_byte() - child.start_byte(),
340                    _ => {}
341                }
342            }
343            (length >= weights.large_literal_length_threshold.max(0) as usize)
344                .then(|| py_node_text(node, source))
345        })
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use brokk_bifrost_test_macros::hardwon;
352
353    #[test]
354    #[hardwon(
355        "6e62e856da86: source scans counted assertion lookalikes and lost operand boundaries"
356    )]
357    fn qs6_assertion_syntax_ignores_strings_and_nested_definitions() {
358        let source = "# \u{e9}\n@pytest.mark.slow\ndef test_marked():\n    self.assertEqual((one, two), (one, two))\ndef test_strings():\n    text = '''\n    assert True\n    self.assertEqual(1, 1)\n    '''\n    # self.assertTrue(True)\n    def helper():\n        assert False\ndef test_fixture_text():\n    text = '@pytest.fixture'\n    assert True\n@pytest.fixture\ndef test_fixture():\n    assert True\n";
359        let file = ProjectFile::new(std::env::temp_dir(), "test_sample.py");
360        let findings =
361            detect_python_test_assertion_smells(&file, source, &TestAssertionWeights::defaults());
362        let actual: Vec<_> = findings
363            .iter()
364            .map(|f| {
365                (
366                    f.enclosing_fq_name.rsplit("::").next().unwrap(),
367                    f.assertion_kind.as_str(),
368                    f.start_byte,
369                )
370            })
371            .collect();
372        assert_eq!(
373            actual,
374            vec![
375                (
376                    "test_marked",
377                    "self-comparison",
378                    source.find("self.assertEqual").unwrap()
379                ),
380                (
381                    "test_strings",
382                    "no-assertions",
383                    source.find("def test_strings").unwrap()
384                ),
385                (
386                    "test_fixture_text",
387                    "constant-truth",
388                    source.rfind("    assert True\n@pytest.fixture").unwrap() + 4
389                ),
390                (
391                    "test_fixture_text",
392                    "shallow-assertions-only",
393                    source.find("def test_fixture_text").unwrap()
394                ),
395            ]
396        );
397    }
398}