McRogueFace/tests/regression/issue_266_subclass_identity_test.py

242 lines
7.3 KiB
Python
Raw Normal View History

"""Regression test: Python subclass identity preservation in grid.entities.
Tests that Entity subclasses retain their Python type and custom methods
when accessed via grid.entities iteration, indexing, and pop(). This
pattern is critical for games like Liber Noster (7DRL 2026) where
subclasses like GameEntity, ZoneExit, Combatant add custom behavior.
Related issues: #266 (self-reference cycle), #275 (tp_dealloc)
"""
import mcrfpy
import gc
import sys
PASS = 0
FAIL = 0
def test(name, condition):
global PASS, FAIL
if condition:
PASS += 1
print(f" PASS: {name}")
else:
FAIL += 1
print(f" FAIL: {name}")
# --- Define subclass hierarchy mimicking Liber Noster ---
class GameEntity(mcrfpy.Entity):
"""Base game entity with custom methods."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.entity_name = kwargs.get("name", "unnamed")
self.description = "A game entity"
def tooltip(self):
return f"{self.entity_name}: {self.description}"
class ZoneExit(GameEntity):
"""Teleportation entity."""
def __init__(self, target_zone="unknown", **kwargs):
super().__init__(**kwargs)
self.target_zone = target_zone
self.description = f"Exit to {target_zone}"
def send(self, target_entity):
return f"Sending {target_entity} to {self.target_zone}"
class AnimatedEntity(GameEntity):
"""Entity with animation state."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.moving = False
self.facing_dir = 0
def anim(self, direction):
self.facing_dir = direction
return f"Animating {self.entity_name} dir={direction}"
class Combatant:
"""Mixin class for combat (not an Entity subclass)."""
def __init_combatant__(self, hp=10, atk=3, dfn=1):
self.hp = hp
self.atk = atk
self.dfn = dfn
self.alive = True
def bump(self, target):
damage = max(0, self.atk - target.dfn)
target.hp -= damage
if target.hp <= 0:
target.alive = False
return {"damage": damage, "defeated": not target.alive}
class Enemy(AnimatedEntity, Combatant):
"""Combines animation and combat."""
def __init__(self, hp=10, atk=3, dfn=1, **kwargs):
AnimatedEntity.__init__(self, **kwargs)
self.__init_combatant__(hp=hp, atk=atk, dfn=dfn)
# --- Tests ---
print("Testing Entity subclass identity preservation...")
# Create a grid and scene
scene = mcrfpy.Scene("test_identity")
grid = mcrfpy.Grid(grid_size=(20, 20), pos=(0, 0), size=(400, 400))
scene.children.append(grid)
mcrfpy.current_scene = scene
# Test 1: Basic subclass creation and grid addition
zexit = ZoneExit(target_zone="dungeon", grid_pos=(5, 5))
grid.entities.append(zexit)
test("ZoneExit added to grid", len(grid.entities) == 1)
# Test 2: Access via index preserves type
e = grid.entities[0]
test("grid.entities[0] returns ZoneExit type", type(e).__name__ == "ZoneExit")
test("grid.entities[0] has send() method", hasattr(e, "send"))
test("grid.entities[0].send() works", e.send("player") == "Sending player to dungeon")
test("grid.entities[0].tooltip() works", "dungeon" in e.tooltip())
del e
# Test 3: Access via iteration preserves type
for e in grid.entities:
test("iteration returns ZoneExit type", type(e).__name__ == "ZoneExit")
test("iteration has send() method", hasattr(e, "send"))
test("iteration has target_zone attr", hasattr(e, "target_zone"))
# Test 4: Drop the original Python reference, force GC
del zexit
gc.collect()
gc.collect()
# This is the critical test: after GC, the subclass should survive
e = grid.entities[0]
test("after GC: type preserved", type(e).__name__ == "ZoneExit")
test("after GC: send() works", e.send("hero") == "Sending hero to dungeon")
test("after GC: target_zone preserved", e.target_zone == "dungeon")
test("after GC: tooltip() works", "dungeon" in e.tooltip())
del e
# Test 5: Multiple subclass types in same grid
enemy1 = Enemy(hp=20, atk=5, dfn=2, name="goblin", grid_pos=(3, 3))
enemy2 = Enemy(hp=15, atk=4, dfn=1, name="skeleton", grid_pos=(7, 7))
anim = AnimatedEntity(name="npc", grid_pos=(10, 10))
grid.entities.append(enemy1)
grid.entities.append(enemy2)
grid.entities.append(anim)
# Drop Python refs, force GC
del enemy1, enemy2, anim
gc.collect()
gc.collect()
test("multiple types: 4 entities in grid", len(grid.entities) == 4)
# Check each entity's type via iteration
types_found = []
for e in grid.entities:
types_found.append(type(e).__name__)
test("type list correct", types_found == ["ZoneExit", "Enemy", "Enemy", "AnimatedEntity"])
# Test 6: isinstance checks (Liber Noster pattern: find Combatants)
combatants = []
for e in grid.entities:
if isinstance(e, Combatant) and e.alive:
combatants.append(e)
test("isinstance(Combatant) finds 2 enemies", len(combatants) == 2)
test("combatant has bump()", hasattr(combatants[0], "bump"))
test("combatant has hp", hasattr(combatants[0], "hp"))
# Test 7: Combat between entities retrieved from grid
attacker = combatants[0]
defender = combatants[1]
result = attacker.bump(defender)
test("combat returns result dict", "damage" in result)
test("combat deals damage", defender.hp < 15)
del attacker, defender, combatants
# Test 8: hasattr checks (Liber Noster pattern: tooltip, pickup)
gc.collect()
for e in grid.entities:
if hasattr(e, "tooltip"):
tip = e.tooltip()
test(f"tooltip() on {type(e).__name__}", tip is not None)
break # Just test one
# Test 9: die() releases identity, allows GC
pre_count = len(grid.entities)
e = grid.entities[0] # ZoneExit
test("before die: is ZoneExit", type(e).__name__ == "ZoneExit")
e.die()
test("after die: entity count decreased", len(grid.entities) == pre_count - 1)
del e
gc.collect()
# Test 10: Remaining entities still have correct types
types_after_die = [type(e).__name__ for e in grid.entities]
test("types after die()", types_after_die == ["Enemy", "Enemy", "AnimatedEntity"])
# Test 11: pop() preserves identity
e = grid.entities.pop(0)
test("pop() returns Enemy", type(e).__name__ == "Enemy")
test("pop() entity has combat attrs", hasattr(e, "hp"))
del e
# Test 12: Entity created with grid= kwarg
zexit2 = ZoneExit(target_zone="tower", grid_pos=(1, 1), grid=grid)
del zexit2
gc.collect()
gc.collect()
e = grid.entities[-1]
test("grid= kwarg: type preserved after GC", type(e).__name__ == "ZoneExit")
test("grid= kwarg: send() works", "tower" in e.send("x"))
del e
# Test 13: remove() releases identity
enemy_ref = None
for e in grid.entities:
if isinstance(e, Enemy):
enemy_ref = e
break
test("found Enemy for remove test", enemy_ref is not None)
if enemy_ref:
grid.entities.remove(enemy_ref)
del enemy_ref
gc.collect()
# Test 14: Stress test - create many entities, drop refs, verify all survive
for i in range(20):
ent = ZoneExit(target_zone=f"zone_{i}", grid_pos=(i % 20, i // 20))
grid.entities.append(ent)
# Don't hold any Python reference
del ent
gc.collect()
gc.collect()
zone_exits_found = 0
for e in grid.entities:
if isinstance(e, ZoneExit) and hasattr(e, "target_zone"):
zone_exits_found += 1
test("stress: all 21 ZoneExits preserved", zone_exits_found == 21)
# Summary
print(f"\n{'='*50}")
total = PASS + FAIL
if FAIL == 0:
print(f"PASS: all {PASS} subclass identity tests passed")
sys.exit(0)
else:
print(f"FAIL: {FAIL}/{total} tests failed")
sys.exit(1)