Merge pull request #92 from mixelpixx/fix/net-label-pin-snapping-feedback

feat: pin-snapping, coordinate feedback, and schematic diagnostics for net label tools
This commit is contained in:
Eugene Mikhantyev
2026-04-12 18:18:13 +01:00
committed by GitHub
16 changed files with 3520 additions and 99 deletions

60
CLAUDE.md Normal file
View File

@@ -0,0 +1,60 @@
# KiCAD MCP Server
## Related Source
- KiCad source code is located at `../kicad-source/` (absolute: `/home/eugene/Projects/kicad-source/`)
## Testing
### When to Write Tests
Write tests for every non-trivial change to Python handler or business logic code:
- **New MCP tools** — add tests for schema validation, handler dispatch, parameter validation, and the core logic path (happy path + key error cases).
- **Changes to existing tools** — add tests covering the changed behaviour; update any tests that no longer reflect reality.
- **Bug fixes** — add a regression test that would have caught the bug before adding the fix.
- **Refactors that delete or rename public methods** — add a test asserting the old name no longer exists (see `TestConnectionManagerOrphanedMethodsRemoved` for the pattern).
You do **not** need tests for TypeScript/TS-layer glue code that only forwards calls to Python (the TS test runner is not yet configured).
### Test Levels
| Level | Use for | Marker |
| ----------- | -------------------------------------------------------------------------------------------------- | -------------------------- |
| Unit | Schema shape, parameter validation, pure logic, mock-heavy handler dispatch | `@pytest.mark.unit` |
| Integration | Real file I/O against a `.kicad_sch` / `.kicad_pcb` copy; WireManager, JunctionManager round-trips | `@pytest.mark.integration` |
Keep unit tests free of file I/O. Keep integration tests free of business-logic assertions that belong in unit tests.
### Where to Put Tests
```
tests/
test_<feature_name>.py # all Python tests go here
```
Group related test classes inside a single file (e.g. `TestSchemas`, `TestHandlerDispatch`, `TestHandleAddSchematicWireRouting` all in `test_wire_junction_changes.py`). Name classes `Test<Area>` and methods `test_<what_is_verified>`.
Use `python/templates/empty.kicad_sch` as the base fixture for integration tests — copy it to a `tempfile` directory, run the handler, then parse the result with `sexpdata`.
### Running Tests
Always use the `.venv` virtualenv for Python commands:
```bash
npm run test:py # pytest tests/ -v
.venv/bin/pytest tests/ -v # all Python tests
.venv/bin/pytest -m unit # unit tests only
.venv/bin/pytest -m integration # integration tests only
.venv/bin/pytest --cov=python # with coverage report
.venv/bin/mypy python/ # type checking
```
## Git Workflow
- **Never open a pull request automatically.** Commit and push when asked, but always wait for explicit instructions before running `gh pr create` or any equivalent command.
## Python Code Style
- **Never use `assert` in production code** — raise a specific exception (`ValueError`, `RuntimeError`, etc.) instead. `assert` is stripped in optimised builds and gives poor error messages.
- **Do not introduce logic-breaking workarounds to satisfy the type checker** (e.g. `x or ""` when `""` is not a valid substitute for `None`). Fix the types or narrow with a proper guard (`if x is None: raise ...`).

View File

@@ -120,15 +120,34 @@ Connect two component pins with a wire. Use this for individual connections betw
Add a net label to the schematic. Add a net label to the schematic.
| Parameter | Type | Required | Description | **Preferred usage — snap to pin:** supply `componentRef` + `pinNumber` and the label is placed at the exact pin endpoint resolved by `PinLocator`. This guarantees an electrical connection. A 0.01 mm offset is enough to break the connection in KiCad, so this mode eliminates all guesswork.
| ------------- | ------ | -------- | ------------------------------------------ |
| schematicPath | string | Yes | Path to the schematic file | **Alternative — explicit position:** supply `position [x, y]`. The coordinates must match a pin or wire endpoint exactly; use `get_schematic_pin_locations` first to obtain them.
| netName | string | Yes | Name of the net (e.g., VCC, GND, SIGNAL_1) |
| position | array | Yes | Position [x, y] for the label | | Parameter | Type | Required | Description |
| ------------- | -------------- | -------- | ---------------------------------------------------------------------- |
| schematicPath | string | Yes | Path to the schematic file |
| netName | string | Yes | Name of the net (e.g., VCC, GND, SIGNAL_1) |
| position | array [x, y] | No\* | Explicit position. Required when `componentRef`/`pinNumber` not given. |
| componentRef | string | No\* | Component reference to snap to (e.g. U1). Use with `pinNumber`. |
| pinNumber | string\|number | No\* | Pin number or name (e.g. `"1"`, `"GND"`). Use with `componentRef`. |
| labelType | string | No | `label` (default), `global_label`, or `hierarchical_label` |
| orientation | number | No | Rotation angle: 0, 90, 180, 270 (default: 0) |
\* Either `position` **or** (`componentRef` + `pinNumber`) is required.
**Response fields:**
| Field | Description |
| --------------- | ------------------------------------------------------------ |
| success | `true` / `false` |
| actual_position | `[x, y]` coordinates where the label was actually placed |
| snapped_to_pin | `{component, pin}` — present only when pin-snapping was used |
| message | Human-readable status |
### 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 from the pin endpoint and placing a net label at the stub's far end. The exact pin coordinates are resolved internally via `PinLocator`.
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ---------------------------------- | | ------------- | ------ | -------- | ---------------------------------- |
@@ -137,7 +156,17 @@ Connect a component pin to a named net.
| pinName | string | Yes | Pin name/number to connect | | pinName | string | Yes | Pin name/number to connect |
| netName | string | Yes | Name of the net to connect to | | netName | string | Yes | Name of the net to connect to |
**Usage Notes:** Creates a wire stub from the pin and places a net label at the stub endpoint. The stub direction follows the pin's outward angle. Default stub length is 2.54mm (0.1 inch, standard grid spacing). **Response fields:**
| Field | Description |
| -------------- | ------------------------------------------ |
| success | `true` / `false` |
| pin_location | `[x, y]` exact pin endpoint used |
| label_location | `[x, y]` where the net label was placed |
| wire_stub | `[[x1,y1],[x2,y2]]` the wire segment added |
| message | Human-readable status |
**Usage Notes:** Creates a wire stub from the pin and places a net label at the stub endpoint. The stub direction follows the pin's outward angle. Default stub length is 2.54 mm (0.1 inch, standard grid spacing). Check `pin_location` in the response to confirm the correct pin was found; no separate verification call is needed.
### connect_passthrough ### connect_passthrough
@@ -155,7 +184,7 @@ Connects all pins of a source connector (e.g. J1) to matching pins of a target c
### get_schematic_pin_locations ### get_schematic_pin_locations
Returns the exact x/y coordinates of every pin on a schematic component. Use this before add_schematic_net_label to place labels correctly on pin endpoints. Returns the exact x/y coordinates of every pin on a schematic component. Useful for inspection or when building custom placement logic. When the goal is to connect a pin to a net, prefer `add_schematic_net_label` with `componentRef`+`pinNumber` (which calls this internally) or `connect_to_net` — both snap to the exact pin endpoint automatically.
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ------------------------------------------------ | | ------------- | ------ | -------- | ------------------------------------------------ |
@@ -182,7 +211,7 @@ Remove a net label from the schematic.
| netName | string | Yes | Name of the net label to remove | | netName | string | Yes | Name of the net label to remove |
| position | object | No | Position to disambiguate if multiple labels with same name (x and y coordinates) | | position | object | No | Position to disambiguate if multiple labels with same name (x and y coordinates) |
## Net Analysis (4 tools) ## Net Analysis (5 tools)
### get_net_connections ### get_net_connections
@@ -219,6 +248,26 @@ List all net labels, global labels, and power flags in the schematic.
| ------------- | ------ | -------- | --------------------------- | | ------------- | ------ | -------- | --------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch file | | schematicPath | string | Yes | Path to the .kicad_sch file |
### get_net_at_point
Return the net name at a given (x, y) coordinate, or `null` if no net label or wire endpoint is present there.
Checks net label / power symbol positions first (exact IU match), then wire endpoints. Faster than `get_wire_connections` when you only need the net name and not full pin traversal.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch schematic file |
| x | number | Yes | X coordinate in mm |
| y | number | Yes | Y coordinate in mm |
**Response fields:**
| Field | Description |
| -------- | ----------------------------------------------------------------------- |
| net_name | Net label string, or `null` if no net found at this point |
| position | `{"x": float, "y": float}` — echoes the query coordinates |
| source | `"net_label"` \| `"wire_endpoint"` \| `null` — how the net was resolved |
## Schematic Creation and Export (5 tools) ## Schematic Creation and Export (5 tools)
### create_schematic ### create_schematic
@@ -271,7 +320,52 @@ Generate a netlist from the schematic.
**Usage Notes:** Returns a complete netlist with component information (reference, value, footprint) and net connections (net name with all connected component/pin pairs). **Usage Notes:** Returns a complete netlist with component information (reference, value, footprint) and net connections (net name with all connected component/pin pairs).
## Validation and Synchronization (3 tools) ## Validation and Synchronization (6 tools)
### list_floating_labels
Return all net labels that are not connected to any component pin.
A label is "floating" when no component pin's coordinate falls on the wire-network reachable from the label's anchor position. Floating labels indicate misplaced or off-grid labels that will cause ERC errors. Does not require the KiCAD UI to be running.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch schematic file |
**Response fields:** list of `{"name": str, "x": float, "y": float, "type": "label" | "global_label"}`.
### find_orphaned_wires
Find wire segments with at least one dangling endpoint — not connected to a component pin, net label, or another wire. Orphaned wires cause ERC "wire end unconnected" errors. Does not require the KiCAD UI to be running.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch schematic file |
**Response fields:**
| Field | Description |
| -------------- | ---------------------------------------------------------------------------- |
| orphaned_wires | List of `{"start": {x,y}, "end": {x,y}, "dangling_ends": [{x,y}, ...]}` (mm) |
| count | Total number of orphaned wire segments |
### snap_to_grid
Snap schematic element coordinates to the nearest grid point. KiCAD uses exact integer matching (10 000 IU/mm) internally, so even a sub-pixel offset makes wires appear connected visually while failing ERC. Run this before `run_erc` to eliminate that class of error. Modifies the `.kicad_sch` file in place. Does not require the KiCAD UI to be running.
| Parameter | Type | Required | Description |
| ------------- | --------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch schematic file |
| gridSize | number | No | Grid spacing in mm (default: 2.54 — standard KiCAD schematic grid; use 1.27 for high-density) |
| elements | array\<string\> | No | Types to snap: `"wires"`, `"junctions"`, `"labels"`, `"components"`. Default: `["wires", "junctions", "labels"]`. `"components"` is opt-in — moving a component without re-routing its wires creates new mismatches. |
**Response fields:**
| Field | Description |
| --------------- | --------------------------------------------------------- |
| snapped | Number of elements that had at least one coordinate moved |
| already_on_grid | Number of elements already on the grid |
| grid_size | Grid spacing used (mm) |
### run_erc ### run_erc

