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
|
#!/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
|
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
|
START_COLOR = mcrfpy.Color(255, 100, 100) # Red for start
|
||||||
END_COLOR = mcrfpy.Color(255, 255, 100) # Yellow for end
|
END_COLOR = mcrfpy.Color(255, 255, 100) # Yellow for end
|
||||||
|
|
||||||
|
GRID_W, GRID_H = 30, 20
|
||||||
|
|
||||||
# Global state
|
# Global state
|
||||||
grid = None
|
grid = None
|
||||||
color_layer = None
|
color_layer = None
|
||||||
mode = "ASTAR"
|
|
||||||
start_pos = (5, 10)
|
start_pos = (5, 10)
|
||||||
end_pos = (27, 10) # Changed from 25 to 27 to avoid the wall
|
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():
|
def create_map():
|
||||||
"""Create a map with obstacles to show pathfinding differences"""
|
"""Create a map with obstacles to show pathfinding differences"""
|
||||||
global grid, color_layer
|
global grid, color_layer
|
||||||
|
|
||||||
pathfinding_comparison = mcrfpy.Scene("pathfinding_comparison")
|
# Create grid (view + backing GridData)
|
||||||
|
grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H), pos=(100, 100), size=(600, 400))
|
||||||
# Create grid
|
|
||||||
grid = mcrfpy.Grid(grid_w=30, grid_h=20)
|
|
||||||
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
||||||
|
|
||||||
# Add color layer for cell coloring
|
# Add color layer for cell coloring (GridPoint.color no longer exists)
|
||||||
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 all as floor
|
# Initialize all as floor
|
||||||
for y in range(20):
|
for y in range(GRID_H):
|
||||||
for x in range(30):
|
for x in range(GRID_W):
|
||||||
grid.at(x, y).walkable = True
|
grid.grid_data.at(x, y).walkable = True
|
||||||
color_layer.set(x, y, FLOOR_COLOR)
|
color_layer.set((x, y), FLOOR_COLOR)
|
||||||
|
|
||||||
# Create obstacles that make A* and Dijkstra differ
|
# Create obstacles that make A* and Dijkstra differ
|
||||||
obstacles = [
|
obstacles = [
|
||||||
|
|
@ -55,172 +75,179 @@ def create_map():
|
||||||
[(25, y) for y in range(5, 15)],
|
[(25, y) for y in range(5, 15)],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
walls = set()
|
||||||
for obstacle_group in obstacles:
|
for obstacle_group in obstacles:
|
||||||
for x, y in obstacle_group:
|
for x, y in obstacle_group:
|
||||||
grid.at(x, y).walkable = False
|
grid.grid_data.at(x, y).walkable = False
|
||||||
color_layer.set(x, y, WALL_COLOR)
|
color_layer.set((x, y), WALL_COLOR)
|
||||||
|
walls.add((x, y))
|
||||||
|
|
||||||
# Mark start and end
|
# Mark start and end
|
||||||
color_layer.set(start_pos[0], start_pos[1], START_COLOR)
|
color_layer.set(start_pos, START_COLOR)
|
||||||
color_layer.set(end_pos[0], end_pos[1], END_COLOR)
|
color_layer.set(end_pos, END_COLOR)
|
||||||
|
|
||||||
def clear_paths():
|
# Dijkstra maps are cached; walkability just changed, so invalidate.
|
||||||
"""Clear path highlighting"""
|
grid.grid_data.clear_dijkstra_maps()
|
||||||
for y in range(20):
|
return walls
|
||||||
for x in range(30):
|
|
||||||
cell = grid.at(x, y)
|
|
||||||
if cell.walkable:
|
|
||||||
color_layer.set(x, y, FLOOR_COLOR)
|
|
||||||
|
|
||||||
# Restore start and end colors
|
def validate_path(cells, label, origin, destination):
|
||||||
color_layer.set(start_pos[0], start_pos[1], START_COLOR)
|
"""A path must be contiguous, walkable, and actually arrive at `destination`.
|
||||||
color_layer.set(end_pos[0], end_pos[1], END_COLOR)
|
|
||||||
|
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():
|
def show_astar():
|
||||||
"""Show A* path"""
|
"""Compute + color the A* path. Returns its cells."""
|
||||||
clear_paths()
|
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
|
check((int(path.destination.x), int(path.destination.y)) == end_pos,
|
||||||
path = grid.compute_astar_path(start_pos[0], start_pos[1], end_pos[0], end_pos[1])
|
"A*: .destination is the requested end cell")
|
||||||
|
|
||||||
# Color the path
|
# NOTE: AStarPath is a *consuming* iterator -- iterating it walks the path,
|
||||||
for i, (x, y) in enumerate(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:
|
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
|
status_text.fill_color = ASTAR_COLOR
|
||||||
|
return cells
|
||||||
|
|
||||||
def show_dijkstra():
|
def show_dijkstra(walls):
|
||||||
"""Show Dijkstra exploration"""
|
"""Compute the Dijkstra flood from start, color it, return the path cells."""
|
||||||
clear_paths()
|
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
|
# The distance field must be defined on reachable floor and undefined on walls.
|
||||||
grid.compute_dijkstra(start_pos[0], start_pos[1])
|
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)
|
# Color cells by distance (showing exploration)
|
||||||
max_dist = 40.0
|
max_dist = 40.0
|
||||||
for y in range(20):
|
for y in range(GRID_H):
|
||||||
for x in range(30):
|
for x in range(GRID_W):
|
||||||
if grid.at(x, y).walkable:
|
if grid.grid_data.at(x, y).walkable:
|
||||||
dist = grid.get_dijkstra_distance(x, y)
|
dist = dmap.distance((x, y))
|
||||||
if dist is not None and dist < max_dist:
|
if dist is not None and dist < max_dist:
|
||||||
# Color based on distance
|
|
||||||
intensity = int(255 * (1 - dist / max_dist))
|
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
|
# Get the actual path back to the root
|
||||||
path = grid.get_dijkstra_path(end_pos[0], end_pos[1])
|
cells = path_cells(dmap.path_from(end_pos))
|
||||||
|
|
||||||
# Highlight the actual path more brightly
|
for (x, y) in cells:
|
||||||
for x, y in path:
|
|
||||||
if (x, y) != start_pos and (x, y) != end_pos:
|
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, START_COLOR)
|
||||||
color_layer.set(start_pos[0], start_pos[1], START_COLOR)
|
color_layer.set(end_pos, END_COLOR)
|
||||||
color_layer.set(end_pos[0], end_pos[1], 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
|
status_text.fill_color = DIJKSTRA_COLOR
|
||||||
|
return cells, dmap
|
||||||
|
|
||||||
def show_both():
|
def show_both(astar_cells, dijkstra_cells, dmap):
|
||||||
"""Show both paths overlaid"""
|
"""Compare the two routes. Different cells are fine; different COST is not."""
|
||||||
clear_paths()
|
print(f" A* : {astar_cells}")
|
||||||
|
print(f" Dijkstra: {dijkstra_cells}")
|
||||||
|
|
||||||
# Get both paths
|
# Both algorithms are optimal, so they must agree on the number of steps
|
||||||
astar_path = grid.compute_astar_path(start_pos[0], start_pos[1], end_pos[0], end_pos[1])
|
# even if they break ties through different cells.
|
||||||
grid.compute_dijkstra(start_pos[0], start_pos[1])
|
check(len(astar_cells) == len(dijkstra_cells),
|
||||||
dijkstra_path = grid.get_dijkstra_path(end_pos[0], end_pos[1])
|
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)
|
different_cells = [c for c in dijkstra_cells if c not in astar_cells]
|
||||||
for x, y in dijkstra_path:
|
|
||||||
if (x, y) != start_pos and (x, y) != end_pos:
|
|
||||||
color_layer.set(x, y, DIJKSTRA_COLOR)
|
|
||||||
|
|
||||||
# Then A* path (green) - will overwrite shared cells
|
status_text.text = (f"Both paths: A*={len(astar_cells)} steps, "
|
||||||
for x, y in astar_path:
|
f"Dijkstra={len(dijkstra_cells)} steps")
|
||||||
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"
|
|
||||||
if different_cells:
|
if different_cells:
|
||||||
info_text.text = f"Paths differ at {len(different_cells)} cells"
|
info_text.text = f"Paths differ at {len(different_cells)} cells"
|
||||||
else:
|
else:
|
||||||
info_text.text = "Paths are identical"
|
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("A* vs Dijkstra Pathfinding Comparison")
|
||||||
print("=====================================")
|
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("A* is optimized for single-target pathfinding")
|
||||||
print("Dijkstra explores in all directions (good for multiple targets)")
|
print("Dijkstra explores in all directions (good for multiple targets)")
|
||||||
|
print()
|
||||||
|
|
||||||
create_map()
|
walls = create_map()
|
||||||
|
|
||||||
# Set up UI
|
# Set up UI
|
||||||
|
pathfinding_comparison = mcrfpy.Scene("pathfinding_comparison")
|
||||||
ui = pathfinding_comparison.children
|
ui = pathfinding_comparison.children
|
||||||
ui.append(grid)
|
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 = mcrfpy.Caption(pos=(250, 20), text="A* vs Dijkstra Pathfinding")
|
||||||
title.fill_color = mcrfpy.Color(255, 255, 255)
|
title.fill_color = mcrfpy.Color(255, 255, 255)
|
||||||
ui.append(title)
|
ui.append(title)
|
||||||
|
|
||||||
# Add status
|
status_text = mcrfpy.Caption(pos=(100, 60), text="Comparing A* and Dijkstra")
|
||||||
status_text = mcrfpy.Caption(pos=(100, 60), text="Press A for A*, D for Dijkstra, B for Both")
|
|
||||||
status_text.fill_color = mcrfpy.Color(200, 200, 200)
|
status_text.fill_color = mcrfpy.Color(200, 200, 200)
|
||||||
ui.append(status_text)
|
ui.append(status_text)
|
||||||
|
|
||||||
# Add info
|
|
||||||
info_text = mcrfpy.Caption(pos=(100, 520), text="")
|
info_text = mcrfpy.Caption(pos=(100, 520), text="")
|
||||||
info_text.fill_color = mcrfpy.Color(200, 200, 200)
|
info_text.fill_color = mcrfpy.Color(200, 200, 200)
|
||||||
ui.append(info_text)
|
ui.append(info_text)
|
||||||
|
|
||||||
# Add legend
|
|
||||||
legend1 = mcrfpy.Caption(pos=(100, 540), text="Red=Start, Yellow=End, Green=A*, Blue=Dijkstra")
|
legend1 = mcrfpy.Caption(pos=(100, 540), text="Red=Start, Yellow=End, Green=A*, Blue=Dijkstra")
|
||||||
legend1.fill_color = mcrfpy.Color(150, 150, 150)
|
legend1.fill_color = mcrfpy.Color(150, 150, 150)
|
||||||
ui.append(legend1)
|
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)
|
legend2.fill_color = mcrfpy.Color(150, 150, 150)
|
||||||
ui.append(legend2)
|
ui.append(legend2)
|
||||||
|
|
||||||
# Set scene and input
|
|
||||||
pathfinding_comparison.activate()
|
pathfinding_comparison.activate()
|
||||||
pathfinding_comparison.on_key = handle_keypress
|
|
||||||
|
|
||||||
# Show initial A* path
|
# Sanity: the ColorLayer really is the coloring mechanism now.
|
||||||
show_astar()
|
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
|
#!/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 mcrfpy
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
if cond:
|
||||||
|
print(f" ok: {msg}")
|
||||||
|
else:
|
||||||
|
print(f" FAIL: {msg}")
|
||||||
|
failures.append(msg)
|
||||||
|
|
||||||
print("Debug visibility...")
|
print("Debug visibility...")
|
||||||
|
|
||||||
# Create scene and grid
|
# Create scene and grid
|
||||||
debug = mcrfpy.Scene("debug")
|
debug = mcrfpy.Scene("debug")
|
||||||
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
|
grid = mcrfpy.Grid(grid_size=(5, 5))
|
||||||
|
|
||||||
# Initialize grid
|
# Initialize grid
|
||||||
print("Initializing grid...")
|
print("Initializing grid...")
|
||||||
|
|
@ -20,39 +36,66 @@ for y in range(5):
|
||||||
|
|
||||||
# Create entity
|
# Create entity
|
||||||
print("Creating entity...")
|
print("Creating entity...")
|
||||||
entity = mcrfpy.Entity((2, 2), grid=grid)
|
entity = mcrfpy.Entity(grid_pos=(2, 2))
|
||||||
entity.sprite_index = 64
|
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
|
# A second entity, so visible_entities() has something to find
|
||||||
print(f"\nGridstate length: {len(entity.gridstate)}")
|
neighbor = mcrfpy.Entity(grid_pos=(4, 4))
|
||||||
print(f"Expected: {5 * 5}")
|
grid.entities.append(neighbor)
|
||||||
|
|
||||||
# Try to access gridstate
|
# Check perspective_map (successor to gridstate)
|
||||||
print("\nChecking gridstate access...")
|
pmap = entity.perspective_map
|
||||||
try:
|
print(f"\nPerspective map size: {pmap.size}")
|
||||||
if len(entity.gridstate) > 0:
|
check(pmap.size == (5, 5), "perspective_map covers every grid cell (5x5)")
|
||||||
state = entity.gridstate[0]
|
|
||||||
print(f"First state: visible={state.visible}, discovered={state.discovered}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error accessing gridstate: {e}")
|
|
||||||
|
|
||||||
# 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...")
|
print("\nTrying update_visibility...")
|
||||||
try:
|
entity.update_visibility()
|
||||||
entity.update_visibility()
|
check(pmap.get((2, 2)) == Perspective.VISIBLE,
|
||||||
print("update_visibility succeeded")
|
"entity's own cell is VISIBLE after update_visibility()")
|
||||||
except Exception as e:
|
check(pmap.get((0, 0)) == Perspective.VISIBLE,
|
||||||
print(f"Error in update_visibility: {e}")
|
"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("\nTesting perspective...")
|
||||||
print(f"Initial perspective: {grid.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:
|
try:
|
||||||
grid.perspective = 0
|
grid.perspective = 0
|
||||||
print(f"Set perspective to 0: {grid.perspective}")
|
check(False, "grid.perspective rejects a non-Entity (int index is gone)")
|
||||||
except Exception as e:
|
except TypeError:
|
||||||
print(f"Error setting perspective: {e}")
|
check(True, "grid.perspective rejects a non-Entity (int index is gone)")
|
||||||
|
|
||||||
print("\nTest complete")
|
print("\nTest complete")
|
||||||
|
if failures:
|
||||||
|
print(f"FAIL: {len(failures)} check(s) failed")
|
||||||
|
sys.exit(1)
|
||||||
|
print("PASS")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
@ -5,6 +5,10 @@ Dijkstra Demo - Shows ALL Path Combinations (Including Invalid)
|
||||||
|
|
||||||
Cycles through every possible entity pair to demonstrate both
|
Cycles through every possible entity pair to demonstrate both
|
||||||
valid paths and properly handled invalid paths (empty lists).
|
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
|
import mcrfpy
|
||||||
|
|
@ -20,24 +24,35 @@ NO_PATH_COLOR = mcrfpy.Color(255, 0, 0) # Pure red for unreachable
|
||||||
|
|
||||||
# Global state
|
# Global state
|
||||||
grid = None
|
grid = None
|
||||||
|
grid_data = None
|
||||||
color_layer = None
|
color_layer = None
|
||||||
entities = []
|
entities = []
|
||||||
current_combo_index = 0
|
current_combo_index = 0
|
||||||
all_combinations = [] # All possible pairs
|
all_combinations = [] # All possible pairs
|
||||||
current_path = []
|
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():
|
def create_map():
|
||||||
"""Create the map with entities"""
|
"""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_size= replaces the old grid_w/grid_h kwargs)
|
||||||
|
grid = mcrfpy.Grid(grid_size=(14, 10))
|
||||||
# Create grid
|
|
||||||
grid = mcrfpy.Grid(grid_w=14, grid_h=10)
|
|
||||||
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
||||||
|
grid_data = grid.grid_data
|
||||||
|
|
||||||
# Add color layer for cell coloring
|
# Add color layer for cell coloring (GridPoint has no .color anymore)
|
||||||
color_layer = grid.add_layer("color", z_index=-1)
|
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||||
|
grid.add_layer(color_layer)
|
||||||
|
|
||||||
# Map layout - Entity 1 is intentionally trapped!
|
# Map layout - Entity 1 is intentionally trapped!
|
||||||
map_layout = [
|
map_layout = [
|
||||||
|
|
@ -57,14 +72,14 @@ def create_map():
|
||||||
entity_positions = []
|
entity_positions = []
|
||||||
for y, row in enumerate(map_layout):
|
for y, row in enumerate(map_layout):
|
||||||
for x, char in enumerate(row):
|
for x, char in enumerate(row):
|
||||||
cell = grid.at(x, y)
|
cell = grid_data.at(x, y)
|
||||||
|
|
||||||
if char == 'W':
|
if char == 'W':
|
||||||
cell.walkable = False
|
cell.walkable = False
|
||||||
color_layer.set(x, y, WALL_COLOR)
|
color_layer.set((x, y), WALL_COLOR)
|
||||||
else:
|
else:
|
||||||
cell.walkable = True
|
cell.walkable = True
|
||||||
color_layer.set(x, y, FLOOR_COLOR)
|
color_layer.set((x, y), FLOOR_COLOR)
|
||||||
|
|
||||||
if char == 'E':
|
if char == 'E':
|
||||||
entity_positions.append((x, y))
|
entity_positions.append((x, y))
|
||||||
|
|
@ -72,7 +87,7 @@ def create_map():
|
||||||
# Create entities
|
# Create entities
|
||||||
entities = []
|
entities = []
|
||||||
for i, (x, y) in enumerate(entity_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'
|
entity.sprite_index = 49 + i # '1', '2', '3'
|
||||||
entities.append(entity)
|
entities.append(entity)
|
||||||
|
|
||||||
|
|
@ -81,6 +96,10 @@ def create_map():
|
||||||
for i, (x, y) in enumerate(entity_positions):
|
for i, (x, y) in enumerate(entity_positions):
|
||||||
print(f"Entity {i+1} at ({x}, {y})")
|
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)
|
# Generate ALL combinations (including invalid ones)
|
||||||
all_combinations = []
|
all_combinations = []
|
||||||
for i in range(len(entities)):
|
for i in range(len(entities)):
|
||||||
|
|
@ -94,16 +113,15 @@ def clear_path_colors():
|
||||||
"""Reset all floor tiles to original color"""
|
"""Reset all floor tiles to original color"""
|
||||||
global current_path
|
global current_path
|
||||||
|
|
||||||
for y in range(grid.grid_h):
|
for y in range(grid_data.grid_h):
|
||||||
for x in range(grid.grid_w):
|
for x in range(grid_data.grid_w):
|
||||||
cell = grid.at(x, y)
|
if grid_data.at(x, y).walkable:
|
||||||
if cell.walkable:
|
color_layer.set((x, y), FLOOR_COLOR)
|
||||||
color_layer.set(x, y, FLOOR_COLOR)
|
|
||||||
|
|
||||||
current_path = []
|
current_path = []
|
||||||
|
|
||||||
def show_combination(index):
|
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
|
global current_combo_index, current_path
|
||||||
|
|
||||||
current_combo_index = index % len(all_combinations)
|
current_combo_index = index % len(all_combinations)
|
||||||
|
|
@ -116,41 +134,60 @@ def show_combination(index):
|
||||||
e_from = entities[from_idx]
|
e_from = entities[from_idx]
|
||||||
e_to = entities[to_idx]
|
e_to = entities[to_idx]
|
||||||
|
|
||||||
# Calculate path
|
# Calculate path (grid_x/grid_y are tile coords; .x/.y are pixels now)
|
||||||
path = e_from.path_to(int(e_to.x), int(e_to.y))
|
path = e_from.path_to(e_to.grid_x, e_to.grid_y)
|
||||||
current_path = path if path else []
|
current_path = path if path else []
|
||||||
|
|
||||||
# Always color start and end positions
|
# Always color start and end positions
|
||||||
color_layer.set(int(e_from.x), int(e_from.y), START_COLOR)
|
color_layer.set((e_from.grid_x, e_from.grid_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_to.grid_x, e_to.grid_y),
|
||||||
|
NO_PATH_COLOR if not path else END_COLOR)
|
||||||
|
|
||||||
# Color the path if it exists
|
# Color the path if it exists
|
||||||
if path:
|
if path:
|
||||||
# Color intermediate steps
|
# Color intermediate steps
|
||||||
for i, (x, y) in enumerate(path):
|
for i, (x, y) in enumerate(path):
|
||||||
if i > 0 and i < len(path) - 1:
|
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
|
status_text.fill_color = mcrfpy.Color(100, 255, 100) # Green for valid
|
||||||
|
|
||||||
# Show path steps
|
# Show path steps
|
||||||
path_display = []
|
path_display = []
|
||||||
for i, (x, y) in enumerate(path[:5]):
|
for (x, y) in path[:5]:
|
||||||
path_display.append(f"({x},{y})")
|
path_display.append(f"({x},{y})")
|
||||||
if len(path) > 5:
|
if len(path) > 5:
|
||||||
path_display.append("...")
|
path_display.append("...")
|
||||||
path_text.text = "Path: " + " → ".join(path_display)
|
path_text.text = "Path: " + " -> ".join(path_display)
|
||||||
else:
|
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
|
status_text.fill_color = mcrfpy.Color(255, 100, 100) # Red for invalid
|
||||||
path_text.text = "Path: [] (No valid path exists)"
|
path_text.text = "Path: [] (No valid path exists)"
|
||||||
|
|
||||||
# Update info
|
# 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):
|
def handle_keypress(key_str, state):
|
||||||
"""Handle keyboard input"""
|
"""Handle keyboard input (interactive mode only)"""
|
||||||
global current_combo_index
|
global current_combo_index
|
||||||
if state == mcrfpy.InputState.RELEASED: return
|
if state == mcrfpy.InputState.RELEASED: return
|
||||||
|
|
||||||
|
|
@ -164,8 +201,8 @@ def handle_keypress(key_str, state):
|
||||||
elif key_str == mcrfpy.Key.R:
|
elif key_str == mcrfpy.Key.R:
|
||||||
show_combination(current_combo_index)
|
show_combination(current_combo_index)
|
||||||
else:
|
else:
|
||||||
num_keys = {mcrfpy.Key.NUM_1: 0, mcrfpy.Key.NUM_2: 1, mcrfpy.Key.NUM_3: 2,
|
num_keys = {mcrfpy.Key.Num1: 0, mcrfpy.Key.Num2: 1, mcrfpy.Key.Num3: 2,
|
||||||
mcrfpy.Key.NUM_4: 3, mcrfpy.Key.NUM_5: 4, mcrfpy.Key.NUM_6: 5}
|
mcrfpy.Key.Num4: 3, mcrfpy.Key.Num5: 4, mcrfpy.Key.Num6: 5}
|
||||||
if key_str in num_keys:
|
if key_str in num_keys:
|
||||||
combo_num = num_keys[key_str]
|
combo_num = num_keys[key_str]
|
||||||
if combo_num < len(all_combinations):
|
if combo_num < len(all_combinations):
|
||||||
|
|
@ -214,12 +251,12 @@ controls.fill_color = mcrfpy.Color(150, 150, 150)
|
||||||
ui.append(controls)
|
ui.append(controls)
|
||||||
|
|
||||||
# Add legend
|
# 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)
|
legend.fill_color = mcrfpy.Color(150, 150, 150)
|
||||||
ui.append(legend)
|
ui.append(legend)
|
||||||
|
|
||||||
# Expected results info
|
# 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)
|
expected.fill_color = mcrfpy.Color(255, 150, 150)
|
||||||
ui.append(expected)
|
ui.append(expected)
|
||||||
|
|
||||||
|
|
@ -227,14 +264,36 @@ ui.append(expected)
|
||||||
dijkstra_all.activate()
|
dijkstra_all.activate()
|
||||||
dijkstra_all.on_key = handle_keypress
|
dijkstra_all.on_key = handle_keypress
|
||||||
|
|
||||||
# Show first combination
|
print("\nExpected results:")
|
||||||
show_combination(0)
|
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!")
|
# Drive every combination and verify the outcome. Entity index 0 is trapped,
|
||||||
print("Expected results:")
|
# so any pair involving it must yield an empty list; the other pairs must be
|
||||||
print(" Path 1: Entity 1→2 = NO PATH (Entity 1 is trapped)")
|
# real paths.
|
||||||
print(" Path 2: Entity 1→3 = NO PATH (Entity 1 is trapped)")
|
for combo_index, (from_idx, to_idx) in enumerate(all_combinations):
|
||||||
print(" Path 3: Entity 2→1 = NO PATH (Entity 1 is trapped)")
|
path = show_combination(combo_index)
|
||||||
print(" Path 4: Entity 2→3 = Valid path")
|
label = f"Entity {from_idx+1}->{to_idx+1}"
|
||||||
print(" Path 5: Entity 3→1 = NO PATH (Entity 1 is trapped)")
|
if 0 in (from_idx, to_idx):
|
||||||
print(" Path 6: Entity 3→2 = Valid path")
|
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.
|
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
|
import mcrfpy
|
||||||
|
|
@ -18,24 +26,33 @@ END_COLOR = mcrfpy.Color(100, 100, 255) # Light blue
|
||||||
|
|
||||||
# Global state
|
# Global state
|
||||||
grid = None
|
grid = None
|
||||||
|
grid_data = None
|
||||||
color_layer = None
|
color_layer = None
|
||||||
entities = []
|
entities = []
|
||||||
current_path_index = 0
|
current_path_index = 0
|
||||||
path_combinations = []
|
path_combinations = []
|
||||||
current_path = []
|
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():
|
def create_map():
|
||||||
"""Create the map with entities"""
|
"""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_size= replaces the old grid_w=/grid_h= kwargs)
|
||||||
|
grid = mcrfpy.Grid(grid_size=(14, 10))
|
||||||
# Create grid
|
grid_data = grid.grid_data
|
||||||
grid = mcrfpy.Grid(grid_w=14, grid_h=10)
|
|
||||||
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
||||||
|
|
||||||
# Add color layer for cell coloring
|
# Add color layer for cell coloring. Cells no longer have a .color;
|
||||||
color_layer = grid.add_layer("color", z_index=-1)
|
# 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
|
||||||
map_layout = [
|
map_layout = [
|
||||||
|
|
@ -55,14 +72,14 @@ def create_map():
|
||||||
entity_positions = []
|
entity_positions = []
|
||||||
for y, row in enumerate(map_layout):
|
for y, row in enumerate(map_layout):
|
||||||
for x, char in enumerate(row):
|
for x, char in enumerate(row):
|
||||||
cell = grid.at(x, y)
|
cell = grid_data.at(x, y)
|
||||||
|
|
||||||
if char == 'W':
|
if char == 'W':
|
||||||
cell.walkable = False
|
cell.walkable = False
|
||||||
color_layer.set(x, y, WALL_COLOR)
|
color_layer.set((x, y), WALL_COLOR)
|
||||||
else:
|
else:
|
||||||
cell.walkable = True
|
cell.walkable = True
|
||||||
color_layer.set(x, y, FLOOR_COLOR)
|
color_layer.set((x, y), FLOOR_COLOR)
|
||||||
|
|
||||||
if char == 'E':
|
if char == 'E':
|
||||||
entity_positions.append((x, y))
|
entity_positions.append((x, y))
|
||||||
|
|
@ -70,7 +87,7 @@ def create_map():
|
||||||
# Create entities
|
# Create entities
|
||||||
entities = []
|
entities = []
|
||||||
for i, (x, y) in enumerate(entity_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'
|
entity.sprite_index = 49 + i # '1', '2', '3'
|
||||||
entities.append(entity)
|
entities.append(entity)
|
||||||
|
|
||||||
|
|
@ -78,22 +95,32 @@ def create_map():
|
||||||
for i, (x, y) in enumerate(entity_positions):
|
for i, (x, y) in enumerate(entity_positions):
|
||||||
print(f" Entity {i+1} at ({x}, {y})")
|
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
|
# Check which entity is trapped
|
||||||
print("\nChecking accessibility:")
|
print("\nChecking accessibility:")
|
||||||
|
reachability = []
|
||||||
for i, e in enumerate(entities):
|
for i, e in enumerate(entities):
|
||||||
# Try to path to each other entity
|
# Try to path to each other entity
|
||||||
can_reach = []
|
can_reach = []
|
||||||
for j, other in enumerate(entities):
|
for j, other in enumerate(entities):
|
||||||
if i != j:
|
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:
|
if path:
|
||||||
can_reach.append(j+1)
|
can_reach.append(j+1)
|
||||||
|
|
||||||
|
reachability.append(can_reach)
|
||||||
if not 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:
|
else:
|
||||||
print(f" Entity {i+1} can reach entities: {can_reach}")
|
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)
|
# Generate valid path combinations (excluding trapped entity)
|
||||||
global path_combinations
|
global path_combinations
|
||||||
path_combinations = []
|
path_combinations = []
|
||||||
|
|
@ -102,26 +129,47 @@ def create_map():
|
||||||
# since entity 1 (index 0) is trapped
|
# since entity 1 (index 0) is trapped
|
||||||
if len(entities) >= 3:
|
if len(entities) >= 3:
|
||||||
# Entity 2 to Entity 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:
|
if path:
|
||||||
path_combinations.append((1, 2, path))
|
path_combinations.append((1, 2, path))
|
||||||
|
|
||||||
# Entity 3 to Entity 2
|
# 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:
|
if path:
|
||||||
path_combinations.append((2, 1, path))
|
path_combinations.append((2, 1, path))
|
||||||
|
|
||||||
print(f"\nFound {len(path_combinations)} valid paths")
|
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():
|
def clear_path_colors():
|
||||||
"""Reset all floor tiles to original color"""
|
"""Reset all floor tiles to original color"""
|
||||||
global current_path
|
global current_path
|
||||||
|
|
||||||
for y in range(grid.grid_h):
|
for y in range(grid_data.grid_h):
|
||||||
for x in range(grid.grid_w):
|
for x in range(grid_data.grid_w):
|
||||||
cell = grid.at(x, y)
|
cell = grid_data.at(x, y)
|
||||||
if cell.walkable:
|
if cell.walkable:
|
||||||
color_layer.set(x, y, FLOOR_COLOR)
|
color_layer.set((x, y), FLOOR_COLOR)
|
||||||
|
|
||||||
current_path = []
|
current_path = []
|
||||||
|
|
||||||
|
|
@ -147,16 +195,16 @@ def show_path(index):
|
||||||
current_path = path
|
current_path = path
|
||||||
if path:
|
if path:
|
||||||
# Color start and end
|
# Color start and end
|
||||||
color_layer.set(int(e_from.x), int(e_from.y), START_COLOR)
|
color_layer.set((e_from.grid_x, e_from.grid_y), START_COLOR)
|
||||||
color_layer.set(int(e_to.x), int(e_to.y), END_COLOR)
|
color_layer.set((e_to.grid_x, e_to.grid_y), END_COLOR)
|
||||||
|
|
||||||
# Color intermediate steps
|
# Color intermediate steps. path_to() excludes the entity's own cell,
|
||||||
for i, (x, y) in enumerate(path):
|
# so every element except the last (the target) is an intermediate step.
|
||||||
if i > 0 and i < len(path) - 1:
|
for (x, y) in path[:-1]:
|
||||||
color_layer.set(x, y, PATH_COLOR)
|
color_layer.set((x, y), PATH_COLOR)
|
||||||
|
|
||||||
# Update status
|
# 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
|
# Update path display
|
||||||
path_display = []
|
path_display = []
|
||||||
|
|
@ -164,7 +212,7 @@ def show_path(index):
|
||||||
path_display.append(f"({x},{y})")
|
path_display.append(f"({x},{y})")
|
||||||
if len(path) > 5:
|
if len(path) > 5:
|
||||||
path_display.append("...")
|
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):
|
def handle_keypress(key_str, state):
|
||||||
"""Handle keyboard input"""
|
"""Handle keyboard input"""
|
||||||
|
|
@ -186,6 +234,8 @@ print("==========================")
|
||||||
print("Note: Entity 1 is trapped by walls!")
|
print("Note: Entity 1 is trapped by walls!")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
dijkstra_cycle = mcrfpy.Scene("dijkstra_cycle")
|
||||||
|
|
||||||
create_map()
|
create_map()
|
||||||
|
|
||||||
# Set up UI
|
# Set up UI
|
||||||
|
|
@ -231,9 +281,34 @@ if path_combinations:
|
||||||
else:
|
else:
|
||||||
status_text.text = "No valid paths! Entity 1 is trapped!"
|
status_text.text = "No valid paths! Entity 1 is trapped!"
|
||||||
|
|
||||||
print("\nDemo ready!")
|
# --- Headless verification: cycle every combination the demo would show ---
|
||||||
print("Controls:")
|
print("\nCycling paths:")
|
||||||
print(" SPACE or N - Next path")
|
for i in range(len(path_combinations)):
|
||||||
print(" P - Previous path")
|
show_path(i)
|
||||||
print(" R - Refresh current path")
|
from_idx, to_idx, path = path_combinations[current_path_index]
|
||||||
print(" Q - Quit")
|
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
|
# Global state
|
||||||
grid = None
|
grid = None
|
||||||
|
grid_data = None
|
||||||
color_layer = None
|
color_layer = None
|
||||||
entities = []
|
entities = []
|
||||||
first_point = None
|
failures = []
|
||||||
second_point = None
|
|
||||||
|
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():
|
def create_simple_map():
|
||||||
"""Create a simple test map"""
|
"""Create a simple test map"""
|
||||||
global grid, color_layer, entities
|
global grid, grid_data, color_layer, entities
|
||||||
|
|
||||||
dijkstra_debug = mcrfpy.Scene("dijkstra_debug")
|
|
||||||
|
|
||||||
# Small grid for easy debugging
|
# 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)
|
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
|
# 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...")
|
print("Initializing 10x10 grid...")
|
||||||
|
|
||||||
# Initialize all as floor
|
# Initialize all as floor
|
||||||
for y in range(10):
|
for y in range(10):
|
||||||
for x in range(10):
|
for x in range(10):
|
||||||
grid.at(x, y).walkable = True
|
grid_data.at(x, y).walkable = True
|
||||||
grid.at(x, y).transparent = True
|
grid_data.at(x, y).transparent = True
|
||||||
color_layer.set(x, y, FLOOR_COLOR)
|
color_layer.set((x, y), FLOOR_COLOR)
|
||||||
|
|
||||||
# Add a simple wall
|
# Add a simple wall
|
||||||
print("Adding walls at:")
|
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})")
|
print(f" Wall at ({x}, {y})")
|
||||||
grid.at(x, y).walkable = False
|
grid_data.at(x, y).walkable = False
|
||||||
color_layer.set(x, y, WALL_COLOR)
|
color_layer.set((x, y), WALL_COLOR)
|
||||||
|
|
||||||
# Create 3 entities
|
# Create 3 entities
|
||||||
entity_positions = [(2, 5), (8, 5), (5, 8)]
|
entity_positions = [(2, 5), (8, 5), (5, 8)]
|
||||||
|
|
@ -60,7 +69,7 @@ def create_simple_map():
|
||||||
print("\nCreating entities at:")
|
print("\nCreating entities at:")
|
||||||
for i, (x, y) in enumerate(entity_positions):
|
for i, (x, y) in enumerate(entity_positions):
|
||||||
print(f" Entity {i+1} at ({x}, {y})")
|
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'
|
entity.sprite_index = 49 + i # '1', '2', '3'
|
||||||
entities.append(entity)
|
entities.append(entity)
|
||||||
|
|
||||||
|
|
@ -75,65 +84,90 @@ def test_path_highlighting():
|
||||||
e1 = entities[0]
|
e1 = entities[0]
|
||||||
e2 = entities[1]
|
e2 = entities[1]
|
||||||
|
|
||||||
print(f"\nEntity 1 position: ({e1.x}, {e1.y})")
|
# NOTE: entity.x/.y are PIXEL coordinates now; tile coords are grid_x/grid_y.
|
||||||
print(f"Entity 2 position: ({e2.x}, {e2.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()
|
# Use entity.path_to()
|
||||||
print("\nCalling 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 returned: {path}")
|
||||||
print(f"Path length: {len(path)} steps")
|
print(f"Path length: {len(path)} steps")
|
||||||
|
|
||||||
|
check(len(path) > 0, "entity.path_to() returned an empty path")
|
||||||
if 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:")
|
print("\nHighlighting path cells:")
|
||||||
for i, (x, y) in enumerate(path):
|
for i, (x, y) in enumerate(path):
|
||||||
print(f" Step {i}: ({x}, {y})")
|
print(f" Step {i}: ({x}, {y})")
|
||||||
# Get current color for debugging
|
# Get current color for debugging
|
||||||
cell = grid.at(x, y)
|
cell = grid_data.at(x, y)
|
||||||
old_c = color_layer.at(x, y)
|
old_c = color_layer.at((x, y))
|
||||||
old_color = (old_c.r, old_c.g, old_c.b)
|
old_color = (old_c.r, old_c.g, old_c.b)
|
||||||
|
|
||||||
# Set new color
|
# Set new color
|
||||||
color_layer.set(x, y, PATH_COLOR)
|
color_layer.set((x, y), PATH_COLOR)
|
||||||
new_c = color_layer.at(x, y)
|
new_c = color_layer.at((x, y))
|
||||||
new_color = (new_c.r, new_c.g, new_c.b)
|
new_color = (new_c.r, new_c.g, new_c.b)
|
||||||
|
|
||||||
print(f" Color changed from {old_color} to {new_color}")
|
print(f" Color changed from {old_color} to {new_color}")
|
||||||
print(f" Walkable: {cell.walkable}")
|
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
|
# Also test grid's Dijkstra methods
|
||||||
print("\n" + "-"*30)
|
print("\n" + "-"*30)
|
||||||
print("Testing grid Dijkstra methods...")
|
print("Testing grid Dijkstra methods...")
|
||||||
|
|
||||||
grid.compute_dijkstra(int(e1.x), int(e1.y))
|
# compute_dijkstra/get_dijkstra_path/get_dijkstra_distance were replaced by
|
||||||
grid_path = grid.get_dijkstra_path(int(e2.x), int(e2.y))
|
# GridData.get_dijkstra_map() -> DijkstraMap.path_from()/.distance()
|
||||||
distance = grid.get_dijkstra_distance(int(e2.x), int(e2.y))
|
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 path: {grid_path}")
|
||||||
print(f"Grid distance: {distance}")
|
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
|
# Verify colors were set
|
||||||
print("\nVerifying cell colors after highlighting:")
|
print("\nVerifying cell colors after highlighting:")
|
||||||
for x, y in path[:3]: # Check first 3 cells
|
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)
|
color = (c.r, c.g, c.b)
|
||||||
expected = (PATH_COLOR.r, PATH_COLOR.g, PATH_COLOR.b)
|
expected = (PATH_COLOR.r, PATH_COLOR.g, PATH_COLOR.b)
|
||||||
match = color == expected
|
match = color == expected
|
||||||
print(f" Cell ({x}, {y}): color={color}, expected={expected}, match={match}")
|
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"""
|
"""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...")
|
print("\nExiting debug...")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
elif keycode == 32: # Space
|
elif key == mcrfpy.Key.Space:
|
||||||
print("\nSpace pressed - retesting path highlighting...")
|
print("\nSpace pressed - retesting path highlighting...")
|
||||||
test_path_highlighting()
|
test_path_highlighting()
|
||||||
|
|
||||||
# Create the map
|
# Create the map
|
||||||
print("Dijkstra Debug Test")
|
print("Dijkstra Debug Test")
|
||||||
print("===================")
|
print("===================")
|
||||||
|
dijkstra_debug = mcrfpy.Scene("dijkstra_debug")
|
||||||
grid = create_simple_map()
|
grid = create_simple_map()
|
||||||
|
|
||||||
# Initial path test
|
# Initial path test
|
||||||
|
|
@ -161,6 +195,17 @@ ui.append(info)
|
||||||
dijkstra_debug.on_key = handle_keypress
|
dijkstra_debug.on_key = handle_keypress
|
||||||
dijkstra_debug.activate()
|
dijkstra_debug.activate()
|
||||||
|
|
||||||
print("\nScene ready. The path should be highlighted in cyan.")
|
# Render once to prove the highlighted grid actually draws (the original test's
|
||||||
print("If you don't see the path, there may be a rendering issue.")
|
# "if you don't see the path there may be a rendering issue" concern).
|
||||||
print("Press SPACE to retest, Q to quit.")
|
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
|
#!/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 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
|
- Space to clear selection
|
||||||
- Q or ESC to quit
|
- 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
|
import mcrfpy
|
||||||
|
from mcrfpy import automation
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Colors - using more distinct values
|
# Colors - using more distinct values
|
||||||
|
|
@ -29,55 +35,65 @@ ENTITY_COLORS = [
|
||||||
|
|
||||||
# Global state
|
# Global state
|
||||||
grid = None
|
grid = None
|
||||||
|
grid_data = None
|
||||||
color_layer = None
|
color_layer = None
|
||||||
entities = []
|
entities = []
|
||||||
first_point = None
|
first_point = None
|
||||||
second_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():
|
def create_map():
|
||||||
"""Create the interactive map with the layout specified by the user"""
|
"""Create the interactive map with the layout specified by the user"""
|
||||||
global grid, color_layer, entities
|
global grid, grid_data, color_layer, entities
|
||||||
|
|
||||||
dijkstra_interactive = mcrfpy.Scene("dijkstra_interactive")
|
|
||||||
|
|
||||||
# Create grid - 14x10 as specified
|
# 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.fill_color = mcrfpy.Color(0, 0, 0)
|
||||||
|
grid_data = grid.grid_data
|
||||||
|
|
||||||
# Add color layer for cell coloring
|
# Add color layer for cell coloring (GridPoint has no .color anymore)
|
||||||
color_layer = grid.add_layer("color", z_index=-1)
|
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||||
|
grid_data.add_layer(color_layer)
|
||||||
# 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
|
|
||||||
]
|
|
||||||
|
|
||||||
# Create the map
|
# Create the map
|
||||||
entity_positions = []
|
entity_positions = []
|
||||||
for y, row in enumerate(map_layout):
|
for y, row in enumerate(map_layout):
|
||||||
for x, char in enumerate(row):
|
for x, char in enumerate(row):
|
||||||
cell = grid.at(x, y)
|
cell = grid_data.at(x, y)
|
||||||
|
|
||||||
if char == 'W':
|
if char == 'W':
|
||||||
# Wall
|
# Wall
|
||||||
cell.walkable = False
|
cell.walkable = False
|
||||||
cell.transparent = False
|
cell.transparent = False
|
||||||
color_layer.set(x, y, WALL_COLOR)
|
color_layer.set((x, y), WALL_COLOR)
|
||||||
else:
|
else:
|
||||||
# Floor
|
# Floor
|
||||||
cell.walkable = True
|
cell.walkable = True
|
||||||
cell.transparent = True
|
cell.transparent = True
|
||||||
color_layer.set(x, y, FLOOR_COLOR)
|
color_layer.set((x, y), FLOOR_COLOR)
|
||||||
|
|
||||||
if char == 'E':
|
if char == 'E':
|
||||||
# Entity position
|
# Entity position
|
||||||
|
|
@ -86,25 +102,32 @@ def create_map():
|
||||||
# Create entities at marked positions
|
# Create entities at marked positions
|
||||||
entities = []
|
entities = []
|
||||||
for i, (x, y) in enumerate(entity_positions):
|
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'
|
entity.sprite_index = 49 + i # '1', '2', '3'
|
||||||
|
grid_data.entities.append(entity)
|
||||||
entities.append(entity)
|
entities.append(entity)
|
||||||
|
|
||||||
return grid
|
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():
|
def clear_path_highlight():
|
||||||
"""Clear any existing path highlighting"""
|
"""Clear any existing path highlighting"""
|
||||||
# Reset all floor tiles to original color
|
# Reset all floor tiles to original color
|
||||||
for y in range(grid.grid_h):
|
for y in range(grid_data.grid_h):
|
||||||
for x in range(grid.grid_w):
|
for x in range(grid_data.grid_w):
|
||||||
cell = grid.at(x, y)
|
if grid_data.at(x, y).walkable:
|
||||||
if cell.walkable:
|
color_layer.set((x, y), FLOOR_COLOR)
|
||||||
color_layer.set(x, y, FLOOR_COLOR)
|
|
||||||
|
|
||||||
def highlight_path():
|
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:
|
if first_point is None or second_point is None:
|
||||||
return
|
return None
|
||||||
|
|
||||||
# Clear previous highlighting
|
# Clear previous highlighting
|
||||||
clear_path_highlight()
|
clear_path_highlight()
|
||||||
|
|
@ -112,64 +135,59 @@ def highlight_path():
|
||||||
# Get entities
|
# Get entities
|
||||||
entity1 = entities[first_point]
|
entity1 = entities[first_point]
|
||||||
entity2 = entities[second_point]
|
entity2 = entities[second_point]
|
||||||
|
start = entity_cell(entity1)
|
||||||
|
end = entity_cell(entity2)
|
||||||
|
|
||||||
# Compute Dijkstra from first entity
|
# Compute Dijkstra from first entity; walkability is static, but clear the
|
||||||
grid.compute_dijkstra(int(entity1.x), int(entity1.y))
|
# 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
|
distance = dijkstra.distance(end)
|
||||||
path = grid.get_dijkstra_path(int(entity2.x), int(entity2.y))
|
path = [] if distance is None else [(int(v.x), int(v.y)) for v in dijkstra.path_from(end)]
|
||||||
|
|
||||||
if path:
|
if path:
|
||||||
# Highlight the path
|
# Highlight the path
|
||||||
for x, y in path:
|
for x, y in path:
|
||||||
cell = grid.at(x, y)
|
if grid_data.at(x, y).walkable:
|
||||||
if cell.walkable:
|
color_layer.set((x, y), PATH_COLOR)
|
||||||
color_layer.set(x, y, PATH_COLOR)
|
|
||||||
|
|
||||||
# Also highlight start and end with entity colors
|
# Also highlight start and end with entity colors
|
||||||
color_layer.set(int(entity1.x), int(entity1.y), ENTITY_COLORS[first_point])
|
color_layer.set(start, ENTITY_COLORS[first_point])
|
||||||
color_layer.set(int(entity2.x), int(entity2.y), ENTITY_COLORS[second_point])
|
color_layer.set(end, ENTITY_COLORS[second_point])
|
||||||
|
|
||||||
# Update info
|
info_text.text = (f"Path: Entity {first_point+1} to Entity {second_point+1} - "
|
||||||
distance = grid.get_dijkstra_distance(int(entity2.x), int(entity2.y))
|
f"{len(path)} steps, {distance:.1f} units")
|
||||||
info_text.text = f"Path: Entity {first_point+1} to Entity {second_point+1} - {len(path)} steps, {distance:.1f} units"
|
|
||||||
else:
|
else:
|
||||||
info_text.text = f"No path between Entity {first_point+1} and Entity {second_point+1}"
|
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"""
|
"""Handle keyboard input"""
|
||||||
global first_point, second_point
|
global first_point, second_point
|
||||||
|
|
||||||
|
if action != mcrfpy.InputState.PRESSED:
|
||||||
|
return
|
||||||
|
|
||||||
# Number keys for first entity
|
# Number keys for first entity
|
||||||
if keycode == 49: # '1'
|
if key in (mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3):
|
||||||
first_point = 0
|
first_point = {mcrfpy.Key.NUM_1: 0, mcrfpy.Key.NUM_2: 1, mcrfpy.Key.NUM_3: 2}[key]
|
||||||
status_text.text = f"First: Entity 1 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
|
status_text.text = (f"First: Entity {first_point+1} | "
|
||||||
highlight_path()
|
f"Second: {f'Entity {second_point+1}' if second_point is not None else '?'}")
|
||||||
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()
|
highlight_path()
|
||||||
|
|
||||||
# Letter keys for second entity
|
# Letter keys for second entity
|
||||||
elif keycode == 65 or keycode == 97: # 'A' or 'a'
|
elif key in (mcrfpy.Key.A, mcrfpy.Key.B, mcrfpy.Key.C):
|
||||||
second_point = 0
|
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 1"
|
status_text.text = (f"First: {f'Entity {first_point+1}' if first_point is not None else '?'} | "
|
||||||
highlight_path()
|
f"Second: Entity {second_point+1}")
|
||||||
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()
|
highlight_path()
|
||||||
|
|
||||||
# Clear selection
|
# Clear selection
|
||||||
elif keycode == 32: # Space
|
elif key == mcrfpy.Key.SPACE:
|
||||||
first_point = None
|
first_point = None
|
||||||
second_point = None
|
second_point = None
|
||||||
clear_path_highlight()
|
clear_path_highlight()
|
||||||
|
|
@ -177,7 +195,7 @@ def handle_keypress(scene_name, keycode):
|
||||||
info_text.text = "Space to clear, Q to quit"
|
info_text.text = "Space to clear, Q to quit"
|
||||||
|
|
||||||
# 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...")
|
print("\nExiting Dijkstra interactive demo...")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
@ -190,6 +208,8 @@ print(" A/B/C - Select second entity")
|
||||||
print(" Space - Clear selection")
|
print(" Space - Clear selection")
|
||||||
print(" Q/ESC - Quit")
|
print(" Q/ESC - Quit")
|
||||||
|
|
||||||
|
dijkstra_interactive = mcrfpy.Scene("dijkstra_interactive")
|
||||||
|
|
||||||
# Create map
|
# Create map
|
||||||
grid = 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)
|
legend1.fill_color = mcrfpy.Color(150, 150, 150)
|
||||||
ui.append(legend1)
|
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)
|
legend2.fill_color = mcrfpy.Color(150, 150, 150)
|
||||||
ui.append(legend2)
|
ui.append(legend2)
|
||||||
|
|
||||||
# Mark entity positions with colored indicators
|
# Mark entity positions with colored indicators
|
||||||
for i, entity in enumerate(entities):
|
for i, entity in enumerate(entities):
|
||||||
marker = mcrfpy.Caption(pos=(120 + int(entity.x) * 40 + 15, 60 + int(entity.y) * 40 + 10),
|
ex, ey = entity_cell(entity)
|
||||||
text=str(i+1))
|
marker = mcrfpy.Caption(pos=(120 + ex * 40 + 15, 60 + ey * 40 + 10), text=str(i+1))
|
||||||
marker.fill_color = ENTITY_COLORS[i]
|
marker.fill_color = ENTITY_COLORS[i]
|
||||||
marker.outline = 1
|
marker.outline = 1
|
||||||
marker.outline_color = mcrfpy.Color(0, 0, 0)
|
marker.outline_color = mcrfpy.Color(0, 0, 0)
|
||||||
|
|
@ -243,4 +263,77 @@ dijkstra_interactive.activate()
|
||||||
print("\nVisualization ready!")
|
print("\nVisualization ready!")
|
||||||
print("Entities are at:")
|
print("Entities are at:")
|
||||||
for i, entity in enumerate(entities):
|
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,11 +1,11 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Enhanced Dijkstra Pathfinding Interactive Demo
|
Enhanced Dijkstra Pathfinding Interactive Demo (headless-driven test)
|
||||||
==============================================
|
====================================================================
|
||||||
|
|
||||||
Interactive visualization with entity pathfinding animations.
|
Interactive visualization with entity pathfinding animations.
|
||||||
|
|
||||||
Controls:
|
Controls (when run with a window):
|
||||||
- Press 1/2/3 to select the first entity
|
- 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
|
- Space to clear selection
|
||||||
|
|
@ -13,11 +13,24 @@ Controls:
|
||||||
- P to pause/resume animation
|
- P to pause/resume animation
|
||||||
- R to reset entity positions
|
- R to reset entity positions
|
||||||
- Q or ESC to quit
|
- 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 mcrfpy
|
||||||
import sys
|
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
|
# Colors
|
||||||
WALL_COLOR = mcrfpy.Color(60, 30, 30)
|
WALL_COLOR = mcrfpy.Color(60, 30, 30)
|
||||||
|
|
@ -30,6 +43,9 @@ ENTITY_COLORS = [
|
||||||
mcrfpy.Color(100, 100, 255), # Entity 3 - Blue
|
mcrfpy.Color(100, 100, 255), # Entity 3 - Blue
|
||||||
]
|
]
|
||||||
|
|
||||||
|
GRID_W = 14
|
||||||
|
GRID_H = 10
|
||||||
|
|
||||||
# Global state
|
# Global state
|
||||||
grid = None
|
grid = None
|
||||||
color_layer = None
|
color_layer = None
|
||||||
|
|
@ -42,33 +58,33 @@ animation_progress = 0.0
|
||||||
animation_speed = 2.0 # cells per second
|
animation_speed = 2.0 # cells per second
|
||||||
original_positions = [] # Store original entity positions
|
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():
|
def create_map():
|
||||||
"""Create the interactive map with the layout specified by the user"""
|
"""Create the interactive map with the layout specified by the user"""
|
||||||
global grid, color_layer, entities, original_positions
|
global grid, color_layer, entities, original_positions
|
||||||
|
|
||||||
dijkstra_enhanced = mcrfpy.Scene("dijkstra_enhanced")
|
|
||||||
|
|
||||||
# Create grid - 14x10 as specified
|
# 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)
|
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
||||||
|
|
||||||
# Add color layer for cell coloring
|
# Add color layer for cell coloring (GridPoint.color is gone; use a ColorLayer)
|
||||||
color_layer = grid.add_layer("color", z_index=-1)
|
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||||
|
grid.add_layer(color_layer)
|
||||||
# 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
|
|
||||||
]
|
|
||||||
|
|
||||||
# Create the map
|
# Create the map
|
||||||
entity_positions = []
|
entity_positions = []
|
||||||
|
|
@ -80,12 +96,12 @@ def create_map():
|
||||||
# Wall
|
# Wall
|
||||||
cell.walkable = False
|
cell.walkable = False
|
||||||
cell.transparent = False
|
cell.transparent = False
|
||||||
color_layer.set(x, y, WALL_COLOR)
|
color_layer.set((x, y), WALL_COLOR)
|
||||||
else:
|
else:
|
||||||
# Floor
|
# Floor
|
||||||
cell.walkable = True
|
cell.walkable = True
|
||||||
cell.transparent = True
|
cell.transparent = True
|
||||||
color_layer.set(x, y, FLOOR_COLOR)
|
color_layer.set((x, y), FLOOR_COLOR)
|
||||||
|
|
||||||
if char == 'E':
|
if char == 'E':
|
||||||
# Entity position
|
# Entity position
|
||||||
|
|
@ -95,26 +111,28 @@ def create_map():
|
||||||
entities = []
|
entities = []
|
||||||
original_positions = []
|
original_positions = []
|
||||||
for i, (x, y) in enumerate(entity_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'
|
entity.sprite_index = 49 + i # '1', '2', '3'
|
||||||
entities.append(entity)
|
entities.append(entity)
|
||||||
original_positions.append((x, y))
|
original_positions.append((x, y))
|
||||||
|
|
||||||
return grid
|
return grid
|
||||||
|
|
||||||
|
|
||||||
def clear_path_highlight():
|
def clear_path_highlight():
|
||||||
"""Clear any existing path highlighting"""
|
"""Clear any existing path highlighting"""
|
||||||
global current_path
|
global current_path
|
||||||
|
|
||||||
# Reset all floor tiles to original color
|
# Reset all floor tiles to original color
|
||||||
for y in range(grid.grid_h):
|
for y in range(GRID_H):
|
||||||
for x in range(grid.grid_w):
|
for x in range(GRID_W):
|
||||||
cell = grid.at(x, y)
|
cell = grid.at(x, y)
|
||||||
if cell.walkable:
|
if cell.walkable:
|
||||||
color_layer.set(x, y, FLOOR_COLOR)
|
color_layer.set((x, y), FLOOR_COLOR)
|
||||||
|
|
||||||
current_path = []
|
current_path = []
|
||||||
|
|
||||||
|
|
||||||
def highlight_path():
|
def highlight_path():
|
||||||
"""Highlight the path between selected entities using entity.path_to()"""
|
"""Highlight the path between selected entities using entity.path_to()"""
|
||||||
global current_path
|
global current_path
|
||||||
|
|
@ -129,8 +147,8 @@ def highlight_path():
|
||||||
entity1 = entities[first_point]
|
entity1 = entities[first_point]
|
||||||
entity2 = entities[second_point]
|
entity2 = entities[second_point]
|
||||||
|
|
||||||
# Use the new path_to method!
|
# Use the path_to method! (entity.x/.y are PIXELS now; cells are grid_x/grid_y)
|
||||||
path = entity1.path_to(int(entity2.x), int(entity2.y))
|
path = entity1.path_to(entity2.grid_x, entity2.grid_y)
|
||||||
|
|
||||||
if path:
|
if path:
|
||||||
current_path = path
|
current_path = path
|
||||||
|
|
@ -141,13 +159,13 @@ def highlight_path():
|
||||||
if cell.walkable:
|
if cell.walkable:
|
||||||
# Use gradient for path visualization
|
# Use gradient for path visualization
|
||||||
if i < len(path) - 1:
|
if i < len(path) - 1:
|
||||||
color_layer.set(x, y, PATH_COLOR)
|
color_layer.set((x, y), PATH_COLOR)
|
||||||
else:
|
else:
|
||||||
color_layer.set(x, y, VISITED_COLOR)
|
color_layer.set((x, y), VISITED_COLOR)
|
||||||
|
|
||||||
# Highlight start and end with entity colors
|
# Highlight start and end with entity colors
|
||||||
color_layer.set(int(entity1.x), int(entity1.y), ENTITY_COLORS[first_point])
|
color_layer.set((entity1.grid_x, entity1.grid_y), ENTITY_COLORS[first_point])
|
||||||
color_layer.set(int(entity2.x), int(entity2.y), ENTITY_COLORS[second_point])
|
color_layer.set((entity2.grid_x, entity2.grid_y), ENTITY_COLORS[second_point])
|
||||||
|
|
||||||
# Update info
|
# Update info
|
||||||
info_text.text = f"Path: Entity {first_point+1} to Entity {second_point+1} - {len(path)} steps"
|
info_text.text = f"Path: Entity {first_point+1} to Entity {second_point+1} - {len(path)} steps"
|
||||||
|
|
@ -155,6 +173,7 @@ def highlight_path():
|
||||||
info_text.text = f"No path between Entity {first_point+1} and Entity {second_point+1}"
|
info_text.text = f"No path between Entity {first_point+1} and Entity {second_point+1}"
|
||||||
current_path = []
|
current_path = []
|
||||||
|
|
||||||
|
|
||||||
def animate_movement(dt):
|
def animate_movement(dt):
|
||||||
"""Animate entity movement along path"""
|
"""Animate entity movement along path"""
|
||||||
global animation_progress, animating, current_path
|
global animation_progress, animating, current_path
|
||||||
|
|
@ -174,11 +193,11 @@ def animate_movement(dt):
|
||||||
# Animation complete
|
# Animation complete
|
||||||
animating = False
|
animating = False
|
||||||
animation_progress = 0.0
|
animation_progress = 0.0
|
||||||
# Snap to final position
|
# Snap to final position (grid_pos = logical cell, draw_pos = visual)
|
||||||
if current_path:
|
if current_path:
|
||||||
final_x, final_y = current_path[-1]
|
final_x, final_y = current_path[-1]
|
||||||
entity.x = float(final_x)
|
entity.grid_pos = (final_x, final_y)
|
||||||
entity.y = float(final_y)
|
entity.draw_pos = (float(final_x), float(final_y))
|
||||||
return
|
return
|
||||||
|
|
||||||
# Interpolate between path points
|
# Interpolate between path points
|
||||||
|
|
@ -189,68 +208,61 @@ def animate_movement(dt):
|
||||||
# Calculate interpolation factor
|
# Calculate interpolation factor
|
||||||
t = animation_progress - path_index
|
t = animation_progress - path_index
|
||||||
|
|
||||||
# Smooth interpolation
|
# Smooth interpolation (draw_pos is the fractional render position)
|
||||||
entity.x = curr_x + (next_x - curr_x) * t
|
entity.draw_pos = (curr_x + (next_x - curr_x) * t,
|
||||||
entity.y = curr_y + (next_y - curr_y) * t
|
curr_y + (next_y - curr_y) * t)
|
||||||
|
entity.grid_pos = (curr_x, curr_y)
|
||||||
else:
|
else:
|
||||||
# At last point
|
# 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
|
global first_point, second_point, animating, animation_progress
|
||||||
|
|
||||||
|
if action != mcrfpy.InputState.PRESSED:
|
||||||
|
return
|
||||||
|
|
||||||
# Number keys for first entity
|
# Number keys for first entity
|
||||||
if keycode == 49: # '1'
|
if key in (mcrfpy.Key.NUM_1, mcrfpy.Key.NUM_2, mcrfpy.Key.NUM_3):
|
||||||
first_point = 0
|
first_point = {mcrfpy.Key.NUM_1: 0, mcrfpy.Key.NUM_2: 1, mcrfpy.Key.NUM_3: 2}[key]
|
||||||
status_text.text = f"First: Entity 1 | Second: {f'Entity {second_point+1}' if second_point is not None else '?'}"
|
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()
|
highlight_path()
|
||||||
|
|
||||||
# Letter keys for second entity
|
# Letter keys for second entity
|
||||||
elif keycode == 65 or keycode == 97: # 'A' or 'a'
|
elif key in (mcrfpy.Key.A, mcrfpy.Key.B, mcrfpy.Key.C):
|
||||||
second_point = 0
|
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 1"
|
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()
|
highlight_path()
|
||||||
|
|
||||||
# Movement control
|
# 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:
|
if current_path and first_point is not None:
|
||||||
animating = True
|
animating = True
|
||||||
animation_progress = 0.0
|
animation_progress = 0.0
|
||||||
control_text.text = "Animation: MOVING (press P to pause)"
|
control_text.text = "Animation: MOVING (press P to pause)"
|
||||||
|
|
||||||
# Pause/Resume
|
# Pause/Resume
|
||||||
elif keycode == 80 or keycode == 112: # 'P' or 'p'
|
elif key == mcrfpy.Key.P:
|
||||||
animating = not animating
|
animating = not animating
|
||||||
control_text.text = f"Animation: {'MOVING' if animating else 'PAUSED'} (press P to {'pause' if animating else 'resume'})"
|
control_text.text = f"Animation: {'MOVING' if animating else 'PAUSED'} (press P to {'pause' if animating else 'resume'})"
|
||||||
|
|
||||||
# Reset positions
|
# Reset positions
|
||||||
elif keycode == 82 or keycode == 114: # 'R' or 'r'
|
elif key == mcrfpy.Key.R:
|
||||||
animating = False
|
animating = False
|
||||||
animation_progress = 0.0
|
animation_progress = 0.0
|
||||||
for i, entity in enumerate(entities):
|
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"
|
control_text.text = "Entities reset to original positions"
|
||||||
highlight_path() # Re-highlight path after reset
|
highlight_path() # Re-highlight path after reset
|
||||||
|
|
||||||
# Clear selection
|
# Clear selection
|
||||||
elif keycode == 32: # Space
|
elif key == mcrfpy.Key.SPACE:
|
||||||
first_point = None
|
first_point = None
|
||||||
second_point = None
|
second_point = None
|
||||||
animating = False
|
animating = False
|
||||||
|
|
@ -261,14 +273,25 @@ def handle_keypress(scene_name, keycode):
|
||||||
control_text.text = "Press M to move, P to pause, R to reset"
|
control_text.text = "Press M to move, P to pause, R to reset"
|
||||||
|
|
||||||
# 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 enhanced Dijkstra demo...")
|
print("\nExiting enhanced Dijkstra demo...")
|
||||||
sys.exit(0)
|
sys.exit(0 if not failures else 1)
|
||||||
|
|
||||||
# Timer callback for animation
|
|
||||||
def update_animation(timer, dt):
|
# 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"""
|
"""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
|
# Create the visualization
|
||||||
print("Enhanced Dijkstra Pathfinding Demo")
|
print("Enhanced Dijkstra Pathfinding Demo")
|
||||||
|
|
@ -282,6 +305,8 @@ print(" R - Reset entity positions")
|
||||||
print(" Space - Clear selection")
|
print(" Space - Clear selection")
|
||||||
print(" Q/ESC - Quit")
|
print(" Q/ESC - Quit")
|
||||||
|
|
||||||
|
dijkstra_enhanced = mcrfpy.Scene("dijkstra_enhanced")
|
||||||
|
|
||||||
# Create map
|
# Create map
|
||||||
grid = create_map()
|
grid = create_map()
|
||||||
|
|
||||||
|
|
@ -324,7 +349,7 @@ ui.append(legend2)
|
||||||
|
|
||||||
# Mark entity positions with colored indicators
|
# Mark entity positions with colored indicators
|
||||||
for i, entity in enumerate(entities):
|
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))
|
text=str(i+1))
|
||||||
marker.fill_color = ENTITY_COLORS[i]
|
marker.fill_color = ENTITY_COLORS[i]
|
||||||
marker.outline = 1
|
marker.outline = 1
|
||||||
|
|
@ -343,4 +368,86 @@ dijkstra_enhanced.activate()
|
||||||
print("\nVisualization ready!")
|
print("\nVisualization ready!")
|
||||||
print("Entities are at:")
|
print("Entities are at:")
|
||||||
for i, entity in enumerate(entities):
|
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,26 +4,44 @@ Dijkstra Pathfinding Test - Headless
|
||||||
====================================
|
====================================
|
||||||
|
|
||||||
Tests all Dijkstra functionality and generates a screenshot.
|
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
|
import mcrfpy
|
||||||
from mcrfpy import automation
|
from mcrfpy import automation
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
if not cond:
|
||||||
|
failures.append(msg)
|
||||||
|
print(f"FAIL: {msg}")
|
||||||
|
return cond
|
||||||
|
|
||||||
def create_test_map():
|
def create_test_map():
|
||||||
"""Create a test map with obstacles"""
|
"""Create a test map with obstacles"""
|
||||||
dijkstra_test = mcrfpy.Scene("dijkstra_test")
|
|
||||||
|
|
||||||
# Create grid
|
# 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)
|
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
|
# Initialize all cells as walkable floor
|
||||||
for y in range(12):
|
for y in range(12):
|
||||||
for x in range(20):
|
for x in range(20):
|
||||||
grid.at(x, y).walkable = True
|
grid.at(x, y).walkable = True
|
||||||
grid.at(x, y).transparent = 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
|
# Add walls to create interesting paths
|
||||||
walls = [
|
walls = [
|
||||||
|
|
@ -38,7 +56,7 @@ def create_test_map():
|
||||||
|
|
||||||
for x, y in walls:
|
for x, y in walls:
|
||||||
grid.at(x, y).walkable = False
|
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
|
# Place test entities
|
||||||
entities = []
|
entities = []
|
||||||
|
|
@ -50,16 +68,20 @@ def create_test_map():
|
||||||
]
|
]
|
||||||
|
|
||||||
for i, (x, y) in enumerate(positions):
|
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'
|
entity.sprite_index = 49 + i # '1', '2', '3'
|
||||||
grid.entities.append(entity)
|
grid.entities.append(entity)
|
||||||
entities.append(entity)
|
entities.append(entity)
|
||||||
# Mark entity positions
|
# Mark entity positions
|
||||||
grid.at(x, y).color = colors[i]
|
color_layer.set((x, y), colors[i])
|
||||||
|
|
||||||
return grid, entities
|
# Walkability was mutated after grid construction; drop any cached maps
|
||||||
|
grid.clear_dijkstra_maps()
|
||||||
|
|
||||||
def test_dijkstra(grid, entities):
|
return grid, color_layer, entities, walls
|
||||||
|
|
||||||
|
|
||||||
|
def test_dijkstra(grid, color_layer, entities, walls):
|
||||||
"""Test Dijkstra pathfinding between all entity pairs"""
|
"""Test Dijkstra pathfinding between all entity pairs"""
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
|
|
@ -69,57 +91,100 @@ def test_dijkstra(grid, entities):
|
||||||
# Compute Dijkstra from entity i
|
# Compute Dijkstra from entity i
|
||||||
e1 = entities[i]
|
e1 = entities[i]
|
||||||
e2 = entities[j]
|
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
|
# Get distance and path to entity j
|
||||||
distance = grid.get_dijkstra_distance(int(e2.x), int(e2.y))
|
distance = dmap.distance(dst)
|
||||||
path = grid.get_dijkstra_path(int(e2.x), int(e2.y))
|
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:
|
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")
|
results.append(f"Path {i+1}→{j+1}: {len(path)} steps, {distance:.1f} units")
|
||||||
|
|
||||||
# Color one interesting path
|
# Color one interesting path
|
||||||
if i == 0 and j == 2: # Path from 1 to 3
|
if i == 0 and j == 2: # Path from 1 to 3
|
||||||
for x, y in path[1:-1]: # Skip endpoints
|
for cx, cy in cells:
|
||||||
if grid.at(x, y).walkable:
|
if (cx, cy) not in (src, dst) and grid.at(cx, cy).walkable:
|
||||||
grid.at(x, y).color = mcrfpy.Color(200, 250, 220)
|
color_layer.set((cx, cy), mcrfpy.Color(200, 250, 220))
|
||||||
else:
|
else:
|
||||||
results.append(f"Path {i+1}→{j+1}: No path found!")
|
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
|
return results
|
||||||
|
|
||||||
|
|
||||||
def run_test(timer, runtime):
|
def run_test(timer, runtime):
|
||||||
"""Timer callback to run tests and take screenshot"""
|
"""Timer callback to run tests"""
|
||||||
# Run pathfinding tests
|
global results
|
||||||
results = test_dijkstra(grid, entities)
|
results = test_dijkstra(grid, color_layer, entities, walls)
|
||||||
|
|
||||||
# Update display with results
|
# Update display with results
|
||||||
y_pos = 380
|
y_pos = 380
|
||||||
for result in results:
|
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)
|
caption.fill_color = mcrfpy.Color(200, 200, 200)
|
||||||
ui.append(caption)
|
ui.append(caption)
|
||||||
y_pos += 20
|
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
|
# Create test map
|
||||||
print("Creating Dijkstra pathfinding test...")
|
print("Creating Dijkstra pathfinding test...")
|
||||||
grid, entities = create_test_map()
|
grid, color_layer, entities, walls = create_test_map()
|
||||||
|
results = None
|
||||||
|
|
||||||
# Set up UI
|
# Set up UI
|
||||||
|
dijkstra_test = mcrfpy.Scene("dijkstra_test")
|
||||||
ui = dijkstra_test.children
|
ui = dijkstra_test.children
|
||||||
ui.append(grid)
|
ui.append(grid)
|
||||||
|
|
||||||
|
|
@ -133,14 +198,40 @@ title.fill_color = mcrfpy.Color(255, 255, 255)
|
||||||
ui.append(title)
|
ui.append(title)
|
||||||
|
|
||||||
# Add legend
|
# 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)
|
legend.fill_color = mcrfpy.Color(180, 180, 180)
|
||||||
ui.append(legend)
|
ui.append(legend)
|
||||||
|
|
||||||
# Set scene
|
# Set scene
|
||||||
dijkstra_test.activate()
|
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)
|
test_timer = mcrfpy.Timer("test", run_test, 100, once=True)
|
||||||
|
|
||||||
print("Running Dijkstra tests...")
|
print("Running Dijkstra tests...")
|
||||||
|
for _ in range(10):
|
||||||
|
mcrfpy.step(0.05)
|
||||||
|
if results is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
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
|
#!/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 sys
|
||||||
import os
|
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
|
def check(label, condition, detail=""):
|
||||||
os.environ['PYTHONSTARTUP'] = ''
|
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
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Interactive Visibility Demo
|
Interactive Visibility Test
|
||||||
==========================
|
===========================
|
||||||
|
|
||||||
Controls:
|
Originally an interactive demo (WASD moves the player, arrows move the enemy,
|
||||||
- WASD: Move the player (green @)
|
Tab cycles perspective, Space recomputes visibility, R resets). Headless mode
|
||||||
- Arrow keys: Move enemy (red E)
|
never delivers real keystrokes, so the same key handler is now driven with
|
||||||
- Tab: Cycle perspective (Omniscient → Player → Enemy → Omniscient)
|
mcrfpy.automation.keyDown/keyUp and the resulting state is asserted.
|
||||||
- Space: Update visibility for current entity
|
|
||||||
- R: Reset positions
|
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
|
import mcrfpy
|
||||||
|
from mcrfpy import automation
|
||||||
import sys
|
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
|
# Create scene and grid
|
||||||
visibility_demo = mcrfpy.Scene("visibility_demo")
|
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
|
grid.fill_color = mcrfpy.Color(20, 20, 30) # Dark background
|
||||||
|
|
||||||
# Add color layer for cell coloring
|
# Add color layer for cell coloring (GridPoint.color no longer exists)
|
||||||
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
|
# Initialize grid - all walkable and transparent
|
||||||
for y in range(20):
|
for y in range(GRID_H):
|
||||||
for x in range(30):
|
for x in range(GRID_W):
|
||||||
cell = grid.at(x, y)
|
cell = grid.at(x, y)
|
||||||
cell.walkable = True
|
cell.walkable = True
|
||||||
cell.transparent = 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
|
# Create walls
|
||||||
walls = [
|
walls = [
|
||||||
# Central cross
|
# Central cross - a solid wall separating left half from right half
|
||||||
[(15, y) for y in range(8, 12)],
|
[(15, y) for y in range(0, GRID_H)],
|
||||||
[(x, 10) for x in range(13, 18)],
|
|
||||||
|
|
||||||
# Rooms
|
# Rooms
|
||||||
# Top-left room
|
# Top-left room
|
||||||
|
|
@ -54,33 +76,42 @@ walls = [
|
||||||
[(28, y) for y in range(15, 18)] + [(x, 18) for x in range(22, 28)],
|
[(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 wall_group in walls:
|
||||||
for x, y in wall_group:
|
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 = grid.at(x, y)
|
||||||
cell.walkable = False
|
cell.walkable = False
|
||||||
cell.transparent = 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
|
# Create entities
|
||||||
player = mcrfpy.Entity((5, 10), grid=grid)
|
player = mcrfpy.Entity(grid_pos=(5, 10))
|
||||||
player.sprite_index = 64 # @
|
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
|
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
|
# Update initial visibility
|
||||||
player.update_visibility()
|
player.update_visibility()
|
||||||
enemy.update_visibility()
|
enemy.update_visibility()
|
||||||
|
|
||||||
# Global state
|
# Global state: perspective is now an Entity | None, not an int index
|
||||||
current_perspective = -1
|
perspectives = [None, player, enemy]
|
||||||
perspective_names = ["Omniscient", "Player", "Enemy"]
|
perspective_names = ["Omniscient", "Player", "Enemy"]
|
||||||
|
current_perspective = 0
|
||||||
|
|
||||||
# UI Setup
|
# UI Setup
|
||||||
ui = visibility_demo.children
|
ui = visibility_demo.children
|
||||||
ui.append(grid)
|
ui.append(grid)
|
||||||
grid.pos = (50, 100)
|
|
||||||
grid.size = (900, 600) # 30*30, 20*30
|
|
||||||
|
|
||||||
# Title
|
# Title
|
||||||
title = mcrfpy.Caption(pos=(350, 20), text="Interactive Visibility Demo")
|
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)
|
perspective_label.fill_color = mcrfpy.Color(200, 200, 200)
|
||||||
ui.append(perspective_label)
|
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 = mcrfpy.Caption(pos=(700, 50), text="Player: (5, 10)")
|
||||||
player_info.fill_color = mcrfpy.Color(100, 255, 100)
|
player_info.fill_color = mcrfpy.Color(100, 255, 100)
|
||||||
ui.append(player_info)
|
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)
|
enemy_info.fill_color = mcrfpy.Color(255, 100, 100)
|
||||||
ui.append(enemy_info)
|
ui.append(enemy_info)
|
||||||
|
|
||||||
|
|
||||||
# Helper functions
|
# Helper functions
|
||||||
def move_entity(entity, dx, dy):
|
def move_entity(entity, dx, dy):
|
||||||
"""Move entity if target is walkable"""
|
"""Move entity if target is walkable"""
|
||||||
new_x = int(entity.x + dx)
|
new_x = int(entity.grid_x + dx)
|
||||||
new_y = int(entity.y + dy)
|
new_y = int(entity.grid_y + dy)
|
||||||
|
|
||||||
if 0 <= new_x < 30 and 0 <= new_y < 20:
|
if 0 <= new_x < GRID_W and 0 <= new_y < GRID_H:
|
||||||
cell = grid.at(new_x, new_y)
|
cell = grid.at(new_x, new_y)
|
||||||
if cell.walkable:
|
if cell.walkable:
|
||||||
entity.x = new_x
|
entity.grid_pos = (new_x, new_y)
|
||||||
entity.y = new_y
|
|
||||||
entity.update_visibility()
|
entity.update_visibility()
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def update_info():
|
def update_info():
|
||||||
"""Update info displays"""
|
"""Update info displays"""
|
||||||
player_info.text = f"Player: ({int(player.x)}, {int(player.y)})"
|
player_info.text = f"Player: ({player.grid_x}, {player.grid_y})"
|
||||||
enemy_info.text = f"Enemy: ({int(enemy.x)}, {int(enemy.y)})"
|
enemy_info.text = f"Enemy: ({enemy.grid_x}, {enemy.grid_y})"
|
||||||
|
|
||||||
|
|
||||||
def cycle_perspective():
|
def cycle_perspective():
|
||||||
"""Cycle through perspectives"""
|
"""Cycle through perspectives: Omniscient -> Player -> Enemy -> Omniscient"""
|
||||||
global current_perspective
|
global current_perspective
|
||||||
|
current_perspective = (current_perspective + 1) % 3
|
||||||
|
grid.perspective = perspectives[current_perspective]
|
||||||
|
perspective_label.text = f"Perspective: {perspective_names[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}"
|
|
||||||
|
|
||||||
# Key handlers
|
# Key handlers
|
||||||
def handle_keys(key, state):
|
def handle_keys(key, state):
|
||||||
"""Handle keyboard input"""
|
"""Handle keyboard input"""
|
||||||
if state == mcrfpy.InputState.RELEASED: return
|
if state == mcrfpy.InputState.RELEASED:
|
||||||
|
return
|
||||||
# Player movement (WASD)
|
# Player movement (WASD)
|
||||||
if key == mcrfpy.Key.W:
|
if key == mcrfpy.Key.W:
|
||||||
move_entity(player, 0, -1)
|
move_entity(player, 0, -1)
|
||||||
|
|
@ -167,37 +194,120 @@ def handle_keys(key, state):
|
||||||
elif key == mcrfpy.Key.SPACE:
|
elif key == mcrfpy.Key.SPACE:
|
||||||
player.update_visibility()
|
player.update_visibility()
|
||||||
enemy.update_visibility()
|
enemy.update_visibility()
|
||||||
print("Updated visibility for both entities")
|
|
||||||
|
|
||||||
# R to reset
|
# R to reset
|
||||||
elif key == mcrfpy.Key.R:
|
elif key == mcrfpy.Key.R:
|
||||||
player.x, player.y = 5, 10
|
player.grid_pos = (5, 10)
|
||||||
enemy.x, enemy.y = 25, 10
|
enemy.grid_pos = (25, 10)
|
||||||
player.update_visibility()
|
player.update_visibility()
|
||||||
enemy.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()
|
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
|
visibility_demo.on_key = handle_keys
|
||||||
|
|
||||||
print("Interactive Visibility Demo")
|
|
||||||
print("===========================")
|
def press(key):
|
||||||
print("WASD: Move player (green @)")
|
"""Deliver a real key event to the scene handler."""
|
||||||
print("Arrows: Move enemy (red E)")
|
automation.keyDown(key)
|
||||||
print("Tab: Cycle perspective")
|
automation.keyUp(key)
|
||||||
print("Space: Update visibility")
|
|
||||||
print("R: Reset positions")
|
|
||||||
print("Q: Quit")
|
UNKNOWN = mcrfpy.Perspective.UNKNOWN
|
||||||
print("\nCurrent perspective: Omniscient (shows all)")
|
DISCOVERED = mcrfpy.Perspective.DISCOVERED
|
||||||
print("Try moving entities and switching perspectives!")
|
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 mcrfpy
|
||||||
import sys
|
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
|
# Create scene and grid
|
||||||
print("Creating scene...")
|
print("Creating scene...")
|
||||||
vis_test = mcrfpy.Scene("vis_test")
|
vis_test = mcrfpy.Scene("vis_test")
|
||||||
|
|
||||||
print("Creating grid...")
|
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
|
# 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
|
# Initialize grid
|
||||||
print("Initializing grid...")
|
print("Initializing grid...")
|
||||||
|
|
@ -21,29 +34,61 @@ for y in range(10):
|
||||||
cell = grid.at(x, y)
|
cell = grid.at(x, y)
|
||||||
cell.walkable = True
|
cell.walkable = True
|
||||||
cell.transparent = 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
|
# Create entity
|
||||||
print("Creating entity...")
|
print("Creating entity...")
|
||||||
entity = mcrfpy.Entity((5, 5), grid=grid)
|
entity = mcrfpy.Entity(grid_pos=(5, 5))
|
||||||
entity.sprite_index = 64
|
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...")
|
print("Updating visibility...")
|
||||||
entity.update_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
|
# Set up UI
|
||||||
print("Setting up UI...")
|
print("Setting up UI...")
|
||||||
ui = vis_test.children
|
ui = vis_test.children
|
||||||
ui.append(grid)
|
ui.append(grid)
|
||||||
grid.pos = (50, 50)
|
check(len(ui) == 1, "grid appended to scene UI")
|
||||||
grid.size = (300, 300)
|
|
||||||
|
|
||||||
# Test perspective
|
# 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...")
|
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}")
|
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...")
|
print("Setting scene...")
|
||||||
vis_test.activate()
|
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
|
#!/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 mcrfpy
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
print("Simple visibility test...")
|
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
|
# Create scene and grid
|
||||||
simple = mcrfpy.Scene("simple")
|
simple = mcrfpy.Scene("simple")
|
||||||
print("Scene created")
|
print("Scene created")
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
|
grid = mcrfpy.Grid(grid_size=(5, 5))
|
||||||
print("Grid created")
|
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
|
# Create entity with grid association
|
||||||
entity = mcrfpy.Entity((2, 2), grid=grid)
|
entity = mcrfpy.Entity(grid_pos=(3, 2))
|
||||||
print(f"Entity created at ({entity.x}, {entity.y})")
|
grid.entities.append(entity)
|
||||||
|
print(f"Entity created at {entity.cell_pos}")
|
||||||
|
|
||||||
# Check if gridstate is initialized
|
# Check that per-entity visibility memory is initialized
|
||||||
print(f"Gridstate length: {len(entity.gridstate)}")
|
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
|
# at() before any FOV computation: nothing is visible yet
|
||||||
try:
|
check("at(0, 0) is None before update_visibility", entity.at(0, 0) is None)
|
||||||
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}")
|
|
||||||
|
|
||||||
# Try update_visibility
|
# Compute visibility from (3, 2)
|
||||||
try:
|
entity.update_visibility()
|
||||||
entity.update_visibility()
|
print("update_visibility() succeeded")
|
||||||
print("update_visibility() succeeded")
|
|
||||||
except Exception as e:
|
check("own cell is VISIBLE", state_map[3, 2] == mcrfpy.Perspective.VISIBLE)
|
||||||
print(f"Error in update_visibility(): {e}")
|
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")
|
print("Test complete")
|
||||||
|
print("PASS")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
@ -1,23 +1,107 @@
|
||||||
#!/usr/bin/env python3
|
#!/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 sys
|
||||||
|
import os
|
||||||
|
import traceback
|
||||||
import mcrfpy
|
import mcrfpy
|
||||||
|
|
||||||
# Monkey-patch to detect interactive mode
|
failures = []
|
||||||
original_ps1 = None
|
accesses = []
|
||||||
if hasattr(sys, 'ps1'):
|
|
||||||
original_ps1 = sys.ps1
|
|
||||||
|
def check(label, condition, detail=""):
|
||||||
|
if condition:
|
||||||
|
print(" ok : %s" % label)
|
||||||
|
else:
|
||||||
|
print(" FAIL : %s %s" % (label, detail))
|
||||||
|
failures.append(label)
|
||||||
|
|
||||||
|
|
||||||
class PS1Detector:
|
class PS1Detector:
|
||||||
def __repr__(self):
|
"""Screams (and records) if anything asks for the interactive prompt."""
|
||||||
import traceback
|
|
||||||
print("\n!!! sys.ps1 accessed! Stack trace:")
|
def _trip(self, how):
|
||||||
|
accesses.append(how)
|
||||||
|
print("\n!!! sys.ps1 accessed via %s! Stack trace:" % how)
|
||||||
traceback.print_stack()
|
traceback.print_stack()
|
||||||
return ">>> "
|
return ">>> "
|
||||||
|
|
||||||
# Set our detector
|
def __repr__(self):
|
||||||
sys.ps1 = PS1Detector()
|
return self._trip("__repr__")
|
||||||
|
|
||||||
print("Trace script loaded, ps1 detector installed")
|
def __str__(self):
|
||||||
|
return self._trip("__str__")
|
||||||
|
|
||||||
# Do nothing else - let the game run
|
|
||||||
|
# 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 mcrfpy
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
def test_grid_creates_view():
|
def test_grid_creates_grid_data():
|
||||||
"""Grid auto-creates a GridView accessible via .view property."""
|
"""Grid auto-creates its GridData, accessible via .grid_data."""
|
||||||
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160))
|
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 grid.grid_data is not None, "Grid should auto-create its grid data"
|
||||||
assert isinstance(grid.view, mcrfpy.GridView)
|
assert isinstance(grid.grid_data, mcrfpy.GridData)
|
||||||
print("PASS: Grid auto-creates view")
|
# 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():
|
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)
|
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160))
|
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
|
# The view must share -- not copy -- the source grid's data.
|
||||||
assert view.grid is grid, "view.grid should be the same Grid"
|
assert view.grid_data is grid.grid_data, "view should share the Grid's GridData"
|
||||||
assert view.grid.grid_w == 10
|
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")
|
print("PASS: view shares grid data")
|
||||||
|
|
||||||
def test_rendering_property_sync():
|
def test_rendering_properties():
|
||||||
"""Setting rendering properties on Grid syncs to the view."""
|
"""Rendering properties live on the view (Grid) itself and round-trip."""
|
||||||
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160))
|
grid = mcrfpy.Grid(grid_size=(10, 10), texture=tex, pos=(0, 0), size=(160, 160))
|
||||||
view = grid.view
|
|
||||||
|
|
||||||
grid.zoom = 3.0
|
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
|
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
|
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
|
grid.camera_rotation = 45.0
|
||||||
# camera_rotation syncs through set_float_member
|
assert abs(grid.camera_rotation - 45.0) < 0.01, f"camera_rotation should round-trip: {grid.camera_rotation}"
|
||||||
print("PASS: rendering properties sync to view")
|
|
||||||
|
# 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():
|
def test_grid_still_works_in_scene():
|
||||||
"""Grid can still be appended to scenes and works as before."""
|
"""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)
|
# Grid is in the scene as itself (not substituted)
|
||||||
assert len(scene.children) == 1
|
assert len(scene.children) == 1
|
||||||
retrieved = scene.children[0]
|
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")
|
print("PASS: Grid works in scene as before")
|
||||||
|
|
||||||
def test_grid_subclass_preserved():
|
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))
|
grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320))
|
||||||
scene.children.append(grid)
|
scene.children.append(grid)
|
||||||
|
|
||||||
|
data = grid.grid_data
|
||||||
for y in range(20):
|
for y in range(20):
|
||||||
for x in range(20):
|
for x in range(20):
|
||||||
grid.at(x, y).walkable = True
|
data.at(x, y).walkable = True
|
||||||
grid.at(x, y).transparent = True
|
data.at(x, y).transparent = True
|
||||||
|
|
||||||
e = mcrfpy.Entity((5, 5), grid=grid)
|
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_x == 5
|
||||||
assert e.cell_y == 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")
|
print("PASS: entity operations unaffected")
|
||||||
|
|
||||||
def test_gridview_independent_rendering():
|
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")
|
scene = mcrfpy.Scene("test_independent")
|
||||||
mcrfpy.current_scene = scene
|
mcrfpy.current_scene = scene
|
||||||
|
|
||||||
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||||
grid = mcrfpy.Grid(grid_size=(20, 20), texture=tex, pos=(0, 0), size=(320, 320))
|
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)
|
view2 = mcrfpy.GridView(grid=grid, pos=(350, 0), size=(160, 160), zoom=0.5)
|
||||||
scene.children.append(grid)
|
scene.children.append(grid)
|
||||||
scene.children.append(view2)
|
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
|
# Grid and view2 should have different zoom
|
||||||
assert abs(grid.zoom - 1.0) < 0.01
|
assert abs(grid.zoom - 1.0) < 0.01
|
||||||
assert abs(view2.zoom - 0.5) < 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
|
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")
|
print("PASS: GridView independent rendering")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
test_grid_creates_view()
|
tests = [
|
||||||
test_view_shares_grid_data()
|
test_grid_creates_grid_data,
|
||||||
test_rendering_property_sync()
|
test_view_shares_grid_data,
|
||||||
test_grid_still_works_in_scene()
|
test_rendering_properties,
|
||||||
test_grid_subclass_preserved()
|
test_grid_still_works_in_scene,
|
||||||
test_entity_operations_unaffected()
|
test_grid_subclass_preserved,
|
||||||
test_gridview_independent_rendering()
|
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("All backward compatibility tests passed")
|
||||||
|
print("PASS")
|
||||||
sys.exit(0)
|
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
|
NOTE: This test uses ColorLayer for color operations since cell.color
|
||||||
is no longer supported. The chunk system affects internal storage, which
|
is no longer supported. The chunk system affects internal storage, which
|
||||||
ColorLayer also uses.
|
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
|
import mcrfpy
|
||||||
|
from mcrfpy import automation
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
def test_small_grid():
|
def test_small_grid():
|
||||||
|
|
@ -23,23 +32,28 @@ def test_small_grid():
|
||||||
|
|
||||||
# Small grid should use flat storage
|
# Small grid should use flat storage
|
||||||
grid = mcrfpy.Grid(grid_size=(50, 50), pos=(10, 10), size=(400, 400))
|
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
|
# Set some cells
|
||||||
for y in range(50):
|
for y in range(50):
|
||||||
for x in range(50):
|
for x in range(50):
|
||||||
cell = grid.at(x, y)
|
cell = grid.at(x, y)
|
||||||
color_layer.set(x, y, mcrfpy.Color((x * 5) % 256, (y * 5) % 256, 128, 255))
|
color_layer.set((x, y), mcrfpy.Color((x * 5) % 256, (y * 5) % 256, 128, 255))
|
||||||
cell.tilesprite = -1
|
cell.walkable = ((x + y) % 2 == 0)
|
||||||
|
|
||||||
# Verify cells
|
# Verify cells
|
||||||
expected_r = (25 * 5) % 256
|
expected_r = (25 * 5) % 256
|
||||||
expected_g = (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:
|
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})")
|
print(f"FAIL: Small grid cell color mismatch. Expected ({expected_r}, {expected_g}), got ({color.r}, {color.g})")
|
||||||
return False
|
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")
|
print(" Small grid: PASS")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
@ -49,7 +63,7 @@ def test_large_grid():
|
||||||
|
|
||||||
# Large grid should use chunk storage (100 > 64)
|
# Large grid should use chunk storage (100 > 64)
|
||||||
grid = mcrfpy.Grid(grid_size=(100, 100), pos=(10, 10), size=(400, 400))
|
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
|
# Set cells across multiple chunks
|
||||||
# Chunks are 64x64, so a 100x100 grid has 2x2 = 4 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:
|
for x, y in test_points:
|
||||||
cell = grid.at(x, y)
|
cell = grid.at(x, y)
|
||||||
color_layer.set(x, y, mcrfpy.Color(x, y, 100, 255))
|
color_layer.set((x, y), mcrfpy.Color(x, y, 100, 255))
|
||||||
cell.tilesprite = -1
|
cell.walkable = False
|
||||||
|
|
||||||
# Verify cells
|
# Verify cells
|
||||||
for x, y in test_points:
|
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:
|
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})")
|
print(f"FAIL: Large grid cell ({x},{y}) color mismatch. Expected ({x}, {y}), got ({color.r}, {color.g})")
|
||||||
return False
|
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")
|
print(" Large grid cell access: PASS")
|
||||||
return True
|
return True
|
||||||
|
|
@ -84,7 +101,7 @@ def test_very_large_grid():
|
||||||
|
|
||||||
# 500x500 = 250,000 cells, should use ~64 chunks (8x8)
|
# 500x500 = 250,000 cells, should use ~64 chunks (8x8)
|
||||||
grid = mcrfpy.Grid(grid_size=(500, 500), pos=(10, 10), size=(400, 400))
|
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
|
# Set some cells at various positions
|
||||||
test_points = [
|
test_points = [
|
||||||
|
|
@ -98,11 +115,11 @@ def test_very_large_grid():
|
||||||
]
|
]
|
||||||
|
|
||||||
for x, y in test_points:
|
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
|
# Verify
|
||||||
for x, y in test_points:
|
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):
|
if color.r != (x % 256) or color.g != (y % 256):
|
||||||
print(f"FAIL: Very large grid cell ({x},{y}) color mismatch")
|
print(f"FAIL: Very large grid cell ({x},{y}) color mismatch")
|
||||||
return False
|
return False
|
||||||
|
|
@ -116,18 +133,18 @@ def test_boundary_case():
|
||||||
|
|
||||||
# 64x64 should use flat storage (not exceeding threshold)
|
# 64x64 should use flat storage (not exceeding threshold)
|
||||||
grid_64 = mcrfpy.Grid(grid_size=(64, 64), pos=(10, 10), size=(400, 400))
|
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 = grid_64.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
|
||||||
color_layer_64.set(63, 63, mcrfpy.Color(255, 0, 0, 255))
|
color_layer_64.set((63, 63), mcrfpy.Color(255, 0, 0, 255))
|
||||||
color = color_layer_64.at(63, 63)
|
color = color_layer_64.at((63, 63))
|
||||||
if color.r != 255:
|
if color.r != 255:
|
||||||
print(f"FAIL: 64x64 grid boundary cell not set correctly, got r={color.r}")
|
print(f"FAIL: 64x64 grid boundary cell not set correctly, got r={color.r}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 65x65 should use chunk storage (exceeding threshold)
|
# 65x65 should use chunk storage (exceeding threshold)
|
||||||
grid_65 = mcrfpy.Grid(grid_size=(65, 65), pos=(10, 10), size=(400, 400))
|
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 = grid_65.add_layer(mcrfpy.ColorLayer(name="color", z_index=-1))
|
||||||
color_layer_65.set(64, 64, mcrfpy.Color(0, 255, 0, 255))
|
color_layer_65.set((64, 64), mcrfpy.Color(0, 255, 0, 255))
|
||||||
color = color_layer_65.at(64, 64)
|
color = color_layer_65.at((64, 64))
|
||||||
if color.g != 255:
|
if color.g != 255:
|
||||||
print(f"FAIL: 65x65 grid cell not set correctly, got g={color.g}")
|
print(f"FAIL: 65x65 grid cell not set correctly, got g={color.g}")
|
||||||
return False
|
return False
|
||||||
|
|
@ -141,16 +158,16 @@ def test_edge_cases():
|
||||||
|
|
||||||
# Create 100x100 grid
|
# Create 100x100 grid
|
||||||
grid = mcrfpy.Grid(grid_size=(100, 100), pos=(10, 10), size=(400, 400))
|
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
|
# Test all corners
|
||||||
corners = [(0, 0), (99, 0), (0, 99), (99, 99)]
|
corners = [(0, 0), (99, 0), (0, 99), (99, 99)]
|
||||||
for i, (x, y) in enumerate(corners):
|
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):
|
for i, (x, y) in enumerate(corners):
|
||||||
expected = i * 60
|
expected = i * 60
|
||||||
color = color_layer.at(x, y)
|
color = color_layer.at((x, y))
|
||||||
if color.r != expected:
|
if color.r != expected:
|
||||||
print(f"FAIL: Corner ({x},{y}) color mismatch, expected {expected}, got {color.r}")
|
print(f"FAIL: Corner ({x},{y}) color mismatch, expected {expected}, got {color.r}")
|
||||||
return False
|
return False
|
||||||
|
|
@ -158,6 +175,31 @@ def test_edge_cases():
|
||||||
print(" Edge cases: PASS")
|
print(" Edge cases: PASS")
|
||||||
return True
|
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
|
# Main
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
@ -173,9 +215,11 @@ if __name__ == "__main__":
|
||||||
results.append(test_very_large_grid())
|
results.append(test_very_large_grid())
|
||||||
results.append(test_boundary_case())
|
results.append(test_boundary_case())
|
||||||
results.append(test_edge_cases())
|
results.append(test_edge_cases())
|
||||||
|
results.append(test_rendering(test))
|
||||||
|
|
||||||
if all(results):
|
if all(results):
|
||||||
print("\n=== ALL TESTS PASSED ===")
|
print("\n=== ALL TESTS PASSED ===")
|
||||||
|
print("PASS")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
else:
|
else:
|
||||||
print("\n=== SOME TESTS FAILED ===")
|
print("\n=== SOME TESTS FAILED ===")
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ print("=" * 60)
|
||||||
test = mcrfpy.Scene("test")
|
test = mcrfpy.Scene("test")
|
||||||
mcrfpy.current_scene = test
|
mcrfpy.current_scene = test
|
||||||
ui = test.children
|
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)
|
grid = mcrfpy.Grid(pos=(0,0), size=(400,300), grid_size=(50, 50), texture=texture)
|
||||||
ui.append(grid)
|
ui.append(grid)
|
||||||
|
|
@ -34,13 +34,15 @@ for y in range(50):
|
||||||
cell.walkable = True
|
cell.walkable = True
|
||||||
cell.transparent = True
|
cell.transparent = True
|
||||||
|
|
||||||
# Add some walls to test blocking
|
# Add a wall that actually occludes: a vertical run at x=20, spanning the
|
||||||
for i in range(10, 20):
|
# rows around the viewer at (25,25). Cells at x < 20 on row 25 are then
|
||||||
grid.at(i, 25).transparent = False
|
# within radius but geometrically behind the wall.
|
||||||
grid.at(i, 25).walkable = False
|
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 ---")
|
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:
|
if result is None:
|
||||||
print(" PASS: compute_fov() returned None")
|
print(" PASS: compute_fov() returned None")
|
||||||
else:
|
else:
|
||||||
|
|
@ -55,16 +57,16 @@ else:
|
||||||
print(" FAIL: Center should be in FOV")
|
print(" FAIL: Center should be in FOV")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Cell within radius should be visible
|
# Cell within radius and in front of the wall should be visible
|
||||||
if grid.is_in_fov(20, 25):
|
if grid.is_in_fov(22, 25):
|
||||||
print(" PASS: Cell (20,25) within radius is in FOV")
|
print(" PASS: Cell (22,25) within radius is in FOV")
|
||||||
else:
|
else:
|
||||||
print(" FAIL: Cell (20,25) should be in FOV")
|
print(" FAIL: Cell (22,25) should be in FOV")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Cell behind wall should NOT be visible
|
# Cell behind the wall (within radius) should NOT be visible
|
||||||
if not grid.is_in_fov(15, 30):
|
if not grid.is_in_fov(18, 25):
|
||||||
print(" PASS: Cell (15,30) behind wall is NOT in FOV")
|
print(" PASS: Cell (18,25) behind wall is NOT in FOV")
|
||||||
else:
|
else:
|
||||||
print(" FAIL: Cell behind wall should not be in FOV")
|
print(" FAIL: Cell behind wall should not be in FOV")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
@ -89,7 +91,7 @@ for y in range(0, 200, 5): # Sample for speed
|
||||||
times = []
|
times = []
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
t0 = time.perf_counter()
|
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
|
elapsed = (time.perf_counter() - t0) * 1000
|
||||||
times.append(elapsed)
|
times.append(elapsed)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,13 @@ Tests:
|
||||||
2. TileLayer creation and manipulation
|
2. TileLayer creation and manipulation
|
||||||
3. Layer z_index ordering relative to entities
|
3. Layer z_index ordering relative to entities
|
||||||
4. Layer management (add_layer, remove_layer, layers property)
|
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 mcrfpy
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -19,21 +26,22 @@ print("=" * 60)
|
||||||
test = mcrfpy.Scene("test")
|
test = mcrfpy.Scene("test")
|
||||||
mcrfpy.current_scene = test
|
mcrfpy.current_scene = test
|
||||||
ui = test.children
|
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)
|
# 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)
|
ui.append(grid)
|
||||||
|
grid_data = grid.grid_data
|
||||||
|
|
||||||
print("\n--- Test 1: Initial state (no layers) ---")
|
print("\n--- Test 1: Initial state (no layers) ---")
|
||||||
if len(grid.layers) == 0:
|
if len(grid.layers) == 0:
|
||||||
print(" PASS: Grid starts with no layers (layers={})")
|
print(" PASS: Grid starts with no layers (layers=[])")
|
||||||
else:
|
else:
|
||||||
print(f" FAIL: Expected 0 layers, got {len(grid.layers)}")
|
print(f" FAIL: Expected 0 layers, got {len(grid.layers)}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
print("\n--- Test 2: Add ColorLayer ---")
|
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}")
|
print(f" Created: {color_layer}")
|
||||||
if color_layer is not None:
|
if color_layer is not None:
|
||||||
print(" PASS: ColorLayer created")
|
print(" PASS: ColorLayer created")
|
||||||
|
|
@ -63,7 +71,7 @@ else:
|
||||||
|
|
||||||
print("\n--- Test 3: ColorLayer cell access ---")
|
print("\n--- Test 3: ColorLayer cell access ---")
|
||||||
# Set a color
|
# 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)
|
color = color_layer.at(5, 5)
|
||||||
if color.r == 255 and color.g == 0 and color.b == 0 and color.a == 128:
|
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}")
|
print(f" PASS: Color at (5,5) is {color.r}, {color.g}, {color.b}, {color.a}")
|
||||||
|
|
@ -81,7 +89,7 @@ else:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
print("\n--- Test 4: Add TileLayer ---")
|
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}")
|
print(f" Created: {tile_layer}")
|
||||||
if tile_layer is not None:
|
if tile_layer is not None:
|
||||||
print(" PASS: TileLayer created")
|
print(" PASS: TileLayer created")
|
||||||
|
|
@ -97,7 +105,7 @@ else:
|
||||||
|
|
||||||
print("\n--- Test 5: TileLayer cell access ---")
|
print("\n--- Test 5: TileLayer cell access ---")
|
||||||
# Set a tile
|
# Set a tile
|
||||||
tile_layer.set(3, 3, 42)
|
tile_layer.set((3, 3), 42)
|
||||||
tile = tile_layer.at(3, 3)
|
tile = tile_layer.at(3, 3)
|
||||||
if tile == 42:
|
if tile == 42:
|
||||||
print(f" PASS: Tile at (3,3) is {tile}")
|
print(f" PASS: Tile at (3,3) is {tile}")
|
||||||
|
|
@ -129,30 +137,34 @@ else:
|
||||||
print(f" FAIL: Layers not sorted")
|
print(f" FAIL: Layers not sorted")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
print("\n--- Test 7: Get layer by z_index ---")
|
print("\n--- Test 7: Get layer by name ---")
|
||||||
layer = grid.layer(-1)
|
# Layer lookup is by NAME now (grid.layer(z_index) no longer exists); z_index is still
|
||||||
if layer is not None and layer.z_index == -1:
|
# the ordering key, verified in Test 6. Note: layer lookups return a fresh wrapper each
|
||||||
print(" PASS: grid.layer(-1) returns ColorLayer")
|
# 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:
|
else:
|
||||||
print(" FAIL: Could not get layer by z_index")
|
print(" FAIL: Could not get ColorLayer by name")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
layer = grid.layer(-2)
|
layer = grid_data.layer("tile")
|
||||||
if layer is not None and layer.z_index == -2:
|
if layer is not None and layer.z_index == -2 and layer.at(3, 3) == tile_layer.at(3, 3):
|
||||||
print(" PASS: grid.layer(-2) returns TileLayer")
|
print(" PASS: grid_data.layer('tile') returns the TileLayer")
|
||||||
else:
|
else:
|
||||||
print(" FAIL: Could not get layer by z_index")
|
print(" FAIL: Could not get TileLayer by name")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
layer = grid.layer(999)
|
layer = grid_data.layer("nonexistent")
|
||||||
if layer is None:
|
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:
|
else:
|
||||||
print(" FAIL: Should return None for non-existent layer")
|
print(" FAIL: Should return None for non-existent layer")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
print("\n--- Test 8: Layer above entities (z_index >= 0) ---")
|
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:
|
if fog_layer.z_index == 1:
|
||||||
print(" PASS: Created layer with z_index=1 (above entities)")
|
print(" PASS: Created layer with z_index=1 (above entities)")
|
||||||
else:
|
else:
|
||||||
|
|
@ -165,7 +177,7 @@ print(" PASS: Fog layer filled")
|
||||||
|
|
||||||
print("\n--- Test 9: Remove layer ---")
|
print("\n--- Test 9: Remove layer ---")
|
||||||
initial_count = len(grid.layers)
|
initial_count = len(grid.layers)
|
||||||
grid.remove_layer(fog_layer)
|
grid_data.remove_layer(fog_layer)
|
||||||
final_count = len(grid.layers)
|
final_count = len(grid.layers)
|
||||||
if final_count == initial_count - 1:
|
if final_count == initial_count - 1:
|
||||||
print(f" PASS: Layer removed ({initial_count} -> {final_count})")
|
print(f" PASS: Layer removed ({initial_count} -> {final_count})")
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,64 @@ Tests:
|
||||||
2. Setting cell values marks layer dirty
|
2. Setting cell values marks layer dirty
|
||||||
3. Fill operation marks layer dirty
|
3. Fill operation marks layer dirty
|
||||||
4. Texture change marks TileLayer dirty
|
4. Texture change marks TileLayer dirty
|
||||||
5. Viewport changes (center/zoom) don't trigger re-render (static benchmark)
|
5. Viewport changes (center/zoom) don't corrupt the cached texture
|
||||||
6. Performance improvement for static layers
|
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
|
import mcrfpy
|
||||||
|
from mcrfpy import automation
|
||||||
import sys
|
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("=" * 60)
|
||||||
print("Issue #148 Regression Test: Layer Dirty Flags and Caching")
|
print("Issue #148 Regression Test: Layer Dirty Flags and Caching")
|
||||||
|
|
@ -22,126 +74,186 @@ print("=" * 60)
|
||||||
test = mcrfpy.Scene("test")
|
test = mcrfpy.Scene("test")
|
||||||
mcrfpy.current_scene = test
|
mcrfpy.current_scene = test
|
||||||
ui = test.children
|
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
|
# Create grid with larger size for performance testing
|
||||||
grid = mcrfpy.Grid(pos=(50, 50), size=(500, 400), grid_size=(50, 40), texture=texture)
|
grid = mcrfpy.Grid(pos=(50, 50), size=(500, 400), grid_size=(50, 40), texture=texture)
|
||||||
ui.append(grid)
|
ui.append(grid)
|
||||||
|
|
||||||
print("\n--- Test 1: Layer creation (starts dirty) ---")
|
print("\n--- Test 1: Layer creation (starts dirty) ---")
|
||||||
color_layer = grid.add_layer("color", z_index=-1)
|
# A grid built with grid_size= already carries a default tile layer; these are added
|
||||||
# The layer should be dirty initially
|
# on top of it. Layers attached at size (0,0) auto-resize to the grid.
|
||||||
# We can't directly check dirty flag from Python, but we verify the system works
|
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||||
|
grid.add_layer(color_layer)
|
||||||
print(" ColorLayer created successfully")
|
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(" TileLayer created successfully")
|
||||||
print(" PASS: Layers created")
|
|
||||||
|
|
||||||
print("\n--- Test 2: Fill operations work ---")
|
check("ColorLayer attached and retrievable by name", grid.layer("color") is not None)
|
||||||
# Fill with some data
|
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))
|
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
|
tile_layer.fill(5)
|
||||||
print(" TileLayer filled with tile index 5")
|
check("TileLayer.fill wrote every cell",
|
||||||
print(" PASS: Fill operations completed")
|
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 ---")
|
print("\n--- Test 3: Cell set operations work ---")
|
||||||
# Set individual cells
|
yellow = mcrfpy.Color(255, 255, 0, 128)
|
||||||
color_layer.set(10, 10, mcrfpy.Color(255, 255, 0, 128))
|
for cell in [(10, 10), (11, 10), (10, 11), (11, 11)]:
|
||||||
color_layer.set(11, 10, mcrfpy.Color(255, 255, 0, 128))
|
color_layer.set(cell, yellow)
|
||||||
color_layer.set(10, 11, mcrfpy.Color(255, 255, 0, 128))
|
check("ColorLayer.set updated the 4 target cells",
|
||||||
color_layer.set(11, 11, mcrfpy.Color(255, 255, 0, 128))
|
all(color_layer.at(c) == yellow for c in [(10, 10), (11, 10), (10, 11), (11, 11)]))
|
||||||
print(" Set 4 cells in ColorLayer to yellow")
|
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)
|
for cell, idx in [((15, 15), 10), ((16, 15), 11), ((15, 16), 10), ((16, 16), 11)]:
|
||||||
tile_layer.set(16, 15, 11)
|
tile_layer.set(cell, idx)
|
||||||
tile_layer.set(15, 16, 10)
|
check("TileLayer.set updated the 4 target cells",
|
||||||
tile_layer.set(16, 16, 11)
|
tile_layer.at((15, 15)) == 10 and tile_layer.at((16, 15)) == 11
|
||||||
print(" Set 4 cells in TileLayer to different tiles")
|
and tile_layer.at((15, 16)) == 10 and tile_layer.at((16, 16)) == 11)
|
||||||
print(" PASS: Cell set operations completed")
|
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 ---")
|
print("\n--- Test 4: Texture change on TileLayer ---")
|
||||||
# Create a second texture and assign it
|
# Note: the .texture getter returns a fresh wrapper each call and Texture has no
|
||||||
texture2 = mcrfpy.Texture("assets/kenney_ice.png", 16, 16)
|
# __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
|
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
|
tile_layer.texture = texture
|
||||||
print(" Restored original texture")
|
check("TileLayer.texture restores the original",
|
||||||
print(" PASS: Texture changes work")
|
tile_layer.texture.source == "assets/kenney_tinydungeon.png",
|
||||||
|
f"got {tile_layer.texture.source}")
|
||||||
|
|
||||||
print("\n--- Test 5: Viewport changes (should use cached texture) ---")
|
print("\n--- Test 5: Dirty flags actually invalidate the cached RenderTexture ---")
|
||||||
# Pan around - these should NOT cause layer re-renders (just blit different region)
|
# 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
|
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):
|
for i in range(10):
|
||||||
grid.center = (100 + i * 20, 80 + i * 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]:
|
for z in [1.0, 0.8, 1.2, 0.5, 1.5, 1.0]:
|
||||||
grid.zoom = z
|
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.center = original_center
|
||||||
grid.zoom = original_zoom
|
grid.zoom = original_zoom
|
||||||
print(" PASS: Viewport changes completed without crashing")
|
restored = render_hash()
|
||||||
|
check("restoring the viewport restores the identical image", restored == after_edit)
|
||||||
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")
|
|
||||||
|
|
||||||
print("\n--- Test 7: Layer visibility toggle ---")
|
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
|
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
|
color_layer.visible = True
|
||||||
print(" ColorLayer shown")
|
check("visible attribute round-trips to True", color_layer.visible is True)
|
||||||
print(" PASS: Visibility toggle works")
|
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 ---")
|
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)
|
stress_grid = mcrfpy.Grid(pos=(10, 10), size=(200, 150), grid_size=(200, 150), texture=texture)
|
||||||
ui.append(stress_grid)
|
stress_scene.children.append(stress_grid)
|
||||||
stress_layer = stress_grid.add_layer("color", z_index=-1)
|
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))
|
stress_layer.fill(mcrfpy.Color(0, 100, 200, 100))
|
||||||
|
|
||||||
# Set a few specific cells
|
|
||||||
for x in range(10):
|
for x in range(10):
|
||||||
for y 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")
|
check("30,000-cell layer filled", stress_layer.at((199, 149)) == mcrfpy.Color(0, 100, 200, 100),
|
||||||
print(" PASS: Large grid handled successfully")
|
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)
|
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("All tests PASSED")
|
||||||
print("=" * 60)
|
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)
|
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.
|
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,
|
kept the old size. Code then iterated using the new grid's dimensions,
|
||||||
writing past the vector's end.
|
writing past the vector's end.
|
||||||
|
|
||||||
Fix: ensureGridstate() unconditionally checks gridstate.size() against
|
Fix: the per-entity visibility buffer unconditionally checks its size against
|
||||||
grid dimensions and resizes if they don't match. Applied to all transfer
|
the grid dimensions and reallocates if they don't match. Exercised here through
|
||||||
methods: set_grid, append, extend, insert, setitem, slice assignment.
|
every transfer method: set_grid, append, extend, insert, setitem, slice assign.
|
||||||
|
|
||||||
Also tests #274: spatial_hash.remove() must be called when removing
|
Also tests #274: spatial_hash.remove() must be called when removing
|
||||||
entities from grids via set_grid(None) or set_grid(other_grid).
|
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 mcrfpy
|
||||||
import sys
|
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():
|
def test_set_grid():
|
||||||
"""entity.grid = new_grid resizes gridstate"""
|
"""entity.grid = new_grid resizes the perspective map"""
|
||||||
small = mcrfpy.Grid(grid_size=(10, 10))
|
small = open_grid(10)
|
||||||
large = mcrfpy.Grid(grid_size=(50, 50))
|
large = open_grid(50)
|
||||||
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=small)
|
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=small)
|
||||||
|
|
||||||
small.perspective = entity
|
small.perspective = entity
|
||||||
small.fov_radius = 4
|
check_sized_to(entity, small, "set_grid/small")
|
||||||
entity.update_visibility()
|
|
||||||
|
|
||||||
gs = entity.gridstate
|
|
||||||
assert len(gs) == 100, f"Expected 100, got {len(gs)}"
|
|
||||||
|
|
||||||
entity.grid = large
|
entity.grid = large
|
||||||
gs = entity.gridstate
|
|
||||||
assert len(gs) == 2500, f"Expected 2500, got {len(gs)}"
|
|
||||||
|
|
||||||
large.perspective = entity
|
large.perspective = entity
|
||||||
large.fov_radius = 8
|
check_sized_to(entity, large, "set_grid/large")
|
||||||
entity.update_visibility()
|
|
||||||
print(" PASS: set_grid")
|
print(" PASS: set_grid")
|
||||||
|
|
||||||
def test_append():
|
def test_append():
|
||||||
"""grid.entities.append(entity) resizes gridstate"""
|
"""grid.entities.append(entity) resizes the perspective map"""
|
||||||
small = mcrfpy.Grid(grid_size=(10, 10))
|
small = open_grid(10)
|
||||||
large = mcrfpy.Grid(grid_size=(40, 40))
|
large = open_grid(40)
|
||||||
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
|
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
|
||||||
entity.update_visibility()
|
check_sized_to(entity, small, "append/small")
|
||||||
|
|
||||||
gs = entity.gridstate
|
|
||||||
assert len(gs) == 100, f"Expected 100, got {len(gs)}"
|
|
||||||
|
|
||||||
large.entities.append(entity)
|
large.entities.append(entity)
|
||||||
gs = entity.gridstate
|
check_sized_to(entity, large, "append/large")
|
||||||
assert len(gs) == 1600, f"Expected 1600, got {len(gs)}"
|
|
||||||
print(" PASS: append")
|
print(" PASS: append")
|
||||||
|
|
||||||
def test_extend():
|
def test_extend():
|
||||||
"""grid.entities.extend([entity]) resizes gridstate"""
|
"""grid.entities.extend([entity]) resizes the perspective map"""
|
||||||
small = mcrfpy.Grid(grid_size=(10, 10))
|
small = open_grid(10)
|
||||||
large = mcrfpy.Grid(grid_size=(30, 30))
|
large = open_grid(30)
|
||||||
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
|
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
|
||||||
entity.update_visibility()
|
check_sized_to(entity, small, "extend/small")
|
||||||
|
|
||||||
large.entities.extend([entity])
|
large.entities.extend([entity])
|
||||||
gs = entity.gridstate
|
check_sized_to(entity, large, "extend/large")
|
||||||
assert len(gs) == 900, f"Expected 900, got {len(gs)}"
|
|
||||||
print(" PASS: extend")
|
print(" PASS: extend")
|
||||||
|
|
||||||
def test_insert():
|
def test_insert():
|
||||||
"""grid.entities.insert(0, entity) resizes gridstate"""
|
"""grid.entities.insert(0, entity) resizes the perspective map"""
|
||||||
small = mcrfpy.Grid(grid_size=(10, 10))
|
small = open_grid(10)
|
||||||
large = mcrfpy.Grid(grid_size=(25, 25))
|
large = open_grid(25)
|
||||||
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
|
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
|
||||||
entity.update_visibility()
|
check_sized_to(entity, small, "insert/small")
|
||||||
|
|
||||||
large.entities.insert(0, entity)
|
large.entities.insert(0, entity)
|
||||||
gs = entity.gridstate
|
check_sized_to(entity, large, "insert/large")
|
||||||
assert len(gs) == 625, f"Expected 625, got {len(gs)}"
|
|
||||||
print(" PASS: insert")
|
print(" PASS: insert")
|
||||||
|
|
||||||
def test_setitem():
|
def test_setitem():
|
||||||
"""grid.entities[0] = entity resizes gridstate"""
|
"""grid.entities[0] = entity resizes the perspective map"""
|
||||||
small = mcrfpy.Grid(grid_size=(10, 10))
|
small = open_grid(10)
|
||||||
large = mcrfpy.Grid(grid_size=(20, 20))
|
large = open_grid(20)
|
||||||
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
|
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
|
# Need a placeholder entity in large grid first
|
||||||
placeholder = mcrfpy.Entity(grid_pos=(0, 0), grid=large)
|
placeholder = mcrfpy.Entity(grid_pos=(0, 0), grid=large)
|
||||||
large.entities[0] = entity
|
large.entities[0] = entity
|
||||||
gs = entity.gridstate
|
assert len(large.entities) == 1
|
||||||
assert len(gs) == 400, f"Expected 400, got {len(gs)}"
|
check_sized_to(entity, large, "setitem/large")
|
||||||
print(" PASS: setitem")
|
print(" PASS: setitem")
|
||||||
|
|
||||||
def test_slice_assign():
|
def test_slice_assign():
|
||||||
"""grid.entities[0:1] = [entity] resizes gridstate"""
|
"""grid.entities[0:1] = [entity] resizes the perspective map"""
|
||||||
small = mcrfpy.Grid(grid_size=(10, 10))
|
small = open_grid(10)
|
||||||
large = mcrfpy.Grid(grid_size=(35, 35))
|
large = open_grid(35)
|
||||||
entity = mcrfpy.Entity(grid_pos=(3, 3), grid=small)
|
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)
|
placeholder = mcrfpy.Entity(grid_pos=(0, 0), grid=large)
|
||||||
large.entities[0:1] = [entity]
|
large.entities[0:1] = [entity]
|
||||||
gs = entity.gridstate
|
check_sized_to(entity, large, "slice/large")
|
||||||
assert len(gs) == 1225, f"Expected 1225, got {len(gs)}"
|
|
||||||
print(" PASS: slice_assign")
|
print(" PASS: slice_assign")
|
||||||
|
|
||||||
def test_update_visibility_after_transfer():
|
def test_update_visibility_after_transfer():
|
||||||
"""update_visibility works correctly after all transfer methods"""
|
"""update_visibility works correctly after repeated grow/shrink transfers"""
|
||||||
grids = [mcrfpy.Grid(grid_size=(s, s)) for s in (5, 80, 3, 60, 10, 100)]
|
grids = [open_grid(s) for s in (5, 80, 3, 60, 10, 100)]
|
||||||
entity = mcrfpy.Entity(grid_pos=(2, 2), grid=grids[0])
|
entity = mcrfpy.Entity(grid_pos=(2, 2), grid=grids[0])
|
||||||
|
|
||||||
for g in grids:
|
for g in grids:
|
||||||
entity.grid = g
|
entity.grid = g
|
||||||
g.perspective = entity
|
g.perspective = entity
|
||||||
g.fov_radius = 4
|
check_sized_to(entity, g, f"cycle/{g.grid_data.grid_w}")
|
||||||
entity.update_visibility()
|
|
||||||
gs = entity.gridstate
|
|
||||||
expected = g.grid_w * g.grid_h
|
|
||||||
assert len(gs) == expected, f"Expected {expected}, got {len(gs)}"
|
|
||||||
print(" PASS: update_visibility_after_transfer")
|
print(" PASS: update_visibility_after_transfer")
|
||||||
|
|
||||||
def test_at_after_transfer():
|
def test_at_after_transfer():
|
||||||
"""entity.at(x, y) works correctly after grid transfer"""
|
"""entity.at(x, y) works correctly after grid transfer"""
|
||||||
small = mcrfpy.Grid(grid_size=(10, 10))
|
small = open_grid(10)
|
||||||
large = mcrfpy.Grid(grid_size=(50, 50))
|
large = open_grid(50)
|
||||||
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=small)
|
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=small)
|
||||||
entity.update_visibility()
|
entity.update_visibility()
|
||||||
|
|
||||||
entity.grid = large
|
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)
|
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")
|
print(" PASS: at_after_transfer")
|
||||||
|
|
||||||
def test_set_grid_none():
|
def test_set_grid_none():
|
||||||
"""entity.grid = None properly removes entity (tests #274)"""
|
"""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)
|
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=grid)
|
||||||
assert len(grid.entities) == 1
|
assert len(grid.entities) == 1
|
||||||
entity.grid = None
|
entity.grid = None
|
||||||
assert len(grid.entities) == 0
|
assert len(grid.entities) == 0
|
||||||
|
assert entity.grid is None
|
||||||
print(" PASS: set_grid_none")
|
print(" PASS: set_grid_none")
|
||||||
|
|
||||||
def test_stress():
|
def test_stress():
|
||||||
|
|
@ -150,26 +170,32 @@ def test_stress():
|
||||||
entity.grid = small_g
|
entity.grid = small_g
|
||||||
small_g.perspective = entity
|
small_g.perspective = entity
|
||||||
entity.update_visibility()
|
entity.update_visibility()
|
||||||
|
assert entity.perspective_map.size == (5, 5)
|
||||||
|
|
||||||
big_g = mcrfpy.Grid(grid_size=(80, 80))
|
big_g = mcrfpy.Grid(grid_size=(80, 80))
|
||||||
entity.grid = big_g
|
entity.grid = big_g
|
||||||
big_g.perspective = entity
|
big_g.perspective = entity
|
||||||
entity.update_visibility()
|
entity.update_visibility()
|
||||||
|
assert entity.perspective_map.size == (80, 80)
|
||||||
|
|
||||||
frames = [mcrfpy.Frame() for _ in range(10)]
|
frames = [mcrfpy.Frame() for _ in range(10)]
|
||||||
del frames
|
del frames
|
||||||
print(" PASS: stress")
|
print(" PASS: stress")
|
||||||
|
|
||||||
print("Testing gridstate resize across transfer methods...")
|
print("Testing perspective map resize across transfer methods...")
|
||||||
test_set_grid()
|
try:
|
||||||
test_append()
|
test_set_grid()
|
||||||
test_extend()
|
test_append()
|
||||||
test_insert()
|
test_extend()
|
||||||
test_setitem()
|
test_insert()
|
||||||
test_slice_assign()
|
test_setitem()
|
||||||
test_update_visibility_after_transfer()
|
test_slice_assign()
|
||||||
test_at_after_transfer()
|
test_update_visibility_after_transfer()
|
||||||
test_set_grid_none()
|
test_at_after_transfer()
|
||||||
test_stress()
|
test_set_grid_none()
|
||||||
print("PASS: all gridstate resize tests passed")
|
test_stress()
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"FAIL: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
print("PASS: all perspective map resize tests passed")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,14 @@ pointers would dangle.
|
||||||
|
|
||||||
Fix: Remove raw pointers. Store (grid, x, y) coordinates and compute
|
Fix: Remove raw pointers. Store (grid, x, y) coordinates and compute
|
||||||
the data address on each property access.
|
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 mcrfpy
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -30,39 +38,63 @@ def test_gridpoint_grid_pos():
|
||||||
assert pos == (7, 3), f"Expected (7, 3), got {pos}"
|
assert pos == (7, 3), f"Expected (7, 3), got {pos}"
|
||||||
print(" PASS: gridpoint_grid_pos")
|
print(" PASS: gridpoint_grid_pos")
|
||||||
|
|
||||||
def test_gridpointstate_access():
|
def test_perspective_map_access():
|
||||||
"""entity.at(x,y) returns working GridPointState via coordinate lookup"""
|
"""entity.at(x,y) / entity.perspective_map use coordinate lookup"""
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||||
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=grid)
|
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=grid)
|
||||||
entity.update_visibility()
|
entity.update_visibility()
|
||||||
|
|
||||||
state = entity.at(5, 5)
|
pm = entity.perspective_map
|
||||||
# Should be accessible
|
assert pm.size == (10, 10), f"Expected (10, 10), got {pm.size}"
|
||||||
assert state is not None
|
|
||||||
# visible/discovered should be boolean
|
|
||||||
assert isinstance(state.visible, bool)
|
|
||||||
assert isinstance(state.discovered, bool)
|
|
||||||
print(" PASS: gridpointstate_access")
|
|
||||||
|
|
||||||
def test_gridpointstate_after_grid_transfer():
|
# The entity's own cell is VISIBLE; entity.at() hands back the GridPoint.
|
||||||
"""GridPointState access works after entity transfers to new grid"""
|
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))
|
small = mcrfpy.Grid(grid_size=(10, 10))
|
||||||
large = mcrfpy.Grid(grid_size=(20, 20))
|
large = mcrfpy.Grid(grid_size=(20, 20))
|
||||||
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=small)
|
entity = mcrfpy.Entity(grid_pos=(5, 5), grid=small)
|
||||||
entity.update_visibility()
|
entity.update_visibility()
|
||||||
|
|
||||||
# Get state on small grid
|
# Wrappers obtained while on the small grid
|
||||||
state1 = entity.at(3, 3)
|
gp_small = small.grid_data.at(3, 3)
|
||||||
assert state1 is not None
|
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.grid = large
|
||||||
entity.update_visibility()
|
entity.update_visibility()
|
||||||
|
|
||||||
# Access a cell that didn't exist on small grid
|
# entity.grid is the shared GridData, not the view (#313/#361)
|
||||||
state2 = entity.at(15, 15)
|
assert entity.grid is large.grid_data
|
||||||
assert state2 is not None
|
|
||||||
print(" PASS: gridpointstate_after_grid_transfer")
|
# 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():
|
def test_gridpoint_subscript():
|
||||||
"""grid[x, y] returns working GridPoint"""
|
"""grid[x, y] returns working GridPoint"""
|
||||||
|
|
@ -72,26 +104,33 @@ def test_gridpoint_subscript():
|
||||||
assert grid.at(3, 4).walkable == True
|
assert grid.at(3, 4).walkable == True
|
||||||
print(" PASS: gridpoint_subscript")
|
print(" PASS: gridpoint_subscript")
|
||||||
|
|
||||||
def test_gridstate_list():
|
def test_perspective_map_all_cells():
|
||||||
"""entity.gridstate returns list with visible/discovered attrs"""
|
"""entity.perspective_map covers every cell of the grid"""
|
||||||
grid = mcrfpy.Grid(grid_size=(5, 5))
|
grid = mcrfpy.Grid(grid_size=(5, 5))
|
||||||
entity = mcrfpy.Entity(grid_pos=(2, 2), grid=grid)
|
entity = mcrfpy.Entity(grid_pos=(2, 2), grid=grid)
|
||||||
entity.update_visibility()
|
entity.update_visibility()
|
||||||
|
|
||||||
gs = entity.gridstate
|
pm = entity.perspective_map
|
||||||
assert len(gs) == 25, f"Expected 25, got {len(gs)}"
|
assert pm.size == (5, 5), f"Expected (5, 5), got {pm.size}"
|
||||||
# Each element should have visible and discovered
|
|
||||||
for state in gs:
|
|
||||||
assert hasattr(state, 'visible')
|
|
||||||
assert hasattr(state, 'discovered')
|
|
||||||
print(" PASS: gridstate_list")
|
|
||||||
|
|
||||||
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_access()
|
||||||
test_gridpoint_grid_pos()
|
test_gridpoint_grid_pos()
|
||||||
test_gridpointstate_access()
|
test_perspective_map_access()
|
||||||
test_gridpointstate_after_grid_transfer()
|
test_perspective_map_after_grid_transfer()
|
||||||
test_gridpoint_subscript()
|
test_gridpoint_subscript()
|
||||||
test_gridstate_list()
|
test_perspective_map_all_cells()
|
||||||
print("PASS: all GridPoint/GridPointState tests passed")
|
print("PASS: all GridPoint/perspective_map tests passed")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,13 @@ def main():
|
||||||
c.walkable = (i % 2 == 0)
|
c.walkable = (i % 2 == 0)
|
||||||
assert g.at(0, 0).walkable is True
|
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)
|
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
|
# Reassigning grid_data invalidates the cache: new wrapper for new data
|
||||||
g2 = mcrfpy.Grid(grid_size=(4, 4), pos=(0, 0), size=(40, 40))
|
g2 = mcrfpy.Grid(grid_size=(4, 4), pos=(0, 0), size=(40, 40))
|
||||||
|
|
|
||||||
|
|
@ -1,220 +1,246 @@
|
||||||
#!/usr/bin/env python3
|
#!/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,
|
The bug: UIGrid::render() created its RenderTexture once, at a hardcoded size, and
|
||||||
which causes rendering issues when the grid is resized beyond these dimensions.
|
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,
|
The fix (UIGridView::ensureRenderTextureSize()) sizes the RenderTexture from the game
|
||||||
but never recreates it when the grid is resized, causing clipping and rendering artifacts.
|
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
|
import mcrfpy
|
||||||
from mcrfpy import automation
|
from mcrfpy import automation
|
||||||
|
import struct
|
||||||
import sys
|
import sys
|
||||||
import os
|
import zlib
|
||||||
|
|
||||||
def create_checkerboard_pattern(grid, grid_width, grid_height, cell_size=2):
|
FAILURES = []
|
||||||
"""Create a checkerboard pattern on the grid for visibility"""
|
|
||||||
|
# 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 x in range(grid_width):
|
||||||
for y in range(grid_height):
|
for y in range(grid_height):
|
||||||
if (x // cell_size + y // cell_size) % 2 == 0:
|
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:
|
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"""
|
"""Add colored markers at the borders to test rendering limits"""
|
||||||
# Red border on top
|
|
||||||
for x in range(grid_width):
|
for x in range(grid_width):
|
||||||
grid.at(x, 0).color = mcrfpy.Color(255, 0, 0, 255)
|
layer.set((x, 0), mcrfpy.Color(*RED))
|
||||||
|
layer.set((x, grid_height - 1), mcrfpy.Color(*RED))
|
||||||
# Green border on right
|
|
||||||
for y in range(grid_height):
|
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
|
def render(path):
|
||||||
for y in range(grid_height):
|
"""step() advances the sim; the screenshot is what forces a render."""
|
||||||
grid.at(0, y).color = mcrfpy.Color(255, 255, 0, 255)
|
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")
|
test = mcrfpy.Scene("test")
|
||||||
mcrfpy.current_scene = test
|
mcrfpy.current_scene = test
|
||||||
|
|
||||||
print("=== Testing UIGrid RenderTexture Resize (Issue #9) ===\n")
|
|
||||||
|
|
||||||
scene_ui = test.children
|
scene_ui = test.children
|
||||||
|
|
||||||
# Test 1: Small grid (should work fine)
|
GRID_X, GRID_Y = 10, 10
|
||||||
print("--- Test 1: Small Grid (400x300) ---")
|
grid = mcrfpy.Grid(grid_size=(GRID_CELLS, GRID_CELLS), pos=(GRID_X, GRID_Y), size=(200, 150))
|
||||||
grid1 = mcrfpy.Grid(20, 15) # 20x15 tiles
|
colors = mcrfpy.ColorLayer(name="cells")
|
||||||
grid1.x = 10
|
grid.add_layer(colors)
|
||||||
grid1.y = 10
|
create_checkerboard_pattern(colors, GRID_CELLS, GRID_CELLS)
|
||||||
grid1.w = 400
|
add_border_markers(colors, GRID_CELLS, GRID_CELLS)
|
||||||
grid1.h = 300
|
scene_ui.append(grid)
|
||||||
scene_ui.append(grid1)
|
|
||||||
|
|
||||||
create_checkerboard_pattern(grid1, 20, 15)
|
# --- Test 1: small grid renders its whole box, and nothing outside it ---------
|
||||||
add_border_markers(grid1, 20, 15)
|
print("--- Test 1: Small Grid (200x150) ---")
|
||||||
|
anchor_camera(grid)
|
||||||
|
img = render("/tmp/issue_9_small_grid.png")
|
||||||
|
|
||||||
mcrfpy.step(0.01)
|
check("top-left tile of small grid is the red border marker",
|
||||||
automation.screenshot("/tmp/issue_9_small_grid.png")
|
img.at(GRID_X + 2, GRID_Y + 2) == RED,
|
||||||
print("PASS: Small grid created and rendered")
|
"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
|
# --- Test 2: enlarging the widget must render the newly exposed area ----------
|
||||||
print("\n--- Test 2: Medium Grid at 1920x1080 Limit ---")
|
# This is the heart of #9: (500,400) and (880,690) were outside the previous render
|
||||||
grid2 = mcrfpy.Grid(64, 36) # 64x36 tiles at 30px each = 1920x1080
|
# area. With a stale RenderTexture they stay blank/clipped.
|
||||||
grid2.x = 10
|
print("\n--- Test 2: Resize 200x150 -> 900x700 ---")
|
||||||
grid2.y = 320
|
grid.w = 900
|
||||||
grid2.w = 1920
|
grid.h = 700
|
||||||
grid2.h = 1080
|
anchor_camera(grid)
|
||||||
scene_ui.append(grid2)
|
img = render("/tmp/issue_9_resized.png")
|
||||||
|
|
||||||
create_checkerboard_pattern(grid2, 64, 36, 4)
|
check("area exposed by the resize is rendered (500,400)",
|
||||||
add_border_markers(grid2, 64, 36)
|
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)
|
# --- Test 3: widget larger than the window still renders its visible portion --
|
||||||
automation.screenshot("/tmp/issue_9_limit_grid.png")
|
print("\n--- Test 3: Grid larger than the window (2400x1400) ---")
|
||||||
print("PASS: Grid at RenderTexture limit created")
|
grid.w = 2400
|
||||||
|
grid.h = 1400
|
||||||
|
anchor_camera(grid)
|
||||||
|
img = render("/tmp/issue_9_beyond_window.png")
|
||||||
|
|
||||||
# Test 3: Resize grid1 beyond limits
|
check("oversized grid renders at the far edge of the window (1000,700)",
|
||||||
print("\n--- Test 3: Resizing Small Grid Beyond 1920x1080 ---")
|
is_content(img.at(1000, 700)), "got %s" % (img.at(1000, 700),))
|
||||||
print("Original size: 400x300")
|
check("oversized grid renders near the window origin (20,20)",
|
||||||
grid1.w = 2400
|
is_content(img.at(20, 20)), "got %s" % (img.at(20, 20),))
|
||||||
grid1.h = 1400
|
|
||||||
print(f"Resized to: {grid1.w}x{grid1.h}")
|
|
||||||
|
|
||||||
# The content should still be visible but may be clipped
|
# --- Test 4: shrinking must not leave stale pixels outside the new box --------
|
||||||
mcrfpy.step(0.01)
|
print("\n--- Test 4: Shrink back to 200x150 ---")
|
||||||
automation.screenshot("/tmp/issue_9_resized_beyond_limit.png")
|
grid.w = 200
|
||||||
print("EXPECTED ISSUE: Grid resized beyond RenderTexture limits")
|
grid.h = 150
|
||||||
print(" Content beyond 1920x1080 will be clipped!")
|
anchor_camera(grid)
|
||||||
|
img = render("/tmp/issue_9_shrunk.png")
|
||||||
|
|
||||||
# Test 4: Create large grid from start
|
check("shrunken grid still renders inside its box",
|
||||||
print("\n--- Test 4: Large Grid from Start (2400x1400) ---")
|
is_content(img.at(100, 100)), "got %s" % (img.at(100, 100),))
|
||||||
# Clear previous grids
|
check("no stale content left outside the shrunken box (500,400)",
|
||||||
while len(scene_ui) > 0:
|
img.at(500, 400) == BACKGROUND, "got %s" % (img.at(500, 400),))
|
||||||
scene_ui.remove(0)
|
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("\n=== SUMMARY ===")
|
||||||
print("Issue #9: UIGrid uses a hardcoded RenderTexture size of 1920x1080")
|
if FAILURES:
|
||||||
print("Problems demonstrated:")
|
print("FAIL: %d check(s) failed: %s" % (len(FAILURES), ", ".join(FAILURES)))
|
||||||
print("1. Grids larger than 1920x1080 are clipped")
|
sys.exit(1)
|
||||||
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")
|
|
||||||
|
|
||||||
print(f"\nScreenshots saved to /tmp/issue_9_*.png")
|
print("Screenshots saved to /tmp/issue_9_*.png")
|
||||||
print("\nTest complete - check screenshots for visual verification")
|
print("PASS")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,7 @@ def recursive_callback(target, prop, value):
|
||||||
callback_count += 1
|
callback_count += 1
|
||||||
|
|
||||||
if callback_count >= MAX_CALLBACKS:
|
if callback_count >= MAX_CALLBACKS:
|
||||||
print(f"PASS - {callback_count} recursive animation callbacks completed without segfault")
|
return
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
# Chain another animation - this used to cause segfault due to iterator invalidation
|
# 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)
|
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
|
# Start the chain
|
||||||
frame.animate("x", 200, 0.1, mcrfpy.Easing.LINEAR, callback=recursive_callback)
|
frame.animate("x", 200, 0.1, mcrfpy.Easing.LINEAR, callback=recursive_callback)
|
||||||
|
|
||||||
def timeout_check(timer, runtime):
|
# In headless mode mcrfpy.step() is the only clock: drive the animation chain forward.
|
||||||
"""Safety timeout"""
|
# Each link is 0.1s; MAX_CALLBACKS links need >= 1.0s of simulated time. Step generously
|
||||||
if callback_count >= MAX_CALLBACKS:
|
# and bail out as soon as the chain is complete.
|
||||||
print(f"PASS - {callback_count} callbacks completed")
|
MAX_STEPS = 400
|
||||||
sys.exit(0)
|
steps = 0
|
||||||
else:
|
while callback_count < MAX_CALLBACKS and steps < MAX_STEPS:
|
||||||
print(f"FAIL - only {callback_count}/{MAX_CALLBACKS} callbacks executed")
|
mcrfpy.step(0.02)
|
||||||
sys.exit(1)
|
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
|
#!/usr/bin/env python3
|
||||||
"""Example of CORRECT test pattern using mcrfpy.step() for automation
|
"""Example of CORRECT test pattern using mcrfpy.step() for automation
|
||||||
Refactored from timer-based approach to synchronous step() pattern.
|
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
|
import mcrfpy
|
||||||
from mcrfpy import automation
|
from mcrfpy import automation
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import os
|
||||||
import sys
|
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
|
# This code runs during --exec script execution
|
||||||
print("=== Setting Up Test Scene ===")
|
print("=== Setting Up Test Scene ===")
|
||||||
|
|
||||||
|
|
@ -32,11 +45,11 @@ caption.font_size = 24
|
||||||
frame.children.append(caption)
|
frame.children.append(caption)
|
||||||
|
|
||||||
# Add click handler to demonstrate interaction
|
# Add click handler to demonstrate interaction
|
||||||
click_received = False
|
# (#230) on_click receives (pos: Vector, button: MouseButton, action: InputState)
|
||||||
def frame_clicked(x, y, button):
|
clicks_received = []
|
||||||
global click_received
|
def frame_clicked(pos, button, action):
|
||||||
click_received = True
|
clicks_received.append((pos, button, action))
|
||||||
print(f"Frame clicked at ({x}, {y}) with button {button}")
|
print(f"Frame clicked at ({pos.x}, {pos.y}) with button {button} ({action})")
|
||||||
|
|
||||||
frame.on_click = frame_clicked
|
frame.on_click = frame_clicked
|
||||||
|
|
||||||
|
|
@ -54,13 +67,25 @@ filename = f"WORKING_screenshot_{timestamp}.png"
|
||||||
# Take screenshot - this should now show our red frame
|
# Take screenshot - this should now show our red frame
|
||||||
result = automation.screenshot(filename)
|
result = automation.screenshot(filename)
|
||||||
print(f"Screenshot taken: {filename} - Result: {result}")
|
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
|
# 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
|
# Step to process the click
|
||||||
mcrfpy.step(0.1)
|
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
|
# Test keyboard input
|
||||||
automation.typewrite("Hello from step-based test!")
|
automation.typewrite("Hello from step-based test!")
|
||||||
|
|
||||||
|
|
@ -69,14 +94,26 @@ mcrfpy.step(0.1)
|
||||||
|
|
||||||
# Take another screenshot to show any changes
|
# Take another screenshot to show any changes
|
||||||
filename2 = f"WORKING_screenshot_after_click_{timestamp}.png"
|
filename2 = f"WORKING_screenshot_after_click_{timestamp}.png"
|
||||||
automation.screenshot(filename2)
|
result2 = automation.screenshot(filename2)
|
||||||
print(f"Second 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("\nThis works because:")
|
||||||
print("1. mcrfpy.step() advances simulation synchronously")
|
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("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)
|
sys.exit(0)
|
||||||
|
|
|
||||||
|
|
@ -90,12 +90,21 @@ for _ in range(10):
|
||||||
check("replacement anim completes", anim5b.is_complete == True)
|
check("replacement anim completes", anim5b.is_complete == True)
|
||||||
|
|
||||||
|
|
||||||
# --- Test 6: Animation object created with loop=True via constructor ---
|
# --- Test 6: loop flag set at Animation creation time ---
|
||||||
anim6 = mcrfpy.Animation("x", 100.0, 1.0, loop=True)
|
# mcrfpy.Animation is no longer exported; the successor factory is target.animate(),
|
||||||
check("Animation constructor loop=True", anim6.is_looping == True)
|
# 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)
|
anim6 = sprite6.animate("x", 100.0, 1.0, loop=True)
|
||||||
check("Animation constructor default loop=False", anim7.is_looping == False)
|
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 ---
|
# --- Summary ---
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,65 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Test for mcrfpy.createScene() method"""
|
"""Test for mcrfpy.Scene() creation and activation (formerly mcrfpy.createScene())"""
|
||||||
import mcrfpy
|
import mcrfpy
|
||||||
|
import sys
|
||||||
|
|
||||||
def test_createScene():
|
def test_createScene():
|
||||||
"""Test creating a new scene"""
|
"""Test creating a new scene"""
|
||||||
|
failures = []
|
||||||
|
scenes = {}
|
||||||
|
|
||||||
# Test creating scenes
|
# Test creating scenes
|
||||||
test_scenes = ["test_scene1", "test_scene2", "special_chars_!@#"]
|
test_scenes = ["test_scene1", "test_scene2", "special_chars_!@#"]
|
||||||
|
|
||||||
for scene_name in test_scenes:
|
for scene_name in test_scenes:
|
||||||
try:
|
try:
|
||||||
_scene = mcrfpy.Scene(scene_name)
|
scenes[scene_name] = mcrfpy.Scene(scene_name)
|
||||||
print(f"✓ Created scene: {scene_name}")
|
print(f"PASS: Created scene: {scene_name}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"✗ Failed to create scene {scene_name}: {e}")
|
print(f"FAIL: Failed to create scene {scene_name}: {e}")
|
||||||
return
|
failures.append(f"create {scene_name}")
|
||||||
|
continue
|
||||||
|
|
||||||
# Try to set scene to verify it was created
|
if scenes[scene_name].name != scene_name:
|
||||||
try:
|
print(f"FAIL: scene.name mismatch: expected '{scene_name}', got '{scenes[scene_name].name}'")
|
||||||
test_scene1.activate() # Note: ensure scene was created
|
failures.append(f"name {scene_name}")
|
||||||
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")
|
# 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
|
# Run test immediately
|
||||||
print("Running createScene test...")
|
print("Running createScene test...")
|
||||||
test_createScene()
|
failures = test_createScene()
|
||||||
print("Test completed.")
|
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
|
#!/usr/bin/env python3
|
||||||
"""Test for mcrfpy.setScene() and currentScene() methods"""
|
"""Test for mcrfpy.current_scene (successor to setScene()/currentScene())"""
|
||||||
import mcrfpy
|
import mcrfpy
|
||||||
|
import sys
|
||||||
|
|
||||||
print("Starting setScene/currentScene test...")
|
print("Starting current_scene test...")
|
||||||
|
|
||||||
# Create test scenes first
|
# Create test scenes first
|
||||||
scenes = ["scene_A", "scene_B", "scene_C"]
|
scenes = ["scene_A", "scene_B", "scene_C"]
|
||||||
|
scene_objs = {}
|
||||||
for scene in scenes:
|
for scene in scenes:
|
||||||
_scene = mcrfpy.Scene(scene)
|
scene_objs[scene] = mcrfpy.Scene(scene)
|
||||||
print(f"Created scene: {scene}")
|
print(f"Created scene: {scene}")
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
# Test switching between scenes
|
# Test switching between scenes by name (mcrfpy.current_scene is read/write)
|
||||||
for scene in scenes:
|
for scene in scenes:
|
||||||
try:
|
try:
|
||||||
mcrfpy.current_scene = scene
|
mcrfpy.current_scene = scene
|
||||||
current = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
|
current = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
|
||||||
if current == scene:
|
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:
|
else:
|
||||||
results.append(f"✗ Scene mismatch: set '{scene}', got '{current}'")
|
results.append(f"[FAIL] Scene mismatch: set '{scene}', got '{current}'")
|
||||||
except Exception as e:
|
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)
|
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)
|
current_after = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
|
||||||
if current_before == current_after:
|
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:
|
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
|
# Print results
|
||||||
for result in results:
|
for result in results:
|
||||||
print(result)
|
print(result)
|
||||||
|
|
||||||
# Determine pass/fail
|
# Determine pass/fail
|
||||||
if all("✓" in r for r in results):
|
if all("[ok]" in r for r in results):
|
||||||
print("PASS")
|
print("PASS")
|
||||||
|
sys.exit(0)
|
||||||
else:
|
else:
|
||||||
print("FAIL")
|
print("FAIL")
|
||||||
|
sys.exit(1)
|
||||||
|
|
|
||||||
|
|
@ -1,80 +1,125 @@
|
||||||
#!/usr/bin/env python3
|
#!/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 mcrfpy
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
print("Debugging empty paths...")
|
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
|
# Create scene and grid
|
||||||
debug = mcrfpy.Scene("debug")
|
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
|
# Initialize grid - all walkable
|
||||||
print("\nInitializing grid...")
|
print("\nInitializing grid...")
|
||||||
for y in range(10):
|
for y in range(10):
|
||||||
for x in range(10):
|
for x in range(10):
|
||||||
grid.at(x, y).walkable = True
|
data.at(x, y).walkable = True
|
||||||
|
|
||||||
# Test simple path
|
# Test simple path
|
||||||
print("\nTest 1: Simple path from (0,0) to (5,5)")
|
print("\nTest 1: Simple path from (0,0) to (5,5)")
|
||||||
path = grid.compute_astar_path(0, 0, 5, 5)
|
path = data.find_path((0, 0), (5, 5))
|
||||||
print(f" A* path: {path}")
|
check(path is not None, "A* found a path on an all-walkable grid")
|
||||||
print(f" Path length: {len(path)}")
|
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
|
# Test with Dijkstra
|
||||||
print("\nTest 2: Same path with Dijkstra")
|
print("\nTest 2: Same path with Dijkstra")
|
||||||
grid.compute_dijkstra(0, 0)
|
dmap = data.get_dijkstra_map(root=(0, 0))
|
||||||
dpath = grid.get_dijkstra_path(5, 5)
|
dpath = dmap.path_from((5, 5))
|
||||||
print(f" Dijkstra path: {dpath}")
|
dsteps = list(dpath) if dpath else []
|
||||||
print(f" Path length: {len(dpath)}")
|
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
|
# Check if grid is properly initialized
|
||||||
print("\nTest 3: Checking grid cells")
|
print("\nTest 3: Checking grid cells")
|
||||||
for y in range(3):
|
for y in range(3):
|
||||||
for x 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}")
|
print(f" Cell ({x},{y}): walkable={cell.walkable}")
|
||||||
|
check(cell.walkable, f"cell ({x},{y}) reports walkable=True")
|
||||||
|
|
||||||
# Test with walls
|
# Test with walls
|
||||||
print("\nTest 4: Path with wall")
|
print("\nTest 4: Path with wall")
|
||||||
grid.at(2, 2).walkable = False
|
for wx in (2, 3, 4):
|
||||||
grid.at(3, 2).walkable = False
|
data.at(wx, 2).walkable = False
|
||||||
grid.at(4, 2).walkable = False
|
data.clear_dijkstra_maps() # walkability changed: invalidate cached maps
|
||||||
print(" Added wall at y=2, x=2,3,4")
|
print(" Added wall at y=2, x=2,3,4")
|
||||||
|
|
||||||
path2 = grid.compute_astar_path(0, 0, 5, 5)
|
path2 = data.find_path((0, 0), (5, 5))
|
||||||
print(f" A* path with wall: {path2}")
|
check(path2 is not None, "A* still finds a path around the wall")
|
||||||
print(f" Path length: {len(path2)}")
|
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
|
# Test invalid paths
|
||||||
print("\nTest 5: Path to blocked cell")
|
print("\nTest 5: Path to blocked cell")
|
||||||
grid.at(9, 9).walkable = False
|
data.at(9, 9).walkable = False
|
||||||
path3 = grid.compute_astar_path(0, 0, 9, 9)
|
data.clear_dijkstra_maps()
|
||||||
|
path3 = data.find_path((0, 0), (9, 9))
|
||||||
print(f" Path to blocked cell: {path3}")
|
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
|
# Check TCOD map sync
|
||||||
print("\nTest 6: Verify TCOD map is synced")
|
print("\nTest 6: Verify TCOD map is synced")
|
||||||
# Try to force a sync
|
# Fully wall off (9,8) and (8,9) too -- (9,9) neighbors -- then re-open (9,9):
|
||||||
print(" Checking if syncTCODMap exists...")
|
# a stale TCOD map would still refuse the destination.
|
||||||
if hasattr(grid, 'sync_tcod_map'):
|
data.at(9, 9).walkable = True
|
||||||
print(" Calling sync_tcod_map()")
|
data.clear_dijkstra_maps()
|
||||||
grid.sync_tcod_map()
|
path_reopened = data.find_path((0, 0), (9, 9))
|
||||||
else:
|
check(path_reopened is not None and len(list(path_reopened)) > 0,
|
||||||
print(" No sync_tcod_map method found")
|
"re-opening a blocked cell makes it reachable again (automatic TCOD sync)")
|
||||||
|
|
||||||
# Try path again
|
# Try path again
|
||||||
print("\nTest 7: Path after potential sync")
|
print("\nTest 7: Path after mutation round-trip")
|
||||||
path4 = grid.compute_astar_path(0, 0, 5, 5)
|
path4 = data.find_path((0, 0), (5, 5))
|
||||||
print(f" A* path: {path4}")
|
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):
|
# Quick UI setup: the grid must survive being attached to a live scene.
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
# Quick UI setup
|
|
||||||
ui = debug.children
|
ui = debug.children
|
||||||
ui.append(grid)
|
ui.append(grid)
|
||||||
debug.activate()
|
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
|
# This script is intentionally (almost) empty.
|
||||||
pass
|
#
|
||||||
|
# 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
|
#!/usr/bin/env python3
|
||||||
"""Test if calling mcrfpy.exit() prevents the >>> prompt"""
|
"""Test that mcrfpy.exit() shuts the engine down instead of leaving it running
|
||||||
import mcrfpy
|
|
||||||
|
|
||||||
|
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...")
|
print("Calling mcrfpy.exit() immediately...")
|
||||||
mcrfpy.exit()
|
result = mcrfpy.exit()
|
||||||
print("This should not print if exit worked")
|
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
|
import mcrfpy
|
||||||
from mcrfpy import automation
|
from mcrfpy import automation
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
def capture_sprites(timer, runtime):
|
SCREENSHOT = "ui_sprite_example.png"
|
||||||
"""Capture sprite examples after render loop starts"""
|
|
||||||
|
|
||||||
# Take screenshot
|
results = []
|
||||||
automation.screenshot("mcrogueface.github.io/images/ui_sprite_example.png")
|
|
||||||
|
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!")
|
print("Sprite screenshot saved!")
|
||||||
|
|
||||||
# Exit after capturing
|
check("screenshot file created", os.path.exists(SCREENSHOT))
|
||||||
sys.exit(0)
|
check("screenshot is non-empty", os.path.exists(SCREENSHOT) and os.path.getsize(SCREENSHOT) > 0)
|
||||||
|
|
||||||
# Create scene
|
# Create scene
|
||||||
sprites = mcrfpy.Scene("sprites")
|
sprites = mcrfpy.Scene("sprites")
|
||||||
|
|
@ -21,9 +30,8 @@ sprites = mcrfpy.Scene("sprites")
|
||||||
# Load texture
|
# Load texture
|
||||||
texture = mcrfpy.Texture("assets/kenney_TD_MR_IP.png", 16, 16)
|
texture = mcrfpy.Texture("assets/kenney_TD_MR_IP.png", 16, 16)
|
||||||
|
|
||||||
# Title
|
# Title (Caption.font is read-only: pass it to the constructor)
|
||||||
title = mcrfpy.Caption(pos=(400, 30), text="Sprite Examples")
|
title = mcrfpy.Caption(pos=(400, 30), font=mcrfpy.default_font, text="Sprite Examples")
|
||||||
title.font = mcrfpy.default_font
|
|
||||||
title.font_size = 24
|
title.font_size = 24
|
||||||
title.fill_color = mcrfpy.Color(255, 255, 255)
|
title.fill_color = mcrfpy.Color(255, 255, 255)
|
||||||
|
|
||||||
|
|
@ -33,102 +41,71 @@ frame.fill_color = mcrfpy.Color(64, 64, 128)
|
||||||
frame.outline = 2
|
frame.outline = 2
|
||||||
|
|
||||||
# Player sprite
|
# Player sprite
|
||||||
player_label = mcrfpy.Caption(pos=(100, 120), text="Player")
|
player_label = mcrfpy.Caption(pos=(100, 120), font=mcrfpy.default_font, text="Player")
|
||||||
player_label.font = mcrfpy.default_font
|
|
||||||
player_label.fill_color = mcrfpy.Color(255, 255, 255)
|
player_label.fill_color = mcrfpy.Color(255, 255, 255)
|
||||||
|
|
||||||
player = mcrfpy.Sprite(120, 150)
|
player = mcrfpy.Sprite(pos=(120, 150), texture=texture, sprite_index=84) # Player sprite
|
||||||
player.texture = texture
|
player.scale = 3.0
|
||||||
player.sprite_index = 84 # Player sprite
|
|
||||||
player.scale = (3.0, 3.0)
|
|
||||||
|
|
||||||
# Enemy sprites
|
# Enemy sprites
|
||||||
enemy_label = mcrfpy.Caption(pos=(250, 120), text="Enemies")
|
enemy_label = mcrfpy.Caption(pos=(250, 120), font=mcrfpy.default_font, text="Enemies")
|
||||||
enemy_label.font = mcrfpy.default_font
|
|
||||||
enemy_label.fill_color = mcrfpy.Color(255, 255, 255)
|
enemy_label.fill_color = mcrfpy.Color(255, 255, 255)
|
||||||
|
|
||||||
rat = mcrfpy.Sprite(250, 150)
|
rat = mcrfpy.Sprite(pos=(250, 150), texture=texture, sprite_index=123) # Rat
|
||||||
rat.texture = texture
|
rat.scale = 3.0
|
||||||
rat.sprite_index = 123 # Rat
|
|
||||||
rat.scale = (3.0, 3.0)
|
|
||||||
|
|
||||||
big_rat = mcrfpy.Sprite(320, 150)
|
big_rat = mcrfpy.Sprite(pos=(320, 150), texture=texture, sprite_index=130) # Big rat
|
||||||
big_rat.texture = texture
|
big_rat.scale = 3.0
|
||||||
big_rat.sprite_index = 130 # Big rat
|
|
||||||
big_rat.scale = (3.0, 3.0)
|
|
||||||
|
|
||||||
cyclops = mcrfpy.Sprite(390, 150)
|
cyclops = mcrfpy.Sprite(pos=(390, 150), texture=texture, sprite_index=109) # Cyclops
|
||||||
cyclops.texture = texture
|
cyclops.scale = 3.0
|
||||||
cyclops.sprite_index = 109 # Cyclops
|
|
||||||
cyclops.scale = (3.0, 3.0)
|
|
||||||
|
|
||||||
# Items row
|
# Items row
|
||||||
items_label = mcrfpy.Caption(pos=(100, 250), text="Items")
|
items_label = mcrfpy.Caption(pos=(100, 250), font=mcrfpy.default_font, text="Items")
|
||||||
items_label.font = mcrfpy.default_font
|
|
||||||
items_label.fill_color = mcrfpy.Color(255, 255, 255)
|
items_label.fill_color = mcrfpy.Color(255, 255, 255)
|
||||||
|
|
||||||
# Boulder
|
# Boulder
|
||||||
boulder = mcrfpy.Sprite(100, 280)
|
boulder = mcrfpy.Sprite(pos=(100, 280), texture=texture, sprite_index=66) # Boulder
|
||||||
boulder.texture = texture
|
boulder.scale = 3.0
|
||||||
boulder.sprite_index = 66 # Boulder
|
|
||||||
boulder.scale = (3.0, 3.0)
|
|
||||||
|
|
||||||
# Chest
|
# Chest
|
||||||
chest = mcrfpy.Sprite(170, 280)
|
chest = mcrfpy.Sprite(pos=(170, 280), texture=texture, sprite_index=89) # Closed chest
|
||||||
chest.texture = texture
|
chest.scale = 3.0
|
||||||
chest.sprite_index = 89 # Closed chest
|
|
||||||
chest.scale = (3.0, 3.0)
|
|
||||||
|
|
||||||
# Key
|
# Key
|
||||||
key = mcrfpy.Sprite(240, 280)
|
key = mcrfpy.Sprite(pos=(240, 280), texture=texture, sprite_index=384) # Key
|
||||||
key.texture = texture
|
key.scale = 3.0
|
||||||
key.sprite_index = 384 # Key
|
|
||||||
key.scale = (3.0, 3.0)
|
|
||||||
|
|
||||||
# Button
|
# Button
|
||||||
button = mcrfpy.Sprite(310, 280)
|
button = mcrfpy.Sprite(pos=(310, 280), texture=texture, sprite_index=250) # Button
|
||||||
button.texture = texture
|
button.scale = 3.0
|
||||||
button.sprite_index = 250 # Button
|
|
||||||
button.scale = (3.0, 3.0)
|
|
||||||
|
|
||||||
# UI elements row
|
# UI elements row
|
||||||
ui_label = mcrfpy.Caption(pos=(100, 380), text="UI Elements")
|
ui_label = mcrfpy.Caption(pos=(100, 380), font=mcrfpy.default_font, text="UI Elements")
|
||||||
ui_label.font = mcrfpy.default_font
|
|
||||||
ui_label.fill_color = mcrfpy.Color(255, 255, 255)
|
ui_label.fill_color = mcrfpy.Color(255, 255, 255)
|
||||||
|
|
||||||
# Hearts
|
# Hearts
|
||||||
heart_full = mcrfpy.Sprite(100, 410)
|
heart_full = mcrfpy.Sprite(pos=(100, 410), texture=texture, sprite_index=210) # Full heart
|
||||||
heart_full.texture = texture
|
heart_full.scale = 3.0
|
||||||
heart_full.sprite_index = 210 # Full heart
|
|
||||||
heart_full.scale = (3.0, 3.0)
|
|
||||||
|
|
||||||
heart_half = mcrfpy.Sprite(170, 410)
|
heart_half = mcrfpy.Sprite(pos=(170, 410), texture=texture, sprite_index=209) # Half heart
|
||||||
heart_half.texture = texture
|
heart_half.scale = 3.0
|
||||||
heart_half.sprite_index = 209 # Half heart
|
|
||||||
heart_half.scale = (3.0, 3.0)
|
|
||||||
|
|
||||||
heart_empty = mcrfpy.Sprite(240, 410)
|
heart_empty = mcrfpy.Sprite(pos=(240, 410), texture=texture, sprite_index=208) # Empty heart
|
||||||
heart_empty.texture = texture
|
heart_empty.scale = 3.0
|
||||||
heart_empty.sprite_index = 208 # Empty heart
|
|
||||||
heart_empty.scale = (3.0, 3.0)
|
|
||||||
|
|
||||||
# Armor
|
# Armor
|
||||||
armor = mcrfpy.Sprite(340, 410)
|
armor = mcrfpy.Sprite(pos=(340, 410), texture=texture, sprite_index=211) # Armor
|
||||||
armor.texture = texture
|
armor.scale = 3.0
|
||||||
armor.sprite_index = 211 # Armor
|
|
||||||
armor.scale = (3.0, 3.0)
|
|
||||||
|
|
||||||
# Scale demonstration
|
# Scale demonstration
|
||||||
scale_label = mcrfpy.Caption(pos=(500, 120), text="Scale Demo")
|
scale_label = mcrfpy.Caption(pos=(500, 120), font=mcrfpy.default_font, text="Scale Demo")
|
||||||
scale_label.font = mcrfpy.default_font
|
|
||||||
scale_label.fill_color = mcrfpy.Color(255, 255, 255)
|
scale_label.fill_color = mcrfpy.Color(255, 255, 255)
|
||||||
|
|
||||||
# Same sprite at different scales
|
# Same sprite at different scales
|
||||||
for i, scale in enumerate([1.0, 2.0, 3.0, 4.0]):
|
for i, scale in enumerate([1.0, 2.0, 3.0, 4.0]):
|
||||||
s = mcrfpy.Sprite(500 + i * 60, 150)
|
s = mcrfpy.Sprite(pos=(500 + i * 60, 150), texture=texture, sprite_index=84) # Player
|
||||||
s.texture = texture
|
s.scale = scale
|
||||||
s.sprite_index = 84 # Player
|
|
||||||
s.scale = (scale, scale)
|
|
||||||
sprites.children.append(s)
|
sprites.children.append(s)
|
||||||
|
|
||||||
# Add all elements to scene
|
# Add all elements to scene
|
||||||
|
|
@ -156,5 +133,22 @@ ui.append(scale_label)
|
||||||
# Switch to scene
|
# Switch to scene
|
||||||
sprites.activate()
|
sprites.activate()
|
||||||
|
|
||||||
# Set timer to capture after rendering starts
|
# 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)
|
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
|
# Apply camera rotation
|
||||||
grid.camera_rotation = 30.0 # 30 degree rotation
|
grid.camera_rotation = 30.0 # 30 degree rotation
|
||||||
grid.center_camera((4, 4)) # Center on middle of grid
|
grid.center_camera((4, 4)) # Center on middle of grid
|
||||||
|
assert grid.camera_rotation == 30.0, "camera_rotation should round-trip"
|
||||||
|
|
||||||
ui.append(grid)
|
ui.append(grid)
|
||||||
|
|
||||||
|
|
@ -46,6 +47,10 @@ for i in range(1, 8):
|
||||||
|
|
||||||
grid2.camera_rotation = 0.0 # No rotation
|
grid2.camera_rotation = 0.0 # No rotation
|
||||||
grid2.center_camera((4, 4))
|
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)
|
ui.append(grid2)
|
||||||
|
|
||||||
|
|
@ -69,6 +74,9 @@ for i in range(6):
|
||||||
grid3.rotation = 15.0
|
grid3.rotation = 15.0
|
||||||
grid3.origin = (100, 75) # Center origin for rotation
|
grid3.origin = (100, 75) # Center origin for rotation
|
||||||
grid3.center_camera((3, 3))
|
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)
|
ui.append(grid3)
|
||||||
|
|
||||||
|
|
@ -76,9 +84,9 @@ label3 = mcrfpy.Caption(text="Grid with viewport rotation=15 (rotates entire wid
|
||||||
ui.append(label3)
|
ui.append(label3)
|
||||||
|
|
||||||
# Test center_camera computes correct pixel center
|
# 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))
|
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
|
# center_camera((0, 0)) should put tile (0,0) at view center
|
||||||
test_grid.center_camera((0, 0))
|
test_grid.center_camera((0, 0))
|
||||||
|
|
@ -90,6 +98,8 @@ c0 = test_grid.center
|
||||||
test_grid.center_camera((10, 7))
|
test_grid.center_camera((10, 7))
|
||||||
c1 = test_grid.center
|
c1 = test_grid.center
|
||||||
assert c0.x != c1.x or c0.y != c1.y, "center_camera at different positions should give different centers"
|
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
|
# center_camera at same position twice should be idempotent
|
||||||
test_grid.center_camera((5, 5))
|
test_grid.center_camera((5, 5))
|
||||||
|
|
|
||||||
|
|
@ -73,24 +73,31 @@ def test_gridview_repr():
|
||||||
print("PASS: GridView repr")
|
print("PASS: GridView repr")
|
||||||
|
|
||||||
def test_gridview_grid_property():
|
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)
|
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||||
grid = mcrfpy.Grid(grid_size=(15, 10), texture=tex, pos=(0, 0), size=(240, 160))
|
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))
|
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_data is grid.grid_data, "view must share the source Grid's GridData"
|
||||||
assert view.grid.grid_w == 15
|
assert view.grid_data.grid_w == 15
|
||||||
# Identity preserved on repeated access
|
# Identity preserved on repeated access
|
||||||
assert view.grid is view.grid
|
assert view.grid_data is view.grid_data
|
||||||
print("PASS: GridView.grid property with identity")
|
print("PASS: GridView.grid_data property with identity")
|
||||||
|
|
||||||
def test_gridview_no_grid():
|
def test_gridview_default_grid():
|
||||||
"""GridView without a grid doesn't crash."""
|
"""GridView with no source grid doesn't crash; it gets its own default GridData."""
|
||||||
view = mcrfpy.GridView()
|
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)
|
r = repr(view)
|
||||||
assert "None" in r
|
assert "GridView" in r
|
||||||
print("PASS: GridView without grid")
|
print("PASS: GridView without a source grid")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
test_gridview_creation()
|
test_gridview_creation()
|
||||||
|
|
@ -99,6 +106,6 @@ if __name__ == "__main__":
|
||||||
test_gridview_multi_view()
|
test_gridview_multi_view()
|
||||||
test_gridview_repr()
|
test_gridview_repr()
|
||||||
test_gridview_grid_property()
|
test_gridview_grid_property()
|
||||||
test_gridview_no_grid()
|
test_gridview_default_grid()
|
||||||
print("All GridView tests passed")
|
print("All GridView tests passed")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import mcrfpy
|
import mcrfpy
|
||||||
|
import sys
|
||||||
|
|
||||||
scene = mcrfpy.Scene("test")
|
scene = mcrfpy.Scene("test")
|
||||||
scene.activate()
|
scene.activate()
|
||||||
f1 = mcrfpy.Frame((10,10), (100,100), fill_color = (255, 0, 0, 64))
|
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(f1)
|
||||||
scene.children.append(f2)
|
scene.children.append(f2)
|
||||||
f1.children.append(f_child)
|
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
|
f_child.parent = f2
|
||||||
|
|
||||||
print(f1.children)
|
print(f1.children)
|
||||||
print(f2.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)
|
abs(sprite9.x - 500.0) < 5.0)
|
||||||
|
|
||||||
|
|
||||||
# --- Test 11: Animation constructor with ping-pong easing ---
|
# --- Test 11: Animation construction with ping-pong easing ---
|
||||||
anim10 = mcrfpy.Animation("x", 100.0, 1.0, mcrfpy.Easing.PING_PONG, loop=True)
|
# mcrfpy.Animation is no longer exported; animations are constructed via the
|
||||||
check("Animation constructor with PING_PONG", anim10 is not None)
|
# target's .animate() method, which returns the Animation object.
|
||||||
check("Animation constructor loop=True", anim10.is_looping == True)
|
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 ---
|
# --- Summary ---
|
||||||
|
|
|
||||||
|
|
@ -3,43 +3,80 @@
|
||||||
|
|
||||||
import mcrfpy
|
import mcrfpy
|
||||||
from mcrfpy import automation
|
from mcrfpy import automation
|
||||||
|
import os
|
||||||
import sys
|
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):
|
def take_screenshot(timer, runtime):
|
||||||
"""Take screenshot after render starts"""
|
"""Take screenshot after the timer fires"""
|
||||||
print(f"Timer callback fired at runtime: {runtime}")
|
print(f"Timer callback fired at runtime: {runtime}")
|
||||||
|
fired.append(runtime)
|
||||||
|
|
||||||
# Try different paths
|
# A writable path must succeed and produce a real PNG file
|
||||||
paths = [
|
print(f"Trying to save to: {GOOD_PATH}")
|
||||||
"test_screenshot.png",
|
if automation.screenshot(GOOD_PATH) is not True:
|
||||||
"./test_screenshot.png",
|
failures.append(f"screenshot({GOOD_PATH}) did not return True")
|
||||||
"mcrogueface.github.io/images/test_screenshot.png"
|
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:
|
# An unwritable path must fail cleanly (return False, no exception)
|
||||||
try:
|
print(f"Trying to save to: {BAD_PATH}")
|
||||||
print(f"Trying to save to: {path}")
|
if automation.screenshot(BAD_PATH) is not False:
|
||||||
automation.screenshot(path)
|
failures.append(f"screenshot({BAD_PATH}) should have returned False")
|
||||||
print(f"Success: {path}")
|
return
|
||||||
except Exception as e:
|
print(f"Failed as expected: {BAD_PATH}")
|
||||||
print(f"Failed {path}: {e}")
|
|
||||||
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
# Create minimal scene
|
# Create minimal scene
|
||||||
test = mcrfpy.Scene("test")
|
test = mcrfpy.Scene("test")
|
||||||
|
|
||||||
# Add a visible element
|
# Add a visible element (Caption.font is read-only as of #320 -- set it in the ctor)
|
||||||
caption = mcrfpy.Caption(pos=(100, 100), text="Screenshot Test")
|
caption = mcrfpy.Caption(pos=(100, 100), font=mcrfpy.default_font, text="Screenshot Test")
|
||||||
caption.font = mcrfpy.default_font
|
|
||||||
caption.fill_color = mcrfpy.Color(255, 255, 255)
|
caption.fill_color = mcrfpy.Color(255, 255, 255)
|
||||||
caption.font_size = 24
|
caption.font_size = 24
|
||||||
|
|
||||||
test.children.append(caption)
|
test.children.append(caption)
|
||||||
test.activate()
|
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...")
|
print("Setting timer...")
|
||||||
mcrfpy.Timer("screenshot", take_screenshot, 500, once=True) # Wait 0.5 seconds
|
mcrfpy.Timer("screenshot", take_screenshot, 500, once=True)
|
||||||
print("Timer set, entering game loop...")
|
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
|
# Global state to track callback
|
||||||
callback_count = 0
|
callback_count = 0
|
||||||
|
callback_args = []
|
||||||
|
|
||||||
# #229 - Animation callbacks now receive (target, property, value) instead of (anim, target)
|
# #229 - Animation callbacks now receive (target, property, value) instead of (anim, target)
|
||||||
def my_callback(target, prop, value):
|
def my_callback(target, prop, value):
|
||||||
"""Simple callback that prints when animation completes"""
|
"""Simple callback that prints when animation completes"""
|
||||||
global callback_count
|
global callback_count
|
||||||
callback_count += 1
|
callback_count += 1
|
||||||
|
callback_args.append((target, prop, value))
|
||||||
print(f"Animation completed! Callback #{callback_count}")
|
print(f"Animation completed! Callback #{callback_count}")
|
||||||
print(f" Target: {type(target).__name__}, Property: {prop}, Value: {value}")
|
print(f" Target: {type(target).__name__}, Property: {prop}, Value: {value}")
|
||||||
|
|
||||||
|
|
@ -23,14 +25,15 @@ callback_demo = mcrfpy.Scene("callback_demo")
|
||||||
callback_demo.activate()
|
callback_demo.activate()
|
||||||
|
|
||||||
# Create a frame to animate
|
# 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 = callback_demo.children
|
||||||
ui.append(frame)
|
ui.append(frame)
|
||||||
|
|
||||||
# Test 1: Animation with callback
|
# 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)...")
|
print("Starting animation with callback (1.0s duration)...")
|
||||||
anim = mcrfpy.Animation("x", 400.0, 1.0, "easeInOutQuad", callback=my_callback)
|
frame.animate("x", 400.0, 1.0, mcrfpy.Easing.EASE_IN_OUT_QUAD, callback=my_callback)
|
||||||
anim.start(frame)
|
|
||||||
|
|
||||||
# Use mcrfpy.step() to advance past animation completion
|
# Use mcrfpy.step() to advance past animation completion
|
||||||
mcrfpy.step(1.5) # Advance 1.5 seconds - animation completes at 1.0s
|
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:
|
if callback_count != 1:
|
||||||
print(f"FAIL: Expected 1 callback, got {callback_count}")
|
print(f"FAIL: Expected 1 callback, got {callback_count}")
|
||||||
sys.exit(1)
|
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!")
|
print("SUCCESS: Callback fired exactly once!")
|
||||||
|
|
||||||
# Test 2: Animation without callback
|
# Test 2: Animation without callback
|
||||||
print("\nTesting animation without callback (0.5s duration)...")
|
print("\nTesting animation without callback (0.5s duration)...")
|
||||||
anim2 = mcrfpy.Animation("y", 300.0, 0.5, "linear")
|
frame.animate("y", 300.0, 0.5, mcrfpy.Easing.LINEAR)
|
||||||
anim2.start(frame)
|
|
||||||
|
|
||||||
# Advance past second animation
|
# Advance past second animation
|
||||||
mcrfpy.step(0.7)
|
mcrfpy.step(0.7)
|
||||||
|
|
@ -51,7 +63,11 @@ mcrfpy.step(0.7)
|
||||||
if callback_count != 1:
|
if callback_count != 1:
|
||||||
print(f"FAIL: Callback count changed to {callback_count}")
|
print(f"FAIL: Callback count changed to {callback_count}")
|
||||||
sys.exit(1)
|
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("SUCCESS: No unexpected callbacks fired!")
|
||||||
print("\nAnimation callback feature working correctly!")
|
print("\nAnimation callback feature working correctly!")
|
||||||
|
print("PASS")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,27 @@
|
||||||
Test Animation Chaining
|
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 mcrfpy
|
||||||
import sys
|
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:
|
class PathAnimator:
|
||||||
"""Handles step-by-step path animation with proper chaining"""
|
"""Handles step-by-step path animation with proper chaining"""
|
||||||
|
|
||||||
|
|
@ -19,7 +34,10 @@ class PathAnimator:
|
||||||
self.step_duration = step_duration
|
self.step_duration = step_duration
|
||||||
self.on_complete = on_complete
|
self.on_complete = on_complete
|
||||||
self.animating = False
|
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):
|
def start(self):
|
||||||
"""Start animating along the path"""
|
"""Start animating along the path"""
|
||||||
|
|
@ -35,47 +53,48 @@ class PathAnimator:
|
||||||
if self.current_index >= len(self.path):
|
if self.current_index >= len(self.path):
|
||||||
# Path complete
|
# Path complete
|
||||||
self.animating = False
|
self.animating = False
|
||||||
if hasattr(self, '_check_timer'):
|
|
||||||
self._check_timer.stop()
|
|
||||||
if self.on_complete:
|
if self.on_complete:
|
||||||
self.on_complete()
|
self.on_complete()
|
||||||
return
|
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
|
# Get target position
|
||||||
target_x, target_y = self.path[self.current_index]
|
target_x, target_y = self.path[self.current_index]
|
||||||
|
|
||||||
# Create animations
|
# Create + start animations ('x'/'y' animate the entity's draw position,
|
||||||
self.anim_x = mcrfpy.Animation("x", float(target_x), self.step_duration, "easeInOut")
|
# in tile coordinates). The x animation's callback chains the next step.
|
||||||
self.anim_y = mcrfpy.Animation("y", float(target_y), self.step_duration, "easeInOut")
|
self.anim_y = self.entity.animate("y", float(target_y), self.step_duration,
|
||||||
|
mcrfpy.Easing.EASE_IN_OUT)
|
||||||
# Start animations
|
self.anim_x = self.entity.animate("x", float(target_x), self.step_duration,
|
||||||
self.anim_x.start(self.entity)
|
mcrfpy.Easing.EASE_IN_OUT,
|
||||||
self.anim_y.start(self.entity)
|
callback=self._on_step_complete)
|
||||||
|
|
||||||
# Update visibility if entity has this method
|
# Update visibility if entity has this method
|
||||||
if hasattr(self.entity, 'update_visibility'):
|
if hasattr(self.entity, 'update_visibility'):
|
||||||
self.entity.update_visibility()
|
self.entity.update_visibility()
|
||||||
|
|
||||||
# Set timer to check completion
|
def _on_step_complete(self, target, prop, value):
|
||||||
self._check_timer = mcrfpy.Timer(self.check_timer_name, self._check_completion, 50)
|
"""Animation completion callback -- advance to the next path node"""
|
||||||
|
self.completed_steps.append((self.current_index,
|
||||||
def _check_completion(self, timer, runtime):
|
(self.entity.draw_pos.x, self.entity.draw_pos.y)))
|
||||||
"""Check if current animation is complete"""
|
self.current_index += 1
|
||||||
if hasattr(self.anim_x, 'is_complete') and self.anim_x.is_complete:
|
self._animate_next_step()
|
||||||
# Move to next step
|
|
||||||
self.current_index += 1
|
|
||||||
timer.stop()
|
|
||||||
self._animate_next_step()
|
|
||||||
|
|
||||||
# Create test scene
|
# Create test scene
|
||||||
chain_test = mcrfpy.Scene("chain_test")
|
chain_test = mcrfpy.Scene("chain_test")
|
||||||
|
|
||||||
# Create grid
|
# 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)
|
grid.fill_color = mcrfpy.Color(20, 20, 30)
|
||||||
|
|
||||||
# Add a color layer for cell coloring
|
# Add a color layer for cell coloring (GridPoint has no .color any more)
|
||||||
color_layer = grid.add_layer("color", z_index=-1)
|
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||||
|
grid.add_layer(color_layer)
|
||||||
|
|
||||||
# Simple map
|
# Simple map
|
||||||
for y in range(15):
|
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:
|
if x == 0 or x == 19 or y == 0 or y == 14:
|
||||||
cell.walkable = False
|
cell.walkable = False
|
||||||
cell.transparent = 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:
|
else:
|
||||||
cell.walkable = True
|
cell.walkable = True
|
||||||
cell.transparent = 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
|
# Create entities
|
||||||
player = mcrfpy.Entity((2, 2), grid=grid)
|
player = mcrfpy.Entity(grid_pos=(2, 2))
|
||||||
player.sprite_index = 64 # @
|
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
|
enemy.sprite_index = 69 # E
|
||||||
|
grid.entities.append(enemy)
|
||||||
|
|
||||||
# UI setup
|
# UI setup
|
||||||
ui = chain_test.children
|
ui = chain_test.children
|
||||||
ui.append(grid)
|
ui.append(grid)
|
||||||
grid.pos = (100, 100)
|
|
||||||
grid.size = (600, 450)
|
|
||||||
|
|
||||||
title = mcrfpy.Caption(pos=(300, 20), text="Animation Chaining Test")
|
title = mcrfpy.Caption(pos=(300, 20), text="Animation Chaining Test")
|
||||||
title.fill_color = mcrfpy.Color(255, 255, 255)
|
title.fill_color = mcrfpy.Color(255, 255, 255)
|
||||||
ui.append(title)
|
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 = mcrfpy.Caption(pos=(100, 70), text="Status: Ready")
|
||||||
info.fill_color = mcrfpy.Color(100, 255, 100)
|
info.fill_color = mcrfpy.Color(100, 255, 100)
|
||||||
ui.append(info)
|
ui.append(info)
|
||||||
|
|
@ -118,23 +133,34 @@ ui.append(info)
|
||||||
# Path animators
|
# Path animators
|
||||||
player_animator = None
|
player_animator = None
|
||||||
enemy_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():
|
def animate_player():
|
||||||
"""Animate player along a path"""
|
"""Animate player along a path"""
|
||||||
global player_animator
|
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():
|
def on_complete():
|
||||||
|
global player_done
|
||||||
|
player_done = True
|
||||||
info.text = "Player animation complete!"
|
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()
|
player_animator.start()
|
||||||
info.text = "Animating player..."
|
info.text = "Animating player..."
|
||||||
|
|
||||||
|
|
@ -142,18 +168,13 @@ def animate_enemy():
|
||||||
"""Animate enemy along a path"""
|
"""Animate enemy along a path"""
|
||||||
global enemy_animator
|
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():
|
def on_complete():
|
||||||
|
global enemy_done
|
||||||
|
enemy_done = True
|
||||||
info.text = "Enemy animation complete!"
|
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()
|
enemy_animator.start()
|
||||||
info.text = "Animating enemy..."
|
info.text = "Animating enemy..."
|
||||||
|
|
||||||
|
|
@ -164,60 +185,101 @@ def animate_both():
|
||||||
animate_enemy()
|
animate_enemy()
|
||||||
|
|
||||||
# Camera follow test
|
# Camera follow test
|
||||||
camera_follow = False
|
camera_follow = True
|
||||||
|
camera_updates = 0
|
||||||
|
|
||||||
def update_camera(timer, runtime):
|
def update_camera(timer, runtime):
|
||||||
"""Update camera to follow player if enabled"""
|
"""Update camera to follow player if enabled"""
|
||||||
|
global camera_updates
|
||||||
if camera_follow and player_animator and player_animator.animating:
|
if camera_follow and player_animator and player_animator.animating:
|
||||||
# Smooth camera follow
|
# Smooth camera follow. grid.center is in pixels; entity.x/.y are the
|
||||||
center_x = player.x * 30 # Assuming ~30 pixels per cell
|
# entity's draw position in pixels (draw_pos is the same in tile coords).
|
||||||
center_y = player.y * 30
|
grid.animate("center", (player.x, player.y), 0.25, mcrfpy.Easing.LINEAR)
|
||||||
cam_anim = mcrfpy.Animation("center", (center_x, center_y), 0.25, "linear")
|
camera_updates += 1
|
||||||
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"
|
|
||||||
|
|
||||||
# Setup
|
# Setup
|
||||||
chain_test.activate()
|
chain_test.activate()
|
||||||
chain_test.on_key = handle_input
|
|
||||||
|
|
||||||
# Camera update timer
|
|
||||||
cam_update_timer = mcrfpy.Timer("cam_update", update_camera, 100)
|
cam_update_timer = mcrfpy.Timer("cam_update", update_camera, 100)
|
||||||
|
|
||||||
print("Animation Chaining Test")
|
print("Animation Chaining Test")
|
||||||
print("=======================")
|
print("=======================")
|
||||||
print("This test demonstrates proper animation chaining")
|
|
||||||
print("to avoid simultaneous position updates.")
|
# --- Drive the chained animations headlessly -------------------------------
|
||||||
print()
|
animate_both()
|
||||||
print("Controls:")
|
|
||||||
print(" 1 - Animate player step by step")
|
check("player animator started", player_animator.animating)
|
||||||
print(" 2 - Animate enemy step by step")
|
check("enemy animator started", enemy_animator.animating)
|
||||||
print(" 3 - Animate both (simultaneous)")
|
|
||||||
print(" C - Toggle camera follow")
|
# Path node 0 is the entity's starting cell, so the first 0.2s step is a no-op
|
||||||
print(" R - Reset positions")
|
# move. Halfway through the *second* step the entity must be strictly between
|
||||||
print(" Q - Quit")
|
# nodes 0 and 1 -- i.e. the animation is interpolating, one step at a time.
|
||||||
print()
|
for _ in range(2):
|
||||||
print("Notice how each entity moves one tile at a time,")
|
mcrfpy.step(0.1) # completes step 0, chains step 1
|
||||||
print("waiting for each step to complete before the next.")
|
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)
|
ui.append(f)
|
||||||
initial_frames.append(f)
|
initial_frames.append(f)
|
||||||
|
|
||||||
# Animate them
|
# Animate them (mcrfpy.Animation is gone; animations are built by the target)
|
||||||
anim = mcrfpy.Animation("y", 300.0, 2.0, "easeOutBounce")
|
f.animate("y", 300.0, 2.0, mcrfpy.Easing.EASE_OUT_BOUNCE)
|
||||||
anim.start(f)
|
|
||||||
|
|
||||||
print(f"Initial scene has {len(ui)} elements")
|
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
|
# Create new animated objects
|
||||||
print("Creating new animated objects...")
|
print("Creating new animated objects...")
|
||||||
|
new_frames = []
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
f = mcrfpy.Frame(pos=(100 + i*50, 200), size=(40, 40))
|
f = mcrfpy.Frame(pos=(100 + i*50, 200), size=(40, 40))
|
||||||
f.fill_color = mcrfpy.Color(100 + i*30, 50, 200)
|
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
|
# Start animation on the new frame
|
||||||
target_x = 300 + i * 50
|
target_x = 300 + i * 50
|
||||||
anim = mcrfpy.Animation("x", float(target_x), 1.0, "easeInOut")
|
f.animate("x", float(target_x), 1.0, mcrfpy.Easing.EASE_IN_OUT)
|
||||||
anim.start(f)
|
new_frames.append((f, float(target_x)))
|
||||||
|
|
||||||
print("New objects created and animated")
|
print("New objects created and animated")
|
||||||
print(f"Scene now has {len(ui)} elements")
|
print(f"Scene now has {len(ui)} elements")
|
||||||
|
|
||||||
# Let new animations run
|
# Let new animations run to completion (duration 1.0s)
|
||||||
mcrfpy.step(1.5)
|
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")
|
print(f"\nFinal scene has {len(ui)} elements")
|
||||||
if len(ui) == 7: # 2 captions + 5 new frames
|
if len(ui) != 7: # 2 captions + 5 new frames
|
||||||
print("SUCCESS: Animation removal test passed!")
|
failures.append(f"Expected 7 elements, got {len(ui)}")
|
||||||
sys.exit(0)
|
|
||||||
else:
|
# The removed frames' animations must not have kept running (or crashed);
|
||||||
print(f"FAIL: Expected 7 elements, got {len(ui)}")
|
# 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)
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("SUCCESS: Animation removal test passed!")
|
||||||
|
print("PASS")
|
||||||
|
sys.exit(0)
|
||||||
|
|
|
||||||
|
|
@ -1,138 +1,137 @@
|
||||||
#!/usr/bin/env python3
|
#!/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 os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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():
|
def test_api_docs_exist():
|
||||||
"""Test that API documentation was generated."""
|
"""Test that API documentation was generated."""
|
||||||
docs_path = Path("docs/API_REFERENCE.md")
|
if not DOCS_PATH.exists():
|
||||||
|
print(f"ERROR: API documentation not found at {DOCS_PATH}")
|
||||||
if not docs_path.exists():
|
|
||||||
print("ERROR: API documentation not found at docs/API_REFERENCE.md")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
print("✓ API documentation file exists")
|
print("+ API documentation file exists")
|
||||||
|
|
||||||
# Check file size
|
# Check file size
|
||||||
size = docs_path.stat().st_size
|
size = DOCS_PATH.stat().st_size
|
||||||
if size < 1000:
|
if size < 1000:
|
||||||
print(f"ERROR: API documentation seems too small ({size} bytes)")
|
print(f"ERROR: API documentation seems too small ({size} bytes)")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
print(f"✓ API documentation has reasonable size ({size} bytes)")
|
print(f"+ API documentation has reasonable size ({size} bytes)")
|
||||||
|
|
||||||
# Read content
|
# Read content
|
||||||
with open(docs_path, 'r') as f:
|
with open(DOCS_PATH, 'r') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
# Check for expected sections
|
# Check for expected sections (current generated layout)
|
||||||
expected_sections = [
|
expected_sections = [
|
||||||
"# McRogueFace API Reference",
|
"# McRogueFace API Reference",
|
||||||
"## Overview",
|
"## Table of Contents",
|
||||||
"## Classes",
|
"## Module Attributes",
|
||||||
"## Functions",
|
"## Functions",
|
||||||
"## Automation Module"
|
"## Classes",
|
||||||
]
|
]
|
||||||
|
|
||||||
missing = []
|
missing = [s for s in expected_sections if s not in content]
|
||||||
for section in expected_sections:
|
|
||||||
if section not in content:
|
|
||||||
missing.append(section)
|
|
||||||
|
|
||||||
if missing:
|
if missing:
|
||||||
print(f"ERROR: Missing sections: {missing}")
|
print(f"ERROR: Missing sections: {missing}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
print("✓ All expected sections present")
|
print("+ All expected sections present")
|
||||||
|
|
||||||
# Check for key classes
|
# Check for key classes ("### ClassName" headings)
|
||||||
key_classes = ["Frame", "Caption", "Sprite", "Grid", "Entity", "Scene"]
|
key_classes = ["Frame", "Caption", "Sprite", "Grid", "Entity", "Scene"]
|
||||||
missing_classes = []
|
missing_classes = [c for c in key_classes if f"\n### {c}\n" not in content]
|
||||||
for cls in key_classes:
|
|
||||||
if f"### class {cls}" not in content:
|
|
||||||
missing_classes.append(cls)
|
|
||||||
|
|
||||||
if missing_classes:
|
if missing_classes:
|
||||||
print(f"ERROR: Missing classes: {missing_classes}")
|
print(f"ERROR: Missing classes: {missing_classes}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
print("✓ All key classes documented")
|
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)
|
|
||||||
|
|
||||||
|
# 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:
|
if missing_funcs:
|
||||||
print(f"ERROR: Missing functions: {missing_funcs}")
|
print(f"ERROR: Missing functions: {missing_funcs}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
print("✓ All key functions documented")
|
print("+ All key functions documented")
|
||||||
|
|
||||||
# Check automation module
|
# Scene management moved from functions to the mcrfpy.current_scene attribute
|
||||||
if "automation.screenshot" in content:
|
if "### `mcrfpy.current_scene`" not in content:
|
||||||
print("✓ Automation module documented")
|
print("ERROR: mcrfpy.current_scene not documented under Module Attributes")
|
||||||
else:
|
|
||||||
print("ERROR: Automation module not properly documented")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
print("+ Module attributes documented")
|
||||||
|
|
||||||
# Count documentation entries
|
# Count documentation entries
|
||||||
class_count = content.count("### class ")
|
class_count = sum(1 for line in content.splitlines()
|
||||||
func_count = content.count("### ") - class_count - content.count("### automation.")
|
if line.startswith("### ") and not line.startswith("### `"))
|
||||||
auto_count = content.count("### automation.")
|
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"\nDocumentation Coverage:")
|
||||||
print(f"- Classes: {class_count}")
|
print(f"- Classes: {class_count}")
|
||||||
print(f"- Functions: {func_count}")
|
print(f"- Functions/attributes: {func_count}")
|
||||||
print(f"- Automation methods: {auto_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
|
return True
|
||||||
|
|
||||||
|
|
||||||
def test_doc_accuracy():
|
def test_doc_accuracy():
|
||||||
"""Test that documentation matches actual API."""
|
"""Test that documentation matches actual API."""
|
||||||
# Import mcrfpy to check
|
|
||||||
import mcrfpy
|
import mcrfpy
|
||||||
|
|
||||||
print("\nVerifying documentation accuracy...")
|
print("\nVerifying documentation accuracy...")
|
||||||
|
|
||||||
# Read documentation
|
with open(DOCS_PATH, 'r') as f:
|
||||||
with open("docs/API_REFERENCE.md", 'r') as f:
|
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
|
passed = True
|
||||||
|
|
||||||
# Check that all public classes are documented
|
# 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('_')]
|
if isinstance(getattr(mcrfpy, name), type) and not name.startswith('_')]
|
||||||
|
|
||||||
undocumented = []
|
undocumented = [c for c in actual_classes if f"\n### {c}\n" not in content]
|
||||||
for cls in actual_classes:
|
|
||||||
if f"### class {cls}" not in content:
|
|
||||||
undocumented.append(cls)
|
|
||||||
|
|
||||||
if undocumented:
|
if undocumented:
|
||||||
print(f"WARNING: Undocumented classes: {undocumented}")
|
print(f"ERROR: Undocumented classes: {undocumented}")
|
||||||
|
passed = False
|
||||||
else:
|
else:
|
||||||
print("✓ All public classes are documented")
|
print(f"+ All {len(actual_classes)} public classes are documented")
|
||||||
|
|
||||||
# Check functions
|
# Check functions
|
||||||
actual_funcs = [name for name in dir(mcrfpy)
|
actual_funcs = [name for name in dir(mcrfpy)
|
||||||
if callable(getattr(mcrfpy, name)) and not name.startswith('_')
|
if callable(getattr(mcrfpy, name)) and not name.startswith('_')
|
||||||
and not isinstance(getattr(mcrfpy, name), type)]
|
and not isinstance(getattr(mcrfpy, name), type)]
|
||||||
|
|
||||||
undoc_funcs = []
|
undoc_funcs = [f for f in actual_funcs if f"\n### `{f}(" not in content]
|
||||||
for func in actual_funcs:
|
|
||||||
if f"### {func}" not in content:
|
|
||||||
undoc_funcs.append(func)
|
|
||||||
|
|
||||||
if undoc_funcs:
|
if undoc_funcs:
|
||||||
print(f"WARNING: Undocumented functions: {undoc_funcs}")
|
print(f"ERROR: Undocumented functions: {undoc_funcs}")
|
||||||
|
passed = False
|
||||||
else:
|
else:
|
||||||
print("✓ All public functions are documented")
|
print(f"+ All {len(actual_funcs)} public functions are documented")
|
||||||
|
|
||||||
|
return passed
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Run all API documentation tests."""
|
"""Run all API documentation tests."""
|
||||||
|
|
@ -154,11 +153,12 @@ def main():
|
||||||
print()
|
print()
|
||||||
|
|
||||||
if all_passed:
|
if all_passed:
|
||||||
print("✅ All API documentation tests passed!")
|
print("PASS: All API documentation tests passed!")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
else:
|
else:
|
||||||
print("❌ Some tests failed.")
|
print("FAIL: Some tests failed.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|
@ -3,7 +3,15 @@
|
||||||
Test A* Pathfinding Implementation
|
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
|
import mcrfpy
|
||||||
|
|
@ -13,24 +21,42 @@ import time
|
||||||
print("A* Pathfinding Test")
|
print("A* Pathfinding Test")
|
||||||
print("==================")
|
print("==================")
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
if cond:
|
||||||
|
print(f" PASS: {msg}")
|
||||||
|
else:
|
||||||
|
print(f" FAIL: {msg}")
|
||||||
|
failures.append(msg)
|
||||||
|
|
||||||
# Create scene and grid
|
# Create scene and grid
|
||||||
astar_test = mcrfpy.Scene("astar_test")
|
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
|
# Initialize grid - all walkable
|
||||||
for y in range(20):
|
for y in range(20):
|
||||||
for x 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...")
|
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):
|
for x in range(8, 12):
|
||||||
if not (x == 10 and y == 10): # Leave a gap at (10, 10)
|
if y == 10: # corridor through the barrier, including (10, 10)
|
||||||
grid.at(x, y).walkable = False
|
continue
|
||||||
print(f" Wall at ({x}, {y})")
|
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
|
# Test points
|
||||||
start = (2, 10)
|
start = (2, 10)
|
||||||
|
|
@ -38,93 +64,125 @@ end = (18, 10)
|
||||||
|
|
||||||
print(f"\nFinding path from {start} to {end}")
|
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*)
|
def as_cells(path):
|
||||||
print("\n2. Testing find_path method:")
|
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()
|
start_time = time.time()
|
||||||
find_path_result = grid.find_path(start[0], start[1], end[0], end[1])
|
astar_path = data.find_path(start, end)
|
||||||
find_path_time = time.time() - start_time
|
astar_time = time.time() - start_time
|
||||||
print(f" find_path length: {len(find_path_result)}")
|
check(astar_path is not None, "A* found a path through the barrier")
|
||||||
print(f" find_path time: {find_path_time*1000:.3f} ms")
|
# NOTE: iterating an AStarPath consumes it, so read .remaining before as_cells().
|
||||||
if find_path_result:
|
astar_remaining = astar_path.remaining if astar_path else 0
|
||||||
print(f" First 5 steps: {find_path_result[:5]}")
|
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
|
# Test 3: Dijkstra pathfinding for comparison
|
||||||
print("\n3. Testing Dijkstra pathfinding:")
|
print("\n3. Testing Dijkstra pathfinding:")
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
grid.compute_dijkstra(start[0], start[1])
|
dmap = data.get_dijkstra_map(root=start)
|
||||||
dijkstra_path = grid.get_dijkstra_path(end[0], end[1])
|
dijkstra_path = dmap.path_from(end)
|
||||||
dijkstra_time = time.time() - start_time
|
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")
|
print(f" Dijkstra time: {dijkstra_time*1000:.3f} ms")
|
||||||
if dijkstra_path:
|
print(f" First 5 steps: {dijkstra_cells[:5]}")
|
||||||
print(f" First 5 steps: {dijkstra_path[: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("\nComparison:")
|
||||||
print(f" A* vs find_path: {'SAME' if astar_path == find_path_result else 'DIFFERENT'}")
|
print(f" A* steps: {len(astar_cells)}, Dijkstra steps: {len(dijkstra_cells)}")
|
||||||
print(f" A* vs Dijkstra: {'SAME' if astar_path == dijkstra_path else 'DIFFERENT'}")
|
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:")
|
print("\n4. Testing with blocked destination:")
|
||||||
blocked_end = (10, 8) # Inside the wall
|
blocked_end = (10, 8) # inside the wall
|
||||||
grid.at(blocked_end[0], blocked_end[1]).walkable = False
|
check(data.at(*blocked_end).walkable is False, "blocked destination is unwalkable")
|
||||||
no_path = grid.compute_astar_path(start[0], start[1], blocked_end[0], blocked_end[1])
|
no_path = data.find_path(start, blocked_end)
|
||||||
print(f" Path to blocked cell: {no_path} (should be empty)")
|
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:")
|
print("\n5. Testing diagonal paths:")
|
||||||
diag_start = (0, 0)
|
diag_start = (0, 0)
|
||||||
diag_end = (5, 5)
|
diag_end = (5, 5)
|
||||||
diag_path = grid.compute_astar_path(diag_start[0], diag_start[1], diag_end[0], diag_end[1])
|
diag_path = data.find_path(diag_start, diag_end)
|
||||||
print(f" Diagonal path from {diag_start} to {diag_end}:")
|
diag_cells = as_cells(diag_path) if diag_path else []
|
||||||
print(f" Length: {len(diag_path)}")
|
print(f" Diagonal path from {diag_start} to {diag_end}: {diag_cells}")
|
||||||
print(f" Path: {diag_path}")
|
# 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)
|
# Test 6: performance / corner-to-corner agreement
|
||||||
|
|
||||||
# Performance test with larger path
|
|
||||||
print("\n6. Performance test (corner to corner):")
|
print("\n6. Performance test (corner to corner):")
|
||||||
corner_paths = []
|
results = {}
|
||||||
methods = [
|
start_time = time.time()
|
||||||
("A*", lambda: grid.compute_astar_path(0, 0, 19, 19)),
|
corner_astar = as_cells(data.find_path((0, 0), (19, 19)) or [])
|
||||||
("Dijkstra", lambda: (grid.compute_dijkstra(0, 0), grid.get_dijkstra_path(19, 19))[1])
|
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:
|
# Quick smoke test that the grid renders in a scene and the clock advances.
|
||||||
start_time = time.time()
|
# Headless: mcrfpy.step() is the only clock; a Timer never fires on its own.
|
||||||
path = method()
|
timer_fired = []
|
||||||
elapsed = time.time() - start_time
|
|
||||||
print(f" {name}: {len(path)} steps in {elapsed*1000:.3f} ms")
|
|
||||||
|
|
||||||
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):
|
def visual_test(timer, runtime):
|
||||||
print("\nVisual test timer fired")
|
print("\nVisual test timer fired")
|
||||||
sys.exit(0)
|
timer_fired.append(runtime)
|
||||||
|
|
||||||
|
|
||||||
# Set up minimal UI for visual test
|
|
||||||
ui = astar_test.children
|
ui = astar_test.children
|
||||||
ui.append(grid)
|
ui.append(grid)
|
||||||
grid.pos = (50, 50)
|
|
||||||
grid.size = (400, 400)
|
|
||||||
|
|
||||||
astar_test.activate()
|
astar_test.activate()
|
||||||
visual_test_timer = mcrfpy.Timer("visual", visual_test, 100, once=True)
|
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
|
#!/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 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("Testing builtins in different contexts...")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
||||||
# Test 1: At module level (working in our test)
|
# Test 1: At module level
|
||||||
print("Test 1: Module level")
|
print("Test 1: Module level")
|
||||||
try:
|
try:
|
||||||
|
xs = []
|
||||||
for x in range(3):
|
for x in range(3):
|
||||||
print(f" x={x}")
|
xs.append(x)
|
||||||
print(" ✓ Module level works")
|
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:
|
except Exception as e:
|
||||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
traceback.print_exc()
|
||||||
|
fail("module level range", f"{type(e).__name__}: {e}")
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
@ -21,13 +42,17 @@ print()
|
||||||
print("Test 2: Inside function")
|
print("Test 2: Inside function")
|
||||||
def test_function():
|
def test_function():
|
||||||
try:
|
try:
|
||||||
|
xs = [x for x in range(3)]
|
||||||
|
xs2 = []
|
||||||
for x in range(3):
|
for x in range(3):
|
||||||
print(f" x={x}")
|
xs2.append(f"x={x}")
|
||||||
print(" ✓ Function level works")
|
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:
|
except Exception as e:
|
||||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
fail("function level builtins", f"{type(e).__name__}: {e}")
|
||||||
|
|
||||||
test_function()
|
test_function()
|
||||||
|
|
||||||
|
|
@ -38,30 +63,35 @@ print("Test 3: Function creating mcrfpy objects")
|
||||||
def create_scene():
|
def create_scene():
|
||||||
try:
|
try:
|
||||||
test = mcrfpy.Scene("test")
|
test = mcrfpy.Scene("test")
|
||||||
print(" ✓ Created scene")
|
print(" ok Created scene")
|
||||||
|
|
||||||
# Now try range
|
# Now try range -- must still work after a Scene exists
|
||||||
for x in range(3):
|
xs = [x for x in range(3)]
|
||||||
print(f" x={x}")
|
if xs != [0, 1, 2]:
|
||||||
print(" ✓ Range after createScene works")
|
fail("range after Scene()", f"expected [0, 1, 2], got {xs}")
|
||||||
|
return None
|
||||||
|
print(" ok Range after Scene creation works")
|
||||||
|
|
||||||
# Create grid
|
# Create grid (current API: grid_size tuple)
|
||||||
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
|
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||||
print(" ✓ Created grid")
|
print(" ok Created grid")
|
||||||
|
|
||||||
# Try range again
|
# Try range again -- must still work after a Grid exists
|
||||||
for x in range(3):
|
xs = [x for x in range(3)]
|
||||||
print(f" x={x}")
|
if xs != [0, 1, 2]:
|
||||||
print(" ✓ Range after Grid creation works")
|
fail("range after Grid()", f"expected [0, 1, 2], got {xs}")
|
||||||
|
return None
|
||||||
|
print(" ok Range after Grid creation works")
|
||||||
|
|
||||||
return grid
|
return grid
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
fail("mcrfpy object construction context", f"{type(e).__name__}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
grid = create_scene()
|
grid = create_scene()
|
||||||
|
if grid is None:
|
||||||
|
fail("create_scene", "returned None")
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
@ -70,19 +100,22 @@ print("Test 4: Exact failing pattern")
|
||||||
def failing_pattern():
|
def failing_pattern():
|
||||||
try:
|
try:
|
||||||
failing_test = mcrfpy.Scene("failing_test")
|
failing_test = mcrfpy.Scene("failing_test")
|
||||||
grid = mcrfpy.Grid(grid_w=14, grid_h=10)
|
grid = mcrfpy.Grid(grid_size=(14, 10))
|
||||||
|
|
||||||
# This is where it fails in the demos
|
# This is where it used to fail in the demos
|
||||||
walls = []
|
walls = []
|
||||||
print(" About to enter range loop...")
|
print(" About to enter range loop...")
|
||||||
for x in range(1, 8):
|
for x in range(1, 8):
|
||||||
walls.append((x, 1))
|
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:
|
except Exception as e:
|
||||||
print(f" ✗ Error at line: {type(e).__name__}: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
fail("demo wall pattern", f"{type(e).__name__}: {e}")
|
||||||
|
|
||||||
failing_pattern()
|
failing_pattern()
|
||||||
|
|
||||||
|
|
@ -95,34 +128,53 @@ def test_append():
|
||||||
walls = []
|
walls = []
|
||||||
# Test 1: Simple append
|
# Test 1: Simple append
|
||||||
walls.append((1, 1))
|
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
|
# Test 2: Manual loop
|
||||||
i = 0
|
i = 0
|
||||||
while i < 3:
|
while i < 3:
|
||||||
walls.append((i, 1))
|
walls.append((i, 1))
|
||||||
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
|
# Test 3: Range with different operations
|
||||||
walls2 = []
|
walls2 = []
|
||||||
for x in range(3):
|
for x in range(3):
|
||||||
tup = (x, 2)
|
tup = (x, 2)
|
||||||
walls2.append(tup)
|
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
|
# Test 4: Direct tuple creation in append
|
||||||
walls3 = []
|
walls3 = []
|
||||||
for x in range(3):
|
for x in range(3):
|
||||||
walls3.append((x, 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:
|
except Exception as e:
|
||||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
fail("append in loop", f"{type(e).__name__}: {e}")
|
||||||
|
|
||||||
test_append()
|
test_append()
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
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("All tests complete.")
|
||||||
|
print("PASS")
|
||||||
|
sys.exit(0)
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,76 @@
|
||||||
#!/usr/bin/env python3
|
#!/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 mcrfpy
|
||||||
|
import sys
|
||||||
|
|
||||||
print("Testing Color fix...")
|
print("Testing Color fix...")
|
||||||
|
|
||||||
# Test 1: Create grid
|
failures = 0
|
||||||
|
|
||||||
|
# Test 1: Create grid + color layer
|
||||||
try:
|
try:
|
||||||
test = mcrfpy.Scene("test")
|
test = mcrfpy.Scene("test")
|
||||||
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
|
grid = mcrfpy.Grid(grid_size=(5, 5))
|
||||||
print("✓ Grid created")
|
color_layer = mcrfpy.ColorLayer(name="bg")
|
||||||
|
grid.add_layer(color_layer)
|
||||||
|
print("+ Grid created")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"✗ Grid creation failed: {e}")
|
print(f"x Grid creation failed: {e}")
|
||||||
exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Test 2: Set color with tuple
|
# Test 2: Set color with tuple
|
||||||
try:
|
try:
|
||||||
grid.at(0, 0).color = (100, 100, 100)
|
color_layer.set((0, 0), (100, 100, 100))
|
||||||
print("✓ Tuple color assignment works")
|
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:
|
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
|
# Test 3: Set color with Color object
|
||||||
try:
|
try:
|
||||||
grid.at(0, 0).color = mcrfpy.Color(200, 200, 200)
|
color_layer.set((0, 0), mcrfpy.Color(200, 200, 200))
|
||||||
print("✓ Color object assignment works!")
|
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:
|
except Exception as e:
|
||||||
print(f"✗ Color assignment failed: {e}")
|
print(f"x Color assignment failed: {e}")
|
||||||
|
failures += 1
|
||||||
|
|
||||||
|
# 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("Done.")
|
||||||
|
print("PASS")
|
||||||
|
sys.exit(0)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,19 @@
|
||||||
#!/usr/bin/env python3
|
#!/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 mcrfpy
|
||||||
|
import sys
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
|
||||||
print("Testing Color operations with range()...")
|
print("Testing Color operations with range()...")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
@ -10,10 +22,14 @@ print("=" * 50)
|
||||||
print("Test 1: Color assignment in grid")
|
print("Test 1: Color assignment in grid")
|
||||||
try:
|
try:
|
||||||
test1 = mcrfpy.Scene("test1")
|
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
|
# 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")
|
print(" ✓ Single color assignment works")
|
||||||
|
|
||||||
# Test range
|
# Test range
|
||||||
|
|
@ -23,22 +39,34 @@ try:
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
print(f" ✗ Error: {type(e).__name__}: {e}")
|
||||||
|
failures.append(f"Test 1: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
# Test 2: Multiple color assignments
|
# Test 2: Multiple color assignments
|
||||||
print("\nTest 2: Multiple color assignments")
|
print("\nTest 2: Multiple color assignments")
|
||||||
try:
|
try:
|
||||||
test2 = mcrfpy.Scene("test2")
|
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
|
# Multiple properties including color
|
||||||
for y in range(15):
|
for y in range(15):
|
||||||
for x in range(25):
|
for x in range(25):
|
||||||
grid.at(x, y).walkable = True
|
grid.at(x, y).walkable = True
|
||||||
grid.at(x, y).transparent = 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")
|
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
|
# This is where it would fail
|
||||||
for i in range(25):
|
for i in range(25):
|
||||||
pass
|
pass
|
||||||
|
|
@ -48,6 +76,7 @@ except Exception as e:
|
||||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
print(f" ✗ Error: {type(e).__name__}: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
failures.append(f"Test 2: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
# Test 3: Exact reproduction of failing pattern
|
# Test 3: Exact reproduction of failing pattern
|
||||||
print("\nTest 3: Exact pattern from dijkstra_demo_final.py")
|
print("\nTest 3: Exact pattern from dijkstra_demo_final.py")
|
||||||
|
|
@ -57,15 +86,17 @@ try:
|
||||||
dijkstra_demo = mcrfpy.Scene("dijkstra_demo")
|
dijkstra_demo = mcrfpy.Scene("dijkstra_demo")
|
||||||
|
|
||||||
# Create grid
|
# 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)
|
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
||||||
|
colors = mcrfpy.ColorLayer(name="cell_color")
|
||||||
|
grid.add_layer(colors)
|
||||||
|
|
||||||
# Initialize all as floor
|
# Initialize all as floor
|
||||||
for y in range(15):
|
for y in range(15):
|
||||||
for x in range(25):
|
for x in range(25):
|
||||||
grid.at(x, y).walkable = True
|
grid.at(x, y).walkable = True
|
||||||
grid.at(x, y).transparent = 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
|
# Create an interesting dungeon layout
|
||||||
walls = []
|
walls = []
|
||||||
|
|
@ -77,15 +108,22 @@ try:
|
||||||
return grid, walls
|
return grid, walls
|
||||||
|
|
||||||
grid, walls = create_demo()
|
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}")
|
print(f" ✓ Function completed successfully, walls: {walls}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
print(f" ✗ Error: {type(e).__name__}: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
failures.append(f"Test 3: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
print("\nConclusion: The bug is inconsistent and may be related to:")
|
print()
|
||||||
print("- Memory layout at the time of execution")
|
if failures:
|
||||||
print("- Specific bytecode patterns in the Python code")
|
for f in failures:
|
||||||
print("- C++ reference counting issues with Color objects")
|
print(f"FAILED: {f}")
|
||||||
print("- Stack/heap corruption in the grid.at() implementation")
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("PASS")
|
||||||
|
sys.exit(0)
|
||||||
|
|
|
||||||
|
|
@ -1,70 +1,138 @@
|
||||||
#!/usr/bin/env python3
|
#!/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 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)
|
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")
|
print("Test 1: Setting color with tuple")
|
||||||
try:
|
try:
|
||||||
test1 = mcrfpy.Scene("test1")
|
grid, layer = make_color_layer(5, 5)
|
||||||
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
|
layer.set((0, 0), (200, 200, 220))
|
||||||
|
|
||||||
# This should work (PyArg_ParseTuple expects tuple)
|
# The original bug surfaced here: an unrelated op raising the pending error.
|
||||||
grid.at(0, 0).color = (200, 200, 220)
|
|
||||||
|
|
||||||
# Check if exception is pending
|
|
||||||
_ = list(range(1))
|
_ = 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:
|
except Exception as e:
|
||||||
print(f" ✗ Tuple assignment failed: {type(e).__name__}: {e}")
|
check("tuple assignment", False, f"{type(e).__name__}: {e}")
|
||||||
|
|
||||||
print()
|
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")
|
print("Test 2: Setting color with Color object")
|
||||||
try:
|
try:
|
||||||
test2 = mcrfpy.Scene("test2")
|
grid, layer = make_color_layer(5, 5)
|
||||||
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
|
layer.set((0, 0), mcrfpy.Color(200, 200, 220))
|
||||||
|
|
||||||
# This will fail in PyArg_ParseTuple but not report it
|
# If a stale error indicator were left behind, this innocent call would
|
||||||
grid.at(0, 0).color = mcrfpy.Color(200, 200, 220)
|
# raise it instead.
|
||||||
print(" ⚠️ Color assignment appeared to work...")
|
|
||||||
|
|
||||||
# But exception is pending!
|
|
||||||
_ = list(range(1))
|
_ = 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:
|
except Exception as e:
|
||||||
print(f" ✗ Exception detected: {type(e).__name__}: {e}")
|
check("Color object assignment", False, f"{type(e).__name__}: {e}")
|
||||||
print(" This confirms the bug - exception was set but not raised")
|
|
||||||
|
|
||||||
print()
|
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)")
|
print("Test 3: Multiple Color assignments (reproducing original bug)")
|
||||||
try:
|
try:
|
||||||
test3 = mcrfpy.Scene("test3")
|
grid, layer = make_color_layer(25, 15)
|
||||||
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
|
|
||||||
|
|
||||||
# Do multiple color assignments
|
for y in range(2):
|
||||||
for y in range(2): # Just 2 rows to be quick
|
|
||||||
for x in range(25):
|
for x in range(25):
|
||||||
grid.at(x, y).color = mcrfpy.Color(200, 200, 220)
|
layer.set((x, y), mcrfpy.Color(200, 200, 220))
|
||||||
|
|
||||||
print(" All color assignments completed...")
|
# The canary from the original bug report.
|
||||||
|
|
||||||
# This should fail
|
|
||||||
for i in range(25):
|
for i in range(25):
|
||||||
pass
|
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:
|
except Exception as e:
|
||||||
print(f" ✗ range(25) failed as expected: {type(e).__name__}")
|
check("multiple Color assignments", False, f"{type(e).__name__}: {e}")
|
||||||
print(" The exception was set during color assignment")
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("Bug confirmed: PyObject_to_sfColor in UIGridPoint.cpp")
|
|
||||||
print("doesn't clear the exception when PyArg_ParseTuple fails.")
|
# Test 4: A genuinely bad color must raise a REAL exception, immediately -- the
|
||||||
print("The fix: Either check PyErr_Occurred() after ParseTuple,")
|
# other half of the bug was errors being set but never raised.
|
||||||
print("or support mcrfpy.Color objects directly.")
|
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
|
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():
|
def test_apply_threshold_basic():
|
||||||
"""apply_threshold sets colors in range"""
|
"""apply_threshold sets colors in range"""
|
||||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
grid, layer = make_color_layer()
|
||||||
layer = grid.add_layer('color', z_index=0)
|
|
||||||
layer.fill((0, 0, 0, 0)) # Clear all
|
layer.fill((0, 0, 0, 0)) # Clear all
|
||||||
|
|
||||||
# Apply threshold - all cells should get blue
|
# Apply threshold - all cells should get blue
|
||||||
|
|
@ -32,8 +43,7 @@ def test_apply_threshold_with_alpha():
|
||||||
"""apply_threshold handles RGBA colors"""
|
"""apply_threshold handles RGBA colors"""
|
||||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
grid, layer = make_color_layer()
|
||||||
layer = grid.add_layer('color', z_index=0)
|
|
||||||
|
|
||||||
layer.apply_threshold(hmap, (0.0, 1.0), (100, 150, 200, 128))
|
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"""
|
"""apply_threshold doesn't modify cells outside range"""
|
||||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
grid, layer = make_color_layer()
|
||||||
layer = grid.add_layer('color', z_index=0)
|
|
||||||
layer.fill((255, 0, 0)) # Fill with red
|
layer.fill((255, 0, 0)) # Fill with red
|
||||||
|
|
||||||
# Apply threshold for range that doesn't include 0.5
|
# 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"""
|
"""apply_threshold accepts Color objects"""
|
||||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
grid, layer = make_color_layer()
|
||||||
layer = grid.add_layer('color', z_index=0)
|
|
||||||
|
|
||||||
color = mcrfpy.Color(50, 100, 150)
|
color = mcrfpy.Color(50, 100, 150)
|
||||||
layer.apply_threshold(hmap, (0.0, 1.0), color)
|
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"""
|
"""apply_threshold rejects mismatched HeightMap size"""
|
||||||
hmap = mcrfpy.HeightMap((5, 5)) # Different size
|
hmap = mcrfpy.HeightMap((5, 5)) # Different size
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
grid, layer = make_color_layer()
|
||||||
layer = grid.add_layer('color', z_index=0)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
layer.apply_threshold(hmap, (0.0, 1.0), (255, 0, 0))
|
layer.apply_threshold(hmap, (0.0, 1.0), (255, 0, 0))
|
||||||
|
|
@ -97,8 +104,7 @@ def test_apply_gradient_basic():
|
||||||
"""apply_gradient interpolates colors"""
|
"""apply_gradient interpolates colors"""
|
||||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
grid, layer = make_color_layer()
|
||||||
layer = grid.add_layer('color', z_index=0)
|
|
||||||
|
|
||||||
# Apply gradient from black to white
|
# Apply gradient from black to white
|
||||||
result = layer.apply_gradient(hmap, (0.0, 1.0), (0, 0, 0), (255, 255, 255))
|
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
|
# Test at minimum of range
|
||||||
hmap_low = mcrfpy.HeightMap((10, 10), fill=0.0)
|
hmap_low = mcrfpy.HeightMap((10, 10), fill=0.0)
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
grid, layer = make_color_layer()
|
||||||
layer = grid.add_layer('color', z_index=0)
|
|
||||||
|
|
||||||
layer.apply_gradient(hmap_low, (0.0, 1.0), (100, 0, 0), (200, 255, 0))
|
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"""
|
"""apply_gradient doesn't modify cells outside range"""
|
||||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
grid, layer = make_color_layer()
|
||||||
layer = grid.add_layer('color', z_index=0)
|
|
||||||
layer.fill((255, 0, 0)) # Fill with red
|
layer.fill((255, 0, 0)) # Fill with red
|
||||||
|
|
||||||
# Apply gradient for range that doesn't include 0.5
|
# 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"""
|
"""apply_ranges with fixed colors"""
|
||||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
grid, layer = make_color_layer()
|
||||||
layer = grid.add_layer('color', z_index=0)
|
|
||||||
layer.fill((0, 0, 0))
|
layer.fill((0, 0, 0))
|
||||||
|
|
||||||
result = layer.apply_ranges(hmap, [
|
result = layer.apply_ranges(hmap, [
|
||||||
|
|
@ -183,8 +186,7 @@ def test_apply_ranges_gradient():
|
||||||
"""apply_ranges with gradient specification"""
|
"""apply_ranges with gradient specification"""
|
||||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
grid, layer = make_color_layer()
|
||||||
layer = grid.add_layer('color', z_index=0)
|
|
||||||
|
|
||||||
# Gradient from (0,0,0) to (255,255,255) over range [0,1]
|
# Gradient from (0,0,0) to (255,255,255) over range [0,1]
|
||||||
# At value 0.5, should be ~(127,127,127)
|
# 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"""
|
"""apply_ranges with mixed fixed and gradient entries"""
|
||||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
grid, layer = make_color_layer()
|
||||||
layer = grid.add_layer('color', z_index=0)
|
|
||||||
layer.fill((0, 0, 0))
|
layer.fill((0, 0, 0))
|
||||||
|
|
||||||
# Test mixed: gradient that includes 0.5
|
# Test mixed: gradient that includes 0.5
|
||||||
|
|
@ -222,8 +223,7 @@ def test_apply_ranges_later_wins():
|
||||||
"""apply_ranges: later ranges override earlier ones"""
|
"""apply_ranges: later ranges override earlier ones"""
|
||||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
grid, layer = make_color_layer()
|
||||||
layer = grid.add_layer('color', z_index=0)
|
|
||||||
|
|
||||||
layer.apply_ranges(hmap, [
|
layer.apply_ranges(hmap, [
|
||||||
((0.0, 1.0), (255, 0, 0)), # Red, matches everything
|
((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"""
|
"""apply_ranges leaves unmatched cells unchanged"""
|
||||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
grid, layer = make_color_layer()
|
||||||
layer = grid.add_layer('color', z_index=0)
|
|
||||||
layer.fill((128, 128, 128)) # Gray marker
|
layer.fill((128, 128, 128)) # Gray marker
|
||||||
|
|
||||||
layer.apply_ranges(hmap, [
|
layer.apply_ranges(hmap, [
|
||||||
|
|
@ -259,8 +258,7 @@ def test_apply_threshold_invalid_range():
|
||||||
"""apply_threshold rejects min > max"""
|
"""apply_threshold rejects min > max"""
|
||||||
hmap = mcrfpy.HeightMap((10, 10))
|
hmap = mcrfpy.HeightMap((10, 10))
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
grid, layer = make_color_layer()
|
||||||
layer = grid.add_layer('color', z_index=0)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
layer.apply_threshold(hmap, (1.0, 0.0), (255, 0, 0))
|
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
|
# Use a value exactly at the range
|
||||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(10, 10))
|
grid, layer = make_color_layer()
|
||||||
layer = grid.add_layer('color', z_index=0)
|
|
||||||
|
|
||||||
# Apply gradient over exact value (min == max)
|
# Apply gradient over exact value (min == max)
|
||||||
layer.apply_gradient(hmap, (0.5, 0.5), (0, 0, 0), (255, 255, 255))
|
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
|
2. Getting distances to any position
|
||||||
3. Finding paths from any position back to the root
|
3. Finding paths from any position back to the root
|
||||||
4. Multi-target pathfinding (flee/approach scenarios)
|
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
|
import mcrfpy
|
||||||
from mcrfpy import libtcod
|
|
||||||
import sys
|
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():
|
def create_test_grid():
|
||||||
"""Create a test grid with obstacles"""
|
"""Create a test grid with obstacles"""
|
||||||
dijkstra_test = mcrfpy.Scene("dijkstra_test")
|
# Create grid (already has a default tile layer)
|
||||||
|
grid = mcrfpy.Grid(grid_size=(20, 20))
|
||||||
# Create grid
|
|
||||||
grid = mcrfpy.Grid(grid_w=20, grid_h=20)
|
|
||||||
|
|
||||||
# Add color layer for cell coloring
|
# Add color layer for cell coloring
|
||||||
color_layer = grid.add_layer("color", z_index=-1)
|
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||||
# Store color_layer on grid for access elsewhere
|
grid.add_layer(color_layer)
|
||||||
grid._color_layer = color_layer
|
|
||||||
|
data = grid.grid_data
|
||||||
|
|
||||||
# Initialize all cells as walkable
|
# Initialize all cells as walkable
|
||||||
for y in range(grid.grid_h):
|
for y in range(data.grid_h):
|
||||||
for x in range(grid.grid_w):
|
for x in range(data.grid_w):
|
||||||
cell = grid.at(x, y)
|
cell = grid.at(x, y)
|
||||||
cell.walkable = True
|
cell.walkable = True
|
||||||
cell.transparent = 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
|
# Create some walls to make pathfinding interesting
|
||||||
# Vertical wall
|
# Vertical wall
|
||||||
|
|
@ -41,92 +58,150 @@ def create_test_grid():
|
||||||
cell = grid.at(10, y)
|
cell = grid.at(10, y)
|
||||||
cell.walkable = False
|
cell.walkable = False
|
||||||
cell.transparent = 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
|
# Horizontal wall
|
||||||
for x in range(5, 15):
|
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 = grid.at(x, 10)
|
||||||
cell.walkable = False
|
cell.walkable = False
|
||||||
cell.transparent = 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():
|
def test_basic_dijkstra():
|
||||||
"""Test basic Dijkstra functionality"""
|
"""Test basic Dijkstra functionality"""
|
||||||
print("\n=== Testing Basic Dijkstra ===")
|
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)
|
# Compute Dijkstra map from position (5, 5)
|
||||||
root_x, root_y = 5, 5
|
root_x, root_y = 5, 5
|
||||||
print(f"Computing Dijkstra map from root ({root_x}, {root_y})")
|
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
|
# Test getting distances to various points
|
||||||
|
# (x, y, expected) -- expected None means "unreachable"
|
||||||
test_points = [
|
test_points = [
|
||||||
(5, 5), # Root position (should be 0)
|
(5, 5, 0.0), # Root position
|
||||||
(6, 5), # Adjacent (should be 1)
|
(6, 5, 1.0), # Adjacent
|
||||||
(7, 5), # Two steps away
|
(7, 5, 2.0), # Two steps away
|
||||||
(15, 15), # Far corner
|
(15, 15, None), # Far corner: reachable, distance checked below
|
||||||
(10, 10), # On a wall (should be unreachable)
|
(10, 10, None), # On a wall: unreachable
|
||||||
]
|
]
|
||||||
|
|
||||||
print("\nDistances from root:")
|
print("\nDistances from root:")
|
||||||
for x, y in test_points:
|
for x, y, expected in test_points:
|
||||||
distance = grid.get_dijkstra_distance(x, y)
|
distance = dmap.distance((x, y))
|
||||||
if distance is None:
|
if distance is None:
|
||||||
print(f" ({x:2}, {y:2}): UNREACHABLE")
|
print(f" ({x:2}, {y:2}): UNREACHABLE")
|
||||||
else:
|
else:
|
||||||
print(f" ({x:2}, {y:2}): {distance:.1f}")
|
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
|
# Test getting paths
|
||||||
print("\nPaths to root:")
|
print("\nPaths to root:")
|
||||||
for x, y in [(15, 5), (15, 15), (5, 15)]:
|
for x, y in [(15, 5), (15, 15), (5, 15)]:
|
||||||
path = grid.get_dijkstra_path(x, y)
|
path = dmap.path_from((x, y))
|
||||||
if path:
|
steps = list(path) if path else []
|
||||||
print(f" From ({x}, {y}): {len(path)} steps")
|
if steps:
|
||||||
# Show first few steps
|
print(f" From ({x}, {y}): {len(steps)} steps")
|
||||||
for i, (px, py) in enumerate(path[:3]):
|
for i, step in enumerate(steps[:3]):
|
||||||
print(f" Step {i+1}: ({px}, {py})")
|
print(f" Step {i+1}: ({step.x}, {step.y})")
|
||||||
if len(path) > 3:
|
if len(steps) > 3:
|
||||||
print(f" ... {len(path)-3} more steps")
|
print(f" ... {len(steps)-3} more steps")
|
||||||
else:
|
else:
|
||||||
print(f" From ({x}, {y}): No path found")
|
print(f" From ({x}, {y}): No path found")
|
||||||
|
|
||||||
def test_libtcod_interface():
|
check(len(steps) > 0, f"no path from ({x}, {y}) back to root")
|
||||||
"""Test the libtcod module interface"""
|
if steps:
|
||||||
print("\n=== Testing libtcod Interface ===")
|
# 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}")
|
||||||
|
|
||||||
grid = create_test_grid()
|
def test_dijkstra_map_interface():
|
||||||
|
"""Test the DijkstraMap object interface (successor to the old libtcod module)"""
|
||||||
|
print("\n=== Testing DijkstraMap Interface ===")
|
||||||
|
|
||||||
# Use libtcod functions
|
grid, _color_layer = create_test_grid()
|
||||||
print("Using libtcod.dijkstra_* functions:")
|
data = grid.grid_data
|
||||||
|
|
||||||
# Create dijkstra context (returns grid)
|
# Create dijkstra map (successor of libtcod.dijkstra_new + dijkstra_compute)
|
||||||
dijkstra = libtcod.dijkstra_new(grid)
|
dijkstra = data.get_dijkstra_map(root=(10, 2))
|
||||||
print(f"Created Dijkstra context: {type(dijkstra)}")
|
print(f"Created Dijkstra map: {type(dijkstra).__name__}, root={dijkstra.root}")
|
||||||
|
check(isinstance(dijkstra, mcrfpy.DijkstraMap),
|
||||||
|
"get_dijkstra_map did not return a DijkstraMap")
|
||||||
|
|
||||||
# Compute from a position
|
# Get distance (successor of libtcod.dijkstra_get_distance)
|
||||||
libtcod.dijkstra_compute(grid, 10, 2)
|
distance = dijkstra.distance((10, 17))
|
||||||
print("Computed Dijkstra map from (10, 2)")
|
|
||||||
|
|
||||||
# Get distance using libtcod
|
|
||||||
distance = libtcod.dijkstra_get_distance(grid, 10, 17)
|
|
||||||
print(f"Distance to (10, 17): {distance}")
|
print(f"Distance to (10, 17): {distance}")
|
||||||
|
check(distance is not None, "(10, 17) should be reachable from (10, 2)")
|
||||||
|
|
||||||
# Get path using libtcod
|
# Get path (successor of libtcod.dijkstra_path_to)
|
||||||
path = libtcod.dijkstra_path_to(grid, 10, 17)
|
path = dijkstra.path_from((10, 17))
|
||||||
print(f"Path from (10, 17) to root: {len(path) if path else 0} steps")
|
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():
|
def test_multi_target_scenario():
|
||||||
"""Test fleeing/approaching multiple targets"""
|
"""Test fleeing/approaching multiple targets"""
|
||||||
print("\n=== Testing Multi-Target Scenario ===")
|
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
|
# Place three "threats" and compute their Dijkstra maps
|
||||||
threats = [(3, 3), (17, 3), (10, 17)]
|
threats = [(3, 3), (17, 3), (10, 17)]
|
||||||
|
|
@ -136,31 +211,36 @@ def test_multi_target_scenario():
|
||||||
|
|
||||||
for i, (tx, ty) in enumerate(threats):
|
for i, (tx, ty) in enumerate(threats):
|
||||||
# Mark threat position
|
# Mark threat position
|
||||||
cell = grid.at(tx, ty)
|
color_layer.set((tx, ty), mcrfpy.Color(255, 0, 0))
|
||||||
cell.tilesprite = 84 # T for threat
|
|
||||||
grid._color_layer.set(tx, ty, mcrfpy.Color(255, 0, 0))
|
|
||||||
|
|
||||||
# Compute Dijkstra from this threat
|
# Compute Dijkstra from this threat
|
||||||
grid.compute_dijkstra(tx, ty)
|
dmap = data.get_dijkstra_map(root=(tx, ty))
|
||||||
|
|
||||||
# Store distances for all cells
|
# Store distances for all cells
|
||||||
distances = {}
|
distances = {}
|
||||||
for y in range(grid.grid_h):
|
for y in range(data.grid_h):
|
||||||
for x in range(grid.grid_w):
|
for x in range(data.grid_w):
|
||||||
d = grid.get_dijkstra_distance(x, y)
|
d = dmap.distance((x, y))
|
||||||
if d is not None:
|
if d is not None:
|
||||||
distances[(x, y)] = d
|
distances[(x, y)] = d
|
||||||
|
|
||||||
threat_distances.append(distances)
|
threat_distances.append(distances)
|
||||||
print(f" Threat {i+1} at ({tx}, {ty}): {len(distances)} reachable cells")
|
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)
|
# Find safest position (farthest from all threats)
|
||||||
print("\nFinding safest position...")
|
print("\nFinding safest position...")
|
||||||
best_pos = None
|
best_pos = None
|
||||||
best_min_dist = 0
|
best_min_dist = 0
|
||||||
|
|
||||||
for y in range(grid.grid_h):
|
for y in range(data.grid_h):
|
||||||
for x in range(grid.grid_w):
|
for x in range(data.grid_w):
|
||||||
# Skip if not walkable
|
# Skip if not walkable
|
||||||
if not grid.at(x, y).walkable:
|
if not grid.at(x, y).walkable:
|
||||||
continue
|
continue
|
||||||
|
|
@ -176,52 +256,63 @@ def test_multi_target_scenario():
|
||||||
best_min_dist = min_dist
|
best_min_dist = min_dist
|
||||||
best_pos = (x, y)
|
best_pos = (x, y)
|
||||||
|
|
||||||
|
check(best_pos is not None, "no safest position found (all cells unreachable?)")
|
||||||
if best_pos:
|
if best_pos:
|
||||||
print(f"Safest position: {best_pos} (min distance to threats: {best_min_dist:.1f})")
|
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
|
# Mark safe position
|
||||||
cell = grid.at(best_pos[0], best_pos[1])
|
color_layer.set((best_pos[0], best_pos[1]), mcrfpy.Color(0, 255, 0))
|
||||||
cell.tilesprite = 83 # S for safe
|
|
||||||
grid._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_basic_dijkstra()
|
||||||
test_libtcod_interface()
|
test_dijkstra_map_interface()
|
||||||
test_multi_target_scenario()
|
test_multi_target_scenario()
|
||||||
|
|
||||||
print("\n=== Dijkstra Implementation Test Complete ===")
|
print("\n=== Dijkstra Implementation Test Complete ===")
|
||||||
print("✓ Basic Dijkstra computation works")
|
if failures:
|
||||||
print("✓ Distance queries work")
|
print(f"\n{len(failures)} check(s) FAILED:")
|
||||||
print("✓ Path finding works")
|
for f in failures:
|
||||||
print("✓ libtcod interface works")
|
print(f" - {f}")
|
||||||
print("✓ Multi-target scenarios work")
|
print("FAIL")
|
||||||
|
sys.exit(1)
|
||||||
# Take screenshot
|
|
||||||
try:
|
|
||||||
from mcrfpy import automation
|
|
||||||
automation.screenshot("dijkstra_test.png")
|
|
||||||
print("\nScreenshot saved: dijkstra_test.png")
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
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)
|
sys.exit(0)
|
||||||
|
|
||||||
# Main execution
|
if __name__ == "__main__":
|
||||||
print("McRogueFace Dijkstra Pathfinding Test")
|
main()
|
||||||
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()
|
|
||||||
|
|
|
||||||
|
|
@ -1,117 +1,141 @@
|
||||||
#!/usr/bin/env python3
|
#!/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 mcrfpy
|
||||||
import sys
|
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():
|
def test_module_doc():
|
||||||
"""Test module-level documentation."""
|
"""Test module-level documentation."""
|
||||||
print("=== Module Documentation ===")
|
print("=== Module Documentation ===")
|
||||||
print(f"Module: {mcrfpy.__name__}")
|
doc = mcrfpy.__doc__
|
||||||
print(f"Doc: {mcrfpy.__doc__[:100]}..." if mcrfpy.__doc__ else "No module doc")
|
check(bool(doc and doc.strip()), "mcrfpy has a module docstring")
|
||||||
|
check(bool(doc and 'McRogueFace' in doc), "module docstring names McRogueFace")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
def test_method_docs():
|
def test_method_docs():
|
||||||
"""Test method documentation."""
|
"""Every public module-level function must be documented."""
|
||||||
print("=== Method Documentation ===")
|
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 = [
|
methods = [
|
||||||
'createSoundBuffer', 'loadMusic', 'setMusicVolume', 'setSoundVolume',
|
'bresenham', 'end_benchmark', 'exit', 'find', 'find_all', 'get_metrics',
|
||||||
'playSound', 'getMusicVolume', 'getSoundVolume', 'sceneUI',
|
'lock', 'log_benchmark', 'set_dev_console', 'set_scale',
|
||||||
'currentScene', 'setScene', 'createScene', 'keypressScene',
|
'start_benchmark', 'step',
|
||||||
'exit', 'setScale', 'find', 'findAll',
|
|
||||||
'getMetrics'
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for method_name in methods:
|
for method_name in methods:
|
||||||
if hasattr(mcrfpy, method_name):
|
check(hasattr(mcrfpy, method_name), f"mcrfpy.{method_name} exists")
|
||||||
method = getattr(mcrfpy, method_name)
|
method = getattr(mcrfpy, method_name, None)
|
||||||
doc = method.__doc__
|
doc = getattr(method, '__doc__', None)
|
||||||
if doc:
|
check(bool(doc and doc.strip()), f"mcrfpy.{method_name} has documentation")
|
||||||
# Extract first line of docstring
|
|
||||||
first_line = doc.strip().split('\n')[0]
|
# Nothing public may be undocumented, even if not listed above.
|
||||||
print(f"{method_name}: {first_line}")
|
for name in dir(mcrfpy):
|
||||||
else:
|
if name.startswith('_'):
|
||||||
print(f"{method_name}: NO DOCUMENTATION")
|
continue
|
||||||
|
obj = getattr(mcrfpy, name)
|
||||||
|
if isinstance(obj, types.BuiltinFunctionType):
|
||||||
|
check(bool(obj.__doc__), f"module function {name} has documentation")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
def test_class_docs():
|
def test_class_docs():
|
||||||
"""Test class documentation."""
|
"""Test class documentation."""
|
||||||
print("=== 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:
|
for class_name in classes:
|
||||||
if hasattr(mcrfpy, class_name):
|
check(hasattr(mcrfpy, class_name), f"mcrfpy.{class_name} exists")
|
||||||
cls = getattr(mcrfpy, class_name)
|
cls = getattr(mcrfpy, class_name, None)
|
||||||
doc = cls.__doc__
|
doc = getattr(cls, '__doc__', None)
|
||||||
if doc:
|
check(bool(doc and doc.strip()), f"class {class_name} has documentation")
|
||||||
# Extract first line
|
if doc:
|
||||||
first_line = doc.strip().split('\n')[0]
|
print(f" {class_name}: {doc.strip().splitlines()[0][:70]}")
|
||||||
print(f"{class_name}: {first_line[:80]}...")
|
|
||||||
else:
|
|
||||||
print(f"{class_name}: NO DOCUMENTATION")
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
def test_property_docs():
|
def test_property_docs():
|
||||||
"""Test property documentation."""
|
"""Every getset property on the core drawables must be documented."""
|
||||||
print("=== Property Documentation ===")
|
print("=== Property Documentation ===")
|
||||||
|
|
||||||
# Test Frame properties
|
frame_props = ['x', 'y', 'w', 'h', 'fill_color', 'outline_color', 'outline',
|
||||||
if hasattr(mcrfpy, 'Frame'):
|
'children', 'visible', 'z_index']
|
||||||
frame_props = ['x', 'y', 'w', 'h', 'fill_color', 'outline_color', 'outline', 'children', 'visible', 'z_index']
|
for prop_name in frame_props:
|
||||||
print("Frame properties:")
|
prop = getattr(mcrfpy.Frame, prop_name, None)
|
||||||
for prop_name in frame_props:
|
check(prop is not None, f"Frame.{prop_name} exists")
|
||||||
prop = getattr(mcrfpy.Frame, prop_name, None)
|
check(bool(prop is not None and prop.__doc__),
|
||||||
if prop and hasattr(prop, '__doc__'):
|
f"Frame.{prop_name} has documentation")
|
||||||
print(f" {prop_name}: {prop.__doc__}")
|
|
||||||
|
# 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()
|
print()
|
||||||
|
|
||||||
def test_method_signatures():
|
def test_method_signatures():
|
||||||
"""Test that methods have correct signatures in docs."""
|
"""Test that docstrings carry a usable signature line."""
|
||||||
print("=== Method Signatures ===")
|
print("=== Method Signatures ===")
|
||||||
|
|
||||||
# Check a few key methods
|
doc = mcrfpy.find.__doc__
|
||||||
if hasattr(mcrfpy, 'setScene'):
|
check(bool(doc and 'find(name: str, scene: str = None)' in doc),
|
||||||
doc = mcrfpy.setScene.__doc__
|
"find() signature present in docstring")
|
||||||
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.step.__doc__
|
||||||
doc = mcrfpy.Timer.__doc__
|
check(bool(doc and 'step(' in doc), "step() signature present in docstring")
|
||||||
if doc and 'Timer' in doc:
|
|
||||||
print("+ Timer class documentation present")
|
|
||||||
else:
|
|
||||||
print("x Timer class documentation missing")
|
|
||||||
|
|
||||||
if hasattr(mcrfpy, 'find'):
|
doc = mcrfpy.Timer.__doc__
|
||||||
doc = mcrfpy.find.__doc__
|
check(bool(doc and 'Timer(' in doc), "Timer signature present in class doc")
|
||||||
if doc and 'find(name: str, scene: str = None)' in doc:
|
|
||||||
print("✓ find signature correct")
|
doc = mcrfpy.Scene.__doc__
|
||||||
else:
|
check(bool(doc and 'Scene(' in doc), "Scene signature present in class doc")
|
||||||
print("✗ find signature incorrect or missing")
|
|
||||||
|
# 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()
|
print()
|
||||||
|
|
||||||
def test_help_output():
|
def test_help_output():
|
||||||
"""Test Python help() function output."""
|
"""Test Python help() function output."""
|
||||||
print("=== Help Function Test ===")
|
print("=== Help Function Test ===")
|
||||||
print("Testing help(mcrfpy.setScene):")
|
|
||||||
import io
|
|
||||||
import contextlib
|
|
||||||
|
|
||||||
# Capture help output
|
|
||||||
buffer = io.StringIO()
|
buffer = io.StringIO()
|
||||||
with contextlib.redirect_stdout(buffer):
|
with contextlib.redirect_stdout(buffer):
|
||||||
help(mcrfpy.setScene)
|
help(mcrfpy.find)
|
||||||
|
|
||||||
help_text = buffer.getvalue()
|
help_text = buffer.getvalue()
|
||||||
if 'transition to a different scene' in help_text:
|
check('Find the first UI element' in help_text,
|
||||||
print("✓ Help text contains expected documentation")
|
"help(mcrfpy.find) contains its documentation")
|
||||||
else:
|
|
||||||
print("✗ Help text missing expected 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()
|
print()
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
@ -126,7 +150,14 @@ def main():
|
||||||
test_method_signatures()
|
test_method_signatures()
|
||||||
test_help_output()
|
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("\nDocumentation tests complete!")
|
||||||
|
print("PASS")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,32 @@ Test Entity Animation
|
||||||
|
|
||||||
Isolated test for entity position animation.
|
Isolated test for entity position animation.
|
||||||
No perspective, just basic movement in a square pattern.
|
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 mcrfpy
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
|
||||||
|
def check(condition, message):
|
||||||
|
if not condition:
|
||||||
|
print(f"FAIL: {message}")
|
||||||
|
failures.append(message)
|
||||||
|
return condition
|
||||||
|
|
||||||
# Create scene
|
# Create scene
|
||||||
test_anim = mcrfpy.Scene("test_anim")
|
test_anim = mcrfpy.Scene("test_anim")
|
||||||
|
|
||||||
# Create simple grid
|
# 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)
|
grid.fill_color = mcrfpy.Color(20, 20, 30)
|
||||||
|
|
||||||
# Add a color layer for cell coloring
|
# Add a color layer for cell coloring (GridPoint.color is gone; use a ColorLayer)
|
||||||
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 all cells as walkable floors
|
# Initialize all cells as walkable floors
|
||||||
for y in range(15):
|
for y in range(15):
|
||||||
|
|
@ -26,7 +38,7 @@ for y in range(15):
|
||||||
cell = grid.at(x, y)
|
cell = grid.at(x, y)
|
||||||
cell.walkable = True
|
cell.walkable = True
|
||||||
cell.transparent = 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
|
# Mark the path we'll follow with different color
|
||||||
path_cells = [(5,5), (6,5), (7,5), (8,5), (9,5), (10,5),
|
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)]
|
(5,9), (5,8), (5,7), (5,6)]
|
||||||
|
|
||||||
for x, y in path_cells:
|
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
|
# Create entity at start position
|
||||||
entity = mcrfpy.Entity((5, 5), grid=grid)
|
entity = mcrfpy.Entity(grid_pos=(5, 5))
|
||||||
entity.sprite_index = 64 # @
|
entity.sprite_index = 64 # @
|
||||||
|
grid.entities.append(entity)
|
||||||
|
|
||||||
# UI setup
|
# UI setup
|
||||||
ui = test_anim.children
|
ui = test_anim.children
|
||||||
ui.append(grid)
|
ui.append(grid)
|
||||||
grid.pos = (100, 100)
|
|
||||||
grid.size = (450, 450) # 15 * 30 pixels per cell
|
|
||||||
|
|
||||||
# Title
|
# Title
|
||||||
title = mcrfpy.Caption(pos=(200, 20), text="Entity Animation Test - Square Path")
|
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)
|
ui.append(title)
|
||||||
|
|
||||||
# Status display
|
# 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)
|
status.fill_color = mcrfpy.Color(200, 200, 200)
|
||||||
ui.append(status)
|
ui.append(status)
|
||||||
|
|
||||||
# Position display
|
# 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)
|
pos_display.fill_color = mcrfpy.Color(255, 255, 100)
|
||||||
ui.append(pos_display)
|
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)
|
anim_info.fill_color = mcrfpy.Color(100, 255, 255)
|
||||||
ui.append(anim_info)
|
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
|
# Animation state
|
||||||
current_waypoint = 0
|
|
||||||
animating = False
|
|
||||||
waypoints = [(5,5), (10,5), (10,10), (5,10), (5,5)]
|
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):
|
def on_anim_done(target, prop, value):
|
||||||
"""Update position display every 200ms"""
|
"""Animation completion callback: (target, property, final_value)"""
|
||||||
pos_display.text = f"Entity Position: ({entity.x:.2f}, {entity.y:.2f})"
|
completed.append((prop, value))
|
||||||
|
|
||||||
# Check if entity is at expected position
|
def animate_to_waypoint(index):
|
||||||
if animating and current_waypoint > 0:
|
"""Animate the entity's draw position to waypoints[index]."""
|
||||||
target = waypoints[current_waypoint - 1]
|
target_x, target_y = waypoints[index]
|
||||||
distance = ((entity.x - target[0])**2 + (entity.y - target[1])**2)**0.5
|
duration = 2.0
|
||||||
debug_info.text = f"Debug: Distance to target {target}: {distance:.3f}"
|
|
||||||
|
|
||||||
def animate_to_next_waypoint():
|
print(f"Animating from ({entity.draw_pos.x}, {entity.draw_pos.y}) to ({target_x}, {target_y})")
|
||||||
"""Animate to the next waypoint"""
|
status.text = f"Moving to waypoint {index + 1}/{len(waypoints)}: ({target_x}, {target_y})"
|
||||||
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})"
|
|
||||||
anim_info.text = f"Animation: Active (target: {target_x}, {target_y})"
|
anim_info.text = f"Animation: Active (target: {target_x}, {target_y})"
|
||||||
|
|
||||||
# Create animations - ensure we're using floats
|
# 'draw_x'/'draw_y' are the tile-space draw coordinates ('x'/'y' are aliases).
|
||||||
duration = 2.0 # 2 seconds per segment
|
# 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
|
||||||
|
|
||||||
# Try different approaches to see what works
|
def step_seconds(seconds, dt=0.1):
|
||||||
|
"""Advance the headless clock; the engine never advances time on its own."""
|
||||||
# Approach 1: Direct property animation
|
for _ in range(int(round(seconds / dt))):
|
||||||
anim_x = mcrfpy.Animation("x", float(target_x), duration, "linear")
|
mcrfpy.step(dt)
|
||||||
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)
|
|
||||||
|
|
||||||
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 test_immediate_position():
|
def test_immediate_position():
|
||||||
"""Test setting position directly"""
|
"""Test setting position directly (no animation)."""
|
||||||
print(f"Before: entity at ({entity.x}, {entity.y})")
|
# #295: grid_pos (logical cell) is DECOUPLED from draw_pos (visual position).
|
||||||
entity.x = 7
|
# Setting one does not move the other; they are assigned independently.
|
||||||
entity.y = 7
|
entity.grid_pos = (7, 7)
|
||||||
print(f"After direct set: entity at ({entity.x}, {entity.y})")
|
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}")
|
||||||
|
|
||||||
# Try with animation to same position
|
entity.draw_pos = (7.0, 7.0)
|
||||||
anim_x = mcrfpy.Animation("x", 9.0, 1.0, "linear")
|
check((entity.draw_pos.x, entity.draw_pos.y) == (7.0, 7.0),
|
||||||
anim_x.start(entity)
|
f"direct draw_pos set: expected (7.0, 7.0), got {entity.draw_pos}")
|
||||||
print("Started animation to x=9.0")
|
|
||||||
|
|
||||||
# Input handler
|
# Animation to a new x, driven by explicit steps
|
||||||
def handle_input(key, state):
|
entity.animate("draw_x", 9.0, 1.0, mcrfpy.Easing.LINEAR)
|
||||||
if state != mcrfpy.InputState.PRESSED:
|
step_seconds(0.5)
|
||||||
return
|
mid_x = entity.draw_pos.x
|
||||||
|
check(7.0 < mid_x < 9.0,
|
||||||
if key == mcrfpy.Key.Q:
|
f"linear animation should be mid-flight at t=0.5s, draw_x={mid_x}")
|
||||||
print("Exiting test...")
|
step_seconds(0.6)
|
||||||
sys.exit(0)
|
check(abs(entity.draw_pos.x - 9.0) < 0.01,
|
||||||
elif key == mcrfpy.Key.SPACE:
|
f"animation should land on draw_x=9.0, got {entity.draw_pos.x}")
|
||||||
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
|
|
||||||
|
|
||||||
print("Entity Animation Test")
|
print("Entity Animation Test")
|
||||||
print("====================")
|
print("====================")
|
||||||
print("This test animates an entity in a square pattern:")
|
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()
|
||||||
print("Controls:")
|
|
||||||
print(" SPACE - Start animation")
|
test_anim.activate()
|
||||||
print(" T - Test immediate position change")
|
grid.perspective = None # omniscient view (perspective takes an Entity or None now)
|
||||||
print(" R - Reset position to (5,5)")
|
|
||||||
print(" Q - Quit")
|
# --- Direct position assignment ---------------------------------------------
|
||||||
print()
|
test_immediate_position()
|
||||||
print("The position display updates every 200ms")
|
|
||||||
print("Watch the console for animation logs")
|
# --- 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
|
#!/usr/bin/env python3
|
||||||
"""Test the new Entity.path_to() method"""
|
"""Test the Entity.path_to() method"""
|
||||||
|
|
||||||
import mcrfpy
|
import mcrfpy
|
||||||
|
import sys
|
||||||
|
|
||||||
print("Testing Entity.path_to() method...")
|
print("Testing Entity.path_to() method...")
|
||||||
print("=" * 50)
|
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
|
# Create scene and grid
|
||||||
path_test = mcrfpy.Scene("path_test")
|
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
|
# Set up a simple map with some walls
|
||||||
for y in range(10):
|
for y in range(10):
|
||||||
|
|
@ -24,48 +34,76 @@ for x, y in walls:
|
||||||
# Create entity
|
# Create entity
|
||||||
entity = mcrfpy.Entity((2, 2), grid=grid)
|
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)")
|
print("\nTest 1: Path to (6, 6)")
|
||||||
try:
|
path = entity.path_to(6, 6)
|
||||||
path = entity.path_to(6, 6)
|
print(f" Path: {path}")
|
||||||
print(f" Path: {path}")
|
err = validate_path(path, (6, 6))
|
||||||
print(f" Length: {len(path)} steps")
|
check("path_to(6, 6) returns a valid walk to (6, 6)", err is None, err or "")
|
||||||
print(" ✓ SUCCESS")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ✗ FAILED: {e}")
|
|
||||||
|
|
||||||
# Test 2: Path with target_x/target_y keywords
|
# Test 2: Path using keyword arguments.
|
||||||
print("\nTest 2: Path using keyword arguments")
|
# API CHANGE: the old target_x=/target_y= keywords are gone; the position-spec
|
||||||
try:
|
# parser now accepts pos=(x, y) (or a bare tuple/Vector).
|
||||||
path = entity.path_to(target_x=7, target_y=7)
|
print("\nTest 2: Path using keyword argument pos=(7, 7)")
|
||||||
print(f" Path: {path}")
|
path = entity.path_to(pos=(7, 7))
|
||||||
print(f" Length: {len(path)} steps")
|
print(f" Path: {path}")
|
||||||
print(" ✓ SUCCESS")
|
err = validate_path(path, (7, 7))
|
||||||
except Exception as e:
|
check("path_to(pos=(7, 7)) returns a valid walk to (7, 7)", err is None, err or "")
|
||||||
print(f" ✗ FAILED: {e}")
|
|
||||||
|
|
||||||
# Test 3: Path to unreachable location
|
# Test 3: Path to current position is empty (already there)
|
||||||
print("\nTest 3: Path to current position")
|
print("\nTest 3: Path to current position")
|
||||||
try:
|
path = entity.path_to(2, 2)
|
||||||
path = entity.path_to(2, 2)
|
print(f" Path: {path}")
|
||||||
print(f" Path: {path}")
|
check("path_to(own position) is empty", path == [], f"got {path}")
|
||||||
print(f" Length: {len(path)} steps")
|
|
||||||
print(" ✓ SUCCESS")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ✗ FAILED: {e}")
|
|
||||||
|
|
||||||
# Test 4: Error cases
|
# Test 4: Error cases
|
||||||
print("\nTest 4: Error handling")
|
print("\nTest 4: Error handling")
|
||||||
try:
|
try:
|
||||||
# Out of bounds
|
entity.path_to(15, 15)
|
||||||
path = entity.path_to(15, 15)
|
check("out-of-bounds target raises ValueError", False, "no exception raised")
|
||||||
print(" ✗ Should have failed for out of bounds")
|
|
||||||
except ValueError as e:
|
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:
|
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("\n" + "=" * 50)
|
||||||
|
if failures:
|
||||||
|
print(f"FAILED: {len(failures)} check(s): {failures}")
|
||||||
|
sys.exit(1)
|
||||||
print("Entity.path_to() testing complete!")
|
print("Entity.path_to() testing complete!")
|
||||||
|
print("PASS")
|
||||||
|
sys.exit(0)
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,36 @@
|
||||||
"""Test edge cases for Entity.path_to() method"""
|
"""Test edge cases for Entity.path_to() method"""
|
||||||
|
|
||||||
import mcrfpy
|
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("Testing Entity.path_to() edge cases...")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
||||||
# Test 1: Entity without grid
|
# Test 1: Entity without grid
|
||||||
print("Test 1: Entity not in grid")
|
print("Test 1: Entity not in grid")
|
||||||
|
entity = mcrfpy.Entity(grid_pos=(5, 5))
|
||||||
try:
|
try:
|
||||||
entity = mcrfpy.Entity((5, 5))
|
|
||||||
path = entity.path_to(8, 8)
|
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:
|
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:
|
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
|
# Test 2: Entity in grid with walls blocking path
|
||||||
print("\nTest 2: Completely blocked path")
|
print("\nTest 2: Completely blocked path")
|
||||||
blocked_test = mcrfpy.Scene("blocked_test")
|
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
|
# Make all tiles walkable first
|
||||||
for y in range(5):
|
for y in range(5):
|
||||||
|
|
@ -31,25 +42,28 @@ for y in range(5):
|
||||||
for x in range(5):
|
for x in range(5):
|
||||||
grid.at(x, 2).walkable = False
|
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)
|
||||||
path = entity.path_to(1, 4)
|
check(path == [], "no path across the full-width wall, got %r" % (path,))
|
||||||
if path:
|
|
||||||
print(f" Path found: {path}")
|
|
||||||
else:
|
|
||||||
print(" ✓ No path found (empty list returned)")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ✗ Unexpected error: {e}")
|
|
||||||
|
|
||||||
# Test 3: Alternative parameter parsing
|
# Test 3: Alternative parameter parsing (keyword x/y)
|
||||||
print("\nTest 3: Alternative parameter names")
|
print("\nTest 3: Alternative parameter names")
|
||||||
try:
|
path = entity.path_to(x=3, y=1)
|
||||||
path = entity.path_to(x=3, y=1)
|
check(path == [(2, 1), (3, 1)],
|
||||||
print(f" Path with x/y params: {path}")
|
"path_to(x=, y=) walked the open row: %r" % (path,))
|
||||||
print(" ✓ SUCCESS")
|
|
||||||
except Exception as e:
|
# The same target via positional args must agree with the keyword form.
|
||||||
print(f" ✗ FAILED: {e}")
|
check(entity.path_to(3, 1) == path,
|
||||||
|
"positional path_to(3, 1) matches the keyword form")
|
||||||
|
|
||||||
print("\n" + "=" * 50)
|
print("\n" + "=" * 50)
|
||||||
|
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("Edge case testing complete!")
|
||||||
|
print("PASS")
|
||||||
|
sys.exit(0)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,19 @@
|
||||||
#!/usr/bin/env python3
|
#!/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 mcrfpy
|
||||||
|
import sys
|
||||||
|
|
||||||
print("Reproducing exact failure pattern...")
|
print("Reproducing exact failure pattern...")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
@ -10,20 +22,28 @@ print("=" * 50)
|
||||||
WALL_COLOR = mcrfpy.Color(60, 30, 30)
|
WALL_COLOR = mcrfpy.Color(60, 30, 30)
|
||||||
FLOOR_COLOR = mcrfpy.Color(200, 200, 220)
|
FLOOR_COLOR = mcrfpy.Color(200, 200, 220)
|
||||||
|
|
||||||
|
GRID_W, GRID_H = 25, 15
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
|
||||||
def test_exact_pattern():
|
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")
|
dijkstra_demo = mcrfpy.Scene("dijkstra_demo")
|
||||||
|
|
||||||
# Create grid
|
# 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)
|
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
|
# Initialize all as floor
|
||||||
for y in range(15):
|
for y in range(GRID_H):
|
||||||
for x in range(25):
|
for x in range(GRID_W):
|
||||||
grid.at(x, y).walkable = True
|
grid.at(x, y).walkable = True
|
||||||
grid.at(x, y).transparent = 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
|
# Create an interesting dungeon layout
|
||||||
walls = []
|
walls = []
|
||||||
|
|
@ -32,65 +52,93 @@ def test_exact_pattern():
|
||||||
# Top-left room
|
# Top-left room
|
||||||
for x in range(1, 8): walls.append((x, 1))
|
for x in range(1, 8): walls.append((x, 1))
|
||||||
|
|
||||||
return grid, walls
|
return grid, color_layer, walls
|
||||||
|
|
||||||
print("Test 1: Running exact pattern...")
|
print("Test 1: Running exact pattern...")
|
||||||
try:
|
try:
|
||||||
grid, walls = test_exact_pattern()
|
grid, color_layer, walls = test_exact_pattern()
|
||||||
print(f" ✓ Success! Created {len(walls)} walls")
|
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:
|
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
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("Test 2: Breaking it down step by step...")
|
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
|
# Step 1: Scene and grid
|
||||||
try:
|
def _step1():
|
||||||
test2 = mcrfpy.Scene("test2")
|
mcrfpy.Scene("test2")
|
||||||
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
|
return mcrfpy.Grid(grid_size=(GRID_W, GRID_H))
|
||||||
print(" ✓ Step 1: Scene and grid created")
|
grid = step("Step 1: Scene and grid created", _step1)
|
||||||
except Exception as e:
|
|
||||||
print(f" ✗ Step 1 failed: {e}")
|
|
||||||
|
|
||||||
# Step 2: Set fill_color
|
# Step 2: Set fill_color
|
||||||
try:
|
def _step2():
|
||||||
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
||||||
print(" ✓ Step 2: fill_color set")
|
assert grid.fill_color == mcrfpy.Color(0, 0, 0)
|
||||||
except Exception as e:
|
step("Step 2: fill_color set", _step2)
|
||||||
print(f" ✗ Step 2 failed: {e}")
|
|
||||||
|
|
||||||
# Step 3: Nested loops with grid.at
|
# Step 3: Nested loops with grid.at + ColorLayer.set (the suspect loop)
|
||||||
try:
|
layer = mcrfpy.ColorLayer(name="floor2")
|
||||||
for y in range(15):
|
grid.add_layer(layer)
|
||||||
for x in range(25):
|
def _step3():
|
||||||
|
for y in range(GRID_H):
|
||||||
|
for x in range(GRID_W):
|
||||||
grid.at(x, y).walkable = True
|
grid.at(x, y).walkable = True
|
||||||
grid.at(x, y).transparent = True
|
grid.at(x, y).transparent = True
|
||||||
grid.at(x, y).color = FLOOR_COLOR
|
layer.set((x, y), FLOOR_COLOR)
|
||||||
print(" ✓ Step 3: Nested loops completed")
|
step("Step 3: Nested loops completed", _step3)
|
||||||
except Exception as e:
|
|
||||||
print(f" ✗ Step 3 failed: {e}")
|
# 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
|
# Step 4: Create walls list
|
||||||
try:
|
def _step4():
|
||||||
walls = []
|
return []
|
||||||
print(" ✓ Step 4: walls list created")
|
walls = step("Step 4: walls list created", _step4)
|
||||||
except Exception as e:
|
|
||||||
print(f" ✗ Step 4 failed: {e}")
|
|
||||||
|
|
||||||
# Step 5: The failing line
|
# Step 5: The line that used to blow up with an exception raised by *step 3*.
|
||||||
try:
|
# 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))
|
for x in range(1, 8): walls.append((x, 1))
|
||||||
print(f" ✓ Step 5: For loop worked, walls = {walls}")
|
return walls
|
||||||
except Exception as e:
|
step("Step 5: For loop worked", _step5)
|
||||||
print(f" ✗ Step 5 failed: {type(e).__name__}: {e}")
|
|
||||||
|
|
||||||
# Check if exception was already pending
|
if walls != [(x, 1) for x in range(1, 8)]:
|
||||||
import sys
|
failures.append(f"Step 5: walls list wrong: {walls}")
|
||||||
exc_info = sys.exc_info()
|
|
||||||
print(f" Exception info: {exc_info}")
|
# 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()
|
||||||
print("The error occurs at step 5, suggesting an exception was")
|
if failures:
|
||||||
print("set during the nested loops but not immediately raised.")
|
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 sys
|
||||||
|
import os
|
||||||
import math
|
import math
|
||||||
|
import traceback
|
||||||
|
|
||||||
# Import the geometry module
|
# Import the geometry module (src/scripts, relative to this test file)
|
||||||
sys.path.insert(0, '/home/john/Development/McRogueFace/src/scripts')
|
sys.path.insert(0, os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)), '..', '..', 'src', 'scripts'))
|
||||||
from geometry import (
|
from geometry import (
|
||||||
# Utilities
|
# Utilities
|
||||||
distance, distance_squared, angle_between, normalize_angle,
|
distance, distance_squared, angle_between, normalize_angle,
|
||||||
|
|
@ -601,4 +604,13 @@ def run_all_tests():
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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():
|
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))
|
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||||
|
|
||||||
result = grid.apply_threshold(hmap, range=(0.0, 1.0), walkable=True)
|
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")
|
print("PASS: test_apply_threshold_returns_self")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -159,7 +163,7 @@ def test_apply_ranges_returns_self():
|
||||||
result = grid.apply_ranges(hmap, [
|
result = grid.apply_ranges(hmap, [
|
||||||
((0.0, 1.0), {"walkable": True}),
|
((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")
|
print("PASS: test_apply_ranges_returns_self")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -275,7 +279,7 @@ def test_chaining():
|
||||||
((0.4, 0.6), {"walkable": True, "transparent": True}),
|
((0.4, 0.6), {"walkable": True, "transparent": True}),
|
||||||
]))
|
]))
|
||||||
|
|
||||||
assert result is grid
|
assert result is grid.grid_data
|
||||||
print("PASS: test_chaining")
|
print("PASS: test_chaining")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,29 @@
|
||||||
#!/usr/bin/env python3
|
#!/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 mcrfpy
|
||||||
import sys
|
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():
|
def test_grid_background():
|
||||||
"""Test Grid background color property"""
|
"""Test Grid background color property"""
|
||||||
print("Testing Grid Background Color...")
|
print("Testing Grid Background Color...")
|
||||||
|
|
@ -17,10 +37,11 @@ def test_grid_background():
|
||||||
ui.append(grid)
|
ui.append(grid)
|
||||||
|
|
||||||
# Add color layer for some tiles to see the background better
|
# 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 x in range(5, 15):
|
||||||
for y in range(5, 10):
|
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
|
# Add UI to show current background color
|
||||||
info_frame = mcrfpy.Frame(pos=(500, 50), size=(200, 150),
|
info_frame = mcrfpy.Frame(pos=(500, 50), size=(200, 150),
|
||||||
|
|
@ -42,81 +63,104 @@ def test_grid_background():
|
||||||
# Activate the scene
|
# Activate the scene
|
||||||
test.activate()
|
test.activate()
|
||||||
|
|
||||||
|
# Track which timer stages actually ran
|
||||||
|
stages = []
|
||||||
|
|
||||||
def run_tests(timer, runtime):
|
def run_tests(timer, runtime):
|
||||||
"""Run background color tests"""
|
"""Run background color tests"""
|
||||||
timer.stop()
|
timer.stop()
|
||||||
|
stages.append("default")
|
||||||
|
|
||||||
print("\nTest 1: Default background color")
|
print("\nTest 1: Default background color")
|
||||||
default_color = grid.background_color
|
default_color = grid.fill_color
|
||||||
print(f"Default: R={default_color.r}, G={default_color.g}, B={default_color.b}, A={default_color.a}")
|
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}"
|
color_display.text = f"R:{default_color.r} G:{default_color.g} B:{default_color.b}"
|
||||||
|
|
||||||
def test_set_color(timer, runtime):
|
def test_set_color(timer, runtime):
|
||||||
timer.stop()
|
timer.stop()
|
||||||
print("\nTest 2: Set background to blue")
|
stages.append("set")
|
||||||
grid.background_color = mcrfpy.Color(20, 40, 100)
|
print("\nTest 2: Set background to blue")
|
||||||
new_color = grid.background_color
|
grid.fill_color = mcrfpy.Color(20, 40, 100)
|
||||||
print(f"+ Set to: R={new_color.r}, G={new_color.g}, B={new_color.b}")
|
new_color = grid.fill_color
|
||||||
color_display.text = f"R:{new_color.r} G:{new_color.g} B:{new_color.b}"
|
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}"
|
||||||
|
|
||||||
def test_animation(timer, runtime):
|
colors = [
|
||||||
timer.stop()
|
mcrfpy.Color(200, 20, 20), # Red
|
||||||
print("\nTest 3: Manual color cycling")
|
mcrfpy.Color(20, 200, 20), # Green
|
||||||
# Manually change color to test property is working
|
mcrfpy.Color(20, 20, 200), # Blue
|
||||||
colors = [
|
]
|
||||||
mcrfpy.Color(200, 20, 20), # Red
|
|
||||||
mcrfpy.Color(20, 200, 20), # Green
|
|
||||||
mcrfpy.Color(20, 20, 200), # Blue
|
|
||||||
]
|
|
||||||
|
|
||||||
color_index = [0] # Use list to allow modification in nested function
|
def test_animation(timer, runtime):
|
||||||
|
timer.stop()
|
||||||
|
stages.append("cycle")
|
||||||
|
print("\nTest 3: Manual color cycling")
|
||||||
|
|
||||||
def cycle_red(t, r):
|
def cycle_red(t, r):
|
||||||
t.stop()
|
t.stop()
|
||||||
grid.background_color = colors[0]
|
grid.fill_color = colors[0]
|
||||||
c = grid.background_color
|
c = grid.fill_color
|
||||||
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
|
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}")
|
check("cycle to Red", rgb(c), (200, 20, 20))
|
||||||
|
|
||||||
def cycle_green(t, r):
|
def cycle_green(t, r):
|
||||||
t.stop()
|
t.stop()
|
||||||
grid.background_color = colors[1]
|
grid.fill_color = colors[1]
|
||||||
c = grid.background_color
|
c = grid.fill_color
|
||||||
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
|
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}")
|
check("cycle to Green", rgb(c), (20, 200, 20))
|
||||||
|
|
||||||
def cycle_blue(t, r):
|
def cycle_blue(t, r):
|
||||||
t.stop()
|
t.stop()
|
||||||
grid.background_color = colors[2]
|
grid.fill_color = colors[2]
|
||||||
c = grid.background_color
|
c = grid.fill_color
|
||||||
color_display.text = f"R:{c.r} G:{c.g} B:{c.b}"
|
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}")
|
check("cycle to Blue", rgb(c), (20, 20, 200))
|
||||||
|
|
||||||
# Cycle through colors
|
# Cycle through colors
|
||||||
mcrfpy.Timer("cycle_0", cycle_red, 100, once=True)
|
mcrfpy.Timer("cycle_0", cycle_red, 100, once=True)
|
||||||
mcrfpy.Timer("cycle_1", cycle_green, 400, once=True)
|
mcrfpy.Timer("cycle_1", cycle_green, 400, once=True)
|
||||||
mcrfpy.Timer("cycle_2", cycle_blue, 700, once=True)
|
mcrfpy.Timer("cycle_2", cycle_blue, 700, once=True)
|
||||||
|
|
||||||
def test_complete(timer, runtime):
|
def test_complete(timer, runtime):
|
||||||
timer.stop()
|
timer.stop()
|
||||||
print("\nTest 4: Final color check")
|
stages.append("complete")
|
||||||
final_color = grid.background_color
|
print("\nTest 4: Final color check")
|
||||||
print(f"Final: R={final_color.r}, G={final_color.g}, B={final_color.b}")
|
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))
|
||||||
|
|
||||||
print("\n+ Grid background color tests completed!")
|
# Schedule tests
|
||||||
print("- Default background color works")
|
|
||||||
print("- Setting background color works")
|
|
||||||
print("- Color cycling works")
|
|
||||||
|
|
||||||
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
|
|
||||||
mcrfpy.Timer("run_tests", run_tests, 100, once=True)
|
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__":
|
if __name__ == "__main__":
|
||||||
test_grid_background()
|
test_grid_background()
|
||||||
|
|
@ -1,124 +1,110 @@
|
||||||
#!/usr/bin/env python3
|
#!/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 mcrfpy
|
||||||
import sys
|
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("Testing Grid constructor PyArg bug...")
|
||||||
print("=" * 50)
|
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")
|
print("Test 1: Check exception state after Grid creation")
|
||||||
try:
|
try:
|
||||||
# Clear any existing exception
|
print(" Creating Grid(grid_size=(25, 15))...")
|
||||||
sys.exc_clear() if hasattr(sys, 'exc_clear') else None
|
grid = mcrfpy.Grid(grid_size=(25, 15))
|
||||||
|
check(True, "Grid(grid_size=(25, 15)) constructed")
|
||||||
# Create grid with problematic dimensions
|
check((grid.grid_size.x, grid.grid_size.y) == (25, 15),
|
||||||
print(" Creating Grid(grid_w=25, grid_h=15)...")
|
f"grid_size is (25, 15), got ({grid.grid_size.x}, {grid.grid_size.y})")
|
||||||
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
|
no_stale_exception("Test 1")
|
||||||
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")
|
|
||||||
|
|
||||||
except Exception as e:
|
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()
|
print()
|
||||||
|
|
||||||
# Test 2: Try different Grid constructor patterns
|
# Test 2: legacy grid_w/grid_h spelling still constructs the same grid
|
||||||
print("Test 2: Different Grid constructor calls")
|
print("Test 2: Legacy grid_w=/grid_h= keywords")
|
||||||
|
for w, h in [(24, 15), (25, 15), (26, 15)]:
|
||||||
# Pattern 1: Positional arguments
|
try:
|
||||||
try:
|
g = mcrfpy.Grid(grid_w=w, grid_h=h)
|
||||||
print(" Trying Grid(25, 15)...")
|
ok = (g.grid_size.x, g.grid_size.y) == (w, h)
|
||||||
grid1 = mcrfpy.Grid(25, 15)
|
check(ok, f"Grid(grid_w={w}, grid_h={h}) -> grid_size ({g.grid_size.x}, {g.grid_size.y})")
|
||||||
for i in range(1): pass
|
no_stale_exception(f"Grid({w}, {h})")
|
||||||
print(" ✓ Positional args worked")
|
except Exception as e:
|
||||||
except Exception as e:
|
check(False, f"Grid(grid_w={w}, grid_h={h}) raised {type(e).__name__}: {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}")
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Test 3: Isolate the exact problem
|
# Test 3: Isolate the exact problem -- a sweep of sizes, each followed by a
|
||||||
print("Test 3: Isolating the problem")
|
# 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):
|
def test_grid_creation(x, y):
|
||||||
"""Test creating a grid and immediately using range()"""
|
"""Test creating a grid and immediately using range()"""
|
||||||
try:
|
try:
|
||||||
grid = mcrfpy.Grid(grid_w=x, grid_h=y)
|
g = mcrfpy.Grid(grid_size=(x, y))
|
||||||
# Immediately test if exception is pending
|
# Immediately test if an exception is pending
|
||||||
list(range(1))
|
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"
|
return True, "Success"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"{type(e).__name__}: {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)]
|
test_sizes = [(10, 10), (20, 20), (24, 15), (25, 14), (25, 15), (25, 16), (30, 30)]
|
||||||
for x, y in test_sizes:
|
for x, y in test_sizes:
|
||||||
success, msg = test_grid_creation(x, y)
|
success, msg = test_grid_creation(x, y)
|
||||||
if success:
|
check(success, f"Grid({x}, {y}): {msg}")
|
||||||
print(f" Grid({x}, {y}): ✓")
|
|
||||||
else:
|
|
||||||
print(f" Grid({x}, {y}): ✗ {msg}")
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Test 4: See if we can clear the exception
|
# Test 4: bad arguments must raise properly (and not corrupt interpreter state)
|
||||||
print("Test 4: Exception clearing")
|
print("Test 4: Invalid arguments raise instead of leaving a pending exception")
|
||||||
try:
|
try:
|
||||||
# Create the problematic grid
|
bad = mcrfpy.Grid(grid_size="not a size")
|
||||||
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
|
check(False, "Grid(grid_size='not a size') should have raised")
|
||||||
print(" Created Grid(25, 15)")
|
except (TypeError, ValueError) as e:
|
||||||
|
check(True, f"Grid(grid_size='not a size') raised {type(e).__name__} as expected")
|
||||||
# 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")
|
|
||||||
|
|
||||||
except Exception as e:
|
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()
|
||||||
print("Conclusion: The Grid constructor is setting a Python exception")
|
print("=" * 50)
|
||||||
print("but not properly returning NULL to propagate it. This leaves")
|
if failures:
|
||||||
print("the exception on the stack, causing the next Python operation")
|
print(f"FAIL: {len(failures)} check(s) failed")
|
||||||
print("to fail with the cryptic 'new style getargs format' error.")
|
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
|
# Create a texture first
|
||||||
print("Loading texture...")
|
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}")
|
print(f"Texture loaded: {texture}")
|
||||||
|
|
||||||
# Create grid
|
# Create grid
|
||||||
|
|
@ -54,5 +54,9 @@ if not hasattr(pos, 'x'):
|
||||||
|
|
||||||
print(f"pos.x={pos.x}, pos.y={pos.y}")
|
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!")
|
print("PASS: Grid Vector properties work correctly!")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,17 @@
|
||||||
"""Test grid iteration patterns to find the exact cause"""
|
"""Test grid iteration patterns to find the exact cause"""
|
||||||
|
|
||||||
import mcrfpy
|
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("Testing grid iteration patterns...")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
@ -14,18 +25,20 @@ try:
|
||||||
|
|
||||||
# Single call
|
# Single call
|
||||||
grid.at(0, 0).walkable = True
|
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
|
# Multiple calls
|
||||||
grid.at(1, 1).walkable = True
|
grid.at(1, 1).walkable = True
|
||||||
grid.at(2, 2).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
|
# Now try a print
|
||||||
print(" ✓ Print after grid.at() works")
|
print(" [ok] Print after grid.at() works")
|
||||||
|
|
||||||
except Exception as e:
|
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()
|
print()
|
||||||
|
|
||||||
|
|
@ -37,13 +50,15 @@ try:
|
||||||
|
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
grid.at(i, 0).walkable = True
|
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 after loop
|
||||||
print(" ✓ Print after loop works")
|
print(" [ok] Print after loop works")
|
||||||
|
|
||||||
except Exception as e:
|
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()
|
print()
|
||||||
|
|
||||||
|
|
@ -57,11 +72,13 @@ try:
|
||||||
for x in range(3):
|
for x in range(3):
|
||||||
grid.at(x, y).walkable = True
|
grid.at(x, y).walkable = True
|
||||||
|
|
||||||
print(" ✓ Nested loops with grid.at() work")
|
check(all(grid.at(x, y).walkable for y in range(3) for x in range(3)),
|
||||||
print(" ✓ Print after nested loops works")
|
"Nested loops with grid.at() work")
|
||||||
|
print(" [ok] Print after nested loops works")
|
||||||
|
|
||||||
except Exception as e:
|
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()
|
print()
|
||||||
|
|
||||||
|
|
@ -72,37 +89,45 @@ try:
|
||||||
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
|
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
|
||||||
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
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
|
# This is the exact nested loop from the failing code
|
||||||
for y in range(15):
|
for y in range(15):
|
||||||
for x in range(25):
|
for x in range(25):
|
||||||
grid.at(x, y).walkable = True
|
grid.at(x, y).walkable = True
|
||||||
grid.at(x, y).transparent = 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(" ✓ Full nested loop completed")
|
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 fails
|
# This is where it used to fail
|
||||||
print(" About to test post-loop operations...")
|
print(" About to test post-loop operations...")
|
||||||
|
|
||||||
# Try different operations
|
# Try different operations
|
||||||
x = 5
|
x = 5
|
||||||
print(f" ✓ Variable assignment works: x={x}")
|
check(x == 5, f"Variable assignment works: x={x}")
|
||||||
|
|
||||||
lst = []
|
lst = []
|
||||||
print(f" ✓ List creation works: {lst}")
|
check(lst == [], f"List creation works: {lst}")
|
||||||
|
|
||||||
# The failing line
|
# The failing line
|
||||||
for i in range(3): pass
|
for i in range(3): pass
|
||||||
print(" ✓ Empty for loop works")
|
print(" [ok] Empty for loop works")
|
||||||
|
|
||||||
# With append
|
# With append
|
||||||
for i in range(3): lst.append(i)
|
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:
|
except Exception as e:
|
||||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
print(f" [FAIL] Error: {type(e).__name__}: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
failures.append(f"Test 4: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
@ -122,17 +147,29 @@ try:
|
||||||
if count % 10 == 0:
|
if count % 10 == 0:
|
||||||
print(f" Processed {count} cells...")
|
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
|
# Now test operations
|
||||||
print(" Testing post-processing operations...")
|
print(" Testing post-processing operations...")
|
||||||
for i in range(3): pass
|
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:
|
except Exception as e:
|
||||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
print(f" [FAIL] Error: {type(e).__name__}: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
failures.append(f"Test 5: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
print()
|
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
|
frame.on_click = on_click_handler
|
||||||
|
|
||||||
# Click inside the frame
|
# Click inside the frame
|
||||||
automation.click(150, 150)
|
automation.click((150, 150))
|
||||||
mcrfpy.step(0.05)
|
mcrfpy.step(0.05)
|
||||||
|
|
||||||
if len(start_clicks) >= 1:
|
if len(start_clicks) >= 1:
|
||||||
|
|
@ -50,7 +50,7 @@ def on_miss_handler(pos, button, action):
|
||||||
frame2.on_click = on_miss_handler
|
frame2.on_click = on_miss_handler
|
||||||
|
|
||||||
# Click outside the frame
|
# Click outside the frame
|
||||||
automation.click(50, 50)
|
automation.click((50, 50))
|
||||||
mcrfpy.step(0.05)
|
mcrfpy.step(0.05)
|
||||||
|
|
||||||
if len(miss_clicks) > 0:
|
if len(miss_clicks) > 0:
|
||||||
|
|
@ -58,7 +58,7 @@ if len(miss_clicks) > 0:
|
||||||
|
|
||||||
# Test 3: Position tracking
|
# Test 3: Position tracking
|
||||||
print("Testing position tracking...")
|
print("Testing position tracking...")
|
||||||
automation.moveTo(123, 456)
|
automation.moveTo((123, 456))
|
||||||
pos = automation.position()
|
pos = automation.position()
|
||||||
if pos[0] != 123 or pos[1] != 456:
|
if pos[0] != 123 or pos[1] != 456:
|
||||||
errors.append(f"Position tracking: expected (123,456), got {pos}")
|
errors.append(f"Position tracking: expected (123,456), got {pos}")
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,33 @@
|
||||||
#!/usr/bin/env python3
|
#!/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
|
import mcrfpy
|
||||||
|
from mcrfpy import automation
|
||||||
import sys
|
import sys
|
||||||
import time
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
# Track success across callbacks
|
# Track success across callbacks
|
||||||
success = True
|
success = True
|
||||||
|
done = False
|
||||||
|
|
||||||
|
SHOT = os.path.join(tempfile.gettempdir(), "test_metrics.png")
|
||||||
|
|
||||||
|
|
||||||
def test_metrics(timer, runtime):
|
def test_metrics(timer, runtime):
|
||||||
"""Test the metrics after timer starts"""
|
"""Test the metrics after the timer fires"""
|
||||||
global success
|
global success
|
||||||
print("\nRunning metrics test...")
|
print("\nRunning metrics test...")
|
||||||
|
|
||||||
|
# step() never renders, so force a render pass to publish the render counters.
|
||||||
|
automation.screenshot(SHOT)
|
||||||
|
|
||||||
# Get metrics
|
# Get metrics
|
||||||
metrics = mcrfpy.get_metrics()
|
metrics = mcrfpy.get_metrics()
|
||||||
|
|
||||||
|
|
@ -43,14 +58,21 @@ def test_metrics(timer, runtime):
|
||||||
else:
|
else:
|
||||||
print(f" PASS: FPS {metrics['fps']} is reasonable")
|
print(f" PASS: FPS {metrics['fps']} is reasonable")
|
||||||
|
|
||||||
# UI elements count (may be 0 if scene hasn't rendered yet)
|
# UI elements count -- a render happened, so the scene's drawables were counted
|
||||||
if metrics['ui_elements'] < 0:
|
if metrics['ui_elements'] <= 0:
|
||||||
print(f" FAIL: UI elements count {metrics['ui_elements']} is negative")
|
print(f" FAIL: UI elements count {metrics['ui_elements']} should be positive after a render")
|
||||||
success = False
|
success = False
|
||||||
else:
|
else:
|
||||||
print(f" PASS: UI element count {metrics['ui_elements']} is valid")
|
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']:
|
if metrics['visible_elements'] > metrics['ui_elements']:
|
||||||
print(" FAIL: Visible elements > total elements")
|
print(" FAIL: Visible elements > total elements")
|
||||||
success = False
|
success = False
|
||||||
|
|
@ -78,9 +100,9 @@ def test_metrics(timer, runtime):
|
||||||
initial_frame = metrics['current_frame']
|
initial_frame = metrics['current_frame']
|
||||||
initial_runtime = metrics['runtime']
|
initial_runtime = metrics['runtime']
|
||||||
|
|
||||||
# Schedule another check after 100ms
|
# Schedule another check after 100ms of simulated time
|
||||||
def check_later(timer2, runtime2):
|
def check_later(timer2, runtime2):
|
||||||
global success
|
global success, done
|
||||||
metrics2 = mcrfpy.get_metrics()
|
metrics2 = mcrfpy.get_metrics()
|
||||||
|
|
||||||
print(f"\nMetrics after 100ms:")
|
print(f"\nMetrics after 100ms:")
|
||||||
|
|
@ -103,16 +125,11 @@ def test_metrics(timer, runtime):
|
||||||
print(" FAIL: Runtime did not increase")
|
print(" FAIL: Runtime did not increase")
|
||||||
success = False
|
success = False
|
||||||
|
|
||||||
print("\n" + "="*50)
|
done = True
|
||||||
if success:
|
|
||||||
print("ALL METRICS TESTS PASSED!")
|
|
||||||
else:
|
|
||||||
print("SOME METRICS TESTS FAILED!")
|
|
||||||
|
|
||||||
sys.exit(0 if success else 1)
|
|
||||||
|
|
||||||
mcrfpy.Timer("check_later", check_later, 100, once=True)
|
mcrfpy.Timer("check_later", check_later, 100, once=True)
|
||||||
|
|
||||||
|
|
||||||
# Set up test scene
|
# Set up test scene
|
||||||
print("Setting up metrics test scene...")
|
print("Setting up metrics test scene...")
|
||||||
metrics_test = mcrfpy.Scene("metrics_test")
|
metrics_test = mcrfpy.Scene("metrics_test")
|
||||||
|
|
@ -137,10 +154,33 @@ frame2 = mcrfpy.Frame(pos=(300, 10), size=(100, 100))
|
||||||
frame2.visible = False
|
frame2.visible = False
|
||||||
ui.append(frame2)
|
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)
|
ui.append(grid)
|
||||||
|
|
||||||
print(f"Created {len(ui)} UI elements (1 invisible)")
|
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)
|
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"""
|
"""Test the name parameter in constructors"""
|
||||||
|
|
||||||
import mcrfpy
|
import mcrfpy
|
||||||
|
import sys
|
||||||
|
|
||||||
# Test Frame with name parameter
|
failures = []
|
||||||
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}")
|
|
||||||
|
|
||||||
# Test Grid with name parameter
|
def check(label, fn, expected_name):
|
||||||
try:
|
"""Construct an object, verify the name parameter round-trips."""
|
||||||
grid1 = mcrfpy.Grid(name="test_grid")
|
try:
|
||||||
print(f"✓ Grid with name: {grid1.name}")
|
obj = fn()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"✗ Grid with name failed: {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
|
# name= keyword accepted by every drawable/entity constructor
|
||||||
try:
|
check("Frame", lambda: mcrfpy.Frame(name="test_frame"), "test_frame")
|
||||||
sprite1 = mcrfpy.Sprite(name="test_sprite")
|
check("Grid", lambda: mcrfpy.Grid(name="test_grid"), "test_grid")
|
||||||
print(f"✓ Sprite with name: {sprite1.name}")
|
check("Sprite", lambda: mcrfpy.Sprite(name="test_sprite"), "test_sprite")
|
||||||
except Exception as e:
|
check("Caption", lambda: mcrfpy.Caption(name="test_caption"), "test_caption")
|
||||||
print(f"✗ Sprite with name failed: {e}")
|
check("Entity", lambda: mcrfpy.Entity(name="test_entity"), "test_entity")
|
||||||
|
|
||||||
# Test Caption with name parameter
|
# Mixed positional args and name= keyword: the positional args must not be
|
||||||
try:
|
# disturbed by the trailing keyword.
|
||||||
caption1 = mcrfpy.Caption(name="test_caption")
|
frame2 = check(
|
||||||
print(f"✓ Caption with name: {caption1.name}")
|
"Frame(positional + name)",
|
||||||
except Exception as e:
|
lambda: mcrfpy.Frame((10, 10), (100, 100), name="positioned_frame"),
|
||||||
print(f"✗ Caption with name failed: {e}")
|
"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
|
# A name given at construction must be findable by mcrfpy.find() once the
|
||||||
try:
|
# object is in a scene -- that is what the name parameter is FOR.
|
||||||
entity1 = mcrfpy.Entity(name="test_entity")
|
scene = mcrfpy.Scene("name_param_test")
|
||||||
print(f"✓ Entity with name: {entity1.name}")
|
named = mcrfpy.Frame(pos=(5, 5), size=(20, 20), name="findable_frame")
|
||||||
except Exception as e:
|
scene.children.append(named)
|
||||||
print(f"✗ Entity with name failed: {e}")
|
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
|
if failures:
|
||||||
try:
|
print(f"\nFAIL: {len(failures)} name parameter check(s) failed")
|
||||||
frame2 = mcrfpy.Frame((10, 10), (100, 100), name="positioned_frame")
|
for f in failures:
|
||||||
print(f"✓ Frame with positional args and name: pos=({frame2.x}, {frame2.y}), size=({frame2.w}, {frame2.h}), name={frame2.name}")
|
print(f" - {f}")
|
||||||
except Exception as e:
|
sys.exit(1)
|
||||||
print(f"✗ Frame with positional and name failed: {e}")
|
|
||||||
|
|
||||||
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
|
#!/usr/bin/env python3
|
||||||
"""Test single-line for loops which seem to be the issue"""
|
"""Test single-line for loops which seem to be the issue"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
|
||||||
import mcrfpy
|
import mcrfpy
|
||||||
|
|
||||||
print("Testing single-line for loops...")
|
print("Testing single-line for loops...")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
|
||||||
|
EXPECTED_WALLS = [(x, 1) for x in range(1, 8)]
|
||||||
|
|
||||||
# Test 1: Simple single-line for
|
# Test 1: Simple single-line for
|
||||||
print("Test 1: Simple single-line for")
|
print("Test 1: Simple single-line for")
|
||||||
try:
|
try:
|
||||||
result = []
|
result = []
|
||||||
for x in range(3): result.append(x)
|
for x in range(3): result.append(x)
|
||||||
|
assert result == [0, 1, 2], result
|
||||||
print(f" ✓ Success: {result}")
|
print(f" ✓ Success: {result}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
failures.append(f"Test 1: {type(e).__name__}: {e}")
|
||||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
print(f" ✗ Error: {type(e).__name__}: {e}")
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
@ -24,10 +32,11 @@ print("Test 2: Single-line with tuple append")
|
||||||
try:
|
try:
|
||||||
walls = []
|
walls = []
|
||||||
for x in range(1, 8): walls.append((x, 1))
|
for x in range(1, 8): walls.append((x, 1))
|
||||||
|
assert walls == EXPECTED_WALLS, walls
|
||||||
print(f" ✓ Success: {walls}")
|
print(f" ✓ Success: {walls}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
failures.append(f"Test 2: {type(e).__name__}: {e}")
|
||||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
print(f" ✗ Error: {type(e).__name__}: {e}")
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
@ -38,9 +47,12 @@ try:
|
||||||
walls = []
|
walls = []
|
||||||
for x in range(1, 8):
|
for x in range(1, 8):
|
||||||
walls.append((x, 1))
|
walls.append((x, 1))
|
||||||
|
assert walls == EXPECTED_WALLS, walls
|
||||||
print(f" ✓ Success: {walls}")
|
print(f" ✓ Success: {walls}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
failures.append(f"Test 3: {type(e).__name__}: {e}")
|
||||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
print(f" ✗ Error: {type(e).__name__}: {e}")
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
@ -48,14 +60,15 @@ print()
|
||||||
print("Test 4: After creating mcrfpy scene/grid")
|
print("Test 4: After creating mcrfpy scene/grid")
|
||||||
try:
|
try:
|
||||||
test = mcrfpy.Scene("test")
|
test = mcrfpy.Scene("test")
|
||||||
grid = mcrfpy.Grid(grid_w=10, grid_h=10)
|
grid = mcrfpy.Grid(grid_size=(10, 10))
|
||||||
|
|
||||||
walls = []
|
walls = []
|
||||||
for x in range(1, 8): walls.append((x, 1))
|
for x in range(1, 8): walls.append((x, 1))
|
||||||
|
assert walls == EXPECTED_WALLS, walls
|
||||||
print(f" ✓ Success with mcrfpy objects: {walls}")
|
print(f" ✓ Success with mcrfpy objects: {walls}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
failures.append(f"Test 4: {type(e).__name__}: {e}")
|
||||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
print(f" ✗ Error: {type(e).__name__}: {e}")
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
@ -64,15 +77,19 @@ print()
|
||||||
print("Test 5: Checking exact error location")
|
print("Test 5: Checking exact error location")
|
||||||
def test_exact_pattern():
|
def test_exact_pattern():
|
||||||
dijkstra_demo = mcrfpy.Scene("dijkstra_demo")
|
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)
|
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
|
# Initialize all as floor
|
||||||
for y in range(15):
|
for y in range(15):
|
||||||
for x in range(25):
|
for x in range(25):
|
||||||
grid.at(x, y).walkable = True
|
grid.at(x, y).walkable = True
|
||||||
grid.at(x, y).transparent = 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
|
# Create an interesting dungeon layout
|
||||||
walls = []
|
walls = []
|
||||||
|
|
@ -87,11 +104,22 @@ def test_exact_pattern():
|
||||||
|
|
||||||
try:
|
try:
|
||||||
grid, walls = test_exact_pattern()
|
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")
|
print(f" Result: Created grid and {len(walls)} walls")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
failures.append(f"Test 5: {type(e).__name__}: {e}")
|
||||||
print(f" ✗ Error: {type(e).__name__}: {e}")
|
print(f" ✗ Error: {type(e).__name__}: {e}")
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
if failures:
|
||||||
|
print("Tests complete: FAIL")
|
||||||
|
for f in failures:
|
||||||
|
print(f" {f}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
print("Tests complete.")
|
print("Tests complete.")
|
||||||
|
print("PASS")
|
||||||
|
sys.exit(0)
|
||||||
|
|
@ -7,18 +7,28 @@ import sys
|
||||||
print("Testing path color changes...")
|
print("Testing path color changes...")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
if not cond:
|
||||||
|
failures.append(msg)
|
||||||
|
print(f" FAIL: {msg}")
|
||||||
|
|
||||||
# Create scene and small grid
|
# Create scene and small grid
|
||||||
test = mcrfpy.Scene("test")
|
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
|
# Add color layer for cell coloring (layers are objects now; add_layer takes no kwargs)
|
||||||
color_layer = grid.add_layer("color", z_index=-1)
|
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||||
|
grid.add_layer(color_layer)
|
||||||
|
|
||||||
|
BASE_COLOR = mcrfpy.Color(200, 200, 200) # Light gray
|
||||||
|
|
||||||
# Initialize
|
# Initialize
|
||||||
for y in range(5):
|
for y in range(5):
|
||||||
for x in range(5):
|
for x in range(5):
|
||||||
grid.at(x, y).walkable = True
|
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
|
# Add entities
|
||||||
e1 = mcrfpy.Entity((0, 0), grid=grid)
|
e1 = mcrfpy.Entity((0, 0), grid=grid)
|
||||||
|
|
@ -26,12 +36,15 @@ e2 = mcrfpy.Entity((4, 4), grid=grid)
|
||||||
e1.sprite_index = 64
|
e1.sprite_index = 64
|
||||||
e2.sprite_index = 69
|
e2.sprite_index = 69
|
||||||
|
|
||||||
print(f"Entity 1 at ({e1.x}, {e1.y})")
|
# .x/.y are PIXEL coords now; logical cell coords live on .cell_pos
|
||||||
print(f"Entity 2 at ({e2.x}, {e2.y})")
|
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
|
# 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}")
|
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
|
# Try to color the path
|
||||||
PATH_COLOR = mcrfpy.Color(100, 255, 100) # Green
|
PATH_COLOR = mcrfpy.Color(100, 255, 100) # Green
|
||||||
|
|
@ -41,46 +54,63 @@ for x, y in path:
|
||||||
# Check before
|
# Check before
|
||||||
before_c = color_layer.at(x, y)
|
before_c = color_layer.at(x, y)
|
||||||
before = (before_c.r, before_c.g, before_c.b)
|
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
|
# Set color
|
||||||
color_layer.set(x, y, PATH_COLOR)
|
color_layer.set((x, y), PATH_COLOR)
|
||||||
|
|
||||||
# Check after
|
# Check after
|
||||||
after_c = color_layer.at(x, y)
|
after_c = color_layer.at(x, y)
|
||||||
after = (after_c.r, after_c.g, after_c.b)
|
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}")
|
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:")
|
print("\nVerifying all cells in grid:")
|
||||||
for y in range(5):
|
for y in range(5):
|
||||||
for x in range(5):
|
for x in range(5):
|
||||||
c = color_layer.at(x, y)
|
c = color_layer.at(x, y)
|
||||||
color = (c.r, c.g, c.b)
|
color = (c.r, c.g, c.b)
|
||||||
is_path = (x, y) in path
|
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(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: colors must survive a real render, not just live in the data.
|
||||||
|
|
||||||
# Quick visual test
|
|
||||||
def check_visual(timer, runtime):
|
def check_visual(timer, runtime):
|
||||||
print("\nTimer fired - checking if scene is rendering...")
|
print("\nTimer fired - checking if scene is rendering...")
|
||||||
# Take screenshot to see actual rendering
|
from mcrfpy import automation
|
||||||
try:
|
automation.screenshot("path_color_test.png")
|
||||||
from mcrfpy import automation
|
print("Screenshot saved as path_color_test.png")
|
||||||
automation.screenshot("path_color_test.png")
|
# Re-read after render: the render pass must not clobber the layer data
|
||||||
print("Screenshot saved as path_color_test.png")
|
for x, y in path:
|
||||||
except:
|
c = color_layer.at(x, y)
|
||||||
print("Could not take screenshot")
|
check((c.r, c.g, c.b) == (100, 255, 100),
|
||||||
sys.exit(0)
|
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
|
# Set up minimal UI to test rendering
|
||||||
ui = test.children
|
ui = test.children
|
||||||
ui.append(grid)
|
ui.append(grid)
|
||||||
grid.pos = (50, 50)
|
|
||||||
grid.size = (250, 250)
|
|
||||||
|
|
||||||
test.activate()
|
test.activate()
|
||||||
|
timer_fired = []
|
||||||
check_timer = mcrfpy.Timer("check", check_visual, 500, once=True)
|
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
|
#!/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
|
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)
|
print("=" * 50)
|
||||||
|
|
||||||
# The bug theory: When Grid is created with keyword args grid_w=25, grid_h=15
|
print("Test 1: grid_size tuple")
|
||||||
# and the code takes the tuple parsing path, PyArg_ParseTupleAndKeywords
|
grid1 = mcrfpy.Grid(grid_size=(25, 15))
|
||||||
# at line 520 fails but doesn't check return value, leaving exception on stack
|
check(dims(grid1) == (25, 15), f"Grid(grid_size=(25,15)) -> {dims(grid1)}")
|
||||||
|
no_pending_exception("Grid(grid_size=(25,15))")
|
||||||
# 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()
|
print()
|
||||||
print("Test 2: Grid with keyword args (the failing case)")
|
print("Test 2: grid_w/grid_h keyword args (the historically failing case)")
|
||||||
try:
|
grid2 = mcrfpy.Grid(grid_w=25, grid_h=15)
|
||||||
grid2 = mcrfpy.Grid(grid_w=25, grid_h=15)
|
check(dims(grid2) == (25, 15), f"Grid(grid_w=25, grid_h=15) -> {dims(grid2)}")
|
||||||
# This should fail if exception is pending
|
no_pending_exception("Grid(grid_w=25, grid_h=15)")
|
||||||
_ = 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()
|
print()
|
||||||
print("Test 3: Check if it's specific to the values 25, 15")
|
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)]:
|
for x, y in [(24, 15), (25, 14), (25, 15), (26, 15), (25, 16)]:
|
||||||
try:
|
grid = mcrfpy.Grid(grid_w=x, grid_h=y)
|
||||||
grid = mcrfpy.Grid(grid_w=x, grid_h=y)
|
check(dims(grid) == (x, y), f"Grid(grid_w={x}, grid_h={y}) -> {dims(grid)}")
|
||||||
_ = list(range(1))
|
no_pending_exception(f"Grid(grid_w={x}, grid_h={y})")
|
||||||
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__}")
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("Test 4: Mix positional and keyword args")
|
print("Test 4: Mix positional and keyword args")
|
||||||
try:
|
# Positional slot 0 is pos (#361), so this is "a 25x15 grid drawn at (10, 20)".
|
||||||
# This might trigger different code path
|
grid3 = mcrfpy.Grid((10, 20), grid_w=25, grid_h=15)
|
||||||
grid3 = mcrfpy.Grid(25, grid_h=15)
|
check(dims(grid3) == (25, 15), f"Grid((10,20), grid_w=25, grid_h=15) -> {dims(grid3)}")
|
||||||
_ = list(range(1))
|
check((grid3.pos.x, grid3.pos.y) == (10, 20), f"positional pos honored -> {grid3.pos}")
|
||||||
print(" ✓ Grid(25, grid_h=15) works")
|
no_pending_exception("Grid((10,20), grid_w=25, grid_h=15)")
|
||||||
except Exception as e:
|
|
||||||
print(f" ✗ Grid(25, grid_h=15) failed: {type(e).__name__}: {e}")
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("Test 5: Test with additional arguments")
|
print("Test 5: Additional arguments alongside the grid dimensions")
|
||||||
try:
|
grid4 = mcrfpy.Grid(grid_w=25, grid_h=15, pos=(0, 0))
|
||||||
# This might help identify which PyArg call fails
|
check(dims(grid4) == (25, 15), f"Grid(..., pos=(0,0)) -> {dims(grid4)}")
|
||||||
grid4 = mcrfpy.Grid(grid_w=25, grid_h=15, pos=(0, 0))
|
no_pending_exception("Grid(..., 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}")
|
|
||||||
|
|
||||||
try:
|
grid5 = mcrfpy.Grid(grid_w=25, grid_h=15, texture=None)
|
||||||
grid5 = mcrfpy.Grid(grid_w=25, grid_h=15, texture=None)
|
check(dims(grid5) == (25, 15), f"Grid(..., texture=None) -> {dims(grid5)}")
|
||||||
_ = list(range(1))
|
no_pending_exception("Grid(..., texture=None)")
|
||||||
print(" ✓ Grid with texture=None works")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ✗ Grid with texture=None failed: {type(e).__name__}: {e}")
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("Hypothesis: The bug is in UIGrid::init line 520-523")
|
print("Test 6: Bad arguments raise cleanly and leave no residue")
|
||||||
print("PyArg_ParseTupleAndKeywords is called but return value not checked")
|
bad_calls = [
|
||||||
print("when parsing remaining arguments in tuple-based initialization path")
|
("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
|
#!/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
|
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("Python version:", sys.version)
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
||||||
# Test 1: Simple range
|
# Test 1: Simple range
|
||||||
print("Test 1: Simple range(5)")
|
print("Test 1: Simple range(5)")
|
||||||
try:
|
r = range(5)
|
||||||
r = range(5)
|
check("range(5) is a range", type(r) is range, "type was %r" % (type(r),))
|
||||||
print(" Created range:", r)
|
check_no_raise("iterate range(5)", lambda: [i for i in r], [0, 1, 2, 3, 4])
|
||||||
print(" Type:", type(r))
|
|
||||||
for i in r:
|
|
||||||
print(" ", i)
|
|
||||||
print(" ✓ Success")
|
|
||||||
except Exception as e:
|
|
||||||
print(" ✗ Error:", type(e).__name__, "-", e)
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Test 2: Range with start/stop
|
# Test 2: Range with start/stop
|
||||||
print("Test 2: range(1, 5)")
|
print("Test 2: range(1, 5)")
|
||||||
try:
|
check_no_raise("iterate range(1, 5)", lambda: list(range(1, 5)), [1, 2, 3, 4])
|
||||||
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)
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Test 3: Range in list comprehension
|
# Test 3: Range in list comprehension
|
||||||
print("Test 3: List comprehension with range")
|
print("Test 3: List comprehension with range")
|
||||||
try:
|
check_no_raise("[x for x in range(3)]", lambda: [x for x in range(3)], [0, 1, 2])
|
||||||
lst = [x for x in range(3)]
|
|
||||||
print(" Result:", lst)
|
|
||||||
print(" ✓ Success")
|
|
||||||
except Exception as e:
|
|
||||||
print(" ✗ Error:", type(e).__name__, "-", e)
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Test 4: Range in for loop (the failing case)
|
# Test 4: Range in for loop (the failing case)
|
||||||
print("Test 4: for x in range(3):")
|
print("Test 4: for x in range(3):")
|
||||||
try:
|
def _for_loop():
|
||||||
|
out = []
|
||||||
for x in range(3):
|
for x in range(3):
|
||||||
print(" ", x)
|
out.append(x)
|
||||||
print(" ✓ Success")
|
return out
|
||||||
except Exception as e:
|
check_no_raise("for x in range(3)", _for_loop, [0, 1, 2])
|
||||||
print(" ✗ Error:", type(e).__name__, "-", e)
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Test 5: len() on list
|
# Test 5: len() on list
|
||||||
print("Test 5: len() on list")
|
print("Test 5: len() on list")
|
||||||
try:
|
check_no_raise("len([1, 2, 3])", lambda: len([1, 2, 3]), 3)
|
||||||
lst = [1, 2, 3]
|
|
||||||
print(" List:", lst)
|
|
||||||
print(" Length:", len(lst))
|
|
||||||
print(" ✓ Success")
|
|
||||||
except Exception as e:
|
|
||||||
print(" ✗ Error:", type(e).__name__, "-", e)
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Test 6: len() on tuple
|
# Test 6: len() on tuple
|
||||||
print("Test 6: len() on tuple")
|
print("Test 6: len() on tuple")
|
||||||
try:
|
check_no_raise("len((1, 2, 3))", lambda: len((1, 2, 3)), 3)
|
||||||
tup = (1, 2, 3)
|
|
||||||
print(" Tuple:", tup)
|
|
||||||
print(" Length:", len(tup))
|
|
||||||
print(" ✓ Success")
|
|
||||||
except Exception as e:
|
|
||||||
print(" ✗ Error:", type(e).__name__, "-", e)
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Test 7: Nested function calls (reproducing the error context)
|
# Test 7: Nested function calls (reproducing the error context)
|
||||||
print("Test 7: Nested context like in the failing code")
|
print("Test 7: Nested context like in the failing code")
|
||||||
try:
|
def _walls():
|
||||||
walls = []
|
walls = []
|
||||||
for x in range(1, 8):
|
for x in range(1, 8):
|
||||||
walls.append((x, 1))
|
walls.append((x, 1))
|
||||||
print(" Walls:", walls)
|
return walls
|
||||||
print(" ✓ Success")
|
check_no_raise("build wall list via range + append", _walls,
|
||||||
except Exception as e:
|
[(1, 1), (2, 1), (3, 1), (4, 1), (5, 1), (6, 1), (7, 1)])
|
||||||
print(" ✗ Error:", type(e).__name__, "-", e)
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Test 8: Check if builtins are intact
|
# Test 8: Check if builtins are intact
|
||||||
print("Test 8: Builtin integrity check")
|
print("Test 8: Builtin integrity check")
|
||||||
print(" range is:", range)
|
import builtins
|
||||||
print(" len is:", len)
|
check("range is builtins.range", range is builtins.range)
|
||||||
print(" type(range):", type(range))
|
check("len is builtins.len", len is builtins.len)
|
||||||
print(" type(len):", type(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()
|
||||||
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
|
#!/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 mcrfpy
|
||||||
|
import sys
|
||||||
|
|
||||||
print("Demonstrating range(25) bug...")
|
failures = []
|
||||||
print("=" * 50)
|
|
||||||
|
|
||||||
# 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
|
def check(condition, label):
|
||||||
print("\nTest 2: range(25) after creating 25x15 grid")
|
if condition:
|
||||||
try:
|
print(" PASS: %s" % label)
|
||||||
test = mcrfpy.Scene("test")
|
else:
|
||||||
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
|
print(" FAIL: %s" % label)
|
||||||
|
failures.append(label)
|
||||||
|
|
||||||
for i in range(25):
|
|
||||||
pass
|
|
||||||
print(" ✓ range(25) still works after grid creation")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ✗ Error: {e}")
|
|
||||||
|
|
||||||
# Test 3: The killer combination
|
def exercise_grid(w, h):
|
||||||
print("\nTest 3: range(25) after 15x25 grid.at() operations")
|
"""Nested grid.at() loop -- the operation that used to corrupt memory."""
|
||||||
try:
|
grid = mcrfpy.Grid(grid_size=(w, h))
|
||||||
test3 = mcrfpy.Scene("test3")
|
|
||||||
grid = mcrfpy.Grid(grid_w=25, grid_h=15)
|
|
||||||
|
|
||||||
# Do the nested loop that triggers the bug
|
|
||||||
count = 0
|
count = 0
|
||||||
for y in range(15):
|
for y in range(h):
|
||||||
for x in range(25):
|
for x in range(w):
|
||||||
grid.at(x, y).walkable = True
|
grid.at(x, y).walkable = True
|
||||||
count += 1
|
count += 1
|
||||||
|
return grid, count
|
||||||
|
|
||||||
print(f" ✓ Completed {count} grid.at() calls")
|
|
||||||
|
|
||||||
# This should fail
|
print("Testing the range(25) bug scenario...")
|
||||||
print(" Testing range(25) now...")
|
print("=" * 50)
|
||||||
for i in range(25):
|
|
||||||
pass
|
|
||||||
print(" ✓ range(25) works (unexpected!)")
|
|
||||||
|
|
||||||
except Exception as e:
|
# Test 1: range(25) works fine normally (baseline sanity)
|
||||||
print(f" ✗ range(25) failed as expected: {type(e).__name__}")
|
print("Test 1: range(25) before any mcrfpy operations")
|
||||||
|
check(list(range(25)) == list(range(25)), "range(25) works initially")
|
||||||
|
|
||||||
# Test 4: Does range(24) still work?
|
# 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")
|
print("\nTest 4: range(24) after same operations")
|
||||||
try:
|
mcrfpy.Scene("test4")
|
||||||
test4 = mcrfpy.Scene("test4")
|
grid = mcrfpy.Grid(grid_size=(25, 15))
|
||||||
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
|
||||||
|
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")
|
||||||
|
|
||||||
for y in range(15):
|
# Test 5: Different grid dimensions -- not specific to 15/25
|
||||||
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}")
|
|
||||||
|
|
||||||
# Test 5: Is it about the specific combination of 15 and 25?
|
|
||||||
print("\nTest 5: Different grid dimensions")
|
print("\nTest 5: Different grid dimensions")
|
||||||
try:
|
mcrfpy.Scene("test5")
|
||||||
test5 = mcrfpy.Scene("test5")
|
grid, count = exercise_grid(30, 20)
|
||||||
grid = mcrfpy.Grid(grid_w=30, grid_h=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")
|
||||||
|
|
||||||
for y in range(20):
|
print("\n" + "=" * 50)
|
||||||
for x in range(30):
|
if failures:
|
||||||
grid.at(x, y).walkable = True
|
print("FAIL: %d check(s) failed:" % len(failures))
|
||||||
|
for f in failures:
|
||||||
|
print(" - %s" % f)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# Test various ranges
|
print("PASS: the range(25) bug is fixed; grid.at() loops do not corrupt the interpreter")
|
||||||
for i in range(25):
|
sys.exit(0)
|
||||||
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}")
|
|
||||||
|
|
||||||
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.")
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,24 @@
|
||||||
#!/usr/bin/env python3
|
#!/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 mcrfpy
|
||||||
|
import sys
|
||||||
|
|
||||||
print("Finding range() failure threshold...")
|
print("Finding range() failure threshold...")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
|
||||||
def test_range_size(n):
|
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:
|
try:
|
||||||
mcrfpy.createScene(f"test_{n}")
|
mcrfpy.Scene(f"test_{n}")
|
||||||
grid = mcrfpy.Grid(grid_w=n, grid_h=n)
|
grid = mcrfpy.Grid(grid_size=(n, n))
|
||||||
|
|
||||||
# Do grid operations
|
# Do grid operations
|
||||||
for y in range(min(n, 10)): # Limit outer loop
|
for y in range(min(n, 10)): # Limit outer loop
|
||||||
|
|
@ -29,79 +37,92 @@ def test_range_size(n):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"Other error: {type(e).__name__}: {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...")
|
print("Testing different range sizes...")
|
||||||
|
|
||||||
# Test powers of 2 first
|
|
||||||
for n in [2, 4, 8, 16, 32]:
|
for n in [2, 4, 8, 16, 32]:
|
||||||
success, result = test_range_size(n)
|
success, result = test_range_size(n)
|
||||||
if success:
|
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:
|
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()
|
print()
|
||||||
|
|
||||||
# Narrow down between working and failing values
|
# The original bug was reported as "10 works, 25 fails" -- walk the whole span
|
||||||
print("Narrowing down exact threshold...")
|
# 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:
|
if success:
|
||||||
print(f" range({mid}): ✓ Works")
|
print(f" range({n}): OK")
|
||||||
low = mid
|
if result != n:
|
||||||
|
failures.append(f"range({n}) produced {result} items, expected {n}")
|
||||||
else:
|
else:
|
||||||
print(f" range({mid}): ✗ Fails")
|
print(f" range({n}): FAIL - {result}")
|
||||||
high = mid
|
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()
|
||||||
print("Testing if it's about grid size vs range size...")
|
print("Testing if it's about grid size vs range size...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Small grid, large range
|
# Small grid, large range
|
||||||
test_small_grid = mcrfpy.Scene("test_small_grid")
|
mcrfpy.Scene("test_small_grid")
|
||||||
grid = mcrfpy.Grid(grid_w=5, grid_h=5)
|
grid = mcrfpy.Grid(grid_size=(5, 5))
|
||||||
|
|
||||||
# Do minimal grid operations
|
# Do minimal grid operations
|
||||||
grid.at(0, 0).walkable = True
|
grid.at(0, 0).walkable = True
|
||||||
|
|
||||||
# Test large range
|
# Test large range
|
||||||
|
count = 0
|
||||||
for i in range(25):
|
for i in range(25):
|
||||||
pass
|
count += 1
|
||||||
print(" ✓ range(25) works with small grid (5x5)")
|
assert count == 25, f"range(25) yielded {count} items"
|
||||||
|
print(" OK: range(25) works with small grid (5x5)")
|
||||||
|
|
||||||
except Exception as e:
|
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:
|
try:
|
||||||
# Large grid, see what happens
|
# Large grid, see what happens
|
||||||
test_large_grid = mcrfpy.Scene("test_large_grid")
|
mcrfpy.Scene("test_large_grid")
|
||||||
grid = mcrfpy.Grid(grid_w=20, grid_h=20)
|
grid = mcrfpy.Grid(grid_size=(20, 20))
|
||||||
|
|
||||||
# Do operations on large grid
|
# Do operations on large grid
|
||||||
|
touched = 0
|
||||||
for y in range(20):
|
for y in range(20):
|
||||||
for x in range(20):
|
for x in range(20):
|
||||||
grid.at(x, y).walkable = True
|
grid.at(x, y).walkable = True
|
||||||
|
touched += 1
|
||||||
print(" ✓ Completed 20x20 grid operations")
|
assert touched == 400, f"touched {touched} cells, expected 400"
|
||||||
|
print(" OK: Completed 20x20 grid operations")
|
||||||
|
|
||||||
# Now test range
|
# Now test range
|
||||||
|
count = 0
|
||||||
for i in range(20):
|
for i in range(20):
|
||||||
pass
|
count += 1
|
||||||
print(" ✓ range(20) works after 20x20 grid operations")
|
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:
|
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()
|
||||||
|
if failures:
|
||||||
|
print("Analysis complete: FAILURES")
|
||||||
|
for f in failures:
|
||||||
|
print(f" - {f}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
print("Analysis complete.")
|
print("Analysis complete.")
|
||||||
|
print("PASS")
|
||||||
|
sys.exit(0)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,27 @@
|
||||||
#!/usr/bin/env python3
|
#!/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
|
import mcrfpy
|
||||||
|
from mcrfpy import automation
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
|
|
||||||
red_scene, blue_scene, green_scene, menu_scene = None, None, None, None # global scoping
|
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():
|
def create_test_scenes():
|
||||||
"""Create several test scenes with different colored backgrounds."""
|
"""Create several test scenes with different colored backgrounds."""
|
||||||
global red_scene, blue_scene, green_scene, menu_scene
|
global red_scene, blue_scene, green_scene, menu_scene
|
||||||
|
|
@ -73,8 +88,6 @@ def handle_key(key, action):
|
||||||
if action != mcrfpy.InputState.PRESSED:
|
if action != mcrfpy.InputState.PRESSED:
|
||||||
return
|
return
|
||||||
|
|
||||||
current_scene = (mcrfpy.current_scene.name if mcrfpy.current_scene else None)
|
|
||||||
|
|
||||||
# Number keys set transition type
|
# Number keys set transition type
|
||||||
keyselections = {
|
keyselections = {
|
||||||
mcrfpy.Key.NUM_1: mcrfpy.Transition.FADE,
|
mcrfpy.Key.NUM_1: mcrfpy.Transition.FADE,
|
||||||
|
|
@ -87,24 +100,6 @@ def handle_key(key, action):
|
||||||
if key in keyselections:
|
if key in keyselections:
|
||||||
current_transition = keyselections[key]
|
current_transition = keyselections[key]
|
||||||
print(f"Transition set to: {current_transition}")
|
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
|
# Letter keys change scene
|
||||||
keytransitions = {
|
keytransitions = {
|
||||||
|
|
@ -115,62 +110,89 @@ def handle_key(key, action):
|
||||||
}
|
}
|
||||||
if key in keytransitions:
|
if key in keytransitions:
|
||||||
if mcrfpy.current_scene != keytransitions[key]:
|
if mcrfpy.current_scene != keytransitions[key]:
|
||||||
|
print(f"Transitioning to {keytransitions[key].name} with {current_transition}")
|
||||||
keytransitions[key].activate(current_transition, transition_duration)
|
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...")
|
print("\nRunning automatic transition test...")
|
||||||
for i, (trans_type, scene) in enumerate(transitions):
|
transitions = [
|
||||||
if trans_type:
|
(mcrfpy.Transition.FADE, red_scene),
|
||||||
print(f"Transition {i+1}: {trans_type} to {scene}")
|
(mcrfpy.Transition.SLIDE_LEFT, blue_scene),
|
||||||
mcrfpy.setScene(scene, trans_type, 1.0)
|
(mcrfpy.Transition.SLIDE_RIGHT, green_scene),
|
||||||
else:
|
(mcrfpy.Transition.SLIDE_UP, red_scene),
|
||||||
print(f"Transition {i+1}: instant to {scene}")
|
(mcrfpy.Transition.SLIDE_DOWN, menu_scene),
|
||||||
mcrfpy.current_scene = scene
|
(mcrfpy.Transition.NONE, blue_scene), # Instant
|
||||||
time.sleep(2) # Wait for transition to complete plus viewing time
|
]
|
||||||
|
for trans_type, scene in transitions:
|
||||||
|
run_transition(scene, trans_type)
|
||||||
print("Automatic test complete!")
|
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
|
# Main test setup
|
||||||
print("=== Scene Transition Test ===")
|
print("=== Scene Transition Test ===")
|
||||||
|
|
@ -178,15 +200,22 @@ create_test_scenes()
|
||||||
|
|
||||||
# Start with menu scene
|
# Start with menu scene
|
||||||
menu_scene.activate()
|
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
|
# Set up keyboard handler
|
||||||
for s in (red_scene, blue_scene, green_scene, menu_scene):
|
for s in (red_scene, blue_scene, green_scene, menu_scene):
|
||||||
s.on_key = handle_key
|
s.on_key = handle_key
|
||||||
#menu_scene.on_key = handle_key
|
|
||||||
|
|
||||||
# Option to run automatic test
|
test_automatic_transitions()
|
||||||
if len(sys.argv) > 1 and sys.argv[1] == "--auto":
|
test_key_driven_transitions()
|
||||||
mcrfpy.Timer("auto_test", lambda t, r: test_automatic_transitions(r), 1000, once=True)
|
|
||||||
else:
|
if failures:
|
||||||
print("\nManual test mode. Use keyboard controls shown on screen.")
|
print(f"\nFAIL: {len(failures)} check(s) failed:")
|
||||||
print("Run with --auto flag for automatic transition demo.")
|
for f in failures:
|
||||||
|
print(f" - {f}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("\nPASS")
|
||||||
|
sys.exit(0)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,15 @@
|
||||||
import mcrfpy
|
import mcrfpy
|
||||||
import sys
|
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():
|
def test_scene_transitions():
|
||||||
"""Test all scene transition types."""
|
"""Test all scene transition types."""
|
||||||
|
|
||||||
|
|
@ -22,13 +31,16 @@ def test_scene_transitions():
|
||||||
frame2 = mcrfpy.Frame(pos=(0, 0), size=(100, 100), fill_color=mcrfpy.Color(0, 0, 255))
|
frame2 = mcrfpy.Frame(pos=(0, 0), size=(100, 100), fill_color=mcrfpy.Color(0, 0, 255))
|
||||||
ui2.append(frame2)
|
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 = [
|
transitions = [
|
||||||
("fade", 0.5),
|
(mcrfpy.Transition.FADE, 0.5),
|
||||||
("slide_left", 0.5),
|
(mcrfpy.Transition.SLIDE_LEFT, 0.5),
|
||||||
("slide_right", 0.5),
|
(mcrfpy.Transition.SLIDE_RIGHT, 0.5),
|
||||||
("slide_up", 0.5),
|
(mcrfpy.Transition.SLIDE_UP, 0.5),
|
||||||
("slide_down", 0.5),
|
(mcrfpy.Transition.SLIDE_DOWN, 0.5),
|
||||||
(None, 0.0), # Instant
|
(None, 0.0), # Instant
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -36,25 +48,50 @@ def test_scene_transitions():
|
||||||
|
|
||||||
# Start with scene1
|
# Start with scene1
|
||||||
scene1.activate()
|
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:
|
for trans_type, duration in transitions:
|
||||||
target = "scene2" if (mcrfpy.current_scene.name if mcrfpy.current_scene else None) == "scene1" else "scene1"
|
source = mcrfpy.current_scene.name
|
||||||
|
target = "scene2" if source == "scene1" else "scene1"
|
||||||
|
target_scene = scenes[target]
|
||||||
|
|
||||||
if trans_type:
|
if trans_type is not None:
|
||||||
print(f"\nTransitioning to {target} with {trans_type} (duration: {duration}s)")
|
print(f"\nTransitioning to {target} with {trans_type!r} (duration: {duration}s)")
|
||||||
mcrfpy.setScene(target, trans_type, duration)
|
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:
|
else:
|
||||||
print(f"\nTransitioning to {target} instantly")
|
print(f"\nTransitioning to {target} instantly")
|
||||||
mcrfpy.current_scene = target
|
mcrfpy.current_scene = target_scene
|
||||||
|
|
||||||
print(f"Current scene after transition: {(mcrfpy.current_scene.name if mcrfpy.current_scene else None)}")
|
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("\n✓ All scene transition types tested successfully!")
|
print("\nAll scene transition types tested.")
|
||||||
print("\nNote: Visual transitions cannot be verified in headless mode.")
|
|
||||||
print("The transitions are implemented and working in the engine.")
|
|
||||||
|
|
||||||
|
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)
|
sys.exit(0)
|
||||||
|
|
||||||
# Run the test immediately
|
|
||||||
test_scene_transitions()
|
|
||||||
|
|
@ -1,32 +1,98 @@
|
||||||
#!/usr/bin/env python3
|
#!/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 mcrfpy
|
||||||
import sys
|
|
||||||
import os
|
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("=== Testing stdin theory ===")
|
||||||
print(f"stdin.isatty(): {sys.stdin.isatty()}")
|
print("stdin.isatty(): %s" % sys.stdin.isatty())
|
||||||
print(f"stdin fileno: {sys.stdin.fileno()}")
|
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 = mcrfpy.Scene("stdin_test")
|
||||||
stdin_test.activate()
|
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
|
# --- 1. stdin fed Python source is NOT evaluated as a REPL ---------------------
|
||||||
print("\nAttempting to prevent interactive mode...")
|
# sys.executable is the mcrogueface binary itself.
|
||||||
try:
|
FED = "print('REPL_LEAKED')\n"
|
||||||
# Method 1: Close stdin
|
|
||||||
sys.stdin.close()
|
|
||||||
print("Closed sys.stdin")
|
|
||||||
except:
|
|
||||||
print("Failed to close sys.stdin")
|
|
||||||
|
|
||||||
try:
|
child_exit = os.path.join(scratch, "_stdin_theory_child_exit.py")
|
||||||
# Method 2: Redirect stdin to /dev/null
|
child_fallthrough = os.path.join(scratch, "_stdin_theory_child_fallthrough.py")
|
||||||
devnull = open(os.devnull, 'r')
|
with open(child_exit, "w") as f:
|
||||||
os.dup2(devnull.fileno(), 0)
|
f.write("import mcrfpy\nimport sys\nprint('CHILD_RAN')\nsys.exit(0)\n")
|
||||||
print("Redirected stdin to /dev/null")
|
with open(child_fallthrough, "w") as f:
|
||||||
except:
|
# Deliberately falls off the end: the historical case where >>> showed up.
|
||||||
print("Failed to redirect stdin")
|
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,132 +5,147 @@ import os
|
||||||
import sys
|
import sys
|
||||||
import ast
|
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():
|
def test_stub_syntax():
|
||||||
"""Test that the stub file has valid Python 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
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(stub_path, 'r') as f:
|
content = read_stub()
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Parse the stub file
|
# Parse the stub file
|
||||||
tree = ast.parse(content)
|
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
|
# Count definitions
|
||||||
classes = [node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)]
|
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)]
|
functions = [node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)]
|
||||||
|
|
||||||
print(f"✓ Found {len(classes)} class definitions")
|
print(f"OK Found {len(classes)} class definitions")
|
||||||
print(f"✓ Found {len(functions)} function/method definitions")
|
print(f"OK Found {len(functions)} function/method definitions")
|
||||||
|
|
||||||
# Check for key classes
|
# Check for key classes (Timer/Scene are the successors to setTimer/createScene)
|
||||||
class_names = {cls.name for cls in classes}
|
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
|
missing = expected_classes - class_names
|
||||||
|
|
||||||
if missing:
|
if missing:
|
||||||
print(f"✗ Missing classes: {missing}")
|
print(f"FAIL Missing classes: {missing}")
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
print("✓ All expected classes are defined")
|
print("OK All expected classes are defined")
|
||||||
|
|
||||||
# Check for key functions
|
# 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)]
|
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)
|
func_set = set(top_level_funcs)
|
||||||
|
expected_funcs = {'find', 'find_all', 'step', 'get_metrics', 'set_scale', 'exit'}
|
||||||
missing_funcs = expected_funcs - func_set
|
missing_funcs = expected_funcs - func_set
|
||||||
|
|
||||||
if missing_funcs:
|
if missing_funcs:
|
||||||
print(f"✗ Missing functions: {missing_funcs}")
|
print(f"FAIL Missing functions: {missing_funcs}")
|
||||||
return False
|
return False
|
||||||
else:
|
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
|
return True
|
||||||
|
|
||||||
except SyntaxError as e:
|
except SyntaxError as e:
|
||||||
print(f"✗ Syntax error in stub file: {e}")
|
print(f"FAIL Syntax error in stub file: {e}")
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ Error parsing stub file: {e}")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def test_type_annotations():
|
def test_type_annotations():
|
||||||
"""Test that type annotations are present and well-formed."""
|
"""Test that type annotations are present and well-formed."""
|
||||||
stub_path = 'stubs/mcrfpy.pyi'
|
content = read_stub()
|
||||||
|
|
||||||
with open(stub_path, 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Check for proper type imports
|
# Check for proper type imports
|
||||||
if 'from typing import' not in content:
|
if 'from typing import' not in content:
|
||||||
print("✗ Missing typing imports")
|
print("FAIL Missing typing imports")
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
print("✓ Has typing imports")
|
print("OK Has typing imports")
|
||||||
|
|
||||||
# Check for Optional usage
|
# Optional/Union/@overload are nice-to-haves; the generator currently emits
|
||||||
if 'Optional[' in content:
|
# PEP 604 unions ("int | None") instead, so these are informational only.
|
||||||
print("✓ Uses Optional type hints")
|
for marker, label in (('Optional[', 'Optional type hints'),
|
||||||
|
('Union[', 'Union type hints'),
|
||||||
# Check for Union usage
|
('@overload', '@overload decorators')):
|
||||||
if 'Union[' in content:
|
if marker in content:
|
||||||
print("✓ Uses Union type hints")
|
print(f"OK Uses {label}")
|
||||||
|
|
||||||
# Check for overload usage
|
|
||||||
if '@overload' in content:
|
|
||||||
print("✓ Uses @overload decorators")
|
|
||||||
|
|
||||||
# Check return type annotations
|
# Check return type annotations
|
||||||
if '-> None:' in content and '-> int:' in content and '-> str:' in content:
|
if '-> None:' in content and '-> int:' in content and '-> str:' in content:
|
||||||
print("✓ Has return type annotations")
|
print("OK Has return type annotations")
|
||||||
else:
|
else:
|
||||||
print("✗ Missing some return type annotations")
|
print("FAIL Missing some return type annotations")
|
||||||
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def test_docstrings():
|
def test_docstrings():
|
||||||
"""Test that docstrings are preserved in stubs."""
|
"""Test that docstrings are preserved in stubs."""
|
||||||
stub_path = 'stubs/mcrfpy.pyi'
|
content = read_stub()
|
||||||
|
|
||||||
with open(stub_path, 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Count docstrings
|
# Count docstrings
|
||||||
docstring_count = content.count('"""')
|
docstring_count = content.count('"""')
|
||||||
if docstring_count > 10: # Should have many docstrings
|
if docstring_count > 10: # Should have many docstrings
|
||||||
print(f"✓ Found {docstring_count // 2} docstrings")
|
print(f"OK Found {docstring_count // 2} docstrings")
|
||||||
else:
|
else:
|
||||||
print(f"✗ Too few docstrings found: {docstring_count // 2}")
|
print(f"FAIL Too few docstrings found: {docstring_count // 2}")
|
||||||
|
return False
|
||||||
|
|
||||||
# Check for specific docstrings
|
# Check for specific docstrings (module, class, method)
|
||||||
if 'Core game engine interface' in content:
|
ok = True
|
||||||
print("✓ Module docstring present")
|
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
|
||||||
|
|
||||||
if 'A rectangular frame UI element' in content:
|
return ok
|
||||||
print("✓ Frame class docstring present")
|
|
||||||
|
|
||||||
if 'Load a sound effect from a file' in content:
|
|
||||||
print("✓ Function docstrings present")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def test_automation_module():
|
def test_automation_module():
|
||||||
"""Test that automation module is properly defined."""
|
"""Test that automation module is properly defined."""
|
||||||
stub_path = 'stubs/mcrfpy.pyi'
|
content = read_stub()
|
||||||
|
|
||||||
with open(stub_path, 'r') as f:
|
# The generator emits the submodule as a _automation_module class plus an
|
||||||
content = f.read()
|
# `automation:` module-level binding.
|
||||||
|
if 'class _automation_module:' in content and 'automation: _automation_module' in content:
|
||||||
if 'class automation:' in content:
|
print("OK automation submodule defined")
|
||||||
print("✓ automation class defined")
|
|
||||||
else:
|
else:
|
||||||
print("✗ automation class missing")
|
print("FAIL automation submodule missing")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check for key automation methods
|
# Check for key automation methods
|
||||||
|
|
@ -139,12 +154,14 @@ def test_automation_module():
|
||||||
for method in automation_methods:
|
for method in automation_methods:
|
||||||
if f'def {method}' not in content:
|
if f'def {method}' not in content:
|
||||||
missing.append(method)
|
missing.append(method)
|
||||||
|
elif not hasattr(mcrfpy.automation, method):
|
||||||
|
missing.append(method + " (stubbed but absent from mcrfpy.automation)")
|
||||||
|
|
||||||
if missing:
|
if missing:
|
||||||
print(f"✗ Missing automation methods: {missing}")
|
print(f"FAIL Missing automation methods: {missing}")
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
print("✓ All key automation methods defined")
|
print("OK All key automation methods defined")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
@ -176,10 +193,10 @@ def main():
|
||||||
print()
|
print()
|
||||||
|
|
||||||
if all_passed:
|
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)
|
sys.exit(0)
|
||||||
else:
|
else:
|
||||||
print("❌ Some tests failed. Please review the stub file.")
|
print("FAIL - Some tests failed. Please review the stub file.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,53 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Test the text input widget system
|
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 sys
|
||||||
import os
|
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
|
import mcrfpy
|
||||||
from text_input_widget import FocusManager, TextInput
|
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():
|
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
|
# Create scene
|
||||||
text_demo = mcrfpy.Scene("text_demo")
|
text_demo = mcrfpy.Scene("text_demo")
|
||||||
scene = text_demo.children
|
scene = text_demo.children
|
||||||
|
|
@ -75,36 +110,116 @@ def create_demo():
|
||||||
for inp in inputs:
|
for inp in inputs:
|
||||||
inp.on_change = update_status
|
inp.on_change = update_status
|
||||||
|
|
||||||
# Keyboard handler
|
# Keyboard handler (#184: receives Key and InputState enums)
|
||||||
def handle_keys(scene_name, key):
|
def handle_keys(key, action):
|
||||||
if not focus_mgr.handle_key(key):
|
if action != mcrfpy.InputState.PRESSED:
|
||||||
|
return
|
||||||
|
if not focus_mgr.handle_key(key_name(key)):
|
||||||
if key == mcrfpy.Key.TAB:
|
if key == mcrfpy.Key.TAB:
|
||||||
focus_mgr.focus_next()
|
focus_mgr.focus_next()
|
||||||
elif key == mcrfpy.Key.ESCAPE:
|
elif key == mcrfpy.Key.ESCAPE:
|
||||||
print("\nFinal values:")
|
print("\nFinal values:")
|
||||||
for i, inp in enumerate(inputs):
|
for i, inp in enumerate(inputs):
|
||||||
print(f" Field {i+1}: '{inp.get_text()}'")
|
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()
|
text_demo.activate()
|
||||||
|
|
||||||
# Run demo test
|
return text_demo, focus_mgr, inputs, status, handle_keys
|
||||||
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!")
|
|
||||||
|
|
||||||
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__":
|
if __name__ == "__main__":
|
||||||
create_demo()
|
main()
|
||||||
|
|
|
||||||
|
|
@ -8,39 +8,64 @@ except ImportError as e:
|
||||||
print(f"Failed to import mcrfpy: {e}", file=sys.stderr)
|
print(f"Failed to import mcrfpy: {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
|
||||||
# Test 1: Try to create a texture with a non-existent file
|
# Test 1: Try to create a texture with a non-existent file
|
||||||
print("Test 1: Creating texture with non-existent file...")
|
print("Test 1: Creating texture with non-existent file...")
|
||||||
try:
|
try:
|
||||||
texture = mcrfpy.Texture("this_file_does_not_exist.png", 16, 16)
|
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("FAIL: Expected IOError but texture was created successfully")
|
||||||
print(f"Texture: {texture}")
|
print(f"Texture: {texture}")
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
print("PASS: Got expected IOError:", e)
|
print("PASS: Got expected IOError:", e)
|
||||||
except Exception as 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}")
|
print(f"FAIL: Got unexpected exception type {type(e).__name__}: {e}")
|
||||||
|
|
||||||
# Test 2: Try to create a texture with an empty filename
|
# Test 2: Try to create a texture with an empty filename
|
||||||
print("\nTest 2: Creating texture with empty filename...")
|
print("\nTest 2: Creating texture with empty filename...")
|
||||||
try:
|
try:
|
||||||
texture = mcrfpy.Texture("", 16, 16)
|
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")
|
print("FAIL: Expected IOError but texture was created successfully")
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
print("PASS: Got expected IOError:", e)
|
print("PASS: Got expected IOError:", e)
|
||||||
except Exception as 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}")
|
print(f"FAIL: Got unexpected exception type {type(e).__name__}: {e}")
|
||||||
|
|
||||||
# Test 3: Verify a valid texture still works
|
# 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:
|
||||||
# Try a common test asset path
|
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||||
texture = mcrfpy.Texture("assets/sprites/tileset.png", 16, 16)
|
|
||||||
print("PASS: Valid texture created successfully")
|
print("PASS: Valid texture created successfully")
|
||||||
print(f" Sheet dimensions: {texture.sheet_width}x{texture.sheet_height}")
|
print(f" Sheet dimensions: {texture.sheet_width}x{texture.sheet_height}")
|
||||||
print(f" Sprite count: {texture.sprite_count}")
|
print(f" Sprite count: {texture.sprite_count}")
|
||||||
except IOError as e:
|
if texture.sprite_width != 16 or texture.sprite_height != 16:
|
||||||
# This is OK if the asset doesn't exist in the test environment
|
failures.append(
|
||||||
print("INFO: Test texture file not found (expected in test environment):", e)
|
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:
|
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(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
|
# Create a grid and get a tile layer
|
||||||
grid = mcrfpy.Grid(grid_size=(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))
|
||||||
layer.fill(-1) # Clear all tiles
|
layer.fill(-1) # Clear all tiles
|
||||||
|
|
||||||
# Apply threshold - all cells should get tile 5
|
# Apply threshold - all cells should get tile 5
|
||||||
|
|
@ -36,7 +36,7 @@ def test_apply_threshold_partial():
|
||||||
hmap.fill(0.0) # Start with 0
|
hmap.fill(0.0) # Start with 0
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(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))
|
||||||
layer.fill(-1)
|
layer.fill(-1)
|
||||||
|
|
||||||
# Apply threshold for range that doesn't match (0.5-1.0 when values are 0.0)
|
# 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)
|
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(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))
|
||||||
layer.fill(99) # Fill with marker value
|
layer.fill(99) # Fill with marker value
|
||||||
|
|
||||||
# Apply threshold for range that doesn't include 0.5
|
# 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))
|
hmap = mcrfpy.HeightMap((10, 10))
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(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:
|
try:
|
||||||
layer.apply_threshold(hmap, (1.0, 0.0), 5) # min > max
|
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
|
hmap = mcrfpy.HeightMap((5, 5)) # Different size
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(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:
|
try:
|
||||||
layer.apply_threshold(hmap, (0.0, 1.0), 5)
|
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)
|
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(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))
|
||||||
layer.fill(-1)
|
layer.fill(-1)
|
||||||
|
|
||||||
# Apply ranges - 0.5 falls in the second range
|
# 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)
|
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(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))
|
||||||
layer.fill(-1)
|
layer.fill(-1)
|
||||||
|
|
||||||
# Apply overlapping ranges - later should win
|
# 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)
|
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(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))
|
||||||
layer.fill(99)
|
layer.fill(99)
|
||||||
|
|
||||||
# Apply ranges that don't match 0.5
|
# Apply ranges that don't match 0.5
|
||||||
|
|
@ -160,7 +160,7 @@ def test_apply_ranges_invalid_format():
|
||||||
hmap = mcrfpy.HeightMap((10, 10))
|
hmap = mcrfpy.HeightMap((10, 10))
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(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
|
# Missing tile index
|
||||||
try:
|
try:
|
||||||
|
|
@ -178,7 +178,7 @@ def test_apply_threshold_boundary():
|
||||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(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))
|
||||||
layer.fill(-1)
|
layer.fill(-1)
|
||||||
|
|
||||||
# Range includes 0.5 exactly
|
# Range includes 0.5 exactly
|
||||||
|
|
@ -193,7 +193,7 @@ def test_apply_threshold_accepts_list():
|
||||||
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
hmap = mcrfpy.HeightMap((10, 10), fill=0.5)
|
||||||
|
|
||||||
grid = mcrfpy.Grid(grid_size=(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))
|
||||||
layer.fill(-1)
|
layer.fill(-1)
|
||||||
|
|
||||||
# Use list instead of tuple
|
# Use list instead of tuple
|
||||||
|
|
@ -225,5 +225,11 @@ def run_all_tests():
|
||||||
|
|
||||||
|
|
||||||
# Run tests directly
|
# 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)
|
sys.exit(0)
|
||||||
|
|
|
||||||
|
|
@ -58,9 +58,13 @@ print(f" Modified a1.radius = {a1.radius}")
|
||||||
|
|
||||||
a1.start_angle = 30
|
a1.start_angle = 30
|
||||||
a1.end_angle = 120
|
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}")
|
print(f" Modified a1 angles: {a1.start_angle} to {a1.end_angle}")
|
||||||
|
|
||||||
a2.color = mcrfpy.Color(255, 0, 255) # Magenta
|
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")
|
print(f" Modified a2.color")
|
||||||
|
|
||||||
# Test 4: Test visibility and opacity
|
# 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)
|
color=mcrfpy.Color(255, 128, 0), thickness=3)
|
||||||
a5.opacity = 0.5
|
a5.opacity = 0.5
|
||||||
ui.append(a5)
|
ui.append(a5)
|
||||||
|
assert a5.opacity == 0.5, f"Expected opacity 0.5, got {a5.opacity}"
|
||||||
print(f" a5.opacity = {a5.opacity}")
|
print(f" a5.opacity = {a5.opacity}")
|
||||||
|
|
||||||
a6 = mcrfpy.Arc(center=(200, 250), radius=30, start_angle=0, end_angle=180,
|
a6 = mcrfpy.Arc(center=(200, 250), radius=30, start_angle=0, end_angle=180,
|
||||||
color=mcrfpy.Color(255, 128, 0), thickness=3)
|
color=mcrfpy.Color(255, 128, 0), thickness=3)
|
||||||
a6.visible = False
|
a6.visible = False
|
||||||
ui.append(a6)
|
ui.append(a6)
|
||||||
|
assert a6.visible is False, f"Expected visible False, got {a6.visible}"
|
||||||
print(f" a6.visible = {a6.visible}")
|
print(f" a6.visible = {a6.visible}")
|
||||||
|
|
||||||
# Test 5: Test z_index
|
# 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...")
|
print("\nTest 5: Testing z_index...")
|
||||||
a7 = mcrfpy.Arc(center=(350, 250), radius=50, start_angle=0, end_angle=270,
|
a7 = mcrfpy.Arc(center=(350, 250), radius=50, start_angle=0, end_angle=270,
|
||||||
color=mcrfpy.Color(0, 255, 255), thickness=10)
|
color=mcrfpy.Color(0, 255, 255), thickness=10)
|
||||||
a7.z_index = 100
|
|
||||||
ui.append(a7)
|
ui.append(a7)
|
||||||
|
a7.z_index = 100
|
||||||
|
|
||||||
a8 = mcrfpy.Arc(center=(370, 250), radius=40, start_angle=0, end_angle=270,
|
a8 = mcrfpy.Arc(center=(370, 250), radius=40, start_angle=0, end_angle=270,
|
||||||
color=mcrfpy.Color(255, 0, 255), thickness=8)
|
color=mcrfpy.Color(255, 0, 255), thickness=8)
|
||||||
a8.z_index = 50
|
|
||||||
ui.append(a8)
|
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}")
|
print(f" a7.z_index = {a7.z_index}, a8.z_index = {a8.z_index}")
|
||||||
|
|
||||||
# Test 6: Test name property
|
# 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}'"
|
assert a9.name == "test_arc", f"Expected name 'test_arc', got '{a9.name}'"
|
||||||
print(f" a9.name = '{a9.name}'")
|
print(f" a9.name = '{a9.name}'")
|
||||||
|
|
||||||
# Test 7: Test get_bounds
|
# Test 7: Test bounds
|
||||||
print("\nTest 7: Testing get_bounds...")
|
# NOTE: get_bounds() was replaced by the .bounds / .global_bounds properties.
|
||||||
bounds = a1.get_bounds()
|
# Each returns (offset: Vector, size: Vector); an arc's extent is the circle that
|
||||||
print(f" a1.get_bounds() = {bounds}")
|
# 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
|
# Test 8: Test move method
|
||||||
print("\nTest 8: Testing move method...")
|
print("\nTest 8: Testing move method...")
|
||||||
old_center = (a1.center.x, a1.center.y)
|
old_center = (a1.center.x, a1.center.y)
|
||||||
a1.move(10, 10)
|
a1.move(10, 10)
|
||||||
new_center = (a1.center.x, a1.center.y)
|
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}")
|
print(f" a1 moved from {old_center} to {new_center}")
|
||||||
|
|
||||||
# Test 9: Negative angle span (draws in reverse)
|
# 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,
|
a10 = mcrfpy.Arc(center=(100, 350), radius=40, start_angle=90, end_angle=0,
|
||||||
color=mcrfpy.Color(128, 255, 128), thickness=4)
|
color=mcrfpy.Color(128, 255, 128), thickness=4)
|
||||||
ui.append(a10)
|
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}")
|
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
|
# Render a frame and take screenshot
|
||||||
mcrfpy.step(0.01)
|
mcrfpy.step(0.01)
|
||||||
automation.screenshot("test_uiarc_result.png")
|
automation.screenshot("test_uiarc_result.png")
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import mcrfpy
|
||||||
from mcrfpy import automation
|
from mcrfpy import automation
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
def rgba(c):
|
||||||
|
return (c.r, c.g, c.b, c.a)
|
||||||
|
|
||||||
# Create a test scene
|
# Create a test scene
|
||||||
test = mcrfpy.Scene("test")
|
test = mcrfpy.Scene("test")
|
||||||
mcrfpy.current_scene = test
|
mcrfpy.current_scene = test
|
||||||
|
|
@ -55,8 +58,13 @@ print(f" c1.radius = {c1.radius}")
|
||||||
|
|
||||||
# Check center
|
# Check center
|
||||||
center = c2.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})")
|
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
|
# Test 3: Modify properties
|
||||||
print("\nTest 3: Modifying properties...")
|
print("\nTest 3: Modifying properties...")
|
||||||
c1.radius = 60
|
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}")
|
print(f" Modified c1.radius = {c1.radius}")
|
||||||
|
|
||||||
c2.fill_color = mcrfpy.Color(128, 0, 128) # Purple
|
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
|
# Test 4: Test visibility and opacity
|
||||||
print("\nTest 4: Testing 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 = mcrfpy.Circle(radius=25, center=(100, 200), fill_color=mcrfpy.Color(255, 128, 0))
|
||||||
c5.opacity = 0.5
|
c5.opacity = 0.5
|
||||||
ui.append(c5)
|
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}")
|
print(f" c5.opacity = {c5.opacity}")
|
||||||
|
|
||||||
c6 = mcrfpy.Circle(radius=25, center=(175, 200), fill_color=mcrfpy.Color(255, 128, 0))
|
c6 = mcrfpy.Circle(radius=25, center=(175, 200), fill_color=mcrfpy.Color(255, 128, 0))
|
||||||
c6.visible = False
|
c6.visible = False
|
||||||
ui.append(c6)
|
ui.append(c6)
|
||||||
|
assert c6.visible is False, f"Expected visible False, got {c6.visible}"
|
||||||
print(f" c6.visible = {c6.visible}")
|
print(f" c6.visible = {c6.visible}")
|
||||||
|
|
||||||
# Test 5: Test z_index
|
# 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...")
|
print("\nTest 5: Testing z_index...")
|
||||||
c7 = mcrfpy.Circle(radius=40, center=(300, 200), fill_color=mcrfpy.Color(0, 255, 255))
|
c7 = mcrfpy.Circle(radius=40, center=(300, 200), fill_color=mcrfpy.Color(0, 255, 255))
|
||||||
c7.z_index = 100
|
|
||||||
ui.append(c7)
|
ui.append(c7)
|
||||||
|
c7.z_index = 100
|
||||||
|
|
||||||
c8 = mcrfpy.Circle(radius=30, center=(320, 200), fill_color=mcrfpy.Color(255, 0, 255))
|
c8 = mcrfpy.Circle(radius=30, center=(320, 200), fill_color=mcrfpy.Color(255, 0, 255))
|
||||||
c8.z_index = 50
|
|
||||||
ui.append(c8)
|
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}")
|
print(f" c7.z_index = {c7.z_index}, c8.z_index = {c8.z_index}")
|
||||||
|
|
||||||
# Test 6: Test name property
|
# 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}'"
|
assert c9.name == "test_circle", f"Expected name 'test_circle', got '{c9.name}'"
|
||||||
print(f" c9.name = '{c9.name}'")
|
print(f" c9.name = '{c9.name}'")
|
||||||
|
|
||||||
# Test 7: Test get_bounds
|
# Test 7: Test bounds
|
||||||
print("\nTest 7: Testing get_bounds...")
|
# get_bounds() was replaced by the .bounds / .global_bounds properties.
|
||||||
bounds = c1.get_bounds()
|
print("\nTest 7: Testing bounds...")
|
||||||
print(f" c1.get_bounds() = {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
|
# Test 8: Test move method
|
||||||
print("\nTest 8: Testing move method...")
|
print("\nTest 8: Testing move method...")
|
||||||
old_center = (c1.center.x, c1.center.y)
|
old_center = (c1.center.x, c1.center.y)
|
||||||
c1.move(10, 10)
|
c1.move(10, 10)
|
||||||
new_center = (c1.center.x, c1.center.y)
|
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}")
|
print(f" c1 moved from {old_center} to {new_center}")
|
||||||
|
|
||||||
# Render a frame and take screenshot
|
# Render a frame and take screenshot
|
||||||
|
|
|
||||||
|
|
@ -4,31 +4,48 @@ Test Knowledge Stubs 1 Visibility System
|
||||||
========================================
|
========================================
|
||||||
|
|
||||||
Tests per-entity visibility tracking with perspective rendering.
|
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
|
import mcrfpy
|
||||||
|
from mcrfpy import automation
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
|
|
||||||
print("Knowledge Stubs 1 - Visibility System Test")
|
print("Knowledge Stubs 1 - Visibility System Test")
|
||||||
print("==========================================")
|
print("==========================================")
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
|
||||||
|
def check(condition, message):
|
||||||
|
if condition:
|
||||||
|
print(f" PASS: {message}")
|
||||||
|
else:
|
||||||
|
print(f" FAIL: {message}")
|
||||||
|
failures.append(message)
|
||||||
|
|
||||||
# Create scene and grid
|
# Create scene and grid
|
||||||
visibility_test = mcrfpy.Scene("visibility_test")
|
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
|
grid.fill_color = mcrfpy.Color(20, 20, 30) # Dark background
|
||||||
|
|
||||||
# Add a color layer for cell coloring
|
# 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
|
# Initialize grid - all walkable and transparent
|
||||||
print("\nInitializing 20x15 grid...")
|
print("\nInitializing 20x15 grid...")
|
||||||
for y in range(15):
|
for y in range(15):
|
||||||
for x in range(20):
|
for x in range(20):
|
||||||
cell = grid.at(x, y)
|
cell = grid.grid_data.at(x, y)
|
||||||
cell.walkable = True
|
cell.walkable = True
|
||||||
cell.transparent = 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
|
# Create some walls to block vision
|
||||||
print("Adding walls...")
|
print("Adding walls...")
|
||||||
|
|
@ -47,126 +64,152 @@ walls = [
|
||||||
|
|
||||||
for wall_group in walls:
|
for wall_group in walls:
|
||||||
for x, y in wall_group:
|
for x, y in wall_group:
|
||||||
cell = grid.at(x, y)
|
cell = grid.grid_data.at(x, y)
|
||||||
cell.walkable = False
|
cell.walkable = False
|
||||||
cell.transparent = 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
|
# Create entities
|
||||||
print("\nCreating entities...")
|
print("\nCreating entities...")
|
||||||
entities = [
|
entities = [
|
||||||
mcrfpy.Entity((2, 7)), # Left side
|
mcrfpy.Entity(grid_pos=(2, 7)), # Left side
|
||||||
mcrfpy.Entity((18, 7)), # Right side
|
mcrfpy.Entity(grid_pos=(18, 7)), # Right side
|
||||||
mcrfpy.Entity((10, 1)), # Top center (above wall)
|
mcrfpy.Entity(grid_pos=(10, 1)), # Top center (above wall)
|
||||||
]
|
]
|
||||||
|
|
||||||
for i, entity in enumerate(entities):
|
for i, entity in enumerate(entities):
|
||||||
entity.sprite_index = 64 + i # @, A, B
|
entity.sprite_index = 64 + i # @, A, B
|
||||||
grid.entities.append(entity)
|
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
|
# Test 1: Check initial perspective_map (was: gridstate)
|
||||||
print("\nTest 1: Initial gridstate")
|
print("\nTest 1: Initial perspective_map")
|
||||||
e0 = entities[0]
|
e0 = entities[0]
|
||||||
print(f" Entity 0 gridstate length: {len(e0.gridstate)}")
|
pm0 = e0.perspective_map
|
||||||
print(f" Expected: {20 * 15}")
|
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
|
# Test 2: Update visibility for each entity
|
||||||
print("\nTest 2: Updating visibility for each entity")
|
print("\nTest 2: Updating visibility for each entity")
|
||||||
|
counts = []
|
||||||
for i, entity in enumerate(entities):
|
for i, entity in enumerate(entities):
|
||||||
entity.update_visibility()
|
entity.update_visibility()
|
||||||
|
|
||||||
# Count visible/discovered cells
|
pm = entity.perspective_map
|
||||||
visible_count = sum(1 for state in entity.gridstate if state.visible)
|
# VISIBLE and DISCOVERED are exclusive states; "ever seen" = both.
|
||||||
discovered_count = sum(1 for state in entity.gridstate if state.discovered)
|
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")
|
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
|
# 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("\nTest 3: Testing perspective property")
|
||||||
print(f" Initial perspective: {grid.perspective}")
|
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}")
|
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:
|
try:
|
||||||
grid.perspective = 10 # Out of range
|
grid.perspective = 10 # Not an Entity
|
||||||
print(" ERROR: Should have raised exception for invalid perspective")
|
check(False, "invalid perspective should raise TypeError")
|
||||||
except IndexError as e:
|
except TypeError as e:
|
||||||
print(f" ✓ Correctly rejected invalid perspective: {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):
|
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
|
# Set scene and UI
|
||||||
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
|
|
||||||
ui = visibility_test.children
|
ui = visibility_test.children
|
||||||
ui.append(grid)
|
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 = mcrfpy.Caption(pos=(200, 10), text="Knowledge Stubs 1 - Visibility Test")
|
||||||
title.fill_color = mcrfpy.Color(255, 255, 255)
|
title.fill_color = mcrfpy.Color(255, 255, 255)
|
||||||
ui.append(title)
|
ui.append(title)
|
||||||
|
|
||||||
# Add info
|
info = mcrfpy.Caption(pos=(50, 520), text="Perspective: None (omniscient)")
|
||||||
info = mcrfpy.Caption(pos=(50, 520), text="Perspective: -1 (omniscient)")
|
|
||||||
info.fill_color = mcrfpy.Color(200, 200, 200)
|
info.fill_color = mcrfpy.Color(200, 200, 200)
|
||||||
ui.append(info)
|
ui.append(info)
|
||||||
|
|
||||||
# Add legend
|
|
||||||
legend = mcrfpy.Caption(pos=(50, 540), text="Black=Never seen, Dark gray=Discovered, Normal=Visible")
|
legend = mcrfpy.Caption(pos=(50, 540), text="Black=Never seen, Dark gray=Discovered, Normal=Visible")
|
||||||
legend.fill_color = mcrfpy.Color(150, 150, 150)
|
legend.fill_color = mcrfpy.Color(150, 150, 150)
|
||||||
ui.append(legend)
|
ui.append(legend)
|
||||||
|
|
||||||
# Set scene
|
|
||||||
visibility_test.activate()
|
visibility_test.activate()
|
||||||
|
|
||||||
# Set timer to cycle perspectives
|
cycle_timer = mcrfpy.Timer("cycle", visual_test, 100)
|
||||||
cycle_timer = mcrfpy.Timer("cycle", visual_test, 2000) # Every 2 seconds
|
# 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...")
|
check(seen_perspectives == cycle_order,
|
||||||
print("Perspectives will cycle: Omniscient → Entity 0 → Entity 1 → Entity 2 → Omniscient")
|
f"cycled through all perspectives (got {len(seen_perspectives)} of {len(cycle_order)})")
|
||||||
|
|
||||||
# Quick test to exit after screenshots
|
# Test 5: Movement and visibility update
|
||||||
def exit_timer_cb(timer, runtime):
|
print("\nTest 5: Movement and visibility update")
|
||||||
print("\nExiting after demo...")
|
entity = entities[0]
|
||||||
sys.exit(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)
|
FLOOR_COLOR = mcrfpy.Color(200, 200, 220)
|
||||||
PATH_COLOR = mcrfpy.Color(100, 255, 100)
|
PATH_COLOR = mcrfpy.Color(100, 255, 100)
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
timer_fired = False
|
||||||
|
|
||||||
# Create scene
|
# Create scene
|
||||||
visual_test = mcrfpy.Scene("visual_test")
|
visual_test = mcrfpy.Scene("visual_test")
|
||||||
|
|
||||||
# Create grid
|
# 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)
|
grid.fill_color = mcrfpy.Color(0, 0, 0)
|
||||||
|
|
||||||
# Add color layer for cell coloring
|
# Add color layer for cell coloring (GridPoint has no .color anymore; use a ColorLayer)
|
||||||
color_layer = grid.add_layer("color", z_index=-1)
|
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
|
||||||
|
grid.add_layer(color_layer)
|
||||||
|
|
||||||
def check_render(timer, runtime):
|
def check_render(timer, runtime):
|
||||||
"""Timer callback to verify rendering"""
|
"""Timer callback to verify rendering"""
|
||||||
|
global timer_fired
|
||||||
|
timer_fired = True
|
||||||
print(f"\nTimer fired after {runtime}ms")
|
print(f"\nTimer fired after {runtime}ms")
|
||||||
|
|
||||||
# Take screenshot
|
# Take screenshot (this is what forces a render in headless mode)
|
||||||
from mcrfpy import automation
|
from mcrfpy import automation
|
||||||
automation.screenshot("visual_path_test.png")
|
automation.screenshot("visual_path_test.png")
|
||||||
print("Screenshot saved as 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
|
# Sample some path cells to verify colors
|
||||||
print("\nSampling path cell colors from grid:")
|
print("\nSampling path cell colors from grid:")
|
||||||
for x, y in [(1, 1), (2, 2), (3, 3)]:
|
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})")
|
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
|
# Initialize all cells as floor
|
||||||
print("Initializing grid...")
|
print("Initializing grid...")
|
||||||
for y in range(5):
|
for y in range(5):
|
||||||
for x in range(5):
|
for x in range(5):
|
||||||
grid.at(x, y).walkable = True
|
grid.at(x, y).walkable = True
|
||||||
color_layer.set(x, y, FLOOR_COLOR)
|
color_layer.set((x, y), FLOOR_COLOR)
|
||||||
|
|
||||||
# Create entities
|
# Create entities
|
||||||
e1 = mcrfpy.Entity((0, 0), grid=grid)
|
e1 = mcrfpy.Entity(grid_pos=(0, 0))
|
||||||
e2 = mcrfpy.Entity((4, 4), grid=grid)
|
e2 = mcrfpy.Entity(grid_pos=(4, 4))
|
||||||
|
grid.entities.append(e1)
|
||||||
|
grid.entities.append(e2)
|
||||||
e1.sprite_index = 64 # @
|
e1.sprite_index = 64 # @
|
||||||
e2.sprite_index = 69 # E
|
e2.sprite_index = 69 # E
|
||||||
|
|
||||||
print(f"Entity 1 at ({e1.x}, {e1.y})")
|
# .x/.y are pixel coords now; .cell_pos is the logical cell
|
||||||
print(f"Entity 2 at ({e2.x}, {e2.y})")
|
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
|
# 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}")
|
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
|
# Color the path
|
||||||
if path:
|
if path:
|
||||||
print("\nColoring path cells green...")
|
print("\nColoring path cells green...")
|
||||||
for x, y in path:
|
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")
|
print(f" Set ({x},{y}) to green")
|
||||||
|
|
||||||
# Set up UI
|
# Set up UI
|
||||||
ui = visual_test.children
|
ui = visual_test.children
|
||||||
ui.append(grid)
|
ui.append(grid)
|
||||||
grid.pos = (50, 50)
|
|
||||||
grid.size = (250, 250)
|
|
||||||
|
|
||||||
# Add title
|
# Add title
|
||||||
title = mcrfpy.Caption(pos=(50, 10), text="Path Visualization Test")
|
title = mcrfpy.Caption(pos=(50, 10), text="Path Visualization Test")
|
||||||
|
|
@ -81,3 +106,18 @@ visual_test.activate()
|
||||||
check_timer = mcrfpy.Timer("check", check_render, 500, once=True)
|
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,10 +1,19 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Test for Entity class - Related to issue #73 (index() method)"""
|
"""Test for Entity class - Related to issue #73 (index() method)"""
|
||||||
import mcrfpy
|
import mcrfpy
|
||||||
from datetime import datetime
|
import sys
|
||||||
|
|
||||||
print("Test script starting...")
|
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():
|
def test_Entity():
|
||||||
"""Test Entity class and index() method for collection removal"""
|
"""Test Entity class and index() method for collection removal"""
|
||||||
# Create test scene with grid
|
# Create test scene with grid
|
||||||
|
|
@ -12,105 +21,103 @@ def test_Entity():
|
||||||
entity_test.activate()
|
entity_test.activate()
|
||||||
ui = entity_test.children
|
ui = entity_test.children
|
||||||
|
|
||||||
# Create a grid
|
# Create a grid (current ctor: grid_size/pos/size keywords)
|
||||||
grid = mcrfpy.Grid(10, 10,
|
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
|
||||||
mcrfpy.default_texture,
|
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(10, 10), size=(400, 400),
|
||||||
mcrfpy.Vector(10, 10),
|
texture=texture)
|
||||||
mcrfpy.Vector(400, 400))
|
|
||||||
ui.append(grid)
|
ui.append(grid)
|
||||||
entities = grid.entities
|
entities = grid.entities
|
||||||
|
|
||||||
# Create multiple entities
|
# Create multiple entities (current ctor: grid_pos=, texture=, sprite_index=)
|
||||||
entity1 = mcrfpy.Entity(mcrfpy.Vector(2, 2), mcrfpy.default_texture, 0, grid)
|
entity1 = mcrfpy.Entity(grid_pos=(2, 2), texture=texture, sprite_index=0)
|
||||||
entity2 = mcrfpy.Entity(mcrfpy.Vector(5, 5), mcrfpy.default_texture, 1, grid)
|
entity2 = mcrfpy.Entity(grid_pos=(5, 5), texture=texture, sprite_index=1)
|
||||||
entity3 = mcrfpy.Entity(mcrfpy.Vector(7, 7), mcrfpy.default_texture, 2, grid)
|
entity3 = mcrfpy.Entity(grid_pos=(7, 7), texture=texture, sprite_index=2)
|
||||||
|
|
||||||
entities.append(entity1)
|
entities.append(entity1)
|
||||||
entities.append(entity2)
|
entities.append(entity2)
|
||||||
entities.append(entity3)
|
entities.append(entity3)
|
||||||
|
|
||||||
print(f"Created {len(entities)} entities")
|
print(f"Created {len(entities)} entities")
|
||||||
|
check(len(entities) == 3, "3 entities in collection after append")
|
||||||
|
|
||||||
# Test entity properties
|
# Test entity properties
|
||||||
try:
|
print(f" Entity1 grid_pos: {entity1.grid_pos}")
|
||||||
print(f" Entity1 pos: {entity1.pos}")
|
print(f" Entity1 pos: {entity1.pos}")
|
||||||
print(f" Entity1 draw_pos: {entity1.draw_pos}")
|
print(f" Entity1 draw_pos: {entity1.draw_pos}")
|
||||||
print(f" Entity1 sprite_index: {entity1.sprite_index}")
|
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
|
# Modify properties
|
||||||
entity1.pos = mcrfpy.Vector(3, 3)
|
entity1.grid_pos = mcrfpy.Vector(3, 3)
|
||||||
entity1.sprite_index = 5
|
entity1.sprite_index = 5
|
||||||
print(" Entity properties modified")
|
check((entity1.grid_pos.x, entity1.grid_pos.y) == (3, 3),
|
||||||
except Exception as e:
|
"entity1.grid_pos writable")
|
||||||
print(f"X Entity property access failed: {e}")
|
check(entity1.sprite_index == 5, "entity1.sprite_index writable")
|
||||||
|
|
||||||
# Test gridstate access
|
# Entity association with a grid: entity.grid is the shared GridData
|
||||||
try:
|
# (current contract, #313/#361 -- it is NOT the Grid view object)
|
||||||
gridstate = entity2.gridstate
|
check(isinstance(entity1.grid, mcrfpy.GridData),
|
||||||
print(" Entity gridstate accessible")
|
"entity.grid returns the GridData")
|
||||||
|
check(entity1.grid is grid.grid_data,
|
||||||
|
"entity.grid is the same GridData as grid.grid_data")
|
||||||
|
|
||||||
# Test at() method
|
# Test perspective/visibility access (was: entity.gridstate)
|
||||||
point_state = entity2.at()#.at(0, 0)
|
pmap = entity2.perspective_map
|
||||||
print(" Entity at() method works")
|
check(pmap is not None, "Entity perspective_map accessible")
|
||||||
except Exception as e:
|
entity2.update_visibility()
|
||||||
print(f"X Entity gridstate/at() failed: {e}")
|
|
||||||
|
# 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)
|
# Test index() method (Issue #73)
|
||||||
print("\nTesting index() method (Issue #73)...")
|
print("\nTesting index() method (Issue #73)...")
|
||||||
try:
|
index = entity2.index()
|
||||||
# Try to find entity2's index
|
print(f":) index() method works: entity2 is at index {index}")
|
||||||
index = entity2.index()
|
check(index == 1, "entity2.index() == 1")
|
||||||
print(f":) index() method works: entity2 is at index {index}")
|
check(entities[index] == entity2, "Index is correct (entities[index] is entity2)")
|
||||||
|
|
||||||
# Verify by checking collection
|
# Remove using the index reported by index() (Issue #73's purpose)
|
||||||
if entities[index] == entity2:
|
removed = entities.pop(index)
|
||||||
print("✓ Index is correct")
|
check(removed == entity2, "pop(index) returned entity2")
|
||||||
else:
|
check(len(entities) == 2, f"Removed entity using index, now {len(entities)} entities")
|
||||||
print("✗ Index mismatch")
|
check(entity1.index() == 0 and entity3.index() == 1,
|
||||||
|
"index() reflects collection after removal")
|
||||||
# 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}")
|
|
||||||
|
|
||||||
# Test EntityCollection iteration
|
# Test EntityCollection iteration
|
||||||
try:
|
positions = [e.grid_pos for e in entities]
|
||||||
positions = []
|
check(len(positions) == 2, f"Entity iteration works: {len(positions)} entities")
|
||||||
for entity in entities:
|
check([(p.x, p.y) for p in positions] == [(3, 3), (7, 7)],
|
||||||
positions.append(entity.pos)
|
"Iteration yields the surviving entities in order")
|
||||||
print(f":) Entity iteration works: {len(positions)} entities")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"X Entity iteration failed: {e}")
|
|
||||||
|
|
||||||
# Test EntityCollection extend (Issue #27)
|
# Test EntityCollection extend (Issue #27)
|
||||||
try:
|
new_entities = [
|
||||||
new_entities = [
|
mcrfpy.Entity(grid_pos=(1, 1), texture=texture, sprite_index=3),
|
||||||
mcrfpy.Entity(mcrfpy.Vector(1, 1), mcrfpy.default_texture, 3, grid),
|
mcrfpy.Entity(grid_pos=(9, 9), texture=texture, sprite_index=4)
|
||||||
mcrfpy.Entity(mcrfpy.Vector(9, 9), mcrfpy.default_texture, 4, grid)
|
]
|
||||||
]
|
entities.extend(new_entities)
|
||||||
entities.extend(new_entities)
|
check(len(entities) == 4, f"extend() method works: now {len(entities)} entities")
|
||||||
print(f":) extend() method works: now {len(entities)} entities")
|
check(new_entities[1].index() == 3, "extended entity reports its index()")
|
||||||
except AttributeError:
|
|
||||||
print("✗ extend() method not implemented (Issue #27)")
|
# remove(entity) is the object-based counterpart to index()/pop()
|
||||||
except Exception as e:
|
entities.remove(entity3)
|
||||||
print(f"X extend() method error: {e}")
|
check(len(entities) == 3, "remove(entity) drops the entity")
|
||||||
|
|
||||||
# Skip screenshot in headless mode
|
|
||||||
print("PASS")
|
|
||||||
|
|
||||||
# Run test immediately in headless mode
|
# Run test immediately in headless mode
|
||||||
print("Running test immediately...")
|
print("Running test immediately...")
|
||||||
test_Entity()
|
test_Entity()
|
||||||
print("Test completed.")
|
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
|
#!/usr/bin/env python3
|
||||||
"""Test for Sprite texture methods - Related to issue #19"""
|
"""Test for Sprite texture methods - Related to issue #19"""
|
||||||
import mcrfpy
|
import mcrfpy
|
||||||
|
import sys
|
||||||
|
|
||||||
print("Testing Sprite texture methods (Issue #19)...")
|
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
|
# Create test scene
|
||||||
sprite_texture_test = mcrfpy.Scene("sprite_texture_test")
|
sprite_texture_test = mcrfpy.Scene("sprite_texture_test")
|
||||||
sprite_texture_test.activate()
|
sprite_texture_test.activate()
|
||||||
ui = sprite_texture_test.children
|
ui = sprite_texture_test.children
|
||||||
|
|
||||||
# Create sprites
|
# Create sprites
|
||||||
# Based on sprite2 syntax: Sprite(x, y, texture, sprite_index, scale)
|
# Current Sprite ctor: Sprite(pos=None, texture=None, sprite_index=0, **kwargs)
|
||||||
sprite1 = mcrfpy.Sprite(10, 10, mcrfpy.default_texture, 0, 2.0)
|
sprite1 = mcrfpy.Sprite(pos=(10, 10), texture=mcrfpy.default_texture, sprite_index=0, scale=2.0)
|
||||||
sprite2 = mcrfpy.Sprite(100, 10, mcrfpy.default_texture, 5, 2.0)
|
sprite2 = mcrfpy.Sprite(pos=(100, 10), texture=mcrfpy.default_texture, sprite_index=5, scale=2.0)
|
||||||
|
|
||||||
ui.append(sprite1)
|
ui.append(sprite1)
|
||||||
ui.append(sprite2)
|
ui.append(sprite2)
|
||||||
|
|
||||||
|
check(len(ui) == 2, "both sprites appended to the scene")
|
||||||
|
|
||||||
# Test getting texture
|
# Test getting texture
|
||||||
try:
|
texture1 = sprite1.texture
|
||||||
texture1 = sprite1.texture
|
texture2 = sprite2.texture
|
||||||
texture2 = sprite2.texture
|
print(f"Got textures: {texture1}, {texture2}")
|
||||||
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")
|
||||||
|
|
||||||
if texture2 == mcrfpy.default_texture:
|
# Test setting texture (Issue #19 asked for a texture setter; it now EXISTS,
|
||||||
print("✓ Texture matches default_texture")
|
# so the old "texture must be read-only" expectation is inverted here.)
|
||||||
except Exception as e:
|
other_texture = mcrfpy.Texture("assets/kenney_TD_MR_IP.png", 16, 16)
|
||||||
print(f"✗ Failed to get texture: {e}")
|
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")
|
||||||
|
|
||||||
# Test setting texture (Issue #19 - get/set texture methods)
|
# Restore the default texture
|
||||||
try:
|
sprite1.texture = mcrfpy.default_texture
|
||||||
# This should fail as texture is read-only currently
|
check(sprite1.texture.source == mcrfpy.default_texture.source,
|
||||||
sprite1.texture = mcrfpy.default_texture
|
"Sprite.texture setter restores the 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 sprite_index property
|
# Test sprite_index property
|
||||||
try:
|
print(f"Sprite2 sprite_index: {sprite2.sprite_index}")
|
||||||
print(f"Sprite2 sprite_index: {sprite2.sprite_index}")
|
check(sprite2.sprite_index == 5, "sprite_index reflects the constructor argument")
|
||||||
sprite2.sprite_index = 10
|
sprite2.sprite_index = 10
|
||||||
print(f"sprite_index set to: {sprite2.sprite_index}")
|
print(f"sprite_index set to: {sprite2.sprite_index}")
|
||||||
except Exception as e:
|
check(sprite2.sprite_index == 10, "sprite_index setter takes effect")
|
||||||
print(f"sprite_index property failed: {e}")
|
|
||||||
|
|
||||||
# Test sprite index validation (Issue #33)
|
# Test sprite index validation (Issue #33)
|
||||||
try:
|
try:
|
||||||
# Try to set invalid sprite index
|
|
||||||
sprite2.sprite_index = 9999
|
sprite2.sprite_index = 9999
|
||||||
print("Should validate sprite index against texture range (Issue #33)")
|
check(False, "out-of-range sprite_index must raise (Issue #33)")
|
||||||
except Exception as e:
|
except ValueError as e:
|
||||||
print(f"Sprite index validation works: {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
|
# Create grid of sprites to show different indices
|
||||||
y_offset = 100
|
y_offset = 100
|
||||||
for i in range(12): # Show first 12 sprites
|
for i in range(12): # Show first 12 sprites
|
||||||
sprite = mcrfpy.Sprite(10 + (i % 6) * 40, y_offset + (i // 6) * 40,
|
sprite = mcrfpy.Sprite(pos=(10 + (i % 6) * 40, y_offset + (i // 6) * 40),
|
||||||
mcrfpy.default_texture, i, 2.0)
|
texture=mcrfpy.default_texture, sprite_index=i, scale=2.0)
|
||||||
ui.append(sprite)
|
ui.append(sprite)
|
||||||
|
|
||||||
caption = mcrfpy.Caption(mcrfpy.Vector(10, 200),
|
check(len(ui) == 14, "12 additional sprites appended")
|
||||||
text="Issue #19: Sprites need texture setter",
|
check(all(ui[2 + i].sprite_index == i for i in range(12)),
|
||||||
fill_color=mcrfpy.Color(255, 255, 255))
|
"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)
|
ui.append(caption)
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
print(f"FAILED ({len(failures)} checks): {failures}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
print("PASS")
|
print("PASS")
|
||||||
|
sys.exit(0)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,17 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Test for UICollection - Related to issue #69 (Sequence Protocol)"""
|
"""Test for UICollection - Related to issue #69 (Sequence Protocol)"""
|
||||||
import mcrfpy
|
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():
|
def test_UICollection():
|
||||||
"""Test UICollection sequence protocol compliance"""
|
"""Test UICollection sequence protocol compliance"""
|
||||||
|
|
@ -13,6 +23,7 @@ def test_UICollection():
|
||||||
# Add various UI elements
|
# Add various UI elements
|
||||||
frame = mcrfpy.Frame(pos=(10, 10), size=(100, 100))
|
frame = mcrfpy.Frame(pos=(10, 10), size=(100, 100))
|
||||||
caption = mcrfpy.Caption(pos=(120, 10), text="Test")
|
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
|
# Skip sprite for now since it requires a texture
|
||||||
|
|
||||||
ui.append(frame)
|
ui.append(frame)
|
||||||
|
|
@ -21,84 +32,57 @@ def test_UICollection():
|
||||||
print("Testing UICollection sequence protocol (Issue #69)...")
|
print("Testing UICollection sequence protocol (Issue #69)...")
|
||||||
|
|
||||||
# Test len()
|
# Test len()
|
||||||
try:
|
check("len() works", len(ui) == 2, f"expected 2, got {len(ui)}")
|
||||||
length = len(ui)
|
|
||||||
print(f"✓ len() works: {length} items")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ len() failed: {e}")
|
|
||||||
|
|
||||||
# Test indexing
|
# Test indexing
|
||||||
try:
|
check("indexing works",
|
||||||
item0 = ui[0]
|
type(ui[0]).__name__ == "Frame" and type(ui[1]).__name__ == "Caption",
|
||||||
item1 = ui[1]
|
f"got [{type(ui[0]).__name__}, {type(ui[1]).__name__}]")
|
||||||
print(f"✓ Indexing works: [{type(item0).__name__}, {type(item1).__name__}]")
|
|
||||||
|
|
||||||
# Test negative indexing
|
# Test negative indexing
|
||||||
last_item = ui[-1]
|
check("negative indexing works", type(ui[-1]).__name__ == "Caption",
|
||||||
print(f"✓ Negative indexing works: ui[-1] = {type(last_item).__name__}")
|
f"ui[-1] = {type(ui[-1]).__name__}")
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ Indexing failed: {e}")
|
|
||||||
|
|
||||||
# Test slicing (if implemented)
|
# Test slicing
|
||||||
try:
|
slice_items = ui[0:2]
|
||||||
slice_items = ui[0:2]
|
check("slicing works", len(slice_items) == 2, f"got {len(slice_items)} items")
|
||||||
print(f"✓ Slicing works: got {len(slice_items)} items")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ Slicing not implemented (Issue #69): {e}")
|
|
||||||
|
|
||||||
# Test iteration
|
# Test iteration
|
||||||
try:
|
types = [type(item).__name__ for item in ui]
|
||||||
types = []
|
check("iteration works", types == ["Frame", "Caption"], f"got {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
|
# Test contains
|
||||||
try:
|
check("'in' operator works", frame in ui, "'in' returned False for existing item")
|
||||||
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
|
# Test remove -- current API takes the Drawable itself (list.remove semantics),
|
||||||
try:
|
# not an index. pop(index) is the index-based removal.
|
||||||
ui.remove(1) # Remove caption
|
ui.remove(caption)
|
||||||
print(f"✓ remove() works, now {len(ui)} items")
|
check("remove(element) works", len(ui) == 1 and caption not in ui,
|
||||||
except Exception as e:
|
f"now {len(ui)} items")
|
||||||
print(f"✗ remove() failed: {e}")
|
|
||||||
|
|
||||||
# Test type preservation (Issue #76)
|
# Test type preservation (Issue #76)
|
||||||
try:
|
parent_frame = mcrfpy.Frame(pos=(250, 10), size=(200, 200),
|
||||||
# Add a frame with children to test nested collections
|
fill_color=mcrfpy.Color(200, 200, 200))
|
||||||
parent_frame = mcrfpy.Frame(pos=(250, 10), size=(200, 200),
|
child_caption = mcrfpy.Caption(pos=(10, 10), text="Child")
|
||||||
fill_color=mcrfpy.Color(200, 200, 200))
|
parent_frame.children.append(child_caption)
|
||||||
child_caption = mcrfpy.Caption(pos=(10, 10), text="Child")
|
ui.append(parent_frame)
|
||||||
parent_frame.children.append(child_caption)
|
|
||||||
ui.append(parent_frame)
|
|
||||||
|
|
||||||
# Check if type is preserved when retrieving
|
retrieved = ui[-1]
|
||||||
retrieved = ui[-1]
|
check("type preservation works", type(retrieved).__name__ == "Frame",
|
||||||
if type(retrieved).__name__ == "Frame":
|
f"got {type(retrieved).__name__} (Issue #76)")
|
||||||
print("✓ Type preservation works")
|
check("retrieved object identity preserved", retrieved is parent_frame)
|
||||||
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)
|
# Test find by name (Issue #41)
|
||||||
try:
|
parent_frame.name = "parent"
|
||||||
found = ui.find("Test")
|
found = ui.find("parent")
|
||||||
print(f"✓ find() method works: {type(found).__name__}")
|
check("find() by name works", found is parent_frame,
|
||||||
except AttributeError:
|
f"got {found!r}")
|
||||||
print("✗ find() method not implemented (Issue #41)")
|
check("find() returns None for missing name", ui.find("nonexistent") is None)
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ find() method error: {e}")
|
|
||||||
|
|
||||||
print("PASS")
|
|
||||||
|
|
||||||
# Run test immediately
|
|
||||||
test_UICollection()
|
test_UICollection()
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
print(f"FAIL ({len(failures)} check(s) failed)")
|
||||||
|
sys.exit(1)
|
||||||
|
print("PASS")
|
||||||
|
sys.exit(0)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,102 @@
|
||||||
#!/usr/bin/env python3
|
#!/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
|
import mcrfpy
|
||||||
from mcrfpy import automation
|
from mcrfpy import automation
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
import sys
|
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():
|
def test_screenshot_validation():
|
||||||
"""Create visible content and validate screenshot output"""
|
"""Create visible content and validate screenshot output"""
|
||||||
|
|
@ -14,103 +107,143 @@ def test_screenshot_validation():
|
||||||
screenshot_validation.activate()
|
screenshot_validation.activate()
|
||||||
ui = screenshot_validation.children
|
ui = screenshot_validation.children
|
||||||
|
|
||||||
# Create multiple colorful elements to ensure visibility
|
|
||||||
print("Creating UI elements...")
|
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
|
# Bright red frame with white outline
|
||||||
frame1 = mcrfpy.Frame(pos=(50, 50), size=(300, 200),
|
frame1 = mcrfpy.Frame(pos=(50, 50), size=(300, 200),
|
||||||
fill_color=mcrfpy.Color(255, 0, 0), # Bright red
|
fill_color=mcrfpy.Color(255, 0, 0), # Bright red
|
||||||
outline_color=mcrfpy.Color(255, 255, 255), # White
|
outline_color=mcrfpy.Color(255, 255, 255), # White
|
||||||
outline=5.0)
|
outline=5.0)
|
||||||
ui.append(frame1)
|
ui.append(frame1)
|
||||||
print("Added red frame at (50, 50)")
|
print("Added red frame at (50, 50)")
|
||||||
|
|
||||||
# Bright green frame
|
# Bright green frame
|
||||||
frame2 = mcrfpy.Frame(pos=(400, 50), size=(300, 200),
|
frame2 = mcrfpy.Frame(pos=(400, 50), size=(300, 200),
|
||||||
fill_color=mcrfpy.Color(0, 255, 0), # Bright green
|
fill_color=mcrfpy.Color(0, 255, 0), # Bright green
|
||||||
outline_color=mcrfpy.Color(0, 0, 0), # Black
|
outline_color=mcrfpy.Color(0, 0, 0), # Black
|
||||||
outline=3.0)
|
outline=3.0)
|
||||||
ui.append(frame2)
|
ui.append(frame2)
|
||||||
print("Added green frame at (400, 50)")
|
print("Added green frame at (400, 50)")
|
||||||
|
|
||||||
# Blue frame
|
# Blue frame
|
||||||
frame3 = mcrfpy.Frame(pos=(50, 300), size=(300, 200),
|
frame3 = mcrfpy.Frame(pos=(50, 300), size=(300, 200),
|
||||||
fill_color=mcrfpy.Color(0, 0, 255), # Bright blue
|
fill_color=mcrfpy.Color(0, 0, 255), # Bright blue
|
||||||
outline_color=mcrfpy.Color(255, 255, 0), # Yellow
|
outline_color=mcrfpy.Color(255, 255, 0), # Yellow
|
||||||
outline=4.0)
|
outline=4.0)
|
||||||
ui.append(frame3)
|
ui.append(frame3)
|
||||||
print("Added blue frame at (50, 300)")
|
print("Added blue frame at (50, 300)")
|
||||||
|
|
||||||
# Add text captions
|
# Add text captions (Caption.font is read-only as of #320 -- set in ctor)
|
||||||
caption1 = mcrfpy.Caption(pos=(60, 60),
|
caption1 = mcrfpy.Caption(pos=(10, 10), font=mcrfpy.default_font,
|
||||||
text="RED FRAME TEST",
|
text="RED FRAME TEST",
|
||||||
fill_color=mcrfpy.Color(255, 255, 255))
|
fill_color=mcrfpy.Color(255, 255, 255))
|
||||||
caption1.font_size = 24
|
caption1.font_size = 24
|
||||||
frame1.children.append(caption1)
|
frame1.children.append(caption1) # child coords are frame-relative
|
||||||
|
|
||||||
caption2 = mcrfpy.Caption(pos=(410, 60),
|
caption2 = mcrfpy.Caption(pos=(410, 60), font=mcrfpy.default_font,
|
||||||
text="GREEN FRAME TEST",
|
text="GREEN FRAME TEST",
|
||||||
fill_color=mcrfpy.Color(0, 0, 0))
|
fill_color=mcrfpy.Color(0, 0, 0))
|
||||||
caption2.font_size = 24
|
caption2.font_size = 24
|
||||||
ui.append(caption2)
|
ui.append(caption2)
|
||||||
|
|
||||||
caption3 = mcrfpy.Caption(pos=(60, 310),
|
caption3 = mcrfpy.Caption(pos=(60, 310), font=mcrfpy.default_font,
|
||||||
text="BLUE FRAME TEST",
|
text="BLUE FRAME TEST",
|
||||||
fill_color=mcrfpy.Color(255, 255, 0))
|
fill_color=mcrfpy.Color(255, 255, 0))
|
||||||
caption3.font_size = 24
|
caption3.font_size = 24
|
||||||
ui.append(caption3)
|
ui.append(caption3)
|
||||||
|
|
||||||
# White background frame to ensure non-transparent background
|
print("\nTotal UI elements: %d" % len(ui))
|
||||||
background = mcrfpy.Frame(pos=(0, 0), size=(1024, 768),
|
check(len(ui) == 6, "expected 6 top-level drawables, got %d" % len(ui))
|
||||||
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 (incl. spaces in filename)
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
# Take multiple screenshots with different names
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
screenshots = [
|
screenshots = [
|
||||||
f"validate_screenshot_basic_{timestamp}.png",
|
os.path.join(OUT_DIR, "validate_screenshot_basic_%s.png" % timestamp),
|
||||||
f"validate_screenshot_with_spaces {timestamp}.png",
|
os.path.join(OUT_DIR, "validate_screenshot_with_spaces %s.png" % timestamp),
|
||||||
f"validate_screenshot_final_{timestamp}.png"
|
os.path.join(OUT_DIR, "validate_screenshot_final_%s.png" % timestamp),
|
||||||
]
|
]
|
||||||
|
|
||||||
print("\nTaking screenshots...")
|
print("\nTaking screenshots...")
|
||||||
for i, filename in enumerate(screenshots):
|
for i, filename in enumerate(screenshots):
|
||||||
result = automation.screenshot(filename)
|
result = automation.screenshot(filename)
|
||||||
print(f"Screenshot {i+1}: {filename} - Result: {result}")
|
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)
|
||||||
|
|
||||||
# Test invalid cases
|
# --- 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...")
|
print("\nTesting edge cases...")
|
||||||
|
|
||||||
# Empty filename
|
# Empty filename: must fail cleanly (False), not raise or write anything
|
||||||
result = automation.screenshot("")
|
result = automation.screenshot("")
|
||||||
print(f"Empty filename result: {result}")
|
print("Empty filename result: %s" % result)
|
||||||
|
check(result is False, "screenshot('') should return False, got %r" % result)
|
||||||
|
|
||||||
# Very long filename
|
# Very long (but legal, <255) filename: must succeed and produce a file
|
||||||
long_name = "x" * 200 + ".png"
|
long_name = os.path.join(OUT_DIR, "x" * 200 + ".png")
|
||||||
result = automation.screenshot(long_name)
|
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("\n=== Test Complete ===")
|
||||||
print("Check the PNG files to see if they contain visible content.")
|
if FAILURES:
|
||||||
print("If they're transparent, the headless renderer may not be working correctly.")
|
print("\n%d FAILURE(S):" % len(FAILURES))
|
||||||
|
for f in FAILURES:
|
||||||
# List what should be visible
|
print(" - %s" % f)
|
||||||
print("\nExpected content:")
|
sys.exit(1)
|
||||||
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")
|
|
||||||
|
|
||||||
|
print("PASS")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
# Run the test immediately
|
|
||||||
|
# Run the test immediately (no timer needed: headless screenshots are synchronous)
|
||||||
test_screenshot_validation()
|
test_screenshot_validation()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue