Skip to content

Commit ecaaad6

Browse files
Add graphical unit tests
1 parent eb2799f commit ecaaad6

File tree

8 files changed

+599
-2
lines changed

8 files changed

+599
-2
lines changed

CLAUDE.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,12 +186,36 @@ The `unittest.sh` script:
186186
- Sets up the proper paths and heapsize
187187
- Can run tests on device using `mpremote` with the `ondevice` argument
188188
- Runs all `test_*.py` files when no argument is provided
189+
- On device, assumes the OS is already running (boot.py and main.py already executed), so tests run against the live system
190+
- Test infrastructure (graphical_test_helper.py) is automatically installed by `scripts/install.sh`
189191

190192
**Available unit test modules**:
191193
- `test_shared_preferences.py`: Tests for `mpos.config.SharedPreferences` (configuration storage)
192194
- `test_intent.py`: Tests for `mpos.content.intent.Intent` (intent creation, extras, flags)
193195
- `test_package_manager.py`: Tests for `PackageManager` (version comparison, app discovery)
194196
- `test_start_app.py`: Tests for app launching (requires SDL display initialization)
197+
- `test_graphical_about_app.py`: Graphical test that verifies About app UI and captures screenshots
198+
199+
**Graphical tests** (UI verification with screenshots):
200+
```bash
201+
# Run graphical tests on desktop
202+
./tests/unittest.sh tests/test_graphical_about_app.py
203+
204+
# Run graphical tests on device
205+
./tests/unittest.sh tests/test_graphical_about_app.py ondevice
206+
207+
# Convert screenshots from raw RGB565 to PNG
208+
cd tests/screenshots
209+
./convert_to_png.sh # Converts all .raw files in the directory
210+
```
211+
212+
Graphical tests use `tests/graphical_test_helper.py` which provides utilities like:
213+
- `wait_for_render()`: Wait for LVGL to process UI events
214+
- `capture_screenshot()`: Take screenshot as RGB565 raw data
215+
- `find_label_with_text()`: Find labels containing specific text
216+
- `verify_text_present()`: Verify expected text is on screen
217+
218+
Screenshots are saved as `.raw` files (RGB565 format) and can be converted to PNG using `tests/screenshots/convert_to_png.sh`
195219

196220
**Manual tests** (interactive, for hardware-specific features):
197221
- `manual_test_camera.py`: Camera and QR scanning

scripts/install.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ $mpremote fs cp -r resources :/
7676

7777
popd
7878

79+
# Install test infrastructure (for running ondevice tests)
80+
echo "Installing test infrastructure..."
81+
$mpremote fs mkdir :/tests
82+
$mpremote fs mkdir :/tests/screenshots
83+
testdir=$(readlink -f "$mydir/../tests")
84+
$mpremote fs cp "$testdir/graphical_test_helper.py" :/tests/graphical_test_helper.py
85+
7986
if [ -z "$appname" ]; then
8087
echo "Not resetting so the installed app can be used immediately."
8188
$mpremote reset

