McRogueFace/tests/regression/issue_359_shared_griddata_test.py
John McCardle c696b4ef41 refactor(grid): GridData is a map, not a widget -- delete UIGrid's drawable half
closes #361
closes #370
closes #371

UIGrid inherited both UIDrawable and GridData, so every map carried a second
camera and RenderTexture that nothing on screen corresponded to. Appending a
_GridData to a scene drew the whole map again through that ghost, frozen at
construction values. GridData is now pure data -- no position, no size, no
render() -- and UIGridView is the only widget.

UIGrid is deleted rather than demoted: shedding UIDrawable left an empty
subclass whose only job was to be what an aliasing shared_ptr pointed into.
GridData absorbs the texture (it defines the cell size, which every tile<->pixel
conversion depends on) and its own PythonObjectCache serial; the binding layer
becomes struct PyGridData. PyObjectsEnum::UIGRID and its 14 case arms go with it.

  * mcrfpy.GridData is public and standalone-constructible. A map with no view
    at all -- an offscreen level, still steppable and pathable -- was previously
    undefined behavior: GridData::markDirty did static_cast<UIGrid*>(this).
  * UIGridView::init constructs the GridData directly instead of building a
    throwaway Python _GridData, stealing its base subobject, and copying ~15
    fields of rendering state out of the discarded wrapper. One init() serves
    both modes, so a second view is no longer a crippled kwarg subset
    (Grid(grid=other, z_index=5) used to raise TypeError).
  * entity.grid returns the GridData. Accepted break: an entity may be on a map
    with no view or with several, so "the grid an entity is in" is not a camera.
    Migrate player.grid.center_camera(p) -> view.center_camera(p).
  * tp_name is mcrfpy.GridView; mcrfpy.Grid is an alias to the same type object.

Two latent bugs surfaced and are fixed here:

  #370  Grid.center_camera() and Grid.size = ... were SILENT NO-OPS -- neither
        was defined on the view, so both delegated to the ghost camera. On
        master, center_camera((5,5)) leaves center_x at 50.0.
  #371  The shader uniform binder strcmp'd tp_name == "mcrfpy.Grid" (the VIEW's
        name) then cast to PyUIGridObject. UB that only worked because both
        wrappers had identical layout and both classes put UIDrawable at offset 0.

Suite 327/327.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 07:14:43 -04:00

194 lines
8.2 KiB
Python

