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

89 statements  

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

1"""Tests for AI orchestrator check action and summary attachment.""" 

2 

3from __future__ import annotations 

4 

5import json 

6from pathlib import Path 

7from unittest.mock import MagicMock, patch 

8 

9import pytest 

10from assertpy import assert_that 

11 

12from lintro.ai.config import AIConfig 

13from lintro.ai.models import AIFixSuggestion, AISummary 

14from lintro.ai.orchestrator import ( 

15 _log_fix_limit_message, 

16 run_ai_enhancement, 

17) 

18from lintro.ai.providers.base import AIResponse 

19from lintro.config.lintro_config import LintroConfig 

20from lintro.enums.action import Action 

21from lintro.models.core.tool_result import ToolResult 

22from tests.unit.ai.conftest import MockAIProvider, MockIssue 

23 

24# --------------------------------------------------------------------------- 

25# Fixtures 

26# --------------------------------------------------------------------------- 

27 

28 

29@pytest.fixture 

30def single_issue_result(): 

31 """ToolResult with one ruff issue.""" 

32 return ToolResult( 

33 name="ruff", 

34 success=False, 

35 issues_count=1, 

36 issues=[ 

37 MockIssue( 

38 file="src/main.py", 

39 line=1, 

40 message="Use of assert", 

41 code="B101", 

42 ), 

43 ], 

44 ) 

45 

46 

47@pytest.fixture 

48def check_config(): 

49 """LintroConfig with AI enabled and max_fix_attempts=5.""" 

50 return LintroConfig(ai=AIConfig(enabled=True, max_fix_attempts=5)) 

51 

52 

53@pytest.fixture 

54def mock_logger(): 

55 """MagicMock logger.""" 

56 return MagicMock() 

57 

58 

59# --------------------------------------------------------------------------- 

60# Check action with fix metadata 

61# --------------------------------------------------------------------------- 

62 

63 

64@patch("lintro.ai.orchestrator.require_ai") 

65@patch("lintro.ai.orchestrator.get_provider") 

66@patch("lintro.ai.orchestrator.generate_summary") 

67@patch("lintro.ai.pipeline.generate_fixes_from_params") 

68@patch( 

69 "lintro.ai.orchestrator._resolve_issue_path", 

70 side_effect=lambda *, file, workspace_root, cwd: Path(file), 

71) 

72def test_run_ai_enhancement_check_fix_preserves_summary_and_fix_metadata( 

73 _mock_normalize, 

74 mock_generate_fixes, 

75 mock_generate_summary, 

76 mock_get_provider, 

77 _mock_require_ai, 

78 single_issue_result, 

79 check_config, 

80 mock_logger, 

81): 

82 """Verify check+fix action attaches both summary and fix metadata to the result.""" 

83 result = single_issue_result 

84 config = check_config 

85 logger = mock_logger 

86 

87 mock_get_provider.return_value = MockAIProvider() 

88 mock_generate_summary.return_value = AISummary(overview="AI overview") 

89 mock_generate_fixes.return_value = [ 

90 AIFixSuggestion( 

91 file="src/main.py", 

92 line=1, 

93 code="B101", 

94 explanation="Replace assert", 

95 ), 

96 ] 

97 

98 run_ai_enhancement( 

99 action=Action.CHECK, 

100 all_results=[result], 

101 lintro_config=config, 

102 logger=logger, 

103 output_format="json", 

104 ai_fix=True, 

105 ) 

106 

107 assert_that(result.ai_metadata).is_not_none() 

108 assert_that(result.ai_metadata).contains_key("summary") 

109 assert_that(result.ai_metadata).contains_key("fix_suggestions") 

110 assert_that(result.ai_metadata["summary"]["overview"]).is_equal_to( 

111 "AI overview", 

112 ) 

113 assert_that(result.ai_metadata["fix_suggestions"]).is_length(1) 

114 summary_kwargs = mock_generate_summary.call_args.kwargs 

115 assert_that(summary_kwargs.get("max_tokens")).is_equal_to(4096) 

116 assert_that(summary_kwargs).contains_key("workspace_root") 

117 

118 

119# --------------------------------------------------------------------------- 

120# Summary attachment 

121# --------------------------------------------------------------------------- 

122 

123 

124@patch("lintro.ai.orchestrator.require_ai") 

125@patch("lintro.ai.orchestrator.get_provider") 

126@patch("lintro.ai.orchestrator.generate_summary") 

127def test_summary_attachment_summary_attached_to_all_results_with_issues( 

128 mock_generate_summary, 

129 mock_get_provider, 

130 _mock_require_ai, 

131): 

132 """Verify the AI summary is attached to every result that has issues.""" 

133 result_a = ToolResult( 

134 name="ruff", 

135 success=False, 

136 issues_count=1, 

137 issues=[ 

138 MockIssue( 

139 file="a.py", 

140 line=1, 

141 message="err", 

142 code="E501", 

143 ), 

144 ], 

145 ) 

