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