tests/graphical_test_helper.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
"""
2+
Graphical testing helper module for MicroPythonOS.
3+
4+
This module provides utilities for graphical/visual testing that work on both
5+
desktop (unix/macOS) and device (ESP32).
6+
7+
Important: Tests using this module should be run with boot and main files
8+
already executed (so display, theme, and UI infrastructure are initialized).
9+
10+
Usage:
11+
from graphical_test_helper import wait_for_render, capture_screenshot
12+
13+
# Start your app
14+
mpos.apps.start_app("com.example.myapp")
15+
16+
# Wait for UI to render
17+
wait_for_render()
18+
19+
# Verify content
20+
assert verify_text_present(lv.screen_active(), "Expected Text")
21+
22+
# Capture screenshot
23+
capture_screenshot("tests/screenshots/mytest.raw")
24+
"""
25+
26+
import lvgl as lv
27+
28+
29+
def wait_for_render(iterations=10):
30+
"""
31+
Wait for LVGL to process UI events and render.
32+
33+
This processes the LVGL task handler multiple times to ensure
34+
all UI updates, animations, and layout changes are complete.
35+
36+
Args:
37+
iterations: Number of task handler iterations to run (default: 10)
38+
"""
39+
import time
40+
for _ in range(iterations):
41+
lv.task_handler()
42+
time.sleep(0.01) # Small delay between iterations
43+
44+
45+
def capture_screenshot(filepath, width=320, height=240, color_format=lv.COLOR_FORMAT.RGB565):
46+
"""
47+
Capture screenshot of current screen using LVGL snapshot.
48+
49+
The screenshot is saved as raw binary data in the specified color format.
50+
To convert RGB565 to PNG, use:
51+
ffmpeg -vcodec rawvideo -f rawvideo -pix_fmt rgb565 -s 320x240 -i file.raw file.png
52+
53+
Args:
54+
filepath: Path where to save the raw screenshot data
55+
width: Screen width in pixels (default: 320)
56+
height: Screen height in pixels (default: 240)
57+
color_format: LVGL color format (default: RGB565 for memory efficiency)
58+
59+
Returns:
60+
bytearray: The screenshot buffer
61+
62+
Raises:
63+
Exception: If screenshot capture fails
64+
"""
65+
# Calculate buffer size based on color format
66+
if color_format == lv.COLOR_FORMAT.RGB565:
67+
bytes_per_pixel = 2
68+
elif color_format == lv.COLOR_FORMAT.RGB888:
69+
bytes_per_pixel = 3
70+
else:
71+
bytes_per_pixel = 4 # ARGB8888
72+
73+
size = width * height * bytes_per_pixel
74+
buffer = bytearray(size)
75+
image_dsc = lv.image_dsc_t()
76+
77+
# Take snapshot of active screen
78+
lv.snapshot_take_to_buf(lv.screen_active(), color_format, image_dsc, buffer, size)
79+
80+
# Save to file
81+
with open(filepath, "wb") as f:
82+
f.write(buffer)
83+
84+
return buffer
85+
86+
87+
def get_all_labels(obj, labels=None):
88+
"""
89+
Recursively find all label widgets in the object hierarchy.
90+
91+
This traverses the entire widget tree starting from obj and
92+
collects all LVGL label objects.
93+
94+
Args:
95+
obj: LVGL object to search (typically lv.screen_active())
96+
labels: Internal accumulator list (leave as None)
97+
98+
Returns:
99+
list: List of all label objects found in the hierarchy
100+
"""
101+
if labels is None:
102+
labels = []
103+
104+
# Check if this object is a label
105+
try:
106+
if obj.get_class() == lv.label_class:
107+
labels.append(obj)
108+
except:
109+
pass # Not a label or no get_class method
110+
111+
# Recursively check children
112+
try:
113+
child_count = obj.get_child_count()
114+
for i in range(child_count):
115+
child = obj.get_child(i)
116+
get_all_labels(child, labels)
117+
except:
118+
pass # No children or error accessing them
119+
120+
return labels
121+
122+
123+
def find_label_with_text(obj, search_text):
124+
"""
125+
Find a label widget containing specific text.
126+
127+
Searches the entire widget hierarchy for a label whose text
128+
contains the search string (substring match).
129+
130+
Args:
131+
obj: LVGL object to search (typically lv.screen_active())
132+
search_text: Text to search for (can be substring)
133+
134+
Returns:
135+
LVGL label object if found, None otherwise
136+
"""
137+
labels = get_all_labels(obj)
138+
for label in labels:
139+
try:
140+
text = label.get_text()
141+
if search_text in text:
142+
return label
143+
except:
144+
pass # Error getting text from this label
145+
return None
146+
147+
148+
def get_screen_text_content(obj):
149+
"""
150+
Extract all text content from all labels on screen.
151+
152+
Useful for debugging or comprehensive text verification.
153+
154+
Args:
155+
obj: LVGL object to search (typically lv.screen_active())
156+
157+
Returns:
158+
list: List of all text strings found in labels
159+
"""
160+
labels = get_all_labels(obj)
161+
texts = []
162+
for label in labels:
163+
try:
164+
text = label.get_text()
165+
if text:
166+
texts.append(text)
167+
except:
168+
pass # Error getting text
169+
return texts
170+
171+
172+
def verify_text_present(obj, expected_text):
173+
"""
174+
Verify that expected text is present somewhere on screen.
175+
176+
This is the primary verification method for graphical tests.
177+
It searches all labels for the expected text.
178+
179+
Args:
180+
obj: LVGL object to search (typically lv.screen_active())
181+
expected_text: Text that should be present (can be substring)
182+
183+
Returns:
184+
bool: True if text found, False otherwise
185+
"""
186+
return find_label_with_text(obj, expected_text) is not None
187+
188+
189+
def print_screen_labels(obj):
190+
"""
191+
Debug helper: Print all label text found on screen.
192+
193+
Useful for debugging tests to see what text is actually present.
194+
195+
Args:
196+
obj: LVGL object to search (typically lv.screen_active())
197+
"""
198+
texts = get_screen_text_content(obj)
199+
print(f"Found {len(texts)} labels on screen:")
200+
for i, text in enumerate(texts):
201+
print(f" {i}: {text}")

