test: add schematic tools tests and apply black formatting

- Add python/tests/conftest.py with MagicMock stubs for pcbnew/skip
- Add python/tests/test_schematic_tools.py with 29 tests covering
  WireManager.delete_wire, WireManager.delete_label (unit + integration),
  and parameter validation for all 11 new _handle_* methods
- Apply black formatting to component_schematic.py, wire_manager.py,
  and kicad_interface.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Eugene Mikhantyev
2026-03-15 22:48:24 +00:00
parent 4dc351660c
commit f562035f79
5 changed files with 1159 additions and 264 deletions

View File

@@ -10,11 +10,15 @@ logger = logging.getLogger(__name__)
# Import dynamic symbol loader
try:
from commands.dynamic_symbol_loader import DynamicSymbolLoader
DYNAMIC_LOADING_AVAILABLE = True
except ImportError:
logger.warning("Dynamic symbol loader not available - falling back to template-only mode")
logger.warning(
"Dynamic symbol loader not available - falling back to template-only mode"
)
DYNAMIC_LOADING_AVAILABLE = False
class ComponentManager:
"""Manage components in a schematic"""
@@ -31,43 +35,44 @@ class ComponentManager:
# Template symbol references mapping component type to template reference
TEMPLATE_MAP = {
# Passives
'R': '_TEMPLATE_R',
'C': '_TEMPLATE_C',
'L': '_TEMPLATE_L',
'Y': '_TEMPLATE_Y',
'Crystal': '_TEMPLATE_Y',
"R": "_TEMPLATE_R",
"C": "_TEMPLATE_C",
"L": "_TEMPLATE_L",
"Y": "_TEMPLATE_Y",
"Crystal": "_TEMPLATE_Y",
# Semiconductors
'D': '_TEMPLATE_D',
'LED': '_TEMPLATE_LED',
'Q': '_TEMPLATE_Q_NPN',
'Q_NPN': '_TEMPLATE_Q_NPN',
'Q_NMOS': '_TEMPLATE_Q_NMOS',
'MOSFET': '_TEMPLATE_Q_NMOS',
"D": "_TEMPLATE_D",
"LED": "_TEMPLATE_LED",
"Q": "_TEMPLATE_Q_NPN",
"Q_NPN": "_TEMPLATE_Q_NPN",
"Q_NMOS": "_TEMPLATE_Q_NMOS",
"MOSFET": "_TEMPLATE_Q_NMOS",
# ICs
'U': '_TEMPLATE_U_OPAMP',
'OpAmp': '_TEMPLATE_U_OPAMP',
'IC': '_TEMPLATE_U_OPAMP',
'U_REG': '_TEMPLATE_U_REG',
'Regulator': '_TEMPLATE_U_REG',
"U": "_TEMPLATE_U_OPAMP",
"OpAmp": "_TEMPLATE_U_OPAMP",
"IC": "_TEMPLATE_U_OPAMP",
"U_REG": "_TEMPLATE_U_REG",
"Regulator": "_TEMPLATE_U_REG",
# Connectors
'J': '_TEMPLATE_J2',
'J2': '_TEMPLATE_J2',
'J4': '_TEMPLATE_J4',
'Conn_2': '_TEMPLATE_J2',
'Conn_4': '_TEMPLATE_J4',
"J": "_TEMPLATE_J2",
"J2": "_TEMPLATE_J2",
"J4": "_TEMPLATE_J4",
"Conn_2": "_TEMPLATE_J2",
"Conn_4": "_TEMPLATE_J4",
# Misc
'SW': '_TEMPLATE_SW',
'Button': '_TEMPLATE_SW',
'Switch': '_TEMPLATE_SW',
"SW": "_TEMPLATE_SW",
"Button": "_TEMPLATE_SW",
"Switch": "_TEMPLATE_SW",
}
@classmethod
def get_or_create_template(cls, schematic: Schematic, comp_type: str, library: Optional[str] = None,
schematic_path: Optional[Path] = None) -> tuple:
def get_or_create_template(
cls,
schematic: Schematic,
comp_type: str,
library: Optional[str] = None,
schematic_path: Optional[Path] = None,
) -> tuple:
"""
Get template reference for a component type, creating it dynamically if needed
@@ -80,11 +85,15 @@ class ComponentManager:
Returns:
Tuple of (template_ref, needs_reload) where needs_reload indicates if schematic must be reloaded
"""
# Helper function to check if template exists in schematic
def template_exists(schematic, template_ref):
"""Check if template exists by iterating symbols (handles special characters)"""
for symbol in schematic.symbol:
if hasattr(symbol.property, 'Reference') and symbol.property.Reference.value == template_ref:
if (
hasattr(symbol.property, "Reference")
and symbol.property.Reference.value == template_ref
):
return True
return False
@@ -113,46 +122,61 @@ class ComponentManager:
# 3. Try dynamic loading
if not DYNAMIC_LOADING_AVAILABLE:
logger.warning(f"Component type '{comp_type}' not in static templates and dynamic loading unavailable")
logger.warning(
f"Component type '{comp_type}' not in static templates and dynamic loading unavailable"
)
# Fall back to basic resistor template
return ('_TEMPLATE_R', False)
return ("_TEMPLATE_R", False)
loader = cls.get_dynamic_loader()
if not loader:
logger.warning("Dynamic loader unavailable, using fallback template")
return ('_TEMPLATE_R', False)
return ("_TEMPLATE_R", False)
# Check if schematic path is available
if schematic_path is None:
logger.warning("Dynamic loading requires schematic file path but none was provided")
fallback = cls.TEMPLATE_MAP.get(comp_type, '_TEMPLATE_R')
logger.warning(
"Dynamic loading requires schematic file path but none was provided"
)
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
return (fallback, False)
# Determine library name
if library is None:
# Default library for common component types
library = 'Device' # Most passives and basic components are in Device library
library = (
"Device" # Most passives and basic components are in Device library
)
try:
logger.info(f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}")
logger.info(
f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}"
)
# Use dynamic symbol loader to inject symbol and create template
template_ref = loader.load_symbol_dynamically(schematic_path, library, comp_type)
template_ref = loader.load_symbol_dynamically(
schematic_path, library, comp_type
)
logger.info(f"Successfully loaded symbol dynamically. Template ref: {template_ref}")
logger.info(
f"Successfully loaded symbol dynamically. Template ref: {template_ref}"
)
# Signal that schematic needs reload to see new template
return (template_ref, True)
except Exception as e:
logger.error(f"Dynamic loading failed: {e}")
import traceback
logger.error(traceback.format_exc())
# Fall back to static template if available
fallback = cls.TEMPLATE_MAP.get(comp_type, '_TEMPLATE_R')
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
return (fallback, False)
@staticmethod
def add_component(schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None):
def add_component(
schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None
):
"""
Add a component to the schematic by cloning from template
@@ -167,66 +191,81 @@ class ComponentManager:
try:
from commands.schematic import SchematicManager
logger.info(f"Adding component: type={component_def.get('type')}, ref={component_def.get('reference')}")
logger.info(
f"Adding component: type={component_def.get('type')}, ref={component_def.get('reference')}"
)
logger.debug(f"Full component_def: {component_def}")
# Get component type and determine template
comp_type = component_def.get('type', 'R')
library = component_def.get('library', None) # Optional library specification
comp_type = component_def.get("type", "R")
library = component_def.get(
"library", None
) # Optional library specification
# Get template reference (static or dynamic)
template_ref, needs_reload = ComponentManager.get_or_create_template(schematic, comp_type, library, schematic_path)
template_ref, needs_reload = ComponentManager.get_or_create_template(
schematic, comp_type, library, schematic_path
)
# If dynamic loading occurred, reload schematic to see new template
if needs_reload and schematic_path:
logger.info(f"Reloading schematic after dynamic loading: {schematic_path}")
logger.info(
f"Reloading schematic after dynamic loading: {schematic_path}"
)
schematic = SchematicManager.load_schematic(str(schematic_path))
# Find template symbol by reference (handles special characters like +)
template_symbol = None
for symbol in schematic.symbol:
if hasattr(symbol.property, 'Reference') and symbol.property.Reference.value == template_ref:
if (
hasattr(symbol.property, "Reference")
and symbol.property.Reference.value == template_ref
):
template_symbol = symbol
break
if not template_symbol:
logger.error(f"Template symbol {template_ref} not found in schematic. Available symbols: {[str(s.property.Reference.value) for s in schematic.symbol]}")
raise ValueError(f"Template symbol {template_ref} not found. The schematic must be created from template_with_symbols.kicad_sch")
logger.error(
f"Template symbol {template_ref} not found in schematic. Available symbols: {[str(s.property.Reference.value) for s in schematic.symbol]}"
)
raise ValueError(
f"Template symbol {template_ref} not found. The schematic must be created from template_with_symbols.kicad_sch"
)
# Clone the template symbol
new_symbol = template_symbol.clone()
logger.debug(f"Cloned template symbol {template_ref}")
# Set reference
reference = component_def.get('reference', 'R?')
reference = component_def.get("reference", "R?")
new_symbol.property.Reference.value = reference
logger.debug(f"Set reference to {reference}")
# Set value
if 'value' in component_def:
new_symbol.property.Value.value = component_def['value']
if "value" in component_def:
new_symbol.property.Value.value = component_def["value"]
logger.debug(f"Set value to {component_def['value']}")
# Set footprint
if 'footprint' in component_def:
new_symbol.property.Footprint.value = component_def['footprint']
if "footprint" in component_def:
new_symbol.property.Footprint.value = component_def["footprint"]
logger.debug(f"Set footprint to {component_def['footprint']}")
# Set datasheet
if 'datasheet' in component_def:
new_symbol.property.Datasheet.value = component_def['datasheet']
if "datasheet" in component_def:
new_symbol.property.Datasheet.value = component_def["datasheet"]
# Set position
x = component_def.get('x', 0)
y = component_def.get('y', 0)
rotation = component_def.get('rotation', 0)
x = component_def.get("x", 0)
y = component_def.get("y", 0)
rotation = component_def.get("rotation", 0)
new_symbol.at.value = [x, y, rotation]
logger.debug(f"Set position to ({x}, {y}, {rotation})")
# Set BOM and board flags
new_symbol.in_bom.value = component_def.get('in_bom', True)
new_symbol.on_board.value = component_def.get('on_board', True)
new_symbol.dnp.value = component_def.get('dnp', False)
new_symbol.in_bom.value = component_def.get("in_bom", True)
new_symbol.on_board.value = component_def.get("on_board", True)
new_symbol.dnp.value = component_def.get("dnp", False)
# Generate new UUID
new_symbol.uuid.value = str(uuid.uuid4())
@@ -263,9 +302,10 @@ class ComponentManager:
print(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
):
"""Update component properties by reference designator"""
try:
symbol_to_update = None
@@ -279,8 +319,8 @@ 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)
# 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}.")
return True
else:
@@ -307,9 +347,14 @@ class ComponentManager:
matching_components = []
query_lower = query.lower()
for symbol in schematic.symbol:
if query_lower in symbol.reference.lower() or \
query_lower in symbol.name.lower() or \
(hasattr(symbol.property, 'Value') and query_lower in symbol.property.Value.value.lower()):
if (
query_lower in symbol.reference.lower()
or query_lower in symbol.name.lower()
or (
hasattr(symbol.property, "Value")
and query_lower in symbol.property.Value.value.lower()
)
):
matching_components.append(symbol)
print(f"Found {len(matching_components)} components matching query '{query}'.")
return matching_components
@@ -320,17 +365,34 @@ class ComponentManager:
print(f"Retrieving all {len(schematic.symbol)} components.")
return list(schematic.symbol)
if __name__ == '__main__':
if __name__ == "__main__":
# Example Usage (for testing)
from schematic import SchematicManager # Assuming schematic.py is in the same directory
from schematic import (
SchematicManager,
) # Assuming schematic.py is in the same directory
# Create a new schematic
test_sch = SchematicManager.create_schematic("ComponentTestSchematic")
# Add components
comp1_def = {"type": "R", "reference": "R1", "value": "10k", "x": 100, "y": 100}
comp2_def = {"type": "C", "reference": "C1", "value": "0.1uF", "x": 200, "y": 100, "library": "Device"}
comp3_def = {"type": "LED", "reference": "D1", "x": 300, "y": 100, "library": "Device", "properties": {"Color": "Red"}}
comp2_def = {
"type": "C",
"reference": "C1",
"value": "0.1uF",
"x": 200,
"y": 100,
"library": "Device",
}
comp3_def = {
"type": "LED",
"reference": "D1",
"x": 300,
"y": 100,
"library": "Device",
"properties": {"Color": "Red"},
}
comp1 = ComponentManager.add_component(test_sch, comp1_def)
comp2 = ComponentManager.add_component(test_sch, comp2_def)
@@ -339,13 +401,19 @@ if __name__ == '__main__':
# Get a component
retrieved_comp = ComponentManager.get_component(test_sch, "C1")
if retrieved_comp:
print(f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})")
print(
f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})"
)
# Update a component
ComponentManager.update_component(test_sch, "R1", {"value": "20k", "Tolerance": "5%"})
ComponentManager.update_component(
test_sch, "R1", {"value": "20k", "Tolerance": "5%"}
)
# Search components
matching_comps = ComponentManager.search_components(test_sch, "100") # Search by position
matching_comps = ComponentManager.search_components(
test_sch, "100"
) # Search by position
print(f"Search results for '100': {[c.reference for c in matching_comps]}")
# Get all components
@@ -355,7 +423,9 @@ if __name__ == '__main__':
# Remove a component
ComponentManager.remove_component(test_sch, "D1")
all_comps_after_remove = ComponentManager.get_all_components(test_sch)
print(f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}")
print(
f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}"
)
# Save the schematic (optional)
# SchematicManager.save_schematic(test_sch, "component_test.kicad_sch")