Coverage for tests / unit / plugins / base / test_execution.py: 99%

144 statements  

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

1"""Unit tests for BaseToolPlugin execution-related methods and ExecutionContext.""" 

2 

3from __future__ import annotations 

4 

5from pathlib import Path 

6from typing import TYPE_CHECKING 

7from unittest.mock import patch 

8 

9import pytest 

10from assertpy import assert_that 

11 

12from lintro.models.core.tool_result import ToolResult 

13from lintro.plugins.base import ( 

14 DEFAULT_EXCLUDE_PATTERNS, 

15 DEFAULT_TIMEOUT, 

16 ExecutionContext, 

17) 

18 

19from .conftest import NoFixPlugin 

20 

21if TYPE_CHECKING: 

22 from tests.unit.plugins.conftest import FakeToolPlugin 

23 

24 

25# ============================================================================= 

26# ExecutionContext Tests 

27# ============================================================================= 

28 

29 

30def test_execution_context_default_values() -> None: 

31 """Verify ExecutionContext initializes with expected default values.""" 

32 ctx = ExecutionContext() 

33 

34 assert_that(ctx.files).is_empty() 

35 assert_that(ctx.rel_files).is_empty() 

36 assert_that(ctx.cwd).is_none() 

37 assert_that(ctx.early_result).is_none() 

38 assert_that(ctx.timeout).is_equal_to(DEFAULT_TIMEOUT) 

39 

40 

41def test_execution_context_should_skip_false_when_no_early_result() -> None: 

42 """Verify should_skip is False when no early_result is set.""" 

43 ctx = ExecutionContext() 

44 

45 assert_that(ctx.should_skip).is_false() 

46 

47 

48def test_execution_context_should_skip_true_when_early_result_set() -> None: 

49 """Verify should_skip is True when early_result is set.""" 

50 result = ToolResult(name="test", success=True, output="", issues_count=0) 

51 ctx = ExecutionContext(early_result=result) 

52 

53 assert_that(ctx.should_skip).is_true() 

54 assert_that(ctx.early_result).is_instance_of(ToolResult) 

55 

56 

57# ============================================================================= 

58# BaseToolPlugin.fix Tests 

59# ============================================================================= 

60 

61 

62def test_fix_raises_not_implemented_when_can_fix_true_but_not_overridden( 

63 fake_tool_plugin: FakeToolPlugin, 

64) -> None: 

65 """Verify fix raises NotImplementedError when can_fix is True but not overridden. 

66 

67 Args: 

68 fake_tool_plugin: The fake tool plugin instance to test. 

69 """ 

70 with pytest.raises(NotImplementedError, match="Subclass must implement"): 

71 fake_tool_plugin.fix([], {}) 

72 

73 

74def test_fix_raises_not_implemented_when_cannot_fix() -> None: 

75 """Verify fix raises NotImplementedError when can_fix is False.""" 

76 plugin = NoFixPlugin() 

77 

78 with pytest.raises(NotImplementedError, match="does not support fixing"): 

79 plugin.fix([], {}) 

80 

81 

82# ============================================================================= 

83# BaseToolPlugin._validate_paths Tests 

84# ============================================================================= 

85 

86 

87def test_validate_paths_valid(fake_tool_plugin: FakeToolPlugin, tmp_path: Path) -> None: 

88 """Verify valid paths pass validation without raising. 

89 

90 Args: 

91 fake_tool_plugin: Fixture providing a FakeToolPlugin instance. 

92 tmp_path: Pytest temporary directory fixture. 

93 """ 

94 test_file = tmp_path / "test.py" 

95 test_file.write_text("print('hello')") 

96 

97 fake_tool_plugin._validate_paths([str(test_file)]) 

98 # Should not raise 

99 

100 

101def test_validate_paths_nonexistent_raises(fake_tool_plugin: FakeToolPlugin) -> None: 

102 """Verify nonexistent path raises FileNotFoundError. 

103 

104 Args: 

105 fake_tool_plugin: Fixture providing a FakeToolPlugin instance. 

106 """ 

107 with pytest.raises(FileNotFoundError, match="does not exist"): 

