Coverage for tests / unit / ai / test_validation_core.py: 100%

93 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Tests for core validation logic and rendering. 

2 

3Covers _validate_suggestions, verify_fixes, and render_validation_terminal. 

4""" 

5 

6from __future__ import annotations 

7 

8from unittest.mock import MagicMock, patch 

9 

10from assertpy import assert_that 

11 

12from lintro.ai.display import render_validation_terminal 

13from lintro.ai.models import AIFixSuggestion 

14from lintro.ai.validation import ( 

15 ValidationResult, 

16 _validate_suggestions, 

17 verify_fixes, 

18) 

19from lintro.models.core.tool_result import ToolResult 

20from lintro.parsers.base_issue import BaseIssue 

21 

22from .conftest import MockIssue 

23 

24 

25def _make_suggestion( 

26 *, 

27 file: str = "src/main.py", 

28 line: int = 10, 

29 code: str = "B101", 

30 tool_name: str = "ruff", 

31) -> AIFixSuggestion: 

32 """Create an AIFixSuggestion for testing.""" 

33 return AIFixSuggestion( 

34 file=file, 

35 line=line, 

36 code=code, 

37 tool_name=tool_name, 

38 original_code="assert x", 

39 suggested_code="if not x: raise", 

40 explanation="Replace assert", 

41 ) 

42 

43 

44# -- render_validation_terminal ------------------------------------------------ 

45 

46 

47def test_render_validation_terminal_renders_verified(): 

48 """Verify terminal rendering displays the verified fix count.""" 

49 result = ValidationResult(verified=3, unverified=0) 

50 output = render_validation_terminal(result) 

51 assert_that(output).contains("3 resolved") 

52 

53 

54def test_render_validation_terminal_renders_unverified_with_details(): 

55 """Verify terminal rendering shows unverified count and detail lines.""" 

56 result = ValidationResult( 

57 verified=1, 

58 unverified=2, 

59 details=[ 

60 "[B101] main.py:10 — issue still present", 

61 "[E501] utils.py:25 — issue still present", 

62 ], 

63 ) 

64 output = render_validation_terminal(result) 

65 assert_that(output).contains("1 resolved") 

66 assert_that(output).contains("2 still present") 

67 assert_that(output).contains("B101") 

68 assert_that(output).contains("E501") 

69 

70 

71def test_render_validation_terminal_empty_result_returns_empty(): 

72 """Verify an empty ValidationResult produces empty terminal output.""" 

73 result = ValidationResult() 

74 output = render_validation_terminal(result) 

75 assert_that(output).is_empty() 

76 

77 

78# -- _validate_suggestions (shared core logic) -------------------------------- 

79 

80 

81def test_validate_suggestions_verified_when_no_remaining_issues(): 

82 """Core validation marks fix as verified when no issues remain for the tool.""" 

83 suggestion = _make_suggestion(tool_name="ruff", code="B101") 

84 result = _validate_suggestions([suggestion], {"ruff": []}) 

85 

86 assert_that(result.verified).is_equal_to(1) 

87 assert_that(result.unverified).is_equal_to(0) 

88 

89 

90def test_validate_suggestions_unverified_when_issue_remains(): 

91 """Core validation marks fix as unverified when the issue still appears.""" 

92 remaining = MagicMock() 

93 remaining.file = "src/main.py" 

94 remaining.code = "B101" 

95 remaining.line = 10 

96 

97 suggestion = _make_suggestion(tool_name="ruff", code="B101", line=10) 

98 result = _validate_suggestions([suggestion], {"ruff": [remaining]}) 

99 

100 assert_that(result.verified).is_equal_to(0) 

101 assert_that(result.unverified).is_equal_to(1) 

102 

103 

104def test_validate_suggestions_skips_tool_without_fresh_issues(): 

105 """When a tool has no entry in fresh_issues_by_tool, its suggestions are skipped.""" 

106 suggestion = _make_suggestion(tool_name="ruff") 

107 result = _validate_suggestions([suggestion], {}) 

108 

109 assert_that(result.verified).is_equal_to(0) 

110 assert_that(result.unverified).is_equal_to(0) 

111 

112 

113# -- verify_fixes (unified entry point) --------------------------------------- 

114 

115 

116def test_verify_fixes_returns_none_for_empty_suggestions(): 

117 """verify_fixes returns None when given an empty suggestions list.""" 

118 result = verify_fixes(applied_suggestions=[], by_tool={}) 

119 assert_that(result).is_none() 

120 

121 

122@patch("lintro.ai.rerun.rerun_tools") 

123@patch("lintro.ai.rerun.apply_rerun_results") 

124def test_verify_fixes_runs_tools_once_and_validates( 

125 mock_apply_rerun, 

126 mock_rerun_tools, 

127): 

128 """verify_fixes calls rerun_tools once and produces a ValidationResult.""" 

129 # Set up a fresh rerun result with no remaining issues 

130 fresh_result = ToolResult( 

131 name="ruff", 

132 success=True, 

133 issues_count=0, 

134 issues=[], 

135 ) 

136 mock_rerun_tools.return_value = [fresh_result] 

137 

138 suggestion = _make_suggestion(tool_name="ruff") 

139 issue = MockIssue( 

140 file="src/main.py", 

141 line=10, 

142 column=1, 

143 message="test", 

144 code="B101", 

145 severity="low", 

146 ) 

147 original_result = ToolResult(name="ruff", success=False, issues_count=1) 

148 issues: list[BaseIssue] = [issue] 

149 by_tool = {"ruff": (original_result, issues)} 

150 

151 result = verify_fixes( 

152 applied_suggestions=[suggestion], 

153 by_tool=by_tool, 

154 ) 

155 

156 assert_that(result).is_not_none() 

157 assert_that(result.verified).is_equal_to(1) # type: ignore[union-attr] # assertpy is_not_none narrows this 

158 assert_that(result.unverified).is_equal_to(0) # type: ignore[union-attr] # assertpy is_not_none narrows this 

159 # rerun_tools should be called exactly once (not twice as before) 

160 mock_rerun_tools.assert_called_once_with(by_tool) 

161 mock_apply_rerun.assert_called_once() 

162 

163 

164@patch("lintro.ai.rerun.rerun_tools") 

165@patch("lintro.ai.rerun.apply_rerun_results") 

166def test_verify_fixes_updates_tool_results_and_validates( 

167 mock_apply_rerun, 

168 mock_rerun_tools, 

169): 

170 """verify_fixes both updates ToolResults (via apply_rerun_results) and validates.""" 

171 remaining = MagicMock() 

172 remaining.file = "src/main.py" 

173 remaining.code = "B101" 

174 remaining.line = 10 

175 

176 fresh_result = ToolResult( 

177 name="ruff", 

178 success=True, 

179 issues_count=1, 

180 issues=[remaining], 

181 ) 

182 mock_rerun_tools.return_value = [fresh_result] 

183 

184 suggestion = _make_suggestion(tool_name="ruff", code="B101", line=10) 

185 issue = MockIssue( 

186 file="src/main.py", 

187 line=10, 

188 column=1, 

189 message="test", 

190 code="B101", 

191 severity="low", 

192 ) 

193 original_result = ToolResult(name="ruff", success=False, issues_count=1) 

194 issues: list[BaseIssue] = [issue] 

195 by_tool = {"ruff": (original_result, issues)} 

196 

197 result = verify_fixes( 

198 applied_suggestions=[suggestion], 

199 by_tool=by_tool, 

200 ) 

201 

202 assert_that(result).is_not_none() 

203 assert_that(result.unverified).is_equal_to(1) # type: ignore[union-attr] # assertpy is_not_none narrows this 

204 assert_that(result.verified).is_equal_to(0) # type: ignore[union-attr] # assertpy is_not_none narrows this 

205 # Confirms apply_rerun_results was called to update ToolResult objects 

206 mock_apply_rerun.assert_called_once() 

207 

208 

209@patch("lintro.ai.rerun.rerun_tools") 

210def test_verify_fixes_handles_no_rerun_results(mock_rerun_tools): 

211 """verify_fixes handles the case where rerun_tools returns None.""" 

212 mock_rerun_tools.return_value = None 

213 

214 suggestion = _make_suggestion(tool_name="ruff") 

215 issue = MockIssue( 

216 file="src/main.py", 

217 line=10, 

218 column=1, 

219 message="test", 

220 code="B101", 

221 severity="low", 

222 ) 

223 original_result = ToolResult(name="ruff", success=False, issues_count=1) 

224 issues: list[BaseIssue] = [issue] 

225 by_tool = {"ruff": (original_result, issues)} 

226 

227 result = verify_fixes( 

228 applied_suggestions=[suggestion], 

229 by_tool=by_tool, 

230 ) 

231 

232 # With no rerun results, verify_fixes returns None 

233 assert_that(result).is_none()