refactor: consolidate get_pin_net into get_wire_connections

get_pin_net was a superset of get_wire_connections with the same
coordinate-based flood-fill but two extra response fields (net, query_point)
and a reference+pin input mode. Having both tools confused LLM tool selection.

get_wire_connections now:
- Returns net (label name or null) and query_point in all response paths
- Accepts reference+pin input in addition to x/y coordinates,
  resolving the pin endpoint via PinLocator internally

get_pin_net tool, handler, schema, TS registration, and tests removed.
test_wire_connectivity.py updated with coverage for all new behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Eugene Mikhantyev
2026-04-12 17:02:35 +01:00
parent f73d8d9795
commit 4f733bb3db
6 changed files with 259 additions and 569 deletions

View File

@@ -220,7 +220,7 @@ def _find_pins_on_net(
def get_wire_connections( def get_wire_connections(
schematic: Any, schematic_path: str, x_mm: float, y_mm: float schematic: Any, schematic_path: str, x_mm: float, y_mm: float
) -> Optional[Dict]: ) -> Optional[Dict]:
"""Find all component pins reachable from a point via connected wires, net labels, and power symbols. """Find the net name and all component pins reachable from a point via connected wires.
The query point (x_mm, y_mm) must be exactly on a wire endpoint or junction (exact IU match). The query point (x_mm, y_mm) must be exactly on a wire endpoint or junction (exact IU match).
Interior (mid-segment) points are not matched — Interior (mid-segment) points are not matched —
@@ -229,67 +229,20 @@ def get_wire_connections(
Net labels and power symbols are traversed: wires on the same named net are Net labels and power symbols are traversed: wires on the same named net are
treated as connected even when they are not geometrically adjacent. treated as connected even when they are not geometrically adjacent.
Returns dict with keys:
- "pins": list of {"component": str, "pin": str}
- "wires": list of {"start": {"x", "y"}, "end": {"x", "y"}} in mm
Or None if no wire endpoint found within tolerance of the query point.
"""
all_wires = _parse_wires(schematic)
if not all_wires:
return {"pins": [], "wires": []}
adjacency, iu_to_wires = _build_adjacency(all_wires)
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
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 visited is None:
return None
wires_out = [
{
"start": {
"x": all_wires[i][0][0] / _IU_PER_MM,
"y": all_wires[i][0][1] / _IU_PER_MM,
},
"end": {
"x": all_wires[i][-1][0] / _IU_PER_MM,
"y": all_wires[i][-1][1] / _IU_PER_MM,
},
}
for i in visited
]
if not hasattr(schematic, "symbol"):
return {"pins": [], "wires": wires_out}
pins = _find_pins_on_net(net_points, schematic_path, schematic)
return {"pins": pins, "wires": wires_out}
def get_pin_net(schematic: Any, schematic_path: str, x_mm: float, y_mm: float) -> Optional[Dict]:
"""Return the net name and all connected pins for the wire network at (x_mm, y_mm).
Returns dict with keys: Returns dict with keys:
- "net": str or None (net label/power name, None if unnamed) - "net": str or None (net label/power name, None if unnamed)
- "pins": list of {"component": str, "pin": str} - "pins": list of {"component": str, "pin": str}
- "wires": list of {"start": {"x", "y"}, "end": {"x", "y"}} in mm - "wires": list of {"start": {"x", "y"}, "end": {"x", "y"}} in mm
- "query_point": {"x": float, "y": float} - "query_point": {"x": float, "y": float}
Or None if no wire endpoint found at the query point. Or None if no wire endpoint found within tolerance of the query point.
""" """
all_wires = _parse_wires(schematic) all_wires = _parse_wires(schematic)
query_point = {"x": x_mm, "y": y_mm}
if not all_wires: if not all_wires:
return {"net": None, "pins": [], "wires": [], "query_point": {"x": x_mm, "y": y_mm}} return {"net": None, "pins": [], "wires": [], "query_point": query_point}
adjacency, iu_to_wires = _build_adjacency(all_wires) adjacency, iu_to_wires = _build_adjacency(all_wires)
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path) point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
visited, net_points = _find_connected_wires( visited, net_points = _find_connected_wires(
@@ -326,11 +279,11 @@ def get_pin_net(schematic: Any, schematic_path: str, x_mm: float, y_mm: float) -
for i in visited for i in visited
] ]
pins: List[Dict] = [] if not hasattr(schematic, "symbol"):
if hasattr(schematic, "symbol"): return {"net": net, "pins": [], "wires": wires_out, "query_point": query_point}
pins = _find_pins_on_net(net_points, schematic_path, schematic)
return {"net": net, "pins": pins, "wires": wires_out, "query_point": {"x": x_mm, "y": y_mm}} pins = _find_pins_on_net(net_points, schematic_path, schematic)
return {"net": net, "pins": pins, "wires": wires_out, "query_point": query_point}
def count_pins_on_net( def count_pins_on_net(

View File

@@ -383,7 +383,6 @@ class KiCADInterface:
"get_net_connections": self._handle_get_net_connections, "get_net_connections": self._handle_get_net_connections,
"get_wire_connections": self._handle_get_wire_connections, "get_wire_connections": self._handle_get_wire_connections,
"get_net_at_point": self._handle_get_net_at_point, "get_net_at_point": self._handle_get_net_at_point,
"get_pin_net": self._handle_get_pin_net,
"run_erc": self._handle_run_erc, "run_erc": self._handle_run_erc,
"generate_netlist": self._handle_generate_netlist, "generate_netlist": self._handle_generate_netlist,
"sync_schematic_to_board": self._handle_sync_schematic_to_board, "sync_schematic_to_board": self._handle_sync_schematic_to_board,
@@ -2515,29 +2514,57 @@ class KiCADInterface:
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_get_wire_connections(self, params: Dict[str, Any]) -> Dict[str, Any]: def _handle_get_wire_connections(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Find all component pins reachable from a point via connected wires""" """Find net name and all component pins reachable from a point or component pin."""
logger.info("Getting wire connections") logger.info("Getting wire connections")
try: try:
from pathlib import Path
from commands.pin_locator import PinLocator
from commands.wire_connectivity import get_wire_connections from commands.wire_connectivity import get_wire_connections
schematic_path = params.get("schematicPath") schematic_path = params.get("schematicPath")
if not schematic_path:
return {"success": False, "message": "Missing required parameter: schematicPath"}
reference = params.get("reference")
pin = params.get("pin")
x = params.get("x") x = params.get("x")
y = params.get("y") y = params.get("y")
if not (schematic_path and x is not None and y is not None): has_ref_pin = reference is not None and pin is not None
has_coords = x is not None and y is not None
if has_ref_pin and has_coords:
return { return {
"success": False, "success": False,
"message": "Missing required parameters: schematicPath, x, y", "message": "Supply either {reference, pin} or {x, y}, not both",
} }
try: if not has_ref_pin and not has_coords:
x, y = float(x), float(y) if reference is not None or pin is not None:
except (TypeError, ValueError): return {
"success": False,
"message": "Both reference and pin are required together",
}
return { return {
"success": False, "success": False,
"message": "Parameters x and y must be numeric", "message": "Must supply either {reference, pin} or {x, y}",
} }
if has_ref_pin:
location = PinLocator().get_pin_location(Path(schematic_path), reference, str(pin))
if location is None:
return {
"success": False,
"message": f"Pin {pin} not found on {reference}",
}
x, y = location[0], location[1]
else:
try:
x, y = float(x), float(y)
except (TypeError, ValueError):
return {"success": False, "message": "Parameters x and y must be numeric"}
schematic = SchematicManager.load_schematic(schematic_path) schematic = SchematicManager.load_schematic(schematic_path)
if not schematic: if not schematic:
return {"success": False, "message": "Failed to load schematic"} return {"success": False, "message": "Failed to load schematic"}
@@ -2549,7 +2576,7 @@ class KiCADInterface:
if result is None: if result is None:
return { return {
"success": False, "success": False,
"message": f"No wire found at ({x},{y}) within tolerance", "message": f"No wire found at ({x},{y}) — point may not be connected",
} }
return {"success": True, **result} return {"success": True, **result}
@@ -2595,70 +2622,6 @@ 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_get_pin_net(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Find net name and all connected pins for a reference+pin or coordinate query"""
logger.info("Getting pin net")
try:
from pathlib import Path
from commands.pin_locator import PinLocator
from commands.wire_connectivity import get_pin_net
schematic_path = params.get("schematicPath")
if not schematic_path:
return {"success": False, "message": "Missing required parameter: schematicPath"}
reference = params.get("reference")
pin = params.get("pin")
x = params.get("x")
y = params.get("y")
has_ref_pin = reference is not None and pin is not None
has_coords = x is not None and y is not None
if not has_ref_pin and not has_coords:
return {
"success": False,
"message": "Must supply either {reference, pin} or {x, y}",
}
if has_ref_pin:
location = PinLocator().get_pin_location(Path(schematic_path), reference, str(pin))
if location is None:
return {
"success": False,
"message": f"Pin {pin} not found on {reference}",
}
x, y = location[0], location[1]
else:
try:
x, y = float(x), float(y)
except (TypeError, ValueError):
return {"success": False, "message": "Parameters x and y must be numeric"}
schematic = SchematicManager.load_schematic(schematic_path)
if not schematic:
return {"success": False, "message": "Failed to load schematic"}
if not hasattr(schematic, "wire"):
return {"success": False, "message": "Schematic has no wires"}
result = get_pin_net(schematic, schematic_path, x, y)
if result is None:
return {
"success": False,
"message": f"No wire found at ({x},{y}) — pin may not be connected",
}
return {"success": True, **result}
except Exception as e:
logger.error(f"Error getting pin net: {str(e)}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_run_erc(self, params: Dict[str, Any]) -> Dict[str, Any]: def _handle_run_erc(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Run Electrical Rules Check on a schematic via kicad-cli""" """Run Electrical Rules Check on a schematic via kicad-cli"""
logger.info("Running ERC on schematic") logger.info("Running ERC on schematic")

View File

@@ -1503,24 +1503,41 @@ SCHEMATIC_TOOLS = [
{ {
"name": "get_wire_connections", "name": "get_wire_connections",
"title": "Get Wire Connections", "title": "Get Wire Connections",
"description": "Returns all wires and component pins connected to the wire at a given point, by flood-filling through touching wires.", "description": (
"Returns the net name and all wires and component pins connected at a given point. "
"Accepts either a component reference + pin number (e.g. reference='U1', pin='3') "
"or a schematic coordinate (x, y in mm). "
"The response includes: 'net' (label name or null for unnamed nets), "
"'pins' (all component pins on the net), 'wires' (all wire segments on the net), "
"and 'query_point' (the resolved coordinate used). "
"The query point must be at a wire endpoint or junction — wire midpoints are not matched. "
"Use get_schematic_pin_locations or list_schematic_wires to obtain exact endpoint coordinates."
),
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
"schematicPath": { "schematicPath": {
"type": "string", "type": "string",
"description": "Path to schematic file", "description": "Path to the schematic file (.kicad_sch)",
},
"reference": {
"type": "string",
"description": "Component reference (e.g. U1, R1). Pair with pin.",
},
"pin": {
"type": "string",
"description": "Pin number or name (e.g. '3', 'SDA'). Pair with reference.",
}, },
"x": { "x": {
"type": "number", "type": "number",
"description": "X coordinate of the point on the wire", "description": "X coordinate of a wire endpoint in mm. Pair with y.",
}, },
"y": { "y": {
"type": "number", "type": "number",
"description": "Y coordinate of the point on the wire", "description": "Y coordinate of a wire endpoint in mm. Pair with x.",
}, },
}, },
"required": ["schematicPath", "x", "y"], "required": ["schematicPath"],
}, },
}, },
{ {
@@ -1552,44 +1569,6 @@ SCHEMATIC_TOOLS = [
"required": ["schematicPath", "x", "y"], "required": ["schematicPath", "x", "y"],
}, },
}, },
{
"name": "get_pin_net",
"title": "Get Pin Net",
"description": (
"Returns the net name and all connected component pins for a query. "
"Accepts either a component reference + pin number (e.g. reference='U1', pin='3') "
"or a schematic coordinate (x, y in mm). "
"If a net label or power symbol is reachable from the query point, its name is "
"returned as 'net'; otherwise 'net' is null (unnamed net). "
"The 'pins' list contains every component pin on that same net."
),
"inputSchema": {
"type": "object",
"properties": {
"schematicPath": {
"type": "string",
"description": "Path to the schematic file (.kicad_sch)",
},
"reference": {
"type": "string",
"description": "Component reference (e.g. U1, R1). Pair with pin.",
},
"pin": {
"type": "string",
"description": "Pin number or name (e.g. '3', 'SDA'). Pair with reference.",
},
"x": {
"type": "number",
"description": "X coordinate of a wire endpoint in mm. Pair with y.",
},
"y": {
"type": "number",
"description": "Y coordinate of a wire endpoint in mm. Pair with x.",
},
},
"required": ["schematicPath"],
},
},
{ {
"name": "get_schematic_pin_locations", "name": "get_schematic_pin_locations",
"title": "Get Schematic Pin Locations", "title": "Get Schematic Pin Locations",

View File

@@ -469,24 +469,50 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
// Get wire connections // Get wire connections
server.tool( server.tool(
"get_wire_connections", "get_wire_connections",
"Find all component pins reachable from a schematic point via connected wires, net labels, and power symbols. The query point must be at a wire endpoint or junction — midpoints of wire segments are not matched. Use get_schematic_pin_locations or list_schematic_wires to obtain exact endpoint coordinates first.", "Returns the net name and all wires and component pins connected at a given point. " +
"Accepts either a component reference + pin number (e.g. reference='U1', pin='3') " +
"or a schematic coordinate (x, y in mm). " +
"Returns net=null for unnamed (unlabelled) nets. " +
"The query point must be at a wire endpoint or junction — midpoints are not matched. " +
"Use get_schematic_pin_locations or list_schematic_wires to obtain exact endpoint coordinates.",
{ {
schematicPath: z.string().describe("Path to the schematic file"), schematicPath: z.string().describe("Path to the schematic file"),
x: z.number().describe("X coordinate of a wire endpoint or junction"), reference: z
y: z.number().describe("Y coordinate of a wire endpoint or junction"), .string()
.optional()
.describe("Component reference (e.g. U1, R1). Pair with pin."),
pin: z
.string()
.optional()
.describe("Pin number or name (e.g. '3', 'SDA'). Pair with reference."),
x: z.number().optional().describe("X coordinate of a wire endpoint in mm. Pair with y."),
y: z.number().optional().describe("Y coordinate of a wire endpoint in mm. Pair with x."),
}, },
async (args: { schematicPath: string; x: number; y: number }) => { async (args: {
schematicPath: string;
reference?: string;
pin?: string;
x?: number;
y?: number;
}) => {
const result = await callKicadScript("get_wire_connections", args); const result = await callKicadScript("get_wire_connections", args);
if (result.success && result.pins) { if (result.success) {
const pinList = result.pins.map((p: any) => ` - ${p.component}/${p.pin}`).join("\n"); const netLabel = result.net ?? "(unnamed)";
const pinList = (result.pins ?? [])
.map((p: any) => ` - ${p.component}/${p.pin}`)
.join("\n");
const wireList = (result.wires ?? []) const wireList = (result.wires ?? [])
.map((w: any) => ` - (${w.start.x},${w.start.y}) → (${w.end.x},${w.end.y})`) .map((w: any) => ` - (${w.start.x},${w.start.y}) → (${w.end.x},${w.end.y})`)
.join("\n"); .join("\n");
const qp = result.query_point;
return { return {
content: [ content: [
{ {
type: "text", type: "text",
text: `Pins connected at (${args.x},${args.y}):\n${pinList || " (none found)"}\n\nWire segments:\n${wireList || " (none)"}`, text:
`Net: ${netLabel}\n` +
`Query point: (${qp?.x ?? args.x}, ${qp?.y ?? args.y})\n` +
`Connected pins:\n${pinList || " (none found)"}\n\nWire segments:\n${wireList || " (none)"}`,
}, },
], ],
}; };
@@ -1498,61 +1524,4 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
} }
}, },
); );
server.tool(
"get_pin_net",
"Returns the net name and all connected pins for a component pin (reference + pin number) " +
"or a schematic coordinate (x, y in mm). Use this instead of list_schematic_nets + " +
"get_wire_connections when you want to answer 'what net is pin 3 of U1 on?'. " +
"Returns net=null for unnamed (unlabelled) nets.",
{
schematicPath: z.string().describe("Path to the schematic file"),
reference: z
.string()
.optional()
.describe("Component reference (e.g. U1, R1). Pair with pin."),
pin: z
.string()
.optional()
.describe("Pin number or name (e.g. '3', 'SDA'). Pair with reference."),
x: z.number().optional().describe("X coordinate of a wire endpoint in mm. Pair with y."),
y: z.number().optional().describe("Y coordinate of a wire endpoint in mm. Pair with x."),
},
async (args: {
schematicPath: string;
reference?: string;
pin?: string;
x?: number;
y?: number;
}) => {
const result = await callKicadScript("get_pin_net", args);
if (result.success) {
const netLabel = result.net ?? "(unnamed)";
const pinList = (result.pins ?? [])
.map((p: any) => ` - ${p.component}/${p.pin}`)
.join("\n");
const qp = result.query_point;
return {
content: [
{
type: "text",
text:
`Net: ${netLabel}\n` +
`Query point: (${qp?.x ?? args.x}, ${qp?.y ?? args.y})\n` +
`Connected pins:\n${pinList || " (none found)"}`,
},
],
};
} else {
return {
content: [
{
type: "text",
text: `Failed to get pin net: ${result.message || "Unknown error"}`,
},
],
};
}
},
);
} }

View File

@@ -1,325 +0,0 @@
"""
Tests for the get_pin_net tool and its handler.
Covers:
- Schema shape (TestGetPinNetSchema)
- Handler dispatch registration (TestGetPinNetHandlerDispatch)
- Parameter validation in the handler (TestGetPinNetHandlerParamValidation)
- Core logic: get_pin_net function (TestGetPinNetCoreLogic)
- Reference+pin resolution path (TestGetPinNetHandlerRefPinResolution)
"""
import sys
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 get_pin_net
# ---------------------------------------------------------------------------
# Shared mock helpers (mirrors test_wire_connectivity.py)
# ---------------------------------------------------------------------------
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_schematic(*wires: Any) -> MagicMock:
sch = MagicMock()
sch.wire = list(wires)
del sch.label
del sch.symbol
return sch
# ---------------------------------------------------------------------------
# TestGetPinNetSchema
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetPinNetSchema:
"""Verify the get_pin_net tool schema is present and well-formed."""
def test_schema_registered(self) -> None:
from schemas.tool_schemas import TOOL_SCHEMAS
assert "get_pin_net" in TOOL_SCHEMAS
def test_schema_required_fields(self) -> None:
from schemas.tool_schemas import TOOL_SCHEMAS
required = TOOL_SCHEMAS["get_pin_net"]["inputSchema"]["required"]
assert required == ["schematicPath"]
def test_schema_has_title_and_description(self) -> None:
from schemas.tool_schemas import TOOL_SCHEMAS
schema = TOOL_SCHEMAS["get_pin_net"]
assert schema.get("title")
assert schema.get("description")
def test_schema_has_optional_fields(self) -> None:
from schemas.tool_schemas import TOOL_SCHEMAS
props = TOOL_SCHEMAS["get_pin_net"]["inputSchema"]["properties"]
for field in ("reference", "pin", "x", "y"):
assert field in props, f"Expected '{field}' in schema properties"
# ---------------------------------------------------------------------------
# TestGetPinNetHandlerDispatch
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetPinNetHandlerDispatch:
"""Verify the handler is wired into KiCadInterface.command_routes."""
def test_get_pin_net_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 "get_pin_net" in iface.command_routes
assert callable(iface.command_routes["get_pin_net"])
# ---------------------------------------------------------------------------
# TestGetPinNetHandlerParamValidation
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetPinNetHandlerParamValidation:
"""Handler returns error responses for bad or missing parameters."""
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_get_pin_net
def test_missing_schematic_path(self) -> None:
handler = self._make_handler()
result = handler({"x": 1.0, "y": 2.0})
assert result["success"] is False
assert "schematicPath" in result["message"] or "Missing" in result["message"]
def test_missing_both_modes(self) -> None:
handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch"})
assert result["success"] is False
def test_partial_ref_only(self) -> None:
handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch", "reference": "U1"})
assert result["success"] is False
def test_partial_pin_only(self) -> None:
handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch", "pin": "3"})
assert result["success"] is False
def test_partial_x_only(self) -> None:
handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0})
assert result["success"] is False
def test_partial_y_only(self) -> None:
handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 1.0})
assert result["success"] is False
def test_non_numeric_coords(self) -> None:
handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "bad", "y": 2.0})
assert result["success"] is False
assert "numeric" in result["message"].lower() or "x" in result["message"]
# ---------------------------------------------------------------------------
# TestGetPinNetCoreLogic
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetPinNetCoreLogic:
"""Unit tests for the get_pin_net function."""
def test_no_wires_returns_empty_dict(self) -> None:
sch = MagicMock()
sch.wire = []
del sch.label
del sch.symbol
result = get_pin_net(sch, "/tmp/test.kicad_sch", 1.0, 2.0)
assert result is not None
assert result["net"] is None
assert result["pins"] == []
assert result["wires"] == []
assert result["query_point"] == {"x": 1.0, "y": 2.0}
def test_no_wire_at_point_returns_none(self) -> None:
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
# Query a midpoint — not on a wire endpoint
result = get_pin_net(sch, "/tmp/test.kicad_sch", 0.5, 0.0)
assert result is None
def test_unnamed_net_no_labels(self) -> None:
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
result = get_pin_net(sch, "/tmp/test.kicad_sch", 0.0, 0.0)
assert result is not None
assert result["net"] is None
def test_net_name_from_label(self) -> None:
"""Wire with a net label at one endpoint should yield that label as net name."""
wire = _make_wire(0.0, 0.0, 1.0, 0.0)
label = MagicMock()
label.value = "SDA"
label.at = MagicMock()
label.at.value = [0.0, 0.0, 0] # label placed at wire start
sch = MagicMock()
sch.wire = [wire]
sch.label = [label]
del sch.symbol
result = get_pin_net(sch, "/tmp/test.kicad_sch", 0.0, 0.0)
assert result is not None
assert result["net"] == "SDA"
def test_query_point_in_result(self) -> None:
sch = _make_schematic(_make_wire(5.0, 3.0, 6.0, 3.0))
result = get_pin_net(sch, "/tmp/test.kicad_sch", 5.0, 3.0)
assert result is not None
assert result["query_point"] == {"x": 5.0, "y": 3.0}
def test_result_has_pins_and_wires_keys(self) -> None:
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
result = get_pin_net(sch, "/tmp/test.kicad_sch", 0.0, 0.0)
assert result is not None
assert "pins" in result
assert "wires" in result
def test_wires_returned_in_mm(self) -> None:
sch = _make_schematic(_make_wire(2.0, 3.0, 4.0, 3.0))
result = get_pin_net(sch, "/tmp/test.kicad_sch", 2.0, 3.0)
assert result is not None
assert len(result["wires"]) == 1
w = result["wires"][0]
assert w["start"]["x"] == pytest.approx(2.0)
assert w["start"]["y"] == pytest.approx(3.0)
assert w["end"]["x"] == pytest.approx(4.0)
assert w["end"]["y"] == pytest.approx(3.0)
# ---------------------------------------------------------------------------
# TestGetPinNetHandlerRefPinResolution
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetPinNetHandlerRefPinResolution:
"""Test the reference+pin → coordinate resolution path in the handler."""
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_get_pin_net
def test_ref_pin_resolves_to_coordinates(self) -> None:
handler = self._make_handler()
mock_result = {
"net": "SDA",
"pins": [{"component": "U1", "pin": "3"}],
"wires": [],
"query_point": {"x": 10.5, "y": 15.2},
}
with (
patch(
"commands.pin_locator.PinLocator.get_pin_location",
return_value=[10.5, 15.2],
),
patch("kicad_interface.SchematicManager.load_schematic") as mock_load,
patch("commands.wire_connectivity.get_pin_net", return_value=mock_result) as mock_gpn,
):
mock_sch = MagicMock()
mock_sch.wire = [_make_wire(10.5, 15.2, 11.5, 15.2)]
mock_load.return_value = mock_sch
result = handler(
{"schematicPath": "/tmp/test.kicad_sch", "reference": "U1", "pin": "3"}
)
assert result["success"] is True
mock_gpn.assert_called_once()
call_args = mock_gpn.call_args
assert call_args[0][2] == pytest.approx(10.5)
assert call_args[0][3] == pytest.approx(15.2)
def test_ref_pin_not_found(self) -> None:
handler = self._make_handler()
with patch(
"commands.pin_locator.PinLocator.get_pin_location",
return_value=None,
):
result = handler(
{"schematicPath": "/tmp/test.kicad_sch", "reference": "U1", "pin": "99"}
)
assert result["success"] is False
assert "U1" in result["message"] or "99" in result["message"]
def test_coordinate_mode_passes_floats(self) -> None:
handler = self._make_handler()
mock_result = {
"net": None,
"pins": [],
"wires": [],
"query_point": {"x": 10.5, "y": 15.2},
}
with (
patch("kicad_interface.SchematicManager.load_schematic") as mock_load,
patch("commands.wire_connectivity.get_pin_net", return_value=mock_result) as mock_gpn,
):
mock_sch = MagicMock()
mock_sch.wire = [_make_wire(10.5, 15.2, 11.5, 15.2)]
mock_load.return_value = mock_sch
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "10.5", "y": "15.2"})
assert result["success"] is True
call_args = mock_gpn.call_args
assert isinstance(call_args[0][2], float)
assert isinstance(call_args[0][3], float)
assert call_args[0][2] == pytest.approx(10.5)
assert call_args[0][3] == pytest.approx(15.2)

