mcp-arduino/tests/test_arduino_library.py
Ryan Malloy eb524b8c1d Major project refactor: Rename to mcp-arduino with smart client capabilities
BREAKING CHANGES:
- Package renamed from mcp-arduino-server to mcp-arduino
- Command changed to 'mcp-arduino' (was 'mcp-arduino-server')
- Repository moved to git.supported.systems/MCP/mcp-arduino

NEW FEATURES:
 Smart client capability detection and dual-mode sampling support
 Intelligent WireViz templates with component-specific circuits (LED, motor, sensor, button, display)
 Client debug tools for MCP capability inspection
 Enhanced error handling with progressive enhancement patterns

IMPROVEMENTS:
🧹 Major repository cleanup - removed 14+ experimental files and tests
📝 Consolidated and reorganized documentation
🐛 Fixed import issues and applied comprehensive linting with ruff
📦 Updated author information to Ryan Malloy (ryan@supported.systems)
🔧 Fixed package version references in startup code

TECHNICAL DETAILS:
- Added dual-mode WireViz: AI generation for sampling clients, smart templates for others
- Implemented client capability detection via MCP handshake inspection
- Created progressive enhancement pattern for universal MCP client compatibility
- Organized test files into proper structure (tests/examples/)
- Applied comprehensive code formatting and lint fixes

The server now provides excellent functionality for ALL MCP clients regardless
of their sampling capabilities, while preserving advanced features for clients
that support them.

Version: 2025.09.27.1
2025-09-27 20:16:43 -06:00

383 lines
14 KiB
Python

