"""Tests for Phase 3.2 — Knowtation deps / impact CLI integration. Test tiers covered (Rule #0): 1. Unit — describe_link round-trip 2. Unit — note_deps forward + reverse, broken-link filtering, kind filtering 3. Unit — note_impact: BFS depth, forward vs reverse, attachment flag echo, negative depth rejection 4. Integration — deps_for_note / impact_for_note on synthetic vault 5. Integration — golden JSON output for a 50-note fixture vault (matches issue spec) 6. Data-integrity — round-trip: every link emitted by note_deps appears in note_impact at depth 1 7. Performance — note_impact across 200-note linear chain completes < 2s 8. Security — non-note path rejected, missing file rejected, path traversal """ from __future__ import annotations import json import pathlib import time import pytest from muse.plugins.knowtation.code_intel import ( deps_for_note, describe_link, impact_for_note, note_deps, note_impact, ) from muse.plugins.knowtation.link_index import ( LinkIndex, ResolvedLink, build_link_index, ) # ============================================================================ # Helpers # ============================================================================ def _make_vault(tmp_path: pathlib.Path, files: dict[str, bytes | str]) -> pathlib.Path: for rel, content in files.items(): full = tmp_path / rel full.parent.mkdir(parents=True, exist_ok=True) if isinstance(content, str): full.write_text(content, encoding="utf-8") else: full.write_bytes(content) return tmp_path # ============================================================================ # TestDescribeLink # ============================================================================ class TestDescribeLink: def test_describe_link_round_trip(self) -> None: link = ResolvedLink( source="a.md", target="b.md", raw="b", kind="wikilink", fragment="section", broken=False, alias="display", ) out = describe_link(link) assert out["source"] == "a.md" assert out["target"] == "b.md" assert out["raw"] == "b" assert out["kind"] == "wikilink" assert out["fragment"] == "section" assert out["broken"] is False assert out["alias"] == "display" def test_describe_broken_link(self) -> None: link = ResolvedLink( source="a.md", target=None, raw="missing", kind="wikilink", broken=True ) out = describe_link(link) assert out["target"] is None assert out["broken"] is True def test_describe_link_serialises_to_json(self) -> None: link = ResolvedLink(source="a.md", target="b.md", raw="b", kind="md_link") json.dumps(describe_link(link)) # ============================================================================ # TestNoteDeps # ============================================================================ class TestNoteDeps: def _basic_index(self, tmp_path: pathlib.Path) -> LinkIndex: _make_vault(tmp_path, { "a.md": b"[[B]] and [C](c.md)", "b.md": b"# B", "c.md": b"# C", }) return build_link_index(tmp_path) def test_outgoing_default(self, tmp_path: pathlib.Path) -> None: idx = self._basic_index(tmp_path) result = note_deps(idx, "a.md") assert result["direction"] == "outgoing" assert result["count"] == 2 def test_incoming_reverse(self, tmp_path: pathlib.Path) -> None: idx = self._basic_index(tmp_path) result = note_deps(idx, "b.md", reverse=True) assert result["direction"] == "incoming" assert result["count"] == 1 assert result["links"][0]["source"] == "a.md" def test_kinds_filter(self, tmp_path: pathlib.Path) -> None: idx = self._basic_index(tmp_path) result = note_deps(idx, "a.md", kinds=("wikilink",)) assert result["count"] == 1 assert result["links"][0]["kind"] == "wikilink" def test_broken_excluded_by_default(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, {"a.md": b"[[Missing]]"}) idx = build_link_index(tmp_path) result = note_deps(idx, "a.md") assert result["count"] == 0 def test_broken_included_when_opted_in(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, {"a.md": b"[[Missing]]"}) idx = build_link_index(tmp_path) result = note_deps(idx, "a.md", include_broken=True) assert result["count"] == 1 def test_missing_note_returns_empty(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, {"a.md": b"# A"}) idx = build_link_index(tmp_path) result = note_deps(idx, "does-not-exist.md") assert result["count"] == 0 assert result["links"] == [] def test_result_is_json_serialisable(self, tmp_path: pathlib.Path) -> None: idx = self._basic_index(tmp_path) result = note_deps(idx, "a.md") json.dumps(result) # ============================================================================ # TestNoteImpact # ============================================================================ class TestNoteImpact: def _chain_vault(self, tmp_path: pathlib.Path) -> LinkIndex: """Build a 5-note linear chain: a → b → c → d → e.""" _make_vault(tmp_path, { "a.md": b"[[B]]", "b.md": b"[[C]]", "c.md": b"[[D]]", "d.md": b"[[E]]", "e.md": b"# Terminal", }) return build_link_index(tmp_path) def test_reverse_bfs_unlimited(self, tmp_path: pathlib.Path) -> None: idx = self._chain_vault(tmp_path) result = note_impact(idx, "e.md") assert result["direction"] == "reverse" assert result["total"] == 4 # a, b, c, d def test_reverse_depth_capped(self, tmp_path: pathlib.Path) -> None: idx = self._chain_vault(tmp_path) result = note_impact(idx, "e.md", depth=2) # Depth 1 = d, depth 2 = c → total 2 assert result["total"] == 2 assert "1" in result["by_depth"] assert "2" in result["by_depth"] assert "3" not in result["by_depth"] def test_forward_bfs(self, tmp_path: pathlib.Path) -> None: idx = self._chain_vault(tmp_path) result = note_impact(idx, "a.md", forward=True) assert result["direction"] == "forward" assert result["total"] == 4 # b, c, d, e def test_negative_depth_rejected(self, tmp_path: pathlib.Path) -> None: idx = build_link_index(_make_vault(tmp_path, {"a.md": b"# A"})) with pytest.raises(ValueError): note_impact(idx, "a.md", depth=-1) def test_seed_not_in_result(self, tmp_path: pathlib.Path) -> None: idx = self._chain_vault(tmp_path) result = note_impact(idx, "c.md") all_notes = [n for notes in result["by_depth"].values() for n in notes] assert "c.md" not in all_notes def test_include_attachments_flag_echoed(self, tmp_path: pathlib.Path) -> None: idx = self._chain_vault(tmp_path) result = note_impact(idx, "e.md", include_attachments=False) assert result["include_attachments"] is False def test_disconnected_note_has_empty_blast(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_impact(idx, "alone.md") assert result["total"] == 0 assert result["by_depth"] == {} def test_result_is_json_serialisable(self, tmp_path: pathlib.Path) -> None: idx = self._chain_vault(tmp_path) result = note_impact(idx, "e.md") json.dumps(result) def test_no_infinite_loop_on_cycle(self, tmp_path: pathlib.Path) -> None: """A → B → A cycle must terminate.""" _make_vault(tmp_path, { "a.md": b"[[B]]", "b.md": b"[[A]]", }) idx = build_link_index(tmp_path) # Both forward and reverse must terminate result_f = note_impact(idx, "a.md", forward=True) result_r = note_impact(idx, "a.md") assert result_f["total"] == 1 # just b assert result_r["total"] == 1 # just b # ============================================================================ # TestConvenienceFunctions # ============================================================================ class TestConvenienceFunctions: def test_deps_for_note(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": b"[[B]]", "b.md": b"# B", }) result = deps_for_note(tmp_path, "a.md") assert result["count"] == 1 assert result["links"][0]["target"] == "b.md" def test_impact_for_note(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": b"[[B]]", "b.md": b"# B", }) result = impact_for_note(tmp_path, "b.md") assert result["total"] == 1 # ============================================================================ # TestGolden50NoteFixture (integration) # ============================================================================ class TestGolden50NoteFixture: """Build a 50-note synthetic vault and verify deterministic JSON output.""" def _make_50_note_vault(self, tmp_path: pathlib.Path) -> pathlib.Path: files: dict[str, bytes | str] = {} # Create 50 notes; note_i links to note_{i+1} and note_0 links to all for i in range(50): links = [] if i < 49: links.append(f"[[note_{i + 1:02d}]]") if i == 0: # Hub note — links to 5 others for j in (10, 20, 30, 40, 49): links.append(f"[[note_{j:02d}]]") files[f"note_{i:02d}.md"] = ( f"# Note {i}\n\nContent.\n\n" + " ".join(links) ).encode() return _make_vault(tmp_path, files) def test_hub_note_has_correct_outgoing_count(self, tmp_path: pathlib.Path) -> None: self._make_50_note_vault(tmp_path) result = deps_for_note(tmp_path, "note_00.md") # 5 hub links + 1 sequential link = 6 assert result["count"] == 6 def test_last_note_has_one_incoming(self, tmp_path: pathlib.Path) -> None: self._make_50_note_vault(tmp_path) result = deps_for_note(tmp_path, "note_49.md", reverse=True) # Linked by note_48 (sequential) and note_00 (hub) → 2 assert result["count"] == 2 def test_blast_radius_from_terminal(self, tmp_path: pathlib.Path) -> None: self._make_50_note_vault(tmp_path) result = impact_for_note(tmp_path, "note_49.md") # Backwards chain: 48 → 47 → … → 1 → 0 (49 notes total) # But 0 is a hub note too, so it's reached at depth 2 (via 48) # or directly at depth 1 if it links to 49 (yes, hub link). # All notes except note_49 itself appear → 49 total assert result["total"] == 49 def test_blast_radius_depth_capped(self, tmp_path: pathlib.Path) -> None: self._make_50_note_vault(tmp_path) result = impact_for_note(tmp_path, "note_49.md", depth=1) # Direct callers: note_48 (sequential) and note_00 (hub) → 2 assert result["total"] == 2 def test_json_output_is_stable(self, tmp_path: pathlib.Path) -> None: self._make_50_note_vault(tmp_path) r1 = json.dumps(deps_for_note(tmp_path, "note_25.md"), sort_keys=True) r2 = json.dumps(deps_for_note(tmp_path, "note_25.md"), sort_keys=True) assert r1 == r2 # ============================================================================ # TestDataIntegrity # ============================================================================ class TestDataIntegrity: def test_outgoing_at_depth_1_equals_deps_targets(self, tmp_path: pathlib.Path) -> None: """note_impact depth-1 forward == set of note_deps outgoing targets.""" _make_vault(tmp_path, { "a.md": b"[[B]] [[C]] [[D]]", "b.md": b"# B", "c.md": b"# C", "d.md": b"# D", }) idx = build_link_index(tmp_path) deps_result = note_deps(idx, "a.md") deps_targets = {link["target"] for link in deps_result["links"]} impact_result = note_impact(idx, "a.md", forward=True, depth=1) depth_1 = set(impact_result["by_depth"].get("1", [])) assert depth_1 == deps_targets def test_incoming_at_depth_1_equals_reverse_deps_sources(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": b"[[Target]]", "b.md": b"[[Target]]", "Target.md": b"# Target", }) idx = build_link_index(tmp_path) reverse_deps = note_deps(idx, "Target.md", reverse=True) sources = {link["source"] for link in reverse_deps["links"]} impact = note_impact(idx, "Target.md", depth=1) depth_1 = set(impact["by_depth"].get("1", [])) assert depth_1 == sources # ============================================================================ # TestPerformance # ============================================================================ class TestPerformance: def test_impact_on_200_note_chain_under_2s(self, tmp_path: pathlib.Path) -> None: files: dict[str, bytes] = {} for i in range(200): if i < 199: files[f"n_{i:03d}.md"] = f"[[n_{i + 1:03d}]]".encode() else: files[f"n_{i:03d}.md"] = b"# Terminal" _make_vault(tmp_path, files) idx = build_link_index(tmp_path) start = time.monotonic() result = note_impact(idx, "n_199.md") elapsed = time.monotonic() - start assert result["total"] == 199 assert elapsed < 2.0, f"Impact on 200-note chain took {elapsed:.2f}s" # ============================================================================ # TestSecurity # ============================================================================ class TestSecurity: def test_deps_for_note_path_traversal_handled(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, {"a.md": b"# A"}) # Non-existent path returns empty result — does not crash result = deps_for_note(tmp_path, "../../etc/passwd.md") assert result["count"] == 0 def test_impact_for_nonexistent_note_returns_empty(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, {"a.md": b"# A"}) result = impact_for_note(tmp_path, "nope.md") assert result["total"] == 0 def test_deps_handles_circular_references(self, tmp_path: pathlib.Path) -> None: _make_vault(tmp_path, { "a.md": b"[[A]]", # self-link }) idx = build_link_index(tmp_path) result = note_deps(idx, "a.md") # Self-link counts as one outgoing edge to itself assert result["count"] == 1