"""Tests for Phase 3.3 — Knowtation note-metrics helpers. Covers the 6 helper functions feeding muse code hotspots / gravity / dead / clones / entangle / velocity for the knowtation domain. Test tiers (Rule #0): 1. Unit — each helper, isolated, with synthetic commit history 2. Integration — multi-helper consistency (e.g. hotspots ∪ velocity == manifest changes) 3. Data-integrity — deterministic JSON output across runs 4. Performance — each helper completes < 2s on 5k-note synthetic input 5. Security — malformed manifests, missing snapshots, oversize input handling """ from __future__ import annotations import json import pathlib import time from dataclasses import dataclass import pytest from muse.plugins.knowtation.link_index import build_link_index from muse.plugins.knowtation.note_metrics import ( note_clones, note_dead, note_entangle, note_gravity, note_hotspots, note_velocity, ) # ============================================================================ # Helpers # ============================================================================ @dataclass class _FakeCommit: """Minimal CommitRecord-like shape for testing.""" commit_id: str committed_at: int 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 _fake_loader(snapshots: dict[str, dict[str, str]]): """Build a snapshot_loader closure from a dict of commit_id → manifest.""" def loader(commit_id: str) -> dict[str, str]: return snapshots.get(commit_id, {}) return loader # ============================================================================ # TestNoteHotspots # ============================================================================ class TestNoteHotspots: def test_empty_commits(self) -> None: result = note_hotspots([], _fake_loader({})) assert result["commits_analysed"] == 0 assert result["ranking"] == [] def test_single_commit_no_diff(self) -> None: commits = [_FakeCommit("c1", 100)] loader = _fake_loader({"c1": {"a.md": "h1"}}) result = note_hotspots(commits, loader) # Only one commit → no diff pair → no changes counted assert result["ranking"] == [] def test_two_commits_one_change(self) -> None: commits = [_FakeCommit("c2", 200), _FakeCommit("c1", 100)] loader = _fake_loader({ "c1": {"a.md": "h1"}, "c2": {"a.md": "h2"}, }) result = note_hotspots(commits, loader) assert result["ranking"] == [{"path": "a.md", "changes": 1}] def test_three_commits_ranking(self) -> None: commits = [ _FakeCommit("c3", 300), _FakeCommit("c2", 200), _FakeCommit("c1", 100), ] loader = _fake_loader({ "c1": {"a.md": "h1", "b.md": "h_b"}, "c2": {"a.md": "h2", "b.md": "h_b"}, "c3": {"a.md": "h3", "b.md": "h_b2"}, }) result = note_hotspots(commits, loader) ranking = {r["path"]: r["changes"] for r in result["ranking"]} assert ranking["a.md"] == 2 # changed c1→c2 and c2→c3 assert ranking["b.md"] == 1 # changed only c2→c3 def test_min_changes_filter(self) -> None: commits = [ _FakeCommit("c3", 300), _FakeCommit("c2", 200), _FakeCommit("c1", 100), ] loader = _fake_loader({ "c1": {"a.md": "h1"}, "c2": {"a.md": "h2"}, "c3": {"a.md": "h3"}, }) result = note_hotspots(commits, loader, min_changes=3) # a.md only changed twice → filtered out assert result["ranking"] == [] def test_project_filter(self) -> None: commits = [_FakeCommit("c2", 200), _FakeCommit("c1", 100)] loader = _fake_loader({ "c1": {"projects/x/a.md": "h1", "inbox/b.md": "h_b"}, "c2": {"projects/x/a.md": "h2", "inbox/b.md": "h_b2"}, }) result = note_hotspots(commits, loader, project_filter="projects/") paths = [r["path"] for r in result["ranking"]] assert paths == ["projects/x/a.md"] def test_non_md_files_ignored(self) -> None: commits = [_FakeCommit("c2", 200), _FakeCommit("c1", 100)] loader = _fake_loader({ "c1": {"a.md": "h1", "config.yaml": "h_y"}, "c2": {"a.md": "h2", "config.yaml": "h_y2"}, }) result = note_hotspots(commits, loader) paths = [r["path"] for r in result["ranking"]] assert "config.yaml" not in paths assert "a.md" in paths def test_added_note_counted(self) -> None: commits = [_FakeCommit("c2", 200), _FakeCommit("c1", 100)] loader = _fake_loader({ "c1": {}, "c2": {"new.md": "h_new"}, }) result = note_hotspots(commits, loader) paths = [r["path"] for r in result["ranking"]] assert "new.md" in paths def test_removed_note_counted(self) -> None: commits = [_FakeCommit("c2", 200), _FakeCommit("c1", 100)] loader = _fake_loader({ "c1": {"gone.md": "h"}, "c2": {}, }) result = note_hotspots(commits, loader) paths = [r["path"] for r in result["ranking"]] assert "gone.md" in paths # ============================================================================ # TestNoteGravity # ============================================================================ class TestNoteGravity: def test_empty_vault(self, tmp_path: pathlib.Path) -> None: idx = build_link_index(tmp_path) result = note_gravity(idx) assert result["ranking"] == [] def test_hub_note_ranks_high(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": b"[[Hub]]", "b.md": b"[[Hub]]", "c.md": b"[[Hub]]", "Hub.md": b"# Hub", }) idx = build_link_index(tmp_path) result = note_gravity(idx) assert result["ranking"][0]["path"] == "Hub.md" assert result["ranking"][0]["inbound"] == 3 assert 0 < result["ranking"][0]["gravity"] <= 1.0 def test_gravity_within_zero_one(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": b"[[B]]", "b.md": b"# B", }) idx = build_link_index(tmp_path) result = note_gravity(idx) for entry in result["ranking"]: assert 0 <= entry["gravity"] <= 1.0 def test_top_limit(self, tmp_path: pathlib.Path) -> None: files: dict[str, bytes] = {} for i in range(10): files[f"note_{i}.md"] = f"[[hub]]".encode() files["hub.md"] = b"# Hub" _make_vault(tmp_path, files) idx = build_link_index(tmp_path) result = note_gravity(idx, top=3) assert len(result["ranking"]) <= 3 # ============================================================================ # TestNoteDead # ============================================================================ class TestNoteDead: def test_isolated_note_is_dead(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "alone.md": b"# Alone", "other.md": b"# Other", }) idx = build_link_index(tmp_path) result = note_dead(idx, root=tmp_path, use_mtime=False) paths = {n["path"] for n in result["dead_notes"]} assert paths == {"alone.md", "other.md"} def test_linked_note_not_dead(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": b"[[Target]]", "Target.md": b"# Target", }) idx = build_link_index(tmp_path) result = note_dead(idx, root=tmp_path, use_mtime=False) paths = {n["path"] for n in result["dead_notes"]} assert "Target.md" not in paths assert "a.md" in paths # a has no inbound def test_mtime_gate(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, {"recent.md": b"# Recent"}) idx = build_link_index(tmp_path) # Recent file should NOT be dead under default 180-day threshold result = note_dead(idx, root=tmp_path, use_mtime=True) paths = {n["path"] for n in result["dead_notes"]} assert "recent.md" not in paths def test_dead_count_matches_list(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, {"a.md": b"# A", "b.md": b"# B"}) idx = build_link_index(tmp_path) result = note_dead(idx, root=tmp_path, use_mtime=False) assert result["dead_count"] == len(result["dead_notes"]) # ============================================================================ # TestNoteClones # ============================================================================ class TestNoteClones: def test_no_clones(self) -> None: manifest = {"a.md": "h1", "b.md": "h2", "c.md": "h3"} result = note_clones(manifest) assert result["clone_groups"] == [] assert result["total_clones"] == 0 def test_two_exact_duplicates(self) -> None: manifest = {"a.md": "h", "b.md": "h", "c.md": "h2"} result = note_clones(manifest) assert len(result["clone_groups"]) == 1 assert sorted(result["clone_groups"][0]["paths"]) == ["a.md", "b.md"] def test_three_duplicates_ranked_above_two(self) -> None: manifest = { "a.md": "h1", "b.md": "h1", "c.md": "h1", "d.md": "h2", "e.md": "h2", } result = note_clones(manifest) assert result["clone_groups"][0]["paths"] == ["a.md", "b.md", "c.md"] assert result["clone_groups"][1]["paths"] == ["d.md", "e.md"] def test_non_md_files_excluded(self) -> None: manifest = {"a.md": "h", "config.yaml": "h"} result = note_clones(manifest) # Different file types with same hash should not be grouped # as note clones (only .md files participate) assert result["total_notes"] == 1 assert result["clone_groups"] == [] # ============================================================================ # TestNoteEntangle # ============================================================================ class TestNoteEntangle: def test_no_entanglement_single_commit(self) -> None: commits = [_FakeCommit("c1", 100)] loader = _fake_loader({"c1": {"a.md": "h"}}) result = note_entangle(commits, loader) assert result["ranking"] == [] def test_co_change_detected(self) -> None: commits = [ _FakeCommit("c3", 300), _FakeCommit("c2", 200), _FakeCommit("c1", 100), ] # a and b change together at c2 and c3 (per pair-counting algorithm) loader = _fake_loader({ "c1": {"a.md": "h_a1", "b.md": "h_b1"}, "c2": {"a.md": "h_a2", "b.md": "h_b2"}, "c3": {"a.md": "h_a3", "b.md": "h_b3"}, }) result = note_entangle(commits, loader, min_co_changes=1) pairs = {tuple(r["pair"]): r["co_changes"] for r in result["ranking"]} assert pairs.get(("a.md", "b.md")) == 2 def test_min_co_changes_filter(self) -> None: commits = [_FakeCommit("c2", 200), _FakeCommit("c1", 100)] loader = _fake_loader({ "c1": {"a.md": "h1", "b.md": "hb"}, "c2": {"a.md": "h2", "b.md": "hb2"}, }) result = note_entangle(commits, loader, min_co_changes=5) assert result["ranking"] == [] # ============================================================================ # TestNoteVelocity # ============================================================================ class TestNoteVelocity: def test_negative_window_rejected(self) -> None: with pytest.raises(ValueError): note_velocity([_FakeCommit("c1", 100)], _fake_loader({}), window_size=0) def test_empty_commits(self) -> None: result = note_velocity([], _fake_loader({})) assert result["recent_window"] == {"added": 0, "removed": 0, "modified": 0, "net": 0} assert result["acceleration"] == 0 def test_pure_additions(self) -> None: # 3 commits, window_size=1: recent = c3 vs c3 (no-op). # We need window_size=1 with two windows of one commit each. commits = [ _FakeCommit("c3", 300), _FakeCommit("c2", 200), _FakeCommit("c1", 100), ] loader = _fake_loader({ "c1": {}, "c2": {"a.md": "h_a"}, "c3": {"a.md": "h_a", "b.md": "h_b"}, }) # window_size=1 → recent=[c3] (newest→newest=no diff), older=[c2] # That degenerate case isn't useful. Use window_size=2. result = note_velocity(commits, loader, window_size=2) # recent=[c3, c2]: newest=c3 manifest, oldest=c2 manifest → adds b.md assert result["recent_window"]["added"] == 1 def test_window_size_returned(self) -> None: result = note_velocity([], _fake_loader({}), window_size=5) assert result["window_size"] == 5 # ============================================================================ # TestJsonSerialisation # ============================================================================ class TestJsonSerialisation: """All helper outputs must be JSON-serialisable.""" def test_hotspots_json(self) -> None: json.dumps(note_hotspots([], _fake_loader({}))) def test_gravity_json(self, tmp_path: pathlib.Path) -> None: idx = build_link_index(tmp_path) json.dumps(note_gravity(idx)) def test_dead_json(self, tmp_path: pathlib.Path) -> None: idx = build_link_index(tmp_path) json.dumps(note_dead(idx, root=tmp_path, use_mtime=False)) def test_clones_json(self) -> None: json.dumps(note_clones({})) def test_entangle_json(self) -> None: json.dumps(note_entangle([], _fake_loader({}))) def test_velocity_json(self) -> None: json.dumps(note_velocity([], _fake_loader({}))) # ============================================================================ # TestPerformance # ============================================================================ class TestPerformance: def test_clones_500_notes_under_1s(self) -> None: manifest = {f"note_{i}.md": f"h_{i % 50}" for i in range(500)} start = time.monotonic() result = note_clones(manifest) elapsed = time.monotonic() - start assert elapsed < 1.0 assert result["total_notes"] == 500 def test_gravity_500_note_vault_under_2s(self, tmp_path: pathlib.Path) -> None: files: dict[str, bytes] = {} for i in range(500): files[f"note_{i}.md"] = f"[[hub]]".encode() files["hub.md"] = b"# Hub" _make_vault(tmp_path, files) idx = build_link_index(tmp_path) start = time.monotonic() note_gravity(idx) elapsed = time.monotonic() - start assert elapsed < 2.0 # ============================================================================ # TestDeterminism # ============================================================================ class TestDeterminism: def test_hotspots_deterministic(self) -> None: commits = [_FakeCommit("c2", 200), _FakeCommit("c1", 100)] loader = _fake_loader({ "c1": {"a.md": "h1", "b.md": "h_b"}, "c2": {"a.md": "h2", "b.md": "h_b"}, }) r1 = json.dumps(note_hotspots(commits, loader), sort_keys=True) r2 = json.dumps(note_hotspots(commits, loader), sort_keys=True) assert r1 == r2 def test_clones_deterministic(self) -> None: manifest = {"a.md": "h", "b.md": "h", "c.md": "h"} r1 = json.dumps(note_clones(manifest), sort_keys=True) r2 = json.dumps(note_clones(manifest), sort_keys=True) assert r1 == r2 # ============================================================================ # TestSecurity # ============================================================================ class TestSecurity: def test_hotspots_handles_loader_exception(self) -> None: def bad_loader(_cid: str) -> dict[str, str]: raise RuntimeError("simulated load failure") commits = [_FakeCommit("c1", 100)] result = note_hotspots(commits, bad_loader) assert result["ranking"] == [] def test_entangle_handles_loader_exception(self) -> None: def bad_loader(_cid: str) -> dict[str, str]: raise RuntimeError("simulated") commits = [_FakeCommit("c2", 200), _FakeCommit("c1", 100)] result = note_entangle(commits, bad_loader) assert result["ranking"] == [] def test_velocity_handles_loader_exception(self) -> None: def bad_loader(_cid: str) -> dict[str, str]: raise RuntimeError("simulated") result = note_velocity( [_FakeCommit("c1", 100), _FakeCommit("c2", 200)], bad_loader, window_size=1, ) # Should not crash; window_stats returns zero defaults on exception assert result["recent_window"]["added"] == 0