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

1"""Tests for validate_applied_fixes and _run_tool_check. 

2 

3Covers the validate_applied_fixes function and its helper _run_tool_check, 

4including matching logic, tool grouping, path resolution, and new issues tracking. 

5""" 

6 

7from __future__ import annotations 

8 

9import os 

10from unittest.mock import MagicMock, patch 

11 

12from assertpy import assert_that 

13 

14from lintro.ai.models import AIFixSuggestion 

15from lintro.ai.validation import ( 

16 validate_applied_fixes, 

17) 

18from lintro.models.core.tool_result import ToolResult 

19 

20 

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 ) 

38 

39 

40# -- validate_applied_fixes --------------------------------------------------- 

41 

42 

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() 

47 

48 

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() 

54 

55 result = validate_applied_fixes([suggestion]) 

56 

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 

60 

61 

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] 

70 

71 suggestion = _make_suggestion() 

72 result = validate_applied_fixes([suggestion]) 

73 

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 

78 

79 

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] 

88 

89 s1 = _make_suggestion(code="B101") 

90 s2 = _make_suggestion(code="E501") # This one is resolved 

91 

92 result = validate_applied_fixes([s1, s2]) 

93 

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 

99 

100 

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] 

109 

110 resolved = _make_suggestion(code="E501", line=10) 

111 unresolved = _make_suggestion(code="E501", line=20) 

112 

113 result = validate_applied_fixes([resolved, unresolved]) 

114 

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 

120 

121 

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] 

132 

133 suggestion = _make_suggestion(code="E501", line=30) 

134 result = validate_applied_fixes([suggestion]) 

135 

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 

139 

140 

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]) 

146 

147 # No tool actually ran, so validate_applied_fixes returns None. 

148 assert_that(result).is_none() 

149 mock_check.assert_not_called() 

150 

151 

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() 

157 

158 result = validate_applied_fixes([suggestion]) 

159 

160 # No tool successfully ran, so validate_applied_fixes returns None. 

161 assert_that(result).is_none() 

162 

163 

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 = [] 

168 

169 s1 = _make_suggestion(tool_name="ruff") 

170 s2 = _make_suggestion(tool_name="mypy", code="error") 

171 

172 result = validate_applied_fixes([s1, s2]) 

173 

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) 

177 

178 

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") 

189 

190 monkeypatch.chdir(tmp_path) 

191 

192 remaining = MagicMock() 

193 remaining.file = os.path.join("src", "main.py") 

194 remaining.code = "B101" 

195 mock_check.return_value = [remaining] 

196 

197 suggestion = _make_suggestion(file=str(project_file.resolve()), code="B101") 

198 result = validate_applied_fixes([suggestion]) 

199 

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 

203 

204 

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] 

217 

218 # Only one suggestion matches B101 -- W123 is new/unrelated 

219 suggestion = _make_suggestion(code="B101", line=10) 

220 result = validate_applied_fixes([suggestion]) 

221 

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 

225 

226 

227# -- _run_tool_check ---------------------------------------------------------- 

228 

229 

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 

234 

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 

245 

246 issues = _run_tool_check("ruff", ["src/main.py"]) 

247 assert_that(issues).is_length(1) 

248 

249 

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 

254 

255 mock_tool = MagicMock() 

256 mock_tool.check.side_effect = RuntimeError("fail") 

257 mock_get_tool.return_value = mock_tool 

258 

259 issues = _run_tool_check("ruff", ["src/main.py"]) 

260 assert_that(issues).is_none() 

261 

262 

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 

267 

268 mock_get_tool.side_effect = KeyError("no such tool") 

269 

270 issues = _run_tool_check("nonexistent", ["src/main.py"]) 

271 assert_that(issues).is_none()