"""Regression test for #359 - the shared-data grid path is unwired.
Two concrete defects, both in the `Grid(grid=other)` shared-GridData path
(the whole point of the #252 split -- split-screen, minimap, multiple
cameras over one dataset):
(a) UIGridView::init_explicit_view never registered the new view with the
GridData it attaches to, so GridData::markDirty()/markCompositeDirty()
(which push markContentDirty/markCompositeDirty up a view's OWN
ancestor chain -- e.g. a Frame(cache_subtree=True) wrapping that view)
never reached a secondary view's ancestors. Note this is NOT the same
thing as the #351 render early-out itself: UIGridView::render() polls
GridData::content_generation directly every time it actually runs, so
a bare secondary view (no cached ancestor) already re-rasters
correctly regardless of this bug. The bug only surfaces when a
secondary view sits under a render-texture-caching ancestor, because
that ancestor decides whether to invoke the child's render() AT ALL --
a push notification, not a poll, is required to reach it. Test 3 below
is the discriminator: it fails (stale cache) on the pre-fix code and
passes once init_explicit_view registers the view.
(b) PyUIGridObject::view (the internal `_GridData.view` shim, meant to
point back at "the" GridView) was placement-new'd but never assigned
anywhere reachable from Python -- `_GridData.view` was always None.
Given `_GridData` is internal (never exported to the mcrfpy module
namespace), the fix deletes the field/getter outright rather than
wiring it up (a single view back-pointer is structurally wrong once N
views can share one GridData; see GridData::views / primaryView() for
the replacement, exercised implicitly by test 2). Test 4 confirms the
dead property is gone, not silently still None.
Design note carried over from the fix: GridData::views is a *list*, not a
single pointer, specifically because N views can share one GridData.
#361 update: entity.grid no longer returns a view at all -- it returns the
GridData. The "which view is primary?" question that views.front() answered for
identity purposes is gone, because the answer was always arbitrary dressed up as
deterministic. The registry itself remains, and is still load-bearing for
dirty-notification fan-out (test 3).
"""
import sys
import os
import tempfile
import mcrfpy
from mcrfpy import automation
TMPDIR = tempfile.mkdtemp(prefix="mcrf359_")
_counter = 0
def shot():
"""Render the active scene to PNG and return its bytes (headless
screenshot is synchronous -- see #351's test for the same pattern)."""
global _counter
path = os.path.join(TMPDIR, "s%d.png" % _counter)
_counter += 1
automation.screenshot(path)
with open(path, "rb") as f:
return f.read()
def test_1_two_views_render_shared_mutations():
"""Two Grid views over one GridData, with different camera params, both
reflect a mutation made through a third path (this is the exact scenario
the #359 issue's own test description asks for)."""
print("(1) two views over one GridData both render mutations...")
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
scene_a = mcrfpy.Scene("issue359_1a")
grid_a = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(160, 160), texture=texture)
scene_a.children.append(grid_a)
scene_b = mcrfpy.Scene("issue359_1b")
grid_b = mcrfpy.Grid(grid=grid_a, pos=(0, 0), size=(160, 160))
grid_b.zoom = 1.5
grid_b.center = (64, 64)
scene_b.children.append(grid_b)
mcrfpy.current_scene = scene_a
a1 = shot()
mcrfpy.current_scene = scene_b
b1 = shot()
# Mutate through the shared data (add a visible entity at cell (3,3)).
e = mcrfpy.Entity((3, 3), grid=grid_a)
e.sprite_index = 5
mcrfpy.current_scene = scene_a
a2 = shot()
assert a2 != a1, "primary view (grid_a) did not reflect the mutation"
mcrfpy.current_scene = scene_b
b2 = shot()
assert b2 != b1, "secondary view (grid_b) did not reflect the mutation"
print(" OK")
def test_2_entity_grid_identity():
"""entity.grid is the shared GridData -- one stable object, the same one both
views are looking at, and NOT either view (#361)."""
print("(2) entity.grid identity with a secondary view registered...")
grid_a = mcrfpy.Grid(grid_size=(5, 5))
grid_b = mcrfpy.Grid(grid=grid_a, pos=(0, 0), size=(80, 80))
e = mcrfpy.Entity((1, 1), grid=grid_a)
data = grid_a.grid_data
assert grid_b.grid_data is data, "the two views do not share one GridData"
assert e.grid is data, f"entity.grid identity broken: {e.grid!r} is not {data!r}"
assert e.grid is e.grid, "entity.grid is not stable across reads"
assert e.grid is not grid_a and e.grid is not grid_b, \
"entity.grid returned a view; it must return the map (#361)"
print(" OK")
def test_3_ancestor_cache_invalidation_through_secondary_view():
"""The discriminating regression: a SECONDARY view nested inside a
Frame(cache_subtree=True) must have its ancestor's cached subtree
invalidated when the shared GridData mutates, even though the mutation
happens through a DIFFERENT (primary) view/handle. This requires
init_explicit_view to register the secondary view with GridData -- pure
content_generation polling (#351) is not enough, because the caching
Frame decides whether to call the child's render() at all."""
print("(3) ancestor render-texture cache invalidated via secondary view...")
texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
scene = mcrfpy.Scene("issue359_3")
grid_a = mcrfpy.Grid(grid_size=(6, 6), pos=(200, 200), size=(96, 96), texture=texture)
# grid_a deliberately NOT added to this scene -- it exists only to own
# the data and be the mutation path; only the secondary view is visible.
grid_b = mcrfpy.Grid(grid=grid_a, pos=(0, 0), size=(96, 96))
frame = mcrfpy.Frame(pos=(0, 0), size=(96, 96))
frame.cache_subtree = True
frame.children.append(grid_b)
scene.children.append(frame)
mcrfpy.current_scene = scene
# First render bakes the Frame's cached subtree (render_dirty starts True).
shot1 = shot()
# Idle render: cache should be stable (sanity check the test methodology).
shot2 = shot()
assert shot1 == shot2, "cache_subtree Frame was not stable across an idle frame (test setup problem)"
# Mutate the shared GridData through the PRIMARY handle grid_a, NOT the
# secondary view. If grid_b is registered on the GridData, this reaches
# grid_b's own ancestor chain (the cache_subtree Frame) and invalidates it.
e = mcrfpy.Entity((3, 3), grid=grid_a)
e.sprite_index = 5
shot3 = shot()
assert shot3 != shot2, (
"Frame(cache_subtree=True) wrapping the SECONDARY view did not invalidate "
"when the shared GridData mutated through the primary handle -- "
"init_explicit_view is not registering the view with GridData (#359 defect a)"
)
print(" OK")
def test_4_griddata_view_property_removed():
"""_GridData.view (PyUIGridObject::view) is dead: nothing in the
codebase ever assigned it, and _GridData is internal/not exported. The
fix deletes the field and getter outright rather than wiring a
structurally-wrong single back-pointer; confirm it's actually gone, not
silently still None."""
print("(4) _GridData.view property deleted (not left dangling)...")
grid_a = mcrfpy.Grid(grid_size=(3, 3))
gd = grid_a.grid_data
assert not hasattr(gd, "view"), (
"_GridData.view still exists -- #359 defect (b) says delete it "
"(internal type, never wired, N-view sharing makes a single "
"back-pointer wrong) rather than leave it dangling"
)
print(" OK")
def main():
test_1_two_views_render_shared_mutations()
test_2_entity_grid_identity()
test_3_ancestor_cache_invalidation_through_secondary_view()
test_4_griddata_view_property_removed()
print("PASS")
if __name__ == "__main__":
try:
main()
sys.exit(0)
except Exception as e:
print(f"\nTEST FAILED: {e}")
import traceback
traceback.print_exc()
sys.exit(1)