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

1"""Tests for GitHub Actions annotation rendering (#705).""" 

2 

3from __future__ import annotations 

4 

5from unittest.mock import patch 

6 

7import pytest 

8from assertpy import assert_that 

9 

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 

17 

18# -- TestRiskToAnnotationLevel: Tests for risk level to annotation level mapping. 

19 

20 

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) 

40 

41 

42# -- TestEscapeAnnotation: Tests for annotation message escaping. ------------ 

43 

44 

45def test_escapes_percent() -> None: 

46 """Escape percent character for annotation.""" 

47 assert_that(_escape_annotation("100%")).is_equal_to("100%25") 

48 

49 

50def test_escapes_newline() -> None: 

51 """Escape newline character for annotation.""" 

52 assert_that(_escape_annotation("line1\nline2")).is_equal_to("line1%0Aline2") 

53 

54 

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

58 

59 

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

63 

64 

65# -- TestRenderFixesAnnotations: Tests for GitHub Actions fix annotation rendering. 

66 

67 

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

72 

73 

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

91 

92 

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

104 

105 

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

117 

118 

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

130 

131 

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

155 

156 

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

169 

170 

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

180 

181 

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

193 

194 

195# -- TestRenderSummaryAnnotations: summary annotation rendering. - 

196 

197 

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

203 

204 

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

214 

215 

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

225 

226 

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

232 

233 

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

243 

244 

245# -- TestRenderFixesAutoDetectAnnotations: auto-detect annotations. 

246 

247 

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 

253 

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