3D / voxel unit tests
This commit is contained in:
parent
e12e80e511
commit
71cd2b9b41
22 changed files with 4705 additions and 0 deletions
98
tests/unit/animated_model_test.py
Normal file
98
tests/unit/animated_model_test.py
Normal 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)
|
||||||
159
tests/unit/animation_test.py
Normal file
159
tests/unit/animation_test.py
Normal 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)
|
||||||
260
tests/unit/billboard_test.py
Normal file
260
tests/unit/billboard_test.py
Normal 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
293
tests/unit/entity3d_test.py
Normal 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
198
tests/unit/fov_3d_test.py
Normal 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()
|
||||||
78
tests/unit/integration_api_test.py
Normal file
78
tests/unit/integration_api_test.py
Normal 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)
|
||||||
182
tests/unit/mesh_instance_test.py
Normal file
182
tests/unit/mesh_instance_test.py
Normal 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)
|
||||||
202
tests/unit/meshlayer_test.py
Normal file
202
tests/unit/meshlayer_test.py
Normal 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
219
tests/unit/model3d_test.py
Normal 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)
|
||||||
208
tests/unit/pathfinding_3d_test.py
Normal file
208
tests/unit/pathfinding_3d_test.py
Normal 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()
|
||||||
241
tests/unit/procgen_interactive_test.py
Normal file
241
tests/unit/procgen_interactive_test.py
Normal 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()
|
||||||
80
tests/unit/skeleton_test.py
Normal file
80
tests/unit/skeleton_test.py
Normal 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)
|
||||||
189
tests/unit/tilemap_file_test.py
Normal file
189
tests/unit/tilemap_file_test.py
Normal 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()
|
||||||
145
tests/unit/tileset_file_test.py
Normal file
145
tests/unit/tileset_file_test.py
Normal 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()
|
||||||
335
tests/unit/voxel_bulk_ops_test.py
Normal file
335
tests/unit/voxel_bulk_ops_test.py
Normal 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())
|
||||||
240
tests/unit/voxel_greedy_meshing_test.py
Normal file
240
tests/unit/voxel_greedy_meshing_test.py
Normal 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)
|
||||||
300
tests/unit/voxel_meshing_test.py
Normal file
300
tests/unit/voxel_meshing_test.py
Normal 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())
|
||||||
247
tests/unit/voxel_navigation_test.py
Normal file
247
tests/unit/voxel_navigation_test.py
Normal 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)
|
||||||
189
tests/unit/voxel_rendering_test.py
Normal file
189
tests/unit/voxel_rendering_test.py
Normal 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())
|
||||||
301
tests/unit/voxel_serialization_test.py
Normal file
301
tests/unit/voxel_serialization_test.py
Normal 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)
|
||||||
345
tests/unit/voxelgrid_test.py
Normal file
345
tests/unit/voxelgrid_test.py
Normal 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())
|
||||||
196
tests/unit/voxelpoint_test.py
Normal file
196
tests/unit/voxelpoint_test.py
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue