Coverage for tests / unit / utils / test_tool_executor_ai.py: 96%

81 statements  

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

1"""Tests for AI-specific behavior in tool executor.""" 

2 

3from __future__ import annotations 

4 

5from typing import Any 

6from unittest.mock import MagicMock 

7 

8from assertpy import assert_that 

9 

10import lintro.utils.tool_executor as te 

11from lintro.ai.config import AIConfig 

12from lintro.config.execution_config import ExecutionConfig 

13from lintro.config.lintro_config import LintroConfig 

14from lintro.enums.action import Action 

15from lintro.models.core.tool_result import ToolResult 

16from lintro.utils.execution.tool_configuration import ToolsToRunResult 

17from lintro.utils.tool_executor import _warn_ai_fix_disabled, run_lint_tools_simple 

18 

19# --------------------------------------------------------------------------- 

20# _warn_ai_fix_disabled 

21# --------------------------------------------------------------------------- 

22 

23 

24def test_warn_ai_fix_disabled_warns_only_for_check_when_fix_requested_and_ai_disabled(): 

25 """Warn when action is CHECK, ai_fix=True, and AI disabled.""" 

26 logger = MagicMock() 

27 

28 _warn_ai_fix_disabled( 

29 action=Action.CHECK, 

30 ai_fix=True, 

31 ai_enabled=False, 

32 logger=logger, 

33 ) 

34 

35 assert_that(logger.console_output.call_count).is_equal_to(1) 

36 warning_text = logger.console_output.call_args[0][0] 

37 assert_that(warning_text).contains("AI fixes requested") 

38 assert_that(warning_text).contains("ai.enabled is false") 

39 

40 

41def test_warn_ai_fix_disabled_no_warning_for_other_states(): 

42 """Test that no warning is issued for non-qualifying state combinations.""" 

43 logger = MagicMock() 

44 

45 _warn_ai_fix_disabled( 

46 action=Action.FIX, 

47 ai_fix=True, 

48 ai_enabled=False, 

49 logger=logger, 

50 ) 

51 _warn_ai_fix_disabled( 

52 action=Action.CHECK, 

53 ai_fix=False, 

54 ai_enabled=False, 

55 logger=logger, 

56 ) 

57 _warn_ai_fix_disabled( 

58 action=Action.CHECK, 

59 ai_fix=True, 

60 ai_enabled=True, 

61 logger=logger, 

62 ) 

63 

64 assert_that(logger.console_output.call_count).is_equal_to(0) 

65 

66 

67def test_warn_ai_fix_disabled_suppressed_for_json_output(): 

68 """Warning is suppressed when output format is JSON.""" 

69 logger = MagicMock() 

70 

71 _warn_ai_fix_disabled( 

72 action=Action.CHECK, 

73 ai_fix=True, 

74 ai_enabled=False, 

75 logger=logger, 

76 output_format="json", 

77 ) 

78 

79 assert_that(logger.console_output.call_count).is_equal_to(0) 

80 

81 

82def test_warn_ai_fix_disabled_suppressed_for_sarif_output(): 

83 """Warning is suppressed when output format is SARIF.""" 

84 logger = MagicMock() 

85 

86 _warn_ai_fix_disabled( 

87 action=Action.CHECK, 

88 ai_fix=True, 

89 ai_enabled=False, 

90 logger=logger, 

91 output_format="sarif", 

92 ) 

93 

94 assert_that(logger.console_output.call_count).is_equal_to(0) 

95 

96 

97# --------------------------------------------------------------------------- 

98# Post-AI total recalculation 

99# --------------------------------------------------------------------------- 

100 

101 

102def test_fix_recomputes_totals_after_ai_changes(monkeypatch, fake_logger): 

103 """Test that fix recomputes totals after AI changes.""" 

104 

105 class _FakeTool: 

106 def set_options(self, **_kwargs: Any) -> None: 

107 return None 

108 

109 def reset_options(self) -> None: 

110 return None 

111 

112 def fix(self, _paths: Any, _options: Any) -> ToolResult: 

113 return ToolResult( 

114 name="ruff", 

115 success=False, 

116 issues_count=1, 

117 fixed_issues_count=0, 

118 remaining_issues_count=1, 

119 issues=[], 

120 ) 

