Coverage for tests / unit / plugins / test_discovery.py: 100%
87 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 plugins/discovery module."""
3from __future__ import annotations
5from pathlib import Path
6from unittest.mock import MagicMock, patch
8import pytest
9from assertpy import assert_that
11from lintro.plugins.discovery import (
12 BUILTIN_DEFINITIONS_PATH,
13 ENTRY_POINT_GROUP,
14 discover_all_tools,
15 discover_builtin_tools,
16 discover_external_plugins,
17 is_discovered,
18 reset_discovery,
19)
22@pytest.fixture(autouse=True)
23def clean_discovery_state() -> None:
24 """Reset discovery state before each test to ensure clean state."""
25 reset_discovery()
28# =============================================================================
29# Tests for discover_builtin_tools
30# =============================================================================
33def test_discover_builtin_tools_loads_tools() -> None:
34 """Load builtin tools from definitions directory."""
35 result = discover_builtin_tools()
36 assert_that(result).is_greater_than(0)
39def test_discover_builtin_tools_skips_private_modules() -> None:
40 """Skip modules starting with underscore."""
41 # Verify __init__.py exists in the definitions path
42 init_file = BUILTIN_DEFINITIONS_PATH / "__init__.py"
43 assert_that(init_file.exists()).is_true()
45 # Get count of non-private .py files
46 non_private_files = [
47 f for f in BUILTIN_DEFINITIONS_PATH.glob("*.py") if not f.name.startswith("_")
48 ]
49 expected_count = len(non_private_files)
51 result = discover_builtin_tools()
53 # Result should match non-private files, proving private files were skipped
54 assert_that(result).is_equal_to(expected_count)
57def test_discover_builtin_tools_handles_missing_path(tmp_path: Path) -> None:
58 """Handle missing definitions path gracefully.
60 Args:
61 tmp_path: Temporary directory path for testing.
62 """
63 with patch(
64 "lintro.plugins.discovery.BUILTIN_DEFINITIONS_PATH",
65 tmp_path / "nonexistent",
66 ):
67 result = discover_builtin_tools()
68 assert_that(result).is_equal_to(0)
71# =============================================================================
72# Tests for discover_external_plugins
73# =============================================================================
76def test_discover_external_plugins_handles_no_entry_points() -> None:
77 """Handle case with no entry points."""
78 with patch("importlib.metadata.entry_points", return_value=[]):
79 result = discover_external_plugins()
80 assert_that(result).is_equal_to(0)
83def test_discover_external_plugins_handles_entry_point_error() -> None:
84 """Handle entry point discovery error."""
85 with patch(
86 "importlib.metadata.entry_points",
87 side_effect=TypeError("Entry point error"),
88 ):
89 result = discover_external_plugins()
90 assert_that(result).is_equal_to(0)
93@pytest.mark.parametrize(
94 ("entry_point_name", "loaded_value", "description"),
95 [
96 ("non_class", "not a class", "string value instead of class"),
97 ("function_ep", lambda: None, "function instead of class"),
98 ("int_ep", 42, "integer instead of class"),
99 ],
100)
101def test_discover_external_plugins_skips_non_class_entry_point(
102 entry_point_name: str,
103 loaded_value: object,
104 description: str,
105) -> None:
106 """Skip entry points that don't point to classes ({description}).
108 Args:
109 entry_point_name: Name of the entry point.
110 loaded_value: The value loaded from the entry point.
111 description: Description of the test case.
112 """
113 mock_ep = MagicMock()
114 mock_ep.name = entry_point_name
115 mock_ep.load.return_value = loaded_value
117 with patch("importlib.metadata.entry_points", return_value=[mock_ep]):
118 result = discover_external_plugins()
119 assert_that(result).is_equal_to(0)
122def test_discover_external_plugins_skips_non_plugin_class() -> None:
123 """Skip classes that don't implement LintroPlugin."""
125 class NotAPlugin:
126 pass
128 mock_ep = MagicMock()
129 mock_ep.name = "not_plugin"
130 mock_ep.load.return_value = NotAPlugin
132 with patch("importlib.metadata.entry_points", return_value=[mock_ep]):
133 result = discover_external_plugins()
134 assert_that(result).is_equal_to(0)
137def test_discover_external_plugins_handles_load_error() -> None:
138 """Handle error when loading entry point."""
139 mock_ep = MagicMock()
140 mock_ep.name = "error_plugin"
141 mock_ep.load.side_effect = ImportError("Load error")
143 with patch("importlib.metadata.entry_points", return_value=[mock_ep]):
144 result = discover_external_plugins()
145 assert_that(result).is_equal_to(0)
148# =============================================================================
149# Tests for discover_all_tools
150# =============================================================================
153def test_discover_all_tools_discovers_tools() -> None:
154 """Discover all tools."""
155 result = discover_all_tools()
156 assert_that(result).is_greater_than(0)
159def test_discover_all_tools_skips_if_already_discovered() -> None:
160 """Skip discovery if already discovered."""
161 first_result = discover_all_tools()
162 assert_that(first_result).is_greater_than(0)
164 # Second call should return 0 (skipped)
165 second_result = discover_all_tools()
166 assert_that(second_result).is_equal_to(0)
169def test_discover_all_tools_force_rediscovery() -> None:
170 """Force rediscovery when force=True."""
171 first_result = discover_all_tools()
172 assert_that(first_result).is_greater_than(0)
174 # Force should re-discover
175 forced_result = discover_all_tools(force=True)
176 assert_that(forced_result).is_greater_than(0)
179# =============================================================================
180# Tests for is_discovered
181# =============================================================================
184def test_is_discovered_false_before_discovery() -> None:
185 """Return False before discovery."""
186 result = is_discovered()
187 assert_that(result).is_false()
190def test_is_discovered_true_after_discovery() -> None:
191 """Return True after discovery."""
192 discover_all_tools()
193 result = is_discovered()
194 assert_that(result).is_true()
197# =============================================================================
198# Tests for reset_discovery
199# =============================================================================
202def test_reset_discovery_resets_discovery_state() -> None:
203 """Reset discovery state."""
204 discover_all_tools()
205 assert_that(is_discovered()).is_true()
207 reset_discovery()
208 result = is_discovered()
209 assert_that(result).is_false()
212# =============================================================================
213# Tests for module constants
214# =============================================================================
217def test_builtin_definitions_path_exists() -> None:
218 """Builtin definitions path exists."""
219 assert_that(BUILTIN_DEFINITIONS_PATH.exists()).is_true()
222def test_builtin_definitions_path_is_directory() -> None:
223 """Builtin definitions path is a directory."""
224 assert_that(BUILTIN_DEFINITIONS_PATH.is_dir()).is_true()
227def test_entry_point_group_value() -> None:
228 """Entry point group is correct."""
229 assert_that(ENTRY_POINT_GROUP).is_equal_to("lintro.plugins")