"""
Shared test fixtures and configuration for BlenderMCP pytest-bdd tests.
This module provides reusable fixtures for testing the BlenderMCP server
without requiring actual Blender integration. Tests validate the MCP interface
contracts, tool schemas, and behavioral specifications.
"""
import pytest
import json
import socket
from unittest.mock import Mock, MagicMock, patch, AsyncMock
from typing import Dict, Any, List
from dataclasses import dataclass
from pathlib import Path
import sys
# For development without installing the package, add src to path
# Alternatively, install with: pip install -e .
try:
import blender_mcp
except ImportError:
src_path = Path(__file__).parent.parent / "src"
sys.path.insert(0, str(src_path.parent))
# Create blender_mcp module namespace pointing to src/
import types
blender_mcp = types.ModuleType("blender_mcp")
blender_mcp.__file__ = str(src_path / "__init__.py")
blender_mcp.__path__ = [str(src_path)]
blender_mcp.__package__ = "blender_mcp"
sys.modules["blender_mcp"] = blender_mcp
# Load src/__init__.py into blender_mcp module
with open(src_path / "__init__.py") as f:
exec(f.read(), blender_mcp.__dict__)
@dataclass
class MockBlenderResponse:
"""Mock response from Blender addon"""
success: bool
data: Dict[str, Any]
error: str = None
class MockBlenderConnection:
"""Mock Blender connection for testing without actual Blender"""
def __init__(self):
self.connected = False
self.last_command = None
self.responses = {}
self.default_response = {"success": True, "result": {}}
def connect(self) -> bool:
"""Simulate connection"""
self.connected = True
return True
def disconnect(self):
"""Simulate disconnection"""
self.connected = False
def send_command(self, command: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
"""Mock sending command and receiving response"""
self.last_command = {"command": command, "params": params}
# Return pre-configured response or default
key = f"{command}:{json.dumps(params, sort_keys=True)}"
if key in self.responses:
return self.responses[key]
return self.default_response.copy()
def set_response(self, command: str, params: Dict[str, Any], response: Dict[str, Any]):
"""Configure a specific response for testing"""
key = f"{command}:{json.dumps(params, sort_keys=True)}"
self.responses[key] = response
# ============================================================================
# FIXTURES: Test Infrastructure
# ============================================================================
@pytest.fixture
def mock_blender_connection():
"""
Provides a mock Blender connection for tests.
This fixture allows tests to validate tool behavior without requiring
an actual Blender instance running.
"""
return MockBlenderConnection()
@pytest.fixture
def mock_server():
"""
Provides a mock MCP server instance for testing.
This mock includes the tool registry and allows validation of
MCP contract compliance without actual socket connections.
"""
from blender_mcp.server import mcp, DEFAULT_HOST, DEFAULT_PORT
server_mock = Mock()
server_mock.mcp = mcp
server_mock.host = DEFAULT_HOST
server_mock.port = DEFAULT_PORT
return server_mock
@pytest.fixture
def sample_mesh_stats():
"""
Provides sample mesh statistics data for testing.
Returns typical mesh_stats output structure.
"""
return {
"success": True,
"result": {
"vertices": 1024,
"edges": 2048,
"faces": 1024,
"tris": 512,
"quads": 512,
"ngons": 0,
"non_manifold_edges": 0,
"sharp_edges": 64,
"surface_area": 2.5,
"volume": 0.125
}
}
@pytest.fixture
def sample_topology_issues():
"""
Provides sample topology issue detection data.
Returns typical detect_topology_issues output.
"""
return {
"success": True,
"result": {
"non_manifold_edges": [10, 25, 42],
"loose_vertices": [5, 15],
"loose_edges": [100],
"flipped_normals": [],
"sharp_edges_above_threshold": [50, 51, 52],
"issues_found": True,
"recommendations": [
"Use 'Select > All by Trait > Non-Manifold' to find problem edges",
"Consider using 'Merge Vertices' or 'Fill Holes' to fix non-manifold geometry"
]
}
}
@pytest.fixture
def sample_clean_topology():
"""
Provides sample data for a clean mesh with no issues.
"""
return {
"success": True,
"result": {
"non_manifold_edges": [],
"loose_vertices": [],
"loose_edges": [],
"flipped_normals": [],
"sharp_edges_above_threshold": [],
"issues_found": False,
"message": "Mesh topology is clean"
}
}
# ============================================================================
# FIXTURES: BDD Context Management
# ============================================================================
@pytest.fixture
def bdd_context():
"""
Provides a shared context dictionary for BDD steps.
This context is used to pass data between Given/When/Then steps
within a single scenario. It's cleared between scenarios automatically
by pytest.
"""
return {}
# ============================================================================
# FIXTURES: Tool Testing Helpers
# ============================================================================
@pytest.fixture
def tool_validator():
"""
Provides a helper to validate MCP tool definitions.
Usage:
assert tool_validator.has_required_fields(tool_def)
assert tool_validator.has_valid_schema(tool_def)
"""
class ToolValidator:
def has_required_fields(self, tool_def: Dict[str, Any]) -> bool:
"""Check if tool definition has required MCP fields"""
required = ["name", "description", "inputSchema"]
return all(field in tool_def for field in required)
def has_valid_schema(self, tool_def: Dict[str, Any]) -> bool:
"""Check if tool has valid JSON schema"""
schema = tool_def.get("inputSchema", {})
return "type" in schema and schema["type"] == "object"
def has_parameter_types(self, tool_def: Dict[str, Any]) -> bool:
"""Check if all parameters have defined types"""
schema = tool_def.get("inputSchema", {})
properties = schema.get("properties", {})
return all("type" in prop for prop in properties.values())
return ToolValidator()
@pytest.fixture
def resource_validator():
"""
Provides a helper to validate MCP resource definitions.
Usage:
assert resource_validator.has_mime_type(resource_def)
assert resource_validator.has_uri(resource_def)
"""
class ResourceValidator:
def has_mime_type(self, resource_def: Dict[str, Any]) -> bool:
"""Check if resource declares MIME type"""
return "mimeType" in resource_def
def has_uri(self, resource_def: Dict[str, Any]) -> bool:
"""Check if resource has valid URI"""
return "uri" in resource_def
def is_image_resource(self, resource_def: Dict[str, Any]) -> bool:
"""Check if resource is an image type"""
mime = resource_def.get("mimeType", "")
return mime.startswith("image/")
return ResourceValidator()
@pytest.fixture
def prompt_validator():
"""
Provides a helper to validate MCP prompt definitions.
Usage:
assert prompt_validator.has_description(prompt_def)
assert prompt_validator.has_arguments(prompt_def)
"""
class PromptValidator:
def has_description(self, prompt_def: Dict[str, Any]) -> bool:
"""Check if prompt has description"""
return "description" in prompt_def and len(prompt_def["description"]) > 0
def has_arguments(self, prompt_def: Dict[str, Any]) -> bool:
"""Check if prompt defines arguments"""
return "arguments" in prompt_def
def provides_guidance(self, prompt_def: Dict[str, Any]) -> bool:
"""Check if prompt provides meaningful guidance"""
desc = prompt_def.get("description", "")
return len(desc) > 50 # Arbitrary threshold for meaningful content
return PromptValidator()
# ============================================================================
# FIXTURES: Mesh Simulation Helpers
# ============================================================================
@pytest.fixture
def mesh_factory():
"""
Factory for creating mock mesh data structures.
Usage:
mesh = mesh_factory.create_cube()
mesh = mesh_factory.create_with_issues()
"""
class MeshFactory:
def create_cube(self, subdivisions: int = 0) -> Dict[str, Any]:
"""Create mock cube mesh data"""
base_verts = 8
base_faces = 6
# Simple subdivision approximation
if subdivisions > 0:
base_verts = base_verts * (4 ** subdivisions)
base_faces = base_faces * (4 ** subdivisions)
return {
"name": "Cube",
"vertices": base_verts,
"faces": base_faces,
"is_manifold": True,
"has_uvs": False
}
def create_with_issues(self, issue_type: str = "non_manifold") -> Dict[str, Any]:
"""Create mesh with specific topology issues"""
mesh = self.create_cube()
mesh["is_manifold"] = False
if issue_type == "non_manifold":
mesh["non_manifold_edges"] = [5, 10, 15]
elif issue_type == "loose":
mesh["loose_vertices"] = [3, 7]
elif issue_type == "flipped_normals":
mesh["flipped_faces"] = [2, 3]
return mesh
def create_high_poly(self, face_count: int = 10000) -> Dict[str, Any]:
"""Create high-poly mesh for testing decimation/remeshing"""
return {
"name": "HighPoly",
"vertices": face_count * 3,
"faces": face_count,
"is_manifold": True,
"has_uvs": True,
"surface_area": 10.0,
"volume": 1.0
}
return MeshFactory()
# ============================================================================
# FIXTURES: Pytest-BDD Configuration
# ============================================================================
def pytest_configure(config):
"""Configure pytest with custom markers"""
config.addinivalue_line(
"markers", "integration: marks tests as integration tests requiring Blender"
)
config.addinivalue_line(
"markers", "unit: marks tests as unit tests (no external dependencies)"
)
config.addinivalue_line(
"markers", "contract: marks tests validating MCP contract compliance"
)
# ============================================================================
# FIXTURES: Hypothesis Strategies (for property-based testing)
# ============================================================================
try:
from hypothesis import strategies as st
@pytest.fixture
def mesh_stats_strategy():
"""Hypothesis strategy for generating valid mesh statistics"""
return st.fixed_dictionaries({
"vertices": st.integers(min_value=3, max_value=100000),
"edges": st.integers(min_value=3, max_value=200000),
"faces": st.integers(min_value=1, max_value=100000),
"tris": st.integers(min_value=0, max_value=100000),
"quads": st.integers(min_value=0, max_value=100000),
"ngons": st.integers(min_value=0, max_value=1000),
})
@pytest.fixture
def angle_strategy():
"""Hypothesis strategy for valid angle values (0-180 degrees)"""
return st.floats(min_value=0.0, max_value=180.0, allow_nan=False)
@pytest.fixture
def ratio_strategy():
"""Hypothesis strategy for valid ratio values (0.0-1.0)"""
return st.floats(min_value=0.0, max_value=1.0, allow_nan=False, exclude_min=True)
except ImportError:
# Hypothesis not installed, skip property-based test fixtures
pass
# ============================================================================
# FIXTURES: Error Simulation
# ============================================================================
@pytest.fixture
def error_simulator():
"""
Helper for simulating various error conditions.
Usage:
response = error_simulator.not_found("Object not found: Cube")
response = error_simulator.invalid_params("Invalid angle value")
"""
class ErrorSimulator:
def not_found(self, message: str) -> Dict[str, Any]:
"""Simulate object not found error"""
return {
"success": False,
"error": "NOT_FOUND",
"message": message
}
def invalid_params(self, message: str) -> Dict[str, Any]:
"""Simulate invalid parameters error"""
return {
"success": False,
"error": "INVALID_PARAMS",
"message": message
}
def precondition_failed(self, message: str, suggestions: List[str] = None) -> Dict[str, Any]:
"""Simulate precondition failure (e.g., non-manifold mesh)"""
return {
"success": False,
"error": "PRECONDITION_FAILED",
"message": message,
"suggestions": suggestions or []
}
def no_uvs(self) -> Dict[str, Any]:
"""Simulate missing UV coordinates error"""
return self.precondition_failed(
"Mesh has no UV coordinates",
["Unwrap UVs before baking", "Use Smart UV Project"]
)
return ErrorSimulator()
# ============================================================================
# HOOKS: Test Output Enhancement
# ============================================================================
def pytest_bdd_step_error(request, feature, scenario, step, step_func, step_func_args, exception):
"""
Hook to enhance error reporting for BDD steps.
This provides clearer context when a step fails.
"""
print(f"\n{'='*60}")
print(f"BDD Step Failed:")
print(f" Feature: {feature.name}")
print(f" Scenario: {scenario.name}")
print(f" Step: {step.keyword} {step.name}")
print(f" Error: {exception}")
print(f"{'='*60}\n")