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:
parent
48eef0b095
commit
112f3571f5
82 changed files with 6348 additions and 3396 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 1→3")
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ===")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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})")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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__':
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue