test(suite): unrot 82 tests that were passing without ever running

The suite was reporting 331/331 while at least 82 of those tests asserted
nothing at all.

They raised during setup on APIs removed long ago -- add_layer(name=...),
GridPoint.color, mcrfpy.Animation(), entity.gridstate, mcrfpy.setScene,
assets/kenney_ice.png, GridData.compute_astar -- registered no timers, hit the
engine's auto-exit-when-no-timers path, exited 0, and were scored PASS. Their
assertions had not executed in months. test_metrics.py is the sharpest example:
the existing metrics test died on line 140 with a TypeError, which is precisely
why #341 (get_metrics counters reading 0) went unnoticed.

The engine no longer permits this (#350: a headless --exec script must call
sys.exit()), and run_tests.py no longer passes a test whose output contains a
Traceback. This commit repairs the 82 they exposed, migrating each to the
current API while preserving its original intent -- not deleting assertions to
make the command exit 0. Each repair was adversarially re-verified by a second
pass asking "is this still a test, or was it gutted?"; none were.

Two tests could not be made to pass because they were right and the engine was
wrong. Rather than paper over them they were left failing and the bugs fixed
separately in 48eef0b: DijkstraMap path order (#375) and layer-setter cache
invalidation (#376). Three integration tests had encoded the reversed Dijkstra
order as expected behavior; their assertions now state the real contract
(excludes the origin, ends at the root).

Suite: 334/334, every one of them actually asserting.

Refs #341, #350, #372
This commit is contained in:
John McCardle 2026-07-14 07:29:23 -04:00
commit 112f3571f5
82 changed files with 6348 additions and 3396 deletions

View file

@ -1,9 +1,19 @@
#!/usr/bin/env python3
"""
A* vs Dijkstra Visual Comparison
=================================
A* vs Dijkstra Comparison
=========================
Shows the difference between A* (single target) and Dijkstra (multi-target).
Verifies that A* (single target) and Dijkstra (multi-target flood) agree on the
cost of the shortest route through an obstacle course, that both produce legal,
contiguous, walkable paths, and that the Dijkstra distance field is consistent
with the paths it hands back.
API notes (updated for the current engine):
* Pathfinding lives on GridData: grid.find_path(start, end) -> AStarPath|None,
grid.get_dijkstra_map(root=...) -> DijkstraMap (.distance(pos), .path_from(pos)).
The old grid.compute_astar_path / compute_dijkstra / get_dijkstra_path are gone.
* GridPoint has no .color -- cell coloring is done with a ColorLayer.
* add_layer() takes a layer OBJECT and no keyword arguments.
"""
import mcrfpy
@ -17,31 +27,41 @@ DIJKSTRA_COLOR = mcrfpy.Color(0, 150, 255) # Blue for Dijkstra
START_COLOR = mcrfpy.Color(255, 100, 100) # Red for start
END_COLOR = mcrfpy.Color(255, 255, 100) # Yellow for end
GRID_W, GRID_H = 30, 20
# Global state
grid = None
color_layer = None
mode = "ASTAR"
start_pos = (5, 10)
end_pos = (27, 10) # Changed from 25 to 27 to avoid the wall
failures = []
def check(condition, message):
"""Record a failed assertion instead of aborting, so we report every problem."""
if condition:
print(f" PASS: {message}")
else:
print(f" FAIL: {message}")
failures.append(message)
def create_map():
"""Create a map with obstacles to show pathfinding differences"""
global grid, color_layer
pathfinding_comparison = mcrfpy.Scene("pathfinding_comparison")
# Create grid
grid = mcrfpy.Grid(grid_w=30, grid_h=20)
# Create grid (view + backing GridData)
grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H), pos=(100, 100), size=(600, 400))
grid.fill_color = mcrfpy.Color(0, 0, 0)
# Add color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# Add color layer for cell coloring (GridPoint.color no longer exists)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.grid_data.add_layer(color_layer)
# Initialize all as floor
for y in range(20):
for x in range(30):
grid.at(x, y).walkable = True
color_layer.set(x, y, FLOOR_COLOR)
for y in range(GRID_H):
for x in range(GRID_W):
grid.grid_data.at(x, y).walkable = True
color_layer.set((x, y), FLOOR_COLOR)
# Create obstacles that make A* and Dijkstra differ
obstacles = [
@ -55,172 +75,179 @@ def create_map():
[(25, y) for y in range(5, 15)],
]
walls = set()
for obstacle_group in obstacles:
for x, y in obstacle_group:
grid.at(x, y).walkable = False
color_layer.set(x, y, WALL_COLOR)
grid.grid_data.at(x, y).walkable = False
color_layer.set((x, y), WALL_COLOR)
walls.add((x, y))
# Mark start and end
color_layer.set(start_pos[0], start_pos[1], START_COLOR)
color_layer.set(end_pos[0], end_pos[1], END_COLOR)
color_layer.set(start_pos, START_COLOR)
color_layer.set(end_pos, END_COLOR)
def clear_paths():
"""Clear path highlighting"""
for y in range(20):
for x in range(30):
cell = grid.at(x, y)
if cell.walkable:
color_layer.set(x, y, FLOOR_COLOR)
# Dijkstra maps are cached; walkability just changed, so invalidate.
grid.grid_data.clear_dijkstra_maps()
return walls
# Restore start and end colors
color_layer.set(start_pos[0], start_pos[1], START_COLOR)
color_layer.set(end_pos[0], end_pos[1], END_COLOR)
def validate_path(cells, label, origin, destination):
"""A path must be contiguous, walkable, and actually arrive at `destination`.
Both find_path() and DijkstraMap.path_from() use the same convention (#375):
the returned path EXCLUDES the origin and ENDS at the destination. The two
differ only in which endpoint is which -- A* is asked start->end, while a
Dijkstra map rooted at start is queried from end, so it walks end->start.
"""
check(len(cells) > 0, f"{label}: path is non-empty")
if not cells:
return
check(cells[-1] == destination,
f"{label}: path ends at {destination} (got {cells[-1]})")
check(origin not in cells, f"{label}: path excludes its origin {origin}")
# The first cell must be a single step from the origin.
prev = origin
contiguous = True
walkable = True
for (x, y) in cells:
dx, dy = abs(x - prev[0]), abs(y - prev[1])
if max(dx, dy) != 1:
contiguous = False
if not grid.grid_data.at(x, y).walkable:
walkable = False
prev = (x, y)
check(contiguous, f"{label}: every step moves exactly one cell (incl. from start)")
check(walkable, f"{label}: every cell on the path is walkable")
def path_cells(path):
return [(int(v.x), int(v.y)) for v in path]
def show_astar():
"""Show A* path"""
clear_paths()
"""Compute + color the A* path. Returns its cells."""
path = grid.grid_data.find_path(start_pos, end_pos)
check(path is not None, "A*: a path exists through the obstacle course")
if path is None:
return []
# Compute A* path
path = grid.compute_astar_path(start_pos[0], start_pos[1], end_pos[0], end_pos[1])
check((int(path.destination.x), int(path.destination.y)) == end_pos,
"A*: .destination is the requested end cell")
# Color the path
for i, (x, y) in enumerate(path):
# NOTE: AStarPath is a *consuming* iterator -- iterating it walks the path,
# so .remaining must be read before draining it.
expected = path.remaining
cells = path_cells(path)
check(expected == len(cells),
f"A*: .remaining ({expected}) matches the iterated length ({len(cells)})")
check(path.remaining == 0, "A*: iterating the path consumes it (.remaining -> 0)")
for (x, y) in cells:
if (x, y) != start_pos and (x, y) != end_pos:
color_layer.set(x, y, ASTAR_COLOR)
color_layer.set((x, y), ASTAR_COLOR)
status_text.text = f"A* Path: {len(path)} steps (optimized for single target)"
status_text.text = f"A* Path: {len(cells)} steps (optimized for single target)"
status_text.fill_color = ASTAR_COLOR
return cells
def show_dijkstra():
"""Show Dijkstra exploration"""
clear_paths()
def show_dijkstra(walls):
"""Compute the Dijkstra flood from start, color it, return the path cells."""
dmap = grid.grid_data.get_dijkstra_map(root=start_pos)
check((int(dmap.root.x), int(dmap.root.y)) == start_pos,
"Dijkstra: map root is the start cell")
# Compute Dijkstra from start
grid.compute_dijkstra(start_pos[0], start_pos[1])
# The distance field must be defined on reachable floor and undefined on walls.
check(dmap.distance(start_pos) == 0.0, "Dijkstra: distance to the root itself is 0")
end_dist = dmap.distance(end_pos)
check(end_dist is not None and end_dist > 0,
f"Dijkstra: end cell is reachable (distance={end_dist})")
wall_distances_defined = [w for w in walls if dmap.distance(w) is not None]
check(not wall_distances_defined,
f"Dijkstra: unwalkable cells have no distance ({len(wall_distances_defined)} leaked)")
# Color cells by distance (showing exploration)
max_dist = 40.0
for y in range(20):
for x in range(30):
if grid.at(x, y).walkable:
dist = grid.get_dijkstra_distance(x, y)
for y in range(GRID_H):
for x in range(GRID_W):
if grid.grid_data.at(x, y).walkable:
dist = dmap.distance((x, y))
if dist is not None and dist < max_dist:
# Color based on distance
intensity = int(255 * (1 - dist / max_dist))
color_layer.set(x, y, mcrfpy.Color(0, intensity // 2, intensity))
color_layer.set((x, y), mcrfpy.Color(0, intensity // 2, intensity))
# Get the actual path
path = grid.get_dijkstra_path(end_pos[0], end_pos[1])
# Get the actual path back to the root
cells = path_cells(dmap.path_from(end_pos))
# Highlight the actual path more brightly
for x, y in path:
for (x, y) in cells:
if (x, y) != start_pos and (x, y) != end_pos:
color_layer.set(x, y, DIJKSTRA_COLOR)
color_layer.set((x, y), DIJKSTRA_COLOR)
# Restore start and end
color_layer.set(start_pos[0], start_pos[1], START_COLOR)
color_layer.set(end_pos[0], end_pos[1], END_COLOR)
color_layer.set(start_pos, START_COLOR)
color_layer.set(end_pos, END_COLOR)
status_text.text = f"Dijkstra: {len(path)} steps (explores all directions)"
status_text.text = f"Dijkstra: {len(cells)} steps (explores all directions)"
status_text.fill_color = DIJKSTRA_COLOR
return cells, dmap
def show_both():
"""Show both paths overlaid"""
clear_paths()
def show_both(astar_cells, dijkstra_cells, dmap):
"""Compare the two routes. Different cells are fine; different COST is not."""
print(f" A* : {astar_cells}")
print(f" Dijkstra: {dijkstra_cells}")
# Get both paths
astar_path = grid.compute_astar_path(start_pos[0], start_pos[1], end_pos[0], end_pos[1])
grid.compute_dijkstra(start_pos[0], start_pos[1])
dijkstra_path = grid.get_dijkstra_path(end_pos[0], end_pos[1])
# Both algorithms are optimal, so they must agree on the number of steps
# even if they break ties through different cells.
check(len(astar_cells) == len(dijkstra_cells),
f"A* and Dijkstra agree on path length (A*={len(astar_cells)}, "
f"Dijkstra={len(dijkstra_cells)})")
print(astar_path, dijkstra_path)
# The Dijkstra distance field must be monotonically increasing along the
# A* route - i.e. the flood is consistent with the single-target search.
monotonic = True
prev = dmap.distance(start_pos)
for cell in astar_cells:
d = dmap.distance(cell)
if d is None or d <= prev:
monotonic = False
break
prev = d
check(monotonic, "Dijkstra distance increases at every step along the A* path")
# Color Dijkstra path first (blue)
for x, y in dijkstra_path:
if (x, y) != start_pos and (x, y) != end_pos:
color_layer.set(x, y, DIJKSTRA_COLOR)
different_cells = [c for c in dijkstra_cells if c not in astar_cells]
# Then A* path (green) - will overwrite shared cells
for x, y in astar_path:
if (x, y) != start_pos and (x, y) != end_pos:
color_layer.set(x, y, ASTAR_COLOR)
# Mark differences
different_cells = []
for cell in dijkstra_path:
if cell not in astar_path:
different_cells.append(cell)
status_text.text = f"Both paths: A*={len(astar_path)} steps, Dijkstra={len(dijkstra_path)} steps"
status_text.text = (f"Both paths: A*={len(astar_cells)} steps, "
f"Dijkstra={len(dijkstra_cells)} steps")
if different_cells:
info_text.text = f"Paths differ at {len(different_cells)} cells"
else:
info_text.text = "Paths are identical"
print(f" {info_text.text}")
def handle_keypress(key_str, state):
"""Handle keyboard input"""
global mode
if state == mcrfpy.InputState.RELEASED: return
print(key_str)
if key_str == mcrfpy.Key.ESCAPE or key_str == mcrfpy.Key.Q:
print("\nExiting...")
sys.exit(0)
elif key_str == mcrfpy.Key.A or key_str == mcrfpy.Key.NUM_1:
mode = "ASTAR"
show_astar()
elif key_str == mcrfpy.Key.D or key_str == mcrfpy.Key.NUM_2:
mode = "DIJKSTRA"
show_dijkstra()
elif key_str == mcrfpy.Key.B or key_str == mcrfpy.Key.NUM_3:
mode = "BOTH"
show_both()
elif key_str == mcrfpy.Key.SPACE:
# Refresh current mode
if mode == "ASTAR":
show_astar()
elif mode == "DIJKSTRA":
show_dijkstra()
else:
show_both()
# Create the demo
# ---------------------------------------------------------------------------
print("A* vs Dijkstra Pathfinding Comparison")
print("=====================================")
print("Controls:")
print(" A or 1 - Show A* path (green)")
print(" D or 2 - Show Dijkstra (blue gradient)")
print(" B or 3 - Show both paths")
print(" Q/ESC - Quit")
print()
print("A* is optimized for single-target pathfinding")
print("Dijkstra explores in all directions (good for multiple targets)")
print()
create_map()
walls = create_map()
# Set up UI
pathfinding_comparison = mcrfpy.Scene("pathfinding_comparison")
ui = pathfinding_comparison.children
ui.append(grid)
# Scale and position
grid.size = (600, 400) # 30*20, 20*20
grid.pos = (100, 100)
# Add title
title = mcrfpy.Caption(pos=(250, 20), text="A* vs Dijkstra Pathfinding")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
# Add status
status_text = mcrfpy.Caption(pos=(100, 60), text="Press A for A*, D for Dijkstra, B for Both")
status_text = mcrfpy.Caption(pos=(100, 60), text="Comparing A* and Dijkstra")
status_text.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(status_text)
# Add info
info_text = mcrfpy.Caption(pos=(100, 520), text="")
info_text.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(info_text)
# Add legend
legend1 = mcrfpy.Caption(pos=(100, 540), text="Red=Start, Yellow=End, Green=A*, Blue=Dijkstra")
legend1.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend1)
@ -229,11 +256,38 @@ legend2 = mcrfpy.Caption(pos=(100, 560), text="Dark=Walls, Light=Floor")
legend2.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend2)
# Set scene and input
pathfinding_comparison.activate()
pathfinding_comparison.on_key = handle_keypress
# Show initial A* path
show_astar()
# Sanity: the ColorLayer really is the coloring mechanism now.
color_layer.set((0, 0), START_COLOR)
_c = color_layer.at((0, 0))
check((_c.r, _c.g, _c.b) == (255, 100, 100),
"ColorLayer.set/.at round-trips a cell color")
color_layer.set((0, 0), FLOOR_COLOR)
print("\nDemo ready!")
print("\n[A*]")
astar_cells = show_astar()
# A* is asked start -> end.
validate_path(astar_cells, "A*", origin=start_pos, destination=end_pos)
print("\n[Dijkstra]")
dijkstra_cells, dmap = show_dijkstra(walls)
# The Dijkstra map is ROOTED at start_pos and queried FROM end_pos, so its path
# runs end -> start: the origin and destination are the mirror of A*'s.
validate_path(dijkstra_cells, "Dijkstra", origin=end_pos, destination=start_pos)
print("\n[Both]")
show_both(astar_cells, dijkstra_cells, dmap)
# Force a render so the colored comparison is actually drawn.
mcrfpy.automation.screenshot("astar_vs_dijkstra.png")
print()
if failures:
print(f"FAIL: {len(failures)} check(s) failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,14 +1,30 @@
#!/usr/bin/env python3
"""Debug visibility crash"""
"""Debug visibility crash
Originally probed entity.gridstate / entity.update_visibility() / grid.perspective
for crashes. Updated to the current API (#313/#361):
- entity.gridstate -> entity.perspective_map (a 3-state DiscreteMap:
UNKNOWN / DISCOVERED / VISIBLE, indexed by (x, y))
- grid.perspective -> now takes an Entity or None, not an int index
"""
import mcrfpy
import sys
failures = []
def check(cond, msg):
if cond:
print(f" ok: {msg}")
else:
print(f" FAIL: {msg}")
failures.append(msg)
print("Debug visibility...")
# Create scene and grid
debug = mcrfpy.Scene("debug")
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
grid = mcrfpy.Grid(grid_size=(5, 5))
# Initialize grid
print("Initializing grid...")
@ -20,39 +36,66 @@ for y in range(5):
# Create entity
print("Creating entity...")
entity = mcrfpy.Entity((2, 2), grid=grid)
entity = mcrfpy.Entity(grid_pos=(2, 2))
entity.sprite_index = 64
print(f"Entity at ({entity.x}, {entity.y})")
grid.entities.append(entity)
# .x/.y are PIXEL coords now; .grid_x/.grid_y are the canonical cell coords
print(f"Entity at cell ({entity.grid_x}, {entity.grid_y}), pixels ({entity.x}, {entity.y})")
check((entity.grid_x, entity.grid_y) == (2, 2), "entity sits at its grid_pos (2, 2)")
# Check gridstate
print(f"\nGridstate length: {len(entity.gridstate)}")
print(f"Expected: {5 * 5}")
# A second entity, so visible_entities() has something to find
neighbor = mcrfpy.Entity(grid_pos=(4, 4))
grid.entities.append(neighbor)
# Try to access gridstate
print("\nChecking gridstate access...")
try:
if len(entity.gridstate) > 0:
state = entity.gridstate[0]
print(f"First state: visible={state.visible}, discovered={state.discovered}")
except Exception as e:
print(f"Error accessing gridstate: {e}")
# Check perspective_map (successor to gridstate)
pmap = entity.perspective_map
print(f"\nPerspective map size: {pmap.size}")
check(pmap.size == (5, 5), "perspective_map covers every grid cell (5x5)")
# Try update_visibility
# Access the map before any FOV is computed: everything is unknown
print("\nChecking perspective_map access...")
Perspective = pmap.enum_type
check(pmap.get((0, 0)) == Perspective.UNKNOWN,
"cells start UNKNOWN before update_visibility()")
check(pmap.histogram() == {int(Perspective.UNKNOWN): 25},
"all 25 cells start UNKNOWN")
# update_visibility must populate the map (open 5x5 room, sight_radius 10)
print("\nTrying update_visibility...")
try:
entity.update_visibility()
print("update_visibility succeeded")
except Exception as e:
print(f"Error in update_visibility: {e}")
entity.update_visibility()
check(pmap.get((2, 2)) == Perspective.VISIBLE,
"entity's own cell is VISIBLE after update_visibility()")
check(pmap.get((0, 0)) == Perspective.VISIBLE,
"far corner of an open, transparent room is VISIBLE")
check(pmap.histogram() == {int(Perspective.VISIBLE): 25},
"all 25 cells of the open room are VISIBLE")
# visible_entities() returns OTHER entities only. Compare by cell, not identity:
# it hands back fresh wrappers rather than cached ones (see notes on UIEntity.cpp:1334).
seen = [(e.grid_x, e.grid_y) for e in entity.visible_entities()]
check(seen == [(4, 4)],
"visible_entities() sees the neighbor at (4, 4) and excludes self")
# Try perspective
# Perspective now takes an Entity (or None), not an index
print("\nTesting perspective...")
print(f"Initial perspective: {grid.perspective}")
check(grid.perspective is None, "grid.perspective defaults to None (omniscient)")
grid.perspective = entity
print(f"Set perspective to entity: {grid.perspective}")
check(grid.perspective is entity, "grid.perspective round-trips the Entity")
grid.perspective = None
check(grid.perspective is None, "grid.perspective can be cleared back to None")
try:
grid.perspective = 0
print(f"Set perspective to 0: {grid.perspective}")
except Exception as e:
print(f"Error setting perspective: {e}")
check(False, "grid.perspective rejects a non-Entity (int index is gone)")
except TypeError:
check(True, "grid.perspective rejects a non-Entity (int index is gone)")
print("\nTest complete")
sys.exit(0)
if failures:
print(f"FAIL: {len(failures)} check(s) failed")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -5,6 +5,10 @@ Dijkstra Demo - Shows ALL Path Combinations (Including Invalid)
Cycles through every possible entity pair to demonstrate both
valid paths and properly handled invalid paths (empty lists).
Entity 1 is sealed inside a 2x2 pocket of walls, so every path to or from
it must come back as an empty list; the two reachable entities must produce
real, contiguous, walkable paths.
"""
import mcrfpy
@ -20,24 +24,35 @@ NO_PATH_COLOR = mcrfpy.Color(255, 0, 0) # Pure red for unreachable
# Global state
grid = None
grid_data = None
color_layer = None
entities = []
current_combo_index = 0
all_combinations = [] # All possible pairs
current_path = []
failures = []
dijkstra_all = mcrfpy.Scene("dijkstra_all")
def check(condition, message):
"""Record a failed expectation instead of aborting the whole run."""
if not condition:
failures.append(message)
print(f"FAIL: {message}")
return condition
def create_map():
"""Create the map with entities"""
global grid, color_layer, entities, all_combinations
global grid, grid_data, color_layer, entities, all_combinations
dijkstra_all = mcrfpy.Scene("dijkstra_all")
# Create grid
grid = mcrfpy.Grid(grid_w=14, grid_h=10)
# Create grid (grid_size= replaces the old grid_w/grid_h kwargs)
grid = mcrfpy.Grid(grid_size=(14, 10))
grid.fill_color = mcrfpy.Color(0, 0, 0)
grid_data = grid.grid_data
# Add color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# Add color layer for cell coloring (GridPoint has no .color anymore)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.add_layer(color_layer)
# Map layout - Entity 1 is intentionally trapped!
map_layout = [
@ -57,14 +72,14 @@ def create_map():
entity_positions = []
for y, row in enumerate(map_layout):
for x, char in enumerate(row):
cell = grid.at(x, y)
cell = grid_data.at(x, y)
if char == 'W':
cell.walkable = False
color_layer.set(x, y, WALL_COLOR)
color_layer.set((x, y), WALL_COLOR)
else:
cell.walkable = True
color_layer.set(x, y, FLOOR_COLOR)
color_layer.set((x, y), FLOOR_COLOR)
if char == 'E':
entity_positions.append((x, y))
@ -72,38 +87,41 @@ def create_map():
# Create entities
entities = []
for i, (x, y) in enumerate(entity_positions):
entity = mcrfpy.Entity((x, y), grid=grid)
entity = mcrfpy.Entity(grid_pos=(x, y), grid=grid)
entity.sprite_index = 49 + i # '1', '2', '3'
entities.append(entity)
print("Map Analysis:")
print("=============")
for i, (x, y) in enumerate(entity_positions):
print(f"Entity {i+1} at ({x}, {y})")
check(len(entities) == 3, f"expected 3 entities from the map, got {len(entities)}")
check(entity_positions == [(10, 2), (6, 4), (0, 5)],
f"unexpected entity positions: {entity_positions}")
# Generate ALL combinations (including invalid ones)
all_combinations = []
for i in range(len(entities)):
for j in range(len(entities)):
if i != j: # Skip self-paths
all_combinations.append((i, j))
print(f"\nTotal path combinations to test: {len(all_combinations)}")
def clear_path_colors():
"""Reset all floor tiles to original color"""
global current_path
for y in range(grid.grid_h):
for x in range(grid.grid_w):
cell = grid.at(x, y)
if cell.walkable:
color_layer.set(x, y, FLOOR_COLOR)
for y in range(grid_data.grid_h):
for x in range(grid_data.grid_w):
if grid_data.at(x, y).walkable:
color_layer.set((x, y), FLOOR_COLOR)
current_path = []
def show_combination(index):
"""Show a specific path combination (valid or invalid)"""
"""Show a specific path combination (valid or invalid); returns the path"""
global current_combo_index, current_path
current_combo_index = index % len(all_combinations)
@ -116,44 +134,63 @@ def show_combination(index):
e_from = entities[from_idx]
e_to = entities[to_idx]
# Calculate path
path = e_from.path_to(int(e_to.x), int(e_to.y))
# Calculate path (grid_x/grid_y are tile coords; .x/.y are pixels now)
path = e_from.path_to(e_to.grid_x, e_to.grid_y)
current_path = path if path else []
# Always color start and end positions
color_layer.set(int(e_from.x), int(e_from.y), START_COLOR)
color_layer.set(int(e_to.x), int(e_to.y), NO_PATH_COLOR if not path else END_COLOR)
color_layer.set((e_from.grid_x, e_from.grid_y), START_COLOR)
color_layer.set((e_to.grid_x, e_to.grid_y),
NO_PATH_COLOR if not path else END_COLOR)
# Color the path if it exists
if path:
# Color intermediate steps
for i, (x, y) in enumerate(path):
if i > 0 and i < len(path) - 1:
color_layer.set(x, y, PATH_COLOR)
color_layer.set((x, y), PATH_COLOR)
status_text.text = f"Path {current_combo_index + 1}/{len(all_combinations)}: Entity {from_idx+1} Entity {to_idx+1} = {len(path)} steps"
status_text.text = f"Path {current_combo_index + 1}/{len(all_combinations)}: Entity {from_idx+1} -> Entity {to_idx+1} = {len(path)} steps"
status_text.fill_color = mcrfpy.Color(100, 255, 100) # Green for valid
# Show path steps
path_display = []
for i, (x, y) in enumerate(path[:5]):
for (x, y) in path[:5]:
path_display.append(f"({x},{y})")
if len(path) > 5:
path_display.append("...")
path_text.text = "Path: " + " ".join(path_display)
path_text.text = "Path: " + " -> ".join(path_display)
else:
status_text.text = f"Path {current_combo_index + 1}/{len(all_combinations)}: Entity {from_idx+1} Entity {to_idx+1} = NO PATH!"
status_text.text = f"Path {current_combo_index + 1}/{len(all_combinations)}: Entity {from_idx+1} -> Entity {to_idx+1} = NO PATH!"
status_text.fill_color = mcrfpy.Color(255, 100, 100) # Red for invalid
path_text.text = "Path: [] (No valid path exists)"
# Update info
info_text.text = f"From: Entity {from_idx+1} at ({int(e_from.x)}, {int(e_from.y)}) | To: Entity {to_idx+1} at ({int(e_to.x)}, {int(e_to.y)})"
info_text.text = f"From: Entity {from_idx+1} at ({e_from.grid_x}, {e_from.grid_y}) | To: Entity {to_idx+1} at ({e_to.grid_x}, {e_to.grid_y})"
return path
def validate_path(path, e_from, e_to, label):
"""A valid path must be contiguous, walkable, and connect the endpoints"""
check(path[-1] == (e_to.grid_x, e_to.grid_y),
f"{label}: path does not end at the target: {path[-1]}")
# path_to() excludes the source cell; the first step must be adjacent to it
sx, sy = e_from.grid_x, e_from.grid_y
check((sx, sy) not in path, f"{label}: path should not include the source cell")
check(max(abs(path[0][0] - sx), abs(path[0][1] - sy)) == 1,
f"{label}: first step {path[0]} is not adjacent to source ({sx}, {sy})")
for (x, y) in path:
check(grid_data.at(x, y).walkable,
f"{label}: path crosses unwalkable cell ({x}, {y})")
for (ax, ay), (bx, by) in zip(path, path[1:]):
check(max(abs(ax - bx), abs(ay - by)) == 1,
f"{label}: non-contiguous step ({ax},{ay}) -> ({bx},{by})")
def handle_keypress(key_str, state):
"""Handle keyboard input"""
"""Handle keyboard input (interactive mode only)"""
global current_combo_index
if state == mcrfpy.InputState.RELEASED: return
if key_str == mcrfpy.Key.ESCAPE or key_str == mcrfpy.Key.Q:
print("\nExiting...")
sys.exit(0)
@ -164,8 +201,8 @@ def handle_keypress(key_str, state):
elif key_str == mcrfpy.Key.R:
show_combination(current_combo_index)
else:
num_keys = {mcrfpy.Key.NUM_1: 0, mcrfpy.Key.NUM_2: 1, mcrfpy.Key.NUM_3: 2,
mcrfpy.Key.NUM_4: 3, mcrfpy.Key.NUM_5: 4, mcrfpy.Key.NUM_6: 5}
num_keys = {mcrfpy.Key.Num1: 0, mcrfpy.Key.Num2: 1, mcrfpy.Key.Num3: 2,
mcrfpy.Key.Num4: 3, mcrfpy.Key.Num5: 4, mcrfpy.Key.Num6: 5}
if key_str in num_keys:
combo_num = num_keys[key_str]
if combo_num < len(all_combinations):
@ -214,12 +251,12 @@ controls.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(controls)
# Add legend
legend = mcrfpy.Caption(pos=(120, 560), text="Red Start→Blue End (valid) | Red Start→Red End (invalid)")
legend = mcrfpy.Caption(pos=(120, 560), text="Red Start->Blue End (valid) | Red Start->Red End (invalid)")
legend.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend)
# Expected results info
expected = mcrfpy.Caption(pos=(120, 580), text="Entity 1 is trapped: paths 1→2, 1→3, 2→1, 3→1 will fail")
expected = mcrfpy.Caption(pos=(120, 580), text="Entity 1 is trapped: paths 1->2, 1->3, 2->1, 3->1 will fail")
expected.fill_color = mcrfpy.Color(255, 150, 150)
ui.append(expected)
@ -227,14 +264,36 @@ ui.append(expected)
dijkstra_all.activate()
dijkstra_all.on_key = handle_keypress
# Show first combination
show_combination(0)
print("\nExpected results:")
print(" Path 1: Entity 1->2 = NO PATH (Entity 1 is trapped)")
print(" Path 2: Entity 1->3 = NO PATH (Entity 1 is trapped)")
print(" Path 3: Entity 2->1 = NO PATH (Entity 1 is trapped)")
print(" Path 4: Entity 2->3 = Valid path")
print(" Path 5: Entity 3->1 = NO PATH (Entity 1 is trapped)")
print(" Path 6: Entity 3->2 = Valid path")
print()
print("\nDemo ready!")
print("Expected results:")
print(" Path 1: Entity 1→2 = NO PATH (Entity 1 is trapped)")
print(" Path 2: Entity 1→3 = NO PATH (Entity 1 is trapped)")
print(" Path 3: Entity 2→1 = NO PATH (Entity 1 is trapped)")
print(" Path 4: Entity 2→3 = Valid path")
print(" Path 5: Entity 3→1 = NO PATH (Entity 1 is trapped)")
print(" Path 6: Entity 3→2 = Valid path")
# Drive every combination and verify the outcome. Entity index 0 is trapped,
# so any pair involving it must yield an empty list; the other pairs must be
# real paths.
for combo_index, (from_idx, to_idx) in enumerate(all_combinations):
path = show_combination(combo_index)
label = f"Entity {from_idx+1}->{to_idx+1}"
if 0 in (from_idx, to_idx):
check(path == [],
f"{label}: expected NO PATH (entity 1 is walled in), got {path}")
print(f" {label}: [] (correctly unreachable)")
else:
if check(bool(path), f"{label}: expected a valid path, got {path}"):
validate_path(path, entities[from_idx], entities[to_idx], label)
print(f" {label}: {len(path)} steps, ends at {path[-1]}")
# Render once so the coloring path is exercised too (rendering costs no sim time)
mcrfpy.automation.screenshot("dijkstra_all_paths.png")
if failures:
print(f"\n{len(failures)} check(s) failed")
sys.exit(1)
print("\nPASS")
sys.exit(0)

View file

@ -4,6 +4,14 @@ Dijkstra Demo - Cycles Through Different Path Combinations
==========================================================
Shows paths between different entity pairs, skipping impossible paths.
Headless verification of the original demo's claims:
- Entity 1 is walled into a pocket and can reach nobody.
- Entities 2 and 3 can reach each other (both directions), so exactly two
path combinations exist.
- Each generated path is a contiguous chain of walkable cells ending on the
target entity.
- Cycling through the combinations recolors the ColorLayer accordingly.
"""
import mcrfpy
@ -18,24 +26,33 @@ END_COLOR = mcrfpy.Color(100, 100, 255) # Light blue
# Global state
grid = None
grid_data = None
color_layer = None
entities = []
current_path_index = 0
path_combinations = []
current_path = []
failures = []
def check(condition, message):
"""Record a failed assertion without aborting the demo"""
if not condition:
failures.append(message)
print(f" FAIL: {message}")
return condition
def create_map():
"""Create the map with entities"""
global grid, color_layer, entities
global grid, grid_data, color_layer, entities
dijkstra_cycle = mcrfpy.Scene("dijkstra_cycle")
# Create grid
grid = mcrfpy.Grid(grid_w=14, grid_h=10)
# Create grid (grid_size= replaces the old grid_w=/grid_h= kwargs)
grid = mcrfpy.Grid(grid_size=(14, 10))
grid_data = grid.grid_data
grid.fill_color = mcrfpy.Color(0, 0, 0)
# Add color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# Add color layer for cell coloring. Cells no longer have a .color;
# ColorLayer objects are constructed then attached (add_layer takes no kwargs).
color_layer = grid_data.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
# Map layout
map_layout = [
@ -55,14 +72,14 @@ def create_map():
entity_positions = []
for y, row in enumerate(map_layout):
for x, char in enumerate(row):
cell = grid.at(x, y)
cell = grid_data.at(x, y)
if char == 'W':
cell.walkable = False
color_layer.set(x, y, WALL_COLOR)
color_layer.set((x, y), WALL_COLOR)
else:
cell.walkable = True
color_layer.set(x, y, FLOOR_COLOR)
color_layer.set((x, y), FLOOR_COLOR)
if char == 'E':
entity_positions.append((x, y))
@ -70,58 +87,89 @@ def create_map():
# Create entities
entities = []
for i, (x, y) in enumerate(entity_positions):
entity = mcrfpy.Entity((x, y), grid=grid)
entity = mcrfpy.Entity(grid_pos=(x, y), grid=grid)
entity.sprite_index = 49 + i # '1', '2', '3'
entities.append(entity)
print("Entities created:")
for i, (x, y) in enumerate(entity_positions):
print(f" Entity {i+1} at ({x}, {y})")
check(entity_positions == [(10, 2), (6, 4), (0, 5)],
f"expected entities at [(10,2),(6,4),(0,5)], got {entity_positions}")
# Check which entity is trapped
print("\nChecking accessibility:")
reachability = []
for i, e in enumerate(entities):
# Try to path to each other entity
can_reach = []
for j, other in enumerate(entities):
if i != j:
path = e.path_to(int(other.x), int(other.y))
path = e.path_to(other.grid_x, other.grid_y)
if path:
can_reach.append(j+1)
reachability.append(can_reach)
if not can_reach:
print(f" Entity {i+1} at ({int(e.x)}, {int(e.y)}) is TRAPPED!")
print(f" Entity {i+1} at ({e.grid_x}, {e.grid_y}) is TRAPPED!")
else:
print(f" Entity {i+1} can reach entities: {can_reach}")
# Entity 1 sits in a sealed pocket; entities 2 and 3 share the open area.
check(reachability[0] == [], "Entity 1 should be trapped (no reachable entities)")
check(reachability[1] == [3], f"Entity 2 should reach only Entity 3, got {reachability[1]}")
check(reachability[2] == [2], f"Entity 3 should reach only Entity 2, got {reachability[2]}")
# Generate valid path combinations (excluding trapped entity)
global path_combinations
path_combinations = []
# Only paths between entities 2 and 3 (indices 1 and 2) will work
# since entity 1 (index 0) is trapped
if len(entities) >= 3:
# Entity 2 to Entity 3
path = entities[1].path_to(int(entities[2].x), int(entities[2].y))
path = entities[1].path_to(entities[2].grid_x, entities[2].grid_y)
if path:
path_combinations.append((1, 2, path))
# Entity 3 to Entity 2
path = entities[2].path_to(int(entities[1].x), int(entities[1].y))
path = entities[2].path_to(entities[1].grid_x, entities[1].grid_y)
if path:
path_combinations.append((2, 1, path))
print(f"\nFound {len(path_combinations)} valid paths")
check(len(path_combinations) == 2, f"expected 2 valid path combinations, got {len(path_combinations)}")
def validate_path(from_idx, to_idx, path):
"""A path must be a contiguous chain of walkable cells ending on the target"""
e_from = entities[from_idx]
e_to = entities[to_idx]
check(len(path) > 0, f"path {from_idx+1}->{to_idx+1} is empty")
if not path:
return
check(tuple(path[-1]) == (e_to.grid_x, e_to.grid_y),
f"path {from_idx+1}->{to_idx+1} does not end on target: {path[-1]}")
# path_to() excludes the entity's own cell, so walk from the entity position
prev = (e_from.grid_x, e_from.grid_y)
for (x, y) in path:
check(grid_data.at(x, y).walkable, f"path crosses non-walkable cell ({x}, {y})")
step = max(abs(x - prev[0]), abs(y - prev[1]))
check(step == 1, f"path is not contiguous: {prev} -> ({x}, {y})")
prev = (x, y)
def clear_path_colors():
"""Reset all floor tiles to original color"""
global current_path
for y in range(grid.grid_h):
for x in range(grid.grid_w):
cell = grid.at(x, y)
for y in range(grid_data.grid_h):
for x in range(grid_data.grid_w):
cell = grid_data.at(x, y)
if cell.walkable:
color_layer.set(x, y, FLOOR_COLOR)
color_layer.set((x, y), FLOOR_COLOR)
current_path = []
@ -147,16 +195,16 @@ def show_path(index):
current_path = path
if path:
# Color start and end
color_layer.set(int(e_from.x), int(e_from.y), START_COLOR)
color_layer.set(int(e_to.x), int(e_to.y), END_COLOR)
color_layer.set((e_from.grid_x, e_from.grid_y), START_COLOR)
color_layer.set((e_to.grid_x, e_to.grid_y), END_COLOR)
# Color intermediate steps
for i, (x, y) in enumerate(path):
if i > 0 and i < len(path) - 1:
color_layer.set(x, y, PATH_COLOR)
# Color intermediate steps. path_to() excludes the entity's own cell,
# so every element except the last (the target) is an intermediate step.
for (x, y) in path[:-1]:
color_layer.set((x, y), PATH_COLOR)
# Update status
status_text.text = f"Path {current_path_index + 1}/{len(path_combinations)}: Entity {from_idx+1} Entity {to_idx+1} ({len(path)} steps)"
status_text.text = f"Path {current_path_index + 1}/{len(path_combinations)}: Entity {from_idx+1} -> Entity {to_idx+1} ({len(path)} steps)"
# Update path display
path_display = []
@ -164,7 +212,7 @@ def show_path(index):
path_display.append(f"({x},{y})")
if len(path) > 5:
path_display.append("...")
path_text.text = "Path: " + " ".join(path_display) if path_display else "Path: None"
path_text.text = "Path: " + " -> ".join(path_display) if path_display else "Path: None"
def handle_keypress(key_str, state):
"""Handle keyboard input"""
@ -186,6 +234,8 @@ print("==========================")
print("Note: Entity 1 is trapped by walls!")
print()
dijkstra_cycle = mcrfpy.Scene("dijkstra_cycle")
create_map()
# Set up UI
@ -231,9 +281,34 @@ if path_combinations:
else:
status_text.text = "No valid paths! Entity 1 is trapped!"
print("\nDemo ready!")
print("Controls:")
print(" SPACE or N - Next path")
print(" P - Previous path")
print(" R - Refresh current path")
print(" Q - Quit")
# --- Headless verification: cycle every combination the demo would show ---
print("\nCycling paths:")
for i in range(len(path_combinations)):
show_path(i)
from_idx, to_idx, path = path_combinations[current_path_index]
print(f" {status_text.text}")
check(current_path_index == i, f"show_path({i}) selected index {current_path_index}")
validate_path(from_idx, to_idx, path)
# The colored overlay must reflect the displayed path
e_from, e_to = entities[from_idx], entities[to_idx]
check(color_layer.at((e_from.grid_x, e_from.grid_y)) == START_COLOR,
"start cell not colored START_COLOR")
check(color_layer.at((e_to.grid_x, e_to.grid_y)) == END_COLOR,
"end cell not colored END_COLOR")
for (x, y) in path[:-1]:
check(color_layer.at((x, y)) == PATH_COLOR, f"path cell ({x},{y}) not colored PATH_COLOR")
# Cycling wraps around (what SPACE/N does)
show_path(len(path_combinations))
check(current_path_index == 0, "cycling past the last path should wrap to 0")
# Render once to prove the scene is drawable with the layers attached
mcrfpy.automation.screenshot("dijkstra_cycle_paths.png")
if failures:
print(f"\nFAIL: {len(failures)} check(s) failed")
sys.exit(1)
print("\nPASS")
sys.exit(0)

View file

@ -18,40 +18,49 @@ ENTITY_COLORS = [
# Global state
grid = None
grid_data = None
color_layer = None
entities = []
first_point = None
second_point = None
failures = []
WALLS = [(5, 2), (5, 3), (5, 4), (5, 5), (5, 6)]
def check(condition, message):
"""Record a failed check instead of dying at the first one."""
if not condition:
print(f" FAIL: {message}")
failures.append(message)
return condition
def create_simple_map():
"""Create a simple test map"""
global grid, color_layer, entities
dijkstra_debug = mcrfpy.Scene("dijkstra_debug")
global grid, grid_data, color_layer, entities
# Small grid for easy debugging
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
grid = mcrfpy.Grid(grid_size=(10, 10))
grid.fill_color = mcrfpy.Color(0, 0, 0)
# Pathfinding + layers live on the shared GridData (#313/#361)
grid_data = grid.grid_data
# Add color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid_data.add_layer(color_layer)
print("Initializing 10x10 grid...")
# Initialize all as floor
for y in range(10):
for x in range(10):
grid.at(x, y).walkable = True
grid.at(x, y).transparent = True
color_layer.set(x, y, FLOOR_COLOR)
grid_data.at(x, y).walkable = True
grid_data.at(x, y).transparent = True
color_layer.set((x, y), FLOOR_COLOR)
# Add a simple wall
print("Adding walls at:")
walls = [(5, 2), (5, 3), (5, 4), (5, 5), (5, 6)]
for x, y in walls:
for x, y in WALLS:
print(f" Wall at ({x}, {y})")
grid.at(x, y).walkable = False
color_layer.set(x, y, WALL_COLOR)
grid_data.at(x, y).walkable = False
color_layer.set((x, y), WALL_COLOR)
# Create 3 entities
entity_positions = [(2, 5), (8, 5), (5, 8)]
@ -60,7 +69,7 @@ def create_simple_map():
print("\nCreating entities at:")
for i, (x, y) in enumerate(entity_positions):
print(f" Entity {i+1} at ({x}, {y})")
entity = mcrfpy.Entity((x, y), grid=grid)
entity = mcrfpy.Entity(grid_pos=(x, y), grid=grid)
entity.sprite_index = 49 + i # '1', '2', '3'
entities.append(entity)
@ -70,70 +79,95 @@ def test_path_highlighting():
"""Test path highlighting with debug output"""
print("\n" + "="*50)
print("Testing path highlighting...")
# Select first two entities
e1 = entities[0]
e2 = entities[1]
print(f"\nEntity 1 position: ({e1.x}, {e1.y})")
print(f"Entity 2 position: ({e2.x}, {e2.y})")
# NOTE: entity.x/.y are PIXEL coordinates now; tile coords are grid_x/grid_y.
print(f"\nEntity 1 position: ({e1.grid_x}, {e1.grid_y})")
print(f"Entity 2 position: ({e2.grid_x}, {e2.grid_y})")
# Use entity.path_to()
print("\nCalling entity.path_to()...")
path = e1.path_to(int(e2.x), int(e2.y))
path = e1.path_to(e2.grid_x, e2.grid_y)
print(f"Path returned: {path}")
print(f"Path length: {len(path)} steps")
check(len(path) > 0, "entity.path_to() returned an empty path")
if path:
check(tuple(path[-1]) == (e2.grid_x, e2.grid_y),
f"path does not end at the target: {path[-1]}")
print("\nHighlighting path cells:")
for i, (x, y) in enumerate(path):
print(f" Step {i}: ({x}, {y})")
# Get current color for debugging
cell = grid.at(x, y)
old_c = color_layer.at(x, y)
cell = grid_data.at(x, y)
old_c = color_layer.at((x, y))
old_color = (old_c.r, old_c.g, old_c.b)
# Set new color
color_layer.set(x, y, PATH_COLOR)
new_c = color_layer.at(x, y)
color_layer.set((x, y), PATH_COLOR)
new_c = color_layer.at((x, y))
new_color = (new_c.r, new_c.g, new_c.b)
print(f" Color changed from {old_color} to {new_color}")
print(f" Walkable: {cell.walkable}")
check(cell.walkable, f"path routed through non-walkable cell ({x}, {y})")
check((x, y) not in WALLS, f"path routed through a wall at ({x}, {y})")
# Also test grid's Dijkstra methods
print("\n" + "-"*30)
print("Testing grid Dijkstra methods...")
grid.compute_dijkstra(int(e1.x), int(e1.y))
grid_path = grid.get_dijkstra_path(int(e2.x), int(e2.y))
distance = grid.get_dijkstra_distance(int(e2.x), int(e2.y))
# compute_dijkstra/get_dijkstra_path/get_dijkstra_distance were replaced by
# GridData.get_dijkstra_map() -> DijkstraMap.path_from()/.distance()
dmap = grid_data.get_dijkstra_map(root=(e1.grid_x, e1.grid_y))
grid_path = list(dmap.path_from((e2.grid_x, e2.grid_y)))
distance = dmap.distance((e2.grid_x, e2.grid_y))
print(f"Grid path: {grid_path}")
print(f"Grid distance: {distance}")
check(distance is not None, "Dijkstra distance to entity 2 is unreachable")
check(len(grid_path) > 0, "Dijkstra path_from() returned an empty path")
for x, y in grid_path:
check(grid_data.at(x, y).walkable,
f"Dijkstra path routed through non-walkable cell ({x}, {y})")
# Dijkstra distance must be at least the straight-line-blocked A* step count
check(distance is None or distance >= len(path) - 0.001,
f"Dijkstra distance {distance} shorter than A* path length {len(path)}")
# Verify colors were set
print("\nVerifying cell colors after highlighting:")
for x, y in path[:3]: # Check first 3 cells
c = color_layer.at(x, y)
c = color_layer.at((x, y))
color = (c.r, c.g, c.b)
expected = (PATH_COLOR.r, PATH_COLOR.g, PATH_COLOR.b)
match = color == expected
print(f" Cell ({x}, {y}): color={color}, expected={expected}, match={match}")
check(match, f"ColorLayer did not retain PATH_COLOR at ({x}, {y})")
def handle_keypress(scene_name, keycode):
# Cells that were never on the path must keep their original color
wall_c = color_layer.at(WALLS[0])
check((wall_c.r, wall_c.g, wall_c.b) == (WALL_COLOR.r, WALL_COLOR.g, WALL_COLOR.b),
"wall cell color was clobbered by path highlighting")
def handle_keypress(key, action):
"""Simple keypress handler"""
if keycode == 81 or keycode == 113 or keycode == 256: # Q/q/ESC
if key == mcrfpy.Key.Q or key == mcrfpy.Key.Escape:
print("\nExiting debug...")
sys.exit(0)
elif keycode == 32: # Space
elif key == mcrfpy.Key.Space:
print("\nSpace pressed - retesting path highlighting...")
test_path_highlighting()
# Create the map
print("Dijkstra Debug Test")
print("===================")
dijkstra_debug = mcrfpy.Scene("dijkstra_debug")
grid = create_simple_map()
# Initial path test
@ -161,6 +195,17 @@ ui.append(info)
dijkstra_debug.on_key = handle_keypress
dijkstra_debug.activate()
print("\nScene ready. The path should be highlighted in cyan.")
print("If you don't see the path, there may be a rendering issue.")
print("Press SPACE to retest, Q to quit.")
# Render once to prove the highlighted grid actually draws (the original test's
# "if you don't see the path there may be a rendering issue" concern).
mcrfpy.step(0.016)
mcrfpy.automation.screenshot("dijkstra_debug.png")
print("\n" + "="*50)
if failures:
print(f"FAIL: {len(failures)} check(s) failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,20 +1,26 @@
#!/usr/bin/env python3
"""
Dijkstra Pathfinding Interactive Demo
=====================================
Dijkstra Pathfinding Interactive Demo (headless self-test)
==========================================================
Interactive visualization showing Dijkstra pathfinding between entities.
Visualization + verification of Dijkstra pathfinding between entities.
Controls:
Controls (when run with a window):
- Press 1/2/3 to select the first entity
- Press A/B/C to select the second entity
- Press A/B/C to select the second entity
- Space to clear selection
- Q or ESC to quit
The path between selected entities is automatically highlighted.
The path between selected entities is highlighted on a ColorLayer.
Under --headless the same selection logic is driven programmatically through the
key handler and the resulting paths / distances / highlight colors are asserted.
Entity 1 lives in a sealed room, so it must be unreachable from the others;
entities 2 and 3 are separated by a long wall and must be reachable around it.
"""
import mcrfpy
from mcrfpy import automation
import sys
# Colors - using more distinct values
@ -29,55 +35,65 @@ ENTITY_COLORS = [
# Global state
grid = None
grid_data = None
color_layer = None
entities = []
first_point = None
second_point = None
last_path = None
failures = []
def check(condition, message):
"""Record a failed assertion without aborting the run"""
if not condition:
failures.append(message)
print(f"FAIL: {message}")
return condition
# Define the map layout from user's specification
# . = floor, W = wall, E = entity position
map_layout = [
"..............", # Row 0
"..W.....WWWW..", # Row 1
"..W.W...W.EW..", # Row 2
"..W.....W..W..", # Row 3
"..W...E.WWWW..", # Row 4
"E.W...........", # Row 5
"..W...........", # Row 6
"..W...........", # Row 7
"..W.WWW.......", # Row 8
"..............", # Row 9
]
def create_map():
"""Create the interactive map with the layout specified by the user"""
global grid, color_layer, entities
dijkstra_interactive = mcrfpy.Scene("dijkstra_interactive")
global grid, grid_data, color_layer, entities
# Create grid - 14x10 as specified
grid = mcrfpy.Grid(grid_w=14, grid_h=10)
grid = mcrfpy.Grid(grid_size=(14, 10))
grid.fill_color = mcrfpy.Color(0, 0, 0)
grid_data = grid.grid_data
# Add color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# Define the map layout from user's specification
# . = floor, W = wall, E = entity position
map_layout = [
"..............", # Row 0
"..W.....WWWW..", # Row 1
"..W.W...W.EW..", # Row 2
"..W.....W..W..", # Row 3
"..W...E.WWWW..", # Row 4
"E.W...........", # Row 5
"..W...........", # Row 6
"..W...........", # Row 7
"..W.WWW.......", # Row 8
"..............", # Row 9
]
# Add color layer for cell coloring (GridPoint has no .color anymore)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid_data.add_layer(color_layer)
# Create the map
entity_positions = []
for y, row in enumerate(map_layout):
for x, char in enumerate(row):
cell = grid.at(x, y)
cell = grid_data.at(x, y)
if char == 'W':
# Wall
cell.walkable = False
cell.transparent = False
color_layer.set(x, y, WALL_COLOR)
color_layer.set((x, y), WALL_COLOR)
else:
# Floor
cell.walkable = True
cell.transparent = True
color_layer.set(x, y, FLOOR_COLOR)
color_layer.set((x, y), FLOOR_COLOR)
if char == 'E':
# Entity position
@ -86,25 +102,32 @@ def create_map():
# Create entities at marked positions
entities = []
for i, (x, y) in enumerate(entity_positions):
entity = mcrfpy.Entity((x, y), grid=grid)
entity = mcrfpy.Entity(grid_pos=(x, y))
entity.sprite_index = 49 + i # '1', '2', '3'
grid_data.entities.append(entity)
entities.append(entity)
return grid
def entity_cell(entity):
"""Logical cell of an entity as an (x, y) int tuple"""
cp = entity.cell_pos
return (int(cp.x), int(cp.y))
def clear_path_highlight():
"""Clear any existing path highlighting"""
# Reset all floor tiles to original color
for y in range(grid.grid_h):
for x in range(grid.grid_w):
cell = grid.at(x, y)
if cell.walkable:
color_layer.set(x, y, FLOOR_COLOR)
for y in range(grid_data.grid_h):
for x in range(grid_data.grid_w):
if grid_data.at(x, y).walkable:
color_layer.set((x, y), FLOOR_COLOR)
def highlight_path():
"""Highlight the path between selected entities"""
"""Highlight the path between selected entities. Returns the path (may be empty)."""
global last_path
last_path = None
if first_point is None or second_point is None:
return
return None
# Clear previous highlighting
clear_path_highlight()
@ -112,72 +135,67 @@ def highlight_path():
# Get entities
entity1 = entities[first_point]
entity2 = entities[second_point]
start = entity_cell(entity1)
end = entity_cell(entity2)
# Compute Dijkstra from first entity
grid.compute_dijkstra(int(entity1.x), int(entity1.y))
# Compute Dijkstra from first entity; walkability is static, but clear the
# cache so each selection recomputes against the current grid.
grid_data.clear_dijkstra_maps()
dijkstra = grid_data.get_dijkstra_map(root=start)
# Get path to second entity
path = grid.get_dijkstra_path(int(entity2.x), int(entity2.y))
distance = dijkstra.distance(end)
path = [] if distance is None else [(int(v.x), int(v.y)) for v in dijkstra.path_from(end)]
if path:
# Highlight the path
for x, y in path:
cell = grid.at(x, y)
if cell.walkable:
color_layer.set(x, y, PATH_COLOR)
if grid_data.at(x, y).walkable:
color_layer.set((x, y), PATH_COLOR)
# Also highlight start and end with entity colors
color_layer.set(int(entity1.x), int(entity1.y), ENTITY_COLORS[first_point])
color_layer.set(int(entity2.x), int(entity2.y), ENTITY_COLORS[second_point])
color_layer.set(start, ENTITY_COLORS[first_point])
color_layer.set(end, ENTITY_COLORS[second_point])
# Update info
distance = grid.get_dijkstra_distance(int(entity2.x), int(entity2.y))
info_text.text = f"Path: Entity {first_point+1} to Entity {second_point+1} - {len(path)} steps, {distance:.1f} units"
info_text.text = (f"Path: Entity {first_point+1} to Entity {second_point+1} - "
f"{len(path)} steps, {distance:.1f} units")
else:
info_text.text = f"No path between Entity {first_point+1} and Entity {second_point+1}"
def handle_keypress(scene_name, keycode):
print(f" {info_text.text}")
last_path = path
return path
def handle_keypress(key, action):
"""Handle keyboard input"""
global first_point, second_point
if action != mcrfpy.InputState.PRESSED:
return
# Number keys for first entity
if keycode == 49: # '1'
first_point = 0
status_text.text = f"First: Entity 1 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
if key in (mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3):
first_point = {mcrfpy.Key.NUM_1: 0, mcrfpy.Key.NUM_2: 1, mcrfpy.Key.NUM_3: 2}[key]
status_text.text = (f"First: Entity {first_point+1} | "
f"Second: {f'Entity {second_point+1}' if second_point is not None else '?'}")
highlight_path()
elif keycode == 50: # '2'
first_point = 1
status_text.text = f"First: Entity 2 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
highlight_path()
elif keycode == 51: # '3'
first_point = 2
status_text.text = f"First: Entity 3 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
highlight_path()
# Letter keys for second entity
elif keycode == 65 or keycode == 97: # 'A' or 'a'
second_point = 0
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 1"
elif key in (mcrfpy.Key.A, mcrfpy.Key.B, mcrfpy.Key.C):
second_point = {mcrfpy.Key.A: 0, mcrfpy.Key.B: 1, mcrfpy.Key.C: 2}[key]
status_text.text = (f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | "
f"Second: Entity {second_point+1}")
highlight_path()
elif keycode == 66 or keycode == 98: # 'B' or 'b'
second_point = 1
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 2"
highlight_path()
elif keycode == 67 or keycode == 99: # 'C' or 'c'
second_point = 2
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 3"
highlight_path()
# Clear selection
elif keycode == 32: # Space
elif key == mcrfpy.Key.SPACE:
first_point = None
second_point = None
clear_path_highlight()
status_text.text = "Press 1/2/3 for first entity, A/B/C for second"
info_text.text = "Space to clear, Q to quit"
# Quit
elif keycode == 81 or keycode == 113 or keycode == 256: # Q/q/ESC
elif key in (mcrfpy.Key.Q, mcrfpy.Key.ESCAPE):
print("\nExiting Dijkstra interactive demo...")
sys.exit(0)
@ -190,6 +208,8 @@ print(" A/B/C - Select second entity")
print(" Space - Clear selection")
print(" Q/ESC - Quit")
dijkstra_interactive = mcrfpy.Scene("dijkstra_interactive")
# Create map
grid = create_map()
@ -221,14 +241,14 @@ legend1 = mcrfpy.Caption(pos=(120, 540), text="Entities: 1=Red 2=Green 3=Blue")
legend1.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend1)
legend2 = mcrfpy.Caption(pos=(120, 560), text="Colors: Dark=Wall Light=Floor Cyan=Path")
legend2 = mcrfpy.Caption(pos=(120, 560), text="Colors: Dark=Wall Light=Floor Green=Path")
legend2.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend2)
# Mark entity positions with colored indicators
for i, entity in enumerate(entities):
marker = mcrfpy.Caption(pos=(120 + int(entity.x) * 40 + 15, 60 + int(entity.y) * 40 + 10),
text=str(i+1))
ex, ey = entity_cell(entity)
marker = mcrfpy.Caption(pos=(120 + ex * 40 + 15, 60 + ey * 40 + 10), text=str(i+1))
marker.fill_color = ENTITY_COLORS[i]
marker.outline = 1
marker.outline_color = mcrfpy.Color(0, 0, 0)
@ -243,4 +263,77 @@ dijkstra_interactive.activate()
print("\nVisualization ready!")
print("Entities are at:")
for i, entity in enumerate(entities):
print(f" Entity {i+1}: ({int(entity.x)}, {int(entity.y)})")
print(f" Entity {i+1}: {entity_cell(entity)}")
# --- Verification: drive the same selection logic the keyboard would ---
check(len(entities) == 3, f"expected 3 entities from map layout, got {len(entities)}")
check(entity_cell(entities[0]) == (10, 2), f"entity 1 at {entity_cell(entities[0])}, expected (10, 2)")
check(entity_cell(entities[1]) == (6, 4), f"entity 2 at {entity_cell(entities[1])}, expected (6, 4)")
check(entity_cell(entities[2]) == (0, 5), f"entity 3 at {entity_cell(entities[2])}, expected (0, 5)")
def press(key):
handle_keypress(key, mcrfpy.InputState.PRESSED)
# Entity 2 -> Entity 3: reachable, but only by going around the long x=2 wall.
print("\nSelect: first=2 (key '2'), second=C (entity 3)")
press(mcrfpy.Key.NUM_2)
press(mcrfpy.Key.C)
path_2_3 = last_path
check(path_2_3,"expected a Dijkstra path between entity 2 (6,4) and entity 3 (0,5)")
if path_2_3:
# The map is ROOTED at the first selection (entity 2) and queried FROM the second
# (entity 3), so the walk runs entity3 -> entity2: per #375 it excludes its origin
# (0,5) and ends at the root (6,4).
check(path_2_3[-1] == (6, 4),
f"path should end at the root, entity 2 (6,4); ended at {path_2_3[-1]}")
check((0, 5) not in path_2_3, "path should exclude its origin, entity 3 (0,5)")
check(all(grid_data.at(x, y).walkable for x, y in path_2_3),
"Dijkstra path crosses a non-walkable cell")
# The wall at x=2 spans rows 1..8, so the path must detour through row 0 or row 9
check(any(y in (0, 9) for x, y in path_2_3),
"path should detour around the x=2 wall via row 0 or row 9")
# Path cells must have been repainted with PATH_COLOR (except the entity endpoints)
mid = path_2_3[len(path_2_3) // 2]
if mid not in ((0, 5), (6, 4)):
c = color_layer.at(*mid)
check((c.r, c.g, c.b) == (PATH_COLOR.r, PATH_COLOR.g, PATH_COLOR.b),
f"path cell {mid} not highlighted with PATH_COLOR, got ({c.r},{c.g},{c.b})")
ec = color_layer.at(0, 5)
check((ec.r, ec.g, ec.b) == (ENTITY_COLORS[2].r, ENTITY_COLORS[2].g, ENTITY_COLORS[2].b),
"destination entity cell not painted with its entity color")
# Entity 1 is sealed inside the walled room: no path in or out.
print("\nSelect: first=1 (key '1'), second=C (entity 3)")
press(mcrfpy.Key.NUM_1)
press(mcrfpy.Key.C)
path_1_3 = last_path
check(not path_1_3, f"entity 1 is walled in; expected no path to entity 3, got {path_1_3}")
check("No path" in info_text.text, f"info text should report no path, got {info_text.text!r}")
grid_data.clear_dijkstra_maps()
d_sealed = grid_data.get_dijkstra_map(root=entity_cell(entities[0])).distance(entity_cell(entities[1]))
check(d_sealed is None, f"distance from sealed entity 1 to entity 2 should be None, got {d_sealed}")
# Space clears the selection and restores floor colors
print("\nSelect: Space (clear)")
press(mcrfpy.Key.SPACE)
check(first_point is None and second_point is None, "Space should clear both selections")
fc = color_layer.at(0, 0)
check((fc.r, fc.g, fc.b) == (FLOOR_COLOR.r, FLOOR_COLOR.g, FLOOR_COLOR.b),
"clear_path_highlight should restore FLOOR_COLOR on walkable cells")
wc = color_layer.at(2, 1)
check((wc.r, wc.g, wc.b) == (WALL_COLOR.r, WALL_COLOR.g, WALL_COLOR.b),
"wall cells must keep WALL_COLOR after clearing the path")
# Render once (headless render is free of sim time) to prove the scene draws.
press(mcrfpy.Key.NUM_2)
press(mcrfpy.Key.C)
automation.screenshot("dijkstra_interactive.png")
if failures:
print(f"\nFAIL: {len(failures)} check(s) failed")
sys.exit(1)
print("\nPASS")
sys.exit(0)

View file

@ -1,23 +1,36 @@
#!/usr/bin/env python3
"""
Enhanced Dijkstra Pathfinding Interactive Demo
==============================================
Enhanced Dijkstra Pathfinding Interactive Demo (headless-driven test)
====================================================================
Interactive visualization with entity pathfinding animations.
Controls:
Controls (when run with a window):
- Press 1/2/3 to select the first entity
- Press A/B/C to select the second entity
- Press A/B/C to select the second entity
- Space to clear selection
- M to make selected entity move along path
- P to pause/resume animation
- R to reset entity positions
- Q or ESC to quit
Under --headless --exec the same handlers are driven programmatically and the
resulting paths / animation / reset are asserted (see run_checks() at the bottom).
"""
import mcrfpy
import sys
import math
failures = []
def check(label, condition, detail=""):
if condition:
print(f" PASS: {label}")
else:
print(f" FAIL: {label} {detail}")
failures.append(label)
# Colors
WALL_COLOR = mcrfpy.Color(60, 30, 30)
@ -30,6 +43,9 @@ ENTITY_COLORS = [
mcrfpy.Color(100, 100, 255), # Entity 3 - Blue
]
GRID_W = 14
GRID_H = 10
# Global state
grid = None
color_layer = None
@ -42,33 +58,33 @@ animation_progress = 0.0
animation_speed = 2.0 # cells per second
original_positions = [] # Store original entity positions
# Define the map layout from user's specification
# . = floor, W = wall, E = entity position
map_layout = [
"..............", # Row 0
"..W.....WWWW..", # Row 1
"..W.W...W.EW..", # Row 2 (entity 1 is sealed inside the room)
"..W.....W..W..", # Row 3
"..W...E.WWWW..", # Row 4
"E.W...........", # Row 5
"..W...........", # Row 6
"..W...........", # Row 7
"..W.WWW.......", # Row 8
"..............", # Row 9
]
def create_map():
"""Create the interactive map with the layout specified by the user"""
global grid, color_layer, entities, original_positions
dijkstra_enhanced = mcrfpy.Scene("dijkstra_enhanced")
# Create grid - 14x10 as specified
grid = mcrfpy.Grid(grid_w=14, grid_h=10)
grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H))
grid.fill_color = mcrfpy.Color(0, 0, 0)
# Add color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# Define the map layout from user's specification
# . = floor, W = wall, E = entity position
map_layout = [
"..............", # Row 0
"..W.....WWWW..", # Row 1
"..W.W...W.EW..", # Row 2
"..W.....W..W..", # Row 3
"..W...E.WWWW..", # Row 4
"E.W...........", # Row 5
"..W...........", # Row 6
"..W...........", # Row 7
"..W.WWW.......", # Row 8
"..............", # Row 9
]
# Add color layer for cell coloring (GridPoint.color is gone; use a ColorLayer)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.add_layer(color_layer)
# Create the map
entity_positions = []
@ -80,12 +96,12 @@ def create_map():
# Wall
cell.walkable = False
cell.transparent = False
color_layer.set(x, y, WALL_COLOR)
color_layer.set((x, y), WALL_COLOR)
else:
# Floor
cell.walkable = True
cell.transparent = True
color_layer.set(x, y, FLOOR_COLOR)
color_layer.set((x, y), FLOOR_COLOR)
if char == 'E':
# Entity position
@ -95,26 +111,28 @@ def create_map():
entities = []
original_positions = []
for i, (x, y) in enumerate(entity_positions):
entity = mcrfpy.Entity((x, y), grid=grid)
entity = mcrfpy.Entity(grid_pos=(x, y), grid=grid)
entity.sprite_index = 49 + i # '1', '2', '3'
entities.append(entity)
original_positions.append((x, y))
return grid
def clear_path_highlight():
"""Clear any existing path highlighting"""
global current_path
# Reset all floor tiles to original color
for y in range(grid.grid_h):
for x in range(grid.grid_w):
for y in range(GRID_H):
for x in range(GRID_W):
cell = grid.at(x, y)
if cell.walkable:
color_layer.set(x, y, FLOOR_COLOR)
color_layer.set((x, y), FLOOR_COLOR)
current_path = []
def highlight_path():
"""Highlight the path between selected entities using entity.path_to()"""
global current_path
@ -129,8 +147,8 @@ def highlight_path():
entity1 = entities[first_point]
entity2 = entities[second_point]
# Use the new path_to method!
path = entity1.path_to(int(entity2.x), int(entity2.y))
# Use the path_to method! (entity.x/.y are PIXELS now; cells are grid_x/grid_y)
path = entity1.path_to(entity2.grid_x, entity2.grid_y)
if path:
current_path = path
@ -141,13 +159,13 @@ def highlight_path():
if cell.walkable:
# Use gradient for path visualization
if i < len(path) - 1:
color_layer.set(x, y, PATH_COLOR)
color_layer.set((x, y), PATH_COLOR)
else:
color_layer.set(x, y, VISITED_COLOR)
color_layer.set((x, y), VISITED_COLOR)
# Highlight start and end with entity colors
color_layer.set(int(entity1.x), int(entity1.y), ENTITY_COLORS[first_point])
color_layer.set(int(entity2.x), int(entity2.y), ENTITY_COLORS[second_point])
color_layer.set((entity1.grid_x, entity1.grid_y), ENTITY_COLORS[first_point])
color_layer.set((entity2.grid_x, entity2.grid_y), ENTITY_COLORS[second_point])
# Update info
info_text.text = f"Path: Entity {first_point+1} to Entity {second_point+1} - {len(path)} steps"
@ -155,102 +173,96 @@ def highlight_path():
info_text.text = f"No path between Entity {first_point+1} and Entity {second_point+1}"
current_path = []
def animate_movement(dt):
"""Animate entity movement along path"""
global animation_progress, animating, current_path
if not animating or not current_path or first_point is None:
return
entity = entities[first_point]
# Update animation progress
animation_progress += animation_speed * dt
# Calculate current position along path
path_index = int(animation_progress)
if path_index >= len(current_path):
# Animation complete
animating = False
animation_progress = 0.0
# Snap to final position
# Snap to final position (grid_pos = logical cell, draw_pos = visual)
if current_path:
final_x, final_y = current_path[-1]
entity.x = float(final_x)
entity.y = float(final_y)
entity.grid_pos = (final_x, final_y)
entity.draw_pos = (float(final_x), float(final_y))
return
# Interpolate between path points
if path_index < len(current_path) - 1:
curr_x, curr_y = current_path[path_index]
next_x, next_y = current_path[path_index + 1]
# Calculate interpolation factor
t = animation_progress - path_index
# Smooth interpolation
entity.x = curr_x + (next_x - curr_x) * t
entity.y = curr_y + (next_y - curr_y) * t
# Smooth interpolation (draw_pos is the fractional render position)
entity.draw_pos = (curr_x + (next_x - curr_x) * t,
curr_y + (next_y - curr_y) * t)
entity.grid_pos = (curr_x, curr_y)
else:
# At last point
entity.x, entity.y = current_path[path_index]
entity.grid_pos = current_path[path_index]
entity.draw_pos = (float(current_path[path_index][0]),
float(current_path[path_index][1]))
def handle_keypress(scene_name, keycode):
"""Handle keyboard input"""
def handle_keypress(key, action):
"""Handle keyboard input (#184: handlers receive Key and InputState enums)"""
global first_point, second_point, animating, animation_progress
if action != mcrfpy.InputState.PRESSED:
return
# Number keys for first entity
if keycode == 49: # '1'
first_point = 0
status_text.text = f"First: Entity 1 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
if key in (mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3):
first_point = {mcrfpy.Key.NUM_1: 0, mcrfpy.Key.NUM_2: 1, mcrfpy.Key.NUM_3: 2}[key]
status_text.text = f"First: Entity {first_point+1} | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
highlight_path()
elif keycode == 50: # '2'
first_point = 1
status_text.text = f"First: Entity 2 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
highlight_path()
elif keycode == 51: # '3'
first_point = 2
status_text.text = f"First: Entity 3 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
highlight_path()
# Letter keys for second entity
elif keycode == 65 or keycode == 97: # 'A' or 'a'
second_point = 0
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 1"
elif key in (mcrfpy.Key.A, mcrfpy.Key.B, mcrfpy.Key.C):
second_point = {mcrfpy.Key.A: 0, mcrfpy.Key.B: 1, mcrfpy.Key.C: 2}[key]
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity {second_point+1}"
highlight_path()
elif keycode == 66 or keycode == 98: # 'B' or 'b'
second_point = 1
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 2"
highlight_path()
elif keycode == 67 or keycode == 99: # 'C' or 'c'
second_point = 2
status_text.text = f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | Second: Entity 3"
highlight_path()
# Movement control
elif keycode == 77 or keycode == 109: # 'M' or 'm'
elif key == mcrfpy.Key.M:
if current_path and first_point is not None:
animating = True
animation_progress = 0.0
control_text.text = "Animation: MOVING (press P to pause)"
# Pause/Resume
elif keycode == 80 or keycode == 112: # 'P' or 'p'
elif key == mcrfpy.Key.P:
animating = not animating
control_text.text = f"Animation: {'MOVING' if animating else 'PAUSED'} (press P to {'pause' if animating else 'resume'})"
# Reset positions
elif keycode == 82 or keycode == 114: # 'R' or 'r'
elif key == mcrfpy.Key.R:
animating = False
animation_progress = 0.0
for i, entity in enumerate(entities):
entity.x, entity.y = original_positions[i]
entity.grid_pos = original_positions[i]
entity.draw_pos = (float(original_positions[i][0]),
float(original_positions[i][1]))
control_text.text = "Entities reset to original positions"
highlight_path() # Re-highlight path after reset
# Clear selection
elif keycode == 32: # Space
elif key == mcrfpy.Key.SPACE:
first_point = None
second_point = None
animating = False
@ -259,16 +271,27 @@ def handle_keypress(scene_name, keycode):
status_text.text = "Press 1/2/3 for first entity, A/B/C for second"
info_text.text = "Space to clear, Q to quit"
control_text.text = "Press M to move, P to pause, R to reset"
# Quit
elif keycode == 81 or keycode == 113 or keycode == 256: # Q/q/ESC
print("\nExiting enhanced Dijkstra demo...")
sys.exit(0)
# Timer callback for animation
def update_animation(timer, dt):
# Quit
elif key in (mcrfpy.Key.Q, mcrfpy.Key.ESCAPE):
print("\nExiting enhanced Dijkstra demo...")
sys.exit(0 if not failures else 1)
# Timer callback for animation. Callback is (timer, runtime_ms) -- runtime is
# cumulative, so derive the frame delta ourselves.
last_runtime_ms = 0.0
timer_ticks = 0
def update_animation(timer, runtime_ms):
"""Update animation state"""
animate_movement(dt / 1000.0) # Convert ms to seconds
global last_runtime_ms, timer_ticks
dt_ms = runtime_ms - last_runtime_ms
last_runtime_ms = runtime_ms
timer_ticks += 1
animate_movement(dt_ms / 1000.0) # Convert ms to seconds
# Create the visualization
print("Enhanced Dijkstra Pathfinding Demo")
@ -282,6 +305,8 @@ print(" R - Reset entity positions")
print(" Space - Clear selection")
print(" Q/ESC - Quit")
dijkstra_enhanced = mcrfpy.Scene("dijkstra_enhanced")
# Create map
grid = create_map()
@ -324,7 +349,7 @@ ui.append(legend2)
# Mark entity positions with colored indicators
for i, entity in enumerate(entities):
marker = mcrfpy.Caption(pos=(120 + int(entity.x) * 40 + 15, 60 + int(entity.y) * 40 + 10),
marker = mcrfpy.Caption(pos=(120 + entity.grid_x * 40 + 15, 60 + entity.grid_y * 40 + 10),
text=str(i+1))
marker.fill_color = ENTITY_COLORS[i]
marker.outline = 1
@ -343,4 +368,86 @@ dijkstra_enhanced.activate()
print("\nVisualization ready!")
print("Entities are at:")
for i, entity in enumerate(entities):
print(f" Entity {i+1}: ({int(entity.x)}, {int(entity.y)})")
print(f" Entity {i+1}: ({entity.grid_x}, {entity.grid_y})")
def press(key):
"""Simulate a key press through the scene's real handler"""
handle_keypress(key, mcrfpy.InputState.PRESSED)
def run_checks():
"""Headless driver: exercise the same code paths the keyboard drives."""
print("\n1. Map construction")
check("three entities placed at 'E' markers", len(entities) == 3,
f"got {len(entities)}")
check("entity positions match layout",
original_positions == [(10, 2), (6, 4), (0, 5)],
f"got {original_positions}")
check("walls are not walkable", not grid.at(2, 1).walkable)
check("floors are walkable", grid.at(0, 0).walkable)
# grid.layer(name) returns a wrapper around the same layer (not the same PyObject)
check("color layer attached", grid.layer("color").name == "color")
print("\n2. path_to() between entity 2 and entity 3 (reachable around the wall)")
press(mcrfpy.Key.NUM_2) # first = entity 2 @ (6,4)
press(mcrfpy.Key.C) # second = entity 3 @ (0,5)
check("path found", len(current_path) > 0, "path_to returned empty")
if current_path:
check("path ends at entity 3", tuple(current_path[-1]) == (0, 5),
f"got {current_path[-1]}")
walkable = all(grid.at(x, y).walkable for x, y in current_path)
check("every path cell is walkable", walkable)
contiguous = all(
max(abs(current_path[i+1][0] - current_path[i][0]),
abs(current_path[i+1][1] - current_path[i][1])) == 1
for i in range(len(current_path) - 1))
check("path steps are contiguous", contiguous)
check("info caption reports the path", "Path:" in info_text.text,
info_text.text)
print("\n3. path_to() into the sealed room (entity 1) has no solution")
saved_path = list(current_path)
press(mcrfpy.Key.A) # second = entity 1 @ (10,2), walled in
check("no path reported", len(current_path) == 0,
f"got {len(current_path)} steps")
check("info caption reports no path", "No path" in info_text.text,
info_text.text)
print("\n4. M drives the entity along the path (timer + step())")
press(mcrfpy.Key.C) # back to the reachable target
check("path restored", current_path == saved_path)
press(mcrfpy.Key.M)
check("animating flag set", animating is True)
# mcrfpy.step() is the headless clock; the 16ms timer fires once per step.
for _ in range(400):
mcrfpy.step(0.05)
if not animating:
break
check("animation timer fired", timer_ticks > 0, f"ticks={timer_ticks}")
check("animation completed", animating is False)
entity2 = entities[1]
check("entity 2 arrived at entity 3's cell",
(entity2.grid_x, entity2.grid_y) == (0, 5),
f"got ({entity2.grid_x}, {entity2.grid_y})")
print("\n5. R resets entities to their original positions")
press(mcrfpy.Key.R)
positions = [(e.grid_x, e.grid_y) for e in entities]
check("positions restored", positions == original_positions,
f"got {positions}")
print("\n6. Space clears the selection and the highlight")
press(mcrfpy.Key.SPACE)
check("selection cleared",
first_point is None and second_point is None and current_path == [])
run_checks()
if failures:
print(f"\nFAIL: {len(failures)} check(s) failed: {failures}")
sys.exit(1)
print("\nPASS")
sys.exit(0)

View file

@ -4,27 +4,45 @@ Dijkstra Pathfinding Test - Headless
====================================
Tests all Dijkstra functionality and generates a screenshot.
API notes (updated for the current engine):
- Dijkstra maps come from grid.get_dijkstra_map(root=...) -> DijkstraMap.
The old compute_dijkstra / get_dijkstra_distance / get_dijkstra_path
methods are gone; DijkstraMap.distance(pos) and .path_from(pos) replace them.
- GridPoint has no .color; per-cell coloring is done with a ColorLayer.
- Entity.x/.y are pixel coordinates; the logical cell is entity.cell_pos.
- Headless has no clock of its own: mcrfpy.step(dt) drives timers.
"""
import mcrfpy
from mcrfpy import automation
import sys
failures = []
def check(cond, msg):
if not cond:
failures.append(msg)
print(f"FAIL: {msg}")
return cond
def create_test_map():
"""Create a test map with obstacles"""
dijkstra_test = mcrfpy.Scene("dijkstra_test")
# Create grid
grid = mcrfpy.Grid(grid_w=20, grid_h=12)
grid = mcrfpy.Grid(grid_size=(20, 12))
grid.fill_color = mcrfpy.Color(0, 0, 0)
# Per-cell coloring now lives on a ColorLayer, not on GridPoint
color_layer = mcrfpy.ColorLayer(name="cells")
grid.add_layer(color_layer)
# Initialize all cells as walkable floor
for y in range(12):
for x in range(20):
grid.at(x, y).walkable = True
grid.at(x, y).transparent = True
grid.at(x, y).color = mcrfpy.Color(200, 200, 220)
color_layer.set((x, y), mcrfpy.Color(200, 200, 220))
# Add walls to create interesting paths
walls = [
# Vertical wall in the middle
@ -35,11 +53,11 @@ def create_test_map():
# Some scattered obstacles
(5, 2), (15, 2), (5, 9), (15, 9)
]
for x, y in walls:
grid.at(x, y).walkable = False
grid.at(x, y).color = mcrfpy.Color(60, 30, 30)
color_layer.set((x, y), mcrfpy.Color(60, 30, 30))
# Place test entities
entities = []
positions = [(2, 2), (17, 2), (9, 10)]
@ -48,78 +66,125 @@ def create_test_map():
mcrfpy.Color(100, 255, 100), # Green
mcrfpy.Color(100, 100, 255) # Blue
]
for i, (x, y) in enumerate(positions):
entity = mcrfpy.Entity(x, y)
entity = mcrfpy.Entity(grid_pos=(x, y))
entity.sprite_index = 49 + i # '1', '2', '3'
grid.entities.append(entity)
entities.append(entity)
# Mark entity positions
grid.at(x, y).color = colors[i]
return grid, entities
color_layer.set((x, y), colors[i])
def test_dijkstra(grid, entities):
# Walkability was mutated after grid construction; drop any cached maps
grid.clear_dijkstra_maps()
return grid, color_layer, entities, walls
def test_dijkstra(grid, color_layer, entities, walls):
"""Test Dijkstra pathfinding between all entity pairs"""
results = []
for i in range(len(entities)):
for j in range(len(entities)):
if i != j:
# Compute Dijkstra from entity i
e1 = entities[i]
e2 = entities[j]
grid.compute_dijkstra(int(e1.x), int(e1.y))
src = (int(e1.cell_pos.x), int(e1.cell_pos.y))
dst = (int(e2.cell_pos.x), int(e2.cell_pos.y))
dmap = grid.get_dijkstra_map(root=src)
check(dmap.root == mcrfpy.Vector(src[0], src[1]),
f"dijkstra map root should be {src}, got {dmap.root}")
# Get distance and path to entity j
distance = grid.get_dijkstra_distance(int(e2.x), int(e2.y))
path = grid.get_dijkstra_path(int(e2.x), int(e2.y))
distance = dmap.distance(dst)
path = list(dmap.path_from(dst))
check(distance is not None, f"no distance from {src} to {dst}")
check(len(path) > 0, f"no path from {src} to {dst}")
if path:
# Root is reachable and the path is a real, connected walk
check(distance > 0, f"distance {src}->{dst} should be positive")
# Number of steps can't be fewer than the Chebyshev distance
cheb = max(abs(src[0] - dst[0]), abs(src[1] - dst[1]))
check(len(path) >= cheb,
f"path {src}->{dst} too short: {len(path)} < {cheb}")
cells = [(int(v.x), int(v.y)) for v in path]
for cx, cy in cells:
check(grid.at(cx, cy).walkable,
f"path {src}->{dst} crosses unwalkable cell {(cx, cy)}")
check((cx, cy) not in walls,
f"path {src}->{dst} crosses wall {(cx, cy)}")
# Consecutive path cells must be adjacent (8-way)
for a, b in zip(cells, cells[1:]):
check(max(abs(a[0] - b[0]), abs(a[1] - b[1])) == 1,
f"path {src}->{dst} jumps from {a} to {b}")
# The map is ROOTED at src and queried FROM dst, so the walk runs
# dst -> src. Per the #375 convention (shared with find_path) it
# EXCLUDES its origin (dst) and ENDS at its destination (src).
check(cells[-1] == src,
f"path from {dst} should end at the root {src}, ended at {cells[-1]}")
check(dst not in cells,
f"path from {dst} should exclude its origin {dst}")
check(max(abs(cells[0][0] - dst[0]), abs(cells[0][1] - dst[1])) == 1,
f"path from {dst} should begin one step away, began at {cells[0]}")
results.append(f"Path {i+1}{j+1}: {len(path)} steps, {distance:.1f} units")
# Color one interesting path
if i == 0 and j == 2: # Path from 1 to 3
for x, y in path[1:-1]: # Skip endpoints
if grid.at(x, y).walkable:
grid.at(x, y).color = mcrfpy.Color(200, 250, 220)
for cx, cy in cells:
if (cx, cy) not in (src, dst) and grid.at(cx, cy).walkable:
color_layer.set((cx, cy), mcrfpy.Color(200, 250, 220))
else:
results.append(f"Path {i+1}{j+1}: No path found!")
# Walls are unreachable: distance must be None, not a bogus number
root = (int(entities[0].cell_pos.x), int(entities[0].cell_pos.y))
dmap = grid.get_dijkstra_map(root=root)
check(dmap.distance((10, 4)) is None,
"distance to an unwalkable wall cell should be None")
check(dmap.distance(root) == 0.0,
f"distance from root to itself should be 0, got {dmap.distance(root)}")
# Dijkstra distances are symmetric on a uniform-cost grid
a = (int(entities[0].cell_pos.x), int(entities[0].cell_pos.y))
b = (int(entities[1].cell_pos.x), int(entities[1].cell_pos.y))
d_ab = grid.get_dijkstra_map(root=a).distance(b)
d_ba = grid.get_dijkstra_map(root=b).distance(a)
check(abs(d_ab - d_ba) < 0.01,
f"distance should be symmetric: {a}->{b}={d_ab}, {b}->{a}={d_ba}")
return results
def run_test(timer, runtime):
"""Timer callback to run tests and take screenshot"""
# Run pathfinding tests
results = test_dijkstra(grid, entities)
"""Timer callback to run tests"""
global results
results = test_dijkstra(grid, color_layer, entities, walls)
# Update display with results
y_pos = 380
for result in results:
caption = mcrfpy.Caption(result, 50, y_pos)
caption = mcrfpy.Caption(text=result, pos=(50, y_pos))
caption.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(caption)
y_pos += 20
# Take screenshot (one-shot timer)
screenshot_timer = mcrfpy.Timer("screenshot", lambda t, rt: take_screenshot(), 500, once=True)
def take_screenshot():
"""Take screenshot and exit"""
try:
automation.screenshot("dijkstra_test.png")
print("Screenshot saved: dijkstra_test.png")
except Exception as e:
print(f"Screenshot failed: {e}")
# Exit
sys.exit(0)
# Create test map
print("Creating Dijkstra pathfinding test...")
grid, entities = create_test_map()
grid, color_layer, entities, walls = create_test_map()
results = None
# Set up UI
dijkstra_test = mcrfpy.Scene("dijkstra_test")
ui = dijkstra_test.children
ui.append(grid)
@ -133,14 +198,40 @@ title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
# Add legend
legend = mcrfpy.Caption(pos=(50, 360), text="Red=Entity1 Green=Entity2 Blue=Entity3 Cyan=Path 13")
legend = mcrfpy.Caption(pos=(50, 360), text="Red=Entity1 Green=Entity2 Blue=Entity3 Cyan=Path 1->3")
legend.fill_color = mcrfpy.Color(180, 180, 180)
ui.append(legend)
# Set scene
dijkstra_test.activate()
# Run test after scene loads (one-shot timer)
# Run test after scene loads (one-shot timer). Headless has no clock of its own,
# so drive the timer forward explicitly with mcrfpy.step().
test_timer = mcrfpy.Timer("test", run_test, 100, once=True)
print("Running Dijkstra tests...")
for _ in range(10):
mcrfpy.step(0.05)
if results is not None:
break
print("Running Dijkstra tests...")
check(results is not None, "timer callback never fired; Dijkstra tests did not run")
if results:
for line in results:
print(line)
check(len(results) == 6, f"expected 6 entity-pair results, got {len(results)}")
check(all("No path found" not in r for r in results),
"every entity pair should be mutually reachable")
# Rendering is orthogonal to sim time; force a render for the screenshot
try:
automation.screenshot("dijkstra_test.png")
print("Screenshot saved: dijkstra_test.png")
except Exception as e:
check(False, f"Screenshot failed: {e}")
if failures:
print(f"FAILED ({len(failures)} checks)")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,29 +1,67 @@
#!/usr/bin/env python3
"""Force Python to be non-interactive"""
"""Verify that --exec scripts run Python non-interactively.
Historically this file *forced* non-interactive mode (deleting sys.ps1/ps2 and
mangling termios) because the embedded interpreter could drop into a REPL and
hang the engine. That workaround is gone; the property it was protecting is now
an engine guarantee, so this asserts it instead of forcing it:
- the interpreter is not in REPL mode (no sys.ps1 / sys.ps2)
- it will not drop into a REPL after the script (-i / inspect not set)
- stdin is not an interactive terminal, and is not consumed by the engine
- the script, not a prompt, decides when the process ends (#350 exit contract)
"""
import sys
import os
import mcrfpy
print("Attempting to force non-interactive mode...")
failures = []
# Remove ps1/ps2 if they exist
if hasattr(sys, 'ps1'):
delattr(sys, 'ps1')
if hasattr(sys, 'ps2'):
delattr(sys, 'ps2')
# Set environment variable
os.environ['PYTHONSTARTUP'] = ''
def check(label, condition, detail=""):
if condition:
print(" ok : %s" % label)
else:
print(" FAIL : %s %s" % (label, detail))
failures.append(label)
# Try to set stdin to non-interactive
try:
import fcntl
import termios
# Make stdin non-interactive by removing ICANON flag
attrs = termios.tcgetattr(0)
attrs[3] = attrs[3] & ~termios.ICANON
termios.tcsetattr(0, termios.TCSANOW, attrs)
print("Modified terminal attributes")
except:
print("Could not modify terminal attributes")
print("Script complete")
print("Checking non-interactive execution of --exec scripts...")
# 1. REPL prompts only exist when the interpreter is interactive. Their presence
# would mean the engine handed us an interactive interpreter.
check("sys.ps1 absent", not hasattr(sys, "ps1"))
check("sys.ps2 absent", not hasattr(sys, "ps2"))
# 2. Interpreter flags: -i / PYTHONINSPECT would make Python drop to a prompt
# *after* the script finishes, hanging a headless run.
check("sys.flags.interactive == 0", sys.flags.interactive == 0,
"got %r" % (sys.flags.interactive,))
check("sys.flags.inspect == 0", sys.flags.inspect == 0,
"got %r" % (sys.flags.inspect,))
check("PYTHONINSPECT not set in env", not os.environ.get("PYTHONINSPECT"))
# 3. stdin must not be an interactive terminal driving a REPL.
# NOTE: deliberately no blocking read here -- under a test runner stdin is an
# open pipe that never sends EOF, so sys.stdin.read() would hang forever. The
# thing worth asserting is that no prompt is bound to it.
check("stdin is not a tty", sys.stdin is None or not sys.stdin.isatty())
# 4. The engine must not be running its own read-eval-print loop over our script:
# module scope is ordinary script scope, and the headless clock only advances
# when we ask it to (#350). One step() must not spin into an interactive loop.
scene = mcrfpy.Scene("force_non_interactive")
mcrfpy.current_scene = scene
fired = []
mcrfpy.Timer("tick", lambda timer, runtime: fired.append(runtime), 50)
for _ in range(4):
mcrfpy.step(0.05)
check("headless clock advances under script control", len(fired) >= 1,
"timer fired %d time(s)" % len(fired))
if failures:
print("FAIL: %d check(s) failed: %s" % (len(failures), ", ".join(failures)))
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,40 +1,62 @@
#!/usr/bin/env python3
"""
Interactive Visibility Demo
==========================
Interactive Visibility Test
===========================
Controls:
- WASD: Move the player (green @)
- Arrow keys: Move enemy (red E)
- Tab: Cycle perspective (Omniscient Player Enemy Omniscient)
- Space: Update visibility for current entity
- R: Reset positions
Originally an interactive demo (WASD moves the player, arrows move the enemy,
Tab cycles perspective, Space recomputes visibility, R resets). Headless mode
never delivers real keystrokes, so the same key handler is now driven with
mcrfpy.automation.keyDown/keyUp and the resulting state is asserted.
Covers:
- walls block movement (walkable) and sight (transparent)
- Entity.update_visibility() / Entity.perspective_map (UNKNOWN/DISCOVERED/VISIBLE)
- moving an entity discovers new cells and demotes VISIBLE -> DISCOVERED
- Grid.perspective cycling (None -> player -> enemy -> None)
API notes (current contract):
- grid.add_layer() takes a layer OBJECT, no kwargs; GridPoint has no .color,
so cell coloring goes through a ColorLayer.
- Grid.perspective is an Entity (or None), not an integer index.
"""
import mcrfpy
from mcrfpy import automation
import sys
GRID_W, GRID_H = 30, 20
failures = []
def check(cond, msg):
if not cond:
failures.append(msg)
print(f"FAIL: {msg}")
return cond
# Create scene and grid
visibility_demo = mcrfpy.Scene("visibility_demo")
grid = mcrfpy.Grid(grid_w=30, grid_h=20)
grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H), pos=(50, 100), size=(900, 600))
grid.fill_color = mcrfpy.Color(20, 20, 30) # Dark background
# Add color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# Add color layer for cell coloring (GridPoint.color no longer exists)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.grid_data.add_layer(color_layer)
# Initialize grid - all walkable and transparent
for y in range(20):
for x in range(30):
for y in range(GRID_H):
for x in range(GRID_W):
cell = grid.at(x, y)
cell.walkable = True
cell.transparent = True
color_layer.set(x, y, mcrfpy.Color(100, 100, 120)) # Floor color
color_layer.set((x, y), mcrfpy.Color(100, 100, 120)) # Floor color
# Create walls
walls = [
# Central cross
[(15, y) for y in range(8, 12)],
[(x, 10) for x in range(13, 18)],
# Central cross - a solid wall separating left half from right half
[(15, y) for y in range(0, GRID_H)],
# Rooms
# Top-left room
@ -54,33 +76,42 @@ walls = [
[(28, y) for y in range(15, 18)] + [(x, 18) for x in range(22, 28)],
]
wall_cells = set()
for wall_group in walls:
for x, y in wall_group:
if 0 <= x < 30 and 0 <= y < 20:
if 0 <= x < GRID_W and 0 <= y < GRID_H:
cell = grid.at(x, y)
cell.walkable = False
cell.transparent = False
color_layer.set(x, y, mcrfpy.Color(40, 20, 20)) # Wall color
color_layer.set((x, y), mcrfpy.Color(40, 20, 20)) # Wall color
wall_cells.add((x, y))
# Create entities
player = mcrfpy.Entity((5, 10), grid=grid)
player = mcrfpy.Entity(grid_pos=(5, 10))
player.sprite_index = 64 # @
enemy = mcrfpy.Entity((25, 10), grid=grid)
grid.grid_data.entities.append(player)
enemy = mcrfpy.Entity(grid_pos=(25, 10))
enemy.sprite_index = 69 # E
grid.grid_data.entities.append(enemy)
# Short FOV radius: the default (10) is wide enough that the player's start cell
# stays in FOV from the far side of the room, so cells could never demote from
# VISIBLE to DISCOVERED. update_visibility() reads GridData.fov_radius (Entity.
# sight_radius only feeds the TARGET trigger).
grid.grid_data.fov_radius = 4
# Update initial visibility
player.update_visibility()
enemy.update_visibility()
# Global state
current_perspective = -1
# Global state: perspective is now an Entity | None, not an int index
perspectives = [None, player, enemy]
perspective_names = ["Omniscient", "Player", "Enemy"]
current_perspective = 0
# UI Setup
ui = visibility_demo.children
ui.append(grid)
grid.pos = (50, 100)
grid.size = (900, 600) # 30*30, 20*30
# Title
title = mcrfpy.Caption(pos=(350, 20), text="Interactive Visibility Demo")
@ -92,10 +123,6 @@ perspective_label = mcrfpy.Caption(pos=(50, 50), text="Perspective: Omniscient")
perspective_label.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(perspective_label)
controls = mcrfpy.Caption(pos=(50, 730), text="WASD: Move player | Arrows: Move enemy | Tab: Cycle perspective | Space: Update visibility | R: Reset")
controls.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(controls)
player_info = mcrfpy.Caption(pos=(700, 50), text="Player: (5, 10)")
player_info.fill_color = mcrfpy.Color(100, 255, 100)
ui.append(player_info)
@ -104,41 +131,41 @@ enemy_info = mcrfpy.Caption(pos=(700, 70), text="Enemy: (25, 10)")
enemy_info.fill_color = mcrfpy.Color(255, 100, 100)
ui.append(enemy_info)
# Helper functions
def move_entity(entity, dx, dy):
"""Move entity if target is walkable"""
new_x = int(entity.x + dx)
new_y = int(entity.y + dy)
if 0 <= new_x < 30 and 0 <= new_y < 20:
new_x = int(entity.grid_x + dx)
new_y = int(entity.grid_y + dy)
if 0 <= new_x < GRID_W and 0 <= new_y < GRID_H:
cell = grid.at(new_x, new_y)
if cell.walkable:
entity.x = new_x
entity.y = new_y
entity.grid_pos = (new_x, new_y)
entity.update_visibility()
return True
return False
def update_info():
"""Update info displays"""
player_info.text = f"Player: ({int(player.x)}, {int(player.y)})"
enemy_info.text = f"Enemy: ({int(enemy.x)}, {int(enemy.y)})"
player_info.text = f"Player: ({player.grid_x}, {player.grid_y})"
enemy_info.text = f"Enemy: ({enemy.grid_x}, {enemy.grid_y})"
def cycle_perspective():
"""Cycle through perspectives"""
"""Cycle through perspectives: Omniscient -> Player -> Enemy -> Omniscient"""
global current_perspective
# Cycle: -1 → 0 → 1 → -1
current_perspective = (current_perspective + 2) % 3 - 1
grid.perspective = current_perspective
name = perspective_names[current_perspective + 1]
perspective_label.text = f"Perspective: {name}"
current_perspective = (current_perspective + 1) % 3
grid.perspective = perspectives[current_perspective]
perspective_label.text = f"Perspective: {perspective_names[current_perspective]}"
# Key handlers
def handle_keys(key, state):
"""Handle keyboard input"""
if state == mcrfpy.InputState.RELEASED: return
if state == mcrfpy.InputState.RELEASED:
return
# Player movement (WASD)
if key == mcrfpy.Key.W:
move_entity(player, 0, -1)
@ -167,37 +194,120 @@ def handle_keys(key, state):
elif key == mcrfpy.Key.SPACE:
player.update_visibility()
enemy.update_visibility()
print("Updated visibility for both entities")
# R to reset
elif key == mcrfpy.Key.R:
player.x, player.y = 5, 10
enemy.x, enemy.y = 25, 10
player.grid_pos = (5, 10)
enemy.grid_pos = (25, 10)
player.update_visibility()
enemy.update_visibility()
update_info()
print("Reset positions")
# Q to quit
elif key == mcrfpy.Key.Q:
print("Exiting...")
sys.exit(0)
update_info()
# Set scene first
visibility_demo.activate()
# Register key handler (operates on current scene)
# Set scene first, then register key handler
visibility_demo.activate()
visibility_demo.on_key = handle_keys
print("Interactive Visibility Demo")
print("===========================")
print("WASD: Move player (green @)")
print("Arrows: Move enemy (red E)")
print("Tab: Cycle perspective")
print("Space: Update visibility")
print("R: Reset positions")
print("Q: Quit")
print("\nCurrent perspective: Omniscient (shows all)")
print("Try moving entities and switching perspectives!")
def press(key):
"""Deliver a real key event to the scene handler."""
automation.keyDown(key)
automation.keyUp(key)
UNKNOWN = mcrfpy.Perspective.UNKNOWN
DISCOVERED = mcrfpy.Perspective.DISCOVERED
VISIBLE = mcrfpy.Perspective.VISIBLE
def seen(entity, x, y):
return entity.perspective_map.get(x, y)
def main():
# --- Initial visibility: player sees its own cell, and cannot see through
# the solid wall at x=15 into the enemy's half of the map.
check(seen(player, 5, 10) == VISIBLE, "player should see its own cell")
check(seen(player, 25, 10) == UNKNOWN,
f"player should not see across the wall; got {seen(player, 25, 10)}")
check(seen(enemy, 25, 10) == VISIBLE, "enemy should see its own cell")
check(seen(enemy, 5, 10) == UNKNOWN,
f"enemy should not see across the wall; got {seen(enemy, 5, 10)}")
check(player not in enemy.visible_entities(),
"wall should hide the player from the enemy")
# --- WASD moves the player; cells it leaves behind stay DISCOVERED.
press('d')
check((player.grid_x, player.grid_y) == (6, 10),
f"'d' should move player right; got {(player.grid_x, player.grid_y)}")
press('s')
check((player.grid_x, player.grid_y) == (6, 11),
f"'s' should move player down; got {(player.grid_x, player.grid_y)}")
check(player_info.text == "Player: (6, 11)", f"caption not updated: {player_info.text}")
# --- Walls block movement: walk the player into the central wall.
while player.grid_x < 14:
before = player.grid_x
press('d')
check(player.grid_x == before + 1, "player should walk freely up to the wall")
check((player.grid_x, player.grid_y) == (14, 11), "player should be adjacent to the wall")
press('d')
check((player.grid_x, player.grid_y) == (14, 11),
f"wall must block movement; player reached {(player.grid_x, player.grid_y)}")
# --- Having walked across the room, previously-seen cells are remembered
# but no longer visible.
check(seen(player, 5, 10) == DISCOVERED,
f"start cell should be remembered, not visible; got {seen(player, 5, 10)}")
check(seen(player, 14, 11) == VISIBLE, "player's current cell should be visible")
check(seen(player, 25, 10) == UNKNOWN, "the wall still hides the enemy's half")
# --- Arrow keys move the enemy.
press('left')
check((enemy.grid_x, enemy.grid_y) == (24, 10),
f"left arrow should move enemy; got {(enemy.grid_x, enemy.grid_y)}")
press('up')
check((enemy.grid_x, enemy.grid_y) == (24, 9),
f"up arrow should move enemy; got {(enemy.grid_x, enemy.grid_y)}")
# --- Space recomputes visibility without moving anything.
pos_before = (player.grid_x, player.grid_y, enemy.grid_x, enemy.grid_y)
press('space')
check((player.grid_x, player.grid_y, enemy.grid_x, enemy.grid_y) == pos_before,
"space must not move entities")
check(seen(player, 14, 11) == VISIBLE, "player still sees its own cell after space")
# --- Tab cycles perspective: Omniscient -> Player -> Enemy -> Omniscient.
check(grid.perspective is None, "grid starts omniscient")
press('tab')
check(grid.perspective is player, f"tab 1 -> player; got {grid.perspective}")
check(perspective_label.text == "Perspective: Player", perspective_label.text)
press('tab')
check(grid.perspective is enemy, f"tab 2 -> enemy; got {grid.perspective}")
press('tab')
check(grid.perspective is None, f"tab 3 -> omniscient; got {grid.perspective}")
check(perspective_label.text == "Perspective: Omniscient", perspective_label.text)
# --- R resets positions and visibility.
press('r')
check((player.grid_x, player.grid_y) == (5, 10), "R resets the player")
check((enemy.grid_x, enemy.grid_y) == (25, 10), "R resets the enemy")
check(seen(player, 5, 10) == VISIBLE, "player sees its cell again after reset")
check(seen(player, 14, 11) == DISCOVERED,
f"cells left behind stay remembered after reset; got {seen(player, 14, 11)}")
# --- Perspective rendering path must not crash.
grid.perspective = player
automation.screenshot("interactive_visibility.png")
grid.perspective = None
if failures:
print(f"FAIL: {len(failures)} check(s) failed")
sys.exit(1)
print("PASS")
sys.exit(0)
if __name__ == "__main__":
main()

View file

@ -4,15 +4,28 @@
import mcrfpy
import sys
failures = []
def check(cond, msg):
if cond:
print(f" ok: {msg}")
else:
print(f" FAIL: {msg}")
failures.append(msg)
# Create scene and grid
print("Creating scene...")
vis_test = mcrfpy.Scene("vis_test")
print("Creating grid...")
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(50, 50), size=(300, 300))
# Add color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# (GridPoint has no .color anymore; per-cell color lives in a ColorLayer)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.add_layer(color_layer)
check(grid.layer("color") is not None, "color layer attached to grid")
check(tuple(color_layer.grid_size) == (10, 10), "color layer auto-resized to grid")
# Initialize grid
print("Initializing grid...")
@ -21,29 +34,61 @@ for y in range(10):
cell = grid.at(x, y)
cell.walkable = True
cell.transparent = True
color_layer.set(x, y, mcrfpy.Color(100, 100, 120))
color_layer.set((x, y), mcrfpy.Color(100, 100, 120))
_c = color_layer.at(3, 7)
check((_c.r, _c.g, _c.b) == (100, 100, 120), "color layer stores per-cell color")
# An opaque wall so visibility has something to actually occlude
for y in range(10):
wall = grid.at(2, y)
wall.transparent = False
wall.walkable = False
# Create entity
print("Creating entity...")
entity = mcrfpy.Entity((5, 5), grid=grid)
entity = mcrfpy.Entity(grid_pos=(5, 5))
entity.sprite_index = 64
grid.entities.append(entity)
check(entity.grid is grid.grid_data, "entity.grid is the shared GridData (#313/#361)")
print("Updating visibility...")
entity.update_visibility()
# entity.gridstate -> entity.perspective_map (UNKNOWN/DISCOVERED/VISIBLE)
pmap = entity.perspective_map
check(pmap.get((5, 5)) == mcrfpy.Perspective.VISIBLE, "entity's own cell is VISIBLE")
check(pmap.get((9, 5)) == mcrfpy.Perspective.VISIBLE, "open cell on entity's side is VISIBLE")
check(pmap.get((0, 5)) == mcrfpy.Perspective.UNKNOWN, "cell behind the wall is UNKNOWN")
check(grid.is_in_fov((5, 5)), "grid.is_in_fov agrees the entity's cell is lit")
# Set up UI
print("Setting up UI...")
ui = vis_test.children
ui.append(grid)
grid.pos = (50, 50)
grid.size = (300, 300)
check(len(ui) == 1, "grid appended to scene UI")
# Test perspective
# perspective is now an Entity (fog-of-war source) or None (omniscient),
# not the old integer entity index (-1 == omniscient).
print("Testing perspective...")
grid.perspective = -1 # Omniscient
grid.perspective = entity
check(grid.perspective is entity, "perspective bound to entity")
check(grid.perspective_enabled, "perspective mode enabled")
grid.perspective = None # Omniscient
print(f"Perspective set to: {grid.perspective}")
check(grid.perspective is None, "perspective cleared")
check(not grid.perspective_enabled, "omniscient: perspective mode disabled")
print("Setting scene...")
vis_test.activate()
check(mcrfpy.current_scene is vis_test, "scene activated")
print("Ready!")
print("Ready!")
if failures:
print(f"FAIL ({len(failures)} checks failed)")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,39 +1,87 @@
#!/usr/bin/env python3
"""Simple visibility test without entity append"""
"""Simple visibility test without entity append
Original intent: verify an Entity associated with a Grid has per-entity visibility
memory that is initialized, that entity.at(x, y) reports per-cell visibility state,
and that entity.update_visibility() recomputes it.
API updates (current contract):
- entity.gridstate -> entity.perspective_map (a 3-state DiscreteMap:
UNKNOWN / DISCOVERED / VISIBLE, lazily allocated once the entity has a grid)
- the per-cell object no longer carries .visible / .discovered; entity.at(x, y)
returns a GridPoint only when the cell is currently visible, else None.
Discovered-but-not-visible cells are read from perspective_map directly.
- Entity(grid_pos=...) + grid.entities.append(entity) replaces Entity((x,y), grid=grid)
"""
import mcrfpy
import sys
print("Simple visibility test...")
failures = []
def check(label, condition):
if condition:
print(f" PASS: {label}")
else:
print(f" FAIL: {label}")
failures.append(label)
# Create scene and grid
simple = mcrfpy.Scene("simple")
print("Scene created")
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
grid = mcrfpy.Grid(grid_size=(5, 5))
print("Grid created")
# Column x == 1 is an opaque wall; everything else is open floor.
for x in range(5):
for y in range(5):
point = grid.at(x, y)
point.walkable = True
point.transparent = (x != 1)
# Create entity with grid association
entity = mcrfpy.Entity((2, 2), grid=grid)
print(f"Entity created at ({entity.x}, {entity.y})")
entity = mcrfpy.Entity(grid_pos=(3, 2))
grid.entities.append(entity)
print(f"Entity created at {entity.cell_pos}")
# Check if gridstate is initialized
print(f"Gridstate length: {len(entity.gridstate)}")
# Check that per-entity visibility memory is initialized
state_map = entity.perspective_map
print(f"Perspective map size: {state_map.size}")
check("perspective_map allocated", state_map is not None)
check("perspective_map matches grid size", tuple(state_map.size) == (5, 5))
check("perspective_map starts all UNKNOWN", state_map.count(mcrfpy.Perspective.UNKNOWN) == 25)
# Try to access at method
try:
state = entity.at(0, 0)
print(f"at(0,0) returned: {state}")
print(f"visible: {state.visible}, discovered: {state.discovered}")
except Exception as e:
print(f"Error in at(): {e}")
# at() before any FOV computation: nothing is visible yet
check("at(0, 0) is None before update_visibility", entity.at(0, 0) is None)
# Try update_visibility
try:
entity.update_visibility()
print("update_visibility() succeeded")
except Exception as e:
print(f"Error in update_visibility(): {e}")
# Compute visibility from (3, 2)
entity.update_visibility()
print("update_visibility() succeeded")
check("own cell is VISIBLE", state_map[3, 2] == mcrfpy.Perspective.VISIBLE)
check("at(3, 2) returns the visible GridPoint", entity.at(3, 2) is not None)
check("cell behind the wall stays UNKNOWN", state_map[0, 2] == mcrfpy.Perspective.UNKNOWN)
check("at(0, 2) is None (occluded)", entity.at(0, 2) is None)
# Move across the wall: previously seen cells must be remembered as DISCOVERED,
# not still reported as visible.
entity.grid_pos = (0, 2)
entity.update_visibility()
print(f"Entity moved to {entity.cell_pos}")
check("new cell is VISIBLE", state_map[0, 2] == mcrfpy.Perspective.VISIBLE)
check("old cell demoted to DISCOVERED", state_map[3, 2] == mcrfpy.Perspective.DISCOVERED)
check("at(3, 2) is None once occluded again", entity.at(3, 2) is None)
if failures:
print(f"Test complete - {len(failures)} FAILURE(S): {failures}")
sys.exit(1)
print("Test complete")
sys.exit(0)
print("PASS")
sys.exit(0)

View file

@ -1,23 +1,107 @@
#!/usr/bin/env python3
"""Trace interactive mode by monkey-patching"""
"""Tripwire test: the engine must never consult the REPL prompt while running.
Originally this was a debugging aid -- it monkey-patched sys.ps1 with an object
that printed a stack trace when accessed, then did "nothing else, let the game
run", so a human could see whether the engine dropped into an interactive REPL.
It never asserted anything and never declared an exit status.
The tripwire is still the right instrument; it just needs to be checked. sys.ps1
is only read by a read-eval-print loop, so any access to it during an --exec run
means the embedded interpreter fell into interactive mode (which, headless, hangs
the process on stdin). This installs the detector, drives a full headless run
past it (#350: mcrfpy.step() is the clock; screenshot() forces a render), and
fails if the tripwire was touched.
Sibling force_non_interactive.py asserts the *static* non-interactive properties
(flags, absent prompts, stdin); this one watches the *running* engine.
"""
import sys
import os
import traceback
import mcrfpy
# Monkey-patch to detect interactive mode
original_ps1 = None
if hasattr(sys, 'ps1'):
original_ps1 = sys.ps1
failures = []
accesses = []
def check(label, condition, detail=""):
if condition:
print(" ok : %s" % label)
else:
print(" FAIL : %s %s" % (label, detail))
failures.append(label)
class PS1Detector:
def __repr__(self):
import traceback
print("\n!!! sys.ps1 accessed! Stack trace:")
"""Screams (and records) if anything asks for the interactive prompt."""
def _trip(self, how):
accesses.append(how)
print("\n!!! sys.ps1 accessed via %s! Stack trace:" % how)
traceback.print_stack()
return ">>> "
# Set our detector
sys.ps1 = PS1Detector()
print("Trace script loaded, ps1 detector installed")
def __repr__(self):
return self._trip("__repr__")
# Do nothing else - let the game run
def __str__(self):
return self._trip("__str__")
# The engine must not have installed a prompt of its own before we get here.
check("sys.ps1 not preinstalled by engine", not hasattr(sys, "ps1"))
detector = PS1Detector()
sys.ps1 = detector
print("ps1 detector installed")
# Now let the engine actually run. In headless mode the engine advances only when
# we drive it, so "letting the game run" means stepping the clock ourselves and
# forcing a render -- both of which are the paths that historically could reenter
# the interpreter.
scene = mcrfpy.Scene("trace_interactive")
mcrfpy.current_scene = scene
ticks = []
mcrfpy.Timer("tick", lambda timer, runtime: ticks.append(runtime), 50)
frame = mcrfpy.Frame(pos=(10, 10), size=(100, 100))
scene.children.append(frame)
frame.animate("x", 200.0, 0.2, mcrfpy.Easing.EASE_IN_OUT)
for _ in range(10):
mcrfpy.step(0.05)
shot = "trace_interactive_render.png"
mcrfpy.automation.screenshot(shot)
if os.path.exists(shot):
os.remove(shot)
# The run must have been real, or the tripwire proves nothing.
check("headless clock advanced (timer fired)", len(ticks) >= 1,
"timer fired %d time(s)" % len(ticks))
check("animation advanced during run", frame.x > 10.0,
"frame.x = %r" % (frame.x,))
# The tripwire itself: nothing may have read sys.ps1 during any of that.
check("sys.ps1 never accessed during engine run", not accesses,
"accessed %d time(s): %s" % (len(accesses), accesses))
# And the engine must not have reconfigured the interpreter behind our back --
# our detector should still be the installed prompt, and inspect mode still off.
check("sys.ps1 still our detector", sys.ps1 is detector)
check("sys.flags.inspect == 0", sys.flags.inspect == 0,
"got %r" % (sys.flags.inspect,))
check("sys.flags.interactive == 0", sys.flags.interactive == 0,
"got %r" % (sys.flags.interactive,))
# Leave the interpreter as we found it.
del sys.ps1
if failures:
print("FAIL: %d check(s) failed: %s" % (len(failures), ", ".join(failures)))
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,44 +1,61 @@
"""Regression test for Grid backward compatibility with GridView shim (#252)."""
"""Regression test for Grid/GridView/GridData compatibility (#252, updated for #313/#361).
The old GridView "shim" model (Grid owns data, .view property auto-creates a separate
GridView object) is gone. Current contract: mcrfpy.Grid IS mcrfpy.GridView (same type);
the view owns rendering state (zoom/center/camera), and the shared map lives in
.grid_data (a mcrfpy.GridData -- cells, entities, layers; not a drawable).
The intent of each test below is preserved; the assertions were retargeted at the
current contract.
"""
import mcrfpy
import sys
def test_grid_creates_view():
"""Grid auto-creates a GridView accessible via .view property."""
def test_grid_creates_grid_data():
"""Grid auto-creates its GridData, accessible via .grid_data."""
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160))
assert grid.view is not None, "Grid should auto-create a view"
assert isinstance(grid.view, mcrfpy.GridView)
print("PASS: Grid auto-creates view")
assert grid.grid_data is not None, "Grid should auto-create its grid data"
assert isinstance(grid.grid_data, mcrfpy.GridData)
# Grid and GridView are the same type now (alias), not shim + shimmed object.
assert mcrfpy.Grid is mcrfpy.GridView, "Grid should be an alias of GridView"
assert isinstance(grid, mcrfpy.GridView)
print("PASS: Grid auto-creates grid data")
def test_view_shares_grid_data():
"""GridView created by shim shares the same grid data."""
"""A GridView constructed over an existing Grid shares the same GridData."""
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160))
view = grid.view
view = mcrfpy.GridView(grid=grid, pos=(0, 0), size=(160, 160))
# View's grid should be the same Grid
assert view.grid is grid, "view.grid should be the same Grid"
assert view.grid.grid_w == 10
# The view must share -- not copy -- the source grid's data.
assert view.grid_data is grid.grid_data, "view should share the Grid's GridData"
assert view.grid_data.grid_w == 10
# Passing the GridData itself is equivalent.
view2 = mcrfpy.GridView(grid=grid.grid_data, pos=(0, 0), size=(160, 160))
assert view2.grid_data is grid.grid_data, "GridView(grid=GridData) should share it"
print("PASS: view shares grid data")
def test_rendering_property_sync():
"""Setting rendering properties on Grid syncs to the view."""
def test_rendering_properties():
"""Rendering properties live on the view (Grid) itself and round-trip."""
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160))
view = grid.view
grid.zoom = 3.0
assert abs(view.zoom - 3.0) < 0.01, f"View zoom should sync: {view.zoom}"
assert abs(grid.zoom - 3.0) < 0.01, f"zoom should round-trip: {grid.zoom}"
grid.center_x = 200.0
assert abs(view.center.x - 200.0) < 0.01, f"View center_x should sync: {view.center.x}"
assert abs(grid.center.x - 200.0) < 0.01, f"center.x should track center_x: {grid.center.x}"
grid.center_y = 150.0
assert abs(view.center.y - 150.0) < 0.01, f"View center_y should sync: {view.center.y}"
assert abs(grid.center.y - 150.0) < 0.01, f"center.y should track center_y: {grid.center.y}"
grid.camera_rotation = 45.0
# camera_rotation syncs through set_float_member
print("PASS: rendering properties sync to view")
assert abs(grid.camera_rotation - 45.0) < 0.01, f"camera_rotation should round-trip: {grid.camera_rotation}"
# Rendering state is view-only; it must not have leaked onto the shared map.
assert not hasattr(grid.grid_data, "zoom"), "GridData must not carry rendering state"
print("PASS: rendering properties live on the view")
def test_grid_still_works_in_scene():
"""Grid can still be appended to scenes and works as before."""
@ -52,7 +69,8 @@ def test_grid_still_works_in_scene():
# Grid is in the scene as itself (not substituted)
assert len(scene.children) == 1
retrieved = scene.children[0]
assert type(retrieved).__name__ == "Grid", f"Expected Grid, got {type(retrieved).__name__}"
assert retrieved is grid, "scene.children should return the same object (#369)"
assert type(retrieved).__name__ == "GridView", f"Expected GridView, got {type(retrieved).__name__}"
print("PASS: Grid works in scene as before")
def test_grid_subclass_preserved():
@ -80,47 +98,66 @@ def test_entity_operations_unaffected():
grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320))
scene.children.append(grid)
data = grid.grid_data
for y in range(20):
for x in range(20):
grid.at(x, y).walkable = True
grid.at(x, y).transparent = True
data.at(x, y).walkable = True
data.at(x, y).transparent = True
e = mcrfpy.Entity((5, 5), grid=grid)
assert len(grid.entities) == 1
assert len(data.entities) == 1
assert e.cell_x == 5
assert e.cell_y == 5
assert len(grid.at(5, 5).entities) == 1
assert len(data.at(5, 5).entities) == 1
# Current contract (#313/#361): entity.grid is the shared GridData, not the view.
assert e.grid is data, "entity.grid should be the GridData it lives in"
print("PASS: entity operations unaffected")
def test_gridview_independent_rendering():
"""A GridView can have different rendering settings from the Grid."""
"""A second GridView over the same data has independent rendering settings."""
scene = mcrfpy.Scene("test_independent")
mcrfpy.current_scene = scene
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320))
# Create an explicit GridView with different settings
# Create an explicit second view over the same grid data, with different settings
view2 = mcrfpy.GridView(grid=grid, pos=(350, 0), size=(160, 160), zoom=0.5)
scene.children.append(grid)
scene.children.append(view2)
assert view2.grid_data is grid.grid_data, "both views should share one GridData"
# Grid and view2 should have different zoom
assert abs(grid.zoom - 1.0) < 0.01
assert abs(view2.zoom - 0.5) < 0.01
# Changing grid zoom doesn't affect explicit GridView
# Changing grid zoom doesn't affect the second view
grid.zoom = 2.0
assert abs(view2.zoom - 0.5) < 0.01, "Explicit GridView should keep its own zoom"
assert abs(view2.zoom - 0.5) < 0.01, "Second GridView should keep its own zoom"
assert abs(grid.zoom - 2.0) < 0.01
print("PASS: GridView independent rendering")
if __name__ == "__main__":
test_grid_creates_view()
test_view_shares_grid_data()
test_rendering_property_sync()
test_grid_still_works_in_scene()
test_grid_subclass_preserved()
test_entity_operations_unaffected()
test_gridview_independent_rendering()
tests = [
test_grid_creates_grid_data,
test_view_shares_grid_data,
test_rendering_properties,
test_grid_still_works_in_scene,
test_grid_subclass_preserved,
test_entity_operations_unaffected,
test_gridview_independent_rendering,
]
failures = 0
for t in tests:
try:
t()
except AssertionError as e:
failures += 1
print(f"FAIL: {t.__name__}: {e}")
if failures:
print(f"FAILED: {failures} of {len(tests)} tests failed")
sys.exit(1)
print("All backward compatibility tests passed")
print("PASS")
sys.exit(0)

View file

@ -12,9 +12,18 @@ while small grids use the original flat storage. Verifies that:
NOTE: This test uses ColorLayer for color operations since cell.color
is no longer supported. The chunk system affects internal storage, which
ColorLayer also uses.
API notes (current contract):
- add_layer() takes a layer OBJECT and no keyword arguments:
grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
- ColorLayer.set()/at() take a POSITION tuple, not separate x/y args to set().
- GridPoint no longer has .tilesprite; cell read/write coverage is preserved
by exercising .walkable (a real per-cell field that lives in the same
chunked storage the issue is about).
"""
import mcrfpy
from mcrfpy import automation
import sys
def test_small_grid():
@ -23,23 +32,28 @@ def test_small_grid():
# Small grid should use flat storage
grid = mcrfpy.Grid(grid_size=(50, 50), pos=(10, 10), size=(400, 400))
color_layer = grid.add_layer("color", z_index=-1)
color_layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
# Set some cells
for y in range(50):
for x in range(50):
cell = grid.at(x, y)
color_layer.set(x, y, mcrfpy.Color((x * 5) % 256, (y * 5) % 256, 128, 255))
cell.tilesprite = -1
color_layer.set((x, y), mcrfpy.Color((x * 5) % 256, (y * 5) % 256, 128, 255))
cell.walkable = ((x + y) % 2 == 0)
# Verify cells
expected_r = (25 * 5) % 256
expected_g = (25 * 5) % 256
color = color_layer.at(25, 25)
color = color_layer.at((25, 25))
if color.r != expected_r or color.g != expected_g:
print(f"FAIL: Small grid cell color mismatch. Expected ({expected_r}, {expected_g}), got ({color.r}, {color.g})")
return False
# Verify per-cell (non-color) storage round-trips
if not grid.at(24, 24).walkable or grid.at(25, 24).walkable:
print("FAIL: Small grid cell walkable mismatch")
return False
print(" Small grid: PASS")
return True
@ -49,7 +63,7 @@ def test_large_grid():
# Large grid should use chunk storage (100 > 64)
grid = mcrfpy.Grid(grid_size=(100, 100), pos=(10, 10), size=(400, 400))
color_layer = grid.add_layer("color", z_index=-1)
color_layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
# Set cells across multiple chunks
# Chunks are 64x64, so a 100x100 grid has 2x2 = 4 chunks
@ -65,15 +79,18 @@ def test_large_grid():
for x, y in test_points:
cell = grid.at(x, y)
color_layer.set(x, y, mcrfpy.Color(x, y, 100, 255))
cell.tilesprite = -1
color_layer.set((x, y), mcrfpy.Color(x, y, 100, 255))
cell.walkable = False
# Verify cells
for x, y in test_points:
color = color_layer.at(x, y)
color = color_layer.at((x, y))
if color.r != x or color.g != y:
print(f"FAIL: Large grid cell ({x},{y}) color mismatch. Expected ({x}, {y}), got ({color.r}, {color.g})")
return False
if grid.at(x, y).walkable:
print(f"FAIL: Large grid cell ({x},{y}) walkable not persisted across chunk boundary")
return False
print(" Large grid cell access: PASS")
return True
@ -84,7 +101,7 @@ def test_very_large_grid():
# 500x500 = 250,000 cells, should use ~64 chunks (8x8)
grid = mcrfpy.Grid(grid_size=(500, 500), pos=(10, 10), size=(400, 400))
color_layer = grid.add_layer("color", z_index=-1)
color_layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
# Set some cells at various positions
test_points = [
@ -98,11 +115,11 @@ def test_very_large_grid():
]
for x, y in test_points:
color_layer.set(x, y, mcrfpy.Color(x % 256, y % 256, 200, 255))
color_layer.set((x, y), mcrfpy.Color(x % 256, y % 256, 200, 255))
# Verify
for x, y in test_points:
color = color_layer.at(x, y)
color = color_layer.at((x, y))
if color.r != (x % 256) or color.g != (y % 256):
print(f"FAIL: Very large grid cell ({x},{y}) color mismatch")
return False
@ -116,18 +133,18 @@ def test_boundary_case():
# 64x64 should use flat storage (not exceeding threshold)
grid_64 = mcrfpy.Grid(grid_size=(64, 64), pos=(10, 10), size=(400, 400))
color_layer_64 = grid_64.add_layer("color", z_index=-1)
color_layer_64.set(63, 63, mcrfpy.Color(255, 0, 0, 255))
color = color_layer_64.at(63, 63)
color_layer_64 = grid_64.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
color_layer_64.set((63, 63), mcrfpy.Color(255, 0, 0, 255))
color = color_layer_64.at((63, 63))
if color.r != 255:
print(f"FAIL: 64x64 grid boundary cell not set correctly, got r={color.r}")
return False
# 65x65 should use chunk storage (exceeding threshold)
grid_65 = mcrfpy.Grid(grid_size=(65, 65), pos=(10, 10), size=(400, 400))
color_layer_65 = grid_65.add_layer("color", z_index=-1)
color_layer_65.set(64, 64, mcrfpy.Color(0, 255, 0, 255))
color = color_layer_65.at(64, 64)
color_layer_65 = grid_65.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
color_layer_65.set((64, 64), mcrfpy.Color(0, 255, 0, 255))
color = color_layer_65.at((64, 64))
if color.g != 255:
print(f"FAIL: 65x65 grid cell not set correctly, got g={color.g}")
return False
@ -141,16 +158,16 @@ def test_edge_cases():
# Create 100x100 grid
grid = mcrfpy.Grid(grid_size=(100, 100), pos=(10, 10), size=(400, 400))
color_layer = grid.add_layer("color", z_index=-1)
color_layer = grid.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
# Test all corners
corners = [(0, 0), (99, 0), (0, 99), (99, 99)]
for i, (x, y) in enumerate(corners):
color_layer.set(x, y, mcrfpy.Color(i * 60, i * 60, i * 60, 255))
color_layer.set((x, y), mcrfpy.Color(i * 60, i * 60, i * 60, 255))
for i, (x, y) in enumerate(corners):
expected = i * 60
color = color_layer.at(x, y)
color = color_layer.at((x, y))
if color.r != expected:
print(f"FAIL: Corner ({x},{y}) color mismatch, expected {expected}, got {color.r}")
return False
@ -158,6 +175,31 @@ def test_edge_cases():
print(" Edge cases: PASS")
return True
def test_rendering(scene):
"""Test that both storage modes actually render (flat + chunked on screen)"""
print("Testing rendering of flat and chunked grids...")
small = mcrfpy.Grid(grid_size=(50, 50), pos=(10, 10), size=(200, 200))
small_colors = small.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
large = mcrfpy.Grid(grid_size=(100, 100), pos=(220, 10), size=(200, 200))
large_colors = large.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
for i in range(50):
small_colors.set((i, i), mcrfpy.Color(255, 0, 0, 255))
for i in range(100):
large_colors.set((i, i), mcrfpy.Color(0, 0, 255, 255))
scene.children.append(small)
scene.children.append(large)
# Rendering is orthogonal to sim time; screenshot forces a render pass.
if not automation.screenshot("issue_123_chunk_render.png"):
print("FAIL: screenshot of flat + chunked grids failed")
return False
print(" Rendering: PASS")
return True
# Main
if __name__ == "__main__":
print("=" * 60)
@ -173,9 +215,11 @@ if __name__ == "__main__":
results.append(test_very_large_grid())
results.append(test_boundary_case())
results.append(test_edge_cases())
results.append(test_rendering(test))
if all(results):
print("\n=== ALL TESTS PASSED ===")
print("PASS")
sys.exit(0)
else:
print("\n=== SOME TESTS FAILED ===")

View file

@ -22,7 +22,7 @@ print("=" * 60)
test = mcrfpy.Scene("test")
mcrfpy.current_scene = test
ui = test.children
texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(pos=(0,0), size=(400,300), grid_size=(50, 50), texture=texture)
ui.append(grid)
@ -34,13 +34,15 @@ for y in range(50):
cell.walkable = True
cell.transparent = True
# Add some walls to test blocking
for i in range(10, 20):
grid.at(i, 25).transparent = False
grid.at(i, 25).walkable = False
# Add a wall that actually occludes: a vertical run at x=20, spanning the
# rows around the viewer at (25,25). Cells at x < 20 on row 25 are then
# within radius but geometrically behind the wall.
for j in range(20, 31):
grid.at(20, j).transparent = False
grid.at(20, j).walkable = False
print("\n--- Test 1: compute_fov() returns None ---")
result = grid.compute_fov(25, 25, radius=10)
result = grid.compute_fov((25, 25), radius=10)
if result is None:
print(" PASS: compute_fov() returned None")
else:
@ -55,16 +57,16 @@ else:
print(" FAIL: Center should be in FOV")
sys.exit(1)
# Cell within radius should be visible
if grid.is_in_fov(20, 25):
print(" PASS: Cell (20,25) within radius is in FOV")
# Cell within radius and in front of the wall should be visible
if grid.is_in_fov(22, 25):
print(" PASS: Cell (22,25) within radius is in FOV")
else:
print(" FAIL: Cell (20,25) should be in FOV")
print(" FAIL: Cell (22,25) should be in FOV")
sys.exit(1)
# Cell behind wall should NOT be visible
if not grid.is_in_fov(15, 30):
print(" PASS: Cell (15,30) behind wall is NOT in FOV")
# Cell behind the wall (within radius) should NOT be visible
if not grid.is_in_fov(18, 25):
print(" PASS: Cell (18,25) behind wall is NOT in FOV")
else:
print(" FAIL: Cell behind wall should not be in FOV")
sys.exit(1)
@ -89,7 +91,7 @@ for y in range(0, 200, 5): # Sample for speed
times = []
for i in range(5):
t0 = time.perf_counter()
grid_large.compute_fov(100, 100, radius=15)
grid_large.compute_fov((100, 100), radius=15)
elapsed = (time.perf_counter() - t0) * 1000
times.append(elapsed)

View file

@ -7,6 +7,13 @@ Tests:
2. TileLayer creation and manipulation
3. Layer z_index ordering relative to entities
4. Layer management (add_layer, remove_layer, layers property)
API notes (current contract):
- Layers are constructed standalone (TileLayer/ColorLayer ctors take kwargs) and then
attached with grid_data.add_layer(layer) -- add_layer takes an object, not kwargs.
- Layer management (add_layer / remove_layer / layer) lives on GridData
(grid.grid_data); the Grid view exposes the read-only `layers` tuple.
- Layers are looked up BY NAME, not by z_index (grid_data.layer(name)).
"""
import mcrfpy
import sys
@ -19,21 +26,22 @@ print("=" * 60)
test = mcrfpy.Scene("test")
mcrfpy.current_scene = test
ui = test.children
texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
# Create grid with explicit empty layers (#150 migration)
grid = mcrfpy.Grid(pos=(50, 50), size=(400, 300), grid_size=(20, 15), texture=texture, layers={})
grid = mcrfpy.Grid(pos=(50, 50), size=(400, 300), grid_size=(20, 15), texture=texture, layers=[])
ui.append(grid)
grid_data = grid.grid_data
print("\n--- Test 1: Initial state (no layers) ---")
if len(grid.layers) == 0:
print(" PASS: Grid starts with no layers (layers={})")
print(" PASS: Grid starts with no layers (layers=[])")
else:
print(f" FAIL: Expected 0 layers, got {len(grid.layers)}")
sys.exit(1)
print("\n--- Test 2: Add ColorLayer ---")
color_layer = grid.add_layer("color", z_index=-1)
color_layer = grid_data.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
print(f" Created: {color_layer}")
if color_layer is not None:
print(" PASS: ColorLayer created")
@ -63,7 +71,7 @@ else:
print("\n--- Test 3: ColorLayer cell access ---")
# Set a color
color_layer.set(5, 5, mcrfpy.Color(255, 0, 0, 128))
color_layer.set((5, 5), mcrfpy.Color(255, 0, 0, 128))
color = color_layer.at(5, 5)
if color.r == 255 and color.g == 0 and color.b == 0 and color.a == 128:
print(f" PASS: Color at (5,5) is {color.r}, {color.g}, {color.b}, {color.a}")
@ -81,7 +89,7 @@ else:
sys.exit(1)
print("\n--- Test 4: Add TileLayer ---")
tile_layer = grid.add_layer("tile", z_index=-2, texture=texture)
tile_layer = grid_data.add_layer(mcrfpy.TileLayer(name="tile", z_index=-2, texture=texture))
print(f" Created: {tile_layer}")
if tile_layer is not None:
print(" PASS: TileLayer created")
@ -97,7 +105,7 @@ else:
print("\n--- Test 5: TileLayer cell access ---")
# Set a tile
tile_layer.set(3, 3, 42)
tile_layer.set((3, 3), 42)
tile = tile_layer.at(3, 3)
if tile == 42:
print(f" PASS: Tile at (3,3) is {tile}")
@ -129,30 +137,34 @@ else:
print(f" FAIL: Layers not sorted")
sys.exit(1)
print("\n--- Test 7: Get layer by z_index ---")
layer = grid.layer(-1)
if layer is not None and layer.z_index == -1:
print(" PASS: grid.layer(-1) returns ColorLayer")
print("\n--- Test 7: Get layer by name ---")
# Layer lookup is by NAME now (grid.layer(z_index) no longer exists); z_index is still
# the ordering key, verified in Test 6. Note: layer lookups return a fresh wrapper each
# call (layers are not in PythonObjectCache), so compare identity via the shared data,
# not `is`.
layer = grid_data.layer("color")
if layer is not None and layer.z_index == -1 and layer.at(5, 5).b == color_layer.at(5, 5).b:
print(" PASS: grid_data.layer('color') returns the ColorLayer")
else:
print(" FAIL: Could not get layer by z_index")
print(" FAIL: Could not get ColorLayer by name")
sys.exit(1)
layer = grid.layer(-2)
if layer is not None and layer.z_index == -2:
print(" PASS: grid.layer(-2) returns TileLayer")
layer = grid_data.layer("tile")
if layer is not None and layer.z_index == -2 and layer.at(3, 3) == tile_layer.at(3, 3):
print(" PASS: grid_data.layer('tile') returns the TileLayer")
else:
print(" FAIL: Could not get layer by z_index")
print(" FAIL: Could not get TileLayer by name")
sys.exit(1)
layer = grid.layer(999)
layer = grid_data.layer("nonexistent")
if layer is None:
print(" PASS: grid.layer(999) returns None for non-existent layer")
print(" PASS: grid_data.layer('nonexistent') returns None for non-existent layer")
else:
print(" FAIL: Should return None for non-existent layer")
sys.exit(1)
print("\n--- Test 8: Layer above entities (z_index >= 0) ---")
fog_layer = grid.add_layer("color", z_index=1)
fog_layer = grid_data.add_layer(mcrfpy.ColorLayer(name="fog", z_index=1))
if fog_layer.z_index == 1:
print(" PASS: Created layer with z_index=1 (above entities)")
else:
@ -165,7 +177,7 @@ print(" PASS: Fog layer filled")
print("\n--- Test 9: Remove layer ---")
initial_count = len(grid.layers)
grid.remove_layer(fog_layer)
grid_data.remove_layer(fog_layer)
final_count = len(grid.layers)
if final_count == initial_count - 1:
print(f" PASS: Layer removed ({initial_count} -> {final_count})")

View file

@ -7,12 +7,64 @@ Tests:
2. Setting cell values marks layer dirty
3. Fill operation marks layer dirty
4. Texture change marks TileLayer dirty
5. Viewport changes (center/zoom) don't trigger re-render (static benchmark)
6. Performance improvement for static layers
5. Viewport changes (center/zoom) don't corrupt the cached texture
6. Performance / large-layer handling for static layers
7. Layer visibility toggle marks the layer dirty
8. Large grid stress test
REPAIR NOTE (API drift):
* add_layer() no longer takes kwargs and no longer constructs the layer for you.
Construct a TileLayer/ColorLayer and attach it: grid.add_layer(layer).
* layer.set() takes a position TUPLE: layer.set((x, y), value) -- not set(x, y, value).
* assets/kenney_ice.png does not exist; using kenney_tinydungeon.png.
* step() is the clock and NEVER renders (#350); rendering is forced with
automation.screenshot(), which costs zero simulation time.
REPAIR NOTE (real coverage):
The original file punted on the actual subject of #148 -- it said "dirty flag
behavior is internal" and only checked that the API didn't crash, so it proved
nothing about caching. Dirty-flag behavior IS observable from Python now: the
GridView caches its composed output and only re-renders when a layer's markDirty()
bumps the grid's content_generation (#351). So a mutation that fails to set the
dirty flag produces a byte-identical screenshot -- a stale cache. We render to PNG
and compare hashes to assert invalidation actually happens.
"""
import mcrfpy
from mcrfpy import automation
import sys
import time
import os
import hashlib
import tempfile
failures = []
def check(label, condition, detail=""):
if condition:
print(f" PASS: {label}")
else:
print(f" FAIL: {label} {detail}")
failures.append(label)
_shot_n = [0]
def render_hash():
"""Force a render (screenshot) and hash the pixels.
step() never renders, so this is the only way to observe what the grid's cached
RenderTexture actually contains. Identical bytes across a mutation == stale cache
== the dirty flag was not set.
"""
_shot_n[0] += 1
path = os.path.join(tempfile.gettempdir(), f"issue148_{_shot_n[0]}.png")
automation.screenshot(path)
with open(path, "rb") as f:
h = hashlib.sha256(f.read()).hexdigest()
os.remove(path)
return h
print("=" * 60)
print("Issue #148 Regression Test: Layer Dirty Flags and Caching")
@ -22,126 +74,186 @@ print("=" * 60)
test = mcrfpy.Scene("test")
mcrfpy.current_scene = test
ui = test.children
texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
# Create grid with larger size for performance testing
grid = mcrfpy.Grid(pos=(50, 50), size=(500, 400), grid_size=(50, 40), texture=texture)
ui.append(grid)
print("\n--- Test 1: Layer creation (starts dirty) ---")
color_layer = grid.add_layer("color", z_index=-1)
# The layer should be dirty initially
# We can't directly check dirty flag from Python, but we verify the system works
# A grid built with grid_size= already carries a default tile layer; these are added
# on top of it. Layers attached at size (0,0) auto-resize to the grid.
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.add_layer(color_layer)
print(" ColorLayer created successfully")
tile_layer = grid.add_layer("tile", z_index=-2, texture=texture)
tile_layer = mcrfpy.TileLayer(name="tile", z_index=-2, texture=texture)
grid.add_layer(tile_layer)
print(" TileLayer created successfully")
print(" PASS: Layers created")
print("\n--- Test 2: Fill operations work ---")
# Fill with some data
check("ColorLayer attached and retrievable by name", grid.layer("color") is not None)
check("TileLayer attached and retrievable by name", grid.layer("tile") is not None)
check("layer auto-resized to grid", color_layer.grid_size == (50, 40),
f"got {color_layer.grid_size}")
print("\n--- Test 2: Fill operations work (and mark the layer dirty) ---")
color_layer.fill(mcrfpy.Color(128, 0, 128, 64))
print(" ColorLayer filled with purple overlay")
check("ColorLayer.fill wrote every cell", color_layer.at((0, 0)) == mcrfpy.Color(128, 0, 128, 64)
and color_layer.at((49, 39)) == mcrfpy.Color(128, 0, 128, 64),
f"got {color_layer.at((0, 0))} / {color_layer.at((49, 39))}")
tile_layer.fill(5) # Fill with tile index 5
print(" TileLayer filled with tile index 5")
print(" PASS: Fill operations completed")
tile_layer.fill(5)
check("TileLayer.fill wrote every cell",
tile_layer.at((0, 0)) == 5 and tile_layer.at((49, 39)) == 5,
f"got {tile_layer.at((0, 0))} / {tile_layer.at((49, 39))}")
print("\n--- Test 3: Cell set operations work ---")
# Set individual cells
color_layer.set(10, 10, mcrfpy.Color(255, 255, 0, 128))
color_layer.set(11, 10, mcrfpy.Color(255, 255, 0, 128))
color_layer.set(10, 11, mcrfpy.Color(255, 255, 0, 128))
color_layer.set(11, 11, mcrfpy.Color(255, 255, 0, 128))
print(" Set 4 cells in ColorLayer to yellow")
yellow = mcrfpy.Color(255, 255, 0, 128)
for cell in [(10, 10), (11, 10), (10, 11), (11, 11)]:
color_layer.set(cell, yellow)
check("ColorLayer.set updated the 4 target cells",
all(color_layer.at(c) == yellow for c in [(10, 10), (11, 10), (10, 11), (11, 11)]))
check("ColorLayer.set left neighbours untouched",
color_layer.at((12, 10)) == mcrfpy.Color(128, 0, 128, 64),
f"got {color_layer.at((12, 10))}")
tile_layer.set(15, 15, 10)
tile_layer.set(16, 15, 11)
tile_layer.set(15, 16, 10)
tile_layer.set(16, 16, 11)
print(" Set 4 cells in TileLayer to different tiles")
print(" PASS: Cell set operations completed")
for cell, idx in [((15, 15), 10), ((16, 15), 11), ((15, 16), 10), ((16, 16), 11)]:
tile_layer.set(cell, idx)
check("TileLayer.set updated the 4 target cells",
tile_layer.at((15, 15)) == 10 and tile_layer.at((16, 15)) == 11
and tile_layer.at((15, 16)) == 10 and tile_layer.at((16, 16)) == 11)
check("TileLayer.set(-1) clears a tile",
(tile_layer.set((20, 20), -1), tile_layer.at((20, 20)))[1] == -1,
f"got {tile_layer.at((20, 20))}")
print("\n--- Test 4: Texture change on TileLayer ---")
# Create a second texture and assign it
texture2 = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
# Note: the .texture getter returns a fresh wrapper each call and Texture has no
# __eq__, so identity/equality can't be asserted -- compare the source path instead.
texture2 = mcrfpy.Texture("assets/kenney_TD_MR_IP.png", 16, 16)
tile_layer.texture = texture2
print(" Changed TileLayer texture")
check("TileLayer.texture accepts a new texture",
tile_layer.texture.source == "assets/kenney_TD_MR_IP.png",
f"got {tile_layer.texture.source}")
# Set back to original
tile_layer.texture = texture
print(" Restored original texture")
print(" PASS: Texture changes work")
check("TileLayer.texture restores the original",
tile_layer.texture.source == "assets/kenney_tinydungeon.png",
f"got {tile_layer.texture.source}")
print("\n--- Test 5: Viewport changes (should use cached texture) ---")
# Pan around - these should NOT cause layer re-renders (just blit different region)
print("\n--- Test 5: Dirty flags actually invalidate the cached RenderTexture ---")
# This is the core of #148. The grid caches its composed output; a mutation that does
# not set the dirty flag yields a byte-identical render (a stale cache).
# The ColorLayer (z=-1) composites OVER the TileLayer (z=-2), so it is kept
# translucent -- an opaque fill would occlude the tile layer and make the TileLayer
# invalidation checks below vacuous.
base = render_hash()
repeat = render_hash()
check("re-rendering an unchanged grid is stable", base == repeat)
color_layer.fill(mcrfpy.Color(255, 0, 0, 64))
after_fill = render_hash()
check("fill() invalidates the cache (render changed)", after_fill != base)
color_layer.set((0, 0), mcrfpy.Color(0, 255, 0, 64))
after_set = render_hash()
check("set() invalidates the cache (render changed)", after_set != after_fill)
tile_layer.fill(7)
after_tile_fill = render_hash()
check("TileLayer.fill() invalidates the cache", after_tile_fill != after_set)
tile_layer.set((5, 5), 12)
after_tile_set = render_hash()
check("TileLayer.set() invalidates the cache", after_tile_set != after_tile_fill)
# Bulk edit path (#328): the view is a 2-D int32 memoryview aliasing layer storage;
# on __exit__ the whole layer is conservatively invalidated.
with tile_layer.edit() as view:
for y in range(40):
for x in range(50):
view[y, x] = 3
check("bulk edit() wrote through to layer storage", tile_layer.at((5, 5)) == 3,
f"got {tile_layer.at((5, 5))}")
after_edit = render_hash()
check("bulk edit() invalidates the cache", after_edit != after_tile_set)
print("\n--- Test 6: Viewport changes (should use the cached texture) ---")
original_center = grid.center
print(f" Original center: {original_center}")
original_zoom = grid.zoom
print(f" Original center: {original_center}, zoom: {original_zoom}")
# Perform multiple viewport changes
for i in range(10):
grid.center = (100 + i * 20, 80 + i * 10)
print(" Performed 10 center changes")
check("center round-trips after 10 changes", grid.center == (280, 170),
f"got {grid.center}")
panned = render_hash()
check("panning the viewport changes the rendered image", panned != after_edit)
# Zoom changes
original_zoom = grid.zoom
for z in [1.0, 0.8, 1.2, 0.5, 1.5, 1.0]:
grid.zoom = z
print(" Performed 6 zoom changes")
check("zoom round-trips after 6 changes", grid.zoom == 1.0, f"got {grid.zoom}")
# Restore
# Restoring the viewport must restore the exact same image: the layer content never
# changed, so this exercises the cache-hit path (blit a different region, no re-render).
grid.center = original_center
grid.zoom = original_zoom
print(" PASS: Viewport changes completed without crashing")
print("\n--- Test 6: Performance benchmark ---")
# Create a large layer for performance testing
perf_grid = mcrfpy.Grid(pos=(50, 50), size=(600, 500), grid_size=(100, 80), texture=texture)
ui.append(perf_grid)
perf_layer = perf_grid.add_layer("tile", z_index=-1, texture=texture)
# Fill with data
perf_layer.fill(1)
# Render a frame to build cache
mcrfpy.step(0.01)
# Subsequent viewport changes should be fast (cache hit)
start = time.time()
for i in range(5):
perf_grid.center = (200 + i * 10, 160 + i * 8)
viewport_changes = time.time() - start
print(f" 5 viewport changes: {viewport_changes*1000:.2f}ms")
print(" PASS: Performance benchmark completed")
restored = render_hash()
check("restoring the viewport restores the identical image", restored == after_edit)
print("\n--- Test 7: Layer visibility toggle ---")
# The layer's own render() early-outs on `visible`, so hiding a layer must change the
# rendered output. It only does so if the setter invalidates the grid's cache.
visible_hash = render_hash()
color_layer.visible = False
print(" ColorLayer hidden")
check("visible attribute round-trips to False", color_layer.visible is False)
hidden_hash = render_hash()
check("hiding a layer changes the rendered image (dirty flag set)",
hidden_hash != visible_hash,
"-- layer.visible setter does not invalidate the grid cache (see notes)")
color_layer.visible = True
print(" ColorLayer shown")
print(" PASS: Visibility toggle works")
check("visible attribute round-trips to True", color_layer.visible is True)
reshown_hash = render_hash()
check("re-showing a layer restores the rendered image",
reshown_hash == visible_hash,
"-- layer.visible setter does not invalidate the grid cache (see notes)")
print("\n--- Test 8: Large grid stress test ---")
# Test with maximum size grid to ensure texture caching works
stress_scene = mcrfpy.Scene("stress")
stress_grid = mcrfpy.Grid(pos=(10, 10), size=(200, 150), grid_size=(200, 150), texture=texture)
ui.append(stress_grid)
stress_layer = stress_grid.add_layer("color", z_index=-1)
stress_scene.children.append(stress_grid)
stress_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
stress_grid.add_layer(stress_layer)
# This would be 30,000 cells - should handle via caching
# 30,000 cells - should be handled via chunked texture caching
stress_layer.fill(mcrfpy.Color(0, 100, 200, 100))
# Set a few specific cells
for x in range(10):
for y in range(10):
stress_layer.set(x, y, mcrfpy.Color(255, 0, 0, 200))
stress_layer.set((x, y), mcrfpy.Color(255, 0, 0, 200))
print(" Created 200x150 grid with 30,000 cells")
print(" PASS: Large grid handled successfully")
check("30,000-cell layer filled", stress_layer.at((199, 149)) == mcrfpy.Color(0, 100, 200, 100),
f"got {stress_layer.at((199, 149))}")
check("30,000-cell layer accepts per-cell sets",
stress_layer.at((9, 9)) == mcrfpy.Color(255, 0, 0, 200),
f"got {stress_layer.at((9, 9))}")
mcrfpy.current_scene = stress_scene
mcrfpy.step(0.016)
stress_render = render_hash()
check("large grid renders without crashing", len(stress_render) == 64)
# A static layer must be re-renderable from cache: identical bytes, no corruption.
check("static large layer renders identically from cache",
render_hash() == stress_render)
print("\n" + "=" * 60)
if failures:
print(f"FAIL - {len(failures)} check(s) failed:")
for f in failures:
print(f" - {f}")
print("=" * 60)
sys.exit(1)
print("All tests PASSED")
print("=" * 60)
print("\nNote: Dirty flag behavior is internal - tests verify API works")
print("Actual caching benefits are measured by render performance.")
sys.exit(0)

View file

@ -1,4 +1,4 @@
"""Regression test: entity gridstate must resize when moving between grids.
"""Regression test: entity perspective map must resize when moving between grids.
Issues #258-#263, #274, #276, #278: UIEntity gridstate heap overflows.
@ -7,139 +7,159 @@ moved from a small grid to a larger grid via ANY transfer method, gridstate
kept the old size. Code then iterated using the new grid's dimensions,
writing past the vector's end.
Fix: ensureGridstate() unconditionally checks gridstate.size() against
grid dimensions and resizes if they don't match. Applied to all transfer
methods: set_grid, append, extend, insert, setitem, slice assignment.
Fix: the per-entity visibility buffer unconditionally checks its size against
the grid dimensions and reallocates if they don't match. Exercised here through
every transfer method: set_grid, append, extend, insert, setitem, slice assign.
Also tests #274: spatial_hash.remove() must be called when removing
entities from grids via set_grid(None) or set_grid(other_grid).
API NOTE (#294): entity.gridstate is now entity.perspective_map, a bounds-checked
DiscreteMap (UNKNOWN/DISCOVERED/VISIBLE) carrying its own .size, and it is *lazy* --
a transfer marks it stale and the next update_visibility() sizes it to the new grid
(see UIEntity::updateVisibility, src/UIEntity.cpp:51). So each case below transfers,
then calls update_visibility(), then asserts the buffer matches the new grid and that
cells only reachable in the NEW (larger) grid are addressable. Under the old bug those
reads/writes ran off the end of the old buffer.
"""
import mcrfpy
import sys
def open_grid(size):
"""Grid of `size` x `size` with all cells transparent/walkable, FOV enabled."""
grid = mcrfpy.Grid(grid_size=(size, size))
data = grid.grid_data
for x in range(size):
for y in range(size):
point = data.at(x, y)
point.transparent = True
point.walkable = True
data.fov_radius = 4
return grid
def check_sized_to(entity, grid, label):
"""Buffer must match the grid, and the grid's far corner must be addressable."""
entity.update_visibility()
pmap = entity.perspective_map
w, h = grid.grid_data.grid_w, grid.grid_data.grid_h
assert pmap.size == (w, h), f"{label}: expected {(w, h)}, got {pmap.size}"
# Under #258 this read walked off the end of the stale, smaller buffer.
pmap.get(w - 1, h - 1)
def test_set_grid():
"""entity.grid = new_grid resizes gridstate"""
small = mcrfpy.Grid(grid_size=(10, 10))
large = mcrfpy.Grid(grid_size=(50, 50))
"""entity.grid = new_grid resizes the perspective map"""
small = open_grid(10)
large = open_grid(50)
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=small)
small.perspective = entity
small.fov_radius = 4
entity.update_visibility()
gs = entity.gridstate
assert len(gs) == 100, f"Expected 100, got {len(gs)}"
check_sized_to(entity, small, "set_grid/small")
entity.grid = large
gs = entity.gridstate
assert len(gs) == 2500, f"Expected 2500, got {len(gs)}"
large.perspective = entity
large.fov_radius = 8
entity.update_visibility()
check_sized_to(entity, large, "set_grid/large")
print(" PASS: set_grid")
def test_append():
"""grid.entities.append(entity) resizes gridstate"""
small = mcrfpy.Grid(grid_size=(10, 10))
large = mcrfpy.Grid(grid_size=(40, 40))
"""grid.entities.append(entity) resizes the perspective map"""
small = open_grid(10)
large = open_grid(40)
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
entity.update_visibility()
gs = entity.gridstate
assert len(gs) == 100, f"Expected 100, got {len(gs)}"
check_sized_to(entity, small, "append/small")
large.entities.append(entity)
gs = entity.gridstate
assert len(gs) == 1600, f"Expected 1600, got {len(gs)}"
check_sized_to(entity, large, "append/large")
print(" PASS: append")
def test_extend():
"""grid.entities.extend([entity]) resizes gridstate"""
small = mcrfpy.Grid(grid_size=(10, 10))
large = mcrfpy.Grid(grid_size=(30, 30))
"""grid.entities.extend([entity]) resizes the perspective map"""
small = open_grid(10)
large = open_grid(30)
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
entity.update_visibility()
check_sized_to(entity, small, "extend/small")
large.entities.extend([entity])
gs = entity.gridstate
assert len(gs) == 900, f"Expected 900, got {len(gs)}"
check_sized_to(entity, large, "extend/large")
print(" PASS: extend")
def test_insert():
"""grid.entities.insert(0, entity) resizes gridstate"""
small = mcrfpy.Grid(grid_size=(10, 10))
large = mcrfpy.Grid(grid_size=(25, 25))
"""grid.entities.insert(0, entity) resizes the perspective map"""
small = open_grid(10)
large = open_grid(25)
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
entity.update_visibility()
check_sized_to(entity, small, "insert/small")
large.entities.insert(0, entity)
gs = entity.gridstate
assert len(gs) == 625, f"Expected 625, got {len(gs)}"
check_sized_to(entity, large, "insert/large")
print(" PASS: insert")
def test_setitem():
"""grid.entities[0] = entity resizes gridstate"""
small = mcrfpy.Grid(grid_size=(10, 10))
large = mcrfpy.Grid(grid_size=(20, 20))
"""grid.entities[0] = entity resizes the perspective map"""
small = open_grid(10)
large = open_grid(20)
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
entity.update_visibility()
check_sized_to(entity, small, "setitem/small")
# Need a placeholder entity in large grid first
placeholder = mcrfpy.Entity(grid_pos=(0, 0), grid=large)
large.entities[0] = entity
gs = entity.gridstate
assert len(gs) == 400, f"Expected 400, got {len(gs)}"
assert len(large.entities) == 1
check_sized_to(entity, large, "setitem/large")
print(" PASS: setitem")
def test_slice_assign():
"""grid.entities[0:1] = [entity] resizes gridstate"""
small = mcrfpy.Grid(grid_size=(10, 10))
large = mcrfpy.Grid(grid_size=(35, 35))
"""grid.entities[0:1] = [entity] resizes the perspective map"""
small = open_grid(10)
large = open_grid(35)
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
entity.update_visibility()
check_sized_to(entity, small, "slice/small")
placeholder = mcrfpy.Entity(grid_pos=(0, 0), grid=large)
large.entities[0:1] = [entity]
gs = entity.gridstate
assert len(gs) == 1225, f"Expected 1225, got {len(gs)}"
check_sized_to(entity, large, "slice/large")
print(" PASS: slice_assign")
def test_update_visibility_after_transfer():
"""update_visibility works correctly after all transfer methods"""
grids = [mcrfpy.Grid(grid_size=(s, s)) for s in (5, 80, 3, 60, 10, 100)]
"""update_visibility works correctly after repeated grow/shrink transfers"""
grids = [open_grid(s) for s in (5, 80, 3, 60, 10, 100)]
entity = mcrfpy.Entity(grid_pos=(2, 2), grid=grids[0])
for g in grids:
entity.grid = g
g.perspective = entity
g.fov_radius = 4
entity.update_visibility()
gs = entity.gridstate
expected = g.grid_w * g.grid_h
assert len(gs) == expected, f"Expected {expected}, got {len(gs)}"
check_sized_to(entity, g, f"cycle/{g.grid_data.grid_w}")
print(" PASS: update_visibility_after_transfer")
def test_at_after_transfer():
"""entity.at(x, y) works correctly after grid transfer"""
small = mcrfpy.Grid(grid_size=(10, 10))
large = mcrfpy.Grid(grid_size=(50, 50))
small = open_grid(10)
large = open_grid(50)
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=small)
entity.update_visibility()
entity.grid = large
# Access a cell that would be out of bounds for the small grid
large.perspective = entity
# Stand on a cell that is out of bounds for the small grid entirely.
entity.grid_pos = (30, 30)
entity.update_visibility()
# at() returns the GridPoint only when the cell is VISIBLE to this entity.
state = entity.at(30, 30)
assert state is not None
assert state is not None, "entity's own cell must be visible after transfer"
assert tuple(state.grid_pos) == (30, 30), f"got {tuple(state.grid_pos)}"
# Far side of the new grid: addressable (not a heap read), just not visible.
assert entity.at(5, 5) is None, "cell outside FOV must report as not visible"
assert entity.perspective_map.get(49, 49) == mcrfpy.Perspective.UNKNOWN
print(" PASS: at_after_transfer")
def test_set_grid_none():
"""entity.grid = None properly removes entity (tests #274)"""
grid = mcrfpy.Grid(grid_size=(10, 10))
grid = open_grid(10)
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=grid)
assert len(grid.entities) == 1
entity.grid = None
assert len(grid.entities) == 0
assert entity.grid is None
print(" PASS: set_grid_none")
def test_stress():
@ -150,26 +170,32 @@ def test_stress():
entity.grid = small_g
small_g.perspective = entity
entity.update_visibility()
assert entity.perspective_map.size == (5, 5)
big_g = mcrfpy.Grid(grid_size=(80, 80))
entity.grid = big_g
big_g.perspective = entity
entity.update_visibility()
assert entity.perspective_map.size == (80, 80)
frames = [mcrfpy.Frame() for _ in range(10)]
del frames
print(" PASS: stress")
print("Testing gridstate resize across transfer methods...")
test_set_grid()
test_append()
test_extend()
test_insert()
test_setitem()
test_slice_assign()
test_update_visibility_after_transfer()
test_at_after_transfer()
test_set_grid_none()
test_stress()
print("PASS: all gridstate resize tests passed")
print("Testing perspective map resize across transfer methods...")
try:
test_set_grid()
test_append()
test_extend()
test_insert()
test_setitem()
test_slice_assign()
test_update_visibility_after_transfer()
test_at_after_transfer()
test_set_grid_none()
test_stress()
except AssertionError as e:
print(f"FAIL: {e}")
sys.exit(1)
print("PASS: all perspective map resize tests passed")
sys.exit(0)

View file

@ -7,6 +7,14 @@ pointers would dangle.
Fix: Remove raw pointers. Store (grid, x, y) coordinates and compute
the data address on each property access.
API update (current contract): the per-entity GridPointState vector is gone.
Its successor is entity.perspective_map, a DiscreteMap of mcrfpy.Perspective
(UNKNOWN / DISCOVERED / VISIBLE), and entity.at(x, y) now returns the grid's
GridPoint only when that cell is VISIBLE to the entity (None otherwise).
The dangle scenario is preserved: the perspective map is reallocated when the
entity transfers to a differently-sized grid, and previously-obtained
GridPoint / DiscreteMap wrappers must remain safe to use afterward.
"""
import mcrfpy
import sys
@ -30,39 +38,63 @@ def test_gridpoint_grid_pos():
assert pos == (7, 3), f"Expected (7, 3), got {pos}"
print(" PASS: gridpoint_grid_pos")
def test_gridpointstate_access():
"""entity.at(x,y) returns working GridPointState via coordinate lookup"""
def test_perspective_map_access():
"""entity.at(x,y) / entity.perspective_map use coordinate lookup"""
grid = mcrfpy.Grid(grid_size=(10, 10))
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=grid)
entity.update_visibility()
state = entity.at(5, 5)
# Should be accessible
assert state is not None
# visible/discovered should be boolean
assert isinstance(state.visible, bool)
assert isinstance(state.discovered, bool)
print(" PASS: gridpointstate_access")
pm = entity.perspective_map
assert pm.size == (10, 10), f"Expected (10, 10), got {pm.size}"
def test_gridpointstate_after_grid_transfer():
"""GridPointState access works after entity transfers to new grid"""
# The entity's own cell is VISIBLE; entity.at() hands back the GridPoint.
assert pm.get((5, 5)) == mcrfpy.Perspective.VISIBLE
gp = entity.at(5, 5)
assert gp is not None
assert gp.grid_pos == (5, 5), f"Expected (5, 5), got {gp.grid_pos}"
# A cell outside the entity's FOV is UNKNOWN, and at() gates on visibility.
assert pm.get((9, 0)) == mcrfpy.Perspective.UNKNOWN
assert entity.at(9, 0) is None
print(" PASS: perspective_map_access")
def test_perspective_map_after_grid_transfer():
"""Perspective/GridPoint access works after entity transfers to new grid"""
small = mcrfpy.Grid(grid_size=(10, 10))
large = mcrfpy.Grid(grid_size=(20, 20))
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=small)
entity.update_visibility()
# Get state on small grid
state1 = entity.at(3, 3)
assert state1 is not None
# Wrappers obtained while on the small grid
gp_small = small.grid_data.at(3, 3)
pm_small = entity.perspective_map
assert pm_small.size == (10, 10)
# Transfer to large grid (gridstate resizes)
# Transfer to large grid: the entity's perspective map is reallocated.
entity.grid = large
entity.update_visibility()
# Access a cell that didn't exist on small grid
state2 = entity.at(15, 15)
assert state2 is not None
print(" PASS: gridpointstate_after_grid_transfer")
# entity.grid is the shared GridData, not the view (#313/#361)
assert entity.grid is large.grid_data
# The new map covers the larger grid, and coordinate lookup reaches cells
# that did not exist on the small grid.
pm_large = entity.perspective_map
assert pm_large.size == (20, 20), f"Expected (20, 20), got {pm_large.size}"
assert pm_large.get((15, 15)) == mcrfpy.Perspective.UNKNOWN
assert pm_large.get((5, 5)) == mcrfpy.Perspective.VISIBLE
assert entity.at(5, 5) is not None
# The pre-transfer wrappers must not dangle: they still describe the small
# grid / old map and remain safe to read and write.
assert pm_small.size == (10, 10)
assert pm_small.get((3, 3)) in (mcrfpy.Perspective.UNKNOWN,
mcrfpy.Perspective.DISCOVERED,
mcrfpy.Perspective.VISIBLE)
assert gp_small.grid_pos == (3, 3)
gp_small.walkable = True
assert small.grid_data.at(3, 3).walkable == True
print(" PASS: perspective_map_after_grid_transfer")
def test_gridpoint_subscript():
"""grid[x, y] returns working GridPoint"""
@ -72,26 +104,33 @@ def test_gridpoint_subscript():
assert grid.at(3, 4).walkable == True
print(" PASS: gridpoint_subscript")
def test_gridstate_list():
"""entity.gridstate returns list with visible/discovered attrs"""
def test_perspective_map_all_cells():
"""entity.perspective_map covers every cell of the grid"""
grid = mcrfpy.Grid(grid_size=(5, 5))
entity = mcrfpy.Entity(grid_pos=(2, 2), grid=grid)
entity.update_visibility()
gs = entity.gridstate
assert len(gs) == 25, f"Expected 25, got {len(gs)}"
# Each element should have visible and discovered
for state in gs:
assert hasattr(state, 'visible')
assert hasattr(state, 'discovered')
print(" PASS: gridstate_list")
pm = entity.perspective_map
assert pm.size == (5, 5), f"Expected (5, 5), got {pm.size}"
print("Testing GridPoint/GridPointState coordinate-based access...")
# Every one of the 25 cells is addressable and holds a Perspective member
valid = (mcrfpy.Perspective.UNKNOWN, mcrfpy.Perspective.DISCOVERED,
mcrfpy.Perspective.VISIBLE)
count = 0
for y in range(5):
for x in range(5):
state = pm.get((x, y))
assert state in valid, f"({x}, {y}) has bad state {state}"
count += 1
assert count == 25, f"Expected 25, got {count}"
print(" PASS: perspective_map_all_cells")
print("Testing GridPoint/perspective_map coordinate-based access...")
test_gridpoint_access()
test_gridpoint_grid_pos()
test_gridpointstate_access()
test_gridpointstate_after_grid_transfer()
test_perspective_map_access()
test_perspective_map_after_grid_transfer()
test_gridpoint_subscript()
test_gridstate_list()
print("PASS: all GridPoint/GridPointState tests passed")
test_perspective_map_all_cells()
print("PASS: all GridPoint/perspective_map tests passed")
sys.exit(0)

View file

@ -26,9 +26,13 @@ def main():
c.walkable = (i % 2 == 0)
assert g.at(0, 0).walkable is True
# entity.grid round-trips to the same Grid object
# entity.grid round-trips to the owning GridData wrapper.
# (#313/#361: entity.grid returns the shared GridData, NOT the Grid view.
# The #348 cache guarantee is what matters here: the same wrapper object
# comes back every time, and it is the *same* wrapper the view hands out.)
e = mcrfpy.Entity((1, 1), grid=g)
assert e.grid is g, "entity.grid should be the owning Grid"
assert e.grid is g.grid_data, "entity.grid should be the owning GridData"
assert e.grid is e.grid, "entity.grid must have stable wrapper identity"
# Reassigning grid_data invalidates the cache: new wrapper for new data
g2 = mcrfpy.Grid(grid_size=(4, 4), pos=(0, 0), size=(40, 40))

View file

@ -1,220 +1,246 @@
#!/usr/bin/env python3
"""
Comprehensive test for Issue #9: Recreate RenderTexture when UIGrid is resized
Regression test for Issue #9: Recreate RenderTexture when a grid view is resized
This test demonstrates that UIGrid has a hardcoded RenderTexture size of 1920x1080,
which causes rendering issues when the grid is resized beyond these dimensions.
The bug: UIGrid::render() created its RenderTexture once, at a hardcoded size, and
never recreated it. Resizing the grid widget beyond that texture left the new area
unrendered (clipped), and shrinking it left stale content blitted outside the box.
The bug: UIGrid::render() creates a RenderTexture with fixed size (1920x1080) once,
but never recreates it when the grid is resized, causing clipping and rendering artifacts.
The fix (UIGridView::ensureRenderTextureSize()) sizes the RenderTexture from the game
resolution and recreates it whenever that changes; the blit is clipped to the widget
box. So today a grid must render *correctly across its whole box at every size*:
* enlarging the widget renders content in the newly exposed area (was clipped),
* shrinking it leaves no stale pixels outside the new box,
* content never spills past the widget bounds,
* a widget larger than the window still renders its visible portion.
This test asserts those four properties on real screenshot pixels. The old version of
this file only printed "check the screenshots by eye" and, worse, died in setup on
grid.at(x, y).color (removed -- per-cell color lives on a ColorLayer now), so none of
it ever ran.
API notes for the update: GridPoint has no .color -> mcrfpy.ColorLayer; Grid takes
grid_size=/pos=/size= kwargs; mcrfpy.setScene/sceneUI -> mcrfpy.Scene + scene.children;
step() is the clock and never renders, automation.screenshot() forces the render.
Window.resolution cannot change in headless, so the resolution-driven recreation path
is exercised via widget resizes only.
"""
import mcrfpy
from mcrfpy import automation
import struct
import sys
import os
import zlib
def create_checkerboard_pattern(grid, grid_width, grid_height, cell_size=2):
"""Create a checkerboard pattern on the grid for visibility"""
FAILURES = []
# Grid data is large enough (160 x 160 cells) that its content covers every widget
# size used below, so "no content here" always means a rendering failure.
GRID_CELLS = 160
WHITE = (255, 255, 255)
GRAY = (100, 100, 100)
RED = (255, 0, 0)
BACKGROUND = (0, 0, 0) # scene clear color, outside any grid
CONTENT_COLORS = (WHITE, GRAY, RED)
def check(label, condition, detail=""):
if condition:
print(" PASS: %s" % label)
else:
print(" FAIL: %s %s" % (label, detail))
FAILURES.append(label)
# --------------------------------------------------------------------------- #
# Minimal PNG reader (stdlib only), same as tests/unit/validate_screenshot_test.py
# --------------------------------------------------------------------------- #
def read_png(path):
with open(path, "rb") as f:
data = f.read()
assert data[:8] == b"\x89PNG\r\n\x1a\n", "not a PNG"
pos = 8
idat = b""
width = height = depth = color = None
while pos < len(data):
(length,) = struct.unpack(">I", data[pos:pos + 4])
ctype = data[pos + 4:pos + 8]
chunk = data[pos + 8:pos + 8 + length]
pos += 12 + length
if ctype == b"IHDR":
width, height, depth, color, _, _, interlace = struct.unpack(">IIBBBBB", chunk)
assert depth == 8, "unexpected bit depth %d" % depth
assert color in (2, 6), "unexpected color type %d" % color
assert interlace == 0, "interlaced PNG not supported"
elif ctype == b"IDAT":
idat += chunk
elif ctype == b"IEND":
break
raw = zlib.decompress(idat)
channels = 3 if color == 2 else 4
stride = width * channels
out = bytearray(height * stride)
prev = bytearray(stride)
p = 0
for y in range(height):
filt = raw[p]
p += 1
line = bytearray(raw[p:p + stride])
p += stride
if filt == 1: # Sub
for i in range(channels, stride):
line[i] = (line[i] + line[i - channels]) & 0xFF
elif filt == 2: # Up
for i in range(stride):
line[i] = (line[i] + prev[i]) & 0xFF
elif filt == 3: # Average
for i in range(stride):
a = line[i - channels] if i >= channels else 0
line[i] = (line[i] + ((a + prev[i]) >> 1)) & 0xFF
elif filt == 4: # Paeth
for i in range(stride):
a = line[i - channels] if i >= channels else 0
b = prev[i]
c = prev[i - channels] if i >= channels else 0
pa, pb, pc = abs(b - c), abs(a - c), abs(a + b - 2 * c)
pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
line[i] = (line[i] + pr) & 0xFF
elif filt != 0:
raise AssertionError("bad PNG filter %d" % filt)
out[y * stride:(y + 1) * stride] = line
prev = line
return width, height, channels, bytes(out)
class Image:
def __init__(self, path):
self.w, self.h, self.ch, self.px = read_png(path)
def at(self, x, y):
i = (y * self.w + x) * self.ch
return tuple(self.px[i:i + 3])
def create_checkerboard_pattern(layer, grid_width, grid_height, cell_size=2):
"""Create a checkerboard pattern on the color layer for visibility"""
for x in range(grid_width):
for y in range(grid_height):
if (x // cell_size + y // cell_size) % 2 == 0:
grid.at(x, y).color = mcrfpy.Color(255, 255, 255, 255) # White
layer.set((x, y), mcrfpy.Color(*WHITE))
else:
grid.at(x, y).color = mcrfpy.Color(100, 100, 100, 255) # Gray
layer.set((x, y), mcrfpy.Color(*GRAY))
def add_border_markers(grid, grid_width, grid_height):
def add_border_markers(layer, grid_width, grid_height):
"""Add colored markers at the borders to test rendering limits"""
# Red border on top
for x in range(grid_width):
grid.at(x, 0).color = mcrfpy.Color(255, 0, 0, 255)
# Green border on right
layer.set((x, 0), mcrfpy.Color(*RED))
layer.set((x, grid_height - 1), mcrfpy.Color(*RED))
for y in range(grid_height):
grid.at(grid_width-1, y).color = mcrfpy.Color(0, 255, 0, 255)
layer.set((0, y), mcrfpy.Color(*RED))
layer.set((grid_width - 1, y), mcrfpy.Color(*RED))
# Blue border on bottom
for x in range(grid_width):
grid.at(x, grid_height-1).color = mcrfpy.Color(0, 0, 255, 255)
# Yellow border on left
for y in range(grid_height):
grid.at(0, y).color = mcrfpy.Color(255, 255, 0, 255)
def render(path):
"""step() advances the sim; the screenshot is what forces a render."""
mcrfpy.step(0.01)
automation.screenshot(path)
return Image(path)
def anchor_camera(grid):
"""Keep tile (0,0) at the widget's top-left after a resize (#169 default)."""
grid.center = (grid.w / 2.0, grid.h / 2.0)
def is_content(px):
return px in CONTENT_COLORS
print("=== Testing grid RenderTexture resize (Issue #9) ===\n")
# Set up the test scene
test = mcrfpy.Scene("test")
mcrfpy.current_scene = test
print("=== Testing UIGrid RenderTexture Resize (Issue #9) ===\n")
scene_ui = test.children
# Test 1: Small grid (should work fine)
print("--- Test 1: Small Grid (400x300) ---")
grid1 = mcrfpy.Grid(20, 15) # 20x15 tiles
grid1.x = 10
grid1.y = 10
grid1.w = 400
grid1.h = 300
scene_ui.append(grid1)
GRID_X, GRID_Y = 10, 10
grid = mcrfpy.Grid(grid_size=(GRID_CELLS, GRID_CELLS), pos=(GRID_X, GRID_Y), size=(200, 150))
colors = mcrfpy.ColorLayer(name="cells")
grid.add_layer(colors)
create_checkerboard_pattern(colors, GRID_CELLS, GRID_CELLS)
add_border_markers(colors, GRID_CELLS, GRID_CELLS)
scene_ui.append(grid)
create_checkerboard_pattern(grid1, 20, 15)
add_border_markers(grid1, 20, 15)
# --- Test 1: small grid renders its whole box, and nothing outside it ---------
print("--- Test 1: Small Grid (200x150) ---")
anchor_camera(grid)
img = render("/tmp/issue_9_small_grid.png")
mcrfpy.step(0.01)
automation.screenshot("/tmp/issue_9_small_grid.png")
print("PASS: Small grid created and rendered")
check("top-left tile of small grid is the red border marker",
img.at(GRID_X + 2, GRID_Y + 2) == RED,
"got %s" % (img.at(GRID_X + 2, GRID_Y + 2),))
check("interior of small grid is rendered content",
is_content(img.at(100, 100)), "got %s" % (img.at(100, 100),))
check("far corner of small grid box is rendered content",
is_content(img.at(GRID_X + 195, GRID_Y + 145)),
"got %s" % (img.at(GRID_X + 195, GRID_Y + 145),))
check("nothing rendered outside the small grid box",
img.at(500, 400) == BACKGROUND, "got %s" % (img.at(500, 400),))
# Test 2: Medium grid at 1920x1080 limit
print("\n--- Test 2: Medium Grid at 1920x1080 Limit ---")
grid2 = mcrfpy.Grid(64, 36) # 64x36 tiles at 30px each = 1920x1080
grid2.x = 10
grid2.y = 320
grid2.w = 1920
grid2.h = 1080
scene_ui.append(grid2)
# --- Test 2: enlarging the widget must render the newly exposed area ----------
# This is the heart of #9: (500,400) and (880,690) were outside the previous render
# area. With a stale RenderTexture they stay blank/clipped.
print("\n--- Test 2: Resize 200x150 -> 900x700 ---")
grid.w = 900
grid.h = 700
anchor_camera(grid)
img = render("/tmp/issue_9_resized.png")
create_checkerboard_pattern(grid2, 64, 36, 4)
add_border_markers(grid2, 64, 36)
check("area exposed by the resize is rendered (500,400)",
is_content(img.at(500, 400)), "got %s" % (img.at(500, 400),))
check("far corner of the enlarged box is rendered (880,690)",
is_content(img.at(880, 690)), "got %s" % (img.at(880, 690),))
check("enlarged grid does not spill past its right edge",
img.at(GRID_X + 900 + 5, 100) == BACKGROUND,
"got %s" % (img.at(GRID_X + 900 + 5, 100),))
check("enlarged grid does not spill past its bottom edge",
img.at(100, GRID_Y + 700 + 5) == BACKGROUND,
"got %s" % (img.at(100, GRID_Y + 700 + 5),))
mcrfpy.step(0.01)
automation.screenshot("/tmp/issue_9_limit_grid.png")
print("PASS: Grid at RenderTexture limit created")
# --- Test 3: widget larger than the window still renders its visible portion --
print("\n--- Test 3: Grid larger than the window (2400x1400) ---")
grid.w = 2400
grid.h = 1400
anchor_camera(grid)
img = render("/tmp/issue_9_beyond_window.png")
# Test 3: Resize grid1 beyond limits
print("\n--- Test 3: Resizing Small Grid Beyond 1920x1080 ---")
print("Original size: 400x300")
grid1.w = 2400
grid1.h = 1400
print(f"Resized to: {grid1.w}x{grid1.h}")
check("oversized grid renders at the far edge of the window (1000,700)",
is_content(img.at(1000, 700)), "got %s" % (img.at(1000, 700),))
check("oversized grid renders near the window origin (20,20)",
is_content(img.at(20, 20)), "got %s" % (img.at(20, 20),))
# The content should still be visible but may be clipped
mcrfpy.step(0.01)
automation.screenshot("/tmp/issue_9_resized_beyond_limit.png")
print("EXPECTED ISSUE: Grid resized beyond RenderTexture limits")
print(" Content beyond 1920x1080 will be clipped!")
# --- Test 4: shrinking must not leave stale pixels outside the new box --------
print("\n--- Test 4: Shrink back to 200x150 ---")
grid.w = 200
grid.h = 150
anchor_camera(grid)
img = render("/tmp/issue_9_shrunk.png")
# Test 4: Create large grid from start
print("\n--- Test 4: Large Grid from Start (2400x1400) ---")
# Clear previous grids
while len(scene_ui) > 0:
scene_ui.remove(0)
check("shrunken grid still renders inside its box",
is_content(img.at(100, 100)), "got %s" % (img.at(100, 100),))
check("no stale content left outside the shrunken box (500,400)",
img.at(500, 400) == BACKGROUND, "got %s" % (img.at(500, 400),))
check("no stale content left outside the shrunken box (880,690)",
img.at(880, 690) == BACKGROUND, "got %s" % (img.at(880, 690),))
grid3 = mcrfpy.Grid(80, 50) # Large tile count
grid3.x = 10
grid3.y = 10
grid3.w = 2400
grid3.h = 1400
scene_ui.append(grid3)
create_checkerboard_pattern(grid3, 80, 50, 5)
add_border_markers(grid3, 80, 50)
# Add markers at specific positions to test rendering
# Mark the center
center_x, center_y = 40, 25
for dx in range(-2, 3):
for dy in range(-2, 3):
grid3.at(center_x + dx, center_y + dy).color = mcrfpy.Color(255, 0, 255, 255) # Magenta
# Mark position at 1920 pixel boundary (64 tiles * 30 pixels/tile = 1920)
if 64 < 80: # Only if within grid bounds
for y in range(min(50, 10)):
grid3.at(64, y).color = mcrfpy.Color(255, 128, 0, 255) # Orange
mcrfpy.step(0.01)
automation.screenshot("/tmp/issue_9_large_grid.png")
print("EXPECTED ISSUE: Large grid created")
print(" Content beyond 1920x1080 will not render!")
print(" Look for missing orange line at x=1920 boundary")
# Test 5: Dynamic resize test
print("\n--- Test 5: Dynamic Resize Test ---")
scene_ui.remove(0)
grid4 = mcrfpy.Grid(100, 100)
grid4.x = 10
grid4.y = 10
scene_ui.append(grid4)
sizes = [(500, 500), (1000, 1000), (1500, 1500), (2000, 2000), (2500, 2500)]
for i, (w, h) in enumerate(sizes):
grid4.w = w
grid4.h = h
# Add pattern at current size
visible_tiles_x = min(100, w // 30)
visible_tiles_y = min(100, h // 30)
# Clear and create new pattern
for x in range(visible_tiles_x):
for y in range(visible_tiles_y):
if x == visible_tiles_x - 1 or y == visible_tiles_y - 1:
# Edge markers
grid4.at(x, y).color = mcrfpy.Color(255, 255, 0, 255)
elif (x + y) % 10 == 0:
# Diagonal lines
grid4.at(x, y).color = mcrfpy.Color(0, 255, 255, 255)
mcrfpy.step(0.01)
automation.screenshot(f"/tmp/issue_9_resize_{w}x{h}.png")
if w > 1920 or h > 1080:
print(f"FAIL: Size {w}x{h}: Content clipped at 1920x1080")
else:
print(f"PASS: Size {w}x{h}: Rendered correctly")
# Test 6: Verify exact clipping boundary
print("\n--- Test 6: Exact Clipping Boundary Test ---")
scene_ui.remove(0)
grid5 = mcrfpy.Grid(70, 40)
grid5.x = 0
grid5.y = 0
grid5.w = 2100 # 70 * 30 = 2100 pixels
grid5.h = 1200 # 40 * 30 = 1200 pixels
scene_ui.append(grid5)
# Create a pattern that shows the boundary clearly
for x in range(70):
for y in range(40):
pixel_x = x * 30
pixel_y = y * 30
if pixel_x == 1920 - 30: # Last tile before boundary
grid5.at(x, y).color = mcrfpy.Color(255, 0, 0, 255) # Red
elif pixel_x == 1920: # First tile after boundary
grid5.at(x, y).color = mcrfpy.Color(0, 255, 0, 255) # Green
elif pixel_y == 1080 - 30: # Last row before boundary
grid5.at(x, y).color = mcrfpy.Color(0, 0, 255, 255) # Blue
elif pixel_y == 1080: # First row after boundary
grid5.at(x, y).color = mcrfpy.Color(255, 255, 0, 255) # Yellow
else:
# Normal checkerboard
if (x + y) % 2 == 0:
grid5.at(x, y).color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.step(0.01)
automation.screenshot("/tmp/issue_9_boundary_test.png")
print("Screenshot saved showing clipping boundary")
print("- Red tiles: Last visible column (x=1890-1919)")
print("- Green tiles: First clipped column (x=1920+)")
print("- Blue tiles: Last visible row (y=1050-1079)")
print("- Yellow tiles: First clipped row (y=1080+)")
# Summary
print("\n=== SUMMARY ===")
print("Issue #9: UIGrid uses a hardcoded RenderTexture size of 1920x1080")
print("Problems demonstrated:")
print("1. Grids larger than 1920x1080 are clipped")
print("2. Resizing grids doesn't recreate the RenderTexture")
print("3. Content beyond the boundary is not rendered")
print("\nThe fix should:")
print("1. Recreate RenderTexture when grid size changes")
print("2. Use the actual grid dimensions instead of hardcoded values")
print("3. Consider memory limits for very large grids")
if FAILURES:
print("FAIL: %d check(s) failed: %s" % (len(FAILURES), ", ".join(FAILURES)))
sys.exit(1)
print(f"\nScreenshots saved to /tmp/issue_9_*.png")
print("\nTest complete - check screenshots for visual verification")
print("Screenshots saved to /tmp/issue_9_*.png")
print("PASS")
sys.exit(0)

View file

@ -25,8 +25,7 @@ def recursive_callback(target, prop, value):
callback_count += 1
if callback_count >= MAX_CALLBACKS:
print(f"PASS - {callback_count} recursive animation callbacks completed without segfault")
sys.exit(0)
return
# Chain another animation - this used to cause segfault due to iterator invalidation
target.animate("x", 100 + (callback_count * 20), 0.1, mcrfpy.Easing.LINEAR, callback=recursive_callback)
@ -34,13 +33,29 @@ def recursive_callback(target, prop, value):
# Start the chain
frame.animate("x", 200, 0.1, mcrfpy.Easing.LINEAR, callback=recursive_callback)
def timeout_check(timer, runtime):
"""Safety timeout"""
if callback_count >= MAX_CALLBACKS:
print(f"PASS - {callback_count} callbacks completed")
sys.exit(0)
else:
print(f"FAIL - only {callback_count}/{MAX_CALLBACKS} callbacks executed")
sys.exit(1)
# In headless mode mcrfpy.step() is the only clock: drive the animation chain forward.
# Each link is 0.1s; MAX_CALLBACKS links need >= 1.0s of simulated time. Step generously
# and bail out as soon as the chain is complete.
MAX_STEPS = 400
steps = 0
while callback_count < MAX_CALLBACKS and steps < MAX_STEPS:
mcrfpy.step(0.02)
steps += 1
safety_timer = mcrfpy.Timer("safety", timeout_check, 5000, once=True)
if callback_count < MAX_CALLBACKS:
print(f"FAIL - only {callback_count}/{MAX_CALLBACKS} callbacks executed after {steps} steps")
sys.exit(1)
# The chain completed without segfault (iterator invalidation in AnimationManager::update).
# The final animation of the chain targets x = 100 + (MAX_CALLBACKS-1)*20; run a few more
# steps so it settles, proving the manager is still healthy after the recursive churn.
for _ in range(20):
mcrfpy.step(0.02)
expected_x = 100 + (MAX_CALLBACKS - 1) * 20
if abs(frame.x - expected_x) > 0.5:
print(f"FAIL - final animation did not complete: frame.x={frame.x}, expected {expected_x}")
sys.exit(1)
print(f"PASS - {callback_count} recursive animation callbacks completed without segfault; frame.x={frame.x}")
sys.exit(0)

View file

@ -1,12 +1,25 @@
#!/usr/bin/env python3
"""Example of CORRECT test pattern using mcrfpy.step() for automation
Refactored from timer-based approach to synchronous step() pattern.
Note (#350/#341): step() is the simulation clock and never renders;
automation.screenshot() is what forces a render of the current scene.
"""
import mcrfpy
from mcrfpy import automation
from datetime import datetime
import os
import sys
failures = []
def check(condition, message):
if condition:
print(f" ok: {message}")
else:
print(f" FAIL: {message}")
failures.append(message)
# This code runs during --exec script execution
print("=== Setting Up Test Scene ===")
@ -32,11 +45,11 @@ 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}")
# (#230) on_click receives (pos: Vector, button: MouseButton, action: InputState)
clicks_received = []
def frame_clicked(pos, button, action):
clicks_received.append((pos, button, action))
print(f"Frame clicked at ({pos.x}, {pos.y}) with button {button} ({action})")
frame.on_click = frame_clicked
@ -54,13 +67,25 @@ 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}")
check(result is True, "screenshot() reported success")
check(os.path.exists(filename), f"{filename} exists on disk")
# A blank 1024x768 PNG compresses to a few hundred bytes; real content is far bigger
check(os.path.getsize(filename) > 2000,
f"{filename} contains rendered content (size {os.path.getsize(filename)} > 2000)")
# Test clicking on the frame
automation.click(200, 200) # Click in center of red frame
automation.click((200, 200)) # Click in center of red frame
# Step to process the click
mcrfpy.step(0.1)
check(len(clicks_received) > 0, "frame.on_click fired for click inside the frame")
if clicks_received:
pos, button, action = clicks_received[0]
check((pos.x, pos.y) == (200.0, 200.0), "click reported the position it was sent to")
check(button == mcrfpy.MouseButton.LEFT, "click reported the LEFT button")
check(action == mcrfpy.InputState.PRESSED, "click reported the PRESSED state")
# Test keyboard input
automation.typewrite("Hello from step-based test!")
@ -69,14 +94,26 @@ mcrfpy.step(0.1)
# Take another screenshot to show any changes
filename2 = f"WORKING_screenshot_after_click_{timestamp}.png"
automation.screenshot(filename2)
result2 = automation.screenshot(filename2)
print(f"Second screenshot: {filename2}")
check(result2 is True, "second screenshot() reported success")
check(os.path.exists(filename2), f"{filename2} exists on disk")
# Clean up the artifacts this test produced
for f in (filename, filename2):
if os.path.exists(f):
os.remove(f)
print("Test completed successfully!")
print("\nThis works because:")
print("1. mcrfpy.step() advances simulation synchronously")
print("2. The scene renders during step() calls")
print("2. automation.screenshot() forces a render of the active scene")
print("3. The RenderTexture contains actual rendered content")
print("PASS")
if failures:
print(f"\nFAIL: {len(failures)} check(s) failed")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("\nPASS")
sys.exit(0)

View file

@ -90,12 +90,21 @@ for _ in range(10):
check("replacement anim completes", anim5b.is_complete == True)
# --- Test 6: Animation object created with loop=True via constructor ---
anim6 = mcrfpy.Animation("x", 100.0, 1.0, loop=True)
check("Animation constructor loop=True", anim6.is_looping == True)
# --- Test 6: loop flag set at Animation creation time ---
# mcrfpy.Animation is no longer exported; the successor factory is target.animate(),
# which returns the Animation object. Same intent: the loop keyword is honored at
# creation, and defaults to False.
sprite6 = mcrfpy.Sprite(pos=(0, 0))
scene.children.append(sprite6)
anim7 = mcrfpy.Animation("x", 100.0, 1.0)
check("Animation constructor default loop=False", anim7.is_looping == False)
anim6 = sprite6.animate("x", 100.0, 1.0, loop=True)
check("animate(loop=True) sets is_looping", anim6.is_looping == True)
anim7 = sprite6.animate("y", 100.0, 1.0)
check("animate() default loop=False", anim7.is_looping == False)
anim8 = sprite6.animate("z_index", 5, 1.0, loop=False)
check("animate(loop=False) sets is_looping False", anim8.is_looping == False)
# --- Summary ---

View file

@ -1,34 +1,65 @@
#!/usr/bin/env python3
"""Test for mcrfpy.createScene() method"""
"""Test for mcrfpy.Scene() creation and activation (formerly mcrfpy.createScene())"""
import mcrfpy
import sys
def test_createScene():
"""Test creating a new scene"""
failures = []
scenes = {}
# Test creating scenes
test_scenes = ["test_scene1", "test_scene2", "special_chars_!@#"]
for scene_name in test_scenes:
try:
_scene = mcrfpy.Scene(scene_name)
print(f" Created scene: {scene_name}")
scenes[scene_name] = mcrfpy.Scene(scene_name)
print(f"PASS: Created scene: {scene_name}")
except Exception as e:
print(f"✗ Failed to create scene {scene_name}: {e}")
return
# Try to set scene to verify it was created
try:
test_scene1.activate() # Note: ensure scene was created
current = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
if current == "test_scene1":
print("✓ Scene switching works correctly")
else:
print(f"✗ Scene switch failed: expected 'test_scene1', got '{current}'")
except Exception as e:
print(f"✗ Scene switching error: {e}")
print("PASS")
print(f"FAIL: Failed to create scene {scene_name}: {e}")
failures.append(f"create {scene_name}")
continue
if scenes[scene_name].name != scene_name:
print(f"FAIL: scene.name mismatch: expected '{scene_name}', got '{scenes[scene_name].name}'")
failures.append(f"name {scene_name}")
# Activate a created scene to verify it exists and is switchable
if "test_scene1" in scenes:
try:
scenes["test_scene1"].activate()
current = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
if current == "test_scene1":
print("PASS: Scene switching works correctly")
else:
print(f"FAIL: Scene switch failed: expected 'test_scene1', got '{current}'")
failures.append("activate()")
except Exception as e:
print(f"FAIL: Scene switching error: {e}")
failures.append("activate()")
# current_scene is also writable directly
try:
mcrfpy.current_scene = scenes["test_scene2"]
current = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
if current == "test_scene2":
print("PASS: mcrfpy.current_scene assignment works correctly")
else:
print(f"FAIL: current_scene assignment failed: expected 'test_scene2', got '{current}'")
failures.append("current_scene=")
except Exception as e:
print(f"FAIL: current_scene assignment error: {e}")
failures.append("current_scene=")
return failures
# Run test immediately
print("Running createScene test...")
test_createScene()
print("Test completed.")
failures = test_createScene()
print("Test completed.")
if failures:
print("FAIL: " + ", ".join(failures))
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,44 +1,78 @@
#!/usr/bin/env python3
"""Test for mcrfpy.setScene() and currentScene() methods"""
"""Test for mcrfpy.current_scene (successor to setScene()/currentScene())"""
import mcrfpy
import sys
print("Starting setScene/currentScene test...")
print("Starting current_scene test...")
# Create test scenes first
scenes = ["scene_A", "scene_B", "scene_C"]
scene_objs = {}
for scene in scenes:
_scene = mcrfpy.Scene(scene)
scene_objs[scene] = mcrfpy.Scene(scene)
print(f"Created scene: {scene}")
results = []
# Test switching between scenes
# Test switching between scenes by name (mcrfpy.current_scene is read/write)
for scene in scenes:
try:
mcrfpy.current_scene = scene
current = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
if current == scene:
results.append(f"✓ setScene/currentScene works for '{scene}'")
results.append(f"[ok] current_scene set/get by name works for '{scene}'")
else:
results.append(f" Scene mismatch: set '{scene}', got '{current}'")
results.append(f"[FAIL] Scene mismatch: set '{scene}', got '{current}'")
except Exception as e:
results.append(f" Error with scene '{scene}': {e}")
results.append(f"[FAIL] Error with scene '{scene}': {e}")
# Test invalid scene - it should not change the current scene
# Test switching by Scene object, and via Scene.activate()
for scene in scenes:
try:
mcrfpy.current_scene = scene_objs[scene]
current = mcrfpy.current_scene
if current.name == scene and current.active:
results.append(f"[ok] current_scene set by Scene object works for '{scene}'")
else:
results.append(f"[FAIL] Object assign mismatch: set '{scene}', got '{current.name}'")
except Exception as e:
results.append(f"[FAIL] Error assigning Scene object '{scene}': {e}")
try:
scene_objs["scene_B"].activate()
if mcrfpy.current_scene.name == "scene_B":
results.append("[ok] Scene.activate() switches the current scene")
else:
results.append(f"[FAIL] activate() left current scene at '{mcrfpy.current_scene.name}'")
except Exception as e:
results.append(f"[FAIL] Error in Scene.activate(): {e}")
# Test invalid scene - it must not change the current scene.
# Current contract: assigning an unknown scene name raises KeyError and the
# current scene is left untouched (the old setScene() silently ignored it).
current_before = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
nonexistent_scene.activate() # Note: ensure scene was created
try:
mcrfpy.current_scene = "nonexistent_scene"
results.append("[FAIL] Assigning a nonexistent scene name did not raise")
except KeyError:
results.append("[ok] Assigning a nonexistent scene name raises KeyError")
except Exception as e:
results.append(f"[FAIL] Expected KeyError for nonexistent scene, got {type(e).__name__}: {e}")
current_after = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
if current_before == current_after:
results.append(f"✓ setScene correctly ignores nonexistent scene (stayed on '{current_after}')")
results.append(f"[ok] Failed switch leaves current scene alone (stayed on '{current_after}')")
else:
results.append(f"✗ Scene changed unexpectedly from '{current_before}' to '{current_after}'")
results.append(f"[FAIL] Scene changed unexpectedly from '{current_before}' to '{current_after}'")
# Print results
for result in results:
print(result)
# Determine pass/fail
if all("" in r for r in results):
if all("[ok]" in r for r in results):
print("PASS")
sys.exit(0)
else:
print("FAIL")
print("FAIL")
sys.exit(1)

View file

@ -1,80 +1,125 @@
#!/usr/bin/env python3
"""Debug empty paths issue"""
"""Debug empty paths issue
Original intent: pathfinding was returning EMPTY paths on a fully-walkable grid.
This test verifies that A* and Dijkstra both return non-empty paths, that walls
are respected (detour), that an unreachable/blocked destination yields no path,
and that the TCOD map stays in sync with walkability changes.
API notes (2026-07): compute_astar_path/compute_dijkstra/get_dijkstra_path are gone.
Pathfinding lives on GridData: find_path() -> AStarPath | None,
get_dijkstra_map(root=...) -> DijkstraMap (cached; clear_dijkstra_maps() after
walkability changes). TCOD sync is automatic -- there is no sync_tcod_map().
"""
import mcrfpy
import sys
print("Debugging empty paths...")
failures = []
def check(cond, msg):
if cond:
print(f" OK: {msg}")
else:
print(f" FAIL: {msg}")
failures.append(msg)
# Create scene and grid
debug = mcrfpy.Scene("debug")
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(320, 320))
data = grid.grid_data
# Initialize grid - all walkable
print("\nInitializing grid...")
for y in range(10):
for x in range(10):
grid.at(x, y).walkable = True
data.at(x, y).walkable = True
# Test simple path
print("\nTest 1: Simple path from (0,0) to (5,5)")
path = grid.compute_astar_path(0, 0, 5, 5)
print(f" A* path: {path}")
print(f" Path length: {len(path)}")
path = data.find_path((0, 0), (5, 5))
check(path is not None, "A* found a path on an all-walkable grid")
steps = list(path) if path else []
print(f" A* path: {steps}")
print(f" Path length: {len(steps)}")
check(len(steps) > 0, "A* path is not empty")
check(path is not None and tuple(path.destination) == (5, 5), "A* path ends at (5,5)")
# Test with Dijkstra
print("\nTest 2: Same path with Dijkstra")
grid.compute_dijkstra(0, 0)
dpath = grid.get_dijkstra_path(5, 5)
print(f" Dijkstra path: {dpath}")
print(f" Path length: {len(dpath)}")
dmap = data.get_dijkstra_map(root=(0, 0))
dpath = dmap.path_from((5, 5))
dsteps = list(dpath) if dpath else []
print(f" Dijkstra path: {dsteps}")
print(f" Path length: {len(dsteps)}")
check(len(dsteps) > 0, "Dijkstra path is not empty")
check(dmap.distance((5, 5)) > 0, "Dijkstra distance to (5,5) is finite and positive")
# Check if grid is properly initialized
print("\nTest 3: Checking grid cells")
for y in range(3):
for x in range(3):
cell = grid.at(x, y)
cell = data.at(x, y)
print(f" Cell ({x},{y}): walkable={cell.walkable}")
check(cell.walkable, f"cell ({x},{y}) reports walkable=True")
# Test with walls
print("\nTest 4: Path with wall")
grid.at(2, 2).walkable = False
grid.at(3, 2).walkable = False
grid.at(4, 2).walkable = False
for wx in (2, 3, 4):
data.at(wx, 2).walkable = False
data.clear_dijkstra_maps() # walkability changed: invalidate cached maps
print(" Added wall at y=2, x=2,3,4")
path2 = grid.compute_astar_path(0, 0, 5, 5)
print(f" A* path with wall: {path2}")
print(f" Path length: {len(path2)}")
path2 = data.find_path((0, 0), (5, 5))
check(path2 is not None, "A* still finds a path around the wall")
steps2 = list(path2) if path2 else []
print(f" A* path with wall: {steps2}")
print(f" Path length: {len(steps2)}")
check(len(steps2) > 0, "walled A* path is not empty")
blocked = {(2, 2), (3, 2), (4, 2)}
check(not any(tuple(p) in blocked for p in steps2),
"A* path does not cross the wall cells (TCOD map synced with walkability)")
# Test invalid paths
print("\nTest 5: Path to blocked cell")
grid.at(9, 9).walkable = False
path3 = grid.compute_astar_path(0, 0, 9, 9)
data.at(9, 9).walkable = False
data.clear_dijkstra_maps()
path3 = data.find_path((0, 0), (9, 9))
print(f" Path to blocked cell: {path3}")
check(path3 is None or len(list(path3)) == 0,
"no path is produced to a non-walkable destination")
# Check TCOD map sync
print("\nTest 6: Verify TCOD map is synced")
# Try to force a sync
print(" Checking if syncTCODMap exists...")
if hasattr(grid, 'sync_tcod_map'):
print(" Calling sync_tcod_map()")
grid.sync_tcod_map()
else:
print(" No sync_tcod_map method found")
# Fully wall off (9,8) and (8,9) too -- (9,9) neighbors -- then re-open (9,9):
# a stale TCOD map would still refuse the destination.
data.at(9, 9).walkable = True
data.clear_dijkstra_maps()
path_reopened = data.find_path((0, 0), (9, 9))
check(path_reopened is not None and len(list(path_reopened)) > 0,
"re-opening a blocked cell makes it reachable again (automatic TCOD sync)")
# Try path again
print("\nTest 7: Path after potential sync")
path4 = grid.compute_astar_path(0, 0, 5, 5)
print(f" A* path: {path4}")
print("\nTest 7: Path after mutation round-trip")
path4 = data.find_path((0, 0), (5, 5))
steps4 = list(path4) if path4 else []
print(f" A* path: {steps4}")
check(len(steps4) > 0, "A* path (0,0)->(5,5) still non-empty after mutations")
def timer_cb(timer, runtime):
sys.exit(0)
# Quick UI setup
# Quick UI setup: the grid must survive being attached to a live scene.
ui = debug.children
ui.append(grid)
debug.activate()
exit_timer = mcrfpy.Timer("exit", timer_cb, 100, once=True)
mcrfpy.step(0.1)
check(mcrfpy.current_scene is debug, "grid attached to the active scene")
print("\nStarting timer...")
if failures:
print(f"\nFAIL ({len(failures)} check(s) failed):")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("\nPASS")
sys.exit(0)

View file

@ -1,2 +1,34 @@
# This script is intentionally empty
pass
# This script is intentionally (almost) empty.
#
# Intent: the engine must survive a script that sets nothing up -- no scene, no UI,
# no timers -- and must not crash when the headless clock is driven in that state.
#
# Under the #350 exit contract a bare `pass` is no longer a valid --exec script:
# falling off the end exits nonzero with a diagnostic. So the "do nothing" case is
# now spelled as "do nothing, then exit 0", plus the checks that make it a real test.
import sys
import mcrfpy
failures = []
# A script that never creates a scene must still find the engine in a sane state.
if not hasattr(mcrfpy, "step"):
failures.append("mcrfpy.step is missing")
if not hasattr(mcrfpy, "current_scene"):
failures.append("mcrfpy.current_scene is missing")
# Driving the clock with no scene / no timers / no animations must be a harmless no-op.
try:
for _ in range(3):
mcrfpy.step(0.05)
except Exception as e:
failures.append("mcrfpy.step() raised with no scene set up: %r" % (e,))
if failures:
for f in failures:
print("FAIL:", f)
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,7 +1,78 @@
#!/usr/bin/env python3
"""Test if calling mcrfpy.exit() prevents the >>> prompt"""
import mcrfpy
"""Test that mcrfpy.exit() shuts the engine down instead of leaving it running
Original intent: "does calling mcrfpy.exit() prevent the >>> prompt?" -- i.e. does
exit() actually terminate the application rather than falling through into an
interactive/never-ending loop?
Current contract (#350): mcrfpy.exit() calls GameEngine::quit(); it stops the run
loop. It is NOT a SystemExit -- the Python script keeps executing after it, and a
headless --exec script still states its own outcome with sys.exit(). So the real
"did exit() work?" question is answered in a child process that DOES enter the run
loop (--run-forever): with exit() it must terminate; without it, it must not.
"""
import mcrfpy
import os
import subprocess
import sys
failures = []
scratch = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "build")
scratch = os.path.abspath(scratch)
# --- 1. exit() is a no-arg call returning None; it does not abort the interpreter ---
print("Calling mcrfpy.exit() immediately...")
mcrfpy.exit()
print("This should not print if exit worked")
result = mcrfpy.exit()
print("This still prints: exit() quits the engine, it does not raise SystemExit")
if result is not None:
failures.append("mcrfpy.exit() should return None, got %r" % (result,))
try:
mcrfpy.exit(1)
except TypeError:
pass
else:
failures.append("mcrfpy.exit() takes no arguments; exit(1) should raise TypeError")
# --- 2. exit() really stops the run loop (the ">>> prompt"/hang the test was about) ---
child_exit = os.path.join(scratch, "_exit_immediately_child_exit.py")
child_stay = os.path.join(scratch, "_exit_immediately_child_stay.py")
with open(child_exit, "w") as f:
f.write("import mcrfpy\nmcrfpy.exit()\n")
with open(child_stay, "w") as f:
f.write("import mcrfpy\n")
try:
# sys.executable is the mcrogueface binary itself.
proc = subprocess.run(
[sys.executable, "--headless", "--run-forever", "--exec", child_exit],
cwd=scratch, capture_output=True, text=True, timeout=20)
if proc.returncode != 0:
failures.append("exit() child should terminate cleanly, got returncode %d"
% proc.returncode)
except subprocess.TimeoutExpired:
failures.append("exit() did not stop the run loop: --run-forever child hung")
# Control: without exit(), --run-forever must keep the process alive. This proves the
# check above is actually observing exit(), not just a run loop that always ends.
try:
subprocess.run(
[sys.executable, "--headless", "--run-forever", "--exec", child_stay],
cwd=scratch, capture_output=True, text=True, timeout=5)
failures.append("control child (no exit()) terminated on its own; "
"the exit() check above proves nothing")
except subprocess.TimeoutExpired:
pass # expected: still running
for path in (child_exit, child_stay):
if os.path.exists(path):
os.remove(path)
if failures:
for f in failures:
print("FAIL: %s" % f)
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -3,17 +3,26 @@
import mcrfpy
from mcrfpy import automation
import os
import sys
def capture_sprites(timer, runtime):
"""Capture sprite examples after render loop starts"""
SCREENSHOT = "ui_sprite_example.png"
# Take screenshot
automation.screenshot("mcrogueface.github.io/images/ui_sprite_example.png")
results = []
def check(label, condition):
results.append((label, bool(condition)))
print(f"{'PASS' if condition else 'FAIL'}: {label}")
def capture_sprites(timer, runtime):
"""Capture sprite examples once the timer fires"""
# Take screenshot (rendering is on-demand in headless: screenshot forces a render)
automation.screenshot(SCREENSHOT)
print("Sprite screenshot saved!")
# Exit after capturing
sys.exit(0)
check("screenshot file created", os.path.exists(SCREENSHOT))
check("screenshot is non-empty", os.path.exists(SCREENSHOT) and os.path.getsize(SCREENSHOT) > 0)
# Create scene
sprites = mcrfpy.Scene("sprites")
@ -21,9 +30,8 @@ sprites = mcrfpy.Scene("sprites")
# Load texture
texture = mcrfpy.Texture("assets/kenney_TD_MR_IP.png", 16, 16)
# Title
title = mcrfpy.Caption(pos=(400, 30), text="Sprite Examples")
title.font = mcrfpy.default_font
# Title (Caption.font is read-only: pass it to the constructor)
title = mcrfpy.Caption(pos=(400, 30), font=mcrfpy.default_font, text="Sprite Examples")
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 255)
@ -33,102 +41,71 @@ frame.fill_color = mcrfpy.Color(64, 64, 128)
frame.outline = 2
# Player sprite
player_label = mcrfpy.Caption(pos=(100, 120), text="Player")
player_label.font = mcrfpy.default_font
player_label = mcrfpy.Caption(pos=(100, 120), font=mcrfpy.default_font, text="Player")
player_label.fill_color = mcrfpy.Color(255, 255, 255)
player = mcrfpy.Sprite(120, 150)
player.texture = texture
player.sprite_index = 84 # Player sprite
player.scale = (3.0, 3.0)
player = mcrfpy.Sprite(pos=(120, 150), texture=texture, sprite_index=84) # Player sprite
player.scale = 3.0
# Enemy sprites
enemy_label = mcrfpy.Caption(pos=(250, 120), text="Enemies")
enemy_label.font = mcrfpy.default_font
enemy_label = mcrfpy.Caption(pos=(250, 120), font=mcrfpy.default_font, text="Enemies")
enemy_label.fill_color = mcrfpy.Color(255, 255, 255)
rat = mcrfpy.Sprite(250, 150)
rat.texture = texture
rat.sprite_index = 123 # Rat
rat.scale = (3.0, 3.0)
rat = mcrfpy.Sprite(pos=(250, 150), texture=texture, sprite_index=123) # Rat
rat.scale = 3.0
big_rat = mcrfpy.Sprite(320, 150)
big_rat.texture = texture
big_rat.sprite_index = 130 # Big rat
big_rat.scale = (3.0, 3.0)
big_rat = mcrfpy.Sprite(pos=(320, 150), texture=texture, sprite_index=130) # Big rat
big_rat.scale = 3.0
cyclops = mcrfpy.Sprite(390, 150)
cyclops.texture = texture
cyclops.sprite_index = 109 # Cyclops
cyclops.scale = (3.0, 3.0)
cyclops = mcrfpy.Sprite(pos=(390, 150), texture=texture, sprite_index=109) # Cyclops
cyclops.scale = 3.0
# Items row
items_label = mcrfpy.Caption(pos=(100, 250), text="Items")
items_label.font = mcrfpy.default_font
items_label = mcrfpy.Caption(pos=(100, 250), font=mcrfpy.default_font, text="Items")
items_label.fill_color = mcrfpy.Color(255, 255, 255)
# Boulder
boulder = mcrfpy.Sprite(100, 280)
boulder.texture = texture
boulder.sprite_index = 66 # Boulder
boulder.scale = (3.0, 3.0)
boulder = mcrfpy.Sprite(pos=(100, 280), texture=texture, sprite_index=66) # Boulder
boulder.scale = 3.0
# Chest
chest = mcrfpy.Sprite(170, 280)
chest.texture = texture
chest.sprite_index = 89 # Closed chest
chest.scale = (3.0, 3.0)
chest = mcrfpy.Sprite(pos=(170, 280), texture=texture, sprite_index=89) # Closed chest
chest.scale = 3.0
# Key
key = mcrfpy.Sprite(240, 280)
key.texture = texture
key.sprite_index = 384 # Key
key.scale = (3.0, 3.0)
key = mcrfpy.Sprite(pos=(240, 280), texture=texture, sprite_index=384) # Key
key.scale = 3.0
# Button
button = mcrfpy.Sprite(310, 280)
button.texture = texture
button.sprite_index = 250 # Button
button.scale = (3.0, 3.0)
button = mcrfpy.Sprite(pos=(310, 280), texture=texture, sprite_index=250) # Button
button.scale = 3.0
# UI elements row
ui_label = mcrfpy.Caption(pos=(100, 380), text="UI Elements")
ui_label.font = mcrfpy.default_font
ui_label = mcrfpy.Caption(pos=(100, 380), font=mcrfpy.default_font, text="UI Elements")
ui_label.fill_color = mcrfpy.Color(255, 255, 255)
# Hearts
heart_full = mcrfpy.Sprite(100, 410)
heart_full.texture = texture
heart_full.sprite_index = 210 # Full heart
heart_full.scale = (3.0, 3.0)
heart_full = mcrfpy.Sprite(pos=(100, 410), texture=texture, sprite_index=210) # Full heart
heart_full.scale = 3.0
heart_half = mcrfpy.Sprite(170, 410)
heart_half.texture = texture
heart_half.sprite_index = 209 # Half heart
heart_half.scale = (3.0, 3.0)
heart_half = mcrfpy.Sprite(pos=(170, 410), texture=texture, sprite_index=209) # Half heart
heart_half.scale = 3.0
heart_empty = mcrfpy.Sprite(240, 410)
heart_empty.texture = texture
heart_empty.sprite_index = 208 # Empty heart
heart_empty.scale = (3.0, 3.0)
heart_empty = mcrfpy.Sprite(pos=(240, 410), texture=texture, sprite_index=208) # Empty heart
heart_empty.scale = 3.0
# Armor
armor = mcrfpy.Sprite(340, 410)
armor.texture = texture
armor.sprite_index = 211 # Armor
armor.scale = (3.0, 3.0)
armor = mcrfpy.Sprite(pos=(340, 410), texture=texture, sprite_index=211) # Armor
armor.scale = 3.0
# Scale demonstration
scale_label = mcrfpy.Caption(pos=(500, 120), text="Scale Demo")
scale_label.font = mcrfpy.default_font
scale_label = mcrfpy.Caption(pos=(500, 120), font=mcrfpy.default_font, text="Scale Demo")
scale_label.fill_color = mcrfpy.Color(255, 255, 255)
# Same sprite at different scales
for i, scale in enumerate([1.0, 2.0, 3.0, 4.0]):
s = mcrfpy.Sprite(500 + i * 60, 150)
s.texture = texture
s.sprite_index = 84 # Player
s.scale = (scale, scale)
s = mcrfpy.Sprite(pos=(500 + i * 60, 150), texture=texture, sprite_index=84) # Player
s.scale = scale
sprites.children.append(s)
# Add all elements to scene
@ -156,5 +133,22 @@ ui.append(scale_label)
# Switch to scene
sprites.activate()
# Set timer to capture after rendering starts
capture_timer = mcrfpy.Timer("capture", capture_sprites, 100, once=True)
# Everything got built and attached
check("scene populated with sprite showcase", len(sprites.children) == 23)
check("sprite scale applied", player.scale == 3.0)
check("sprite index applied", player.sprite_index == 84)
# Set timer to capture; headless has no clock of its own, so drive it with step()
capture_timer = mcrfpy.Timer("capture", capture_sprites, 100, once=True)
for _ in range(4):
mcrfpy.step(0.05)
check("capture timer fired", any(label.startswith("screenshot") for label, _ in results))
failures = [label for label, ok in results if not ok]
if failures:
print(f"FAIL - {len(failures)} check(s) failed: {failures}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -28,6 +28,7 @@ for i in range(1, 8):
# Apply camera rotation
grid.camera_rotation = 30.0 # 30 degree rotation
grid.center_camera((4, 4)) # Center on middle of grid
assert grid.camera_rotation == 30.0, "camera_rotation should round-trip"
ui.append(grid)
@ -46,6 +47,10 @@ for i in range(1, 8):
grid2.camera_rotation = 0.0 # No rotation
grid2.center_camera((4, 4))
assert grid2.camera_rotation == 0.0, "camera_rotation=0 should round-trip"
# camera_rotation is per-view state: rotating grid must not disturb grid2
assert grid.camera_rotation == 30.0, "camera_rotation must be per-grid, not shared"
ui.append(grid2)
@ -69,6 +74,9 @@ for i in range(6):
grid3.rotation = 15.0
grid3.origin = (100, 75) # Center origin for rotation
grid3.center_camera((3, 3))
assert grid3.rotation == 15.0, "viewport rotation should round-trip"
# viewport rotation is distinct from camera rotation
assert grid3.camera_rotation == 0.0, "setting .rotation must not set .camera_rotation"
ui.append(grid3)
@ -76,9 +84,9 @@ label3 = mcrfpy.Caption(text="Grid with viewport rotation=15 (rotates entire wid
ui.append(label3)
# Test center_camera computes correct pixel center
# (GridView has no .cell_size accessor; the raw cell metrics aren't needed here --
# center_camera is verified by the relationships between the centers it produces.)
test_grid = mcrfpy.Grid(grid_size=(20, 15), pos=(0, 0), size=(320, 240))
cell_w = test_grid.cell_size[0]
cell_h = test_grid.cell_size[1]
# center_camera((0, 0)) should put tile (0,0) at view center
test_grid.center_camera((0, 0))
@ -90,6 +98,8 @@ c0 = test_grid.center
test_grid.center_camera((10, 7))
c1 = test_grid.center
assert c0.x != c1.x or c0.y != c1.y, "center_camera at different positions should give different centers"
# ...and it should move monotonically with the tile coordinate
assert c1.x > c0.x and c1.y > c0.y, "center_camera to a larger tile should increase the center"
# center_camera at same position twice should be idempotent
test_grid.center_camera((5, 5))

View file

@ -73,24 +73,31 @@ def test_gridview_repr():
print("PASS: GridView repr")
def test_gridview_grid_property():
"""GridView.grid returns the correct Grid with identity preservation."""
"""GridView.grid_data returns the shared GridData with identity preservation.
Contract change (#313/#361): a view no longer exposes `.grid` (the source view);
Grid and GridView are the same type, and the map they share is `.grid_data`.
"""
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(15, 10), texture=tex, pos=(0, 0), size=(240, 160))
view = mcrfpy.GridView(grid=grid, pos=(250, 0), size=(240, 160))
assert view.grid is grid, "view.grid should be the same Grid object"
assert view.grid.grid_w == 15
assert view.grid_data is grid.grid_data, "view must share the source Grid's GridData"
assert view.grid_data.grid_w == 15
# Identity preserved on repeated access
assert view.grid is view.grid
print("PASS: GridView.grid property with identity")
assert view.grid_data is view.grid_data
print("PASS: GridView.grid_data property with identity")
def test_gridview_no_grid():
"""GridView without a grid doesn't crash."""
def test_gridview_default_grid():
"""GridView with no source grid doesn't crash; it gets its own default GridData."""
view = mcrfpy.GridView()
assert view.grid is None
gd = view.grid_data
assert gd is not None, "a bare GridView still owns a default GridData"
assert gd.grid_w > 0 and gd.grid_h > 0
assert gd is view.grid_data # identity preserved
r = repr(view)
assert "None" in r
print("PASS: GridView without grid")
assert "GridView" in r
print("PASS: GridView without a source grid")
if __name__ == "__main__":
test_gridview_creation()
@ -99,6 +106,6 @@ if __name__ == "__main__":
test_gridview_multi_view()
test_gridview_repr()
test_gridview_grid_property()
test_gridview_no_grid()
test_gridview_default_grid()
print("All GridView tests passed")
sys.exit(0)

View file

@ -1,4 +1,6 @@
import mcrfpy
import sys
scene = mcrfpy.Scene("test")
scene.activate()
f1 = mcrfpy.Frame((10,10), (100,100), fill_color = (255, 0, 0, 64))
@ -8,7 +10,37 @@ f_child = mcrfpy.Frame((25,25), (50,50), fill_color = (0, 0, 255, 64))
scene.children.append(f1)
scene.children.append(f2)
f1.children.append(f_child)
failures = []
# Before reparent: child belongs to f1
if len(f1.children) != 1:
failures.append("f1 should have 1 child before reparent, has %d" % len(f1.children))
if len(f2.children) != 0:
failures.append("f2 should have 0 children before reparent, has %d" % len(f2.children))
if f_child.parent is not f1:
failures.append("f_child.parent should be f1 before reparent, got %r" % (f_child.parent,))
# Reparent by assigning .parent
f_child.parent = f2
print(f1.children)
print(f2.children)
# After reparent: child moved out of f1 and into f2 (no duplication, no orphaning)
if len(f1.children) != 0:
failures.append("f1 should have 0 children after reparent, has %d" % len(f1.children))
if len(f2.children) != 1:
failures.append("f2 should have 1 child after reparent, has %d" % len(f2.children))
elif f2.children[0] is not f_child:
failures.append("f2's child is not f_child")
if f_child.parent is not f2:
failures.append("f_child.parent should be f2 after reparent, got %r" % (f_child.parent,))
if failures:
for f in failures:
print("FAIL: " + f)
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -188,10 +188,15 @@ check(f"standard easing complete() goes to target ({sprite9.x:.1f})",
abs(sprite9.x - 500.0) < 5.0)
# --- Test 11: Animation constructor with ping-pong easing ---
anim10 = mcrfpy.Animation("x", 100.0, 1.0, mcrfpy.Easing.PING_PONG, loop=True)
check("Animation constructor with PING_PONG", anim10 is not None)
check("Animation constructor loop=True", anim10.is_looping == True)
# --- Test 11: Animation construction with ping-pong easing ---
# mcrfpy.Animation is no longer exported; animations are constructed via the
# target's .animate() method, which returns the Animation object.
sprite10 = mcrfpy.Sprite(pos=(0, 0))
scene.children.append(sprite10)
anim10 = sprite10.animate("x", 100.0, 1.0, mcrfpy.Easing.PING_PONG, loop=True)
check("animate() with PING_PONG", anim10 is not None)
check("animate() loop=True", anim10.is_looping == True)
check("animate() ping-pong easing retained", anim10.is_complete == False)
# --- Summary ---

View file

@ -3,43 +3,80 @@
import mcrfpy
from mcrfpy import automation
import os
import sys
import time
failures = []
fired = []
OUT_DIR = "test_screenshots"
GOOD_PATH = os.path.join(OUT_DIR, "test_screenshot.png")
BAD_PATH = os.path.join("no_such_dir_xyz", "test_screenshot.png")
def take_screenshot(timer, runtime):
"""Take screenshot after render starts"""
"""Take screenshot after the timer fires"""
print(f"Timer callback fired at runtime: {runtime}")
fired.append(runtime)
# Try different paths
paths = [
"test_screenshot.png",
"./test_screenshot.png",
"mcrogueface.github.io/images/test_screenshot.png"
]
# A writable path must succeed and produce a real PNG file
print(f"Trying to save to: {GOOD_PATH}")
if automation.screenshot(GOOD_PATH) is not True:
failures.append(f"screenshot({GOOD_PATH}) did not return True")
return
if not os.path.exists(GOOD_PATH):
failures.append(f"screenshot reported success but {GOOD_PATH} does not exist")
return
size = os.path.getsize(GOOD_PATH)
if size == 0:
failures.append(f"{GOOD_PATH} is empty")
return
with open(GOOD_PATH, "rb") as f:
magic = f.read(8)
if magic != b"\x89PNG\r\n\x1a\n":
failures.append(f"{GOOD_PATH} is not a PNG (magic={magic!r})")
return
print(f"Success: {GOOD_PATH} ({size} bytes)")
for path in paths:
try:
print(f"Trying to save to: {path}")
automation.screenshot(path)
print(f"Success: {path}")
except Exception as e:
print(f"Failed {path}: {e}")
# An unwritable path must fail cleanly (return False, no exception)
print(f"Trying to save to: {BAD_PATH}")
if automation.screenshot(BAD_PATH) is not False:
failures.append(f"screenshot({BAD_PATH}) should have returned False")
return
print(f"Failed as expected: {BAD_PATH}")
sys.exit(0)
# Create minimal scene
test = mcrfpy.Scene("test")
# Add a visible element
caption = mcrfpy.Caption(pos=(100, 100), text="Screenshot Test")
caption.font = mcrfpy.default_font
# Add a visible element (Caption.font is read-only as of #320 -- set it in the ctor)
caption = mcrfpy.Caption(pos=(100, 100), font=mcrfpy.default_font, text="Screenshot Test")
caption.fill_color = mcrfpy.Color(255, 255, 255)
caption.font_size = 24
test.children.append(caption)
test.activate()
# Use timer to ensure rendering has started
os.makedirs(OUT_DIR, exist_ok=True)
if os.path.exists(GOOD_PATH):
os.remove(GOOD_PATH)
# Timer fires at 500ms; headless has no clock of its own, so step() drives it (#350)
print("Setting timer...")
mcrfpy.Timer("screenshot", take_screenshot, 500, once=True) # Wait 0.5 seconds
print("Timer set, entering game loop...")
mcrfpy.Timer("screenshot", take_screenshot, 500, once=True)
print("Timer set, advancing the clock...")
for _ in range(20): # 20 * 50ms = 1000ms > 500ms
mcrfpy.step(0.05)
if fired:
break
if not fired:
failures.append("timer never fired after 1000ms of stepping")
if failures:
for f in failures:
print(f"FAIL: {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -9,12 +9,14 @@ print("=" * 30)
# Global state to track callback
callback_count = 0
callback_args = []
# #229 - Animation callbacks now receive (target, property, value) instead of (anim, target)
def my_callback(target, prop, value):
"""Simple callback that prints when animation completes"""
global callback_count
callback_count += 1
callback_args.append((target, prop, value))
print(f"Animation completed! Callback #{callback_count}")
print(f" Target: {type(target).__name__}, Property: {prop}, Value: {value}")
@ -23,14 +25,15 @@ 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))
frame = mcrfpy.Frame(pos=(100, 100), size=(200, 200), fill_color=(255, 0, 0))
ui = callback_demo.children
ui.append(frame)
# Test 1: Animation with callback
# mcrfpy.Animation is no longer exported; animations are constructed via the
# target drawable's .animate() method, which starts them immediately.
print("Starting animation with callback (1.0s duration)...")
anim = mcrfpy.Animation("x", 400.0, 1.0, "easeInOutQuad", callback=my_callback)
anim.start(frame)
frame.animate("x", 400.0, 1.0, mcrfpy.Easing.EASE_IN_OUT_QUAD, callback=my_callback)
# Use mcrfpy.step() to advance past animation completion
mcrfpy.step(1.5) # Advance 1.5 seconds - animation completes at 1.0s
@ -38,12 +41,21 @@ mcrfpy.step(1.5) # Advance 1.5 seconds - animation completes at 1.0s
if callback_count != 1:
print(f"FAIL: Expected 1 callback, got {callback_count}")
sys.exit(1)
# The callback receives (target, property, final_value) -- and the animation must
# have actually driven the property to its target.
cb_target, cb_prop, cb_value = callback_args[0]
if cb_target is not frame or cb_prop != "x" or cb_value != 400.0:
print(f"FAIL: Bad callback args: {callback_args[0]}")
sys.exit(1)
if frame.x != 400.0:
print(f"FAIL: Expected frame.x == 400.0, got {frame.x}")
sys.exit(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)
frame.animate("y", 300.0, 0.5, mcrfpy.Easing.LINEAR)
# Advance past second animation
mcrfpy.step(0.7)
@ -51,7 +63,11 @@ mcrfpy.step(0.7)
if callback_count != 1:
print(f"FAIL: Callback count changed to {callback_count}")
sys.exit(1)
if frame.y != 300.0:
print(f"FAIL: Expected frame.y == 300.0, got {frame.y}")
sys.exit(1)
print("SUCCESS: No unexpected callbacks fired!")
print("\nAnimation callback feature working correctly!")
print("PASS")
sys.exit(0)

View file

@ -3,15 +3,30 @@
Test Animation Chaining
=======================
Demonstrates proper animation chaining to avoid glitches.
Demonstrates proper animation chaining to avoid glitches: an entity walks a path
one tile at a time, and each step's animation only starts after the previous
step's animation has completed (never two overlapping position animations).
Headless: mcrfpy.step(dt) is the clock -- it drives both timers and animations.
Animations are created with target.animate(...) (mcrfpy.Animation is no longer
constructible); chaining uses the completion callback instead of polling.
"""
import mcrfpy
import sys
failures = []
def check(name, condition, detail=""):
if condition:
print(f" PASS: {name}")
else:
print(f" FAIL: {name} {detail}")
failures.append(name)
class PathAnimator:
"""Handles step-by-step path animation with proper chaining"""
def __init__(self, entity, path, step_duration=0.3, on_complete=None):
self.entity = entity
self.path = path
@ -19,63 +34,67 @@ class PathAnimator:
self.step_duration = step_duration
self.on_complete = on_complete
self.animating = False
self.check_timer_name = f"path_check_{id(self)}"
self.completed_steps = [] # (index, draw_pos) recorded as each step lands
self.overlaps = 0 # times a new step started while one was in flight
self.anim_x = None
self.anim_y = None
def start(self):
"""Start animating along the path"""
if not self.path or self.animating:
return
self.current_index = 0
self.animating = True
self._animate_next_step()
def _animate_next_step(self):
"""Animate to the next position in the path"""
if self.current_index >= len(self.path):
# Path complete
self.animating = False
if hasattr(self, '_check_timer'):
self._check_timer.stop()
if self.on_complete:
self.on_complete()
return
# Detect a chaining violation: the previous step must be finished
if self.anim_x is not None and not self.anim_x.is_complete:
self.overlaps += 1
if self.anim_y is not None and not self.anim_y.is_complete:
self.overlaps += 1
# Get target position
target_x, target_y = self.path[self.current_index]
# Create animations
self.anim_x = mcrfpy.Animation("x", float(target_x), self.step_duration, "easeInOut")
self.anim_y = mcrfpy.Animation("y", float(target_y), self.step_duration, "easeInOut")
# Start animations
self.anim_x.start(self.entity)
self.anim_y.start(self.entity)
# Create + start animations ('x'/'y' animate the entity's draw position,
# in tile coordinates). The x animation's callback chains the next step.
self.anim_y = self.entity.animate("y", float(target_y), self.step_duration,
mcrfpy.Easing.EASE_IN_OUT)
self.anim_x = self.entity.animate("x", float(target_x), self.step_duration,
mcrfpy.Easing.EASE_IN_OUT,
callback=self._on_step_complete)
# Update visibility if entity has this method
if hasattr(self.entity, 'update_visibility'):
self.entity.update_visibility()
# Set timer to check completion
self._check_timer = mcrfpy.Timer(self.check_timer_name, self._check_completion, 50)
def _check_completion(self, timer, runtime):
"""Check if current animation is complete"""
if hasattr(self.anim_x, 'is_complete') and self.anim_x.is_complete:
# Move to next step
self.current_index += 1
timer.stop()
self._animate_next_step()
def _on_step_complete(self, target, prop, value):
"""Animation completion callback -- advance to the next path node"""
self.completed_steps.append((self.current_index,
(self.entity.draw_pos.x, self.entity.draw_pos.y)))
self.current_index += 1
self._animate_next_step()
# Create test scene
chain_test = mcrfpy.Scene("chain_test")
# Create grid
grid = mcrfpy.Grid(grid_w=20, grid_h=15)
grid = mcrfpy.Grid(grid_size=(20, 15), pos=(100, 100), size=(600, 450))
grid.fill_color = mcrfpy.Color(20, 20, 30)
# Add a color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# Add a color layer for cell coloring (GridPoint has no .color any more)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.add_layer(color_layer)
# Simple map
for y in range(15):
@ -84,33 +103,29 @@ for y in range(15):
if x == 0 or x == 19 or y == 0 or y == 14:
cell.walkable = False
cell.transparent = False
color_layer.set(x, y, mcrfpy.Color(60, 40, 40))
color_layer.set((x, y), mcrfpy.Color(60, 40, 40))
else:
cell.walkable = True
cell.transparent = True
color_layer.set(x, y, mcrfpy.Color(100, 100, 120))
color_layer.set((x, y), mcrfpy.Color(100, 100, 120))
# Create entities
player = mcrfpy.Entity((2, 2), grid=grid)
player = mcrfpy.Entity(grid_pos=(2, 2))
player.sprite_index = 64 # @
grid.entities.append(player)
enemy = mcrfpy.Entity((17, 12), grid=grid)
enemy = mcrfpy.Entity(grid_pos=(17, 12))
enemy.sprite_index = 69 # E
grid.entities.append(enemy)
# UI setup
ui = chain_test.children
ui.append(grid)
grid.pos = (100, 100)
grid.size = (600, 450)
title = mcrfpy.Caption(pos=(300, 20), text="Animation Chaining Test")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
status = mcrfpy.Caption(pos=(100, 50), text="Press 1: Animate Player | 2: Animate Enemy | 3: Both | Q: Quit")
status.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(status)
info = mcrfpy.Caption(pos=(100, 70), text="Status: Ready")
info.fill_color = mcrfpy.Color(100, 255, 100)
ui.append(info)
@ -118,42 +133,48 @@ ui.append(info)
# Path animators
player_animator = None
enemy_animator = None
player_done = False
enemy_done = False
PLAYER_PATH = [
(2, 2), (3, 2), (4, 2), (5, 2), (6, 2), # Right
(6, 3), (6, 4), (6, 5), (6, 6), # Down
(7, 6), (8, 6), (9, 6), (10, 6), # Right
(10, 7), (10, 8), (10, 9), # Down
]
ENEMY_PATH = [
(17, 12), (16, 12), (15, 12), (14, 12), # Left
(14, 11), (14, 10), (14, 9), # Up
(13, 9), (12, 9), (11, 9), (10, 9), # Left
(10, 8), (10, 7), (10, 6), # Up
]
def animate_player():
"""Animate player along a path"""
global player_animator
# Define path
path = [
(2, 2), (3, 2), (4, 2), (5, 2), (6, 2), # Right
(6, 3), (6, 4), (6, 5), (6, 6), # Down
(7, 6), (8, 6), (9, 6), (10, 6), # Right
(10, 7), (10, 8), (10, 9), # Down
]
def on_complete():
global player_done
player_done = True
info.text = "Player animation complete!"
player_animator = PathAnimator(player, path, step_duration=0.2, on_complete=on_complete)
player_animator = PathAnimator(player, PLAYER_PATH, step_duration=0.2,
on_complete=on_complete)
player_animator.start()
info.text = "Animating player..."
def animate_enemy():
"""Animate enemy along a path"""
global enemy_animator
# Define path
path = [
(17, 12), (16, 12), (15, 12), (14, 12), # Left
(14, 11), (14, 10), (14, 9), # Up
(13, 9), (12, 9), (11, 9), (10, 9), # Left
(10, 8), (10, 7), (10, 6), # Up
]
def on_complete():
global enemy_done
enemy_done = True
info.text = "Enemy animation complete!"
enemy_animator = PathAnimator(enemy, path, step_duration=0.25, on_complete=on_complete)
enemy_animator = PathAnimator(enemy, ENEMY_PATH, step_duration=0.25,
on_complete=on_complete)
enemy_animator.start()
info.text = "Animating enemy..."
@ -164,60 +185,101 @@ def animate_both():
animate_enemy()
# Camera follow test
camera_follow = False
camera_follow = True
camera_updates = 0
def update_camera(timer, runtime):
"""Update camera to follow player if enabled"""
global camera_updates
if camera_follow and player_animator and player_animator.animating:
# Smooth camera follow
center_x = player.x * 30 # Assuming ~30 pixels per cell
center_y = player.y * 30
cam_anim = mcrfpy.Animation("center", (center_x, center_y), 0.25, "linear")
cam_anim.start(grid)
# Input handler
def handle_input(key, state):
global camera_follow
if state != mcrfpy.InputState.PRESSED:
return
if key == mcrfpy.Key.Q:
sys.exit(0)
elif key == mcrfpy.Key.NUM_1:
animate_player()
elif key == mcrfpy.Key.NUM_2:
animate_enemy()
elif key == mcrfpy.Key.NUM_3:
animate_both()
elif key == mcrfpy.Key.C:
camera_follow = not camera_follow
info.text = f"Camera follow: {'ON' if camera_follow else 'OFF'}"
elif key == mcrfpy.Key.R:
# Reset positions
player.x, player.y = 2, 2
enemy.x, enemy.y = 17, 12
info.text = "Positions reset"
# Smooth camera follow. grid.center is in pixels; entity.x/.y are the
# entity's draw position in pixels (draw_pos is the same in tile coords).
grid.animate("center", (player.x, player.y), 0.25, mcrfpy.Easing.LINEAR)
camera_updates += 1
# Setup
chain_test.activate()
chain_test.on_key = handle_input
# Camera update timer
cam_update_timer = mcrfpy.Timer("cam_update", update_camera, 100)
print("Animation Chaining Test")
print("=======================")
print("This test demonstrates proper animation chaining")
print("to avoid simultaneous position updates.")
print()
print("Controls:")
print(" 1 - Animate player step by step")
print(" 2 - Animate enemy step by step")
print(" 3 - Animate both (simultaneous)")
print(" C - Toggle camera follow")
print(" R - Reset positions")
print(" Q - Quit")
print()
print("Notice how each entity moves one tile at a time,")
print("waiting for each step to complete before the next.")
# --- Drive the chained animations headlessly -------------------------------
animate_both()
check("player animator started", player_animator.animating)
check("enemy animator started", enemy_animator.animating)
# Path node 0 is the entity's starting cell, so the first 0.2s step is a no-op
# move. Halfway through the *second* step the entity must be strictly between
# nodes 0 and 1 -- i.e. the animation is interpolating, one step at a time.
for _ in range(2):
mcrfpy.step(0.1) # completes step 0, chains step 1
check("first step completed before the next began",
player_animator.current_index == 1 and len(player_animator.completed_steps) == 1,
f"(index={player_animator.current_index})")
mcrfpy.step(0.1) # halfway through step 1: (2,2) -> (3,2)
mid_x = player.draw_pos.x
check("player interpolates between tiles", 2.0 < mid_x < 3.0,
f"(draw_x={mid_x})")
check("only one step in flight at a time (mid-step)",
player_animator.current_index == 1,
f"(index={player_animator.current_index})")
# Player: 16 nodes * 0.2s; enemy: 14 nodes * 0.25s -> 3.5s worst case.
elapsed = 0.3
while elapsed < 6.0 and not (player_done and enemy_done):
mcrfpy.step(0.05)
elapsed += 0.05
# --- Assertions ------------------------------------------------------------
check("player path completed", player_done)
check("enemy path completed", enemy_done)
check("player animator stopped", not player_animator.animating)
check("enemy animator stopped", not enemy_animator.animating)
check("no overlapping player animations", player_animator.overlaps == 0,
f"(overlaps={player_animator.overlaps})")
check("no overlapping enemy animations", enemy_animator.overlaps == 0,
f"(overlaps={enemy_animator.overlaps})")
check("player visited every path node",
[i for i, _ in player_animator.completed_steps] == list(range(len(PLAYER_PATH))),
f"({player_animator.completed_steps})")
check("enemy visited every path node",
[i for i, _ in enemy_animator.completed_steps] == list(range(len(ENEMY_PATH))),
f"({enemy_animator.completed_steps})")
# Each step landed exactly on its path node (chaining kept positions in sync)
player_landings_ok = all(pos == (float(px), float(py))
for (i, pos), (px, py)
in zip(player_animator.completed_steps, PLAYER_PATH))
check("each player step landed on its path node", player_landings_ok,
f"({player_animator.completed_steps})")
enemy_landings_ok = all(pos == (float(px), float(py))
for (i, pos), (px, py)
in zip(enemy_animator.completed_steps, ENEMY_PATH))
check("each enemy step landed on its path node", enemy_landings_ok,
f"({enemy_animator.completed_steps})")
check("player ended at final path node",
(player.draw_pos.x, player.draw_pos.y) == (float(PLAYER_PATH[-1][0]),
float(PLAYER_PATH[-1][1])),
f"({player.draw_pos})")
check("enemy ended at final path node",
(enemy.draw_pos.x, enemy.draw_pos.y) == (float(ENEMY_PATH[-1][0]),
float(ENEMY_PATH[-1][1])),
f"({enemy.draw_pos})")
# Camera-follow timer ran while the player was animating
check("camera follow timer fired during animation", camera_updates > 0,
f"(updates={camera_updates})")
if failures:
print(f"FAIL: {len(failures)} check(s) failed: {failures}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -33,9 +33,8 @@ for i in range(10):
ui.append(f)
initial_frames.append(f)
# Animate them
anim = mcrfpy.Animation("y", 300.0, 2.0, "easeOutBounce")
anim.start(f)
# Animate them (mcrfpy.Animation is gone; animations are built by the target)
f.animate("y", 300.0, 2.0, mcrfpy.Easing.EASE_OUT_BOUNCE)
print(f"Initial scene has {len(ui)} elements")
@ -55,6 +54,7 @@ print(f"Scene has {len(ui)} elements after clearing")
# Create new animated objects
print("Creating new animated objects...")
new_frames = []
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)
@ -62,20 +62,34 @@ for i in range(5):
# Start animation on the new frame
target_x = 300 + i * 50
anim = mcrfpy.Animation("x", float(target_x), 1.0, "easeInOut")
anim.start(f)
f.animate("x", float(target_x), 1.0, mcrfpy.Easing.EASE_IN_OUT)
new_frames.append((f, float(target_x)))
print("New objects created and animated")
print(f"Scene now has {len(ui)} elements")
# Let new animations run
mcrfpy.step(1.5)
# Let new animations run to completion (duration 1.0s)
for _ in range(20):
mcrfpy.step(0.1)
# Final check
failures = []
# Final check: element count survived the clear/recreate cycle
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)}")
if len(ui) != 7: # 2 captions + 5 new frames
failures.append(f"Expected 7 elements, got {len(ui)}")
# The removed frames' animations must not have kept running (or crashed);
# the new frames' animations must have actually completed.
for i, (f, target_x) in enumerate(new_frames):
if abs(f.x - target_x) > 0.5:
failures.append(f"new frame {i}: x={f.x}, expected {target_x}")
if failures:
for msg in failures:
print(f"FAIL: {msg}")
sys.exit(1)
print("SUCCESS: Animation removal test passed!")
print("PASS")
sys.exit(0)

View file

@ -1,164 +1,164 @@
#!/usr/bin/env python3
"""Test that API documentation generator works correctly."""
"""Test that API documentation generator works correctly.
The hand-written docs/API_REFERENCE.md is gone; the canonical API reference is now
docs/API_REFERENCE_DYNAMIC.md, generated from the compiled module by
tools/generate_dynamic_docs.py (MCRF_* docstring macros -> introspection -> markdown).
This test verifies that generated artifact exists, is well-formed, and stays in sync
with the live mcrfpy module.
"""
import os
import sys
from pathlib import Path
# tests/unit/test_api_docs.py -> repo root -> docs/
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
DOCS_PATH = REPO_ROOT / "docs" / "API_REFERENCE_DYNAMIC.md"
def test_api_docs_exist():
"""Test that API documentation was generated."""
docs_path = Path("docs/API_REFERENCE.md")
if not docs_path.exists():
print("ERROR: API documentation not found at docs/API_REFERENCE.md")
if not DOCS_PATH.exists():
print(f"ERROR: API documentation not found at {DOCS_PATH}")
return False
print(" API documentation file exists")
print("+ API documentation file exists")
# Check file size
size = docs_path.stat().st_size
size = DOCS_PATH.stat().st_size
if size < 1000:
print(f"ERROR: API documentation seems too small ({size} bytes)")
return False
print(f" API documentation has reasonable size ({size} bytes)")
print(f"+ API documentation has reasonable size ({size} bytes)")
# Read content
with open(docs_path, 'r') as f:
with open(DOCS_PATH, 'r') as f:
content = f.read()
# Check for expected sections
# Check for expected sections (current generated layout)
expected_sections = [
"# McRogueFace API Reference",
"## Overview",
"## Classes",
"## Table of Contents",
"## Module Attributes",
"## Functions",
"## Automation Module"
"## Classes",
]
missing = []
for section in expected_sections:
if section not in content:
missing.append(section)
missing = [s for s in expected_sections if s not in content]
if missing:
print(f"ERROR: Missing sections: {missing}")
return False
print(" All expected sections present")
# Check for key classes
print("+ All expected sections present")
# Check for key classes ("### ClassName" headings)
key_classes = ["Frame", "Caption", "Sprite", "Grid", "Entity", "Scene"]
missing_classes = []
for cls in key_classes:
if f"### class {cls}" not in content:
missing_classes.append(cls)
missing_classes = [c for c in key_classes if f"\n### {c}\n" not in content]
if missing_classes:
print(f"ERROR: Missing classes: {missing_classes}")
return False
print("✓ All key classes documented")
# Check for key functions
key_functions = ["createScene", "setScene", "currentScene", "find", "setTimer"]
missing_funcs = []
for func in key_functions:
if f"### {func}" not in content:
missing_funcs.append(func)
print("+ All key classes documented")
# Check for key module-level functions ("### `name(...)`" headings)
key_functions = ["find", "find_all", "step", "get_metrics", "set_scale", "exit"]
missing_funcs = [f for f in key_functions if f"\n### `{f}(" not in content]
if missing_funcs:
print(f"ERROR: Missing functions: {missing_funcs}")
return False
print("✓ All key functions documented")
# Check automation module
if "automation.screenshot" in content:
print("✓ Automation module documented")
else:
print("ERROR: Automation module not properly documented")
print("+ All key functions documented")
# Scene management moved from functions to the mcrfpy.current_scene attribute
if "### `mcrfpy.current_scene`" not in content:
print("ERROR: mcrfpy.current_scene not documented under Module Attributes")
return False
print("+ Module attributes documented")
# Count documentation entries
class_count = content.count("### class ")
func_count = content.count("### ") - class_count - content.count("### automation.")
auto_count = content.count("### automation.")
class_count = sum(1 for line in content.splitlines()
if line.startswith("### ") and not line.startswith("### `"))
func_count = sum(1 for line in content.splitlines() if line.startswith("### `"))
member_count = sum(1 for line in content.splitlines() if line.startswith("#### "))
print(f"\nDocumentation Coverage:")
print(f"- Classes: {class_count}")
print(f"- Functions: {func_count}")
print(f"- Automation methods: {auto_count}")
print(f"- Functions/attributes: {func_count}")
print(f"- Class members: {member_count}")
if class_count == 0 or func_count == 0 or member_count == 0:
print("ERROR: documentation contains no entries")
return False
return True
def test_doc_accuracy():
"""Test that documentation matches actual API."""
# Import mcrfpy to check
import mcrfpy
print("\nVerifying documentation accuracy...")
# Read documentation
with open("docs/API_REFERENCE.md", 'r') as f:
with open(DOCS_PATH, 'r') as f:
content = f.read()
passed = True
# Check that all public classes are documented
actual_classes = [name for name in dir(mcrfpy)
actual_classes = [name for name in dir(mcrfpy)
if isinstance(getattr(mcrfpy, name), type) and not name.startswith('_')]
undocumented = []
for cls in actual_classes:
if f"### class {cls}" not in content:
undocumented.append(cls)
undocumented = [c for c in actual_classes if f"\n### {c}\n" not in content]
if undocumented:
print(f"WARNING: Undocumented classes: {undocumented}")
print(f"ERROR: Undocumented classes: {undocumented}")
passed = False
else:
print("✓ All public classes are documented")
print(f"+ All {len(actual_classes)} public classes are documented")
# Check functions
actual_funcs = [name for name in dir(mcrfpy)
if callable(getattr(mcrfpy, name)) and not name.startswith('_')
actual_funcs = [name for name in dir(mcrfpy)
if callable(getattr(mcrfpy, name)) and not name.startswith('_')
and not isinstance(getattr(mcrfpy, name), type)]
undoc_funcs = []
for func in actual_funcs:
if f"### {func}" not in content:
undoc_funcs.append(func)
undoc_funcs = [f for f in actual_funcs if f"\n### `{f}(" not in content]
if undoc_funcs:
print(f"WARNING: Undocumented functions: {undoc_funcs}")
print(f"ERROR: Undocumented functions: {undoc_funcs}")
passed = False
else:
print("✓ All public functions are documented")
return True
print(f"+ All {len(actual_funcs)} public functions are documented")
return passed
def main():
"""Run all API documentation tests."""
print("API Documentation Tests")
print("======================\n")
all_passed = True
# Test 1: Documentation exists and is complete
print("Test 1: Documentation Generation")
if not test_api_docs_exist():
all_passed = False
print()
# Test 2: Documentation accuracy
print("Test 2: Documentation Accuracy")
if not test_doc_accuracy():
all_passed = False
print()
if all_passed:
print(" All API documentation tests passed!")
print("PASS: All API documentation tests passed!")
sys.exit(0)
else:
print(" Some tests failed.")
print("FAIL: Some tests failed.")
sys.exit(1)
if __name__ == '__main__':
main()
main()

View file

@ -3,7 +3,15 @@
Test A* Pathfinding Implementation
==================================
Compares A* with Dijkstra and the existing find_path method.
Compares A* (GridData.find_path) with Dijkstra (GridData.get_dijkstra_map)
and verifies path validity around obstacles.
API notes (current contract):
- The old grid.compute_astar_path/compute_dijkstra/get_dijkstra_path methods are gone.
A* is now GridData.find_path(start, end, ...) -> AStarPath | None
Dijkstra is now GridData.get_dijkstra_map(root=...) -> DijkstraMap (.path_from/.distance)
- Pathfinding lives on GridData, not on the Grid view (mcrfpy.Grid is a GridView).
- Headless has no automatic clock: timers only fire from mcrfpy.step().
"""
import mcrfpy
@ -13,24 +21,42 @@ import time
print("A* Pathfinding Test")
print("==================")
failures = []
def check(cond, msg):
if cond:
print(f" PASS: {msg}")
else:
print(f" FAIL: {msg}")
failures.append(msg)
# Create scene and grid
astar_test = mcrfpy.Scene("astar_test")
grid = mcrfpy.Grid(grid_w=20, grid_h=20)
grid = mcrfpy.Grid(grid_size=(20, 20), pos=(50, 50), size=(400, 400))
data = grid.grid_data # pathfinding lives on GridData
# Initialize grid - all walkable
for y in range(20):
for x in range(20):
grid.at(x, y).walkable = True
data.at(x, y).walkable = True
# Create a wall barrier with a narrow passage
# Create a wall barrier with a narrow passage.
# The barrier is 4 cells thick (x 8..11) and spans the full grid height, with a
# 1-cell-tall corridor carved through it at y == 10. Two corrections vs. the
# original test: (a) a partial-height wall could simply be rounded diagonally for
# the same cost, and (b) a single free cell inside a 4-thick wall is not a passage
# at all -- every neighbour is wall, so it can never be entered.
print("\nCreating wall with narrow passage...")
for y in range(5, 15):
walls = set()
for y in range(20):
for x in range(8, 12):
if not (x == 10 and y == 10): # Leave a gap at (10, 10)
grid.at(x, y).walkable = False
print(f" Wall at ({x}, {y})")
if y == 10: # corridor through the barrier, including (10, 10)
continue
data.at(x, y).walkable = False
walls.add((x, y))
print(f"\nPassage at (10, 10)")
print(f"Wall cells: {len(walls)}, passage at (10, 10)")
data.clear_dijkstra_maps()
# Test points
start = (2, 10)
@ -38,93 +64,125 @@ end = (18, 10)
print(f"\nFinding path from {start} to {end}")
# Test 1: A* pathfinding
print("\n1. Testing A* pathfinding (compute_astar_path):")
start_time = time.time()
astar_path = grid.compute_astar_path(start[0], start[1], end[0], end[1])
astar_time = time.time() - start_time
print(f" A* path length: {len(astar_path)}")
print(f" A* time: {astar_time*1000:.3f} ms")
if astar_path:
print(f" First 5 steps: {astar_path[:5]}")
# Test 2: find_path method (which should also use A*)
print("\n2. Testing find_path method:")
def as_cells(path):
return [(int(v.x), int(v.y)) for v in path]
def is_contiguous(cells):
for a, b in zip(cells, cells[1:]):
if max(abs(a[0] - b[0]), abs(a[1] - b[1])) != 1:
return False
return True
# Test 1: A* pathfinding
print("\n1. Testing A* pathfinding (find_path):")
start_time = time.time()
find_path_result = grid.find_path(start[0], start[1], end[0], end[1])
find_path_time = time.time() - start_time
print(f" find_path length: {len(find_path_result)}")
print(f" find_path time: {find_path_time*1000:.3f} ms")
if find_path_result:
print(f" First 5 steps: {find_path_result[:5]}")
astar_path = data.find_path(start, end)
astar_time = time.time() - start_time
check(astar_path is not None, "A* found a path through the barrier")
# NOTE: iterating an AStarPath consumes it, so read .remaining before as_cells().
astar_remaining = astar_path.remaining if astar_path else 0
astar_cells = as_cells(astar_path) if astar_path else []
print(f" A* path length: {len(astar_cells)}")
print(f" A* time: {astar_time*1000:.3f} ms")
print(f" First 5 steps: {astar_cells[:5]}")
check(bool(astar_cells) and astar_cells[-1] == end, "A* path terminates at the destination")
check(not (walls & set(astar_cells)), "A* path never enters a wall cell")
check(is_contiguous([start] + astar_cells), "A* path is contiguous from the start cell")
check((10, 10) in astar_cells, "A* path uses the narrow passage at (10, 10)")
# Test 2: A* path costs/endpoints via the AStarPath object
print("\n2. Testing AStarPath object:")
check(as_cells([astar_path.origin])[0] == start, "AStarPath.origin is the start cell")
check(as_cells([astar_path.destination])[0] == end, "AStarPath.destination is the end cell")
check(astar_remaining == len(astar_cells), "AStarPath.remaining matches path length")
check(astar_path.remaining == 0, "AStarPath is consumed once fully walked/iterated")
# Test 3: Dijkstra pathfinding for comparison
print("\n3. Testing Dijkstra pathfinding:")
start_time = time.time()
grid.compute_dijkstra(start[0], start[1])
dijkstra_path = grid.get_dijkstra_path(end[0], end[1])
dmap = data.get_dijkstra_map(root=start)
dijkstra_path = dmap.path_from(end)
dijkstra_time = time.time() - start_time
print(f" Dijkstra path length: {len(dijkstra_path)}")
dijkstra_cells = as_cells(dijkstra_path) if dijkstra_path else []
print(f" Dijkstra path length: {len(dijkstra_cells)}")
print(f" Dijkstra time: {dijkstra_time*1000:.3f} ms")
if dijkstra_path:
print(f" First 5 steps: {dijkstra_path[:5]}")
print(f" First 5 steps: {dijkstra_cells[:5]}")
check(bool(dijkstra_cells), "Dijkstra found a path back to the root")
check(not (walls & set(dijkstra_cells)), "Dijkstra path never enters a wall cell")
check((10, 10) in dijkstra_cells, "Dijkstra path uses the narrow passage at (10, 10)")
check(dmap.distance(end) is not None, "Dijkstra distance to the destination is defined")
# Compare results
# Compare results - both are optimal, so step counts must agree
print("\nComparison:")
print(f" A* vs find_path: {'SAME' if astar_path == find_path_result else 'DIFFERENT'}")
print(f" A* vs Dijkstra: {'SAME' if astar_path == dijkstra_path else 'DIFFERENT'}")
print(f" A* steps: {len(astar_cells)}, Dijkstra steps: {len(dijkstra_cells)}")
check(len(astar_cells) == len(dijkstra_cells),
"A* and Dijkstra agree on optimal step count")
# Test with no path (blocked endpoints)
# Test 4: no path (blocked destination inside the wall)
print("\n4. Testing with blocked destination:")
blocked_end = (10, 8) # Inside the wall
grid.at(blocked_end[0], blocked_end[1]).walkable = False
no_path = grid.compute_astar_path(start[0], start[1], blocked_end[0], blocked_end[1])
print(f" Path to blocked cell: {no_path} (should be empty)")
blocked_end = (10, 8) # inside the wall
check(data.at(*blocked_end).walkable is False, "blocked destination is unwalkable")
no_path = data.find_path(start, blocked_end)
print(f" Path to blocked cell: {no_path}")
check(no_path is None or len(no_path) == 0, "no path is returned for a blocked destination")
# Test diagonal movement
# Test 5: diagonal movement
print("\n5. Testing diagonal paths:")
diag_start = (0, 0)
diag_end = (5, 5)
diag_path = grid.compute_astar_path(diag_start[0], diag_start[1], diag_end[0], diag_end[1])
print(f" Diagonal path from {diag_start} to {diag_end}:")
print(f" Length: {len(diag_path)}")
print(f" Path: {diag_path}")
diag_path = data.find_path(diag_start, diag_end)
diag_cells = as_cells(diag_path) if diag_path else []
print(f" Diagonal path from {diag_start} to {diag_end}: {diag_cells}")
# Optimal diagonal path is 5 moves (one diagonal step per cell)
check(len(diag_cells) == 5, "diagonal path from (0,0) to (5,5) takes 5 steps")
check(diag_cells and diag_cells[-1] == diag_end, "diagonal path reaches the destination")
# Expected optimal diagonal path length is 5 moves (moving diagonally each step)
# Performance test with larger path
# Test 6: performance / corner-to-corner agreement
print("\n6. Performance test (corner to corner):")
corner_paths = []
methods = [
("A*", lambda: grid.compute_astar_path(0, 0, 19, 19)),
("Dijkstra", lambda: (grid.compute_dijkstra(0, 0), grid.get_dijkstra_path(19, 19))[1])
]
results = {}
start_time = time.time()
corner_astar = as_cells(data.find_path((0, 0), (19, 19)) or [])
results["A*"] = (len(corner_astar), time.time() - start_time)
start_time = time.time()
corner_dmap = data.get_dijkstra_map(root=(0, 0))
corner_dijkstra = as_cells(corner_dmap.path_from((19, 19)) or [])
results["Dijkstra"] = (len(corner_dijkstra), time.time() - start_time)
for name, (steps, elapsed) in results.items():
print(f" {name}: {steps} steps in {elapsed*1000:.3f} ms")
check(len(corner_astar) > 0 and len(corner_dijkstra) > 0,
"corner-to-corner path found by both algorithms")
check(len(corner_astar) == len(corner_dijkstra),
"corner-to-corner step counts agree between A* and Dijkstra")
for name, method in methods:
start_time = time.time()
path = method()
elapsed = time.time() - start_time
print(f" {name}: {len(path)} steps in {elapsed*1000:.3f} ms")
# Quick smoke test that the grid renders in a scene and the clock advances.
# Headless: mcrfpy.step() is the only clock; a Timer never fires on its own.
timer_fired = []
print("\nA* pathfinding tests completed!")
print("Summary:")
print(" - A* pathfinding is working correctly")
print(" - Paths match between A* and Dijkstra")
print(" - Empty paths returned for blocked destinations")
print(" - Diagonal movement supported")
# Quick visual test
def visual_test(timer, runtime):
print("\nVisual test timer fired")
sys.exit(0)
timer_fired.append(runtime)
# Set up minimal UI for visual test
ui = astar_test.children
ui.append(grid)
grid.pos = (50, 50)
grid.size = (400, 400)
astar_test.activate()
visual_test_timer = mcrfpy.Timer("visual", visual_test, 100, once=True)
print("\nStarting visual test...")
print("\nStarting visual test...")
for _ in range(10):
mcrfpy.step(0.05)
check(bool(timer_fired), "scene timer fired after stepping the headless clock")
print("\nA* pathfinding tests completed!")
if failures:
print(f"\nFAIL: {len(failures)} check(s) failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,19 +1,40 @@
#!/usr/bin/env python3
"""Test Python builtins in function context like the failing demos"""
"""Test Python builtins in function context like the failing demos
Regression guard: builtins (range, list.append, f-strings) must keep working at
module level, inside functions, and -- critically -- after mcrfpy objects have
been constructed. A broken embedded-interpreter builtins dict used to make
`range()` blow up in exactly those spots.
Failures are RECORDED, not just printed: the script exits 1 if any check fails.
"""
import mcrfpy
import sys
import traceback
failures = []
def fail(label, msg):
failures.append(f"{label}: {msg}")
print(f" x FAIL {label}: {msg}")
print("Testing builtins in different contexts...")
print("=" * 50)
# Test 1: At module level (working in our test)
# Test 1: At module level
print("Test 1: Module level")
try:
xs = []
for x in range(3):
print(f" x={x}")
print(" ✓ Module level works")
xs.append(x)
if xs != [0, 1, 2]:
fail("module level range", f"expected [0, 1, 2], got {xs}")
else:
print(" ok Module level works")
except Exception as e:
print(f" ✗ Error: {type(e).__name__}: {e}")
traceback.print_exc()
fail("module level range", f"{type(e).__name__}: {e}")
print()
@ -21,13 +42,17 @@ print()
print("Test 2: Inside function")
def test_function():
try:
xs = [x for x in range(3)]
xs2 = []
for x in range(3):
print(f" x={x}")
print(" ✓ Function level works")
xs2.append(f"x={x}")
if xs != [0, 1, 2] or xs2 != ["x=0", "x=1", "x=2"]:
fail("function level builtins", f"got {xs}, {xs2}")
return
print(" ok Function level works")
except Exception as e:
print(f" ✗ Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
fail("function level builtins", f"{type(e).__name__}: {e}")
test_function()
@ -38,30 +63,35 @@ print("Test 3: Function creating mcrfpy objects")
def create_scene():
try:
test = mcrfpy.Scene("test")
print(" ✓ Created scene")
# Now try range
for x in range(3):
print(f" x={x}")
print(" ✓ Range after createScene works")
# Create grid
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
print(" ✓ Created grid")
# Try range again
for x in range(3):
print(f" x={x}")
print(" ✓ Range after Grid creation works")
print(" ok Created scene")
# Now try range -- must still work after a Scene exists
xs = [x for x in range(3)]
if xs != [0, 1, 2]:
fail("range after Scene()", f"expected [0, 1, 2], got {xs}")
return None
print(" ok Range after Scene creation works")
# Create grid (current API: grid_size tuple)
grid = mcrfpy.Grid(grid_size=(10, 10))
print(" ok Created grid")
# Try range again -- must still work after a Grid exists
xs = [x for x in range(3)]
if xs != [0, 1, 2]:
fail("range after Grid()", f"expected [0, 1, 2], got {xs}")
return None
print(" ok Range after Grid creation works")
return grid
except Exception as e:
print(f" ✗ Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
fail("mcrfpy object construction context", f"{type(e).__name__}: {e}")
return None
grid = create_scene()
if grid is None:
fail("create_scene", "returned None")
print()
@ -70,19 +100,22 @@ print("Test 4: Exact failing pattern")
def failing_pattern():
try:
failing_test = mcrfpy.Scene("failing_test")
grid = mcrfpy.Grid(grid_w=14, grid_h=10)
# This is where it fails in the demos
grid = mcrfpy.Grid(grid_size=(14, 10))
# This is where it used to fail in the demos
walls = []
print(" About to enter range loop...")
for x in range(1, 8):
walls.append((x, 1))
print(f" ✓ Created walls: {walls}")
expected = [(x, 1) for x in range(1, 8)]
if walls != expected:
fail("demo wall pattern", f"expected {expected}, got {walls}")
return
print(f" ok Created walls: {walls}")
except Exception as e:
print(f" ✗ Error at line: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
fail("demo wall pattern", f"{type(e).__name__}: {e}")
failing_pattern()
@ -95,34 +128,53 @@ def test_append():
walls = []
# Test 1: Simple append
walls.append((1, 1))
print(" ✓ Single append works")
if walls != [(1, 1)]:
fail("single append", f"got {walls}")
return
print(" ok Single append works")
# Test 2: Manual loop
i = 0
while i < 3:
walls.append((i, 1))
i += 1
print(f" ✓ While loop append works: {walls}")
if walls != [(1, 1), (0, 1), (1, 1), (2, 1)]:
fail("while loop append", f"got {walls}")
return
print(f" ok While loop append works: {walls}")
# Test 3: Range with different operations
walls2 = []
for x in range(3):
tup = (x, 2)
walls2.append(tup)
print(f" ✓ Range with temp variable works: {walls2}")
if walls2 != [(0, 2), (1, 2), (2, 2)]:
fail("range with temp variable", f"got {walls2}")
return
print(f" ok Range with temp variable works: {walls2}")
# Test 4: Direct tuple creation in append
walls3 = []
for x in range(3):
walls3.append((x, 3))
print(f" ✓ Direct tuple append works: {walls3}")
if walls3 != [(0, 3), (1, 3), (2, 3)]:
fail("direct tuple append", f"got {walls3}")
return
print(f" ok Direct tuple append works: {walls3}")
except Exception as e:
print(f" ✗ Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
fail("append in loop", f"{type(e).__name__}: {e}")
test_append()
print()
print("All tests complete.")
if failures:
print(f"FAIL ({len(failures)} check(s) failed)")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("All tests complete.")
print("PASS")
sys.exit(0)

View file

@ -1,31 +1,76 @@
#!/usr/bin/env python3
"""Simple test for Color setter fix"""
"""Simple test for Color setter fix
Original intent: assigning a cell color from a plain (r, g, b) tuple must work
just as well as assigning a mcrfpy.Color object.
API update: per-cell color no longer lives on GridPoint (grid.at(x, y).color is
gone). The successor API is a ColorLayer attached to the grid:
cl = mcrfpy.ColorLayer(name="bg"); grid.add_layer(cl)
cl.set((x, y), color)
So the same tuple-vs-Color coercion is exercised through ColorLayer.set.
"""
import mcrfpy
import sys
print("Testing Color fix...")
# Test 1: Create grid
failures = 0
# Test 1: Create grid + color layer
try:
test = mcrfpy.Scene("test")
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
print("✓ Grid created")
grid = mcrfpy.Grid(grid_size=(5, 5))
color_layer = mcrfpy.ColorLayer(name="bg")
grid.add_layer(color_layer)
print("+ Grid created")
except Exception as e:
print(f"✗ Grid creation failed: {e}")
exit(1)
print(f"x Grid creation failed: {e}")
sys.exit(1)
# Test 2: Set color with tuple
try:
grid.at(0, 0).color = (100, 100, 100)
print("✓ Tuple color assignment works")
color_layer.set((0, 0), (100, 100, 100))
c = color_layer.at((0, 0))
assert (c.r, c.g, c.b) == (100, 100, 100), f"expected (100,100,100), got {(c.r, c.g, c.b)}"
print("+ Tuple color assignment works")
except Exception as e:
print(f"✗ Tuple assignment failed: {e}")
print(f"x Tuple assignment failed: {e}")
failures += 1
# Test 3: Set color with Color object
try:
grid.at(0, 0).color = mcrfpy.Color(200, 200, 200)
print("✓ Color object assignment works!")
color_layer.set((0, 0), mcrfpy.Color(200, 200, 200))
c = color_layer.at((0, 0))
assert (c.r, c.g, c.b) == (200, 200, 200), f"expected (200,200,200), got {(c.r, c.g, c.b)}"
print("+ Color object assignment works!")
except Exception as e:
print(f"✗ Color assignment failed: {e}")
print(f"x Color assignment failed: {e}")
failures += 1
print("Done.")
# Test 4: RGBA tuple (4-component) also coerces
try:
color_layer.set((1, 1), (10, 20, 30, 40))
c = color_layer.at((1, 1))
assert (c.r, c.g, c.b, c.a) == (10, 20, 30, 40), f"expected (10,20,30,40), got {(c.r, c.g, c.b, c.a)}"
print("+ RGBA tuple assignment works")
except Exception as e:
print(f"x RGBA tuple assignment failed: {e}")
failures += 1
# Test 5: bad input must raise, not silently succeed
try:
color_layer.set((2, 2), "not a color")
print("x Bad color value was accepted")
failures += 1
except (TypeError, ValueError):
print("+ Invalid color rejected")
if failures:
print(f"FAIL ({failures} check(s) failed)")
sys.exit(1)
print("Done.")
print("PASS")
sys.exit(0)

View file

@ -1,7 +1,19 @@
#!/usr/bin/env python3
"""Test if Color assignment is the trigger"""
"""Test if Color assignment is the trigger
Originally a repro script for a crash/corruption suspected in per-cell Color
assignment (grid.at(x, y).color = Color(...)) followed by range() iteration.
API update: GridPoint no longer carries a .color -- per-cell color now lives on a
ColorLayer (grid.add_layer(mcrfpy.ColorLayer(...)); layer.set((x, y), Color)).
The original intent (bulk color writes must not corrupt the interpreter, and the
colors must actually stick) is preserved against the new API.
"""
import mcrfpy
import sys
failures = []
print("Testing Color operations with range()...")
print("=" * 50)
@ -10,44 +22,61 @@ print("=" * 50)
print("Test 1: Color assignment in grid")
try:
test1 = mcrfpy.Scene("test1")
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
grid = mcrfpy.Grid(grid_size=(25, 15))
colors = mcrfpy.ColorLayer(name="cell_color")
grid.add_layer(colors)
# Assign color to a cell
grid.at(0, 0).color = mcrfpy.Color(200, 200, 220)
colors.set((0, 0), mcrfpy.Color(200, 200, 220))
c = colors.at((0, 0))
assert (c.r, c.g, c.b) == (200, 200, 220), f"color readback wrong: {c}"
print(" ✓ Single color assignment works")
# Test range
for i in range(25):
pass
print(" ✓ range(25) works after single color assignment")
except Exception as e:
print(f" ✗ Error: {type(e).__name__}: {e}")
failures.append(f"Test 1: {type(e).__name__}: {e}")
# Test 2: Multiple color assignments
print("\nTest 2: Multiple color assignments")
try:
test2 = mcrfpy.Scene("test2")
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
grid = mcrfpy.Grid(grid_size=(25, 15))
colors = mcrfpy.ColorLayer(name="cell_color")
grid.add_layer(colors)
# Multiple properties including color
for y in range(15):
for x in range(25):
grid.at(x, y).walkable = True
grid.at(x, y).transparent = True
grid.at(x, y).color = mcrfpy.Color(200, 200, 220)
colors.set((x, y), mcrfpy.Color(200, 200, 220))
print(" ✓ Completed all property assignments")
# Every cell must have kept its value
for y in range(15):
for x in range(25):
c = colors.at((x, y))
assert (c.r, c.g, c.b) == (200, 200, 220), f"cell ({x},{y}) = {c}"
assert grid.at(x, y).walkable is True, f"cell ({x},{y}) not walkable"
assert grid.at(x, y).transparent is True, f"cell ({x},{y}) not transparent"
print(" ✓ All 375 cells read back correctly")
# This is where it would fail
for i in range(25):
pass
print(" ✓ range(25) still works!")
except Exception as e:
print(f" ✗ Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
failures.append(f"Test 2: {type(e).__name__}: {e}")
# Test 3: Exact reproduction of failing pattern
print("\nTest 3: Exact pattern from dijkstra_demo_final.py")
@ -55,37 +84,46 @@ try:
# Recreate the exact function
def create_demo():
dijkstra_demo = mcrfpy.Scene("dijkstra_demo")
# Create grid
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
grid = mcrfpy.Grid(grid_size=(25, 15))
grid.fill_color = mcrfpy.Color(0, 0, 0)
colors = mcrfpy.ColorLayer(name="cell_color")
grid.add_layer(colors)
# Initialize all as floor
for y in range(15):
for x in range(25):
grid.at(x, y).walkable = True
grid.at(x, y).transparent = True
grid.at(x, y).color = mcrfpy.Color(200, 200, 220)
colors.set((x, y), mcrfpy.Color(200, 200, 220))
# Create an interesting dungeon layout
walls = []
# Room walls
# Top-left room
for x in range(1, 8): walls.append((x, 1))
return grid, walls
grid, walls = create_demo()
assert walls == [(x, 1) for x in range(1, 8)], f"walls corrupted: {walls}"
fc = grid.fill_color
assert (fc.r, fc.g, fc.b) == (0, 0, 0), f"fill_color corrupted: {fc}"
print(f" ✓ Function completed successfully, walls: {walls}")
except Exception as e:
print(f" ✗ Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
failures.append(f"Test 3: {type(e).__name__}: {e}")
print("\nConclusion: The bug is inconsistent and may be related to:")
print("- Memory layout at the time of execution")
print("- Specific bytecode patterns in the Python code")
print("- C++ reference counting issues with Color objects")
print("- Stack/heap corruption in the grid.at() implementation")
print()
if failures:
for f in failures:
print(f"FAILED: {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,70 +1,138 @@
#!/usr/bin/env python3
"""Test that confirms the Color setter bug"""
"""Test that the grid cell Color setter does not leave a pending exception.
Original bug: PyObject_to_sfColor in UIGridPoint.cpp called PyArg_ParseTuple to
parse the assigned color. When handed an mcrfpy.Color object (rather than a
tuple) the parse failed, the setter swallowed the failure without clearing the
error indicator, and the *pending* exception then erupted out of the next,
totally unrelated Python operation (e.g. `range(25)`).
GridPoint.color no longer exists; per-cell color now lives on a ColorLayer
(grid.add_layer(mcrfpy.ColorLayer(...)); layer.set((x, y), color)). This test is
retargeted onto that successor API, preserving the original intent:
- a tuple is accepted
- an mcrfpy.Color object is accepted (the case that used to fail)
- neither leaves a stray pending exception that surfaces later
- the assigned color actually round-trips
"""
import mcrfpy
import sys
print("Testing GridPoint color setter bug...")
failures = []
def check(label, condition, detail=""):
if condition:
print(f" PASS: {label}")
else:
print(f" FAIL: {label} {detail}")
failures.append(label)
def make_color_layer(w, h):
scene = mcrfpy.Scene(f"colorsetter_{w}x{h}")
grid = mcrfpy.Grid(grid_size=(w, h))
layer = mcrfpy.ColorLayer(name="bg")
grid.add_layer(layer)
return grid, layer
print("Testing grid cell color setter (pending-exception bug)...")
print("=" * 50)
# Test 1: Setting color with tuple (old way)
# Test 1: Setting color with a tuple (the path that always worked)
print("Test 1: Setting color with tuple")
try:
test1 = mcrfpy.Scene("test1")
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
# This should work (PyArg_ParseTuple expects tuple)
grid.at(0, 0).color = (200, 200, 220)
# Check if exception is pending
grid, layer = make_color_layer(5, 5)
layer.set((0, 0), (200, 200, 220))
# The original bug surfaced here: an unrelated op raising the pending error.
_ = list(range(1))
print(" ✓ Tuple assignment works")
c = layer.at((0, 0))
check("tuple assignment raises nothing", True)
check(
"tuple color round-trips",
(c.r, c.g, c.b) == (200, 200, 220),
f"got ({c.r}, {c.g}, {c.b})",
)
except Exception as e:
print(f" ✗ Tuple assignment failed: {type(e).__name__}: {e}")
check("tuple assignment", False, f"{type(e).__name__}: {e}")
print()
# Test 2: Setting color with Color object (the bug)
# Test 2: Setting color with a Color object (this is what used to set the
# pending exception). It must now work outright -- no exception, pending or not.
print("Test 2: Setting color with Color object")
try:
test2 = mcrfpy.Scene("test2")
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
# This will fail in PyArg_ParseTuple but not report it
grid.at(0, 0).color = mcrfpy.Color(200, 200, 220)
print(" ⚠️ Color assignment appeared to work...")
# But exception is pending!
grid, layer = make_color_layer(5, 5)
layer.set((0, 0), mcrfpy.Color(200, 200, 220))
# If a stale error indicator were left behind, this innocent call would
# raise it instead.
_ = list(range(1))
print(" ✓ No exception detected (unexpected!)")
c = layer.at((0, 0))
check("Color object assignment raises nothing", True)
check(
"Color object round-trips",
(c.r, c.g, c.b) == (200, 200, 220),
f"got ({c.r}, {c.g}, {c.b})",
)
except Exception as e:
print(f" ✗ Exception detected: {type(e).__name__}: {e}")
print(" This confirms the bug - exception was set but not raised")
check("Color object assignment", False, f"{type(e).__name__}: {e}")
print()
# Test 3: Multiple color assignments
# Test 3: Many Color assignments in a row (reproduces the original failure
# shape: the error only became visible on a later, unrelated operation).
print("Test 3: Multiple Color assignments (reproducing original bug)")
try:
test3 = mcrfpy.Scene("test3")
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
# Do multiple color assignments
for y in range(2): # Just 2 rows to be quick
grid, layer = make_color_layer(25, 15)
for y in range(2):
for x in range(25):
grid.at(x, y).color = mcrfpy.Color(200, 200, 220)
print(" All color assignments completed...")
# This should fail
layer.set((x, y), mcrfpy.Color(200, 200, 220))
# The canary from the original bug report.
for i in range(25):
pass
print(" ✓ range(25) worked (unexpected!)")
c = layer.at((24, 1))
check("50 Color assignments leave no pending exception", True)
check(
"last assigned cell round-trips",
(c.r, c.g, c.b) == (200, 200, 220),
f"got ({c.r}, {c.g}, {c.b})",
)
except Exception as e:
print(f" ✗ range(25) failed as expected: {type(e).__name__}")
print(" The exception was set during color assignment")
check("multiple Color assignments", False, f"{type(e).__name__}: {e}")
print()
print("Bug confirmed: PyObject_to_sfColor in UIGridPoint.cpp")
print("doesn't clear the exception when PyArg_ParseTuple fails.")
print("The fix: Either check PyErr_Occurred() after ParseTuple,")
print("or support mcrfpy.Color objects directly.")
# Test 4: A genuinely bad color must raise a REAL exception, immediately -- the
# other half of the bug was errors being set but never raised.
print("Test 4: Invalid color raises immediately")
try:
grid, layer = make_color_layer(5, 5)
try:
layer.set((0, 0), "not a color")
check("invalid color raises", False, "no exception raised")
except TypeError:
check("invalid color raises TypeError at the call site", True)
# And the failed call must not have left an error indicator behind.
_ = list(range(1))
check("failed set leaves no pending exception", True)
except Exception as e:
check("invalid color handling", False, f"{type(e).__name__}: {e}")
print()
print("=" * 50)
if failures:
print(f"FAILED ({len(failures)}): {', '.join(failures)}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -8,12 +8,23 @@ import sys
import mcrfpy
def make_color_layer(size=(10, 10)):
"""Create a Grid with an attached ColorLayer, returning (grid, layer).
add_layer() takes a layer object and no keyword arguments; the ColorLayer
constructor is what accepts name/z_index.
"""
grid = mcrfpy.Grid(grid_size=size)
layer = mcrfpy.ColorLayer(name='color', z_index=0)
grid.add_layer(layer)
return grid, layer
def test_apply_threshold_basic():
"""apply_threshold sets colors in range"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
layer.fill((0, 0, 0, 0)) # Clear all
# Apply threshold - all cells should get blue
@ -32,8 +43,7 @@ def test_apply_threshold_with_alpha():
"""apply_threshold handles RGBA colors"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
layer.apply_threshold(hmap, (0.0, 1.0), (100, 150, 200, 128))
@ -47,8 +57,7 @@ def test_apply_threshold_preserves_outside():
"""apply_threshold doesn't modify cells outside range"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
layer.fill((255, 0, 0)) # Fill with red
# Apply threshold for range that doesn't include 0.5
@ -65,8 +74,7 @@ def test_apply_threshold_with_color_object():
"""apply_threshold accepts Color objects"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
color = mcrfpy.Color(50, 100, 150)
layer.apply_threshold(hmap, (0.0, 1.0), color)
@ -80,8 +88,7 @@ def test_apply_threshold_size_mismatch():
"""apply_threshold rejects mismatched HeightMap size"""
hmap = mcrfpy.HeightMap((5, 5)) # Different size
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
try:
layer.apply_threshold(hmap, (0.0, 1.0), (255, 0, 0))
@ -97,8 +104,7 @@ def test_apply_gradient_basic():
"""apply_gradient interpolates colors"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
# Apply gradient from black to white
result = layer.apply_gradient(hmap, (0.0, 1.0), (0, 0, 0), (255, 255, 255))
@ -118,8 +124,7 @@ def test_apply_gradient_full_range():
# Test at minimum of range
hmap_low = mcrfpy.HeightMap((10, 10), fill=0.0)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
layer.apply_gradient(hmap_low, (0.0, 1.0), (100, 0, 0), (200, 255, 0))
@ -144,8 +149,7 @@ def test_apply_gradient_preserves_outside():
"""apply_gradient doesn't modify cells outside range"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
layer.fill((255, 0, 0)) # Fill with red
# Apply gradient for range that doesn't include 0.5
@ -161,8 +165,7 @@ def test_apply_ranges_fixed_colors():
"""apply_ranges with fixed colors"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
layer.fill((0, 0, 0))
result = layer.apply_ranges(hmap, [
@ -183,8 +186,7 @@ def test_apply_ranges_gradient():
"""apply_ranges with gradient specification"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
# Gradient from (0,0,0) to (255,255,255) over range [0,1]
# At value 0.5, should be ~(127,127,127)
@ -201,8 +203,7 @@ def test_apply_ranges_mixed():
"""apply_ranges with mixed fixed and gradient entries"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
layer.fill((0, 0, 0))
# Test mixed: gradient that includes 0.5
@ -222,8 +223,7 @@ def test_apply_ranges_later_wins():
"""apply_ranges: later ranges override earlier ones"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
layer.apply_ranges(hmap, [
((0.0, 1.0), (255, 0, 0)), # Red, matches everything
@ -240,8 +240,7 @@ def test_apply_ranges_no_match_unchanged():
"""apply_ranges leaves unmatched cells unchanged"""
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
layer.fill((128, 128, 128)) # Gray marker
layer.apply_ranges(hmap, [
@ -259,8 +258,7 @@ def test_apply_threshold_invalid_range():
"""apply_threshold rejects min > max"""
hmap = mcrfpy.HeightMap((10, 10))
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
try:
layer.apply_threshold(hmap, (1.0, 0.0), (255, 0, 0))
@ -277,8 +275,7 @@ def test_apply_gradient_narrow_range():
# Use a value exactly at the range
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('color', z_index=0)
grid, layer = make_color_layer()
# Apply gradient over exact value (min == max)
layer.apply_gradient(hmap, (0.5, 0.5), (0, 0, 0), (255, 255, 255))

View file

@ -8,32 +8,49 @@ Demonstrates:
2. Getting distances to any position
3. Finding paths from any position back to the root
4. Multi-target pathfinding (flee/approach scenarios)
API notes (updated for current mcrfpy):
- The `mcrfpy.libtcod` module is gone. Its successor is the GridData Dijkstra API:
grid.grid_data.get_dijkstra_map(root=(x, y)) -> DijkstraMap
dmap.root / dmap.distance((x, y)) / dmap.path_from((x, y))
grid.grid_data.clear_dijkstra_maps() (invalidate after walkability changes)
- GridPoint has no .tilesprite/.color; tiles go on a TileLayer, colors on a ColorLayer.
"""
import mcrfpy
from mcrfpy import libtcod
import sys
failures = []
def check(cond, message):
"""Record a failed check instead of aborting, so all checks run."""
if not cond:
failures.append(message)
print(f" FAIL: {message}")
return cond
def adjacent(a, b):
"""True if a and b are the same cell or 8-way neighbors."""
return abs(int(a[0]) - int(b[0])) <= 1 and abs(int(a[1]) - int(b[1])) <= 1
def create_test_grid():
"""Create a test grid with obstacles"""
dijkstra_test = mcrfpy.Scene("dijkstra_test")
# Create grid
grid = mcrfpy.Grid(grid_w=20, grid_h=20)
# Create grid (already has a default tile layer)
grid = mcrfpy.Grid(grid_size=(20, 20))
# Add color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# Store color_layer on grid for access elsewhere
grid._color_layer = color_layer
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.add_layer(color_layer)
data = grid.grid_data
# Initialize all cells as walkable
for y in range(grid.grid_h):
for x in range(grid.grid_w):
for y in range(data.grid_h):
for x in range(data.grid_w):
cell = grid.at(x, y)
cell.walkable = True
cell.transparent = True
cell.tilesprite = 46 # . period
color_layer.set(x, y, mcrfpy.Color(50, 50, 50))
color_layer.set((x, y), mcrfpy.Color(50, 50, 50))
# Create some walls to make pathfinding interesting
# Vertical wall
@ -41,187 +58,261 @@ def create_test_grid():
cell = grid.at(10, y)
cell.walkable = False
cell.transparent = False
cell.tilesprite = 219 # Block
color_layer.set(10, y, mcrfpy.Color(100, 100, 100))
color_layer.set((10, y), mcrfpy.Color(100, 100, 100))
# Horizontal wall
for x in range(5, 15):
if x != 10: # Leave a gap
if x != 10: # (10, 10) is already part of the vertical wall
cell = grid.at(x, 10)
cell.walkable = False
cell.transparent = False
cell.tilesprite = 219
color_layer.set(x, 10, mcrfpy.Color(100, 100, 100))
color_layer.set((x, 10), mcrfpy.Color(100, 100, 100))
return grid
# Walkability changed after construction; drop any cached maps
data.clear_dijkstra_maps()
return grid, color_layer
def test_basic_dijkstra():
"""Test basic Dijkstra functionality"""
print("\n=== Testing Basic Dijkstra ===")
grid = create_test_grid()
grid, _color_layer = create_test_grid()
data = grid.grid_data
# Compute Dijkstra map from position (5, 5)
root_x, root_y = 5, 5
print(f"Computing Dijkstra map from root ({root_x}, {root_y})")
grid.compute_dijkstra(root_x, root_y)
dmap = data.get_dijkstra_map(root=(root_x, root_y))
check(dmap is not None, "get_dijkstra_map returned None")
check((dmap.root.x, dmap.root.y) == (root_x, root_y),
f"dmap.root {dmap.root} != root ({root_x}, {root_y})")
# Test getting distances to various points
# (x, y, expected) -- expected None means "unreachable"
test_points = [
(5, 5), # Root position (should be 0)
(6, 5), # Adjacent (should be 1)
(7, 5), # Two steps away
(15, 15), # Far corner
(10, 10), # On a wall (should be unreachable)
(5, 5, 0.0), # Root position
(6, 5, 1.0), # Adjacent
(7, 5, 2.0), # Two steps away
(15, 15, None), # Far corner: reachable, distance checked below
(10, 10, None), # On a wall: unreachable
]
print("\nDistances from root:")
for x, y in test_points:
distance = grid.get_dijkstra_distance(x, y)
for x, y, expected in test_points:
distance = dmap.distance((x, y))
if distance is None:
print(f" ({x:2}, {y:2}): UNREACHABLE")
else:
print(f" ({x:2}, {y:2}): {distance:.1f}")
if (x, y) == (10, 10):
check(distance is None, "wall cell (10, 10) should be unreachable")
elif (x, y) == (15, 15):
# Reachable by going around the walls; exact cost depends on
# diagonal_cost, but it must be finite and clearly non-trivial.
check(distance is not None, "(15, 15) should be reachable around the walls")
if distance is not None:
check(distance > 10.0, f"(15, 15) distance {distance} implausibly short")
else:
check(distance is not None, f"({x}, {y}) should be reachable")
if distance is not None:
check(abs(distance - expected) < 0.001,
f"({x}, {y}) distance {distance} != expected {expected}")
# Test getting paths
print("\nPaths to root:")
for x, y in [(15, 5), (15, 15), (5, 15)]:
path = grid.get_dijkstra_path(x, y)
if path:
print(f" From ({x}, {y}): {len(path)} steps")
# Show first few steps
for i, (px, py) in enumerate(path[:3]):
print(f" Step {i+1}: ({px}, {py})")
if len(path) > 3:
print(f" ... {len(path)-3} more steps")
path = dmap.path_from((x, y))
steps = list(path) if path else []
if steps:
print(f" From ({x}, {y}): {len(steps)} steps")
for i, step in enumerate(steps[:3]):
print(f" Step {i+1}: ({step.x}, {step.y})")
if len(steps) > 3:
print(f" ... {len(steps)-3} more steps")
else:
print(f" From ({x}, {y}): No path found")
def test_libtcod_interface():
"""Test the libtcod module interface"""
print("\n=== Testing libtcod Interface ===")
grid = create_test_grid()
# Use libtcod functions
print("Using libtcod.dijkstra_* functions:")
# Create dijkstra context (returns grid)
dijkstra = libtcod.dijkstra_new(grid)
print(f"Created Dijkstra context: {type(dijkstra)}")
# Compute from a position
libtcod.dijkstra_compute(grid, 10, 2)
print("Computed Dijkstra map from (10, 2)")
# Get distance using libtcod
distance = libtcod.dijkstra_get_distance(grid, 10, 17)
check(len(steps) > 0, f"no path from ({x}, {y}) back to root")
if steps:
# An AStarPath walks origin -> destination: successive steps must be
# adjacent, the first step must neighbor the query position, and the
# last step must be the root. (This is what find_path() returns.)
cells = [(int(s.x), int(s.y)) for s in steps]
check(adjacent(cells[0], (x, y)),
f"path from ({x}, {y}): first step {cells[0]} is not adjacent to the start")
check(cells[-1] == (root_x, root_y),
f"path from ({x}, {y}) ends at {cells[-1]}, not the root ({root_x}, {root_y})")
for a, b in zip(cells, cells[1:]):
check(adjacent(a, b),
f"path from ({x}, {y}) jumps from {a} to {b} (not adjacent)")
for cell in cells:
check(grid.at(cell[0], cell[1]).walkable,
f"path from ({x}, {y}) crosses wall at {cell}")
def test_dijkstra_map_interface():
"""Test the DijkstraMap object interface (successor to the old libtcod module)"""
print("\n=== Testing DijkstraMap Interface ===")
grid, _color_layer = create_test_grid()
data = grid.grid_data
# Create dijkstra map (successor of libtcod.dijkstra_new + dijkstra_compute)
dijkstra = data.get_dijkstra_map(root=(10, 2))
print(f"Created Dijkstra map: {type(dijkstra).__name__}, root={dijkstra.root}")
check(isinstance(dijkstra, mcrfpy.DijkstraMap),
"get_dijkstra_map did not return a DijkstraMap")
# Get distance (successor of libtcod.dijkstra_get_distance)
distance = dijkstra.distance((10, 17))
print(f"Distance to (10, 17): {distance}")
# Get path using libtcod
path = libtcod.dijkstra_path_to(grid, 10, 17)
print(f"Path from (10, 17) to root: {len(path) if path else 0} steps")
check(distance is not None, "(10, 17) should be reachable from (10, 2)")
# Get path (successor of libtcod.dijkstra_path_to)
path = dijkstra.path_from((10, 17))
steps = [(int(s.x), int(s.y)) for s in path] if path else []
print(f"Path from (10, 17) to root: {len(steps)} steps -> {steps[:4]}...")
check(len(steps) > 0, "no path from (10, 17) to root (10, 2)")
if steps:
check((path.origin.x, path.origin.y) == (10, 17), "AStarPath.origin is not the query pos")
check((path.destination.x, path.destination.y) == (10, 2),
"AStarPath.destination is not the root")
check(adjacent(steps[0], (10, 17)),
f"first step {steps[0]} is not adjacent to the start (10, 17)")
check(steps[-1] == (10, 2), f"path ends at {steps[-1]}, not root (10, 2)")
# step_from(pos) is documented as "single step from position toward root":
# it must be the same as the first element of path_from(pos), and adjacent.
step = dijkstra.step_from((10, 17))
print(f"step_from((10, 17)) -> {step}")
check(step is not None, "step_from returned None for a reachable cell")
if step is not None:
check(adjacent((int(step.x), int(step.y)), (10, 17)),
f"step_from((10, 17)) returned ({int(step.x)}, {int(step.y)}), which is not adjacent")
# Maps are cached per-root; asking again yields an equivalent map
again = data.get_dijkstra_map(root=(10, 2))
check((again.root.x, again.root.y) == (10, 2), "cached map lost its root")
check(again.distance((10, 17)) == distance, "cached map distance changed")
def test_multi_target_scenario():
"""Test fleeing/approaching multiple targets"""
print("\n=== Testing Multi-Target Scenario ===")
grid = create_test_grid()
grid, color_layer = create_test_grid()
data = grid.grid_data
# Place three "threats" and compute their Dijkstra maps
threats = [(3, 3), (17, 3), (10, 17)]
print("Computing threat distances...")
threat_distances = []
for i, (tx, ty) in enumerate(threats):
# Mark threat position
cell = grid.at(tx, ty)
cell.tilesprite = 84 # T for threat
grid._color_layer.set(tx, ty, mcrfpy.Color(255, 0, 0))
color_layer.set((tx, ty), mcrfpy.Color(255, 0, 0))
# Compute Dijkstra from this threat
grid.compute_dijkstra(tx, ty)
dmap = data.get_dijkstra_map(root=(tx, ty))
# Store distances for all cells
distances = {}
for y in range(grid.grid_h):
for x in range(grid.grid_w):
d = grid.get_dijkstra_distance(x, y)
for y in range(data.grid_h):
for x in range(data.grid_w):
d = dmap.distance((x, y))
if d is not None:
distances[(x, y)] = d
threat_distances.append(distances)
print(f" Threat {i+1} at ({tx}, {ty}): {len(distances)} reachable cells")
check(distances.get((tx, ty)) == 0.0,
f"threat {i+1} root ({tx}, {ty}) should have distance 0")
check(len(distances) > 100,
f"threat {i+1} only reached {len(distances)} cells; map looks broken")
check((10, 10) not in distances,
f"threat {i+1} reached wall cell (10, 10)")
# Find safest position (farthest from all threats)
print("\nFinding safest position...")
best_pos = None
best_min_dist = 0
for y in range(grid.grid_h):
for x in range(grid.grid_w):
for y in range(data.grid_h):
for x in range(data.grid_w):
# Skip if not walkable
if not grid.at(x, y).walkable:
continue
# Get minimum distance to any threat
min_dist = float('inf')
for threat_dist in threat_distances:
if (x, y) in threat_dist:
min_dist = min(min_dist, threat_dist[(x, y)])
# Track best position
if min_dist > best_min_dist and min_dist != float('inf'):
best_min_dist = min_dist
best_pos = (x, y)
check(best_pos is not None, "no safest position found (all cells unreachable?)")
if best_pos:
print(f"Safest position: {best_pos} (min distance to threats: {best_min_dist:.1f})")
check(grid.at(best_pos[0], best_pos[1]).walkable, "safest position is a wall")
check(best_min_dist > 0, "safest position sits on top of a threat")
# Mark safe position
cell = grid.at(best_pos[0], best_pos[1])
cell.tilesprite = 83 # S for safe
grid._color_layer.set(best_pos[0], best_pos[1], mcrfpy.Color(0, 255, 0))
color_layer.set((best_pos[0], best_pos[1]), mcrfpy.Color(0, 255, 0))
# Multi-source Dijkstra: one map rooted at ALL threats at once. Its distance
# to any cell must equal the min over the individual per-threat maps.
multi = data.get_dijkstra_map(roots=threats)
mismatches = 0
for (x, y), _ in list(threat_distances[0].items())[:200]:
expected = min(td[(x, y)] for td in threat_distances if (x, y) in td)
got = multi.distance((x, y))
if got is None or abs(got - expected) > 0.001:
mismatches += 1
check(mismatches == 0,
f"multi-source Dijkstra disagrees with min-of-single-source on {mismatches} cells")
def main():
print("McRogueFace Dijkstra Pathfinding Test")
print("=====================================")
# Set up scene so the grid is actually attached to a live UI tree
dijkstra_test = mcrfpy.Scene("dijkstra_test")
grid, _color_layer = create_test_grid()
grid.pos = (0, 0)
grid.size = (400, 400)
ui = dijkstra_test.children
ui.append(grid)
title = mcrfpy.Caption(text="Dijkstra Pathfinding Test", pos=(10, 10))
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
dijkstra_test.activate()
def run_test(timer, runtime):
"""Timer callback to run tests after scene loads"""
test_basic_dijkstra()
test_libtcod_interface()
test_dijkstra_map_interface()
test_multi_target_scenario()
print("\n=== Dijkstra Implementation Test Complete ===")
print("✓ Basic Dijkstra computation works")
print("✓ Distance queries work")
print("✓ Path finding works")
print("✓ libtcod interface works")
print("✓ Multi-target scenarios work")
# Take screenshot
try:
from mcrfpy import automation
automation.screenshot("dijkstra_test.png")
print("\nScreenshot saved: dijkstra_test.png")
except:
pass
if failures:
print(f"\n{len(failures)} check(s) FAILED:")
for f in failures:
print(f" - {f}")
print("FAIL")
sys.exit(1)
print("Basic Dijkstra computation works")
print("Distance queries work")
print("Path finding works")
print("DijkstraMap interface works")
print("Multi-target scenarios work")
print("PASS")
sys.exit(0)
# Main execution
print("McRogueFace Dijkstra Pathfinding Test")
print("=====================================")
# Set up scene
grid = create_test_grid()
ui = dijkstra_test.children
ui.append(grid)
# Add title
title = mcrfpy.Caption(pos=(10, 10), text="Dijkstra Pathfinding Test")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
# Set timer to run tests
test_timer = mcrfpy.Timer("test", run_test, 100, once=True)
# Show scene
dijkstra_test.activate()
if __name__ == "__main__":
main()

View file

@ -1,133 +1,164 @@
#!/usr/bin/env python3
"""Test that method documentation is properly accessible in Python."""
"""Test that method documentation is properly accessible in Python.
Rot repair: this test used to only *print* what it found (so a missing
docstring silently "passed"), and it probed the pre-#350 API surface
(setScene/createScene/sceneUI/currentScene, the module-level audio
functions). It now asserts against the current mcrfpy API and fails loudly
on any missing docstring.
"""
import mcrfpy
import sys
import types
import io
import contextlib
failures = []
def check(condition, message):
if condition:
print(f" PASS: {message}")
else:
print(f" FAIL: {message}")
failures.append(message)
def test_module_doc():
"""Test module-level documentation."""
print("=== Module Documentation ===")
print(f"Module: {mcrfpy.__name__}")
print(f"Doc: {mcrfpy.__doc__[:100]}..." if mcrfpy.__doc__ else "No module doc")
doc = mcrfpy.__doc__
check(bool(doc and doc.strip()), "mcrfpy has a module docstring")
check(bool(doc and 'McRogueFace' in doc), "module docstring names McRogueFace")
print()
def test_method_docs():
"""Test method documentation."""
"""Every public module-level function must be documented."""
print("=== Method Documentation ===")
# Test main API methods
# Current module-level API (audio moved to Sound/Music/SoundBuffer classes;
# setScene/createScene/currentScene/sceneUI replaced by mcrfpy.Scene).
methods = [
'createSoundBuffer', 'loadMusic', 'setMusicVolume', 'setSoundVolume',
'playSound', 'getMusicVolume', 'getSoundVolume', 'sceneUI',
'currentScene', 'setScene', 'createScene', 'keypressScene',
'exit', 'setScale', 'find', 'findAll',
'getMetrics'
'bresenham', 'end_benchmark', 'exit', 'find', 'find_all', 'get_metrics',
'lock', 'log_benchmark', 'set_dev_console', 'set_scale',
'start_benchmark', 'step',
]
for method_name in methods:
if hasattr(mcrfpy, method_name):
method = getattr(mcrfpy, method_name)
doc = method.__doc__
if doc:
# Extract first line of docstring
first_line = doc.strip().split('\n')[0]
print(f"{method_name}: {first_line}")
else:
print(f"{method_name}: NO DOCUMENTATION")
check(hasattr(mcrfpy, method_name), f"mcrfpy.{method_name} exists")
method = getattr(mcrfpy, method_name, None)
doc = getattr(method, '__doc__', None)
check(bool(doc and doc.strip()), f"mcrfpy.{method_name} has documentation")
# Nothing public may be undocumented, even if not listed above.
for name in dir(mcrfpy):
if name.startswith('_'):
continue
obj = getattr(mcrfpy, name)
if isinstance(obj, types.BuiltinFunctionType):
check(bool(obj.__doc__), f"module function {name} has documentation")
print()
def test_class_docs():
"""Test class documentation."""
print("=== Class Documentation ===")
classes = ['Frame', 'Caption', 'Sprite', 'Grid', 'Entity', 'Color', 'Vector', 'Texture', 'Font', 'Timer']
classes = ['Frame', 'Caption', 'Sprite', 'Grid', 'GridView', 'GridData',
'Entity', 'Color', 'Vector', 'Texture', 'Font', 'Timer', 'Scene']
for class_name in classes:
if hasattr(mcrfpy, class_name):
cls = getattr(mcrfpy, class_name)
doc = cls.__doc__
if doc:
# Extract first line
first_line = doc.strip().split('\n')[0]
print(f"{class_name}: {first_line[:80]}...")
else:
print(f"{class_name}: NO DOCUMENTATION")
check(hasattr(mcrfpy, class_name), f"mcrfpy.{class_name} exists")
cls = getattr(mcrfpy, class_name, None)
doc = getattr(cls, '__doc__', None)
check(bool(doc and doc.strip()), f"class {class_name} has documentation")
if doc:
print(f" {class_name}: {doc.strip().splitlines()[0][:70]}")
print()
def test_property_docs():
"""Test property documentation."""
"""Every getset property on the core drawables must be documented."""
print("=== Property Documentation ===")
# Test Frame properties
if hasattr(mcrfpy, 'Frame'):
frame_props = ['x', 'y', 'w', 'h', 'fill_color', 'outline_color', 'outline', 'children', 'visible', 'z_index']
print("Frame properties:")
for prop_name in frame_props:
prop = getattr(mcrfpy.Frame, prop_name, None)
if prop and hasattr(prop, '__doc__'):
print(f" {prop_name}: {prop.__doc__}")
frame_props = ['x', 'y', 'w', 'h', 'fill_color', 'outline_color', 'outline',
'children', 'visible', 'z_index']
for prop_name in frame_props:
prop = getattr(mcrfpy.Frame, prop_name, None)
check(prop is not None, f"Frame.{prop_name} exists")
check(bool(prop is not None and prop.__doc__),
f"Frame.{prop_name} has documentation")
# No getset descriptor on the core types may be undocumented.
for class_name in ['Frame', 'Caption', 'Sprite', 'Grid', 'Entity',
'Color', 'Vector', 'Timer']:
cls = getattr(mcrfpy, class_name)
for prop_name, prop in vars(cls).items():
if isinstance(prop, types.GetSetDescriptorType):
check(bool(prop.__doc__),
f"{class_name}.{prop_name} has documentation")
print()
def test_method_signatures():
"""Test that methods have correct signatures in docs."""
"""Test that docstrings carry a usable signature line."""
print("=== Method Signatures ===")
# Check a few key methods
if hasattr(mcrfpy, 'setScene'):
doc = mcrfpy.setScene.__doc__
if doc and 'setScene(scene: str, transition: str = None, duration: float = 0.0)' in doc:
print("✓ setScene signature correct")
else:
print("✗ setScene signature incorrect or missing")
if hasattr(mcrfpy, 'Timer'):
doc = mcrfpy.Timer.__doc__
if doc and 'Timer' in doc:
print("+ Timer class documentation present")
else:
print("x Timer class documentation missing")
if hasattr(mcrfpy, 'find'):
doc = mcrfpy.find.__doc__
if doc and 'find(name: str, scene: str = None)' in doc:
print("✓ find signature correct")
else:
print("✗ find signature incorrect or missing")
doc = mcrfpy.find.__doc__
check(bool(doc and 'find(name: str, scene: str = None)' in doc),
"find() signature present in docstring")
doc = mcrfpy.step.__doc__
check(bool(doc and 'step(' in doc), "step() signature present in docstring")
doc = mcrfpy.Timer.__doc__
check(bool(doc and 'Timer(' in doc), "Timer signature present in class doc")
doc = mcrfpy.Scene.__doc__
check(bool(doc and 'Scene(' in doc), "Scene signature present in class doc")
# Bound method documentation (animation is now an object method, #229).
doc = mcrfpy.Frame.animate.__doc__
check(bool(doc and 'animate(' in doc),
"Frame.animate() signature present in docstring")
print()
def test_help_output():
"""Test Python help() function output."""
print("=== Help Function Test ===")
print("Testing help(mcrfpy.setScene):")
import io
import contextlib
# Capture help output
buffer = io.StringIO()
with contextlib.redirect_stdout(buffer):
help(mcrfpy.setScene)
help(mcrfpy.find)
help_text = buffer.getvalue()
if 'transition to a different scene' in help_text:
print("✓ Help text contains expected documentation")
else:
print("✗ Help text missing expected documentation")
check('Find the first UI element' in help_text,
"help(mcrfpy.find) contains its documentation")
buffer = io.StringIO()
with contextlib.redirect_stdout(buffer):
help(mcrfpy.Frame)
help_text = buffer.getvalue()
check('Frame' in help_text and 'animate' in help_text,
"help(mcrfpy.Frame) lists the class and its methods")
print()
def main():
"""Run all documentation tests."""
print("McRogueFace Documentation Tests")
print("===============================\n")
test_module_doc()
test_method_docs()
test_class_docs()
test_property_docs()
test_method_signatures()
test_help_output()
if failures:
print(f"\nFAIL: {len(failures)} documentation check(s) failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("\nDocumentation tests complete!")
print("PASS")
sys.exit(0)
if __name__ == '__main__':
main()
main()

View file

@ -5,20 +5,32 @@ Test Entity Animation
Isolated test for entity position animation.
No perspective, just basic movement in a square pattern.
Headless: mcrfpy.step(dt) is the only clock, so the waypoint sequence is driven
explicitly instead of by keyboard input + a free-running game loop (#350/#341).
"""
import mcrfpy
import sys
failures = []
def check(condition, message):
if not condition:
print(f"FAIL: {message}")
failures.append(message)
return condition
# Create scene
test_anim = mcrfpy.Scene("test_anim")
# Create simple grid
grid = mcrfpy.Grid(grid_w=15, grid_h=15)
grid = mcrfpy.Grid(grid_size=(15, 15), pos=(100, 100), size=(450, 450))
grid.fill_color = mcrfpy.Color(20, 20, 30)
# Add a color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# Add a color layer for cell coloring (GridPoint.color is gone; use a ColorLayer)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.grid_data.add_layer(color_layer)
# Initialize all cells as walkable floors
for y in range(15):
@ -26,7 +38,7 @@ for y in range(15):
cell = grid.at(x, y)
cell.walkable = True
cell.transparent = True
color_layer.set(x, y, mcrfpy.Color(100, 100, 120))
color_layer.set((x, y), mcrfpy.Color(100, 100, 120))
# Mark the path we'll follow with different color
path_cells = [(5,5), (6,5), (7,5), (8,5), (9,5), (10,5),
@ -35,17 +47,16 @@ path_cells = [(5,5), (6,5), (7,5), (8,5), (9,5), (10,5),
(5,9), (5,8), (5,7), (5,6)]
for x, y in path_cells:
color_layer.set(x, y, mcrfpy.Color(120, 120, 150))
color_layer.set((x, y), mcrfpy.Color(120, 120, 150))
# Create entity at start position
entity = mcrfpy.Entity((5, 5), grid=grid)
entity = mcrfpy.Entity(grid_pos=(5, 5))
entity.sprite_index = 64 # @
grid.entities.append(entity)
# UI setup
ui = test_anim.children
ui.append(grid)
grid.pos = (100, 100)
grid.size = (450, 450) # 15 * 30 pixels per cell
# Title
title = mcrfpy.Caption(pos=(200, 20), text="Entity Animation Test - Square Path")
@ -53,12 +64,13 @@ title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
# Status display
status = mcrfpy.Caption(pos=(100, 50), text="Press SPACE to start animation | Q to quit")
status = mcrfpy.Caption(pos=(100, 50), text="Animating square path")
status.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(status)
# Position display
pos_display = mcrfpy.Caption(pos=(100, 70), text=f"Entity Position: ({entity.x:.2f}, {entity.y:.2f})")
pos_display = mcrfpy.Caption(pos=(100, 70),
text=f"Entity Position: ({entity.draw_pos.x:.2f}, {entity.draw_pos.y:.2f})")
pos_display.fill_color = mcrfpy.Color(255, 255, 100)
ui.append(pos_display)
@ -67,139 +79,118 @@ anim_info = mcrfpy.Caption(pos=(400, 70), text="Animation: Not started")
anim_info.fill_color = mcrfpy.Color(100, 255, 255)
ui.append(anim_info)
# Debug info
debug_info = mcrfpy.Caption(pos=(100, 570), text="Debug: Waiting...")
debug_info.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(debug_info)
# Animation state
current_waypoint = 0
animating = False
waypoints = [(5,5), (10,5), (10,10), (5,10), (5,5)]
completed = [] # (property, final_value) tuples from animation callbacks
def update_position_display(timer, runtime):
"""Update position display every 200ms"""
pos_display.text = f"Entity Position: ({entity.x:.2f}, {entity.y:.2f})"
def on_anim_done(target, prop, value):
"""Animation completion callback: (target, property, final_value)"""
completed.append((prop, value))
# Check if entity is at expected position
if animating and current_waypoint > 0:
target = waypoints[current_waypoint - 1]
distance = ((entity.x - target[0])**2 + (entity.y - target[1])**2)**0.5
debug_info.text = f"Debug: Distance to target {target}: {distance:.3f}"
def animate_to_waypoint(index):
"""Animate the entity's draw position to waypoints[index]."""
target_x, target_y = waypoints[index]
duration = 2.0
def animate_to_next_waypoint():
"""Animate to the next waypoint"""
global current_waypoint, animating
if current_waypoint >= len(waypoints):
status.text = "Animation complete! Press SPACE to restart"
anim_info.text = "Animation: Complete"
animating = False
current_waypoint = 0
return
target_x, target_y = waypoints[current_waypoint]
# Log what we're doing
print(f"Animating from ({entity.x}, {entity.y}) to ({target_x}, {target_y})")
# Update status
status.text = f"Moving to waypoint {current_waypoint + 1}/{len(waypoints)}: ({target_x}, {target_y})"
print(f"Animating from ({entity.draw_pos.x}, {entity.draw_pos.y}) to ({target_x}, {target_y})")
status.text = f"Moving to waypoint {index + 1}/{len(waypoints)}: ({target_x}, {target_y})"
anim_info.text = f"Animation: Active (target: {target_x}, {target_y})"
# Create animations - ensure we're using floats
duration = 2.0 # 2 seconds per segment
# Try different approaches to see what works
# Approach 1: Direct property animation
anim_x = mcrfpy.Animation("x", float(target_x), duration, "linear")
anim_y = mcrfpy.Animation("y", float(target_y), duration, "linear")
# Start animations
anim_x.start(entity)
anim_y.start(entity)
# Log animation details
print(f"Started animations: x to {float(target_x)}, y to {float(target_y)}, duration: {duration}s")
current_waypoint += 1
# Schedule next waypoint
global next_waypoint_timer
next_waypoint_timer = mcrfpy.Timer("next_waypoint", lambda t, r: animate_to_next_waypoint(), int(duration * 1000 + 100), once=True)
# 'draw_x'/'draw_y' are the tile-space draw coordinates ('x'/'y' are aliases).
# mcrfpy.Animation is no longer exported; animations are created via .animate().
entity.animate("draw_x", float(target_x), duration, mcrfpy.Easing.LINEAR,
callback=on_anim_done)
entity.animate("draw_y", float(target_y), duration, mcrfpy.Easing.LINEAR,
callback=on_anim_done)
return duration
def start_animation():
"""Start or restart the animation sequence"""
global current_waypoint, animating
# Reset entity position
entity.x = 5
entity.y = 5
# Reset state
current_waypoint = 0
animating = True
print("Starting animation sequence...")
# Start first animation
animate_to_next_waypoint()
def step_seconds(seconds, dt=0.1):
"""Advance the headless clock; the engine never advances time on its own."""
for _ in range(int(round(seconds / dt))):
mcrfpy.step(dt)
def test_immediate_position():
"""Test setting position directly"""
print(f"Before: entity at ({entity.x}, {entity.y})")
entity.x = 7
entity.y = 7
print(f"After direct set: entity at ({entity.x}, {entity.y})")
# Try with animation to same position
anim_x = mcrfpy.Animation("x", 9.0, 1.0, "linear")
anim_x.start(entity)
print("Started animation to x=9.0")
"""Test setting position directly (no animation)."""
# #295: grid_pos (logical cell) is DECOUPLED from draw_pos (visual position).
# Setting one does not move the other; they are assigned independently.
entity.grid_pos = (7, 7)
check(tuple(entity.cell_pos) == (7, 7),
f"direct grid_pos set: expected cell (7,7), got {tuple(entity.cell_pos)}")
check((entity.draw_pos.x, entity.draw_pos.y) == (5.0, 5.0),
f"grid_pos is decoupled from draw_pos; draw_pos should be unchanged at "
f"(5,5), got {entity.draw_pos}")
# Input handler
def handle_input(key, state):
if state != mcrfpy.InputState.PRESSED:
return
entity.draw_pos = (7.0, 7.0)
check((entity.draw_pos.x, entity.draw_pos.y) == (7.0, 7.0),
f"direct draw_pos set: expected (7.0, 7.0), got {entity.draw_pos}")
if key == mcrfpy.Key.Q:
print("Exiting test...")
sys.exit(0)
elif key == mcrfpy.Key.SPACE:
if not animating:
start_animation()
else:
print("Animation already in progress!")
elif key == mcrfpy.Key.T:
# Test immediate position change
test_immediate_position()
elif key == mcrfpy.Key.R:
# Reset position
entity.x = 5
entity.y = 5
print(f"Reset entity to ({entity.x}, {entity.y})")
# Set scene
test_anim.activate()
test_anim.on_key = handle_input
# Start position update timer
update_pos_timer = mcrfpy.Timer("update_pos", update_position_display, 200)
# No perspective (omniscient view)
grid.perspective = -1
# Animation to a new x, driven by explicit steps
entity.animate("draw_x", 9.0, 1.0, mcrfpy.Easing.LINEAR)
step_seconds(0.5)
mid_x = entity.draw_pos.x
check(7.0 < mid_x < 9.0,
f"linear animation should be mid-flight at t=0.5s, draw_x={mid_x}")
step_seconds(0.6)
check(abs(entity.draw_pos.x - 9.0) < 0.01,
f"animation should land on draw_x=9.0, got {entity.draw_pos.x}")
print("Entity Animation Test")
print("====================")
print("This test animates an entity in a square pattern:")
print("(5,5) → (10,5) → (10,10) → (5,10) → (5,5)")
print("(5,5) -> (10,5) -> (10,10) -> (5,10) -> (5,5)")
print()
print("Controls:")
print(" SPACE - Start animation")
print(" T - Test immediate position change")
print(" R - Reset position to (5,5)")
print(" Q - Quit")
print()
print("The position display updates every 200ms")
print("Watch the console for animation logs")
test_anim.activate()
grid.perspective = None # omniscient view (perspective takes an Entity or None now)
# --- Direct position assignment ---------------------------------------------
test_immediate_position()
# --- Square-path waypoint animation -----------------------------------------
entity.grid_pos = (5, 5)
entity.draw_pos = (5.0, 5.0) # decoupled from grid_pos; reset both
completed.clear()
for i, (wx, wy) in enumerate(waypoints):
start = (entity.draw_pos.x, entity.draw_pos.y)
duration = animate_to_waypoint(i)
# Halfway through: entity must be strictly between start and target on any
# axis that actually changes (proves interpolation, not a snap).
step_seconds(duration / 2)
half = (entity.draw_pos.x, entity.draw_pos.y)
for axis, s, t, h in (("x", start[0], wx, half[0]), ("y", start[1], wy, half[1])):
if s != t:
check(min(s, t) < h < max(s, t),
f"waypoint {i}: draw_{axis} should be interpolating between "
f"{s} and {t} at half-time, got {h}")
# Finish the segment (plus a little slack for the final step)
step_seconds(duration / 2 + 0.2)
end = entity.draw_pos
check(abs(end.x - wx) < 0.01 and abs(end.y - wy) < 0.01,
f"waypoint {i}: expected draw_pos ({wx}, {wy}), got ({end.x}, {end.y})")
pos_display.text = f"Entity Position: ({end.x:.2f}, {end.y:.2f})"
anim_info.text = "Animation: Complete"
# Each waypoint fires two completion callbacks (draw_x and draw_y)
check(len(completed) == 2 * len(waypoints),
f"expected {2 * len(waypoints)} animation callbacks, got {len(completed)}")
# The entity ends where it started -- a closed square
check(abs(entity.draw_pos.x - 5.0) < 0.01 and abs(entity.draw_pos.y - 5.0) < 0.01,
f"square path should end at (5, 5), got {entity.draw_pos}")
# Animating draw_pos does not move the entity's logical cell (#313 contract:
# draw_pos is the visual position, cell_pos/grid_pos is the authoritative cell).
check(tuple(entity.cell_pos) == (5, 5),
f"logical cell should still be (5,5), got {tuple(entity.cell_pos)}")
if failures:
print(f"\n{len(failures)} check(s) failed")
sys.exit(1)
print("\nPASS")
sys.exit(0)

View file

@ -1,14 +1,24 @@
#!/usr/bin/env python3
"""Test the new Entity.path_to() method"""
"""Test the Entity.path_to() method"""
import mcrfpy
import sys
print("Testing Entity.path_to() method...")
print("=" * 50)
failures = []
def check(label, condition, detail=""):
if condition:
print(f" PASS {label}")
else:
print(f" FAIL {label}: {detail}")
failures.append(label)
# Create scene and grid
path_test = mcrfpy.Scene("path_test")
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
grid = mcrfpy.Grid(grid_size=(10, 10))
# Set up a simple map with some walls
for y in range(10):
@ -24,48 +34,76 @@ for x, y in walls:
# Create entity
entity = mcrfpy.Entity((2, 2), grid=grid)
print(f"Entity at: ({entity.x}, {entity.y})")
# NOTE: entity.x/.y are pixel draw coords now; the logical cell is entity.cell_pos.
start = (int(entity.cell_pos.x), int(entity.cell_pos.y))
print(f"Entity at cell: {start}")
check("entity starts at (2, 2)", start == (2, 2), f"got {start}")
# Test 1: Simple path
def validate_path(path, target):
"""A path must be a contiguous, wall-free walk ending on the target."""
if not path:
return "path is empty"
if tuple(path[-1]) != target:
return f"path does not end at {target}: {path[-1]}"
prev = start
for step in path:
step = tuple(step)
if step in walls:
return f"path walks through wall {step}"
dx, dy = abs(step[0] - prev[0]), abs(step[1] - prev[1])
if max(dx, dy) != 1:
return f"non-adjacent step {prev} -> {step}"
prev = step
return None
# Test 1: Simple path (positional x, y)
print("\nTest 1: Path to (6, 6)")
try:
path = entity.path_to(6, 6)
print(f" Path: {path}")
print(f" Length: {len(path)} steps")
print(" ✓ SUCCESS")
except Exception as e:
print(f" ✗ FAILED: {e}")
path = entity.path_to(6, 6)
print(f" Path: {path}")
err = validate_path(path, (6, 6))
check("path_to(6, 6) returns a valid walk to (6, 6)", err is None, err or "")
# Test 2: Path with target_x/target_y keywords
print("\nTest 2: Path using keyword arguments")
try:
path = entity.path_to(target_x=7, target_y=7)
print(f" Path: {path}")
print(f" Length: {len(path)} steps")
print(" ✓ SUCCESS")
except Exception as e:
print(f" ✗ FAILED: {e}")
# Test 2: Path using keyword arguments.
# API CHANGE: the old target_x=/target_y= keywords are gone; the position-spec
# parser now accepts pos=(x, y) (or a bare tuple/Vector).
print("\nTest 2: Path using keyword argument pos=(7, 7)")
path = entity.path_to(pos=(7, 7))
print(f" Path: {path}")
err = validate_path(path, (7, 7))
check("path_to(pos=(7, 7)) returns a valid walk to (7, 7)", err is None, err or "")
# Test 3: Path to unreachable location
# Test 3: Path to current position is empty (already there)
print("\nTest 3: Path to current position")
try:
path = entity.path_to(2, 2)
print(f" Path: {path}")
print(f" Length: {len(path)} steps")
print(" ✓ SUCCESS")
except Exception as e:
print(f" ✗ FAILED: {e}")
path = entity.path_to(2, 2)
print(f" Path: {path}")
check("path_to(own position) is empty", path == [], f"got {path}")
# Test 4: Error cases
print("\nTest 4: Error handling")
try:
# Out of bounds
path = entity.path_to(15, 15)
print(" ✗ Should have failed for out of bounds")
entity.path_to(15, 15)
check("out-of-bounds target raises ValueError", False, "no exception raised")
except ValueError as e:
print(f" ✓ Correctly caught out of bounds: {e}")
check("out-of-bounds target raises ValueError", True)
print(f" ({e})")
except Exception as e:
print(f" ✗ Wrong exception type: {e}")
check("out-of-bounds target raises ValueError", False,
f"wrong exception type {type(e).__name__}: {e}")
# Test 5: Unreachable target -> empty path (wall off a cell completely)
print("\nTest 5: Unreachable target")
for x, y in [(8, 0), (8, 1), (9, 1)]:
grid.at(x, y).walkable = False
path = entity.path_to(9, 0)
print(f" Path: {path}")
check("walled-off target yields empty path", path == [], f"got {path}")
print("\n" + "=" * 50)
print("Entity.path_to() testing complete!")
if failures:
print(f"FAILED: {len(failures)} check(s): {failures}")
sys.exit(1)
print("Entity.path_to() testing complete!")
print("PASS")
sys.exit(0)

View file

@ -2,25 +2,36 @@
"""Test edge cases for Entity.path_to() method"""
import mcrfpy
import sys
failures = []
def check(condition, message):
"""Record a pass/fail instead of just printing it."""
if condition:
print(" [ok] %s" % message)
else:
print(" [FAIL] %s" % message)
failures.append(message)
print("Testing Entity.path_to() edge cases...")
print("=" * 50)
# Test 1: Entity without grid
print("Test 1: Entity not in grid")
entity = mcrfpy.Entity(grid_pos=(5, 5))
try:
entity = mcrfpy.Entity((5, 5))
path = entity.path_to(8, 8)
print(" ✗ Should have failed for entity not in grid")
check(False, "path_to() on a grid-less entity should raise ValueError, got %r" % (path,))
except ValueError as e:
print(f" ✓ Correctly caught no grid error: {e}")
check(True, "correctly raised ValueError with no grid: %s" % e)
except Exception as e:
print(f" ✗ Wrong exception type: {e}")
check(False, "wrong exception type: %s: %s" % (type(e).__name__, e))
# Test 2: Entity in grid with walls blocking path
print("\nTest 2: Completely blocked path")
blocked_test = mcrfpy.Scene("blocked_test")
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
grid = mcrfpy.Grid(grid_size=(5, 5))
# Make all tiles walkable first
for y in range(5):
@ -31,25 +42,28 @@ for y in range(5):
for x in range(5):
grid.at(x, 2).walkable = False
entity = mcrfpy.Entity((1, 1), grid=grid)
entity = mcrfpy.Entity(grid_pos=(1, 1), grid=grid)
try:
path = entity.path_to(1, 4)
if path:
print(f" Path found: {path}")
else:
print(" ✓ No path found (empty list returned)")
except Exception as e:
print(f" ✗ Unexpected error: {e}")
path = entity.path_to(1, 4)
check(path == [], "no path across the full-width wall, got %r" % (path,))
# Test 3: Alternative parameter parsing
# Test 3: Alternative parameter parsing (keyword x/y)
print("\nTest 3: Alternative parameter names")
try:
path = entity.path_to(x=3, y=1)
print(f" Path with x/y params: {path}")
print(" ✓ SUCCESS")
except Exception as e:
print(f" ✗ FAILED: {e}")
path = entity.path_to(x=3, y=1)
check(path == [(2, 1), (3, 1)],
"path_to(x=, y=) walked the open row: %r" % (path,))
# The same target via positional args must agree with the keyword form.
check(entity.path_to(3, 1) == path,
"positional path_to(3, 1) matches the keyword form")
print("\n" + "=" * 50)
print("Edge case testing complete!")
if failures:
print("Edge case testing FAILED (%d):" % len(failures))
for f in failures:
print(" - %s" % f)
sys.exit(1)
print("Edge case testing complete!")
print("PASS")
sys.exit(0)

View file

@ -1,7 +1,19 @@
#!/usr/bin/env python3
"""Reproduce the exact failure from dijkstra_demo_final.py"""
"""Reproduce the exact failure from dijkstra_demo_final.py
Original intent: a bulk per-cell mutation loop (walkable/transparent/color over
every cell of a Grid) appeared to set a Python exception that was NOT raised at
its source, but instead surfaced later on an unrelated line (the `walls.append`
loop). This test drives that exact pattern and asserts that:
1. the pattern completes without raising, and
2. no *stale/pending* exception leaks out onto a later, unrelated statement.
API note: GridPoint no longer has `.color` -- per-cell color now lives on a
ColorLayer (grid.add_layer(mcrfpy.ColorLayer(...)); layer.set((x, y), color)).
"""
import mcrfpy
import sys
print("Reproducing exact failure pattern...")
print("=" * 50)
@ -10,87 +22,123 @@ print("=" * 50)
WALL_COLOR = mcrfpy.Color(60, 30, 30)
FLOOR_COLOR = mcrfpy.Color(200, 200, 220)
GRID_W, GRID_H = 25, 15
failures = []
def test_exact_pattern():
"""Exact code from dijkstra_demo_final.py"""
"""Exact code from dijkstra_demo_final.py (ported to the current API)"""
dijkstra_demo = mcrfpy.Scene("dijkstra_demo")
# Create grid
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H))
grid.fill_color = mcrfpy.Color(0, 0, 0)
# Per-cell color now lives on a ColorLayer, not on GridPoint
color_layer = mcrfpy.ColorLayer(name="floor")
grid.add_layer(color_layer)
# Initialize all as floor
for y in range(15):
for x in range(25):
for y in range(GRID_H):
for x in range(GRID_W):
grid.at(x, y).walkable = True
grid.at(x, y).transparent = True
grid.at(x, y).color = FLOOR_COLOR
color_layer.set((x, y), FLOOR_COLOR)
# Create an interesting dungeon layout
walls = []
# Room walls
# Top-left room
for x in range(1, 8): walls.append((x, 1))
return grid, walls
return grid, color_layer, walls
print("Test 1: Running exact pattern...")
try:
grid, walls = test_exact_pattern()
print(f" ✓ Success! Created {len(walls)} walls")
grid, color_layer, walls = test_exact_pattern()
print(f" OK Success! Created {len(walls)} walls")
if len(walls) != 7:
failures.append(f"expected 7 walls, got {len(walls)}")
except Exception as e:
print(f" ✗ Failed: {type(e).__name__}: {e}")
failures.append(f"exact pattern raised {type(e).__name__}: {e}")
print(f" FAIL Failed: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
print()
print("Test 2: Breaking it down step by step...")
def step(label, fn):
"""Run a step; a raise here is a real failure, not something to swallow."""
try:
result = fn()
print(f" OK {label}")
return result
except Exception as e:
failures.append(f"{label} raised {type(e).__name__}: {e}")
print(f" FAIL {label}: {type(e).__name__}: {e}")
return None
# Step 1: Scene and grid
try:
test2 = mcrfpy.Scene("test2")
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
print(" ✓ Step 1: Scene and grid created")
except Exception as e:
print(f" ✗ Step 1 failed: {e}")
def _step1():
mcrfpy.Scene("test2")
return mcrfpy.Grid(grid_size=(GRID_W, GRID_H))
grid = step("Step 1: Scene and grid created", _step1)
# Step 2: Set fill_color
try:
def _step2():
grid.fill_color = mcrfpy.Color(0, 0, 0)
print(" ✓ Step 2: fill_color set")
except Exception as e:
print(f" ✗ Step 2 failed: {e}")
assert grid.fill_color == mcrfpy.Color(0, 0, 0)
step("Step 2: fill_color set", _step2)
# Step 3: Nested loops with grid.at
try:
for y in range(15):
for x in range(25):
# Step 3: Nested loops with grid.at + ColorLayer.set (the suspect loop)
layer = mcrfpy.ColorLayer(name="floor2")
grid.add_layer(layer)
def _step3():
for y in range(GRID_H):
for x in range(GRID_W):
grid.at(x, y).walkable = True
grid.at(x, y).transparent = True
grid.at(x, y).color = FLOOR_COLOR
print(" ✓ Step 3: Nested loops completed")
except Exception as e:
print(f" ✗ Step 3 failed: {e}")
layer.set((x, y), FLOOR_COLOR)
step("Step 3: Nested loops completed", _step3)
# The mutations must have actually landed (not silently dropped)
if not (grid.at(0, 0).walkable and grid.at(GRID_W - 1, GRID_H - 1).walkable):
failures.append("Step 3: walkable was not persisted by the nested loop")
if not grid.at(GRID_W - 1, GRID_H - 1).transparent:
failures.append("Step 3: transparent was not persisted by the nested loop")
if layer.at((GRID_W - 1, GRID_H - 1)) != FLOOR_COLOR:
failures.append("Step 3: ColorLayer color was not persisted by the nested loop")
# Step 4: Create walls list
try:
walls = []
print(" ✓ Step 4: walls list created")
except Exception as e:
print(f" ✗ Step 4 failed: {e}")
def _step4():
return []
walls = step("Step 4: walls list created", _step4)
# Step 5: The failing line
try:
# Step 5: The line that used to blow up with an exception raised by *step 3*.
# This is the heart of the regression: after the nested loops, plain Python must
# still work. A stale pending error would explode right here.
def _step5():
for x in range(1, 8): walls.append((x, 1))
print(f" ✓ Step 5: For loop worked, walls = {walls}")
except Exception as e:
print(f" ✗ Step 5 failed: {type(e).__name__}: {e}")
# Check if exception was already pending
import sys
exc_info = sys.exc_info()
print(f" Exception info: {exc_info}")
return walls
step("Step 5: For loop worked", _step5)
if walls != [(x, 1) for x in range(1, 8)]:
failures.append(f"Step 5: walls list wrong: {walls}")
# Explicitly confirm no exception is left pending after the nested loops.
if sys.exc_info()[0] is not None:
failures.append(f"stale pending exception after loops: {sys.exc_info()}")
print()
print("The error occurs at step 5, suggesting an exception was")
print("set during the nested loops but not immediately raised.")
if failures:
print("FAIL: no exception may be deferred out of the nested cell-mutation loops")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("The nested cell-mutation loops raise nothing, persist their writes, and")
print("leave no pending exception to surface on a later line.")
print("PASS")
sys.exit(0)

View file

@ -9,10 +9,13 @@ Tests cover:
"""
import sys
import os
import math
import traceback
# Import the geometry module
sys.path.insert(0, '/home/john/Development/McRogueFace/src/scripts')
# Import the geometry module (src/scripts, relative to this test file)
sys.path.insert(0, os.path.join(
os.path.dirname(os.path.abspath(__file__)), '..', '..', 'src', 'scripts'))
from geometry import (
# Utilities
distance, distance_squared, angle_between, normalize_angle,
@ -601,4 +604,13 @@ def run_all_tests():
print("=" * 50)
if __name__ == "__main__":
run_all_tests()
# Exit contract (#350): headless --exec scripts must call sys.exit() explicitly.
# Any failed assertion is a real failure -> report it and exit nonzero.
try:
run_all_tests()
except Exception:
traceback.print_exc()
print("FAIL")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -66,12 +66,16 @@ def test_apply_threshold_out_of_range():
def test_apply_threshold_returns_self():
"""apply_threshold returns self for chaining"""
"""apply_threshold returns the GridData for chaining
Current contract (#313/#361): the apply_* methods live on GridData, and the
Grid view forwards to it. They return the GridData ("self"), not the view.
"""
grid = mcrfpy.Grid(grid_size=(10, 10))
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
result = grid.apply_threshold(hmap, range=(0.0, 1.0), walkable=True)
assert result is grid, "apply_threshold should return self"
assert result is grid.grid_data, "apply_threshold should return the GridData (self)"
print("PASS: test_apply_threshold_returns_self")
@ -159,7 +163,7 @@ def test_apply_ranges_returns_self():
result = grid.apply_ranges(hmap, [
((0.0, 1.0), {"walkable": True}),
])
assert result is grid, "apply_ranges should return self"
assert result is grid.grid_data, "apply_ranges should return the GridData (self)"
print("PASS: test_apply_ranges_returns_self")
@ -275,7 +279,7 @@ def test_chaining():
((0.4, 0.6), {"walkable": True, "transparent": True}),
]))
assert result is grid
assert result is grid.grid_data
print("PASS: test_chaining")

View file

@ -1,27 +1,48 @@
#!/usr/bin/env python3
"""Test Grid background color functionality"""
"""Test Grid background color functionality
API notes (post-#313/#350):
- Grid.background_color was renamed to Grid.fill_color ("Background fill color
(Color). Drawn behind all tiles and entities."). Same property, new name.
- grid.add_layer() takes a layer OBJECT and no keyword arguments; the layer's
ctor takes the kwargs. ColorLayer.set() takes a (x, y) tuple.
- Headless time only advances via mcrfpy.step(); timers never fire on their own.
"""
import mcrfpy
import sys
failures = []
def check(label, actual, expected):
if actual == expected:
print(f"+ {label}: {actual}")
else:
print(f"FAIL {label}: expected {expected}, got {actual}")
failures.append(label)
def rgb(color):
return (color.r, color.g, color.b)
def test_grid_background():
"""Test Grid background color property"""
print("Testing Grid Background Color...")
# Create a test scene
test = mcrfpy.Scene("test")
ui = test.children
# Create a grid with default background
grid = mcrfpy.Grid(pos=(50, 50), size=(400, 300), grid_size=(20, 15))
ui.append(grid)
# Add color layer for some tiles to see the background better
color_layer = grid.add_layer("color", z_index=-1)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.add_layer(color_layer)
for x in range(5, 15):
for y in range(5, 10):
color_layer.set(x, y, mcrfpy.Color(100, 150, 100))
color_layer.set((x, y), mcrfpy.Color(100, 150, 100))
# Add UI to show current background color
info_frame = mcrfpy.Frame(pos=(500, 50), size=(200, 150),
fill_color=mcrfpy.Color(40, 40, 40),
@ -38,85 +59,108 @@ def test_grid_background():
color_display.font_size = 12
color_display.fill_color = mcrfpy.Color(200, 200, 200)
info_frame.children.append(color_display)
# Activate the scene
test.activate()
# Track which timer stages actually ran
stages = []
def run_tests(timer, runtime):
"""Run background color tests"""
timer.stop()
stages.append("default")
print("\nTest 1: Default background color")
default_color = grid.background_color
print(f"Default: R={default_color.r}, G={default_color.g}, B={default_color.b}, A={default_color.a}")
default_color = grid.fill_color
print(f"Default: R={default_color.r}, G={default_color.g}, "
f"B={default_color.b}, A={default_color.a}")
color_display.text = f"R:{default_color.r} G:{default_color.g} B:{default_color.b}"
def test_set_color(timer, runtime):
timer.stop()
print("\nTest 2: Set background to blue")
grid.background_color = mcrfpy.Color(20, 40, 100)
new_color = grid.background_color
print(f"+ Set to: R={new_color.r}, G={new_color.g}, B={new_color.b}")
color_display.text = f"R:{new_color.r} G:{new_color.g} B:{new_color.b}"
def test_animation(timer, runtime):
timer.stop()
print("\nTest 3: Manual color cycling")
# Manually change color to test property is working
colors = [
mcrfpy.Color(200, 20, 20), # Red
mcrfpy.Color(20, 200, 20), # Green
mcrfpy.Color(20, 20, 200), # Blue
]
def test_set_color(timer, runtime):
timer.stop()
stages.append("set")
print("\nTest 2: Set background to blue")
grid.fill_color = mcrfpy.Color(20, 40, 100)
new_color = grid.fill_color
check("set background to (20, 40, 100)", rgb(new_color), (20, 40, 100))
color_display.text = f"R:{new_color.r} G:{new_color.g} B:{new_color.b}"
color_index = [0] # Use list to allow modification in nested function
colors = [
mcrfpy.Color(200, 20, 20), # Red
mcrfpy.Color(20, 200, 20), # Green
mcrfpy.Color(20, 20, 200), # Blue
]
def cycle_red(t, r):
t.stop()
grid.background_color = colors[0]
c = grid.background_color
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
print(f"+ Set to Red: R={c.r}, G={c.g}, B={c.b}")
def test_animation(timer, runtime):
timer.stop()
stages.append("cycle")
print("\nTest 3: Manual color cycling")
def cycle_green(t, r):
t.stop()
grid.background_color = colors[1]
c = grid.background_color
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
print(f"+ Set to Green: R={c.r}, G={c.g}, B={c.b}")
def cycle_red(t, r):
t.stop()
grid.fill_color = colors[0]
c = grid.fill_color
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
check("cycle to Red", rgb(c), (200, 20, 20))
def cycle_blue(t, r):
t.stop()
grid.background_color = colors[2]
c = grid.background_color
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
print(f"+ Set to Blue: R={c.r}, G={c.g}, B={c.b}")
def cycle_green(t, r):
t.stop()
grid.fill_color = colors[1]
c = grid.fill_color
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
check("cycle to Green", rgb(c), (20, 200, 20))
# Cycle through colors
mcrfpy.Timer("cycle_0", cycle_red, 100, once=True)
mcrfpy.Timer("cycle_1", cycle_green, 400, once=True)
mcrfpy.Timer("cycle_2", cycle_blue, 700, once=True)
def cycle_blue(t, r):
t.stop()
grid.fill_color = colors[2]
c = grid.fill_color
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
check("cycle to Blue", rgb(c), (20, 20, 200))
def test_complete(timer, runtime):
timer.stop()
print("\nTest 4: Final color check")
final_color = grid.background_color
print(f"Final: R={final_color.r}, G={final_color.g}, B={final_color.b}")
# Cycle through colors
mcrfpy.Timer("cycle_0", cycle_red, 100, once=True)
mcrfpy.Timer("cycle_1", cycle_green, 400, once=True)
mcrfpy.Timer("cycle_2", cycle_blue, 700, once=True)
print("\n+ Grid background color tests completed!")
print("- Default background color works")
print("- Setting background color works")
print("- Color cycling works")
def test_complete(timer, runtime):
timer.stop()
stages.append("complete")
print("\nTest 4: Final color check")
final_color = grid.fill_color
# The last color set by the cycle stage must have stuck.
check("final color is Blue", rgb(final_color), (20, 20, 200))
sys.exit(0)
# Schedule tests
mcrfpy.Timer("test_set", test_set_color, 1000, once=True)
mcrfpy.Timer("test_anim", test_animation, 2000, once=True)
mcrfpy.Timer("complete", test_complete, 4500, once=True)
# Start tests
# Schedule tests
mcrfpy.Timer("run_tests", run_tests, 100, once=True)
mcrfpy.Timer("test_set", test_set_color, 1000, once=True)
mcrfpy.Timer("test_anim", test_animation, 2000, once=True)
mcrfpy.Timer("complete", test_complete, 4500, once=True)
# Headless: mcrfpy.step() is the only clock. 5 simulated seconds at 50ms/step,
# which covers the 4500ms 'complete' timer with margin.
for _ in range(140):
mcrfpy.step(0.05)
# Force a render so the grid (with its fill_color) actually goes through the
# render path -- the property is a render-time input, not just a stored value.
mcrfpy.automation.screenshot("test_grid_background.png")
for stage in ("default", "set", "cycle", "complete"):
if stage not in stages:
print(f"FAIL: timer stage '{stage}' never ran")
failures.append(f"stage:{stage}")
if failures:
print(f"\nFAIL: {len(failures)} check(s) failed: {failures}")
sys.exit(1)
print("\n+ Grid background color tests completed!")
print("- Default background color works")
print("- Setting background color works")
print("- Color cycling works")
print("PASS")
sys.exit(0)
if __name__ == "__main__":
test_grid_background()
test_grid_background()

View file

@ -1,124 +1,110 @@
#!/usr/bin/env python3
"""Test Grid constructor to isolate the PyArg bug"""
"""Test Grid constructor to isolate the PyArg bug
Original intent: the Grid constructor used to set a Python exception without
returning NULL, leaving a stale exception on the interpreter stack. The next
Python operation (e.g. range(1)) then blew up with a cryptic
"new style getargs format" error. This test constructs Grids of many sizes and
verifies that (a) construction succeeds, (b) the resulting Grid is actually the
size that was asked for, and (c) no stale exception is left behind.
API notes (current, post-#313/#361): the Grid constructor takes
grid_size=(w, h); grid_w=/grid_h= are the legacy spelling and still work.
"""
import mcrfpy
import sys
failures = []
def check(cond, msg):
if cond:
print(f" PASS: {msg}")
else:
print(f" FAIL: {msg}")
failures.append(msg)
def no_stale_exception(label):
"""Any pending-but-unraised exception trips the next Python operation."""
try:
list(range(1))
except Exception as e:
check(False, f"{label}: stale exception after Grid creation: {type(e).__name__}: {e}")
return False
check(True, f"{label}: no stale exception after Grid creation")
return True
print("Testing Grid constructor PyArg bug...")
print("=" * 50)
# Test 1: Check if exception is set after Grid creation
# Test 1: Check exception state after Grid creation
print("Test 1: Check exception state after Grid creation")
try:
# Clear any existing exception
sys.exc_clear() if hasattr(sys, 'exc_clear') else None
# Create grid with problematic dimensions
print(" Creating Grid(grid_w=25, grid_h=15)...")
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
print(" Grid created successfully")
# Check if there's a pending exception
exc = sys.exc_info()
if exc[0] is not None:
print(f" ⚠️ Pending exception detected: {exc}")
# Try to trigger the error
print(" Calling range(1)...")
for i in range(1):
pass
print(" ✓ range(1) worked")
print(" Creating Grid(grid_size=(25, 15))...")
grid = mcrfpy.Grid(grid_size=(25, 15))
check(True, "Grid(grid_size=(25, 15)) constructed")
check((grid.grid_size.x, grid.grid_size.y) == (25, 15),
f"grid_size is (25, 15), got ({grid.grid_size.x}, {grid.grid_size.y})")
no_stale_exception("Test 1")
except Exception as e:
print(f" ✗ Exception: {type(e).__name__}: {e}")
check(False, f"Grid(grid_size=(25, 15)) raised {type(e).__name__}: {e}")
print()
# Test 2: Try different Grid constructor patterns
print("Test 2: Different Grid constructor calls")
# Pattern 1: Positional arguments
try:
print(" Trying Grid(25, 15)...")
grid1 = mcrfpy.Grid(25, 15)
for i in range(1): pass
print(" ✓ Positional args worked")
except Exception as e:
print(f" ✗ Positional args failed: {e}")
# Pattern 2: Different size
try:
print(" Trying Grid(grid_w=24, grid_h=15)...")
grid2 = mcrfpy.Grid(grid_w=24, grid_h=15)
for i in range(1): pass
print(" ✓ Size 24x15 worked")
except Exception as e:
print(f" ✗ Size 24x15 failed: {e}")
# Pattern 3: Check if it's specifically 25
try:
print(" Trying Grid(grid_w=26, grid_h=15)...")
grid3 = mcrfpy.Grid(grid_w=26, grid_h=15)
for i in range(1): pass
print(" ✓ Size 26x15 worked")
except Exception as e:
print(f" ✗ Size 26x15 failed: {e}")
# Test 2: legacy grid_w/grid_h spelling still constructs the same grid
print("Test 2: Legacy grid_w=/grid_h= keywords")
for w, h in [(24, 15), (25, 15), (26, 15)]:
try:
g = mcrfpy.Grid(grid_w=w, grid_h=h)
ok = (g.grid_size.x, g.grid_size.y) == (w, h)
check(ok, f"Grid(grid_w={w}, grid_h={h}) -> grid_size ({g.grid_size.x}, {g.grid_size.y})")
no_stale_exception(f"Grid({w}, {h})")
except Exception as e:
check(False, f"Grid(grid_w={w}, grid_h={h}) raised {type(e).__name__}: {e}")
print()
# Test 3: Isolate the exact problem
print("Test 3: Isolating the problem")
# Test 3: Isolate the exact problem -- a sweep of sizes, each followed by a
# plain Python operation that would explode if an exception were left pending.
print("Test 3: Size sweep (construct, then immediately use the interpreter)")
def test_grid_creation(x, y):
"""Test creating a grid and immediately using range()"""
try:
grid = mcrfpy.Grid(grid_w=x, grid_h=y)
# Immediately test if exception is pending
g = mcrfpy.Grid(grid_size=(x, y))
# Immediately test if an exception is pending
list(range(1))
if (g.grid_size.x, g.grid_size.y) != (x, y):
return False, f"wrong grid_size ({g.grid_size.x}, {g.grid_size.y})"
return True, "Success"
except Exception as e:
return False, f"{type(e).__name__}: {e}"
# Test various sizes
test_sizes = [(10, 10), (20, 20), (24, 15), (25, 14), (25, 15), (25, 16), (30, 30)]
for x, y in test_sizes:
success, msg = test_grid_creation(x, y)
if success:
print(f" Grid({x}, {y}): ✓")
else:
print(f" Grid({x}, {y}): ✗ {msg}")
check(success, f"Grid({x}, {y}): {msg}")
print()
# Test 4: See if we can clear the exception
print("Test 4: Exception clearing")
# Test 4: bad arguments must raise properly (and not corrupt interpreter state)
print("Test 4: Invalid arguments raise instead of leaving a pending exception")
try:
# Create the problematic grid
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
print(" Created Grid(25, 15)")
# Try to clear any pending exception
try:
# This should fail if there's a pending exception
list(range(1))
print(" No pending exception!")
except:
print(" ⚠️ Pending exception detected")
# Clear it
sys.exc_clear() if hasattr(sys, 'exc_clear') else None
# Try again
try:
list(range(1))
print(" ✓ Exception cleared, range() works now")
except:
print(" ✗ Exception persists")
bad = mcrfpy.Grid(grid_size="not a size")
check(False, "Grid(grid_size='not a size') should have raised")
except (TypeError, ValueError) as e:
check(True, f"Grid(grid_size='not a size') raised {type(e).__name__} as expected")
except Exception as e:
print(f" ✗ Failed: {e}")
check(False, f"Grid(grid_size='not a size') raised unexpected {type(e).__name__}: {e}")
no_stale_exception("Test 4")
print()
print("Conclusion: The Grid constructor is setting a Python exception")
print("but not properly returning NULL to propagate it. This leaves")
print("the exception on the stack, causing the next Python operation")
print("to fail with the cryptic 'new style getargs format' error.")
print("=" * 50)
if failures:
print(f"FAIL: {len(failures)} check(s) failed")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -7,7 +7,7 @@ print("Testing Grid features...")
# Create a texture first
print("Loading texture...")
texture = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
print(f"Texture loaded: {texture}")
# Create grid
@ -54,5 +54,9 @@ if not hasattr(pos, 'x'):
print(f"pos.x={pos.x}, pos.y={pos.y}")
if pos.x != 50 or pos.y != 100:
print(f"FAIL: pos should be (50, 100), got ({pos.x}, {pos.y})")
sys.exit(1)
print("PASS: Grid Vector properties work correctly!")
sys.exit(0)

View file

@ -2,6 +2,17 @@
"""Test grid iteration patterns to find the exact cause"""
import mcrfpy
import sys
failures = []
def check(condition, label):
"""Record a failed check instead of silently printing a checkmark."""
if condition:
print(f" [ok] {label}")
else:
print(f" [FAIL] {label}")
failures.append(label)
print("Testing grid iteration patterns...")
print("=" * 50)
@ -11,21 +22,23 @@ print("Test 1: Basic grid.at() calls")
try:
test1 = mcrfpy.Scene("test1")
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
# Single call
grid.at(0, 0).walkable = True
print("Single grid.at() call works")
check(grid.at(0, 0).walkable is True, "Single grid.at() call works")
# Multiple calls
grid.at(1, 1).walkable = True
grid.at(2, 2).walkable = True
print(" ✓ Multiple grid.at() calls work")
check(grid.at(1, 1).walkable and grid.at(2, 2).walkable,
"Multiple grid.at() calls work")
# Now try a print
print(" Print after grid.at() works")
print(" [ok] Print after grid.at() works")
except Exception as e:
print(f" ✗ Error: {type(e).__name__}: {e}")
print(f" [FAIL] Error: {type(e).__name__}: {e}")
failures.append(f"Test 1: {type(e).__name__}: {e}")
print()
@ -34,16 +47,18 @@ print("Test 2: Grid.at() in simple loop")
try:
test2 = mcrfpy.Scene("test2")
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
for i in range(3):
grid.at(i, 0).walkable = True
print(" ✓ Single loop with grid.at() works")
check(all(grid.at(i, 0).walkable for i in range(3)),
"Single loop with grid.at() works")
# Print after loop
print(" Print after loop works")
print(" [ok] Print after loop works")
except Exception as e:
print(f" ✗ Error: {type(e).__name__}: {e}")
print(f" [FAIL] Error: {type(e).__name__}: {e}")
failures.append(f"Test 2: {type(e).__name__}: {e}")
print()
@ -52,16 +67,18 @@ print("Test 3: Nested loops with grid.at()")
try:
test3 = mcrfpy.Scene("test3")
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
for y in range(3):
for x in range(3):
grid.at(x, y).walkable = True
print(" ✓ Nested loops with grid.at() work")
print(" ✓ Print after nested loops works")
check(all(grid.at(x, y).walkable for y in range(3) for x in range(3)),
"Nested loops with grid.at() work")
print(" [ok] Print after nested loops works")
except Exception as e:
print(f" ✗ Error: {type(e).__name__}: {e}")
print(f" [FAIL] Error: {type(e).__name__}: {e}")
failures.append(f"Test 3: {type(e).__name__}: {e}")
print()
@ -71,38 +88,46 @@ try:
test4 = mcrfpy.Scene("test4")
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
grid.fill_color = mcrfpy.Color(0, 0, 0)
# Per-cell color moved off GridPoint onto ColorLayer (Grid layer rework);
# GridPoint now exposes only entities/grid_pos/transparent/walkable.
colors = mcrfpy.ColorLayer(name="cell_color")
grid.add_layer(colors)
# This is the exact nested loop from the failing code
for y in range(15):
for x in range(25):
grid.at(x, y).walkable = True
grid.at(x, y).transparent = True
grid.at(x, y).color = mcrfpy.Color(200, 200, 220)
print(" ✓ Full nested loop completed")
# This is where it fails
colors.set((x, y), mcrfpy.Color(200, 200, 220))
check(all(grid.at(x, y).walkable and grid.at(x, y).transparent
for y in range(15) for x in range(25)),
"Full nested loop completed (375 cells set)")
# This is where it used to fail
print(" About to test post-loop operations...")
# Try different operations
x = 5
print(f"Variable assignment works: x={x}")
check(x == 5, f"Variable assignment works: x={x}")
lst = []
print(f"List creation works: {lst}")
check(lst == [], f"List creation works: {lst}")
# The failing line
for i in range(3): pass
print(" Empty for loop works")
print(" [ok] Empty for loop works")
# With append
for i in range(3): lst.append(i)
print(f"For loop with append works: {lst}")
check(lst == [0, 1, 2], f"For loop with append works: {lst}")
except Exception as e:
print(f" Error: {type(e).__name__}: {e}")
print(f" [FAIL] Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
failures.append(f"Test 4: {type(e).__name__}: {e}")
print()
@ -111,28 +136,40 @@ print("Test 5: Testing grid.at() call limits")
try:
test5 = mcrfpy.Scene("test5")
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
count = 0
for y in range(10):
for x in range(10):
grid.at(x, y).walkable = True
count += 1
# Test print every 10 calls
if count % 10 == 0:
print(f" Processed {count} cells...")
print(f" ✓ Processed all {count} cells")
check(count == 100, f"Processed all {count} cells")
check(all(grid.at(x, y).walkable for y in range(10) for x in range(10)),
"All 100 cells still readable after the loop")
# Now test operations
print(" Testing post-processing operations...")
for i in range(3): pass
print(" All operations work after 100 grid.at() calls")
print(" [ok] All operations work after 100 grid.at() calls")
except Exception as e:
print(f" Error: {type(e).__name__}: {e}")
print(f" [FAIL] Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
failures.append(f"Test 5: {type(e).__name__}: {e}")
print()
print("Tests complete.")
print("Tests complete.")
if failures:
print(f"FAIL: {len(failures)} check(s) failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -24,7 +24,7 @@ def on_click_handler(pos, button, action):
frame.on_click = on_click_handler
# Click inside the frame
automation.click(150, 150)
automation.click((150, 150))
mcrfpy.step(0.05)
if len(start_clicks) >= 1:
@ -50,7 +50,7 @@ def on_miss_handler(pos, button, action):
frame2.on_click = on_miss_handler
# Click outside the frame
automation.click(50, 50)
automation.click((50, 50))
mcrfpy.step(0.05)
if len(miss_clicks) > 0:
@ -58,7 +58,7 @@ if len(miss_clicks) > 0:
# Test 3: Position tracking
print("Testing position tracking...")
automation.moveTo(123, 456)
automation.moveTo((123, 456))
pos = automation.position()
if pos[0] != 123 or pos[1] != 456:
errors.append(f"Position tracking: expected (123,456), got {pos}")

View file

@ -1,18 +1,33 @@
#!/usr/bin/env python3
"""Test script to verify the profiling metrics system"""
"""Test script to verify the profiling metrics system
Updated for the #350 headless model: mcrfpy.step(dt) is the only clock (timers never
fire on their own), and step() never renders -- render counters (draw_calls,
ui_elements, visible_elements) are only published by an actual render pass, which we
force with automation.screenshot().
"""
import mcrfpy
from mcrfpy import automation
import sys
import time
import os
import tempfile
# Track success across callbacks
success = True
done = False
SHOT = os.path.join(tempfile.gettempdir(), "test_metrics.png")
def test_metrics(timer, runtime):
"""Test the metrics after timer starts"""
"""Test the metrics after the timer fires"""
global success
print("\nRunning metrics test...")
# step() never renders, so force a render pass to publish the render counters.
automation.screenshot(SHOT)
# Get metrics
metrics = mcrfpy.get_metrics()
@ -43,14 +58,21 @@ def test_metrics(timer, runtime):
else:
print(f" PASS: FPS {metrics['fps']} is reasonable")
# UI elements count (may be 0 if scene hasn't rendered yet)
if metrics['ui_elements'] < 0:
print(f" FAIL: UI elements count {metrics['ui_elements']} is negative")
# UI elements count -- a render happened, so the scene's drawables were counted
if metrics['ui_elements'] <= 0:
print(f" FAIL: UI elements count {metrics['ui_elements']} should be positive after a render")
success = False
else:
print(f" PASS: UI element count {metrics['ui_elements']} is valid")
# Visible elements should be <= total elements
# Draw calls should be positive after a render
if metrics['draw_calls'] <= 0:
print(f" FAIL: Draw calls {metrics['draw_calls']} should be positive after a render")
success = False
else:
print(f" PASS: Draw call count {metrics['draw_calls']} is valid")
# Visible elements should be <= total elements (one frame is invisible)
if metrics['visible_elements'] > metrics['ui_elements']:
print(" FAIL: Visible elements > total elements")
success = False
@ -78,9 +100,9 @@ def test_metrics(timer, runtime):
initial_frame = metrics['current_frame']
initial_runtime = metrics['runtime']
# Schedule another check after 100ms
# Schedule another check after 100ms of simulated time
def check_later(timer2, runtime2):
global success
global success, done
metrics2 = mcrfpy.get_metrics()
print(f"\nMetrics after 100ms:")
@ -103,16 +125,11 @@ def test_metrics(timer, runtime):
print(" FAIL: Runtime did not increase")
success = False
print("\n" + "="*50)
if success:
print("ALL METRICS TESTS PASSED!")
else:
print("SOME METRICS TESTS FAILED!")
sys.exit(0 if success else 1)
done = True
mcrfpy.Timer("check_later", check_later, 100, once=True)
# Set up test scene
print("Setting up metrics test scene...")
metrics_test = mcrfpy.Scene("metrics_test")
@ -137,10 +154,33 @@ frame2 = mcrfpy.Frame(pos=(300, 10), size=(100, 100))
frame2.visible = False
ui.append(frame2)
grid = mcrfpy.Grid(10, 10, mcrfpy.default_texture, (10, 200), (200, 200))
# Grid ctor is keyword-based now; grid_size= describes a new GridData.
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(10, 200), size=(200, 200))
ui.append(grid)
print(f"Created {len(ui)} UI elements (1 invisible)")
# Schedule test to run after render loop starts
# Schedule test to run after 50ms of simulated time
mcrfpy.Timer("test", test_metrics, 50, once=True)
# step() is the clock in headless mode: drive time until both timers have fired.
for _ in range(200):
if done:
break
mcrfpy.step(0.016)
if os.path.exists(SHOT):
os.remove(SHOT)
print("\n" + "=" * 50)
if not done:
print("FAIL: timer callbacks never completed")
success = False
if success:
print("ALL METRICS TESTS PASSED!")
print("PASS")
sys.exit(0)
else:
print("SOME METRICS TESTS FAILED!")
sys.exit(1)

View file

@ -2,47 +2,67 @@
"""Test the name parameter in constructors"""
import mcrfpy
import sys
# Test Frame with name parameter
try:
frame1 = mcrfpy.Frame(name="test_frame")
print(f"✓ Frame with name: {frame1.name}")
except Exception as e:
print(f"✗ Frame with name failed: {e}")
failures = []
# Test Grid with name parameter
try:
grid1 = mcrfpy.Grid(name="test_grid")
print(f"✓ Grid with name: {grid1.name}")
except Exception as e:
print(f"✗ Grid with name failed: {e}")
def check(label, fn, expected_name):
"""Construct an object, verify the name parameter round-trips."""
try:
obj = fn()
except Exception as e:
failures.append(f"{label}: construction failed: {e!r}")
print(f"FAIL {label}: construction failed: {e!r}")
return None
if obj.name != expected_name:
failures.append(f"{label}: name is {obj.name!r}, expected {expected_name!r}")
print(f"FAIL {label}: name is {obj.name!r}, expected {expected_name!r}")
return obj
print(f"OK {label}: name={obj.name!r}")
return obj
# Test Sprite with name parameter
try:
sprite1 = mcrfpy.Sprite(name="test_sprite")
print(f"✓ Sprite with name: {sprite1.name}")
except Exception as e:
print(f"✗ Sprite with name failed: {e}")
# name= keyword accepted by every drawable/entity constructor
check("Frame", lambda: mcrfpy.Frame(name="test_frame"), "test_frame")
check("Grid", lambda: mcrfpy.Grid(name="test_grid"), "test_grid")
check("Sprite", lambda: mcrfpy.Sprite(name="test_sprite"), "test_sprite")
check("Caption", lambda: mcrfpy.Caption(name="test_caption"), "test_caption")
check("Entity", lambda: mcrfpy.Entity(name="test_entity"), "test_entity")
# Test Caption with name parameter
try:
caption1 = mcrfpy.Caption(name="test_caption")
print(f"✓ Caption with name: {caption1.name}")
except Exception as e:
print(f"✗ Caption with name failed: {e}")
# Mixed positional args and name= keyword: the positional args must not be
# disturbed by the trailing keyword.
frame2 = check(
"Frame(positional + name)",
lambda: mcrfpy.Frame((10, 10), (100, 100), name="positioned_frame"),
"positioned_frame",
)
if frame2 is not None:
geometry = (frame2.x, frame2.y, frame2.w, frame2.h)
if geometry != (10.0, 10.0, 100.0, 100.0):
failures.append(f"Frame(positional + name): geometry is {geometry}, expected (10.0, 10.0, 100.0, 100.0)")
print(f"FAIL Frame(positional + name): geometry is {geometry}")
else:
print(f"OK Frame(positional + name): pos=({frame2.x}, {frame2.y}), size=({frame2.w}, {frame2.h})")
# Test Entity with name parameter
try:
entity1 = mcrfpy.Entity(name="test_entity")
print(f"✓ Entity with name: {entity1.name}")
except Exception as e:
print(f"✗ Entity with name failed: {e}")
# A name given at construction must be findable by mcrfpy.find() once the
# object is in a scene -- that is what the name parameter is FOR.
scene = mcrfpy.Scene("name_param_test")
named = mcrfpy.Frame(pos=(5, 5), size=(20, 20), name="findable_frame")
scene.children.append(named)
found = mcrfpy.find("findable_frame", "name_param_test")
if found is None:
failures.append("find(): could not find 'findable_frame' by its constructor-assigned name")
print("FAIL find(): could not find 'findable_frame' by its constructor-assigned name")
elif found is not named:
failures.append(f"find(): returned {found!r}, expected the same object appended to the scene")
print(f"FAIL find(): returned {found!r}, not the original object")
else:
print("OK find(): constructor-assigned name is findable in the scene")
# Test with mixed positional and name
try:
frame2 = mcrfpy.Frame((10, 10), (100, 100), name="positioned_frame")
print(f"✓ Frame with positional args and name: pos=({frame2.x}, {frame2.y}), size=({frame2.w}, {frame2.h}), name={frame2.name}")
except Exception as e:
print(f"✗ Frame with positional and name failed: {e}")
if failures:
print(f"\nFAIL: {len(failures)} name parameter check(s) failed")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("\n✅ All name parameter tests complete!")
print("\nPASS: all name parameter tests complete")
sys.exit(0)

View file

@ -1,20 +1,28 @@
#!/usr/bin/env python3
"""Test single-line for loops which seem to be the issue"""
import sys
import traceback
import mcrfpy
print("Testing single-line for loops...")
print("=" * 50)
failures = []
EXPECTED_WALLS = [(x, 1) for x in range(1, 8)]
# Test 1: Simple single-line for
print("Test 1: Simple single-line for")
try:
result = []
for x in range(3): result.append(x)
assert result == [0, 1, 2], result
print(f" ✓ Success: {result}")
except Exception as e:
failures.append(f"Test 1: {type(e).__name__}: {e}")
print(f" ✗ Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
print()
@ -24,10 +32,11 @@ print("Test 2: Single-line with tuple append")
try:
walls = []
for x in range(1, 8): walls.append((x, 1))
assert walls == EXPECTED_WALLS, walls
print(f" ✓ Success: {walls}")
except Exception as e:
failures.append(f"Test 2: {type(e).__name__}: {e}")
print(f" ✗ Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
print()
@ -38,9 +47,12 @@ try:
walls = []
for x in range(1, 8):
walls.append((x, 1))
assert walls == EXPECTED_WALLS, walls
print(f" ✓ Success: {walls}")
except Exception as e:
failures.append(f"Test 3: {type(e).__name__}: {e}")
print(f" ✗ Error: {type(e).__name__}: {e}")
traceback.print_exc()
print()
@ -48,14 +60,15 @@ print()
print("Test 4: After creating mcrfpy scene/grid")
try:
test = mcrfpy.Scene("test")
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
grid = mcrfpy.Grid(grid_size=(10, 10))
walls = []
for x in range(1, 8): walls.append((x, 1))
assert walls == EXPECTED_WALLS, walls
print(f" ✓ Success with mcrfpy objects: {walls}")
except Exception as e:
failures.append(f"Test 4: {type(e).__name__}: {e}")
print(f" ✗ Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
print()
@ -64,34 +77,49 @@ print()
print("Test 5: Checking exact error location")
def test_exact_pattern():
dijkstra_demo = mcrfpy.Scene("dijkstra_demo")
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
grid = mcrfpy.Grid(grid_size=(25, 15))
grid.fill_color = mcrfpy.Color(0, 0, 0)
# Per-cell color now lives on a ColorLayer; GridPoint has no .color
colors = mcrfpy.ColorLayer(name="bg")
grid.add_layer(colors)
# Initialize all as floor
for y in range(15):
for x in range(25):
grid.at(x, y).walkable = True
grid.at(x, y).transparent = True
grid.at(x, y).color = mcrfpy.Color(200, 200, 220)
colors.set((x, y), mcrfpy.Color(200, 200, 220))
# Create an interesting dungeon layout
walls = []
# Room walls
# Top-left room
print(" About to execute problem line...")
for x in range(1, 8): walls.append((x, 1)) # Line 40 in original
print(" ✓ Got past the problem line!")
return grid, walls
try:
grid, walls = test_exact_pattern()
assert walls == EXPECTED_WALLS, walls
assert grid.at(0, 0).walkable is True
assert grid.at(24, 14).transparent is True
print(f" Result: Created grid and {len(walls)} walls")
except Exception as e:
failures.append(f"Test 5: {type(e).__name__}: {e}")
print(f" ✗ Error: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
print()
print("Tests complete.")
if failures:
print("Tests complete: FAIL")
for f in failures:
print(f" {f}")
sys.exit(1)
print("Tests complete.")
print("PASS")
sys.exit(0)

View file

@ -7,18 +7,28 @@ import sys
print("Testing path color changes...")
print("=" * 50)
failures = []
def check(cond, msg):
if not cond:
failures.append(msg)
print(f" FAIL: {msg}")
# Create scene and small grid
test = mcrfpy.Scene("test")
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(50, 50), size=(250, 250))
# Add color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# Add color layer for cell coloring (layers are objects now; add_layer takes no kwargs)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.add_layer(color_layer)
BASE_COLOR = mcrfpy.Color(200, 200, 200) # Light gray
# Initialize
for y in range(5):
for x in range(5):
grid.at(x, y).walkable = True
color_layer.set(x, y, mcrfpy.Color(200, 200, 200)) # Light gray
color_layer.set((x, y), BASE_COLOR)
# Add entities
e1 = mcrfpy.Entity((0, 0), grid=grid)
@ -26,12 +36,15 @@ e2 = mcrfpy.Entity((4, 4), grid=grid)
e1.sprite_index = 64
e2.sprite_index = 69
print(f"Entity 1 at ({e1.x}, {e1.y})")
print(f"Entity 2 at ({e2.x}, {e2.y})")
# .x/.y are PIXEL coords now; logical cell coords live on .cell_pos
print(f"Entity 1 at ({e1.cell_pos.x}, {e1.cell_pos.y})")
print(f"Entity 2 at ({e2.cell_pos.x}, {e2.cell_pos.y})")
# Get path
path = e1.path_to(int(e2.x), int(e2.y))
path = e1.path_to(int(e2.cell_pos.x), int(e2.cell_pos.y))
print(f"\nPath: {path}")
check(len(path) > 0, "path_to() returned an empty path on a fully walkable grid")
check(path[-1] == (4, 4), f"path does not end at the target: {path[-1] if path else None}")
# Try to color the path
PATH_COLOR = mcrfpy.Color(100, 255, 100) # Green
@ -41,46 +54,63 @@ for x, y in path:
# Check before
before_c = color_layer.at(x, y)
before = (before_c.r, before_c.g, before_c.b)
check(before == (200, 200, 200), f"cell ({x},{y}) was not the initialized gray: {before}")
# Set color
color_layer.set(x, y, PATH_COLOR)
color_layer.set((x, y), PATH_COLOR)
# Check after
after_c = color_layer.at(x, y)
after = (after_c.r, after_c.g, after_c.b)
check(after == (100, 255, 100), f"cell ({x},{y}) did not take the path color: {after}")
print(f" Cell ({x},{y}): {before} -> {after}")
# Verify all path cells
# Verify all path cells (and that non-path cells were NOT touched)
print("\nVerifying all cells in grid:")
for y in range(5):
for x in range(5):
c = color_layer.at(x, y)
color = (c.r, c.g, c.b)
is_path = (x, y) in path
expected = (100, 255, 100) if is_path else (200, 200, 200)
check(color == expected,
f"cell ({x},{y}) in_path={is_path} expected {expected}, got {color}")
print(f" ({x},{y}): color={color}, in_path={is_path}")
print("\nIf colors are changing in data but not visually, it may be a rendering issue.")
# Quick visual test
# Quick visual test: colors must survive a real render, not just live in the data.
def check_visual(timer, runtime):
print("\nTimer fired - checking if scene is rendering...")
# Take screenshot to see actual rendering
try:
from mcrfpy import automation
automation.screenshot("path_color_test.png")
print("Screenshot saved as path_color_test.png")
except:
print("Could not take screenshot")
sys.exit(0)
from mcrfpy import automation
automation.screenshot("path_color_test.png")
print("Screenshot saved as path_color_test.png")
# Re-read after render: the render pass must not clobber the layer data
for x, y in path:
c = color_layer.at(x, y)
check((c.r, c.g, c.b) == (100, 255, 100),
f"cell ({x},{y}) lost its color after rendering: {(c.r, c.g, c.b)}")
timer_fired.append(True)
# Set up minimal UI to test rendering
ui = test.children
ui.append(grid)
grid.pos = (50, 50)
grid.size = (250, 250)
test.activate()
timer_fired = []
check_timer = mcrfpy.Timer("check", check_visual, 500, once=True)
print("\nStarting render test...")
print("\nStarting render test...")
# Headless: mcrfpy.step() is the only clock. Drive it until the timer fires.
for _ in range(20):
mcrfpy.step(0.05)
if timer_fired:
break
check(bool(timer_fired), "Timer never fired after stepping 1.0s of simulated time")
print("=" * 50)
if failures:
print(f"FAIL ({len(failures)} check(s) failed)")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,73 +1,121 @@
#!/usr/bin/env python3
"""Test to confirm the PyArg bug in Grid constructor"""
"""Regression test for the PyArg bug in the Grid constructor.
Original bug theory: UIGrid::init called PyArg_ParseTupleAndKeywords without
checking its return value, so a failed parse left an exception pending on the
stack. The grid was built anyway (with defaults), and the stale exception then
surfaced at some unrelated later call.
This test pins the constructor's argument parsing down for real:
- every accepted spelling of "make a 25x15 grid" produces a 25x15 grid,
- no successful construction leaves a pending exception behind,
- every rejected spelling raises a clean exception AND leaves the interpreter
usable (no residue that poisons the next call).
API note (#361): the Grid constructor's POSITIONAL order is
(pos, size, grid_size, texture) -- not (grid_w, grid_h). Grid dimensions are
given by grid_size=(w, h) or by the grid_w=/grid_h= keywords. The old
`Grid(25, 15)` positional spelling now means pos=25, size=15 and is not a way
to size a grid; that check is updated to the current contract below.
"""
import sys
import mcrfpy
print("Testing PyArg bug hypothesis...")
failures = []
def check(condition, label):
if condition:
print(f" ok {label}")
else:
print(f" FAIL {label}")
failures.append(label)
def dims(grid):
return (int(grid.grid_size.x), int(grid.grid_size.y))
def no_pending_exception(label):
"""A pending-but-unreturned C exception surfaces at the next Python call."""
try:
_ = list(range(1))
except BaseException as e: # pragma: no cover - this is the bug being tested
check(False, f"{label}: stale exception leaked: {type(e).__name__}: {e}")
return
check(sys.exc_info() == (None, None, None), f"{label}: no exception pending")
print("Testing Grid constructor argument parsing...")
print("=" * 50)
# The bug theory: When Grid is created with keyword args grid_w=25, grid_h=15
# and the code takes the tuple parsing path, PyArg_ParseTupleAndKeywords
# at line 520 fails but doesn't check return value, leaving exception on stack
# Test 1: Create Grid with different argument patterns
print("Test 1: Grid with positional args")
try:
grid1 = mcrfpy.Grid(25, 15)
# Force Python to check for pending exceptions
_ = list(range(1))
print(" ✓ Grid(25, 15) works")
except Exception as e:
print(f" ✗ Grid(25, 15) failed: {type(e).__name__}: {e}")
print("Test 1: grid_size tuple")
grid1 = mcrfpy.Grid(grid_size=(25, 15))
check(dims(grid1) == (25, 15), f"Grid(grid_size=(25,15)) -> {dims(grid1)}")
no_pending_exception("Grid(grid_size=(25,15))")
print()
print("Test 2: Grid with keyword args (the failing case)")
try:
grid2 = mcrfpy.Grid(grid_w=25, grid_h=15)
# This should fail if exception is pending
_ = list(range(1))
print(" ✓ Grid(grid_w=25, grid_h=15) works")
except Exception as e:
print(f" ✗ Grid(grid_w=25, grid_h=15) failed: {type(e).__name__}: {e}")
print("Test 2: grid_w/grid_h keyword args (the historically failing case)")
grid2 = mcrfpy.Grid(grid_w=25, grid_h=15)
check(dims(grid2) == (25, 15), f"Grid(grid_w=25, grid_h=15) -> {dims(grid2)}")
no_pending_exception("Grid(grid_w=25, grid_h=15)")
print()
print("Test 3: Check if it's specific to the values 25, 15")
for x, y in [(24, 15), (25, 14), (25, 15), (26, 15), (25, 16)]:
try:
grid = mcrfpy.Grid(grid_w=x, grid_h=y)
_ = list(range(1))
print(f" ✓ Grid(grid_w={x}, grid_h={y}) works")
except Exception as e:
print(f" ✗ Grid(grid_w={x}, grid_h={y}) failed: {type(e).__name__}")
grid = mcrfpy.Grid(grid_w=x, grid_h=y)
check(dims(grid) == (x, y), f"Grid(grid_w={x}, grid_h={y}) -> {dims(grid)}")
no_pending_exception(f"Grid(grid_w={x}, grid_h={y})")
print()
print("Test 4: Mix positional and keyword args")
try:
# This might trigger different code path
grid3 = mcrfpy.Grid(25, grid_h=15)
_ = list(range(1))
print(" ✓ Grid(25, grid_h=15) works")
except Exception as e:
print(f" ✗ Grid(25, grid_h=15) failed: {type(e).__name__}: {e}")
# Positional slot 0 is pos (#361), so this is "a 25x15 grid drawn at (10, 20)".
grid3 = mcrfpy.Grid((10, 20), grid_w=25, grid_h=15)
check(dims(grid3) == (25, 15), f"Grid((10,20), grid_w=25, grid_h=15) -> {dims(grid3)}")
check((grid3.pos.x, grid3.pos.y) == (10, 20), f"positional pos honored -> {grid3.pos}")
no_pending_exception("Grid((10,20), grid_w=25, grid_h=15)")
print()
print("Test 5: Test with additional arguments")
try:
# This might help identify which PyArg call fails
grid4 = mcrfpy.Grid(grid_w=25, grid_h=15, pos=(0, 0))
_ = list(range(1))
print(" ✓ Grid with pos argument works")
except Exception as e:
print(f" ✗ Grid with pos failed: {type(e).__name__}: {e}")
print("Test 5: Additional arguments alongside the grid dimensions")
grid4 = mcrfpy.Grid(grid_w=25, grid_h=15, pos=(0, 0))
check(dims(grid4) == (25, 15), f"Grid(..., pos=(0,0)) -> {dims(grid4)}")
no_pending_exception("Grid(..., pos=(0,0))")
try:
grid5 = mcrfpy.Grid(grid_w=25, grid_h=15, texture=None)
_ = list(range(1))
print(" ✓ Grid with texture=None works")
except Exception as e:
print(f" ✗ Grid with texture=None failed: {type(e).__name__}: {e}")
grid5 = mcrfpy.Grid(grid_w=25, grid_h=15, texture=None)
check(dims(grid5) == (25, 15), f"Grid(..., texture=None) -> {dims(grid5)}")
no_pending_exception("Grid(..., texture=None)")
print()
print("Hypothesis: The bug is in UIGrid::init line 520-523")
print("PyArg_ParseTupleAndKeywords is called but return value not checked")
print("when parsing remaining arguments in tuple-based initialization path")
print("Test 6: Bad arguments raise cleanly and leave no residue")
bad_calls = [
("grid_size not a tuple", lambda: mcrfpy.Grid(grid_size="nope"), TypeError),
("unknown keyword", lambda: mcrfpy.Grid(grid_size=(4, 4), bogus=3), TypeError),
("grid= with grid_size=", lambda: mcrfpy.Grid(grid=mcrfpy.Grid(grid_size=(4, 4)),
grid_size=(5, 5)), TypeError),
]
for label, call, expected in bad_calls:
try:
call()
except expected as e:
check(True, f"{label} -> {type(e).__name__}: {e}")
except BaseException as e:
check(False, f"{label} -> wrong exception {type(e).__name__}: {e}")
else:
check(False, f"{label} -> no exception raised")
# The failed parse must not poison the interpreter for the next caller.
survivor = mcrfpy.Grid(grid_w=7, grid_h=3)
check(dims(survivor) == (7, 3), f"{label}: next Grid() still correct")
no_pending_exception(f"{label}: after failure")
print()
print("=" * 50)
if failures:
print(f"FAIL: {len(failures)} check(s) failed")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,105 +1,104 @@
#!/usr/bin/env python3
"""Test Python builtins to diagnose the SystemError"""
"""Test Python builtins to diagnose the SystemError
Regression coverage for the embedded CPython interpreter: range/len/iteration
and the builtins table itself must behave exactly as they do in stock Python.
(Historically these raised SystemError inside McRogueFace's interpreter.)
"""
import sys
failures = []
def check(name, condition, detail=""):
"""Record and report a single check."""
if condition:
print(" [PASS]", name)
else:
print(" [FAIL]", name, "-", detail)
failures.append(name)
def check_no_raise(name, fn, expected):
"""Call fn(); it must not raise, and must return `expected`."""
try:
actual = fn()
except Exception as e:
check(name, False, "raised %s: %s" % (type(e).__name__, e))
return
check(name, actual == expected, "got %r, expected %r" % (actual, expected))
print("Python version:", sys.version)
print("=" * 50)
# Test 1: Simple range
print("Test 1: Simple range(5)")
try:
r = range(5)
print(" Created range:", r)
print(" Type:", type(r))
for i in r:
print(" ", i)
print(" ✓ Success")
except Exception as e:
print(" ✗ Error:", type(e).__name__, "-", e)
r = range(5)
check("range(5) is a range", type(r) is range, "type was %r" % (type(r),))
check_no_raise("iterate range(5)", lambda: [i for i in r], [0, 1, 2, 3, 4])
print()
# Test 2: Range with start/stop
print("Test 2: range(1, 5)")
try:
r = range(1, 5)
print(" Created range:", r)
for i in r:
print(" ", i)
print(" ✓ Success")
except Exception as e:
print(" ✗ Error:", type(e).__name__, "-", e)
check_no_raise("iterate range(1, 5)", lambda: list(range(1, 5)), [1, 2, 3, 4])
print()
# Test 3: Range in list comprehension
print("Test 3: List comprehension with range")
try:
lst = [x for x in range(3)]
print(" Result:", lst)
print(" ✓ Success")
except Exception as e:
print(" ✗ Error:", type(e).__name__, "-", e)
check_no_raise("[x for x in range(3)]", lambda: [x for x in range(3)], [0, 1, 2])
print()
# Test 4: Range in for loop (the failing case)
print("Test 4: for x in range(3):")
try:
def _for_loop():
out = []
for x in range(3):
print(" ", x)
print(" ✓ Success")
except Exception as e:
print(" ✗ Error:", type(e).__name__, "-", e)
out.append(x)
return out
check_no_raise("for x in range(3)", _for_loop, [0, 1, 2])
print()
# Test 5: len() on list
print("Test 5: len() on list")
try:
lst = [1, 2, 3]
print(" List:", lst)
print(" Length:", len(lst))
print(" ✓ Success")
except Exception as e:
print(" ✗ Error:", type(e).__name__, "-", e)
check_no_raise("len([1, 2, 3])", lambda: len([1, 2, 3]), 3)
print()
# Test 6: len() on tuple
print("Test 6: len() on tuple")
try:
tup = (1, 2, 3)
print(" Tuple:", tup)
print(" Length:", len(tup))
print(" ✓ Success")
except Exception as e:
print(" ✗ Error:", type(e).__name__, "-", e)
check_no_raise("len((1, 2, 3))", lambda: len((1, 2, 3)), 3)
print()
# Test 7: Nested function calls (reproducing the error context)
print("Test 7: Nested context like in the failing code")
try:
def _walls():
walls = []
for x in range(1, 8):
walls.append((x, 1))
print(" Walls:", walls)
print(" ✓ Success")
except Exception as e:
print(" ✗ Error:", type(e).__name__, "-", e)
import traceback
traceback.print_exc()
return walls
check_no_raise("build wall list via range + append", _walls,
[(1, 1), (2, 1), (3, 1), (4, 1), (5, 1), (6, 1), (7, 1)])
print()
# Test 8: Check if builtins are intact
print("Test 8: Builtin integrity check")
print(" range is:", range)
print(" len is:", len)
print(" type(range):", type(range))
print(" type(len):", type(len))
import builtins
check("range is builtins.range", range is builtins.range)
check("len is builtins.len", len is builtins.len)
check("type(range) is type", type(range) is type, "was %r" % (type(range),))
check("len is a builtin_function_or_method",
type(len).__name__ == "builtin_function_or_method",
"was %r" % (type(len).__name__,))
print()
print("Tests complete.")
if failures:
print("FAIL: %d check(s) failed: %s" % (len(failures), ", ".join(failures)))
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,102 +1,87 @@
#!/usr/bin/env python3
"""Demonstrate the range(25) bug precisely"""
"""Regression test for the historical range(25) bug.
A nested grid.at()/walkable loop over a 25x15 grid used to corrupt the
interpreter (refcount / memory corruption in the C++ binding), so that a
plain `range(25)` afterwards raised. This test drives the exact scenario and
asserts that (a) the interpreter survives intact and (b) the grid.at() writes
actually stick.
"""
import mcrfpy
import sys
print("Demonstrating range(25) bug...")
print("=" * 50)
failures = []
# Test 1: range(25) works fine normally
print("Test 1: range(25) before any mcrfpy operations")
try:
for i in range(25):
pass
print(" ✓ range(25) works fine initially")
except Exception as e:
print(f" ✗ Error: {e}")
# Test 2: range(25) after creating scene/grid
print("\nTest 2: range(25) after creating 25x15 grid")
try:
test = mcrfpy.Scene("test")
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
for i in range(25):
pass
print(" ✓ range(25) still works after grid creation")
except Exception as e:
print(f" ✗ Error: {e}")
def check(condition, label):
if condition:
print(" PASS: %s" % label)
else:
print(" FAIL: %s" % label)
failures.append(label)
# Test 3: The killer combination
print("\nTest 3: range(25) after 15x25 grid.at() operations")
try:
test3 = mcrfpy.Scene("test3")
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
# Do the nested loop that triggers the bug
def exercise_grid(w, h):
"""Nested grid.at() loop -- the operation that used to corrupt memory."""
grid = mcrfpy.Grid(grid_size=(w, h))
count = 0
for y in range(15):
for x in range(25):
for y in range(h):
for x in range(w):
grid.at(x, y).walkable = True
count += 1
print(f" ✓ Completed {count} grid.at() calls")
# This should fail
print(" Testing range(25) now...")
for i in range(25):
pass
print(" ✓ range(25) works (unexpected!)")
except Exception as e:
print(f" ✗ range(25) failed as expected: {type(e).__name__}")
return grid, count
# Test 4: Does range(24) still work?
print("Testing the range(25) bug scenario...")
print("=" * 50)
# Test 1: range(25) works fine normally (baseline sanity)
print("Test 1: range(25) before any mcrfpy operations")
check(list(range(25)) == list(range(25)), "range(25) works initially")
# Test 2: range(25) after creating a 25x15 grid
print("\nTest 2: range(25) after creating 25x15 grid")
mcrfpy.Scene("test2")
grid = mcrfpy.Grid(grid_size=(25, 15))
check(len(list(range(25))) == 25, "range(25) still works after grid creation")
check(tuple(grid.grid_size) == (25, 15), "grid is 25x15")
# Test 3: The killer combination -- 15x25 nested grid.at() loop, then range(25)
print("\nTest 3: range(25) after 15x25 grid.at() operations")
mcrfpy.Scene("test3")
grid, count = exercise_grid(25, 15)
check(count == 375, "completed %d grid.at() calls (expected 375)" % count)
check(list(range(25)) == list(range(25)), "range(25) works after grid.at() loop")
check(sum(range(25)) == 300, "range(25) still produces correct values")
check(all(grid.at(x, y).walkable for y in range(15) for x in range(25)),
"every cell readback is walkable (writes stuck)")
# Test 4: range(24) after the same operations
print("\nTest 4: range(24) after same operations")
try:
test4 = mcrfpy.Scene("test4")
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
for y in range(15):
for x in range(24): # One less
grid.at(x, y).walkable = True
for i in range(24):
pass
print(" ✓ range(24) works")
# What about range(25)?
for i in range(25):
pass
print(" ✓ range(25) also works when grid ops used range(24)")
except Exception as e:
print(f" ✗ Error: {e}")
mcrfpy.Scene("test4")
grid = mcrfpy.Grid(grid_size=(25, 15))
for y in range(15):
for x in range(24): # One less
grid.at(x, y).walkable = True
check(len(list(range(24))) == 24, "range(24) works")
check(len(list(range(25))) == 25, "range(25) also works when grid ops used range(24)")
check(not grid.at(24, 0).walkable, "untouched column 24 stayed unwalkable")
# Test 5: Is it about the specific combination of 15 and 25?
# Test 5: Different grid dimensions -- not specific to 15/25
print("\nTest 5: Different grid dimensions")
try:
test5 = mcrfpy.Scene("test5")
grid = mcrfpy.Grid(grid_w=30, grid_h=20)
for y in range(20):
for x in range(30):
grid.at(x, y).walkable = True
# Test various ranges
for i in range(25):
pass
print(" ✓ range(25) works with 30x20 grid")
for i in range(30):
pass
print(" ✓ range(30) works with 30x20 grid")
except Exception as e:
print(f" ✗ Error: {e}")
mcrfpy.Scene("test5")
grid, count = exercise_grid(30, 20)
check(count == 600, "completed %d grid.at() calls on 30x20 grid" % count)
check(len(list(range(25))) == 25, "range(25) works with 30x20 grid")
check(len(list(range(30))) == 30, "range(30) works with 30x20 grid")
print("\nConclusion: There's a specific bug triggered by:")
print("1. Creating a grid with grid_w=25")
print("2. Using range(25) in a nested loop with grid.at() calls")
print("3. Then trying to use range(25) again")
print("\nThis appears to be a memory corruption or reference counting issue in the C++ code.")
print("\n" + "=" * 50)
if failures:
print("FAIL: %d check(s) failed:" % len(failures))
for f in failures:
print(" - %s" % f)
sys.exit(1)
print("PASS: the range(25) bug is fixed; grid.at() loops do not corrupt the interpreter")
sys.exit(0)

View file

@ -1,107 +1,128 @@
#!/usr/bin/env python3
"""Find the exact threshold where range() starts failing"""
"""Regression: range() (and general interpreter state) must survive grid operations.
Historically, iterating a grid with grid.at(x, y) at certain sizes corrupted the
Python interpreter state and a subsequent range() raised SystemError. This test
sweeps grid/range sizes and asserts that never happens again.
"""
import mcrfpy
import sys
print("Finding range() failure threshold...")
print("=" * 50)
failures = []
def test_range_size(n):
"""Test if range(n) works after grid operations"""
"""Test if range(n) works after grid operations on an n x n grid"""
try:
mcrfpy.createScene(f"test_{n}")
grid = mcrfpy.Grid(grid_w=n, grid_h=n)
mcrfpy.Scene(f"test_{n}")
grid = mcrfpy.Grid(grid_size=(n, n))
# Do grid operations
for y in range(min(n, 10)): # Limit outer loop
for x in range(n):
if x < n and y < n:
grid.at(x, y).walkable = True
# Now test if range(n) still works
test_list = []
for i in range(n):
test_list.append(i)
return True, len(test_list)
except SystemError as e:
return False, str(e)
except Exception as e:
return False, f"Other error: {type(e).__name__}: {e}"
# Binary search for the threshold
# Sweep a range of sizes; every one of them must work.
print("Testing different range sizes...")
# Test powers of 2 first
for n in [2, 4, 8, 16, 32]:
success, result = test_range_size(n)
if success:
print(f" range({n:2d}): ✓ Success - created list of {result} items")
print(f" range({n:2d}): OK - created list of {result} items")
if result != n:
failures.append(f"range({n}) produced {result} items, expected {n}")
else:
print(f" range({n:2d}): ✗ Failed - {result}")
print(f" range({n:2d}): FAIL - {result}")
failures.append(f"range({n}) after {n}x{n} grid ops: {result}")
print()
# Narrow down between working and failing values
print("Narrowing down exact threshold...")
# The original bug was reported as "10 works, 25 fails" -- walk the whole span
# and require that every size in it works.
print("Sweeping the historically suspect span (10..25)...")
# From our tests: 10 works, 25 fails
low = 10
high = 25
while low < high - 1:
mid = (low + high) // 2
success, result = test_range_size(mid)
for n in range(10, 26):
success, result = test_range_size(n)
if success:
print(f" range({mid}): ✓ Works")
low = mid
print(f" range({n}): OK")
if result != n:
failures.append(f"range({n}) produced {result} items, expected {n}")
else:
print(f" range({mid}): ✗ Fails")
high = mid
print(f" range({n}): FAIL - {result}")
failures.append(f"range({n}) after {n}x{n} grid ops: {result}")
print()
print(f"Threshold found: range({low}) works, range({high}) fails")
# Test if it's specifically about range or about the grid size
print()
print("Testing if it's about grid size vs range size...")
try:
# Small grid, large range
test_small_grid = mcrfpy.Scene("test_small_grid")
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
mcrfpy.Scene("test_small_grid")
grid = mcrfpy.Grid(grid_size=(5, 5))
# Do minimal grid operations
grid.at(0, 0).walkable = True
# Test large range
count = 0
for i in range(25):
pass
print(" ✓ range(25) works with small grid (5x5)")
count += 1
assert count == 25, f"range(25) yielded {count} items"
print(" OK: range(25) works with small grid (5x5)")
except Exception as e:
print(f" ✗ range(25) fails with small grid: {e}")
print(f" FAIL: range(25) fails with small grid: {type(e).__name__}: {e}")
failures.append(f"small grid / large range: {type(e).__name__}: {e}")
try:
# Large grid, see what happens
test_large_grid = mcrfpy.Scene("test_large_grid")
grid = mcrfpy.Grid(grid_w=20, grid_h=20)
mcrfpy.Scene("test_large_grid")
grid = mcrfpy.Grid(grid_size=(20, 20))
# Do operations on large grid
touched = 0
for y in range(20):
for x in range(20):
grid.at(x, y).walkable = True
print(" ✓ Completed 20x20 grid operations")
touched += 1
assert touched == 400, f"touched {touched} cells, expected 400"
print(" OK: Completed 20x20 grid operations")
# Now test range
count = 0
for i in range(20):
pass
print(" ✓ range(20) works after 20x20 grid operations")
count += 1
assert count == 20, f"range(20) yielded {count} items"
print(" OK: range(20) works after 20x20 grid operations")
# And the values written are readable back
assert grid.at(19, 19).walkable is True, "walkable did not persist at (19,19)"
except Exception as e:
print(f" ✗ Error with 20x20 grid: {e}")
print(f" FAIL: Error with 20x20 grid: {type(e).__name__}: {e}")
failures.append(f"20x20 grid: {type(e).__name__}: {e}")
print()
print("Analysis complete.")
if failures:
print("Analysis complete: FAILURES")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("Analysis complete.")
print("PASS")
sys.exit(0)

View file

@ -1,12 +1,27 @@
#!/usr/bin/env python3
"""Test scene transitions to verify implementation and demonstrate usage."""
"""Test scene transitions to verify implementation and demonstrate usage.
Headless model (#350): mcrfpy.step(dt) is the only clock -- transitions only
advance when we step. current_scene stays on the OUTGOING scene for the whole
duration of a non-instant transition, and flips to the incoming scene when the
transition finishes.
"""
import mcrfpy
from mcrfpy import automation
import sys
import time
red_scene, blue_scene, green_scene, menu_scene = None, None, None, None # global scoping
failures = []
def check(label, condition):
if condition:
print(f" ok : {label}")
else:
print(f" FAIL: {label}")
failures.append(label)
def create_test_scenes():
"""Create several test scenes with different colored backgrounds."""
global red_scene, blue_scene, green_scene, menu_scene
@ -59,7 +74,7 @@ def create_test_scenes():
scene_info = mcrfpy.Caption(pos=(512, 300), text="R: Red Scene | B: Blue Scene | G: Green Scene | M: Menu", font=mcrfpy.default_font)
scene_info.fill_color = mcrfpy.Color(150, 150, 150, 255)
ui4.append(scene_info)
print("Created test scenes: red_scene, blue_scene, green_scene, menu_scene")
# Track current transition type
@ -69,12 +84,10 @@ transition_duration = 1.0
def handle_key(key, action):
"""Handle keyboard input for scene transitions."""
global current_transition, transition_duration
if action != mcrfpy.InputState.PRESSED:
return
current_scene = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
# Number keys set transition type
keyselections = {
mcrfpy.Key.NUM_1: mcrfpy.Transition.FADE,
@ -87,25 +100,7 @@ def handle_key(key, action):
if key in keyselections:
current_transition = keyselections[key]
print(f"Transition set to: {current_transition}")
#if key == mcrfpy.Key.NUM_1:
# current_transition = "fade"
# print("Transition set to: fade")
#elif key == mcrfpy.Key.NUM_2:
# current_transition = "slide_left"
# print("Transition set to: slide_left")
#elif key == mcrfpy.Key.NUM_3:
# current_transition = "slide_right"
# print("Transition set to: slide_right")
#elif key == mcrfpy.Key.NUM_4:
# current_transition = "slide_up"
# print("Transition set to: slide_up")
#elif key == mcrfpy.Key.NUM_5:
# current_transition = "slide_down"
# print("Transition set to: slide_down")
#elif key == mcrfpy.Key.NUM_6:
# current_transition = None # Instant
# print("Transition set to: instant")
# Letter keys change scene
keytransitions = {
mcrfpy.Key.R: red_scene,
@ -115,62 +110,89 @@ def handle_key(key, action):
}
if key in keytransitions:
if mcrfpy.current_scene != keytransitions[key]:
print(f"Transitioning to {keytransitions[key].name} with {current_transition}")
keytransitions[key].activate(current_transition, transition_duration)
#elif key == mcrfpy.Key.R:
# if current_scene != "red_scene":
# print(f"Transitioning to red_scene with {current_transition}")
# if current_transition:
# mcrfpy.setScene("red_scene", current_transition, transition_duration)
# else:
# red_scene.activate()
#elif key == mcrfpy.Key.B:
# if current_scene != "blue_scene":
# print(f"Transitioning to blue_scene with {current_transition}")
# if current_transition:
# mcrfpy.setScene("blue_scene", current_transition, transition_duration)
# else:
# blue_scene.activate()
#elif key == mcrfpy.Key.G:
# if current_scene != "green_scene":
# print(f"Transitioning to green_scene with {current_transition}")
# if current_transition:
# mcrfpy.setScene("green_scene", current_transition, transition_duration)
# else:
# green_scene.activate()
#elif key == mcrfpy.Key.M:
# if current_scene != "menu_scene":
# print(f"Transitioning to menu_scene with {current_transition}")
# if current_transition:
# mcrfpy.setScene("menu_scene", current_transition, transition_duration)
# else:
# menu_scene.activate()
elif key == mcrfpy.Key.ESCAPE:
print("Exiting...")
sys.exit(0)
def test_automatic_transitions(delay):
"""Run through all transitions automatically after a delay."""
transitions = [
("fade", "red_scene"),
("slide_left", "blue_scene"),
("slide_right", "green_scene"),
("slide_up", "red_scene"),
("slide_down", "menu_scene"),
(None, "blue_scene"), # Instant
]
def press(key_name):
"""Send a real key press/release through the engine to the active scene."""
automation.keyDown(key_name)
automation.keyUp(key_name)
def run_transition(target, trans_type, duration=1.0):
"""Activate `target` with `trans_type` and drive the clock until it lands."""
origin = mcrfpy.current_scene
target.activate(trans_type, duration)
if trans_type == mcrfpy.Transition.NONE:
# Instant: no time needs to pass at all.
check(f"{trans_type}: instant switch to {target.name}",
mcrfpy.current_scene == target)
else:
# The outgoing scene stays current until the transition finishes.
check(f"{trans_type}: still on {origin.name} at t=0",
mcrfpy.current_scene == origin)
mcrfpy.step(duration / 2.0)
check(f"{trans_type}: still on {origin.name} mid-transition",
mcrfpy.current_scene == origin)
# Step past the end of the transition.
for _ in range(4):
mcrfpy.step(duration / 2.0)
if mcrfpy.current_scene == target:
break
check(f"{trans_type}: arrived at {target.name}",
mcrfpy.current_scene == target)
check(f"{trans_type}: {target.name}.active", target.active)
if origin != target:
check(f"{trans_type}: {origin.name} deactivated", not origin.active)
def test_automatic_transitions():
"""Run through every transition type, asserting each one lands."""
print("\nRunning automatic transition test...")
for i, (trans_type, scene) in enumerate(transitions):
if trans_type:
print(f"Transition {i+1}: {trans_type} to {scene}")
mcrfpy.setScene(scene, trans_type, 1.0)
else:
print(f"Transition {i+1}: instant to {scene}")
mcrfpy.current_scene = scene
time.sleep(2) # Wait for transition to complete plus viewing time
transitions = [
(mcrfpy.Transition.FADE, red_scene),
(mcrfpy.Transition.SLIDE_LEFT, blue_scene),
(mcrfpy.Transition.SLIDE_RIGHT, green_scene),
(mcrfpy.Transition.SLIDE_UP, red_scene),
(mcrfpy.Transition.SLIDE_DOWN, menu_scene),
(mcrfpy.Transition.NONE, blue_scene), # Instant
]
for trans_type, scene in transitions:
run_transition(scene, trans_type)
print("Automatic test complete!")
sys.exit(0)
def test_key_driven_transitions():
"""Exercise the keyboard handler itself: number key picks the transition,
letter key picks the scene."""
print("\nRunning key-driven transition test...")
press("6") # Transition.NONE
press("m") # -> menu_scene, instantly
mcrfpy.step(0.0)
check("key '6'+'m': instant to menu_scene", mcrfpy.current_scene == menu_scene)
press("3") # Transition.SLIDE_RIGHT
press("g") # -> green_scene over transition_duration
mcrfpy.step(0.0)
check("key '3'+'g': still on menu_scene while sliding",
mcrfpy.current_scene == menu_scene)
for _ in range(6):
mcrfpy.step(0.25)
if mcrfpy.current_scene == green_scene:
break
check("key '3'+'g': arrived at green_scene",
mcrfpy.current_scene == green_scene)
check("green_scene.active after key transition", green_scene.active)
# Pressing the letter key for the scene we're already on is a no-op.
press("g")
mcrfpy.step(0.1)
check("key 'g' on green_scene is a no-op", mcrfpy.current_scene == green_scene)
# Main test setup
print("=== Scene Transition Test ===")
@ -178,15 +200,22 @@ create_test_scenes()
# Start with menu scene
menu_scene.activate()
check("menu_scene is the starting scene", mcrfpy.current_scene == menu_scene)
check("menu_scene.active", menu_scene.active)
check("menu_scene has 5 children", len(menu_scene.children) == 5)
# Set up keyboard handler
for s in (red_scene, blue_scene, green_scene, menu_scene):
s.on_key = handle_key
#menu_scene.on_key = handle_key
# Option to run automatic test
if len(sys.argv) > 1 and sys.argv[1] == "--auto":
mcrfpy.Timer("auto_test", lambda t, r: test_automatic_transitions(r), 1000, once=True)
else:
print("\nManual test mode. Use keyboard controls shown on screen.")
print("Run with --auto flag for automatic transition demo.")
test_automatic_transitions()
test_key_driven_transitions()
if failures:
print(f"\nFAIL: {len(failures)} check(s) failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("\nPASS")
sys.exit(0)

View file

@ -4,12 +4,21 @@
import mcrfpy
import sys
failures = []
def check(cond, msg):
if cond:
print(f" ok: {msg}")
else:
print(f" FAIL: {msg}")
failures.append(msg)
def test_scene_transitions():
"""Test all scene transition types."""
# Create two simple scenes
print("Creating test scenes...")
# Scene 1
scene1 = mcrfpy.Scene("scene1")
ui1 = scene1.children
@ -21,40 +30,68 @@ def test_scene_transitions():
ui2 = scene2.children
frame2 = mcrfpy.Frame(pos=(0, 0), size=(100, 100), fill_color=mcrfpy.Color(0, 0, 255))
ui2.append(frame2)
# Test each transition type
scenes = {"scene1": scene1, "scene2": scene2}
# Test each transition type. mcrfpy.setScene(name, str, duration) is gone;
# transitions are now scene.activate(mcrfpy.Transition.X, duration).
transitions = [
("fade", 0.5),
("slide_left", 0.5),
("slide_right", 0.5),
("slide_up", 0.5),
("slide_down", 0.5),
(mcrfpy.Transition.FADE, 0.5),
(mcrfpy.Transition.SLIDE_LEFT, 0.5),
(mcrfpy.Transition.SLIDE_RIGHT, 0.5),
(mcrfpy.Transition.SLIDE_UP, 0.5),
(mcrfpy.Transition.SLIDE_DOWN, 0.5),
(None, 0.0), # Instant
]
print("\nTesting scene transitions:")
# Start with scene1
scene1.activate()
print(f"Initial scene: {(mcrfpy.current_scene.name if mcrfpy.current_scene else None)}")
check(mcrfpy.current_scene.name == "scene1",
f"initial scene is scene1 (got {mcrfpy.current_scene.name})")
for trans_type, duration in transitions:
target = "scene2" if (mcrfpy.current_scene.name if mcrfpy.current_scene else None) == "scene1" else "scene1"
if trans_type:
print(f"\nTransitioning to {target} with {trans_type} (duration: {duration}s)")
mcrfpy.setScene(target, trans_type, duration)
source = mcrfpy.current_scene.name
target = "scene2" if source == "scene1" else "scene1"
target_scene = scenes[target]
if trans_type is not None:
print(f"\nTransitioning to {target} with {trans_type!r} (duration: {duration}s)")
target_scene.activate(trans_type, duration)
# A transition is in flight: the outgoing scene stays current until it
# completes, and headless only advances when we call mcrfpy.step().
check(mcrfpy.current_scene.name == source,
f"mid-transition current_scene is still {source}")
# Half the duration elapsed -> still not switched.
steps = int(duration / 0.05)
for _ in range(steps // 2):
mcrfpy.step(0.05)
check(mcrfpy.current_scene.name == source,
f"halfway through {trans_type!r}, current_scene is still {source}")
# Run past the end of the transition.
for _ in range(steps):
mcrfpy.step(0.05)
else:
print(f"\nTransitioning to {target} instantly")
mcrfpy.current_scene = target
print(f"Current scene after transition: {(mcrfpy.current_scene.name if mcrfpy.current_scene else None)}")
print("\n✓ All scene transition types tested successfully!")
print("\nNote: Visual transitions cannot be verified in headless mode.")
print("The transitions are implemented and working in the engine.")
sys.exit(0)
mcrfpy.current_scene = target_scene
# Run the test immediately
test_scene_transitions()
check(mcrfpy.current_scene.name == target,
f"after transition, current_scene is {target}")
check(target_scene.active is True, f"{target}.active is True")
check(scenes[source].active is False, f"{source}.active is False")
print("\nAll scene transition types tested.")
if __name__ == "__main__":
test_scene_transitions()
if failures:
print(f"\nFAIL: {len(failures)} check(s) failed")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("\nPASS")
sys.exit(0)

View file

@ -1,32 +1,98 @@
#!/usr/bin/env python3
"""Test if closing stdin prevents the >>> prompt"""
"""Test that a --exec script never falls through into an interactive REPL
Original intent: "is stdin the reason the >>> prompt appears after my script runs?"
The script tried closing / redirecting stdin and then eyeballed the terminal for a
">>>" prompt. It asserted nothing and never stated an exit status.
Current contract: --exec scripts are run non-interactively. The engine never hands
stdin to the Python REPL, whether or not stdin is open, and whether or not the
script exits cleanly. That is verified here in child processes that are *given* a
readable stdin containing Python source: if the interpreter ever went interactive,
it would echo ">>>" and execute the fed-in line.
"""
import mcrfpy
import sys
import os
import subprocess
import sys
failures = []
scratch = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "..", "build"))
print("=== Testing stdin theory ===")
print(f"stdin.isatty(): {sys.stdin.isatty()}")
print(f"stdin fileno: {sys.stdin.fileno()}")
print("stdin.isatty(): %s" % sys.stdin.isatty())
print("stdin fileno: %s" % sys.stdin.fileno())
# Set up a basic scene
# A --exec script is not interactive even though stdin is a real, readable fd.
if sys.stdin.isatty():
failures.append("--exec script should not have a tty on stdin")
# Set up a basic scene (the original script did this to have the engine "live")
stdin_test = mcrfpy.Scene("stdin_test")
stdin_test.activate()
if mcrfpy.current_scene is not stdin_test:
failures.append("scene.activate() did not set mcrfpy.current_scene")
# Try to prevent interactive mode by closing stdin
print("\nAttempting to prevent interactive mode...")
try:
# Method 1: Close stdin
sys.stdin.close()
print("Closed sys.stdin")
except:
print("Failed to close sys.stdin")
# --- 1. stdin fed Python source is NOT evaluated as a REPL ---------------------
# sys.executable is the mcrogueface binary itself.
FED = "print('REPL_LEAKED')\n"
try:
# Method 2: Redirect stdin to /dev/null
devnull = open(os.devnull, 'r')
os.dup2(devnull.fileno(), 0)
print("Redirected stdin to /dev/null")
except:
print("Failed to redirect stdin")
child_exit = os.path.join(scratch, "_stdin_theory_child_exit.py")
child_fallthrough = os.path.join(scratch, "_stdin_theory_child_fallthrough.py")
with open(child_exit, "w") as f:
f.write("import mcrfpy\nimport sys\nprint('CHILD_RAN')\nsys.exit(0)\n")
with open(child_fallthrough, "w") as f:
# Deliberately falls off the end: the historical case where >>> showed up.
f.write("import mcrfpy\nprint('CHILD_RAN')\n")
print("\nScript complete. If >>> still appears, the issue is elsewhere.")
for name, path, expect_zero in (("clean-exit", child_exit, True),
("fall-through", child_fallthrough, False)):
try:
proc = subprocess.run(
[sys.executable, "--headless", "--exec", path],
cwd=scratch, input=FED, capture_output=True, text=True, timeout=20)
except subprocess.TimeoutExpired:
failures.append("%s child hung: it is waiting on stdin (interactive mode)" % name)
continue
out = proc.stdout + proc.stderr
if "CHILD_RAN" not in out:
failures.append("%s child never ran the script" % name)
if ">>>" in out:
failures.append("%s child printed a >>> prompt: --exec went interactive" % name)
if "REPL_LEAKED" in out:
failures.append("%s child executed source fed on stdin: --exec went interactive"
% name)
if expect_zero and proc.returncode != 0:
failures.append("%s child should exit 0, got %d" % (name, proc.returncode))
if not expect_zero and proc.returncode == 0:
failures.append("fall-through child should exit nonzero (#350 exit contract)")
for path in (child_exit, child_fallthrough):
if os.path.exists(path):
os.remove(path)
# --- 2. The stdin manipulations the original script tried still work ----------
# (They are unnecessary -- part 1 proves stdin was never the cause -- but the engine
# must not be destabilized by a script that closes or redirects fd 0.)
print("\nAttempting the original stdin manipulations...")
sys.stdin.close()
print("Closed sys.stdin")
devnull = open(os.devnull, 'r')
os.dup2(devnull.fileno(), 0)
print("Redirected stdin to /dev/null")
# Engine is still alive and steppable after fd 0 was yanked out from under it.
mcrfpy.step(0.016)
if mcrfpy.current_scene is not stdin_test:
failures.append("engine lost its scene after stdin was closed/redirected")
if failures:
for f in failures:
print("FAIL: %s" % f)
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -5,147 +5,164 @@ import os
import sys
import ast
import mcrfpy
# The stub lives in the repo, but tests run with cwd=build/. Anchor on this file.
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
STUB_PATH = os.path.join(REPO_ROOT, 'stubs', 'mcrfpy.pyi')
def read_stub():
with open(STUB_PATH, 'r') as f:
return f.read()
def test_stub_syntax():
"""Test that the stub file has valid Python syntax."""
stub_path = 'stubs/mcrfpy.pyi'
if not os.path.exists(stub_path):
print(f"ERROR: Stub file not found at {stub_path}")
if not os.path.exists(STUB_PATH):
print(f"ERROR: Stub file not found at {STUB_PATH}")
return False
try:
with open(stub_path, 'r') as f:
content = f.read()
content = read_stub()
# Parse the stub file
tree = ast.parse(content)
print(f" Stub file has valid Python syntax ({len(content)} bytes)")
print(f"OK Stub file has valid Python syntax ({len(content)} bytes)")
# Count definitions
classes = [node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)]
functions = [node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)]
print(f" Found {len(classes)} class definitions")
print(f" Found {len(functions)} function/method definitions")
# Check for key classes
print(f"OK Found {len(classes)} class definitions")
print(f"OK Found {len(functions)} function/method definitions")
# Check for key classes (Timer/Scene are the successors to setTimer/createScene)
class_names = {cls.name for cls in classes}
expected_classes = {'Frame', 'Caption', 'Sprite', 'Grid', 'Entity', 'Color', 'Vector', 'Scene', 'Window'}
expected_classes = {'Frame', 'Caption', 'Sprite', 'Grid', 'GridView', 'GridData',
'Entity', 'Color', 'Vector', 'Scene', 'Timer', 'Window'}
missing = expected_classes - class_names
if missing:
print(f" Missing classes: {missing}")
print(f"FAIL Missing classes: {missing}")
return False
else:
print("✓ All expected classes are defined")
# Check for key functions
print("OK All expected classes are defined")
# Check for key module-level functions (current API: setScene/createScene/
# currentScene/setTimer/findAll are gone; Scene objects, Timer and find_all
# replaced them).
top_level_funcs = [node.name for node in tree.body if isinstance(node, ast.FunctionDef)]
expected_funcs = {'createScene', 'setScene', 'currentScene', 'find', 'findAll', 'setTimer'}
func_set = set(top_level_funcs)
expected_funcs = {'find', 'find_all', 'step', 'get_metrics', 'set_scale', 'exit'}
missing_funcs = expected_funcs - func_set
if missing_funcs:
print(f" Missing functions: {missing_funcs}")
print(f"FAIL Missing functions: {missing_funcs}")
return False
else:
print("✓ All expected functions are defined")
print("OK All expected functions are defined")
# Every function the stub advertises must actually exist on the module,
# and removed APIs must not linger in the stub.
phantom = sorted(f for f in func_set if not hasattr(mcrfpy, f))
if phantom:
print(f"FAIL Stub declares functions absent from mcrfpy: {phantom}")
return False
print(f"OK All {len(func_set)} stub functions exist on the mcrfpy module")
phantom_classes = sorted(c for c in expected_classes if not hasattr(mcrfpy, c))
if phantom_classes:
print(f"FAIL Stub declares classes absent from mcrfpy: {phantom_classes}")
return False
print("OK All expected stub classes exist on the mcrfpy module")
return True
except SyntaxError as e:
print(f"✗ Syntax error in stub file: {e}")
return False
except Exception as e:
print(f"✗ Error parsing stub file: {e}")
print(f"FAIL Syntax error in stub file: {e}")
return False
def test_type_annotations():
"""Test that type annotations are present and well-formed."""
stub_path = 'stubs/mcrfpy.pyi'
with open(stub_path, 'r') as f:
content = f.read()
content = read_stub()
# Check for proper type imports
if 'from typing import' not in content:
print(" Missing typing imports")
print("FAIL Missing typing imports")
return False
else:
print("✓ Has typing imports")
# Check for Optional usage
if 'Optional[' in content:
print("✓ Uses Optional type hints")
# Check for Union usage
if 'Union[' in content:
print("✓ Uses Union type hints")
# Check for overload usage
if '@overload' in content:
print("✓ Uses @overload decorators")
print("OK Has typing imports")
# Optional/Union/@overload are nice-to-haves; the generator currently emits
# PEP 604 unions ("int | None") instead, so these are informational only.
for marker, label in (('Optional[', 'Optional type hints'),
('Union[', 'Union type hints'),
('@overload', '@overload decorators')):
if marker in content:
print(f"OK Uses {label}")
# Check return type annotations
if '-> None:' in content and '-> int:' in content and '-> str:' in content:
print(" Has return type annotations")
print("OK Has return type annotations")
else:
print("✗ Missing some return type annotations")
print("FAIL Missing some return type annotations")
return False
return True
def test_docstrings():
"""Test that docstrings are preserved in stubs."""
stub_path = 'stubs/mcrfpy.pyi'
with open(stub_path, 'r') as f:
content = f.read()
content = read_stub()
# Count docstrings
docstring_count = content.count('"""')
if docstring_count > 10: # Should have many docstrings
print(f" Found {docstring_count // 2} docstrings")
print(f"OK Found {docstring_count // 2} docstrings")
else:
print(f"✗ Too few docstrings found: {docstring_count // 2}")
# Check for specific docstrings
if 'Core game engine interface' in content:
print("✓ Module docstring present")
if 'A rectangular frame UI element' in content:
print("✓ Frame class docstring present")
if 'Load a sound effect from a file' in content:
print("✓ Function docstrings present")
return True
print(f"FAIL Too few docstrings found: {docstring_count // 2}")
return False
# Check for specific docstrings (module, class, method)
ok = True
for needle, label in (
('Type stubs for McRogueFace Python API', 'Module docstring'),
('A rectangular frame UI element', 'Frame class docstring'),
('Take a screenshot', 'Method docstring'),
):
if needle in content:
print(f"OK {label} present")
else:
print(f"FAIL {label} missing")
ok = False
return ok
def test_automation_module():
"""Test that automation module is properly defined."""
stub_path = 'stubs/mcrfpy.pyi'
with open(stub_path, 'r') as f:
content = f.read()
if 'class automation:' in content:
print("✓ automation class defined")
content = read_stub()
# The generator emits the submodule as a _automation_module class plus an
# `automation:` module-level binding.
if 'class _automation_module:' in content and 'automation: _automation_module' in content:
print("OK automation submodule defined")
else:
print("✗ automation class missing")
print("FAIL automation submodule missing")
return False
# Check for key automation methods
automation_methods = ['screenshot', 'click', 'moveTo', 'keyDown', 'typewrite']
missing = []
for method in automation_methods:
if f'def {method}' not in content:
missing.append(method)
elif not hasattr(mcrfpy.automation, method):
missing.append(method + " (stubbed but absent from mcrfpy.automation)")
if missing:
print(f"✗ Missing automation methods: {missing}")
print(f"FAIL Missing automation methods: {missing}")
return False
else:
print(" All key automation methods defined")
print("OK All key automation methods defined")
return True
def main():
@ -176,10 +193,10 @@ def main():
print()
if all_passed:
print(" All tests passed! Type stubs are valid and complete.")
print("PASS - All tests passed! Type stubs are valid and complete.")
sys.exit(0)
else:
print(" Some tests failed. Please review the stub file.")
print("FAIL - Some tests failed. Please review the stub file.")
sys.exit(1)
if __name__ == '__main__':

View file

@ -1,18 +1,53 @@
#!/usr/bin/env python3
"""
Test the text input widget system
Exercises src/scripts/text_input_widget.py (FocusManager + TextInput) headlessly:
building the widgets into a scene, focus/blur, typing, editing keys, cursor
movement, placeholder visibility and on_change notifications.
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src', 'scripts'))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)),
'..', '..', 'src', 'scripts'))
import mcrfpy
from text_input_widget import FocusManager, TextInput
failures = []
def check(condition, message):
if not condition:
failures.append(message)
print(f"FAIL: {message}")
return condition
# The TextInput widget speaks a string key protocol; the engine delivers
# mcrfpy.Key enums (#184). Translate at the scene handler, as a real game would.
KEY_NAMES = {
mcrfpy.Key.BACKSPACE: "BackSpace",
mcrfpy.Key.DELETE: "Delete",
mcrfpy.Key.LEFT: "Left",
mcrfpy.Key.RIGHT: "Right",
mcrfpy.Key.HOME: "Home",
mcrfpy.Key.END: "End",
mcrfpy.Key.TAB: "Tab",
mcrfpy.Key.ENTER: "Return",
mcrfpy.Key.SPACE: " ",
}
for _letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
KEY_NAMES[getattr(mcrfpy.Key, _letter)] = _letter.lower()
def key_name(key):
return KEY_NAMES.get(key, "Unknown")
def create_demo():
"""Create demo scene with text inputs"""
"""Create demo scene with text inputs; returns (scene, focus_mgr, inputs, status, on_key)"""
# Create scene
text_demo = mcrfpy.Scene("text_demo")
scene = text_demo.children
@ -75,36 +110,116 @@ def create_demo():
for inp in inputs:
inp.on_change = update_status
# Keyboard handler
def handle_keys(scene_name, key):
if not focus_mgr.handle_key(key):
# Keyboard handler (#184: receives Key and InputState enums)
def handle_keys(key, action):
if action != mcrfpy.InputState.PRESSED:
return
if not focus_mgr.handle_key(key_name(key)):
if key == mcrfpy.Key.TAB:
focus_mgr.focus_next()
elif key == mcrfpy.Key.ESCAPE:
print("\nFinal values:")
for i, inp in enumerate(inputs):
print(f" Field {i+1}: '{inp.get_text()}'")
sys.exit(0)
text_demo.on_key = "text_demo", handle_keys
text_demo.on_key = handle_keys
text_demo.activate()
# Run demo test
def run_test(timer, runtime):
print("\n=== Text Input Widget Test ===")
print("Features:")
print("- Click to focus fields")
print("- Tab to navigate between fields")
print("- Type to enter text")
print("- Backspace/Delete to edit")
print("- Home/End for cursor movement")
print("- Placeholder text")
print("- Visual focus indication")
print("- Press Escape to exit")
print("\nTry it out!")
return text_demo, focus_mgr, inputs, status, handle_keys
info_timer = mcrfpy.Timer("info", run_test, 100, once=True)
def press(handler, key):
handler(key, mcrfpy.InputState.PRESSED)
def main():
scene, focus_mgr, inputs, status, on_key = create_demo()
print("\n=== Text Input Widget Test ===")
# --- construction / registration ---
check(len(focus_mgr.widgets) == 4, "four inputs registered with the focus manager")
check(mcrfpy.current_scene is scene, "text_demo scene is active")
# --- initial focus: first registered widget ---
first, second = inputs[0], inputs[1]
check(focus_mgr.focused_widget is first, "first input focused on registration")
check(first.focused is True, "first input reports focused")
check(first.cursor.visible is True, "focused input shows its cursor")
check(second.cursor.visible is False, "unfocused input hides its cursor")
check(second.placeholder_text.visible is True,
"unfocused empty input shows placeholder")
check(first.placeholder_text.visible is False,
"focused input hides placeholder")
# --- typing ---
for key in (mcrfpy.Key.J, mcrfpy.Key.O, mcrfpy.Key.H, mcrfpy.Key.N):
press(on_key, key)
check(first.get_text() == "john", f"typing inserts text (got {first.get_text()!r})")
check(first.cursor_pos == 4, f"cursor advances with typing (got {first.cursor_pos})")
check(first.text_display.text == "john", "text display mirrors the text")
check(status.text.startswith("Data: john |"),
f"on_change updated the status caption (got {status.text!r})")
# --- editing: backspace ---
press(on_key, mcrfpy.Key.BACKSPACE)
check(first.get_text() == "joh", f"backspace deletes before cursor (got {first.get_text()!r})")
check(first.cursor_pos == 3, "backspace moves cursor back")
# --- cursor movement: Home / Left / Right / End + insert at cursor ---
press(on_key, mcrfpy.Key.HOME)
check(first.cursor_pos == 0, "Home moves cursor to start")
press(on_key, mcrfpy.Key.RIGHT)
check(first.cursor_pos == 1, "Right moves cursor forward")
press(on_key, mcrfpy.Key.LEFT)
check(first.cursor_pos == 0, "Left moves cursor back")
press(on_key, mcrfpy.Key.B)
check(first.get_text() == "bjoh", f"insertion happens at the cursor (got {first.get_text()!r})")
press(on_key, mcrfpy.Key.DELETE)
check(first.get_text() == "boh", f"Delete removes character after cursor (got {first.get_text()!r})")
press(on_key, mcrfpy.Key.END)
check(first.cursor_pos == 3, "End moves cursor to the end")
# --- Tab navigation: unhandled by widget, focuses next ---
press(on_key, mcrfpy.Key.TAB)
check(focus_mgr.focused_widget is second, "Tab focuses the next input")
check(first.focused is False, "previous input blurred")
check(first.cursor.visible is False, "blurred input hides its cursor")
check(second.focused is True, "next input focused")
check(first.placeholder_text.visible is False,
"blurred but non-empty input keeps placeholder hidden")
press(on_key, mcrfpy.Key.A)
check(second.get_text() == "a", "typing goes to the newly focused input")
check(first.get_text() == "boh", "previously focused input keeps its text")
focus_mgr.focus_prev()
check(focus_mgr.focused_widget is first, "focus_prev returns to the first input")
# --- programmatic set_text / get_text ---
inputs[3].set_text("hello world")
check(inputs[3].get_text() == "hello world", "set_text sets the text")
check(inputs[3].cursor_pos == len("hello world"), "set_text places cursor at end")
check(inputs[3].text_display.text == "hello world", "set_text updates the display")
check(inputs[3].placeholder_text.visible is False,
"placeholder hidden once the field has text")
# --- timer + render still work with the widgets in the scene ---
fired = []
mcrfpy.Timer("info", lambda t, rt: fired.append(rt), 100, once=True)
for _ in range(4):
mcrfpy.step(0.05)
check(len(fired) == 1, f"one-shot timer fired exactly once (got {len(fired)})")
mcrfpy.automation.screenshot("test_text_input.png")
check(os.path.exists("test_text_input.png"), "scene with text inputs rendered")
if failures:
print(f"\nFAIL: {len(failures)} check(s) failed")
sys.exit(1)
print("\nPASS")
sys.exit(0)
if __name__ == "__main__":
create_demo()
main()

View file

@ -8,39 +8,64 @@ except ImportError as e:
print(f"Failed to import mcrfpy: {e}", file=sys.stderr)
sys.exit(1)
failures = []
# Test 1: Try to create a texture with a non-existent file
print("Test 1: Creating texture with non-existent file...")
try:
texture = mcrfpy.Texture("this_file_does_not_exist.png", 16, 16)
failures.append("Test 1: Expected IOError but texture was created successfully")
print("FAIL: Expected IOError but texture was created successfully")
print(f"Texture: {texture}")
except IOError as e:
print("PASS: Got expected IOError:", e)
except Exception as e:
failures.append(f"Test 1: Got unexpected exception type {type(e).__name__}: {e}")
print(f"FAIL: Got unexpected exception type {type(e).__name__}: {e}")
# Test 2: Try to create a texture with an empty filename
print("\nTest 2: Creating texture with empty filename...")
try:
texture = mcrfpy.Texture("", 16, 16)
failures.append("Test 2: Expected IOError but texture was created successfully")
print("FAIL: Expected IOError but texture was created successfully")
except IOError as e:
print("PASS: Got expected IOError:", e)
except Exception as e:
failures.append(f"Test 2: Got unexpected exception type {type(e).__name__}: {e}")
print(f"FAIL: Got unexpected exception type {type(e).__name__}: {e}")
# Test 3: Verify a valid texture still works
print("\nTest 3: Creating texture with valid file (if exists)...")
# (the old path assets/sprites/tileset.png never existed, so this check used to be
# skipped as "INFO"; use a real shipped asset so it actually exercises the good path)
print("\nTest 3: Creating texture with valid file...")
try:
# Try a common test asset path
texture = mcrfpy.Texture("assets/sprites/tileset.png", 16, 16)
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
print("PASS: Valid texture created successfully")
print(f" Sheet dimensions: {texture.sheet_width}x{texture.sheet_height}")
print(f" Sprite count: {texture.sprite_count}")
except IOError as e:
# This is OK if the asset doesn't exist in the test environment
print("INFO: Test texture file not found (expected in test environment):", e)
if texture.sprite_width != 16 or texture.sprite_height != 16:
failures.append(
f"Test 3: expected 16x16 sprites, got "
f"{texture.sprite_width}x{texture.sprite_height}"
)
if texture.sheet_width <= 0 or texture.sheet_height <= 0:
failures.append("Test 3: sheet dimensions should be positive")
if texture.sprite_count != texture.sheet_width * texture.sheet_height:
failures.append(
f"Test 3: sprite_count {texture.sprite_count} != "
f"sheet_width * sheet_height ({texture.sheet_width * texture.sheet_height})"
)
except Exception as e:
failures.append(f"Test 3: Unexpected error with valid path: {type(e).__name__}: {e}")
print(f"FAIL: Unexpected error with valid path: {type(e).__name__}: {e}")
print("\nAll tests completed. No segfault occurred!")
print("\nAll tests completed. No segfault occurred!")
if failures:
for f in failures:
print(f"FAILURE: {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -14,7 +14,7 @@ def test_apply_threshold_basic():
# Create a grid and get a tile layer
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('tile', z_index=0)
layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0))
layer.fill(-1) # Clear all tiles
# Apply threshold - all cells should get tile 5
@ -36,7 +36,7 @@ def test_apply_threshold_partial():
hmap.fill(0.0) # Start with 0
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('tile', z_index=0)
layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0))
layer.fill(-1)
# Apply threshold for range that doesn't match (0.5-1.0 when values are 0.0)
@ -52,7 +52,7 @@ def test_apply_threshold_preserves_outside():
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('tile', z_index=0)
layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0))
layer.fill(99) # Fill with marker value
# Apply threshold for range that doesn't include 0.5
@ -68,7 +68,7 @@ def test_apply_threshold_invalid_range():
hmap = mcrfpy.HeightMap((10, 10))
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('tile', z_index=0)
layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0))
try:
layer.apply_threshold(hmap, (1.0, 0.0), 5) # min > max
@ -85,7 +85,7 @@ def test_apply_threshold_size_mismatch():
hmap = mcrfpy.HeightMap((5, 5)) # Different size
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('tile', z_index=0)
layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0))
try:
layer.apply_threshold(hmap, (0.0, 1.0), 5)
@ -102,7 +102,7 @@ def test_apply_ranges_basic():
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('tile', z_index=0)
layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0))
layer.fill(-1)
# Apply ranges - 0.5 falls in the second range
@ -122,7 +122,7 @@ def test_apply_ranges_later_wins():
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('tile', z_index=0)
layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0))
layer.fill(-1)
# Apply overlapping ranges - later should win
@ -141,7 +141,7 @@ def test_apply_ranges_no_match_unchanged():
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('tile', z_index=0)
layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0))
layer.fill(99)
# Apply ranges that don't match 0.5
@ -160,7 +160,7 @@ def test_apply_ranges_invalid_format():
hmap = mcrfpy.HeightMap((10, 10))
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('tile', z_index=0)
layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0))
# Missing tile index
try:
@ -178,7 +178,7 @@ def test_apply_threshold_boundary():
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('tile', z_index=0)
layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0))
layer.fill(-1)
# Range includes 0.5 exactly
@ -193,7 +193,7 @@ def test_apply_threshold_accepts_list():
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
grid = mcrfpy.Grid(grid_size=(10, 10))
layer = grid.add_layer('tile', z_index=0)
layer = grid.add_layer(mcrfpy.TileLayer(name='tile', z_index=0))
layer.fill(-1)
# Use list instead of tuple
@ -225,5 +225,11 @@ def run_all_tests():
# Run tests directly
run_all_tests()
try:
run_all_tests()
except AssertionError as e:
print(f"FAIL: assertion failed: {e}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -58,9 +58,13 @@ print(f" Modified a1.radius = {a1.radius}")
a1.start_angle = 30
a1.end_angle = 120
assert a1.start_angle == 30, f"Expected start_angle 30, got {a1.start_angle}"
assert a1.end_angle == 120, f"Expected end_angle 120, got {a1.end_angle}"
print(f" Modified a1 angles: {a1.start_angle} to {a1.end_angle}")
a2.color = mcrfpy.Color(255, 0, 255) # Magenta
assert (a2.color.r, a2.color.g, a2.color.b) == (255, 0, 255), \
f"Expected magenta, got {a2.color}"
print(f" Modified a2.color")
# Test 4: Test visibility and opacity
@ -69,25 +73,31 @@ a5 = mcrfpy.Arc(center=(100, 250), radius=30, start_angle=0, end_angle=180,
color=mcrfpy.Color(255, 128, 0), thickness=3)
a5.opacity = 0.5
ui.append(a5)
assert a5.opacity == 0.5, f"Expected opacity 0.5, got {a5.opacity}"
print(f" a5.opacity = {a5.opacity}")
a6 = mcrfpy.Arc(center=(200, 250), radius=30, start_angle=0, end_angle=180,
color=mcrfpy.Color(255, 128, 0), thickness=3)
a6.visible = False
ui.append(a6)
assert a6.visible is False, f"Expected visible False, got {a6.visible}"
print(f" a6.visible = {a6.visible}")
# Test 5: Test z_index
# NOTE: UICollection.append() auto-assigns z_index (last + 10), so z_index must be
# set AFTER appending or the assignment is overwritten by the collection.
print("\nTest 5: Testing z_index...")
a7 = mcrfpy.Arc(center=(350, 250), radius=50, start_angle=0, end_angle=270,
color=mcrfpy.Color(0, 255, 255), thickness=10)
a7.z_index = 100
ui.append(a7)
a7.z_index = 100
a8 = mcrfpy.Arc(center=(370, 250), radius=40, start_angle=0, end_angle=270,
color=mcrfpy.Color(255, 0, 255), thickness=8)
a8.z_index = 50
ui.append(a8)
a8.z_index = 50
assert a7.z_index == 100, f"Expected z_index 100, got {a7.z_index}"
assert a8.z_index == 50, f"Expected z_index 50, got {a8.z_index}"
print(f" a7.z_index = {a7.z_index}, a8.z_index = {a8.z_index}")
# Test 6: Test name property
@ -98,16 +108,32 @@ ui.append(a9)
assert a9.name == "test_arc", f"Expected name 'test_arc', got '{a9.name}'"
print(f" a9.name = '{a9.name}'")
# Test 7: Test get_bounds
print("\nTest 7: Testing get_bounds...")
bounds = a1.get_bounds()
print(f" a1.get_bounds() = {bounds}")
# Test 7: Test bounds
# NOTE: get_bounds() was replaced by the .bounds / .global_bounds properties.
# Each returns (offset: Vector, size: Vector); an arc's extent is the circle that
# contains it, so size == 2 * (radius + thickness/2) on both axes.
print("\nTest 7: Testing bounds...")
offset, size = a1.bounds
expected_extent = 2 * (a1.radius + a1.thickness / 2)
assert size.x == expected_extent, f"Expected bounds width {expected_extent}, got {size.x}"
assert size.y == expected_extent, f"Expected bounds height {expected_extent}, got {size.y}"
print(f" a1.bounds = ({offset}, {size})")
g_offset, g_size = a1.global_bounds
assert (g_size.x, g_size.y) == (size.x, size.y), \
f"global_bounds size {g_size} should match bounds size {size}"
print(f" a1.global_bounds = ({g_offset}, {g_size})")
# Test 8: Test move method
print("\nTest 8: Testing move method...")
old_center = (a1.center.x, a1.center.y)
a1.move(10, 10)
new_center = (a1.center.x, a1.center.y)
assert new_center == (old_center[0] + 10, old_center[1] + 10), \
f"Expected move to offset center by (10, 10): {old_center} -> {new_center}"
# global_bounds tracks the moved arc; local bounds size is unchanged by a move
assert a1.global_bounds[0].x == new_center[0], \
f"global_bounds should follow center x {new_center[0]}, got {a1.global_bounds[0].x}"
print(f" a1 moved from {old_center} to {new_center}")
# Test 9: Negative angle span (draws in reverse)
@ -115,8 +141,13 @@ print("\nTest 9: Testing negative angle span...")
a10 = mcrfpy.Arc(center=(100, 350), radius=40, start_angle=90, end_angle=0,
color=mcrfpy.Color(128, 255, 128), thickness=4)
ui.append(a10)
assert a10.start_angle == 90 and a10.end_angle == 0, \
f"Reverse span should be preserved verbatim, got {a10.start_angle} to {a10.end_angle}"
print(f" Arc 10 (reverse): {a10}")
# All 10 arcs should be in the scene
assert len(ui) == 10, f"Expected 10 arcs in the scene, got {len(ui)}"
# Render a frame and take screenshot
mcrfpy.step(0.01)
automation.screenshot("test_uiarc_result.png")

View file

@ -4,6 +4,9 @@ import mcrfpy
from mcrfpy import automation
import sys
def rgba(c):
return (c.r, c.g, c.b, c.a)
# Create a test scene
test = mcrfpy.Scene("test")
mcrfpy.current_scene = test
@ -55,8 +58,13 @@ print(f" c1.radius = {c1.radius}")
# Check center
center = c2.center
assert (center.x, center.y) == (250, 100), f"Expected center (250, 100), got ({center.x}, {center.y})"
print(f" c2.center = ({center.x}, {center.y})")
assert c3.outline == 5.0, f"Expected outline 5.0, got {c3.outline}"
assert rgba(c4.fill_color) == (0, 0, 0, 0), f"Expected transparent fill, got {rgba(c4.fill_color)}"
print(f" c3.outline = {c3.outline}, c4.fill_color = {rgba(c4.fill_color)}")
# Test 3: Modify properties
print("\nTest 3: Modifying properties...")
c1.radius = 60
@ -64,29 +72,36 @@ assert c1.radius == 60, f"Expected radius 60, got {c1.radius}"
print(f" Modified c1.radius = {c1.radius}")
c2.fill_color = mcrfpy.Color(128, 0, 128) # Purple
print(f" Modified c2.fill_color")
assert rgba(c2.fill_color) == (128, 0, 128, 255), f"Expected purple fill, got {rgba(c2.fill_color)}"
print(f" Modified c2.fill_color = {rgba(c2.fill_color)}")
# Test 4: Test visibility and opacity
print("\nTest 4: Testing visibility and opacity...")
c5 = mcrfpy.Circle(radius=25, center=(100, 200), fill_color=mcrfpy.Color(255, 128, 0))
c5.opacity = 0.5
ui.append(c5)
assert abs(c5.opacity - 0.5) < 1e-6, f"Expected opacity 0.5, got {c5.opacity}"
print(f" c5.opacity = {c5.opacity}")
c6 = mcrfpy.Circle(radius=25, center=(175, 200), fill_color=mcrfpy.Color(255, 128, 0))
c6.visible = False
ui.append(c6)
assert c6.visible is False, f"Expected visible False, got {c6.visible}"
print(f" c6.visible = {c6.visible}")
# Test 5: Test z_index
# NOTE: UICollection.append() assigns z_index by insertion order, so z_index must be
# set AFTER appending (setting it before append is silently overwritten).
print("\nTest 5: Testing z_index...")
c7 = mcrfpy.Circle(radius=40, center=(300, 200), fill_color=mcrfpy.Color(0, 255, 255))
c7.z_index = 100
ui.append(c7)
c7.z_index = 100
c8 = mcrfpy.Circle(radius=30, center=(320, 200), fill_color=mcrfpy.Color(255, 0, 255))
c8.z_index = 50
ui.append(c8)
c8.z_index = 50
assert c7.z_index == 100, f"Expected z_index 100, got {c7.z_index}"
assert c8.z_index == 50, f"Expected z_index 50, got {c8.z_index}"
print(f" c7.z_index = {c7.z_index}, c8.z_index = {c8.z_index}")
# Test 6: Test name property
@ -96,16 +111,21 @@ ui.append(c9)
assert c9.name == "test_circle", f"Expected name 'test_circle', got '{c9.name}'"
print(f" c9.name = '{c9.name}'")
# Test 7: Test get_bounds
print("\nTest 7: Testing get_bounds...")
bounds = c1.get_bounds()
print(f" c1.get_bounds() = {bounds}")
# Test 7: Test bounds
# get_bounds() was replaced by the .bounds / .global_bounds properties.
print("\nTest 7: Testing bounds...")
pos, size = c1.bounds # c1: radius 60, center (100, 100)
assert (pos.x, pos.y) == (40, 40), f"Expected bounds pos (40, 40), got ({pos.x}, {pos.y})"
assert (size.x, size.y) == (120, 120), f"Expected bounds size (120, 120), got ({size.x}, {size.y})"
print(f" c1.bounds = (({pos.x}, {pos.y}), ({size.x}, {size.y}))")
# Test 8: Test move method
print("\nTest 8: Testing move method...")
old_center = (c1.center.x, c1.center.y)
c1.move(10, 10)
new_center = (c1.center.x, c1.center.y)
assert new_center == (old_center[0] + 10, old_center[1] + 10), \
f"Expected move to offset center by (10, 10): {old_center} -> {new_center}"
print(f" c1 moved from {old_center} to {new_center}")
# Render a frame and take screenshot

View file

@ -4,31 +4,48 @@ Test Knowledge Stubs 1 Visibility System
========================================
Tests per-entity visibility tracking with perspective rendering.
API notes (updated for current mcrfpy):
- entity.gridstate -> entity.perspective_map (a 3-state DiscreteMap:
UNKNOWN / DISCOVERED / VISIBLE, exclusive states).
- grid.perspective is an Entity (or None for omniscient), not an int index.
- Cell colors come from a ColorLayer, not GridPoint.color.
- Headless time only advances via mcrfpy.step().
"""
import mcrfpy
from mcrfpy import automation
import sys
import time
print("Knowledge Stubs 1 - Visibility System Test")
print("==========================================")
failures = []
def check(condition, message):
if condition:
print(f" PASS: {message}")
else:
print(f" FAIL: {message}")
failures.append(message)
# Create scene and grid
visibility_test = mcrfpy.Scene("visibility_test")
grid = mcrfpy.Grid(grid_w=20, grid_h=15)
grid = mcrfpy.Grid(grid_size=(20, 15), pos=(50, 50), size=(600, 450))
grid.fill_color = mcrfpy.Color(20, 20, 30) # Dark background
# Add a color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.grid_data.add_layer(color_layer)
# Initialize grid - all walkable and transparent
print("\nInitializing 20x15 grid...")
for y in range(15):
for x in range(20):
cell = grid.at(x, y)
cell = grid.grid_data.at(x, y)
cell.walkable = True
cell.transparent = True
color_layer.set(x, y, mcrfpy.Color(100, 100, 120)) # Floor color
color_layer.set((x, y), mcrfpy.Color(100, 100, 120)) # Floor color
# Create some walls to block vision
print("Adding walls...")
@ -47,126 +64,152 @@ walls = [
for wall_group in walls:
for x, y in wall_group:
cell = grid.at(x, y)
cell = grid.grid_data.at(x, y)
cell.walkable = False
cell.transparent = False
color_layer.set(x, y, mcrfpy.Color(40, 20, 20)) # Wall color
color_layer.set((x, y), mcrfpy.Color(40, 20, 20)) # Wall color
# Create entities
print("\nCreating entities...")
entities = [
mcrfpy.Entity((2, 7)), # Left side
mcrfpy.Entity((18, 7)), # Right side
mcrfpy.Entity((10, 1)), # Top center (above wall)
mcrfpy.Entity(grid_pos=(2, 7)), # Left side
mcrfpy.Entity(grid_pos=(18, 7)), # Right side
mcrfpy.Entity(grid_pos=(10, 1)), # Top center (above wall)
]
for i, entity in enumerate(entities):
entity.sprite_index = 64 + i # @, A, B
grid.entities.append(entity)
print(f" Entity {i}: position ({entity.x}, {entity.y})")
print(f" Entity {i}: cell {entity.grid_x, entity.grid_y}")
# Test 1: Check initial gridstate
print("\nTest 1: Initial gridstate")
# Test 1: Check initial perspective_map (was: gridstate)
print("\nTest 1: Initial perspective_map")
e0 = entities[0]
print(f" Entity 0 gridstate length: {len(e0.gridstate)}")
print(f" Expected: {20 * 15}")
pm0 = e0.perspective_map
print(f" Entity 0 perspective_map size: {pm0.size}")
check(pm0.size == (20, 15), "perspective_map covers all 20x15 cells")
check(pm0.enum_type is mcrfpy.Perspective, "perspective_map uses the Perspective enum")
# Test 2: Update visibility for each entity
print("\nTest 2: Updating visibility for each entity")
counts = []
for i, entity in enumerate(entities):
entity.update_visibility()
# Count visible/discovered cells
visible_count = sum(1 for state in entity.gridstate if state.visible)
discovered_count = sum(1 for state in entity.gridstate if state.discovered)
pm = entity.perspective_map
# VISIBLE and DISCOVERED are exclusive states; "ever seen" = both.
visible_count = pm.count(mcrfpy.Perspective.VISIBLE)
discovered_count = visible_count + pm.count(mcrfpy.Perspective.DISCOVERED)
counts.append((visible_count, discovered_count))
print(f" Entity {i}: {visible_count} visible, {discovered_count} discovered")
check(visible_count > 0, f"Entity {i} sees at least one cell")
check(pm.get(entity.grid_pos) == mcrfpy.Perspective.VISIBLE,
f"Entity {i} sees its own cell")
check(visible_count < 20 * 15, f"Entity {i} does not see the whole map (walls block FOV)")
# Walls must actually block vision: entity 0 (left of the vertical wall at x=10)
# must not see entity 1's cell (18, 7) on the far side of it.
pm0 = entities[0].perspective_map
check(pm0.get((18, 7)) == mcrfpy.Perspective.UNKNOWN,
"Entity 0 cannot see through the wall to (18, 7)")
# ...but the near face of a blocking wall is itself visible.
check(pm0.get((5, 7)) == mcrfpy.Perspective.VISIBLE,
"Entity 0 sees the wall cell (5, 7) that blocks it")
# Test 3: Test perspective property
# NOTE: perspective is now an Entity (or None), not an integer index (#313/#361).
print("\nTest 3: Testing perspective property")
print(f" Initial perspective: {grid.perspective}")
grid.perspective = 0
check(grid.perspective is None, "perspective defaults to None (omniscient)")
check(grid.perspective_enabled is False, "perspective_enabled defaults to False")
grid.perspective = entities[0]
print(f" Set to entity 0: {grid.perspective}")
check(grid.perspective is entities[0], "perspective returns the assigned entity")
check(grid.perspective_enabled is True, "assigning an entity enables perspective mode")
# Test invalid perspective
# Test invalid perspective (an int index is no longer accepted)
try:
grid.perspective = 10 # Out of range
print(" ERROR: Should have raised exception for invalid perspective")
except IndexError as e:
print(f" ✓ Correctly rejected invalid perspective: {e}")
grid.perspective = 10 # Not an Entity
check(False, "invalid perspective should raise TypeError")
except TypeError as e:
print(f" Correctly rejected invalid perspective: {e}")
check(True, "invalid perspective raises TypeError")
# Test 4: Visual demonstration - cycle perspectives via a timer, driven by step()
print("\nTest 4: cycling perspectives")
cycle_order = [None, entities[0], entities[1], entities[2]]
seen_perspectives = []
# Test 4: Visual demonstration
def visual_test(timer, runtime):
print(f"\nVisual test - cycling perspectives at {runtime}ms")
idx = len(seen_perspectives)
if idx >= len(cycle_order):
timer.stop()
return
grid.perspective = cycle_order[idx]
seen_perspectives.append(grid.perspective)
label = "omniscient" if grid.perspective is None else f"Entity {idx - 1}"
print(f" Switched to {label} perspective at {runtime}ms")
automation.screenshot(f"visibility_perspective_{idx}.png")
# Cycle through perspectives
current = grid.perspective
if current == -1:
grid.perspective = 0
print(" Switched to Entity 0 perspective")
elif current == 0:
grid.perspective = 1
print(" Switched to Entity 1 perspective")
elif current == 1:
grid.perspective = 2
print(" Switched to Entity 2 perspective")
else:
grid.perspective = -1
print(" Switched to omniscient view")
# Take screenshot
from mcrfpy import automation
filename = f"visibility_perspective_{grid.perspective}.png"
automation.screenshot(filename)
print(f" Screenshot saved: {filename}")
# Test 5: Movement and visibility update
print("\nTest 5: Movement and visibility update")
entity = entities[0]
print(f" Entity 0 initial position: ({entity.x}, {entity.y})")
# Move entity
entity.x = 8
entity.y = 7
print(f" Moved to: ({entity.x}, {entity.y})")
# Update visibility
entity.update_visibility()
visible_count = sum(1 for state in entity.gridstate if state.visible)
print(f" Visible cells after move: {visible_count}")
# Set up UI
# Set scene and UI
ui = visibility_test.children
ui.append(grid)
grid.pos = (50, 50)
grid.size = (600, 450) # 20*30, 15*30
# Add title
title = mcrfpy.Caption(pos=(200, 10), text="Knowledge Stubs 1 - Visibility Test")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
# Add info
info = mcrfpy.Caption(pos=(50, 520), text="Perspective: -1 (omniscient)")
info = mcrfpy.Caption(pos=(50, 520), text="Perspective: None (omniscient)")
info.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(info)
# Add legend
legend = mcrfpy.Caption(pos=(50, 540), text="Black=Never seen, Dark gray=Discovered, Normal=Visible")
legend.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend)
# Set scene
visibility_test.activate()
# Set timer to cycle perspectives
cycle_timer = mcrfpy.Timer("cycle", visual_test, 2000) # Every 2 seconds
cycle_timer = mcrfpy.Timer("cycle", visual_test, 100)
# Headless: mcrfpy.step() is the only clock; one timer event per step.
for _ in range(len(cycle_order) + 1):
mcrfpy.step(0.1)
print("\nTest complete! Visual demo cycling through perspectives...")
print("Perspectives will cycle: Omniscient → Entity 0 → Entity 1 → Entity 2 → Omniscient")
check(seen_perspectives == cycle_order,
f"cycled through all perspectives (got {len(seen_perspectives)} of {len(cycle_order)})")
# Quick test to exit after screenshots
def exit_timer_cb(timer, runtime):
print("\nExiting after demo...")
sys.exit(0)
# Test 5: Movement and visibility update
print("\nTest 5: Movement and visibility update")
entity = entities[0]
# NOTE: entity.x/.y are pixel draw coordinates; the logical cell is grid_x/grid_y.
print(f" Entity 0 initial cell: {entity.grid_x, entity.grid_y}")
before_visible, _ = counts[0]
exit_timer_obj = mcrfpy.Timer("exit", exit_timer_cb, 10000, once=True) # Exit after 10 seconds
# Move entity next to the horizontal wall opening
entity.grid_pos = (8, 7)
print(f" Moved to: {entity.grid_x, entity.grid_y}")
check((entity.grid_x, entity.grid_y) == (8, 7), "entity moved to (8, 7)")
# Update visibility
entity.update_visibility()
pm0 = entity.perspective_map
visible_count = pm0.count(mcrfpy.Perspective.VISIBLE)
ever_seen = visible_count + pm0.count(mcrfpy.Perspective.DISCOVERED)
print(f" Visible cells after move: {visible_count} (ever seen: {ever_seen})")
check(visible_count > 0, "entity sees cells from its new position")
check(pm0.get((8, 7)) == mcrfpy.Perspective.VISIBLE, "entity sees its new cell")
check(ever_seen >= before_visible,
"memory is retained across movement (discovered cells persist)")
# Cells visible from the old spot but not the new one must be remembered, not forgotten.
check(pm0.get((2, 7)) in (mcrfpy.Perspective.DISCOVERED, mcrfpy.Perspective.VISIBLE),
"old position remains at least DISCOVERED after moving away")
print()
if failures:
print(f"FAIL: {len(failures)} check(s) failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -9,21 +9,27 @@ WALL_COLOR = mcrfpy.Color(60, 30, 30)
FLOOR_COLOR = mcrfpy.Color(200, 200, 220)
PATH_COLOR = mcrfpy.Color(100, 255, 100)
failures = []
timer_fired = False
# Create scene
visual_test = mcrfpy.Scene("visual_test")
# Create grid
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(50, 50), size=(250, 250))
grid.fill_color = mcrfpy.Color(0, 0, 0)
# Add color layer for cell coloring
color_layer = grid.add_layer("color", z_index=-1)
# Add color layer for cell coloring (GridPoint has no .color anymore; use a ColorLayer)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.add_layer(color_layer)
def check_render(timer, runtime):
"""Timer callback to verify rendering"""
global timer_fired
timer_fired = True
print(f"\nTimer fired after {runtime}ms")
# Take screenshot
# Take screenshot (this is what forces a render in headless mode)
from mcrfpy import automation
automation.screenshot("visual_path_test.png")
print("Screenshot saved as visual_path_test.png")
@ -31,43 +37,62 @@ def check_render(timer, runtime):
# Sample some path cells to verify colors
print("\nSampling path cell colors from grid:")
for x, y in [(1, 1), (2, 2), (3, 3)]:
color = color_layer.at(x, y)
color = color_layer.at((x, y))
print(f" Cell ({x},{y}): color=({color.r}, {color.g}, {color.b})")
if (color.r, color.g, color.b) != (PATH_COLOR.r, PATH_COLOR.g, PATH_COLOR.b):
failures.append(f"cell ({x},{y}) is not PATH_COLOR: "
f"({color.r}, {color.g}, {color.b})")
sys.exit(0)
# A cell off the path must still be floor-colored
off = color_layer.at((0, 4))
if (off.r, off.g, off.b) != (FLOOR_COLOR.r, FLOOR_COLOR.g, FLOOR_COLOR.b):
failures.append(f"off-path cell (0,4) is not FLOOR_COLOR: "
f"({off.r}, {off.g}, {off.b})")
timer.stop()
# Initialize all cells as floor
print("Initializing grid...")
for y in range(5):
for x in range(5):
grid.at(x, y).walkable = True
color_layer.set(x, y, FLOOR_COLOR)
color_layer.set((x, y), FLOOR_COLOR)
# Create entities
e1 = mcrfpy.Entity((0, 0), grid=grid)
e2 = mcrfpy.Entity((4, 4), grid=grid)
e1 = mcrfpy.Entity(grid_pos=(0, 0))
e2 = mcrfpy.Entity(grid_pos=(4, 4))
grid.entities.append(e1)
grid.entities.append(e2)
e1.sprite_index = 64 # @
e2.sprite_index = 69 # E
print(f"Entity 1 at ({e1.x}, {e1.y})")
print(f"Entity 2 at ({e2.x}, {e2.y})")
# .x/.y are pixel coords now; .cell_pos is the logical cell
print(f"Entity 1 at ({e1.cell_pos.x}, {e1.cell_pos.y})")
print(f"Entity 2 at ({e2.cell_pos.x}, {e2.cell_pos.y})")
# Get path
path = e1.path_to(int(e2.x), int(e2.y))
path = e1.path_to(int(e2.cell_pos.x), int(e2.cell_pos.y))
print(f"\nPath from E1 to E2: {path}")
if not path:
failures.append("path_to() returned no path between (0,0) and (4,4)")
else:
if path[-1] != (4, 4):
failures.append(f"path does not end at target: {path[-1]}")
for cell in [(1, 1), (2, 2), (3, 3)]:
if cell not in path:
failures.append(f"expected diagonal path cell {cell} missing from path")
# Color the path
if path:
print("\nColoring path cells green...")
for x, y in path:
color_layer.set(x, y, PATH_COLOR)
color_layer.set((x, y), PATH_COLOR)
print(f" Set ({x},{y}) to green")
# Set up UI
ui = visual_test.children
ui.append(grid)
grid.pos = (50, 50)
grid.size = (250, 250)
# Add title
title = mcrfpy.Caption(pos=(50, 10), text="Path Visualization Test")
@ -80,4 +105,19 @@ visual_test.activate()
# Set timer to check rendering
check_timer = mcrfpy.Timer("check", check_render, 500, once=True)
print("\nScene ready. Path should be visible in green.")
print("\nScene ready. Path should be visible in green.")
# Headless: mcrfpy.step() is the clock; the timer never fires on its own.
for _ in range(20):
mcrfpy.step(0.1)
if not timer_fired:
failures.append("timer callback never fired; render checks did not run")
if failures:
for f in failures:
print(f"FAIL: {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,116 +1,123 @@
#!/usr/bin/env python3
"""Test for Entity class - Related to issue #73 (index() method)"""
import mcrfpy
from datetime import datetime
import sys
print("Test script starting...")
failures = []
def check(cond, msg):
if cond:
print(f":) {msg}")
else:
print(f"X {msg}")
failures.append(msg)
def test_Entity():
"""Test Entity class and index() method for collection removal"""
# Create test scene with grid
entity_test = mcrfpy.Scene("entity_test")
entity_test.activate()
ui = entity_test.children
# Create a grid
grid = mcrfpy.Grid(10, 10,
mcrfpy.default_texture,
mcrfpy.Vector(10, 10),
mcrfpy.Vector(400, 400))
# Create a grid (current ctor: grid_size/pos/size keywords)
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(10, 10), size=(400, 400),
texture=texture)
ui.append(grid)
entities = grid.entities
# Create multiple entities
entity1 = mcrfpy.Entity(mcrfpy.Vector(2, 2), mcrfpy.default_texture, 0, grid)
entity2 = mcrfpy.Entity(mcrfpy.Vector(5, 5), mcrfpy.default_texture, 1, grid)
entity3 = mcrfpy.Entity(mcrfpy.Vector(7, 7), mcrfpy.default_texture, 2, grid)
# Create multiple entities (current ctor: grid_pos=, texture=, sprite_index=)
entity1 = mcrfpy.Entity(grid_pos=(2, 2), texture=texture, sprite_index=0)
entity2 = mcrfpy.Entity(grid_pos=(5, 5), texture=texture, sprite_index=1)
entity3 = mcrfpy.Entity(grid_pos=(7, 7), texture=texture, sprite_index=2)
entities.append(entity1)
entities.append(entity2)
entities.append(entity3)
print(f"Created {len(entities)} entities")
# Test entity properties
try:
print(f" Entity1 pos: {entity1.pos}")
print(f" Entity1 draw_pos: {entity1.draw_pos}")
print(f" Entity1 sprite_index: {entity1.sprite_index}")
# Modify properties
entity1.pos = mcrfpy.Vector(3, 3)
entity1.sprite_index = 5
print(" Entity properties modified")
except Exception as e:
print(f"X Entity property access failed: {e}")
# Test gridstate access
try:
gridstate = entity2.gridstate
print(" Entity gridstate accessible")
# Test at() method
point_state = entity2.at()#.at(0, 0)
print(" Entity at() method works")
except Exception as e:
print(f"X Entity gridstate/at() failed: {e}")
print(f"Created {len(entities)} entities")
check(len(entities) == 3, "3 entities in collection after append")
# Test entity properties
print(f" Entity1 grid_pos: {entity1.grid_pos}")
print(f" Entity1 pos: {entity1.pos}")
print(f" Entity1 draw_pos: {entity1.draw_pos}")
print(f" Entity1 sprite_index: {entity1.sprite_index}")
check(entity1.sprite_index == 0, "entity1.sprite_index reads back as 0")
check((entity1.grid_pos.x, entity1.grid_pos.y) == (2, 2),
"entity1.grid_pos reads back as (2, 2)")
# Modify properties
entity1.grid_pos = mcrfpy.Vector(3, 3)
entity1.sprite_index = 5
check((entity1.grid_pos.x, entity1.grid_pos.y) == (3, 3),
"entity1.grid_pos writable")
check(entity1.sprite_index == 5, "entity1.sprite_index writable")
# Entity association with a grid: entity.grid is the shared GridData
# (current contract, #313/#361 -- it is NOT the Grid view object)
check(isinstance(entity1.grid, mcrfpy.GridData),
"entity.grid returns the GridData")
check(entity1.grid is grid.grid_data,
"entity.grid is the same GridData as grid.grid_data")
# Test perspective/visibility access (was: entity.gridstate)
pmap = entity2.perspective_map
check(pmap is not None, "Entity perspective_map accessible")
entity2.update_visibility()
# Test at() method - returns the GridPoint if visible to this entity
point_state = entity2.at(5, 5)
check(point_state is not None, "Entity at() returns own (visible) cell")
check(point_state is None or tuple(point_state.grid_pos) == (5, 5),
"Entity at(5, 5) returns the GridPoint for (5, 5)")
# Test index() method (Issue #73)
print("\nTesting index() method (Issue #73)...")
try:
# Try to find entity2's index
index = entity2.index()
print(f":) index() method works: entity2 is at index {index}")
# Verify by checking collection
if entities[index] == entity2:
print("✓ Index is correct")
else:
print("✗ Index mismatch")
# Remove using index
entities.remove(index)
print(f":) Removed entity using index, now {len(entities)} entities")
except AttributeError:
print("✗ index() method not implemented (Issue #73)")
# Try manual removal as workaround
try:
for i in range(len(entities)):
if entities[i] == entity2:
entities.remove(i)
print(":) Manual removal workaround succeeded")
break
except:
print("✗ Manual removal also failed")
except Exception as e:
print(f":) index() method error: {e}")
index = entity2.index()
print(f":) index() method works: entity2 is at index {index}")
check(index == 1, "entity2.index() == 1")
check(entities[index] == entity2, "Index is correct (entities[index] is entity2)")
# Remove using the index reported by index() (Issue #73's purpose)
removed = entities.pop(index)
check(removed == entity2, "pop(index) returned entity2")
check(len(entities) == 2, f"Removed entity using index, now {len(entities)} entities")
check(entity1.index() == 0 and entity3.index() == 1,
"index() reflects collection after removal")
# Test EntityCollection iteration
try:
positions = []
for entity in entities:
positions.append(entity.pos)
print(f":) Entity iteration works: {len(positions)} entities")
except Exception as e:
print(f"X Entity iteration failed: {e}")
positions = [e.grid_pos for e in entities]
check(len(positions) == 2, f"Entity iteration works: {len(positions)} entities")
check([(p.x, p.y) for p in positions] == [(3, 3), (7, 7)],
"Iteration yields the surviving entities in order")
# Test EntityCollection extend (Issue #27)
try:
new_entities = [
mcrfpy.Entity(mcrfpy.Vector(1, 1), mcrfpy.default_texture, 3, grid),
mcrfpy.Entity(mcrfpy.Vector(9, 9), mcrfpy.default_texture, 4, grid)
]
entities.extend(new_entities)
print(f":) extend() method works: now {len(entities)} entities")
except AttributeError:
print("✗ extend() method not implemented (Issue #27)")
except Exception as e:
print(f"X extend() method error: {e}")
# Skip screenshot in headless mode
print("PASS")
new_entities = [
mcrfpy.Entity(grid_pos=(1, 1), texture=texture, sprite_index=3),
mcrfpy.Entity(grid_pos=(9, 9), texture=texture, sprite_index=4)
]
entities.extend(new_entities)
check(len(entities) == 4, f"extend() method works: now {len(entities)} entities")
check(new_entities[1].index() == 3, "extended entity reports its index()")
# remove(entity) is the object-based counterpart to index()/pop()
entities.remove(entity3)
check(len(entities) == 3, "remove(entity) drops the entity")
# Run test immediately in headless mode
print("Running test immediately...")
test_Entity()
print("Test completed.")
if failures:
print(f"FAIL: {len(failures)} check(s) failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,69 +1,94 @@
#!/usr/bin/env python3
"""Test for Sprite texture methods - Related to issue #19"""
import mcrfpy
import sys
print("Testing Sprite texture methods (Issue #19)...")
failures = []
def check(condition, message):
if condition:
print(f"PASS: {message}")
else:
print(f"FAIL: {message}")
failures.append(message)
# Create test scene
sprite_texture_test = mcrfpy.Scene("sprite_texture_test")
sprite_texture_test.activate()
ui = sprite_texture_test.children
# Create sprites
# Based on sprite2 syntax: Sprite(x, y, texture, sprite_index, scale)
sprite1 = mcrfpy.Sprite(10, 10, mcrfpy.default_texture, 0, 2.0)
sprite2 = mcrfpy.Sprite(100, 10, mcrfpy.default_texture, 5, 2.0)
# Current Sprite ctor: Sprite(pos=None, texture=None, sprite_index=0, **kwargs)
sprite1 = mcrfpy.Sprite(pos=(10, 10), texture=mcrfpy.default_texture, sprite_index=0, scale=2.0)
sprite2 = mcrfpy.Sprite(pos=(100, 10), texture=mcrfpy.default_texture, sprite_index=5, scale=2.0)
ui.append(sprite1)
ui.append(sprite2)
# Test getting texture
try:
texture1 = sprite1.texture
texture2 = sprite2.texture
print(f"✓ Got textures: {texture1}, {texture2}")
if texture2 == mcrfpy.default_texture:
print("✓ Texture matches default_texture")
except Exception as e:
print(f"✗ Failed to get texture: {e}")
check(len(ui) == 2, "both sprites appended to the scene")
# Test setting texture (Issue #19 - get/set texture methods)
try:
# This should fail as texture is read-only currently
sprite1.texture = mcrfpy.default_texture
print("✗ Texture setter should not exist (Issue #19)")
except AttributeError:
print("✓ Texture is read-only (Issue #19 requests setter)")
except Exception as e:
print(f"✗ Unexpected error setting texture: {e}")
# Test getting texture
texture1 = sprite1.texture
texture2 = sprite2.texture
print(f"Got textures: {texture1}, {texture2}")
check(isinstance(texture1, mcrfpy.Texture) and isinstance(texture2, mcrfpy.Texture),
"Sprite.texture returns a Texture")
# Texture wrappers do not compare by identity; compare the underlying source.
check(texture2.source == mcrfpy.default_texture.source,
"sprite texture is the default texture that was passed in")
# Test setting texture (Issue #19 asked for a texture setter; it now EXISTS,
# so the old "texture must be read-only" expectation is inverted here.)
other_texture = mcrfpy.Texture("assets/kenney_TD_MR_IP.png", 16, 16)
sprite1.sprite_index = 0
sprite1.texture = other_texture
check(sprite1.texture.source == other_texture.source,
"Sprite.texture setter swaps the texture (Issue #19)")
check(sprite1.bounds[1].x == other_texture.sprite_width * sprite1.scale,
"swapped texture drives the sprite's rendered size")
# Restore the default texture
sprite1.texture = mcrfpy.default_texture
check(sprite1.texture.source == mcrfpy.default_texture.source,
"Sprite.texture setter restores the default texture")
# Test sprite_index property
try:
print(f"Sprite2 sprite_index: {sprite2.sprite_index}")
sprite2.sprite_index = 10
print(f"sprite_index set to: {sprite2.sprite_index}")
except Exception as e:
print(f"sprite_index property failed: {e}")
print(f"Sprite2 sprite_index: {sprite2.sprite_index}")
check(sprite2.sprite_index == 5, "sprite_index reflects the constructor argument")
sprite2.sprite_index = 10
print(f"sprite_index set to: {sprite2.sprite_index}")
check(sprite2.sprite_index == 10, "sprite_index setter takes effect")
# Test sprite index validation (Issue #33)
try:
# Try to set invalid sprite index
sprite2.sprite_index = 9999
print("Should validate sprite index against texture range (Issue #33)")
except Exception as e:
check(False, "out-of-range sprite_index must raise (Issue #33)")
except ValueError as e:
print(f"Sprite index validation works: {e}")
check(True, "out-of-range sprite_index raises ValueError (Issue #33)")
check(sprite2.sprite_index == 10, "rejected sprite_index leaves the old value intact")
# Create grid of sprites to show different indices
y_offset = 100
for i in range(12): # Show first 12 sprites
sprite = mcrfpy.Sprite(10 + (i % 6) * 40, y_offset + (i // 6) * 40,
mcrfpy.default_texture, i, 2.0)
sprite = mcrfpy.Sprite(pos=(10 + (i % 6) * 40, y_offset + (i // 6) * 40),
texture=mcrfpy.default_texture, sprite_index=i, scale=2.0)
ui.append(sprite)
caption = mcrfpy.Caption(mcrfpy.Vector(10, 200),
text="Issue #19: Sprites need texture setter",
fill_color=mcrfpy.Color(255, 255, 255))
check(len(ui) == 14, "12 additional sprites appended")
check(all(ui[2 + i].sprite_index == i for i in range(12)),
"each sprite in the grid kept its own sprite_index")
caption = mcrfpy.Caption(pos=(10, 200),
text="Issue #19: Sprite texture setter",
fill_color=mcrfpy.Color(255, 255, 255))
ui.append(caption)
print("PASS")
if failures:
print(f"FAILED ({len(failures)} checks): {failures}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,7 +1,17 @@
#!/usr/bin/env python3
"""Test for UICollection - Related to issue #69 (Sequence Protocol)"""
import mcrfpy
from datetime import datetime
import sys
failures = []
def check(label, condition, detail=""):
if condition:
print(f"[ok] {label}")
else:
msg = f"{label}{': ' + detail if detail else ''}"
print(f"[FAIL] {msg}")
failures.append(msg)
def test_UICollection():
"""Test UICollection sequence protocol compliance"""
@ -9,96 +19,70 @@ def test_UICollection():
collection_test = mcrfpy.Scene("collection_test")
collection_test.activate()
ui = collection_test.children
# Add various UI elements
frame = mcrfpy.Frame(pos=(10, 10), size=(100, 100))
caption = mcrfpy.Caption(pos=(120, 10), text="Test")
caption.name = "Test" # find() searches by name, not text
# Skip sprite for now since it requires a texture
ui.append(frame)
ui.append(caption)
print("Testing UICollection sequence protocol (Issue #69)...")
# Test len()
try:
length = len(ui)
print(f"✓ len() works: {length} items")
except Exception as e:
print(f"✗ len() failed: {e}")
# Test indexing
try:
item0 = ui[0]
item1 = ui[1]
print(f"✓ Indexing works: [{type(item0).__name__}, {type(item1).__name__}]")
# Test negative indexing
last_item = ui[-1]
print(f"✓ Negative indexing works: ui[-1] = {type(last_item).__name__}")
except Exception as e:
print(f"✗ Indexing failed: {e}")
# Test slicing (if implemented)
try:
slice_items = ui[0:2]
print(f"✓ Slicing works: got {len(slice_items)} items")
except Exception as e:
print(f"✗ Slicing not implemented (Issue #69): {e}")
# Test iteration
try:
types = []
for item in ui:
types.append(type(item).__name__)
print(f"✓ Iteration works: {types}")
except Exception as e:
print(f"✗ Iteration failed: {e}")
# Test contains
try:
if frame in ui:
print("'in' operator works")
else:
print("'in' operator returned False for existing item")
except Exception as e:
print(f"'in' operator not implemented (Issue #69): {e}")
# Test remove
try:
ui.remove(1) # Remove caption
print(f"✓ remove() works, now {len(ui)} items")
except Exception as e:
print(f"✗ remove() failed: {e}")
# Test type preservation (Issue #76)
try:
# Add a frame with children to test nested collections
parent_frame = mcrfpy.Frame(pos=(250, 10), size=(200, 200),
fill_color=mcrfpy.Color(200, 200, 200))
child_caption = mcrfpy.Caption(pos=(10, 10), text="Child")
parent_frame.children.append(child_caption)
ui.append(parent_frame)
# Check if type is preserved when retrieving
retrieved = ui[-1]
if type(retrieved).__name__ == "Frame":
print("✓ Type preservation works")
else:
print(f"✗ Type not preserved (Issue #76): got {type(retrieved).__name__}")
except Exception as e:
print(f"✗ Type preservation test failed: {e}")
# Test find by name (Issue #41 - not yet implemented)
try:
found = ui.find("Test")
print(f"✓ find() method works: {type(found).__name__}")
except AttributeError:
print("✗ find() method not implemented (Issue #41)")
except Exception as e:
print(f"✗ find() method error: {e}")
print("PASS")
# Run test immediately
test_UICollection()
print("Testing UICollection sequence protocol (Issue #69)...")
# Test len()
check("len() works", len(ui) == 2, f"expected 2, got {len(ui)}")
# Test indexing
check("indexing works",
type(ui[0]).__name__ == "Frame" and type(ui[1]).__name__ == "Caption",
f"got [{type(ui[0]).__name__}, {type(ui[1]).__name__}]")
# Test negative indexing
check("negative indexing works", type(ui[-1]).__name__ == "Caption",
f"ui[-1] = {type(ui[-1]).__name__}")
# Test slicing
slice_items = ui[0:2]
check("slicing works", len(slice_items) == 2, f"got {len(slice_items)} items")
# Test iteration
types = [type(item).__name__ for item in ui]
check("iteration works", types == ["Frame", "Caption"], f"got {types}")
# Test contains
check("'in' operator works", frame in ui, "'in' returned False for existing item")
# Test remove -- current API takes the Drawable itself (list.remove semantics),
# not an index. pop(index) is the index-based removal.
ui.remove(caption)
check("remove(element) works", len(ui) == 1 and caption not in ui,
f"now {len(ui)} items")
# Test type preservation (Issue #76)
parent_frame = mcrfpy.Frame(pos=(250, 10), size=(200, 200),
fill_color=mcrfpy.Color(200, 200, 200))
child_caption = mcrfpy.Caption(pos=(10, 10), text="Child")
parent_frame.children.append(child_caption)
ui.append(parent_frame)
retrieved = ui[-1]
check("type preservation works", type(retrieved).__name__ == "Frame",
f"got {type(retrieved).__name__} (Issue #76)")
check("retrieved object identity preserved", retrieved is parent_frame)
# Test find by name (Issue #41)
parent_frame.name = "parent"
found = ui.find("parent")
check("find() by name works", found is parent_frame,
f"got {found!r}")
check("find() returns None for missing name", ui.find("nonexistent") is None)
test_UICollection()
if failures:
print(f"FAIL ({len(failures)} check(s) failed)")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -1,116 +1,249 @@
#!/usr/bin/env python3
"""Validate screenshot functionality and analyze pixel data"""
"""Validate screenshot functionality and analyze pixel data
The headless renderer must produce a PNG that actually contains the drawn
content -- not a transparent/blank image. This test draws frames of known
colors at known positions, screenshots them, decodes the PNG and asserts the
expected color is present at the expected pixel. It also covers filename edge
cases (spaces, empty, very long).
"""
import mcrfpy
from mcrfpy import automation
from datetime import datetime
import os
import struct
import sys
import zlib
FAILURES = []
OUT_DIR = "test_screenshots"
def check(cond, msg):
if not cond:
print("FAIL: %s" % msg)
FAILURES.append(msg)
# --------------------------------------------------------------------------- #
# Minimal PNG reader (stdlib only): 8-bit RGB/RGBA, non-interlaced -- what the
# engine's screenshot encoder emits. (Same reader as
# tests/regression/issue_355_camera_rotation_hit_test.py)
# --------------------------------------------------------------------------- #
def read_png(path):
with open(path, "rb") as f:
data = f.read()
assert data[:8] == b"\x89PNG\r\n\x1a\n", "not a PNG"
pos = 8
idat = b""
width = height = depth = color = None
while pos < len(data):
(length,) = struct.unpack(">I", data[pos:pos + 4])
ctype = data[pos + 4:pos + 8]
chunk = data[pos + 8:pos + 8 + length]
pos += 12 + length
if ctype == b"IHDR":
width, height, depth, color, _, _, interlace = struct.unpack(">IIBBBBB", chunk)
assert depth == 8, "unexpected bit depth %d" % depth
assert color in (2, 6), "unexpected color type %d" % color
assert interlace == 0, "interlaced PNG not supported"
elif ctype == b"IDAT":
idat += chunk
elif ctype == b"IEND":
break
raw = zlib.decompress(idat)
channels = 3 if color == 2 else 4
stride = width * channels
out = bytearray(height * stride)
prev = bytearray(stride)
p = 0
for y in range(height):
filt = raw[p]
p += 1
line = bytearray(raw[p:p + stride])
p += stride
if filt == 1: # Sub
for i in range(channels, stride):
line[i] = (line[i] + line[i - channels]) & 0xFF
elif filt == 2: # Up
for i in range(stride):
line[i] = (line[i] + prev[i]) & 0xFF
elif filt == 3: # Average
for i in range(stride):
a = line[i - channels] if i >= channels else 0
line[i] = (line[i] + ((a + prev[i]) >> 1)) & 0xFF
elif filt == 4: # Paeth
for i in range(stride):
a = line[i - channels] if i >= channels else 0
b = prev[i]
c = prev[i - channels] if i >= channels else 0
pa, pb, pc = abs(b - c), abs(a - c), abs(a + b - 2 * c)
pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
line[i] = (line[i] + pr) & 0xFF
elif filt != 0:
raise AssertionError("bad PNG filter %d" % filt)
out[y * stride:(y + 1) * stride] = line
prev = line
return width, height, channels, bytes(out)
class Image:
def __init__(self, path):
self.w, self.h, self.ch, self.px = read_png(path)
def at(self, x, y):
i = (y * self.w + x) * self.ch
return tuple(self.px[i:i + 3]), (self.px[i + 3] if self.ch == 4 else 255)
def test_screenshot_validation():
"""Create visible content and validate screenshot output"""
print("=== Screenshot Validation Test ===\n")
# Create a scene with bright, visible content
screenshot_validation = mcrfpy.Scene("screenshot_validation")
screenshot_validation.activate()
ui = screenshot_validation.children
# Create multiple colorful elements to ensure visibility
print("Creating UI elements...")
# Light gray background frame, added FIRST so it is behind everything.
# (The old version appended it last and tried ui.remove(index) to reorder;
# UICollection.remove takes a Drawable, not an index -- insert(0, ...) is
# the supported way to put a drawable at the back.)
background = mcrfpy.Frame(pos=(0, 0), size=(1024, 768),
fill_color=mcrfpy.Color(200, 200, 200))
ui.append(background)
# Bright red frame with white outline
frame1 = mcrfpy.Frame(pos=(50, 50), size=(300, 200),
fill_color=mcrfpy.Color(255, 0, 0), # Bright red
outline_color=mcrfpy.Color(255, 255, 255), # White
outline=5.0)
fill_color=mcrfpy.Color(255, 0, 0), # Bright red
outline_color=mcrfpy.Color(255, 255, 255), # White
outline=5.0)
ui.append(frame1)
print("Added red frame at (50, 50)")
# Bright green frame
frame2 = mcrfpy.Frame(pos=(400, 50), size=(300, 200),
fill_color=mcrfpy.Color(0, 255, 0), # Bright green
outline_color=mcrfpy.Color(0, 0, 0), # Black
outline=3.0)
fill_color=mcrfpy.Color(0, 255, 0), # Bright green
outline_color=mcrfpy.Color(0, 0, 0), # Black
outline=3.0)
ui.append(frame2)
print("Added green frame at (400, 50)")
# Blue frame
frame3 = mcrfpy.Frame(pos=(50, 300), size=(300, 200),
fill_color=mcrfpy.Color(0, 0, 255), # Bright blue
outline_color=mcrfpy.Color(255, 255, 0), # Yellow
outline=4.0)
fill_color=mcrfpy.Color(0, 0, 255), # Bright blue
outline_color=mcrfpy.Color(255, 255, 0), # Yellow
outline=4.0)
ui.append(frame3)
print("Added blue frame at (50, 300)")
# Add text captions
caption1 = mcrfpy.Caption(pos=(60, 60),
text="RED FRAME TEST",
fill_color=mcrfpy.Color(255, 255, 255))
# Add text captions (Caption.font is read-only as of #320 -- set in ctor)
caption1 = mcrfpy.Caption(pos=(10, 10), font=mcrfpy.default_font,
text="RED FRAME TEST",
fill_color=mcrfpy.Color(255, 255, 255))
caption1.font_size = 24
frame1.children.append(caption1)
frame1.children.append(caption1) # child coords are frame-relative
caption2 = mcrfpy.Caption(pos=(410, 60),
text="GREEN FRAME TEST",
fill_color=mcrfpy.Color(0, 0, 0))
caption2 = mcrfpy.Caption(pos=(410, 60), font=mcrfpy.default_font,
text="GREEN FRAME TEST",
fill_color=mcrfpy.Color(0, 0, 0))
caption2.font_size = 24
ui.append(caption2)
caption3 = mcrfpy.Caption(pos=(60, 310),
text="BLUE FRAME TEST",
fill_color=mcrfpy.Color(255, 255, 0))
caption3 = mcrfpy.Caption(pos=(60, 310), font=mcrfpy.default_font,
text="BLUE FRAME TEST",
fill_color=mcrfpy.Color(255, 255, 0))
caption3.font_size = 24
ui.append(caption3)
# White background frame to ensure non-transparent background
background = mcrfpy.Frame(pos=(0, 0), size=(1024, 768),
fill_color=mcrfpy.Color(200, 200, 200)) # Light gray
# Insert at beginning so it's behind everything
ui.remove(len(ui) - 1) # Remove to re-add at start
ui.append(background)
# Re-add all other elements on top
for frame in [frame1, frame2, frame3, caption2, caption3]:
ui.append(frame)
print(f"\nTotal UI elements: {len(ui)}")
# Take multiple screenshots with different names
print("\nTotal UI elements: %d" % len(ui))
check(len(ui) == 6, "expected 6 top-level drawables, got %d" % len(ui))
# Take multiple screenshots with different names (incl. spaces in filename)
os.makedirs(OUT_DIR, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
screenshots = [
f"validate_screenshot_basic_{timestamp}.png",
f"validate_screenshot_with_spaces {timestamp}.png",
f"validate_screenshot_final_{timestamp}.png"
os.path.join(OUT_DIR, "validate_screenshot_basic_%s.png" % timestamp),
os.path.join(OUT_DIR, "validate_screenshot_with_spaces %s.png" % timestamp),
os.path.join(OUT_DIR, "validate_screenshot_final_%s.png" % timestamp),
]
print("\nTaking screenshots...")
for i, filename in enumerate(screenshots):
result = automation.screenshot(filename)
print(f"Screenshot {i+1}: {filename} - Result: {result}")
# Test invalid cases
print("Screenshot %d: %s - Result: %s" % (i + 1, filename, result))
check(result is True, "screenshot(%r) did not return True" % filename)
check(os.path.exists(filename) and os.path.getsize(filename) > 0,
"screenshot(%r) produced no file" % filename)
# --- The actual point of this test: the PNG must contain the drawn content,
# --- not a transparent/blank image.
if not FAILURES:
img = Image(screenshots[0])
print("\nDecoded %dx%d (%d channels)" % (img.w, img.h, img.ch))
expected = [
((900, 700), (200, 200, 200), "light gray background"),
((200, 150), (255, 0, 0), "red frame interior"),
((550, 150), (0, 255, 0), "green frame interior"),
((200, 400), (0, 0, 255), "blue frame interior"),
# Outlines are stroked OUTWARD from the frame bounds, so sample just
# left of x=50: red frame outline is 5px (45..50), blue frame's 4px (46..50).
((47, 150), (255, 255, 255), "white outline of red frame"),
((47, 400), (255, 255, 0), "yellow outline of blue frame"),
]
for (x, y), want, what in expected:
if x >= img.w or y >= img.h:
check(False, "sample point (%d,%d) outside %dx%d image" % (x, y, img.w, img.h))
continue
rgb, alpha = img.at(x, y)
check(rgb == want,
"%s: pixel (%d,%d) is %s, expected %s" % (what, x, y, rgb, want))
check(alpha == 255,
"%s: pixel (%d,%d) is transparent (alpha=%d) -- headless "
"renderer produced an empty image" % (what, x, y, alpha))
# Text must have been rasterized: white glyph pixels inside the red frame.
# caption1 sits at frame-relative (10,10) -> absolute (60,60), 24px font.
white_in_red = 0
for y in range(60, 90):
for x in range(60, 300):
if img.at(x, y)[0] == (255, 255, 255):
white_in_red += 1
check(white_in_red > 20,
"caption text not rendered inside red frame (only %d white pixels)"
% white_in_red)
# Test invalid / edge-case filenames
print("\nTesting edge cases...")
# Empty filename
# Empty filename: must fail cleanly (False), not raise or write anything
result = automation.screenshot("")
print(f"Empty filename result: {result}")
# Very long filename
long_name = "x" * 200 + ".png"
print("Empty filename result: %s" % result)
check(result is False, "screenshot('') should return False, got %r" % result)
# Very long (but legal, <255) filename: must succeed and produce a file
long_name = os.path.join(OUT_DIR, "x" * 200 + ".png")
result = automation.screenshot(long_name)
print(f"Long filename result: {result}")
print("Long filename result: %s" % result)
check(result is True, "screenshot(<200-char name>) should return True, got %r" % result)
check(os.path.exists(long_name), "long-filename screenshot produced no file")
print("\n=== Test Complete ===")
print("Check the PNG files to see if they contain visible content.")
print("If they're transparent, the headless renderer may not be working correctly.")
# List what should be visible
print("\nExpected content:")
print("- Light gray background (200, 200, 200)")
print("- Red frame with white outline at (50, 50)")
print("- Green frame with black outline at (400, 50)")
print("- Blue frame with yellow outline at (50, 300)")
print("- White, black, and yellow text labels")
if FAILURES:
print("\n%d FAILURE(S):" % len(FAILURES))
for f in FAILURES:
print(" - %s" % f)
sys.exit(1)
print("PASS")
sys.exit(0)
# Run the test immediately
test_screenshot_validation()
# Run the test immediately (no timer needed: headless screenshots are synchronous)
test_screenshot_validation()