Files
KiC-AI/test_aux_mcp_smoke.py
2026-03-08 03:17:21 +01:00

260 lines
10 KiB
Python

#!/usr/bin/env python3
"""
Smoke test for the auxiliary KIC-AI MCP servers.
This validates the line-delimited JSON-RPC transport and a minimal set of
real tool calls against deterministic local fixtures.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional
ROOT = Path(__file__).resolve().parent
CACHE_ROOT = Path.home() / "Kill_LIFE" / ".cad-home" / "kicad-mcp" / "kicad-v10-libs"
SCHEMATIC_FIXTURE = """\
(kicad_sch (version 20250114) (generator "aux-mcp-smoke")
(uuid 11111111-1111-1111-1111-111111111111)
(paper "A4")
(symbol (lib_id "Device:R") (at 10 10 0)
(property "Reference" "R1" (at 10 12 0))
(property "Value" "10k" (at 10 8 0))
(property "Footprint" "Resistor_SMD:R_0805_2012Metric" (at 10 6 0))
(property "Datasheet" "~" (at 10 4 0))
)
(symbol (lib_id "Device:C") (at 20 10 0)
(property "Reference" "C1" (at 20 12 0))
(property "Value" "100nF" (at 20 8 0))
(property "Footprint" "Capacitor_SMD:C_0805_2012Metric" (at 20 6 0))
(property "Datasheet" "~" (at 20 4 0))
)
(symbol (lib_id "MCU_Microchip_ATmega:ATmega328P-AU") (at 30 10 0)
(property "Reference" "U1" (at 30 12 0))
(property "Value" "ATmega328P-AU" (at 30 8 0))
(property "Footprint" "Package_QFP:TQFP-32_7x7mm_P0.8mm" (at 30 6 0))
(property "Manufacturer" "Microchip" (at 30 4 0))
(property "Part" "ATMEGA328P-AU" (at 30 2 0))
)
(symbol (lib_id "Connector_Generic:Conn_01x02") (at 40 10 0)
(property "Reference" "J1" (at 40 12 0))
(property "Value" "Conn_01x02" (at 40 8 0))
(property "Footprint" "" (at 40 6 0))
)
(global_label "VCC" (shape input) (at 5 5 0) (fields_autoplaced))
(label "SCL" (at 15 5 0) (fields_autoplaced))
(wire (pts (xy 5 5) (xy 15 5)) (stroke (width 0) (type default)) (uuid abcdefab-cdef-abcd-efab-cdefabcdefab))
(wire (pts (xy 15 5) (xy 25 5)) (stroke (width 0) (type default)) (uuid bcdefabc-defa-bcde-fabc-defabcdefabc))
)
"""
PCB_FIXTURE = """\
(kicad_pcb
(version 20260206)
(generator "pcbnew")
(title_block (title "aux-mcp-smoke"))
(layers
(0 "F.Cu" signal)
(2 "B.Cu" signal)
(25 "Edge.Cuts" user)
)
(gr_rect (start 0 0) (end 50 40) (stroke (width 0.1) (type default)) (fill none) (layer "Edge.Cuts"))
(footprint "Resistor_SMD:R_0805_2012Metric" (layer "F.Cu") (tstamp 1))
(footprint "Package_QFP:TQFP-32_7x7mm_P0.8mm" (layer "F.Cu") (tstamp 2))
(segment (start 5 5) (end 10 5) (width 0.25) (layer "F.Cu") (net 1) (tstamp 3))
(zone (net 0) (net_name "") (layer "F.Cu") (tstamp 4) (name "") (hatch edge 0.5)
(connect_pads yes (clearance 0.5))
(min_thickness 0.25)
(filled_areas_thickness no)
(fill yes)
(polygon (pts (xy 0 0) (xy 10 0) (xy 10 10) (xy 0 10)))
)
)
"""
class MCPLineClient:
def __init__(self, command: List[str], *, cwd: Optional[Path] = None, env: Optional[Dict[str, str]] = None):
process_env = os.environ.copy()
if env:
process_env.update(env)
self.process = subprocess.Popen(
command,
cwd=cwd or ROOT,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
env=process_env,
)
self._request_id = 1
def close(self) -> None:
if self.process.poll() is None:
self.process.terminate()
try:
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.process.kill()
def request(self, method: str, params: Dict[str, Any]) -> Dict[str, Any]:
request = {"jsonrpc": "2.0", "id": self._request_id, "method": method, "params": params}
self._request_id += 1
assert self.process.stdin is not None
assert self.process.stdout is not None
self.process.stdin.write(json.dumps(request) + "\n")
self.process.stdin.flush()
response = self.process.stdout.readline()
if not response:
stderr = ""
if self.process.stderr is not None:
stderr = self.process.stderr.read()
raise RuntimeError(f"No response for {method}; stderr={stderr.strip()}")
payload = json.loads(response)
if "error" in payload:
raise RuntimeError(f"{method} failed: {payload['error']}")
return payload["result"]
def assert_true(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def create_fixture_project() -> Path:
tmpdir = Path(tempfile.mkdtemp(prefix="aux-mcp-smoke-"))
(tmpdir / "aux_test.kicad_sch").write_text(SCHEMATIC_FIXTURE, encoding="utf-8")
(tmpdir / "aux_test.kicad_pcb").write_text(PCB_FIXTURE, encoding="utf-8")
return tmpdir
def run_component_db_checks(project_dir: Path) -> Dict[str, Any]:
client = MCPLineClient(
["python3", "-m", "mcp_servers.component_db"],
env={"KICAD_AUX_MCP_ROOTS": str(project_dir)},
)
try:
init = client.request("initialize", {})
tools = client.request("tools/list", {})
search = client.request(
"tools/call",
{"name": "search_components", "arguments": {"type": "microcontroller", "limit": 5}},
)
pricing = client.request(
"tools/call",
{"name": "get_pricing", "arguments": {"part_numbers": ["Device:R", "Device:C"]}},
)
assert_true(init["serverInfo"]["name"] == "component-database", "component_db initialize failed")
assert_true(len(tools["tools"]) == 3, "component_db tools/list mismatch")
assert_true(search["total_found"] >= 1, "component_db search returned no results")
assert_true("pricing" in pricing, "component_db pricing payload missing")
assert_true((CACHE_ROOT / ".symbol_index.sqlite3").exists(), "symbol sqlite index was not created")
return {"catalog_size": search["catalog_size"], "search_results": search["total_found"]}
finally:
client.close()
def run_nexar_checks() -> Dict[str, Any]:
client = MCPLineClient(["python3", "-m", "mcp_servers.nexar"])
try:
init = client.request("initialize", {})
tools = client.request("tools/list", {})
search = client.request(
"tools/call",
{"name": "search_parts", "arguments": {"query": "STM32", "limit": 2}},
)
assert_true(init["serverInfo"]["name"] == "nexar-api", "nexar initialize failed")
assert_true(len(tools["tools"]) >= 4, "nexar tools/list mismatch")
assert_true(search["_meta"]["parts"], "nexar search did not expose _meta.parts")
return {"parts_found": len(search["_meta"]["parts"]), "demo_mode": search["_meta"]["demo_mode"]}
finally:
client.close()
def run_kicad_tools_checks(project_dir: Path) -> Dict[str, Any]:
schematic = project_dir / "aux_test.kicad_sch"
pcb = project_dir / "aux_test.kicad_pcb"
client = MCPLineClient(["python3", "-m", "mcp_servers.kicad_tools"])
try:
init = client.request("initialize", {})
tools = client.request("tools/list", {})
component_list = client.request(
"tools/call",
{"name": "get_component_list", "arguments": {"schematic_path": str(schematic)}},
)
analysis = client.request(
"tools/call",
{"name": "analyze_schematic", "arguments": {"schematic_path": str(schematic)}},
)
bom = client.request(
"tools/call",
{"name": "generate_bom", "arguments": {"schematic_path": str(schematic), "group_by": "value"}},
)
footprints = client.request(
"tools/call",
{"name": "validate_footprints", "arguments": {"schematic_path": str(schematic)}},
)
pcb_analysis = client.request(
"tools/call",
{"name": "analyze_pcb", "arguments": {"pcb_path": str(pcb)}},
)
suggestions = client.request(
"tools/call",
{"name": "suggest_improvements", "arguments": {"project_path": str(schematic)}},
)
assert_true(init["serverInfo"]["name"] == "kicad-tools", "kicad_tools initialize failed")
assert_true(len(tools["tools"]) == 6, "kicad_tools tools/list mismatch")
assert_true(component_list["component_count"] == 4, "unexpected schematic component count")
assert_true(analysis["analysis_summary"]["total_components"] == 4, "schematic analysis mismatch")
assert_true(bom["total_components"] == 4, "BOM generation mismatch")
assert_true("issues" in footprints, "footprint validation payload missing")
assert_true(footprints["local_library_count"] > 0, "container KiCad libraries were not detected")
assert_true(footprints["valid_footprints"] >= 3, "expected fixture footprints to resolve against KiCad v10 libraries")
assert_true((CACHE_ROOT / ".footprint_index.json").exists(), "footprint index file was not created")
assert_true(pcb_analysis["board_info"]["components"] == 2, "pcb analysis mismatch")
assert_true("suggestions" in suggestions, "suggest improvements payload missing")
return {
"components": component_list["component_count"],
"bom_items": bom["total_items"],
"pcb_components": pcb_analysis["board_info"]["components"],
}
finally:
client.close()
def main() -> int:
project_dir = create_fixture_project()
try:
component_db = run_component_db_checks(project_dir)
nexar = run_nexar_checks()
kicad_tools = run_kicad_tools_checks(project_dir)
print(
"OK:"
f" component_db.catalog={component_db['catalog_size']}"
f" component_db.search={component_db['search_results']}"
f" nexar.parts={nexar['parts_found']}"
f" nexar.demo={nexar['demo_mode']}"
f" kicad_tools.components={kicad_tools['components']}"
f" kicad_tools.bom_items={kicad_tools['bom_items']}"
f" kicad_tools.pcb_components={kicad_tools['pcb_components']}"
)
return 0
finally:
shutil.rmtree(project_dir, ignore_errors=True)
if __name__ == "__main__":
raise SystemExit(main())