417 lines
13 KiB
Python
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
|