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

1"""Tests for the discover_project function.""" 

2 

3from pathlib import Path 

4from unittest.mock import Mock, patch 

5 

6import pytest 

7 

8from pythinfer.inout import ( 

9 MAX_DISCOVERY_SEARCH_DEPTH, 

10 PROJECT_FILE_NAME, 

11 discover_project, 

12) 

13 

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. 

18 

19 

20class TestDiscoverProjectSuccess: 

21 """Test successful discovery scenarios.""" 

22 

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

27 

28 result = discover_project(tmp_path) 

29 

30 assert isinstance(result, Path) 

31 assert result.name == PROJECT_FILE_NAME 

32 assert result.parent == tmp_path 

33 

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

39 

40 # Create subdirectory and search from there 

41 subdir = tmp_path / "subdir" / "nested" 

42 subdir.mkdir(parents=True) 

43 

44 result = discover_project(subdir) 

45 

46 assert isinstance(result, Path) 

47 assert result.name == PROJECT_FILE_NAME 

48 assert result.parent == tmp_path 

49 

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

55 

56 # Create deeply nested subdirectory 

57 deep_subdir = tmp_path / "a" / "b" / "c" / "d" / "e" 

58 deep_subdir.mkdir(parents=True) 

59 

60 result = discover_project(deep_subdir) 

61 

62 assert isinstance(result, Path) 

63 assert result.name == PROJECT_FILE_NAME 

64 assert result.parent == tmp_path 

65 

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

71 

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

77 

78 # Search from deeply nested subdirectory 

79 deep_subdir = subdir / "nested" 

80 deep_subdir.mkdir() 

81 

82 result = discover_project(deep_subdir) 

83 

84 # Should find the closer config, not the root one 

85 assert result == closer_config 

86 

87 

88class TestDiscoverProjectRecursion: 

89 """Test recursive search behavior.""" 

90 

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

96 

97 # Create deeply nested subdirectory 

98 deep_subdir = tmp_path / "a" / "b" / "c" 

99 deep_subdir.mkdir(parents=True) 

100 

101 # Should still find the config despite depth 

102 result = discover_project(deep_subdir) 

103 assert result == config_path 

104 

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 

109 

110 # Create a subdirectory under the mocked home 

111 subdir = tmp_path / "subdir" 

112 subdir.mkdir() 

113 

114 # Search from subdirectory; should not find config and should fail 

115 with pytest.raises(FileNotFoundError, match="reached `\\$HOME` directory"): 

116 discover_project(subdir) 

117 

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) 

125 

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) 

132 

133 def test_raises_on_root_directory(self) -> None: 

134 """Test that search raises error when root directory is reached.""" 

135 root = Path("/") 

136 

137 with pytest.raises(FileNotFoundError, match="reached root directory"): 

138 discover_project(root) 

139 

140 

141class TestDiscoverProjectMocking: 

142 """Test discover_project with mocked paths.""" 

143 

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

148 

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 

153 

154 def track_resolve(self: Path) -> Path: 

155 nonlocal resolve_called 

156 resolve_called = True 

157 return original_resolve(self) 

158 

159 with patch.object(Path, "resolve", track_resolve): 

160 result = discover_project(tmp_path) 

161 

162 assert result == config_path 

163 assert resolve_called 

164 

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

170 

171 # Mock Path.exists to verify it's called 

172 original_exists = Path.exists 

173 call_count = 0 

174 

175 def mock_exists(self: Path) -> bool: 

176 nonlocal call_count 

177 call_count += 1 

178 return original_exists(self) 

179 

180 with patch.object(Path, "exists", mock_exists): 

181 result = discover_project(tmp_path) 

182 

183 assert result == config_path 

184 assert call_count >= 1 # exists() was called at least once 

185 

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

194 

195 # Change to the temp directory 

196 monkeypatch.chdir(tmp_path) 

197 

198 # Create a relative path 

199 rel_path = Path() 

200 result = discover_project(rel_path) 

201 

202 assert result == config_path 

203 

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

212 

213 subdir = tmp_path / "subdir" 

214 subdir.mkdir() 

215 

216 # Change to subdirectory 

217 monkeypatch.chdir(subdir) 

218 

219 # Discover from relative current directory 

220 rel_path = Path() 

221 result = discover_project(rel_path) 

222 

223 assert result == config_path 

224 

225 

226class TestDiscoverProjectErrorCases: 

227 """Test error handling and edge cases.""" 

228 

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

234 

235 with pytest.raises(FileNotFoundError): 

236 discover_project(subdir) 

237 

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

242 

243 with pytest.raises(FileNotFoundError) as exc_info: 

244 discover_project(subdir) 

245 

246 assert PROJECT_FILE_NAME in str(exc_info.value) 

247 

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

254 

255 nested = tmp_path / "level1" / "level2" / "level3" 

256 nested.mkdir(parents=True) 

257 

258 # Should succeed even though depth goes > 0 

259 result = discover_project(nested) 

260 assert result == config_path 

261 

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

269 

270 # Create a symlink 

271 link_dir = tmp_path / "link" 

272 link_dir.symlink_to(actual_dir) 

273 

274 # Search from symlink should find config in resolved path 

275 result = discover_project(link_dir) 

276 assert result == config_path 

277 

278 

279class TestDiscoverProjectIntegration: 

280 """Integration tests combining multiple aspects.""" 

281 

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

287 

288 config_path = project_root / PROJECT_FILE_NAME 

289 config_path.touch() 

290 

291 # Create nested search location 

292 search_location = project_root / "src" / "data" / "input" 

293 search_location.mkdir(parents=True) 

294 

295 result = discover_project(search_location) 

296 

297 assert result == config_path 

298 

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

306 

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

312 

313 # Search from inside inner project 

314 search_location = inner / "src" 

315 search_location.mkdir() 

316 

317 result = discover_project(search_location) 

318 

319 # Should find inner project, not outer 

320 assert result == inner_config