McRogueFace/tests/regression/issue_335_layer_edit_test.py
John McCardle ad223b1ac0 feat(grid): numpy buffer views for ColorLayer/TileLayer via layer.edit(); closes #335
Implements the #328 bulk-edit convention for grid layers. Writable zero-copy
views of layer data are exposed ONLY through a context manager:

    with layer.edit() as view:        # ColorLayer -> (h, w, 4) uint8
        np.asarray(view)[...] = ...    # TileLayer  -> (h, w) int32 (-1 = no tile)
    # on __exit__ the whole layer is conservatively invalidated (markDirty),
    # so the edit re-renders.

Both layers already stored dense, contiguous, row-major data (std::vector<
sf::Color> / std::vector<int>; render caches chunk separately), so the views are
true zero-copy aliases. A new internal _LayerEdit type is the buffer exporter
(bf_getbuffer) and the context manager (__enter__ returns memoryview(self),
__exit__ calls GridLayer::markDirty()); it is not directly instantiable. The
conservative whole-layer invalidation on exit means no change-tracking is
needed, and it composes with the #351 render early-out (markDirty bumps
content_generation).

Independent of GridData SoA (#332): these are the render layers, not the logic
grid. A contiguous walkable/transparent view now has its storage prerequisite
from #332 but remains a separate follow-up.

Regression test issue_335_layer_edit_test.py: buffer shape/format/readonly,
zero-copy writeback both directions, the -1 tile sentinel, optional numpy
aliasing, and a screenshot check that __exit__ actually re-renders. API-surface
golden re-baselined (two new edit methods); docs/stubs regenerated; frozen
docstring gate 100%. Suite 312/312.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 02:04:42 -04:00

93 lines
3.2 KiB
Python

"""Regression test for #335 - ColorLayer/TileLayer numpy buffer views via edit().
Per the #328 bulk-edit convention, writable zero-copy views of layer data are
exposed only through `with layer.edit() as view:`. ColorLayer yields an
(h, w, 4) uint8 view; TileLayer yields an (h, w) int32 view (-1 = no tile).
Writes alias the layer storage (no copy); on __exit__ the whole layer is
conservatively invalidated so the edit re-renders.
Verifies buffer shape/format, zero-copy writeback both directions, the -1
sentinel, optional numpy aliasing, and that exiting the context actually
re-renders (screenshot diff -- the edit->markDirty->#351 early-out loop).
"""
import mcrfpy
from mcrfpy import automation
import sys
import os
import tempfile
TMPDIR = tempfile.mkdtemp(prefix="mcrf335_")
_n = 0
def shot():
global _n
p = os.path.join(TMPDIR, "s%d.png" % _n)
_n += 1
automation.screenshot(p)
with open(p, "rb") as f:
return f.read()
def main():
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
scene = mcrfpy.Scene("t335")
scene.activate()
cl = mcrfpy.ColorLayer(name="fog", z_index=1)
tl = mcrfpy.TileLayer(name="terrain", z_index=-2, texture=tex)
grid = mcrfpy.Grid(grid_size=(8, 6), pos=(10, 10), size=(256, 192),
texture=tex, layers=[tl, cl])
scene.children.append(grid)
# --- ColorLayer: (h, w, 4) uint8, writable, zero-copy both directions ---
with cl.edit() as view:
assert view.shape == (6, 8, 4), f"color shape {view.shape}"
assert view.format == 'B', f"color format {view.format}"
assert view.readonly is False
view[1, 2, 0] = 200 # y=1, x=2, red channel
assert cl.at(2, 1).r == 200, "color view -> layer writeback failed"
# layer -> view direction
cl.set((3, 4), mcrfpy.Color(7, 8, 9, 255)) # x=3, y=4
with cl.edit() as view:
assert view[4, 3, 0] == 7 and view[4, 3, 2] == 9, "layer -> view read failed"
# --- TileLayer: (h, w) int32, -1 sentinel ---
with tl.edit() as view:
assert view.shape == (6, 8), f"tile shape {view.shape}"
assert view.format == 'i', f"tile format {view.format}"
view[5, 6] = 42 # y=5, x=6
view[0, 0] = -1
assert tl.at(6, 5) == 42, "tile view -> layer writeback failed"
assert tl.at(0, 0) == -1, "tile -1 sentinel round-trip failed"
# --- Optional numpy zero-copy aliasing ---
try:
import numpy as np
with tl.edit() as view:
a = np.asarray(view)
assert a.shape == (6, 8) and a.dtype == np.int32, (a.shape, a.dtype)
a[3, 3] = 17
assert tl.at(3, 3) == 17, "numpy write did not alias TileLayer"
print(" numpy zero-copy verified")
except ImportError:
print(" numpy not bundled; memoryview path verified")
# --- __exit__ invalidation actually re-renders (edit -> markDirty -> #351) ---
before = shot()
with cl.edit() as view:
# paint a visible opaque block through the view
for y in range(6):
for x in range(8):
view[y, x, 0] = 255
view[y, x, 3] = 255
after = shot()
assert before != after, "layer edit did not re-render on __exit__"
print("PASS")
sys.exit(0)
if __name__ == "__main__":
main()