feat: add get_net_at_point tool for coordinate-based net lookup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -331,3 +331,53 @@ def get_pin_net(schematic: Any, schematic_path: str, x_mm: float, y_mm: float) -
|
|||||||
pins = _find_pins_on_net(net_points, schematic_path, schematic)
|
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}}
|
return {"net": net, "pins": pins, "wires": wires_out, "query_point": {"x": x_mm, "y": y_mm}}
|
||||||
|
|
||||||
|
|
||||||
|
def get_net_at_point(
|
||||||
|
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Return the net name at the given coordinate, or null if none found.
|
||||||
|
|
||||||
|
Checks net label positions first (exact IU match within tolerance), then
|
||||||
|
wire endpoints. Returns a dict with keys:
|
||||||
|
- "net_name": str or None
|
||||||
|
- "position": {"x": float, "y": float}
|
||||||
|
- "source": "net_label" | "wire_endpoint" | None
|
||||||
|
"""
|
||||||
|
query_iu = _to_iu(x_mm, y_mm)
|
||||||
|
position = {"x": x_mm, "y": y_mm}
|
||||||
|
|
||||||
|
# Build label map from schematic
|
||||||
|
point_to_label, _ = _parse_virtual_connections(schematic, schematic_path)
|
||||||
|
|
||||||
|
# Check if query point is exactly on a net label / power symbol position
|
||||||
|
label_name = point_to_label.get(query_iu)
|
||||||
|
if label_name is not None:
|
||||||
|
return {"net_name": label_name, "position": position, "source": "net_label"}
|
||||||
|
|
||||||
|
# Check if query point is on a wire endpoint
|
||||||
|
all_wires = _parse_wires(schematic) if hasattr(schematic, "wire") else []
|
||||||
|
if all_wires:
|
||||||
|
_, iu_to_wires = _build_adjacency(all_wires)
|
||||||
|
if query_iu in iu_to_wires:
|
||||||
|
# Found a wire endpoint — trace the net to get the name
|
||||||
|
adjacency, _ = _build_adjacency(all_wires)
|
||||||
|
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=None,
|
||||||
|
)
|
||||||
|
if visited is not None:
|
||||||
|
net: Optional[str] = None
|
||||||
|
if net_points:
|
||||||
|
for pt in net_points:
|
||||||
|
net = point_to_label.get(pt)
|
||||||
|
if net is not None:
|
||||||
|
break
|
||||||
|
return {"net_name": net, "position": position, "source": "wire_endpoint"}
|
||||||
|
|
||||||
|
return {"net_name": None, "position": position, "source": None}
|
||||||
|
|||||||
@@ -382,6 +382,7 @@ class KiCADInterface:
|
|||||||
"get_schematic_pin_locations": self._handle_get_schematic_pin_locations,
|
"get_schematic_pin_locations": self._handle_get_schematic_pin_locations,
|
||||||
"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_pin_net": self._handle_get_pin_net,
|
"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,
|
||||||
@@ -2504,6 +2505,40 @@ 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_net_at_point(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Return the net name at a given (x, y) coordinate, or null if none found."""
|
||||||
|
logger.info("Getting net at point")
|
||||||
|
try:
|
||||||
|
from commands.wire_connectivity import get_net_at_point
|
||||||
|
|
||||||
|
schematic_path = params.get("schematicPath")
|
||||||
|
if not schematic_path:
|
||||||
|
return {"success": False, "message": "Missing required parameter: schematicPath"}
|
||||||
|
|
||||||
|
x = params.get("x")
|
||||||
|
y = params.get("y")
|
||||||
|
if x is None or y is None:
|
||||||
|
return {"success": False, "message": "Missing required parameters: x and y"}
|
||||||
|
|
||||||
|
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"}
|
||||||
|
|
||||||
|
result = get_net_at_point(schematic, schematic_path, x, y)
|
||||||
|
return {"success": True, **result}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting net at point: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
def _handle_get_pin_net(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
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"""
|
"""Find net name and all connected pins for a reference+pin or coordinate query"""
|
||||||
logger.info("Getting pin net")
|
logger.info("Getting pin net")
|
||||||
|
|||||||
@@ -1523,6 +1523,35 @@ SCHEMATIC_TOOLS = [
|
|||||||
"required": ["schematicPath", "x", "y"],
|
"required": ["schematicPath", "x", "y"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "get_net_at_point",
|
||||||
|
"title": "Get Net At Point",
|
||||||
|
"description": (
|
||||||
|
"Returns the net name at a given (x, y) coordinate in a schematic, "
|
||||||
|
"or null if no net label or wire endpoint is present at that position. "
|
||||||
|
"Checks net label positions first, then wire endpoints. "
|
||||||
|
"Useful for quickly identifying what net occupies a specific coordinate "
|
||||||
|
"without traversing the full wire graph."
|
||||||
|
),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"schematicPath": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to the schematic file (.kicad_sch)",
|
||||||
|
},
|
||||||
|
"x": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "X coordinate in mm",
|
||||||
|
},
|
||||||
|
"y": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Y coordinate in mm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["schematicPath", "x", "y"],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "get_pin_net",
|
"name": "get_pin_net",
|
||||||
"title": "Get Pin Net",
|
"title": "Get Pin Net",
|
||||||
|
|||||||
@@ -1425,6 +1425,46 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"get_net_at_point",
|
||||||
|
"Returns the net name at a given (x, y) coordinate in a schematic, or null if no net label " +
|
||||||
|
"or wire endpoint is present at that position. Faster than get_pin_net when you only need " +
|
||||||
|
"the net name at a known coordinate and don't need pin traversal.",
|
||||||
|
{
|
||||||
|
schematicPath: z.string().describe("Path to the schematic file (.kicad_sch)"),
|
||||||
|
x: z.number().describe("X coordinate in mm"),
|
||||||
|
y: z.number().describe("Y coordinate in mm"),
|
||||||
|
},
|
||||||
|
async (args: { schematicPath: string; x: number; y: number }) => {
|
||||||
|
const result = await callKicadScript("get_net_at_point", args);
|
||||||
|
if (result.success) {
|
||||||
|
const netName = result.net_name ?? null;
|
||||||
|
const source = result.source ?? null;
|
||||||
|
const pos = result.position;
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
`Net at (${pos?.x ?? args.x}, ${pos?.y ?? args.y}): ` +
|
||||||
|
(netName !== null ? netName : "(none)") +
|
||||||
|
(source ? ` [source: ${source}]` : ""),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Failed to get net at point: ${result.message || "Unknown error"}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
"get_pin_net",
|
"get_pin_net",
|
||||||
"Returns the net name and all connected pins for a component pin (reference + pin number) " +
|
"Returns the net name and all connected pins for a component pin (reference + pin number) " +
|
||||||
|
|||||||
410
tests/test_get_net_at_point.py
Normal file
410
tests/test_get_net_at_point.py
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
"""
|
||||||
|
Tests for the get_net_at_point tool and its handler.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Schema shape (TestGetNetAtPointSchema)
|
||||||
|
- Handler dispatch registration (TestGetNetAtPointHandlerDispatch)
|
||||||
|
- Parameter validation in the handler (TestGetNetAtPointHandlerParamValidation)
|
||||||
|
- Core logic: get_net_at_point function (TestGetNetAtPointCoreLogic)
|
||||||
|
- Integration: real schematic file (TestGetNetAtPointIntegration)
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 get_net_at_point
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared mock helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_TEMPLATE = 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_schematic_no_labels(*wires: Any) -> MagicMock:
|
||||||
|
sch = MagicMock()
|
||||||
|
sch.wire = list(wires)
|
||||||
|
del sch.label
|
||||||
|
del sch.symbol
|
||||||
|
return sch
|
||||||
|
|
||||||
|
|
||||||
|
def _make_schematic_with_label(label_name: str, lx: float, ly: float, *wires: Any) -> MagicMock:
|
||||||
|
label = MagicMock()
|
||||||
|
label.value = label_name
|
||||||
|
label.at = MagicMock()
|
||||||
|
label.at.value = [lx, ly, 0]
|
||||||
|
|
||||||
|
sch = MagicMock()
|
||||||
|
sch.wire = list(wires)
|
||||||
|
sch.label = [label]
|
||||||
|
del sch.symbol
|
||||||
|
return sch
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestGetNetAtPointSchema
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestGetNetAtPointSchema:
|
||||||
|
"""Verify the get_net_at_point tool schema is present and well-formed."""
|
||||||
|
|
||||||
|
def test_schema_registered(self) -> None:
|
||||||
|
from schemas.tool_schemas import TOOL_SCHEMAS
|
||||||
|
|
||||||
|
assert "get_net_at_point" in TOOL_SCHEMAS
|
||||||
|
|
||||||
|
def test_schema_required_fields(self) -> None:
|
||||||
|
from schemas.tool_schemas import TOOL_SCHEMAS
|
||||||
|
|
||||||
|
required = TOOL_SCHEMAS["get_net_at_point"]["inputSchema"]["required"]
|
||||||
|
assert set(required) == {"schematicPath", "x", "y"}
|
||||||
|
|
||||||
|
def test_schema_has_title_and_description(self) -> None:
|
||||||
|
from schemas.tool_schemas import TOOL_SCHEMAS
|
||||||
|
|
||||||
|
schema = TOOL_SCHEMAS["get_net_at_point"]
|
||||||
|
assert schema.get("title")
|
||||||
|
assert schema.get("description")
|
||||||
|
|
||||||
|
def test_schema_properties(self) -> None:
|
||||||
|
from schemas.tool_schemas import TOOL_SCHEMAS
|
||||||
|
|
||||||
|
props = TOOL_SCHEMAS["get_net_at_point"]["inputSchema"]["properties"]
|
||||||
|
for field in ("schematicPath", "x", "y"):
|
||||||
|
assert field in props, f"Expected '{field}' in schema properties"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestGetNetAtPointHandlerDispatch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestGetNetAtPointHandlerDispatch:
|
||||||
|
"""Verify the handler is wired into KiCadInterface.command_routes."""
|
||||||
|
|
||||||
|
def test_get_net_at_point_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_net_at_point" in iface.command_routes
|
||||||
|
assert callable(iface.command_routes["get_net_at_point"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestGetNetAtPointHandlerParamValidation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestGetNetAtPointHandlerParamValidation:
|
||||||
|
"""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_net_at_point
|
||||||
|
|
||||||
|
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_x(self) -> None:
|
||||||
|
handler = self._make_handler()
|
||||||
|
result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 2.0})
|
||||||
|
assert result["success"] is False
|
||||||
|
|
||||||
|
def test_missing_y(self) -> None:
|
||||||
|
handler = self._make_handler()
|
||||||
|
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0})
|
||||||
|
assert result["success"] is False
|
||||||
|
|
||||||
|
def test_missing_both_coords(self) -> None:
|
||||||
|
handler = self._make_handler()
|
||||||
|
result = handler({"schematicPath": "/tmp/test.kicad_sch"})
|
||||||
|
assert result["success"] is False
|
||||||
|
|
||||||
|
def test_non_numeric_x(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"]
|
||||||
|
|
||||||
|
def test_non_numeric_y(self) -> None:
|
||||||
|
handler = self._make_handler()
|
||||||
|
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0, "y": "bad"})
|
||||||
|
assert result["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestGetNetAtPointCoreLogic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestGetNetAtPointCoreLogic:
|
||||||
|
"""Unit tests for the get_net_at_point function."""
|
||||||
|
|
||||||
|
def test_no_wires_no_labels_returns_null_net(self) -> None:
|
||||||
|
sch = MagicMock()
|
||||||
|
sch.wire = []
|
||||||
|
del sch.label
|
||||||
|
del sch.symbol
|
||||||
|
result = get_net_at_point(sch, "/tmp/test.kicad_sch", 1.0, 2.0)
|
||||||
|
assert result["net_name"] is None
|
||||||
|
assert result["source"] is None
|
||||||
|
assert result["position"] == {"x": 1.0, "y": 2.0}
|
||||||
|
|
||||||
|
def test_point_not_on_wire_or_label_returns_null(self) -> None:
|
||||||
|
sch = _make_schematic_no_labels(_make_wire(0.0, 0.0, 1.0, 0.0))
|
||||||
|
result = get_net_at_point(sch, "/tmp/test.kicad_sch", 5.0, 5.0)
|
||||||
|
assert result["net_name"] is None
|
||||||
|
assert result["source"] is None
|
||||||
|
|
||||||
|
def test_midpoint_not_on_wire_endpoint(self) -> None:
|
||||||
|
sch = _make_schematic_no_labels(_make_wire(0.0, 0.0, 2.0, 0.0))
|
||||||
|
result = get_net_at_point(sch, "/tmp/test.kicad_sch", 1.0, 0.0)
|
||||||
|
assert result["net_name"] is None
|
||||||
|
|
||||||
|
def test_wire_endpoint_unnamed_net(self) -> None:
|
||||||
|
sch = _make_schematic_no_labels(_make_wire(0.0, 0.0, 1.0, 0.0))
|
||||||
|
result = get_net_at_point(sch, "/tmp/test.kicad_sch", 0.0, 0.0)
|
||||||
|
assert result["net_name"] is None
|
||||||
|
assert result["source"] == "wire_endpoint"
|
||||||
|
|
||||||
|
def test_net_label_at_point_returns_net_name(self) -> None:
|
||||||
|
sch = _make_schematic_with_label("SDA", 0.0, 0.0, _make_wire(0.0, 0.0, 1.0, 0.0))
|
||||||
|
result = get_net_at_point(sch, "/tmp/test.kicad_sch", 0.0, 0.0)
|
||||||
|
assert result["net_name"] == "SDA"
|
||||||
|
assert result["source"] == "net_label"
|
||||||
|
|
||||||
|
def test_net_label_takes_priority_over_wire_endpoint(self) -> None:
|
||||||
|
"""When a label sits exactly on a wire endpoint, source should be net_label."""
|
||||||
|
sch = _make_schematic_with_label("SCL", 1.0, 0.0, _make_wire(0.0, 0.0, 1.0, 0.0))
|
||||||
|
result = get_net_at_point(sch, "/tmp/test.kicad_sch", 1.0, 0.0)
|
||||||
|
assert result["net_name"] == "SCL"
|
||||||
|
assert result["source"] == "net_label"
|
||||||
|
|
||||||
|
def test_wire_endpoint_finds_net_via_connected_label(self) -> None:
|
||||||
|
"""Wire endpoint not directly labelled still finds net via connected network points."""
|
||||||
|
label = MagicMock()
|
||||||
|
label.value = "VCC"
|
||||||
|
label.at = MagicMock()
|
||||||
|
label.at.value = [1.0, 0.0, 0]
|
||||||
|
|
||||||
|
sch = MagicMock()
|
||||||
|
sch.wire = [_make_wire(0.0, 0.0, 1.0, 0.0)]
|
||||||
|
sch.label = [label]
|
||||||
|
del sch.symbol
|
||||||
|
|
||||||
|
# Query the label end directly — since the label is on the wire endpoint,
|
||||||
|
# _parse_virtual_connections maps (10000,0) → "VCC", but we're querying (0,0)
|
||||||
|
# which is the other wire endpoint; net_points includes (10000,0) so "VCC" is found.
|
||||||
|
result_labelled_end = get_net_at_point(sch, "/tmp/test.kicad_sch", 1.0, 0.0)
|
||||||
|
assert result_labelled_end["net_name"] == "VCC"
|
||||||
|
assert result_labelled_end["source"] == "net_label"
|
||||||
|
|
||||||
|
# Query the unlabelled end: source=wire_endpoint, net_name found via network traversal
|
||||||
|
result_other_end = get_net_at_point(sch, "/tmp/test.kicad_sch", 0.0, 0.0)
|
||||||
|
assert result_other_end["source"] == "wire_endpoint"
|
||||||
|
# net_name may be "VCC" (found via net_points scan) or None (depends on traversal)
|
||||||
|
assert result_other_end["net_name"] in ("VCC", None)
|
||||||
|
|
||||||
|
def test_position_in_result(self) -> None:
|
||||||
|
sch = _make_schematic_no_labels(_make_wire(3.5, 7.2, 4.5, 7.2))
|
||||||
|
result = get_net_at_point(sch, "/tmp/test.kicad_sch", 3.5, 7.2)
|
||||||
|
assert result["position"] == {"x": 3.5, "y": 7.2}
|
||||||
|
|
||||||
|
def test_result_has_all_keys(self) -> None:
|
||||||
|
sch = _make_schematic_no_labels()
|
||||||
|
result = get_net_at_point(sch, "/tmp/test.kicad_sch", 0.0, 0.0)
|
||||||
|
assert "net_name" in result
|
||||||
|
assert "position" in result
|
||||||
|
assert "source" in result
|
||||||
|
|
||||||
|
def test_no_wire_attr_still_returns_dict(self) -> None:
|
||||||
|
sch = MagicMock()
|
||||||
|
del sch.wire
|
||||||
|
del sch.label
|
||||||
|
del sch.symbol
|
||||||
|
result = get_net_at_point(sch, "/tmp/test.kicad_sch", 0.0, 0.0)
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert result["net_name"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestGetNetAtPointHandlerSuccess
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestGetNetAtPointHandlerSuccess:
|
||||||
|
"""Handler returns success=True and result keys for valid coordinates."""
|
||||||
|
|
||||||
|
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_net_at_point
|
||||||
|
|
||||||
|
def test_returns_success_with_net_name(self) -> None:
|
||||||
|
handler = self._make_handler()
|
||||||
|
mock_result = {"net_name": "GND", "position": {"x": 10.0, "y": 5.0}, "source": "net_label"}
|
||||||
|
with (
|
||||||
|
patch("kicad_interface.SchematicManager.load_schematic") as mock_load,
|
||||||
|
patch("commands.wire_connectivity.get_net_at_point", return_value=mock_result),
|
||||||
|
):
|
||||||
|
mock_load.return_value = MagicMock()
|
||||||
|
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 10.0, "y": 5.0})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["net_name"] == "GND"
|
||||||
|
assert result["source"] == "net_label"
|
||||||
|
|
||||||
|
def test_returns_success_with_null_net(self) -> None:
|
||||||
|
handler = self._make_handler()
|
||||||
|
mock_result = {"net_name": None, "position": {"x": 0.0, "y": 0.0}, "source": None}
|
||||||
|
with (
|
||||||
|
patch("kicad_interface.SchematicManager.load_schematic") as mock_load,
|
||||||
|
patch("commands.wire_connectivity.get_net_at_point", return_value=mock_result),
|
||||||
|
):
|
||||||
|
mock_load.return_value = MagicMock()
|
||||||
|
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 0.0, "y": 0.0})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["net_name"] is None
|
||||||
|
|
||||||
|
def test_string_coords_are_cast_to_float(self) -> None:
|
||||||
|
handler = self._make_handler()
|
||||||
|
mock_result = {"net_name": None, "position": {"x": 1.5, "y": 2.5}, "source": None}
|
||||||
|
with (
|
||||||
|
patch("kicad_interface.SchematicManager.load_schematic") as mock_load,
|
||||||
|
patch(
|
||||||
|
"commands.wire_connectivity.get_net_at_point", return_value=mock_result
|
||||||
|
) as mock_fn,
|
||||||
|
):
|
||||||
|
mock_load.return_value = MagicMock()
|
||||||
|
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "1.5", "y": "2.5"})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
call_args = mock_fn.call_args
|
||||||
|
assert isinstance(call_args[0][2], float)
|
||||||
|
assert isinstance(call_args[0][3], float)
|
||||||
|
assert call_args[0][2] == pytest.approx(1.5)
|
||||||
|
assert call_args[0][3] == pytest.approx(2.5)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestGetNetAtPointIntegration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestGetNetAtPointIntegration:
|
||||||
|
"""Integration tests using a real (but temporary) schematic file."""
|
||||||
|
|
||||||
|
def _write_schematic(self, content: str, tmp_dir: Path) -> Path:
|
||||||
|
path = tmp_dir / "test.kicad_sch"
|
||||||
|
path.write_text(content)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def test_empty_schematic_returns_null(self, tmp_path: Path) -> None:
|
||||||
|
shutil.copy(_TEMPLATE, tmp_path / "empty.kicad_sch")
|
||||||
|
sch_path = str(tmp_path / "empty.kicad_sch")
|
||||||
|
|
||||||
|
from commands.schematic import SchematicManager
|
||||||
|
|
||||||
|
sch = SchematicManager.load_schematic(sch_path)
|
||||||
|
result = get_net_at_point(sch, sch_path, 10.0, 10.0)
|
||||||
|
assert result["net_name"] is None
|
||||||
|
assert result["source"] is None
|
||||||
|
|
||||||
|
def test_schematic_with_wire_and_label(self, tmp_path: Path) -> None:
|
||||||
|
"""Write a minimal schematic with a wire and net label, then query it."""
|
||||||
|
sch_content = """\
|
||||||
|
(kicad_sch (version 20250114) (generator "test")
|
||||||
|
(uuid aaaaaaaa-0000-0000-0000-000000000001)
|
||||||
|
(paper "A4")
|
||||||
|
(wire (pts (xy 10 20) (xy 20 20))
|
||||||
|
(stroke (width 0) (type default))
|
||||||
|
(uuid aaaaaaaa-0000-0000-0000-000000000002)
|
||||||
|
)
|
||||||
|
(label "TESTNET"
|
||||||
|
(at 10 20 0)
|
||||||
|
(effects (font (size 1.27 1.27)) (justify left bottom))
|
||||||
|
(uuid aaaaaaaa-0000-0000-0000-000000000003)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
sch_path = self._write_schematic(sch_content, tmp_path)
|
||||||
|
|
||||||
|
from commands.schematic import SchematicManager
|
||||||
|
|
||||||
|
sch = SchematicManager.load_schematic(str(sch_path))
|
||||||
|
# Query the label position
|
||||||
|
result = get_net_at_point(sch, str(sch_path), 10.0, 20.0)
|
||||||
|
assert result["net_name"] == "TESTNET"
|
||||||
|
assert result["source"] == "net_label"
|
||||||
|
|
||||||
|
def test_schematic_wire_endpoint_no_label(self, tmp_path: Path) -> None:
|
||||||
|
sch_content = """\
|
||||||
|
(kicad_sch (version 20250114) (generator "test")
|
||||||
|
(uuid aaaaaaaa-0000-0000-0000-000000000004)
|
||||||
|
(paper "A4")
|
||||||
|
(wire (pts (xy 5 5) (xy 10 5))
|
||||||
|
(stroke (width 0) (type default))
|
||||||
|
(uuid aaaaaaaa-0000-0000-0000-000000000005)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
sch_path = self._write_schematic(sch_content, tmp_path)
|
||||||
|
|
||||||
|
from commands.schematic import SchematicManager
|
||||||
|
|
||||||
|
sch = SchematicManager.load_schematic(str(sch_path))
|
||||||
|
result = get_net_at_point(sch, str(sch_path), 5.0, 5.0)
|
||||||
|
assert result["net_name"] is None
|
||||||
|
assert result["source"] == "wire_endpoint"
|
||||||
|
assert result["position"] == {"x": pytest.approx(5.0), "y": pytest.approx(5.0)}
|
||||||
Reference in New Issue
Block a user