velxio/backend/tests/test_mcp_tools.py

417 lines
13 KiB
Python

"""
Tests for the Velxio MCP server tools.
These tests exercise the tool functions directly (without a running MCP transport)
and mock the ArduinoCLIService to avoid requiring arduino-cli to be installed.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
# Ensure the backend package is importable when running from the repo root
sys.path.insert(0, str(Path(__file__).parent.parent))
# ---------------------------------------------------------------------------
# Wokwi utility tests
# ---------------------------------------------------------------------------
from app.mcp.wokwi import (
color_to_hex,
format_wokwi_diagram,
generate_arduino_sketch,
hex_to_color_name,
parse_wokwi_diagram,
)
class TestColorHelpers:
def test_named_color_to_hex(self):
assert color_to_hex("red") == "#ff0000"
assert color_to_hex("GREEN") == "#00ff00"
def test_hex_passthrough(self):
assert color_to_hex("#1a2b3c") == "#1a2b3c"
def test_hex_to_name(self):
assert hex_to_color_name("#ff0000") == "red"
assert hex_to_color_name("#unknown") == "#unknown"
SAMPLE_DIAGRAM = {
"version": 1,
"author": "test",
"editor": "wokwi",
"parts": [
{"type": "wokwi-arduino-uno", "id": "uno", "top": 0, "left": 0, "attrs": {}},
{
"type": "wokwi-led",
"id": "led1",
"top": 100,
"left": 200,
"rotate": 0,
"attrs": {"color": "red"},
},
{
"type": "wokwi-resistor",
"id": "r1",
"top": 150,
"left": 200,
"attrs": {"value": "220"},
},
],
"connections": [
["uno:13", "led1:A", "green", []],
["led1:C", "r1:1", "black", []],
["r1:2", "uno:GND.1", "black", []],
],
}
class TestParseWokwiDiagram:
def test_detects_board_fqbn(self):
result = parse_wokwi_diagram(SAMPLE_DIAGRAM)
assert result["board_fqbn"] == "arduino:avr:uno"
def test_components_parsed(self):
result = parse_wokwi_diagram(SAMPLE_DIAGRAM)
ids = [c["id"] for c in result["components"]]
assert "uno" in ids
assert "led1" in ids
assert "r1" in ids
def test_connections_parsed(self):
result = parse_wokwi_diagram(SAMPLE_DIAGRAM)
conns = result["connections"]
assert len(conns) == 3
first = conns[0]
assert first["from_part"] == "uno"
assert first["from_pin"] == "13"
assert first["to_part"] == "led1"
assert first["to_pin"] == "A"
assert first["color"] == "#00ff00" # 'green' -> hex
def test_version(self):
result = parse_wokwi_diagram(SAMPLE_DIAGRAM)
assert result["version"] == 1
def test_empty_diagram(self):
result = parse_wokwi_diagram({})
assert result["board_fqbn"] == "arduino:avr:uno"
assert result["components"] == []
assert result["connections"] == []
def test_unknown_board_defaults(self):
diagram = {"parts": [{"type": "unknown-board", "id": "b1", "attrs": {}}]}
result = parse_wokwi_diagram(diagram)
assert result["board_fqbn"] == "arduino:avr:uno"
class TestFormatWokwiDiagram:
def test_round_trip(self):
"""parse → format should preserve the essential structure."""
circuit = parse_wokwi_diagram(SAMPLE_DIAGRAM)
wokwi = format_wokwi_diagram(circuit)
part_ids = [p["id"] for p in wokwi["parts"]]
assert "led1" in part_ids
assert "r1" in part_ids
def test_board_injected(self):
circuit = {
"board_fqbn": "arduino:avr:uno",
"components": [],
"connections": [],
"version": 1,
}
wokwi = format_wokwi_diagram(circuit)
types = [p["type"] for p in wokwi["parts"]]
assert "wokwi-arduino-uno" in types
def test_author_embedded(self):
circuit = parse_wokwi_diagram(SAMPLE_DIAGRAM)
wokwi = format_wokwi_diagram(circuit, author="alice")
assert wokwi["author"] == "alice"
def test_connections_formatted(self):
circuit = parse_wokwi_diagram(SAMPLE_DIAGRAM)
wokwi = format_wokwi_diagram(circuit)
assert len(wokwi["connections"]) == 3
# Each connection should be [from, to, color, []]
for conn in wokwi["connections"]:
assert isinstance(conn, list)
assert len(conn) == 4
class TestGenerateArduinoSketch:
def test_generates_setup_and_loop(self):
circuit = parse_wokwi_diagram(SAMPLE_DIAGRAM)
sketch = generate_arduino_sketch(circuit)
assert "void setup()" in sketch
assert "void loop()" in sketch
def test_contains_serial_begin(self):
circuit = parse_wokwi_diagram(SAMPLE_DIAGRAM)
sketch = generate_arduino_sketch(circuit)
assert "Serial.begin" in sketch
# ---------------------------------------------------------------------------
# MCP tool function tests
# ---------------------------------------------------------------------------
from app.mcp.server import (
compile_project,
create_circuit,
export_wokwi_json,
generate_code_files,
import_wokwi_json,
run_project,
update_circuit,
)
class TestImportWokwiJson:
@pytest.mark.asyncio
async def test_valid_diagram(self):
result = await import_wokwi_json(json.dumps(SAMPLE_DIAGRAM))
assert "board_fqbn" in result
assert "components" in result
assert "connections" in result
@pytest.mark.asyncio
async def test_invalid_json(self):
result = await import_wokwi_json("not-json{")
assert "error" in result
@pytest.mark.asyncio
async def test_not_object(self):
result = await import_wokwi_json("[1, 2, 3]")
assert "error" in result
class TestExportWokwiJson:
@pytest.mark.asyncio
async def test_valid_circuit(self):
circuit = parse_wokwi_diagram(SAMPLE_DIAGRAM)
result = await export_wokwi_json(circuit)
assert "parts" in result
assert "connections" in result
assert result["editor"] == "velxio"
@pytest.mark.asyncio
async def test_invalid_input(self):
result = await export_wokwi_json("not a dict") # type: ignore[arg-type]
assert "error" in result
class TestCreateCircuit:
@pytest.mark.asyncio
async def test_defaults(self):
result = await create_circuit()
assert result["board_fqbn"] == "arduino:avr:uno"
assert result["components"] == []
assert result["connections"] == []
@pytest.mark.asyncio
async def test_with_components(self):
result = await create_circuit(
board_fqbn="arduino:avr:uno",
components=[{"id": "led1", "type": "wokwi-led", "left": 100, "top": 50}],
connections=[
{
"from_part": "uno",
"from_pin": "13",
"to_part": "led1",
"to_pin": "A",
"color": "green",
}
],
)
assert len(result["components"]) == 1
assert result["components"][0]["id"] == "led1"
assert len(result["connections"]) == 1
class TestUpdateCircuit:
@pytest.mark.asyncio
async def test_add_component(self):
circuit = await create_circuit(
components=[{"id": "led1", "type": "wokwi-led"}]
)
updated = await update_circuit(
circuit=circuit,
add_components=[{"id": "btn1", "type": "wokwi-pushbutton"}],
)
ids = [c["id"] for c in updated["components"]]
assert "led1" in ids
assert "btn1" in ids
@pytest.mark.asyncio
async def test_remove_component(self):
circuit = await create_circuit(
components=[
{"id": "led1", "type": "wokwi-led"},
{"id": "r1", "type": "wokwi-resistor"},
]
)
updated = await update_circuit(
circuit=circuit,
remove_component_ids=["r1"],
)
ids = [c["id"] for c in updated["components"]]
assert "led1" in ids
assert "r1" not in ids
@pytest.mark.asyncio
async def test_change_board(self):
circuit = await create_circuit()
updated = await update_circuit(
circuit=circuit,
board_fqbn="rp2040:rp2040:rpipico",
)
assert updated["board_fqbn"] == "rp2040:rp2040:rpipico"
@pytest.mark.asyncio
async def test_add_and_remove_connections(self):
circuit = await create_circuit(
connections=[
{
"from_part": "uno",
"from_pin": "13",
"to_part": "led1",
"to_pin": "A",
}
]
)
updated = await update_circuit(
circuit=circuit,
add_connections=[
{
"from_part": "uno",
"from_pin": "12",
"to_part": "led2",
"to_pin": "A",
}
],
remove_connections=[
{
"from_part": "uno",
"from_pin": "13",
"to_part": "led1",
"to_pin": "A",
}
],
)
assert len(updated["connections"]) == 1
assert updated["connections"][0]["from_pin"] == "12"
@pytest.mark.asyncio
async def test_invalid_circuit(self):
result = await update_circuit(circuit="bad") # type: ignore[arg-type]
assert "error" in result
class TestGenerateCodeFiles:
@pytest.mark.asyncio
async def test_generates_ino_file(self):
circuit = await create_circuit()
result = await generate_code_files(circuit=circuit)
assert len(result["files"]) == 1
assert result["files"][0]["name"] == "sketch.ino"
@pytest.mark.asyncio
async def test_custom_sketch_name(self):
circuit = await create_circuit()
result = await generate_code_files(circuit=circuit, sketch_name="blink")
assert result["files"][0]["name"] == "blink.ino"
@pytest.mark.asyncio
async def test_extra_instructions(self):
circuit = await create_circuit()
result = await generate_code_files(
circuit=circuit,
extra_instructions="Generated by agent",
)
assert "Generated by agent" in result["files"][0]["content"]
@pytest.mark.asyncio
async def test_invalid_circuit(self):
result = await generate_code_files(circuit="bad") # type: ignore[arg-type]
assert "error" in result
class TestCompileProject:
@pytest.mark.asyncio
async def test_missing_keys(self):
result = await compile_project(files=[{"name": "sketch.ino"}])
assert result["success"] is False
assert "content" in result["error"]
@pytest.mark.asyncio
async def test_calls_arduino_cli(self):
mock_result = {
"success": True,
"hex_content": ":00000001FF",
"binary_content": None,
"binary_type": None,
"stdout": "",
"stderr": "",
}
with patch(
"app.mcp.server._arduino.compile",
new=AsyncMock(return_value=mock_result),
):
result = await compile_project(
files=[{"name": "sketch.ino", "content": "void setup(){} void loop(){}"}]
)
assert result["success"] is True
assert result["hex_content"] is not None
class TestRunProject:
@pytest.mark.asyncio
async def test_simulation_ready_flag_on_success(self):
mock_result = {
"success": True,
"hex_content": ":00000001FF",
"binary_content": None,
"binary_type": None,
"stdout": "",
"stderr": "",
}
with patch(
"app.mcp.server._arduino.compile",
new=AsyncMock(return_value=mock_result),
):
result = await run_project(
files=[{"name": "sketch.ino", "content": "void setup(){} void loop(){}"}]
)
assert result["simulation_ready"] is True
@pytest.mark.asyncio
async def test_simulation_ready_flag_on_failure(self):
mock_result = {
"success": False,
"hex_content": None,
"binary_content": None,
"binary_type": None,
"stdout": "",
"stderr": "error",
"error": "Compilation failed",
}
with patch(
"app.mcp.server._arduino.compile",
new=AsyncMock(return_value=mock_result),
):
result = await run_project(
files=[{"name": "sketch.ino", "content": "bad code{{{"}]
)
assert result["simulation_ready"] is False