"""Tests for Phase 3.3 — Knowtation CLI hooks (cli_hooks.py). Covers the six runners that dispatch ``muse code`` commands to the note-metrics helpers when the active domain is ``knowtation``. Test tiers (Rule #0): 1. Unit — each runner handles missing-arg defaults via getattr fallbacks 2. Integration — JSON-mode output is shape-correct and stable 3. Security — runners survive corrupt manifests and missing snapshots Notes ----- These tests intentionally exercise the runners *without* a real Muse repo context — they monkeypatch the snapshot/commit lookups to use synthetic data, which keeps them fast and isolated from store layer changes. """ from __future__ import annotations import io import json import pathlib import types from contextlib import redirect_stdout import pytest from muse.plugins.knowtation import cli_hooks # ============================================================================ # Helpers # ============================================================================ def _ns(**kw) -> types.SimpleNamespace: return types.SimpleNamespace(**kw) def _make_vault(tmp_path: pathlib.Path, files: dict[str, bytes]) -> pathlib.Path: for rel, content in files.items(): full = tmp_path / rel full.parent.mkdir(parents=True, exist_ok=True) full.write_bytes(content) return tmp_path def _capture_run(runner, root: pathlib.Path, **arg_kw) -> str: """Run *runner* with stubbed args; return captured stdout.""" buf = io.StringIO() args = _ns(**arg_kw) with redirect_stdout(buf): runner(root, args) return buf.getvalue() # ============================================================================ # TestHotspotsRunner # ============================================================================ class TestHotspotsRunner: def test_runs_with_empty_vault(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(cli_hooks, "_walk_commits", lambda *_a, **_kw: []) out = _capture_run( cli_hooks.run_hotspots_for_vault, tmp_path, top=20, min_changes=1, as_json=True, ) payload = json.loads(out) assert payload["domain"] == "knowtation" assert payload["ranking"] == [] def test_text_mode_renders(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(cli_hooks, "_walk_commits", lambda *_a, **_kw: []) out = _capture_run( cli_hooks.run_hotspots_for_vault, tmp_path, top=20, min_changes=1, as_json=False, ) assert "Note churn" in out # ============================================================================ # TestGravityRunner # ============================================================================ class TestGravityRunner: def test_empty_vault_json(self, tmp_path: pathlib.Path) -> None: out = _capture_run( cli_hooks.run_gravity_for_vault, tmp_path, top=20, as_json=True, ) payload = json.loads(out) assert payload["domain"] == "knowtation" assert payload["ranking"] == [] def test_hub_vault(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": b"[[hub]]", "b.md": b"[[hub]]", "hub.md": b"# Hub", }) out = _capture_run( cli_hooks.run_gravity_for_vault, tmp_path, top=20, as_json=True, ) payload = json.loads(out) assert payload["ranking"][0]["path"] == "hub.md" # ============================================================================ # TestDeadRunner # ============================================================================ class TestDeadRunner: def test_isolated_notes_listed(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "alone.md": b"# Alone", }) out = _capture_run( cli_hooks.run_dead_for_vault, tmp_path, use_mtime=False, mtime_days=180, top=None, as_json=True, ) payload = json.loads(out) paths = {n["path"] for n in payload["dead_notes"]} assert "alone.md" in paths def test_top_limit_applied(self, tmp_path: pathlib.Path) -> None: for i in range(5): (tmp_path / f"n_{i}.md").write_bytes(b"# Note") out = _capture_run( cli_hooks.run_dead_for_vault, tmp_path, use_mtime=False, mtime_days=180, top=2, as_json=True, ) payload = json.loads(out) assert len(payload["dead_notes"]) == 2 # ============================================================================ # TestClonesRunner # ============================================================================ class TestClonesRunner: def test_clones_runs_with_stubbed_head( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: # Stub the commit/manifest path so we don't need a real repo. from muse.core import store monkeypatch.setattr(cli_hooks, "read_repo_id", lambda _r: "test_repo") monkeypatch.setattr(cli_hooks, "read_current_branch", lambda _r: "main") class _Fake: commit_id = "abc" monkeypatch.setattr( cli_hooks, "resolve_commit_ref", lambda _r, _id, _b, _ref: _Fake(), ) monkeypatch.setattr( cli_hooks, "get_commit_snapshot_manifest", lambda _r, _cid: {"a.md": "h", "b.md": "h", "c.md": "h2"}, ) out = _capture_run(cli_hooks.run_clones_for_vault, tmp_path, as_json=True) payload = json.loads(out) assert payload["total_clones"] == 2 # a.md and b.md share hash "h" # ============================================================================ # TestEntangleRunner # ============================================================================ class TestEntangleRunner: def test_empty_commits_safe(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(cli_hooks, "_walk_commits", lambda *_a, **_kw: []) out = _capture_run( cli_hooks.run_entangle_for_vault, tmp_path, top=20, min=2, as_json=True, ) payload = json.loads(out) assert payload["ranking"] == [] # ============================================================================ # TestVelocityRunner # ============================================================================ class TestVelocityRunner: def test_empty_commits_safe(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(cli_hooks, "_walk_commits", lambda *_a, **_kw: []) out = _capture_run( cli_hooks.run_velocity_for_vault, tmp_path, window=10, as_json=True, ) payload = json.loads(out) assert payload["window_size"] == 10 assert payload["acceleration"] == 0 # ============================================================================ # TestSnapshotLoaderFactory # ============================================================================ class TestSnapshotLoaderFactory: def test_loader_returns_empty_on_exception(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: def bad_loader(_r, _c): raise RuntimeError("simulated") monkeypatch.setattr(cli_hooks, "get_commit_snapshot_manifest", bad_loader) loader = cli_hooks._snapshot_loader(tmp_path) assert loader("anything") == {} # ============================================================================ # TestWalkCommitsResilience # ============================================================================ class TestWalkCommitsResilience: def test_walk_returns_empty_on_failure(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: # tmp_path is not a Muse repo → read_repo_id will raise. result = cli_hooks._walk_commits(tmp_path) assert result == []