kicad-mcp/tests/unit/utils/test_kicad_cli.py
Ryan Malloy bc0f3db97c
Some checks are pending
CI / Lint and Format (push) Waiting to run
CI / Test Python 3.11 on macos-latest (push) Waiting to run
CI / Test Python 3.12 on macos-latest (push) Waiting to run
CI / Test Python 3.13 on macos-latest (push) Waiting to run
CI / Test Python 3.10 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.11 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.12 on ubuntu-latest (push) Waiting to run
CI / Test Python 3.13 on ubuntu-latest (push) Waiting to run
CI / Security Scan (push) Waiting to run
CI / Build Package (push) Blocked by required conditions
Implement comprehensive AI/LLM integration for KiCad MCP server
Add intelligent analysis and recommendation tools for KiCad designs:

## New AI Tools (kicad_mcp/tools/ai_tools.py)
- suggest_components_for_circuit: Smart component suggestions based on circuit analysis
- recommend_design_rules: Automated design rule recommendations for different technologies
- optimize_pcb_layout: PCB layout optimization for signal integrity, thermal, and cost
- analyze_design_completeness: Comprehensive design completeness analysis

## Enhanced Utilities
- component_utils.py: Add ComponentType enum and component classification functions
- pattern_recognition.py: Enhanced circuit pattern analysis and recommendations
- netlist_parser.py: Implement missing parse_netlist_file function for AI tools

## Key Features
- Circuit pattern recognition for power supplies, amplifiers, microcontrollers
- Technology-specific design rules (standard, HDI, RF, automotive)
- Layout optimization suggestions with implementation steps
- Component suggestion system with standard values and examples
- Design completeness scoring with actionable recommendations

## Server Integration
- Register AI tools in FastMCP server
- Integrate with existing KiCad utilities and file parsers
- Error handling and graceful fallbacks for missing data

Fixes ImportError that prevented server startup and enables advanced
AI-powered design assistance for KiCad projects.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-11 16:15:58 -06:00

414 lines
15 KiB
Python