146 result_b = ToolResult( 

147 name="mypy", 

148 success=False, 

149 issues_count=1, 

150 issues=[ 

151 MockIssue( 

152 file="b.py", 

153 line=2, 

154 message="err", 

155 code="E303", 

156 ), 

157 ], 

158 ) 

159 config = LintroConfig(ai=AIConfig(enabled=True)) 

160 logger = MagicMock() 

161 

162 mock_get_provider.return_value = MockAIProvider() 

163 mock_generate_summary.return_value = AISummary(overview="overview") 

164 

165 run_ai_enhancement( 

166 action=Action.CHECK, 

167 all_results=[result_a, result_b], 

168 lintro_config=config, 

169 logger=logger, 

170 output_format="json", 

171 ) 

172 

173 assert_that(result_a.ai_metadata).is_not_none() 

174 assert_that(result_b.ai_metadata).is_not_none() 

175 assert_that(result_a.ai_metadata).contains_key("summary") 

176 assert_that(result_b.ai_metadata).contains_key("summary") 

177 assert_that(result_a.ai_metadata["summary"]["overview"]).is_equal_to( # type: ignore[index] # assertpy is_not_none narrows this 

178 "overview", 

179 ) 

180 assert_that(result_b.ai_metadata["summary"]["overview"]).is_equal_to( # type: ignore[index] # assertpy is_not_none narrows this 

181 "overview", 

182 ) 

183 

184 

185# --------------------------------------------------------------------------- 

186# _log_fix_limit_message 

187# --------------------------------------------------------------------------- 

188 

189 

190def test_log_fix_limit_message_no_log_when_within_limit(): 

191 """No console output when total_issues <= max_fix_attempts.""" 

192 logger = MagicMock() 

193 _log_fix_limit_message( 

194 logger=logger, 

195 total_issues=3, 

196 max_fix_attempts=5, 

197 ) 

198 logger.console_output.assert_not_called() 

199 

200 

201def test_log_fix_limit_message_no_log_when_exactly_at_limit(): 

202 """No console output when total_issues == max_fix_attempts.""" 

203 logger = MagicMock() 

204 _log_fix_limit_message( 

205 logger=logger, 

206 total_issues=5, 

207 max_fix_attempts=5, 

208 ) 

209 logger.console_output.assert_not_called() 

210 

211 

212def test_log_fix_limit_message_logs_when_over_limit(): 

213 """Logs skipped count when total_issues > max_fix_attempts.""" 

214 logger = MagicMock() 

215 _log_fix_limit_message( 

216 logger=logger, 

217 total_issues=10, 

218 max_fix_attempts=5, 

219 ) 

220 logger.console_output.assert_called_once() 

221 msg = logger.console_output.call_args[0][0] 

222 assert_that(msg).contains("5 of") 

223 assert_that(msg).contains("10") 

224 assert_that(msg).contains("5 skipped") 

225 

226 

227# --------------------------------------------------------------------------- 

228# Integration: end-to-end check with real summary generation 

229# --------------------------------------------------------------------------- 

230 

231 

232@patch("lintro.ai.orchestrator.require_ai") 

233@patch("lintro.ai.orchestrator.get_provider") 

234def test_integration_orchestrator_end_to_end_check_with_real_summary_generation( 

235 mock_get_provider, 

236 _mock_require_ai, 

237): 

238 """Verify the real code path executes with only the provider mocked.""" 

239 result = ToolResult( 

240 name="ruff", 

241 success=False, 

242 issues_count=1, 

243 issues=[ 

244 MockIssue( 

245 file="src/main.py", 

246 line=1, 

247 message="Use of assert", 

248 code="B101", 

249 severity="low", 

250 ), 

251 ], 

252 ) 

253 

254 summary_response = AIResponse( 

255 content=json.dumps( 

256 { 

257 "overview": "Found 1 issue", 

258 "key_patterns": ["assert usage"], 

259 "priority_actions": ["Replace asserts"], 

260 "triage_suggestions": [], 

261 "estimated_effort": "5 minutes", 

262 }, 

263 ), 

264 model="mock-model", 

265 input_tokens=100, 

266 output_tokens=50, 

267 cost_estimate=0.002, 

268 provider="mock", 

269 ) 

270 

271 mock_get_provider.return_value = MockAIProvider(responses=[summary_response]) 

272 config = LintroConfig(ai=AIConfig(enabled=True)) 

273 logger = MagicMock() 

274 

275 run_ai_enhancement( 

276 action=Action.CHECK, 

277 all_results=[result], 

278 lintro_config=config, 

279 logger=logger, 

280 output_format="json", 

281 ) 

282 

283 assert_that(result.ai_metadata).is_not_none() 

284 assert_that(result.ai_metadata).contains_key("summary") 

285 assert_that(result.ai_metadata["summary"]["overview"]).is_equal_to( # type: ignore[index] # assertpy is_not_none narrows this 

286 "Found 1 issue", 

287 )