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

129 statements  

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

1"""Tests for AI issue filtering (path/rule allow/deny policy).""" 

2 

3from __future__ import annotations 

4 

5from assertpy import assert_that 

6 

7from lintro.ai.config import AIConfig 

8from lintro.ai.filters import filter_issues, should_process_issue 

9from lintro.parsers.base_issue import BaseIssue 

10from tests.unit.ai.conftest import MockIssue 

11 

12# -- should_process_issue: no filters ----------------------------------------- 

13 

14 

15def test_no_filters_allows_all() -> None: 

16 """With no filters configured, all issues pass.""" 

17 config = AIConfig() 

18 issue = MockIssue(file="src/main.py", code="E501") 

19 assert_that(should_process_issue(issue, config)).is_true() 

20 

21 

22def test_no_filters_allows_empty_fields() -> None: 

23 """Issues with empty file/code pass when no filters are set.""" 

24 config = AIConfig() 

25 issue = MockIssue(file="", code="") 

26 assert_that(should_process_issue(issue, config)).is_true() 

27 

28 

29# -- include_paths ------------------------------------------------------------- 

30 

31 

32def test_include_paths_matches() -> None: 

33 """Issue with matching path is included.""" 

34 config = AIConfig(include_paths=["src/*.py"]) 

35 issue = MockIssue(file="src/main.py", code="E501") 

36 assert_that(should_process_issue(issue, config)).is_true() 

37 

38 

39def test_include_paths_no_match() -> None: 

40 """Issue with non-matching path is excluded.""" 

41 config = AIConfig(include_paths=["src/*.py"]) 

42 issue = MockIssue(file="tests/test_main.py", code="E501") 

43 assert_that(should_process_issue(issue, config)).is_false() 

44 

45 

46def test_include_paths_multiple_patterns() -> None: 

47 """Issue matching any include pattern is included.""" 

48 config = AIConfig(include_paths=["src/*.py", "lib/*.py"]) 

49 issue = MockIssue(file="lib/utils.py", code="E501") 

50 assert_that(should_process_issue(issue, config)).is_true() 

51 

52 

53def test_include_paths_glob_star_star() -> None: 

54 """Recursive glob pattern matches at all nesting depths.""" 

55 config = AIConfig(include_paths=["src/**/*.py"]) 

56 # Zero segments: src/foo.py 

57 assert_that( 

58 should_process_issue(MockIssue(file="src/foo.py", code="E501"), config), 

59 ).is_true() 

60 # One segment: src/a/foo.py 

61 assert_that( 

62 should_process_issue(MockIssue(file="src/a/foo.py", code="E501"), config), 

63 ).is_true() 

64 # Deep nesting: src/a/b/c/foo.py 

65 assert_that( 

66 should_process_issue( 

67 MockIssue(file="src/deep/nested/module.py", code="E501"), 

68 config, 

69 ), 

70 ).is_true() 

71 # Non-matching prefix 

72 assert_that( 

73 should_process_issue(MockIssue(file="other/foo.py", code="E501"), config), 

74 ).is_false() 

75 # Non-matching extension 

76 assert_that( 

77 should_process_issue(MockIssue(file="src/foo.txt", code="E501"), config), 

78 ).is_false() 

79 

80 

81# -- exclude_paths ------------------------------------------------------------- 

82 

83 

84def test_exclude_paths_matches() -> None: 

85 """Issue with matching exclude path is rejected.""" 

86 config = AIConfig(exclude_paths=["tests/*"]) 

87 issue = MockIssue(file="tests/test_main.py", code="E501") 

88 assert_that(should_process_issue(issue, config)).is_false() 

89 

90 

91def test_exclude_paths_no_match() -> None: 

92 """Issue not matching exclude path is allowed.""" 

93 config = AIConfig(exclude_paths=["tests/*"]) 

94 issue = MockIssue(file="src/main.py", code="E501") 

95 assert_that(should_process_issue(issue, config)).is_true() 

96 

97 

98def test_exclude_paths_multiple_patterns() -> None: 

