Coverage for tests / unit / ai / test_interactive.py: 100%
164 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 interactive fix review."""
3from __future__ import annotations
5from unittest.mock import patch
7from assertpy import assert_that
9from lintro.ai.apply import _apply_fix, apply_fixes
10from lintro.ai.interactive import (
11 _group_by_code,
12 _render_prompt,
13 review_fixes_interactive,
14)
15from lintro.ai.models import AIFixSuggestion
17# -- _apply_fix no fallback ----------------------------------------------------
20def test_apply_fix_no_fallback_when_line_targeting_misses(tmp_path):
21 """When line-targeted replacement misses, fix fails (no fallback)."""
22 f = tmp_path / "test.py"
23 # File must be long enough that clamped target_idx (last line)
24 # plus search_radius (default 5) cannot reach line 0.
25 filler = "".join(f"line {i}\n" for i in range(2, 22))
26 f.write_text(f"old code\n{filler}")
28 fix = AIFixSuggestion(
29 file=str(f),
30 line=99, # Way off -- no match near this line
31 original_code="old code",
32 suggested_code="new code",
33 )
35 result = _apply_fix(fix, workspace_root=tmp_path)
36 assert_that(result).is_false()
37 # File should be unchanged -- no fallback replacement
38 assert_that(f.read_text()).contains("old code")
41@patch("lintro.ai.apply.logger")
42def test_apply_fix_line_targeted_does_not_log_warning(mock_logger, tmp_path):
43 """Successful line-targeted replacement should NOT log a warning."""
44 f = tmp_path / "test.py"
45 f.write_text("x = 1\nprint('ok')\n")
47 fix = AIFixSuggestion(
48 file=str(f),
49 line=1,
50 original_code="x = 1",
51 suggested_code="x = 2",
52 )
54 result = _apply_fix(fix, workspace_root=tmp_path)
55 assert_that(result).is_true()
56 mock_logger.warning.assert_not_called()
59# -- _apply_fix ----------------------------------------------------------------
62def test_apply_fix_applies_fix(tmp_path):
63 """Verify that a valid fix replaces the original code in the file."""
64 f = tmp_path / "test.py"
65 f.write_text("assert x > 0\nprint('ok')\n")
67 fix = AIFixSuggestion(
68 file=str(f),
69 line=1,
70 original_code="assert x > 0",
71 suggested_code="if x <= 0:\n raise ValueError",
72 )
74 result = _apply_fix(fix, workspace_root=tmp_path)
75 assert_that(result).is_true()
77 content = f.read_text()
78 assert_that(content).contains("if x <= 0:")
79 assert_that(content).does_not_contain("assert x > 0")
82def test_apply_fix_skips_when_original_not_found(tmp_path):
83 """_apply_fix returns False when original code is not found."""
84 f = tmp_path / "test.py"
85 f.write_text("x = 1\n")
87 fix = AIFixSuggestion(
88 file=str(f),
89 line=1,
90 original_code="nonexistent code",
91 suggested_code="new code",
92 )
94 result = _apply_fix(fix, workspace_root=tmp_path)
95 assert_that(result).is_false()
98def test_apply_fix_handles_missing_file(tmp_path):
99 """Verify that _apply_fix returns False for a nonexistent file path."""
100 fix = AIFixSuggestion(
101 file=str(tmp_path / "nonexistent" / "file.py"),
102 original_code="x",
103 suggested_code="y",
104 )
105 result = _apply_fix(fix, workspace_root=tmp_path)
106 assert_that(result).is_false()
109def test_apply_fix_line_targeted_replacement(tmp_path):
110 """Fix applies near the target line, not an earlier occurrence."""
111 f = tmp_path / "test.py"
112 f.write_text("x = 1\nprint('a')\nx = 1\nprint('b')\n")
114 fix = AIFixSuggestion(
115 file=str(f),
116 line=3,
117 original_code="x = 1",
118 suggested_code="x = 2",
119 )
121 result = _apply_fix(fix, workspace_root=tmp_path)
122 assert_that(result).is_true()
124 content = f.read_text()
125 lines = content.splitlines()
126 # First occurrence should remain unchanged
127 assert_that(lines[0]).is_equal_to("x = 1")
128 # Third line (line 3) should be changed
129 assert_that(lines[2]).is_equal_to("x = 2")
132def test_apply_fix_fails_when_line_targeting_misses(tmp_path):
133 """Returns False when line targeting misses (no fallback)."""
134 f = tmp_path / "test.py"
135 # File must be long enough that clamped target_idx (last line)
136 # plus default search_radius (5) cannot reach line 0.
137 filler = "".join(f"line {i}\n" for i in range(2, 22))
138 f.write_text(f"old code\n{filler}")
140 fix = AIFixSuggestion(
141 file=str(f),
142 line=99, # Way off -- no match near this line
143 original_code="old code",
144 suggested_code="new code",
145 )
147 result = _apply_fix(fix, workspace_root=tmp_path)
148 assert_that(result).is_false()
149 # File should remain unchanged
150 assert_that(f.read_text()).contains("old code")
153def test_apply_fix_empty_original_code(tmp_path):
154 """Verify that an empty original_code string causes _apply_fix to return False."""
155 f = tmp_path / "test.py"
156 f.write_text("x = 1\n")
158 fix = AIFixSuggestion(
159 file=str(f),
160 original_code="",
161 suggested_code="y = 2",
162 )
164 result = _apply_fix(fix, workspace_root=tmp_path)
165 assert_that(result).is_false()
168def test_apply_fix_blocks_writes_outside_workspace_root(tmp_path):
169 """Verify that fixes targeting files outside workspace_root are rejected."""
170 workspace_root = tmp_path / "workspace"
171 workspace_root.mkdir(parents=True)
172 outside_file = tmp_path / "outside.py"
173 outside_file.write_text("x = 1\n", encoding="utf-8")
175 fix = AIFixSuggestion(
176 file=str(outside_file),
177 original_code="x = 1",
178 suggested_code="x = 2",
179 )
181 result = _apply_fix(fix, workspace_root=workspace_root)
182 assert_that(result).is_false()
183 assert_that(outside_file.read_text(encoding="utf-8")).is_equal_to("x = 1\n")
186def test_apply_fix_fails_when_line_misses_with_flag(tmp_path):
187 """Fix fails when line targeting misses (auto_apply flag passed but unused)."""
188 f = tmp_path / "test.py"
189 # File must be long enough that clamped target_idx (last line)
190 # plus default search_radius (5) cannot reach line 0.
191 filler = "".join(f"line {i}\n" for i in range(2, 22))
192 f.write_text(f"old code\n{filler}")
194 fix = AIFixSuggestion(
195 file=str(f),
196 line=99, # Way off -- no match near this line
197 original_code="old code",
198 suggested_code="new code",
199 )
201 result = _apply_fix(fix, auto_apply=True, workspace_root=tmp_path)
202 assert_that(result).is_false()
203 # File should be unchanged
204 assert_that(f.read_text()).contains("old code")
207def test_apply_fix_search_radius_limits_search(tmp_path):
208 """A narrow search_radius can miss a match outside the radius."""
209 f = tmp_path / "test.py"
210 # Place target code far from line hint
211 lines = ["filler\n"] * 20 + ["target code\n"]
212 f.write_text("".join(lines))
214 fix = AIFixSuggestion(
215 file=str(f),
216 line=1, # Hint at line 1, target is at line 21
217 original_code="target code",
218 suggested_code="replaced code",
219 )
221 # With radius=2, line-targeted search won't reach line 21
222 result = _apply_fix(fix, auto_apply=True, search_radius=2, workspace_root=tmp_path)
223 assert_that(result).is_false()
224 assert_that(f.read_text()).contains("target code")
227# -- apply_fixes ---------------------------------------------------------------
230def test_apply_fixes_returns_only_successful(tmp_path):
231 """Verify that apply_fixes returns only successfully applied suggestions."""
232 f = tmp_path / "test.py"
233 f.write_text("x = 1\n")
235 applied = apply_fixes(
236 [
237 AIFixSuggestion(
238 file=str(f),
239 line=1,
240 original_code="x = 1",
241 suggested_code="x = 2",
242 ),
243 AIFixSuggestion(
244 file=str(f),
245 line=1,
246 original_code="missing",
247 suggested_code="x = 3",
248 ),
249 ],
250 workspace_root=tmp_path,
251 )
252 assert_that(applied).is_length(1)
253 assert_that(applied[0].suggested_code).is_equal_to("x = 2")
256def test_apply_fixes_with_auto_apply_flag_fails_when_line_misses(tmp_path):
257 """apply_fixes with auto_apply=True fails when line targeting misses."""
258 f = tmp_path / "test.py"
259 # File must be long enough that clamped target_idx (last line)
260 # plus default search_radius (5) cannot reach line 0.
261 filler = "".join(f"line {i}\n" for i in range(2, 22))
262 f.write_text(f"old code\n{filler}")
264 applied = apply_fixes(
265 [
266 AIFixSuggestion(
267 file=str(f),
268 line=99,
269 original_code="old code",
270 suggested_code="new code",
271 ),
272 ],
273 auto_apply=True,
274 workspace_root=tmp_path,
275 )
276 # auto_apply=True prevents fallback, so nothing should be applied
277 assert_that(applied).is_empty()
278 assert_that(f.read_text()).contains("old code")
281# -- _group_by_code ------------------------------------------------------------
284def test_group_by_code_groups_by_code():
285 """Verify that fixes are grouped into separate lists by their rule code."""
286 fixes = [
287 AIFixSuggestion(file="a.py", code="B101"),
288 AIFixSuggestion(file="b.py", code="B101"),
289 AIFixSuggestion(file="c.py", code="E501"),
290 ]
291 groups = _group_by_code(fixes)
292 assert_that(groups).contains_key("B101")
293 assert_that(groups).contains_key("E501")
294 assert_that(groups["B101"]).is_length(2)
295 assert_that(groups["E501"]).is_length(1)
298def test_group_by_code_empty_code_uses_unknown():
299 """Verify that an empty code string is grouped under the 'unknown' key."""
300 fixes = [AIFixSuggestion(file="a.py", code="")]
301 groups = _group_by_code(fixes)
302 assert_that(groups).contains_key("unknown")
305def test_group_by_code_empty_list():
306 """Verify that an empty fix list produces an empty grouping."""
307 groups = _group_by_code([])
308 assert_that(groups).is_empty()
311# -- review_fixes_interactive --------------------------------------------------
314def test_review_fixes_interactive_empty_suggestions(tmp_path):
315 """Verify that empty suggestions result in zero accepted, rejected, and applied."""
316 accepted, rejected, applied = review_fixes_interactive(
317 [],
318 workspace_root=tmp_path,
319 )
320 assert_that(accepted).is_equal_to(0)
321 assert_that(rejected).is_equal_to(0)
322 assert_that(applied).is_empty()
325def test_review_fixes_interactive_non_interactive_skips(tmp_path):
326 """Verify that non-interactive stdin causes the review to be skipped."""
327 fixes = [
328 AIFixSuggestion(
329 file="test.py",
330 original_code="x",
331 suggested_code="y",
332 ),
333 ]
334 with patch("sys.stdin") as mock_stdin:
335 mock_stdin.isatty.return_value = False
336 accepted, _rejected, applied = review_fixes_interactive(
337 fixes,
338 workspace_root=tmp_path,
339 )
340 assert_that(accepted).is_equal_to(0)
341 assert_that(applied).is_empty()
344def test_review_fixes_interactive_prompt_text_clarifies_scope():
345 """Verify that the rendered prompt includes scope clarification text."""
346 prompt = _render_prompt(validate_mode=False, safe_default=False)
347 assert_that(prompt).contains("accept group + remaining")
348 assert_that(prompt).contains("verify fixes")
351@patch("lintro.ai.interactive.sys.stdin")
352@patch("lintro.ai.interactive.click.getchar")
353def test_review_fixes_interactive_accept_via_keyboard(
354 mock_getchar,
355 mock_stdin,
356 tmp_path,
357):
358 """Pressing 'y' accepts a group and applies fixes."""
359 mock_stdin.isatty.return_value = True
360 mock_getchar.return_value = "y"
362 f = tmp_path / "test.py"
363 f.write_text("old_code\n")
365 fixes = [
366 AIFixSuggestion(
367 file=str(f),
368 line=1,
369 code="E501",
370 original_code="old_code",
371 suggested_code="new_code",
372 ),
373 ]
375 accepted, rejected, applied = review_fixes_interactive(
376 fixes,
377 workspace_root=tmp_path,
378 )
380 assert_that(accepted).is_equal_to(1)
381 assert_that(rejected).is_equal_to(0)
382 assert_that(applied).is_length(1)
385@patch("lintro.ai.interactive.sys.stdin")
386@patch("lintro.ai.interactive.click.getchar")
387def test_review_fixes_interactive_reject_via_keyboard(
388 mock_getchar,
389 mock_stdin,
390 tmp_path,
391):
392 """Pressing 'r' rejects a group."""
393 mock_stdin.isatty.return_value = True
394 mock_getchar.return_value = "r"
396 fixes = [
397 AIFixSuggestion(
398 file=str(tmp_path / "test.py"),
399 code="B101",
400 original_code="x",
401 suggested_code="y",
402 ),
403 ]
405 accepted, rejected, applied = review_fixes_interactive(
406 fixes,
407 workspace_root=tmp_path,
408 )
410 assert_that(accepted).is_equal_to(0)
411 assert_that(rejected).is_equal_to(1)
412 assert_that(applied).is_empty()
415@patch("lintro.ai.interactive.sys.stdin")
416@patch("lintro.ai.interactive.click.getchar")
417def test_review_fixes_interactive_quit_via_keyboard(
418 mock_getchar,
419 mock_stdin,
420 tmp_path,
421):
422 """Pressing 'q' quits the review early."""
423 mock_stdin.isatty.return_value = True
424 mock_getchar.return_value = "q"
426 fixes = [
427 AIFixSuggestion(
428 file=str(tmp_path / "a.py"),
429 code="B101",
430 original_code="x",
431 suggested_code="y",
432 ),
433 AIFixSuggestion(
434 file=str(tmp_path / "b.py"),
435 code="E501",
436 original_code="a",
437 suggested_code="b",
438 ),
439 ]
441 accepted, _rejected, applied = review_fixes_interactive(
442 fixes,
443 workspace_root=tmp_path,
444 )
446 assert_that(accepted).is_equal_to(0)
447 # Only the first group was seen before quit
448 assert_that(applied).is_empty()