108 fake_tool_plugin._validate_paths(["/nonexistent/path"]) 

109 

110 

111def test_validate_paths_inaccessible_raises( 

112 fake_tool_plugin: FakeToolPlugin, 

113 tmp_path: Path, 

114) -> None: 

115 """Verify inaccessible path raises PermissionError. 

116 

117 Args: 

118 fake_tool_plugin: Fixture providing a FakeToolPlugin instance. 

119 tmp_path: Pytest temporary directory fixture. 

120 """ 

121 test_file = tmp_path / "test.py" 

122 test_file.write_text("print('hello')") 

123 

124 with ( 

125 patch("os.access", return_value=False), 

126 pytest.raises(PermissionError, match="not accessible"), 

127 ): 

128 fake_tool_plugin._validate_paths([str(test_file)]) 

129 

130 

131# ============================================================================= 

132# BaseToolPlugin._get_cwd Tests 

133# ============================================================================= 

134 

135 

136def test_get_cwd_single_file(fake_tool_plugin: FakeToolPlugin, tmp_path: Path) -> None: 

137 """Verify single file returns its parent directory as cwd. 

138 

139 Args: 

140 fake_tool_plugin: Fixture providing a FakeToolPlugin instance. 

141 tmp_path: Pytest temporary directory fixture. 

142 """ 

143 test_file = tmp_path / "test.py" 

144 test_file.write_text("") 

145 

146 result = fake_tool_plugin._get_cwd([str(test_file)]) 

147 

148 assert_that(result).is_equal_to(str(tmp_path)) 

149 

150 

151def test_get_cwd_multiple_files_same_directory( 

152 fake_tool_plugin: FakeToolPlugin, 

153 tmp_path: Path, 

154) -> None: 

155 """Verify multiple files in same directory return that directory as cwd. 

156 

157 Args: 

158 fake_tool_plugin: Fixture providing a FakeToolPlugin instance. 

159 tmp_path: Pytest temporary directory fixture. 

160 """ 

161 file1 = tmp_path / "a.py" 

162 file2 = tmp_path / "b.py" 

163 file1.write_text("") 

164 file2.write_text("") 

165 

166 result = fake_tool_plugin._get_cwd([str(file1), str(file2)]) 

167 

168 assert_that(result).is_equal_to(str(tmp_path)) 

169 

170 

171def test_get_cwd_multiple_files_different_directories( 

172 fake_tool_plugin: FakeToolPlugin, 

173 tmp_path: Path, 

174) -> None: 

175 """Verify multiple files in different directories return common parent as cwd. 

176 

177 Args: 

178 fake_tool_plugin: Fixture providing a FakeToolPlugin instance. 

179 tmp_path: Pytest temporary directory fixture. 

180 """ 

181 dir1 = tmp_path / "src" 

182 dir2 = tmp_path / "tests" 

183 dir1.mkdir() 

184 dir2.mkdir() 

185 file1 = dir1 / "a.py" 

186 file2 = dir2 / "b.py" 

187 file1.write_text("") 

188 file2.write_text("") 

189 

190 result = fake_tool_plugin._get_cwd([str(file1), str(file2)]) 

191 

192 assert_that(result).is_equal_to(str(tmp_path)) 

193 

194 

195def test_get_cwd_empty_paths_returns_none(fake_tool_plugin: FakeToolPlugin) -> None: 

196 """Verify empty paths list returns None. 

197 

198 Args: 

199 fake_tool_plugin: Fixture providing a FakeToolPlugin instance. 

200 """ 

201 result = fake_tool_plugin._get_cwd([]) 

202 

203 assert_that(result).is_none() 

204 

205 

206# ============================================================================= 

207# BaseToolPlugin._prepare_execution Tests 

208# ============================================================================= 

209 

210 

211def test_prepare_execution_version_check_fails_returns_early_result( 

212 fake_tool_plugin: FakeToolPlugin, 

213) -> None: 

214 """Verify early result is returned when version check fails. 

215 

216 Args: 

217 fake_tool_plugin: Fixture providing a FakeToolPlugin instance. 

218 """ 

