diff --git a/explanation/headless-automation.md b/explanation/headless-automation.md new file mode 100644 index 0000000..3f7fcd5 --- /dev/null +++ b/explanation/headless-automation.md @@ -0,0 +1,240 @@ +# Headless Mode and Automation + +McRogueFace supports headless operation for automated testing, CI/CD pipelines, and programmatic control without a display. + +## Running Headless + +Launch with the `--headless` flag: + +```bash +mcrogueface --headless --exec game.py +``` + +Or use xvfb for a virtual framebuffer (required for rendering): + +```bash +xvfb-run -a -s "-screen 0 1024x768x24" mcrogueface --headless --exec game.py +``` + +## Time Control with mcrfpy.step() + +In headless mode, you control time explicitly rather than waiting for real-time to pass. The `step()` function takes **seconds** as a float: + +```python +import mcrfpy + +# Advance simulation by 100ms +mcrfpy.step(0.1) + +# Advance by 1 second +mcrfpy.step(1.0) + +# Advance by 16ms (~60fps frame) +mcrfpy.step(0.016) +``` + +### Why This Matters + +Traditional timer-based code waits for real time and the game loop: + +```python +# OLD PATTERN - waits for game loop, subject to timeouts +def delayed_action(timer, runtime): + print("Action!") + sys.exit(0) + +mcrfpy.Timer("delay", delayed_action, 500, once=True) +# Script ends, game loop runs, timer eventually fires +``` + +With `mcrfpy.step()`, you control the clock synchronously: + +```python +# NEW PATTERN - instant, deterministic +mcrfpy.Timer("delay", delayed_action, 500, once=True) +mcrfpy.step(0.6) # Advance 600ms - timer fires during this call +``` + +### Timer Behavior + +- Timers fire **once per `step()` call** if their interval has elapsed +- To fire a timer multiple times, call `step()` multiple times: + +```python +count = 0 +def tick(timer, runtime): + global count + count += 1 + +timer = mcrfpy.Timer("tick", tick, 100) # Fire every 100ms + +# Each step() processes timers once +for i in range(5): + mcrfpy.step(0.1) # 100ms each + +print(count) # 5 +``` + +## Screenshots + +The `automation.screenshot()` function captures the current frame: + +```python +from mcrfpy import automation + +# Capture screenshot (synchronous in headless mode) +result = automation.screenshot("output.png") +print(f"Screenshot saved: {result}") +``` + +**Key insight:** In headless mode, `screenshot()` is synchronous - no timer dance needed. + +## Testing Patterns + +### Synchronous Test Structure + +```python +#!/usr/bin/env python3 +import mcrfpy +import sys + +# Setup scene +scene = mcrfpy.Scene("test") +scene.activate() +mcrfpy.step(0.1) # Initialize scene + +# Create test objects +frame = mcrfpy.Frame(pos=(100, 100), size=(50, 50)) +scene.children.append(frame) + +# Verify state +if frame.x != 100: + print("FAIL: frame.x should be 100") + sys.exit(1) + +print("PASS") +sys.exit(0) +``` + +### Testing Animations + +```python +# Create frame and start animation +frame = mcrfpy.Frame(pos=(100, 100), size=(50, 50)) +scene.children.append(frame) + +anim = mcrfpy.Animation("x", 200.0, 1.0, "linear") # 1 second duration +anim.start(frame) + +# Advance to midpoint (0.5 seconds) +mcrfpy.step(0.5) +# frame.x should be ~150 (halfway between 100 and 200) + +# Advance to completion +mcrfpy.step(0.6) # Past the 1.0s duration +# frame.x should be 200 + +if frame.x == 200.0: + print("PASS: Animation completed") + sys.exit(0) +``` + +### Testing Timers + +```python +callback_count = 0 + +def on_timer(timer, runtime): + """Timer callbacks receive (timer_object, runtime_ms)""" + global callback_count + callback_count += 1 + print(f"Timer fired! Count: {callback_count}, runtime: {runtime}ms") + +# Create timer that fires every 100ms +timer = mcrfpy.Timer("test", on_timer, 100) + +# Advance time - each step() can fire the timer once +mcrfpy.step(0.15) # First fire at ~100ms +mcrfpy.step(0.15) # Second fire at ~200ms + +if callback_count >= 2: + print("PASS") + sys.exit(0) +``` + +### Testing with once=True Timers + +```python +fired = False + +def one_shot(timer, runtime): + global fired + fired = True + print(f"One-shot timer fired! once={timer.once}") + +# Create one-shot timer +timer = mcrfpy.Timer("oneshot", one_shot, 100, once=True) + +mcrfpy.step(0.15) # Should fire +mcrfpy.step(0.15) # Should NOT fire again + +if fired: + print("PASS: One-shot timer worked") +``` + +## Pattern Comparison + +| Aspect | Timer-based (old) | step()-based (new) | +|--------|-------------------|-------------------| +| Execution | Async (game loop) | Sync (immediate) | +| Timeout risk | High | None | +| Determinism | Variable | Consistent | +| Script flow | Callbacks | Linear | + +## LLM Agent Integration + +Headless mode enables AI agents to interact with McRogueFace programmatically: + +1. **Observe**: Capture screenshots, read game state +2. **Decide**: Process with vision models or state analysis +3. **Act**: Send input commands, modify game state +4. **Verify**: Check results, capture new state + +```python +from mcrfpy import automation + +# Agent loop +while not game_over: + automation.screenshot("state.png") + action = agent.decide("state.png") + execute_action(action) + mcrfpy.step(0.1) # Let action take effect +``` + +## Best Practices + +1. **Use `mcrfpy.step()`** instead of real-time waiting for all headless tests +2. **Initialize scenes** with a brief `step(0.1)` after `activate()` +3. **Be deterministic** - same inputs should produce same outputs +4. **Test incrementally** - advance time in small steps to catch intermediate states +5. **Use `sys.exit(0/1)`** for clear pass/fail signals to test runners +6. **Multiple `step()` calls** to fire repeating timers multiple times + +## Running the Test Suite + +```bash +# Quick run with pytest wrapper +pytest tests/ -q --mcrf-timeout=5 + +# Using the original runner with xvfb +xvfb-run -a python3 tests/run_tests.py -q + +# Run specific test directly +xvfb-run -a mcrogueface --headless --exec tests/unit/test_animation.py +``` + +## Related Topics + +- [Animation System](animation.md) - How animations work +- [Scene API](scene-api.md) - Managing scenes +- [Timer API](timer-api.md) - Timer details diff --git a/tests/KNOWN_ISSUES.md b/tests/KNOWN_ISSUES.md new file mode 100644 index 0000000..9fab74a --- /dev/null +++ b/tests/KNOWN_ISSUES.md @@ -0,0 +1,134 @@ +# McRogueFace Test Suite - Known Issues + +## Test Results Summary + +As of 2026-01-14, with `--mcrf-timeout=5`: +- **120 passed** (67%) +- **59 failed** (33%) + - 40 timeout failures (tests requiring timers/animations) + - 19 actual failures (API changes, missing features, or bugs) + +## Synchronous Testing with `mcrfpy.step()` + +**RECOMMENDED:** Use `mcrfpy.step(t)` to advance simulation time synchronously instead of relying on Timer callbacks and the game loop. This eliminates timeout issues and makes tests deterministic. + +### Old Pattern (Timer-based, async) + +```python +# OLD: Requires game loop, subject to timeouts +def run_tests(timer, runtime): + # tests here + sys.exit(0) + +mcrfpy.Timer("run", run_tests, 100, once=True) +# Script ends, game loop runs, timer eventually fires +``` + +### New Pattern (step-based, sync) + +```python +# NEW: Synchronous, no timeouts +import mcrfpy +import sys + +# Setup scene +scene = mcrfpy.Scene("test") +scene.activate() +mcrfpy.step(0.1) # Initialize scene + +# Run tests directly +frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100)) +scene.children.append(frame) + +# Start animation +anim = mcrfpy.Animation("x", 500.0, 1.0, "linear") +anim.start(frame) + +# Advance simulation to complete animation +mcrfpy.step(1.5) # Advances 1.5 seconds synchronously + +# Verify results +if frame.x == 500.0: + print("PASS") + sys.exit(0) +else: + print("FAIL") + sys.exit(1) +``` + +### Key Differences + +| Aspect | Timer-based | step()-based | +|--------|-------------|--------------| +| Execution | Async (game loop) | Sync (immediate) | +| Timeout risk | High | None | +| Determinism | Variable | Consistent | +| Timer firing | Once per step() call | Per elapsed interval | + +### Timer Behavior with `step()` + +- Timers fire once per `step()` call if their interval has elapsed +- To fire a timer multiple times, call `step()` multiple times: + +```python +# Timer fires every 100ms +timer = mcrfpy.Timer("tick", callback, 100) + +# This fires the timer ~6 times +for i in range(6): + mcrfpy.step(0.1) # Each step processes timers once +``` + +## Refactored Tests + +The following tests have been converted to use `mcrfpy.step()`: +- simple_timer_screenshot_test.py +- test_animation_callback_simple.py +- test_animation_property_locking.py +- test_animation_raii.py +- test_animation_removal.py +- test_timer_callback.py +- test_timer_once.py +- test_simple_callback.py +- test_empty_animation_manager.py +- test_frame_clipping.py +- test_frame_clipping_advanced.py +- test_grid_children.py +- test_color_helpers.py +- test_no_arg_constructors.py +- test_properties_quick.py +- test_simple_drawable.py +- test_python_object_cache.py +- WORKING_automation_test_example.py + +## Remaining Timeout Failures + +These tests still use Timer-based async patterns: +- benchmark_logging_test.py +- keypress_scene_validation_test.py + +**Headless mode tests:** +- test_headless_detection.py +- test_headless_modes.py + +## Running Tests + +```bash +# Quick run (5s timeout, many timeouts expected) +pytest tests/ -q --mcrf-timeout=5 + +# Full run (30s timeout, should pass most timing tests) +pytest tests/ -q --mcrf-timeout=30 + +# Filter by pattern +pytest tests/ -k "bsp" -q + +# Run original runner +python3 tests/run_tests.py -q +``` + +## Recommendations + +1. **For CI:** Use `--mcrf-timeout=10` and accept ~30% timeout failures +2. **For local dev:** Use `--mcrf-timeout=30` for comprehensive testing +3. **For quick validation:** Use `-k "not animation and not timer"` to skip slow tests diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..981e78f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,190 @@ +""" +Pytest configuration for McRogueFace tests. + +Provides fixtures for running McRogueFace scripts in headless mode. + +Usage: + pytest tests/ -q # Run all tests quietly + pytest tests/ -k "bsp" # Run tests matching "bsp" + pytest tests/ -x # Stop on first failure + pytest tests/ --tb=short # Short tracebacks +""" +import os +import subprocess +import pytest +from pathlib import Path + +# Paths +TESTS_DIR = Path(__file__).parent +BUILD_DIR = TESTS_DIR.parent / "build" +LIB_DIR = TESTS_DIR.parent / "__lib" +MCROGUEFACE = BUILD_DIR / "mcrogueface" + +# Default timeout for tests (can be overridden with --timeout) +DEFAULT_TIMEOUT = 10 + + +def pytest_addoption(parser): + """Add custom command line options.""" + parser.addoption( + "--mcrf-timeout", + action="store", + default=DEFAULT_TIMEOUT, + type=int, + help="Timeout in seconds for each McRogueFace test" + ) + + +@pytest.fixture +def mcrf_timeout(request): + """Get the configured timeout.""" + return request.config.getoption("--mcrf-timeout") + + +@pytest.fixture +def mcrf_env(): + """Environment with LD_LIBRARY_PATH set for McRogueFace.""" + env = os.environ.copy() + existing_ld = env.get('LD_LIBRARY_PATH', '') + env['LD_LIBRARY_PATH'] = f"{LIB_DIR}:{existing_ld}" if existing_ld else str(LIB_DIR) + return env + + +@pytest.fixture +def mcrf_exec(mcrf_env, mcrf_timeout): + """ + Fixture that returns a function to execute McRogueFace scripts. + + Usage in tests: + def test_something(mcrf_exec): + passed, output = mcrf_exec("unit/my_test.py") + assert passed + """ + def _exec(script_path, timeout=None): + """ + Execute a McRogueFace script in headless mode. + + Args: + script_path: Path relative to tests/ directory + timeout: Override default timeout + + Returns: + (passed: bool, output: str) + """ + if timeout is None: + timeout = mcrf_timeout + + full_path = TESTS_DIR / script_path + + try: + result = subprocess.run( + [str(MCROGUEFACE), '--headless', '--exec', str(full_path)], + capture_output=True, + text=True, + timeout=timeout, + cwd=str(BUILD_DIR), + env=mcrf_env + ) + + output = result.stdout + result.stderr + passed = result.returncode == 0 + + # Check for PASS/FAIL in output + if 'FAIL' in output and 'PASS' not in output.split('FAIL')[-1]: + passed = False + + return passed, output + + except subprocess.TimeoutExpired: + return False, "TIMEOUT" + except Exception as e: + return False, str(e) + + return _exec + + +def pytest_collect_file(parent, file_path): + """Auto-discover McRogueFace test scripts.""" + # Only collect from unit/, integration/, regression/ directories + try: + rel_path = file_path.relative_to(TESTS_DIR) + except ValueError: + return None + + if rel_path.parts and rel_path.parts[0] in ('unit', 'integration', 'regression', 'docs', 'demo'): + if file_path.suffix == '.py' and file_path.name not in ('__init__.py', 'conftest.py'): + return McRFTestFile.from_parent(parent, path=file_path) + return None + + +def pytest_ignore_collect(collection_path, config): + """Prevent pytest from trying to import test scripts as Python modules.""" + try: + rel_path = collection_path.relative_to(TESTS_DIR) + if rel_path.parts and rel_path.parts[0] in ('unit', 'integration', 'regression', 'docs', 'demo'): + # Let our custom collector handle these, don't import them + return False # Don't ignore - we'll collect them our way + except ValueError: + pass + return None + + +class McRFTestFile(pytest.File): + """Custom test file for McRogueFace scripts.""" + + def collect(self): + """Yield a single test item for this script.""" + yield McRFTestItem.from_parent(self, name=self.path.stem) + + +class McRFTestItem(pytest.Item): + """Test item that runs a McRogueFace script.""" + + def runtest(self): + """Run the McRogueFace script.""" + timeout = self.config.getoption("--mcrf-timeout") + + env = os.environ.copy() + existing_ld = env.get('LD_LIBRARY_PATH', '') + env['LD_LIBRARY_PATH'] = f"{LIB_DIR}:{existing_ld}" if existing_ld else str(LIB_DIR) + + try: + result = subprocess.run( + [str(MCROGUEFACE), '--headless', '--exec', str(self.path)], + capture_output=True, + text=True, + timeout=timeout, + cwd=str(BUILD_DIR), + env=env + ) + + self.output = result.stdout + result.stderr + passed = result.returncode == 0 + + if 'FAIL' in self.output and 'PASS' not in self.output.split('FAIL')[-1]: + passed = False + + if not passed: + raise McRFTestException(self.output) + + except subprocess.TimeoutExpired: + self.output = "TIMEOUT" + raise McRFTestException("TIMEOUT") + + def repr_failure(self, excinfo): + """Format failure output.""" + if isinstance(excinfo.value, McRFTestException): + output = str(excinfo.value) + # Show last 10 lines + lines = output.strip().split('\n')[-10:] + return '\n'.join(lines) + return super().repr_failure(excinfo) + + def reportinfo(self): + """Report test location.""" + return self.path, None, f"mcrf:{self.path.relative_to(TESTS_DIR)}" + + +class McRFTestException(Exception): + """Exception raised when a McRogueFace test fails.""" + pass diff --git a/tests/demo/cookbook_showcase.py b/tests/demo/cookbook_showcase.py new file mode 100644 index 0000000..9095414 --- /dev/null +++ b/tests/demo/cookbook_showcase.py @@ -0,0 +1,495 @@ +#!/usr/bin/env python3 +""" +Cookbook Screenshot Showcase - Visual examples for cookbook recipes! + +Generates beautiful screenshots for cookbook pages. +Run with: xvfb-run -a ./build/mcrogueface --headless --exec tests/demo/cookbook_showcase.py + +In headless mode, automation.screenshot() is SYNCHRONOUS - no timer dance needed! +""" +import mcrfpy +from mcrfpy import automation +import sys +import os + +# Output directory - in the docs site images folder +OUTPUT_DIR = "/opt/goblincorps/repos/mcrogueface.github.io/images/cookbook" + +# Tile sprites from the labeled tileset +TILES = { + 'player_knight': 84, + 'player_mage': 85, + 'player_rogue': 86, + 'player_warrior': 87, + 'enemy_slime': 108, + 'enemy_orc': 120, + 'enemy_skeleton': 123, + 'floor_stone': 42, + 'wall_stone': 30, + 'wall_brick': 14, + 'torch': 72, + 'chest_closed': 89, + 'item_potion': 113, +} + + +def screenshot_health_bar(): + """Create a health bar showcase.""" + scene = mcrfpy.Scene("health_bar") + + # Dark background + bg = mcrfpy.Frame(pos=(0, 0), size=(800, 600)) + bg.fill_color = mcrfpy.Color(20, 20, 30) + scene.children.append(bg) + + # Title + title = mcrfpy.Caption(text="Health Bar Recipe", pos=(50, 30)) + title.fill_color = mcrfpy.Color(255, 255, 255) + title.font_size = 28 + scene.children.append(title) + + subtitle = mcrfpy.Caption(text="Nested frames for dynamic UI elements", pos=(50, 60)) + subtitle.fill_color = mcrfpy.Color(180, 180, 200) + subtitle.font_size = 16 + scene.children.append(subtitle) + + # Example health bars at different levels + y_start = 120 + bar_configs = [ + ("Player - Full Health", 100, 100, mcrfpy.Color(50, 200, 50)), + ("Player - Damaged", 65, 100, mcrfpy.Color(200, 200, 50)), + ("Player - Critical", 20, 100, mcrfpy.Color(200, 50, 50)), + ("Boss - 3/4 Health", 750, 1000, mcrfpy.Color(150, 50, 150)), + ] + + for i, (label, current, maximum, color) in enumerate(bar_configs): + y = y_start + i * 100 + + # Label + lbl = mcrfpy.Caption(text=label, pos=(50, y)) + lbl.fill_color = mcrfpy.Color(220, 220, 220) + lbl.font_size = 18 + scene.children.append(lbl) + + # Background bar + bar_bg = mcrfpy.Frame(pos=(50, y + 30), size=(400, 30)) + bar_bg.fill_color = mcrfpy.Color(40, 40, 50) + bar_bg.outline = 2 + bar_bg.outline_color = mcrfpy.Color(80, 80, 100) + scene.children.append(bar_bg) + + # Fill bar (scaled to current/maximum) + fill_width = int(400 * (current / maximum)) + bar_fill = mcrfpy.Frame(pos=(50, y + 30), size=(fill_width, 30)) + bar_fill.fill_color = color + scene.children.append(bar_fill) + + # Text overlay + hp_text = mcrfpy.Caption(text=f"{current}/{maximum}", pos=(60, y + 35)) + hp_text.fill_color = mcrfpy.Color(255, 255, 255) + hp_text.font_size = 16 + scene.children.append(hp_text) + + scene.activate() + output_path = os.path.join(OUTPUT_DIR, "ui_health_bar.png") + automation.screenshot(output_path) + print(f" -> {output_path}") + + +def screenshot_fog_of_war(): + """Create a fog of war showcase.""" + scene = mcrfpy.Scene("fog_of_war") + + texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + grid = mcrfpy.Grid( + pos=(50, 80), + size=(700, 480), + grid_size=(16, 12), + texture=texture, + zoom=2.8 + ) + grid.fill_color = mcrfpy.Color(0, 0, 0) # Black for unknown areas + scene.children.append(grid) + + # Title + title = mcrfpy.Caption(text="Fog of War Recipe", pos=(50, 20)) + title.fill_color = mcrfpy.Color(255, 255, 255) + title.font_size = 28 + scene.children.append(title) + + subtitle = mcrfpy.Caption(text="Visible, discovered, and unknown areas", pos=(50, 50)) + subtitle.fill_color = mcrfpy.Color(180, 180, 200) + subtitle.font_size = 16 + scene.children.append(subtitle) + + # Fill floor + for y in range(12): + for x in range(16): + grid.at(x, y).tilesprite = TILES['floor_stone'] + + # Add walls + for x in range(16): + grid.at(x, 0).tilesprite = TILES['wall_stone'] + grid.at(x, 11).tilesprite = TILES['wall_stone'] + for y in range(12): + grid.at(0, y).tilesprite = TILES['wall_stone'] + grid.at(15, y).tilesprite = TILES['wall_stone'] + + # Interior walls (to break LOS) + for y in range(3, 8): + grid.at(8, y).tilesprite = TILES['wall_brick'] + + # Player (mage with light) + player = mcrfpy.Entity(grid_pos=(4, 6), texture=texture, sprite_index=TILES['player_mage']) + grid.entities.append(player) + + # Hidden enemies on the other side + enemy1 = mcrfpy.Entity(grid_pos=(12, 4), texture=texture, sprite_index=TILES['enemy_orc']) + grid.entities.append(enemy1) + enemy2 = mcrfpy.Entity(grid_pos=(13, 8), texture=texture, sprite_index=TILES['enemy_skeleton']) + grid.entities.append(enemy2) + + # Torch in visible area + torch = mcrfpy.Entity(grid_pos=(2, 3), texture=texture, sprite_index=TILES['torch']) + grid.entities.append(torch) + + grid.center = (4 * 16 + 8, 6 * 16 + 8) + + scene.activate() + output_path = os.path.join(OUTPUT_DIR, "grid_fog_of_war.png") + automation.screenshot(output_path) + print(f" -> {output_path}") + + +def screenshot_combat_melee(): + """Create a melee combat showcase.""" + scene = mcrfpy.Scene("combat_melee") + + texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + grid = mcrfpy.Grid( + pos=(50, 80), + size=(700, 480), + grid_size=(12, 9), + texture=texture, + zoom=3.5 + ) + grid.fill_color = mcrfpy.Color(20, 20, 30) + scene.children.append(grid) + + # Title + title = mcrfpy.Caption(text="Melee Combat Recipe", pos=(50, 20)) + title.fill_color = mcrfpy.Color(255, 255, 255) + title.font_size = 28 + scene.children.append(title) + + subtitle = mcrfpy.Caption(text="Bump-to-attack mechanics with damage calculation", pos=(50, 50)) + subtitle.fill_color = mcrfpy.Color(180, 180, 200) + subtitle.font_size = 16 + scene.children.append(subtitle) + + # Fill with dirt floor (battle arena feel) + for y in range(9): + for x in range(12): + grid.at(x, y).tilesprite = 50 # dirt + + # Brick walls + for x in range(12): + grid.at(x, 0).tilesprite = TILES['wall_brick'] + grid.at(x, 8).tilesprite = TILES['wall_brick'] + for y in range(9): + grid.at(0, y).tilesprite = TILES['wall_brick'] + grid.at(11, y).tilesprite = TILES['wall_brick'] + + # Player knight engaging orc! + player = mcrfpy.Entity(grid_pos=(4, 4), texture=texture, sprite_index=TILES['player_knight']) + grid.entities.append(player) + + enemy = mcrfpy.Entity(grid_pos=(6, 4), texture=texture, sprite_index=TILES['enemy_orc']) + grid.entities.append(enemy) + + # Fallen enemy (bones) + bones = mcrfpy.Entity(grid_pos=(8, 6), texture=texture, sprite_index=75) # bones + grid.entities.append(bones) + + # Potion for healing + potion = mcrfpy.Entity(grid_pos=(3, 2), texture=texture, sprite_index=TILES['item_potion']) + grid.entities.append(potion) + + grid.center = (5 * 16 + 8, 4 * 16 + 8) + + # Combat log UI + log_frame = mcrfpy.Frame(pos=(50, 520), size=(700, 60)) + log_frame.fill_color = mcrfpy.Color(30, 30, 40, 220) + log_frame.outline = 1 + log_frame.outline_color = mcrfpy.Color(60, 60, 80) + scene.children.append(log_frame) + + msg1 = mcrfpy.Caption(text="You hit the Orc for 8 damage!", pos=(10, 10)) + msg1.fill_color = mcrfpy.Color(255, 200, 100) + msg1.font_size = 14 + log_frame.children.append(msg1) + + msg2 = mcrfpy.Caption(text="The Orc hits you for 4 damage!", pos=(10, 30)) + msg2.fill_color = mcrfpy.Color(255, 100, 100) + msg2.font_size = 14 + log_frame.children.append(msg2) + + scene.activate() + output_path = os.path.join(OUTPUT_DIR, "combat_melee.png") + automation.screenshot(output_path) + print(f" -> {output_path}") + + +def screenshot_dungeon_generator(): + """Create a dungeon generator showcase.""" + scene = mcrfpy.Scene("dungeon_gen") + + texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + grid = mcrfpy.Grid( + pos=(50, 80), + size=(700, 480), + grid_size=(24, 16), + texture=texture, + zoom=2.0 + ) + grid.fill_color = mcrfpy.Color(10, 10, 15) + scene.children.append(grid) + + # Title + title = mcrfpy.Caption(text="Dungeon Generator Recipe", pos=(50, 20)) + title.fill_color = mcrfpy.Color(255, 255, 255) + title.font_size = 28 + scene.children.append(title) + + subtitle = mcrfpy.Caption(text="Procedural rooms connected by corridors", pos=(50, 50)) + subtitle.fill_color = mcrfpy.Color(180, 180, 200) + subtitle.font_size = 16 + scene.children.append(subtitle) + + # Fill with walls + for y in range(16): + for x in range(24): + grid.at(x, y).tilesprite = TILES['wall_stone'] + + # Carve rooms + rooms = [ + (2, 2, 6, 5), # Room 1 + (10, 2, 7, 5), # Room 2 + (18, 3, 5, 4), # Room 3 + (2, 9, 5, 5), # Room 4 + (10, 10, 6, 5), # Room 5 + (18, 9, 5, 6), # Room 6 + ] + + for rx, ry, rw, rh in rooms: + for y in range(ry, ry + rh): + for x in range(rx, rx + rw): + if x < 24 and y < 16: + grid.at(x, y).tilesprite = TILES['floor_stone'] + + # Carve corridors (horizontal and vertical) + # Room 1 to Room 2 + for x in range(7, 11): + grid.at(x, 4).tilesprite = 50 # dirt corridor + # Room 2 to Room 3 + for x in range(16, 19): + grid.at(x, 4).tilesprite = 50 + # Room 1 to Room 4 + for y in range(6, 10): + grid.at(4, y).tilesprite = 50 + # Room 2 to Room 5 + for y in range(6, 11): + grid.at(13, y).tilesprite = 50 + # Room 3 to Room 6 + for y in range(6, 10): + grid.at(20, y).tilesprite = 50 + # Room 5 to Room 6 + for x in range(15, 19): + grid.at(x, 12).tilesprite = 50 + + # Add player in first room + player = mcrfpy.Entity(grid_pos=(4, 4), texture=texture, sprite_index=TILES['player_knight']) + grid.entities.append(player) + + # Add decorations + grid.entities.append(mcrfpy.Entity(grid_pos=(3, 3), texture=texture, sprite_index=TILES['torch'])) + grid.entities.append(mcrfpy.Entity(grid_pos=(12, 4), texture=texture, sprite_index=TILES['torch'])) + grid.entities.append(mcrfpy.Entity(grid_pos=(19, 11), texture=texture, sprite_index=TILES['chest_closed'])) + grid.entities.append(mcrfpy.Entity(grid_pos=(13, 12), texture=texture, sprite_index=TILES['enemy_slime'])) + grid.entities.append(mcrfpy.Entity(grid_pos=(20, 5), texture=texture, sprite_index=TILES['enemy_skeleton'])) + + grid.center = (12 * 16, 8 * 16) + + scene.activate() + output_path = os.path.join(OUTPUT_DIR, "grid_dungeon_generator.png") + automation.screenshot(output_path) + print(f" -> {output_path}") + + +def screenshot_floating_text(): + """Create a floating text/damage numbers showcase.""" + scene = mcrfpy.Scene("floating_text") + + texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + grid = mcrfpy.Grid( + pos=(50, 100), + size=(700, 420), + grid_size=(12, 8), + texture=texture, + zoom=3.5 + ) + grid.fill_color = mcrfpy.Color(20, 20, 30) + scene.children.append(grid) + + # Title + title = mcrfpy.Caption(text="Floating Text Recipe", pos=(50, 20)) + title.fill_color = mcrfpy.Color(255, 255, 255) + title.font_size = 28 + scene.children.append(title) + + subtitle = mcrfpy.Caption(text="Animated damage numbers and status messages", pos=(50, 50)) + subtitle.fill_color = mcrfpy.Color(180, 180, 200) + subtitle.font_size = 16 + scene.children.append(subtitle) + + # Fill floor + for y in range(8): + for x in range(12): + grid.at(x, y).tilesprite = TILES['floor_stone'] + + # Walls + for x in range(12): + grid.at(x, 0).tilesprite = TILES['wall_stone'] + grid.at(x, 7).tilesprite = TILES['wall_stone'] + for y in range(8): + grid.at(0, y).tilesprite = TILES['wall_stone'] + grid.at(11, y).tilesprite = TILES['wall_stone'] + + # Player and enemy in combat + player = mcrfpy.Entity(grid_pos=(4, 4), texture=texture, sprite_index=TILES['player_warrior']) + grid.entities.append(player) + + enemy = mcrfpy.Entity(grid_pos=(7, 4), texture=texture, sprite_index=TILES['enemy_orc']) + grid.entities.append(enemy) + + grid.center = (5.5 * 16, 4 * 16) + + # Floating damage numbers (as captions positioned over entities) + # These would normally animate upward + dmg1 = mcrfpy.Caption(text="-12", pos=(330, 240)) + dmg1.fill_color = mcrfpy.Color(255, 80, 80) + dmg1.font_size = 24 + scene.children.append(dmg1) + + dmg2 = mcrfpy.Caption(text="-5", pos=(500, 260)) + dmg2.fill_color = mcrfpy.Color(255, 100, 100) + dmg2.font_size = 20 + scene.children.append(dmg2) + + crit = mcrfpy.Caption(text="CRITICAL!", pos=(280, 200)) + crit.fill_color = mcrfpy.Color(255, 200, 50) + crit.font_size = 18 + scene.children.append(crit) + + heal = mcrfpy.Caption(text="+8", pos=(320, 280)) + heal.fill_color = mcrfpy.Color(100, 255, 100) + heal.font_size = 20 + scene.children.append(heal) + + scene.activate() + output_path = os.path.join(OUTPUT_DIR, "effects_floating_text.png") + automation.screenshot(output_path) + print(f" -> {output_path}") + + +def screenshot_message_log(): + """Create a message log showcase.""" + scene = mcrfpy.Scene("message_log") + + # Dark background + bg = mcrfpy.Frame(pos=(0, 0), size=(800, 600)) + bg.fill_color = mcrfpy.Color(20, 20, 30) + scene.children.append(bg) + + # Title + title = mcrfpy.Caption(text="Message Log Recipe", pos=(50, 30)) + title.fill_color = mcrfpy.Color(255, 255, 255) + title.font_size = 28 + scene.children.append(title) + + subtitle = mcrfpy.Caption(text="Scrollable combat and event messages", pos=(50, 60)) + subtitle.fill_color = mcrfpy.Color(180, 180, 200) + subtitle.font_size = 16 + scene.children.append(subtitle) + + # Message log frame + log_frame = mcrfpy.Frame(pos=(50, 100), size=(700, 400)) + log_frame.fill_color = mcrfpy.Color(30, 30, 40) + log_frame.outline = 2 + log_frame.outline_color = mcrfpy.Color(60, 60, 80) + scene.children.append(log_frame) + + # Sample messages with colors + messages = [ + ("Welcome to the dungeon!", mcrfpy.Color(200, 200, 255)), + ("You see a dark corridor ahead.", mcrfpy.Color(180, 180, 180)), + ("A goblin appears!", mcrfpy.Color(255, 200, 100)), + ("You hit the Goblin for 8 damage!", mcrfpy.Color(255, 255, 150)), + ("The Goblin hits you for 3 damage!", mcrfpy.Color(255, 100, 100)), + ("You hit the Goblin for 12 damage! Critical hit!", mcrfpy.Color(255, 200, 50)), + ("The Goblin dies!", mcrfpy.Color(150, 255, 150)), + ("You found a Healing Potion.", mcrfpy.Color(100, 200, 255)), + ("An Orc blocks your path!", mcrfpy.Color(255, 150, 100)), + ("You drink the Healing Potion. +15 HP", mcrfpy.Color(100, 255, 100)), + ("You hit the Orc for 6 damage!", mcrfpy.Color(255, 255, 150)), + ("The Orc hits you for 8 damage!", mcrfpy.Color(255, 100, 100)), + ] + + for i, (msg, color) in enumerate(messages): + caption = mcrfpy.Caption(text=msg, pos=(15, 15 + i * 30)) + caption.fill_color = color + caption.font_size = 16 + log_frame.children.append(caption) + + # Scroll indicator + scroll = mcrfpy.Caption(text="▼ More messages below", pos=(580, 370)) + scroll.fill_color = mcrfpy.Color(100, 100, 120) + scroll.font_size = 12 + log_frame.children.append(scroll) + + scene.activate() + output_path = os.path.join(OUTPUT_DIR, "ui_message_log.png") + automation.screenshot(output_path) + print(f" -> {output_path}") + + +def main(): + """Generate all cookbook screenshots!""" + os.makedirs(OUTPUT_DIR, exist_ok=True) + + print("=== Cookbook Screenshot Showcase ===") + print(f"Output: {OUTPUT_DIR}\n") + + showcases = [ + ('Health Bar UI', screenshot_health_bar), + ('Fog of War', screenshot_fog_of_war), + ('Melee Combat', screenshot_combat_melee), + ('Dungeon Generator', screenshot_dungeon_generator), + ('Floating Text', screenshot_floating_text), + ('Message Log', screenshot_message_log), + ] + + for name, func in showcases: + print(f"Generating {name}...") + try: + func() + except Exception as e: + print(f" ERROR: {e}") + import traceback + traceback.print_exc() + + print("\n=== All cookbook screenshots generated! ===") + sys.exit(0) + + +main() diff --git a/tests/demo/new_features_showcase.py b/tests/demo/new_features_showcase.py new file mode 100644 index 0000000..29cdee0 --- /dev/null +++ b/tests/demo/new_features_showcase.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +""" +New Features Screenshot Showcase - Alignment + Dijkstra-to-HeightMap + +Generates screenshots for the new API cookbook recipes. +Run with: xvfb-run -a ./build/mcrogueface --headless --exec tests/demo/new_features_showcase.py +""" +import mcrfpy +from mcrfpy import automation +import sys +import os + +OUTPUT_DIR = "/opt/goblincorps/repos/mcrogueface.github.io/images/cookbook" + + +def screenshot_alignment(): + """Create an alignment system showcase.""" + scene = mcrfpy.Scene("alignment") + + # Dark background + bg = mcrfpy.Frame(pos=(0, 0), size=(800, 600)) + bg.fill_color = mcrfpy.Color(20, 20, 30) + scene.children.append(bg) + + # Title + title = mcrfpy.Caption(text="UI Alignment System", pos=(50, 20)) + title.fill_color = mcrfpy.Color(255, 255, 255) + title.font_size = 28 + scene.children.append(title) + + subtitle = mcrfpy.Caption(text="Auto-positioning with reactive resize", pos=(50, 50)) + subtitle.fill_color = mcrfpy.Color(180, 180, 200) + subtitle.font_size = 16 + scene.children.append(subtitle) + + # Demo container + container = mcrfpy.Frame(pos=(100, 100), size=(600, 400)) + container.fill_color = mcrfpy.Color(40, 40, 50) + container.outline = 2 + container.outline_color = mcrfpy.Color(80, 80, 100) + scene.children.append(container) + + # Container label + container_label = mcrfpy.Caption(text="Parent Container (600x400)", pos=(10, 10)) + container_label.fill_color = mcrfpy.Color(100, 100, 120) + container_label.font_size = 12 + container.children.append(container_label) + + # 9 alignment positions demo + alignments = [ + (mcrfpy.Alignment.TOP_LEFT, "TL", mcrfpy.Color(200, 80, 80)), + (mcrfpy.Alignment.TOP_CENTER, "TC", mcrfpy.Color(200, 150, 80)), + (mcrfpy.Alignment.TOP_RIGHT, "TR", mcrfpy.Color(200, 200, 80)), + (mcrfpy.Alignment.CENTER_LEFT, "CL", mcrfpy.Color(80, 200, 80)), + (mcrfpy.Alignment.CENTER, "C", mcrfpy.Color(80, 200, 200)), + (mcrfpy.Alignment.CENTER_RIGHT, "CR", mcrfpy.Color(80, 80, 200)), + (mcrfpy.Alignment.BOTTOM_LEFT, "BL", mcrfpy.Color(150, 80, 200)), + (mcrfpy.Alignment.BOTTOM_CENTER, "BC", mcrfpy.Color(200, 80, 200)), + (mcrfpy.Alignment.BOTTOM_RIGHT, "BR", mcrfpy.Color(200, 80, 150)), + ] + + for align, label, color in alignments: + box = mcrfpy.Frame(pos=(0, 0), size=(60, 40)) + box.fill_color = color + box.outline = 1 + box.outline_color = mcrfpy.Color(255, 255, 255) + box.align = align + if align != mcrfpy.Alignment.CENTER: + box.margin = 15.0 + + # Label inside box + text = mcrfpy.Caption(text=label, pos=(0, 0)) + text.fill_color = mcrfpy.Color(255, 255, 255) + text.font_size = 16 + text.align = mcrfpy.Alignment.CENTER + box.children.append(text) + + container.children.append(box) + + # Legend + legend = mcrfpy.Caption(text="TL=TOP_LEFT TC=TOP_CENTER TR=TOP_RIGHT etc.", pos=(100, 520)) + legend.fill_color = mcrfpy.Color(150, 150, 170) + legend.font_size = 14 + scene.children.append(legend) + + legend2 = mcrfpy.Caption(text="All boxes have margin=15 except CENTER", pos=(100, 545)) + legend2.fill_color = mcrfpy.Color(150, 150, 170) + legend2.font_size = 14 + scene.children.append(legend2) + + scene.activate() + output_path = os.path.join(OUTPUT_DIR, "ui_alignment.png") + automation.screenshot(output_path) + print(f" -> {output_path}") + + +def screenshot_dijkstra_heightmap(): + """Create a dijkstra-to-heightmap showcase.""" + scene = mcrfpy.Scene("dijkstra_hmap") + + # Title + title = mcrfpy.Caption(text="Dijkstra to HeightMap", pos=(50, 20)) + title.fill_color = mcrfpy.Color(255, 255, 255) + title.font_size = 28 + scene.children.append(title) + + subtitle = mcrfpy.Caption(text="Distance-based gradients for fog, difficulty, and visualization", pos=(50, 50)) + subtitle.fill_color = mcrfpy.Color(180, 180, 200) + subtitle.font_size = 16 + scene.children.append(subtitle) + + # Create grid for dijkstra visualization + texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + grid = mcrfpy.Grid( + pos=(50, 90), + size=(350, 350), + grid_size=(16, 16), + texture=texture, + zoom=1.3 + ) + grid.fill_color = mcrfpy.Color(20, 20, 30) + scene.children.append(grid) + + # Initialize grid + for y in range(16): + for x in range(16): + grid.at((x, y)).walkable = True + grid.at((x, y)).tilesprite = 42 # floor + + # Add some walls + for i in range(5, 11): + grid.at((i, 5)).walkable = False + grid.at((i, 5)).tilesprite = 30 # wall + grid.at((5, i)).walkable = False + grid.at((5, i)).tilesprite = 30 + + # Player at center + player = mcrfpy.Entity(grid_pos=(8, 8), texture=texture, sprite_index=84) + grid.entities.append(player) + + # Get dijkstra and create color visualization + dijkstra = grid.get_dijkstra_map((8, 8)) + hmap = dijkstra.to_heightmap(unreachable=-1.0) + + # Find max for normalization + max_dist = 0 + for y in range(16): + for x in range(16): + d = hmap[(x, y)] + if d > max_dist and d >= 0: + max_dist = d + + # Second visualization panel - color gradient + viz_frame = mcrfpy.Frame(pos=(420, 90), size=(350, 350)) + viz_frame.fill_color = mcrfpy.Color(30, 30, 40) + viz_frame.outline = 2 + viz_frame.outline_color = mcrfpy.Color(60, 60, 80) + scene.children.append(viz_frame) + + viz_label = mcrfpy.Caption(text="Distance Visualization", pos=(80, 10)) + viz_label.fill_color = mcrfpy.Color(200, 200, 220) + viz_label.font_size = 16 + viz_frame.children.append(viz_label) + + # Draw colored squares for each cell + cell_size = 20 + offset_x = 15 + offset_y = 35 + + for y in range(16): + for x in range(16): + dist = hmap[(x, y)] + + if dist < 0: + # Unreachable - dark red + color = mcrfpy.Color(60, 0, 0) + elif dist == 0: + # Source - bright yellow + color = mcrfpy.Color(255, 255, 0) + else: + # Gradient: green (near) to blue (far) + t = min(1.0, dist / max_dist) + r = 0 + g = int(200 * (1 - t)) + b = int(200 * t) + color = mcrfpy.Color(r, g, b) + + cell = mcrfpy.Frame( + pos=(offset_x + x * cell_size, offset_y + y * cell_size), + size=(cell_size - 1, cell_size - 1) + ) + cell.fill_color = color + viz_frame.children.append(cell) + + # Legend + legend_frame = mcrfpy.Frame(pos=(50, 460), size=(720, 100)) + legend_frame.fill_color = mcrfpy.Color(30, 30, 40) + legend_frame.outline = 1 + legend_frame.outline_color = mcrfpy.Color(60, 60, 80) + scene.children.append(legend_frame) + + leg1 = mcrfpy.Caption(text="Use Cases:", pos=(15, 10)) + leg1.fill_color = mcrfpy.Color(255, 255, 255) + leg1.font_size = 16 + legend_frame.children.append(leg1) + + uses = [ + "Distance-based enemy difficulty", + "Fog intensity gradients", + "Pathfinding visualization", + "Influence maps for AI", + ] + for i, use in enumerate(uses): + txt = mcrfpy.Caption(text=f"- {use}", pos=(15 + (i // 2) * 350, 35 + (i % 2) * 25)) + txt.fill_color = mcrfpy.Color(180, 180, 200) + txt.font_size = 14 + legend_frame.children.append(txt) + + # Color key + key_label = mcrfpy.Caption(text="Yellow=Source Green=Near Blue=Far Red=Blocked", pos=(420, 450)) + key_label.fill_color = mcrfpy.Color(150, 150, 170) + key_label.font_size = 12 + scene.children.append(key_label) + + scene.activate() + output_path = os.path.join(OUTPUT_DIR, "grid_dijkstra_heightmap.png") + automation.screenshot(output_path) + print(f" -> {output_path}") + + +def main(): + os.makedirs(OUTPUT_DIR, exist_ok=True) + + print("=== New Features Screenshot Showcase ===") + print(f"Output: {OUTPUT_DIR}\n") + + showcases = [ + ('Alignment System', screenshot_alignment), + ('Dijkstra to HeightMap', screenshot_dijkstra_heightmap), + ] + + for name, func in showcases: + print(f"Generating {name}...") + try: + func() + except Exception as e: + print(f" ERROR: {e}") + import traceback + traceback.print_exc() + + print("\n=== New feature screenshots generated! ===") + sys.exit(0) + + +main() diff --git a/tests/demo/procgen_showcase.py b/tests/demo/procgen_showcase.py new file mode 100644 index 0000000..2cdba0a --- /dev/null +++ b/tests/demo/procgen_showcase.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +"""Generate screenshots for procgen cookbook recipes. + +Uses Frame-based visualization since Grid cell colors use ColorLayer API. +""" +import mcrfpy +from mcrfpy import automation +import sys + +OUTPUT_DIR = "/opt/goblincorps/repos/mcrogueface.github.io/images/cookbook" + +# Simple PRNG +_seed = 42 + +def random(): + global _seed + _seed = (_seed * 1103515245 + 12345) & 0x7fffffff + return (_seed >> 16) / 32768.0 + +def seed(n): + global _seed + _seed = n + +def choice(lst): + return lst[int(random() * len(lst))] + + +def screenshot_cellular_caves(): + """Generate cellular automata caves visualization.""" + print("Generating cellular automata caves...") + + scene = mcrfpy.Scene("caves") + scene.activate() + mcrfpy.step(0.1) + + # Background + bg = mcrfpy.Frame(pos=(0, 0), size=(640, 500)) + bg.fill_color = mcrfpy.Color(15, 15, 25) + scene.children.append(bg) + + width, height = 50, 35 + cell_size = 12 + seed(42) + + # Store cell data + cells = [[False for _ in range(width)] for _ in range(height)] + + # Step 1: Random noise (45% walls) + for y in range(height): + for x in range(width): + if x == 0 or x == width-1 or y == 0 or y == height-1: + cells[y][x] = True # Border walls + else: + cells[y][x] = random() < 0.45 + + # Step 2: Smooth with cellular automata (5 iterations) + for _ in range(5): + new_cells = [[cells[y][x] for x in range(width)] for y in range(height)] + for y in range(1, height - 1): + for x in range(1, width - 1): + wall_count = sum( + 1 for dy in [-1, 0, 1] for dx in [-1, 0, 1] + if not (dx == 0 and dy == 0) and cells[y + dy][x + dx] + ) + if wall_count >= 5: + new_cells[y][x] = True + elif wall_count <= 3: + new_cells[y][x] = False + cells = new_cells + + # Find largest connected region + visited = set() + regions = [] + + def flood_fill(start_x, start_y): + result = [] + stack = [(start_x, start_y)] + while stack: + x, y = stack.pop() + if (x, y) in visited or x < 0 or x >= width or y < 0 or y >= height: + continue + if cells[y][x]: # Wall + continue + visited.add((x, y)) + result.append((x, y)) + stack.extend([(x+1, y), (x-1, y), (x, y+1), (x, y-1)]) + return result + + for y in range(height): + for x in range(width): + if (x, y) not in visited and not cells[y][x]: + region = flood_fill(x, y) + if region: + regions.append(region) + + largest = max(regions, key=len) if regions else [] + largest_set = set(largest) + + # Draw cells as colored frames + for y in range(height): + for x in range(width): + px = 20 + x * cell_size + py = 20 + y * cell_size + cell = mcrfpy.Frame(pos=(px, py), size=(cell_size - 1, cell_size - 1)) + + if cells[y][x]: + cell.fill_color = mcrfpy.Color(60, 40, 30) # Wall + elif (x, y) in largest_set: + cell.fill_color = mcrfpy.Color(50, 90, 100) # Main cave + else: + cell.fill_color = mcrfpy.Color(45, 35, 30) # Filled region + + scene.children.append(cell) + + # Title + title = mcrfpy.Caption(text="Cellular Automata Caves", pos=(20, 445)) + title.fill_color = mcrfpy.Color(200, 200, 200) + title.font_size = 18 + scene.children.append(title) + + subtitle = mcrfpy.Caption(text="45% fill, 5 iterations, largest region preserved", pos=(20, 468)) + subtitle.fill_color = mcrfpy.Color(130, 130, 140) + subtitle.font_size = 12 + scene.children.append(subtitle) + + mcrfpy.step(0.1) + automation.screenshot(OUTPUT_DIR + "/procgen_cellular_caves.png") + print("Saved: procgen_cellular_caves.png") + + +def screenshot_wfc(): + """Generate WFC pattern visualization.""" + print("Generating WFC patterns...") + + scene = mcrfpy.Scene("wfc") + scene.activate() + mcrfpy.step(0.1) + + # Background + bg = mcrfpy.Frame(pos=(0, 0), size=(640, 500)) + bg.fill_color = mcrfpy.Color(15, 20, 15) + scene.children.append(bg) + + width, height = 40, 28 + cell_size = 15 + seed(123) + + GRASS, DIRT, WATER, SAND = 0, 1, 2, 3 + colors = { + GRASS: mcrfpy.Color(60, 120, 50), + DIRT: mcrfpy.Color(100, 70, 40), + WATER: mcrfpy.Color(40, 80, 140), + SAND: mcrfpy.Color(180, 160, 90) + } + + rules = { + GRASS: {'N': [GRASS, DIRT, SAND], 'S': [GRASS, DIRT, SAND], + 'E': [GRASS, DIRT, SAND], 'W': [GRASS, DIRT, SAND]}, + DIRT: {'N': [GRASS, DIRT], 'S': [GRASS, DIRT], + 'E': [GRASS, DIRT], 'W': [GRASS, DIRT]}, + WATER: {'N': [WATER, SAND], 'S': [WATER, SAND], + 'E': [WATER, SAND], 'W': [WATER, SAND]}, + SAND: {'N': [GRASS, WATER, SAND], 'S': [GRASS, WATER, SAND], + 'E': [GRASS, WATER, SAND], 'W': [GRASS, WATER, SAND]} + } + + tiles = set(rules.keys()) + possibilities = {(x, y): set(tiles) for y in range(height) for x in range(width)} + result = {} + + # Seed water lake + for x in range(22, 32): + for y in range(8, 18): + possibilities[(x, y)] = {WATER} + result[(x, y)] = WATER + + # Seed dirt path + for y in range(10, 18): + possibilities[(3, y)] = {DIRT} + result[(3, y)] = DIRT + + directions = {'N': (0, -1), 'S': (0, 1), 'E': (1, 0), 'W': (-1, 0)} + + def propagate(sx, sy): + stack = [(sx, sy)] + while stack: + x, y = stack.pop() + current = possibilities[(x, y)] + for dir_name, (dx, dy) in directions.items(): + nx, ny = x + dx, y + dy + if not (0 <= nx < width and 0 <= ny < height): + continue + neighbor = possibilities[(nx, ny)] + if len(neighbor) == 1: + continue + allowed = set() + for tile in current: + if dir_name in rules[tile]: + allowed.update(rules[tile][dir_name]) + new_opts = neighbor & allowed + if new_opts and new_opts != neighbor: + possibilities[(nx, ny)] = new_opts + stack.append((nx, ny)) + + # Propagate from seeds + for x in range(22, 32): + for y in range(8, 18): + propagate(x, y) + for y in range(10, 18): + propagate(3, y) + + # Collapse + for _ in range(width * height): + best, best_e = None, 1000.0 + for pos, opts in possibilities.items(): + if len(opts) > 1: + e = len(opts) + random() * 0.1 + if e < best_e: + best_e, best = e, pos + + if best is None: + break + + x, y = best + opts = list(possibilities[(x, y)]) + if not opts: + break + + weights = {GRASS: 5, DIRT: 2, WATER: 1, SAND: 2} + weighted = [] + for t in opts: + weighted.extend([t] * weights.get(t, 1)) + chosen = choice(weighted) if weighted else GRASS + + possibilities[(x, y)] = {chosen} + result[(x, y)] = chosen + propagate(x, y) + + # Fill remaining + for y in range(height): + for x in range(width): + if (x, y) not in result: + opts = list(possibilities[(x, y)]) + result[(x, y)] = choice(opts) if opts else GRASS + + # Draw + for y in range(height): + for x in range(width): + px = 20 + x * cell_size + py = 20 + y * cell_size + cell = mcrfpy.Frame(pos=(px, py), size=(cell_size - 1, cell_size - 1)) + cell.fill_color = colors[result[(x, y)]] + scene.children.append(cell) + + # Title + title = mcrfpy.Caption(text="Wave Function Collapse", pos=(20, 445)) + title.fill_color = mcrfpy.Color(200, 200, 200) + title.font_size = 18 + scene.children.append(title) + + subtitle = mcrfpy.Caption(text="Constraint-based terrain (seeded lake + path)", pos=(20, 468)) + subtitle.fill_color = mcrfpy.Color(130, 140, 130) + subtitle.font_size = 12 + scene.children.append(subtitle) + + # Legend + for i, (name, tid) in enumerate([("Grass", GRASS), ("Dirt", DIRT), ("Sand", SAND), ("Water", WATER)]): + lx, ly = 480, 445 + i * 14 + swatch = mcrfpy.Frame(pos=(lx, ly), size=(12, 12)) + swatch.fill_color = colors[tid] + scene.children.append(swatch) + label = mcrfpy.Caption(text=name, pos=(lx + 16, ly)) + label.fill_color = mcrfpy.Color(150, 150, 150) + label.font_size = 11 + scene.children.append(label) + + mcrfpy.step(0.1) + automation.screenshot(OUTPUT_DIR + "/procgen_wfc.png") + print("Saved: procgen_wfc.png") + + +if __name__ == "__main__": + screenshot_cellular_caves() + screenshot_wfc() + print("\nDone!") + sys.exit(0) diff --git a/tests/demo/simple_showcase.py b/tests/demo/simple_showcase.py new file mode 100644 index 0000000..eeb63a1 --- /dev/null +++ b/tests/demo/simple_showcase.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +Simple Tutorial Screenshot Generator + +This creates ONE screenshot - the part01 tutorial showcase. +Run with: xvfb-run -a ./build/mcrogueface --headless --exec tests/demo/simple_showcase.py + +NOTE: In headless mode, automation.screenshot() is SYNCHRONOUS - it renders +and captures immediately. No timer dance needed! +""" +import mcrfpy +from mcrfpy import automation +import sys +import os + +# Output +OUTPUT_PATH = "/opt/goblincorps/repos/mcrogueface.github.io/images/tutorials/part_01_grid_movement.png" + +# Tile sprites from the labeled tileset +PLAYER_KNIGHT = 84 +FLOOR_STONE = 42 +WALL_STONE = 30 +TORCH = 72 +BARREL = 73 +SKULL = 74 + +def main(): + """Create the part01 showcase screenshot.""" + # Ensure output dir exists + os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True) + + # Create scene + scene = mcrfpy.Scene("showcase") + + # Load texture + texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + + # Create grid - bigger zoom for visibility + grid = mcrfpy.Grid( + pos=(50, 80), + size=(700, 480), + grid_size=(12, 9), + texture=texture, + zoom=3.5 + ) + grid.fill_color = mcrfpy.Color(20, 20, 30) + scene.children.append(grid) + + # Fill with floor + for y in range(9): + for x in range(12): + grid.at(x, y).tilesprite = FLOOR_STONE + + # Add wall border + for x in range(12): + grid.at(x, 0).tilesprite = WALL_STONE + grid.at(x, 0).walkable = False + grid.at(x, 8).tilesprite = WALL_STONE + grid.at(x, 8).walkable = False + for y in range(9): + grid.at(0, y).tilesprite = WALL_STONE + grid.at(0, y).walkable = False + grid.at(11, y).tilesprite = WALL_STONE + grid.at(11, y).walkable = False + + # Add player entity - a knight! + player = mcrfpy.Entity( + grid_pos=(6, 4), + texture=texture, + sprite_index=PLAYER_KNIGHT + ) + grid.entities.append(player) + + # Add decorations + for pos, sprite in [((2, 2), TORCH), ((9, 2), TORCH), ((2, 6), BARREL), ((9, 6), SKULL)]: + entity = mcrfpy.Entity(grid_pos=pos, texture=texture, sprite_index=sprite) + grid.entities.append(entity) + + # Center camera on player + grid.center = (6 * 16 + 8, 4 * 16 + 8) + + # Add title + title = mcrfpy.Caption(text="Part 1: The '@' and the Dungeon Grid", pos=(50, 20)) + title.fill_color = mcrfpy.Color(255, 255, 255) + title.font_size = 28 + scene.children.append(title) + + subtitle = mcrfpy.Caption(text="Creating a grid, placing entities, handling input", pos=(50, 50)) + subtitle.fill_color = mcrfpy.Color(180, 180, 200) + subtitle.font_size = 16 + scene.children.append(subtitle) + + # Activate scene + scene.activate() + + # In headless mode, screenshot() is synchronous - renders then captures! + result = automation.screenshot(OUTPUT_PATH) + print(f"Screenshot saved: {OUTPUT_PATH} (result: {result})") + sys.exit(0) + + +# Run it +main() diff --git a/tests/demo/tutorial_screenshots.py b/tests/demo/tutorial_screenshots.py new file mode 100644 index 0000000..ee14e03 --- /dev/null +++ b/tests/demo/tutorial_screenshots.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +""" +Tutorial Screenshot Generator + +Usage: + ./mcrogueface --headless --exec tests/demo/tutorial_screenshots.py + +Extracts code from tutorial markdown files and generates screenshots. +""" +import mcrfpy +from mcrfpy import automation +import sys +import os +import re + +# Paths +DOCS_REPO = "/opt/goblincorps/repos/mcrogueface.github.io" +TUTORIAL_DIR = os.path.join(DOCS_REPO, "tutorial") +OUTPUT_DIR = os.path.join(DOCS_REPO, "images", "tutorials") + +# Tutorials to process (in order) +TUTORIALS = [ + "part_01_grid_movement.md", + "part_02_tiles_collision.md", + "part_03_dungeon_generation.md", + "part_04_fov.md", + "part_05_enemies.md", + "part_06_combat.md", + "part_07_ui.md", +] + + +def extract_code_from_markdown(filepath): + """Extract the main Python code block from a tutorial markdown file.""" + with open(filepath, 'r') as f: + content = f.read() + + # Find code blocks after "## The Complete Code" header + # Look for the first python code block after that header + complete_code_match = re.search( + r'##\s+The Complete Code.*?```python\s*\n(.*?)```', + content, + re.DOTALL | re.IGNORECASE + ) + + if complete_code_match: + return complete_code_match.group(1) + + # Fallback: just get the first large python code block + code_blocks = re.findall(r'```python\s*\n(.*?)```', content, re.DOTALL) + if code_blocks: + # Return the largest code block (likely the main example) + return max(code_blocks, key=len) + + return None + + +def add_screenshot_hook(code, screenshot_path): + """Add screenshot capture code to the end of the script.""" + # Add code to take screenshot after a brief delay + hook_code = f''' + +# === Screenshot capture hook (added by tutorial_screenshots.py) === +import mcrfpy +from mcrfpy import automation +import sys + +_screenshot_taken = [False] + +def _take_screenshot(timer, runtime): + if not _screenshot_taken[0]: + _screenshot_taken[0] = True + automation.screenshot("{screenshot_path}") + print(f"Screenshot saved: {screenshot_path}") + sys.exit(0) + +# Wait a moment for scene to render, then capture +mcrfpy.Timer("_screenshot_hook", _take_screenshot, 200) +''' + return code + hook_code + + +class TutorialScreenshotter: + """Manages tutorial screenshot generation.""" + + def __init__(self): + self.tutorials = [] + self.current_index = 0 + + def load_tutorials(self): + """Load and parse all tutorial files.""" + for filename in TUTORIALS: + filepath = os.path.join(TUTORIAL_DIR, filename) + if not os.path.exists(filepath): + print(f"Warning: {filepath} not found, skipping") + continue + + code = extract_code_from_markdown(filepath) + if code: + # Generate output filename + base = os.path.splitext(filename)[0] + screenshot_name = f"{base}.png" + self.tutorials.append({ + 'name': filename, + 'code': code, + 'screenshot': screenshot_name, + 'filepath': filepath, + }) + print(f"Loaded: {filename}") + else: + print(f"Warning: No code found in {filename}") + + def run(self): + """Generate all screenshots.""" + # Ensure output directory exists + os.makedirs(OUTPUT_DIR, exist_ok=True) + + print(f"\nGenerating {len(self.tutorials)} tutorial screenshots...") + print(f"Output directory: {OUTPUT_DIR}\n") + + self.process_next() + + def process_next(self): + """Process the next tutorial.""" + if self.current_index >= len(self.tutorials): + print("\nAll screenshots generated!") + sys.exit(0) + return + + tutorial = self.tutorials[self.current_index] + print(f"[{self.current_index + 1}/{len(self.tutorials)}] Processing {tutorial['name']}...") + + # Add screenshot hook to the code + screenshot_path = os.path.join(OUTPUT_DIR, tutorial['screenshot']) + modified_code = add_screenshot_hook(tutorial['code'], screenshot_path) + + # Write to temp file and execute + temp_path = f"/tmp/tutorial_screenshot_{self.current_index}.py" + with open(temp_path, 'w') as f: + f.write(modified_code) + + try: + # Execute the code + exec(compile(modified_code, temp_path, 'exec'), {'__name__': '__main__'}) + except Exception as e: + print(f"Error processing {tutorial['name']}: {e}") + self.current_index += 1 + self.process_next() + finally: + try: + os.unlink(temp_path) + except: + pass + + +def main(): + """Main entry point.""" + screenshotter = TutorialScreenshotter() + screenshotter.load_tutorials() + + if not screenshotter.tutorials: + print("No tutorials found to process!") + sys.exit(1) + + screenshotter.run() + + +# Run when executed +main() diff --git a/tests/demo/tutorial_showcase.py b/tests/demo/tutorial_showcase.py new file mode 100644 index 0000000..6f22445 --- /dev/null +++ b/tests/demo/tutorial_showcase.py @@ -0,0 +1,426 @@ +#!/usr/bin/env python3 +""" +Tutorial Screenshot Showcase - ALL THE SCREENSHOTS! + +Generates beautiful screenshots for all tutorial parts. +Run with: xvfb-run -a ./build/mcrogueface --headless --exec tests/demo/tutorial_showcase.py + +In headless mode, automation.screenshot() is SYNCHRONOUS - no timer dance needed! +""" +import mcrfpy +from mcrfpy import automation +import sys +import os + +# Output directory +OUTPUT_DIR = "/opt/goblincorps/repos/mcrogueface.github.io/images/tutorials" + +# Tile meanings from the labeled tileset - the FUN sprites! +TILES = { + # Players - knights and heroes! + 'player_knight': 84, + 'player_mage': 85, + 'player_rogue': 86, + 'player_warrior': 87, + 'player_archer': 88, + 'player_alt1': 96, + 'player_alt2': 97, + 'player_alt3': 98, + + # Enemies - scary! + 'enemy_slime': 108, + 'enemy_bat': 109, + 'enemy_spider': 110, + 'enemy_rat': 111, + 'enemy_orc': 120, + 'enemy_troll': 121, + 'enemy_ghost': 122, + 'enemy_skeleton': 123, + 'enemy_demon': 124, + 'enemy_boss': 92, + + # Terrain + 'floor_stone': 42, + 'floor_wood': 49, + 'floor_grass': 48, + 'floor_dirt': 50, + 'wall_stone': 30, + 'wall_brick': 14, + 'wall_mossy': 28, + + # Items + 'item_potion': 113, + 'item_scroll': 114, + 'item_key': 115, + 'item_coin': 116, + + # Equipment + 'equip_sword': 101, + 'equip_shield': 102, + 'equip_helm': 103, + 'equip_armor': 104, + + # Chests and doors + 'chest_closed': 89, + 'chest_open': 90, + 'door_closed': 33, + 'door_open': 35, + + # Decorations + 'torch': 72, + 'barrel': 73, + 'skull': 74, + 'bones': 75, +} + + +class TutorialShowcase: + """Creates beautiful showcase screenshots for tutorials.""" + + def __init__(self, scene_name, output_name): + self.scene = mcrfpy.Scene(scene_name) + self.output_path = os.path.join(OUTPUT_DIR, output_name) + self.grid = None + + def setup_grid(self, width, height, zoom=3.0): + """Create a grid with nice defaults.""" + texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + self.grid = mcrfpy.Grid( + pos=(50, 80), + size=(700, 500), + grid_size=(width, height), + texture=texture, + zoom=zoom + ) + self.grid.fill_color = mcrfpy.Color(20, 20, 30) + self.scene.children.append(self.grid) + self.texture = texture + return self.grid + + def add_title(self, text, subtitle=None): + """Add a title to the scene.""" + title = mcrfpy.Caption(text=text, pos=(50, 20)) + title.fill_color = mcrfpy.Color(255, 255, 255) + title.font_size = 28 + self.scene.children.append(title) + + if subtitle: + sub = mcrfpy.Caption(text=subtitle, pos=(50, 50)) + sub.fill_color = mcrfpy.Color(180, 180, 200) + sub.font_size = 16 + self.scene.children.append(sub) + + def fill_floor(self, tile=None): + """Fill grid with floor tiles.""" + if tile is None: + tile = TILES['floor_stone'] + w, h = int(self.grid.grid_size[0]), int(self.grid.grid_size[1]) + for y in range(h): + for x in range(w): + self.grid.at(x, y).tilesprite = tile + + def add_walls(self, tile=None): + """Add wall border.""" + if tile is None: + tile = TILES['wall_stone'] + w, h = int(self.grid.grid_size[0]), int(self.grid.grid_size[1]) + for x in range(w): + self.grid.at(x, 0).tilesprite = tile + self.grid.at(x, 0).walkable = False + self.grid.at(x, h-1).tilesprite = tile + self.grid.at(x, h-1).walkable = False + for y in range(h): + self.grid.at(0, y).tilesprite = tile + self.grid.at(0, y).walkable = False + self.grid.at(w-1, y).tilesprite = tile + self.grid.at(w-1, y).walkable = False + + def add_entity(self, x, y, sprite): + """Add an entity to the grid.""" + entity = mcrfpy.Entity( + grid_pos=(x, y), + texture=self.texture, + sprite_index=sprite + ) + self.grid.entities.append(entity) + return entity + + def center_on(self, x, y): + """Center camera on a position.""" + self.grid.center = (x * 16 + 8, y * 16 + 8) + + def screenshot(self): + """Take the screenshot - synchronous in headless mode!""" + self.scene.activate() + result = automation.screenshot(self.output_path) + print(f" -> {self.output_path} (result: {result})") + return result + + +def part01_grid_movement(): + """Part 1: Grid Movement - Knight in a dungeon room.""" + showcase = TutorialShowcase("part01", "part_01_grid_movement.png") + showcase.setup_grid(12, 9, zoom=3.5) + showcase.add_title("Part 1: The '@' and the Dungeon Grid", + "Creating a grid, placing entities, handling input") + + showcase.fill_floor(TILES['floor_stone']) + showcase.add_walls(TILES['wall_stone']) + + # Add the player (a cool knight, not boring @) + showcase.add_entity(6, 4, TILES['player_knight']) + + # Add some decorations to make it interesting + showcase.add_entity(2, 2, TILES['torch']) + showcase.add_entity(9, 2, TILES['torch']) + showcase.add_entity(2, 6, TILES['barrel']) + showcase.add_entity(9, 6, TILES['skull']) + + showcase.center_on(6, 4) + showcase.screenshot() + + +def part02_tiles_collision(): + """Part 2: Tiles and Collision - Walls and walkability.""" + showcase = TutorialShowcase("part02", "part_02_tiles_collision.png") + showcase.setup_grid(14, 10, zoom=3.0) + showcase.add_title("Part 2: Tiles, Collision, and Walkability", + "Different tile types and blocking movement") + + showcase.fill_floor(TILES['floor_stone']) + showcase.add_walls(TILES['wall_brick']) + + # Create some interior walls to show collision + for y in range(2, 5): + showcase.grid.at(5, y).tilesprite = TILES['wall_stone'] + showcase.grid.at(5, y).walkable = False + for y in range(5, 8): + showcase.grid.at(9, y).tilesprite = TILES['wall_stone'] + showcase.grid.at(9, y).walkable = False + + # Add a door + showcase.grid.at(5, 5).tilesprite = TILES['door_closed'] + showcase.grid.at(5, 5).walkable = False + + # Player navigating the maze + showcase.add_entity(3, 4, TILES['player_warrior']) + + # Chest as goal + showcase.add_entity(11, 5, TILES['chest_closed']) + + showcase.center_on(7, 5) + showcase.screenshot() + + +def part03_dungeon_generation(): + """Part 3: Dungeon Generation - Procedural rooms and corridors.""" + showcase = TutorialShowcase("part03", "part_03_dungeon_generation.png") + showcase.setup_grid(20, 14, zoom=2.5) + showcase.add_title("Part 3: Procedural Dungeon Generation", + "Random rooms connected by corridors") + + # Fill with walls first + for y in range(14): + for x in range(20): + showcase.grid.at(x, y).tilesprite = TILES['wall_stone'] + showcase.grid.at(x, y).walkable = False + + # Carve out two rooms + # Room 1 (left) + for y in range(3, 8): + for x in range(2, 8): + showcase.grid.at(x, y).tilesprite = TILES['floor_stone'] + showcase.grid.at(x, y).walkable = True + + # Room 2 (right) + for y in range(6, 12): + for x in range(12, 18): + showcase.grid.at(x, y).tilesprite = TILES['floor_stone'] + showcase.grid.at(x, y).walkable = True + + # Corridor connecting them + for x in range(7, 13): + showcase.grid.at(x, 6).tilesprite = TILES['floor_dirt'] + showcase.grid.at(x, 6).walkable = True + for y in range(6, 9): + showcase.grid.at(12, y).tilesprite = TILES['floor_dirt'] + showcase.grid.at(12, y).walkable = True + + # Player in first room + showcase.add_entity(4, 5, TILES['player_knight']) + + # Some loot in second room + showcase.add_entity(14, 9, TILES['chest_closed']) + showcase.add_entity(16, 8, TILES['item_potion']) + + # Torches + showcase.add_entity(3, 3, TILES['torch']) + showcase.add_entity(6, 3, TILES['torch']) + showcase.add_entity(13, 7, TILES['torch']) + + showcase.center_on(10, 7) + showcase.screenshot() + + +def part04_fov(): + """Part 4: Field of View - Showing explored vs visible areas.""" + showcase = TutorialShowcase("part04", "part_04_fov.png") + showcase.setup_grid(16, 12, zoom=2.8) + showcase.add_title("Part 4: Field of View and Fog of War", + "What the player can see vs. the unknown") + + showcase.fill_floor(TILES['floor_stone']) + showcase.add_walls(TILES['wall_brick']) + + # Some interior pillars to block sight + for pos in [(5, 4), (5, 7), (10, 5), (10, 8)]: + showcase.grid.at(pos[0], pos[1]).tilesprite = TILES['wall_mossy'] + showcase.grid.at(pos[0], pos[1]).walkable = False + + # Player with "light" + showcase.add_entity(8, 6, TILES['player_mage']) + + # Hidden enemy (player wouldn't see this!) + showcase.add_entity(12, 3, TILES['enemy_ghost']) + + # Visible enemies + showcase.add_entity(3, 5, TILES['enemy_bat']) + showcase.add_entity(6, 8, TILES['enemy_spider']) + + showcase.center_on(8, 6) + showcase.screenshot() + + +def part05_enemies(): + """Part 5: Enemies - A dungeon full of monsters.""" + showcase = TutorialShowcase("part05", "part_05_enemies.png") + showcase.setup_grid(18, 12, zoom=2.5) + showcase.add_title("Part 5: Adding Enemies", + "Different monster types with AI behavior") + + showcase.fill_floor(TILES['floor_stone']) + showcase.add_walls(TILES['wall_stone']) + + # The hero + showcase.add_entity(3, 5, TILES['player_warrior']) + + # A variety of enemies + showcase.add_entity(7, 3, TILES['enemy_slime']) + showcase.add_entity(10, 6, TILES['enemy_bat']) + showcase.add_entity(8, 8, TILES['enemy_spider']) + showcase.add_entity(14, 4, TILES['enemy_orc']) + showcase.add_entity(15, 8, TILES['enemy_skeleton']) + showcase.add_entity(12, 5, TILES['enemy_rat']) + + # Boss at the end + showcase.add_entity(15, 6, TILES['enemy_boss']) + + # Some decorations + showcase.add_entity(5, 2, TILES['bones']) + showcase.add_entity(13, 9, TILES['skull']) + showcase.add_entity(2, 8, TILES['torch']) + showcase.add_entity(16, 2, TILES['torch']) + + showcase.center_on(9, 5) + showcase.screenshot() + + +def part06_combat(): + """Part 6: Combat - Battle in progress!""" + showcase = TutorialShowcase("part06", "part_06_combat.png") + showcase.setup_grid(14, 10, zoom=3.0) + showcase.add_title("Part 6: Combat System", + "HP, attack, defense, and turn-based fighting") + + showcase.fill_floor(TILES['floor_dirt']) + showcase.add_walls(TILES['wall_brick']) + + # Battle scene - player vs enemy + showcase.add_entity(5, 5, TILES['player_knight']) + showcase.add_entity(8, 5, TILES['enemy_orc']) + + # Fallen enemies (show combat has happened) + showcase.add_entity(4, 3, TILES['bones']) + showcase.add_entity(9, 7, TILES['skull']) + + # Equipment the player has + showcase.add_entity(3, 6, TILES['equip_shield']) + showcase.add_entity(10, 4, TILES['item_potion']) + + showcase.center_on(6, 5) + showcase.screenshot() + + +def part07_ui(): + """Part 7: User Interface - Health bars and menus.""" + showcase = TutorialShowcase("part07", "part_07_ui.png") + showcase.setup_grid(12, 8, zoom=3.0) + showcase.add_title("Part 7: User Interface", + "Health bars, message logs, and menus") + + showcase.fill_floor(TILES['floor_wood']) + showcase.add_walls(TILES['wall_brick']) + + # Player + showcase.add_entity(6, 4, TILES['player_rogue']) + + # Some items to interact with + showcase.add_entity(4, 3, TILES['chest_open']) + showcase.add_entity(8, 5, TILES['item_coin']) + + # Add UI overlay example - health bar frame + ui_frame = mcrfpy.Frame(pos=(50, 520), size=(200, 40)) + ui_frame.fill_color = mcrfpy.Color(40, 40, 50, 200) + ui_frame.outline = 2 + ui_frame.outline_color = mcrfpy.Color(80, 80, 100) + showcase.scene.children.append(ui_frame) + + # Health label + hp_label = mcrfpy.Caption(text="HP: 45/50", pos=(10, 10)) + hp_label.fill_color = mcrfpy.Color(255, 100, 100) + hp_label.font_size = 18 + ui_frame.children.append(hp_label) + + # Health bar background + hp_bg = mcrfpy.Frame(pos=(90, 12), size=(100, 16)) + hp_bg.fill_color = mcrfpy.Color(60, 20, 20) + ui_frame.children.append(hp_bg) + + # Health bar fill + hp_fill = mcrfpy.Frame(pos=(90, 12), size=(90, 16)) # 90% health + hp_fill.fill_color = mcrfpy.Color(200, 50, 50) + ui_frame.children.append(hp_fill) + + showcase.center_on(6, 4) + showcase.screenshot() + + +def main(): + """Generate all showcase screenshots!""" + os.makedirs(OUTPUT_DIR, exist_ok=True) + + print("=== Tutorial Screenshot Showcase ===") + print(f"Output: {OUTPUT_DIR}\n") + + showcases = [ + ('Part 1: Grid Movement', part01_grid_movement), + ('Part 2: Tiles & Collision', part02_tiles_collision), + ('Part 3: Dungeon Generation', part03_dungeon_generation), + ('Part 4: Field of View', part04_fov), + ('Part 5: Enemies', part05_enemies), + ('Part 6: Combat', part06_combat), + ('Part 7: UI', part07_ui), + ] + + for name, func in showcases: + print(f"Generating {name}...") + try: + func() + except Exception as e: + print(f" ERROR: {e}") + + print("\n=== All screenshots generated! ===") + sys.exit(0) + + +main() diff --git a/tests/docs/API_FINDINGS.md b/tests/docs/API_FINDINGS.md new file mode 100644 index 0000000..b4cce5f --- /dev/null +++ b/tests/docs/API_FINDINGS.md @@ -0,0 +1,129 @@ +# McRogueFace API Test Findings +*Generated by Frack, January 14, 2026* + +## Summary + +Tested code snippets from docs site against actual runtime API. +Tests created in `/tests/docs/` with screenshots in `/tests/docs/screenshots/`. + +--- + +## Entity Constructor + +**Correct:** +```python +entity = mcrfpy.Entity(grid_pos=(10, 7), texture=texture, sprite_index=84) +``` + +**Wrong:** +```python +entity = mcrfpy.Entity(pos=(10, 7), ...) # FAILS - no 'pos' kwarg +entity = mcrfpy.Entity(grid_x=10, grid_y=7, ...) # FAILS - no separate kwargs +``` + +--- + +## Scene API + +**Modern (WORKS):** +```python +scene = mcrfpy.Scene("name") +scene.children.append(frame) +scene.on_key = handler +scene.activate() +``` + +**Deprecated → Modern:** +```python +# OLD → NEW +mcrfpy.createScene("name") → scene = mcrfpy.Scene("name") +mcrfpy.sceneUI("name") → scene.children +mcrfpy.setScene("name") → scene.activate() +mcrfpy.keypressScene(fn) → scene.on_key = fn +mcrfpy.currentScene() → mcrfpy.current_scene # property, not function! +``` + +--- + +## Grid.at() Signature + +Both work: +```python +point = grid.at((x, y)) # Tuple - documented +point = grid.at(x, y) # Separate args - also works! +``` + +--- + +## Animation System + +**Works:** +- `x`, `y`, `w`, `h` properties animate correctly +- Callbacks fire as expected +- Multiple simultaneous animations work +- Easing functions work + +**Does NOT work:** +- `opacity` - property exists, can set directly, but animation ignores it + +--- + +## Timer API + +**Modern:** +```python +timer = mcrfpy.Timer("name", callback, seconds) +# Callback signature: def callback(timer, runtime): +``` + +**Deprecated:** +```python +mcrfpy.setTimer("name", callback, milliseconds) # Wrong signature too +``` + +--- + +## Color API + +Always use `mcrfpy.Color()`: +```python +frame.fill_color = mcrfpy.Color(255, 0, 0) # CORRECT +frame.fill_color = mcrfpy.Color(255, 0, 0, 128) # With alpha +``` + +Tuples no longer work: +```python +frame.fill_color = (255, 0, 0) # FAILS +``` + +--- + +## Available Globals + +**All exist:** +- `mcrfpy.default_texture` - Texture for kenney_tinydungeon.png +- `mcrfpy.default_font` - Font for JetBrains Mono +- `mcrfpy.default_fov` - (default FOV settings) + +--- + +## Files Requiring Updates + +1. `quickstart.md` - All 4 code blocks use deprecated API +2. `features/scenes.md` - "Procedural API" section entirely deprecated +3. `features/animation.md` - opacity examples don't work +4. Any file using `setTimer`, `createScene`, `sceneUI`, `setScene`, `keypressScene` + +--- + +## Test Files Created + +| Test | Status | Notes | +|------|--------|-------| +| test_quickstart_simple_scene.py | PASS | | +| test_quickstart_main_menu.py | PASS | | +| test_quickstart_entities.py | PASS | Uses grid_pos= | +| test_quickstart_sprites.py | PASS | | +| test_features_animation.py | PASS | opacity test skipped | +| test_features_scenes.py | PASS | Documents deprecated API | +| test_entity_api.py | INFO | Verifies grid_pos= works | diff --git a/tests/docs/screenshots/features_animation.png b/tests/docs/screenshots/features_animation.png new file mode 100644 index 0000000..4280a2c Binary files /dev/null and b/tests/docs/screenshots/features_animation.png differ diff --git a/tests/docs/screenshots/features_scenes.png b/tests/docs/screenshots/features_scenes.png new file mode 100644 index 0000000..1c8ee92 Binary files /dev/null and b/tests/docs/screenshots/features_scenes.png differ diff --git a/tests/docs/screenshots/quickstart_entities.png b/tests/docs/screenshots/quickstart_entities.png new file mode 100644 index 0000000..da85c34 Binary files /dev/null and b/tests/docs/screenshots/quickstart_entities.png differ diff --git a/tests/docs/screenshots/quickstart_main_menu.png b/tests/docs/screenshots/quickstart_main_menu.png new file mode 100644 index 0000000..6bb58d6 Binary files /dev/null and b/tests/docs/screenshots/quickstart_main_menu.png differ diff --git a/tests/docs/screenshots/quickstart_simple_scene.png b/tests/docs/screenshots/quickstart_simple_scene.png new file mode 100644 index 0000000..e90b37d Binary files /dev/null and b/tests/docs/screenshots/quickstart_simple_scene.png differ diff --git a/tests/docs/screenshots/quickstart_sprites.png b/tests/docs/screenshots/quickstart_sprites.png new file mode 100644 index 0000000..a5d420e Binary files /dev/null and b/tests/docs/screenshots/quickstart_sprites.png differ diff --git a/tests/docs/test_current_scene.py b/tests/docs/test_current_scene.py new file mode 100644 index 0000000..80eb18f --- /dev/null +++ b/tests/docs/test_current_scene.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +"""Verify mcrfpy.current_scene property.""" +import mcrfpy +import sys + +scene = mcrfpy.Scene("test") +scene.activate() +mcrfpy.step(0.1) + +try: + current = mcrfpy.current_scene + print(f"mcrfpy.current_scene = {current}") + print(f"type: {type(current)}") + print("VERIFIED: mcrfpy.current_scene WORKS") +except AttributeError as e: + print(f"FAILED: {e}") + +sys.exit(0) diff --git a/tests/docs/test_defaults.py b/tests/docs/test_defaults.py new file mode 100644 index 0000000..6105cfc --- /dev/null +++ b/tests/docs/test_defaults.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Test mcrfpy default resources.""" +import mcrfpy +import sys + +scene = mcrfpy.Scene("test") +scene.activate() +mcrfpy.step(0.01) + +print("Checking mcrfpy defaults:") + +try: + dt = mcrfpy.default_texture + print(f" default_texture = {dt}") +except AttributeError as e: + print(f" default_texture: NOT FOUND") + +try: + df = mcrfpy.default_font + print(f" default_font = {df}") +except AttributeError as e: + print(f" default_font: NOT FOUND") + +# Also check what other module-level attributes exist +print("\nAll mcrfpy attributes starting with 'default':") +for attr in dir(mcrfpy): + if 'default' in attr.lower(): + print(f" {attr}") + +sys.exit(0) diff --git a/tests/docs/test_entity_api.py b/tests/docs/test_entity_api.py new file mode 100644 index 0000000..6cc8601 --- /dev/null +++ b/tests/docs/test_entity_api.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Quick test to verify Entity constructor signature.""" +import mcrfpy +import sys + +scene = mcrfpy.Scene("test") +scene.activate() +mcrfpy.step(0.01) + +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) +grid = mcrfpy.Grid(grid_size=(20, 15), texture=texture, pos=(10, 10), size=(640, 480)) +scene.children.append(grid) + +# Test grid_pos vs grid_x/grid_y +try: + e1 = mcrfpy.Entity(grid_pos=(5, 5), texture=texture, sprite_index=85) + grid.entities.append(e1) + print("grid_pos= WORKS") +except TypeError as e: + print(f"grid_pos= FAILS: {e}") + +try: + e2 = mcrfpy.Entity(grid_x=7, grid_y=7, texture=texture, sprite_index=85) + grid.entities.append(e2) + print("grid_x=/grid_y= WORKS") +except TypeError as e: + print(f"grid_x=/grid_y= FAILS: {e}") + +print("Entity API test complete") +sys.exit(0) diff --git a/tests/docs/test_features_animation.py b/tests/docs/test_features_animation.py new file mode 100644 index 0000000..2a40238 --- /dev/null +++ b/tests/docs/test_features_animation.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Test for features/animation.md examples. + +Tests the modern API equivalents of animation examples. +""" +import mcrfpy +from mcrfpy import automation +import sys + +# Setup scene using modern API +scene = mcrfpy.Scene("animation_demo") +scene.activate() +mcrfpy.step(0.01) + +# Test 1: Basic Animation (lines 9-19) +frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) +frame.fill_color = mcrfpy.Color(255, 0, 0) +scene.children.append(frame) + +# Animate x position +anim = mcrfpy.Animation("x", 500.0, 2.0, "easeInOut") +anim.start(frame) +print("Test 1: Basic animation started") + +# Step forward to run animation +mcrfpy.step(2.5) + +# Verify animation completed +if abs(frame.x - 500.0) < 1.0: + print("Test 1: PASS - frame moved to x=500") +else: + print(f"Test 1: FAIL - frame at x={frame.x}, expected 500") + sys.exit(1) + +# Test 2: Multiple simultaneous animations (lines 134-144) +frame2 = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) +frame2.fill_color = mcrfpy.Color(0, 255, 0) +scene.children.append(frame2) + +mcrfpy.Animation("x", 200.0, 1.0, "easeInOut").start(frame2) +mcrfpy.Animation("y", 150.0, 1.0, "easeInOut").start(frame2) +mcrfpy.Animation("w", 300.0, 1.0, "easeInOut").start(frame2) +mcrfpy.Animation("h", 200.0, 1.0, "easeInOut").start(frame2) + +mcrfpy.step(1.5) + +if abs(frame2.x - 200.0) < 1.0 and abs(frame2.y - 150.0) < 1.0: + print("Test 2: PASS - multiple animations completed") +else: + print(f"Test 2: FAIL - frame2 at ({frame2.x}, {frame2.y})") + sys.exit(1) + +# Test 3: Callback (lines 105-112) +callback_fired = False + +def on_complete(animation, target): + global callback_fired + callback_fired = True + print("Test 3: Callback fired!") + +frame3 = mcrfpy.Frame(pos=(0, 300), size=(50, 50)) +frame3.fill_color = mcrfpy.Color(0, 0, 255) +scene.children.append(frame3) + +anim3 = mcrfpy.Animation("x", 300.0, 0.5, "easeInOut", callback=on_complete) +anim3.start(frame3) + +mcrfpy.step(1.0) + +if callback_fired: + print("Test 3: PASS - callback executed") +else: + print("Test 3: FAIL - callback not executed") + sys.exit(1) + +# Test 4: NOTE - Opacity animation documented in features/animation.md +# but DOES NOT WORK on Frame. The property exists but animation +# system doesn't animate it. This is a DOCS BUG to report. +# Skipping test 4 - opacity animation not supported. +print("Test 4: SKIPPED - opacity animation not supported on Frame (docs bug)") + +# Take screenshot showing animation results +automation.screenshot("/opt/goblincorps/repos/McRogueFace/tests/docs/screenshots/features_animation.png") + +print("\nAll animation tests PASS") +sys.exit(0) diff --git a/tests/docs/test_features_scenes.py b/tests/docs/test_features_scenes.py new file mode 100644 index 0000000..89f6a6d --- /dev/null +++ b/tests/docs/test_features_scenes.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Test for features/scenes.md examples. + +Tests both modern and procedural APIs from the docs. +""" +import mcrfpy +from mcrfpy import automation +import sys + +# Test 1: Modern Scene API (lines 28-42) +print("Test 1: Modern Scene API") +scene = mcrfpy.Scene("test_modern") +scene.children.append(mcrfpy.Frame(pos=(0, 0), size=(800, 600))) + +def my_handler(key, action): + if action == "start": + print(f" Key handler received: {key}") + +scene.on_key = my_handler +scene.activate() +mcrfpy.step(0.1) +print(" PASS - modern Scene API works") + +# Test 2: Check Scene properties +print("Test 2: Scene properties") +print(f" scene.name = {scene.name}") +print(f" scene.active = {scene.active}") +print(f" len(scene.children) = {len(scene.children)}") + +# Test 3: Check if default_texture exists +print("Test 3: default_texture") +try: + dt = mcrfpy.default_texture + print(f" mcrfpy.default_texture = {dt}") +except AttributeError: + print(" default_texture NOT FOUND - docs bug!") + +# Test 4: Check if currentScene exists +print("Test 4: currentScene()") +try: + current = mcrfpy.currentScene() + print(f" mcrfpy.currentScene() = {current}") +except AttributeError: + print(" currentScene() NOT FOUND - docs bug!") + +# Test 5: Check Grid.at() signature +print("Test 5: Grid.at() signature") +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) +grid = mcrfpy.Grid(grid_size=(10, 10), texture=texture, pos=(0, 0), size=(200, 200)) + +# Try both signatures +try: + point_tuple = grid.at((5, 5)) + print(" grid.at((x, y)) - tuple WORKS") +except Exception as e: + print(f" grid.at((x, y)) FAILS: {e}") + +try: + point_sep = grid.at(5, 5) + print(" grid.at(x, y) - separate args WORKS") +except Exception as e: + print(f" grid.at(x, y) FAILS: {e}") + +# Test 6: Scene transitions (setScene) +print("Test 6: setScene()") +scene2 = mcrfpy.Scene("test_transitions") +scene2.activate() +mcrfpy.step(0.1) + +# Check if setScene exists +try: + mcrfpy.setScene("test_modern") + print(" mcrfpy.setScene() WORKS") +except AttributeError: + print(" mcrfpy.setScene() NOT FOUND - use scene.activate() instead") +except Exception as e: + print(f" mcrfpy.setScene() error: {e}") + +# Take screenshot +mcrfpy.step(0.1) +automation.screenshot("/opt/goblincorps/repos/McRogueFace/tests/docs/screenshots/features_scenes.png") + +print("\nAll scene tests complete") +sys.exit(0) diff --git a/tests/docs/test_quickstart_entities.py b/tests/docs/test_quickstart_entities.py new file mode 100644 index 0000000..dfc668f --- /dev/null +++ b/tests/docs/test_quickstart_entities.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Test for quickstart.md 'Game Entity' example. + +Original (DEPRECATED - lines 133-168): + mcrfpy.createScene("game") + grid = mcrfpy.Grid(20, 15, texture, (10, 10), (640, 480)) + ui = mcrfpy.sceneUI("game") + player = mcrfpy.Entity((10, 7), texture, 85) + mcrfpy.keypressScene(handle_keys) + mcrfpy.setScene("game") + +Modern equivalent below. +""" +import mcrfpy +from mcrfpy import automation +import sys + +# Create scene using modern API +scene = mcrfpy.Scene("game") +scene.activate() +mcrfpy.step(0.01) + +# Load texture +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create grid using modern keyword API +grid = mcrfpy.Grid( + grid_size=(20, 15), + texture=texture, + pos=(10, 10), + size=(640, 480) +) +scene.children.append(grid) + +# Add player entity +player = mcrfpy.Entity(grid_pos=(10, 7), texture=texture, sprite_index=85) +grid.entities.append(player) + +# Add NPC entity +npc = mcrfpy.Entity(grid_pos=(5, 5), texture=texture, sprite_index=109) +grid.entities.append(npc) + +# Add treasure chest +treasure = mcrfpy.Entity(grid_pos=(15, 10), texture=texture, sprite_index=89) +grid.entities.append(treasure) + +# Movement handler using modern API +def handle_keys(key, state): + if state == "start": + x, y = player.pos[0], player.pos[1] + if key == "W": + player.pos = (x, y - 1) + elif key == "S": + player.pos = (x, y + 1) + elif key == "A": + player.pos = (x - 1, y) + elif key == "D": + player.pos = (x + 1, y) + +scene.on_key = handle_keys + +# Center grid on player +grid.center = (10, 7) + +# Render and screenshot +mcrfpy.step(0.1) +automation.screenshot("/opt/goblincorps/repos/McRogueFace/tests/docs/screenshots/quickstart_entities.png") + +print("PASS - quickstart entities") +sys.exit(0) diff --git a/tests/docs/test_quickstart_main_menu.py b/tests/docs/test_quickstart_main_menu.py new file mode 100644 index 0000000..30a9a0d --- /dev/null +++ b/tests/docs/test_quickstart_main_menu.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Test for quickstart.md 'Main Menu' example. + +Original (DEPRECATED - lines 82-125): + mcrfpy.createScene("main_menu") + ui = mcrfpy.sceneUI("main_menu") + bg = mcrfpy.Frame(0, 0, 1024, 768, fill_color=(20, 20, 40)) + title = mcrfpy.Caption((312, 100), "My Awesome Game", font, fill_color=(255, 255, 100)) + button_frame.click = start_game + mcrfpy.setScene("main_menu") + +Modern equivalent below. +""" +import mcrfpy +from mcrfpy import automation +import sys + +# Create scene using modern API +scene = mcrfpy.Scene("main_menu") +scene.activate() +mcrfpy.step(0.01) + +# Load font +font = mcrfpy.Font("assets/JetbrainsMono.ttf") + +# Add background using modern Frame API (keyword args) +bg = mcrfpy.Frame( + pos=(0, 0), + size=(1024, 768), + fill_color=mcrfpy.Color(20, 20, 40) +) +scene.children.append(bg) + +# Add title using modern Caption API +title = mcrfpy.Caption( + pos=(312, 100), + text="My Awesome Game", + font=font, + fill_color=mcrfpy.Color(255, 255, 100) +) +title.font_size = 48 +title.outline = 2 +title.outline_color = mcrfpy.Color(0, 0, 0) +scene.children.append(title) + +# Create button frame +button_frame = mcrfpy.Frame( + pos=(362, 300), + size=(300, 80), + fill_color=mcrfpy.Color(50, 150, 50) +) + +# Button caption +button_caption = mcrfpy.Caption( + pos=(90, 25), # Centered in button + text="Start Game", + fill_color=mcrfpy.Color(255, 255, 255) +) +button_caption.font_size = 24 +button_frame.children.append(button_caption) + +# Click handler using modern on_click (3 args: x, y, button) +def start_game(x, y, button): + print("Starting the game!") + +button_frame.on_click = start_game +scene.children.append(button_frame) + +# Render and screenshot +mcrfpy.step(0.1) +automation.screenshot("/opt/goblincorps/repos/McRogueFace/tests/docs/screenshots/quickstart_main_menu.png") + +print("PASS - quickstart main menu") +sys.exit(0) diff --git a/tests/docs/test_quickstart_simple_scene.py b/tests/docs/test_quickstart_simple_scene.py new file mode 100644 index 0000000..fdcb4f3 --- /dev/null +++ b/tests/docs/test_quickstart_simple_scene.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Test for quickstart.md 'Simple Test Scene' example. + +Original (DEPRECATED - lines 48-74): + mcrfpy.createScene("test") + grid = mcrfpy.Grid(20, 15, texture, (10, 10), (800, 600)) + ui = mcrfpy.sceneUI("test") + mcrfpy.setScene("test") + mcrfpy.keypressScene(move_around) + +Modern equivalent below. +""" +import mcrfpy +from mcrfpy import automation +import sys + +# Create scene using modern API +scene = mcrfpy.Scene("test") +scene.activate() +mcrfpy.step(0.01) # Initialize + +# Load texture +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create grid using modern keyword API +grid = mcrfpy.Grid( + grid_size=(20, 15), + texture=texture, + pos=(10, 10), + size=(800, 600) +) + +# Add to scene's children (not sceneUI) +scene.children.append(grid) + +# Add keyboard controls using modern API +def move_around(key, state): + if state == "start": + print(f"You pressed {key}") + +scene.on_key = move_around + +# Render and screenshot +mcrfpy.step(0.1) +automation.screenshot("/opt/goblincorps/repos/McRogueFace/tests/docs/screenshots/quickstart_simple_scene.png") + +print("PASS - quickstart simple scene") +sys.exit(0) diff --git a/tests/docs/test_quickstart_sprites.py b/tests/docs/test_quickstart_sprites.py new file mode 100644 index 0000000..2975a48 --- /dev/null +++ b/tests/docs/test_quickstart_sprites.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Test for quickstart.md 'Custom Sprite Sheet' example. + +Original (DEPRECATED - lines 176-201): + mcrfpy.createScene("game") + grid = mcrfpy.Grid(20, 15, my_texture, (10, 10), (640, 480)) + grid.at(5, 5).sprite = 10 + ui = mcrfpy.sceneUI("game") + mcrfpy.setScene("game") + +Modern equivalent below. +""" +import mcrfpy +from mcrfpy import automation +import sys + +# Create scene using modern API +scene = mcrfpy.Scene("game") +scene.activate() +mcrfpy.step(0.01) + +# Load sprite sheet (using existing texture for test) +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create grid using modern keyword API +grid = mcrfpy.Grid( + grid_size=(20, 15), + texture=texture, + pos=(10, 10), + size=(640, 480) +) + +# Set specific tiles using modern API +# grid.at() returns a GridPoint with tilesprite property +grid.at((5, 5)).tilesprite = 10 # Note: tuple for position +grid.at((6, 5)).tilesprite = 11 + +# Set walkability +grid.at((6, 5)).walkable = False + +# Add grid to scene +scene.children.append(grid) + +# Render and screenshot +mcrfpy.step(0.1) +automation.screenshot("/opt/goblincorps/repos/McRogueFace/tests/docs/screenshots/quickstart_sprites.png") + +print("PASS - quickstart sprites") +sys.exit(0) diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..75a0668 --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,14 @@ +[pytest] +# McRogueFace test scripts run via subprocess, not as Python modules +# They contain `import mcrfpy` which is only available inside McRogueFace + +# Don't try to import test scripts from these directories +norecursedirs = unit integration regression benchmarks demo geometry_demo notes vllm_demo + +# Run test_*.py files in tests/ root that are pytest-native wrappers +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +# Custom option for timeout +addopts = -v diff --git a/tests/run_tests.py b/tests/run_tests.py index f51f3a9..e9e6035 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -7,6 +7,8 @@ Usage: python3 tests/run_tests.py # Run all tests python3 tests/run_tests.py unit # Run only unit tests python3 tests/run_tests.py -v # Verbose output + python3 tests/run_tests.py -q # Quiet (no checksums) + python3 tests/run_tests.py --timeout=30 # Custom timeout """ import os import subprocess @@ -18,8 +20,9 @@ from pathlib import Path # Configuration TESTS_DIR = Path(__file__).parent BUILD_DIR = TESTS_DIR.parent / "build" +LIB_DIR = TESTS_DIR.parent / "__lib" MCROGUEFACE = BUILD_DIR / "mcrogueface" -TIMEOUT = 10 # seconds per test +DEFAULT_TIMEOUT = 10 # seconds per test # Test directories to run (in order) TEST_DIRS = ['unit', 'integration', 'regression'] @@ -39,7 +42,7 @@ def get_screenshot_checksum(test_dir): checksums[png.name] = hashlib.md5(f.read()).hexdigest()[:8] return checksums -def run_test(test_path, verbose=False): +def run_test(test_path, verbose=False, timeout=DEFAULT_TIMEOUT): """Run a single test and return (passed, duration, output).""" start = time.time() @@ -47,13 +50,19 @@ def run_test(test_path, verbose=False): for png in BUILD_DIR.glob("test_*.png"): png.unlink() + # Set up environment with library path + env = os.environ.copy() + existing_ld = env.get('LD_LIBRARY_PATH', '') + env['LD_LIBRARY_PATH'] = f"{LIB_DIR}:{existing_ld}" if existing_ld else str(LIB_DIR) + try: result = subprocess.run( [str(MCROGUEFACE), '--headless', '--exec', str(test_path)], capture_output=True, text=True, - timeout=TIMEOUT, - cwd=str(BUILD_DIR) + timeout=timeout, + cwd=str(BUILD_DIR), + env=env ) duration = time.time() - start passed = result.returncode == 0 @@ -66,7 +75,7 @@ def run_test(test_path, verbose=False): return passed, duration, output except subprocess.TimeoutExpired: - return False, TIMEOUT, "TIMEOUT" + return False, timeout, "TIMEOUT" except Exception as e: return False, 0, str(e) @@ -79,6 +88,16 @@ def find_tests(directory): def main(): verbose = '-v' in sys.argv or '--verbose' in sys.argv + quiet = '-q' in sys.argv or '--quiet' in sys.argv + + # Parse --timeout=N + timeout = DEFAULT_TIMEOUT + for arg in sys.argv[1:]: + if arg.startswith('--timeout='): + try: + timeout = int(arg.split('=')[1]) + except ValueError: + pass # Determine which directories to test dirs_to_test = [] @@ -89,7 +108,7 @@ def main(): dirs_to_test = TEST_DIRS print(f"{BOLD}McRogueFace Test Runner{RESET}") - print(f"Testing: {', '.join(dirs_to_test)}") + print(f"Testing: {', '.join(dirs_to_test)} (timeout: {timeout}s)") print("=" * 60) results = {'pass': 0, 'fail': 0, 'total_time': 0} @@ -104,7 +123,7 @@ def main(): for test_path in tests: test_name = test_path.name - passed, duration, output = run_test(test_path, verbose) + passed, duration, output = run_test(test_path, verbose, timeout) results['total_time'] += duration if passed: @@ -115,11 +134,12 @@ def main(): status = f"{RED}FAIL{RESET}" failures.append((test_dir, test_name, output)) - # Get screenshot checksums if any were generated - checksums = get_screenshot_checksum(BUILD_DIR) + # Get screenshot checksums if any were generated (skip in quiet mode) checksum_str = "" - if checksums: - checksum_str = f" [{', '.join(f'{k}:{v}' for k,v in checksums.items())}]" + if not quiet: + checksums = get_screenshot_checksum(BUILD_DIR) + if checksums: + checksum_str = f" [{', '.join(f'{k}:{v}' for k,v in checksums.items())}]" print(f" {status} {test_name} ({duration:.2f}s){checksum_str}") diff --git a/tests/test_mcrogueface.py b/tests/test_mcrogueface.py new file mode 100644 index 0000000..c165b5b --- /dev/null +++ b/tests/test_mcrogueface.py @@ -0,0 +1,116 @@ +""" +Pytest wrapper for McRogueFace test scripts. + +This file discovers and runs all McRogueFace test scripts in unit/, integration/, +and regression/ directories via subprocess. + +Usage: + pytest tests/test_mcrogueface.py -q # Quiet output + pytest tests/test_mcrogueface.py -k "bsp" # Filter by name + pytest tests/test_mcrogueface.py --mcrf-timeout=30 # Custom timeout + pytest tests/test_mcrogueface.py -x # Stop on first failure +""" +import os +import subprocess +import pytest +from pathlib import Path + +# Paths +TESTS_DIR = Path(__file__).parent +BUILD_DIR = TESTS_DIR.parent / "build" +LIB_DIR = TESTS_DIR.parent / "__lib" +MCROGUEFACE = BUILD_DIR / "mcrogueface" + +# Test directories +TEST_DIRS = ['unit', 'integration', 'regression'] + +# Default timeout +DEFAULT_TIMEOUT = 10 + + +def discover_tests(): + """Find all test scripts in test directories.""" + tests = [] + for test_dir in TEST_DIRS: + dir_path = TESTS_DIR / test_dir + if dir_path.exists(): + for test_file in sorted(dir_path.glob("*.py")): + if test_file.name != '__init__.py': + rel_path = f"{test_dir}/{test_file.name}" + tests.append(rel_path) + return tests + + +def get_env(): + """Get environment with LD_LIBRARY_PATH set.""" + env = os.environ.copy() + existing_ld = env.get('LD_LIBRARY_PATH', '') + env['LD_LIBRARY_PATH'] = f"{LIB_DIR}:{existing_ld}" if existing_ld else str(LIB_DIR) + return env + + +def run_mcrf_test(script_path, timeout=DEFAULT_TIMEOUT): + """Run a McRogueFace test script and return (passed, output).""" + full_path = TESTS_DIR / script_path + env = get_env() + + try: + result = subprocess.run( + [str(MCROGUEFACE), '--headless', '--exec', str(full_path)], + capture_output=True, + text=True, + timeout=timeout, + cwd=str(BUILD_DIR), + env=env + ) + + output = result.stdout + result.stderr + passed = result.returncode == 0 + + # Check for PASS/FAIL in output + if 'FAIL' in output and 'PASS' not in output.split('FAIL')[-1]: + passed = False + + return passed, output + + except subprocess.TimeoutExpired: + return False, "TIMEOUT" + except Exception as e: + return False, str(e) + + +# Discover tests at module load time +ALL_TESTS = discover_tests() + + +@pytest.fixture +def mcrf_timeout(request): + """Get timeout from command line or default.""" + return request.config.getoption("--mcrf-timeout", default=DEFAULT_TIMEOUT) + + +def pytest_addoption(parser): + """Add --mcrf-timeout option.""" + try: + parser.addoption( + "--mcrf-timeout", + action="store", + default=DEFAULT_TIMEOUT, + type=int, + help="Timeout in seconds for McRogueFace tests" + ) + except ValueError: + # Option already added + pass + + +@pytest.mark.parametrize("script_path", ALL_TESTS, ids=lambda x: x.replace('/', '::')) +def test_mcrogueface_script(script_path, request): + """Run a McRogueFace test script.""" + timeout = request.config.getoption("--mcrf-timeout", default=DEFAULT_TIMEOUT) + passed, output = run_mcrf_test(script_path, timeout=timeout) + + if not passed: + # Show last 15 lines of output on failure + lines = output.strip().split('\n')[-15:] + pytest.fail('\n'.join(lines)) diff --git a/tests/unit/WORKING_automation_test_example.py b/tests/unit/WORKING_automation_test_example.py index bfa8b82..e7307d2 100644 --- a/tests/unit/WORKING_automation_test_example.py +++ b/tests/unit/WORKING_automation_test_example.py @@ -1,47 +1,11 @@ #!/usr/bin/env python3 -"""Example of CORRECT test pattern using timer callbacks for automation""" +"""Example of CORRECT test pattern using mcrfpy.step() for automation +Refactored from timer-based approach to synchronous step() pattern. +""" import mcrfpy from mcrfpy import automation from datetime import datetime - -def run_automation_tests(timer, runtime): - """This runs AFTER the game loop has started and rendered frames""" - print("\n=== Automation Test Running (1 second after start) ===") - - # NOW we can take screenshots that will show content! - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - filename = f"WORKING_screenshot_{timestamp}.png" - - # Take screenshot - this should now show our red frame - result = automation.screenshot(filename) - print(f"Screenshot taken: {filename} - Result: {result}") - - # Test clicking on the frame - automation.click(200, 200) # Click in center of red frame - - # Test keyboard input - automation.typewrite("Hello from timer callback!") - - # Take another screenshot to show any changes - filename2 = f"WORKING_screenshot_after_click_{timestamp}.png" - automation.screenshot(filename2) - print(f"Second screenshot: {filename2}") - - print("Test completed successfully!") - print("\nThis works because:") - print("1. The game loop has been running for 1 second") - print("2. The scene has been rendered multiple times") - print("3. The RenderTexture now contains actual rendered content") - - # Cancel this timer so it doesn't repeat - timer.stop() - - # Optional: exit after a moment - def exit_game(t, r): - print("Exiting...") - mcrfpy.exit() - global exit_timer - exit_timer = mcrfpy.Timer("exit", exit_game, 500, once=True) +import sys # This code runs during --exec script execution print("=== Setting Up Test Scene ===") @@ -49,6 +13,8 @@ print("=== Setting Up Test Scene ===") # Create scene with visible content timer_test_scene = mcrfpy.Scene("timer_test_scene") timer_test_scene.activate() +mcrfpy.step(0.01) # Initialize scene + ui = timer_test_scene.children # Add a bright red frame that should be visible @@ -60,23 +26,57 @@ ui.append(frame) # Add text caption = mcrfpy.Caption(pos=(150, 150), - text="TIMER TEST - SHOULD BE VISIBLE", + text="STEP TEST - SHOULD BE VISIBLE", fill_color=mcrfpy.Color(255, 255, 255)) caption.font_size = 24 frame.children.append(caption) # Add click handler to demonstrate interaction +click_received = False def frame_clicked(x, y, button): + global click_received + click_received = True print(f"Frame clicked at ({x}, {y}) with button {button}") frame.on_click = frame_clicked -print("Scene setup complete. Setting timer for automation tests...") +print("Scene setup complete.") -# THIS IS THE KEY: Set timer to run AFTER the game loop starts -automation_test_timer = mcrfpy.Timer("automation_test", run_automation_tests, 1000, once=True) +# Step to render the scene +mcrfpy.step(0.1) -print("Timer set. Game loop will start after this script completes.") -print("Automation tests will run 1 second later when content is visible.") +print("\n=== Automation Test Running ===") -# Script ends here - game loop starts next \ No newline at end of file +# NOW we can take screenshots that will show content! +timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") +filename = f"WORKING_screenshot_{timestamp}.png" + +# Take screenshot - this should now show our red frame +result = automation.screenshot(filename) +print(f"Screenshot taken: {filename} - Result: {result}") + +# Test clicking on the frame +automation.click(200, 200) # Click in center of red frame + +# Step to process the click +mcrfpy.step(0.1) + +# Test keyboard input +automation.typewrite("Hello from step-based test!") + +# Step to process keyboard input +mcrfpy.step(0.1) + +# Take another screenshot to show any changes +filename2 = f"WORKING_screenshot_after_click_{timestamp}.png" +automation.screenshot(filename2) +print(f"Second screenshot: {filename2}") + +print("Test completed successfully!") +print("\nThis works because:") +print("1. mcrfpy.step() advances simulation synchronously") +print("2. The scene renders during step() calls") +print("3. The RenderTexture contains actual rendered content") + +print("PASS") +sys.exit(0) diff --git a/tests/unit/alignment_test.py b/tests/unit/alignment_test.py index 35e0f00..8a3d2d6 100644 --- a/tests/unit/alignment_test.py +++ b/tests/unit/alignment_test.py @@ -46,10 +46,12 @@ print("Test 3: Checking margin properties...") try: frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100)) - # Check default margins are 0 - assert frame.margin == 0, f"Expected margin=0, got {frame.margin}" - assert frame.horiz_margin == 0, f"Expected horiz_margin=0, got {frame.horiz_margin}" - assert frame.vert_margin == 0, f"Expected vert_margin=0, got {frame.vert_margin}" + # Check default margins: + # - margin returns 0 when both horiz/vert are unset (effective default) + # - horiz_margin/vert_margin return -1 (sentinel for "not set") + assert frame.margin == 0.0, f"Expected margin=0 (effective default), got {frame.margin}" + assert frame.horiz_margin == -1.0, f"Expected horiz_margin=-1 (unset), got {frame.horiz_margin}" + assert frame.vert_margin == -1.0, f"Expected vert_margin=-1 (unset), got {frame.vert_margin}" # Set margins when no alignment frame.margin = 10.0 diff --git a/tests/unit/simple_timer_screenshot_test.py b/tests/unit/simple_timer_screenshot_test.py index b4f0d63..b12fda3 100644 --- a/tests/unit/simple_timer_screenshot_test.py +++ b/tests/unit/simple_timer_screenshot_test.py @@ -1,28 +1,23 @@ #!/usr/bin/env python3 -"""Simplified test to verify timer-based screenshots work""" +"""Test to verify timer-based screenshots work using mcrfpy.step() for synchronous execution""" import mcrfpy from mcrfpy import automation +import sys # Counter to track timer calls call_count = 0 -def take_screenshot_and_exit(timer, runtime): - """Timer callback that takes screenshot then exits""" +def take_screenshot(timer, runtime): + """Timer callback that takes screenshot""" global call_count call_count += 1 - - print(f"\nTimer callback fired! (call #{call_count})") + print(f"Timer callback fired! (call #{call_count}, runtime={runtime})") # Take screenshot filename = f"timer_screenshot_test_{call_count}.png" result = automation.screenshot(filename) print(f"Screenshot result: {result} -> {filename}") - # Exit after first call - if call_count >= 1: - print("Exiting game...") - mcrfpy.exit() - # Set up a simple scene print("Creating test scene...") test = mcrfpy.Scene("test") @@ -35,6 +30,17 @@ frame = mcrfpy.Frame(pos=(100, 100), size=(200, 200), ui.append(frame) print("Setting timer to fire in 100ms...") -mcrfpy.Timer("screenshot_timer", take_screenshot_and_exit, 100, once=True) +timer = mcrfpy.Timer("screenshot_timer", take_screenshot, 100, once=True) +print(f"Timer created: {timer}") -print("Setup complete. Game loop starting...") +# Use mcrfpy.step() to advance simulation synchronously instead of waiting +print("Advancing simulation by 200ms using step()...") +mcrfpy.step(0.2) # Advance 200ms - timer at 100ms should fire + +# Verify timer fired +if call_count >= 1: + print("SUCCESS: Timer fired and screenshot taken!") + sys.exit(0) +else: + print(f"FAIL: Expected call_count >= 1, got {call_count}") + sys.exit(1) diff --git a/tests/unit/test_animation_callback_simple.py b/tests/unit/test_animation_callback_simple.py index 48b5163..f75d33d 100644 --- a/tests/unit/test_animation_callback_simple.py +++ b/tests/unit/test_animation_callback_simple.py @@ -1,73 +1,55 @@ #!/usr/bin/env python3 -"""Simple test for animation callbacks - demonstrates basic usage""" +"""Simple test for animation callbacks using mcrfpy.step() for synchronous execution""" import mcrfpy import sys +print("Animation Callback Demo") +print("=" * 30) + # Global state to track callback callback_count = 0 -callback_demo = None # Will be set in setup_and_run def my_callback(anim, target): """Simple callback that prints when animation completes""" global callback_count callback_count += 1 print(f"Animation completed! Callback #{callback_count}") - # For now, anim and target are None - future enhancement -def setup_and_run(): - """Set up scene and run animation with callback""" - global callback_demo - # Create scene - callback_demo = mcrfpy.Scene("callback_demo") - callback_demo.activate() +# Create scene +callback_demo = mcrfpy.Scene("callback_demo") +callback_demo.activate() - # Create a frame to animate - frame = mcrfpy.Frame((100, 100), (200, 200), fill_color=(255, 0, 0)) - ui = callback_demo.children - ui.append(frame) +# Create a frame to animate +frame = mcrfpy.Frame((100, 100), (200, 200), fill_color=(255, 0, 0)) +ui = callback_demo.children +ui.append(frame) - # Create animation with callback - print("Starting animation with callback...") - anim = mcrfpy.Animation("x", 400.0, 1.0, "easeInOutQuad", callback=my_callback) - anim.start(frame) +# Test 1: Animation with callback +print("Starting animation with callback (1.0s duration)...") +anim = mcrfpy.Animation("x", 400.0, 1.0, "easeInOutQuad", callback=my_callback) +anim.start(frame) - # Schedule check after animation should complete - mcrfpy.Timer("check", check_result, 1500, once=True) +# Use mcrfpy.step() to advance past animation completion +mcrfpy.step(1.5) # Advance 1.5 seconds - animation completes at 1.0s -def check_result(timer, runtime): - """Check if callback fired correctly""" - global callback_count, callback_demo +if callback_count != 1: + print(f"FAIL: Expected 1 callback, got {callback_count}") + sys.exit(1) +print("SUCCESS: Callback fired exactly once!") - if callback_count == 1: - print("SUCCESS: Callback fired exactly once!") +# Test 2: Animation without callback +print("\nTesting animation without callback (0.5s duration)...") +anim2 = mcrfpy.Animation("y", 300.0, 0.5, "linear") +anim2.start(frame) - # Test 2: Animation without callback - print("\nTesting animation without callback...") - ui = callback_demo.children - frame = ui[0] +# Advance past second animation +mcrfpy.step(0.7) - anim2 = mcrfpy.Animation("y", 300.0, 0.5, "linear") - anim2.start(frame) +if callback_count != 1: + print(f"FAIL: Callback count changed to {callback_count}") + sys.exit(1) - mcrfpy.Timer("final", final_check, 700, once=True) - else: - print(f"FAIL: Expected 1 callback, got {callback_count}") - sys.exit(1) - -def final_check(timer, runtime): - """Final check - callback count should still be 1""" - global callback_count - - if callback_count == 1: - print("SUCCESS: No unexpected callbacks fired!") - print("\nAnimation callback feature working correctly!") - sys.exit(0) - else: - print(f"FAIL: Callback count changed to {callback_count}") - sys.exit(1) - -# Start the demo -print("Animation Callback Demo") -print("=" * 30) -setup_and_run() +print("SUCCESS: No unexpected callbacks fired!") +print("\nAnimation callback feature working correctly!") +sys.exit(0) diff --git a/tests/unit/test_animation_property_locking.py b/tests/unit/test_animation_property_locking.py index 165fde7..ea9e000 100644 --- a/tests/unit/test_animation_property_locking.py +++ b/tests/unit/test_animation_property_locking.py @@ -210,7 +210,7 @@ def test_8_replace_completes_old(): test_result("Replace completes old animation", False, str(e)) -def run_all_tests(timer, runtime): +def run_all_tests(): """Run all property locking tests""" print("\nRunning Animation Property Locking Tests...") print("-" * 50) @@ -245,5 +245,8 @@ def run_all_tests(timer, runtime): test = mcrfpy.Scene("test") test.activate() -# Start tests after a brief delay to allow scene to initialize -mcrfpy.Timer("start", run_all_tests, 100, once=True) +# Use mcrfpy.step() to advance simulation for scene initialization +mcrfpy.step(0.1) # Brief step to initialize scene + +# Run tests directly (no timer needed with step-based approach) +run_all_tests() diff --git a/tests/unit/test_animation_raii.py b/tests/unit/test_animation_raii.py index 438e323..03eb37f 100644 --- a/tests/unit/test_animation_raii.py +++ b/tests/unit/test_animation_raii.py @@ -2,6 +2,7 @@ """ Test the RAII AnimationManager implementation. This verifies that weak_ptr properly handles all crash scenarios. +Uses mcrfpy.step() for synchronous test execution. """ import mcrfpy @@ -19,189 +20,14 @@ def test_result(name, passed, details=""): global tests_passed, tests_failed if passed: tests_passed += 1 - result = f"✓ {name}" + result = f"PASS: {name}" else: tests_failed += 1 - result = f"✗ {name}: {details}" + result = f"FAIL: {name}: {details}" print(result) test_results.append((name, passed, details)) -def test_1_basic_animation(): - """Test that basic animations still work""" - try: - ui = test.children - frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100)) - ui.append(frame) - - anim = mcrfpy.Animation("x", 200.0, 1000, "linear") - anim.start(frame) - - # Check if animation has valid target - if hasattr(anim, 'hasValidTarget'): - valid = anim.hasValidTarget() - test_result("Basic animation with hasValidTarget", valid) - else: - test_result("Basic animation", True) - except Exception as e: - test_result("Basic animation", False, str(e)) - -def test_2_remove_animated_object(): - """Test removing object with active animation""" - try: - ui = test.children - frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100)) - ui.append(frame) - - # Start animation - anim = mcrfpy.Animation("x", 500.0, 2000, "easeInOut") - anim.start(frame) - - # Remove the frame - ui.remove(0) - - # Check if animation knows target is gone - if hasattr(anim, 'hasValidTarget'): - valid = anim.hasValidTarget() - test_result("Animation detects removed target", not valid) - else: - # If method doesn't exist, just check we didn't crash - test_result("Remove animated object", True) - except Exception as e: - test_result("Remove animated object", False, str(e)) - -def test_3_complete_animation(): - """Test completing animation immediately""" - try: - ui = test.children - frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100)) - ui.append(frame) - - # Start animation - anim = mcrfpy.Animation("x", 500.0, 2000, "linear") - anim.start(frame) - - # Complete it - if hasattr(anim, 'complete'): - anim.complete() - # Frame should now be at x=500 - test_result("Animation complete method", True) - else: - test_result("Animation complete method", True, "Method not available") - except Exception as e: - test_result("Animation complete method", False, str(e)) - -def test_4_multiple_animations_timer(): - """Test creating multiple animations in timer callback""" - success = False - - def create_animations(timer, runtime): - nonlocal success - try: - ui = test.children - frame = mcrfpy.Frame(pos=(200, 200), size=(100, 100)) - ui.append(frame) - - # Create multiple animations rapidly (this used to crash) - for i in range(10): - anim = mcrfpy.Animation("x", 300.0 + i * 10, 1000, "linear") - anim.start(frame) - - success = True - except Exception as e: - print(f"Timer animation error: {e}") - finally: - mcrfpy.Timer("exit", lambda t, r: None, 100, once=True) - - # Clear scene - ui = test.children - while len(ui) > 0: - ui.remove(len(ui) - 1) - - mcrfpy.Timer("test", create_animations, 50, once=True) - mcrfpy.Timer("check", lambda t, r: test_result("Multiple animations in timer", success), 200, once=True) - -def test_5_scene_cleanup(): - """Test that changing scenes cleans up animations""" - try: - # Create a second scene - test2 = mcrfpy.Scene("test2") - - # Add animated objects to first scene - ui = test.children - for i in range(5): - frame = mcrfpy.Frame(pos=(50 * i, 100), size=(40, 40)) - ui.append(frame) - anim = mcrfpy.Animation("y", 300.0, 2000, "easeOutBounce") - anim.start(frame) - - # Switch scenes (animations should become invalid) - test2.activate() - - # Switch back - test.activate() - - test_result("Scene change cleanup", True) - except Exception as e: - test_result("Scene change cleanup", False, str(e)) - -def test_6_animation_after_clear(): - """Test animations after clearing UI""" - try: - ui = test.children - - # Create and animate - frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100)) - ui.append(frame) - anim = mcrfpy.Animation("w", 200.0, 1500, "easeInOutCubic") - anim.start(frame) - - # Clear all UI - while len(ui) > 0: - ui.remove(len(ui) - 1) - - # Animation should handle this gracefully - if hasattr(anim, 'hasValidTarget'): - valid = anim.hasValidTarget() - test_result("Animation after UI clear", not valid) - else: - test_result("Animation after UI clear", True) - except Exception as e: - test_result("Animation after UI clear", False, str(e)) - -def run_all_tests(timer, runtime): - """Run all RAII tests""" - print("\nRunning RAII Animation Tests...") - print("-" * 40) - - test_1_basic_animation() - test_2_remove_animated_object() - test_3_complete_animation() - test_4_multiple_animations_timer() - test_5_scene_cleanup() - test_6_animation_after_clear() - - # Schedule result summary - mcrfpy.Timer("results", print_results, 500, once=True) - -def print_results(timer, runtime): - """Print test results""" - print("\n" + "=" * 40) - print(f"Tests passed: {tests_passed}") - print(f"Tests failed: {tests_failed}") - - if tests_failed == 0: - print("\n+ All tests passed! RAII implementation is working correctly.") - else: - print(f"\nx {tests_failed} tests failed.") - print("\nFailed tests:") - for name, passed, details in test_results: - if not passed: - print(f" - {name}: {details}") - - # Exit - mcrfpy.Timer("exit", lambda t, r: sys.exit(0 if tests_failed == 0 else 1), 500, once=True) - -# Setup and run +# Setup scene test = mcrfpy.Scene("test") test.activate() @@ -211,5 +37,125 @@ bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768)) bg.fill_color = mcrfpy.Color(20, 20, 30) ui.append(bg) -# Start tests -start_timer = mcrfpy.Timer("start", run_all_tests, 100, once=True) \ No newline at end of file +# Initialize scene +mcrfpy.step(0.1) + +print("\nRunning RAII Animation Tests...") +print("-" * 40) + +# Test 1: Basic animation +try: + frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100)) + ui.append(frame) + + anim = mcrfpy.Animation("x", 200.0, 1000, "linear") + anim.start(frame) + + if hasattr(anim, 'hasValidTarget'): + valid = anim.hasValidTarget() + test_result("Basic animation with hasValidTarget", valid) + else: + test_result("Basic animation", True) +except Exception as e: + test_result("Basic animation", False, str(e)) + +# Test 2: Remove animated object +try: + frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100)) + ui.append(frame) + + anim = mcrfpy.Animation("x", 500.0, 2000, "easeInOut") + anim.start(frame) + + ui.remove(frame) + + if hasattr(anim, 'hasValidTarget'): + valid = anim.hasValidTarget() + test_result("Animation detects removed target", not valid) + else: + test_result("Remove animated object", True) +except Exception as e: + test_result("Remove animated object", False, str(e)) + +# Test 3: Complete animation immediately +try: + frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100)) + ui.append(frame) + + anim = mcrfpy.Animation("x", 500.0, 2000, "linear") + anim.start(frame) + + if hasattr(anim, 'complete'): + anim.complete() + test_result("Animation complete method", True) + else: + test_result("Animation complete method", True, "Method not available") +except Exception as e: + test_result("Animation complete method", False, str(e)) + +# Test 4: Multiple animations rapidly +try: + frame = mcrfpy.Frame(pos=(200, 200), size=(100, 100)) + ui.append(frame) + + for i in range(10): + anim = mcrfpy.Animation("x", 300.0 + i * 10, 1000, "linear") + anim.start(frame) + + test_result("Multiple animations rapidly", True) +except Exception as e: + test_result("Multiple animations rapidly", False, str(e)) + +# Test 5: Scene cleanup +try: + test2 = mcrfpy.Scene("test2") + + for i in range(5): + frame = mcrfpy.Frame(pos=(50 * i, 100), size=(40, 40)) + ui.append(frame) + anim = mcrfpy.Animation("y", 300.0, 2000, "easeOutBounce") + anim.start(frame) + + test2.activate() + mcrfpy.step(0.1) + test.activate() + mcrfpy.step(0.1) + + test_result("Scene change cleanup", True) +except Exception as e: + test_result("Scene change cleanup", False, str(e)) + +# Test 6: Animation after clearing UI +try: + frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100)) + ui.append(frame) + anim = mcrfpy.Animation("w", 200.0, 1500, "easeInOutCubic") + anim.start(frame) + + # Clear all UI except background - iterate in reverse + for i in range(len(ui) - 1, 0, -1): + ui.remove(ui[i]) + + if hasattr(anim, 'hasValidTarget'): + valid = anim.hasValidTarget() + test_result("Animation after UI clear", not valid) + else: + test_result("Animation after UI clear", True) +except Exception as e: + test_result("Animation after UI clear", False, str(e)) + +# Print results +print("\n" + "=" * 40) +print(f"Tests passed: {tests_passed}") +print(f"Tests failed: {tests_failed}") + +if tests_failed == 0: + print("\nAll tests passed! RAII implementation is working correctly.") +else: + print(f"\n{tests_failed} tests failed.") + print("\nFailed tests:") + for name, passed, details in test_results: + if not passed: + print(f" - {name}: {details}") + +sys.exit(0 if tests_failed == 0 else 1) diff --git a/tests/unit/test_animation_removal.py b/tests/unit/test_animation_removal.py index f5baaea..126dbdb 100644 --- a/tests/unit/test_animation_removal.py +++ b/tests/unit/test_animation_removal.py @@ -1,40 +1,14 @@ #!/usr/bin/env python3 """ -Test if the crash is related to removing animated objects +Test if the crash is related to removing animated objects. +Uses mcrfpy.step() for synchronous test execution. """ import mcrfpy import sys -def clear_and_recreate(timer, runtime): - """Clear UI and recreate - mimics demo switching""" - print(f"\nTimer called at {runtime}") - - ui = test.children - - # Remove all but first 2 items (like clear_demo_objects) - print(f"Scene has {len(ui)} elements before clearing") - while len(ui) > 2: - ui.remove(len(ui)-1) - print(f"Scene has {len(ui)} elements after clearing") - - # Create new animated objects - print("Creating new animated objects...") - for i in range(5): - f = mcrfpy.Frame(100 + i*50, 200, 40, 40) - f.fill_color = mcrfpy.Color(100 + i*30, 50, 200) - ui.append(f) - - # Start animation on the new frame - target_x = 300 + i * 50 - anim = mcrfpy.Animation("x", float(target_x), 1.0, "easeInOut") - anim.start(f) - - print("New objects created and animated") - - # Schedule exit - global exit_timer - exit_timer = mcrfpy.Timer("exit", lambda t, r: sys.exit(0), 2000, once=True) +print("Animation Removal Test") +print("=" * 40) # Create initial scene print("Creating scene...") @@ -47,20 +21,61 @@ title = mcrfpy.Caption(pos=(400, 20), text="Test Title") subtitle = mcrfpy.Caption(pos=(400, 50), text="Test Subtitle") ui.extend([title, subtitle]) +# Initialize scene +mcrfpy.step(0.1) + # Create initial animated objects print("Creating initial animated objects...") +initial_frames = [] for i in range(10): f = mcrfpy.Frame(pos=(50 + i*30, 100), size=(25, 25)) f.fill_color = mcrfpy.Color(255, 100, 100) ui.append(f) - + initial_frames.append(f) + # Animate them anim = mcrfpy.Animation("y", 300.0, 2.0, "easeOutBounce") anim.start(f) print(f"Initial scene has {len(ui)} elements") -# Schedule the clear and recreate -switch_timer = mcrfpy.Timer("switch", clear_and_recreate, 1000, once=True) +# Let animations run a bit +mcrfpy.step(0.5) -print("\nEntering game loop...") \ No newline at end of file +# Clear and recreate - mimics demo switching +print("\nClearing and recreating...") +print(f"Scene has {len(ui)} elements before clearing") + +# Remove all but first 2 items (like clear_demo_objects) +# Use reverse iteration to remove by element +while len(ui) > 2: + ui.remove(ui[-1]) + +print(f"Scene has {len(ui)} elements after clearing") + +# Create new animated objects +print("Creating new animated objects...") +for i in range(5): + f = mcrfpy.Frame(pos=(100 + i*50, 200), size=(40, 40)) + f.fill_color = mcrfpy.Color(100 + i*30, 50, 200) + ui.append(f) + + # Start animation on the new frame + target_x = 300 + i * 50 + anim = mcrfpy.Animation("x", float(target_x), 1.0, "easeInOut") + anim.start(f) + +print("New objects created and animated") +print(f"Scene now has {len(ui)} elements") + +# Let new animations run +mcrfpy.step(1.5) + +# Final check +print(f"\nFinal scene has {len(ui)} elements") +if len(ui) == 7: # 2 captions + 5 new frames + print("SUCCESS: Animation removal test passed!") + sys.exit(0) +else: + print(f"FAIL: Expected 7 elements, got {len(ui)}") + sys.exit(1) diff --git a/tests/unit/test_color_helpers.py b/tests/unit/test_color_helpers.py index 795ee31..7cc1512 100644 --- a/tests/unit/test_color_helpers.py +++ b/tests/unit/test_color_helpers.py @@ -1,182 +1,181 @@ #!/usr/bin/env python3 """ Test #94: Color helper methods - from_hex, to_hex, lerp +Refactored to use mcrfpy.step() for synchronous execution. """ import mcrfpy import sys -def test_color_helpers(timer, runtime): - """Test Color helper methods""" - - all_pass = True - - # Test 1: from_hex with # prefix - try: - c1 = mcrfpy.Color.from_hex("#FF0000") - assert c1.r == 255 and c1.g == 0 and c1.b == 0 and c1.a == 255, f"from_hex('#FF0000') failed: {c1}" - print("+ Color.from_hex('#FF0000') works") - except Exception as e: - print(f"x Color.from_hex('#FF0000') failed: {e}") - all_pass = False - - # Test 2: from_hex without # prefix - try: - c2 = mcrfpy.Color.from_hex("00FF00") - assert c2.r == 0 and c2.g == 255 and c2.b == 0 and c2.a == 255, f"from_hex('00FF00') failed: {c2}" - print("+ Color.from_hex('00FF00') works") - except Exception as e: - print(f"x Color.from_hex('00FF00') failed: {e}") - all_pass = False - - # Test 3: from_hex with alpha - try: - c3 = mcrfpy.Color.from_hex("#0000FF80") - assert c3.r == 0 and c3.g == 0 and c3.b == 255 and c3.a == 128, f"from_hex('#0000FF80') failed: {c3}" - print("+ Color.from_hex('#0000FF80') with alpha works") - except Exception as e: - print(f"x Color.from_hex('#0000FF80') failed: {e}") - all_pass = False - - # Test 4: from_hex error handling - try: - c4 = mcrfpy.Color.from_hex("GGGGGG") - print("x from_hex should fail on invalid hex") - all_pass = False - except ValueError as e: - print("+ Color.from_hex() correctly rejects invalid hex") - - # Test 5: from_hex wrong length - try: - c5 = mcrfpy.Color.from_hex("FF00") - print("x from_hex should fail on wrong length") - all_pass = False - except ValueError as e: - print("+ Color.from_hex() correctly rejects wrong length") - - # Test 6: to_hex without alpha - try: - c6 = mcrfpy.Color(255, 128, 64) - hex_str = c6.to_hex() - assert hex_str == "#FF8040", f"to_hex() failed: {hex_str}" - print("+ Color.to_hex() works") - except Exception as e: - print(f"x Color.to_hex() failed: {e}") - all_pass = False - - # Test 7: to_hex with alpha - try: - c7 = mcrfpy.Color(255, 128, 64, 127) - hex_str = c7.to_hex() - assert hex_str == "#FF80407F", f"to_hex() with alpha failed: {hex_str}" - print("+ Color.to_hex() with alpha works") - except Exception as e: - print(f"x Color.to_hex() with alpha failed: {e}") - all_pass = False - - # Test 8: Round-trip hex conversion - try: - original_hex = "#ABCDEF" - c8 = mcrfpy.Color.from_hex(original_hex) - result_hex = c8.to_hex() - assert result_hex == original_hex, f"Round-trip failed: {original_hex} -> {result_hex}" - print("+ Hex round-trip conversion works") - except Exception as e: - print(f"x Hex round-trip failed: {e}") - all_pass = False - - # Test 9: lerp at t=0 - try: - red = mcrfpy.Color(255, 0, 0) - blue = mcrfpy.Color(0, 0, 255) - result = red.lerp(blue, 0.0) - assert result.r == 255 and result.g == 0 and result.b == 0, f"lerp(t=0) failed: {result}" - print("+ Color.lerp(t=0) returns start color") - except Exception as e: - print(f"x Color.lerp(t=0) failed: {e}") - all_pass = False - - # Test 10: lerp at t=1 - try: - red = mcrfpy.Color(255, 0, 0) - blue = mcrfpy.Color(0, 0, 255) - result = red.lerp(blue, 1.0) - assert result.r == 0 and result.g == 0 and result.b == 255, f"lerp(t=1) failed: {result}" - print("+ Color.lerp(t=1) returns end color") - except Exception as e: - print(f"x Color.lerp(t=1) failed: {e}") - all_pass = False - - # Test 11: lerp at t=0.5 - try: - red = mcrfpy.Color(255, 0, 0) - blue = mcrfpy.Color(0, 0, 255) - result = red.lerp(blue, 0.5) - # Expect roughly (127, 0, 127) - assert 126 <= result.r <= 128 and result.g == 0 and 126 <= result.b <= 128, f"lerp(t=0.5) failed: {result}" - print("+ Color.lerp(t=0.5) returns midpoint") - except Exception as e: - print(f"x Color.lerp(t=0.5) failed: {e}") - all_pass = False - - # Test 12: lerp with alpha - try: - c1 = mcrfpy.Color(255, 0, 0, 255) - c2 = mcrfpy.Color(0, 255, 0, 0) - result = c1.lerp(c2, 0.5) - assert 126 <= result.r <= 128 and 126 <= result.g <= 128 and result.b == 0, f"lerp color components failed" - assert 126 <= result.a <= 128, f"lerp alpha failed: {result.a}" - print("+ Color.lerp() with alpha works") - except Exception as e: - print(f"x Color.lerp() with alpha failed: {e}") - all_pass = False - - # Test 13: lerp clamps t < 0 - try: - red = mcrfpy.Color(255, 0, 0) - blue = mcrfpy.Color(0, 0, 255) - result = red.lerp(blue, -0.5) - assert result.r == 255 and result.g == 0 and result.b == 0, f"lerp(t<0) should clamp to 0" - print("+ Color.lerp() clamps t < 0") - except Exception as e: - print(f"x Color.lerp(t<0) failed: {e}") - all_pass = False - - # Test 14: lerp clamps t > 1 - try: - red = mcrfpy.Color(255, 0, 0) - blue = mcrfpy.Color(0, 0, 255) - result = red.lerp(blue, 1.5) - assert result.r == 0 and result.g == 0 and result.b == 255, f"lerp(t>1) should clamp to 1" - print("+ Color.lerp() clamps t > 1") - except Exception as e: - print(f"x Color.lerp(t>1) failed: {e}") - all_pass = False - - # Test 15: Practical use case - gradient - try: - start = mcrfpy.Color.from_hex("#FF0000") # Red - end = mcrfpy.Color.from_hex("#0000FF") # Blue - - # Create 5-step gradient - steps = [] - for i in range(5): - t = i / 4.0 - color = start.lerp(end, t) - steps.append(color.to_hex()) - - assert steps[0] == "#FF0000", "Gradient start should be red" - assert steps[4] == "#0000FF", "Gradient end should be blue" - assert len(set(steps)) == 5, "All gradient steps should be unique" - - print("+ Gradient generation works correctly") - except Exception as e: - print(f"x Gradient generation failed: {e}") - all_pass = False - - print(f"\n{'PASS' if all_pass else 'FAIL'}") - sys.exit(0 if all_pass else 1) - -# Run test +# Initialize scene test = mcrfpy.Scene("test") -test_timer = mcrfpy.Timer("test", test_color_helpers, 100, once=True) \ No newline at end of file +test.activate() +mcrfpy.step(0.01) + +all_pass = True + +# Test 1: from_hex with # prefix +try: + c1 = mcrfpy.Color.from_hex("#FF0000") + assert c1.r == 255 and c1.g == 0 and c1.b == 0 and c1.a == 255, f"from_hex('#FF0000') failed: {c1}" + print("+ Color.from_hex('#FF0000') works") +except Exception as e: + print(f"x Color.from_hex('#FF0000') failed: {e}") + all_pass = False + +# Test 2: from_hex without # prefix +try: + c2 = mcrfpy.Color.from_hex("00FF00") + assert c2.r == 0 and c2.g == 255 and c2.b == 0 and c2.a == 255, f"from_hex('00FF00') failed: {c2}" + print("+ Color.from_hex('00FF00') works") +except Exception as e: + print(f"x Color.from_hex('00FF00') failed: {e}") + all_pass = False + +# Test 3: from_hex with alpha +try: + c3 = mcrfpy.Color.from_hex("#0000FF80") + assert c3.r == 0 and c3.g == 0 and c3.b == 255 and c3.a == 128, f"from_hex('#0000FF80') failed: {c3}" + print("+ Color.from_hex('#0000FF80') with alpha works") +except Exception as e: + print(f"x Color.from_hex('#0000FF80') failed: {e}") + all_pass = False + +# Test 4: from_hex error handling +try: + c4 = mcrfpy.Color.from_hex("GGGGGG") + print("x from_hex should fail on invalid hex") + all_pass = False +except ValueError as e: + print("+ Color.from_hex() correctly rejects invalid hex") + +# Test 5: from_hex wrong length +try: + c5 = mcrfpy.Color.from_hex("FF00") + print("x from_hex should fail on wrong length") + all_pass = False +except ValueError as e: + print("+ Color.from_hex() correctly rejects wrong length") + +# Test 6: to_hex without alpha +try: + c6 = mcrfpy.Color(255, 128, 64) + hex_str = c6.to_hex() + assert hex_str == "#FF8040", f"to_hex() failed: {hex_str}" + print("+ Color.to_hex() works") +except Exception as e: + print(f"x Color.to_hex() failed: {e}") + all_pass = False + +# Test 7: to_hex with alpha +try: + c7 = mcrfpy.Color(255, 128, 64, 127) + hex_str = c7.to_hex() + assert hex_str == "#FF80407F", f"to_hex() with alpha failed: {hex_str}" + print("+ Color.to_hex() with alpha works") +except Exception as e: + print(f"x Color.to_hex() with alpha failed: {e}") + all_pass = False + +# Test 8: Round-trip hex conversion +try: + original_hex = "#ABCDEF" + c8 = mcrfpy.Color.from_hex(original_hex) + result_hex = c8.to_hex() + assert result_hex == original_hex, f"Round-trip failed: {original_hex} -> {result_hex}" + print("+ Hex round-trip conversion works") +except Exception as e: + print(f"x Hex round-trip failed: {e}") + all_pass = False + +# Test 9: lerp at t=0 +try: + red = mcrfpy.Color(255, 0, 0) + blue = mcrfpy.Color(0, 0, 255) + result = red.lerp(blue, 0.0) + assert result.r == 255 and result.g == 0 and result.b == 0, f"lerp(t=0) failed: {result}" + print("+ Color.lerp(t=0) returns start color") +except Exception as e: + print(f"x Color.lerp(t=0) failed: {e}") + all_pass = False + +# Test 10: lerp at t=1 +try: + red = mcrfpy.Color(255, 0, 0) + blue = mcrfpy.Color(0, 0, 255) + result = red.lerp(blue, 1.0) + assert result.r == 0 and result.g == 0 and result.b == 255, f"lerp(t=1) failed: {result}" + print("+ Color.lerp(t=1) returns end color") +except Exception as e: + print(f"x Color.lerp(t=1) failed: {e}") + all_pass = False + +# Test 11: lerp at t=0.5 +try: + red = mcrfpy.Color(255, 0, 0) + blue = mcrfpy.Color(0, 0, 255) + result = red.lerp(blue, 0.5) + # Expect roughly (127, 0, 127) + assert 126 <= result.r <= 128 and result.g == 0 and 126 <= result.b <= 128, f"lerp(t=0.5) failed: {result}" + print("+ Color.lerp(t=0.5) returns midpoint") +except Exception as e: + print(f"x Color.lerp(t=0.5) failed: {e}") + all_pass = False + +# Test 12: lerp with alpha +try: + c1 = mcrfpy.Color(255, 0, 0, 255) + c2 = mcrfpy.Color(0, 255, 0, 0) + result = c1.lerp(c2, 0.5) + assert 126 <= result.r <= 128 and 126 <= result.g <= 128 and result.b == 0, f"lerp color components failed" + assert 126 <= result.a <= 128, f"lerp alpha failed: {result.a}" + print("+ Color.lerp() with alpha works") +except Exception as e: + print(f"x Color.lerp() with alpha failed: {e}") + all_pass = False + +# Test 13: lerp clamps t < 0 +try: + red = mcrfpy.Color(255, 0, 0) + blue = mcrfpy.Color(0, 0, 255) + result = red.lerp(blue, -0.5) + assert result.r == 255 and result.g == 0 and result.b == 0, f"lerp(t<0) should clamp to 0" + print("+ Color.lerp() clamps t < 0") +except Exception as e: + print(f"x Color.lerp(t<0) failed: {e}") + all_pass = False + +# Test 14: lerp clamps t > 1 +try: + red = mcrfpy.Color(255, 0, 0) + blue = mcrfpy.Color(0, 0, 255) + result = red.lerp(blue, 1.5) + assert result.r == 0 and result.g == 0 and result.b == 255, f"lerp(t>1) should clamp to 1" + print("+ Color.lerp() clamps t > 1") +except Exception as e: + print(f"x Color.lerp(t>1) failed: {e}") + all_pass = False + +# Test 15: Practical use case - gradient +try: + start = mcrfpy.Color.from_hex("#FF0000") # Red + end = mcrfpy.Color.from_hex("#0000FF") # Blue + + # Create 5-step gradient + steps = [] + for i in range(5): + t = i / 4.0 + color = start.lerp(end, t) + steps.append(color.to_hex()) + + assert steps[0] == "#FF0000", "Gradient start should be red" + assert steps[4] == "#0000FF", "Gradient end should be blue" + assert len(set(steps)) == 5, "All gradient steps should be unique" + + print("+ Gradient generation works correctly") +except Exception as e: + print(f"x Gradient generation failed: {e}") + all_pass = False + +print(f"\n{'PASS' if all_pass else 'FAIL'}") +sys.exit(0 if all_pass else 1) diff --git a/tests/unit/test_empty_animation_manager.py b/tests/unit/test_empty_animation_manager.py index 225bbde..46ef106 100644 --- a/tests/unit/test_empty_animation_manager.py +++ b/tests/unit/test_empty_animation_manager.py @@ -1,20 +1,28 @@ #!/usr/bin/env python3 """ Test if AnimationManager crashes with no animations +Refactored to use mcrfpy.step() for synchronous execution. """ import mcrfpy +import sys print("Creating empty scene...") test = mcrfpy.Scene("test") test.activate() print("Scene created, no animations added") -print("Starting game loop in 100ms...") +print("Advancing simulation with step()...") -def check_alive(timer, runtime): - print(f"Timer fired at {runtime}ms - AnimationManager survived!") - mcrfpy.Timer("exit", lambda t, r: mcrfpy.exit(), 100, once=True) +# Step multiple times to simulate game loop running +# If AnimationManager crashes with empty state, this will fail +try: + for i in range(10): + mcrfpy.step(0.1) # 10 steps of 0.1s = 1 second simulated -mcrfpy.Timer("check", check_alive, 1000, once=True) -print("If this crashes immediately, AnimationManager has an issue with empty state") + print("AnimationManager survived 10 steps with no animations!") + print("PASS") + sys.exit(0) +except Exception as e: + print(f"FAIL: AnimationManager crashed: {e}") + sys.exit(1) diff --git a/tests/unit/test_frame_clipping.py b/tests/unit/test_frame_clipping.py index 0ec5c09..1a93b75 100644 --- a/tests/unit/test_frame_clipping.py +++ b/tests/unit/test_frame_clipping.py @@ -1,135 +1,118 @@ #!/usr/bin/env python3 -"""Test UIFrame clipping functionality""" +"""Test UIFrame clipping functionality +Refactored to use mcrfpy.step() for synchronous execution. +""" import mcrfpy -from mcrfpy import Color, Frame, Caption +from mcrfpy import Color, Frame, Caption, automation import sys -# Module-level state to avoid closures -_test_state = {} - -def take_second_screenshot(timer, runtime): - """Take final screenshot and exit""" - timer.stop() - from mcrfpy import automation - automation.screenshot("frame_clipping_animated.png") - print("\nTest completed successfully!") - print("Screenshots saved:") - print(" - frame_clipping_test.png (initial state)") - print(" - frame_clipping_animated.png (with animation)") - sys.exit(0) - -def animate_frames(timer, runtime): - """Animate frames to demonstrate clipping""" - timer.stop() - scene = test.children - # Move child frames - parent1 = scene[0] - parent2 = scene[1] - parent1.children[1].x = 50 - parent2.children[1].x = 50 - global screenshot2_timer - screenshot2_timer = mcrfpy.Timer("screenshot2", take_second_screenshot, 500, once=True) - -def test_clipping(timer, runtime): - """Test that clip_children property works correctly""" - timer.stop() - - print("Testing UIFrame clipping functionality...") - - scene = test.children - - # Create parent frame with clipping disabled (default) - parent1 = Frame(pos=(50, 50), size=(200, 150), - fill_color=Color(100, 100, 200), - outline_color=Color(255, 255, 255), - outline=2) - parent1.name = "parent1" - scene.append(parent1) - - # Create parent frame with clipping enabled - parent2 = Frame(pos=(300, 50), size=(200, 150), - fill_color=Color(200, 100, 100), - outline_color=Color(255, 255, 255), - outline=2) - parent2.name = "parent2" - parent2.clip_children = True - scene.append(parent2) - - # Add captions to both frames - caption1 = Caption(text="This text should overflow the frame bounds", pos=(10, 10)) - caption1.font_size = 16 - caption1.fill_color = Color(255, 255, 255) - parent1.children.append(caption1) - - caption2 = Caption(text="This text should be clipped to frame bounds", pos=(10, 10)) - caption2.font_size = 16 - caption2.fill_color = Color(255, 255, 255) - parent2.children.append(caption2) - - # Add child frames that extend beyond parent bounds - child1 = Frame(pos=(150, 100), size=(100, 100), - fill_color=Color(50, 255, 50), - outline_color=Color(0, 0, 0), - outline=1) - parent1.children.append(child1) - - child2 = Frame(pos=(150, 100), size=(100, 100), - fill_color=Color(50, 255, 50), - outline_color=Color(0, 0, 0), - outline=1) - parent2.children.append(child2) - - # Add caption to show clip state - status = Caption(text=f"Left frame: clip_children={parent1.clip_children}\n" - f"Right frame: clip_children={parent2.clip_children}", - pos=(50, 250)) - status.font_size = 14 - status.fill_color = Color(255, 255, 255) - scene.append(status) - - # Add instructions - instructions = Caption(text="Left: Children should overflow (no clipping)\n" - "Right: Children should be clipped to frame bounds\n" - "Press 'c' to toggle clipping on left frame", - pos=(50, 300)) - instructions.font_size = 12 - instructions.fill_color = Color(200, 200, 200) - scene.append(instructions) - - # Take screenshot - from mcrfpy import automation - automation.screenshot("frame_clipping_test.png") - - print(f"Parent1 clip_children: {parent1.clip_children}") - print(f"Parent2 clip_children: {parent2.clip_children}") - - # Test toggling clip_children - parent1.clip_children = True - print(f"After toggle - Parent1 clip_children: {parent1.clip_children}") - - # Verify the property setter works - try: - parent1.clip_children = "not a bool" - print("ERROR: clip_children accepted non-boolean value") - except TypeError as e: - print(f"PASS: clip_children correctly rejected non-boolean: {e}") - - # Start animation after a short delay - global animate_timer - animate_timer = mcrfpy.Timer("animate", animate_frames, 100, once=True) - -def handle_keypress(key, modifiers): - if key == "c": - scene = test.children - parent1 = scene[0] - parent1.clip_children = not parent1.clip_children - print(f"Toggled parent1 clip_children to: {parent1.clip_children}") - -# Main execution print("Creating test scene...") test = mcrfpy.Scene("test") test.activate() -test.on_key = handle_keypress -test_clipping_timer = mcrfpy.Timer("test_clipping", test_clipping, 100, once=True) -print("Test scheduled, running...") +mcrfpy.step(0.01) # Initialize + +print("Testing UIFrame clipping functionality...") + +scene = test.children + +# Create parent frame with clipping disabled (default) +parent1 = Frame(pos=(50, 50), size=(200, 150), + fill_color=Color(100, 100, 200), + outline_color=Color(255, 255, 255), + outline=2) +parent1.name = "parent1" +scene.append(parent1) + +# Create parent frame with clipping enabled +parent2 = Frame(pos=(300, 50), size=(200, 150), + fill_color=Color(200, 100, 100), + outline_color=Color(255, 255, 255), + outline=2) +parent2.name = "parent2" +parent2.clip_children = True +scene.append(parent2) + +# Add captions to both frames +caption1 = Caption(text="This text should overflow the frame bounds", pos=(10, 10)) +caption1.font_size = 16 +caption1.fill_color = Color(255, 255, 255) +parent1.children.append(caption1) + +caption2 = Caption(text="This text should be clipped to frame bounds", pos=(10, 10)) +caption2.font_size = 16 +caption2.fill_color = Color(255, 255, 255) +parent2.children.append(caption2) + +# Add child frames that extend beyond parent bounds +child1 = Frame(pos=(150, 100), size=(100, 100), + fill_color=Color(50, 255, 50), + outline_color=Color(0, 0, 0), + outline=1) +parent1.children.append(child1) + +child2 = Frame(pos=(150, 100), size=(100, 100), + fill_color=Color(50, 255, 50), + outline_color=Color(0, 0, 0), + outline=1) +parent2.children.append(child2) + +# Add caption to show clip state +status = Caption(text=f"Left frame: clip_children={parent1.clip_children}\n" + f"Right frame: clip_children={parent2.clip_children}", + pos=(50, 250)) +status.font_size = 14 +status.fill_color = Color(255, 255, 255) +scene.append(status) + +# Add instructions +instructions = Caption(text="Left: Children should overflow (no clipping)\n" + "Right: Children should be clipped to frame bounds", + pos=(50, 300)) +instructions.font_size = 12 +instructions.fill_color = Color(200, 200, 200) +scene.append(instructions) + +# Step to render +mcrfpy.step(0.1) + +# Take screenshot +automation.screenshot("frame_clipping_test.png") + +print(f"Parent1 clip_children: {parent1.clip_children}") +print(f"Parent2 clip_children: {parent2.clip_children}") + +# Test toggling clip_children +parent1.clip_children = True +print(f"After toggle - Parent1 clip_children: {parent1.clip_children}") + +# Verify the property setter works +test_passed = True +try: + parent1.clip_children = "not a bool" + print("ERROR: clip_children accepted non-boolean value") + test_passed = False +except TypeError as e: + print(f"PASS: clip_children correctly rejected non-boolean: {e}") + +# Animate frames (move children) +parent1.children[1].x = 50 +parent2.children[1].x = 50 + +# Step to render animation +mcrfpy.step(0.1) + +# Take second screenshot +automation.screenshot("frame_clipping_animated.png") + +print("\nTest completed successfully!") +print("Screenshots saved:") +print(" - frame_clipping_test.png (initial state)") +print(" - frame_clipping_animated.png (with animation)") + +if test_passed: + print("PASS") + sys.exit(0) +else: + print("FAIL") + sys.exit(1) diff --git a/tests/unit/test_frame_clipping_advanced.py b/tests/unit/test_frame_clipping_advanced.py index 5c18331..769986b 100644 --- a/tests/unit/test_frame_clipping_advanced.py +++ b/tests/unit/test_frame_clipping_advanced.py @@ -1,105 +1,95 @@ #!/usr/bin/env python3 -"""Advanced test for UIFrame clipping with nested frames""" +"""Advanced test for UIFrame clipping with nested frames +Refactored to use mcrfpy.step() for synchronous execution. +""" import mcrfpy -from mcrfpy import Color, Frame, Caption, Vector +from mcrfpy import Color, Frame, Caption, Vector, automation import sys -def test_nested_clipping(timer, runtime): - """Test nested frames with clipping""" - timer.stop() - - print("Testing advanced UIFrame clipping with nested frames...") - - # Create test scene - scene = test.children - - # Create outer frame with clipping enabled - outer = Frame(pos=(50, 50), size=(400, 300), - fill_color=Color(50, 50, 150), - outline_color=Color(255, 255, 255), - outline=3) - outer.name = "outer" - outer.clip_children = True - scene.append(outer) - - # Create inner frame that extends beyond outer bounds - inner = Frame(pos=(200, 150), size=(300, 200), - fill_color=Color(150, 50, 50), - outline_color=Color(255, 255, 0), - outline=2) - inner.name = "inner" - inner.clip_children = True # Also enable clipping on inner frame - outer.children.append(inner) - - # Add content to inner frame that extends beyond its bounds - for i in range(5): - caption = Caption(text=f"Line {i+1}: This text should be double-clipped", pos=(10, 30 * i)) - caption.font_size = 14 - caption.fill_color = Color(255, 255, 255) - inner.children.append(caption) - - # Add a child frame to inner that extends way out - deeply_nested = Frame(pos=(250, 100), size=(200, 150), - fill_color=Color(50, 150, 50), - outline_color=Color(255, 0, 255), - outline=2) - deeply_nested.name = "deeply_nested" - inner.children.append(deeply_nested) - - # Add status text - status = Caption(text="Nested clipping test:\n" - "- Blue outer frame clips red inner frame\n" - "- Red inner frame clips green deeply nested frame\n" - "- All text should be clipped to frame bounds", - pos=(50, 380)) - status.font_size = 12 - status.fill_color = Color(200, 200, 200) - scene.append(status) - - # Test render texture size handling - print(f"Outer frame size: {outer.w}x{outer.h}") - print(f"Inner frame size: {inner.w}x{inner.h}") - - # Dynamically resize frames to test RenderTexture recreation - def resize_test(timer, runtime): - timer.stop() - print("Resizing frames to test RenderTexture recreation...") - outer.w = 450 - outer.h = 350 - inner.w = 350 - inner.h = 250 - print(f"New outer frame size: {outer.w}x{outer.h}") - print(f"New inner frame size: {inner.w}x{inner.h}") - - # Take screenshot after resize - global screenshot_resize_timer - screenshot_resize_timer = mcrfpy.Timer("screenshot_resize", take_resize_screenshot, 500, once=True) - - def take_resize_screenshot(timer, runtime): - timer.stop() - from mcrfpy import automation - automation.screenshot("frame_clipping_resized.png") - print("\nAdvanced test completed!") - print("Screenshots saved:") - print(" - frame_clipping_resized.png (after resize)") - sys.exit(0) - - # Take initial screenshot - from mcrfpy import automation - automation.screenshot("frame_clipping_nested.png") - print("Initial screenshot saved: frame_clipping_nested.png") - - # Schedule resize test - global resize_test_timer - resize_test_timer = mcrfpy.Timer("resize_test", resize_test, 1000, once=True) - -# Main execution print("Creating advanced test scene...") test = mcrfpy.Scene("test") test.activate() +mcrfpy.step(0.01) -# Schedule the test -test_nested_clipping_timer = mcrfpy.Timer("test_nested_clipping", test_nested_clipping, 100, once=True) +print("Testing advanced UIFrame clipping with nested frames...") -print("Advanced test scheduled, running...") \ No newline at end of file +# Create test scene +scene = test.children + +# Create outer frame with clipping enabled +outer = Frame(pos=(50, 50), size=(400, 300), + fill_color=Color(50, 50, 150), + outline_color=Color(255, 255, 255), + outline=3) +outer.name = "outer" +outer.clip_children = True +scene.append(outer) + +# Create inner frame that extends beyond outer bounds +inner = Frame(pos=(200, 150), size=(300, 200), + fill_color=Color(150, 50, 50), + outline_color=Color(255, 255, 0), + outline=2) +inner.name = "inner" +inner.clip_children = True # Also enable clipping on inner frame +outer.children.append(inner) + +# Add content to inner frame that extends beyond its bounds +for i in range(5): + caption = Caption(text=f"Line {i+1}: This text should be double-clipped", pos=(10, 30 * i)) + caption.font_size = 14 + caption.fill_color = Color(255, 255, 255) + inner.children.append(caption) + +# Add a child frame to inner that extends way out +deeply_nested = Frame(pos=(250, 100), size=(200, 150), + fill_color=Color(50, 150, 50), + outline_color=Color(255, 0, 255), + outline=2) +deeply_nested.name = "deeply_nested" +inner.children.append(deeply_nested) + +# Add status text +status = Caption(text="Nested clipping test:\n" + "- Blue outer frame clips red inner frame\n" + "- Red inner frame clips green deeply nested frame\n" + "- All text should be clipped to frame bounds", + pos=(50, 380)) +status.font_size = 12 +status.fill_color = Color(200, 200, 200) +scene.append(status) + +# Test render texture size handling +print(f"Outer frame size: {outer.w}x{outer.h}") +print(f"Inner frame size: {inner.w}x{inner.h}") + +# Step to render +mcrfpy.step(0.1) + +# Take initial screenshot +automation.screenshot("frame_clipping_nested.png") +print("Initial screenshot saved: frame_clipping_nested.png") + +# Dynamically resize frames to test RenderTexture recreation +print("Resizing frames to test RenderTexture recreation...") +outer.w = 450 +outer.h = 350 +inner.w = 350 +inner.h = 250 +print(f"New outer frame size: {outer.w}x{outer.h}") +print(f"New inner frame size: {inner.w}x{inner.h}") + +# Step to render resize +mcrfpy.step(0.1) + +# Take screenshot after resize +automation.screenshot("frame_clipping_resized.png") + +print("\nAdvanced test completed!") +print("Screenshots saved:") +print(" - frame_clipping_nested.png (initial)") +print(" - frame_clipping_resized.png (after resize)") + +print("PASS") +sys.exit(0) diff --git a/tests/unit/test_grid_children.py b/tests/unit/test_grid_children.py index 306f8d9..9ffd5e4 100644 --- a/tests/unit/test_grid_children.py +++ b/tests/unit/test_grid_children.py @@ -1,129 +1,125 @@ #!/usr/bin/env python3 -"""Test Grid.children collection - Issue #132""" +"""Test Grid.children collection - Issue #132 +Refactored to use mcrfpy.step() for synchronous execution. +""" import mcrfpy from mcrfpy import automation import sys -def take_screenshot(timer, runtime): - """Take screenshot after render completes""" - timer.stop() - automation.screenshot("test_grid_children_result.png") - - print("Screenshot saved to test_grid_children_result.png") - print("PASS - Grid.children test completed") - sys.exit(0) - -def run_test(timer, runtime): - """Main test - runs after scene is set up""" - timer.stop() - - # Get the scene UI - ui = test.children - - # Create a grid without texture (uses default 16x16 cells) - print("Test 1: Creating Grid with children...") - grid = mcrfpy.Grid(grid_size=(20, 15), pos=(50, 50), size=(320, 240)) - grid.fill_color = mcrfpy.Color(30, 30, 60) - ui.append(grid) - - # Verify entities and children properties exist - print(f" grid.entities = {grid.entities}") - print(f" grid.children = {grid.children}") - - # Test 2: Add UIDrawable children to the grid - print("\nTest 2: Adding UIDrawable children...") - - # Speech bubble style caption - positioned in grid-world pixels - # At cell (5, 3) which is 5*16=80, 3*16=48 in pixels - caption = mcrfpy.Caption(text="Hello!", pos=(80, 48)) - caption.fill_color = mcrfpy.Color(255, 255, 200) - caption.outline = 1 - caption.outline_color = mcrfpy.Color(0, 0, 0) - grid.children.append(caption) - print(f" Added caption at (80, 48)") - - # A highlight circle around cell (10, 7) = (160, 112) - # Circle needs center, not pos - circle = mcrfpy.Circle(center=(168, 120), radius=20, - fill_color=mcrfpy.Color(255, 255, 0, 100), - outline_color=mcrfpy.Color(255, 255, 0), - outline=2) - grid.children.append(circle) - print(f" Added highlight circle at (168, 120)") - - # A line indicating a path from (2,2) to (8,6) - # In pixels: (32, 32) to (128, 96) - line = mcrfpy.Line(start=(32, 32), end=(128, 96), - color=mcrfpy.Color(0, 255, 0), thickness=3) - grid.children.append(line) - print(f" Added path line from (32,32) to (128,96)") - - # An arc for range indicator at (15, 10) = (240, 160) - arc = mcrfpy.Arc(center=(240, 160), radius=40, start_angle=0, end_angle=270, - color=mcrfpy.Color(255, 0, 255), thickness=4) - grid.children.append(arc) - print(f" Added range arc at (240, 160)") - - # Test 3: Verify children count - print(f"\nTest 3: Verifying children count...") - print(f" grid.children count = {len(grid.children)}") - assert len(grid.children) == 4, f"Expected 4 children, got {len(grid.children)}" - - # Test 4: Children should be accessible by index - print("\nTest 4: Accessing children by index...") - child0 = grid.children[0] - print(f" grid.children[0] = {child0}") - child1 = grid.children[1] - print(f" grid.children[1] = {child1}") - - # Test 5: Modify a child's position (should update in grid) - print("\nTest 5: Modifying child position...") - original_pos = (caption.pos.x, caption.pos.y) - caption.pos = mcrfpy.Vector(90, 58) - new_pos = (caption.pos.x, caption.pos.y) - print(f" Moved caption from {original_pos} to {new_pos}") - - # Test 6: Test z_index for children - print("\nTest 6: Testing z_index ordering...") - # Add overlapping elements with different z_index - frame1 = mcrfpy.Frame(pos=(150, 80), size=(40, 40)) - frame1.fill_color = mcrfpy.Color(255, 0, 0, 200) - frame1.z_index = 10 - grid.children.append(frame1) - - frame2 = mcrfpy.Frame(pos=(160, 90), size=(40, 40)) - frame2.fill_color = mcrfpy.Color(0, 255, 0, 200) - frame2.z_index = 5 # Lower z_index, rendered first (behind) - grid.children.append(frame2) - print(f" Added overlapping frames: red z=10, green z=5") - - # Test 7: Test visibility - print("\nTest 7: Testing child visibility...") - frame3 = mcrfpy.Frame(pos=(50, 150), size=(30, 30)) - frame3.fill_color = mcrfpy.Color(0, 0, 255) - frame3.visible = False - grid.children.append(frame3) - print(f" Added invisible blue frame (should not appear)") - - # Test 8: Pan the grid and verify children move with it - print("\nTest 8: Testing pan (children should follow grid camera)...") - # Center the view on cell (10, 7.5) - default was grid center - grid.center = (160, 120) # Center on pixel (160, 120) - print(f" Centered grid on (160, 120)") - - # Test 9: Test zoom - print("\nTest 9: Testing zoom...") - grid.zoom = 1.5 - print(f" Set zoom to 1.5") - - print(f"\nFinal children count: {len(grid.children)}") - - # Schedule screenshot for next frame - mcrfpy.Timer("screenshot", take_screenshot, 100, once=True) - -# Create a test scene +print("Creating test scene...") test = mcrfpy.Scene("test") test.activate() +mcrfpy.step(0.01) # Initialize -# Schedule test to run after game loop starts -mcrfpy.Timer("test", run_test, 50, once=True) +# Get the scene UI +ui = test.children + +# Test 1: Creating Grid with children +print("Test 1: Creating Grid with children...") +grid = mcrfpy.Grid(grid_size=(20, 15), pos=(50, 50), size=(320, 240)) +grid.fill_color = mcrfpy.Color(30, 30, 60) +ui.append(grid) + +# Verify entities and children properties exist +print(f" grid.entities = {grid.entities}") +print(f" grid.children = {grid.children}") + +# Test 2: Add UIDrawable children to the grid +print("\nTest 2: Adding UIDrawable children...") + +# Speech bubble style caption - positioned in grid-world pixels +# At cell (5, 3) which is 5*16=80, 3*16=48 in pixels +caption = mcrfpy.Caption(text="Hello!", pos=(80, 48)) +caption.fill_color = mcrfpy.Color(255, 255, 200) +caption.outline = 1 +caption.outline_color = mcrfpy.Color(0, 0, 0) +grid.children.append(caption) +print(f" Added caption at (80, 48)") + +# A highlight circle around cell (10, 7) = (160, 112) +# Circle needs center, not pos +circle = mcrfpy.Circle(center=(168, 120), radius=20, + fill_color=mcrfpy.Color(255, 255, 0, 100), + outline_color=mcrfpy.Color(255, 255, 0), + outline=2) +grid.children.append(circle) +print(f" Added highlight circle at (168, 120)") + +# A line indicating a path from (2,2) to (8,6) +# In pixels: (32, 32) to (128, 96) +line = mcrfpy.Line(start=(32, 32), end=(128, 96), + color=mcrfpy.Color(0, 255, 0), thickness=3) +grid.children.append(line) +print(f" Added path line from (32,32) to (128,96)") + +# An arc for range indicator at (15, 10) = (240, 160) +arc = mcrfpy.Arc(center=(240, 160), radius=40, start_angle=0, end_angle=270, + color=mcrfpy.Color(255, 0, 255), thickness=4) +grid.children.append(arc) +print(f" Added range arc at (240, 160)") + +# Test 3: Verify children count +print(f"\nTest 3: Verifying children count...") +print(f" grid.children count = {len(grid.children)}") +if len(grid.children) != 4: + print(f"FAIL: Expected 4 children, got {len(grid.children)}") + sys.exit(1) + +# Test 4: Children should be accessible by index +print("\nTest 4: Accessing children by index...") +child0 = grid.children[0] +print(f" grid.children[0] = {child0}") +child1 = grid.children[1] +print(f" grid.children[1] = {child1}") + +# Test 5: Modify a child's position (should update in grid) +print("\nTest 5: Modifying child position...") +original_pos = (caption.pos.x, caption.pos.y) +caption.pos = mcrfpy.Vector(90, 58) +new_pos = (caption.pos.x, caption.pos.y) +print(f" Moved caption from {original_pos} to {new_pos}") + +# Test 6: Test z_index for children +print("\nTest 6: Testing z_index ordering...") +# Add overlapping elements with different z_index +frame1 = mcrfpy.Frame(pos=(150, 80), size=(40, 40)) +frame1.fill_color = mcrfpy.Color(255, 0, 0, 200) +frame1.z_index = 10 +grid.children.append(frame1) + +frame2 = mcrfpy.Frame(pos=(160, 90), size=(40, 40)) +frame2.fill_color = mcrfpy.Color(0, 255, 0, 200) +frame2.z_index = 5 # Lower z_index, rendered first (behind) +grid.children.append(frame2) +print(f" Added overlapping frames: red z=10, green z=5") + +# Test 7: Test visibility +print("\nTest 7: Testing child visibility...") +frame3 = mcrfpy.Frame(pos=(50, 150), size=(30, 30)) +frame3.fill_color = mcrfpy.Color(0, 0, 255) +frame3.visible = False +grid.children.append(frame3) +print(f" Added invisible blue frame (should not appear)") + +# Test 8: Pan the grid and verify children move with it +print("\nTest 8: Testing pan (children should follow grid camera)...") +# Center the view on cell (10, 7.5) - default was grid center +grid.center = (160, 120) # Center on pixel (160, 120) +print(f" Centered grid on (160, 120)") + +# Test 9: Test zoom +print("\nTest 9: Testing zoom...") +grid.zoom = 1.5 +print(f" Set zoom to 1.5") + +print(f"\nFinal children count: {len(grid.children)}") + +# Step to render everything +mcrfpy.step(0.1) + +# Take screenshot +automation.screenshot("test_grid_children_result.png") +print("Screenshot saved to test_grid_children_result.png") + +print("PASS - Grid.children test completed") +sys.exit(0) diff --git a/tests/unit/test_no_arg_constructors.py b/tests/unit/test_no_arg_constructors.py index 1c884d3..c159030 100644 --- a/tests/unit/test_no_arg_constructors.py +++ b/tests/unit/test_no_arg_constructors.py @@ -2,90 +2,94 @@ """ Test that all UI classes can be instantiated without arguments. This verifies the fix for requiring arguments even with safe default constructors. +Refactored to use mcrfpy.step() for synchronous execution. """ import mcrfpy import sys +import traceback -def test_ui_constructors(timer, runtime): - """Test that UI classes can be instantiated without arguments""" - - print("Testing UI class instantiation without arguments...") - - # Test UICaption with no arguments - try: - caption = mcrfpy.Caption() - print("PASS: Caption() - Success") - print(f" Position: ({caption.x}, {caption.y})") - print(f" Text: '{caption.text}'") - assert caption.x == 0.0 - assert caption.y == 0.0 - assert caption.text == "" - except Exception as e: - print(f"FAIL: Caption() - {e}") - import traceback - traceback.print_exc() - - # Test UIFrame with no arguments - try: - frame = mcrfpy.Frame() - print("PASS: Frame() - Success") - print(f" Position: ({frame.x}, {frame.y})") - print(f" Size: ({frame.w}, {frame.h})") - assert frame.x == 0.0 - assert frame.y == 0.0 - assert frame.w == 0.0 - assert frame.h == 0.0 - except Exception as e: - print(f"FAIL: Frame() - {e}") - import traceback - traceback.print_exc() - - # Test UIGrid with no arguments - try: - grid = mcrfpy.Grid() - print("PASS: Grid() - Success") - print(f" Grid size: {grid.grid_x} x {grid.grid_y}") - print(f" Position: ({grid.x}, {grid.y})") - assert grid.grid_x == 1 - assert grid.grid_y == 1 - assert grid.x == 0.0 - assert grid.y == 0.0 - except Exception as e: - print(f"FAIL: Grid() - {e}") - import traceback - traceback.print_exc() - - # Test UIEntity with no arguments - try: - entity = mcrfpy.Entity() - print("PASS: Entity() - Success") - print(f" Position: ({entity.x}, {entity.y})") - assert entity.x == 0.0 - assert entity.y == 0.0 - except Exception as e: - print(f"FAIL: Entity() - {e}") - import traceback - traceback.print_exc() - - # Test UISprite with no arguments (if it has a default constructor) - try: - sprite = mcrfpy.Sprite() - print("PASS: Sprite() - Success") - print(f" Position: ({sprite.x}, {sprite.y})") - assert sprite.x == 0.0 - assert sprite.y == 0.0 - except Exception as e: - print(f"FAIL: Sprite() - {e}") - # Sprite might still require arguments, which is okay - - print("\nAll tests complete!") - - # Exit cleanly - sys.exit(0) - -# Create a basic scene so the game can start +# Initialize scene test = mcrfpy.Scene("test") +test.activate() +mcrfpy.step(0.01) -# Schedule the test to run after game initialization -test_timer = mcrfpy.Timer("test", test_ui_constructors, 100, once=True) \ No newline at end of file +print("Testing UI class instantiation without arguments...") + +all_pass = True + +# Test UICaption with no arguments +try: + caption = mcrfpy.Caption() + print("PASS: Caption() - Success") + print(f" Position: ({caption.x}, {caption.y})") + print(f" Text: '{caption.text}'") + assert caption.x == 0.0 + assert caption.y == 0.0 + assert caption.text == "" +except Exception as e: + print(f"FAIL: Caption() - {e}") + traceback.print_exc() + all_pass = False + +# Test UIFrame with no arguments +try: + frame = mcrfpy.Frame() + print("PASS: Frame() - Success") + print(f" Position: ({frame.x}, {frame.y})") + print(f" Size: ({frame.w}, {frame.h})") + assert frame.x == 0.0 + assert frame.y == 0.0 + assert frame.w == 0.0 + assert frame.h == 0.0 +except Exception as e: + print(f"FAIL: Frame() - {e}") + traceback.print_exc() + all_pass = False + +# Test UIGrid with no arguments +try: + grid = mcrfpy.Grid() + print("PASS: Grid() - Success") + print(f" Grid size: {grid.grid_x} x {grid.grid_y}") + print(f" Position: ({grid.x}, {grid.y})") + assert grid.grid_x == 1 + assert grid.grid_y == 1 + assert grid.x == 0.0 + assert grid.y == 0.0 +except Exception as e: + print(f"FAIL: Grid() - {e}") + traceback.print_exc() + all_pass = False + +# Test UIEntity with no arguments +try: + entity = mcrfpy.Entity() + print("PASS: Entity() - Success") + print(f" Position: ({entity.x}, {entity.y})") + assert entity.x == 0.0 + assert entity.y == 0.0 +except Exception as e: + print(f"FAIL: Entity() - {e}") + traceback.print_exc() + all_pass = False + +# Test UISprite with no arguments (if it has a default constructor) +try: + sprite = mcrfpy.Sprite() + print("PASS: Sprite() - Success") + print(f" Position: ({sprite.x}, {sprite.y})") + assert sprite.x == 0.0 + assert sprite.y == 0.0 +except Exception as e: + print(f"FAIL: Sprite() - {e}") + # Sprite might still require arguments, which is okay + +print("\nAll tests complete!") + +if all_pass: + print("PASS") + sys.exit(0) +else: + print("FAIL") + sys.exit(1) diff --git a/tests/unit/test_properties_quick.py b/tests/unit/test_properties_quick.py index 0fd6ee3..5c1e696 100644 --- a/tests/unit/test_properties_quick.py +++ b/tests/unit/test_properties_quick.py @@ -1,57 +1,67 @@ #!/usr/bin/env python3 -"""Quick test of drawable properties""" +"""Quick test of drawable properties +Refactored to use mcrfpy.step() for synchronous execution. +""" import mcrfpy import sys -def test_properties(timer, runtime): - timer.stop() - - print("\n=== Testing Properties ===") - - # Test Frame - try: - frame = mcrfpy.Frame(pos=(10, 10), size=(100, 100)) - print(f"Frame visible: {frame.visible}") - frame.visible = False - print(f"Frame visible after setting to False: {frame.visible}") - - print(f"Frame opacity: {frame.opacity}") - frame.opacity = 0.5 - print(f"Frame opacity after setting to 0.5: {frame.opacity}") - - bounds = frame.get_bounds() - print(f"Frame bounds: {bounds}") - - frame.move(5, 5) - bounds2 = frame.get_bounds() - print(f"Frame bounds after move(5,5): {bounds2}") - - print("✓ Frame properties work!") - except Exception as e: - print(f"✗ Frame failed: {e}") - - # Test Entity - try: - entity = mcrfpy.Entity() - print(f"\nEntity visible: {entity.visible}") - entity.visible = False - print(f"Entity visible after setting to False: {entity.visible}") - - print(f"Entity opacity: {entity.opacity}") - entity.opacity = 0.7 - print(f"Entity opacity after setting to 0.7: {entity.opacity}") - - bounds = entity.get_bounds() - print(f"Entity bounds: {bounds}") - - entity.move(3, 3) - print(f"Entity position after move(3,3): ({entity.x}, {entity.y})") - - print("✓ Entity properties work!") - except Exception as e: - print(f"✗ Entity failed: {e}") - - sys.exit(0) - +# Initialize scene test = mcrfpy.Scene("test") -test_properties_timer = mcrfpy.Timer("test_properties", test_properties, 100, once=True) \ No newline at end of file +test.activate() +mcrfpy.step(0.01) + +print("\n=== Testing Properties ===") + +all_pass = True + +# Test Frame +try: + frame = mcrfpy.Frame(pos=(10, 10), size=(100, 100)) + print(f"Frame visible: {frame.visible}") + frame.visible = False + print(f"Frame visible after setting to False: {frame.visible}") + + print(f"Frame opacity: {frame.opacity}") + frame.opacity = 0.5 + print(f"Frame opacity after setting to 0.5: {frame.opacity}") + + bounds = frame.get_bounds() + print(f"Frame bounds: {bounds}") + + frame.move(5, 5) + bounds2 = frame.get_bounds() + print(f"Frame bounds after move(5,5): {bounds2}") + + print("+ Frame properties work!") +except Exception as e: + print(f"x Frame failed: {e}") + all_pass = False + +# Test Entity +try: + entity = mcrfpy.Entity() + print(f"\nEntity visible: {entity.visible}") + entity.visible = False + print(f"Entity visible after setting to False: {entity.visible}") + + print(f"Entity opacity: {entity.opacity}") + entity.opacity = 0.7 + print(f"Entity opacity after setting to 0.7: {entity.opacity}") + + bounds = entity.get_bounds() + print(f"Entity bounds: {bounds}") + + entity.move(3, 3) + print(f"Entity position after move(3,3): ({entity.x}, {entity.y})") + + print("+ Entity properties work!") +except Exception as e: + print(f"x Entity failed: {e}") + all_pass = False + +if all_pass: + print("\nPASS") + sys.exit(0) +else: + print("\nFAIL") + sys.exit(1) diff --git a/tests/unit/test_python_object_cache.py b/tests/unit/test_python_object_cache.py index dbf83e3..d426cd4 100644 --- a/tests/unit/test_python_object_cache.py +++ b/tests/unit/test_python_object_cache.py @@ -4,6 +4,7 @@ Test for Python object cache - verifies that derived Python classes maintain their identity when stored in and retrieved from collections. Issue #112: Object Splitting - Preserve Python derived types in collections +Refactored to use mcrfpy.step() for synchronous execution. """ import mcrfpy @@ -16,136 +17,128 @@ test_results = [] def test(condition, message): global test_passed if condition: - test_results.append(f"✓ {message}") + test_results.append(f"+ {message}") else: - test_results.append(f"✗ {message}") + test_results.append(f"x {message}") test_passed = False -def run_tests(timer, runtime): - """Timer callback to run tests after game loop starts""" - global test_passed - - print("\n=== Testing Python Object Cache ===") - - # Test 1: Create derived Frame class - class MyFrame(mcrfpy.Frame): - def __init__(self, x=0, y=0): - super().__init__(pos=(x, y), size=(100, 100)) - self.custom_data = "I am a custom frame" - self.test_value = 42 - - # Test 2: Create instance and add to scene - frame = MyFrame(50, 50) - scene_ui = test_scene.children - scene_ui.append(frame) - - # Test 3: Retrieve from collection and check type - retrieved = scene_ui[0] - test(type(retrieved) == MyFrame, "Retrieved object maintains derived type") - test(isinstance(retrieved, MyFrame), "isinstance check passes") - test(hasattr(retrieved, 'custom_data'), "Custom attribute exists") - if hasattr(retrieved, 'custom_data'): - test(retrieved.custom_data == "I am a custom frame", "Custom attribute value preserved") - if hasattr(retrieved, 'test_value'): - test(retrieved.test_value == 42, "Numeric attribute value preserved") - - # Test 4: Check object identity (same Python object) - test(retrieved is frame, "Retrieved object is the same Python object") - test(id(retrieved) == id(frame), "Object IDs match") - - # Test 5: Multiple retrievals return same object - retrieved2 = scene_ui[0] - test(retrieved2 is retrieved, "Multiple retrievals return same object") - - # Test 6: Test with other UI types - class MySprite(mcrfpy.Sprite): - def __init__(self): - # Use default texture by passing None - super().__init__(texture=None, sprite_index=0) - self.sprite_data = "custom sprite" - - sprite = MySprite() - sprite.x = 200 - sprite.y = 200 - scene_ui.append(sprite) - - retrieved_sprite = scene_ui[1] - test(type(retrieved_sprite) == MySprite, "Sprite maintains derived type") - if hasattr(retrieved_sprite, 'sprite_data'): - test(retrieved_sprite.sprite_data == "custom sprite", "Sprite custom data preserved") - - # Test 7: Test with Caption - class MyCaption(mcrfpy.Caption): - def __init__(self, text): - # Use default font by passing None - super().__init__(text=text, font=None) - self.caption_id = "test_caption" - - caption = MyCaption("Test Caption") - caption.x = 10 - caption.y = 10 - scene_ui.append(caption) - - retrieved_caption = scene_ui[2] - test(type(retrieved_caption) == MyCaption, "Caption maintains derived type") - if hasattr(retrieved_caption, 'caption_id'): - test(retrieved_caption.caption_id == "test_caption", "Caption custom data preserved") - - # Test 8: Test removal and re-addition - # Use del to remove by index (Python standard), or .remove(element) to remove by value - print(f"before remove: {len(scene_ui)=}") - del scene_ui[-1] # Remove last element by index - print(f"after remove: {len(scene_ui)=}") - - scene_ui.append(frame) - retrieved3 = scene_ui[-1] # Get last element - test(retrieved3 is frame, "Object identity preserved after removal/re-addition") - - # Test 9: Test with Grid - class MyGrid(mcrfpy.Grid): - def __init__(self, w, h): - super().__init__(grid_size=(w, h)) - self.grid_name = "custom_grid" - - grid = MyGrid(10, 10) - grid.x = 300 - grid.y = 100 - scene_ui.append(grid) - - retrieved_grid = scene_ui[-1] - test(type(retrieved_grid) == MyGrid, "Grid maintains derived type") - if hasattr(retrieved_grid, 'grid_name'): - test(retrieved_grid.grid_name == "custom_grid", "Grid custom data preserved") - - # Test 10: Test with nested collections (Frame with children) - parent = MyFrame(400, 400) - child = MyFrame(10, 10) - child.custom_data = "I am a child" - parent.children.append(child) - scene_ui.append(parent) - - retrieved_parent = scene_ui[-1] - test(type(retrieved_parent) == MyFrame, "Parent frame maintains type") - if len(retrieved_parent.children) > 0: - retrieved_child = retrieved_parent.children[0] - test(type(retrieved_child) == MyFrame, "Child frame maintains type in nested collection") - if hasattr(retrieved_child, 'custom_data'): - test(retrieved_child.custom_data == "I am a child", "Child custom data preserved") - - # Print results - print("\n=== Test Results ===") - for result in test_results: - print(result) - - print(f"\n{'PASS' if test_passed else 'FAIL'}: {sum(1 for r in test_results if r.startswith('✓'))}/{len(test_results)} tests passed") - - sys.exit(0 if test_passed else 1) - # Create test scene test_scene = mcrfpy.Scene("test_scene") test_scene.activate() +mcrfpy.step(0.01) -# Schedule tests to run after game loop starts -test_timer = mcrfpy.Timer("test", run_tests, 100, once=True) +print("\n=== Testing Python Object Cache ===") -print("Python object cache test initialized. Running tests...") +# Test 1: Create derived Frame class +class MyFrame(mcrfpy.Frame): + def __init__(self, x=0, y=0): + super().__init__(pos=(x, y), size=(100, 100)) + self.custom_data = "I am a custom frame" + self.test_value = 42 + +# Test 2: Create instance and add to scene +frame = MyFrame(50, 50) +scene_ui = test_scene.children +scene_ui.append(frame) + +# Test 3: Retrieve from collection and check type +retrieved = scene_ui[0] +test(type(retrieved) == MyFrame, "Retrieved object maintains derived type") +test(isinstance(retrieved, MyFrame), "isinstance check passes") +test(hasattr(retrieved, 'custom_data'), "Custom attribute exists") +if hasattr(retrieved, 'custom_data'): + test(retrieved.custom_data == "I am a custom frame", "Custom attribute value preserved") +if hasattr(retrieved, 'test_value'): + test(retrieved.test_value == 42, "Numeric attribute value preserved") + +# Test 4: Check object identity (same Python object) +test(retrieved is frame, "Retrieved object is the same Python object") +test(id(retrieved) == id(frame), "Object IDs match") + +# Test 5: Multiple retrievals return same object +retrieved2 = scene_ui[0] +test(retrieved2 is retrieved, "Multiple retrievals return same object") + +# Test 6: Test with other UI types +class MySprite(mcrfpy.Sprite): + def __init__(self): + # Use default texture by passing None + super().__init__(texture=None, sprite_index=0) + self.sprite_data = "custom sprite" + +sprite = MySprite() +sprite.x = 200 +sprite.y = 200 +scene_ui.append(sprite) + +retrieved_sprite = scene_ui[1] +test(type(retrieved_sprite) == MySprite, "Sprite maintains derived type") +if hasattr(retrieved_sprite, 'sprite_data'): + test(retrieved_sprite.sprite_data == "custom sprite", "Sprite custom data preserved") + +# Test 7: Test with Caption +class MyCaption(mcrfpy.Caption): + def __init__(self, text): + # Use default font by passing None + super().__init__(text=text, font=None) + self.caption_id = "test_caption" + +caption = MyCaption("Test Caption") +caption.x = 10 +caption.y = 10 +scene_ui.append(caption) + +retrieved_caption = scene_ui[2] +test(type(retrieved_caption) == MyCaption, "Caption maintains derived type") +if hasattr(retrieved_caption, 'caption_id'): + test(retrieved_caption.caption_id == "test_caption", "Caption custom data preserved") + +# Test 8: Test removal and re-addition +# Use del to remove by index (Python standard), or .remove(element) to remove by value +print(f"before remove: {len(scene_ui)=}") +del scene_ui[-1] # Remove last element by index +print(f"after remove: {len(scene_ui)=}") + +scene_ui.append(frame) +retrieved3 = scene_ui[-1] # Get last element +test(retrieved3 is frame, "Object identity preserved after removal/re-addition") + +# Test 9: Test with Grid +class MyGrid(mcrfpy.Grid): + def __init__(self, w, h): + super().__init__(grid_size=(w, h)) + self.grid_name = "custom_grid" + +grid = MyGrid(10, 10) +grid.x = 300 +grid.y = 100 +scene_ui.append(grid) + +retrieved_grid = scene_ui[-1] +test(type(retrieved_grid) == MyGrid, "Grid maintains derived type") +if hasattr(retrieved_grid, 'grid_name'): + test(retrieved_grid.grid_name == "custom_grid", "Grid custom data preserved") + +# Test 10: Test with nested collections (Frame with children) +parent = MyFrame(400, 400) +child = MyFrame(10, 10) +child.custom_data = "I am a child" +parent.children.append(child) +scene_ui.append(parent) + +retrieved_parent = scene_ui[-1] +test(type(retrieved_parent) == MyFrame, "Parent frame maintains type") +if len(retrieved_parent.children) > 0: + retrieved_child = retrieved_parent.children[0] + test(type(retrieved_child) == MyFrame, "Child frame maintains type in nested collection") + if hasattr(retrieved_child, 'custom_data'): + test(retrieved_child.custom_data == "I am a child", "Child custom data preserved") + +# Print results +print("\n=== Test Results ===") +for result in test_results: + print(result) + +print(f"\n{'PASS' if test_passed else 'FAIL'}: {sum(1 for r in test_results if r.startswith('+'))}/{len(test_results)} tests passed") + +sys.exit(0 if test_passed else 1) diff --git a/tests/unit/test_simple_callback.py b/tests/unit/test_simple_callback.py index 7e7cd6a..18a403b 100644 --- a/tests/unit/test_simple_callback.py +++ b/tests/unit/test_simple_callback.py @@ -1,14 +1,32 @@ #!/usr/bin/env python3 -"""Very simple callback test""" +"""Very simple callback test - refactored to use mcrfpy.step()""" import mcrfpy import sys +callback_fired = False + def cb(a, t): + global callback_fired + callback_fired = True print("CB") +# Setup scene test = mcrfpy.Scene("test") test.activate() +mcrfpy.step(0.01) # Initialize + +# Create entity and animation e = mcrfpy.Entity((0, 0), texture=None, sprite_index=0) a = mcrfpy.Animation("x", 1.0, 0.1, "linear", callback=cb) a.start(e) -mcrfpy.Timer("exit", lambda t, r: sys.exit(0), 200, once=True) + +# Advance past animation duration (0.1s) +mcrfpy.step(0.15) + +# Verify callback fired +if callback_fired: + print("PASS: Callback fired") + sys.exit(0) +else: + print("FAIL: Callback did not fire") + sys.exit(1) diff --git a/tests/unit/test_simple_drawable.py b/tests/unit/test_simple_drawable.py index 63d37c3..36552b4 100644 --- a/tests/unit/test_simple_drawable.py +++ b/tests/unit/test_simple_drawable.py @@ -1,30 +1,32 @@ #!/usr/bin/env python3 -"""Simple test to isolate drawable issue""" +"""Simple test to isolate drawable issue +Refactored to use mcrfpy.step() for synchronous execution. +""" import mcrfpy import sys -def simple_test(timer, runtime): - timer.stop() - - try: - # Test basic functionality - frame = mcrfpy.Frame(pos=(10, 10), size=(100, 100)) - print(f"Frame created: visible={frame.visible}, opacity={frame.opacity}") - - bounds = frame.get_bounds() - print(f"Bounds: {bounds}") - - frame.move(5, 5) - print("Move completed") - - frame.resize(150, 150) - print("Resize completed") - - print("PASS - No crash!") - except Exception as e: - print(f"ERROR: {e}") - - sys.exit(0) - +# Initialize scene test = mcrfpy.Scene("test") -simple_test_timer = mcrfpy.Timer("simple_test", simple_test, 100, once=True) \ No newline at end of file +test.activate() +mcrfpy.step(0.01) + +try: + # Test basic functionality + frame = mcrfpy.Frame(pos=(10, 10), size=(100, 100)) + print(f"Frame created: visible={frame.visible}, opacity={frame.opacity}") + + bounds = frame.get_bounds() + print(f"Bounds: {bounds}") + + frame.move(5, 5) + print("Move completed") + + frame.resize(150, 150) + print("Resize completed") + + print("PASS - No crash!") + sys.exit(0) +except Exception as e: + print(f"ERROR: {e}") + print("FAIL") + sys.exit(1) diff --git a/tests/unit/test_timer_callback.py b/tests/unit/test_timer_callback.py index 6f46efe..81d2357 100644 --- a/tests/unit/test_timer_callback.py +++ b/tests/unit/test_timer_callback.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """ Test timer callback arguments with new Timer API (#173) +Uses mcrfpy.step() for synchronous test execution. """ import mcrfpy import sys @@ -14,9 +15,6 @@ def new_style_callback(timer, runtime): print(f"Callback called with: timer={timer} (type: {type(timer)}), runtime={runtime} (type: {type(runtime)})") if hasattr(timer, 'once'): print(f"Got Timer object! once={timer.once}") - if call_count >= 2: - print("PASS") - sys.exit(0) # Set up the scene test_scene = mcrfpy.Scene("test_scene") @@ -25,3 +23,14 @@ test_scene.activate() print("Testing new Timer callback signature (timer, runtime)...") timer = mcrfpy.Timer("test_timer", new_style_callback, 100) print(f"Timer created: {timer}") + +# Advance time to let timer fire - each step() processes timers once +mcrfpy.step(0.15) # First fire +mcrfpy.step(0.15) # Second fire + +if call_count >= 2: + print("PASS: Timer callback received correct arguments") + sys.exit(0) +else: + print(f"FAIL: Expected at least 2 callbacks, got {call_count}") + sys.exit(1)