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
276fn 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}