McRogueFace/tests/unit/test_astar.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

188 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""
Test A* Pathfinding Implementation
==================================
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 sys
import time
print("A* Pathfinding Test")
print("==================")
failures = []
def check(cond, msg):
if cond:
print(f" PASS: {msg}")
else:
print(f" FAIL: {msg}")
failures.append(msg)
# Create scene and grid
astar_test = mcrfpy.Scene("astar_test")
grid = mcrfpy.Grid(grid_size=(20, 20), pos=(50, 50), size=(400, 400))
data = grid.grid_data # pathfinding lives on GridData
# Initialize grid - all walkable
for y in range(20):
for x in range(20):
data.at(x, y).walkable = True
# 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...")
walls = set()
for y in range(20):
for x in range(8, 12):
if y == 10: # corridor through the barrier, including (10, 10)
continue
data.at(x, y).walkable = False
walls.add((x, y))
print(f"Wall cells: {len(walls)}, passage at (10, 10)")
data.clear_dijkstra_maps()
# Test points
start = (2, 10)
end = (18, 10)
print(f"\nFinding path from {start} to {end}")
def as_cells(path):
return [(int(v.x), int(v.y)) for v in path]
def is_contiguous(cells):
for a, b in zip(cells, cells[1:]):
if max(abs(a[0] - b[0]), abs(a[1] - b[1])) != 1:
return False
return True
# Test 1: A* pathfinding
print("\n1. Testing A* pathfinding (find_path):")
start_time = time.time()
astar_path = data.find_path(start, end)
astar_time = time.time() - start_time
check(astar_path is not None, "A* found a path through the barrier")
# NOTE: iterating an AStarPath consumes it, so read .remaining before as_cells().
astar_remaining = astar_path.remaining if astar_path else 0
astar_cells = as_cells(astar_path) if astar_path else []
print(f" A* path length: {len(astar_cells)}")
print(f" A* time: {astar_time*1000:.3f} ms")
print(f" First 5 steps: {astar_cells[:5]}")
check(bool(astar_cells) and astar_cells[-1] == end, "A* path terminates at the destination")
check(not (walls & set(astar_cells)), "A* path never enters a wall cell")
check(is_contiguous([start] + astar_cells), "A* path is contiguous from the start cell")
check((10, 10) in astar_cells, "A* path uses the narrow passage at (10, 10)")
# Test 2: A* path costs/endpoints via the AStarPath object
print("\n2. Testing AStarPath object:")
check(as_cells([astar_path.origin])[0] == start, "AStarPath.origin is the start cell")
check(as_cells([astar_path.destination])[0] == end, "AStarPath.destination is the end cell")
check(astar_remaining == len(astar_cells), "AStarPath.remaining matches path length")
check(astar_path.remaining == 0, "AStarPath is consumed once fully walked/iterated")
# Test 3: Dijkstra pathfinding for comparison
print("\n3. Testing Dijkstra pathfinding:")
start_time = time.time()
dmap = data.get_dijkstra_map(root=start)
dijkstra_path = dmap.path_from(end)
dijkstra_time = time.time() - start_time
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" First 5 steps: {dijkstra_cells[:5]}")
check(bool(dijkstra_cells), "Dijkstra found a path back to the root")
check(not (walls & set(dijkstra_cells)), "Dijkstra path never enters a wall cell")
check((10, 10) in dijkstra_cells, "Dijkstra path uses the narrow passage at (10, 10)")
check(dmap.distance(end) is not None, "Dijkstra distance to the destination is defined")
# Compare results - both are optimal, so step counts must agree
print("\nComparison:")
print(f" A* steps: {len(astar_cells)}, Dijkstra steps: {len(dijkstra_cells)}")
check(len(astar_cells) == len(dijkstra_cells),
"A* and Dijkstra agree on optimal step count")
# Test 4: no path (blocked destination inside the wall)
print("\n4. Testing with blocked destination:")
blocked_end = (10, 8) # inside the wall
check(data.at(*blocked_end).walkable is False, "blocked destination is unwalkable")
no_path = data.find_path(start, blocked_end)
print(f" Path to blocked cell: {no_path}")
check(no_path is None or len(no_path) == 0, "no path is returned for a blocked destination")
# Test 5: diagonal movement
print("\n5. Testing diagonal paths:")
diag_start = (0, 0)
diag_end = (5, 5)
diag_path = data.find_path(diag_start, diag_end)
diag_cells = as_cells(diag_path) if diag_path else []
print(f" Diagonal path from {diag_start} to {diag_end}: {diag_cells}")
# Optimal diagonal path is 5 moves (one diagonal step per cell)
check(len(diag_cells) == 5, "diagonal path from (0,0) to (5,5) takes 5 steps")
check(diag_cells and diag_cells[-1] == diag_end, "diagonal path reaches the destination")
# Test 6: performance / corner-to-corner agreement
print("\n6. Performance test (corner to corner):")
results = {}
start_time = time.time()
corner_astar = as_cells(data.find_path((0, 0), (19, 19)) or [])
results["A*"] = (len(corner_astar), time.time() - start_time)
start_time = time.time()
corner_dmap = data.get_dijkstra_map(root=(0, 0))
corner_dijkstra = as_cells(corner_dmap.path_from((19, 19)) or [])
results["Dijkstra"] = (len(corner_dijkstra), time.time() - start_time)
for name, (steps, elapsed) in results.items():
print(f" {name}: {steps} steps in {elapsed*1000:.3f} ms")
check(len(corner_astar) > 0 and len(corner_dijkstra) > 0,
"corner-to-corner path found by both algorithms")
check(len(corner_astar) == len(corner_dijkstra),
"corner-to-corner step counts agree between A* and Dijkstra")
# Quick smoke test that the grid renders in a scene and the clock advances.
# Headless: mcrfpy.step() is the only clock; a Timer never fires on its own.
timer_fired = []
def visual_test(timer, runtime):
print("\nVisual test timer fired")
timer_fired.append(runtime)
ui = astar_test.children
ui.append(grid)
astar_test.activate()
visual_test_timer = mcrfpy.Timer("visual", visual_test, 100, once=True)
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)