3D / voxel unit tests

This commit is contained in:
John McCardle 2026-02-06 16:15:07 -05:00
commit 71cd2b9b41
22 changed files with 4705 additions and 0 deletions

View file

@ -0,0 +1,98 @@
# animated_model_test.py - Test loading actual animated glTF models
# Tests skeleton and animation data loading from real files
import mcrfpy
import sys
import os
def test_rigged_simple():
"""Test loading RiggedSimple - a cylinder with 2 bones"""
print("Loading RiggedSimple.glb...")
model = mcrfpy.Model3D("../assets/models/RiggedSimple.glb")
print(f" has_skeleton: {model.has_skeleton}")
print(f" bone_count: {model.bone_count}")
print(f" animation_clips: {model.animation_clips}")
print(f" vertex_count: {model.vertex_count}")
print(f" triangle_count: {model.triangle_count}")
print(f" mesh_count: {model.mesh_count}")
assert model.has_skeleton == True, f"Expected has_skeleton=True, got {model.has_skeleton}"
assert model.bone_count > 0, f"Expected bone_count > 0, got {model.bone_count}"
assert len(model.animation_clips) > 0, f"Expected animation clips, got {model.animation_clips}"
print("[PASS] test_rigged_simple")
def test_cesium_man():
"""Test loading CesiumMan - animated humanoid figure"""
print("Loading CesiumMan.glb...")
model = mcrfpy.Model3D("../assets/models/CesiumMan.glb")
print(f" has_skeleton: {model.has_skeleton}")
print(f" bone_count: {model.bone_count}")
print(f" animation_clips: {model.animation_clips}")
print(f" vertex_count: {model.vertex_count}")
print(f" triangle_count: {model.triangle_count}")
print(f" mesh_count: {model.mesh_count}")
assert model.has_skeleton == True, f"Expected has_skeleton=True, got {model.has_skeleton}"
assert model.bone_count > 0, f"Expected bone_count > 0, got {model.bone_count}"
assert len(model.animation_clips) > 0, f"Expected animation clips, got {model.animation_clips}"
print("[PASS] test_cesium_man")
def test_entity_with_animated_model():
"""Test Entity3D with an animated model attached"""
print("Testing Entity3D with animated model...")
model = mcrfpy.Model3D("../assets/models/RiggedSimple.glb")
entity = mcrfpy.Entity3D()
entity.model = model
# Check animation clips are available
clips = model.animation_clips
print(f" Available clips: {clips}")
if clips:
# Set animation clip
entity.anim_clip = clips[0]
assert entity.anim_clip == clips[0], f"Expected clip '{clips[0]}', got '{entity.anim_clip}'"
# Test animation time progression
entity.anim_time = 0.5
assert abs(entity.anim_time - 0.5) < 0.001, f"Expected anim_time~=0.5, got {entity.anim_time}"
# Test speed
entity.anim_speed = 2.0
assert abs(entity.anim_speed - 2.0) < 0.001, f"Expected anim_speed~=2.0, got {entity.anim_speed}"
print("[PASS] test_entity_with_animated_model")
def run_all_tests():
"""Run all animated model tests"""
tests = [
test_rigged_simple,
test_cesium_man,
test_entity_with_animated_model,
]
passed = 0
failed = 0
for test in tests:
try:
test()
passed += 1
except AssertionError as e:
print(f"[FAIL] {test.__name__}: {e}")
failed += 1
except Exception as e:
print(f"[ERROR] {test.__name__}: {e}")
failed += 1
print(f"\n=== Results: {passed} passed, {failed} failed ===")
return failed == 0
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)

View file

@ -0,0 +1,159 @@
# animation_test.py - Unit tests for Entity3D skeletal animation
import mcrfpy
import sys
def test_entity3d_animation_defaults():
"""Test Entity3D animation property defaults"""
entity = mcrfpy.Entity3D()
# Default animation state
assert entity.anim_clip == "", f"Expected empty anim_clip, got '{entity.anim_clip}'"
assert entity.anim_time == 0.0, f"Expected anim_time=0.0, got {entity.anim_time}"
assert entity.anim_speed == 1.0, f"Expected anim_speed=1.0, got {entity.anim_speed}"
assert entity.anim_loop == True, f"Expected anim_loop=True, got {entity.anim_loop}"
assert entity.anim_paused == False, f"Expected anim_paused=False, got {entity.anim_paused}"
assert entity.anim_frame == 0, f"Expected anim_frame=0, got {entity.anim_frame}"
# Auto-animate defaults
assert entity.auto_animate == True, f"Expected auto_animate=True, got {entity.auto_animate}"
assert entity.walk_clip == "walk", f"Expected walk_clip='walk', got '{entity.walk_clip}'"
assert entity.idle_clip == "idle", f"Expected idle_clip='idle', got '{entity.idle_clip}'"
print("[PASS] test_entity3d_animation_defaults")
def test_entity3d_animation_properties():
"""Test setting Entity3D animation properties"""
entity = mcrfpy.Entity3D()
# Set animation clip
entity.anim_clip = "test_anim"
assert entity.anim_clip == "test_anim", f"Expected 'test_anim', got '{entity.anim_clip}'"
# Set animation time
entity.anim_time = 1.5
assert abs(entity.anim_time - 1.5) < 0.001, f"Expected anim_time~=1.5, got {entity.anim_time}"
# Set animation speed
entity.anim_speed = 2.0
assert abs(entity.anim_speed - 2.0) < 0.001, f"Expected anim_speed~=2.0, got {entity.anim_speed}"
# Set loop
entity.anim_loop = False
assert entity.anim_loop == False, f"Expected anim_loop=False, got {entity.anim_loop}"
# Set paused
entity.anim_paused = True
assert entity.anim_paused == True, f"Expected anim_paused=True, got {entity.anim_paused}"
print("[PASS] test_entity3d_animation_properties")
def test_entity3d_auto_animate():
"""Test Entity3D auto-animate settings"""
entity = mcrfpy.Entity3D()
# Disable auto-animate
entity.auto_animate = False
assert entity.auto_animate == False
# Set custom clip names
entity.walk_clip = "run"
entity.idle_clip = "stand"
assert entity.walk_clip == "run"
assert entity.idle_clip == "stand"
print("[PASS] test_entity3d_auto_animate")
def test_entity3d_animation_callback():
"""Test Entity3D animation complete callback"""
entity = mcrfpy.Entity3D()
callback_called = [False]
callback_args = [None, None]
def on_complete(ent, clip_name):
callback_called[0] = True
callback_args[0] = ent
callback_args[1] = clip_name
# Set callback
entity.on_anim_complete = on_complete
assert entity.on_anim_complete is not None
# Clear callback
entity.on_anim_complete = None
# Should not raise error even though callback is None
print("[PASS] test_entity3d_animation_callback")
def test_entity3d_animation_callback_invalid():
"""Test that non-callable is rejected for animation callback"""
entity = mcrfpy.Entity3D()
try:
entity.on_anim_complete = "not a function"
assert False, "Should have raised TypeError"
except TypeError:
pass
print("[PASS] test_entity3d_animation_callback_invalid")
def test_entity3d_with_model():
"""Test Entity3D animation with a non-skeletal model"""
entity = mcrfpy.Entity3D()
cube = mcrfpy.Model3D.cube()
entity.model = cube
# Setting animation clip on non-skeletal model should not crash
entity.anim_clip = "walk" # Should just do nothing gracefully
assert entity.anim_clip == "walk" # The property is set even if model has no animation
# Frame should be 0 since there's no skeleton
assert entity.anim_frame == 0
print("[PASS] test_entity3d_with_model")
def test_entity3d_animation_negative_speed():
"""Test that animation speed can be negative (reverse playback)"""
entity = mcrfpy.Entity3D()
entity.anim_speed = -1.0
assert abs(entity.anim_speed - (-1.0)) < 0.001
entity.anim_speed = 0.0
assert entity.anim_speed == 0.0
print("[PASS] test_entity3d_animation_negative_speed")
def run_all_tests():
"""Run all animation tests"""
tests = [
test_entity3d_animation_defaults,
test_entity3d_animation_properties,
test_entity3d_auto_animate,
test_entity3d_animation_callback,
test_entity3d_animation_callback_invalid,
test_entity3d_with_model,
test_entity3d_animation_negative_speed,
]
passed = 0
failed = 0
for test in tests:
try:
test()
passed += 1
except AssertionError as e:
print(f"[FAIL] {test.__name__}: {e}")
failed += 1
except Exception as e:
print(f"[ERROR] {test.__name__}: {e}")
failed += 1
print(f"\n=== Results: {passed} passed, {failed} failed ===")
return failed == 0
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)

View file

@ -0,0 +1,260 @@
# billboard_test.py - Unit test for Billboard 3D camera-facing sprites
import mcrfpy
import sys
def test_billboard_creation():
"""Test basic Billboard creation and default properties"""
bb = mcrfpy.Billboard()
# Default sprite index
assert bb.sprite_index == 0, f"Expected sprite_index=0, got {bb.sprite_index}"
# Default position
assert bb.pos == (0.0, 0.0, 0.0), f"Expected pos=(0,0,0), got {bb.pos}"
# Default scale
assert bb.scale == 1.0, f"Expected scale=1.0, got {bb.scale}"
# Default facing mode
assert bb.facing == "camera_y", f"Expected facing='camera_y', got {bb.facing}"
# Default theta/phi (for fixed mode)
assert bb.theta == 0.0, f"Expected theta=0.0, got {bb.theta}"
assert bb.phi == 0.0, f"Expected phi=0.0, got {bb.phi}"
# Default opacity and visibility
assert bb.opacity == 1.0, f"Expected opacity=1.0, got {bb.opacity}"
assert bb.visible == True, f"Expected visible=True, got {bb.visible}"
print("[PASS] test_billboard_creation")
def test_billboard_with_kwargs():
"""Test Billboard creation with keyword arguments"""
bb = mcrfpy.Billboard(
sprite_index=5,
pos=(10.0, 5.0, -3.0),
scale=2.5,
facing="camera",
opacity=0.8
)
assert bb.sprite_index == 5, f"Expected sprite_index=5, got {bb.sprite_index}"
assert bb.pos == (10.0, 5.0, -3.0), f"Expected pos=(10,5,-3), got {bb.pos}"
assert bb.scale == 2.5, f"Expected scale=2.5, got {bb.scale}"
assert bb.facing == "camera", f"Expected facing='camera', got {bb.facing}"
assert abs(bb.opacity - 0.8) < 0.001, f"Expected opacity~=0.8, got {bb.opacity}"
print("[PASS] test_billboard_with_kwargs")
def test_billboard_facing_modes():
"""Test all Billboard facing modes"""
bb = mcrfpy.Billboard()
# Test camera mode (full rotation to face camera)
bb.facing = "camera"
assert bb.facing == "camera", f"Expected facing='camera', got {bb.facing}"
# Test camera_y mode (only Y-axis rotation, stays upright)
bb.facing = "camera_y"
assert bb.facing == "camera_y", f"Expected facing='camera_y', got {bb.facing}"
# Test fixed mode (uses theta/phi angles)
bb.facing = "fixed"
assert bb.facing == "fixed", f"Expected facing='fixed', got {bb.facing}"
print("[PASS] test_billboard_facing_modes")
def test_billboard_fixed_rotation():
"""Test fixed mode rotation angles (theta/phi)"""
bb = mcrfpy.Billboard(facing="fixed")
# Set theta (horizontal rotation)
bb.theta = 1.5708 # ~90 degrees
assert abs(bb.theta - 1.5708) < 0.001, f"Expected theta~=1.5708, got {bb.theta}"
# Set phi (vertical tilt)
bb.phi = 0.7854 # ~45 degrees
assert abs(bb.phi - 0.7854) < 0.001, f"Expected phi~=0.7854, got {bb.phi}"
print("[PASS] test_billboard_fixed_rotation")
def test_billboard_property_modification():
"""Test modifying Billboard properties after creation"""
bb = mcrfpy.Billboard()
# Modify position
bb.pos = (5.0, 10.0, 15.0)
assert bb.pos == (5.0, 10.0, 15.0), f"Expected pos=(5,10,15), got {bb.pos}"
# Modify sprite index
bb.sprite_index = 42
assert bb.sprite_index == 42, f"Expected sprite_index=42, got {bb.sprite_index}"
# Modify scale
bb.scale = 0.5
assert bb.scale == 0.5, f"Expected scale=0.5, got {bb.scale}"
# Modify opacity
bb.opacity = 0.25
assert abs(bb.opacity - 0.25) < 0.001, f"Expected opacity~=0.25, got {bb.opacity}"
# Modify visibility
bb.visible = False
assert bb.visible == False, f"Expected visible=False, got {bb.visible}"
print("[PASS] test_billboard_property_modification")
def test_billboard_opacity_clamping():
"""Test that opacity is clamped to 0-1 range"""
bb = mcrfpy.Billboard()
# Test upper clamp
bb.opacity = 2.0
assert bb.opacity == 1.0, f"Expected opacity=1.0 after clamping, got {bb.opacity}"
# Test lower clamp
bb.opacity = -0.5
assert bb.opacity == 0.0, f"Expected opacity=0.0 after clamping, got {bb.opacity}"
print("[PASS] test_billboard_opacity_clamping")
def test_billboard_repr():
"""Test Billboard string representation"""
bb = mcrfpy.Billboard(pos=(1.0, 2.0, 3.0), sprite_index=7, facing="camera")
repr_str = repr(bb)
# Check that repr contains expected information
assert "Billboard" in repr_str, f"Expected 'Billboard' in repr, got {repr_str}"
print("[PASS] test_billboard_repr")
def test_billboard_with_texture():
"""Test Billboard with texture assignment"""
# Use default_texture which is always available
tex = mcrfpy.default_texture
bb = mcrfpy.Billboard(texture=tex, sprite_index=0)
# Verify texture is assigned
assert bb.texture is not None, "Expected texture to be assigned"
assert bb.sprite_index == 0, f"Expected sprite_index=0, got {bb.sprite_index}"
# Change sprite index
bb.sprite_index = 10
assert bb.sprite_index == 10, f"Expected sprite_index=10, got {bb.sprite_index}"
# Test assigning texture via property
bb2 = mcrfpy.Billboard()
assert bb2.texture is None, "Expected no texture initially"
bb2.texture = tex
assert bb2.texture is not None, "Expected texture after assignment"
# Test setting texture to None
bb2.texture = None
assert bb2.texture is None, "Expected None after clearing texture"
print("[PASS] test_billboard_with_texture")
def test_viewport3d_billboard_methods():
"""Test Viewport3D billboard management methods"""
vp = mcrfpy.Viewport3D()
# Initial count should be 0
assert vp.billboard_count() == 0, f"Expected 0, got {vp.billboard_count()}"
# Add billboards
bb1 = mcrfpy.Billboard(pos=(1, 0, 1), scale=1.0)
vp.add_billboard(bb1)
assert vp.billboard_count() == 1, f"Expected 1, got {vp.billboard_count()}"
bb2 = mcrfpy.Billboard(pos=(2, 0, 2), scale=0.5)
vp.add_billboard(bb2)
assert vp.billboard_count() == 2, f"Expected 2, got {vp.billboard_count()}"
# Get billboard by index
retrieved = vp.get_billboard(0)
assert retrieved.pos == (1.0, 0.0, 1.0), f"Expected (1,0,1), got {retrieved.pos}"
# Modify retrieved billboard
retrieved.pos = (5, 1, 5)
assert retrieved.pos == (5.0, 1.0, 5.0), f"Expected (5,1,5), got {retrieved.pos}"
# Clear all billboards
vp.clear_billboards()
assert vp.billboard_count() == 0, f"Expected 0 after clear, got {vp.billboard_count()}"
print("[PASS] test_viewport3d_billboard_methods")
def test_viewport3d_billboard_index_bounds():
"""Test get_billboard index bounds checking"""
vp = mcrfpy.Viewport3D()
# Empty viewport - any index should fail
try:
vp.get_billboard(0)
assert False, "Should have raised IndexError"
except IndexError:
pass
# Add one billboard
bb = mcrfpy.Billboard()
vp.add_billboard(bb)
# Index 0 should work
vp.get_billboard(0)
# Index 1 should fail
try:
vp.get_billboard(1)
assert False, "Should have raised IndexError"
except IndexError:
pass
# Negative index should fail
try:
vp.get_billboard(-1)
assert False, "Should have raised IndexError"
except IndexError:
pass
print("[PASS] test_viewport3d_billboard_index_bounds")
def run_all_tests():
"""Run all Billboard tests"""
tests = [
test_billboard_creation,
test_billboard_with_kwargs,
test_billboard_facing_modes,
test_billboard_fixed_rotation,
test_billboard_property_modification,
test_billboard_opacity_clamping,
test_billboard_repr,
test_billboard_with_texture,
test_viewport3d_billboard_methods,
test_viewport3d_billboard_index_bounds,
]
passed = 0
failed = 0
skipped = 0
for test in tests:
try:
test()
passed += 1
except AssertionError as e:
print(f"[FAIL] {test.__name__}: {e}")
failed += 1
except Exception as e:
if "[SKIP]" in str(e):
skipped += 1
else:
print(f"[ERROR] {test.__name__}: {e}")
failed += 1
print(f"\n=== Results: {passed} passed, {failed} failed, {skipped} skipped ===")
return failed == 0
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)

293
tests/unit/entity3d_test.py Normal file
View file

