McRogueFace/tests/regression/issue_369_parent_identity_test.py

136 lines
4.8 KiB
Python
Raw Normal View History

fix(bindings): route every drawable wrapper through PythonObjectCache `.parent` allocated a brand-new Python wrapper on every read, so object identity never held: `kid.parent is parent` was False, and so was `kid.parent is kid.parent`. A Frame/Grid subclass reached through .parent came back as the base type with its Python attributes gone, and `.parent` could not be used as a dict key or set member. The cache-aware converter already existed -- convertDrawableToPython in UICollection.cpp -- but was file-local, so three other paths hand-rolled their own tp_alloc switch and skipped the cache: - UIDrawable::get_parent (the filed bug) - find_in_collection (mcrfpy.find / find_all) - UIEntityCollection concat and slice __getitem__ The entity sites were the worst of them: a duplicate wrapper's tp_dealloc unconditionally clears UIEntity::pyobject, destroying the #266 subclass identity ref held by the *original* wrapper. Promote the converter to UIDrawable::pyobject_for and route all sites through it; add the matching convertEntityToPython for entities. This also gives .parent the Line/Circle/Arc/Viewport3D arms it never had (they previously fell through to None). Residual gap, deliberately not closed here: the cache holds weakrefs, so a subclass wrapper GC'd while only C++ holds the object still resurrects as the base type. Closing that needs an owner-held strong ref (the #348 pattern) and is a lifetime design decision; filed separately. Verified A/B: 17 of the new test's 22 checks fail pre-fix, all pass after. Suite 330/330. Also corrects CLAUDE.md, which documented the long-deleted RET_PY_INSTANCE macro and recommended bare tp_alloc as the "most common" pattern -- i.e. taught this exact bug. closes #369
2026-07-13 23:37:01 -04:00
"""
Regression test for issue #369.
Every path that hands a C++ drawable back to Python must return the *same* wrapper
object, or `child.parent is parent` silently answers False and Python subclasses
reached through those paths lose their identity.
Broken paths this locks down:
- UIDrawable::get_parent (.parent)
- find_in_collection (mcrfpy.find / find_all)
- UIEntityCollection concat/slice (entities + [...], entities[a:b])
"""
import mcrfpy
import sys
failures = []
def check(label, condition):
if condition:
print(f" PASS: {label}")
else:
print(f" FAIL: {label}")
failures.append(label)
scene = mcrfpy.Scene("issue369")
ui = scene.children
# ---------------------------------------------------------------- .parent identity
print("1. .parent returns a stable, identical wrapper")
parent = mcrfpy.Frame(pos=(0, 0), size=(100, 100), name="parent_frame")
kid = mcrfpy.Frame(pos=(1, 1), size=(10, 10), name="kid_frame")
ui.append(parent)
parent.children.append(kid)
check("kid.parent is parent", kid.parent is parent)
check("repeated reads are identical", kid.parent is kid.parent)
check("id() is stable across reads", id(kid.parent) == id(kid.parent))
# .parent must be usable as a dict key / set member
seen = {kid.parent: "found"}
check(".parent works as a dict key", seen.get(parent) == "found")
check(".parent works in a set", len({kid.parent, kid.parent, parent}) == 1)
# ------------------------------------------------------- subclass survives .parent
print("2. Python subclass identity survives .parent")
class MyFrame(mcrfpy.Frame):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.custom_state = "preserved"
sub_parent = MyFrame(pos=(0, 0), size=(200, 200), name="sub_parent")
sub_kid = mcrfpy.Caption(pos=(5, 5), text="inner", name="sub_kid")
ui.append(sub_parent)
sub_parent.children.append(sub_kid)
check("sub_kid.parent is sub_parent", sub_kid.parent is sub_parent)
check("subclass type preserved", isinstance(sub_kid.parent, MyFrame))
check("subclass attributes preserved",
getattr(sub_kid.parent, "custom_state", None) == "preserved")
# ------------------------------------------------- .parent covers all drawable types
print("3. .parent resolves non-Frame/Caption/Sprite/Grid parents (was None)")
# Line/Circle/Arc/Viewport3D had no switch arm and fell through to Py_RETURN_NONE.
# They aren't containers, so verify the *child-of-grid* case instead: a GridView
# parent must come back identical, and every type must at least round-trip via find().
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(100, 100), name="the_grid")
ui.append(grid)
overlay = mcrfpy.Frame(pos=(0, 0), size=(10, 10), name="overlay_child")
grid.children.append(overlay)
check("overlay.parent is grid", overlay.parent is grid)
# ------------------------------------------------------------- find() identity
print("4. find() / find_all() return existing wrappers")
found = mcrfpy.find("parent_frame", "issue369")
check("find() returns the same object", found is parent)
found_sub = mcrfpy.find("sub_parent", "issue369")
check("find() preserves subclass", found_sub is sub_parent)
check("find() subclass attrs intact",
getattr(found_sub, "custom_state", None) == "preserved")
all_found = mcrfpy.find_all("*_frame", "issue369")
by_id = {id(o) for o in all_found}
check("find_all() returns live wrappers",
id(parent) in by_id and id(kid) in by_id)
# ---------------------------------------------------------- entity collection paths
print("5. EntityCollection slice / concat preserve entity identity")
class MyEntity(mcrfpy.Entity):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.tag = "mine"
e0 = MyEntity(grid_pos=(1, 1))
e1 = mcrfpy.Entity(grid_pos=(2, 2))
e2 = mcrfpy.Entity(grid_pos=(3, 3))
for e in (e0, e1, e2):
grid.entities.append(e)
check("entities[0] is e0", grid.entities[0] is e0)
sliced = grid.entities[0:2]
check("slice returns the same wrappers", sliced[0] is e0 and sliced[1] is e1)
check("slice preserves subclass", isinstance(sliced[0], MyEntity))
check("slice preserves subclass attrs", getattr(sliced[0], "tag", None) == "mine")
concat = grid.entities + []
check("concat returns the same wrappers", concat[0] is e0)
check("concat preserves subclass", isinstance(concat[0], MyEntity))
# The #266 identity ref must survive a duplicate-wrapper round trip: before the fix,
# the temporary wrapper's dealloc cleared UIEntity::pyobject on the shared C++ object.
del sliced, concat
import gc
gc.collect()
check("entity identity survives temp wrappers", grid.entities[0] is e0)
check("subclass attrs survive temp wrappers",
getattr(grid.entities[0], "tag", None) == "mine")
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)