Skip to content

Commit d378098

Browse files
Improve instrumentation
1 parent 6173336 commit d378098

3 files changed

Lines changed: 322 additions & 14 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,4 @@ MPOS Controller (`scripts/mpos_controller.py`):
7878
- `mpos.screenshot()` captures via `capture_screenshot()` on device, then reads raw file and converts to BMP via `_build_bmp()`.
7979
- The notification bar (top status bar) is NOT always present. It's controlled by the `bar_open` global in `internal_filesystem/lib/mpos/ui/topmenu.py`. Check it with `mpos.eval("mpos.ui.topmenu.bar_open")` (the module is already imported by `main.py`). When open, its height is `AppearanceManager.NOTIFICATION_BAR_HEIGHT` (24px).
8080
- All tests pass covering exec, eval, screenshot, input simulation, screen introspection, file I/O, and physical device control.
81+
- Host-side controller tests in `tests/cpython_mpos_controller.py` run via `python3 tests/cpython_mpos_controller.py` (desktop) or `python3 tests/cpython_mpos_controller.py --serial /dev/ttyACM0` (device); they are NOT run by `unittest.sh` (which targets MicroPython-side tests).

internal_filesystem/lib/mpos/ui/testing.py

Lines changed: 77 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1421,6 +1421,19 @@ def get_screen_widget_tree(obj=None, depth=0):
14211421
return _dump_widget_tree(obj, depth)
14221422

14231423

1424+
ALL_FLAGS = (
1425+
"CLICKABLE", "CLICK_FOCUSABLE", "ADV_HITTEST", "PRESS_LOCK",
1426+
"SCROLLABLE", "SCROLL_CHAIN", "SCROLL_ELASTIC", "SCROLL_MOMENTUM",
1427+
"SCROLL_ONE", "SCROLL_WITH_ARROW", "SNAPPABLE", "FLOATING",
1428+
"EVENT_BUBBLE", "HIDDEN", "IGNORE_LAYOUT",
1429+
)
1430+
ALL_STATES = (
1431+
"CHECKED", "DISABLED", "FOCUSED", "FOCUS_KEY",
1432+
"EDITED", "PRESSED", "SCROLLED",
1433+
"USER_1", "USER_2", "USER_3", "USER_4", "USER_5", "USER_6",
1434+
)
1435+
1436+
14241437
def _dump_widget_tree(obj, depth):
14251438
"""Recursive helper that dumps a single object tree branch."""
14261439
info = {"depth": depth}
@@ -1455,27 +1468,77 @@ def _dump_widget_tree(obj, depth):
14551468
except Exception:
14561469
pass
14571470

1458-
# Flags
1471+
# All flags
1472+
flag_names = []
1473+
for n in ALL_FLAGS:
1474+
try:
1475+
fl = getattr(lv.obj.FLAG, n, None)
1476+
if fl is not None and obj.has_flag(fl):
1477+
flag_names.append(n.lower())
1478+
except Exception:
1479+
pass
1480+
if flag_names:
1481+
info["flags"] = flag_names
1482+
info["clickable"] = "clickable" in flag_names
1483+
info["hidden"] = "hidden" in flag_names
1484+
info["scrollable"] = "scrollable" in flag_names
1485+
info["floating"] = "floating" in flag_names
1486+
info["event_bubble"] = "event_bubble" in flag_names
1487+
1488+
# All states
1489+
state_names = []
1490+
for n in ALL_STATES:
1491+
try:
1492+
fl = getattr(lv.STATE, n, None)
1493+
if fl is not None and obj.has_state(fl):
1494+
state_names.append(n.lower())
1495+
except Exception:
1496+
pass
1497+
if state_names:
1498+
info["state"] = state_names
1499+
1500+
# Scroll position
14591501
try:
1460-
info["clickable"] = obj.has_flag(lv.obj.FLAG.CLICKABLE)
1502+
sx = obj.get_scroll_x()
1503+
sy = obj.get_scroll_y()
1504+
if sx or sy:
1505+
info["scroll_x"] = sx
1506+
info["scroll_y"] = sy
14611507
except Exception:
14621508
pass
1509+
1510+
# Opacity
14631511
try:
1464-
info["hidden"] = obj.has_flag(lv.obj.FLAG.HIDDEN)
1512+
opa = obj.get_style_opa(lv.PART.MAIN)
1513+
if opa != lv.OPA.COVER:
1514+
info["opa"] = opa
14651515
except Exception:
14661516
pass
14671517

