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
« 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."""
3from __future__ import annotations
5from pathlib import Path
6from typing import TYPE_CHECKING
7from unittest.mock import patch
9import pytest
10from assertpy import assert_that
12from lintro.models.core.tool_result import ToolResult
13from lintro.plugins.base import (
14 DEFAULT_EXCLUDE_PATTERNS,
15 DEFAULT_TIMEOUT,
16 ExecutionContext,
17)
19from .conftest import NoFixPlugin
21if TYPE_CHECKING:
22 from tests.unit.plugins.conftest import FakeToolPlugin
25# =============================================================================
26# ExecutionContext Tests
27# =============================================================================
30def test_execution_context_default_values() -> None:
31 """Verify ExecutionContext initializes with expected default values."""
32 ctx = ExecutionContext()
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)
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()
45 assert_that(ctx.should_skip).is_false()
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)
53 assert_that(ctx.should_skip).is_true()
54 assert_that(ctx.early_result).is_instance_of(ToolResult)
57# =============================================================================
58# BaseToolPlugin.fix Tests
59# =============================================================================
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.
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([], {})
74def test_fix_raises_not_implemented_when_cannot_fix() -> None:
75 """Verify fix raises NotImplementedError when can_fix is False."""
76 plugin = NoFixPlugin()
78 with pytest.raises(NotImplementedError, match="does not support fixing"):
79 plugin.fix([], {})
82# =============================================================================
83# BaseToolPlugin._validate_paths Tests
84# =============================================================================
87def test_validate_paths_valid(fake_tool_plugin: FakeToolPlugin, tmp_path: Path) -> None:
88 """Verify valid paths pass validation without raising.
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')")
97 fake_tool_plugin._validate_paths([str(test_file)])
98 # Should not raise
101def test_validate_paths_nonexistent_raises(fake_tool_plugin: FakeToolPlugin) -> None:
102 """Verify nonexistent path raises FileNotFoundError.
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"])
111def test_validate_paths_inaccessible_raises(
112 fake_tool_plugin: FakeToolPlugin,
113 tmp_path: Path,
114) -> None:
115 """Verify inaccessible path raises PermissionError.
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')")
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)])
131# =============================================================================
132# BaseToolPlugin._get_cwd Tests
133# =============================================================================
136def test_get_cwd_single_file(fake_tool_plugin: FakeToolPlugin, tmp_path: Path) -> None:
137 """Verify single file returns its parent directory as cwd.
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("")
146 result = fake_tool_plugin._get_cwd([str(test_file)])
148 assert_that(result).is_equal_to(str(tmp_path))
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.
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("")
166 result = fake_tool_plugin._get_cwd([str(file1), str(file2)])
168 assert_that(result).is_equal_to(str(tmp_path))
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.
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("")
190 result = fake_tool_plugin._get_cwd([str(file1), str(file2)])
192 assert_that(result).is_equal_to(str(tmp_path))
195def test_get_cwd_empty_paths_returns_none(fake_tool_plugin: FakeToolPlugin) -> None:
196 """Verify empty paths list returns None.
198 Args:
199 fake_tool_plugin: Fixture providing a FakeToolPlugin instance.
200 """
201 result = fake_tool_plugin._get_cwd([])
203 assert_that(result).is_none()
206# =============================================================================
207# BaseToolPlugin._prepare_execution Tests
208# =============================================================================
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.
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 )
226 with patch(
227 "lintro.plugins.execution_preparation.verify_tool_version",
228 return_value=skip_result,
229 ):
230 ctx = fake_tool_plugin._prepare_execution(["."], {})
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)
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.
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([], {})
251 assert_that(ctx.should_skip).is_true()
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.
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)], {})
276 assert_that(ctx.should_skip).is_true()
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.
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')")
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)], {})
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)])
309# =============================================================================
310# BaseToolPlugin._get_executable_command Tests
311# =============================================================================
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.
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)
342 for item in expected_contains:
343 assert_that(result).contains(item)
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.
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)
382 assert_that(result).is_equal_to([expected_path])
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.
398 Args:
399 fake_tool_plugin: Fixture providing a FakeToolPlugin instance.
400 tool_name: Name of the tool being tested.
401 """
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
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)
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)
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.
445 Args:
446 fake_tool_plugin: Fixture providing a FakeToolPlugin instance.
447 """
448 from lintro.enums.tool_name import ToolName
450 with patch("shutil.which", return_value="/usr/bin/bunx"):
451 result = fake_tool_plugin._get_executable_command(ToolName.MARKDOWNLINT)
453 assert_that(result).contains("bunx", "markdownlint-cli2")
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")
463 assert_that(result).is_equal_to(["bunx", "astro"])
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")
473 assert_that(result).is_equal_to(["bunx", "vue-tsc"])
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.
481 Args:
482 fake_tool_plugin: Fixture providing a FakeToolPlugin instance.
483 """
484 from lintro.enums.tool_name import ToolName
486 with patch("shutil.which", return_value=None):
487 result = fake_tool_plugin._get_executable_command(ToolName.MARKDOWNLINT)
489 assert_that(result).is_equal_to(["markdownlint-cli2"])
492def test_get_executable_command_cargo_tool(fake_tool_plugin: FakeToolPlugin) -> None:
493 """Verify Rust/Cargo tools return cargo command.
495 Args:
496 fake_tool_plugin: Fixture providing a FakeToolPlugin instance.
497 """
498 result = fake_tool_plugin._get_executable_command("clippy")
500 assert_that(result).is_equal_to(["cargo", "clippy"])
503def test_get_executable_command_unknown_tool(fake_tool_plugin: FakeToolPlugin) -> None:
504 """Verify unknown tools return just the tool name.
506 Args:
507 fake_tool_plugin: Fixture providing a FakeToolPlugin instance.
508 """
509 result = fake_tool_plugin._get_executable_command("unknown-tool")
511 assert_that(result).is_equal_to(["unknown-tool"])
514# =============================================================================
515# Module Constants Tests
516# =============================================================================
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.
530 Args:
531 pattern: The pattern to check for in DEFAULT_EXCLUDE_PATTERNS.
532 """
533 assert_that(pattern in DEFAULT_EXCLUDE_PATTERNS).is_true()
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()