Coverage for tests / unit / ai / test_validation_applied.py: 100%
151 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 validate_applied_fixes and _run_tool_check.
3Covers the validate_applied_fixes function and its helper _run_tool_check,
4including matching logic, tool grouping, path resolution, and new issues tracking.
5"""
7from __future__ import annotations
9import os
10from unittest.mock import MagicMock, patch
12from assertpy import assert_that
14from lintro.ai.models import AIFixSuggestion
15from lintro.ai.validation import (
16 validate_applied_fixes,
17)
18from lintro.models.core.tool_result import ToolResult
21def _make_suggestion(
22 *,
23 file: str = "src/main.py",
24 line: int = 10,
25 code: str = "B101",
26 tool_name: str = "ruff",
27) -> AIFixSuggestion:
28 """Create an AIFixSuggestion for testing."""
29 return AIFixSuggestion(
30 file=file,
31 line=line,
32 code=code,
33 tool_name=tool_name,
34 original_code="assert x",
35 suggested_code="if not x: raise",
36 explanation="Replace assert",
37 )
40# -- validate_applied_fixes ---------------------------------------------------
43def test_validate_applied_fixes_returns_none_for_empty():
44 """Verify validation returns None when given an empty suggestions list."""
45 result = validate_applied_fixes([])
46 assert_that(result).is_none()
49@patch("lintro.ai.validation._run_tool_check")
50def test_validate_applied_fixes_verified_when_issue_gone(mock_check):
51 """Verify a fix is marked as verified when the tool reports no remaining issues."""
52 mock_check.return_value = [] # No issues remain
53 suggestion = _make_suggestion()
55 result = validate_applied_fixes([suggestion])
57 assert_that(result).is_not_none()
58 assert_that(result.verified).is_equal_to(1) # type: ignore[union-attr] # assertpy is_not_none narrows this
59 assert_that(result.unverified).is_equal_to(0) # type: ignore[union-attr] # assertpy is_not_none narrows this
62@patch("lintro.ai.validation._run_tool_check")
63def test_validate_applied_fixes_unverified_when_issue_remains(mock_check):
64 """Fix is marked unverified when issue still appears in output."""
65 remaining = MagicMock()
66 remaining.file = "src/main.py"
67 remaining.code = "B101"
68 remaining.line = 10
69 mock_check.return_value = [remaining]
71 suggestion = _make_suggestion()
72 result = validate_applied_fixes([suggestion])
74 assert_that(result).is_not_none()
75 assert_that(result.verified).is_equal_to(0) # type: ignore[union-attr] # assertpy is_not_none narrows this
76 assert_that(result.unverified).is_equal_to(1) # type: ignore[union-attr] # assertpy is_not_none narrows this
77 assert_that(result.details).is_length(1) # type: ignore[union-attr] # assertpy is_not_none narrows this
80@patch("lintro.ai.validation._run_tool_check")
81def test_validate_applied_fixes_mixed_verified_and_unverified(mock_check):
82 """Verify correct counts when some fixes are verified and others are not."""
83 remaining = MagicMock()
84 remaining.file = "src/main.py"
85 remaining.code = "B101"
86 remaining.line = 10
87 mock_check.return_value = [remaining]
89 s1 = _make_suggestion(code="B101")
90 s2 = _make_suggestion(code="E501") # This one is resolved
92 result = validate_applied_fixes([s1, s2])
94 assert_that(result).is_not_none()
95 assert_that(result.verified).is_equal_to(1) # type: ignore[union-attr] # assertpy is_not_none narrows this
96 assert_that(result.unverified).is_equal_to(1) # type: ignore[union-attr] # assertpy is_not_none narrows this
97 assert_that(result.verified_by_tool.get("ruff")).is_equal_to(1) # type: ignore[union-attr] # assertpy is_not_none narrows this
98 assert_that(result.unverified_by_tool.get("ruff")).is_equal_to(1) # type: ignore[union-attr] # assertpy is_not_none narrows this
101@patch("lintro.ai.validation._run_tool_check")
102def test_validate_applied_fixes_matches_by_line_before_file_code(mock_check):
103 """Validation matches remaining issues by line, not just file."""
104 remaining = MagicMock()
105 remaining.file = "src/main.py"
106 remaining.code = "E501"
107 remaining.line = 20
108 mock_check.return_value = [remaining]
110 resolved = _make_suggestion(code="E501", line=10)
111 unresolved = _make_suggestion(code="E501", line=20)
113 result = validate_applied_fixes([resolved, unresolved])
115 assert_that(result).is_not_none()
116 assert_that(result.verified).is_equal_to(1) # type: ignore[union-attr] # assertpy is_not_none narrows this
117 assert_that(result.unverified).is_equal_to(1) # type: ignore[union-attr] # assertpy is_not_none narrows this
118 assert_that(result.details).is_length(1) # type: ignore[union-attr] # assertpy is_not_none narrows this
119 assert_that(result.details[0]).contains("main.py:20") # type: ignore[union-attr] # assertpy is_not_none narrows this
122@patch("lintro.ai.validation._run_tool_check")
123def test_validate_applied_fixes_unknown_remaining_line_marks_issue_unverified(
124 mock_check,
125):
126 """Verify a remaining issue with unknown line number marks the fix as unverified."""
127 remaining = MagicMock()
128 remaining.file = "src/main.py"
129 remaining.code = "E501"
130 remaining.line = None
131 mock_check.return_value = [remaining]
133 suggestion = _make_suggestion(code="E501", line=30)
134 result = validate_applied_fixes([suggestion])
136 assert_that(result).is_not_none()
137 assert_that(result.verified).is_equal_to(0) # type: ignore[union-attr] # assertpy is_not_none narrows this
138 assert_that(result.unverified).is_equal_to(1) # type: ignore[union-attr] # assertpy is_not_none narrows this
141@patch("lintro.ai.validation._run_tool_check")
142def test_validate_applied_fixes_skips_unknown_tool(mock_check):
143 """Suggestions with unknown tool names are skipped silently."""
144 suggestion = _make_suggestion(tool_name="unknown")
145 result = validate_applied_fixes([suggestion])
147 # No tool actually ran, so validate_applied_fixes returns None.
148 assert_that(result).is_none()
149 mock_check.assert_not_called()
152@patch("lintro.ai.validation._run_tool_check")
153def test_validate_applied_fixes_skips_when_check_returns_none(mock_check):
154 """Verify None is returned when all tool checks return None."""
155 mock_check.return_value = None # Tool not available
156 suggestion = _make_suggestion()
158 result = validate_applied_fixes([suggestion])
160 # No tool successfully ran, so validate_applied_fixes returns None.
161 assert_that(result).is_none()
164@patch("lintro.ai.validation._run_tool_check")
165def test_validate_applied_fixes_groups_by_tool(mock_check):
166 """Verify validation groups suggestions by tool and checks each tool separately."""
167 mock_check.return_value = []
169 s1 = _make_suggestion(tool_name="ruff")
170 s2 = _make_suggestion(tool_name="mypy", code="error")
172 result = validate_applied_fixes([s1, s2])
174 assert_that(result).is_not_none()
175 assert_that(result.verified).is_equal_to(2) # type: ignore[union-attr] # assertpy is_not_none narrows this
176 assert_that(mock_check.call_count).is_equal_to(2)
179@patch("lintro.ai.validation._run_tool_check")
180def test_validate_applied_fixes_matches_relative_remaining_paths_against_absolute_fixes(
181 mock_check,
182 tmp_path,
183 monkeypatch,
184):
185 """Relative remaining paths match against absolute fix paths."""
186 project_file = tmp_path / "src" / "main.py"
187 project_file.parent.mkdir(parents=True)
188 project_file.write_text("print('ok')\n")
190 monkeypatch.chdir(tmp_path)
192 remaining = MagicMock()
193 remaining.file = os.path.join("src", "main.py")
194 remaining.code = "B101"
195 mock_check.return_value = [remaining]
197 suggestion = _make_suggestion(file=str(project_file.resolve()), code="B101")
198 result = validate_applied_fixes([suggestion])
200 assert_that(result).is_not_none()
201 assert_that(result.unverified).is_equal_to(1) # type: ignore[union-attr] # assertpy is_not_none narrows this
202 assert_that(result.verified).is_equal_to(0) # type: ignore[union-attr] # assertpy is_not_none narrows this
205@patch("lintro.ai.validation._run_tool_check")
206def test_validate_applied_fixes_tracks_new_issues(mock_check):
207 """Leftover remaining_counts after matching become new_issues."""
208 remaining_a = MagicMock()
209 remaining_a.file = "src/main.py"
210 remaining_a.code = "W123"
211 remaining_a.line = 5
212 remaining_b = MagicMock()
213 remaining_b.file = "src/main.py"
214 remaining_b.code = "B101"
215 remaining_b.line = 10
216 mock_check.return_value = [remaining_a, remaining_b]
218 # Only one suggestion matches B101 -- W123 is new/unrelated
219 suggestion = _make_suggestion(code="B101", line=10)
220 result = validate_applied_fixes([suggestion])
222 assert_that(result).is_not_none()
223 assert_that(result.unverified).is_equal_to(1) # type: ignore[union-attr] # assertpy is_not_none narrows this
224 assert_that(result.new_issues).is_equal_to(1) # type: ignore[union-attr] # assertpy is_not_none narrows this
227# -- _run_tool_check ----------------------------------------------------------
230@patch("lintro.tools.tool_manager.get_tool")
231def test_run_tool_check_returns_issues(mock_get_tool):
232 """Verify _run_tool_check returns the list of issues from a successful tool run."""
233 from lintro.ai.validation import _run_tool_check
235 mock_issue = MagicMock()
236 mock_result = ToolResult(
237 name="ruff",
238 success=True,
239 issues_count=1,
240 issues=[mock_issue],
241 )
242 mock_tool = MagicMock()
243 mock_tool.check.return_value = mock_result
244 mock_get_tool.return_value = mock_tool
246 issues = _run_tool_check("ruff", ["src/main.py"])
247 assert_that(issues).is_length(1)
250@patch("lintro.tools.tool_manager.get_tool")
251def test_run_tool_check_returns_none_on_error(mock_get_tool):
252 """Verify _run_tool_check returns None when the tool raises an exception."""
253 from lintro.ai.validation import _run_tool_check
255 mock_tool = MagicMock()
256 mock_tool.check.side_effect = RuntimeError("fail")
257 mock_get_tool.return_value = mock_tool
259 issues = _run_tool_check("ruff", ["src/main.py"])
260 assert_that(issues).is_none()
263@patch("lintro.tools.tool_manager.get_tool")
264def test_run_tool_check_returns_none_for_missing_tool(mock_get_tool):
265 """Verify _run_tool_check returns None when the requested tool does not exist."""
266 from lintro.ai.validation import _run_tool_check
268 mock_get_tool.side_effect = KeyError("no such tool")
270 issues = _run_tool_check("nonexistent", ["src/main.py"])
271 assert_that(issues).is_none()