feat: add connected_pin_count to list_schematic_nets and list_floating_labels tool
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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}}
|
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 '<unknown>'}: {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(
|
def get_net_at_point(
|
||||||
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
|
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -406,6 +406,7 @@ class KiCADInterface:
|
|||||||
"get_elements_in_region": self._handle_get_elements_in_region,
|
"get_elements_in_region": self._handle_get_elements_in_region,
|
||||||
"find_wires_crossing_symbols": self._handle_find_wires_crossing_symbols,
|
"find_wires_crossing_symbols": self._handle_find_wires_crossing_symbols,
|
||||||
"find_orphaned_wires": self._handle_find_orphaned_wires,
|
"find_orphaned_wires": self._handle_find_orphaned_wires,
|
||||||
|
"list_floating_labels": self._handle_list_floating_labels,
|
||||||
"snap_to_grid": self._handle_snap_to_grid,
|
"snap_to_grid": self._handle_snap_to_grid,
|
||||||
"import_svg_logo": self._handle_import_svg_logo,
|
"import_svg_logo": self._handle_import_svg_logo,
|
||||||
# UI/Process management commands
|
# UI/Process management commands
|
||||||
@@ -1924,6 +1925,13 @@ class KiCADInterface:
|
|||||||
try:
|
try:
|
||||||
from pathlib import Path
|
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")
|
schematic_path = params.get("schematicPath")
|
||||||
if not schematic_path:
|
if not schematic_path:
|
||||||
return {"success": False, "message": "schematicPath is required"}
|
return {"success": False, "message": "schematicPath is required"}
|
||||||
@@ -1943,15 +1951,34 @@ class KiCADInterface:
|
|||||||
if hasattr(label, "value"):
|
if hasattr(label, "value"):
|
||||||
net_names.add(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 = []
|
nets = []
|
||||||
for net_name in sorted(net_names):
|
for net_name in sorted(net_names):
|
||||||
connections = ConnectionManager.get_net_connections(
|
connections = ConnectionManager.get_net_connections(
|
||||||
schematic, net_name, Path(schematic_path)
|
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(
|
nets.append(
|
||||||
{
|
{
|
||||||
"name": net_name,
|
"name": net_name,
|
||||||
"connections": connections,
|
"connections": connections,
|
||||||
|
"connected_pin_count": pin_count,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3063,6 +3090,34 @@ class KiCADInterface:
|
|||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
return {"success": False, "message": str(e)}
|
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]:
|
def _handle_snap_to_grid(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Snap schematic element coordinates to the nearest grid point"""
|
"""Snap schematic element coordinates to the nearest grid point"""
|
||||||
logger.info("Snapping schematic elements to grid")
|
logger.info("Snapping schematic elements to grid")
|
||||||
|
|||||||
@@ -1881,6 +1881,27 @@ SCHEMATIC_TOOLS = [
|
|||||||
"required": ["schematicPath"],
|
"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",
|
"name": "snap_to_grid",
|
||||||
"title": "Snap Schematic Elements to Grid",
|
"title": "Snap Schematic Elements to Grid",
|
||||||
|
|||||||
@@ -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 lines = nets.map((n: any) => {
|
||||||
const conns = (n.connections || []).map((c: any) => `${c.component}/${c.pin}`).join(", ");
|
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 {
|
return {
|
||||||
content: [
|
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
|
// Find orphaned wires
|
||||||
server.tool(
|
server.tool(
|
||||||
"find_orphaned_wires",
|
"find_orphaned_wires",
|
||||||
|
|||||||
516
tests/test_net_connectivity.py
Normal file
516
tests/test_net_connectivity.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user