McRogueFace/tests/integration/astar_vs_dijkstra.py
John McCardle 112f3571f5 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
2026-07-14 07:29:23 -04:00

293 lines
11 KiB
Python

#!/usr/bin/env python3
"""
A* vs Dijkstra Comparison
=========================
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 sys
# Colors
WALL_COLOR = mcrfpy.Color(40, 20, 20)
FLOOR_COLOR = mcrfpy.Color(60, 60, 80)
ASTAR_COLOR = mcrfpy.Color(0, 255, 0) # Green for A*
DIJKSTRA_COLOR = mcrfpy.Color(0, 150, 255) # Blue for Dijkstra
START_COLOR = mcrfpy.Color(255, 100, 100) # Red for start
END_COLOR = mcrfpy.Color(255, 255, 100) # Yellow for end
GRID_W, GRID_H = 30, 20
# Global state
grid = None
color_layer = None
start_pos = (5, 10)
end_pos = (27, 10) # Changed from 25 to 27 to avoid the wall
failures = []
def check(condition, message):
"""Record a failed assertion instead of aborting, so we report every problem."""
if condition:
print(f" PASS: {message}")
else:
print(f" FAIL: {message}")
failures.append(message)
def create_map():
"""Create a map with obstacles to show pathfinding differences"""
global grid, color_layer
# Create grid (view + backing GridData)
grid = mcrfpy.Grid(grid_size=(GRID_W, GRID_H), pos=(100, 100), size=(600, 400))
grid.fill_color = mcrfpy.Color(0, 0, 0)
# Add color layer for cell coloring (GridPoint.color no longer exists)
color_layer = mcrfpy.ColorLayer(name="color", z_index=-1)
grid.grid_data.add_layer(color_layer)
# Initialize all as floor
for y in range(GRID_H):
for x in range(GRID_W):
grid.grid_data.at(x, y).walkable = True
color_layer.set((x, y), FLOOR_COLOR)
# Create obstacles that make A* and Dijkstra differ
obstacles = [
# Vertical wall with gaps
[(15, y) for y in range(3, 17) if y not in [8, 12]],
# Horizontal walls
[(x, 5) for x in range(10, 20)],
[(x, 15) for x in range(10, 20)],
# Maze-like structure
[(x, 10) for x in range(20, 25)],
[(25, y) for y in range(5, 15)],
]
walls = set()
for obstacle_group in obstacles:
for x, y in obstacle_group:
grid.grid_data.at(x, y).walkable = False
color_layer.set((x, y), WALL_COLOR)
walls.add((x, y))
# Mark start and end
color_layer.set(start_pos, START_COLOR)
color_layer.set(end_pos, END_COLOR)
# Dijkstra maps are cached; walkability just changed, so invalidate.
grid.grid_data.clear_dijkstra_maps()
return walls
def validate_path(cells, label, origin, destination):
"""A path must be contiguous, walkable, and actually arrive at `destination`.
Both find_path() and DijkstraMap.path_from() use the same convention (#375):
the returned path EXCLUDES the origin and ENDS at the destination. The two
differ only in which endpoint is which -- A* is asked start->end, while a
Dijkstra map rooted at start is queried from end, so it walks end->start.
"""
check(len(cells) > 0, f"{label}: path is non-empty")
if not cells:
return
check(cells[-1] == destination,
f"{label}: path ends at {destination} (got {cells[-1]})")
check(origin not in cells, f"{label}: path excludes its origin {origin}")
# The first cell must be a single step from the origin.
prev = origin
contiguous = True
walkable = True
for (x, y) in cells:
dx, dy = abs(x - prev[0]), abs(y - prev[1])
if max(dx, dy) != 1:
contiguous = False
if not grid.grid_data.at(x, y).walkable:
walkable = False
prev = (x, y)
check(contiguous, f"{label}: every step moves exactly one cell (incl. from start)")
check(walkable, f"{label}: every cell on the path is walkable")
def path_cells(path):
return [(int(v.x), int(v.y)) for v in path]
def show_astar():
"""Compute + color the A* path. Returns its cells."""
path = grid.grid_data.find_path(start_pos, end_pos)
check(path is not None, "A*: a path exists through the obstacle course")
if path is None:
return []
check((int(path.destination.x), int(path.destination.y)) == end_pos,
"A*: .destination is the requested end cell")
# NOTE: AStarPath is a *consuming* iterator -- iterating it walks the path,
# so .remaining must be read before draining it.
expected = path.remaining
cells = path_cells(path)
check(expected == len(cells),
f"A*: .remaining ({expected}) matches the iterated length ({len(cells)})")
check(path.remaining == 0, "A*: iterating the path consumes it (.remaining -> 0)")
for (x, y) in cells:
if (x, y) != start_pos and (x, y) != end_pos:
color_layer.set((x, y), ASTAR_COLOR)
status_text.text = f"A* Path: {len(cells)} steps (optimized for single target)"
status_text.fill_color = ASTAR_COLOR
return cells
def show_dijkstra(walls):
"""Compute the Dijkstra flood from start, color it, return the path cells."""
dmap = grid.grid_data.get_dijkstra_map(root=start_pos)
check((int(dmap.root.x), int(dmap.root.y)) == start_pos,
"Dijkstra: map root is the start cell")
# The distance field must be defined on reachable floor and undefined on walls.
check(dmap.distance(start_pos) == 0.0, "Dijkstra: distance to the root itself is 0")
end_dist = dmap.distance(end_pos)
check(end_dist is not None and end_dist > 0,
f"Dijkstra: end cell is reachable (distance={end_dist})")
wall_distances_defined = [w for w in walls if dmap.distance(w) is not None]
check(not wall_distances_defined,
f"Dijkstra: unwalkable cells have no distance ({len(wall_distances_defined)} leaked)")
# Color cells by distance (showing exploration)
max_dist = 40.0
for y in range(GRID_H):
for x in range(GRID_W):
if grid.grid_data.at(x, y).walkable:
dist = dmap.distance((x, y))
if dist is not None and dist < max_dist:
intensity = int(255 * (1 - dist / max_dist))
color_layer.set((x, y), mcrfpy.Color(0, intensity // 2, intensity))
# Get the actual path back to the root
cells = path_cells(dmap.path_from(end_pos))
for (x, y) in cells:
if (x, y) != start_pos and (x, y) != end_pos:
color_layer.set((x, y), DIJKSTRA_COLOR)
color_layer.set(start_pos, START_COLOR)
color_layer.set(end_pos, END_COLOR)
status_text.text = f"Dijkstra: {len(cells)} steps (explores all directions)"
status_text.fill_color = DIJKSTRA_COLOR
return cells, dmap
def show_both(astar_cells, dijkstra_cells, dmap):
"""Compare the two routes. Different cells are fine; different COST is not."""
print(f" A* : {astar_cells}")
print(f" Dijkstra: {dijkstra_cells}")
# Both algorithms are optimal, so they must agree on the number of steps
# even if they break ties through different cells.
check(len(astar_cells) == len(dijkstra_cells),
f"A* and Dijkstra agree on path length (A*={len(astar_cells)}, "
f"Dijkstra={len(dijkstra_cells)})")
# 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")
different_cells = [c for c in dijkstra_cells if c not in astar_cells]
status_text.text = (f"Both paths: A*={len(astar_cells)} steps, "
f"Dijkstra={len(dijkstra_cells)} steps")
if different_cells:
info_text.text = f"Paths differ at {len(different_cells)} cells"
else:
info_text.text = "Paths are identical"
print(f" {info_text.text}")
# ---------------------------------------------------------------------------
print("A* vs Dijkstra Pathfinding Comparison")
print("=====================================")
print("A* is optimized for single-target pathfinding")
print("Dijkstra explores in all directions (good for multiple targets)")
print()
walls = create_map()
# Set up UI
pathfinding_comparison = mcrfpy.Scene("pathfinding_comparison")
ui = pathfinding_comparison.children
ui.append(grid)
title = mcrfpy.Caption(pos=(250, 20), text="A* vs Dijkstra Pathfinding")
title.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(title)
status_text = mcrfpy.Caption(pos=(100, 60), text="Comparing A* and Dijkstra")
status_text.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(status_text)
info_text = mcrfpy.Caption(pos=(100, 520), text="")
info_text.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(info_text)
legend1 = mcrfpy.Caption(pos=(100, 540), text="Red=Start, Yellow=End, Green=A*, Blue=Dijkstra")
legend1.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend1)
legend2 = mcrfpy.Caption(pos=(100, 560), text="Dark=Walls, Light=Floor")
legend2.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(legend2)
pathfinding_comparison.activate()
# Sanity: the ColorLayer really is the coloring mechanism now.
color_layer.set((0, 0), START_COLOR)
_c = color_layer.at((0, 0))
check((_c.r, _c.g, _c.b) == (255, 100, 100),
"ColorLayer.set/.at round-trips a cell color")
color_layer.set((0, 0), FLOOR_COLOR)
print("\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)