diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d3340d2..5e23b3d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,7 +46,9 @@ repos: files: ^python/ exclude: ^python/commands/board\.py$ args: [--config-file=pyproject.toml] - additional_dependencies: [] + additional_dependencies: + - types-requests + - pytest - repo: local hooks: diff --git a/pyproject.toml b/pyproject.toml index 5346eb0..fd99a12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,15 +23,20 @@ disable_error_code = [ "arg-type", "attr-defined", "union-attr", - "call-arg", - "assignment", "var-annotated", - "annotation-unchecked", "index", - "has-type", - "dict-item", - "misc", - "list-item", - "return-value", - "operator", ] + +[[tool.mypy.overrides]] +module = [ + "pcbnew", + "cairosvg", + "sexpdata", + "skip", + "kipy", + "kipy.*", + "schematic", + "PIL", + "PIL.*", +] +ignore_missing_imports = true diff --git a/python/commands/component_schematic.py b/python/commands/component_schematic.py index 597709d..c280919 100644 --- a/python/commands/component_schematic.py +++ b/python/commands/component_schematic.py @@ -2,7 +2,7 @@ import logging import os import uuid from pathlib import Path -from typing import Optional +from typing import Any, Dict, List, Optional, Tuple from skip import Schematic @@ -25,7 +25,7 @@ class ComponentManager: _dynamic_loader = None @classmethod - def get_dynamic_loader(cls): + def get_dynamic_loader(cls) -> Any: """Get or create dynamic symbol loader instance""" if cls._dynamic_loader is None and DYNAMIC_LOADING_AVAILABLE: cls._dynamic_loader = DynamicSymbolLoader() @@ -86,7 +86,7 @@ class ComponentManager: """ # Helper function to check if template exists in schematic - def template_exists(schematic, template_ref): + def template_exists(schematic: Any, template_ref: str) -> bool: """Check if template exists by iterating symbols (handles special characters)""" for symbol in schematic.symbol: if ( @@ -165,7 +165,7 @@ class ComponentManager: @staticmethod def add_component( schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None - ): + ) -> Any: """ Add a component to the schematic by cloning from template @@ -265,7 +265,7 @@ class ComponentManager: raise @staticmethod - def remove_component(schematic: Schematic, component_ref: str): + def remove_component(schematic: Schematic, component_ref: str) -> bool: """Remove a component from the schematic by reference designator""" try: # kicad-skip doesn't have a direct remove_symbol method by reference. @@ -288,7 +288,7 @@ class ComponentManager: return False @staticmethod - def update_component(schematic: Schematic, component_ref: str, new_properties: dict): + def update_component(schematic: Schematic, component_ref: str, new_properties: dict) -> bool: """Update component properties by reference designator""" try: symbol_to_update = None @@ -313,7 +313,7 @@ class ComponentManager: return False @staticmethod - def get_component(schematic: Schematic, component_ref: str): + def get_component(schematic: Schematic, component_ref: str) -> Any: """Get a component by reference designator""" for symbol in schematic.symbol: if symbol.reference == component_ref: @@ -323,7 +323,7 @@ class ComponentManager: return None @staticmethod - def search_components(schematic: Schematic, query: str): + def search_components(schematic: Schematic, query: str) -> List[Any]: """Search for components matching criteria (basic implementation)""" # This is a basic search, could be expanded to use regex or more complex logic matching_components = [] @@ -342,7 +342,7 @@ class ComponentManager: return matching_components @staticmethod - def get_all_components(schematic: Schematic): + def get_all_components(schematic: Schematic) -> List[Any]: """Get all components in schematic""" logger.debug(f"Retrieving all {len(schematic.symbol)} components.") return list(schematic.symbol) diff --git a/python/commands/connection_schematic.py b/python/commands/connection_schematic.py index 19dfb95..69f8ceb 100644 --- a/python/commands/connection_schematic.py +++ b/python/commands/connection_schematic.py @@ -1,7 +1,7 @@ import logging import os from pathlib import Path -from typing import Optional +from typing import Any, Dict, List, Optional from skip import Schematic @@ -25,14 +25,14 @@ class ConnectionManager: _pin_locator = None @classmethod - def get_pin_locator(cls): + def get_pin_locator(cls) -> Any: """Get or create pin locator instance""" if cls._pin_locator is None and WIRE_MANAGER_AVAILABLE: cls._pin_locator = PinLocator() return cls._pin_locator @staticmethod - def add_net_label(schematic: Schematic, net_name: str, position: list): + def add_net_label(schematic: Schematic, net_name: str, position: list) -> Any: """ Add a net label to the schematic @@ -57,7 +57,9 @@ class ConnectionManager: return None @staticmethod - def connect_to_net(schematic_path: Path, component_ref: str, pin_name: str, net_name: str): + def connect_to_net( + schematic_path: Path, component_ref: str, pin_name: str, net_name: str + ) -> bool: """ Connect a component pin to a named net using a wire stub and label @@ -132,7 +134,7 @@ class ConnectionManager: target_ref: str, net_prefix: str = "PIN", pin_offset: int = 0, - ): + ) -> Dict[str, List[str]]: """ Connect all pins of source_ref to matching pins of target_ref via shared net labels. Useful for passthrough adapters: J1 pin N <-> J2 pin N on net {net_prefix}_{N}. @@ -203,7 +205,7 @@ class ConnectionManager: @staticmethod def get_net_connections( schematic: Schematic, net_name: str, schematic_path: Optional[Path] = None - ): + ) -> List[Dict]: """ Get all connections for a named net using wire graph analysis @@ -221,7 +223,7 @@ class ConnectionManager: connections = [] tolerance = 0.5 # 0.5mm tolerance for point coincidence (grid spacing consideration) - def points_coincide(p1, p2): + def points_coincide(p1: Any, p2: Any) -> bool: """Check if two points are the same (within tolerance)""" if not p1 or not p2: return False @@ -324,8 +326,8 @@ class ConnectionManager: continue # Check if pin coincides with any wire point - for wire_pt in connected_wire_points: - if points_coincide(pin_loc, list(wire_pt)): + for wire_pt_tup in connected_wire_points: + if points_coincide(pin_loc, list(wire_pt_tup)): connections.append({"component": ref, "pin": pin_num}) break # Pin found, no need to check more wire points @@ -344,8 +346,10 @@ class ConnectionManager: symbol_y = float(symbol_pos[1]) # Check if symbol is near any wire point (within 10mm) - for wire_pt in connected_wire_points: - dist = ((symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2) ** 0.5 + for wire_pt_tup in connected_wire_points: + dist = ( + (symbol_x - wire_pt_tup[0]) ** 2 + (symbol_y - wire_pt_tup[1]) ** 2 + ) ** 0.5 if dist < 10.0: # 10mm proximity threshold connections.append({"component": ref, "pin": "unknown"}) break # Only add once per component @@ -361,7 +365,9 @@ class ConnectionManager: return [] @staticmethod - def generate_netlist(schematic: Schematic, schematic_path: Optional[Path] = None): + def generate_netlist( + schematic: Schematic, schematic_path: Optional[Path] = None + ) -> Dict[str, Any]: """ Generate a netlist from the schematic diff --git a/python/commands/datasheet_manager.py b/python/commands/datasheet_manager.py index fe11f14..db39a16 100644 --- a/python/commands/datasheet_manager.py +++ b/python/commands/datasheet_manager.py @@ -12,7 +12,7 @@ No API key required. import logging import re from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple logger = logging.getLogger("kicad_interface") @@ -49,7 +49,7 @@ class DatasheetManager: return None @staticmethod - def _find_lib_symbols_range(lines: List[str]): + def _find_lib_symbols_range(lines: List[str]) -> Tuple[Optional[int], Optional[int]]: """ Find the line range of the (lib_symbols ...) section. Returns (start, end) line indices or (None, None) if not found. diff --git a/python/commands/footprint.py b/python/commands/footprint.py index 55e54f7..389822f 100644 --- a/python/commands/footprint.py +++ b/python/commands/footprint.py @@ -238,7 +238,10 @@ class FootprintCreator: changes.append(f"drill (inserted)→{new_drill}") if shape: block, n = re.subn( - r'(pad\s+"[^"]*"\s+\w+\s+)\w+', lambda m: m.group(1) + shape, block, count=1 + r'(pad\s+"[^"]*"\s+\w+\s+)\w+', + lambda m: str(m.group(1)) + shape, + block, + count=1, ) if n: changes.append(f"shape→{shape}") diff --git a/python/commands/freerouting.py b/python/commands/freerouting.py index 3da22b1..8dff349 100644 --- a/python/commands/freerouting.py +++ b/python/commands/freerouting.py @@ -95,6 +95,8 @@ def _build_freerouting_cmd( """Build the command to run Freerouting.""" if use_docker: docker_exe = _find_docker() + if docker_exe is None: + raise RuntimeError("Docker/Podman executable not found") board_dir = os.path.dirname(dsn_path) dsn_name = os.path.basename(dsn_path) ses_name = os.path.basename(ses_path) @@ -120,6 +122,8 @@ def _build_freerouting_cmd( ] else: java_exe = _find_java() + if java_exe is None: + raise RuntimeError("Java executable not found") return [ java_exe, "-jar", @@ -136,7 +140,7 @@ def _build_freerouting_cmd( class FreeroutingCommands: """Handles Freerouting autoroute operations.""" - def __init__(self, board=None): + def __init__(self, board: Any = None) -> None: self.board = board def _resolve_execution_mode(self, jar_path: str) -> Dict[str, Any]: diff --git a/python/commands/jlcpcb_parts.py b/python/commands/jlcpcb_parts.py index 3edadd5..76f6f4e 100644 --- a/python/commands/jlcpcb_parts.py +++ b/python/commands/jlcpcb_parts.py @@ -11,7 +11,7 @@ import os import sqlite3 from datetime import datetime from pathlib import Path -from typing import Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional, Tuple logger = logging.getLogger("kicad_interface") @@ -38,10 +38,10 @@ class JLCPCBPartsManager: db_path = str(data_dir / "jlcpcb_parts.db") self.db_path = db_path - self.conn = None + self.conn: Optional[sqlite3.Connection] = None self._init_database() - def _init_database(self): + def _init_database(self) -> None: """Initialize SQLite database with schema""" self.conn = sqlite3.connect(self.db_path) self.conn.row_factory = sqlite3.Row # Return rows as dicts @@ -90,7 +90,9 @@ class JLCPCBPartsManager: self.conn.commit() logger.info(f"Initialized JLCPCB parts database at {self.db_path}") - def import_parts(self, parts: List[Dict], progress_callback=None): + def import_parts( + self, parts: List[Dict], progress_callback: Optional[Callable[..., Any]] = None + ) -> None: """ Import parts into database from JLCPCB API response @@ -167,7 +169,9 @@ class JLCPCBPartsManager: else: return "Extended" # Default to Extended - def import_jlcsearch_parts(self, parts: List[Dict], progress_callback=None): + def import_jlcsearch_parts( + self, parts: List[Dict], progress_callback: Optional[Callable[..., Any]] = None + ) -> None: """ Import parts into database from JLCSearch API response @@ -452,7 +456,7 @@ class JLCPCBPartsManager: alternatives = [p for p in alternatives if p["lcsc"] != lcsc_number] # Sort by: Basic first, then by price, then by stock - def sort_key(p): + def sort_key(p: Dict[str, Any]) -> Tuple[int, float, int]: is_basic = 1 if p.get("library_type") == "Basic" else 0 try: prices = json.loads(p.get("price_json", "[]")) @@ -467,7 +471,7 @@ class JLCPCBPartsManager: return alternatives[:limit] - def close(self): + def close(self) -> None: """Close database connection""" if self.conn: self.conn.close() diff --git a/python/commands/jlcsearch.py b/python/commands/jlcsearch.py index 9ae38c8..1ca6ad2 100644 --- a/python/commands/jlcsearch.py +++ b/python/commands/jlcsearch.py @@ -7,7 +7,7 @@ jlcsearch service at https://jlcsearch.tscircuit.com/ import logging import time -from typing import Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional, Union import requests @@ -24,12 +24,12 @@ class JLCSearchClient: BASE_URL = "https://jlcsearch.tscircuit.com" - def __init__(self): + def __init__(self) -> None: """Initialize JLCSearch API client""" pass def search_components( - self, category: str = "components", limit: int = 100, offset: int = 0, **filters + self, category: str = "components", limit: int = 100, offset: int = 0, **filters: Dict ) -> List[Dict]: """ Search components in JLCSearch database @@ -87,7 +87,7 @@ class JLCSearchClient: - stock: Available stock - price1: Price per unit """ - filters = {} + filters: Dict[str, Any] = {} if resistance is not None: filters["resistance"] = resistance if package: @@ -109,7 +109,7 @@ class JLCSearchClient: Returns: List of capacitor dicts """ - filters = {} + filters: Dict[str, Any] = {} if capacitance is not None: filters["capacitance"] = capacitance if package: diff --git a/python/commands/library.py b/python/commands/library.py index 5e137e8..f38c496 100644 --- a/python/commands/library.py +++ b/python/commands/library.py @@ -35,7 +35,7 @@ class LibraryManager: self.footprint_cache: Dict[str, List[str]] = {} # library -> [footprint names] self._load_libraries() - def _load_libraries(self): + def _load_libraries(self) -> None: """Load libraries from fp-lib-table files""" # Load global libraries global_table = self._get_global_fp_lib_table() @@ -78,7 +78,7 @@ class LibraryManager: return None - def _parse_fp_lib_table(self, table_path: Path): + def _parse_fp_lib_table(self, table_path: Path) -> None: """ Parse fp-lib-table file diff --git a/python/commands/library_schematic.py b/python/commands/library_schematic.py index 8d683d5..d057884 100644 --- a/python/commands/library_schematic.py +++ b/python/commands/library_schematic.py @@ -3,6 +3,7 @@ import logging # Symbol class might not be directly importable in the current version import os +from typing import Any, Dict, List, Optional from skip import Schematic @@ -13,7 +14,7 @@ class LibraryManager: """Manage symbol libraries""" @staticmethod - def list_available_libraries(search_paths=None): + def list_available_libraries(search_paths: Optional[List[str]] = None) -> Dict[str, List[str]]: """List all available symbol libraries""" if search_paths is None: # Default library paths based on common KiCAD installations @@ -46,7 +47,7 @@ class LibraryManager: return {"paths": libraries, "names": library_names} @staticmethod - def list_library_symbols(library_path): + def list_library_symbols(library_path: str) -> List[Any]: """List all symbols in a library""" try: # kicad-skip doesn't provide a direct way to simply list symbols in a library @@ -66,7 +67,7 @@ class LibraryManager: return [] @staticmethod - def get_symbol_details(library_path, symbol_name): + def get_symbol_details(library_path: str, symbol_name: str) -> Dict[str, Any]: """Get detailed information about a symbol""" try: # Similar to list_library_symbols, this might require a more direct approach @@ -80,7 +81,7 @@ class LibraryManager: return {} @staticmethod - def search_symbols(query, search_paths=None): + def search_symbols(query: str, search_paths: Optional[List[str]] = None) -> List[Any]: """Search for symbols matching criteria""" try: # This would typically involve: @@ -101,7 +102,9 @@ class LibraryManager: return [] @staticmethod - def get_default_symbol_for_component_type(component_type, search_paths=None): + def get_default_symbol_for_component_type( + component_type: str, search_paths: Optional[List[str]] = None + ) -> Dict[str, str]: """Get a recommended default symbol for a given component type""" # This method provides a simplified way to get a symbol for common component types # It's useful when the user doesn't specify a particular library/symbol diff --git a/python/commands/library_symbol.py b/python/commands/library_symbol.py index 95cd716..ff7f650 100644 --- a/python/commands/library_symbol.py +++ b/python/commands/library_symbol.py @@ -55,7 +55,7 @@ class SymbolLibraryManager: self.symbol_cache: Dict[str, List[SymbolInfo]] = {} # library -> [SymbolInfo] self._load_libraries() - def _load_libraries(self): + def _load_libraries(self) -> None: """Load libraries from sym-lib-table files""" # Load global libraries global_table = self._get_global_sym_lib_table() @@ -98,7 +98,7 @@ class SymbolLibraryManager: return None - def _parse_sym_lib_table(self, table_path: Path): + def _parse_sym_lib_table(self, table_path: Path) -> None: """ Parse sym-lib-table file @@ -370,7 +370,7 @@ class SymbolLibraryManager: query_lower = query.lower() # Determine which libraries to search - libraries_to_search = self.libraries.keys() + libraries_to_search: list[str] = list(self.libraries.keys()) if library_filter: filter_lower = library_filter.lower() libraries_to_search = [ diff --git a/python/commands/pin_locator.py b/python/commands/pin_locator.py index 7122216..2ce4716 100644 --- a/python/commands/pin_locator.py +++ b/python/commands/pin_locator.py @@ -9,7 +9,7 @@ import logging import math import tempfile from pathlib import Path -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple import sexpdata from sexpdata import Symbol @@ -21,7 +21,7 @@ logger = logging.getLogger("kicad_interface") class PinLocator: """Locate pins on symbol instances in KiCad schematics""" - def __init__(self): + def __init__(self) -> None: """Initialize pin locator with empty cache""" self.pin_definition_cache = {} # Cache: "lib_id:symbol_name" -> pin_data self._schematic_cache: Dict[str, object] = {} # Cache: path -> loaded Schematic @@ -41,9 +41,9 @@ class PinLocator: "2": {"x": 0, "y": -3.81, "angle": 90, "length": 1.27, "name": "~", "type": "passive"} } """ - pins = {} + pins: Dict[str, Dict[str, Any]] = {} - def extract_pins_recursive(sexp): + def extract_pins_recursive(sexp: Any) -> None: """Recursively search for pin definitions""" if not isinstance(sexp, list): return diff --git a/python/commands/routing.py b/python/commands/routing.py index 317de8b..9accea3 100644 --- a/python/commands/routing.py +++ b/python/commands/routing.py @@ -115,7 +115,7 @@ class RoutingCommands: "errorDetails": f"'{ref}' does not exist on the board", } - def find_pad(ref: str, pad_num: str): + def find_pad(ref: str, pad_num: str) -> Any: fp = footprints[ref] for pad in fp.Pads(): if pad.GetNumber() == pad_num: diff --git a/python/commands/schematic.py b/python/commands/schematic.py index 19a801a..4d4c312 100644 --- a/python/commands/schematic.py +++ b/python/commands/schematic.py @@ -2,6 +2,7 @@ import logging import os import shutil import uuid +from typing import Any, Optional from skip import Schematic @@ -12,7 +13,7 @@ class SchematicManager: """Core schematic operations using kicad-skip""" @staticmethod - def create_schematic(name, metadata=None): + def create_schematic(name: str, metadata: Optional[Any] = None) -> Any: """Create a new empty schematic from template""" try: # Determine template path (use template_with_symbols for component cloning support) @@ -70,7 +71,7 @@ class SchematicManager: raise @staticmethod - def load_schematic(file_path): + def load_schematic(file_path: str) -> Optional[Any]: """Load an existing schematic""" if not os.path.exists(file_path): logger.error(f"Schematic file not found at {file_path}") @@ -84,7 +85,7 @@ class SchematicManager: return None @staticmethod - def save_schematic(schematic, file_path): + def save_schematic(schematic: Any, file_path: str) -> bool: """Save a schematic to file""" try: # kicad-skip uses write method, not save @@ -96,7 +97,7 @@ class SchematicManager: return False @staticmethod - def get_schematic_metadata(schematic): + def get_schematic_metadata(schematic: Any) -> dict[str, Any]: """Extract metadata from schematic""" # kicad-skip doesn't expose a direct metadata object on Schematic. # We can return basic info like version and generator. diff --git a/python/commands/schematic_analysis.py b/python/commands/schematic_analysis.py index d1a212a..975b005 100644 --- a/python/commands/schematic_analysis.py +++ b/python/commands/schematic_analysis.py @@ -782,7 +782,7 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]: collisions = [] # Pre-compute per-symbol data - symbol_data = [] + symbol_data: List[Dict[str, Any]] = [] for sym in symbols: ref = sym["reference"] if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref: diff --git a/python/commands/svg_import.py b/python/commands/svg_import.py index e6f2f33..6654277 100644 --- a/python/commands/svg_import.py +++ b/python/commands/svg_import.py @@ -129,7 +129,7 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]: cx_ = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2 cy_ = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2 - def angle(ux, uy, vx, vy): + def angle(ux: float, uy: float, vx: float, vy: float) -> float: a = math.acos( max(-1, min(1, (ux * vx + uy * vy) / (math.hypot(ux, uy) * math.hypot(vx, vy)))) ) @@ -293,10 +293,10 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]: def _parse_transform(transform_str: str) -> List[List[float]]: """Parse SVG transform attribute, return list of 3×3 matrix rows [a,b,c; d,e,f; 0,0,1].""" - def identity(): + def identity() -> List[List[float]]: return [[1, 0, 0], [0, 1, 0], [0, 0, 1]] - def mat_mul(A, B): + def mat_mul(A: List[List[float]], B: List[List[float]]) -> List[List[float]]: return [[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)] for r in range(3)] result = identity() @@ -345,7 +345,7 @@ def _apply_transform(pts: List[Point], mat: List[List[float]]) -> List[Point]: return out -def _mat_mul(A, B): +def _mat_mul(A: List[List[float]], B: List[List[float]]) -> List[List[float]]: return [[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)] for r in range(3)] @@ -366,7 +366,7 @@ def _get_attr(el: ET.Element, name: str, default: Optional[str] = None) -> Optio return default -def _identity(): +def _identity() -> List[List[float]]: return [[1, 0, 0], [0, 1, 0], [0, 0, 1]] diff --git a/python/commands/wire_connectivity.py b/python/commands/wire_connectivity.py index acd1f3c..d1728c9 100644 --- a/python/commands/wire_connectivity.py +++ b/python/commands/wire_connectivity.py @@ -8,7 +8,7 @@ coordinate matching, mirroring KiCad's own connectivity algorithm. import logging from pathlib import Path -from typing import Dict, List, Optional, Set, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple from commands.pin_locator import PinLocator @@ -22,7 +22,7 @@ def _to_iu(x_mm: float, y_mm: float) -> Tuple[int, int]: return (round(x_mm * _IU_PER_MM), round(y_mm * _IU_PER_MM)) -def _parse_wires(schematic) -> List[List[Tuple[int, int]]]: +def _parse_wires(schematic: Any) -> List[List[Tuple[int, int]]]: """Extract wire endpoints from a schematic object as IU tuples.""" all_wires = [] for wire in schematic.wire: @@ -67,7 +67,9 @@ def _build_adjacency( return adjacency, iu_to_wires -def _parse_virtual_connections(schematic, schematic_path): +def _parse_virtual_connections( + schematic: Any, schematic_path: Any +) -> Tuple[Dict[Tuple[int, int], str], Dict[str, List[Tuple[int, int]]]]: """Return virtual connectivity from net labels and power symbols. Returns a tuple of: @@ -175,8 +177,8 @@ def _find_connected_wires( def _find_pins_on_net( net_points: Set[Tuple[int, int]], - schematic_path, - schematic, + schematic_path: Any, + schematic: Any, ) -> List[Dict]: """Find component pins that land on net points using exact IU matching. @@ -216,7 +218,7 @@ def _find_pins_on_net( def get_wire_connections( - schematic, schematic_path: str, x_mm: float, y_mm: float + schematic: Any, schematic_path: str, x_mm: float, y_mm: float ) -> Optional[Dict]: """Find all component pins reachable from a point via connected wires, net labels, and power symbols. diff --git a/python/commands/wire_dragger.py b/python/commands/wire_dragger.py index 2ad7a54..379df22 100644 --- a/python/commands/wire_dragger.py +++ b/python/commands/wire_dragger.py @@ -7,7 +7,7 @@ All methods operate on in-memory sexpdata lists (no disk I/O). import logging import math import uuid -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple import sexpdata from sexpdata import Symbol @@ -55,7 +55,7 @@ class WireDragger: """Pure-logic helpers for wire-endpoint dragging during component moves.""" @staticmethod - def find_symbol(sch_data: list, reference: str): + def find_symbol(sch_data: list, reference: str) -> Any: """ Find a placed symbol by reference designator. @@ -218,7 +218,7 @@ class WireDragger: junction_k = _K["junction"] at_k = _K["at"] - def find_new(x: float, y: float): + def find_new(x: float, y: float) -> Optional[Tuple[float, float]]: for (ox, oy), (nx, ny) in old_to_new.items(): if _coords_match(x, y, ox, oy, eps): return nx, ny diff --git a/python/commands/wire_manager.py b/python/commands/wire_manager.py index 3851f36..eb55ad9 100644 --- a/python/commands/wire_manager.py +++ b/python/commands/wire_manager.py @@ -11,7 +11,7 @@ import math import tempfile import uuid from pathlib import Path -from typing import List, Optional, Tuple +from typing import Any, List, Optional, Tuple import sexpdata from sexpdata import Symbol @@ -255,7 +255,7 @@ class WireManager: @staticmethod def _parse_wire( - wire_item, + wire_item: Any, ) -> Optional[Tuple[Tuple[float, float], Tuple[float, float], float, str]]: """ Parse a wire S-expression item in a single pass. diff --git a/python/kicad_api/base.py b/python/kicad_api/base.py index b64c922..2bdbd14 100644 --- a/python/kicad_api/base.py +++ b/python/kicad_api/base.py @@ -127,7 +127,7 @@ class BoardAPI(ABC): pass @abstractmethod - def get_size(self) -> Dict[str, float]: + def get_size(self) -> Dict[str, Any]: """ Get current board size @@ -169,6 +169,7 @@ class BoardAPI(ABC): y: float, rotation: float = 0, layer: str = "F.Cu", + value: str = "", ) -> bool: """ Place a component on the board diff --git a/python/kicad_api/ipc_backend.py b/python/kicad_api/ipc_backend.py index 65a3879..e69b94e 100644 --- a/python/kicad_api/ipc_backend.py +++ b/python/kicad_api/ipc_backend.py @@ -40,10 +40,10 @@ class IPCBackend(KiCADBackend): without requiring manual reload. """ - def __init__(self): + def __init__(self) -> None: self._kicad = None self._connected = False - self._version = None + self._version: Optional[str] = None self._on_change_callbacks: List[Callable] = [] def connect(self, socket_path: Optional[str] = None) -> bool: @@ -257,13 +257,13 @@ class IPCBoardAPI(BoardAPI): Uses transactions for proper undo/redo support. """ - def __init__(self, kicad_instance, notify_callback: Callable): + def __init__(self, kicad_instance: Any, notify_callback: Callable) -> None: self._kicad = kicad_instance self._board = None self._notify = notify_callback self._current_commit = None - def _get_board(self): + def _get_board(self) -> Any: """Get board instance, connecting if needed.""" if self._board is None: try: @@ -350,7 +350,7 @@ class IPCBoardAPI(BoardAPI): logger.error(f"Failed to set board size: {e}") return False - def get_size(self) -> Dict[str, float]: + def get_size(self) -> Dict[str, Any]: """Get current board size from bounding box.""" try: board = self._get_board() @@ -490,7 +490,7 @@ class IPCBoardAPI(BoardAPI): logger.error(f"Failed to place component: {e}") return False - def _load_footprint_from_library(self, footprint_path: str): + def _load_footprint_from_library(self, footprint_path: str) -> Any: """ Load a footprint from the library using pcbnew SWIG API. @@ -546,7 +546,14 @@ class IPCBoardAPI(BoardAPI): return None def _place_loaded_footprint( - self, loaded_fp, reference: str, x: float, y: float, rotation: float, layer: str, value: str + self, + loaded_fp: Any, + reference: str, + x: float, + y: float, + rotation: float, + layer: str, + value: str, ) -> bool: """ Place a loaded pcbnew footprint onto the board. diff --git a/python/kicad_api/swig_backend.py b/python/kicad_api/swig_backend.py index 7864c2b..a12b604 100644 --- a/python/kicad_api/swig_backend.py +++ b/python/kicad_api/swig_backend.py @@ -26,7 +26,7 @@ class SWIGBackend(KiCADBackend): for compatibility during migration period. """ - def __init__(self): + def __init__(self) -> None: self._connected = False self._pcbnew = None logger.warning( @@ -98,7 +98,7 @@ class SWIGBackend(KiCADBackend): from commands.project import ProjectCommands try: - result = ProjectCommands.open_project(str(path)) + result = ProjectCommands().open_project({"filename": str(path)}) return result except Exception as e: logger.error(f"Failed to open project: {e}") @@ -112,8 +112,10 @@ class SWIGBackend(KiCADBackend): from commands.project import ProjectCommands try: - path_str = str(path) if path else None - result = ProjectCommands.save_project(path_str) + params: Dict[str, Any] = {} + if path: + params["filename"] = str(path) + result = ProjectCommands().save_project(params) return result except Exception as e: logger.error(f"Failed to save project: {e}") @@ -137,7 +139,7 @@ class SWIGBackend(KiCADBackend): class SWIGBoardAPI(BoardAPI): """Board API implementation wrapping SWIG/pcbnew""" - def __init__(self, pcbnew_module): + def __init__(self, pcbnew_module: Any) -> None: self.pcbnew = pcbnew_module self._board = None @@ -146,13 +148,15 @@ class SWIGBoardAPI(BoardAPI): from commands.board import BoardCommands try: - result = BoardCommands.set_board_size(width, height, unit) + result = BoardCommands(board=self._board).set_board_size( + {"width": width, "height": height, "unit": unit} + ) return result.get("success", False) except Exception as e: logger.error(f"Failed to set board size: {e}") return False - def get_size(self) -> Dict[str, float]: + def get_size(self) -> Dict[str, Any]: """Get board size""" # TODO: Implement using existing SWIG code raise NotImplementedError("get_size not yet wrapped") @@ -173,7 +177,7 @@ class SWIGBoardAPI(BoardAPI): from commands.component import ComponentCommands try: - result = ComponentCommands.get_component_list() + result = ComponentCommands(board=self._board).get_component_list({}) if result.get("success"): return result.get("components", []) return [] @@ -189,17 +193,20 @@ class SWIGBoardAPI(BoardAPI): y: float, rotation: float = 0, layer: str = "F.Cu", + value: str = "", ) -> bool: """Place component using existing implementation""" from commands.component import ComponentCommands try: - result = ComponentCommands.place_component( - component_id=footprint, - position={"x": x, "y": y, "unit": "mm"}, - reference=reference, - rotation=rotation, - layer=layer, + result = ComponentCommands(board=self._board).place_component( + { + "componentId": footprint, + "position": {"x": x, "y": y, "unit": "mm"}, + "reference": reference, + "rotation": rotation, + "layer": layer, + } ) return result.get("success", False) except Exception as e: diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 68db19d..7fd00b7 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -12,6 +12,7 @@ import logging import os import sys import traceback +from pathlib import Path from typing import Any, Dict, Optional from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read @@ -241,7 +242,7 @@ except ImportError as e: class KiCADInterface: """Main interface class to handle KiCAD operations""" - def __init__(self): + def __init__(self) -> None: """Initialize the interface and command handlers""" self.board = None self.project_filename = None @@ -560,7 +561,7 @@ class KiCADInterface: "connect_passthrough", } - def _auto_save_board(self): + def _auto_save_board(self) -> None: """Save board to disk after SWIG mutations. Called automatically after every board-mutating SWIG command so that data is not lost if Claude hits the context limit before save_project. @@ -574,7 +575,7 @@ class KiCADInterface: except Exception as e: logger.warning(f"Auto-save failed: {e}") - def _update_command_handlers(self): + def _update_command_handlers(self) -> None: """Update board reference in all command handlers""" logger.debug("Updating board reference in command handlers") self.project_commands.board = self.board @@ -586,7 +587,7 @@ class KiCADInterface: self.freerouting_commands.board = self.board # Schematic command handlers - def _handle_create_schematic(self, params): + def _handle_create_schematic(self, params: Dict[str, Any]) -> Dict[str, Any]: """Create a new schematic""" logger.info("Creating schematic") try: @@ -623,7 +624,7 @@ class KiCADInterface: logger.error(f"Error creating schematic: {str(e)}") return {"success": False, "message": str(e)} - def _handle_load_schematic(self, params): + def _handle_load_schematic(self, params: Dict[str, Any]) -> Dict[str, Any]: """Load an existing schematic""" logger.info("Loading schematic") try: @@ -644,7 +645,7 @@ class KiCADInterface: logger.error(f"Error loading schematic: {str(e)}") return {"success": False, "message": str(e)} - def _handle_place_component(self, params): + def _handle_place_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """Place a component on the PCB, with project-local fp-lib-table support. If boardPath is given and differs from the currently loaded board, the board is reloaded from boardPath before placing — prevents silent failures @@ -679,7 +680,7 @@ class KiCADInterface: return self.component_commands.place_component(params) - def _handle_add_schematic_component(self, params): + def _handle_add_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add a component to a schematic using text-based injection (no sexpdata)""" logger.info("Adding component to schematic") try: @@ -732,7 +733,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_delete_schematic_component(self, params): + def _handle_delete_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """Remove a placed symbol from a schematic using text-based manipulation (no skip writes)""" logger.info("Deleting schematic component") try: @@ -757,7 +758,7 @@ class KiCADInterface: with open(sch_file, "r", encoding="utf-8") as f: content = f.read() - def find_matching_paren(s, start): + def find_matching_paren(s: str, start: int) -> int: """Find the closing paren matching the opening paren at start.""" depth = 0 i = start @@ -838,7 +839,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_edit_schematic_component(self, params): + def _handle_edit_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """Update properties of a placed symbol in a schematic (footprint, value, reference). Uses text-based in-place editing – preserves position, UUID and all other fields. """ @@ -883,7 +884,7 @@ class KiCADInterface: with open(sch_file, "r", encoding="utf-8") as f: content = f.read() - def find_matching_paren(s, start): + def find_matching_paren(s: str, start: int) -> int: """Find the position of the closing paren matching the opening paren at start.""" depth = 0 i = start @@ -928,7 +929,7 @@ class KiCADInterface: break search_start = end + 1 - if block_start is None: + if block_start is None or block_end is None: return { "success": False, "message": f"Component '{reference}' not found in schematic", @@ -991,7 +992,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_get_schematic_component(self, params): + def _handle_get_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """Return full component info: position and all field values with their (at x y angle) positions.""" logger.info("Getting schematic component info") try: @@ -1016,7 +1017,7 @@ class KiCADInterface: with open(sch_file, "r", encoding="utf-8") as f: content = f.read() - def find_matching_paren(s, start): + def find_matching_paren(s: str, start: int) -> int: depth = 0 i = start while i < len(s): @@ -1058,7 +1059,7 @@ class KiCADInterface: break search_start = end + 1 - if block_start is None: + if block_start is None or block_end is None: return { "success": False, "message": f"Component '{reference}' not found in schematic", @@ -1114,7 +1115,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_add_schematic_wire(self, params): + def _handle_add_schematic_wire(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add a wire to a schematic using WireManager, with optional pin snapping""" logger.info("Adding wire to schematic") try: @@ -1164,7 +1165,7 @@ class KiCADInterface: for pin_num, coords in pin_locs.items(): all_pins.append((ref, pin_num, coords)) - def find_nearest_pin(point, tolerance): + def find_nearest_pin(point: Any, tolerance: Any) -> Any: """Find the nearest pin within tolerance of a point.""" best = None best_dist = tolerance @@ -1238,7 +1239,7 @@ class KiCADInterface: "errorDetails": traceback.format_exc(), } - def _handle_add_schematic_junction(self, params): + def _handle_add_schematic_junction(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add a junction (connection dot) to a schematic using WireManager""" logger.info("Adding junction to schematic") try: @@ -1271,19 +1272,19 @@ class KiCADInterface: "errorDetails": traceback.format_exc(), } - def _handle_list_schematic_libraries(self, params): + def _handle_list_schematic_libraries(self, params: Dict[str, Any]) -> Dict[str, Any]: """List available symbol libraries""" logger.info("Listing schematic libraries") try: search_paths = params.get("searchPaths") - libraries = LibraryManager.list_available_libraries(search_paths) + libraries = SchematicLibraryManager.list_available_libraries(search_paths) return {"success": True, "libraries": libraries} except Exception as e: logger.error(f"Error listing schematic libraries: {str(e)}") return {"success": False, "message": str(e)} - def _handle_find_unconnected_pins(self, params): + def _handle_find_unconnected_pins(self, params: Dict[str, Any]) -> Dict[str, Any]: """List component pins with no wire/label/power symbol touching them""" logger.info("Finding unconnected pins") try: @@ -1303,7 +1304,7 @@ class KiCADInterface: logger.error(f"Error finding unconnected pins: {e}") return {"success": False, "message": str(e)} - def _handle_check_wire_collisions(self, params): + def _handle_check_wire_collisions(self, params: Dict[str, Any]) -> Dict[str, Any]: """Detect wires passing through component bodies without connecting to pins""" logger.info("Checking wire collisions") try: @@ -1327,7 +1328,7 @@ class KiCADInterface: # Footprint handlers # # ------------------------------------------------------------------ # - def _handle_create_footprint(self, params): + def _handle_create_footprint(self, params: Dict[str, Any]) -> Dict[str, Any]: """Create a new .kicad_mod footprint file in a .pretty library.""" logger.info(f"create_footprint: {params.get('name')} in {params.get('libraryPath')}") try: @@ -1349,7 +1350,7 @@ class KiCADInterface: logger.error(f"create_footprint error: {e}") return {"success": False, "error": str(e)} - def _handle_edit_footprint_pad(self, params): + def _handle_edit_footprint_pad(self, params: Dict[str, Any]) -> Dict[str, Any]: """Edit an existing pad in a .kicad_mod file.""" logger.info( f"edit_footprint_pad: pad {params.get('padNumber')} in {params.get('footprintPath')}" @@ -1368,7 +1369,7 @@ class KiCADInterface: logger.error(f"edit_footprint_pad error: {e}") return {"success": False, "error": str(e)} - def _handle_list_footprint_libraries(self, params): + def _handle_list_footprint_libraries(self, params: Dict[str, Any]) -> Dict[str, Any]: """List .pretty footprint libraries and their contents.""" logger.info("list_footprint_libraries") try: @@ -1378,7 +1379,7 @@ class KiCADInterface: logger.error(f"list_footprint_libraries error: {e}") return {"success": False, "error": str(e)} - def _handle_register_footprint_library(self, params): + def _handle_register_footprint_library(self, params: Dict[str, Any]) -> Dict[str, Any]: """Register a .pretty library in KiCAD's fp-lib-table.""" logger.info(f"register_footprint_library: {params.get('libraryPath')}") try: @@ -1398,7 +1399,7 @@ class KiCADInterface: # Symbol creator handlers # # ------------------------------------------------------------------ # - def _handle_create_symbol(self, params): + def _handle_create_symbol(self, params: Dict[str, Any]) -> Dict[str, Any]: """Create a new symbol in a .kicad_sym library.""" logger.info(f"create_symbol: {params.get('name')} in {params.get('libraryPath')}") try: @@ -1422,7 +1423,7 @@ class KiCADInterface: logger.error(f"create_symbol error: {e}") return {"success": False, "error": str(e)} - def _handle_delete_symbol(self, params): + def _handle_delete_symbol(self, params: Dict[str, Any]) -> Dict[str, Any]: """Delete a symbol from a .kicad_sym library.""" logger.info(f"delete_symbol: {params.get('name')} from {params.get('libraryPath')}") try: @@ -1435,7 +1436,7 @@ class KiCADInterface: logger.error(f"delete_symbol error: {e}") return {"success": False, "error": str(e)} - def _handle_list_symbols_in_library(self, params): + def _handle_list_symbols_in_library(self, params: Dict[str, Any]) -> Dict[str, Any]: """List all symbols in a .kicad_sym file.""" logger.info(f"list_symbols_in_library: {params.get('libraryPath')}") try: @@ -1447,7 +1448,7 @@ class KiCADInterface: logger.error(f"list_symbols_in_library error: {e}") return {"success": False, "error": str(e)} - def _handle_register_symbol_library(self, params): + def _handle_register_symbol_library(self, params: Dict[str, Any]) -> Dict[str, Any]: """Register a .kicad_sym library in KiCAD's sym-lib-table.""" logger.info(f"register_symbol_library: {params.get('libraryPath')}") try: @@ -1463,7 +1464,7 @@ class KiCADInterface: logger.error(f"register_symbol_library error: {e}") return {"success": False, "error": str(e)} - def _handle_export_schematic_pdf(self, params): + def _handle_export_schematic_pdf(self, params: Dict[str, Any]) -> Dict[str, Any]: """Export schematic to PDF""" logger.info("Exporting schematic to PDF") try: @@ -1512,7 +1513,7 @@ class KiCADInterface: logger.error(f"Error exporting schematic to PDF: {str(e)}") return {"success": False, "message": str(e)} - def _handle_add_schematic_net_label(self, params): + def _handle_add_schematic_net_label(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add a net label to schematic using WireManager""" logger.info("Adding net label to schematic") try: @@ -1558,7 +1559,7 @@ class KiCADInterface: "errorDetails": traceback.format_exc(), } - def _handle_connect_to_net(self, params): + def _handle_connect_to_net(self, params: Dict[str, Any]) -> Dict[str, Any]: """Connect a component pin to a named net using wire stub and label""" logger.info("Connecting component pin to net") try: @@ -1595,7 +1596,7 @@ class KiCADInterface: "errorDetails": traceback.format_exc(), } - def _handle_connect_passthrough(self, params): + def _handle_connect_passthrough(self, params: Dict[str, Any]) -> Dict[str, Any]: """Connect all pins of source connector to matching pins of target connector""" logger.info("Connecting passthrough between two connectors") try: @@ -1632,7 +1633,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_get_schematic_pin_locations(self, params): + def _handle_get_schematic_pin_locations(self, params: Dict[str, Any]) -> Dict[str, Any]: """Return exact pin endpoint coordinates for a schematic component""" logger.info("Getting schematic pin locations") try: @@ -1687,7 +1688,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_get_schematic_view(self, params): + def _handle_get_schematic_view(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get a rasterised image of the schematic (SVG export → optional PNG conversion)""" logger.info("Getting schematic view") import base64 @@ -1776,7 +1777,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_list_schematic_components(self, params): + def _handle_list_schematic_components(self, params: Dict[str, Any]) -> Dict[str, Any]: """List all components in a schematic""" logger.info("Listing schematic components") try: @@ -1869,7 +1870,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_list_schematic_nets(self, params): + def _handle_list_schematic_nets(self, params: Dict[str, Any]) -> Dict[str, Any]: """List all nets in a schematic with their connections""" logger.info("Listing schematic nets") try: @@ -1915,7 +1916,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_list_schematic_wires(self, params): + def _handle_list_schematic_wires(self, params: Dict[str, Any]) -> Dict[str, Any]: """List all wires in a schematic""" logger.info("Listing schematic wires") try: @@ -1958,7 +1959,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_list_schematic_labels(self, params): + def _handle_list_schematic_labels(self, params: Dict[str, Any]) -> Dict[str, Any]: """List all net labels and power flags in a schematic""" logger.info("Listing schematic labels") try: @@ -2037,7 +2038,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_move_schematic_component(self, params): + def _handle_move_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """Move a schematic component to a new position, dragging connected wires.""" logger.info("Moving schematic component") try: @@ -2121,7 +2122,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_rotate_schematic_component(self, params): + def _handle_rotate_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """Rotate a schematic component""" logger.info("Rotating schematic component") try: @@ -2171,7 +2172,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_annotate_schematic(self, params): + def _handle_annotate_schematic(self, params: Dict[str, Any]) -> Dict[str, Any]: """Annotate unannotated components in schematic (R? -> R1, R2, ...)""" logger.info("Annotating schematic") try: @@ -2249,7 +2250,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_delete_schematic_wire(self, params): + def _handle_delete_schematic_wire(self, params: Dict[str, Any]) -> Dict[str, Any]: """Delete a wire from the schematic matching start/end points""" logger.info("Deleting schematic wire") try: @@ -2280,7 +2281,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_delete_schematic_net_label(self, params): + def _handle_delete_schematic_net_label(self, params: Dict[str, Any]) -> Dict[str, Any]: """Delete a net label from the schematic""" logger.info("Deleting schematic net label") try: @@ -2315,7 +2316,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_export_schematic_svg(self, params): + def _handle_export_schematic_svg(self, params: Dict[str, Any]) -> Dict[str, Any]: """Export schematic to SVG using kicad-cli""" logger.info("Exporting schematic SVG") import glob @@ -2389,7 +2390,7 @@ class KiCADInterface: logger.error(f"Error exporting schematic SVG: {e}") return {"success": False, "message": str(e)} - def _handle_get_net_connections(self, params): + def _handle_get_net_connections(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get all connections for a named net""" logger.info("Getting net connections") try: @@ -2409,7 +2410,7 @@ class KiCADInterface: logger.error(f"Error getting net connections: {str(e)}") return {"success": False, "message": str(e)} - def _handle_get_wire_connections(self, params): + def _handle_get_wire_connections(self, params: Dict[str, Any]) -> Dict[str, Any]: """Find all component pins reachable from a point via connected wires""" logger.info("Getting wire connections") try: @@ -2456,7 +2457,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_run_erc(self, params): + def _handle_run_erc(self, params: Dict[str, Any]) -> Dict[str, Any]: """Run Electrical Rules Check on a schematic via kicad-cli""" logger.info("Running ERC on schematic") import os @@ -2552,7 +2553,7 @@ class KiCADInterface: logger.error(f"Error running ERC: {str(e)}") return {"success": False, "message": str(e)} - def _handle_generate_netlist(self, params): + def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]: """Generate netlist from schematic""" logger.info("Generating netlist from schematic") try: @@ -2571,7 +2572,7 @@ class KiCADInterface: logger.error(f"Error generating netlist: {str(e)}") return {"success": False, "message": str(e)} - def _handle_sync_schematic_to_board(self, params): + def _handle_sync_schematic_to_board(self, params: Dict[str, Any]) -> Dict[str, Any]: """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. """ @@ -2694,7 +2695,7 @@ class KiCADInterface: # Schematic analysis tools (read-only) # =================================================================== - def _handle_get_schematic_view_region(self, params): + def _handle_get_schematic_view_region(self, params: Dict[str, Any]) -> Dict[str, Any]: """Export a cropped region of the schematic as an image""" logger.info("Exporting schematic view region") import base64 @@ -2809,7 +2810,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_find_overlapping_elements(self, params): + def _handle_find_overlapping_elements(self, params: Dict[str, Any]) -> Dict[str, Any]: """Detect spatially overlapping symbols, wires, and labels""" logger.info("Finding overlapping elements in schematic") try: @@ -2835,7 +2836,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_get_elements_in_region(self, params): + def _handle_get_elements_in_region(self, params: Dict[str, Any]) -> Dict[str, Any]: """List all wires, labels, and symbols within a rectangular region""" logger.info("Getting elements in schematic region") try: @@ -2865,7 +2866,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_find_wires_crossing_symbols(self, params): + def _handle_find_wires_crossing_symbols(self, params: Dict[str, Any]) -> Dict[str, Any]: """Find wires that cross over component symbol bodies""" logger.info("Finding wires crossing symbols in schematic") try: @@ -2891,7 +2892,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_import_svg_logo(self, params): + def _handle_import_svg_logo(self, params: Dict[str, Any]) -> Dict[str, Any]: """Import an SVG file as PCB graphic polygons on the silkscreen""" logger.info("Importing SVG logo into PCB") try: @@ -2938,7 +2939,7 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} - def _handle_snapshot_project(self, params): + def _handle_snapshot_project(self, params: Dict[str, Any]) -> Dict[str, Any]: """Copy the entire project folder to a snapshot directory for checkpoint/resume.""" import shutil from datetime import datetime @@ -3022,7 +3023,7 @@ class KiCADInterface: logger.error(f"snapshot_project error: {e}") return {"success": False, "message": str(e)} - def _handle_check_kicad_ui(self, params): + def _handle_check_kicad_ui(self, params: Dict[str, Any]) -> Dict[str, Any]: """Check if KiCAD UI is running""" logger.info("Checking if KiCAD UI is running") try: @@ -3040,7 +3041,7 @@ class KiCADInterface: logger.error(f"Error checking KiCAD UI status: {str(e)}") return {"success": False, "message": str(e)} - def _handle_launch_kicad_ui(self, params): + def _handle_launch_kicad_ui(self, params: Dict[str, Any]) -> Dict[str, Any]: """Launch KiCAD UI""" logger.info("Launching KiCAD UI") try: @@ -3059,7 +3060,7 @@ class KiCADInterface: logger.error(f"Error launching KiCAD UI: {str(e)}") return {"success": False, "message": str(e)} - def _handle_refill_zones(self, params): + def _handle_refill_zones(self, params: Dict[str, Any]) -> Dict[str, Any]: """Refill all copper pour zones on the board. pcbnew.ZONE_FILLER.Fill() can cause a C++ access violation (0xC0000005) @@ -3146,7 +3147,7 @@ print("ok") # These methods are called automatically when IPC is available # ========================================================================= - def _ipc_route_trace(self, params): + def _ipc_route_trace(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for route_trace - adds track with real-time UI update""" try: # Extract parameters matching the existing route_trace interface @@ -3189,7 +3190,7 @@ print("ok") logger.error(f"IPC route_trace error: {e}") return {"success": False, "message": str(e)} - def _ipc_add_via(self, params): + def _ipc_add_via(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for add_via - adds via with real-time UI update""" try: position = params.get("position", {}) @@ -3222,7 +3223,7 @@ print("ok") logger.error(f"IPC add_via error: {e}") return {"success": False, "message": str(e)} - def _ipc_add_net(self, params): + def _ipc_add_net(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for add_net""" # Note: Net creation via IPC is limited - nets are typically created # when components are placed. Return success for compatibility. @@ -3234,7 +3235,7 @@ print("ok") "net": {"name": name}, } - def _ipc_add_copper_pour(self, params): + def _ipc_add_copper_pour(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for add_copper_pour - adds zone with real-time UI update""" try: layer = params.get("layer", "F.Cu") @@ -3289,7 +3290,7 @@ print("ok") logger.error(f"IPC add_copper_pour error: {e}") return {"success": False, "message": str(e)} - def _ipc_refill_zones(self, params): + def _ipc_refill_zones(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for refill_zones - refills all zones with real-time UI update""" try: success = self.ipc_board_api.refill_zones() @@ -3304,7 +3305,7 @@ print("ok") logger.error(f"IPC refill_zones error: {e}") return {"success": False, "message": str(e)} - def _ipc_add_text(self, params): + def _ipc_add_text(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for add_text/add_board_text - adds text with real-time UI update""" try: text = params.get("text", "") @@ -3331,7 +3332,7 @@ print("ok") logger.error(f"IPC add_text error: {e}") return {"success": False, "message": str(e)} - def _ipc_set_board_size(self, params): + def _ipc_set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for set_board_size""" try: width = params.get("width", 100) @@ -3353,7 +3354,7 @@ print("ok") logger.error(f"IPC set_board_size error: {e}") return {"success": False, "message": str(e)} - def _ipc_get_board_info(self, params): + def _ipc_get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for get_board_info""" try: size = self.ipc_board_api.get_size() @@ -3378,7 +3379,7 @@ print("ok") logger.error(f"IPC get_board_info error: {e}") return {"success": False, "message": str(e)} - def _ipc_place_component(self, params): + def _ipc_place_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for place_component - places component with real-time UI update""" try: reference = params.get("reference", params.get("componentId", "")) @@ -3419,7 +3420,7 @@ print("ok") logger.error(f"IPC place_component error: {e}") return {"success": False, "message": str(e)} - def _ipc_move_component(self, params): + def _ipc_move_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for move_component - moves component with real-time UI update""" try: reference = params.get("reference", params.get("componentId", "")) @@ -3444,7 +3445,7 @@ print("ok") logger.error(f"IPC move_component error: {e}") return {"success": False, "message": str(e)} - def _ipc_delete_component(self, params): + def _ipc_delete_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for delete_component - deletes component with real-time UI update""" try: reference = params.get("reference", params.get("componentId", "")) @@ -3463,7 +3464,7 @@ print("ok") logger.error(f"IPC delete_component error: {e}") return {"success": False, "message": str(e)} - def _ipc_get_component_list(self, params): + def _ipc_get_component_list(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for get_component_list""" try: components = self.ipc_board_api.list_components() @@ -3473,7 +3474,7 @@ print("ok") logger.error(f"IPC get_component_list error: {e}") return {"success": False, "message": str(e)} - def _ipc_save_project(self, params): + def _ipc_save_project(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for save_project""" try: success = self.ipc_board_api.save() @@ -3486,14 +3487,14 @@ print("ok") logger.error(f"IPC save_project error: {e}") return {"success": False, "message": str(e)} - def _ipc_delete_trace(self, params): + def _ipc_delete_trace(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for delete_trace - Note: IPC doesn't support direct trace deletion yet""" # IPC API doesn't have a direct delete track method # Fall back to SWIG for this operation logger.info("delete_trace: Falling back to SWIG (IPC doesn't support trace deletion)") return self.routing_commands.delete_trace(params) - def _ipc_get_nets_list(self, params): + def _ipc_get_nets_list(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for get_nets_list - gets nets with real-time data""" try: nets = self.ipc_board_api.get_nets() @@ -3503,7 +3504,7 @@ print("ok") logger.error(f"IPC get_nets_list error: {e}") return {"success": False, "message": str(e)} - def _ipc_add_board_outline(self, params): + def _ipc_add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for add_board_outline - adds board edge with real-time UI update. Rounded rectangles are delegated to the SWIG path because the IPC BoardSegment type cannot represent arcs; the SWIG path writes directly to the .kicad_pcb file @@ -3566,7 +3567,7 @@ print("ok") logger.error(f"IPC add_board_outline error: {e}") return {"success": False, "message": str(e)} - def _ipc_add_mounting_hole(self, params): + def _ipc_add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for add_mounting_hole - adds mounting hole with real-time UI update""" try: from kipy.board_types import BoardCircle @@ -3601,7 +3602,7 @@ print("ok") logger.error(f"IPC add_mounting_hole error: {e}") return {"success": False, "message": str(e)} - def _ipc_get_layer_list(self, params): + def _ipc_get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for get_layer_list - gets enabled layers""" try: layers = self.ipc_board_api.get_enabled_layers() @@ -3611,7 +3612,7 @@ print("ok") logger.error(f"IPC get_layer_list error: {e}") return {"success": False, "message": str(e)} - def _ipc_rotate_component(self, params): + def _ipc_rotate_component(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for rotate_component - rotates component with real-time UI update""" try: reference = params.get("reference", params.get("componentId", "")) @@ -3653,7 +3654,7 @@ print("ok") logger.error(f"IPC rotate_component error: {e}") return {"success": False, "message": str(e)} - def _ipc_get_component_properties(self, params): + def _ipc_get_component_properties(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for get_component_properties - gets detailed component info""" try: reference = params.get("reference", params.get("componentId", "")) @@ -3677,7 +3678,7 @@ print("ok") # Legacy IPC command handlers (explicit ipc_* commands) # ========================================================================= - def _handle_get_backend_info(self, params): + def _handle_get_backend_info(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get information about the current backend""" return { "success": True, @@ -3692,7 +3693,7 @@ print("ok") ), } - def _handle_ipc_add_track(self, params): + def _handle_ipc_add_track(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add a track using IPC backend (real-time)""" if not self.use_ipc or not self.ipc_board_api: return {"success": False, "message": "IPC backend not available"} @@ -3718,7 +3719,7 @@ print("ok") logger.error(f"Error adding track via IPC: {e}") return {"success": False, "message": str(e)} - def _handle_ipc_add_via(self, params): + def _handle_ipc_add_via(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add a via using IPC backend (real-time)""" if not self.use_ipc or not self.ipc_board_api: return {"success": False, "message": "IPC backend not available"} @@ -3741,7 +3742,7 @@ print("ok") logger.error(f"Error adding via via IPC: {e}") return {"success": False, "message": str(e)} - def _handle_ipc_add_text(self, params): + def _handle_ipc_add_text(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add text using IPC backend (real-time)""" if not self.use_ipc or not self.ipc_board_api: return {"success": False, "message": "IPC backend not available"} @@ -3766,7 +3767,7 @@ print("ok") logger.error(f"Error adding text via IPC: {e}") return {"success": False, "message": str(e)} - def _handle_ipc_list_components(self, params): + def _handle_ipc_list_components(self, params: Dict[str, Any]) -> Dict[str, Any]: """List components using IPC backend""" if not self.use_ipc or not self.ipc_board_api: return {"success": False, "message": "IPC backend not available"} @@ -3778,7 +3779,7 @@ print("ok") logger.error(f"Error listing components via IPC: {e}") return {"success": False, "message": str(e)} - def _handle_ipc_get_tracks(self, params): + def _handle_ipc_get_tracks(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get tracks using IPC backend""" if not self.use_ipc or not self.ipc_board_api: return {"success": False, "message": "IPC backend not available"} @@ -3790,7 +3791,7 @@ print("ok") logger.error(f"Error getting tracks via IPC: {e}") return {"success": False, "message": str(e)} - def _handle_ipc_get_vias(self, params): + def _handle_ipc_get_vias(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get vias using IPC backend""" if not self.use_ipc or not self.ipc_board_api: return {"success": False, "message": "IPC backend not available"} @@ -3802,7 +3803,7 @@ print("ok") logger.error(f"Error getting vias via IPC: {e}") return {"success": False, "message": str(e)} - def _handle_ipc_save_board(self, params): + def _handle_ipc_save_board(self, params: Dict[str, Any]) -> Dict[str, Any]: """Save board using IPC backend""" if not self.use_ipc or not self.ipc_board_api: return {"success": False, "message": "IPC backend not available"} @@ -3819,7 +3820,7 @@ print("ok") # JLCPCB API handlers - def _handle_download_jlcpcb_database(self, params): + def _handle_download_jlcpcb_database(self, params: Dict[str, Any]) -> Dict[str, Any]: """Download JLCPCB parts database from JLCSearch API""" try: force = params.get("force", False) @@ -3870,7 +3871,7 @@ print("ok") "message": f"Failed to download database: {str(e)}", } - def _handle_search_jlcpcb_parts(self, params): + def _handle_search_jlcpcb_parts(self, params: Dict[str, Any]) -> Dict[str, Any]: """Search JLCPCB parts database""" try: query = params.get("query") @@ -3909,7 +3910,7 @@ print("ok") logger.error(f"Error searching JLCPCB parts: {e}", exc_info=True) return {"success": False, "message": f"Search failed: {str(e)}"} - def _handle_get_jlcpcb_part(self, params): + def _handle_get_jlcpcb_part(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get detailed information for a specific JLCPCB part""" try: lcsc_number = params.get("lcsc_number") @@ -3929,7 +3930,7 @@ print("ok") logger.error(f"Error getting JLCPCB part: {e}", exc_info=True) return {"success": False, "message": f"Failed to get part info: {str(e)}"} - def _handle_get_jlcpcb_database_stats(self, params): + def _handle_get_jlcpcb_database_stats(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get statistics about JLCPCB database""" try: stats = self.jlcpcb_parts.get_database_stats() @@ -3939,7 +3940,7 @@ print("ok") logger.error(f"Error getting database stats: {e}", exc_info=True) return {"success": False, "message": f"Failed to get stats: {str(e)}"} - def _handle_suggest_jlcpcb_alternatives(self, params): + def _handle_suggest_jlcpcb_alternatives(self, params: Dict[str, Any]) -> Dict[str, Any]: """Suggest alternative JLCPCB parts""" try: lcsc_number = params.get("lcsc_number") @@ -3980,7 +3981,7 @@ print("ok") "message": f"Failed to suggest alternatives: {str(e)}", } - def _handle_enrich_datasheets(self, params): + def _handle_enrich_datasheets(self, params: Dict[str, Any]) -> Dict[str, Any]: """Enrich schematic Datasheet fields from LCSC numbers""" try: from pathlib import Path @@ -3998,7 +3999,7 @@ print("ok") "message": f"Failed to enrich datasheets: {str(e)}", } - def _handle_get_datasheet_url(self, params): + def _handle_get_datasheet_url(self, params: Dict[str, Any]) -> Dict[str, Any]: """Return LCSC datasheet and product URLs for a part number""" try: lcsc = params.get("lcsc", "") @@ -4024,7 +4025,7 @@ print("ok") } -def _write_response(response_fd, response): +def _write_response(response_fd: Any, response: Any) -> None: """Write a JSON response to the original stdout fd. All response output goes through this function so that stray C-level @@ -4035,7 +4036,7 @@ def _write_response(response_fd, response): os.write(response_fd, payload.encode("utf-8")) -def main(): +def main() -> None: """Main entry point""" # --- Redirect stdout so pcbnew C++ noise never reaches the TS host --- # Save the real stdout fd for our exclusive JSON response channel. diff --git a/python/resources/resource_definitions.py b/python/resources/resource_definitions.py index ba893db..5919b3e 100644 --- a/python/resources/resource_definitions.py +++ b/python/resources/resource_definitions.py @@ -72,7 +72,7 @@ RESOURCE_DEFINITIONS = [ # ============================================================================= -def handle_resource_read(uri: str, interface) -> Dict[str, Any]: +def handle_resource_read(uri: str, interface: Any) -> Dict[str, Any]: """ Handle reading a resource by URI @@ -116,7 +116,7 @@ def handle_resource_read(uri: str, interface) -> Dict[str, Any]: # ============================================================================= -def _get_project_info(interface) -> Dict[str, Any]: +def _get_project_info(interface: Any) -> Dict[str, Any]: """Get current project information""" result = interface.project_commands.get_project_info({}) @@ -142,7 +142,7 @@ def _get_project_info(interface) -> Dict[str, Any]: } -def _get_board_info(interface) -> Dict[str, Any]: +def _get_board_info(interface: Any) -> Dict[str, Any]: """Get board properties and metadata""" result = interface.board_commands.get_board_info({}) @@ -168,7 +168,7 @@ def _get_board_info(interface) -> Dict[str, Any]: } -def _get_components(interface) -> Dict[str, Any]: +def _get_components(interface: Any) -> Dict[str, Any]: """Get list of all components""" result = interface.component_commands.get_component_list({}) @@ -197,7 +197,7 @@ def _get_components(interface) -> Dict[str, Any]: } -def _get_nets(interface) -> Dict[str, Any]: +def _get_nets(interface: Any) -> Dict[str, Any]: """Get list of electrical nets""" result = interface.routing_commands.get_nets_list({}) @@ -224,7 +224,7 @@ def _get_nets(interface) -> Dict[str, Any]: } -def _get_layers(interface) -> Dict[str, Any]: +def _get_layers(interface: Any) -> Dict[str, Any]: """Get layer stack information""" result = interface.board_commands.get_layer_list({}) @@ -251,7 +251,7 @@ def _get_layers(interface) -> Dict[str, Any]: } -def _get_design_rules(interface) -> Dict[str, Any]: +def _get_design_rules(interface: Any) -> Dict[str, Any]: """Get design rule settings""" result = interface.design_rule_commands.get_design_rules({}) @@ -277,7 +277,7 @@ def _get_design_rules(interface) -> Dict[str, Any]: } -def _get_drc_report(interface) -> Dict[str, Any]: +def _get_drc_report(interface: Any) -> Dict[str, Any]: """Get DRC violations""" result = interface.design_rule_commands.get_drc_violations({}) @@ -313,7 +313,7 @@ def _get_drc_report(interface) -> Dict[str, Any]: } -def _get_board_preview(interface) -> Dict[str, Any]: +def _get_board_preview(interface: Any) -> Dict[str, Any]: """Get board preview as PNG image""" result = interface.board_commands.get_board_2d_view({"width": 800, "height": 600}) diff --git a/python/test_ipc_backend.py b/python/test_ipc_backend.py index a6baf16..2eb5c35 100644 --- a/python/test_ipc_backend.py +++ b/python/test_ipc_backend.py @@ -16,6 +16,7 @@ Usage: import os import sys +from typing import Any, Optional # Add parent directory to path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) @@ -29,7 +30,7 @@ logging.basicConfig( logger = logging.getLogger(__name__) -def test_connection(): +def test_connection() -> Optional[Any]: """Test basic IPC connection to KiCAD.""" print("\n" + "=" * 60) print("TEST 1: IPC Connection") @@ -62,7 +63,7 @@ def test_connection(): return None -def test_board_access(backend): +def test_board_access(backend: Any) -> Optional[Any]: """Test board access and component listing.""" print("\n" + "=" * 60) print("TEST 2: Board Access") @@ -93,7 +94,7 @@ def test_board_access(backend): return None -def test_board_info(board_api): +def test_board_info(board_api: Any) -> bool: """Test getting board information.""" print("\n" + "=" * 60) print("TEST 3: Board Information") @@ -134,7 +135,7 @@ def test_board_info(board_api): return False -def test_realtime_track(board_api, interactive=False): +def test_realtime_track(board_api: Any, interactive: bool = False) -> bool: """Test adding a track in real-time (appears immediately in KiCAD UI).""" print("\n" + "=" * 60) print("TEST 4: Real-time Track Addition") @@ -168,7 +169,7 @@ def test_realtime_track(board_api, interactive=False): return False -def test_realtime_via(board_api, interactive=False): +def test_realtime_via(board_api: Any, interactive: bool = False) -> bool: """Test adding a via in real-time (appears immediately in KiCAD UI).""" print("\n" + "=" * 60) print("TEST 5: Real-time Via Addition") @@ -200,7 +201,7 @@ def test_realtime_via(board_api, interactive=False): return False -def test_realtime_text(board_api, interactive=False): +def test_realtime_text(board_api: Any, interactive: bool = False) -> bool: """Test adding text in real-time.""" print("\n" + "=" * 60) print("TEST 6: Real-time Text Addition") @@ -229,7 +230,7 @@ def test_realtime_text(board_api, interactive=False): return False -def test_selection(board_api, interactive=False): +def test_selection(board_api: Any, interactive: bool = False) -> bool: """Test getting the current selection from KiCAD UI.""" print("\n" + "=" * 60) print("TEST 7: UI Selection") @@ -255,7 +256,7 @@ def test_selection(board_api, interactive=False): return False -def run_all_tests(interactive=False): +def run_all_tests(interactive: bool = False) -> bool: """Run all IPC backend tests.""" print("\n" + "=" * 60) print("KiCAD IPC Backend Test Suite") diff --git a/python/tests/test_delete_schematic_component.py b/python/tests/test_delete_schematic_component.py index cefeb55..7fa7265 100644 --- a/python/tests/test_delete_schematic_component.py +++ b/python/tests/test_delete_schematic_component.py @@ -1,207 +1,208 @@ -""" -Regression tests for delete_schematic_component. - -Key regression: the handler previously used a line-by-line regex that required -`(symbol` and `(lib_id` to appear on the *same* line. KiCAD's file writer puts -them on *separate* lines, so every real-world delete returned "not found". -""" - -import re -import sys -import tempfile -from pathlib import Path - -import pytest - -sys.path.insert(0, str(Path(__file__).parent.parent)) - -TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch" - -# Inline format (single line) – matches what tests previously used -PLACED_RESISTOR_INLINE = """\ - (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") - (property "Reference" "R1" (at 51.27 47.46 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "10k" (at 51.27 52.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 50 50 0) - (effects (font (size 1.27 1.27)) hide) - ) - ) -""" - -# Multi-line format – as KiCAD's own file writer produces it. -# (symbol and (lib_id are on separate lines, which broke the old regex. -PLACED_RESISTOR_MULTILINE = """\ -\t(symbol -\t\t(lib_id "Device:R") -\t\t(at 50 50 0) -\t\t(unit 1) -\t\t(in_bom yes) -\t\t(on_board yes) -\t\t(dnp no) -\t\t(uuid "bbbbbbbb-cccc-dddd-eeee-ffffffffffff") -\t\t(property "Reference" "R2" -\t\t\t(at 51.27 47.46 0) -\t\t\t(effects -\t\t\t\t(font -\t\t\t\t\t(size 1.27 1.27) -\t\t\t\t) -\t\t\t) -\t\t) -\t\t(property "Value" "4.7k" -\t\t\t(at 51.27 52.54 0) -\t\t\t(effects -\t\t\t\t(font -\t\t\t\t\t(size 1.27 1.27) -\t\t\t\t) -\t\t\t) -\t\t) -\t) -""" - -# Multi-line power symbol – the exact scenario that was reported as broken. -PLACED_POWER_SYMBOL_MULTILINE = """\ -\t(symbol -\t\t(lib_id "power:VCC") -\t\t(at 365.6 38.1 0) -\t\t(unit 1) -\t\t(in_bom yes) -\t\t(on_board yes) -\t\t(dnp no) -\t\t(uuid "cccccccc-dddd-eeee-ffff-000000000030") -\t\t(property "Reference" "#PWR030" -\t\t\t(at 365.6 41.91 0) -\t\t\t(effects -\t\t\t\t(font -\t\t\t\t\t(size 1.27 1.27) -\t\t\t\t) -\t\t\t\t(hide yes) -\t\t\t) -\t\t) -\t\t(property "Value" "VCC" -\t\t\t(at 365.6 35.56 0) -\t\t\t(effects -\t\t\t\t(font -\t\t\t\t\t(size 1.27 1.27) -\t\t\t\t) -\t\t\t) -\t\t) -\t) -""" - - -def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path: - dest = tmp_path / "test.kicad_sch" - src_content = TEMPLATE_SCH.read_text(encoding="utf-8") - if extra_block: - src_content = src_content.rstrip() - if src_content.endswith(")"): - src_content = src_content[:-1] + "\n" + extra_block + ")\n" - dest.write_text(src_content, encoding="utf-8") - return dest - - -# --------------------------------------------------------------------------- -# Unit tests – regression proof for the old regex vs the new approach -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestDeleteDetectionRegex: - """Verify that the new content-string pattern finds blocks in both formats.""" - - OLD_PATTERN = re.compile(r"^\s*\(symbol\s+\(lib_id\s+\"", re.MULTILINE) - NEW_PATTERN = re.compile(r'\(symbol\s+\(lib_id\s+"') - - def test_old_regex_fails_on_multiline_format(self): - """Regression: old line-by-line regex must NOT match the multi-line format.""" - # The old code used re.match on individual lines; simulate that here. - lines = PLACED_RESISTOR_MULTILINE.split("\n") - matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)] - assert matches == [], "Old regex should not match multi-line KiCAD format" - - def test_old_regex_matches_inline_format(self): - """Old regex did work on single-line (inline) format.""" - lines = PLACED_RESISTOR_INLINE.split("\n") - matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)] - assert len(matches) == 1 - - def test_new_pattern_matches_multiline_format(self): - """New content-string pattern must find blocks in multi-line format.""" - assert self.NEW_PATTERN.search(PLACED_RESISTOR_MULTILINE) is not None - - def test_new_pattern_matches_inline_format(self): - """New content-string pattern also works on inline format.""" - assert self.NEW_PATTERN.search(PLACED_RESISTOR_INLINE) is not None - - def test_new_pattern_matches_power_symbol_multiline(self): - """New pattern must find #PWR030 power symbol in multi-line format.""" - assert self.NEW_PATTERN.search(PLACED_POWER_SYMBOL_MULTILINE) is not None - - def test_reference_extraction_from_multiline_block(self): - """Reference property can be found inside a multi-line block.""" - ref_pattern = re.compile(r'\(property\s+"Reference"\s+"#PWR030"') - assert ref_pattern.search(PLACED_POWER_SYMBOL_MULTILINE) is not None - - -# --------------------------------------------------------------------------- -# Integration tests – real file I/O using the handler -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -class TestDeleteSchematicComponentIntegration: - def _get_handler(self): - from kicad_interface import KiCADInterface - - iface = KiCADInterface.__new__(KiCADInterface) - return iface._handle_delete_schematic_component - - def test_delete_inline_format_succeeds(self, tmp_path): - sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE) - result = self._get_handler()({"schematicPath": str(sch), "reference": "R1"}) - assert result["success"] is True - assert result["deleted_count"] == 1 - - def test_delete_multiline_format_succeeds(self, tmp_path): - """Regression: must succeed when KiCAD writes (symbol and (lib_id on separate lines.""" - sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_MULTILINE) - result = self._get_handler()({"schematicPath": str(sch), "reference": "R2"}) - assert result["success"] is True - assert result["deleted_count"] == 1 - - def test_delete_power_symbol_multiline_succeeds(self, tmp_path): - """Regression: #PWR030 multi-line power symbol must be deletable.""" - sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE) - result = self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"}) - assert result["success"] is True - assert result["deleted_count"] == 1 - - def test_component_absent_after_delete(self, tmp_path): - sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE) - self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"}) - remaining = sch.read_text(encoding="utf-8") - assert '"#PWR030"' not in remaining - - def test_unknown_reference_returns_failure(self, tmp_path): - sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE) - result = self._get_handler()({"schematicPath": str(sch), "reference": "U99"}) - assert result["success"] is False - assert "not found" in result["message"] - - def test_missing_schematic_path_returns_failure(self, tmp_path): - result = self._get_handler()({"reference": "R1"}) - assert result["success"] is False - - def test_missing_reference_returns_failure(self, tmp_path): - sch = _make_test_schematic(tmp_path) - result = self._get_handler()({"schematicPath": str(sch)}) - assert result["success"] is False +""" +Regression tests for delete_schematic_component. + +Key regression: the handler previously used a line-by-line regex that required +`(symbol` and `(lib_id` to appear on the *same* line. KiCAD's file writer puts +them on *separate* lines, so every real-world delete returned "not found". +""" + +import re +import sys +import tempfile +from pathlib import Path +from typing import Any + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch" + +# Inline format (single line) – matches what tests previously used +PLACED_RESISTOR_INLINE = """\ + (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") + (property "Reference" "R1" (at 51.27 47.46 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "10k" (at 51.27 52.54 0) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 50 50 0) + (effects (font (size 1.27 1.27)) hide) + ) + ) +""" + +# Multi-line format – as KiCAD's own file writer produces it. +# (symbol and (lib_id are on separate lines, which broke the old regex. +PLACED_RESISTOR_MULTILINE = """\ +\t(symbol +\t\t(lib_id "Device:R") +\t\t(at 50 50 0) +\t\t(unit 1) +\t\t(in_bom yes) +\t\t(on_board yes) +\t\t(dnp no) +\t\t(uuid "bbbbbbbb-cccc-dddd-eeee-ffffffffffff") +\t\t(property "Reference" "R2" +\t\t\t(at 51.27 47.46 0) +\t\t\t(effects +\t\t\t\t(font +\t\t\t\t\t(size 1.27 1.27) +\t\t\t\t) +\t\t\t) +\t\t) +\t\t(property "Value" "4.7k" +\t\t\t(at 51.27 52.54 0) +\t\t\t(effects +\t\t\t\t(font +\t\t\t\t\t(size 1.27 1.27) +\t\t\t\t) +\t\t\t) +\t\t) +\t) +""" + +# Multi-line power symbol – the exact scenario that was reported as broken. +PLACED_POWER_SYMBOL_MULTILINE = """\ +\t(symbol +\t\t(lib_id "power:VCC") +\t\t(at 365.6 38.1 0) +\t\t(unit 1) +\t\t(in_bom yes) +\t\t(on_board yes) +\t\t(dnp no) +\t\t(uuid "cccccccc-dddd-eeee-ffff-000000000030") +\t\t(property "Reference" "#PWR030" +\t\t\t(at 365.6 41.91 0) +\t\t\t(effects +\t\t\t\t(font +\t\t\t\t\t(size 1.27 1.27) +\t\t\t\t) +\t\t\t\t(hide yes) +\t\t\t) +\t\t) +\t\t(property "Value" "VCC" +\t\t\t(at 365.6 35.56 0) +\t\t\t(effects +\t\t\t\t(font +\t\t\t\t\t(size 1.27 1.27) +\t\t\t\t) +\t\t\t) +\t\t) +\t) +""" + + +def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path: + dest = tmp_path / "test.kicad_sch" + src_content = TEMPLATE_SCH.read_text(encoding="utf-8") + if extra_block: + src_content = src_content.rstrip() + if src_content.endswith(")"): + src_content = src_content[:-1] + "\n" + extra_block + ")\n" + dest.write_text(src_content, encoding="utf-8") + return dest + + +# --------------------------------------------------------------------------- +# Unit tests – regression proof for the old regex vs the new approach +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestDeleteDetectionRegex: + """Verify that the new content-string pattern finds blocks in both formats.""" + + OLD_PATTERN = re.compile(r"^\s*\(symbol\s+\(lib_id\s+\"", re.MULTILINE) + NEW_PATTERN = re.compile(r'\(symbol\s+\(lib_id\s+"') + + def test_old_regex_fails_on_multiline_format(self) -> None: + """Regression: old line-by-line regex must NOT match the multi-line format.""" + # The old code used re.match on individual lines; simulate that here. + lines = PLACED_RESISTOR_MULTILINE.split("\n") + matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)] + assert matches == [], "Old regex should not match multi-line KiCAD format" + + def test_old_regex_matches_inline_format(self) -> None: + """Old regex did work on single-line (inline) format.""" + lines = PLACED_RESISTOR_INLINE.split("\n") + matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)] + assert len(matches) == 1 + + def test_new_pattern_matches_multiline_format(self) -> None: + """New content-string pattern must find blocks in multi-line format.""" + assert self.NEW_PATTERN.search(PLACED_RESISTOR_MULTILINE) is not None + + def test_new_pattern_matches_inline_format(self) -> None: + """New content-string pattern also works on inline format.""" + assert self.NEW_PATTERN.search(PLACED_RESISTOR_INLINE) is not None + + def test_new_pattern_matches_power_symbol_multiline(self) -> None: + """New pattern must find #PWR030 power symbol in multi-line format.""" + assert self.NEW_PATTERN.search(PLACED_POWER_SYMBOL_MULTILINE) is not None + + def test_reference_extraction_from_multiline_block(self) -> None: + """Reference property can be found inside a multi-line block.""" + ref_pattern = re.compile(r'\(property\s+"Reference"\s+"#PWR030"') + assert ref_pattern.search(PLACED_POWER_SYMBOL_MULTILINE) is not None + + +# --------------------------------------------------------------------------- +# Integration tests – real file I/O using the handler +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestDeleteSchematicComponentIntegration: + def _get_handler(self) -> Any: + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + return iface._handle_delete_schematic_component + + def test_delete_inline_format_succeeds(self, tmp_path: Any) -> None: + sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE) + result = self._get_handler()({"schematicPath": str(sch), "reference": "R1"}) + assert result["success"] is True + assert result["deleted_count"] == 1 + + def test_delete_multiline_format_succeeds(self, tmp_path: Any) -> None: + """Regression: must succeed when KiCAD writes (symbol and (lib_id on separate lines.""" + sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_MULTILINE) + result = self._get_handler()({"schematicPath": str(sch), "reference": "R2"}) + assert result["success"] is True + assert result["deleted_count"] == 1 + + def test_delete_power_symbol_multiline_succeeds(self, tmp_path: Any) -> None: + """Regression: #PWR030 multi-line power symbol must be deletable.""" + sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE) + result = self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"}) + assert result["success"] is True + assert result["deleted_count"] == 1 + + def test_component_absent_after_delete(self, tmp_path: Any) -> None: + sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE) + self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"}) + remaining = sch.read_text(encoding="utf-8") + assert '"#PWR030"' not in remaining + + def test_unknown_reference_returns_failure(self, tmp_path: Any) -> None: + sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE) + result = self._get_handler()({"schematicPath": str(sch), "reference": "U99"}) + assert result["success"] is False + assert "not found" in result["message"] + + def test_missing_schematic_path_returns_failure(self, tmp_path: Any) -> None: + result = self._get_handler()({"reference": "R1"}) + assert result["success"] is False + + def test_missing_reference_returns_failure(self, tmp_path: Any) -> None: + sch = _make_test_schematic(tmp_path) + result = self._get_handler()({"schematicPath": str(sch)}) + assert result["success"] is False diff --git a/python/tests/test_freerouting.py b/python/tests/test_freerouting.py index c04f769..116d72c 100644 --- a/python/tests/test_freerouting.py +++ b/python/tests/test_freerouting.py @@ -12,6 +12,7 @@ Covers: import sys from pathlib import Path +from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -33,7 +34,7 @@ pcbnew_mock = sys.modules["pcbnew"] @pytest.fixture(autouse=True) -def reset_pcbnew_mock(): +def reset_pcbnew_mock() -> Any: """Reset pcbnew mock before each test.""" pcbnew_mock.reset_mock() pcbnew_mock.ExportSpecctraDSN.side_effect = None @@ -44,7 +45,7 @@ def reset_pcbnew_mock(): @pytest.fixture -def mock_board(): +def mock_board() -> Any: board = MagicMock() board.GetFileName.return_value = "/tmp/test_project/test.kicad_pcb" board.GetTracks.return_value = [] @@ -52,16 +53,16 @@ def mock_board(): @pytest.fixture -def cmds(mock_board): +def cmds(mock_board: Any) -> Any: return FreeroutingCommands(board=mock_board) @pytest.fixture -def cmds_no_board(): +def cmds_no_board() -> Any: return FreeroutingCommands(board=None) -def _patch_direct_java(): +def _patch_direct_java() -> Any: """Patch to simulate Java 21+ available locally.""" return patch.object( FreeroutingCommands, @@ -70,7 +71,7 @@ def _patch_direct_java(): ) -def _patch_docker_mode(): +def _patch_docker_mode() -> Any: """Patch to simulate Docker execution mode.""" return patch.object( FreeroutingCommands, @@ -79,7 +80,7 @@ def _patch_docker_mode(): ) -def _patch_no_runtime(): +def _patch_no_runtime() -> Any: """Patch to simulate no Java and no Docker.""" return patch.object( FreeroutingCommands, @@ -97,7 +98,7 @@ def _patch_no_runtime(): class TestCheckFreerouting: - def test_no_java_no_docker(self, cmds): + def test_no_java_no_docker(self, cmds: Any) -> None: with ( patch("commands.freerouting._find_java", return_value=None), patch( @@ -112,7 +113,7 @@ class TestCheckFreerouting: assert result["ready"] is False assert result["execution_mode"] == "none" - def test_java_too_old_docker_available(self, cmds, tmp_path): + def test_java_too_old_docker_available(self, cmds: Any, tmp_path: Any) -> None: jar = tmp_path / "freerouting.jar" jar.touch() with ( @@ -135,7 +136,7 @@ class TestCheckFreerouting: assert result["ready"] is True assert result["execution_mode"] == "docker" - def test_java_21_direct(self, cmds, tmp_path): + def test_java_21_direct(self, cmds: Any, tmp_path: Any) -> None: jar = tmp_path / "freerouting.jar" jar.touch() with ( @@ -165,12 +166,12 @@ class TestCheckFreerouting: class TestExportDsn: - def test_no_board(self, cmds_no_board): + def test_no_board(self, cmds_no_board: Any) -> None: result = cmds_no_board.export_dsn({}) assert result["success"] is False assert "No board" in result["message"] - def test_export_success(self, cmds, tmp_path): + def test_export_success(self, cmds: Any, tmp_path: Any) -> None: board_path = str(tmp_path / "test.kicad_pcb") dsn_path = str(tmp_path / "test.dsn") cmds.board.GetFileName.return_value = board_path @@ -182,7 +183,7 @@ class TestExportDsn: assert result["success"] is True assert result["path"] == dsn_path - def test_export_custom_path(self, cmds, tmp_path): + def test_export_custom_path(self, cmds: Any, tmp_path: Any) -> None: output = str(tmp_path / "custom.dsn") pcbnew_mock.ExportSpecctraDSN.return_value = True Path(output).write_text("(pcb test)") @@ -191,7 +192,7 @@ class TestExportDsn: assert result["success"] is True assert result["path"] == output - def test_export_failure(self, cmds): + def test_export_failure(self, cmds: Any) -> None: pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("DSN error") result = cmds.export_dsn({}) assert result["success"] is False @@ -204,22 +205,22 @@ class TestExportDsn: class TestImportSes: - def test_no_board(self, cmds_no_board): + def test_no_board(self, cmds_no_board: Any) -> None: result = cmds_no_board.import_ses({"sesPath": "/tmp/test.ses"}) assert result["success"] is False assert "No board" in result["message"] - def test_missing_ses_path(self, cmds): + def test_missing_ses_path(self, cmds: Any) -> None: result = cmds.import_ses({}) assert result["success"] is False assert "Missing sesPath" in result["message"] - def test_ses_file_not_found(self, cmds): + def test_ses_file_not_found(self, cmds: Any) -> None: result = cmds.import_ses({"sesPath": "/nonexistent/test.ses"}) assert result["success"] is False assert "not found" in result["message"] - def test_import_success(self, cmds, tmp_path): + def test_import_success(self, cmds: Any, tmp_path: Any) -> None: ses_file = tmp_path / "test.ses" ses_file.write_text("(session test)") @@ -229,7 +230,7 @@ class TestImportSes: result = cmds.import_ses({"sesPath": str(ses_file)}) assert result["success"] is True - def test_import_failure(self, cmds, tmp_path): + def test_import_failure(self, cmds: Any, tmp_path: Any) -> None: ses_file = tmp_path / "test.ses" ses_file.write_text("(session test)") @@ -245,12 +246,12 @@ class TestImportSes: class TestAutoroute: - def test_no_board(self, cmds_no_board): + def test_no_board(self, cmds_no_board: Any) -> None: result = cmds_no_board.autoroute({}) assert result["success"] is False assert "No board" in result["message"] - def test_no_runtime(self, cmds, tmp_path): + def test_no_runtime(self, cmds: Any, tmp_path: Any) -> None: jar = tmp_path / "freerouting.jar" jar.touch() with _patch_no_runtime(): @@ -258,13 +259,13 @@ class TestAutoroute: assert result["success"] is False assert "No suitable Java runtime" in result["message"] - def test_no_jar(self, cmds): + def test_no_jar(self, cmds: Any) -> None: result = cmds.autoroute({"freeroutingJar": "/nonexistent/freerouting.jar"}) assert result["success"] is False assert "JAR not found" in result["message"] @patch("commands.freerouting.subprocess.run") - def test_dsn_export_fails(self, mock_run, cmds, tmp_path): + def test_dsn_export_fails(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None: jar = tmp_path / "freerouting.jar" jar.touch() @@ -276,7 +277,7 @@ class TestAutoroute: assert "DSN export failed" in result["message"] @patch("commands.freerouting.subprocess.run") - def test_freerouting_timeout(self, mock_run, cmds, tmp_path): + def test_freerouting_timeout(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None: import subprocess jar = tmp_path / "freerouting.jar" @@ -300,7 +301,7 @@ class TestAutoroute: assert "timed out" in result["message"] @patch("commands.freerouting.subprocess.run") - def test_full_success_direct(self, mock_run, cmds, tmp_path): + def test_full_success_direct(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None: jar = tmp_path / "freerouting.jar" jar.touch() board_dir = tmp_path / "project" @@ -336,7 +337,7 @@ class TestAutoroute: assert "elapsed_seconds" in result @patch("commands.freerouting.subprocess.run") - def test_full_success_docker(self, mock_run, cmds, tmp_path): + def test_full_success_docker(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None: jar = tmp_path / "freerouting.jar" jar.touch() board_dir = tmp_path / "project" @@ -375,7 +376,7 @@ class TestAutoroute: assert "--rm" in call_args @patch("commands.freerouting.subprocess.run") - def test_freerouting_nonzero_exit(self, mock_run, cmds, tmp_path): + def test_freerouting_nonzero_exit(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None: jar = tmp_path / "freerouting.jar" jar.touch() board_dir = tmp_path / "project" @@ -404,14 +405,14 @@ class TestAutoroute: class TestFindJava: - def test_finds_via_which(self): + def test_finds_via_which(self) -> None: with patch( "commands.freerouting.shutil.which", return_value="/usr/bin/java", ): assert _find_java() == "/usr/bin/java" - def test_none_when_not_found(self): + def test_none_when_not_found(self) -> None: with ( patch( "commands.freerouting.shutil.which", @@ -423,21 +424,21 @@ class TestFindJava: class TestFindDocker: - def test_finds_docker(self): + def test_finds_docker(self) -> None: with patch( "commands.freerouting.shutil.which", side_effect=lambda x: "/usr/bin/docker" if x == "docker" else None, ): assert _find_docker() == "/usr/bin/docker" - def test_finds_podman(self): + def test_finds_podman(self) -> None: with patch( "commands.freerouting.shutil.which", side_effect=lambda x: "/usr/bin/podman" if x == "podman" else None, ): assert _find_docker() == "/usr/bin/podman" - def test_none_when_not_found(self): + def test_none_when_not_found(self) -> None: with patch( "commands.freerouting.shutil.which", return_value=None, @@ -446,7 +447,7 @@ class TestFindDocker: class TestDockerAvailable: - def test_docker_found(self): + def test_docker_found(self) -> None: with ( patch( "commands.freerouting._find_docker", @@ -457,14 +458,14 @@ class TestDockerAvailable: mock_run.return_value = MagicMock(returncode=0) assert _docker_available() is True - def test_docker_not_installed(self): + def test_docker_not_installed(self) -> None: with patch( "commands.freerouting._find_docker", return_value=None, ): assert _docker_available() is False - def test_docker_not_running(self): + def test_docker_not_running(self) -> None: with ( patch( "commands.freerouting._find_docker", @@ -477,17 +478,17 @@ class TestDockerAvailable: class TestJavaVersionOk: - def test_java_21(self): + def test_java_21(self) -> None: with patch("commands.freerouting.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stderr='openjdk version "21.0.1"', stdout="") assert _java_version_ok("/usr/bin/java") is True - def test_java_17(self): + def test_java_17(self) -> None: with patch("commands.freerouting.subprocess.run") as mock_run: mock_run.return_value = MagicMock(stderr='openjdk version "17.0.18"', stdout="") assert _java_version_ok("/usr/bin/java") is False - def test_java_error(self): + def test_java_error(self) -> None: with patch( "commands.freerouting.subprocess.run", side_effect=Exception("not found"), diff --git a/python/tests/test_move_with_wire_preservation.py b/python/tests/test_move_with_wire_preservation.py index fceefc8..0952d41 100644 --- a/python/tests/test_move_with_wire_preservation.py +++ b/python/tests/test_move_with_wire_preservation.py @@ -1,1009 +1,1012 @@ -""" -Tests for move_schematic_component with wire preservation (WireDragger). - -Unit tests use synthetic sexpdata lists — no disk I/O, no KiCAD install needed. -Integration tests copy empty.kicad_sch to a tempdir and exercise the full handler. -""" - -import shutil -import sys -import tempfile -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -import sexpdata -from sexpdata import Symbol - -# Make python/ importable -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from commands.wire_dragger import EPS, WireDragger, _coords_match, _rotate - -TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "empty.kicad_sch" - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _sym(name: str) -> Symbol: - return Symbol(name) - - -def _make_wire(x1, y1, x2, y2): - return [ - _sym("wire"), - [_sym("pts"), [_sym("xy"), x1, y1], [_sym("xy"), x2, y2]], - [_sym("stroke"), [_sym("width"), 0], [_sym("type"), _sym("default")]], - [_sym("uuid"), "00000000-0000-0000-0000-000000000000"], - ] - - -def _make_junction(x, y): - return [ - _sym("junction"), - [_sym("at"), x, y], - [_sym("diameter"), 0], - [_sym("color"), 0, 0, 0, 0], - [_sym("uuid"), "00000000-0000-0000-0000-000000000001"], - ] - - -def _make_symbol(ref, x, y, rotation=0, lib_id="Device:R", mirror=None): - """Build a minimal placed-symbol s-expression.""" - item = [ - _sym("symbol"), - [_sym("lib_id"), lib_id], - [_sym("at"), x, y, rotation], - [_sym("unit"), 1], - [_sym("property"), "Reference", ref, [_sym("at"), x + 2, y, 0]], - [_sym("property"), "Value", "10k", [_sym("at"), x, y, 0]], - ] - if mirror: - item.append([_sym("mirror"), _sym(mirror)]) - return item - - -def _make_lib_symbol_r(): - """Minimal Device:R lib_symbols entry — pins at (0, 3.81) and (0, -3.81).""" - return [ - _sym("symbol"), - "Device:R", - [ - _sym("symbol"), - "R_1_1", - [ - _sym("pin"), - _sym("passive"), - _sym("line"), - [_sym("at"), 0, 3.81, 270], - [_sym("length"), 1.27], - [ - _sym("name"), - "~", - [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]], - ], - [ - _sym("number"), - "1", - [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]], - ], - ], - [ - _sym("pin"), - _sym("passive"), - _sym("line"), - [_sym("at"), 0, -3.81, 90], - [_sym("length"), 1.27], - [ - _sym("name"), - "~", - [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]], - ], - [ - _sym("number"), - "2", - [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]], - ], - ], - ], - ] - - -def _make_sch_data(extra_items=None): - """Build a minimal sch_data list with lib_symbols and sheet_instances.""" - data = [ - _sym("kicad_sch"), - [_sym("lib_symbols"), _make_lib_symbol_r()], - [_sym("sheet_instances"), [_sym("path"), "/", [_sym("page"), "1"]]], - ] - if extra_items: - # Insert before sheet_instances (last item) - for item in extra_items: - data.insert(len(data) - 1, item) - return data - - -# --------------------------------------------------------------------------- -# TestRotatePoint -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestRotatePoint: - def test_zero_rotation(self): - assert _rotate(1.0, 2.0, 0) == (1.0, 2.0) - - def test_90_degrees(self): - rx, ry = _rotate(1.0, 0.0, 90) - assert abs(rx - 0.0) < 1e-9 - assert abs(ry - 1.0) < 1e-9 - - def test_180_degrees(self): - rx, ry = _rotate(1.0, 0.0, 180) - assert abs(rx - (-1.0)) < 1e-9 - assert abs(ry - 0.0) < 1e-9 - - def test_270_degrees(self): - rx, ry = _rotate(0.0, 1.0, 270) - assert abs(rx - 1.0) < 1e-6 - assert abs(ry - 0.0) < 1e-6 - - -# --------------------------------------------------------------------------- -# TestFindSymbol -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestFindSymbol: - def test_returns_none_for_missing_reference(self): - sch = _make_sch_data([_make_symbol("R1", 10, 20)]) - assert WireDragger.find_symbol(sch, "R99") is None - - def test_returns_item_and_position(self): - sch = _make_sch_data([_make_symbol("R1", 10.5, 20.5, rotation=90)]) - result = WireDragger.find_symbol(sch, "R1") - assert result is not None - _, old_x, old_y, rotation, lib_id, mirror_x, mirror_y = result - assert abs(old_x - 10.5) < EPS - assert abs(old_y - 20.5) < EPS - assert abs(rotation - 90) < EPS - assert lib_id == "Device:R" - assert mirror_x is False - assert mirror_y is False - - def test_detects_mirror_x(self): - sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="x")]) - result = WireDragger.find_symbol(sch, "R1") - assert result is not None - assert result[5] is True # mirror_x - assert result[6] is False # mirror_y - - def test_detects_mirror_y(self): - sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="y")]) - result = WireDragger.find_symbol(sch, "R1") - assert result is not None - assert result[5] is False # mirror_x - assert result[6] is True # mirror_y - - -# --------------------------------------------------------------------------- -# TestComputePinPositions -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestComputePinPositions: - def test_resistor_at_origin_no_rotation(self): - """Device:R at (0, 0) rot=0 — pins at (0, 3.81) and (0, -3.81).""" - sch = _make_sch_data([_make_symbol("R1", 0, 0)]) - positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20) - assert "1" in positions and "2" in positions - old1, new1 = positions["1"] - old2, new2 = positions["2"] - # Pin 1 old: (0 + 0, 0 + 3.81) - assert abs(old1[0] - 0) < 1e-4 - assert abs(old1[1] - 3.81) < 1e-4 - # Pin 2 old: (0 + 0, 0 - 3.81) - assert abs(old2[0] - 0) < 1e-4 - assert abs(old2[1] - (-3.81)) < 1e-4 - # New positions shifted by (10, 20) - assert abs(new1[0] - 10) < 1e-4 - assert abs(new1[1] - 23.81) < 1e-4 - assert abs(new2[0] - 10) < 1e-4 - assert abs(new2[1] - 16.19) < 1e-4 - - def test_resistor_rotated_90(self): - """Device:R at (100, 100) rot=90 — pins should be at (100+3.81, 100) and (100-3.81, 100).""" - sch = _make_sch_data([_make_symbol("R1", 100, 100, rotation=90)]) - positions = WireDragger.compute_pin_positions(sch, "R1", 100, 100) - old1, _ = positions["1"] - old2, _ = positions["2"] - # rotate(0, 3.81, 90) = (0*cos90 - 3.81*sin90, 0*sin90 + 3.81*cos90) = (-3.81, 0) - # Wait — pin 1 is at local (0, 3.81), rotated 90° CCW: - # x' = 0*cos90 - 3.81*sin90 = -3.81, y' = 0*sin90 + 3.81*cos90 ≈ 0 - # world: (100 - 3.81, 100 + 0) = (96.19, 100) - assert abs(old1[0] - 96.19) < 1e-3 - assert abs(old1[1] - 100) < 1e-3 - - def test_returns_empty_for_missing_component(self): - sch = _make_sch_data() - result = WireDragger.compute_pin_positions(sch, "MISSING", 0, 0) - assert result == {} - - def test_delta_is_consistent(self): - """new_xy - old_xy should equal (new_x - old_x, new_y - old_y) for any rotation.""" - sch = _make_sch_data([_make_symbol("R1", 50, 50, rotation=45)]) - positions = WireDragger.compute_pin_positions(sch, "R1", 60, 70) - for pin_num, (old_xy, new_xy) in positions.items(): - dx = new_xy[0] - old_xy[0] - dy = new_xy[1] - old_xy[1] - assert abs(dx - 10) < 1e-4, f"Pin {pin_num}: dx={dx}" - assert abs(dy - 20) < 1e-4, f"Pin {pin_num}: dy={dy}" - - -# --------------------------------------------------------------------------- -# TestDragWires -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestDragWires: - def test_no_wires_returns_zero_counts(self): - sch = _make_sch_data() - result = WireDragger.drag_wires(sch, {(0.0, 0.0): (10.0, 10.0)}) - assert result["endpoints_moved"] == 0 - assert result["wires_removed"] == 0 - - def test_wire_start_endpoint_moved(self): - wire = _make_wire(0, 3.81, 0, 10) - sch = _make_sch_data([wire]) - result = WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) - assert result["endpoints_moved"] == 1 - assert result["wires_removed"] == 0 - # Find the updated wire in sch_data - updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")) - pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")) - xy1 = pts[1] - assert abs(xy1[1] - 10.0) < EPS - assert abs(xy1[2] - 23.81) < EPS - - def test_wire_end_endpoint_moved(self): - wire = _make_wire(0, 10, 0, -3.81) - sch = _make_sch_data([wire]) - result = WireDragger.drag_wires(sch, {(0.0, -3.81): (10.0, 16.19)}) - assert result["endpoints_moved"] == 1 - updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")) - pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")) - xy2 = pts[2] - assert abs(xy2[1] - 10.0) < EPS - assert abs(xy2[2] - 16.19) < EPS - - def test_zero_length_wire_removed(self): - """When both endpoints of a wire are moved to the same point, wire is deleted.""" - wire = _make_wire(0, 3.81, 0, -3.81) - sch = _make_sch_data([wire]) - # Both pins land at same position (degenerate move) - result = WireDragger.drag_wires( - sch, - { - (0.0, 3.81): (5.0, 5.0), - (0.0, -3.81): (5.0, 5.0), - }, - ) - assert result["wires_removed"] == 1 - wires_remaining = [i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")] - assert len(wires_remaining) == 0 - - def test_unrelated_wire_not_touched(self): - """A wire whose endpoints don't match any old pin is not changed.""" - wire = _make_wire(50, 50, 60, 50) - sch = _make_sch_data([wire]) - original_start = (50.0, 50.0) - result = WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) - assert result["endpoints_moved"] == 0 - updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")) - pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")) - xy1 = pts[1] - assert abs(xy1[1] - 50.0) < EPS - assert abs(xy1[2] - 50.0) < EPS - - def test_both_endpoints_on_moved_component(self): - """Wire connecting two pins of same component — both endpoints shift together.""" - wire = _make_wire(0, 3.81, 0, -3.81) - sch = _make_sch_data([wire]) - result = WireDragger.drag_wires( - sch, - { - (0.0, 3.81): (10.0, 23.81), - (0.0, -3.81): (10.0, 16.19), - }, - ) - assert result["endpoints_moved"] == 2 - assert result["wires_removed"] == 0 - - def test_junction_moved_with_endpoint(self): - junction = _make_junction(0, 3.81) - sch = _make_sch_data([junction]) - WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) - updated_j = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("junction")) - at_sub = next( - s for s in updated_j[1:] if isinstance(s, list) and s and s[0] == Symbol("at") - ) - assert abs(at_sub[1] - 10.0) < EPS - assert abs(at_sub[2] - 23.81) < EPS - - def test_junction_at_unrelated_position_not_touched(self): - junction = _make_junction(99, 99) - sch = _make_sch_data([junction]) - WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) - updated_j = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("junction")) - at_sub = next( - s for s in updated_j[1:] if isinstance(s, list) and s and s[0] == Symbol("at") - ) - assert abs(at_sub[1] - 99.0) < EPS - assert abs(at_sub[2] - 99.0) < EPS - - -# --------------------------------------------------------------------------- -# TestUpdateSymbolPosition -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestUpdateSymbolPosition: - def test_updates_position(self): - sch = _make_sch_data([_make_symbol("R1", 10, 20)]) - result = WireDragger.update_symbol_position(sch, "R1", 30, 40) - assert result is True - found = WireDragger.find_symbol(sch, "R1") - assert abs(found[1] - 30) < EPS - assert abs(found[2] - 40) < EPS - - def test_returns_false_for_missing(self): - sch = _make_sch_data() - assert WireDragger.update_symbol_position(sch, "MISSING", 0, 0) is False - - def test_preserves_rotation(self): - sch = _make_sch_data([_make_symbol("R1", 10, 20, rotation=90)]) - WireDragger.update_symbol_position(sch, "R1", 30, 40) - found = WireDragger.find_symbol(sch, "R1") - assert abs(found[3] - 90) < EPS # rotation preserved - - def test_property_labels_follow_symbol_move(self): - """Property (at ...) positions must shift by the same delta as the symbol.""" - sym = _make_symbol("R1", 100, 80) - sch = _make_sch_data([sym]) - - # Record initial property positions - prop_k = _sym("property") - at_k = _sym("at") - initial_positions = {} - for sub in sym[1:]: - if isinstance(sub, list) and sub and sub[0] == prop_k: - name = sub[1] - for psub in sub[2:]: - if isinstance(psub, list) and psub and psub[0] == at_k: - initial_positions[name] = (psub[1], psub[2]) - break - assert len(initial_positions) >= 2 # Reference and Value at minimum - - # Move component from (100, 80) to (120, 100) — delta (20, 20) - result = WireDragger.update_symbol_position(sch, "R1", 120, 100) - assert result is True - - # Verify each property shifted by (20, 20) - for sub in sym[1:]: - if isinstance(sub, list) and sub and sub[0] == prop_k: - name = sub[1] - for psub in sub[2:]: - if isinstance(psub, list) and psub and psub[0] == at_k: - expected_x = initial_positions[name][0] + 20 - expected_y = initial_positions[name][1] + 20 - assert ( - abs(psub[1] - expected_x) < EPS - ), f"{name} x: expected {expected_x}, got {psub[1]}" - assert ( - abs(psub[2] - expected_y) < EPS - ), f"{name} y: expected {expected_y}, got {psub[2]}" - break - - -# --------------------------------------------------------------------------- -# Integration tests -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -class TestMoveWithWirePreservation: - """Integration tests using a real .kicad_sch file.""" - - def _make_schematic(self, extra_sexp=""): - """Copy empty.kicad_sch to a temp file and optionally append content.""" - 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 _add_resistor(self, path: Path, ref: str, x: float, y: float, rotation: float = 0) -> Path: - """Append a Device:R symbol to the schematic file.""" - import uuid - - u = str(uuid.uuid4()) - sexp = f""" - (symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1) - (in_bom yes) (on_board yes) (dnp no) - (uuid "{u}") - (property "Reference" "{ref}" (at {x + 2.032} {y} 90) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "10k" (at {x} {y} 90) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at {x - 1.778} {y} 90) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at {x} {y} 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid "{uuid.uuid4()}")) - (pin "2" (uuid "{uuid.uuid4()}")) - (instances (project "test" (path "/" (reference "{ref}") (unit 1)))) - )""" - content = path.read_text(encoding="utf-8") - idx = content.rfind(")") - path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8") - return path - - def _add_wire(self, path: Path, x1, y1, x2, y2) -> Path: - """Append a wire to the schematic file.""" - import uuid - - wire_sexp = f""" - (wire (pts (xy {x1} {y1}) (xy {x2} {y2})) - (stroke (width 0) (type default)) - (uuid "{uuid.uuid4()}") - )""" - content = path.read_text(encoding="utf-8") - idx = content.rfind(")") - path.write_text(content[:idx] + "\n" + wire_sexp + "\n)", encoding="utf-8") - return path - - def _parse_wires(self, path: Path): - """Return list of ((x1,y1),(x2,y2)) for every wire in the file.""" - content = path.read_text(encoding="utf-8") - data = sexpdata.loads(content) - wires = [] - for item in data: - if not (isinstance(item, list) and item and item[0] == Symbol("wire")): - continue - pts = next( - (s for s in item[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")), - None, - ) - if pts is None: - continue - xys = [ - p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy") - ] - if len(xys) >= 2: - wires.append( - ( - (float(xys[0][1]), float(xys[0][2])), - (float(xys[-1][1]), float(xys[-1][2])), - ) - ) - return wires - - def _get_symbol_pos(self, path: Path, ref: str): - content = path.read_text(encoding="utf-8") - data = sexpdata.loads(content) - found = WireDragger.find_symbol(data, ref) - if found is None: - return None - return found[1], found[2] - - def test_symbol_position_updated(self): - sch = self._make_schematic() - self._add_resistor(sch, "R1", 100, 100) - # Call handler directly - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from kicad_interface import KiCADInterface - - iface = KiCADInterface() - result = iface.handle_command( - "move_schematic_component", - { - "schematicPath": str(sch), - "reference": "R1", - "position": {"x": 120, "y": 130}, - }, - ) - assert result["success"], result.get("message") - pos = self._get_symbol_pos(sch, "R1") - assert abs(pos[0] - 120) < EPS - assert abs(pos[1] - 130) < EPS - - def test_connected_wire_endpoint_follows_pin(self): - """Wire endpoint at pin 1 of R1 should move with the component.""" - sch = self._make_schematic() - # R1 at (100, 100) — pin 1 at (100, 103.81) - self._add_resistor(sch, "R1", 100, 100) - self._add_wire(sch, 100, 103.81, 100, 120) # wire from pin 1 upward - - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from kicad_interface import KiCADInterface - - iface = KiCADInterface() - result = iface.handle_command( - "move_schematic_component", - { - "schematicPath": str(sch), - "reference": "R1", - "position": {"x": 110, "y": 100}, - }, - ) - assert result["success"], result.get("message") - assert result["wiresMoved"] >= 1 - - wires = self._parse_wires(sch) - assert len(wires) == 1 - # Pin 1 new world position: (110 + 0, 100 + 3.81) = (110, 103.81) - w = wires[0] - endpoints = {w[0], w[1]} - new_pin1 = (110.0, 103.81) - assert any( - abs(ep[0] - new_pin1[0]) < 0.01 and abs(ep[1] - new_pin1[1]) < 0.01 for ep in endpoints - ), f"Expected pin endpoint near {new_pin1}, got {endpoints}" - - def test_unrelated_wire_unchanged(self): - """A wire not connected to R1 must not be modified.""" - sch = self._make_schematic() - self._add_resistor(sch, "R1", 100, 100) - self._add_wire(sch, 50, 50, 60, 50) # unrelated wire - - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from kicad_interface import KiCADInterface - - iface = KiCADInterface() - iface.handle_command( - "move_schematic_component", - { - "schematicPath": str(sch), - "reference": "R1", - "position": {"x": 110, "y": 110}, - }, - ) - - wires = self._parse_wires(sch) - unrelated = [(s, e) for s, e in wires if abs(s[0] - 50) < 0.01 and abs(s[1] - 50) < 0.01] - assert len(unrelated) == 1 - - def test_no_zero_length_wires_after_move(self): - """No zero-length wires should appear in the file after a move.""" - sch = self._make_schematic() - self._add_resistor(sch, "R1", 100, 100) - # Wire from pin 1 to pin 2 of same component (intra-component wire) - self._add_wire(sch, 100, 103.81, 100, 96.19) - - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from kicad_interface import KiCADInterface - - iface = KiCADInterface() - iface.handle_command( - "move_schematic_component", - { - "schematicPath": str(sch), - "reference": "R1", - "position": {"x": 110, "y": 100}, - }, - ) - - wires = self._parse_wires(sch) - for start, end in wires: - assert not ( - abs(start[0] - end[0]) < EPS and abs(start[1] - end[1]) < EPS - ), f"Zero-length wire found at {start}" - - def test_preserve_wires_false_skips_wire_update(self): - """preserveWires=False should move the symbol but leave wires alone.""" - sch = self._make_schematic() - self._add_resistor(sch, "R1", 100, 100) - self._add_wire(sch, 100, 103.81, 100, 120) - - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from kicad_interface import KiCADInterface - - iface = KiCADInterface() - result = iface.handle_command( - "move_schematic_component", - { - "schematicPath": str(sch), - "reference": "R1", - "position": {"x": 110, "y": 100}, - "preserveWires": False, - }, - ) - assert result["success"] - assert result["wiresMoved"] == 0 - - # Wire should still start at old pin position - wires = self._parse_wires(sch) - assert len(wires) == 1 - endpoints = {wires[0][0], wires[0][1]} - old_pin1 = (100.0, 103.81) - assert any( - abs(ep[0] - old_pin1[0]) < 0.01 and abs(ep[1] - old_pin1[1]) < 0.01 for ep in endpoints - ), f"Wire should still be at {old_pin1}, got {endpoints}" - - def test_missing_component_returns_error(self): - sch = self._make_schematic() - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from kicad_interface import KiCADInterface - - iface = KiCADInterface() - result = iface.handle_command( - "move_schematic_component", - { - "schematicPath": str(sch), - "reference": "NOTHERE", - "position": {"x": 0, "y": 0}, - }, - ) - assert not result["success"] - assert "not found" in result.get("message", "").lower() - - -# --------------------------------------------------------------------------- -# TestSynthesizeTouchingPinWires (unit) -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestSynthesizeTouchingPinWires: - """Unit tests for WireDragger.synthesize_touching_pin_wires.""" - - def _make_two_resistors(self, r1_x, r1_y, r2_x, r2_y): - """Build sch_data with R1 and R2, each Device:R.""" - return _make_sch_data( - [ - _make_symbol("R1", r1_x, r1_y), - _make_symbol("R2", r2_x, r2_y), - ] - ) - - def test_no_stationary_symbols_returns_zero(self): - """With only the moved component in sch_data, nothing is synthesized.""" - sch = _make_sch_data([_make_symbol("R1", 0, 0)]) - pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20) - count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) - assert count == 0 - - def test_touching_pin_gap_generates_wire(self): - """ - R1 at (0, 0) pin2 at (0, -3.81). - R2 at (0, -7.62) pin1 at (0, -3.81). ← pins touch - Moving R1 to (10, 0) causes pin2 to move to (10, -3.81). - A wire from (0, -3.81) to (10, -3.81) should be synthesized. - """ - # R2 pin1 is at (0, -7.62 + 3.81) = (0, -3.81) - sch = self._make_two_resistors(0, 0, 0, -7.62) - - # Verify the touching: R1 pin2 old = (0, -3.81), R2 pin1 = (0, -3.81) - pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0) - old2, new2 = pin_positions["2"] - assert abs(old2[0] - 0) < 1e-3 and abs(old2[1] - (-3.81)) < 1e-3 - assert abs(new2[0] - 10) < 1e-3 and abs(new2[1] - (-3.81)) < 1e-3 - - wire_count_before = sum( - 1 for item in sch if isinstance(item, list) and item and item[0] == _sym("wire") - ) - count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) - assert count == 1, f"Expected 1 synthesized wire, got {count}" - - wires = [ - item for item in sch if isinstance(item, list) and item and item[0] == _sym("wire") - ] - assert len(wires) == wire_count_before + 1 - - # The new wire should span (0, -3.81) → (10, -3.81) - new_wire = wires[-1] - pts = next(s for s in new_wire[1:] if isinstance(s, list) and s and s[0] == _sym("pts")) - xys = [p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == _sym("xy")] - assert len(xys) == 2 - endpoints = { - (round(float(xys[0][1]), 3), round(float(xys[0][2]), 3)), - (round(float(xys[1][1]), 3), round(float(xys[1][2]), 3)), - } - assert (0.0, -3.81) in endpoints, f"Expected (0, -3.81) in wire endpoints, got {endpoints}" - assert ( - 10.0, - -3.81, - ) in endpoints, f"Expected (10, -3.81) in wire endpoints, got {endpoints}" - - def test_no_wire_when_pin_didnt_move(self): - """ - If old_xy == new_xy for a touching pin (component moved but this pin stayed put), - no wire should be synthesized. - """ - # R1 at (0, 0), R2 at (0, -7.62) — pin2 of R1 and pin1 of R2 touch at (0, -3.81) - sch = self._make_two_resistors(0, 0, 0, -7.62) - # Moving R1 to (0, 0) — effectively no move, same position - pin_positions = WireDragger.compute_pin_positions(sch, "R1", 0, 0) - count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) - assert count == 0 - - def test_no_wire_when_rejoins_other_stationary_pin(self): - """ - If the moved pin's new position coincides with another stationary pin, - no wire should be synthesized (they touch again). - """ - # R1 at (0, 0), R2 at (0, -7.62), R3 at (10, -7.62) - # R1 pin2 was touching R2 pin1 at (0, -3.81). - # Moving R1 to (10, 0): pin2 lands at (10, -3.81) which is R3 pin1. - sch = _make_sch_data( - [ - _make_symbol("R1", 0, 0), - _make_symbol("R2", 0, -7.62), - _make_symbol("R3", 10, -7.62), - ] - ) - pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0) - count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) - assert count == 0, f"Expected 0 synthesized wires (rejoined), got {count}" - - def test_empty_pin_positions_returns_zero(self): - sch = _make_sch_data([_make_symbol("R1", 0, 0)]) - count = WireDragger.synthesize_touching_pin_wires(sch, "R1", {}) - assert count == 0 - - def test_non_touching_pins_not_affected(self): - """ - When R1 and R2 are NOT touching (different positions), no wire is synthesized. - """ - # R1 at (0, 0), R2 at (100, 100) — far apart - sch = self._make_two_resistors(0, 0, 100, 100) - pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0) - count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) - assert count == 0 - - -# --------------------------------------------------------------------------- -# TestOldToNewCollision (unit) — regression for the duplicate-pin-position bug -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestOldToNewCollision: - """Verify that coincident pins do not silently overwrite each other in old_to_new.""" - - def test_handler_logs_warning_on_collision(self, caplog): - """ - When two pins share the same old position, a warning should be logged - and the *first* mapping should be kept (not overwritten by the second). - """ - import logging - - # Build a fake pin_positions dict with a deliberate collision - pin_positions = { - "1": ((0.0, 3.81), (10.0, 23.81)), - "2": ((0.0, 3.81), (10.0, 16.19)), # same old_xy as pin "1" - } - - old_to_new = {} - with caplog.at_level(logging.WARNING, logger="kicad_interface"): - for _pin, (old_xy, new_xy) in pin_positions.items(): - if old_xy in old_to_new: - import logging as _logging - - logger_inner = _logging.getLogger("kicad_interface") - logger_inner.warning( - f"move_schematic_component: pin {_pin!r} shares old position {old_xy} " - f"with another pin; keeping first entry, skipping duplicate" - ) - continue - old_to_new[old_xy] = new_xy - - # Only one entry should exist, and it should be the first one - assert len(old_to_new) == 1 - assert old_to_new[(0.0, 3.81)] == (10.0, 23.81) - # Warning should have been logged - assert any("skipping duplicate" in r.message for r in caplog.records) - - -# --------------------------------------------------------------------------- -# TestTouchingPinIntegration (integration) -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -class TestTouchingPinIntegration: - """Integration tests for pin-touching connection wire synthesis.""" - - def _make_schematic(self, extra_sexp=""): - """Copy empty.kicad_sch to a temp file.""" - 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 _add_resistor(self, path: Path, ref: str, x: float, y: float, rotation: float = 0) -> Path: - import uuid as _uuid - - u = str(_uuid.uuid4()) - sexp = f""" - (symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1) - (in_bom yes) (on_board yes) (dnp no) - (uuid "{u}") - (property "Reference" "{ref}" (at {x + 2.032} {y} 90) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "10k" (at {x} {y} 90) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at {x - 1.778} {y} 90) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at {x} {y} 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid "{_uuid.uuid4()}")) - (pin "2" (uuid "{_uuid.uuid4()}")) - (instances (project "test" (path "/" (reference "{ref}") (unit 1)))) - )""" - content = path.read_text(encoding="utf-8") - idx = content.rfind(")") - path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8") - return path - - def _parse_wires(self, path: Path): - content = path.read_text(encoding="utf-8") - data = sexpdata.loads(content) - wires = [] - for item in data: - if not (isinstance(item, list) and item and item[0] == Symbol("wire")): - continue - pts = next( - (s for s in item[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")), - None, - ) - if pts is None: - continue - xys = [ - p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy") - ] - if len(xys) >= 2: - wires.append( - ( - (float(xys[0][1]), float(xys[0][2])), - (float(xys[-1][1]), float(xys[-1][2])), - ) - ) - return wires - - def test_touching_pin_wire_created_on_move(self): - """ - R1 at (100, 100) and R2 at (100, 92.38) share a touching pin: - R1 pin2 = (100, 96.19), R2 pin1 = (100, 96.19). - Moving R1 to (110, 100) should synthesize a wire from (100, 96.19) to (110, 96.19). - """ - sch = self._make_schematic() - # R1 pin2 world position = 100 + (-3.81) = 96.19 - # R2 pin1 world position = 92.38 + 3.81 = 96.19 - self._add_resistor(sch, "R1", 100, 100) - self._add_resistor(sch, "R2", 100, 92.38) - - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from kicad_interface import KiCADInterface - - iface = KiCADInterface() - result = iface.handle_command( - "move_schematic_component", - { - "schematicPath": str(sch), - "reference": "R1", - "position": {"x": 110, "y": 100}, - }, - ) - assert result["success"], result.get("message") - assert ( - result.get("wiresSynthesized", 0) >= 1 - ), f"Expected at least 1 synthesized wire, got {result}" - - wires = self._parse_wires(sch) - # There should be a wire bridging the old and new pin2 positions - old_pin2 = (100.0, 96.19) - new_pin2 = (110.0, 96.19) - bridging = [ - (s, e) - for s, e in wires - if ( - ( - abs(s[0] - old_pin2[0]) < 0.05 - and abs(s[1] - old_pin2[1]) < 0.05 - and abs(e[0] - new_pin2[0]) < 0.05 - and abs(e[1] - new_pin2[1]) < 0.05 - ) - or ( - abs(e[0] - old_pin2[0]) < 0.05 - and abs(e[1] - old_pin2[1]) < 0.05 - and abs(s[0] - new_pin2[0]) < 0.05 - and abs(s[1] - new_pin2[1]) < 0.05 - ) - ) - ] - assert ( - len(bridging) >= 1 - ), f"Expected a bridging wire from {old_pin2} to {new_pin2}, got wires: {wires}" - - def test_no_wire_synthesized_when_no_touching_pins(self): - """ - Two resistors with no pin overlap should not generate any synthesized wires. - """ - sch = self._make_schematic() - self._add_resistor(sch, "R1", 100, 100) - self._add_resistor(sch, "R2", 150, 150) # far away - - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from kicad_interface import KiCADInterface - - iface = KiCADInterface() - result = iface.handle_command( - "move_schematic_component", - { - "schematicPath": str(sch), - "reference": "R1", - "position": {"x": 110, "y": 100}, - }, - ) - assert result["success"], result.get("message") - assert result.get("wiresSynthesized", 0) == 0 - - def test_existing_wires_still_dragged_with_touching_pins(self): - """ - When R1 has both an explicit wire AND a touching-pin connection, - both should be handled: the wire dragged and the touching-pin bridged. - """ - sch = self._make_schematic() - # R1 at (100, 100), R2 at (100, 92.38) — pin2 of R1 touches pin1 of R2 - self._add_resistor(sch, "R1", 100, 100) - self._add_resistor(sch, "R2", 100, 92.38) - - # Also add an explicit wire at pin1 of R1 (100, 103.81) going up - import uuid as _uuid - - wire_sexp = f""" - (wire (pts (xy 100 103.81) (xy 100 115)) - (stroke (width 0) (type default)) - (uuid "{_uuid.uuid4()}") - )""" - content = sch.read_text(encoding="utf-8") - idx = content.rfind(")") - sch.write_text(content[:idx] + "\n" + wire_sexp + "\n)", encoding="utf-8") - - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - from kicad_interface import KiCADInterface - - iface = KiCADInterface() - result = iface.handle_command( - "move_schematic_component", - { - "schematicPath": str(sch), - "reference": "R1", - "position": {"x": 110, "y": 100}, - }, - ) - assert result["success"], result.get("message") - assert result.get("wiresMoved", 0) >= 1, "Expected at least one wire endpoint dragged" - assert result.get("wiresSynthesized", 0) >= 1, "Expected at least one touching-pin wire" +""" +Tests for move_schematic_component with wire preservation (WireDragger). + +Unit tests use synthetic sexpdata lists — no disk I/O, no KiCAD install needed. +Integration tests copy empty.kicad_sch to a tempdir and exercise the full handler. +""" + +import shutil +import sys +import tempfile +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +import sexpdata +from sexpdata import Symbol + +# Make python/ importable +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from commands.wire_dragger import EPS, WireDragger, _coords_match, _rotate + +TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "empty.kicad_sch" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _sym(name: str) -> Symbol: + return Symbol(name) + + +def _make_wire(x1: Any, y1: Any, x2: Any, y2: Any) -> Any: + return [ + _sym("wire"), + [_sym("pts"), [_sym("xy"), x1, y1], [_sym("xy"), x2, y2]], + [_sym("stroke"), [_sym("width"), 0], [_sym("type"), _sym("default")]], + [_sym("uuid"), "00000000-0000-0000-0000-000000000000"], + ] + + +def _make_junction(x: Any, y: Any) -> Any: + return [ + _sym("junction"), + [_sym("at"), x, y], + [_sym("diameter"), 0], + [_sym("color"), 0, 0, 0, 0], + [_sym("uuid"), "00000000-0000-0000-0000-000000000001"], + ] + + +def _make_symbol( + ref: Any, x: Any, y: Any, rotation: Any = 0, lib_id: str = "Device:R", mirror: Any = None +) -> Any: + """Build a minimal placed-symbol s-expression.""" + item = [ + _sym("symbol"), + [_sym("lib_id"), lib_id], + [_sym("at"), x, y, rotation], + [_sym("unit"), 1], + [_sym("property"), "Reference", ref, [_sym("at"), x + 2, y, 0]], + [_sym("property"), "Value", "10k", [_sym("at"), x, y, 0]], + ] + if mirror: + item.append([_sym("mirror"), _sym(mirror)]) + return item + + +def _make_lib_symbol_r() -> Any: + """Minimal Device:R lib_symbols entry — pins at (0, 3.81) and (0, -3.81).""" + return [ + _sym("symbol"), + "Device:R", + [ + _sym("symbol"), + "R_1_1", + [ + _sym("pin"), + _sym("passive"), + _sym("line"), + [_sym("at"), 0, 3.81, 270], + [_sym("length"), 1.27], + [ + _sym("name"), + "~", + [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]], + ], + [ + _sym("number"), + "1", + [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]], + ], + ], + [ + _sym("pin"), + _sym("passive"), + _sym("line"), + [_sym("at"), 0, -3.81, 90], + [_sym("length"), 1.27], + [ + _sym("name"), + "~", + [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]], + ], + [ + _sym("number"), + "2", + [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]], + ], + ], + ], + ] + + +def _make_sch_data(extra_items: Any = None) -> Any: + """Build a minimal sch_data list with lib_symbols and sheet_instances.""" + data = [ + _sym("kicad_sch"), + [_sym("lib_symbols"), _make_lib_symbol_r()], + [_sym("sheet_instances"), [_sym("path"), "/", [_sym("page"), "1"]]], + ] + if extra_items: + # Insert before sheet_instances (last item) + for item in extra_items: + data.insert(len(data) - 1, item) + return data + + +# --------------------------------------------------------------------------- +# TestRotatePoint +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestRotatePoint: + def test_zero_rotation(self) -> None: + assert _rotate(1.0, 2.0, 0) == (1.0, 2.0) + + def test_90_degrees(self) -> None: + rx, ry = _rotate(1.0, 0.0, 90) + assert abs(rx - 0.0) < 1e-9 + assert abs(ry - 1.0) < 1e-9 + + def test_180_degrees(self) -> None: + rx, ry = _rotate(1.0, 0.0, 180) + assert abs(rx - (-1.0)) < 1e-9 + assert abs(ry - 0.0) < 1e-9 + + def test_270_degrees(self) -> None: + rx, ry = _rotate(0.0, 1.0, 270) + assert abs(rx - 1.0) < 1e-6 + assert abs(ry - 0.0) < 1e-6 + + +# --------------------------------------------------------------------------- +# TestFindSymbol +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestFindSymbol: + def test_returns_none_for_missing_reference(self) -> None: + sch = _make_sch_data([_make_symbol("R1", 10, 20)]) + assert WireDragger.find_symbol(sch, "R99") is None + + def test_returns_item_and_position(self) -> None: + sch = _make_sch_data([_make_symbol("R1", 10.5, 20.5, rotation=90)]) + result = WireDragger.find_symbol(sch, "R1") + assert result is not None + _, old_x, old_y, rotation, lib_id, mirror_x, mirror_y = result + assert abs(old_x - 10.5) < EPS + assert abs(old_y - 20.5) < EPS + assert abs(rotation - 90) < EPS + assert lib_id == "Device:R" + assert mirror_x is False + assert mirror_y is False + + def test_detects_mirror_x(self) -> None: + sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="x")]) + result = WireDragger.find_symbol(sch, "R1") + assert result is not None + assert result[5] is True # mirror_x + assert result[6] is False # mirror_y + + def test_detects_mirror_y(self) -> None: + sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="y")]) + result = WireDragger.find_symbol(sch, "R1") + assert result is not None + assert result[5] is False # mirror_x + assert result[6] is True # mirror_y + + +# --------------------------------------------------------------------------- +# TestComputePinPositions +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestComputePinPositions: + def test_resistor_at_origin_no_rotation(self) -> None: + """Device:R at (0, 0) rot=0 — pins at (0, 3.81) and (0, -3.81).""" + sch = _make_sch_data([_make_symbol("R1", 0, 0)]) + positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20) + assert "1" in positions and "2" in positions + old1, new1 = positions["1"] + old2, new2 = positions["2"] + # Pin 1 old: (0 + 0, 0 + 3.81) + assert abs(old1[0] - 0) < 1e-4 + assert abs(old1[1] - 3.81) < 1e-4 + # Pin 2 old: (0 + 0, 0 - 3.81) + assert abs(old2[0] - 0) < 1e-4 + assert abs(old2[1] - (-3.81)) < 1e-4 + # New positions shifted by (10, 20) + assert abs(new1[0] - 10) < 1e-4 + assert abs(new1[1] - 23.81) < 1e-4 + assert abs(new2[0] - 10) < 1e-4 + assert abs(new2[1] - 16.19) < 1e-4 + + def test_resistor_rotated_90(self) -> None: + """Device:R at (100, 100) rot=90 — pins should be at (100+3.81, 100) and (100-3.81, 100).""" + sch = _make_sch_data([_make_symbol("R1", 100, 100, rotation=90)]) + positions = WireDragger.compute_pin_positions(sch, "R1", 100, 100) + old1, _ = positions["1"] + old2, _ = positions["2"] + # rotate(0, 3.81, 90) = (0*cos90 - 3.81*sin90, 0*sin90 + 3.81*cos90) = (-3.81, 0) + # Wait — pin 1 is at local (0, 3.81), rotated 90° CCW: + # x' = 0*cos90 - 3.81*sin90 = -3.81, y' = 0*sin90 + 3.81*cos90 ≈ 0 + # world: (100 - 3.81, 100 + 0) = (96.19, 100) + assert abs(old1[0] - 96.19) < 1e-3 + assert abs(old1[1] - 100) < 1e-3 + + def test_returns_empty_for_missing_component(self) -> None: + sch = _make_sch_data() + result = WireDragger.compute_pin_positions(sch, "MISSING", 0, 0) + assert result == {} + + def test_delta_is_consistent(self) -> None: + """new_xy - old_xy should equal (new_x - old_x, new_y - old_y) for any rotation.""" + sch = _make_sch_data([_make_symbol("R1", 50, 50, rotation=45)]) + positions = WireDragger.compute_pin_positions(sch, "R1", 60, 70) + for pin_num, (old_xy, new_xy) in positions.items(): + dx = new_xy[0] - old_xy[0] + dy = new_xy[1] - old_xy[1] + assert abs(dx - 10) < 1e-4, f"Pin {pin_num}: dx={dx}" + assert abs(dy - 20) < 1e-4, f"Pin {pin_num}: dy={dy}" + + +# --------------------------------------------------------------------------- +# TestDragWires +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestDragWires: + def test_no_wires_returns_zero_counts(self) -> None: + sch = _make_sch_data() + result = WireDragger.drag_wires(sch, {(0.0, 0.0): (10.0, 10.0)}) + assert result["endpoints_moved"] == 0 + assert result["wires_removed"] == 0 + + def test_wire_start_endpoint_moved(self) -> None: + wire = _make_wire(0, 3.81, 0, 10) + sch = _make_sch_data([wire]) + result = WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) + assert result["endpoints_moved"] == 1 + assert result["wires_removed"] == 0 + # Find the updated wire in sch_data + updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")) + pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")) + xy1 = pts[1] + assert abs(xy1[1] - 10.0) < EPS + assert abs(xy1[2] - 23.81) < EPS + + def test_wire_end_endpoint_moved(self) -> None: + wire = _make_wire(0, 10, 0, -3.81) + sch = _make_sch_data([wire]) + result = WireDragger.drag_wires(sch, {(0.0, -3.81): (10.0, 16.19)}) + assert result["endpoints_moved"] == 1 + updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")) + pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")) + xy2 = pts[2] + assert abs(xy2[1] - 10.0) < EPS + assert abs(xy2[2] - 16.19) < EPS + + def test_zero_length_wire_removed(self) -> None: + """When both endpoints of a wire are moved to the same point, wire is deleted.""" + wire = _make_wire(0, 3.81, 0, -3.81) + sch = _make_sch_data([wire]) + # Both pins land at same position (degenerate move) + result = WireDragger.drag_wires( + sch, + { + (0.0, 3.81): (5.0, 5.0), + (0.0, -3.81): (5.0, 5.0), + }, + ) + assert result["wires_removed"] == 1 + wires_remaining = [i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")] + assert len(wires_remaining) == 0 + + def test_unrelated_wire_not_touched(self) -> None: + """A wire whose endpoints don't match any old pin is not changed.""" + wire = _make_wire(50, 50, 60, 50) + sch = _make_sch_data([wire]) + original_start = (50.0, 50.0) + result = WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) + assert result["endpoints_moved"] == 0 + updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")) + pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")) + xy1 = pts[1] + assert abs(xy1[1] - 50.0) < EPS + assert abs(xy1[2] - 50.0) < EPS + + def test_both_endpoints_on_moved_component(self) -> None: + """Wire connecting two pins of same component — both endpoints shift together.""" + wire = _make_wire(0, 3.81, 0, -3.81) + sch = _make_sch_data([wire]) + result = WireDragger.drag_wires( + sch, + { + (0.0, 3.81): (10.0, 23.81), + (0.0, -3.81): (10.0, 16.19), + }, + ) + assert result["endpoints_moved"] == 2 + assert result["wires_removed"] == 0 + + def test_junction_moved_with_endpoint(self) -> None: + junction = _make_junction(0, 3.81) + sch = _make_sch_data([junction]) + WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) + updated_j = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("junction")) + at_sub = next( + s for s in updated_j[1:] if isinstance(s, list) and s and s[0] == Symbol("at") + ) + assert abs(at_sub[1] - 10.0) < EPS + assert abs(at_sub[2] - 23.81) < EPS + + def test_junction_at_unrelated_position_not_touched(self) -> None: + junction = _make_junction(99, 99) + sch = _make_sch_data([junction]) + WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) + updated_j = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("junction")) + at_sub = next( + s for s in updated_j[1:] if isinstance(s, list) and s and s[0] == Symbol("at") + ) + assert abs(at_sub[1] - 99.0) < EPS + assert abs(at_sub[2] - 99.0) < EPS + + +# --------------------------------------------------------------------------- +# TestUpdateSymbolPosition +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestUpdateSymbolPosition: + def test_updates_position(self) -> None: + sch = _make_sch_data([_make_symbol("R1", 10, 20)]) + result = WireDragger.update_symbol_position(sch, "R1", 30, 40) + assert result is True + found = WireDragger.find_symbol(sch, "R1") + assert abs(found[1] - 30) < EPS + assert abs(found[2] - 40) < EPS + + def test_returns_false_for_missing(self) -> None: + sch = _make_sch_data() + assert WireDragger.update_symbol_position(sch, "MISSING", 0, 0) is False + + def test_preserves_rotation(self) -> None: + sch = _make_sch_data([_make_symbol("R1", 10, 20, rotation=90)]) + WireDragger.update_symbol_position(sch, "R1", 30, 40) + found = WireDragger.find_symbol(sch, "R1") + assert abs(found[3] - 90) < EPS # rotation preserved + + def test_property_labels_follow_symbol_move(self) -> None: + """Property (at ...) positions must shift by the same delta as the symbol.""" + sym = _make_symbol("R1", 100, 80) + sch = _make_sch_data([sym]) + + # Record initial property positions + prop_k = _sym("property") + at_k = _sym("at") + initial_positions = {} + for sub in sym[1:]: + if isinstance(sub, list) and sub and sub[0] == prop_k: + name = sub[1] + for psub in sub[2:]: + if isinstance(psub, list) and psub and psub[0] == at_k: + initial_positions[name] = (psub[1], psub[2]) + break + assert len(initial_positions) >= 2 # Reference and Value at minimum + + # Move component from (100, 80) to (120, 100) — delta (20, 20) + result = WireDragger.update_symbol_position(sch, "R1", 120, 100) + assert result is True + + # Verify each property shifted by (20, 20) + for sub in sym[1:]: + if isinstance(sub, list) and sub and sub[0] == prop_k: + name = sub[1] + for psub in sub[2:]: + if isinstance(psub, list) and psub and psub[0] == at_k: + expected_x = initial_positions[name][0] + 20 + expected_y = initial_positions[name][1] + 20 + assert ( + abs(psub[1] - expected_x) < EPS + ), f"{name} x: expected {expected_x}, got {psub[1]}" + assert ( + abs(psub[2] - expected_y) < EPS + ), f"{name} y: expected {expected_y}, got {psub[2]}" + break + + +# --------------------------------------------------------------------------- +# Integration tests +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestMoveWithWirePreservation: + """Integration tests using a real .kicad_sch file.""" + + def _make_schematic(self, extra_sexp: Any = "") -> Any: + """Copy empty.kicad_sch to a temp file and optionally append content.""" + 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 _add_resistor(self, path: Path, ref: str, x: float, y: float, rotation: float = 0) -> Path: + """Append a Device:R symbol to the schematic file.""" + import uuid + + u = str(uuid.uuid4()) + sexp = f""" + (symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1) + (in_bom yes) (on_board yes) (dnp no) + (uuid "{u}") + (property "Reference" "{ref}" (at {x + 2.032} {y} 90) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "10k" (at {x} {y} 90) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at {x - 1.778} {y} 90) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at {x} {y} 0) + (effects (font (size 1.27 1.27)) hide) + ) + (pin "1" (uuid "{uuid.uuid4()}")) + (pin "2" (uuid "{uuid.uuid4()}")) + (instances (project "test" (path "/" (reference "{ref}") (unit 1)))) + )""" + content = path.read_text(encoding="utf-8") + idx = content.rfind(")") + path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8") + return path + + def _add_wire(self, path: Path, x1: float, y1: float, x2: float, y2: float) -> Path: + """Append a wire to the schematic file.""" + import uuid + + wire_sexp = f""" + (wire (pts (xy {x1} {y1}) (xy {x2} {y2})) + (stroke (width 0) (type default)) + (uuid "{uuid.uuid4()}") + )""" + content = path.read_text(encoding="utf-8") + idx = content.rfind(")") + path.write_text(content[:idx] + "\n" + wire_sexp + "\n)", encoding="utf-8") + return path + + def _parse_wires(self, path: Path) -> Any: + """Return list of ((x1,y1),(x2,y2)) for every wire in the file.""" + content = path.read_text(encoding="utf-8") + data = sexpdata.loads(content) + wires = [] + for item in data: + if not (isinstance(item, list) and item and item[0] == Symbol("wire")): + continue + pts = next( + (s for s in item[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")), + None, + ) + if pts is None: + continue + xys = [ + p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy") + ] + if len(xys) >= 2: + wires.append( + ( + (float(xys[0][1]), float(xys[0][2])), + (float(xys[-1][1]), float(xys[-1][2])), + ) + ) + return wires + + def _get_symbol_pos(self, path: Path, ref: str) -> Any: + content = path.read_text(encoding="utf-8") + data = sexpdata.loads(content) + found = WireDragger.find_symbol(data, ref) + if found is None: + return None + return found[1], found[2] + + def test_symbol_position_updated(self) -> None: + sch = self._make_schematic() + self._add_resistor(sch, "R1", 100, 100) + # Call handler directly + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 120, "y": 130}, + }, + ) + assert result["success"], result.get("message") + pos = self._get_symbol_pos(sch, "R1") + assert abs(pos[0] - 120) < EPS + assert abs(pos[1] - 130) < EPS + + def test_connected_wire_endpoint_follows_pin(self) -> None: + """Wire endpoint at pin 1 of R1 should move with the component.""" + sch = self._make_schematic() + # R1 at (100, 100) — pin 1 at (100, 103.81) + self._add_resistor(sch, "R1", 100, 100) + self._add_wire(sch, 100, 103.81, 100, 120) # wire from pin 1 upward + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 100}, + }, + ) + assert result["success"], result.get("message") + assert result["wiresMoved"] >= 1 + + wires = self._parse_wires(sch) + assert len(wires) == 1 + # Pin 1 new world position: (110 + 0, 100 + 3.81) = (110, 103.81) + w = wires[0] + endpoints = {w[0], w[1]} + new_pin1 = (110.0, 103.81) + assert any( + abs(ep[0] - new_pin1[0]) < 0.01 and abs(ep[1] - new_pin1[1]) < 0.01 for ep in endpoints + ), f"Expected pin endpoint near {new_pin1}, got {endpoints}" + + def test_unrelated_wire_unchanged(self) -> None: + """A wire not connected to R1 must not be modified.""" + sch = self._make_schematic() + self._add_resistor(sch, "R1", 100, 100) + self._add_wire(sch, 50, 50, 60, 50) # unrelated wire + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 110}, + }, + ) + + wires = self._parse_wires(sch) + unrelated = [(s, e) for s, e in wires if abs(s[0] - 50) < 0.01 and abs(s[1] - 50) < 0.01] + assert len(unrelated) == 1 + + def test_no_zero_length_wires_after_move(self) -> None: + """No zero-length wires should appear in the file after a move.""" + sch = self._make_schematic() + self._add_resistor(sch, "R1", 100, 100) + # Wire from pin 1 to pin 2 of same component (intra-component wire) + self._add_wire(sch, 100, 103.81, 100, 96.19) + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 100}, + }, + ) + + wires = self._parse_wires(sch) + for start, end in wires: + assert not ( + abs(start[0] - end[0]) < EPS and abs(start[1] - end[1]) < EPS + ), f"Zero-length wire found at {start}" + + def test_preserve_wires_false_skips_wire_update(self) -> None: + """preserveWires=False should move the symbol but leave wires alone.""" + sch = self._make_schematic() + self._add_resistor(sch, "R1", 100, 100) + self._add_wire(sch, 100, 103.81, 100, 120) + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 100}, + "preserveWires": False, + }, + ) + assert result["success"] + assert result["wiresMoved"] == 0 + + # Wire should still start at old pin position + wires = self._parse_wires(sch) + assert len(wires) == 1 + endpoints = {wires[0][0], wires[0][1]} + old_pin1 = (100.0, 103.81) + assert any( + abs(ep[0] - old_pin1[0]) < 0.01 and abs(ep[1] - old_pin1[1]) < 0.01 for ep in endpoints + ), f"Wire should still be at {old_pin1}, got {endpoints}" + + def test_missing_component_returns_error(self) -> None: + sch = self._make_schematic() + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "NOTHERE", + "position": {"x": 0, "y": 0}, + }, + ) + assert not result["success"] + assert "not found" in result.get("message", "").lower() + + +# --------------------------------------------------------------------------- +# TestSynthesizeTouchingPinWires (unit) +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSynthesizeTouchingPinWires: + """Unit tests for WireDragger.synthesize_touching_pin_wires.""" + + def _make_two_resistors(self, r1_x: Any, r1_y: Any, r2_x: Any, r2_y: Any) -> Any: + """Build sch_data with R1 and R2, each Device:R.""" + return _make_sch_data( + [ + _make_symbol("R1", r1_x, r1_y), + _make_symbol("R2", r2_x, r2_y), + ] + ) + + def test_no_stationary_symbols_returns_zero(self) -> None: + """With only the moved component in sch_data, nothing is synthesized.""" + sch = _make_sch_data([_make_symbol("R1", 0, 0)]) + pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20) + count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) + assert count == 0 + + def test_touching_pin_gap_generates_wire(self) -> None: + """ + R1 at (0, 0) pin2 at (0, -3.81). + R2 at (0, -7.62) pin1 at (0, -3.81). ← pins touch + Moving R1 to (10, 0) causes pin2 to move to (10, -3.81). + A wire from (0, -3.81) to (10, -3.81) should be synthesized. + """ + # R2 pin1 is at (0, -7.62 + 3.81) = (0, -3.81) + sch = self._make_two_resistors(0, 0, 0, -7.62) + + # Verify the touching: R1 pin2 old = (0, -3.81), R2 pin1 = (0, -3.81) + pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0) + old2, new2 = pin_positions["2"] + assert abs(old2[0] - 0) < 1e-3 and abs(old2[1] - (-3.81)) < 1e-3 + assert abs(new2[0] - 10) < 1e-3 and abs(new2[1] - (-3.81)) < 1e-3 + + wire_count_before = sum( + 1 for item in sch if isinstance(item, list) and item and item[0] == _sym("wire") + ) + count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) + assert count == 1, f"Expected 1 synthesized wire, got {count}" + + wires = [ + item for item in sch if isinstance(item, list) and item and item[0] == _sym("wire") + ] + assert len(wires) == wire_count_before + 1 + + # The new wire should span (0, -3.81) → (10, -3.81) + new_wire = wires[-1] + pts = next(s for s in new_wire[1:] if isinstance(s, list) and s and s[0] == _sym("pts")) + xys = [p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == _sym("xy")] + assert len(xys) == 2 + endpoints = { + (round(float(xys[0][1]), 3), round(float(xys[0][2]), 3)), + (round(float(xys[1][1]), 3), round(float(xys[1][2]), 3)), + } + assert (0.0, -3.81) in endpoints, f"Expected (0, -3.81) in wire endpoints, got {endpoints}" + assert ( + 10.0, + -3.81, + ) in endpoints, f"Expected (10, -3.81) in wire endpoints, got {endpoints}" + + def test_no_wire_when_pin_didnt_move(self) -> None: + """ + If old_xy == new_xy for a touching pin (component moved but this pin stayed put), + no wire should be synthesized. + """ + # R1 at (0, 0), R2 at (0, -7.62) — pin2 of R1 and pin1 of R2 touch at (0, -3.81) + sch = self._make_two_resistors(0, 0, 0, -7.62) + # Moving R1 to (0, 0) — effectively no move, same position + pin_positions = WireDragger.compute_pin_positions(sch, "R1", 0, 0) + count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) + assert count == 0 + + def test_no_wire_when_rejoins_other_stationary_pin(self) -> None: + """ + If the moved pin's new position coincides with another stationary pin, + no wire should be synthesized (they touch again). + """ + # R1 at (0, 0), R2 at (0, -7.62), R3 at (10, -7.62) + # R1 pin2 was touching R2 pin1 at (0, -3.81). + # Moving R1 to (10, 0): pin2 lands at (10, -3.81) which is R3 pin1. + sch = _make_sch_data( + [ + _make_symbol("R1", 0, 0), + _make_symbol("R2", 0, -7.62), + _make_symbol("R3", 10, -7.62), + ] + ) + pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0) + count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) + assert count == 0, f"Expected 0 synthesized wires (rejoined), got {count}" + + def test_empty_pin_positions_returns_zero(self) -> None: + sch = _make_sch_data([_make_symbol("R1", 0, 0)]) + count = WireDragger.synthesize_touching_pin_wires(sch, "R1", {}) + assert count == 0 + + def test_non_touching_pins_not_affected(self) -> None: + """ + When R1 and R2 are NOT touching (different positions), no wire is synthesized. + """ + # R1 at (0, 0), R2 at (100, 100) — far apart + sch = self._make_two_resistors(0, 0, 100, 100) + pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0) + count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) + assert count == 0 + + +# --------------------------------------------------------------------------- +# TestOldToNewCollision (unit) — regression for the duplicate-pin-position bug +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestOldToNewCollision: + """Verify that coincident pins do not silently overwrite each other in old_to_new.""" + + def test_handler_logs_warning_on_collision(self, caplog: Any) -> None: + """ + When two pins share the same old position, a warning should be logged + and the *first* mapping should be kept (not overwritten by the second). + """ + import logging + + # Build a fake pin_positions dict with a deliberate collision + pin_positions = { + "1": ((0.0, 3.81), (10.0, 23.81)), + "2": ((0.0, 3.81), (10.0, 16.19)), # same old_xy as pin "1" + } + + old_to_new = {} + with caplog.at_level(logging.WARNING, logger="kicad_interface"): + for _pin, (old_xy, new_xy) in pin_positions.items(): + if old_xy in old_to_new: + import logging as _logging + + logger_inner = _logging.getLogger("kicad_interface") + logger_inner.warning( + f"move_schematic_component: pin {_pin!r} shares old position {old_xy} " + f"with another pin; keeping first entry, skipping duplicate" + ) + continue + old_to_new[old_xy] = new_xy + + # Only one entry should exist, and it should be the first one + assert len(old_to_new) == 1 + assert old_to_new[(0.0, 3.81)] == (10.0, 23.81) + # Warning should have been logged + assert any("skipping duplicate" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# TestTouchingPinIntegration (integration) +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestTouchingPinIntegration: + """Integration tests for pin-touching connection wire synthesis.""" + + def _make_schematic(self, extra_sexp: Any = "") -> Any: + """Copy empty.kicad_sch to a temp file.""" + 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 _add_resistor(self, path: Path, ref: str, x: float, y: float, rotation: float = 0) -> Path: + import uuid as _uuid + + u = str(_uuid.uuid4()) + sexp = f""" + (symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1) + (in_bom yes) (on_board yes) (dnp no) + (uuid "{u}") + (property "Reference" "{ref}" (at {x + 2.032} {y} 90) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "10k" (at {x} {y} 90) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at {x - 1.778} {y} 90) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at {x} {y} 0) + (effects (font (size 1.27 1.27)) hide) + ) + (pin "1" (uuid "{_uuid.uuid4()}")) + (pin "2" (uuid "{_uuid.uuid4()}")) + (instances (project "test" (path "/" (reference "{ref}") (unit 1)))) + )""" + content = path.read_text(encoding="utf-8") + idx = content.rfind(")") + path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8") + return path + + def _parse_wires(self, path: Path) -> Any: + content = path.read_text(encoding="utf-8") + data = sexpdata.loads(content) + wires = [] + for item in data: + if not (isinstance(item, list) and item and item[0] == Symbol("wire")): + continue + pts = next( + (s for s in item[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")), + None, + ) + if pts is None: + continue + xys = [ + p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy") + ] + if len(xys) >= 2: + wires.append( + ( + (float(xys[0][1]), float(xys[0][2])), + (float(xys[-1][1]), float(xys[-1][2])), + ) + ) + return wires + + def test_touching_pin_wire_created_on_move(self) -> None: + """ + R1 at (100, 100) and R2 at (100, 92.38) share a touching pin: + R1 pin2 = (100, 96.19), R2 pin1 = (100, 96.19). + Moving R1 to (110, 100) should synthesize a wire from (100, 96.19) to (110, 96.19). + """ + sch = self._make_schematic() + # R1 pin2 world position = 100 + (-3.81) = 96.19 + # R2 pin1 world position = 92.38 + 3.81 = 96.19 + self._add_resistor(sch, "R1", 100, 100) + self._add_resistor(sch, "R2", 100, 92.38) + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 100}, + }, + ) + assert result["success"], result.get("message") + assert ( + result.get("wiresSynthesized", 0) >= 1 + ), f"Expected at least 1 synthesized wire, got {result}" + + wires = self._parse_wires(sch) + # There should be a wire bridging the old and new pin2 positions + old_pin2 = (100.0, 96.19) + new_pin2 = (110.0, 96.19) + bridging = [ + (s, e) + for s, e in wires + if ( + ( + abs(s[0] - old_pin2[0]) < 0.05 + and abs(s[1] - old_pin2[1]) < 0.05 + and abs(e[0] - new_pin2[0]) < 0.05 + and abs(e[1] - new_pin2[1]) < 0.05 + ) + or ( + abs(e[0] - old_pin2[0]) < 0.05 + and abs(e[1] - old_pin2[1]) < 0.05 + and abs(s[0] - new_pin2[0]) < 0.05 + and abs(s[1] - new_pin2[1]) < 0.05 + ) + ) + ] + assert ( + len(bridging) >= 1 + ), f"Expected a bridging wire from {old_pin2} to {new_pin2}, got wires: {wires}" + + def test_no_wire_synthesized_when_no_touching_pins(self) -> None: + """ + Two resistors with no pin overlap should not generate any synthesized wires. + """ + sch = self._make_schematic() + self._add_resistor(sch, "R1", 100, 100) + self._add_resistor(sch, "R2", 150, 150) # far away + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 100}, + }, + ) + assert result["success"], result.get("message") + assert result.get("wiresSynthesized", 0) == 0 + + def test_existing_wires_still_dragged_with_touching_pins(self) -> None: + """ + When R1 has both an explicit wire AND a touching-pin connection, + both should be handled: the wire dragged and the touching-pin bridged. + """ + sch = self._make_schematic() + # R1 at (100, 100), R2 at (100, 92.38) — pin2 of R1 touches pin1 of R2 + self._add_resistor(sch, "R1", 100, 100) + self._add_resistor(sch, "R2", 100, 92.38) + + # Also add an explicit wire at pin1 of R1 (100, 103.81) going up + import uuid as _uuid + + wire_sexp = f""" + (wire (pts (xy 100 103.81) (xy 100 115)) + (stroke (width 0) (type default)) + (uuid "{_uuid.uuid4()}") + )""" + content = sch.read_text(encoding="utf-8") + idx = content.rfind(")") + sch.write_text(content[:idx] + "\n" + wire_sexp + "\n)", encoding="utf-8") + + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from kicad_interface import KiCADInterface + + iface = KiCADInterface() + result = iface.handle_command( + "move_schematic_component", + { + "schematicPath": str(sch), + "reference": "R1", + "position": {"x": 110, "y": 100}, + }, + ) + assert result["success"], result.get("message") + assert result.get("wiresMoved", 0) >= 1, "Expected at least one wire endpoint dragged" + assert result.get("wiresSynthesized", 0) >= 1, "Expected at least one touching-pin wire" diff --git a/python/tests/test_schematic_analysis.py b/python/tests/test_schematic_analysis.py index 45158e3..333058b 100644 --- a/python/tests/test_schematic_analysis.py +++ b/python/tests/test_schematic_analysis.py @@ -1,948 +1,948 @@ -""" -Tests for schematic analysis tools (Tools 2–5). - -Unit tests use mock data / synthetic S-expressions. -Integration tests parse real .kicad_sch files via sexpdata. -""" - -import os -import shutil -import sys -import tempfile -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -import sexpdata -from sexpdata import Symbol - -# Ensure the python/ package is importable -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from commands.schematic_analysis import ( - _aabb_overlap, - _check_wire_overlap, - _compute_symbol_bbox_direct, - _distance, - _extract_lib_symbols, - _line_segment_intersects_aabb, - _load_sexp, - _parse_labels, - _parse_lib_symbol_graphics, - _parse_symbols, - _parse_wires, - _point_in_rect, - _transform_local_point, - compute_symbol_bbox, - find_overlapping_elements, - find_wires_crossing_symbols, - get_elements_in_region, -) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "empty.kicad_sch" - - -def _make_temp_schematic(extra_sexp: str = "") -> Path: - """Copy empty.kicad_sch to a temp file and optionally append S-expression content.""" - tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" - shutil.copy(TEMPLATE_PATH, tmp) - if extra_sexp: - content = tmp.read_text(encoding="utf-8") - # Insert before the final closing paren - idx = content.rfind(")") - content = content[:idx] + "\n" + extra_sexp + "\n)" - tmp.write_text(content, encoding="utf-8") - return tmp - - -import uuid as _uuid - - -def _make_resistor_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str: - """Generate a proper Device:R symbol S-expression that skip can parse.""" - u = str(_uuid.uuid4()) - return f""" - (symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1) - (in_bom yes) (on_board yes) (dnp no) - (uuid "{u}") - (property "Reference" "{ref}" (at {x + 2.032} {y} 90) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "10k" (at {x} {y} 90) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at {x - 1.778} {y} 90) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at {x} {y} 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid "{_uuid.uuid4()}")) - (pin "2" (uuid "{_uuid.uuid4()}")) - (instances - (project "test" - (path "/" (reference "{ref}") (unit 1)) - ) - ) - ) -""" - - -def _make_led_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str: - """Generate a proper Device:LED symbol S-expression (horizontal pin spread).""" - u = str(_uuid.uuid4()) - return f""" - (symbol (lib_id "Device:LED") (at {x} {y} {rotation}) (unit 1) - (in_bom yes) (on_board yes) (dnp no) - (uuid "{u}") - (property "Reference" "{ref}" (at {x} {y - 2.54} 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "LED" (at {x} {y + 2.54} 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at {x} {y} 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at {x} {y} 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid "{_uuid.uuid4()}")) - (pin "2" (uuid "{_uuid.uuid4()}")) - (instances - (project "test" - (path "/" (reference "{ref}") (unit 1)) - ) - ) - ) -""" - - -# =================================================================== -# Unit tests — geometry helpers -# =================================================================== - - -class TestGeometryHelpers: - """Test low-level geometry utilities.""" - - def test_point_in_rect_inside(self): - assert _point_in_rect(5, 5, 0, 0, 10, 10) is True - - def test_point_in_rect_outside(self): - assert _point_in_rect(15, 5, 0, 0, 10, 10) is False - - def test_point_in_rect_boundary(self): - assert _point_in_rect(0, 0, 0, 0, 10, 10) is True - - def test_distance_zero(self): - assert _distance((0, 0), (0, 0)) == 0 - - def test_distance_unit(self): - assert abs(_distance((0, 0), (3, 4)) - 5.0) < 1e-9 - - def test_aabb_intersection_crossing(self): - # Line from (0,5) to (10,5) should intersect box (2,2)-(8,8) - assert _line_segment_intersects_aabb(0, 5, 10, 5, 2, 2, 8, 8) is True - - def test_aabb_intersection_miss(self): - # Line from (0,0) to (10,0) should miss box (2,2)-(8,8) - assert _line_segment_intersects_aabb(0, 0, 10, 0, 2, 2, 8, 8) is False - - def test_aabb_intersection_inside(self): - # Line entirely inside the box - assert _line_segment_intersects_aabb(3, 3, 7, 7, 2, 2, 8, 8) is True - - def test_aabb_intersection_diagonal(self): - # Diagonal line crossing through box - assert _line_segment_intersects_aabb(0, 0, 10, 10, 2, 2, 8, 8) is True - - def test_aabb_intersection_parallel_outside(self): - # Horizontal line above the box - assert _line_segment_intersects_aabb(0, 9, 10, 9, 2, 2, 8, 8) is False - - def test_aabb_intersection_touching_edge(self): - # Line ending exactly at box edge - assert _line_segment_intersects_aabb(0, 2, 2, 2, 2, 2, 8, 8) is True - - -# =================================================================== -# Unit tests — S-expression parsers -# =================================================================== - - -class TestSexpParsers: - """Test S-expression parsing functions with synthetic data.""" - - def test_parse_wires_basic(self): - sexp = sexpdata.loads("""(kicad_sch - (wire (pts (xy 10 20) (xy 30 40)) - (stroke (width 0) (type default)) - (uuid "abc")) - )""") - wires = _parse_wires(sexp) - assert len(wires) == 1 - assert wires[0]["start"] == (10.0, 20.0) - assert wires[0]["end"] == (30.0, 40.0) - - def test_parse_wires_empty(self): - sexp = sexpdata.loads("(kicad_sch)") - assert _parse_wires(sexp) == [] - - def test_parse_labels_both_types(self): - sexp = sexpdata.loads("""(kicad_sch - (label "VCC" (at 10 20 0)) - (global_label "GND" (at 30 40 0)) - )""") - labels = _parse_labels(sexp) - assert len(labels) == 2 - assert labels[0]["name"] == "VCC" - assert labels[0]["type"] == "label" - assert labels[1]["name"] == "GND" - assert labels[1]["type"] == "global_label" - - def test_parse_symbols(self): - sexp = sexpdata.loads("""(kicad_sch - (symbol (lib_id "Device:R") (at 100 100 0) - (property "Reference" "R1" (at 0 0 0))) - (symbol (lib_id "power:VCC") (at 50 50 0) - (property "Reference" "#PWR01" (at 0 0 0))) - )""") - symbols = _parse_symbols(sexp) - assert len(symbols) == 2 - assert symbols[0]["reference"] == "R1" - assert symbols[0]["is_power"] is False - assert symbols[1]["reference"] == "#PWR01" - assert symbols[1]["is_power"] is True - - -# =================================================================== -# Unit tests — analysis functions with mocked PinLocator -# =================================================================== - - -class TestAABBOverlap: - """Test AABB overlap helper.""" - - def test_overlapping_boxes(self): - assert _aabb_overlap((0, 0, 10, 10), (5, 5, 15, 15)) is True - - def test_non_overlapping_boxes(self): - assert _aabb_overlap((0, 0, 10, 10), (20, 20, 30, 30)) is False - - def test_touching_boxes_no_overlap(self): - # Touching edges are not overlapping (strict inequality) - assert _aabb_overlap((0, 0, 10, 10), (10, 0, 20, 10)) is False - - def test_contained_box(self): - assert _aabb_overlap((0, 0, 20, 20), (5, 5, 15, 15)) is True - - def test_overlap_one_axis_only(self): - # Overlap in X but not Y - assert _aabb_overlap((0, 0, 10, 10), (5, 15, 15, 25)) is False - - -class TestFindOverlappingElements: - """Test overlapping detection logic.""" - - def test_no_overlaps_in_empty_schematic(self): - tmp = _make_temp_schematic() - result = find_overlapping_elements(tmp, tolerance=0.5) - assert result["totalOverlaps"] == 0 - - def test_overlapping_symbols_detected(self): - # Two resistors at nearly the same position — bboxes fully overlap - extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100.1, 100) - tmp = _make_temp_schematic(extra) - result = find_overlapping_elements(tmp, tolerance=0.5) - assert result["totalOverlaps"] >= 1 - assert len(result["overlappingSymbols"]) >= 1 - - def test_well_separated_symbols_not_flagged(self): - extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 200, 200) - tmp = _make_temp_schematic(extra) - result = find_overlapping_elements(tmp, tolerance=0.5) - assert result["totalOverlaps"] == 0 - - def test_collinear_wire_overlap(self): - extra = """ - (wire (pts (xy 10 50) (xy 30 50)) - (stroke (width 0) (type default)) - (uuid "w1")) - (wire (pts (xy 20 50) (xy 40 50)) - (stroke (width 0) (type default)) - (uuid "w2")) - """ - tmp = _make_temp_schematic(extra) - result = find_overlapping_elements(tmp, tolerance=0.5) - assert len(result["overlappingWires"]) >= 1 - - def test_overlapping_bodies_different_centers(self): - """Two resistors whose bodies overlap even though centers are ~5mm apart. - - Device:R pins are at y ±3.81 relative to center, so the body spans - ~7.62mm vertically. Two resistors at the same X but 5mm apart in Y - have overlapping bodies — this is the bug the center-distance approach missed. - """ - # R1 at y=100, R2 at y=105 — pin spans [96.19, 103.81] and [101.19, 108.81] - # These overlap in Y from 101.19 to 103.81 - extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100, 105) - tmp = _make_temp_schematic(extra) - result = find_overlapping_elements(tmp, tolerance=0.5) - assert result["totalOverlaps"] >= 1, ( - "Should detect overlap when component bodies intersect, " - "even if centers are far apart" - ) - assert len(result["overlappingSymbols"]) >= 1 - - def test_adjacent_resistors_no_overlap(self): - """Two vertical resistors side by side should not overlap. - - R pins at y ±3.81, but different X positions far enough apart. - """ - extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 110, 100) - tmp = _make_temp_schematic(extra) - result = find_overlapping_elements(tmp, tolerance=0.5) - assert result["totalOverlaps"] == 0 - - def test_resistor_and_led_overlapping_bodies(self): - """A resistor and an LED placed close enough that bodies overlap. - - LED pins at x ±3.81, R pins at y ±3.81. Place LED at same position - as R — bodies clearly overlap. - """ - extra = _make_resistor_sexp("R1", 100, 100) + _make_led_sexp("D1", 100, 100) - tmp = _make_temp_schematic(extra) - result = find_overlapping_elements(tmp, tolerance=0.5) - assert result["totalOverlaps"] >= 1 - - -class TestGetElementsInRegion: - """Test region query logic.""" - - def test_elements_inside_region_found(self): - extra = """ - (symbol (lib_id "Device:R") (at 50 50 0) - (property "Reference" "R1" (at 0 0 0)) - (property "Value" "10k" (at 0 0 0))) - (wire (pts (xy 45 50) (xy 55 50)) - (stroke (width 0) (type default)) - (uuid "w1")) - (label "NET1" (at 50 50 0)) - """ - tmp = _make_temp_schematic(extra) - result = get_elements_in_region(tmp, 40, 40, 60, 60) - assert result["counts"]["symbols"] >= 1 - assert result["counts"]["wires"] >= 1 - assert result["counts"]["labels"] >= 1 - - def test_elements_outside_region_excluded(self): - extra = """ - (symbol (lib_id "Device:R") (at 200 200 0) - (property "Reference" "R1" (at 0 0 0)) - (property "Value" "10k" (at 0 0 0))) - """ - tmp = _make_temp_schematic(extra) - result = get_elements_in_region(tmp, 0, 0, 50, 50) - assert result["counts"]["symbols"] == 0 - - -class TestComputeSymbolBbox: - """Test bounding box computation.""" - - def test_returns_none_for_unknown_symbol(self): - tmp = _make_temp_schematic() - from commands.pin_locator import PinLocator - - locator = PinLocator() - result = compute_symbol_bbox(tmp, "NONEXISTENT", locator) - assert result is None - - -# =================================================================== -# Integration tests — full schematic parsing -# =================================================================== - - -@pytest.mark.integration -class TestIntegrationFindWiresCrossingSymbols: - """Integration test for wire crossing symbol detection.""" - - def test_wire_not_touching_pins_is_collision(self): - """A wire passing through a component bbox without pin contact → collision.""" - # LED D1 at (100,100) → pin 1 at (96.19, 100), pin 2 at (103.81, 100) - # Vertical wire from (100, 95) to (100, 105) crosses through the body - # without touching either horizontal pin - extra = _make_led_sexp("D1", 100, 100) + """ - (wire (pts (xy 100 95) (xy 100 105)) - (stroke (width 0) (type default)) - (uuid "w1")) - """ - tmp = _make_temp_schematic(extra) - result = find_wires_crossing_symbols(tmp) - d1_collisions = [c for c in result if c["component"]["reference"] == "D1"] - assert len(d1_collisions) >= 1 - - def test_unannotated_duplicates_not_over_reported(self): - """ - Regression: two components with the same unannotated reference ("R?") at - different positions should each produce independent bounding boxes. - A wire crossing only one of them must produce exactly 1 collision, not 2. - - Before the fix, PinLocator.get_all_symbol_pins always resolved "R?" to - the first match, so both symbols got identical bboxes and the same wire - was counted against both. - """ - # R? at (100, 100): Device:R pins are at (100, 96.19) and (100, 103.81). - # Effective bbox (after expansion + margin) ≈ x=[99,101], y=[96.69,103.31]. - # R? at (200, 100): identical type but far away → no intersection with wire. - r_at_100 = _make_resistor_sexp("R?", 100, 100) - r_at_200 = _make_resistor_sexp("R?", 200, 100) - # Horizontal wire crossing the body of the first R? only - wire = """ - (wire (pts (xy 95 100) (xy 105 100)) - (stroke (width 0) (type default)) - (uuid "w-collision")) - """ - tmp = _make_temp_schematic(r_at_100 + r_at_200 + wire) - result = find_wires_crossing_symbols(tmp) - # The wire must not be reported against the far-away R? at (200, 100) - collisions_at_200 = [c for c in result if abs(c["component"]["position"]["x"] - 200) < 0.5] - assert len(collisions_at_200) == 0, ( - "Wire at x≈100 must not be flagged against the R? at x=200; " - "likely caused by reference-lookup always returning the first 'R?'" - ) - - def test_wire_starting_at_pin_passing_through_body(self): - """A wire that starts at a pin but continues through the component body - must be flagged — this is the core bug where the old suppression logic - treated any wire touching a pin as a valid connection.""" - # LED D1 at (100,100) → pin 1 at (96.19, 100), pin 2 at (103.81, 100) - # Wire starts exactly at pin 1 and extends through the body to the right - extra = _make_led_sexp("D1", 100, 100) + """ - (wire (pts (xy 96.19 100) (xy 110 100)) - (stroke (width 0) (type default)) - (uuid "w-through")) - """ - tmp = _make_temp_schematic(extra) - result = find_wires_crossing_symbols(tmp) - d1_crossings = [c for c in result if c["component"]["reference"] == "D1"] - assert ( - len(d1_crossings) >= 1 - ), "Wire starting at pin but passing through body must be detected" - - def test_wire_terminating_at_pin_from_outside(self): - """A wire that arrives at a pin from outside the component body - is a valid connection and must NOT be flagged.""" - # LED D1 at (100,100) → pin 1 at (96.19, 100) - # Wire comes from the left and terminates at pin 1 - extra = _make_led_sexp("D1", 100, 100) + """ - (wire (pts (xy 80 100) (xy 96.19 100)) - (stroke (width 0) (type default)) - (uuid "w-valid")) - """ - tmp = _make_temp_schematic(extra) - result = find_wires_crossing_symbols(tmp) - d1_crossings = [c for c in result if c["component"]["reference"] == "D1"] - assert len(d1_crossings) == 0, "Wire terminating at pin from outside should not be flagged" - - def test_wire_shorts_component_pins_detected_as_collision(self): - """Regression: a wire connecting pin1→pin2 of the same component - must be reported even though both endpoints land on pins.""" - r_sexp = _make_resistor_sexp("R_short", 100.0, 100.0) - wire_sexp = ( - "(wire (pts (xy 100 103.81) (xy 100 96.19))\n" - " (stroke (width 0) (type default))\n" - ' (uuid "aaaaaaaa-0000-0000-0000-000000000001"))' - ) - sch = _make_temp_schematic(r_sexp + "\n" + wire_sexp) - collisions = find_wires_crossing_symbols(sch) - assert len(collisions) == 1 - w = collisions[0]["wire"] - assert w["start"]["x"] == pytest.approx(100.0) - assert w["start"]["y"] == pytest.approx(103.81) - assert collisions[0]["component"]["reference"] == "R_short" - - -@pytest.mark.integration -class TestIntegrationGetElementsInRegion: - """Integration test for region query.""" - - def test_region_returns_pin_data(self): - """Symbols in region should include pin position data.""" - extra = _make_resistor_sexp("R1", 100, 100) - tmp = _make_temp_schematic(extra) - result = get_elements_in_region(tmp, 90, 90, 110, 110) - assert result["counts"]["symbols"] == 1 - sym = result["symbols"][0] - assert "pins" in sym - assert len(sym["pins"]) == 2 # Resistor has 2 pins - - def test_wire_passing_through_region_included(self): - """A wire that passes through a region (no endpoints inside) should be included.""" - extra = """ - (wire (pts (xy 0 50) (xy 100 50)) - (stroke (width 0) (type default)) - (uuid "w-through")) - """ - tmp = _make_temp_schematic(extra) - result = get_elements_in_region(tmp, 40, 40, 60, 60) - assert result["counts"]["wires"] == 1 - - def test_wire_outside_region_excluded(self): - """A wire entirely outside a region should not be included.""" - extra = """ - (wire (pts (xy 0 0) (xy 10 0)) - (stroke (width 0) (type default)) - (uuid "w-outside")) - """ - tmp = _make_temp_schematic(extra) - result = get_elements_in_region(tmp, 40, 40, 60, 60) - assert result["counts"]["wires"] == 0 - - -# =================================================================== -# Unit tests — _check_wire_overlap -# =================================================================== - - -class TestCheckWireOverlap: - """Test wire overlap detection for horizontal, vertical, and diagonal cases.""" - - def test_horizontal_overlap(self): - w1 = {"start": (10, 50), "end": (30, 50)} - w2 = {"start": (20, 50), "end": (40, 50)} - result = _check_wire_overlap(w1, w2, 0.5) - assert result is not None - assert result["type"] == "collinear_overlap" - - def test_vertical_overlap(self): - w1 = {"start": (50, 10), "end": (50, 30)} - w2 = {"start": (50, 20), "end": (50, 40)} - result = _check_wire_overlap(w1, w2, 0.5) - assert result is not None - assert result["type"] == "collinear_overlap" - - def test_diagonal_overlap(self): - w1 = {"start": (0, 0), "end": (20, 20)} - w2 = {"start": (10, 10), "end": (30, 30)} - result = _check_wire_overlap(w1, w2, 0.5) - assert result is not None - assert result["type"] == "collinear_overlap" - - def test_horizontal_no_overlap(self): - w1 = {"start": (10, 50), "end": (20, 50)} - w2 = {"start": (30, 50), "end": (40, 50)} - result = _check_wire_overlap(w1, w2, 0.5) - assert result is None - - def test_parallel_offset_no_overlap(self): - """Two parallel wires offset perpendicularly should not overlap.""" - w1 = {"start": (0, 0), "end": (20, 20)} - w2 = {"start": (0, 5), "end": (20, 25)} - result = _check_wire_overlap(w1, w2, 0.5) - assert result is None - - def test_non_parallel_no_overlap(self): - """Two wires at different angles should not overlap.""" - w1 = {"start": (0, 0), "end": (10, 10)} - w2 = {"start": (0, 0), "end": (10, 0)} - result = _check_wire_overlap(w1, w2, 0.5) - assert result is None - - def test_zero_length_segment(self): - w1 = {"start": (10, 10), "end": (10, 10)} - w2 = {"start": (10, 10), "end": (20, 20)} - result = _check_wire_overlap(w1, w2, 0.5) - assert result is None - - -@pytest.mark.integration -class TestIntegrationDiagonalWireOverlap: - """Integration tests for diagonal collinear wire overlap detection.""" - - def test_diagonal_collinear_wire_overlap(self): - """Two 45-degree wires that overlap should be detected.""" - extra = """ - (wire (pts (xy 0 0) (xy 20 20)) - (stroke (width 0) (type default)) - (uuid "w-diag1")) - (wire (pts (xy 10 10) (xy 30 30)) - (stroke (width 0) (type default)) - (uuid "w-diag2")) - """ - tmp = _make_temp_schematic(extra) - result = find_overlapping_elements(tmp, tolerance=0.5) - assert len(result["overlappingWires"]) >= 1 - - def test_diagonal_parallel_no_overlap(self): - """Two parallel 45-degree wires that are offset should not overlap.""" - extra = """ - (wire (pts (xy 0 0) (xy 20 20)) - (stroke (width 0) (type default)) - (uuid "w-diag1")) - (wire (pts (xy 0 5) (xy 20 25)) - (stroke (width 0) (type default)) - (uuid "w-diag2")) - """ - tmp = _make_temp_schematic(extra) - result = find_overlapping_elements(tmp, tolerance=0.5) - assert len(result["overlappingWires"]) == 0 - - def test_diagonal_non_collinear_no_overlap(self): - """Two wires at different angles crossing should not be flagged as collinear overlap.""" - extra = """ - (wire (pts (xy 0 0) (xy 20 20)) - (stroke (width 0) (type default)) - (uuid "w-diag1")) - (wire (pts (xy 0 20) (xy 20 0)) - (stroke (width 0) (type default)) - (uuid "w-diag2")) - """ - tmp = _make_temp_schematic(extra) - result = find_overlapping_elements(tmp, tolerance=0.5) - assert len(result["overlappingWires"]) == 0 - - -# =================================================================== -# Unit tests — _extract_lib_symbols -# =================================================================== - - -class TestExtractLibSymbols: - """Test _extract_lib_symbols helper.""" - - def test_extracts_pins_from_lib_symbols(self): - sexp = sexpdata.loads("""(kicad_sch - (lib_symbols - (symbol "Device:R" - (symbol "Device:R_0_1" - (pin passive (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 (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))))))) - ) - )""") - result = _extract_lib_symbols(sexp) - assert "Device:R" in result - pins = result["Device:R"]["pins"] - assert "1" in pins - assert "2" in pins - assert pins["1"]["y"] == pytest.approx(3.81) - - def test_empty_schematic_returns_empty(self): - sexp = sexpdata.loads("(kicad_sch)") - result = _extract_lib_symbols(sexp) - assert result == {} - - def test_no_lib_symbols_section(self): - sexp = sexpdata.loads("""(kicad_sch - (wire (pts (xy 0 0) (xy 10 10))) - )""") - result = _extract_lib_symbols(sexp) - assert result == {} - - def test_extract_includes_graphics_points(self): - """_extract_lib_symbols should return graphics_points from body shapes.""" - sexp = sexpdata.loads("""(kicad_sch - (lib_symbols - (symbol "Device:R" - (symbol "Device:R_0_1" - (rectangle (start -1.016 -2.54) (end 1.016 2.54) - (stroke (width 0.254) (type default)) - (fill (type none)))) - (symbol "Device: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))))))) - ) - )""") - result = _extract_lib_symbols(sexp) - lib_data = result["Device:R"] - assert "graphics_points" in lib_data - gfx = lib_data["graphics_points"] - assert len(gfx) >= 2 - # Rectangle corners should be present - xs = [p[0] for p in gfx] - ys = [p[1] for p in gfx] - assert pytest.approx(-1.016) in xs - assert pytest.approx(1.016) in xs - assert pytest.approx(-2.54) in ys - assert pytest.approx(2.54) in ys - - -# =================================================================== -# Unit tests — _parse_lib_symbol_graphics -# =================================================================== - - -class TestParseLibSymbolGraphics: - """Test graphics extraction from lib_symbol definitions.""" - - def test_rectangle(self): - sexp = sexpdata.loads("""(symbol "Device:R" - (symbol "Device:R_0_1" - (rectangle (start -1.016 -2.54) (end 1.016 2.54) - (stroke (width 0.254) (type default)) - (fill (type none)))))""") - pts = _parse_lib_symbol_graphics(sexp) - assert len(pts) == 2 - assert (-1.016, -2.54) in pts - assert (1.016, 2.54) in pts - - def test_polyline(self): - sexp = sexpdata.loads("""(symbol "Device:C" - (symbol "Device:C_0_1" - (polyline - (pts (xy -2.032 -0.762) (xy 2.032 -0.762)) - (stroke (width 0.508) (type default)) - (fill (type none)))))""") - pts = _parse_lib_symbol_graphics(sexp) - assert (-2.032, -0.762) in pts - assert (2.032, -0.762) in pts - - def test_circle(self): - sexp = sexpdata.loads("""(symbol "Test:Circle" - (symbol "Test:Circle_0_1" - (circle (center 0 0) (radius 5) - (stroke (width 0.254) (type default)) - (fill (type none)))))""") - pts = _parse_lib_symbol_graphics(sexp) - assert len(pts) == 2 - assert (-5.0, -5.0) in pts - assert (5.0, 5.0) in pts - - def test_arc(self): - sexp = sexpdata.loads("""(symbol "Test:Arc" - (symbol "Test:Arc_0_1" - (arc (start 1 0) (mid 0 1) (end -1 0) - (stroke (width 0.254) (type default)) - (fill (type none)))))""") - pts = _parse_lib_symbol_graphics(sexp) - assert (1.0, 0.0) in pts - assert (0.0, 1.0) in pts - assert (-1.0, 0.0) in pts - - def test_no_graphics(self): - sexp = sexpdata.loads("""(symbol "Test:Empty" - (symbol "Test:Empty_1_1" - (pin passive line (at 0 0 0) (length 1.27) - (name "~" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))))))""") - pts = _parse_lib_symbol_graphics(sexp) - assert pts == [] - - -# =================================================================== -# Unit tests — _transform_local_point -# =================================================================== - - -class TestTransformLocalPoint: - """Test local→absolute coordinate transform.""" - - def test_no_transform(self): - # ly is negated (lib y-up → schematic y-down) - x, y = _transform_local_point(1.0, 2.0, 100.0, 200.0, 0, False, False) - assert x == pytest.approx(101.0) - assert y == pytest.approx(198.0) - - def test_mirror_x(self): - # y-negate then mirror_x cancel out → net ly unchanged - x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, True, False) - assert x == pytest.approx(1.0) - assert y == pytest.approx(2.0) - - def test_mirror_y(self): - x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, False, True) - assert x == pytest.approx(-1.0) - assert y == pytest.approx(-2.0) - - def test_rotation_90(self): - # ly=0 negated is still 0, then rotate lx=1 by 90° - x, y = _transform_local_point(1.0, 0.0, 0.0, 0.0, 90, False, False) - assert x == pytest.approx(0.0, abs=1e-9) - assert y == pytest.approx(1.0, abs=1e-9) - - -# =================================================================== -# Unit tests — _compute_symbol_bbox_direct with graphics -# =================================================================== - - -class TestComputeSymbolBboxWithGraphics: - """Test that bounding box computation uses graphics points when available.""" - - def test_resistor_bbox_from_graphics(self): - """Device:R rectangle is (-1.016, -2.54) to (1.016, 2.54) in local coords. - Pins at (0, ±3.81). Placed at (100, 100) with no rotation. - Bbox should span from pin-to-pin in Y and use rectangle width in X.""" - sym = { - "x": 100.0, - "y": 100.0, - "rotation": 0, - "mirror_x": False, - "mirror_y": False, - } - pin_defs = { - "1": { - "x": 0, - "y": 3.81, - "angle": 270, - "length": 1.27, - "name": "~", - "type": "passive", - }, - "2": { - "x": 0, - "y": -3.81, - "angle": 90, - "length": 1.27, - "name": "~", - "type": "passive", - }, - } - graphics_points = [(-1.016, -2.54), (1.016, 2.54)] - - bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) - assert bbox is not None - min_x, min_y, max_x, max_y = bbox - # X should come from rectangle: 100 ± 1.016 - assert min_x == pytest.approx(100 - 1.016) - assert max_x == pytest.approx(100 + 1.016) - # Y should come from pins (extending beyond rectangle): 100 ± 3.81 - assert min_y == pytest.approx(100 - 3.81) - assert max_y == pytest.approx(100 + 3.81) - - def test_fallback_without_graphics(self): - """Without graphics_points, should use the old degenerate expansion.""" - sym = { - "x": 100.0, - "y": 100.0, - "rotation": 0, - "mirror_x": False, - "mirror_y": False, - } - pin_defs = { - "1": { - "x": 0, - "y": 3.81, - "angle": 270, - "length": 1.27, - "name": "~", - "type": "passive", - }, - "2": { - "x": 0, - "y": -3.81, - "angle": 90, - "length": 1.27, - "name": "~", - "type": "passive", - }, - } - - bbox = _compute_symbol_bbox_direct(sym, pin_defs) - assert bbox is not None - min_x, min_y, max_x, max_y = bbox - # X should be expanded with min_body=1.5: 100 ± 1.5 - assert min_x == pytest.approx(100 - 1.5) - assert max_x == pytest.approx(100 + 1.5) - - def test_rotated_symbol_graphics(self): - """Graphics points should be rotated along with the symbol.""" - sym = { - "x": 100.0, - "y": 100.0, - "rotation": 90, - "mirror_x": False, - "mirror_y": False, - } - pin_defs = { - "1": { - "x": 0, - "y": 3.81, - "angle": 270, - "length": 1.27, - "name": "~", - "type": "passive", - }, - "2": { - "x": 0, - "y": -3.81, - "angle": 90, - "length": 1.27, - "name": "~", - "type": "passive", - }, - } - # Rectangle corners in local coords - graphics_points = [(-1.016, -2.54), (1.016, 2.54)] - - bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) - assert bbox is not None - min_x, min_y, max_x, max_y = bbox - # After 90° rotation, X and Y swap roles - # Pins now extend along X: 100 ± 3.81 - # Rectangle now extends along Y: 100 ± 1.016 - assert min_x == pytest.approx(100 - 3.81, abs=0.01) - assert max_x == pytest.approx(100 + 3.81, abs=0.01) - - -@pytest.mark.integration -class TestIntegrationGraphicsBbox: - """Integration tests verifying graphics-based bbox from real template data.""" - - def test_resistor_bbox_uses_rectangle(self): - """The template's Device:R has a rectangle body. - Verify that the bbox for a placed resistor uses the actual - rectangle width rather than the degenerate 1.5mm expansion.""" - extra = _make_resistor_sexp("R1", 100, 100) - tmp = _make_temp_schematic(extra) - sexp_data = _load_sexp(tmp) - symbols = _parse_symbols(sexp_data) - lib_defs = _extract_lib_symbols(sexp_data) - - r1 = [s for s in symbols if s["reference"] == "R1"][0] - lib_data = lib_defs.get(r1["lib_id"], {}) - pin_defs = lib_data.get("pins", {}) - graphics_points = lib_data.get("graphics_points", []) - - assert len(graphics_points) >= 2, "Should have extracted rectangle points" - - bbox = _compute_symbol_bbox_direct(r1, pin_defs, graphics_points=graphics_points) - assert bbox is not None - min_x, min_y, max_x, max_y = bbox - # Rectangle is ±1.016 in X, NOT ±1.5 from degenerate expansion - assert max_x - min_x == pytest.approx(2 * 1.016, abs=0.01) - - def test_led_bbox_uses_polyline(self): - """The template's Device:LED uses polylines for its body. - Verify that the bbox uses polyline extents.""" - extra = _make_led_sexp("D1", 100, 100) - tmp = _make_temp_schematic(extra) - sexp_data = _load_sexp(tmp) - symbols = _parse_symbols(sexp_data) - lib_defs = _extract_lib_symbols(sexp_data) - - d1 = [s for s in symbols if s["reference"] == "D1"][0] - lib_data = lib_defs.get(d1["lib_id"], {}) - graphics_points = lib_data.get("graphics_points", []) - - assert len(graphics_points) >= 4, "Should have extracted polyline points" - # LED body polylines span from -1.27 to 1.27 in both X and Y - xs = [p[0] for p in graphics_points] - ys = [p[1] for p in graphics_points] - assert min(xs) == pytest.approx(-1.27) - assert max(xs) == pytest.approx(1.27) - assert min(ys) == pytest.approx(-1.27) - assert max(ys) == pytest.approx(1.27) +""" +Tests for schematic analysis tools (Tools 2–5). + +Unit tests use mock data / synthetic S-expressions. +Integration tests parse real .kicad_sch files via sexpdata. +""" + +import os +import shutil +import sys +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import sexpdata +from sexpdata import Symbol + +# Ensure the python/ package is importable +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from commands.schematic_analysis import ( + _aabb_overlap, + _check_wire_overlap, + _compute_symbol_bbox_direct, + _distance, + _extract_lib_symbols, + _line_segment_intersects_aabb, + _load_sexp, + _parse_labels, + _parse_lib_symbol_graphics, + _parse_symbols, + _parse_wires, + _point_in_rect, + _transform_local_point, + compute_symbol_bbox, + find_overlapping_elements, + find_wires_crossing_symbols, + get_elements_in_region, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "empty.kicad_sch" + + +def _make_temp_schematic(extra_sexp: str = "") -> Path: + """Copy empty.kicad_sch to a temp file and optionally append S-expression content.""" + tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" + shutil.copy(TEMPLATE_PATH, tmp) + if extra_sexp: + content = tmp.read_text(encoding="utf-8") + # Insert before the final closing paren + idx = content.rfind(")") + content = content[:idx] + "\n" + extra_sexp + "\n)" + tmp.write_text(content, encoding="utf-8") + return tmp + + +import uuid as _uuid + + +def _make_resistor_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str: + """Generate a proper Device:R symbol S-expression that skip can parse.""" + u = str(_uuid.uuid4()) + return f""" + (symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1) + (in_bom yes) (on_board yes) (dnp no) + (uuid "{u}") + (property "Reference" "{ref}" (at {x + 2.032} {y} 90) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "10k" (at {x} {y} 90) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at {x - 1.778} {y} 90) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at {x} {y} 0) + (effects (font (size 1.27 1.27)) hide) + ) + (pin "1" (uuid "{_uuid.uuid4()}")) + (pin "2" (uuid "{_uuid.uuid4()}")) + (instances + (project "test" + (path "/" (reference "{ref}") (unit 1)) + ) + ) + ) +""" + + +def _make_led_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str: + """Generate a proper Device:LED symbol S-expression (horizontal pin spread).""" + u = str(_uuid.uuid4()) + return f""" + (symbol (lib_id "Device:LED") (at {x} {y} {rotation}) (unit 1) + (in_bom yes) (on_board yes) (dnp no) + (uuid "{u}") + (property "Reference" "{ref}" (at {x} {y - 2.54} 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "LED" (at {x} {y + 2.54} 0) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at {x} {y} 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at {x} {y} 0) + (effects (font (size 1.27 1.27)) hide) + ) + (pin "1" (uuid "{_uuid.uuid4()}")) + (pin "2" (uuid "{_uuid.uuid4()}")) + (instances + (project "test" + (path "/" (reference "{ref}") (unit 1)) + ) + ) + ) +""" + + +# =================================================================== +# Unit tests — geometry helpers +# =================================================================== + + +class TestGeometryHelpers: + """Test low-level geometry utilities.""" + + def test_point_in_rect_inside(self) -> None: + assert _point_in_rect(5, 5, 0, 0, 10, 10) is True + + def test_point_in_rect_outside(self) -> None: + assert _point_in_rect(15, 5, 0, 0, 10, 10) is False + + def test_point_in_rect_boundary(self) -> None: + assert _point_in_rect(0, 0, 0, 0, 10, 10) is True + + def test_distance_zero(self) -> None: + assert _distance((0, 0), (0, 0)) == 0 + + def test_distance_unit(self) -> None: + assert abs(_distance((0, 0), (3, 4)) - 5.0) < 1e-9 + + def test_aabb_intersection_crossing(self) -> None: + # Line from (0,5) to (10,5) should intersect box (2,2)-(8,8) + assert _line_segment_intersects_aabb(0, 5, 10, 5, 2, 2, 8, 8) is True + + def test_aabb_intersection_miss(self) -> None: + # Line from (0,0) to (10,0) should miss box (2,2)-(8,8) + assert _line_segment_intersects_aabb(0, 0, 10, 0, 2, 2, 8, 8) is False + + def test_aabb_intersection_inside(self) -> None: + # Line entirely inside the box + assert _line_segment_intersects_aabb(3, 3, 7, 7, 2, 2, 8, 8) is True + + def test_aabb_intersection_diagonal(self) -> None: + # Diagonal line crossing through box + assert _line_segment_intersects_aabb(0, 0, 10, 10, 2, 2, 8, 8) is True + + def test_aabb_intersection_parallel_outside(self) -> None: + # Horizontal line above the box + assert _line_segment_intersects_aabb(0, 9, 10, 9, 2, 2, 8, 8) is False + + def test_aabb_intersection_touching_edge(self) -> None: + # Line ending exactly at box edge + assert _line_segment_intersects_aabb(0, 2, 2, 2, 2, 2, 8, 8) is True + + +# =================================================================== +# Unit tests — S-expression parsers +# =================================================================== + + +class TestSexpParsers: + """Test S-expression parsing functions with synthetic data.""" + + def test_parse_wires_basic(self) -> None: + sexp = sexpdata.loads("""(kicad_sch + (wire (pts (xy 10 20) (xy 30 40)) + (stroke (width 0) (type default)) + (uuid "abc")) + )""") + wires = _parse_wires(sexp) + assert len(wires) == 1 + assert wires[0]["start"] == (10.0, 20.0) + assert wires[0]["end"] == (30.0, 40.0) + + def test_parse_wires_empty(self) -> None: + sexp = sexpdata.loads("(kicad_sch)") + assert _parse_wires(sexp) == [] + + def test_parse_labels_both_types(self) -> None: + sexp = sexpdata.loads("""(kicad_sch + (label "VCC" (at 10 20 0)) + (global_label "GND" (at 30 40 0)) + )""") + labels = _parse_labels(sexp) + assert len(labels) == 2 + assert labels[0]["name"] == "VCC" + assert labels[0]["type"] == "label" + assert labels[1]["name"] == "GND" + assert labels[1]["type"] == "global_label" + + def test_parse_symbols(self) -> None: + sexp = sexpdata.loads("""(kicad_sch + (symbol (lib_id "Device:R") (at 100 100 0) + (property "Reference" "R1" (at 0 0 0))) + (symbol (lib_id "power:VCC") (at 50 50 0) + (property "Reference" "#PWR01" (at 0 0 0))) + )""") + symbols = _parse_symbols(sexp) + assert len(symbols) == 2 + assert symbols[0]["reference"] == "R1" + assert symbols[0]["is_power"] is False + assert symbols[1]["reference"] == "#PWR01" + assert symbols[1]["is_power"] is True + + +# =================================================================== +# Unit tests — analysis functions with mocked PinLocator +# =================================================================== + + +class TestAABBOverlap: + """Test AABB overlap helper.""" + + def test_overlapping_boxes(self) -> None: + assert _aabb_overlap((0, 0, 10, 10), (5, 5, 15, 15)) is True + + def test_non_overlapping_boxes(self) -> None: + assert _aabb_overlap((0, 0, 10, 10), (20, 20, 30, 30)) is False + + def test_touching_boxes_no_overlap(self) -> None: + # Touching edges are not overlapping (strict inequality) + assert _aabb_overlap((0, 0, 10, 10), (10, 0, 20, 10)) is False + + def test_contained_box(self) -> None: + assert _aabb_overlap((0, 0, 20, 20), (5, 5, 15, 15)) is True + + def test_overlap_one_axis_only(self) -> None: + # Overlap in X but not Y + assert _aabb_overlap((0, 0, 10, 10), (5, 15, 15, 25)) is False + + +class TestFindOverlappingElements: + """Test overlapping detection logic.""" + + def test_no_overlaps_in_empty_schematic(self) -> None: + tmp = _make_temp_schematic() + result = find_overlapping_elements(tmp, tolerance=0.5) + assert result["totalOverlaps"] == 0 + + def test_overlapping_symbols_detected(self) -> None: + # Two resistors at nearly the same position — bboxes fully overlap + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100.1, 100) + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert result["totalOverlaps"] >= 1 + assert len(result["overlappingSymbols"]) >= 1 + + def test_well_separated_symbols_not_flagged(self) -> None: + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 200, 200) + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert result["totalOverlaps"] == 0 + + def test_collinear_wire_overlap(self) -> None: + extra = """ + (wire (pts (xy 10 50) (xy 30 50)) + (stroke (width 0) (type default)) + (uuid "w1")) + (wire (pts (xy 20 50) (xy 40 50)) + (stroke (width 0) (type default)) + (uuid "w2")) + """ + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert len(result["overlappingWires"]) >= 1 + + def test_overlapping_bodies_different_centers(self) -> None: + """Two resistors whose bodies overlap even though centers are ~5mm apart. + + Device:R pins are at y ±3.81 relative to center, so the body spans + ~7.62mm vertically. Two resistors at the same X but 5mm apart in Y + have overlapping bodies — this is the bug the center-distance approach missed. + """ + # R1 at y=100, R2 at y=105 — pin spans [96.19, 103.81] and [101.19, 108.81] + # These overlap in Y from 101.19 to 103.81 + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100, 105) + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert result["totalOverlaps"] >= 1, ( + "Should detect overlap when component bodies intersect, " + "even if centers are far apart" + ) + assert len(result["overlappingSymbols"]) >= 1 + + def test_adjacent_resistors_no_overlap(self) -> None: + """Two vertical resistors side by side should not overlap. + + R pins at y ±3.81, but different X positions far enough apart. + """ + extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 110, 100) + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert result["totalOverlaps"] == 0 + + def test_resistor_and_led_overlapping_bodies(self) -> None: + """A resistor and an LED placed close enough that bodies overlap. + + LED pins at x ±3.81, R pins at y ±3.81. Place LED at same position + as R — bodies clearly overlap. + """ + extra = _make_resistor_sexp("R1", 100, 100) + _make_led_sexp("D1", 100, 100) + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert result["totalOverlaps"] >= 1 + + +class TestGetElementsInRegion: + """Test region query logic.""" + + def test_elements_inside_region_found(self) -> None: + extra = """ + (symbol (lib_id "Device:R") (at 50 50 0) + (property "Reference" "R1" (at 0 0 0)) + (property "Value" "10k" (at 0 0 0))) + (wire (pts (xy 45 50) (xy 55 50)) + (stroke (width 0) (type default)) + (uuid "w1")) + (label "NET1" (at 50 50 0)) + """ + tmp = _make_temp_schematic(extra) + result = get_elements_in_region(tmp, 40, 40, 60, 60) + assert result["counts"]["symbols"] >= 1 + assert result["counts"]["wires"] >= 1 + assert result["counts"]["labels"] >= 1 + + def test_elements_outside_region_excluded(self) -> None: + extra = """ + (symbol (lib_id "Device:R") (at 200 200 0) + (property "Reference" "R1" (at 0 0 0)) + (property "Value" "10k" (at 0 0 0))) + """ + tmp = _make_temp_schematic(extra) + result = get_elements_in_region(tmp, 0, 0, 50, 50) + assert result["counts"]["symbols"] == 0 + + +class TestComputeSymbolBbox: + """Test bounding box computation.""" + + def test_returns_none_for_unknown_symbol(self) -> None: + tmp = _make_temp_schematic() + from commands.pin_locator import PinLocator + + locator = PinLocator() + result = compute_symbol_bbox(tmp, "NONEXISTENT", locator) + assert result is None + + +# =================================================================== +# Integration tests — full schematic parsing +# =================================================================== + + +@pytest.mark.integration +class TestIntegrationFindWiresCrossingSymbols: + """Integration test for wire crossing symbol detection.""" + + def test_wire_not_touching_pins_is_collision(self) -> None: + """A wire passing through a component bbox without pin contact → collision.""" + # LED D1 at (100,100) → pin 1 at (96.19, 100), pin 2 at (103.81, 100) + # Vertical wire from (100, 95) to (100, 105) crosses through the body + # without touching either horizontal pin + extra = _make_led_sexp("D1", 100, 100) + """ + (wire (pts (xy 100 95) (xy 100 105)) + (stroke (width 0) (type default)) + (uuid "w1")) + """ + tmp = _make_temp_schematic(extra) + result = find_wires_crossing_symbols(tmp) + d1_collisions = [c for c in result if c["component"]["reference"] == "D1"] + assert len(d1_collisions) >= 1 + + def test_unannotated_duplicates_not_over_reported(self) -> None: + """ + Regression: two components with the same unannotated reference ("R?") at + different positions should each produce independent bounding boxes. + A wire crossing only one of them must produce exactly 1 collision, not 2. + + Before the fix, PinLocator.get_all_symbol_pins always resolved "R?" to + the first match, so both symbols got identical bboxes and the same wire + was counted against both. + """ + # R? at (100, 100): Device:R pins are at (100, 96.19) and (100, 103.81). + # Effective bbox (after expansion + margin) ≈ x=[99,101], y=[96.69,103.31]. + # R? at (200, 100): identical type but far away → no intersection with wire. + r_at_100 = _make_resistor_sexp("R?", 100, 100) + r_at_200 = _make_resistor_sexp("R?", 200, 100) + # Horizontal wire crossing the body of the first R? only + wire = """ + (wire (pts (xy 95 100) (xy 105 100)) + (stroke (width 0) (type default)) + (uuid "w-collision")) + """ + tmp = _make_temp_schematic(r_at_100 + r_at_200 + wire) + result = find_wires_crossing_symbols(tmp) + # The wire must not be reported against the far-away R? at (200, 100) + collisions_at_200 = [c for c in result if abs(c["component"]["position"]["x"] - 200) < 0.5] + assert len(collisions_at_200) == 0, ( + "Wire at x≈100 must not be flagged against the R? at x=200; " + "likely caused by reference-lookup always returning the first 'R?'" + ) + + def test_wire_starting_at_pin_passing_through_body(self) -> None: + """A wire that starts at a pin but continues through the component body + must be flagged — this is the core bug where the old suppression logic + treated any wire touching a pin as a valid connection.""" + # LED D1 at (100,100) → pin 1 at (96.19, 100), pin 2 at (103.81, 100) + # Wire starts exactly at pin 1 and extends through the body to the right + extra = _make_led_sexp("D1", 100, 100) + """ + (wire (pts (xy 96.19 100) (xy 110 100)) + (stroke (width 0) (type default)) + (uuid "w-through")) + """ + tmp = _make_temp_schematic(extra) + result = find_wires_crossing_symbols(tmp) + d1_crossings = [c for c in result if c["component"]["reference"] == "D1"] + assert ( + len(d1_crossings) >= 1 + ), "Wire starting at pin but passing through body must be detected" + + def test_wire_terminating_at_pin_from_outside(self) -> None: + """A wire that arrives at a pin from outside the component body + is a valid connection and must NOT be flagged.""" + # LED D1 at (100,100) → pin 1 at (96.19, 100) + # Wire comes from the left and terminates at pin 1 + extra = _make_led_sexp("D1", 100, 100) + """ + (wire (pts (xy 80 100) (xy 96.19 100)) + (stroke (width 0) (type default)) + (uuid "w-valid")) + """ + tmp = _make_temp_schematic(extra) + result = find_wires_crossing_symbols(tmp) + d1_crossings = [c for c in result if c["component"]["reference"] == "D1"] + assert len(d1_crossings) == 0, "Wire terminating at pin from outside should not be flagged" + + def test_wire_shorts_component_pins_detected_as_collision(self) -> None: + """Regression: a wire connecting pin1→pin2 of the same component + must be reported even though both endpoints land on pins.""" + r_sexp = _make_resistor_sexp("R_short", 100.0, 100.0) + wire_sexp = ( + "(wire (pts (xy 100 103.81) (xy 100 96.19))\n" + " (stroke (width 0) (type default))\n" + ' (uuid "aaaaaaaa-0000-0000-0000-000000000001"))' + ) + sch = _make_temp_schematic(r_sexp + "\n" + wire_sexp) + collisions = find_wires_crossing_symbols(sch) + assert len(collisions) == 1 + w = collisions[0]["wire"] + assert w["start"]["x"] == pytest.approx(100.0) + assert w["start"]["y"] == pytest.approx(103.81) + assert collisions[0]["component"]["reference"] == "R_short" + + +@pytest.mark.integration +class TestIntegrationGetElementsInRegion: + """Integration test for region query.""" + + def test_region_returns_pin_data(self) -> None: + """Symbols in region should include pin position data.""" + extra = _make_resistor_sexp("R1", 100, 100) + tmp = _make_temp_schematic(extra) + result = get_elements_in_region(tmp, 90, 90, 110, 110) + assert result["counts"]["symbols"] == 1 + sym = result["symbols"][0] + assert "pins" in sym + assert len(sym["pins"]) == 2 # Resistor has 2 pins + + def test_wire_passing_through_region_included(self) -> None: + """A wire that passes through a region (no endpoints inside) should be included.""" + extra = """ + (wire (pts (xy 0 50) (xy 100 50)) + (stroke (width 0) (type default)) + (uuid "w-through")) + """ + tmp = _make_temp_schematic(extra) + result = get_elements_in_region(tmp, 40, 40, 60, 60) + assert result["counts"]["wires"] == 1 + + def test_wire_outside_region_excluded(self) -> None: + """A wire entirely outside a region should not be included.""" + extra = """ + (wire (pts (xy 0 0) (xy 10 0)) + (stroke (width 0) (type default)) + (uuid "w-outside")) + """ + tmp = _make_temp_schematic(extra) + result = get_elements_in_region(tmp, 40, 40, 60, 60) + assert result["counts"]["wires"] == 0 + + +# =================================================================== +# Unit tests — _check_wire_overlap +# =================================================================== + + +class TestCheckWireOverlap: + """Test wire overlap detection for horizontal, vertical, and diagonal cases.""" + + def test_horizontal_overlap(self) -> None: + w1 = {"start": (10, 50), "end": (30, 50)} + w2 = {"start": (20, 50), "end": (40, 50)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is not None + assert result["type"] == "collinear_overlap" + + def test_vertical_overlap(self) -> None: + w1 = {"start": (50, 10), "end": (50, 30)} + w2 = {"start": (50, 20), "end": (50, 40)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is not None + assert result["type"] == "collinear_overlap" + + def test_diagonal_overlap(self) -> None: + w1 = {"start": (0, 0), "end": (20, 20)} + w2 = {"start": (10, 10), "end": (30, 30)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is not None + assert result["type"] == "collinear_overlap" + + def test_horizontal_no_overlap(self) -> None: + w1 = {"start": (10, 50), "end": (20, 50)} + w2 = {"start": (30, 50), "end": (40, 50)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is None + + def test_parallel_offset_no_overlap(self) -> None: + """Two parallel wires offset perpendicularly should not overlap.""" + w1 = {"start": (0, 0), "end": (20, 20)} + w2 = {"start": (0, 5), "end": (20, 25)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is None + + def test_non_parallel_no_overlap(self) -> None: + """Two wires at different angles should not overlap.""" + w1 = {"start": (0, 0), "end": (10, 10)} + w2 = {"start": (0, 0), "end": (10, 0)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is None + + def test_zero_length_segment(self) -> None: + w1 = {"start": (10, 10), "end": (10, 10)} + w2 = {"start": (10, 10), "end": (20, 20)} + result = _check_wire_overlap(w1, w2, 0.5) + assert result is None + + +@pytest.mark.integration +class TestIntegrationDiagonalWireOverlap: + """Integration tests for diagonal collinear wire overlap detection.""" + + def test_diagonal_collinear_wire_overlap(self) -> None: + """Two 45-degree wires that overlap should be detected.""" + extra = """ + (wire (pts (xy 0 0) (xy 20 20)) + (stroke (width 0) (type default)) + (uuid "w-diag1")) + (wire (pts (xy 10 10) (xy 30 30)) + (stroke (width 0) (type default)) + (uuid "w-diag2")) + """ + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert len(result["overlappingWires"]) >= 1 + + def test_diagonal_parallel_no_overlap(self) -> None: + """Two parallel 45-degree wires that are offset should not overlap.""" + extra = """ + (wire (pts (xy 0 0) (xy 20 20)) + (stroke (width 0) (type default)) + (uuid "w-diag1")) + (wire (pts (xy 0 5) (xy 20 25)) + (stroke (width 0) (type default)) + (uuid "w-diag2")) + """ + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert len(result["overlappingWires"]) == 0 + + def test_diagonal_non_collinear_no_overlap(self) -> None: + """Two wires at different angles crossing should not be flagged as collinear overlap.""" + extra = """ + (wire (pts (xy 0 0) (xy 20 20)) + (stroke (width 0) (type default)) + (uuid "w-diag1")) + (wire (pts (xy 0 20) (xy 20 0)) + (stroke (width 0) (type default)) + (uuid "w-diag2")) + """ + tmp = _make_temp_schematic(extra) + result = find_overlapping_elements(tmp, tolerance=0.5) + assert len(result["overlappingWires"]) == 0 + + +# =================================================================== +# Unit tests — _extract_lib_symbols +# =================================================================== + + +class TestExtractLibSymbols: + """Test _extract_lib_symbols helper.""" + + def test_extracts_pins_from_lib_symbols(self) -> None: + sexp = sexpdata.loads("""(kicad_sch + (lib_symbols + (symbol "Device:R" + (symbol "Device:R_0_1" + (pin passive (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 (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))))))) + ) + )""") + result = _extract_lib_symbols(sexp) + assert "Device:R" in result + pins = result["Device:R"]["pins"] + assert "1" in pins + assert "2" in pins + assert pins["1"]["y"] == pytest.approx(3.81) + + def test_empty_schematic_returns_empty(self) -> None: + sexp = sexpdata.loads("(kicad_sch)") + result = _extract_lib_symbols(sexp) + assert result == {} + + def test_no_lib_symbols_section(self) -> None: + sexp = sexpdata.loads("""(kicad_sch + (wire (pts (xy 0 0) (xy 10 10))) + )""") + result = _extract_lib_symbols(sexp) + assert result == {} + + def test_extract_includes_graphics_points(self) -> None: + """_extract_lib_symbols should return graphics_points from body shapes.""" + sexp = sexpdata.loads("""(kicad_sch + (lib_symbols + (symbol "Device:R" + (symbol "Device:R_0_1" + (rectangle (start -1.016 -2.54) (end 1.016 2.54) + (stroke (width 0.254) (type default)) + (fill (type none)))) + (symbol "Device: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))))))) + ) + )""") + result = _extract_lib_symbols(sexp) + lib_data = result["Device:R"] + assert "graphics_points" in lib_data + gfx = lib_data["graphics_points"] + assert len(gfx) >= 2 + # Rectangle corners should be present + xs = [p[0] for p in gfx] + ys = [p[1] for p in gfx] + assert pytest.approx(-1.016) in xs + assert pytest.approx(1.016) in xs + assert pytest.approx(-2.54) in ys + assert pytest.approx(2.54) in ys + + +# =================================================================== +# Unit tests — _parse_lib_symbol_graphics +# =================================================================== + + +class TestParseLibSymbolGraphics: + """Test graphics extraction from lib_symbol definitions.""" + + def test_rectangle(self) -> None: + sexp = sexpdata.loads("""(symbol "Device:R" + (symbol "Device:R_0_1" + (rectangle (start -1.016 -2.54) (end 1.016 2.54) + (stroke (width 0.254) (type default)) + (fill (type none)))))""") + pts = _parse_lib_symbol_graphics(sexp) + assert len(pts) == 2 + assert (-1.016, -2.54) in pts + assert (1.016, 2.54) in pts + + def test_polyline(self) -> None: + sexp = sexpdata.loads("""(symbol "Device:C" + (symbol "Device:C_0_1" + (polyline + (pts (xy -2.032 -0.762) (xy 2.032 -0.762)) + (stroke (width 0.508) (type default)) + (fill (type none)))))""") + pts = _parse_lib_symbol_graphics(sexp) + assert (-2.032, -0.762) in pts + assert (2.032, -0.762) in pts + + def test_circle(self) -> None: + sexp = sexpdata.loads("""(symbol "Test:Circle" + (symbol "Test:Circle_0_1" + (circle (center 0 0) (radius 5) + (stroke (width 0.254) (type default)) + (fill (type none)))))""") + pts = _parse_lib_symbol_graphics(sexp) + assert len(pts) == 2 + assert (-5.0, -5.0) in pts + assert (5.0, 5.0) in pts + + def test_arc(self) -> None: + sexp = sexpdata.loads("""(symbol "Test:Arc" + (symbol "Test:Arc_0_1" + (arc (start 1 0) (mid 0 1) (end -1 0) + (stroke (width 0.254) (type default)) + (fill (type none)))))""") + pts = _parse_lib_symbol_graphics(sexp) + assert (1.0, 0.0) in pts + assert (0.0, 1.0) in pts + assert (-1.0, 0.0) in pts + + def test_no_graphics(self) -> None: + sexp = sexpdata.loads("""(symbol "Test:Empty" + (symbol "Test:Empty_1_1" + (pin passive line (at 0 0 0) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))))))""") + pts = _parse_lib_symbol_graphics(sexp) + assert pts == [] + + +# =================================================================== +# Unit tests — _transform_local_point +# =================================================================== + + +class TestTransformLocalPoint: + """Test local→absolute coordinate transform.""" + + def test_no_transform(self) -> None: + # ly is negated (lib y-up → schematic y-down) + x, y = _transform_local_point(1.0, 2.0, 100.0, 200.0, 0, False, False) + assert x == pytest.approx(101.0) + assert y == pytest.approx(198.0) + + def test_mirror_x(self) -> None: + # y-negate then mirror_x cancel out → net ly unchanged + x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, True, False) + assert x == pytest.approx(1.0) + assert y == pytest.approx(2.0) + + def test_mirror_y(self) -> None: + x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, False, True) + assert x == pytest.approx(-1.0) + assert y == pytest.approx(-2.0) + + def test_rotation_90(self) -> None: + # ly=0 negated is still 0, then rotate lx=1 by 90° + x, y = _transform_local_point(1.0, 0.0, 0.0, 0.0, 90, False, False) + assert x == pytest.approx(0.0, abs=1e-9) + assert y == pytest.approx(1.0, abs=1e-9) + + +# =================================================================== +# Unit tests — _compute_symbol_bbox_direct with graphics +# =================================================================== + + +class TestComputeSymbolBboxWithGraphics: + """Test that bounding box computation uses graphics points when available.""" + + def test_resistor_bbox_from_graphics(self) -> None: + """Device:R rectangle is (-1.016, -2.54) to (1.016, 2.54) in local coords. + Pins at (0, ±3.81). Placed at (100, 100) with no rotation. + Bbox should span from pin-to-pin in Y and use rectangle width in X.""" + sym = { + "x": 100.0, + "y": 100.0, + "rotation": 0, + "mirror_x": False, + "mirror_y": False, + } + pin_defs = { + "1": { + "x": 0, + "y": 3.81, + "angle": 270, + "length": 1.27, + "name": "~", + "type": "passive", + }, + "2": { + "x": 0, + "y": -3.81, + "angle": 90, + "length": 1.27, + "name": "~", + "type": "passive", + }, + } + graphics_points = [(-1.016, -2.54), (1.016, 2.54)] + + bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) + assert bbox is not None + min_x, min_y, max_x, max_y = bbox + # X should come from rectangle: 100 ± 1.016 + assert min_x == pytest.approx(100 - 1.016) + assert max_x == pytest.approx(100 + 1.016) + # Y should come from pins (extending beyond rectangle): 100 ± 3.81 + assert min_y == pytest.approx(100 - 3.81) + assert max_y == pytest.approx(100 + 3.81) + + def test_fallback_without_graphics(self) -> None: + """Without graphics_points, should use the old degenerate expansion.""" + sym = { + "x": 100.0, + "y": 100.0, + "rotation": 0, + "mirror_x": False, + "mirror_y": False, + } + pin_defs = { + "1": { + "x": 0, + "y": 3.81, + "angle": 270, + "length": 1.27, + "name": "~", + "type": "passive", + }, + "2": { + "x": 0, + "y": -3.81, + "angle": 90, + "length": 1.27, + "name": "~", + "type": "passive", + }, + } + + bbox = _compute_symbol_bbox_direct(sym, pin_defs) + assert bbox is not None + min_x, min_y, max_x, max_y = bbox + # X should be expanded with min_body=1.5: 100 ± 1.5 + assert min_x == pytest.approx(100 - 1.5) + assert max_x == pytest.approx(100 + 1.5) + + def test_rotated_symbol_graphics(self) -> None: + """Graphics points should be rotated along with the symbol.""" + sym = { + "x": 100.0, + "y": 100.0, + "rotation": 90, + "mirror_x": False, + "mirror_y": False, + } + pin_defs = { + "1": { + "x": 0, + "y": 3.81, + "angle": 270, + "length": 1.27, + "name": "~", + "type": "passive", + }, + "2": { + "x": 0, + "y": -3.81, + "angle": 90, + "length": 1.27, + "name": "~", + "type": "passive", + }, + } + # Rectangle corners in local coords + graphics_points = [(-1.016, -2.54), (1.016, 2.54)] + + bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) + assert bbox is not None + min_x, min_y, max_x, max_y = bbox + # After 90° rotation, X and Y swap roles + # Pins now extend along X: 100 ± 3.81 + # Rectangle now extends along Y: 100 ± 1.016 + assert min_x == pytest.approx(100 - 3.81, abs=0.01) + assert max_x == pytest.approx(100 + 3.81, abs=0.01) + + +@pytest.mark.integration +class TestIntegrationGraphicsBbox: + """Integration tests verifying graphics-based bbox from real template data.""" + + def test_resistor_bbox_uses_rectangle(self) -> None: + """The template's Device:R has a rectangle body. + Verify that the bbox for a placed resistor uses the actual + rectangle width rather than the degenerate 1.5mm expansion.""" + extra = _make_resistor_sexp("R1", 100, 100) + tmp = _make_temp_schematic(extra) + sexp_data = _load_sexp(tmp) + symbols = _parse_symbols(sexp_data) + lib_defs = _extract_lib_symbols(sexp_data) + + r1 = [s for s in symbols if s["reference"] == "R1"][0] + lib_data = lib_defs.get(r1["lib_id"], {}) + pin_defs = lib_data.get("pins", {}) + graphics_points = lib_data.get("graphics_points", []) + + assert len(graphics_points) >= 2, "Should have extracted rectangle points" + + bbox = _compute_symbol_bbox_direct(r1, pin_defs, graphics_points=graphics_points) + assert bbox is not None + min_x, min_y, max_x, max_y = bbox + # Rectangle is ±1.016 in X, NOT ±1.5 from degenerate expansion + assert max_x - min_x == pytest.approx(2 * 1.016, abs=0.01) + + def test_led_bbox_uses_polyline(self) -> None: + """The template's Device:LED uses polylines for its body. + Verify that the bbox uses polyline extents.""" + extra = _make_led_sexp("D1", 100, 100) + tmp = _make_temp_schematic(extra) + sexp_data = _load_sexp(tmp) + symbols = _parse_symbols(sexp_data) + lib_defs = _extract_lib_symbols(sexp_data) + + d1 = [s for s in symbols if s["reference"] == "D1"][0] + lib_data = lib_defs.get(d1["lib_id"], {}) + graphics_points = lib_data.get("graphics_points", []) + + assert len(graphics_points) >= 4, "Should have extracted polyline points" + # LED body polylines span from -1.27 to 1.27 in both X and Y + xs = [p[0] for p in graphics_points] + ys = [p[1] for p in graphics_points] + assert min(xs) == pytest.approx(-1.27) + assert max(xs) == pytest.approx(1.27) + assert min(ys) == pytest.approx(-1.27) + assert max(ys) == pytest.approx(1.27) diff --git a/python/tests/test_schematic_component_fields.py b/python/tests/test_schematic_component_fields.py index 2fb1c19..96d5d84 100644 --- a/python/tests/test_schematic_component_fields.py +++ b/python/tests/test_schematic_component_fields.py @@ -1,369 +1,370 @@ -""" -Tests for get_schematic_component and edit_schematic_component fieldPositions support. -""" - -import re -import shutil -import sys -import tempfile -from pathlib import Path - -import pytest - -# Ensure python/ directory is on path so kicad_interface can be imported -sys.path.insert(0, str(Path(__file__).parent.parent)) -sys.path.insert(0, str(Path(__file__).parent.parent.parent / "python")) - - -# --------------------------------------------------------------------------- -# Helpers shared across tests -# --------------------------------------------------------------------------- - -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 = """\ - (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") - (property "Reference" "R1" (at 51.27 47.46 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "10k" (at 51.27 52.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 50 50 0) - (effects (font (size 1.27 1.27)) hide) - ) - ) -""" - - -def _make_test_schematic(tmp_dir: Path, extra_block: str = "") -> Path: - """Copy empty.kicad_sch into tmp_dir, optionally appending a placed symbol block.""" - dest = tmp_dir / "test.kicad_sch" - src_content = TEMPLATE_SCH.read_text(encoding="utf-8") - # Insert placed symbol block before the closing paren of the top-level form - if extra_block: - src_content = src_content.rstrip() - if src_content.endswith(")"): - src_content = src_content[:-1] + "\n" + extra_block + ")\n" - dest.write_text(src_content, encoding="utf-8") - return dest - - -# --------------------------------------------------------------------------- -# 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.""" - - def _parse_fields(self, block_text: str) -> dict: - """Mirrors the regex used in _handle_get_schematic_component.""" - prop_pattern = re.compile( - r'\(property\s+"([^"]*)"\s+"([^"]*)"\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)' - ) - 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), - } - return fields - - def _parse_comp_pos(self, block_text: str): - """Mirrors the regex used to extract symbol position.""" - m = re.search( - r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)', - block_text, - ) - if m: - return { - "x": float(m.group(1)), - "y": float(m.group(2)), - "angle": float(m.group(3)), - } - return None - - def test_parses_reference_field(self): - fields = self._parse_fields(PLACED_RESISTOR_BLOCK) - assert "Reference" in fields - assert fields["Reference"]["value"] == "R1" - assert fields["Reference"]["x"] == pytest.approx(51.27) - assert fields["Reference"]["y"] == pytest.approx(47.46) - assert fields["Reference"]["angle"] == pytest.approx(0.0) - - def test_parses_value_field(self): - fields = self._parse_fields(PLACED_RESISTOR_BLOCK) - assert "Value" in fields - assert fields["Value"]["value"] == "10k" - assert fields["Value"]["x"] == pytest.approx(51.27) - assert fields["Value"]["y"] == pytest.approx(52.54) - - def test_parses_all_four_standard_fields(self): - fields = self._parse_fields(PLACED_RESISTOR_BLOCK) - assert set(fields.keys()) >= {"Reference", "Value", "Footprint", "Datasheet"} - - def test_parses_component_position(self): - pos = self._parse_comp_pos(PLACED_RESISTOR_BLOCK) - assert pos is not None - assert pos["x"] == pytest.approx(50.0) - assert pos["y"] == pytest.approx(50.0) - assert pos["angle"] == pytest.approx(0.0) - - def test_field_position_regex_replaces_correctly(self): - """Mirrors the regex used in _handle_edit_schematic_component for fieldPositions.""" - field_name = "Reference" - 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})", - block, - ) - fields = self._parse_fields(block) - assert fields["Reference"]["x"] == pytest.approx(99.0) - assert fields["Reference"]["y"] == pytest.approx(88.0) - # Value should be unchanged - assert fields["Value"]["x"] == pytest.approx(51.27) - - def test_field_position_regex_preserves_value(self): - """Replacing position must not change the field value string.""" - 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)", - block, - ) - fields = self._parse_fields(block) - assert fields["Value"]["value"] == "10k" - - -# --------------------------------------------------------------------------- -# 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.""" - - @pytest.fixture - def sch_with_r1(self, tmp_path): - return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK) - - 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", - }, - ) - 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", - }, - ) - 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", - }, - ) - 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", - }, - ) - ref_field = result["fields"]["Reference"] - assert ref_field["value"] == "R1" - assert ref_field["x"] == pytest.approx(51.27) - assert ref_field["y"] == pytest.approx(47.46) - - 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", - }, - ) - val_field = result["fields"]["Value"] - assert val_field["value"] == "10k" - assert val_field["x"] == pytest.approx(51.27) - assert val_field["y"] == pytest.approx(52.54) - - 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", - }, - ) - 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", - }, - ) - assert result["success"] is False - - -@pytest.mark.integration -class TestEditSchematicComponentFieldPositions: - """Integration tests for the new fieldPositions parameter.""" - - @pytest.fixture - def sch_with_r1(self, tmp_path): - return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK) - - 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}}, - }, - ) - 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", - }, - ) - 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", - }, - ) - # 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}, - }, - }, - ) - assert result["success"] is True - - 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}}, - }, - ) - 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", - }, - ) - assert result["success"] is False +""" +Tests for get_schematic_component and edit_schematic_component fieldPositions support. +""" + +import re +import shutil +import sys +import tempfile +from pathlib import Path +from typing import Any + +import pytest + +# Ensure python/ directory is on path so kicad_interface can be imported +sys.path.insert(0, str(Path(__file__).parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "python")) + + +# --------------------------------------------------------------------------- +# Helpers shared across tests +# --------------------------------------------------------------------------- + +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 = """\ + (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") + (property "Reference" "R1" (at 51.27 47.46 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "10k" (at 51.27 52.54 0) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 50 50 0) + (effects (font (size 1.27 1.27)) hide) + ) + ) +""" + + +def _make_test_schematic(tmp_dir: Path, extra_block: str = "") -> Path: + """Copy empty.kicad_sch into tmp_dir, optionally appending a placed symbol block.""" + dest = tmp_dir / "test.kicad_sch" + src_content = TEMPLATE_SCH.read_text(encoding="utf-8") + # Insert placed symbol block before the closing paren of the top-level form + if extra_block: + src_content = src_content.rstrip() + if src_content.endswith(")"): + src_content = src_content[:-1] + "\n" + extra_block + ")\n" + dest.write_text(src_content, encoding="utf-8") + return dest + + +# --------------------------------------------------------------------------- +# 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.""" + + def _parse_fields(self, block_text: str) -> dict: + """Mirrors the regex used in _handle_get_schematic_component.""" + prop_pattern = re.compile( + r'\(property\s+"([^"]*)"\s+"([^"]*)"\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)' + ) + 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), + } + return fields + + def _parse_comp_pos(self, block_text: str) -> Any: + """Mirrors the regex used to extract symbol position.""" + m = re.search( + r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)', + block_text, + ) + if m: + return { + "x": float(m.group(1)), + "y": float(m.group(2)), + "angle": float(m.group(3)), + } + return None + + def test_parses_reference_field(self) -> None: + fields = self._parse_fields(PLACED_RESISTOR_BLOCK) + assert "Reference" in fields + assert fields["Reference"]["value"] == "R1" + assert fields["Reference"]["x"] == pytest.approx(51.27) + assert fields["Reference"]["y"] == pytest.approx(47.46) + assert fields["Reference"]["angle"] == pytest.approx(0.0) + + def test_parses_value_field(self) -> None: + fields = self._parse_fields(PLACED_RESISTOR_BLOCK) + assert "Value" in fields + assert fields["Value"]["value"] == "10k" + assert fields["Value"]["x"] == pytest.approx(51.27) + assert fields["Value"]["y"] == pytest.approx(52.54) + + def test_parses_all_four_standard_fields(self) -> None: + fields = self._parse_fields(PLACED_RESISTOR_BLOCK) + assert set(fields.keys()) >= {"Reference", "Value", "Footprint", "Datasheet"} + + def test_parses_component_position(self) -> None: + pos = self._parse_comp_pos(PLACED_RESISTOR_BLOCK) + assert pos is not None + assert pos["x"] == pytest.approx(50.0) + assert pos["y"] == pytest.approx(50.0) + assert pos["angle"] == pytest.approx(0.0) + + def test_field_position_regex_replaces_correctly(self) -> None: + """Mirrors the regex used in _handle_edit_schematic_component for fieldPositions.""" + field_name = "Reference" + 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})", + block, + ) + fields = self._parse_fields(block) + assert fields["Reference"]["x"] == pytest.approx(99.0) + assert fields["Reference"]["y"] == pytest.approx(88.0) + # Value should be unchanged + assert fields["Value"]["x"] == pytest.approx(51.27) + + def test_field_position_regex_preserves_value(self) -> None: + """Replacing position must not change the field value string.""" + 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)", + block, + ) + fields = self._parse_fields(block) + assert fields["Value"]["value"] == "10k" + + +# --------------------------------------------------------------------------- +# 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.""" + + @pytest.fixture + def sch_with_r1(self, tmp_path: Any) -> Any: + return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK) + + def _get_interface(self) -> Any: + """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: Any) -> None: + iface = self._get_interface() + 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: Any) -> None: + iface = self._get_interface() + 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: Any) -> None: + iface = self._get_interface() + 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: Any) -> None: + iface = self._get_interface() + 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) + assert ref_field["y"] == pytest.approx(47.46) + + def test_get_returns_value_field(self, sch_with_r1: Any) -> None: + iface = self._get_interface() + 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) + assert val_field["y"] == pytest.approx(52.54) + + def test_get_unknown_reference_returns_failure(self, sch_with_r1: Any) -> None: + iface = self._get_interface() + 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) -> None: + iface = self._get_interface() + result = iface.handle_command( + "get_schematic_component", + { + "reference": "R1", + }, + ) + assert result["success"] is False + + +@pytest.mark.integration +class TestEditSchematicComponentFieldPositions: + """Integration tests for the new fieldPositions parameter.""" + + @pytest.fixture + def sch_with_r1(self, tmp_path: Any) -> Any: + return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK) + + def _get_interface(self) -> Any: + from kicad_interface import KiCADInterface + + return KiCADInterface() + + def test_reposition_reference_label(self, sch_with_r1: Any) -> None: + 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}}, + }, + ) + 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", + }, + ) + 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: Any) -> None: + 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", + }, + ) + # 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: Any) -> None: + 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}, + }, + }, + ) + assert result["success"] is True + + 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: Any) -> None: + """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}}, + }, + ) + assert result["success"] is True + + def test_no_params_still_fails(self, sch_with_r1: Any) -> None: + """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", + }, + ) + assert result["success"] is False diff --git a/python/tests/test_schematic_tools.py b/python/tests/test_schematic_tools.py index d6814b5..ef89753 100644 --- a/python/tests/test_schematic_tools.py +++ b/python/tests/test_schematic_tools.py @@ -1,476 +1,477 @@ -""" -Tests for schematic inspection and editing tools added in the schematic_tools branch. - -Covers: - - WireManager.delete_wire (unit + integration) - - WireManager.delete_label (unit + integration) - - Handler-level parameter validation for the 11 new KiCADInterface handlers - (tested by calling _handle_* methods on a lightweight stub that avoids - importing the full kicad_interface module). -""" - -import shutil -import tempfile -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -import sexpdata - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -TEMPLATES_DIR = Path(__file__).parent.parent / "templates" -EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch" - -# Minimal schematic content used by integration tests -_WIRE_SCH = """\ -(kicad_sch (version 20250114) (generator "test") - (uuid aaaaaaaa-0000-0000-0000-000000000000) - (paper "A4") - (wire (pts (xy 10 20) (xy 30 20)) - (stroke (width 0) (type default)) - (uuid bbbbbbbb-0000-0000-0000-000000000001) - ) - (label "VCC" (at 50 50 0) - (effects (font (size 1.27 1.27)) (justify left bottom)) - (uuid cccccccc-0000-0000-0000-000000000002) - ) - (sheet_instances (path "/" (page "1"))) -) -""" - - -def _write_temp_sch(content: str) -> Path: - """Write *content* to a temp file and return its Path.""" - tmp = tempfile.NamedTemporaryFile(suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8") - tmp.write(content) - tmp.close() - return Path(tmp.name) - - -# --------------------------------------------------------------------------- -# Unit tests – WireManager.delete_wire -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestDeleteWireUnit: - """Unit-level tests for WireManager.delete_wire.""" - - def setup_method(self): - from commands.wire_manager import WireManager - - self.WireManager = WireManager - - def test_nonexistent_file_returns_false(self, tmp_path): - result = self.WireManager.delete_wire(tmp_path / "nope.kicad_sch", [0, 0], [10, 10]) - assert result is False - - def test_no_matching_wire_returns_false(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - shutil.copy(EMPTY_SCH, sch) - result = self.WireManager.delete_wire(sch, [99, 99], [100, 100]) - assert result is False - - def test_tolerance_argument_accepted(self, tmp_path): - """Ensure the tolerance kwarg doesn't raise a TypeError.""" - sch = tmp_path / "test.kicad_sch" - shutil.copy(EMPTY_SCH, sch) - result = self.WireManager.delete_wire(sch, [0, 0], [1, 1], tolerance=0.1) - assert result is False # no wire in empty sch - - -# --------------------------------------------------------------------------- -# Unit tests – WireManager.delete_label -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestDeleteLabelUnit: - """Unit-level tests for WireManager.delete_label.""" - - def setup_method(self): - from commands.wire_manager import WireManager - - self.WireManager = WireManager - - def test_nonexistent_file_returns_false(self, tmp_path): - result = self.WireManager.delete_label(tmp_path / "nope.kicad_sch", "VCC") - assert result is False - - def test_missing_label_returns_false(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - shutil.copy(EMPTY_SCH, sch) - result = self.WireManager.delete_label(sch, "NONEXISTENT") - assert result is False - - def test_position_kwarg_accepted(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - shutil.copy(EMPTY_SCH, sch) - result = self.WireManager.delete_label(sch, "VCC", position=[10.0, 20.0], tolerance=0.5) - assert result is False - - -# --------------------------------------------------------------------------- -# Integration tests – WireManager.delete_wire -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -class TestDeleteWireIntegration: - """Integration tests that read/write real .kicad_sch files.""" - - def setup_method(self): - from commands.wire_manager import WireManager - - self.WireManager = WireManager - - def test_exact_match_deletes_wire(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - - result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0]) - - assert result is True - data = sexpdata.loads(sch.read_text(encoding="utf-8")) - wire_items = [ - item - for item in data - if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire") - ] - assert wire_items == [], "Wire should have been removed from the file" - - def test_reverse_direction_match_deletes_wire(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - - # Pass end/start swapped – should still match - result = self.WireManager.delete_wire(sch, [30.0, 20.0], [10.0, 20.0]) - - assert result is True - - def test_within_tolerance_deletes_wire(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - - # Coordinates differ by 0.3 mm — within default tolerance of 0.5 - result = self.WireManager.delete_wire(sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5) - assert result is True - - def test_outside_tolerance_no_delete(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - - result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0) - # tolerance=0.0 means exact float equality — may still match on most - # platforms, but the key thing is that a *distant* miss is rejected - sch2 = tmp_path / "test2.kicad_sch" - sch2.write_text(_WIRE_SCH, encoding="utf-8") - result2 = self.WireManager.delete_wire(sch2, [10.6, 20.0], [30.0, 20.0], tolerance=0.5) - assert result2 is False, "Coordinate differs by 0.6 mm — outside 0.5 mm tolerance" - - def test_file_is_valid_sexp_after_deletion(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0]) - # Must parse without exception - sexpdata.loads(sch.read_text(encoding="utf-8")) - - def test_label_preserved_after_wire_deletion(self, tmp_path): - """Deleting a wire must not remove unrelated elements.""" - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0]) - data = sexpdata.loads(sch.read_text(encoding="utf-8")) - labels = [ - item - for item in data - if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label") - ] - assert len(labels) == 1 - - -# --------------------------------------------------------------------------- -# Integration tests – WireManager.delete_label -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -class TestDeleteLabelIntegration: - def setup_method(self): - from commands.wire_manager import WireManager - - self.WireManager = WireManager - - def test_deletes_label_by_name(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - - result = self.WireManager.delete_label(sch, "VCC") - - assert result is True - data = sexpdata.loads(sch.read_text(encoding="utf-8")) - labels = [ - item - for item in data - if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label") - ] - assert labels == [], "Label should have been removed" - - def test_deletes_label_with_matching_position(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - - result = self.WireManager.delete_label(sch, "VCC", position=[50.0, 50.0]) - assert result is True - - def test_position_mismatch_no_delete(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - - result = self.WireManager.delete_label(sch, "VCC", position=[99.0, 99.0], tolerance=0.5) - assert result is False - - def test_wire_preserved_after_label_deletion(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - self.WireManager.delete_label(sch, "VCC") - data = sexpdata.loads(sch.read_text(encoding="utf-8")) - wires = [ - item - for item in data - if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire") - ] - assert len(wires) == 1 - - def test_file_is_valid_sexp_after_deletion(self, tmp_path): - sch = tmp_path / "test.kicad_sch" - sch.write_text(_WIRE_SCH, encoding="utf-8") - self.WireManager.delete_label(sch, "VCC") - sexpdata.loads(sch.read_text(encoding="utf-8")) - - -# --------------------------------------------------------------------------- -# Unit tests – handler parameter validation (via lightweight handler stubs) -# --------------------------------------------------------------------------- -# We test the validation logic of the new _handle_* methods without importing -# the full kicad_interface module (which pulls in pcbnew and calls sys.exit). -# Each handler is extracted as a standalone function for testing. - - -def _make_handler_under_test(handler_name: str): - """ - Return the unbound handler method from kicad_interface by importing only - that method's source via exec, bypassing module-level side effects. - - This works because every _handle_* method starts with a params dict check - before doing any file I/O or heavy imports. - """ - import importlib.util - import types - - # We monkey-patch sys.modules to avoid pcbnew/skip side effects - stubs = {} - for mod in ("pcbnew", "skip", "commands.schematic"): - stubs[mod] = types.ModuleType(mod) - - # Provide a minimal SchematicManager stub so attribute lookups don't fail - schema_stub = types.ModuleType("commands.schematic") - schema_stub.SchematicManager = MagicMock() - stubs["commands.schematic"] = schema_stub - - with patch.dict("sys.modules", stubs): - # Import just the handlers module in isolation isn't feasible for - # kicad_interface.py (module-level sys.exit). Instead, we directly - # call the method on a MagicMock instance, binding the real function. - pass - - return None # Not used; see TestHandlerParamValidation below - - -@pytest.mark.unit -class TestHandlerParamValidation: - """ - Verify that each new handler returns success=False with an informative - message when required parameters are missing, without needing real files. - - We call the handler functions directly after building minimal stub objects - that satisfy the dependency chain up to the first parameter-check branch. - """ - - def _make_iface_stub(self): - """Return a stub that exposes only the handler methods under test.""" - import importlib - import types - - # Build a minimal namespace that satisfies the imports inside each handler - stub_mod = types.ModuleType("_handler_stubs") - stub_mod.os = __import__("os") - - class _Stub: - pass - - return _Stub() - - # --- delete_schematic_wire --- - - def test_delete_wire_missing_schematic_path(self): - from commands.wire_manager import WireManager - - with patch.object(WireManager, "delete_wire", return_value=False): - # Simulate the handler logic inline - params = {"start": {"x": 0, "y": 0}, "end": {"x": 10, "y": 10}} - schematic_path = params.get("schematicPath") - assert schematic_path is None - # Handler should short-circuit before calling WireManager - result = ( - {"success": False, "message": "schematicPath is required"} - if not schematic_path - else {} - ) - assert result["success"] is False - assert "schematicPath" in result["message"] - - # --- delete_schematic_net_label --- - - def test_delete_label_missing_net_name(self): - params = {"schematicPath": "/some/file.kicad_sch"} - net_name = params.get("netName") - result = ( - { - "success": False, - "message": "schematicPath and netName are required", - } - if not net_name - else {} - ) - assert result["success"] is False - - def test_delete_label_missing_schematic_path(self): - params = {"netName": "VCC"} - schematic_path = params.get("schematicPath") - result = ( - { - "success": False, - "message": "schematicPath and netName are required", - } - if not schematic_path - else {} - ) - assert result["success"] is False - - # --- list_schematic_components --- - - def test_list_components_missing_path(self): - params = {} - schematic_path = params.get("schematicPath") - result = ( - {"success": False, "message": "schematicPath is required"} if not schematic_path else {} - ) - assert result["success"] is False - - # --- list_schematic_nets --- - - def test_list_nets_missing_path(self): - params = {} - result = ( - {"success": False, "message": "schematicPath is required"} - if not params.get("schematicPath") - else {} - ) - assert result["success"] is False - - # --- list_schematic_wires --- - - def test_list_wires_missing_path(self): - params = {} - result = ( - {"success": False, "message": "schematicPath is required"} - if not params.get("schematicPath") - else {} - ) - assert result["success"] is False - - # --- list_schematic_labels --- - - def test_list_labels_missing_path(self): - params = {} - result = ( - {"success": False, "message": "schematicPath is required"} - if not params.get("schematicPath") - else {} - ) - assert result["success"] is False - - # --- move_schematic_component --- - - def test_move_component_missing_reference(self): - params = { - "schematicPath": "/some/file.kicad_sch", - "position": {"x": 10, "y": 20}, - } - result = ( - { - "success": False, - "message": "schematicPath and reference are required", - } - if not params.get("reference") - else {} - ) - assert result["success"] is False - - def test_move_component_missing_position(self): - params = { - "schematicPath": "/some/file.kicad_sch", - "reference": "R1", - "position": {}, - } - new_x = params["position"].get("x") - new_y = params["position"].get("y") - result = ( - {"success": False, "message": "position with x and y is required"} - if new_x is None or new_y is None - else {} - ) - assert result["success"] is False - - # --- rotate_schematic_component --- - - def test_rotate_component_missing_reference(self): - params = {"schematicPath": "/some/file.kicad_sch"} - result = ( - { - "success": False, - "message": "schematicPath and reference are required", - } - if not params.get("reference") - else {} - ) - assert result["success"] is False - - # --- annotate_schematic --- - - def test_annotate_missing_path(self): - params = {} - result = ( - {"success": False, "message": "schematicPath is required"} - if not params.get("schematicPath") - else {} - ) - assert result["success"] is False - - # --- export_schematic_svg --- - - def test_export_svg_missing_output_path(self): - params = {"schematicPath": "/some/file.kicad_sch"} - result = ( - { - "success": False, - "message": "schematicPath and outputPath are required", - } - if not params.get("outputPath") - else {} - ) - assert result["success"] is False +""" +Tests for schematic inspection and editing tools added in the schematic_tools branch. + +Covers: + - WireManager.delete_wire (unit + integration) + - WireManager.delete_label (unit + integration) + - Handler-level parameter validation for the 11 new KiCADInterface handlers + (tested by calling _handle_* methods on a lightweight stub that avoids + importing the full kicad_interface module). +""" + +import shutil +import tempfile +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +import sexpdata + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +TEMPLATES_DIR = Path(__file__).parent.parent / "templates" +EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch" + +# Minimal schematic content used by integration tests +_WIRE_SCH = """\ +(kicad_sch (version 20250114) (generator "test") + (uuid aaaaaaaa-0000-0000-0000-000000000000) + (paper "A4") + (wire (pts (xy 10 20) (xy 30 20)) + (stroke (width 0) (type default)) + (uuid bbbbbbbb-0000-0000-0000-000000000001) + ) + (label "VCC" (at 50 50 0) + (effects (font (size 1.27 1.27)) (justify left bottom)) + (uuid cccccccc-0000-0000-0000-000000000002) + ) + (sheet_instances (path "/" (page "1"))) +) +""" + + +def _write_temp_sch(content: str) -> Path: + """Write *content* to a temp file and return its Path.""" + tmp = tempfile.NamedTemporaryFile(suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8") + tmp.write(content) + tmp.close() + return Path(tmp.name) + + +# --------------------------------------------------------------------------- +# Unit tests – WireManager.delete_wire +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestDeleteWireUnit: + """Unit-level tests for WireManager.delete_wire.""" + + def setup_method(self) -> None: + from commands.wire_manager import WireManager + + self.WireManager = WireManager + + def test_nonexistent_file_returns_false(self, tmp_path: Any) -> None: + result = self.WireManager.delete_wire(tmp_path / "nope.kicad_sch", [0, 0], [10, 10]) + assert result is False + + def test_no_matching_wire_returns_false(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + shutil.copy(EMPTY_SCH, sch) + result = self.WireManager.delete_wire(sch, [99, 99], [100, 100]) + assert result is False + + def test_tolerance_argument_accepted(self, tmp_path: Any) -> None: + """Ensure the tolerance kwarg doesn't raise a TypeError.""" + sch = tmp_path / "test.kicad_sch" + shutil.copy(EMPTY_SCH, sch) + result = self.WireManager.delete_wire(sch, [0, 0], [1, 1], tolerance=0.1) + assert result is False # no wire in empty sch + + +# --------------------------------------------------------------------------- +# Unit tests – WireManager.delete_label +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestDeleteLabelUnit: + """Unit-level tests for WireManager.delete_label.""" + + def setup_method(self) -> None: + from commands.wire_manager import WireManager + + self.WireManager = WireManager + + def test_nonexistent_file_returns_false(self, tmp_path: Any) -> None: + result = self.WireManager.delete_label(tmp_path / "nope.kicad_sch", "VCC") + assert result is False + + def test_missing_label_returns_false(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + shutil.copy(EMPTY_SCH, sch) + result = self.WireManager.delete_label(sch, "NONEXISTENT") + assert result is False + + def test_position_kwarg_accepted(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + shutil.copy(EMPTY_SCH, sch) + result = self.WireManager.delete_label(sch, "VCC", position=[10.0, 20.0], tolerance=0.5) + assert result is False + + +# --------------------------------------------------------------------------- +# Integration tests – WireManager.delete_wire +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestDeleteWireIntegration: + """Integration tests that read/write real .kicad_sch files.""" + + def setup_method(self) -> None: + from commands.wire_manager import WireManager + + self.WireManager = WireManager + + def test_exact_match_deletes_wire(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + + result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0]) + + assert result is True + data = sexpdata.loads(sch.read_text(encoding="utf-8")) + wire_items = [ + item + for item in data + if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire") + ] + assert wire_items == [], "Wire should have been removed from the file" + + def test_reverse_direction_match_deletes_wire(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + + # Pass end/start swapped – should still match + result = self.WireManager.delete_wire(sch, [30.0, 20.0], [10.0, 20.0]) + + assert result is True + + def test_within_tolerance_deletes_wire(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + + # Coordinates differ by 0.3 mm — within default tolerance of 0.5 + result = self.WireManager.delete_wire(sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5) + assert result is True + + def test_outside_tolerance_no_delete(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + + result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0) + # tolerance=0.0 means exact float equality — may still match on most + # platforms, but the key thing is that a *distant* miss is rejected + sch2 = tmp_path / "test2.kicad_sch" + sch2.write_text(_WIRE_SCH, encoding="utf-8") + result2 = self.WireManager.delete_wire(sch2, [10.6, 20.0], [30.0, 20.0], tolerance=0.5) + assert result2 is False, "Coordinate differs by 0.6 mm — outside 0.5 mm tolerance" + + def test_file_is_valid_sexp_after_deletion(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0]) + # Must parse without exception + sexpdata.loads(sch.read_text(encoding="utf-8")) + + def test_label_preserved_after_wire_deletion(self, tmp_path: Any) -> None: + """Deleting a wire must not remove unrelated elements.""" + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0]) + data = sexpdata.loads(sch.read_text(encoding="utf-8")) + labels = [ + item + for item in data + if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label") + ] + assert len(labels) == 1 + + +# --------------------------------------------------------------------------- +# Integration tests – WireManager.delete_label +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestDeleteLabelIntegration: + def setup_method(self) -> None: + from commands.wire_manager import WireManager + + self.WireManager = WireManager + + def test_deletes_label_by_name(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + + result = self.WireManager.delete_label(sch, "VCC") + + assert result is True + data = sexpdata.loads(sch.read_text(encoding="utf-8")) + labels = [ + item + for item in data + if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label") + ] + assert labels == [], "Label should have been removed" + + def test_deletes_label_with_matching_position(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + + result = self.WireManager.delete_label(sch, "VCC", position=[50.0, 50.0]) + assert result is True + + def test_position_mismatch_no_delete(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + + result = self.WireManager.delete_label(sch, "VCC", position=[99.0, 99.0], tolerance=0.5) + assert result is False + + def test_wire_preserved_after_label_deletion(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + self.WireManager.delete_label(sch, "VCC") + data = sexpdata.loads(sch.read_text(encoding="utf-8")) + wires = [ + item + for item in data + if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire") + ] + assert len(wires) == 1 + + def test_file_is_valid_sexp_after_deletion(self, tmp_path: Any) -> None: + sch = tmp_path / "test.kicad_sch" + sch.write_text(_WIRE_SCH, encoding="utf-8") + self.WireManager.delete_label(sch, "VCC") + sexpdata.loads(sch.read_text(encoding="utf-8")) + + +# --------------------------------------------------------------------------- +# Unit tests – handler parameter validation (via lightweight handler stubs) +# --------------------------------------------------------------------------- +# We test the validation logic of the new _handle_* methods without importing +# the full kicad_interface module (which pulls in pcbnew and calls sys.exit). +# Each handler is extracted as a standalone function for testing. + + +def _make_handler_under_test(handler_name: str) -> None: + """ + Return the unbound handler method from kicad_interface by importing only + that method's source via exec, bypassing module-level side effects. + + This works because every _handle_* method starts with a params dict check + before doing any file I/O or heavy imports. + """ + import importlib.util + import types + + # We monkey-patch sys.modules to avoid pcbnew/skip side effects + stubs = {} + for mod in ("pcbnew", "skip", "commands.schematic"): + stubs[mod] = types.ModuleType(mod) + + # Provide a minimal SchematicManager stub so attribute lookups don't fail + schema_stub = types.ModuleType("commands.schematic") + schema_stub.SchematicManager = MagicMock() + stubs["commands.schematic"] = schema_stub + + with patch.dict("sys.modules", stubs): + # Import just the handlers module in isolation isn't feasible for + # kicad_interface.py (module-level sys.exit). Instead, we directly + # call the method on a MagicMock instance, binding the real function. + pass + + return None # Not used; see TestHandlerParamValidation below + + +@pytest.mark.unit +class TestHandlerParamValidation: + """ + Verify that each new handler returns success=False with an informative + message when required parameters are missing, without needing real files. + + We call the handler functions directly after building minimal stub objects + that satisfy the dependency chain up to the first parameter-check branch. + """ + + def _make_iface_stub(self) -> Any: + """Return a stub that exposes only the handler methods under test.""" + import importlib + import types + + # Build a minimal namespace that satisfies the imports inside each handler + stub_mod = types.ModuleType("_handler_stubs") + stub_mod.os = __import__("os") + + class _Stub: + pass + + return _Stub() + + # --- delete_schematic_wire --- + + def test_delete_wire_missing_schematic_path(self) -> None: + from commands.wire_manager import WireManager + + with patch.object(WireManager, "delete_wire", return_value=False): + # Simulate the handler logic inline + params = {"start": {"x": 0, "y": 0}, "end": {"x": 10, "y": 10}} + schematic_path = params.get("schematicPath") + assert schematic_path is None + # Handler should short-circuit before calling WireManager + result: dict[str, Any] = ( + {"success": False, "message": "schematicPath is required"} + if not schematic_path + else {} + ) + assert result["success"] is False + assert "schematicPath" in result["message"] + + # --- delete_schematic_net_label --- + + def test_delete_label_missing_net_name(self) -> None: + params = {"schematicPath": "/some/file.kicad_sch"} + net_name = params.get("netName") + result = ( + { + "success": False, + "message": "schematicPath and netName are required", + } + if not net_name + else {} + ) + assert result["success"] is False + + def test_delete_label_missing_schematic_path(self) -> None: + params = {"netName": "VCC"} + schematic_path = params.get("schematicPath") + result = ( + { + "success": False, + "message": "schematicPath and netName are required", + } + if not schematic_path + else {} + ) + assert result["success"] is False + + # --- list_schematic_components --- + + def test_list_components_missing_path(self) -> None: + params = {} + schematic_path = params.get("schematicPath") + result = ( + {"success": False, "message": "schematicPath is required"} if not schematic_path else {} + ) + assert result["success"] is False + + # --- list_schematic_nets --- + + def test_list_nets_missing_path(self) -> None: + params = {} + result = ( + {"success": False, "message": "schematicPath is required"} + if not params.get("schematicPath") + else {} + ) + assert result["success"] is False + + # --- list_schematic_wires --- + + def test_list_wires_missing_path(self) -> None: + params = {} + result = ( + {"success": False, "message": "schematicPath is required"} + if not params.get("schematicPath") + else {} + ) + assert result["success"] is False + + # --- list_schematic_labels --- + + def test_list_labels_missing_path(self) -> None: + params = {} + result = ( + {"success": False, "message": "schematicPath is required"} + if not params.get("schematicPath") + else {} + ) + assert result["success"] is False + + # --- move_schematic_component --- + + def test_move_component_missing_reference(self) -> None: + params = { + "schematicPath": "/some/file.kicad_sch", + "position": {"x": 10, "y": 20}, + } + result = ( + { + "success": False, + "message": "schematicPath and reference are required", + } + if not params.get("reference") + else {} + ) + assert result["success"] is False + + def test_move_component_missing_position(self) -> None: + params = { + "schematicPath": "/some/file.kicad_sch", + "reference": "R1", + "position": {}, + } + new_x = params["position"].get("x") + new_y = params["position"].get("y") + result = ( + {"success": False, "message": "position with x and y is required"} + if new_x is None or new_y is None + else {} + ) + assert result["success"] is False + + # --- rotate_schematic_component --- + + def test_rotate_component_missing_reference(self) -> None: + params = {"schematicPath": "/some/file.kicad_sch"} + result = ( + { + "success": False, + "message": "schematicPath and reference are required", + } + if not params.get("reference") + else {} + ) + assert result["success"] is False + + # --- annotate_schematic --- + + def test_annotate_missing_path(self) -> None: + params = {} + result = ( + {"success": False, "message": "schematicPath is required"} + if not params.get("schematicPath") + else {} + ) + assert result["success"] is False + + # --- export_schematic_svg --- + + def test_export_svg_missing_output_path(self) -> None: + params = {"schematicPath": "/some/file.kicad_sch"} + result = ( + { + "success": False, + "message": "schematicPath and outputPath are required", + } + if not params.get("outputPath") + else {} + ) + assert result["success"] is False diff --git a/python/tests/test_wire_connectivity.py b/python/tests/test_wire_connectivity.py index 6df3c8c..06bb41a 100644 --- a/python/tests/test_wire_connectivity.py +++ b/python/tests/test_wire_connectivity.py @@ -1,332 +1,332 @@ -""" -Tests for the wire_connectivity module and the get_wire_connections handler. - -Covers: - - Schema shape (TestSchema) - - Handler dispatch registration (TestHandlerDispatch) - - Parameter validation in the handler (TestHandlerParamValidation) - - Core logic: _to_iu, _parse_wires, _build_adjacency, _find_connected_wires, - get_wire_connections (TestCoreLogic) -""" - -import sys -from pathlib import Path -from typing import Any -from unittest.mock import MagicMock, patch - -import pytest - -# Ensure the python package root is importable -sys.path.insert(0, str(Path(__file__).parent.parent)) - -# --------------------------------------------------------------------------- -# Module under test -# --------------------------------------------------------------------------- -from commands.wire_connectivity import ( - _build_adjacency, - _find_connected_wires, - _parse_wires, - _to_iu, - get_wire_connections, -) - -# --------------------------------------------------------------------------- -# Helpers to build minimal mock schematic objects -# --------------------------------------------------------------------------- - - -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(*wires) -> MagicMock: - sch = MagicMock() - sch.wire = list(wires) - # No net labels, no symbols by default - del sch.label # make hasattr(..., "label") return False - del sch.symbol # make hasattr(..., "symbol") return False - return sch - - -# --------------------------------------------------------------------------- -# TestSchema -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestSchema: - """Verify the get_wire_connections tool schema is present and well-formed.""" - - def test_schema_registered(self): - from schemas.tool_schemas import TOOL_SCHEMAS - - assert "get_wire_connections" in TOOL_SCHEMAS - - def test_schema_required_fields(self): - from schemas.tool_schemas import TOOL_SCHEMAS - - schema = TOOL_SCHEMAS["get_wire_connections"] - required = schema["inputSchema"]["required"] - assert "schematicPath" in required - assert "x" in required - assert "y" in required - - def test_schema_has_title_and_description(self): - from schemas.tool_schemas import TOOL_SCHEMAS - - schema = TOOL_SCHEMAS["get_wire_connections"] - assert schema.get("title") - assert schema.get("description") - - -# --------------------------------------------------------------------------- -# TestHandlerDispatch -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestHandlerDispatch: - """Verify the handler is wired into KiCadInterface.command_routes.""" - - def test_get_wire_connections_in_routes(self): - # Import lazily to avoid heavy side-effects at collection time - 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() - - # Build routes only (avoid full __init__ side-effects) - # The routes dict is built in __init__; we call it directly. - iface.__init__() - - assert "get_wire_connections" in iface.command_routes - assert callable(iface.command_routes["get_wire_connections"]) - - -# --------------------------------------------------------------------------- -# TestHandlerParamValidation -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestHandlerParamValidation: - """Handler returns error responses for bad or missing parameters.""" - - def _make_handler(self): - """Return a bound _handle_get_wire_connections without full init.""" - 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_missing_schematic_path(self): - 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): - handler = self._make_handler() - result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 2.0}) - assert result["success"] is False - - def test_missing_y(self): - handler = self._make_handler() - result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0}) - assert result["success"] is False - - def test_non_numeric_x(self): - 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): - handler = self._make_handler() - result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0, "y": "bad"}) - assert result["success"] is False - - -# --------------------------------------------------------------------------- -# TestCoreLogic -# --------------------------------------------------------------------------- - -_IU = 10_000 # IU per mm - - -@pytest.mark.unit -class TestCoreLogic: - """Unit tests for the pure-logic functions in wire_connectivity.""" - - # --- _to_iu --- - - def test_to_iu_integer_mm(self): - assert _to_iu(1.0, 2.0) == (10_000, 20_000) - - def test_to_iu_fractional_mm(self): - assert _to_iu(0.5, 0.25) == (5_000, 2_500) - - def test_to_iu_zero(self): - assert _to_iu(0.0, 0.0) == (0, 0) - - def test_to_iu_negative(self): - assert _to_iu(-1.0, -2.0) == (-10_000, -20_000) - - # --- _parse_wires --- - - def test_parse_wires_single_wire(self): - sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) - result = _parse_wires(sch) - assert len(result) == 1 - assert result[0] == [(0, 0), (10_000, 0)] - - def test_parse_wires_empty_schematic(self): - sch = MagicMock() - sch.wire = [] - assert _parse_wires(sch) == [] - - def test_parse_wires_multiple_wires(self): - sch = _make_schematic( - _make_wire(0.0, 0.0, 1.0, 0.0), - _make_wire(1.0, 0.0, 2.0, 0.0), - ) - assert len(_parse_wires(sch)) == 2 - - def test_parse_wires_skips_wire_without_pts(self): - bad_wire = MagicMock(spec=[]) # no `pts` attribute - sch = MagicMock() - sch.wire = [bad_wire] - assert _parse_wires(sch) == [] - - # --- _build_adjacency --- - - def test_build_adjacency_two_connected_wires(self): - # wire0: (0,0)-(1,0), wire1: (1,0)-(2,0) — share endpoint (1,0) - wires = [ - [(0, 0), (10_000, 0)], - [(10_000, 0), (20_000, 0)], - ] - adjacency, iu_to_wires = _build_adjacency(wires) - assert 1 in adjacency[0] - assert 0 in adjacency[1] - - def test_build_adjacency_two_disconnected_wires(self): - wires = [ - [(0, 0), (10_000, 0)], - [(20_000, 0), (30_000, 0)], - ] - adjacency, _ = _build_adjacency(wires) - assert adjacency[0] == set() - assert adjacency[1] == set() - - def test_build_adjacency_iu_to_wires_maps_correctly(self): - wires = [ - [(0, 0), (10_000, 0)], - [(10_000, 0), (20_000, 0)], - ] - _, iu_to_wires = _build_adjacency(wires) - assert iu_to_wires[(10_000, 0)] == {0, 1} - assert iu_to_wires[(0, 0)] == {0} - - def test_build_adjacency_three_wires_at_junction(self): - # All three wires meet at (10,000, 0) - wires = [ - [(0, 0), (10_000, 0)], - [(10_000, 0), (20_000, 0)], - [(10_000, 0), (10_000, 10_000)], - ] - adjacency, _ = _build_adjacency(wires) - assert adjacency[0] == {1, 2} - assert adjacency[1] == {0, 2} - assert adjacency[2] == {0, 1} - - # --- _find_connected_wires --- - - def test_find_connected_wires_no_wire_at_point(self): - wires = [[(0, 0), (10_000, 0)]] - adjacency, iu_to_wires = _build_adjacency(wires) - visited, net_points = _find_connected_wires(5.0, 0.0, wires, iu_to_wires, adjacency) - assert visited is None - assert net_points is None - - def test_find_connected_wires_single_wire(self): - wires = [[(0, 0), (10_000, 0)]] - adjacency, iu_to_wires = _build_adjacency(wires) - visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) - assert visited == {0} - assert (0, 0) in net_points - assert (10_000, 0) in net_points - - def test_find_connected_wires_flood_fills_chain(self): - # Three wires in a chain: A-B-C-D - wires = [ - [(0, 0), (10_000, 0)], - [(10_000, 0), (20_000, 0)], - [(20_000, 0), (30_000, 0)], - ] - adjacency, iu_to_wires = _build_adjacency(wires) - visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) - assert visited == {0, 1, 2} - - def test_find_connected_wires_does_not_cross_gap(self): - # Two disconnected segments; query on segment 0 should not reach segment 1 - wires = [ - [(0, 0), (10_000, 0)], - [(20_000, 0), (30_000, 0)], - ] - adjacency, iu_to_wires = _build_adjacency(wires) - visited, _ = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) - assert visited == {0} - - # --- get_wire_connections (integration of internal functions) --- - - def test_get_wire_connections_no_wires(self): - sch = MagicMock() - sch.wire = [] - result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) - assert result == {"pins": [], "wires": []} - - def test_get_wire_connections_no_wire_at_point_returns_none(self): - 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 - - def test_get_wire_connections_returns_wire_data(self): - 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) - assert result is not None - assert result["pins"] == [] - assert len(result["wires"]) == 1 - wire = result["wires"][0] - assert wire["start"] == {"x": 0.0, "y": 0.0} - assert wire["end"] == {"x": 1.0, "y": 0.0} - - def test_get_wire_connections_chain_returns_all_wires(self): - sch = _make_schematic( - _make_wire(0.0, 0.0, 1.0, 0.0), - _make_wire(1.0, 0.0, 2.0, 0.0), - ) - result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) - assert result is not None - assert len(result["wires"]) == 2 +""" +Tests for the wire_connectivity module and the get_wire_connections handler. + +Covers: + - Schema shape (TestSchema) + - Handler dispatch registration (TestHandlerDispatch) + - Parameter validation in the handler (TestHandlerParamValidation) + - Core logic: _to_iu, _parse_wires, _build_adjacency, _find_connected_wires, + get_wire_connections (TestCoreLogic) +""" + +import sys +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +# Ensure the python package root is importable +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# --------------------------------------------------------------------------- +# Module under test +# --------------------------------------------------------------------------- +from commands.wire_connectivity import ( + _build_adjacency, + _find_connected_wires, + _parse_wires, + _to_iu, + get_wire_connections, +) + +# --------------------------------------------------------------------------- +# Helpers to build minimal mock schematic objects +# --------------------------------------------------------------------------- + + +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(*wires: Any) -> MagicMock: + sch = MagicMock() + sch.wire = list(wires) + # No net labels, no symbols by default + del sch.label # make hasattr(..., "label") return False + del sch.symbol # make hasattr(..., "symbol") return False + return sch + + +# --------------------------------------------------------------------------- +# TestSchema +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSchema: + """Verify the get_wire_connections tool schema is present and well-formed.""" + + def test_schema_registered(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + assert "get_wire_connections" in TOOL_SCHEMAS + + def test_schema_required_fields(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + schema = TOOL_SCHEMAS["get_wire_connections"] + required = schema["inputSchema"]["required"] + assert "schematicPath" in required + assert "x" in required + assert "y" in required + + def test_schema_has_title_and_description(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + schema = TOOL_SCHEMAS["get_wire_connections"] + assert schema.get("title") + assert schema.get("description") + + +# --------------------------------------------------------------------------- +# TestHandlerDispatch +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestHandlerDispatch: + """Verify the handler is wired into KiCadInterface.command_routes.""" + + def test_get_wire_connections_in_routes(self) -> None: + # Import lazily to avoid heavy side-effects at collection time + 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() + + # Build routes only (avoid full __init__ side-effects) + # The routes dict is built in __init__; we call it directly. + KiCADInterface.__init__(iface) + + assert "get_wire_connections" in iface.command_routes + assert callable(iface.command_routes["get_wire_connections"]) + + +# --------------------------------------------------------------------------- +# TestHandlerParamValidation +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestHandlerParamValidation: + """Handler returns error responses for bad or missing parameters.""" + + def _make_handler(self) -> Any: + """Return a bound _handle_get_wire_connections without full init.""" + 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_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_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 + + +# --------------------------------------------------------------------------- +# TestCoreLogic +# --------------------------------------------------------------------------- + +_IU = 10_000 # IU per mm + + +@pytest.mark.unit +class TestCoreLogic: + """Unit tests for the pure-logic functions in wire_connectivity.""" + + # --- _to_iu --- + + def test_to_iu_integer_mm(self) -> None: + assert _to_iu(1.0, 2.0) == (10_000, 20_000) + + def test_to_iu_fractional_mm(self) -> None: + assert _to_iu(0.5, 0.25) == (5_000, 2_500) + + def test_to_iu_zero(self) -> None: + assert _to_iu(0.0, 0.0) == (0, 0) + + def test_to_iu_negative(self) -> None: + assert _to_iu(-1.0, -2.0) == (-10_000, -20_000) + + # --- _parse_wires --- + + def test_parse_wires_single_wire(self) -> None: + sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) + result = _parse_wires(sch) + assert len(result) == 1 + assert result[0] == [(0, 0), (10_000, 0)] + + def test_parse_wires_empty_schematic(self) -> None: + sch = MagicMock() + sch.wire = [] + assert _parse_wires(sch) == [] + + def test_parse_wires_multiple_wires(self) -> None: + sch = _make_schematic( + _make_wire(0.0, 0.0, 1.0, 0.0), + _make_wire(1.0, 0.0, 2.0, 0.0), + ) + assert len(_parse_wires(sch)) == 2 + + def test_parse_wires_skips_wire_without_pts(self) -> None: + bad_wire = MagicMock(spec=[]) # no `pts` attribute + sch = MagicMock() + sch.wire = [bad_wire] + assert _parse_wires(sch) == [] + + # --- _build_adjacency --- + + def test_build_adjacency_two_connected_wires(self) -> None: + # wire0: (0,0)-(1,0), wire1: (1,0)-(2,0) — share endpoint (1,0) + wires = [ + [(0, 0), (10_000, 0)], + [(10_000, 0), (20_000, 0)], + ] + adjacency, iu_to_wires = _build_adjacency(wires) + assert 1 in adjacency[0] + assert 0 in adjacency[1] + + def test_build_adjacency_two_disconnected_wires(self) -> None: + wires = [ + [(0, 0), (10_000, 0)], + [(20_000, 0), (30_000, 0)], + ] + adjacency, _ = _build_adjacency(wires) + assert adjacency[0] == set() + assert adjacency[1] == set() + + def test_build_adjacency_iu_to_wires_maps_correctly(self) -> None: + wires = [ + [(0, 0), (10_000, 0)], + [(10_000, 0), (20_000, 0)], + ] + _, iu_to_wires = _build_adjacency(wires) + assert iu_to_wires[(10_000, 0)] == {0, 1} + assert iu_to_wires[(0, 0)] == {0} + + def test_build_adjacency_three_wires_at_junction(self) -> None: + # All three wires meet at (10,000, 0) + wires = [ + [(0, 0), (10_000, 0)], + [(10_000, 0), (20_000, 0)], + [(10_000, 0), (10_000, 10_000)], + ] + adjacency, _ = _build_adjacency(wires) + assert adjacency[0] == {1, 2} + assert adjacency[1] == {0, 2} + assert adjacency[2] == {0, 1} + + # --- _find_connected_wires --- + + def test_find_connected_wires_no_wire_at_point(self) -> None: + wires = [[(0, 0), (10_000, 0)]] + adjacency, iu_to_wires = _build_adjacency(wires) + visited, net_points = _find_connected_wires(5.0, 0.0, wires, iu_to_wires, adjacency) + assert visited is None + assert net_points is None + + def test_find_connected_wires_single_wire(self) -> None: + wires = [[(0, 0), (10_000, 0)]] + adjacency, iu_to_wires = _build_adjacency(wires) + visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) + assert visited == {0} + assert (0, 0) in net_points + assert (10_000, 0) in net_points + + def test_find_connected_wires_flood_fills_chain(self) -> None: + # Three wires in a chain: A-B-C-D + wires = [ + [(0, 0), (10_000, 0)], + [(10_000, 0), (20_000, 0)], + [(20_000, 0), (30_000, 0)], + ] + adjacency, iu_to_wires = _build_adjacency(wires) + visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) + assert visited == {0, 1, 2} + + def test_find_connected_wires_does_not_cross_gap(self) -> None: + # Two disconnected segments; query on segment 0 should not reach segment 1 + wires = [ + [(0, 0), (10_000, 0)], + [(20_000, 0), (30_000, 0)], + ] + adjacency, iu_to_wires = _build_adjacency(wires) + visited, _ = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) + assert visited == {0} + + # --- get_wire_connections (integration of internal functions) --- + + def test_get_wire_connections_no_wires(self) -> None: + sch = MagicMock() + sch.wire = [] + result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) + assert result == {"pins": [], "wires": []} + + 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)) + result = get_wire_connections(sch, "/fake/path.kicad_sch", 5.0, 0.0) + assert result is None + + def test_get_wire_connections_returns_wire_data(self) -> None: + 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) + assert result is not None + assert result["pins"] == [] + assert len(result["wires"]) == 1 + wire = result["wires"][0] + assert wire["start"] == {"x": 0.0, "y": 0.0} + assert wire["end"] == {"x": 1.0, "y": 0.0} + + def test_get_wire_connections_chain_returns_all_wires(self) -> None: + sch = _make_schematic( + _make_wire(0.0, 0.0, 1.0, 0.0), + _make_wire(1.0, 0.0, 2.0, 0.0), + ) + result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) + assert result is not None + assert len(result["wires"]) == 2 diff --git a/python/tests/test_wire_junction_changes.py b/python/tests/test_wire_junction_changes.py index 01bda02..063a4fd 100644 --- a/python/tests/test_wire_junction_changes.py +++ b/python/tests/test_wire_junction_changes.py @@ -1,1009 +1,1010 @@ -""" -Tests for fix/tool-schema-descriptions branch changes: -- add_schematic_wire: waypoints param, pin snapping, polyline routing -- add_schematic_junction: new tool replacing add_schematic_connection -- Schema updates in tool_schemas.py -- ConnectionManager orphaned method removal -""" - -import shutil -import sys -import tempfile -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -import sexpdata -from sexpdata import Symbol - -# Add python dir to path -PYTHON_DIR = Path(__file__).parent.parent -sys.path.insert(0, str(PYTHON_DIR)) - -TEMPLATES_DIR = PYTHON_DIR / "templates" -EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch" - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_temp_sch(): - """Copy the empty schematic template to a temp file and return the Path.""" - tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" - shutil.copy(EMPTY_SCH, tmp) - return tmp - - -def _parse_sch(path: Path): - """Parse a .kicad_sch file and return the S-expression list.""" - with open(path, "r", encoding="utf-8") as f: - return sexpdata.loads(f.read()) - - -def _find_elements(sch_data, tag: str): - """Return all top-level S-expression elements with the given tag Symbol.""" - return [item for item in sch_data if isinstance(item, list) and item and item[0] == Symbol(tag)] - - -# --------------------------------------------------------------------------- -# 1. Schema tests -# --------------------------------------------------------------------------- - - -class TestSchemas: - """Verify tool_schemas.py reflects the new API.""" - - @pytest.fixture(autouse=True) - def load_schemas(self): - from schemas.tool_schemas import SCHEMATIC_TOOLS - - self.tools = {t["name"]: t for t in SCHEMATIC_TOOLS} - - def test_add_schematic_wire_has_waypoints(self): - schema = self.tools["add_schematic_wire"]["inputSchema"] - assert "waypoints" in schema["properties"], "waypoints must be a property" - assert "waypoints" in schema["required"] - - def test_add_schematic_wire_has_schematic_path(self): - schema = self.tools["add_schematic_wire"]["inputSchema"] - assert "schematicPath" in schema["properties"] - assert "schematicPath" in schema["required"] - - def test_add_schematic_wire_has_snap_params(self): - schema = self.tools["add_schematic_wire"]["inputSchema"] - props = schema["properties"] - assert "snapToPins" in props - assert props["snapToPins"]["type"] == "boolean" - assert "snapTolerance" in props - assert props["snapTolerance"]["type"] == "number" - - def test_add_schematic_wire_no_old_point_params(self): - schema = self.tools["add_schematic_wire"]["inputSchema"] - props = schema["properties"] - assert "startPoint" not in props, "startPoint should be removed" - assert "endPoint" not in props, "endPoint should be removed" - - def test_add_schematic_connection_removed(self): - assert ( - "add_schematic_connection" not in self.tools - ), "add_schematic_connection must not appear in SCHEMATIC_TOOLS" - - def test_add_schematic_junction_present(self): - assert "add_schematic_junction" in self.tools - - def test_add_schematic_junction_schema(self): - schema = self.tools["add_schematic_junction"]["inputSchema"] - props = schema["properties"] - assert "schematicPath" in props - assert "position" in props - assert set(schema["required"]) >= {"schematicPath", "position"} - - def test_add_schematic_junction_position_is_array(self): - schema = self.tools["add_schematic_junction"]["inputSchema"] - pos = schema["properties"]["position"] - assert pos["type"] == "array" - assert pos.get("minItems") == 2 - assert pos.get("maxItems") == 2 - - -# --------------------------------------------------------------------------- -# 2. Handler dispatch tests -# --------------------------------------------------------------------------- - - -class TestHandlerDispatch: - """Verify KiCADInterface registers the right tool handlers.""" - - @pytest.fixture(autouse=True) - def load_handler_map(self): - # Import only the dispatch table without initialising KiCAD connections - import importlib - import types - - # Patch heavy imports before loading kicad_interface - for mod in ["pcbnew", "skip"]: - sys.modules.setdefault(mod, types.ModuleType(mod)) - - from kicad_interface import KiCADInterface - - # Peek at the dispatch table by instantiating with mocked internals - with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): - obj = KiCADInterface.__new__(KiCADInterface) - # Manually set attributes that __init__ normally provides - obj._backend = None - # Build the handler map the same way the real __init__ does - obj._tool_handlers = { - "add_schematic_wire": obj._handle_add_schematic_wire, - "add_schematic_junction": obj._handle_add_schematic_junction, - "add_schematic_net_label": obj._handle_add_schematic_net_label, - } - self.handlers = obj._tool_handlers - - def test_add_schematic_wire_registered(self): - from kicad_interface import KiCADInterface - - # Just verify the class has the handler method - assert hasattr(KiCADInterface, "_handle_add_schematic_wire") - - def test_add_schematic_junction_registered(self): - from kicad_interface import KiCADInterface - - assert hasattr(KiCADInterface, "_handle_add_schematic_junction") - - def test_add_schematic_connection_not_present(self): - from kicad_interface import KiCADInterface - - assert not hasattr( - KiCADInterface, "_handle_add_schematic_connection" - ), "_handle_add_schematic_connection should be removed" - - -# --------------------------------------------------------------------------- -# 3. _handle_add_schematic_wire — parameter validation -# --------------------------------------------------------------------------- - - -class TestHandleAddSchematicWireValidation: - """Unit tests for _handle_add_schematic_wire validation paths (no disk I/O).""" - - @pytest.fixture(autouse=True) - def handler(self): - import types - - 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): - self.iface = KiCADInterface.__new__(KiCADInterface) - - def test_missing_schematic_path(self): - result = self.iface._handle_add_schematic_wire({"waypoints": [[0, 0], [10, 0]]}) - assert result["success"] is False - assert "Schematic path" in result["message"] - - def test_missing_waypoints(self): - result = self.iface._handle_add_schematic_wire({"schematicPath": "/tmp/x.kicad_sch"}) - assert result["success"] is False - assert "waypoint" in result["message"].lower() - - def test_single_waypoint_rejected(self): - result = self.iface._handle_add_schematic_wire( - { - "schematicPath": "/tmp/x.kicad_sch", - "waypoints": [[0, 0]], - } - ) - assert result["success"] is False - assert "waypoint" in result["message"].lower() - - -# --------------------------------------------------------------------------- -# 4. _handle_add_schematic_wire — wire routing logic -# --------------------------------------------------------------------------- - - -class TestHandleAddSchematicWireRouting: - """Unit tests verifying add_wire vs add_polyline_wire dispatch, no pin snapping.""" - - @pytest.fixture(autouse=True) - def setup(self): - import types - - 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): - self.iface = KiCADInterface.__new__(KiCADInterface) - self.sch_path = _make_temp_sch() - yield - # cleanup - shutil.rmtree(self.sch_path.parent, ignore_errors=True) - - @patch("commands.wire_manager.WireManager.add_wire", return_value=True) - def test_two_waypoints_calls_add_wire(self, mock_add_wire): - result = self.iface._handle_add_schematic_wire( - { - "schematicPath": str(self.sch_path), - "waypoints": [[10.0, 20.0], [30.0, 20.0]], - "snapToPins": False, - } - ) - assert result["success"] is True - mock_add_wire.assert_called_once() - args = mock_add_wire.call_args[0] - assert args[1] == [10.0, 20.0] - assert args[2] == [30.0, 20.0] - - @patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) - def test_four_waypoints_calls_add_polyline_wire(self, mock_poly): - result = self.iface._handle_add_schematic_wire( - { - "schematicPath": str(self.sch_path), - "waypoints": [[0, 0], [10, 0], [10, 10], [20, 10]], - "snapToPins": False, - } - ) - assert result["success"] is True - mock_poly.assert_called_once() - - def test_points_key_without_waypoints_is_rejected(self): - """'points' key alone (without 'waypoints') is rejected — no fallback.""" - result = self.iface._handle_add_schematic_wire( - { - "schematicPath": str(self.sch_path), - "points": [[5.0, 5.0], [15.0, 5.0]], - "snapToPins": False, - } - ) - assert result["success"] is False - assert "waypoint" in result["message"].lower() - - @patch("commands.wire_manager.WireManager.add_wire", return_value=False) - def test_failure_response(self, _): - result = self.iface._handle_add_schematic_wire( - { - "schematicPath": str(self.sch_path), - "waypoints": [[0, 0], [10, 0]], - "snapToPins": False, - } - ) - assert result["success"] is False - - -# --------------------------------------------------------------------------- -# 5. _handle_add_schematic_wire — pin snapping -# --------------------------------------------------------------------------- - - -class TestPinSnapping: - """Verify pin snapping logic snaps endpoints correctly.""" - - @pytest.fixture(autouse=True) - def setup(self): - import types - - # Provide a minimal skip.Schematic stub so the handler can import it - skip_mod = types.ModuleType("skip") - - class FakeSchematic: - def __init__(self, path): - pass - - @property - def symbol(self): - return [] # no symbols → no pins in snapping loop - - skip_mod.Schematic = FakeSchematic - sys.modules["skip"] = skip_mod - sys.modules.setdefault("pcbnew", types.ModuleType("pcbnew")) - - from kicad_interface import KiCADInterface - - with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): - self.iface = KiCADInterface.__new__(KiCADInterface) - - self.sch_path = _make_temp_sch() - yield - shutil.rmtree(self.sch_path.parent, ignore_errors=True) - - @patch("commands.wire_manager.WireManager.add_wire", return_value=True) - @patch("commands.pin_locator.PinLocator.get_all_symbol_pins") - def test_start_point_snapped_within_tolerance(self, mock_pins, mock_wire): - """First waypoint within tolerance of a pin should be snapped to pin coords.""" - # get_all_symbol_pins won't be called because symbol list is empty in fixture. - # Instead we patch find_nearest_pin indirectly by providing all_pins via the - # skip.Schematic stub that returns one symbol with a known pin. - import types - - skip_mod = sys.modules["skip"] - - class FakeSymbol: - class property: - class Reference: - value = "R1" - - def __init__(self): - pass - - skip_mod.Schematic = lambda path: type("FakeSch", (), {"symbol": [FakeSymbol()]})() - - mock_pins.return_value = {"1": [10.0, 20.0], "2": [10.0, 30.0]} - - # Re-import so the patched skip.Schematic is used - import importlib - - import kicad_interface - - importlib.reload(kicad_interface) - from kicad_interface import KiCADInterface - - with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): - iface = KiCADInterface.__new__(KiCADInterface) - - with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw: - result = iface._handle_add_schematic_wire( - { - "schematicPath": str(self.sch_path), - "waypoints": [[10.05, 20.05], [50.0, 20.0]], - "snapToPins": True, - "snapTolerance": 1.0, - } - ) - if result["success"]: - called_start = mw.call_args[0][1] - assert called_start == [ - 10.0, - 20.0, - ], f"Start should snap to [10.0, 20.0], got {called_start}" - # If it failed due to stub issues, just verify no exception - - def test_snap_disabled_passes_original_coords(self): - """With snapToPins=False the handler should not load PinLocator at all.""" - with ( - patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw, - patch("commands.pin_locator.PinLocator") as mock_locator_cls, - ): - result = self.iface._handle_add_schematic_wire( - { - "schematicPath": str(self.sch_path), - "waypoints": [[1.0, 2.0], [3.0, 4.0]], - "snapToPins": False, - } - ) - mock_locator_cls.assert_not_called() - assert result["success"] is True - called_start = mw.call_args[0][1] - assert called_start == [1.0, 2.0] - - @patch("commands.wire_manager.WireManager.add_wire", return_value=True) - def test_snap_miss_leaves_coords_unchanged(self, mock_wire): - """Point beyond tolerance should not be snapped.""" - with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw: - result = self.iface._handle_add_schematic_wire( - { - "schematicPath": str(self.sch_path), - "waypoints": [[100.0, 100.0], [200.0, 100.0]], - "snapToPins": True, - "snapTolerance": 0.5, - # skip.Schematic returns no symbols (fixture), so no pins to snap to - } - ) - assert result["success"] is True - # No snapping info in message - assert "snapped" not in result.get("message", "") - - @patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) - def test_intermediate_waypoints_not_snapped(self, mock_poly): - """Middle waypoints must remain unchanged even with snapToPins=True.""" - mid = [50.0, 50.0] - with patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) as mp: - result = self.iface._handle_add_schematic_wire( - { - "schematicPath": str(self.sch_path), - "waypoints": [[100.0, 100.0], mid[:], [200.0, 100.0]], - "snapToPins": True, - "snapTolerance": 100.0, # huge tolerance, but mid must not snap - } - ) - assert result["success"] is True - called_points = mp.call_args[0][1] - assert ( - called_points[1] == mid - ), f"Middle waypoint should not be snapped, got {called_points[1]}" - - -# --------------------------------------------------------------------------- -# 6. _handle_add_schematic_junction — unit tests -# --------------------------------------------------------------------------- - - -class TestHandleAddSchematicJunction: - - @pytest.fixture(autouse=True) - def setup(self): - import types - - 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): - self.iface = KiCADInterface.__new__(KiCADInterface) - - def test_missing_schematic_path(self): - result = self.iface._handle_add_schematic_junction({"position": [10.0, 20.0]}) - assert result["success"] is False - assert "Schematic path" in result["message"] - - def test_missing_position(self): - result = self.iface._handle_add_schematic_junction({"schematicPath": "/tmp/x.kicad_sch"}) - assert result["success"] is False - assert "Position" in result["message"] - - @patch("commands.wire_manager.WireManager.add_junction", return_value=True) - def test_success(self, mock_jct): - sch = _make_temp_sch() - try: - result = self.iface._handle_add_schematic_junction( - { - "schematicPath": str(sch), - "position": [25.4, 25.4], - } - ) - assert result["success"] is True - assert "Junction added" in result["message"] - mock_jct.assert_called_once_with(sch, [25.4, 25.4]) - finally: - shutil.rmtree(sch.parent, ignore_errors=True) - - @patch("commands.wire_manager.WireManager.add_junction", return_value=False) - def test_failure(self, _): - sch = _make_temp_sch() - try: - result = self.iface._handle_add_schematic_junction( - { - "schematicPath": str(sch), - "position": [25.4, 25.4], - } - ) - assert result["success"] is False - assert "Failed" in result["message"] - finally: - shutil.rmtree(sch.parent, ignore_errors=True) - - -# --------------------------------------------------------------------------- -# 7. ConnectionManager — orphaned methods removed -# --------------------------------------------------------------------------- - - -class TestConnectionManagerOrphanedMethodsRemoved: - - def test_add_wire_removed(self): - from commands.connection_schematic import ConnectionManager - - assert not hasattr( - ConnectionManager, "add_wire" - ), "ConnectionManager.add_wire should have been removed" - - def test_add_connection_removed(self): - from commands.connection_schematic import ConnectionManager - - assert not hasattr( - ConnectionManager, "add_connection" - ), "ConnectionManager.add_connection should have been removed" - - def test_get_pin_location_removed(self): - from commands.connection_schematic import ConnectionManager - - assert not hasattr( - ConnectionManager, "get_pin_location" - ), "ConnectionManager.get_pin_location should have been removed" - - -# --------------------------------------------------------------------------- -# 8. Integration tests — real disk I/O, no KiCAD process -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -class TestIntegrationWireManager: - """Integration tests using real schematic files and WireManager.""" - - @pytest.fixture(autouse=True) - def sch(self): - path = _make_temp_sch() - yield path - shutil.rmtree(path.parent, ignore_errors=True) - - def test_add_wire_writes_wire_element(self, sch): - from commands.wire_manager import WireManager - - ok = WireManager.add_wire(sch, [10.0, 10.0], [30.0, 10.0]) - assert ok is True - data = _parse_sch(sch) - wires = _find_elements(data, "wire") - assert len(wires) == 1 - - def test_add_polyline_wire_creates_segments(self, sch): - """N waypoints should produce N-1 individual 2-point wire segments.""" - from commands.wire_manager import WireManager - - pts = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [20.0, 10.0]] - ok = WireManager.add_polyline_wire(sch, pts) - assert ok is True - data = _parse_sch(sch) - wires = _find_elements(data, "wire") - assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}" - - def test_add_junction_writes_junction_element(self, sch): - from commands.wire_manager import WireManager - - ok = WireManager.add_junction(sch, [25.4, 25.4]) - assert ok is True - data = _parse_sch(sch) - junctions = _find_elements(data, "junction") - assert len(junctions) == 1 - # Verify position - at = junctions[0][1] # (at x y) - assert at[1] == 25.4 - assert at[2] == 25.4 - - def test_wire_endpoint_coordinates_match(self, sch): - from commands.wire_manager import WireManager - - WireManager.add_wire(sch, [5.0, 7.5], [15.0, 7.5]) - data = _parse_sch(sch) - wire = _find_elements(data, "wire")[0] - pts = [ - item for item in wire if isinstance(item, list) and item and item[0] == Symbol("pts") - ][0] - xy_entries = [ - item for item in pts if isinstance(item, list) and item and item[0] == Symbol("xy") - ] - assert xy_entries[0][1] == 5.0 - assert xy_entries[0][2] == 7.5 - assert xy_entries[1][1] == 15.0 - assert xy_entries[1][2] == 7.5 - - -@pytest.mark.integration -class TestIntegrationHandlerEndToEnd: - """Integration tests for KiCADInterface handlers writing to real schematic files.""" - - @pytest.fixture(autouse=True) - def setup(self): - import types - - 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): - self.iface = KiCADInterface.__new__(KiCADInterface) - self.sch = _make_temp_sch() - yield - shutil.rmtree(self.sch.parent, ignore_errors=True) - - def test_junction_handler_writes_junction(self): - result = self.iface._handle_add_schematic_junction( - { - "schematicPath": str(self.sch), - "position": [50.8, 50.8], - } - ) - assert result["success"] is True - data = _parse_sch(self.sch) - junctions = _find_elements(data, "junction") - assert len(junctions) == 1 - - def test_wire_handler_two_points_writes_wire(self): - result = self.iface._handle_add_schematic_wire( - { - "schematicPath": str(self.sch), - "waypoints": [[10.0, 10.0], [30.0, 10.0]], - "snapToPins": False, - } - ) - assert result["success"] is True - data = _parse_sch(self.sch) - wires = _find_elements(data, "wire") - assert len(wires) == 1 - - def test_wire_handler_four_points_creates_three_segments(self): - result = self.iface._handle_add_schematic_wire( - { - "schematicPath": str(self.sch), - "waypoints": [[0, 0], [10, 0], [10, 10], [20, 10]], - "snapToPins": False, - } - ) - assert result["success"] is True - data = _parse_sch(self.sch) - wires = _find_elements(data, "wire") - assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}" - - -# --------------------------------------------------------------------------- -# 9. Unit tests — _point_strictly_on_wire -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestPointStrictlyOnWire: - """Unit tests for WireManager._point_strictly_on_wire geometry helper.""" - - @staticmethod - def _fn(px, py, x1, y1, x2, y2, eps=1e-6): - from commands.wire_manager import WireManager - - return WireManager._point_strictly_on_wire(px, py, x1, y1, x2, y2, eps) - - def test_horizontal_midpoint(self): - assert self._fn(5, 0, 0, 0, 10, 0) is True - - def test_vertical_midpoint(self): - assert self._fn(0, 5, 0, 0, 0, 10) is True - - def test_horizontal_at_start_endpoint(self): - """Point at wire start should NOT be strictly on wire.""" - assert self._fn(0, 0, 0, 0, 10, 0) is False - - def test_horizontal_at_end_endpoint(self): - """Point at wire end should NOT be strictly on wire.""" - assert self._fn(10, 0, 0, 0, 10, 0) is False - - def test_vertical_at_start_endpoint(self): - assert self._fn(0, 0, 0, 0, 0, 10) is False - - def test_vertical_at_end_endpoint(self): - assert self._fn(0, 10, 0, 0, 0, 10) is False - - def test_point_off_horizontal_wire(self): - """Point above a horizontal wire.""" - assert self._fn(5, 1, 0, 0, 10, 0) is False - - def test_point_off_vertical_wire(self): - """Point to the right of a vertical wire.""" - assert self._fn(1, 5, 0, 0, 0, 10) is False - - def test_point_beyond_horizontal_wire(self): - """Point collinear but past the end of a horizontal wire.""" - assert self._fn(15, 0, 0, 0, 10, 0) is False - - def test_point_beyond_vertical_wire(self): - """Point collinear but past the end of a vertical wire.""" - assert self._fn(0, 15, 0, 0, 0, 10) is False - - def test_diagonal_wire_always_false(self): - """Only horizontal/vertical wires are handled; diagonal → False.""" - assert self._fn(5, 5, 0, 0, 10, 10) is False - - def test_reversed_horizontal_endpoints(self): - """Wire endpoints reversed (x2 < x1) should still work.""" - assert self._fn(5, 0, 10, 0, 0, 0) is True - - def test_reversed_vertical_endpoints(self): - """Wire endpoints reversed (y2 < y1) should still work.""" - assert self._fn(0, 5, 0, 10, 0, 0) is True - - def test_near_endpoint_within_epsilon(self): - """Point within epsilon of endpoint should NOT be considered strictly on wire.""" - assert self._fn(1e-7, 0, 0, 0, 10, 0) is False - - def test_zero_length_wire(self): - """Degenerate wire with same start/end — nothing is strictly between.""" - assert self._fn(5, 5, 5, 5, 5, 5) is False - - -# --------------------------------------------------------------------------- -# 10. Unit tests — _parse_wire -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestParseWire: - """Unit tests for WireManager._parse_wire S-expression parser.""" - - @staticmethod - def _fn(item): - from commands.wire_manager import WireManager - - return WireManager._parse_wire(item) - - def test_valid_wire(self): - wire = [ - Symbol("wire"), - [Symbol("pts"), [Symbol("xy"), 10.0, 20.0], [Symbol("xy"), 30.0, 20.0]], - [ - Symbol("stroke"), - [Symbol("width"), 0], - [Symbol("type"), Symbol("default")], - ], - [Symbol("uuid"), "abc-123"], - ] - result = TestParseWire._fn(wire) - assert result is not None - start, end, width, stype = result - assert start == (10.0, 20.0) - assert end == (30.0, 20.0) - assert width == 0 - assert stype == "default" - - def test_non_wire_element_returns_none(self): - junction = [Symbol("junction"), [Symbol("at"), 10, 20]] - assert TestParseWire._fn(junction) is None - - def test_non_list_returns_none(self): - assert TestParseWire._fn("not a list") is None - - def test_empty_list_returns_none(self): - assert TestParseWire._fn([]) is None - - def test_wire_with_no_pts_returns_none(self): - wire = [Symbol("wire"), [Symbol("stroke"), [Symbol("width"), 0]]] - assert TestParseWire._fn(wire) is None - - def test_wire_with_only_one_xy_returns_none(self): - wire = [ - Symbol("wire"), - [Symbol("pts"), [Symbol("xy"), 10.0, 20.0]], - ] - assert TestParseWire._fn(wire) is None - - def test_wire_without_stroke_uses_defaults(self): - wire = [ - Symbol("wire"), - [Symbol("pts"), [Symbol("xy"), 0, 0], [Symbol("xy"), 10, 0]], - ] - result = TestParseWire._fn(wire) - assert result is not None - _, _, width, stype = result - assert width == 0 - assert stype == "default" - - -# --------------------------------------------------------------------------- -# 11. Unit tests — _make_wire_sexp -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestMakeWireSexp: - """Unit tests for WireManager._make_wire_sexp builder.""" - - def test_produces_valid_parseable_wire(self): - from commands.wire_manager import WireManager - - sexp = WireManager._make_wire_sexp([10, 20], [30, 20]) - parsed = WireManager._parse_wire(sexp) - assert parsed is not None - start, end, width, stype = parsed - assert start == (10, 20) - assert end == (30, 20) - assert width == 0 - assert stype == "default" - - def test_custom_stroke(self): - from commands.wire_manager import WireManager - - sexp = WireManager._make_wire_sexp([0, 0], [5, 0], stroke_width=0.5, stroke_type="dash") - parsed = WireManager._parse_wire(sexp) - assert parsed is not None - _, _, width, stype = parsed - assert width == 0.5 - assert stype == "dash" - - def test_has_uuid(self): - from commands.wire_manager import WireManager - - sexp = WireManager._make_wire_sexp([0, 0], [10, 0]) - # uuid is the last element - uuid_entry = sexp[-1] - assert uuid_entry[0] == Symbol("uuid") - assert isinstance(uuid_entry[1], str) and len(uuid_entry[1]) > 0 - - def test_two_calls_produce_different_uuids(self): - from commands.wire_manager import WireManager - - sexp1 = WireManager._make_wire_sexp([0, 0], [10, 0]) - sexp2 = WireManager._make_wire_sexp([0, 0], [10, 0]) - assert sexp1[-1][1] != sexp2[-1][1], "Each wire should have a unique UUID" - - -# --------------------------------------------------------------------------- -# 12. Unit tests — _break_wires_at_point -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestBreakWiresAtPoint: - """Unit tests for WireManager._break_wires_at_point T-junction logic.""" - - @staticmethod - def _make_sch_data_with_wires(wire_coords): - """Build a minimal sch_data list with wire elements and a sheet_instances marker.""" - from commands.wire_manager import WireManager - - data = [Symbol("kicad_sch")] - for start, end in wire_coords: - data.append(WireManager._make_wire_sexp(start, end)) - data.append([Symbol("sheet_instances")]) - return data - - def test_split_horizontal_wire_at_midpoint(self): - from commands.wire_manager import WireManager - - data = self._make_sch_data_with_wires([([0, 0], [20, 0])]) - splits = WireManager._break_wires_at_point(data, [10, 0]) - assert splits == 1 - wires = _find_elements(data, "wire") - assert len(wires) == 2 - # Verify the two segments share the split point - coords = [] - for w in wires: - parsed = WireManager._parse_wire(w) - coords.append((parsed[0], parsed[1])) - endpoints = {c for pair in coords for c in pair} - assert (10.0, 0.0) in endpoints - - def test_split_vertical_wire_at_midpoint(self): - from commands.wire_manager import WireManager - - data = self._make_sch_data_with_wires([([5, 0], [5, 30])]) - splits = WireManager._break_wires_at_point(data, [5, 15]) - assert splits == 1 - wires = _find_elements(data, "wire") - assert len(wires) == 2 - - def test_no_split_at_wire_endpoint(self): - """Point at existing endpoint should not trigger a split.""" - from commands.wire_manager import WireManager - - data = self._make_sch_data_with_wires([([0, 0], [20, 0])]) - splits = WireManager._break_wires_at_point(data, [0, 0]) - assert splits == 0 - wires = _find_elements(data, "wire") - assert len(wires) == 1 - - def test_no_split_point_not_on_wire(self): - from commands.wire_manager import WireManager - - data = self._make_sch_data_with_wires([([0, 0], [20, 0])]) - splits = WireManager._break_wires_at_point(data, [10, 5]) - assert splits == 0 - wires = _find_elements(data, "wire") - assert len(wires) == 1 - - def test_split_multiple_wires_at_same_point(self): - """Two crossing wires at (10, 10) — both should be split.""" - from commands.wire_manager import WireManager - - data = self._make_sch_data_with_wires( - [ - ([0, 10], [20, 10]), # horizontal through (10,10) - ([10, 0], [10, 20]), # vertical through (10,10) - ] - ) - splits = WireManager._break_wires_at_point(data, [10, 10]) - assert splits == 2 - wires = _find_elements(data, "wire") - assert len(wires) == 4 # each wire split into 2 - - def test_split_preserves_stroke_properties(self): - from commands.wire_manager import WireManager - - data = [Symbol("kicad_sch")] - data.append( - WireManager._make_wire_sexp([0, 0], [20, 0], stroke_width=0.5, stroke_type="dash") - ) - data.append([Symbol("sheet_instances")]) - splits = WireManager._break_wires_at_point(data, [10, 0]) - assert splits == 1 - wires = _find_elements(data, "wire") - for w in wires: - parsed = WireManager._parse_wire(w) - assert parsed[2] == 0.5, "stroke_width should be preserved" - assert parsed[3] == "dash", "stroke_type should be preserved" - - def test_no_split_on_diagonal_wire(self): - """Diagonal wires are not handled by _point_strictly_on_wire → no split.""" - from commands.wire_manager import WireManager - - data = self._make_sch_data_with_wires([([0, 0], [10, 10])]) - splits = WireManager._break_wires_at_point(data, [5, 5]) - assert splits == 0 - - def test_empty_sch_data(self): - from commands.wire_manager import WireManager - - data = [Symbol("kicad_sch"), [Symbol("sheet_instances")]] - splits = WireManager._break_wires_at_point(data, [10, 10]) - assert splits == 0 - - -# --------------------------------------------------------------------------- -# 13. Integration tests — T-junction wire breaking -# --------------------------------------------------------------------------- - - -@pytest.mark.integration -class TestIntegrationTJunction: - """Integration tests for T-junction wire breaking during add_wire/add_junction.""" - - @pytest.fixture(autouse=True) - def sch(self): - path = _make_temp_sch() - yield path - shutil.rmtree(path.parent, ignore_errors=True) - - def test_add_wire_breaks_existing_horizontal_wire(self, sch): - """Adding a vertical wire whose endpoint is mid-horizontal-wire should split it.""" - from commands.wire_manager import WireManager - - # First add a horizontal wire (0,10) -> (20,10) - WireManager.add_wire(sch, [0, 10], [20, 10]) - # Now add a vertical wire ending at (10,10) — the midpoint of the horizontal wire - WireManager.add_wire(sch, [10, 0], [10, 10]) - data = _parse_sch(sch) - wires = _find_elements(data, "wire") - # Original horizontal wire should be split into 2, plus the new vertical = 3 total - assert len(wires) == 3, f"Expected 3 wires (split + new), got {len(wires)}" - - def test_add_wire_does_not_break_at_shared_endpoint(self, sch): - """Wire connecting at an existing endpoint should not trigger a split.""" - from commands.wire_manager import WireManager - - WireManager.add_wire(sch, [0, 0], [10, 0]) - # New wire starts at (10,0) — existing endpoint, not midpoint - WireManager.add_wire(sch, [10, 0], [10, 10]) - data = _parse_sch(sch) - wires = _find_elements(data, "wire") - assert len(wires) == 2, f"Expected 2 wires (no split), got {len(wires)}" - - def test_add_junction_breaks_wire(self, sch): - """Adding a junction mid-wire should split that wire.""" - from commands.wire_manager import WireManager - - WireManager.add_wire(sch, [0, 0], [30, 0]) - WireManager.add_junction(sch, [15, 0]) - data = _parse_sch(sch) - wires = _find_elements(data, "wire") - assert len(wires) == 2, f"Expected 2 wires after junction split, got {len(wires)}" - junctions = _find_elements(data, "junction") - assert len(junctions) == 1 - - def test_add_junction_at_wire_endpoint_no_split(self, sch): - """Junction at wire endpoint should not split it.""" - from commands.wire_manager import WireManager - - WireManager.add_wire(sch, [0, 0], [20, 0]) - WireManager.add_junction(sch, [20, 0]) - data = _parse_sch(sch) - wires = _find_elements(data, "wire") - assert len(wires) == 1, f"Expected 1 wire (no split at endpoint), got {len(wires)}" - - def test_polyline_breaks_existing_wire(self, sch): - """Polyline whose start/end hits mid-wire should break it.""" - from commands.wire_manager import WireManager - - WireManager.add_wire(sch, [0, 10], [20, 10]) - # Polyline starting at (10,10) — mid-horizontal-wire - WireManager.add_polyline_wire(sch, [[10, 10], [10, 20], [20, 20]]) - data = _parse_sch(sch) - wires = _find_elements(data, "wire") - # 2 from split + 2 polyline segments = 4 - assert len(wires) == 4, f"Expected 4 wires, got {len(wires)}" - - def test_polyline_two_points_same_as_add_wire(self, sch): - """Polyline with exactly 2 points should produce 1 wire segment.""" - from commands.wire_manager import WireManager - - WireManager.add_polyline_wire(sch, [[0, 0], [10, 0]]) - data = _parse_sch(sch) - wires = _find_elements(data, "wire") - assert len(wires) == 1 +""" +Tests for fix/tool-schema-descriptions branch changes: +- add_schematic_wire: waypoints param, pin snapping, polyline routing +- add_schematic_junction: new tool replacing add_schematic_connection +- Schema updates in tool_schemas.py +- ConnectionManager orphaned method removal +""" + +import shutil +import sys +import tempfile +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +import sexpdata +from sexpdata import Symbol + +# Add python dir to path +PYTHON_DIR = Path(__file__).parent.parent +sys.path.insert(0, str(PYTHON_DIR)) + +TEMPLATES_DIR = PYTHON_DIR / "templates" +EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_temp_sch() -> Any: + """Copy the empty schematic template to a temp file and return the Path.""" + tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" + shutil.copy(EMPTY_SCH, tmp) + return tmp + + +def _parse_sch(path: Path) -> Any: + """Parse a .kicad_sch file and return the S-expression list.""" + with open(path, "r", encoding="utf-8") as f: + return sexpdata.loads(f.read()) + + +def _find_elements(sch_data: Any, tag: str) -> Any: + """Return all top-level S-expression elements with the given tag Symbol.""" + return [item for item in sch_data if isinstance(item, list) and item and item[0] == Symbol(tag)] + + +# --------------------------------------------------------------------------- +# 1. Schema tests +# --------------------------------------------------------------------------- + + +class TestSchemas: + """Verify tool_schemas.py reflects the new 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_add_schematic_wire_has_waypoints(self) -> None: + schema = self.tools["add_schematic_wire"]["inputSchema"] + assert "waypoints" in schema["properties"], "waypoints must be a property" + assert "waypoints" in schema["required"] + + def test_add_schematic_wire_has_schematic_path(self) -> None: + schema = self.tools["add_schematic_wire"]["inputSchema"] + assert "schematicPath" in schema["properties"] + assert "schematicPath" in schema["required"] + + def test_add_schematic_wire_has_snap_params(self) -> None: + schema = self.tools["add_schematic_wire"]["inputSchema"] + props = schema["properties"] + assert "snapToPins" in props + assert props["snapToPins"]["type"] == "boolean" + assert "snapTolerance" in props + assert props["snapTolerance"]["type"] == "number" + + def test_add_schematic_wire_no_old_point_params(self) -> None: + schema = self.tools["add_schematic_wire"]["inputSchema"] + props = schema["properties"] + assert "startPoint" not in props, "startPoint should be removed" + assert "endPoint" not in props, "endPoint should be removed" + + def test_add_schematic_connection_removed(self) -> None: + assert ( + "add_schematic_connection" not in self.tools + ), "add_schematic_connection must not appear in SCHEMATIC_TOOLS" + + def test_add_schematic_junction_present(self) -> None: + assert "add_schematic_junction" in self.tools + + def test_add_schematic_junction_schema(self) -> None: + schema = self.tools["add_schematic_junction"]["inputSchema"] + props = schema["properties"] + assert "schematicPath" in props + assert "position" in props + assert set(schema["required"]) >= {"schematicPath", "position"} + + def test_add_schematic_junction_position_is_array(self) -> None: + schema = self.tools["add_schematic_junction"]["inputSchema"] + pos = schema["properties"]["position"] + assert pos["type"] == "array" + assert pos.get("minItems") == 2 + assert pos.get("maxItems") == 2 + + +# --------------------------------------------------------------------------- +# 2. Handler dispatch tests +# --------------------------------------------------------------------------- + + +class TestHandlerDispatch: + """Verify KiCADInterface registers the right tool handlers.""" + + @pytest.fixture(autouse=True) + def load_handler_map(self) -> Any: + # Import only the dispatch table without initialising KiCAD connections + import importlib + import types + + # Patch heavy imports before loading kicad_interface + for mod in ["pcbnew", "skip"]: + sys.modules.setdefault(mod, types.ModuleType(mod)) + + from kicad_interface import KiCADInterface + + # Peek at the dispatch table by instantiating with mocked internals + with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): + obj = KiCADInterface.__new__(KiCADInterface) + # Manually set attributes that __init__ normally provides + obj._backend = None + # Build the handler map the same way the real __init__ does + obj._tool_handlers = { + "add_schematic_wire": obj._handle_add_schematic_wire, + "add_schematic_junction": obj._handle_add_schematic_junction, + "add_schematic_net_label": obj._handle_add_schematic_net_label, + } + self.handlers = obj._tool_handlers + + def test_add_schematic_wire_registered(self) -> None: + from kicad_interface import KiCADInterface + + # Just verify the class has the handler method + assert hasattr(KiCADInterface, "_handle_add_schematic_wire") + + def test_add_schematic_junction_registered(self) -> None: + from kicad_interface import KiCADInterface + + assert hasattr(KiCADInterface, "_handle_add_schematic_junction") + + def test_add_schematic_connection_not_present(self) -> None: + from kicad_interface import KiCADInterface + + assert not hasattr( + KiCADInterface, "_handle_add_schematic_connection" + ), "_handle_add_schematic_connection should be removed" + + +# --------------------------------------------------------------------------- +# 3. _handle_add_schematic_wire — parameter validation +# --------------------------------------------------------------------------- + + +class TestHandleAddSchematicWireValidation: + """Unit tests for _handle_add_schematic_wire validation paths (no disk I/O).""" + + @pytest.fixture(autouse=True) + def handler(self) -> Any: + import types + + 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): + self.iface = KiCADInterface.__new__(KiCADInterface) + + def test_missing_schematic_path(self) -> None: + result = self.iface._handle_add_schematic_wire({"waypoints": [[0, 0], [10, 0]]}) + assert result["success"] is False + assert "Schematic path" in result["message"] + + def test_missing_waypoints(self) -> None: + result = self.iface._handle_add_schematic_wire({"schematicPath": "/tmp/x.kicad_sch"}) + assert result["success"] is False + assert "waypoint" in result["message"].lower() + + def test_single_waypoint_rejected(self) -> None: + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": "/tmp/x.kicad_sch", + "waypoints": [[0, 0]], + } + ) + assert result["success"] is False + assert "waypoint" in result["message"].lower() + + +# --------------------------------------------------------------------------- +# 4. _handle_add_schematic_wire — wire routing logic +# --------------------------------------------------------------------------- + + +class TestHandleAddSchematicWireRouting: + """Unit tests verifying add_wire vs add_polyline_wire dispatch, no pin snapping.""" + + @pytest.fixture(autouse=True) + def setup(self) -> Any: + import types + + 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): + self.iface = KiCADInterface.__new__(KiCADInterface) + self.sch_path = _make_temp_sch() + yield + # cleanup + shutil.rmtree(self.sch_path.parent, ignore_errors=True) + + @patch("commands.wire_manager.WireManager.add_wire", return_value=True) + def test_two_waypoints_calls_add_wire(self, mock_add_wire: Any) -> None: + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[10.0, 20.0], [30.0, 20.0]], + "snapToPins": False, + } + ) + assert result["success"] is True + mock_add_wire.assert_called_once() + args = mock_add_wire.call_args[0] + assert args[1] == [10.0, 20.0] + assert args[2] == [30.0, 20.0] + + @patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) + def test_four_waypoints_calls_add_polyline_wire(self, mock_poly: Any) -> None: + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[0, 0], [10, 0], [10, 10], [20, 10]], + "snapToPins": False, + } + ) + assert result["success"] is True + mock_poly.assert_called_once() + + def test_points_key_without_waypoints_is_rejected(self) -> None: + """'points' key alone (without 'waypoints') is rejected — no fallback.""" + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "points": [[5.0, 5.0], [15.0, 5.0]], + "snapToPins": False, + } + ) + assert result["success"] is False + assert "waypoint" in result["message"].lower() + + @patch("commands.wire_manager.WireManager.add_wire", return_value=False) + def test_failure_response(self, _: Any) -> None: + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[0, 0], [10, 0]], + "snapToPins": False, + } + ) + assert result["success"] is False + + +# --------------------------------------------------------------------------- +# 5. _handle_add_schematic_wire — pin snapping +# --------------------------------------------------------------------------- + + +class TestPinSnapping: + """Verify pin snapping logic snaps endpoints correctly.""" + + @pytest.fixture(autouse=True) + def setup(self) -> Any: + import types + + # Provide a minimal skip.Schematic stub so the handler can import it + skip_mod = types.ModuleType("skip") + + class FakeSchematic: + def __init__(self, path: Any) -> None: + pass + + @property + def symbol(self) -> list[Any]: + return [] # no symbols → no pins in snapping loop + + skip_mod.Schematic = FakeSchematic + sys.modules["skip"] = skip_mod + sys.modules.setdefault("pcbnew", types.ModuleType("pcbnew")) + + from kicad_interface import KiCADInterface + + with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): + self.iface = KiCADInterface.__new__(KiCADInterface) + + self.sch_path = _make_temp_sch() + yield + shutil.rmtree(self.sch_path.parent, ignore_errors=True) + + @patch("commands.wire_manager.WireManager.add_wire", return_value=True) + @patch("commands.pin_locator.PinLocator.get_all_symbol_pins") + def test_start_point_snapped_within_tolerance(self, mock_pins: Any, mock_wire: Any) -> None: + """First waypoint within tolerance of a pin should be snapped to pin coords.""" + # get_all_symbol_pins won't be called because symbol list is empty in fixture. + # Instead we patch find_nearest_pin indirectly by providing all_pins via the + # skip.Schematic stub that returns one symbol with a known pin. + import types + + skip_mod = sys.modules["skip"] + + class FakeSymbol: + class property: + class Reference: + value = "R1" + + def __init__(self) -> None: + pass + + skip_mod.Schematic = lambda path: type("FakeSch", (), {"symbol": [FakeSymbol()]})() + + mock_pins.return_value = {"1": [10.0, 20.0], "2": [10.0, 30.0]} + + # Re-import so the patched skip.Schematic is used + import importlib + + import kicad_interface + + importlib.reload(kicad_interface) + from kicad_interface import KiCADInterface + + with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): + iface = KiCADInterface.__new__(KiCADInterface) + + with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw: + result = iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[10.05, 20.05], [50.0, 20.0]], + "snapToPins": True, + "snapTolerance": 1.0, + } + ) + if result["success"]: + called_start = mw.call_args[0][1] + assert called_start == [ + 10.0, + 20.0, + ], f"Start should snap to [10.0, 20.0], got {called_start}" + # If it failed due to stub issues, just verify no exception + + def test_snap_disabled_passes_original_coords(self) -> None: + """With snapToPins=False the handler should not load PinLocator at all.""" + with ( + patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw, + patch("commands.pin_locator.PinLocator") as mock_locator_cls, + ): + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[1.0, 2.0], [3.0, 4.0]], + "snapToPins": False, + } + ) + mock_locator_cls.assert_not_called() + assert result["success"] is True + called_start = mw.call_args[0][1] + assert called_start == [1.0, 2.0] + + @patch("commands.wire_manager.WireManager.add_wire", return_value=True) + def test_snap_miss_leaves_coords_unchanged(self, mock_wire: Any) -> None: + """Point beyond tolerance should not be snapped.""" + with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw: + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[100.0, 100.0], [200.0, 100.0]], + "snapToPins": True, + "snapTolerance": 0.5, + # skip.Schematic returns no symbols (fixture), so no pins to snap to + } + ) + assert result["success"] is True + # No snapping info in message + assert "snapped" not in result.get("message", "") + + @patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) + def test_intermediate_waypoints_not_snapped(self, mock_poly: Any) -> None: + """Middle waypoints must remain unchanged even with snapToPins=True.""" + mid = [50.0, 50.0] + with patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) as mp: + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch_path), + "waypoints": [[100.0, 100.0], mid[:], [200.0, 100.0]], + "snapToPins": True, + "snapTolerance": 100.0, # huge tolerance, but mid must not snap + } + ) + assert result["success"] is True + called_points = mp.call_args[0][1] + assert ( + called_points[1] == mid + ), f"Middle waypoint should not be snapped, got {called_points[1]}" + + +# --------------------------------------------------------------------------- +# 6. _handle_add_schematic_junction — unit tests +# --------------------------------------------------------------------------- + + +class TestHandleAddSchematicJunction: + + @pytest.fixture(autouse=True) + def setup(self) -> Any: + import types + + 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): + self.iface = KiCADInterface.__new__(KiCADInterface) + + def test_missing_schematic_path(self) -> None: + result = self.iface._handle_add_schematic_junction({"position": [10.0, 20.0]}) + assert result["success"] is False + assert "Schematic path" in result["message"] + + def test_missing_position(self) -> None: + result = self.iface._handle_add_schematic_junction({"schematicPath": "/tmp/x.kicad_sch"}) + assert result["success"] is False + assert "Position" in result["message"] + + @patch("commands.wire_manager.WireManager.add_junction", return_value=True) + def test_success(self, mock_jct: Any) -> None: + sch = _make_temp_sch() + try: + result = self.iface._handle_add_schematic_junction( + { + "schematicPath": str(sch), + "position": [25.4, 25.4], + } + ) + assert result["success"] is True + assert "Junction added" in result["message"] + mock_jct.assert_called_once_with(sch, [25.4, 25.4]) + finally: + shutil.rmtree(sch.parent, ignore_errors=True) + + @patch("commands.wire_manager.WireManager.add_junction", return_value=False) + def test_failure(self, _: Any) -> None: + sch = _make_temp_sch() + try: + result = self.iface._handle_add_schematic_junction( + { + "schematicPath": str(sch), + "position": [25.4, 25.4], + } + ) + assert result["success"] is False + assert "Failed" in result["message"] + finally: + shutil.rmtree(sch.parent, ignore_errors=True) + + +# --------------------------------------------------------------------------- +# 7. ConnectionManager — orphaned methods removed +# --------------------------------------------------------------------------- + + +class TestConnectionManagerOrphanedMethodsRemoved: + + def test_add_wire_removed(self) -> None: + from commands.connection_schematic import ConnectionManager + + assert not hasattr( + ConnectionManager, "add_wire" + ), "ConnectionManager.add_wire should have been removed" + + def test_add_connection_removed(self) -> None: + from commands.connection_schematic import ConnectionManager + + assert not hasattr( + ConnectionManager, "add_connection" + ), "ConnectionManager.add_connection should have been removed" + + def test_get_pin_location_removed(self) -> None: + from commands.connection_schematic import ConnectionManager + + assert not hasattr( + ConnectionManager, "get_pin_location" + ), "ConnectionManager.get_pin_location should have been removed" + + +# --------------------------------------------------------------------------- +# 8. Integration tests — real disk I/O, no KiCAD process +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestIntegrationWireManager: + """Integration tests using real schematic files and WireManager.""" + + @pytest.fixture(autouse=True) + def sch(self) -> Any: + path = _make_temp_sch() + yield path + shutil.rmtree(path.parent, ignore_errors=True) + + def test_add_wire_writes_wire_element(self, sch: Any) -> None: + from commands.wire_manager import WireManager + + ok = WireManager.add_wire(sch, [10.0, 10.0], [30.0, 10.0]) + assert ok is True + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + assert len(wires) == 1 + + def test_add_polyline_wire_creates_segments(self, sch: Any) -> None: + """N waypoints should produce N-1 individual 2-point wire segments.""" + from commands.wire_manager import WireManager + + pts = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [20.0, 10.0]] + ok = WireManager.add_polyline_wire(sch, pts) + assert ok is True + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}" + + def test_add_junction_writes_junction_element(self, sch: Any) -> None: + from commands.wire_manager import WireManager + + ok = WireManager.add_junction(sch, [25.4, 25.4]) + assert ok is True + data = _parse_sch(sch) + junctions = _find_elements(data, "junction") + assert len(junctions) == 1 + # Verify position + at = junctions[0][1] # (at x y) + assert at[1] == 25.4 + assert at[2] == 25.4 + + def test_wire_endpoint_coordinates_match(self, sch: Any) -> None: + from commands.wire_manager import WireManager + + WireManager.add_wire(sch, [5.0, 7.5], [15.0, 7.5]) + data = _parse_sch(sch) + wire = _find_elements(data, "wire")[0] + pts = [ + item for item in wire if isinstance(item, list) and item and item[0] == Symbol("pts") + ][0] + xy_entries = [ + item for item in pts if isinstance(item, list) and item and item[0] == Symbol("xy") + ] + assert xy_entries[0][1] == 5.0 + assert xy_entries[0][2] == 7.5 + assert xy_entries[1][1] == 15.0 + assert xy_entries[1][2] == 7.5 + + +@pytest.mark.integration +class TestIntegrationHandlerEndToEnd: + """Integration tests for KiCADInterface handlers writing to real schematic files.""" + + @pytest.fixture(autouse=True) + def setup(self) -> Any: + import types + + 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): + self.iface = KiCADInterface.__new__(KiCADInterface) + self.sch = _make_temp_sch() + yield + shutil.rmtree(self.sch.parent, ignore_errors=True) + + def test_junction_handler_writes_junction(self) -> None: + result = self.iface._handle_add_schematic_junction( + { + "schematicPath": str(self.sch), + "position": [50.8, 50.8], + } + ) + assert result["success"] is True + data = _parse_sch(self.sch) + junctions = _find_elements(data, "junction") + assert len(junctions) == 1 + + def test_wire_handler_two_points_writes_wire(self) -> None: + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch), + "waypoints": [[10.0, 10.0], [30.0, 10.0]], + "snapToPins": False, + } + ) + assert result["success"] is True + data = _parse_sch(self.sch) + wires = _find_elements(data, "wire") + assert len(wires) == 1 + + def test_wire_handler_four_points_creates_three_segments(self) -> None: + result = self.iface._handle_add_schematic_wire( + { + "schematicPath": str(self.sch), + "waypoints": [[0, 0], [10, 0], [10, 10], [20, 10]], + "snapToPins": False, + } + ) + assert result["success"] is True + data = _parse_sch(self.sch) + wires = _find_elements(data, "wire") + assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}" + + +# --------------------------------------------------------------------------- +# 9. Unit tests — _point_strictly_on_wire +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestPointStrictlyOnWire: + """Unit tests for WireManager._point_strictly_on_wire geometry helper.""" + + @staticmethod + def _fn(px: Any, py: Any, x1: Any, y1: Any, x2: Any, y2: Any, eps: Any = 1e-6) -> Any: + from commands.wire_manager import WireManager + + return WireManager._point_strictly_on_wire(px, py, x1, y1, x2, y2, eps) + + def test_horizontal_midpoint(self) -> None: + assert self._fn(5, 0, 0, 0, 10, 0) is True + + def test_vertical_midpoint(self) -> None: + assert self._fn(0, 5, 0, 0, 0, 10) is True + + def test_horizontal_at_start_endpoint(self) -> None: + """Point at wire start should NOT be strictly on wire.""" + assert self._fn(0, 0, 0, 0, 10, 0) is False + + def test_horizontal_at_end_endpoint(self) -> None: + """Point at wire end should NOT be strictly on wire.""" + assert self._fn(10, 0, 0, 0, 10, 0) is False + + def test_vertical_at_start_endpoint(self) -> None: + assert self._fn(0, 0, 0, 0, 0, 10) is False + + def test_vertical_at_end_endpoint(self) -> None: + assert self._fn(0, 10, 0, 0, 0, 10) is False + + def test_point_off_horizontal_wire(self) -> None: + """Point above a horizontal wire.""" + assert self._fn(5, 1, 0, 0, 10, 0) is False + + def test_point_off_vertical_wire(self) -> None: + """Point to the right of a vertical wire.""" + assert self._fn(1, 5, 0, 0, 0, 10) is False + + def test_point_beyond_horizontal_wire(self) -> None: + """Point collinear but past the end of a horizontal wire.""" + assert self._fn(15, 0, 0, 0, 10, 0) is False + + def test_point_beyond_vertical_wire(self) -> None: + """Point collinear but past the end of a vertical wire.""" + assert self._fn(0, 15, 0, 0, 0, 10) is False + + def test_diagonal_wire_always_false(self) -> None: + """Only horizontal/vertical wires are handled; diagonal → False.""" + assert self._fn(5, 5, 0, 0, 10, 10) is False + + def test_reversed_horizontal_endpoints(self) -> None: + """Wire endpoints reversed (x2 < x1) should still work.""" + assert self._fn(5, 0, 10, 0, 0, 0) is True + + def test_reversed_vertical_endpoints(self) -> None: + """Wire endpoints reversed (y2 < y1) should still work.""" + assert self._fn(0, 5, 0, 10, 0, 0) is True + + def test_near_endpoint_within_epsilon(self) -> None: + """Point within epsilon of endpoint should NOT be considered strictly on wire.""" + assert self._fn(1e-7, 0, 0, 0, 10, 0) is False + + def test_zero_length_wire(self) -> None: + """Degenerate wire with same start/end — nothing is strictly between.""" + assert self._fn(5, 5, 5, 5, 5, 5) is False + + +# --------------------------------------------------------------------------- +# 10. Unit tests — _parse_wire +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestParseWire: + """Unit tests for WireManager._parse_wire S-expression parser.""" + + @staticmethod + def _fn(item: Any) -> Any: + from commands.wire_manager import WireManager + + return WireManager._parse_wire(item) + + def test_valid_wire(self) -> None: + wire = [ + Symbol("wire"), + [Symbol("pts"), [Symbol("xy"), 10.0, 20.0], [Symbol("xy"), 30.0, 20.0]], + [ + Symbol("stroke"), + [Symbol("width"), 0], + [Symbol("type"), Symbol("default")], + ], + [Symbol("uuid"), "abc-123"], + ] + result = TestParseWire._fn(wire) + assert result is not None + start, end, width, stype = result + assert start == (10.0, 20.0) + assert end == (30.0, 20.0) + assert width == 0 + assert stype == "default" + + def test_non_wire_element_returns_none(self) -> None: + junction = [Symbol("junction"), [Symbol("at"), 10, 20]] + assert TestParseWire._fn(junction) is None + + def test_non_list_returns_none(self) -> None: + assert TestParseWire._fn("not a list") is None + + def test_empty_list_returns_none(self) -> None: + assert TestParseWire._fn([]) is None + + def test_wire_with_no_pts_returns_none(self) -> None: + wire = [Symbol("wire"), [Symbol("stroke"), [Symbol("width"), 0]]] + assert TestParseWire._fn(wire) is None + + def test_wire_with_only_one_xy_returns_none(self) -> None: + wire = [ + Symbol("wire"), + [Symbol("pts"), [Symbol("xy"), 10.0, 20.0]], + ] + assert TestParseWire._fn(wire) is None + + def test_wire_without_stroke_uses_defaults(self) -> None: + wire = [ + Symbol("wire"), + [Symbol("pts"), [Symbol("xy"), 0, 0], [Symbol("xy"), 10, 0]], + ] + result = TestParseWire._fn(wire) + assert result is not None + _, _, width, stype = result + assert width == 0 + assert stype == "default" + + +# --------------------------------------------------------------------------- +# 11. Unit tests — _make_wire_sexp +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestMakeWireSexp: + """Unit tests for WireManager._make_wire_sexp builder.""" + + def test_produces_valid_parseable_wire(self) -> None: + from commands.wire_manager import WireManager + + sexp = WireManager._make_wire_sexp([10, 20], [30, 20]) + parsed = WireManager._parse_wire(sexp) + assert parsed is not None + start, end, width, stype = parsed + assert start == (10, 20) + assert end == (30, 20) + assert width == 0 + assert stype == "default" + + def test_custom_stroke(self) -> None: + from commands.wire_manager import WireManager + + sexp = WireManager._make_wire_sexp([0, 0], [5, 0], stroke_width=0.5, stroke_type="dash") + parsed = WireManager._parse_wire(sexp) + assert parsed is not None + _, _, width, stype = parsed + assert width == 0.5 + assert stype == "dash" + + def test_has_uuid(self) -> None: + from commands.wire_manager import WireManager + + sexp = WireManager._make_wire_sexp([0, 0], [10, 0]) + # uuid is the last element + uuid_entry = sexp[-1] + assert uuid_entry[0] == Symbol("uuid") + assert isinstance(uuid_entry[1], str) and len(uuid_entry[1]) > 0 + + def test_two_calls_produce_different_uuids(self) -> None: + from commands.wire_manager import WireManager + + sexp1 = WireManager._make_wire_sexp([0, 0], [10, 0]) + sexp2 = WireManager._make_wire_sexp([0, 0], [10, 0]) + assert sexp1[-1][1] != sexp2[-1][1], "Each wire should have a unique UUID" + + +# --------------------------------------------------------------------------- +# 12. Unit tests — _break_wires_at_point +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestBreakWiresAtPoint: + """Unit tests for WireManager._break_wires_at_point T-junction logic.""" + + @staticmethod + def _make_sch_data_with_wires(wire_coords: Any) -> list[Any]: + """Build a minimal sch_data list with wire elements and a sheet_instances marker.""" + from commands.wire_manager import WireManager + + data = [Symbol("kicad_sch")] + for start, end in wire_coords: + data.append(WireManager._make_wire_sexp(start, end)) + data.append([Symbol("sheet_instances")]) + return data + + def test_split_horizontal_wire_at_midpoint(self) -> None: + from commands.wire_manager import WireManager + + data = self._make_sch_data_with_wires([([0, 0], [20, 0])]) + splits = WireManager._break_wires_at_point(data, [10, 0]) + assert splits == 1 + wires = _find_elements(data, "wire") + assert len(wires) == 2 + # Verify the two segments share the split point + coords = [] + for w in wires: + parsed = WireManager._parse_wire(w) + coords.append((parsed[0], parsed[1])) + endpoints = {c for pair in coords for c in pair} + assert (10.0, 0.0) in endpoints + + def test_split_vertical_wire_at_midpoint(self) -> None: + from commands.wire_manager import WireManager + + data = self._make_sch_data_with_wires([([5, 0], [5, 30])]) + splits = WireManager._break_wires_at_point(data, [5, 15]) + assert splits == 1 + wires = _find_elements(data, "wire") + assert len(wires) == 2 + + def test_no_split_at_wire_endpoint(self) -> None: + """Point at existing endpoint should not trigger a split.""" + from commands.wire_manager import WireManager + + data = self._make_sch_data_with_wires([([0, 0], [20, 0])]) + splits = WireManager._break_wires_at_point(data, [0, 0]) + assert splits == 0 + wires = _find_elements(data, "wire") + assert len(wires) == 1 + + def test_no_split_point_not_on_wire(self) -> None: + from commands.wire_manager import WireManager + + data = self._make_sch_data_with_wires([([0, 0], [20, 0])]) + splits = WireManager._break_wires_at_point(data, [10, 5]) + assert splits == 0 + wires = _find_elements(data, "wire") + assert len(wires) == 1 + + def test_split_multiple_wires_at_same_point(self) -> None: + """Two crossing wires at (10, 10) — both should be split.""" + from commands.wire_manager import WireManager + + data = self._make_sch_data_with_wires( + [ + ([0, 10], [20, 10]), # horizontal through (10,10) + ([10, 0], [10, 20]), # vertical through (10,10) + ] + ) + splits = WireManager._break_wires_at_point(data, [10, 10]) + assert splits == 2 + wires = _find_elements(data, "wire") + assert len(wires) == 4 # each wire split into 2 + + def test_split_preserves_stroke_properties(self) -> None: + from commands.wire_manager import WireManager + + data = [Symbol("kicad_sch")] + data.append( + WireManager._make_wire_sexp([0, 0], [20, 0], stroke_width=0.5, stroke_type="dash") + ) + data.append([Symbol("sheet_instances")]) + splits = WireManager._break_wires_at_point(data, [10, 0]) + assert splits == 1 + wires = _find_elements(data, "wire") + for w in wires: + parsed = WireManager._parse_wire(w) + assert parsed[2] == 0.5, "stroke_width should be preserved" + assert parsed[3] == "dash", "stroke_type should be preserved" + + def test_no_split_on_diagonal_wire(self) -> None: + """Diagonal wires are not handled by _point_strictly_on_wire → no split.""" + from commands.wire_manager import WireManager + + data = self._make_sch_data_with_wires([([0, 0], [10, 10])]) + splits = WireManager._break_wires_at_point(data, [5, 5]) + assert splits == 0 + + def test_empty_sch_data(self) -> None: + from commands.wire_manager import WireManager + + data = [Symbol("kicad_sch"), [Symbol("sheet_instances")]] + splits = WireManager._break_wires_at_point(data, [10, 10]) + assert splits == 0 + + +# --------------------------------------------------------------------------- +# 13. Integration tests — T-junction wire breaking +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +class TestIntegrationTJunction: + """Integration tests for T-junction wire breaking during add_wire/add_junction.""" + + @pytest.fixture(autouse=True) + def sch(self) -> Any: + path = _make_temp_sch() + yield path + shutil.rmtree(path.parent, ignore_errors=True) + + def test_add_wire_breaks_existing_horizontal_wire(self, sch: Any) -> None: + """Adding a vertical wire whose endpoint is mid-horizontal-wire should split it.""" + from commands.wire_manager import WireManager + + # First add a horizontal wire (0,10) -> (20,10) + WireManager.add_wire(sch, [0, 10], [20, 10]) + # Now add a vertical wire ending at (10,10) — the midpoint of the horizontal wire + WireManager.add_wire(sch, [10, 0], [10, 10]) + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + # Original horizontal wire should be split into 2, plus the new vertical = 3 total + assert len(wires) == 3, f"Expected 3 wires (split + new), got {len(wires)}" + + def test_add_wire_does_not_break_at_shared_endpoint(self, sch: Any) -> None: + """Wire connecting at an existing endpoint should not trigger a split.""" + from commands.wire_manager import WireManager + + WireManager.add_wire(sch, [0, 0], [10, 0]) + # New wire starts at (10,0) — existing endpoint, not midpoint + WireManager.add_wire(sch, [10, 0], [10, 10]) + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + assert len(wires) == 2, f"Expected 2 wires (no split), got {len(wires)}" + + def test_add_junction_breaks_wire(self, sch: Any) -> None: + """Adding a junction mid-wire should split that wire.""" + from commands.wire_manager import WireManager + + WireManager.add_wire(sch, [0, 0], [30, 0]) + WireManager.add_junction(sch, [15, 0]) + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + assert len(wires) == 2, f"Expected 2 wires after junction split, got {len(wires)}" + junctions = _find_elements(data, "junction") + assert len(junctions) == 1 + + def test_add_junction_at_wire_endpoint_no_split(self, sch: Any) -> None: + """Junction at wire endpoint should not split it.""" + from commands.wire_manager import WireManager + + WireManager.add_wire(sch, [0, 0], [20, 0]) + WireManager.add_junction(sch, [20, 0]) + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + assert len(wires) == 1, f"Expected 1 wire (no split at endpoint), got {len(wires)}" + + def test_polyline_breaks_existing_wire(self, sch: Any) -> None: + """Polyline whose start/end hits mid-wire should break it.""" + from commands.wire_manager import WireManager + + WireManager.add_wire(sch, [0, 10], [20, 10]) + # Polyline starting at (10,10) — mid-horizontal-wire + WireManager.add_polyline_wire(sch, [[10, 10], [10, 20], [20, 20]]) + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + # 2 from split + 2 polyline segments = 4 + assert len(wires) == 4, f"Expected 4 wires, got {len(wires)}" + + def test_polyline_two_points_same_as_add_wire(self, sch: Any) -> None: + """Polyline with exactly 2 points should produce 1 wire segment.""" + from commands.wire_manager import WireManager + + WireManager.add_polyline_wire(sch, [[0, 0], [10, 0]]) + data = _parse_sch(sch) + wires = _find_elements(data, "wire") + assert len(wires) == 1 diff --git a/python/utils/kicad_process.py b/python/utils/kicad_process.py index bb367bd..4642c33 100644 --- a/python/utils/kicad_process.py +++ b/python/utils/kicad_process.py @@ -157,9 +157,9 @@ class KiCADProcessManager: timeout=5 if system == "Windows" else None, ) if result.returncode == 0: - path = result.stdout.strip().split("\n")[0] - logger.info(f"Found KiCAD executable: {path}") - return Path(path) + exe_path = result.stdout.strip().split("\n")[0] + logger.info(f"Found KiCAD executable: {exe_path}") + return Path(exe_path) # Platform-specific default paths if system == "Linux":