From 01461ccce49a3f7c633c3180f1c5797624c134dd Mon Sep 17 00:00:00 2001 From: kdmukai Date: Wed, 30 Aug 2023 23:22:48 -0500 Subject: [PATCH 1/6] Add SettingsQR update info * Move SettingsQR confirmation screen out of scan_screens.py --- src/seedsigner/gui/screens/scan_screens.py | 33 --------------- .../gui/screens/settings_screens.py | 41 +++++++++++++++++++ src/seedsigner/views/settings_views.py | 13 +++++- 3 files changed, 52 insertions(+), 35 deletions(-) diff --git a/src/seedsigner/gui/screens/scan_screens.py b/src/seedsigner/gui/screens/scan_screens.py index 26a53baef..13d3dcf78 100644 --- a/src/seedsigner/gui/screens/scan_screens.py +++ b/src/seedsigner/gui/screens/scan_screens.py @@ -174,36 +174,3 @@ def _run(self): self.camera.stop_video_stream_mode() break - - -@dataclass -class SettingsUpdatedScreen(ButtonListScreen): - config_name: str = None - title: str = "Settings QR" - is_bottom_list: bool = True - - def __post_init__(self): - # Customize defaults - self.button_data = ["Home"] - self.show_back_button = False - - super().__post_init__() - - start_y = self.top_nav.height + 20 - if self.config_name: - self.config_name_textarea = TextArea( - text=f'"{self.config_name}"', - is_text_centered=True, - auto_line_break=True, - screen_y=start_y - ) - self.components.append(self.config_name_textarea) - start_y = self.config_name_textarea.screen_y + 50 - - self.components.append(TextArea( - text="Settings imported successfully!", - is_text_centered=True, - auto_line_break=True, - screen_y=start_y - )) - diff --git a/src/seedsigner/gui/screens/settings_screens.py b/src/seedsigner/gui/screens/settings_screens.py index c09f1e276..548284ada 100644 --- a/src/seedsigner/gui/screens/settings_screens.py +++ b/src/seedsigner/gui/screens/settings_screens.py @@ -296,3 +296,44 @@ def __post_init__(self): supersampling_factor=1, screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING )) + + + +@dataclass +class SettingsQRConfirmationScreen(ButtonListScreen): + config_name: str = None + title: str = "Settings QR" + is_bottom_list: bool = True + + def __post_init__(self): + from seedsigner.hardware.microsd import MicroSD + from seedsigner.models.settings import Settings, SettingsConstants + + # Customize defaults + self.button_data = ["Home"] + self.show_back_button = False + super().__post_init__() + + settings = Settings.get_instance() + if MicroSD.get_instance().is_inserted and settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__ENABLED: + status_message = "Persistent Settings enabled. Settings saved to SD card." + else: + status_message = "Settings updated in temporary memory" + + start_y = self.top_nav.height + 20 + if self.config_name: + self.config_name_textarea = TextArea( + text=f'"{self.config_name}"', + is_text_centered=True, + auto_line_break=True, + screen_y=start_y + ) + self.components.append(self.config_name_textarea) + start_y = self.config_name_textarea.screen_y + 50 + + self.components.append(TextArea( + text=status_message, + is_text_centered=True, + auto_line_break=True, + screen_y=start_y + )) diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index ca9c36dcf..17a98726d 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -1,5 +1,6 @@ import logging from seedsigner.gui.components import SeedSignerIconConstants +from seedsigner.hardware.microsd import MicroSD from .view import View, Destination, MainMenuView @@ -195,13 +196,21 @@ def __init__(self, data: str): # May raise an Exception which will bubble up to the Controller to display to the # user. self.config_name, settings_update_dict = Settings.parse_settingsqr(data) + + persistent_settings = settings_update_dict.get(SettingsConstants.SETTING__PERSISTENT_SETTINGS) + if persistent_settings == SettingsConstants.OPTION__ENABLED and not MicroSD.get_instance().is_inserted: + # SettingsQR wants to enable persistent settings, but no MicroSD is inserted. + # For the sake of simplicity we just ignore that setting for now. + # TODO: Can consider a warning screen instead that gives the user some options. + del settings_update_dict[SettingsConstants.SETTING__PERSISTENT_SETTINGS] + self.settings.update(settings_update_dict) def run(self): - from seedsigner.gui.screens.scan_screens import SettingsUpdatedScreen + from seedsigner.gui.screens.settings_screens import SettingsQRConfirmationScreen self.run_screen( - SettingsUpdatedScreen, + SettingsQRConfirmationScreen, config_name=self.config_name ) From afcc042052b11e61a2c11ac0d2b89645037bbf9d Mon Sep 17 00:00:00 2001 From: kdmukai Date: Wed, 30 Aug 2023 23:24:09 -0500 Subject: [PATCH 2/6] SettingsQR tests; mocked `MicroSD.is_inserted` --- tests/base.py | 24 +++++++++++++-- tests/test_flows_settings.py | 58 ++++++++++++++++++++++++++++++++++-- tests/test_settings.py | 1 + 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/tests/base.py b/tests/base.py index fadf6ff7c..e6ac5621c 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,6 +1,6 @@ import sys from dataclasses import dataclass -from mock import MagicMock, patch +from mock import MagicMock, Mock, patch from typing import Callable # Prevent importing modules w/Raspi hardware dependencies. @@ -11,17 +11,27 @@ sys.modules['seedsigner.views.screensaver'] = MagicMock() sys.modules['seedsigner.hardware.buttons'] = MagicMock() sys.modules['seedsigner.hardware.camera'] = MagicMock() -sys.modules['seedsigner.hardware.microsd'] = MagicMock() from seedsigner.controller import Controller, FlowBasedTestException, StopFlowBasedTest from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON +from seedsigner.hardware.microsd import MicroSD from seedsigner.models.settings import Settings from seedsigner.views.view import Destination, MainMenuView, View + class BaseTest: + class MockMicroSD(Mock): + """ + A test suite-friendly replacement for `MicroSD` that gives a test explicit + control over the reported state of the SD card. + """ + # Tests are free to directly manipulate this attribute as needed + is_inserted: bool = True + + @classmethod def setup_class(cls): # Ensure there are no on-disk artifacts after running tests. @@ -30,6 +40,13 @@ def setup_class(cls): # Mock out the loading screen so it can't spawn. View classes must import locally! patch('seedsigner.gui.screens.screen.LoadingScreenThread').start() + # Instantiate the mocked MicroSD; hold on to the instance so tests can manipulate + # it later. + cls.mock_microsd = BaseTest.MockMicroSD() + + # And mock it over `MicroSD`'s instance + MicroSD.get_instance = Mock(return_value=cls.mock_microsd) + @classmethod def teardown_class(cls): @@ -62,11 +79,12 @@ def reset_controller(cls): def setup_method(self): - """ Guarantee a clean/default Controller and Settings state for each test case """ + """ Guarantee a clean/default Controller, Settings, & MicroSD state for each test case """ BaseTest.reset_controller() BaseTest.reset_settings() self.controller = Controller.get_instance() self.settings = Settings.get_instance() + self.mock_microsd.is_inserted = True def teardown_method(self): diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py index 1fb394a25..d9a625745 100644 --- a/tests/test_flows_settings.py +++ b/tests/test_flows_settings.py @@ -1,4 +1,5 @@ import os +from typing import Callable # Must import test base before the Controller from base import FlowTest, FlowStep @@ -6,13 +7,13 @@ from seedsigner.models.settings import Settings from seedsigner.models.settings_definition import SettingsDefinition, SettingsConstants from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON +from seedsigner.hardware.microsd import MicroSD from seedsigner.views.view import MainMenuView -from seedsigner.views import settings_views +from seedsigner.views import scan_views, settings_views class TestSettingsFlows(FlowTest): - def test_persistent_settings(self): """ Basic flow from MainMenuView to enable/disable persistent settings """ # Which option are we testing? @@ -67,3 +68,56 @@ def test_donate(self): FlowStep(settings_views.DonateView), FlowStep(settings_views.SettingsMenuView), ]) + + + def test_settingsqr(self): + """ + Scanning a SettingsQR should present the success screen and then return to + MainMenuView. + """ + def load_persistent_settingsqr_into_decoder(view: scan_views.ScanView): + settingsqr_data_persistent: str = "settings::v1 name=Total_noob_mode persistent=E coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E" + view.decoder.add_data(settingsqr_data_persistent) + + def load_not_persistent_settingsqr_into_decoder(view: scan_views.ScanView): + settingsqr_data_not_persistent: str = "settings::v1 name=Ephemeral_noob_mode persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E" + view.decoder.add_data(settingsqr_data_not_persistent) + + def _run_test(initial_setting_state: str, load_settingsqr_into_decoder: Callable, expected_setting_state: str): + self.settings.set_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS, initial_setting_state) + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), + FlowStep(scan_views.ScanView, before_run=load_settingsqr_into_decoder), # simulate read message QR; ret val is ignored + FlowStep(settings_views.SettingsIngestSettingsQRView), # ret val is ignored + FlowStep(MainMenuView), + ]) + + assert self.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == expected_setting_state + + + # First load a SettingsQR that enables persistent settings + self.mock_microsd.is_inserted = True + assert MicroSD.get_instance().is_inserted is True + + _run_test( + initial_setting_state=SettingsConstants.OPTION__DISABLED, + load_settingsqr_into_decoder=load_persistent_settingsqr_into_decoder, + expected_setting_state=SettingsConstants.OPTION__ENABLED + ) + + # Then one that disables it + _run_test( + initial_setting_state=SettingsConstants.OPTION__ENABLED, + load_settingsqr_into_decoder=load_not_persistent_settingsqr_into_decoder, + expected_setting_state=SettingsConstants.OPTION__DISABLED + ) + + # Now try to enable persistent settings when the SD card is not inserted + self.mock_microsd.is_inserted = False + assert MicroSD.get_instance().is_inserted is False + + _run_test( + initial_setting_state=SettingsConstants.OPTION__DISABLED, + load_settingsqr_into_decoder=load_persistent_settingsqr_into_decoder, + expected_setting_state=SettingsConstants.OPTION__DISABLED + ) diff --git a/tests/test_settings.py b/tests/test_settings.py index 760260866..94ac626aa 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -8,6 +8,7 @@ class TestSettings(BaseTest): @classmethod def setup_class(cls): + super().setup_class() cls.settings = Settings.get_instance() From 68bd13147eadc7d084cdfa5cb4358c41ef16b93d Mon Sep 17 00:00:00 2001 From: kdmukai Date: Wed, 30 Aug 2023 23:25:10 -0500 Subject: [PATCH 3/6] SettingsQR status update screens added to screenshot generator --- tests/screenshot_generator/generator.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index c50e719bc..83a581351 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -110,10 +110,11 @@ def test_generate_screenshots(target_locale): continue settings_views_list.append((settings_views.SettingsEntryUpdateSelectionView, dict(attr_name=settings_entry.attr_name), f"SettingsEntryUpdateSelectionView_{settings_entry.attr_name}")) - settings_views_list.append(settings_views.IOTestView) - settings_views_list.append(settings_views.DonateView) + settingsqr_data_persistent = "settings::v1 name=Total_noob_mode persistent=E coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E" + settingsqr_data_not_persistent = "settings::v1 name=Ephemeral_noob_mode persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E" + screenshot_sections = { "Main Menu Views": [ MainMenuView, @@ -123,7 +124,6 @@ def test_generate_screenshots(target_locale): PowerOptionsView, RestartView, PowerOffView, - (settings_views.SettingsIngestSettingsQRView, dict(data="settings::v1 name=Uncle_Jim's_noob_mode")), ], "Seed Views": [ seed_views.SeedsMenuView, @@ -215,7 +215,12 @@ def test_generate_screenshots(target_locale): tools_views.ToolsAddressExplorerAddressListView, #tools_views.ToolsAddressExplorerAddressView, ], - "Settings Views": settings_views_list, + "Settings Views": settings_views_list + [ + settings_views.IOTestView, + settings_views.DonateView, + (settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_persistent), "SettingsIngestSettingsQRView_persistent"), + (settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_not_persistent), "SettingsIngestSettingsQRView_not_persistent"), + ], "Misc Error Views": [ NotYetImplementedView, (UnhandledExceptionView, dict(error=UnhandledExceptionViewFood)), @@ -227,7 +232,7 @@ def test_generate_screenshots(target_locale): text="QRCode is invalid or is a data format not yet supported.", button_text="Back", )), - ], + ] } readme = f"""# SeedSigner Screenshots\n""" From a29e348b30dc9074f26ae0dd7aac429a7758c712 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Thu, 31 Aug 2023 06:55:11 -0500 Subject: [PATCH 4/6] Make Screen dumb again --- src/seedsigner/gui/screens/settings_screens.py | 12 ++---------- src/seedsigner/views/settings_views.py | 10 ++++++++-- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/seedsigner/gui/screens/settings_screens.py b/src/seedsigner/gui/screens/settings_screens.py index 548284ada..55fa5dd00 100644 --- a/src/seedsigner/gui/screens/settings_screens.py +++ b/src/seedsigner/gui/screens/settings_screens.py @@ -303,23 +303,15 @@ def __post_init__(self): class SettingsQRConfirmationScreen(ButtonListScreen): config_name: str = None title: str = "Settings QR" + status_message: str = "Settings updated..." is_bottom_list: bool = True def __post_init__(self): - from seedsigner.hardware.microsd import MicroSD - from seedsigner.models.settings import Settings, SettingsConstants - # Customize defaults self.button_data = ["Home"] self.show_back_button = False super().__post_init__() - settings = Settings.get_instance() - if MicroSD.get_instance().is_inserted and settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__ENABLED: - status_message = "Persistent Settings enabled. Settings saved to SD card." - else: - status_message = "Settings updated in temporary memory" - start_y = self.top_nav.height + 20 if self.config_name: self.config_name_textarea = TextArea( @@ -332,7 +324,7 @@ def __post_init__(self): start_y = self.config_name_textarea.screen_y + 50 self.components.append(TextArea( - text=status_message, + text=self.status_message, is_text_centered=True, auto_line_break=True, screen_y=start_y diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index 17a98726d..8eca039c2 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -205,13 +205,19 @@ def __init__(self, data: str): del settings_update_dict[SettingsConstants.SETTING__PERSISTENT_SETTINGS] self.settings.update(settings_update_dict) - + + if MicroSD.get_instance().is_inserted and self.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__ENABLED: + self.status_message = "Persistent Settings enabled. Settings saved to SD card." + else: + self.status_message = "Settings updated in temporary memory" + def run(self): from seedsigner.gui.screens.settings_screens import SettingsQRConfirmationScreen self.run_screen( SettingsQRConfirmationScreen, - config_name=self.config_name + config_name=self.config_name, + status_message=self.status_message, ) # Only one exit point From 892c3538a478efcf9bace5afd848aaa6119c3575 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Thu, 31 Aug 2023 06:58:10 -0500 Subject: [PATCH 5/6] More detailed comment --- tests/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/base.py b/tests/base.py index e6ac5621c..496e1b86c 100644 --- a/tests/base.py +++ b/tests/base.py @@ -28,7 +28,8 @@ class MockMicroSD(Mock): A test suite-friendly replacement for `MicroSD` that gives a test explicit control over the reported state of the SD card. """ - # Tests are free to directly manipulate this attribute as needed + # Tests are free to directly manipulate this attribute as needed (it's reset to + # True before each test in `BaseTest.setup_method()`). is_inserted: bool = True From d810d12d64be659feb14ba6c7427f847f92cb541 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Thu, 31 Aug 2023 18:44:40 -0500 Subject: [PATCH 6/6] bugfix; caught by @jdlcdl --- src/seedsigner/models/settings.py | 6 ++++++ src/seedsigner/views/settings_views.py | 7 ------- tests/test_flows_settings.py | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/seedsigner/models/settings.py b/src/seedsigner/models/settings.py index 7b5f46021..f0b1c97f0 100644 --- a/src/seedsigner/models/settings.py +++ b/src/seedsigner/models/settings.py @@ -87,6 +87,12 @@ def parse_settingsqr(cls, data: str) -> tuple[str, dict]: values = value for v in values: if v not in [opt[0] for opt in settings_entry.selection_options]: + if settings_entry.attr_name == SettingsConstants.SETTING__PERSISTENT_SETTINGS and v == SettingsConstants.OPTION__ENABLED: + # Special case: trying to enable Persistent Settings when + # DISABLED is the only option allowed (because the SD card is not + # inserted. Explicitly set to DISABLED. + value = SettingsConstants.OPTION__DISABLED + break raise InvalidSettingsQRData(f"""{abbreviated_name} = '{v}' is not valid""") updated_settings[settings_entry.attr_name] = value diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index 8eca039c2..e2a742ca7 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -196,13 +196,6 @@ def __init__(self, data: str): # May raise an Exception which will bubble up to the Controller to display to the # user. self.config_name, settings_update_dict = Settings.parse_settingsqr(data) - - persistent_settings = settings_update_dict.get(SettingsConstants.SETTING__PERSISTENT_SETTINGS) - if persistent_settings == SettingsConstants.OPTION__ENABLED and not MicroSD.get_instance().is_inserted: - # SettingsQR wants to enable persistent settings, but no MicroSD is inserted. - # For the sake of simplicity we just ignore that setting for now. - # TODO: Can consider a warning screen instead that gives the user some options. - del settings_update_dict[SettingsConstants.SETTING__PERSISTENT_SETTINGS] self.settings.update(settings_update_dict) diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py index d9a625745..a6eb34d93 100644 --- a/tests/test_flows_settings.py +++ b/tests/test_flows_settings.py @@ -1,6 +1,8 @@ import os from typing import Callable +from mock import PropertyMock, patch + # Must import test base before the Controller from base import FlowTest, FlowStep @@ -116,6 +118,19 @@ def _run_test(initial_setting_state: str, load_settingsqr_into_decoder: Callable self.mock_microsd.is_inserted = False assert MicroSD.get_instance().is_inserted is False + # Have to jump through some hoops to completely simulate the SD card being + # removed; we need Settings to restrict Persistent Settings to only allow + # DISABLED. + with patch('seedsigner.models.settings.Settings.HOSTNAME', new_callable=PropertyMock) as mock_hostname: + # Must identify itself as SeedSigner OS to trigger the SD card removal logic + mock_hostname.return_value = Settings.SEEDSIGNER_OS + Settings.handle_microsd_state_change(MicroSD.ACTION__REMOVED) + + selection_options = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS).selection_options + assert len(selection_options) == 1 + assert selection_options[0][0] == SettingsConstants.OPTION__DISABLED + assert self.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__DISABLED + _run_test( initial_setting_state=SettingsConstants.OPTION__DISABLED, load_settingsqr_into_decoder=load_persistent_settingsqr_into_decoder,