chore: enable strict mypy checks and fix pre-commit mypy hook

Add type annotations to all previously untyped functions and remove 9
suppressed error codes (call-arg, assignment, return-value, operator,
has-type, dict-item, misc, list-item, annotation-unchecked) by fixing
the underlying type issues.

Add [[tool.mypy.overrides]] with ignore_missing_imports for KiCAD-specific
modules (pcbnew, sexpdata, skip, cairosvg, kipy, PIL) so the pre-commit
mypy hook passes in its isolated venv. Add types-requests and pytest to
additional_dependencies in .pre-commit-config.yaml.

Also fixes several real bugs uncovered by stricter checks: incorrect static
calls to instance methods in swig_backend, wrong return type on get_size,
missing value param in BoardAPI.place_component, variable shadowing in
kicad_process.py, unqualified LibraryManager reference in kicad_interface,
and missing top-level Path import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Eugene Mikhantyev
2026-04-05 23:48:35 +01:00
parent 5a2b481db3
commit 9b1024a8f3
35 changed files with 4665 additions and 4610 deletions

View File

@@ -46,7 +46,9 @@ repos:
files: ^python/ files: ^python/
exclude: ^python/commands/board\.py$ exclude: ^python/commands/board\.py$
args: [--config-file=pyproject.toml] args: [--config-file=pyproject.toml]
additional_dependencies: [] additional_dependencies:
- types-requests
- pytest
- repo: local - repo: local
hooks: hooks:

View File

@@ -23,15 +23,20 @@ disable_error_code = [
"arg-type", "arg-type",
"attr-defined", "attr-defined",
"union-attr", "union-attr",
"call-arg",
"assignment",
"var-annotated", "var-annotated",
"annotation-unchecked",
"index", "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

View File

@@ -2,7 +2,7 @@ import logging
import os import os
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Any, Dict, List, Optional, Tuple
from skip import Schematic from skip import Schematic
@@ -25,7 +25,7 @@ class ComponentManager:
_dynamic_loader = None _dynamic_loader = None
@classmethod @classmethod
def get_dynamic_loader(cls): def get_dynamic_loader(cls) -> Any:
"""Get or create dynamic symbol loader instance""" """Get or create dynamic symbol loader instance"""
if cls._dynamic_loader is None and DYNAMIC_LOADING_AVAILABLE: if cls._dynamic_loader is None and DYNAMIC_LOADING_AVAILABLE:
cls._dynamic_loader = DynamicSymbolLoader() cls._dynamic_loader = DynamicSymbolLoader()
@@ -86,7 +86,7 @@ class ComponentManager:
""" """
# Helper function to check if template exists in schematic # 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)""" """Check if template exists by iterating symbols (handles special characters)"""
for symbol in schematic.symbol: for symbol in schematic.symbol:
if ( if (
@@ -165,7 +165,7 @@ class ComponentManager:
@staticmethod @staticmethod
def add_component( def add_component(
schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None
): ) -> Any:
""" """
Add a component to the schematic by cloning from template Add a component to the schematic by cloning from template
@@ -265,7 +265,7 @@ class ComponentManager:
raise raise
@staticmethod @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""" """Remove a component from the schematic by reference designator"""
try: try:
# kicad-skip doesn't have a direct remove_symbol method by reference. # kicad-skip doesn't have a direct remove_symbol method by reference.
@@ -288,7 +288,7 @@ class ComponentManager:
return False return False
@staticmethod @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""" """Update component properties by reference designator"""
try: try:
symbol_to_update = None symbol_to_update = None
@@ -313,7 +313,7 @@ class ComponentManager:
return False return False
@staticmethod @staticmethod
def get_component(schematic: Schematic, component_ref: str): def get_component(schematic: Schematic, component_ref: str) -> Any:
"""Get a component by reference designator""" """Get a component by reference designator"""
for symbol in schematic.symbol: for symbol in schematic.symbol:
if symbol.reference == component_ref: if symbol.reference == component_ref:
@@ -323,7 +323,7 @@ class ComponentManager:
return None return None
@staticmethod @staticmethod
def search_components(schematic: Schematic, query: str): def search_components(schematic: Schematic, query: str) -> List[Any]:
"""Search for components matching criteria (basic implementation)""" """Search for components matching criteria (basic implementation)"""
# This is a basic search, could be expanded to use regex or more complex logic # This is a basic search, could be expanded to use regex or more complex logic
matching_components = [] matching_components = []
@@ -342,7 +342,7 @@ class ComponentManager:
return matching_components return matching_components
@staticmethod @staticmethod
def get_all_components(schematic: Schematic): def get_all_components(schematic: Schematic) -> List[Any]:
"""Get all components in schematic""" """Get all components in schematic"""
logger.debug(f"Retrieving all {len(schematic.symbol)} components.") logger.debug(f"Retrieving all {len(schematic.symbol)} components.")
return list(schematic.symbol) return list(schematic.symbol)

View File

@@ -1,7 +1,7 @@
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Any, Dict, List, Optional
from skip import Schematic from skip import Schematic
@@ -25,14 +25,14 @@ class ConnectionManager:
_pin_locator = None _pin_locator = None
@classmethod @classmethod
def get_pin_locator(cls): def get_pin_locator(cls) -> Any:
"""Get or create pin locator instance""" """Get or create pin locator instance"""
if cls._pin_locator is None and WIRE_MANAGER_AVAILABLE: if cls._pin_locator is None and WIRE_MANAGER_AVAILABLE:
cls._pin_locator = PinLocator() cls._pin_locator = PinLocator()
return cls._pin_locator return cls._pin_locator
@staticmethod @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 Add a net label to the schematic
@@ -57,7 +57,9 @@ class ConnectionManager:
return None return None
@staticmethod @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 Connect a component pin to a named net using a wire stub and label
@@ -132,7 +134,7 @@ class ConnectionManager:
target_ref: str, target_ref: str,
net_prefix: str = "PIN", net_prefix: str = "PIN",
pin_offset: int = 0, pin_offset: int = 0,
): ) -> Dict[str, List[str]]:
""" """
Connect all pins of source_ref to matching pins of target_ref via shared net labels. 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}. Useful for passthrough adapters: J1 pin N <-> J2 pin N on net {net_prefix}_{N}.
@@ -203,7 +205,7 @@ class ConnectionManager:
@staticmethod @staticmethod
def get_net_connections( def get_net_connections(
schematic: Schematic, net_name: str, schematic_path: Optional[Path] = None schematic: Schematic, net_name: str, schematic_path: Optional[Path] = None
): ) -> List[Dict]:
""" """
Get all connections for a named net using wire graph analysis Get all connections for a named net using wire graph analysis
@@ -221,7 +223,7 @@ class ConnectionManager:
connections = [] connections = []
tolerance = 0.5 # 0.5mm tolerance for point coincidence (grid spacing consideration) 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)""" """Check if two points are the same (within tolerance)"""
if not p1 or not p2: if not p1 or not p2:
return False return False
@@ -324,8 +326,8 @@ class ConnectionManager:
continue continue
# Check if pin coincides with any wire point # Check if pin coincides with any wire point
for wire_pt in connected_wire_points: for wire_pt_tup in connected_wire_points:
if points_coincide(pin_loc, list(wire_pt)): if points_coincide(pin_loc, list(wire_pt_tup)):
connections.append({"component": ref, "pin": pin_num}) connections.append({"component": ref, "pin": pin_num})
break # Pin found, no need to check more wire points break # Pin found, no need to check more wire points
@@ -344,8 +346,10 @@ class ConnectionManager:
symbol_y = float(symbol_pos[1]) symbol_y = float(symbol_pos[1])
# Check if symbol is near any wire point (within 10mm) # Check if symbol is near any wire point (within 10mm)
for wire_pt in connected_wire_points: for wire_pt_tup in connected_wire_points:
dist = ((symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2) ** 0.5 dist = (
(symbol_x - wire_pt_tup[0]) ** 2 + (symbol_y - wire_pt_tup[1]) ** 2
) ** 0.5
if dist < 10.0: # 10mm proximity threshold if dist < 10.0: # 10mm proximity threshold
connections.append({"component": ref, "pin": "unknown"}) connections.append({"component": ref, "pin": "unknown"})
break # Only add once per component break # Only add once per component
@@ -361,7 +365,9 @@ class ConnectionManager:
return [] return []
@staticmethod @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 Generate a netlist from the schematic

View File

@@ -12,7 +12,7 @@ No API key required.
import logging import logging
import re import re
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional, Tuple
logger = logging.getLogger("kicad_interface") logger = logging.getLogger("kicad_interface")
@@ -49,7 +49,7 @@ class DatasheetManager:
return None return None
@staticmethod @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. Find the line range of the (lib_symbols ...) section.
Returns (start, end) line indices or (None, None) if not found. Returns (start, end) line indices or (None, None) if not found.

View File

@@ -238,7 +238,10 @@ class FootprintCreator:
changes.append(f"drill (inserted)→{new_drill}") changes.append(f"drill (inserted)→{new_drill}")
if shape: if shape:
block, n = re.subn( 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: if n:
changes.append(f"shape→{shape}") changes.append(f"shape→{shape}")

View File

@@ -95,6 +95,8 @@ def _build_freerouting_cmd(
"""Build the command to run Freerouting.""" """Build the command to run Freerouting."""
if use_docker: if use_docker:
docker_exe = _find_docker() docker_exe = _find_docker()
if docker_exe is None:
raise RuntimeError("Docker/Podman executable not found")
board_dir = os.path.dirname(dsn_path) board_dir = os.path.dirname(dsn_path)
dsn_name = os.path.basename(dsn_path) dsn_name = os.path.basename(dsn_path)
ses_name = os.path.basename(ses_path) ses_name = os.path.basename(ses_path)
@@ -120,6 +122,8 @@ def _build_freerouting_cmd(
] ]
else: else:
java_exe = _find_java() java_exe = _find_java()
if java_exe is None:
raise RuntimeError("Java executable not found")
return [ return [
java_exe, java_exe,
"-jar", "-jar",
@@ -136,7 +140,7 @@ def _build_freerouting_cmd(
class FreeroutingCommands: class FreeroutingCommands:
"""Handles Freerouting autoroute operations.""" """Handles Freerouting autoroute operations."""
def __init__(self, board=None): def __init__(self, board: Any = None) -> None:
self.board = board self.board = board
def _resolve_execution_mode(self, jar_path: str) -> Dict[str, Any]: def _resolve_execution_mode(self, jar_path: str) -> Dict[str, Any]:

View File

@@ -11,7 +11,7 @@ import os
import sqlite3 import sqlite3
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Any, Callable, Dict, List, Optional, Tuple
logger = logging.getLogger("kicad_interface") logger = logging.getLogger("kicad_interface")
@@ -38,10 +38,10 @@ class JLCPCBPartsManager:
db_path = str(data_dir / "jlcpcb_parts.db") db_path = str(data_dir / "jlcpcb_parts.db")
self.db_path = db_path self.db_path = db_path
self.conn = None self.conn: Optional[sqlite3.Connection] = None
self._init_database() self._init_database()
def _init_database(self): def _init_database(self) -> None:
"""Initialize SQLite database with schema""" """Initialize SQLite database with schema"""
self.conn = sqlite3.connect(self.db_path) self.conn = sqlite3.connect(self.db_path)
self.conn.row_factory = sqlite3.Row # Return rows as dicts self.conn.row_factory = sqlite3.Row # Return rows as dicts
@@ -90,7 +90,9 @@ class JLCPCBPartsManager:
self.conn.commit() self.conn.commit()
logger.info(f"Initialized JLCPCB parts database at {self.db_path}") 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 Import parts into database from JLCPCB API response
@@ -167,7 +169,9 @@ class JLCPCBPartsManager:
else: else:
return "Extended" # Default to Extended 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 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] alternatives = [p for p in alternatives if p["lcsc"] != lcsc_number]
# Sort by: Basic first, then by price, then by stock # 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 is_basic = 1 if p.get("library_type") == "Basic" else 0
try: try:
prices = json.loads(p.get("price_json", "[]")) prices = json.loads(p.get("price_json", "[]"))
@@ -467,7 +471,7 @@ class JLCPCBPartsManager:
return alternatives[:limit] return alternatives[:limit]
def close(self): def close(self) -> None:
"""Close database connection""" """Close database connection"""
if self.conn: if self.conn:
self.conn.close() self.conn.close()

View File

@@ -7,7 +7,7 @@ jlcsearch service at https://jlcsearch.tscircuit.com/
import logging import logging
import time import time
from typing import Callable, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional, Union
import requests import requests
@@ -24,12 +24,12 @@ class JLCSearchClient:
BASE_URL = "https://jlcsearch.tscircuit.com" BASE_URL = "https://jlcsearch.tscircuit.com"
def __init__(self): def __init__(self) -> None:
"""Initialize JLCSearch API client""" """Initialize JLCSearch API client"""
pass pass
def search_components( 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]: ) -> List[Dict]:
""" """
Search components in JLCSearch database Search components in JLCSearch database
@@ -87,7 +87,7 @@ class JLCSearchClient:
- stock: Available stock - stock: Available stock
- price1: Price per unit - price1: Price per unit
""" """
filters = {} filters: Dict[str, Any] = {}
if resistance is not None: if resistance is not None:
filters["resistance"] = resistance filters["resistance"] = resistance
if package: if package:
@@ -109,7 +109,7 @@ class JLCSearchClient:
Returns: Returns:
List of capacitor dicts List of capacitor dicts
""" """
filters = {} filters: Dict[str, Any] = {}
if capacitance is not None: if capacitance is not None:
filters["capacitance"] = capacitance filters["capacitance"] = capacitance
if package: if package:

View File

@@ -35,7 +35,7 @@ class LibraryManager:
self.footprint_cache: Dict[str, List[str]] = {} # library -> [footprint names] self.footprint_cache: Dict[str, List[str]] = {} # library -> [footprint names]
self._load_libraries() self._load_libraries()
def _load_libraries(self): def _load_libraries(self) -> None:
"""Load libraries from fp-lib-table files""" """Load libraries from fp-lib-table files"""
# Load global libraries # Load global libraries
global_table = self._get_global_fp_lib_table() global_table = self._get_global_fp_lib_table()
@@ -78,7 +78,7 @@ class LibraryManager:
return None 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 Parse fp-lib-table file

View File

@@ -3,6 +3,7 @@ import logging
# Symbol class might not be directly importable in the current version # Symbol class might not be directly importable in the current version
import os import os
from typing import Any, Dict, List, Optional
from skip import Schematic from skip import Schematic
@@ -13,7 +14,7 @@ class LibraryManager:
"""Manage symbol libraries""" """Manage symbol libraries"""
@staticmethod @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""" """List all available symbol libraries"""
if search_paths is None: if search_paths is None:
# Default library paths based on common KiCAD installations # Default library paths based on common KiCAD installations
@@ -46,7 +47,7 @@ class LibraryManager:
return {"paths": libraries, "names": library_names} return {"paths": libraries, "names": library_names}
@staticmethod @staticmethod
def list_library_symbols(library_path): def list_library_symbols(library_path: str) -> List[Any]:
"""List all symbols in a library""" """List all symbols in a library"""
try: try:
# kicad-skip doesn't provide a direct way to simply list symbols in a library # kicad-skip doesn't provide a direct way to simply list symbols in a library
@@ -66,7 +67,7 @@ class LibraryManager:
return [] return []
@staticmethod @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""" """Get detailed information about a symbol"""
try: try:
# Similar to list_library_symbols, this might require a more direct approach # Similar to list_library_symbols, this might require a more direct approach
@@ -80,7 +81,7 @@ class LibraryManager:
return {} return {}
@staticmethod @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""" """Search for symbols matching criteria"""
try: try:
# This would typically involve: # This would typically involve:
@@ -101,7 +102,9 @@ class LibraryManager:
return [] return []
@staticmethod @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""" """Get a recommended default symbol for a given component type"""
# This method provides a simplified way to get a symbol for common component types # 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 # It's useful when the user doesn't specify a particular library/symbol

View File

@@ -55,7 +55,7 @@ class SymbolLibraryManager:
self.symbol_cache: Dict[str, List[SymbolInfo]] = {} # library -> [SymbolInfo] self.symbol_cache: Dict[str, List[SymbolInfo]] = {} # library -> [SymbolInfo]
self._load_libraries() self._load_libraries()
def _load_libraries(self): def _load_libraries(self) -> None:
"""Load libraries from sym-lib-table files""" """Load libraries from sym-lib-table files"""
# Load global libraries # Load global libraries
global_table = self._get_global_sym_lib_table() global_table = self._get_global_sym_lib_table()
@@ -98,7 +98,7 @@ class SymbolLibraryManager:
return None 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 Parse sym-lib-table file
@@ -370,7 +370,7 @@ class SymbolLibraryManager:
query_lower = query.lower() query_lower = query.lower()
# Determine which libraries to search # Determine which libraries to search
libraries_to_search = self.libraries.keys() libraries_to_search: list[str] = list(self.libraries.keys())
if library_filter: if library_filter:
filter_lower = library_filter.lower() filter_lower = library_filter.lower()
libraries_to_search = [ libraries_to_search = [

View File

@@ -9,7 +9,7 @@ import logging
import math import math
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import sexpdata import sexpdata
from sexpdata import Symbol from sexpdata import Symbol
@@ -21,7 +21,7 @@ logger = logging.getLogger("kicad_interface")
class PinLocator: class PinLocator:
"""Locate pins on symbol instances in KiCad schematics""" """Locate pins on symbol instances in KiCad schematics"""
def __init__(self): def __init__(self) -> None:
"""Initialize pin locator with empty cache""" """Initialize pin locator with empty cache"""
self.pin_definition_cache = {} # Cache: "lib_id:symbol_name" -> pin_data self.pin_definition_cache = {} # Cache: "lib_id:symbol_name" -> pin_data
self._schematic_cache: Dict[str, object] = {} # Cache: path -> loaded Schematic 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"} "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""" """Recursively search for pin definitions"""
if not isinstance(sexp, list): if not isinstance(sexp, list):
return return

View File

@@ -115,7 +115,7 @@ class RoutingCommands:
"errorDetails": f"'{ref}' does not exist on the board", "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] fp = footprints[ref]
for pad in fp.Pads(): for pad in fp.Pads():
if pad.GetNumber() == pad_num: if pad.GetNumber() == pad_num:

View File

@@ -2,6 +2,7 @@ import logging
import os import os
import shutil import shutil
import uuid import uuid
from typing import Any, Optional
from skip import Schematic from skip import Schematic
@@ -12,7 +13,7 @@ class SchematicManager:
"""Core schematic operations using kicad-skip""" """Core schematic operations using kicad-skip"""
@staticmethod @staticmethod
def create_schematic(name, metadata=None): def create_schematic(name: str, metadata: Optional[Any] = None) -> Any:
"""Create a new empty schematic from template""" """Create a new empty schematic from template"""
try: try:
# Determine template path (use template_with_symbols for component cloning support) # Determine template path (use template_with_symbols for component cloning support)
@@ -70,7 +71,7 @@ class SchematicManager:
raise raise
@staticmethod @staticmethod
def load_schematic(file_path): def load_schematic(file_path: str) -> Optional[Any]:
"""Load an existing schematic""" """Load an existing schematic"""
if not os.path.exists(file_path): if not os.path.exists(file_path):
logger.error(f"Schematic file not found at {file_path}") logger.error(f"Schematic file not found at {file_path}")
@@ -84,7 +85,7 @@ class SchematicManager:
return None return None
@staticmethod @staticmethod
def save_schematic(schematic, file_path): def save_schematic(schematic: Any, file_path: str) -> bool:
"""Save a schematic to file""" """Save a schematic to file"""
try: try:
# kicad-skip uses write method, not save # kicad-skip uses write method, not save
@@ -96,7 +97,7 @@ class SchematicManager:
return False return False
@staticmethod @staticmethod
def get_schematic_metadata(schematic): def get_schematic_metadata(schematic: Any) -> dict[str, Any]:
"""Extract metadata from schematic""" """Extract metadata from schematic"""
# kicad-skip doesn't expose a direct metadata object on Schematic. # kicad-skip doesn't expose a direct metadata object on Schematic.
# We can return basic info like version and generator. # We can return basic info like version and generator.

View File

@@ -782,7 +782,7 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]:
collisions = [] collisions = []
# Pre-compute per-symbol data # Pre-compute per-symbol data
symbol_data = [] symbol_data: List[Dict[str, Any]] = []
for sym in symbols: for sym in symbols:
ref = sym["reference"] ref = sym["reference"]
if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref: if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref:

View File

@@ -129,7 +129,7 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
cx_ = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2 cx_ = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2
cy_ = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 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( a = math.acos(
max(-1, min(1, (ux * vx + uy * vy) / (math.hypot(ux, uy) * math.hypot(vx, vy)))) 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]]: 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].""" """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]] 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)] 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() result = identity()
@@ -345,7 +345,7 @@ def _apply_transform(pts: List[Point], mat: List[List[float]]) -> List[Point]:
return out 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)] 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 return default
def _identity(): def _identity() -> List[List[float]]:
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]] return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]

View File

@@ -8,7 +8,7 @@ coordinate matching, mirroring KiCad's own connectivity algorithm.
import logging import logging
from pathlib import Path 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 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)) 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.""" """Extract wire endpoints from a schematic object as IU tuples."""
all_wires = [] all_wires = []
for wire in schematic.wire: for wire in schematic.wire:
@@ -67,7 +67,9 @@ def _build_adjacency(
return adjacency, iu_to_wires 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. """Return virtual connectivity from net labels and power symbols.
Returns a tuple of: Returns a tuple of:
@@ -175,8 +177,8 @@ def _find_connected_wires(
def _find_pins_on_net( def _find_pins_on_net(
net_points: Set[Tuple[int, int]], net_points: Set[Tuple[int, int]],
schematic_path, schematic_path: Any,
schematic, schematic: Any,
) -> List[Dict]: ) -> List[Dict]:
"""Find component pins that land on net points using exact IU matching. """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( 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]: ) -> Optional[Dict]:
"""Find all component pins reachable from a point via connected wires, net labels, and power symbols. """Find all component pins reachable from a point via connected wires, net labels, and power symbols.

View File

@@ -7,7 +7,7 @@ All methods operate on in-memory sexpdata lists (no disk I/O).
import logging import logging
import math import math
import uuid import uuid
from typing import Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import sexpdata import sexpdata
from sexpdata import Symbol from sexpdata import Symbol
@@ -55,7 +55,7 @@ class WireDragger:
"""Pure-logic helpers for wire-endpoint dragging during component moves.""" """Pure-logic helpers for wire-endpoint dragging during component moves."""
@staticmethod @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. Find a placed symbol by reference designator.
@@ -218,7 +218,7 @@ class WireDragger:
junction_k = _K["junction"] junction_k = _K["junction"]
at_k = _K["at"] 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(): for (ox, oy), (nx, ny) in old_to_new.items():
if _coords_match(x, y, ox, oy, eps): if _coords_match(x, y, ox, oy, eps):
return nx, ny return nx, ny

View File

@@ -11,7 +11,7 @@ import math
import tempfile import tempfile
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import List, Optional, Tuple from typing import Any, List, Optional, Tuple
import sexpdata import sexpdata
from sexpdata import Symbol from sexpdata import Symbol
@@ -255,7 +255,7 @@ class WireManager:
@staticmethod @staticmethod
def _parse_wire( def _parse_wire(
wire_item, wire_item: Any,
) -> Optional[Tuple[Tuple[float, float], Tuple[float, float], float, str]]: ) -> Optional[Tuple[Tuple[float, float], Tuple[float, float], float, str]]:
""" """
Parse a wire S-expression item in a single pass. Parse a wire S-expression item in a single pass.

View File

@@ -127,7 +127,7 @@ class BoardAPI(ABC):
pass pass
@abstractmethod @abstractmethod
def get_size(self) -> Dict[str, float]: def get_size(self) -> Dict[str, Any]:
""" """
Get current board size Get current board size
@@ -169,6 +169,7 @@ class BoardAPI(ABC):
y: float, y: float,
rotation: float = 0, rotation: float = 0,
layer: str = "F.Cu", layer: str = "F.Cu",
value: str = "",
) -> bool: ) -> bool:
""" """
Place a component on the board Place a component on the board

View File

@@ -40,10 +40,10 @@ class IPCBackend(KiCADBackend):
without requiring manual reload. without requiring manual reload.
""" """
def __init__(self): def __init__(self) -> None:
self._kicad = None self._kicad = None
self._connected = False self._connected = False
self._version = None self._version: Optional[str] = None
self._on_change_callbacks: List[Callable] = [] self._on_change_callbacks: List[Callable] = []
def connect(self, socket_path: Optional[str] = None) -> bool: def connect(self, socket_path: Optional[str] = None) -> bool:
@@ -257,13 +257,13 @@ class IPCBoardAPI(BoardAPI):
Uses transactions for proper undo/redo support. 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._kicad = kicad_instance
self._board = None self._board = None
self._notify = notify_callback self._notify = notify_callback
self._current_commit = None self._current_commit = None
def _get_board(self): def _get_board(self) -> Any:
"""Get board instance, connecting if needed.""" """Get board instance, connecting if needed."""
if self._board is None: if self._board is None:
try: try:
@@ -350,7 +350,7 @@ class IPCBoardAPI(BoardAPI):
logger.error(f"Failed to set board size: {e}") logger.error(f"Failed to set board size: {e}")
return False return False
def get_size(self) -> Dict[str, float]: def get_size(self) -> Dict[str, Any]:
"""Get current board size from bounding box.""" """Get current board size from bounding box."""
try: try:
board = self._get_board() board = self._get_board()
@@ -490,7 +490,7 @@ class IPCBoardAPI(BoardAPI):
logger.error(f"Failed to place component: {e}") logger.error(f"Failed to place component: {e}")
return False 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. Load a footprint from the library using pcbnew SWIG API.
@@ -546,7 +546,14 @@ class IPCBoardAPI(BoardAPI):
return None return None
def _place_loaded_footprint( 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: ) -> bool:
""" """
Place a loaded pcbnew footprint onto the board. Place a loaded pcbnew footprint onto the board.

View File

