Coverage for tests / unit / tools / core / test_command_builders.py: 99%
210 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 command_builders module."""
3from __future__ import annotations
5import sys
6from collections.abc import Generator
7from unittest.mock import MagicMock, patch
9import pytest
10from assertpy import assert_that
12from lintro.enums.tool_name import ToolName
13from lintro.tools.core.command_builders import (
14 CargoBuilder,
15 CommandBuilder,
16 CommandBuilderRegistry,
17 NodeJSBuilder,
18 PytestBuilder,
19 PythonBundledBuilder,
20 StandaloneBuilder,
21)
24def _mock_which_for_venv(
25 *,
26 in_venv: bool,
27 in_path: str | None = None,
28 expected_names: str | set[str],
29) -> MagicMock:
30 """Create a shutil.which mock that controls venv vs PATH discovery.
32 When in_venv is True, shutil.which(tool, path=scripts_dir) returns
33 a path (simulating the tool being in the venv). When False, it returns
34 None for the venv lookup but returns in_path for the PATH lookup.
35 The mock validates that the requested name matches expected_names and
36 that the scripts directory path looks correct before returning results.
38 Args:
39 in_venv: Whether the tool should be found in the venv scripts dir.
40 in_path: Path to return for PATH-based discovery (None = not found).
41 expected_names: Executable name(s) this mock should respond to.
43 Returns:
44 Mock to use with patch("shutil.which", ...).
45 """
46 names = {expected_names} if isinstance(expected_names, str) else expected_names
48 def which_side_effect(
49 name: str,
50 path: str | None = None,
51 ) -> str | None:
52 if path is not None:
53 # Venv scripts lookup: validate name and path
54 if name not in names:
55 return None
56 if not path.endswith(("/bin", "\\Scripts")):
57 return None
58 return f"/fake/venv/bin/{name}" if in_venv else None
59 # PATH lookup: validate name
60 if name not in names:
61 return None
62 return in_path
64 return MagicMock(side_effect=which_side_effect)
67@pytest.fixture(autouse=True)
68def reset_registry() -> Generator[None, None, None]:
69 """Reset the command builder registry before and after each test.
71 Yields:
72 None: After clearing the registry and before restoring.
73 """
74 original_builders = CommandBuilderRegistry._builders.copy()
75 yield
76 CommandBuilderRegistry._builders = original_builders
79# =============================================================================
80# PythonBundledBuilder tests
81# =============================================================================
84def test_python_bundled_builder_handles_ruff() -> None:
85 """PythonBundledBuilder can handle ruff."""
86 builder = PythonBundledBuilder()
87 assert_that(builder.can_handle(ToolName.RUFF)).is_true()
90def test_python_bundled_builder_handles_black() -> None:
91 """PythonBundledBuilder can handle black."""
92 builder = PythonBundledBuilder()
93 assert_that(builder.can_handle(ToolName.BLACK)).is_true()
96def test_python_bundled_builder_handles_mypy() -> None:
97 """PythonBundledBuilder can handle mypy."""
98 builder = PythonBundledBuilder()
99 assert_that(builder.can_handle(ToolName.MYPY)).is_true()
102def test_python_bundled_builder_does_not_handle_markdownlint() -> None:
103 """PythonBundledBuilder does not handle Node.js tools."""
104 builder = PythonBundledBuilder()
105 assert_that(builder.can_handle(ToolName.MARKDOWNLINT)).is_false()
108def test_python_bundled_builder_prefers_path_binary_outside_venv() -> None:
109 """PythonBundledBuilder prefers PATH binary when outside venv."""
110 builder = PythonBundledBuilder()
111 # Simulate running outside a venv (prefix == base_prefix)
112 with (
113 patch("shutil.which", return_value="/usr/local/bin/ruff"),
114 patch(
115 "lintro.tools.core.command_builders.sys.prefix",
116 "/usr/local",
117 ),
118 patch(
119 "lintro.tools.core.command_builders.sys.base_prefix",
120 "/usr/local",
121 ),
122 patch(
123 "lintro.tools.core.command_builders._is_compiled_binary",
124 return_value=False,
125 ),
126 ):
127 cmd = builder.get_command("ruff", ToolName.RUFF)
128 assert_that(cmd).is_equal_to(["/usr/local/bin/ruff"])
131def test_python_bundled_builder_prefers_python_module_in_venv() -> None:
132 """PythonBundledBuilder prefers python -m when tool is in venv scripts."""
133 builder = PythonBundledBuilder()
134 # Simulate running inside a venv with the tool present in venv scripts
135 with (
136 patch(
137 "shutil.which",
138 _mock_which_for_venv(in_venv=True, expected_names="ruff"),
139 ),
140 patch(
141 "lintro.tools.core.command_builders.sys.prefix",
142 "/app/.venv",
143 ),
144 patch(
145 "lintro.tools.core.command_builders.sys.base_prefix",
146 "/usr/local",
147 ),
148 patch(
149 "lintro.tools.core.command_builders._is_compiled_binary",
150 return_value=False,
151 ),
152 patch(
153 "lintro.tools.core.command_builders.sysconfig.get_path",
154 return_value="/app/.venv/bin",
155 ),
156 ):
157 cmd = builder.get_command("ruff", ToolName.RUFF)
158 # Should return [python_exe, "-m", "ruff"] when tool is in venv
159 assert_that(cmd).is_length(3)
160 assert_that(cmd[0]).is_equal_to(sys.executable)
161 assert_that(cmd[1]).is_equal_to("-m")
162 assert_that(cmd[2]).is_equal_to("ruff")
165def test_python_bundled_builder_prefers_path_when_tool_not_in_venv() -> None:
166 """PythonBundledBuilder uses PATH when tool is not in venv (Homebrew)."""
167 builder = PythonBundledBuilder()
168 # Simulate Homebrew: in a venv, but tool is a separate Homebrew formula
169 with (
170 patch(
171 "shutil.which",
172 _mock_which_for_venv(
173 in_venv=False,
174 in_path="/opt/homebrew/bin/ruff",
175 expected_names="ruff",
176 ),
177 ),
178 patch(
179 "lintro.tools.core.command_builders.sys.prefix",
180 "/opt/homebrew/Cellar/lintro/0.57.7/libexec",
181 ),
182 patch(
183 "lintro.tools.core.command_builders.sys.base_prefix",
184 "/opt/homebrew/Cellar/python@3.13/3.13.0/Frameworks",
185 ),
186 patch(
187 "lintro.tools.core.command_builders._is_compiled_binary",
188 return_value=False,
189 ),
190 patch(
191 "lintro.tools.core.command_builders.sysconfig.get_path",
192 return_value="/opt/homebrew/Cellar/lintro/0.57.7/libexec/bin",
193 ),
194 ):
195 cmd = builder.get_command("ruff", ToolName.RUFF)
196 assert_that(cmd).is_equal_to(["/opt/homebrew/bin/ruff"])
199def test_python_bundled_builder_last_resort_python_m_in_venv() -> None:
200 """PythonBundledBuilder falls back to python -m when tool nowhere."""
201 builder = PythonBundledBuilder()
202 # In a venv, tool NOT in venv scripts, NOT in PATH
203 with (
204 patch(
205 "shutil.which",
206 _mock_which_for_venv(in_venv=False, in_path=None, expected_names="ruff"),
207 ),
208 patch(
209 "lintro.tools.core.command_builders.sys.prefix",
210 "/opt/homebrew/Cellar/lintro/0.57.7/libexec",
211 ),
212 patch(
213 "lintro.tools.core.command_builders.sys.base_prefix",
214 "/opt/homebrew/Cellar/python@3.13/3.13.0/Frameworks",
215 ),
216 patch(
217 "lintro.tools.core.command_builders._is_compiled_binary",
218 return_value=False,
219 ),
220 patch(
221 "lintro.tools.core.command_builders.sysconfig.get_path",
222 return_value="/opt/homebrew/Cellar/lintro/0.57.7/libexec/bin",
223 ),
224 ):
225 cmd = builder.get_command("ruff", ToolName.RUFF)
226 # Last resort: python -m
227 assert_that(cmd).is_length(3)
228 assert_that(cmd[0]).is_equal_to(sys.executable)
229 assert_that(cmd[1]).is_equal_to("-m")
230 assert_that(cmd[2]).is_equal_to("ruff")
233def test_python_bundled_builder_falls_back_to_python_module() -> None:
234 """PythonBundledBuilder falls back to python -m when tool not in PATH."""
235 builder = PythonBundledBuilder()
236 with (
237 patch("shutil.which", return_value=None),
238 patch(
239 "lintro.tools.core.command_builders._is_compiled_binary",
240 return_value=False,
241 ),
242 ):
243 cmd = builder.get_command("ruff", ToolName.RUFF)
244 # Should return [python_exe, "-m", "ruff"]
245 assert_that(cmd).is_length(3)
246 assert_that(cmd[0]).is_equal_to(sys.executable)
247 assert_that(cmd[1]).is_equal_to("-m")
248 assert_that(cmd[2]).is_equal_to("ruff")
251def test_python_bundled_builder_skips_python_module_when_compiled() -> None:
252 """PythonBundledBuilder skips python -m fallback when compiled."""
253 builder = PythonBundledBuilder()
254 with (
255 patch("shutil.which", return_value=None),
256 patch(
257 "lintro.tools.core.command_builders._is_compiled_binary",
258 return_value=True,
259 ),
260 ):
261 cmd = builder.get_command("ruff", ToolName.RUFF)
262 # Should return just [tool_name] when compiled
263 assert_that(cmd).is_equal_to(["ruff"])
266# =============================================================================
267# PytestBuilder tests
268# =============================================================================
271def test_pytest_builder_handles_pytest() -> None:
272 """PytestBuilder can handle pytest."""
273 builder = PytestBuilder()
274 assert_that(builder.can_handle(ToolName.PYTEST)).is_true()
277def test_pytest_builder_does_not_handle_ruff() -> None:
278 """PytestBuilder does not handle ruff."""
279 builder = PytestBuilder()
280 assert_that(builder.can_handle(ToolName.RUFF)).is_false()
283def test_pytest_builder_prefers_path_binary_outside_venv() -> None:
284 """PytestBuilder prefers PATH binary when outside venv."""
285 builder = PytestBuilder()
286 # Simulate running outside a venv (prefix == base_prefix)
287 with (
288 patch("shutil.which", return_value="/usr/local/bin/pytest"),
289 patch(
290 "lintro.tools.core.command_builders.sys.prefix",
291 "/usr/local",
292 ),
293 patch(
294 "lintro.tools.core.command_builders.sys.base_prefix",
295 "/usr/local",
296 ),
297 patch(
298 "lintro.tools.core.command_builders._is_compiled_binary",
299 return_value=False,
300 ),
301 ):
302 cmd = builder.get_command("pytest", ToolName.PYTEST)
303 assert_that(cmd).is_equal_to(["/usr/local/bin/pytest"])
306def test_pytest_builder_prefers_python_module_in_venv() -> None:
307 """PytestBuilder prefers python -m pytest when tool is in venv scripts."""
308 builder = PytestBuilder()
309 # Simulate running inside a venv with pytest present in venv scripts
310 with (
311 patch(
312 "shutil.which",
313 _mock_which_for_venv(in_venv=True, expected_names="pytest"),
314 ),
315 patch(
316 "lintro.tools.core.command_builders.sys.prefix",
317 "/app/.venv",
318 ),
319 patch(
320 "lintro.tools.core.command_builders.sys.base_prefix",
321 "/usr/local",
322 ),
323 patch(
324 "lintro.tools.core.command_builders._is_compiled_binary",
325 return_value=False,
326 ),
327 patch(
328 "lintro.tools.core.command_builders.sysconfig.get_path",
329 return_value="/app/.venv/bin",
330 ),
331 ):
332 cmd = builder.get_command("pytest", ToolName.PYTEST)
333 # Should return [python_exe, "-m", "pytest"] when tool is in venv
334 assert_that(cmd).is_length(3)
335 assert_that(cmd[0]).is_equal_to(sys.executable)
336 assert_that(cmd[1]).is_equal_to("-m")
337 assert_that(cmd[2]).is_equal_to("pytest")
340def test_pytest_builder_prefers_path_when_tool_not_in_venv() -> None:
341 """PytestBuilder uses PATH when pytest is not in venv (Homebrew)."""
342 builder = PytestBuilder()
343 with (
344 patch(
345 "shutil.which",
346 _mock_which_for_venv(
347 in_venv=False,
348 in_path="/opt/homebrew/bin/pytest",
349 expected_names="pytest",
350 ),
351 ),
352 patch(
353 "lintro.tools.core.command_builders.sys.prefix",
354 "/opt/homebrew/Cellar/lintro/0.57.7/libexec",
355 ),
356 patch(
357 "lintro.tools.core.command_builders.sys.base_prefix",
358 "/opt/homebrew/Cellar/python@3.13/3.13.0/Frameworks",
359 ),
360 patch(
361 "lintro.tools.core.command_builders._is_compiled_binary",
362 return_value=False,
363 ),
364 patch(
365 "lintro.tools.core.command_builders.sysconfig.get_path",
366 return_value="/opt/homebrew/Cellar/lintro/0.57.7/libexec/bin",
367 ),
368 ):
369 cmd = builder.get_command("pytest", ToolName.PYTEST)
370 assert_that(cmd).is_equal_to(["/opt/homebrew/bin/pytest"])
373def test_pytest_builder_last_resort_python_m_in_venv() -> None:
374 """PytestBuilder falls back to python -m when pytest nowhere."""
375 builder = PytestBuilder()
376 with (
377 patch(
378 "shutil.which",
379 _mock_which_for_venv(in_venv=False, in_path=None, expected_names="pytest"),
380 ),
381 patch(
382 "lintro.tools.core.command_builders.sys.prefix",
383 "/opt/homebrew/Cellar/lintro/0.57.7/libexec",
384 ),
385 patch(
386 "lintro.tools.core.command_builders.sys.base_prefix",
387 "/opt/homebrew/Cellar/python@3.13/3.13.0/Frameworks",
388 ),
389 patch(
390 "lintro.tools.core.command_builders._is_compiled_binary",
391 return_value=False,
392 ),
393 patch(
394 "lintro.tools.core.command_builders.sysconfig.get_path",
395 return_value="/opt/homebrew/Cellar/lintro/0.57.7/libexec/bin",
396 ),
397 ):
398 cmd = builder.get_command("pytest", ToolName.PYTEST)
399 assert_that(cmd).is_length(3)
400 assert_that(cmd[0]).is_equal_to(sys.executable)
401 assert_that(cmd[1]).is_equal_to("-m")
402 assert_that(cmd[2]).is_equal_to("pytest")
405def test_pytest_builder_falls_back_to_python_module() -> None:
406 """PytestBuilder falls back to python -m pytest when not in PATH."""
407 builder = PytestBuilder()
408 with (
409 patch("shutil.which", return_value=None),
410 patch(
411 "lintro.tools.core.command_builders._is_compiled_binary",
412 return_value=False,
413 ),
414 ):
415 cmd = builder.get_command("pytest", ToolName.PYTEST)
416 # Should return [python_exe, "-m", "pytest"]
417 assert_that(cmd).is_length(3)
418 assert_that(cmd[0]).is_equal_to(sys.executable)
419 assert_that(cmd[1]).is_equal_to("-m")
420 assert_that(cmd[2]).is_equal_to("pytest")
423def test_pytest_builder_skips_python_module_when_compiled() -> None:
424 """PytestBuilder skips python -m fallback when compiled."""
425 builder = PytestBuilder()
426 with (
427 patch("shutil.which", return_value=None),
428 patch(
429 "lintro.tools.core.command_builders._is_compiled_binary",
430 return_value=True,
431 ),
432 ):
433 cmd = builder.get_command("pytest", ToolName.PYTEST)
434 # Should return just ["pytest"] when compiled
435 assert_that(cmd).is_equal_to(["pytest"])
438# =============================================================================
439# NodeJSBuilder tests
440# =============================================================================
443def test_nodejs_builder_handles_markdownlint() -> None:
444 """NodeJSBuilder can handle markdownlint."""
445 builder = NodeJSBuilder()
446 assert_that(builder.can_handle(ToolName.MARKDOWNLINT)).is_true()
449def test_nodejs_builder_handles_astro_check() -> None:
450 """NodeJSBuilder can handle astro-check."""
451 builder = NodeJSBuilder()
452 assert_that(builder.can_handle(ToolName.ASTRO_CHECK)).is_true()
455def test_nodejs_builder_does_not_handle_ruff() -> None:
456 """NodeJSBuilder does not handle Python tools."""
457 builder = NodeJSBuilder()
458 assert_that(builder.can_handle(ToolName.RUFF)).is_false()
461def test_nodejs_builder_uses_bunx_when_available() -> None:
462 """NodeJSBuilder uses bunx when available."""
463 builder = NodeJSBuilder()
464 with patch("shutil.which", return_value="/usr/local/bin/bunx"):
465 cmd = builder.get_command("markdownlint", ToolName.MARKDOWNLINT)
466 assert_that(cmd).is_equal_to(["bunx", "markdownlint-cli2"])
469def test_nodejs_builder_falls_back_to_package_name() -> None:
470 """NodeJSBuilder falls back to package name when bunx not available."""
471 builder = NodeJSBuilder()
472 with patch("shutil.which", return_value=None):
473 cmd = builder.get_command("markdownlint", ToolName.MARKDOWNLINT)
474 assert_that(cmd).is_equal_to(["markdownlint-cli2"])
477def test_nodejs_builder_astro_check_uses_astro_binary() -> None:
478 """NodeJSBuilder resolves astro-check to astro binary."""
479 builder = NodeJSBuilder()
480 with patch("shutil.which", return_value="/usr/local/bin/bunx"):
481 cmd = builder.get_command("astro-check", ToolName.ASTRO_CHECK)
482 assert_that(cmd).is_equal_to(["bunx", "astro"])
485def test_nodejs_builder_handles_vue_tsc() -> None:
486 """NodeJSBuilder can handle vue-tsc."""
487 builder = NodeJSBuilder()
488 assert_that(builder.can_handle(ToolName.VUE_TSC)).is_true()
491def test_nodejs_builder_vue_tsc_uses_vue_tsc_binary() -> None:
492 """NodeJSBuilder resolves vue-tsc to vue-tsc binary."""
493 builder = NodeJSBuilder()
494 with patch("shutil.which", return_value="/usr/local/bin/bunx"):
495 cmd = builder.get_command("vue-tsc", ToolName.VUE_TSC)
496 assert_that(cmd).is_equal_to(["bunx", "vue-tsc"])
499# =============================================================================
500# CargoBuilder tests
501# =============================================================================
504def test_cargo_builder_handles_clippy() -> None:
505 """CargoBuilder can handle clippy."""
506 builder = CargoBuilder()
507 assert_that(builder.can_handle(ToolName.CLIPPY)).is_true()
510def test_cargo_builder_does_not_handle_ruff() -> None:
511 """CargoBuilder does not handle Python tools."""
512 builder = CargoBuilder()
513 assert_that(builder.can_handle(ToolName.RUFF)).is_false()
516def test_cargo_builder_returns_cargo_clippy() -> None:
517 """CargoBuilder returns ['cargo', 'clippy'] command."""
518 builder = CargoBuilder()
519 cmd = builder.get_command("clippy", ToolName.CLIPPY)
520 assert_that(cmd).is_equal_to(["cargo", "clippy"])
523def test_cargo_builder_handles_cargo_audit() -> None:
524 """CargoBuilder can handle cargo_audit."""
525 builder = CargoBuilder()
526 assert_that(builder.can_handle(ToolName.CARGO_AUDIT)).is_true()
529def test_cargo_builder_returns_cargo_audit() -> None:
530 """CargoBuilder returns ['cargo', 'audit'] command for cargo_audit."""
531 builder = CargoBuilder()
532 cmd = builder.get_command("cargo_audit", ToolName.CARGO_AUDIT)
533 assert_that(cmd).is_equal_to(["cargo", "audit"])
536# =============================================================================
537# StandaloneBuilder tests
538# =============================================================================
541def test_standalone_builder_handles_hadolint() -> None:
542 """StandaloneBuilder can handle hadolint."""
543 builder = StandaloneBuilder()
544 assert_that(builder.can_handle(ToolName.HADOLINT)).is_true()
547def test_standalone_builder_handles_actionlint() -> None:
548 """StandaloneBuilder can handle actionlint."""
549 builder = StandaloneBuilder()
550 assert_that(builder.can_handle(ToolName.ACTIONLINT)).is_true()
553def test_standalone_builder_does_not_handle_ruff() -> None:
554 """StandaloneBuilder does not handle Python bundled tools."""
555 builder = StandaloneBuilder()
556 assert_that(builder.can_handle(ToolName.RUFF)).is_false()
559def test_standalone_builder_returns_tool_name() -> None:
560 """StandaloneBuilder returns just the tool name."""
561 builder = StandaloneBuilder()
562 cmd = builder.get_command("hadolint", ToolName.HADOLINT)
563 assert_that(cmd).is_equal_to(["hadolint"])
566# =============================================================================
567# CommandBuilderRegistry tests
568# =============================================================================
571def test_registry_uses_first_matching_builder() -> None:
572 """Registry returns command from first builder that can_handle()."""
573 CommandBuilderRegistry.clear()
575 # Register a custom builder that handles ruff
576 class CustomRuffBuilder(CommandBuilder):
577 def can_handle(self, tool_name_enum: ToolName | None) -> bool:
578 return tool_name_enum == ToolName.RUFF
580 def get_command(
581 self,
582 tool_name: str,
583 tool_name_enum: ToolName | None,
584 ) -> list[str]:
585 return ["custom-ruff"]
587 CommandBuilderRegistry.register(CustomRuffBuilder())
588 CommandBuilderRegistry.register(PythonBundledBuilder())
590 cmd = CommandBuilderRegistry.get_command("ruff", ToolName.RUFF)
591 assert_that(cmd).is_equal_to(["custom-ruff"])
594def test_registry_fallback_to_tool_name() -> None:
595 """Registry falls back to [tool_name] if no builder matches."""
596 CommandBuilderRegistry.clear()
598 cmd = CommandBuilderRegistry.get_command("unknown_tool", None)
599 assert_that(cmd).is_equal_to(["unknown_tool"])
602def test_registry_is_registered() -> None:
603 """Registry can check if a builder exists for a tool."""
604 CommandBuilderRegistry.clear()
605 CommandBuilderRegistry.register(PythonBundledBuilder())
607 assert_that(CommandBuilderRegistry.is_registered(ToolName.RUFF)).is_true()
608 assert_that(CommandBuilderRegistry.is_registered(ToolName.MARKDOWNLINT)).is_false()
611def test_registry_clear() -> None:
612 """Registry clear removes all builders."""
613 CommandBuilderRegistry.clear()
614 CommandBuilderRegistry.register(PythonBundledBuilder())
616 assert_that(CommandBuilderRegistry._builders).is_length(1)
618 CommandBuilderRegistry.clear()
619 assert_that(CommandBuilderRegistry._builders).is_empty()