219 skip_result = ToolResult( 

220 name="fake-tool", 

221 success=True, 

222 output="Version check failed", 

223 issues_count=0, 

224 ) 

225 

226 with patch( 

227 "lintro.plugins.execution_preparation.verify_tool_version", 

228 return_value=skip_result, 

229 ): 

230 ctx = fake_tool_plugin._prepare_execution(["."], {}) 

231 

232 assert_that(ctx.should_skip).is_true() 

233 assert_that(ctx.early_result).is_equal_to(skip_result) 

234 assert_that(ctx.early_result).is_instance_of(ToolResult) 

235 

236 

237def test_prepare_execution_empty_paths_returns_early_result( 

238 fake_tool_plugin: FakeToolPlugin, 

239) -> None: 

240 """Verify early result is returned for empty paths. 

241 

242 Args: 

243 fake_tool_plugin: Fixture providing a FakeToolPlugin instance. 

244 """ 

245 with patch( 

246 "lintro.plugins.execution_preparation.verify_tool_version", 

247 return_value=None, 

248 ): 

249 ctx = fake_tool_plugin._prepare_execution([], {}) 

250 

251 assert_that(ctx.should_skip).is_true() 

252 

253 

254def test_prepare_execution_no_files_found_returns_early_result( 

255 fake_tool_plugin: FakeToolPlugin, 

256 tmp_path: Path, 

257) -> None: 

258 """Verify early result is returned when no files are found. 

259 

260 Args: 

261 fake_tool_plugin: Fixture providing a FakeToolPlugin instance. 

262 tmp_path: Pytest temporary directory fixture. 

263 """ 

264 with ( 

265 patch( 

266 "lintro.plugins.execution_preparation.verify_tool_version", 

267 return_value=None, 

268 ), 

269 patch( 

270 "lintro.plugins.execution_preparation.discover_files", 

271 return_value=[], 

272 ), 

273 ): 

274 ctx = fake_tool_plugin._prepare_execution([str(tmp_path)], {}) 

275 

276 assert_that(ctx.should_skip).is_true() 

277 

278 

279def test_prepare_execution_successful_returns_context_with_files( 

280 fake_tool_plugin: FakeToolPlugin, 

281 tmp_path: Path, 

282) -> None: 

283 """Verify successful preparation returns context with discovered files. 

284 

285 Args: 

286 fake_tool_plugin: Fixture providing a FakeToolPlugin instance. 

287 tmp_path: Pytest temporary directory fixture. 

288 """ 

289 test_file = tmp_path / "test.py" 

290 test_file.write_text("print('hello')") 

291 

292 with ( 

293 patch( 

294 "lintro.plugins.execution_preparation.verify_tool_version", 

295 return_value=None, 

296 ), 

297 patch( 

298 "lintro.plugins.execution_preparation.discover_files", 

299 return_value=[str(test_file)], 

300 ), 

301 ): 

302 ctx = fake_tool_plugin._prepare_execution([str(tmp_path)], {}) 

303 

304 assert_that(ctx.should_skip).is_false() 

305 assert_that(ctx.files).is_length(1) 

306 assert_that(ctx.files).is_equal_to([str(test_file)]) 

307 

308 

309# ============================================================================= 

310# BaseToolPlugin._get_executable_command Tests 

311# ============================================================================= 

312 

313 

314@pytest.mark.parametrize( 

315 ("tool_name", "expected_contains"), 

316 [ 

317 pytest.param("ruff", ["-m", "ruff"], id="python_bundled_ruff"), 

318 pytest.param("pytest", ["-m", "pytest"], id="python_bundled_pytest"), 

319 ], 

320) 

321def test_get_executable_command_python_bundled_tools_fallback( 

322 fake_tool_plugin: FakeToolPlugin, 

323 tool_name: str, 

324 expected_contains: list[str], 

325) -> None: 

326 """Verify Python bundled tools fall back to python -m when not in PATH. 

327 

328 Args: 

329 fake_tool_plugin: Fixture providing a FakeToolPlugin instance. 

330 tool_name: Name of the tool being tested. 

331 expected_contains: List of expected substrings in the command. 

332 """ 

