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
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.
27 L3 -- assertion details
31from pathlib
import Path
35from oct.core.workspace
import (
42 matches_workspace_files,
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")
67 (tmp_path /
"a").mkdir()
68 (tmp_path /
"b").mkdir()
73 {
"path":
"a",
"name":
"a"},
74 {
"path":
"b",
"name":
"b",
"profile":
"strict"},
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"
91 ws = load_workspace(tmp_path)
92 assert ws.subprojects == ()
99 ws = load_workspace(tmp_path)
100 assert ws.subprojects == ()
111 with pytest.raises(WorkspaceError, match=
"not found"):
112 load_workspace(tmp_path)
115 (tmp_path /
".option_c_workspace.json").write_text(
116 "not json", encoding=
"utf-8",
118 with pytest.raises(WorkspaceError, match=
"failed to read"):
119 load_workspace(tmp_path)
122 (tmp_path /
".option_c_workspace.json").write_text(
123 "[]", encoding=
"utf-8",
125 with pytest.raises(WorkspaceError, match=
"must be a JSON object"):
126 load_workspace(tmp_path)
133 with pytest.raises(WorkspaceError, match=
"unsupported"):
134 load_workspace(tmp_path)
141 with pytest.raises(WorkspaceError, match=
"non-empty string"):
142 load_workspace(tmp_path)
145 self, tmp_path: Path,
149 abs_path_raw = str((tmp_path /
"abs").resolve())
153 "subprojects": [{
"path": abs_path_raw,
"name":
"abs"}],
155 with pytest.raises(WorkspaceError, match=
"absolute path"):
156 load_workspace(tmp_path)
159 self, tmp_path: Path,
164 "subprojects": [{
"path":
"../escape",
"name":
"out"}],
166 with pytest.raises(WorkspaceError, match=
"escapes the workspace"):
167 load_workspace(tmp_path)
170 self, tmp_path: Path,
172 (tmp_path /
"a").mkdir()
177 {
"path":
"a",
"name":
"a"},
178 {
"path":
"a",
"name":
"a-dup"},
181 with pytest.raises(WorkspaceError, match=
"duplicate"):
182 load_workspace(tmp_path)
185 (tmp_path /
"a").mkdir()
190 {
"path":
"a",
"name":
"a",
"profile":
"extreme"},
193 with pytest.raises(WorkspaceError, match=
"profile"):
194 load_workspace(tmp_path)
206 "version":
"1.0",
"name":
"X",
208 result = find_workspace_root(tmp_path)
209 assert result == tmp_path.resolve()
213 "version":
"1.0",
"name":
"X",
215 nested = tmp_path /
"a" /
"b" /
"c"
216 nested.mkdir(parents=
True)
217 result = find_workspace_root(nested)
218 assert result == tmp_path.resolve()
221 nested = tmp_path /
"deep"
224 assert find_workspace_root(nested)
is None or \
225 find_workspace_root(nested).is_relative_to(tmp_path)
is False
237 "version":
"1.0",
"name":
"X",
239 ws = load_workspace(tmp_path)
240 assert ws.workspace_profile ==
"compact"
244 "version":
"1.0",
"name":
"X",
245 "workspace_profile":
"strict",
247 ws = load_workspace(tmp_path)
248 assert ws.workspace_profile ==
"strict"
252 "version":
"1.0",
"name":
"X",
253 "workspace_profile":
"extreme",
255 with pytest.raises(WorkspaceError, match=
"workspace_profile"):
256 load_workspace(tmp_path)
263 "version":
"1.0",
"name":
"X",
265 ws = load_workspace(tmp_path)
266 assert ws.workspace_files == ()
270 "version":
"1.0",
"name":
"X",
272 "CLAUDE.md",
".github/**",
"docs/**",
275 ws = load_workspace(tmp_path)
276 assert ws.workspace_files == (
277 "CLAUDE.md",
".github/**",
"docs/**",
282 "version":
"1.0",
"name":
"X",
283 "workspace_files":
"CLAUDE.md",
285 with pytest.raises(WorkspaceError, match=
"must be a list"):
286 load_workspace(tmp_path)
290 "version":
"1.0",
"name":
"X",
291 "workspace_files": [
"!CLAUDE.md"],
293 with pytest.raises(WorkspaceError, match=
"negation"):
294 load_workspace(tmp_path)
298 "version":
"1.0",
"name":
"X",
299 "workspace_files": [
"/abs/path"],
301 with pytest.raises(WorkspaceError, match=
"absolute"):
302 load_workspace(tmp_path)
306 "version":
"1.0",
"name":
"X",
307 "workspace_files": [
"bad pattern with spaces"],
309 with pytest.raises(WorkspaceError, match=
"safe set"):
310 load_workspace(tmp_path)
317 "version":
"1.0",
"name":
"X",
319 ws = load_workspace(tmp_path)
320 assert ws.ignore_at_workspace_scope == ()
324 "version":
"1.0",
"name":
"X",
325 "ignore_at_workspace_scope": [
326 ".claude/settings.local.json",
327 ".claude/worktrees/**",
330 ws = load_workspace(tmp_path)
331 assert ws.ignore_at_workspace_scope == (
332 ".claude/settings.local.json",
333 ".claude/worktrees/**",
338 "version":
"1.0",
"name":
"X",
339 "ignore_at_workspace_scope":
"*.tmp",
341 with pytest.raises(WorkspaceError, match=
"must be a list"):
342 load_workspace(tmp_path)
346 "version":
"1.0",
"name":
"X",
347 "ignore_at_workspace_scope": [
"!negate"],
349 with pytest.raises(WorkspaceError, match=
"negation"):
350 load_workspace(tmp_path)
356 (tmp_path /
"a").mkdir()
357 (tmp_path /
"b").mkdir()
359 "version":
"1.0",
"name":
"X",
361 {
"path":
"a",
"name":
"alpha"},
362 {
"path":
"b",
"name":
"beta",
"profile":
"strict"},
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"
379 assert matches_workspace_files(
"CLAUDE.md", ())
is False
382 assert matches_workspace_files(
383 "CLAUDE.md", (
"CLAUDE.md",),
385 assert matches_workspace_files(
386 "OTHER.md", (
"CLAUDE.md",),
390 assert matches_workspace_files(
"foo.py", (
"*.py",))
is True
392 assert matches_workspace_files(
393 "src/foo.py", (
"*.py",),
397 assert matches_workspace_files(
398 "src/foo.py", (
"**/*.py",),
400 assert matches_workspace_files(
401 "a/b/c.py", (
"**/*.py",),
405 assert matches_workspace_files(
406 "docs/README.md", (
"docs/**",),
408 assert matches_workspace_files(
409 "docs/sub/file.md", (
"docs/**",),
411 assert matches_workspace_files(
412 "src/file.md", (
"docs/**",),
416 assert matches_workspace_files(
419 assert matches_workspace_files(
420 "ab.txt", (
"?.txt",),
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
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",
445 "version":
"1.0",
"name":
"X",
446 "subprojects": [{
"path":
"a",
"name":
"alpha"}],
448 ws = load_workspace(tmp_path)
449 sp = assign_to_subproject(
450 tmp_path /
"a" /
"src" /
"foo.py", ws,
452 assert sp
is not None
453 assert sp.name ==
"alpha"
456 self, tmp_path: Path,
458 (tmp_path /
"a").mkdir()
459 (tmp_path /
"CLAUDE.md").write_text(
"x", encoding=
"utf-8")
461 "version":
"1.0",
"name":
"X",
462 "subprojects": [{
"path":
"a",
"name":
"alpha"}],
464 ws = load_workspace(tmp_path)
465 sp = assign_to_subproject(tmp_path /
"CLAUDE.md", ws)
471 (tmp_path /
"a" /
"b").mkdir(parents=
True)
472 (tmp_path /
"a" /
"b" /
"foo.py").write_text(
473 "x", encoding=
"utf-8",
476 "version":
"1.0",
"name":
"X",
478 {
"path":
"a",
"name":
"outer"},
479 {
"path":
"a/b",
"name":
"inner"},
482 ws = load_workspace(tmp_path)
483 sp = assign_to_subproject(
484 tmp_path /
"a" /
"b" /
"foo.py", ws,
486 assert sp
is not None
487 assert sp.name ==
"inner"
490 (tmp_path /
"x.py").write_text(
"x", encoding=
"utf-8")
492 "version":
"1.0",
"name":
"X",
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_file_inside_subproject(self, Path tmp_path)
None test_finds_at_start(self, Path tmp_path)
None test_finds_at_parent(self, Path tmp_path)
None test_returns_none_when_absent(self, Path tmp_path)
None test_invalid_pattern_rejected(self, Path tmp_path)
None test_default_empty(self, Path tmp_path)
None test_must_be_list(self, Path tmp_path)
None test_happy_path(self, Path tmp_path)
None test_malformed_json(self, Path tmp_path)
None test_empty_name(self, Path tmp_path)
None test_subproject_path_escape_rejected(self, Path tmp_path)
None test_top_level_not_object(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_wrong_version(self, Path tmp_path)
None test_subproject_duplicate_path_rejected(self, Path tmp_path)
None test_missing_manifest(self, Path tmp_path)
None test_minimal_manifest(self, Path tmp_path)
None test_subprojects_field_optional(self, Path tmp_path)
None test_no_subprojects_is_valid(self, Path tmp_path)
None test_any_of_multiple_patterns(self)
None test_question_mark(self)
None test_double_star_crosses_segments(self)
None test_empty_patterns_returns_false(self)
None test_exact_match(self)
None test_dir_prefix_with_double_star(self)
None test_single_star_within_segment(self)
None test_lookup_index(self, Path tmp_path)
None test_happy_path(self, Path tmp_path)
None test_unsafe_chars_rejected(self, Path tmp_path)
None test_absolute_pattern_rejected(self, Path tmp_path)
None test_default_empty(self, Path tmp_path)
None test_must_be_list(self, Path tmp_path)
None test_negation_pattern_rejected(self, Path tmp_path)
None test_invalid_value_rejected(self, Path tmp_path)
None test_default_when_absent(self, Path tmp_path)
None test_explicit_strict(self, Path tmp_path)
Path _make_manifest(Path root, dict payload)