Merge pull request #226 from ravishivt/feat/list-symbol-pins
feat(schematic): add list_symbol_pins to read pins from symbol libraries
This commit is contained in:
199
python/commands/symbol_pins.py
Normal file
199
python/commands/symbol_pins.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
"""
|
||||||
|
Symbol pin discovery commands.
|
||||||
|
|
||||||
|
Read-only tools for inspecting a symbol's pins straight from the KiCad symbol
|
||||||
|
libraries, without needing a schematic loaded. Useful as a pre-flight step before
|
||||||
|
placing components and wiring nets (e.g. discover pin numbers/names before
|
||||||
|
connect_to_net). Complements ``get_schematic_pin_locations`` (which reports pin
|
||||||
|
coordinates only *after* a symbol has been placed on a schematic).
|
||||||
|
|
||||||
|
Tools:
|
||||||
|
- list_symbol_pins: pins for one symbol, read straight from the library file
|
||||||
|
- batch_list_symbol_pins: pins for many symbols in one call (+ body bounding box)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from commands.dynamic_symbol_loader import DynamicSymbolLoader
|
||||||
|
|
||||||
|
logger = logging.getLogger("kicad_interface")
|
||||||
|
|
||||||
|
# Standard symmetric 2-pin passives that qualify for compact output in
|
||||||
|
# batch_list_symbol_pins (their pin detail is rarely needed when placing).
|
||||||
|
COMPACT_SYMBOLS = {
|
||||||
|
"Device:R",
|
||||||
|
"Device:R_Small",
|
||||||
|
"Device:R_US",
|
||||||
|
"Device:C",
|
||||||
|
"Device:C_Small",
|
||||||
|
"Device:C_Polarized",
|
||||||
|
"Device:C_Polarized_Small",
|
||||||
|
"Device:L",
|
||||||
|
"Device:L_Small",
|
||||||
|
"Device:LED",
|
||||||
|
"Device:D",
|
||||||
|
"Device:D_Zener",
|
||||||
|
"Device:D_Schottky",
|
||||||
|
"Device:Ferrite_Bead",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pin-envelope padding (mm) used to derive a symbol body bounding box (50 mil).
|
||||||
|
_BODY_PAD_MM = 1.27
|
||||||
|
|
||||||
|
# Matches a pin S-expression inside a symbol definition, capturing
|
||||||
|
# type, x, y, angle, name and number.
|
||||||
|
_PIN_RE = re.compile(
|
||||||
|
r"\(pin\s+(\S+)\s+\S+\s+\(at\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\)"
|
||||||
|
r'.*?\(name\s+"([^"]*)".*?\(number\s+"([^"]*)"',
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_symbol_pins(
|
||||||
|
loader: DynamicSymbolLoader, library_name: str, symbol_name: str
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Return pin data for a symbol read directly from its library file (no schematic needed).
|
||||||
|
|
||||||
|
Each entry: {"number", "name", "type", "x", "y", "angle"} where x/y/angle are in
|
||||||
|
symbol-local coordinates (Y increases upward, per the KiCad library convention).
|
||||||
|
|
||||||
|
Raises ValueError (carrying .suggestions) if the symbol cannot be found — this mirrors
|
||||||
|
DynamicSymbolLoader.extract_symbol_from_library's behaviour for close-match hints.
|
||||||
|
"""
|
||||||
|
block = loader.extract_symbol_from_library(library_name, symbol_name)
|
||||||
|
if not block:
|
||||||
|
err = ValueError(f"Symbol '{library_name}:{symbol_name}' not found")
|
||||||
|
err.suggestions = [] # type: ignore[attr-defined]
|
||||||
|
raise err
|
||||||
|
pins: List[Dict[str, Any]] = []
|
||||||
|
for m in _PIN_RE.finditer(block):
|
||||||
|
pins.append(
|
||||||
|
{
|
||||||
|
"number": m.group(6),
|
||||||
|
"name": m.group(5),
|
||||||
|
"type": m.group(1),
|
||||||
|
"x": float(m.group(2)),
|
||||||
|
"y": float(m.group(3)),
|
||||||
|
"angle": float(m.group(4)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sorted(pins, key=lambda p: (len(p["number"]), p["number"]))
|
||||||
|
|
||||||
|
|
||||||
|
def _body_bbox(pins: List[Dict[str, Any]]) -> Optional[Dict[str, float]]:
|
||||||
|
"""Bounding box of the pin envelope expanded by _BODY_PAD_MM on each side."""
|
||||||
|
coords = [(p["x"], p["y"]) for p in pins if "x" in p]
|
||||||
|
if not coords:
|
||||||
|
return None
|
||||||
|
xs = [c[0] for c in coords]
|
||||||
|
ys = [c[1] for c in coords]
|
||||||
|
return {
|
||||||
|
"x_min": round(min(xs) - _BODY_PAD_MM, 4),
|
||||||
|
"y_min": round(min(ys) - _BODY_PAD_MM, 4),
|
||||||
|
"x_max": round(max(xs) + _BODY_PAD_MM, 4),
|
||||||
|
"y_max": round(max(ys) + _BODY_PAD_MM, 4),
|
||||||
|
"width": round(max(xs) - min(xs) + 2 * _BODY_PAD_MM, 4),
|
||||||
|
"height": round(max(ys) - min(ys) + 2 * _BODY_PAD_MM, 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SymbolPinCommands:
|
||||||
|
"""Handlers for symbol pin discovery tools. Stateless; each call builds its own loader."""
|
||||||
|
|
||||||
|
def list_symbol_pins(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""List pin numbers, names and types for one symbol, read from its library."""
|
||||||
|
logger.info("Listing symbol pins from library")
|
||||||
|
try:
|
||||||
|
symbol_spec = params.get("symbol", "")
|
||||||
|
schematic_path = params.get("schematicPath")
|
||||||
|
|
||||||
|
if not symbol_spec or ":" not in symbol_spec:
|
||||||
|
return {"success": False, "message": "symbol must be 'Library:SymbolName'"}
|
||||||
|
|
||||||
|
library_name, symbol_name = symbol_spec.split(":", 1)
|
||||||
|
project_path = Path(schematic_path).parent if schematic_path else None
|
||||||
|
loader = DynamicSymbolLoader(project_path=project_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pins = _parse_symbol_pins(loader, library_name, symbol_name)
|
||||||
|
except ValueError as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": str(e),
|
||||||
|
"suggestions": getattr(e, "suggestions", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"symbol": symbol_spec,
|
||||||
|
"pin_count": len(pins),
|
||||||
|
"pins": pins,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing symbol pins: {e}")
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
|
def batch_list_symbol_pins(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""List pins (+ body bounding box) for multiple symbols in a single call."""
|
||||||
|
logger.info("Batch listing symbol pins")
|
||||||
|
try:
|
||||||
|
symbols = params.get("symbols", [])
|
||||||
|
schematic_path = params.get("schematicPath")
|
||||||
|
compact = bool(params.get("compact", False))
|
||||||
|
|
||||||
|
if not symbols:
|
||||||
|
return {"success": False, "message": "symbols list is required"}
|
||||||
|
|
||||||
|
project_path = Path(schematic_path).parent if schematic_path else None
|
||||||
|
loader = DynamicSymbolLoader(project_path=project_path)
|
||||||
|
results: Dict[str, Any] = {}
|
||||||
|
errors: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
for symbol_spec in symbols:
|
||||||
|
if ":" not in symbol_spec:
|
||||||
|
errors[symbol_spec] = "symbol must be 'Library:SymbolName'"
|
||||||
|
continue
|
||||||
|
library_name, symbol_name = symbol_spec.split(":", 1)
|
||||||
|
try:
|
||||||
|
pins = _parse_symbol_pins(loader, library_name, symbol_name)
|
||||||
|
except ValueError as e:
|
||||||
|
errors[symbol_spec] = {
|
||||||
|
"message": str(e),
|
||||||
|
"suggestions": getattr(e, "suggestions", []),
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
body_bbox = _body_bbox(pins)
|
||||||
|
is_symmetric_2pin = len(pins) == 2 and (
|
||||||
|
symbol_spec in COMPACT_SYMBOLS
|
||||||
|
or all(p.get("type", "") == "passive" for p in pins)
|
||||||
|
)
|
||||||
|
if compact and is_symmetric_2pin:
|
||||||
|
results[symbol_spec] = {
|
||||||
|
"pin_count": len(pins),
|
||||||
|
"body_bbox": body_bbox,
|
||||||
|
"is_symmetric": True,
|
||||||
|
"compact": True,
|
||||||
|
"note": "Pin detail omitted (compact mode, symmetric 2-pin passive). "
|
||||||
|
"Set compact=false to see individual pin coords.",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
results[symbol_spec] = {
|
||||||
|
"pins": pins,
|
||||||
|
"pin_count": len(pins),
|
||||||
|
"body_bbox": body_bbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": len(errors) == 0,
|
||||||
|
"symbols": results,
|
||||||
|
"errors": errors if errors else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in batch_list_symbol_pins: {e}")
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
@@ -326,6 +326,7 @@ try:
|
|||||||
from commands.routing import RoutingCommands
|
from commands.routing import RoutingCommands
|
||||||
from commands.schematic import SchematicManager
|
from commands.schematic import SchematicManager
|
||||||
from commands.symbol_creator import SymbolCreator
|
from commands.symbol_creator import SymbolCreator
|
||||||
|
from commands.symbol_pins import SymbolPinCommands
|
||||||
|
|
||||||
logger.info("Successfully imported all command handlers")
|
logger.info("Successfully imported all command handlers")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
@@ -444,6 +445,9 @@ class KiCADInterface:
|
|||||||
# Initialize symbol library manager (for searching local KiCad symbol libraries)
|
# Initialize symbol library manager (for searching local KiCad symbol libraries)
|
||||||
self.symbol_library_commands = SymbolLibraryCommands()
|
self.symbol_library_commands = SymbolLibraryCommands()
|
||||||
|
|
||||||
|
# Symbol pin discovery commands (read-only pin lookup from symbol libraries)
|
||||||
|
self.symbol_pin_commands = SymbolPinCommands()
|
||||||
|
|
||||||
# Initialize JLCPCB API integration
|
# Initialize JLCPCB API integration
|
||||||
self.jlcpcb_client = JLCPCBClient() # Official API (requires auth)
|
self.jlcpcb_client = JLCPCBClient() # Official API (requires auth)
|
||||||
from commands.jlcsearch import JLCSearchClient
|
from commands.jlcsearch import JLCSearchClient
|
||||||
@@ -527,6 +531,9 @@ class KiCADInterface:
|
|||||||
"search_symbols": self.symbol_library_commands.search_symbols,
|
"search_symbols": self.symbol_library_commands.search_symbols,
|
||||||
"list_library_symbols": self.symbol_library_commands.list_library_symbols,
|
"list_library_symbols": self.symbol_library_commands.list_library_symbols,
|
||||||
"get_symbol_info": self.symbol_library_commands.get_symbol_info,
|
"get_symbol_info": self.symbol_library_commands.get_symbol_info,
|
||||||
|
# Symbol pin discovery commands (read pins straight from symbol libraries)
|
||||||
|
"list_symbol_pins": self.symbol_pin_commands.list_symbol_pins,
|
||||||
|
"batch_list_symbol_pins": self.symbol_pin_commands.batch_list_symbol_pins,
|
||||||
# JLCPCB API commands (complete parts catalog via API)
|
# JLCPCB API commands (complete parts catalog via API)
|
||||||
"download_jlcpcb_database": self._handle_download_jlcpcb_database,
|
"download_jlcpcb_database": self._handle_download_jlcpcb_database,
|
||||||
"search_jlcpcb_parts": self._handle_search_jlcpcb_parts,
|
"search_jlcpcb_parts": self._handle_search_jlcpcb_parts,
|
||||||
|
|||||||
@@ -207,4 +207,108 @@ Returns symbol references that can be used directly in schematics.`,
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// List pins for a symbol from the library (no schematic needed)
|
||||||
|
server.tool(
|
||||||
|
"list_symbol_pins",
|
||||||
|
"Return pin names, numbers, and types for a symbol directly from the library — no schematic required. Use this before add_schematic_component to discover pins for connect_to_net calls. Each pin has 'number' (e.g. '1', 'A5') and 'name' (e.g. 'FB', 'GND') — connect_to_net accepts either. Pass schematicPath to resolve project-local symbols. Returns close-match suggestions if the symbol name is slightly wrong.",
|
||||||
|
{
|
||||||
|
symbol: z
|
||||||
|
.string()
|
||||||
|
.describe("Symbol in 'Library:SymbolName' format (e.g., Device:R, Connector:Conn_01x04)"),
|
||||||
|
schematicPath: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Path to .kicad_sch — enables project-local sym-lib-table lookup"),
|
||||||
|
},
|
||||||
|
async (args: { symbol: string; schematicPath?: string }) => {
|
||||||
|
const result = await callKicadScript("list_symbol_pins", args);
|
||||||
|
if (result.success) {
|
||||||
|
if (result.pins.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Symbol ${result.symbol} has no pins.` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const lines = result.pins.map(
|
||||||
|
(p: any) => ` Pin ${p.number} (${p.name}) — type: ${p.type}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `${result.symbol} — ${result.pin_count} pin(s):\n${lines.join("\n")}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const hint = result.suggestions?.length
|
||||||
|
? `\nDid you mean: ${result.suggestions.join(", ")}?`
|
||||||
|
: "";
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Failed to list pins: ${result.message || "Unknown error"}${hint}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// List pins for multiple symbols in one call
|
||||||
|
server.tool(
|
||||||
|
"batch_list_symbol_pins",
|
||||||
|
"Return pin names, numbers, types, and symbol-local coordinates for multiple symbols in a single call. Use instead of calling list_symbol_pins repeatedly when placing a subcircuit — saves 5–10 round-trips. Each result includes pins (with x/y/angle in symbol-local coords, Y-up per KiCAD lib convention) and body_bbox (bounding box of pin envelope ±1.27mm, symbol-local coords). IMPORTANT: coordinates are symbol-local (Y-up, pre-rotation); after placement use get_schematic_pin_locations for post-rotation schematic coordinates. Set compact=true for simple 2-pin passives (Device:R/C/L) to get just pin_count, body_bbox, and is_symmetric.",
|
||||||
|
{
|
||||||
|
symbols: z
|
||||||
|
.array(z.string())
|
||||||
|
.describe(
|
||||||
|
"Array of symbols in 'Library:SymbolName' format (e.g., ['Device:R', 'Device:C'])",
|
||||||
|
),
|
||||||
|
schematicPath: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Path to .kicad_sch — enables project-local sym-lib-table lookup"),
|
||||||
|
compact: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe("If true, omit per-pin detail for standard 2-pin symmetric passives."),
|
||||||
|
},
|
||||||
|
async (args: { symbols: string[]; schematicPath?: string; compact?: boolean }) => {
|
||||||
|
const result = await callKicadScript("batch_list_symbol_pins", args);
|
||||||
|
if (result.success !== false || (result.symbols && Object.keys(result.symbols).length > 0)) {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const [sym, data] of Object.entries(result.symbols || {})) {
|
||||||
|
const d = data as any;
|
||||||
|
const bb = d.body_bbox;
|
||||||
|
const bboxStr = bb ? ` | body ${bb.width.toFixed(2)}×${bb.height.toFixed(2)}mm` : "";
|
||||||
|
if (d.is_symmetric && d.compact) {
|
||||||
|
lines.push(`${sym} — ${d.pin_count} pin(s), symmetric${bboxStr}`);
|
||||||
|
} else {
|
||||||
|
const pinLines = (d.pins || []).map((p: any) => {
|
||||||
|
const coords = p.x !== undefined ? ` at (${p.x},${p.y}) angle=${p.angle}` : "";
|
||||||
|
return ` Pin ${p.number} (${p.name}) — type: ${p.type}${coords}`;
|
||||||
|
});
|
||||||
|
lines.push(`${sym} — ${d.pin_count} pin(s)${bboxStr}:`);
|
||||||
|
lines.push(...pinLines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.errors && Object.keys(result.errors).length > 0) {
|
||||||
|
lines.push("\nErrors:");
|
||||||
|
for (const [sym, err] of Object.entries(result.errors as Record<string, any>)) {
|
||||||
|
const hint = err.suggestions?.length
|
||||||
|
? ` (did you mean: ${err.suggestions.join(", ")}?)`
|
||||||
|
: "";
|
||||||
|
lines.push(` ${sym}: ${err.message || err}${hint}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: `Failed to list pins: ${result.message || "Unknown error"}` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,11 @@ export const toolCategories: ToolCategory[] = [
|
|||||||
description: "Footprint library access: search, browse, get footprint information",
|
description: "Footprint library access: search, browse, get footprint information",
|
||||||
tools: ["list_libraries", "search_footprints", "list_library_footprints", "get_footprint_info"],
|
tools: ["list_libraries", "search_footprints", "list_library_footprints", "get_footprint_info"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "symbol_pins",
|
||||||
|
description: "Read a symbol's pins straight from the library (no schematic needed)",
|
||||||
|
tools: ["list_symbol_pins", "batch_list_symbol_pins"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "routing",
|
name: "routing",
|
||||||
description: "Advanced routing operations: vias, copper pours",
|
description: "Advanced routing operations: vias, copper pours",
|
||||||
|
|||||||
137
tests/test_symbol_pins.py
Normal file
137
tests/test_symbol_pins.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the symbol/pin discovery commands (commands/symbol_pins.py).
|
||||||
|
|
||||||
|
These avoid any dependency on a system-wide KiCad library install by feeding the
|
||||||
|
parser a hand-written symbol block and by stubbing DynamicSymbolLoader, so they run
|
||||||
|
identically in CI and on a developer machine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
||||||
|
|
||||||
|
from commands import symbol_pins # noqa: E402
|
||||||
|
from commands.symbol_pins import SymbolPinCommands, _body_bbox, _parse_symbol_pins # noqa: E402
|
||||||
|
|
||||||
|
# A minimal symbol definition block with two pins, in KiCad lib S-expression form.
|
||||||
|
_R_BLOCK = """
|
||||||
|
(symbol "R"
|
||||||
|
(symbol "R_1_1"
|
||||||
|
(pin passive line (at 0 3.81 270) (length 1.27)
|
||||||
|
(name "~" (effects (font (size 1.27 1.27))))
|
||||||
|
(number "1" (effects (font (size 1.27 1.27)))))
|
||||||
|
(pin passive line (at 0 -3.81 90) (length 1.27)
|
||||||
|
(name "~" (effects (font (size 1.27 1.27))))
|
||||||
|
(number "2" (effects (font (size 1.27 1.27)))))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Three-pin block with named pins to verify name/type/ordering.
|
||||||
|
_U_BLOCK = """
|
||||||
|
(symbol "LDO"
|
||||||
|
(symbol "LDO_1_1"
|
||||||
|
(pin power_in line (at -5.08 0 0) (name "VIN" (effects (font (size 1 1)))) (number "1"))
|
||||||
|
(pin power_out line (at 5.08 0 180) (name "VOUT" (effects (font (size 1 1)))) (number "3"))
|
||||||
|
(pin power_in line (at 0 -5.08 90) (name "GND" (effects (font (size 1 1)))) (number "2"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_loader(block):
|
||||||
|
loader = MagicMock()
|
||||||
|
loader.extract_symbol_from_library.return_value = block
|
||||||
|
return loader
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseSymbolPins:
|
||||||
|
def test_basic_two_pin(self):
|
||||||
|
pins = _parse_symbol_pins(_fake_loader(_R_BLOCK), "Device", "R")
|
||||||
|
assert [p["number"] for p in pins] == ["1", "2"]
|
||||||
|
assert pins[0] == {
|
||||||
|
"number": "1",
|
||||||
|
"name": "~",
|
||||||
|
"type": "passive",
|
||||||
|
"x": 0.0,
|
||||||
|
"y": 3.81,
|
||||||
|
"angle": 270.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_named_pins_and_sort(self):
|
||||||
|
pins = _parse_symbol_pins(_fake_loader(_U_BLOCK), "Reg", "LDO")
|
||||||
|
# Sorted by (len(number), number): "1","2","3"
|
||||||
|
assert [p["number"] for p in pins] == ["1", "2", "3"]
|
||||||
|
by_num = {p["number"]: p for p in pins}
|
||||||
|
assert by_num["1"]["name"] == "VIN" and by_num["1"]["type"] == "power_in"
|
||||||
|
assert by_num["3"]["name"] == "VOUT" and by_num["3"]["type"] == "power_out"
|
||||||
|
|
||||||
|
def test_not_found_raises(self):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_parse_symbol_pins(_fake_loader(None), "Device", "Nope")
|
||||||
|
|
||||||
|
|
||||||
|
class TestBodyBbox:
|
||||||
|
def test_bbox_math(self):
|
||||||
|
pins = [{"x": 0.0, "y": 3.81}, {"x": 0.0, "y": -3.81}]
|
||||||
|
bb = _body_bbox(pins)
|
||||||
|
assert bb["width"] == pytest.approx(2.54)
|
||||||
|
assert bb["height"] == pytest.approx(10.16)
|
||||||
|
assert bb["y_max"] == pytest.approx(5.08)
|
||||||
|
|
||||||
|
def test_bbox_empty(self):
|
||||||
|
assert _body_bbox([]) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestListSymbolPins:
|
||||||
|
def test_bad_spec(self):
|
||||||
|
c = SymbolPinCommands()
|
||||||
|
r = c.list_symbol_pins({"symbol": "NoColon"})
|
||||||
|
assert r["success"] is False
|
||||||
|
|
||||||
|
def test_happy_path(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(symbol_pins, "DynamicSymbolLoader", lambda **kw: _fake_loader(_R_BLOCK))
|
||||||
|
c = SymbolPinCommands()
|
||||||
|
r = c.list_symbol_pins({"symbol": "Device:R"})
|
||||||
|
assert r["success"] is True
|
||||||
|
assert r["pin_count"] == 2
|
||||||
|
assert r["symbol"] == "Device:R"
|
||||||
|
|
||||||
|
def test_not_found_returns_suggestions(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(symbol_pins, "DynamicSymbolLoader", lambda **kw: _fake_loader(None))
|
||||||
|
c = SymbolPinCommands()
|
||||||
|
r = c.list_symbol_pins({"symbol": "Device:Nope"})
|
||||||
|
assert r["success"] is False
|
||||||
|
assert "suggestions" in r
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchListSymbolPins:
|
||||||
|
def test_requires_symbols(self):
|
||||||
|
c = SymbolPinCommands()
|
||||||
|
assert c.batch_list_symbol_pins({"symbols": []})["success"] is False
|
||||||
|
|
||||||
|
def test_compact_symmetric_passive(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(symbol_pins, "DynamicSymbolLoader", lambda **kw: _fake_loader(_R_BLOCK))
|
||||||
|
c = SymbolPinCommands()
|
||||||
|
r = c.batch_list_symbol_pins({"symbols": ["Device:R"], "compact": True})
|
||||||
|
assert r["success"] is True
|
||||||
|
entry = r["symbols"]["Device:R"]
|
||||||
|
assert entry["compact"] is True and entry["is_symmetric"] is True
|
||||||
|
assert "pins" not in entry # detail omitted in compact mode
|
||||||
|
assert entry["body_bbox"]["width"] == pytest.approx(2.54)
|
||||||
|
|
||||||
|
def test_full_detail_when_not_compact(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(symbol_pins, "DynamicSymbolLoader", lambda **kw: _fake_loader(_R_BLOCK))
|
||||||
|
c = SymbolPinCommands()
|
||||||
|
r = c.batch_list_symbol_pins({"symbols": ["Device:R"], "compact": False})
|
||||||
|
assert r["symbols"]["Device:R"]["pins"]
|
||||||
|
|
||||||
|
def test_bad_spec_collected_as_error(self):
|
||||||
|
c = SymbolPinCommands()
|
||||||
|
r = c.batch_list_symbol_pins({"symbols": ["NoColon"]})
|
||||||
|
assert r["success"] is False
|
||||||
|
assert "NoColon" in r["errors"]
|
||||||
Reference in New Issue
Block a user