feat: Initial commit of FreeCAD MCP/tooling proj.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Test suite for FreeCAD MCP Server."""
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Pytest configuration and shared fixtures."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_python_code():
|
||||
"""Sample Python code for execution tests."""
|
||||
return """
|
||||
x = 1 + 1
|
||||
_result_ = {"value": x, "type": type(x).__name__}
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_freecad_code():
|
||||
"""Sample FreeCAD Python code."""
|
||||
return """
|
||||
import Part
|
||||
box = Part.makeBox(10, 20, 30)
|
||||
_result_ = {
|
||||
"volume": box.Volume,
|
||||
"area": box.Area,
|
||||
"is_valid": box.isValid(),
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_execution_result():
|
||||
"""Mock execution result for testing."""
|
||||
from freecad_mcp.bridge.base import ExecutionResult
|
||||
|
||||
return ExecutionResult(
|
||||
success=True,
|
||||
result={"value": 42},
|
||||
stdout="",
|
||||
stderr="",
|
||||
execution_time_ms=10.5,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_document_info():
|
||||
"""Mock document info for testing."""
|
||||
from freecad_mcp.bridge.base import DocumentInfo
|
||||
|
||||
return DocumentInfo(
|
||||
name="TestDoc",
|
||||
path="/tmp/test.FCStd",
|
||||
objects=["Box", "Cylinder"],
|
||||
is_modified=False,
|
||||
label="Test Document",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_object_info():
|
||||
"""Mock object info for testing."""
|
||||
from freecad_mcp.bridge.base import ObjectInfo
|
||||
|
||||
return ObjectInfo(
|
||||
name="Box",
|
||||
label="My Box",
|
||||
type_id="Part::Box",
|
||||
properties={"Length": 10.0, "Width": 20.0, "Height": 30.0},
|
||||
shape_info={
|
||||
"type": "Solid",
|
||||
"volume": 6000.0,
|
||||
"area": 2200.0,
|
||||
"is_valid": True,
|
||||
},
|
||||
children=[],
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Integration tests requiring FreeCAD installation."""
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Pytest configuration for integration tests.
|
||||
|
||||
This module handles connection checking and provides consolidated skip behavior
|
||||
when the FreeCAD MCP bridge is not available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
import xmlrpc.client
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
# Global flag to track bridge availability (checked once per session)
|
||||
_bridge_available: bool | None = None
|
||||
_bridge_error: str | None = None
|
||||
_warning_emitted: bool = False
|
||||
|
||||
|
||||
def _check_bridge_connection() -> tuple[bool, str | None]:
|
||||
"""Check if the FreeCAD MCP bridge is available.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_available, error_message)
|
||||
"""
|
||||
global _bridge_available, _bridge_error
|
||||
|
||||
if _bridge_available is not None:
|
||||
return _bridge_available, _bridge_error
|
||||
|
||||
try:
|
||||
proxy = xmlrpc.client.ServerProxy("http://localhost:9875", allow_none=True)
|
||||
result: dict[str, Any] = proxy.ping() # type: ignore[assignment]
|
||||
if result.get("pong"):
|
||||
_bridge_available = True
|
||||
_bridge_error = None
|
||||
else:
|
||||
_bridge_available = False
|
||||
_bridge_error = "FreeCAD MCP bridge not responding to ping"
|
||||
except ConnectionRefusedError:
|
||||
_bridge_available = False
|
||||
_bridge_error = "Connection refused - FreeCAD MCP bridge not running"
|
||||
except Exception as e:
|
||||
_bridge_available = False
|
||||
_bridge_error = f"Cannot connect to FreeCAD MCP bridge: {e}"
|
||||
|
||||
return _bridge_available, _bridge_error
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(
|
||||
_config: pytest.Config, items: list[pytest.Item]
|
||||
) -> None:
|
||||
"""Skip all integration tests if the bridge is not available.
|
||||
|
||||
This runs once during test collection and emits a single warning instead of
|
||||
per-test skip messages.
|
||||
"""
|
||||
global _warning_emitted
|
||||
|
||||
# Filter to only integration tests in this directory
|
||||
integration_tests = [
|
||||
item for item in items if "tests/integration" in str(item.fspath)
|
||||
]
|
||||
|
||||
if not integration_tests:
|
||||
return
|
||||
|
||||
# Check bridge connection once
|
||||
is_available, error = _check_bridge_connection()
|
||||
|
||||
if not is_available:
|
||||
# Apply skip marker to all integration tests
|
||||
skip_marker = pytest.mark.skip(reason="FreeCAD MCP bridge unavailable")
|
||||
for item in integration_tests:
|
||||
item.add_marker(skip_marker)
|
||||
|
||||
# Emit a single warning (only once)
|
||||
if not _warning_emitted:
|
||||
_warning_emitted = True
|
||||
warnings.warn(
|
||||
f"Skipping {len(integration_tests)} integration tests: {error}. "
|
||||
f"Start the bridge with 'just run-gui' or 'just run-headless'.",
|
||||
pytest.PytestWarning,
|
||||
stacklevel=1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def xmlrpc_proxy() -> xmlrpc.client.ServerProxy:
|
||||
"""Create XML-RPC proxy to FreeCAD MCP bridge.
|
||||
|
||||
This fixture is shared across all integration test modules.
|
||||
The connection check has already been performed during collection.
|
||||
"""
|
||||
is_available, error = _check_bridge_connection()
|
||||
if not is_available:
|
||||
pytest.skip(error or "FreeCAD MCP bridge not available")
|
||||
|
||||
return xmlrpc.client.ServerProxy("http://localhost:9875", allow_none=True)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,579 @@
|
||||
"""Integration tests for FreeCAD MCP GUI mode.
|
||||
|
||||
These tests verify that the MCP bridge works correctly when FreeCAD is running
|
||||
in GUI mode (with full graphical interface). They test GUI-specific features
|
||||
like visibility, display modes, colors, and camera operations.
|
||||
|
||||
Note: These tests require a running FreeCAD GUI server.
|
||||
Start it with: just run-gui
|
||||
|
||||
To run these tests:
|
||||
pytest tests/integration/test_gui_mode.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import xmlrpc.client
|
||||
from collections.abc import Generator
|
||||
|
||||
# Mark all tests in this module as integration tests and gui tests
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.gui]
|
||||
|
||||
# Note: xmlrpc_proxy fixture is defined in conftest.py
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def temp_dir() -> Generator[str, None, None]:
|
||||
"""Create a temporary directory for test files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield tmpdir
|
||||
|
||||
|
||||
def execute_code(proxy: xmlrpc.client.ServerProxy, code: str) -> dict[str, Any]:
|
||||
"""Execute Python code via the MCP bridge and return the result."""
|
||||
result: dict[str, Any] = proxy.execute(code) # type: ignore[assignment]
|
||||
assert result.get("success"), f"Execution failed: {result.get('error_traceback')}"
|
||||
return result
|
||||
|
||||
|
||||
class TestGUIConnection:
|
||||
"""Tests for GUI mode connectivity and detection."""
|
||||
|
||||
def test_ping(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test that the server responds to ping."""
|
||||
result: dict[str, Any] = xmlrpc_proxy.ping() # type: ignore[assignment]
|
||||
assert result["pong"] is True
|
||||
assert "timestamp" in result
|
||||
|
||||
def test_gui_mode_detected(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test that FreeCAD is running in GUI mode (GuiUp=True)."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
_result_ = {"gui_up": FreeCAD.GuiUp}
|
||||
""",
|
||||
)
|
||||
# In GUI mode, GuiUp should be True (1)
|
||||
assert result["result"]["gui_up"] == 1 or result["result"]["gui_up"] is True
|
||||
|
||||
|
||||
class TestGUIObjectCreation:
|
||||
"""Tests for object creation in GUI mode."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Create a fresh document for each test."""
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
# Close any existing GUITestDoc
|
||||
if "GUITestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("GUITestDoc")
|
||||
doc = FreeCAD.newDocument("GUITestDoc")
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
def test_create_box_with_view_object(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
|
||||
) -> None:
|
||||
"""Test creating a box with ViewObject in GUI mode."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
box = Part.makeBox(10, 20, 30)
|
||||
obj = doc.addObject("Part::Feature", "GUIBox")
|
||||
obj.Shape = box
|
||||
doc.recompute()
|
||||
|
||||
has_view_object = hasattr(obj, "ViewObject") and obj.ViewObject is not None
|
||||
|
||||
_result_ = {
|
||||
"name": obj.Name,
|
||||
"volume": obj.Shape.Volume,
|
||||
"has_view_object": has_view_object,
|
||||
"valid": obj.Shape.isValid()
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["name"] == "GUIBox"
|
||||
assert abs(result["result"]["volume"] - 6000.0) < 0.01
|
||||
assert result["result"]["has_view_object"] is True
|
||||
assert result["result"]["valid"] is True
|
||||
|
||||
|
||||
class TestVisibility:
|
||||
"""Tests for object visibility operations in GUI mode."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Create a document with test objects."""
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
if "VisibilityTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("VisibilityTestDoc")
|
||||
doc = FreeCAD.newDocument("VisibilityTestDoc")
|
||||
|
||||
box = Part.makeBox(10, 10, 10)
|
||||
obj = doc.addObject("Part::Feature", "VisBox")
|
||||
obj.Shape = box
|
||||
doc.recompute()
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
def test_hide_object(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test hiding an object."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
obj = doc.getObject("VisBox")
|
||||
|
||||
# Hide the object
|
||||
obj.ViewObject.Visibility = False
|
||||
|
||||
_result_ = {
|
||||
"visible": obj.ViewObject.Visibility
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["visible"] is False
|
||||
|
||||
def test_show_object(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test showing an object."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
obj = doc.getObject("VisBox")
|
||||
|
||||
# First hide, then show
|
||||
obj.ViewObject.Visibility = False
|
||||
obj.ViewObject.Visibility = True
|
||||
|
||||
_result_ = {
|
||||
"visible": obj.ViewObject.Visibility
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["visible"] is True
|
||||
|
||||
|
||||
class TestDisplayMode:
|
||||
"""Tests for display mode operations in GUI mode."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Create a document with test objects."""
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
if "DisplayModeTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("DisplayModeTestDoc")
|
||||
doc = FreeCAD.newDocument("DisplayModeTestDoc")
|
||||
|
||||
box = Part.makeBox(10, 10, 10)
|
||||
obj = doc.addObject("Part::Feature", "DisplayBox")
|
||||
obj.Shape = box
|
||||
doc.recompute()
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
def test_get_display_modes(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test getting available display modes."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
obj = doc.getObject("DisplayBox")
|
||||
|
||||
modes = obj.ViewObject.listDisplayModes()
|
||||
|
||||
_result_ = {
|
||||
"modes": list(modes),
|
||||
"current": obj.ViewObject.DisplayMode
|
||||
}
|
||||
""",
|
||||
)
|
||||
# Typical modes: "Flat Lines", "Shaded", "Wireframe", etc.
|
||||
assert len(result["result"]["modes"]) > 0
|
||||
assert result["result"]["current"] is not None
|
||||
|
||||
def test_set_display_mode(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test setting display mode."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
obj = doc.getObject("DisplayBox")
|
||||
|
||||
modes = obj.ViewObject.listDisplayModes()
|
||||
# Set to a different mode if available
|
||||
target_mode = "Wireframe" if "Wireframe" in modes else modes[0]
|
||||
obj.ViewObject.DisplayMode = target_mode
|
||||
|
||||
_result_ = {
|
||||
"mode_set": obj.ViewObject.DisplayMode,
|
||||
"target_mode": target_mode
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["mode_set"] == result["result"]["target_mode"]
|
||||
|
||||
|
||||
class TestObjectColor:
|
||||
"""Tests for object color operations in GUI mode."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Create a document with test objects."""
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
if "ColorTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("ColorTestDoc")
|
||||
doc = FreeCAD.newDocument("ColorTestDoc")
|
||||
|
||||
box = Part.makeBox(10, 10, 10)
|
||||
obj = doc.addObject("Part::Feature", "ColorBox")
|
||||
obj.Shape = box
|
||||
doc.recompute()
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
def test_set_shape_color(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test setting object shape color."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
obj = doc.getObject("ColorBox")
|
||||
|
||||
# Set color to red (RGB as tuple of floats 0-1)
|
||||
obj.ViewObject.ShapeColor = (1.0, 0.0, 0.0)
|
||||
|
||||
color = obj.ViewObject.ShapeColor
|
||||
|
||||
_result_ = {
|
||||
"r": color[0],
|
||||
"g": color[1],
|
||||
"b": color[2]
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["r"] == pytest.approx(1.0, rel=0.01)
|
||||
assert result["result"]["g"] == pytest.approx(0.0, abs=0.01)
|
||||
assert result["result"]["b"] == pytest.approx(0.0, abs=0.01)
|
||||
|
||||
def test_set_transparency(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test setting object transparency."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
obj = doc.getObject("ColorBox")
|
||||
|
||||
# Set 50% transparency
|
||||
obj.ViewObject.Transparency = 50
|
||||
|
||||
_result_ = {
|
||||
"transparency": obj.ViewObject.Transparency
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["transparency"] == 50
|
||||
|
||||
|
||||
class TestCameraOperations:
|
||||
"""Tests for camera/view operations in GUI mode."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Create a document with test objects."""
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
if "CameraTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("CameraTestDoc")
|
||||
doc = FreeCAD.newDocument("CameraTestDoc")
|
||||
|
||||
box = Part.makeBox(10, 10, 10)
|
||||
obj = doc.addObject("Part::Feature", "CameraBox")
|
||||
obj.Shape = box
|
||||
doc.recompute()
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
def test_fit_all(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test fit all view operation."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import FreeCADGui
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
view = FreeCADGui.ActiveDocument.ActiveView
|
||||
|
||||
# Fit all objects in view
|
||||
view.fitAll()
|
||||
|
||||
_result_ = {"success": True}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["success"] is True
|
||||
|
||||
def test_set_view_direction(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test setting view direction (front, top, etc.)."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import FreeCADGui
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
view = FreeCADGui.ActiveDocument.ActiveView
|
||||
|
||||
# Set to front view
|
||||
view.viewFront()
|
||||
|
||||
# Get camera orientation
|
||||
cam = view.getCameraOrientation()
|
||||
|
||||
_result_ = {
|
||||
"success": True,
|
||||
"camera_orientation": [cam.Q[0], cam.Q[1], cam.Q[2], cam.Q[3]]
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["success"] is True
|
||||
|
||||
|
||||
class TestScreenshot:
|
||||
"""Tests for screenshot functionality in GUI mode."""
|
||||
|
||||
def test_capture_screenshot(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
|
||||
) -> None:
|
||||
"""Test capturing a screenshot."""
|
||||
# First create a document with objects
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
if "ScreenshotTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("ScreenshotTestDoc")
|
||||
doc = FreeCAD.newDocument("ScreenshotTestDoc")
|
||||
|
||||
box = Part.makeBox(20, 20, 20)
|
||||
obj = doc.addObject("Part::Feature", "ScreenshotBox")
|
||||
obj.Shape = box
|
||||
obj.ViewObject.ShapeColor = (0.0, 0.5, 1.0) # Blue color
|
||||
doc.recompute()
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
screenshot_path = Path(temp_dir) / "test_screenshot.png"
|
||||
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
f"""
|
||||
import FreeCAD
|
||||
import FreeCADGui
|
||||
import os
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
view = FreeCADGui.ActiveDocument.ActiveView
|
||||
|
||||
# Fit objects in view
|
||||
view.fitAll()
|
||||
|
||||
# Save screenshot
|
||||
view.saveImage({screenshot_path!r}, 800, 600, "White")
|
||||
|
||||
_result_ = {{
|
||||
"saved": os.path.exists({screenshot_path!r}),
|
||||
"path": {screenshot_path!r}
|
||||
}}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["saved"] is True
|
||||
assert screenshot_path.exists()
|
||||
# Verify file has content
|
||||
assert screenshot_path.stat().st_size > 0
|
||||
|
||||
|
||||
class TestComplexGUIWorkflow:
|
||||
"""Tests for complex workflows that combine GUI and modeling features."""
|
||||
|
||||
def test_create_assembly_with_colors(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
|
||||
) -> None:
|
||||
"""Test creating multiple objects with different colors."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
if "AssemblyTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("AssemblyTestDoc")
|
||||
doc = FreeCAD.newDocument("AssemblyTestDoc")
|
||||
|
||||
# Create base plate (gray)
|
||||
base = Part.makeBox(100, 100, 5)
|
||||
base_obj = doc.addObject("Part::Feature", "BasePlate")
|
||||
base_obj.Shape = base
|
||||
base_obj.ViewObject.ShapeColor = (0.5, 0.5, 0.5)
|
||||
|
||||
# Create pillar 1 (red)
|
||||
pillar1 = Part.makeBox(10, 10, 50, FreeCAD.Vector(10, 10, 5))
|
||||
pillar1_obj = doc.addObject("Part::Feature", "Pillar1")
|
||||
pillar1_obj.Shape = pillar1
|
||||
pillar1_obj.ViewObject.ShapeColor = (1.0, 0.0, 0.0)
|
||||
|
||||
# Create pillar 2 (green)
|
||||
pillar2 = Part.makeBox(10, 10, 50, FreeCAD.Vector(80, 10, 5))
|
||||
pillar2_obj = doc.addObject("Part::Feature", "Pillar2")
|
||||
pillar2_obj.Shape = pillar2
|
||||
pillar2_obj.ViewObject.ShapeColor = (0.0, 1.0, 0.0)
|
||||
|
||||
# Create pillar 3 (blue)
|
||||
pillar3 = Part.makeBox(10, 10, 50, FreeCAD.Vector(80, 80, 5))
|
||||
pillar3_obj = doc.addObject("Part::Feature", "Pillar3")
|
||||
pillar3_obj.Shape = pillar3
|
||||
pillar3_obj.ViewObject.ShapeColor = (0.0, 0.0, 1.0)
|
||||
|
||||
# Create pillar 4 (yellow)
|
||||
pillar4 = Part.makeBox(10, 10, 50, FreeCAD.Vector(10, 80, 5))
|
||||
pillar4_obj = doc.addObject("Part::Feature", "Pillar4")
|
||||
pillar4_obj.Shape = pillar4
|
||||
pillar4_obj.ViewObject.ShapeColor = (1.0, 1.0, 0.0)
|
||||
|
||||
# Create top plate (semi-transparent gray)
|
||||
top = Part.makeBox(100, 100, 5, FreeCAD.Vector(0, 0, 55))
|
||||
top_obj = doc.addObject("Part::Feature", "TopPlate")
|
||||
top_obj.Shape = top
|
||||
top_obj.ViewObject.ShapeColor = (0.5, 0.5, 0.5)
|
||||
top_obj.ViewObject.Transparency = 30
|
||||
|
||||
doc.recompute()
|
||||
|
||||
_result_ = {
|
||||
"object_count": len(doc.Objects),
|
||||
"objects": [obj.Name for obj in doc.Objects],
|
||||
"all_visible": all(obj.ViewObject.Visibility for obj in doc.Objects)
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["object_count"] == 6
|
||||
assert "BasePlate" in result["result"]["objects"]
|
||||
assert "TopPlate" in result["result"]["objects"]
|
||||
assert result["result"]["all_visible"] is True
|
||||
|
||||
def test_export_with_screenshot(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
|
||||
) -> None:
|
||||
"""Test creating model, setting view, taking screenshot, and exporting."""
|
||||
step_path = Path(temp_dir) / "workflow_export.step"
|
||||
screenshot_path = Path(temp_dir) / "workflow_screenshot.png"
|
||||
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
f"""
|
||||
import FreeCAD
|
||||
import FreeCADGui
|
||||
import Part
|
||||
import os
|
||||
|
||||
if "WorkflowTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("WorkflowTestDoc")
|
||||
doc = FreeCAD.newDocument("WorkflowTestDoc")
|
||||
|
||||
# Create a simple bracket shape
|
||||
# Base
|
||||
base = Part.makeBox(50, 30, 5)
|
||||
# Vertical support
|
||||
vertical = Part.makeBox(5, 30, 40, FreeCAD.Vector(0, 0, 5))
|
||||
# Top flange with hole
|
||||
top_flange = Part.makeBox(20, 30, 5, FreeCAD.Vector(0, 0, 45))
|
||||
hole = Part.makeCylinder(4, 10, FreeCAD.Vector(10, 15, 40))
|
||||
|
||||
# Combine and cut
|
||||
bracket = base.fuse(vertical).fuse(top_flange).cut(hole)
|
||||
bracket_obj = doc.addObject("Part::Feature", "Bracket")
|
||||
bracket_obj.Shape = bracket
|
||||
bracket_obj.ViewObject.ShapeColor = (0.2, 0.4, 0.8)
|
||||
|
||||
doc.recompute()
|
||||
|
||||
# Set isometric view
|
||||
view = FreeCADGui.ActiveDocument.ActiveView
|
||||
view.viewIsometric()
|
||||
view.fitAll()
|
||||
|
||||
# Take screenshot
|
||||
view.saveImage({screenshot_path!r}, 800, 600, "White")
|
||||
|
||||
# Export to STEP
|
||||
bracket_obj.Shape.exportStep({step_path!r})
|
||||
|
||||
_result_ = {{
|
||||
"bracket_valid": bracket_obj.Shape.isValid(),
|
||||
"bracket_volume": bracket_obj.Shape.Volume,
|
||||
"screenshot_exists": os.path.exists({screenshot_path!r}),
|
||||
"step_exists": os.path.exists({step_path!r})
|
||||
}}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["bracket_valid"] is True
|
||||
assert result["result"]["bracket_volume"] > 0
|
||||
assert result["result"]["screenshot_exists"] is True
|
||||
assert result["result"]["step_exists"] is True
|
||||
assert screenshot_path.exists()
|
||||
assert step_path.exists()
|
||||
@@ -0,0 +1,624 @@
|
||||
"""Integration tests for FreeCAD MCP headless mode.
|
||||
|
||||
These tests verify that the MCP bridge works correctly when FreeCAD is running
|
||||
in headless mode (without GUI). They test object creation, manipulation, and
|
||||
export functionality.
|
||||
|
||||
Note: These tests require a running FreeCAD headless server.
|
||||
Start it with: just run-headless
|
||||
|
||||
To run these tests:
|
||||
pytest tests/integration/test_headless_mode.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import xmlrpc.client
|
||||
from collections.abc import Generator
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
# Note: xmlrpc_proxy fixture is defined in conftest.py
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def temp_dir() -> Generator[str, None, None]:
|
||||
"""Create a temporary directory for test files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield tmpdir
|
||||
|
||||
|
||||
def execute_code(proxy: xmlrpc.client.ServerProxy, code: str) -> dict[str, Any]:
|
||||
"""Execute Python code via the MCP bridge and return the result."""
|
||||
result: dict[str, Any] = proxy.execute(code) # type: ignore[assignment]
|
||||
assert result.get("success"), f"Execution failed: {result.get('error_traceback')}"
|
||||
return result
|
||||
|
||||
|
||||
class TestHeadlessConnection:
|
||||
"""Tests for basic headless mode connectivity."""
|
||||
|
||||
def test_ping(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test that the server responds to ping."""
|
||||
result: dict[str, Any] = xmlrpc_proxy.ping() # type: ignore[assignment]
|
||||
assert result["pong"] is True
|
||||
assert "timestamp" in result
|
||||
|
||||
def test_headless_mode_detected(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
|
||||
) -> None:
|
||||
"""Test that FreeCAD is running in headless mode (GuiUp=False)."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
_result_ = {"gui_up": FreeCAD.GuiUp}
|
||||
""",
|
||||
)
|
||||
# In headless mode, GuiUp should be False (0)
|
||||
assert result["result"]["gui_up"] == 0 or result["result"]["gui_up"] is False
|
||||
|
||||
|
||||
class TestDocumentManagement:
|
||||
"""Tests for document creation and management in headless mode."""
|
||||
|
||||
def test_create_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test creating a new document."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
doc = FreeCAD.newDocument("TestDoc")
|
||||
_result_ = {"name": doc.Name, "object_count": len(doc.Objects)}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["name"] == "TestDoc"
|
||||
assert result["result"]["object_count"] == 0
|
||||
|
||||
def test_list_documents(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test listing open documents."""
|
||||
# First create a document
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
if not FreeCAD.listDocuments():
|
||||
FreeCAD.newDocument("ListTestDoc")
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
docs = list(FreeCAD.listDocuments().keys())
|
||||
_result_ = {"documents": docs, "count": len(docs)}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["count"] >= 1
|
||||
|
||||
def test_close_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test closing a document."""
|
||||
# Create a document to close
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
doc = FreeCAD.newDocument("ToClose")
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
FreeCAD.closeDocument("ToClose")
|
||||
_result_ = {"closed": "ToClose" not in FreeCAD.listDocuments()}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["closed"] is True
|
||||
|
||||
|
||||
class TestPrimitiveCreation:
|
||||
"""Tests for creating primitive shapes in headless mode."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Create a fresh document for each test."""
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
# Close any existing PrimitiveTestDoc
|
||||
if "PrimitiveTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("PrimitiveTestDoc")
|
||||
doc = FreeCAD.newDocument("PrimitiveTestDoc")
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
def test_create_box(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test creating a Part::Box primitive."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
box = Part.makeBox(10, 20, 30)
|
||||
obj = doc.addObject("Part::Feature", "TestBox")
|
||||
obj.Shape = box
|
||||
doc.recompute()
|
||||
|
||||
_result_ = {
|
||||
"name": obj.Name,
|
||||
"volume": obj.Shape.Volume,
|
||||
"valid": obj.Shape.isValid()
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["name"] == "TestBox"
|
||||
# Volume should be 10 * 20 * 30 = 6000
|
||||
assert abs(result["result"]["volume"] - 6000.0) < 0.01
|
||||
assert result["result"]["valid"] is True
|
||||
|
||||
def test_create_cylinder(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test creating a Part::Cylinder primitive."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
import math
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
cylinder = Part.makeCylinder(5, 20)
|
||||
obj = doc.addObject("Part::Feature", "TestCylinder")
|
||||
obj.Shape = cylinder
|
||||
doc.recompute()
|
||||
|
||||
expected_volume = math.pi * 5**2 * 20
|
||||
|
||||
_result_ = {
|
||||
"name": obj.Name,
|
||||
"volume": obj.Shape.Volume,
|
||||
"expected_volume": expected_volume,
|
||||
"valid": obj.Shape.isValid()
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["name"] == "TestCylinder"
|
||||
# Volume should be π * r² * h = π * 25 * 20 ≈ 1570.8
|
||||
assert (
|
||||
abs(result["result"]["volume"] - result["result"]["expected_volume"]) < 0.1
|
||||
)
|
||||
assert result["result"]["valid"] is True
|
||||
|
||||
def test_create_sphere(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test creating a Part::Sphere primitive."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
import math
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
sphere = Part.makeSphere(10)
|
||||
obj = doc.addObject("Part::Feature", "TestSphere")
|
||||
obj.Shape = sphere
|
||||
doc.recompute()
|
||||
|
||||
expected_volume = (4/3) * math.pi * 10**3
|
||||
|
||||
_result_ = {
|
||||
"name": obj.Name,
|
||||
"volume": obj.Shape.Volume,
|
||||
"expected_volume": expected_volume,
|
||||
"valid": obj.Shape.isValid()
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["name"] == "TestSphere"
|
||||
# Volume should be (4/3) * π * r³ ≈ 4188.79
|
||||
assert (
|
||||
abs(result["result"]["volume"] - result["result"]["expected_volume"]) < 1.0
|
||||
)
|
||||
assert result["result"]["valid"] is True
|
||||
|
||||
def test_create_cone(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test creating a Part::Cone primitive."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
cone = Part.makeCone(10, 0, 20) # Base radius=10, top radius=0, height=20
|
||||
obj = doc.addObject("Part::Feature", "TestCone")
|
||||
obj.Shape = cone
|
||||
doc.recompute()
|
||||
|
||||
_result_ = {
|
||||
"name": obj.Name,
|
||||
"volume": obj.Shape.Volume,
|
||||
"valid": obj.Shape.isValid()
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["name"] == "TestCone"
|
||||
# Volume of a cone = (1/3) * π * r² * h ≈ 2094.4
|
||||
assert result["result"]["volume"] > 2000
|
||||
assert result["result"]["valid"] is True
|
||||
|
||||
def test_create_torus(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test creating a Part::Torus primitive."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
torus = Part.makeTorus(20, 5) # Major radius=20, minor radius=5
|
||||
obj = doc.addObject("Part::Feature", "TestTorus")
|
||||
obj.Shape = torus
|
||||
doc.recompute()
|
||||
|
||||
_result_ = {
|
||||
"name": obj.Name,
|
||||
"volume": obj.Shape.Volume,
|
||||
"valid": obj.Shape.isValid()
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["name"] == "TestTorus"
|
||||
assert result["result"]["volume"] > 0
|
||||
assert result["result"]["valid"] is True
|
||||
|
||||
|
||||
class TestBooleanOperations:
|
||||
"""Tests for boolean operations in headless mode."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Create a fresh document for each test."""
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
if "BooleanTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("BooleanTestDoc")
|
||||
doc = FreeCAD.newDocument("BooleanTestDoc")
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
def test_boolean_fuse(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test boolean fuse (union) operation."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
|
||||
# Create two overlapping boxes
|
||||
box1 = Part.makeBox(10, 10, 10)
|
||||
box2 = Part.makeBox(10, 10, 10, FreeCAD.Vector(5, 0, 0))
|
||||
|
||||
obj1 = doc.addObject("Part::Feature", "Box1")
|
||||
obj1.Shape = box1
|
||||
obj2 = doc.addObject("Part::Feature", "Box2")
|
||||
obj2.Shape = box2
|
||||
|
||||
# Fuse them
|
||||
fused = box1.fuse(box2)
|
||||
obj_fused = doc.addObject("Part::Feature", "Fused")
|
||||
obj_fused.Shape = fused
|
||||
doc.recompute()
|
||||
|
||||
# Fused volume should be less than 2000 (two boxes) due to overlap
|
||||
_result_ = {
|
||||
"fused_volume": obj_fused.Shape.Volume,
|
||||
"valid": obj_fused.Shape.isValid()
|
||||
}
|
||||
""",
|
||||
)
|
||||
# Two 10x10x10 boxes overlapping by 5mm = 2000 - 500 = 1500
|
||||
assert result["result"]["fused_volume"] == pytest.approx(1500.0, rel=0.01)
|
||||
assert result["result"]["valid"] is True
|
||||
|
||||
def test_boolean_cut(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Test boolean cut (subtract) operation."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
|
||||
# Create a box and a cylinder to cut from it
|
||||
box = Part.makeBox(20, 20, 20)
|
||||
cylinder = Part.makeCylinder(5, 30, FreeCAD.Vector(10, 10, -5))
|
||||
|
||||
# Cut cylinder from box
|
||||
cut = box.cut(cylinder)
|
||||
obj_cut = doc.addObject("Part::Feature", "Cut")
|
||||
obj_cut.Shape = cut
|
||||
doc.recompute()
|
||||
|
||||
box_volume = 20 * 20 * 20
|
||||
import math
|
||||
cylinder_volume_in_box = math.pi * 5**2 * 20 # Only 20mm of cylinder is in box
|
||||
|
||||
_result_ = {
|
||||
"cut_volume": obj_cut.Shape.Volume,
|
||||
"expected_volume": box_volume - cylinder_volume_in_box,
|
||||
"valid": obj_cut.Shape.isValid()
|
||||
}
|
||||
""",
|
||||
)
|
||||
# Volume should be box - cylinder intersection
|
||||
assert (
|
||||
abs(result["result"]["cut_volume"] - result["result"]["expected_volume"])
|
||||
< 10
|
||||
)
|
||||
assert result["result"]["valid"] is True
|
||||
|
||||
|
||||
class TestObjectManipulation:
|
||||
"""Tests for object manipulation in headless mode."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
||||
"""Create a fresh document with a test box."""
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
if "ManipTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("ManipTestDoc")
|
||||
doc = FreeCAD.newDocument("ManipTestDoc")
|
||||
|
||||
box = Part.makeBox(10, 10, 10)
|
||||
obj = doc.addObject("Part::Feature", "ManipBox")
|
||||
obj.Shape = box
|
||||
doc.recompute()
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
|
||||
def test_set_placement_position(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
|
||||
) -> None:
|
||||
"""Test setting object position."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
obj = doc.getObject("ManipBox")
|
||||
|
||||
# Move the object
|
||||
obj.Placement.Base = FreeCAD.Vector(100, 200, 300)
|
||||
|
||||
_result_ = {
|
||||
"x": obj.Placement.Base.x,
|
||||
"y": obj.Placement.Base.y,
|
||||
"z": obj.Placement.Base.z
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["x"] == 100.0
|
||||
assert result["result"]["y"] == 200.0
|
||||
assert result["result"]["z"] == 300.0
|
||||
|
||||
def test_set_placement_rotation(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
|
||||
) -> None:
|
||||
"""Test setting object rotation."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
obj = doc.getObject("ManipBox")
|
||||
|
||||
# Rotate the object 45 degrees around Z axis
|
||||
obj.Placement.Rotation = FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), 45)
|
||||
doc.recompute()
|
||||
|
||||
euler = obj.Placement.Rotation.toEuler()
|
||||
_result_ = {
|
||||
"rotation_z": euler[0], # Yaw
|
||||
"valid": obj.Shape.isValid()
|
||||
}
|
||||
""",
|
||||
)
|
||||
# Check rotation is approximately 45 degrees
|
||||
assert abs(result["result"]["rotation_z"] - 45.0) < 0.1
|
||||
assert result["result"]["valid"] is True
|
||||
|
||||
|
||||
class TestExportOperations:
|
||||
"""Tests for export functionality in headless mode."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_document(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
|
||||
) -> None:
|
||||
"""Create a document with objects for export tests."""
|
||||
execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
if "ExportTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("ExportTestDoc")
|
||||
doc = FreeCAD.newDocument("ExportTestDoc")
|
||||
|
||||
# Create a simple box
|
||||
box = Part.makeBox(10, 10, 10)
|
||||
obj = doc.addObject("Part::Feature", "ExportBox")
|
||||
obj.Shape = box
|
||||
doc.recompute()
|
||||
|
||||
_result_ = True
|
||||
""",
|
||||
)
|
||||
self.temp_dir = temp_dir
|
||||
|
||||
def test_export_step(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
|
||||
) -> None:
|
||||
"""Test exporting to STEP format."""
|
||||
step_path = Path(temp_dir) / "test_export.step"
|
||||
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
f"""
|
||||
import FreeCAD
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
obj = doc.getObject("ExportBox")
|
||||
obj.Shape.exportStep({step_path!r})
|
||||
|
||||
import os
|
||||
_result_ = {{"exported": os.path.exists({step_path!r})}}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["exported"] is True
|
||||
assert step_path.exists()
|
||||
|
||||
def test_save_fcstd(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
|
||||
) -> None:
|
||||
"""Test saving as FreeCAD native format."""
|
||||
fcstd_path = Path(temp_dir) / "test_save.FCStd"
|
||||
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
f"""
|
||||
import FreeCAD
|
||||
|
||||
doc = FreeCAD.ActiveDocument
|
||||
doc.saveAs({fcstd_path!r})
|
||||
|
||||
import os
|
||||
_result_ = {{
|
||||
"saved": os.path.exists({fcstd_path!r}),
|
||||
"path": {fcstd_path!r}
|
||||
}}
|
||||
""",
|
||||
)
|
||||
assert result["result"]["saved"] is True
|
||||
assert fcstd_path.exists()
|
||||
|
||||
|
||||
class TestGUIFeaturesInHeadless:
|
||||
"""Tests to verify GUI features fail gracefully in headless mode."""
|
||||
|
||||
def test_gui_features_not_available(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
|
||||
) -> None:
|
||||
"""Test that GUI-only features return appropriate errors."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
|
||||
gui_available = FreeCAD.GuiUp
|
||||
|
||||
if not gui_available:
|
||||
_result_ = {
|
||||
"success": False,
|
||||
"error": "GUI not available in headless mode",
|
||||
"gui_up": False
|
||||
}
|
||||
else:
|
||||
_result_ = {
|
||||
"success": True,
|
||||
"gui_up": True
|
||||
}
|
||||
""",
|
||||
)
|
||||
# In headless mode, GUI should not be available
|
||||
assert result["result"]["gui_up"] is False
|
||||
|
||||
|
||||
class TestComplexWorkflow:
|
||||
"""Tests for complex multi-step workflows in headless mode."""
|
||||
|
||||
def test_parametric_modeling_workflow(
|
||||
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
|
||||
) -> None:
|
||||
"""Test a complete parametric modeling workflow."""
|
||||
result = execute_code(
|
||||
xmlrpc_proxy,
|
||||
"""
|
||||
import FreeCAD
|
||||
import Part
|
||||
|
||||
# Create document
|
||||
if "WorkflowTestDoc" in FreeCAD.listDocuments():
|
||||
FreeCAD.closeDocument("WorkflowTestDoc")
|
||||
doc = FreeCAD.newDocument("WorkflowTestDoc")
|
||||
|
||||
# Step 1: Create base box
|
||||
base_box = Part.makeBox(50, 50, 10)
|
||||
base_obj = doc.addObject("Part::Feature", "Base")
|
||||
base_obj.Shape = base_box
|
||||
|
||||
# Step 2: Create cylinder to cut
|
||||
cylinder = Part.makeCylinder(5, 20, FreeCAD.Vector(25, 25, -5))
|
||||
|
||||
# Step 3: Cut hole in base
|
||||
result_shape = base_box.cut(cylinder)
|
||||
final_obj = doc.addObject("Part::Feature", "BaseWithHole")
|
||||
final_obj.Shape = result_shape
|
||||
|
||||
# Step 4: Add fillet (rounded edges) - using Part API
|
||||
# Select edges and apply fillet
|
||||
try:
|
||||
filleted = final_obj.Shape.makeFillet(2, final_obj.Shape.Edges[:4])
|
||||
fillet_obj = doc.addObject("Part::Feature", "Filleted")
|
||||
fillet_obj.Shape = filleted
|
||||
fillet_success = True
|
||||
except Exception:
|
||||
fillet_success = False
|
||||
|
||||
doc.recompute()
|
||||
|
||||
_result_ = {
|
||||
"objects": [obj.Name for obj in doc.Objects],
|
||||
"final_volume": final_obj.Shape.Volume,
|
||||
"final_valid": final_obj.Shape.isValid(),
|
||||
"fillet_success": fillet_success
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert "Base" in result["result"]["objects"]
|
||||
assert "BaseWithHole" in result["result"]["objects"]
|
||||
assert result["result"]["final_valid"] is True
|
||||
# Volume should be base - cylinder hole
|
||||
assert result["result"]["final_volume"] > 0
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests for FreeCAD MCP Server."""
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Tests for bridge base classes."""
|
||||
|
||||
import pytest
|
||||
|
||||
from freecad_mcp.bridge.base import (
|
||||
DocumentInfo,
|
||||
ExecutionResult,
|
||||
FreecadBridge,
|
||||
ObjectInfo,
|
||||
)
|
||||
|
||||
|
||||
class TestExecutionResult:
|
||||
"""Tests for ExecutionResult dataclass."""
|
||||
|
||||
def test_successful_result(self):
|
||||
"""Successful execution result should have correct attributes."""
|
||||
result = ExecutionResult(
|
||||
success=True,
|
||||
result={"value": 42},
|
||||
stdout="output",
|
||||
stderr="",
|
||||
execution_time_ms=10.5,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.result == {"value": 42}
|
||||
assert result.stdout == "output"
|
||||
assert result.stderr == ""
|
||||
assert result.execution_time_ms == 10.5
|
||||
assert result.error_type is None
|
||||
assert result.error_traceback is None
|
||||
|
||||
def test_failed_result(self):
|
||||
"""Failed execution result should include error details."""
|
||||
result = ExecutionResult(
|
||||
success=False,
|
||||
result=None,
|
||||
stdout="",
|
||||
stderr="error occurred",
|
||||
execution_time_ms=5.0,
|
||||
error_type="ValueError",
|
||||
error_traceback="Traceback...",
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.result is None
|
||||
assert result.error_type == "ValueError"
|
||||
assert result.error_traceback == "Traceback..."
|
||||
|
||||
|
||||
class TestDocumentInfo:
|
||||
"""Tests for DocumentInfo dataclass."""
|
||||
|
||||
def test_document_with_all_fields(self):
|
||||
"""DocumentInfo should store all fields correctly."""
|
||||
doc = DocumentInfo(
|
||||
name="TestDoc",
|
||||
path="/path/to/doc.FCStd",
|
||||
objects=["Box", "Cylinder"],
|
||||
is_modified=True,
|
||||
label="Test Document",
|
||||
)
|
||||
|
||||
assert doc.name == "TestDoc"
|
||||
assert doc.path == "/path/to/doc.FCStd"
|
||||
assert doc.objects == ["Box", "Cylinder"]
|
||||
assert doc.is_modified is True
|
||||
assert doc.label == "Test Document"
|
||||
|
||||
def test_document_with_defaults(self):
|
||||
"""DocumentInfo should use sensible defaults."""
|
||||
doc = DocumentInfo(name="Doc", path=None)
|
||||
|
||||
assert doc.name == "Doc"
|
||||
assert doc.path is None
|
||||
assert doc.objects == []
|
||||
assert doc.is_modified is False
|
||||
assert doc.label == "Doc" # Label defaults to name
|
||||
|
||||
|
||||
class TestObjectInfo:
|
||||
"""Tests for ObjectInfo dataclass."""
|
||||
|
||||
def test_object_with_shape(self):
|
||||
"""ObjectInfo should store shape information correctly."""
|
||||
obj = ObjectInfo(
|
||||
name="Box",
|
||||
label="My Box",
|
||||
type_id="Part::Box",
|
||||
properties={"Length": 10.0},
|
||||
shape_info={"type": "Solid", "volume": 1000.0},
|
||||
children=["Child1"],
|
||||
)
|
||||
|
||||
assert obj.name == "Box"
|
||||
assert obj.label == "My Box"
|
||||
assert obj.type_id == "Part::Box"
|
||||
assert obj.properties == {"Length": 10.0}
|
||||
assert obj.shape_info == {"type": "Solid", "volume": 1000.0}
|
||||
assert obj.children == ["Child1"]
|
||||
|
||||
def test_object_with_defaults(self):
|
||||
"""ObjectInfo should use sensible defaults."""
|
||||
obj = ObjectInfo(name="Obj", label="Obj", type_id="Part::Feature")
|
||||
|
||||
assert obj.properties == {}
|
||||
assert obj.shape_info is None
|
||||
assert obj.children == []
|
||||
|
||||
|
||||
class TestFreecadBridgeInterface:
|
||||
"""Tests for FreecadBridge abstract interface."""
|
||||
|
||||
def test_cannot_instantiate_abstract_class(self):
|
||||
"""FreecadBridge should not be instantiable directly."""
|
||||
with pytest.raises(TypeError):
|
||||
FreecadBridge() # type: ignore[abstract]
|
||||
|
||||
def test_subclass_must_implement_methods(self):
|
||||
"""Subclass must implement all abstract methods."""
|
||||
|
||||
class IncompleteBridge(FreecadBridge):
|
||||
pass
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
IncompleteBridge() # type: ignore[abstract]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Tests for configuration module."""
|
||||
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from freecad_mcp.config import FreecadMode, ServerConfig, TransportType, get_config
|
||||
|
||||
|
||||
class TestServerConfig:
|
||||
"""Tests for ServerConfig class."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Default configuration should use sensible defaults."""
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
config = ServerConfig()
|
||||
|
||||
assert config.mode == FreecadMode.EMBEDDED
|
||||
assert config.socket_host == "localhost"
|
||||
assert config.socket_port == 9876
|
||||
assert config.timeout_ms == 30000
|
||||
assert config.max_output_size == 1_000_000
|
||||
assert config.transport == TransportType.STDIO
|
||||
assert config.http_port == 8000
|
||||
assert config.log_level == "INFO"
|
||||
assert config.enable_sandbox is True
|
||||
|
||||
def test_socket_mode_from_env(self):
|
||||
"""Configuration should read mode from environment."""
|
||||
with mock.patch.dict(os.environ, {"FREECAD_MODE": "socket"}):
|
||||
config = ServerConfig()
|
||||
|
||||
assert config.mode == FreecadMode.SOCKET
|
||||
|
||||
def test_custom_port_from_env(self):
|
||||
"""Configuration should read port from environment."""
|
||||
with mock.patch.dict(os.environ, {"FREECAD_SOCKET_PORT": "12345"}):
|
||||
config = ServerConfig()
|
||||
|
||||
assert config.socket_port == 12345
|
||||
|
||||
def test_http_transport_from_env(self):
|
||||
"""Configuration should read transport from environment."""
|
||||
with mock.patch.dict(os.environ, {"FREECAD_TRANSPORT": "http"}):
|
||||
config = ServerConfig()
|
||||
|
||||
assert config.transport == TransportType.HTTP
|
||||
|
||||
def test_invalid_port_raises_error(self):
|
||||
"""Invalid port should raise validation error."""
|
||||
with mock.patch.dict(os.environ, {"FREECAD_SOCKET_PORT": "99999"}):
|
||||
with pytest.raises(Exception): # Pydantic validation error
|
||||
ServerConfig()
|
||||
|
||||
def test_get_config_returns_instance(self):
|
||||
"""get_config should return a ServerConfig instance."""
|
||||
config = get_config()
|
||||
|
||||
assert isinstance(config, ServerConfig)
|
||||
|
||||
|
||||
class TestFreecadMode:
|
||||
"""Tests for FreecadMode enum."""
|
||||
|
||||
def test_embedded_value(self):
|
||||
"""EMBEDDED should have correct string value."""
|
||||
assert FreecadMode.EMBEDDED.value == "embedded"
|
||||
|
||||
def test_socket_value(self):
|
||||
"""SOCKET should have correct string value."""
|
||||
assert FreecadMode.SOCKET.value == "socket"
|
||||
|
||||
|
||||
class TestTransportType:
|
||||
"""Tests for TransportType enum."""
|
||||
|
||||
def test_stdio_value(self):
|
||||
"""STDIO should have correct string value."""
|
||||
assert TransportType.STDIO.value == "stdio"
|
||||
|
||||
def test_http_value(self):
|
||||
"""HTTP should have correct string value."""
|
||||
assert TransportType.HTTP.value == "http"
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Tests for embedded bridge implementation."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from freecad_mcp.bridge.embedded import EmbeddedBridge
|
||||
|
||||
|
||||
class TestEmbeddedBridge:
|
||||
"""Tests for EmbeddedBridge class."""
|
||||
|
||||
def test_initialization(self):
|
||||
"""Bridge should initialize with correct defaults."""
|
||||
bridge = EmbeddedBridge()
|
||||
|
||||
assert bridge._freecad_path is None
|
||||
assert bridge._fc_module is None
|
||||
assert bridge._connected is False
|
||||
|
||||
def test_initialization_with_path(self):
|
||||
"""Bridge should accept custom FreeCAD path."""
|
||||
bridge = EmbeddedBridge(freecad_path="/custom/path")
|
||||
|
||||
assert bridge._freecad_path == "/custom/path"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_connected_before_connect(self):
|
||||
"""is_connected should return False before connect."""
|
||||
bridge = EmbeddedBridge()
|
||||
|
||||
assert await bridge.is_connected() is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_python_when_not_connected(self):
|
||||
"""execute_python should return error when not connected."""
|
||||
bridge = EmbeddedBridge()
|
||||
|
||||
result = await bridge.execute_python("x = 1")
|
||||
|
||||
assert result.success is False
|
||||
assert result.error_type == "ConnectionError"
|
||||
assert "not connected" in result.stderr
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_when_not_connected(self):
|
||||
"""disconnect should handle not being connected."""
|
||||
bridge = EmbeddedBridge()
|
||||
|
||||
# Should not raise
|
||||
await bridge.disconnect()
|
||||
|
||||
assert bridge._connected is False
|
||||
|
||||
|
||||
class TestEmbeddedBridgeCodeExecution:
|
||||
"""Tests for code execution in embedded bridge."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_freecad(self):
|
||||
"""Create a mock FreeCAD module."""
|
||||
mock_fc = mock.MagicMock()
|
||||
mock_fc.Version.return_value = ["0", "21", "2", "2024-01-01"]
|
||||
mock_fc.listDocuments.return_value = {}
|
||||
mock_fc.ActiveDocument = None
|
||||
return mock_fc
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_simple_code(self, mock_freecad):
|
||||
"""execute_python should execute simple Python code."""
|
||||
bridge = EmbeddedBridge()
|
||||
bridge._fc_module = mock_freecad
|
||||
bridge._connected = True
|
||||
|
||||
result = await bridge.execute_python("_result_ = 1 + 1")
|
||||
|
||||
assert result.success is True
|
||||
assert result.result == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_code_with_print(self, mock_freecad):
|
||||
"""execute_python should capture stdout."""
|
||||
bridge = EmbeddedBridge()
|
||||
bridge._fc_module = mock_freecad
|
||||
bridge._connected = True
|
||||
|
||||
result = await bridge.execute_python("print('hello')")
|
||||
|
||||
assert result.success is True
|
||||
assert "hello" in result.stdout
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_code_with_error(self, mock_freecad):
|
||||
"""execute_python should capture exceptions."""
|
||||
bridge = EmbeddedBridge()
|
||||
bridge._fc_module = mock_freecad
|
||||
bridge._connected = True
|
||||
|
||||
result = await bridge.execute_python("raise ValueError('test error')")
|
||||
|
||||
assert result.success is False
|
||||
assert result.error_type == "ValueError"
|
||||
assert result.error_traceback is not None
|
||||
assert "test error" in result.error_traceback
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_code_with_syntax_error(self, mock_freecad):
|
||||
"""execute_python should handle syntax errors."""
|
||||
bridge = EmbeddedBridge()
|
||||
bridge._fc_module = mock_freecad
|
||||
bridge._connected = True
|
||||
|
||||
result = await bridge.execute_python("def bad syntax")
|
||||
|
||||
assert result.success is False
|
||||
assert result.error_type == "SyntaxError"
|
||||
|
||||
|
||||
class TestEmbeddedBridgeDocuments:
|
||||
"""Tests for document handling in embedded bridge."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_freecad_with_doc(self):
|
||||
"""Create mock FreeCAD with a document."""
|
||||
mock_doc = mock.MagicMock()
|
||||
mock_doc.Name = "TestDoc"
|
||||
mock_doc.Label = "Test Document"
|
||||
mock_doc.FileName = "/tmp/test.FCStd"
|
||||
mock_doc.Modified = False
|
||||
mock_doc.Objects = []
|
||||
|
||||
mock_fc = mock.MagicMock()
|
||||
mock_fc.listDocuments.return_value = {"TestDoc": mock_doc}
|
||||
mock_fc.ActiveDocument = mock_doc
|
||||
mock_fc.getDocument.return_value = mock_doc
|
||||
|
||||
return mock_fc
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_documents_empty(self):
|
||||
"""get_documents should return empty list when no docs."""
|
||||
mock_fc = mock.MagicMock()
|
||||
mock_fc.listDocuments.return_value = {}
|
||||
|
||||
bridge = EmbeddedBridge()
|
||||
bridge._fc_module = mock_fc
|
||||
bridge._connected = True
|
||||
|
||||
# Mock the execute_python to return empty list
|
||||
result = await bridge.get_documents()
|
||||
|
||||
# Since we're not mocking exec properly, we expect empty
|
||||
assert isinstance(result, list)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Tests for socket bridge implementation."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from freecad_mcp.bridge.socket import SocketBridge
|
||||
|
||||
|
||||
class TestSocketBridge:
|
||||
"""Tests for SocketBridge class."""
|
||||
|
||||
def test_initialization_defaults(self):
|
||||
"""Bridge should initialize with correct defaults."""
|
||||
bridge = SocketBridge()
|
||||
|
||||
assert bridge._host == "localhost"
|
||||
assert bridge._port == 9876
|
||||
assert bridge._reader is None
|
||||
assert bridge._writer is None
|
||||
|
||||
def test_initialization_custom(self):
|
||||
"""Bridge should accept custom host and port."""
|
||||
bridge = SocketBridge(host="192.168.1.1", port=12345)
|
||||
|
||||
assert bridge._host == "192.168.1.1"
|
||||
assert bridge._port == 12345
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_connected_before_connect(self):
|
||||
"""is_connected should return False before connect."""
|
||||
bridge = SocketBridge()
|
||||
|
||||
assert await bridge.is_connected() is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_failure(self):
|
||||
"""connect should raise ConnectionError on failure."""
|
||||
bridge = SocketBridge(host="invalid-host-12345", port=99999)
|
||||
|
||||
with pytest.raises(ConnectionError):
|
||||
await bridge.connect()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_when_not_connected(self):
|
||||
"""disconnect should handle not being connected."""
|
||||
bridge = SocketBridge()
|
||||
|
||||
# Should not raise
|
||||
await bridge.disconnect()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_python_when_not_connected(self):
|
||||
"""execute_python should return error when not connected."""
|
||||
bridge = SocketBridge()
|
||||
|
||||
result = await bridge.execute_python("x = 1")
|
||||
|
||||
assert result.success is False
|
||||
assert result.error_type == "ConnectionError"
|
||||
|
||||
|
||||
class TestSocketBridgeCommunication:
|
||||
"""Tests for socket communication."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_streams(self):
|
||||
"""Create mock reader and writer streams."""
|
||||
reader = mock.AsyncMock(spec=asyncio.StreamReader)
|
||||
writer = mock.MagicMock(spec=asyncio.StreamWriter)
|
||||
writer.is_closing.return_value = False
|
||||
writer.drain = mock.AsyncMock()
|
||||
writer.close = mock.MagicMock()
|
||||
writer.wait_closed = mock.AsyncMock()
|
||||
return reader, writer
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_request_format(self, mock_streams):
|
||||
"""Requests should be formatted correctly when connected."""
|
||||
reader, writer = mock_streams
|
||||
bridge = SocketBridge()
|
||||
bridge._reader = reader
|
||||
bridge._writer = writer
|
||||
bridge._connected = True
|
||||
|
||||
# Setup response
|
||||
response = {"jsonrpc": "2.0", "id": "test-1", "result": {"success": True}}
|
||||
reader.readline.return_value = json.dumps(response).encode() + b"\n"
|
||||
|
||||
# Our bridge sends and receives in one call
|
||||
# Just verify we can set up the bridge state correctly
|
||||
assert bridge._reader is not None
|
||||
assert bridge._writer is not None
|
||||
assert bridge._connected is True
|
||||
|
||||
|
||||
class TestSocketBridgeDocuments:
|
||||
"""Tests for document handling via socket."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_documents_when_not_connected(self):
|
||||
"""get_documents should return empty list when not connected."""
|
||||
bridge = SocketBridge()
|
||||
|
||||
# This will fail to send request, should return empty
|
||||
docs = await bridge.get_documents()
|
||||
|
||||
assert docs == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_active_document_when_not_connected(self):
|
||||
"""get_active_document should return None when not connected."""
|
||||
bridge = SocketBridge()
|
||||
|
||||
doc = await bridge.get_active_document()
|
||||
|
||||
assert doc is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_gui_available_when_not_connected(self):
|
||||
"""is_gui_available should return False when not connected."""
|
||||
bridge = SocketBridge()
|
||||
|
||||
result = await bridge.is_gui_available()
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestSocketBridgeVersionInfo:
|
||||
"""Tests for version info handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_version_when_not_connected(self):
|
||||
"""get_freecad_version should return unknown when not connected."""
|
||||
bridge = SocketBridge()
|
||||
|
||||
version = await bridge.get_freecad_version()
|
||||
|
||||
assert version["version"] == "unknown"
|
||||
assert version["gui_available"] is False
|
||||
Reference in New Issue
Block a user