style: apply Black formatting to changed files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -524,11 +524,24 @@ class KiCADInterface:
|
||||
|
||||
# Board-mutating commands that trigger auto-save on SWIG path
|
||||
_BOARD_MUTATING_COMMANDS = {
|
||||
"place_component", "move_component", "rotate_component", "delete_component",
|
||||
"route_trace", "route_pad_to_pad", "add_via", "delete_trace", "add_net",
|
||||
"add_board_outline", "add_mounting_hole", "add_text", "add_board_text",
|
||||
"add_copper_pour", "refill_zones", "import_svg_logo",
|
||||
"sync_schematic_to_board", "connect_passthrough",
|
||||
"place_component",
|
||||
"move_component",
|
||||
"rotate_component",
|
||||
"delete_component",
|
||||
"route_trace",
|
||||
"route_pad_to_pad",
|
||||
"add_via",
|
||||
"delete_trace",
|
||||
"add_net",
|
||||
"add_board_outline",
|
||||
"add_mounting_hole",
|
||||
"add_text",
|
||||
"add_board_text",
|
||||
"add_copper_pour",
|
||||
"refill_zones",
|
||||
"import_svg_logo",
|
||||
"sync_schematic_to_board",
|
||||
"connect_passthrough",
|
||||
}
|
||||
|
||||
def _auto_save_board(self):
|
||||
@@ -833,7 +846,9 @@ class KiCADInterface:
|
||||
new_footprint = params.get("footprint")
|
||||
new_value = params.get("value")
|
||||
new_reference = params.get("newReference")
|
||||
field_positions = params.get("fieldPositions") # dict: {"Reference": {"x": 1, "y": 2, "angle": 0}}
|
||||
field_positions = params.get(
|
||||
"fieldPositions"
|
||||
) # dict: {"Reference": {"x": 1, "y": 2, "angle": 0}}
|
||||
|
||||
if not schematic_path:
|
||||
return {"success": False, "message": "schematicPath is required"}
|
||||
@@ -939,8 +954,10 @@ class KiCADInterface:
|
||||
y = pos.get("y", 0)
|
||||
angle = pos.get("angle", 0)
|
||||
block_text = re.sub(
|
||||
r'(\(property\s+"' + re.escape(field_name) + r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)',
|
||||
rf'\1(at {x} {y} {angle})',
|
||||
r'(\(property\s+"'
|
||||
+ re.escape(field_name)
|
||||
+ r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)',
|
||||
rf"\1(at {x} {y} {angle})",
|
||||
block_text,
|
||||
)
|
||||
|
||||
@@ -987,7 +1004,10 @@ class KiCADInterface:
|
||||
|
||||
sch_file = Path(schematic_path)
|
||||
if not sch_file.exists():
|
||||
return {"success": False, "message": f"Schematic not found: {schematic_path}"}
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Schematic not found: {schematic_path}",
|
||||
}
|
||||
|
||||
with open(sch_file, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
@@ -1007,7 +1027,9 @@ class KiCADInterface:
|
||||
|
||||
# Skip lib_symbols section
|
||||
lib_sym_pos = content.find("(lib_symbols")
|
||||
lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1
|
||||
lib_sym_end = (
|
||||
find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1
|
||||
)
|
||||
|
||||
# Find the placed symbol block for this reference
|
||||
block_start = block_end = None
|
||||
@@ -1025,21 +1047,34 @@ class KiCADInterface:
|
||||
if end < 0:
|
||||
search_start = pos + 1
|
||||
continue
|
||||
block_text = content[pos: end + 1]
|
||||
if re.search(r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', block_text):
|
||||
block_text = content[pos : end + 1]
|
||||
if re.search(
|
||||
r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"',
|
||||
block_text,
|
||||
):
|
||||
block_start, block_end = pos, end
|
||||
break
|
||||
search_start = end + 1
|
||||
|
||||
if block_start is None:
|
||||
return {"success": False, "message": f"Component '{reference}' not found in schematic"}
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Component '{reference}' not found in schematic",
|
||||
}
|
||||
|
||||
block_text = content[block_start: block_end + 1]
|
||||
block_text = content[block_start : block_end + 1]
|
||||
|
||||
# Extract component position: first (at x y angle) in the symbol header line
|
||||
comp_at = re.search(r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)', block_text)
|
||||
comp_at = re.search(
|
||||
r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)',
|
||||
block_text,
|
||||
)
|
||||
if comp_at:
|
||||
comp_pos = {"x": float(comp_at.group(1)), "y": float(comp_at.group(2)), "angle": float(comp_at.group(3))}
|
||||
comp_pos = {
|
||||
"x": float(comp_at.group(1)),
|
||||
"y": float(comp_at.group(2)),
|
||||
"angle": float(comp_at.group(3)),
|
||||
}
|
||||
else:
|
||||
comp_pos = None
|
||||
|
||||
@@ -1049,7 +1084,13 @@ class KiCADInterface:
|
||||
)
|
||||
fields = {}
|
||||
for m in prop_pattern.finditer(block_text):
|
||||
name, value, x, y, angle = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5)
|
||||
name, value, x, y, angle = (
|
||||
m.group(1),
|
||||
m.group(2),
|
||||
m.group(3),
|
||||
m.group(4),
|
||||
m.group(5),
|
||||
)
|
||||
fields[name] = {
|
||||
"value": value,
|
||||
"x": float(x),
|
||||
@@ -1067,6 +1108,7 @@ class KiCADInterface:
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting schematic component: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
@@ -1457,7 +1499,10 @@ class KiCADInterface:
|
||||
pin_offset = int(params.get("pinOffset", 0))
|
||||
|
||||
if not all([schematic_path, source_ref, target_ref]):
|
||||
return {"success": False, "message": "Missing required parameters: schematicPath, sourceRef, targetRef"}
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing required parameters: schematicPath, sourceRef, targetRef",
|
||||
}
|
||||
|
||||
result = ConnectionManager.connect_passthrough(
|
||||
Path(schematic_path), source_ref, target_ref, net_prefix, pin_offset
|
||||
@@ -1474,6 +1519,7 @@ class KiCADInterface:
|
||||
except Exception as e:
|
||||
logger.error(f"Error in connect_passthrough: {str(e)}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
@@ -1488,26 +1534,39 @@ class KiCADInterface:
|
||||
reference = params.get("reference")
|
||||
|
||||
if not all([schematic_path, reference]):
|
||||
return {"success": False, "message": "Missing required parameters: schematicPath, reference"}
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing required parameters: schematicPath, reference",
|
||||
}
|
||||
|
||||
locator = PinLocator()
|
||||
all_pins = locator.get_all_symbol_pins(Path(schematic_path), reference)
|
||||
|
||||
if not all_pins:
|
||||
return {"success": False, "message": f"No pins found for {reference} — check reference and schematic path"}
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"No pins found for {reference} — check reference and schematic path",
|
||||
}
|
||||
|
||||
# Enrich with pin names and angles from the symbol definition
|
||||
pins_def = locator.get_symbol_pins(
|
||||
Path(schematic_path),
|
||||
locator._get_lib_id(Path(schematic_path), reference),
|
||||
) if hasattr(locator, "_get_lib_id") else {}
|
||||
pins_def = (
|
||||
locator.get_symbol_pins(
|
||||
Path(schematic_path),
|
||||
locator._get_lib_id(Path(schematic_path), reference),
|
||||
)
|
||||
if hasattr(locator, "_get_lib_id")
|
||||
else {}
|
||||
)
|
||||
|
||||
result = {}
|
||||
for pin_num, coords in all_pins.items():
|
||||
entry = {"x": coords[0], "y": coords[1]}
|
||||
if pin_num in pins_def:
|
||||
entry["name"] = pins_def[pin_num].get("name", pin_num)
|
||||
entry["angle"] = locator.get_pin_angle(Path(schematic_path), reference, pin_num) or 0
|
||||
entry["angle"] = (
|
||||
locator.get_pin_angle(Path(schematic_path), reference, pin_num)
|
||||
or 0
|
||||
)
|
||||
result[pin_num] = entry
|
||||
|
||||
return {"success": True, "reference": reference, "pins": result}
|
||||
@@ -1515,6 +1574,7 @@ class KiCADInterface:
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting pin locations: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
@@ -1562,14 +1622,27 @@ class KiCADInterface:
|
||||
"errorDetails": "Install KiCAD 8.0+ or add kicad-cli to PATH.",
|
||||
}
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".json", delete=False
|
||||
) as tmp:
|
||||
json_output = tmp.name
|
||||
|
||||
try:
|
||||
cmd = [kicad_cli, "sch", "erc", "--format", "json", "--output", json_output, schematic_path]
|
||||
cmd = [
|
||||
kicad_cli,
|
||||
"sch",
|
||||
"erc",
|
||||
"--format",
|
||||
"json",
|
||||
"--output",
|
||||
json_output,
|
||||
schematic_path,
|
||||
]
|
||||
logger.info(f"Running ERC command: {' '.join(cmd)}")
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=120
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"ERC command failed: {result.stderr}")
|
||||
@@ -1590,13 +1663,18 @@ class KiCADInterface:
|
||||
items = v.get("items", [])
|
||||
loc = {}
|
||||
if items and "pos" in items[0]:
|
||||
loc = {"x": items[0]["pos"].get("x", 0), "y": items[0]["pos"].get("y", 0)}
|
||||
violations.append({
|
||||
"type": v.get("type", "unknown"),
|
||||
"severity": vseverity,
|
||||
"message": v.get("description", ""),
|
||||
"location": loc,
|
||||
})
|
||||
loc = {
|
||||
"x": items[0]["pos"].get("x", 0),
|
||||
"y": items[0]["pos"].get("y", 0),
|
||||
}
|
||||
violations.append(
|
||||
{
|
||||
"type": v.get("type", "unknown"),
|
||||
"severity": vseverity,
|
||||
"message": v.get("description", ""),
|
||||
"location": loc,
|
||||
}
|
||||
)
|
||||
if vseverity in severity_counts:
|
||||
severity_counts[vseverity] += 1
|
||||
|
||||
@@ -1643,10 +1721,12 @@ class KiCADInterface:
|
||||
|
||||
def _handle_sync_schematic_to_board(self, params):
|
||||
"""Sync schematic netlist to PCB board (equivalent to KiCAD F8 'Update PCB from Schematic').
|
||||
Reads net connections from the schematic and assigns them to the matching pads in the PCB."""
|
||||
Reads net connections from the schematic and assigns them to the matching pads in the PCB.
|
||||
"""
|
||||
logger.info("Syncing schematic to board")
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
schematic_path = params.get("schematicPath")
|
||||
board_path = params.get("boardPath")
|
||||
|
||||
@@ -1658,7 +1738,10 @@ class KiCADInterface:
|
||||
board = self.board
|
||||
board_path = board.GetFileName() if not board_path else board_path
|
||||
else:
|
||||
return {"success": False, "message": "No board loaded. Use open_project first or provide boardPath."}
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board loaded. Use open_project first or provide boardPath.",
|
||||
}
|
||||
|
||||
if not board_path:
|
||||
board_path = board.GetFileName()
|
||||
@@ -1675,14 +1758,19 @@ class KiCADInterface:
|
||||
schematic_path = str(sch_files[0])
|
||||
|
||||
if not schematic_path or not Path(schematic_path).exists():
|
||||
return {"success": False, "message": f"Schematic not found. Provide schematicPath. Tried: {schematic_path}"}
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Schematic not found. Provide schematicPath. Tried: {schematic_path}",
|
||||
}
|
||||
|
||||
# Generate netlist from schematic
|
||||
schematic = SchematicManager.load_schematic(schematic_path)
|
||||
if not schematic:
|
||||
return {"success": False, "message": "Failed to load schematic"}
|
||||
|
||||
netlist = ConnectionManager.generate_netlist(schematic, schematic_path=schematic_path)
|
||||
netlist = ConnectionManager.generate_netlist(
|
||||
schematic, schematic_path=schematic_path
|
||||
)
|
||||
|
||||
# Build (reference, pad_number) -> net_name map
|
||||
pad_net_map = {} # {(ref, pin_str): net_name}
|
||||
@@ -1733,7 +1821,9 @@ class KiCADInterface:
|
||||
self.board = board
|
||||
self._update_command_handlers()
|
||||
|
||||
logger.info(f"sync_schematic_to_board: {len(added_nets)} nets added, {assigned_pads} pads assigned")
|
||||
logger.info(
|
||||
f"sync_schematic_to_board: {len(added_nets)} nets added, {assigned_pads} pads assigned"
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"PCB nets synced from schematic: {len(added_nets)} nets added, {assigned_pads} pads assigned",
|
||||
@@ -1746,6 +1836,7 @@ class KiCADInterface:
|
||||
except Exception as e:
|
||||
logger.error(f"Error in sync_schematic_to_board: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
@@ -1765,9 +1856,14 @@ class KiCADInterface:
|
||||
filled = bool(params.get("filled", True))
|
||||
|
||||
if not pcb_path or not svg_path:
|
||||
return {"success": False, "message": "Missing required parameters: pcbPath, svgPath"}
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing required parameters: pcbPath, svgPath",
|
||||
}
|
||||
|
||||
result = import_svg_to_pcb(pcb_path, svg_path, x, y, width, layer, stroke_width, filled)
|
||||
result = import_svg_to_pcb(
|
||||
pcb_path, svg_path, x, y, width, layer, stroke_width, filled
|
||||
)
|
||||
|
||||
# import_svg_to_pcb writes gr_poly entries directly to the .kicad_pcb file,
|
||||
# bypassing the pcbnew in-memory board object. Any subsequent board.Save()
|
||||
@@ -1780,13 +1876,16 @@ class KiCADInterface:
|
||||
self._update_command_handlers()
|
||||
logger.info("Reloaded board into pcbnew after SVG logo import")
|
||||
except Exception as reload_err:
|
||||
logger.warning(f"Board reload after SVG import failed (non-fatal): {reload_err}")
|
||||
logger.warning(
|
||||
f"Board reload after SVG import failed (non-fatal): {reload_err}"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing SVG logo: {str(e)}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
@@ -1795,9 +1894,10 @@ class KiCADInterface:
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
step = params.get("step", "")
|
||||
label = params.get("label", "")
|
||||
step = params.get("step", "")
|
||||
label = params.get("label", "")
|
||||
prompt_text = params.get("prompt", "")
|
||||
# Determine project directory from loaded board or explicit path
|
||||
project_dir = None
|
||||
@@ -1808,7 +1908,10 @@ class KiCADInterface:
|
||||
if not project_dir:
|
||||
project_dir = params.get("projectPath")
|
||||
if not project_dir or not os.path.isdir(project_dir):
|
||||
return {"success": False, "message": "Could not determine project directory for snapshot"}
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Could not determine project directory for snapshot",
|
||||
}
|
||||
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
@@ -1818,16 +1921,21 @@ class KiCADInterface:
|
||||
|
||||
prompt_file = None
|
||||
if prompt_text:
|
||||
prompt_filename = f"PROMPT_step{step}_{ts}.md" if step else f"PROMPT_{ts}.md"
|
||||
prompt_filename = (
|
||||
f"PROMPT_step{step}_{ts}.md" if step else f"PROMPT_{ts}.md"
|
||||
)
|
||||
prompt_file = logs_dir / prompt_filename
|
||||
prompt_file.write_text(prompt_text, encoding="utf-8")
|
||||
logger.info(f"Prompt saved: {prompt_file}")
|
||||
|
||||
# Copy current MCP session log into logs/ before snapshotting
|
||||
import platform
|
||||
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
mcp_log_dir = os.path.join(os.environ.get("APPDATA", ""), "Claude", "logs")
|
||||
mcp_log_dir = os.path.join(
|
||||
os.environ.get("APPDATA", ""), "Claude", "logs"
|
||||
)
|
||||
elif system == "Darwin":
|
||||
mcp_log_dir = os.path.expanduser("~/Library/Logs/Claude")
|
||||
else:
|
||||
@@ -1842,11 +1950,15 @@ class KiCADInterface:
|
||||
if "Initializing server" in line:
|
||||
session_start = i
|
||||
session_lines = all_lines[session_start:]
|
||||
log_filename = f"mcp_log_step{step}_{ts}.txt" if step else f"mcp_log_{ts}.txt"
|
||||
log_filename = (
|
||||
f"mcp_log_step{step}_{ts}.txt" if step else f"mcp_log_{ts}.txt"
|
||||
)
|
||||
mcp_log_dest = logs_dir / log_filename
|
||||
with open(mcp_log_dest, "w", encoding="utf-8") as f:
|
||||
f.writelines(session_lines)
|
||||
logger.info(f"MCP session log saved: {mcp_log_dest} ({len(session_lines)} lines)")
|
||||
logger.info(
|
||||
f"MCP session log saved: {mcp_log_dest} ({len(session_lines)} lines)"
|
||||
)
|
||||
|
||||
base_name = Path(project_dir).name
|
||||
suffix_parts = [p for p in [f"step{step}" if step else "", label, ts] if p]
|
||||
@@ -1855,7 +1967,9 @@ class KiCADInterface:
|
||||
snapshots_base.mkdir(exist_ok=True)
|
||||
snapshot_dir = str(snapshots_base / snapshot_name)
|
||||
|
||||
shutil.copytree(project_dir, snapshot_dir, ignore=shutil.ignore_patterns("snapshots"))
|
||||
shutil.copytree(
|
||||
project_dir, snapshot_dir, ignore=shutil.ignore_patterns("snapshots")
|
||||
)
|
||||
logger.info(f"Project snapshot saved: {snapshot_dir}")
|
||||
return {
|
||||
"success": True,
|
||||
@@ -1928,13 +2042,19 @@ class KiCADInterface:
|
||||
# First save the board so the subprocess can load it fresh
|
||||
board_path = self.board.GetFileName()
|
||||
if not board_path:
|
||||
return {"success": False, "message": "Board has no file path — save first"}
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Board has no file path — save first",
|
||||
}
|
||||
self.board.Save(board_path)
|
||||
|
||||
zone_count = self.board.GetAreaCount() if hasattr(self.board, "GetAreaCount") else 0
|
||||
zone_count = (
|
||||
self.board.GetAreaCount() if hasattr(self.board, "GetAreaCount") else 0
|
||||
)
|
||||
|
||||
# Run pcbnew zone fill in an isolated subprocess to prevent crashes
|
||||
import subprocess, sys, textwrap
|
||||
|
||||
script = textwrap.dedent(f"""
|
||||
import pcbnew, sys
|
||||
board = pcbnew.LoadBoard({repr(board_path)})
|
||||
@@ -1946,7 +2066,9 @@ print("ok")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
capture_output=True, text=True, timeout=60
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if result.returncode == 0 and "ok" in result.stdout:
|
||||
# Reload board after subprocess modified it
|
||||
@@ -1959,12 +2081,18 @@ print("ok")
|
||||
"zoneCount": zone_count,
|
||||
}
|
||||
else:
|
||||
logger.warning(f"Zone fill subprocess failed: rc={result.returncode} stderr={result.stderr[:200]}")
|
||||
logger.warning(
|
||||
f"Zone fill subprocess failed: rc={result.returncode} stderr={result.stderr[:200]}"
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Zone fill failed in subprocess — zones are defined and will fill when opened in KiCAD (press B). Continuing is safe.",
|
||||
"zoneCount": zone_count,
|
||||
"details": result.stderr[:300] if result.stderr else result.stdout[:300],
|
||||
"details": (
|
||||
result.stderr[:300]
|
||||
if result.stderr
|
||||
else result.stdout[:300]
|
||||
),
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Zone fill subprocess timed out after 60s")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Tests for get_schematic_component and edit_schematic_component fieldPositions support.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import shutil
|
||||
@@ -21,7 +22,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / "python"))
|
||||
TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch"
|
||||
|
||||
# Minimal placed-symbol block we can embed into a schematic for testing
|
||||
PLACED_RESISTOR_BLOCK = '''\
|
||||
PLACED_RESISTOR_BLOCK = """\
|
||||
(symbol (lib_id "Device:R") (at 50 50 0) (unit 1)
|
||||
(in_bom yes) (on_board yes) (dnp no)
|
||||
(uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
|
||||
@@ -38,7 +39,7 @@ PLACED_RESISTOR_BLOCK = '''\
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
)
|
||||
'''
|
||||
"""
|
||||
|
||||
|
||||
def _make_test_schematic(tmp_dir: Path, extra_block: str = "") -> Path:
|
||||
@@ -58,6 +59,7 @@ def _make_test_schematic(tmp_dir: Path, extra_block: str = "") -> Path:
|
||||
# Unit tests – regex / parsing logic only (no file I/O, no KiCAD imports)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetSchematicComponentParsing:
|
||||
"""Unit tests for the regex logic used by _handle_get_schematic_component."""
|
||||
@@ -69,8 +71,19 @@ class TestGetSchematicComponentParsing:
|
||||
)
|
||||
fields = {}
|
||||
for m in prop_pattern.finditer(block_text):
|
||||
name, value, x, y, angle = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5)
|
||||
fields[name] = {"value": value, "x": float(x), "y": float(y), "angle": float(angle)}
|
||||
name, value, x, y, angle = (
|
||||
m.group(1),
|
||||
m.group(2),
|
||||
m.group(3),
|
||||
m.group(4),
|
||||
m.group(5),
|
||||
)
|
||||
fields[name] = {
|
||||
"value": value,
|
||||
"x": float(x),
|
||||
"y": float(y),
|
||||
"angle": float(angle),
|
||||
}
|
||||
return fields
|
||||
|
||||
def _parse_comp_pos(self, block_text: str):
|
||||
@@ -80,7 +93,11 @@ class TestGetSchematicComponentParsing:
|
||||
block_text,
|
||||
)
|
||||
if m:
|
||||
return {"x": float(m.group(1)), "y": float(m.group(2)), "angle": float(m.group(3))}
|
||||
return {
|
||||
"x": float(m.group(1)),
|
||||
"y": float(m.group(2)),
|
||||
"angle": float(m.group(3)),
|
||||
}
|
||||
return None
|
||||
|
||||
def test_parses_reference_field(self):
|
||||
@@ -115,8 +132,10 @@ class TestGetSchematicComponentParsing:
|
||||
new_x, new_y, new_angle = 99.0, 88.0, 0
|
||||
block = PLACED_RESISTOR_BLOCK
|
||||
block = re.sub(
|
||||
r'(\(property\s+"' + re.escape(field_name) + r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)',
|
||||
rf'\1(at {new_x} {new_y} {new_angle})',
|
||||
r'(\(property\s+"'
|
||||
+ re.escape(field_name)
|
||||
+ r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)',
|
||||
rf"\1(at {new_x} {new_y} {new_angle})",
|
||||
block,
|
||||
)
|
||||
fields = self._parse_fields(block)
|
||||
@@ -130,7 +149,7 @@ class TestGetSchematicComponentParsing:
|
||||
block = PLACED_RESISTOR_BLOCK
|
||||
block = re.sub(
|
||||
r'(\(property\s+"Value"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)',
|
||||
r'\1(at 0.0 0.0 0)',
|
||||
r"\1(at 0.0 0.0 0)",
|
||||
block,
|
||||
)
|
||||
fields = self._parse_fields(block)
|
||||
@@ -141,6 +160,7 @@ class TestGetSchematicComponentParsing:
|
||||
# Integration tests – real file I/O using the empty.kicad_sch template
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestGetSchematicComponentIntegration:
|
||||
"""Integration tests: write a real .kicad_sch and call the handler."""
|
||||
@@ -152,40 +172,53 @@ class TestGetSchematicComponentIntegration:
|
||||
def _get_interface(self):
|
||||
"""Lazily import KiCADInterface to avoid pcbnew import at collection time."""
|
||||
from kicad_interface import KiCADInterface
|
||||
|
||||
return KiCADInterface()
|
||||
|
||||
def test_get_returns_success(self, sch_with_r1):
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command("get_schematic_component", {
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
})
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
},
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
def test_get_returns_correct_reference(self, sch_with_r1):
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command("get_schematic_component", {
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
})
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
},
|
||||
)
|
||||
assert result["reference"] == "R1"
|
||||
|
||||
def test_get_returns_component_position(self, sch_with_r1):
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command("get_schematic_component", {
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
})
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
},
|
||||
)
|
||||
assert result["position"] is not None
|
||||
assert result["position"]["x"] == pytest.approx(50.0)
|
||||
assert result["position"]["y"] == pytest.approx(50.0)
|
||||
|
||||
def test_get_returns_reference_field_position(self, sch_with_r1):
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command("get_schematic_component", {
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
})
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
},
|
||||
)
|
||||
ref_field = result["fields"]["Reference"]
|
||||
assert ref_field["value"] == "R1"
|
||||
assert ref_field["x"] == pytest.approx(51.27)
|
||||
@@ -193,10 +226,13 @@ class TestGetSchematicComponentIntegration:
|
||||
|
||||
def test_get_returns_value_field(self, sch_with_r1):
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command("get_schematic_component", {
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
})
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
},
|
||||
)
|
||||
val_field = result["fields"]["Value"]
|
||||
assert val_field["value"] == "10k"
|
||||
assert val_field["x"] == pytest.approx(51.27)
|
||||
@@ -204,18 +240,24 @@ class TestGetSchematicComponentIntegration:
|
||||
|
||||
def test_get_unknown_reference_returns_failure(self, sch_with_r1):
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command("get_schematic_component", {
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R99",
|
||||
})
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R99",
|
||||
},
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert "R99" in result["message"]
|
||||
|
||||
def test_get_missing_path_returns_failure(self):
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command("get_schematic_component", {
|
||||
"reference": "R1",
|
||||
})
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
{
|
||||
"reference": "R1",
|
||||
},
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
@@ -229,74 +271,99 @@ class TestEditSchematicComponentFieldPositions:
|
||||
|
||||
def _get_interface(self):
|
||||
from kicad_interface import KiCADInterface
|
||||
|
||||
return KiCADInterface()
|
||||
|
||||
def test_reposition_reference_label(self, sch_with_r1):
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command("edit_schematic_component", {
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
"fieldPositions": {"Reference": {"x": 99.0, "y": 88.0, "angle": 0}},
|
||||
})
|
||||
result = iface.handle_command(
|
||||
"edit_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
"fieldPositions": {"Reference": {"x": 99.0, "y": 88.0, "angle": 0}},
|
||||
},
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
# Verify the position was actually written
|
||||
get_result = iface.handle_command("get_schematic_component", {
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
})
|
||||
get_result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
},
|
||||
)
|
||||
assert get_result["fields"]["Reference"]["x"] == pytest.approx(99.0)
|
||||
assert get_result["fields"]["Reference"]["y"] == pytest.approx(88.0)
|
||||
|
||||
def test_reposition_does_not_change_value(self, sch_with_r1):
|
||||
iface = self._get_interface()
|
||||
iface.handle_command("edit_schematic_component", {
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
"fieldPositions": {"Reference": {"x": 99.0, "y": 88.0}},
|
||||
})
|
||||
get_result = iface.handle_command("get_schematic_component", {
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
})
|
||||
iface.handle_command(
|
||||
"edit_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
"fieldPositions": {"Reference": {"x": 99.0, "y": 88.0}},
|
||||
},
|
||||
)
|
||||
get_result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
},
|
||||
)
|
||||
# Value field position must be unchanged
|
||||
assert get_result["fields"]["Value"]["x"] == pytest.approx(51.27)
|
||||
assert get_result["fields"]["Value"]["y"] == pytest.approx(52.54)
|
||||
|
||||
def test_reposition_multiple_fields(self, sch_with_r1):
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command("edit_schematic_component", {
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
"fieldPositions": {
|
||||
"Reference": {"x": 10.0, "y": 20.0, "angle": 0},
|
||||
"Value": {"x": 10.0, "y": 30.0, "angle": 0},
|
||||
result = iface.handle_command(
|
||||
"edit_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
"fieldPositions": {
|
||||
"Reference": {"x": 10.0, "y": 20.0, "angle": 0},
|
||||
"Value": {"x": 10.0, "y": 30.0, "angle": 0},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
get_result = iface.handle_command("get_schematic_component", {
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
})
|
||||
get_result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
},
|
||||
)
|
||||
assert get_result["fields"]["Reference"]["x"] == pytest.approx(10.0)
|
||||
assert get_result["fields"]["Value"]["y"] == pytest.approx(30.0)
|
||||
|
||||
def test_fieldpositions_alone_is_valid(self, sch_with_r1):
|
||||
"""fieldPositions without value/footprint/newReference should succeed."""
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command("edit_schematic_component", {
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
"fieldPositions": {"Value": {"x": 55.0, "y": 60.0}},
|
||||
})
|
||||
result = iface.handle_command(
|
||||
"edit_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
"fieldPositions": {"Value": {"x": 55.0, "y": 60.0}},
|
||||
},
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
def test_no_params_still_fails(self, sch_with_r1):
|
||||
"""Providing no update params should return an error."""
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command("edit_schematic_component", {
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
})
|
||||
result = iface.handle_command(
|
||||
"edit_schematic_component",
|
||||
{
|
||||
"schematicPath": str(sch_with_r1),
|
||||
"reference": "R1",
|
||||
},
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
Reference in New Issue
Block a user