@ -0,0 +1,293 @@
# entity3d_test.py - Unit test for Entity3D 3D game entities
import mcrfpy
import sys
def test_entity3d_creation():
"""Test basic Entity3D creation and default properties"""
e = mcrfpy.Entity3D()
# Default grid position (0, 0)
assert e.pos == (0, 0), f"Expected pos=(0, 0), got {e.pos}"
assert e.grid_pos == (0, 0), f"Expected grid_pos=(0, 0), got {e.grid_pos}"
# Default world position (at origin)
wp = e.world_pos
assert len(wp) == 3, f"Expected 3-tuple for world_pos, got {wp}"
assert wp[0] == 0.0, f"Expected world_pos.x=0, got {wp[0]}"
assert wp[2] == 0.0, f"Expected world_pos.z=0, got {wp[2]}"
# Default rotation
assert e.rotation == 0.0, f"Expected rotation=0, got {e.rotation}"
# Default scale
assert e.scale == 1.0, f"Expected scale=1.0, got {e.scale}"
# Default visibility
assert e.visible == True, f"Expected visible=True, got {e.visible}"
# Default color (orange: 200, 100, 50)
c = e.color
assert c.r == 200, f"Expected color.r=200, got {c.r}"
assert c.g == 100, f"Expected color.g=100, got {c.g}"
assert c.b == 50, f"Expected color.b=50, got {c.b}"
print("[PASS] test_entity3d_creation")
def test_entity3d_with_pos():
"""Test Entity3D creation with position argument"""
e = mcrfpy.Entity3D(pos=(5, 10))
assert e.pos == (5, 10), f"Expected pos=(5, 10), got {e.pos}"
assert e.grid_pos == (5, 10), f"Expected grid_pos=(5, 10), got {e.grid_pos}"
print("[PASS] test_entity3d_with_pos")
def test_entity3d_with_kwargs():
"""Test Entity3D creation with keyword arguments"""
e = mcrfpy.Entity3D(
pos=(3, 7),
rotation=90.0,
scale=2.0,
visible=False,
color=mcrfpy.Color(255, 0, 0)
)
assert e.pos == (3, 7), f"Expected pos=(3, 7), got {e.pos}"
assert e.rotation == 90.0, f"Expected rotation=90, got {e.rotation}"
assert e.scale == 2.0, f"Expected scale=2.0, got {e.scale}"
assert e.visible == False, f"Expected visible=False, got {e.visible}"
assert e.color.r == 255, f"Expected color.r=255, got {e.color.r}"
assert e.color.g == 0, f"Expected color.g=0, got {e.color.g}"
print("[PASS] test_entity3d_with_kwargs")
def test_entity3d_property_modification():
"""Test modifying Entity3D properties after creation"""
e = mcrfpy.Entity3D()
# Modify rotation
e.rotation = 180.0
assert e.rotation == 180.0, f"Expected rotation=180, got {e.rotation}"
# Modify scale
e.scale = 0.5
assert e.scale == 0.5, f"Expected scale=0.5, got {e.scale}"
# Modify visibility
e.visible = False
assert e.visible == False, f"Expected visible=False, got {e.visible}"
e.visible = True
assert e.visible == True, f"Expected visible=True, got {e.visible}"
# Modify color
e.color = mcrfpy.Color(0, 255, 128)
assert e.color.r == 0, f"Expected color.r=0, got {e.color.r}"
assert e.color.g == 255, f"Expected color.g=255, got {e.color.g}"
assert e.color.b == 128, f"Expected color.b=128, got {e.color.b}"
print("[PASS] test_entity3d_property_modification")
def test_entity3d_teleport():
"""Test Entity3D teleport method"""
e = mcrfpy.Entity3D(pos=(0, 0))
# Teleport to new position
e.teleport(15, 20)
assert e.pos == (15, 20), f"Expected pos=(15, 20), got {e.pos}"
assert e.grid_pos == (15, 20), f"Expected grid_pos=(15, 20), got {e.grid_pos}"
# World position should also update
wp = e.world_pos
# World position is grid * cell_size, but we don't know cell size here
# Just verify it changed from origin
assert wp[0] != 0.0 or wp[2] != 0.0, f"Expected world_pos to change, got {wp}"
print("[PASS] test_entity3d_teleport")
def test_entity3d_pos_setter():
"""Test setting position via pos property"""
e = mcrfpy.Entity3D(pos=(0, 0))
# Set position (this should trigger animated movement when in a viewport)
e.pos = (8, 12)
# The grid position should update
assert e.pos == (8, 12), f"Expected pos=(8, 12), got {e.pos}"
print("[PASS] test_entity3d_pos_setter")
def test_entity3d_repr():
"""Test Entity3D string representation"""
e = mcrfpy.Entity3D(pos=(5, 10))
e.rotation = 45.0
repr_str = repr(e)
assert "Entity3D" in repr_str, f"Expected 'Entity3D' in repr, got {repr_str}"
assert "5" in repr_str, f"Expected grid_x in repr, got {repr_str}"
assert "10" in repr_str, f"Expected grid_z in repr, got {repr_str}"
print("[PASS] test_entity3d_repr")
def test_entity3d_viewport_integration():
"""Test adding Entity3D to a Viewport3D"""
# Create viewport with navigation grid
vp = mcrfpy.Viewport3D()
vp.set_grid_size(32, 32)
# Create entity
e = mcrfpy.Entity3D(pos=(5, 5))
# Verify entity has no viewport initially
assert e.viewport is None, f"Expected viewport=None before adding, got {e.viewport}"
# Add to viewport
vp.entities.append(e)
# Verify entity count
assert len(vp.entities) == 1, f"Expected 1 entity, got {len(vp.entities)}"
# Verify entity was linked to viewport
# Note: viewport property may not be set until render cycle
# For now, just verify the entity is in the collection
retrieved = vp.entities[0]
assert retrieved.pos == (5, 5), f"Expected retrieved entity at (5, 5), got {retrieved.pos}"
print("[PASS] test_entity3d_viewport_integration")
def test_entitycollection3d_operations():
"""Test EntityCollection3D sequence operations"""
vp = mcrfpy.Viewport3D()
vp.set_grid_size(20, 20)
# Initially empty
assert len(vp.entities) == 0, f"Expected 0 entities initially, got {len(vp.entities)}"
# Add multiple entities
e1 = mcrfpy.Entity3D(pos=(1, 1))
e2 = mcrfpy.Entity3D(pos=(5, 5))
e3 = mcrfpy.Entity3D(pos=(10, 10))
vp.entities.append(e1)
vp.entities.append(e2)
vp.entities.append(e3)
assert len(vp.entities) == 3, f"Expected 3 entities, got {len(vp.entities)}"
# Access by index
assert vp.entities[0].pos == (1, 1), f"Expected entities[0] at (1,1)"
assert vp.entities[1].pos == (5, 5), f"Expected entities[1] at (5,5)"
assert vp.entities[2].pos == (10, 10), f"Expected entities[2] at (10,10)"
# Negative indexing
assert vp.entities[-1].pos == (10, 10), f"Expected entities[-1] at (10,10)"
# Contains check
assert e2 in vp.entities, "Expected e2 in entities"
# Iteration
positions = [e.pos for e in vp.entities]
assert (1, 1) in positions, "Expected (1,1) in iterated positions"
assert (5, 5) in positions, "Expected (5,5) in iterated positions"
# Remove
vp.entities.remove(e2)
assert len(vp.entities) == 2, f"Expected 2 entities after remove, got {len(vp.entities)}"
assert e2 not in vp.entities, "Expected e2 not in entities after remove"
# Clear
vp.entities.clear()
assert len(vp.entities) == 0, f"Expected 0 entities after clear, got {len(vp.entities)}"
print("[PASS] test_entitycollection3d_operations")
def test_entity3d_scene_integration():
"""Test Entity3D works when viewport is in a scene"""
scene = mcrfpy.Scene("entity3d_test")
vp = mcrfpy.Viewport3D(pos=(0, 0), size=(640, 480))
vp.set_grid_size(32, 32)
# Add viewport to scene
scene.children.append(vp)
# Add entity to viewport
e = mcrfpy.Entity3D(pos=(16, 16), rotation=45.0, color=mcrfpy.Color(0, 255, 0))
vp.entities.append(e)
# Verify everything is connected
assert len(scene.children) == 1, "Expected 1 child in scene"
viewport_from_scene = scene.children[0]
assert type(viewport_from_scene).__name__ == "Viewport3D"
assert len(viewport_from_scene.entities) == 1, "Expected 1 entity in viewport"
entity_from_vp = viewport_from_scene.entities[0]
assert entity_from_vp.pos == (16, 16), f"Expected entity at (16, 16), got {entity_from_vp.pos}"
assert entity_from_vp.rotation == 45.0, f"Expected rotation=45, got {entity_from_vp.rotation}"
print("[PASS] test_entity3d_scene_integration")
def test_entity3d_multiple_entities():
"""Test multiple entities in a viewport"""
vp = mcrfpy.Viewport3D()
vp.set_grid_size(50, 50)
# Create a grid of entities
entities = []
for x in range(0, 50, 10):
for z in range(0, 50, 10):
e = mcrfpy.Entity3D(pos=(x, z))
e.color = mcrfpy.Color(x * 5, z * 5, 128)
entities.append(e)
vp.entities.append(e)
expected_count = 5 * 5 # 0, 10, 20, 30, 40 for both x and z
assert len(vp.entities) == expected_count, f"Expected {expected_count} entities, got {len(vp.entities)}"
# Verify we can access all entities
found_positions = set()
for e in vp.entities:
found_positions.add(e.pos)
assert len(found_positions) == expected_count, f"Expected {expected_count} unique positions"
print("[PASS] test_entity3d_multiple_entities")
def run_all_tests():
"""Run all Entity3D tests"""
tests = [
test_entity3d_creation,
test_entity3d_with_pos,
test_entity3d_with_kwargs,
test_entity3d_property_modification,
test_entity3d_teleport,
test_entity3d_pos_setter,
test_entity3d_repr,
test_entity3d_viewport_integration,
test_entitycollection3d_operations,
test_entity3d_scene_integration,
test_entity3d_multiple_entities,
]
passed = 0
failed = 0
for test in tests:
try:
test()
passed += 1
except AssertionError as e:
print(f"[FAIL] {test.__name__}: {e}")
failed += 1
except Exception as e:
print(f"[ERROR] {test.__name__}: {type(e).__name__}: {e}")
failed += 1
print(f"\n=== Results: {passed} passed, {failed} failed ===")
return failed == 0
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)

198
tests/unit/fov_3d_test.py Normal file
View file

