diff --git a/docs/SCHEMATIC_TOOLS_REFERENCE.md b/docs/SCHEMATIC_TOOLS_REFERENCE.md
index deccaf9..72c63c8 100644
--- a/docs/SCHEMATIC_TOOLS_REFERENCE.md
+++ b/docs/SCHEMATIC_TOOLS_REFERENCE.md
@@ -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 |
| 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
@@ -312,13 +312,27 @@ Return a rasterized image of the schematic (PNG by default, or SVG). Uses kicad-
### 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 |
-| ------------- | ------ | -------- | -------------------------- |
-| schematicPath | string | Yes | Path to the schematic file |
+| Parameter | Type | Required | Description |
+| ------------- | ------ | -------- | ---------------------------------------------- |
+| 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)
diff --git a/python/kicad_interface.py b/python/kicad_interface.py
index f39e40b..234670f 100644
--- a/python/kicad_interface.py
+++ b/python/kicad_interface.py
@@ -384,6 +384,7 @@ class KiCADInterface:
"get_wire_connections": self._handle_get_wire_connections,
"get_net_at_point": self._handle_get_net_at_point,
"run_erc": self._handle_run_erc,
+ "export_netlist": self._handle_export_netlist,
"generate_netlist": self._handle_generate_netlist,
"sync_schematic_to_board": self._handle_sync_schematic_to_board,
"list_schematic_libraries": self._handle_list_schematic_libraries,
@@ -2717,23 +2718,190 @@ class KiCADInterface:
logger.error(f"Error running ERC: {str(e)}")
return {"success": False, "message": str(e)}
- def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]:
- """Generate netlist from schematic"""
- logger.info("Generating netlist from schematic")
+ # ------------------------------------------------------------------
+ # kicad-cli helper shared by netlist handlers
+ # ------------------------------------------------------------------
+
+ @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:
schematic_path = params.get("schematicPath")
+ output_path = params.get("outputPath")
+ fmt = params.get("format", "KiCad")
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)
- if not schematic:
- return {"success": False, "message": "Failed to load schematic"}
+ kicad_cli = self._find_kicad_cli_static()
+ if not kicad_cli:
+ return {"success": False, "message": "kicad-cli not found in PATH"}
- netlist = ConnectionManager.generate_netlist(schematic, schematic_path=schematic_path)
- return {"success": True, "netlist": netlist}
+ fmt_map = {
+ "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:
- 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)}
def _handle_sync_schematic_to_board(self, params: Dict[str, Any]) -> Dict[str, Any]:
diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py
index a71ed0c..0d9c410 100644
--- a/python/schemas/tool_schemas.py
+++ b/python/schemas/tool_schemas.py
@@ -1654,24 +1654,20 @@ SCHEMATIC_TOOLS = [
},
{
"name": "generate_netlist",
- "title": "Generate Netlist",
- "description": "Generates a netlist from the schematic showing all components and their net connections.",
+ "title": "Generate Netlist (JSON)",
+ "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": {
"type": "object",
"properties": {
"schematicPath": {
"type": "string",
- "description": "Path to 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",
+ "description": "Absolute path to the .kicad_sch schematic file",
},
},
"required": ["schematicPath"],
diff --git a/src/tools/export.ts b/src/tools/export.ts
index 0c6f524..f36f688 100644
--- a/src/tools/export.ts
+++ b/src/tools/export.ts
@@ -222,16 +222,19 @@ export function registerExportTools(server: McpServer, callKicadScript: CommandF
// ------------------------------------------------------
server.tool(
"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
.enum(["KiCad", "Spice", "Cadstar", "OrcadPCB2"])
.optional()
.describe("Netlist format (default: KiCad)"),
},
- async ({ outputPath, format }) => {
+ async ({ schematicPath, outputPath, format }) => {
logger.debug(`Exporting netlist to: ${outputPath}`);
const result = await callKicadScript("export_netlist", {
+ schematicPath,
outputPath,
format,
});
diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts
index 3ddad9a..3e8b63d 100644
--- a/src/tools/schematic.ts
+++ b/src/tools/schematic.ts
@@ -1152,9 +1152,9 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
// Generate netlist
server.tool(
"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 }) => {
const result = await callKicadScript("generate_netlist", args);
diff --git a/tests/test_netlist_handlers.py b/tests/test_netlist_handlers.py
new file mode 100644
index 0000000..519d647
--- /dev/null
+++ b/tests/test_netlist_handlers.py
@@ -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("""\
+
+
+
+
+ 10k
+ Resistor_SMD:R_0402
+
+
+ 100n
+ Capacitor_SMD:C_0402
+
+
+
+
+
+
+
+
+
+
+
+
+
+""")
+
+
+# ===========================================================================
+# 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("""\
+
+
+
+
+
+ """)
+ result = self._call_with_xml(iface, tmp_path, empty_xml)
+ assert result["success"] is True
+ assert result["netlist"]["components"] == []
+ assert result["netlist"]["nets"] == []