View File

@@ -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,30 +70,38 @@ 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 as e:
logger.warning(
f"Could not get pin angle for {component_ref}/{pin_name}, defaulting to 0: {e}"
)
pin_angle_deg = 0 pin_angle_deg = 0
import math as _math import math as _math
@@ -106,26 +114,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 +193,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:

View File

@@ -7,12 +7,15 @@ and checking connectivity in KiCad schematic files.
import logging import logging
import math import math
from collections import defaultdict
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple from typing import Any, Dict, List, Optional, Set, Tuple
import sexpdata import sexpdata
from commands.pin_locator import PinLocator from commands.pin_locator import PinLocator
from commands.wire_connectivity import _parse_virtual_connections, _to_iu
from sexpdata import Symbol from sexpdata import Symbol
from skip import Schematic
logger = logging.getLogger("kicad_interface") logger = logging.getLogger("kicad_interface")
@@ -872,3 +875,102 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]:
) )
return collisions return collisions
def find_orphaned_wires(schematic_path: Path) -> Dict[str, Any]:
"""
Find wire segments with at least one dangling endpoint.
A wire endpoint is dangling when the IU point at that endpoint satisfies
all three conditions simultaneously:
1. No other wire shares that IU endpoint (would imply a junction / T-join)
2. No component pin is at that IU point
3. No net label or power symbol pin is at that IU point
Uses exact KiCad IU matching (10 000 IU/mm) — same strategy as
wire_connectivity.py — to avoid floating-point tolerance issues.
Returns:
{
"orphaned_wires": [
{
"start": {"x": float, "y": float},
"end": {"x": float, "y": float},
"dangling_ends": [{"x": float, "y": float}, ...]
},
...
],
"count": int
}
"""
sexp_data = _load_sexp(schematic_path)
# --- wire endpoints in mm and IU ---
wires_mm = _parse_wires(sexp_data)
wires_iu: List[Tuple[Tuple[int, int], Tuple[int, int]]] = [
(_to_iu(*w["start"]), _to_iu(*w["end"])) for w in wires_mm
]
# Count how many wires touch each IU endpoint
iu_to_count: Dict[Tuple[int, int], int] = defaultdict(int)
for s_iu, e_iu in wires_iu:
iu_to_count[s_iu] += 1
iu_to_count[e_iu] += 1
# --- anchors: component pins ---
pin_iu: Set[Tuple[int, int]] = set()
try:
locator = PinLocator()
sch = Schematic(str(schematic_path))
for symbol in sch.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
continue
all_pins = locator.get_all_symbol_pins(schematic_path, ref)
for coords in all_pins.values():
pin_iu.add(_to_iu(float(coords[0]), float(coords[1])))
except Exception as e:
logger.warning(f"Error reading pins for symbol: {e}")
except Exception as e:
logger.warning(f"Could not load schematic via skip for pin extraction: {e}")
sch = None
# --- anchors: net labels and global_labels ---
labels = _parse_labels(sexp_data)
label_iu: Set[Tuple[int, int]] = {_to_iu(lbl["x"], lbl["y"]) for lbl in labels}
# --- anchors: power symbol pins (VCC, GND …) ---
power_iu: Set[Tuple[int, int]] = set()
if sch is not None:
try:
point_to_label, _ = _parse_virtual_connections(sch, schematic_path)
power_iu = set(point_to_label.keys())
except Exception as e:
logger.warning(f"Could not extract power symbol anchors: {e}")
anchored_iu = pin_iu | label_iu | power_iu
# --- classify each wire ---
orphaned: List[Dict[str, Any]] = []
for i, (s_iu, e_iu) in enumerate(wires_iu):
w = wires_mm[i]
dangling_ends: List[Dict[str, float]] = []
for pt_iu, pt_mm in [(s_iu, w["start"]), (e_iu, w["end"])]:
if iu_to_count[pt_iu] > 1:
continue # shared with another wire → connected
if pt_iu in anchored_iu:
continue # touches a pin or label → connected
dangling_ends.append({"x": pt_mm[0], "y": pt_mm[1]})
if dangling_ends:
orphaned.append(
{
"start": {"x": w["start"][0], "y": w["start"][1]},
"end": {"x": w["end"][0], "y": w["end"][1]},
"dangling_ends": dangling_ends,
}
)
return {"orphaned_wires": orphaned, "count": len(orphaned)}

View File