121 

122 def check(self, _paths: Any, _options: Any) -> ToolResult: 

123 return ToolResult( 

124 name="ruff", 

125 success=False, 

126 issues_count=1, 

127 issues=[], 

128 ) 

129 

130 lintro_config = LintroConfig( 

131 execution=ExecutionConfig(parallel=False), 

132 ai=AIConfig( 

133 enabled=True, 

134 auto_apply=True, 

135 ), 

136 ) 

137 

138 monkeypatch.setattr( 

139 te, 

140 "get_tools_to_run", 

141 lambda tools, action, **_kw: ToolsToRunResult(to_run=["ruff"]), 

142 ) 

143 monkeypatch.setattr( 

144 te.tool_manager, # type: ignore[attr-defined] # singleton 

145 "get_tool", 

146 lambda name: _FakeTool(), 

147 ) 

148 monkeypatch.setattr( 

149 te, 

150 "configure_tool_for_execution", 

151 lambda **kwargs: None, 

152 ) 

153 monkeypatch.setattr( 

154 te, 

155 "execute_post_checks", 

156 lambda **kwargs: ( 

157 kwargs["total_issues"], 

158 kwargs["total_fixed"], 

159 kwargs["total_remaining"], 

160 ), 

161 ) 

162 

163 import lintro.config.config_loader as config_loader 

164 import lintro.utils.console as console_module 

165 import lintro.utils.logger_setup as logger_setup 

166 from lintro.utils.output import OutputManager 

167 

168 monkeypatch.setattr(config_loader, "get_config", lambda: lintro_config) 

169 monkeypatch.setattr( 

170 console_module, 

171 "create_logger", 

172 lambda **kwargs: fake_logger, 

173 ) 

174 monkeypatch.setattr( 

175 logger_setup, 

176 "setup_execution_logging", 

177 lambda run_dir, debug=False: None, 

178 ) 

179 monkeypatch.setattr( 

180 OutputManager, 

181 "write_reports_from_results", 

182 lambda self, results: None, 

183 ) 

184 monkeypatch.setattr(te, "load_post_checks_config", lambda: {"enabled": False}) 

185 

186 def _fake_ai_enhancement(**kwargs): 

187 result = kwargs["all_results"][0] 

188 result.success = True 

189 result.fixed_issues_count = result.issues_count 

190 result.remaining_issues_count = 0 

191 result.issues_count = 0 

192 

193 import lintro.ai.hook as hook_module 

194 

195 class _FakeHook: 

196 def should_run(self, action: Any) -> bool: 

197 return True 

198 

199 def execute( 

200 self, 

201 action: Any, 

202 all_results: Any, 

203 *, 

204 console_logger: Any, 

205 output_format: Any, 

206 ) -> object: 

207 _fake_ai_enhancement(all_results=all_results) 

208 return object() # non-None sentinel to signal hook ran 

209 

210 monkeypatch.setattr( 

211 hook_module, 

212 "AIPostExecutionHook", 

213 lambda lintro_config, ai_fix=False: _FakeHook(), 

214 ) 

215 

216 captured: dict[str, int] = {} 

217 

218 def _capture_exit_code( 

219 *, 

220 action, 

221 all_results, 

222 total_issues, 

223 total_remaining, 

224 main_phase_empty_due_to_filter, 

225 ): 

226 captured["total_issues"] = total_issues 

227 captured["total_remaining"] = total_remaining 

228 return 0 if total_remaining == 0 else 1 

229 

230 monkeypatch.setattr(te, "determine_exit_code", _capture_exit_code) 

231 

232 exit_code = run_lint_tools_simple( 

233 action="fmt", 

234 paths=["."], 

235 tools="ruff", 

236 tool_options=None, 

237 exclude=None, 

238 include_venv=False, 

239 group_by="auto", 

240 output_format="json", 

241 verbose=False, 

242 raw_output=False, 

243 ) 

244 

245 assert_that(exit_code).is_equal_to(0) 

246 assert_that(captured.get("total_issues")).is_equal_to(0) 

247 assert_that(captured.get("total_remaining")).is_equal_to(0)