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

1"""Tests for AI fix refinement module.""" 

2 

3from __future__ import annotations 

4 

5from pathlib import Path 

6from unittest.mock import MagicMock, patch 

7 

8from assertpy import assert_that 

9 

10from lintro.ai.models import AIFixSuggestion 

11from lintro.ai.refinement import _revert_fix, refine_unverified_fixes 

12from lintro.ai.validation import ValidationResult 

13 

14 

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] 

27 

28 

29# -- _revert_fix ----------------------------------------------------------- 

30 

31 

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 ) 

40 

41 with patch("lintro.ai.refinement.apply_fixes") as mock_apply: 

42 mock_apply.return_value = [suggestion] 

43 result = _revert_fix(suggestion, tmp_path) 

44 

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

53 

54 

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

58 

59 with patch("lintro.ai.refinement.apply_fixes") as mock_apply: 

60 mock_apply.return_value = [] 

61 result = _revert_fix(suggestion, tmp_path) 

62 

63 assert_that(result).is_false() 

64 

65 

66# -- refine_unverified_fixes ----------------------------------------------- 

67 

68 

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 

84 

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 ) 

92 

93 assert_that(refined).is_empty() 

94 assert_that(cost).is_equal_to(0.0) 

95 

96 

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 ) 

105 

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 

117 

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) 

128 

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 

135 

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] 

142 

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 ) 

150 

151 assert_that(refined).is_length(1) 

152 assert_that(cost).is_close_to(0.001, 0.0001) 

153 

154 

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 ) 

163 

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 

171 

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 ) 

181 

182 assert_that(refined).is_empty() 

183 assert_that(cost).is_equal_to(0.0) 

184 

185 

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 ) 

194 

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 

206 

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) 

216 

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 

223 

224 mock_parse.return_value = None 

225 

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 ) 

233 

234 assert_that(refined).is_empty()