@@ -0,0 +1,211 @@
"""
Snap-to-grid tool for KiCAD schematics.
Snaps wire endpoints, junction positions, net labels, and optionally component
positions to the nearest grid point. Modifies the schematic file in place.
The standard KiCAD schematic grid is 50 mil (1.27 mm). Component pins are
placed at multiples of 1.27 mm relative to the symbol origin, so absolute pin
coordinates end up as odd multiples of 1.27 mm (e.g. 26.67 mm = 21 × 1.27 mm).
These are valid on-grid positions that must not be moved.
The coarser 2.54 mm (100-mil) grid is a common mistake: exactly half of all
valid 1.27 mm positions are not multiples of 2.54 mm and would be displaced by
1.27 mm — moving labels or wire endpoints off their pins and breaking
connectivity.
Off-grid coordinates cause wires that appear visually connected to fail ERC
connectivity checks because KiCAD uses exact integer (IU) matching internally.
"""
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
import sexpdata
from sexpdata import Symbol
logger = logging.getLogger("kicad_interface")
_DEFAULT_GRID_MM: float = 1.27
# Element type names exposed in the public API
_VALID_ELEMENTS = frozenset({"wires", "junctions", "labels", "components"})
# Tags treated as net labels (all have (at x y angle) structure)
_LABEL_TAGS = frozenset(
{
Symbol("label"),
Symbol("global_label"),
Symbol("hierarchical_label"),
Symbol("net_tie"),
Symbol("no_connect"),
}
)
def _snap_mm(value: float, grid_mm: float) -> float:
"""Snap a single coordinate to the nearest grid multiple."""
return round(value / grid_mm) * grid_mm
def _is_on_grid(value: float, grid_mm: float, eps: float = 1e-9) -> bool:
"""Return True if *value* is already within *eps* of a grid point."""
snapped = _snap_mm(value, grid_mm)
return abs(value - snapped) < eps
def _snap_xy_pair(item: list, grid_mm: float) -> int:
"""
Snap a ``(xy x y)`` S-expression item in place.
Returns 1 if at least one coordinate changed, 0 otherwise.
"""
if not (isinstance(item, list) and len(item) >= 3 and item[0] == Symbol("xy")):
return 0
x_orig, y_orig = float(item[1]), float(item[2])
x_new = _snap_mm(x_orig, grid_mm)
y_new = _snap_mm(y_orig, grid_mm)
changed = not (_is_on_grid(x_orig, grid_mm) and _is_on_grid(y_orig, grid_mm))
item[1] = x_new
item[2] = y_new
return 1 if changed else 0
def _snap_at_xy(item: list, grid_mm: float) -> int:
"""
Snap an ``(at x y ...)`` S-expression item in place (indices 1 and 2 only).
Preserves rotation / angle at index 3+ unchanged.
Returns 1 if at least one coordinate changed, 0 otherwise.
"""
if not (isinstance(item, list) and len(item) >= 3 and item[0] == Symbol("at")):
return 0
x_orig, y_orig = float(item[1]), float(item[2])
x_new = _snap_mm(x_orig, grid_mm)
y_new = _snap_mm(y_orig, grid_mm)
changed = not (_is_on_grid(x_orig, grid_mm) and _is_on_grid(y_orig, grid_mm))
item[1] = x_new
item[2] = y_new
return 1 if changed else 0
def snap_to_grid(
schematic_path: Path,
grid_size: float = _DEFAULT_GRID_MM,
elements: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""
Snap element coordinates in a ``.kicad_sch`` file to the nearest grid point.
Modifies the file in place and returns statistics.
Args:
schematic_path: Path to the ``.kicad_sch`` file.
grid_size: Grid spacing in mm (default 1.27 mm = 50 mil).
Do NOT use 2.54 mm — half of all valid KiCAD pin
positions fall between 2.54 mm grid lines and would
be displaced 1.27 mm, breaking connectivity.
elements: List of element types to snap. Valid values:
``"wires"``, ``"junctions"``, ``"labels"``,
``"components"``. Defaults to
``["wires", "junctions", "labels"]`` when ``None``.
Returns:
``{"snapped": int, "already_on_grid": int, "grid_size": float}``
where *snapped* is the number of elements that had at least one
coordinate moved.
"""
if grid_size <= 0:
raise ValueError(f"grid_size must be positive, got {grid_size}")
if elements is None:
active: frozenset = frozenset({"wires", "junctions", "labels"})
else:
unknown = set(elements) - _VALID_ELEMENTS
if unknown:
raise ValueError(
f"Unknown element type(s): {sorted(unknown)}. "
f"Valid types: {sorted(_VALID_ELEMENTS)}"
)
active = frozenset(elements)
with open(schematic_path, "r", encoding="utf-8") as fh:
sch_data = sexpdata.loads(fh.read())
snapped = 0
already_on_grid = 0
snap_wires = "wires" in active
snap_junctions = "junctions" in active
snap_labels = "labels" in active
snap_components = "components" in active
for item in sch_data:
if not isinstance(item, list) or not item:
continue
tag = item[0]
# -----------------------------------------------------------------
# Wires: (wire (pts (xy x y) (xy x y)) ...)
# -----------------------------------------------------------------
if snap_wires and tag == Symbol("wire"):
changed = 0
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == Symbol("pts"):
for pt in sub[1:]:
changed += _snap_xy_pair(pt, grid_size)
if changed:
snapped += 1
else:
already_on_grid += 1
continue
# -----------------------------------------------------------------
# Junctions: (junction (at x y) ...)
# -----------------------------------------------------------------
if snap_junctions and tag == Symbol("junction"):
changed = 0
for sub in item[1:]:
changed += _snap_at_xy(sub, grid_size)
if changed:
snapped += 1
else:
already_on_grid += 1
continue
# -----------------------------------------------------------------
# Labels: (label|global_label|hierarchical_label|no_connect … (at x y angle) …)
# -----------------------------------------------------------------
if snap_labels and tag in _LABEL_TAGS:
changed = 0
for sub in item[1:]:
changed += _snap_at_xy(sub, grid_size)
if changed:
snapped += 1
else:
already_on_grid += 1
continue
# -----------------------------------------------------------------
# Components: (symbol (lib_id …) (at x y rotation) …)
# Snap only the top-level (at …) — not property sub-positions.
# -----------------------------------------------------------------
if snap_components and tag == Symbol("symbol"):
changed = 0
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == Symbol("at"):
changed += _snap_at_xy(sub, grid_size)
break # only the first (at …) belongs to the symbol itself
if changed:
snapped += 1
else:
already_on_grid += 1
continue
with open(schematic_path, "w", encoding="utf-8") as fh:
fh.write(sexpdata.dumps(sch_data))
return {
"snapped": snapped,
"already_on_grid": already_on_grid,
"grid_size": grid_size,
}

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 —
@@ -230,13 +230,16 @@ def get_wire_connections(
treated as connected even when they are not geometrically adjacent. treated as connected even when they are not geometrically adjacent.
Returns dict with keys: Returns dict with keys:
- "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}
Or None if no wire endpoint found within tolerance of 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 {"pins": [], "wires": []} 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)
@@ -254,6 +257,14 @@ def get_wire_connections(
if visited is None: if visited is None:
return None return None
# Resolve net name: first label anchor that falls on this net's IU points
net: Optional[str] = None
for pt in net_points:
label = point_to_label.get(pt)
if label is not None:
net = label
break
wires_out = [ wires_out = [
{ {
"start": { "start": {
@@ -269,7 +280,215 @@ def get_wire_connections(
] ]
if not hasattr(schematic, "symbol"): if not hasattr(schematic, "symbol"):
return {"pins": [], "wires": wires_out} return {"net": net, "pins": [], "wires": wires_out, "query_point": query_point}
pins = _find_pins_on_net(net_points, schematic_path, schematic) pins = _find_pins_on_net(net_points, schematic_path, schematic)
return {"pins": pins, "wires": wires_out} return {"net": net, "pins": pins, "wires": wires_out, "query_point": query_point}
def count_pins_on_net(
schematic: Any,
schematic_path: str,
net_name: str,
all_wires: List[List[Tuple[int, int]]],
iu_to_wires: Dict[Tuple[int, int], Set[int]],
adjacency: List[Set[int]],
point_to_label: Dict[Tuple[int, int], str],
label_to_points: Dict[str, List[Tuple[int, int]]],
) -> int:
"""Count the number of component pins connected to the named net.
A pin is counted if its IU coordinate falls on the wire-network reachable
from any label anchor for *net_name*, or directly on a label anchor of that
net (pin directly touching a label with no intervening wire).
Returns the count of distinct (component, pin_num) pairs on this net.
"""
label_positions = label_to_points.get(net_name, [])
if not label_positions:
return 0
# Collect the union of all net-points across all label positions for this net
all_net_points: Set[Tuple[int, int]] = set()
for lx, ly in label_positions:
# Include the label anchor itself so pins directly at the label count
all_net_points.add((lx, ly))
# Trace from this label position into the wire graph
x_mm = lx / _IU_PER_MM
y_mm = ly / _IU_PER_MM
visited, net_points = _find_connected_wires(
x_mm,
y_mm,
all_wires,
iu_to_wires,
adjacency,
point_to_label=point_to_label,
label_to_points=label_to_points,
)
if net_points:
all_net_points |= net_points
if not hasattr(schematic, "symbol"):
return 0
locator = PinLocator()
seen: Set[Tuple[str, str]] = set()
ref = None
for symbol in schematic.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
continue
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
if not all_pins:
continue
for pin_num, pin_data in all_pins.items():
pin_iu = _to_iu(float(pin_data[0]), float(pin_data[1]))
if pin_iu in all_net_points:
key = (ref, pin_num)
if key not in seen:
seen.add(key)
except Exception as e:
logger.warning(
f"Error checking pins for {ref if ref is not None else '<unknown>'}: {e}"
)
return len(seen)
def list_floating_labels(schematic: Any, schematic_path: str) -> List[Dict[str, Any]]:
"""Return net labels that are not connected to any component pin.
A label is "floating" when no component pin's IU coordinate falls on the
wire-network reachable from the label's anchor position. These labels are
likely placed off-grid or incorrectly positioned and will cause ERC errors.
Returns a list of dicts with keys:
- "name": str — the net label text
- "x": float — label X position in mm
- "y": float — label Y position in mm
- "type": str — "label" or "global_label"
"""
all_wires = _parse_wires(schematic)
if all_wires:
adjacency, iu_to_wires = _build_adjacency(all_wires)
else:
adjacency = []
iu_to_wires = {}
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
# Build a set of all pin IU positions for fast lookup
pin_iu_set: Set[Tuple[int, int]] = set()
if hasattr(schematic, "symbol"):
locator = PinLocator()
for symbol in schematic.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
continue
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
if not all_pins:
continue
for pin_data in all_pins.values():
pin_iu_set.add(_to_iu(float(pin_data[0]), float(pin_data[1])))
except Exception as e:
logger.warning(f"Error reading pins for floating-label check: {e}")
floating: List[Dict[str, Any]] = []
if not hasattr(schematic, "label"):
return floating
for label in schematic.label:
try:
if not hasattr(label, "value"):
continue
name = label.value
if not hasattr(label, "at") or not hasattr(label.at, "value"):
continue
coords = label.at.value
lx_mm = float(coords[0])
ly_mm = float(coords[1])
label_iu = _to_iu(lx_mm, ly_mm)
# Check if the label anchor itself is a pin position
if label_iu in pin_iu_set:
continue
# Trace the wire-network from this label and check for pins
if all_wires:
_, net_points = _find_connected_wires(
lx_mm,
ly_mm,
all_wires,
iu_to_wires,
adjacency,
point_to_label=point_to_label,
label_to_points=label_to_points,
)
else:
net_points = None
if net_points is not None and net_points & pin_iu_set:
continue # at least one pin on this net
floating.append({"name": name, "x": lx_mm, "y": ly_mm, "type": "label"})
except Exception as e:
logger.warning(f"Error checking label for floating status: {e}")
return floating
def get_net_at_point(
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
) -> Dict[str, Any]:
"""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:
adjacency, 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
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}

View File

