fix: add pin-snapping and coordinate feedback to net label tools
add_schematic_net_label now accepts optional componentRef + pinNumber to
snap the label directly to the exact pin endpoint via PinLocator, removing
all approximation risk. The response always includes actual_position and,
when snapping was used, snapped_to_pin — so the caller gets confirmation
of exactly where the label landed.
connect_to_net return type changed from bool to Dict, returning
pin_location, label_location, and wire_stub on success so agents no
longer need a separate verification call to confirm placement.
connect_passthrough updated to check result.get("success") against the
new dict return. tool_schemas.py and schematic.ts updated to match
(position is now optional, componentRef/pinNumber/labelType/orientation
added, connect_to_net schema field names corrected).
17 new unit tests in tests/test_net_label_pin_snapping.py.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -59,9 +59,9 @@ class ConnectionManager:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def connect_to_net(
|
def connect_to_net(
|
||||||
schematic_path: Path, component_ref: str, pin_name: str, net_name: str
|
schematic_path: Path, component_ref: str, pin_name: str, net_name: str
|
||||||
) -> bool:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Connect a component pin to a named net using a wire stub and label
|
Connect a component pin to a named net using a wire stub and label.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
schematic_path: Path to .kicad_sch file
|
schematic_path: Path to .kicad_sch file
|
||||||
@@ -70,27 +70,32 @@ class ConnectionManager:
|
|||||||
net_name: Name of the net to connect to (e.g., "VCC", "GND", "SIGNAL_1")
|
net_name: Name of the net to connect to (e.g., "VCC", "GND", "SIGNAL_1")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if successful, False otherwise
|
Dict with keys:
|
||||||
|
success – bool
|
||||||
|
pin_location – [x, y] exact pin endpoint used (present on success)
|
||||||
|
label_location – [x, y] where the net label was placed (present on success)
|
||||||
|
wire_stub – [[x1,y1],[x2,y2]] the wire segment added (present on success)
|
||||||
|
message – human-readable status
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not WIRE_MANAGER_AVAILABLE:
|
if not WIRE_MANAGER_AVAILABLE:
|
||||||
logger.error("WireManager/PinLocator not available")
|
logger.error("WireManager/PinLocator not available")
|
||||||
return False
|
return {"success": False, "message": "WireManager/PinLocator not available"}
|
||||||
|
|
||||||
locator = ConnectionManager.get_pin_locator()
|
locator = ConnectionManager.get_pin_locator()
|
||||||
if not locator:
|
if not locator:
|
||||||
logger.error("Pin locator unavailable")
|
logger.error("Pin locator unavailable")
|
||||||
return False
|
return {"success": False, "message": "Pin locator unavailable"}
|
||||||
|
|
||||||
# Get pin location using PinLocator
|
# Get pin location using PinLocator
|
||||||
pin_loc = locator.get_pin_location(schematic_path, component_ref, pin_name)
|
pin_loc = locator.get_pin_location(schematic_path, component_ref, pin_name)
|
||||||
if not pin_loc:
|
if not pin_loc:
|
||||||
logger.error(f"Could not locate pin {component_ref}/{pin_name}")
|
msg = f"Could not locate pin {component_ref}/{pin_name}"
|
||||||
return False
|
logger.error(msg)
|
||||||
|
return {"success": False, "message": msg}
|
||||||
|
|
||||||
# Add a small wire stub from the pin (2.54mm = 0.1 inch, standard grid spacing)
|
# Add a small wire stub from the pin (2.54mm = 0.1 inch, standard grid spacing)
|
||||||
# Stub direction follows the pin's outward angle from the PinLocator
|
# Stub direction follows the pin's outward angle from the PinLocator
|
||||||
pin_angle_deg = getattr(locator, "_last_pin_angle", 0)
|
|
||||||
try:
|
try:
|
||||||
pin_angle_deg = locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0
|
pin_angle_deg = locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -106,26 +111,34 @@ class ConnectionManager:
|
|||||||
# Create wire stub using WireManager
|
# Create wire stub using WireManager
|
||||||
wire_success = WireManager.add_wire(schematic_path, pin_loc, stub_end)
|
wire_success = WireManager.add_wire(schematic_path, pin_loc, stub_end)
|
||||||
if not wire_success:
|
if not wire_success:
|
||||||
logger.error(f"Failed to create wire stub for net connection")
|
msg = "Failed to create wire stub for net connection"
|
||||||
return False
|
logger.error(msg)
|
||||||
|
return {"success": False, "message": msg}
|
||||||
|
|
||||||
# Add label at the end of the stub using WireManager
|
# Add label at the end of the stub using WireManager
|
||||||
label_success = WireManager.add_label(
|
label_success = WireManager.add_label(
|
||||||
schematic_path, net_name, stub_end, label_type="label"
|
schematic_path, net_name, stub_end, label_type="label"
|
||||||
)
|
)
|
||||||
if not label_success:
|
if not label_success:
|
||||||
logger.error(f"Failed to add net label '{net_name}'")
|
msg = f"Failed to add net label '{net_name}'"
|
||||||
return False
|
logger.error(msg)
|
||||||
|
return {"success": False, "message": msg}
|
||||||
|
|
||||||
logger.info(f"Connected {component_ref}/{pin_name} to net '{net_name}'")
|
logger.info(f"Connected {component_ref}/{pin_name} to net '{net_name}'")
|
||||||
return True
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Connected {component_ref}/{pin_name} to net '{net_name}'",
|
||||||
|
"pin_location": pin_loc,
|
||||||
|
"label_location": stub_end,
|
||||||
|
"wire_stub": [pin_loc, stub_end],
|
||||||
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error connecting to net: {e}")
|
logger.error(f"Error connecting to net: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
return False
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def connect_passthrough(
|
def connect_passthrough(
|
||||||
@@ -177,18 +190,18 @@ class ConnectionManager:
|
|||||||
else f"{net_prefix}_{pin_num}"
|
else f"{net_prefix}_{pin_num}"
|
||||||
)
|
)
|
||||||
|
|
||||||
ok_src = ConnectionManager.connect_to_net(
|
res_src = ConnectionManager.connect_to_net(
|
||||||
schematic_path, source_ref, pin_num, net_name
|
schematic_path, source_ref, pin_num, net_name
|
||||||
)
|
)
|
||||||
if not ok_src:
|
if not res_src.get("success"):
|
||||||
failed.append(f"{source_ref}/{pin_num}")
|
failed.append(f"{source_ref}/{pin_num}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if pin_num in tgt_pins:
|
if pin_num in tgt_pins:
|
||||||
ok_tgt = ConnectionManager.connect_to_net(
|
res_tgt = ConnectionManager.connect_to_net(
|
||||||
schematic_path, target_ref, pin_num, net_name
|
schematic_path, target_ref, pin_num, net_name
|
||||||
)
|
)
|
||||||
if not ok_tgt:
|
if not res_tgt.get("success"):
|
||||||
failed.append(f"{target_ref}/{pin_num}")
|
failed.append(f"{target_ref}/{pin_num}")
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1514,9 +1514,16 @@ class KiCADInterface:
|
|||||||
return {"success": False, "message": str(e)}
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
def _handle_add_schematic_net_label(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def _handle_add_schematic_net_label(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Add a net label to schematic using WireManager"""
|
"""Add a net label to schematic using WireManager.
|
||||||
|
|
||||||
|
When componentRef and pinNumber are supplied the label is placed at the
|
||||||
|
exact pin endpoint retrieved via PinLocator, ignoring the provided
|
||||||
|
position. The response includes the actual coordinates used and
|
||||||
|
whether the label landed on a pin endpoint.
|
||||||
|
"""
|
||||||
logger.info("Adding net label to schematic")
|
logger.info("Adding net label to schematic")
|
||||||
try:
|
try:
|
||||||
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from commands.wire_manager import WireManager
|
from commands.wire_manager import WireManager
|
||||||
@@ -1524,13 +1531,48 @@ class KiCADInterface:
|
|||||||
schematic_path = params.get("schematicPath")
|
schematic_path = params.get("schematicPath")
|
||||||
net_name = params.get("netName")
|
net_name = params.get("netName")
|
||||||
position = params.get("position")
|
position = params.get("position")
|
||||||
label_type = params.get(
|
label_type = params.get("labelType", "label")
|
||||||
"labelType", "label"
|
orientation = params.get("orientation", 0)
|
||||||
) # 'label', 'global_label', 'hierarchical_label'
|
component_ref = params.get("componentRef")
|
||||||
orientation = params.get("orientation", 0) # 0, 90, 180, 270
|
pin_number = params.get("pinNumber")
|
||||||
|
|
||||||
if not all([schematic_path, net_name, position]):
|
if not all([schematic_path, net_name]):
|
||||||
return {"success": False, "message": "Missing required parameters"}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Missing required parameters: schematicPath, netName",
|
||||||
|
}
|
||||||
|
|
||||||
|
snapped_to_pin: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
if component_ref and pin_number:
|
||||||
|
# Snap position to exact pin endpoint using PinLocator
|
||||||
|
from commands.pin_locator import PinLocator
|
||||||
|
|
||||||
|
locator = PinLocator()
|
||||||
|
pin_loc = locator.get_pin_location(
|
||||||
|
Path(schematic_path), component_ref, str(pin_number)
|
||||||
|
)
|
||||||
|
if pin_loc is None:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": (
|
||||||
|
f"Could not locate pin {pin_number} on {component_ref}. "
|
||||||
|
"Check the reference and pin number."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
position = pin_loc
|
||||||
|
snapped_to_pin = {"component": component_ref, "pin": str(pin_number)}
|
||||||
|
logger.info(
|
||||||
|
f"Snapped label '{net_name}' to pin {component_ref}/{pin_number} at {position}"
|
||||||
|
)
|
||||||
|
elif position is None:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": (
|
||||||
|
"Missing position. Either provide position [x, y] or "
|
||||||
|
"componentRef + pinNumber to snap to a pin endpoint."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
# Use WireManager for S-expression manipulation
|
# Use WireManager for S-expression manipulation
|
||||||
success = WireManager.add_label(
|
success = WireManager.add_label(
|
||||||
@@ -1541,13 +1583,22 @@ class KiCADInterface:
|
|||||||
orientation=orientation,
|
orientation=orientation,
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if not success:
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": f"Added net label '{net_name}' at {position}",
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {"success": False, "message": "Failed to add net label"}
|
return {"success": False, "message": "Failed to add net label"}
|
||||||
|
|
||||||
|
response: Dict[str, Any] = {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Added net label '{net_name}' at {position}",
|
||||||
|
"actual_position": position,
|
||||||
|
}
|
||||||
|
if snapped_to_pin:
|
||||||
|
response["snapped_to_pin"] = snapped_to_pin
|
||||||
|
response["message"] = (
|
||||||
|
f"Added net label '{net_name}' at exact pin endpoint "
|
||||||
|
f"{component_ref}/{pin_number} ({position[0]}, {position[1]})"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error adding net label: {str(e)}")
|
logger.error(f"Error adding net label: {str(e)}")
|
||||||
import traceback
|
import traceback
|
||||||
@@ -1574,17 +1625,10 @@ class KiCADInterface:
|
|||||||
return {"success": False, "message": "Missing required parameters"}
|
return {"success": False, "message": "Missing required parameters"}
|
||||||
|
|
||||||
# Use ConnectionManager with new WireManager integration
|
# Use ConnectionManager with new WireManager integration
|
||||||
success = ConnectionManager.connect_to_net(
|
result = ConnectionManager.connect_to_net(
|
||||||
Path(schematic_path), component_ref, pin_name, net_name
|
Path(schematic_path), component_ref, pin_name, net_name
|
||||||
)
|
)
|
||||||
|
return result
|
||||||
if success:
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": f"Connected {component_ref}/{pin_name} to net '{net_name}'",
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {"success": False, "message": "Failed to connect to net"}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error connecting to net: {str(e)}")
|
logger.error(f"Error connecting to net: {str(e)}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|||||||
@@ -1399,7 +1399,15 @@ SCHEMATIC_TOOLS = [
|
|||||||
{
|
{
|
||||||
"name": "add_schematic_net_label",
|
"name": "add_schematic_net_label",
|
||||||
"title": "Add Net Label",
|
"title": "Add Net Label",
|
||||||
"description": "Adds a net label at exact coordinates on a schematic wire or pin endpoint. WARNING: x/y must match an existing wire endpoint or pin endpoint exactly — placing the label even 0.01mm away from a pin will result in an unconnected pin ERC error. To connect a component pin to a net by reference and pin number (recommended), use connect_to_net instead.",
|
"description": (
|
||||||
|
"Add a net label to a schematic. "
|
||||||
|
"PREFERRED: supply componentRef + pinNumber to snap the label to the exact pin endpoint — "
|
||||||
|
"this guarantees an electrical connection. "
|
||||||
|
"Alternatively supply position [x, y], but the coordinates must match the pin endpoint exactly "
|
||||||
|
"(even a 0.01 mm offset breaks the connection). "
|
||||||
|
"The response includes actual_position (coordinates actually used) and snapped_to_pin "
|
||||||
|
"(present when a pin reference was resolved)."
|
||||||
|
),
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1411,21 +1419,45 @@ SCHEMATIC_TOOLS = [
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Name of the net (e.g., VCC, GND, SDA)",
|
"description": "Name of the net (e.g., VCC, GND, SDA)",
|
||||||
},
|
},
|
||||||
"x": {"type": "number", "description": "X coordinate on schematic"},
|
"position": {
|
||||||
"y": {"type": "number", "description": "Y coordinate on schematic"},
|
"type": "array",
|
||||||
"rotation": {
|
"items": {"type": "number"},
|
||||||
|
"minItems": 2,
|
||||||
|
"maxItems": 2,
|
||||||
|
"description": "Position [x, y] for the label. Required when componentRef/pinNumber are not given.",
|
||||||
|
},
|
||||||
|
"componentRef": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Component reference to snap label to (e.g. U1, R1). Use with pinNumber.",
|
||||||
|
},
|
||||||
|
"pinNumber": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Pin number or name on componentRef (e.g. '1', 'GND'). Use with componentRef.",
|
||||||
|
},
|
||||||
|
"labelType": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["label", "global_label", "hierarchical_label"],
|
||||||
|
"description": "Label type (default: label)",
|
||||||
|
"default": "label",
|
||||||
|
},
|
||||||
|
"orientation": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"description": "Rotation angle in degrees (0, 90, 180, 270)",
|
"description": "Rotation angle in degrees (0, 90, 180, 270)",
|
||||||
"default": 0,
|
"default": 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": ["schematicPath", "netName", "x", "y"],
|
"required": ["schematicPath", "netName"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "connect_to_net",
|
"name": "connect_to_net",
|
||||||
"title": "Connect Pin to Net",
|
"title": "Connect Pin to Net",
|
||||||
"description": "Intelligently connects a component pin to a named net, automatically routing wires as needed.",
|
"description": (
|
||||||
|
"Connect a component pin to a named net by adding a wire stub and net label at the exact "
|
||||||
|
"pin endpoint. The response includes pin_location (exact pin coords), label_location "
|
||||||
|
"(where the label was placed), and wire_stub (the wire segment added) so you can confirm "
|
||||||
|
"the placement without a separate verification call."
|
||||||
|
),
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1433,11 +1465,11 @@ SCHEMATIC_TOOLS = [
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Path to schematic file",
|
"description": "Path to schematic file",
|
||||||
},
|
},
|
||||||
"reference": {
|
"componentRef": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Component reference designator (e.g., R1, U3)",
|
"description": "Component reference designator (e.g., R1, U3)",
|
||||||
},
|
},
|
||||||
"pinNumber": {
|
"pinName": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Pin number or name on the component",
|
"description": "Pin number or name on the component",
|
||||||
},
|
},
|
||||||
@@ -1446,7 +1478,7 @@ SCHEMATIC_TOOLS = [
|
|||||||
"description": "Name of the net to connect to",
|
"description": "Name of the net to connect to",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": ["schematicPath", "reference", "pinNumber", "netName"],
|
"required": ["schematicPath", "componentRef", "pinName", "netName"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -325,20 +325,55 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
|
|||||||
// Add net label
|
// Add net label
|
||||||
server.tool(
|
server.tool(
|
||||||
"add_schematic_net_label",
|
"add_schematic_net_label",
|
||||||
"Add a net label to the schematic",
|
"Add a net label to the schematic. " +
|
||||||
|
"PREFERRED: supply componentRef + pinNumber to snap the label to the exact pin endpoint — " +
|
||||||
|
"this guarantees an electrical connection. " +
|
||||||
|
"Alternatively supply position [x, y], but the coordinates must match the pin endpoint exactly " +
|
||||||
|
"(even a 0.01 mm offset breaks the connection). " +
|
||||||
|
"The response includes actual_position (coordinates actually used) and snapped_to_pin " +
|
||||||
|
"(present when a pin reference was resolved).",
|
||||||
{
|
{
|
||||||
schematicPath: z.string().describe("Path to the schematic file"),
|
schematicPath: z.string().describe("Path to the schematic file"),
|
||||||
netName: z.string().describe("Name of the net (e.g., VCC, GND, SIGNAL_1)"),
|
netName: z.string().describe("Name of the net (e.g., VCC, GND, SIGNAL_1)"),
|
||||||
position: z.array(z.number()).length(2).describe("Position [x, y] for the label"),
|
position: z
|
||||||
|
.array(z.number())
|
||||||
|
.length(2)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Position [x, y] for the label. Required when componentRef/pinNumber are not given.",
|
||||||
|
),
|
||||||
|
componentRef: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Component reference to snap label to (e.g. U1, R1). Use with pinNumber."),
|
||||||
|
pinNumber: z
|
||||||
|
.union([z.string(), z.number()])
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Pin number or name on componentRef to snap label to (e.g. '1', 'GND'). Use with componentRef.",
|
||||||
|
),
|
||||||
|
labelType: z
|
||||||
|
.enum(["label", "global_label", "hierarchical_label"])
|
||||||
|
.optional()
|
||||||
|
.describe("Label type (default: label)"),
|
||||||
|
orientation: z.number().optional().describe("Rotation angle 0/90/180/270 (default: 0)"),
|
||||||
},
|
},
|
||||||
async (args: { schematicPath: string; netName: string; position: number[] }) => {
|
async (args: {
|
||||||
|
schematicPath: string;
|
||||||
|
netName: string;
|
||||||
|
position?: number[];
|
||||||
|
componentRef?: string;
|
||||||
|
pinNumber?: string | number;
|
||||||
|
labelType?: string;
|
||||||
|
orientation?: number;
|
||||||
|
}) => {
|
||||||
const result = await callKicadScript("add_schematic_net_label", args);
|
const result = await callKicadScript("add_schematic_net_label", args);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `Successfully added net label '${args.netName}' at position [${args.position}]`,
|
text: JSON.stringify(result, null, 2),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -358,7 +393,9 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
|
|||||||
// Connect pin to net
|
// Connect pin to net
|
||||||
server.tool(
|
server.tool(
|
||||||
"connect_to_net",
|
"connect_to_net",
|
||||||
"Connect a component pin to a named net",
|
"Connect a component pin to a named net by adding a wire stub and net label at the exact pin endpoint. " +
|
||||||
|
"The response includes pin_location (exact pin coords), label_location (where the label was placed), " +
|
||||||
|
"and wire_stub (the wire segment added) so you can confirm the placement.",
|
||||||
{
|
{
|
||||||
schematicPath: z.string().describe("Path to the schematic file"),
|
schematicPath: z.string().describe("Path to the schematic file"),
|
||||||
componentRef: z.string().describe("Component reference (e.g., U1, R1)"),
|
componentRef: z.string().describe("Component reference (e.g., U1, R1)"),
|
||||||
@@ -377,7 +414,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `Successfully connected ${args.componentRef}/${args.pinName} to net '${args.netName}'`,
|
text: JSON.stringify(result, null, 2),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
300
tests/test_net_label_pin_snapping.py
Normal file
300
tests/test_net_label_pin_snapping.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
"""
|
||||||
|
Tests for net label pin-snapping and connect_to_net richer response.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- add_schematic_net_label with componentRef+pinNumber snaps to exact pin coords
|
||||||
|
- add_schematic_net_label without position and without pin ref returns error
|
||||||
|
- add_schematic_net_label with unknown pin returns an informative error
|
||||||
|
- connect_to_net returns pin_location, label_location, wire_stub on success
|
||||||
|
- connect_to_net returns success=False with message on failure
|
||||||
|
- connect_passthrough uses new dict return from connect_to_net correctly
|
||||||
|
- tool_schemas.py reflects new optional fields
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Path setup – mirror existing test files
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PYTHON_DIR = Path(__file__).parent.parent / "python"
|
||||||
|
sys.path.insert(0, str(PYTHON_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_iface() -> Any:
|
||||||
|
"""Return a KiCADInterface instance with __init__ stubbed out."""
|
||||||
|
for mod in ["pcbnew", "skip"]:
|
||||||
|
sys.modules.setdefault(mod, types.ModuleType(mod))
|
||||||
|
from kicad_interface import KiCADInterface
|
||||||
|
|
||||||
|
with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None):
|
||||||
|
return KiCADInterface.__new__(KiCADInterface)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Schema tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestAddNetLabelSchema:
|
||||||
|
"""Verify tool_schemas.py reflects the new add_schematic_net_label API."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def load_schemas(self) -> Any:
|
||||||
|
from schemas.tool_schemas import SCHEMATIC_TOOLS
|
||||||
|
|
||||||
|
self.tools = {t["name"]: t for t in SCHEMATIC_TOOLS}
|
||||||
|
|
||||||
|
def test_position_is_optional(self) -> None:
|
||||||
|
schema = self.tools["add_schematic_net_label"]["inputSchema"]
|
||||||
|
assert "position" not in schema["required"], "position must not be required"
|
||||||
|
|
||||||
|
def test_component_ref_property_exists(self) -> None:
|
||||||
|
schema = self.tools["add_schematic_net_label"]["inputSchema"]
|
||||||
|
assert "componentRef" in schema["properties"]
|
||||||
|
|
||||||
|
def test_pin_number_property_exists(self) -> None:
|
||||||
|
schema = self.tools["add_schematic_net_label"]["inputSchema"]
|
||||||
|
assert "pinNumber" in schema["properties"]
|
||||||
|
|
||||||
|
def test_only_schematic_path_and_net_name_required(self) -> None:
|
||||||
|
schema = self.tools["add_schematic_net_label"]["inputSchema"]
|
||||||
|
assert set(schema["required"]) == {"schematicPath", "netName"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestConnectToNetSchema:
|
||||||
|
"""Verify tool_schemas.py reflects the richer connect_to_net description."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def load_schemas(self) -> Any:
|
||||||
|
from schemas.tool_schemas import SCHEMATIC_TOOLS
|
||||||
|
|
||||||
|
self.tools = {t["name"]: t for t in SCHEMATIC_TOOLS}
|
||||||
|
|
||||||
|
def test_description_mentions_pin_location(self) -> None:
|
||||||
|
desc = self.tools["connect_to_net"]["description"]
|
||||||
|
assert "pin_location" in desc
|
||||||
|
|
||||||
|
def test_description_mentions_label_location(self) -> None:
|
||||||
|
desc = self.tools["connect_to_net"]["description"]
|
||||||
|
assert "label_location" in desc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. _handle_add_schematic_net_label – unit tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestHandleAddSchematicNetLabelSnapping:
|
||||||
|
"""Unit tests for the pin-snapping path of _handle_add_schematic_net_label."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup(self) -> Any:
|
||||||
|
self.iface = _make_iface()
|
||||||
|
|
||||||
|
# -- happy-path: snap to pin -----------------------------------------
|
||||||
|
|
||||||
|
@patch("commands.wire_manager.WireManager.add_label", return_value=True)
|
||||||
|
@patch("commands.pin_locator.PinLocator.get_pin_location", return_value=[42.0, 13.5])
|
||||||
|
def test_snap_uses_pin_coords(self, mock_pin_loc: Any, mock_add_label: Any) -> None:
|
||||||
|
result = self.iface._handle_add_schematic_net_label(
|
||||||
|
{
|
||||||
|
"schematicPath": "/fake/sch.kicad_sch",
|
||||||
|
"netName": "VCC",
|
||||||
|
"componentRef": "U1",
|
||||||
|
"pinNumber": "1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["actual_position"] == [42.0, 13.5]
|
||||||
|
assert result["snapped_to_pin"] == {"component": "U1", "pin": "1"}
|
||||||
|
# WireManager.add_label must have been called with the pin coords
|
||||||
|
mock_add_label.assert_called_once()
|
||||||
|
call_args = mock_add_label.call_args
|
||||||
|
assert call_args[0][2] == [42.0, 13.5] # position positional arg
|
||||||
|
|
||||||
|
@patch("commands.wire_manager.WireManager.add_label", return_value=True)
|
||||||
|
@patch("commands.pin_locator.PinLocator.get_pin_location", return_value=[10.0, 20.0])
|
||||||
|
def test_snap_ignores_provided_position(self, mock_pin_loc: Any, mock_add_label: Any) -> None:
|
||||||
|
"""If both position and componentRef/pinNumber are given, pin coords win."""
|
||||||
|
result = self.iface._handle_add_schematic_net_label(
|
||||||
|
{
|
||||||
|
"schematicPath": "/fake/sch.kicad_sch",
|
||||||
|
"netName": "GND",
|
||||||
|
"position": [999.0, 999.0],
|
||||||
|
"componentRef": "R1",
|
||||||
|
"pinNumber": "2",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["actual_position"] == [10.0, 20.0]
|
||||||
|
|
||||||
|
# -- error: pin not found --------------------------------------------
|
||||||
|
|
||||||
|
@patch("commands.pin_locator.PinLocator.get_pin_location", return_value=None)
|
||||||
|
def test_snap_unknown_pin_returns_error(self, mock_pin_loc: Any) -> None:
|
||||||
|
result = self.iface._handle_add_schematic_net_label(
|
||||||
|
{
|
||||||
|
"schematicPath": "/fake/sch.kicad_sch",
|
||||||
|
"netName": "VCC",
|
||||||
|
"componentRef": "U99",
|
||||||
|
"pinNumber": "99",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "U99" in result["message"] or "pin" in result["message"].lower()
|
||||||
|
|
||||||
|
# -- error: no position and no pin ref --------------------------------
|
||||||
|
|
||||||
|
def test_no_position_no_ref_returns_error(self) -> None:
|
||||||
|
result = self.iface._handle_add_schematic_net_label(
|
||||||
|
{
|
||||||
|
"schematicPath": "/fake/sch.kicad_sch",
|
||||||
|
"netName": "VCC",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "position" in result["message"].lower() or "componentRef" in result["message"]
|
||||||
|
|
||||||
|
# -- happy-path: explicit position ------------------------------------
|
||||||
|
|
||||||
|
@patch("commands.wire_manager.WireManager.add_label", return_value=True)
|
||||||
|
def test_explicit_position_used_when_no_ref(self, mock_add_label: Any) -> None:
|
||||||
|
result = self.iface._handle_add_schematic_net_label(
|
||||||
|
{
|
||||||
|
"schematicPath": "/fake/sch.kicad_sch",
|
||||||
|
"netName": "CLK",
|
||||||
|
"position": [55.0, 77.0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["actual_position"] == [55.0, 77.0]
|
||||||
|
assert "snapped_to_pin" not in result
|
||||||
|
|
||||||
|
# -- missing required params -----------------------------------------
|
||||||
|
|
||||||
|
def test_missing_net_name_returns_error(self) -> None:
|
||||||
|
result = self.iface._handle_add_schematic_net_label(
|
||||||
|
{
|
||||||
|
"schematicPath": "/fake/sch.kicad_sch",
|
||||||
|
"position": [10.0, 20.0],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert result["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. connect_to_net – unit tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestConnectToNetRicherResponse:
|
||||||
|
"""connect_to_net now returns coordinates instead of a bare bool."""
|
||||||
|
|
||||||
|
@patch("commands.wire_manager.WireManager.add_label", return_value=True)
|
||||||
|
@patch("commands.wire_manager.WireManager.add_wire", return_value=True)
|
||||||
|
@patch("commands.pin_locator.PinLocator.get_pin_angle", return_value=0.0)
|
||||||
|
@patch("commands.pin_locator.PinLocator.get_pin_location", return_value=[100.0, 50.0])
|
||||||
|
def test_success_returns_coordinates(
|
||||||
|
self,
|
||||||
|
mock_pin_loc: Any,
|
||||||
|
mock_pin_angle: Any,
|
||||||
|
mock_add_wire: Any,
|
||||||
|
mock_add_label: Any,
|
||||||
|
) -> None:
|
||||||
|
from commands.connection_schematic import ConnectionManager
|
||||||
|
|
||||||
|
result = ConnectionManager.connect_to_net(Path("/fake/sch.kicad_sch"), "U1", "5", "VCC")
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["pin_location"] == [100.0, 50.0]
|
||||||
|
assert "label_location" in result
|
||||||
|
assert "wire_stub" in result
|
||||||
|
# wire_stub is [[pin_x, pin_y], [label_x, label_y]]
|
||||||
|
assert result["wire_stub"][0] == [100.0, 50.0]
|
||||||
|
assert result["wire_stub"][1] == result["label_location"]
|
||||||
|
|
||||||
|
@patch("commands.pin_locator.PinLocator.get_pin_location", return_value=None)
|
||||||
|
def test_unknown_pin_returns_failure_dict(self, mock_pin_loc: Any) -> None:
|
||||||
|
from commands.connection_schematic import ConnectionManager
|
||||||
|
|
||||||
|
result = ConnectionManager.connect_to_net(Path("/fake/sch.kicad_sch"), "U99", "99", "VCC")
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "message" in result
|
||||||
|
|
||||||
|
@patch("commands.wire_manager.WireManager.add_wire", return_value=False)
|
||||||
|
@patch("commands.pin_locator.PinLocator.get_pin_angle", return_value=0.0)
|
||||||
|
@patch("commands.pin_locator.PinLocator.get_pin_location", return_value=[10.0, 20.0])
|
||||||
|
def test_wire_failure_returns_failure_dict(
|
||||||
|
self, mock_pin_loc: Any, mock_pin_angle: Any, mock_add_wire: Any
|
||||||
|
) -> None:
|
||||||
|
from commands.connection_schematic import ConnectionManager
|
||||||
|
|
||||||
|
result = ConnectionManager.connect_to_net(Path("/fake/sch.kicad_sch"), "R1", "1", "GND")
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "message" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. connect_passthrough – uses dict return correctly
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestConnectPassthroughUsesDict:
|
||||||
|
"""connect_passthrough must handle the dict returned by connect_to_net."""
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"commands.connection_schematic.ConnectionManager.connect_to_net",
|
||||||
|
return_value={
|
||||||
|
"success": True,
|
||||||
|
"pin_location": [0, 0],
|
||||||
|
"label_location": [2.54, 0],
|
||||||
|
"wire_stub": [[0, 0], [2.54, 0]],
|
||||||
|
"message": "ok",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
@patch(
|
||||||
|
"commands.pin_locator.PinLocator.get_all_symbol_pins",
|
||||||
|
side_effect=[{"1": [0.0, 0.0]}, {"1": [10.0, 10.0]}],
|
||||||
|
)
|
||||||
|
def test_passthrough_succeeds_with_dict_return(self, mock_pins: Any, mock_connect: Any) -> None:
|
||||||
|
from commands.connection_schematic import ConnectionManager
|
||||||
|
|
||||||
|
result = ConnectionManager.connect_passthrough(
|
||||||
|
Path("/fake/sch.kicad_sch"), "J1", "J2", net_prefix="PIN"
|
||||||
|
)
|
||||||
|
assert len(result["connected"]) == 1
|
||||||
|
assert len(result["failed"]) == 0
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"commands.connection_schematic.ConnectionManager.connect_to_net",
|
||||||
|
return_value={"success": False, "message": "pin not found"},
|
||||||
|
)
|
||||||
|
@patch(
|
||||||
|
"commands.pin_locator.PinLocator.get_all_symbol_pins",
|
||||||
|
side_effect=[{"1": [0.0, 0.0]}, {"1": [10.0, 10.0]}],
|
||||||
|
)
|
||||||
|
def test_passthrough_records_failure_with_dict_return(
|
||||||
|
self, mock_pins: Any, mock_connect: Any
|
||||||
|
) -> None:
|
||||||
|
from commands.connection_schematic import ConnectionManager
|
||||||
|
|
||||||
|
result = ConnectionManager.connect_passthrough(
|
||||||
|
Path("/fake/sch.kicad_sch"), "J1", "J2", net_prefix="PIN"
|
||||||
|
)
|
||||||
|
assert len(result["failed"]) >= 1
|
||||||
Reference in New Issue
Block a user