Merge pull request #95 from mixelpixx/fix/netlist-tools
fix: implement export_netlist handler and rewrite generate_netlist to use kicad-cli
This commit is contained in:
@@ -268,7 +268,7 @@ Checks net label / power symbol positions first (exact IU match), then wire endp
|
|||||||
| position | `{"x": float, "y": float}` — echoes the query coordinates |
|
| position | `{"x": float, "y": float}` — echoes the query coordinates |
|
||||||
| source | `"net_label"` \| `"wire_endpoint"` \| `null` — how the net was resolved |
|
| source | `"net_label"` \| `"wire_endpoint"` \| `null` — how the net was resolved |
|
||||||
|
|
||||||
## Schematic Creation and Export (5 tools)
|
## Schematic Creation and Export (6 tools)
|
||||||
|
|
||||||
### create_schematic
|
### create_schematic
|
||||||
|
|
||||||
@@ -312,13 +312,27 @@ Return a rasterized image of the schematic (PNG by default, or SVG). Uses kicad-
|
|||||||
|
|
||||||
### generate_netlist
|
### generate_netlist
|
||||||
|
|
||||||
Generate a netlist from the schematic.
|
Return a structured JSON netlist from the schematic for programmatic use. Uses `kicad-cli` internally — the schematic file must be saved to disk first.
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
| Parameter | Type | Required | Description |
|
||||||
| ------------- | ------ | -------- | -------------------------- |
|
| ------------- | ------ | -------- | ---------------------------------------------- |
|
||||||
| schematicPath | string | Yes | Path to the schematic file |
|
| schematicPath | string | Yes | Absolute path to the .kicad_sch schematic file |
|
||||||
|
|
||||||
**Usage Notes:** Returns a complete netlist with component information (reference, value, footprint) and net connections (net name with all connected component/pin pairs).
|
**Returns:** `{ components: [{reference, value, footprint}], nets: [{name, connections: [{component, pin}]}] }`
|
||||||
|
|
||||||
|
**Usage Notes:** Use this when you need net membership data in the conversation (e.g., to verify connectivity). For writing a netlist to a file or exporting SPICE/Cadstar/OrcadPCB2 format, use `export_netlist` instead.
|
||||||
|
|
||||||
|
### export_netlist
|
||||||
|
|
||||||
|
Export a netlist to a file in a standard EDA format using `kicad-cli`. Supports SPICE (for simulation), KiCad XML (for archiving/import), Cadstar, and OrcadPCB2.
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| ------------- | ------ | -------- | ------------------------------------------------------------ |
|
||||||
|
| schematicPath | string | Yes | Absolute path to the .kicad_sch schematic file |
|
||||||
|
| outputPath | string | Yes | Absolute path for the output file (e.g. `/tmp/design.spice`) |
|
||||||
|
| format | enum | No | `KiCad` (default), `Spice`, `Cadstar`, `OrcadPCB2` |
|
||||||
|
|
||||||
|
**Usage Notes:** The schematic file must be saved before calling this tool. Use `Spice` format to produce a SPICE netlist for simulation or diff against a reference. The output file is created or overwritten at `outputPath`.
|
||||||
|
|
||||||
## Validation and Synchronization (6 tools)
|
## Validation and Synchronization (6 tools)
|
||||||
|
|
||||||
|
|||||||
@@ -384,6 +384,7 @@ class KiCADInterface:
|
|||||||
"get_wire_connections": self._handle_get_wire_connections,
|
"get_wire_connections": self._handle_get_wire_connections,
|
||||||
"get_net_at_point": self._handle_get_net_at_point,
|
"get_net_at_point": self._handle_get_net_at_point,
|
||||||
"run_erc": self._handle_run_erc,
|
"run_erc": self._handle_run_erc,
|
||||||
|
"export_netlist": self._handle_export_netlist,
|
||||||
"generate_netlist": self._handle_generate_netlist,
|
"generate_netlist": self._handle_generate_netlist,
|
||||||
"sync_schematic_to_board": self._handle_sync_schematic_to_board,
|
"sync_schematic_to_board": self._handle_sync_schematic_to_board,
|
||||||
"list_schematic_libraries": self._handle_list_schematic_libraries,
|
"list_schematic_libraries": self._handle_list_schematic_libraries,
|
||||||
@@ -2717,23 +2718,190 @@ class KiCADInterface:
|
|||||||
logger.error(f"Error running ERC: {str(e)}")
|
logger.error(f"Error running ERC: {str(e)}")
|
||||||
return {"success": False, "message": str(e)}
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
# ------------------------------------------------------------------
|
||||||
"""Generate netlist from schematic"""
|
# kicad-cli helper shared by netlist handlers
|
||||||
logger.info("Generating netlist from schematic")
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _find_kicad_cli_static() -> Optional[str]:
|
||||||
|
"""Return path to kicad-cli executable, or None."""
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
cli = shutil.which("kicad-cli")
|
||||||
|
if cli:
|
||||||
|
return cli
|
||||||
|
|
||||||
|
system = platform.system()
|
||||||
|
if system == "Windows":
|
||||||
|
candidates = [
|
||||||
|
r"C:\Program Files\KiCad\9.0\bin\kicad-cli.exe",
|
||||||
|
r"C:\Program Files\KiCad\8.0\bin\kicad-cli.exe",
|
||||||
|
r"C:\Program Files (x86)\KiCad\9.0\bin\kicad-cli.exe",
|
||||||
|
r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe",
|
||||||
|
]
|
||||||
|
elif system == "Darwin":
|
||||||
|
candidates = [
|
||||||
|
"/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli",
|
||||||
|
"/usr/local/bin/kicad-cli",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
candidates = [
|
||||||
|
"/usr/bin/kicad-cli",
|
||||||
|
"/usr/local/bin/kicad-cli",
|
||||||
|
]
|
||||||
|
for path in candidates:
|
||||||
|
if os.path.exists(path):
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _handle_export_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Export netlist to a file using kicad-cli."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
logger.info("Exporting netlist via kicad-cli")
|
||||||
try:
|
try:
|
||||||
schematic_path = params.get("schematicPath")
|
schematic_path = params.get("schematicPath")
|
||||||
|
output_path = params.get("outputPath")
|
||||||
|
fmt = params.get("format", "KiCad")
|
||||||
|
|
||||||
if not schematic_path:
|
if not schematic_path:
|
||||||
return {"success": False, "message": "Schematic path is required"}
|
return {"success": False, "message": "schematicPath is required"}
|
||||||
|
if not output_path:
|
||||||
|
return {"success": False, "message": "outputPath is required"}
|
||||||
|
if not os.path.exists(schematic_path):
|
||||||
|
return {"success": False, "message": f"Schematic not found: {schematic_path}"}
|
||||||
|
|
||||||
schematic = SchematicManager.load_schematic(schematic_path)
|
kicad_cli = self._find_kicad_cli_static()
|
||||||
if not schematic:
|
if not kicad_cli:
|
||||||
return {"success": False, "message": "Failed to load schematic"}
|
return {"success": False, "message": "kicad-cli not found in PATH"}
|
||||||
|
|
||||||
netlist = ConnectionManager.generate_netlist(schematic, schematic_path=schematic_path)
|
fmt_map = {
|
||||||
return {"success": True, "netlist": netlist}
|
"KiCad": "kicadxml",
|
||||||
|
"Spice": "spice",
|
||||||
|
"Cadstar": "cadstar",
|
||||||
|
"OrcadPCB2": "orcadpcb2",
|
||||||
|
}
|
||||||
|
cli_format = fmt_map.get(fmt, "kicadxml")
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
kicad_cli,
|
||||||
|
"sch",
|
||||||
|
"export",
|
||||||
|
"netlist",
|
||||||
|
"--format",
|
||||||
|
cli_format,
|
||||||
|
"--output",
|
||||||
|
output_path,
|
||||||
|
schematic_path,
|
||||||
|
]
|
||||||
|
logger.info(f"Running: {' '.join(cmd)}")
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return {"success": True, "outputPath": output_path, "format": fmt}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"kicad-cli failed (exit {result.returncode}): {result.stderr.strip()}",
|
||||||
|
}
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"success": False, "message": "kicad-cli not found in PATH"}
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {"success": False, "message": "kicad-cli timed out after 60 seconds"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error generating netlist: {str(e)}")
|
logger.error(f"Error exporting netlist: {e}")
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
|
def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Generate netlist from schematic and return structured JSON.
|
||||||
|
|
||||||
|
Uses kicad-cli to export KiCad XML netlist to a temp file, then
|
||||||
|
parses it into {components, nets} structure expected by the TS handler.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
logger.info("Generating netlist from schematic via kicad-cli")
|
||||||
|
try:
|
||||||
|
schematic_path = params.get("schematicPath")
|
||||||
|
if not schematic_path:
|
||||||
|
return {"success": False, "message": "Schematic path is required"}
|
||||||
|
if not os.path.exists(schematic_path):
|
||||||
|
return {"success": False, "message": f"Schematic not found: {schematic_path}"}
|
||||||
|
|
||||||
|
kicad_cli = self._find_kicad_cli_static()
|
||||||
|
if not kicad_cli:
|
||||||
|
return {"success": False, "message": "kicad-cli not found in PATH"}
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
cmd = [
|
||||||
|
kicad_cli,
|
||||||
|
"sch",
|
||||||
|
"export",
|
||||||
|
"netlist",
|
||||||
|
"--format",
|
||||||
|
"kicadxml",
|
||||||
|
"--output",
|
||||||
|
tmp_path,
|
||||||
|
schematic_path,
|
||||||
|
]
|
||||||
|
logger.info(f"Running: {' '.join(cmd)}")
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"kicad-cli failed (exit {result.returncode}): {result.stderr.strip()}",
|
||||||
|
}
|
||||||
|
|
||||||
|
tree = ET.parse(tmp_path)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
components = []
|
||||||
|
for comp in root.findall("./components/comp"):
|
||||||
|
ref = comp.get("ref", "")
|
||||||
|
value = comp.findtext("value", "")
|
||||||
|
footprint = comp.findtext("footprint", "")
|
||||||
|
components.append({"reference": ref, "value": value, "footprint": footprint})
|
||||||
|
|
||||||
|
nets = []
|
||||||
|
for net in root.findall("./nets/net"):
|
||||||
|
net_name = net.get("name", "")
|
||||||
|
connections = []
|
||||||
|
for node in net.findall("node"):
|
||||||
|
connections.append(
|
||||||
|
{
|
||||||
|
"component": node.get("ref", ""),
|
||||||
|
"pin": node.get("pin", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
nets.append({"name": net_name, "connections": connections})
|
||||||
|
|
||||||
|
logger.info(f"Generated netlist: {len(components)} components, {len(nets)} nets")
|
||||||
|
return {"success": True, "netlist": {"components": components, "nets": nets}}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"success": False, "message": "kicad-cli not found in PATH"}
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {"success": False, "message": "kicad-cli timed out after 60 seconds"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating netlist: {e}")
|
||||||
return {"success": False, "message": str(e)}
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
def _handle_sync_schematic_to_board(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def _handle_sync_schematic_to_board(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -1654,24 +1654,20 @@ SCHEMATIC_TOOLS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "generate_netlist",
|
"name": "generate_netlist",
|
||||||
"title": "Generate Netlist",
|
"title": "Generate Netlist (JSON)",
|
||||||
"description": "Generates a netlist from the schematic showing all components and their net connections.",
|
"description": (
|
||||||
|
"Returns a structured JSON netlist from the schematic: component list "
|
||||||
|
"(reference, value, footprint) and net list (net name + all connected "
|
||||||
|
"component/pin pairs). Uses kicad-cli internally — requires a saved "
|
||||||
|
".kicad_sch file. For writing to a file or exporting SPICE/Cadstar/OrcadPCB2 "
|
||||||
|
"format, use export_netlist instead."
|
||||||
|
),
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"schematicPath": {
|
"schematicPath": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Path to schematic file",
|
"description": "Absolute path to the .kicad_sch schematic file",
|
||||||
},
|
|
||||||
"outputPath": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Optional path to save netlist file",
|
|
||||||
},
|
|
||||||
"format": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["kicad", "json", "spice"],
|
|
||||||
"description": "Netlist output format",
|
|
||||||
"default": "json",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": ["schematicPath"],
|
"required": ["schematicPath"],
|
||||||
|
|||||||
@@ -222,16 +222,19 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF
|
|||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
server.tool(
|
server.tool(
|
||||||
"export_netlist",
|
"export_netlist",
|
||||||
|
"Export the schematic netlist to a file using kicad-cli. Supports KiCad XML (default), Spice (for simulation), Cadstar, and OrcadPCB2 formats. Use this when you need to write a netlist file to disk — for example to produce a SPICE file for simulation or to diff against a reference. To get net/component data inline without writing a file, use generate_netlist instead.",
|
||||||
{
|
{
|
||||||
outputPath: z.string().describe("Path to save the netlist file"),
|
schematicPath: z.string().describe("Absolute path to the .kicad_sch schematic file"),
|
||||||
|
outputPath: z.string().describe("Absolute path for the output file (e.g. /tmp/design.spice)"),
|
||||||
format: z
|
format: z
|
||||||
.enum(["KiCad", "Spice", "Cadstar", "OrcadPCB2"])
|
.enum(["KiCad", "Spice", "Cadstar", "OrcadPCB2"])
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Netlist format (default: KiCad)"),
|
.describe("Netlist format (default: KiCad)"),
|
||||||
},
|
},
|
||||||
async ({ outputPath, format }) => {
|
async ({ schematicPath, outputPath, format }) => {
|
||||||
logger.debug(`Exporting netlist to: ${outputPath}`);
|
logger.debug(`Exporting netlist to: ${outputPath}`);
|
||||||
const result = await callKicadScript("export_netlist", {
|
const result = await callKicadScript("export_netlist", {
|
||||||
|
schematicPath,
|
||||||
outputPath,
|
outputPath,
|
||||||
format,
|
format,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1152,9 +1152,9 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
|
|||||||
// Generate netlist
|
// Generate netlist
|
||||||
server.tool(
|
server.tool(
|
||||||
"generate_netlist",
|
"generate_netlist",
|
||||||
"Generate a netlist from the schematic",
|
"Return a structured JSON netlist from the schematic — component list (reference, value, footprint) and net list (net name with all connected component/pin pairs). Use this to inspect or verify connectivity within the conversation. Does not write any file. To export a netlist file in Spice, KiCad XML, Cadstar, or OrcadPCB2 format, use export_netlist instead.",
|
||||||
{
|
{
|
||||||
schematicPath: z.string().describe("Path to the schematic file"),
|
schematicPath: z.string().describe("Absolute path to the .kicad_sch schematic file"),
|
||||||
},
|
},
|
||||||
async (args: { schematicPath: string }) => {
|
async (args: { schematicPath: string }) => {
|
||||||
const result = await callKicadScript("generate_netlist", args);
|
const result = await callKicadScript("generate_netlist", args);
|
||||||
|
|||||||
375
tests/test_netlist_handlers.py
Normal file
375
tests/test_netlist_handlers.py
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
"""
|
||||||
|
Tests for export_netlist and generate_netlist handlers.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Parameter validation (unit)
|
||||||
|
- kicad-cli invocation and response parsing (unit, subprocess mocked)
|
||||||
|
- XML → structured JSON conversion for generate_netlist (unit)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared fixture: KiCADInterface instance (no __init__, avoids pcbnew/IPC)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_iface() -> Any:
|
||||||
|
with patch("kicad_interface.USE_IPC_BACKEND", False):
|
||||||
|
from kicad_interface import KiCADInterface
|
||||||
|
|
||||||
|
iface = KiCADInterface.__new__(KiCADInterface)
|
||||||
|
return iface
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def iface():
|
||||||
|
return _make_iface()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sample KiCad XML netlist (minimal but structurally valid)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_KICAD_NETLIST_XML = textwrap.dedent("""\
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<export version="E">
|
||||||
|
<components>
|
||||||
|
<comp ref="R1">
|
||||||
|
<value>10k</value>
|
||||||
|
<footprint>Resistor_SMD:R_0402</footprint>
|
||||||
|
</comp>
|
||||||
|
<comp ref="C1">
|
||||||
|
<value>100n</value>
|
||||||
|
<footprint>Capacitor_SMD:C_0402</footprint>
|
||||||
|
</comp>
|
||||||
|
</components>
|
||||||
|
<nets>
|
||||||
|
<net code="1" name="VCC">
|
||||||
|
<node ref="R1" pin="1"/>
|
||||||
|
<node ref="C1" pin="+"/>
|
||||||
|
</net>
|
||||||
|
<net code="2" name="GND">
|
||||||
|
<node ref="R1" pin="2"/>
|
||||||
|
<node ref="C1" pin="-"/>
|
||||||
|
</net>
|
||||||
|
</nets>
|
||||||
|
</export>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Dispatch: both commands wired into command_routes
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestNetlistDispatch:
|
||||||
|
def _make_full_iface(self) -> Any:
|
||||||
|
with patch("kicad_interface.USE_IPC_BACKEND", False):
|
||||||
|
from kicad_interface import KiCADInterface
|
||||||
|
|
||||||
|
obj = KiCADInterface.__new__(KiCADInterface)
|
||||||
|
obj.board = None
|
||||||
|
obj.project_filename = None
|
||||||
|
obj.use_ipc = False
|
||||||
|
obj.ipc_backend = MagicMock()
|
||||||
|
obj.ipc_board_api = None
|
||||||
|
obj.footprint_library = MagicMock()
|
||||||
|
obj.project_commands = MagicMock()
|
||||||
|
obj.board_commands = MagicMock()
|
||||||
|
obj.component_commands = MagicMock()
|
||||||
|
obj.routing_commands = MagicMock()
|
||||||
|
KiCADInterface.__init__(obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def test_export_netlist_in_routes(self):
|
||||||
|
obj = self._make_full_iface()
|
||||||
|
assert "export_netlist" in obj.command_routes
|
||||||
|
assert callable(obj.command_routes["export_netlist"])
|
||||||
|
|
||||||
|
def test_generate_netlist_in_routes(self):
|
||||||
|
obj = self._make_full_iface()
|
||||||
|
assert "generate_netlist" in obj.command_routes
|
||||||
|
assert callable(obj.command_routes["generate_netlist"])
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# export_netlist
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestExportNetlistValidation:
|
||||||
|
def test_missing_schematic_path(self, iface, tmp_path):
|
||||||
|
result = iface._handle_export_netlist({"outputPath": str(tmp_path / "out.xml")})
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "schematicPath" in result["message"]
|
||||||
|
|
||||||
|
def test_missing_output_path(self, iface, tmp_path):
|
||||||
|
sch = tmp_path / "test.kicad_sch"
|
||||||
|
sch.write_text("(kicad_sch)")
|
||||||
|
result = iface._handle_export_netlist({"schematicPath": str(sch)})
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "outputPath" in result["message"]
|
||||||
|
|
||||||
|
def test_schematic_not_found(self, iface, tmp_path):
|
||||||
|
result = iface._handle_export_netlist(
|
||||||
|
{
|
||||||
|
"schematicPath": "/nonexistent/file.kicad_sch",
|
||||||
|
"outputPath": str(tmp_path / "out.xml"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "not found" in result["message"].lower()
|
||||||
|
|
||||||
|
def test_kicad_cli_not_found(self, iface, tmp_path):
|
||||||
|
sch = tmp_path / "test.kicad_sch"
|
||||||
|
sch.write_text("(kicad_sch)")
|
||||||
|
with patch(
|
||||||
|
"kicad_interface.KiCADInterface._find_kicad_cli_static", staticmethod(lambda: None)
|
||||||
|
):
|
||||||
|
result = iface._handle_export_netlist(
|
||||||
|
{"schematicPath": str(sch), "outputPath": str(tmp_path / "out.xml")}
|
||||||
|
)
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "kicad-cli" in result["message"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestExportNetlistCliInvocation:
|
||||||
|
def _run_with_mock_cli(self, iface, tmp_path, fmt_param, expected_cli_fmt):
|
||||||
|
sch = tmp_path / "test.kicad_sch"
|
||||||
|
sch.write_text("(kicad_sch)")
|
||||||
|
out = tmp_path / "out.net"
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
captured["cmd"] = cmd
|
||||||
|
return MagicMock(returncode=0, stderr="")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=fake_run),
|
||||||
|
patch(
|
||||||
|
"kicad_interface.KiCADInterface._find_kicad_cli_static",
|
||||||
|
staticmethod(lambda: "/usr/bin/kicad-cli"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = iface._handle_export_netlist(
|
||||||
|
{"schematicPath": str(sch), "outputPath": str(out), "format": fmt_param}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True, result
|
||||||
|
assert expected_cli_fmt in captured["cmd"]
|
||||||
|
assert str(sch) in captured["cmd"]
|
||||||
|
assert str(out) in captured["cmd"]
|
||||||
|
|
||||||
|
def test_format_spice(self, iface, tmp_path):
|
||||||
|
self._run_with_mock_cli(iface, tmp_path, "Spice", "spice")
|
||||||
|
|
||||||
|
def test_format_kicad(self, iface, tmp_path):
|
||||||
|
self._run_with_mock_cli(iface, tmp_path, "KiCad", "kicadxml")
|
||||||
|
|
||||||
|
def test_format_cadstar(self, iface, tmp_path):
|
||||||
|
self._run_with_mock_cli(iface, tmp_path, "Cadstar", "cadstar")
|
||||||
|
|
||||||
|
def test_format_orcadpcb2(self, iface, tmp_path):
|
||||||
|
self._run_with_mock_cli(iface, tmp_path, "OrcadPCB2", "orcadpcb2")
|
||||||
|
|
||||||
|
def test_response_contains_output_path(self, iface, tmp_path):
|
||||||
|
sch = tmp_path / "test.kicad_sch"
|
||||||
|
sch.write_text("(kicad_sch)")
|
||||||
|
out = tmp_path / "out.net"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", return_value=MagicMock(returncode=0, stderr="")),
|
||||||
|
patch(
|
||||||
|
"kicad_interface.KiCADInterface._find_kicad_cli_static",
|
||||||
|
staticmethod(lambda: "/usr/bin/kicad-cli"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = iface._handle_export_netlist(
|
||||||
|
{"schematicPath": str(sch), "outputPath": str(out), "format": "Spice"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["outputPath"] == str(out)
|
||||||
|
assert result["format"] == "Spice"
|
||||||
|
|
||||||
|
def test_cli_failure_propagated(self, iface, tmp_path):
|
||||||
|
sch = tmp_path / "test.kicad_sch"
|
||||||
|
sch.write_text("(kicad_sch)")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", return_value=MagicMock(returncode=1, stderr="bad input")),
|
||||||
|
patch(
|
||||||
|
"kicad_interface.KiCADInterface._find_kicad_cli_static",
|
||||||
|
staticmethod(lambda: "/usr/bin/kicad-cli"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = iface._handle_export_netlist(
|
||||||
|
{"schematicPath": str(sch), "outputPath": str(tmp_path / "out.net")}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "bad input" in result["message"]
|
||||||
|
|
||||||
|
def test_cli_timeout_propagated(self, iface, tmp_path):
|
||||||
|
sch = tmp_path / "test.kicad_sch"
|
||||||
|
sch.write_text("(kicad_sch)")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=subprocess.TimeoutExpired("kicad-cli", 60)),
|
||||||
|
patch(
|
||||||
|
"kicad_interface.KiCADInterface._find_kicad_cli_static",
|
||||||
|
staticmethod(lambda: "/usr/bin/kicad-cli"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = iface._handle_export_netlist(
|
||||||
|
{"schematicPath": str(sch), "outputPath": str(tmp_path / "out.net")}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "timed out" in result["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# generate_netlist
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestGenerateNetlistValidation:
|
||||||
|
def test_missing_schematic_path(self, iface):
|
||||||
|
result = iface._handle_generate_netlist({})
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "required" in result["message"].lower()
|
||||||
|
|
||||||
|
def test_schematic_not_found(self, iface):
|
||||||
|
result = iface._handle_generate_netlist({"schematicPath": "/nonexistent/file.kicad_sch"})
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "not found" in result["message"].lower()
|
||||||
|
|
||||||
|
def test_kicad_cli_not_found(self, iface, tmp_path):
|
||||||
|
sch = tmp_path / "test.kicad_sch"
|
||||||
|
sch.write_text("(kicad_sch)")
|
||||||
|
with patch(
|
||||||
|
"kicad_interface.KiCADInterface._find_kicad_cli_static", staticmethod(lambda: None)
|
||||||
|
):
|
||||||
|
result = iface._handle_generate_netlist({"schematicPath": str(sch)})
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "kicad-cli" in result["message"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestGenerateNetlistXmlParsing:
|
||||||
|
"""Verify the XML → JSON conversion is correct."""
|
||||||
|
|
||||||
|
def _call_with_xml(self, iface, tmp_path, xml_content):
|
||||||
|
sch = tmp_path / "test.kicad_sch"
|
||||||
|
sch.write_text("(kicad_sch)")
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
# Write the XML to the --output path in the command
|
||||||
|
out_idx = cmd.index("--output") + 1
|
||||||
|
Path(cmd[out_idx]).write_text(xml_content)
|
||||||
|
return MagicMock(returncode=0, stderr="")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=fake_run),
|
||||||
|
patch(
|
||||||
|
"kicad_interface.KiCADInterface._find_kicad_cli_static",
|
||||||
|
staticmethod(lambda: "/usr/bin/kicad-cli"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
return iface._handle_generate_netlist({"schematicPath": str(sch)})
|
||||||
|
|
||||||
|
def test_success_flag(self, iface, tmp_path):
|
||||||
|
result = self._call_with_xml(iface, tmp_path, _KICAD_NETLIST_XML)
|
||||||
|
assert result["success"] is True
|
||||||
|
|
||||||
|
def test_components_count(self, iface, tmp_path):
|
||||||
|
result = self._call_with_xml(iface, tmp_path, _KICAD_NETLIST_XML)
|
||||||
|
assert len(result["netlist"]["components"]) == 2
|
||||||
|
|
||||||
|
def test_component_refs(self, iface, tmp_path):
|
||||||
|
result = self._call_with_xml(iface, tmp_path, _KICAD_NETLIST_XML)
|
||||||
|
refs = {c["reference"] for c in result["netlist"]["components"]}
|
||||||
|
assert refs == {"R1", "C1"}
|
||||||
|
|
||||||
|
def test_component_fields(self, iface, tmp_path):
|
||||||
|
result = self._call_with_xml(iface, tmp_path, _KICAD_NETLIST_XML)
|
||||||
|
r1 = next(c for c in result["netlist"]["components"] if c["reference"] == "R1")
|
||||||
|
assert r1["value"] == "10k"
|
||||||
|
assert r1["footprint"] == "Resistor_SMD:R_0402"
|
||||||
|
|
||||||
|
def test_nets_count(self, iface, tmp_path):
|
||||||
|
result = self._call_with_xml(iface, tmp_path, _KICAD_NETLIST_XML)
|
||||||
|
assert len(result["netlist"]["nets"]) == 2
|
||||||
|
|
||||||
|
def test_net_names(self, iface, tmp_path):
|
||||||
|
result = self._call_with_xml(iface, tmp_path, _KICAD_NETLIST_XML)
|
||||||
|
names = {n["name"] for n in result["netlist"]["nets"]}
|
||||||
|
assert names == {"VCC", "GND"}
|
||||||
|
|
||||||
|
def test_net_connections(self, iface, tmp_path):
|
||||||
|
result = self._call_with_xml(iface, tmp_path, _KICAD_NETLIST_XML)
|
||||||
|
vcc = next(n for n in result["netlist"]["nets"] if n["name"] == "VCC")
|
||||||
|
assert len(vcc["connections"]) == 2
|
||||||
|
comps = {c["component"] for c in vcc["connections"]}
|
||||||
|
assert comps == {"R1", "C1"}
|
||||||
|
|
||||||
|
def test_cli_failure_propagated(self, iface, tmp_path):
|
||||||
|
sch = tmp_path / "test.kicad_sch"
|
||||||
|
sch.write_text("(kicad_sch)")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", return_value=MagicMock(returncode=1, stderr="parse error")),
|
||||||
|
patch(
|
||||||
|
"kicad_interface.KiCADInterface._find_kicad_cli_static",
|
||||||
|
staticmethod(lambda: "/usr/bin/kicad-cli"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = iface._handle_generate_netlist({"schematicPath": str(sch)})
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "parse error" in result["message"]
|
||||||
|
|
||||||
|
def test_cli_timeout_propagated(self, iface, tmp_path):
|
||||||
|
sch = tmp_path / "test.kicad_sch"
|
||||||
|
sch.write_text("(kicad_sch)")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("subprocess.run", side_effect=subprocess.TimeoutExpired("kicad-cli", 60)),
|
||||||
|
patch(
|
||||||
|
"kicad_interface.KiCADInterface._find_kicad_cli_static",
|
||||||
|
staticmethod(lambda: "/usr/bin/kicad-cli"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = iface._handle_generate_netlist({"schematicPath": str(sch)})
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "timed out" in result["message"].lower()
|
||||||
|
|
||||||
|
def test_empty_schematic(self, iface, tmp_path):
|
||||||
|
empty_xml = textwrap.dedent("""\
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<export version="E">
|
||||||
|
<components/>
|
||||||
|
<nets/>
|
||||||
|
</export>
|
||||||
|
""")
|
||||||
|
result = self._call_with_xml(iface, tmp_path, empty_xml)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["netlist"]["components"] == []
|
||||||
|
assert result["netlist"]["nets"] == []
|
||||||
Reference in New Issue
Block a user