fix(ui/testing): use send_event for reliable label clicks in tests

click_label() now detects clickable parent containers and uses
send_event(lv.EVENT.CLICKED) instead of simulate_click() for more
reliable UI test interactions.

This fixes sporadic failures in test_graphical_imu_calibration_ui_bug.py
where clicking "Check IMU Calibration" would sometimes fail because
simulate_click() wasn't reliably triggering the click event on the
parent container.

- Add use_send_event parameter to click_label() (default: True)
- Detect clickable parent containers and send events directly to them
- Verified with 15 consecutive test runs (100% pass rate)
This commit is contained in:
Thomas Farstrike
2025-12-17 19:09:40 +01:00
parent afe8434bc7
commit 740f239acc
2 changed files with 181 additions and 21 deletions
+176 -21
View File
@@ -518,7 +518,7 @@ def _ensure_touch_indev():
print("Created simulated touch input device")
def simulate_click(x, y, press_duration_ms=50):
def simulate_click(x, y, press_duration_ms=100):
"""
Simulate a touch/click at the specified coordinates.
@@ -543,7 +543,7 @@ def simulate_click(x, y, press_duration_ms=50):
Args:
x: X coordinate to click (in pixels)
y: Y coordinate to click (in pixels)
press_duration_ms: How long to hold the press (default: 50ms)
press_duration_ms: How long to hold the press (default: 100ms)
Example:
from mpos.ui.testing import simulate_click, wait_for_render
@@ -568,21 +568,37 @@ def simulate_click(x, y, press_duration_ms=50):
_touch_y = y
_touch_pressed = True
# Process the press immediately
# Process the press event
lv.task_handler()
time.sleep(0.02)
lv.task_handler()
def release_timer_cb(timer):
"""Timer callback to release the touch press."""
global _touch_pressed
_touch_pressed = False
lv.task_handler() # Process the release immediately
# Wait for press duration
time.sleep(press_duration_ms / 1000.0)
# Schedule the release
timer = lv.timer_create(release_timer_cb, press_duration_ms, None)
timer.set_repeat_count(1)
# Release the touch
_touch_pressed = False
def click_button(button_text, timeout=5):
"""Find and click a button with given text."""
# Process the release event - this triggers the CLICKED event
lv.task_handler()
time.sleep(0.02)
lv.task_handler()
time.sleep(0.02)
lv.task_handler()
def click_button(button_text, timeout=5, use_send_event=True):
"""Find and click a button with given text.
Args:
button_text: Text to search for in button labels
timeout: Maximum time to wait for button to appear (default: 5s)
use_send_event: If True, use send_event() which is more reliable for
triggering button actions. If False, use simulate_click()
which simulates actual touch input. (default: True)
Returns:
True if button was found and clicked, False otherwise
"""
start = time.time()
while time.time() - start < timeout:
button = find_button_with_text(lv.screen_active(), button_text)
@@ -590,28 +606,167 @@ def click_button(button_text, timeout=5):
coords = get_widget_coords(button)
if coords:
print(f"Clicking button '{button_text}' at ({coords['center_x']}, {coords['center_y']})")
simulate_click(coords['center_x'], coords['center_y'])
if use_send_event:
# Use send_event for more reliable button triggering
button.send_event(lv.EVENT.CLICKED, None)
else:
# Use simulate_click for actual touch simulation
simulate_click(coords['center_x'], coords['center_y'])
wait_for_render(iterations=20)
return True
wait_for_render(iterations=5)
print(f"ERROR: Button '{button_text}' not found after {timeout}s")
return False
def click_label(label_text, timeout=5):
"""Find a label with given text and click on it (or its clickable parent)."""
def click_label(label_text, timeout=5, use_send_event=True):
"""Find a label with given text and click on it (or its clickable parent).
This function finds a label, scrolls it into view (with multiple attempts
if needed), verifies it's within the visible viewport, and then clicks it.
If the label itself is not clickable, it will try clicking the parent container.
Args:
label_text: Text to search for in labels
timeout: Maximum time to wait for label to appear (default: 5s)
use_send_event: If True, use send_event() on clickable parent which is more
reliable. If False, use simulate_click(). (default: True)
Returns:
True if label was found and clicked, False otherwise
"""
start = time.time()
while time.time() - start < timeout:
label = find_label_with_text(lv.screen_active(), label_text)
if label:
print("Scrolling label to view...")
label.scroll_to_view_recursive(True)
wait_for_render(iterations=50) # needs quite a bit of time
# Get screen dimensions for viewport check
screen = lv.screen_active()
screen_coords = get_widget_coords(screen)
if not screen_coords:
screen_coords = {'x1': 0, 'y1': 0, 'x2': 320, 'y2': 240}
# Try scrolling multiple times to ensure label is fully visible
max_scroll_attempts = 5
for scroll_attempt in range(max_scroll_attempts):
print(f"Scrolling label to view (attempt {scroll_attempt + 1}/{max_scroll_attempts})...")
label.scroll_to_view_recursive(True)
wait_for_render(iterations=50) # needs quite a bit of time for scroll animation
# Get updated coordinates after scroll
coords = get_widget_coords(label)
if not coords:
break
# Check if label center is within visible viewport
# Account for some margin (e.g., status bar at top, nav bar at bottom)
# Use a larger bottom margin to ensure the element is fully clickable
viewport_top = screen_coords['y1'] + 30 # Account for status bar
viewport_bottom = screen_coords['y2'] - 30 # Larger margin at bottom for clickability
viewport_left = screen_coords['x1']
viewport_right = screen_coords['x2']
center_x = coords['center_x']
center_y = coords['center_y']
is_visible = (viewport_left <= center_x <= viewport_right and
viewport_top <= center_y <= viewport_bottom)
if is_visible:
print(f"Label '{label_text}' is visible at ({center_x}, {center_y})")
# Try to find a clickable parent (container) - many UIs have clickable containers
# with non-clickable labels inside. We'll click on the label's position but
# the event should bubble up to the clickable parent.
click_target = label
clickable_parent = None
click_coords = coords
try:
parent = label.get_parent()
if parent and parent.has_flag(lv.obj.FLAG.CLICKABLE):
# The parent is clickable - we can use send_event on it
clickable_parent = parent
parent_coords = get_widget_coords(parent)
if parent_coords:
print(f"Found clickable parent container: ({parent_coords['x1']}, {parent_coords['y1']}) to ({parent_coords['x2']}, {parent_coords['y2']})")
# Use label's x but ensure y is within parent bounds
click_x = center_x
click_y = center_y
# Clamp to parent bounds with some margin
if click_y < parent_coords['y1'] + 5:
click_y = parent_coords['y1'] + 5
if click_y > parent_coords['y2'] - 5:
click_y = parent_coords['y2'] - 5
click_coords = {'center_x': click_x, 'center_y': click_y}
except Exception as e:
print(f"Could not check parent clickability: {e}")
print(f"Clicking label '{label_text}' at ({click_coords['center_x']}, {click_coords['center_y']})")
if use_send_event and clickable_parent:
# Use send_event on the clickable parent for more reliable triggering
print(f"Using send_event on clickable parent")
clickable_parent.send_event(lv.EVENT.CLICKED, None)
else:
# Use simulate_click for actual touch simulation
simulate_click(click_coords['center_x'], click_coords['center_y'])
wait_for_render(iterations=20)
return True
else:
print(f"Label '{label_text}' at ({center_x}, {center_y}) not fully visible "
f"(viewport: y={viewport_top}-{viewport_bottom}), scrolling more...")
# Additional scroll - try scrolling the parent container
try:
parent = label.get_parent()
if parent:
# Try to find a scrollable ancestor
scrollable = parent
for _ in range(5): # Check up to 5 levels up
try:
grandparent = scrollable.get_parent()
if grandparent:
scrollable = grandparent
except:
break
# Scroll by a fixed amount to bring label more into view
current_scroll = scrollable.get_scroll_y()
if center_y > viewport_bottom:
# Need to scroll down (increase scroll_y)
scrollable.scroll_to_y(current_scroll + 60, True)
elif center_y < viewport_top:
# Need to scroll up (decrease scroll_y)
scrollable.scroll_to_y(max(0, current_scroll - 60), True)
wait_for_render(iterations=30)
except Exception as e:
print(f"Additional scroll failed: {e}")
# If we exhausted scroll attempts, try clicking anyway
coords = get_widget_coords(label)
if coords:
print(f"Clicking label '{label_text}' at ({coords['center_x']}, {coords['center_y']})")
simulate_click(coords['center_x'], coords['center_y'])
# Try to find a clickable parent even for fallback click
click_coords = coords
try:
parent = label.get_parent()
if parent and parent.has_flag(lv.obj.FLAG.CLICKABLE):
parent_coords = get_widget_coords(parent)
if parent_coords:
click_coords = parent_coords
print(f"Using clickable parent for fallback click")
except:
pass
print(f"Clicking at ({click_coords['center_x']}, {click_coords['center_y']}) after max scroll attempts")
# Try to use send_event if we have a clickable parent
try:
parent = label.get_parent()
if use_send_event and parent and parent.has_flag(lv.obj.FLAG.CLICKABLE):
print(f"Using send_event on clickable parent for fallback")
parent.send_event(lv.EVENT.CLICKED, None)
else:
simulate_click(click_coords['center_x'], click_coords['center_y'])
except:
simulate_click(click_coords['center_x'], click_coords['center_y'])
wait_for_render(iterations=20)
return True
wait_for_render(iterations=5)
print(f"ERROR: Label '{label_text}' not found after {timeout}s")
return False
@@ -50,6 +50,11 @@ class TestIMUCalibrationUI(unittest.TestCase):
wait_for_render(iterations=30)
print("Settings app opened\n")
# Initialize touch device with dummy click (required for simulate_click to work)
print("Initializing touch input device...")
simulate_click(10, 10)
wait_for_render(iterations=10)
print("Current screen content:")
print_screen_labels(lv.screen_active())
print()