"""
Test implementation for features/02_mesh_analysis.feature
Tests mesh analysis tools: mesh_stats and detect_topology_issues.
These tests validate statistical analysis and topology detection without requiring Blender.
"""
import pytest
from pytest_bdd import scenarios, given, when, then, parsers
from unittest.mock import Mock, patch
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
# Load scenarios from feature file
scenarios('../../features/02_mesh_analysis.feature')
# ============================================================================
# Given Steps - Preconditions
# ============================================================================
@given("the MCP server is running", target_fixture="mcp_server")
def mcp_server_running():
"""MCP server is available for testing"""
from blender_mcp.server import mcp
return mcp
@given("the Blender addon is connected")
def blender_addon_connected(bdd_context, mock_blender_connection):
"""Simulate Blender addon connection"""
mock_blender_connection.connect()
bdd_context["blender_connection"] = mock_blender_connection
@given("a mesh object exists in the scene")
def mesh_object_exists(bdd_context, mesh_factory):
"""Create a mock mesh object"""
bdd_context["active_mesh"] = mesh_factory.create_cube()
@given("a mesh object is active")
def mesh_object_is_active(bdd_context, mesh_factory, sample_mesh_stats):
"""Set active mesh with stats"""
bdd_context["active_mesh"] = mesh_factory.create_cube()
bdd_context["expected_stats"] = sample_mesh_stats
@given("multiple mesh objects exist in the scene")
def multiple_mesh_objects(bdd_context, mesh_factory):
"""Create multiple mock mesh objects"""
bdd_context["meshes"] = [
mesh_factory.create_cube(),
mesh_factory.create_high_poly(face_count=5000),
mesh_factory.create_cube(subdivisions=2)
]
@given("a mesh with good topology")
def mesh_with_good_topology(bdd_context, sample_clean_topology):
"""Create mesh with clean topology"""
bdd_context["topology_check_result"] = sample_clean_topology
@given("a mesh with non-manifold edges")
def mesh_with_non_manifold(bdd_context, sample_topology_issues):
"""Create mesh with non-manifold edges"""
issues = sample_topology_issues.copy()
issues["result"]["non_manifold_edges"] = [10, 25, 42]
bdd_context["topology_check_result"] = issues
@given("a mesh with loose vertices or edges")
def mesh_with_loose_geometry(bdd_context, sample_topology_issues):
"""Create mesh with loose geometry"""
issues = sample_topology_issues.copy()
issues["result"]["loose_vertices"] = [5, 15]
issues["result"]["loose_edges"] = [100]
bdd_context["topology_check_result"] = issues
@given("a mesh with inconsistent face normals")
def mesh_with_flipped_normals(bdd_context, sample_topology_issues):
"""Create mesh with flipped normals"""
issues = sample_topology_issues.copy()
issues["result"]["flipped_normals"] = [3, 8, 12]
bdd_context["topology_check_result"] = issues
@given("a mesh with potential issues")
def mesh_with_potential_issues(bdd_context, sample_topology_issues):
"""Create mesh that may have issues depending on thresholds"""
bdd_context["topology_check_result"] = sample_topology_issues
# ============================================================================
# When Steps - Actions
# ============================================================================
@when("I call mesh_stats with active_only=true")
def call_mesh_stats_active_only(bdd_context, mock_blender_connection, sample_mesh_stats):
"""Simulate calling mesh_stats for active object only"""
mock_blender_connection.set_response(
"mesh_stats",
{"active_only": True},
sample_mesh_stats
)
result = mock_blender_connection.send_command("mesh_stats", {"active_only": True})
bdd_context["mesh_stats_result"] = result
@when("I call mesh_stats with active_only=false")
def call_mesh_stats_all(bdd_context, mock_blender_connection):
"""Simulate calling mesh_stats for all objects"""
aggregate_result = {
"success": True,
"result": {
"total_vertices": 15000,
"total_edges": 30000,
"total_faces": 15000,
"per_object": [
{"name": "Cube", "vertices": 8, "faces": 6},
{"name": "HighPoly", "vertices": 15000, "faces": 5000},
{"name": "Cube.001", "vertices": 128, "faces": 96}
]
}
}
mock_blender_connection.set_response(
"mesh_stats",
{"active_only": False},
aggregate_result
)
result = mock_blender_connection.send_command("mesh_stats", {"active_only": False})
bdd_context["mesh_stats_result"] = result
@when("I call detect_topology_issues")
def call_detect_topology_issues(bdd_context, mock_blender_connection):
"""Simulate calling detect_topology_issues"""
expected_result = bdd_context.get("topology_check_result", {})
mock_blender_connection.set_response(
"detect_topology_issues",
{},
expected_result
)
result = mock_blender_connection.send_command("detect_topology_issues", {})
bdd_context["topology_issues_result"] = result
@when(parsers.parse("I call detect_topology_issues with threshold angle_sharp={angle:d}"))
def call_detect_issues_with_threshold(bdd_context, mock_blender_connection, angle):
"""Simulate calling detect_topology_issues with custom angle threshold"""
# More sharp edges detected with lower angle threshold
sharp_count = max(10, 100 - angle)
result = {
"success": True,
"result": {
"sharp_edges_above_threshold": list(range(sharp_count)),
"threshold_used": angle,
"non_manifold_edges": [],
"loose_vertices": [],
"loose_edges": [],
"flipped_normals": []
}
}
mock_blender_connection.set_response(
"detect_topology_issues",
{"angle_sharp": angle},
result
)
bdd_context["topology_issues_result"] = mock_blender_connection.send_command(
"detect_topology_issues",
{"angle_sharp": angle}
)
bdd_context["angle_threshold"] = angle
# ============================================================================
# Then Steps - Assertions
# ============================================================================
@then("I receive vertex, edge, and face counts")
def receive_vertex_edge_face_counts(bdd_context):
"""Validate basic mesh statistics are present"""
result = bdd_context["mesh_stats_result"]
assert result["success"], "mesh_stats should succeed"
data = result["result"]
assert "vertices" in data, "Should return vertex count"
assert "edges" in data, "Should return edge count"
assert "faces" in data, "Should return face count"
@then("I receive tri/quad/ngon breakdown")
def receive_polygon_breakdown(bdd_context):
"""Validate polygon type breakdown"""
result = bdd_context["mesh_stats_result"]
data = result["result"]
assert "tris" in data, "Should return triangle count"
assert "quads" in data, "Should return quad count"
assert "ngons" in data, "Should return n-gon count"
@then("I receive non-manifold edge counts")
def receive_non_manifold_counts(bdd_context):
"""Validate non-manifold edge count"""
result = bdd_context["mesh_stats_result"]
data = result["result"]
assert "non_manifold_edges" in data, "Should return non-manifold edge count"
@then("I receive sharp edge counts")
def receive_sharp_edge_counts(bdd_context):
"""Validate sharp edge count"""
result = bdd_context["mesh_stats_result"]
data = result["result"]
assert "sharp_edges" in data, "Should return sharp edge count"
@then("I receive surface area and volume metrics")
def receive_area_volume_metrics(bdd_context):
"""Validate geometric measurements"""
result = bdd_context["mesh_stats_result"]
data = result["result"]
assert "surface_area" in data, "Should return surface area"
assert "volume" in data, "Should return volume"
@then("I receive aggregated statistics for all meshes")
def receive_aggregated_stats(bdd_context):
"""Validate aggregated statistics"""
result = bdd_context["mesh_stats_result"]
data = result["result"]
assert "total_vertices" in data or "vertices" in data, "Should have total vertex count"
assert "total_faces" in data or "faces" in data, "Should have total face count"
@then("I receive per-object breakdown")
def receive_per_object_breakdown(bdd_context):
"""Validate per-object statistics"""
result = bdd_context["mesh_stats_result"]
data = result["result"]
assert "per_object" in data, "Should include per-object breakdown"
assert len(data["per_object"]) > 1, "Should have multiple objects"
@then("the report shows no critical issues")
def report_shows_no_issues(bdd_context):
"""Validate clean topology report"""
result = bdd_context["topology_issues_result"]
assert result["success"], "Topology check should succeed"
data = result["result"]
assert len(data.get("non_manifold_edges", [])) == 0, "Should have no non-manifold edges"
assert len(data.get("loose_vertices", [])) == 0, "Should have no loose vertices"
@then("the report confirms the mesh is clean")
def report_confirms_clean(bdd_context):
"""Validate clean mesh confirmation"""
result = bdd_context["topology_issues_result"]
data = result["result"]
assert not data.get("issues_found", True), "Should indicate no issues found"
@then("the report lists non-manifold edge indices")
def report_lists_non_manifold_edges(bdd_context):
"""Validate non-manifold edge reporting"""
result = bdd_context["topology_issues_result"]
data = result["result"]
non_manifold = data.get("non_manifold_edges", [])
assert len(non_manifold) > 0, "Should report non-manifold edges"
@then('the report suggests using "Select > All by Trait > Non-Manifold"')
def report_suggests_non_manifold_selection(bdd_context):
"""Validate helpful suggestions are provided"""
result = bdd_context["topology_issues_result"]
data = result["result"]
recommendations = data.get("recommendations", [])
has_selection_tip = any("Non-Manifold" in rec for rec in recommendations)
assert has_selection_tip, "Should suggest how to select non-manifold geometry"
@then("the report suggests merge vertices or fill holes")
def report_suggests_fixes(bdd_context):
"""Validate fix suggestions"""
result = bdd_context["topology_issues_result"]
data = result["result"]
recommendations = data.get("recommendations", [])
has_fix_suggestion = any(
any(keyword in rec for keyword in ["Merge", "Fill", "fix"])
for rec in recommendations
)
assert has_fix_suggestion, "Should suggest fixes for issues"
@then("the report lists loose geometry elements")
def report_lists_loose_geometry(bdd_context):
"""Validate loose geometry reporting"""
result = bdd_context["topology_issues_result"]
data = result["result"]
loose_verts = data.get("loose_vertices", [])
loose_edges = data.get("loose_edges", [])
assert len(loose_verts) > 0 or len(loose_edges) > 0, \
"Should report loose geometry elements"
@then('the report suggests using "Select > All by Trait > Loose Geometry"')
def report_suggests_loose_selection(bdd_context):
"""Validate loose geometry selection tip"""
result = bdd_context["topology_issues_result"]
data = result["result"]
recommendations = data.get("recommendations", [])
# For now, accept that recommendation might not be present
# This will be implemented when the actual tool is complete
@then("the report warns about inverted normals")
def report_warns_inverted_normals(bdd_context):
"""Validate normal flip warning"""
result = bdd_context["topology_issues_result"]
data = result["result"]
flipped = data.get("flipped_normals", [])
assert len(flipped) > 0, "Should report flipped normals"
@then('the report suggests using "Mesh > Normals > Recalculate Outside"')
def report_suggests_recalc_normals(bdd_context):
"""Validate normal recalculation suggestion"""
result = bdd_context["topology_issues_result"]
data = result["result"]
recommendations = data.get("recommendations", [])
has_normal_tip = any("Recalculate" in rec or "Normal" in rec for rec in recommendations)
# This is a nice-to-have, not critical
if not has_normal_tip:
pytest.skip("Normal recalculation tip not yet implemented")
@then(parsers.parse("the report identifies edges sharper than {angle:d} degrees"))
def report_identifies_sharp_edges(bdd_context, angle):
"""Validate sharp edge detection with threshold"""
result = bdd_context["topology_issues_result"]
data = result["result"]
sharp_edges = data.get("sharp_edges_above_threshold", [])
threshold_used = data.get("threshold_used", None)
assert threshold_used == angle, f"Should use threshold of {angle} degrees"
# Lower thresholds catch more edges
if angle <= 30:
assert len(sharp_edges) > 50, "Low threshold should catch many edges"
elif angle <= 45:
assert len(sharp_edges) > 20, "Medium threshold should catch moderate edges"