McRogueFace/tests/unit/bsp_adjacency_test.py
John McCardle 8628ac164b BSP: add room adjacency graph for corridor generation (closes #210)
New features:
- bsp.adjacency[i] returns tuple of neighbor leaf indices
- bsp.get_leaf(index) returns BSPNode by leaf index (O(1) lookup)
- node.leaf_index returns this leaf's index (0..n-1) or None
- node.adjacent_tiles[j] returns tuple of Vector wall tiles bordering neighbor j

Implementation details:
- Lazy-computed adjacency cache with generation-based invalidation
- O(n²) pairwise adjacency check on first access
- Wall tiles computed per-direction (not symmetric) for correct perspective
- Supports 'in' operator: `5 in leaf.adjacent_tiles`

Code review fixes applied:
- split_once now increments generation to invalidate cache
- Wall tile cache uses (self, neighbor) key, not symmetric
- Added sq_contains for 'in' operator support
- Documented wall tile semantics (tiles on THIS leaf's boundary)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:43:57 -05:00

323 lines
11 KiB
Python

#!/usr/bin/env python3
"""Tests for BSP adjacency graph feature (#210)"""
import mcrfpy
import sys
def test_adjacency_basic():
"""Test basic adjacency on a simple 2-leaf BSP"""
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 50))
# Split once - creates 2 leaves
bsp.split_once(horizontal=False, position=50) # Vertical split at x=50
leaves = list(bsp.leaves())
assert len(leaves) == 2, f"Expected 2 leaves, got {len(leaves)}"
# Access adjacency
adj = bsp.adjacency
assert len(adj) == 2, f"Expected adjacency len 2, got {len(adj)}"
# Each leaf should be adjacent to the other
neighbors_0 = adj[0]
neighbors_1 = adj[1]
assert 1 in neighbors_0, f"Leaf 0 should be adjacent to leaf 1, got {neighbors_0}"
assert 0 in neighbors_1, f"Leaf 1 should be adjacent to leaf 0, got {neighbors_1}"
print(" test_adjacency_basic: PASS")
def test_leaf_indexing():
"""Test that leaf_index property works correctly"""
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100))
bsp.split_recursive(depth=2, min_size=(10, 10), seed=42)
leaves = list(bsp.leaves())
# Each leaf should have a valid index
for i, leaf in enumerate(leaves):
assert leaf.leaf_index == i, f"Leaf {i} has index {leaf.leaf_index}"
# Non-leaves should return None
root = bsp.root
if not root.is_leaf:
assert root.leaf_index is None, "Non-leaf should have leaf_index=None"
print(" test_leaf_indexing: PASS")
def test_adjacency_symmetry():
"""Test that adjacency is symmetric: if A adjacent to B, then B adjacent to A"""
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100))
bsp.split_recursive(depth=3, min_size=(10, 10), seed=42)
adj = bsp.adjacency
n = len(adj)
for i in range(n):
for j in adj[i]:
assert i in adj[j], f"Adjacency not symmetric: {i} -> {j} but not {j} -> {i}"
print(" test_adjacency_symmetry: PASS")
def test_adjacent_tiles_basic():
"""Test that adjacent_tiles returns Vector tuples"""
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 50))
bsp.split_once(horizontal=False, position=50) # Vertical split at x=50
leaves = list(bsp.leaves())
assert len(leaves) == 2
leaf0 = leaves[0]
neighbors = bsp.adjacency[0]
assert len(neighbors) > 0, "Leaf 0 should have neighbors"
neighbor_idx = neighbors[0]
wall_tiles = leaf0.adjacent_tiles[neighbor_idx]
assert len(wall_tiles) > 0, "Should have wall tiles"
# Check that wall tiles are Vector objects
first_tile = wall_tiles[0]
assert hasattr(first_tile, 'x') and hasattr(first_tile, 'y'), \
f"Wall tile should be a Vector, got {type(first_tile)}"
print(" test_adjacent_tiles_basic: PASS")
def test_adjacent_tiles_keyerror():
"""Test that non-adjacent lookups raise KeyError"""
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100))
bsp.split_recursive(depth=3, min_size=(10, 10), seed=42)
leaves = list(bsp.leaves())
# Find a non-adjacent pair
adj = bsp.adjacency
for i in range(len(leaves)):
for j in range(len(leaves)):
if i != j and j not in adj[i]:
# i and j are not adjacent
try:
_ = leaves[i].adjacent_tiles[j]
assert False, f"Expected KeyError for non-adjacent pair {i}, {j}"
except KeyError:
pass # Expected
print(" test_adjacent_tiles_keyerror: PASS")
return
# If we get here, all pairs are adjacent (unlikely with depth 3)
print(" test_adjacent_tiles_keyerror: SKIP (all pairs adjacent)")
def test_cache_invalidation():
"""Test that cache is invalidated on clear() and split_recursive()"""
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100))
bsp.split_recursive(depth=2, min_size=(10, 10), seed=42)
# Access adjacency to populate cache
adj1 = bsp.adjacency
n1 = len(adj1)
# Clear and re-split
bsp.clear()
bsp.split_recursive(depth=3, min_size=(10, 10), seed=123)
# Access adjacency again - should be rebuilt
adj2 = bsp.adjacency
n2 = len(adj2)
# Different seed/depth should give different results
assert n2 > n1 or n2 != n1, "Cache should be invalidated after clear()"
print(" test_cache_invalidation: PASS")
def test_wall_tiles_on_boundary():
"""Test that wall tiles are on the correct boundary"""
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 50))
bsp.split_once(horizontal=False, position=50) # Vertical split at x=50
leaves = list(bsp.leaves())
leaf0 = leaves[0] # Should be left side (x: 0-50)
leaf1 = leaves[1] # Should be right side (x: 50-100)
# Get wall tiles from leaf0 to leaf1
wall_tiles = leaf0.adjacent_tiles[1]
# Wall should be at x=49 (last column of leaf0) for leaf0
for tile in wall_tiles:
x, y = int(tile.x), int(tile.y)
# Tile should be within leaf0's bounds
assert x == 49, f"Wall tile x should be 49 (boundary), got {x}"
assert 0 <= y < 50, f"Wall tile y should be in range 0-49, got {y}"
print(" test_wall_tiles_on_boundary: PASS")
def test_negative_indexing():
"""Test that negative indices work for adjacency"""
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100))
bsp.split_recursive(depth=2, min_size=(10, 10), seed=42)
adj = bsp.adjacency
n = len(adj)
# adj[-1] should be same as adj[n-1]
last_positive = adj[n-1]
last_negative = adj[-1]
assert last_positive == last_negative, \
f"Negative indexing failed: adj[-1]={last_negative}, adj[{n-1}]={last_positive}"
print(" test_negative_indexing: PASS")
def test_iteration():
"""Test that adjacency can be iterated"""
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100))
bsp.split_recursive(depth=2, min_size=(10, 10), seed=42)
adj = bsp.adjacency
# Should be iterable
count = 0
for neighbors in adj:
assert isinstance(neighbors, tuple), f"Expected tuple, got {type(neighbors)}"
count += 1
assert count == len(adj), f"Iteration count {count} != len {len(adj)}"
print(" test_iteration: PASS")
def test_keys_method():
"""Test that adjacent_tiles.keys() works"""
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 50))
bsp.split_once(horizontal=False, position=50)
leaves = list(bsp.leaves())
leaf0 = leaves[0]
keys = leaf0.adjacent_tiles.keys()
assert isinstance(keys, tuple), f"keys() should return tuple, got {type(keys)}"
assert len(keys) > 0, "Should have at least one neighbor"
assert 1 in keys, f"Leaf 1 should be in keys, got {keys}"
print(" test_keys_method: PASS")
def test_split_once_invalidation():
"""Test that split_once invalidates adjacency cache and BSPNode references"""
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100))
# First split - creates 2 leaves
bsp.split_once(horizontal=False, position=50)
# Access adjacency to build cache
adj1 = bsp.adjacency
n1 = len(adj1)
assert n1 == 2, f"Expected 2 leaves after first split, got {n1}"
# Get a reference to a leaf before second split
old_leaf = list(bsp.leaves())[0]
old_leaf_index = old_leaf.leaf_index
# Second split_once on the BSP (note: split_once always works on root)
# After this, the tree structure changes, but old_leaf should be stale
bsp.clear() # Clear and split fresh to get more leaves
bsp.split_once(horizontal=False, position=50)
bsp.split_once(horizontal=True, position=50) # Won't work - split_once only on root
# The old leaf reference should now be stale
try:
_ = old_leaf.leaf_index
assert False, "Expected RuntimeError for stale BSPNode"
except RuntimeError:
pass # Expected - node is stale after clear()
# Access adjacency - should reflect new structure (2 leaves again)
adj2 = bsp.adjacency
n2 = len(adj2)
assert n2 == 2, f"Expected 2 leaves after clear+split, got {n2}"
print(" test_split_once_invalidation: PASS")
def test_wall_tiles_perspective():
"""Test that wall tiles are from the correct leaf's perspective"""
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 50))
bsp.split_once(horizontal=False, position=50) # Vertical split at x=50
leaves = list(bsp.leaves())
leaf0 = leaves[0] # Left side: x 0-50
leaf1 = leaves[1] # Right side: x 50-100
# Get tiles from leaf0's perspective (should be at x=49, leaf0's edge)
tiles_0_to_1 = leaf0.adjacent_tiles[1]
for tile in tiles_0_to_1:
assert int(tile.x) == 49, f"Leaf0->Leaf1 tile should be at x=49, got {tile.x}"
# Get tiles from leaf1's perspective (should be at x=50, leaf1's edge)
tiles_1_to_0 = leaf1.adjacent_tiles[0]
for tile in tiles_1_to_0:
assert int(tile.x) == 50, f"Leaf1->Leaf0 tile should be at x=50, got {tile.x}"
print(" test_wall_tiles_perspective: PASS")
def test_get_leaf():
"""Test bsp.get_leaf(index) method"""
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 100))
bsp.split_recursive(depth=2, min_size=(10, 10), seed=42)
leaves = list(bsp.leaves())
n = len(leaves)
# Test positive indices
for i in range(n):
leaf = bsp.get_leaf(i)
assert leaf.leaf_index == i, f"get_leaf({i}) returned leaf with index {leaf.leaf_index}"
# Test negative index
last_leaf = bsp.get_leaf(-1)
assert last_leaf.leaf_index == n - 1, f"get_leaf(-1) should return leaf {n-1}, got {last_leaf.leaf_index}"
# Test out of range
try:
bsp.get_leaf(n)
assert False, "Expected IndexError for out-of-range index"
except IndexError:
pass # Expected
print(" test_get_leaf: PASS")
def test_contains_operator():
"""Test 'in' operator for adjacent_tiles"""
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 50))
bsp.split_once(horizontal=False, position=50)
leaves = list(bsp.leaves())
leaf0 = leaves[0]
# Leaf 1 should be adjacent to leaf 0
assert 1 in leaf0.adjacent_tiles, "1 should be in leaf0.adjacent_tiles"
# Some arbitrary index should not be (assuming only 2 leaves)
assert 5 not in leaf0.adjacent_tiles, "5 should not be in leaf0.adjacent_tiles"
print(" test_contains_operator: PASS")
def run_all_tests():
"""Run all adjacency tests"""
print("Running BSP adjacency tests (#210)...")
test_adjacency_basic()
test_leaf_indexing()
test_adjacency_symmetry()
test_adjacent_tiles_basic()
test_adjacent_tiles_keyerror()
test_cache_invalidation()
test_wall_tiles_on_boundary()
test_negative_indexing()
test_iteration()
test_keys_method()
test_split_once_invalidation()
test_wall_tiles_perspective()
test_get_leaf()
test_contains_operator()
print("\nAll BSP adjacency tests passed!")
return 0
if __name__ == "__main__":
sys.exit(run_all_tests())