@@ -13,7 +13,7 @@ import os
import sys import sys
import traceback import traceback
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read
@@ -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,
"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,
@@ -403,6 +404,9 @@ class KiCADInterface:
"find_overlapping_elements": self._handle_find_overlapping_elements, "find_overlapping_elements": self._handle_find_overlapping_elements,
"get_elements_in_region": self._handle_get_elements_in_region, "get_elements_in_region": self._handle_get_elements_in_region,
"find_wires_crossing_symbols": self._handle_find_wires_crossing_symbols, "find_wires_crossing_symbols": self._handle_find_wires_crossing_symbols,
"find_orphaned_wires": self._handle_find_orphaned_wires,
"list_floating_labels": self._handle_list_floating_labels,
"snap_to_grid": self._handle_snap_to_grid,
"import_svg_logo": self._handle_import_svg_logo, "import_svg_logo": self._handle_import_svg_logo,
# UI/Process management commands # UI/Process management commands
"check_kicad_ui": self._handle_check_kicad_ui, "check_kicad_ui": self._handle_check_kicad_ui,
@@ -1514,7 +1518,13 @@ 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:
from pathlib import Path from pathlib import Path
@@ -1524,13 +1534,66 @@ 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."
),
}
# Collect existing net names BEFORE adding the new label so we can
# detect case-mismatch collisions against pre-existing nets only.
existing_net_names: List[str] = []
try:
pre_schematic = SchematicManager.load_schematic(schematic_path)
if pre_schematic is not None:
if hasattr(pre_schematic, "label"):
for lbl in pre_schematic.label:
if hasattr(lbl, "value"):
existing_net_names.append(lbl.value)
if hasattr(pre_schematic, "global_label"):
for lbl in pre_schematic.global_label:
if hasattr(lbl, "value"):
existing_net_names.append(lbl.value)
except Exception:
# Non-fatal: if we can't read existing nets, skip the warning
existing_net_names = []
# Use WireManager for S-expression manipulation # Use WireManager for S-expression manipulation
success = WireManager.add_label( success = WireManager.add_label(
@@ -1541,13 +1604,33 @@ 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"}
# Compute case-mismatch warnings against pre-existing net names.
# A collision is: existing name != new name, but lowercases match.
new_name_lower = net_name.lower()
case_warnings: List[str] = [
f"Net '{existing}' already exists — label '{net_name}' may be a case mismatch."
for existing in existing_net_names
if existing.lower() == new_name_lower and existing != net_name
]
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]})"
)
if case_warnings:
response["case_warnings"] = case_warnings
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 +1657,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
@@ -1876,6 +1952,13 @@ class KiCADInterface:
try: try:
from pathlib import Path from pathlib import Path
from commands.wire_connectivity import (
_build_adjacency,
_parse_virtual_connections,
_parse_wires,
count_pins_on_net,
)
schematic_path = params.get("schematicPath") schematic_path = params.get("schematicPath")
if not schematic_path: if not schematic_path:
return {"success": False, "message": "schematicPath is required"} return {"success": False, "message": "schematicPath is required"}
@@ -1895,15 +1978,34 @@ class KiCADInterface:
if hasattr(label, "value"): if hasattr(label, "value"):
net_names.add(label.value) net_names.add(label.value)
# Pre-build shared wire graph structures for efficiency
all_wires = _parse_wires(schematic)
if all_wires:
adjacency, iu_to_wires = _build_adjacency(all_wires)
else:
adjacency, iu_to_wires = [], {}
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
nets = [] nets = []
for net_name in sorted(net_names): for net_name in sorted(net_names):
connections = ConnectionManager.get_net_connections( connections = ConnectionManager.get_net_connections(
schematic, net_name, Path(schematic_path) schematic, net_name, Path(schematic_path)
) )
pin_count = count_pins_on_net(
schematic,
schematic_path,
net_name,
all_wires,
iu_to_wires,
adjacency,
point_to_label,
label_to_points,
)
nets.append( nets.append(
{ {
"name": net_name, "name": net_name,
"connections": connections, "connections": connections,
"connected_pin_count": pin_count,
} }
) )
@@ -2411,29 +2513,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"}
@@ -2445,7 +2575,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}
@@ -2457,6 +2587,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_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")
@@ -2892,6 +3056,91 @@ 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_find_orphaned_wires(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Find wire segments with at least one dangling (unconnected) endpoint"""
logger.info("Finding orphaned wires in schematic")
try:
from pathlib import Path
from commands.schematic_analysis import find_orphaned_wires
schematic_path = params.get("schematicPath")
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
result = find_orphaned_wires(Path(schematic_path))
return {
"success": True,
**result,
"message": f"Found {result['count']} orphaned wire(s)",
}
except Exception as e:
logger.error(f"Error finding orphaned wires: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_list_floating_labels(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""List net labels that are not connected to any component pin"""
logger.info("Listing floating net labels in schematic")
try:
from commands.wire_connectivity import list_floating_labels
schematic_path = params.get("schematicPath")
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
schematic = SchematicManager.load_schematic(schematic_path)
if not schematic:
return {"success": False, "message": "Failed to load schematic"}
labels = list_floating_labels(schematic, schematic_path)
return {
"success": True,
"floating_labels": labels,
"count": len(labels),
"message": f"Found {len(labels)} floating label(s)",
}
except Exception as e:
logger.error(f"Error listing floating labels: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_snap_to_grid(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Snap schematic element coordinates to the nearest grid point"""
logger.info("Snapping schematic elements to grid")
try:
from pathlib import Path
from commands.schematic_snap import snap_to_grid
schematic_path = params.get("schematicPath")
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
grid_size = float(params.get("gridSize", 1.27))
elements = params.get("elements") # None → defaults inside snap_to_grid
result = snap_to_grid(Path(schematic_path), grid_size=grid_size, elements=elements)
total = result["snapped"] + result["already_on_grid"]
return {
"success": True,
**result,
"message": (
f"Snapped {result['snapped']} element(s) to {grid_size} mm grid "
f"({result['already_on_grid']} of {total} were already on grid)"
),
}
except Exception as e:
logger.error(f"Error snapping to grid: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_import_svg_logo(self, params: Dict[str, Any]) -> Dict[str, Any]: def _handle_import_svg_logo(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Import an SVG file as PCB graphic polygons on the silkscreen""" """Import an SVG file as PCB graphic polygons on the silkscreen"""
logger.info("Importing SVG logo into PCB") logger.info("Importing SVG logo into PCB")

View File

@@ -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"],
}, },
}, },
{ {
@@ -1471,21 +1503,67 @@ 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"],
},
},
{
"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"], "required": ["schematicPath", "x", "y"],
@@ -1762,6 +1840,93 @@ SCHEMATIC_TOOLS = [
"required": ["schematicPath"], "required": ["schematicPath"],
}, },
}, },
{
"name": "find_orphaned_wires",
"title": "Find Orphaned Wires",
"description": (
"Find wire segments with at least one dangling endpoint — an endpoint not connected "
"to a component pin, net label, or another wire. "
"Orphaned wires cause ERC 'wire end unconnected' errors and indicate routing mistakes. "
"Does not require the KiCad UI to be running."
),
"inputSchema": {
"type": "object",
"properties": {
"schematicPath": {
"type": "string",
"description": "Path to the .kicad_sch schematic file",
}
},
"required": ["schematicPath"],
},
},
{
"name": "list_floating_labels",
"title": "List Floating Net Labels",
"description": (
"Returns all net labels in the schematic that are not connected to any component pin. "
"A label is 'floating' when no component pin's coordinate falls on the wire-network "
"reachable from the label's anchor position. "
"Floating labels indicate misplaced or off-grid labels that will cause ERC errors. "
"Does not require the KiCad UI to be running."
),
"inputSchema": {
"type": "object",
"properties": {
"schematicPath": {
"type": "string",
"description": "Path to the .kicad_sch schematic file",
}
},
"required": ["schematicPath"],
},
},
{
"name": "snap_to_grid",
"title": "Snap Schematic Elements to Grid",
"description": (
"Snap schematic element coordinates to the nearest grid point. "
"KiCAD eeschema uses exact integer matching (10 000 IU/mm) for connectivity, "
"so even a sub-pixel coordinate offset will make wires appear connected visually "
"but fail ERC checks. Running this tool before ERC eliminates that class of error. "
"Modifies the .kicad_sch file in place. "
"Does not require the KiCAD UI to be running."
),
"inputSchema": {
"type": "object",
"properties": {
"schematicPath": {
"type": "string",
"description": "Path to the .kicad_sch schematic file",
},
"gridSize": {
"type": "number",
"description": (
"Grid spacing in mm (default: 1.27 — standard KiCAD schematic grid). "
"Do NOT use 2.54: half of all valid KiCAD pin positions are at odd "
"multiples of 1.27 mm and would be displaced 1.27 mm, breaking "
"connectivity."
),
"default": 1.27,
},
"elements": {
"type": "array",
"description": (
"Element types to snap. "
'Valid values: "wires", "junctions", "labels", "components". '
'Defaults to ["wires", "junctions", "labels"] when omitted. '
'"components" is opt-in because moving a component without re-routing '
"its wires will create new mismatches."
),
"items": {
"type": "string",
"enum": ["wires", "junctions", "labels", "components"],
},
},
},
"required": ["schematicPath"],
},
},
] ]
# ============================================================================= # =============================================================================

View File

@@ -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),
}, },
], ],
}; };
@@ -432,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)"}`,
}, },
], ],
}; };
@@ -623,7 +686,9 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
} }
const lines = nets.map((n: any) => { const lines = nets.map((n: any) => {
const conns = (n.connections || []).map((c: any) => `${c.component}/${c.pin}`).join(", "); const conns = (n.connections || []).map((c: any) => `${c.component}/${c.pin}`).join(", ");
return ` ${n.name}: ${conns || "(no connections)"}`; const pinCount =
n.connected_pin_count !== undefined ? ` [${n.connected_pin_count} pin(s)]` : "";
return ` ${n.name}${pinCount}: ${conns || "(no connections)"}`;
}); });
return { return {
content: [ content: [
@@ -1323,4 +1388,140 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
}; };
}, },
); );
// List floating net labels
server.tool(
"list_floating_labels",
"Returns all net labels in the schematic that are not connected to any component pin. " +
"A label is 'floating' when no component pin falls on the wire-network reachable from the " +
"label's position. Floating labels indicate misplaced or off-grid labels that cause ERC errors. " +
"Does not require the KiCAD UI to be running.",
{
schematicPath: z.string().describe("Path to the .kicad_sch schematic file"),
},
async (args: { schematicPath: string }) => {
const result = await callKicadScript("list_floating_labels", args);
if (result.success) {
const labels: any[] = result.floating_labels || [];
if (labels.length === 0) {
return { content: [{ type: "text", text: "No floating labels found." }] };
}
const lines: string[] = [`Found ${labels.length} floating label(s):\n`];
labels.slice(0, 50).forEach((lbl: any) => {
lines.push(` "${lbl.name}" (${lbl.type}) at (${lbl.x}, ${lbl.y})`);
});
if (labels.length > 50) {
lines.push(` ... and ${labels.length - 50} more`);
}
return { content: [{ type: "text", text: lines.join("\n") }] };
}
return {
content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }],
};
},
);
// Find orphaned wires
server.tool(
"find_orphaned_wires",
"Find wire segments with at least one dangling endpoint — not connected to a component pin, " +
"net label, or another wire. Orphaned wires cause ERC 'wire end unconnected' errors. " +
"Does not require the KiCad UI to be running.",
{
schematicPath: z.string().describe("Path to the .kicad_sch schematic file"),
},
async (args: { schematicPath: string }) => {
const result = await callKicadScript("find_orphaned_wires", args);
if (result.success) {
const wires: any[] = result.orphaned_wires || [];
if (wires.length === 0) {
return { content: [{ type: "text", text: "No orphaned wires found." }] };
}
const lines: string[] = [`Found ${wires.length} orphaned wire(s):\n`];
wires.slice(0, 50).forEach((w: any) => {
const dangling = w.dangling_ends.map((e: any) => `(${e.x}, ${e.y})`).join(", ");
lines.push(
` wire (${w.start.x}, ${w.start.y})→(${w.end.x}, ${w.end.y}) dangling end(s): ${dangling}`,
);
});
if (wires.length > 50) lines.push(` ... and ${wires.length - 50} more`);
return { content: [{ type: "text", text: lines.join("\n") }] };
}
return {
content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }],
};
},
);
// Snap schematic elements to grid
server.tool(
"snap_to_grid",
"Snap schematic element coordinates to the nearest grid point. " +
"KiCAD uses exact integer matching for connectivity, so off-grid coordinates cause wires " +
"that look connected to fail ERC checks. " +
"Modifies the .kicad_sch file in place. Does not require the KiCAD UI to be running.",
{
schematicPath: z.string().describe("Path to the .kicad_sch schematic file"),
gridSize: z
.number()
.optional()
.describe("Grid spacing in mm (default: 2.54 — standard KiCAD schematic grid)"),
elements: z
.array(z.enum(["wires", "junctions", "labels", "components"]))
.optional()
.describe(
'Element types to snap (default: ["wires", "junctions", "labels"]). ' +
'"components" is opt-in — moving a component without re-routing wires creates new mismatches.',
),
},
async (args: { schematicPath: string; gridSize?: number; elements?: string[] }) => {
const result = await callKicadScript("snap_to_grid", args);
if (result.success) {
return { content: [{ type: "text", text: result.message }] };
}
return {
content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }],
};
},
);
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"}`,
},
],
};
}
},
);
} }

View File

