Coverage for tests / unit / tools / core / test_line_length_checker.py: 100%

91 statements  

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

1"""Unit tests for the shared line length checker utility.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import subprocess 

7from typing import TYPE_CHECKING 

8from unittest.mock import MagicMock, patch 

9 

10import pytest 

11from assertpy import assert_that 

12 

13from lintro.tools.core.line_length_checker import ( 

14 LineLengthViolation, 

15 check_line_length_violations, 

16) 

17 

18if TYPE_CHECKING: 

19 from collections.abc import Generator 

20 

21 

22# --- LineLengthViolation dataclass tests --- 

23 

24 

25def test_line_length_violation_default_code_is_e501() -> None: 

26 """Test that the default code is E501.""" 

27 violation = LineLengthViolation( 

28 file="test.py", 

29 line=10, 

30 column=89, 

31 message="Line too long (100 > 88)", 

32 ) 

33 assert_that(violation.code).is_equal_to("E501") 

34 

35 

36def test_line_length_violation_all_fields_set() -> None: 

37 """Test creating a violation with all fields.""" 

38 violation = LineLengthViolation( 

39 file="/path/to/file.py", 

40 line=42, 

41 column=100, 

42 message="Line too long (120 > 88)", 

43 code="E501", 

44 ) 

45 assert_that(violation.file).is_equal_to("/path/to/file.py") 

46 assert_that(violation.line).is_equal_to(42) 

47 assert_that(violation.column).is_equal_to(100) 

48 assert_that(violation.message).is_equal_to("Line too long (120 > 88)") 

49 assert_that(violation.code).is_equal_to("E501") 

50 

51 

52# --- Fixtures for check_line_length_violations tests --- 

53 

54 

55@pytest.fixture 

56def mock_ruff_available() -> Generator[MagicMock, None, None]: 

57 """Mock shutil.which to return ruff as available. 

58 

59 Yields: 

60 MagicMock: Configured mock for shutil.which. 

61 """ 

62 with patch("shutil.which", return_value="/usr/bin/ruff") as mock: 

63 yield mock 

64 

65 

66@pytest.fixture 

67def mock_subprocess() -> Generator[MagicMock, None, None]: 

68 """Mock subprocess.run for testing. 

69 

70 Yields: 

71 MagicMock: Configured mock for subprocess.run. 

72 """ 

73 with patch("subprocess.run") as mock: 

74 mock.return_value = MagicMock(stdout="[]", returncode=0) 

75 yield mock 

76 

77 

78# --- check_line_length_violations function tests --- 

79 

80 

81def test_check_line_length_empty_files_returns_empty_list() -> None: 

82 """Test that empty file list returns empty violations.""" 

83 result = check_line_length_violations(files=[]) 

84 assert_that(result).is_empty() 

85 

86 

87def test_check_line_length_ruff_not_available_returns_empty() -> None: 

88 """Test that missing ruff returns empty violations without error.""" 

89 with patch("shutil.which", return_value=None) as mock_which: 

90 result = check_line_length_violations(files=["test.py"]) 

91 assert_that(result).is_empty() 

92 mock_which.assert_called_once_with("ruff") 

93 

94 

95def test_check_line_length_successful_detection( 

96 mock_ruff_available: MagicMock, 

97 mock_subprocess: MagicMock, 

98) -> None: 

99 """Test successful detection of E501 violations. 

100 

101 Args: 

102 mock_ruff_available: Mock fixture ensuring ruff is available. 

103 mock_subprocess: Mock fixture for subprocess operations. 

104 """ 

105 ruff_output = json.dumps( 

106 [ 

107 { 

108 "filename": "/path/to/file.py", 

109 "location": {"row": 10, "column": 89}, 

110 "message": "Line too long (100 > 88)", 

111 "code": "E501", 

112 }, 

113 ], 

114 ) 

115 mock_subprocess.return_value = MagicMock(stdout=ruff_output, returncode=1) 

116 

117 result = check_line_length_violations(files=["file.py"], cwd="/path/to") 

118 

119 assert_that(result).is_length(1) 

120 assert_that(result[0].file).is_equal_to("/path/to/file.py") 

121 assert_that(result[0].line).is_equal_to(10) 

122 assert_that(result[0].column).is_equal_to(89) 

123 assert_that(result[0].message).is_equal_to("Line too long (100 > 88)") 

124 assert_that(result[0].code).is_equal_to("E501") 

125 

126 

127def test_check_line_length_custom_line_length( 

128 mock_ruff_available: MagicMock, 

129 mock_subprocess: MagicMock, 

130) -> None: 

131 """Test that custom line_length is passed to ruff. 

132 

133 Args: 

134 mock_ruff_available: Mock fixture ensuring ruff is available. 

135 mock_subprocess: Mock fixture for subprocess operations. 

136 """ 

137 check_line_length_violations(files=["test.py"], line_length=100) 

138 

139 call_args = mock_subprocess.call_args 

140 cmd = call_args[0][0] 

141 assert_that(cmd).contains("--line-length") 

142 assert_that(cmd).contains("100") 

143 

144 

145@pytest.mark.parametrize( 

146 "exception,description", 

147 [ 

148 (subprocess.TimeoutExpired(cmd=["ruff"], timeout=30), "timeout"), 

149 (FileNotFoundError("ruff not found"), "file_not_found"), 

150 (RuntimeError("Unexpected error"), "generic_error"), 

151 ], 

152 ids=["timeout", "file_not_found", "generic_error"], 

153) 

154def test_check_line_length_exception_returns_empty( 

155 mock_ruff_available: MagicMock, 

156 exception: Exception, 

157 description: str, 

158) -> None: 

159 """Test that various exceptions return empty list gracefully. 

