Skip to content

Commit c972eea

Browse files
committed
Cover the mouse paths no scenario was exercising
Panel scenarios reach their editors through the control channel, which never touches the engine's mouse callins, so the recent screen-space change broke clicking without a single test noticing. mouse-coordinates is the one that would have caught it: a click places a feature, and the same pixel is traced through the control channel, which addresses the window top-origin. The two must describe the same spot, so a flip on either side fails with both coordinates in the message instead of moving trees around in a golden. mouse-ui drives the panel with nothing but X11 input -- tab, editor button, typed field, numeric drag -- and reads the editor model back. It cannot cover the panel's own hit test: RmlUi consumes a press over the panel document inside the engine, so those never reach the callin. Neither carries a golden, so together they cost about 3s of scenario time.
1 parent c16d1ea commit c972eea

2 files changed

Lines changed: 126 additions & 0 deletions

File tree

tools/e2e/driver/_scenarios.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from e2e.scenarios import console as console
1414
from e2e.scenarios import gallery as gallery
1515
from e2e.scenarios import misc as misc
16+
from e2e.scenarios import mouse as _mouse # noqa: F401
1617
from e2e.scenarios import shell as shell
1718
from e2e.scenarios import visual_sweep as visual_sweep
1819
from e2e.scenarios import workflows as _workflows # noqa: F401

tools/e2e/scenarios/mouse.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Physical-mouse coverage for the panel and for the screen-to-world contract.
2+
3+
Most scenarios reach the editors through the typed control channel, which never
4+
touches the engine's mouse callins. These two do the opposite: every input here
5+
is a real X11 event, and the assertions read the resulting state back through
6+
the control channel. They carry no goldens, so they stay cheap enough to run on
7+
every suite.
8+
"""
9+
10+
from typing import TYPE_CHECKING
11+
12+
from e2e.driver.timing import Delay
13+
from e2e.scenarios.helpers.camera import zoom_map
14+
from e2e.scenarios.helpers.geometry import (
15+
MAP,
16+
OBJECTS,
17+
TAB_X,
18+
TAB_Y,
19+
editor_point,
20+
panel_left,
21+
panel_point,
22+
window_size,
23+
)
24+
from e2e.scenarios.helpers.objects import arm_tree as _arm_tree
25+
from e2e.scenarios.helpers.objects import open_object_editor as _open
26+
from e2e.scenarios.helpers.registry import scenario
27+
from e2e.scenarios.objects.common import _placed
28+
29+
if TYPE_CHECKING:
30+
from e2e.driver.state import RunState
31+
32+
# A click and the ray query for the same pixel describe the same spot. The
33+
# budget covers the brush's own rounding, not a different point on the map: a
34+
# mirrored y lands hundreds of units away even near the middle of the view.
35+
TRACE_TOLERANCE = 40.0
36+
37+
38+
@scenario()
39+
def mouse_ui(run_state: "RunState") -> None:
40+
"""The panel, driven only by the pointer.
41+
42+
Tab, editor button, field click, typed commit and a numeric drag are all
43+
real X11 input, and the editor model is read back to prove each one landed
44+
on the control it aimed at. Without this, every editor is reachable in tests
45+
only through the control channel, which no user has.
46+
47+
Note what this cannot cover: RmlUi consumes a press over the panel document
48+
inside the engine, so those clicks never reach the native mouse callin at
49+
all. The panel's own hit test therefore sees map presses only, and this
50+
scenario proves the user-facing path rather than that hit test.
51+
"""
52+
run_state.focus()
53+
left = panel_left(run_state)
54+
run_state.click(left + TAB_X["map"], TAB_Y, delay=Delay.FRAME)
55+
run_state.click(*editor_point(left, "map", "terrain"), delay=Delay.READY)
56+
57+
terrain = run_state.control.editor("heightmapEditor")
58+
run_state.fill_text(
59+
*panel_point(left, MAP["terrain_size"]),
60+
"140",
61+
click_delay=Delay.FRAME,
62+
commit_delay=Delay.SETTLE,
63+
)
64+
typed = terrain.get("size")
65+
assert typed == 140.0, f"typing into the Size field gave {typed!r}, want 140.0"
66+
67+
# A numeric drag pins the pointer and warps it back, so the motion has to be
68+
# relative. The direction is what matters: the exact value depends on which
69+
# tick the release meets.
70+
run_state.press(*panel_point(left, MAP["terrain_size"]))
71+
run_state.move_relative(60)
72+
run_state.release(*panel_point(left, MAP["terrain_size"]), delay=Delay.SETTLE)
73+
dragged = terrain.get("size")
74+
assert isinstance(dragged, float), f"Size is {dragged!r}, want a number"
75+
assert dragged > typed, f"dragging right did not raise Size: {typed} -> {dragged}"
76+
77+
# A press beside the panel belongs to the map, not to the control that was
78+
# last touched. This is the ownership half of the hit test.
79+
run_state.fill_text(
80+
*panel_point(left, MAP["terrain_rotation"]),
81+
"15",
82+
click_delay=Delay.FRAME,
83+
commit_delay=Delay.SETTLE,
84+
)
85+
rotation = terrain.get("rotation")
86+
assert rotation == 15.0, f"the second field took {rotation!r}, want 15.0"
87+
assert terrain.get("size") == dragged, "editing Rotation moved Size"
88+
89+
90+
@scenario()
91+
def mouse_coordinates(run_state: "RunState") -> None:
92+
"""A click places an object where that pixel points.
93+
94+
The engine reports mouse callbacks in its own bottom-origin screen space,
95+
while control clients address the window top-origin like a screenshot. Both
96+
paths are asked about the same two pixels here, so a future flip on either
97+
side fails immediately -- and says so, rather than moving a golden's trees.
98+
"""
99+
run_state.focus()
100+
left = _open(run_state, "features")
101+
run_state.click(*panel_point(left, OBJECTS["add"]), delay=Delay.DIALOG)
102+
_arm_tree(run_state, left)
103+
104+
width, height = window_size(run_state)
105+
spot_x = width // 3
106+
zoom_map(run_state, point=(spot_x, height // 2))
107+
108+
# Two points either side of the middle: a mirrored y is invisible at the
109+
# centre of the view and grows with the distance from it.
110+
for label, spot_y in (("upper", height // 3), ("lower", height * 2 // 3)):
111+
expected = run_state.control.camera.trace_screen_ray(spot_x, spot_y)
112+
assert expected["hit_type"] == 3, f"{label}: the ray missed the ground: {expected}"
113+
mark = len(run_state.commands())
114+
run_state.click_settled(spot_x, spot_y, delay=Delay.SETTLE)
115+
placed = _placed(run_state, mark)
116+
assert len(placed) == 1, f"{label}: click placed {len(placed)} objects, want 1"
117+
118+
want_x, _want_y, want_z = expected["position"]
119+
got = placed[0]
120+
drift = max(abs(got["x"] - want_x), abs(got["z"] - want_z))
121+
assert drift <= TRACE_TOLERANCE, (
122+
f"{label}: click at ({spot_x}, {spot_y}) placed the feature at "
123+
f"({got['x']:.0f}, {got['z']:.0f}), but that pixel traces to "
124+
f"({want_x:.0f}, {want_z:.0f}) -- {drift:.0f} units away"
125+
)

0 commit comments

Comments
 (0)