Update Writing Tests

John McCardle 2026-02-07 23:47:32 +00:00
commit f05289abc2

@ -1,338 +1,338 @@
# Writing Tests # Writing Tests
Guide to creating automated tests for McRogueFace using Python and the automation API. Guide to creating automated tests for McRogueFace using Python and the automation API.
## Quick Reference ## Quick Reference
**Test Location:** `tests/` directory (NOT `build/tests/` - that gets shipped!) **Test Location:** `tests/` directory (NOT `build/tests/` - that gets shipped!)
**Test Types:** **Test Types:**
1. **Direct Execution** - No game loop, immediate results 1. **Direct Execution** - No game loop, immediate results
2. **Timer-Based** - Requires rendering/game loop 2. **Timer-Based** - Requires rendering/game loop
**Key Tools:** **Key Tools:**
- `mcrfpy.automation` - Screenshot and input automation - `mcrfpy.automation` - Screenshot and input automation
- `--headless` flag - Run without display - `--headless` flag - Run without display
- `--exec` flag - Execute specific script - `--exec` flag - Execute specific script
**Format:** See CLAUDE.md "Testing Guidelines" section **Format:** See CLAUDE.md "Testing Guidelines" section
--- ---
## Test Type 1: Direct Execution ## Test Type 1: Direct Execution
For tests that don't need rendering or game state. For tests that don't need rendering or game state.
### Template ### Template
```python ```python
"""Test description goes here.""" """Test description goes here."""
import mcrfpy import mcrfpy
import sys import sys
def test_feature(): def test_feature():
# Setup # Setup
scene = mcrfpy.Scene("test") scene = mcrfpy.Scene("test")
frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
scene.children.append(frame) scene.children.append(frame)
# Test # Test
frame.x = 42 frame.x = 42
assert frame.x == 42, f"Expected 42, got {frame.x}" assert frame.x == 42, f"Expected 42, got {frame.x}"
return True return True
if __name__ == "__main__": if __name__ == "__main__":
try: try:
if test_feature(): if test_feature():
print("PASS") print("PASS")
sys.exit(0) sys.exit(0)
except Exception as e: except Exception as e:
print(f"FAIL: {e}") print(f"FAIL: {e}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
sys.exit(1) sys.exit(1)
``` ```
### Running ### Running
```bash ```bash
cd build cd build
./mcrogueface --headless --exec ../tests/unit/test_myfeature.py ./mcrogueface --headless --exec ../tests/unit/test_myfeature.py
``` ```
--- ---
## Test Type 2: Timer-Based Tests ## Test Type 2: Timer-Based Tests
For tests requiring rendering, screenshots, or elapsed time. For tests requiring rendering, screenshots, or elapsed time.
### Template ### Template
```python ```python
"""Test requiring game loop.""" """Test requiring game loop."""
import mcrfpy import mcrfpy
from mcrfpy import automation from mcrfpy import automation
import sys import sys
# Setup scene BEFORE game loop starts # Setup scene BEFORE game loop starts
scene = mcrfpy.Scene("test") scene = mcrfpy.Scene("test")
ui = scene.children ui = scene.children
frame = mcrfpy.Frame(pos=(100, 100), size=(200, 150), frame = mcrfpy.Frame(pos=(100, 100), size=(200, 150),
fill_color=mcrfpy.Color(255, 0, 0)) fill_color=mcrfpy.Color(255, 0, 0))
ui.append(frame) ui.append(frame)
mcrfpy.current_scene = scene mcrfpy.current_scene = scene
def run_test(timer, runtime): def run_test(timer, runtime):
"""Timer callback receives (timer_object, runtime_ms).""" """Timer callback receives (timer_object, runtime_ms)."""
automation.screenshot("test_output.png") automation.screenshot("test_output.png")
# Verify results # Verify results
assert frame.x == 100 assert frame.x == 100
print("PASS") print("PASS")
sys.exit(0) # MUST exit! sys.exit(0) # MUST exit!
# Schedule test to run after game loop starts # Schedule test to run after game loop starts
test_timer = mcrfpy.Timer("test_runner", run_test, 100) test_timer = mcrfpy.Timer("test_runner", run_test, 100)
``` ```
**Important:** Timer callbacks receive `(timer, runtime_ms)`. Screenshots only work after rendering starts. **Important:** Timer callbacks receive `(timer, runtime_ms)`. Screenshots only work after rendering starts.
### Example: Testing Click Events ### Example: Testing Click Events
```python ```python
import mcrfpy import mcrfpy
from mcrfpy import automation from mcrfpy import automation
import sys import sys
clicks_received = [] clicks_received = []
scene = mcrfpy.Scene("test") scene = mcrfpy.Scene("test")
frame = mcrfpy.Frame(pos=(100, 100), size=(200, 150), frame = mcrfpy.Frame(pos=(100, 100), size=(200, 150),
fill_color=mcrfpy.Color(0, 255, 0)) fill_color=mcrfpy.Color(0, 255, 0))
def on_click(pos, button, action): def on_click(pos, button, action):
clicks_received.append((pos, button, action)) clicks_received.append((pos, button, action))
frame.on_click = on_click frame.on_click = on_click
scene.children.append(frame) scene.children.append(frame)
mcrfpy.current_scene = scene mcrfpy.current_scene = scene
def run_test(timer, runtime): def run_test(timer, runtime):
# Simulate click on frame center # Simulate click on frame center
automation.click(200, 175) automation.click(200, 175)
# Give it a frame to process # Give it a frame to process
verify_timer = mcrfpy.Timer("verify", verify_results, 32) verify_timer = mcrfpy.Timer("verify", verify_results, 32)
def verify_results(timer, runtime): def verify_results(timer, runtime):
assert len(clicks_received) > 0, "No clicks received!" assert len(clicks_received) > 0, "No clicks received!"
pos, button, action = clicks_received[0] pos, button, action = clicks_received[0]
print(f"Click received at ({pos.x}, {pos.y})") print(f"Click received at ({pos.x}, {pos.y})")
print("PASS") print("PASS")
sys.exit(0) sys.exit(0)
test_timer = mcrfpy.Timer("test", run_test, 100) test_timer = mcrfpy.Timer("test", run_test, 100)
``` ```
--- ---
## Automation API ## Automation API
### Screenshots ### Screenshots
```python ```python
from mcrfpy import automation from mcrfpy import automation
# Take screenshot (synchronous in headless mode) # Take screenshot (synchronous in headless mode)
automation.screenshot("output.png") automation.screenshot("output.png")
``` ```
### Mouse Input ### Mouse Input
```python ```python
from mcrfpy import automation from mcrfpy import automation
automation.click(x, y) # Left click at position automation.click(x, y) # Left click at position
automation.move(x, y) # Move mouse to position automation.move(x, y) # Move mouse to position
``` ```
### Keyboard Input ### Keyboard Input
```python ```python
from mcrfpy import automation from mcrfpy import automation
automation.keypress(key_code) # Simulate key press automation.keypress(key_code) # Simulate key press
``` ```
--- ---
## Testing Patterns ## Testing Patterns
### Pattern 1: Property Round-Trip ### Pattern 1: Property Round-Trip
```python ```python
def test_property_roundtrip(): def test_property_roundtrip():
obj = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) obj = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
test_values = [0, 50, 100, 255, 127] test_values = [0, 50, 100, 255, 127]
for value in test_values: for value in test_values:
obj.x = value obj.x = value
assert obj.x == value, f"Failed for {value}" assert obj.x == value, f"Failed for {value}"
``` ```
### Pattern 2: Exception Testing ### Pattern 2: Exception Testing
```python ```python
def test_invalid_input(): def test_invalid_input():
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(160, 160)) grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(160, 160))
try: try:
grid.at(-1, -1) grid.at(-1, -1)
assert False, "Should have raised exception" assert False, "Should have raised exception"
except Exception: except Exception:
pass # Expected pass # Expected
``` ```
### Pattern 3: Grid Operations ### Pattern 3: Grid Operations
```python ```python
def test_grid_walkable(): def test_grid_walkable():
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(160, 160)) grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(160, 160))
grid.at(5, 5).walkable = True grid.at(5, 5).walkable = True
assert grid.at(5, 5).walkable == True assert grid.at(5, 5).walkable == True
grid.at(5, 5).walkable = False grid.at(5, 5).walkable = False
assert grid.at(5, 5).walkable == False assert grid.at(5, 5).walkable == False
``` ```
### Pattern 4: Entity Lifecycle ### Pattern 4: Entity Lifecycle
```python ```python
def test_entity_lifecycle(): def test_entity_lifecycle():
grid = mcrfpy.Grid(grid_size=(20, 20), pos=(0, 0), size=(320, 320)) grid = mcrfpy.Grid(grid_size=(20, 20), pos=(0, 0), size=(320, 320))
# Create and add # Create and add
entity = mcrfpy.Entity(grid_pos=(5, 5), sprite_index=0) entity = mcrfpy.Entity(grid_pos=(5, 5), sprite_index=0)
assert entity.grid is None assert entity.grid is None
grid.entities.append(entity) grid.entities.append(entity)
assert entity.grid is not None assert entity.grid is not None
# Move # Move
entity.grid_x = 10 entity.grid_x = 10
assert entity.grid_x == 10 assert entity.grid_x == 10
# Remove # Remove
entity.die() entity.die()
assert entity.grid is None assert entity.grid is None
``` ```
### Pattern 5: Callback Setup ### Pattern 5: Callback Setup
```python ```python
def test_click_callbacks(): def test_click_callbacks():
frame = mcrfpy.Frame(pos=(100, 100), size=(200, 150)) frame = mcrfpy.Frame(pos=(100, 100), size=(200, 150))
clicks = [] clicks = []
# on_click receives (pos: Vector, button: MouseButton, action: InputState) # on_click receives (pos: Vector, button: MouseButton, action: InputState)
def on_click(pos, button, action): def on_click(pos, button, action):
if button == mcrfpy.MouseButton.LEFT and action == mcrfpy.InputState.PRESSED: if button == mcrfpy.MouseButton.LEFT and action == mcrfpy.InputState.PRESSED:
clicks.append((pos.x, pos.y)) clicks.append((pos.x, pos.y))
frame.on_click = on_click frame.on_click = on_click
# on_enter/on_exit receive (pos: Vector) # on_enter/on_exit receive (pos: Vector)
frame.on_enter = lambda pos: None frame.on_enter = lambda pos: None
frame.on_exit = lambda pos: None frame.on_exit = lambda pos: None
``` ```
### Pattern 6: Timer Usage ### Pattern 6: Timer Usage
```python ```python
def test_timer(): def test_timer():
fired = [False] fired = [False]
def on_timer(timer, runtime): def on_timer(timer, runtime):
fired[0] = True fired[0] = True
t = mcrfpy.Timer("test_timer", on_timer, 100) t = mcrfpy.Timer("test_timer", on_timer, 100)
# In headless mode, use step() to advance time # In headless mode, use step() to advance time
mcrfpy.step(0.2) # 200ms mcrfpy.step(0.2) # 200ms
assert fired[0], "Timer should have fired" assert fired[0], "Timer should have fired"
t.stop() # Clean up t.stop() # Clean up
``` ```
### Pattern 7: Animation Testing ### Pattern 7: Animation Testing
```python ```python
def test_animation(): def test_animation():
frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
frame.animate("x", 500.0, 2.0, mcrfpy.Easing.EASE_IN_OUT) frame.animate("x", 500.0, 2.0, mcrfpy.Easing.EASE_IN_OUT)
# In headless mode, advance time to test # In headless mode, advance time to test
mcrfpy.step(1.0) # Halfway through mcrfpy.step(1.0) # Halfway through
assert frame.x > 0 # Should have moved assert frame.x > 0 # Should have moved
mcrfpy.step(1.0) # Complete mcrfpy.step(1.0) # Complete
# frame.x should be at or near 500 # frame.x should be at or near 500
``` ```
--- ---
## Test-Driven Development (TDD) ## Test-Driven Development (TDD)
### TDD Workflow ### TDD Workflow
1. **Write failing test** - Demonstrates the bug/missing feature 1. **Write failing test** - Demonstrates the bug/missing feature
2. **Run test** - Verify it fails 2. **Run test** - Verify it fails
3. **Implement fix** - Make minimum change to pass 3. **Implement fix** - Make minimum change to pass
4. **Run test** - Verify it passes 4. **Run test** - Verify it passes
5. **Refactor** - Clean up 5. **Refactor** - Clean up
6. **Run full suite** - `cd tests && python3 run_tests.py` 6. **Run full suite** - `cd tests && python3 run_tests.py`
--- ---
## Testing Best Practices ## Testing Best Practices
### DO: ### DO:
- **Test one thing at a time** - Each test function covers one behavior - **Test one thing at a time** - Each test function covers one behavior
- **Use descriptive names** - `test_entity_moves_to_valid_position()` - **Use descriptive names** - `test_entity_moves_to_valid_position()`
- **Always exit** - Use `sys.exit(0)` for pass, `sys.exit(1)` for fail - **Always exit** - Use `sys.exit(0)` for pass, `sys.exit(1)` for fail
- **Test edge cases** - Boundaries, empty states, invalid input - **Test edge cases** - Boundaries, empty states, invalid input
- **Clean up timers** - Call `timer.stop()` when done - **Clean up timers** - Call `timer.stop()` when done
### DON'T: ### DON'T:
- **Rely on timing** - Use `step()` in headless mode, not `time.sleep()` - **Rely on timing** - Use `step()` in headless mode, not `time.sleep()`
- **Forget sys.exit()** in timer tests - Will hang indefinitely - **Forget sys.exit()** in timer tests - Will hang indefinitely
- **Test multiple unrelated things** in one test function - **Test multiple unrelated things** in one test function
- **Put tests in build/** - They get shipped with the game - **Put tests in build/** - They get shipped with the game
--- ---
## Running the Test Suite ## Running the Test Suite
```bash ```bash
# Full suite # Full suite
cd tests && python3 run_tests.py cd tests && python3 run_tests.py
# Single test # Single test
cd build && ./mcrogueface --headless --exec ../tests/unit/my_test.py cd build && ./mcrogueface --headless --exec ../tests/unit/my_test.py
# Inline test # Inline test
cd build && ./mcrogueface --headless -c "import mcrfpy; print('OK')" cd build && ./mcrogueface --headless -c "import mcrfpy; print('OK')"
``` ```
--- ---
## Related Documentation ## Related Documentation
- [[Headless-Mode]] - step() function and headless operation - [[Headless-Mode]] - step() function and headless operation
- [[Input-and-Events]] - Event handler signatures - [[Input-and-Events]] - Event handler signatures
- [[Animation-System]] - Animation testing - [[Animation-System]] - Animation testing
- CLAUDE.md - Testing guidelines section - CLAUDE.md - Testing guidelines section
--- ---
*Last updated: 2026-02-07* *Last updated: 2026-02-07*