160 

161 Args: 

162 mock_ruff_available: Mock fixture ensuring ruff is available. 

163 exception: The exception to be raised by subprocess.run. 

164 description: Description of the test case. 

165 """ 

166 with patch("subprocess.run", side_effect=exception): 

167 result = check_line_length_violations(files=["test.py"], timeout=30) 

168 assert_that(result).is_empty() 

169 

170 

171@pytest.mark.parametrize( 

172 "stdout,description", 

173 [ 

174 ("not valid json", "invalid_json"), 

175 ("", "empty_stdout"), 

176 ], 

177 ids=["invalid_json", "empty_stdout"], 

178) 

179def test_check_line_length_invalid_output_returns_empty( 

180 mock_ruff_available: MagicMock, 

181 stdout: str, 

182 description: str, 

183) -> None: 

184 """Test that invalid/empty stdout returns empty list gracefully. 

185 

186 Args: 

187 mock_ruff_available: Mock fixture ensuring ruff is available. 

188 stdout: The stdout output from subprocess.run. 

189 description: Description of the test case. 

190 """ 

191 with patch("subprocess.run") as mock_run: 

192 mock_run.return_value = MagicMock(stdout=stdout, returncode=1) 

193 result = check_line_length_violations(files=["test.py"]) 

194 assert_that(result).is_empty() 

195 

196 

197def test_check_line_length_relative_paths_converted_to_absolute( 

198 mock_ruff_available: MagicMock, 

199 mock_subprocess: MagicMock, 

200) -> None: 

201 """Test that relative file paths are converted to absolute. 

202 

203 Args: 

204 mock_ruff_available: Mock fixture ensuring ruff is available. 

205 mock_subprocess: Mock fixture for subprocess operations. 

206 """ 

207 check_line_length_violations( 

208 files=["src/module.py", "tests/test_module.py"], 

209 cwd="/project", 

210 ) 

211 

212 call_args = mock_subprocess.call_args 

213 cmd = call_args[0][0] 

214 assert_that(cmd).contains("/project/src/module.py") 

215 assert_that(cmd).contains("/project/tests/test_module.py") 

216 

217 

218def test_check_line_length_old_ruff_json_format( 

219 mock_ruff_available: MagicMock, 

220 mock_subprocess: MagicMock, 

221) -> None: 

222 """Test compatibility with older Ruff JSON format (no location wrapper). 

223 

224 Args: 

225 mock_ruff_available: Mock fixture ensuring ruff is available. 

226 mock_subprocess: Mock fixture for subprocess operations. 

227 """ 

228 ruff_output = json.dumps( 

229 [ 

230 { 

231 "filename": "/path/to/file.py", 

232 "row": 15, 

233 "column": 100, 

234 "message": "Line too long (110 > 88)", 

235 "code": "E501", 

236 }, 

237 ], 

238 ) 

239 mock_subprocess.return_value = MagicMock(stdout=ruff_output, returncode=1) 

240 

241 result = check_line_length_violations(files=["file.py"], cwd="/path/to") 

242 

243 assert_that(result).is_length(1) 

244 assert_that(result[0].line).is_equal_to(15) 

245 assert_that(result[0].column).is_equal_to(100) 

246 

247 

248def test_check_line_length_multiple_violations( 

249 mock_ruff_available: MagicMock, 

250 mock_subprocess: MagicMock, 

251) -> None: 

252 """Test handling multiple E501 violations. 

253 

254 Args: 

255 mock_ruff_available: Mock fixture ensuring ruff is available. 

256 mock_subprocess: Mock fixture for subprocess operations. 

257 """ 

258 ruff_output = json.dumps( 

259 [ 

260 { 

261 "filename": "/path/file1.py", 

262 "location": {"row": 10, "column": 89}, 

263 "message": "Line too long (100 > 88)", 

264 "code": "E501", 

265 }, 

266 { 

267 "filename": "/path/file2.py", 

268 "location": {"row": 25, "column": 89}, 

269 "message": "Line too long (150 > 88)", 

270 "code": "E501", 

271 }, 

272 { 

273 "filename": "/path/file1.py", 

274 "location": {"row": 50, "column": 89}, 

275 "message": "Line too long (200 > 88)", 

276 "code": "E501", 

277 }, 

278 ], 

279 ) 

280 mock_subprocess.return_value = MagicMock(stdout=ruff_output, returncode=1) 

281 

282 result = check_line_length_violations(files=["file1.py", "file2.py"]) 

283 

284 assert_that(result).is_length(3) 

285 assert_that([v.file for v in result]).contains("/path/file1.py", "/path/file2.py") 

286 

287 

288def test_check_line_length_command_includes_required_flags( 

289 mock_ruff_available: MagicMock, 

290 mock_subprocess: MagicMock, 

291) -> None: 

292 """Test that the ruff command includes required flags. 

293 

294 Args: 

295 mock_ruff_available: Mock for ruff availability check. 

296 mock_subprocess: Mock for subprocess calls. 

297 """ 

298 check_line_length_violations(files=["test.py"]) 

299 

300 call_args = mock_subprocess.call_args 

301 cmd = call_args[0][0] 

302 

303 assert_that(cmd).contains("check") 

304 assert_that(cmd).contains("--select") 

305 assert_that(cmd).contains("E501") 

306 assert_that(cmd).contains("--output-format") 

307 assert_that(cmd).contains("json") 

308 assert_that(cmd).contains("--no-cache")