@@ -0,0 +1,350 @@
"""
Tests for case-sensitivity warnings in add_schematic_net_label.
When a label is placed whose name differs from an existing net only in case
(e.g. adding "outp" when "OUTP" already exists), the tool should succeed but
include a `case_warnings` list in the response.
Covers:
- Unit: case_warnings populated when names differ only in case
- Unit: no case_warnings when name is an exact match or no similar name exists
- Integration: place "outp" in a schematic that already has "OUTP" -> warning
- Integration: place "VCC" in a schematic that already has "VCC" -> no warning
"""
import shutil
import sys
import tempfile
import types
import uuid
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
PYTHON_DIR = Path(__file__).resolve().parent.parent / "python"
sys.path.insert(0, str(PYTHON_DIR))
TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "python" / "templates" / "empty.kicad_sch"
# ---------------------------------------------------------------------------
# Helpers shared between unit and integration tests
# ---------------------------------------------------------------------------
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)
def _label_sexp(name: str, x: float, y: float, angle: float = 0) -> str:
u = str(uuid.uuid4())
return (
f'(label "{name}" (at {x} {y} {angle})\n'
f" (effects (font (size 1.27 1.27)) (justify left bottom))\n"
f' (uuid "{u}"))'
)
def _make_temp_schematic(extra_sexp: str = "") -> Path:
"""Copy empty.kicad_sch to a temp dir, optionally injecting extra S-expressions."""
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
shutil.copy(TEMPLATE_PATH, tmp)
if extra_sexp:
content = tmp.read_text(encoding="utf-8")
idx = content.rfind(")")
content = content[:idx] + "\n" + extra_sexp + "\n)"
tmp.write_text(content, encoding="utf-8")
return tmp
# ---------------------------------------------------------------------------
# Unit tests — mock file I/O completely
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestCaseWarningPopulated:
"""case_warnings should be present when new label differs from existing by case only."""
@pytest.fixture(autouse=True)
def setup(self) -> None:
self.iface = _make_iface()
def _make_mock_label(self, value: str) -> MagicMock:
lbl = MagicMock()
lbl.value = value
return lbl
def _make_mock_schematic(self, label_names: list) -> MagicMock:
sch = MagicMock(spec=["label", "global_label"])
sch.label = [self._make_mock_label(n) for n in label_names]
sch.global_label = []
return sch
@patch("commands.wire_manager.WireManager.add_label", return_value=True)
@patch("commands.schematic.SchematicManager.load_schematic")
def test_case_warning_when_uppercase_exists(self, mock_load: Any, mock_add_label: Any) -> None:
"""Adding 'outp' when 'OUTP' already exists produces a case_warning."""
mock_load.return_value = self._make_mock_schematic(["OUTP"])
result = self.iface._handle_add_schematic_net_label(
{
"schematicPath": "/fake/sch.kicad_sch",
"netName": "outp",
"position": [10.0, 20.0],
}
)
assert result["success"] is True
assert "case_warnings" in result
assert len(result["case_warnings"]) == 1
assert "OUTP" in result["case_warnings"][0]
assert "outp" in result["case_warnings"][0]
@patch("commands.wire_manager.WireManager.add_label", return_value=True)
@patch("commands.schematic.SchematicManager.load_schematic")
def test_case_warning_when_lowercase_exists(self, mock_load: Any, mock_add_label: Any) -> None:
"""Adding 'OUTP' when 'outp' already exists produces a case_warning."""
mock_load.return_value = self._make_mock_schematic(["outp"])
result = self.iface._handle_add_schematic_net_label(
{
"schematicPath": "/fake/sch.kicad_sch",
"netName": "OUTP",
"position": [10.0, 20.0],
}
)
assert result["success"] is True
assert "case_warnings" in result
assert len(result["case_warnings"]) == 1
@patch("commands.wire_manager.WireManager.add_label", return_value=True)
@patch("commands.schematic.SchematicManager.load_schematic")
def test_multiple_case_collisions_all_reported(
self, mock_load: Any, mock_add_label: Any
) -> None:
"""Multiple existing labels that differ only in case all produce warnings."""
mock_load.return_value = self._make_mock_schematic(["OUTP", "Outp", "oUtP"])
result = self.iface._handle_add_schematic_net_label(
{
"schematicPath": "/fake/sch.kicad_sch",
"netName": "outp",
"position": [10.0, 20.0],
}
)
assert result["success"] is True
assert "case_warnings" in result
assert len(result["case_warnings"]) == 3
@patch("commands.wire_manager.WireManager.add_label", return_value=True)
@patch("commands.schematic.SchematicManager.load_schematic")
def test_global_label_case_collision_reported(
self, mock_load: Any, mock_add_label: Any
) -> None:
"""Case collision against a global_label also produces a warning."""
sch = MagicMock(spec=["label", "global_label"])
sch.label = []
sch.global_label = [self._make_mock_label("VDD")]
mock_load.return_value = sch
result = self.iface._handle_add_schematic_net_label(
{
"schematicPath": "/fake/sch.kicad_sch",
"netName": "vdd",
"position": [10.0, 20.0],
}
)
assert result["success"] is True
assert "case_warnings" in result
assert len(result["case_warnings"]) == 1
assert "VDD" in result["case_warnings"][0]
@pytest.mark.unit
class TestCaseWarningAbsent:
"""case_warnings should be absent (or empty) when there is no case collision."""
@pytest.fixture(autouse=True)
def setup(self) -> None:
self.iface = _make_iface()
def _make_mock_schematic(self, label_names: list) -> MagicMock:
sch = MagicMock(spec=["label", "global_label"])
sch.label = []
for name in label_names:
lbl = MagicMock()
lbl.value = name
sch.label.append(lbl)
sch.global_label = []
return sch
@patch("commands.wire_manager.WireManager.add_label", return_value=True)
@patch("commands.schematic.SchematicManager.load_schematic")
def test_exact_match_no_warning(self, mock_load: Any, mock_add_label: Any) -> None:
"""Adding 'VCC' when 'VCC' already exists is not a case mismatch."""
mock_load.return_value = self._make_mock_schematic(["VCC"])
result = self.iface._handle_add_schematic_net_label(
{
"schematicPath": "/fake/sch.kicad_sch",
"netName": "VCC",
"position": [10.0, 20.0],
}
)
assert result["success"] is True
assert "case_warnings" not in result or result.get("case_warnings") == []
@patch("commands.wire_manager.WireManager.add_label", return_value=True)
@patch("commands.schematic.SchematicManager.load_schematic")
def test_unrelated_nets_no_warning(self, mock_load: Any, mock_add_label: Any) -> None:
"""Adding a label whose name has no case-insensitive match produces no warning."""
mock_load.return_value = self._make_mock_schematic(["GND", "VCC", "CLK"])
result = self.iface._handle_add_schematic_net_label(
{
"schematicPath": "/fake/sch.kicad_sch",
"netName": "MOSI",
"position": [10.0, 20.0],
}
)
assert result["success"] is True
assert "case_warnings" not in result or result.get("case_warnings") == []
@patch("commands.wire_manager.WireManager.add_label", return_value=True)
@patch("commands.schematic.SchematicManager.load_schematic")
def test_empty_schematic_no_warning(self, mock_load: Any, mock_add_label: Any) -> None:
"""Adding a label to an empty schematic produces no warning."""
mock_load.return_value = self._make_mock_schematic([])
result = self.iface._handle_add_schematic_net_label(
{
"schematicPath": "/fake/sch.kicad_sch",
"netName": "SIG",
"position": [10.0, 20.0],
}
)
assert result["success"] is True
assert "case_warnings" not in result or result.get("case_warnings") == []
@patch("commands.wire_manager.WireManager.add_label", return_value=True)
@patch("commands.schematic.SchematicManager.load_schematic")
def test_load_failure_no_warning_but_still_succeeds(
self, mock_load: Any, mock_add_label: Any
) -> None:
"""If loading the schematic for net-name inspection fails, succeed without warning."""
mock_load.side_effect = RuntimeError("I/O error")
result = self.iface._handle_add_schematic_net_label(
{
"schematicPath": "/fake/sch.kicad_sch",
"netName": "SIG",
"position": [10.0, 20.0],
}
)
assert result["success"] is True
assert "case_warnings" not in result or result.get("case_warnings") == []
# ---------------------------------------------------------------------------
# Integration tests — real .kicad_sch file I/O
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestCaseWarningIntegration:
"""End-to-end: use the handler against a real .kicad_sch file."""
@pytest.fixture(autouse=True)
def setup(self) -> None:
self.iface = _make_iface()
def test_case_collision_produces_warning(self) -> None:
"""Placing 'outp' when 'OUTP' already exists in the file produces a warning."""
path = _make_temp_schematic(_label_sexp("OUTP", 10.0, 10.0))
result = self.iface._handle_add_schematic_net_label(
{
"schematicPath": str(path),
"netName": "outp",
"position": [20.0, 20.0],
}
)
assert result["success"] is True, f"Unexpected failure: {result.get('message')}"
assert "case_warnings" in result, "Expected case_warnings in response"
assert len(result["case_warnings"]) >= 1
# Warning message should name both parties
warning_text = " ".join(result["case_warnings"])
assert "OUTP" in warning_text
assert "outp" in warning_text
def test_exact_match_no_warning(self) -> None:
"""Placing 'VCC' when 'VCC' already exists should NOT produce a case warning."""
path = _make_temp_schematic(_label_sexp("VCC", 10.0, 10.0))
result = self.iface._handle_add_schematic_net_label(
{
"schematicPath": str(path),
"netName": "VCC",
"position": [20.0, 20.0],
}
)
assert result["success"] is True, f"Unexpected failure: {result.get('message')}"
case_warnings = result.get("case_warnings", [])
assert case_warnings == [], f"Unexpected case_warnings: {case_warnings}"
def test_no_similar_nets_no_warning(self) -> None:
"""Placing a label whose name has no case-insensitive match gives no warning."""
path = _make_temp_schematic(_label_sexp("GND", 10.0, 10.0))
result = self.iface._handle_add_schematic_net_label(
{
"schematicPath": str(path),
"netName": "MOSI",
"position": [20.0, 20.0],
}
)
assert result["success"] is True, f"Unexpected failure: {result.get('message')}"
case_warnings = result.get("case_warnings", [])
assert case_warnings == [], f"Unexpected case_warnings: {case_warnings}"
def test_mixed_case_collision_multiple_existing(self) -> None:
"""Multiple existing labels with same letters in different case all warn."""
extra = "\n".join(
[
_label_sexp("OUTP", 10.0, 10.0),
_label_sexp("Outp", 15.0, 10.0),
]
)
path = _make_temp_schematic(extra)
result = self.iface._handle_add_schematic_net_label(
{
"schematicPath": str(path),
"netName": "outp",
"position": [20.0, 20.0],
}
)
assert result["success"] is True, f"Unexpected failure: {result.get('message')}"
assert "case_warnings" in result
assert len(result["case_warnings"]) == 2

View 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)}

View File

