Skip to content

Commit d557aff

Browse files
authored
Merge pull request #850 from kdmukai/multiselect_settings_selection_required
[Bugfix] Multiselect settings must have a selection
2 parents 01cb2cc + a8e6814 commit d557aff

5 files changed

Lines changed: 145 additions & 7 deletions

File tree

src/seedsigner/models/settings.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ def parse_settingsqr(cls, data: str) -> tuple[str, dict]:
8181
for entry in data.split()[split_index:]:
8282
abbreviated_name, value = entry.split("=")
8383

84+
# Empty values ("some_setting= other_setting=E") are invalid
85+
if value == "":
86+
raise InvalidSettingsQRData(f"{abbreviated_name} cannot be empty")
87+
8488
# Parse multi-value settings; integer-ize where needed
8589
if "," in value:
8690
values_updated = []
@@ -103,6 +107,7 @@ def parse_settingsqr(cls, data: str) -> tuple[str, dict]:
103107
values = [value]
104108
else:
105109
values = value
110+
106111
for v in values:
107112
if v not in [opt[0] for opt in settings_entry.selection_options]:
108113
if settings_entry.attr_name == SettingsConstants.SETTING__PERSISTENT_SETTINGS and v == SettingsConstants.OPTION__ENABLED:
@@ -154,8 +159,13 @@ def update(self, new_settings: dict):
154159
# Clean the incoming data, if necessary
155160
if entry.type == SettingsConstants.TYPE__MULTISELECT:
156161
if type(new_settings[entry.attr_name]) == str:
157-
# Break comma-separated SettingsQR input into List
158-
new_settings[entry.attr_name] = new_settings[entry.attr_name].split(",")
162+
# Break comma-separated multiselect options into List; avoid empty
163+
# values.
164+
new_settings[entry.attr_name] = [value for value in new_settings[entry.attr_name].split(",") if value.strip()]
165+
166+
if not new_settings[entry.attr_name]:
167+
# Multiselect cannot be empty; load defaults to avoid issues
168+
new_settings[entry.attr_name] = entry.default_value
159169

160170
for key, value in new_settings.items():
161171
self.set_value(key, value)

src/seedsigner/views/settings_views.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,13 @@ def run(self):
204204
)
205205

206206
if ret_value == RET_CODE__BACK_BUTTON:
207+
if self.settings_entry.type == SettingsConstants.TYPE__MULTISELECT:
208+
# After the user finishes toggling multiselect options, initial_value will
209+
# have their final selections when they hit BACK to exit. All current
210+
# multiselect settings require at least one option to be selected.
211+
if not initial_value:
212+
return Destination(SettingsSelectionRequiredWarningView, view_args={"attr_name": self.settings_entry.attr_name})
213+
207214
if self.blocking_view:
208215
return Destination(self.blocking_view, clear_history=True)
209216
return settings_menu_view_destination
@@ -261,6 +268,37 @@ def run(self):
261268

262269

263270

271+
class SettingsSelectionRequiredWarningView(View):
272+
def __init__(self, attr_name: str):
273+
super().__init__()
274+
self.settings_entry = SettingsDefinition.get_settings_entry(attr_name)
275+
276+
277+
def run(self):
278+
from seedsigner.gui.screens.screen import WarningScreen
279+
280+
# TRANSLATOR_NOTE: Title of a warning dialog when configuring a setting that requires at least one option to be selected.
281+
title = _("Selection Required")
282+
283+
# TRANSLATOR_NOTE: The name of the setting being configured (e.g. "Script types") will be inserted.
284+
text = _("At least one option must be selected for \"{}\".").format(self.settings_entry.display_name)
285+
286+
# TRANSLATOR_NOTE: Text for the button that returns the user to the setting configuration screen.
287+
button_text = _("Return to setting")
288+
289+
self.run_screen(
290+
WarningScreen,
291+
title=title,
292+
status_headline=None,
293+
text=text,
294+
button_data=[ButtonOption(button_text)],
295+
show_back_button=False,
296+
)
297+
298+
return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=self.settings_entry.attr_name))
299+
300+
301+
264302
class SettingsIngestSettingsQRView(View):
265303
def __init__(self, data: str):
266304
from seedsigner.hardware.microsd import MicroSD

tests/screenshot_generator/generator.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,7 @@ def PSBTOpReturnView_raw_hex_data_cb_before():
427427
ScreenshotConfig(settings_views.DonateView),
428428
ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_persistent), screenshot_name="SettingsIngestSettingsQRView_persistent"),
429429
ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_not_persistent), screenshot_name="SettingsIngestSettingsQRView_not_persistent"),
430+
ScreenshotConfig(settings_views.SettingsSelectionRequiredWarningView, dict(attr_name=SettingsConstants.SETTING__SCRIPT_TYPES)),
430431
],
431432
"Misc Error Views": [
432433
ScreenshotConfig(NotYetImplementedView),

tests/test_flows_settings.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,35 @@ def test_persistent_settings(self):
3737

3838

3939
def test_multiselect(self):
40-
""" Multiselect Settings options should stay in-place; requires BACK to exit. """
40+
"""
41+
Multiselect Settings options should stay in-place; requires BACK to exit. If no
42+
selections are made, route to the warning screen and return the user to the
43+
settings entry until at least one option is selected.
44+
"""
4145
# Which option are we testing?
42-
settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__XPUB_QR_FORMAT)
46+
settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SIG_TYPES)
47+
48+
# Enable all options to start
49+
self.settings.set_value(settings_entry.attr_name, [option[0] for option in settings_entry.selection_options])
50+
51+
# Sanity check, we only expect two options for this setting
52+
assert len(settings_entry.selection_options) == 2
4353

