Files
freecad-robust-mcp-fc111/tests/integration/test_gui_mode.py
T
cd77560297 ci: Add CI and Release workflows and other test fixes (#7)
* ci: add caching and fix workflow failures

- Add UV package caching to all workflows for faster dependency installation
- Add pre-commit hook caching with OS-specific cache keys
- Skip no-commit-to-branch hook in CI (fails on main branch)
- Remove broken apt cache action (doesn't work with PPAs)
- Simplify FreeCAD command detection (PPA installs to standard PATH)
- Add FreeCAD Python version logging for debugging

* ci: Add code Rabbit configuration file

* ci: fix issues in CI workflows

* ci: add uv.lock

* ci: tweaks

* ci: Fix errors and add UUID generation and checking

* ci: Use the GitHub FreeCAD release latest stables

* ci: skip macro test for now, due to headless mode

* feat: Add a multi export macro

* ci: Fix tests

* ci: fix docker build workflow

* ci: skip trufflehog in GitHub Actions due to wasm panic bug

TruffleHog has a known wasm/go-re2 panic bug that causes failures
in GitHub Actions environment. The hook still runs locally during
development for secrets detection.

- Add trufflehog to SKIP env var in pre-commit.yaml
- Update trufflehog to v3.88.7 (latest)
- Add reference to upstream issue #3321

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: use boolean holes instead of PartDesign::Hole in CI

PartDesign::Hole has a CADKernelError bug in FreeCAD AppImage headless
mode on Linux (used in GitHub Actions) where it fails with "Cannot make
face from profile". The SmartCutter class already supports boolean holes
as an alternative.

Changes:
- Modify SmartCutter.execute() to use boolean holes by default
- Update all test assertions for Part::Feature output type
- Update test docstrings and class descriptions
- Remove PartDesign-specific checks (Group, Sketcher::SketchObject)
- Update workflow comment explaining the CI limitation

Boolean holes work reliably in both GUI and headless mode across all
FreeCAD configurations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ci: clean up GitHub Actions workflows

* ci: Dependabot improvements

* ci: add CodeQL scanning workflow

* ci: AI review suggested improvements

* ci: add coderabbit updates for intentional decisions

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:29:25 -08:00

583 lines
16 KiB
Python

"""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"
screenshot_path_str = str(screenshot_path)
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_str!r}, 800, 600, "White")
_result_ = {{
"saved": os.path.exists({screenshot_path_str!r}),
"path": {screenshot_path_str!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"
step_path_str = str(step_path)
screenshot_path_str = str(screenshot_path)
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_str!r}, 800, 600, "White")
# Export to STEP
bracket_obj.Shape.exportStep({step_path_str!r})
_result_ = {{
"bracket_valid": bracket_obj.Shape.isValid(),
"bracket_volume": bracket_obj.Shape.Volume,
"screenshot_exists": os.path.exists({screenshot_path_str!r}),
"step_exists": os.path.exists({step_path_str!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()