99 """Issue matching any exclude pattern is rejected.""" 

100 config = AIConfig(exclude_paths=["tests/*", "docs/*"]) 

101 issue = MockIssue(file="docs/readme.py", code="E501") 

102 assert_that(should_process_issue(issue, config)).is_false() 

103 

104 

105# -- include_paths + exclude_paths together ------------------------------------ 

106 

107 

108def test_include_and_exclude_paths_include_wins_when_not_excluded() -> None: 

109 """Include passes, exclude does not match: issue is processed.""" 

110 config = AIConfig(include_paths=["src/*.py"], exclude_paths=["src/vendor/*"]) 

111 issue = MockIssue(file="src/main.py", code="E501") 

112 assert_that(should_process_issue(issue, config)).is_true() 

113 

114 

115def test_include_and_exclude_paths_exclude_overrides() -> None: 

116 """Include passes but exclude also matches: issue is rejected.""" 

117 config = AIConfig(include_paths=["src/**"], exclude_paths=["src/vendor/*"]) 

118 issue = MockIssue(file="src/vendor/lib.py", code="E501") 

119 assert_that(should_process_issue(issue, config)).is_false() 

120 

121 

122def test_include_paths_rejects_before_exclude_checked() -> None: 

123 """If include_paths doesn't match, exclude_paths is irrelevant.""" 

124 config = AIConfig(include_paths=["lib/*"], exclude_paths=["tests/*"]) 

125 issue = MockIssue(file="src/main.py", code="E501") 

126 assert_that(should_process_issue(issue, config)).is_false() 

127 

128 

129# -- include_rules ------------------------------------------------------------- 

130 

131 

132def test_include_rules_matches() -> None: 

133 """Issue with matching rule code is included.""" 

134 config = AIConfig(include_rules=["E5*"]) 

135 issue = MockIssue(file="src/main.py", code="E501") 

136 assert_that(should_process_issue(issue, config)).is_true() 

137 

138 

139def test_include_rules_no_match() -> None: 

140 """Issue with non-matching rule code is excluded.""" 

141 config = AIConfig(include_rules=["E5*"]) 

142 issue = MockIssue(file="src/main.py", code="W123") 

143 assert_that(should_process_issue(issue, config)).is_false() 

144 

145 

146def test_include_rules_exact_match() -> None: 

147 """Exact rule code match works.""" 

148 config = AIConfig(include_rules=["B101"]) 

149 issue = MockIssue(file="src/main.py", code="B101") 

150 assert_that(should_process_issue(issue, config)).is_true() 

151 

152 

153# -- exclude_rules ------------------------------------------------------------- 

154 

155 

156def test_exclude_rules_matches() -> None: 

157 """Issue with matching exclude rule is rejected.""" 

158 config = AIConfig(exclude_rules=["E501"]) 

159 issue = MockIssue(file="src/main.py", code="E501") 

160 assert_that(should_process_issue(issue, config)).is_false() 

161 

162 

163def test_exclude_rules_no_match() -> None: 

164 """Issue not matching exclude rule is allowed.""" 

165 config = AIConfig(exclude_rules=["E501"]) 

166 issue = MockIssue(file="src/main.py", code="B101") 

167 assert_that(should_process_issue(issue, config)).is_true() 

168 

169 

170def test_exclude_rules_glob_pattern() -> None: 

171 """Glob pattern in exclude_rules matches multiple codes.""" 

172 config = AIConfig(exclude_rules=["E*"]) 

173 issue = MockIssue(file="src/main.py", code="E501") 

174 assert_that(should_process_issue(issue, config)).is_false() 

175 

176 

177# -- include_rules + exclude_rules together ------------------------------------ 

178 

179 

180def test_include_and_exclude_rules_together() -> None: 

181 """Include passes but exclude also matches: issue is rejected.""" 

182 config = AIConfig(include_rules=["E*"], exclude_rules=["E501"]) 

183 issue = MockIssue(file="src/main.py", code="E501") 

184 assert_that(should_process_issue(issue, config)).is_false() 

185 

186 

