Coverage for tests / unit / ai / test_refinement.py: 100%
117 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 refinement module."""
3from __future__ import annotations
5from pathlib import Path
6from unittest.mock import MagicMock, patch
8from assertpy import assert_that
10from lintro.ai.models import AIFixSuggestion
11from lintro.ai.refinement import _revert_fix, refine_unverified_fixes
12from lintro.ai.validation import ValidationResult
15def _make_suggestion(**kwargs: object) -> AIFixSuggestion:
16 """Create a minimal AIFixSuggestion for tests."""
17 defaults = {
18 "file": "test.py",
19 "line": 10,
20 "code": "E001",
21 "original_code": "x = 1",
22 "suggested_code": "x = 2",
23 "tool_name": "ruff",
24 }
25 defaults.update(kwargs)
26 return AIFixSuggestion(**defaults) # type: ignore[arg-type]
29# -- _revert_fix -----------------------------------------------------------
32def test_revert_fix_calls_apply_fixes_with_reversed_suggestion(
33 tmp_path: Path,
34) -> None:
35 """_revert_fix creates a reverse suggestion and calls apply_fixes."""
36 suggestion = _make_suggestion(
37 original_code="old_code",
38 suggested_code="new_code",
39 )
41 with patch("lintro.ai.refinement.apply_fixes") as mock_apply:
42 mock_apply.return_value = [suggestion]
43 result = _revert_fix(suggestion, tmp_path)
45 assert_that(result).is_true()
46 mock_apply.assert_called_once()
47 # Check that the reverse suggestion swaps original and suggested code
48 call_args = mock_apply.call_args
49 reverse_suggestions = call_args[0][0]
50 assert_that(reverse_suggestions).is_length(1)
51 assert_that(reverse_suggestions[0].original_code).is_equal_to("new_code")
52 assert_that(reverse_suggestions[0].suggested_code).is_equal_to("old_code")
55def test_revert_fix_returns_false_when_apply_fails(tmp_path: Path) -> None:
56 """_revert_fix returns False when apply_fixes returns empty list."""
57 suggestion = _make_suggestion()
59 with patch("lintro.ai.refinement.apply_fixes") as mock_apply:
60 mock_apply.return_value = []
61 result = _revert_fix(suggestion, tmp_path)
63 assert_that(result).is_false()
66# -- refine_unverified_fixes -----------------------------------------------
69def test_refine_returns_empty_when_no_unverified_keys(tmp_path: Path) -> None:
70 """refine_unverified_fixes returns empty list when no detail matches."""
71 suggestion = _make_suggestion()
72 validation = ValidationResult(
73 verified=1,
74 unverified=0,
75 details=["[E001] test.py:10 — fix verified"],
76 )
77 provider = MagicMock()
78 ai_config = MagicMock()
79 ai_config.fallback_models = []
80 ai_config.max_retries = 0
81 ai_config.retry_base_delay = 1.0
82 ai_config.retry_max_delay = 30.0
83 ai_config.retry_backoff_factor = 2.0
85 refined, cost = refine_unverified_fixes(
86 applied_suggestions=[suggestion],
87 validation=validation,
88 provider=provider,
89 ai_config=ai_config,
90 workspace_root=tmp_path,
91 )
93 assert_that(refined).is_empty()
94 assert_that(cost).is_equal_to(0.0)
97def test_refine_parses_detail_strings_correctly(tmp_path: Path) -> None:
98 """Parses '[code] file:line - issue still present' details."""
99 suggestion = _make_suggestion(code="W123", line=42, file="src/main.py")
100 validation = ValidationResult(
101 verified=0,
102 unverified=1,
103 details=["[W123] src/main.py:42 — issue still present"],
104 )
106 provider = MagicMock()
107 ai_config = MagicMock()
108 ai_config.fallback_models = []
109 ai_config.max_retries = 0
110 ai_config.retry_base_delay = 1.0
111 ai_config.retry_max_delay = 30.0
112 ai_config.retry_backoff_factor = 2.0
113 ai_config.context_lines = 15
114 ai_config.max_tokens = 4096
115 ai_config.api_timeout = 60.0
116 ai_config.fix_search_radius = 5
118 with (
119 patch("lintro.ai.refinement._revert_fix") as mock_revert,
120 patch("lintro.ai.refinement.read_file_safely") as mock_read,
121 patch("lintro.ai.refinement.extract_context") as mock_ctx,
122 patch("lintro.ai.refinement.parse_fix_response") as mock_parse,
123 patch("lintro.ai.refinement.apply_fixes") as mock_apply,
124 ):
125 mock_revert.return_value = True
126 mock_read.return_value = "file content\n"
127 mock_ctx.return_value = ("context", 1, 10)
129 mock_response = MagicMock()
130 mock_response.content = "response content"
131 mock_response.input_tokens = 100
132 mock_response.output_tokens = 50
133 mock_response.cost_estimate = 0.001
134 provider.complete.return_value = mock_response
136 refined_sugg = _make_suggestion(code="W123", line=42)
137 refined_sugg.input_tokens = 100
138 refined_sugg.output_tokens = 50
139 refined_sugg.cost_estimate = 0.001
140 mock_parse.return_value = refined_sugg
141 mock_apply.return_value = [refined_sugg]
143 refined, cost = refine_unverified_fixes(
144 applied_suggestions=[suggestion],
145 validation=validation,
146 provider=provider,
147 ai_config=ai_config,
148 workspace_root=tmp_path,
149 )
151 assert_that(refined).is_length(1)
152 assert_that(cost).is_close_to(0.001, 0.0001)
155def test_refine_skips_when_revert_fails(tmp_path: Path) -> None:
156 """refine_unverified_fixes skips a suggestion when revert fails."""
157 suggestion = _make_suggestion(code="E001", line=10)
158 validation = ValidationResult(
159 verified=0,
160 unverified=1,
161 details=["[E001] test.py:10 — issue still present"],
162 )
164 provider = MagicMock()
165 ai_config = MagicMock()
166 ai_config.fallback_models = []
167 ai_config.max_retries = 0
168 ai_config.retry_base_delay = 1.0
169 ai_config.retry_max_delay = 30.0
170 ai_config.retry_backoff_factor = 2.0
172 with patch("lintro.ai.refinement._revert_fix") as mock_revert:
173 mock_revert.return_value = False
174 refined, cost = refine_unverified_fixes(
175 applied_suggestions=[suggestion],
176 validation=validation,
177 provider=provider,
178 ai_config=ai_config,
179 workspace_root=tmp_path,
180 )
182 assert_that(refined).is_empty()
183 assert_that(cost).is_equal_to(0.0)
186def test_refine_skips_when_parse_returns_none(tmp_path: Path) -> None:
187 """refine_unverified_fixes skips when _parse_fix_response returns None."""
188 suggestion = _make_suggestion(code="E001", line=10)
189 validation = ValidationResult(
190 verified=0,
191 unverified=1,
192 details=["[E001] test.py:10 — issue still present"],
193 )
195 provider = MagicMock()
196 ai_config = MagicMock()
197 ai_config.fallback_models = []
198 ai_config.max_retries = 0
199 ai_config.retry_base_delay = 1.0
200 ai_config.retry_max_delay = 30.0
201 ai_config.retry_backoff_factor = 2.0
202 ai_config.context_lines = 15
203 ai_config.max_tokens = 4096
204 ai_config.api_timeout = 60.0
205 ai_config.fix_search_radius = 5
207 with (
208 patch("lintro.ai.refinement._revert_fix") as mock_revert,
209 patch("lintro.ai.refinement.read_file_safely") as mock_read,
210 patch("lintro.ai.refinement.extract_context") as mock_ctx,
211 patch("lintro.ai.refinement.parse_fix_response") as mock_parse,
212 ):
213 mock_revert.return_value = True
214 mock_read.return_value = "file content\n"
215 mock_ctx.return_value = ("context", 1, 10)
217 mock_response = MagicMock()
218 mock_response.content = "response content"
219 mock_response.input_tokens = 100
220 mock_response.output_tokens = 50
221 mock_response.cost_estimate = 0.001
222 provider.complete.return_value = mock_response
224 mock_parse.return_value = None
226 refined, _cost = refine_unverified_fixes(
227 applied_suggestions=[suggestion],
228 validation=validation,
229 provider=provider,
230 ai_config=ai_config,
231 workspace_root=tmp_path,
232 )
234 assert_that(refined).is_empty()