Coverage for tests / unit / ai / test_apply.py: 100%
207 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 AI fix application logic (lintro.ai.apply)."""
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.models import AIFixSuggestion
12# ---------------------------------------------------------------------------
13# Line-targeted replacement — exact line
14# ---------------------------------------------------------------------------
17def test_apply_fix_exact_line_match(tmp_path):
18 """Fix applies when original_code is on exactly the reported line."""
19 f = tmp_path / "test.py"
20 f.write_text("a = 1\nb = 2\nc = 3\n")
22 fix = AIFixSuggestion(
23 file=str(f),
24 line=2,
25 original_code="b = 2",
26 suggested_code="b = 42",
27 )
29 result = _apply_fix(fix, workspace_root=tmp_path)
30 assert_that(result).is_true()
31 assert_that(f.read_text()).is_equal_to("a = 1\nb = 42\nc = 3\n")
34# ---------------------------------------------------------------------------
35# Line-targeted replacement — adjacent lines within radius
36# ---------------------------------------------------------------------------
39def test_apply_fix_adjacent_line_within_radius(tmp_path):
40 """Fix succeeds when original_code is a few lines off the hint."""
41 f = tmp_path / "test.py"
42 content = "line1\nline2\ntarget\nline4\nline5\n"
43 f.write_text(content)
45 fix = AIFixSuggestion(
46 file=str(f),
47 line=5, # hint is off by 2
48 original_code="target",
49 suggested_code="replaced",
50 )
52 result = _apply_fix(fix, workspace_root=tmp_path)
53 assert_that(result).is_true()
54 assert_that(f.read_text()).contains("replaced")
55 assert_that(f.read_text()).does_not_contain("target")
58def test_apply_fix_prefers_closest_occurrence(tmp_path):
59 """When duplicate code exists, the occurrence closest to hint wins."""
60 f = tmp_path / "test.py"
61 f.write_text("x = 1\nfiller\nfiller\nfiller\nx = 1\n")
63 fix = AIFixSuggestion(
64 file=str(f),
65 line=5,
66 original_code="x = 1",
67 suggested_code="x = 99",
68 )
70 result = _apply_fix(fix, workspace_root=tmp_path)
71 assert_that(result).is_true()
73 lines = f.read_text().splitlines()
74 # First occurrence untouched, last one replaced
75 assert_that(lines[0]).is_equal_to("x = 1")
76 assert_that(lines[4]).is_equal_to("x = 99")
79# ---------------------------------------------------------------------------
80# Search radius limiting
81# ---------------------------------------------------------------------------
84def test_apply_fix_search_radius_1_limits_search(tmp_path):
85 """Radius=1 only checks target line and one line above/below."""
86 f = tmp_path / "test.py"
87 f.write_text("a\nb\nc\nd\ne\n")
89 fix = AIFixSuggestion(
90 file=str(f),
91 line=1, # hint at line 1, target at line 5
92 original_code="e",
93 suggested_code="E",
94 )
96 result = _apply_fix(fix, workspace_root=tmp_path, search_radius=1)
97 assert_that(result).is_false()
98 assert_that(f.read_text()).contains("e")
101def test_apply_fix_large_radius_finds_distant_match(tmp_path):
102 """A large radius can reach code far from the hint."""
103 lines = ["filler\n"] * 10 + ["target\n"]
104 f = tmp_path / "test.py"
105 f.write_text("".join(lines))
107 fix = AIFixSuggestion(
108 file=str(f),
109 line=5,
110 original_code="target",
111 suggested_code="replaced",
112 )
114 result = _apply_fix(fix, workspace_root=tmp_path, search_radius=10)
115 assert_that(result).is_true()
116 assert_that(f.read_text()).contains("replaced")
119# ---------------------------------------------------------------------------
120# Workspace boundary enforcement
121# ---------------------------------------------------------------------------
124def test_apply_fix_rejects_symlink_escape(tmp_path):
125 """Fix targeting a symlink that resolves outside workspace is rejected."""
126 workspace = tmp_path / "workspace"
127 workspace.mkdir()
128 outside = tmp_path / "outside.py"
129 outside.write_text("x = 1\n")
131 link = workspace / "link.py"
132 link.symlink_to(outside)
134 fix = AIFixSuggestion(
135 file=str(link),
136 line=1,
137 original_code="x = 1",
138 suggested_code="x = 2",
139 )
141 result = _apply_fix(fix, workspace_root=workspace)
142 assert_that(result).is_false()
143 assert_that(outside.read_text()).is_equal_to("x = 1\n")
146def test_apply_fix_rejects_parent_traversal(tmp_path):
147 """Fix with '../' traversal outside workspace is rejected."""
148 workspace = tmp_path / "workspace"
149 workspace.mkdir()
150 outside = tmp_path / "outside.py"
151 outside.write_text("x = 1\n")
153 fix = AIFixSuggestion(
154 file=str(workspace / ".." / "outside.py"),
155 line=1,
156 original_code="x = 1",
157 suggested_code="x = 2",
158 )
160 result = _apply_fix(fix, workspace_root=workspace)
161 assert_that(result).is_false()
162 assert_that(outside.read_text()).is_equal_to("x = 1\n")
165def test_apply_fix_accepts_file_inside_workspace(tmp_path):
166 """Fix targeting a file inside workspace succeeds normally."""
167 workspace = tmp_path / "workspace"
168 workspace.mkdir()
169 f = workspace / "ok.py"
170 f.write_text("x = 1\n")
172 fix = AIFixSuggestion(
173 file=str(f),
174 line=1,
175 original_code="x = 1",
176 suggested_code="x = 2",
177 )
179 result = _apply_fix(fix, workspace_root=workspace)
180 assert_that(result).is_true()
181 assert_that(f.read_text()).is_equal_to("x = 2\n")
184# ---------------------------------------------------------------------------
185# Empty original_code handling
186# ---------------------------------------------------------------------------
189def test_apply_fix_empty_original_code_returns_false(tmp_path):
190 """An empty original_code causes _apply_fix to return False."""
191 f = tmp_path / "test.py"
192 f.write_text("x = 1\n")
194 fix = AIFixSuggestion(
195 file=str(f),
196 line=1,
197 original_code="",
198 suggested_code="y = 2",
199 )
201 result = _apply_fix(fix, workspace_root=tmp_path)
202 assert_that(result).is_false()
203 assert_that(f.read_text()).is_equal_to("x = 1\n")
206def test_apply_fix_whitespace_only_original_code(tmp_path):
207 """Whitespace-only original_code does not match typical code lines."""
208 f = tmp_path / "test.py"
209 f.write_text("x = 1\ny = 2\n")
211 fix = AIFixSuggestion(
212 file=str(f),
213 line=1,
214 original_code=" ",
215 suggested_code="z = 3",
216 )
218 result = _apply_fix(fix, workspace_root=tmp_path)
219 assert_that(result).is_false()
222# ---------------------------------------------------------------------------
223# Missing file handling
224# ---------------------------------------------------------------------------
227def test_apply_fix_missing_file_returns_false(tmp_path):
228 """_apply_fix returns False for a nonexistent file path."""
229 fix = AIFixSuggestion(
230 file=str(tmp_path / "nonexistent" / "file.py"),
231 line=1,
232 original_code="x",
233 suggested_code="y",
234 )
236 result = _apply_fix(fix, workspace_root=tmp_path)
237 assert_that(result).is_false()
240def test_apply_fix_empty_file_path_returns_false(tmp_path):
241 """_apply_fix returns False when file path is empty string."""
242 fix = AIFixSuggestion(
243 file="",
244 line=1,
245 original_code="x",
246 suggested_code="y",
247 )
249 result = _apply_fix(fix, workspace_root=tmp_path)
250 assert_that(result).is_false()
253# ---------------------------------------------------------------------------
254# Multi-line replacement
255# ---------------------------------------------------------------------------
258def test_apply_fix_multi_line_original_and_suggested(tmp_path):
259 """Multi-line original is replaced by multi-line suggested code."""
260 f = tmp_path / "test.py"
261 f.write_text("if True:\n x = 1\n y = 2\nprint('done')\n")
263 fix = AIFixSuggestion(
264 file=str(f),
265 line=2,
266 original_code=" x = 1\n y = 2",
267 suggested_code=" x = 10\n y = 20\n z = 30",
268 )
270 result = _apply_fix(fix, workspace_root=tmp_path)
271 assert_that(result).is_true()
273 content = f.read_text()
274 assert_that(content).contains("x = 10")
275 assert_that(content).contains("y = 20")
276 assert_that(content).contains("z = 30")
277 assert_that(content).does_not_contain("x = 1\n")
278 assert_that(content).contains("print('done')")
281def test_apply_fix_multi_line_to_single_line(tmp_path):
282 """Multi-line original replaced by single line (fewer lines)."""
283 f = tmp_path / "test.py"
284 f.write_text("a = 1\nb = 2\nc = 3\nd = 4\n")
286 fix = AIFixSuggestion(
287 file=str(f),
288 line=2,
289 original_code="b = 2\nc = 3",
290 suggested_code="bc = 23",
291 )
293 result = _apply_fix(fix, workspace_root=tmp_path)
294 assert_that(result).is_true()
296 content = f.read_text()
297 assert_that(content).contains("bc = 23")
298 assert_that(content).contains("a = 1")
299 assert_that(content).contains("d = 4")
302def test_apply_fix_single_line_to_multi_line(tmp_path):
303 """Single-line original expands to multiple lines."""
304 f = tmp_path / "test.py"
305 f.write_text("x = compute()\n")
307 fix = AIFixSuggestion(
308 file=str(f),
309 line=1,
310 original_code="x = compute()",
311 suggested_code="try:\n x = compute()\nexcept Exception:\n x = None",
312 )
314 result = _apply_fix(fix, workspace_root=tmp_path)
315 assert_that(result).is_true()
317 content = f.read_text()
318 assert_that(content).contains("try:")
319 assert_that(content).contains("except Exception:")
322# ---------------------------------------------------------------------------
323# Newline handling edge cases
324# ---------------------------------------------------------------------------
327def test_apply_fix_file_without_trailing_newline(tmp_path):
328 """Fix works on files that do not end with a trailing newline."""
329 f = tmp_path / "test.py"
330 f.write_text("x = 1") # no trailing newline
332 fix = AIFixSuggestion(
333 file=str(f),
334 line=1,
335 original_code="x = 1",
336 suggested_code="x = 2",
337 )
339 result = _apply_fix(fix, workspace_root=tmp_path)
340 assert_that(result).is_true()
341 assert_that(f.read_text()).contains("x = 2")
344def test_apply_fix_preserves_other_lines_newlines(tmp_path):
345 """Lines not involved in the fix retain their newlines."""
346 f = tmp_path / "test.py"
347 f.write_text("a = 1\nb = 2\nc = 3\n")
349 fix = AIFixSuggestion(
350 file=str(f),
351 line=2,
352 original_code="b = 2",
353 suggested_code="b = 99",
354 )
356 result = _apply_fix(fix, workspace_root=tmp_path)
357 assert_that(result).is_true()
358 assert_that(f.read_text()).is_equal_to("a = 1\nb = 99\nc = 3\n")
361# ---------------------------------------------------------------------------
362# Invalid / negative line numbers
363# ---------------------------------------------------------------------------
366def test_apply_fix_negative_line_returns_false(tmp_path):
367 """Negative line number returns False."""
368 f = tmp_path / "test.py"
369 f.write_text("x = 1\n")
371 fix = AIFixSuggestion(
372 file=str(f),
373 line=-1,
374 original_code="x = 1",
375 suggested_code="x = 2",
376 )
378 result = _apply_fix(fix, workspace_root=tmp_path)
379 assert_that(result).is_false()
382def test_apply_fix_line_zero_returns_false(tmp_path):
383 """Line 0 means 'unspecified' — search_order is empty, returns False."""
384 f = tmp_path / "test.py"
385 f.write_text("x = 1\n")
387 fix = AIFixSuggestion(
388 file=str(f),
389 line=0,
390 original_code="x = 1",
391 suggested_code="x = 2",
392 )
394 result = _apply_fix(fix, workspace_root=tmp_path)
395 assert_that(result).is_false()
398# ---------------------------------------------------------------------------
399# Line number far beyond file length (clamping)
400# ---------------------------------------------------------------------------
403def test_apply_fix_line_beyond_file_length_clamps(tmp_path):
404 """Line number beyond EOF is clamped; code near end is still found."""
405 f = tmp_path / "test.py"
406 f.write_text("a\nb\ntarget\n")
408 fix = AIFixSuggestion(
409 file=str(f),
410 line=999,
411 original_code="target",
412 suggested_code="replaced",
413 )
415 # default radius=5 should cover the 3-line file from the clamped position
416 result = _apply_fix(fix, workspace_root=tmp_path)
417 assert_that(result).is_true()
418 assert_that(f.read_text()).contains("replaced")
421# ---------------------------------------------------------------------------
422# apply_fixes — batch behaviour
423# ---------------------------------------------------------------------------
426def test_apply_fixes_returns_only_successful(tmp_path):
427 """apply_fixes returns only successfully applied suggestions."""
428 f = tmp_path / "test.py"
429 f.write_text("x = 1\ny = 2\n")
431 fixes = [
432 AIFixSuggestion(
433 file=str(f),
434 line=1,
435 original_code="x = 1",
436 suggested_code="x = 10",
437 ),
438 AIFixSuggestion(
439 file=str(f),
440 line=2,
441 original_code="MISSING",
442 suggested_code="z = 3",
443 ),
444 ]
446 applied = apply_fixes(fixes, workspace_root=tmp_path)
447 assert_that(applied).is_length(1)
448 assert_that(applied[0].suggested_code).is_equal_to("x = 10")
451def test_apply_fixes_empty_list(tmp_path):
452 """apply_fixes with an empty list returns an empty list."""
453 applied = apply_fixes([], workspace_root=tmp_path)
454 assert_that(applied).is_empty()
457def test_apply_fixes_all_fail(tmp_path):
458 """apply_fixes returns empty when all fixes fail."""
459 f = tmp_path / "test.py"
460 f.write_text("x = 1\n")
462 fixes = [
463 AIFixSuggestion(
464 file=str(f),
465 line=1,
466 original_code="NOPE",
467 suggested_code="y",
468 ),
469 AIFixSuggestion(
470 file=str(f),
471 line=1,
472 original_code="ALSO_NOPE",
473 suggested_code="z",
474 ),
475 ]
477 applied = apply_fixes(fixes, workspace_root=tmp_path)
478 assert_that(applied).is_empty()
481def test_apply_fixes_forwards_search_radius(tmp_path):
482 """apply_fixes passes search_radius through to _apply_fix."""
483 f = tmp_path / "test.py"
484 lines = ["filler\n"] * 20 + ["target\n"]
485 f.write_text("".join(lines))
487 fixes = [
488 AIFixSuggestion(
489 file=str(f),
490 line=1,
491 original_code="target",
492 suggested_code="replaced",
493 ),
494 ]
496 # radius=2 won't reach line 21 from line 1
497 applied = apply_fixes(fixes, workspace_root=tmp_path, search_radius=2)
498 assert_that(applied).is_empty()
499 assert_that(f.read_text()).contains("target")
502def test_apply_fixes_forwards_auto_apply(tmp_path):
503 """apply_fixes passes auto_apply through to _apply_fix."""
504 f = tmp_path / "test.py"
505 f.write_text("old code\nline 2\n")
507 fixes = [
508 AIFixSuggestion(
509 file=str(f),
510 line=1,
511 original_code="old code",
512 suggested_code="new code",
513 ),
514 ]
516 with patch("lintro.ai.apply._apply_fix", return_value=True) as mock:
517 apply_fixes(fixes, auto_apply=True, workspace_root=tmp_path)
518 mock.assert_called_once()
519 assert_that(mock.call_args.kwargs["auto_apply"]).is_true()
522# ---------------------------------------------------------------------------
523# Logging behaviour
524# ---------------------------------------------------------------------------
527@patch("lintro.ai.apply.logger")
528def test_apply_fix_logs_debug_for_invalid_line(mock_logger, tmp_path):
529 """Invalid (non-int-like) line triggers a debug log, not a crash."""
530 f = tmp_path / "test.py"
531 f.write_text("x = 1\n")
533 fix = AIFixSuggestion(
534 file=str(f),
535 line=-5,
536 original_code="x = 1",
537 suggested_code="x = 2",
538 )
540 result = _apply_fix(fix, workspace_root=tmp_path)
541 assert_that(result).is_false()
542 mock_logger.debug.assert_called_once()
545@patch("lintro.ai.apply.logger")
546def test_apply_fix_successful_no_warning(mock_logger, tmp_path):
547 """Successful line-targeted replacement logs no warning."""
548 f = tmp_path / "test.py"
549 f.write_text("x = 1\n")
551 fix = AIFixSuggestion(
552 file=str(f),
553 line=1,
554 original_code="x = 1",
555 suggested_code="x = 2",
556 )
558 result = _apply_fix(fix, workspace_root=tmp_path)
559 assert_that(result).is_true()
560 mock_logger.warning.assert_not_called()