Coverage for tests / unit / tools / test_edge_cases.py: 95%

131 statements  

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

1"""Edge case tests for tool plugin handling. 

2 

3This module tests edge cases that may occur in real-world usage: 

4- Symlink handling (regular and broken symlinks) 

5- Long file paths (200, 260, 500+ characters) 

6- Unicode in output (bullets, arrows, CJK, emoji, accented chars) 

7- Concurrent execution thread safety 

8""" 

9 

10from __future__ import annotations 

11 

12from concurrent.futures import ThreadPoolExecutor 

13from pathlib import Path 

14from typing import TYPE_CHECKING 

15from unittest.mock import MagicMock, patch 

16 

17import pytest 

18from assertpy import assert_that 

19 

20from lintro.enums.tool_name import ToolName 

21from lintro.models.core.tool_result import ToolResult 

22from lintro.tools.definitions.ruff import RuffPlugin 

23 

24if TYPE_CHECKING: 

25 from collections.abc import Callable 

26 

27 

28# ============================================================================= 

29# Symlink handling tests 

30# ============================================================================= 

31 

32 

33def test_regular_symlink_is_followed(tmp_path: Path) -> None: 

34 """Verify regular symlinks are correctly resolved and processed. 

35 

36 Args: 

37 tmp_path: Temporary directory path for test files. 

38 """ 

39 # Create a real file 

40 real_file = tmp_path / "real_file.py" 

41 real_file.write_text("x = 1\n") 

42 

43 # Create a symlink to the file 

44 symlink_path = tmp_path / "symlink_file.py" 

45 symlink_path.symlink_to(real_file) 

46 

47 assert_that(symlink_path.exists()).is_true() 

48 assert_that(symlink_path.is_symlink()).is_true() 

49 assert_that(symlink_path.resolve()).is_equal_to(real_file.resolve()) 

50 

51 

52def test_broken_symlink_handled_gracefully(tmp_path: Path) -> None: 

53 """Verify broken symlinks don't cause crashes during path filtering. 

54 

55 Args: 

56 tmp_path: Temporary directory path for test files. 

57 """ 

58 # Create a symlink to a non-existent target 

59 broken_symlink = tmp_path / "broken_link.py" 

60 target_path = tmp_path / "nonexistent.py" 

61 broken_symlink.symlink_to(target_path) 

62 

63 # The symlink exists as a link but its target doesn't 

64 assert_that(broken_symlink.is_symlink()).is_true() 

65 assert_that(broken_symlink.exists()).is_false() 

66 

67 # Verify we can check for broken symlinks 

68 try: 

69 broken_symlink.resolve(strict=True) 

70 resolved = True 

71 except FileNotFoundError: 

72 resolved = False 

73 

74 assert_that(resolved).is_false() 

75 

76 

77def test_symlink_directory_traversal(tmp_path: Path) -> None: 

78 """Verify symlinks to directories are handled correctly. 

79 

80 Args: 

81 tmp_path: Temporary directory path for test files. 

82 """ 

83 # Create a subdirectory with a file 

84 subdir = tmp_path / "subdir" 

85 subdir.mkdir() 

86 (subdir / "file.py").write_text("y = 2\n") 

87 

88 # Create a symlink to the directory 

89 dir_link = tmp_path / "link_to_subdir" 

90 dir_link.symlink_to(subdir) 

91 

92 # File should be accessible through the symlink 

93 linked_file = dir_link / "file.py" 

94 assert_that(linked_file.exists()).is_true() 

95 assert_that(linked_file.read_text()).is_equal_to("y = 2\n") 

96 

97 

98# ============================================================================= 

99# Long file path tests 

100# ============================================================================= 

101 

102 

103@pytest.mark.parametrize( 

104 ("path_length", "description"), 

105 [ 

106 pytest.param(200, "long-path-200", id="200-chars"), 

107 pytest.param(250, "near-windows-limit", id="250-chars"), 

108 ], 

109) 

110def test_long_file_paths_handled( 

111 tmp_path: Path, 

112 path_length: int, 

113 description: str, 

114) -> None: 

115 """Verify long file paths are handled correctly. 

116 

117 Args: 

118 tmp_path: Temporary directory path for test files. 

119 path_length: Target path length to test. 

120 description: Test description. 

121 """ 

