From 71cd2b9b414ba5eb92ebc5338c489753cbc2959b Mon Sep 17 00:00:00 2001 From: John McCardle Date: Fri, 6 Feb 2026 16:15:07 -0500 Subject: [PATCH] 3D / voxel unit tests --- tests/unit/animated_model_test.py | 98 +++++++ tests/unit/animation_test.py | 159 +++++++++++ tests/unit/billboard_test.py | 260 ++++++++++++++++++ tests/unit/entity3d_test.py | 293 ++++++++++++++++++++ tests/unit/fov_3d_test.py | 198 ++++++++++++++ tests/unit/integration_api_test.py | 78 ++++++ tests/unit/mesh_instance_test.py | 182 +++++++++++++ tests/unit/meshlayer_test.py | 202 ++++++++++++++ tests/unit/model3d_test.py | 219 +++++++++++++++ tests/unit/pathfinding_3d_test.py | 208 ++++++++++++++ tests/unit/procgen_interactive_test.py | 241 +++++++++++++++++ tests/unit/skeleton_test.py | 80 ++++++ tests/unit/tilemap_file_test.py | 189 +++++++++++++ tests/unit/tileset_file_test.py | 145 ++++++++++ tests/unit/voxel_bulk_ops_test.py | 335 +++++++++++++++++++++++ tests/unit/voxel_greedy_meshing_test.py | 240 +++++++++++++++++ tests/unit/voxel_meshing_test.py | 300 +++++++++++++++++++++ tests/unit/voxel_navigation_test.py | 247 +++++++++++++++++ tests/unit/voxel_rendering_test.py | 189 +++++++++++++ tests/unit/voxel_serialization_test.py | 301 +++++++++++++++++++++ tests/unit/voxelgrid_test.py | 345 ++++++++++++++++++++++++ tests/unit/voxelpoint_test.py | 196 ++++++++++++++ 22 files changed, 4705 insertions(+) create mode 100644 tests/unit/animated_model_test.py create mode 100644 tests/unit/animation_test.py create mode 100644 tests/unit/billboard_test.py create mode 100644 tests/unit/entity3d_test.py create mode 100644 tests/unit/fov_3d_test.py create mode 100644 tests/unit/integration_api_test.py create mode 100644 tests/unit/mesh_instance_test.py create mode 100644 tests/unit/meshlayer_test.py create mode 100644 tests/unit/model3d_test.py create mode 100644 tests/unit/pathfinding_3d_test.py create mode 100644 tests/unit/procgen_interactive_test.py create mode 100644 tests/unit/skeleton_test.py create mode 100644 tests/unit/tilemap_file_test.py create mode 100644 tests/unit/tileset_file_test.py create mode 100644 tests/unit/voxel_bulk_ops_test.py create mode 100644 tests/unit/voxel_greedy_meshing_test.py create mode 100644 tests/unit/voxel_meshing_test.py create mode 100644 tests/unit/voxel_navigation_test.py create mode 100644 tests/unit/voxel_rendering_test.py create mode 100644 tests/unit/voxel_serialization_test.py create mode 100644 tests/unit/voxelgrid_test.py create mode 100644 tests/unit/voxelpoint_test.py diff --git a/tests/unit/animated_model_test.py b/tests/unit/animated_model_test.py new file mode 100644 index 0000000..9bf6347 --- /dev/null +++ b/tests/unit/animated_model_test.py @@ -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) diff --git a/tests/unit/animation_test.py b/tests/unit/animation_test.py new file mode 100644 index 0000000..52e833b --- /dev/null +++ b/tests/unit/animation_test.py @@ -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) diff --git a/tests/unit/billboard_test.py b/tests/unit/billboard_test.py new file mode 100644 index 0000000..d7d499e --- /dev/null +++ b/tests/unit/billboard_test.py @@ -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) diff --git a/tests/unit/entity3d_test.py b/tests/unit/entity3d_test.py new file mode 100644 index 0000000..21da752 --- /dev/null +++ b/tests/unit/entity3d_test.py @@ -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) diff --git a/tests/unit/fov_3d_test.py b/tests/unit/fov_3d_test.py new file mode 100644 index 0000000..186c7e4 --- /dev/null +++ b/tests/unit/fov_3d_test.py @@ -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() diff --git a/tests/unit/integration_api_test.py b/tests/unit/integration_api_test.py new file mode 100644 index 0000000..e1f6f74 --- /dev/null +++ b/tests/unit/integration_api_test.py @@ -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) diff --git a/tests/unit/mesh_instance_test.py b/tests/unit/mesh_instance_test.py new file mode 100644 index 0000000..05ac45a --- /dev/null +++ b/tests/unit/mesh_instance_test.py @@ -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) diff --git a/tests/unit/meshlayer_test.py b/tests/unit/meshlayer_test.py new file mode 100644 index 0000000..b734c88 --- /dev/null +++ b/tests/unit/meshlayer_test.py @@ -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() diff --git a/tests/unit/model3d_test.py b/tests/unit/model3d_test.py new file mode 100644 index 0000000..537dfa7 --- /dev/null +++ b/tests/unit/model3d_test.py @@ -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) diff --git a/tests/unit/pathfinding_3d_test.py b/tests/unit/pathfinding_3d_test.py new file mode 100644 index 0000000..8985b30 --- /dev/null +++ b/tests/unit/pathfinding_3d_test.py @@ -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() diff --git a/tests/unit/procgen_interactive_test.py b/tests/unit/procgen_interactive_test.py new file mode 100644 index 0000000..69fb8a4 --- /dev/null +++ b/tests/unit/procgen_interactive_test.py @@ -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() diff --git a/tests/unit/skeleton_test.py b/tests/unit/skeleton_test.py new file mode 100644 index 0000000..6b96a8e --- /dev/null +++ b/tests/unit/skeleton_test.py @@ -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) diff --git a/tests/unit/tilemap_file_test.py b/tests/unit/tilemap_file_test.py new file mode 100644 index 0000000..2aad7d7 --- /dev/null +++ b/tests/unit/tilemap_file_test.py @@ -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() diff --git a/tests/unit/tileset_file_test.py b/tests/unit/tileset_file_test.py new file mode 100644 index 0000000..83d3ac7 --- /dev/null +++ b/tests/unit/tileset_file_test.py @@ -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() diff --git a/tests/unit/voxel_bulk_ops_test.py b/tests/unit/voxel_bulk_ops_test.py new file mode 100644 index 0000000..614dcf6 --- /dev/null +++ b/tests/unit/voxel_bulk_ops_test.py @@ -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()) diff --git a/tests/unit/voxel_greedy_meshing_test.py b/tests/unit/voxel_greedy_meshing_test.py new file mode 100644 index 0000000..c2746fa --- /dev/null +++ b/tests/unit/voxel_greedy_meshing_test.py @@ -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) diff --git a/tests/unit/voxel_meshing_test.py b/tests/unit/voxel_meshing_test.py new file mode 100644 index 0000000..1d7130d --- /dev/null +++ b/tests/unit/voxel_meshing_test.py @@ -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()) diff --git a/tests/unit/voxel_navigation_test.py b/tests/unit/voxel_navigation_test.py new file mode 100644 index 0000000..73e497c --- /dev/null +++ b/tests/unit/voxel_navigation_test.py @@ -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) diff --git a/tests/unit/voxel_rendering_test.py b/tests/unit/voxel_rendering_test.py new file mode 100644 index 0000000..3f9ab5a --- /dev/null +++ b/tests/unit/voxel_rendering_test.py @@ -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()) diff --git a/tests/unit/voxel_serialization_test.py b/tests/unit/voxel_serialization_test.py new file mode 100644 index 0000000..1737168 --- /dev/null +++ b/tests/unit/voxel_serialization_test.py @@ -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) diff --git a/tests/unit/voxelgrid_test.py b/tests/unit/voxelgrid_test.py new file mode 100644 index 0000000..18cd37e --- /dev/null +++ b/tests/unit/voxelgrid_test.py @@ -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()) diff --git a/tests/unit/voxelpoint_test.py b/tests/unit/voxelpoint_test.py new file mode 100644 index 0000000..7b2ff7b --- /dev/null +++ b/tests/unit/voxelpoint_test.py @@ -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()