Files
freecad-robust-mcp-fc111/tests/unit/test_resources.py
T
Sean P. KaneandGitHub f14ab8177d Fix: rename workbench and prepare for release (#27)
* 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
2026-01-12 09:30:05 -08:00

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"
)