333 with ( 

334 patch("shutil.which", return_value=None), 

335 patch( 

336 "lintro.tools.core.command_builders._is_compiled_binary", 

337 return_value=False, 

338 ), 

339 ): 

340 result = fake_tool_plugin._get_executable_command(tool_name) 

341 

342 for item in expected_contains: 

343 assert_that(result).contains(item) 

344 

345 

346@pytest.mark.parametrize( 

347 "tool_name", 

348 [ 

349 pytest.param("ruff", id="python_bundled_ruff"), 

350 pytest.param("pytest", id="python_bundled_pytest"), 

351 ], 

352) 

353def test_get_executable_command_python_bundled_tools_path_binary_outside_venv( 

354 fake_tool_plugin: FakeToolPlugin, 

355 tool_name: str, 

356) -> None: 

357 """Verify Python bundled tools prefer PATH binary when outside venv. 

358 

359 Args: 

360 fake_tool_plugin: Fixture providing a FakeToolPlugin instance. 

361 tool_name: Name of the tool being tested. 

362 """ 

363 expected_path = f"/usr/local/bin/{tool_name}" 

364 # Simulate running outside a venv (prefix == base_prefix) 

365 with ( 

366 patch("shutil.which", return_value=expected_path), 

367 patch( 

368 "lintro.tools.core.command_builders.sys.prefix", 

369 "/usr/local", 

370 ), 

371 patch( 

372 "lintro.tools.core.command_builders.sys.base_prefix", 

373 "/usr/local", 

374 ), 

375 patch( 

376 "lintro.tools.core.command_builders._is_compiled_binary", 

377 return_value=False, 

378 ), 

379 ): 

380 result = fake_tool_plugin._get_executable_command(tool_name) 

381 

382 assert_that(result).is_equal_to([expected_path]) 

383 

384 

385@pytest.mark.parametrize( 

386 "tool_name", 

387 [ 

388 pytest.param("ruff", id="python_bundled_ruff"), 

389 pytest.param("pytest", id="python_bundled_pytest"), 

390 ], 

391) 

392def test_get_executable_command_python_bundled_tools_python_module_in_venv( 

393 fake_tool_plugin: FakeToolPlugin, 

394 tool_name: str, 

395) -> None: 

396 """Verify Python bundled tools prefer python -m when tool is in venv bin. 

397 

398 Args: 

399 fake_tool_plugin: Fixture providing a FakeToolPlugin instance. 

400 tool_name: Name of the tool being tested. 

401 """ 

402 

403 # Mock shutil.which to find tool in venv scripts dir but not via PATH 

404 def which_side_effect( 

405 name: str, 

406 path: str | None = None, 

407 ) -> str | None: 

408 if path is not None: 

409 return f"/app/.venv/bin/{name}" # Found in venv scripts 

410 return f"/usr/local/bin/{name}" # Also in PATH 

411 

412 # Simulate running inside a venv with tool present in venv scripts 

413 with ( 

414 patch("shutil.which", side_effect=which_side_effect), 

415 patch( 

416 "lintro.tools.core.command_builders.sys.prefix", 

417 "/app/.venv", 

418 ), 

419 patch( 

420 "lintro.tools.core.command_builders.sys.base_prefix", 

421 "/usr/local", 

422 ), 

423 patch( 

424 "lintro.tools.core.command_builders._is_compiled_binary", 

425 return_value=False, 

426 ), 

427 patch( 

428 "lintro.tools.core.command_builders.sysconfig.get_path", 

429 return_value="/app/.venv/bin", 

430 ), 

431 ): 

432 result = fake_tool_plugin._get_executable_command(tool_name) 

433 

434 # Should return [python_exe, "-m", tool_name] when tool is in venv 

435 assert_that(result).is_length(3) 

436 assert_that(result[1]).is_equal_to("-m") 

437 assert_that(result[2]).is_equal_to(tool_name) 

438 

439 

440def test_get_executable_command_nodejs_tool_with_bunx( 

441 fake_tool_plugin: FakeToolPlugin, 

442) -> None: 