122 # Calculate how many nested directories we need 

123 # Each directory adds ~5 chars (name + separator) 

124 base_len = len(str(tmp_path)) 

125 remaining = path_length - base_len - 10 # Leave room for filename 

126 

127 # Create nested directories 

128 current = tmp_path 

129 segment_len = 8 # Directory name length 

130 while len(str(current)) < base_len + remaining: 

131 dir_name = "d" * segment_len 

132 current = current / dir_name 

133 try: 

134 current.mkdir(exist_ok=True) 

135 except OSError: 

136 # Path might be too long for the OS 

137 break 

138 

139 # Create a file in the deepest directory 

140 if current.exists(): 

141 test_file = current / "test.py" 

142 try: 

143 test_file.write_text("x = 1\n") 

144 assert_that(test_file.exists()).is_true() 

145 assert_that(len(str(test_file))).is_greater_than(100) 

146 except OSError: 

147 # File creation might fail on some systems 

148 pass 

149 

150 

151def test_path_with_spaces_and_special_chars(tmp_path: Path) -> None: 

152 """Verify paths with spaces and special characters work correctly. 

153 

154 Args: 

155 tmp_path: Temporary directory path for test files. 

156 """ 

157 # Create a directory with spaces and special chars 

158 special_dir = tmp_path / "path with spaces" 

159 special_dir.mkdir() 

160 

161 test_file = special_dir / "file [1].py" 

162 test_file.write_text("x = 1\n") 

163 

164 assert_that(test_file.exists()).is_true() 

165 assert_that(test_file.read_text()).is_equal_to("x = 1\n") 

166 

167 

168# ============================================================================= 

169# Unicode output tests 

170# ============================================================================= 

171 

172 

173UNICODE_TEST_CASES = [ 

174 pytest.param("• Bullet point", "bullet", id="bullet"), 

175 pytest.param("→ Arrow", "arrow", id="arrow"), 

176 pytest.param("✓ Check mark", "checkmark", id="checkmark"), 

177 pytest.param("✗ Cross mark", "crossmark", id="crossmark"), 

178 pytest.param("中文测试", "cjk-chinese", id="chinese"), 

179 pytest.param("日本語テスト", "cjk-japanese", id="japanese"), 

180 pytest.param("한국어 테스트", "cjk-korean", id="korean"), 

181 pytest.param("😀 Emoji test", "emoji", id="emoji"), 

182 pytest.param("Ñ ñ á é í ó ú", "accented", id="accented"), 

183 pytest.param("αβγδ ΑΒΓΔ", "greek", id="greek"), 

184 pytest.param("∀∃∅∈∉∋∌", "math", id="math-symbols"), 

185] 

186 

187 

188@pytest.mark.parametrize( 

189 ("unicode_text", "category"), 

190 UNICODE_TEST_CASES, 

191) 

192def test_unicode_in_tool_output(unicode_text: str, category: str) -> None: 

193 """Verify Unicode characters in tool output are handled correctly. 

194 

195 Args: 

196 unicode_text: Unicode text to test. 

197 category: Category of Unicode being tested. 

198 """ 

199 # Create a mock tool result with Unicode in output 

200 result = ToolResult( 

201 name=ToolName.RUFF, 

202 success=True, 

203 issues_count=0, 

204 output=f"Message: {unicode_text}", 

205 issues=None, 

206 ) 

207 

208 assert_that(result.output).contains(unicode_text) 

209 assert_that(str(result.output)).is_not_empty() 

210 

211 

212@pytest.mark.parametrize( 

213 ("unicode_text", "category"), 

214 UNICODE_TEST_CASES, 

215) 

216def test_unicode_in_file_paths( 

217 tmp_path: Path, 

218 unicode_text: str, 

219 category: str, 

220) -> None: 

221 """Verify Unicode characters in file paths are handled correctly. 

222 

223 Args: 

224 tmp_path: Temporary directory path for test files. 

225 unicode_text: Unicode text to test in file name. 

226 category: Category of Unicode being tested. 

227 """ 

228 # Create a file with Unicode in the name 

229 # Remove characters that are invalid in file names 

230 safe_name = unicode_text.replace("/", "_").replace("\\", "_") 

231 safe_name = "".join(c for c in safe_name if c.isalnum() or c in " _-")[:20] 

232 

