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

1"""Unit tests for plugins/discovery module.""" 

2 

3from __future__ import annotations 

4 

5from pathlib import Path 

6from unittest.mock import MagicMock, patch 

7 

8import pytest 

9from assertpy import assert_that 

10 

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) 

20 

21 

22@pytest.fixture(autouse=True) 

23def clean_discovery_state() -> None: 

24 """Reset discovery state before each test to ensure clean state.""" 

25 reset_discovery() 

26 

27 

28# ============================================================================= 

29# Tests for discover_builtin_tools 

30# ============================================================================= 

31 

32 

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) 

37 

38 

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() 

44 

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) 

50 

51 result = discover_builtin_tools() 

52 

53 # Result should match non-private files, proving private files were skipped 

54 assert_that(result).is_equal_to(expected_count) 

55 

56 

57def test_discover_builtin_tools_handles_missing_path(tmp_path: Path) -> None: 

58 """Handle missing definitions path gracefully. 

59 

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) 

69 

70 

71# ============================================================================= 

72# Tests for discover_external_plugins 

73# ============================================================================= 

74 

75 

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) 

81 

82 

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) 

91 

92 

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}). 

107 

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 

116 

117 with patch("importlib.metadata.entry_points", return_value=[mock_ep]): 

118 result = discover_external_plugins() 

119 assert_that(result).is_equal_to(0) 

120 

121 

122def test_discover_external_plugins_skips_non_plugin_class() -> None: 

123 """Skip classes that don't implement LintroPlugin.""" 

124 

125 class NotAPlugin: 

126 pass 

127 

128 mock_ep = MagicMock() 

129 mock_ep.name = "not_plugin" 

130 mock_ep.load.return_value = NotAPlugin 

131 

132 with patch("importlib.metadata.entry_points", return_value=[mock_ep]): 

133 result = discover_external_plugins() 

134 assert_that(result).is_equal_to(0) 

135 

136 

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") 

142 

143 with patch("importlib.metadata.entry_points", return_value=[mock_ep]): 

144 result = discover_external_plugins() 

145 assert_that(result).is_equal_to(0) 

146 

147 

148# ============================================================================= 

149# Tests for discover_all_tools 

150# ============================================================================= 

151 

152 

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) 

157 

158 

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) 

163 

164 # Second call should return 0 (skipped) 

165 second_result = discover_all_tools() 

166 assert_that(second_result).is_equal_to(0) 

167 

168 

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) 

173 

174 # Force should re-discover 

175 forced_result = discover_all_tools(force=True) 

176 assert_that(forced_result).is_greater_than(0) 

177 

178 

179# ============================================================================= 

180# Tests for is_discovered 

181# ============================================================================= 

182 

183 

184def test_is_discovered_false_before_discovery() -> None: 

185 """Return False before discovery.""" 

186 result = is_discovered() 

187 assert_that(result).is_false() 

188 

189 

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() 

195 

196 

197# ============================================================================= 

198# Tests for reset_discovery 

199# ============================================================================= 

200 

201 

202def test_reset_discovery_resets_discovery_state() -> None: 

203 """Reset discovery state.""" 

204 discover_all_tools() 

205 assert_that(is_discovered()).is_true() 

206 

207 reset_discovery() 

208 result = is_discovered() 

209 assert_that(result).is_false() 

210 

211 

212# ============================================================================= 

213# Tests for module constants 

214# ============================================================================= 

215 

216 

217def test_builtin_definitions_path_exists() -> None: 

218 """Builtin definitions path exists.""" 

219 assert_that(BUILTIN_DEFINITIONS_PATH.exists()).is_true() 

220 

221 

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() 

225 

226 

227def test_entry_point_group_value() -> None: 

228 """Entry point group is correct.""" 

229 assert_that(ENTRY_POINT_GROUP).is_equal_to("lintro.plugins")