Coverage for tests / unit / ai / test_fix_generation_basic.py: 96%
139 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 basic fix generation.
3Covers generate_fixes single-issue paths, prompt construction,
4path resolution, and ToolResult.cwd field.
5"""
7from __future__ import annotations
9import json
10import os
12import pytest
13from assertpy import assert_that
15from lintro.ai.fix import (
16 generate_fixes,
17)
18from lintro.ai.providers.base import AIResponse
19from lintro.models.core.tool_result import ToolResult
20from tests.unit.ai.conftest import MockAIProvider, MockIssue
22# ---------------------------------------------------------------------------
23# Fixtures
24# ---------------------------------------------------------------------------
27@pytest.fixture
28def source_file(tmp_path):
29 """Create a minimal Python source file and return its path."""
30 f = tmp_path / "test.py"
31 f.write_text("x = 1\n")
32 return f
35@pytest.fixture
36def single_issue(source_file):
37 """Return a single MockIssue pointing at the source file."""
38 return MockIssue(
39 file=str(source_file),
40 line=1,
41 code="B101",
42 message="test",
43 )
46# ---------------------------------------------------------------------------
47# generate_fixes
48# ---------------------------------------------------------------------------
51def test_generate_fixes_empty_issues(mock_provider):
52 """Verify that an empty issue list returns an empty result."""
53 result = generate_fixes(
54 [],
55 mock_provider,
56 tool_name="ruff",
57 )
58 assert_that(result).is_empty()
61def test_generate_fixes_generates_fixes_for_unfixable(tmp_path):
62 """Unfixable issues are sent to the AI and produce suggestions."""
63 source = tmp_path / "test.py"
64 source.write_text("assert x > 0\nprint('hello')\n")
66 issue = MockIssue(
67 file=str(source),
68 line=1,
69 code="B101",
70 message="Use of assert",
71 fixable=False,
72 )
74 response = AIResponse(
75 content=json.dumps(
76 {
77 "original_code": "assert x > 0",
78 "suggested_code": "if x <= 0:\n raise ValueError",
79 "explanation": "Replace assert",
80 "confidence": "high",
81 },
82 ),
83 model="mock",
84 input_tokens=100,
85 output_tokens=50,
86 cost_estimate=0.001,
87 provider="mock",
88 )
89 provider = MockAIProvider(responses=[response])
91 result = generate_fixes(
92 [issue],
93 provider,
94 tool_name="bandit",
95 workspace_root=tmp_path,
96 )
98 assert_that(result).is_length(1)
99 assert_that(result[0].code).is_equal_to("B101")
100 assert_that(result[0].diff).is_not_empty()
103def test_generate_fixes_processes_fixable_issues(tmp_path):
104 """AI should attempt fixes for ALL issues, including fixable ones."""
105 source = tmp_path / "test.py"
106 source.write_text("x = 1\n")
108 issue = MockIssue(
109 file=str(source),
110 line=1,
111 code="E501",
112 message="Line too long",
113 fixable=True,
114 )
116 provider = MockAIProvider()
117 generate_fixes(
118 [issue],
119 provider,
120 tool_name="ruff",
121 workspace_root=tmp_path,
122 )
124 assert_that(provider.calls).is_not_empty()
127def test_generate_fixes_skips_issues_without_file(mock_provider):
128 """Verify that issues without a file path are skipped."""
129 issue = MockIssue(line=1, code="B101", message="test")
130 result = generate_fixes(
131 [issue],
132 mock_provider,
133 tool_name="ruff",
134 )
135 assert_that(result).is_empty()
138def test_generate_fixes_respects_max_issues(tmp_path):
139 """Verify that the max_issues parameter limits the number of provider calls."""
140 # Use separate files so batching does not group them
141 sources = []
142 for i in range(1, 6):
143 f = tmp_path / f"test{i}.py"
144 f.write_text("x = 1\n" * 50)
145 sources.append(f)
147 issues = [
148 MockIssue(
149 file=str(sources[i - 1]),
150 line=1,
151 code="B101",
152 message="test",
153 )
154 for i in range(1, 6)
155 ]
157 provider = MockAIProvider()
158 generate_fixes(
159 issues,
160 provider,
161 tool_name="ruff",
162 max_issues=2,
163 workspace_root=tmp_path,
164 )
166 assert_that(provider.calls).is_length(2)
169def test_generate_fixes_provider_prompt_uses_workspace_relative_path(tmp_path):
170 """Provider prompt contains workspace-relative paths, not absolute."""
171 source = tmp_path / "src" / "service.py"
172 source.parent.mkdir(parents=True)
173 source.write_text("assert ready\n", encoding="utf-8")
175 issue = MockIssue(
176 file=str(source),
177 line=1,
178 code="B101",
179 message="Use of assert",
180 )
182 response = AIResponse(
183 content=json.dumps(
184 {
185 "original_code": "assert ready",
186 "suggested_code": "if not ready:\n raise ValueError",
187 "explanation": "Replace assert",
188 "confidence": "high",
189 },
190 ),
191 model="mock",
192 input_tokens=10,
193 output_tokens=10,
194 cost_estimate=0.001,
195 provider="mock",
196 )
197 provider = MockAIProvider(responses=[response])
199 generate_fixes(
200 [issue],
201 provider,
202 tool_name="ruff",
203 workspace_root=tmp_path,
204 max_tokens=333,
205 )
207 assert_that(provider.calls).is_length(1)
208 prompt = provider.calls[0]["prompt"]
209 assert_that(prompt).contains("File: src/service.py")
210 assert_that(prompt).does_not_contain(str(source))
211 assert_that(provider.calls[0]["max_tokens"]).is_equal_to(333)
214def test_generate_fixes_skips_issue_outside_workspace_root(tmp_path):
215 """Verify that issues with files outside the workspace root are skipped."""
216 outside = tmp_path.parent / "outside.py"
217 outside.write_text("assert x\n", encoding="utf-8")
219 issue = MockIssue(
220 file=str(outside),
221 line=1,
222 code="B101",
223 message="Use of assert",
224 )
226 provider = MockAIProvider()
227 result = generate_fixes(
228 [issue],
229 provider,
230 tool_name="ruff",
231 workspace_root=tmp_path,
232 )
234 assert_that(provider.calls).is_empty()
235 assert_that(result).is_empty()
238# ---------------------------------------------------------------------------
239# Timeout propagation
240# ---------------------------------------------------------------------------
243def test_timeout_reaches_provider(tmp_path):
244 """Custom timeout value is passed through to provider.complete()."""
245 source = tmp_path / "test.py"
246 source.write_text("x = 1\n")
248 issue = MockIssue(
249 file=str(source),
250 line=1,
251 code="B101",
252 message="test",
253 )
255 provider = MockAIProvider()
256 generate_fixes(
257 [issue],
258 provider,
259 tool_name="ruff",
260 workspace_root=tmp_path,
261 timeout=120.0,
262 )
264 assert_that(provider.calls).is_length(1)
265 assert_that(provider.calls[0]["timeout"]).is_equal_to(120.0)
268def test_default_timeout_is_60(tmp_path):
269 """Default timeout (60s) is used when no custom value is provided."""
270 source = tmp_path / "test.py"
271 source.write_text("x = 1\n")
273 issue = MockIssue(
274 file=str(source),
275 line=1,
276 code="B101",
277 message="test",
278 )
280 provider = MockAIProvider()
281 generate_fixes(
282 [issue],
283 provider,
284 tool_name="ruff",
285 workspace_root=tmp_path,
286 )
288 assert_that(provider.calls).is_length(1)
289 assert_that(provider.calls[0]["timeout"]).is_equal_to(60.0)
292# ---------------------------------------------------------------------------
293# ToolResult.cwd field
294# ---------------------------------------------------------------------------
297def test_tool_result_cwd_defaults_to_none():
298 """Verify that ToolResult.cwd defaults to None when not specified."""
299 result = ToolResult(name="test", success=True)
300 assert_that(result.cwd).is_none()
303def test_tool_result_cwd_preserves_value():
304 """Verify that ToolResult.cwd preserves the value passed at construction."""
305 result = ToolResult(name="test", success=True, cwd="/some/path")
306 assert_that(result.cwd).is_equal_to("/some/path")
309# ---------------------------------------------------------------------------
310# Relative path resolution (ToolResult.cwd)
311# ---------------------------------------------------------------------------
314def test_resolves_relative_paths_with_cwd(tmp_path):
315 """Issues with relative paths should be resolved using result.cwd."""
316 tool_cwd = tmp_path / "test_samples" / "tools"
317 js_dir = tool_cwd / "javascript" / "oxlint"
318 js_dir.mkdir(parents=True)
319 source = js_dir / "violations.js"
320 source.write_text("var x = 1;\n")
322 issue = MockIssue(
323 file="javascript/oxlint/violations.js",
324 line=1,
325 code="no-var",
326 message="Unexpected var",
327 )
329 cwd = str(tool_cwd)
330 if not os.path.isabs(issue.file):
331 issue.file = os.path.join(cwd, issue.file)
333 provider = MockAIProvider()
334 generate_fixes(
335 [issue],
336 provider,
337 tool_name="oxlint",
338 workspace_root=tmp_path,
339 )
341 assert_that(provider.calls).is_length(1)
344def test_absolute_paths_unchanged_by_resolution():
345 """Absolute paths should not be modified by path resolution."""
346 issue = MockIssue(
347 file="/absolute/path/to/file.py",
348 line=1,
349 code="B101",
350 message="test",
351 )
353 cwd = "/some/other/dir"
354 if not os.path.isabs(issue.file):
355 issue.file = os.path.join(cwd, issue.file)
357 assert_that(issue.file).is_equal_to("/absolute/path/to/file.py")
360def test_no_resolution_when_cwd_is_none():
361 """When cwd is None, relative paths should remain unchanged."""
362 issue = MockIssue(
363 file="relative/path/file.js",
364 line=1,
365 code="no-var",
366 message="test",
367 )
369 cwd = None
370 if cwd and not os.path.isabs(issue.file):
371 issue.file = os.path.join(cwd, issue.file)
373 assert_that(issue.file).is_equal_to("relative/path/file.js")
376def test_generate_fixes_skips_issues_with_unreadable_relative_paths(tmp_path):
377 """Relative paths not resolvable from CWD are silently skipped."""
378 subdir = tmp_path / "tools" / "js"
379 subdir.mkdir(parents=True)
380 source = subdir / "test.js"
381 source.write_text("var x = 1;\n")
383 issue = MockIssue(
384 file="js/test.js",
385 line=1,
386 code="no-var",
387 message="Unexpected var",
388 )
390 provider = MockAIProvider()
391 result = generate_fixes(
392 [issue],
393 provider,
394 tool_name="oxlint",
395 )
397 assert_that(provider.calls).is_empty()
398 assert_that(result).is_empty()
401def test_single_issue_file_not_batched(tmp_path):
402 """A file with only 1 issue should use the single-issue path."""
403 source = tmp_path / "single.py"
404 source.write_text("x = 1\n")
406 issue = MockIssue(
407 file=str(source),
408 line=1,
409 code="B101",
410 message="test",
411 )
413 provider = MockAIProvider()
414 generate_fixes(
415 [issue],
416 provider,
417 tool_name="ruff",
418 workspace_root=tmp_path,
419 )
421 assert_that(provider.calls).is_length(1)
422 prompt = provider.calls[0]["prompt"]
423 # Single-issue prompt, not batch
424 assert_that(prompt).does_not_contain("JSON array")
425 assert_that(prompt).contains("Error code: B101")