187def test_include_rules_passes_exclude_rules_does_not_match() -> None: 

188 """Include passes, exclude does not match: issue is processed.""" 

189 config = AIConfig(include_rules=["E*"], exclude_rules=["E501"]) 

190 issue = MockIssue(file="src/main.py", code="E302") 

191 assert_that(should_process_issue(issue, config)).is_true() 

192 

193 

194# -- Combined path and rule filters ------------------------------------------- 

195 

196 

197def test_path_and_rule_filters_both_pass() -> None: 

198 """Both path and rule filters must pass for issue to be processed.""" 

199 config = AIConfig(include_paths=["src/*"], include_rules=["E*"]) 

200 issue = MockIssue(file="src/main.py", code="E501") 

201 assert_that(should_process_issue(issue, config)).is_true() 

202 

203 

204def test_path_passes_rule_fails() -> None: 

205 """Path passes but rule does not: issue is rejected.""" 

206 config = AIConfig(include_paths=["src/*"], include_rules=["E*"]) 

207 issue = MockIssue(file="src/main.py", code="B101") 

208 assert_that(should_process_issue(issue, config)).is_false() 

209 

210 

211def test_rule_passes_path_fails() -> None: 

212 """Rule passes but path does not: issue is rejected.""" 

213 config = AIConfig(include_paths=["src/*"], include_rules=["E*"]) 

214 issue = MockIssue(file="tests/test.py", code="E501") 

215 assert_that(should_process_issue(issue, config)).is_false() 

216 

217 

218# -- filter_issues ------------------------------------------------------------- 

219 

220 

221def test_filter_issues_returns_matching_only() -> None: 

222 """filter_issues returns only issues that pass the filter.""" 

223 config = AIConfig(include_paths=["src/*"]) 

224 issues: list[BaseIssue] = [ 

225 MockIssue(file="src/main.py", code="E501"), 

226 MockIssue(file="tests/test.py", code="E501"), 

227 MockIssue(file="src/utils.py", code="B101"), 

228 ] 

229 result = filter_issues(issues, config) 

230 assert_that(result).is_length(2) 

231 assert_that([i.file for i in result]).contains("src/main.py", "src/utils.py") 

232 

233 

234def test_filter_issues_empty_list() -> None: 

235 """filter_issues handles empty list.""" 

236 config = AIConfig(include_paths=["src/*"]) 

237 result = filter_issues([], config) 

238 assert_that(result).is_empty() 

239 

240 

241def test_filter_issues_no_filters() -> None: 

242 """filter_issues with no filters returns all issues.""" 

243 config = AIConfig() 

244 issues: list[BaseIssue] = [ 

245 MockIssue(file="src/main.py", code="E501"), 

246 MockIssue(file="tests/test.py", code="B101"), 

247 ] 

248 result = filter_issues(issues, config) 

249 assert_that(result).is_length(2) 

250 

251 

252def test_filter_issues_all_excluded() -> None: 

253 """filter_issues can exclude all issues.""" 

254 config = AIConfig(exclude_paths=["**"]) 

255 issues: list[BaseIssue] = [ 

256 MockIssue(file="src/main.py", code="E501"), 

257 MockIssue(file="tests/test.py", code="B101"), 

258 ] 

259 result = filter_issues(issues, config) 

260 assert_that(result).is_empty() 

261 

262 

263# -- Edge cases ---------------------------------------------------------------- 

264 

265 

266def test_issue_without_code_attribute() -> None: 

267 """BaseIssue without code attribute is handled gracefully.""" 

268 config = AIConfig(include_rules=["E*"]) 

269 issue = BaseIssue(file="src/main.py", line=1, message="test") 

270 # BaseIssue has no code attr, getattr returns "" 

271 assert_that(should_process_issue(issue, config)).is_false() 

272 

273 

274def test_issue_without_file() -> None: 

275 """Issue with empty file and include_paths filter is excluded.""" 

276 config = AIConfig(include_paths=["src/*"]) 

277 issue = MockIssue(file="", code="E501") 

278 assert_that(should_process_issue(issue, config)).is_false()