tests/screenshots/.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Ignore all screenshot files
2+
*.raw
3+
4+
# Ignore converted PNG files (can be regenerated from .raw)
5+
*.png
6+
7+
# Allow this .gitignore and README.md
8+
!.gitignore
9+
!README.md

tests/screenshots/README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Test Screenshots
2+
3+
This directory contains screenshots captured during graphical tests.
4+
5+
## File Format
6+
7+
Screenshots are saved as raw binary data in RGB565 format:
8+
- 2 bytes per pixel
9+
- For 320x240 screen: 153,600 bytes per file
10+
- Filename format: `{test_name}_{hardware_id}.raw`
11+
12+
## Converting to PNG
13+
14+
### Quick Method (Recommended)
15+
16+
Use the provided convenience script to convert all screenshots:
17+
18+
```bash
19+
cd tests/screenshots
20+
./convert_to_png.sh
21+
```
22+
23+
For custom dimensions:
24+
```bash
25+
./convert_to_png.sh 296 240
26+
```
27+
28+
### Manual Conversion
29+
30+
To view individual screenshots, convert them to PNG using ffmpeg:
31+
32+
```bash
33+
# For 320x240 screenshots (default)
34+
ffmpeg -vcodec rawvideo -f rawvideo -pix_fmt rgb565 -s 320x240 -i screenshot.raw screenshot.png
35+
36+
# For other sizes (e.g., 296x240 for some hardware)
37+
ffmpeg -vcodec rawvideo -f rawvideo -pix_fmt rgb565 -s 296x240 -i screenshot.raw screenshot.png
38+
```
39+
40+
## Visual Regression Testing
41+
42+
Screenshots can be used for visual regression testing by:
43+
1. Capturing a "golden" reference screenshot
44+
2. Comparing new screenshots against the reference
45+
3. Detecting visual changes
46+
47+
For pixel-by-pixel comparison, you can use ImageMagick:
48+
49+
```bash
50+
# Convert both to PNG first
51+
ffmpeg -vcodec rawvideo -f rawvideo -pix_fmt rgb565 -s 320x240 -i reference.raw reference.png
52+
ffmpeg -vcodec rawvideo -f rawvideo -pix_fmt rgb565 -s 320x240 -i current.raw current.png
53+
54+
# Compare
55+
compare -metric AE reference.png current.png diff.png
56+
```
57+
58+
## .gitignore
59+
60+
Screenshot files (.raw and .png) are ignored by git to avoid bloating the repository.
61+
Reference/golden screenshots should be stored separately or documented clearly.

0 commit comments

Comments
 (0)