@@ -26,7 +26,7 @@ class SWIGBackend(KiCADBackend):
for compatibility during migration period. for compatibility during migration period.
""" """
def __init__(self): def __init__(self) -> None:
self._connected = False self._connected = False
self._pcbnew = None self._pcbnew = None
logger.warning( logger.warning(
@@ -98,7 +98,7 @@ class SWIGBackend(KiCADBackend):
from commands.project import ProjectCommands from commands.project import ProjectCommands
try: try:
result = ProjectCommands.open_project(str(path)) result = ProjectCommands().open_project({"filename": str(path)})
return result return result
except Exception as e: except Exception as e:
logger.error(f"Failed to open project: {e}") logger.error(f"Failed to open project: {e}")
@@ -112,8 +112,10 @@ class SWIGBackend(KiCADBackend):
from commands.project import ProjectCommands from commands.project import ProjectCommands
try: try:
path_str = str(path) if path else None params: Dict[str, Any] = {}
result = ProjectCommands.save_project(path_str) if path:
params["filename"] = str(path)
result = ProjectCommands().save_project(params)
return result return result
except Exception as e: except Exception as e:
logger.error(f"Failed to save project: {e}") logger.error(f"Failed to save project: {e}")
@@ -137,7 +139,7 @@ class SWIGBackend(KiCADBackend):
class SWIGBoardAPI(BoardAPI): class SWIGBoardAPI(BoardAPI):
"""Board API implementation wrapping SWIG/pcbnew""" """Board API implementation wrapping SWIG/pcbnew"""
def __init__(self, pcbnew_module): def __init__(self, pcbnew_module: Any) -> None:
self.pcbnew = pcbnew_module self.pcbnew = pcbnew_module
self._board = None self._board = None
@@ -146,13 +148,15 @@ class SWIGBoardAPI(BoardAPI):
from commands.board import BoardCommands from commands.board import BoardCommands
try: 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) return result.get("success", False)
except Exception as e: except Exception as e:
logger.error(f"Failed to set board size: {e}") logger.error(f"Failed to set board size: {e}")
return False return False
def get_size(self) -> Dict[str, float]: def get_size(self) -> Dict[str, Any]:
"""Get board size""" """Get board size"""
# TODO: Implement using existing SWIG code # TODO: Implement using existing SWIG code
raise NotImplementedError("get_size not yet wrapped") raise NotImplementedError("get_size not yet wrapped")
@@ -173,7 +177,7 @@ class SWIGBoardAPI(BoardAPI):
from commands.component import ComponentCommands from commands.component import ComponentCommands
try: try:
result = ComponentCommands.get_component_list() result = ComponentCommands(board=self._board).get_component_list({})
if result.get("success"): if result.get("success"):
return result.get("components", []) return result.get("components", [])
return [] return []
@@ -189,17 +193,20 @@ class SWIGBoardAPI(BoardAPI):
y: float, y: float,
rotation: float = 0, rotation: float = 0,
layer: str = "F.Cu", layer: str = "F.Cu",
value: str = "",
) -> bool: ) -> bool:
"""Place component using existing implementation""" """Place component using existing implementation"""
from commands.component import ComponentCommands from commands.component import ComponentCommands
try: try:
result = ComponentCommands.place_component( result = ComponentCommands(board=self._board).place_component(
component_id=footprint, {
position={"x": x, "y": y, "unit": "mm"}, "componentId": footprint,
reference=reference, "position": {"x": x, "y": y, "unit": "mm"},
rotation=rotation, "reference": reference,
layer=layer, "rotation": rotation,
"layer": layer,
}
) )
return result.get("success", False) return result.get("success", False)
except Exception as e: except Exception as e:

View File

@@ -12,6 +12,7 @@ import logging
import os import os
import sys import sys
import traceback import traceback
from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read
@@ -241,7 +242,7 @@ except ImportError as e:
class KiCADInterface: class KiCADInterface:
"""Main interface class to handle KiCAD operations""" """Main interface class to handle KiCAD operations"""
def __init__(self): def __init__(self) -> None:
"""Initialize the interface and command handlers""" """Initialize the interface and command handlers"""
self.board = None self.board = None
self.project_filename = None self.project_filename = None
@@ -560,7 +561,7 @@ class KiCADInterface:
"connect_passthrough", "connect_passthrough",
} }
def _auto_save_board(self): def _auto_save_board(self) -> None:
"""Save board to disk after SWIG mutations. """Save board to disk after SWIG mutations.
Called automatically after every board-mutating SWIG command so that Called automatically after every board-mutating SWIG command so that
data is not lost if Claude hits the context limit before save_project. data is not lost if Claude hits the context limit before save_project.
@@ -574,7 +575,7 @@ class KiCADInterface:
except Exception as e: except Exception as e:
logger.warning(f"Auto-save failed: {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""" """Update board reference in all command handlers"""
logger.debug("Updating board reference in command handlers") logger.debug("Updating board reference in command handlers")
self.project_commands.board = self.board self.project_commands.board = self.board
@@ -586,7 +587,7 @@ class KiCADInterface:
self.freerouting_commands.board = self.board self.freerouting_commands.board = self.board
# Schematic command handlers # 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""" """Create a new schematic"""
logger.info("Creating schematic") logger.info("Creating schematic")
try: try:
@@ -623,7 +624,7 @@ class KiCADInterface:
logger.error(f"Error creating schematic: {str(e)}") logger.error(f"Error creating schematic: {str(e)}")
return {"success": False, "message": 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""" """Load an existing schematic"""
logger.info("Loading schematic") logger.info("Loading schematic")
try: try:
@@ -644,7 +645,7 @@ class KiCADInterface:
logger.error(f"Error loading schematic: {str(e)}") logger.error(f"Error loading schematic: {str(e)}")
return {"success": False, "message": 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. """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 If boardPath is given and differs from the currently loaded board, the
board is reloaded from boardPath before placing — prevents silent failures board is reloaded from boardPath before placing — prevents silent failures
@@ -679,7 +680,7 @@ class KiCADInterface:
return self.component_commands.place_component(params) 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)""" """Add a component to a schematic using text-based injection (no sexpdata)"""
logger.info("Adding component to schematic") logger.info("Adding component to schematic")
try: try:
@@ -732,7 +733,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_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)""" """Remove a placed symbol from a schematic using text-based manipulation (no skip writes)"""
logger.info("Deleting schematic component") logger.info("Deleting schematic component")
try: try:
@@ -757,7 +758,7 @@ class KiCADInterface:
with open(sch_file, "r", encoding="utf-8") as f: with open(sch_file, "r", encoding="utf-8") as f:
content = f.read() 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.""" """Find the closing paren matching the opening paren at start."""
depth = 0 depth = 0
i = start i = start
@@ -838,7 +839,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_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). """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. 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: with open(sch_file, "r", encoding="utf-8") as f:
content = f.read() 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.""" """Find the position of the closing paren matching the opening paren at start."""
depth = 0 depth = 0
i = start i = start
@@ -928,7 +929,7 @@ class KiCADInterface:
break break
search_start = end + 1 search_start = end + 1
if block_start is None: if block_start is None or block_end is None:
return { return {
"success": False, "success": False,
"message": f"Component '{reference}' not found in schematic", "message": f"Component '{reference}' not found in schematic",
@@ -991,7 +992,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_get_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.""" """Return full component info: position and all field values with their (at x y angle) positions."""
logger.info("Getting schematic component info") logger.info("Getting schematic component info")
try: try:
@@ -1016,7 +1017,7 @@ class KiCADInterface:
with open(sch_file, "r", encoding="utf-8") as f: with open(sch_file, "r", encoding="utf-8") as f:
content = f.read() content = f.read()
def find_matching_paren(s, start): def find_matching_paren(s: str, start: int) -> int:
depth = 0 depth = 0
i = start i = start
while i < len(s): while i < len(s):
@@ -1058,7 +1059,7 @@ class KiCADInterface:
break break
search_start = end + 1 search_start = end + 1
if block_start is None: if block_start is None or block_end is None:
return { return {
"success": False, "success": False,
"message": f"Component '{reference}' not found in schematic", "message": f"Component '{reference}' not found in schematic",
@@ -1114,7 +1115,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_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""" """Add a wire to a schematic using WireManager, with optional pin snapping"""
logger.info("Adding wire to schematic") logger.info("Adding wire to schematic")
try: try:
@@ -1164,7 +1165,7 @@ class KiCADInterface:
for pin_num, coords in pin_locs.items(): for pin_num, coords in pin_locs.items():
all_pins.append((ref, pin_num, coords)) 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.""" """Find the nearest pin within tolerance of a point."""
best = None best = None
best_dist = tolerance best_dist = tolerance
@@ -1238,7 +1239,7 @@ class KiCADInterface:
"errorDetails": traceback.format_exc(), "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""" """Add a junction (connection dot) to a schematic using WireManager"""
logger.info("Adding junction to schematic") logger.info("Adding junction to schematic")
try: try:
@@ -1271,19 +1272,19 @@ class KiCADInterface:
"errorDetails": traceback.format_exc(), "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""" """List available symbol libraries"""
logger.info("Listing schematic libraries") logger.info("Listing schematic libraries")
try: try:
search_paths = params.get("searchPaths") search_paths = params.get("searchPaths")
libraries = LibraryManager.list_available_libraries(search_paths) libraries = SchematicLibraryManager.list_available_libraries(search_paths)
return {"success": True, "libraries": libraries} return {"success": True, "libraries": libraries}
except Exception as e: except Exception as e:
logger.error(f"Error listing schematic libraries: {str(e)}") logger.error(f"Error listing schematic libraries: {str(e)}")
return {"success": False, "message": 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""" """List component pins with no wire/label/power symbol touching them"""
logger.info("Finding unconnected pins") logger.info("Finding unconnected pins")
try: try:
@@ -1303,7 +1304,7 @@ class KiCADInterface:
logger.error(f"Error finding unconnected pins: {e}") logger.error(f"Error finding unconnected pins: {e}")
return {"success": False, "message": str(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""" """Detect wires passing through component bodies without connecting to pins"""
logger.info("Checking wire collisions") logger.info("Checking wire collisions")
try: try:
@@ -1327,7 +1328,7 @@ class KiCADInterface:
# Footprint handlers # # 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.""" """Create a new .kicad_mod footprint file in a .pretty library."""
logger.info(f"create_footprint: {params.get('name')} in {params.get('libraryPath')}") logger.info(f"create_footprint: {params.get('name')} in {params.get('libraryPath')}")
try: try:
@@ -1349,7 +1350,7 @@ class KiCADInterface:
logger.error(f"create_footprint error: {e}") logger.error(f"create_footprint error: {e}")
return {"success": False, "error": str(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.""" """Edit an existing pad in a .kicad_mod file."""
logger.info( logger.info(
f"edit_footprint_pad: pad {params.get('padNumber')} in {params.get('footprintPath')}" 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}") logger.error(f"edit_footprint_pad error: {e}")
return {"success": False, "error": str(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.""" """List .pretty footprint libraries and their contents."""
logger.info("list_footprint_libraries") logger.info("list_footprint_libraries")
try: try:
@@ -1378,7 +1379,7 @@ class KiCADInterface:
logger.error(f"list_footprint_libraries error: {e}") logger.error(f"list_footprint_libraries error: {e}")
return {"success": False, "error": str(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.""" """Register a .pretty library in KiCAD's fp-lib-table."""
logger.info(f"register_footprint_library: {params.get('libraryPath')}") logger.info(f"register_footprint_library: {params.get('libraryPath')}")
try: try:
@@ -1398,7 +1399,7 @@ class KiCADInterface:
# Symbol creator handlers # # 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.""" """Create a new symbol in a .kicad_sym library."""
logger.info(f"create_symbol: {params.get('name')} in {params.get('libraryPath')}") logger.info(f"create_symbol: {params.get('name')} in {params.get('libraryPath')}")
try: try:
@@ -1422,7 +1423,7 @@ class KiCADInterface:
logger.error(f"create_symbol error: {e}") logger.error(f"create_symbol error: {e}")
return {"success": False, "error": str(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.""" """Delete a symbol from a .kicad_sym library."""
logger.info(f"delete_symbol: {params.get('name')} from {params.get('libraryPath')}") logger.info(f"delete_symbol: {params.get('name')} from {params.get('libraryPath')}")
try: try:
@@ -1435,7 +1436,7 @@ class KiCADInterface:
logger.error(f"delete_symbol error: {e}") logger.error(f"delete_symbol error: {e}")
return {"success": False, "error": str(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.""" """List all symbols in a .kicad_sym file."""
logger.info(f"list_symbols_in_library: {params.get('libraryPath')}") logger.info(f"list_symbols_in_library: {params.get('libraryPath')}")
try: try:
@@ -1447,7 +1448,7 @@ class KiCADInterface:
logger.error(f"list_symbols_in_library error: {e}") logger.error(f"list_symbols_in_library error: {e}")
return {"success": False, "error": str(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.""" """Register a .kicad_sym library in KiCAD's sym-lib-table."""
logger.info(f"register_symbol_library: {params.get('libraryPath')}") logger.info(f"register_symbol_library: {params.get('libraryPath')}")
try: try:
@@ -1463,7 +1464,7 @@ class KiCADInterface:
logger.error(f"register_symbol_library error: {e}") logger.error(f"register_symbol_library error: {e}")
return {"success": False, "error": str(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""" """Export schematic to PDF"""
logger.info("Exporting schematic to PDF") logger.info("Exporting schematic to PDF")
try: try:
@@ -1512,7 +1513,7 @@ class KiCADInterface:
logger.error(f"Error exporting schematic to PDF: {str(e)}") logger.error(f"Error exporting schematic to PDF: {str(e)}")
return {"success": False, "message": 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""" """Add a net label to schematic using WireManager"""
logger.info("Adding net label to schematic") logger.info("Adding net label to schematic")
try: try:
@@ -1558,7 +1559,7 @@ class KiCADInterface:
"errorDetails": traceback.format_exc(), "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""" """Connect a component pin to a named net using wire stub and label"""
logger.info("Connecting component pin to net") logger.info("Connecting component pin to net")
try: try:
@@ -1595,7 +1596,7 @@ class KiCADInterface:
"errorDetails": traceback.format_exc(), "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""" """Connect all pins of source connector to matching pins of target connector"""
logger.info("Connecting passthrough between two connectors") logger.info("Connecting passthrough between two connectors")
try: try:
@@ -1632,7 +1633,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_get_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""" """Return exact pin endpoint coordinates for a schematic component"""
logger.info("Getting schematic pin locations") logger.info("Getting schematic pin locations")
try: try:
@@ -1687,7 +1688,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_get_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)""" """Get a rasterised image of the schematic (SVG export → optional PNG conversion)"""
logger.info("Getting schematic view") logger.info("Getting schematic view")
import base64 import base64
@@ -1776,7 +1777,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_list_schematic_components(self, params): def _handle_list_schematic_components(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""List all components in a schematic""" """List all components in a schematic"""
logger.info("Listing schematic components") logger.info("Listing schematic components")
try: try:
@@ -1869,7 +1870,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_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""" """List all nets in a schematic with their connections"""
logger.info("Listing schematic nets") logger.info("Listing schematic nets")
try: try:
@@ -1915,7 +1916,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_list_schematic_wires(self, params): def _handle_list_schematic_wires(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""List all wires in a schematic""" """List all wires in a schematic"""
logger.info("Listing schematic wires") logger.info("Listing schematic wires")
try: try:
@@ -1958,7 +1959,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_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""" """List all net labels and power flags in a schematic"""
logger.info("Listing schematic labels") logger.info("Listing schematic labels")
try: try:
@@ -2037,7 +2038,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_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.""" """Move a schematic component to a new position, dragging connected wires."""
logger.info("Moving schematic component") logger.info("Moving schematic component")
try: try:
@@ -2121,7 +2122,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_rotate_schematic_component(self, params): def _handle_rotate_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Rotate a schematic component""" """Rotate a schematic component"""
logger.info("Rotating schematic component") logger.info("Rotating schematic component")
try: try:
@@ -2171,7 +2172,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_annotate_schematic(self, params): def _handle_annotate_schematic(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Annotate unannotated components in schematic (R? -> R1, R2, ...)""" """Annotate unannotated components in schematic (R? -> R1, R2, ...)"""
logger.info("Annotating schematic") logger.info("Annotating schematic")
try: try:
@@ -2249,7 +2250,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_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""" """Delete a wire from the schematic matching start/end points"""
logger.info("Deleting schematic wire") logger.info("Deleting schematic wire")
try: try:
@@ -2280,7 +2281,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_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""" """Delete a net label from the schematic"""
logger.info("Deleting schematic net label") logger.info("Deleting schematic net label")
try: try:
@@ -2315,7 +2316,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_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""" """Export schematic to SVG using kicad-cli"""
logger.info("Exporting schematic SVG") logger.info("Exporting schematic SVG")
import glob import glob
@@ -2389,7 +2390,7 @@ class KiCADInterface:
logger.error(f"Error exporting schematic SVG: {e}") logger.error(f"Error exporting schematic SVG: {e}")
return {"success": False, "message": str(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""" """Get all connections for a named net"""
logger.info("Getting net connections") logger.info("Getting net connections")
try: try:
@@ -2409,7 +2410,7 @@ class KiCADInterface:
logger.error(f"Error getting net connections: {str(e)}") logger.error(f"Error getting net connections: {str(e)}")
return {"success": False, "message": 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""" """Find all component pins reachable from a point via connected wires"""
logger.info("Getting wire connections") logger.info("Getting wire connections")
try: try:
@@ -2456,7 +2457,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_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""" """Run Electrical Rules Check on a schematic via kicad-cli"""
logger.info("Running ERC on schematic") logger.info("Running ERC on schematic")
import os import os
@@ -2552,7 +2553,7 @@ class KiCADInterface:
logger.error(f"Error running ERC: {str(e)}") logger.error(f"Error running ERC: {str(e)}")
return {"success": False, "message": 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""" """Generate netlist from schematic"""
logger.info("Generating netlist from schematic") logger.info("Generating netlist from schematic")
try: try:
@@ -2571,7 +2572,7 @@ class KiCADInterface:
logger.error(f"Error generating netlist: {str(e)}") logger.error(f"Error generating netlist: {str(e)}")
return {"success": False, "message": 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'). """Sync schematic netlist to PCB board (equivalent to KiCAD F8 'Update PCB from Schematic').
Reads net connections from the schematic and assigns them to the matching pads in the PCB. Reads net connections from the schematic and assigns them to the matching pads in the PCB.
""" """
@@ -2694,7 +2695,7 @@ class KiCADInterface:
# Schematic analysis tools (read-only) # 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""" """Export a cropped region of the schematic as an image"""
logger.info("Exporting schematic view region") logger.info("Exporting schematic view region")
import base64 import base64
@@ -2809,7 +2810,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_find_overlapping_elements(self, params): def _handle_find_overlapping_elements(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Detect spatially overlapping symbols, wires, and labels""" """Detect spatially overlapping symbols, wires, and labels"""
logger.info("Finding overlapping elements in schematic") logger.info("Finding overlapping elements in schematic")
try: try:
@@ -2835,7 +2836,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_get_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""" """List all wires, labels, and symbols within a rectangular region"""
logger.info("Getting elements in schematic region") logger.info("Getting elements in schematic region")
try: try:
@@ -2865,7 +2866,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_find_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""" """Find wires that cross over component symbol bodies"""
logger.info("Finding wires crossing symbols in schematic") logger.info("Finding wires crossing symbols in schematic")
try: try:
@@ -2891,7 +2892,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_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""" """Import an SVG file as PCB graphic polygons on the silkscreen"""
logger.info("Importing SVG logo into PCB") logger.info("Importing SVG logo into PCB")
try: try:
@@ -2938,7 +2939,7 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
def _handle_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.""" """Copy the entire project folder to a snapshot directory for checkpoint/resume."""
import shutil import shutil
from datetime import datetime from datetime import datetime
@@ -3022,7 +3023,7 @@ class KiCADInterface:
logger.error(f"snapshot_project error: {e}") logger.error(f"snapshot_project error: {e}")
return {"success": False, "message": str(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""" """Check if KiCAD UI is running"""
logger.info("Checking if KiCAD UI is running") logger.info("Checking if KiCAD UI is running")
try: try:
@@ -3040,7 +3041,7 @@ class KiCADInterface:
logger.error(f"Error checking KiCAD UI status: {str(e)}") logger.error(f"Error checking KiCAD UI status: {str(e)}")
return {"success": False, "message": 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""" """Launch KiCAD UI"""
logger.info("Launching KiCAD UI") logger.info("Launching KiCAD UI")
try: try:
@@ -3059,7 +3060,7 @@ class KiCADInterface:
logger.error(f"Error launching KiCAD UI: {str(e)}") logger.error(f"Error launching KiCAD UI: {str(e)}")
return {"success": False, "message": 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. """Refill all copper pour zones on the board.
pcbnew.ZONE_FILLER.Fill() can cause a C++ access violation (0xC0000005) 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 # 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""" """IPC handler for route_trace - adds track with real-time UI update"""
try: try:
# Extract parameters matching the existing route_trace interface # Extract parameters matching the existing route_trace interface
@@ -3189,7 +3190,7 @@ print("ok")
logger.error(f"IPC route_trace error: {e}") logger.error(f"IPC route_trace error: {e}")
return {"success": False, "message": str(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""" """IPC handler for add_via - adds via with real-time UI update"""
try: try:
position = params.get("position", {}) position = params.get("position", {})
@@ -3222,7 +3223,7 @@ print("ok")
logger.error(f"IPC add_via error: {e}") logger.error(f"IPC add_via error: {e}")
return {"success": False, "message": str(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""" """IPC handler for add_net"""
# Note: Net creation via IPC is limited - nets are typically created # Note: Net creation via IPC is limited - nets are typically created
# when components are placed. Return success for compatibility. # when components are placed. Return success for compatibility.
@@ -3234,7 +3235,7 @@ print("ok")
"net": {"name": name}, "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""" """IPC handler for add_copper_pour - adds zone with real-time UI update"""
try: try:
layer = params.get("layer", "F.Cu") layer = params.get("layer", "F.Cu")
@@ -3289,7 +3290,7 @@ print("ok")
logger.error(f"IPC add_copper_pour error: {e}") logger.error(f"IPC add_copper_pour error: {e}")
return {"success": False, "message": str(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""" """IPC handler for refill_zones - refills all zones with real-time UI update"""
try: try:
success = self.ipc_board_api.refill_zones() success = self.ipc_board_api.refill_zones()
@@ -3304,7 +3305,7 @@ print("ok")
logger.error(f"IPC refill_zones error: {e}") logger.error(f"IPC refill_zones error: {e}")
return {"success": False, "message": str(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""" """IPC handler for add_text/add_board_text - adds text with real-time UI update"""
try: try:
text = params.get("text", "") text = params.get("text", "")
@@ -3331,7 +3332,7 @@ print("ok")
logger.error(f"IPC add_text error: {e}") logger.error(f"IPC add_text error: {e}")
return {"success": False, "message": str(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""" """IPC handler for set_board_size"""
try: try:
width = params.get("width", 100) width = params.get("width", 100)
@@ -3353,7 +3354,7 @@ print("ok")
logger.error(f"IPC set_board_size error: {e}") logger.error(f"IPC set_board_size error: {e}")
return {"success": False, "message": str(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""" """IPC handler for get_board_info"""
try: try:
size = self.ipc_board_api.get_size() size = self.ipc_board_api.get_size()
@@ -3378,7 +3379,7 @@ print("ok")
logger.error(f"IPC get_board_info error: {e}") logger.error(f"IPC get_board_info error: {e}")
return {"success": False, "message": str(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""" """IPC handler for place_component - places component with real-time UI update"""
try: try:
reference = params.get("reference", params.get("componentId", "")) reference = params.get("reference", params.get("componentId", ""))
@@ -3419,7 +3420,7 @@ print("ok")
logger.error(f"IPC place_component error: {e}") logger.error(f"IPC place_component error: {e}")
return {"success": False, "message": str(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""" """IPC handler for move_component - moves component with real-time UI update"""
try: try:
reference = params.get("reference", params.get("componentId", "")) reference = params.get("reference", params.get("componentId", ""))
@@ -3444,7 +3445,7 @@ print("ok")
logger.error(f"IPC move_component error: {e}") logger.error(f"IPC move_component error: {e}")
return {"success": False, "message": str(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""" """IPC handler for delete_component - deletes component with real-time UI update"""
try: try:
reference = params.get("reference", params.get("componentId", "")) reference = params.get("reference", params.get("componentId", ""))
@@ -3463,7 +3464,7 @@ print("ok")
logger.error(f"IPC delete_component error: {e}") logger.error(f"IPC delete_component error: {e}")
return {"success": False, "message": str(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""" """IPC handler for get_component_list"""
try: try:
components = self.ipc_board_api.list_components() components = self.ipc_board_api.list_components()
@@ -3473,7 +3474,7 @@ print("ok")
logger.error(f"IPC get_component_list error: {e}") logger.error(f"IPC get_component_list error: {e}")
return {"success": False, "message": str(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""" """IPC handler for save_project"""
try: try:
success = self.ipc_board_api.save() success = self.ipc_board_api.save()
@@ -3486,14 +3487,14 @@ print("ok")
logger.error(f"IPC save_project error: {e}") logger.error(f"IPC save_project error: {e}")
return {"success": False, "message": str(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 handler for delete_trace - Note: IPC doesn't support direct trace deletion yet"""
# IPC API doesn't have a direct delete track method # IPC API doesn't have a direct delete track method
# Fall back to SWIG for this operation # Fall back to SWIG for this operation
logger.info("delete_trace: Falling back to SWIG (IPC doesn't support trace deletion)") logger.info("delete_trace: Falling back to SWIG (IPC doesn't support trace deletion)")
return self.routing_commands.delete_trace(params) 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""" """IPC handler for get_nets_list - gets nets with real-time data"""
try: try:
nets = self.ipc_board_api.get_nets() nets = self.ipc_board_api.get_nets()
@@ -3503,7 +3504,7 @@ print("ok")
logger.error(f"IPC get_nets_list error: {e}") logger.error(f"IPC get_nets_list error: {e}")
return {"success": False, "message": str(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. """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 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 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}") logger.error(f"IPC add_board_outline error: {e}")
return {"success": False, "message": str(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""" """IPC handler for add_mounting_hole - adds mounting hole with real-time UI update"""
try: try:
from kipy.board_types import BoardCircle from kipy.board_types import BoardCircle
@@ -3601,7 +3602,7 @@ print("ok")
logger.error(f"IPC add_mounting_hole error: {e}") logger.error(f"IPC add_mounting_hole error: {e}")
return {"success": False, "message": str(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""" """IPC handler for get_layer_list - gets enabled layers"""
try: try:
layers = self.ipc_board_api.get_enabled_layers() layers = self.ipc_board_api.get_enabled_layers()
@@ -3611,7 +3612,7 @@ print("ok")
logger.error(f"IPC get_layer_list error: {e}") logger.error(f"IPC get_layer_list error: {e}")
return {"success": False, "message": str(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""" """IPC handler for rotate_component - rotates component with real-time UI update"""
try: try:
reference = params.get("reference", params.get("componentId", "")) reference = params.get("reference", params.get("componentId", ""))
@@ -3653,7 +3654,7 @@ print("ok")
logger.error(f"IPC rotate_component error: {e}") logger.error(f"IPC rotate_component error: {e}")
return {"success": False, "message": str(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""" """IPC handler for get_component_properties - gets detailed component info"""
try: try:
reference = params.get("reference", params.get("componentId", "")) reference = params.get("reference", params.get("componentId", ""))
@@ -3677,7 +3678,7 @@ print("ok")
# Legacy IPC command handlers (explicit ipc_* commands) # 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""" """Get information about the current backend"""
return { return {
"success": True, "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)""" """Add a track using IPC backend (real-time)"""
if not self.use_ipc or not self.ipc_board_api: if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"} return {"success": False, "message": "IPC backend not available"}
@@ -3718,7 +3719,7 @@ print("ok")
logger.error(f"Error adding track via IPC: {e}") logger.error(f"Error adding track via IPC: {e}")
return {"success": False, "message": str(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)""" """Add a via using IPC backend (real-time)"""
if not self.use_ipc or not self.ipc_board_api: if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"} return {"success": False, "message": "IPC backend not available"}
@@ -3741,7 +3742,7 @@ print("ok")
logger.error(f"Error adding via via IPC: {e}") logger.error(f"Error adding via via IPC: {e}")
return {"success": False, "message": str(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)""" """Add text using IPC backend (real-time)"""
if not self.use_ipc or not self.ipc_board_api: if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"} return {"success": False, "message": "IPC backend not available"}
@@ -3766,7 +3767,7 @@ print("ok")
logger.error(f"Error adding text via IPC: {e}") logger.error(f"Error adding text via IPC: {e}")
return {"success": False, "message": str(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""" """List components using IPC backend"""
if not self.use_ipc or not self.ipc_board_api: if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"} return {"success": False, "message": "IPC backend not available"}
@@ -3778,7 +3779,7 @@ print("ok")
logger.error(f"Error listing components via IPC: {e}") logger.error(f"Error listing components via IPC: {e}")
return {"success": False, "message": str(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""" """Get tracks using IPC backend"""
if not self.use_ipc or not self.ipc_board_api: if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"} return {"success": False, "message": "IPC backend not available"}
@@ -3790,7 +3791,7 @@ print("ok")
logger.error(f"Error getting tracks via IPC: {e}") logger.error(f"Error getting tracks via IPC: {e}")
return {"success": False, "message": str(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""" """Get vias using IPC backend"""
if not self.use_ipc or not self.ipc_board_api: if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"} return {"success": False, "message": "IPC backend not available"}
@@ -3802,7 +3803,7 @@ print("ok")
logger.error(f"Error getting vias via IPC: {e}") logger.error(f"Error getting vias via IPC: {e}")
return {"success": False, "message": str(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""" """Save board using IPC backend"""
if not self.use_ipc or not self.ipc_board_api: if not self.use_ipc or not self.ipc_board_api:
return {"success": False, "message": "IPC backend not available"} return {"success": False, "message": "IPC backend not available"}
@@ -3819,7 +3820,7 @@ print("ok")
# JLCPCB API handlers # 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""" """Download JLCPCB parts database from JLCSearch API"""
try: try:
force = params.get("force", False) force = params.get("force", False)
@@ -3870,7 +3871,7 @@ print("ok")
"message": f"Failed to download database: {str(e)}", "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""" """Search JLCPCB parts database"""
try: try:
query = params.get("query") query = params.get("query")
@@ -3909,7 +3910,7 @@ print("ok")
logger.error(f"Error searching JLCPCB parts: {e}", exc_info=True) logger.error(f"Error searching JLCPCB parts: {e}", exc_info=True)
return {"success": False, "message": f"Search failed: {str(e)}"} 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""" """Get detailed information for a specific JLCPCB part"""
try: try:
lcsc_number = params.get("lcsc_number") lcsc_number = params.get("lcsc_number")
@@ -3929,7 +3930,7 @@ print("ok")
logger.error(f"Error getting JLCPCB part: {e}", exc_info=True) logger.error(f"Error getting JLCPCB part: {e}", exc_info=True)
return {"success": False, "message": f"Failed to get part info: {str(e)}"} 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""" """Get statistics about JLCPCB database"""
try: try:
stats = self.jlcpcb_parts.get_database_stats() stats = self.jlcpcb_parts.get_database_stats()
@@ -3939,7 +3940,7 @@ print("ok")
logger.error(f"Error getting database stats: {e}", exc_info=True) logger.error(f"Error getting database stats: {e}", exc_info=True)
return {"success": False, "message": f"Failed to get stats: {str(e)}"} 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""" """Suggest alternative JLCPCB parts"""
try: try:
lcsc_number = params.get("lcsc_number") lcsc_number = params.get("lcsc_number")
@@ -3980,7 +3981,7 @@ print("ok")
"message": f"Failed to suggest alternatives: {str(e)}", "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""" """Enrich schematic Datasheet fields from LCSC numbers"""
try: try:
from pathlib import Path from pathlib import Path
@@ -3998,7 +3999,7 @@ print("ok")
"message": f"Failed to enrich datasheets: {str(e)}", "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""" """Return LCSC datasheet and product URLs for a part number"""
try: try:
lcsc = params.get("lcsc", "") 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. """Write a JSON response to the original stdout fd.
All response output goes through this function so that stray C-level 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")) os.write(response_fd, payload.encode("utf-8"))
def main(): def main() -> None:
"""Main entry point""" """Main entry point"""
# --- Redirect stdout so pcbnew C++ noise never reaches the TS host --- # --- Redirect stdout so pcbnew C++ noise never reaches the TS host ---
# Save the real stdout fd for our exclusive JSON response channel. # Save the real stdout fd for our exclusive JSON response channel.

View File

@@ -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 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""" """Get current project information"""
result = interface.project_commands.get_project_info({}) 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""" """Get board properties and metadata"""
result = interface.board_commands.get_board_info({}) 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""" """Get list of all components"""
result = interface.component_commands.get_component_list({}) 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""" """Get list of electrical nets"""
result = interface.routing_commands.get_nets_list({}) 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""" """Get layer stack information"""
result = interface.board_commands.get_layer_list({}) 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""" """Get design rule settings"""
result = interface.design_rule_commands.get_design_rules({}) 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""" """Get DRC violations"""
result = interface.design_rule_commands.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""" """Get board preview as PNG image"""
result = interface.board_commands.get_board_2d_view({"width": 800, "height": 600}) result = interface.board_commands.get_board_2d_view({"width": 800, "height": 600})

View File

@@ -16,6 +16,7 @@ Usage:
import os import os
import sys import sys
from typing import Any, Optional
# Add parent directory to path # Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -29,7 +30,7 @@ logging.basicConfig(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def test_connection(): def test_connection() -> Optional[Any]:
"""Test basic IPC connection to KiCAD.""" """Test basic IPC connection to KiCAD."""
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("TEST 1: IPC Connection") print("TEST 1: IPC Connection")
@@ -62,7 +63,7 @@ def test_connection():
return None return None
def test_board_access(backend): def test_board_access(backend: Any) -> Optional[Any]:
"""Test board access and component listing.""" """Test board access and component listing."""
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("TEST 2: Board Access") print("TEST 2: Board Access")
@@ -93,7 +94,7 @@ def test_board_access(backend):
return None return None
def test_board_info(board_api): def test_board_info(board_api: Any) -> bool:
"""Test getting board information.""" """Test getting board information."""
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("TEST 3: Board Information") print("TEST 3: Board Information")
@@ -134,7 +135,7 @@ def test_board_info(board_api):
return False 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).""" """Test adding a track in real-time (appears immediately in KiCAD UI)."""
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("TEST 4: Real-time Track Addition") print("TEST 4: Real-time Track Addition")
@@ -168,7 +169,7 @@ def test_realtime_track(board_api, interactive=False):
return 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).""" """Test adding a via in real-time (appears immediately in KiCAD UI)."""
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("TEST 5: Real-time Via Addition") print("TEST 5: Real-time Via Addition")
@@ -200,7 +201,7 @@ def test_realtime_via(board_api, interactive=False):
return 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.""" """Test adding text in real-time."""
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("TEST 6: Real-time Text Addition") print("TEST 6: Real-time Text Addition")
@@ -229,7 +230,7 @@ def test_realtime_text(board_api, interactive=False):
return 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.""" """Test getting the current selection from KiCAD UI."""
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("TEST 7: UI Selection") print("TEST 7: UI Selection")
@@ -255,7 +256,7 @@ def test_selection(board_api, interactive=False):
return False return False
def run_all_tests(interactive=False): def run_all_tests(interactive: bool = False) -> bool:
"""Run all IPC backend tests.""" """Run all IPC backend tests."""
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("KiCAD IPC Backend Test Suite") print("KiCAD IPC Backend Test Suite")

View File

@@ -10,6 +10,7 @@ import re
import sys import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Any
import pytest import pytest
@@ -121,32 +122,32 @@ class TestDeleteDetectionRegex:
OLD_PATTERN = re.compile(r"^\s*\(symbol\s+\(lib_id\s+\"", re.MULTILINE) OLD_PATTERN = re.compile(r"^\s*\(symbol\s+\(lib_id\s+\"", re.MULTILINE)
NEW_PATTERN = re.compile(r'\(symbol\s+\(lib_id\s+"') NEW_PATTERN = re.compile(r'\(symbol\s+\(lib_id\s+"')
def test_old_regex_fails_on_multiline_format(self): def test_old_regex_fails_on_multiline_format(self) -> None:
"""Regression: old line-by-line regex must NOT match the multi-line format.""" """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. # The old code used re.match on individual lines; simulate that here.
lines = PLACED_RESISTOR_MULTILINE.split("\n") lines = PLACED_RESISTOR_MULTILINE.split("\n")
matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)] 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" assert matches == [], "Old regex should not match multi-line KiCAD format"
def test_old_regex_matches_inline_format(self): def test_old_regex_matches_inline_format(self) -> None:
"""Old regex did work on single-line (inline) format.""" """Old regex did work on single-line (inline) format."""
lines = PLACED_RESISTOR_INLINE.split("\n") lines = PLACED_RESISTOR_INLINE.split("\n")
matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)] matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)]
assert len(matches) == 1 assert len(matches) == 1
def test_new_pattern_matches_multiline_format(self): def test_new_pattern_matches_multiline_format(self) -> None:
"""New content-string pattern must find blocks in multi-line format.""" """New content-string pattern must find blocks in multi-line format."""
assert self.NEW_PATTERN.search(PLACED_RESISTOR_MULTILINE) is not None assert self.NEW_PATTERN.search(PLACED_RESISTOR_MULTILINE) is not None
def test_new_pattern_matches_inline_format(self): def test_new_pattern_matches_inline_format(self) -> None:
"""New content-string pattern also works on inline format.""" """New content-string pattern also works on inline format."""
assert self.NEW_PATTERN.search(PLACED_RESISTOR_INLINE) is not None assert self.NEW_PATTERN.search(PLACED_RESISTOR_INLINE) is not None
def test_new_pattern_matches_power_symbol_multiline(self): def test_new_pattern_matches_power_symbol_multiline(self) -> None:
"""New pattern must find #PWR030 power symbol in multi-line format.""" """New pattern must find #PWR030 power symbol in multi-line format."""
assert self.NEW_PATTERN.search(PLACED_POWER_SYMBOL_MULTILINE) is not None assert self.NEW_PATTERN.search(PLACED_POWER_SYMBOL_MULTILINE) is not None
def test_reference_extraction_from_multiline_block(self): def test_reference_extraction_from_multiline_block(self) -> None:
"""Reference property can be found inside a multi-line block.""" """Reference property can be found inside a multi-line block."""
ref_pattern = re.compile(r'\(property\s+"Reference"\s+"#PWR030"') ref_pattern = re.compile(r'\(property\s+"Reference"\s+"#PWR030"')
assert ref_pattern.search(PLACED_POWER_SYMBOL_MULTILINE) is not None assert ref_pattern.search(PLACED_POWER_SYMBOL_MULTILINE) is not None
@@ -159,49 +160,49 @@ class TestDeleteDetectionRegex:
@pytest.mark.integration @pytest.mark.integration
class TestDeleteSchematicComponentIntegration: class TestDeleteSchematicComponentIntegration:
def _get_handler(self): def _get_handler(self) -> Any:
from kicad_interface import KiCADInterface from kicad_interface import KiCADInterface
iface = KiCADInterface.__new__(KiCADInterface) iface = KiCADInterface.__new__(KiCADInterface)
return iface._handle_delete_schematic_component return iface._handle_delete_schematic_component
def test_delete_inline_format_succeeds(self, tmp_path): def test_delete_inline_format_succeeds(self, tmp_path: Any) -> None:
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE) sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE)
result = self._get_handler()({"schematicPath": str(sch), "reference": "R1"}) result = self._get_handler()({"schematicPath": str(sch), "reference": "R1"})
assert result["success"] is True assert result["success"] is True
assert result["deleted_count"] == 1 assert result["deleted_count"] == 1
def test_delete_multiline_format_succeeds(self, tmp_path): def test_delete_multiline_format_succeeds(self, tmp_path: Any) -> None:
"""Regression: must succeed when KiCAD writes (symbol and (lib_id on separate lines.""" """Regression: must succeed when KiCAD writes (symbol and (lib_id on separate lines."""
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_MULTILINE) sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_MULTILINE)
result = self._get_handler()({"schematicPath": str(sch), "reference": "R2"}) result = self._get_handler()({"schematicPath": str(sch), "reference": "R2"})
assert result["success"] is True assert result["success"] is True
assert result["deleted_count"] == 1 assert result["deleted_count"] == 1
def test_delete_power_symbol_multiline_succeeds(self, tmp_path): def test_delete_power_symbol_multiline_succeeds(self, tmp_path: Any) -> None:
"""Regression: #PWR030 multi-line power symbol must be deletable.""" """Regression: #PWR030 multi-line power symbol must be deletable."""
sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE) sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE)
result = self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"}) result = self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"})
assert result["success"] is True assert result["success"] is True
assert result["deleted_count"] == 1 assert result["deleted_count"] == 1
def test_component_absent_after_delete(self, tmp_path): def test_component_absent_after_delete(self, tmp_path: Any) -> None:
sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE) sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE)
self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"}) self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"})
remaining = sch.read_text(encoding="utf-8") remaining = sch.read_text(encoding="utf-8")
assert '"#PWR030"' not in remaining assert '"#PWR030"' not in remaining
def test_unknown_reference_returns_failure(self, tmp_path): def test_unknown_reference_returns_failure(self, tmp_path: Any) -> None:
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE) sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE)
result = self._get_handler()({"schematicPath": str(sch), "reference": "U99"}) result = self._get_handler()({"schematicPath": str(sch), "reference": "U99"})
assert result["success"] is False assert result["success"] is False
assert "not found" in result["message"] assert "not found" in result["message"]
def test_missing_schematic_path_returns_failure(self, tmp_path): def test_missing_schematic_path_returns_failure(self, tmp_path: Any) -> None:
result = self._get_handler()({"reference": "R1"}) result = self._get_handler()({"reference": "R1"})
assert result["success"] is False assert result["success"] is False
def test_missing_reference_returns_failure(self, tmp_path): def test_missing_reference_returns_failure(self, tmp_path: Any) -> None:
sch = _make_test_schematic(tmp_path) sch = _make_test_schematic(tmp_path)
result = self._get_handler()({"schematicPath": str(sch)}) result = self._get_handler()({"schematicPath": str(sch)})
assert result["success"] is False assert result["success"] is False

View File

@@ -12,6 +12,7 @@ Covers:
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -33,7 +34,7 @@ pcbnew_mock = sys.modules["pcbnew"]
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def reset_pcbnew_mock(): def reset_pcbnew_mock() -> Any:
"""Reset pcbnew mock before each test.""" """Reset pcbnew mock before each test."""
pcbnew_mock.reset_mock() pcbnew_mock.reset_mock()
pcbnew_mock.ExportSpecctraDSN.side_effect = None pcbnew_mock.ExportSpecctraDSN.side_effect = None
@@ -44,7 +45,7 @@ def reset_pcbnew_mock():
@pytest.fixture @pytest.fixture
def mock_board(): def mock_board() -> Any:
board = MagicMock() board = MagicMock()
board.GetFileName.return_value = "/tmp/test_project/test.kicad_pcb" board.GetFileName.return_value = "/tmp/test_project/test.kicad_pcb"
board.GetTracks.return_value = [] board.GetTracks.return_value = []
@@ -52,16 +53,16 @@ def mock_board():
@pytest.fixture @pytest.fixture
def cmds(mock_board): def cmds(mock_board: Any) -> Any:
return FreeroutingCommands(board=mock_board) return FreeroutingCommands(board=mock_board)
@pytest.fixture @pytest.fixture
def cmds_no_board(): def cmds_no_board() -> Any:
return FreeroutingCommands(board=None) return FreeroutingCommands(board=None)
def _patch_direct_java(): def _patch_direct_java() -> Any:
"""Patch to simulate Java 21+ available locally.""" """Patch to simulate Java 21+ available locally."""
return patch.object( return patch.object(
FreeroutingCommands, FreeroutingCommands,
@@ -70,7 +71,7 @@ def _patch_direct_java():
) )
def _patch_docker_mode(): def _patch_docker_mode() -> Any:
"""Patch to simulate Docker execution mode.""" """Patch to simulate Docker execution mode."""
return patch.object( return patch.object(
FreeroutingCommands, 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.""" """Patch to simulate no Java and no Docker."""
return patch.object( return patch.object(
FreeroutingCommands, FreeroutingCommands,
@@ -97,7 +98,7 @@ def _patch_no_runtime():
class TestCheckFreerouting: class TestCheckFreerouting:
def test_no_java_no_docker(self, cmds): def test_no_java_no_docker(self, cmds: Any) -> None:
with ( with (
patch("commands.freerouting._find_java", return_value=None), patch("commands.freerouting._find_java", return_value=None),
patch( patch(
@@ -112,7 +113,7 @@ class TestCheckFreerouting:
assert result["ready"] is False assert result["ready"] is False
assert result["execution_mode"] == "none" 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 = tmp_path / "freerouting.jar"
jar.touch() jar.touch()
with ( with (
@@ -135,7 +136,7 @@ class TestCheckFreerouting:
assert result["ready"] is True assert result["ready"] is True
assert result["execution_mode"] == "docker" 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 = tmp_path / "freerouting.jar"
jar.touch() jar.touch()
with ( with (
@@ -165,12 +166,12 @@ class TestCheckFreerouting:
class TestExportDsn: 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({}) result = cmds_no_board.export_dsn({})
assert result["success"] is False assert result["success"] is False
assert "No board" in result["message"] 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") board_path = str(tmp_path / "test.kicad_pcb")
dsn_path = str(tmp_path / "test.dsn") dsn_path = str(tmp_path / "test.dsn")
cmds.board.GetFileName.return_value = board_path cmds.board.GetFileName.return_value = board_path
@@ -182,7 +183,7 @@ class TestExportDsn:
assert result["success"] is True assert result["success"] is True
assert result["path"] == dsn_path 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") output = str(tmp_path / "custom.dsn")
pcbnew_mock.ExportSpecctraDSN.return_value = True pcbnew_mock.ExportSpecctraDSN.return_value = True
Path(output).write_text("(pcb test)") Path(output).write_text("(pcb test)")
@@ -191,7 +192,7 @@ class TestExportDsn:
assert result["success"] is True assert result["success"] is True
assert result["path"] == output 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") pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("DSN error")
result = cmds.export_dsn({}) result = cmds.export_dsn({})
assert result["success"] is False assert result["success"] is False
@@ -204,22 +205,22 @@ class TestExportDsn:
class TestImportSes: 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"}) result = cmds_no_board.import_ses({"sesPath": "/tmp/test.ses"})
assert result["success"] is False assert result["success"] is False
assert "No board" in result["message"] 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({}) result = cmds.import_ses({})
assert result["success"] is False assert result["success"] is False
assert "Missing sesPath" in result["message"] 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"}) result = cmds.import_ses({"sesPath": "/nonexistent/test.ses"})
assert result["success"] is False assert result["success"] is False
assert "not found" in result["message"] 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 = tmp_path / "test.ses"
ses_file.write_text("(session test)") ses_file.write_text("(session test)")
@@ -229,7 +230,7 @@ class TestImportSes:
result = cmds.import_ses({"sesPath": str(ses_file)}) result = cmds.import_ses({"sesPath": str(ses_file)})
assert result["success"] is True 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 = tmp_path / "test.ses"
ses_file.write_text("(session test)") ses_file.write_text("(session test)")
@@ -245,12 +246,12 @@ class TestImportSes:
class TestAutoroute: 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({}) result = cmds_no_board.autoroute({})
assert result["success"] is False assert result["success"] is False
assert "No board" in result["message"] 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 = tmp_path / "freerouting.jar"
jar.touch() jar.touch()
with _patch_no_runtime(): with _patch_no_runtime():
@@ -258,13 +259,13 @@ class TestAutoroute:
assert result["success"] is False assert result["success"] is False
assert "No suitable Java runtime" in result["message"] 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"}) result = cmds.autoroute({"freeroutingJar": "/nonexistent/freerouting.jar"})
assert result["success"] is False assert result["success"] is False
assert "JAR not found" in result["message"] assert "JAR not found" in result["message"]
@patch("commands.freerouting.subprocess.run") @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 = tmp_path / "freerouting.jar"
jar.touch() jar.touch()
@@ -276,7 +277,7 @@ class TestAutoroute:
assert "DSN export failed" in result["message"] assert "DSN export failed" in result["message"]
@patch("commands.freerouting.subprocess.run") @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 import subprocess
jar = tmp_path / "freerouting.jar" jar = tmp_path / "freerouting.jar"
@@ -300,7 +301,7 @@ class TestAutoroute:
assert "timed out" in result["message"] assert "timed out" in result["message"]
@patch("commands.freerouting.subprocess.run") @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 = tmp_path / "freerouting.jar"
jar.touch() jar.touch()
board_dir = tmp_path / "project" board_dir = tmp_path / "project"
@@ -336,7 +337,7 @@ class TestAutoroute:
assert "elapsed_seconds" in result assert "elapsed_seconds" in result
@patch("commands.freerouting.subprocess.run") @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 = tmp_path / "freerouting.jar"
jar.touch() jar.touch()
board_dir = tmp_path / "project" board_dir = tmp_path / "project"
@@ -375,7 +376,7 @@ class TestAutoroute:
assert "--rm" in call_args assert "--rm" in call_args
@patch("commands.freerouting.subprocess.run") @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 = tmp_path / "freerouting.jar"
jar.touch() jar.touch()
board_dir = tmp_path / "project" board_dir = tmp_path / "project"
@@ -404,14 +405,14 @@ class TestAutoroute:
class TestFindJava: class TestFindJava:
def test_finds_via_which(self): def test_finds_via_which(self) -> None:
with patch( with patch(
"commands.freerouting.shutil.which", "commands.freerouting.shutil.which",
return_value="/usr/bin/java", return_value="/usr/bin/java",
): ):
assert _find_java() == "/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 ( with (
patch( patch(
"commands.freerouting.shutil.which", "commands.freerouting.shutil.which",
@@ -423,21 +424,21 @@ class TestFindJava:
class TestFindDocker: class TestFindDocker:
def test_finds_docker(self): def test_finds_docker(self) -> None:
with patch( with patch(
"commands.freerouting.shutil.which", "commands.freerouting.shutil.which",
side_effect=lambda x: "/usr/bin/docker" if x == "docker" else None, side_effect=lambda x: "/usr/bin/docker" if x == "docker" else None,
): ):
assert _find_docker() == "/usr/bin/docker" assert _find_docker() == "/usr/bin/docker"
def test_finds_podman(self): def test_finds_podman(self) -> None:
with patch( with patch(
"commands.freerouting.shutil.which", "commands.freerouting.shutil.which",
side_effect=lambda x: "/usr/bin/podman" if x == "podman" else None, side_effect=lambda x: "/usr/bin/podman" if x == "podman" else None,
): ):
assert _find_docker() == "/usr/bin/podman" assert _find_docker() == "/usr/bin/podman"
def test_none_when_not_found(self): def test_none_when_not_found(self) -> None:
with patch( with patch(
"commands.freerouting.shutil.which", "commands.freerouting.shutil.which",
return_value=None, return_value=None,
@@ -446,7 +447,7 @@ class TestFindDocker:
class TestDockerAvailable: class TestDockerAvailable:
def test_docker_found(self): def test_docker_found(self) -> None:
with ( with (
patch( patch(
"commands.freerouting._find_docker", "commands.freerouting._find_docker",
@@ -457,14 +458,14 @@ class TestDockerAvailable:
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
assert _docker_available() is True assert _docker_available() is True
def test_docker_not_installed(self): def test_docker_not_installed(self) -> None:
with patch( with patch(
"commands.freerouting._find_docker", "commands.freerouting._find_docker",
return_value=None, return_value=None,
): ):
assert _docker_available() is False assert _docker_available() is False
def test_docker_not_running(self): def test_docker_not_running(self) -> None:
with ( with (
patch( patch(
"commands.freerouting._find_docker", "commands.freerouting._find_docker",
@@ -477,17 +478,17 @@ class TestDockerAvailable:
class TestJavaVersionOk: class TestJavaVersionOk:
def test_java_21(self): def test_java_21(self) -> None:
with patch("commands.freerouting.subprocess.run") as mock_run: with patch("commands.freerouting.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(stderr='openjdk version "21.0.1"', stdout="") mock_run.return_value = MagicMock(stderr='openjdk version "21.0.1"', stdout="")
assert _java_version_ok("/usr/bin/java") is True 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: with patch("commands.freerouting.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(stderr='openjdk version "17.0.18"', stdout="") mock_run.return_value = MagicMock(stderr='openjdk version "17.0.18"', stdout="")
assert _java_version_ok("/usr/bin/java") is False assert _java_version_ok("/usr/bin/java") is False
def test_java_error(self): def test_java_error(self) -> None:
with patch( with patch(
"commands.freerouting.subprocess.run", "commands.freerouting.subprocess.run",
side_effect=Exception("not found"), side_effect=Exception("not found"),

View File

@@ -9,6 +9,7 @@ import shutil
import sys import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -32,7 +33,7 @@ def _sym(name: str) -> Symbol:
return Symbol(name) return Symbol(name)
def _make_wire(x1, y1, x2, y2): def _make_wire(x1: Any, y1: Any, x2: Any, y2: Any) -> Any:
return [ return [
_sym("wire"), _sym("wire"),
[_sym("pts"), [_sym("xy"), x1, y1], [_sym("xy"), x2, y2]], [_sym("pts"), [_sym("xy"), x1, y1], [_sym("xy"), x2, y2]],
@@ -41,7 +42,7 @@ def _make_wire(x1, y1, x2, y2):
] ]
def _make_junction(x, y): def _make_junction(x: Any, y: Any) -> Any:
return [ return [
_sym("junction"), _sym("junction"),
[_sym("at"), x, y], [_sym("at"), x, y],
@@ -51,7 +52,9 @@ def _make_junction(x, y):
] ]
def _make_symbol(ref, x, y, rotation=0, lib_id="Device:R", mirror=None): 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.""" """Build a minimal placed-symbol s-expression."""
item = [ item = [
_sym("symbol"), _sym("symbol"),
@@ -66,7 +69,7 @@ def _make_symbol(ref, x, y, rotation=0, lib_id="Device:R", mirror=None):
return item return item
def _make_lib_symbol_r(): def _make_lib_symbol_r() -> Any:
"""Minimal Device:R lib_symbols entry — pins at (0, 3.81) and (0, -3.81).""" """Minimal Device:R lib_symbols entry — pins at (0, 3.81) and (0, -3.81)."""
return [ return [
_sym("symbol"), _sym("symbol"),
@@ -112,7 +115,7 @@ def _make_lib_symbol_r():
] ]
def _make_sch_data(extra_items=None): def _make_sch_data(extra_items: Any = None) -> Any:
"""Build a minimal sch_data list with lib_symbols and sheet_instances.""" """Build a minimal sch_data list with lib_symbols and sheet_instances."""
data = [ data = [
_sym("kicad_sch"), _sym("kicad_sch"),
@@ -133,20 +136,20 @@ def _make_sch_data(extra_items=None):
@pytest.mark.unit @pytest.mark.unit
class TestRotatePoint: class TestRotatePoint:
def test_zero_rotation(self): def test_zero_rotation(self) -> None:
assert _rotate(1.0, 2.0, 0) == (1.0, 2.0) assert _rotate(1.0, 2.0, 0) == (1.0, 2.0)
def test_90_degrees(self): def test_90_degrees(self) -> None:
rx, ry = _rotate(1.0, 0.0, 90) rx, ry = _rotate(1.0, 0.0, 90)
assert abs(rx - 0.0) < 1e-9 assert abs(rx - 0.0) < 1e-9
assert abs(ry - 1.0) < 1e-9 assert abs(ry - 1.0) < 1e-9
def test_180_degrees(self): def test_180_degrees(self) -> None:
rx, ry = _rotate(1.0, 0.0, 180) rx, ry = _rotate(1.0, 0.0, 180)
assert abs(rx - (-1.0)) < 1e-9 assert abs(rx - (-1.0)) < 1e-9
assert abs(ry - 0.0) < 1e-9 assert abs(ry - 0.0) < 1e-9
def test_270_degrees(self): def test_270_degrees(self) -> None:
rx, ry = _rotate(0.0, 1.0, 270) rx, ry = _rotate(0.0, 1.0, 270)
assert abs(rx - 1.0) < 1e-6 assert abs(rx - 1.0) < 1e-6
assert abs(ry - 0.0) < 1e-6 assert abs(ry - 0.0) < 1e-6
@@ -159,11 +162,11 @@ class TestRotatePoint:
@pytest.mark.unit @pytest.mark.unit
class TestFindSymbol: class TestFindSymbol:
def test_returns_none_for_missing_reference(self): def test_returns_none_for_missing_reference(self) -> None:
sch = _make_sch_data([_make_symbol("R1", 10, 20)]) sch = _make_sch_data([_make_symbol("R1", 10, 20)])
assert WireDragger.find_symbol(sch, "R99") is None assert WireDragger.find_symbol(sch, "R99") is None
def test_returns_item_and_position(self): def test_returns_item_and_position(self) -> None:
sch = _make_sch_data([_make_symbol("R1", 10.5, 20.5, rotation=90)]) sch = _make_sch_data([_make_symbol("R1", 10.5, 20.5, rotation=90)])
result = WireDragger.find_symbol(sch, "R1") result = WireDragger.find_symbol(sch, "R1")
assert result is not None assert result is not None
@@ -175,14 +178,14 @@ class TestFindSymbol:
assert mirror_x is False assert mirror_x is False
assert mirror_y is False assert mirror_y is False
def test_detects_mirror_x(self): def test_detects_mirror_x(self) -> None:
sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="x")]) sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="x")])
result = WireDragger.find_symbol(sch, "R1") result = WireDragger.find_symbol(sch, "R1")
assert result is not None assert result is not None
assert result[5] is True # mirror_x assert result[5] is True # mirror_x
assert result[6] is False # mirror_y assert result[6] is False # mirror_y
def test_detects_mirror_y(self): def test_detects_mirror_y(self) -> None:
sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="y")]) sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="y")])
result = WireDragger.find_symbol(sch, "R1") result = WireDragger.find_symbol(sch, "R1")
assert result is not None assert result is not None
@@ -197,7 +200,7 @@ class TestFindSymbol:
@pytest.mark.unit @pytest.mark.unit
class TestComputePinPositions: class TestComputePinPositions:
def test_resistor_at_origin_no_rotation(self): 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).""" """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)]) sch = _make_sch_data([_make_symbol("R1", 0, 0)])
positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20) positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20)
@@ -216,7 +219,7 @@ class TestComputePinPositions:
assert abs(new2[0] - 10) < 1e-4 assert abs(new2[0] - 10) < 1e-4
assert abs(new2[1] - 16.19) < 1e-4 assert abs(new2[1] - 16.19) < 1e-4
def test_resistor_rotated_90(self): 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).""" """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)]) sch = _make_sch_data([_make_symbol("R1", 100, 100, rotation=90)])
positions = WireDragger.compute_pin_positions(sch, "R1", 100, 100) positions = WireDragger.compute_pin_positions(sch, "R1", 100, 100)
@@ -229,12 +232,12 @@ class TestComputePinPositions:
assert abs(old1[0] - 96.19) < 1e-3 assert abs(old1[0] - 96.19) < 1e-3
assert abs(old1[1] - 100) < 1e-3 assert abs(old1[1] - 100) < 1e-3
def test_returns_empty_for_missing_component(self): def test_returns_empty_for_missing_component(self) -> None:
sch = _make_sch_data() sch = _make_sch_data()
result = WireDragger.compute_pin_positions(sch, "MISSING", 0, 0) result = WireDragger.compute_pin_positions(sch, "MISSING", 0, 0)
assert result == {} assert result == {}
def test_delta_is_consistent(self): def test_delta_is_consistent(self) -> None:
"""new_xy - old_xy should equal (new_x - old_x, new_y - old_y) for any rotation.""" """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)]) sch = _make_sch_data([_make_symbol("R1", 50, 50, rotation=45)])
positions = WireDragger.compute_pin_positions(sch, "R1", 60, 70) positions = WireDragger.compute_pin_positions(sch, "R1", 60, 70)
@@ -252,13 +255,13 @@ class TestComputePinPositions:
@pytest.mark.unit @pytest.mark.unit
class TestDragWires: class TestDragWires:
def test_no_wires_returns_zero_counts(self): def test_no_wires_returns_zero_counts(self) -> None:
sch = _make_sch_data() sch = _make_sch_data()
result = WireDragger.drag_wires(sch, {(0.0, 0.0): (10.0, 10.0)}) result = WireDragger.drag_wires(sch, {(0.0, 0.0): (10.0, 10.0)})
assert result["endpoints_moved"] == 0 assert result["endpoints_moved"] == 0
assert result["wires_removed"] == 0 assert result["wires_removed"] == 0
def test_wire_start_endpoint_moved(self): def test_wire_start_endpoint_moved(self) -> None:
wire = _make_wire(0, 3.81, 0, 10) wire = _make_wire(0, 3.81, 0, 10)
sch = _make_sch_data([wire]) sch = _make_sch_data([wire])
result = WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) result = WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)})
@@ -271,7 +274,7 @@ class TestDragWires:
assert abs(xy1[1] - 10.0) < EPS assert abs(xy1[1] - 10.0) < EPS
assert abs(xy1[2] - 23.81) < EPS assert abs(xy1[2] - 23.81) < EPS
def test_wire_end_endpoint_moved(self): def test_wire_end_endpoint_moved(self) -> None:
wire = _make_wire(0, 10, 0, -3.81) wire = _make_wire(0, 10, 0, -3.81)
sch = _make_sch_data([wire]) sch = _make_sch_data([wire])
result = WireDragger.drag_wires(sch, {(0.0, -3.81): (10.0, 16.19)}) result = WireDragger.drag_wires(sch, {(0.0, -3.81): (10.0, 16.19)})
@@ -282,7 +285,7 @@ class TestDragWires:
assert abs(xy2[1] - 10.0) < EPS assert abs(xy2[1] - 10.0) < EPS
assert abs(xy2[2] - 16.19) < EPS assert abs(xy2[2] - 16.19) < EPS
def test_zero_length_wire_removed(self): def test_zero_length_wire_removed(self) -> None:
"""When both endpoints of a wire are moved to the same point, wire is deleted.""" """When both endpoints of a wire are moved to the same point, wire is deleted."""
wire = _make_wire(0, 3.81, 0, -3.81) wire = _make_wire(0, 3.81, 0, -3.81)
sch = _make_sch_data([wire]) sch = _make_sch_data([wire])
@@ -298,7 +301,7 @@ class TestDragWires:
wires_remaining = [i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")] wires_remaining = [i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")]
assert len(wires_remaining) == 0 assert len(wires_remaining) == 0
def test_unrelated_wire_not_touched(self): def test_unrelated_wire_not_touched(self) -> None:
"""A wire whose endpoints don't match any old pin is not changed.""" """A wire whose endpoints don't match any old pin is not changed."""
wire = _make_wire(50, 50, 60, 50) wire = _make_wire(50, 50, 60, 50)
sch = _make_sch_data([wire]) sch = _make_sch_data([wire])
@@ -311,7 +314,7 @@ class TestDragWires:
assert abs(xy1[1] - 50.0) < EPS assert abs(xy1[1] - 50.0) < EPS
assert abs(xy1[2] - 50.0) < EPS assert abs(xy1[2] - 50.0) < EPS
def test_both_endpoints_on_moved_component(self): def test_both_endpoints_on_moved_component(self) -> None:
"""Wire connecting two pins of same component — both endpoints shift together.""" """Wire connecting two pins of same component — both endpoints shift together."""
wire = _make_wire(0, 3.81, 0, -3.81) wire = _make_wire(0, 3.81, 0, -3.81)
sch = _make_sch_data([wire]) sch = _make_sch_data([wire])
@@ -325,7 +328,7 @@ class TestDragWires:
assert result["endpoints_moved"] == 2 assert result["endpoints_moved"] == 2
assert result["wires_removed"] == 0 assert result["wires_removed"] == 0
def test_junction_moved_with_endpoint(self): def test_junction_moved_with_endpoint(self) -> None:
junction = _make_junction(0, 3.81) junction = _make_junction(0, 3.81)
sch = _make_sch_data([junction]) sch = _make_sch_data([junction])
WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)})
@@ -336,7 +339,7 @@ class TestDragWires:
assert abs(at_sub[1] - 10.0) < EPS assert abs(at_sub[1] - 10.0) < EPS
assert abs(at_sub[2] - 23.81) < EPS assert abs(at_sub[2] - 23.81) < EPS
def test_junction_at_unrelated_position_not_touched(self): def test_junction_at_unrelated_position_not_touched(self) -> None:
junction = _make_junction(99, 99) junction = _make_junction(99, 99)
sch = _make_sch_data([junction]) sch = _make_sch_data([junction])
WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)}) WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)})
@@ -355,7 +358,7 @@ class TestDragWires:
@pytest.mark.unit @pytest.mark.unit
class TestUpdateSymbolPosition: class TestUpdateSymbolPosition:
def test_updates_position(self): def test_updates_position(self) -> None:
sch = _make_sch_data([_make_symbol("R1", 10, 20)]) sch = _make_sch_data([_make_symbol("R1", 10, 20)])
result = WireDragger.update_symbol_position(sch, "R1", 30, 40) result = WireDragger.update_symbol_position(sch, "R1", 30, 40)
assert result is True assert result is True
@@ -363,17 +366,17 @@ class TestUpdateSymbolPosition:
assert abs(found[1] - 30) < EPS assert abs(found[1] - 30) < EPS
assert abs(found[2] - 40) < EPS assert abs(found[2] - 40) < EPS
def test_returns_false_for_missing(self): def test_returns_false_for_missing(self) -> None:
sch = _make_sch_data() sch = _make_sch_data()
assert WireDragger.update_symbol_position(sch, "MISSING", 0, 0) is False assert WireDragger.update_symbol_position(sch, "MISSING", 0, 0) is False
def test_preserves_rotation(self): def test_preserves_rotation(self) -> None:
sch = _make_sch_data([_make_symbol("R1", 10, 20, rotation=90)]) sch = _make_sch_data([_make_symbol("R1", 10, 20, rotation=90)])
WireDragger.update_symbol_position(sch, "R1", 30, 40) WireDragger.update_symbol_position(sch, "R1", 30, 40)
found = WireDragger.find_symbol(sch, "R1") found = WireDragger.find_symbol(sch, "R1")
assert abs(found[3] - 90) < EPS # rotation preserved assert abs(found[3] - 90) < EPS # rotation preserved
def test_property_labels_follow_symbol_move(self): def test_property_labels_follow_symbol_move(self) -> None:
"""Property (at ...) positions must shift by the same delta as the symbol.""" """Property (at ...) positions must shift by the same delta as the symbol."""
sym = _make_symbol("R1", 100, 80) sym = _make_symbol("R1", 100, 80)
sch = _make_sch_data([sym]) sch = _make_sch_data([sym])
@@ -421,7 +424,7 @@ class TestUpdateSymbolPosition:
class TestMoveWithWirePreservation: class TestMoveWithWirePreservation:
"""Integration tests using a real .kicad_sch file.""" """Integration tests using a real .kicad_sch file."""
def _make_schematic(self, extra_sexp=""): def _make_schematic(self, extra_sexp: Any = "") -> Any:
"""Copy empty.kicad_sch to a temp file and optionally append content.""" """Copy empty.kicad_sch to a temp file and optionally append content."""
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
shutil.copy(TEMPLATE_PATH, tmp) shutil.copy(TEMPLATE_PATH, tmp)
@@ -462,7 +465,7 @@ class TestMoveWithWirePreservation:
path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8") path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8")
return path return path
def _add_wire(self, path: Path, x1, y1, x2, y2) -> Path: def _add_wire(self, path: Path, x1: float, y1: float, x2: float, y2: float) -> Path:
"""Append a wire to the schematic file.""" """Append a wire to the schematic file."""
import uuid import uuid
@@ -476,7 +479,7 @@ class TestMoveWithWirePreservation:
path.write_text(content[:idx] + "\n" + wire_sexp + "\n)", encoding="utf-8") path.write_text(content[:idx] + "\n" + wire_sexp + "\n)", encoding="utf-8")
return path return path
def _parse_wires(self, path: Path): def _parse_wires(self, path: Path) -> Any:
"""Return list of ((x1,y1),(x2,y2)) for every wire in the file.""" """Return list of ((x1,y1),(x2,y2)) for every wire in the file."""
content = path.read_text(encoding="utf-8") content = path.read_text(encoding="utf-8")
data = sexpdata.loads(content) data = sexpdata.loads(content)
@@ -502,7 +505,7 @@ class TestMoveWithWirePreservation:
) )
return wires return wires
def _get_symbol_pos(self, path: Path, ref: str): def _get_symbol_pos(self, path: Path, ref: str) -> Any:
content = path.read_text(encoding="utf-8") content = path.read_text(encoding="utf-8")
data = sexpdata.loads(content) data = sexpdata.loads(content)
found = WireDragger.find_symbol(data, ref) found = WireDragger.find_symbol(data, ref)
@@ -510,7 +513,7 @@ class TestMoveWithWirePreservation:
return None return None
return found[1], found[2] return found[1], found[2]
def test_symbol_position_updated(self): def test_symbol_position_updated(self) -> None:
sch = self._make_schematic() sch = self._make_schematic()
self._add_resistor(sch, "R1", 100, 100) self._add_resistor(sch, "R1", 100, 100)
# Call handler directly # Call handler directly
@@ -531,7 +534,7 @@ class TestMoveWithWirePreservation:
assert abs(pos[0] - 120) < EPS assert abs(pos[0] - 120) < EPS
assert abs(pos[1] - 130) < EPS assert abs(pos[1] - 130) < EPS
def test_connected_wire_endpoint_follows_pin(self): def test_connected_wire_endpoint_follows_pin(self) -> None:
"""Wire endpoint at pin 1 of R1 should move with the component.""" """Wire endpoint at pin 1 of R1 should move with the component."""
sch = self._make_schematic() sch = self._make_schematic()
# R1 at (100, 100) — pin 1 at (100, 103.81) # R1 at (100, 100) — pin 1 at (100, 103.81)
@@ -563,7 +566,7 @@ class TestMoveWithWirePreservation:
abs(ep[0] - new_pin1[0]) < 0.01 and abs(ep[1] - new_pin1[1]) < 0.01 for ep in endpoints 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}" ), f"Expected pin endpoint near {new_pin1}, got {endpoints}"
def test_unrelated_wire_unchanged(self): def test_unrelated_wire_unchanged(self) -> None:
"""A wire not connected to R1 must not be modified.""" """A wire not connected to R1 must not be modified."""
sch = self._make_schematic() sch = self._make_schematic()
self._add_resistor(sch, "R1", 100, 100) self._add_resistor(sch, "R1", 100, 100)
@@ -586,7 +589,7 @@ class TestMoveWithWirePreservation:
unrelated = [(s, e) for s, e in wires if abs(s[0] - 50) < 0.01 and abs(s[1] - 50) < 0.01] 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 assert len(unrelated) == 1
def test_no_zero_length_wires_after_move(self): def test_no_zero_length_wires_after_move(self) -> None:
"""No zero-length wires should appear in the file after a move.""" """No zero-length wires should appear in the file after a move."""
sch = self._make_schematic() sch = self._make_schematic()
self._add_resistor(sch, "R1", 100, 100) self._add_resistor(sch, "R1", 100, 100)
@@ -612,7 +615,7 @@ class TestMoveWithWirePreservation:
abs(start[0] - end[0]) < EPS and abs(start[1] - end[1]) < EPS abs(start[0] - end[0]) < EPS and abs(start[1] - end[1]) < EPS
), f"Zero-length wire found at {start}" ), f"Zero-length wire found at {start}"
def test_preserve_wires_false_skips_wire_update(self): def test_preserve_wires_false_skips_wire_update(self) -> None:
"""preserveWires=False should move the symbol but leave wires alone.""" """preserveWires=False should move the symbol but leave wires alone."""
sch = self._make_schematic() sch = self._make_schematic()
self._add_resistor(sch, "R1", 100, 100) self._add_resistor(sch, "R1", 100, 100)
@@ -643,7 +646,7 @@ class TestMoveWithWirePreservation:
abs(ep[0] - old_pin1[0]) < 0.01 and abs(ep[1] - old_pin1[1]) < 0.01 for ep in endpoints 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}" ), f"Wire should still be at {old_pin1}, got {endpoints}"
def test_missing_component_returns_error(self): def test_missing_component_returns_error(self) -> None:
sch = self._make_schematic() sch = self._make_schematic()
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from kicad_interface import KiCADInterface from kicad_interface import KiCADInterface
@@ -670,7 +673,7 @@ class TestMoveWithWirePreservation:
class TestSynthesizeTouchingPinWires: class TestSynthesizeTouchingPinWires:
"""Unit tests for WireDragger.synthesize_touching_pin_wires.""" """Unit tests for WireDragger.synthesize_touching_pin_wires."""
def _make_two_resistors(self, r1_x, r1_y, r2_x, r2_y): 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.""" """Build sch_data with R1 and R2, each Device:R."""
return _make_sch_data( return _make_sch_data(
[ [
@@ -679,14 +682,14 @@ class TestSynthesizeTouchingPinWires:
] ]
) )
def test_no_stationary_symbols_returns_zero(self): def test_no_stationary_symbols_returns_zero(self) -> None:
"""With only the moved component in sch_data, nothing is synthesized.""" """With only the moved component in sch_data, nothing is synthesized."""
sch = _make_sch_data([_make_symbol("R1", 0, 0)]) sch = _make_sch_data([_make_symbol("R1", 0, 0)])
pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20) pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20)
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
assert count == 0 assert count == 0
def test_touching_pin_gap_generates_wire(self): def test_touching_pin_gap_generates_wire(self) -> None:
""" """
R1 at (0, 0) pin2 at (0, -3.81). R1 at (0, 0) pin2 at (0, -3.81).
R2 at (0, -7.62) pin1 at (0, -3.81). ← pins touch R2 at (0, -7.62) pin1 at (0, -3.81). ← pins touch
@@ -728,7 +731,7 @@ class TestSynthesizeTouchingPinWires:
-3.81, -3.81,
) in endpoints, f"Expected (10, -3.81) in wire endpoints, got {endpoints}" ) in endpoints, f"Expected (10, -3.81) in wire endpoints, got {endpoints}"
def test_no_wire_when_pin_didnt_move(self): 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), If old_xy == new_xy for a touching pin (component moved but this pin stayed put),
no wire should be synthesized. no wire should be synthesized.
@@ -740,7 +743,7 @@ class TestSynthesizeTouchingPinWires:
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
assert count == 0 assert count == 0
def test_no_wire_when_rejoins_other_stationary_pin(self): def test_no_wire_when_rejoins_other_stationary_pin(self) -> None:
""" """
If the moved pin's new position coincides with another stationary pin, If the moved pin's new position coincides with another stationary pin,
no wire should be synthesized (they touch again). no wire should be synthesized (they touch again).
@@ -759,12 +762,12 @@ class TestSynthesizeTouchingPinWires:
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions) count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
assert count == 0, f"Expected 0 synthesized wires (rejoined), got {count}" assert count == 0, f"Expected 0 synthesized wires (rejoined), got {count}"
def test_empty_pin_positions_returns_zero(self): def test_empty_pin_positions_returns_zero(self) -> None:
sch = _make_sch_data([_make_symbol("R1", 0, 0)]) sch = _make_sch_data([_make_symbol("R1", 0, 0)])
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", {}) count = WireDragger.synthesize_touching_pin_wires(sch, "R1", {})
assert count == 0 assert count == 0
def test_non_touching_pins_not_affected(self): def test_non_touching_pins_not_affected(self) -> None:
""" """
When R1 and R2 are NOT touching (different positions), no wire is synthesized. When R1 and R2 are NOT touching (different positions), no wire is synthesized.
""" """
@@ -784,7 +787,7 @@ class TestSynthesizeTouchingPinWires:
class TestOldToNewCollision: class TestOldToNewCollision:
"""Verify that coincident pins do not silently overwrite each other in old_to_new.""" """Verify that coincident pins do not silently overwrite each other in old_to_new."""
def test_handler_logs_warning_on_collision(self, caplog): def test_handler_logs_warning_on_collision(self, caplog: Any) -> None:
""" """
When two pins share the same old position, a warning should be logged 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). and the *first* mapping should be kept (not overwritten by the second).
@@ -827,7 +830,7 @@ class TestOldToNewCollision:
class TestTouchingPinIntegration: class TestTouchingPinIntegration:
"""Integration tests for pin-touching connection wire synthesis.""" """Integration tests for pin-touching connection wire synthesis."""
def _make_schematic(self, extra_sexp=""): def _make_schematic(self, extra_sexp: Any = "") -> Any:
"""Copy empty.kicad_sch to a temp file.""" """Copy empty.kicad_sch to a temp file."""
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
shutil.copy(TEMPLATE_PATH, tmp) shutil.copy(TEMPLATE_PATH, tmp)
@@ -867,7 +870,7 @@ class TestTouchingPinIntegration:
path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8") path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8")
return path return path
def _parse_wires(self, path: Path): def _parse_wires(self, path: Path) -> Any:
content = path.read_text(encoding="utf-8") content = path.read_text(encoding="utf-8")
data = sexpdata.loads(content) data = sexpdata.loads(content)
wires = [] wires = []
@@ -892,7 +895,7 @@ class TestTouchingPinIntegration:
) )
return wires return wires
def test_touching_pin_wire_created_on_move(self): 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 at (100, 100) and R2 at (100, 92.38) share a touching pin:
R1 pin2 = (100, 96.19), R2 pin1 = (100, 96.19). R1 pin2 = (100, 96.19), R2 pin1 = (100, 96.19).
@@ -947,7 +950,7 @@ class TestTouchingPinIntegration:
len(bridging) >= 1 len(bridging) >= 1
), f"Expected a bridging wire from {old_pin2} to {new_pin2}, got wires: {wires}" ), f"Expected a bridging wire from {old_pin2} to {new_pin2}, got wires: {wires}"
def test_no_wire_synthesized_when_no_touching_pins(self): def test_no_wire_synthesized_when_no_touching_pins(self) -> None:
""" """
Two resistors with no pin overlap should not generate any synthesized wires. Two resistors with no pin overlap should not generate any synthesized wires.
""" """
@@ -970,7 +973,7 @@ class TestTouchingPinIntegration:
assert result["success"], result.get("message") assert result["success"], result.get("message")
assert result.get("wiresSynthesized", 0) == 0 assert result.get("wiresSynthesized", 0) == 0
def test_existing_wires_still_dragged_with_touching_pins(self): def test_existing_wires_still_dragged_with_touching_pins(self) -> None:
""" """
When R1 has both an explicit wire AND a touching-pin connection, When R1 has both an explicit wire AND a touching-pin connection,
both should be handled: the wire dragged and the touching-pin bridged. both should be handled: the wire dragged and the touching-pin bridged.

