* chore: rename workbench to FreecadRobustMCPBridge * fix: stdio cleanup and bug fix for JSON RPC * fix: update FreeCAD MCP bridge and tests * test: Fix GUI Integration tests and other issues * fix: unit tests in CI and a few other things * docs: generate GitHub pages site and link to it. * fix: General code improvements and DRY refactoring * chore: General cleanup * chore: small fixes * docs: cleanup * docs: fixes * docs: small corrections * docs: fix * test: release check * bump: workbench v0.6.0 * chore: bump macros to 0.6.0 * docs: Release improvements * refactor: improve DRYness of code
480 lines
17 KiB
Python
480 lines
17 KiB
Python
"""Tests for FreeCAD Robust MCP resources."""
|
|
|
|
import json
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from freecad_mcp.bridge.base import (
|
|
ConnectionStatus,
|
|
DocumentInfo,
|
|
MacroInfo,
|
|
ObjectInfo,
|
|
WorkbenchInfo,
|
|
)
|
|
|
|
|
|
class TestFreecadResources:
|
|
"""Tests for FreeCAD Robust MCP resources."""
|
|
|
|
@pytest.fixture
|
|
def mock_mcp(self) -> MagicMock:
|
|
"""Create a mock MCP server that captures resource registrations."""
|
|
mcp = MagicMock()
|
|
mcp._registered_resources = {}
|
|
|
|
def resource_decorator(
|
|
uri: str,
|
|
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
def wrapper(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
mcp._registered_resources[uri] = func
|
|
return func
|
|
|
|
return wrapper
|
|
|
|
mcp.resource = resource_decorator
|
|
return mcp
|
|
|
|
@pytest.fixture
|
|
def mock_bridge(self) -> AsyncMock:
|
|
"""Create a mock FreeCAD bridge."""
|
|
return AsyncMock()
|
|
|
|
@pytest.fixture
|
|
def register_resources(
|
|
self, mock_mcp: MagicMock, mock_bridge: AsyncMock
|
|
) -> dict[str, Callable[..., Any]]:
|
|
"""Register resources and return the registered functions."""
|
|
from freecad_mcp.resources.freecad import register_resources
|
|
|
|
async def get_bridge() -> AsyncMock:
|
|
return mock_bridge
|
|
|
|
register_resources(mock_mcp, get_bridge)
|
|
return mock_mcp._registered_resources
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_version(
|
|
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
|
) -> None:
|
|
"""freecad://version should return version info."""
|
|
mock_bridge.get_freecad_version = AsyncMock(
|
|
return_value={
|
|
"version": "1.0.0",
|
|
"build_date": "2024-01-15",
|
|
"python_version": "3.11.6",
|
|
"gui_available": True,
|
|
}
|
|
)
|
|
|
|
resource_version = register_resources["freecad://version"]
|
|
result = await resource_version()
|
|
data = json.loads(result)
|
|
|
|
assert data["version"] == "1.0.0"
|
|
assert data["gui_available"] is True
|
|
mock_bridge.get_freecad_version.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_status_connected(
|
|
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
|
) -> None:
|
|
"""freecad://status should return connected status."""
|
|
mock_bridge.get_status = AsyncMock(
|
|
return_value=ConnectionStatus(
|
|
connected=True,
|
|
mode="xmlrpc",
|
|
freecad_version="1.0.0",
|
|
gui_available=True,
|
|
last_ping_ms=5.5,
|
|
error=None,
|
|
)
|
|
)
|
|
|
|
resource_status = register_resources["freecad://status"]
|
|
result = await resource_status()
|
|
data = json.loads(result)
|
|
|
|
assert data["connected"] is True
|
|
assert data["mode"] == "xmlrpc"
|
|
assert data["last_ping_ms"] == 5.5
|
|
assert data["error"] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_status_disconnected(
|
|
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
|
) -> None:
|
|
"""freecad://status should return error when disconnected."""
|
|
mock_bridge.get_status = AsyncMock(
|
|
return_value=ConnectionStatus(
|
|
connected=False,
|
|
mode="xmlrpc",
|
|
error="Connection refused",
|
|
)
|
|
)
|
|
|
|
resource_status = register_resources["freecad://status"]
|
|
result = await resource_status()
|
|
data = json.loads(result)
|
|
|
|
assert data["connected"] is False
|
|
assert data["error"] == "Connection refused"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_documents_empty(
|
|
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
|
) -> None:
|
|
"""freecad://documents should return empty list when no documents."""
|
|
mock_bridge.get_documents = AsyncMock(return_value=[])
|
|
|
|
resource_documents = register_resources["freecad://documents"]
|
|
result = await resource_documents()
|
|
data = json.loads(result)
|
|
|
|
assert data == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_documents_with_docs(
|
|
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
|
) -> None:
|
|
"""freecad://documents should return document list."""
|
|
mock_docs = [
|
|
DocumentInfo(
|
|
name="Doc1",
|
|
label="Document 1",
|
|
path="/tmp/doc1.FCStd",
|
|
objects=["Box", "Cylinder"],
|
|
is_modified=False,
|
|
active_object="Box",
|
|
),
|
|
DocumentInfo(
|
|
name="Doc2",
|
|
label="Document 2",
|
|
path=None,
|
|
objects=["Sphere"],
|
|
is_modified=True,
|
|
active_object=None,
|
|
),
|
|
]
|
|
mock_bridge.get_documents = AsyncMock(return_value=mock_docs)
|
|
|
|
resource_documents = register_resources["freecad://documents"]
|
|
result = await resource_documents()
|
|
data = json.loads(result)
|
|
|
|
assert len(data) == 2
|
|
assert data[0]["name"] == "Doc1"
|
|
assert data[0]["object_count"] == 2
|
|
assert data[1]["name"] == "Doc2"
|
|
assert data[1]["is_modified"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_document_found(
|
|
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
|
) -> None:
|
|
"""freecad://documents/{name} should return document info."""
|
|
mock_docs = [
|
|
DocumentInfo(
|
|
name="TestDoc",
|
|
label="Test Document",
|
|
path="/tmp/test.FCStd",
|
|
objects=["Part1", "Part2"],
|
|
is_modified=False,
|
|
active_object="Part1",
|
|
),
|
|
]
|
|
mock_bridge.get_documents = AsyncMock(return_value=mock_docs)
|
|
|
|
resource_document = register_resources["freecad://documents/{name}"]
|
|
result = await resource_document(name="TestDoc")
|
|
data = json.loads(result)
|
|
|
|
assert data["name"] == "TestDoc"
|
|
assert data["objects"] == ["Part1", "Part2"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_document_not_found(
|
|
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
|
) -> None:
|
|
"""freecad://documents/{name} should return error when not found."""
|
|
mock_bridge.get_documents = AsyncMock(return_value=[])
|
|
|
|
resource_document = register_resources["freecad://documents/{name}"]
|
|
result = await resource_document(name="NonExistent")
|
|
data = json.loads(result)
|
|
|
|
assert "error" in data
|
|
assert "not found" in data["error"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_document_objects(
|
|
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
|
) -> None:
|
|
"""freecad://documents/{name}/objects should return object list."""
|
|
mock_objects = [
|
|
ObjectInfo(
|
|
name="Box",
|
|
label="My Box",
|
|
type_id="Part::Box",
|
|
visibility=True,
|
|
children=[],
|
|
parents=[],
|
|
),
|
|
ObjectInfo(
|
|
name="Cylinder",
|
|
label="My Cylinder",
|
|
type_id="Part::Cylinder",
|
|
visibility=False,
|
|
children=[],
|
|
parents=[],
|
|
),
|
|
]
|
|
mock_bridge.get_objects = AsyncMock(return_value=mock_objects)
|
|
|
|
resource_objects = register_resources["freecad://documents/{name}/objects"]
|
|
result = await resource_objects(name="TestDoc")
|
|
data = json.loads(result)
|
|
|
|
assert len(data) == 2
|
|
assert data[0]["name"] == "Box"
|
|
assert data[0]["type_id"] == "Part::Box"
|
|
assert data[1]["visibility"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_object_details(
|
|
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
|
) -> None:
|
|
"""freecad://objects/{doc_name}/{obj_name} should return object details."""
|
|
mock_object = ObjectInfo(
|
|
name="Box",
|
|
label="My Box",
|
|
type_id="Part::Box",
|
|
properties={"Length": 10.0, "Width": 20.0, "Height": 30.0},
|
|
shape_info={
|
|
"shape_type": "Solid",
|
|
"volume": 6000.0,
|
|
"area": 2200.0,
|
|
"is_valid": True,
|
|
},
|
|
visibility=True,
|
|
children=[],
|
|
parents=[],
|
|
)
|
|
mock_bridge.get_object = AsyncMock(return_value=mock_object)
|
|
|
|
resource_object = register_resources["freecad://objects/{doc_name}/{obj_name}"]
|
|
result = await resource_object(doc_name="TestDoc", obj_name="Box")
|
|
data = json.loads(result)
|
|
|
|
assert data["name"] == "Box"
|
|
assert data["type_id"] == "Part::Box"
|
|
assert data["properties"]["Length"] == 10.0
|
|
assert data["shape_info"]["volume"] == 6000.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_active_document(
|
|
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
|
) -> None:
|
|
"""freecad://active-document should return active document."""
|
|
mock_doc = DocumentInfo(
|
|
name="ActiveDoc",
|
|
label="Active Document",
|
|
path="/tmp/active.FCStd",
|
|
objects=["Part1"],
|
|
is_modified=True,
|
|
active_object="Part1",
|
|
)
|
|
mock_bridge.get_active_document = AsyncMock(return_value=mock_doc)
|
|
|
|
resource_active = register_resources["freecad://active-document"]
|
|
result = await resource_active()
|
|
data = json.loads(result)
|
|
|
|
assert data["name"] == "ActiveDoc"
|
|
assert data["is_modified"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_active_document_none(
|
|
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
|
) -> None:
|
|
"""freecad://active-document should return null when no active document."""
|
|
mock_bridge.get_active_document = AsyncMock(return_value=None)
|
|
|
|
resource_active = register_resources["freecad://active-document"]
|
|
result = await resource_active()
|
|
data = json.loads(result)
|
|
|
|
# Implementation returns json.dumps(None) which deserializes to Python None
|
|
assert data is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_workbenches(
|
|
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
|
) -> None:
|
|
"""freecad://workbenches should return workbench list."""
|
|
mock_workbenches = [
|
|
WorkbenchInfo(
|
|
name="PartDesignWorkbench",
|
|
label="Part Design",
|
|
icon="",
|
|
is_active=True,
|
|
),
|
|
WorkbenchInfo(
|
|
name="SketcherWorkbench",
|
|
label="Sketcher",
|
|
icon="",
|
|
is_active=False,
|
|
),
|
|
]
|
|
mock_bridge.get_workbenches = AsyncMock(return_value=mock_workbenches)
|
|
|
|
resource_workbenches = register_resources["freecad://workbenches"]
|
|
result = await resource_workbenches()
|
|
data = json.loads(result)
|
|
|
|
assert len(data) == 2
|
|
assert data[0]["name"] == "PartDesignWorkbench"
|
|
assert data[0]["is_active"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_active_workbench(
|
|
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
|
) -> None:
|
|
"""freecad://workbenches/active should return active workbench."""
|
|
mock_workbenches = [
|
|
WorkbenchInfo(
|
|
name="PartDesignWorkbench",
|
|
label="Part Design",
|
|
icon="",
|
|
is_active=True,
|
|
),
|
|
WorkbenchInfo(
|
|
name="SketcherWorkbench",
|
|
label="Sketcher",
|
|
icon="",
|
|
is_active=False,
|
|
),
|
|
]
|
|
mock_bridge.get_workbenches = AsyncMock(return_value=mock_workbenches)
|
|
|
|
resource_active_wb = register_resources["freecad://workbenches/active"]
|
|
result = await resource_active_wb()
|
|
data = json.loads(result)
|
|
|
|
assert data["name"] == "PartDesignWorkbench"
|
|
assert data["label"] == "Part Design"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_macros(
|
|
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
|
) -> None:
|
|
"""freecad://macros should return macro list."""
|
|
mock_macros = [
|
|
MacroInfo(
|
|
name="MultiExport",
|
|
path="/home/user/.local/share/FreeCAD/Macro/MultiExport.FCMacro",
|
|
description="Export to multiple formats",
|
|
is_system=False,
|
|
),
|
|
MacroInfo(
|
|
name="SystemMacro",
|
|
path="/usr/share/freecad/Macro/SystemMacro.FCMacro",
|
|
description="System macro",
|
|
is_system=True,
|
|
),
|
|
]
|
|
mock_bridge.get_macros = AsyncMock(return_value=mock_macros)
|
|
|
|
resource_macros = register_resources["freecad://macros"]
|
|
result = await resource_macros()
|
|
data = json.loads(result)
|
|
|
|
assert len(data) == 2
|
|
assert data[0]["name"] == "MultiExport"
|
|
assert data[0]["is_system"] is False
|
|
assert data[1]["is_system"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_console(
|
|
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
|
) -> None:
|
|
"""freecad://console should return console output."""
|
|
mock_bridge.get_console_output = AsyncMock(
|
|
return_value=[
|
|
"FreeCAD started",
|
|
"Document created",
|
|
"Box created",
|
|
]
|
|
)
|
|
|
|
resource_console = register_resources["freecad://console"]
|
|
result = await resource_console()
|
|
data = json.loads(result)
|
|
|
|
assert "lines" in data
|
|
assert len(data["lines"]) == 3
|
|
assert data["count"] == 3
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_capabilities(
|
|
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
|
) -> None:
|
|
"""freecad://capabilities should return server capabilities."""
|
|
resource_capabilities = register_resources["freecad://capabilities"]
|
|
result = await resource_capabilities()
|
|
data = json.loads(result)
|
|
|
|
# Should have tools section
|
|
assert "tools" in data
|
|
assert "execution" in data["tools"]
|
|
assert "documents" in data["tools"]
|
|
|
|
# Should have resources section - list of dicts with uri/description
|
|
assert "resources" in data
|
|
assert any("capabilities" in r.get("uri", "") for r in data["resources"])
|
|
|
|
# Should have prompts section
|
|
assert "prompts" in data
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_capabilities_includes_all_resources(
|
|
self, register_resources: dict[str, Callable[..., Any]], mock_bridge: AsyncMock
|
|
) -> None:
|
|
"""freecad://capabilities should include all registered resources.
|
|
|
|
This test ensures the capabilities resource stays in sync when new
|
|
resources are added. Per CLAUDE.md: "When adding new MCP tools or
|
|
resources, you MUST also update the freecad://capabilities resource."
|
|
"""
|
|
resource_capabilities = register_resources["freecad://capabilities"]
|
|
result = await resource_capabilities()
|
|
data = json.loads(result)
|
|
|
|
# Get all registered resource URIs (excluding capabilities itself)
|
|
registered_uris = {
|
|
uri for uri in register_resources if uri != "freecad://capabilities"
|
|
}
|
|
|
|
# Get URIs listed in the capabilities response (filter out None values)
|
|
capability_uris = {
|
|
r.get("uri") for r in data.get("resources", []) if r.get("uri") is not None
|
|
}
|
|
|
|
# All registered resources should be listed in capabilities
|
|
missing_resources = registered_uris - capability_uris
|
|
assert not missing_resources, (
|
|
f"Resources registered but not in capabilities: {missing_resources}. "
|
|
f"Update resource_capabilities() in src/freecad_mcp/resources/freecad.py"
|
|
)
|
|
|
|
# Reverse check: capabilities should not list stale/phantom resources
|
|
# that are no longer registered (excluding capabilities itself)
|
|
stale_resources = capability_uris - registered_uris - {"freecad://capabilities"}
|
|
assert not stale_resources, (
|
|
f"Stale resources in capabilities (not registered): {stale_resources}. "
|
|
f"Remove these from resource_capabilities() in "
|
|
f"src/freecad_mcp/resources/freecad.py"
|
|
)
|