Option C Tools
Loading...
Searching...
No Matches
test_workspace.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_core/test_workspace.py
4
5"""
6Purpose
7-------
8Unit tests for ``oct.core.workspace`` (F1, leanest first cut). The
9loader validates the manifest schema and resolves subproject paths
10relative to the workspace root; the discovery helper walks upward
11to find the manifest.
12
13Responsibilities
14----------------
15- Manifest validation: each malformed input produces a
16 :class:`WorkspaceError` with a useful message.
17- Manifest happy path: valid input produces the expected
18 :class:`Workspace` with absolute, root-resolved subproject paths.
19- ``find_workspace_root`` walks upward until it finds the manifest,
20 returning ``None`` when none exists.
21
22Diagnostics
23-----------
24Domain: CORE-TESTS
25Levels:
26 L2 -- test lifecycle
27 L3 -- assertion details
28"""
29
30import json
31from pathlib import Path
32
33import pytest
34
35from oct.core.workspace import (
36 Subproject,
37 Workspace,
38 WorkspaceError,
39 assign_to_subproject,
40 find_workspace_root,
41 load_workspace,
42 matches_workspace_files,
43)
44
45
46# =====================================================================
47# Helpers
48# =====================================================================
49
50
51def _make_manifest(root: Path, payload: dict) -> Path:
52 """Drop a workspace manifest at *root* with the given payload."""
53 root.mkdir(parents=True, exist_ok=True)
54 path = root / ".option_c_workspace.json"
55 path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
56 return path
57
58
59# =====================================================================
60# Happy path
61# =====================================================================
62
63
65
66 def test_minimal_manifest(self, tmp_path: Path) -> None:
67 (tmp_path / "a").mkdir()
68 (tmp_path / "b").mkdir()
69 _make_manifest(tmp_path, {
70 "version": "1.0",
71 "name": "Demo",
72 "subprojects": [
73 {"path": "a", "name": "a"},
74 {"path": "b", "name": "b", "profile": "strict"},
75 ],
76 })
77 ws = load_workspace(tmp_path)
78 assert isinstance(ws, Workspace)
79 assert ws.name == "Demo"
80 assert ws.root == tmp_path.resolve()
81 assert len(ws.subprojects) == 2
82 assert ws.subprojects[0].path == (tmp_path / "a").resolve()
83 assert ws.subprojects[1].profile == "strict"
84
85 def test_no_subprojects_is_valid(self, tmp_path: Path) -> None:
86 _make_manifest(tmp_path, {
87 "version": "1.0",
88 "name": "Empty",
89 "subprojects": [],
90 })
91 ws = load_workspace(tmp_path)
92 assert ws.subprojects == ()
93
94 def test_subprojects_field_optional(self, tmp_path: Path) -> None:
95 _make_manifest(tmp_path, {
96 "version": "1.0",
97 "name": "Empty2",
98 })
99 ws = load_workspace(tmp_path)
100 assert ws.subprojects == ()
101
102
103# =====================================================================
104# Validation failures
105# =====================================================================
106
107
109
110 def test_missing_manifest(self, tmp_path: Path) -> None:
111 with pytest.raises(WorkspaceError, match="not found"):
112 load_workspace(tmp_path)
113
114 def test_malformed_json(self, tmp_path: Path) -> None:
115 (tmp_path / ".option_c_workspace.json").write_text(
116 "not json", encoding="utf-8",
117 )
118 with pytest.raises(WorkspaceError, match="failed to read"):
119 load_workspace(tmp_path)
120
121 def test_top_level_not_object(self, tmp_path: Path) -> None:
122 (tmp_path / ".option_c_workspace.json").write_text(
123 "[]", encoding="utf-8",
124 )
125 with pytest.raises(WorkspaceError, match="must be a JSON object"):
126 load_workspace(tmp_path)
127
128 def test_wrong_version(self, tmp_path: Path) -> None:
129 _make_manifest(tmp_path, {
130 "version": "9.9",
131 "name": "X",
132 })
133 with pytest.raises(WorkspaceError, match="unsupported"):
134 load_workspace(tmp_path)
135
136 def test_empty_name(self, tmp_path: Path) -> None:
137 _make_manifest(tmp_path, {
138 "version": "1.0",
139 "name": "",
140 })
141 with pytest.raises(WorkspaceError, match="non-empty string"):
142 load_workspace(tmp_path)
143
145 self, tmp_path: Path,
146 ) -> None:
147 # Use a real absolute path that's properly anchored on the
148 # current platform (drive letter on Windows, "/" on Unix).
149 abs_path_raw = str((tmp_path / "abs").resolve())
150 _make_manifest(tmp_path, {
151 "version": "1.0",
152 "name": "X",
153 "subprojects": [{"path": abs_path_raw, "name": "abs"}],
154 })
155 with pytest.raises(WorkspaceError, match="absolute path"):
156 load_workspace(tmp_path)
157
159 self, tmp_path: Path,
160 ) -> None:
161 _make_manifest(tmp_path, {
162 "version": "1.0",
163 "name": "X",
164 "subprojects": [{"path": "../escape", "name": "out"}],
165 })
166 with pytest.raises(WorkspaceError, match="escapes the workspace"):
167 load_workspace(tmp_path)
168
170 self, tmp_path: Path,
171 ) -> None:
172 (tmp_path / "a").mkdir()
173 _make_manifest(tmp_path, {
174 "version": "1.0",
175 "name": "X",
176 "subprojects": [
177 {"path": "a", "name": "a"},
178 {"path": "a", "name": "a-dup"},
179 ],
180 })
181 with pytest.raises(WorkspaceError, match="duplicate"):
182 load_workspace(tmp_path)
183
184 def test_subproject_invalid_profile(self, tmp_path: Path) -> None:
185 (tmp_path / "a").mkdir()
186 _make_manifest(tmp_path, {
187 "version": "1.0",
188 "name": "X",
189 "subprojects": [
190 {"path": "a", "name": "a", "profile": "extreme"},
191 ],
192 })
193 with pytest.raises(WorkspaceError, match="profile"):
194 load_workspace(tmp_path)
195
196
197# =====================================================================
198# find_workspace_root
199# =====================================================================
200
201
203
204 def test_finds_at_start(self, tmp_path: Path) -> None:
205 _make_manifest(tmp_path, {
206 "version": "1.0", "name": "X",
207 })
208 result = find_workspace_root(tmp_path)
209 assert result == tmp_path.resolve()
210
211 def test_finds_at_parent(self, tmp_path: Path) -> None:
212 _make_manifest(tmp_path, {
213 "version": "1.0", "name": "X",
214 })
215 nested = tmp_path / "a" / "b" / "c"
216 nested.mkdir(parents=True)
217 result = find_workspace_root(nested)
218 assert result == tmp_path.resolve()
219
220 def test_returns_none_when_absent(self, tmp_path: Path) -> None:
221 nested = tmp_path / "deep"
222 nested.mkdir()
223 # No manifest anywhere along the chain (within tmp_path scope).
224 assert find_workspace_root(nested) is None or \
225 find_workspace_root(nested).is_relative_to(tmp_path) is False
226
227
228# =====================================================================
229# F1 fan-out: workspace_profile / workspace_files / ignore_at_workspace_scope
230# =====================================================================
231
232
234
235 def test_default_when_absent(self, tmp_path: Path) -> None:
236 _make_manifest(tmp_path, {
237 "version": "1.0", "name": "X",
238 })
239 ws = load_workspace(tmp_path)
240 assert ws.workspace_profile == "compact"
241
242 def test_explicit_strict(self, tmp_path: Path) -> None:
243 _make_manifest(tmp_path, {
244 "version": "1.0", "name": "X",
245 "workspace_profile": "strict",
246 })
247 ws = load_workspace(tmp_path)
248 assert ws.workspace_profile == "strict"
249
250 def test_invalid_value_rejected(self, tmp_path: Path) -> None:
251 _make_manifest(tmp_path, {
252 "version": "1.0", "name": "X",
253 "workspace_profile": "extreme",
254 })
255 with pytest.raises(WorkspaceError, match="workspace_profile"):
256 load_workspace(tmp_path)
257
258
260
261 def test_default_empty(self, tmp_path: Path) -> None:
262 _make_manifest(tmp_path, {
263 "version": "1.0", "name": "X",
264 })
265 ws = load_workspace(tmp_path)
266 assert ws.workspace_files == ()
267
268 def test_happy_path(self, tmp_path: Path) -> None:
269 _make_manifest(tmp_path, {
270 "version": "1.0", "name": "X",
271 "workspace_files": [
272 "CLAUDE.md", ".github/**", "docs/**",
273 ],
274 })
275 ws = load_workspace(tmp_path)
276 assert ws.workspace_files == (
277 "CLAUDE.md", ".github/**", "docs/**",
278 )
279
280 def test_must_be_list(self, tmp_path: Path) -> None:
281 _make_manifest(tmp_path, {
282 "version": "1.0", "name": "X",
283 "workspace_files": "CLAUDE.md", # not a list
284 })
285 with pytest.raises(WorkspaceError, match="must be a list"):
286 load_workspace(tmp_path)
287
288 def test_negation_pattern_rejected(self, tmp_path: Path) -> None:
289 _make_manifest(tmp_path, {
290 "version": "1.0", "name": "X",
291 "workspace_files": ["!CLAUDE.md"],
292 })
293 with pytest.raises(WorkspaceError, match="negation"):
294 load_workspace(tmp_path)
295
296 def test_absolute_pattern_rejected(self, tmp_path: Path) -> None:
297 _make_manifest(tmp_path, {
298 "version": "1.0", "name": "X",
299 "workspace_files": ["/abs/path"],
300 })
301 with pytest.raises(WorkspaceError, match="absolute"):
302 load_workspace(tmp_path)
303
304 def test_unsafe_chars_rejected(self, tmp_path: Path) -> None:
305 _make_manifest(tmp_path, {
306 "version": "1.0", "name": "X",
307 "workspace_files": ["bad pattern with spaces"],
308 })
309 with pytest.raises(WorkspaceError, match="safe set"):
310 load_workspace(tmp_path)
311
312
314
315 def test_default_empty(self, tmp_path: Path) -> None:
316 _make_manifest(tmp_path, {
317 "version": "1.0", "name": "X",
318 })
319 ws = load_workspace(tmp_path)
320 assert ws.ignore_at_workspace_scope == ()
321
322 def test_happy_path(self, tmp_path: Path) -> None:
323 _make_manifest(tmp_path, {
324 "version": "1.0", "name": "X",
325 "ignore_at_workspace_scope": [
326 ".claude/settings.local.json",
327 ".claude/worktrees/**",
328 ],
329 })
330 ws = load_workspace(tmp_path)
331 assert ws.ignore_at_workspace_scope == (
332 ".claude/settings.local.json",
333 ".claude/worktrees/**",
334 )
335
336 def test_must_be_list(self, tmp_path: Path) -> None:
337 _make_manifest(tmp_path, {
338 "version": "1.0", "name": "X",
339 "ignore_at_workspace_scope": "*.tmp",
340 })
341 with pytest.raises(WorkspaceError, match="must be a list"):
342 load_workspace(tmp_path)
343
344 def test_invalid_pattern_rejected(self, tmp_path: Path) -> None:
345 _make_manifest(tmp_path, {
346 "version": "1.0", "name": "X",
347 "ignore_at_workspace_scope": ["!negate"],
348 })
349 with pytest.raises(WorkspaceError, match="negation"):
350 load_workspace(tmp_path)
351
352
354
355 def test_lookup_index(self, tmp_path: Path) -> None:
356 (tmp_path / "a").mkdir()
357 (tmp_path / "b").mkdir()
358 _make_manifest(tmp_path, {
359 "version": "1.0", "name": "X",
360 "subprojects": [
361 {"path": "a", "name": "alpha"},
362 {"path": "b", "name": "beta", "profile": "strict"},
363 ],
364 })
365 ws = load_workspace(tmp_path)
366 idx = ws.subprojects_by_name
367 assert set(idx.keys()) == {"alpha", "beta"}
368 assert idx["beta"].profile == "strict"
369
370
371# =====================================================================
372# matches_workspace_files
373# =====================================================================
374
375
377
379 assert matches_workspace_files("CLAUDE.md", ()) is False
380
381 def test_exact_match(self) -> None:
382 assert matches_workspace_files(
383 "CLAUDE.md", ("CLAUDE.md",),
384 ) is True
385 assert matches_workspace_files(
386 "OTHER.md", ("CLAUDE.md",),
387 ) is False
388
390 assert matches_workspace_files("foo.py", ("*.py",)) is True
391 # ``*`` does NOT cross segment boundaries.
392 assert matches_workspace_files(
393 "src/foo.py", ("*.py",),
394 ) is False
395
397 assert matches_workspace_files(
398 "src/foo.py", ("**/*.py",),
399 ) is True
400 assert matches_workspace_files(
401 "a/b/c.py", ("**/*.py",),
402 ) is True
403
405 assert matches_workspace_files(
406 "docs/README.md", ("docs/**",),
407 ) is True
408 assert matches_workspace_files(
409 "docs/sub/file.md", ("docs/**",),
410 ) is True
411 assert matches_workspace_files(
412 "src/file.md", ("docs/**",),
413 ) is False
414
415 def test_question_mark(self) -> None:
416 assert matches_workspace_files(
417 "a.txt", ("?.txt",),
418 ) is True
419 assert matches_workspace_files(
420 "ab.txt", ("?.txt",),
421 ) is False
422
424 patterns = ("CLAUDE.md", ".github/**", "docs/**")
425 assert matches_workspace_files("CLAUDE.md", patterns) is True
426 assert matches_workspace_files(".github/CI.yml", patterns) is True
427 assert matches_workspace_files("docs/foo.md", patterns) is True
428 assert matches_workspace_files("src/foo.py", patterns) is False
429
430
431# =====================================================================
432# assign_to_subproject
433# =====================================================================
434
435
437
438 def test_file_inside_subproject(self, tmp_path: Path) -> None:
439 (tmp_path / "a").mkdir()
440 (tmp_path / "a" / "src").mkdir()
441 (tmp_path / "a" / "src" / "foo.py").write_text(
442 "x = 1\n", encoding="utf-8",
443 )
444 _make_manifest(tmp_path, {
445 "version": "1.0", "name": "X",
446 "subprojects": [{"path": "a", "name": "alpha"}],
447 })
448 ws = load_workspace(tmp_path)
449 sp = assign_to_subproject(
450 tmp_path / "a" / "src" / "foo.py", ws,
451 )
452 assert sp is not None
453 assert sp.name == "alpha"
454
456 self, tmp_path: Path,
457 ) -> None:
458 (tmp_path / "a").mkdir()
459 (tmp_path / "CLAUDE.md").write_text("x", encoding="utf-8")
460 _make_manifest(tmp_path, {
461 "version": "1.0", "name": "X",
462 "subprojects": [{"path": "a", "name": "alpha"}],
463 })
464 ws = load_workspace(tmp_path)
465 sp = assign_to_subproject(tmp_path / "CLAUDE.md", ws)
466 assert sp is None
467
468 def test_longest_prefix_wins_for_nested(self, tmp_path: Path) -> None:
469 # ``a`` and ``a/b`` are both subprojects (rare nested case).
470 # A file at ``a/b/foo.py`` must resolve to ``a/b``, not ``a``.
471 (tmp_path / "a" / "b").mkdir(parents=True)
472 (tmp_path / "a" / "b" / "foo.py").write_text(
473 "x", encoding="utf-8",
474 )
475 _make_manifest(tmp_path, {
476 "version": "1.0", "name": "X",
477 "subprojects": [
478 {"path": "a", "name": "outer"},
479 {"path": "a/b", "name": "inner"},
480 ],
481 })
482 ws = load_workspace(tmp_path)
483 sp = assign_to_subproject(
484 tmp_path / "a" / "b" / "foo.py", ws,
485 )
486 assert sp is not None
487 assert sp.name == "inner"
488
489 def test_empty_subprojects_returns_none(self, tmp_path: Path) -> None:
490 (tmp_path / "x.py").write_text("x", encoding="utf-8")
491 _make_manifest(tmp_path, {
492 "version": "1.0", "name": "X",
493 })
494 ws = load_workspace(tmp_path)
495 assert assign_to_subproject(tmp_path / "x.py", ws) is None
None test_longest_prefix_wins_for_nested(self, Path tmp_path)
None test_file_outside_any_subproject_returns_none(self, Path tmp_path)
None test_empty_subprojects_returns_none(self, Path tmp_path)
None test_returns_none_when_absent(self, Path tmp_path)
None test_subproject_path_escape_rejected(self, Path tmp_path)
None test_subproject_invalid_profile(self, Path tmp_path)
None test_subproject_absolute_path_rejected(self, Path tmp_path)
None test_subproject_duplicate_path_rejected(self, Path tmp_path)
None test_subprojects_field_optional(self, Path tmp_path)
None test_no_subprojects_is_valid(self, Path tmp_path)
Path _make_manifest(Path root, dict payload)