diff --git a/python/commands/wire_connectivity.py b/python/commands/wire_connectivity.py index 5c4c147..3e65dd0 100644 --- a/python/commands/wire_connectivity.py +++ b/python/commands/wire_connectivity.py @@ -333,6 +333,165 @@ def get_pin_net(schematic: Any, schematic_path: str, x_mm: float, y_mm: float) - return {"net": net, "pins": pins, "wires": wires_out, "query_point": {"x": x_mm, "y": y_mm}} +def count_pins_on_net( + schematic: Any, + schematic_path: str, + net_name: str, + all_wires: List[List[Tuple[int, int]]], + iu_to_wires: Dict[Tuple[int, int], Set[int]], + adjacency: List[Set[int]], + point_to_label: Dict[Tuple[int, int], str], + label_to_points: Dict[str, List[Tuple[int, int]]], +) -> int: + """Count the number of component pins connected to the named net. + + A pin is counted if its IU coordinate falls on the wire-network reachable + from any label anchor for *net_name*, or directly on a label anchor of that + net (pin directly touching a label with no intervening wire). + + Returns the count of distinct (component, pin_num) pairs on this net. + """ + label_positions = label_to_points.get(net_name, []) + if not label_positions: + return 0 + + # Collect the union of all net-points across all label positions for this net + all_net_points: Set[Tuple[int, int]] = set() + for lx, ly in label_positions: + # Include the label anchor itself so pins directly at the label count + all_net_points.add((lx, ly)) + # Trace from this label position into the wire graph + x_mm = lx / _IU_PER_MM + y_mm = ly / _IU_PER_MM + visited, net_points = _find_connected_wires( + x_mm, + y_mm, + all_wires, + iu_to_wires, + adjacency, + point_to_label=point_to_label, + label_to_points=label_to_points, + ) + if net_points: + all_net_points |= net_points + + if not hasattr(schematic, "symbol"): + return 0 + + locator = PinLocator() + seen: Set[Tuple[str, str]] = set() + ref = None + for symbol in schematic.symbol: + try: + if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"): + continue + ref = symbol.property.Reference.value + if ref.startswith("_TEMPLATE"): + continue + all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref) + if not all_pins: + continue + for pin_num, pin_data in all_pins.items(): + pin_iu = _to_iu(float(pin_data[0]), float(pin_data[1])) + if pin_iu in all_net_points: + key = (ref, pin_num) + if key not in seen: + seen.add(key) + except Exception as e: + logger.warning( + f"Error checking pins for {ref if ref is not None else ''}: {e}" + ) + + return len(seen) + + +def list_floating_labels(schematic: Any, schematic_path: str) -> List[Dict[str, Any]]: + """Return net labels that are not connected to any component pin. + + A label is "floating" when no component pin's IU coordinate falls on the + wire-network reachable from the label's anchor position. These labels are + likely placed off-grid or incorrectly positioned and will cause ERC errors. + + Returns a list of dicts with keys: + - "name": str — the net label text + - "x": float — label X position in mm + - "y": float — label Y position in mm + - "type": str — "label" or "global_label" + """ + all_wires = _parse_wires(schematic) + if all_wires: + adjacency, iu_to_wires = _build_adjacency(all_wires) + else: + adjacency = [] + iu_to_wires = {} + + point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path) + + # Build a set of all pin IU positions for fast lookup + pin_iu_set: Set[Tuple[int, int]] = set() + if hasattr(schematic, "symbol"): + locator = PinLocator() + for symbol in schematic.symbol: + try: + if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"): + continue + ref = symbol.property.Reference.value + if ref.startswith("_TEMPLATE"): + continue + all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref) + if not all_pins: + continue + for pin_data in all_pins.values(): + pin_iu_set.add(_to_iu(float(pin_data[0]), float(pin_data[1]))) + except Exception as e: + logger.warning(f"Error reading pins for floating-label check: {e}") + + floating: List[Dict[str, Any]] = [] + + if not hasattr(schematic, "label"): + return floating + + for label in schematic.label: + try: + if not hasattr(label, "value"): + continue + name = label.value + if not hasattr(label, "at") or not hasattr(label.at, "value"): + continue + coords = label.at.value + lx_mm = float(coords[0]) + ly_mm = float(coords[1]) + lx_iu = _to_iu(lx_mm, ly_mm) + + # Check if the label anchor itself is a pin position + if lx_iu in pin_iu_set: + continue + + # Trace the wire-network from this label and check for pins + if all_wires: + _, net_points = _find_connected_wires( + lx_mm, + ly_mm, + all_wires, + iu_to_wires, + adjacency, + point_to_label=point_to_label, + label_to_points=label_to_points, + ) + else: + net_points = None + + if net_points is not None and net_points & pin_iu_set: + continue # at least one pin on this net + + floating.append({"name": name, "x": lx_mm, "y": ly_mm, "type": "label"}) + + except Exception as e: + logger.warning(f"Error checking label for floating status: {e}") + + return floating + + def get_net_at_point( schematic: Any, schematic_path: str, x_mm: float, y_mm: float ) -> Dict[str, Any]: diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 4e23237..a710dd1 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -406,6 +406,7 @@ class KiCADInterface: "get_elements_in_region": self._handle_get_elements_in_region, "find_wires_crossing_symbols": self._handle_find_wires_crossing_symbols, "find_orphaned_wires": self._handle_find_orphaned_wires, + "list_floating_labels": self._handle_list_floating_labels, "snap_to_grid": self._handle_snap_to_grid, "import_svg_logo": self._handle_import_svg_logo, # UI/Process management commands @@ -1924,6 +1925,13 @@ class KiCADInterface: try: from pathlib import Path + from commands.wire_connectivity import ( + _build_adjacency, + _parse_virtual_connections, + _parse_wires, + count_pins_on_net, + ) + schematic_path = params.get("schematicPath") if not schematic_path: return {"success": False, "message": "schematicPath is required"} @@ -1943,15 +1951,34 @@ class KiCADInterface: if hasattr(label, "value"): net_names.add(label.value) + # Pre-build shared wire graph structures for efficiency + all_wires = _parse_wires(schematic) + if all_wires: + adjacency, iu_to_wires = _build_adjacency(all_wires) + else: + adjacency, iu_to_wires = [], {} + point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path) + nets = [] for net_name in sorted(net_names): connections = ConnectionManager.get_net_connections( schematic, net_name, Path(schematic_path) ) + pin_count = count_pins_on_net( + schematic, + schematic_path, + net_name, + all_wires, + iu_to_wires, + adjacency, + point_to_label, + label_to_points, + ) nets.append( { "name": net_name, "connections": connections, + "connected_pin_count": pin_count, } ) @@ -3063,6 +3090,34 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} + def _handle_list_floating_labels(self, params: Dict[str, Any]) -> Dict[str, Any]: + """List net labels that are not connected to any component pin""" + logger.info("Listing floating net labels in schematic") + try: + from commands.wire_connectivity import list_floating_labels + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + + schematic = SchematicManager.load_schematic(schematic_path) + if not schematic: + return {"success": False, "message": "Failed to load schematic"} + + labels = list_floating_labels(schematic, schematic_path) + return { + "success": True, + "floating_labels": labels, + "count": len(labels), + "message": f"Found {len(labels)} floating label(s)", + } + except Exception as e: + logger.error(f"Error listing floating labels: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + def _handle_snap_to_grid(self, params: Dict[str, Any]) -> Dict[str, Any]: """Snap schematic element coordinates to the nearest grid point""" logger.info("Snapping schematic elements to grid") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 2eb6584..bf4b1fc 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1881,6 +1881,27 @@ SCHEMATIC_TOOLS = [ "required": ["schematicPath"], }, }, + { + "name": "list_floating_labels", + "title": "List Floating Net Labels", + "description": ( + "Returns all net labels in the schematic that are not connected to any component pin. " + "A label is 'floating' when no component pin's coordinate falls on the wire-network " + "reachable from the label's anchor position. " + "Floating labels indicate misplaced or off-grid labels that will cause ERC errors. " + "Does not require the KiCad UI to be running." + ), + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the .kicad_sch schematic file", + } + }, + "required": ["schematicPath"], + }, + }, { "name": "snap_to_grid", "title": "Snap Schematic Elements to Grid", diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index b3113e6..f82bb8e 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -660,7 +660,9 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp } const lines = nets.map((n: any) => { const conns = (n.connections || []).map((c: any) => `${c.component}/${c.pin}`).join(", "); - return ` ${n.name}: ${conns || "(no connections)"}`; + const pinCount = + n.connected_pin_count !== undefined ? ` [${n.connected_pin_count} pin(s)]` : ""; + return ` ${n.name}${pinCount}: ${conns || "(no connections)"}`; }); return { content: [ @@ -1361,6 +1363,38 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, ); + // List floating net labels + server.tool( + "list_floating_labels", + "Returns all net labels in the schematic that are not connected to any component pin. " + + "A label is 'floating' when no component pin falls on the wire-network reachable from the " + + "label's position. Floating labels indicate misplaced or off-grid labels that cause ERC errors. " + + "Does not require the KiCAD UI to be running.", + { + schematicPath: z.string().describe("Path to the .kicad_sch schematic file"), + }, + async (args: { schematicPath: string }) => { + const result = await callKicadScript("list_floating_labels", args); + if (result.success) { + const labels: any[] = result.floating_labels || []; + if (labels.length === 0) { + return { content: [{ type: "text", text: "No floating labels found." }] }; + } + const lines: string[] = [`Found ${labels.length} floating label(s):\n`]; + labels.slice(0, 50).forEach((lbl: any) => { + lines.push(` "${lbl.name}" (${lbl.type}) at (${lbl.x}, ${lbl.y})`); + }); + if (labels.length > 50) { + lines.push(` ... and ${labels.length - 50} more`); + } + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + return { + content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }], + }; + }, + ); + // Find orphaned wires server.tool( "find_orphaned_wires", diff --git a/tests/test_net_connectivity.py b/tests/test_net_connectivity.py new file mode 100644 index 0000000..3750f3c --- /dev/null +++ b/tests/test_net_connectivity.py @@ -0,0 +1,516 @@ +""" +Tests for connected_pin_count in list_schematic_nets and the list_floating_labels tool. + +Covers: + - Schema registration for list_floating_labels (TestListFloatingLabelsSchema) + - Handler dispatch registration (TestListFloatingLabelsDispatch) + - Parameter validation (TestListFloatingLabelsParamValidation) + - Core logic: list_floating_labels (TestListFloatingLabelsCoreLogic) + - Core logic: count_pins_on_net (TestCountPinsOnNet) + - connected_pin_count field in list_schematic_nets handler (TestListSchematicNetsConnectedPinCount) + - Integration: floating labels in a real schematic file (TestListFloatingLabelsIntegration) +""" + +import shutil +import sys +import tempfile +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")) + +from commands.wire_connectivity import ( + _build_adjacency, + _parse_virtual_connections, + _parse_wires, + count_pins_on_net, + list_floating_labels, +) + +# --------------------------------------------------------------------------- +# Shared mock helpers +# --------------------------------------------------------------------------- + +TEMPLATE_SCH = Path(__file__).parent.parent / "python" / "templates" / "empty.kicad_sch" + + +def _make_point(x: float, y: float) -> MagicMock: + pt = MagicMock() + pt.value = [x, y] + return pt + + +def _make_wire(x1: float, y1: float, x2: float, y2: float) -> MagicMock: + wire = MagicMock() + wire.pts = MagicMock() + wire.pts.xy = [_make_point(x1, y1), _make_point(x2, y2)] + return wire + + +def _make_label(name: str, x: float, y: float) -> MagicMock: + label = MagicMock() + label.value = name + label.at = MagicMock() + label.at.value = [x, y, 0] + return label + + +def _make_schematic_no_labels_no_symbols(*wires: Any) -> MagicMock: + sch = MagicMock() + sch.wire = list(wires) + del sch.label + del sch.symbol + return sch + + +# --------------------------------------------------------------------------- +# TestListFloatingLabelsSchema +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestListFloatingLabelsSchema: + """Verify the list_floating_labels schema is registered and well-formed.""" + + def test_schema_registered(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + assert "list_floating_labels" in TOOL_SCHEMAS + + def test_schema_required_fields(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + required = TOOL_SCHEMAS["list_floating_labels"]["inputSchema"]["required"] + assert required == ["schematicPath"] + + def test_schema_has_title_and_description(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + schema = TOOL_SCHEMAS["list_floating_labels"] + assert schema.get("title") + assert schema.get("description") + + +# --------------------------------------------------------------------------- +# TestListFloatingLabelsDispatch +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestListFloatingLabelsDispatch: + """Verify the handler is wired into KiCadInterface.command_routes.""" + + def test_list_floating_labels_in_routes(self) -> None: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + iface.board = None + iface.project_filename = None + iface.use_ipc = False + iface.ipc_backend = MagicMock() + iface.ipc_board_api = None + iface.footprint_library = MagicMock() + iface.project_commands = MagicMock() + iface.board_commands = MagicMock() + iface.component_commands = MagicMock() + iface.routing_commands = MagicMock() + KiCADInterface.__init__(iface) + + assert "list_floating_labels" in iface.command_routes + assert callable(iface.command_routes["list_floating_labels"]) + + +# --------------------------------------------------------------------------- +# TestListFloatingLabelsParamValidation +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestListFloatingLabelsParamValidation: + """Handler returns error for missing schematicPath.""" + + def _make_handler(self) -> Any: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + return iface._handle_list_floating_labels + + def test_missing_schematic_path(self) -> None: + handler = self._make_handler() + result = handler({}) + assert result["success"] is False + assert "schematicPath" in result["message"] + + def test_bad_schematic_path_returns_error(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/nonexistent/path/test.kicad_sch"}) + assert result["success"] is False + + +# --------------------------------------------------------------------------- +# TestListFloatingLabelsCoreLogic +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestListFloatingLabelsCoreLogic: + """Unit tests for the list_floating_labels function.""" + + def test_no_labels_returns_empty(self) -> None: + sch = _make_schematic_no_labels_no_symbols() + result = list_floating_labels(sch, "/tmp/test.kicad_sch") + assert result == [] + + def test_label_with_no_wires_and_no_pins_is_floating(self) -> None: + label = _make_label("SDA", 10.0, 5.0) + sch = MagicMock() + sch.wire = [] + sch.label = [label] + del sch.symbol + result = list_floating_labels(sch, "/tmp/test.kicad_sch") + assert len(result) == 1 + assert result[0]["name"] == "SDA" + assert result[0]["x"] == pytest.approx(10.0) + assert result[0]["y"] == pytest.approx(5.0) + assert result[0]["type"] == "label" + + def test_label_connected_to_pin_not_floating(self) -> None: + """Label at (0,0) connected to a pin at (2,0) via wire should NOT be floating.""" + wire = _make_wire(0.0, 0.0, 2.0, 0.0) + label = _make_label("SCL", 0.0, 0.0) + + sch = MagicMock() + sch.wire = [wire] + sch.label = [label] + + # Mock a symbol whose pin is at (2, 0) + symbol = MagicMock() + symbol.property = MagicMock() + symbol.property.Reference = MagicMock() + symbol.property.Reference.value = "U1" + sch.symbol = [symbol] + + with patch( + "commands.pin_locator.PinLocator.get_all_symbol_pins", + return_value={"1": (2.0, 0.0)}, + ): + result = list_floating_labels(sch, "/tmp/test.kicad_sch") + + assert result == [] + + def test_label_not_connected_to_any_pin_is_floating(self) -> None: + """Label at (0,0) with no wires to any pin should be floating.""" + label = _make_label("MOSI", 0.0, 0.0) + wire = _make_wire(0.0, 0.0, 1.0, 0.0) + + sch = MagicMock() + sch.wire = [wire] + sch.label = [label] + + # A symbol whose pin is at a completely different location + symbol = MagicMock() + symbol.property = MagicMock() + symbol.property.Reference = MagicMock() + symbol.property.Reference.value = "U2" + sch.symbol = [symbol] + + with patch( + "commands.pin_locator.PinLocator.get_all_symbol_pins", + return_value={"1": (99.0, 99.0)}, + ): + result = list_floating_labels(sch, "/tmp/test.kicad_sch") + + assert len(result) == 1 + assert result[0]["name"] == "MOSI" + + def test_label_directly_on_pin_not_floating(self) -> None: + """Label placed directly at a pin position (no wire needed) should NOT be floating.""" + label = _make_label("PWR", 5.0, 3.0) + + sch = MagicMock() + sch.wire = [] + sch.label = [label] + + symbol = MagicMock() + symbol.property = MagicMock() + symbol.property.Reference = MagicMock() + symbol.property.Reference.value = "R1" + sch.symbol = [symbol] + + # Pin is exactly at the label position + with patch( + "commands.pin_locator.PinLocator.get_all_symbol_pins", + return_value={"1": (5.0, 3.0)}, + ): + result = list_floating_labels(sch, "/tmp/test.kicad_sch") + + assert result == [] + + def test_multiple_labels_mixed_floating_and_connected(self) -> None: + """Two labels: one connected, one floating.""" + label_connected = _make_label("NET_A", 0.0, 0.0) + label_floating = _make_label("NET_B", 20.0, 20.0) + wire = _make_wire(0.0, 0.0, 2.0, 0.0) + + sch = MagicMock() + sch.wire = [wire] + sch.label = [label_connected, label_floating] + + symbol = MagicMock() + symbol.property = MagicMock() + symbol.property.Reference = MagicMock() + symbol.property.Reference.value = "C1" + sch.symbol = [symbol] + + with patch( + "commands.pin_locator.PinLocator.get_all_symbol_pins", + return_value={"1": (2.0, 0.0)}, + ): + result = list_floating_labels(sch, "/tmp/test.kicad_sch") + + assert len(result) == 1 + assert result[0]["name"] == "NET_B" + + def test_template_symbols_skipped(self) -> None: + """Symbols with _TEMPLATE references should be skipped, not crash.""" + label = _make_label("VBUS", 0.0, 0.0) + + sch = MagicMock() + sch.wire = [] + sch.label = [label] + + template_sym = MagicMock() + template_sym.property = MagicMock() + template_sym.property.Reference = MagicMock() + template_sym.property.Reference.value = "_TEMPLATE_R" + sch.symbol = [template_sym] + + with patch( + "commands.pin_locator.PinLocator.get_all_symbol_pins", + return_value={"1": (0.0, 0.0)}, + ) as mock_pins: + result = list_floating_labels(sch, "/tmp/test.kicad_sch") + + # _TEMPLATE_ symbols are skipped; mock_pins should not have been called + mock_pins.assert_not_called() + assert len(result) == 1 + + +# --------------------------------------------------------------------------- +# TestCountPinsOnNet +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestCountPinsOnNet: + """Unit tests for count_pins_on_net.""" + + def _build_graph(self, sch: Any, schematic_path: str): # type: ignore[return] + all_wires = _parse_wires(sch) + if all_wires: + adjacency, iu_to_wires = _build_adjacency(all_wires) + else: + adjacency, iu_to_wires = [], {} + point_to_label, label_to_points = _parse_virtual_connections(sch, schematic_path) + return all_wires, iu_to_wires, adjacency, point_to_label, label_to_points + + def test_no_labels_returns_zero(self) -> None: + sch = _make_schematic_no_labels_no_symbols() + all_wires, iu_to_wires, adj, p2l, l2p = self._build_graph(sch, "/tmp/t.kicad_sch") + count = count_pins_on_net( + sch, "/tmp/t.kicad_sch", "VCC", all_wires, iu_to_wires, adj, p2l, l2p + ) + assert count == 0 + + def test_unknown_net_returns_zero(self) -> None: + wire = _make_wire(0.0, 0.0, 1.0, 0.0) + label = _make_label("SDA", 0.0, 0.0) + sch = MagicMock() + sch.wire = [wire] + sch.label = [label] + del sch.symbol + all_wires, iu_to_wires, adj, p2l, l2p = self._build_graph(sch, "/tmp/t.kicad_sch") + count = count_pins_on_net( + sch, "/tmp/t.kicad_sch", "UNKNOWN_NET", all_wires, iu_to_wires, adj, p2l, l2p + ) + assert count == 0 + + def test_counts_pin_via_wire(self) -> None: + """Label at (0,0), wire to (2,0), pin at (2,0) → count == 1.""" + wire = _make_wire(0.0, 0.0, 2.0, 0.0) + label = _make_label("SCL", 0.0, 0.0) + sch = MagicMock() + sch.wire = [wire] + sch.label = [label] + symbol = MagicMock() + symbol.property = MagicMock() + symbol.property.Reference = MagicMock() + symbol.property.Reference.value = "U1" + sch.symbol = [symbol] + all_wires, iu_to_wires, adj, p2l, l2p = self._build_graph(sch, "/tmp/t.kicad_sch") + with patch( + "commands.pin_locator.PinLocator.get_all_symbol_pins", + return_value={"3": (2.0, 0.0)}, + ): + count = count_pins_on_net( + sch, "/tmp/t.kicad_sch", "SCL", all_wires, iu_to_wires, adj, p2l, l2p + ) + assert count == 1 + + def test_no_symbol_attribute_returns_zero(self) -> None: + wire = _make_wire(0.0, 0.0, 2.0, 0.0) + label = _make_label("SDA", 0.0, 0.0) + sch = MagicMock() + sch.wire = [wire] + sch.label = [label] + del sch.symbol + all_wires, iu_to_wires, adj, p2l, l2p = self._build_graph(sch, "/tmp/t.kicad_sch") + count = count_pins_on_net( + sch, "/tmp/t.kicad_sch", "SDA", all_wires, iu_to_wires, adj, p2l, l2p + ) + assert count == 0 + + +# --------------------------------------------------------------------------- +# TestListSchematicNetsConnectedPinCount +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestListSchematicNetsConnectedPinCount: + """Verify connected_pin_count is present in list_schematic_nets response.""" + + def _make_handler(self) -> Any: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + return iface._handle_list_schematic_nets + + def test_connected_pin_count_present_in_response(self) -> None: + handler = self._make_handler() + + label = _make_label("NET1", 0.0, 0.0) + mock_sch = MagicMock() + mock_sch.wire = [] + mock_sch.label = [label] + del mock_sch.global_label + del mock_sch.symbol + + with ( + patch("kicad_interface.SchematicManager.load_schematic", return_value=mock_sch), + patch( + "kicad_interface.ConnectionManager.get_net_connections", + return_value=[], + ), + ): + result = handler({"schematicPath": "/tmp/test.kicad_sch"}) + + assert result["success"] is True + assert len(result["nets"]) == 1 + net = result["nets"][0] + assert "connected_pin_count" in net + assert isinstance(net["connected_pin_count"], int) + + def test_connected_pin_count_is_zero_when_no_pins(self) -> None: + handler = self._make_handler() + + label = _make_label("ORPHAN_NET", 50.0, 50.0) + mock_sch = MagicMock() + mock_sch.wire = [] + mock_sch.label = [label] + del mock_sch.global_label + del mock_sch.symbol + + with ( + patch("kicad_interface.SchematicManager.load_schematic", return_value=mock_sch), + patch( + "kicad_interface.ConnectionManager.get_net_connections", + return_value=[], + ), + ): + result = handler({"schematicPath": "/tmp/test.kicad_sch"}) + + assert result["success"] is True + assert result["nets"][0]["connected_pin_count"] == 0 + + +# --------------------------------------------------------------------------- +# TestListFloatingLabelsIntegration +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestListFloatingLabelsIntegration: + """Integration tests using a real .kicad_sch file.""" + + def _make_sch_with_floating_label(self, tmp_path: Path) -> Path: + """Copy the empty template and append a floating label.""" + sch_path = tmp_path / "test.kicad_sch" + shutil.copy(TEMPLATE_SCH, sch_path) + content = sch_path.read_text(encoding="utf-8") + floating_label = ( + ' (label "FLOATING_NET" (at 100 100 0)\n' + " (effects (font (size 1.27 1.27)))\n" + " (uuid 11111111-0000-0000-0000-000000000001)\n" + " )" + ) + idx = content.rfind(")") + content = content[:idx] + "\n" + floating_label + "\n)" + sch_path.write_text(content, encoding="utf-8") + return sch_path + + def test_empty_schematic_has_no_floating_labels(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + sch_path = Path(tmp) / "empty.kicad_sch" + shutil.copy(TEMPLATE_SCH, sch_path) + + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + result = iface._handle_list_floating_labels({"schematicPath": str(sch_path)}) + + assert result["success"] is True + assert result["count"] == 0 + assert result["floating_labels"] == [] + + def test_schematic_with_floating_label_detected(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + sch_path = self._make_sch_with_floating_label(Path(tmp)) + + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + result = iface._handle_list_floating_labels({"schematicPath": str(sch_path)}) + + assert result["success"] is True + assert result["count"] == 1 + label = result["floating_labels"][0] + assert label["name"] == "FLOATING_NET" + assert label["x"] == pytest.approx(100.0) + assert label["y"] == pytest.approx(100.0) + assert label["type"] == "label" + + def test_list_schematic_nets_has_connected_pin_count(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + sch_path = self._make_sch_with_floating_label(Path(tmp)) + + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + result = iface._handle_list_schematic_nets({"schematicPath": str(sch_path)}) + + assert result["success"] is True + assert result["count"] == 1 + net = result["nets"][0] + assert net["name"] == "FLOATING_NET" + assert "connected_pin_count" in net + assert net["connected_pin_count"] == 0