4454
self.run_sequence([
4555
FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS),
4656
FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_views.SettingsMenuView.ADVANCED),
4757
FlowStep(settings_views.SettingsMenuView, button_data_selection=ButtonOption(settings_entry.display_name)),
48-
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=0), # select/deselect first option
49-
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select/deselect second option
50-
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select/deselect second option
58+
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=0), # deselect first option
59+
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # deselect second option
60+
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select second option
61+
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # deselect second option
62+
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON), # BACK to exit
63+
64+
# Both options were deselected, should route to the warning screen
65+
FlowStep(settings_views.SettingsSelectionRequiredWarningView),
66+
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=0), # select first option
67+
68+
# Now we can exit
5169
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON), # BACK to exit
5270
FlowStep(settings_views.SettingsMenuView),
5371
])

tests/test_settings.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import pytest
23
from base import BaseTest
34
from seedsigner.models.settings import InvalidSettingsQRData, Settings
@@ -35,6 +36,68 @@ def test_settings_defaults(self):
3536
assert settings.get_value(settings_entry.attr_name) == settings_entry.default_value
3637

3738

39+
def test_load_persistent_settings(self):
40+
""" Settings should load previously saved persistent settings from disk, if any
41+
exist. """
42+
# Initial Settings will start with defaults
43+
settings = Settings.get_instance()
44+
45+
# Enable persistent settings and make another change
46+
settings.set_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS, SettingsConstants.OPTION__ENABLED)
47+
48+
assert settings.get_value(SettingsConstants.SETTING__QR_DENSITY) != SettingsConstants.DENSITY__HIGH
49+
settings.set_value(SettingsConstants.SETTING__QR_DENSITY, SettingsConstants.DENSITY__HIGH)
50+
51+
# Hold on to the settings.json content
52+
settings_json = None
53+
with open(Settings.SETTINGS_FILENAME) as settings_file:
54+
settings_json = json.loads(settings_file.read())
55+
56+
# Now wipe out the Settings singleton
57+
BaseTest.reset_settings()
58+
59+
# This also deletes settings.json, so recreate it
60+
with open(Settings.SETTINGS_FILENAME, "w") as settings_file:
61+
settings_file.write(json.dumps(settings_json))
62+
63+
# Now instantiate the Settings singleton again; it should load from disk
64+
settings = Settings.get_instance()
65+
66+
# Persistent setting change should have survived
67+
assert settings.get_value(SettingsConstants.SETTING__QR_DENSITY) == SettingsConstants.DENSITY__HIGH
68+
69+
70+
def test_load_empty_multiselect_settings(self):
71+
""" Empty multiselect settings should load defaults. """
72+
# Initial Settings will start with defaults
73+
settings = Settings.get_instance()
74+
75+
# Enable persistent settings to write settings.json to disk
76+
settings.set_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS, SettingsConstants.OPTION__ENABLED)
77+
78+
# Hold on to the settings.json content
79+
settings_dict = None
80+
with open(Settings.SETTINGS_FILENAME) as settings_file:
81+
settings_dict = json.loads(settings_file.read())
82+
83+
def _verify_defaults_loaded(attr_name: str):
84+
# Verify that the multiselect setting has loaded its defaults
85+
settings = Settings.get_instance()
86+
cur_setting_value = settings.get_value(attr_name)
87+
assert cur_setting_value == SettingsDefinition.get_settings_entry(attr_name).default_value
88+
89+
# Alter the settings to test against various empty values
90+
for empty_value in ["", ",", [], None]:
91+
settings_dict[SettingsConstants.SETTING__SIG_TYPES] = empty_value
92+
settings.update(settings_dict)
93+
_verify_defaults_loaded(SettingsConstants.SETTING__SIG_TYPES)
94+
95+
# One last test: remove the multiselect setting entirely
96+
del settings_dict[SettingsConstants.SETTING__SIG_TYPES]
97+
settings.update(settings_dict)
98+
_verify_defaults_loaded(SettingsConstants.SETTING__SIG_TYPES)
99+
100+
38101
def test_parse_settingsqr_data(self):
39102
"""
40103
SettingsQR parser should successfully parse a valid settingsqr input string and
@@ -108,6 +171,14 @@ def test_settingsqr_fails_unrecognized_option(self):
108171
assert "passphrase" in str(e.value)
109172

110173

174+
def test_settingsqr_fails_empty_values(self):
175+
""" SettingsQR parser should fail if a setting is empty """
176+
settingsqr_data = "settings::v1 persistent=D sigs= camera=180"
177+
with pytest.raises(InvalidSettingsQRData) as e:
178+
Settings.parse_settingsqr(settingsqr_data)
179+
assert "sigs" in str(e.value)
180+
181+
111182
def test_settingsqr_parses_line_break_separators(self):
112183
""" SettingsQR parser should read line breaks as acceptable separators """
113184
settingsqr_data = "settings::v1\nname=Foo\nsigs=ss,ms\nscripts=nat,nes,tr\npassphrase=E\n"

0 commit comments

Comments
 (0)