Coverage for tests / unit / ai / test_annotations.py: 100%
91 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 GitHub Actions annotation rendering (#705)."""
3from __future__ import annotations
5from unittest.mock import patch
7import pytest
8from assertpy import assert_that
10from lintro.ai.display.fixes import (
11 _escape_annotation,
12 _risk_to_annotation_level,
13 render_fixes_annotations,
14)
15from lintro.ai.display.summary import render_summary_annotations
16from lintro.ai.models import AIFixSuggestion, AISummary
18# -- TestRiskToAnnotationLevel: Tests for risk level to annotation level mapping.
21@pytest.mark.parametrize(
22 ("input_val", "expected"),
23 [
24 ("high", "error"),
25 ("critical", "error"),
26 ("medium", "warning"),
27 ("behavioral-risk", "warning"),
28 ("low", "notice"),
29 ("safe-style", "notice"),
30 ("", "warning"),
31 ("something-else", "warning"),
32 ("HIGH", "error"),
33 ("Low", "notice"),
34 (" high ", "error"),
35 ],
36)
37def test_risk_to_annotation_level(input_val: str, expected: str) -> None:
38 """Map risk level to annotation severity."""
39 assert_that(_risk_to_annotation_level(input_val)).is_equal_to(expected)
42# -- TestEscapeAnnotation: Tests for annotation message escaping. ------------
45def test_escapes_percent() -> None:
46 """Escape percent character for annotation."""
47 assert_that(_escape_annotation("100%")).is_equal_to("100%25")
50def test_escapes_newline() -> None:
51 """Escape newline character for annotation."""
52 assert_that(_escape_annotation("line1\nline2")).is_equal_to("line1%0Aline2")
55def test_escapes_carriage_return() -> None:
56 """Escape carriage return character for annotation."""
57 assert_that(_escape_annotation("a\rb")).is_equal_to("a%0Db")
60def test_plain_text_unchanged() -> None:
61 """Leave plain text without special characters unchanged."""
62 assert_that(_escape_annotation("hello world")).is_equal_to("hello world")
65# -- TestRenderFixesAnnotations: Tests for GitHub Actions fix annotation rendering.
68def test_empty_suggestions_returns_empty() -> None:
69 """Return empty string for empty suggestion list."""
70 result = render_fixes_annotations([])
71 assert_that(result).is_equal_to("")
74def test_single_suggestion_emits_annotation() -> None:
75 """Emit annotation with file, line, title, and message."""
76 s = AIFixSuggestion(
77 file="src/main.py",
78 line=10,
79 code="B101",
80 tool_name="bandit",
81 explanation="Replace assert",
82 confidence="high",
83 risk_level="low",
84 )
85 result = render_fixes_annotations([s])
86 assert_that(result).contains("::notice")
87 assert_that(result).contains("file=src/main.py")
88 assert_that(result).contains("line=10")
89 assert_that(result).contains("title=bandit(B101)")
90 assert_that(result).contains("AI fix available [B101]: Replace assert")
93def test_high_risk_emits_error() -> None:
94 """Emit error-level annotation for high risk suggestion."""
95 s = AIFixSuggestion(
96 file="src/main.py",
97 line=5,
98 code="S101",
99 risk_level="high",
100 explanation="Dangerous pattern",
101 )
102 result = render_fixes_annotations([s])
103 assert_that(result).starts_with("::error")
106def test_medium_risk_emits_warning() -> None:
107 """Emit warning-level annotation for medium risk suggestion."""
108 s = AIFixSuggestion(
109 file="src/main.py",
110 line=5,
111 code="W001",
112 risk_level="medium",
113 explanation="Some warning",
114 )
115 result = render_fixes_annotations([s])
116 assert_that(result).starts_with("::warning")
119def test_no_risk_level_defaults_to_warning() -> None:
120 """Default to warning-level annotation when risk level is empty."""
121 s = AIFixSuggestion(
122 file="src/main.py",
123 line=5,
124 code="X001",
125 risk_level="",
126 explanation="Some issue",
127 )
128 result = render_fixes_annotations([s])
129 assert_that(result).starts_with("::warning")
132def test_multiple_suggestions_emit_multiple_lines() -> None:
133 """Emit one annotation line per suggestion."""
134 suggestions = [
135 AIFixSuggestion(
136 file="a.py",
137 line=1,
138 code="A",
139 risk_level="low",
140 explanation="Fix A",
141 ),
142 AIFixSuggestion(
143 file="b.py",
144 line=2,
145 code="B",
146 risk_level="high",
147 explanation="Fix B",
148 ),
149 ]
150 result = render_fixes_annotations(suggestions)
151 lines = result.strip().split("\n")
152 assert_that(lines).is_length(2)
153 assert_that(lines[0]).contains("::notice")
154 assert_that(lines[1]).contains("::error")
157def test_includes_confidence_in_message() -> None:
158 """Include confidence level in annotation message."""
159 s = AIFixSuggestion(
160 file="x.py",
161 line=1,
162 code="C",
163 confidence="high",
164 risk_level="low",
165 explanation="Fix it",
166 )
167 result = render_fixes_annotations([s])
168 assert_that(result).contains("(confidence: high)")
171def test_no_file_omits_file_prop() -> None:
172 """Omit file property when suggestion has no file."""
173 s = AIFixSuggestion(
174 code="X",
175 risk_level="low",
176 explanation="No file",
177 )
178 result = render_fixes_annotations([s])
179 assert_that(result).does_not_contain("file=")
182def test_code_without_tool_name() -> None:
183 """Use bare code as title when tool name is absent."""
184 s = AIFixSuggestion(
185 file="f.py",
186 line=1,
187 code="E501",
188 risk_level="low",
189 explanation="Line too long",
190 )
191 result = render_fixes_annotations([s])
192 assert_that(result).contains("title=E501")
195# -- TestRenderSummaryAnnotations: summary annotation rendering. -
198def test_empty_summary_returns_empty() -> None:
199 """Return empty string for summary with empty overview."""
200 summary = AISummary(overview="")
201 result = render_summary_annotations(summary)
202 assert_that(result).is_equal_to("")
205def test_key_patterns_emit_warnings() -> None:
206 """Emit warning annotations for key patterns."""
207 summary = AISummary(
208 overview="Overview text",
209 key_patterns=["Missing type hints", "No docstrings"],
210 )
211 result = render_summary_annotations(summary)
212 assert_that(result).contains("::warning title=AI Pattern::Missing type hints")
213 assert_that(result).contains("::warning title=AI Pattern::No docstrings")
216def test_priority_actions_emit_notices() -> None:
217 """Emit notice annotations for priority actions."""
218 summary = AISummary(
219 overview="Overview text",
220 priority_actions=["1. Fix imports", "2. Add tests"],
221 )
222 result = render_summary_annotations(summary)
223 assert_that(result).contains("::notice title=AI Priority::Fix imports")
224 assert_that(result).contains("::notice title=AI Priority::Add tests")
227def test_no_patterns_or_actions_returns_empty() -> None:
228 """Return empty string when no patterns or actions present."""
229 summary = AISummary(overview="Just an overview")
230 result = render_summary_annotations(summary)
231 assert_that(result).is_equal_to("")
234def test_escapes_special_characters() -> None:
235 """Escape special characters in pattern annotations."""
236 summary = AISummary(
237 overview="Overview",
238 key_patterns=["100% of files\nhave issues"],
239 )
240 result = render_summary_annotations(summary)
241 assert_that(result).contains("%25")
242 assert_that(result).contains("%0A")
245# -- TestRenderFixesAutoDetectAnnotations: auto-detect annotations.
248def test_github_actions_includes_annotations(
249 sample_fix_suggestions: list[AIFixSuggestion],
250) -> None:
251 """Verify render_fixes emits annotations when in GitHub Actions."""
252 from lintro.ai.display.fixes import render_fixes
254 with patch.dict("os.environ", {"GITHUB_ACTIONS": "true"}):
255 result = render_fixes(sample_fix_suggestions)
256 assert_that(result).contains("::group::")
257 # Fixture omits risk_level, so render_fixes defaults to "warning" level
258 assert_that(result).contains("::warning")
259 assert_that(result).contains("AI fix available")
260 assert_that(result).contains("::endgroup::")