Coverage for tests / unit / ai / test_fix_parsing.py: 100%
90 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Tests for parse_fix_response, parse_batch_response, and generate_diff."""
3from __future__ import annotations
5import json
7from assertpy import assert_that
9from lintro.ai.fix_parsing import (
10 generate_diff,
11 parse_batch_response,
12 parse_fix_response,
13)
15# ---------------------------------------------------------------------------
16# generate_diff
17# ---------------------------------------------------------------------------
20def test_generate_diff_generates_unified_diff():
21 """Verify unified diff output contains expected file headers and change markers."""
22 diff = generate_diff("test.py", "old code\n", "new code\n")
23 assert_that(diff).contains("a/test.py")
24 assert_that(diff).contains("b/test.py")
25 assert_that(diff).contains("-old code")
26 assert_that(diff).contains("+new code")
29def test_generate_diff_no_diff_for_identical():
30 """Verify that identical content produces an empty diff string."""
31 diff = generate_diff("test.py", "same\n", "same\n")
32 assert_that(diff).is_equal_to("")
35# ---------------------------------------------------------------------------
36# parse_fix_response
37# ---------------------------------------------------------------------------
40def test_parse_fix_response_valid_response():
41 """Valid JSON is parsed into a fix suggestion with correct fields."""
42 content = json.dumps(
43 {
44 "original_code": "assert x > 0",
45 "suggested_code": "if not x > 0:\n raise ValueError",
46 "explanation": "Replace assert",
47 "confidence": "high",
48 },
49 )
50 result = parse_fix_response(content, "main.py", 10, "B101")
51 assert_that(result).is_not_none()
52 assert_that(result.file).is_equal_to("main.py") # type: ignore[union-attr] # assertpy is_not_none narrows this
53 assert_that(result.confidence).is_equal_to("high") # type: ignore[union-attr] # assertpy is_not_none narrows this
54 assert_that(result.diff).is_not_empty() # type: ignore[union-attr] # assertpy is_not_none narrows this
57def test_parse_fix_response_non_object_json():
58 """Non-object JSON (array, string, number) returns None."""
59 for payload in ["[1, 2]", '"just a string"', "42"]:
60 result = parse_fix_response(payload, "main.py", 10, "B101")
61 assert_that(result).is_none()
64def test_parse_fix_response_non_string_code_fields():
65 """Non-string original_code or suggested_code returns None."""
66 content = json.dumps(
67 {
68 "original_code": 123,
69 "suggested_code": ["not", "a", "string"],
70 "explanation": "Fix",
71 "confidence": "medium",
72 },
73 )
74 result = parse_fix_response(content, "main.py", 10, "B101")
75 assert_that(result).is_none()
78def test_parse_fix_response_invalid_json():
79 """Verify that invalid JSON content returns None."""
80 result = parse_fix_response("not json", "main.py", 10, "B101")
81 assert_that(result).is_none()
84def test_parse_fix_response_identical_code():
85 """Verify that identical original and suggested code returns None."""
86 content = json.dumps(
87 {
88 "original_code": "x = 1",
89 "suggested_code": "x = 1",
90 "explanation": "No change",
91 "confidence": "high",
92 },
93 )
94 result = parse_fix_response(content, "main.py", 10, "B101")
95 assert_that(result).is_none()
98def test_parse_fix_response_empty_original():
99 """Verify that an empty original_code field returns None."""
100 content = json.dumps(
101 {
102 "original_code": "",
103 "suggested_code": "new code",
104 "explanation": "Fix",
105 "confidence": "medium",
106 },
107 )
108 result = parse_fix_response(content, "main.py", 10, "B101")
109 assert_that(result).is_none()
112def test_parse_fix_response_empty_suggested():
113 """Verify that an empty suggested_code field returns None."""
114 content = json.dumps(
115 {
116 "original_code": "old code",
117 "suggested_code": "",
118 "explanation": "Fix",
119 "confidence": "medium",
120 },
121 )
122 result = parse_fix_response(content, "main.py", 10, "B101")
123 assert_that(result).is_none()
126def test_parse_fix_response_extracts_risk_level():
127 """parse_fix_response should populate risk_level from the JSON payload."""
128 content = json.dumps(
129 {
130 "original_code": "assert x > 0",
131 "suggested_code": "if not x > 0:\n raise ValueError",
132 "explanation": "Replace assert",
133 "confidence": "high",
134 "risk_level": "low",
135 },
136 )
137 result = parse_fix_response(content, "main.py", 10, "B101")
138 assert_that(result).is_not_none()
139 assert_that(result.risk_level).is_equal_to("low") # type: ignore[union-attr] # assertpy is_not_none narrows this
142def test_parse_fix_response_risk_level_defaults_to_empty():
143 """When risk_level is absent from the JSON, the field should default to ''."""
144 content = json.dumps(
145 {
146 "original_code": "assert x > 0",
147 "suggested_code": "if not x > 0:\n raise ValueError",
148 "explanation": "Replace assert",
149 "confidence": "high",
150 },
151 )
152 result = parse_fix_response(content, "main.py", 10, "B101")
153 assert_that(result).is_not_none()
154 assert_that(result.risk_level).is_equal_to("") # type: ignore[union-attr] # assertpy is_not_none narrows this
157def test_parse_fix_response_confidence_defaults_to_medium():
158 """When confidence is absent from the JSON, the field should default to 'medium'."""
159 content = json.dumps(
160 {
161 "original_code": "assert x > 0",
162 "suggested_code": "if not x > 0:\n raise ValueError",
163 "explanation": "Replace assert",
164 },
165 )
166 result = parse_fix_response(content, "main.py", 10, "B101")
167 assert_that(result).is_not_none()
168 assert_that(result.confidence).is_equal_to("medium") # type: ignore[union-attr] # assertpy is_not_none narrows this
171# ---------------------------------------------------------------------------
172# parse_batch_response
173# ---------------------------------------------------------------------------
176def test_parse_batch_response_valid():
177 """Valid batch JSON array is parsed into suggestions."""
178 content = json.dumps(
179 [
180 {
181 "line": 5,
182 "code": "E501",
183 "original_code": "old",
184 "suggested_code": "new",
185 "explanation": "Fix",
186 "confidence": "high",
187 "risk_level": "safe-style",
188 },
189 ],
190 )
191 result = parse_batch_response(content, "test.py")
192 assert_that(result).is_length(1)
193 assert_that(result[0].line).is_equal_to(5)
194 assert_that(result[0].code).is_equal_to("E501")
195 assert_that(result[0].risk_level).is_equal_to("safe-style")
198def test_parse_batch_response_invalid_json():
199 """Invalid JSON returns empty list."""
200 result = parse_batch_response("not json", "test.py")
201 assert_that(result).is_empty()
204def test_parse_batch_response_not_array():
205 """Non-array JSON returns empty list."""
206 result = parse_batch_response('{"key": "value"}', "test.py")
207 assert_that(result).is_empty()
210def test_parse_batch_response_mixed_valid_and_invalid():
211 """Only valid items are returned; invalid items are skipped."""
212 content = json.dumps(
213 [
214 # Valid item
215 {
216 "line": 10,
217 "code": "E501",
218 "original_code": "old line",
219 "suggested_code": "new line",
220 "explanation": "Fix",
221 "confidence": "high",
222 "risk_level": "safe-style",
223 },
224 # Non-dict item (string)
225 "not a dict",
226 # Null item
227 None,
228 # Missing suggested_code
229 {
230 "line": 20,
231 "code": "E502",
232 "original_code": "code",
233 },
234 # Identical original and suggested
235 {
236 "line": 30,
237 "code": "E503",
238 "original_code": "same",
239 "suggested_code": "same",
240 },
241 # Non-string code fields
242 {
243 "line": 40,
244 "code": "E504",
245 "original_code": 123,
246 "suggested_code": ["list"],
247 },
248 ],
249 )
250 result = parse_batch_response(content, "test.py")
251 assert_that(result).is_length(1)
252 assert_that(result[0].line).is_equal_to(10)
253 assert_that(result[0].code).is_equal_to("E501")
254 assert_that(result[0].risk_level).is_equal_to("safe-style")
257def test_parse_batch_response_skips_identical_code():
258 """Items with identical original and suggested code are skipped."""
259 content = json.dumps(
260 [
261 {
262 "line": 1,
263 "code": "E501",
264 "original_code": "same",
265 "suggested_code": "same",
266 "explanation": "No change",
267 "confidence": "high",
268 },
269 ],
270 )
271 result = parse_batch_response(content, "test.py")
272 assert_that(result).is_empty()
275def test_parse_batch_response_coerces_line_and_code():
276 """Verify line is coerced to int and code to str from non-standard types."""
277 content = json.dumps(
278 [
279 {
280 "line": "7",
281 "code": 123,
282 "original_code": "old",
283 "suggested_code": "new",
284 },
285 {
286 "line": "notanint",
287 "code": None,
288 "original_code": "old2",
289 "suggested_code": "new2",
290 },
291 ],
292 )
293 result = parse_batch_response(content, "test.py")
294 assert_that(result).is_length(2)
295 # Numeric string coerced to int
296 assert_that(result[0].line).is_equal_to(7)
297 assert_that(result[0].code).is_equal_to("123")
298 # Non-numeric string falls back to 0
299 assert_that(result[1].line).is_equal_to(0)
300 assert_that(result[1].code).is_equal_to("None")