diff --git a/Writing-Tests.-.md b/Writing-Tests.-.md new file mode 100644 index 0000000..cba06d2 --- /dev/null +++ b/Writing-Tests.-.md @@ -0,0 +1,338 @@ +# Writing Tests + +Guide to creating automated tests for McRogueFace using Python and the automation API. + +## Quick Reference + +**Test Location:** `tests/` directory (NOT `build/tests/` - that gets shipped!) + +**Test Types:** +1. **Direct Execution** - No game loop, immediate results +2. **Timer-Based** - Requires rendering/game loop + +**Key Tools:** +- `mcrfpy.automation` - Screenshot and input automation +- `--headless` flag - Run without display +- `--exec` flag - Execute specific script + +**Format:** See CLAUDE.md "Testing Guidelines" section + +--- + +## Test Type 1: Direct Execution + +For tests that don't need rendering or game state. + +### Template + +```python +"""Test description goes here.""" +import mcrfpy +import sys + +def test_feature(): + # Setup + scene = mcrfpy.Scene("test") + frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) + scene.children.append(frame) + + # Test + frame.x = 42 + assert frame.x == 42, f"Expected 42, got {frame.x}" + + return True + +if __name__ == "__main__": + try: + if test_feature(): + print("PASS") + sys.exit(0) + except Exception as e: + print(f"FAIL: {e}") + import traceback + traceback.print_exc() + sys.exit(1) +``` + +### Running + +```bash +cd build +./mcrogueface --headless --exec ../tests/unit/test_myfeature.py +``` + +--- + +## Test Type 2: Timer-Based Tests + +For tests requiring rendering, screenshots, or elapsed time. + +### Template + +```python +"""Test requiring game loop.""" +import mcrfpy +from mcrfpy import automation +import sys + +# Setup scene BEFORE game loop starts +scene = mcrfpy.Scene("test") +ui = scene.children + +frame = mcrfpy.Frame(pos=(100, 100), size=(200, 150), + fill_color=mcrfpy.Color(255, 0, 0)) +ui.append(frame) +mcrfpy.current_scene = scene + +def run_test(timer, runtime): + """Timer callback receives (timer_object, runtime_ms).""" + automation.screenshot("test_output.png") + + # Verify results + assert frame.x == 100 + + print("PASS") + sys.exit(0) # MUST exit! + +# Schedule test to run after game loop starts +test_timer = mcrfpy.Timer("test_runner", run_test, 100) +``` + +**Important:** Timer callbacks receive `(timer, runtime_ms)`. Screenshots only work after rendering starts. + +### Example: Testing Click Events + +```python +import mcrfpy +from mcrfpy import automation +import sys + +clicks_received = [] + +scene = mcrfpy.Scene("test") +frame = mcrfpy.Frame(pos=(100, 100), size=(200, 150), + fill_color=mcrfpy.Color(0, 255, 0)) + +def on_click(pos, button, action): + clicks_received.append((pos, button, action)) + +frame.on_click = on_click +scene.children.append(frame) +mcrfpy.current_scene = scene + +def run_test(timer, runtime): + # Simulate click on frame center + automation.click(200, 175) + # Give it a frame to process + verify_timer = mcrfpy.Timer("verify", verify_results, 32) + +def verify_results(timer, runtime): + assert len(clicks_received) > 0, "No clicks received!" + pos, button, action = clicks_received[0] + print(f"Click received at ({pos.x}, {pos.y})") + print("PASS") + sys.exit(0) + +test_timer = mcrfpy.Timer("test", run_test, 100) +``` + +--- + +## Automation API + +### Screenshots + +```python +from mcrfpy import automation + +# Take screenshot (synchronous in headless mode) +automation.screenshot("output.png") +``` + +### Mouse Input + +```python +from mcrfpy import automation + +automation.click(x, y) # Left click at position +automation.move(x, y) # Move mouse to position +``` + +### Keyboard Input + +```python +from mcrfpy import automation + +automation.keypress(key_code) # Simulate key press +``` + +--- + +## Testing Patterns + +### Pattern 1: Property Round-Trip + +```python +def test_property_roundtrip(): + obj = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) + test_values = [0, 50, 100, 255, 127] + for value in test_values: + obj.x = value + assert obj.x == value, f"Failed for {value}" +``` + +### Pattern 2: Exception Testing + +```python +def test_invalid_input(): + grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(160, 160)) + try: + grid.at(-1, -1) + assert False, "Should have raised exception" + except Exception: + pass # Expected +``` + +### Pattern 3: Grid Operations + +```python +def test_grid_walkable(): + grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(160, 160)) + grid.at(5, 5).walkable = True + assert grid.at(5, 5).walkable == True + + grid.at(5, 5).walkable = False + assert grid.at(5, 5).walkable == False +``` + +### Pattern 4: Entity Lifecycle + +```python +def test_entity_lifecycle(): + grid = mcrfpy.Grid(grid_size=(20, 20), pos=(0, 0), size=(320, 320)) + + # Create and add + entity = mcrfpy.Entity(grid_pos=(5, 5), sprite_index=0) + assert entity.grid is None + grid.entities.append(entity) + assert entity.grid is not None + + # Move + entity.grid_x = 10 + assert entity.grid_x == 10 + + # Remove + entity.die() + assert entity.grid is None +``` + +### Pattern 5: Callback Setup + +```python +def test_click_callbacks(): + frame = mcrfpy.Frame(pos=(100, 100), size=(200, 150)) + clicks = [] + + # on_click receives (pos: Vector, button: MouseButton, action: InputState) + def on_click(pos, button, action): + if button == mcrfpy.MouseButton.LEFT and action == mcrfpy.InputState.PRESSED: + clicks.append((pos.x, pos.y)) + + frame.on_click = on_click + # on_enter/on_exit receive (pos: Vector) + frame.on_enter = lambda pos: None + frame.on_exit = lambda pos: None +``` + +### Pattern 6: Timer Usage + +```python +def test_timer(): + fired = [False] + + def on_timer(timer, runtime): + fired[0] = True + + t = mcrfpy.Timer("test_timer", on_timer, 100) + + # In headless mode, use step() to advance time + mcrfpy.step(0.2) # 200ms + assert fired[0], "Timer should have fired" + + t.stop() # Clean up +``` + +### Pattern 7: Animation Testing + +```python +def test_animation(): + frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) + frame.animate("x", 500.0, 2.0, mcrfpy.Easing.EASE_IN_OUT) + + # In headless mode, advance time to test + mcrfpy.step(1.0) # Halfway through + assert frame.x > 0 # Should have moved + + mcrfpy.step(1.0) # Complete + # frame.x should be at or near 500 +``` + +--- + +## Test-Driven Development (TDD) + +### TDD Workflow + +1. **Write failing test** - Demonstrates the bug/missing feature +2. **Run test** - Verify it fails +3. **Implement fix** - Make minimum change to pass +4. **Run test** - Verify it passes +5. **Refactor** - Clean up +6. **Run full suite** - `cd tests && python3 run_tests.py` + +--- + +## Testing Best Practices + +### DO: + +- **Test one thing at a time** - Each test function covers one behavior +- **Use descriptive names** - `test_entity_moves_to_valid_position()` +- **Always exit** - Use `sys.exit(0)` for pass, `sys.exit(1)` for fail +- **Test edge cases** - Boundaries, empty states, invalid input +- **Clean up timers** - Call `timer.stop()` when done + +### DON'T: + +- **Rely on timing** - Use `step()` in headless mode, not `time.sleep()` +- **Forget sys.exit()** in timer tests - Will hang indefinitely +- **Test multiple unrelated things** in one test function +- **Put tests in build/** - They get shipped with the game + +--- + +## Running the Test Suite + +```bash +# Full suite +cd tests && python3 run_tests.py + +# Single test +cd build && ./mcrogueface --headless --exec ../tests/unit/my_test.py + +# Inline test +cd build && ./mcrogueface --headless -c "import mcrfpy; print('OK')" +``` + +--- + +## Related Documentation + +- [[Headless-Mode]] - step() function and headless operation +- [[Input-and-Events]] - Event handler signatures +- [[Animation-System]] - Animation testing +- CLAUDE.md - Testing guidelines section + +--- + +*Last updated: 2026-02-07* \ No newline at end of file diff --git a/Writing-Tests.md b/Writing-Tests.md deleted file mode 100644 index e41fafd..0000000 --- a/Writing-Tests.md +++ /dev/null @@ -1,512 +0,0 @@ -# Writing Tests - -Guide to creating automated tests for McRogueFace using Python and the automation API. - -## Quick Reference - -**Test Location:** `tests/` directory (NOT `build/tests/` - that gets shipped!) - -**Test Types:** -1. **Direct Execution** - No game loop, immediate results -2. **Timer-Based** - Requires rendering/game loop - -**Key Tools:** -- `mcrfpy.automation` - Screenshot and input automation -- `--headless` flag - Run without display -- `--exec` flag - Execute specific script - -**Format:** See CLAUDE.md "Testing Guidelines" section - ---- - -## Test Type 1: Direct Execution - -For tests that don't need rendering or game state. - -### Template - -```python -#!/usr/bin/env python3 -"""Test description goes here.""" - -import mcrfpy -import sys - -def test_feature(): - # Setup - obj = mcrfpy.SomeClass() - - # Test - obj.property = 42 - - # Verify - assert obj.property == 42, f"Expected 42, got {obj.property}" - - # More tests... - return True - -if __name__ == "__main__": - try: - if test_feature(): - print("PASS") - sys.exit(0) - else: - print("FAIL") - sys.exit(1) - except Exception as e: - print(f"FAIL: {e}") - import traceback - traceback.print_exc() - sys.exit(1) -``` - -### Running - -```bash -cd build -./mcrogueface --headless --exec ../tests/test_myfeature.py -``` - -### Example: Testing Vector Operations - -```python -import mcrfpy -import sys - -def test_vector(): - # Create vector - v = mcrfpy.Vector(3.0, 4.0) - - # Test properties - assert v.x == 3.0 - assert v.y == 4.0 - - # Test tuple access - assert v[0] == 3.0 - assert v[1] == 4.0 - - # Test modification - v.x = 10.0 - assert v.x == 10.0 - - print("All vector tests passed") - return True - -if __name__ == "__main__": - if test_vector(): - print("PASS") - sys.exit(0) - else: - print("FAIL") - sys.exit(1) -``` - ---- - -## Test Type 2: Timer-Based Tests - -For tests requiring rendering, screenshots, or game loop. - -### Template - -```python -#!/usr/bin/env python3 -"""Test description - requires game loop.""" - -import mcrfpy -from mcrfpy import automation -import sys - -# Setup scene BEFORE game loop starts -mcrfpy.createScene("test") - -# Create UI elements -frame = mcrfpy.Frame(100, 100, 200, 150) -frame.fill_color = mcrfpy.Color(255, 0, 0) -mcrfpy.sceneUI("test").append(frame) - -# Switch to test scene -mcrfpy.setScene("test") - -def run_test(runtime_ms): - """Timer callback - runs AFTER game loop starts.""" - - # Take screenshot - automation.screenshot("test_output.png") - - # Perform interactions - automation.click(150, 150) # Click on frame - - # Verify results - # ... check state, take more screenshots, etc ... - - # Report results - print("PASS") - sys.exit(0) # MUST exit! - -# Schedule test to run after game loop starts -mcrfpy.setTimer("test_runner", run_test, 100) # 100ms delay -``` - -### Running - -```bash -cd build -./mcrogueface --headless --exec ../tests/test_rendering.py -``` - -**Important:** Timer callbacks are essential! Screenshots only work after rendering starts. - -### Example: Testing Click Events - -```python -import mcrfpy -from mcrfpy import automation -import sys - -# Track clicks -clicks_received = [] - -def on_frame_click(x, y, button, state): - clicks_received.append((x, y, button, state)) - -# Setup scene -mcrfpy.createScene("test") - -frame = mcrfpy.Frame(100, 100, 200, 150) -frame.fill_color = mcrfpy.Color(0, 255, 0) -frame.click = on_frame_click - -mcrfpy.sceneUI("test").append(frame) -mcrfpy.setScene("test") - -def run_test(runtime_ms): - # Simulate click on frame center - automation.click(200, 175) - - # Give it a frame to process - mcrfpy.setTimer("verify", verify_results, 32) - -def verify_results(runtime_ms): - # Check click was received - assert len(clicks_received) > 0, "No clicks received!" - - x, y, button, state = clicks_received[0] - assert button == 1, f"Wrong button: {button}" - assert state == True, "Wrong state" - - print(f"Click received at ({x}, {y})") - print("PASS") - sys.exit(0) - -mcrfpy.setTimer("test", run_test, 100) -``` - ---- - -## Automation API - -### Screenshots - -```python -from mcrfpy import automation - -# Take screenshot -automation.screenshot("output.png") - -# Screenshots saved to current directory -# Use for visual regression testing -``` - -### Mouse Input - -```python -from mcrfpy import automation - -# Click at position -automation.click(x, y, button=1) # button: 1=left, 2=middle, 3=right - -# Move mouse -automation.move(x, y) -``` - -### Keyboard Input - -```python -from mcrfpy import automation - -# Key press -automation.keypress(key_code, pressed=True) - -# Key release -automation.keypress(key_code, pressed=False) -``` - -**Key codes:** See `mcrfpy.Key.*` constants - ---- - -## Test-Driven Development (TDD) - -### TDD Workflow - -1. **Write failing test** - Demonstrates the bug/missing feature -2. **Run test** - Verify it fails -3. **Implement fix** - Make minimum change to pass -4. **Run test** - Verify it passes -5. **Refactor** - Clean up if needed -6. **Run test** - Verify still passes - -### Example: TDD for New Feature - -**Step 1: Write failing test** - -`tests/test_sprite_rotation.py`: -```python -import mcrfpy - -def test_sprite_rotation(): - sprite = mcrfpy.Sprite("test.png", 0, 0) - - # This should work but doesn't yet - sprite.rotation = 90 - assert sprite.rotation == 90 - - return True - -if __name__ == "__main__": - if test_sprite_rotation(): - print("PASS") - else: - print("FAIL") -``` - -**Step 2: Run test - it fails** - -```bash -$ ./mcrogueface --headless --exec tests/test_sprite_rotation.py -AttributeError: 'Sprite' object has no attribute 'rotation' -FAIL -``` - -**Step 3: Implement feature** - -Add `rotation` property to `src/UISprite.cpp` (see [[Adding-Python-Bindings]]) - -**Step 4: Run test - it passes** - -```bash -$ ./mcrogueface --headless --exec tests/test_sprite_rotation.py -PASS -``` - ---- - -## Testing Best Practices - -### DO: - -✅ **Test one thing at a time** -```python -def test_grid_walkable_setter(): - grid = mcrfpy.Grid(10, 10, 16, 16) - grid.walkable((5, 5), True) - # Test only walkable setter, nothing else -``` - -✅ **Use descriptive test names** -```python -# Good -def test_entity_moves_to_valid_position() - -# Bad -def test1() -``` - -✅ **Clean up after tests** -```python -def test_feature(): - # Setup - obj = create_test_object() - - try: - # Test - obj.do_thing() - finally: - # Cleanup - obj.cleanup() -``` - -✅ **Test edge cases** -```python -def test_grid_bounds(): - grid = mcrfpy.Grid(10, 10, 16, 16) - - # Test boundaries - grid.at((0, 0)) # Min - grid.at((9, 9)) # Max - - # Test out of bounds - try: - grid.at((10, 10)) - assert False, "Should have raised exception" - except ValueError: - pass # Expected -``` - -### DON'T: - -❌ **Test multiple unrelated things** -```python -# Bad - testing grid, entity, AND animation -def test_everything(): - grid = mcrfpy.Grid(10, 10, 16, 16) - entity = mcrfpy.Entity(5, 5, 0) - mcrfpy.animate(entity, "x", 10, 1000, "linear") - # Too much! -``` - -❌ **Forget to exit in timer-based tests** -```python -def run_test(runtime_ms): - automation.screenshot("test.png") - print("PASS") - # MISSING: sys.exit(0) - will hang! -``` - -❌ **Rely on timing** -```python -# Bad - fragile -time.sleep(0.5) # Hope animation finished -assert entity.x == 100 - -# Good - check state -while entity.x < 100 and timeout < 1000: - time.sleep(0.016) - timeout += 16 -assert entity.x == 100 -``` - ---- - -## Common Testing Patterns - -### Pattern 1: Property Round-Trip - -```python -def test_property_roundtrip(): - obj = mcrfpy.Frame(0, 0, 100, 100) - - # Set various values - test_values = [0, 50, 100, 255, 127] - - for value in test_values: - obj.x = value - assert obj.x == value, f"Failed for {value}" -``` - -### Pattern 2: Visual Regression - -```python -def run_test(runtime_ms): - # Render scene - automation.screenshot("current.png") - - # Compare with golden image - # (Manual comparison or use image diff tool) - - print("Check current.png against golden/expected.png") - sys.exit(0) -``` - -### Pattern 3: Exception Testing - -```python -def test_invalid_input(): - grid = mcrfpy.Grid(10, 10, 16, 16) - - # Test exception is raised - try: - grid.at((-1, -1)) # Invalid coords - assert False, "Should have raised ValueError" - except ValueError as e: - assert "out of bounds" in str(e).lower() -``` - -### Pattern 4: State Machine Testing - -```python -def test_entity_states(): - entity = mcrfpy.Entity(0, 0, 0) - - # Initial state - assert entity.state == "idle" - - # Transition - entity.attack() - assert entity.state == "attacking" - - # Complete action - entity.finish_attack() - assert entity.state == "idle" -``` - ---- - -## Debugging Failed Tests - -### Get More Information - -```python -def test_feature(): - try: - obj.do_thing() - except Exception as e: - print(f"Exception: {e}") - print(f"Object state: {obj}") - print(f"Type: {type(obj)}") - raise # Re-raise to fail test -``` - -### Visual Debugging - -```python -def run_test(runtime_ms): - # Take screenshot before action - automation.screenshot("before.png") - - # Perform action - do_something() - - # Take screenshot after - automation.screenshot("after.png") - - # Now you can compare visually -``` - -### Print Debugging - -```python -def test_complex_operation(): - print("Starting test...") - - obj = create_object() - print(f"Created: {obj}") - - result = obj.process() - print(f"Result: {result}") - - assert result == expected -``` - ---- - -## Related Documentation - -- CLAUDE.md - Testing guidelines section -- [[Performance-Optimization-Workflow]] - Creating benchmarks -- `tests/` directory - Example tests - -**Testing Pattern Examples:** -- `tests/test_grid_operations.py` - Direct execution -- `tests/issue_78_middle_click_test.py` - Timer-based with automation \ No newline at end of file