Since the #252 Grid/GridView split, mcrfpy.Grid(...) constructs a UIGridView (PyObjectsEnum::UIGRIDVIEW), but every input dispatch site gated on derived_type() == UIGRID — the internal mcrfpy._GridData type that no scene-graph node is ever an instance of. All grid input was dead: cell click/enter/exit callbacks never fired, clicks on a grid's child drawables and entities never dispatched, and hover inside grids did nothing. The cell-callback family was split three ways, which is the actual bug: storage on GridData, firing on UIGrid (it needed UIDrawable::serial_number), and the camera required to resolve a screen pixel into a cell on UIGridView. So merely re-pointing the enum gate at UIGRIDVIEW would still report wrong cells for any panned or zoomed grid, and UIGrid::fireCellClick's subclass lookup resolved the serial of the throwaway _GridData wrapper that init_with_data DECREFs — meaning a Python subclass overriding on_cell_click as a method could never dispatch. Input ownership now lives entirely on UIGridView: - on_cell_click/enter/exit, hovered_cell, last_clicked_cell and the subclass dispatch cache move off GridData/UIGrid (hovered_cell must be per-view: two views over one GridData would otherwise ping-pong exit/enter). - UIGridView::click_at descends children (reverse z) -> entities -> cell. Children keep grid-world pixel coordinates: that is by design (#360), not an inconsistency with Frame's frame-local children. - The six derived_type()==UIGRID gates are replaced by virtuals on UIDrawable (dispatchCellClick, updateHover, asGridData) so no caller switches on grid-ness again. - Fixes a prerequisite bug: UIGridView never overrode onPositionChanged(), so box and position diverged and a repositioned Grid rendered at the stale spot while hit-testing against the new one. Any cell hit-test was meaningless until these agreed. - grid.perspective was silently dead (the FOV overlay gated on a UIGridView::perspective_enabled that nothing ever set); perspective state moves to the view with the rest. #359 is fused into this commit rather than sequenced after it: the shared-data back-reference is rewritten by the same hunks that move the callbacks off GridData, and separating them would mean authoring an intermediate GridData.h / UIGridView.cpp that never existed. GridData::owning_view (a single weak_ptr, for a relationship that is N views to one GridData) becomes a vector<weak_ptr> registry with register/unregister. The registry is kept rather than deleted: the markCompositeDirty push is NOT redundant with #351's content_generation poll, because it propagates bottom-up through each view's ancestor chain (e.g. a Frame(use_render_texture=True) wrapping a view), which a top-down render-time poll cannot do. Grid subclass method dispatch, panned/zoomed cell resolution, grid-child clicks, and cell hover are all covered by new regression tests. tests/unit/ test_grid_cell_events.py previously printed "PARTIAL (events may require interactive mode)" and exited 0 having fired zero events — that dishonest pass is why this shipped broken; it now asserts. Suite: 322/322. closes #355 closes #359 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
223 lines
8.4 KiB
Python
223 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Regression test for #355 - mcrfpy.Grid input is entirely dead.
|
|
|
|
Since the #252 Grid/GridView split, mcrfpy.Grid(...) constructs a
|
|
UIGridView (PyObjectsEnum::UIGRIDVIEW). Every input dispatch site that
|
|
matters for grid cell/child/entity interactivity (PyScene::do_mouse_input,
|
|
PyScene::do_mouse_hover's processHover, and the subclass-method lookup in
|
|
UIGrid::fireCellClick) still gates on derived_type() == UIGRID -- the
|
|
*internal* _GridData type nothing user-visible is ever an instance of. As a
|
|
result:
|
|
- on_cell_click / on_cell_enter / on_cell_exit never fire on a real Grid.
|
|
- Child drawables appended to grid.children never receive their own
|
|
on_click.
|
|
- A Python subclass of mcrfpy.Grid that overrides on_cell_click as a
|
|
*method* (rather than assigning the on_cell_click property) is doubly
|
|
broken: is_python_subclass/serial_number tracking for that lookup is
|
|
keyed off the internal _GridData wrapper, not the public Grid/GridView
|
|
instance a subclass is actually built from.
|
|
|
|
Each check below is a hard assertion (sys.exit via unhandled AssertionError
|
|
on failure) -- no "PARTIAL" outs.
|
|
|
|
Coordinate math: with an unmodified camera and an explicit `size=`,
|
|
UIGridView's default center is size/(2*zoom) == the viewport's own
|
|
half-width/half-height, which cancels the half-width term in the
|
|
screen->cell formula, so grid_space == local screen-relative offset
|
|
whenever zoom==1. cell = floor(grid_space / cell_pixel_size), with the
|
|
16x16 default texture. Case (b) explicitly overrides grid.center/grid.zoom
|
|
to non-default values and uses the full formula
|
|
(grid_space = (local - half_width) / zoom + center_x) so it is a genuine
|
|
discriminator against a fix that reads the wrong (stale, internal
|
|
_GridData-owned) camera copy instead of the public Grid/GridView's own
|
|
center_x/center_y/zoom.
|
|
"""
|
|
import sys
|
|
import mcrfpy
|
|
from mcrfpy import automation
|
|
|
|
|
|
def test_a_plain_cell_click():
|
|
"""(a) on_cell_click fires with the correct cell for a click on a plain Grid."""
|
|
print("(a) plain grid cell click...")
|
|
scene = mcrfpy.Scene("issue355_a")
|
|
ui = scene.children
|
|
mcrfpy.current_scene = scene
|
|
|
|
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
|
|
ui.append(grid)
|
|
|
|
clicks = []
|
|
def on_cell_click(cell_pos, button, action):
|
|
clicks.append((cell_pos.x, cell_pos.y, button, action))
|
|
grid.on_cell_click = on_cell_click
|
|
|
|
# local=(36,52) -> cell (2,3), see module docstring for the math.
|
|
automation.click((136, 152))
|
|
mcrfpy.step(0.05)
|
|
|
|
assert len(clicks) >= 1, f"on_cell_click never fired; clicks={clicks}"
|
|
cx, cy, button, action = clicks[0]
|
|
assert (cx, cy) == (2, 3), f"expected cell (2,3), got ({cx},{cy})"
|
|
assert button == mcrfpy.MouseButton.LEFT
|
|
assert action == mcrfpy.InputState.PRESSED
|
|
print(" OK")
|
|
|
|
|
|
def test_b_panned_zoomed_cell_click():
|
|
"""(b) on_cell_click reports the CORRECT cell after grid.center/grid.zoom
|
|
are set to non-default values -- discriminates against a fix that reads
|
|
a stale internal camera copy instead of the public Grid's own."""
|
|
print("(b) panned+zoomed grid cell click...")
|
|
scene = mcrfpy.Scene("issue355_b")
|
|
ui = scene.children
|
|
mcrfpy.current_scene = scene
|
|
|
|
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(50, 50), size=(200, 200))
|
|
ui.append(grid)
|
|
|
|
grid.zoom = 2.0
|
|
grid.center = (88, 88) # pixel-space camera center (== tile (5.5,5.5) at 16px cells)
|
|
assert grid.zoom == 2.0
|
|
assert (grid.center.x, grid.center.y) == (88, 88), \
|
|
f"grid.center did not take the assigned value: {grid.center}"
|
|
|
|
clicks = []
|
|
def on_cell_click(cell_pos, button, action):
|
|
clicks.append((cell_pos.x, cell_pos.y))
|
|
grid.on_cell_click = on_cell_click
|
|
|
|
# local=(60,60); grid_space = (60-100)/2.0 + 88 = 68; cell = floor(68/16) = 4
|
|
automation.click((110, 110))
|
|
mcrfpy.step(0.05)
|
|
|
|
assert len(clicks) >= 1, f"on_cell_click never fired under pan/zoom; clicks={clicks}"
|
|
assert clicks[0] == (4, 4), (
|
|
f"expected cell (4,4) under grid.center=(88,88)/grid.zoom=2.0, got {clicks[0]} "
|
|
"(a stale-camera fix would report (3,3) instead)"
|
|
)
|
|
print(" OK")
|
|
|
|
|
|
def test_c_hover_boundary():
|
|
"""(c) on_cell_enter / on_cell_exit fire on hover across a cell boundary."""
|
|
print("(c) cell hover enter/exit across a boundary...")
|
|
scene = mcrfpy.Scene("issue355_c")
|
|
ui = scene.children
|
|
mcrfpy.current_scene = scene
|
|
|
|
grid = mcrfpy.Grid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
|
|
ui.append(grid)
|
|
|
|
enters = []
|
|
exits = []
|
|
grid.on_cell_enter = lambda pos: enters.append((pos.x, pos.y))
|
|
grid.on_cell_exit = lambda pos: exits.append((pos.x, pos.y))
|
|
|
|
# local=(40,40) -> cell (2,2); local=(56,40) -> cell (3,2)
|
|
automation.moveTo((140, 140))
|
|
mcrfpy.step(0.05)
|
|
automation.moveTo((156, 140))
|
|
mcrfpy.step(0.05)
|
|
|
|
assert (2, 2) in enters, f"on_cell_enter never fired for (2,2); enters={enters}"
|
|
assert (2, 2) in exits, f"on_cell_exit never fired for (2,2); exits={exits}"
|
|
assert (3, 2) in enters, f"on_cell_enter never fired for (3,2); enters={enters}"
|
|
print(" OK")
|
|
|
|
|
|
def test_d_child_drawable_click():
|
|
"""(d) A Frame child of grid.children receives its own on_click when
|
|
clicked, at the screen pixel derived from the grid's camera transform
|
|
(children live in grid-world pixel coordinates, not screen pixels)."""
|
|
print("(d) child drawable click (grid-world pixel coords via camera)...")
|
|
scene = mcrfpy.Scene("issue355_d")
|
|
ui = scene.children
|
|
mcrfpy.current_scene = scene
|
|
|
|
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(50, 50), size=(200, 200))
|
|
ui.append(grid)
|
|
|
|
# Grid-world pixel box [90,110]x[90,110]. Default camera (center=(100,100),
|
|
# zoom=1) makes grid_space == local, so:
|
|
# screen (150,150) -> local (100,100) -> world (100,100): INSIDE the frame
|
|
# screen (120,120) -> local (70,70) -> world (70,70): OUTSIDE the frame
|
|
frame = mcrfpy.Frame(pos=(90, 90), size=(20, 20))
|
|
grid.children.append(frame)
|
|
|
|
frame_clicks = []
|
|
def on_frame_click(pos, button, action):
|
|
frame_clicks.append((pos.x, pos.y))
|
|
frame.on_click = on_frame_click
|
|
|
|
# Miss first: proves the screen point isn't just always hitting the frame.
|
|
automation.click((120, 120))
|
|
mcrfpy.step(0.05)
|
|
assert len(frame_clicks) == 0, (
|
|
f"frame.on_click fired for a screen point outside its grid-world bounds: {frame_clicks}"
|
|
)
|
|
|
|
automation.click((150, 150))
|
|
mcrfpy.step(0.05)
|
|
assert len(frame_clicks) >= 1, (
|
|
"frame.on_click never fired for a click on a Frame in grid.children "
|
|
"at the correctly camera-transformed screen pixel"
|
|
)
|
|
print(" OK")
|
|
|
|
|
|
def test_e_subclass_method_override():
|
|
"""(e) A Python subclass of mcrfpy.Grid overriding on_cell_click as a
|
|
METHOD (not the property) has that method invoked."""
|
|
print("(e) Grid subclass on_cell_click method override...")
|
|
|
|
class ClickCountingGrid(mcrfpy.Grid):
|
|
def __init__(self, *a, **kw):
|
|
super().__init__(*a, **kw)
|
|
self.clicked_cells = []
|
|
|
|
def on_cell_click(self, cell_pos, button, action):
|
|
self.clicked_cells.append((cell_pos.x, cell_pos.y))
|
|
|
|
scene = mcrfpy.Scene("issue355_e")
|
|
ui = scene.children
|
|
mcrfpy.current_scene = scene
|
|
|
|
mg = ClickCountingGrid(grid_size=(5, 5), pos=(100, 100), size=(200, 200))
|
|
ui.append(mg)
|
|
|
|
# local=(36,52) -> cell (2,3), same math as (a).
|
|
automation.click((136, 152))
|
|
mcrfpy.step(0.05)
|
|
|
|
assert len(mg.clicked_cells) >= 1, (
|
|
"ClickCountingGrid.on_cell_click (method override) never fired"
|
|
)
|
|
assert mg.clicked_cells[0] == (2, 3), \
|
|
f"expected cell (2,3), got {mg.clicked_cells[0]}"
|
|
print(" OK")
|
|
|
|
|
|
def main():
|
|
test_a_plain_cell_click()
|
|
test_b_panned_zoomed_cell_click()
|
|
test_c_hover_boundary()
|
|
test_d_child_drawable_click()
|
|
test_e_subclass_method_override()
|
|
print("PASS")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# NOTE: --exec does not turn an unhandled Python exception into a
|
|
# non-zero process exit code (verified empirically), so failures MUST
|
|
# be caught and translated to sys.exit(1) explicitly here -- letting a
|
|
# bare AssertionError propagate would silently "pass" the suite, which
|
|
# is exactly the masking anti-pattern #355 shipped behind.
|
|
try:
|
|
main()
|
|
sys.exit(0)
|
|
except Exception as e:
|
|
print(f"\nTEST FAILED: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
sys.exit(1)
|