182 lines
7.0 KiB
Python
182 lines
7.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Smoke-test active plan, kanban, and plan lifecycle MCP helpers."""
|
|
|
|
import importlib.util
|
|
import pathlib
|
|
import sys
|
|
|
|
|
|
def load_module():
|
|
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
|
|
sys.path.insert(0, str(script_path.parent))
|
|
spec = importlib.util.spec_from_file_location("napseer_mcp_server_ergonomics_test", script_path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
assert spec and spec.loader
|
|
spec.loader.exec_module(module)
|
|
module.AUTH = {"account_id": "acct-1", "token": "token", "token_expires_at": "later"}
|
|
module.DEFAULT_PROJECT_ID = "project-1"
|
|
module.project_memory_encryption_active = lambda project_id: False
|
|
return module
|
|
|
|
|
|
def node(path, status="planned", metadata=None, tags=None, content="body text", archived_at=None, node_type="plan"):
|
|
folder, name = path.rsplit("/", 1)
|
|
merged_metadata = {"status": status}
|
|
if metadata:
|
|
merged_metadata.update(metadata)
|
|
return {
|
|
"id": f"id-{name}",
|
|
"project_id": "project-1",
|
|
"full_path": path,
|
|
"folder_path": folder,
|
|
"name": name,
|
|
"type": node_type,
|
|
"tags": tags or [],
|
|
"links": [],
|
|
"metadata": merged_metadata,
|
|
"content_text": content,
|
|
"updated_at": "2026-05-06T12:00:00Z",
|
|
"archived_at": archived_at,
|
|
"encryption_state": "plaintext",
|
|
}
|
|
|
|
|
|
def test_active_plan_listing(mod):
|
|
active = node("/plans/active", "in_progress", tags=["plan"])
|
|
completed = node("/plans/completed", "completed", tags=["plan", "completed"])
|
|
archived = node("/plans/archived", "planned", tags=["plan"], archived_at="2026-05-06T12:01:00Z")
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
assert method == "GET"
|
|
assert "/nodes?" in path
|
|
assert "folder_path=%2Fplans" in path
|
|
return {"items": [active, completed, archived], "next_cursor": None}
|
|
|
|
mod.request_json = fake_request
|
|
result = mod.list_active_plans({})
|
|
assert result["ok"] is True
|
|
assert result["view"] == "summary"
|
|
assert [item["full_path"] for item in result["items"]] == ["/plans/active"]
|
|
assert "content_text" not in result["items"][0]
|
|
assert result["items"][0]["status"] == "in_progress"
|
|
|
|
|
|
def test_kanban_list_grouping_and_filters(mod):
|
|
doing = node(
|
|
"/kanban/doing/fix-wrapper",
|
|
"doing",
|
|
metadata={"column": "doing", "priority": "high", "owner": "codex", "blocked": True},
|
|
tags=["kanban"],
|
|
node_type="kanban_card",
|
|
)
|
|
todo = node(
|
|
"/kanban/todo/add-tests",
|
|
"todo",
|
|
metadata={"column": "todo", "priority": "normal", "owner": "codex", "blocked": False},
|
|
tags=["kanban"],
|
|
node_type="kanban_card",
|
|
)
|
|
done = node(
|
|
"/kanban/done/old-card",
|
|
"done",
|
|
metadata={"column": "done", "owner": "codex"},
|
|
tags=["kanban"],
|
|
node_type="kanban_card",
|
|
)
|
|
outside = node("/plans/not-kanban", "planned")
|
|
|
|
def fake_request(method, path, payload=None, **kwargs):
|
|
assert method == "GET"
|
|
assert "q=" in path
|
|
return {"items": [doing, todo, done, outside], "next_cursor": None}
|
|
|
|
mod.request_json = fake_request
|
|
result = mod.list_kanban_cards({"owner": "codex", "blocked": True, "group_by": "column"})
|
|
assert result["ok"] is True
|
|
assert result["kanban_root"] == "/kanban"
|
|
assert [item["full_path"] for item in result["items"]] == ["/kanban/doing/fix-wrapper"]
|
|
assert "content_text" not in result["items"][0]
|
|
assert result["items"][0]["column"] == "doing"
|
|
assert result["items"][0]["blocked"] is True
|
|
assert list(result["groups"]) == ["doing"]
|
|
|
|
|
|
def test_plan_lifecycle_dry_run_and_write(mod):
|
|
plan = node("/plans/example", "in_progress", tags=["plan"])
|
|
outcome = node("/implementation-notes/example", "completed", node_type="implementation-note")
|
|
writes = []
|
|
|
|
def fake_get(args, allow_agent=False):
|
|
if args["path"] == "/plans/example":
|
|
return plan
|
|
if args["path"] == "/implementation-notes/example":
|
|
return outcome
|
|
raise RuntimeError("HTTP 404")
|
|
|
|
mod.get_node_by_path = fake_get
|
|
mod.try_get_node_by_path = lambda path, allow_agent=False: outcome if path == "/implementation-notes/example" else None
|
|
mod.update_node_by_path = lambda args: writes.append(("update", args)) or {"node": {**plan, **args}}
|
|
mod.archive_node_by_path = lambda args: writes.append(("archive", args)) or {"archived": True}
|
|
|
|
dry = mod.complete_plan({"path": "/plans/example", "outcome_path": "/implementation-notes/example", "dry_run": True})
|
|
assert dry["changed"] is True
|
|
assert dry["dry_run"] is True
|
|
assert dry["status"] == "completed"
|
|
assert "archive node" in dry["planned_changes"]
|
|
assert writes == []
|
|
|
|
written = mod.complete_plan({"path": "/plans/example", "outcome_path": "/implementation-notes/example", "reason": "implemented"})
|
|
assert written["changed"] is True
|
|
assert written["archived"] is True
|
|
assert writes[0][0] == "update"
|
|
assert writes[0][1]["metadata"]["status"] == "completed"
|
|
assert writes[0][1]["metadata"]["outcome_path"] == "/implementation-notes/example"
|
|
assert "completed" in writes[0][1]["tags"]
|
|
assert "archived" in writes[0][1]["tags"]
|
|
assert writes[0][1]["links"] == [{"path": "/implementation-notes/example", "relation": "implemented-by"}]
|
|
assert writes[1][0] == "archive"
|
|
|
|
|
|
def test_plan_lifecycle_archived_idempotent(mod):
|
|
archived = node(
|
|
"/plans/example",
|
|
"completed",
|
|
metadata={"outcome_path": "/implementation-notes/example"},
|
|
tags=["plan", "completed", "archived"],
|
|
archived_at="2026-05-06T12:01:00Z",
|
|
)
|
|
archived["links"] = [{"path": "/implementation-notes/example", "relation": "implemented-by"}]
|
|
|
|
mod.get_node_by_path = lambda args, allow_agent=False: (_ for _ in ()).throw(RuntimeError("HTTP 404"))
|
|
mod.list_project_nodes = lambda args: {"items": [archived]}
|
|
result = mod.complete_plan({"path": "/plans/example", "outcome_path": "/implementation-notes/example"})
|
|
assert result["ok"] is True
|
|
assert result["changed"] is False
|
|
assert result["archived"] is True
|
|
|
|
|
|
def test_tool_registry(mod):
|
|
names = {tool["name"] for tool in mod.raw_tools()}
|
|
for name in ["nap_plan_list_active", "nap_kanban_list", "nap_plan_complete", "nap_plan_supersede", "nap_plan_cancel"]:
|
|
assert name in names
|
|
assert mod.tool_contract_metadata("nap_plan_complete")["dry_run_supported"] is True
|
|
assert mod.tool_contract_metadata("nap_plan_list_active")["side_effect"] == "none"
|
|
|
|
|
|
def run():
|
|
mod = load_module()
|
|
test_active_plan_listing(mod)
|
|
mod = load_module()
|
|
test_kanban_list_grouping_and_filters(mod)
|
|
mod = load_module()
|
|
test_plan_lifecycle_dry_run_and_write(mod)
|
|
mod = load_module()
|
|
test_plan_lifecycle_archived_idempotent(mod)
|
|
mod = load_module()
|
|
test_tool_registry(mod)
|
|
print("ok: MCP memory ergonomics helpers smoke passed")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
run()
|