|
| 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