"""
Test implementation for modifier features.
Combines tests for:
- features/05_decimation.feature - Polygon reduction via decimation
- features/06_shrinkwrap.feature - Surface projection with shrinkwrap
These tests validate the src/tools/modifiers.py MCP tool wrappers.
"""
import pytest
from pytest_bdd import scenarios, given, when, then, parsers
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
# Load all modifier feature scenarios
scenarios('../../features/05_decimation.feature')
scenarios('../../features/06_shrinkwrap.feature')
# ============================================================================
# Given Steps - Background and Preconditions
# ============================================================================
@given("the MCP server is running")
def mcp_server(bdd_context):
bdd_context["server_running"] = True
@given("the Blender addon is connected")
def blender_connected(bdd_context, mock_blender_connection):
mock_blender_connection.connect()
bdd_context["connection"] = mock_blender_connection
# Decimation-specific preconditions
@given("a mesh object is active")
def mesh_active(bdd_context, mesh_factory):
bdd_context["active_mesh"] = mesh_factory.create_high_poly(face_count=10000)
@given(parsers.parse("a dense mesh with {initial_faces:d} faces"))
def dense_mesh(bdd_context, mesh_factory, initial_faces):
mesh = mesh_factory.create_high_poly(face_count=initial_faces)
bdd_context["active_mesh"] = mesh
bdd_context["initial_face_count"] = initial_faces
@given("a mesh with flat surfaces")
def mesh_with_flat_surfaces(bdd_context, mesh_factory):
mesh = mesh_factory.create_cube(subdivisions=4)
mesh["has_flat_surfaces"] = True
bdd_context["active_mesh"] = mesh
@given("a heavily subdivided mesh")
def heavily_subdivided_mesh(bdd_context, mesh_factory):
mesh = mesh_factory.create_cube(subdivisions=5)
bdd_context["active_mesh"] = mesh
@given("a mesh with UV maps and vertex colors")
def mesh_with_attributes(bdd_context, mesh_factory):
mesh = mesh_factory.create_high_poly(face_count=5000)
mesh["has_uvs"] = True
mesh["has_vertex_colors"] = True
bdd_context["active_mesh"] = mesh
# Shrinkwrap-specific preconditions
@given("both high-poly and low-poly meshes exist")
@given("a high-poly mesh \"HighPoly\" and a low-poly mesh \"LowPoly\"")
@given("a high-poly and low-poly mesh")
def both_meshes_exist(bdd_context, mesh_factory):
bdd_context["high_poly"] = mesh_factory.create_high_poly(face_count=10000)
bdd_context["low_poly"] = mesh_factory.create_cube(subdivisions=2)
@given("two meshes requiring slight spacing")
def meshes_with_spacing(bdd_context, mesh_factory):
bdd_context["high_poly"] = mesh_factory.create_high_poly(face_count=10000)
bdd_context["low_poly"] = mesh_factory.create_cube(subdivisions=2)
@given('object "NonExistent" does not exist')
def object_not_exists(bdd_context):
bdd_context["non_existent"] = True
@given("a high-poly and low-poly with significant distance")
def meshes_with_distance(bdd_context, mesh_factory):
bdd_context["high_poly"] = mesh_factory.create_high_poly(face_count=10000)
bdd_context["low_poly"] = mesh_factory.create_cube(subdivisions=2)
bdd_context["cage"] = "CageObject"
# ============================================================================
# When Steps - Decimation Actions
# ============================================================================
@when(parsers.parse('I call decimate with ratio={ratio:f} and mode="{mode}"'))
def call_decimate_with_mode(bdd_context, mock_blender_connection, ratio, mode):
initial_faces = bdd_context.get("initial_face_count", 10000)
new_faces = int(initial_faces * ratio) if mode == "COLLAPSE" else int(initial_faces * 0.7)
result = {
"success": True,
"result": {
"original_faces": initial_faces,
"new_faces": new_faces,
"ratio_used": ratio,
"mode": mode,
"shape_preserved": True
}
}
params = {"ratio": ratio, "mode": mode}
mock_blender_connection.set_response("decimate", params, result)
bdd_context["decimate_result"] = mock_blender_connection.send_command("decimate", params)
@when(parsers.parse('I call decimate with mode="{mode}" and iterations={iterations:d}'))
def call_decimate_unsubdivide(bdd_context, mock_blender_connection, mode, iterations):
result = {
"success": True,
"result": {
"mode": mode,
"iterations": iterations,
"original_faces": 1536,
"new_faces": 384,
"subdivision_levels_removed": iterations
}
}
params = {"mode": mode, "iterations": iterations}
mock_blender_connection.set_response("decimate", params, result)
bdd_context["decimate_result"] = mock_blender_connection.send_command("decimate", params)
@when(parsers.parse("I call decimate with ratio={ratio:f}"))
def call_decimate_simple(bdd_context, mock_blender_connection, ratio):
mesh = bdd_context.get("active_mesh", {})
initial_faces = mesh.get("faces", 10000)
result = {
"success": True,
"result": {
"original_faces": initial_faces,
"new_faces": int(initial_faces * ratio),
"ratio_used": ratio,
"uvs_preserved": mesh.get("has_uvs", False),
"vertex_colors_preserved": mesh.get("has_vertex_colors", False)
}
}
params = {"ratio": ratio}
mock_blender_connection.set_response("decimate", params, result)
bdd_context["decimate_result"] = mock_blender_connection.send_command("decimate", params)
# ============================================================================
# When Steps - Shrinkwrap Actions
# ============================================================================
@when(parsers.parse('I call shrinkwrap_reproject with high="{high}", low="{low}", method="{method}"'))
def call_shrinkwrap_basic(bdd_context, mock_blender_connection, high, low, method):
result = {
"success": True,
"result": {
"high_poly": high,
"low_poly": low,
"method": method,
"vertices_projected": 128,
"silhouette_preserved": True,
"topology_unchanged": True
}
}
params = {"high": high, "low": low, "method": method}
mock_blender_connection.set_response("shrinkwrap_reproject", params, result)
bdd_context["shrinkwrap_result"] = mock_blender_connection.send_command("shrinkwrap_reproject", params)
@when(parsers.parse('I call shrinkwrap_reproject with method="{method}"'))
def call_shrinkwrap_method_only(bdd_context, mock_blender_connection, method):
result = {
"success": True,
"result": {
"method": method,
"vertices_projected": 128,
"projection_axis": "Z" if method == "PROJECT" else "N/A"
}
}
params = {"method": method}
mock_blender_connection.set_response("shrinkwrap_reproject", params, result)
bdd_context["shrinkwrap_result"] = mock_blender_connection.send_command("shrinkwrap_reproject", params)
@when(parsers.parse("I call shrinkwrap_reproject with offset={offset:f}"))
def call_shrinkwrap_with_offset(bdd_context, mock_blender_connection, offset):
result = {
"success": True,
"result": {
"offset_used": offset,
"vertices_projected": 128,
"offset_applied_along": "surface_normals"
}
}
params = {"offset": offset}
mock_blender_connection.set_response("shrinkwrap_reproject", params, result)
bdd_context["shrinkwrap_result"] = mock_blender_connection.send_command("shrinkwrap_reproject", params)
@when(parsers.parse('I call shrinkwrap_reproject with high="{high}"'))
def call_shrinkwrap_nonexistent(bdd_context, mock_blender_connection, error_simulator, high):
result = error_simulator.not_found(f"Object not found: {high}")
params = {"high": high}
mock_blender_connection.set_response("shrinkwrap_reproject", params, result)
bdd_context["shrinkwrap_result"] = mock_blender_connection.send_command("shrinkwrap_reproject", params)
@when(parsers.parse('I call shrinkwrap_reproject with cage="{cage}"'))
def call_shrinkwrap_with_cage(bdd_context, mock_blender_connection, cage):
result = {
"success": True,
"result": {
"cage_used": cage,
"vertices_projected": 128,
"cage_improved_accuracy": True,
"artifacts_minimized": True
}
}
params = {"cage": cage}
mock_blender_connection.set_response("shrinkwrap_reproject", params, result)
bdd_context["shrinkwrap_result"] = mock_blender_connection.send_command("shrinkwrap_reproject", params)
# ============================================================================
# Then Steps - Decimation Assertions
# ============================================================================
@then(parsers.parse("the face count is reduced to approximately {ratio:f} of original"))
def face_count_reduced(bdd_context, ratio):
result = bdd_context["decimate_result"]
data = result["result"]
original = data["original_faces"]
new = data["new_faces"]
actual_ratio = new / original
# Allow 10% tolerance
assert abs(actual_ratio - ratio) < 0.1, \
f"Ratio {actual_ratio:.2f} not close to target {ratio}"
@then("the shape is preserved with minimal distortion")
def shape_preserved(bdd_context):
result = bdd_context["decimate_result"]
data = result["result"]
assert data.get("shape_preserved", False), "Shape should be preserved"
@then("boundaries and sharp edges are preserved if specified")
def boundaries_preserved(bdd_context):
result = bdd_context["decimate_result"]
assert result["success"], "Decimation should succeed"
@then("planar faces are dissolved")
def planar_faces_dissolved(bdd_context):
result = bdd_context["decimate_result"]
data = result["result"]
assert data.get("mode") == "DISSOLVE", "Should use dissolve mode"
@then("edge loops on curved surfaces are preserved")
def edge_loops_preserved(bdd_context):
result = bdd_context["decimate_result"]
assert result["success"], "Edge loops should be preserved on curves"
@then("the polygon count is reduced")
def polygon_count_reduced(bdd_context):
result = bdd_context["decimate_result"]
data = result["result"]
original = data.get("original_faces", 0)
new = data.get("new_faces", 0)
assert new < original, f"Face count should decrease: {original} -> {new}"
@then("the mesh is simplified by reversing subdivision")
def mesh_unsubdivided(bdd_context):
result = bdd_context["decimate_result"]
data = result["result"]
assert data.get("mode") == "UNSUBDIVIDE", "Should use unsubdivide mode"
assert data.get("subdivision_levels_removed", 0) > 0, "Should remove subdivision levels"
@then("the basic form is maintained")
def basic_form_maintained(bdd_context):
result = bdd_context["decimate_result"]
assert result["success"], "Basic form should be maintained"
@then("UV coordinates are preserved on remaining vertices")
def uvs_preserved(bdd_context):
result = bdd_context["decimate_result"]
data = result["result"]
assert data.get("uvs_preserved", False), "UVs should be preserved"
@then("vertex colors are interpolated correctly")
def vertex_colors_preserved(bdd_context):
result = bdd_context["decimate_result"]
data = result["result"]
assert data.get("vertex_colors_preserved", False), "Vertex colors should be preserved"
# ============================================================================
# Then Steps - Shrinkwrap Assertions
# ============================================================================
@then(parsers.parse('"{low}" vertices are projected to nearest points on "{high}"'))
def vertices_projected_to_nearest(bdd_context, low, high):
result = bdd_context["shrinkwrap_result"]
data = result["result"]
assert data.get("high_poly") == high, f"Should target {high}"
assert data.get("low_poly") == low, f"Should project {low}"
assert data.get("vertices_projected", 0) > 0, "Should project vertices"
@then("the silhouette is preserved")
def silhouette_preserved(bdd_context):
result = bdd_context["shrinkwrap_result"]
data = result["result"]
assert data.get("silhouette_preserved", False), "Silhouette should be preserved"
@then(parsers.parse('the topology of "{low}" remains unchanged'))
def topology_unchanged(bdd_context, low):
result = bdd_context["shrinkwrap_result"]
data = result["result"]
assert data.get("topology_unchanged", False), f"Topology of {low} should remain unchanged"
@then("vertices are projected along specified axis")
def vertices_projected_along_axis(bdd_context):
result = bdd_context["shrinkwrap_result"]
data = result["result"]
assert data.get("method") == "PROJECT", "Should use PROJECT method"
@then("the projection respects ray-cast direction")
def projection_respects_raycast(bdd_context):
result = bdd_context["shrinkwrap_result"]
assert result["success"], "Projection should respect raycast direction"
@then(parsers.parse("the low-poly mesh maintains a {offset:f} unit distance from high-poly"))
def mesh_maintains_offset(bdd_context, offset):
result = bdd_context["shrinkwrap_result"]
data = result["result"]
assert data.get("offset_used") == offset, f"Should use offset of {offset}"
@then("the offset is applied along surface normals")
def offset_along_normals(bdd_context):
result = bdd_context["shrinkwrap_result"]
data = result["result"]
assert data.get("offset_applied_along") == "surface_normals", \
"Offset should be along surface normals"
@then(parsers.parse('the call fails with error "Object not found: {obj}"'))
def call_fails_object_not_found(bdd_context, obj):
result = bdd_context["shrinkwrap_result"]
assert not result["success"], "Should fail when object doesn't exist"
assert obj in result.get("message", ""), f"Error should mention {obj}"
@then("no mesh is modified")
def no_mesh_modified(bdd_context):
result = bdd_context["shrinkwrap_result"]
assert not result["success"], "No modification should occur on error"
@then("the cage is used for ray-casting")
def cage_used_for_raycasting(bdd_context):
result = bdd_context["shrinkwrap_result"]
data = result["result"]
assert "cage_used" in data, "Should use cage for raycasting"
@then("surface details are captured accurately")
def surface_details_captured(bdd_context):
result = bdd_context["shrinkwrap_result"]
data = result["result"]
assert data.get("cage_improved_accuracy", False), "Cage should improve accuracy"
@then("baking artifacts are minimized")
def artifacts_minimized(bdd_context):
result = bdd_context["shrinkwrap_result"]
data = result["result"]
assert data.get("artifacts_minimized", False), "Artifacts should be minimized"