From c7b0e3105be9513a5126fa1be4869f09e7ce7f48 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 12 Apr 2026 18:52:46 +0100 Subject: [PATCH] fix: implement export_netlist handler and rewrite generate_netlist to use kicad-cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit export_netlist was returning "Unknown command" because no Python handler existed. generate_netlist was timing out (30s) due to an O(nets × components × pins) wire-graph algorithm with a new PinLocator instantiated per net. Both handlers now delegate to `kicad-cli sch export netlist`: - export_netlist: new handler; writes KiCad XML / Spice / Cadstar / OrcadPCB2 to the caller-supplied outputPath. Added schematicPath parameter to the TS tool definition (was absent, making file export impossible). - generate_netlist: replaces the slow wire-graph with kicad-cli + XML parse; returns the same {components, nets} JSON the TS handler already expected. Also adds _find_kicad_cli_static() so both handlers share CLI discovery without depending on ExportCommands (which requires a loaded pcbnew board). Cleaned up generate_netlist schema in tool_schemas.py (removed outputPath and format fields the handler never used), updated MCP tool descriptions and SCHEMATIC_TOOLS_REFERENCE.md to clearly distinguish the two tools. 26 unit tests added covering parameter validation, subprocess mocking, format mapping, XML→JSON parsing, and error/timeout propagation. Co-Authored-By: Claude Sonnet 4.6 --- docs/SCHEMATIC_TOOLS_REFERENCE.md | 26 ++- python/kicad_interface.py | 188 ++++++++++++++- python/schemas/tool_schemas.py | 22 +- src/tools/export.ts | 7 +- src/tools/schematic.ts | 4 +- tests/test_netlist_handlers.py | 375 ++++++++++++++++++++++++++++++ 6 files changed, 589 insertions(+), 33 deletions(-) create mode 100644 tests/test_netlist_handlers.py 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"] == []