Skip to content

Commit 3ea405b

Browse files
Richard Taylorclaude
authored andcommitted
SettingActivity: add radio invariant test + CHANGELOG entry
Addresses merge-checklist misses on #125: - Note the behaviour change in the Future-release section of CHANGELOG.md under Frameworks:. - Add a graphical test pinning the three relevant transitions: * click on the active option re-checks it (new invariant) * click on a different option still switches selection * first-ever click from the no-selection state still works Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 1ac082f commit 3ea405b

2 files changed

Lines changed: 131 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Frameworks:
1111
- Add mpos.ui.change_task_handler() for improving IR timing accuracy
1212
- AppearanceManager: fix set_light_mode() and set_primary_color()
1313
- SharedPreferences: don't print values on serial/REPL
14+
- SettingActivity: clicking the already-selected option of a radio group no longer un-selects it. Previously a stray tap could save an empty string for a required radio setting (e.g. clearing `wallet_type` in a downstream app), leaving the app in an unconfigured state. Radios now enforce the convention that exactly one option stays selected once a choice has been made; the user changes the pick by clicking a different option
1415
- WebServer: add basic "View Screen" functionality to view the device's display remotely
1516

1617
OS:
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""
2+
Graphical test for SettingActivity radio-button invariant.
3+
4+
Clicking the already-selected radio option previously let the user
5+
un-select it and save an empty string for the setting. This broke the
6+
radio-group convention (exactly one option selected once the user has
7+
made a choice) and let apps with required radio settings — Lightning
8+
Piggy's wallet_type being the motivating case — silently lose their
9+
configuration on a stray tap.
10+
11+
The fix: when the user clicks the currently-active option, re-add the
12+
CHECKED state so the selection sticks. Changing the pick by clicking a
13+
different option works exactly as before.
14+
15+
Usage:
16+
Desktop: ./tests/unittest.sh tests/test_graphical_setting_activity_radio.py
17+
Device: ./tests/unittest.sh tests/test_graphical_setting_activity_radio.py --ondevice
18+
"""
19+
20+
import unittest
21+
import lvgl as lv
22+
23+
from mpos.ui.setting_activity import SettingActivity
24+
from mpos import wait_for_render
25+
26+
27+
class _FakeEvent:
28+
"""Minimal stand-in for an LVGL event — SettingActivity.radio_event_handler
29+
only calls event.get_target_obj()."""
30+
def __init__(self, target):
31+
self._target = target
32+
def get_target_obj(self):
33+
return self._target
34+
35+
36+
class _RadioHandlerFixture:
37+
"""Holds the same attributes SettingActivity.radio_event_handler reads on
38+
`self`. Avoids instantiating the full Activity (which needs a running
39+
AppManager) just to exercise one callback."""
40+
def __init__(self, container, active_index):
41+
self.radio_container = container
42+
self.active_radio_index = active_index
43+
44+
45+
class TestSettingActivityRadioInvariant(unittest.TestCase):
46+
"""Verify the radio-group invariant: exactly one option stays selected
47+
after the user has made a pick."""
48+
49+
def setUp(self):
50+
self.screen = lv.obj()
51+
self.screen.set_size(320, 240)
52+
lv.screen_load(self.screen)
53+
54+
self.container = lv.obj(self.screen)
55+
self.container.set_flex_flow(lv.FLEX_FLOW.COLUMN)
56+
57+
self.cb0 = lv.checkbox(self.container)
58+
self.cb0.set_text("Option 0")
59+
self.cb1 = lv.checkbox(self.container)
60+
self.cb1.set_text("Option 1")
61+
62+
wait_for_render(2)
63+
64+
def tearDown(self):
65+
lv.screen_load(lv.obj())
66+
wait_for_render(2)
67+
68+
def _invoke(self, target, active_index):
69+
"""Run radio_event_handler as it would run in production — we bind the
70+
unbound function to a fixture that exposes the attributes the handler
71+
reads on `self`."""
72+
fixture = _RadioHandlerFixture(self.container, active_index)
73+
SettingActivity.radio_event_handler(fixture, _FakeEvent(target))
74+
return fixture
75+
76+
def test_click_active_option_keeps_it_selected(self):
77+
"""Clicking the already-selected option must NOT un-select it.
78+
79+
Before the fix: fixture.active_radio_index flips to -1 and the
80+
checkbox stays un-checked, so save_setting would persist "".
81+
After the fix: the checkbox is re-checked and active_radio_index
82+
stays pointing at the same option.
83+
"""
84+
self.cb0.add_state(lv.STATE.CHECKED)
85+
# Simulate LVGL's "checkbox toggle on click" behaviour: STATE.CHECKED
86+
# has just been removed and VALUE_CHANGED is about to fire.
87+
self.cb0.remove_state(lv.STATE.CHECKED)
88+
self.assertFalse(bool(self.cb0.get_state() & lv.STATE.CHECKED))
89+
90+
fixture = self._invoke(self.cb0, active_index=0)
91+
92+
self.assertTrue(
93+
bool(self.cb0.get_state() & lv.STATE.CHECKED),
94+
"active option must be re-checked when user tries to un-select it",
95+
)
96+
self.assertEqual(
97+
fixture.active_radio_index, 0,
98+
"active_radio_index must stay pointing at the active option",
99+
)
100+
101+
def test_click_different_option_switches_selection(self):
102+
"""Clicking a *different* option still switches normally: the new one
103+
becomes checked, the old one loses its checked state, and
104+
active_radio_index moves."""
105+
self.cb0.add_state(lv.STATE.CHECKED)
106+
# LVGL toggled cb1 on, firing VALUE_CHANGED with cb1 as the target.
107+
self.cb1.add_state(lv.STATE.CHECKED)
108+
109+
fixture = self._invoke(self.cb1, active_index=0)
110+
111+
self.assertFalse(
112+
bool(self.cb0.get_state() & lv.STATE.CHECKED),
113+
"previously-active option must lose its CHECKED state",
114+
)
115+
self.assertTrue(
116+
bool(self.cb1.get_state() & lv.STATE.CHECKED),
117+
"newly-clicked option must stay CHECKED",
118+
)
119+
self.assertEqual(fixture.active_radio_index, 1)
120+
121+
def test_first_selection_from_empty_state(self):
122+
"""Before any choice is made, active_radio_index = -1. Clicking any
123+
option selects it normally."""
124+
# Nothing checked yet; user clicks cb1.
125+
self.cb1.add_state(lv.STATE.CHECKED)
126+
127+
fixture = self._invoke(self.cb1, active_index=-1)
128+
129+
self.assertTrue(bool(self.cb1.get_state() & lv.STATE.CHECKED))
130+
self.assertEqual(fixture.active_radio_index, 1)

0 commit comments

Comments
 (0)