View File

@@ -7,6 +7,8 @@ Covers:
- Parameter validation in the handler (TestHandlerParamValidation) - Parameter validation in the handler (TestHandlerParamValidation)
- Core logic: _to_iu, _parse_wires, _build_adjacency, _find_connected_wires, - Core logic: _to_iu, _parse_wires, _build_adjacency, _find_connected_wires,
get_wire_connections (TestCoreLogic) get_wire_connections (TestCoreLogic)
- New net/query_point fields and reference+pin input mode (TestGetWireConnectionsNewFields,
TestGetWireConnectionsHandlerRefPinMode)
""" """
import sys import sys
@@ -77,8 +79,23 @@ class TestSchema:
schema = TOOL_SCHEMAS["get_wire_connections"] schema = TOOL_SCHEMAS["get_wire_connections"]
required = schema["inputSchema"]["required"] required = schema["inputSchema"]["required"]
assert "schematicPath" in required assert "schematicPath" in required
assert "x" in required # x, y and reference, pin are all optional (dual-mode input)
assert "y" in required assert "x" not in required
assert "y" not in required
def test_schema_optional_fields(self) -> None:
from schemas.tool_schemas import TOOL_SCHEMAS
props = TOOL_SCHEMAS["get_wire_connections"]["inputSchema"]["properties"]
assert "reference" in props
assert "pin" in props
assert "x" in props
assert "y" in props
def test_get_pin_net_not_registered(self) -> None:
from schemas.tool_schemas import TOOL_SCHEMAS
assert "get_pin_net" not in TOOL_SCHEMAS
def test_schema_has_title_and_description(self) -> None: def test_schema_has_title_and_description(self) -> None:
from schemas.tool_schemas import TOOL_SCHEMAS from schemas.tool_schemas import TOOL_SCHEMAS
@@ -145,14 +162,24 @@ class TestHandlerParamValidation:
assert result["success"] is False assert result["success"] is False
assert "schematicPath" in result["message"] or "Missing" in result["message"] assert "schematicPath" in result["message"] or "Missing" in result["message"]
def test_missing_x(self) -> None: def test_missing_both_modes(self) -> None:
handler = self._make_handler() handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 2.0}) result = handler({"schematicPath": "/tmp/test.kicad_sch"})
assert result["success"] is False
assert (
"reference" in result["message"]
or "x" in result["message"]
or "supply" in result["message"].lower()
)
def test_partial_reference_without_pin(self) -> None:
handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch", "reference": "U1"})
assert result["success"] is False assert result["success"] is False
def test_missing_y(self) -> None: def test_partial_pin_without_reference(self) -> None:
handler = self._make_handler() handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0}) result = handler({"schematicPath": "/tmp/test.kicad_sch", "pin": "3"})
assert result["success"] is False assert result["success"] is False
def test_non_numeric_x(self) -> None: def test_non_numeric_x(self) -> None:
@@ -304,7 +331,11 @@ class TestCoreLogic:
sch = MagicMock() sch = MagicMock()
sch.wire = [] sch.wire = []
result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0)
assert result == {"pins": [], "wires": []} assert result is not None
assert result["pins"] == []
assert result["wires"] == []
assert result["net"] is None
assert result["query_point"] == {"x": 0.0, "y": 0.0}
def test_get_wire_connections_no_wire_at_point_returns_none(self) -> None: def test_get_wire_connections_no_wire_at_point_returns_none(self) -> None:
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
@@ -313,7 +344,6 @@ class TestCoreLogic:
def test_get_wire_connections_returns_wire_data(self) -> None: def test_get_wire_connections_returns_wire_data(self) -> None:
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
# Prevent _find_pins_on_net from iterating symbols
result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0)
assert result is not None assert result is not None
assert result["pins"] == [] assert result["pins"] == []
@@ -321,6 +351,8 @@ class TestCoreLogic:
wire = result["wires"][0] wire = result["wires"][0]
assert wire["start"] == {"x": 0.0, "y": 0.0} assert wire["start"] == {"x": 0.0, "y": 0.0}
assert wire["end"] == {"x": 1.0, "y": 0.0} assert wire["end"] == {"x": 1.0, "y": 0.0}
assert "net" in result
assert "query_point" in result
def test_get_wire_connections_chain_returns_all_wires(self) -> None: def test_get_wire_connections_chain_returns_all_wires(self) -> None:
sch = _make_schematic( sch = _make_schematic(
@@ -330,3 +362,122 @@ class TestCoreLogic:
result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0)
assert result is not None assert result is not None
assert len(result["wires"]) == 2 assert len(result["wires"]) == 2
# ---------------------------------------------------------------------------
# TestGetWireConnectionsNewFields
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetWireConnectionsNewFields:
"""Verify net and query_point are present in all return paths."""
def test_net_field_present_when_no_wires(self) -> None:
sch = MagicMock()
sch.wire = []
result = get_wire_connections(sch, "/fake/path.kicad_sch", 1.0, 2.0)
assert result is not None
assert "net" in result
assert result["net"] is None
def test_query_point_echoed_when_no_wires(self) -> None:
sch = MagicMock()
sch.wire = []
result = get_wire_connections(sch, "/fake/path.kicad_sch", 3.5, 7.25)
assert result is not None
assert result["query_point"] == {"x": 3.5, "y": 7.25}
def test_net_is_none_for_unnamed_net(self) -> None:
# Wire with no labels → net should be None
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0)
assert result is not None
assert result["net"] is None
def test_query_point_echoed_with_wire(self) -> None:
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0)
assert result is not None
assert result["query_point"] == {"x": 0.0, "y": 0.0}
def test_net_none_returned_when_no_wire_at_point(self) -> None:
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
result = get_wire_connections(sch, "/fake/path.kicad_sch", 5.0, 0.0)
assert result is None # no match at midpoint
# ---------------------------------------------------------------------------
# TestGetWireConnectionsHandlerRefPinMode
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetWireConnectionsHandlerRefPinMode:
"""Handler correctly resolves reference+pin to coordinates via PinLocator."""
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_get_wire_connections
def test_ref_pin_resolves_to_coordinates(self) -> None:
handler = self._make_handler()
mock_result = {
"net": "VCC",
"pins": [],
"wires": [],
"query_point": {"x": 10.0, "y": 20.0},
}
with (
patch(
"commands.pin_locator.PinLocator.get_pin_location",
return_value=(10.0, 20.0),
),
patch("commands.wire_connectivity.get_wire_connections", return_value=mock_result),
patch(
"kicad_interface.SchematicManager.load_schematic",
return_value=MagicMock(wire=[MagicMock()]),
),
):
result = handler(
{"schematicPath": "/fake/path.kicad_sch", "reference": "U1", "pin": "3"}
)
assert result["success"] is True
def test_ref_pin_not_found_returns_error(self) -> None:
handler = self._make_handler()
with patch(
"commands.pin_locator.PinLocator.get_pin_location",
return_value=None,
):
result = handler(
{"schematicPath": "/fake/path.kicad_sch", "reference": "U1", "pin": "99"}
)
assert result["success"] is False
assert "99" in result["message"] or "U1" in result["message"]
def test_missing_both_modes_returns_error(self) -> None:
handler = self._make_handler()
result = handler({"schematicPath": "/fake/path.kicad_sch"})
assert result["success"] is False
def test_partial_reference_without_pin_returns_error(self) -> None:
handler = self._make_handler()
result = handler({"schematicPath": "/fake/path.kicad_sch", "reference": "U1"})
assert result["success"] is False
def test_partial_pin_without_reference_returns_error(self) -> None:
handler = self._make_handler()
result = handler({"schematicPath": "/fake/path.kicad_sch", "pin": "3"})
assert result["success"] is False
def test_get_pin_net_not_in_routes(self) -> None:
with patch("kicad_interface.USE_IPC_BACKEND", False):
from kicad_interface import KiCADInterface
iface = KiCADInterface.__new__(KiCADInterface)
KiCADInterface.__init__(iface)
assert "get_pin_net" not in iface.command_routes