"""
Tests for the kicad_mcp.utils.kicad_cli module.
"""
import platform
import subprocess
from unittest.mock import Mock, patch
import pytest
from kicad_mcp.utils.kicad_cli import (
KiCadCLIError,
KiCadCLIManager,
find_kicad_cli,
get_cli_manager,
get_kicad_cli_path,
get_kicad_version,
is_kicad_cli_available,
)
class TestKiCadCLIError:
"""Test KiCadCLIError exception."""
def test_exception_creation(self):
"""Test that KiCadCLIError can be created and raised."""
with pytest.raises(KiCadCLIError) as exc_info:
raise KiCadCLIError("Test error message")
assert str(exc_info.value) == "Test error message"
class TestKiCadCLIManager:
"""Test KiCadCLIManager class."""
def setup_method(self):
"""Set up test instance."""
self.manager = KiCadCLIManager()
def test_init(self):
"""Test manager initialization."""
manager = KiCadCLIManager()
assert manager._cached_cli_path is None
assert manager._cache_validated is False
assert manager._system == platform.system()
@patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager._detect_cli_path')
@patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager._validate_cli_path')
def test_find_kicad_cli_success(self, mock_validate, mock_detect):
"""Test successful CLI detection."""
mock_detect.return_value = "/usr/bin/kicad-cli"
mock_validate.return_value = True
result = self.manager.find_kicad_cli()
assert result == "/usr/bin/kicad-cli"
assert self.manager._cached_cli_path == "/usr/bin/kicad-cli"
assert self.manager._cache_validated is True
@patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager._detect_cli_path')
def test_find_kicad_cli_not_found(self, mock_detect):
"""Test CLI detection failure."""
mock_detect.return_value = None
result = self.manager.find_kicad_cli()
assert result is None
assert self.manager._cached_cli_path is None
assert self.manager._cache_validated is False
@patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager._detect_cli_path')
@patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager._validate_cli_path')
def test_find_kicad_cli_validation_failure(self, mock_validate, mock_detect):
"""Test CLI detection with validation failure."""
mock_detect.return_value = "/usr/bin/kicad-cli"
mock_validate.return_value = False
result = self.manager.find_kicad_cli()
assert result is None
assert self.manager._cached_cli_path is None
assert self.manager._cache_validated is False
def test_find_kicad_cli_cached(self):
"""Test that cached CLI path is returned."""
self.manager._cached_cli_path = "/cached/path"
self.manager._cache_validated = True
with patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager._detect_cli_path') as mock_detect:
result = self.manager.find_kicad_cli()
assert result == "/cached/path"
mock_detect.assert_not_called()
def test_find_kicad_cli_force_refresh(self):
"""Test force refresh ignores cache."""
self.manager._cached_cli_path = "/cached/path"
self.manager._cache_validated = True
with patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager._detect_cli_path') as mock_detect, \
patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager._validate_cli_path') as mock_validate:
mock_detect.return_value = "/new/path"
mock_validate.return_value = True
result = self.manager.find_kicad_cli(force_refresh=True)
assert result == "/new/path"
mock_detect.assert_called_once()
@patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager.find_kicad_cli')
def test_get_cli_path_success(self, mock_find):
"""Test successful CLI path retrieval."""
mock_find.return_value = "/usr/bin/kicad-cli"
result = self.manager.get_cli_path()
assert result == "/usr/bin/kicad-cli"
@patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager.find_kicad_cli')
def test_get_cli_path_not_required(self, mock_find):
"""Test CLI path retrieval when not required."""
mock_find.return_value = None
result = self.manager.get_cli_path(required=False)
assert result is None
@patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager.find_kicad_cli')
def test_get_cli_path_required_raises(self, mock_find):
"""Test that exception is raised when CLI required but not found."""
mock_find.return_value = None
with pytest.raises(KiCadCLIError) as exc_info:
self.manager.get_cli_path(required=True)
assert "KiCad CLI not found" in str(exc_info.value)
@patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager.find_kicad_cli')
def test_is_available_true(self, mock_find):
"""Test is_available returns True when CLI found."""
mock_find.return_value = "/usr/bin/kicad-cli"
assert self.manager.is_available() is True
@patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager.find_kicad_cli')
def test_is_available_false(self, mock_find):
"""Test is_available returns False when CLI not found."""
mock_find.return_value = None
assert self.manager.is_available() is False
@patch('kicad_mcp.utils.kicad_cli.subprocess.run')
@patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager.find_kicad_cli')
def test_get_version_success(self, mock_find, mock_run):
"""Test successful version retrieval."""
mock_find.return_value = "/usr/bin/kicad-cli"
mock_result = Mock()
mock_result.returncode = 0
mock_result.stdout = "KiCad 7.0.0\n"
mock_run.return_value = mock_result
version = self.manager.get_version()
assert version == "KiCad 7.0.0"
mock_run.assert_called_once()
@patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager.find_kicad_cli')
def test_get_version_cli_not_found(self, mock_find):
"""Test version retrieval when CLI not found."""
mock_find.return_value = None
version = self.manager.get_version()
assert version is None
@patch('kicad_mcp.utils.kicad_cli.subprocess.run')
@patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager.find_kicad_cli')
def test_get_version_subprocess_error(self, mock_find, mock_run):
"""Test version retrieval with subprocess error."""
mock_find.return_value = "/usr/bin/kicad-cli"
mock_run.side_effect = subprocess.SubprocessError("Test error")
version = self.manager.get_version()
assert version is None
@patch('kicad_mcp.utils.kicad_cli.os.environ.get')
@patch('kicad_mcp.utils.kicad_cli.os.path.isfile')
@patch('kicad_mcp.utils.kicad_cli.os.access')
def test_detect_cli_path_environment_variable(self, mock_access, mock_isfile, mock_env_get):
"""Test CLI detection from environment variable."""
mock_env_get.return_value = "/custom/kicad-cli"
mock_isfile.return_value = True
mock_access.return_value = True
result = self.manager._detect_cli_path()
assert result == "/custom/kicad-cli"
@patch('kicad_mcp.utils.kicad_cli.os.environ.get')
@patch('kicad_mcp.utils.kicad_cli.shutil.which')
def test_detect_cli_path_system_path(self, mock_which, mock_env_get):
"""Test CLI detection from system PATH."""
mock_env_get.return_value = None
mock_which.return_value = "/usr/bin/kicad-cli"
result = self.manager._detect_cli_path()
assert result == "/usr/bin/kicad-cli"
@patch('kicad_mcp.utils.kicad_cli.os.environ.get')
@patch('kicad_mcp.utils.kicad_cli.shutil.which')
@patch('kicad_mcp.utils.kicad_cli.os.path.isfile')
@patch('kicad_mcp.utils.kicad_cli.os.access')
def test_detect_cli_path_common_locations(self, mock_access, mock_isfile, mock_which, mock_env_get):
"""Test CLI detection from common installation paths."""
mock_env_get.return_value = None
mock_which.return_value = None
mock_isfile.side_effect = lambda x: x == "/usr/local/bin/kicad-cli"
mock_access.return_value = True
result = self.manager._detect_cli_path()
assert result == "/usr/local/bin/kicad-cli"
def test_get_cli_executable_name_windows(self):
"""Test CLI executable name on Windows."""
with patch('platform.system', return_value='Windows'):
manager = KiCadCLIManager()
name = manager._get_cli_executable_name()
assert name == "kicad-cli.exe"
def test_get_cli_executable_name_unix(self):
"""Test CLI executable name on Unix-like systems."""
with patch('platform.system', return_value='Linux'):
manager = KiCadCLIManager()
name = manager._get_cli_executable_name()
assert name == "kicad-cli"
def test_get_common_installation_paths_macos(self):
"""Test common installation paths on macOS."""
with patch('platform.system', return_value='Darwin'):
manager = KiCadCLIManager()
paths = manager._get_common_installation_paths()
assert "/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli" in paths
assert "/opt/homebrew/bin/kicad-cli" in paths
def test_get_common_installation_paths_windows(self):
"""Test common installation paths on Windows."""
with patch('platform.system', return_value='Windows'):
manager = KiCadCLIManager()
paths = manager._get_common_installation_paths()
assert r"C:\Program Files\KiCad\bin\kicad-cli.exe" in paths
assert r"C:\Program Files (x86)\KiCad\bin\kicad-cli.exe" in paths
def test_get_common_installation_paths_linux(self):
"""Test common installation paths on Linux."""
with patch('platform.system', return_value='Linux'):
manager = KiCadCLIManager()
paths = manager._get_common_installation_paths()
assert "/usr/bin/kicad-cli" in paths
assert "/snap/kicad/current/usr/bin/kicad-cli" in paths
@patch('kicad_mcp.utils.kicad_cli.subprocess.run')
def test_validate_cli_path_success(self, mock_run):
"""Test successful CLI validation."""
mock_result = Mock()
mock_result.returncode = 0
mock_run.return_value = mock_result
result = self.manager._validate_cli_path("/usr/bin/kicad-cli")
assert result is True
@patch('kicad_mcp.utils.kicad_cli.subprocess.run')
def test_validate_cli_path_failure(self, mock_run):
"""Test CLI validation failure."""
mock_result = Mock()
mock_result.returncode = 1
mock_run.return_value = mock_result
result = self.manager._validate_cli_path("/usr/bin/kicad-cli")
assert result is False
@patch('kicad_mcp.utils.kicad_cli.subprocess.run')
def test_validate_cli_path_exception(self, mock_run):
"""Test CLI validation with exception."""
mock_run.side_effect = subprocess.SubprocessError("Test error")
result = self.manager._validate_cli_path("/usr/bin/kicad-cli")
assert result is False
class TestGlobalFunctions:
"""Test global convenience functions."""
def setup_method(self):
"""Reset global manager before each test."""
import kicad_mcp.utils.kicad_cli
kicad_mcp.utils.kicad_cli._cli_manager = None
def test_get_cli_manager_singleton(self):
"""Test that get_cli_manager returns singleton instance."""
manager1 = get_cli_manager()
manager2 = get_cli_manager()
assert manager1 is manager2
assert isinstance(manager1, KiCadCLIManager)
@patch('kicad_mcp.utils.kicad_cli.get_cli_manager')
def test_find_kicad_cli_convenience(self, mock_get_manager):
"""Test find_kicad_cli convenience function."""
mock_manager = Mock()
mock_manager.find_kicad_cli.return_value = "/usr/bin/kicad-cli"
mock_get_manager.return_value = mock_manager
result = find_kicad_cli(force_refresh=True)
assert result == "/usr/bin/kicad-cli"
mock_manager.find_kicad_cli.assert_called_once_with(True)
@patch('kicad_mcp.utils.kicad_cli.get_cli_manager')
def test_get_kicad_cli_path_convenience(self, mock_get_manager):
"""Test get_kicad_cli_path convenience function."""
mock_manager = Mock()
mock_manager.get_cli_path.return_value = "/usr/bin/kicad-cli"
mock_get_manager.return_value = mock_manager
result = get_kicad_cli_path(required=False)
assert result == "/usr/bin/kicad-cli"
mock_manager.get_cli_path.assert_called_once_with(False)
@patch('kicad_mcp.utils.kicad_cli.get_cli_manager')
def test_is_kicad_cli_available_convenience(self, mock_get_manager):
"""Test is_kicad_cli_available convenience function."""
mock_manager = Mock()
mock_manager.is_available.return_value = True
mock_get_manager.return_value = mock_manager
result = is_kicad_cli_available()
assert result is True
mock_manager.is_available.assert_called_once()
@patch('kicad_mcp.utils.kicad_cli.get_cli_manager')
def test_get_kicad_version_convenience(self, mock_get_manager):
"""Test get_kicad_version convenience function."""
mock_manager = Mock()
mock_manager.get_version.return_value = "KiCad 7.0.0"
mock_get_manager.return_value = mock_manager
result = get_kicad_version()
assert result == "KiCad 7.0.0"
mock_manager.get_version.assert_called_once()
class TestIntegration:
"""Integration tests for KiCad CLI functionality."""
def test_manager_lifecycle(self):
"""Test complete manager lifecycle."""
manager = KiCadCLIManager()
# Initial state
assert manager._cached_cli_path is None
assert not manager._cache_validated
# Simulate finding CLI
with patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager._detect_cli_path') as mock_detect, \
patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager._validate_cli_path') as mock_validate:
mock_detect.return_value = "/test/kicad-cli"
mock_validate.return_value = True
# First call should detect and cache
path1 = manager.find_kicad_cli()
assert path1 == "/test/kicad-cli"
assert manager._cached_cli_path == "/test/kicad-cli"
assert manager._cache_validated
# Second call should use cache
path2 = manager.find_kicad_cli()
assert path2 == "/test/kicad-cli"
assert mock_detect.call_count == 1 # Should only be called once
# Force refresh should re-detect
mock_detect.return_value = "/new/path"
path3 = manager.find_kicad_cli(force_refresh=True)
assert path3 == "/new/path"
assert mock_detect.call_count == 2
def test_error_propagation(self):
"""Test that errors are properly propagated."""
manager = KiCadCLIManager()
with patch('kicad_mcp.utils.kicad_cli.KiCadCLIManager.find_kicad_cli') as mock_find:
mock_find.return_value = None
# Should not raise when required=False
result = manager.get_cli_path(required=False)
assert result is None
# Should raise when required=True
with pytest.raises(KiCadCLIError):
manager.get_cli_path(required=True)