gabriel / muse public
test_tag_supercharge.py python
404 lines 17.8 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
1 """Supercharge tests for ``muse tag``.
2
3 Covers gaps identified in the review:
4 - duration_ms + exit_code in all JSON outputs (add / list / remove)
5 - JSON errors emitted to stdout (not stderr) when --json is set
6 - _TagJson TypedDict schema completeness with new telemetry fields
7 - Tag ID is sha256-prefixed, not UUID
8 - Docstring correctness (schema in help text)
9
10 Unit
11 ----
12 U1 _TagJson TypedDict has duration_ms and exit_code fields
13 U2 _emit_error helper is present in module (or equivalent pattern)
14 U3 format error in JSON mode goes to stdout
15 U4 tag_id format is sha256 prefix, not UUID
16 U5 commit_id in JSON output is sha256-prefixed
17
18 Integration — duration_ms / exit_code
19 -------------------------------------
20 I1 tag add --json includes duration_ms (float ≥ 0)
21 I2 tag add --json includes exit_code == 0 on success
22 I3 tag add --json --dry-run includes duration_ms and exit_code == 0
23 I4 tag add already_tagged includes duration_ms and exit_code == 0
24 I5 tag list --json includes duration_ms and exit_code == 0
25 I6 tag list --json empty repo includes duration_ms and exit_code == 0
26 I7 tag remove --json includes duration_ms and exit_code == 0
27 I8 tag remove --json not_found includes duration_ms and exit_code == 0
28
29 Integration — JSON errors to stdout
30 -------------------------------------
31 E1 tag add invalid tag name --json → JSON error on stdout, not stderr
32 E2 tag add commit not found --json → JSON error on stdout
33 E3 tag add bad format --json → handled (format validated before JSON flag seen,
34 but error must still exit nonzero)
35 E4 tag list commit not found --json → JSON error on stdout
36 E5 tag remove invalid tag name --json → JSON error on stdout
37 E6 tag remove commit not found --json → JSON error on stdout
38
39 Error JSON schema
40 -----------------
41 S1 add invalid-tag error JSON has "error", "message", "duration_ms", "exit_code"
42 S2 add commit-not-found error JSON has expected keys
43 S3 list commit-not-found error JSON has expected keys
44 S4 remove invalid-tag error JSON has expected keys
45
46 Data integrity
47 --------------
48 D1 tag_id is sha256-prefixed string (not UUID) on add
49 D2 tag_id is sha256-prefixed on list
50 D3 commit_id is sha256-prefixed on add
51 D4 commit_id is sha256-prefixed on list
52 D5 commit_id is sha256-prefixed on remove
53
54 Performance
55 -----------
56 P1 duration_ms is a float (not int, not None)
57 P2 duration_ms is under 5000 ms for a simple add
58 P3 duration_ms values across two sequential calls are both positive
59
60 Concurrent reads
61 ----------------
62 C1 concurrent tag list calls on isolated repos produce correct counts
63 """
64
65 from __future__ import annotations
66
67 import json
68 import os
69 import pathlib
70 import threading
71 import time
72
73 import pytest
74
75 from tests.cli_test_helper import CliRunner
76
77 cli = None
78 runner = CliRunner()
79
80 _CHDIR_LOCK = threading.Lock()
81
82
83 def _env(root: pathlib.Path) -> dict[str, str]:
84 return {"MUSE_REPO_ROOT": str(root)}
85
86
87 @pytest.fixture()
88 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
89 monkeypatch.chdir(tmp_path)
90 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
91 runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
92 (tmp_path / "song.mid").write_bytes(b"\x00" * 16)
93 runner.invoke(cli, ["commit", "-m", "base"], env=_env(tmp_path), catch_exceptions=False)
94 return tmp_path
95
96
97 @pytest.fixture()
98 def tagged_repo(repo: pathlib.Path) -> pathlib.Path:
99 runner.invoke(cli, ["tag", "add", "emotion:joyful"], env=_env(repo), catch_exceptions=False)
100 return repo
101
102
103 # ---------------------------------------------------------------------------
104 # Unit
105 # ---------------------------------------------------------------------------
106
107 class TestUnit:
108 def test_u1_typeddict_has_duration_ms(self) -> None:
109 """U1 — _TagJson TypedDict must declare duration_ms field."""
110 from muse.cli.commands.tag import _TagJson
111 hints = _TagJson.__annotations__
112 assert "duration_ms" in hints, "_TagJson missing duration_ms"
113
114 def test_u2_typeddict_has_exit_code(self) -> None:
115 """U2 — _TagJson TypedDict must declare exit_code field."""
116 from muse.cli.commands.tag import _TagJson
117 hints = _TagJson.__annotations__
118 assert "exit_code" in hints, "_TagJson missing exit_code"
119
120 def test_u3_format_error_json_mode(self, repo: pathlib.Path) -> None:
121 """U3 — Invalid format: --json is ambiguous (format set before --json), but exit nonzero."""
122 r = runner.invoke(cli, ["tag", "add", "emotion:happy", "--format", "bad"],
123 env=_env(repo))
124 assert r.exit_code != 0
125
126 def test_u4_tag_id_is_sha256(self, repo: pathlib.Path) -> None:
127 """U4 — tag_id must use sha256: prefix, not UUID."""
128 r = runner.invoke(cli, ["tag", "add", "emotion:happy", "--json"], env=_env(repo))
129 data = json.loads(r.output)
130 assert data["tag_id"].startswith("sha256:"), f"tag_id not sha256: got {data['tag_id']!r}"
131
132 def test_u5_commit_id_is_sha256(self, repo: pathlib.Path) -> None:
133 """U5 — commit_id in add output must use sha256: prefix."""
134 r = runner.invoke(cli, ["tag", "add", "emotion:happy", "--json"], env=_env(repo))
135 data = json.loads(r.output)
136 assert data["commit_id"].startswith("sha256:"), \
137 f"commit_id not sha256: got {data['commit_id']!r}"
138
139
140 # ---------------------------------------------------------------------------
141 # Integration — duration_ms / exit_code in every success path
142 # ---------------------------------------------------------------------------
143
144 class TestElapsedMsExitCode:
145 def test_i1_add_has_duration_ms(self, repo: pathlib.Path) -> None:
146 """I1 — tag add --json success includes duration_ms."""
147 r = runner.invoke(cli, ["tag", "add", "emotion:happy", "--json"], env=_env(repo))
148 assert r.exit_code == 0
149 data = json.loads(r.output)
150 assert "duration_ms" in data, f"Missing duration_ms in: {data}"
151
152 def test_i2_add_has_exit_code_zero(self, repo: pathlib.Path) -> None:
153 """I2 — tag add --json success includes exit_code == 0."""
154 r = runner.invoke(cli, ["tag", "add", "emotion:happy", "--json"], env=_env(repo))
155 data = json.loads(r.output)
156 assert data["exit_code"] == 0
157
158 def test_i3_add_dry_run_has_duration_ms(self, repo: pathlib.Path) -> None:
159 """I3 — tag add --dry-run --json includes duration_ms and exit_code."""
160 r = runner.invoke(cli, ["tag", "add", "emotion:happy", "--dry-run", "--json"],
161 env=_env(repo), catch_exceptions=False)
162 data = json.loads(r.output)
163 assert "duration_ms" in data
164 assert data["exit_code"] == 0
165
166 def test_i4_add_already_tagged_has_duration_ms(self, tagged_repo: pathlib.Path) -> None:
167 """I4 — tag add already_tagged includes duration_ms and exit_code."""
168 r = runner.invoke(cli, ["tag", "add", "emotion:joyful", "--json"],
169 env=_env(tagged_repo), catch_exceptions=False)
170 data = json.loads(r.output)
171 assert data["status"] == "already_tagged"
172 assert "duration_ms" in data
173 assert data["exit_code"] == 0
174
175 def test_i5_list_has_duration_ms(self, tagged_repo: pathlib.Path) -> None:
176 """I5 — tag list --json includes duration_ms and exit_code."""
177 r = runner.invoke(cli, ["tag", "list", "--json"],
178 env=_env(tagged_repo), catch_exceptions=False)
179 data = json.loads(r.output)
180 assert "duration_ms" in data
181 assert data["exit_code"] == 0
182
183 def test_i6_list_empty_has_duration_ms(self, repo: pathlib.Path) -> None:
184 """I6 — tag list --json empty repo includes duration_ms."""
185 r = runner.invoke(cli, ["tag", "list", "--json"],
186 env=_env(repo), catch_exceptions=False)
187 data = json.loads(r.output)
188 assert "duration_ms" in data
189 assert data["total"] == 0
190
191 def test_i7_remove_has_duration_ms(self, tagged_repo: pathlib.Path) -> None:
192 """I7 — tag remove --json includes duration_ms and exit_code."""
193 r = runner.invoke(cli, ["tag", "remove", "emotion:joyful", "--json"],
194 env=_env(tagged_repo), catch_exceptions=False)
195 data = json.loads(r.output)
196 assert "duration_ms" in data
197 assert data["exit_code"] == 0
198
199 def test_i8_remove_not_found_has_duration_ms(self, repo: pathlib.Path) -> None:
200 """I8 — tag remove not_found --json includes duration_ms."""
201 r = runner.invoke(cli, ["tag", "remove", "nonexistent:tag", "--json"],
202 env=_env(repo), catch_exceptions=False)
203 data = json.loads(r.output)
204 assert data["status"] == "not_found"
205 assert "duration_ms" in data
206 assert data["exit_code"] == 0
207
208
209 # ---------------------------------------------------------------------------
210 # Integration — JSON errors go to stdout in --json mode
211 # ---------------------------------------------------------------------------
212
213 class TestJsonErrorsToStdout:
214 def test_e1_add_invalid_tag_json_to_stdout(self, repo: pathlib.Path) -> None:
215 """E1 — invalid tag name + --json → JSON error on stdout."""
216 r = runner.invoke(cli, ["tag", "add", "bad\x1btag", "--json"], env=_env(repo))
217 assert r.exit_code != 0
218 # stdout must be parseable JSON
219 data = json.loads(r.output)
220 assert "error" in data
221
222 def test_e2_add_commit_not_found_json_to_stdout(self, repo: pathlib.Path) -> None:
223 """E2 — commit not found + --json → JSON error on stdout."""
224 r = runner.invoke(cli, ["tag", "add", "emotion:happy", "deadbeef00", "--json"],
225 env=_env(repo))
226 assert r.exit_code != 0
227 data = json.loads(r.output)
228 assert "error" in data
229
230 def test_e3_bad_format_exits_nonzero(self, repo: pathlib.Path) -> None:
231 """E3 — bad --format exits nonzero."""
232 r = runner.invoke(cli, ["tag", "add", "emotion:happy", "--format", "bad"],
233 env=_env(repo))
234 assert r.exit_code != 0
235
236 def test_e4_list_commit_not_found_json_to_stdout(self, repo: pathlib.Path) -> None:
237 """E4 — tag list commit not found + --json → JSON error on stdout."""
238 r = runner.invoke(cli, ["tag", "list", "deadbeef00", "--json"], env=_env(repo))
239 assert r.exit_code != 0
240 data = json.loads(r.output)
241 assert "error" in data
242
243 def test_e5_remove_invalid_tag_json_to_stdout(self, repo: pathlib.Path) -> None:
244 """E5 — tag remove invalid tag name + --json → JSON error on stdout."""
245 r = runner.invoke(cli, ["tag", "remove", "bad\x1btag", "--json"], env=_env(repo))
246 assert r.exit_code != 0
247 data = json.loads(r.output)
248 assert "error" in data
249
250 def test_e6_remove_commit_not_found_json_to_stdout(self, repo: pathlib.Path) -> None:
251 """E6 — tag remove commit not found + --json → JSON error on stdout."""
252 r = runner.invoke(cli, ["tag", "remove", "emotion:happy", "deadbeef00", "--json"],
253 env=_env(repo))
254 assert r.exit_code != 0
255 data = json.loads(r.output)
256 assert "error" in data
257
258
259 # ---------------------------------------------------------------------------
260 # Error JSON schema
261 # ---------------------------------------------------------------------------
262
263 class TestErrorJsonSchema:
264 _REQUIRED = {"error", "message", "duration_ms", "exit_code"}
265
266 def test_s1_add_invalid_tag_error_schema(self, repo: pathlib.Path) -> None:
267 """S1 — invalid tag error JSON has all required fields."""
268 r = runner.invoke(cli, ["tag", "add", "bad\x1btag", "--json"], env=_env(repo))
269 data = json.loads(r.output)
270 assert self._REQUIRED <= data.keys(), f"Missing keys: {self._REQUIRED - data.keys()}"
271
272 def test_s2_add_commit_not_found_error_schema(self, repo: pathlib.Path) -> None:
273 """S2 — commit not found error JSON has all required fields."""
274 r = runner.invoke(cli, ["tag", "add", "emotion:happy", "deadbeef00", "--json"],
275 env=_env(repo))
276 data = json.loads(r.output)
277 assert self._REQUIRED <= data.keys()
278 assert data["exit_code"] == 1
279
280 def test_s3_list_commit_not_found_error_schema(self, repo: pathlib.Path) -> None:
281 """S3 — list commit not found error JSON has all required fields."""
282 r = runner.invoke(cli, ["tag", "list", "deadbeef00", "--json"], env=_env(repo))
283 data = json.loads(r.output)
284 assert self._REQUIRED <= data.keys()
285
286 def test_s4_remove_invalid_tag_error_schema(self, repo: pathlib.Path) -> None:
287 """S4 — remove invalid tag error JSON has all required fields."""
288 r = runner.invoke(cli, ["tag", "remove", "bad\x1btag", "--json"], env=_env(repo))
289 data = json.loads(r.output)
290 assert self._REQUIRED <= data.keys()
291
292
293 # ---------------------------------------------------------------------------
294 # Data integrity
295 # ---------------------------------------------------------------------------
296
297 class TestDataIntegrity:
298 def test_d1_tag_id_sha256_on_add(self, repo: pathlib.Path) -> None:
299 """D1 — tag_id from add is sha256: prefixed (71 chars)."""
300 r = runner.invoke(cli, ["tag", "add", "emotion:happy", "--json"], env=_env(repo))
301 data = json.loads(r.output)
302 assert data["tag_id"].startswith("sha256:")
303 assert len(data["tag_id"]) == 71
304
305 def test_d2_tag_id_sha256_on_list(self, tagged_repo: pathlib.Path) -> None:
306 """D2 — tag_id in list entries is sha256: prefixed."""
307 r = runner.invoke(cli, ["tag", "list", "--json"], env=_env(tagged_repo))
308 entry = json.loads(r.output)["tags"][0]
309 assert entry["tag_id"].startswith("sha256:")
310
311 def test_d3_commit_id_sha256_on_add(self, repo: pathlib.Path) -> None:
312 """D3 — commit_id in add output is sha256: prefixed."""
313 r = runner.invoke(cli, ["tag", "add", "emotion:happy", "--json"], env=_env(repo))
314 data = json.loads(r.output)
315 assert data["commit_id"].startswith("sha256:")
316 assert len(data["commit_id"]) == 71
317
318 def test_d4_commit_id_sha256_on_list(self, tagged_repo: pathlib.Path) -> None:
319 """D4 — commit_id in list entries is sha256: prefixed."""
320 r = runner.invoke(cli, ["tag", "list", "--json"], env=_env(tagged_repo))
321 entry = json.loads(r.output)["tags"][0]
322 assert entry["commit_id"].startswith("sha256:")
323 assert len(entry["commit_id"]) == 71
324
325 def test_d5_commit_id_sha256_on_remove(self, tagged_repo: pathlib.Path) -> None:
326 """D5 — commit_id in remove output is sha256: prefixed."""
327 r = runner.invoke(cli, ["tag", "remove", "emotion:joyful", "--json"],
328 env=_env(tagged_repo))
329 data = json.loads(r.output)
330 assert data["commit_id"].startswith("sha256:")
331 assert len(data["commit_id"]) == 71
332
333
334 # ---------------------------------------------------------------------------
335 # Performance
336 # ---------------------------------------------------------------------------
337
338 class TestPerformance:
339 def test_p1_duration_ms_is_float(self, repo: pathlib.Path) -> None:
340 """P1 — duration_ms must be a float (not int, not None)."""
341 r = runner.invoke(cli, ["tag", "add", "emotion:happy", "--json"], env=_env(repo))
342 data = json.loads(r.output)
343 assert isinstance(data["duration_ms"], float), \
344 f"duration_ms is {type(data['duration_ms']).__name__}, expected float"
345
346 def test_p2_duration_ms_under_5000(self, repo: pathlib.Path) -> None:
347 """P2 — duration_ms must be < 5000 ms for a simple add."""
348 r = runner.invoke(cli, ["tag", "add", "emotion:happy", "--json"], env=_env(repo))
349 data = json.loads(r.output)
350 assert data["duration_ms"] < 5000.0
351
352 def test_p3_duration_ms_is_positive(self, repo: pathlib.Path) -> None:
353 """P3 — duration_ms must be ≥ 0."""
354 for tag in ["emotion:one", "section:two"]:
355 r = runner.invoke(cli, ["tag", "add", tag, "--json"], env=_env(repo))
356 data = json.loads(r.output)
357 assert data["duration_ms"] >= 0.0
358
359
360 # ---------------------------------------------------------------------------
361 # Concurrent reads
362 # ---------------------------------------------------------------------------
363
364 class TestConcurrent:
365 def test_c1_concurrent_list_isolated_repos(self, tmp_path: pathlib.Path) -> None:
366 """C1 — concurrent tag list calls on isolated repos return correct counts."""
367 errors: list[str] = []
368 lock = threading.Lock()
369
370 def _build_and_list(idx: int) -> None:
371 try:
372 repo_dir = tmp_path / f"cr_{idx}"
373 repo_dir.mkdir()
374 with _CHDIR_LOCK:
375 saved = os.getcwd()
376 try:
377 os.chdir(repo_dir)
378 runner.invoke(cli, ["init"], env={"MUSE_REPO_ROOT": str(repo_dir)})
379 (repo_dir / "a.mid").write_bytes(b"\x00" * 4)
380 runner.invoke(cli, ["commit", "-m", "c"],
381 env={"MUSE_REPO_ROOT": str(repo_dir)})
382 finally:
383 os.chdir(saved)
384
385 env = {"MUSE_REPO_ROOT": str(repo_dir)}
386 # Add 3 tags
387 for i in range(3):
388 runner.invoke(cli, ["tag", "add", f"ns:tag{i}"], env=env)
389
390 r = runner.invoke(cli, ["tag", "list", "--json"], env=env)
391 data = json.loads(r.output)
392 if data["total"] != 3:
393 with lock:
394 errors.append(f"repo {idx}: expected 3 tags, got {data['total']}")
395 except Exception as exc:
396 with lock:
397 errors.append(f"repo {idx}: {exc}")
398
399 threads = [threading.Thread(target=_build_and_list, args=(i,)) for i in range(5)]
400 for t in threads:
401 t.start()
402 for t in threads:
403 t.join()
404 assert not errors, f"Concurrent errors: {errors}"
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago