fix: merge upstream type annotations into create_schematic signature
Combined our path parameter fix with upstream's type annotation additions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -46,7 +46,9 @@ repos:
|
||||
files: ^python/
|
||||
exclude: ^python/commands/board\.py$
|
||||
args: [--config-file=pyproject.toml]
|
||||
additional_dependencies: []
|
||||
additional_dependencies:
|
||||
- types-requests
|
||||
- pytest
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
|
||||
@@ -15,25 +15,28 @@ line_length = 100
|
||||
python_version = "3.10"
|
||||
warn_return_any = false
|
||||
warn_unused_configs = true
|
||||
ignore_missing_imports = true
|
||||
check_untyped_defs = false
|
||||
disallow_untyped_defs = false
|
||||
explicit_package_bases = true
|
||||
mypy_path = "."
|
||||
mypy_path = ".:python"
|
||||
disable_error_code = [
|
||||
"import-untyped",
|
||||
"arg-type",
|
||||
"attr-defined",
|
||||
"union-attr",
|
||||
"call-arg",
|
||||
"assignment",
|
||||
"var-annotated",
|
||||
"annotation-unchecked",
|
||||
"index",
|
||||
"has-type",
|
||||
"dict-item",
|
||||
"misc",
|
||||
"list-item",
|
||||
"return-value",
|
||||
"operator",
|
||||
]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"pcbnew",
|
||||
"cairosvg",
|
||||
"sexpdata",
|
||||
"skip",
|
||||
"kipy",
|
||||
"kipy.*",
|
||||
"schematic",
|
||||
"PIL",
|
||||
"PIL.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from skip import Schematic
|
||||
|
||||
@@ -25,7 +25,7 @@ class ComponentManager:
|
||||
_dynamic_loader = None
|
||||
|
||||
@classmethod
|
||||
def get_dynamic_loader(cls):
|
||||
def get_dynamic_loader(cls) -> Any:
|
||||
"""Get or create dynamic symbol loader instance"""
|
||||
if cls._dynamic_loader is None and DYNAMIC_LOADING_AVAILABLE:
|
||||
cls._dynamic_loader = DynamicSymbolLoader()
|
||||
@@ -86,7 +86,7 @@ class ComponentManager:
|
||||
"""
|
||||
|
||||
# Helper function to check if template exists in schematic
|
||||
def template_exists(schematic, template_ref):
|
||||
def template_exists(schematic: Any, template_ref: str) -> bool:
|
||||
"""Check if template exists by iterating symbols (handles special characters)"""
|
||||
for symbol in schematic.symbol:
|
||||
if (
|
||||
@@ -165,7 +165,7 @@ class ComponentManager:
|
||||
@staticmethod
|
||||
def add_component(
|
||||
schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None
|
||||
):
|
||||
) -> Any:
|
||||
"""
|
||||
Add a component to the schematic by cloning from template
|
||||
|
||||
@@ -265,7 +265,7 @@ class ComponentManager:
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def remove_component(schematic: Schematic, component_ref: str):
|
||||
def remove_component(schematic: Schematic, component_ref: str) -> bool:
|
||||
"""Remove a component from the schematic by reference designator"""
|
||||
try:
|
||||
# kicad-skip doesn't have a direct remove_symbol method by reference.
|
||||
@@ -278,17 +278,17 @@ class ComponentManager:
|
||||
|
||||
if symbol_to_remove:
|
||||
schematic.symbol._elements.remove(symbol_to_remove)
|
||||
print(f"Removed component {component_ref} from schematic.")
|
||||
logger.info(f"Removed component {component_ref} from schematic.")
|
||||
return True
|
||||
else:
|
||||
print(f"Component with reference {component_ref} not found.")
|
||||
logger.warning(f"Component with reference {component_ref} not found.")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Error removing component {component_ref}: {e}")
|
||||
logger.error(f"Error removing component {component_ref}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def update_component(schematic: Schematic, component_ref: str, new_properties: dict):
|
||||
def update_component(schematic: Schematic, component_ref: str, new_properties: dict) -> bool:
|
||||
"""Update component properties by reference designator"""
|
||||
try:
|
||||
symbol_to_update = None
|
||||
@@ -302,29 +302,28 @@ class ComponentManager:
|
||||
if key in symbol_to_update.property:
|
||||
symbol_to_update.property[key].value = value
|
||||
else:
|
||||
# Add as a new property if it doesn't exist
|
||||
symbol_to_update.property.append(key, value)
|
||||
print(f"Updated properties for component {component_ref}.")
|
||||
logger.info(f"Updated properties for component {component_ref}.")
|
||||
return True
|
||||
else:
|
||||
print(f"Component with reference {component_ref} not found.")
|
||||
logger.warning(f"Component with reference {component_ref} not found.")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Error updating component {component_ref}: {e}")
|
||||
logger.error(f"Error updating component {component_ref}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_component(schematic: Schematic, component_ref: str):
|
||||
def get_component(schematic: Schematic, component_ref: str) -> Any:
|
||||
"""Get a component by reference designator"""
|
||||
for symbol in schematic.symbol:
|
||||
if symbol.reference == component_ref:
|
||||
print(f"Found component with reference {component_ref}.")
|
||||
logger.debug(f"Found component with reference {component_ref}.")
|
||||
return symbol
|
||||
print(f"Component with reference {component_ref} not found.")
|
||||
logger.warning(f"Component with reference {component_ref} not found.")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def search_components(schematic: Schematic, query: str):
|
||||
def search_components(schematic: Schematic, query: str) -> List[Any]:
|
||||
"""Search for components matching criteria (basic implementation)"""
|
||||
# This is a basic search, could be expanded to use regex or more complex logic
|
||||
matching_components = []
|
||||
@@ -339,13 +338,13 @@ class ComponentManager:
|
||||
)
|
||||
):
|
||||
matching_components.append(symbol)
|
||||
print(f"Found {len(matching_components)} components matching query '{query}'.")
|
||||
logger.debug(f"Found {len(matching_components)} components matching query '{query}'.")
|
||||
return matching_components
|
||||
|
||||
@staticmethod
|
||||
def get_all_components(schematic: Schematic):
|
||||
def get_all_components(schematic: Schematic) -> List[Any]:
|
||||
"""Get all components in schematic"""
|
||||
print(f"Retrieving all {len(schematic.symbol)} components.")
|
||||
logger.debug(f"Retrieving all {len(schematic.symbol)} components.")
|
||||
return list(schematic.symbol)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from skip import Schematic
|
||||
|
||||
@@ -25,14 +25,14 @@ class ConnectionManager:
|
||||
_pin_locator = None
|
||||
|
||||
@classmethod
|
||||
def get_pin_locator(cls):
|
||||
def get_pin_locator(cls) -> Any:
|
||||
"""Get or create pin locator instance"""
|
||||
if cls._pin_locator is None and WIRE_MANAGER_AVAILABLE:
|
||||
cls._pin_locator = PinLocator()
|
||||
return cls._pin_locator
|
||||
|
||||
@staticmethod
|
||||
def add_net_label(schematic: Schematic, net_name: str, position: list):
|
||||
def add_net_label(schematic: Schematic, net_name: str, position: list) -> Any:
|
||||
"""
|
||||
Add a net label to the schematic
|
||||
|
||||
@@ -57,7 +57,9 @@ class ConnectionManager:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def connect_to_net(schematic_path: Path, component_ref: str, pin_name: str, net_name: str):
|
||||
def connect_to_net(
|
||||
schematic_path: Path, component_ref: str, pin_name: str, net_name: str
|
||||
) -> bool:
|
||||
"""
|
||||
Connect a component pin to a named net using a wire stub and label
|
||||
|
||||
@@ -132,7 +134,7 @@ class ConnectionManager:
|
||||
target_ref: str,
|
||||
net_prefix: str = "PIN",
|
||||
pin_offset: int = 0,
|
||||
):
|
||||
) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Connect all pins of source_ref to matching pins of target_ref via shared net labels.
|
||||
Useful for passthrough adapters: J1 pin N <-> J2 pin N on net {net_prefix}_{N}.
|
||||
@@ -203,7 +205,7 @@ class ConnectionManager:
|
||||
@staticmethod
|
||||
def get_net_connections(
|
||||
schematic: Schematic, net_name: str, schematic_path: Optional[Path] = None
|
||||
):
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Get all connections for a named net using wire graph analysis
|
||||
|
||||
@@ -221,7 +223,7 @@ class ConnectionManager:
|
||||
connections = []
|
||||
tolerance = 0.5 # 0.5mm tolerance for point coincidence (grid spacing consideration)
|
||||
|
||||
def points_coincide(p1, p2):
|
||||
def points_coincide(p1: Any, p2: Any) -> bool:
|
||||
"""Check if two points are the same (within tolerance)"""
|
||||
if not p1 or not p2:
|
||||
return False
|
||||
@@ -324,8 +326,8 @@ class ConnectionManager:
|
||||
continue
|
||||
|
||||
# Check if pin coincides with any wire point
|
||||
for wire_pt in connected_wire_points:
|
||||
if points_coincide(pin_loc, list(wire_pt)):
|
||||
for wire_pt_tup in connected_wire_points:
|
||||
if points_coincide(pin_loc, list(wire_pt_tup)):
|
||||
connections.append({"component": ref, "pin": pin_num})
|
||||
break # Pin found, no need to check more wire points
|
||||
|
||||
@@ -344,8 +346,10 @@ class ConnectionManager:
|
||||
symbol_y = float(symbol_pos[1])
|
||||
|
||||
# Check if symbol is near any wire point (within 10mm)
|
||||
for wire_pt in connected_wire_points:
|
||||
dist = ((symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2) ** 0.5
|
||||
for wire_pt_tup in connected_wire_points:
|
||||
dist = (
|
||||
(symbol_x - wire_pt_tup[0]) ** 2 + (symbol_y - wire_pt_tup[1]) ** 2
|
||||
) ** 0.5
|
||||
if dist < 10.0: # 10mm proximity threshold
|
||||
connections.append({"component": ref, "pin": "unknown"})
|
||||
break # Only add once per component
|
||||
@@ -361,7 +365,9 @@ class ConnectionManager:
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def generate_netlist(schematic: Schematic, schematic_path: Optional[Path] = None):
|
||||
def generate_netlist(
|
||||
schematic: Schematic, schematic_path: Optional[Path] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate a netlist from the schematic
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ No API key required.
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
@@ -49,7 +49,7 @@ class DatasheetManager:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _find_lib_symbols_range(lines: List[str]):
|
||||
def _find_lib_symbols_range(lines: List[str]) -> Tuple[Optional[int], Optional[int]]:
|
||||
"""
|
||||
Find the line range of the (lib_symbols ...) section.
|
||||
Returns (start, end) line indices or (None, None) if not found.
|
||||
|
||||
@@ -238,7 +238,10 @@ class FootprintCreator:
|
||||
changes.append(f"drill (inserted)→{new_drill}")
|
||||
if shape:
|
||||
block, n = re.subn(
|
||||
r'(pad\s+"[^"]*"\s+\w+\s+)\w+', lambda m: m.group(1) + shape, block, count=1
|
||||
r'(pad\s+"[^"]*"\s+\w+\s+)\w+',
|
||||
lambda m: str(m.group(1)) + shape,
|
||||
block,
|
||||
count=1,
|
||||
)
|
||||
if n:
|
||||
changes.append(f"shape→{shape}")
|
||||
|
||||
@@ -95,6 +95,8 @@ def _build_freerouting_cmd(
|
||||
"""Build the command to run Freerouting."""
|
||||
if use_docker:
|
||||
docker_exe = _find_docker()
|
||||
if docker_exe is None:
|
||||
raise RuntimeError("Docker/Podman executable not found")
|
||||
board_dir = os.path.dirname(dsn_path)
|
||||
dsn_name = os.path.basename(dsn_path)
|
||||
ses_name = os.path.basename(ses_path)
|
||||
@@ -120,6 +122,8 @@ def _build_freerouting_cmd(
|
||||
]
|
||||
else:
|
||||
java_exe = _find_java()
|
||||
if java_exe is None:
|
||||
raise RuntimeError("Java executable not found")
|
||||
return [
|
||||
java_exe,
|
||||
"-jar",
|
||||
@@ -136,7 +140,7 @@ def _build_freerouting_cmd(
|
||||
class FreeroutingCommands:
|
||||
"""Handles Freerouting autoroute operations."""
|
||||
|
||||
def __init__(self, board=None):
|
||||
def __init__(self, board: Any = None) -> None:
|
||||
self.board = board
|
||||
|
||||
def _resolve_execution_mode(self, jar_path: str) -> Dict[str, Any]:
|
||||
|
||||
@@ -11,7 +11,7 @@ import os
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
@@ -38,10 +38,10 @@ class JLCPCBPartsManager:
|
||||
db_path = str(data_dir / "jlcpcb_parts.db")
|
||||
|
||||
self.db_path = db_path
|
||||
self.conn = None
|
||||
self.conn: Optional[sqlite3.Connection] = None
|
||||
self._init_database()
|
||||
|
||||
def _init_database(self):
|
||||
def _init_database(self) -> None:
|
||||
"""Initialize SQLite database with schema"""
|
||||
self.conn = sqlite3.connect(self.db_path)
|
||||
self.conn.row_factory = sqlite3.Row # Return rows as dicts
|
||||
@@ -90,7 +90,9 @@ class JLCPCBPartsManager:
|
||||
self.conn.commit()
|
||||
logger.info(f"Initialized JLCPCB parts database at {self.db_path}")
|
||||
|
||||
def import_parts(self, parts: List[Dict], progress_callback=None):
|
||||
def import_parts(
|
||||
self, parts: List[Dict], progress_callback: Optional[Callable[..., Any]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Import parts into database from JLCPCB API response
|
||||
|
||||
@@ -167,7 +169,9 @@ class JLCPCBPartsManager:
|
||||
else:
|
||||
return "Extended" # Default to Extended
|
||||
|
||||
def import_jlcsearch_parts(self, parts: List[Dict], progress_callback=None):
|
||||
def import_jlcsearch_parts(
|
||||
self, parts: List[Dict], progress_callback: Optional[Callable[..., Any]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Import parts into database from JLCSearch API response
|
||||
|
||||
@@ -452,7 +456,7 @@ class JLCPCBPartsManager:
|
||||
alternatives = [p for p in alternatives if p["lcsc"] != lcsc_number]
|
||||
|
||||
# Sort by: Basic first, then by price, then by stock
|
||||
def sort_key(p):
|
||||
def sort_key(p: Dict[str, Any]) -> Tuple[int, float, int]:
|
||||
is_basic = 1 if p.get("library_type") == "Basic" else 0
|
||||
try:
|
||||
prices = json.loads(p.get("price_json", "[]"))
|
||||
@@ -467,7 +471,7 @@ class JLCPCBPartsManager:
|
||||
|
||||
return alternatives[:limit]
|
||||
|
||||
def close(self):
|
||||
def close(self) -> None:
|
||||
"""Close database connection"""
|
||||
if self.conn:
|
||||
self.conn.close()
|
||||
|
||||
@@ -7,7 +7,7 @@ jlcsearch service at https://jlcsearch.tscircuit.com/
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable, Dict, List, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional, Union
|
||||
|
||||
import requests
|
||||
|
||||
@@ -24,12 +24,12 @@ class JLCSearchClient:
|
||||
|
||||
BASE_URL = "https://jlcsearch.tscircuit.com"
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize JLCSearch API client"""
|
||||
pass
|
||||
|
||||
def search_components(
|
||||
self, category: str = "components", limit: int = 100, offset: int = 0, **filters
|
||||
self, category: str = "components", limit: int = 100, offset: int = 0, **filters: Dict
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Search components in JLCSearch database
|
||||
@@ -87,7 +87,7 @@ class JLCSearchClient:
|
||||
- stock: Available stock
|
||||
- price1: Price per unit
|
||||
"""
|
||||
filters = {}
|
||||
filters: Dict[str, Any] = {}
|
||||
if resistance is not None:
|
||||
filters["resistance"] = resistance
|
||||
if package:
|
||||
@@ -109,7 +109,7 @@ class JLCSearchClient:
|
||||
Returns:
|
||||
List of capacitor dicts
|
||||
"""
|
||||
filters = {}
|
||||
filters: Dict[str, Any] = {}
|
||||
if capacitance is not None:
|
||||
filters["capacitance"] = capacitance
|
||||
if package:
|
||||
|
||||
@@ -35,7 +35,7 @@ class LibraryManager:
|
||||
self.footprint_cache: Dict[str, List[str]] = {} # library -> [footprint names]
|
||||
self._load_libraries()
|
||||
|
||||
def _load_libraries(self):
|
||||
def _load_libraries(self) -> None:
|
||||
"""Load libraries from fp-lib-table files"""
|
||||
# Load global libraries
|
||||
global_table = self._get_global_fp_lib_table()
|
||||
@@ -78,7 +78,7 @@ class LibraryManager:
|
||||
|
||||
return None
|
||||
|
||||
def _parse_fp_lib_table(self, table_path: Path):
|
||||
def _parse_fp_lib_table(self, table_path: Path) -> None:
|
||||
"""
|
||||
Parse fp-lib-table file
|
||||
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import glob
|
||||
import logging
|
||||
|
||||
# Symbol class might not be directly importable in the current version
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from skip import Schematic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LibraryManager:
|
||||
"""Manage symbol libraries"""
|
||||
|
||||
@staticmethod
|
||||
def list_available_libraries(search_paths=None):
|
||||
def list_available_libraries(search_paths: Optional[List[str]] = None) -> Dict[str, List[str]]:
|
||||
"""List all available symbol libraries"""
|
||||
if search_paths is None:
|
||||
# Default library paths based on common KiCAD installations
|
||||
@@ -31,11 +35,11 @@ class LibraryManager:
|
||||
matching_libs = glob.glob(path_pattern, recursive=True)
|
||||
libraries.extend(matching_libs)
|
||||
except Exception as e:
|
||||
print(f"Error searching for libraries at {path_pattern}: {e}")
|
||||
logger.error(f"Error searching for libraries at {path_pattern}: {e}")
|
||||
|
||||
# Extract library names from paths
|
||||
library_names = [os.path.splitext(os.path.basename(lib))[0] for lib in libraries]
|
||||
print(
|
||||
logger.info(
|
||||
f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}"
|
||||
)
|
||||
|
||||
@@ -43,7 +47,7 @@ class LibraryManager:
|
||||
return {"paths": libraries, "names": library_names}
|
||||
|
||||
@staticmethod
|
||||
def list_library_symbols(library_path):
|
||||
def list_library_symbols(library_path: str) -> List[Any]:
|
||||
"""List all symbols in a library"""
|
||||
try:
|
||||
# kicad-skip doesn't provide a direct way to simply list symbols in a library
|
||||
@@ -54,30 +58,30 @@ class LibraryManager:
|
||||
# A potential approach would be to load the library file using KiCAD's Python API
|
||||
# or by parsing the library file format.
|
||||
# KiCAD symbol libraries are .kicad_sym files which are S-expression format
|
||||
print(
|
||||
logger.warning(
|
||||
f"Attempted to list symbols in library {library_path}. This requires advanced implementation."
|
||||
)
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"Error listing symbols in library {library_path}: {e}")
|
||||
logger.error(f"Error listing symbols in library {library_path}: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_symbol_details(library_path, symbol_name):
|
||||
def get_symbol_details(library_path: str, symbol_name: str) -> Dict[str, Any]:
|
||||
"""Get detailed information about a symbol"""
|
||||
try:
|
||||
# Similar to list_library_symbols, this might require a more direct approach
|
||||
# using KiCAD's Python API or by parsing the symbol library.
|
||||
print(
|
||||
logger.warning(
|
||||
f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation."
|
||||
)
|
||||
return {}
|
||||
except Exception as e:
|
||||
print(f"Error getting symbol details for {symbol_name} in {library_path}: {e}")
|
||||
logger.error(f"Error getting symbol details for {symbol_name} in {library_path}: {e}")
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def search_symbols(query, search_paths=None):
|
||||
def search_symbols(query: str, search_paths: Optional[List[str]] = None) -> List[Any]:
|
||||
"""Search for symbols matching criteria"""
|
||||
try:
|
||||
# This would typically involve:
|
||||
@@ -89,16 +93,18 @@ class LibraryManager:
|
||||
libraries = LibraryManager.list_available_libraries(search_paths)
|
||||
|
||||
results = []
|
||||
print(
|
||||
logger.warning(
|
||||
f"Searched for symbols matching '{query}'. This requires advanced implementation."
|
||||
)
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"Error searching for symbols matching '{query}': {e}")
|
||||
logger.error(f"Error searching for symbols matching '{query}': {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_default_symbol_for_component_type(component_type, search_paths=None):
|
||||
def get_default_symbol_for_component_type(
|
||||
component_type: str, search_paths: Optional[List[str]] = None
|
||||
) -> Dict[str, str]:
|
||||
"""Get a recommended default symbol for a given component type"""
|
||||
# This method provides a simplified way to get a symbol for common component types
|
||||
# It's useful when the user doesn't specify a particular library/symbol
|
||||
|
||||
@@ -55,7 +55,7 @@ class SymbolLibraryManager:
|
||||
self.symbol_cache: Dict[str, List[SymbolInfo]] = {} # library -> [SymbolInfo]
|
||||
self._load_libraries()
|
||||
|
||||
def _load_libraries(self):
|
||||
def _load_libraries(self) -> None:
|
||||
"""Load libraries from sym-lib-table files"""
|
||||
# Load global libraries
|
||||
global_table = self._get_global_sym_lib_table()
|
||||
@@ -98,7 +98,7 @@ class SymbolLibraryManager:
|
||||
|
||||
return None
|
||||
|
||||
def _parse_sym_lib_table(self, table_path: Path):
|
||||
def _parse_sym_lib_table(self, table_path: Path) -> None:
|
||||
"""
|
||||
Parse sym-lib-table file
|
||||
|
||||
@@ -370,7 +370,7 @@ class SymbolLibraryManager:
|
||||
query_lower = query.lower()
|
||||
|
||||
# Determine which libraries to search
|
||||
libraries_to_search = self.libraries.keys()
|
||||
libraries_to_search: list[str] = list(self.libraries.keys())
|
||||
if library_filter:
|
||||
filter_lower = library_filter.lower()
|
||||
libraries_to_search = [
|
||||
|
||||
@@ -9,7 +9,7 @@ import logging
|
||||
import math
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import sexpdata
|
||||
from sexpdata import Symbol
|
||||
@@ -21,7 +21,7 @@ logger = logging.getLogger("kicad_interface")
|
||||
class PinLocator:
|
||||
"""Locate pins on symbol instances in KiCad schematics"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize pin locator with empty cache"""
|
||||
self.pin_definition_cache = {} # Cache: "lib_id:symbol_name" -> pin_data
|
||||
self._schematic_cache: Dict[str, object] = {} # Cache: path -> loaded Schematic
|
||||
@@ -41,9 +41,9 @@ class PinLocator:
|
||||
"2": {"x": 0, "y": -3.81, "angle": 90, "length": 1.27, "name": "~", "type": "passive"}
|
||||
}
|
||||
"""
|
||||
pins = {}
|
||||
pins: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def extract_pins_recursive(sexp):
|
||||
def extract_pins_recursive(sexp: Any) -> None:
|
||||
"""Recursively search for pin definitions"""
|
||||
if not isinstance(sexp, list):
|
||||
return
|
||||
|
||||
@@ -115,7 +115,7 @@ class RoutingCommands:
|
||||
"errorDetails": f"'{ref}' does not exist on the board",
|
||||
}
|
||||
|
||||
def find_pad(ref: str, pad_num: str):
|
||||
def find_pad(ref: str, pad_num: str) -> Any:
|
||||
fp = footprints[ref]
|
||||
for pad in fp.Pads():
|
||||
if pad.GetNumber() == pad_num:
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from skip import Schematic
|
||||
|
||||
@@ -12,7 +13,7 @@ class SchematicManager:
|
||||
"""Core schematic operations using kicad-skip"""
|
||||
|
||||
@staticmethod
|
||||
def create_schematic(name, path=None, metadata=None):
|
||||
def create_schematic(name: str, path: Optional[str] = None, metadata: Optional[Any] = None) -> Any:
|
||||
"""Create a new empty schematic from template"""
|
||||
try:
|
||||
# Determine template path (use template_with_symbols for component cloning support)
|
||||
@@ -71,7 +72,7 @@ class SchematicManager:
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def load_schematic(file_path):
|
||||
def load_schematic(file_path: str) -> Optional[Any]:
|
||||
"""Load an existing schematic"""
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"Schematic file not found at {file_path}")
|
||||
@@ -85,7 +86,7 @@ class SchematicManager:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def save_schematic(schematic, file_path):
|
||||
def save_schematic(schematic: Any, file_path: str) -> bool:
|
||||
"""Save a schematic to file"""
|
||||
try:
|
||||
# kicad-skip uses write method, not save
|
||||
@@ -97,7 +98,7 @@ class SchematicManager:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_schematic_metadata(schematic):
|
||||
def get_schematic_metadata(schematic: Any) -> dict[str, Any]:
|
||||
"""Extract metadata from schematic"""
|
||||
# kicad-skip doesn't expose a direct metadata object on Schematic.
|
||||
# We can return basic info like version and generator.
|
||||
|
||||
@@ -782,7 +782,7 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]:
|
||||
collisions = []
|
||||
|
||||
# Pre-compute per-symbol data
|
||||
symbol_data = []
|
||||
symbol_data: List[Dict[str, Any]] = []
|
||||
for sym in symbols:
|
||||
ref = sym["reference"]
|
||||
if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref:
|
||||
|
||||
@@ -129,7 +129,7 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
|
||||
cx_ = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2
|
||||
cy_ = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2
|
||||
|
||||
def angle(ux, uy, vx, vy):
|
||||
def angle(ux: float, uy: float, vx: float, vy: float) -> float:
|
||||
a = math.acos(
|
||||
max(-1, min(1, (ux * vx + uy * vy) / (math.hypot(ux, uy) * math.hypot(vx, vy))))
|
||||
)
|
||||
@@ -293,10 +293,10 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
|
||||
def _parse_transform(transform_str: str) -> List[List[float]]:
|
||||
"""Parse SVG transform attribute, return list of 3×3 matrix rows [a,b,c; d,e,f; 0,0,1]."""
|
||||
|
||||
def identity():
|
||||
def identity() -> List[List[float]]:
|
||||
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
|
||||
|
||||
def mat_mul(A, B):
|
||||
def mat_mul(A: List[List[float]], B: List[List[float]]) -> List[List[float]]:
|
||||
return [[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)] for r in range(3)]
|
||||
|
||||
result = identity()
|
||||
@@ -345,7 +345,7 @@ def _apply_transform(pts: List[Point], mat: List[List[float]]) -> List[Point]:
|
||||
return out
|
||||
|
||||
|
||||
def _mat_mul(A, B):
|
||||
def _mat_mul(A: List[List[float]], B: List[List[float]]) -> List[List[float]]:
|
||||
return [[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)] for r in range(3)]
|
||||
|
||||
|
||||
@@ -366,7 +366,7 @@ def _get_attr(el: ET.Element, name: str, default: Optional[str] = None) -> Optio
|
||||
return default
|
||||
|
||||
|
||||
def _identity():
|
||||
def _identity() -> List[List[float]]:
|
||||
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ coordinate matching, mirroring KiCad's own connectivity algorithm.
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from commands.pin_locator import PinLocator
|
||||
|
||||
@@ -22,7 +22,7 @@ def _to_iu(x_mm: float, y_mm: float) -> Tuple[int, int]:
|
||||
return (round(x_mm * _IU_PER_MM), round(y_mm * _IU_PER_MM))
|
||||
|
||||
|
||||
def _parse_wires(schematic) -> List[List[Tuple[int, int]]]:
|
||||
def _parse_wires(schematic: Any) -> List[List[Tuple[int, int]]]:
|
||||
"""Extract wire endpoints from a schematic object as IU tuples."""
|
||||
all_wires = []
|
||||
for wire in schematic.wire:
|
||||
@@ -67,7 +67,9 @@ def _build_adjacency(
|
||||
return adjacency, iu_to_wires
|
||||
|
||||
|
||||
def _parse_virtual_connections(schematic, schematic_path):
|
||||
def _parse_virtual_connections(
|
||||
schematic: Any, schematic_path: Any
|
||||
) -> Tuple[Dict[Tuple[int, int], str], Dict[str, List[Tuple[int, int]]]]:
|
||||
"""Return virtual connectivity from net labels and power symbols.
|
||||
|
||||
Returns a tuple of:
|
||||
@@ -175,8 +177,8 @@ def _find_connected_wires(
|
||||
|
||||
def _find_pins_on_net(
|
||||
net_points: Set[Tuple[int, int]],
|
||||
schematic_path,
|
||||
schematic,
|
||||
schematic_path: Any,
|
||||
schematic: Any,
|
||||
) -> List[Dict]:
|
||||
"""Find component pins that land on net points using exact IU matching.
|
||||
|
||||
@@ -216,7 +218,7 @@ def _find_pins_on_net(
|
||||
|
||||
|
||||
def get_wire_connections(
|
||||
schematic, schematic_path: str, x_mm: float, y_mm: float
|
||||
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
|
||||
) -> Optional[Dict]:
|
||||
"""Find all component pins reachable from a point via connected wires, net labels, and power symbols.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ All methods operate on in-memory sexpdata lists (no disk I/O).
|
||||
import logging
|
||||
import math
|
||||
import uuid
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import sexpdata
|
||||
from sexpdata import Symbol
|
||||
@@ -55,7 +55,7 @@ class WireDragger:
|
||||
"""Pure-logic helpers for wire-endpoint dragging during component moves."""
|
||||
|
||||
@staticmethod
|
||||
def find_symbol(sch_data: list, reference: str):
|
||||
def find_symbol(sch_data: list, reference: str) -> Any:
|
||||
"""
|
||||
Find a placed symbol by reference designator.
|
||||
|
||||
@@ -218,7 +218,7 @@ class WireDragger:
|
||||
junction_k = _K["junction"]
|
||||
at_k = _K["at"]
|
||||
|
||||
def find_new(x: float, y: float):
|
||||
def find_new(x: float, y: float) -> Optional[Tuple[float, float]]:
|
||||
for (ox, oy), (nx, ny) in old_to_new.items():
|
||||
if _coords_match(x, y, ox, oy, eps):
|
||||
return nx, ny
|
||||
|
||||
@@ -11,7 +11,7 @@ import math
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
import sexpdata
|
||||
from sexpdata import Symbol
|
||||
@@ -255,7 +255,7 @@ class WireManager:
|
||||
|
||||
@staticmethod
|
||||
def _parse_wire(
|
||||
wire_item,
|
||||
wire_item: Any,
|
||||
) -> Optional[Tuple[Tuple[float, float], Tuple[float, float], float, str]]:
|
||||
"""
|
||||
Parse a wire S-expression item in a single pass.
|
||||
|
||||
@@ -127,7 +127,7 @@ class BoardAPI(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_size(self) -> Dict[str, float]:
|
||||
def get_size(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current board size
|
||||
|
||||
@@ -169,6 +169,7 @@ class BoardAPI(ABC):
|
||||
y: float,
|
||||
rotation: float = 0,
|
||||
layer: str = "F.Cu",
|
||||
value: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
Place a component on the board
|
||||
|
||||
@@ -40,10 +40,10 @@ class IPCBackend(KiCADBackend):
|
||||
without requiring manual reload.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self._kicad = None
|
||||
self._connected = False
|
||||
self._version = None
|
||||
self._version: Optional[str] = None
|
||||
self._on_change_callbacks: List[Callable] = []
|
||||
|
||||
def connect(self, socket_path: Optional[str] = None) -> bool:
|
||||
@@ -257,13 +257,13 @@ class IPCBoardAPI(BoardAPI):
|
||||
Uses transactions for proper undo/redo support.
|
||||
"""
|
||||
|
||||
def __init__(self, kicad_instance, notify_callback: Callable):
|
||||
def __init__(self, kicad_instance: Any, notify_callback: Callable) -> None:
|
||||
self._kicad = kicad_instance
|
||||
self._board = None
|
||||
self._notify = notify_callback
|
||||
self._current_commit = None
|
||||
|
||||
def _get_board(self):
|
||||
def _get_board(self) -> Any:
|
||||
"""Get board instance, connecting if needed."""
|
||||
if self._board is None:
|
||||
try:
|
||||
@@ -350,7 +350,7 @@ class IPCBoardAPI(BoardAPI):
|
||||
logger.error(f"Failed to set board size: {e}")
|
||||
return False
|
||||
|
||||
def get_size(self) -> Dict[str, float]:
|
||||
def get_size(self) -> Dict[str, Any]:
|
||||
"""Get current board size from bounding box."""
|
||||
try:
|
||||
board = self._get_board()
|
||||
@@ -490,7 +490,7 @@ class IPCBoardAPI(BoardAPI):
|
||||
logger.error(f"Failed to place component: {e}")
|
||||
return False
|
||||
|
||||
def _load_footprint_from_library(self, footprint_path: str):
|
||||
def _load_footprint_from_library(self, footprint_path: str) -> Any:
|
||||
"""
|
||||
Load a footprint from the library using pcbnew SWIG API.
|
||||
|
||||
@@ -546,7 +546,14 @@ class IPCBoardAPI(BoardAPI):
|
||||
return None
|
||||
|
||||
def _place_loaded_footprint(
|
||||
self, loaded_fp, reference: str, x: float, y: float, rotation: float, layer: str, value: str
|
||||
self,
|
||||
loaded_fp: Any,
|
||||
reference: str,
|
||||
x: float,
|
||||
y: float,
|
||||
rotation: float,
|
||||
layer: str,
|
||||
value: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Place a loaded pcbnew footprint onto the board.
|
||||
|
||||
@@ -26,7 +26,7 @@ class SWIGBackend(KiCADBackend):
|
||||
for compatibility during migration period.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
self._connected = False
|
||||
self._pcbnew = None
|
||||
logger.warning(
|
||||
@@ -98,7 +98,7 @@ class SWIGBackend(KiCADBackend):
|
||||
from commands.project import ProjectCommands
|
||||
|
||||
try:
|
||||
result = ProjectCommands.open_project(str(path))
|
||||
result = ProjectCommands().open_project({"filename": str(path)})
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open project: {e}")
|
||||
@@ -112,8 +112,10 @@ class SWIGBackend(KiCADBackend):
|
||||
from commands.project import ProjectCommands
|
||||
|
||||
try:
|
||||
path_str = str(path) if path else None
|
||||
result = ProjectCommands.save_project(path_str)
|
||||
params: Dict[str, Any] = {}
|
||||
if path:
|
||||
params["filename"] = str(path)
|
||||
result = ProjectCommands().save_project(params)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save project: {e}")
|
||||
@@ -137,7 +139,7 @@ class SWIGBackend(KiCADBackend):
|
||||
class SWIGBoardAPI(BoardAPI):
|
||||
"""Board API implementation wrapping SWIG/pcbnew"""
|
||||
|
||||
def __init__(self, pcbnew_module):
|
||||
def __init__(self, pcbnew_module: Any) -> None:
|
||||
self.pcbnew = pcbnew_module
|
||||
self._board = None
|
||||
|
||||
@@ -146,13 +148,15 @@ class SWIGBoardAPI(BoardAPI):
|
||||
from commands.board import BoardCommands
|
||||
|
||||
try:
|
||||
result = BoardCommands.set_board_size(width, height, unit)
|
||||
result = BoardCommands(board=self._board).set_board_size(
|
||||
{"width": width, "height": height, "unit": unit}
|
||||
)
|
||||
return result.get("success", False)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set board size: {e}")
|
||||
return False
|
||||
|
||||
def get_size(self) -> Dict[str, float]:
|
||||
def get_size(self) -> Dict[str, Any]:
|
||||
"""Get board size"""
|
||||
# TODO: Implement using existing SWIG code
|
||||
raise NotImplementedError("get_size not yet wrapped")
|
||||
@@ -173,7 +177,7 @@ class SWIGBoardAPI(BoardAPI):
|
||||
from commands.component import ComponentCommands
|
||||
|
||||
try:
|
||||
result = ComponentCommands.get_component_list()
|
||||
result = ComponentCommands(board=self._board).get_component_list({})
|
||||
if result.get("success"):
|
||||
return result.get("components", [])
|
||||
return []
|
||||
@@ -189,17 +193,20 @@ class SWIGBoardAPI(BoardAPI):
|
||||
y: float,
|
||||
rotation: float = 0,
|
||||
layer: str = "F.Cu",
|
||||
value: str = "",
|
||||
) -> bool:
|
||||
"""Place component using existing implementation"""
|
||||
from commands.component import ComponentCommands
|
||||
|
||||
try:
|
||||
result = ComponentCommands.place_component(
|
||||
component_id=footprint,
|
||||
position={"x": x, "y": y, "unit": "mm"},
|
||||
reference=reference,
|
||||
rotation=rotation,
|
||||
layer=layer,
|
||||
result = ComponentCommands(board=self._board).place_component(
|
||||
{
|
||||
"componentId": footprint,
|
||||
"position": {"x": x, "y": y, "unit": "mm"},
|
||||
"reference": reference,
|
||||
"rotation": rotation,
|
||||
"layer": layer,
|
||||
}
|
||||
)
|
||||
return result.get("success", False)
|
||||
except Exception as e:
|
||||
|
||||
@@ -12,6 +12,7 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read
|
||||
@@ -241,7 +242,7 @@ except ImportError as e:
|
||||
class KiCADInterface:
|
||||
"""Main interface class to handle KiCAD operations"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the interface and command handlers"""
|
||||
self.board = None
|
||||
self.project_filename = None
|
||||
@@ -560,7 +561,7 @@ class KiCADInterface:
|
||||
"connect_passthrough",
|
||||
}
|
||||
|
||||
def _auto_save_board(self):
|
||||
def _auto_save_board(self) -> None:
|
||||
"""Save board to disk after SWIG mutations.
|
||||
Called automatically after every board-mutating SWIG command so that
|
||||
data is not lost if Claude hits the context limit before save_project.
|
||||
@@ -574,7 +575,7 @@ class KiCADInterface:
|
||||
except Exception as e:
|
||||
logger.warning(f"Auto-save failed: {e}")
|
||||
|
||||
def _update_command_handlers(self):
|
||||
def _update_command_handlers(self) -> None:
|
||||
"""Update board reference in all command handlers"""
|
||||
logger.debug("Updating board reference in command handlers")
|
||||
self.project_commands.board = self.board
|
||||
@@ -586,7 +587,7 @@ class KiCADInterface:
|
||||
self.freerouting_commands.board = self.board
|
||||
|
||||
# Schematic command handlers
|
||||
def _handle_create_schematic(self, params):
|
||||
def _handle_create_schematic(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create a new schematic"""
|
||||
logger.info("Creating schematic")
|
||||
try:
|
||||
@@ -624,7 +625,7 @@ class KiCADInterface:
|
||||
logger.error(f"Error creating schematic: {str(e)}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_load_schematic(self, params):
|
||||
def _handle_load_schematic(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Load an existing schematic"""
|
||||
logger.info("Loading schematic")
|
||||
try:
|
||||
@@ -645,7 +646,7 @@ class KiCADInterface:
|
||||
logger.error(f"Error loading schematic: {str(e)}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_place_component(self, params):
|
||||
def _handle_place_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Place a component on the PCB, with project-local fp-lib-table support.
|
||||
If boardPath is given and differs from the currently loaded board, the
|
||||
board is reloaded from boardPath before placing — prevents silent failures
|
||||
@@ -680,7 +681,7 @@ class KiCADInterface:
|
||||
|
||||
return self.component_commands.place_component(params)
|
||||
|
||||
def _handle_add_schematic_component(self, params):
|
||||
def _handle_add_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a component to a schematic using text-based injection (no sexpdata)"""
|
||||
logger.info("Adding component to schematic")
|
||||
try:
|
||||
@@ -733,7 +734,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_delete_schematic_component(self, params):
|
||||
def _handle_delete_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Remove a placed symbol from a schematic using text-based manipulation (no skip writes)"""
|
||||
logger.info("Deleting schematic component")
|
||||
try:
|
||||
@@ -758,7 +759,7 @@ class KiCADInterface:
|
||||
with open(sch_file, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
def find_matching_paren(s, start):
|
||||
def find_matching_paren(s: str, start: int) -> int:
|
||||
"""Find the closing paren matching the opening paren at start."""
|
||||
depth = 0
|
||||
i = start
|
||||
@@ -839,7 +840,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_edit_schematic_component(self, params):
|
||||
def _handle_edit_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Update properties of a placed symbol in a schematic (footprint, value, reference).
|
||||
Uses text-based in-place editing – preserves position, UUID and all other fields.
|
||||
"""
|
||||
@@ -884,7 +885,7 @@ class KiCADInterface:
|
||||
with open(sch_file, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
def find_matching_paren(s, start):
|
||||
def find_matching_paren(s: str, start: int) -> int:
|
||||
"""Find the position of the closing paren matching the opening paren at start."""
|
||||
depth = 0
|
||||
i = start
|
||||
@@ -929,7 +930,7 @@ class KiCADInterface:
|
||||
break
|
||||
search_start = end + 1
|
||||
|
||||
if block_start is None:
|
||||
if block_start is None or block_end is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Component '{reference}' not found in schematic",
|
||||
@@ -992,7 +993,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_get_schematic_component(self, params):
|
||||
def _handle_get_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Return full component info: position and all field values with their (at x y angle) positions."""
|
||||
logger.info("Getting schematic component info")
|
||||
try:
|
||||
@@ -1017,7 +1018,7 @@ class KiCADInterface:
|
||||
with open(sch_file, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
def find_matching_paren(s, start):
|
||||
def find_matching_paren(s: str, start: int) -> int:
|
||||
depth = 0
|
||||
i = start
|
||||
while i < len(s):
|
||||
@@ -1059,7 +1060,7 @@ class KiCADInterface:
|
||||
break
|
||||
search_start = end + 1
|
||||
|
||||
if block_start is None:
|
||||
if block_start is None or block_end is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Component '{reference}' not found in schematic",
|
||||
@@ -1115,7 +1116,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_add_schematic_wire(self, params):
|
||||
def _handle_add_schematic_wire(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a wire to a schematic using WireManager, with optional pin snapping"""
|
||||
logger.info("Adding wire to schematic")
|
||||
try:
|
||||
@@ -1165,7 +1166,7 @@ class KiCADInterface:
|
||||
for pin_num, coords in pin_locs.items():
|
||||
all_pins.append((ref, pin_num, coords))
|
||||
|
||||
def find_nearest_pin(point, tolerance):
|
||||
def find_nearest_pin(point: Any, tolerance: Any) -> Any:
|
||||
"""Find the nearest pin within tolerance of a point."""
|
||||
best = None
|
||||
best_dist = tolerance
|
||||
@@ -1239,7 +1240,7 @@ class KiCADInterface:
|
||||
"errorDetails": traceback.format_exc(),
|
||||
}
|
||||
|
||||
def _handle_add_schematic_junction(self, params):
|
||||
def _handle_add_schematic_junction(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a junction (connection dot) to a schematic using WireManager"""
|
||||
logger.info("Adding junction to schematic")
|
||||
try:
|
||||
@@ -1272,19 +1273,19 @@ class KiCADInterface:
|
||||
"errorDetails": traceback.format_exc(),
|
||||
}
|
||||
|
||||
def _handle_list_schematic_libraries(self, params):
|
||||
def _handle_list_schematic_libraries(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""List available symbol libraries"""
|
||||
logger.info("Listing schematic libraries")
|
||||
try:
|
||||
search_paths = params.get("searchPaths")
|
||||
|
||||
libraries = LibraryManager.list_available_libraries(search_paths)
|
||||
libraries = SchematicLibraryManager.list_available_libraries(search_paths)
|
||||
return {"success": True, "libraries": libraries}
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing schematic libraries: {str(e)}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_find_unconnected_pins(self, params):
|
||||
def _handle_find_unconnected_pins(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""List component pins with no wire/label/power symbol touching them"""
|
||||
logger.info("Finding unconnected pins")
|
||||
try:
|
||||
@@ -1304,7 +1305,7 @@ class KiCADInterface:
|
||||
logger.error(f"Error finding unconnected pins: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_check_wire_collisions(self, params):
|
||||
def _handle_check_wire_collisions(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Detect wires passing through component bodies without connecting to pins"""
|
||||
logger.info("Checking wire collisions")
|
||||
try:
|
||||
@@ -1328,7 +1329,7 @@ class KiCADInterface:
|
||||
# Footprint handlers #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _handle_create_footprint(self, params):
|
||||
def _handle_create_footprint(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create a new .kicad_mod footprint file in a .pretty library."""
|
||||
logger.info(f"create_footprint: {params.get('name')} in {params.get('libraryPath')}")
|
||||
try:
|
||||
@@ -1350,7 +1351,7 @@ class KiCADInterface:
|
||||
logger.error(f"create_footprint error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _handle_edit_footprint_pad(self, params):
|
||||
def _handle_edit_footprint_pad(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Edit an existing pad in a .kicad_mod file."""
|
||||
logger.info(
|
||||
f"edit_footprint_pad: pad {params.get('padNumber')} in {params.get('footprintPath')}"
|
||||
@@ -1369,7 +1370,7 @@ class KiCADInterface:
|
||||
logger.error(f"edit_footprint_pad error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _handle_list_footprint_libraries(self, params):
|
||||
def _handle_list_footprint_libraries(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""List .pretty footprint libraries and their contents."""
|
||||
logger.info("list_footprint_libraries")
|
||||
try:
|
||||
@@ -1379,7 +1380,7 @@ class KiCADInterface:
|
||||
logger.error(f"list_footprint_libraries error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _handle_register_footprint_library(self, params):
|
||||
def _handle_register_footprint_library(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Register a .pretty library in KiCAD's fp-lib-table."""
|
||||
logger.info(f"register_footprint_library: {params.get('libraryPath')}")
|
||||
try:
|
||||
@@ -1399,7 +1400,7 @@ class KiCADInterface:
|
||||
# Symbol creator handlers #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _handle_create_symbol(self, params):
|
||||
def _handle_create_symbol(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create a new symbol in a .kicad_sym library."""
|
||||
logger.info(f"create_symbol: {params.get('name')} in {params.get('libraryPath')}")
|
||||
try:
|
||||
@@ -1423,7 +1424,7 @@ class KiCADInterface:
|
||||
logger.error(f"create_symbol error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _handle_delete_symbol(self, params):
|
||||
def _handle_delete_symbol(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Delete a symbol from a .kicad_sym library."""
|
||||
logger.info(f"delete_symbol: {params.get('name')} from {params.get('libraryPath')}")
|
||||
try:
|
||||
@@ -1436,7 +1437,7 @@ class KiCADInterface:
|
||||
logger.error(f"delete_symbol error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _handle_list_symbols_in_library(self, params):
|
||||
def _handle_list_symbols_in_library(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""List all symbols in a .kicad_sym file."""
|
||||
logger.info(f"list_symbols_in_library: {params.get('libraryPath')}")
|
||||
try:
|
||||
@@ -1448,7 +1449,7 @@ class KiCADInterface:
|
||||
logger.error(f"list_symbols_in_library error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _handle_register_symbol_library(self, params):
|
||||
def _handle_register_symbol_library(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Register a .kicad_sym library in KiCAD's sym-lib-table."""
|
||||
logger.info(f"register_symbol_library: {params.get('libraryPath')}")
|
||||
try:
|
||||
@@ -1464,7 +1465,7 @@ class KiCADInterface:
|
||||
logger.error(f"register_symbol_library error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _handle_export_schematic_pdf(self, params):
|
||||
def _handle_export_schematic_pdf(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Export schematic to PDF"""
|
||||
logger.info("Exporting schematic to PDF")
|
||||
try:
|
||||
@@ -1513,7 +1514,7 @@ class KiCADInterface:
|
||||
logger.error(f"Error exporting schematic to PDF: {str(e)}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_add_schematic_net_label(self, params):
|
||||
def _handle_add_schematic_net_label(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a net label to schematic using WireManager"""
|
||||
logger.info("Adding net label to schematic")
|
||||
try:
|
||||
@@ -1559,7 +1560,7 @@ class KiCADInterface:
|
||||
"errorDetails": traceback.format_exc(),
|
||||
}
|
||||
|
||||
def _handle_connect_to_net(self, params):
|
||||
def _handle_connect_to_net(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Connect a component pin to a named net using wire stub and label"""
|
||||
logger.info("Connecting component pin to net")
|
||||
try:
|
||||
@@ -1596,7 +1597,7 @@ class KiCADInterface:
|
||||
"errorDetails": traceback.format_exc(),
|
||||
}
|
||||
|
||||
def _handle_connect_passthrough(self, params):
|
||||
def _handle_connect_passthrough(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Connect all pins of source connector to matching pins of target connector"""
|
||||
logger.info("Connecting passthrough between two connectors")
|
||||
try:
|
||||
@@ -1633,7 +1634,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_get_schematic_pin_locations(self, params):
|
||||
def _handle_get_schematic_pin_locations(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Return exact pin endpoint coordinates for a schematic component"""
|
||||
logger.info("Getting schematic pin locations")
|
||||
try:
|
||||
@@ -1688,7 +1689,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_get_schematic_view(self, params):
|
||||
def _handle_get_schematic_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get a rasterised image of the schematic (SVG export → optional PNG conversion)"""
|
||||
logger.info("Getting schematic view")
|
||||
import base64
|
||||
@@ -1777,7 +1778,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_list_schematic_components(self, params):
|
||||
def _handle_list_schematic_components(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""List all components in a schematic"""
|
||||
logger.info("Listing schematic components")
|
||||
try:
|
||||
@@ -1870,7 +1871,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_list_schematic_nets(self, params):
|
||||
def _handle_list_schematic_nets(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""List all nets in a schematic with their connections"""
|
||||
logger.info("Listing schematic nets")
|
||||
try:
|
||||
@@ -1916,7 +1917,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_list_schematic_wires(self, params):
|
||||
def _handle_list_schematic_wires(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""List all wires in a schematic"""
|
||||
logger.info("Listing schematic wires")
|
||||
try:
|
||||
@@ -1959,7 +1960,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_list_schematic_labels(self, params):
|
||||
def _handle_list_schematic_labels(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""List all net labels and power flags in a schematic"""
|
||||
logger.info("Listing schematic labels")
|
||||
try:
|
||||
@@ -2038,7 +2039,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_move_schematic_component(self, params):
|
||||
def _handle_move_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Move a schematic component to a new position, dragging connected wires."""
|
||||
logger.info("Moving schematic component")
|
||||
try:
|
||||
@@ -2122,7 +2123,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_rotate_schematic_component(self, params):
|
||||
def _handle_rotate_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Rotate a schematic component"""
|
||||
logger.info("Rotating schematic component")
|
||||
try:
|
||||
@@ -2172,7 +2173,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_annotate_schematic(self, params):
|
||||
def _handle_annotate_schematic(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Annotate unannotated components in schematic (R? -> R1, R2, ...)"""
|
||||
logger.info("Annotating schematic")
|
||||
try:
|
||||
@@ -2250,7 +2251,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_delete_schematic_wire(self, params):
|
||||
def _handle_delete_schematic_wire(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Delete a wire from the schematic matching start/end points"""
|
||||
logger.info("Deleting schematic wire")
|
||||
try:
|
||||
@@ -2281,7 +2282,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_delete_schematic_net_label(self, params):
|
||||
def _handle_delete_schematic_net_label(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Delete a net label from the schematic"""
|
||||
logger.info("Deleting schematic net label")
|
||||
try:
|
||||
@@ -2316,7 +2317,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_export_schematic_svg(self, params):
|
||||
def _handle_export_schematic_svg(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Export schematic to SVG using kicad-cli"""
|
||||
logger.info("Exporting schematic SVG")
|
||||
import glob
|
||||
@@ -2390,7 +2391,7 @@ class KiCADInterface:
|
||||
logger.error(f"Error exporting schematic SVG: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_get_net_connections(self, params):
|
||||
def _handle_get_net_connections(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get all connections for a named net"""
|
||||
logger.info("Getting net connections")
|
||||
try:
|
||||
@@ -2410,7 +2411,7 @@ class KiCADInterface:
|
||||
logger.error(f"Error getting net connections: {str(e)}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_get_wire_connections(self, params):
|
||||
def _handle_get_wire_connections(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Find all component pins reachable from a point via connected wires"""
|
||||
logger.info("Getting wire connections")
|
||||
try:
|
||||
@@ -2457,7 +2458,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_run_erc(self, params):
|
||||
def _handle_run_erc(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Run Electrical Rules Check on a schematic via kicad-cli"""
|
||||
logger.info("Running ERC on schematic")
|
||||
import os
|
||||
@@ -2553,7 +2554,7 @@ class KiCADInterface:
|
||||
logger.error(f"Error running ERC: {str(e)}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_generate_netlist(self, params):
|
||||
def _handle_generate_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Generate netlist from schematic"""
|
||||
logger.info("Generating netlist from schematic")
|
||||
try:
|
||||
@@ -2572,7 +2573,7 @@ class KiCADInterface:
|
||||
logger.error(f"Error generating netlist: {str(e)}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_sync_schematic_to_board(self, params):
|
||||
def _handle_sync_schematic_to_board(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Sync schematic netlist to PCB board (equivalent to KiCAD F8 'Update PCB from Schematic').
|
||||
Reads net connections from the schematic and assigns them to the matching pads in the PCB.
|
||||
"""
|
||||
@@ -2695,7 +2696,7 @@ class KiCADInterface:
|
||||
# Schematic analysis tools (read-only)
|
||||
# ===================================================================
|
||||
|
||||
def _handle_get_schematic_view_region(self, params):
|
||||
def _handle_get_schematic_view_region(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Export a cropped region of the schematic as an image"""
|
||||
logger.info("Exporting schematic view region")
|
||||
import base64
|
||||
@@ -2810,7 +2811,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_find_overlapping_elements(self, params):
|
||||
def _handle_find_overlapping_elements(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Detect spatially overlapping symbols, wires, and labels"""
|
||||
logger.info("Finding overlapping elements in schematic")
|
||||
try:
|
||||
@@ -2836,7 +2837,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_get_elements_in_region(self, params):
|
||||
def _handle_get_elements_in_region(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""List all wires, labels, and symbols within a rectangular region"""
|
||||
logger.info("Getting elements in schematic region")
|
||||
try:
|
||||
@@ -2866,7 +2867,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_find_wires_crossing_symbols(self, params):
|
||||
def _handle_find_wires_crossing_symbols(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Find wires that cross over component symbol bodies"""
|
||||
logger.info("Finding wires crossing symbols in schematic")
|
||||
try:
|
||||
@@ -2892,7 +2893,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_import_svg_logo(self, params):
|
||||
def _handle_import_svg_logo(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Import an SVG file as PCB graphic polygons on the silkscreen"""
|
||||
logger.info("Importing SVG logo into PCB")
|
||||
try:
|
||||
@@ -2939,7 +2940,7 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_snapshot_project(self, params):
|
||||
def _handle_snapshot_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Copy the entire project folder to a snapshot directory for checkpoint/resume."""
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
@@ -3023,7 +3024,7 @@ class KiCADInterface:
|
||||
logger.error(f"snapshot_project error: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_check_kicad_ui(self, params):
|
||||
def _handle_check_kicad_ui(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Check if KiCAD UI is running"""
|
||||
logger.info("Checking if KiCAD UI is running")
|
||||
try:
|
||||
@@ -3041,7 +3042,7 @@ class KiCADInterface:
|
||||
logger.error(f"Error checking KiCAD UI status: {str(e)}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_launch_kicad_ui(self, params):
|
||||
def _handle_launch_kicad_ui(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Launch KiCAD UI"""
|
||||
logger.info("Launching KiCAD UI")
|
||||
try:
|
||||
@@ -3060,7 +3061,7 @@ class KiCADInterface:
|
||||
logger.error(f"Error launching KiCAD UI: {str(e)}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_refill_zones(self, params):
|
||||
def _handle_refill_zones(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Refill all copper pour zones on the board.
|
||||
|
||||
pcbnew.ZONE_FILLER.Fill() can cause a C++ access violation (0xC0000005)
|
||||
@@ -3147,7 +3148,7 @@ print("ok")
|
||||
# These methods are called automatically when IPC is available
|
||||
# =========================================================================
|
||||
|
||||
def _ipc_route_trace(self, params):
|
||||
def _ipc_route_trace(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for route_trace - adds track with real-time UI update"""
|
||||
try:
|
||||
# Extract parameters matching the existing route_trace interface
|
||||
@@ -3190,7 +3191,7 @@ print("ok")
|
||||
logger.error(f"IPC route_trace error: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _ipc_add_via(self, params):
|
||||
def _ipc_add_via(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for add_via - adds via with real-time UI update"""
|
||||
try:
|
||||
position = params.get("position", {})
|
||||
@@ -3223,7 +3224,7 @@ print("ok")
|
||||
logger.error(f"IPC add_via error: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _ipc_add_net(self, params):
|
||||
def _ipc_add_net(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for add_net"""
|
||||
# Note: Net creation via IPC is limited - nets are typically created
|
||||
# when components are placed. Return success for compatibility.
|
||||
@@ -3235,7 +3236,7 @@ print("ok")
|
||||
"net": {"name": name},
|
||||
}
|
||||
|
||||
def _ipc_add_copper_pour(self, params):
|
||||
def _ipc_add_copper_pour(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for add_copper_pour - adds zone with real-time UI update"""
|
||||
try:
|
||||
layer = params.get("layer", "F.Cu")
|
||||
@@ -3290,7 +3291,7 @@ print("ok")
|
||||
logger.error(f"IPC add_copper_pour error: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _ipc_refill_zones(self, params):
|
||||
def _ipc_refill_zones(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for refill_zones - refills all zones with real-time UI update"""
|
||||
try:
|
||||
success = self.ipc_board_api.refill_zones()
|
||||
@@ -3305,7 +3306,7 @@ print("ok")
|
||||
logger.error(f"IPC refill_zones error: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _ipc_add_text(self, params):
|
||||
def _ipc_add_text(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for add_text/add_board_text - adds text with real-time UI update"""
|
||||
try:
|
||||
text = params.get("text", "")
|
||||
@@ -3332,7 +3333,7 @@ print("ok")
|
||||
logger.error(f"IPC add_text error: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _ipc_set_board_size(self, params):
|
||||
def _ipc_set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for set_board_size"""
|
||||
try:
|
||||
width = params.get("width", 100)
|
||||
@@ -3354,7 +3355,7 @@ print("ok")
|
||||
logger.error(f"IPC set_board_size error: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _ipc_get_board_info(self, params):
|
||||
def _ipc_get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for get_board_info"""
|
||||
try:
|
||||
size = self.ipc_board_api.get_size()
|
||||
@@ -3379,7 +3380,7 @@ print("ok")
|
||||
logger.error(f"IPC get_board_info error: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _ipc_place_component(self, params):
|
||||
def _ipc_place_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for place_component - places component with real-time UI update"""
|
||||
try:
|
||||
reference = params.get("reference", params.get("componentId", ""))
|
||||
@@ -3420,7 +3421,7 @@ print("ok")
|
||||
logger.error(f"IPC place_component error: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _ipc_move_component(self, params):
|
||||
def _ipc_move_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for move_component - moves component with real-time UI update"""
|
||||
try:
|
||||
reference = params.get("reference", params.get("componentId", ""))
|
||||
@@ -3445,7 +3446,7 @@ print("ok")
|
||||
logger.error(f"IPC move_component error: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _ipc_delete_component(self, params):
|
||||
def _ipc_delete_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for delete_component - deletes component with real-time UI update"""
|
||||
try:
|
||||
reference = params.get("reference", params.get("componentId", ""))
|
||||
@@ -3464,7 +3465,7 @@ print("ok")
|
||||
logger.error(f"IPC delete_component error: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _ipc_get_component_list(self, params):
|
||||
def _ipc_get_component_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for get_component_list"""
|
||||
try:
|
||||
components = self.ipc_board_api.list_components()
|
||||
@@ -3474,7 +3475,7 @@ print("ok")
|
||||
logger.error(f"IPC get_component_list error: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _ipc_save_project(self, params):
|
||||
def _ipc_save_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for save_project"""
|
||||
try:
|
||||
success = self.ipc_board_api.save()
|
||||
@@ -3487,14 +3488,14 @@ print("ok")
|
||||
logger.error(f"IPC save_project error: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _ipc_delete_trace(self, params):
|
||||
def _ipc_delete_trace(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for delete_trace - Note: IPC doesn't support direct trace deletion yet"""
|
||||
# IPC API doesn't have a direct delete track method
|
||||
# Fall back to SWIG for this operation
|
||||
logger.info("delete_trace: Falling back to SWIG (IPC doesn't support trace deletion)")
|
||||
return self.routing_commands.delete_trace(params)
|
||||
|
||||
def _ipc_get_nets_list(self, params):
|
||||
def _ipc_get_nets_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for get_nets_list - gets nets with real-time data"""
|
||||
try:
|
||||
nets = self.ipc_board_api.get_nets()
|
||||
@@ -3504,7 +3505,7 @@ print("ok")
|
||||
logger.error(f"IPC get_nets_list error: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _ipc_add_board_outline(self, params):
|
||||
def _ipc_add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for add_board_outline - adds board edge with real-time UI update.
|
||||
Rounded rectangles are delegated to the SWIG path because the IPC BoardSegment
|
||||
type cannot represent arcs; the SWIG path writes directly to the .kicad_pcb file
|
||||
@@ -3567,7 +3568,7 @@ print("ok")
|
||||
logger.error(f"IPC add_board_outline error: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _ipc_add_mounting_hole(self, params):
|
||||
def _ipc_add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for add_mounting_hole - adds mounting hole with real-time UI update"""
|
||||
try:
|
||||
from kipy.board_types import BoardCircle
|
||||
@@ -3602,7 +3603,7 @@ print("ok")
|
||||
logger.error(f"IPC add_mounting_hole error: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _ipc_get_layer_list(self, params):
|
||||
def _ipc_get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for get_layer_list - gets enabled layers"""
|
||||
try:
|
||||
layers = self.ipc_board_api.get_enabled_layers()
|
||||
@@ -3612,7 +3613,7 @@ print("ok")
|
||||
logger.error(f"IPC get_layer_list error: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _ipc_rotate_component(self, params):
|
||||
def _ipc_rotate_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for rotate_component - rotates component with real-time UI update"""
|
||||
try:
|
||||
reference = params.get("reference", params.get("componentId", ""))
|
||||
@@ -3654,7 +3655,7 @@ print("ok")
|
||||
logger.error(f"IPC rotate_component error: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _ipc_get_component_properties(self, params):
|
||||
def _ipc_get_component_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""IPC handler for get_component_properties - gets detailed component info"""
|
||||
try:
|
||||
reference = params.get("reference", params.get("componentId", ""))
|
||||
@@ -3678,7 +3679,7 @@ print("ok")
|
||||
# Legacy IPC command handlers (explicit ipc_* commands)
|
||||
# =========================================================================
|
||||
|
||||
def _handle_get_backend_info(self, params):
|
||||
def _handle_get_backend_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get information about the current backend"""
|
||||
return {
|
||||
"success": True,
|
||||
@@ -3693,7 +3694,7 @@ print("ok")
|
||||
),
|
||||
}
|
||||
|
||||
def _handle_ipc_add_track(self, params):
|
||||
def _handle_ipc_add_track(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a track using IPC backend (real-time)"""
|
||||
if not self.use_ipc or not self.ipc_board_api:
|
||||
return {"success": False, "message": "IPC backend not available"}
|
||||
@@ -3719,7 +3720,7 @@ print("ok")
|
||||
logger.error(f"Error adding track via IPC: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_ipc_add_via(self, params):
|
||||
def _handle_ipc_add_via(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a via using IPC backend (real-time)"""
|
||||
if not self.use_ipc or not self.ipc_board_api:
|
||||
return {"success": False, "message": "IPC backend not available"}
|
||||
@@ -3742,7 +3743,7 @@ print("ok")
|
||||
logger.error(f"Error adding via via IPC: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_ipc_add_text(self, params):
|
||||
def _handle_ipc_add_text(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add text using IPC backend (real-time)"""
|
||||
if not self.use_ipc or not self.ipc_board_api:
|
||||
return {"success": False, "message": "IPC backend not available"}
|
||||
@@ -3767,7 +3768,7 @@ print("ok")
|
||||
logger.error(f"Error adding text via IPC: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_ipc_list_components(self, params):
|
||||
def _handle_ipc_list_components(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""List components using IPC backend"""
|
||||
if not self.use_ipc or not self.ipc_board_api:
|
||||
return {"success": False, "message": "IPC backend not available"}
|
||||
@@ -3779,7 +3780,7 @@ print("ok")
|
||||
logger.error(f"Error listing components via IPC: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_ipc_get_tracks(self, params):
|
||||
def _handle_ipc_get_tracks(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get tracks using IPC backend"""
|
||||
if not self.use_ipc or not self.ipc_board_api:
|
||||
return {"success": False, "message": "IPC backend not available"}
|
||||
@@ -3791,7 +3792,7 @@ print("ok")
|
||||
logger.error(f"Error getting tracks via IPC: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_ipc_get_vias(self, params):
|
||||
def _handle_ipc_get_vias(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get vias using IPC backend"""
|
||||
if not self.use_ipc or not self.ipc_board_api:
|
||||
return {"success": False, "message": "IPC backend not available"}
|
||||
@@ -3803,7 +3804,7 @@ print("ok")
|
||||
logger.error(f"Error getting vias via IPC: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_ipc_save_board(self, params):
|
||||
def _handle_ipc_save_board(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Save board using IPC backend"""
|
||||
if not self.use_ipc or not self.ipc_board_api:
|
||||
return {"success": False, "message": "IPC backend not available"}
|
||||
@@ -3820,7 +3821,7 @@ print("ok")
|
||||
|
||||
# JLCPCB API handlers
|
||||
|
||||
def _handle_download_jlcpcb_database(self, params):
|
||||
def _handle_download_jlcpcb_database(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Download JLCPCB parts database from JLCSearch API"""
|
||||
try:
|
||||
force = params.get("force", False)
|
||||
@@ -3871,7 +3872,7 @@ print("ok")
|
||||
"message": f"Failed to download database: {str(e)}",
|
||||
}
|
||||
|
||||
def _handle_search_jlcpcb_parts(self, params):
|
||||
def _handle_search_jlcpcb_parts(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Search JLCPCB parts database"""
|
||||
try:
|
||||
query = params.get("query")
|
||||
@@ -3910,7 +3911,7 @@ print("ok")
|
||||
logger.error(f"Error searching JLCPCB parts: {e}", exc_info=True)
|
||||
return {"success": False, "message": f"Search failed: {str(e)}"}
|
||||
|
||||
def _handle_get_jlcpcb_part(self, params):
|
||||
def _handle_get_jlcpcb_part(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get detailed information for a specific JLCPCB part"""
|
||||
try:
|
||||
lcsc_number = params.get("lcsc_number")
|
||||
@@ -3930,7 +3931,7 @@ print("ok")
|
||||
logger.error(f"Error getting JLCPCB part: {e}", exc_info=True)
|
||||
return {"success": False, "message": f"Failed to get part info: {str(e)}"}
|
||||
|
||||
def _handle_get_jlcpcb_database_stats(self, params):
|
||||
def _handle_get_jlcpcb_database_stats(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get statistics about JLCPCB database"""
|
||||
try:
|
||||
stats = self.jlcpcb_parts.get_database_stats()
|
||||
@@ -3940,7 +3941,7 @@ print("ok")
|
||||
logger.error(f"Error getting database stats: {e}", exc_info=True)
|
||||
return {"success": False, "message": f"Failed to get stats: {str(e)}"}
|
||||
|
||||
def _handle_suggest_jlcpcb_alternatives(self, params):
|
||||
def _handle_suggest_jlcpcb_alternatives(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Suggest alternative JLCPCB parts"""
|
||||
try:
|
||||
lcsc_number = params.get("lcsc_number")
|
||||
@@ -3981,7 +3982,7 @@ print("ok")
|
||||
"message": f"Failed to suggest alternatives: {str(e)}",
|
||||
}
|
||||
|
||||
def _handle_enrich_datasheets(self, params):
|
||||
def _handle_enrich_datasheets(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Enrich schematic Datasheet fields from LCSC numbers"""
|
||||
try:
|
||||
from pathlib import Path
|
||||
@@ -3999,7 +4000,7 @@ print("ok")
|
||||
"message": f"Failed to enrich datasheets: {str(e)}",
|
||||
}
|
||||
|
||||
def _handle_get_datasheet_url(self, params):
|
||||
def _handle_get_datasheet_url(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Return LCSC datasheet and product URLs for a part number"""
|
||||
try:
|
||||
lcsc = params.get("lcsc", "")
|
||||
@@ -4025,8 +4026,29 @@ print("ok")
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
def _write_response(response_fd: Any, response: Any) -> None:
|
||||
"""Write a JSON response to the original stdout fd.
|
||||
|
||||
All response output goes through this function so that stray C-level
|
||||
writes from pcbnew (warnings, diagnostics) never corrupt the JSON
|
||||
framing seen by the TypeScript host.
|
||||
"""
|
||||
payload = json.dumps(response) + "\n"
|
||||
os.write(response_fd, payload.encode("utf-8"))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point"""
|
||||
# --- Redirect stdout so pcbnew C++ noise never reaches the TS host ---
|
||||
# Save the real stdout fd for our exclusive JSON response channel.
|
||||
_response_fd = os.dup(1)
|
||||
# Point fd 1 (C-level stdout) at stderr so that any printf / std::cout
|
||||
# output from pcbnew or other C extensions is visible in logs but does
|
||||
# NOT corrupt the JSON stream the TypeScript side is parsing.
|
||||
os.dup2(2, 1)
|
||||
# Also redirect Python-level stdout to stderr for the same reason.
|
||||
sys.stdout = sys.stderr
|
||||
|
||||
logger.info("Starting KiCAD interface...")
|
||||
interface = KiCADInterface()
|
||||
|
||||
@@ -4168,10 +4190,9 @@ def main():
|
||||
# Handle command
|
||||
response = interface.handle_command(command, params)
|
||||
|
||||
# Send response
|
||||
# Send response via the clean fd (immune to pcbnew stdout noise)
|
||||
logger.debug(f"Sending response: {response}")
|
||||
print(json.dumps(response))
|
||||
sys.stdout.flush()
|
||||
_write_response(_response_fd, response)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Invalid JSON input: {str(e)}")
|
||||
@@ -4180,8 +4201,7 @@ def main():
|
||||
"message": "Invalid JSON input",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
print(json.dumps(response))
|
||||
sys.stdout.flush()
|
||||
_write_response(_response_fd, response)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("KiCAD interface stopped")
|
||||
|
||||
@@ -72,7 +72,7 @@ RESOURCE_DEFINITIONS = [
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
|
||||
def handle_resource_read(uri: str, interface: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
Handle reading a resource by URI
|
||||
|
||||
@@ -116,7 +116,7 @@ def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _get_project_info(interface) -> Dict[str, Any]:
|
||||
def _get_project_info(interface: Any) -> Dict[str, Any]:
|
||||
"""Get current project information"""
|
||||
result = interface.project_commands.get_project_info({})
|
||||
|
||||
@@ -142,7 +142,7 @@ def _get_project_info(interface) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _get_board_info(interface) -> Dict[str, Any]:
|
||||
def _get_board_info(interface: Any) -> Dict[str, Any]:
|
||||
"""Get board properties and metadata"""
|
||||
result = interface.board_commands.get_board_info({})
|
||||
|
||||
@@ -168,7 +168,7 @@ def _get_board_info(interface) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _get_components(interface) -> Dict[str, Any]:
|
||||
def _get_components(interface: Any) -> Dict[str, Any]:
|
||||
"""Get list of all components"""
|
||||
result = interface.component_commands.get_component_list({})
|
||||
|
||||
@@ -197,7 +197,7 @@ def _get_components(interface) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _get_nets(interface) -> Dict[str, Any]:
|
||||
def _get_nets(interface: Any) -> Dict[str, Any]:
|
||||
"""Get list of electrical nets"""
|
||||
result = interface.routing_commands.get_nets_list({})
|
||||
|
||||
@@ -224,7 +224,7 @@ def _get_nets(interface) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _get_layers(interface) -> Dict[str, Any]:
|
||||
def _get_layers(interface: Any) -> Dict[str, Any]:
|
||||
"""Get layer stack information"""
|
||||
result = interface.board_commands.get_layer_list({})
|
||||
|
||||
@@ -251,7 +251,7 @@ def _get_layers(interface) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _get_design_rules(interface) -> Dict[str, Any]:
|
||||
def _get_design_rules(interface: Any) -> Dict[str, Any]:
|
||||
"""Get design rule settings"""
|
||||
result = interface.design_rule_commands.get_design_rules({})
|
||||
|
||||
@@ -277,7 +277,7 @@ def _get_design_rules(interface) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _get_drc_report(interface) -> Dict[str, Any]:
|
||||
def _get_drc_report(interface: Any) -> Dict[str, Any]:
|
||||
"""Get DRC violations"""
|
||||
result = interface.design_rule_commands.get_drc_violations({})
|
||||
|
||||
@@ -313,7 +313,7 @@ def _get_drc_report(interface) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _get_board_preview(interface) -> Dict[str, Any]:
|
||||
def _get_board_preview(interface: Any) -> Dict[str, Any]:
|
||||
"""Get board preview as PNG image"""
|
||||
result = interface.board_commands.get_board_2d_view({"width": 800, "height": 600})
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ Usage:
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Optional
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -29,7 +30,7 @@ logging.basicConfig(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def test_connection():
|
||||
def test_connection() -> Optional[Any]:
|
||||
"""Test basic IPC connection to KiCAD."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 1: IPC Connection")
|
||||
@@ -62,7 +63,7 @@ def test_connection():
|
||||
return None
|
||||
|
||||
|
||||
def test_board_access(backend):
|
||||
def test_board_access(backend: Any) -> Optional[Any]:
|
||||
"""Test board access and component listing."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 2: Board Access")
|
||||
@@ -93,7 +94,7 @@ def test_board_access(backend):
|
||||
return None
|
||||
|
||||
|
||||
def test_board_info(board_api):
|
||||
def test_board_info(board_api: Any) -> bool:
|
||||
"""Test getting board information."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 3: Board Information")
|
||||
@@ -134,7 +135,7 @@ def test_board_info(board_api):
|
||||
return False
|
||||
|
||||
|
||||
def test_realtime_track(board_api, interactive=False):
|
||||
def test_realtime_track(board_api: Any, interactive: bool = False) -> bool:
|
||||
"""Test adding a track in real-time (appears immediately in KiCAD UI)."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 4: Real-time Track Addition")
|
||||
@@ -168,7 +169,7 @@ def test_realtime_track(board_api, interactive=False):
|
||||
return False
|
||||
|
||||
|
||||
def test_realtime_via(board_api, interactive=False):
|
||||
def test_realtime_via(board_api: Any, interactive: bool = False) -> bool:
|
||||
"""Test adding a via in real-time (appears immediately in KiCAD UI)."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 5: Real-time Via Addition")
|
||||
@@ -200,7 +201,7 @@ def test_realtime_via(board_api, interactive=False):
|
||||
return False
|
||||
|
||||
|
||||
def test_realtime_text(board_api, interactive=False):
|
||||
def test_realtime_text(board_api: Any, interactive: bool = False) -> bool:
|
||||
"""Test adding text in real-time."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 6: Real-time Text Addition")
|
||||
@@ -229,7 +230,7 @@ def test_realtime_text(board_api, interactive=False):
|
||||
return False
|
||||
|
||||
|
||||
def test_selection(board_api, interactive=False):
|
||||
def test_selection(board_api: Any, interactive: bool = False) -> bool:
|
||||
"""Test getting the current selection from KiCAD UI."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 7: UI Selection")
|
||||
@@ -255,7 +256,7 @@ def test_selection(board_api, interactive=False):
|
||||
return False
|
||||
|
||||
|
||||
def run_all_tests(interactive=False):
|
||||
def run_all_tests(interactive: bool = False) -> bool:
|
||||
"""Run all IPC backend tests."""
|
||||
print("\n" + "=" * 60)
|
||||
print("KiCAD IPC Backend Test Suite")
|
||||
|
||||
@@ -10,6 +10,7 @@ import re
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -121,32 +122,32 @@ class TestDeleteDetectionRegex:
|
||||
OLD_PATTERN = re.compile(r"^\s*\(symbol\s+\(lib_id\s+\"", re.MULTILINE)
|
||||
NEW_PATTERN = re.compile(r'\(symbol\s+\(lib_id\s+"')
|
||||
|
||||
def test_old_regex_fails_on_multiline_format(self):
|
||||
def test_old_regex_fails_on_multiline_format(self) -> None:
|
||||
"""Regression: old line-by-line regex must NOT match the multi-line format."""
|
||||
# The old code used re.match on individual lines; simulate that here.
|
||||
lines = PLACED_RESISTOR_MULTILINE.split("\n")
|
||||
matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)]
|
||||
assert matches == [], "Old regex should not match multi-line KiCAD format"
|
||||
|
||||
def test_old_regex_matches_inline_format(self):
|
||||
def test_old_regex_matches_inline_format(self) -> None:
|
||||
"""Old regex did work on single-line (inline) format."""
|
||||
lines = PLACED_RESISTOR_INLINE.split("\n")
|
||||
matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)]
|
||||
assert len(matches) == 1
|
||||
|
||||
def test_new_pattern_matches_multiline_format(self):
|
||||
def test_new_pattern_matches_multiline_format(self) -> None:
|
||||
"""New content-string pattern must find blocks in multi-line format."""
|
||||
assert self.NEW_PATTERN.search(PLACED_RESISTOR_MULTILINE) is not None
|
||||
|
||||
def test_new_pattern_matches_inline_format(self):
|
||||
def test_new_pattern_matches_inline_format(self) -> None:
|
||||
"""New content-string pattern also works on inline format."""
|
||||
assert self.NEW_PATTERN.search(PLACED_RESISTOR_INLINE) is not None
|
||||
|
||||
def test_new_pattern_matches_power_symbol_multiline(self):
|
||||
def test_new_pattern_matches_power_symbol_multiline(self) -> None:
|
||||
"""New pattern must find #PWR030 power symbol in multi-line format."""
|
||||
assert self.NEW_PATTERN.search(PLACED_POWER_SYMBOL_MULTILINE) is not None
|
||||
|
||||
def test_reference_extraction_from_multiline_block(self):
|
||||
def test_reference_extraction_from_multiline_block(self) -> None:
|
||||
"""Reference property can be found inside a multi-line block."""
|
||||
ref_pattern = re.compile(r'\(property\s+"Reference"\s+"#PWR030"')
|
||||
assert ref_pattern.search(PLACED_POWER_SYMBOL_MULTILINE) is not None
|
||||
@@ -159,49 +160,49 @@ class TestDeleteDetectionRegex:
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDeleteSchematicComponentIntegration:
|
||||
def _get_handler(self):
|
||||
def _get_handler(self) -> Any:
|
||||
from kicad_interface import KiCADInterface
|
||||
|
||||
iface = KiCADInterface.__new__(KiCADInterface)
|
||||
return iface._handle_delete_schematic_component
|
||||
|
||||
def test_delete_inline_format_succeeds(self, tmp_path):
|
||||
def test_delete_inline_format_succeeds(self, tmp_path: Any) -> None:
|
||||
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE)
|
||||
result = self._get_handler()({"schematicPath": str(sch), "reference": "R1"})
|
||||
assert result["success"] is True
|
||||
assert result["deleted_count"] == 1
|
||||
|
||||
def test_delete_multiline_format_succeeds(self, tmp_path):
|
||||
def test_delete_multiline_format_succeeds(self, tmp_path: Any) -> None:
|
||||
"""Regression: must succeed when KiCAD writes (symbol and (lib_id on separate lines."""
|
||||
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_MULTILINE)
|
||||
result = self._get_handler()({"schematicPath": str(sch), "reference": "R2"})
|
||||
assert result["success"] is True
|
||||
assert result["deleted_count"] == 1
|
||||
|
||||
def test_delete_power_symbol_multiline_succeeds(self, tmp_path):
|
||||
def test_delete_power_symbol_multiline_succeeds(self, tmp_path: Any) -> None:
|
||||
"""Regression: #PWR030 multi-line power symbol must be deletable."""
|
||||
sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE)
|
||||
result = self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"})
|
||||
assert result["success"] is True
|
||||
assert result["deleted_count"] == 1
|
||||
|
||||
def test_component_absent_after_delete(self, tmp_path):
|
||||
def test_component_absent_after_delete(self, tmp_path: Any) -> None:
|
||||
sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE)
|
||||
self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"})
|
||||
remaining = sch.read_text(encoding="utf-8")
|
||||
assert '"#PWR030"' not in remaining
|
||||
|
||||
def test_unknown_reference_returns_failure(self, tmp_path):
|
||||
def test_unknown_reference_returns_failure(self, tmp_path: Any) -> None:
|
||||
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE)
|
||||
result = self._get_handler()({"schematicPath": str(sch), "reference": "U99"})
|
||||
assert result["success"] is False
|
||||
assert "not found" in result["message"]
|
||||
|
||||
def test_missing_schematic_path_returns_failure(self, tmp_path):
|
||||
def test_missing_schematic_path_returns_failure(self, tmp_path: Any) -> None:
|
||||
result = self._get_handler()({"reference": "R1"})
|
||||
assert result["success"] is False
|
||||
|
||||
def test_missing_reference_returns_failure(self, tmp_path):
|
||||
def test_missing_reference_returns_failure(self, tmp_path: Any) -> None:
|
||||
sch = _make_test_schematic(tmp_path)
|
||||
result = self._get_handler()({"schematicPath": str(sch)})
|
||||
assert result["success"] is False
|
||||
|
||||
@@ -12,6 +12,7 @@ Covers:
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -33,7 +34,7 @@ pcbnew_mock = sys.modules["pcbnew"]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_pcbnew_mock():
|
||||
def reset_pcbnew_mock() -> Any:
|
||||
"""Reset pcbnew mock before each test."""
|
||||
pcbnew_mock.reset_mock()
|
||||
pcbnew_mock.ExportSpecctraDSN.side_effect = None
|
||||
@@ -44,7 +45,7 @@ def reset_pcbnew_mock():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_board():
|
||||
def mock_board() -> Any:
|
||||
board = MagicMock()
|
||||
board.GetFileName.return_value = "/tmp/test_project/test.kicad_pcb"
|
||||
board.GetTracks.return_value = []
|
||||
@@ -52,16 +53,16 @@ def mock_board():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cmds(mock_board):
|
||||
def cmds(mock_board: Any) -> Any:
|
||||
return FreeroutingCommands(board=mock_board)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cmds_no_board():
|
||||
def cmds_no_board() -> Any:
|
||||
return FreeroutingCommands(board=None)
|
||||
|
||||
|
||||
def _patch_direct_java():
|
||||
def _patch_direct_java() -> Any:
|
||||
"""Patch to simulate Java 21+ available locally."""
|
||||
return patch.object(
|
||||
FreeroutingCommands,
|
||||
@@ -70,7 +71,7 @@ def _patch_direct_java():
|
||||
)
|
||||
|
||||
|
||||
def _patch_docker_mode():
|
||||
def _patch_docker_mode() -> Any:
|
||||
"""Patch to simulate Docker execution mode."""
|
||||
return patch.object(
|
||||
FreeroutingCommands,
|
||||
@@ -79,7 +80,7 @@ def _patch_docker_mode():
|
||||
)
|
||||
|
||||
|
||||
def _patch_no_runtime():
|
||||
def _patch_no_runtime() -> Any:
|
||||
"""Patch to simulate no Java and no Docker."""
|
||||
return patch.object(
|
||||
FreeroutingCommands,
|
||||
@@ -97,7 +98,7 @@ def _patch_no_runtime():
|
||||
|
||||
|
||||
class TestCheckFreerouting:
|
||||
def test_no_java_no_docker(self, cmds):
|
||||
def test_no_java_no_docker(self, cmds: Any) -> None:
|
||||
with (
|
||||
patch("commands.freerouting._find_java", return_value=None),
|
||||
patch(
|
||||
@@ -112,7 +113,7 @@ class TestCheckFreerouting:
|
||||
assert result["ready"] is False
|
||||
assert result["execution_mode"] == "none"
|
||||
|
||||
def test_java_too_old_docker_available(self, cmds, tmp_path):
|
||||
def test_java_too_old_docker_available(self, cmds: Any, tmp_path: Any) -> None:
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
with (
|
||||
@@ -135,7 +136,7 @@ class TestCheckFreerouting:
|
||||
assert result["ready"] is True
|
||||
assert result["execution_mode"] == "docker"
|
||||
|
||||
def test_java_21_direct(self, cmds, tmp_path):
|
||||
def test_java_21_direct(self, cmds: Any, tmp_path: Any) -> None:
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
with (
|
||||
@@ -165,12 +166,12 @@ class TestCheckFreerouting:
|
||||
|
||||
|
||||
class TestExportDsn:
|
||||
def test_no_board(self, cmds_no_board):
|
||||
def test_no_board(self, cmds_no_board: Any) -> None:
|
||||
result = cmds_no_board.export_dsn({})
|
||||
assert result["success"] is False
|
||||
assert "No board" in result["message"]
|
||||
|
||||
def test_export_success(self, cmds, tmp_path):
|
||||
def test_export_success(self, cmds: Any, tmp_path: Any) -> None:
|
||||
board_path = str(tmp_path / "test.kicad_pcb")
|
||||
dsn_path = str(tmp_path / "test.dsn")
|
||||
cmds.board.GetFileName.return_value = board_path
|
||||
@@ -182,7 +183,7 @@ class TestExportDsn:
|
||||
assert result["success"] is True
|
||||
assert result["path"] == dsn_path
|
||||
|
||||
def test_export_custom_path(self, cmds, tmp_path):
|
||||
def test_export_custom_path(self, cmds: Any, tmp_path: Any) -> None:
|
||||
output = str(tmp_path / "custom.dsn")
|
||||
pcbnew_mock.ExportSpecctraDSN.return_value = True
|
||||
Path(output).write_text("(pcb test)")
|
||||
@@ -191,7 +192,7 @@ class TestExportDsn:
|
||||
assert result["success"] is True
|
||||
assert result["path"] == output
|
||||
|
||||
def test_export_failure(self, cmds):
|
||||
def test_export_failure(self, cmds: Any) -> None:
|
||||
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("DSN error")
|
||||
result = cmds.export_dsn({})
|
||||
assert result["success"] is False
|
||||
@@ -204,22 +205,22 @@ class TestExportDsn:
|
||||
|
||||
|
||||
class TestImportSes:
|
||||
def test_no_board(self, cmds_no_board):
|
||||
def test_no_board(self, cmds_no_board: Any) -> None:
|
||||
result = cmds_no_board.import_ses({"sesPath": "/tmp/test.ses"})
|
||||
assert result["success"] is False
|
||||
assert "No board" in result["message"]
|
||||
|
||||
def test_missing_ses_path(self, cmds):
|
||||
def test_missing_ses_path(self, cmds: Any) -> None:
|
||||
result = cmds.import_ses({})
|
||||
assert result["success"] is False
|
||||
assert "Missing sesPath" in result["message"]
|
||||
|
||||
def test_ses_file_not_found(self, cmds):
|
||||
def test_ses_file_not_found(self, cmds: Any) -> None:
|
||||
result = cmds.import_ses({"sesPath": "/nonexistent/test.ses"})
|
||||
assert result["success"] is False
|
||||
assert "not found" in result["message"]
|
||||
|
||||
def test_import_success(self, cmds, tmp_path):
|
||||
def test_import_success(self, cmds: Any, tmp_path: Any) -> None:
|
||||
ses_file = tmp_path / "test.ses"
|
||||
ses_file.write_text("(session test)")
|
||||
|
||||
@@ -229,7 +230,7 @@ class TestImportSes:
|
||||
result = cmds.import_ses({"sesPath": str(ses_file)})
|
||||
assert result["success"] is True
|
||||
|
||||
def test_import_failure(self, cmds, tmp_path):
|
||||
def test_import_failure(self, cmds: Any, tmp_path: Any) -> None:
|
||||
ses_file = tmp_path / "test.ses"
|
||||
ses_file.write_text("(session test)")
|
||||
|
||||
@@ -245,12 +246,12 @@ class TestImportSes:
|
||||
|
||||
|
||||
class TestAutoroute:
|
||||
def test_no_board(self, cmds_no_board):
|
||||
def test_no_board(self, cmds_no_board: Any) -> None:
|
||||
result = cmds_no_board.autoroute({})
|
||||
assert result["success"] is False
|
||||
assert "No board" in result["message"]
|
||||
|
||||
def test_no_runtime(self, cmds, tmp_path):
|
||||
def test_no_runtime(self, cmds: Any, tmp_path: Any) -> None:
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
with _patch_no_runtime():
|
||||
@@ -258,13 +259,13 @@ class TestAutoroute:
|
||||
assert result["success"] is False
|
||||
assert "No suitable Java runtime" in result["message"]
|
||||
|
||||
def test_no_jar(self, cmds):
|
||||
def test_no_jar(self, cmds: Any) -> None:
|
||||
result = cmds.autoroute({"freeroutingJar": "/nonexistent/freerouting.jar"})
|
||||
assert result["success"] is False
|
||||
assert "JAR not found" in result["message"]
|
||||
|
||||
@patch("commands.freerouting.subprocess.run")
|
||||
def test_dsn_export_fails(self, mock_run, cmds, tmp_path):
|
||||
def test_dsn_export_fails(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None:
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
|
||||
@@ -276,7 +277,7 @@ class TestAutoroute:
|
||||
assert "DSN export failed" in result["message"]
|
||||
|
||||
@patch("commands.freerouting.subprocess.run")
|
||||
def test_freerouting_timeout(self, mock_run, cmds, tmp_path):
|
||||
def test_freerouting_timeout(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None:
|
||||
import subprocess
|
||||
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
@@ -300,7 +301,7 @@ class TestAutoroute:
|
||||
assert "timed out" in result["message"]
|
||||
|
||||
@patch("commands.freerouting.subprocess.run")
|
||||
def test_full_success_direct(self, mock_run, cmds, tmp_path):
|
||||
def test_full_success_direct(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None:
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
board_dir = tmp_path / "project"
|
||||
@@ -336,7 +337,7 @@ class TestAutoroute:
|
||||
assert "elapsed_seconds" in result
|
||||
|
||||
@patch("commands.freerouting.subprocess.run")
|
||||
def test_full_success_docker(self, mock_run, cmds, tmp_path):
|
||||
def test_full_success_docker(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None:
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
board_dir = tmp_path / "project"
|
||||
@@ -375,7 +376,7 @@ class TestAutoroute:
|
||||
assert "--rm" in call_args
|
||||
|
||||
@patch("commands.freerouting.subprocess.run")
|
||||
def test_freerouting_nonzero_exit(self, mock_run, cmds, tmp_path):
|
||||
def test_freerouting_nonzero_exit(self, mock_run: Any, cmds: Any, tmp_path: Any) -> None:
|
||||
jar = tmp_path / "freerouting.jar"
|
||||
jar.touch()
|
||||
board_dir = tmp_path / "project"
|
||||
@@ -404,14 +405,14 @@ class TestAutoroute:
|
||||
|
||||
|
||||
class TestFindJava:
|
||||
def test_finds_via_which(self):
|
||||
def test_finds_via_which(self) -> None:
|
||||
with patch(
|
||||
"commands.freerouting.shutil.which",
|
||||
return_value="/usr/bin/java",
|
||||
):
|
||||
assert _find_java() == "/usr/bin/java"
|
||||
|
||||
def test_none_when_not_found(self):
|
||||
def test_none_when_not_found(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"commands.freerouting.shutil.which",
|
||||
@@ -423,21 +424,21 @@ class TestFindJava:
|
||||
|
||||
|
||||
class TestFindDocker:
|
||||
def test_finds_docker(self):
|
||||
def test_finds_docker(self) -> None:
|
||||
with patch(
|
||||
"commands.freerouting.shutil.which",
|
||||
side_effect=lambda x: "/usr/bin/docker" if x == "docker" else None,
|
||||
):
|
||||
assert _find_docker() == "/usr/bin/docker"
|
||||
|
||||
def test_finds_podman(self):
|
||||
def test_finds_podman(self) -> None:
|
||||
with patch(
|
||||
"commands.freerouting.shutil.which",
|
||||
side_effect=lambda x: "/usr/bin/podman" if x == "podman" else None,
|
||||
):
|
||||
assert _find_docker() == "/usr/bin/podman"
|
||||
|
||||
def test_none_when_not_found(self):
|
||||
def test_none_when_not_found(self) -> None:
|
||||
with patch(
|
||||
"commands.freerouting.shutil.which",
|
||||
return_value=None,
|
||||
@@ -446,7 +447,7 @@ class TestFindDocker:
|
||||
|
||||
|
||||
class TestDockerAvailable:
|
||||
def test_docker_found(self):
|
||||
def test_docker_found(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"commands.freerouting._find_docker",
|
||||
@@ -457,14 +458,14 @@ class TestDockerAvailable:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
assert _docker_available() is True
|
||||
|
||||
def test_docker_not_installed(self):
|
||||
def test_docker_not_installed(self) -> None:
|
||||
with patch(
|
||||
"commands.freerouting._find_docker",
|
||||
return_value=None,
|
||||
):
|
||||
assert _docker_available() is False
|
||||
|
||||
def test_docker_not_running(self):
|
||||
def test_docker_not_running(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"commands.freerouting._find_docker",
|
||||
@@ -477,17 +478,17 @@ class TestDockerAvailable:
|
||||
|
||||
|
||||
class TestJavaVersionOk:
|
||||
def test_java_21(self):
|
||||
def test_java_21(self) -> None:
|
||||
with patch("commands.freerouting.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stderr='openjdk version "21.0.1"', stdout="")
|
||||
assert _java_version_ok("/usr/bin/java") is True
|
||||
|
||||
def test_java_17(self):
|
||||
def test_java_17(self) -> None:
|
||||
with patch("commands.freerouting.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stderr='openjdk version "17.0.18"', stdout="")
|
||||
assert _java_version_ok("/usr/bin/java") is False
|
||||
|
||||
def test_java_error(self):
|
||||
def test_java_error(self) -> None:
|
||||
with patch(
|
||||
"commands.freerouting.subprocess.run",
|
||||
side_effect=Exception("not found"),
|
||||
|
||||
@@ -9,6 +9,7 @@ import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -32,7 +33,7 @@ def _sym(name: str) -> Symbol:
|
||||
return Symbol(name)
|
||||
|
||||
|
||||
def _make_wire(x1, y1, x2, y2):
|
||||
def _make_wire(x1: Any, y1: Any, x2: Any, y2: Any) -> Any:
|
||||
return [
|
||||
_sym("wire"),
|
||||
[_sym("pts"), [_sym("xy"), x1, y1], [_sym("xy"), x2, y2]],
|
||||
@@ -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 [
|
||||
_sym("junction"),
|
||||
[_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."""
|
||||
item = [
|
||||
_sym("symbol"),
|
||||
@@ -66,7 +69,7 @@ def _make_symbol(ref, x, y, rotation=0, lib_id="Device:R", mirror=None):
|
||||
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)."""
|
||||
return [
|
||||
_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."""
|
||||
data = [
|
||||
_sym("kicad_sch"),
|
||||
@@ -133,20 +136,20 @@ def _make_sch_data(extra_items=None):
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRotatePoint:
|
||||
def test_zero_rotation(self):
|
||||
def test_zero_rotation(self) -> None:
|
||||
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)
|
||||
assert abs(rx - 0.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)
|
||||
assert abs(rx - (-1.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)
|
||||
assert abs(rx - 1.0) < 1e-6
|
||||
assert abs(ry - 0.0) < 1e-6
|
||||
@@ -159,11 +162,11 @@ class TestRotatePoint:
|
||||
|
||||
@pytest.mark.unit
|
||||
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)])
|
||||
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)])
|
||||
result = WireDragger.find_symbol(sch, "R1")
|
||||
assert result is not None
|
||||
@@ -175,14 +178,14 @@ class TestFindSymbol:
|
||||
assert mirror_x 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")])
|
||||
result = WireDragger.find_symbol(sch, "R1")
|
||||
assert result is not None
|
||||
assert result[5] is True # mirror_x
|
||||
assert result[6] is False # mirror_y
|
||||
|
||||
def test_detects_mirror_y(self):
|
||||
def test_detects_mirror_y(self) -> None:
|
||||
sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="y")])
|
||||
result = WireDragger.find_symbol(sch, "R1")
|
||||
assert result is not None
|
||||
@@ -197,7 +200,7 @@ class TestFindSymbol:
|
||||
|
||||
@pytest.mark.unit
|
||||
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)."""
|
||||
sch = _make_sch_data([_make_symbol("R1", 0, 0)])
|
||||
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[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)."""
|
||||
sch = _make_sch_data([_make_symbol("R1", 100, 100, rotation=90)])
|
||||
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[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()
|
||||
result = WireDragger.compute_pin_positions(sch, "MISSING", 0, 0)
|
||||
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."""
|
||||
sch = _make_sch_data([_make_symbol("R1", 50, 50, rotation=45)])
|
||||
positions = WireDragger.compute_pin_positions(sch, "R1", 60, 70)
|
||||
@@ -252,13 +255,13 @@ class TestComputePinPositions:
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDragWires:
|
||||
def test_no_wires_returns_zero_counts(self):
|
||||
def test_no_wires_returns_zero_counts(self) -> None:
|
||||
sch = _make_sch_data()
|
||||
result = WireDragger.drag_wires(sch, {(0.0, 0.0): (10.0, 10.0)})
|
||||
assert result["endpoints_moved"] == 0
|
||||
assert result["wires_removed"] == 0
|
||||
|
||||
def test_wire_start_endpoint_moved(self):
|
||||
def test_wire_start_endpoint_moved(self) -> None:
|
||||
wire = _make_wire(0, 3.81, 0, 10)
|
||||
sch = _make_sch_data([wire])
|
||||
result = WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)})
|
||||
@@ -271,7 +274,7 @@ class TestDragWires:
|
||||
assert abs(xy1[1] - 10.0) < 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)
|
||||
sch = _make_sch_data([wire])
|
||||
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[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."""
|
||||
wire = _make_wire(0, 3.81, 0, -3.81)
|
||||
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")]
|
||||
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."""
|
||||
wire = _make_wire(50, 50, 60, 50)
|
||||
sch = _make_sch_data([wire])
|
||||
@@ -311,7 +314,7 @@ class TestDragWires:
|
||||
assert abs(xy1[1] - 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 = _make_wire(0, 3.81, 0, -3.81)
|
||||
sch = _make_sch_data([wire])
|
||||
@@ -325,7 +328,7 @@ class TestDragWires:
|
||||
assert result["endpoints_moved"] == 2
|
||||
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)
|
||||
sch = _make_sch_data([junction])
|
||||
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[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)
|
||||
sch = _make_sch_data([junction])
|
||||
WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)})
|
||||
@@ -355,7 +358,7 @@ class TestDragWires:
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUpdateSymbolPosition:
|
||||
def test_updates_position(self):
|
||||
def test_updates_position(self) -> None:
|
||||
sch = _make_sch_data([_make_symbol("R1", 10, 20)])
|
||||
result = WireDragger.update_symbol_position(sch, "R1", 30, 40)
|
||||
assert result is True
|
||||
@@ -363,17 +366,17 @@ class TestUpdateSymbolPosition:
|
||||
assert abs(found[1] - 30) < 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()
|
||||
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)])
|
||||
WireDragger.update_symbol_position(sch, "R1", 30, 40)
|
||||
found = WireDragger.find_symbol(sch, "R1")
|
||||
assert abs(found[3] - 90) < EPS # rotation preserved
|
||||
|
||||
def test_property_labels_follow_symbol_move(self):
|
||||
def test_property_labels_follow_symbol_move(self) -> None:
|
||||
"""Property (at ...) positions must shift by the same delta as the symbol."""
|
||||
sym = _make_symbol("R1", 100, 80)
|
||||
sch = _make_sch_data([sym])
|
||||
@@ -421,7 +424,7 @@ class TestUpdateSymbolPosition:
|
||||
class TestMoveWithWirePreservation:
|
||||
"""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."""
|
||||
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
|
||||
shutil.copy(TEMPLATE_PATH, tmp)
|
||||
@@ -462,7 +465,7 @@ class TestMoveWithWirePreservation:
|
||||
path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8")
|
||||
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."""
|
||||
import uuid
|
||||
|
||||
@@ -476,7 +479,7 @@ class TestMoveWithWirePreservation:
|
||||
path.write_text(content[:idx] + "\n" + wire_sexp + "\n)", encoding="utf-8")
|
||||
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."""
|
||||
content = path.read_text(encoding="utf-8")
|
||||
data = sexpdata.loads(content)
|
||||
@@ -502,7 +505,7 @@ class TestMoveWithWirePreservation:
|
||||
)
|
||||
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")
|
||||
data = sexpdata.loads(content)
|
||||
found = WireDragger.find_symbol(data, ref)
|
||||
@@ -510,7 +513,7 @@ class TestMoveWithWirePreservation:
|
||||
return None
|
||||
return found[1], found[2]
|
||||
|
||||
def test_symbol_position_updated(self):
|
||||
def test_symbol_position_updated(self) -> None:
|
||||
sch = self._make_schematic()
|
||||
self._add_resistor(sch, "R1", 100, 100)
|
||||
# Call handler directly
|
||||
@@ -531,7 +534,7 @@ class TestMoveWithWirePreservation:
|
||||
assert abs(pos[0] - 120) < 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."""
|
||||
sch = self._make_schematic()
|
||||
# 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
|
||||
), 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."""
|
||||
sch = self._make_schematic()
|
||||
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]
|
||||
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."""
|
||||
sch = self._make_schematic()
|
||||
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
|
||||
), 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."""
|
||||
sch = self._make_schematic()
|
||||
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
|
||||
), 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()
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from kicad_interface import KiCADInterface
|
||||
@@ -670,7 +673,7 @@ class TestMoveWithWirePreservation:
|
||||
class TestSynthesizeTouchingPinWires:
|
||||
"""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."""
|
||||
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."""
|
||||
sch = _make_sch_data([_make_symbol("R1", 0, 0)])
|
||||
pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20)
|
||||
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
|
||||
assert count == 0
|
||||
|
||||
def test_touching_pin_gap_generates_wire(self):
|
||||
def test_touching_pin_gap_generates_wire(self) -> None:
|
||||
"""
|
||||
R1 at (0, 0) pin2 at (0, -3.81).
|
||||
R2 at (0, -7.62) pin1 at (0, -3.81). ← pins touch
|
||||
@@ -728,7 +731,7 @@ class TestSynthesizeTouchingPinWires:
|
||||
-3.81,
|
||||
) 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),
|
||||
no wire should be synthesized.
|
||||
@@ -740,7 +743,7 @@ class TestSynthesizeTouchingPinWires:
|
||||
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
|
||||
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,
|
||||
no wire should be synthesized (they touch again).
|
||||
@@ -759,12 +762,12 @@ class TestSynthesizeTouchingPinWires:
|
||||
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
|
||||
assert count == 0, f"Expected 0 synthesized wires (rejoined), got {count}"
|
||||
|
||||
def test_empty_pin_positions_returns_zero(self):
|
||||
def test_empty_pin_positions_returns_zero(self) -> None:
|
||||
sch = _make_sch_data([_make_symbol("R1", 0, 0)])
|
||||
count = WireDragger.synthesize_touching_pin_wires(sch, "R1", {})
|
||||
assert count == 0
|
||||
|
||||
def test_non_touching_pins_not_affected(self):
|
||||
def test_non_touching_pins_not_affected(self) -> None:
|
||||
"""
|
||||
When R1 and R2 are NOT touching (different positions), no wire is synthesized.
|
||||
"""
|
||||
@@ -784,7 +787,7 @@ class TestSynthesizeTouchingPinWires:
|
||||
class TestOldToNewCollision:
|
||||
"""Verify that coincident pins do not silently overwrite each other in old_to_new."""
|
||||
|
||||
def test_handler_logs_warning_on_collision(self, caplog):
|
||||
def test_handler_logs_warning_on_collision(self, caplog: Any) -> None:
|
||||
"""
|
||||
When two pins share the same old position, a warning should be logged
|
||||
and the *first* mapping should be kept (not overwritten by the second).
|
||||
@@ -827,7 +830,7 @@ class TestOldToNewCollision:
|
||||
class TestTouchingPinIntegration:
|
||||
"""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."""
|
||||
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
|
||||
shutil.copy(TEMPLATE_PATH, tmp)
|
||||
@@ -867,7 +870,7 @@ class TestTouchingPinIntegration:
|
||||
path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8")
|
||||
return path
|
||||
|
||||
def _parse_wires(self, path: Path):
|
||||
def _parse_wires(self, path: Path) -> Any:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
data = sexpdata.loads(content)
|
||||
wires = []
|
||||
@@ -892,7 +895,7 @@ class TestTouchingPinIntegration:
|
||||
)
|
||||
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 pin2 = (100, 96.19), R2 pin1 = (100, 96.19).
|
||||
@@ -947,7 +950,7 @@ class TestTouchingPinIntegration:
|
||||
len(bridging) >= 1
|
||||
), f"Expected a bridging wire from {old_pin2} to {new_pin2}, got wires: {wires}"
|
||||
|
||||
def test_no_wire_synthesized_when_no_touching_pins(self):
|
||||
def test_no_wire_synthesized_when_no_touching_pins(self) -> None:
|
||||
"""
|
||||
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.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,
|
||||
both should be handled: the wire dragged and the touching-pin bridged.
|
||||
|
||||
@@ -130,42 +130,42 @@ def _make_led_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str:
|
||||
class TestGeometryHelpers:
|
||||
"""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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def test_distance_zero(self):
|
||||
def test_distance_zero(self) -> None:
|
||||
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
|
||||
|
||||
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)
|
||||
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)
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
assert _line_segment_intersects_aabb(0, 2, 2, 2, 2, 2, 8, 8) is True
|
||||
|
||||
@@ -178,7 +178,7 @@ class TestGeometryHelpers:
|
||||
class TestSexpParsers:
|
||||
"""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
|
||||
(wire (pts (xy 10 20) (xy 30 40))
|
||||
(stroke (width 0) (type default))
|
||||
@@ -189,11 +189,11 @@ class TestSexpParsers:
|
||||
assert wires[0]["start"] == (10.0, 20.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)")
|
||||
assert _parse_wires(sexp) == []
|
||||
|
||||
def test_parse_labels_both_types(self):
|
||||
def test_parse_labels_both_types(self) -> None:
|
||||
sexp = sexpdata.loads("""(kicad_sch
|
||||
(label "VCC" (at 10 20 0))
|
||||
(global_label "GND" (at 30 40 0))
|
||||
@@ -205,7 +205,7 @@ class TestSexpParsers:
|
||||
assert labels[1]["name"] == "GND"
|
||||
assert labels[1]["type"] == "global_label"
|
||||
|
||||
def test_parse_symbols(self):
|
||||
def test_parse_symbols(self) -> None:
|
||||
sexp = sexpdata.loads("""(kicad_sch
|
||||
(symbol (lib_id "Device:R") (at 100 100 0)
|
||||
(property "Reference" "R1" (at 0 0 0)))
|
||||
@@ -228,20 +228,20 @@ class TestSexpParsers:
|
||||
class TestAABBOverlap:
|
||||
"""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
|
||||
|
||||
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
|
||||
|
||||
def test_touching_boxes_no_overlap(self):
|
||||
def test_touching_boxes_no_overlap(self) -> None:
|
||||
# Touching edges are not overlapping (strict inequality)
|
||||
assert _aabb_overlap((0, 0, 10, 10), (10, 0, 20, 10)) is False
|
||||
|
||||
def test_contained_box(self):
|
||||
def test_contained_box(self) -> None:
|
||||
assert _aabb_overlap((0, 0, 20, 20), (5, 5, 15, 15)) is True
|
||||
|
||||
def test_overlap_one_axis_only(self):
|
||||
def test_overlap_one_axis_only(self) -> None:
|
||||
# Overlap in X but not Y
|
||||
assert _aabb_overlap((0, 0, 10, 10), (5, 15, 15, 25)) is False
|
||||
|
||||
@@ -249,12 +249,12 @@ class TestAABBOverlap:
|
||||
class TestFindOverlappingElements:
|
||||
"""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()
|
||||
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||
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
|
||||
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100.1, 100)
|
||||
tmp = _make_temp_schematic(extra)
|
||||
@@ -262,13 +262,13 @@ class TestFindOverlappingElements:
|
||||
assert result["totalOverlaps"] >= 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)
|
||||
tmp = _make_temp_schematic(extra)
|
||||
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||
assert result["totalOverlaps"] == 0
|
||||
|
||||
def test_collinear_wire_overlap(self):
|
||||
def test_collinear_wire_overlap(self) -> None:
|
||||
extra = """
|
||||
(wire (pts (xy 10 50) (xy 30 50))
|
||||
(stroke (width 0) (type default))
|
||||
@@ -281,7 +281,7 @@ class TestFindOverlappingElements:
|
||||
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
def test_adjacent_resistors_no_overlap(self):
|
||||
def test_adjacent_resistors_no_overlap(self) -> None:
|
||||
"""Two vertical resistors side by side should not overlap.
|
||||
|
||||
R pins at y ±3.81, but different X positions far enough apart.
|
||||
@@ -309,7 +309,7 @@ class TestFindOverlappingElements:
|
||||
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||
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.
|
||||
|
||||
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:
|
||||
"""Test region query logic."""
|
||||
|
||||
def test_elements_inside_region_found(self):
|
||||
def test_elements_inside_region_found(self) -> None:
|
||||
extra = """
|
||||
(symbol (lib_id "Device:R") (at 50 50 0)
|
||||
(property "Reference" "R1" (at 0 0 0))
|
||||
@@ -340,7 +340,7 @@ class TestGetElementsInRegion:
|
||||
assert result["counts"]["wires"] >= 1
|
||||
assert result["counts"]["labels"] >= 1
|
||||
|
||||
def test_elements_outside_region_excluded(self):
|
||||
def test_elements_outside_region_excluded(self) -> None:
|
||||
extra = """
|
||||
(symbol (lib_id "Device:R") (at 200 200 0)
|
||||
(property "Reference" "R1" (at 0 0 0))
|
||||
@@ -354,7 +354,7 @@ class TestGetElementsInRegion:
|
||||
class TestComputeSymbolBbox:
|
||||
"""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()
|
||||
from commands.pin_locator import PinLocator
|
||||
|
||||
@@ -372,7 +372,7 @@ class TestComputeSymbolBbox:
|
||||
class TestIntegrationFindWiresCrossingSymbols:
|
||||
"""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."""
|
||||
# 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
|
||||
@@ -387,7 +387,7 @@ class TestIntegrationFindWiresCrossingSymbols:
|
||||
d1_collisions = [c for c in result if c["component"]["reference"] == "D1"]
|
||||
assert len(d1_collisions) >= 1
|
||||
|
||||
def test_unannotated_duplicates_not_over_reported(self):
|
||||
def test_unannotated_duplicates_not_over_reported(self) -> None:
|
||||
"""
|
||||
Regression: two components with the same unannotated reference ("R?") at
|
||||
different positions should each produce independent bounding boxes.
|
||||
@@ -417,7 +417,7 @@ class TestIntegrationFindWiresCrossingSymbols:
|
||||
"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
|
||||
must be flagged — this is the core bug where the old suppression logic
|
||||
treated any wire touching a pin as a valid connection."""
|
||||
@@ -435,7 +435,7 @@ class TestIntegrationFindWiresCrossingSymbols:
|
||||
len(d1_crossings) >= 1
|
||||
), "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
|
||||
is a valid connection and must NOT be flagged."""
|
||||
# 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"]
|
||||
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
|
||||
must be reported even though both endpoints land on pins."""
|
||||
r_sexp = _make_resistor_sexp("R_short", 100.0, 100.0)
|
||||
@@ -472,7 +472,7 @@ class TestIntegrationFindWiresCrossingSymbols:
|
||||
class TestIntegrationGetElementsInRegion:
|
||||
"""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."""
|
||||
extra = _make_resistor_sexp("R1", 100, 100)
|
||||
tmp = _make_temp_schematic(extra)
|
||||
@@ -482,7 +482,7 @@ class TestIntegrationGetElementsInRegion:
|
||||
assert "pins" in sym
|
||||
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."""
|
||||
extra = """
|
||||
(wire (pts (xy 0 50) (xy 100 50))
|
||||
@@ -493,7 +493,7 @@ class TestIntegrationGetElementsInRegion:
|
||||
result = get_elements_in_region(tmp, 40, 40, 60, 60)
|
||||
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."""
|
||||
extra = """
|
||||
(wire (pts (xy 0 0) (xy 10 0))
|
||||
@@ -513,48 +513,48 @@ class TestIntegrationGetElementsInRegion:
|
||||
class TestCheckWireOverlap:
|
||||
"""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)}
|
||||
w2 = {"start": (20, 50), "end": (40, 50)}
|
||||
result = _check_wire_overlap(w1, w2, 0.5)
|
||||
assert result is not None
|
||||
assert result["type"] == "collinear_overlap"
|
||||
|
||||
def test_vertical_overlap(self):
|
||||
def test_vertical_overlap(self) -> None:
|
||||
w1 = {"start": (50, 10), "end": (50, 30)}
|
||||
w2 = {"start": (50, 20), "end": (50, 40)}
|
||||
result = _check_wire_overlap(w1, w2, 0.5)
|
||||
assert result is not None
|
||||
assert result["type"] == "collinear_overlap"
|
||||
|
||||
def test_diagonal_overlap(self):
|
||||
def test_diagonal_overlap(self) -> None:
|
||||
w1 = {"start": (0, 0), "end": (20, 20)}
|
||||
w2 = {"start": (10, 10), "end": (30, 30)}
|
||||
result = _check_wire_overlap(w1, w2, 0.5)
|
||||
assert result is not None
|
||||
assert result["type"] == "collinear_overlap"
|
||||
|
||||
def test_horizontal_no_overlap(self):
|
||||
def test_horizontal_no_overlap(self) -> None:
|
||||
w1 = {"start": (10, 50), "end": (20, 50)}
|
||||
w2 = {"start": (30, 50), "end": (40, 50)}
|
||||
result = _check_wire_overlap(w1, w2, 0.5)
|
||||
assert result is None
|
||||
|
||||
def test_parallel_offset_no_overlap(self):
|
||||
def test_parallel_offset_no_overlap(self) -> None:
|
||||
"""Two parallel wires offset perpendicularly should not overlap."""
|
||||
w1 = {"start": (0, 0), "end": (20, 20)}
|
||||
w2 = {"start": (0, 5), "end": (20, 25)}
|
||||
result = _check_wire_overlap(w1, w2, 0.5)
|
||||
assert result is None
|
||||
|
||||
def test_non_parallel_no_overlap(self):
|
||||
def test_non_parallel_no_overlap(self) -> None:
|
||||
"""Two wires at different angles should not overlap."""
|
||||
w1 = {"start": (0, 0), "end": (10, 10)}
|
||||
w2 = {"start": (0, 0), "end": (10, 0)}
|
||||
result = _check_wire_overlap(w1, w2, 0.5)
|
||||
assert result is None
|
||||
|
||||
def test_zero_length_segment(self):
|
||||
def test_zero_length_segment(self) -> None:
|
||||
w1 = {"start": (10, 10), "end": (10, 10)}
|
||||
w2 = {"start": (10, 10), "end": (20, 20)}
|
||||
result = _check_wire_overlap(w1, w2, 0.5)
|
||||
@@ -565,7 +565,7 @@ class TestCheckWireOverlap:
|
||||
class TestIntegrationDiagonalWireOverlap:
|
||||
"""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."""
|
||||
extra = """
|
||||
(wire (pts (xy 0 0) (xy 20 20))
|
||||
@@ -579,7 +579,7 @@ class TestIntegrationDiagonalWireOverlap:
|
||||
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||
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."""
|
||||
extra = """
|
||||
(wire (pts (xy 0 0) (xy 20 20))
|
||||
@@ -593,7 +593,7 @@ class TestIntegrationDiagonalWireOverlap:
|
||||
result = find_overlapping_elements(tmp, tolerance=0.5)
|
||||
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."""
|
||||
extra = """
|
||||
(wire (pts (xy 0 0) (xy 20 20))
|
||||
@@ -616,7 +616,7 @@ class TestIntegrationDiagonalWireOverlap:
|
||||
class TestExtractLibSymbols:
|
||||
"""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
|
||||
(lib_symbols
|
||||
(symbol "Device:R"
|
||||
@@ -636,19 +636,19 @@ class TestExtractLibSymbols:
|
||||
assert "2" in pins
|
||||
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)")
|
||||
result = _extract_lib_symbols(sexp)
|
||||
assert result == {}
|
||||
|
||||
def test_no_lib_symbols_section(self):
|
||||
def test_no_lib_symbols_section(self) -> None:
|
||||
sexp = sexpdata.loads("""(kicad_sch
|
||||
(wire (pts (xy 0 0) (xy 10 10)))
|
||||
)""")
|
||||
result = _extract_lib_symbols(sexp)
|
||||
assert result == {}
|
||||
|
||||
def test_extract_includes_graphics_points(self):
|
||||
def test_extract_includes_graphics_points(self) -> None:
|
||||
"""_extract_lib_symbols should return graphics_points from body shapes."""
|
||||
sexp = sexpdata.loads("""(kicad_sch
|
||||
(lib_symbols
|
||||
@@ -688,7 +688,7 @@ class TestExtractLibSymbols:
|
||||
class TestParseLibSymbolGraphics:
|
||||
"""Test graphics extraction from lib_symbol definitions."""
|
||||
|
||||
def test_rectangle(self):
|
||||
def test_rectangle(self) -> None:
|
||||
sexp = sexpdata.loads("""(symbol "Device:R"
|
||||
(symbol "Device:R_0_1"
|
||||
(rectangle (start -1.016 -2.54) (end 1.016 2.54)
|
||||
@@ -699,7 +699,7 @@ class TestParseLibSymbolGraphics:
|
||||
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"
|
||||
(symbol "Device:C_0_1"
|
||||
(polyline
|
||||
@@ -710,7 +710,7 @@ class TestParseLibSymbolGraphics:
|
||||
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"
|
||||
(symbol "Test:Circle_0_1"
|
||||
(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
|
||||
|
||||
def test_arc(self):
|
||||
def test_arc(self) -> None:
|
||||
sexp = sexpdata.loads("""(symbol "Test:Arc"
|
||||
(symbol "Test:Arc_0_1"
|
||||
(arc (start 1 0) (mid 0 1) (end -1 0)
|
||||
@@ -732,7 +732,7 @@ class TestParseLibSymbolGraphics:
|
||||
assert (0.0, 1.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"
|
||||
(symbol "Test:Empty_1_1"
|
||||
(pin passive line (at 0 0 0) (length 1.27)
|
||||
@@ -750,24 +750,24 @@ class TestParseLibSymbolGraphics:
|
||||
class TestTransformLocalPoint:
|
||||
"""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)
|
||||
x, y = _transform_local_point(1.0, 2.0, 100.0, 200.0, 0, False, False)
|
||||
assert x == pytest.approx(101.0)
|
||||
assert y == pytest.approx(198.0)
|
||||
|
||||
def test_mirror_x(self):
|
||||
def test_mirror_x(self) -> None:
|
||||
# y-negate then mirror_x cancel out → net ly unchanged
|
||||
x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, True, False)
|
||||
assert x == pytest.approx(1.0)
|
||||
assert y == pytest.approx(2.0)
|
||||
|
||||
def test_mirror_y(self):
|
||||
def test_mirror_y(self) -> None:
|
||||
x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, False, True)
|
||||
assert x == pytest.approx(-1.0)
|
||||
assert y == pytest.approx(-2.0)
|
||||
|
||||
def test_rotation_90(self):
|
||||
def test_rotation_90(self) -> None:
|
||||
# ly=0 negated is still 0, then rotate lx=1 by 90°
|
||||
x, y = _transform_local_point(1.0, 0.0, 0.0, 0.0, 90, False, False)
|
||||
assert x == pytest.approx(0.0, abs=1e-9)
|
||||
@@ -782,7 +782,7 @@ class TestTransformLocalPoint:
|
||||
class TestComputeSymbolBboxWithGraphics:
|
||||
"""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.
|
||||
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."""
|
||||
@@ -823,7 +823,7 @@ class TestComputeSymbolBboxWithGraphics:
|
||||
assert min_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."""
|
||||
sym = {
|
||||
"x": 100.0,
|
||||
@@ -858,7 +858,7 @@ class TestComputeSymbolBboxWithGraphics:
|
||||
assert min_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."""
|
||||
sym = {
|
||||
"x": 100.0,
|
||||
@@ -902,7 +902,7 @@ class TestComputeSymbolBboxWithGraphics:
|
||||
class TestIntegrationGraphicsBbox:
|
||||
"""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.
|
||||
Verify that the bbox for a placed resistor uses the actual
|
||||
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
|
||||
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.
|
||||
Verify that the bbox uses polyline extents."""
|
||||
extra = _make_led_sexp("D1", 100, 100)
|
||||
|
||||
@@ -7,6 +7,7 @@ import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -86,7 +87,7 @@ class TestGetSchematicComponentParsing:
|
||||
}
|
||||
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."""
|
||||
m = re.search(
|
||||
r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)',
|
||||
@@ -100,7 +101,7 @@ class TestGetSchematicComponentParsing:
|
||||
}
|
||||
return None
|
||||
|
||||
def test_parses_reference_field(self):
|
||||
def test_parses_reference_field(self) -> None:
|
||||
fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
|
||||
assert "Reference" in fields
|
||||
assert fields["Reference"]["value"] == "R1"
|
||||
@@ -108,25 +109,25 @@ class TestGetSchematicComponentParsing:
|
||||
assert fields["Reference"]["y"] == pytest.approx(47.46)
|
||||
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)
|
||||
assert "Value" in fields
|
||||
assert fields["Value"]["value"] == "10k"
|
||||
assert fields["Value"]["x"] == pytest.approx(51.27)
|
||||
assert fields["Value"]["y"] == pytest.approx(52.54)
|
||||
|
||||
def test_parses_all_four_standard_fields(self):
|
||||
def test_parses_all_four_standard_fields(self) -> None:
|
||||
fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
|
||||
assert set(fields.keys()) >= {"Reference", "Value", "Footprint", "Datasheet"}
|
||||
|
||||
def test_parses_component_position(self):
|
||||
def test_parses_component_position(self) -> None:
|
||||
pos = self._parse_comp_pos(PLACED_RESISTOR_BLOCK)
|
||||
assert pos is not None
|
||||
assert pos["x"] == pytest.approx(50.0)
|
||||
assert pos["y"] == pytest.approx(50.0)
|
||||
assert pos["angle"] == pytest.approx(0.0)
|
||||
|
||||
def test_field_position_regex_replaces_correctly(self):
|
||||
def test_field_position_regex_replaces_correctly(self) -> None:
|
||||
"""Mirrors the regex used in _handle_edit_schematic_component for fieldPositions."""
|
||||
field_name = "Reference"
|
||||
new_x, new_y, new_angle = 99.0, 88.0, 0
|
||||
@@ -144,7 +145,7 @@ class TestGetSchematicComponentParsing:
|
||||
# Value should be unchanged
|
||||
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."""
|
||||
block = PLACED_RESISTOR_BLOCK
|
||||
block = re.sub(
|
||||
@@ -166,16 +167,16 @@ class TestGetSchematicComponentIntegration:
|
||||
"""Integration tests: write a real .kicad_sch and call the handler."""
|
||||
|
||||
@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)
|
||||
|
||||
def _get_interface(self):
|
||||
def _get_interface(self) -> Any:
|
||||
"""Lazily import KiCADInterface to avoid pcbnew import at collection time."""
|
||||
from kicad_interface import KiCADInterface
|
||||
|
||||
return KiCADInterface()
|
||||
|
||||
def test_get_returns_success(self, sch_with_r1):
|
||||
def test_get_returns_success(self, sch_with_r1: Any) -> None:
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
@@ -186,7 +187,7 @@ class TestGetSchematicComponentIntegration:
|
||||
)
|
||||
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()
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
@@ -197,7 +198,7 @@ class TestGetSchematicComponentIntegration:
|
||||
)
|
||||
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()
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
@@ -210,7 +211,7 @@ class TestGetSchematicComponentIntegration:
|
||||
assert result["position"]["x"] == pytest.approx(50.0)
|
||||
assert result["position"]["y"] == pytest.approx(50.0)
|
||||
|
||||
def test_get_returns_reference_field_position(self, sch_with_r1):
|
||||
def test_get_returns_reference_field_position(self, sch_with_r1: Any) -> None:
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
@@ -224,7 +225,7 @@ class TestGetSchematicComponentIntegration:
|
||||
assert ref_field["x"] == pytest.approx(51.27)
|
||||
assert ref_field["y"] == pytest.approx(47.46)
|
||||
|
||||
def test_get_returns_value_field(self, sch_with_r1):
|
||||
def test_get_returns_value_field(self, sch_with_r1: Any) -> None:
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
@@ -238,7 +239,7 @@ class TestGetSchematicComponentIntegration:
|
||||
assert val_field["x"] == pytest.approx(51.27)
|
||||
assert val_field["y"] == pytest.approx(52.54)
|
||||
|
||||
def test_get_unknown_reference_returns_failure(self, sch_with_r1):
|
||||
def test_get_unknown_reference_returns_failure(self, sch_with_r1: Any) -> None:
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
@@ -250,7 +251,7 @@ class TestGetSchematicComponentIntegration:
|
||||
assert result["success"] is False
|
||||
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()
|
||||
result = iface.handle_command(
|
||||
"get_schematic_component",
|
||||
@@ -266,15 +267,15 @@ class TestEditSchematicComponentFieldPositions:
|
||||
"""Integration tests for the new fieldPositions parameter."""
|
||||
|
||||
@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)
|
||||
|
||||
def _get_interface(self):
|
||||
def _get_interface(self) -> Any:
|
||||
from kicad_interface import 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()
|
||||
result = iface.handle_command(
|
||||
"edit_schematic_component",
|
||||
@@ -297,7 +298,7 @@ class TestEditSchematicComponentFieldPositions:
|
||||
assert get_result["fields"]["Reference"]["x"] == pytest.approx(99.0)
|
||||
assert get_result["fields"]["Reference"]["y"] == pytest.approx(88.0)
|
||||
|
||||
def test_reposition_does_not_change_value(self, sch_with_r1):
|
||||
def test_reposition_does_not_change_value(self, sch_with_r1: Any) -> None:
|
||||
iface = self._get_interface()
|
||||
iface.handle_command(
|
||||
"edit_schematic_component",
|
||||
@@ -318,7 +319,7 @@ class TestEditSchematicComponentFieldPositions:
|
||||
assert get_result["fields"]["Value"]["x"] == pytest.approx(51.27)
|
||||
assert get_result["fields"]["Value"]["y"] == pytest.approx(52.54)
|
||||
|
||||
def test_reposition_multiple_fields(self, sch_with_r1):
|
||||
def test_reposition_multiple_fields(self, sch_with_r1: Any) -> None:
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command(
|
||||
"edit_schematic_component",
|
||||
@@ -343,7 +344,7 @@ class TestEditSchematicComponentFieldPositions:
|
||||
assert get_result["fields"]["Reference"]["x"] == pytest.approx(10.0)
|
||||
assert get_result["fields"]["Value"]["y"] == pytest.approx(30.0)
|
||||
|
||||
def test_fieldpositions_alone_is_valid(self, sch_with_r1):
|
||||
def test_fieldpositions_alone_is_valid(self, sch_with_r1: Any) -> None:
|
||||
"""fieldPositions without value/footprint/newReference should succeed."""
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command(
|
||||
@@ -356,7 +357,7 @@ class TestEditSchematicComponentFieldPositions:
|
||||
)
|
||||
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."""
|
||||
iface = self._get_interface()
|
||||
result = iface.handle_command(
|
||||
|
||||
@@ -12,6 +12,7 @@ Covers:
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -59,22 +60,22 @@ def _write_temp_sch(content: str) -> Path:
|
||||
class TestDeleteWireUnit:
|
||||
"""Unit-level tests for WireManager.delete_wire."""
|
||||
|
||||
def setup_method(self):
|
||||
def setup_method(self) -> None:
|
||||
from commands.wire_manager import 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])
|
||||
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"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
result = self.WireManager.delete_wire(sch, [99, 99], [100, 100])
|
||||
assert result is False
|
||||
|
||||
def test_tolerance_argument_accepted(self, tmp_path):
|
||||
def test_tolerance_argument_accepted(self, tmp_path: Any) -> None:
|
||||
"""Ensure the tolerance kwarg doesn't raise a TypeError."""
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
@@ -91,22 +92,22 @@ class TestDeleteWireUnit:
|
||||
class TestDeleteLabelUnit:
|
||||
"""Unit-level tests for WireManager.delete_label."""
|
||||
|
||||
def setup_method(self):
|
||||
def setup_method(self) -> None:
|
||||
from commands.wire_manager import 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")
|
||||
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"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
result = self.WireManager.delete_label(sch, "NONEXISTENT")
|
||||
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"
|
||||
shutil.copy(EMPTY_SCH, sch)
|
||||
result = self.WireManager.delete_label(sch, "VCC", position=[10.0, 20.0], tolerance=0.5)
|
||||
@@ -122,12 +123,12 @@ class TestDeleteLabelUnit:
|
||||
class TestDeleteWireIntegration:
|
||||
"""Integration tests that read/write real .kicad_sch files."""
|
||||
|
||||
def setup_method(self):
|
||||
def setup_method(self) -> None:
|
||||
from commands.wire_manager import 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.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
@@ -142,7 +143,7 @@ class TestDeleteWireIntegration:
|
||||
]
|
||||
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.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
@@ -151,7 +152,7 @@ class TestDeleteWireIntegration:
|
||||
|
||||
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.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)
|
||||
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.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)
|
||||
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.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
|
||||
# Must parse without exception
|
||||
sexpdata.loads(sch.read_text(encoding="utf-8"))
|
||||
|
||||
def test_label_preserved_after_wire_deletion(self, tmp_path):
|
||||
def test_label_preserved_after_wire_deletion(self, tmp_path: Any) -> None:
|
||||
"""Deleting a wire must not remove unrelated elements."""
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
@@ -199,12 +200,12 @@ class TestDeleteWireIntegration:
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDeleteLabelIntegration:
|
||||
def setup_method(self):
|
||||
def setup_method(self) -> None:
|
||||
from commands.wire_manager import 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.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
@@ -219,21 +220,21 @@ class TestDeleteLabelIntegration:
|
||||
]
|
||||
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.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_label(sch, "VCC", position=[50.0, 50.0])
|
||||
assert result is True
|
||||
|
||||
def test_position_mismatch_no_delete(self, tmp_path):
|
||||
def test_position_mismatch_no_delete(self, tmp_path: Any) -> None:
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
|
||||
result = self.WireManager.delete_label(sch, "VCC", position=[99.0, 99.0], tolerance=0.5)
|
||||
assert result is False
|
||||
|
||||
def test_wire_preserved_after_label_deletion(self, tmp_path):
|
||||
def test_wire_preserved_after_label_deletion(self, tmp_path: Any) -> None:
|
||||
sch = tmp_path / "test.kicad_sch"
|
||||
sch.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
self.WireManager.delete_label(sch, "VCC")
|
||||
@@ -245,7 +246,7 @@ class TestDeleteLabelIntegration:
|
||||
]
|
||||
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.write_text(_WIRE_SCH, encoding="utf-8")
|
||||
self.WireManager.delete_label(sch, "VCC")
|
||||
@@ -260,7 +261,7 @@ class TestDeleteLabelIntegration:
|
||||
# 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
|
||||
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.
|
||||
"""
|
||||
|
||||
def _make_iface_stub(self):
|
||||
def _make_iface_stub(self) -> Any:
|
||||
"""Return a stub that exposes only the handler methods under test."""
|
||||
import importlib
|
||||
import types
|
||||
@@ -316,7 +317,7 @@ class TestHandlerParamValidation:
|
||||
|
||||
# --- 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
|
||||
|
||||
with patch.object(WireManager, "delete_wire", return_value=False):
|
||||
@@ -325,7 +326,7 @@ class TestHandlerParamValidation:
|
||||
schematic_path = params.get("schematicPath")
|
||||
assert schematic_path is None
|
||||
# Handler should short-circuit before calling WireManager
|
||||
result = (
|
||||
result: dict[str, Any] = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
if not schematic_path
|
||||
else {}
|
||||
@@ -335,7 +336,7 @@ class TestHandlerParamValidation:
|
||||
|
||||
# --- 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"}
|
||||
net_name = params.get("netName")
|
||||
result = (
|
||||
@@ -348,7 +349,7 @@ class TestHandlerParamValidation:
|
||||
)
|
||||
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"}
|
||||
schematic_path = params.get("schematicPath")
|
||||
result = (
|
||||
@@ -363,7 +364,7 @@ class TestHandlerParamValidation:
|
||||
|
||||
# --- list_schematic_components ---
|
||||
|
||||
def test_list_components_missing_path(self):
|
||||
def test_list_components_missing_path(self) -> None:
|
||||
params = {}
|
||||
schematic_path = params.get("schematicPath")
|
||||
result = (
|
||||
@@ -373,7 +374,7 @@ class TestHandlerParamValidation:
|
||||
|
||||
# --- list_schematic_nets ---
|
||||
|
||||
def test_list_nets_missing_path(self):
|
||||
def test_list_nets_missing_path(self) -> None:
|
||||
params = {}
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
@@ -384,7 +385,7 @@ class TestHandlerParamValidation:
|
||||
|
||||
# --- list_schematic_wires ---
|
||||
|
||||
def test_list_wires_missing_path(self):
|
||||
def test_list_wires_missing_path(self) -> None:
|
||||
params = {}
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
@@ -395,7 +396,7 @@ class TestHandlerParamValidation:
|
||||
|
||||
# --- list_schematic_labels ---
|
||||
|
||||
def test_list_labels_missing_path(self):
|
||||
def test_list_labels_missing_path(self) -> None:
|
||||
params = {}
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
@@ -406,7 +407,7 @@ class TestHandlerParamValidation:
|
||||
|
||||
# --- move_schematic_component ---
|
||||
|
||||
def test_move_component_missing_reference(self):
|
||||
def test_move_component_missing_reference(self) -> None:
|
||||
params = {
|
||||
"schematicPath": "/some/file.kicad_sch",
|
||||
"position": {"x": 10, "y": 20},
|
||||
@@ -421,7 +422,7 @@ class TestHandlerParamValidation:
|
||||
)
|
||||
assert result["success"] is False
|
||||
|
||||
def test_move_component_missing_position(self):
|
||||
def test_move_component_missing_position(self) -> None:
|
||||
params = {
|
||||
"schematicPath": "/some/file.kicad_sch",
|
||||
"reference": "R1",
|
||||
@@ -438,7 +439,7 @@ class TestHandlerParamValidation:
|
||||
|
||||
# --- rotate_schematic_component ---
|
||||
|
||||
def test_rotate_component_missing_reference(self):
|
||||
def test_rotate_component_missing_reference(self) -> None:
|
||||
params = {"schematicPath": "/some/file.kicad_sch"}
|
||||
result = (
|
||||
{
|
||||
@@ -452,7 +453,7 @@ class TestHandlerParamValidation:
|
||||
|
||||
# --- annotate_schematic ---
|
||||
|
||||
def test_annotate_missing_path(self):
|
||||
def test_annotate_missing_path(self) -> None:
|
||||
params = {}
|
||||
result = (
|
||||
{"success": False, "message": "schematicPath is required"}
|
||||
@@ -463,7 +464,7 @@ class TestHandlerParamValidation:
|
||||
|
||||
# --- 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"}
|
||||
result = (
|
||||
{
|
||||
|
||||
@@ -48,7 +48,7 @@ def _make_wire(x1: float, y1: float, x2: float, y2: float) -> MagicMock:
|
||||
return wire
|
||||
|
||||
|
||||
def _make_schematic(*wires) -> MagicMock:
|
||||
def _make_schematic(*wires: Any) -> MagicMock:
|
||||
sch = MagicMock()
|
||||
sch.wire = list(wires)
|
||||
# No net labels, no symbols by default
|
||||
@@ -66,12 +66,12 @@ def _make_schematic(*wires) -> MagicMock:
|
||||
class TestSchema:
|
||||
"""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
|
||||
|
||||
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
|
||||
|
||||
schema = TOOL_SCHEMAS["get_wire_connections"]
|
||||
@@ -80,7 +80,7 @@ class TestSchema:
|
||||
assert "x" 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
|
||||
|
||||
schema = TOOL_SCHEMAS["get_wire_connections"]
|
||||
@@ -97,7 +97,7 @@ class TestSchema:
|
||||
class TestHandlerDispatch:
|
||||
"""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
|
||||
with patch("kicad_interface.USE_IPC_BACKEND", False):
|
||||
from kicad_interface import KiCADInterface
|
||||
@@ -116,7 +116,7 @@ class TestHandlerDispatch:
|
||||
|
||||
# Build routes only (avoid full __init__ side-effects)
|
||||
# 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 callable(iface.command_routes["get_wire_connections"])
|
||||
@@ -131,7 +131,7 @@ class TestHandlerDispatch:
|
||||
class TestHandlerParamValidation:
|
||||
"""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."""
|
||||
with patch("kicad_interface.USE_IPC_BACKEND", False):
|
||||
from kicad_interface import KiCADInterface
|
||||
@@ -139,29 +139,29 @@ class TestHandlerParamValidation:
|
||||
iface = KiCADInterface.__new__(KiCADInterface)
|
||||
return iface._handle_get_wire_connections
|
||||
|
||||
def test_missing_schematic_path(self):
|
||||
def test_missing_schematic_path(self) -> None:
|
||||
handler = self._make_handler()
|
||||
result = handler({"x": 1.0, "y": 2.0})
|
||||
assert result["success"] is False
|
||||
assert "schematicPath" in result["message"] or "Missing" in result["message"]
|
||||
|
||||
def test_missing_x(self):
|
||||
def test_missing_x(self) -> None:
|
||||
handler = self._make_handler()
|
||||
result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 2.0})
|
||||
assert result["success"] is False
|
||||
|
||||
def test_missing_y(self):
|
||||
def test_missing_y(self) -> None:
|
||||
handler = self._make_handler()
|
||||
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0})
|
||||
assert result["success"] is False
|
||||
|
||||
def test_non_numeric_x(self):
|
||||
def test_non_numeric_x(self) -> None:
|
||||
handler = self._make_handler()
|
||||
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "bad", "y": 2.0})
|
||||
assert result["success"] is False
|
||||
assert "numeric" in result["message"].lower() or "x" in result["message"]
|
||||
|
||||
def test_non_numeric_y(self):
|
||||
def test_non_numeric_y(self) -> None:
|
||||
handler = self._make_handler()
|
||||
result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0, "y": "bad"})
|
||||
assert result["success"] is False
|
||||
@@ -180,39 +180,39 @@ class TestCoreLogic:
|
||||
|
||||
# --- _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)
|
||||
|
||||
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)
|
||||
|
||||
def test_to_iu_zero(self):
|
||||
def test_to_iu_zero(self) -> None:
|
||||
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)
|
||||
|
||||
# --- _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))
|
||||
result = _parse_wires(sch)
|
||||
assert len(result) == 1
|
||||
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.wire = []
|
||||
assert _parse_wires(sch) == []
|
||||
|
||||
def test_parse_wires_multiple_wires(self):
|
||||
def test_parse_wires_multiple_wires(self) -> None:
|
||||
sch = _make_schematic(
|
||||
_make_wire(0.0, 0.0, 1.0, 0.0),
|
||||
_make_wire(1.0, 0.0, 2.0, 0.0),
|
||||
)
|
||||
assert len(_parse_wires(sch)) == 2
|
||||
|
||||
def test_parse_wires_skips_wire_without_pts(self):
|
||||
def test_parse_wires_skips_wire_without_pts(self) -> None:
|
||||
bad_wire = MagicMock(spec=[]) # no `pts` attribute
|
||||
sch = MagicMock()
|
||||
sch.wire = [bad_wire]
|
||||
@@ -220,7 +220,7 @@ class TestCoreLogic:
|
||||
|
||||
# --- _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)
|
||||
wires = [
|
||||
[(0, 0), (10_000, 0)],
|
||||
@@ -230,7 +230,7 @@ class TestCoreLogic:
|
||||
assert 1 in adjacency[0]
|
||||
assert 0 in adjacency[1]
|
||||
|
||||
def test_build_adjacency_two_disconnected_wires(self):
|
||||
def test_build_adjacency_two_disconnected_wires(self) -> None:
|
||||
wires = [
|
||||
[(0, 0), (10_000, 0)],
|
||||
[(20_000, 0), (30_000, 0)],
|
||||
@@ -239,7 +239,7 @@ class TestCoreLogic:
|
||||
assert adjacency[0] == 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 = [
|
||||
[(0, 0), (10_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[(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)
|
||||
wires = [
|
||||
[(0, 0), (10_000, 0)],
|
||||
@@ -262,14 +262,14 @@ class TestCoreLogic:
|
||||
|
||||
# --- _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)]]
|
||||
adjacency, iu_to_wires = _build_adjacency(wires)
|
||||
visited, net_points = _find_connected_wires(5.0, 0.0, wires, iu_to_wires, adjacency)
|
||||
assert visited is None
|
||||
assert net_points is None
|
||||
|
||||
def test_find_connected_wires_single_wire(self):
|
||||
def test_find_connected_wires_single_wire(self) -> None:
|
||||
wires = [[(0, 0), (10_000, 0)]]
|
||||
adjacency, iu_to_wires = _build_adjacency(wires)
|
||||
visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency)
|
||||
@@ -277,7 +277,7 @@ class TestCoreLogic:
|
||||
assert (0, 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
|
||||
wires = [
|
||||
[(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)
|
||||
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
|
||||
wires = [
|
||||
[(0, 0), (10_000, 0)],
|
||||
@@ -300,18 +300,18 @@ class TestCoreLogic:
|
||||
|
||||
# --- 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.wire = []
|
||||
result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0)
|
||||
assert result == {"pins": [], "wires": []}
|
||||
|
||||
def test_get_wire_connections_no_wire_at_point_returns_none(self):
|
||||
def test_get_wire_connections_no_wire_at_point_returns_none(self) -> None:
|
||||
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
|
||||
result = get_wire_connections(sch, "/fake/path.kicad_sch", 5.0, 0.0)
|
||||
assert result is None
|
||||
|
||||
def test_get_wire_connections_returns_wire_data(self):
|
||||
def test_get_wire_connections_returns_wire_data(self) -> None:
|
||||
sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0))
|
||||
# Prevent _find_pins_on_net from iterating symbols
|
||||
result = get_wire_connections(sch, "/fake/path.kicad_sch", 0.0, 0.0)
|
||||
@@ -322,7 +322,7 @@ class TestCoreLogic:
|
||||
assert wire["start"] == {"x": 0.0, "y": 0.0}
|
||||
assert wire["end"] == {"x": 1.0, "y": 0.0}
|
||||
|
||||
def test_get_wire_connections_chain_returns_all_wires(self):
|
||||
def test_get_wire_connections_chain_returns_all_wires(self) -> None:
|
||||
sch = _make_schematic(
|
||||
_make_wire(0.0, 0.0, 1.0, 0.0),
|
||||
_make_wire(1.0, 0.0, 2.0, 0.0),
|
||||
|
||||
@@ -10,6 +10,7 @@ import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
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."""
|
||||
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
|
||||
shutil.copy(EMPTY_SCH, 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."""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
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 [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."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def load_schemas(self):
|
||||
def load_schemas(self) -> Any:
|
||||
from schemas.tool_schemas import SCHEMATIC_TOOLS
|
||||
|
||||
self.tools = {t["name"]: t for t in SCHEMATIC_TOOLS}
|
||||
|
||||
def test_add_schematic_wire_has_waypoints(self):
|
||||
def test_add_schematic_wire_has_waypoints(self) -> None:
|
||||
schema = self.tools["add_schematic_wire"]["inputSchema"]
|
||||
assert "waypoints" in schema["properties"], "waypoints must be a property"
|
||||
assert "waypoints" in schema["required"]
|
||||
|
||||
def test_add_schematic_wire_has_schematic_path(self):
|
||||
def test_add_schematic_wire_has_schematic_path(self) -> None:
|
||||
schema = self.tools["add_schematic_wire"]["inputSchema"]
|
||||
assert "schematicPath" in schema["properties"]
|
||||
assert "schematicPath" in schema["required"]
|
||||
|
||||
def test_add_schematic_wire_has_snap_params(self):
|
||||
def test_add_schematic_wire_has_snap_params(self) -> None:
|
||||
schema = self.tools["add_schematic_wire"]["inputSchema"]
|
||||
props = schema["properties"]
|
||||
assert "snapToPins" in props
|
||||
@@ -79,28 +80,28 @@ class TestSchemas:
|
||||
assert "snapTolerance" in props
|
||||
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"]
|
||||
props = schema["properties"]
|
||||
assert "startPoint" not in props, "startPoint should be removed"
|
||||
assert "endPoint" not in props, "endPoint should be removed"
|
||||
|
||||
def test_add_schematic_connection_removed(self):
|
||||
def test_add_schematic_connection_removed(self) -> None:
|
||||
assert (
|
||||
"add_schematic_connection" not in self.tools
|
||||
), "add_schematic_connection must not appear in SCHEMATIC_TOOLS"
|
||||
|
||||
def test_add_schematic_junction_present(self):
|
||||
def test_add_schematic_junction_present(self) -> None:
|
||||
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"]
|
||||
props = schema["properties"]
|
||||
assert "schematicPath" in props
|
||||
assert "position" in props
|
||||
assert set(schema["required"]) >= {"schematicPath", "position"}
|
||||
|
||||
def test_add_schematic_junction_position_is_array(self):
|
||||
def test_add_schematic_junction_position_is_array(self) -> None:
|
||||
schema = self.tools["add_schematic_junction"]["inputSchema"]
|
||||
pos = schema["properties"]["position"]
|
||||
assert pos["type"] == "array"
|
||||
@@ -117,7 +118,7 @@ class TestHandlerDispatch:
|
||||
"""Verify KiCADInterface registers the right tool handlers."""
|
||||
|
||||
@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 importlib
|
||||
import types
|
||||
@@ -141,18 +142,18 @@ class TestHandlerDispatch:
|
||||
}
|
||||
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
|
||||
|
||||
# Just verify the class has the handler method
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
assert not hasattr(
|
||||
@@ -169,7 +170,7 @@ class TestHandleAddSchematicWireValidation:
|
||||
"""Unit tests for _handle_add_schematic_wire validation paths (no disk I/O)."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def handler(self):
|
||||
def handler(self) -> Any:
|
||||
import types
|
||||
|
||||
for mod in ["pcbnew", "skip"]:
|
||||
@@ -179,17 +180,17 @@ class TestHandleAddSchematicWireValidation:
|
||||
with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None):
|
||||
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]]})
|
||||
assert result["success"] is False
|
||||
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"})
|
||||
assert result["success"] is False
|
||||
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(
|
||||
{
|
||||
"schematicPath": "/tmp/x.kicad_sch",
|
||||
@@ -209,7 +210,7 @@ class TestHandleAddSchematicWireRouting:
|
||||
"""Unit tests verifying add_wire vs add_polyline_wire dispatch, no pin snapping."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self):
|
||||
def setup(self) -> Any:
|
||||
import types
|
||||
|
||||
for mod in ["pcbnew", "skip"]:
|
||||
@@ -224,7 +225,7 @@ class TestHandleAddSchematicWireRouting:
|
||||
shutil.rmtree(self.sch_path.parent, ignore_errors=True)
|
||||
|
||||
@patch("commands.wire_manager.WireManager.add_wire", return_value=True)
|
||||
def test_two_waypoints_calls_add_wire(self, mock_add_wire):
|
||||
def test_two_waypoints_calls_add_wire(self, mock_add_wire: Any) -> None:
|
||||
result = self.iface._handle_add_schematic_wire(
|
||||
{
|
||||
"schematicPath": str(self.sch_path),
|
||||
@@ -239,7 +240,7 @@ class TestHandleAddSchematicWireRouting:
|
||||
assert args[2] == [30.0, 20.0]
|
||||
|
||||
@patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True)
|
||||
def test_four_waypoints_calls_add_polyline_wire(self, mock_poly):
|
||||
def test_four_waypoints_calls_add_polyline_wire(self, mock_poly: Any) -> None:
|
||||
result = self.iface._handle_add_schematic_wire(
|
||||
{
|
||||
"schematicPath": str(self.sch_path),
|
||||
@@ -250,7 +251,7 @@ class TestHandleAddSchematicWireRouting:
|
||||
assert result["success"] is True
|
||||
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."""
|
||||
result = self.iface._handle_add_schematic_wire(
|
||||
{
|
||||
@@ -263,7 +264,7 @@ class TestHandleAddSchematicWireRouting:
|
||||
assert "waypoint" in result["message"].lower()
|
||||
|
||||
@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(
|
||||
{
|
||||
"schematicPath": str(self.sch_path),
|
||||
@@ -283,18 +284,18 @@ class TestPinSnapping:
|
||||
"""Verify pin snapping logic snaps endpoints correctly."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self):
|
||||
def setup(self) -> Any:
|
||||
import types
|
||||
|
||||
# Provide a minimal skip.Schematic stub so the handler can import it
|
||||
skip_mod = types.ModuleType("skip")
|
||||
|
||||
class FakeSchematic:
|
||||
def __init__(self, path):
|
||||
def __init__(self, path: Any) -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def symbol(self):
|
||||
def symbol(self) -> list[Any]:
|
||||
return [] # no symbols → no pins in snapping loop
|
||||
|
||||
skip_mod.Schematic = FakeSchematic
|
||||
@@ -312,7 +313,7 @@ class TestPinSnapping:
|
||||
|
||||
@patch("commands.wire_manager.WireManager.add_wire", return_value=True)
|
||||
@patch("commands.pin_locator.PinLocator.get_all_symbol_pins")
|
||||
def test_start_point_snapped_within_tolerance(self, mock_pins, mock_wire):
|
||||
def test_start_point_snapped_within_tolerance(self, mock_pins: Any, mock_wire: Any) -> None:
|
||||
"""First waypoint within tolerance of a pin should be snapped to pin coords."""
|
||||
# get_all_symbol_pins won't be called because symbol list is empty in fixture.
|
||||
# Instead we patch find_nearest_pin indirectly by providing all_pins via the
|
||||
@@ -326,7 +327,7 @@ class TestPinSnapping:
|
||||
class Reference:
|
||||
value = "R1"
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
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}"
|
||||
# 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 (
|
||||
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]
|
||||
|
||||
@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."""
|
||||
with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw:
|
||||
result = self.iface._handle_add_schematic_wire(
|
||||
@@ -397,7 +398,7 @@ class TestPinSnapping:
|
||||
assert "snapped" not in result.get("message", "")
|
||||
|
||||
@patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True)
|
||||
def test_intermediate_waypoints_not_snapped(self, mock_poly):
|
||||
def test_intermediate_waypoints_not_snapped(self, mock_poly: Any) -> None:
|
||||
"""Middle waypoints must remain unchanged even with snapToPins=True."""
|
||||
mid = [50.0, 50.0]
|
||||
with patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) as mp:
|
||||
@@ -424,7 +425,7 @@ class TestPinSnapping:
|
||||
class TestHandleAddSchematicJunction:
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self):
|
||||
def setup(self) -> Any:
|
||||
import types
|
||||
|
||||
for mod in ["pcbnew", "skip"]:
|
||||
@@ -434,18 +435,18 @@ class TestHandleAddSchematicJunction:
|
||||
with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None):
|
||||
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]})
|
||||
assert result["success"] is False
|
||||
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"})
|
||||
assert result["success"] is False
|
||||
assert "Position" in result["message"]
|
||||
|
||||
@patch("commands.wire_manager.WireManager.add_junction", return_value=True)
|
||||
def test_success(self, mock_jct):
|
||||
def test_success(self, mock_jct: Any) -> None:
|
||||
sch = _make_temp_sch()
|
||||
try:
|
||||
result = self.iface._handle_add_schematic_junction(
|
||||
@@ -461,7 +462,7 @@ class TestHandleAddSchematicJunction:
|
||||
shutil.rmtree(sch.parent, ignore_errors=True)
|
||||
|
||||
@patch("commands.wire_manager.WireManager.add_junction", return_value=False)
|
||||
def test_failure(self, _):
|
||||
def test_failure(self, _: Any) -> None:
|
||||
sch = _make_temp_sch()
|
||||
try:
|
||||
result = self.iface._handle_add_schematic_junction(
|
||||
@@ -483,21 +484,21 @@ class TestHandleAddSchematicJunction:
|
||||
|
||||
class TestConnectionManagerOrphanedMethodsRemoved:
|
||||
|
||||
def test_add_wire_removed(self):
|
||||
def test_add_wire_removed(self) -> None:
|
||||
from commands.connection_schematic import ConnectionManager
|
||||
|
||||
assert not hasattr(
|
||||
ConnectionManager, "add_wire"
|
||||
), "ConnectionManager.add_wire should have been removed"
|
||||
|
||||
def test_add_connection_removed(self):
|
||||
def test_add_connection_removed(self) -> None:
|
||||
from commands.connection_schematic import ConnectionManager
|
||||
|
||||
assert not hasattr(
|
||||
ConnectionManager, "add_connection"
|
||||
), "ConnectionManager.add_connection should have been removed"
|
||||
|
||||
def test_get_pin_location_removed(self):
|
||||
def test_get_pin_location_removed(self) -> None:
|
||||
from commands.connection_schematic import ConnectionManager
|
||||
|
||||
assert not hasattr(
|
||||
@@ -515,12 +516,12 @@ class TestIntegrationWireManager:
|
||||
"""Integration tests using real schematic files and WireManager."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def sch(self):
|
||||
def sch(self) -> Any:
|
||||
path = _make_temp_sch()
|
||||
yield path
|
||||
shutil.rmtree(path.parent, ignore_errors=True)
|
||||
|
||||
def test_add_wire_writes_wire_element(self, sch):
|
||||
def test_add_wire_writes_wire_element(self, sch: Any) -> None:
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
ok = WireManager.add_wire(sch, [10.0, 10.0], [30.0, 10.0])
|
||||
@@ -529,7 +530,7 @@ class TestIntegrationWireManager:
|
||||
wires = _find_elements(data, "wire")
|
||||
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."""
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
@@ -540,7 +541,7 @@ class TestIntegrationWireManager:
|
||||
wires = _find_elements(data, "wire")
|
||||
assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}"
|
||||
|
||||
def test_add_junction_writes_junction_element(self, sch):
|
||||
def test_add_junction_writes_junction_element(self, sch: Any) -> None:
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
ok = WireManager.add_junction(sch, [25.4, 25.4])
|
||||
@@ -553,7 +554,7 @@ class TestIntegrationWireManager:
|
||||
assert at[1] == 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
|
||||
|
||||
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."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self):
|
||||
def setup(self) -> Any:
|
||||
import types
|
||||
|
||||
for mod in ["pcbnew", "skip"]:
|
||||
@@ -589,7 +590,7 @@ class TestIntegrationHandlerEndToEnd:
|
||||
yield
|
||||
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(
|
||||
{
|
||||
"schematicPath": str(self.sch),
|
||||
@@ -601,7 +602,7 @@ class TestIntegrationHandlerEndToEnd:
|
||||
junctions = _find_elements(data, "junction")
|
||||
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(
|
||||
{
|
||||
"schematicPath": str(self.sch),
|
||||
@@ -614,7 +615,7 @@ class TestIntegrationHandlerEndToEnd:
|
||||
wires = _find_elements(data, "wire")
|
||||
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(
|
||||
{
|
||||
"schematicPath": str(self.sch),
|
||||
@@ -638,64 +639,64 @@ class TestPointStrictlyOnWire:
|
||||
"""Unit tests for WireManager._point_strictly_on_wire geometry helper."""
|
||||
|
||||
@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
|
||||
|
||||
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
|
||||
|
||||
def test_vertical_midpoint(self):
|
||||
def test_vertical_midpoint(self) -> None:
|
||||
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."""
|
||||
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."""
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def test_point_off_horizontal_wire(self):
|
||||
def test_point_off_horizontal_wire(self) -> None:
|
||||
"""Point above a horizontal wire."""
|
||||
assert self._fn(5, 1, 0, 0, 10, 0) is False
|
||||
|
||||
def test_point_off_vertical_wire(self):
|
||||
def test_point_off_vertical_wire(self) -> None:
|
||||
"""Point to the right of a vertical wire."""
|
||||
assert self._fn(1, 5, 0, 0, 0, 10) is False
|
||||
|
||||
def test_point_beyond_horizontal_wire(self):
|
||||
def test_point_beyond_horizontal_wire(self) -> None:
|
||||
"""Point collinear but past the end of a horizontal wire."""
|
||||
assert self._fn(15, 0, 0, 0, 10, 0) is False
|
||||
|
||||
def test_point_beyond_vertical_wire(self):
|
||||
def test_point_beyond_vertical_wire(self) -> None:
|
||||
"""Point collinear but past the end of a vertical wire."""
|
||||
assert self._fn(0, 15, 0, 0, 0, 10) is False
|
||||
|
||||
def test_diagonal_wire_always_false(self):
|
||||
def test_diagonal_wire_always_false(self) -> None:
|
||||
"""Only horizontal/vertical wires are handled; diagonal → False."""
|
||||
assert self._fn(5, 5, 0, 0, 10, 10) is False
|
||||
|
||||
def test_reversed_horizontal_endpoints(self):
|
||||
def test_reversed_horizontal_endpoints(self) -> None:
|
||||
"""Wire endpoints reversed (x2 < x1) should still work."""
|
||||
assert self._fn(5, 0, 10, 0, 0, 0) is True
|
||||
|
||||
def test_reversed_vertical_endpoints(self):
|
||||
def test_reversed_vertical_endpoints(self) -> None:
|
||||
"""Wire endpoints reversed (y2 < y1) should still work."""
|
||||
assert self._fn(0, 5, 0, 10, 0, 0) is True
|
||||
|
||||
def test_near_endpoint_within_epsilon(self):
|
||||
def test_near_endpoint_within_epsilon(self) -> None:
|
||||
"""Point within epsilon of endpoint should NOT be considered strictly on wire."""
|
||||
assert self._fn(1e-7, 0, 0, 0, 10, 0) is False
|
||||
|
||||
def test_zero_length_wire(self):
|
||||
def test_zero_length_wire(self) -> None:
|
||||
"""Degenerate wire with same start/end — nothing is strictly between."""
|
||||
assert self._fn(5, 5, 5, 5, 5, 5) is False
|
||||
|
||||
@@ -710,12 +711,12 @@ class TestParseWire:
|
||||
"""Unit tests for WireManager._parse_wire S-expression parser."""
|
||||
|
||||
@staticmethod
|
||||
def _fn(item):
|
||||
def _fn(item: Any) -> Any:
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
return WireManager._parse_wire(item)
|
||||
|
||||
def test_valid_wire(self):
|
||||
def test_valid_wire(self) -> None:
|
||||
wire = [
|
||||
Symbol("wire"),
|
||||
[Symbol("pts"), [Symbol("xy"), 10.0, 20.0], [Symbol("xy"), 30.0, 20.0]],
|
||||
@@ -734,28 +735,28 @@ class TestParseWire:
|
||||
assert width == 0
|
||||
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]]
|
||||
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
|
||||
|
||||
def test_empty_list_returns_none(self):
|
||||
def test_empty_list_returns_none(self) -> 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]]]
|
||||
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 = [
|
||||
Symbol("wire"),
|
||||
[Symbol("pts"), [Symbol("xy"), 10.0, 20.0]],
|
||||
]
|
||||
assert TestParseWire._fn(wire) is None
|
||||
|
||||
def test_wire_without_stroke_uses_defaults(self):
|
||||
def test_wire_without_stroke_uses_defaults(self) -> None:
|
||||
wire = [
|
||||
Symbol("wire"),
|
||||
[Symbol("pts"), [Symbol("xy"), 0, 0], [Symbol("xy"), 10, 0]],
|
||||
@@ -776,7 +777,7 @@ class TestParseWire:
|
||||
class TestMakeWireSexp:
|
||||
"""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
|
||||
|
||||
sexp = WireManager._make_wire_sexp([10, 20], [30, 20])
|
||||
@@ -788,7 +789,7 @@ class TestMakeWireSexp:
|
||||
assert width == 0
|
||||
assert stype == "default"
|
||||
|
||||
def test_custom_stroke(self):
|
||||
def test_custom_stroke(self) -> None:
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
sexp = WireManager._make_wire_sexp([0, 0], [5, 0], stroke_width=0.5, stroke_type="dash")
|
||||
@@ -798,7 +799,7 @@ class TestMakeWireSexp:
|
||||
assert width == 0.5
|
||||
assert stype == "dash"
|
||||
|
||||
def test_has_uuid(self):
|
||||
def test_has_uuid(self) -> None:
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
sexp = WireManager._make_wire_sexp([0, 0], [10, 0])
|
||||
@@ -807,7 +808,7 @@ class TestMakeWireSexp:
|
||||
assert uuid_entry[0] == Symbol("uuid")
|
||||
assert isinstance(uuid_entry[1], str) and len(uuid_entry[1]) > 0
|
||||
|
||||
def test_two_calls_produce_different_uuids(self):
|
||||
def test_two_calls_produce_different_uuids(self) -> None:
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
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."""
|
||||
|
||||
@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."""
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
@@ -835,7 +836,7 @@ class TestBreakWiresAtPoint:
|
||||
data.append([Symbol("sheet_instances")])
|
||||
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
|
||||
|
||||
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}
|
||||
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
|
||||
|
||||
data = self._make_sch_data_with_wires([([5, 0], [5, 30])])
|
||||
@@ -860,7 +861,7 @@ class TestBreakWiresAtPoint:
|
||||
wires = _find_elements(data, "wire")
|
||||
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."""
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
@@ -870,7 +871,7 @@ class TestBreakWiresAtPoint:
|
||||
wires = _find_elements(data, "wire")
|
||||
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
|
||||
|
||||
data = self._make_sch_data_with_wires([([0, 0], [20, 0])])
|
||||
@@ -879,7 +880,7 @@ class TestBreakWiresAtPoint:
|
||||
wires = _find_elements(data, "wire")
|
||||
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."""
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
@@ -894,7 +895,7 @@ class TestBreakWiresAtPoint:
|
||||
wires = _find_elements(data, "wire")
|
||||
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
|
||||
|
||||
data = [Symbol("kicad_sch")]
|
||||
@@ -910,7 +911,7 @@ class TestBreakWiresAtPoint:
|
||||
assert parsed[2] == 0.5, "stroke_width should be preserved"
|
||||
assert parsed[3] == "dash", "stroke_type should be preserved"
|
||||
|
||||
def test_no_split_on_diagonal_wire(self):
|
||||
def test_no_split_on_diagonal_wire(self) -> None:
|
||||
"""Diagonal wires are not handled by _point_strictly_on_wire → no split."""
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
@@ -918,7 +919,7 @@ class TestBreakWiresAtPoint:
|
||||
splits = WireManager._break_wires_at_point(data, [5, 5])
|
||||
assert splits == 0
|
||||
|
||||
def test_empty_sch_data(self):
|
||||
def test_empty_sch_data(self) -> None:
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
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."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def sch(self):
|
||||
def sch(self) -> Any:
|
||||
path = _make_temp_sch()
|
||||
yield path
|
||||
shutil.rmtree(path.parent, ignore_errors=True)
|
||||
|
||||
def test_add_wire_breaks_existing_horizontal_wire(self, sch):
|
||||
def test_add_wire_breaks_existing_horizontal_wire(self, sch: Any) -> None:
|
||||
"""Adding a vertical wire whose endpoint is mid-horizontal-wire should split it."""
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
@@ -954,7 +955,7 @@ class TestIntegrationTJunction:
|
||||
# Original horizontal wire should be split into 2, plus the new vertical = 3 total
|
||||
assert len(wires) == 3, f"Expected 3 wires (split + new), got {len(wires)}"
|
||||
|
||||
def test_add_wire_does_not_break_at_shared_endpoint(self, sch):
|
||||
def test_add_wire_does_not_break_at_shared_endpoint(self, sch: Any) -> None:
|
||||
"""Wire connecting at an existing endpoint should not trigger a split."""
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
@@ -965,7 +966,7 @@ class TestIntegrationTJunction:
|
||||
wires = _find_elements(data, "wire")
|
||||
assert len(wires) == 2, f"Expected 2 wires (no split), got {len(wires)}"
|
||||
|
||||
def test_add_junction_breaks_wire(self, sch):
|
||||
def test_add_junction_breaks_wire(self, sch: Any) -> None:
|
||||
"""Adding a junction mid-wire should split that wire."""
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
@@ -977,7 +978,7 @@ class TestIntegrationTJunction:
|
||||
junctions = _find_elements(data, "junction")
|
||||
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."""
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
@@ -987,7 +988,7 @@ class TestIntegrationTJunction:
|
||||
wires = _find_elements(data, "wire")
|
||||
assert len(wires) == 1, f"Expected 1 wire (no split at endpoint), got {len(wires)}"
|
||||
|
||||
def test_polyline_breaks_existing_wire(self, sch):
|
||||
def test_polyline_breaks_existing_wire(self, sch: Any) -> None:
|
||||
"""Polyline whose start/end hits mid-wire should break it."""
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
@@ -999,7 +1000,7 @@ class TestIntegrationTJunction:
|
||||
# 2 from split + 2 polyline segments = 4
|
||||
assert len(wires) == 4, f"Expected 4 wires, got {len(wires)}"
|
||||
|
||||
def test_polyline_two_points_same_as_add_wire(self, sch):
|
||||
def test_polyline_two_points_same_as_add_wire(self, sch: Any) -> None:
|
||||
"""Polyline with exactly 2 points should produce 1 wire segment."""
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
|
||||
@@ -157,9 +157,9 @@ class KiCADProcessManager:
|
||||
timeout=5 if system == "Windows" else None,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
path = result.stdout.strip().split("\n")[0]
|
||||
logger.info(f"Found KiCAD executable: {path}")
|
||||
return Path(path)
|
||||
exe_path = result.stdout.strip().split("\n")[0]
|
||||
logger.info(f"Found KiCAD executable: {exe_path}")
|
||||
return Path(exe_path)
|
||||
|
||||
# Platform-specific default paths
|
||||
if system == "Linux":
|
||||
|
||||
@@ -516,7 +516,16 @@ export class KiCADMcpServer {
|
||||
// Determine timeout based on command type
|
||||
// DRC and export operations need longer timeouts for large boards
|
||||
let commandTimeout = 30000; // Default 30 seconds
|
||||
const longRunningCommands = ["run_drc", "export_gerber", "export_pdf", "export_3d"];
|
||||
const longRunningCommands = [
|
||||
"run_drc",
|
||||
"export_gerber",
|
||||
"export_pdf",
|
||||
"export_3d",
|
||||
"sync_schematic_to_board",
|
||||
"list_schematic_nets",
|
||||
"list_schematic_labels",
|
||||
"get_schematic_view",
|
||||
];
|
||||
if (longRunningCommands.includes(command)) {
|
||||
commandTimeout = 600000; // 10 minutes for long operations
|
||||
logger.info(`Using extended timeout (${commandTimeout / 1000}s) for command: ${command}`);
|
||||
@@ -550,7 +559,21 @@ export class KiCADMcpServer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse a complete JSON response from the buffer
|
||||
* Try to parse a complete JSON response from the buffer.
|
||||
*
|
||||
* Responses from the Python side are single-line JSON terminated by '\n'
|
||||
* (written via _write_response). The buffer may also contain non-JSON
|
||||
* preamble lines (e.g. C-level warnings from pcbnew that leaked to the
|
||||
* response fd before the redirect took effect).
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Fast path: JSON.parse(buffer) — works for clean, complete responses
|
||||
* (JSON.parse tolerates trailing whitespace/newlines).
|
||||
* 2. If that fails and the buffer has no '\n' yet, the response line is
|
||||
* still arriving in chunks — keep collecting.
|
||||
* 3. If the buffer has '\n', split into lines and search from the END for
|
||||
* a parseable JSON line. This avoids prematurely resolving with a
|
||||
* truncated JSON object when a large response is still chunking in.
|
||||
*/
|
||||
private tryParseResponse(): void {
|
||||
if (!this.currentRequestHandler) {
|
||||
@@ -564,14 +587,64 @@ export class KiCADMcpServer {
|
||||
return;
|
||||
}
|
||||
|
||||
let result: any;
|
||||
|
||||
// Fast path: try to parse the response as JSON. Handles the common
|
||||
// case of a clean, complete JSON response (possibly with trailing \n).
|
||||
try {
|
||||
// Try to parse the response as JSON
|
||||
const result = JSON.parse(this.responseBuffer);
|
||||
result = JSON.parse(this.responseBuffer);
|
||||
} catch {
|
||||
// Direct parse failed. Either the response is still arriving in
|
||||
// chunks, or the buffer has non-JSON preamble from pcbnew.
|
||||
//
|
||||
// The Python side writes each response as a single line of JSON
|
||||
// terminated by \n. We use the newline as the completion signal:
|
||||
// if there is no \n in the buffer yet, the JSON line is still
|
||||
// being assembled from chunks — keep collecting.
|
||||
if (!this.responseBuffer.includes("\n")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Buffer contains newline(s). Split into lines and look for a
|
||||
// complete JSON object, searching from the END so that preamble
|
||||
// lines (which may themselves contain '{') are skipped.
|
||||
const lines = this.responseBuffer.split("\n");
|
||||
let jsonLineIndex = -1;
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim();
|
||||
if (line.length === 0) continue;
|
||||
if (!line.startsWith("{")) continue;
|
||||
|
||||
try {
|
||||
result = JSON.parse(line);
|
||||
jsonLineIndex = i;
|
||||
break;
|
||||
} catch {
|
||||
// Looks like JSON but doesn't parse — could be an incomplete
|
||||
// final line still being chunked. Keep collecting.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonLineIndex < 0) {
|
||||
// No parseable JSON line found yet. Either only preamble has
|
||||
// arrived, or the JSON line is split across the last \n boundary
|
||||
// and is still incomplete. Keep collecting.
|
||||
return;
|
||||
}
|
||||
|
||||
// Log any preceding non-JSON lines as preamble
|
||||
const preambleLines = lines.slice(0, jsonLineIndex).filter((l) => l.trim().length > 0);
|
||||
if (preambleLines.length > 0) {
|
||||
logger.warn(
|
||||
`Stripped non-JSON preamble from Python response: ${preambleLines.join(" | ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, we have a valid JSON response
|
||||
logger.debug(
|
||||
`Completed KiCAD command with result: ${result.success ? "success" : "failure"}`,
|
||||
);
|
||||
logger.debug(`Completed KiCAD command with result: ${result.success ? "success" : "failure"}`);
|
||||
|
||||
// Clear the timeout since we got a response
|
||||
if (this.currentRequestHandler.timeoutHandle) {
|
||||
@@ -591,10 +664,6 @@ export class KiCADMcpServer {
|
||||
|
||||
// Process next request if any
|
||||
setTimeout(() => this.processNextRequest(), 0);
|
||||
} catch (e) {
|
||||
// Not a complete JSON yet, keep collecting data
|
||||
// This is normal for large responses that come in chunks
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user