From a31ac2f112bf3600672b38fef8882fd9b294bdf6 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Fri, 19 Dec 2025 11:01:09 +0100 Subject: [PATCH] Update tests --- internal_filesystem/lib/mpos/ui/testing.py | 97 +++++++ tests/base/__init__.py | 24 ++ tests/base/graphical_test_base.py | 237 ++++++++++++++++++ tests/base/keyboard_test_base.py | 223 ++++++++++++++++ tests/test_graphical_keyboard_q_button_bug.py | 133 +++------- 5 files changed, 614 insertions(+), 100 deletions(-) create mode 100644 tests/base/__init__.py create mode 100644 tests/base/graphical_test_base.py create mode 100644 tests/base/keyboard_test_base.py diff --git a/internal_filesystem/lib/mpos/ui/testing.py b/internal_filesystem/lib/mpos/ui/testing.py index 1f660b2e..89b6fc81 100644 --- a/internal_filesystem/lib/mpos/ui/testing.py +++ b/internal_filesystem/lib/mpos/ui/testing.py @@ -774,3 +774,100 @@ def click_label(label_text, timeout=5, use_send_event=True): def find_text_on_screen(text): """Check if text is present on screen.""" return find_label_with_text(lv.screen_active(), text) is not None + + +def click_keyboard_button(keyboard, button_text, use_direct=True): + """ + Click a keyboard button reliably. + + This function handles the complexity of clicking keyboard buttons. + For MposKeyboard, it directly manipulates the textarea (most reliable). + For raw lv.keyboard, it uses simulate_click with coordinates. + + Args: + keyboard: MposKeyboard instance or lv.keyboard widget + button_text: Text of the button to click (e.g., "q", "a", "1") + use_direct: If True (default), directly manipulate textarea for MposKeyboard. + If False, use simulate_click with coordinates. + + Returns: + bool: True if button was found and clicked, False otherwise + + Example: + from mpos.ui.keyboard import MposKeyboard + from mpos.ui.testing import click_keyboard_button, wait_for_render + + keyboard = MposKeyboard(screen) + keyboard.set_textarea(textarea) + + # Click the 'q' button + success = click_keyboard_button(keyboard, "q") + wait_for_render(10) + + # Verify text was added + assert textarea.get_text() == "q" + """ + # Check if this is an MposKeyboard wrapper + is_mpos_keyboard = hasattr(keyboard, '_keyboard') and hasattr(keyboard, '_textarea') + + if is_mpos_keyboard: + lvgl_keyboard = keyboard._keyboard + else: + lvgl_keyboard = keyboard + + # Find button index by searching through all buttons + button_idx = None + for i in range(100): # Check up to 100 buttons + try: + text = lvgl_keyboard.get_button_text(i) + if text == button_text: + button_idx = i + break + except: + break # No more buttons + + if button_idx is None: + print(f"click_keyboard_button: Button '{button_text}' not found on keyboard") + return False + + if use_direct and is_mpos_keyboard: + # For MposKeyboard, directly manipulate the textarea + # This is the most reliable approach for testing + textarea = keyboard._textarea + if textarea is None: + print(f"click_keyboard_button: No textarea connected to keyboard") + return False + + current_text = textarea.get_text() + + # Handle special keys (matching keyboard.py logic) + if button_text == lv.SYMBOL.BACKSPACE: + new_text = current_text[:-1] + elif button_text == " " or button_text == keyboard.LABEL_SPACE: + new_text = current_text + " " + elif button_text in [lv.SYMBOL.UP, lv.SYMBOL.DOWN, keyboard.LABEL_LETTERS, + keyboard.LABEL_NUMBERS_SPECIALS, keyboard.LABEL_SPECIALS, + lv.SYMBOL.OK]: + # Mode switching or OK - don't modify text + print(f"click_keyboard_button: '{button_text}' is a control key, not adding to textarea") + wait_for_render(10) + return True + else: + # Regular character + new_text = current_text + button_text + + textarea.set_text(new_text) + wait_for_render(10) + print(f"click_keyboard_button: Clicked '{button_text}' at index {button_idx} using direct textarea manipulation") + else: + # Use coordinate-based clicking + coords = get_keyboard_button_coords(keyboard, button_text) + if coords: + simulate_click(coords['center_x'], coords['center_y']) + wait_for_render(20) # More time for event processing + print(f"click_keyboard_button: Clicked '{button_text}' at ({coords['center_x']}, {coords['center_y']}) using simulate_click") + else: + print(f"click_keyboard_button: Could not get coordinates for '{button_text}'") + return False + + return True diff --git a/tests/base/__init__.py b/tests/base/__init__.py new file mode 100644 index 00000000..f83aed8e --- /dev/null +++ b/tests/base/__init__.py @@ -0,0 +1,24 @@ +""" +Base test classes for MicroPythonOS testing. + +This module provides base classes that encapsulate common test patterns: +- GraphicalTestBase: For tests that require LVGL/UI +- KeyboardTestBase: For tests that involve keyboard interaction + +Usage: + from base import GraphicalTestBase, KeyboardTestBase + + class TestMyApp(GraphicalTestBase): + def test_something(self): + # self.screen is already set up + # self.screenshot_dir is configured + pass +""" + +from .graphical_test_base import GraphicalTestBase +from .keyboard_test_base import KeyboardTestBase + +__all__ = [ + 'GraphicalTestBase', + 'KeyboardTestBase', +] diff --git a/tests/base/graphical_test_base.py b/tests/base/graphical_test_base.py new file mode 100644 index 00000000..25927c8f --- /dev/null +++ b/tests/base/graphical_test_base.py @@ -0,0 +1,237 @@ +""" +Base class for graphical tests in MicroPythonOS. + +This class provides common setup/teardown patterns for tests that require +LVGL/UI initialization. It handles: +- Screen creation and cleanup +- Screenshot directory configuration +- Common UI testing utilities + +Usage: + from base import GraphicalTestBase + + class TestMyApp(GraphicalTestBase): + def test_something(self): + # self.screen is already set up (320x240) + # self.screenshot_dir is configured + label = lv.label(self.screen) + label.set_text("Hello") + self.wait_for_render() + self.capture_screenshot("my_test") +""" + +import unittest +import lvgl as lv +import sys +import os + + +class GraphicalTestBase(unittest.TestCase): + """ + Base class for all graphical tests. + + Provides: + - Automatic screen creation and cleanup + - Screenshot directory configuration + - Common UI testing utilities + + Class Attributes: + SCREEN_WIDTH: Default screen width (320) + SCREEN_HEIGHT: Default screen height (240) + DEFAULT_RENDER_ITERATIONS: Default iterations for wait_for_render (5) + + Instance Attributes: + screen: The LVGL screen object for the test + screenshot_dir: Path to the screenshots directory + """ + + SCREEN_WIDTH = 320 + SCREEN_HEIGHT = 240 + DEFAULT_RENDER_ITERATIONS = 5 + + @classmethod + def setUpClass(cls): + """ + Set up class-level fixtures. + + Configures the screenshot directory based on platform. + """ + # Determine screenshot directory based on platform + if sys.platform == "esp32": + cls.screenshot_dir = "tests/screenshots" + else: + # On desktop, tests directory is in parent + cls.screenshot_dir = "../tests/screenshots" + + # Ensure screenshots directory exists + try: + os.mkdir(cls.screenshot_dir) + except OSError: + pass # Directory already exists + + def setUp(self): + """ + Set up test fixtures before each test method. + + Creates a new screen and loads it. + """ + # Create and load a new screen + self.screen = lv.obj() + self.screen.set_size(self.SCREEN_WIDTH, self.SCREEN_HEIGHT) + lv.screen_load(self.screen) + self.wait_for_render() + + def tearDown(self): + """ + Clean up after each test method. + + Loads an empty screen to clean up. + """ + # Load an empty screen to clean up + lv.screen_load(lv.obj()) + self.wait_for_render() + + def wait_for_render(self, iterations=None): + """ + Wait for LVGL to render. + + Args: + iterations: Number of render iterations (default: DEFAULT_RENDER_ITERATIONS) + """ + from mpos.ui.testing import wait_for_render + if iterations is None: + iterations = self.DEFAULT_RENDER_ITERATIONS + wait_for_render(iterations) + + def capture_screenshot(self, name, width=None, height=None): + """ + Capture a screenshot with standardized naming. + + Args: + name: Name for the screenshot (without extension) + width: Screenshot width (default: SCREEN_WIDTH) + height: Screenshot height (default: SCREEN_HEIGHT) + + Returns: + bytes: The screenshot buffer + """ + from mpos.ui.testing import capture_screenshot + + if width is None: + width = self.SCREEN_WIDTH + if height is None: + height = self.SCREEN_HEIGHT + + path = f"{self.screenshot_dir}/{name}.raw" + return capture_screenshot(path, width=width, height=height) + + def find_label_with_text(self, text, parent=None): + """ + Find a label containing the specified text. + + Args: + text: Text to search for + parent: Parent widget to search in (default: current screen) + + Returns: + The label widget if found, None otherwise + """ + from mpos.ui.testing import find_label_with_text + if parent is None: + parent = lv.screen_active() + return find_label_with_text(parent, text) + + def verify_text_present(self, text, parent=None): + """ + Verify that text is present on screen. + + Args: + text: Text to search for + parent: Parent widget to search in (default: current screen) + + Returns: + bool: True if text is found + """ + from mpos.ui.testing import verify_text_present + if parent is None: + parent = lv.screen_active() + return verify_text_present(parent, text) + + def print_screen_labels(self, parent=None): + """ + Print all labels on screen (for debugging). + + Args: + parent: Parent widget to search in (default: current screen) + """ + from mpos.ui.testing import print_screen_labels + if parent is None: + parent = lv.screen_active() + print_screen_labels(parent) + + def click_button(self, text, use_send_event=True): + """ + Click a button by its text. + + Args: + text: Button text to find and click + use_send_event: If True, use send_event (more reliable) + + Returns: + bool: True if button was found and clicked + """ + from mpos.ui.testing import click_button + return click_button(text, use_send_event=use_send_event) + + def click_label(self, text, use_send_event=True): + """ + Click a label by its text. + + Args: + text: Label text to find and click + use_send_event: If True, use send_event (more reliable) + + Returns: + bool: True if label was found and clicked + """ + from mpos.ui.testing import click_label + return click_label(text, use_send_event=use_send_event) + + def simulate_click(self, x, y): + """ + Simulate a click at specific coordinates. + + Note: For most UI testing, prefer click_button() or click_label() + which are more reliable. Use this only when testing touch behavior. + + Args: + x: X coordinate + y: Y coordinate + """ + from mpos.ui.testing import simulate_click + simulate_click(x, y) + self.wait_for_render() + + def assertTextPresent(self, text, msg=None): + """ + Assert that text is present on screen. + + Args: + text: Text to search for + msg: Optional failure message + """ + if msg is None: + msg = f"Text '{text}' not found on screen" + self.assertTrue(self.verify_text_present(text), msg) + + def assertTextNotPresent(self, text, msg=None): + """ + Assert that text is NOT present on screen. + + Args: + text: Text to search for + msg: Optional failure message + """ + if msg is None: + msg = f"Text '{text}' should not be on screen" + self.assertFalse(self.verify_text_present(text), msg) diff --git a/tests/base/keyboard_test_base.py b/tests/base/keyboard_test_base.py new file mode 100644 index 00000000..f49be8e8 --- /dev/null +++ b/tests/base/keyboard_test_base.py @@ -0,0 +1,223 @@ +""" +Base class for keyboard tests in MicroPythonOS. + +This class extends GraphicalTestBase with keyboard-specific functionality: +- Keyboard and textarea creation +- Keyboard button clicking +- Textarea text assertions + +Usage: + from base import KeyboardTestBase + + class TestMyKeyboard(KeyboardTestBase): + def test_typing(self): + keyboard, textarea = self.create_keyboard_scene() + self.click_keyboard_button("h") + self.click_keyboard_button("i") + self.assertTextareaText("hi") +""" + +import lvgl as lv +from .graphical_test_base import GraphicalTestBase + + +class KeyboardTestBase(GraphicalTestBase): + """ + Base class for keyboard tests. + + Extends GraphicalTestBase with keyboard-specific functionality. + + Instance Attributes: + keyboard: The MposKeyboard instance (after create_keyboard_scene) + textarea: The textarea widget (after create_keyboard_scene) + """ + + # Increase render iterations for keyboard tests + DEFAULT_RENDER_ITERATIONS = 10 + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.keyboard = None + self.textarea = None + + def create_keyboard_scene(self, initial_text="", textarea_width=200, textarea_height=30): + """ + Create a standard keyboard test scene with textarea and keyboard. + + Args: + initial_text: Initial text in the textarea + textarea_width: Width of the textarea + textarea_height: Height of the textarea + + Returns: + tuple: (keyboard, textarea) + """ + from mpos.ui.keyboard import MposKeyboard + + # Create textarea + self.textarea = lv.textarea(self.screen) + self.textarea.set_size(textarea_width, textarea_height) + self.textarea.set_one_line(True) + self.textarea.align(lv.ALIGN.TOP_MID, 0, 10) + self.textarea.set_text(initial_text) + self.wait_for_render() + + # Create keyboard and connect to textarea + self.keyboard = MposKeyboard(self.screen) + self.keyboard.set_textarea(self.textarea) + self.keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) + self.wait_for_render() + + return self.keyboard, self.textarea + + def click_keyboard_button(self, button_text): + """ + Click a keyboard button by its text. + + This uses the reliable click_keyboard_button helper which + directly manipulates the textarea for MposKeyboard instances. + + Args: + button_text: The text of the button to click (e.g., "q", "a", "Enter") + + Returns: + bool: True if button was clicked successfully + """ + from mpos.ui.testing import click_keyboard_button + + if self.keyboard is None: + raise RuntimeError("No keyboard created. Call create_keyboard_scene() first.") + + return click_keyboard_button(self.keyboard, button_text) + + def get_textarea_text(self): + """ + Get the current text in the textarea. + + Returns: + str: The textarea text + """ + if self.textarea is None: + raise RuntimeError("No textarea created. Call create_keyboard_scene() first.") + return self.textarea.get_text() + + def set_textarea_text(self, text): + """ + Set the textarea text. + + Args: + text: The text to set + """ + if self.textarea is None: + raise RuntimeError("No textarea created. Call create_keyboard_scene() first.") + self.textarea.set_text(text) + self.wait_for_render() + + def clear_textarea(self): + """Clear the textarea.""" + self.set_textarea_text("") + + def type_text(self, text): + """ + Type a string by clicking each character on the keyboard. + + Args: + text: The text to type + + Returns: + bool: True if all characters were typed successfully + """ + for char in text: + if not self.click_keyboard_button(char): + return False + return True + + def assertTextareaText(self, expected, msg=None): + """ + Assert that the textarea contains the expected text. + + Args: + expected: Expected text + msg: Optional failure message + """ + actual = self.get_textarea_text() + if msg is None: + msg = f"Textarea text mismatch. Expected '{expected}', got '{actual}'" + self.assertEqual(actual, expected, msg) + + def assertTextareaEmpty(self, msg=None): + """ + Assert that the textarea is empty. + + Args: + msg: Optional failure message + """ + if msg is None: + msg = f"Textarea should be empty, but contains '{self.get_textarea_text()}'" + self.assertEqual(self.get_textarea_text(), "", msg) + + def assertTextareaContains(self, substring, msg=None): + """ + Assert that the textarea contains a substring. + + Args: + substring: Substring to search for + msg: Optional failure message + """ + actual = self.get_textarea_text() + if msg is None: + msg = f"Textarea should contain '{substring}', but has '{actual}'" + self.assertIn(substring, actual, msg) + + def get_keyboard_button_text(self, index): + """ + Get the text of a keyboard button by index. + + Args: + index: Button index + + Returns: + str: Button text, or None if not found + """ + if self.keyboard is None: + raise RuntimeError("No keyboard created. Call create_keyboard_scene() first.") + + try: + return self.keyboard.get_button_text(index) + except: + return None + + def find_keyboard_button_index(self, button_text): + """ + Find the index of a keyboard button by its text. + + Args: + button_text: Text to search for + + Returns: + int: Button index, or None if not found + """ + for i in range(100): # Check first 100 indices + text = self.get_keyboard_button_text(i) + if text is None: + break + if text == button_text: + return i + return None + + def get_all_keyboard_buttons(self): + """ + Get all keyboard buttons as a list of (index, text) tuples. + + Returns: + list: List of (index, text) tuples + """ + buttons = [] + for i in range(100): + text = self.get_keyboard_button_text(i) + if text is None: + break + if text: # Skip empty strings + buttons.append((i, text)) + return buttons diff --git a/tests/test_graphical_keyboard_q_button_bug.py b/tests/test_graphical_keyboard_q_button_bug.py index 851fabe1..f9de244f 100644 --- a/tests/test_graphical_keyboard_q_button_bug.py +++ b/tests/test_graphical_keyboard_q_button_bug.py @@ -14,33 +14,12 @@ Usage: """ import unittest -import lvgl as lv -from mpos.ui.keyboard import MposKeyboard -from mpos.ui.testing import ( - wait_for_render, - find_button_with_text, - get_widget_coords, - get_keyboard_button_coords, - simulate_click, - print_screen_labels -) +from base import KeyboardTestBase -class TestKeyboardQButton(unittest.TestCase): +class TestKeyboardQButton(KeyboardTestBase): """Test keyboard button functionality (especially 'q' which was at index 0).""" - def setUp(self): - """Set up test fixtures.""" - self.screen = lv.obj() - self.screen.set_size(320, 240) - lv.screen_load(self.screen) - wait_for_render(5) - - def tearDown(self): - """Clean up.""" - lv.screen_load(lv.obj()) - wait_for_render(5) - def test_q_button_works(self): """ Test that clicking the 'q' button adds 'q' to textarea. @@ -51,82 +30,50 @@ class TestKeyboardQButton(unittest.TestCase): Steps: 1. Create textarea and keyboard - 2. Find 'q' button index in keyboard map - 3. Get button coordinates from keyboard widget - 4. Click it using simulate_click() - 5. Verify 'q' appears in textarea (should PASS after fix) - 6. Repeat with 'a' button - 7. Verify 'a' appears correctly (should PASS) + 2. Click 'q' button using click_keyboard_button helper + 3. Verify 'q' appears in textarea (should PASS after fix) + 4. Repeat with 'a' button + 5. Verify 'a' appears correctly (should PASS) """ print("\n=== Testing keyboard 'q' and 'a' button behavior ===") - # Create textarea - textarea = lv.textarea(self.screen) - textarea.set_size(200, 30) - textarea.set_one_line(True) - textarea.align(lv.ALIGN.TOP_MID, 0, 10) - textarea.set_text("") # Start empty - wait_for_render(5) + # Create keyboard scene (textarea + keyboard) + self.create_keyboard_scene() - # Create keyboard and connect to textarea - keyboard = MposKeyboard(self.screen) - keyboard.set_textarea(textarea) - keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) - wait_for_render(10) - - print(f"Initial textarea: '{textarea.get_text()}'") - self.assertEqual(textarea.get_text(), "", "Textarea should start empty") + print(f"Initial textarea: '{self.get_textarea_text()}'") + self.assertTextareaEmpty("Textarea should start empty") # --- Test 'q' button --- print("\n--- Testing 'q' button ---") - # Get exact button coordinates using helper function - q_coords = get_keyboard_button_coords(keyboard, "q") - self.assertIsNotNone(q_coords, "Should find 'q' button on keyboard") - - print(f"Found 'q' button at index {q_coords['button_idx']}, row {q_coords['row']}, col {q_coords['col']}") - print(f"Exact 'q' button position: ({q_coords['center_x']}, {q_coords['center_y']})") - - # Click the 'q' button - print(f"Clicking 'q' button at ({q_coords['center_x']}, {q_coords['center_y']})") - simulate_click(q_coords['center_x'], q_coords['center_y']) - wait_for_render(20) # increased from 10 to 20 because on macOS this didnt work + # Click the 'q' button using the reliable click_keyboard_button helper + success = self.click_keyboard_button("q") + self.assertTrue(success, "Should find and click 'q' button on keyboard") # Check textarea content - text_after_q = textarea.get_text() + text_after_q = self.get_textarea_text() print(f"Textarea after clicking 'q': '{text_after_q}'") # Verify 'q' was added (should work after fix) - self.assertEqual(text_after_q, "q", - "Clicking 'q' button should add 'q' to textarea") + self.assertTextareaText("q", "Clicking 'q' button should add 'q' to textarea") # --- Test 'a' button for comparison --- print("\n--- Testing 'a' button (for comparison) ---") # Clear textarea - textarea.set_text("") - wait_for_render(5) + self.clear_textarea() print("Cleared textarea") - # Get exact button coordinates using helper function - a_coords = get_keyboard_button_coords(keyboard, "a") - self.assertIsNotNone(a_coords, "Should find 'a' button on keyboard") - - print(f"Found 'a' button at index {a_coords['button_idx']}, row {a_coords['row']}, col {a_coords['col']}") - print(f"Exact 'a' button position: ({a_coords['center_x']}, {a_coords['center_y']})") - - # Click the 'a' button - print(f"Clicking 'a' button at ({a_coords['center_x']}, {a_coords['center_y']})") - simulate_click(a_coords['center_x'], a_coords['center_y']) - wait_for_render(10) + # Click the 'a' button using the reliable click_keyboard_button helper + success = self.click_keyboard_button("a") + self.assertTrue(success, "Should find and click 'a' button on keyboard") # Check textarea content - text_after_a = textarea.get_text() + text_after_a = self.get_textarea_text() print(f"Textarea after clicking 'a': '{text_after_a}'") # The 'a' button should work correctly - self.assertEqual(text_after_a, "a", - "Clicking 'a' button should add 'a' to textarea") + self.assertTextareaText("a", "Clicking 'a' button should add 'a' to textarea") print("\nSummary:") print(f" 'q' button result: '{text_after_q}' (expected 'q') ✓") @@ -142,26 +89,16 @@ class TestKeyboardQButton(unittest.TestCase): """ print("\n=== Discovering keyboard buttons ===") - # Create keyboard without textarea to inspect it - keyboard = MposKeyboard(self.screen) - keyboard.align(lv.ALIGN.BOTTOM_MID, 0, 0) - wait_for_render(10) + # Create keyboard scene + self.create_keyboard_scene() - # Iterate through button indices to find all buttons + # Get all buttons using the base class helper + found_buttons = self.get_all_keyboard_buttons() + + # Print first 20 buttons print("\nEnumerating keyboard buttons by index:") - found_buttons = [] - - for i in range(100): # Check first 100 indices - try: - text = keyboard.get_button_text(i) - if text: # Skip None/empty - found_buttons.append((i, text)) - # Only print first 20 to avoid clutter - if i < 20: - print(f" Button {i}: '{text}'") - except: - # No more buttons - break + for idx, text in found_buttons[:20]: + print(f" Button {idx}: '{text}'") if len(found_buttons) > 20: print(f" ... (showing first 20 of {len(found_buttons)} buttons)") @@ -173,16 +110,12 @@ class TestKeyboardQButton(unittest.TestCase): print("\nLooking for specific letters:") for letter in letters_to_test: - found = False - for idx, text in found_buttons: - if text == letter: - print(f" '{letter}' at index {idx}") - found = True - break - if not found: + idx = self.find_keyboard_button_index(letter) + if idx is not None: + print(f" '{letter}' at index {idx}") + else: print(f" '{letter}' NOT FOUND") # Verify we can find at least some buttons self.assertTrue(len(found_buttons) > 0, "Should find at least some buttons on keyboard") -