443 """Verify Node.js tools return bunx command when bunx is available. 

444 

445 Args: 

446 fake_tool_plugin: Fixture providing a FakeToolPlugin instance. 

447 """ 

448 from lintro.enums.tool_name import ToolName 

449 

450 with patch("shutil.which", return_value="/usr/bin/bunx"): 

451 result = fake_tool_plugin._get_executable_command(ToolName.MARKDOWNLINT) 

452 

453 assert_that(result).contains("bunx", "markdownlint-cli2") 

454 

455 

456def test_get_executable_command_astro_check_with_bunx( 

457 fake_tool_plugin: FakeToolPlugin, 

458) -> None: 

459 """Verify astro-check resolves to bunx astro command.""" 

460 with patch("shutil.which", return_value="/usr/bin/bunx"): 

461 result = fake_tool_plugin._get_executable_command("astro-check") 

462 

463 assert_that(result).is_equal_to(["bunx", "astro"]) 

464 

465 

466def test_get_executable_command_vue_tsc_with_bunx( 

467 fake_tool_plugin: FakeToolPlugin, 

468) -> None: 

469 """Verify vue-tsc resolves to bunx vue-tsc command.""" 

470 with patch("shutil.which", return_value="/usr/bin/bunx"): 

471 result = fake_tool_plugin._get_executable_command("vue-tsc") 

472 

473 assert_that(result).is_equal_to(["bunx", "vue-tsc"]) 

474 

475 

476def test_get_executable_command_nodejs_tool_without_bunx( 

477 fake_tool_plugin: FakeToolPlugin, 

478) -> None: 

479 """Verify Node.js tools return tool name when bunx is not available. 

480 

481 Args: 

482 fake_tool_plugin: Fixture providing a FakeToolPlugin instance. 

483 """ 

484 from lintro.enums.tool_name import ToolName 

485 

486 with patch("shutil.which", return_value=None): 

487 result = fake_tool_plugin._get_executable_command(ToolName.MARKDOWNLINT) 

488 

489 assert_that(result).is_equal_to(["markdownlint-cli2"]) 

490 

491 

492def test_get_executable_command_cargo_tool(fake_tool_plugin: FakeToolPlugin) -> None: 

493 """Verify Rust/Cargo tools return cargo command. 

494 

495 Args: 

496 fake_tool_plugin: Fixture providing a FakeToolPlugin instance. 

497 """ 

498 result = fake_tool_plugin._get_executable_command("clippy") 

499 

500 assert_that(result).is_equal_to(["cargo", "clippy"]) 

501 

502 

503def test_get_executable_command_unknown_tool(fake_tool_plugin: FakeToolPlugin) -> None: 

504 """Verify unknown tools return just the tool name. 

505 

506 Args: 

507 fake_tool_plugin: Fixture providing a FakeToolPlugin instance. 

508 """ 

509 result = fake_tool_plugin._get_executable_command("unknown-tool") 

510 

511 assert_that(result).is_equal_to(["unknown-tool"]) 

512 

513 

514# ============================================================================= 

515# Module Constants Tests 

516# ============================================================================= 

517 

518 

519@pytest.mark.parametrize( 

520 "pattern", 

521 [ 

522 pytest.param(".git", id="git_directory"), 

523 pytest.param("__pycache__", id="pycache_directory"), 

524 pytest.param("*.pyc", id="pyc_files"), 

525 ], 

526) 

527def test_default_exclude_patterns_contains_expected_patterns(pattern: str) -> None: 

528 """Verify DEFAULT_EXCLUDE_PATTERNS contains essential patterns. 

529 

530 Args: 

531 pattern: The pattern to check for in DEFAULT_EXCLUDE_PATTERNS. 

532 """ 

533 assert_that(pattern in DEFAULT_EXCLUDE_PATTERNS).is_true() 

534 

535 

536def test_default_exclude_patterns_is_not_empty() -> None: 

537 """Verify DEFAULT_EXCLUDE_PATTERNS is not empty.""" 

538 assert_that(DEFAULT_EXCLUDE_PATTERNS).is_not_empty()