test_core_workspace.py
python
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9
docs: expand cache plan with all seven testing tiers and do…
Sonnet 4.6
131 days ago
| 1 | """Tests for muse/core/workspace.py — multi-repository workspace management.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import inspect |
| 6 | import json |
| 7 | import pathlib |
| 8 | import time |
| 9 | |
| 10 | import pytest |
| 11 | |
| 12 | from muse.core.workspace import ( |
| 13 | WorkspaceMemberStatus, |
| 14 | add_workspace_member, |
| 15 | list_workspace_members, |
| 16 | remove_workspace_member, |
| 17 | ) |
| 18 | |
| 19 | |
| 20 | # --------------------------------------------------------------------------- |
| 21 | # Helpers |
| 22 | # --------------------------------------------------------------------------- |
| 23 | |
| 24 | |
| 25 | def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path: |
| 26 | muse = tmp_path / ".muse" |
| 27 | for d in ("objects", "commits", "snapshots", "refs/heads"): |
| 28 | (muse / d).mkdir(parents=True, exist_ok=True) |
| 29 | (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo"})) |
| 30 | (muse / "HEAD").write_text("ref: refs/heads/main\n") |
| 31 | (muse / "refs" / "heads" / "main").write_text("0" * 64) |
| 32 | return tmp_path |
| 33 | |
| 34 | |
| 35 | # --------------------------------------------------------------------------- |
| 36 | # add_workspace_member |
| 37 | # --------------------------------------------------------------------------- |
| 38 | |
| 39 | |
| 40 | def test_add_member_creates_manifest(tmp_path: pathlib.Path) -> None: |
| 41 | repo = _make_repo(tmp_path) |
| 42 | add_workspace_member(repo, "core", "https://musehub.ai/acme/core") |
| 43 | manifest_path = repo / ".muse" / "workspace.toml" |
| 44 | assert manifest_path.exists() |
| 45 | |
| 46 | |
| 47 | def test_add_member_stores_name_and_url(tmp_path: pathlib.Path) -> None: |
| 48 | repo = _make_repo(tmp_path) |
| 49 | add_workspace_member(repo, "sounds", "https://musehub.ai/acme/sounds") |
| 50 | members = list_workspace_members(repo) |
| 51 | assert len(members) == 1 |
| 52 | assert members[0].name == "sounds" |
| 53 | assert members[0].url == "https://musehub.ai/acme/sounds" |
| 54 | |
| 55 | |
| 56 | def test_add_member_default_path(tmp_path: pathlib.Path) -> None: |
| 57 | repo = _make_repo(tmp_path) |
| 58 | add_workspace_member(repo, "core", "https://example.com/core") |
| 59 | members = list_workspace_members(repo) |
| 60 | assert "repos/core" in str(members[0].path) |
| 61 | |
| 62 | |
| 63 | def test_add_member_custom_path(tmp_path: pathlib.Path) -> None: |
| 64 | repo = _make_repo(tmp_path) |
| 65 | add_workspace_member(repo, "core", "https://example.com/core", path="vendor/core") |
| 66 | members = list_workspace_members(repo) |
| 67 | assert "vendor/core" in str(members[0].path) |
| 68 | |
| 69 | |
| 70 | def test_add_member_default_branch_is_main(tmp_path: pathlib.Path) -> None: |
| 71 | repo = _make_repo(tmp_path) |
| 72 | add_workspace_member(repo, "core", "https://example.com/core") |
| 73 | members = list_workspace_members(repo) |
| 74 | assert members[0].branch == "main" |
| 75 | |
| 76 | |
| 77 | def test_add_member_custom_branch(tmp_path: pathlib.Path) -> None: |
| 78 | repo = _make_repo(tmp_path) |
| 79 | add_workspace_member(repo, "data", "https://example.com/data", branch="v2") |
| 80 | members = list_workspace_members(repo) |
| 81 | assert members[0].branch == "v2" |
| 82 | |
| 83 | |
| 84 | def test_add_duplicate_member_raises(tmp_path: pathlib.Path) -> None: |
| 85 | repo = _make_repo(tmp_path) |
| 86 | add_workspace_member(repo, "core", "https://example.com/core") |
| 87 | with pytest.raises(ValueError, match="already exists"): |
| 88 | add_workspace_member(repo, "core", "https://example.com/other") |
| 89 | |
| 90 | |
| 91 | def test_add_multiple_members(tmp_path: pathlib.Path) -> None: |
| 92 | repo = _make_repo(tmp_path) |
| 93 | add_workspace_member(repo, "core", "https://example.com/core") |
| 94 | add_workspace_member(repo, "sounds", "https://example.com/sounds") |
| 95 | add_workspace_member(repo, "docs", "https://example.com/docs") |
| 96 | members = list_workspace_members(repo) |
| 97 | assert len(members) == 3 |
| 98 | |
| 99 | |
| 100 | # --------------------------------------------------------------------------- |
| 101 | # remove_workspace_member |
| 102 | # --------------------------------------------------------------------------- |
| 103 | |
| 104 | |
| 105 | def test_remove_member_removes_from_manifest(tmp_path: pathlib.Path) -> None: |
| 106 | repo = _make_repo(tmp_path) |
| 107 | add_workspace_member(repo, "core", "https://example.com/core") |
| 108 | remove_workspace_member(repo, "core") |
| 109 | members = list_workspace_members(repo) |
| 110 | assert len(members) == 0 |
| 111 | |
| 112 | |
| 113 | def test_remove_nonexistent_member_raises(tmp_path: pathlib.Path) -> None: |
| 114 | repo = _make_repo(tmp_path) |
| 115 | # First add a member so the manifest exists, then try to remove a nonexistent one. |
| 116 | add_workspace_member(repo, "core", "https://example.com/core") |
| 117 | with pytest.raises(ValueError, match="not found"): |
| 118 | remove_workspace_member(repo, "nonexistent") |
| 119 | |
| 120 | |
| 121 | def test_remove_no_manifest_raises(tmp_path: pathlib.Path) -> None: |
| 122 | repo = _make_repo(tmp_path) |
| 123 | with pytest.raises(ValueError, match="No workspace manifest"): |
| 124 | remove_workspace_member(repo, "anything") |
| 125 | |
| 126 | |
| 127 | def test_remove_only_removes_named_member(tmp_path: pathlib.Path) -> None: |
| 128 | repo = _make_repo(tmp_path) |
| 129 | add_workspace_member(repo, "core", "https://example.com/core") |
| 130 | add_workspace_member(repo, "sounds", "https://example.com/sounds") |
| 131 | remove_workspace_member(repo, "core") |
| 132 | members = list_workspace_members(repo) |
| 133 | assert len(members) == 1 |
| 134 | assert members[0].name == "sounds" |
| 135 | |
| 136 | |
| 137 | # --------------------------------------------------------------------------- |
| 138 | # list_workspace_members |
| 139 | # --------------------------------------------------------------------------- |
| 140 | |
| 141 | |
| 142 | def test_list_returns_empty_when_no_manifest(tmp_path: pathlib.Path) -> None: |
| 143 | repo = _make_repo(tmp_path) |
| 144 | assert list_workspace_members(repo) == [] |
| 145 | |
| 146 | |
| 147 | def test_list_present_false_when_not_cloned(tmp_path: pathlib.Path) -> None: |
| 148 | repo = _make_repo(tmp_path) |
| 149 | add_workspace_member(repo, "core", "https://example.com/core") |
| 150 | members = list_workspace_members(repo) |
| 151 | assert members[0].present is False |
| 152 | |
| 153 | |
| 154 | def test_list_present_true_when_cloned(tmp_path: pathlib.Path) -> None: |
| 155 | repo = _make_repo(tmp_path) |
| 156 | add_workspace_member(repo, "local", str(tmp_path / "local_clone"), path="local_clone") |
| 157 | # Simulate a cloned repo at the expected path. |
| 158 | clone_path = tmp_path / "local_clone" |
| 159 | (clone_path / ".muse").mkdir(parents=True, exist_ok=True) |
| 160 | members = list_workspace_members(repo) |
| 161 | assert members[0].present is True |
| 162 | |
| 163 | |
| 164 | def test_list_head_none_when_not_cloned(tmp_path: pathlib.Path) -> None: |
| 165 | repo = _make_repo(tmp_path) |
| 166 | add_workspace_member(repo, "core", "https://example.com/core") |
| 167 | members = list_workspace_members(repo) |
| 168 | assert members[0].head_commit is None |
| 169 | |
| 170 | |
| 171 | def test_list_returns_workspace_member_status(tmp_path: pathlib.Path) -> None: |
| 172 | repo = _make_repo(tmp_path) |
| 173 | add_workspace_member(repo, "core", "https://example.com/core") |
| 174 | members = list_workspace_members(repo) |
| 175 | assert isinstance(members[0], WorkspaceMemberStatus) |
| 176 | |
| 177 | |
| 178 | # --------------------------------------------------------------------------- |
| 179 | # Performance — parallelism and no-subprocess shelf count |
| 180 | # --------------------------------------------------------------------------- |
| 181 | |
| 182 | |
| 183 | def _make_present_repo(parent: pathlib.Path, name: str) -> pathlib.Path: |
| 184 | """Create a minimal present repo inside *parent* and return its path.""" |
| 185 | repo = parent / name |
| 186 | muse = repo / ".muse" |
| 187 | for d in ("objects", "commits", "snapshots", "refs/heads"): |
| 188 | (muse / d).mkdir(parents=True, exist_ok=True) |
| 189 | (muse / "repo.json").write_text(json.dumps({"repo_id": f"test-{name}"})) |
| 190 | (muse / "HEAD").write_text("ref: refs/heads/main\n") |
| 191 | (muse / "refs" / "heads" / "main").write_text("0" * 64) |
| 192 | (muse / "shelf.json").write_text("[]") |
| 193 | return repo |
| 194 | |
| 195 | |
| 196 | def test_list_workspace_members_runs_in_parallel(tmp_path: pathlib.Path) -> None: |
| 197 | """list_workspace_members must dispatch per-member work concurrently. |
| 198 | |
| 199 | We verify this structurally: list_workspace_members (or the helper it |
| 200 | calls) must use concurrent.futures.ThreadPoolExecutor — a sequential |
| 201 | implementation would be unacceptably slow for large workspaces. |
| 202 | """ |
| 203 | import concurrent.futures |
| 204 | from muse.core import workspace as ws_module |
| 205 | |
| 206 | source = inspect.getsource(ws_module) |
| 207 | assert "ThreadPoolExecutor" in source, ( |
| 208 | "list_workspace_members must use ThreadPoolExecutor for parallelism" |
| 209 | ) |
| 210 | |
| 211 | |
| 212 | def test_shelf_count_does_not_spawn_subprocess(tmp_path: pathlib.Path) -> None: |
| 213 | """Shelf count must be read from .muse/shelf.json, not via subprocess. |
| 214 | |
| 215 | A subprocess for every member is the single largest performance cost. |
| 216 | The shelf count is a simple list-length read — no subprocess needed. |
| 217 | """ |
| 218 | from muse.core import workspace as ws_module |
| 219 | |
| 220 | source = inspect.getsource(ws_module) |
| 221 | # No subprocess spawning for shelf count. |
| 222 | assert '"muse", "shelf"' not in source, ( |
| 223 | "_member_status must not spawn 'muse shelf list' — read shelf.json directly" |
| 224 | ) |
| 225 | |
| 226 | |
| 227 | def test_shelf_count_read_from_file(tmp_path: pathlib.Path) -> None: |
| 228 | """shelf_count reflects entries in .muse/shelf.json without any subprocess.""" |
| 229 | workspace = _make_repo(tmp_path) |
| 230 | member_path = tmp_path / "member_a" |
| 231 | _make_present_repo(tmp_path, "member_a") |
| 232 | |
| 233 | # Write two shelf entries directly to shelf.json. |
| 234 | shelf_data = [{"id": "aaa", "intent": "wip1"}, {"id": "bbb", "intent": "wip2"}] |
| 235 | (member_path / ".muse" / "shelf.json").write_text(json.dumps(shelf_data)) |
| 236 | |
| 237 | add_workspace_member(workspace, "member_a", "https://example.com/a", path="member_a") |
| 238 | members = list_workspace_members(workspace) |
| 239 | assert members[0].shelf_count == 2, ( |
| 240 | f"Expected shelf_count=2, got {members[0].shelf_count}" |
| 241 | ) |
| 242 | |
| 243 | |
| 244 | def test_shelf_count_zero_when_file_empty(tmp_path: pathlib.Path) -> None: |
| 245 | """shelf_count is 0 when shelf.json holds an empty list.""" |
| 246 | workspace = _make_repo(tmp_path) |
| 247 | _make_present_repo(tmp_path, "member_b") |
| 248 | add_workspace_member(workspace, "member_b", "https://example.com/b", path="member_b") |
| 249 | members = list_workspace_members(workspace) |
| 250 | assert members[0].shelf_count == 0 |
| 251 | |
| 252 | |
| 253 | def test_shelf_count_zero_when_file_absent(tmp_path: pathlib.Path) -> None: |
| 254 | """shelf_count is 0 when .muse/shelf.json does not exist.""" |
| 255 | workspace = _make_repo(tmp_path) |
| 256 | member_path = _make_present_repo(tmp_path, "member_c") |
| 257 | (member_path / ".muse" / "shelf.json").unlink() |
| 258 | add_workspace_member(workspace, "member_c", "https://example.com/c", path="member_c") |
| 259 | members = list_workspace_members(workspace) |
| 260 | assert members[0].shelf_count == 0 |
| 261 | |
| 262 | |
| 263 | def test_list_workspace_members_wall_time(tmp_path: pathlib.Path) -> None: |
| 264 | """Seven present members must complete in under 2 s wall time. |
| 265 | |
| 266 | The sequential implementation with two subprocesses per member takes |
| 267 | ~3–4 s. Parallelism plus file-I/O shelf counting brings this well |
| 268 | under 1 s; 2 s is a generous upper bound that rules out regression |
| 269 | without being fragile on slow CI machines. |
| 270 | """ |
| 271 | workspace = _make_repo(tmp_path) |
| 272 | for i in range(7): |
| 273 | _make_present_repo(tmp_path, f"repo{i}") |
| 274 | add_workspace_member( |
| 275 | workspace, f"repo{i}", f"https://example.com/repo{i}", path=f"repo{i}" |
| 276 | ) |
| 277 | t0 = time.perf_counter() |
| 278 | members = list_workspace_members(workspace) |
| 279 | elapsed = time.perf_counter() - t0 |
| 280 | assert len(members) == 7 |
| 281 | assert elapsed < 2.0, ( |
| 282 | f"list_workspace_members took {elapsed:.2f}s for 7 members (limit 2.0s). " |
| 283 | "Check that members are processed in parallel and shelf uses file I/O." |
| 284 | ) |
| 285 | |
| 286 | |
| 287 | # --------------------------------------------------------------------------- |
| 288 | # Stress |
| 289 | # --------------------------------------------------------------------------- |
| 290 | |
| 291 | |
| 292 | def test_stress_50_members(tmp_path: pathlib.Path) -> None: |
| 293 | """Adding 50 members should all be preserved and listed correctly.""" |
| 294 | repo = _make_repo(tmp_path) |
| 295 | for i in range(50): |
| 296 | add_workspace_member(repo, f"svc{i}", f"https://example.com/svc{i}") |
| 297 | members = list_workspace_members(repo) |
| 298 | assert len(members) == 50 |
| 299 | names = {m.name for m in members} |
| 300 | for i in range(50): |
| 301 | assert f"svc{i}" in names |
| 302 | |
| 303 | |
| 304 | def test_stress_add_remove_cycle(tmp_path: pathlib.Path) -> None: |
| 305 | """Add and remove 20 members; manifest should be empty at the end.""" |
| 306 | repo = _make_repo(tmp_path) |
| 307 | for i in range(20): |
| 308 | add_workspace_member(repo, f"repo{i}", f"https://example.com/repo{i}") |
| 309 | for i in range(20): |
| 310 | remove_workspace_member(repo, f"repo{i}") |
| 311 | assert list_workspace_members(repo) == [] |
File History
3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9
docs: expand cache plan with all seven testing tiers and do…
Sonnet 4.6
131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c
docs: docstring sprint for-each-ref→hotspots — idiomatic ru…
Sonnet 4.6
patch
138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9
fix(cursorignore): remove git-ism (.git/worktrees)
Human
minor
⚠
140 days ago