@ -0,0 +1,198 @@
# fov_3d_test.py - Unit tests for 3D field of view
# Tests FOV computation on VoxelPoint navigation grid
import mcrfpy
import sys
def test_basic_fov():
"""Test basic FOV computation"""
print("Testing basic FOV...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
viewport.grid_size = (20, 20)
# Compute FOV from center
visible = viewport.compute_fov((10, 10), radius=5)
# Should have visible cells
assert len(visible) > 0, "Expected visible cells"
# Origin should be visible
assert (10, 10) in visible, "Origin should be visible"
# Cells within radius should be visible
assert (10, 11) in visible, "(10, 11) should be visible"
assert (11, 10) in visible, "(11, 10) should be visible"
# Cells outside radius should not be visible
assert (10, 20) not in visible, "(10, 20) should not be visible"
assert (0, 0) not in visible, "(0, 0) should not be visible"
print(f" PASS: Basic FOV ({len(visible)} cells visible)")
def test_fov_with_walls():
"""Test FOV blocked by opaque cells"""
print("Testing FOV with walls...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
viewport.grid_size = (20, 20)
# Create a wall blocking line of sight
# Wall at x=12
for z in range(5, 16):
viewport.at(12, z).transparent = False
# Compute FOV from (10, 10)
visible = viewport.compute_fov((10, 10), radius=10)
# Origin should be visible
assert (10, 10) in visible, "Origin should be visible"
# Cells before wall should be visible
assert (11, 10) in visible, "Cell before wall should be visible"
# Wall cells themselves might be visible (at the edge)
# But cells behind wall should NOT be visible
# Note: Exact behavior depends on FOV algorithm
# Cells well behind the wall should not be visible
# (18, 10) is 6 cells behind the wall
assert (18, 10) not in visible, "Cell behind wall should not be visible"
print(f" PASS: FOV with walls ({len(visible)} cells visible)")
def test_fov_radius():
"""Test FOV respects radius"""
print("Testing FOV radius...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
viewport.grid_size = (30, 30)
# Compute small FOV
visible_small = viewport.compute_fov((15, 15), radius=3)
# Compute larger FOV
visible_large = viewport.compute_fov((15, 15), radius=8)
# Larger radius should reveal more cells
assert len(visible_large) > len(visible_small), \
f"Larger radius should reveal more cells ({len(visible_large)} vs {len(visible_small)})"
# Small FOV cells should be subset of large FOV
for cell in visible_small:
assert cell in visible_large, f"{cell} in small FOV but not in large FOV"
print(f" PASS: FOV radius (small={len(visible_small)}, large={len(visible_large)})")
def test_is_in_fov():
"""Test is_in_fov() method"""
print("Testing is_in_fov...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
viewport.grid_size = (20, 20)
# Compute FOV
viewport.compute_fov((10, 10), radius=5)
# Check is_in_fov matches compute_fov results
assert viewport.is_in_fov(10, 10) == True, "Origin should be in FOV"
assert viewport.is_in_fov(10, 11) == True, "Adjacent cell should be in FOV"
assert viewport.is_in_fov(0, 0) == False, "Distant cell should not be in FOV"
print(" PASS: is_in_fov method")
def test_fov_corner():
"""Test FOV from corner position"""
print("Testing FOV from corner...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
viewport.grid_size = (20, 20)
# Compute FOV from corner
visible = viewport.compute_fov((0, 0), radius=5)
# Origin should be visible
assert (0, 0) in visible, "Origin should be visible"
# Cells in direction of grid should be visible
assert (1, 0) in visible, "(1, 0) should be visible"
assert (0, 1) in visible, "(0, 1) should be visible"
# Should handle edge of grid gracefully
# Shouldn't crash or have negative coordinates
print(f" PASS: FOV from corner ({len(visible)} cells visible)")
def test_fov_empty_grid():
"""Test FOV on uninitialized grid"""
print("Testing FOV on empty grid...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
# Grid size is 0x0 by default
# Compute FOV should return empty list or handle gracefully
visible = viewport.compute_fov((0, 0), radius=5)
assert len(visible) == 0, "FOV on empty grid should return empty list"
print(" PASS: FOV on empty grid")
def test_multiple_fov_calls():
"""Test that multiple FOV calls work correctly"""
print("Testing multiple FOV calls...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
viewport.grid_size = (20, 20)
# First FOV from (5, 5)
visible1 = viewport.compute_fov((5, 5), radius=4)
assert (5, 5) in visible1, "First origin should be visible"
# Second FOV from (15, 15)
visible2 = viewport.compute_fov((15, 15), radius=4)
assert (15, 15) in visible2, "Second origin should be visible"
# is_in_fov should reflect the LAST computed FOV
assert viewport.is_in_fov(15, 15) == True, "Last origin should be in FOV"
# Note: (5, 5) might not be in FOV anymore depending on radius
print(" PASS: Multiple FOV calls")
def run_all_tests():
"""Run all unit tests"""
print("=" * 60)
print("3D FOV Unit Tests")
print("=" * 60)
try:
test_basic_fov()
test_fov_with_walls()
test_fov_radius()
test_is_in_fov()
test_fov_corner()
test_fov_empty_grid()
test_multiple_fov_calls()
print("=" * 60)
print("ALL TESTS PASSED")
print("=" * 60)
sys.exit(0)
except AssertionError as e:
print(f"FAIL: {e}")
sys.exit(1)
except Exception as e:
print(f"ERROR: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
# Run tests
run_all_tests()

View file

@ -0,0 +1,78 @@
# integration_api_test.py - Test Milestone 8 API additions
# Tests: Entity3D.follow_path, .is_moving, .clear_path
# Viewport3D.screen_to_world, .follow
import mcrfpy
import sys
print("Testing Milestone 8 API additions...")
# Create test scene
scene = mcrfpy.Scene("test")
# Create viewport
viewport = mcrfpy.Viewport3D(
pos=(0, 0),
size=(800, 600),
render_resolution=(320, 240),
fov=60.0,
camera_pos=(10.0, 10.0, 10.0),
camera_target=(5.0, 0.0, 5.0)
)
scene.children.append(viewport)
# Set up navigation grid
viewport.set_grid_size(20, 20)
# Create entity
entity = mcrfpy.Entity3D(pos=(5, 5))
viewport.entities.append(entity)
# Test 1: is_moving property (should be False initially)
print(f"Test 1: is_moving = {entity.is_moving}")
assert entity.is_moving == False, "Entity should not be moving initially"
print(" PASS: is_moving is False initially")
# Test 2: follow_path method
path = [(6, 5), (7, 5), (8, 5)]
entity.follow_path(path)
print(f"Test 2: follow_path({path})")
# After follow_path, entity should be moving (or at least have queued moves)
print(f" is_moving after follow_path = {entity.is_moving}")
assert entity.is_moving == True, "Entity should be moving after follow_path"
print(" PASS: follow_path queued movement")
# Test 3: clear_path method
entity.clear_path()
print("Test 3: clear_path()")
print(f" is_moving after clear_path = {entity.is_moving}")
# Note: is_moving may still be True if animation is in progress
print(" PASS: clear_path executed without error")
# Test 4: screen_to_world
world_pos = viewport.screen_to_world(400, 300)
print(f"Test 4: screen_to_world(400, 300) = {world_pos}")
if world_pos is None:
print(" WARNING: screen_to_world returned None (ray missed ground)")
else:
assert len(world_pos) == 3, "Should return (x, y, z) tuple"
print(f" PASS: Got world position {world_pos}")
# Test 5: follow method
viewport.follow(entity, distance=8.0, height=5.0)
print("Test 5: follow(entity, distance=8, height=5)")
cam_pos = viewport.camera_pos
print(f" Camera position after follow: {cam_pos}")
print(" PASS: follow executed without error")
# Test 6: path_to (existing method)
path = entity.path_to(10, 10)
print(f"Test 6: path_to(10, 10) = {path[:3]}..." if len(path) > 3 else f"Test 6: path_to(10, 10) = {path}")
print(" PASS: path_to works")
print()
print("=" * 50)
print("All Milestone 8 API tests PASSED!")
print("=" * 50)
sys.exit(0)

View file

@ -0,0 +1,182 @@
# mesh_instance_test.py - Unit test for MeshLayer mesh instances and Viewport3D mesh APIs
import mcrfpy
import sys
def test_viewport3d_add_mesh():
"""Test adding meshes to Viewport3D layers"""
vp = mcrfpy.Viewport3D()
# Add a mesh layer first
vp.add_layer("ground", z_index=0)
# Create a model to place (simple cube primitive)
model = mcrfpy.Model3D()
# Add mesh instance at position
result = vp.add_mesh("ground", model, pos=(5.0, 0.0, 5.0))
# Should return the index of the added mesh
assert result is not None, "Expected add_mesh to return something"
assert isinstance(result, int), f"Expected int index, got {type(result)}"
assert result == 0, f"Expected first mesh index 0, got {result}"
print("[PASS] test_viewport3d_add_mesh")
def test_viewport3d_add_mesh_with_transform():
"""Test adding meshes with rotation and scale"""
vp = mcrfpy.Viewport3D()
vp.add_layer("buildings", z_index=0)
model = mcrfpy.Model3D()
# Add with rotation (in degrees as per API)
idx1 = vp.add_mesh("buildings", model, pos=(10.0, 0.0, 10.0), rotation=90)
assert idx1 == 0, f"Expected first mesh index 0, got {idx1}"
# Add with scale
idx2 = vp.add_mesh("buildings", model, pos=(15.0, 0.0, 15.0), scale=2.0)
assert idx2 == 1, f"Expected second mesh index 1, got {idx2}"
# Add with both rotation and scale
idx3 = vp.add_mesh("buildings", model, pos=(5.0, 0.0, 5.0), rotation=45, scale=0.5)
assert idx3 == 2, f"Expected third mesh index 2, got {idx3}"
print("[PASS] test_viewport3d_add_mesh_with_transform")
def test_viewport3d_clear_meshes():
"""Test clearing meshes from a layer"""
vp = mcrfpy.Viewport3D()
vp.add_layer("objects", z_index=0)
model = mcrfpy.Model3D()
# Add several meshes
vp.add_mesh("objects", model, pos=(1.0, 0.0, 1.0))
vp.add_mesh("objects", model, pos=(2.0, 0.0, 2.0))
vp.add_mesh("objects", model, pos=(3.0, 0.0, 3.0))
# Clear meshes from layer
vp.clear_meshes("objects")
# Add a new mesh - should get index 0 since list was cleared
idx = vp.add_mesh("objects", model, pos=(0.0, 0.0, 0.0))
assert idx == 0, f"Expected index 0 after clear, got {idx}"
print("[PASS] test_viewport3d_clear_meshes")
def test_viewport3d_place_blocking():
"""Test placing blocking information on the navigation grid"""
vp = mcrfpy.Viewport3D()
# Initialize navigation grid first
vp.set_grid_size(width=16, depth=16)
# Place blocking cell (unwalkable, non-transparent)
vp.place_blocking(grid_pos=(5, 5), footprint=(1, 1))
# Place larger blocking footprint
vp.place_blocking(grid_pos=(10, 10), footprint=(2, 2))
# Place blocking with custom walkability
vp.place_blocking(grid_pos=(0, 0), footprint=(3, 3), walkable=False, transparent=True)
# Verify the cells were marked (check via VoxelPoint)
cell = vp.at(5, 5)
assert cell.walkable == False, f"Expected cell (5,5) unwalkable, got walkable={cell.walkable}"
cell_transparent = vp.at(0, 0)
assert cell_transparent.transparent == True, f"Expected cell (0,0) transparent"
print("[PASS] test_viewport3d_place_blocking")
def test_viewport3d_mesh_layer_operations():
"""Test various mesh layer operations"""
vp = mcrfpy.Viewport3D()
# Create multiple layers
vp.add_layer("floor", z_index=0)
vp.add_layer("walls", z_index=1)
vp.add_layer("props", z_index=2)
model = mcrfpy.Model3D()
# Add meshes to different layers
vp.add_mesh("floor", model, pos=(0.0, 0.0, 0.0))
vp.add_mesh("walls", model, pos=(1.0, 1.0, 0.0), rotation=0, scale=1.5)
vp.add_mesh("props", model, pos=(2.0, 0.0, 2.0), scale=0.25)
# Clear only one layer
vp.clear_meshes("walls")
# Other layers should be unaffected
# (Can verify by adding to them and checking indices)
idx_floor = vp.add_mesh("floor", model, pos=(5.0, 0.0, 5.0))
assert idx_floor == 1, f"Expected floor mesh index 1, got {idx_floor}"
idx_walls = vp.add_mesh("walls", model, pos=(5.0, 0.0, 5.0))
assert idx_walls == 0, f"Expected walls mesh index 0 after clear, got {idx_walls}"
print("[PASS] test_viewport3d_mesh_layer_operations")
def test_auto_layer_creation():
"""Test that add_mesh auto-creates layers if they don't exist"""
vp = mcrfpy.Viewport3D()
model = mcrfpy.Model3D()
# Add mesh to a layer that doesn't exist yet - should auto-create it
idx = vp.add_mesh("auto_created", model, pos=(0.0, 0.0, 0.0))
assert idx == 0, f"Expected index 0 for auto-created layer, got {idx}"
# Verify the layer was created
layer = vp.get_layer("auto_created")
assert layer is not None, "Expected auto_created layer to exist"
print("[PASS] test_auto_layer_creation")
def test_invalid_layer_clear():
"""Test error handling for clearing non-existent layers"""
vp = mcrfpy.Viewport3D()
# Try to clear meshes from non-existent layer
try:
vp.clear_meshes("nonexistent")
# If it doesn't raise, it might just silently succeed (which is fine too)
print("[PASS] test_invalid_layer_clear (no exception)")
return
except (ValueError, KeyError, RuntimeError):
print("[PASS] test_invalid_layer_clear (exception raised)")
return
def run_all_tests():
"""Run all mesh instance tests"""
tests = [
test_viewport3d_add_mesh,
test_viewport3d_add_mesh_with_transform,
test_viewport3d_clear_meshes,
test_viewport3d_place_blocking,
test_viewport3d_mesh_layer_operations,
test_auto_layer_creation,
test_invalid_layer_clear,
]
passed = 0
failed = 0
for test in tests:
try:
test()
passed += 1
except AssertionError as e:
print(f"[FAIL] {test.__name__}: {e}")
failed += 1
except Exception as e:
print(f"[ERROR] {test.__name__}: {e}")
failed += 1
print(f"\n=== Results: {passed} passed, {failed} failed ===")
return failed == 0
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)

View file

@ -0,0 +1,202 @@
# meshlayer_test.py - Unit tests for MeshLayer terrain system
# Tests HeightMap to 3D mesh conversion via Viewport3D
import mcrfpy
import sys
def test_viewport3d_layer_creation():
"""Test that layers can be created and managed"""
print("Testing Viewport3D layer creation...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
# Initial layer count should be 0
assert viewport.layer_count() == 0, f"Expected 0 layers, got {viewport.layer_count()}"
# Add a layer
layer_info = viewport.add_layer("test_layer", z_index=5)
assert layer_info is not None, "add_layer returned None"
assert layer_info["name"] == "test_layer", f"Layer name mismatch: {layer_info['name']}"
assert layer_info["z_index"] == 5, f"Z-index mismatch: {layer_info['z_index']}"
# Layer count should be 1
assert viewport.layer_count() == 1, f"Expected 1 layer, got {viewport.layer_count()}"
# Get the layer
retrieved = viewport.get_layer("test_layer")
assert retrieved is not None, "get_layer returned None"
assert retrieved["name"] == "test_layer"
# Get non-existent layer
missing = viewport.get_layer("nonexistent")
assert missing is None, "Expected None for missing layer"
# Remove the layer
removed = viewport.remove_layer("test_layer")
assert removed == True, "remove_layer should return True"
assert viewport.layer_count() == 0, "Layer count should be 0 after removal"
# Remove non-existent layer
removed_again = viewport.remove_layer("test_layer")
assert removed_again == False, "remove_layer should return False for missing layer"
print(" PASS: Layer creation and management")
def test_terrain_from_heightmap():
"""Test building terrain mesh from HeightMap"""
print("Testing terrain mesh from HeightMap...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
# Create a small heightmap
hm = mcrfpy.HeightMap((10, 10))
hm.fill(0.5) # Flat terrain at 0.5 height
# Build terrain
vertex_count = viewport.build_terrain(
layer_name="terrain",
heightmap=hm,
y_scale=2.0,
cell_size=1.0
)
# Expected vertices: (10-1) x (10-1) quads x 2 triangles x 3 vertices = 9 * 9 * 6 = 486
expected_verts = 9 * 9 * 6
assert vertex_count == expected_verts, f"Expected {expected_verts} vertices, got {vertex_count}"
# Verify layer exists
layer = viewport.get_layer("terrain")
assert layer is not None, "Terrain layer not found"
assert layer["vertex_count"] == expected_verts
print(f" PASS: Built terrain with {vertex_count} vertices")
def test_heightmap_terrain_generation():
"""Test that HeightMap generation methods work with terrain"""
print("Testing HeightMap generation methods...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
# Test midpoint displacement
hm = mcrfpy.HeightMap((17, 17)) # Power of 2 + 1 for midpoint displacement
hm.mid_point_displacement(0.5, seed=123)
hm.normalize(0.0, 1.0)
min_h, max_h = hm.min_max()
assert min_h >= 0.0, f"Min height should be >= 0, got {min_h}"
assert max_h <= 1.0, f"Max height should be <= 1, got {max_h}"
vertex_count = viewport.build_terrain("terrain", hm, y_scale=5.0, cell_size=1.0)
assert vertex_count > 0, "Should have vertices"
print(f" PASS: Midpoint displacement terrain with {vertex_count} vertices")
def test_orbit_camera():
"""Test camera orbit helper"""
print("Testing camera orbit...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
# Test orbit at different angles
import math
viewport.orbit_camera(angle=0, distance=10, height=5)
pos = viewport.camera_pos
assert abs(pos[0] - 10.0) < 0.01, f"X should be 10 at angle=0, got {pos[0]}"
assert abs(pos[1] - 5.0) < 0.01, f"Y (height) should be 5, got {pos[1]}"
assert abs(pos[2]) < 0.01, f"Z should be 0 at angle=0, got {pos[2]}"
viewport.orbit_camera(angle=math.pi/2, distance=10, height=5)
pos = viewport.camera_pos
assert abs(pos[0]) < 0.01, f"X should be 0 at angle=pi/2, got {pos[0]}"
assert abs(pos[2] - 10.0) < 0.01, f"Z should be 10 at angle=pi/2, got {pos[2]}"
print(" PASS: Camera orbit positioning")
def test_large_terrain():
"""Test larger terrain (performance check)"""
print("Testing larger terrain mesh...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
# 80x45 is mentioned in the milestone doc
hm = mcrfpy.HeightMap((80, 45))
hm.mid_point_displacement(0.5, seed=999)
hm.normalize(0.0, 1.0)
vertex_count = viewport.build_terrain("large_terrain", hm, y_scale=4.0, cell_size=1.0)
# Expected: 79 * 44 * 6 = 20,856 vertices
expected = 79 * 44 * 6
assert vertex_count == expected, f"Expected {expected} vertices, got {vertex_count}"
print(f" PASS: Large terrain ({80}x{45} heightmap) with {vertex_count} vertices")
def test_terrain_color_map():
"""Test applying RGB color maps to terrain"""
print("Testing terrain color map...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
# Create small terrain
hm = mcrfpy.HeightMap((10, 10))
hm.fill(0.5)
viewport.build_terrain("colored_terrain", hm, y_scale=2.0, cell_size=1.0)
# Create RGB color maps
r_map = mcrfpy.HeightMap((10, 10))
g_map = mcrfpy.HeightMap((10, 10))
b_map = mcrfpy.HeightMap((10, 10))
# Fill with test colors (red terrain)
r_map.fill(1.0)
g_map.fill(0.0)
b_map.fill(0.0)
# Apply colors - should not raise
viewport.apply_terrain_colors("colored_terrain", r_map, g_map, b_map)
# Test with mismatched dimensions (should fail silently or raise)
wrong_size = mcrfpy.HeightMap((5, 5))
wrong_size.fill(0.5)
# This should not crash, just do nothing due to dimension mismatch
viewport.apply_terrain_colors("colored_terrain", wrong_size, wrong_size, wrong_size)
# Test with non-existent layer
try:
viewport.apply_terrain_colors("nonexistent", r_map, g_map, b_map)
assert False, "Should have raised ValueError for non-existent layer"
except ValueError:
pass # Expected
print(" PASS: Terrain color map application")
def run_all_tests():
"""Run all unit tests"""
print("=" * 60)
print("MeshLayer Unit Tests")
print("=" * 60)
try:
test_viewport3d_layer_creation()
test_terrain_from_heightmap()
test_heightmap_terrain_generation()
test_orbit_camera()
test_large_terrain()
test_terrain_color_map()
print("=" * 60)
print("ALL TESTS PASSED")
print("=" * 60)
sys.exit(0)
except AssertionError as e:
print(f"FAIL: {e}")
sys.exit(1)
except Exception as e:
print(f"ERROR: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
# Run tests
run_all_tests()

219
tests/unit/model3d_test.py Normal file
View file

@ -0,0 +1,219 @@
# model3d_test.py - Unit test for Model3D 3D model resource
import mcrfpy
import sys
def test_model3d_cube():
"""Test Model3D.cube() creates valid model"""
cube = mcrfpy.Model3D.cube(2.0)
assert cube.name == "cube", f"Expected name='cube', got '{cube.name}'"
assert cube.vertex_count == 24, f"Expected 24 vertices, got {cube.vertex_count}"
assert cube.triangle_count == 12, f"Expected 12 triangles, got {cube.triangle_count}"
assert cube.has_skeleton == False, f"Expected has_skeleton=False, got {cube.has_skeleton}"
assert cube.mesh_count == 1, f"Expected 1 mesh, got {cube.mesh_count}"
# Check bounds for size=2.0 cube
bounds = cube.bounds
assert bounds is not None, "Bounds should not be None"
min_b, max_b = bounds
assert min_b == (-1.0, -1.0, -1.0), f"Expected min=(-1,-1,-1), got {min_b}"
assert max_b == (1.0, 1.0, 1.0), f"Expected max=(1,1,1), got {max_b}"
print("[PASS] test_model3d_cube")
def test_model3d_cube_default_size():
"""Test Model3D.cube() with default size"""
cube = mcrfpy.Model3D.cube()
# Default size is 1.0, so bounds should be -0.5 to 0.5
bounds = cube.bounds
min_b, max_b = bounds
assert abs(min_b[0] - (-0.5)) < 0.001, f"Expected min.x=-0.5, got {min_b[0]}"
assert abs(max_b[0] - 0.5) < 0.001, f"Expected max.x=0.5, got {max_b[0]}"
print("[PASS] test_model3d_cube_default_size")
def test_model3d_plane():
"""Test Model3D.plane() creates valid model"""
plane = mcrfpy.Model3D.plane(4.0, 2.0, 2)
assert plane.name == "plane", f"Expected name='plane', got '{plane.name}'"
# 2 segments = 3x3 grid = 9 vertices
assert plane.vertex_count == 9, f"Expected 9 vertices, got {plane.vertex_count}"
# 2x2 quads = 8 triangles
assert plane.triangle_count == 8, f"Expected 8 triangles, got {plane.triangle_count}"
assert plane.has_skeleton == False, f"Expected has_skeleton=False"
# Bounds should be width/2, 0, depth/2
bounds = plane.bounds
min_b, max_b = bounds
assert abs(min_b[0] - (-2.0)) < 0.001, f"Expected min.x=-2, got {min_b[0]}"
assert abs(max_b[0] - 2.0) < 0.001, f"Expected max.x=2, got {max_b[0]}"
assert abs(min_b[2] - (-1.0)) < 0.001, f"Expected min.z=-1, got {min_b[2]}"
assert abs(max_b[2] - 1.0) < 0.001, f"Expected max.z=1, got {max_b[2]}"
print("[PASS] test_model3d_plane")
def test_model3d_plane_default():
"""Test Model3D.plane() with default parameters"""
plane = mcrfpy.Model3D.plane()
# Default is 1x1 with 1 segment = 4 vertices, 2 triangles
assert plane.vertex_count == 4, f"Expected 4 vertices, got {plane.vertex_count}"
assert plane.triangle_count == 2, f"Expected 2 triangles, got {plane.triangle_count}"
print("[PASS] test_model3d_plane_default")
def test_model3d_sphere():
"""Test Model3D.sphere() creates valid model"""
sphere = mcrfpy.Model3D.sphere(1.0, 8, 6)
assert sphere.name == "sphere", f"Expected name='sphere', got '{sphere.name}'"
# vertices = (segments+1) * (rings+1) = 9 * 7 = 63
assert sphere.vertex_count == 63, f"Expected 63 vertices, got {sphere.vertex_count}"
# triangles = 2 * segments * rings = 2 * 8 * 6 = 96
assert sphere.triangle_count == 96, f"Expected 96 triangles, got {sphere.triangle_count}"
# Bounds should be radius in all directions
bounds = sphere.bounds
min_b, max_b = bounds
assert abs(min_b[0] - (-1.0)) < 0.001, f"Expected min.x=-1, got {min_b[0]}"
assert abs(max_b[0] - 1.0) < 0.001, f"Expected max.x=1, got {max_b[0]}"
print("[PASS] test_model3d_sphere")
def test_model3d_sphere_default():
"""Test Model3D.sphere() with default parameters"""
sphere = mcrfpy.Model3D.sphere()
# Default radius=0.5, segments=16, rings=12
# vertices = 17 * 13 = 221
assert sphere.vertex_count == 221, f"Expected 221 vertices, got {sphere.vertex_count}"
# triangles = 2 * 16 * 12 = 384
assert sphere.triangle_count == 384, f"Expected 384 triangles, got {sphere.triangle_count}"
print("[PASS] test_model3d_sphere_default")
def test_model3d_empty():
"""Test creating empty Model3D"""
empty = mcrfpy.Model3D()
assert empty.name == "unnamed", f"Expected name='unnamed', got '{empty.name}'"
assert empty.vertex_count == 0, f"Expected 0 vertices, got {empty.vertex_count}"
assert empty.triangle_count == 0, f"Expected 0 triangles, got {empty.triangle_count}"
assert empty.mesh_count == 0, f"Expected 0 meshes, got {empty.mesh_count}"
print("[PASS] test_model3d_empty")
def test_model3d_repr():
"""Test Model3D string representation"""
cube = mcrfpy.Model3D.cube()
repr_str = repr(cube)
assert "Model3D" in repr_str, f"Expected 'Model3D' in repr, got {repr_str}"
assert "cube" in repr_str, f"Expected 'cube' in repr, got {repr_str}"
assert "24" in repr_str, f"Expected vertex count in repr, got {repr_str}"
print("[PASS] test_model3d_repr")
def test_entity3d_model_property():
"""Test Entity3D.model property"""
e = mcrfpy.Entity3D(pos=(0, 0))
# Initially no model
assert e.model is None, f"Expected model=None, got {e.model}"
# Assign model
cube = mcrfpy.Model3D.cube()
e.model = cube
assert e.model is not None, "Expected model to be set"
assert e.model.name == "cube", f"Expected model.name='cube', got {e.model.name}"
# Swap model
sphere = mcrfpy.Model3D.sphere()
e.model = sphere
assert e.model.name == "sphere", f"Expected model.name='sphere', got {e.model.name}"
# Clear model
e.model = None
assert e.model is None, f"Expected model=None after clearing"
print("[PASS] test_entity3d_model_property")
def test_entity3d_model_type_error():
"""Test Entity3D.model raises TypeError for invalid input"""
e = mcrfpy.Entity3D()
try:
e.model = "not a model"
print("[FAIL] test_entity3d_model_type_error: Expected TypeError")
return
except TypeError:
pass
try:
e.model = 123
print("[FAIL] test_entity3d_model_type_error: Expected TypeError")
return
except TypeError:
pass
print("[PASS] test_entity3d_model_type_error")
def test_entity3d_with_model_in_viewport():
"""Test Entity3D with model in a Viewport3D"""
vp = mcrfpy.Viewport3D()
vp.set_grid_size(16, 16)
# Create entity with model
cube = mcrfpy.Model3D.cube(0.5)
e = mcrfpy.Entity3D(pos=(8, 8))
e.model = cube
# Add to viewport
vp.entities.append(e)
# Verify model is preserved
retrieved = vp.entities[0]
assert retrieved.model is not None, "Expected model to be preserved"
assert retrieved.model.name == "cube", f"Expected model.name='cube', got {retrieved.model.name}"
print("[PASS] test_entity3d_with_model_in_viewport")
def run_all_tests():
"""Run all Model3D tests"""
tests = [
test_model3d_cube,
test_model3d_cube_default_size,
test_model3d_plane,
test_model3d_plane_default,
test_model3d_sphere,
test_model3d_sphere_default,
test_model3d_empty,
test_model3d_repr,
test_entity3d_model_property,
test_entity3d_model_type_error,
test_entity3d_with_model_in_viewport,
]
passed = 0
failed = 0
for test in tests:
try:
test()
passed += 1
except AssertionError as e:
print(f"[FAIL] {test.__name__}: {e}")
failed += 1
except Exception as e:
print(f"[ERROR] {test.__name__}: {type(e).__name__}: {e}")
failed += 1
print(f"\n=== Results: {passed} passed, {failed} failed ===")
return failed == 0
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)

View file

@ -0,0 +1,208 @@
# pathfinding_3d_test.py - Unit tests for 3D pathfinding
# Tests A* pathfinding on VoxelPoint navigation grid
import mcrfpy
import sys
def test_simple_path():
"""Test pathfinding on an open grid"""
print("Testing simple pathfinding...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
viewport.grid_size = (10, 10)
# Find path from corner to corner
path = viewport.find_path((0, 0), (9, 9))
# Should find a path
assert len(path) > 0, "Expected a path, got empty list"
# Path should end at destination (start is not included)
assert path[-1] == (9, 9), f"Path should end at (9, 9), got {path[-1]}"
# Path length should be reasonable (diagonal allows shorter paths)
# Manhattan distance is 18, but with diagonals it can be ~9-14 steps
assert len(path) >= 9 and len(path) <= 18, f"Path length {len(path)} is unexpected"
print(f" PASS: Simple pathfinding ({len(path)} steps)")
def test_path_with_obstacles():
"""Test pathfinding around obstacles"""
print("Testing pathfinding with obstacles...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
viewport.grid_size = (10, 10)
# Create a wall blocking direct path
# Wall from (4, 0) to (4, 8)
for z in range(9):
viewport.at(4, z).walkable = False
# Find path from left side to right side
path = viewport.find_path((2, 5), (7, 5))
# Should find a path (going around the wall via z=9)
assert len(path) > 0, "Expected a path around the wall"
# Verify path doesn't go through wall
for x, z in path:
if x == 4 and z < 9:
assert False, f"Path goes through wall at ({x}, {z})"
print(f" PASS: Pathfinding with obstacles ({len(path)} steps)")
def test_no_path():
"""Test pathfinding when no path exists"""
print("Testing no path scenario...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
viewport.grid_size = (10, 10)
# Create a complete wall blocking all paths
# Wall from (5, 0) to (5, 9) - blocks entire grid
for z in range(10):
viewport.at(5, z).walkable = False
# Try to find path from left to right
path = viewport.find_path((2, 5), (7, 5))
# Should return empty list (no path)
assert len(path) == 0, f"Expected empty path, got {len(path)} steps"
print(" PASS: No path returns empty list")
def test_start_equals_end():
"""Test pathfinding when start equals end"""
print("Testing start equals end...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
viewport.grid_size = (10, 10)
# Find path to same location
path = viewport.find_path((5, 5), (5, 5))
# Should return empty path (already there)
assert len(path) == 0, f"Expected empty path for start==end, got {len(path)} steps"
print(" PASS: Start equals end")
def test_adjacent_path():
"""Test pathfinding to adjacent cell"""
print("Testing adjacent cell pathfinding...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
viewport.grid_size = (10, 10)
# Find path to adjacent cell
path = viewport.find_path((5, 5), (5, 6))
# Should be a single step
assert len(path) == 1, f"Expected 1 step, got {len(path)}"
assert path[0] == (5, 6), f"Expected (5, 6), got {path[0]}"
print(" PASS: Adjacent cell pathfinding")
def test_heightmap_threshold():
"""Test apply_threshold sets walkability"""
print("Testing HeightMap threshold...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
# Create a heightmap
hm = mcrfpy.HeightMap((10, 10))
# Set heights: left half low (0.2), right half high (0.8)
for z in range(10):
for x in range(5):
hm[x, z] = 0.2
for x in range(5, 10):
hm[x, z] = 0.8
# Initialize grid
viewport.grid_size = (10, 10)
# Apply threshold: mark high areas (>0.6) as unwalkable
viewport.apply_threshold(hm, 0.6, 1.0, walkable=False)
# Check left side is walkable
assert viewport.at(2, 5).walkable == True, "Left side should be walkable"
# Check right side is unwalkable
assert viewport.at(7, 5).walkable == False, "Right side should be unwalkable"
# Pathfinding should fail to cross
path = viewport.find_path((2, 5), (7, 5))
assert len(path) == 0, "Path should not exist through unwalkable terrain"
print(" PASS: HeightMap threshold")
def test_slope_cost():
"""Test slope cost calculation"""
print("Testing slope cost...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
viewport.grid_size = (10, 10)
# Create terrain with a steep slope
# Set heights manually
for z in range(10):
for x in range(10):
viewport.at(x, z).height = 0.0
# Create a cliff at x=5
for z in range(10):
for x in range(5, 10):
viewport.at(x, z).height = 2.0 # 2.0 units high
# Apply slope cost: max slope 0.5, mark steeper as unwalkable
viewport.set_slope_cost(max_slope=0.5, cost_multiplier=2.0)
# Check that cells at the cliff edge are marked unwalkable
# Cell at (4, 5) borders (5, 5) which is 2.0 higher
assert viewport.at(4, 5).walkable == False, "Cliff edge should be unwalkable"
assert viewport.at(5, 5).walkable == False, "Cliff top edge should be unwalkable"
# Cells away from cliff should still be walkable
assert viewport.at(0, 5).walkable == True, "Flat area should be walkable"
assert viewport.at(9, 5).walkable == True, "Flat high area should be walkable"
print(" PASS: Slope cost")
def run_all_tests():
"""Run all unit tests"""
print("=" * 60)
print("3D Pathfinding Unit Tests")
print("=" * 60)
try:
test_simple_path()
test_path_with_obstacles()
test_no_path()
test_start_equals_end()
test_adjacent_path()
test_heightmap_threshold()
test_slope_cost()
print("=" * 60)
print("ALL TESTS PASSED")
print("=" * 60)
sys.exit(0)
except AssertionError as e:
print(f"FAIL: {e}")
sys.exit(1)
except Exception as e:
print(f"ERROR: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
# Run tests
run_all_tests()

View file

@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""Unit tests for the Interactive Procedural Generation Demo System.
Tests:
- Demo creation and initialization
- Step execution (forward/backward)
- Parameter changes and regeneration
- Layer visibility toggling
- State snapshot capture/restore
"""
import sys
import os
# Add tests directory to path
tests_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if tests_dir not in sys.path:
sys.path.insert(0, tests_dir)
import mcrfpy
from procgen_interactive.demos.cave_demo import CaveDemo
from procgen_interactive.demos.dungeon_demo import DungeonDemo
from procgen_interactive.demos.terrain_demo import TerrainDemo
from procgen_interactive.demos.town_demo import TownDemo
def test_cave_demo():
"""Test Cave demo creation and stepping."""
print("Testing CaveDemo...")
demo = CaveDemo()
demo.activate()
# Run all steps
for i in range(len(demo.steps)):
demo.advance_step()
assert demo.current_step == i + 1, f"Step count mismatch: {demo.current_step} != {i + 1}"
# Test backward navigation
demo.reverse_step()
assert demo.current_step == len(demo.steps) - 1, "Reverse step failed"
print(" CaveDemo OK")
return True
def test_dungeon_demo():
"""Test Dungeon demo creation and stepping."""
print("Testing DungeonDemo...")
demo = DungeonDemo()
demo.activate()
# Run all steps
for i in range(len(demo.steps)):
demo.advance_step()
assert demo.current_step == len(demo.steps), "Step count mismatch"
print(" DungeonDemo OK")
return True
def test_terrain_demo():
"""Test Terrain demo creation and stepping."""
print("Testing TerrainDemo...")
demo = TerrainDemo()
demo.activate()
# Run all steps
for i in range(len(demo.steps)):
demo.advance_step()
assert demo.current_step == len(demo.steps), "Step count mismatch"
print(" TerrainDemo OK")
return True
def test_town_demo():
"""Test Town demo creation and stepping."""
print("Testing TownDemo...")
demo = TownDemo()
demo.activate()
# Run all steps
for i in range(len(demo.steps)):
demo.advance_step()
assert demo.current_step == len(demo.steps), "Step count mismatch"
print(" TownDemo OK")
return True
def test_parameter_change(demo=None):
"""Test that parameter changes trigger regeneration."""
print("Testing parameter changes...")
# Reuse existing demo if provided (to avoid scene name conflict)
if demo is None:
demo = CaveDemo()
demo.activate()
# Change a parameter
seed_param = demo.parameters["seed"]
original_seed = seed_param.value
# Test parameter value change
seed_param.value = original_seed + 1
assert seed_param.value == original_seed + 1, "Parameter value not updated"
# Test parameter bounds
seed_param.value = -10 # Should clamp to min (0)
assert seed_param.value >= 0, "Parameter min bound not enforced"
# Test increment/decrement
seed_param.value = 100
old_val = seed_param.value
seed_param.increment()
assert seed_param.value > old_val, "Increment failed"
seed_param.decrement()
assert seed_param.value == old_val, "Decrement failed"
print(" Parameter changes OK")
return True
def test_layer_visibility(demo=None):
"""Test layer visibility toggling."""
print("Testing layer visibility...")
# Reuse existing demo if provided (to avoid scene name conflict)
if demo is None:
demo = CaveDemo()
demo.activate()
# Get a layer
final_layer = demo.get_layer("final")
assert final_layer is not None, "Layer not found"
# Test visibility toggle
original_visible = final_layer.visible
final_layer.visible = not original_visible
assert final_layer.visible == (not original_visible), "Visibility not toggled"
# Toggle back
final_layer.visible = original_visible
assert final_layer.visible == original_visible, "Visibility not restored"
print(" Layer visibility OK")
return True
def main():
"""Run all tests."""
print("=" * 50)
print("Interactive Procgen Demo System Tests")
print("=" * 50)
print()
passed = 0
failed = 0
# Demo creation tests
demo_tests = [
("test_cave_demo", test_cave_demo),
("test_dungeon_demo", test_dungeon_demo),
("test_terrain_demo", test_terrain_demo),
("test_town_demo", test_town_demo),
]
# Create a fresh cave demo for parameter/layer tests
cave_demo = None
for name, test in demo_tests:
try:
if test():
passed += 1
# Save cave demo for later tests
if name == "test_cave_demo":
cave_demo = CaveDemo.__last_instance__ if hasattr(CaveDemo, '__last_instance__') else None
else:
failed += 1
print(f" FAILED: {name}")
except Exception as e:
failed += 1
print(f" ERROR in {name}: {e}")
import traceback
traceback.print_exc()
# Parameter and layer tests use the last cave demo created
# (or create a new one if cave test didn't run)
try:
# These tests are about the parameter/layer system, not demo creation
# We test with the first cave demo's parameters and layers
from procgen_interactive.core.parameter import Parameter
print("Testing parameter system...")
p = Parameter(name="test", display="Test", type="int", default=50, min_val=0, max_val=100)
p.value = 75
assert p.value == 75, "Parameter set failed"
p.increment()
assert p.value == 76, "Increment failed"
p.value = -10
assert p.value == 0, "Min bound not enforced"
p.value = 200
assert p.value == 100, "Max bound not enforced"
print(" Parameter system OK")
passed += 1
print("Testing float parameter...")
p = Parameter(name="test", display="Test", type="float", default=0.5, min_val=0.0, max_val=1.0, step=0.1)
p.value = 0.7
assert abs(p.value - 0.7) < 0.001, "Float parameter set failed"
p.increment()
assert abs(p.value - 0.8) < 0.001, "Float increment failed"
print(" Float parameter OK")
passed += 1
except Exception as e:
failed += 1
print(f" ERROR in parameter tests: {e}")
import traceback
traceback.print_exc()
print()
print("=" * 50)
print(f"Results: {passed} passed, {failed} failed")
print("=" * 50)
if failed == 0:
print("PASS")
sys.exit(0)
else:
print("FAIL")
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,80 @@
# skeleton_test.py - Unit tests for skeletal animation in Model3D
import mcrfpy
import sys
def test_model_skeleton_default():
"""Test that procedural models don't have skeletons"""
cube = mcrfpy.Model3D.cube(1.0)
assert cube.has_skeleton == False, f"Expected cube.has_skeleton=False, got {cube.has_skeleton}"
assert cube.bone_count == 0, f"Expected cube.bone_count=0, got {cube.bone_count}"
assert cube.animation_clips == [], f"Expected empty animation_clips, got {cube.animation_clips}"
print("[PASS] test_model_skeleton_default")
def test_model_animation_clips_empty():
"""Test that models without skeleton have no animation clips"""
sphere = mcrfpy.Model3D.sphere(0.5)
clips = sphere.animation_clips
assert isinstance(clips, list), f"Expected list, got {type(clips)}"
assert len(clips) == 0, f"Expected 0 clips, got {len(clips)}"
print("[PASS] test_model_animation_clips_empty")
def test_model_properties():
"""Test Model3D skeleton-related property access"""
plane = mcrfpy.Model3D.plane(2.0, 2.0)
# These should all work without error
_ = plane.has_skeleton
_ = plane.bone_count
_ = plane.animation_clips
_ = plane.name
_ = plane.vertex_count
_ = plane.triangle_count
_ = plane.mesh_count
_ = plane.bounds
print("[PASS] test_model_properties")
def test_model_repr_no_skeleton():
"""Test Model3D repr for non-skeletal model"""
cube = mcrfpy.Model3D.cube()
r = repr(cube)
assert "Model3D" in r, f"Expected 'Model3D' in repr, got {r}"
assert "skeletal" not in r, f"Non-skeletal model should not say 'skeletal' in repr"
print("[PASS] test_model_repr_no_skeleton")
def run_all_tests():
"""Run all skeleton tests"""
tests = [
test_model_skeleton_default,
test_model_animation_clips_empty,
test_model_properties,
test_model_repr_no_skeleton,
]
passed = 0
failed = 0
for test in tests:
try:
test()
passed += 1
except AssertionError as e:
print(f"[FAIL] {test.__name__}: {e}")
failed += 1
except Exception as e:
print(f"[ERROR] {test.__name__}: {e}")
failed += 1
print(f"\n=== Results: {passed} passed, {failed} failed ===")
return failed == 0
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)

View file

@ -0,0 +1,189 @@
"""Unit tests for mcrfpy.TileMapFile - Tiled tilemap loading"""
import mcrfpy
import sys
PASS_COUNT = 0
FAIL_COUNT = 0
def check(condition, msg):
global PASS_COUNT, FAIL_COUNT
if condition:
PASS_COUNT += 1
print(f" PASS: {msg}")
else:
FAIL_COUNT += 1
print(f" FAIL: {msg}")
def test_tmx_loading():
"""Test loading a .tmx map"""
print("=== TMX Loading ===")
tm = mcrfpy.TileMapFile("../tests/assets/tiled/test_map.tmx")
check(tm.width == 4, f"width = {tm.width}")
check(tm.height == 4, f"height = {tm.height}")
check(tm.tile_width == 16, f"tile_width = {tm.tile_width}")
check(tm.tile_height == 16, f"tile_height = {tm.tile_height}")
check(tm.orientation == "orthogonal", f"orientation = '{tm.orientation}'")
return tm
def test_tmj_loading():
"""Test loading a .tmj map"""
print("\n=== TMJ Loading ===")
tm = mcrfpy.TileMapFile("../tests/assets/tiled/test_map.tmj")
check(tm.width == 4, f"width = {tm.width}")
check(tm.height == 4, f"height = {tm.height}")
check(tm.tile_width == 16, f"tile_width = {tm.tile_width}")
check(tm.tile_height == 16, f"tile_height = {tm.tile_height}")
return tm
def test_map_properties(tm):
"""Test map properties"""
print("\n=== Map Properties ===")
props = tm.properties
check(isinstance(props, dict), f"properties is dict: {type(props)}")
check(props.get("map_name") == "test", f"map_name = '{props.get('map_name')}'")
def test_tileset_references(tm):
"""Test tileset references"""
print("\n=== Tileset References ===")
check(tm.tileset_count == 1, f"tileset_count = {tm.tileset_count}")
firstgid, ts = tm.tileset(0)
check(firstgid == 1, f"firstgid = {firstgid}")
check(isinstance(ts, mcrfpy.TileSetFile), f"tileset is TileSetFile: {type(ts)}")
check(ts.name == "test_tileset", f"tileset name = '{ts.name}'")
check(ts.tile_count == 16, f"tileset tile_count = {ts.tile_count}")
def test_tile_layer_names(tm):
"""Test tile layer name listing"""
print("\n=== Layer Names ===")
names = tm.tile_layer_names
check(len(names) == 2, f"tile_layer count = {len(names)}")
check("Ground" in names, f"'Ground' in names: {names}")
check("Overlay" in names, f"'Overlay' in names: {names}")
obj_names = tm.object_layer_names
check(len(obj_names) == 1, f"object_layer count = {len(obj_names)}")
check("Objects" in obj_names, f"'Objects' in obj_names: {obj_names}")
def test_tile_layer_data(tm):
"""Test raw tile layer data access"""
print("\n=== Tile Layer Data ===")
ground = tm.tile_layer_data("Ground")
check(len(ground) == 16, f"Ground layer length = {len(ground)}")
# First row: 1,2,1,1 (GIDs)
check(ground[0] == 1, f"ground[0] = {ground[0]}")
check(ground[1] == 2, f"ground[1] = {ground[1]}")
check(ground[2] == 1, f"ground[2] = {ground[2]}")
check(ground[3] == 1, f"ground[3] = {ground[3]}")
overlay = tm.tile_layer_data("Overlay")
check(len(overlay) == 16, f"Overlay layer length = {len(overlay)}")
# First row all zeros (empty)
check(overlay[0] == 0, f"overlay[0] = {overlay[0]} (empty)")
# Second row: 0,9,10,0
check(overlay[5] == 9, f"overlay[5] = {overlay[5]}")
try:
tm.tile_layer_data("nonexistent")
check(False, "tile_layer_data('nonexistent') should raise KeyError")
except KeyError:
check(True, "tile_layer_data('nonexistent') raises KeyError")
def test_resolve_gid(tm):
"""Test GID resolution"""
print("\n=== GID Resolution ===")
# GID 0 = empty
ts_idx, local_id = tm.resolve_gid(0)
check(ts_idx == -1, f"GID 0: ts_idx = {ts_idx}")
check(local_id == -1, f"GID 0: local_id = {local_id}")
# GID 1 = first tileset (firstgid=1), local_id=0
ts_idx, local_id = tm.resolve_gid(1)
check(ts_idx == 0, f"GID 1: ts_idx = {ts_idx}")
check(local_id == 0, f"GID 1: local_id = {local_id}")
# GID 2 = first tileset, local_id=1
ts_idx, local_id = tm.resolve_gid(2)
check(ts_idx == 0, f"GID 2: ts_idx = {ts_idx}")
check(local_id == 1, f"GID 2: local_id = {local_id}")
# GID 9 = first tileset, local_id=8
ts_idx, local_id = tm.resolve_gid(9)
check(ts_idx == 0, f"GID 9: ts_idx = {ts_idx}")
check(local_id == 8, f"GID 9: local_id = {local_id}")
def test_object_layer(tm):
"""Test object layer access"""
print("\n=== Object Layer ===")
objects = tm.object_layer("Objects")
check(isinstance(objects, list), f"objects is list: {type(objects)}")
check(len(objects) == 2, f"object count = {len(objects)}")
# Find spawn point
spawn = None
trigger = None
for obj in objects:
if obj.get("name") == "spawn":
spawn = obj
elif obj.get("name") == "trigger_zone":
trigger = obj
check(spawn is not None, "spawn object found")
if spawn:
check(spawn.get("x") == 32, f"spawn x = {spawn.get('x')}")
check(spawn.get("y") == 32, f"spawn y = {spawn.get('y')}")
check(spawn.get("point") == True, f"spawn is point")
props = spawn.get("properties", {})
check(props.get("player_start") == True, f"player_start = {props.get('player_start')}")
check(trigger is not None, "trigger_zone object found")
if trigger:
check(trigger.get("width") == 64, f"trigger width = {trigger.get('width')}")
check(trigger.get("height") == 64, f"trigger height = {trigger.get('height')}")
props = trigger.get("properties", {})
check(props.get("zone_id") == 42, f"zone_id = {props.get('zone_id')}")
try:
tm.object_layer("nonexistent")
check(False, "object_layer('nonexistent') should raise KeyError")
except KeyError:
check(True, "object_layer('nonexistent') raises KeyError")
def test_error_handling():
"""Test error cases"""
print("\n=== Error Handling ===")
try:
mcrfpy.TileMapFile("nonexistent.tmx")
check(False, "Missing file should raise IOError")
except IOError:
check(True, "Missing file raises IOError")
def test_repr(tm):
"""Test repr"""
print("\n=== Repr ===")
r = repr(tm)
check("TileMapFile" in r, f"repr contains 'TileMapFile': {r}")
check("4x4" in r, f"repr contains dimensions: {r}")
def main():
tm_tmx = test_tmx_loading()
tm_tmj = test_tmj_loading()
test_map_properties(tm_tmx)
test_tileset_references(tm_tmx)
test_tile_layer_names(tm_tmx)
test_tile_layer_data(tm_tmx)
test_resolve_gid(tm_tmx)
test_object_layer(tm_tmx)
test_error_handling()
test_repr(tm_tmx)
print(f"\n{'='*40}")
print(f"Results: {PASS_COUNT} passed, {FAIL_COUNT} failed")
if FAIL_COUNT > 0:
sys.exit(1)
else:
print("ALL TESTS PASSED")
sys.exit(0)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,145 @@
"""Unit tests for mcrfpy.TileSetFile - Tiled tileset loading"""
import mcrfpy
import sys
import os
PASS_COUNT = 0
FAIL_COUNT = 0
def check(condition, msg):
global PASS_COUNT, FAIL_COUNT
if condition:
PASS_COUNT += 1
print(f" PASS: {msg}")
else:
FAIL_COUNT += 1
print(f" FAIL: {msg}")
def test_tsx_loading():
"""Test loading a .tsx tileset"""
print("=== TSX Loading ===")
ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx")
check(ts.name == "test_tileset", f"name = '{ts.name}'")
check(ts.tile_width == 16, f"tile_width = {ts.tile_width}")
check(ts.tile_height == 16, f"tile_height = {ts.tile_height}")
check(ts.tile_count == 16, f"tile_count = {ts.tile_count}")
check(ts.columns == 4, f"columns = {ts.columns}")
check(ts.margin == 0, f"margin = {ts.margin}")
check(ts.spacing == 0, f"spacing = {ts.spacing}")
check("test_tileset.png" in ts.image_source, f"image_source contains PNG: {ts.image_source}")
return ts
def test_tsj_loading():
"""Test loading a .tsj tileset"""
print("\n=== TSJ Loading ===")
ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsj")
check(ts.name == "test_tileset", f"name = '{ts.name}'")
check(ts.tile_width == 16, f"tile_width = {ts.tile_width}")
check(ts.tile_height == 16, f"tile_height = {ts.tile_height}")
check(ts.tile_count == 16, f"tile_count = {ts.tile_count}")
check(ts.columns == 4, f"columns = {ts.columns}")
return ts
def test_properties(ts):
"""Test tileset properties"""
print("\n=== Properties ===")
props = ts.properties
check(isinstance(props, dict), f"properties is dict: {type(props)}")
check(props.get("author") == "test", f"author = '{props.get('author')}'")
check(props.get("version") == 1, f"version = {props.get('version')}")
def test_tile_info(ts):
"""Test per-tile metadata"""
print("\n=== Tile Info ===")
info = ts.tile_info(0)
check(info is not None, "tile_info(0) exists")
check("properties" in info, "has 'properties' key")
check("animation" in info, "has 'animation' key")
check(info["properties"].get("terrain") == "grass", f"terrain = '{info['properties'].get('terrain')}'")
check(info["properties"].get("walkable") == True, f"walkable = {info['properties'].get('walkable')}")
check(len(info["animation"]) == 2, f"animation frames = {len(info['animation'])}")
check(info["animation"][0] == (0, 500), f"frame 0 = {info['animation'][0]}")
check(info["animation"][1] == (4, 500), f"frame 1 = {info['animation'][1]}")
info1 = ts.tile_info(1)
check(info1 is not None, "tile_info(1) exists")
check(info1["properties"].get("terrain") == "dirt", f"terrain = '{info1['properties'].get('terrain')}'")
check(len(info1["animation"]) == 0, "tile 1 has no animation")
info_none = ts.tile_info(5)
check(info_none is None, "tile_info(5) returns None (no metadata)")
def test_wang_sets(ts):
"""Test Wang set access"""
print("\n=== Wang Sets ===")
wang_sets = ts.wang_sets
check(len(wang_sets) == 1, f"wang_sets count = {len(wang_sets)}")
ws = wang_sets[0]
check(ws.name == "terrain", f"wang set name = '{ws.name}'")
check(ws.type == "corner", f"wang set type = '{ws.type}'")
check(ws.color_count == 2, f"color_count = {ws.color_count}")
colors = ws.colors
check(len(colors) == 2, f"colors length = {len(colors)}")
check(colors[0]["name"] == "Grass", f"color 0 name = '{colors[0]['name']}'")
check(colors[0]["index"] == 1, f"color 0 index = {colors[0]['index']}")
check(colors[1]["name"] == "Dirt", f"color 1 name = '{colors[1]['name']}'")
check(colors[1]["index"] == 2, f"color 1 index = {colors[1]['index']}")
def test_wang_set_lookup(ts):
"""Test wang_set() method"""
print("\n=== Wang Set Lookup ===")
ws = ts.wang_set("terrain")
check(ws.name == "terrain", "wang_set('terrain') found")
try:
ts.wang_set("nonexistent")
check(False, "wang_set('nonexistent') should raise KeyError")
except KeyError:
check(True, "wang_set('nonexistent') raises KeyError")
def test_to_texture(ts):
"""Test texture creation"""
print("\n=== to_texture ===")
tex = ts.to_texture()
check(tex is not None, "to_texture() returns a Texture")
check(isinstance(tex, mcrfpy.Texture), f"is Texture: {type(tex)}")
def test_error_handling():
"""Test error cases"""
print("\n=== Error Handling ===")
try:
mcrfpy.TileSetFile("nonexistent.tsx")
check(False, "Missing file should raise IOError")
except IOError:
check(True, "Missing file raises IOError")
def test_repr(ts):
"""Test repr"""
print("\n=== Repr ===")
r = repr(ts)
check("TileSetFile" in r, f"repr contains 'TileSetFile': {r}")
check("test_tileset" in r, f"repr contains name: {r}")
def main():
ts_tsx = test_tsx_loading()
ts_tsj = test_tsj_loading()
test_properties(ts_tsx)
test_tile_info(ts_tsx)
test_wang_sets(ts_tsx)
test_wang_set_lookup(ts_tsx)
test_to_texture(ts_tsx)
test_error_handling()
test_repr(ts_tsx)
print(f"\n{'='*40}")
print(f"Results: {PASS_COUNT} passed, {FAIL_COUNT} failed")
if FAIL_COUNT > 0:
sys.exit(1)
else:
print("ALL TESTS PASSED")
sys.exit(0)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,335 @@
#!/usr/bin/env python3
"""Unit tests for VoxelGrid bulk operations (Milestone 11)
Tests:
- fill_box_hollow: Verify shell only, interior empty
- fill_sphere: Volume roughly matches (4/3)πr³
- fill_cylinder: Volume roughly matches πr²h
- fill_noise: Higher threshold = fewer voxels
- copy_region/paste_region: Round-trip verification
- skip_air option for paste
"""
import sys
import math
# Track test results
passed = 0
failed = 0
def test(name, condition, detail=""):
"""Record test result"""
global passed, failed
if condition:
print(f"[PASS] {name}")
passed += 1
else:
print(f"[FAIL] {name}" + (f" - {detail}" if detail else ""))
failed += 1
def test_fill_box_hollow_basic():
"""fill_box_hollow creates correct shell"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(10, 10, 10))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Create hollow 6x6x6 box with thickness 1
vg.fill_box_hollow((2, 2, 2), (7, 7, 7), stone, thickness=1)
# Total box = 6x6x6 = 216
# Interior = 4x4x4 = 64
# Shell = 216 - 64 = 152
expected = 152
actual = vg.count_non_air()
test("Hollow box: shell has correct voxel count", actual == expected,
f"got {actual}, expected {expected}")
# Verify interior is empty (center should be air)
test("Hollow box: interior is air", vg.get(4, 4, 4) == 0)
test("Hollow box: interior is air (another point)", vg.get(5, 5, 5) == 0)
# Verify shell exists
test("Hollow box: corner is filled", vg.get(2, 2, 2) == stone)
test("Hollow box: edge is filled", vg.get(4, 2, 2) == stone)
def test_fill_box_hollow_thick():
"""fill_box_hollow with thickness > 1"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(12, 12, 12))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Create hollow 10x10x10 box with thickness 2
vg.fill_box_hollow((1, 1, 1), (10, 10, 10), stone, thickness=2)
# Total box = 10x10x10 = 1000
# Interior = 6x6x6 = 216
# Shell = 1000 - 216 = 784
expected = 784
actual = vg.count_non_air()
test("Thick hollow box: correct voxel count", actual == expected,
f"got {actual}, expected {expected}")
# Verify interior is empty
test("Thick hollow box: center is air", vg.get(5, 5, 5) == 0)
def test_fill_sphere_volume():
"""fill_sphere produces roughly spherical shape"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(30, 30, 30))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Fill sphere with radius 8
radius = 8
vg.fill_sphere((15, 15, 15), radius, stone)
# Expected volume ≈ (4/3)πr³
expected_vol = (4.0 / 3.0) * math.pi * (radius ** 3)
actual = vg.count_non_air()
# Voxel sphere should be within 20% of theoretical volume
ratio = actual / expected_vol
test("Sphere volume: within 20% of (4/3)πr³",
0.8 <= ratio <= 1.2,
f"got {actual}, expected ~{int(expected_vol)}, ratio={ratio:.2f}")
def test_fill_sphere_carve():
"""fill_sphere with material 0 carves out voxels"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(20, 20, 20))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Fill entire grid with stone
vg.fill(stone)
initial = vg.count_non_air()
test("Sphere carve: initial fill", initial == 8000) # 20x20x20
# Carve out a sphere (material 0)
vg.fill_sphere((10, 10, 10), 5, 0) # Air
final = vg.count_non_air()
test("Sphere carve: voxels removed", final < initial)
def test_fill_cylinder_volume():
"""fill_cylinder produces roughly cylindrical shape"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(30, 30, 30))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Fill cylinder with radius 5, height 10
radius = 5
height = 10
vg.fill_cylinder((15, 5, 15), radius, height, stone)
# Expected volume ≈ πr²h
expected_vol = math.pi * (radius ** 2) * height
actual = vg.count_non_air()
# Voxel cylinder should be within 20% of theoretical volume
ratio = actual / expected_vol
test("Cylinder volume: within 20% of πr²h",
0.8 <= ratio <= 1.2,
f"got {actual}, expected ~{int(expected_vol)}, ratio={ratio:.2f}")
def test_fill_cylinder_bounds():
"""fill_cylinder respects grid bounds"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(10, 10, 10))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Cylinder partially outside grid
vg.fill_cylinder((2, 0, 2), 3, 15, stone) # height extends beyond grid
# Should not crash, and have some voxels
count = vg.count_non_air()
test("Cylinder bounds: handles out-of-bounds gracefully", count > 0)
test("Cylinder bounds: limited by grid height", count < 3.14 * 9 * 15)
def test_fill_noise_threshold():
"""fill_noise: higher threshold = fewer voxels"""
import mcrfpy
vg1 = mcrfpy.VoxelGrid(size=(16, 16, 16))
vg2 = mcrfpy.VoxelGrid(size=(16, 16, 16))
stone = vg1.add_material("stone", color=mcrfpy.Color(128, 128, 128))
vg2.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Same seed, different thresholds
vg1.fill_noise((0, 0, 0), (15, 15, 15), stone, threshold=0.3, scale=0.15, seed=12345)
vg2.fill_noise((0, 0, 0), (15, 15, 15), stone, threshold=0.7, scale=0.15, seed=12345)
count1 = vg1.count_non_air()
count2 = vg2.count_non_air()
# Higher threshold should produce fewer voxels
test("Noise threshold: high threshold produces fewer voxels",
count2 < count1,
f"threshold=0.3 gave {count1}, threshold=0.7 gave {count2}")
def test_fill_noise_seed():
"""fill_noise: same seed produces same result"""
import mcrfpy
vg1 = mcrfpy.VoxelGrid(size=(16, 16, 16))
vg2 = mcrfpy.VoxelGrid(size=(16, 16, 16))
stone = vg1.add_material("stone", color=mcrfpy.Color(128, 128, 128))
vg2.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Same parameters
vg1.fill_noise((0, 0, 0), (15, 15, 15), stone, threshold=0.5, scale=0.1, seed=42)
vg2.fill_noise((0, 0, 0), (15, 15, 15), stone, threshold=0.5, scale=0.1, seed=42)
# Should produce identical results
count1 = vg1.count_non_air()
count2 = vg2.count_non_air()
test("Noise seed: same seed produces same count", count1 == count2,
f"got {count1} vs {count2}")
# Check a few sample points
same_values = True
for x, y, z in [(0, 0, 0), (8, 8, 8), (15, 15, 15), (3, 7, 11)]:
if vg1.get(x, y, z) != vg2.get(x, y, z):
same_values = False
break
test("Noise seed: same seed produces identical voxels", same_values)
def test_copy_paste_basic():
"""copy_region and paste_region round-trip"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(20, 10, 20))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
brick = vg.add_material("brick", color=mcrfpy.Color(165, 42, 42))
# Create a small structure
vg.fill_box((2, 0, 2), (5, 3, 5), stone)
vg.set(3, 1, 3, brick) # Add a different material
# Copy the region
prefab = vg.copy_region((2, 0, 2), (5, 3, 5))
# Verify VoxelRegion properties
test("Copy region: correct width", prefab.width == 4)
test("Copy region: correct height", prefab.height == 4)
test("Copy region: correct depth", prefab.depth == 4)
test("Copy region: size tuple", prefab.size == (4, 4, 4))
# Paste elsewhere
vg.paste_region(prefab, (10, 0, 10))
# Verify paste
test("Paste region: stone at corner", vg.get(10, 0, 10) == stone)
test("Paste region: brick inside", vg.get(11, 1, 11) == brick)
def test_copy_paste_skip_air():
"""paste_region with skip_air=True doesn't overwrite"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(20, 10, 20))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
gold = vg.add_material("gold", color=mcrfpy.Color(255, 215, 0))
# Create prefab with air gaps
vg.fill_box((0, 0, 0), (3, 3, 3), stone)
vg.set(1, 1, 1, 0) # Air hole
vg.set(2, 2, 2, 0) # Another air hole
# Copy it
prefab = vg.copy_region((0, 0, 0), (3, 3, 3))
# Place gold in destination
vg.set(11, 1, 11, gold) # Where air hole will paste
vg.set(12, 2, 12, gold) # Where another air hole will paste
# Paste with skip_air=True (default)
vg.paste_region(prefab, (10, 0, 10), skip_air=True)
# Gold should still be there (air didn't overwrite)
test("Skip air: preserves existing material", vg.get(11, 1, 11) == gold)
test("Skip air: preserves at other location", vg.get(12, 2, 12) == gold)
def test_copy_paste_overwrite():
"""paste_region with skip_air=False overwrites"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(20, 10, 20))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
gold = vg.add_material("gold", color=mcrfpy.Color(255, 215, 0))
# Create prefab with air gap
vg.fill_box((0, 0, 0), (3, 3, 3), stone)
vg.set(1, 1, 1, 0) # Air hole
# Copy it
prefab = vg.copy_region((0, 0, 0), (3, 3, 3))
# Clear and place gold in destination
vg.clear()
vg.set(11, 1, 11, gold)
# Paste with skip_air=False
vg.paste_region(prefab, (10, 0, 10), skip_air=False)
# Gold should be overwritten with air
test("Overwrite air: replaces existing material", vg.get(11, 1, 11) == 0)
def test_voxel_region_repr():
"""VoxelRegion has proper repr"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(10, 10, 10))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
vg.fill_box((0, 0, 0), (4, 4, 4), stone)
prefab = vg.copy_region((0, 0, 0), (4, 4, 4))
rep = repr(prefab)
test("VoxelRegion repr: contains dimensions", "5x5x5" in rep)
test("VoxelRegion repr: is VoxelRegion", "VoxelRegion" in rep)
def main():
"""Run all bulk operation tests"""
print("=" * 60)
print("VoxelGrid Bulk Operations Tests (Milestone 11)")
print("=" * 60)
print()
test_fill_box_hollow_basic()
print()
test_fill_box_hollow_thick()
print()
test_fill_sphere_volume()
print()
test_fill_sphere_carve()
print()
test_fill_cylinder_volume()
print()
test_fill_cylinder_bounds()
print()
test_fill_noise_threshold()
print()
test_fill_noise_seed()
print()
test_copy_paste_basic()
print()
test_copy_paste_skip_air()
print()
test_copy_paste_overwrite()
print()
test_voxel_region_repr()
print()
print("=" * 60)
print(f"Results: {passed} passed, {failed} failed")
print("=" * 60)
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""Unit tests for Milestone 13: Greedy Meshing
Tests that greedy meshing produces correct mesh geometry with reduced vertex count.
"""
import mcrfpy
import sys
# Test counters
tests_passed = 0
tests_failed = 0
def test(name, condition):
"""Simple test helper"""
global tests_passed, tests_failed
if condition:
tests_passed += 1
print(f" PASS: {name}")
else:
tests_failed += 1
print(f" FAIL: {name}")
# =============================================================================
# Test greedy meshing property
# =============================================================================
print("\n=== Testing greedy_meshing property ===")
vg = mcrfpy.VoxelGrid((8, 8, 8), cell_size=1.0)
test("Default greedy_meshing is False", vg.greedy_meshing == False)
vg.greedy_meshing = True
test("Can enable greedy_meshing", vg.greedy_meshing == True)
vg.greedy_meshing = False
test("Can disable greedy_meshing", vg.greedy_meshing == False)
# =============================================================================
# Test vertex count reduction
# =============================================================================
print("\n=== Testing vertex count reduction ===")
# Create a solid 4x4x4 cube - this should benefit greatly from greedy meshing
# Non-greedy: 6 faces per voxel for exposed faces = many quads
# Greedy: 6 large quads (one per side)
vg2 = mcrfpy.VoxelGrid((4, 4, 4), cell_size=1.0)
stone = vg2.add_material("stone", (128, 128, 128))
vg2.fill((stone)) # Fill entire grid
# Get vertex count with standard meshing
vg2.greedy_meshing = False
vg2.rebuild_mesh()
standard_vertices = vg2.vertex_count
print(f" Standard meshing: {standard_vertices} vertices")
# Get vertex count with greedy meshing
vg2.greedy_meshing = True
vg2.rebuild_mesh()
greedy_vertices = vg2.vertex_count
print(f" Greedy meshing: {greedy_vertices} vertices")
# For a solid 4x4x4 cube, standard meshing creates:
# Each face of the cube is 4x4 = 16 voxel faces
# 6 cube faces * 16 faces/side * 6 vertices/face = 576 vertices
# Greedy meshing creates:
# 6 cube faces * 1 merged quad * 6 vertices/quad = 36 vertices
test("Greedy meshing reduces vertex count", greedy_vertices < standard_vertices)
test("Solid cube greedy: 36 vertices (6 faces * 6 verts)", greedy_vertices == 36)
# =============================================================================
# Test larger solid block
# =============================================================================
print("\n=== Testing larger solid block ===")
vg3 = mcrfpy.VoxelGrid((16, 16, 16), cell_size=1.0)
stone3 = vg3.add_material("stone", (128, 128, 128))
vg3.fill(stone3)
vg3.greedy_meshing = False
vg3.rebuild_mesh()
standard_verts_large = vg3.vertex_count
print(f" Standard: {standard_verts_large} vertices")
vg3.greedy_meshing = True
vg3.rebuild_mesh()
greedy_verts_large = vg3.vertex_count
print(f" Greedy: {greedy_verts_large} vertices")
# 16x16 faces = 256 quads per side -> 1 quad per side with greedy
# Reduction factor should be significant
reduction_factor = standard_verts_large / greedy_verts_large if greedy_verts_large > 0 else 0
print(f" Reduction factor: {reduction_factor:.1f}x")
test("Large block greedy: still 36 vertices", greedy_verts_large == 36)
test("Significant vertex reduction (>10x)", reduction_factor > 10)
# =============================================================================
# Test checkerboard pattern (worst case for greedy)
# =============================================================================
print("\n=== Testing checkerboard pattern (greedy stress test) ===")
vg4 = mcrfpy.VoxelGrid((4, 4, 4), cell_size=1.0)
stone4 = vg4.add_material("stone", (128, 128, 128))
# Create checkerboard pattern - no adjacent same-material voxels
for z in range(4):
for y in range(4):
for x in range(4):
if (x + y + z) % 2 == 0:
vg4.set(x, y, z, stone4)
vg4.greedy_meshing = False
vg4.rebuild_mesh()
standard_checker = vg4.vertex_count
print(f" Standard: {standard_checker} vertices")
vg4.greedy_meshing = True
vg4.rebuild_mesh()
greedy_checker = vg4.vertex_count
print(f" Greedy: {greedy_checker} vertices")
# In checkerboard, greedy meshing can't merge much, so counts should be similar
test("Checkerboard: greedy meshing works (produces vertices)", greedy_checker > 0)
# Greedy might still reduce a bit due to row merging
test("Checkerboard: greedy <= standard", greedy_checker <= standard_checker)
# =============================================================================
# Test different materials (no cross-material merging)
# =============================================================================
print("\n=== Testing multi-material (no cross-material merging) ===")
vg5 = mcrfpy.VoxelGrid((4, 4, 4), cell_size=1.0)
red = vg5.add_material("red", (255, 0, 0))
blue = vg5.add_material("blue", (0, 0, 255))
# Half red, half blue
vg5.fill_box((0, 0, 0), (1, 3, 3), red)
vg5.fill_box((2, 0, 0), (3, 3, 3), blue)
vg5.greedy_meshing = True
vg5.rebuild_mesh()
multi_material_verts = vg5.vertex_count
print(f" Multi-material greedy: {multi_material_verts} vertices")
# Should have 6 quads per material half = 12 quads = 72 vertices
# But there's a shared face between them that gets culled
# Actually: each 2x4x4 block has 5 exposed faces (not the shared internal face)
# So 5 + 5 = 10 quads = 60 vertices, but may be more due to the contact face
test("Multi-material produces vertices", multi_material_verts > 0)
# =============================================================================
# Test hollow box (interior faces)
# =============================================================================
print("\n=== Testing hollow box ===")
vg6 = mcrfpy.VoxelGrid((8, 8, 8), cell_size=1.0)
stone6 = vg6.add_material("stone", (128, 128, 128))
vg6.fill_box_hollow((0, 0, 0), (7, 7, 7), stone6, thickness=1)
vg6.greedy_meshing = False
vg6.rebuild_mesh()
standard_hollow = vg6.vertex_count
print(f" Standard: {standard_hollow} vertices")
vg6.greedy_meshing = True
vg6.rebuild_mesh()
greedy_hollow = vg6.vertex_count
print(f" Greedy: {greedy_hollow} vertices")
# Hollow box has 6 outer faces and 6 inner faces
# Greedy should merge each face into one quad
# Expected: 12 quads * 6 verts = 72 vertices
test("Hollow box: greedy reduces vertices", greedy_hollow < standard_hollow)
# =============================================================================
# Test floor slab (single layer)
# =============================================================================
print("\n=== Testing floor slab (single layer) ===")
vg7 = mcrfpy.VoxelGrid((10, 1, 10), cell_size=1.0)
floor_mat = vg7.add_material("floor", (100, 80, 60))
vg7.fill(floor_mat)
vg7.greedy_meshing = False
vg7.rebuild_mesh()
standard_floor = vg7.vertex_count
print(f" Standard: {standard_floor} vertices")
vg7.greedy_meshing = True
vg7.rebuild_mesh()
greedy_floor = vg7.vertex_count
print(f" Greedy: {greedy_floor} vertices")
# Floor slab: 10x10 top face + 10x10 bottom face + 4 edge faces (10x1 each)
# Greedy: 6 quads = 36 vertices
test("Floor slab: greedy = 36 vertices", greedy_floor == 36)
# =============================================================================
# Test that mesh is marked dirty when property changes
# =============================================================================
print("\n=== Testing dirty flag behavior ===")
vg8 = mcrfpy.VoxelGrid((4, 4, 4), cell_size=1.0)
stone8 = vg8.add_material("stone", (128, 128, 128))
vg8.fill(stone8)
# Build mesh first
vg8.greedy_meshing = False
vg8.rebuild_mesh()
first_count = vg8.vertex_count
# Change greedy_meshing - mesh should be marked dirty
vg8.greedy_meshing = True
# Rebuild
vg8.rebuild_mesh()
second_count = vg8.vertex_count
test("Changing greedy_meshing affects vertex count", first_count != second_count)
# =============================================================================
# Summary
# =============================================================================
print(f"\n=== Results: {tests_passed} passed, {tests_failed} failed ===")
if tests_failed > 0:
sys.exit(1)
else:
print("All tests passed!")
sys.exit(0)

View file

@ -0,0 +1,300 @@
#!/usr/bin/env python3
"""Unit tests for VoxelGrid mesh generation (Milestone 10)
Tests:
- Single voxel produces 36 vertices (6 faces x 6 verts)
- Two adjacent voxels share a face (60 verts instead of 72)
- Hollow cube only has outer faces
- fill_box works correctly
- Mesh dirty flag triggers rebuild
- Vertex positions are in correct local space
"""
import sys
# Track test results
passed = 0
failed = 0
def test(name, condition, detail=""):
"""Record test result"""
global passed, failed
if condition:
print(f"[PASS] {name}")
passed += 1
else:
print(f"[FAIL] {name}" + (f" - {detail}" if detail else ""))
failed += 1
def test_single_voxel():
"""Single voxel should produce 6 faces = 36 vertices"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(8, 8, 8))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Initially no vertices (empty grid)
test("Single voxel: initial vertex_count is 0", vg.vertex_count == 0)
# Add one voxel
vg.set(4, 4, 4, stone)
vg.rebuild_mesh()
# One voxel = 6 faces, each face = 2 triangles = 6 vertices
expected = 6 * 6
test("Single voxel: produces 36 vertices", vg.vertex_count == expected,
f"got {vg.vertex_count}, expected {expected}")
def test_two_adjacent():
"""Two adjacent voxels should share a face, producing 60 vertices instead of 72"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(8, 8, 8))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Add two adjacent voxels (share one face)
vg.set(4, 4, 4, stone)
vg.set(5, 4, 4, stone) # Adjacent in X
vg.rebuild_mesh()
# Two separate voxels would be 72 vertices
# Shared face is culled: 2 * 36 - 2 * 6 = 72 - 12 = 60
expected = 60
test("Two adjacent: shared face culled", vg.vertex_count == expected,
f"got {vg.vertex_count}, expected {expected}")
def test_hollow_cube():
"""Hollow 3x3x3 cube should have much fewer vertices than solid"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(8, 8, 8))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Create hollow 3x3x3 cube (only shell voxels)
# Solid 3x3x3 = 27 voxels, Hollow = 26 voxels (remove center)
for x in range(3):
for y in range(3):
for z in range(3):
# Skip center voxel
if x == 1 and y == 1 and z == 1:
continue
vg.set(x, y, z, stone)
test("Hollow cube: 26 voxels placed", vg.count_non_air() == 26)
vg.rebuild_mesh()
# The hollow center creates inner faces facing the air void
# Outer surface = 6 sides * 9 faces = 54 faces
# Inner surface = 6 faces touching the center void
# Total = 60 faces = 360 vertices
expected = 360
test("Hollow cube: outer + inner void faces", vg.vertex_count == expected,
f"got {vg.vertex_count}, expected {expected}")
def test_fill_box():
"""fill_box should fill a rectangular region"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(16, 8, 16))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Fill a 4x3x5 box
vg.fill_box((2, 1, 3), (5, 3, 7), stone)
# Count: (5-2+1) * (3-1+1) * (7-3+1) = 4 * 3 * 5 = 60
expected = 60
test("fill_box: correct voxel count", vg.count_non_air() == expected,
f"got {vg.count_non_air()}, expected {expected}")
# Verify specific cells
test("fill_box: corner (2,1,3) is filled", vg.get(2, 1, 3) == stone)
test("fill_box: corner (5,3,7) is filled", vg.get(5, 3, 7) == stone)
test("fill_box: outside (1,1,3) is empty", vg.get(1, 1, 3) == 0)
test("fill_box: outside (6,1,3) is empty", vg.get(6, 1, 3) == 0)
def test_fill_box_reversed():
"""fill_box should handle reversed coordinates"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(16, 8, 16))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Fill with reversed coordinates (max before min)
vg.fill_box((5, 3, 7), (2, 1, 3), stone)
# Should still fill 4x3x5 = 60 voxels
expected = 60
test("fill_box reversed: correct voxel count", vg.count_non_air() == expected,
f"got {vg.count_non_air()}, expected {expected}")
def test_fill_box_clamping():
"""fill_box should clamp to grid bounds"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(8, 8, 8))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Fill beyond grid bounds
vg.fill_box((-5, -5, -5), (100, 100, 100), stone)
# Should fill entire 8x8x8 grid = 512 voxels
expected = 512
test("fill_box clamping: fills entire grid", vg.count_non_air() == expected,
f"got {vg.count_non_air()}, expected {expected}")
def test_mesh_dirty():
"""Modifying voxels should mark mesh dirty; rebuild_mesh updates vertex count"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(8, 8, 8))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Initial state
vg.set(4, 4, 4, stone)
vg.rebuild_mesh()
initial_count = vg.vertex_count
test("Mesh dirty: initial vertex count correct", initial_count == 36)
# Modify voxel - marks dirty but doesn't auto-rebuild
vg.set(4, 4, 5, stone)
# vertex_count doesn't auto-trigger rebuild (returns stale value)
stale_count = vg.vertex_count
test("Mesh dirty: vertex_count before rebuild is stale", stale_count == 36)
# Explicit rebuild updates the mesh
vg.rebuild_mesh()
new_count = vg.vertex_count
# Two adjacent voxels = 60 vertices
test("Mesh dirty: rebuilt after explicit rebuild_mesh()", new_count == 60,
f"got {new_count}, expected 60")
def test_vertex_positions():
"""Vertices should be in correct local space positions"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(8, 8, 8), cell_size=2.0)
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Place voxel at (0,0,0)
vg.set(0, 0, 0, stone)
vg.rebuild_mesh()
# With cell_size=2.0, the voxel center is at (1, 1, 1)
# Vertices should be at corners: (0,0,0) to (2,2,2)
# The vertex_count should still be 36
test("Vertex positions: correct vertex count", vg.vertex_count == 36)
def test_empty_grid():
"""Empty grid should produce no vertices"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(8, 8, 8))
vg.rebuild_mesh()
test("Empty grid: zero vertices", vg.vertex_count == 0)
def test_all_air():
"""Grid filled with air produces no vertices"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(8, 8, 8))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Fill with stone, then fill with air
vg.fill(stone)
vg.fill(0) # Air
vg.rebuild_mesh()
test("All air: zero vertices", vg.vertex_count == 0)
def test_large_solid_cube():
"""Large solid cube should have face culling efficiency"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(8, 8, 8))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Fill entire grid
vg.fill(stone)
vg.rebuild_mesh()
# Without culling: 512 voxels * 6 faces * 6 verts = 18432
# With culling: only outer shell faces
# 6 faces of cube, each 8x8 = 64 faces per side = 384 faces
# 384 * 6 verts = 2304 vertices
expected = 2304
test("Large solid cube: face culling efficiency",
vg.vertex_count == expected,
f"got {vg.vertex_count}, expected {expected}")
# Verify massive reduction
no_cull = 512 * 6 * 6
reduction = (no_cull - vg.vertex_count) / no_cull * 100
test("Large solid cube: >85% vertex reduction",
reduction > 85,
f"got {reduction:.1f}% reduction")
def test_transparent_material():
"""Faces between solid and transparent materials should be generated"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(8, 8, 8))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
glass = vg.add_material("glass", color=mcrfpy.Color(200, 200, 255, 128),
transparent=True)
# Place stone with glass neighbor
vg.set(4, 4, 4, stone)
vg.set(5, 4, 4, glass)
vg.rebuild_mesh()
# Stone has 6 faces (all exposed - glass is transparent)
# Glass has 5 faces (face towards stone not generated - stone is solid)
# Total = 36 + 30 = 66 vertices
expected = 66
test("Transparent material: correct face culling", vg.vertex_count == expected,
f"got {vg.vertex_count}, expected {expected}")
def main():
"""Run all mesh generation tests"""
print("=" * 60)
print("VoxelGrid Mesh Generation Tests (Milestone 10)")
print("=" * 60)
print()
test_single_voxel()
print()
test_two_adjacent()
print()
test_hollow_cube()
print()
test_fill_box()
print()
test_fill_box_reversed()
print()
test_fill_box_clamping()
print()
test_mesh_dirty()
print()
test_vertex_positions()
print()
test_empty_grid()
print()
test_all_air()
print()
test_large_solid_cube()
print()
test_transparent_material()
print()
print("=" * 60)
print(f"Results: {passed} passed, {failed} failed")
print("=" * 60)
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""Unit tests for Milestone 12: VoxelGrid Navigation Projection
Tests VoxelGrid.project_column() and Viewport3D voxel-to-nav projection methods.
"""
import mcrfpy
import sys
# Test counters
tests_passed = 0
tests_failed = 0
def test(name, condition):
"""Simple test helper"""
global tests_passed, tests_failed
if condition:
tests_passed += 1
print(f" PASS: {name}")
else:
tests_failed += 1
print(f" FAIL: {name}")
def approx_eq(a, b, epsilon=0.001):
"""Approximate floating-point equality"""
return abs(a - b) < epsilon
# =============================================================================
# Test projectColumn() on VoxelGrid
# =============================================================================
print("\n=== Testing VoxelGrid.project_column() ===")
# Test 1: Empty grid - all air
vg = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0)
nav = vg.project_column(5, 5)
test("Empty grid - height is 0", approx_eq(nav['height'], 0.0))
test("Empty grid - not walkable (no floor)", nav['walkable'] == False)
test("Empty grid - transparent", nav['transparent'] == True)
test("Empty grid - default path cost", approx_eq(nav['path_cost'], 1.0))
# Test 2: Simple floor
vg2 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0)
stone = vg2.add_material("stone", (128, 128, 128))
vg2.fill_box((0, 0, 0), (9, 0, 9), stone) # Floor at y=0
nav2 = vg2.project_column(5, 5)
test("Floor at y=0 - height is 1.0 (top of floor voxel)", approx_eq(nav2['height'], 1.0))
test("Floor at y=0 - walkable", nav2['walkable'] == True)
test("Floor at y=0 - not transparent (has solid voxel)", nav2['transparent'] == False)
# Test 3: Solid column extending to top - no headroom at boundary
vg3 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0)
stone3 = vg3.add_material("stone", (128, 128, 128))
vg3.fill_box((0, 0, 0), (9, 0, 9), stone3) # Floor at y=0
vg3.fill_box((0, 2, 0), (9, 9, 9), stone3) # Solid block from y=2 to y=9
nav3 = vg3.project_column(5, 5, headroom=2)
# Scan finds y=9 as topmost floor (boundary has "air above" but no actual headroom)
# Height = 10.0 (top of y=9 voxel), no air above means airCount=0, so not walkable
test("Top boundary floor - height at top", approx_eq(nav3['height'], 10.0))
test("Top boundary floor - not walkable (no headroom)", nav3['walkable'] == False)
# Test 4: Single floor slab with plenty of headroom
vg4 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0)
stone4 = vg4.add_material("stone", (128, 128, 128))
vg4.fill_box((0, 2, 0), (9, 2, 9), stone4) # Floor slab at y=2 (air below, 7 voxels air above)
nav4 = vg4.project_column(5, 5, headroom=2)
test("Floor slab at y=2 - height is 3.0", approx_eq(nav4['height'], 3.0))
test("Floor slab - walkable (7 voxels headroom)", nav4['walkable'] == True)
# Test 5: Custom headroom thresholds
nav4_h1 = vg4.project_column(5, 5, headroom=1)
test("Headroom=1 - walkable", nav4_h1['walkable'] == True)
nav4_h7 = vg4.project_column(5, 5, headroom=7)
test("Headroom=7 - walkable (exactly 7 air voxels)", nav4_h7['walkable'] == True)
nav4_h8 = vg4.project_column(5, 5, headroom=8)
test("Headroom=8 - not walkable (only 7 air)", nav4_h8['walkable'] == False)
# Test 6: Multi-level floor (finds topmost walkable)
vg5 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0)
stone5 = vg5.add_material("stone", (128, 128, 128))
vg5.fill_box((0, 0, 0), (9, 0, 9), stone5) # Bottom floor at y=0
vg5.fill_box((0, 5, 0), (9, 5, 9), stone5) # Upper floor at y=5
nav5 = vg5.project_column(5, 5)
test("Multi-level - finds top floor", approx_eq(nav5['height'], 6.0))
test("Multi-level - walkable", nav5['walkable'] == True)
# Test 7: Transparent material
vg6 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0)
glass = vg6.add_material("glass", (200, 200, 255), transparent=True)
vg6.set(5, 5, 5, glass)
nav6 = vg6.project_column(5, 5)
test("Transparent voxel - column is transparent", nav6['transparent'] == True)
# Test 8: Non-transparent material
vg7 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0)
wall = vg7.add_material("wall", (100, 100, 100), transparent=False)
vg7.set(5, 5, 5, wall)
nav7 = vg7.project_column(5, 5)
test("Opaque voxel - column not transparent", nav7['transparent'] == False)
# Test 9: Path cost from material
vg8 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=1.0)
mud = vg8.add_material("mud", (139, 90, 43), path_cost=2.0)
vg8.fill_box((0, 0, 0), (9, 0, 9), mud) # Floor of mud
nav8 = vg8.project_column(5, 5)
test("Mud floor - path cost is 2.0", approx_eq(nav8['path_cost'], 2.0))
# Test 10: Cell size affects height
vg9 = mcrfpy.VoxelGrid((10, 10, 10), cell_size=2.0)
stone9 = vg9.add_material("stone", (128, 128, 128))
vg9.fill_box((0, 0, 0), (9, 0, 9), stone9) # Floor at y=0
nav9 = vg9.project_column(5, 5)
test("Cell size 2.0 - height is 2.0", approx_eq(nav9['height'], 2.0))
# Test 11: Out of bounds returns default
nav_oob = vg.project_column(-1, 5)
test("Out of bounds - not walkable", nav_oob['walkable'] == False)
test("Out of bounds - height 0", approx_eq(nav_oob['height'], 0.0))
# =============================================================================
# Test Viewport3D voxel-to-nav projection
# =============================================================================
print("\n=== Testing Viewport3D voxel-to-nav projection ===")
# Create viewport with navigation grid
vp = mcrfpy.Viewport3D(pos=(0, 0), size=(640, 480))
vp.set_grid_size(20, 20)
vp.cell_size = 1.0
# Test 12: Initial nav grid state
cell = vp.at(10, 10)
test("Initial nav cell - walkable", cell.walkable == True)
test("Initial nav cell - transparent", cell.transparent == True)
test("Initial nav cell - height 0", approx_eq(cell.height, 0.0))
test("Initial nav cell - cost 1", approx_eq(cell.cost, 1.0))
# Test 13: Project simple voxel grid
vg_nav = mcrfpy.VoxelGrid((10, 5, 10), cell_size=1.0)
stone_nav = vg_nav.add_material("stone", (128, 128, 128))
vg_nav.fill_box((0, 0, 0), (9, 0, 9), stone_nav) # Floor
vg_nav.offset = (5, 0, 5) # Position grid at (5, 0, 5) in world
vp.add_voxel_layer(vg_nav)
vp.project_voxel_to_nav(vg_nav, headroom=2)
# Check cell within grid footprint
cell_in = vp.at(10, 10) # World (10, 10) = voxel grid local (5, 5)
test("Projected cell - walkable (floor present)", cell_in.walkable == True)
test("Projected cell - height is 1.0", approx_eq(cell_in.height, 1.0))
test("Projected cell - not transparent", cell_in.transparent == False)
# Check cell outside grid footprint (unchanged)
cell_out = vp.at(0, 0) # Outside voxel grid area
test("Outside cell - still walkable (unchanged)", cell_out.walkable == True)
test("Outside cell - height still 0", approx_eq(cell_out.height, 0.0))
# Test 14: Clear voxel nav region
vp.clear_voxel_nav_region(vg_nav)
cell_cleared = vp.at(10, 10)
test("Cleared cell - walkable reset to true", cell_cleared.walkable == True)
test("Cleared cell - height reset to 0", approx_eq(cell_cleared.height, 0.0))
test("Cleared cell - transparent reset to true", cell_cleared.transparent == True)
# Test 15: Project with walls (blocking)
vg_wall = mcrfpy.VoxelGrid((10, 5, 10), cell_size=1.0)
stone_wall = vg_wall.add_material("stone", (128, 128, 128))
vg_wall.fill_box((0, 0, 0), (9, 4, 9), stone_wall) # Solid block (no air above floor)
vg_wall.offset = (0, 0, 0)
vp2 = mcrfpy.Viewport3D(pos=(0, 0), size=(640, 480))
vp2.set_grid_size(20, 20)
vp2.add_voxel_layer(vg_wall)
vp2.project_voxel_to_nav(vg_wall)
cell_wall = vp2.at(5, 5)
test("Solid block - height at top", approx_eq(cell_wall.height, 5.0))
test("Solid block - not transparent", cell_wall.transparent == False)
# Test 16: project_all_voxels_to_nav with multiple layers
vp3 = mcrfpy.Viewport3D(pos=(0, 0), size=(640, 480))
vp3.set_grid_size(20, 20)
# First layer - lower priority
vg_layer1 = mcrfpy.VoxelGrid((20, 5, 20), cell_size=1.0)
dirt = vg_layer1.add_material("dirt", (139, 90, 43))
vg_layer1.fill_box((0, 0, 0), (19, 0, 19), dirt) # Floor everywhere
# Second layer - higher priority, partial coverage
vg_layer2 = mcrfpy.VoxelGrid((5, 5, 5), cell_size=1.0)
stone_l2 = vg_layer2.add_material("stone", (128, 128, 128))
vg_layer2.fill_box((0, 0, 0), (4, 2, 4), stone_l2) # Higher floor
vg_layer2.offset = (5, 0, 5)
vp3.add_voxel_layer(vg_layer1, z_index=0)
vp3.add_voxel_layer(vg_layer2, z_index=1)
vp3.project_all_voxels_to_nav()
cell_dirt = vp3.at(0, 0) # Only dirt layer
cell_stone = vp3.at(7, 7) # Stone layer overlaps (higher z_index)
test("Multi-layer - dirt area height is 1", approx_eq(cell_dirt.height, 1.0))
test("Multi-layer - stone area height is 3 (higher layer)", approx_eq(cell_stone.height, 3.0))
# Test 17: Viewport projection with different headroom values
vg_low = mcrfpy.VoxelGrid((10, 5, 10), cell_size=1.0)
stone_low = vg_low.add_material("stone", (128, 128, 128))
vg_low.fill_box((0, 0, 0), (9, 0, 9), stone_low) # Floor at y=0
# Grid has height=5, so floor at y=0 has 4 air voxels above (y=1,2,3,4)
vp4 = mcrfpy.Viewport3D(pos=(0, 0), size=(640, 480))
vp4.set_grid_size(20, 20)
vp4.add_voxel_layer(vg_low)
vp4.project_voxel_to_nav(vg_low, headroom=1)
test("Headroom 1 - walkable (4 air voxels)", vp4.at(5, 5).walkable == True)
vp4.project_voxel_to_nav(vg_low, headroom=4)
test("Headroom 4 - walkable (exactly 4 air)", vp4.at(5, 5).walkable == True)
vp4.project_voxel_to_nav(vg_low, headroom=5)
test("Headroom 5 - not walkable (only 4 air)", vp4.at(5, 5).walkable == False)
# Test 18: Grid offset in world space
vg_offset = mcrfpy.VoxelGrid((5, 5, 5), cell_size=1.0)
stone_off = vg_offset.add_material("stone", (128, 128, 128))
vg_offset.fill_box((0, 0, 0), (4, 0, 4), stone_off)
vg_offset.offset = (10, 5, 10) # Y offset = 5
vp5 = mcrfpy.Viewport3D(pos=(0, 0), size=(640, 480))
vp5.set_grid_size(20, 20)
vp5.add_voxel_layer(vg_offset)
vp5.project_voxel_to_nav(vg_offset)
cell_off = vp5.at(12, 12)
test("Y-offset grid - height includes offset", approx_eq(cell_off.height, 6.0)) # floor 1 + offset 5
# =============================================================================
# Summary
# =============================================================================
print(f"\n=== Results: {tests_passed} passed, {tests_failed} failed ===")
if tests_failed > 0:
sys.exit(1)
else:
print("All tests passed!")
sys.exit(0)

View file

@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""Unit tests for VoxelGrid rendering integration (Milestone 10)
Tests:
- Adding voxel layer to viewport
- Removing voxel layer from viewport
- Voxel layer count tracking
- Screenshot verification (visual rendering)
"""
import sys
# Track test results
passed = 0
failed = 0
def test(name, condition, detail=""):
"""Record test result"""
global passed, failed
if condition:
print(f"[PASS] {name}")
passed += 1
else:
print(f"[FAIL] {name}" + (f" - {detail}" if detail else ""))
failed += 1
def test_add_to_viewport():
"""Test adding a voxel layer to viewport"""
import mcrfpy
# Create viewport
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
# Create voxel grid
vg = mcrfpy.VoxelGrid(size=(8, 8, 8))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
vg.set(4, 4, 4, stone)
# Initial layer count
test("Add to viewport: initial count is 0", viewport.voxel_layer_count() == 0)
# Add voxel layer
viewport.add_voxel_layer(vg, z_index=1)
test("Add to viewport: count increases to 1", viewport.voxel_layer_count() == 1)
def test_add_multiple_layers():
"""Test adding multiple voxel layers"""
import mcrfpy
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
vg1 = mcrfpy.VoxelGrid(size=(4, 4, 4))
vg2 = mcrfpy.VoxelGrid(size=(4, 4, 4))
vg3 = mcrfpy.VoxelGrid(size=(4, 4, 4))
viewport.add_voxel_layer(vg1, z_index=0)
viewport.add_voxel_layer(vg2, z_index=1)
viewport.add_voxel_layer(vg3, z_index=2)
test("Multiple layers: count is 3", viewport.voxel_layer_count() == 3)
def test_remove_from_viewport():
"""Test removing a voxel layer from viewport"""
import mcrfpy
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
vg1 = mcrfpy.VoxelGrid(size=(4, 4, 4))
vg2 = mcrfpy.VoxelGrid(size=(4, 4, 4))
viewport.add_voxel_layer(vg1, z_index=0)
viewport.add_voxel_layer(vg2, z_index=1)
test("Remove: initial count is 2", viewport.voxel_layer_count() == 2)
# Remove one layer
result = viewport.remove_voxel_layer(vg1)
test("Remove: returns True for existing layer", result == True)
test("Remove: count decreases to 1", viewport.voxel_layer_count() == 1)
# Remove same layer again should return False
result = viewport.remove_voxel_layer(vg1)
test("Remove: returns False for non-existing layer", result == False)
test("Remove: count still 1", viewport.voxel_layer_count() == 1)
def test_remove_nonexistent():
"""Test removing a layer that was never added"""
import mcrfpy
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
vg = mcrfpy.VoxelGrid(size=(4, 4, 4))
result = viewport.remove_voxel_layer(vg)
test("Remove nonexistent: returns False", result == False)
def test_add_invalid_type():
"""Test that adding non-VoxelGrid raises error"""
import mcrfpy
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
error_raised = False
try:
viewport.add_voxel_layer("not a voxel grid")
except TypeError:
error_raised = True
test("Add invalid type: raises TypeError", error_raised)
def test_z_index_parameter():
"""Test that z_index parameter is accepted"""
import mcrfpy
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
vg = mcrfpy.VoxelGrid(size=(4, 4, 4))
# Should not raise error
error_raised = False
try:
viewport.add_voxel_layer(vg, z_index=5)
except Exception as e:
error_raised = True
print(f" Error: {e}")
test("Z-index parameter: accepted without error", not error_raised)
def test_viewport_in_scene():
"""Test viewport with voxel layer added to a scene"""
import mcrfpy
# Create and activate a test scene
scene = mcrfpy.Scene("voxel_test_scene")
# Create viewport
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
# Create voxel grid with visible content
vg = mcrfpy.VoxelGrid(size=(8, 8, 8))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
vg.fill_box((2, 0, 2), (5, 3, 5), stone)
vg.offset = (0, 0, 0)
# Add voxel layer to viewport
viewport.add_voxel_layer(vg, z_index=0)
# Position camera to see the voxels
viewport.camera_pos = (10, 10, 10)
viewport.camera_target = (4, 2, 4)
# Add viewport to scene
scene.children.append(viewport)
# Trigger mesh generation
vg.rebuild_mesh()
test("Viewport in scene: voxel layer added", viewport.voxel_layer_count() == 1)
test("Viewport in scene: voxels have content", vg.count_non_air() > 0)
test("Viewport in scene: mesh generated", vg.vertex_count > 0)
def main():
"""Run all rendering integration tests"""
print("=" * 60)
print("VoxelGrid Rendering Integration Tests (Milestone 10)")
print("=" * 60)
print()
test_add_to_viewport()
print()
test_add_multiple_layers()
print()
test_remove_from_viewport()
print()
test_remove_nonexistent()
print()
test_add_invalid_type()
print()
test_z_index_parameter()
print()
test_viewport_in_scene()
print()
print("=" * 60)
print(f"Results: {passed} passed, {failed} failed")
print("=" * 60)
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,301 @@
#!/usr/bin/env python3
"""Unit tests for Milestone 14: VoxelGrid Serialization
Tests save/load to file and to_bytes/from_bytes memory serialization.
"""
import mcrfpy
import sys
import os
import tempfile
# Test counters
tests_passed = 0
tests_failed = 0
def test(name, condition):
"""Simple test helper"""
global tests_passed, tests_failed
if condition:
tests_passed += 1
print(f" PASS: {name}")
else:
tests_failed += 1
print(f" FAIL: {name}")
# =============================================================================
# Test basic save/load
# =============================================================================
print("\n=== Testing basic save/load ===")
# Create a test grid with materials and voxel data
vg = mcrfpy.VoxelGrid((8, 8, 8), cell_size=1.0)
stone = vg.add_material("stone", (128, 128, 128))
wood = vg.add_material("wood", (139, 90, 43), transparent=False, path_cost=0.8)
glass = vg.add_material("glass", (200, 220, 255, 128), transparent=True, path_cost=1.5)
# Fill with some patterns
vg.fill_box((0, 0, 0), (7, 0, 7), stone) # Floor
vg.fill_box((0, 1, 0), (0, 3, 7), wood) # Wall
vg.set(4, 1, 4, glass) # Single glass block
original_non_air = vg.count_non_air()
original_stone = vg.count_material(stone)
original_wood = vg.count_material(wood)
original_glass = vg.count_material(glass)
print(f" Original grid: {original_non_air} non-air voxels")
print(f" Stone={original_stone}, Wood={original_wood}, Glass={original_glass}")
# Save to temp file
with tempfile.NamedTemporaryFile(suffix='.mcvg', delete=False) as f:
temp_path = f.name
save_result = vg.save(temp_path)
test("save() returns True on success", save_result == True)
test("File was created", os.path.exists(temp_path))
file_size = os.path.getsize(temp_path)
print(f" File size: {file_size} bytes")
test("File has non-zero size", file_size > 0)
# Create new grid and load
vg2 = mcrfpy.VoxelGrid((1, 1, 1)) # Start with tiny grid
load_result = vg2.load(temp_path)
test("load() returns True on success", load_result == True)
# Verify loaded data matches
test("Loaded size matches original", vg2.size == (8, 8, 8))
test("Loaded cell_size matches", vg2.cell_size == 1.0)
test("Loaded material_count matches", vg2.material_count == 3)
test("Loaded count_non_air matches", vg2.count_non_air() == original_non_air)
test("Loaded stone count matches", vg2.count_material(stone) == original_stone)
test("Loaded wood count matches", vg2.count_material(wood) == original_wood)
test("Loaded glass count matches", vg2.count_material(glass) == original_glass)
# Clean up temp file
os.unlink(temp_path)
test("Temp file cleaned up", not os.path.exists(temp_path))
# =============================================================================
# Test to_bytes/from_bytes
# =============================================================================
print("\n=== Testing to_bytes/from_bytes ===")
vg3 = mcrfpy.VoxelGrid((4, 4, 4), cell_size=2.0)
mat1 = vg3.add_material("test_mat", (255, 0, 0))
vg3.fill_box((1, 1, 1), (2, 2, 2), mat1)
original_bytes = vg3.to_bytes()
test("to_bytes() returns bytes", isinstance(original_bytes, bytes))
test("Bytes have content", len(original_bytes) > 0)
print(f" Serialized to {len(original_bytes)} bytes")
# Load into new grid
vg4 = mcrfpy.VoxelGrid((1, 1, 1))
load_result = vg4.from_bytes(original_bytes)
test("from_bytes() returns True", load_result == True)
test("Bytes loaded - size matches", vg4.size == (4, 4, 4))
test("Bytes loaded - cell_size matches", vg4.cell_size == 2.0)
test("Bytes loaded - voxels match", vg4.count_non_air() == vg3.count_non_air())
# =============================================================================
# Test material preservation
# =============================================================================
print("\n=== Testing material preservation ===")
vg5 = mcrfpy.VoxelGrid((4, 4, 4))
mat_a = vg5.add_material("alpha", (10, 20, 30, 200), sprite_index=5, transparent=True, path_cost=0.5)
mat_b = vg5.add_material("beta", (100, 110, 120, 255), sprite_index=-1, transparent=False, path_cost=2.0)
vg5.set(0, 0, 0, mat_a)
vg5.set(1, 1, 1, mat_b)
data = vg5.to_bytes()
vg6 = mcrfpy.VoxelGrid((1, 1, 1))
vg6.from_bytes(data)
# Check first material
mat_a_loaded = vg6.get_material(1)
test("Material 1 name preserved", mat_a_loaded['name'] == "alpha")
test("Material 1 color R preserved", mat_a_loaded['color'].r == 10)
test("Material 1 color G preserved", mat_a_loaded['color'].g == 20)
test("Material 1 color B preserved", mat_a_loaded['color'].b == 30)
test("Material 1 color A preserved", mat_a_loaded['color'].a == 200)
test("Material 1 sprite_index preserved", mat_a_loaded['sprite_index'] == 5)
test("Material 1 transparent preserved", mat_a_loaded['transparent'] == True)
test("Material 1 path_cost preserved", abs(mat_a_loaded['path_cost'] - 0.5) < 0.001)
# Check second material
mat_b_loaded = vg6.get_material(2)
test("Material 2 name preserved", mat_b_loaded['name'] == "beta")
test("Material 2 transparent preserved", mat_b_loaded['transparent'] == False)
test("Material 2 path_cost preserved", abs(mat_b_loaded['path_cost'] - 2.0) < 0.001)
# =============================================================================
# Test voxel data integrity
# =============================================================================
print("\n=== Testing voxel data integrity ===")
vg7 = mcrfpy.VoxelGrid((16, 16, 16))
mat = vg7.add_material("checker", (255, 255, 255))
# Create checkerboard pattern
for z in range(16):
for y in range(16):
for x in range(16):
if (x + y + z) % 2 == 0:
vg7.set(x, y, z, mat)
original_count = vg7.count_non_air()
print(f" Original checkerboard: {original_count} voxels")
# Save/load
data = vg7.to_bytes()
print(f" Serialized size: {len(data)} bytes")
vg8 = mcrfpy.VoxelGrid((1, 1, 1))
vg8.from_bytes(data)
test("Checkerboard voxel count preserved", vg8.count_non_air() == original_count)
# Verify individual voxels
all_match = True
for z in range(16):
for y in range(16):
for x in range(16):
expected = mat if (x + y + z) % 2 == 0 else 0
actual = vg8.get(x, y, z)
if actual != expected:
all_match = False
break
if not all_match:
break
test("All checkerboard voxels match", all_match)
# =============================================================================
# Test RLE compression effectiveness
# =============================================================================
print("\n=== Testing RLE compression ===")
# Test with uniform data (should compress well)
vg9 = mcrfpy.VoxelGrid((32, 32, 32))
mat_uniform = vg9.add_material("solid", (100, 100, 100))
vg9.fill(mat_uniform)
uniform_bytes = vg9.to_bytes()
raw_size = 32 * 32 * 32 # 32768 bytes uncompressed
compressed_size = len(uniform_bytes)
compression_ratio = raw_size / compressed_size if compressed_size > 0 else 0
print(f" Uniform 32x32x32: raw={raw_size}, compressed={compressed_size}")
print(f" Compression ratio: {compression_ratio:.1f}x")
test("Uniform data compresses significantly (>10x)", compression_ratio > 10)
# Test with alternating data (should compress poorly)
vg10 = mcrfpy.VoxelGrid((32, 32, 32))
mat_alt = vg10.add_material("alt", (200, 200, 200))
for z in range(32):
for y in range(32):
for x in range(32):
if (x + y + z) % 2 == 0:
vg10.set(x, y, z, mat_alt)
alt_bytes = vg10.to_bytes()
alt_ratio = raw_size / len(alt_bytes) if len(alt_bytes) > 0 else 0
print(f" Alternating pattern: compressed={len(alt_bytes)}")
print(f" Compression ratio: {alt_ratio:.1f}x")
# Alternating data should still compress somewhat due to row patterns
test("Alternating data serializes successfully", len(alt_bytes) > 0)
# =============================================================================
# Test error handling
# =============================================================================
print("\n=== Testing error handling ===")
vg_err = mcrfpy.VoxelGrid((2, 2, 2))
# Test load from non-existent file
load_fail = vg_err.load("/nonexistent/path/file.mcvg")
test("load() returns False for non-existent file", load_fail == False)
# Test from_bytes with invalid data
invalid_data = b"not valid mcvg data"
from_fail = vg_err.from_bytes(invalid_data)
test("from_bytes() returns False for invalid data", from_fail == False)
# Test from_bytes with truncated data
vg_good = mcrfpy.VoxelGrid((2, 2, 2))
good_data = vg_good.to_bytes()
truncated = good_data[:10] # Take only first 10 bytes
from_truncated = vg_err.from_bytes(truncated)
test("from_bytes() returns False for truncated data", from_truncated == False)
# =============================================================================
# Test large grid
# =============================================================================
print("\n=== Testing large grid ===")
vg_large = mcrfpy.VoxelGrid((64, 32, 64))
mat_large = vg_large.add_material("large", (50, 50, 50))
# Fill floor and some walls
vg_large.fill_box((0, 0, 0), (63, 0, 63), mat_large) # Floor
vg_large.fill_box((0, 1, 0), (0, 31, 63), mat_large) # Wall
large_bytes = vg_large.to_bytes()
print(f" 64x32x64 grid: {len(large_bytes)} bytes")
vg_large2 = mcrfpy.VoxelGrid((1, 1, 1))
vg_large2.from_bytes(large_bytes)
test("Large grid size preserved", vg_large2.size == (64, 32, 64))
test("Large grid voxels preserved", vg_large2.count_non_air() == vg_large.count_non_air())
# =============================================================================
# Test round-trip with transform
# =============================================================================
print("\n=== Testing transform preservation (not serialized) ===")
# Note: Transform (offset, rotation) is NOT serialized - it's runtime state
vg_trans = mcrfpy.VoxelGrid((4, 4, 4))
vg_trans.offset = (10, 20, 30)
vg_trans.rotation = 45.0
mat_trans = vg_trans.add_material("trans", (128, 128, 128))
vg_trans.set(0, 0, 0, mat_trans)
data_trans = vg_trans.to_bytes()
vg_trans2 = mcrfpy.VoxelGrid((1, 1, 1))
vg_trans2.from_bytes(data_trans)
# Voxel data should be preserved
test("Voxel data preserved after load", vg_trans2.get(0, 0, 0) == mat_trans)
# Transform should be at default (not serialized)
test("Offset resets to default after load", vg_trans2.offset == (0, 0, 0))
test("Rotation resets to default after load", vg_trans2.rotation == 0.0)
# =============================================================================
# Summary
# =============================================================================
print(f"\n=== Results: {tests_passed} passed, {tests_failed} failed ===")
if tests_failed > 0:
sys.exit(1)
else:
print("All tests passed!")
sys.exit(0)

View file

@ -0,0 +1,345 @@
#!/usr/bin/env python3
"""Unit tests for VoxelGrid (Milestone 9)
Tests the core VoxelGrid data structure:
- Creation with various sizes
- Per-voxel get/set operations
- Bounds checking behavior
- Material palette management
- Bulk operations (fill, clear)
- Transform properties (offset, rotation)
- Statistics (count_non_air, count_material)
"""
import sys
# Track test results
passed = 0
failed = 0
def test(name, condition, detail=""):
"""Record test result"""
global passed, failed
if condition:
print(f"[PASS] {name}")
passed += 1
else:
print(f"[FAIL] {name}" + (f" - {detail}" if detail else ""))
failed += 1
def test_creation():
"""Test VoxelGrid creation with various parameters"""
import mcrfpy
# Basic creation
vg = mcrfpy.VoxelGrid(size=(16, 8, 16))
test("Creation: basic", vg is not None)
test("Creation: width", vg.width == 16)
test("Creation: height", vg.height == 8)
test("Creation: depth", vg.depth == 16)
test("Creation: default cell_size", vg.cell_size == 1.0)
# With cell_size
vg2 = mcrfpy.VoxelGrid(size=(10, 5, 10), cell_size=2.0)
test("Creation: custom cell_size", vg2.cell_size == 2.0)
# Size property
test("Creation: size tuple", vg.size == (16, 8, 16))
# Initial state
test("Creation: initially empty", vg.count_non_air() == 0)
test("Creation: no materials", vg.material_count == 0)
def test_invalid_creation():
"""Test that invalid parameters raise errors"""
import mcrfpy
errors_caught = 0
try:
vg = mcrfpy.VoxelGrid(size=(0, 8, 16))
except ValueError:
errors_caught += 1
try:
vg = mcrfpy.VoxelGrid(size=(16, -1, 16))
except ValueError:
errors_caught += 1
try:
vg = mcrfpy.VoxelGrid(size=(16, 8, 16), cell_size=-1.0)
except ValueError:
errors_caught += 1
try:
vg = mcrfpy.VoxelGrid(size=(16, 8)) # Missing dimension
except (ValueError, TypeError):
errors_caught += 1
test("Invalid creation: catches errors", errors_caught == 4, f"caught {errors_caught}/4")
def test_get_set():
"""Test per-voxel get/set operations"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(16, 8, 16))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Initially all air
test("Get/Set: initial value is air", vg.get(0, 0, 0) == 0)
# Set and get
vg.set(5, 3, 7, stone)
test("Get/Set: set then get", vg.get(5, 3, 7) == stone)
# Verify adjacent cells unaffected
test("Get/Set: adjacent unaffected", vg.get(5, 3, 6) == 0)
test("Get/Set: adjacent unaffected 2", vg.get(4, 3, 7) == 0)
# Set back to air
vg.set(5, 3, 7, 0)
test("Get/Set: set to air", vg.get(5, 3, 7) == 0)
# Multiple materials
wood = vg.add_material("wood", color=mcrfpy.Color(139, 90, 43))
vg.set(0, 0, 0, stone)
vg.set(1, 0, 0, wood)
vg.set(2, 0, 0, stone)
test("Get/Set: multiple materials",
vg.get(0, 0, 0) == stone and vg.get(1, 0, 0) == wood and vg.get(2, 0, 0) == stone)
def test_bounds():
"""Test bounds checking behavior"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(8, 4, 8))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Out of bounds get returns 0 (air)
test("Bounds: negative x", vg.get(-1, 0, 0) == 0)
test("Bounds: negative y", vg.get(0, -1, 0) == 0)
test("Bounds: negative z", vg.get(0, 0, -1) == 0)
test("Bounds: overflow x", vg.get(8, 0, 0) == 0)
test("Bounds: overflow y", vg.get(0, 4, 0) == 0)
test("Bounds: overflow z", vg.get(0, 0, 8) == 0)
test("Bounds: large overflow", vg.get(100, 100, 100) == 0)
# Out of bounds set is silently ignored (no crash)
vg.set(-1, 0, 0, stone) # Should not crash
vg.set(100, 0, 0, stone) # Should not crash
test("Bounds: OOB set doesn't crash", True)
# Corner cases - max valid indices
vg.set(7, 3, 7, stone)
test("Bounds: max valid index", vg.get(7, 3, 7) == stone)
def test_materials():
"""Test material palette management"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(8, 8, 8))
# Add first material
stone_id = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
test("Materials: first ID is 1", stone_id == 1)
# Add with all properties
glass_id = vg.add_material("glass",
color=mcrfpy.Color(200, 200, 255, 128),
sprite_index=5,
transparent=True,
path_cost=0.5)
test("Materials: second ID is 2", glass_id == 2)
# Verify material count
test("Materials: count", vg.material_count == 2)
# Get material and verify properties
stone = vg.get_material(stone_id)
test("Materials: name", stone["name"] == "stone")
test("Materials: color type", hasattr(stone["color"], 'r'))
test("Materials: default sprite_index", stone["sprite_index"] == -1)
test("Materials: default transparent", stone["transparent"] == False)
test("Materials: default path_cost", stone["path_cost"] == 1.0)
glass = vg.get_material(glass_id)
test("Materials: custom sprite_index", glass["sprite_index"] == 5)
test("Materials: custom transparent", glass["transparent"] == True)
test("Materials: custom path_cost", glass["path_cost"] == 0.5)
# Air material (ID 0)
air = vg.get_material(0)
test("Materials: air name", air["name"] == "air")
test("Materials: air transparent", air["transparent"] == True)
# Invalid material ID returns air
invalid = vg.get_material(255)
test("Materials: invalid returns air", invalid["name"] == "air")
def test_fill_clear():
"""Test bulk fill and clear operations"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(10, 5, 10))
total = 10 * 5 * 10 # 500
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Fill with material
vg.fill(stone)
test("Fill: all cells filled", vg.count_non_air() == total)
test("Fill: specific cell", vg.get(5, 2, 5) == stone)
test("Fill: corner cell", vg.get(0, 0, 0) == stone)
test("Fill: opposite corner", vg.get(9, 4, 9) == stone)
# Clear (fill with air)
vg.clear()
test("Clear: all cells empty", vg.count_non_air() == 0)
test("Clear: specific cell", vg.get(5, 2, 5) == 0)
def test_transform():
"""Test transform properties (offset, rotation)"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(8, 8, 8))
# Default values
test("Transform: default offset", vg.offset == (0.0, 0.0, 0.0))
test("Transform: default rotation", vg.rotation == 0.0)
# Set offset
vg.offset = (10.5, -5.0, 20.0)
offset = vg.offset
test("Transform: set offset x", abs(offset[0] - 10.5) < 0.001)
test("Transform: set offset y", abs(offset[1] - (-5.0)) < 0.001)
test("Transform: set offset z", abs(offset[2] - 20.0) < 0.001)
# Set rotation
vg.rotation = 45.0
test("Transform: set rotation", abs(vg.rotation - 45.0) < 0.001)
# Negative rotation
vg.rotation = -90.0
test("Transform: negative rotation", abs(vg.rotation - (-90.0)) < 0.001)
# Large rotation
vg.rotation = 720.0
test("Transform: large rotation", abs(vg.rotation - 720.0) < 0.001)
def test_statistics():
"""Test statistics methods"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(10, 10, 10))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
wood = vg.add_material("wood", color=mcrfpy.Color(139, 90, 43))
# Initially empty
test("Stats: initial non_air", vg.count_non_air() == 0)
test("Stats: initial stone count", vg.count_material(stone) == 0)
# Add some voxels
for i in range(5):
vg.set(i, 0, 0, stone)
for i in range(3):
vg.set(i, 1, 0, wood)
test("Stats: non_air after setting", vg.count_non_air() == 8)
test("Stats: stone count", vg.count_material(stone) == 5)
test("Stats: wood count", vg.count_material(wood) == 3)
test("Stats: air count", vg.count_material(0) == 10*10*10 - 8)
def test_repr():
"""Test string representation"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(16, 8, 16))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
vg.set(0, 0, 0, stone)
repr_str = repr(vg)
test("Repr: contains VoxelGrid", "VoxelGrid" in repr_str)
test("Repr: contains dimensions", "16x8x16" in repr_str)
test("Repr: contains materials", "materials=1" in repr_str)
test("Repr: contains non_air", "non_air=1" in repr_str)
def test_large_grid():
"""Test with larger grid sizes"""
import mcrfpy
# 64x64x64 = 262144 voxels
vg = mcrfpy.VoxelGrid(size=(64, 64, 64))
test("Large: creation", vg is not None)
test("Large: size", vg.size == (64, 64, 64))
stone = vg.add_material("stone", color=mcrfpy.Color(128, 128, 128))
# Fill entire grid
vg.fill(stone)
expected = 64 * 64 * 64
test("Large: fill count", vg.count_non_air() == expected, f"got {vg.count_non_air()}, expected {expected}")
# Clear
vg.clear()
test("Large: clear", vg.count_non_air() == 0)
def test_material_limit():
"""Test material palette limit (255 max)"""
import mcrfpy
vg = mcrfpy.VoxelGrid(size=(8, 8, 8))
# Add many materials
for i in range(255):
mat_id = vg.add_material(f"mat_{i}", color=mcrfpy.Color(i, i, i))
if mat_id != i + 1:
test("Material limit: IDs sequential", False, f"expected {i+1}, got {mat_id}")
return
test("Material limit: 255 materials added", vg.material_count == 255)
# 256th should fail
try:
vg.add_material("overflow", color=mcrfpy.Color(255, 255, 255))
test("Material limit: overflow error", False, "should have raised exception")
except RuntimeError:
test("Material limit: overflow error", True)
def main():
"""Run all tests"""
print("=" * 60)
print("VoxelGrid Unit Tests (Milestone 9)")
print("=" * 60)
print()
test_creation()
print()
test_invalid_creation()
print()
test_get_set()
print()
test_bounds()
print()
test_materials()
print()
test_fill_clear()
print()
test_transform()
print()
test_statistics()
print()
test_repr()
print()
test_large_grid()
print()
test_material_limit()
print()
print("=" * 60)
print(f"Results: {passed} passed, {failed} failed")
print("=" * 60)
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,196 @@
# voxelpoint_test.py - Unit tests for VoxelPoint navigation grid
# Tests grid creation, cell access, and property modification
import mcrfpy
import sys
def test_grid_creation():
"""Test creating and sizing a navigation grid"""
print("Testing navigation grid creation...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
# Initial grid should be 0x0
assert viewport.grid_size == (0, 0), f"Expected (0, 0), got {viewport.grid_size}"
# Set grid size via property
viewport.grid_size = (10, 8)
assert viewport.grid_size == (10, 8), f"Expected (10, 8), got {viewport.grid_size}"
# Set grid size via method
viewport.set_grid_size(20, 15)
assert viewport.grid_size == (20, 15), f"Expected (20, 15), got {viewport.grid_size}"
print(" PASS: Grid creation and sizing")
def test_voxelpoint_access():
"""Test accessing VoxelPoint cells"""
print("Testing VoxelPoint access...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
viewport.grid_size = (10, 10)
# Access a cell
vp = viewport.at(5, 5)
assert vp is not None, "at() returned None"
# Check grid_pos
assert vp.grid_pos == (5, 5), f"Expected grid_pos (5, 5), got {vp.grid_pos}"
# Test bounds checking
try:
viewport.at(-1, 0)
assert False, "Expected IndexError for negative coordinate"
except IndexError:
pass
try:
viewport.at(10, 5) # Out of bounds (0-9 valid)
assert False, "Expected IndexError for out of bounds"
except IndexError:
pass
print(" PASS: VoxelPoint access")
def test_voxelpoint_properties():
"""Test VoxelPoint property read/write"""
print("Testing VoxelPoint properties...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
viewport.grid_size = (10, 10)
vp = viewport.at(3, 4)
# Test default values
assert vp.walkable == True, f"Default walkable should be True, got {vp.walkable}"
assert vp.transparent == True, f"Default transparent should be True, got {vp.transparent}"
assert vp.height == 0.0, f"Default height should be 0.0, got {vp.height}"
assert vp.cost == 1.0, f"Default cost should be 1.0, got {vp.cost}"
# Test setting bool properties
vp.walkable = False
assert vp.walkable == False, "walkable not set to False"
vp.transparent = False
assert vp.transparent == False, "transparent not set to False"
# Test setting float properties
vp.height = 5.5
assert abs(vp.height - 5.5) < 0.01, f"height should be 5.5, got {vp.height}"
vp.cost = 2.0
assert abs(vp.cost - 2.0) < 0.01, f"cost should be 2.0, got {vp.cost}"
# Test cost must be non-negative
try:
vp.cost = -1.0
assert False, "Expected ValueError for negative cost"
except ValueError:
pass
print(" PASS: VoxelPoint properties")
def test_voxelpoint_persistence():
"""Test that VoxelPoint changes persist in the grid"""
print("Testing VoxelPoint persistence...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
viewport.grid_size = (10, 10)
# Modify a cell
vp = viewport.at(2, 3)
vp.walkable = False
vp.height = 10.0
vp.cost = 3.5
# Access the same cell again
vp2 = viewport.at(2, 3)
assert vp2.walkable == False, "walkable change did not persist"
assert abs(vp2.height - 10.0) < 0.01, "height change did not persist"
assert abs(vp2.cost - 3.5) < 0.01, "cost change did not persist"
# Make sure other cells are unaffected
vp3 = viewport.at(2, 4)
assert vp3.walkable == True, "Adjacent cell was modified"
assert vp3.height == 0.0, "Adjacent cell height was modified"
print(" PASS: VoxelPoint persistence")
def test_cell_size_property():
"""Test cell_size property"""
print("Testing cell_size property...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
# Default cell size should be 1.0
assert abs(viewport.cell_size - 1.0) < 0.01, f"Default cell_size should be 1.0, got {viewport.cell_size}"
# Set cell size
viewport.cell_size = 2.5
assert abs(viewport.cell_size - 2.5) < 0.01, f"cell_size should be 2.5, got {viewport.cell_size}"
# cell_size must be positive
try:
viewport.cell_size = 0
assert False, "Expected ValueError for zero cell_size"
except ValueError:
pass
try:
viewport.cell_size = -1.0
assert False, "Expected ValueError for negative cell_size"
except ValueError:
pass
print(" PASS: cell_size property")
def test_repr():
"""Test VoxelPoint __repr__"""
print("Testing VoxelPoint repr...")
viewport = mcrfpy.Viewport3D(pos=(0, 0), size=(320, 240))
viewport.grid_size = (10, 10)
vp = viewport.at(3, 7)
r = repr(vp)
assert "VoxelPoint" in r, f"repr should contain 'VoxelPoint', got {r}"
assert "3, 7" in r, f"repr should contain '3, 7', got {r}"
print(" PASS: VoxelPoint repr")
def run_all_tests():
"""Run all unit tests"""
print("=" * 60)
print("VoxelPoint Unit Tests")
print("=" * 60)
try:
test_grid_creation()
test_voxelpoint_access()
test_voxelpoint_properties()
test_voxelpoint_persistence()
test_cell_size_property()
test_repr()
print("=" * 60)
print("ALL TESTS PASSED")
print("=" * 60)
sys.exit(0)
except AssertionError as e:
print(f"FAIL: {e}")
sys.exit(1)
except Exception as e:
print(f"ERROR: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
# Run tests
run_all_tests()