Scripts become thin CLIs over kb_framework (graph, query, query_tools, vault, instructions) so the hosted read-only connector shares one implementation with the stdio server. The MCP server builds the graph from Markdown on demand and invalidates it on writes; refresh_graph rebuilds in memory and reports validation problems. Pre-commit hook validates only; index/graph.json is no longer read, written or staged (generate-graph.py --write remains for operators). Golden fixtures pin graph and tool payloads to the previous output. Agent and skill docs updated to match. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
163 lines
6.6 KiB
Python
163 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Tests for the importable `kb_framework` package.
|
|
|
|
The package is the contract both the stdio MCP server and the hosted connector
|
|
consume: build the graph in memory from a vault's Markdown, answer the query
|
|
tools over that graph, and read framework instructions. These tests pin the
|
|
graph shape against a golden fixture produced by the file-writing generator,
|
|
and the tool payloads against goldens captured from the stdio server, so the
|
|
two front ends cannot drift apart.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
FRAMEWORK = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(FRAMEWORK))
|
|
FIXTURES = FRAMEWORK / "tests" / "fixtures"
|
|
VAULT = FIXTURES / "vault"
|
|
|
|
from kb_framework import graph as kbg # noqa: E402
|
|
from kb_framework import query as kbq # noqa: E402
|
|
from kb_framework import query_tools as kbt # noqa: E402
|
|
from kb_framework import vault as kbv # noqa: E402
|
|
from kb_framework import instructions as kbi # noqa: E402
|
|
|
|
|
|
def golden_graph() -> dict:
|
|
return json.loads((FIXTURES / "graph_golden.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
def golden_stdio() -> dict:
|
|
return json.loads((FIXTURES / "stdio_golden.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
class TestBuildGraphInMemory(unittest.TestCase):
|
|
def test_matches_golden_without_touching_disk(self) -> None:
|
|
result = kbg.build_graph(VAULT)
|
|
self.assertEqual(result.graph["nodes"], golden_graph()["nodes"])
|
|
self.assertEqual(result.graph["edges"], golden_graph()["edges"])
|
|
self.assertEqual(result.graph["version"], 1)
|
|
self.assertTrue(result.graph["generated_at"])
|
|
self.assertFalse((VAULT / "index").exists())
|
|
|
|
def test_reports_no_problems_for_clean_fixture(self) -> None:
|
|
result = kbg.build_graph(VAULT)
|
|
self.assertEqual(result.unresolved, [])
|
|
self.assertEqual(result.wiki_uses, [])
|
|
self.assertEqual(kbg.validation_problems(result), [])
|
|
|
|
def test_validation_reports_unresolved_and_bad_types(self) -> None:
|
|
import tempfile
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
(root / "a.md").write_text(
|
|
"---\nid: a\ntype: descision\n---\n[x](missing){\"type\":\"bogus\"}\n[[legacy]]\n",
|
|
encoding="utf-8",
|
|
)
|
|
result = kbg.build_graph(root)
|
|
problems = "\n".join(kbg.validation_problems(result))
|
|
self.assertIn("missing", problems)
|
|
self.assertIn("descision", problems)
|
|
self.assertIn("legacy", problems)
|
|
self.assertFalse(result.ok)
|
|
|
|
|
|
class TestQueryFunctions(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.graph = golden_graph()
|
|
self.stdio = golden_stdio()
|
|
|
|
def strip(self, payload: dict) -> dict:
|
|
payload = dict(payload)
|
|
payload.pop("generated_at", None)
|
|
return payload
|
|
|
|
def test_summary_matches_stdio_golden(self) -> None:
|
|
case = self.stdio["graph_summary"]
|
|
payload = kbq.summary(self.graph, **case["args"])
|
|
self.assertEqual(self.strip(payload), case["structuredContent"])
|
|
|
|
def test_search_matches_stdio_golden(self) -> None:
|
|
case = self.stdio["graph_search_nodes"]
|
|
self.assertEqual(kbq.search_nodes(self.graph, case["args"]), case["structuredContent"])
|
|
|
|
def test_neighbors_matches_stdio_golden(self) -> None:
|
|
case = self.stdio["graph_neighbors"]
|
|
self.assertEqual(kbq.neighbors(self.graph, **case["args"]), case["structuredContent"])
|
|
|
|
def test_neighbors_unknown_id_raises(self) -> None:
|
|
with self.assertRaises(ValueError):
|
|
kbq.neighbors(self.graph, "nope")
|
|
|
|
def test_node_path_resolves_id(self) -> None:
|
|
self.assertEqual(kbq.node_path(self.graph, "area-ops"), "02_areas/ops.md")
|
|
with self.assertRaises(ValueError):
|
|
kbq.node_path(self.graph, "nope")
|
|
|
|
|
|
class TestVaultHelpers(unittest.TestCase):
|
|
def test_resolve_in_root_rejects_escape_and_absolute(self) -> None:
|
|
for bad in ("../x.md", "/etc/passwd", "", " "):
|
|
with self.subTest(bad=bad), self.assertRaises(ValueError):
|
|
kbv.resolve_in_root(VAULT, bad)
|
|
self.assertEqual(kbv.resolve_in_root(VAULT, "README.md"), VAULT / "README.md")
|
|
|
|
def test_read_note_payload_text_and_binary(self) -> None:
|
|
text = kbv.read_note_payload(VAULT, "02_areas/ops.md")
|
|
self.assertEqual(text["binary"], False)
|
|
self.assertIn("# Area Ops", text["content"])
|
|
binary = kbv.read_note_payload(VAULT, "03_resources/demo/raw/paper.pdf")
|
|
self.assertTrue(binary["binary"])
|
|
self.assertEqual(set(binary), {"path", "binary", "bytes", "sha256", "hint"})
|
|
with self.assertRaises(FileNotFoundError):
|
|
kbv.read_note_payload(VAULT, "nope.md")
|
|
|
|
|
|
class TestQueryTools(unittest.TestCase):
|
|
"""The five read-only tools, as the stdio server and the connector expose them."""
|
|
|
|
def setUp(self) -> None:
|
|
self.graph = golden_graph()
|
|
self.stdio = golden_stdio()
|
|
|
|
def test_definitions_match_stdio(self) -> None:
|
|
self.assertEqual(kbt.definitions(), self.stdio["tool_definitions"])
|
|
|
|
def test_call_returns_stdio_result_shape(self) -> None:
|
|
for key in ("graph_summary", "graph_search_nodes", "graph_neighbors", "read_note_by_id", "read_note_by_path"):
|
|
case = self.stdio[key]
|
|
with self.subTest(tool=key):
|
|
result = kbt.call(case["tool"], case["args"], graph=self.graph, root=VAULT)
|
|
sc = dict(result["structuredContent"])
|
|
sc.pop("generated_at", None)
|
|
self.assertEqual(sc, case["structuredContent"])
|
|
self.assertEqual(result["content"][0]["type"], "text")
|
|
self.assertEqual(json.loads(result["content"][0]["text"]).get("path", sc.get("path")), sc.get("path"))
|
|
|
|
def test_read_note_requires_exactly_one_selector(self) -> None:
|
|
for args in ({}, {"id": "area-ops", "path": "README.md"}):
|
|
with self.subTest(args=args), self.assertRaises(ValueError):
|
|
kbt.call("read_note", args, graph=self.graph, root=VAULT)
|
|
|
|
def test_unknown_tool_raises(self) -> None:
|
|
with self.assertRaises(ValueError):
|
|
kbt.call("write_note", {}, graph=self.graph, root=VAULT)
|
|
|
|
|
|
class TestInstructions(unittest.TestCase):
|
|
def test_reads_framework_documents(self) -> None:
|
|
payload = kbi.read_instructions()
|
|
self.assertEqual([d["path"] for d in payload["documents"]], ["AGENTS.md", "AGENT_QUERY_GUIDE.md"])
|
|
self.assertIn("AGENT_QUERY_GUIDE", payload["documents"][0]["content"])
|
|
self.assertTrue(Path(payload["framework_root"]).is_dir())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|