View File

@@ -130,42 +130,42 @@ def _make_led_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str:
class TestGeometryHelpers: class TestGeometryHelpers:
"""Test low-level geometry utilities.""" """Test low-level geometry utilities."""
def test_point_in_rect_inside(self): def test_point_in_rect_inside(self) -> None:
assert _point_in_rect(5, 5, 0, 0, 10, 10) is True assert _point_in_rect(5, 5, 0, 0, 10, 10) is True
def test_point_in_rect_outside(self): def test_point_in_rect_outside(self) -> None:
assert _point_in_rect(15, 5, 0, 0, 10, 10) is False assert _point_in_rect(15, 5, 0, 0, 10, 10) is False
def test_point_in_rect_boundary(self): def test_point_in_rect_boundary(self) -> None:
assert _point_in_rect(0, 0, 0, 0, 10, 10) is True assert _point_in_rect(0, 0, 0, 0, 10, 10) is True
def test_distance_zero(self): def test_distance_zero(self) -> None:
assert _distance((0, 0), (0, 0)) == 0 assert _distance((0, 0), (0, 0)) == 0
def test_distance_unit(self): def test_distance_unit(self) -> None:
assert abs(_distance((0, 0), (3, 4)) - 5.0) < 1e-9 assert abs(_distance((0, 0), (3, 4)) - 5.0) < 1e-9
def test_aabb_intersection_crossing(self): def test_aabb_intersection_crossing(self) -> None:
# Line from (0,5) to (10,5) should intersect box (2,2)-(8,8) # 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 assert _line_segment_intersects_aabb(0, 5, 10, 5, 2, 2, 8, 8) is True
def test_aabb_intersection_miss(self): def test_aabb_intersection_miss(self) -> None:
# Line from (0,0) to (10,0) should miss box (2,2)-(8,8) # 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 assert _line_segment_intersects_aabb(0, 0, 10, 0, 2, 2, 8, 8) is False
def test_aabb_intersection_inside(self): def test_aabb_intersection_inside(self) -> None:
# Line entirely inside the box # Line entirely inside the box
assert _line_segment_intersects_aabb(3, 3, 7, 7, 2, 2, 8, 8) is True assert _line_segment_intersects_aabb(3, 3, 7, 7, 2, 2, 8, 8) is True
def test_aabb_intersection_diagonal(self): def test_aabb_intersection_diagonal(self) -> None:
# Diagonal line crossing through box # Diagonal line crossing through box
assert _line_segment_intersects_aabb(0, 0, 10, 10, 2, 2, 8, 8) is True assert _line_segment_intersects_aabb(0, 0, 10, 10, 2, 2, 8, 8) is True
def test_aabb_intersection_parallel_outside(self): def test_aabb_intersection_parallel_outside(self) -> None:
# Horizontal line above the box # Horizontal line above the box
assert _line_segment_intersects_aabb(0, 9, 10, 9, 2, 2, 8, 8) is False assert _line_segment_intersects_aabb(0, 9, 10, 9, 2, 2, 8, 8) is False
def test_aabb_intersection_touching_edge(self): def test_aabb_intersection_touching_edge(self) -> None:
# Line ending exactly at box edge # Line ending exactly at box edge
assert _line_segment_intersects_aabb(0, 2, 2, 2, 2, 2, 8, 8) is True assert _line_segment_intersects_aabb(0, 2, 2, 2, 2, 2, 8, 8) is True
@@ -178,7 +178,7 @@ class TestGeometryHelpers:
class TestSexpParsers: class TestSexpParsers:
"""Test S-expression parsing functions with synthetic data.""" """Test S-expression parsing functions with synthetic data."""
def test_parse_wires_basic(self): def test_parse_wires_basic(self) -> None:
sexp = sexpdata.loads("""(kicad_sch sexp = sexpdata.loads("""(kicad_sch
(wire (pts (xy 10 20) (xy 30 40)) (wire (pts (xy 10 20) (xy 30 40))
(stroke (width 0) (type default)) (stroke (width 0) (type default))
@@ -189,11 +189,11 @@ class TestSexpParsers:
assert wires[0]["start"] == (10.0, 20.0) assert wires[0]["start"] == (10.0, 20.0)
assert wires[0]["end"] == (30.0, 40.0) assert wires[0]["end"] == (30.0, 40.0)
def test_parse_wires_empty(self): def test_parse_wires_empty(self) -> None:
sexp = sexpdata.loads("(kicad_sch)") sexp = sexpdata.loads("(kicad_sch)")
assert _parse_wires(sexp) == [] assert _parse_wires(sexp) == []
def test_parse_labels_both_types(self): def test_parse_labels_both_types(self) -> None:
sexp = sexpdata.loads("""(kicad_sch sexp = sexpdata.loads("""(kicad_sch
(label "VCC" (at 10 20 0)) (label "VCC" (at 10 20 0))
(global_label "GND" (at 30 40 0)) (global_label "GND" (at 30 40 0))
@@ -205,7 +205,7 @@ class TestSexpParsers:
assert labels[1]["name"] == "GND" assert labels[1]["name"] == "GND"
assert labels[1]["type"] == "global_label" assert labels[1]["type"] == "global_label"
def test_parse_symbols(self): def test_parse_symbols(self) -> None:
sexp = sexpdata.loads("""(kicad_sch sexp = sexpdata.loads("""(kicad_sch
(symbol (lib_id "Device:R") (at 100 100 0) (symbol (lib_id "Device:R") (at 100 100 0)
(property "Reference" "R1" (at 0 0 0))) (property "Reference" "R1" (at 0 0 0)))
@@ -228,20 +228,20 @@ class TestSexpParsers:
class TestAABBOverlap: class TestAABBOverlap:
"""Test AABB overlap helper.""" """Test AABB overlap helper."""
def test_overlapping_boxes(self): def test_overlapping_boxes(self) -> None:
assert _aabb_overlap((0, 0, 10, 10), (5, 5, 15, 15)) is True assert _aabb_overlap((0, 0, 10, 10), (5, 5, 15, 15)) is True
def test_non_overlapping_boxes(self): def test_non_overlapping_boxes(self) -> None:
assert _aabb_overlap((0, 0, 10, 10), (20, 20, 30, 30)) is False assert _aabb_overlap((0, 0, 10, 10), (20, 20, 30, 30)) is False
def test_touching_boxes_no_overlap(self): def test_touching_boxes_no_overlap(self) -> None:
# Touching edges are not overlapping (strict inequality) # Touching edges are not overlapping (strict inequality)
assert _aabb_overlap((0, 0, 10, 10), (10, 0, 20, 10)) is False assert _aabb_overlap((0, 0, 10, 10), (10, 0, 20, 10)) is False
def test_contained_box(self): def test_contained_box(self) -> None:
assert _aabb_overlap((0, 0, 20, 20), (5, 5, 15, 15)) is True assert _aabb_overlap((0, 0, 20, 20), (5, 5, 15, 15)) is True
def test_overlap_one_axis_only(self): def test_overlap_one_axis_only(self) -> None:
# Overlap in X but not Y # Overlap in X but not Y
assert _aabb_overlap((0, 0, 10, 10), (5, 15, 15, 25)) is False assert _aabb_overlap((0, 0, 10, 10), (5, 15, 15, 25)) is False
@@ -249,12 +249,12 @@ class TestAABBOverlap:
class TestFindOverlappingElements: class TestFindOverlappingElements:
"""Test overlapping detection logic.""" """Test overlapping detection logic."""
def test_no_overlaps_in_empty_schematic(self): def test_no_overlaps_in_empty_schematic(self) -> None:
tmp = _make_temp_schematic() tmp = _make_temp_schematic()
result = find_overlapping_elements(tmp, tolerance=0.5) result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] == 0 assert result["totalOverlaps"] == 0
def test_overlapping_symbols_detected(self): def test_overlapping_symbols_detected(self) -> None:
# Two resistors at nearly the same position — bboxes fully overlap # Two resistors at nearly the same position — bboxes fully overlap
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100.1, 100) extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100.1, 100)
tmp = _make_temp_schematic(extra) tmp = _make_temp_schematic(extra)
@@ -262,13 +262,13 @@ class TestFindOverlappingElements:
assert result["totalOverlaps"] >= 1 assert result["totalOverlaps"] >= 1
assert len(result["overlappingSymbols"]) >= 1 assert len(result["overlappingSymbols"]) >= 1
def test_well_separated_symbols_not_flagged(self): def test_well_separated_symbols_not_flagged(self) -> None:
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 200, 200) extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 200, 200)
tmp = _make_temp_schematic(extra) tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5) result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] == 0 assert result["totalOverlaps"] == 0
def test_collinear_wire_overlap(self): def test_collinear_wire_overlap(self) -> None:
extra = """ extra = """
(wire (pts (xy 10 50) (xy 30 50)) (wire (pts (xy 10 50) (xy 30 50))
(stroke (width 0) (type default)) (stroke (width 0) (type default))
@@ -281,7 +281,7 @@ class TestFindOverlappingElements:
result = find_overlapping_elements(tmp, tolerance=0.5) result = find_overlapping_elements(tmp, tolerance=0.5)
assert len(result["overlappingWires"]) >= 1 assert len(result["overlappingWires"]) >= 1
def test_overlapping_bodies_different_centers(self): def test_overlapping_bodies_different_centers(self) -> None:
"""Two resistors whose bodies overlap even though centers are ~5mm apart. """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 Device:R pins are at y ±3.81 relative to center, so the body spans
@@ -299,7 +299,7 @@ class TestFindOverlappingElements:
) )
assert len(result["overlappingSymbols"]) >= 1 assert len(result["overlappingSymbols"]) >= 1
def test_adjacent_resistors_no_overlap(self): def test_adjacent_resistors_no_overlap(self) -> None:
"""Two vertical resistors side by side should not overlap. """Two vertical resistors side by side should not overlap.
R pins at y ±3.81, but different X positions far enough apart. R pins at y ±3.81, but different X positions far enough apart.
@@ -309,7 +309,7 @@ class TestFindOverlappingElements:
result = find_overlapping_elements(tmp, tolerance=0.5) result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] == 0 assert result["totalOverlaps"] == 0
def test_resistor_and_led_overlapping_bodies(self): def test_resistor_and_led_overlapping_bodies(self) -> None:
"""A resistor and an LED placed close enough that bodies overlap. """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 LED pins at x ±3.81, R pins at y ±3.81. Place LED at same position
@@ -324,7 +324,7 @@ class TestFindOverlappingElements:
class TestGetElementsInRegion: class TestGetElementsInRegion:
"""Test region query logic.""" """Test region query logic."""
def test_elements_inside_region_found(self): def test_elements_inside_region_found(self) -> None:
extra = """ extra = """
(symbol (lib_id "Device:R") (at 50 50 0) (symbol (lib_id "Device:R") (at 50 50 0)
(property "Reference" "R1" (at 0 0 0)) (property "Reference" "R1" (at 0 0 0))
@@ -340,7 +340,7 @@ class TestGetElementsInRegion:
assert result["counts"]["wires"] >= 1 assert result["counts"]["wires"] >= 1
assert result["counts"]["labels"] >= 1 assert result["counts"]["labels"] >= 1
def test_elements_outside_region_excluded(self): def test_elements_outside_region_excluded(self) -> None:
extra = """ extra = """
(symbol (lib_id "Device:R") (at 200 200 0) (symbol (lib_id "Device:R") (at 200 200 0)
(property "Reference" "R1" (at 0 0 0)) (property "Reference" "R1" (at 0 0 0))
@@ -354,7 +354,7 @@ class TestGetElementsInRegion:
class TestComputeSymbolBbox: class TestComputeSymbolBbox:
"""Test bounding box computation.""" """Test bounding box computation."""
def test_returns_none_for_unknown_symbol(self): def test_returns_none_for_unknown_symbol(self) -> None:
tmp = _make_temp_schematic() tmp = _make_temp_schematic()
from commands.pin_locator import PinLocator from commands.pin_locator import PinLocator
@@ -372,7 +372,7 @@ class TestComputeSymbolBbox:
class TestIntegrationFindWiresCrossingSymbols: class TestIntegrationFindWiresCrossingSymbols:
"""Integration test for wire crossing symbol detection.""" """Integration test for wire crossing symbol detection."""
def test_wire_not_touching_pins_is_collision(self): def test_wire_not_touching_pins_is_collision(self) -> None:
"""A wire passing through a component bbox without pin contact → collision.""" """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) # 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 # Vertical wire from (100, 95) to (100, 105) crosses through the body
@@ -387,7 +387,7 @@ class TestIntegrationFindWiresCrossingSymbols:
d1_collisions = [c for c in result if c["component"]["reference"] == "D1"] d1_collisions = [c for c in result if c["component"]["reference"] == "D1"]
assert len(d1_collisions) >= 1 assert len(d1_collisions) >= 1
def test_unannotated_duplicates_not_over_reported(self): def test_unannotated_duplicates_not_over_reported(self) -> None:
""" """
Regression: two components with the same unannotated reference ("R?") at Regression: two components with the same unannotated reference ("R?") at
different positions should each produce independent bounding boxes. different positions should each produce independent bounding boxes.
@@ -417,7 +417,7 @@ class TestIntegrationFindWiresCrossingSymbols:
"likely caused by reference-lookup always returning the first 'R?'" "likely caused by reference-lookup always returning the first 'R?'"
) )
def test_wire_starting_at_pin_passing_through_body(self): def test_wire_starting_at_pin_passing_through_body(self) -> None:
"""A wire that starts at a pin but continues through the component body """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 must be flagged — this is the core bug where the old suppression logic
treated any wire touching a pin as a valid connection.""" treated any wire touching a pin as a valid connection."""
@@ -435,7 +435,7 @@ class TestIntegrationFindWiresCrossingSymbols:
len(d1_crossings) >= 1 len(d1_crossings) >= 1
), "Wire starting at pin but passing through body must be detected" ), "Wire starting at pin but passing through body must be detected"
def test_wire_terminating_at_pin_from_outside(self): def test_wire_terminating_at_pin_from_outside(self) -> None:
"""A wire that arrives at a pin from outside the component body """A wire that arrives at a pin from outside the component body
is a valid connection and must NOT be flagged.""" is a valid connection and must NOT be flagged."""
# LED D1 at (100,100) → pin 1 at (96.19, 100) # LED D1 at (100,100) → pin 1 at (96.19, 100)
@@ -450,7 +450,7 @@ class TestIntegrationFindWiresCrossingSymbols:
d1_crossings = [c for c in result if c["component"]["reference"] == "D1"] 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" 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): def test_wire_shorts_component_pins_detected_as_collision(self) -> None:
"""Regression: a wire connecting pin1→pin2 of the same component """Regression: a wire connecting pin1→pin2 of the same component
must be reported even though both endpoints land on pins.""" must be reported even though both endpoints land on pins."""
r_sexp = _make_resistor_sexp("R_short", 100.0, 100.0) r_sexp = _make_resistor_sexp("R_short", 100.0, 100.0)
@@ -472,7 +472,7 @@ class TestIntegrationFindWiresCrossingSymbols:
class TestIntegrationGetElementsInRegion: class TestIntegrationGetElementsInRegion:
"""Integration test for region query.""" """Integration test for region query."""
def test_region_returns_pin_data(self): def test_region_returns_pin_data(self) -> None:
"""Symbols in region should include pin position data.""" """Symbols in region should include pin position data."""
extra = _make_resistor_sexp("R1", 100, 100) extra = _make_resistor_sexp("R1", 100, 100)
tmp = _make_temp_schematic(extra) tmp = _make_temp_schematic(extra)
@@ -482,7 +482,7 @@ class TestIntegrationGetElementsInRegion:
assert "pins" in sym assert "pins" in sym
assert len(sym["pins"]) == 2 # Resistor has 2 pins assert len(sym["pins"]) == 2 # Resistor has 2 pins
def test_wire_passing_through_region_included(self): def test_wire_passing_through_region_included(self) -> None:
"""A wire that passes through a region (no endpoints inside) should be included.""" """A wire that passes through a region (no endpoints inside) should be included."""
extra = """ extra = """
(wire (pts (xy 0 50) (xy 100 50)) (wire (pts (xy 0 50) (xy 100 50))
@@ -493,7 +493,7 @@ class TestIntegrationGetElementsInRegion:
result = get_elements_in_region(tmp, 40, 40, 60, 60) result = get_elements_in_region(tmp, 40, 40, 60, 60)
assert result["counts"]["wires"] == 1 assert result["counts"]["wires"] == 1
def test_wire_outside_region_excluded(self): def test_wire_outside_region_excluded(self) -> None:
"""A wire entirely outside a region should not be included.""" """A wire entirely outside a region should not be included."""
extra = """ extra = """
(wire (pts (xy 0 0) (xy 10 0)) (wire (pts (xy 0 0) (xy 10 0))
@@ -513,48 +513,48 @@ class TestIntegrationGetElementsInRegion:
class TestCheckWireOverlap: class TestCheckWireOverlap:
"""Test wire overlap detection for horizontal, vertical, and diagonal cases.""" """Test wire overlap detection for horizontal, vertical, and diagonal cases."""
def test_horizontal_overlap(self): def test_horizontal_overlap(self) -> None:
w1 = {"start": (10, 50), "end": (30, 50)} w1 = {"start": (10, 50), "end": (30, 50)}
w2 = {"start": (20, 50), "end": (40, 50)} w2 = {"start": (20, 50), "end": (40, 50)}
result = _check_wire_overlap(w1, w2, 0.5) result = _check_wire_overlap(w1, w2, 0.5)
assert result is not None assert result is not None
assert result["type"] == "collinear_overlap" assert result["type"] == "collinear_overlap"
def test_vertical_overlap(self): def test_vertical_overlap(self) -> None:
w1 = {"start": (50, 10), "end": (50, 30)} w1 = {"start": (50, 10), "end": (50, 30)}
w2 = {"start": (50, 20), "end": (50, 40)} w2 = {"start": (50, 20), "end": (50, 40)}
result = _check_wire_overlap(w1, w2, 0.5) result = _check_wire_overlap(w1, w2, 0.5)
assert result is not None assert result is not None
assert result["type"] == "collinear_overlap" assert result["type"] == "collinear_overlap"
def test_diagonal_overlap(self): def test_diagonal_overlap(self) -> None:
w1 = {"start": (0, 0), "end": (20, 20)} w1 = {"start": (0, 0), "end": (20, 20)}
w2 = {"start": (10, 10), "end": (30, 30)} w2 = {"start": (10, 10), "end": (30, 30)}
result = _check_wire_overlap(w1, w2, 0.5) result = _check_wire_overlap(w1, w2, 0.5)
assert result is not None assert result is not None
assert result["type"] == "collinear_overlap" assert result["type"] == "collinear_overlap"
def test_horizontal_no_overlap(self): def test_horizontal_no_overlap(self) -> None:
w1 = {"start": (10, 50), "end": (20, 50)} w1 = {"start": (10, 50), "end": (20, 50)}
w2 = {"start": (30, 50), "end": (40, 50)} w2 = {"start": (30, 50), "end": (40, 50)}
result = _check_wire_overlap(w1, w2, 0.5) result = _check_wire_overlap(w1, w2, 0.5)
assert result is None assert result is None
def test_parallel_offset_no_overlap(self): def test_parallel_offset_no_overlap(self) -> None:
"""Two parallel wires offset perpendicularly should not overlap.""" """Two parallel wires offset perpendicularly should not overlap."""
w1 = {"start": (0, 0), "end": (20, 20)} w1 = {"start": (0, 0), "end": (20, 20)}
w2 = {"start": (0, 5), "end": (20, 25)} w2 = {"start": (0, 5), "end": (20, 25)}
result = _check_wire_overlap(w1, w2, 0.5) result = _check_wire_overlap(w1, w2, 0.5)
assert result is None assert result is None
def test_non_parallel_no_overlap(self): def test_non_parallel_no_overlap(self) -> None:
"""Two wires at different angles should not overlap.""" """Two wires at different angles should not overlap."""
w1 = {"start": (0, 0), "end": (10, 10)} w1 = {"start": (0, 0), "end": (10, 10)}
w2 = {"start": (0, 0), "end": (10, 0)} w2 = {"start": (0, 0), "end": (10, 0)}
result = _check_wire_overlap(w1, w2, 0.5) result = _check_wire_overlap(w1, w2, 0.5)
assert result is None assert result is None
def test_zero_length_segment(self): def test_zero_length_segment(self) -> None:
w1 = {"start": (10, 10), "end": (10, 10)} w1 = {"start": (10, 10), "end": (10, 10)}
w2 = {"start": (10, 10), "end": (20, 20)} w2 = {"start": (10, 10), "end": (20, 20)}
result = _check_wire_overlap(w1, w2, 0.5) result = _check_wire_overlap(w1, w2, 0.5)
@@ -565,7 +565,7 @@ class TestCheckWireOverlap:
class TestIntegrationDiagonalWireOverlap: class TestIntegrationDiagonalWireOverlap:
"""Integration tests for diagonal collinear wire overlap detection.""" """Integration tests for diagonal collinear wire overlap detection."""
def test_diagonal_collinear_wire_overlap(self): def test_diagonal_collinear_wire_overlap(self) -> None:
"""Two 45-degree wires that overlap should be detected.""" """Two 45-degree wires that overlap should be detected."""
extra = """ extra = """
(wire (pts (xy 0 0) (xy 20 20)) (wire (pts (xy 0 0) (xy 20 20))
@@ -579,7 +579,7 @@ class TestIntegrationDiagonalWireOverlap:
result = find_overlapping_elements(tmp, tolerance=0.5) result = find_overlapping_elements(tmp, tolerance=0.5)
assert len(result["overlappingWires"]) >= 1 assert len(result["overlappingWires"]) >= 1
def test_diagonal_parallel_no_overlap(self): def test_diagonal_parallel_no_overlap(self) -> None:
"""Two parallel 45-degree wires that are offset should not overlap.""" """Two parallel 45-degree wires that are offset should not overlap."""
extra = """ extra = """
(wire (pts (xy 0 0) (xy 20 20)) (wire (pts (xy 0 0) (xy 20 20))
@@ -593,7 +593,7 @@ class TestIntegrationDiagonalWireOverlap:
result = find_overlapping_elements(tmp, tolerance=0.5) result = find_overlapping_elements(tmp, tolerance=0.5)
assert len(result["overlappingWires"]) == 0 assert len(result["overlappingWires"]) == 0
def test_diagonal_non_collinear_no_overlap(self): def test_diagonal_non_collinear_no_overlap(self) -> None:
"""Two wires at different angles crossing should not be flagged as collinear overlap.""" """Two wires at different angles crossing should not be flagged as collinear overlap."""
extra = """ extra = """
(wire (pts (xy 0 0) (xy 20 20)) (wire (pts (xy 0 0) (xy 20 20))
@@ -616,7 +616,7 @@ class TestIntegrationDiagonalWireOverlap:
class TestExtractLibSymbols: class TestExtractLibSymbols:
"""Test _extract_lib_symbols helper.""" """Test _extract_lib_symbols helper."""
def test_extracts_pins_from_lib_symbols(self): def test_extracts_pins_from_lib_symbols(self) -> None:
sexp = sexpdata.loads("""(kicad_sch sexp = sexpdata.loads("""(kicad_sch
(lib_symbols (lib_symbols
(symbol "Device:R" (symbol "Device:R"
@@ -636,19 +636,19 @@ class TestExtractLibSymbols:
assert "2" in pins assert "2" in pins
assert pins["1"]["y"] == pytest.approx(3.81) assert pins["1"]["y"] == pytest.approx(3.81)
def test_empty_schematic_returns_empty(self): def test_empty_schematic_returns_empty(self) -> None:
sexp = sexpdata.loads("(kicad_sch)") sexp = sexpdata.loads("(kicad_sch)")
result = _extract_lib_symbols(sexp) result = _extract_lib_symbols(sexp)
assert result == {} assert result == {}
def test_no_lib_symbols_section(self): def test_no_lib_symbols_section(self) -> None:
sexp = sexpdata.loads("""(kicad_sch sexp = sexpdata.loads("""(kicad_sch
(wire (pts (xy 0 0) (xy 10 10))) (wire (pts (xy 0 0) (xy 10 10)))
)""") )""")
result = _extract_lib_symbols(sexp) result = _extract_lib_symbols(sexp)
assert result == {} assert result == {}
def test_extract_includes_graphics_points(self): def test_extract_includes_graphics_points(self) -> None:
"""_extract_lib_symbols should return graphics_points from body shapes.""" """_extract_lib_symbols should return graphics_points from body shapes."""
sexp = sexpdata.loads("""(kicad_sch sexp = sexpdata.loads("""(kicad_sch
(lib_symbols (lib_symbols
@@ -688,7 +688,7 @@ class TestExtractLibSymbols:
class TestParseLibSymbolGraphics: class TestParseLibSymbolGraphics:
"""Test graphics extraction from lib_symbol definitions.""" """Test graphics extraction from lib_symbol definitions."""
def test_rectangle(self): def test_rectangle(self) -> None:
sexp = sexpdata.loads("""(symbol "Device:R" sexp = sexpdata.loads("""(symbol "Device:R"
(symbol "Device:R_0_1" (symbol "Device:R_0_1"
(rectangle (start -1.016 -2.54) (end 1.016 2.54) (rectangle (start -1.016 -2.54) (end 1.016 2.54)
@@ -699,7 +699,7 @@ class TestParseLibSymbolGraphics:
assert (-1.016, -2.54) in pts assert (-1.016, -2.54) in pts
assert (1.016, 2.54) in pts assert (1.016, 2.54) in pts
def test_polyline(self): def test_polyline(self) -> None:
sexp = sexpdata.loads("""(symbol "Device:C" sexp = sexpdata.loads("""(symbol "Device:C"
(symbol "Device:C_0_1" (symbol "Device:C_0_1"
(polyline (polyline
@@ -710,7 +710,7 @@ class TestParseLibSymbolGraphics:
assert (-2.032, -0.762) in pts assert (-2.032, -0.762) in pts
assert (2.032, -0.762) in pts assert (2.032, -0.762) in pts
def test_circle(self): def test_circle(self) -> None:
sexp = sexpdata.loads("""(symbol "Test:Circle" sexp = sexpdata.loads("""(symbol "Test:Circle"
(symbol "Test:Circle_0_1" (symbol "Test:Circle_0_1"
(circle (center 0 0) (radius 5) (circle (center 0 0) (radius 5)
@@ -721,7 +721,7 @@ class TestParseLibSymbolGraphics:
assert (-5.0, -5.0) in pts assert (-5.0, -5.0) in pts
assert (5.0, 5.0) in pts assert (5.0, 5.0) in pts
def test_arc(self): def test_arc(self) -> None:
sexp = sexpdata.loads("""(symbol "Test:Arc" sexp = sexpdata.loads("""(symbol "Test:Arc"
(symbol "Test:Arc_0_1" (symbol "Test:Arc_0_1"
(arc (start 1 0) (mid 0 1) (end -1 0) (arc (start 1 0) (mid 0 1) (end -1 0)
@@ -732,7 +732,7 @@ class TestParseLibSymbolGraphics:
assert (0.0, 1.0) in pts assert (0.0, 1.0) in pts
assert (-1.0, 0.0) in pts assert (-1.0, 0.0) in pts
def test_no_graphics(self): def test_no_graphics(self) -> None:
sexp = sexpdata.loads("""(symbol "Test:Empty" sexp = sexpdata.loads("""(symbol "Test:Empty"
(symbol "Test:Empty_1_1" (symbol "Test:Empty_1_1"
(pin passive line (at 0 0 0) (length 1.27) (pin passive line (at 0 0 0) (length 1.27)
@@ -750,24 +750,24 @@ class TestParseLibSymbolGraphics:
class TestTransformLocalPoint: class TestTransformLocalPoint:
"""Test local→absolute coordinate transform.""" """Test local→absolute coordinate transform."""
def test_no_transform(self): def test_no_transform(self) -> None:
# ly is negated (lib y-up → schematic y-down) # 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) x, y = _transform_local_point(1.0, 2.0, 100.0, 200.0, 0, False, False)
assert x == pytest.approx(101.0) assert x == pytest.approx(101.0)
assert y == pytest.approx(198.0) assert y == pytest.approx(198.0)
def test_mirror_x(self): def test_mirror_x(self) -> None:
# y-negate then mirror_x cancel out → net ly unchanged # 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) x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, True, False)
assert x == pytest.approx(1.0) assert x == pytest.approx(1.0)
assert y == pytest.approx(2.0) assert y == pytest.approx(2.0)
def test_mirror_y(self): def test_mirror_y(self) -> None:
x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, False, True) x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, False, True)
assert x == pytest.approx(-1.0) assert x == pytest.approx(-1.0)
assert y == pytest.approx(-2.0) assert y == pytest.approx(-2.0)
def test_rotation_90(self): def test_rotation_90(self) -> None:
# ly=0 negated is still 0, then rotate lx=1 by 90° # 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) 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 x == pytest.approx(0.0, abs=1e-9)
@@ -782,7 +782,7 @@ class TestTransformLocalPoint:
class TestComputeSymbolBboxWithGraphics: class TestComputeSymbolBboxWithGraphics:
"""Test that bounding box computation uses graphics points when available.""" """Test that bounding box computation uses graphics points when available."""
def test_resistor_bbox_from_graphics(self): def test_resistor_bbox_from_graphics(self) -> None:
"""Device:R rectangle is (-1.016, -2.54) to (1.016, 2.54) in local coords. """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. 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.""" Bbox should span from pin-to-pin in Y and use rectangle width in X."""
@@ -823,7 +823,7 @@ class TestComputeSymbolBboxWithGraphics:
assert min_y == pytest.approx(100 - 3.81) assert min_y == pytest.approx(100 - 3.81)
assert max_y == pytest.approx(100 + 3.81) assert max_y == pytest.approx(100 + 3.81)
def test_fallback_without_graphics(self): def test_fallback_without_graphics(self) -> None:
"""Without graphics_points, should use the old degenerate expansion.""" """Without graphics_points, should use the old degenerate expansion."""
sym = { sym = {
"x": 100.0, "x": 100.0,
@@ -858,7 +858,7 @@ class TestComputeSymbolBboxWithGraphics:
assert min_x == pytest.approx(100 - 1.5) assert min_x == pytest.approx(100 - 1.5)
assert max_x == pytest.approx(100 + 1.5) assert max_x == pytest.approx(100 + 1.5)
def test_rotated_symbol_graphics(self): def test_rotated_symbol_graphics(self) -> None:
"""Graphics points should be rotated along with the symbol.""" """Graphics points should be rotated along with the symbol."""
sym = { sym = {
"x": 100.0, "x": 100.0,
@@ -902,7 +902,7 @@ class TestComputeSymbolBboxWithGraphics:
class TestIntegrationGraphicsBbox: class TestIntegrationGraphicsBbox:
"""Integration tests verifying graphics-based bbox from real template data.""" """Integration tests verifying graphics-based bbox from real template data."""
def test_resistor_bbox_uses_rectangle(self): def test_resistor_bbox_uses_rectangle(self) -> None:
"""The template's Device:R has a rectangle body. """The template's Device:R has a rectangle body.
Verify that the bbox for a placed resistor uses the actual Verify that the bbox for a placed resistor uses the actual
rectangle width rather than the degenerate 1.5mm expansion.""" rectangle width rather than the degenerate 1.5mm expansion."""
@@ -925,7 +925,7 @@ class TestIntegrationGraphicsBbox:
# Rectangle is ±1.016 in X, NOT ±1.5 from degenerate expansion # 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) assert max_x - min_x == pytest.approx(2 * 1.016, abs=0.01)
def test_led_bbox_uses_polyline(self): def test_led_bbox_uses_polyline(self) -> None:
"""The template's Device:LED uses polylines for its body. """The template's Device:LED uses polylines for its body.
Verify that the bbox uses polyline extents.""" Verify that the bbox uses polyline extents."""
extra = _make_led_sexp("D1", 100, 100) extra = _make_led_sexp("D1", 100, 100)

View File

@@ -7,6 +7,7 @@ import shutil
import sys import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Any
import pytest import pytest
@@ -86,7 +87,7 @@ class TestGetSchematicComponentParsing:
} }
return fields return fields
def _parse_comp_pos(self, block_text: str): def _parse_comp_pos(self, block_text: str) -> Any:
"""Mirrors the regex used to extract symbol position.""" """Mirrors the regex used to extract symbol position."""
m = re.search( m = re.search(
r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)', r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)',
@@ -100,7 +101,7 @@ class TestGetSchematicComponentParsing:
} }
return None return None
def test_parses_reference_field(self): def test_parses_reference_field(self) -> None:
fields = self._parse_fields(PLACED_RESISTOR_BLOCK) fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
assert "Reference" in fields assert "Reference" in fields
assert fields["Reference"]["value"] == "R1" assert fields["Reference"]["value"] == "R1"
@@ -108,25 +109,25 @@ class TestGetSchematicComponentParsing:
assert fields["Reference"]["y"] == pytest.approx(47.46) assert fields["Reference"]["y"] == pytest.approx(47.46)
assert fields["Reference"]["angle"] == pytest.approx(0.0) assert fields["Reference"]["angle"] == pytest.approx(0.0)
def test_parses_value_field(self): def test_parses_value_field(self) -> None:
fields = self._parse_fields(PLACED_RESISTOR_BLOCK) fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
assert "Value" in fields assert "Value" in fields
assert fields["Value"]["value"] == "10k" assert fields["Value"]["value"] == "10k"
assert fields["Value"]["x"] == pytest.approx(51.27) assert fields["Value"]["x"] == pytest.approx(51.27)
assert fields["Value"]["y"] == pytest.approx(52.54) assert fields["Value"]["y"] == pytest.approx(52.54)
def test_parses_all_four_standard_fields(self): def test_parses_all_four_standard_fields(self) -> None:
fields = self._parse_fields(PLACED_RESISTOR_BLOCK) fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
assert set(fields.keys()) >= {"Reference", "Value", "Footprint", "Datasheet"} assert set(fields.keys()) >= {"Reference", "Value", "Footprint", "Datasheet"}
def test_parses_component_position(self): def test_parses_component_position(self) -> None:
pos = self._parse_comp_pos(PLACED_RESISTOR_BLOCK) pos = self._parse_comp_pos(PLACED_RESISTOR_BLOCK)
assert pos is not None assert pos is not None
assert pos["x"] == pytest.approx(50.0) assert pos["x"] == pytest.approx(50.0)
assert pos["y"] == pytest.approx(50.0) assert pos["y"] == pytest.approx(50.0)
assert pos["angle"] == pytest.approx(0.0) assert pos["angle"] == pytest.approx(0.0)
def test_field_position_regex_replaces_correctly(self): def test_field_position_regex_replaces_correctly(self) -> None:
"""Mirrors the regex used in _handle_edit_schematic_component for fieldPositions.""" """Mirrors the regex used in _handle_edit_schematic_component for fieldPositions."""
field_name = "Reference" field_name = "Reference"
new_x, new_y, new_angle = 99.0, 88.0, 0 new_x, new_y, new_angle = 99.0, 88.0, 0
@@ -144,7 +145,7 @@ class TestGetSchematicComponentParsing:
# Value should be unchanged # Value should be unchanged
assert fields["Value"]["x"] == pytest.approx(51.27) assert fields["Value"]["x"] == pytest.approx(51.27)
def test_field_position_regex_preserves_value(self): def test_field_position_regex_preserves_value(self) -> None:
"""Replacing position must not change the field value string.""" """Replacing position must not change the field value string."""
block = PLACED_RESISTOR_BLOCK block = PLACED_RESISTOR_BLOCK
block = re.sub( block = re.sub(
@@ -166,16 +167,16 @@ class TestGetSchematicComponentIntegration:
"""Integration tests: write a real .kicad_sch and call the handler.""" """Integration tests: write a real .kicad_sch and call the handler."""
@pytest.fixture @pytest.fixture
def sch_with_r1(self, tmp_path): def sch_with_r1(self, tmp_path: Any) -> Any:
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK) return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
def _get_interface(self): def _get_interface(self) -> Any:
"""Lazily import KiCADInterface to avoid pcbnew import at collection time.""" """Lazily import KiCADInterface to avoid pcbnew import at collection time."""
from kicad_interface import KiCADInterface from kicad_interface import KiCADInterface
return KiCADInterface() return KiCADInterface()
def test_get_returns_success(self, sch_with_r1): def test_get_returns_success(self, sch_with_r1: Any) -> None:
iface = self._get_interface() iface = self._get_interface()
result = iface.handle_command( result = iface.handle_command(
"get_schematic_component", "get_schematic_component",
@@ -186,7 +187,7 @@ class TestGetSchematicComponentIntegration:
) )
assert result["success"] is True assert result["success"] is True
def test_get_returns_correct_reference(self, sch_with_r1): def test_get_returns_correct_reference(self, sch_with_r1: Any) -> None:
iface = self._get_interface() iface = self._get_interface()
result = iface.handle_command( result = iface.handle_command(
"get_schematic_component", "get_schematic_component",
@@ -197,7 +198,7 @@ class TestGetSchematicComponentIntegration:
) )
assert result["reference"] == "R1" assert result["reference"] == "R1"
def test_get_returns_component_position(self, sch_with_r1): def test_get_returns_component_position(self, sch_with_r1: Any) -> None:
iface = self._get_interface() iface = self._get_interface()
result = iface.handle_command( result = iface.handle_command(
"get_schematic_component", "get_schematic_component",
@@ -210,7 +211,7 @@ class TestGetSchematicComponentIntegration:
assert result["position"]["x"] == pytest.approx(50.0) assert result["position"]["x"] == pytest.approx(50.0)
assert result["position"]["y"] == pytest.approx(50.0) assert result["position"]["y"] == pytest.approx(50.0)
def test_get_returns_reference_field_position(self, sch_with_r1): def test_get_returns_reference_field_position(self, sch_with_r1: Any) -> None:
iface = self._get_interface() iface = self._get_interface()
result = iface.handle_command( result = iface.handle_command(
"get_schematic_component", "get_schematic_component",
@@ -224,7 +225,7 @@ class TestGetSchematicComponentIntegration:
assert ref_field["x"] == pytest.approx(51.27) assert ref_field["x"] == pytest.approx(51.27)
assert ref_field["y"] == pytest.approx(47.46) assert ref_field["y"] == pytest.approx(47.46)
def test_get_returns_value_field(self, sch_with_r1): def test_get_returns_value_field(self, sch_with_r1: Any) -> None:
iface = self._get_interface() iface = self._get_interface()
result = iface.handle_command( result = iface.handle_command(
"get_schematic_component", "get_schematic_component",
@@ -238,7 +239,7 @@ class TestGetSchematicComponentIntegration:
assert val_field["x"] == pytest.approx(51.27) assert val_field["x"] == pytest.approx(51.27)
assert val_field["y"] == pytest.approx(52.54) assert val_field["y"] == pytest.approx(52.54)
def test_get_unknown_reference_returns_failure(self, sch_with_r1): def test_get_unknown_reference_returns_failure(self, sch_with_r1: Any) -> None:
iface = self._get_interface() iface = self._get_interface()
result = iface.handle_command( result = iface.handle_command(
"get_schematic_component", "get_schematic_component",
@@ -250,7 +251,7 @@ class TestGetSchematicComponentIntegration:
assert result["success"] is False assert result["success"] is False
assert "R99" in result["message"] assert "R99" in result["message"]
def test_get_missing_path_returns_failure(self): def test_get_missing_path_returns_failure(self) -> None:
iface = self._get_interface() iface = self._get_interface()
result = iface.handle_command( result = iface.handle_command(
"get_schematic_component", "get_schematic_component",
@@ -266,15 +267,15 @@ class TestEditSchematicComponentFieldPositions:
"""Integration tests for the new fieldPositions parameter.""" """Integration tests for the new fieldPositions parameter."""
@pytest.fixture @pytest.fixture
def sch_with_r1(self, tmp_path): def sch_with_r1(self, tmp_path: Any) -> Any:
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK) return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
def _get_interface(self): def _get_interface(self) -> Any:
from kicad_interface import KiCADInterface from kicad_interface import KiCADInterface
return KiCADInterface() return KiCADInterface()
def test_reposition_reference_label(self, sch_with_r1): def test_reposition_reference_label(self, sch_with_r1: Any) -> None:
iface = self._get_interface() iface = self._get_interface()
result = iface.handle_command( result = iface.handle_command(
"edit_schematic_component", "edit_schematic_component",
@@ -297,7 +298,7 @@ class TestEditSchematicComponentFieldPositions:
assert get_result["fields"]["Reference"]["x"] == pytest.approx(99.0) assert get_result["fields"]["Reference"]["x"] == pytest.approx(99.0)
assert get_result["fields"]["Reference"]["y"] == pytest.approx(88.0) assert get_result["fields"]["Reference"]["y"] == pytest.approx(88.0)
def test_reposition_does_not_change_value(self, sch_with_r1): def test_reposition_does_not_change_value(self, sch_with_r1: Any) -> None:
iface = self._get_interface() iface = self._get_interface()
iface.handle_command( iface.handle_command(
"edit_schematic_component", "edit_schematic_component",
@@ -318,7 +319,7 @@ class TestEditSchematicComponentFieldPositions:
assert get_result["fields"]["Value"]["x"] == pytest.approx(51.27) assert get_result["fields"]["Value"]["x"] == pytest.approx(51.27)
assert get_result["fields"]["Value"]["y"] == pytest.approx(52.54) assert get_result["fields"]["Value"]["y"] == pytest.approx(52.54)
def test_reposition_multiple_fields(self, sch_with_r1): def test_reposition_multiple_fields(self, sch_with_r1: Any) -> None:
iface = self._get_interface() iface = self._get_interface()
result = iface.handle_command( result = iface.handle_command(
"edit_schematic_component", "edit_schematic_component",
@@ -343,7 +344,7 @@ class TestEditSchematicComponentFieldPositions:
assert get_result["fields"]["Reference"]["x"] == pytest.approx(10.0) assert get_result["fields"]["Reference"]["x"] == pytest.approx(10.0)
assert get_result["fields"]["Value"]["y"] == pytest.approx(30.0) assert get_result["fields"]["Value"]["y"] == pytest.approx(30.0)
def test_fieldpositions_alone_is_valid(self, sch_with_r1): def test_fieldpositions_alone_is_valid(self, sch_with_r1: Any) -> None:
"""fieldPositions without value/footprint/newReference should succeed.""" """fieldPositions without value/footprint/newReference should succeed."""
iface = self._get_interface() iface = self._get_interface()
result = iface.handle_command( result = iface.handle_command(
@@ -356,7 +357,7 @@ class TestEditSchematicComponentFieldPositions:
) )
assert result["success"] is True assert result["success"] is True
def test_no_params_still_fails(self, sch_with_r1): def test_no_params_still_fails(self, sch_with_r1: Any) -> None:
"""Providing no update params should return an error.""" """Providing no update params should return an error."""
iface = self._get_interface() iface = self._get_interface()
result = iface.handle_command( result = iface.handle_command(

View File

@@ -12,6 +12,7 @@ Covers:
import shutil import shutil
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -59,22 +60,22 @@ def _write_temp_sch(content: str) -> Path:
class TestDeleteWireUnit: class TestDeleteWireUnit:
"""Unit-level tests for WireManager.delete_wire.""" """Unit-level tests for WireManager.delete_wire."""
def setup_method(self): def setup_method(self) -> None:
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
self.WireManager = WireManager self.WireManager = WireManager
def test_nonexistent_file_returns_false(self, tmp_path): 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]) result = self.WireManager.delete_wire(tmp_path / "nope.kicad_sch", [0, 0], [10, 10])
assert result is False assert result is False
def test_no_matching_wire_returns_false(self, tmp_path): def test_no_matching_wire_returns_false(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch" sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch) shutil.copy(EMPTY_SCH, sch)
result = self.WireManager.delete_wire(sch, [99, 99], [100, 100]) result = self.WireManager.delete_wire(sch, [99, 99], [100, 100])
assert result is False assert result is False
def test_tolerance_argument_accepted(self, tmp_path): def test_tolerance_argument_accepted(self, tmp_path: Any) -> None:
"""Ensure the tolerance kwarg doesn't raise a TypeError.""" """Ensure the tolerance kwarg doesn't raise a TypeError."""
sch = tmp_path / "test.kicad_sch" sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch) shutil.copy(EMPTY_SCH, sch)
@@ -91,22 +92,22 @@ class TestDeleteWireUnit:
class TestDeleteLabelUnit: class TestDeleteLabelUnit:
"""Unit-level tests for WireManager.delete_label.""" """Unit-level tests for WireManager.delete_label."""
def setup_method(self): def setup_method(self) -> None:
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
self.WireManager = WireManager self.WireManager = WireManager
def test_nonexistent_file_returns_false(self, tmp_path): def test_nonexistent_file_returns_false(self, tmp_path: Any) -> None:
result = self.WireManager.delete_label(tmp_path / "nope.kicad_sch", "VCC") result = self.WireManager.delete_label(tmp_path / "nope.kicad_sch", "VCC")
assert result is False assert result is False
def test_missing_label_returns_false(self, tmp_path): def test_missing_label_returns_false(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch" sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch) shutil.copy(EMPTY_SCH, sch)
result = self.WireManager.delete_label(sch, "NONEXISTENT") result = self.WireManager.delete_label(sch, "NONEXISTENT")
assert result is False assert result is False
def test_position_kwarg_accepted(self, tmp_path): def test_position_kwarg_accepted(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch" sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch) shutil.copy(EMPTY_SCH, sch)
result = self.WireManager.delete_label(sch, "VCC", position=[10.0, 20.0], tolerance=0.5) result = self.WireManager.delete_label(sch, "VCC", position=[10.0, 20.0], tolerance=0.5)
@@ -122,12 +123,12 @@ class TestDeleteLabelUnit:
class TestDeleteWireIntegration: class TestDeleteWireIntegration:
"""Integration tests that read/write real .kicad_sch files.""" """Integration tests that read/write real .kicad_sch files."""
def setup_method(self): def setup_method(self) -> None:
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
self.WireManager = WireManager self.WireManager = WireManager
def test_exact_match_deletes_wire(self, tmp_path): def test_exact_match_deletes_wire(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch" sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8") sch.write_text(_WIRE_SCH, encoding="utf-8")
@@ -142,7 +143,7 @@ class TestDeleteWireIntegration:
] ]
assert wire_items == [], "Wire should have been removed from the file" assert wire_items == [], "Wire should have been removed from the file"
def test_reverse_direction_match_deletes_wire(self, tmp_path): def test_reverse_direction_match_deletes_wire(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch" sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8") sch.write_text(_WIRE_SCH, encoding="utf-8")
@@ -151,7 +152,7 @@ class TestDeleteWireIntegration:
assert result is True assert result is True
def test_within_tolerance_deletes_wire(self, tmp_path): def test_within_tolerance_deletes_wire(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch" sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8") sch.write_text(_WIRE_SCH, encoding="utf-8")
@@ -159,7 +160,7 @@ class TestDeleteWireIntegration:
result = self.WireManager.delete_wire(sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5) result = self.WireManager.delete_wire(sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5)
assert result is True assert result is True
def test_outside_tolerance_no_delete(self, tmp_path): def test_outside_tolerance_no_delete(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch" sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8") sch.write_text(_WIRE_SCH, encoding="utf-8")
@@ -171,14 +172,14 @@ class TestDeleteWireIntegration:
result2 = self.WireManager.delete_wire(sch2, [10.6, 20.0], [30.0, 20.0], tolerance=0.5) 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" 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): def test_file_is_valid_sexp_after_deletion(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch" sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8") sch.write_text(_WIRE_SCH, encoding="utf-8")
self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0]) self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
# Must parse without exception # Must parse without exception
sexpdata.loads(sch.read_text(encoding="utf-8")) sexpdata.loads(sch.read_text(encoding="utf-8"))
def test_label_preserved_after_wire_deletion(self, tmp_path): def test_label_preserved_after_wire_deletion(self, tmp_path: Any) -> None:
"""Deleting a wire must not remove unrelated elements.""" """Deleting a wire must not remove unrelated elements."""
sch = tmp_path / "test.kicad_sch" sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8") sch.write_text(_WIRE_SCH, encoding="utf-8")
@@ -199,12 +200,12 @@ class TestDeleteWireIntegration:
@pytest.mark.integration @pytest.mark.integration
class TestDeleteLabelIntegration: class TestDeleteLabelIntegration:
def setup_method(self): def setup_method(self) -> None:
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
self.WireManager = WireManager self.WireManager = WireManager
def test_deletes_label_by_name(self, tmp_path): def test_deletes_label_by_name(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch" sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8") sch.write_text(_WIRE_SCH, encoding="utf-8")
@@ -219,21 +220,21 @@ class TestDeleteLabelIntegration:
] ]
assert labels == [], "Label should have been removed" assert labels == [], "Label should have been removed"
def test_deletes_label_with_matching_position(self, tmp_path): def test_deletes_label_with_matching_position(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch" sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8") sch.write_text(_WIRE_SCH, encoding="utf-8")
result = self.WireManager.delete_label(sch, "VCC", position=[50.0, 50.0]) result = self.WireManager.delete_label(sch, "VCC", position=[50.0, 50.0])
assert result is True assert result is True
def test_position_mismatch_no_delete(self, tmp_path): def test_position_mismatch_no_delete(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch" sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8") sch.write_text(_WIRE_SCH, encoding="utf-8")
result = self.WireManager.delete_label(sch, "VCC", position=[99.0, 99.0], tolerance=0.5) result = self.WireManager.delete_label(sch, "VCC", position=[99.0, 99.0], tolerance=0.5)
assert result is False assert result is False
def test_wire_preserved_after_label_deletion(self, tmp_path): def test_wire_preserved_after_label_deletion(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch" sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8") sch.write_text(_WIRE_SCH, encoding="utf-8")
self.WireManager.delete_label(sch, "VCC") self.WireManager.delete_label(sch, "VCC")
@@ -245,7 +246,7 @@ class TestDeleteLabelIntegration:
] ]
assert len(wires) == 1 assert len(wires) == 1
def test_file_is_valid_sexp_after_deletion(self, tmp_path): def test_file_is_valid_sexp_after_deletion(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch" sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8") sch.write_text(_WIRE_SCH, encoding="utf-8")
self.WireManager.delete_label(sch, "VCC") self.WireManager.delete_label(sch, "VCC")
@@ -260,7 +261,7 @@ class TestDeleteLabelIntegration:
# Each handler is extracted as a standalone function for testing. # Each handler is extracted as a standalone function for testing.
def _make_handler_under_test(handler_name: str): def _make_handler_under_test(handler_name: str) -> None:
""" """
Return the unbound handler method from kicad_interface by importing only Return the unbound handler method from kicad_interface by importing only
that method's source via exec, bypassing module-level side effects. that method's source via exec, bypassing module-level side effects.
@@ -300,7 +301,7 @@ class TestHandlerParamValidation:
that satisfy the dependency chain up to the first parameter-check branch. that satisfy the dependency chain up to the first parameter-check branch.
""" """
def _make_iface_stub(self): def _make_iface_stub(self) -> Any:
"""Return a stub that exposes only the handler methods under test.""" """Return a stub that exposes only the handler methods under test."""
import importlib import importlib
import types import types
@@ -316,7 +317,7 @@ class TestHandlerParamValidation:
# --- delete_schematic_wire --- # --- delete_schematic_wire ---
def test_delete_wire_missing_schematic_path(self): def test_delete_wire_missing_schematic_path(self) -> None:
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
with patch.object(WireManager, "delete_wire", return_value=False): with patch.object(WireManager, "delete_wire", return_value=False):
@@ -325,7 +326,7 @@ class TestHandlerParamValidation:
schematic_path = params.get("schematicPath") schematic_path = params.get("schematicPath")
assert schematic_path is None assert schematic_path is None
# Handler should short-circuit before calling WireManager # Handler should short-circuit before calling WireManager
result = ( result: dict[str, Any] = (
{"success": False, "message": "schematicPath is required"} {"success": False, "message": "schematicPath is required"}
if not schematic_path if not schematic_path
else {} else {}
@@ -335,7 +336,7 @@ class TestHandlerParamValidation:
# --- delete_schematic_net_label --- # --- delete_schematic_net_label ---
def test_delete_label_missing_net_name(self): def test_delete_label_missing_net_name(self) -> None:
params = {"schematicPath": "/some/file.kicad_sch"} params = {"schematicPath": "/some/file.kicad_sch"}
net_name = params.get("netName") net_name = params.get("netName")
result = ( result = (
@@ -348,7 +349,7 @@ class TestHandlerParamValidation:
) )
assert result["success"] is False assert result["success"] is False
def test_delete_label_missing_schematic_path(self): def test_delete_label_missing_schematic_path(self) -> None:
params = {"netName": "VCC"} params = {"netName": "VCC"}
schematic_path = params.get("schematicPath") schematic_path = params.get("schematicPath")
result = ( result = (
@@ -363,7 +364,7 @@ class TestHandlerParamValidation:
# --- list_schematic_components --- # --- list_schematic_components ---
def test_list_components_missing_path(self): def test_list_components_missing_path(self) -> None:
params = {} params = {}
schematic_path = params.get("schematicPath") schematic_path = params.get("schematicPath")
result = ( result = (
@@ -373,7 +374,7 @@ class TestHandlerParamValidation:
# --- list_schematic_nets --- # --- list_schematic_nets ---
def test_list_nets_missing_path(self): def test_list_nets_missing_path(self) -> None:
params = {} params = {}
result = ( result = (
{"success": False, "message": "schematicPath is required"} {"success": False, "message": "schematicPath is required"}
@@ -384,7 +385,7 @@ class TestHandlerParamValidation:
# --- list_schematic_wires --- # --- list_schematic_wires ---
def test_list_wires_missing_path(self): def test_list_wires_missing_path(self) -> None:
params = {} params = {}
result = ( result = (
{"success": False, "message": "schematicPath is required"} {"success": False, "message": "schematicPath is required"}
@@ -395,7 +396,7 @@ class TestHandlerParamValidation:
# --- list_schematic_labels --- # --- list_schematic_labels ---
def test_list_labels_missing_path(self): def test_list_labels_missing_path(self) -> None:
params = {} params = {}
result = ( result = (
{"success": False, "message": "schematicPath is required"} {"success": False, "message": "schematicPath is required"}
@@ -406,7 +407,7 @@ class TestHandlerParamValidation:
# --- move_schematic_component --- # --- move_schematic_component ---
def test_move_component_missing_reference(self): def test_move_component_missing_reference(self) -> None:
params = { params = {
"schematicPath": "/some/file.kicad_sch", "schematicPath": "/some/file.kicad_sch",
"position": {"x": 10, "y": 20}, "position": {"x": 10, "y": 20},
@@ -421,7 +422,7 @@ class TestHandlerParamValidation:
) )
assert result["success"] is False assert result["success"] is False
def test_move_component_missing_position(self): def test_move_component_missing_position(self) -> None:
params = { params = {
"schematicPath": "/some/file.kicad_sch", "schematicPath": "/some/file.kicad_sch",
"reference": "R1", "reference": "R1",
@@ -438,7 +439,7 @@ class TestHandlerParamValidation:
# --- rotate_schematic_component --- # --- rotate_schematic_component ---
def test_rotate_component_missing_reference(self): def test_rotate_component_missing_reference(self) -> None:
params = {"schematicPath": "/some/file.kicad_sch"} params = {"schematicPath": "/some/file.kicad_sch"}
result = ( result = (
{ {
@@ -452,7 +453,7 @@ class TestHandlerParamValidation:
# --- annotate_schematic --- # --- annotate_schematic ---
def test_annotate_missing_path(self): def test_annotate_missing_path(self) -> None:
params = {} params = {}
result = ( result = (
{"success": False, "message": "schematicPath is required"} {"success": False, "message": "schematicPath is required"}
@@ -463,7 +464,7 @@ class TestHandlerParamValidation:
# --- export_schematic_svg --- # --- export_schematic_svg ---
def test_export_svg_missing_output_path(self): def test_export_svg_missing_output_path(self) -> None:
params = {"schematicPath": "/some/file.kicad_sch"} params = {"schematicPath": "/some/file.kicad_sch"}
result = ( result = (
{ {

View File

@@ -48,7 +48,7 @@ def _make_wire(x1: float, y1: float, x2: float, y2: float) -> MagicMock:
return wire return wire
def _make_schematic(*wires) -> MagicMock: def _make_schematic(*wires: Any) -> MagicMock:
sch = MagicMock() sch = MagicMock()
sch.wire = list(wires) sch.wire = list(wires)
# No net labels, no symbols by default # No net labels, no symbols by default
@@ -66,12 +66,12 @@ def _make_schematic(*wires) -> MagicMock:
class TestSchema: class TestSchema:
"""Verify the get_wire_connections tool schema is present and well-formed.""" """Verify the get_wire_connections tool schema is present and well-formed."""
def test_schema_registered(self): def test_schema_registered(self) -> None:
from schemas.tool_schemas import TOOL_SCHEMAS from schemas.tool_schemas import TOOL_SCHEMAS
assert "get_wire_connections" in TOOL_SCHEMAS assert "get_wire_connections" in TOOL_SCHEMAS
def test_schema_required_fields(self): def test_schema_required_fields(self) -> None:
from schemas.tool_schemas import TOOL_SCHEMAS from schemas.tool_schemas import TOOL_SCHEMAS
schema = TOOL_SCHEMAS["get_wire_connections"] schema = TOOL_SCHEMAS["get_wire_connections"]
@@ -80,7 +80,7 @@ class TestSchema:
assert "x" in required assert "x" in required
assert "y" in required assert "y" in required
def test_schema_has_title_and_description(self): def test_schema_has_title_and_description(self) -> None:
from schemas.tool_schemas import TOOL_SCHEMAS from schemas.tool_schemas import TOOL_SCHEMAS
schema = TOOL_SCHEMAS["get_wire_connections"] schema = TOOL_SCHEMAS["get_wire_connections"]
@@ -97,7 +97,7 @@ class TestSchema:
class TestHandlerDispatch: class TestHandlerDispatch:
"""Verify the handler is wired into KiCadInterface.command_routes.""" """Verify the handler is wired into KiCadInterface.command_routes."""
def test_get_wire_connections_in_routes(self): def test_get_wire_connections_in_routes(self) -> None:
# Import lazily to avoid heavy side-effects at collection time # Import lazily to avoid heavy side-effects at collection time
with patch("kicad_interface.USE_IPC_BACKEND", False): with patch("kicad_interface.USE_IPC_BACKEND", False):
from kicad_interface import KiCADInterface from kicad_interface import KiCADInterface
@@ -116,7 +116,7 @@ class TestHandlerDispatch:
# Build routes only (avoid full __init__ side-effects) # Build routes only (avoid full __init__ side-effects)
# The routes dict is built in __init__; we call it directly. # The routes dict is built in __init__; we call it directly.
iface.__init__() KiCADInterface.__init__(iface)
assert "get_wire_connections" in iface.command_routes assert "get_wire_connections" in iface.command_routes
assert callable(iface.command_routes["get_wire_connections"]) assert callable(iface.command_routes["get_wire_connections"])
@@ -131,7 +131,7 @@ class TestHandlerDispatch:
class TestHandlerParamValidation: class TestHandlerParamValidation:
"""Handler returns error responses for bad or missing parameters.""" """Handler returns error responses for bad or missing parameters."""
def _make_handler(self): def _make_handler(self) -> Any:
"""Return a bound _handle_get_wire_connections without full init.""" """Return a bound _handle_get_wire_connections without full init."""
with patch("kicad_interface.USE_IPC_BACKEND", False): with patch("kicad_interface.USE_IPC_BACKEND", False):
from kicad_interface import KiCADInterface from kicad_interface import KiCADInterface
@@ -139,29 +139,29 @@ class TestHandlerParamValidation:
iface = KiCADInterface.__new__(KiCADInterface) iface = KiCADInterface.__new__(KiCADInterface)
return iface._handle_get_wire_connections return iface._handle_get_wire_connections
def test_missing_schematic_path(self): def test_missing_schematic_path(self) -> None:
handler = self._make_handler() handler = self._make_handler()
result = handler({"x": 1.0, "y": 2.0}) result = handler({"x": 1.0, "y": 2.0})
assert result["success"] is False assert result["success"] is False
assert "schematicPath" in result["message"] or "Missing" in result["message"] assert "schematicPath" in result["message"] or "Missing" in result["message"]
def test_missing_x(self): def test_missing_x(self) -> None:
handler = self._make_handler() handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 2.0}) result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 2.0})
assert result["success"] is False assert result["success"] is False
def test_missing_y(self): def test_missing_y(self) -> None:
handler = self._make_handler() handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0}) result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0})
assert result["success"] is False assert result["success"] is False
def test_non_numeric_x(self): def test_non_numeric_x(self) -> None:
handler = self._make_handler() handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "bad", "y": 2.0}) result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "bad", "y": 2.0})
assert result["success"] is False assert result["success"] is False
assert "numeric" in result["message"].lower() or "x" in result["message"] assert "numeric" in result["message"].lower() or "x" in result["message"]
def test_non_numeric_y(self): def test_non_numeric_y(self) -> None:
handler = self._make_handler() handler = self._make_handler()
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0, "y": "bad"}) result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0, "y": "bad"})
assert result["success"] is False assert result["success"] is False
@@ -180,39 +180,39 @@ class TestCoreLogic:
# --- _to_iu --- # --- _to_iu ---
def test_to_iu_integer_mm(self): def test_to_iu_integer_mm(self) -> None:
assert _to_iu(1.0, 2.0) == (10_000, 20_000) assert _to_iu(1.0, 2.0) == (10_000, 20_000)
def test_to_iu_fractional_mm(self): def test_to_iu_fractional_mm(self) -> None:
assert _to_iu(0.5, 0.25) == (5_000, 2_500) assert _to_iu(0.5, 0.25) == (5_000, 2_500)
def test_to_iu_zero(self): def test_to_iu_zero(self) -> None:
assert _to_iu(0.0, 0.0) == (0, 0) assert _to_iu(0.0, 0.0) == (0, 0)
def test_to_iu_negative(self): def test_to_iu_negative(self) -> None:
assert _to_iu(-1.0, -2.0) == (-10_000, -20_000) assert _to_iu(-1.0, -2.0) == (-10_000, -20_000)
# --- _parse_wires --- # --- _parse_wires ---
def test_parse_wires_single_wire(self): def test_parse_wires_single_wire(self) -> None:
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
result = _parse_wires(sch) result = _parse_wires(sch)
assert len(result) == 1 assert len(result) == 1
assert result[0] == [(0, 0), (10_000, 0)] assert result[0] == [(0, 0), (10_000, 0)]
def test_parse_wires_empty_schematic(self): def test_parse_wires_empty_schematic(self) -> None:
sch = MagicMock() sch = MagicMock()
sch.wire = [] sch.wire = []
assert _parse_wires(sch) == [] assert _parse_wires(sch) == []
def test_parse_wires_multiple_wires(self): def test_parse_wires_multiple_wires(self) -> None:
sch = _make_schematic( sch = _make_schematic(
_make_wire(0.0, 0.0, 1.0, 0.0), _make_wire(0.0, 0.0, 1.0, 0.0),
_make_wire(1.0, 0.0, 2.0, 0.0), _make_wire(1.0, 0.0, 2.0, 0.0),
) )
assert len(_parse_wires(sch)) == 2 assert len(_parse_wires(sch)) == 2
def test_parse_wires_skips_wire_without_pts(self): def test_parse_wires_skips_wire_without_pts(self) -> None:
bad_wire = MagicMock(spec=[]) # no `pts` attribute bad_wire = MagicMock(spec=[]) # no `pts` attribute
sch = MagicMock() sch = MagicMock()
sch.wire = [bad_wire] sch.wire = [bad_wire]
@@ -220,7 +220,7 @@ class TestCoreLogic:
# --- _build_adjacency --- # --- _build_adjacency ---
def test_build_adjacency_two_connected_wires(self): def test_build_adjacency_two_connected_wires(self) -> None:
# wire0: (0,0)-(1,0), wire1: (1,0)-(2,0) — share endpoint (1,0) # wire0: (0,0)-(1,0), wire1: (1,0)-(2,0) — share endpoint (1,0)
wires = [ wires = [
[(0, 0), (10_000, 0)], [(0, 0), (10_000, 0)],
@@ -230,7 +230,7 @@ class TestCoreLogic:
assert 1 in adjacency[0] assert 1 in adjacency[0]
assert 0 in adjacency[1] assert 0 in adjacency[1]
def test_build_adjacency_two_disconnected_wires(self): def test_build_adjacency_two_disconnected_wires(self) -> None:
wires = [ wires = [
[(0, 0), (10_000, 0)], [(0, 0), (10_000, 0)],
[(20_000, 0), (30_000, 0)], [(20_000, 0), (30_000, 0)],
@@ -239,7 +239,7 @@ class TestCoreLogic:
assert adjacency[0] == set() assert adjacency[0] == set()
assert adjacency[1] == set() assert adjacency[1] == set()
def test_build_adjacency_iu_to_wires_maps_correctly(self): def test_build_adjacency_iu_to_wires_maps_correctly(self) -> None:
wires = [ wires = [
[(0, 0), (10_000, 0)], [(0, 0), (10_000, 0)],
[(10_000, 0), (20_000, 0)], [(10_000, 0), (20_000, 0)],
@@ -248,7 +248,7 @@ class TestCoreLogic:
assert iu_to_wires[(10_000, 0)] == {0, 1} assert iu_to_wires[(10_000, 0)] == {0, 1}
assert iu_to_wires[(0, 0)] == {0} assert iu_to_wires[(0, 0)] == {0}
def test_build_adjacency_three_wires_at_junction(self): def test_build_adjacency_three_wires_at_junction(self) -> None:
# All three wires meet at (10,000, 0) # All three wires meet at (10,000, 0)
wires = [ wires = [
[(0, 0), (10_000, 0)], [(0, 0), (10_000, 0)],
@@ -262,14 +262,14 @@ class TestCoreLogic:
# --- _find_connected_wires --- # --- _find_connected_wires ---
def test_find_connected_wires_no_wire_at_point(self): def test_find_connected_wires_no_wire_at_point(self) -> None:
wires = [[(0, 0), (10_000, 0)]] wires = [[(0, 0), (10_000, 0)]]
adjacency, iu_to_wires = _build_adjacency(wires) adjacency, iu_to_wires = _build_adjacency(wires)
visited, net_points = _find_connected_wires(5.0, 0.0, wires, iu_to_wires, adjacency) visited, net_points = _find_connected_wires(5.0, 0.0, wires, iu_to_wires, adjacency)
assert visited is None assert visited is None
assert net_points is None assert net_points is None
def test_find_connected_wires_single_wire(self): def test_find_connected_wires_single_wire(self) -> None:
wires = [[(0, 0), (10_000, 0)]] wires = [[(0, 0), (10_000, 0)]]
adjacency, iu_to_wires = _build_adjacency(wires) adjacency, iu_to_wires = _build_adjacency(wires)
visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency)
@@ -277,7 +277,7 @@ class TestCoreLogic:
assert (0, 0) in net_points assert (0, 0) in net_points
assert (10_000, 0) in net_points assert (10_000, 0) in net_points
def test_find_connected_wires_flood_fills_chain(self): def test_find_connected_wires_flood_fills_chain(self) -> None:
# Three wires in a chain: A-B-C-D # Three wires in a chain: A-B-C-D
wires = [ wires = [
[(0, 0), (10_000, 0)], [(0, 0), (10_000, 0)],
@@ -288,7 +288,7 @@ class TestCoreLogic:
visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency) visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency)
assert visited == {0, 1, 2} assert visited == {0, 1, 2}
def test_find_connected_wires_does_not_cross_gap(self): def test_find_connected_wires_does_not_cross_gap(self) -> None:
# Two disconnected segments; query on segment 0 should not reach segment 1 # Two disconnected segments; query on segment 0 should not reach segment 1
wires = [ wires = [
[(0, 0), (10_000, 0)], [(0, 0), (10_000, 0)],
@@ -300,18 +300,18 @@ class TestCoreLogic:
# --- get_wire_connections (integration of internal functions) --- # --- get_wire_connections (integration of internal functions) ---
def test_get_wire_connections_no_wires(self): def test_get_wire_connections_no_wires(self) -> None:
sch = MagicMock() sch = MagicMock()
sch.wire = [] sch.wire = []
result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0)
assert result == {"pins": [], "wires": []} assert result == {"pins": [], "wires": []}
def test_get_wire_connections_no_wire_at_point_returns_none(self): def test_get_wire_connections_no_wire_at_point_returns_none(self) -> None:
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
result = get_wire_connections(sch, "/fake/path.kicad_sch", 5.0, 0.0) result = get_wire_connections(sch, "/fake/path.kicad_sch", 5.0, 0.0)
assert result is None assert result is None
def test_get_wire_connections_returns_wire_data(self): def test_get_wire_connections_returns_wire_data(self) -> None:
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
# Prevent _find_pins_on_net from iterating symbols # Prevent _find_pins_on_net from iterating symbols
result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0) result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0)
@@ -322,7 +322,7 @@ class TestCoreLogic:
assert wire["start"] == {"x": 0.0, "y": 0.0} assert wire["start"] == {"x": 0.0, "y": 0.0}
assert wire["end"] == {"x": 1.0, "y": 0.0} assert wire["end"] == {"x": 1.0, "y": 0.0}
def test_get_wire_connections_chain_returns_all_wires(self): def test_get_wire_connections_chain_returns_all_wires(self) -> None:
sch = _make_schematic( sch = _make_schematic(
_make_wire(0.0, 0.0, 1.0, 0.0), _make_wire(0.0, 0.0, 1.0, 0.0),
_make_wire(1.0, 0.0, 2.0, 0.0), _make_wire(1.0, 0.0, 2.0, 0.0),

View File

@@ -10,6 +10,7 @@ import shutil
import sys import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
@@ -29,20 +30,20 @@ EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _make_temp_sch(): def _make_temp_sch() -> Any:
"""Copy the empty schematic template to a temp file and return the Path.""" """Copy the empty schematic template to a temp file and return the Path."""
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch" tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
shutil.copy(EMPTY_SCH, tmp) shutil.copy(EMPTY_SCH, tmp)
return tmp return tmp
def _parse_sch(path: Path): def _parse_sch(path: Path) -> Any:
"""Parse a .kicad_sch file and return the S-expression list.""" """Parse a .kicad_sch file and return the S-expression list."""
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
return sexpdata.loads(f.read()) return sexpdata.loads(f.read())
def _find_elements(sch_data, tag: str): def _find_elements(sch_data: Any, tag: str) -> Any:
"""Return all top-level S-expression elements with the given tag Symbol.""" """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)] return [item for item in sch_data if isinstance(item, list) and item and item[0] == Symbol(tag)]
@@ -56,22 +57,22 @@ class TestSchemas:
"""Verify tool_schemas.py reflects the new API.""" """Verify tool_schemas.py reflects the new API."""
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def load_schemas(self): def load_schemas(self) -> Any:
from schemas.tool_schemas import SCHEMATIC_TOOLS from schemas.tool_schemas import SCHEMATIC_TOOLS
self.tools = {t["name"]: t for t in SCHEMATIC_TOOLS} self.tools = {t["name"]: t for t in SCHEMATIC_TOOLS}
def test_add_schematic_wire_has_waypoints(self): def test_add_schematic_wire_has_waypoints(self) -> None:
schema = self.tools["add_schematic_wire"]["inputSchema"] schema = self.tools["add_schematic_wire"]["inputSchema"]
assert "waypoints" in schema["properties"], "waypoints must be a property" assert "waypoints" in schema["properties"], "waypoints must be a property"
assert "waypoints" in schema["required"] assert "waypoints" in schema["required"]
def test_add_schematic_wire_has_schematic_path(self): def test_add_schematic_wire_has_schematic_path(self) -> None:
schema = self.tools["add_schematic_wire"]["inputSchema"] schema = self.tools["add_schematic_wire"]["inputSchema"]
assert "schematicPath" in schema["properties"] assert "schematicPath" in schema["properties"]
assert "schematicPath" in schema["required"] assert "schematicPath" in schema["required"]
def test_add_schematic_wire_has_snap_params(self): def test_add_schematic_wire_has_snap_params(self) -> None:
schema = self.tools["add_schematic_wire"]["inputSchema"] schema = self.tools["add_schematic_wire"]["inputSchema"]
props = schema["properties"] props = schema["properties"]
assert "snapToPins" in props assert "snapToPins" in props
@@ -79,28 +80,28 @@ class TestSchemas:
assert "snapTolerance" in props assert "snapTolerance" in props
assert props["snapTolerance"]["type"] == "number" assert props["snapTolerance"]["type"] == "number"
def test_add_schematic_wire_no_old_point_params(self): def test_add_schematic_wire_no_old_point_params(self) -> None:
schema = self.tools["add_schematic_wire"]["inputSchema"] schema = self.tools["add_schematic_wire"]["inputSchema"]
props = schema["properties"] props = schema["properties"]
assert "startPoint" not in props, "startPoint should be removed" assert "startPoint" not in props, "startPoint should be removed"
assert "endPoint" not in props, "endPoint should be removed" assert "endPoint" not in props, "endPoint should be removed"
def test_add_schematic_connection_removed(self): def test_add_schematic_connection_removed(self) -> None:
assert ( assert (
"add_schematic_connection" not in self.tools "add_schematic_connection" not in self.tools
), "add_schematic_connection must not appear in SCHEMATIC_TOOLS" ), "add_schematic_connection must not appear in SCHEMATIC_TOOLS"
def test_add_schematic_junction_present(self): def test_add_schematic_junction_present(self) -> None:
assert "add_schematic_junction" in self.tools assert "add_schematic_junction" in self.tools
def test_add_schematic_junction_schema(self): def test_add_schematic_junction_schema(self) -> None:
schema = self.tools["add_schematic_junction"]["inputSchema"] schema = self.tools["add_schematic_junction"]["inputSchema"]
props = schema["properties"] props = schema["properties"]
assert "schematicPath" in props assert "schematicPath" in props
assert "position" in props assert "position" in props
assert set(schema["required"]) >= {"schematicPath", "position"} assert set(schema["required"]) >= {"schematicPath", "position"}
def test_add_schematic_junction_position_is_array(self): def test_add_schematic_junction_position_is_array(self) -> None:
schema = self.tools["add_schematic_junction"]["inputSchema"] schema = self.tools["add_schematic_junction"]["inputSchema"]
pos = schema["properties"]["position"] pos = schema["properties"]["position"]
assert pos["type"] == "array" assert pos["type"] == "array"
@@ -117,7 +118,7 @@ class TestHandlerDispatch:
"""Verify KiCADInterface registers the right tool handlers.""" """Verify KiCADInterface registers the right tool handlers."""
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def load_handler_map(self): def load_handler_map(self) -> Any:
# Import only the dispatch table without initialising KiCAD connections # Import only the dispatch table without initialising KiCAD connections
import importlib import importlib
import types import types
@@ -141,18 +142,18 @@ class TestHandlerDispatch:
} }
self.handlers = obj._tool_handlers self.handlers = obj._tool_handlers
def test_add_schematic_wire_registered(self): def test_add_schematic_wire_registered(self) -> None:
from kicad_interface import KiCADInterface from kicad_interface import KiCADInterface
# Just verify the class has the handler method # Just verify the class has the handler method
assert hasattr(KiCADInterface, "_handle_add_schematic_wire") assert hasattr(KiCADInterface, "_handle_add_schematic_wire")
def test_add_schematic_junction_registered(self): def test_add_schematic_junction_registered(self) -> None:
from kicad_interface import KiCADInterface from kicad_interface import KiCADInterface
assert hasattr(KiCADInterface, "_handle_add_schematic_junction") assert hasattr(KiCADInterface, "_handle_add_schematic_junction")
def test_add_schematic_connection_not_present(self): def test_add_schematic_connection_not_present(self) -> None:
from kicad_interface import KiCADInterface from kicad_interface import KiCADInterface
assert not hasattr( assert not hasattr(
@@ -169,7 +170,7 @@ class TestHandleAddSchematicWireValidation:
"""Unit tests for _handle_add_schematic_wire validation paths (no disk I/O).""" """Unit tests for _handle_add_schematic_wire validation paths (no disk I/O)."""
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def handler(self): def handler(self) -> Any:
import types import types
for mod in ["pcbnew", "skip"]: for mod in ["pcbnew", "skip"]:
@@ -179,17 +180,17 @@ class TestHandleAddSchematicWireValidation:
with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None):
self.iface = KiCADInterface.__new__(KiCADInterface) self.iface = KiCADInterface.__new__(KiCADInterface)
def test_missing_schematic_path(self): def test_missing_schematic_path(self) -> None:
result = self.iface._handle_add_schematic_wire({"waypoints": [[0, 0], [10, 0]]}) result = self.iface._handle_add_schematic_wire({"waypoints": [[0, 0], [10, 0]]})
assert result["success"] is False assert result["success"] is False
assert "Schematic path" in result["message"] assert "Schematic path" in result["message"]
def test_missing_waypoints(self): def test_missing_waypoints(self) -> None:
result = self.iface._handle_add_schematic_wire({"schematicPath": "/tmp/x.kicad_sch"}) result = self.iface._handle_add_schematic_wire({"schematicPath": "/tmp/x.kicad_sch"})
assert result["success"] is False assert result["success"] is False
assert "waypoint" in result["message"].lower() assert "waypoint" in result["message"].lower()
def test_single_waypoint_rejected(self): def test_single_waypoint_rejected(self) -> None:
result = self.iface._handle_add_schematic_wire( result = self.iface._handle_add_schematic_wire(
{ {
"schematicPath": "/tmp/x.kicad_sch", "schematicPath": "/tmp/x.kicad_sch",
@@ -209,7 +210,7 @@ class TestHandleAddSchematicWireRouting:
"""Unit tests verifying add_wire vs add_polyline_wire dispatch, no pin snapping.""" """Unit tests verifying add_wire vs add_polyline_wire dispatch, no pin snapping."""
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup(self): def setup(self) -> Any:
import types import types
for mod in ["pcbnew", "skip"]: for mod in ["pcbnew", "skip"]:
@@ -224,7 +225,7 @@ class TestHandleAddSchematicWireRouting:
shutil.rmtree(self.sch_path.parent, ignore_errors=True) shutil.rmtree(self.sch_path.parent, ignore_errors=True)
@patch("commands.wire_manager.WireManager.add_wire", return_value=True) @patch("commands.wire_manager.WireManager.add_wire", return_value=True)
def test_two_waypoints_calls_add_wire(self, mock_add_wire): def test_two_waypoints_calls_add_wire(self, mock_add_wire: Any) -> None:
result = self.iface._handle_add_schematic_wire( result = self.iface._handle_add_schematic_wire(
{ {
"schematicPath": str(self.sch_path), "schematicPath": str(self.sch_path),
@@ -239,7 +240,7 @@ class TestHandleAddSchematicWireRouting:
assert args[2] == [30.0, 20.0] assert args[2] == [30.0, 20.0]
@patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) @patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True)
def test_four_waypoints_calls_add_polyline_wire(self, mock_poly): def test_four_waypoints_calls_add_polyline_wire(self, mock_poly: Any) -> None:
result = self.iface._handle_add_schematic_wire( result = self.iface._handle_add_schematic_wire(
{ {
"schematicPath": str(self.sch_path), "schematicPath": str(self.sch_path),
@@ -250,7 +251,7 @@ class TestHandleAddSchematicWireRouting:
assert result["success"] is True assert result["success"] is True
mock_poly.assert_called_once() mock_poly.assert_called_once()
def test_points_key_without_waypoints_is_rejected(self): def test_points_key_without_waypoints_is_rejected(self) -> None:
"""'points' key alone (without 'waypoints') is rejected — no fallback.""" """'points' key alone (without 'waypoints') is rejected — no fallback."""
result = self.iface._handle_add_schematic_wire( result = self.iface._handle_add_schematic_wire(
{ {
@@ -263,7 +264,7 @@ class TestHandleAddSchematicWireRouting:
assert "waypoint" in result["message"].lower() assert "waypoint" in result["message"].lower()
@patch("commands.wire_manager.WireManager.add_wire", return_value=False) @patch("commands.wire_manager.WireManager.add_wire", return_value=False)
def test_failure_response(self, _): def test_failure_response(self, _: Any) -> None:
result = self.iface._handle_add_schematic_wire( result = self.iface._handle_add_schematic_wire(
{ {
"schematicPath": str(self.sch_path), "schematicPath": str(self.sch_path),
@@ -283,18 +284,18 @@ class TestPinSnapping:
"""Verify pin snapping logic snaps endpoints correctly.""" """Verify pin snapping logic snaps endpoints correctly."""
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup(self): def setup(self) -> Any:
import types import types
# Provide a minimal skip.Schematic stub so the handler can import it # Provide a minimal skip.Schematic stub so the handler can import it
skip_mod = types.ModuleType("skip") skip_mod = types.ModuleType("skip")
class FakeSchematic: class FakeSchematic:
def __init__(self, path): def __init__(self, path: Any) -> None:
pass pass
@property @property
def symbol(self): def symbol(self) -> list[Any]:
return [] # no symbols → no pins in snapping loop return [] # no symbols → no pins in snapping loop
skip_mod.Schematic = FakeSchematic skip_mod.Schematic = FakeSchematic
@@ -312,7 +313,7 @@ class TestPinSnapping:
@patch("commands.wire_manager.WireManager.add_wire", return_value=True) @patch("commands.wire_manager.WireManager.add_wire", return_value=True)
@patch("commands.pin_locator.PinLocator.get_all_symbol_pins") @patch("commands.pin_locator.PinLocator.get_all_symbol_pins")
def test_start_point_snapped_within_tolerance(self, mock_pins, mock_wire): 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.""" """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. # 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 # Instead we patch find_nearest_pin indirectly by providing all_pins via the
@@ -326,7 +327,7 @@ class TestPinSnapping:
class Reference: class Reference:
value = "R1" value = "R1"
def __init__(self): def __init__(self) -> None:
pass pass
skip_mod.Schematic = lambda path: type("FakeSch", (), {"symbol": [FakeSymbol()]})() skip_mod.Schematic = lambda path: type("FakeSch", (), {"symbol": [FakeSymbol()]})()
@@ -361,7 +362,7 @@ class TestPinSnapping:
], f"Start should snap to [10.0, 20.0], got {called_start}" ], f"Start should snap to [10.0, 20.0], got {called_start}"
# If it failed due to stub issues, just verify no exception # If it failed due to stub issues, just verify no exception
def test_snap_disabled_passes_original_coords(self): def test_snap_disabled_passes_original_coords(self) -> None:
"""With snapToPins=False the handler should not load PinLocator at all.""" """With snapToPins=False the handler should not load PinLocator at all."""
with ( with (
patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw, patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw,
@@ -380,7 +381,7 @@ class TestPinSnapping:
assert called_start == [1.0, 2.0] assert called_start == [1.0, 2.0]
@patch("commands.wire_manager.WireManager.add_wire", return_value=True) @patch("commands.wire_manager.WireManager.add_wire", return_value=True)
def test_snap_miss_leaves_coords_unchanged(self, mock_wire): def test_snap_miss_leaves_coords_unchanged(self, mock_wire: Any) -> None:
"""Point beyond tolerance should not be snapped.""" """Point beyond tolerance should not be snapped."""
with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw: with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw:
result = self.iface._handle_add_schematic_wire( result = self.iface._handle_add_schematic_wire(
@@ -397,7 +398,7 @@ class TestPinSnapping:
assert "snapped" not in result.get("message", "") assert "snapped" not in result.get("message", "")
@patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) @patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True)
def test_intermediate_waypoints_not_snapped(self, mock_poly): def test_intermediate_waypoints_not_snapped(self, mock_poly: Any) -> None:
"""Middle waypoints must remain unchanged even with snapToPins=True.""" """Middle waypoints must remain unchanged even with snapToPins=True."""
mid = [50.0, 50.0] mid = [50.0, 50.0]
with patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) as mp: with patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) as mp:
@@ -424,7 +425,7 @@ class TestPinSnapping:
class TestHandleAddSchematicJunction: class TestHandleAddSchematicJunction:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup(self): def setup(self) -> Any:
import types import types
for mod in ["pcbnew", "skip"]: for mod in ["pcbnew", "skip"]:
@@ -434,18 +435,18 @@ class TestHandleAddSchematicJunction:
with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None):
self.iface = KiCADInterface.__new__(KiCADInterface) self.iface = KiCADInterface.__new__(KiCADInterface)
def test_missing_schematic_path(self): def test_missing_schematic_path(self) -> None:
result = self.iface._handle_add_schematic_junction({"position": [10.0, 20.0]}) result = self.iface._handle_add_schematic_junction({"position": [10.0, 20.0]})
assert result["success"] is False assert result["success"] is False
assert "Schematic path" in result["message"] assert "Schematic path" in result["message"]
def test_missing_position(self): def test_missing_position(self) -> None:
result = self.iface._handle_add_schematic_junction({"schematicPath": "/tmp/x.kicad_sch"}) result = self.iface._handle_add_schematic_junction({"schematicPath": "/tmp/x.kicad_sch"})
assert result["success"] is False assert result["success"] is False
assert "Position" in result["message"] assert "Position" in result["message"]
@patch("commands.wire_manager.WireManager.add_junction", return_value=True) @patch("commands.wire_manager.WireManager.add_junction", return_value=True)
def test_success(self, mock_jct): def test_success(self, mock_jct: Any) -> None:
sch = _make_temp_sch() sch = _make_temp_sch()
try: try:
result = self.iface._handle_add_schematic_junction( result = self.iface._handle_add_schematic_junction(
@@ -461,7 +462,7 @@ class TestHandleAddSchematicJunction:
shutil.rmtree(sch.parent, ignore_errors=True) shutil.rmtree(sch.parent, ignore_errors=True)
@patch("commands.wire_manager.WireManager.add_junction", return_value=False) @patch("commands.wire_manager.WireManager.add_junction", return_value=False)
def test_failure(self, _): def test_failure(self, _: Any) -> None:
sch = _make_temp_sch() sch = _make_temp_sch()
try: try:
result = self.iface._handle_add_schematic_junction( result = self.iface._handle_add_schematic_junction(
@@ -483,21 +484,21 @@ class TestHandleAddSchematicJunction:
class TestConnectionManagerOrphanedMethodsRemoved: class TestConnectionManagerOrphanedMethodsRemoved:
def test_add_wire_removed(self): def test_add_wire_removed(self) -> None:
from commands.connection_schematic import ConnectionManager from commands.connection_schematic import ConnectionManager
assert not hasattr( assert not hasattr(
ConnectionManager, "add_wire" ConnectionManager, "add_wire"
), "ConnectionManager.add_wire should have been removed" ), "ConnectionManager.add_wire should have been removed"
def test_add_connection_removed(self): def test_add_connection_removed(self) -> None:
from commands.connection_schematic import ConnectionManager from commands.connection_schematic import ConnectionManager
assert not hasattr( assert not hasattr(
ConnectionManager, "add_connection" ConnectionManager, "add_connection"
), "ConnectionManager.add_connection should have been removed" ), "ConnectionManager.add_connection should have been removed"
def test_get_pin_location_removed(self): def test_get_pin_location_removed(self) -> None:
from commands.connection_schematic import ConnectionManager from commands.connection_schematic import ConnectionManager
assert not hasattr( assert not hasattr(
@@ -515,12 +516,12 @@ class TestIntegrationWireManager:
"""Integration tests using real schematic files and WireManager.""" """Integration tests using real schematic files and WireManager."""
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def sch(self): def sch(self) -> Any:
path = _make_temp_sch() path = _make_temp_sch()
yield path yield path
shutil.rmtree(path.parent, ignore_errors=True) shutil.rmtree(path.parent, ignore_errors=True)
def test_add_wire_writes_wire_element(self, sch): def test_add_wire_writes_wire_element(self, sch: Any) -> None:
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
ok = WireManager.add_wire(sch, [10.0, 10.0], [30.0, 10.0]) ok = WireManager.add_wire(sch, [10.0, 10.0], [30.0, 10.0])
@@ -529,7 +530,7 @@ class TestIntegrationWireManager:
wires = _find_elements(data, "wire") wires = _find_elements(data, "wire")
assert len(wires) == 1 assert len(wires) == 1
def test_add_polyline_wire_creates_segments(self, sch): def test_add_polyline_wire_creates_segments(self, sch: Any) -> None:
"""N waypoints should produce N-1 individual 2-point wire segments.""" """N waypoints should produce N-1 individual 2-point wire segments."""
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
@@ -540,7 +541,7 @@ class TestIntegrationWireManager:
wires = _find_elements(data, "wire") wires = _find_elements(data, "wire")
assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}" assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}"
def test_add_junction_writes_junction_element(self, sch): def test_add_junction_writes_junction_element(self, sch: Any) -> None:
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
ok = WireManager.add_junction(sch, [25.4, 25.4]) ok = WireManager.add_junction(sch, [25.4, 25.4])
@@ -553,7 +554,7 @@ class TestIntegrationWireManager:
assert at[1] == 25.4 assert at[1] == 25.4
assert at[2] == 25.4 assert at[2] == 25.4
def test_wire_endpoint_coordinates_match(self, sch): def test_wire_endpoint_coordinates_match(self, sch: Any) -> None:
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
WireManager.add_wire(sch, [5.0, 7.5], [15.0, 7.5]) WireManager.add_wire(sch, [5.0, 7.5], [15.0, 7.5])
@@ -576,7 +577,7 @@ class TestIntegrationHandlerEndToEnd:
"""Integration tests for KiCADInterface handlers writing to real schematic files.""" """Integration tests for KiCADInterface handlers writing to real schematic files."""
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup(self): def setup(self) -> Any:
import types import types
for mod in ["pcbnew", "skip"]: for mod in ["pcbnew", "skip"]:
@@ -589,7 +590,7 @@ class TestIntegrationHandlerEndToEnd:
yield yield
shutil.rmtree(self.sch.parent, ignore_errors=True) shutil.rmtree(self.sch.parent, ignore_errors=True)
def test_junction_handler_writes_junction(self): def test_junction_handler_writes_junction(self) -> None:
result = self.iface._handle_add_schematic_junction( result = self.iface._handle_add_schematic_junction(
{ {
"schematicPath": str(self.sch), "schematicPath": str(self.sch),
@@ -601,7 +602,7 @@ class TestIntegrationHandlerEndToEnd:
junctions = _find_elements(data, "junction") junctions = _find_elements(data, "junction")
assert len(junctions) == 1 assert len(junctions) == 1
def test_wire_handler_two_points_writes_wire(self): def test_wire_handler_two_points_writes_wire(self) -> None:
result = self.iface._handle_add_schematic_wire( result = self.iface._handle_add_schematic_wire(
{ {
"schematicPath": str(self.sch), "schematicPath": str(self.sch),
@@ -614,7 +615,7 @@ class TestIntegrationHandlerEndToEnd:
wires = _find_elements(data, "wire") wires = _find_elements(data, "wire")
assert len(wires) == 1 assert len(wires) == 1
def test_wire_handler_four_points_creates_three_segments(self): def test_wire_handler_four_points_creates_three_segments(self) -> None:
result = self.iface._handle_add_schematic_wire( result = self.iface._handle_add_schematic_wire(
{ {
"schematicPath": str(self.sch), "schematicPath": str(self.sch),
@@ -638,64 +639,64 @@ class TestPointStrictlyOnWire:
"""Unit tests for WireManager._point_strictly_on_wire geometry helper.""" """Unit tests for WireManager._point_strictly_on_wire geometry helper."""
@staticmethod @staticmethod
def _fn(px, py, x1, y1, x2, y2, eps=1e-6): 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 from commands.wire_manager import WireManager
return WireManager._point_strictly_on_wire(px, py, x1, y1, x2, y2, eps) return WireManager._point_strictly_on_wire(px, py, x1, y1, x2, y2, eps)
def test_horizontal_midpoint(self): def test_horizontal_midpoint(self) -> None:
assert self._fn(5, 0, 0, 0, 10, 0) is True assert self._fn(5, 0, 0, 0, 10, 0) is True
def test_vertical_midpoint(self): def test_vertical_midpoint(self) -> None:
assert self._fn(0, 5, 0, 0, 0, 10) is True assert self._fn(0, 5, 0, 0, 0, 10) is True
def test_horizontal_at_start_endpoint(self): def test_horizontal_at_start_endpoint(self) -> None:
"""Point at wire start should NOT be strictly on wire.""" """Point at wire start should NOT be strictly on wire."""
assert self._fn(0, 0, 0, 0, 10, 0) is False assert self._fn(0, 0, 0, 0, 10, 0) is False
def test_horizontal_at_end_endpoint(self): def test_horizontal_at_end_endpoint(self) -> None:
"""Point at wire end should NOT be strictly on wire.""" """Point at wire end should NOT be strictly on wire."""
assert self._fn(10, 0, 0, 0, 10, 0) is False assert self._fn(10, 0, 0, 0, 10, 0) is False
def test_vertical_at_start_endpoint(self): def test_vertical_at_start_endpoint(self) -> None:
assert self._fn(0, 0, 0, 0, 0, 10) is False assert self._fn(0, 0, 0, 0, 0, 10) is False
def test_vertical_at_end_endpoint(self): def test_vertical_at_end_endpoint(self) -> None:
assert self._fn(0, 10, 0, 0, 0, 10) is False assert self._fn(0, 10, 0, 0, 0, 10) is False
def test_point_off_horizontal_wire(self): def test_point_off_horizontal_wire(self) -> None:
"""Point above a horizontal wire.""" """Point above a horizontal wire."""
assert self._fn(5, 1, 0, 0, 10, 0) is False assert self._fn(5, 1, 0, 0, 10, 0) is False
def test_point_off_vertical_wire(self): def test_point_off_vertical_wire(self) -> None:
"""Point to the right of a vertical wire.""" """Point to the right of a vertical wire."""
assert self._fn(1, 5, 0, 0, 0, 10) is False assert self._fn(1, 5, 0, 0, 0, 10) is False
def test_point_beyond_horizontal_wire(self): def test_point_beyond_horizontal_wire(self) -> None:
"""Point collinear but past the end of a horizontal wire.""" """Point collinear but past the end of a horizontal wire."""
assert self._fn(15, 0, 0, 0, 10, 0) is False assert self._fn(15, 0, 0, 0, 10, 0) is False
def test_point_beyond_vertical_wire(self): def test_point_beyond_vertical_wire(self) -> None:
"""Point collinear but past the end of a vertical wire.""" """Point collinear but past the end of a vertical wire."""
assert self._fn(0, 15, 0, 0, 0, 10) is False assert self._fn(0, 15, 0, 0, 0, 10) is False
def test_diagonal_wire_always_false(self): def test_diagonal_wire_always_false(self) -> None:
"""Only horizontal/vertical wires are handled; diagonal → False.""" """Only horizontal/vertical wires are handled; diagonal → False."""
assert self._fn(5, 5, 0, 0, 10, 10) is False assert self._fn(5, 5, 0, 0, 10, 10) is False
def test_reversed_horizontal_endpoints(self): def test_reversed_horizontal_endpoints(self) -> None:
"""Wire endpoints reversed (x2 < x1) should still work.""" """Wire endpoints reversed (x2 < x1) should still work."""
assert self._fn(5, 0, 10, 0, 0, 0) is True assert self._fn(5, 0, 10, 0, 0, 0) is True
def test_reversed_vertical_endpoints(self): def test_reversed_vertical_endpoints(self) -> None:
"""Wire endpoints reversed (y2 < y1) should still work.""" """Wire endpoints reversed (y2 < y1) should still work."""
assert self._fn(0, 5, 0, 10, 0, 0) is True assert self._fn(0, 5, 0, 10, 0, 0) is True
def test_near_endpoint_within_epsilon(self): def test_near_endpoint_within_epsilon(self) -> None:
"""Point within epsilon of endpoint should NOT be considered strictly on wire.""" """Point within epsilon of endpoint should NOT be considered strictly on wire."""
assert self._fn(1e-7, 0, 0, 0, 10, 0) is False assert self._fn(1e-7, 0, 0, 0, 10, 0) is False
def test_zero_length_wire(self): def test_zero_length_wire(self) -> None:
"""Degenerate wire with same start/end — nothing is strictly between.""" """Degenerate wire with same start/end — nothing is strictly between."""
assert self._fn(5, 5, 5, 5, 5, 5) is False assert self._fn(5, 5, 5, 5, 5, 5) is False
@@ -710,12 +711,12 @@ class TestParseWire:
"""Unit tests for WireManager._parse_wire S-expression parser.""" """Unit tests for WireManager._parse_wire S-expression parser."""
@staticmethod @staticmethod
def _fn(item): def _fn(item: Any) -> Any:
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
return WireManager._parse_wire(item) return WireManager._parse_wire(item)
def test_valid_wire(self): def test_valid_wire(self) -> None:
wire = [ wire = [
Symbol("wire"), Symbol("wire"),
[Symbol("pts"), [Symbol("xy"), 10.0, 20.0], [Symbol("xy"), 30.0, 20.0]], [Symbol("pts"), [Symbol("xy"), 10.0, 20.0], [Symbol("xy"), 30.0, 20.0]],
@@ -734,28 +735,28 @@ class TestParseWire:
assert width == 0 assert width == 0
assert stype == "default" assert stype == "default"
def test_non_wire_element_returns_none(self): def test_non_wire_element_returns_none(self) -> None:
junction = [Symbol("junction"), [Symbol("at"), 10, 20]] junction = [Symbol("junction"), [Symbol("at"), 10, 20]]
assert TestParseWire._fn(junction) is None assert TestParseWire._fn(junction) is None
def test_non_list_returns_none(self): def test_non_list_returns_none(self) -> None:
assert TestParseWire._fn("not a list") is None assert TestParseWire._fn("not a list") is None
def test_empty_list_returns_none(self): def test_empty_list_returns_none(self) -> None:
assert TestParseWire._fn([]) is None assert TestParseWire._fn([]) is None
def test_wire_with_no_pts_returns_none(self): def test_wire_with_no_pts_returns_none(self) -> None:
wire = [Symbol("wire"), [Symbol("stroke"), [Symbol("width"), 0]]] wire = [Symbol("wire"), [Symbol("stroke"), [Symbol("width"), 0]]]
assert TestParseWire._fn(wire) is None assert TestParseWire._fn(wire) is None
def test_wire_with_only_one_xy_returns_none(self): def test_wire_with_only_one_xy_returns_none(self) -> None:
wire = [ wire = [
Symbol("wire"), Symbol("wire"),
[Symbol("pts"), [Symbol("xy"), 10.0, 20.0]], [Symbol("pts"), [Symbol("xy"), 10.0, 20.0]],
] ]
assert TestParseWire._fn(wire) is None assert TestParseWire._fn(wire) is None
def test_wire_without_stroke_uses_defaults(self): def test_wire_without_stroke_uses_defaults(self) -> None:
wire = [ wire = [
Symbol("wire"), Symbol("wire"),
[Symbol("pts"), [Symbol("xy"), 0, 0], [Symbol("xy"), 10, 0]], [Symbol("pts"), [Symbol("xy"), 0, 0], [Symbol("xy"), 10, 0]],
@@ -776,7 +777,7 @@ class TestParseWire:
class TestMakeWireSexp: class TestMakeWireSexp:
"""Unit tests for WireManager._make_wire_sexp builder.""" """Unit tests for WireManager._make_wire_sexp builder."""
def test_produces_valid_parseable_wire(self): def test_produces_valid_parseable_wire(self) -> None:
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
sexp = WireManager._make_wire_sexp([10, 20], [30, 20]) sexp = WireManager._make_wire_sexp([10, 20], [30, 20])
@@ -788,7 +789,7 @@ class TestMakeWireSexp:
assert width == 0 assert width == 0
assert stype == "default" assert stype == "default"
def test_custom_stroke(self): def test_custom_stroke(self) -> None:
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
sexp = WireManager._make_wire_sexp([0, 0], [5, 0], stroke_width=0.5, stroke_type="dash") sexp = WireManager._make_wire_sexp([0, 0], [5, 0], stroke_width=0.5, stroke_type="dash")
@@ -798,7 +799,7 @@ class TestMakeWireSexp:
assert width == 0.5 assert width == 0.5
assert stype == "dash" assert stype == "dash"
def test_has_uuid(self): def test_has_uuid(self) -> None:
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
sexp = WireManager._make_wire_sexp([0, 0], [10, 0]) sexp = WireManager._make_wire_sexp([0, 0], [10, 0])
@@ -807,7 +808,7 @@ class TestMakeWireSexp:
assert uuid_entry[0] == Symbol("uuid") assert uuid_entry[0] == Symbol("uuid")
assert isinstance(uuid_entry[1], str) and len(uuid_entry[1]) > 0 assert isinstance(uuid_entry[1], str) and len(uuid_entry[1]) > 0
def test_two_calls_produce_different_uuids(self): def test_two_calls_produce_different_uuids(self) -> None:
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
sexp1 = WireManager._make_wire_sexp([0, 0], [10, 0]) sexp1 = WireManager._make_wire_sexp([0, 0], [10, 0])
@@ -825,7 +826,7 @@ class TestBreakWiresAtPoint:
"""Unit tests for WireManager._break_wires_at_point T-junction logic.""" """Unit tests for WireManager._break_wires_at_point T-junction logic."""
@staticmethod @staticmethod
def _make_sch_data_with_wires(wire_coords): 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.""" """Build a minimal sch_data list with wire elements and a sheet_instances marker."""
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
@@ -835,7 +836,7 @@ class TestBreakWiresAtPoint:
data.append([Symbol("sheet_instances")]) data.append([Symbol("sheet_instances")])
return data return data
def test_split_horizontal_wire_at_midpoint(self): def test_split_horizontal_wire_at_midpoint(self) -> None:
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
data = self._make_sch_data_with_wires([([0, 0], [20, 0])]) data = self._make_sch_data_with_wires([([0, 0], [20, 0])])
@@ -851,7 +852,7 @@ class TestBreakWiresAtPoint:
endpoints = {c for pair in coords for c in pair} endpoints = {c for pair in coords for c in pair}
assert (10.0, 0.0) in endpoints assert (10.0, 0.0) in endpoints
def test_split_vertical_wire_at_midpoint(self): def test_split_vertical_wire_at_midpoint(self) -> None:
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
data = self._make_sch_data_with_wires([([5, 0], [5, 30])]) data = self._make_sch_data_with_wires([([5, 0], [5, 30])])
@@ -860,7 +861,7 @@ class TestBreakWiresAtPoint:
wires = _find_elements(data, "wire") wires = _find_elements(data, "wire")
assert len(wires) == 2 assert len(wires) == 2
def test_no_split_at_wire_endpoint(self): def test_no_split_at_wire_endpoint(self) -> None:
"""Point at existing endpoint should not trigger a split.""" """Point at existing endpoint should not trigger a split."""
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
@@ -870,7 +871,7 @@ class TestBreakWiresAtPoint:
wires = _find_elements(data, "wire") wires = _find_elements(data, "wire")
assert len(wires) == 1 assert len(wires) == 1
def test_no_split_point_not_on_wire(self): def test_no_split_point_not_on_wire(self) -> None:
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
data = self._make_sch_data_with_wires([([0, 0], [20, 0])]) data = self._make_sch_data_with_wires([([0, 0], [20, 0])])
@@ -879,7 +880,7 @@ class TestBreakWiresAtPoint:
wires = _find_elements(data, "wire") wires = _find_elements(data, "wire")
assert len(wires) == 1 assert len(wires) == 1
def test_split_multiple_wires_at_same_point(self): def test_split_multiple_wires_at_same_point(self) -> None:
"""Two crossing wires at (10, 10) — both should be split.""" """Two crossing wires at (10, 10) — both should be split."""
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
@@ -894,7 +895,7 @@ class TestBreakWiresAtPoint:
wires = _find_elements(data, "wire") wires = _find_elements(data, "wire")
assert len(wires) == 4 # each wire split into 2 assert len(wires) == 4 # each wire split into 2
def test_split_preserves_stroke_properties(self): def test_split_preserves_stroke_properties(self) -> None:
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
data = [Symbol("kicad_sch")] data = [Symbol("kicad_sch")]
@@ -910,7 +911,7 @@ class TestBreakWiresAtPoint:
assert parsed[2] == 0.5, "stroke_width should be preserved" assert parsed[2] == 0.5, "stroke_width should be preserved"
assert parsed[3] == "dash", "stroke_type should be preserved" assert parsed[3] == "dash", "stroke_type should be preserved"
def test_no_split_on_diagonal_wire(self): def test_no_split_on_diagonal_wire(self) -> None:
"""Diagonal wires are not handled by _point_strictly_on_wire → no split.""" """Diagonal wires are not handled by _point_strictly_on_wire → no split."""
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
@@ -918,7 +919,7 @@ class TestBreakWiresAtPoint:
splits = WireManager._break_wires_at_point(data, [5, 5]) splits = WireManager._break_wires_at_point(data, [5, 5])
assert splits == 0 assert splits == 0
def test_empty_sch_data(self): def test_empty_sch_data(self) -> None:
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
data = [Symbol("kicad_sch"), [Symbol("sheet_instances")]] data = [Symbol("kicad_sch"), [Symbol("sheet_instances")]]
@@ -936,12 +937,12 @@ class TestIntegrationTJunction:
"""Integration tests for T-junction wire breaking during add_wire/add_junction.""" """Integration tests for T-junction wire breaking during add_wire/add_junction."""
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def sch(self): def sch(self) -> Any:
path = _make_temp_sch() path = _make_temp_sch()
yield path yield path
shutil.rmtree(path.parent, ignore_errors=True) shutil.rmtree(path.parent, ignore_errors=True)
def test_add_wire_breaks_existing_horizontal_wire(self, sch): 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.""" """Adding a vertical wire whose endpoint is mid-horizontal-wire should split it."""
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
@@ -954,7 +955,7 @@ class TestIntegrationTJunction:
# Original horizontal wire should be split into 2, plus the new vertical = 3 total # 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)}" 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): 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.""" """Wire connecting at an existing endpoint should not trigger a split."""
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
@@ -965,7 +966,7 @@ class TestIntegrationTJunction:
wires = _find_elements(data, "wire") wires = _find_elements(data, "wire")
assert len(wires) == 2, f"Expected 2 wires (no split), got {len(wires)}" assert len(wires) == 2, f"Expected 2 wires (no split), got {len(wires)}"
def test_add_junction_breaks_wire(self, sch): def test_add_junction_breaks_wire(self, sch: Any) -> None:
"""Adding a junction mid-wire should split that wire.""" """Adding a junction mid-wire should split that wire."""
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
@@ -977,7 +978,7 @@ class TestIntegrationTJunction:
junctions = _find_elements(data, "junction") junctions = _find_elements(data, "junction")
assert len(junctions) == 1 assert len(junctions) == 1
def test_add_junction_at_wire_endpoint_no_split(self, sch): def test_add_junction_at_wire_endpoint_no_split(self, sch: Any) -> None:
"""Junction at wire endpoint should not split it.""" """Junction at wire endpoint should not split it."""
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
@@ -987,7 +988,7 @@ class TestIntegrationTJunction:
wires = _find_elements(data, "wire") wires = _find_elements(data, "wire")
assert len(wires) == 1, f"Expected 1 wire (no split at endpoint), got {len(wires)}" assert len(wires) == 1, f"Expected 1 wire (no split at endpoint), got {len(wires)}"
def test_polyline_breaks_existing_wire(self, sch): def test_polyline_breaks_existing_wire(self, sch: Any) -> None:
"""Polyline whose start/end hits mid-wire should break it.""" """Polyline whose start/end hits mid-wire should break it."""
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
@@ -999,7 +1000,7 @@ class TestIntegrationTJunction:
# 2 from split + 2 polyline segments = 4 # 2 from split + 2 polyline segments = 4
assert len(wires) == 4, f"Expected 4 wires, got {len(wires)}" assert len(wires) == 4, f"Expected 4 wires, got {len(wires)}"
def test_polyline_two_points_same_as_add_wire(self, sch): def test_polyline_two_points_same_as_add_wire(self, sch: Any) -> None:
"""Polyline with exactly 2 points should produce 1 wire segment.""" """Polyline with exactly 2 points should produce 1 wire segment."""
from commands.wire_manager import WireManager from commands.wire_manager import WireManager

View File

@@ -157,9 +157,9 @@ class KiCADProcessManager:
timeout=5 if system == "Windows" else None, timeout=5 if system == "Windows" else None,
) )
if result.returncode == 0: if result.returncode == 0:
path = result.stdout.strip().split("\n")[0] exe_path = result.stdout.strip().split("\n")[0]
logger.info(f"Found KiCAD executable: {path}") logger.info(f"Found KiCAD executable: {exe_path}")
return Path(path) return Path(exe_path)
# Platform-specific default paths # Platform-specific default paths
if system == "Linux": if system == "Linux":