"""
Tests for ArduinoLibrary component
"""
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from tests.conftest import assert_logged_info, assert_progress_reported
class TestArduinoLibrary:
"""Test suite for ArduinoLibrary component"""
@pytest.mark.asyncio
async def test_search_libraries_success(self, library_component, test_context, mock_arduino_cli):
"""Test successful library search"""
# Setup mock response
mock_response = {
"libraries": [
{
"name": "Servo",
"author": "Arduino",
"sentence": "Control servo motors",
"paragraph": "Detailed description",
"category": "Device Control",
"architectures": ["*"],
"latest": {"version": "1.1.8"}
},
{
"name": "WiFi",
"author": "Arduino",
"sentence": "WiFi connectivity",
"paragraph": "WiFi library",
"category": "Communication",
"architectures": ["esp32"],
"latest": {"version": "2.0.0"}
}
]
}
mock_arduino_cli.return_value.stdout = json.dumps(mock_response)
mock_arduino_cli.return_value.returncode = 0
result = await library_component.search_libraries(
test_context,
"servo",
limit=5
)
assert result["success"] is True
assert result["count"] == 2
assert len(result["libraries"]) == 2
assert result["libraries"][0]["name"] == "Servo"
# Verify arduino-cli was called correctly
mock_arduino_cli.assert_called_once()
call_args = mock_arduino_cli.call_args[0][0]
assert "lib" in call_args
assert "search" in call_args
assert "servo" in call_args
@pytest.mark.asyncio
async def test_search_libraries_empty(self, library_component, test_context, mock_arduino_cli):
"""Test library search with no results"""
mock_arduino_cli.return_value.stdout = '{"libraries": []}'
mock_arduino_cli.return_value.returncode = 0
result = await library_component.search_libraries(
test_context,
"nonexistent"
)
assert result["count"] == 0
assert result["libraries"] == []
assert "No libraries found" in result["message"]
@pytest.mark.asyncio
async def test_search_libraries_limit(self, library_component, test_context, mock_arduino_cli):
"""Test library search respects limit"""
# Create mock response with many libraries
libraries = [
{
"name": f"Library{i}",
"author": "Test",
"latest": {"version": "1.0.0"}
}
for i in range(20)
]
mock_arduino_cli.return_value.stdout = json.dumps({"libraries": libraries})
mock_arduino_cli.return_value.returncode = 0
result = await library_component.search_libraries(
test_context,
"test",
limit=5
)
assert result["count"] == 5
assert len(result["libraries"]) == 5
@pytest.mark.asyncio
async def test_install_library_success(self, library_component, test_context, mock_async_subprocess):
"""Test successful library installation with progress"""
# Mock the async subprocess for progress tracking
mock_process = mock_async_subprocess.return_value
mock_process.returncode = 0
# Simulate progress output
mock_process.stdout.readline = AsyncMock(side_effect=[
b'Downloading Servo@1.1.8...\n',
b'Installing Servo@1.1.8...\n',
b'Servo@1.1.8 installed\n',
b'' # End of stream
])
mock_process.wait = AsyncMock(return_value=0)
result = await library_component.install_library(
test_context,
"Servo"
)
assert result["success"] is True
assert "installed successfully" in result["message"]
# Verify progress was reported
assert_progress_reported(test_context, min_calls=2)
assert_logged_info(test_context, "Starting installation")
@pytest.mark.asyncio
async def test_install_library_with_version(self, library_component, test_context, mock_async_subprocess):
"""Test installing specific library version"""
mock_process = mock_async_subprocess.return_value
mock_process.returncode = 0
mock_process.stdout.readline = AsyncMock(return_value=b'')
mock_process.wait = AsyncMock(return_value=0)
result = await library_component.install_library(
test_context,
"WiFi",
"2.0.0"
)
assert result["success"] is True
# Verify version was included in command
call_args = mock_async_subprocess.call_args[0]
assert "@2.0.0" in call_args
@pytest.mark.asyncio
async def test_install_library_already_installed(self, library_component, test_context, mock_async_subprocess):
"""Test installing library that's already installed"""
mock_process = mock_async_subprocess.return_value
mock_process.returncode = 1
# Simulate the stderr output for already installed
mock_process.stderr.readline = AsyncMock(side_effect=[
b'Library already installed\n',
b''
])
mock_process.stdout.readline = AsyncMock(return_value=b'')
mock_process.wait = AsyncMock(return_value=1)
result = await library_component.install_library(
test_context,
"ExistingLib"
)
assert result["success"] is True
assert "already installed" in result["message"]
@pytest.mark.asyncio
async def test_uninstall_library_success(self, library_component, test_context, mock_arduino_cli):
"""Test successful library uninstallation"""
mock_arduino_cli.return_value.returncode = 0
mock_arduino_cli.return_value.stdout = "Library uninstalled"
result = await library_component.uninstall_library(
test_context,
"OldLibrary"
)
assert result["success"] is True
assert "uninstalled successfully" in result["message"]
# Verify command
call_args = mock_arduino_cli.call_args[0][0]
assert "lib" in call_args
assert "uninstall" in call_args
assert "OldLibrary" in call_args
@pytest.mark.asyncio
async def test_list_library_examples_found(self, library_component, test_context, temp_dir):
"""Test listing examples from installed library"""
# Create library directory structure
lib_dir = temp_dir / "Arduino" / "libraries" / "TestLib"
lib_dir.mkdir(parents=True)
examples_dir = lib_dir / "examples"
examples_dir.mkdir()
# Create example sketches
example1 = examples_dir / "Basic"
example1.mkdir()
(example1 / "Basic.ino").write_text("// Basic example\nvoid setup() {}")
example2 = examples_dir / "Advanced"
example2.mkdir()
(example2 / "Advanced.ino").write_text("// Advanced example\n// With multiple features\nvoid setup() {}")
# Update component's arduino_user_dir
library_component.arduino_user_dir = temp_dir / "Arduino"
result = await library_component.list_library_examples(
test_context,
"TestLib"
)
assert result["success"] is True
assert result["count"] == 2
assert len(result["examples"]) == 2
# Check example details
example_names = [ex["name"] for ex in result["examples"]]
assert "Basic" in example_names
assert "Advanced" in example_names
@pytest.mark.asyncio
async def test_list_library_examples_not_found(self, library_component, test_context, temp_dir):
"""Test listing examples for non-existent library"""
# Create the libraries directory but no library
lib_dir = temp_dir / "Arduino" / "libraries"
lib_dir.mkdir(parents=True)
library_component.arduino_user_dir = temp_dir / "Arduino"
result = await library_component.list_library_examples(
test_context,
"NonExistentLib"
)
assert "error" in result
assert "not found" in result["error"]
@pytest.mark.asyncio
async def test_list_library_examples_no_examples(self, library_component, test_context, temp_dir):
"""Test library with no examples"""
# Create library without examples directory
lib_dir = temp_dir / "Arduino" / "libraries" / "NoExamplesLib"
lib_dir.mkdir(parents=True)
library_component.arduino_user_dir = temp_dir / "Arduino"
result = await library_component.list_library_examples(
test_context,
"NoExamplesLib"
)
assert result["message"] == "Library 'NoExamplesLib' has no examples"
assert result["examples"] == []
@pytest.mark.asyncio
async def test_list_library_examples_fuzzy_match(self, library_component, test_context, temp_dir):
"""Test fuzzy matching for library names"""
# Create library with slightly different name
lib_dir = temp_dir / "Arduino" / "libraries" / "ServoMotor"
lib_dir.mkdir(parents=True)
examples_dir = lib_dir / "examples"
examples_dir.mkdir()
example = examples_dir / "Sweep"
example.mkdir()
(example / "Sweep.ino").write_text("// Sweep example")
library_component.arduino_user_dir = temp_dir / "Arduino"
# Enable fuzzy matching
library_component.fuzzy_available = True
library_component.fuzz = MagicMock()
library_component.fuzz.ratio = MagicMock(return_value=85) # High similarity score
result = await library_component.list_library_examples(
test_context,
"Servo" # Close but not exact match
)
# With fuzzy matching, it should find ServoMotor
assert result["success"] is True
assert result["library"] == "ServoMotor"
assert result["count"] == 1
@pytest.mark.asyncio
async def test_get_installed_libraries(self, library_component, mock_arduino_cli):
"""Test getting list of installed libraries"""
mock_response = {
"installed_libraries": [
{
"name": "Servo",
"version": "1.1.8",
"author": "Arduino",
"sentence": "Control servo motors"
}
]
}
mock_arduino_cli.return_value.stdout = json.dumps(mock_response)
mock_arduino_cli.return_value.returncode = 0
# Call the private method directly
libraries = await library_component._get_installed_libraries()
assert len(libraries) == 1
assert libraries[0]["name"] == "Servo"
@pytest.mark.asyncio
async def test_list_installed_libraries_resource(self, library_component, mock_arduino_cli):
"""Test the MCP resource for listing installed libraries"""
mock_response = {
"installed_libraries": [
{
"name": "WiFi",
"version": "2.0.0",
"author": "Arduino",
"sentence": "Connect to WiFi networks"
},
{
"name": "SPI",
"version": "1.0.0",
"author": "Arduino",
"sentence": "SPI communication"
}
]
}
mock_arduino_cli.return_value.stdout = json.dumps(mock_response)
mock_arduino_cli.return_value.returncode = 0
result = await library_component.list_installed_libraries()
assert "Installed Arduino Libraries (2)" in result
assert "WiFi" in result
assert "SPI" in result
def test_get_example_description(self, library_component, temp_dir):
"""Test extracting description from example file"""
# Test single-line comment
ino_file = temp_dir / "test.ino"
ino_file.write_text("// This is a test example\nvoid setup() {}")
description = library_component._get_example_description(ino_file)
assert description == "This is a test example"
# Test multi-line comment - it finds the first non-star line
ino_file.write_text("/*\n * Multi-line\n * Example description\n */\nvoid setup() {}")
description = library_component._get_example_description(ino_file)
assert description == "Multi-line" # It returns the first non-star content
# Test no description
ino_file.write_text("void setup() {}")
description = library_component._get_example_description(ino_file)
assert description == "No description available"
@pytest.mark.asyncio
async def test_install_library_timeout(self, library_component, test_context, mock_async_subprocess):
"""Test library installation timeout handling"""
mock_process = mock_async_subprocess.return_value
# Simulate timeout
async def timeout_side_effect():
raise asyncio.TimeoutError()
mock_process.wait = timeout_side_effect
# Mock readline to prevent hanging
mock_process.stdout.readline = AsyncMock(return_value=b'')
mock_process.stderr.readline = AsyncMock(return_value=b'')
result = await library_component.install_library(
test_context,
"SlowLibrary"
)
assert "error" in result
assert "timed out" in result["error"]