gabriel / muse public
test_harmony_cli.py python
1,169 lines 42.5 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Tests for ``muse harmony`` CLI — Phase 2.
2
3 Coverage tiers
4 --------------
5 I Unit — TypedDict field presence; subcommand registration
6 II Integration — every subcommand success path (text + JSON)
7 III Integration — every subcommand error path (bad args, not-found)
8 IV End-to-end — full lifecycle through the CLI layer
9 V Data integrity— JSON round-trips; all fields always present
10 VI Security — path-traversal IDs rejected; invalid hex rejected
11 VII Performance — each subcommand completes within 300 ms
12 """
13 from __future__ import annotations
14 from collections.abc import Mapping
15
16 from muse.core._types import fake_id, short_id
17 import json
18 import pathlib
19 import time
20 import typing
21
22 import pytest
23
24 from tests.cli_test_helper import CliRunner
25
26 runner = CliRunner()
27
28 # ---------------------------------------------------------------------------
29 # Helpers
30 # ---------------------------------------------------------------------------
31
32
33
34 # ---------------------------------------------------------------------------
35 # Fixtures
36 # ---------------------------------------------------------------------------
37
38
39 @pytest.fixture()
40 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
41 """Minimal Muse repo — .muse/config.toml present so require_repo() succeeds."""
42 muse_dir = tmp_path / ".muse"
43 muse_dir.mkdir()
44 (muse_dir / "config.toml").write_text('[repo]\nname = "test"\nid = "abc123"\n')
45 monkeypatch.chdir(tmp_path)
46 return tmp_path
47
48
49 def _record(
50 repo: pathlib.Path,
51 *,
52 path: str = "track.mid",
53 domain: str = "midi",
54 conflict_type: str = "content",
55 ours: str = "ours",
56 theirs: str = "theirs",
57 ) -> str:
58 """Invoke ``muse harmony record`` and return the pattern_id."""
59 r = runner.invoke(None, [
60 "harmony", "record",
61 "--path", path,
62 "--domain", domain,
63 "--conflict-type", conflict_type,
64 "--ours-id", fake_id(ours),
65 "--theirs-id", fake_id(theirs),
66 "--json",
67 ])
68 assert r.exit_code == 0, f"record failed: {r.output}"
69 return json.loads(r.output)["pattern_id"]
70
71
72 def _resolve(
73 repo: pathlib.Path,
74 pattern_id: str,
75 *,
76 strategy: str = "manual",
77 confidence: str = "0.9",
78 outcome: str = "outcome",
79 rationale: str = "test",
80 agent_id: str | None = None,
81 ) -> str:
82 """Invoke ``muse harmony resolve`` and return the resolution_id."""
83 args = [
84 "harmony", "resolve",
85 "--pattern-id", pattern_id,
86 "--strategy", strategy,
87 "--outcome-blob", fake_id(outcome),
88 "--confidence", confidence,
89 "--rationale", rationale,
90 "--json",
91 ]
92 if agent_id:
93 args += ["--agent-id", agent_id]
94 r = runner.invoke(None, args)
95 assert r.exit_code == 0, f"resolve failed: {r.output}"
96 return json.loads(r.output)["resolution_id"]
97
98
99 # ===========================================================================
100 # Tier I — Unit: TypedDict schemas and subcommand registration
101 # ===========================================================================
102
103
104 class TestTypedDictSchemas:
105 """I: All TypedDict output schemas declare expected keys."""
106
107 def _hints(self, name: str) -> Mapping[str, object]:
108 import muse.cli.commands.harmony as h
109 td = getattr(h, name)
110 return typing.get_type_hints(td)
111
112 def test_record_json_has_pattern_id(self) -> None:
113 assert "pattern_id" in self._hints("_HarmonyRecordJson")
114
115 def test_record_json_has_already_existed(self) -> None:
116 assert "already_existed" in self._hints("_HarmonyRecordJson")
117
118 def test_list_json_has_total(self) -> None:
119 assert "total" in self._hints("_HarmonyListJson")
120
121 def test_list_json_has_patterns(self) -> None:
122 assert "patterns" in self._hints("_HarmonyListJson")
123
124 def test_show_json_has_pattern(self) -> None:
125 assert "pattern" in self._hints("_HarmonyShowJson")
126
127 def test_show_json_has_resolutions(self) -> None:
128 assert "resolutions" in self._hints("_HarmonyShowJson")
129
130 def test_resolve_json_has_resolution_id(self) -> None:
131 assert "resolution_id" in self._hints("_HarmonyResolveJson")
132
133 def test_resolve_json_has_pattern_id(self) -> None:
134 assert "pattern_id" in self._hints("_HarmonyResolveJson")
135
136 def test_resolve_json_has_already_existed(self) -> None:
137 assert "already_existed" in self._hints("_HarmonyResolveJson")
138
139 def test_best_json_has_pattern_id(self) -> None:
140 assert "pattern_id" in self._hints("_HarmonyBestJson")
141
142 def test_best_json_has_resolution(self) -> None:
143 assert "resolution" in self._hints("_HarmonyBestJson")
144
145 def test_forget_json_has_pattern_id(self) -> None:
146 assert "pattern_id" in self._hints("_HarmonyForgetJson")
147
148 def test_forget_json_has_removed(self) -> None:
149 assert "removed" in self._hints("_HarmonyForgetJson")
150
151 def test_scalar_json_has_removed(self) -> None:
152 assert "removed" in self._hints("_HarmonyScalarJson")
153
154 def test_policy_add_json_has_policy_id(self) -> None:
155 assert "policy_id" in self._hints("_HarmonyPolicyAddJson")
156
157 def test_policy_list_json_has_total(self) -> None:
158 assert "total" in self._hints("_HarmonyPolicyListJson")
159
160 def test_policy_list_json_has_policies(self) -> None:
161 assert "policies" in self._hints("_HarmonyPolicyListJson")
162
163 def test_policy_remove_json_has_policy_id(self) -> None:
164 assert "policy_id" in self._hints("_HarmonyPolicyRemoveJson")
165
166 def test_policy_remove_json_has_removed(self) -> None:
167 assert "removed" in self._hints("_HarmonyPolicyRemoveJson")
168
169 def test_audit_json_has_total(self) -> None:
170 assert "total" in self._hints("_HarmonyAuditJson")
171
172 def test_audit_json_has_entries(self) -> None:
173 assert "entries" in self._hints("_HarmonyAuditJson")
174
175
176 class TestRegistration:
177 """I: harmony is registered in the CLI and subcommands are reachable."""
178
179 def test_harmony_help_exits_0(self, repo: pathlib.Path) -> None:
180 r = runner.invoke(None, ["harmony", "--help"])
181 assert r.exit_code == 0
182
183 def test_harmony_record_help(self, repo: pathlib.Path) -> None:
184 r = runner.invoke(None, ["harmony", "record", "--help"])
185 assert r.exit_code == 0
186
187 def test_harmony_list_help(self, repo: pathlib.Path) -> None:
188 r = runner.invoke(None, ["harmony", "list", "--help"])
189 assert r.exit_code == 0
190
191 def test_harmony_show_help(self, repo: pathlib.Path) -> None:
192 r = runner.invoke(None, ["harmony", "show", "--help"])
193 assert r.exit_code == 0
194
195 def test_harmony_resolve_help(self, repo: pathlib.Path) -> None:
196 r = runner.invoke(None, ["harmony", "resolve", "--help"])
197 assert r.exit_code == 0
198
199 def test_harmony_best_help(self, repo: pathlib.Path) -> None:
200 r = runner.invoke(None, ["harmony", "best", "--help"])
201 assert r.exit_code == 0
202
203 def test_harmony_forget_help(self, repo: pathlib.Path) -> None:
204 r = runner.invoke(None, ["harmony", "forget", "--help"])
205 assert r.exit_code == 0
206
207 def test_harmony_clear_help(self, repo: pathlib.Path) -> None:
208 r = runner.invoke(None, ["harmony", "clear", "--help"])
209 assert r.exit_code == 0
210
211 def test_harmony_gc_help(self, repo: pathlib.Path) -> None:
212 r = runner.invoke(None, ["harmony", "gc", "--help"])
213 assert r.exit_code == 0
214
215 def test_harmony_policy_add_help(self, repo: pathlib.Path) -> None:
216 r = runner.invoke(None, ["harmony", "policy-add", "--help"])
217 assert r.exit_code == 0
218
219 def test_harmony_policy_list_help(self, repo: pathlib.Path) -> None:
220 r = runner.invoke(None, ["harmony", "policy-list", "--help"])
221 assert r.exit_code == 0
222
223 def test_harmony_policy_remove_help(self, repo: pathlib.Path) -> None:
224 r = runner.invoke(None, ["harmony", "policy-remove", "--help"])
225 assert r.exit_code == 0
226
227 def test_harmony_audit_help(self, repo: pathlib.Path) -> None:
228 r = runner.invoke(None, ["harmony", "audit", "--help"])
229 assert r.exit_code == 0
230
231
232 # ===========================================================================
233 # Tier II — Integration: success paths
234 # ===========================================================================
235
236
237 class TestRecordSuccess:
238 """II: muse harmony record — success paths."""
239
240 def test_record_json_returns_pattern_id(self, repo: pathlib.Path) -> None:
241 r = runner.invoke(None, [
242 "harmony", "record",
243 "--path", "track.mid",
244 "--domain", "midi",
245 "--conflict-type", "content",
246 "--ours-id", fake_id("ours"),
247 "--theirs-id", fake_id("theirs"),
248 "--json",
249 ])
250 assert r.exit_code == 0
251 data = json.loads(r.output)
252 assert len(data["pattern_id"]) == 71
253 assert data["already_existed"] is False
254
255 def test_record_idempotent_sets_already_existed(self, repo: pathlib.Path) -> None:
256 args = [
257 "harmony", "record",
258 "--path", "track.mid",
259 "--domain", "midi",
260 "--conflict-type", "content",
261 "--ours-id", fake_id("ours"),
262 "--theirs-id", fake_id("theirs"),
263 "--json",
264 ]
265 r1 = runner.invoke(None, args)
266 r2 = runner.invoke(None, args)
267 assert r1.exit_code == 0
268 assert r2.exit_code == 0
269 d1 = json.loads(r1.output)
270 d2 = json.loads(r2.output)
271 assert d1["pattern_id"] == d2["pattern_id"]
272 assert d2["already_existed"] is True
273
274 def test_record_text_output(self, repo: pathlib.Path) -> None:
275 r = runner.invoke(None, [
276 "harmony", "record",
277 "--path", "bass.mid",
278 "--domain", "midi",
279 "--conflict-type", "structural",
280 "--ours-id", fake_id("o2"),
281 "--theirs-id", fake_id("t2"),
282 ])
283 assert r.exit_code == 0
284 assert "bass.mid" in r.output
285
286 def test_record_with_semantic_fingerprint(self, repo: pathlib.Path) -> None:
287 r = runner.invoke(None, [
288 "harmony", "record",
289 "--path", "piano.mid",
290 "--domain", "midi",
291 "--conflict-type", "content",
292 "--ours-id", fake_id("ours"),
293 "--theirs-id", fake_id("theirs"),
294 "--semantic-fingerprint", fake_id("custom-semantic"),
295 "--json",
296 ])
297 assert r.exit_code == 0
298 data = json.loads(r.output)
299 assert len(data["pattern_id"]) == 71
300
301 def test_record_with_description(self, repo: pathlib.Path) -> None:
302 r = runner.invoke(None, [
303 "harmony", "record",
304 "--path", "lead.mid",
305 "--domain", "midi",
306 "--conflict-type", "content",
307 "--ours-id", fake_id("ours"),
308 "--theirs-id", fake_id("theirs"),
309 "--description", '{"bar": 4, "key": "Gmaj"}',
310 "--json",
311 ])
312 assert r.exit_code == 0
313
314
315 class TestListSuccess:
316 """II: muse harmony list — success paths."""
317
318 def test_list_empty_json(self, repo: pathlib.Path) -> None:
319 r = runner.invoke(None, ["harmony", "list", "--json"])
320 assert r.exit_code == 0
321 data = json.loads(r.output)
322 assert data["total"] == 0
323 assert data["patterns"] == []
324
325 def test_list_shows_recorded_pattern(self, repo: pathlib.Path) -> None:
326 pid = _record(repo)
327 r = runner.invoke(None, ["harmony", "list", "--json"])
328 assert r.exit_code == 0
329 data = json.loads(r.output)
330 assert data["total"] == 1
331 assert data["patterns"][0]["pattern_id"] == pid
332
333 def test_list_pattern_entry_has_required_fields(self, repo: pathlib.Path) -> None:
334 _record(repo)
335 r = runner.invoke(None, ["harmony", "list", "--json"])
336 entry = json.loads(r.output)["patterns"][0]
337 for field in ("pattern_id", "path", "domain", "conflict_type",
338 "resolution_count", "recorded_at", "recorded_by"):
339 assert field in entry, f"missing field: {field}"
340
341 def test_list_resolution_count_increments(self, repo: pathlib.Path) -> None:
342 pid = _record(repo)
343 _resolve(repo, pid)
344 r = runner.invoke(None, ["harmony", "list", "--json"])
345 entry = json.loads(r.output)["patterns"][0]
346 assert entry["resolution_count"] == 1
347
348 def test_list_text_output(self, repo: pathlib.Path) -> None:
349 _record(repo)
350 r = runner.invoke(None, ["harmony", "list"])
351 assert r.exit_code == 0
352 assert "track.mid" in r.output
353
354 def test_list_filter_by_domain(self, repo: pathlib.Path) -> None:
355 _record(repo, path="a.mid", domain="midi")
356 _record(repo, path="b.py", domain="code", ours="oa", theirs="tb")
357 r = runner.invoke(None, ["harmony", "list", "--domain", "midi", "--json"])
358 data = json.loads(r.output)
359 assert data["total"] == 1
360 assert data["patterns"][0]["domain"] == "midi"
361
362 def test_list_filter_by_conflict_type(self, repo: pathlib.Path) -> None:
363 _record(repo, path="a.mid", conflict_type="content")
364 _record(repo, path="b.mid", conflict_type="structural", ours="o2", theirs="t2")
365 r = runner.invoke(None, ["harmony", "list", "--conflict-type", "structural", "--json"])
366 data = json.loads(r.output)
367 assert data["total"] == 1
368 assert data["patterns"][0]["conflict_type"] == "structural"
369
370
371 class TestShowSuccess:
372 """II: muse harmony show — success paths."""
373
374 def test_show_pattern_json(self, repo: pathlib.Path) -> None:
375 pid = _record(repo)
376 r = runner.invoke(None, ["harmony", "show", pid, "--json"])
377 assert r.exit_code == 0
378 data = json.loads(r.output)
379 assert data["pattern"]["pattern_id"] == pid
380 assert data["resolutions"] == []
381
382 def test_show_includes_resolutions(self, repo: pathlib.Path) -> None:
383 pid = _record(repo)
384 rid = _resolve(repo, pid)
385 r = runner.invoke(None, ["harmony", "show", pid, "--json"])
386 data = json.loads(r.output)
387 assert len(data["resolutions"]) == 1
388 assert data["resolutions"][0]["resolution_id"] == rid
389
390 def test_show_resolution_has_required_fields(self, repo: pathlib.Path) -> None:
391 pid = _record(repo)
392 _resolve(repo, pid)
393 r = runner.invoke(None, ["harmony", "show", pid, "--json"])
394 res = json.loads(r.output)["resolutions"][0]
395 for field in ("resolution_id", "strategy", "confidence", "human_verified",
396 "applied_count", "resolved_by", "resolved_at", "rationale"):
397 assert field in res, f"missing field: {field}"
398
399 def test_show_text_output(self, repo: pathlib.Path) -> None:
400 pid = _record(repo)
401 r = runner.invoke(None, ["harmony", "show", pid])
402 assert r.exit_code == 0
403 assert short_id(pid) in r.output
404
405
406 class TestResolveSuccess:
407 """II: muse harmony resolve — success paths."""
408
409 def test_resolve_json(self, repo: pathlib.Path) -> None:
410 pid = _record(repo)
411 r = runner.invoke(None, [
412 "harmony", "resolve",
413 "--pattern-id", pid,
414 "--strategy", "manual",
415 "--outcome-blob", fake_id("outcome"),
416 "--confidence", "0.85",
417 "--rationale", "looks good",
418 "--json",
419 ])
420 assert r.exit_code == 0
421 data = json.loads(r.output)
422 assert len(data["resolution_id"]) == 71
423 assert data["pattern_id"] == pid
424 assert data["already_existed"] is False
425
426 def test_resolve_idempotent(self, repo: pathlib.Path) -> None:
427 pid = _record(repo)
428 args = [
429 "harmony", "resolve",
430 "--pattern-id", pid,
431 "--strategy", "manual",
432 "--outcome-blob", fake_id("outcome"),
433 "--confidence", "0.9",
434 "--json",
435 ]
436 r1 = runner.invoke(None, args)
437 r2 = runner.invoke(None, args)
438 assert r1.exit_code == 0
439 assert r2.exit_code == 0
440 d1, d2 = json.loads(r1.output), json.loads(r2.output)
441 assert d1["resolution_id"] == d2["resolution_id"]
442 assert d2["already_existed"] is True
443
444 def test_resolve_with_agent_provenance(self, repo: pathlib.Path) -> None:
445 pid = _record(repo)
446 r = runner.invoke(None, [
447 "harmony", "resolve",
448 "--pattern-id", pid,
449 "--strategy", "exact-replay",
450 "--outcome-blob", fake_id("out"),
451 "--confidence", "1.0",
452 "--agent-id", "claude-code",
453 "--model-id", "claude-sonnet-4-6",
454 "--json",
455 ])
456 assert r.exit_code == 0
457 data = json.loads(r.output)
458 assert len(data["resolution_id"]) == 71
459
460 def test_resolve_human_verified(self, repo: pathlib.Path) -> None:
461 pid = _record(repo)
462 r = runner.invoke(None, [
463 "harmony", "resolve",
464 "--pattern-id", pid,
465 "--strategy", "manual",
466 "--outcome-blob", fake_id("out"),
467 "--confidence", "0.95",
468 "--human-verified",
469 "--json",
470 ])
471 assert r.exit_code == 0
472
473 def test_resolve_text_output(self, repo: pathlib.Path) -> None:
474 pid = _record(repo)
475 r = runner.invoke(None, [
476 "harmony", "resolve",
477 "--pattern-id", pid,
478 "--strategy", "manual",
479 "--outcome-blob", fake_id("out"),
480 "--confidence", "0.8",
481 ])
482 assert r.exit_code == 0
483 assert short_id(pid) in r.output
484
485
486 class TestBestSuccess:
487 """II: muse harmony best — success paths."""
488
489 def test_best_returns_null_when_no_resolution(self, repo: pathlib.Path) -> None:
490 pid = _record(repo)
491 r = runner.invoke(None, ["harmony", "best", pid, "--json"])
492 assert r.exit_code == 0
493 data = json.loads(r.output)
494 assert data["pattern_id"] == pid
495 assert data["resolution"] is None
496
497 def test_best_returns_highest_quality(self, repo: pathlib.Path) -> None:
498 pid = _record(repo)
499 _resolve(repo, pid, confidence="0.5", outcome="low")
500 _resolve(repo, pid, confidence="0.9", outcome="high")
501 r = runner.invoke(None, ["harmony", "best", pid, "--json"])
502 data = json.loads(r.output)
503 assert data["resolution"] is not None
504 assert data["resolution"]["confidence"] == pytest.approx(0.9)
505
506 def test_best_text_output(self, repo: pathlib.Path) -> None:
507 pid = _record(repo)
508 _resolve(repo, pid)
509 r = runner.invoke(None, ["harmony", "best", pid])
510 assert r.exit_code == 0
511 assert short_id(pid) in r.output
512
513
514 class TestForgetSuccess:
515 """II: muse harmony forget — success paths."""
516
517 def test_forget_existing_pattern(self, repo: pathlib.Path) -> None:
518 pid = _record(repo)
519 r = runner.invoke(None, ["harmony", "forget", pid, "--json"])
520 assert r.exit_code == 0
521 data = json.loads(r.output)
522 assert data["pattern_id"] == pid
523 assert data["removed"] is True
524
525 def test_forget_nonexistent_returns_false(self, repo: pathlib.Path) -> None:
526 r = runner.invoke(None, ["harmony", "forget", fake_id("a"), "--json"])
527 assert r.exit_code == 0
528 data = json.loads(r.output)
529 assert data["removed"] is False
530
531 def test_forget_text_output(self, repo: pathlib.Path) -> None:
532 pid = _record(repo)
533 r = runner.invoke(None, ["harmony", "forget", pid])
534 assert r.exit_code == 0
535 assert short_id(pid) in r.output
536
537
538 class TestClearSuccess:
539 """II: muse harmony clear — success paths."""
540
541 def test_clear_empty(self, repo: pathlib.Path) -> None:
542 r = runner.invoke(None, ["harmony", "clear", "--yes", "--json"])
543 assert r.exit_code == 0
544 assert json.loads(r.output)["removed"] == 0
545
546 def test_clear_removes_all(self, repo: pathlib.Path) -> None:
547 for i in range(3):
548 _record(repo, path=f"f{i}.mid", ours=f"o{i}", theirs=f"t{i}")
549 r = runner.invoke(None, ["harmony", "clear", "--yes", "--json"])
550 assert r.exit_code == 0
551 assert json.loads(r.output)["removed"] == 3
552
553 def test_clear_text_output(self, repo: pathlib.Path) -> None:
554 _record(repo)
555 r = runner.invoke(None, ["harmony", "clear", "--yes"])
556 assert r.exit_code == 0
557 assert "1" in r.output
558
559
560 class TestGcSuccess:
561 """II: muse harmony gc — success paths."""
562
563 def test_gc_empty_store(self, repo: pathlib.Path) -> None:
564 r = runner.invoke(None, ["harmony", "gc", "--json"])
565 assert r.exit_code == 0
566 data = json.loads(r.output)
567 assert data["removed"] == 0
568 assert "age_days" in data
569
570 def test_gc_removes_stale_unresolved(self, repo: pathlib.Path) -> None:
571 # Record a pattern and manually backdate it
572 pid = _record(repo)
573 import muse.core.harmony as hm
574 meta_p = hm.pattern_dir(pathlib.Path("."), pid) / "pattern.json"
575 pattern_data = json.loads(meta_p.read_text())
576 pattern_data["recorded_at"] = "2020-01-01T00:00:00+00:00"
577 meta_p.write_text(json.dumps(pattern_data))
578
579 r = runner.invoke(None, ["harmony", "gc", "--age", "1", "--json"])
580 assert r.exit_code == 0
581 assert json.loads(r.output)["removed"] == 1
582
583 def test_gc_text_output(self, repo: pathlib.Path) -> None:
584 r = runner.invoke(None, ["harmony", "gc"])
585 assert r.exit_code == 0
586
587
588 class TestPolicyAddSuccess:
589 """II: muse harmony policy-add — success paths."""
590
591 def test_policy_add_json(self, repo: pathlib.Path) -> None:
592 r = runner.invoke(None, [
593 "harmony", "policy-add",
594 "--policy-id", "prefer-ours",
595 "--description", "Always prefer ours for midi",
596 "--scope", "repo",
597 "--action", "prefer-ours",
598 "--json",
599 ])
600 assert r.exit_code == 0
601 data = json.loads(r.output)
602 assert data["policy_id"] == "prefer-ours"
603 assert data["action"] == "prefer-ours"
604 assert data["scope"] == "repo"
605
606 def test_policy_add_with_condition(self, repo: pathlib.Path) -> None:
607 r = runner.invoke(None, [
608 "harmony", "policy-add",
609 "--policy-id", "midi-content",
610 "--description", "Midi content policy",
611 "--scope", "domain",
612 "--action", "prefer-ours",
613 "--conflict-type", "content",
614 "--domain", "midi",
615 "--path-pattern", "*.mid",
616 "--confidence", "0.95",
617 "--json",
618 ])
619 assert r.exit_code == 0
620 data = json.loads(r.output)
621 assert data["policy_id"] == "midi-content"
622
623 def test_policy_add_text_output(self, repo: pathlib.Path) -> None:
624 r = runner.invoke(None, [
625 "harmony", "policy-add",
626 "--policy-id", "my-policy",
627 "--description", "Test",
628 "--scope", "workspace",
629 "--action", "escalate",
630 ])
631 assert r.exit_code == 0
632 assert "my-policy" in r.output
633
634
635 class TestPolicyListSuccess:
636 """II: muse harmony policy-list — success paths."""
637
638 def test_policy_list_empty(self, repo: pathlib.Path) -> None:
639 r = runner.invoke(None, ["harmony", "policy-list", "--json"])
640 assert r.exit_code == 0
641 data = json.loads(r.output)
642 assert data["total"] == 0
643 assert data["policies"] == []
644
645 def test_policy_list_shows_added(self, repo: pathlib.Path) -> None:
646 runner.invoke(None, [
647 "harmony", "policy-add",
648 "--policy-id", "p1",
649 "--description", "d",
650 "--scope", "repo",
651 "--action", "prefer-ours",
652 ])
653 r = runner.invoke(None, ["harmony", "policy-list", "--json"])
654 data = json.loads(r.output)
655 assert data["total"] == 1
656 assert data["policies"][0]["policy_id"] == "p1"
657
658 def test_policy_list_entry_has_required_fields(self, repo: pathlib.Path) -> None:
659 runner.invoke(None, [
660 "harmony", "policy-add",
661 "--policy-id", "p2",
662 "--description", "desc",
663 "--scope", "repo",
664 "--action", "prefer-ours",
665 ])
666 r = runner.invoke(None, ["harmony", "policy-list", "--json"])
667 entry = json.loads(r.output)["policies"][0]
668 for field in ("policy_id", "description", "scope", "action", "confidence",
669 "conflict_type", "domain", "path_pattern", "created_at", "created_by"):
670 assert field in entry, f"missing field: {field}"
671
672 def test_policy_list_scope_sorted(self, repo: pathlib.Path) -> None:
673 for pid, scope in [("f", "file"), ("w", "workspace"), ("d", "domain"), ("r", "repo")]:
674 runner.invoke(None, [
675 "harmony", "policy-add",
676 "--policy-id", pid,
677 "--description", "x",
678 "--scope", scope,
679 "--action", "prefer-ours",
680 ])
681 r = runner.invoke(None, ["harmony", "policy-list", "--json"])
682 scopes = [p["scope"] for p in json.loads(r.output)["policies"]]
683 assert scopes.index("workspace") < scopes.index("repo")
684 assert scopes.index("repo") < scopes.index("domain")
685 assert scopes.index("domain") < scopes.index("file")
686
687
688 class TestPolicyRemoveSuccess:
689 """II: muse harmony policy-remove — success paths."""
690
691 def test_policy_remove_existing(self, repo: pathlib.Path) -> None:
692 runner.invoke(None, [
693 "harmony", "policy-add",
694 "--policy-id", "to-remove",
695 "--description", "x",
696 "--scope", "repo",
697 "--action", "prefer-ours",
698 ])
699 r = runner.invoke(None, ["harmony", "policy-remove", "to-remove", "--json"])
700 assert r.exit_code == 0
701 data = json.loads(r.output)
702 assert data["policy_id"] == "to-remove"
703 assert data["removed"] is True
704
705 def test_policy_remove_nonexistent(self, repo: pathlib.Path) -> None:
706 r = runner.invoke(None, ["harmony", "policy-remove", "no-such-policy", "--json"])
707 assert r.exit_code == 0
708 data = json.loads(r.output)
709 assert data["removed"] is False
710
711
712 class TestAuditSuccess:
713 """II: muse harmony audit — success paths."""
714
715 def test_audit_empty(self, repo: pathlib.Path) -> None:
716 r = runner.invoke(None, ["harmony", "audit", "--json"])
717 assert r.exit_code == 0
718 data = json.loads(r.output)
719 assert data["total"] == 0
720 assert data["entries"] == []
721
722 def test_audit_shows_entries_after_record(self, repo: pathlib.Path) -> None:
723 _record(repo)
724 r = runner.invoke(None, ["harmony", "audit", "--json"])
725 assert r.exit_code == 0
726 data = json.loads(r.output)
727 assert data["total"] >= 1
728
729 def test_audit_entry_has_required_fields(self, repo: pathlib.Path) -> None:
730 _record(repo)
731 r = runner.invoke(None, ["harmony", "audit", "--json"])
732 entry = json.loads(r.output)["entries"][0]
733 for field in ("audit_id", "event_type", "pattern_id", "resolution_id",
734 "policy_id", "acted_by", "occurred_at", "metadata"):
735 assert field in entry, f"missing field: {field}"
736
737 def test_audit_limit(self, repo: pathlib.Path) -> None:
738 for i in range(5):
739 _record(repo, path=f"f{i}.mid", ours=f"o{i}", theirs=f"t{i}")
740 r = runner.invoke(None, ["harmony", "audit", "--limit", "2", "--json"])
741 data = json.loads(r.output)
742 assert len(data["entries"]) <= 2
743
744
745 # ===========================================================================
746 # Tier III — Integration: error paths
747 # ===========================================================================
748
749
750 class TestRecordErrors:
751 """III: muse harmony record — error paths."""
752
753 def test_record_missing_path_exits_nonzero(self, repo: pathlib.Path) -> None:
754 r = runner.invoke(None, [
755 "harmony", "record",
756 "--domain", "midi",
757 "--conflict-type", "content",
758 "--ours-id", fake_id("o"),
759 "--theirs-id", fake_id("t"),
760 ])
761 assert r.exit_code != 0
762
763 def test_record_missing_ours_id_exits_nonzero(self, repo: pathlib.Path) -> None:
764 r = runner.invoke(None, [
765 "harmony", "record",
766 "--path", "track.mid",
767 "--domain", "midi",
768 "--conflict-type", "content",
769 "--theirs-id", fake_id("t"),
770 ])
771 assert r.exit_code != 0
772
773 def test_record_invalid_ours_id_exits_1(self, repo: pathlib.Path) -> None:
774 r = runner.invoke(None, [
775 "harmony", "record",
776 "--path", "track.mid",
777 "--domain", "midi",
778 "--conflict-type", "content",
779 "--ours-id", "not-hex",
780 "--theirs-id", fake_id("t"),
781 "--json",
782 ])
783 assert r.exit_code == 1
784
785 def test_record_bad_description_json_exits_1(self, repo: pathlib.Path) -> None:
786 r = runner.invoke(None, [
787 "harmony", "record",
788 "--path", "track.mid",
789 "--domain", "midi",
790 "--conflict-type", "content",
791 "--ours-id", fake_id("o"),
792 "--theirs-id", fake_id("t"),
793 "--description", "{bad json",
794 "--json",
795 ])
796 assert r.exit_code == 1
797
798
799 class TestResolveErrors:
800 """III: muse harmony resolve — error paths."""
801
802 def test_resolve_missing_pattern_exits_1(self, repo: pathlib.Path) -> None:
803 r = runner.invoke(None, [
804 "harmony", "resolve",
805 "--pattern-id", "a" * 64,
806 "--strategy", "manual",
807 "--outcome-blob", fake_id("out"),
808 "--confidence", "0.9",
809 "--json",
810 ])
811 assert r.exit_code == 1
812
813 def test_resolve_invalid_pattern_id_exits_1(self, repo: pathlib.Path) -> None:
814 r = runner.invoke(None, [
815 "harmony", "resolve",
816 "--pattern-id", "not-hex",
817 "--strategy", "manual",
818 "--outcome-blob", fake_id("out"),
819 "--confidence", "0.9",
820 "--json",
821 ])
822 assert r.exit_code == 1
823
824 def test_resolve_confidence_out_of_range_exits_1(self, repo: pathlib.Path) -> None:
825 pid = _record(repo)
826 r = runner.invoke(None, [
827 "harmony", "resolve",
828 "--pattern-id", pid,
829 "--strategy", "manual",
830 "--outcome-blob", fake_id("out"),
831 "--confidence", "1.5",
832 "--json",
833 ])
834 assert r.exit_code == 1
835
836 def test_resolve_negative_confidence_exits_1(self, repo: pathlib.Path) -> None:
837 pid = _record(repo)
838 r = runner.invoke(None, [
839 "harmony", "resolve",
840 "--pattern-id", pid,
841 "--strategy", "manual",
842 "--outcome-blob", fake_id("out"),
843 "--confidence", "-0.1",
844 "--json",
845 ])
846 assert r.exit_code == 1
847
848
849 class TestShowErrors:
850 """III: muse harmony show — error paths."""
851
852 def test_show_nonexistent_exits_1(self, repo: pathlib.Path) -> None:
853 r = runner.invoke(None, ["harmony", "show", "a" * 64, "--json"])
854 assert r.exit_code == 1
855
856 def test_show_invalid_id_exits_1(self, repo: pathlib.Path) -> None:
857 r = runner.invoke(None, ["harmony", "show", "bad-id", "--json"])
858 assert r.exit_code == 1
859
860
861 class TestBestErrors:
862 """III: muse harmony best — error paths."""
863
864 def test_best_invalid_id_exits_1(self, repo: pathlib.Path) -> None:
865 r = runner.invoke(None, ["harmony", "best", "bad-id", "--json"])
866 assert r.exit_code == 1
867
868
869 class TestForgetErrors:
870 """III: muse harmony forget — error paths."""
871
872 def test_forget_invalid_id_exits_1(self, repo: pathlib.Path) -> None:
873 r = runner.invoke(None, ["harmony", "forget", "bad-id", "--json"])
874 assert r.exit_code == 1
875
876
877 class TestGcErrors:
878 """III: muse harmony gc — error paths."""
879
880 def test_gc_invalid_age_exits_1(self, repo: pathlib.Path) -> None:
881 r = runner.invoke(None, ["harmony", "gc", "--age", "0", "--json"])
882 assert r.exit_code == 1
883
884 def test_gc_negative_age_exits_1(self, repo: pathlib.Path) -> None:
885 r = runner.invoke(None, ["harmony", "gc", "--age", "-5", "--json"])
886 assert r.exit_code == 1
887
888
889 class TestPolicyErrors:
890 """III: muse harmony policy-add — error paths."""
891
892 def test_policy_add_invalid_id_exits_1(self, repo: pathlib.Path) -> None:
893 r = runner.invoke(None, [
894 "harmony", "policy-add",
895 "--policy-id", "bad/id",
896 "--description", "x",
897 "--scope", "repo",
898 "--action", "prefer-ours",
899 "--json",
900 ])
901 assert r.exit_code == 1
902
903 def test_policy_remove_invalid_id_exits_1(self, repo: pathlib.Path) -> None:
904 r = runner.invoke(None, ["harmony", "policy-remove", "bad/id", "--json"])
905 assert r.exit_code == 1
906
907
908 # ===========================================================================
909 # Tier IV — End-to-end lifecycle
910 # ===========================================================================
911
912
913 class TestEndToEnd:
914 """IV: Full lifecycle via CLI."""
915
916 def test_record_resolve_best_lifecycle(self, repo: pathlib.Path) -> None:
917 # Record
918 pid = _record(repo, path="lifecycle.mid", domain="midi")
919
920 # Resolve
921 rid = _resolve(repo, pid, strategy="manual", confidence="0.88")
922
923 # Best
924 r = runner.invoke(None, ["harmony", "best", pid, "--json"])
925 data = json.loads(r.output)
926 assert data["resolution"]["resolution_id"] == rid
927 assert data["resolution"]["confidence"] == pytest.approx(0.88)
928
929 # Audit has entries
930 ra = runner.invoke(None, ["harmony", "audit", "--json"])
931 assert json.loads(ra.output)["total"] >= 2
932
933 def test_policy_controls_match_then_remove(self, repo: pathlib.Path) -> None:
934 # Add policy
935 runner.invoke(None, [
936 "harmony", "policy-add",
937 "--policy-id", "midi-all",
938 "--description", "prefer ours for all midi",
939 "--scope", "domain",
940 "--action", "prefer-ours",
941 "--domain", "midi",
942 "--json",
943 ])
944
945 # List confirms it's there
946 rl = runner.invoke(None, ["harmony", "policy-list", "--json"])
947 assert json.loads(rl.output)["total"] == 1
948
949 # Remove
950 runner.invoke(None, ["harmony", "policy-remove", "midi-all"])
951
952 # List now empty
953 rl2 = runner.invoke(None, ["harmony", "policy-list", "--json"])
954 assert json.loads(rl2.output)["total"] == 0
955
956 def test_forget_removes_from_list(self, repo: pathlib.Path) -> None:
957 pid = _record(repo)
958 runner.invoke(None, ["harmony", "forget", pid])
959 r = runner.invoke(None, ["harmony", "list", "--json"])
960 assert json.loads(r.output)["total"] == 0
961
962 def test_clear_empties_store(self, repo: pathlib.Path) -> None:
963 for i in range(3):
964 _record(repo, path=f"g{i}.mid", ours=f"go{i}", theirs=f"gt{i}")
965 runner.invoke(None, ["harmony", "clear", "--yes"])
966 r = runner.invoke(None, ["harmony", "list", "--json"])
967 assert json.loads(r.output)["total"] == 0
968
969 def test_gc_does_not_remove_resolved_pattern(self, repo: pathlib.Path) -> None:
970 pid = _record(repo)
971 _resolve(repo, pid)
972
973 # Backdate recorded_at to trigger age threshold
974 import muse.core.harmony as hm
975 meta_p = hm.pattern_dir(pathlib.Path("."), pid) / "pattern.json"
976 d = json.loads(meta_p.read_text())
977 d["recorded_at"] = "2020-01-01T00:00:00+00:00"
978 meta_p.write_text(json.dumps(d))
979
980 r = runner.invoke(None, ["harmony", "gc", "--age", "1", "--json"])
981 assert json.loads(r.output)["removed"] == 0
982
983 rl = runner.invoke(None, ["harmony", "list", "--json"])
984 assert json.loads(rl.output)["total"] == 1
985
986
987 # ===========================================================================
988 # Tier V — Data integrity
989 # ===========================================================================
990
991
992 class TestDataIntegrity:
993 """V: JSON schemas always fully populated; round-trips correct."""
994
995 def test_list_entry_fields_always_present(self, repo: pathlib.Path) -> None:
996 _record(repo)
997 r = runner.invoke(None, ["harmony", "list", "--json"])
998 entry = json.loads(r.output)["patterns"][0]
999 # resolution_count must be 0, not absent
1000 assert entry["resolution_count"] == 0
1001
1002 def test_show_resolutions_field_present_when_empty(self, repo: pathlib.Path) -> None:
1003 pid = _record(repo)
1004 r = runner.invoke(None, ["harmony", "show", pid, "--json"])
1005 data = json.loads(r.output)
1006 assert "resolutions" in data
1007 assert isinstance(data["resolutions"], list)
1008
1009 def test_best_resolution_is_null_not_missing(self, repo: pathlib.Path) -> None:
1010 pid = _record(repo)
1011 r = runner.invoke(None, ["harmony", "best", pid, "--json"])
1012 data = json.loads(r.output)
1013 assert "resolution" in data
1014 assert data["resolution"] is None
1015
1016 def test_gc_json_always_has_age_days(self, repo: pathlib.Path) -> None:
1017 r = runner.invoke(None, ["harmony", "gc", "--json"])
1018 data = json.loads(r.output)
1019 assert "age_days" in data
1020 assert isinstance(data["age_days"], int)
1021
1022 def test_policy_list_null_conditions_present(self, repo: pathlib.Path) -> None:
1023 runner.invoke(None, [
1024 "harmony", "policy-add",
1025 "--policy-id", "no-conds",
1026 "--description", "x",
1027 "--scope", "repo",
1028 "--action", "prefer-ours",
1029 ])
1030 r = runner.invoke(None, ["harmony", "policy-list", "--json"])
1031 entry = json.loads(r.output)["policies"][0]
1032 assert entry["conflict_type"] is None
1033 assert entry["domain"] is None
1034 assert entry["path_pattern"] is None
1035
1036 def test_resolve_confidence_round_trip(self, repo: pathlib.Path) -> None:
1037 pid = _record(repo)
1038 _resolve(repo, pid, confidence="0.73")
1039 r = runner.invoke(None, ["harmony", "best", pid, "--json"])
1040 conf = json.loads(r.output)["resolution"]["confidence"]
1041 assert abs(conf - 0.73) < 0.01
1042
1043
1044 # ===========================================================================
1045 # Tier VI — Security
1046 # ===========================================================================
1047
1048
1049 class TestSecurity:
1050 """VI: path-traversal IDs and crafted inputs are rejected."""
1051
1052 def test_record_path_traversal_ours_id_rejected(self, repo: pathlib.Path) -> None:
1053 r = runner.invoke(None, [
1054 "harmony", "record",
1055 "--path", "track.mid",
1056 "--domain", "midi",
1057 "--conflict-type", "content",
1058 "--ours-id", "../../../etc/passwd",
1059 "--theirs-id", fake_id("t"),
1060 "--json",
1061 ])
1062 assert r.exit_code == 1
1063
1064 def test_show_path_traversal_rejected(self, repo: pathlib.Path) -> None:
1065 r = runner.invoke(None, ["harmony", "show", "../../evil", "--json"])
1066 assert r.exit_code == 1
1067
1068 def test_forget_path_traversal_rejected(self, repo: pathlib.Path) -> None:
1069 r = runner.invoke(None, ["harmony", "forget", "../../evil", "--json"])
1070 assert r.exit_code == 1
1071
1072 def test_best_path_traversal_rejected(self, repo: pathlib.Path) -> None:
1073 r = runner.invoke(None, ["harmony", "best", "../../evil", "--json"])
1074 assert r.exit_code == 1
1075
1076 def test_policy_add_slash_in_id_rejected(self, repo: pathlib.Path) -> None:
1077 r = runner.invoke(None, [
1078 "harmony", "policy-add",
1079 "--policy-id", "evil/policy",
1080 "--description", "x",
1081 "--scope", "repo",
1082 "--action", "prefer-ours",
1083 "--json",
1084 ])
1085 assert r.exit_code == 1
1086
1087 def test_policy_remove_slash_in_id_rejected(self, repo: pathlib.Path) -> None:
1088 r = runner.invoke(None, ["harmony", "policy-remove", "../etc/passwd", "--json"])
1089 assert r.exit_code == 1
1090
1091 def test_resolve_path_traversal_pattern_id_rejected(self, repo: pathlib.Path) -> None:
1092 r = runner.invoke(None, [
1093 "harmony", "resolve",
1094 "--pattern-id", "../../evil",
1095 "--strategy", "manual",
1096 "--outcome-blob", fake_id("out"),
1097 "--confidence", "0.9",
1098 "--json",
1099 ])
1100 assert r.exit_code == 1
1101
1102
1103 # ===========================================================================
1104 # Tier VII — Performance
1105 # ===========================================================================
1106
1107
1108 class TestPerformance:
1109 """VII: each subcommand completes within 300 ms (after warm-up)."""
1110
1111 def test_record_under_300ms(self, repo: pathlib.Path) -> None:
1112 start = time.monotonic()
1113 runner.invoke(None, [
1114 "harmony", "record",
1115 "--path", "perf.mid",
1116 "--domain", "midi",
1117 "--conflict-type", "content",
1118 "--ours-id", fake_id("po"),
1119 "--theirs-id", fake_id("pt"),
1120 ])
1121 elapsed = (time.monotonic() - start) * 1000
1122 assert elapsed < 300, f"record took {elapsed:.0f}ms"
1123
1124 def test_list_under_300ms(self, repo: pathlib.Path) -> None:
1125 _record(repo)
1126 start = time.monotonic()
1127 runner.invoke(None, ["harmony", "list", "--json"])
1128 elapsed = (time.monotonic() - start) * 1000
1129 assert elapsed < 300, f"list took {elapsed:.0f}ms"
1130
1131 def test_show_under_300ms(self, repo: pathlib.Path) -> None:
1132 pid = _record(repo)
1133 start = time.monotonic()
1134 runner.invoke(None, ["harmony", "show", pid, "--json"])
1135 elapsed = (time.monotonic() - start) * 1000
1136 assert elapsed < 300, f"show took {elapsed:.0f}ms"
1137
1138 def test_resolve_under_300ms(self, repo: pathlib.Path) -> None:
1139 pid = _record(repo)
1140 start = time.monotonic()
1141 runner.invoke(None, [
1142 "harmony", "resolve",
1143 "--pattern-id", pid,
1144 "--strategy", "manual",
1145 "--outcome-blob", fake_id("po"),
1146 "--confidence", "0.9",
1147 ])
1148 elapsed = (time.monotonic() - start) * 1000
1149 assert elapsed < 300, f"resolve took {elapsed:.0f}ms"
1150
1151 def test_policy_operations_under_300ms(self, repo: pathlib.Path) -> None:
1152 start = time.monotonic()
1153 runner.invoke(None, [
1154 "harmony", "policy-add",
1155 "--policy-id", "perf-policy",
1156 "--description", "x",
1157 "--scope", "repo",
1158 "--action", "prefer-ours",
1159 ])
1160 runner.invoke(None, ["harmony", "policy-list", "--json"])
1161 runner.invoke(None, ["harmony", "policy-remove", "perf-policy"])
1162 elapsed = (time.monotonic() - start) * 1000
1163 assert elapsed < 300, f"policy ops took {elapsed:.0f}ms"
1164
1165 def test_gc_under_300ms(self, repo: pathlib.Path) -> None:
1166 start = time.monotonic()
1167 runner.invoke(None, ["harmony", "gc", "--json"])
1168 elapsed = (time.monotonic() - start) * 1000
1169 assert elapsed < 300, f"gc took {elapsed:.0f}ms"
File History 2 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 137 days ago