1468-
# States
1469-
states = []
1470-
for name in ("CHECKED", "DISABLED", "FOCUSED", "PRESSED", "EDITED"):
1471-
try:
1472-
flag = getattr(lv.STATE, name, None)
1473-
if flag is not None and obj.has_state(flag):
1474-
states.append(name.lower())
1475-
except Exception:
1476-
pass
1477-
if states:
1478-
info["state"] = states
1518+
# Widget-specific fields
1519+
t = info.get("type", "")
1520+
try:
1521+
if t in ("slider", "arc", "bar", "meter"):
1522+
info["value"] = obj.get_value()
1523+
except Exception:
1524+
pass
1525+
try:
1526+
if t == "dropdown":
1527+
info["selected"] = obj.get_selected()
1528+
info["options"] = obj.get_options()
1529+
except Exception:
1530+
pass
1531+
try:
1532+
if t == "textarea":
1533+
info["one_line"] = obj.get_one_line()
1534+
info["cursor_pos"] = obj.get_cursor_pos()
1535+
except Exception:
1536+
pass
1537+
try:
1538+
if t == "buttonmatrix":
1539+
info["selected_btn"] = obj.get_selected_button()
1540+
except Exception:
1541+
pass
14791542

14801543
# Children
14811544
try:

tests/cpython_mpos_controller.py

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
#!/usr/bin/env python3
2+
"""
3+
CPython-side integration tests for MPOSController (both backends).
4+
5+
Tests exec, eval, multiline, state persistence, UI creation,
6+
screenshot capture, widget tree introspection, visible text
7+
extraction, button interaction, and multiple session cycles.
8+
9+
Usage:
10+
# Desktop (process) backend
11+
python3 tests/test_mpos_controller.py
12+
13+
# Serial device backend
14+
python3 tests/test_mpos_controller.py --serial /dev/ttyACM0
15+
16+
# Specific sections
17+
python3 tests/test_mpos_controller.py --only basic,ui,interaction
18+
"""
19+
20+
import sys
21+
import os
22+
import time
23+
import argparse
24+
25+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
26+
from scripts.mpos_controller import MPOSController
27+
28+
29+
PASS = 0
30+
FAIL = 0
31+
32+
33+
def section(name):
34+
print(f"\n{'='*60}")
35+
print(f" [{name}]")
36+
print(f"{'='*60}")
37+
38+
39+
def check(cond, msg):
40+
global PASS, FAIL
41+
if cond:
42+
print(f" ✓ {msg}")
43+
PASS += 1
44+
else:
45+
print(f" ✗ {msg}")
46+
FAIL += 1
47+
48+
49+
def find_buttons(tree, results=None):
50+
if results is None:
51+
results = []
52+
for entry in tree:
53+
if entry.get("type") == "button" and not entry.get("hidden"):
54+
results.append(entry)
55+
if "children" in entry:
56+
find_buttons(entry["children"], results)
57+
return results
58+
59+
60+
def run_tests(mpos, only=None):
61+
sections = {
62+
"basic": test_basic,
63+
"ui": test_ui_introspection,
64+
"interaction": test_interaction,
65+
"sessions": test_multiple_sessions,
66+
}
67+
if only:
68+
names = [s.strip() for s in only.split(",")]
69+
for n in names:
70+
if n in sections:
71+
sections[n](mpos)
72+
else:
73+
for name, fn in sections.items():
74+
fn(mpos)
75+
76+
77+
def test_basic(mpos):
78+
section("Basic exec / eval / multiline")
79+
80+
out = mpos.exec("print('hello from mpos')")
81+
check(b"hello from mpos" in out, f"exec prints output: {out!r}")
82+
83+
val = mpos.eval("1 + 1")
84+
check(val == 2, f"eval 1+1 == {val}")
85+
86+
val = mpos.eval("'foo' + 'bar'")
87+
check(val == "foobar", f"eval str concat == {val!r}")
88+
89+
out = mpos.exec_multiline("""
90+
for i in range(3):
91+
print(i)
92+
""")
93+
check(b"0" in out and b"2" in out, f"multiline loop: {out!r}")
94+
95+
mpos.exec("x = 42")
96+
val = mpos.eval("x")
97+
check(val == 42, f"state persists across execs: x == {val}")
98+
99+
for i in range(10):
100+
out = mpos.exec(f"print({i})")
101+
check(str(i).encode() in out, f"sequential exec {i}")
102+
if any(str(j).encode() not in out for j in range(i, i+1)):
103+
break
104+
105+
for i in range(5):
106+
mpos.exec(f"x = {i * 10}")
107+
val = mpos.eval("x")
108+
check(val == i * 10, f"interleaved exec/eval {i}: x == {val}")
109+
110+
111+
def test_ui_introspection(mpos):
112+
section("UI creation / screenshot / widget tree / visible text")
113+
114+
mpos.exec("""
115+
import lvgl as lv
116+
scr = lv.obj()
117+
lv.screen_load(scr)
118+
scr.set_style_bg_color(lv.color_hex(0xFFFFFF), 0)
119+
btn = lv.button(scr)
120+
btn.set_size(120, 50)
121+
btn.align(lv.ALIGN.CENTER, 0, 0)
122+
lv.label(btn).set_text("Click Me")
123+
title = lv.label(scr)
124+
title.set_text("Test UI")
125+
title.align(lv.ALIGN.TOP_MID, 0, 10)
126+
""")
127+
time.sleep(0.3)
128+
129+
texts = mpos.get_visible_text()
130+
check("Test UI" in texts, f"visible text has 'Test UI': {texts}")
131+
check("Click Me" in texts, f"visible text has 'Click Me': {texts}")
132+
133+
tree = mpos.get_widget_tree()
134+
btns = find_buttons(tree)
135+
check(len(btns) >= 1, f"found {len(btns)} visible buttons")
136+
if btns:
137+
b = btns[0]
138+
check(b.get("clickable"), f"button is clickable")
139+
check("flags" in b, f"button has flags field: {b.get('flags')}")
140+
check("center_x" in b and "center_y" in b, f"button has coords: ({b.get('center_x')}, {b.get('center_y')})")
141+
142+
bmp = mpos.screenshot()
143+
check(bmp[:2] == b"BM", f"screenshot has BMP header: {bmp[:2]!r}")
144+
check(len(bmp) > 1000, f"screenshot size: {len(bmp)} bytes")
145+
146+
check(mpos.find_text("Test UI"), "find_text finds 'Test UI'")
147+
check(not mpos.find_text("NonexistentXYZ12345"), "find_text rejects nonexistent")
148+
149+
150+
def test_interaction(mpos):
151+
section("Button interaction (press_key / press)")
152+
153+
mpos.exec("""
154+
import lvgl as lv
155+
scr = lv.obj()
156+
lv.screen_load(scr)
157+
scr.set_style_bg_color(lv.color_hex(0xFFFFFF), 0)
158+
result = lv.label(scr)
159+
result.set_text("not clicked")
160+
result.align(lv.ALIGN.TOP_MID, 0, 30)
161+
btn = lv.button(scr)
162+
btn.set_size(120, 50)
163+
btn.align(lv.ALIGN.CENTER, 0, 0)
164+
lv.label(btn).set_text("Press")
165+
def cb(e):
166+
result.set_text("clicked!")
167+
btn.add_event_cb(cb, lv.EVENT.CLICKED, None)
168+
""")
169+
time.sleep(0.3)
170+
171+
mpos.press_key("Press")
172+
time.sleep(0.3)
173+
texts = mpos.get_visible_text()
174+
check("clicked!" in texts, f"press_key triggers callback: {texts}")
175+
176+
mpos.exec("result.set_text('nope')")
177+
time.sleep(0.1)
178+
tree = mpos.get_widget_tree()
179+
btns = find_buttons(tree)
180+
if btns:
181+
b = btns[0]
182+
cx, cy = b["center_x"], b["center_y"]
183+
check(0 <= cy < 240, f"button y={cy} in screen bounds")
184+
mpos.press(cx, cy)
185+
time.sleep(0.3)
186+
texts = mpos.get_visible_text()
187+
if "clicked!" in texts:
188+
check(True, "press() triggers callback")
189+
else:
190+
# Fallback: send_event directly
191+
mpos.exec("""
192+
import lvgl as lv
193+
scr = lv.screen_active()
194+
btn = scr.get_child(1)
195+
btn.send_event(lv.EVENT.CLICKED, None)
196+
""")
197+
time.sleep(0.2)
198+
texts = mpos.get_visible_text()
199+
check("clicked!" in texts, f"send_event fallback: {texts}")
200+
201+
202+
def test_multiple_sessions(mpos):
203+
section("Multiple sessions")
204+
for i in range(3):
205+
with MPOSController() as m:
206+
out = m.exec("print('session ' + str(42))")
207+
check(b"session 42" in out, f"session {i+1}")
208+
check(True, "all 3 sessions OK")
209+
210+
211+
def main():
212+
parser = argparse.ArgumentParser(description="Test MPOSController backends")
213+
parser.add_argument("--serial", help="Serial port for device backend")
214+
parser.add_argument("--only", help="Comma-separated test sections: basic,ui,interaction,sessions")
215+
parser.add_argument("--binary", help="Path to lvgl_micropy_unix binary")
216+
args = parser.parse_args()
217+
218+
global PASS, FAIL
219+
220+
if args.serial:
221+
print(f"\n{'#'*60}")
222+
print(f" Testing SERIAL backend (port: {args.serial})")
223+
print(f"{'#'*60}")
224+
ctrl = MPOSController(backend="serial", port=args.serial, baudrate=115200, reset=True)
225+
try:
226+
ctrl.start()
227+
run_tests(ctrl, only=args.only)
228+
finally:
229+
ctrl.stop()
230+
else:
231+
print(f"\n{'#'*60}")
232+
print(f" Testing DESKTOP (process) backend")
233+
print(f"{'#'*60}")
234+
with MPOSController(binary=args.binary) as mpos:
235+
run_tests(mpos, only=args.only)
236+
237+
print(f"\n{'='*60}")
238+
print(f" Results: {PASS} passed, {FAIL} failed")
239+
print(f"{'='*60}")
240+
return 1 if FAIL else 0
241+
242+
243+
if __name__ == "__main__":
244+
sys.exit(main())

0 commit comments

Comments
 (0)