Coverage for tests / unit / core / test_version_requirements.py: 98%
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"""Tests for version requirements functionality."""
3from __future__ import annotations
5import json
6from pathlib import Path
7from typing import TYPE_CHECKING
8from unittest.mock import MagicMock, patch
10import pytest
11from assertpy import assert_that
12from packaging.version import Version
14if TYPE_CHECKING:
15 pass
17from lintro._tool_versions import TOOL_VERSIONS, get_min_version, get_tool_version
18from lintro.enums.tool_name import ToolName, normalize_tool_name
19from lintro.tools.core.version_parsing import (
20 ToolVersionInfo,
21 check_tool_version,
22 compare_versions,
23 extract_version_from_output,
24 get_install_hints,
25 get_minimum_versions,
26 parse_version,
27)
28from lintro.tools.core.version_requirements import get_all_tool_versions
31@pytest.mark.parametrize(
32 "version_str,expected",
33 [
34 ("1.2.3", Version("1.2.3")),
35 ("0.14.0", Version("0.14.0")),
36 ("2.0", Version("2.0")),
37 ("1.0.0-alpha", Version("1.0.0")), # Pre-release suffix stripped
38 ("v1.5.0", Version("1.5.0")), # Handle leading 'v'
39 ],
40)
41def test_parse_version(version_str: str, expected: Version) -> None:
42 """Test version string parsing using packaging.version.
44 Args:
45 version_str: Version string to parse.
46 expected: Expected parsed Version object.
47 """
48 assert_that(parse_version(version_str)).is_equal_to(expected)
51def test_parse_version_invalid() -> None:
52 """Test that parse_version raises ValueError for invalid input."""
53 with pytest.raises(ValueError, match="Unable to parse version string"):
54 parse_version("invalid")
57@pytest.mark.parametrize(
58 "version1,version2,expected",
59 [
60 ("1.2.3", "1.2.3", 0), # Equal
61 ("1.2.3", "1.2.4", -1), # version1 < version2
62 ("1.3.0", "1.2.4", 1), # version1 > version2
63 ("2.0.0", "1.9.9", 1), # Major version difference
64 ("1.10.0", "1.2.0", 1), # Minor version difference
65 ],
66)
67def test_compare_versions(version1: str, version2: str, expected: int) -> None:
68 """Test version comparison.
70 Args:
71 version1: First version string to compare.
72 version2: Second version string to compare.
73 expected: Expected comparison result (-1, 0, or 1).
74 """
75 assert_that(compare_versions(version1, version2)).is_equal_to(expected)
78@pytest.mark.parametrize(
79 "tool_name,output,expected",
80 [
81 ("black", "black, 25.9.0 (compiled: yes)", "25.9.0"),
82 ("bandit", "__main__.py 1.8.6", "1.8.6"),
83 ("hadolint", "Haskell Dockerfile Linter 2.14.0", "2.14.0"),
84 ("actionlint", "actionlint 1.7.5", "1.7.5"),
85 ("pydoclint", "pydoclint 0.5.9", "0.5.9"),
86 ("semgrep", "semgrep 1.148.0", "1.148.0"),
87 ("ruff", "ruff 0.14.4", "0.14.4"),
88 ("yamllint", "yamllint 1.37.1", "1.37.1"),
89 # Astro: actual output has "v" prefix and double space
90 ("astro-check", "astro v5.5.3", "5.5.3"),
91 # Vue-tsc: simple version output
92 ("vue-tsc", "2.2.4", "2.2.4"),
93 # Clippy: rustc output should extract Rust version directly
94 ("clippy", "rustc 1.92.0 (ded5c06cf 2025-12-08)", "1.92.0"),
95 # Clippy: clippy output should convert 0.1.X to 1.X.0
96 ("clippy", "clippy 0.1.92 (ded5c06cf2 2025-12-08)", "1.92.0"),
97 ("clippy", "clippy 0.1.75 (abcdef123 2024-01-01)", "1.75.0"),
98 ],
99)
100def test_extract_version_from_output(
101 tool_name: str,
102 output: str,
103 expected: str,
104) -> None:
105 """Test version extraction from various tool outputs.
107 Args:
108 tool_name: Name of the tool.
109 output: Raw version output string from tool.
110 expected: Expected extracted version string.
111 """
112 assert_that(extract_version_from_output(output, tool_name)).is_equal_to(expected)
115def test_get_minimum_versions_from_tool_versions() -> None:
116 """Test reading minimum versions from _tool_versions.py."""
117 versions = get_minimum_versions()
119 # Should include external tools from _tool_versions.py
120 assert_that(versions).contains_key("hadolint")
121 assert_that(versions).contains_key("actionlint")
122 assert_that(versions).contains_key("pytest")
123 assert_that(versions).contains_key("semgrep")
125 # Versions should be strings
126 assert_that(versions["hadolint"]).is_instance_of(str)
127 assert_that(versions["actionlint"]).is_instance_of(str)
130@pytest.mark.parametrize(
131 "tool_name",
132 [
133 ToolName.ACTIONLINT,
134 ToolName.HADOLINT,
135 ToolName.OXLINT,
136 ToolName.PRETTIER,
137 ToolName.SEMGREP,
138 ToolName.SHELLCHECK,
139 ToolName.TSC,
140 ],
141)
142def test_get_min_version_returns_version_for_registered_tools(
143 tool_name: ToolName,
144) -> None:
145 """Test that get_min_version returns version for registered tools.
147 Args:
148 tool_name: ToolName enum member to test.
149 """
150 version = get_min_version(tool_name)
151 assert_that(version).is_instance_of(str)
152 assert_that(version).matches(r"^\d+\.\d+") # Starts with X.Y
155def test_get_min_version_raises_keyerror_for_unknown_tool() -> None:
156 """Test that get_min_version raises KeyError for unknown tools."""
157 with pytest.raises(KeyError, match="not found"):
158 get_min_version("nonexistent_tool") # type: ignore[arg-type]
161def test_get_tool_version_supports_companion_packages() -> None:
162 """Companion npm packages should resolve via get_tool_version."""
163 repo_root = Path(__file__).resolve().parents[3]
164 package_json = json.loads((repo_root / "package.json").read_text())
165 expected = package_json["devDependencies"]["@astrojs/check"].lstrip("^~")
167 version = get_tool_version("@astrojs/check")
169 assert_that(version).is_equal_to(expected)
172def test_tool_versions_uses_toolname_enum_keys() -> None:
173 """Test that TOOL_VERSIONS uses ToolName enum as keys."""
174 for key in TOOL_VERSIONS:
175 assert_that(key).is_instance_of(ToolName)
178def test_all_external_tools_registered_in_tool_versions() -> None:
179 """Test that all expected external tools have versions available.
181 npm-managed tools (markdownlint, oxfmt, oxlint, prettier, tsc) are
182 read from package.json at runtime. Non-npm tools are in TOOL_VERSIONS.
183 """
184 from lintro._tool_versions import get_all_expected_versions
186 repo_root = Path(__file__).resolve().parents[3]
187 manifest_path = repo_root / "lintro" / "tools" / "manifest.json"
189 # Non-npm tools should be in TOOL_VERSIONS
190 expected_non_npm_tools = {
191 ToolName.ACTIONLINT,
192 ToolName.CARGO_AUDIT,
193 ToolName.CLIPPY,
194 ToolName.GITLEAKS,
195 ToolName.HADOLINT,
196 ToolName.PYTEST,
197 ToolName.RUSTC,
198 ToolName.RUSTFMT,
199 ToolName.SEMGREP,
200 ToolName.SHELLCHECK,
201 ToolName.SHFMT,
202 ToolName.SQLFLUFF,
203 ToolName.TAPLO,
204 }
205 if manifest_path.exists():
206 manifest = json.loads(manifest_path.read_text())
207 manifest_tools = {
208 normalize_tool_name(tool["name"])
209 for tool in manifest.get("tools", [])
210 if isinstance(tool, dict) and tool.get("name")
211 }
212 assert_that(set(TOOL_VERSIONS.keys())).is_subset_of(manifest_tools)
213 else:
214 assert_that(set(TOOL_VERSIONS.keys())).is_equal_to(expected_non_npm_tools)
216 # All tools (including npm-managed) should be available via get_all_expected_versions
217 all_versions = get_all_expected_versions()
218 if manifest_path.exists():
219 assert_that(set(all_versions.keys())).is_equal_to(manifest_tools)
220 else:
221 expected_all_tools = {
222 ToolName.ACTIONLINT,
223 ToolName.ASTRO_CHECK,
224 ToolName.CARGO_AUDIT,
225 ToolName.CLIPPY,
226 ToolName.GITLEAKS,
227 ToolName.HADOLINT,
228 ToolName.MARKDOWNLINT,
229 ToolName.OXFMT,
230 ToolName.OXLINT,
231 ToolName.PRETTIER,
232 ToolName.PYTEST,
233 ToolName.RUSTC,
234 ToolName.RUSTFMT,
235 ToolName.SEMGREP,
236 ToolName.SHELLCHECK,
237 ToolName.SHFMT,
238 ToolName.SQLFLUFF,
239 ToolName.SVELTE_CHECK,
240 ToolName.TAPLO,
241 ToolName.TSC,
242 ToolName.VUE_TSC,
243 }
244 assert_that(set(all_versions.keys())).is_equal_to(expected_all_tools)
247def test_get_tool_version_returns_version_for_toolname_enum() -> None:
248 """Test that get_tool_version works with ToolName enum."""
249 version = get_tool_version(ToolName.TSC)
250 assert_that(version).is_not_none()
251 assert_that(version).is_instance_of(str)
254def test_get_tool_version_typescript_alias_resolves_to_tsc() -> None:
255 """Test that 'typescript' alias resolves to TSC version.
257 This is important for shell script compatibility where the npm
258 package name 'typescript' needs to resolve to the tsc version.
259 """
260 typescript_version = get_tool_version("typescript")
261 tsc_version = get_tool_version(ToolName.TSC)
262 assert_that(typescript_version).is_equal_to(tsc_version)
263 assert_that(typescript_version).is_not_none()
266def test_get_tool_version_returns_none_for_unknown_tool() -> None:
267 """Test that get_tool_version returns None for unknown tools."""
268 version = get_tool_version("nonexistent_tool")
269 assert_that(version).is_none()
272def test_get_install_hints() -> None:
273 """Test generating install hints."""
274 hints = get_install_hints()
276 assert_that(hints).contains_key("pytest")
277 assert_that(hints).contains_key("markdownlint")
278 assert_that(hints["pytest"]).contains("Install via:")
279 assert_that(hints["markdownlint"]).contains("bun add")
282def test_version_caching() -> None:
283 """Test that versions are cached properly."""
284 # First call
285 versions1 = get_minimum_versions()
286 hints1 = get_install_hints()
288 # Second call should return equal values (cached)
289 versions2 = get_minimum_versions()
290 hints2 = get_install_hints()
292 assert_that(versions1).is_equal_to(versions2)
293 assert_that(hints1).is_equal_to(hints2)
296@patch("subprocess.run")
297def test_check_tool_version_success(mock_run: MagicMock) -> None:
298 """Test successful version check.
300 Args:
301 mock_run: Mocked subprocess.run function.
302 """
303 min_hadolint = get_minimum_versions()["hadolint"]
304 mock_run.return_value = type(
305 "MockResult",
306 (),
307 {
308 "returncode": 0,
309 "stdout": f"Haskell Dockerfile Linter {min_hadolint}",
310 "stderr": "",
311 },
312 )()
314 result = check_tool_version("hadolint", ["hadolint"])
316 assert_that(result.name).is_equal_to("hadolint")
317 assert_that(result.current_version).is_equal_to(min_hadolint)
318 assert_that(result.min_version).is_equal_to(min_hadolint)
319 assert_that(result.version_check_passed).is_true()
320 assert_that(result.error_message).is_none()
323@patch("subprocess.run")
324def test_check_tool_version_hyphenated_alias_uses_requirements(
325 mock_run: MagicMock,
326) -> None:
327 """Hyphenated tool names should resolve to canonical version requirements."""
328 min_astro = get_minimum_versions()["astro_check"]
329 mock_run.return_value = type(
330 "MockResult",
331 (),
332 {
333 "returncode": 0,
334 "stdout": f"astro {min_astro}",
335 "stderr": "",
336 },
337 )()
339 result = check_tool_version("astro-check", ["bunx", "astro"])
341 assert_that(result.name).is_equal_to("astro-check")
342 assert_that(result.current_version).is_equal_to(min_astro)
343 assert_that(result.min_version).is_equal_to(min_astro)
344 assert_that(result.install_hint).contains("astro")
345 assert_that(result.version_check_passed).is_true()
348@patch("subprocess.run")
349def test_check_tool_version_failure(mock_run: MagicMock) -> None:
350 """Test version check that fails due to old version.
352 Args:
353 mock_run: Mocked subprocess.run function.
354 """
355 min_hadolint = get_minimum_versions()["hadolint"]
356 mock_run.return_value = type(
357 "MockResult",
358 (),
359 {
360 "returncode": 0,
361 "stdout": "Haskell Dockerfile Linter 0.0.0", # Always below any real minimum
362 "stderr": "",
363 },
364 )()
366 result = check_tool_version("hadolint", ["hadolint"])
368 assert_that(result.name).is_equal_to("hadolint")
369 assert_that(result.current_version).is_equal_to("0.0.0")
370 assert_that(result.min_version).is_equal_to(min_hadolint)
371 assert_that(result.version_check_passed).is_false()
372 assert_that(result.error_message).contains("below minimum requirement")
375@patch("subprocess.run")
376def test_check_tool_version_command_failure(mock_run: MagicMock) -> None:
377 """Test version check when command fails.
379 Args:
380 mock_run: Mocked subprocess.run function.
381 """
382 mock_run.side_effect = FileNotFoundError("Command not found")
384 result = check_tool_version("nonexistent", ["nonexistent"])
386 assert_that(result.name).is_equal_to("nonexistent")
387 assert_that(result.current_version).is_none()
388 # For tools not in requirements, version check passes (no enforcement)
389 assert_that(result.version_check_passed).is_true()
390 assert_that(result.error_message).is_not_none()
391 assert_that(result.error_message).contains("Failed to run version check")
394def test_tool_version_info_creation() -> None:
395 """Test ToolVersionInfo dataclass."""
396 info = ToolVersionInfo(
397 name="test_tool",
398 min_version="1.0.0",
399 install_hint="Install test_tool",
400 current_version="1.2.0",
401 version_check_passed=True,
402 )
404 assert_that(info.name).is_equal_to("test_tool")
405 assert_that(info.current_version).is_equal_to("1.2.0")
406 assert_that(info.version_check_passed).is_true()
409@patch("subprocess.run")
410def test_get_all_tool_versions(mock_run: MagicMock) -> None:
411 """Test getting versions for all tools.
413 Args:
414 mock_run: Mocked subprocess.run function.
415 """
416 # Mock successful version checks for all tools
417 mock_run.return_value = type(
418 "MockResult",
419 (),
420 {
421 "returncode": 0,
422 "stdout": "0.14.4", # Generic version response
423 "stderr": "",
424 },
425 )()
427 results = get_all_tool_versions()
429 # Dynamically build expected set from registry (same source of truth)
430 from lintro.tools.core.tool_registry import ToolRegistry
432 registry = ToolRegistry.load()
433 expected_tools = {
434 tool.name
435 for tool in registry.all_tools(include_dev=True)
436 if tool.version_command
437 }
439 assert_that(set(results.keys())).is_equal_to(expected_tools)
441 # Each result should be a ToolVersionInfo
442 for tool_name, info in results.items():
443 assert_that(info).is_instance_of(ToolVersionInfo)
444 assert_that(info.name).is_equal_to(tool_name)