233 if not safe_name.strip(): 

234 safe_name = "unicode_file" 

235 

236 test_file = tmp_path / f"{safe_name}.py" 

237 try: 

238 test_file.write_text("x = 1\n") 

239 assert_that(test_file.exists()).is_true() 

240 except (OSError, UnicodeEncodeError): 

241 # Some systems may not support all Unicode in filenames 

242 pytest.skip(f"System doesn't support {category} in filenames") 

243 

244 

245# ============================================================================= 

246# Concurrent execution tests 

247# ============================================================================= 

248 

249 

250def test_concurrent_tool_result_creation() -> None: 

251 """Verify ToolResult creation is thread-safe under concurrent access. 

252 

253 Multiple threads creating ToolResult objects simultaneously should 

254 not cause race conditions or data corruption. 

255 """ 

256 results: list[ToolResult] = [] 

257 

258 def create_result(index: int) -> ToolResult: 

259 return ToolResult( 

260 name=ToolName.RUFF, 

261 success=index % 2 == 0, 

262 issues_count=index, 

263 output=f"Output {index}", 

264 issues=None, 

265 ) 

266 

267 with ThreadPoolExecutor(max_workers=4) as executor: 

268 futures = [executor.submit(create_result, i) for i in range(20)] 

269 results = [f.result() for f in futures] 

270 

271 assert_that(results).is_length(20) 

272 for i, result in enumerate(results): 

273 assert_that(result.issues_count).is_equal_to(i) 

274 assert_that(result.output).is_equal_to(f"Output {i}") 

275 

276 

277def test_concurrent_plugin_instantiation( 

278 mock_execution_context_factory: Callable[..., MagicMock], 

279) -> None: 

280 """Verify plugin instances can be created concurrently. 

281 

282 Args: 

283 mock_execution_context_factory: Factory for creating mock execution contexts. 

284 """ 

285 plugins: list[RuffPlugin] = [] 

286 

287 def create_plugin(_: int) -> RuffPlugin: 

288 return RuffPlugin() 

289 

290 with ThreadPoolExecutor(max_workers=4) as executor: 

291 futures = [executor.submit(create_plugin, i) for i in range(10)] 

292 plugins = [f.result() for f in futures] 

293 

294 assert_that(plugins).is_length(10) 

295 for plugin in plugins: 

296 assert_that(plugin.definition.name).is_equal_to(ToolName.RUFF) 

297 

298 

299# ============================================================================= 

300# Empty and edge input tests 

301# ============================================================================= 

302 

303 

304def test_empty_file_list_handling() -> None: 

305 """Verify empty file list is handled gracefully.""" 

306 plugin = RuffPlugin() 

307 

308 with patch.object(plugin, "_prepare_execution") as mock_prepare: 

309 mock_ctx = MagicMock() 

310 mock_ctx.should_skip = True 

311 mock_ctx.early_result = ToolResult( 

312 name=ToolName.RUFF, 

313 success=True, 

314 issues_count=0, 

315 output="", 

316 issues=None, 

317 ) 

318 mock_prepare.return_value = mock_ctx 

319 

320 result = plugin.check([], {}) 

321 assert_that(result.success).is_true() 

322 assert_that(result.issues_count).is_equal_to(0) 

323 

324 

325def test_single_file_handling(tmp_path: Path) -> None: 

326 """Verify single file is processed correctly. 

327 

328 Args: 

329 tmp_path: Temporary directory path for test files. 

330 """ 

331 test_file = tmp_path / "single.py" 

332 test_file.write_text("x = 1\n") 

333 

334 plugin = RuffPlugin() 

335 

336 with ( 

337 patch.object(plugin, "_prepare_execution") as mock_prepare, 

338 patch.object(plugin, "_run_subprocess", return_value=(True, "")), 

339 ): 

340 mock_ctx = MagicMock() 

341 mock_ctx.should_skip = False 

342 mock_ctx.early_result = None 

343 mock_ctx.files = [str(test_file)] 

344 mock_ctx.rel_files = ["single.py"] 

345 mock_ctx.timeout = 30 

346 mock_ctx.cwd = str(tmp_path) 

347 mock_prepare.return_value = mock_ctx 

348 

349 result = plugin.check([str(test_file)], {}) 

350 assert_that(result.name).is_equal_to(ToolName.RUFF)