@@ -0,0 +1,516 @@
"""
Tests for connected_pin_count in list_schematic_nets and the list_floating_labels tool.
Covers:
- Schema registration for list_floating_labels (TestListFloatingLabelsSchema)
- Handler dispatch registration (TestListFloatingLabelsDispatch)
- Parameter validation (TestListFloatingLabelsParamValidation)
- Core logic: list_floating_labels (TestListFloatingLabelsCoreLogic)
- Core logic: count_pins_on_net (TestCountPinsOnNet)
- connected_pin_count field in list_schematic_nets handler (TestListSchematicNetsConnectedPinCount)
- Integration: floating labels in a real schematic file (TestListFloatingLabelsIntegration)
"""
import shutil
import sys
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
from commands.wire_connectivity import (
_build_adjacency,
_parse_virtual_connections,
_parse_wires,
count_pins_on_net,
list_floating_labels,
)
# ---------------------------------------------------------------------------
# Shared mock helpers
# ---------------------------------------------------------------------------
TEMPLATE_SCH = Path(__file__).parent.parent / "python" / "templates" / "empty.kicad_sch"
def _make_point(x: float, y: float) -> MagicMock:
pt = MagicMock()
pt.value = [x, y]
return pt
def _make_wire(x1: float, y1: float, x2: float, y2: float) -> MagicMock:
wire = MagicMock()
wire.pts = MagicMock()
wire.pts.xy = [_make_point(x1, y1), _make_point(x2, y2)]
return wire
def _make_label(name: str, x: float, y: float) -> MagicMock:
label = MagicMock()
label.value = name
label.at = MagicMock()
label.at.value = [x, y, 0]
return label
def _make_schematic_no_labels_no_symbols(*wires: Any) -> MagicMock:
sch = MagicMock()
sch.wire = list(wires)
del sch.label
del sch.symbol
return sch
# ---------------------------------------------------------------------------
# TestListFloatingLabelsSchema
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestListFloatingLabelsSchema:
"""Verify the list_floating_labels schema is registered and well-formed."""
def test_schema_registered(self) -> None:
from schemas.tool_schemas import TOOL_SCHEMAS
assert "list_floating_labels" in TOOL_SCHEMAS
def test_schema_required_fields(self) -> None:
from schemas.tool_schemas import TOOL_SCHEMAS
required = TOOL_SCHEMAS["list_floating_labels"]["inputSchema"]["required"]
assert required == ["schematicPath"]
def test_schema_has_title_and_description(self) -> None:
from schemas.tool_schemas import TOOL_SCHEMAS
schema = TOOL_SCHEMAS["list_floating_labels"]
assert schema.get("title")
assert schema.get("description")
# ---------------------------------------------------------------------------
# TestListFloatingLabelsDispatch
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestListFloatingLabelsDispatch:
"""Verify the handler is wired into KiCadInterface.command_routes."""
def test_list_floating_labels_in_routes(self) -> None:
with patch("kicad_interface.USE_IPC_BACKEND", False):
from kicad_interface import KiCADInterface
iface = KiCADInterface.__new__(KiCADInterface)
iface.board = None
iface.project_filename = None
iface.use_ipc = False
iface.ipc_backend = MagicMock()
iface.ipc_board_api = None
iface.footprint_library = MagicMock()
iface.project_commands = MagicMock()
iface.board_commands = MagicMock()
iface.component_commands = MagicMock()
iface.routing_commands = MagicMock()
KiCADInterface.__init__(iface)
assert "list_floating_labels" in iface.command_routes
assert callable(iface.command_routes["list_floating_labels"])
# ---------------------------------------------------------------------------
# TestListFloatingLabelsParamValidation
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestListFloatingLabelsParamValidation:
"""Handler returns error for missing schematicPath."""
def _make_handler(self) -> Any:
with patch("kicad_interface.USE_IPC_BACKEND", False):
from kicad_interface import KiCADInterface
iface = KiCADInterface.__new__(KiCADInterface)
return iface._handle_list_floating_labels
def test_missing_schematic_path(self) -> None:
handler = self._make_handler()
result = handler({})
assert result["success"] is False
assert "schematicPath" in result["message"]
def test_bad_schematic_path_returns_error(self) -> None:
handler = self._make_handler()
result = handler({"schematicPath": "/nonexistent/path/test.kicad_sch"})
assert result["success"] is False
# ---------------------------------------------------------------------------
# TestListFloatingLabelsCoreLogic
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestListFloatingLabelsCoreLogic:
"""Unit tests for the list_floating_labels function."""
def test_no_labels_returns_empty(self) -> None:
sch = _make_schematic_no_labels_no_symbols()
result = list_floating_labels(sch, "/tmp/test.kicad_sch")
assert result == []
def test_label_with_no_wires_and_no_pins_is_floating(self) -> None:
label = _make_label("SDA", 10.0, 5.0)
sch = MagicMock()
sch.wire = []
sch.label = [label]
del sch.symbol
result = list_floating_labels(sch, "/tmp/test.kicad_sch")
assert len(result) == 1
assert result[0]["name"] == "SDA"
assert result[0]["x"] == pytest.approx(10.0)
assert result[0]["y"] == pytest.approx(5.0)
assert result[0]["type"] == "label"
def test_label_connected_to_pin_not_floating(self) -> None:
"""Label at (0,0) connected to a pin at (2,0) via wire should NOT be floating."""
wire = _make_wire(0.0, 0.0, 2.0, 0.0)
label = _make_label("SCL", 0.0, 0.0)
sch = MagicMock()
sch.wire = [wire]
sch.label = [label]
# Mock a symbol whose pin is at (2, 0)
symbol = MagicMock()
symbol.property = MagicMock()
symbol.property.Reference = MagicMock()
symbol.property.Reference.value = "U1"
sch.symbol = [symbol]
with patch(
"commands.pin_locator.PinLocator.get_all_symbol_pins",
return_value={"1": (2.0, 0.0)},
):
result = list_floating_labels(sch, "/tmp/test.kicad_sch")
assert result == []
def test_label_not_connected_to_any_pin_is_floating(self) -> None:
"""Label at (0,0) with no wires to any pin should be floating."""
label = _make_label("MOSI", 0.0, 0.0)
wire = _make_wire(0.0, 0.0, 1.0, 0.0)
sch = MagicMock()
sch.wire = [wire]
sch.label = [label]
# A symbol whose pin is at a completely different location
symbol = MagicMock()
symbol.property = MagicMock()
symbol.property.Reference = MagicMock()
symbol.property.Reference.value = "U2"
sch.symbol = [symbol]
with patch(
"commands.pin_locator.PinLocator.get_all_symbol_pins",
return_value={"1": (99.0, 99.0)},
):
result = list_floating_labels(sch, "/tmp/test.kicad_sch")
assert len(result) == 1
assert result[0]["name"] == "MOSI"
def test_label_directly_on_pin_not_floating(self) -> None:
"""Label placed directly at a pin position (no wire needed) should NOT be floating."""
label = _make_label("PWR", 5.0, 3.0)
sch = MagicMock()
sch.wire = []
sch.label = [label]
symbol = MagicMock()
symbol.property = MagicMock()
symbol.property.Reference = MagicMock()
symbol.property.Reference.value = "R1"
sch.symbol = [symbol]
# Pin is exactly at the label position
with patch(
"commands.pin_locator.PinLocator.get_all_symbol_pins",
return_value={"1": (5.0, 3.0)},
):
result = list_floating_labels(sch, "/tmp/test.kicad_sch")
assert result == []
def test_multiple_labels_mixed_floating_and_connected(self) -> None:
"""Two labels: one connected, one floating."""
label_connected = _make_label("NET_A", 0.0, 0.0)
label_floating = _make_label("NET_B", 20.0, 20.0)
wire = _make_wire(0.0, 0.0, 2.0, 0.0)
sch = MagicMock()
sch.wire = [wire]
sch.label = [label_connected, label_floating]
symbol = MagicMock()
symbol.property = MagicMock()
symbol.property.Reference = MagicMock()
symbol.property.Reference.value = "C1"
sch.symbol = [symbol]
with patch(
"commands.pin_locator.PinLocator.get_all_symbol_pins",
return_value={"1": (2.0, 0.0)},
):
result = list_floating_labels(sch, "/tmp/test.kicad_sch")
assert len(result) == 1
assert result[0]["name"] == "NET_B"
def test_template_symbols_skipped(self) -> None:
"""Symbols with _TEMPLATE references should be skipped, not crash."""
label = _make_label("VBUS", 0.0, 0.0)
sch = MagicMock()
sch.wire = []
sch.label = [label]
template_sym = MagicMock()
template_sym.property = MagicMock()
template_sym.property.Reference = MagicMock()
template_sym.property.Reference.value = "_TEMPLATE_R"
sch.symbol = [template_sym]
with patch(
"commands.pin_locator.PinLocator.get_all_symbol_pins",
return_value={"1": (0.0, 0.0)},
) as mock_pins:
result = list_floating_labels(sch, "/tmp/test.kicad_sch")
# _TEMPLATE_ symbols are skipped; mock_pins should not have been called
mock_pins.assert_not_called()
assert len(result) == 1
# ---------------------------------------------------------------------------
# TestCountPinsOnNet
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestCountPinsOnNet:
"""Unit tests for count_pins_on_net."""
def _build_graph(self, sch: Any, schematic_path: str): # type: ignore[return]
all_wires = _parse_wires(sch)
if all_wires:
adjacency, iu_to_wires = _build_adjacency(all_wires)
else:
adjacency, iu_to_wires = [], {}
point_to_label, label_to_points = _parse_virtual_connections(sch, schematic_path)
return all_wires, iu_to_wires, adjacency, point_to_label, label_to_points
def test_no_labels_returns_zero(self) -> None:
sch = _make_schematic_no_labels_no_symbols()
all_wires, iu_to_wires, adj, p2l, l2p = self._build_graph(sch, "/tmp/t.kicad_sch")
count = count_pins_on_net(
sch, "/tmp/t.kicad_sch", "VCC", all_wires, iu_to_wires, adj, p2l, l2p
)
assert count == 0
def test_unknown_net_returns_zero(self) -> None:
wire = _make_wire(0.0, 0.0, 1.0, 0.0)
label = _make_label("SDA", 0.0, 0.0)
sch = MagicMock()
sch.wire = [wire]
sch.label = [label]
del sch.symbol
all_wires, iu_to_wires, adj, p2l, l2p = self._build_graph(sch, "/tmp/t.kicad_sch")
count = count_pins_on_net(
sch, "/tmp/t.kicad_sch", "UNKNOWN_NET", all_wires, iu_to_wires, adj, p2l, l2p
)
assert count == 0
def test_counts_pin_via_wire(self) -> None:
"""Label at (0,0), wire to (2,0), pin at (2,0) → count == 1."""
wire = _make_wire(0.0, 0.0, 2.0, 0.0)
label = _make_label("SCL", 0.0, 0.0)
sch = MagicMock()
sch.wire = [wire]
sch.label = [label]
symbol = MagicMock()
symbol.property = MagicMock()
symbol.property.Reference = MagicMock()
symbol.property.Reference.value = "U1"
sch.symbol = [symbol]
all_wires, iu_to_wires, adj, p2l, l2p = self._build_graph(sch, "/tmp/t.kicad_sch")
with patch(
"commands.pin_locator.PinLocator.get_all_symbol_pins",
return_value={"3": (2.0, 0.0)},
):
count = count_pins_on_net(
sch, "/tmp/t.kicad_sch", "SCL", all_wires, iu_to_wires, adj, p2l, l2p
)
assert count == 1
def test_no_symbol_attribute_returns_zero(self) -> None:
wire = _make_wire(0.0, 0.0, 2.0, 0.0)
label = _make_label("SDA", 0.0, 0.0)
sch = MagicMock()
sch.wire = [wire]
sch.label = [label]
del sch.symbol
all_wires, iu_to_wires, adj, p2l, l2p = self._build_graph(sch, "/tmp/t.kicad_sch")
count = count_pins_on_net(
sch, "/tmp/t.kicad_sch", "SDA", all_wires, iu_to_wires, adj, p2l, l2p
)
assert count == 0
# ---------------------------------------------------------------------------
# TestListSchematicNetsConnectedPinCount
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestListSchematicNetsConnectedPinCount:
"""Verify connected_pin_count is present in list_schematic_nets response."""
def _make_handler(self) -> Any:
with patch("kicad_interface.USE_IPC_BACKEND", False):
from kicad_interface import KiCADInterface
iface = KiCADInterface.__new__(KiCADInterface)
return iface._handle_list_schematic_nets
def test_connected_pin_count_present_in_response(self) -> None:
handler = self._make_handler()
label = _make_label("NET1", 0.0, 0.0)
mock_sch = MagicMock()
mock_sch.wire = []
mock_sch.label = [label]
del mock_sch.global_label
del mock_sch.symbol
with (
patch("kicad_interface.SchematicManager.load_schematic", return_value=mock_sch),
patch(
"kicad_interface.ConnectionManager.get_net_connections",
return_value=[],
),
):
result = handler({"schematicPath": "/tmp/test.kicad_sch"})
assert result["success"] is True
assert len(result["nets"]) == 1
net = result["nets"][0]
assert "connected_pin_count" in net
assert isinstance(net["connected_pin_count"], int)
def test_connected_pin_count_is_zero_when_no_pins(self) -> None:
handler = self._make_handler()
label = _make_label("ORPHAN_NET", 50.0, 50.0)
mock_sch = MagicMock()
mock_sch.wire = []
mock_sch.label = [label]
del mock_sch.global_label
del mock_sch.symbol
with (
patch("kicad_interface.SchematicManager.load_schematic", return_value=mock_sch),
patch(
"kicad_interface.ConnectionManager.get_net_connections",
return_value=[],
),
):
result = handler({"schematicPath": "/tmp/test.kicad_sch"})
assert result["success"] is True
assert result["nets"][0]["connected_pin_count"] == 0
# ---------------------------------------------------------------------------
# TestListFloatingLabelsIntegration
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestListFloatingLabelsIntegration:
"""Integration tests using a real .kicad_sch file."""
def _make_sch_with_floating_label(self, tmp_path: Path) -> Path:
"""Copy the empty template and append a floating label."""
sch_path = tmp_path / "test.kicad_sch"
shutil.copy(TEMPLATE_SCH, sch_path)
content = sch_path.read_text(encoding="utf-8")
floating_label = (
' (label "FLOATING_NET" (at 100 100 0)\n'
" (effects (font (size 1.27 1.27)))\n"
" (uuid 11111111-0000-0000-0000-000000000001)\n"
" )"
)
idx = content.rfind(")")
content = content[:idx] + "\n" + floating_label + "\n)"
sch_path.write_text(content, encoding="utf-8")
return sch_path
def test_empty_schematic_has_no_floating_labels(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
sch_path = Path(tmp) / "empty.kicad_sch"
shutil.copy(TEMPLATE_SCH, sch_path)
with patch("kicad_interface.USE_IPC_BACKEND", False):
from kicad_interface import KiCADInterface
iface = KiCADInterface.__new__(KiCADInterface)
result = iface._handle_list_floating_labels({"schematicPath": str(sch_path)})
assert result["success"] is True
assert result["count"] == 0
assert result["floating_labels"] == []
def test_schematic_with_floating_label_detected(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
sch_path = self._make_sch_with_floating_label(Path(tmp))
with patch("kicad_interface.USE_IPC_BACKEND", False):
from kicad_interface import KiCADInterface
iface = KiCADInterface.__new__(KiCADInterface)
result = iface._handle_list_floating_labels({"schematicPath": str(sch_path)})
assert result["success"] is True
assert result["count"] == 1
label = result["floating_labels"][0]
assert label["name"] == "FLOATING_NET"
assert label["x"] == pytest.approx(100.0)
assert label["y"] == pytest.approx(100.0)
assert label["type"] == "label"
def test_list_schematic_nets_has_connected_pin_count(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
sch_path = self._make_sch_with_floating_label(Path(tmp))
with patch("kicad_interface.USE_IPC_BACKEND", False):
from kicad_interface import KiCADInterface
iface = KiCADInterface.__new__(KiCADInterface)
result = iface._handle_list_schematic_nets({"schematicPath": str(sch_path)})
assert result["success"] is True
assert result["count"] == 1
net = result["nets"][0]
assert net["name"] == "FLOATING_NET"
assert "connected_pin_count" in net
assert net["connected_pin_count"] == 0

View 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

View File

@@ -34,6 +34,7 @@ from commands.schematic_analysis import (
_point_in_rect, _point_in_rect,
_transform_local_point, _transform_local_point,
compute_symbol_bbox, compute_symbol_bbox,
find_orphaned_wires,
find_overlapping_elements, find_overlapping_elements,
find_wires_crossing_symbols, find_wires_crossing_symbols,
get_elements_in_region, get_elements_in_region,
@@ -946,3 +947,133 @@ class TestIntegrationGraphicsBbox:
assert max(xs) == pytest.approx(1.27) assert max(xs) == pytest.approx(1.27)
assert min(ys) == pytest.approx(-1.27) assert min(ys) == pytest.approx(-1.27)
assert max(ys) == pytest.approx(1.27) assert max(ys) == pytest.approx(1.27)
# ---------------------------------------------------------------------------
# TestFindOrphanedWires
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestFindOrphanedWires:
"""Integration tests for find_orphaned_wires."""
def test_empty_schematic_no_orphans(self) -> None:
"""A schematic with no wires has no orphans."""
tmp = _make_temp_schematic()
result = find_orphaned_wires(tmp)
assert result["count"] == 0
assert result["orphaned_wires"] == []
def test_isolated_wire_is_orphaned(self) -> None:
"""A single wire floating in empty space has both endpoints dangling."""
extra = """
(wire (pts (xy 10 20) (xy 30 20))
(stroke (width 0) (type default))
(uuid "w-isolated"))
"""
tmp = _make_temp_schematic(extra)
result = find_orphaned_wires(tmp)
assert result["count"] == 1
w = result["orphaned_wires"][0]
assert len(w["dangling_ends"]) == 2
def test_wire_between_two_labels_not_orphaned(self) -> None:
"""A wire whose endpoints both land on net labels is fully connected."""
extra = """
(label "VCC" (at 10 20 0)
(effects (font (size 1.27 1.27)) (justify left bottom))
(uuid "lbl1"))
(label "GND" (at 30 20 0)
(effects (font (size 1.27 1.27)) (justify left bottom))
(uuid "lbl2"))
(wire (pts (xy 10 20) (xy 30 20))
(stroke (width 0) (type default))
(uuid "w-label-to-label"))
"""
tmp = _make_temp_schematic(extra)
result = find_orphaned_wires(tmp)
assert result["count"] == 0
def test_wire_with_one_dangling_end(self) -> None:
"""A wire from a label to empty space has exactly one dangling end."""
extra = """
(label "SIG" (at 10 20 0)
(effects (font (size 1.27 1.27)) (justify left bottom))
(uuid "lbl-sig"))
(wire (pts (xy 10 20) (xy 40 20))
(stroke (width 0) (type default))
(uuid "w-stub"))
"""
tmp = _make_temp_schematic(extra)
result = find_orphaned_wires(tmp)
assert result["count"] == 1
w = result["orphaned_wires"][0]
assert len(w["dangling_ends"]) == 1
# The dangling end is the far end at x=40, not the label end at x=10
assert w["dangling_ends"][0]["x"] == pytest.approx(40.0)
def test_connected_wires_not_orphaned(self) -> None:
"""Two wires sharing an endpoint are connected — neither is orphaned
provided the remaining ends are also anchored."""
# Wire A: (10,20)→(20,20), Wire B: (20,20)→(30,20)
# Both share endpoint at (20,20). Anchor the outer ends with labels.
extra = """
(label "A" (at 10 20 0)
(effects (font (size 1.27 1.27)) (justify left bottom))
(uuid "lbl-a"))
(label "B" (at 30 20 0)
(effects (font (size 1.27 1.27)) (justify left bottom))
(uuid "lbl-b"))
(wire (pts (xy 10 20) (xy 20 20))
(stroke (width 0) (type default))
(uuid "w1"))
(wire (pts (xy 20 20) (xy 30 20))
(stroke (width 0) (type default))
(uuid "w2"))
"""
tmp = _make_temp_schematic(extra)
result = find_orphaned_wires(tmp)
assert result["count"] == 0
def test_t_junction_shared_endpoint_not_dangling(self) -> None:
"""Three wires meeting at a single point — the shared vertex is connected
to multiple wires and must not be reported as dangling."""
# Three wires all touching (50, 50). Outer ends get labels.
extra = """
(label "L1" (at 30 50 0)
(effects (font (size 1.27 1.27)) (justify left bottom))
(uuid "lbl-t1"))
(label "L2" (at 70 50 0)
(effects (font (size 1.27 1.27)) (justify left bottom))
(uuid "lbl-t2"))
(label "L3" (at 50 30 0)
(effects (font (size 1.27 1.27)) (justify left bottom))
(uuid "lbl-t3"))
(wire (pts (xy 30 50) (xy 50 50))
(stroke (width 0) (type default))
(uuid "wt1"))
(wire (pts (xy 50 50) (xy 70 50))
(stroke (width 0) (type default))
(uuid "wt2"))
(wire (pts (xy 50 50) (xy 50 30))
(stroke (width 0) (type default))
(uuid "wt3"))
"""
tmp = _make_temp_schematic(extra)
result = find_orphaned_wires(tmp)
assert result["count"] == 0
def test_multiple_isolated_wires_all_reported(self) -> None:
"""Two separate isolated wires are both reported."""
extra = """
(wire (pts (xy 10 10) (xy 20 10))
(stroke (width 0) (type default))
(uuid "wi1"))
(wire (pts (xy 50 50) (xy 60 50))
(stroke (width 0) (type default))
(uuid "wi2"))
"""
tmp = _make_temp_schematic(extra)
result = find_orphaned_wires(tmp)
assert result["count"] == 2

246
tests/test_snap_to_grid.py Normal file
View File

@@ -0,0 +1,246 @@
"""
Tests for the snap_to_grid schematic tool.
Unit tests cover the snapping math and per-element-type logic using synthetic
S-expressions. Integration tests run against real .kicad_sch files created
from the empty template.
"""
import shutil
import sys
import tempfile
import uuid
from pathlib import Path
import pytest
import sexpdata
from sexpdata import Symbol
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
from commands.schematic_snap import _is_on_grid, _snap_mm, snap_to_grid
# ---------------------------------------------------------------------------
# Shared fixture helpers
# ---------------------------------------------------------------------------
TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "python" / "templates" / "empty.kicad_sch"
def _make_temp_schematic(extra_sexp: str = "") -> Path:
"""Copy empty.kicad_sch to a temp dir, optionally injecting extra S-expressions."""
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
shutil.copy(TEMPLATE_PATH, tmp)
if extra_sexp:
content = tmp.read_text(encoding="utf-8")
idx = content.rfind(")")
content = content[:idx] + "\n" + extra_sexp + "\n)"
tmp.write_text(content, encoding="utf-8")
return tmp
def _wire_sexp(x1: float, y1: float, x2: float, y2: float) -> str:
u = str(uuid.uuid4())
return (
f"(wire (pts (xy {x1} {y1}) (xy {x2} {y2}))\n"
f" (stroke (width 0) (type default))\n"
f' (uuid "{u}"))'
)
def _junction_sexp(x: float, y: float) -> str:
u = str(uuid.uuid4())
return f'(junction (at {x} {y}) (diameter 0) (color 0 0 0 0) (uuid "{u}"))'
def _label_sexp(name: str, x: float, y: float, angle: float = 0) -> str:
u = str(uuid.uuid4())
return (
f'(label "{name}" (at {x} {y} {angle})\n'
f" (effects (font (size 1.27 1.27)) (justify left bottom))\n"
f' (uuid "{u}"))'
)
# ---------------------------------------------------------------------------
# Unit tests — pure math, no file I/O
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSnapMath:
def test_snap_mm_already_on_grid(self):
assert _snap_mm(2.54, 2.54) == pytest.approx(2.54)
def test_snap_mm_rounds_up(self):
# 2.55 is closer to 5.08 than to 2.54 (distance 2.53 vs 0.01)
# Actually 2.55 / 2.54 = 1.0039..., rounds to 1 → 2.54
assert _snap_mm(2.55, 2.54) == pytest.approx(2.54)
def test_snap_mm_rounds_to_next(self):
# 3.81 / 2.54 = 1.5 → rounds to 2 → 5.08
assert _snap_mm(3.81, 2.54) == pytest.approx(5.08)
def test_snap_mm_negative(self):
assert _snap_mm(-2.51, 2.54) == pytest.approx(-2.54)
def test_snap_mm_zero(self):
assert _snap_mm(0.0, 2.54) == pytest.approx(0.0)
def test_snap_mm_small_grid(self):
assert _snap_mm(1.28, 1.27) == pytest.approx(1.27)
def test_is_on_grid_true(self):
assert _is_on_grid(2.54, 2.54)
assert _is_on_grid(0.0, 2.54)
assert _is_on_grid(5.08, 2.54)
def test_is_on_grid_false(self):
assert not _is_on_grid(2.55, 2.54)
assert not _is_on_grid(1.0, 2.54)
def test_snap_invalid_grid_raises(self):
with pytest.raises(ValueError, match="grid_size must be positive"):
snap_to_grid(Path("/nonexistent"), grid_size=-1.0)
def test_snap_unknown_element_raises(self):
with pytest.raises(ValueError, match="Unknown element type"):
snap_to_grid(Path("/nonexistent"), elements=["bogus"])
# ---------------------------------------------------------------------------
# Integration tests — real .kicad_sch files
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestSnapWires:
def test_off_grid_wire_is_snapped(self):
path = _make_temp_schematic(_wire_sexp(2.51, 5.03, 7.56, 5.03))
result = snap_to_grid(path, grid_size=2.54, elements=["wires"])
assert result["snapped"] >= 1
# Verify coordinates in the written file
data = sexpdata.loads(path.read_text(encoding="utf-8"))
wire = next(
item for item in data if isinstance(item, list) and item and item[0] == Symbol("wire")
)
pts = next(sub for sub in wire[1:] if isinstance(sub, list) and sub[0] == Symbol("pts"))
xy_pairs = [sub for sub in pts[1:] if isinstance(sub, list) and sub[0] == Symbol("xy")]
for pt in xy_pairs:
assert _is_on_grid(float(pt[1]), 2.54), f"x={pt[1]} not on grid"
assert _is_on_grid(float(pt[2]), 2.54), f"y={pt[2]} not on grid"
def test_on_grid_wire_counts_as_already_on_grid(self):
path = _make_temp_schematic(_wire_sexp(2.54, 5.08, 7.62, 5.08))
result = snap_to_grid(path, grid_size=2.54, elements=["wires"])
assert result["snapped"] == 0
assert result["already_on_grid"] >= 1
def test_wires_not_snapped_when_excluded(self):
path = _make_temp_schematic(_wire_sexp(2.51, 5.03, 7.56, 5.03))
result = snap_to_grid(path, grid_size=2.54, elements=["junctions"])
assert result["snapped"] == 0
@pytest.mark.integration
class TestSnapJunctions:
def test_off_grid_junction_is_snapped(self):
path = _make_temp_schematic(_junction_sexp(2.51, 2.51))
result = snap_to_grid(path, grid_size=2.54, elements=["junctions"])
assert result["snapped"] >= 1
data = sexpdata.loads(path.read_text(encoding="utf-8"))
junc = next(
item
for item in data
if isinstance(item, list) and item and item[0] == Symbol("junction")
)
at = next(sub for sub in junc[1:] if isinstance(sub, list) and sub[0] == Symbol("at"))
assert _is_on_grid(float(at[1]), 2.54)
assert _is_on_grid(float(at[2]), 2.54)
def test_on_grid_junction_unchanged(self):
path = _make_temp_schematic(_junction_sexp(2.54, 2.54))
result = snap_to_grid(path, grid_size=2.54, elements=["junctions"])
assert result["snapped"] == 0
assert result["already_on_grid"] >= 1
@pytest.mark.integration
class TestSnapLabels:
def test_off_grid_label_snapped_preserves_angle(self):
path = _make_temp_schematic(_label_sexp("NET_A", 2.51, 5.03, angle=90))
result = snap_to_grid(path, grid_size=2.54, elements=["labels"])
assert result["snapped"] >= 1
data = sexpdata.loads(path.read_text(encoding="utf-8"))
lbl = next(
item for item in data if isinstance(item, list) and item and item[0] == Symbol("label")
)
at = next(sub for sub in lbl[1:] if isinstance(sub, list) and sub[0] == Symbol("at"))
assert _is_on_grid(float(at[1]), 2.54), f"x={at[1]} not on grid"
assert _is_on_grid(float(at[2]), 2.54), f"y={at[2]} not on grid"
# angle must be preserved
assert float(at[3]) == pytest.approx(90.0)
def test_on_grid_label_unchanged(self):
path = _make_temp_schematic(_label_sexp("NET_B", 2.54, 5.08))
result = snap_to_grid(path, grid_size=2.54, elements=["labels"])
assert result["snapped"] == 0
@pytest.mark.integration
class TestSnapDefaults:
def test_default_elements_snaps_wires_and_junctions_and_labels(self):
extra = "\n".join(
[
_wire_sexp(2.51, 5.03, 7.56, 5.03),
_junction_sexp(2.51, 2.51),
_label_sexp("VCC", 2.51, 2.51),
]
)
path = _make_temp_schematic(extra)
result = snap_to_grid(path) # defaults: grid=2.54, elements=None
assert result["snapped"] >= 3
assert result["grid_size"] == pytest.approx(1.27)
def test_idempotent(self):
path = _make_temp_schematic(_wire_sexp(2.51, 5.03, 7.56, 5.03))
snap_to_grid(path, grid_size=2.54)
content_after_first = path.read_text(encoding="utf-8")
snap_to_grid(path, grid_size=2.54)
content_after_second = path.read_text(encoding="utf-8")
assert content_after_first == content_after_second
def test_default_grid_is_1_27mm(self):
# Regression: default was 2.54 mm, which displaces valid KiCAD pin
# coordinates that fall on the 50-mil (1.27 mm) grid but not on the
# 100-mil (2.54 mm) grid — e.g. 26.67 mm = 21 × 1.27 mm.
# With the correct 1.27 mm default those coordinates must be left
# untouched (snapped == 0, already_on_grid >= 1).
# 26.67 / 2.54 == 10.5 → would snap to 25.40 mm (off by 1.27 mm).
# 26.67 / 1.27 == 21.0 → already on grid, no move.
path = _make_temp_schematic(_wire_sexp(335.28, 26.67, 350.52, 26.67))
result = snap_to_grid(path) # default grid
assert result["grid_size"] == pytest.approx(1.27)
assert result["snapped"] == 0, (
"Wire at valid 50-mil pin coordinates was displaced by default snap — "
"default grid must be 1.27 mm, not 2.54 mm"
)
assert result["already_on_grid"] >= 1
def test_custom_grid(self):
# 1.27 mm grid — wire at 1.25 should snap to 1.27
path = _make_temp_schematic(_wire_sexp(1.25, 1.25, 2.51, 2.51))
result = snap_to_grid(path, grid_size=1.27)
assert result["snapped"] >= 1
data = sexpdata.loads(path.read_text(encoding="utf-8"))
wire = next(
item for item in data if isinstance(item, list) and item and item[0] == Symbol("wire")
)
pts = next(sub for sub in wire[1:] if isinstance(sub, list) and sub[0] == Symbol("pts"))
xy_pairs = [sub for sub in pts[1:] if isinstance(sub, list) and sub[0] == Symbol("xy")]
for pt in xy_pairs:
assert _is_on_grid(float(pt[1]), 1.27), f"x={pt[1]} not on 1.27 grid"
assert _is_on_grid(float(pt[2]), 1.27), f"y={pt[2]} not on 1.27 grid"

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