Coverage for tests / unit / inout / test_discover_project.py: 100%
159 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-26 21:25 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-26 21:25 +0000
1"""Tests for the discover_project function."""
3from pathlib import Path
4from unittest.mock import Mock, patch
6import pytest
8from pythinfer.inout import (
9 MAX_DISCOVERY_SEARCH_DEPTH,
10 PROJECT_FILE_NAME,
11 discover_project,
12)
14# NB: TODO(robert): a lot of these, if not all, are integration-style tests since they
15# involve actual filesystem operations (creating temp dirs/files).
16# Also, a lot of them are testing internal implementation details (like whether they
17# call resolve() or exists()), which is not necessary, but for now it's acceptable.
20class TestDiscoverProjectSuccess:
21 """Test successful discovery scenarios."""
23 def test_discovers_project_in_start_path(self, tmp_path: Path) -> None:
24 """Test that discover_project finds config immediately in start_path."""
25 config_path = tmp_path / PROJECT_FILE_NAME
26 config_path.touch()
28 result = discover_project(tmp_path)
30 assert isinstance(result, Path)
31 assert result.name == PROJECT_FILE_NAME
32 assert result.parent == tmp_path
34 def test_discovers_project_in_parent_directory(self, tmp_path: Path) -> None:
35 """Test that discover_project recursively searches parent directories."""
36 # Create config in parent directory
37 config_path = tmp_path / PROJECT_FILE_NAME
38 config_path.touch()
40 # Create subdirectory and search from there
41 subdir = tmp_path / "subdir" / "nested"
42 subdir.mkdir(parents=True)
44 result = discover_project(subdir)
46 assert isinstance(result, Path)
47 assert result.name == PROJECT_FILE_NAME
48 assert result.parent == tmp_path
50 def test_discovers_project_multiple_levels_deep(self, tmp_path: Path) -> None:
51 """Test discovery across multiple directory levels."""
52 # Create config at root of temp directory
53 config_path = tmp_path / PROJECT_FILE_NAME
54 config_path.touch()
56 # Create deeply nested subdirectory
57 deep_subdir = tmp_path / "a" / "b" / "c" / "d" / "e"
58 deep_subdir.mkdir(parents=True)
60 result = discover_project(deep_subdir)
62 assert isinstance(result, Path)
63 assert result.name == PROJECT_FILE_NAME
64 assert result.parent == tmp_path
66 def test_prefers_closest_project_config(self, tmp_path: Path) -> None:
67 """Test that discovery returns the closest config file."""
68 # Create config at root
69 root_config = tmp_path / PROJECT_FILE_NAME
70 root_config.touch()
72 # Create a closer config in subdirectory
73 subdir = tmp_path / "subdir"
74 subdir.mkdir()
75 closer_config = subdir / PROJECT_FILE_NAME
76 closer_config.touch()
78 # Search from deeply nested subdirectory
79 deep_subdir = subdir / "nested"
80 deep_subdir.mkdir()
82 result = discover_project(deep_subdir)
84 # Should find the closer config, not the root one
85 assert result == closer_config
88class TestDiscoverProjectRecursion:
89 """Test recursive search behavior."""
91 def test_recursion_depth_tracking(self, tmp_path: Path) -> None:
92 """Test that recursion depth is properly tracked."""
93 # Create a config several levels deep
94 config_path = tmp_path / PROJECT_FILE_NAME
95 config_path.touch()
97 # Create deeply nested subdirectory
98 deep_subdir = tmp_path / "a" / "b" / "c"
99 deep_subdir.mkdir(parents=True)
101 # Should still find the config despite depth
102 result = discover_project(deep_subdir)
103 assert result == config_path
105 @patch("pythinfer.inout.Path.home")
106 def test_stops_at_home_directory(self, mock_home: Mock, tmp_path: Path) -> None:
107 """Test that search stops at $HOME directory without finding config."""
108 mock_home.return_value = tmp_path
110 # Create a subdirectory under the mocked home
111 subdir = tmp_path / "subdir"
112 subdir.mkdir()
114 # Search from subdirectory; should not find config and should fail
115 with pytest.raises(FileNotFoundError, match="reached `\\$HOME` directory"):
116 discover_project(subdir)
118 def test_raises_on_max_depth_exceeded(self, tmp_path: Path) -> None:
119 """Test that search raises error when max depth is exceeded."""
120 # Create a deeply nested subdirectory deeper than MAX_DISCOVERY_SEARCH_DEPTH
121 deep_subdir = tmp_path
122 for i in range(MAX_DISCOVERY_SEARCH_DEPTH + 5):
123 deep_subdir = deep_subdir / f"level_{i}"
124 deep_subdir.mkdir(parents=True)
126 # Search from deep subdirectory without config
127 with pytest.raises(
128 FileNotFoundError,
129 match="reached maximum search depth",
130 ):
131 discover_project(deep_subdir)
133 def test_raises_on_root_directory(self) -> None:
134 """Test that search raises error when root directory is reached."""
135 root = Path("/")
137 with pytest.raises(FileNotFoundError, match="reached root directory"):
138 discover_project(root)
141class TestDiscoverProjectMocking:
142 """Test discover_project with mocked paths."""
144 def test_with_mocked_resolve(self, tmp_path: Path) -> None:
145 """Test discover_project with real paths and verify resolve is called."""
146 config_path = tmp_path / PROJECT_FILE_NAME
147 config_path.touch()
149 # Create a path with trailing slashes and relative components
150 # to ensure resolve() is properly handling the normalization
151 resolve_called = False
152 original_resolve = Path.resolve
154 def track_resolve(self: Path) -> Path:
155 nonlocal resolve_called
156 resolve_called = True
157 return original_resolve(self)
159 with patch.object(Path, "resolve", track_resolve):
160 result = discover_project(tmp_path)
162 assert result == config_path
163 assert resolve_called
165 def test_with_mocked_path_exists(self, tmp_path: Path) -> None:
166 """Test discover_project with mocked exists() method."""
167 # Create real project structure
168 config_path = tmp_path / PROJECT_FILE_NAME
169 config_path.touch()
171 # Mock Path.exists to verify it's called
172 original_exists = Path.exists
173 call_count = 0
175 def mock_exists(self: Path) -> bool:
176 nonlocal call_count
177 call_count += 1
178 return original_exists(self)
180 with patch.object(Path, "exists", mock_exists):
181 result = discover_project(tmp_path)
183 assert result == config_path
184 assert call_count >= 1 # exists() was called at least once
186 def test_discovers_with_relative_path_input(
187 self,
188 tmp_path: Path,
189 monkeypatch,
190 ) -> None:
191 """Test that discover_project works with relative paths."""
192 config_path = tmp_path / PROJECT_FILE_NAME
193 config_path.touch()
195 # Change to the temp directory
196 monkeypatch.chdir(tmp_path)
198 # Create a relative path
199 rel_path = Path()
200 result = discover_project(rel_path)
202 assert result == config_path
204 def test_discovers_with_relative_path_from_subdir(
205 self,
206 tmp_path: Path,
207 monkeypatch,
208 ) -> None:
209 """Test discovery from relative path in subdirectory."""
210 config_path = tmp_path / PROJECT_FILE_NAME
211 config_path.touch()
213 subdir = tmp_path / "subdir"
214 subdir.mkdir()
216 # Change to subdirectory
217 monkeypatch.chdir(subdir)
219 # Discover from relative current directory
220 rel_path = Path()
221 result = discover_project(rel_path)
223 assert result == config_path
226class TestDiscoverProjectErrorCases:
227 """Test error handling and edge cases."""
229 def test_raises_file_not_found_no_config(self, tmp_path: Path) -> None:
230 """Test that FileNotFoundError is raised when no config is found."""
231 # Create subdirectory without any project config
232 subdir = tmp_path / "subdir"
233 subdir.mkdir()
235 with pytest.raises(FileNotFoundError):
236 discover_project(subdir)
238 def test_error_message_contains_config_filename(self, tmp_path: Path) -> None:
239 """Test that error message includes the config filename."""
240 subdir = tmp_path / "subdir"
241 subdir.mkdir()
243 with pytest.raises(FileNotFoundError) as exc_info:
244 discover_project(subdir)
246 assert PROJECT_FILE_NAME in str(exc_info.value)
248 def test_internal_depth_parameter_increments(self, tmp_path: Path) -> None:
249 """Test that _current_depth parameter increments on recursion."""
250 # We'll test this indirectly by creating a directory structure
251 # that requires recursion and verifying the function works
252 config_path = tmp_path / PROJECT_FILE_NAME
253 config_path.touch()
255 nested = tmp_path / "level1" / "level2" / "level3"
256 nested.mkdir(parents=True)
258 # Should succeed even though depth goes > 0
259 result = discover_project(nested)
260 assert result == config_path
262 def test_symlink_resolution(self, tmp_path: Path) -> None:
263 """Test that symlinks are resolved properly."""
264 # Create actual directory with config
265 actual_dir = tmp_path / "actual"
266 actual_dir.mkdir()
267 config_path = actual_dir / PROJECT_FILE_NAME
268 config_path.touch()
270 # Create a symlink
271 link_dir = tmp_path / "link"
272 link_dir.symlink_to(actual_dir)
274 # Search from symlink should find config in resolved path
275 result = discover_project(link_dir)
276 assert result == config_path
279class TestDiscoverProjectIntegration:
280 """Integration tests combining multiple aspects."""
282 def test_full_discovery_workflow(self, tmp_path: Path) -> None:
283 """Test a complete discovery workflow."""
284 # Create a realistic project structure
285 project_root = tmp_path / "myproject"
286 project_root.mkdir()
288 config_path = project_root / PROJECT_FILE_NAME
289 config_path.touch()
291 # Create nested search location
292 search_location = project_root / "src" / "data" / "input"
293 search_location.mkdir(parents=True)
295 result = discover_project(search_location)
297 assert result == config_path
299 def test_discovery_with_multiple_nested_projects(self, tmp_path: Path) -> None:
300 """Test that discovery finds the nearest project, not the furthest."""
301 # Create outer project
302 outer = tmp_path / "outer"
303 outer.mkdir()
304 outer_config = outer / PROJECT_FILE_NAME
305 outer_config.touch()
307 # Create nested inner project
308 inner = outer / "inner" / "project"
309 inner.mkdir(parents=True)
310 inner_config = inner / PROJECT_FILE_NAME
311 inner_config.touch()
313 # Search from inside inner project
314 search_location = inner / "src"
315 search_location.mkdir()
317 result = discover_project(search_location)
319 # Should find inner project, not outer
320 assert result == inner_config