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
« 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."""
3from __future__ import annotations
5import json
6from pathlib import Path
7from unittest.mock import MagicMock, patch
9import pytest
10from assertpy import assert_that
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
24# ---------------------------------------------------------------------------
25# Fixtures
26# ---------------------------------------------------------------------------
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 )
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))
53@pytest.fixture
54def mock_logger():
55 """MagicMock logger."""
56 return MagicMock()
59# ---------------------------------------------------------------------------
60# Check action with fix metadata
61# ---------------------------------------------------------------------------
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
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 ]
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 )
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")
119# ---------------------------------------------------------------------------
120# Summary attachment
121# ---------------------------------------------------------------------------
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()
162 mock_get_provider.return_value = MockAIProvider()
163 mock_generate_summary.return_value = AISummary(overview="overview")
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 )
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 )
185# ---------------------------------------------------------------------------
186# _log_fix_limit_message
187# ---------------------------------------------------------------------------
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()
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()
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")
227# ---------------------------------------------------------------------------
228# Integration: end-to-end check with real summary generation
229# ---------------------------------------------------------------------------
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 )
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 )
271 mock_get_provider.return_value = MockAIProvider(responses=[summary_response])
272 config = LintroConfig(ai=AIConfig(enabled=True))
273 logger = MagicMock()
275 run_ai_enhancement(
276 action=Action.CHECK,
277 all_results=[result],
278 lintro_config=config,
279 logger=logger,
280 output_format="json",
281 )
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 )